android: A52 — release limited frames on the vblank time grid, not on every Nth wakeup
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.
| gamescope | panel | fps | 1200 Hz interval histogram |
|---|---|---|---|
| shipped (20261030.3) | 120 Hz | 54.55 | 20x 1162, 30x 430, 10x 141, 40x 34 |
| shipped, fresh session | 120 Hz | 54.49 | 20x 1283, 30x 393, 10x 74, 40x 22 |
| shipped | 60 Hz | 53.67 | 20x 1545, 40x 206 |
| A52 | 120 Hz | 59.97 | 20x 1382, 10x 292, 30x 268, 40x 15 |
| A52, 4 min later | 120 Hz | 59.90 | 20x 1364, 10x 300, 30x 271, 40x 18 |
| A52 | 60 Hz | 59.71 | 20x 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.