Files
DroidDeck/tools/tests/test_render_on_commit.py
MaxsTechReview b91e8fdeab fix: a session in picture-in-picture draws at 30 fps instead of the full refresh (#1842)
* fix: a session in picture-in-picture draws at 30 fps instead of the full refresh

Since auto picture-in-picture became the default (#1727), pressing Home leaves the session running in a small window that takes no input. A game kept drawing at its full rate there: on a REDMAGIC Astra 2 Among Us in picture-in-picture drew 3.31 W, the same as playing it and about 1.05 W more than a suspended session. While in picture-in-picture the compositor now paces the session at 30 fps (or a lower limit already set). Mirroring to glasses keeps the glasses rate, and the display mode request ignores the picture-in-picture limit so the app in front is not slowed.

* fix: keep the no-argument output limit the glasses pacing tests read

* fix: hold scene renders to the output limit and keep frame generation off in picture-in-picture

A frame limit only paced dmabuf buffer releases, so a shared-memory client was still drawn on every vsync with its frame callbacks sent at the panel's rate. The vsync tick now waits until the limit's interval, less half a refresh, has passed since the last scene. Frame generation is disarmed while picture-in-picture is shown and armed again when it ends; frame generation requests are applied in order.

* Give the commit render harness the frame limit hold

* Hold scene renders to a frame limit deadline, also on the fallback timer, and keep frame generation for glasses mirroring in picture in picture
2026-10-30 13:29:10 -04:00

226 lines
8.3 KiB
Python

import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_output_refresh import SOURCE, extract_function
PREAMBLE = r'''
#include <assert.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
struct wl_event_source { int armed; };
static struct wl_event_source idle_source, timer_source;
static void (*idle_func)(void *);
static int idles_added, timers_armed, renders, window_requests, effects_changes, pose_changes;
static int64_t clock_ns;
static int g_display_storage;
static void *g_display = &g_display_storage;
static struct wl_event_source *g_render_idle, *g_fallback_timer;
static int g_fallback_armed, g_dirty, g_hud_fresh, framegen;
static void *g_hud_surface;
static int64_t g_dirty_since_ns, g_last_vsync_ns, g_tick_vsync_ns, g_hud_last_ns, g_rendered_tick_ns;
static int g_tick_held;
static int64_t g_hold_missed_ns;
#define HOLD_RETRY_NS 1000000000LL
static int64_t g_refresh_ns = 16666667;
volatile int g_render_on_commit = 1;
volatile int g_release_unpaced = 1;
volatile int g_fps_limit;
static int64_t g_scene_due_ns;
static atomic_int g_stream_on;
static struct { unsigned ticks, ticks_late_2ms, commit_renders; int64_t tick_late_ns, tick_late_max_ns; } g_pace;
static struct { unsigned ticks; } g_perf;
static int64_t now_ns(void) { return clock_ns; }
static void *wl_display_get_event_loop(void *display) { return display; }
static struct wl_event_source *wl_event_loop_add_idle(void *loop, void (*func)(void *), void *data) {
idles_added++; idle_func = func; return &idle_source;
}
static struct wl_event_source *wl_event_loop_add_timer(void *loop, int (*func)(void *), void *data) { return &timer_source; }
static int wl_event_source_timer_update(struct wl_event_source *source, int ms) { timers_armed++; return 0; }
static int vkp_framegen_active(void) { return framegen; }
static int vkp_apply_window_request(void) { return window_requests; }
static int vkp_effects_sync(void) { return effects_changes; }
static int vkp_cardboard_pose_changed(void) { return pose_changes; }
static void render_scene(void) { renders++; g_dirty = 0; g_dirty_since_ns = 0; g_rendered_tick_ns = g_tick_vsync_ns; g_tick_held = 0; }
static int on_fallback_timer(void *data) { return 0; }
static void on_commit_idle(void *data);
'''
CHECKS = r'''
static void run_idle(void) {
void (*func)(void *) = idle_func;
idle_func = NULL;
if (func) func(NULL);
}
static void tick(int64_t t) { clock_ns = t; on_vsync(t); }
static void arrive(int64_t after_ns) { clock_ns += after_ns; schedule_render(); }
int main(void) {
const int64_t refresh = 6944444, ms = 1000000;
int64_t t = 1000000000LL;
int r0;
unsigned c0;
g_refresh_ns = refresh;
tick(t);
assert(renders == 0);
/* a new frame is drawn when it arrives */
arrive(2 * ms);
assert(idles_added == 1 && renders == 0);
run_idle();
assert(renders == 1 && g_pace.commit_renders == 1 && g_render_idle == NULL);
/* a second frame in the same refresh is left for the next arrival, which is drawn when it arrives */
arrive(1 * ms);
assert(idles_added == 1);
tick(t += refresh);
assert(renders == 1 && g_tick_held == 1);
arrive(2 * ms);
assert(idles_added == 2);
run_idle();
assert(renders == 2 && g_pace.commit_renders == 2 && g_tick_held == 0);
/* a frame no other frame follows is drawn at the tick after next */
arrive(1 * ms);
tick(t += refresh);
assert(renders == 2);
tick(t += refresh);
assert(renders == 3 && g_pace.commit_renders == 2);
/* after that, the tick draws every frame for a second, then holds one again */
arrive(1 * ms);
tick(t += refresh);
assert(renders == 4 && g_tick_held == 0);
t += HOLD_RETRY_NS;
tick(t);
arrive(1 * ms);
run_idle();
arrive(1 * ms);
tick(t += refresh);
assert(renders == 5 && g_tick_held == 1);
arrive(2 * ms);
run_idle();
assert(renders == 6 && g_pace.commit_renders == 4);
/* one frame per refresh, starting right after a tick draw, settles on drawing every frame when it arrives */
window_requests = 1;
tick(t += refresh);
window_requests = 0;
arrive(2 * ms);
r0 = renders; c0 = g_pace.commit_renders;
for (int i = 0; i < 10; i++) {
tick(t += refresh);
arrive(2 * ms);
run_idle();
}
assert(renders - r0 == 10 && g_pace.commit_renders - c0 == 10);
/* a client that draws again only after its frame callback (sent when the scene is drawn) keeps one frame
per refresh: two held frames in 200 refreshes, about 1.4 s */
r0 = renders; c0 = g_pace.commit_renders;
{
int seen = renders - 1;
for (int i = 0; i < 200; i++) {
if (renders != seen) { seen = renders; arrive(1 * ms); run_idle(); }
tick(t += refresh);
}
}
assert(renders - r0 == 198 && g_pace.commit_renders == c0);
/* a window, effect or headset change at the tick draws the waiting frame now */
t += HOLD_RETRY_NS;
tick(t);
arrive(1 * ms);
run_idle();
r0 = renders;
arrive(1 * ms);
window_requests = 1;
tick(t += refresh);
window_requests = 0;
assert(renders - r0 == 1 && g_tick_held == 0);
/* a draw queued before the tick still draws the frame once */
tick(t += refresh);
r0 = renders; c0 = g_pace.commit_renders;
arrive(1 * ms);
tick(t += refresh);
assert(renders == r0);
run_idle();
assert(renders - r0 == 1 && g_pace.commit_renders - c0 == 1);
tick(t += refresh);
assert(renders - r0 == 1);
/* streaming switched on after the draw was queued leaves the frame for the tick */
tick(t += refresh);
r0 = renders; c0 = g_pace.commit_renders;
arrive(1 * ms);
atomic_store(&g_stream_on, 1);
run_idle();
assert(renders == r0);
tick(t += refresh);
atomic_store(&g_stream_on, 0);
assert(renders - r0 == 1 && g_pace.commit_renders == c0);
/* paced output, frame generation, the switch off and streaming keep drawing on the tick */
const int idles = idles_added;
r0 = renders;
for (int c = 0; c < 4; c++) {
tick(t += refresh);
if (c == 0) g_release_unpaced = 0;
if (c == 1) framegen = 1;
if (c == 2) g_render_on_commit = 0;
if (c == 3) atomic_store(&g_stream_on, 1);
arrive(1 * ms);
assert(idles_added == idles);
tick(t += refresh);
g_release_unpaced = 1; framegen = 0; g_render_on_commit = 1; atomic_store(&g_stream_on, 0);
assert(renders - r0 == c + 1);
}
/* without screen ticks the fallback timer still draws */
clock_ns += 200000000LL;
schedule_render();
assert(idles_added == idles && g_fallback_armed == 1 && timers_armed == 1);
puts("PASS");
return 0;
}
'''
@unittest.skipUnless(sys.platform.startswith('linux'), 'needs a Linux host compiler')
class RenderOnCommitTest(unittest.TestCase):
def test_a_new_frame_is_drawn_on_arrival_at_most_once_per_refresh(self):
compiler = shutil.which(os.environ.get('CC', 'cc'))
if compiler is None:
self.skipTest('A host C compiler is required')
source = SOURCE.read_text()
render = extract_function(source, 'render_scene')
self.assertIn('g_rendered_tick_ns = g_tick_vsync_ns;', render)
self.assertIn('g_tick_held = 0;', render)
functions = ('scene_held_for_limit', 'commit_renders_on', 'render_on_commit', 'schedule_render', 'scene_waits_for_game', 'on_commit_idle', 'on_vsync')
harness = PREAMBLE + '\n'.join(extract_function(source, name) for name in functions) + CHECKS
with tempfile.TemporaryDirectory(prefix='droiddeck-render-on-commit-') as folder:
path = Path(folder)
(path / 'commit.c').write_text(harness)
binary = path / 'commit-test'
build = subprocess.run([compiler, '-std=c11', '-Wall', '-Wextra', '-Werror',
'-Wno-unused-parameter', '-Wno-unused-function', '-O2',
str(path / 'commit.c'), '-o', str(binary)], capture_output=True, text=True)
self.assertEqual(0, build.returncode, build.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual('PASS', result.stdout.strip())
if __name__ == '__main__':
unittest.main()