refactor: the guest's present mode is the only mode, and every limit comes from one rule
What this is
The shim now treats the guest's declared present mode as the only mode, applies every rate limit from a single rule, derives every wait from a measured rate instead of a literal, and keeps a buffer alive only as long as the display actually holds it. Tearing under direct scan-out is gone without costing swapchain depth, and frame generation is no longer starved by the mechanism that fixed it.
One rule decides every rate
limits.rs turns the guest's present mode, the cap, the multiplier and the panel's refresh into the frames per second the guest may acquire and the frames per second the generator may add:
| present | cap | LSFG | acquire | generated | total | hold | show in HUD |
|---|---|---|---|---|---|---|---|
| synced | on | off | cap | — | cap | note | cap |
| synced | off | off | refresh | — | refresh | note | refresh |
| synced | on | on | cap | cap × (m−1), at most refresh − cap | cap + min(cap × (m−1), refresh − cap) | note | total |
| synced | off | on | refresh / m | acquire × (m−1) | acquire × m | note | total |
| unsynced | on | off | cap | — | cap | note | cap |
| unsynced | off | off | none | — | none | note | measured |
| unsynced | on | on | cap | cap × (m−1) | cap × m | note | total |
| unsynced | off | on | refresh / m | acquire × (m−1) | acquire × m | note | total |
hold is when an acquired image may be drawn into again. It follows the served mode — FIFO or MAILBOX, since every declaration on this route is served as one of the two — so it is note on every row: the image returns when the compositor reports its release and the display reports its scan-out release after that.
IMMEDIATEis served asMAILBOX— the one substitution left, in drm only, because an immediate guest would otherwise tear by construction.- A generation session is served the QuickMenu's mode (
LsfgConfig::present_mode), and its image count is sized for the served mode, so a guest that asks FIFO while being served mailbox gets the mailbox depth.
The cap is a rate, and the schedule is a grid
One regulator replaces the previous three, and its release points are booked from the previous release point, not from the instant a frame happened to start. Lateness is therefore absorbed by the following frame instead of accumulating: a 30 cap that read 27.74 now reads 29.0 on the same title, with the residual visible as skipped frames — stalls reported rather than repaid.
The hold follows the present mode
The synced generated ceiling is the panel's leftover, not the panel: source frames
are part of the same budget, so generated <= refresh - acquire. At 60 Hz, cap 40,
×2 that leaves 20 generated against 40 source frames — half a frame each, which
floors to none, so generation does nothing at any cap above half the refresh. That
is the price of not over-subscribing the panel, and it is deliberate.
A synced slot waits for SurfaceFlinger's own release note before the guest may write into it, and an unsynced one does not. That removed the tear (sf hold measures 1.5–2.5 display periods) while an uncapped immediate session returns to free-running instead of being pinned at depth × refresh / hold.
Generation destinations share the same release path — the hole that made five destinations park for a session and produce six frames is closed in both allocators, and the bound is the only fallback, so a silent channel costs one bound per slot and leaks nothing.
The present wait reports handover, not scan-out
A frame handed to the compositor is not a frame that has been scanned out, and VK_KHR_present_wait is a report in native FIFO, not the throttle — the throttle is inside vkQueuePresentKHR. Answering the wait at the latch made the shim wait twice for the same frame: once in its own park and again in the guest's wait. The override route now answers at commit for every mode, which leaves the display's pacing with the compositor, where the spec puts it.
Diagnostics that do not lie
The census reports a rate (frames over elapsed clock time) instead of the reciprocal of the mean acquire duration; it states the image-count decision once per swapchain creation; it exposes the generation ring as dests=[in_flight/total parked=n] and destination releases at the default log level; and a generated frame reports itself at its own commit rather than riding the next acquired frame.
Verified
Device results are listed per device: the same build behaves differently on each, and one of the two found defects only exists on the second.
Retroid Pocket Nova (Adreno 740, 60 Hz panel)
- Witcher 3, ×2, mailbox: frame generation went from 6 frames per session to 3 103+,
short: free4 790 → ~0, rate 27.7 → 29.0 of a 30 cap, no tearing. - Dave, vsync on: steady 60 fps with the acquisition path no longer paying a park and a present wait for the same frame.
- Hades II, uncapped at 120 Hz: free-running instead of
depth × refresh / hold. - Portal 2: declares
IMMEDIATEand tore under direct scan-out. Serving that declaration asMAILBOXremoved the tearing outright.
Odin 3 (Adreno 830, 60 Hz panel)
- Witcher 3, generation off, FIFO, cap 60: two defects met here. A park bound derived from the frame interval it governs fed on itself (3 fps → 318 ms spacing → 636 ms bound → 3 fps), and the release note does not name the parked buffer —
rnotes=2961againstpark_timeouts=2954,notelagempty on every bucket, andslot ino=11411besidereleased=[11366 11368 11370], same allocator, never the same buffer. Parks are now answered by order as well as by identity.slot_avg_us26 728 → 1 461,acq_fps23.5 → HUD 53.8, GPU 14 % → 89 % — the session became device-bound, which is where it should be. - Doom 2016,
FIFO_RELAXEDon the strict list (two images): holds exactly 30.0 whenever a released slot waits for the display's note. Three changes were needed and each is in this PR —FIFO_RELAXEDserved asMAILBOX(a late frame is superseded rather than deferred a whole vblank), the hidden-slot ring sized from the guest's ask rather than from the served count (a mailbox floor raised 2 to 4, which hid the ring), and hidden slots not parked (four parked copies dry the ring, the frame falls back to a guest slot, and two guest images over a 1.5-4.5-period hold lands on2 / hold= 30). Result: 29.9 → 57.4 fps, GPU 79 %,dests=[1/4 parked=0],park_timeouts=0. - A fallback that took the swapchain lock twice deadlocked the acquire thread on this device:
acq=5frozen, guest parked on a timeline semaphore,park_timeouts=0. Both defects are named because neither was visible in the counters.
clippy -D warnings clean, 344 tests, build-android.sh clean, check-exports.sh PASS.