android: A52 — release limited frames on the vblank time grid, not on every Nth wakeup

Merged
UT
11d ago
+100-0

Rebased replacement for #21, which GitHub closed when its base branch (fix/gamescope-resolution-scanout, merged as #20) was deleted. One patch, 0050-A52-fps-limit-on-the-vblank-time-grid.patch, on top of A51. The latch-grid snap that #21 carried for a while is gone: the grid is the nested refresh and the target alone.

What this fixes

The non-VRR frame-rate limit counts compositor wakeups: a window gets its frame callback on every refresh / targetth wakeup (steamcompmgr_should_vblank_window, vblank_idx % nVblankDivisor). That equals every Nth vblank only while no wakeup is ever late. CVBlankTimer::GetNextVBlank steps to the next vblank after now, so a wakeup that lands more than one vblank late skips the vblanks in between, and vblank_idx never counts them. A session runs on the 1200 Hz grid (-r 1200), where a wakeup is late by a vblank after 0.83 ms of scheduling delay, so every late wakeup holds the next release a whole vblank longer and the cap lands below the target in proportion to the wakeups lost.

The change

The limiter keeps a release grid in time: the next release is the previous one plus the target period, and a frame is released on the first vblank whose nominal time (VBlankTime::schedule.ulTargetVBlank) reaches it, with half a vblank of tolerance for the anchor moving. A late wakeup delays that one release by the lateness and the grid absorbs it on the next, so the average rate is the target. A stall longer than a period is a lost frame, never a debt repaid with back-to-back releases. The VRR path is untouched.

Measured (Retroid Pocket 6, 8 Gen 2 / Adreno 740, Android 13, SteamWorld Dig 2 windowed, GL via zink, cap 60, payload 20261030.3 with only bin/gamescope swapped)

Frames counted at SurfaceFlinger (dumpsys SurfaceFlinger --latency), 30 s per run, same in-game scene; the app's on-screen counter agreed in every run.

gamescopepanelfps1200 Hz interval histogram
shipped (20261030.3)120 Hz54.5520x 1162, 30x 430, 10x 141, 40x 34
shipped, fresh session120 Hz54.4920x 1283, 30x 393, 10x 74, 40x 22
shipped60 Hz53.6720x 1545, 40x 206
A52120 Hz59.9720x 1382, 10x 292, 30x 268, 40x 15
A52, 4 min later120 Hz59.9020x 1364, 10x 300, 30x 271, 40x 18
A5260 Hz59.7120x 1925, 40x 9

Swapped shipped → A52 → shipped → A52; it reproduced both ways. The loss follows the 1200 Hz nested grid, not the panel rate (same ~10 % at 60 Hz and at 120 Hz). A52 held 60 with the app's power tuner at minimum clocks (prime core 595 MHz), so CPU frequency is not the cause. At 120 Hz, A52's releases are still not phase-locked to the panel (the 10x/30x pairs); that is a separate pacing matter, not the cap.