(data);
if (ev->new_surface == nullptr) {
show_default_cursor(r);
}
});
}
// ---- output bring-up ---------------------------------------------------------
void handle_new_output(Runner& r, wlr_output* out) {
if (r.output != nullptr) {
return; // spike: drive ONE output
}
r.output = out;
wlr_output_init_render(out, r.allocator, r.renderer);
wlr_output_state st;
wlr_output_state_init(&st);
wlr_output_state_set_enabled(&st, true);
wlr_output_mode* mode = wlr_output_preferred_mode(out);
if (mode != nullptr) {
wlr_output_state_set_mode(&st, mode);
}
const bool modeset_ok = wlr_output_commit_state(out, &st);
wlr_output_state_finish(&st);
if (out->width > 0) {
r.out_w = out->width;
r.out_h = out->height;
}
slog("output ADDED + MODESET: name='%s' %dx%d refresh=%dmHz preferred_mode=%d commit=%s",
out->name, r.out_w, r.out_h, mode != nullptr ? mode->refresh : 0, mode != nullptr,
modeset_ok ? "OK" : "FAILED");
if (!modeset_ok) {
slog("WARNING: modeset commit FAILED — the panel will likely stay black. "
"Check WLR_RENDERER=gles2 and DRM permissions.");
}
wlr_output_layout_output* lo = wlr_output_layout_add_auto(r.output_layout, out);
r.scene_output = wlr_scene_output_create(r.scene, out);
wlr_scene_output_layout_add_output(r.scene_layout, lo, r.scene_output);
// Load the xcursor theme for this output's scale BEFORE we ever set an
// xcursor image (wlr_cursor_set_xcursor needs the theme loaded at the right
// scale to produce a buffer for the plane). Without this the cursor plane
// has no image => the "no mouse cursor visible" field symptom. Mirrors the
// shipped kernel, which loads the theme on output bring-up.
if (r.cursor_mgr != nullptr) {
wlr_xcursor_manager_load(r.cursor_mgr, out->scale);
slog("xcursor theme loaded for output scale %.2f (cursor can now show an image)",
out->scale);
}
// If a pointer/touch device already exists, show the default cursor now that
// the theme is loaded (device-add may have run before the output came up).
show_default_cursor(r);
// Guaranteed-visible NON-BLACK background + a test marker (criterion C),
// created in the scene tree FIRST so they sit UNDER the RmlUi present node.
// If the RmlUi/dmabuf present path works, the opaque composite covers these
// (you see the tilted window on the document's own dark-blue body). If the
// present path is BROKEN (no buffer reaches present_node), wlr_scene still
// paints these — so "totally black" (nothing presents / modeset failed) is
// visibly distinct from "dark blue + marker" (presenting, but the RmlUi
// layer is empty). Dark blue: an unmistakable "the spike is alive" signal.
const float kBlue[4] = {0.05f, 0.08f, 0.20f, 1.0f};
const float kAmber[4] = {1.0f, 0.65f, 0.0f, 1.0f};
r.bg_rect = wlr_scene_rect_create(&r.scene->tree, r.out_w, r.out_h, kBlue);
r.marker_rect = wlr_scene_rect_create(&r.scene->tree, 64, 64, kAmber);
wlr_scene_node_set_position(&r.marker_rect->node, 24, 24);
// Build the present target + RmlUi document sized to the output, then a
// single full-output scene_buffer node to present it (criterion 7). Created
// AFTER the background rects so it renders ON TOP of them.
r.gl.make_current();
const bool present_ok = r.present.init(&r.gl, r.allocator, r.out_w, r.out_h);
r.present_node = wlr_scene_buffer_create(&r.scene->tree, nullptr);
r.present.scene_buffer = r.present_node;
r.ctx = Rml::CreateContext("run", Rml::Vector2i(r.out_w, r.out_h), r.gl.render);
// --demo loads the curated 4-corner document (deeper perspective + the FPS
// HUD square); plain --run keeps the single-stage kRunRml. The corner panels
// get their per-corner inward tilt at map time (layout_corner_element); here
// we only center the HUD and grab its live-FPS text element.
r.doc = r.ctx->LoadDocumentFromMemory(r.demo ? kDemoRml : kRunRml);
if (r.doc != nullptr) {
r.doc->Show();
}
if (r.demo && r.doc != nullptr) {
if (Rml::Element* hud = r.doc->GetElementById("hud")) {
// Center the HUD square on the output so it is readable and NOT hidden
// behind the corner windows (the corners are inset into the quadrants;
// the centre is clear). Position it in absolute output pixels.
const int hud_w = 220, hud_h = 84;
hud->SetProperty("left", std::to_string((r.out_w - hud_w) / 2) + "px");
hud->SetProperty("top", std::to_string((r.out_h - hud_h) / 2) + "px");
// The holds the live FPS number; cache it for updates.
for (int i = 0; i < hud->GetNumChildren(); ++i) {
Rml::Element* child = hud->GetChild(i);
if (child != nullptr && child->IsClassSet("big")) {
r.hud_el = child;
break;
}
}
slog("--demo: FPS HUD centered at output centre (%dx%d square), hud_el=%p",
hud_w, hud_h, static_cast(r.hud_el));
}
// Grab the click-accuracy debug readout box (hidden until 'D' toggles it).
r.dbg_el = r.doc->GetElementById("dbg");
slog("--demo: click-debug overlay ready (press D to toggle), dbg_el=%p",
static_cast(r.dbg_el));
// Open the dedicated per-5s min/max FPS log (separate from the diagnostic
// log). fps_log_open picks $UNBOX_SPIKE_FPS_LOG else $HOME/rml-spike-fps.log.
r.fps_log = fps_log_open(r.fps_log_path);
if (r.fps_log != nullptr) {
slog("--demo: per-5s min/max FPS log open at '%s'", r.fps_log_path.c_str());
} else {
slog("--demo: WARNING could not open FPS log at '%s' — FPS still shown on the HUD",
r.fps_log_path.c_str());
}
const double t = spike::now_sec();
r.fps_last_sample = t;
r.bucket_start = t;
r.bucket_frames0 = 0;
}
r.gl.restore_current();
slog("present target init=%d dmabuf=%d; RmlUi document=%s (%s); background+marker rects placed",
present_ok, r.present.dmabuf, r.doc != nullptr ? "loaded" : "FAILED",
r.demo ? "--demo 4-corner + HUD" : "--run single-stage");
r.frame_l.connect(out->events.frame, [&r](void*) { on_frame(r); });
wlr_output_schedule_frame(out);
slog("output '%s' up at %dx%d; present node + RmlUi document built", out->name, r.out_w,
r.out_h);
}
Runner* g_runner = nullptr;
// EACH client connect: wl_display's client-created signal is a raw wl_listener
// (not a wlr signal, so no RAII Listener wraps it). For a single-TU throwaway
// spike this is in-bounds; we never let it outlive the display (removed in
// teardown). Loud per-connect logging answers "did foot even connect?".
void on_client_created(wl_listener* l, void* data) {
auto* client = static_cast(data);
pid_t pid = 0;
uid_t uid = 0;
gid_t gid = 0;
wl_client_get_credentials(client, &pid, &uid, &gid);
++g_runner->client_connects;
slog("CLIENT CONNECT #%d: a wl_client connected (pid=%d uid=%d) — now waiting for it to "
"create + MAP a surface",
g_runner->client_connects, static_cast(pid), static_cast(uid));
(void)l;
}
} // namespace
auto run_real_seat(const char* startup_cmd, bool demo) -> int {
log_open();
wlr_log_init(WLR_INFO, nullptr);
slog("=== rml-compositing-spike --%s START (persistent log: %s) ===",
demo ? "demo" : "run", g_log_path.c_str());
slog("env: WLR_BACKENDS=%s WLR_RENDERER=%s", getenv("WLR_BACKENDS") ? getenv("WLR_BACKENDS") : "(auto)",
getenv("WLR_RENDERER") ? getenv("WLR_RENDERER") : "(auto)");
Runner r;
r.demo = demo; // the curated 4-window perf-load scenario; plain --run leaves it false
g_runner = &r;
r.display = wl_display_create();
r.loop = wl_display_get_event_loop(r.display);
// Loudly log EACH client connect (diagnose "did foot connect?"). Raw
// wl_listener — removed in teardown before the display dies.
r.client_created_l.notify = on_client_created;
wl_display_add_client_created_listener(r.display, &r.client_created_l);
r.backend = wlr_backend_autocreate(r.loop, &r.session);
if (r.backend == nullptr) {
slog("FATAL: failed to create backend");
log_close();
return 1;
}
slog("backend created: session=%s (NULL session => nested/headless, no VT switching)",
r.session != nullptr ? "present (real seat)" : "NULL");
r.renderer = wlr_renderer_autocreate(r.backend);
wlr_renderer_init_wl_display(r.renderer, r.display);
r.allocator = wlr_allocator_autocreate(r.backend, r.renderer);
slog("renderer selected: gles2=%d (RML compositing requires gles2)",
wlr_renderer_is_gles2(r.renderer));
if (!wlr_renderer_is_gles2(r.renderer)) {
slog("FATAL: renderer is not gles2 — RML compositing needs the GL path. "
"Set WLR_RENDERER=gles2.");
log_close();
return 1;
}
// SAFETY (criterion A) — signal handlers FIRST, so even an early hang during
// bring-up can be killed cleanly. wl_event_loop_add_signal turns the signal
// into a normal event-loop dispatch on the single thread: SIGINT/SIGTERM ->
// wl_display_terminate -> wl_display_run returns -> clean wlroots/session
// teardown restores the VT to text mode. This is what lets `kill`/`timeout`/
// an SSH `pkill` exit WITHOUT a hard reboot.
r.sigint_src = wl_event_loop_add_signal(r.loop, SIGINT, [](int, void* data) {
slog("SIGINT received -> wl_display_terminate (clean exit)");
wl_display_terminate(static_cast(data));
return 0;
}, r.display);
r.sigterm_src = wl_event_loop_add_signal(r.loop, SIGTERM, [](int, void* data) {
slog("SIGTERM received -> wl_display_terminate (clean exit)");
wl_display_terminate(static_cast(data));
return 0;
}, r.display);
// SAFETY — the GUARANTEED backstop: a DEAD-MAN self-timeout that terminates
// the session no matter what, so the machine can NEVER be locked again.
// DEFAULT 15s (was 120). Override with UNBOX_SPIKE_TIMEOUT seconds (0 =
// disabled, for a deliberate long real-seat session once you trust the key
// escapes). Pressing `P` re-arms it to the FULL interval (see handle_escape_
// keys) — so keeping the session alive past 15s REQUIRES periodic P presses,
// which doubles as the real-seat keyboard-input liveness test.
// Default 15s for plain --run; 120s for --demo (still a backstop, but long
// enough to play an HD video and watch the FPS HUD/log). UNBOX_SPIKE_TIMEOUT
// overrides either (0 = disabled).
int timeout_s = demo ? 120 : 15;
if (const char* env = getenv("UNBOX_SPIKE_TIMEOUT")) {
timeout_s = std::atoi(env);
}
r.deadman_ms = timeout_s * 1000;
if (timeout_s > 0) {
// The timer fires against the Runner so it can log + re-arm. It is a
// ONE-SHOT (we never re-arm it ourselves on expiry): when it fires, the
// session dies — UNLESS a `P` press re-armed it first.
r.safety_timer = wl_event_loop_add_timer(r.loop, [](void* data) {
auto* rr = static_cast(data);
slog("DEAD-MAN FIRED (no `P` press within %ds) -> wl_display_terminate. "
"If you expected the session to stay open, real-seat keyboard input is DEAD "
"(P never reached the handler).",
rr->deadman_ms / 1000);
wl_display_terminate(rr->display);
return 0;
}, &r);
deadman_rearm(r);
slog("DEAD-MAN self-timeout armed: %ds (press P to reset; UNBOX_SPIKE_TIMEOUT=0 to "
"disable). Survival past %ds == keyboard input WORKS.",
timeout_s, timeout_s);
} else {
slog("DEAD-MAN self-timeout DISABLED (UNBOX_SPIKE_TIMEOUT=0) — rely on Esc / "
"Ctrl+Alt+Backspace / Ctrl+Alt+F-key / signals to exit");
}
slog("ESCAPE HATCH: Esc or Ctrl+Alt+Backspace = quit; Ctrl+Alt+F1..F12 = switch VT; "
"SIGINT/SIGTERM = clean quit; P = reset dead-man (keyboard liveness test)");
r.compositor = wlr_compositor_create(r.display, 5, r.renderer);
wlr_subcompositor_create(r.display);
wlr_data_device_manager_create(r.display);
r.output_layout = wlr_output_layout_create(r.display);
r.scene = wlr_scene_create();
r.scene_layout = wlr_scene_attach_output_layout(r.scene, r.output_layout);
r.cursor = wlr_cursor_create();
wlr_cursor_attach_output_layout(r.cursor, r.output_layout);
r.cursor_mgr = wlr_xcursor_manager_create(nullptr, 24);
r.seat = wlr_seat_create(r.display, "seat0");
r.xdg_shell = wlr_xdg_shell_create(r.display, 3);
// Wire from new_toplevel/new_popup (role assigned) — NOT new_surface (no role
// yet). This is what makes the configure handshake complete so clients map.
r.new_toplevel_l.connect(r.xdg_shell->events.new_toplevel, [&r](void* data) {
handle_new_toplevel(r, static_cast(data));
});
r.new_popup_l.connect(r.xdg_shell->events.new_popup, [&r](void* data) {
handle_new_popup(r, static_cast(data));
});
r.layer_shell = wlr_layer_shell_v1_create(r.display, 4);
r.new_layer_l.connect(r.layer_shell->events.new_surface, [&r](void* data) {
handle_new_layer(r, static_cast(data));
});
r.new_output_l.connect(r.backend->events.new_output,
[&r](void* data) { handle_new_output(r, static_cast(data)); });
r.new_input_l.connect(r.backend->events.new_input, [&r](void* data) {
handle_new_input(r, static_cast(data));
});
attach_input(r);
// Initialize the GL bridge against the wlr EGLDisplay now (before any output;
// the import path only needs the display).
EGLDisplay egl = wlr_egl_get_display(wlr_gles2_renderer_get_egl(r.renderer));
if (!r.gl.init(egl)) {
slog("FATAL: GL bridge init failed — NO-GO on this hardware");
log_close();
return 1;
}
const char* socket = wl_display_add_socket_auto(r.display);
if (socket == nullptr) {
slog("FATAL: failed to add wayland socket");
log_close();
return 1;
}
// Export WAYLAND_DISPLAY in OUR environment so EVERY child (the spawn below
// AND anything it forks) inherits our socket, not the stale parent value.
// Mirrors the shipped kernel (server.cpp): without this the client connects
// to the WRONG compositor and nothing shows — a black-screen cause.
setenv("WAYLAND_DISPLAY", socket, 1);
slog("WAYLAND_DISPLAY=%s (exported into process env; children inherit it)", socket);
if (!wlr_backend_start(r.backend)) {
slog("FATAL: failed to start backend");
log_close();
return 1;
}
slog("backend started; up on WAYLAND_DISPLAY=%s", socket);
// Spawn a child running `cmd` via /bin/sh, with WAYLAND_DISPLAY exported and
// optionally an extra env var (KEY=VALUE) set in the child (firefox needs
// MOZ_ENABLE_WAYLAND=1). `label` is logged. Returns the pid (>0) or -1.
auto spawn_client = [&](const char* cmd, const char* extra_env, const char* label) -> pid_t {
const pid_t pid = fork();
if (pid == 0) {
setenv("WAYLAND_DISPLAY", socket, 1);
if (extra_env != nullptr && extra_env[0] != '\0') {
// extra_env is "KEY=VALUE"; split once on '='.
const char* eq = std::strchr(extra_env, '=');
if (eq != nullptr) {
const std::string key(extra_env, eq);
setenv(key.c_str(), eq + 1, 1);
}
}
execl("/bin/sh", "/bin/sh", "-c", cmd, static_cast(nullptr));
std::fprintf(stderr, "[run] exec of client failed: %s\n", cmd);
_exit(127);
}
if (pid > 0) {
slog("client SPAWN: pid=%d %s cmd='%s'%s%s (watch for a 'CLIENT SURFACE MAP' line)",
static_cast(pid), label, cmd, extra_env != nullptr ? " env=" : "",
extra_env != nullptr ? extra_env : "");
} else {
slog("WARNING: fork() failed; no client spawned for %s", label);
}
return pid;
};
if (r.demo) {
// The curated perf load: 3x foot + 1x firefox, one per inward-angled
// corner. firefox is steered to its designated corner at map time
// (claim_corner). If firefox is not installed / cannot connect, the
// NO-CLIENT/corner bookkeeping degrades gracefully — the terminals still
// fill their corners — and we log a loud warning here AND from the
// watchdog. We probe for the firefox binary first so the warning is loud
// even before any connection attempt.
slog("--demo: spawning the curated 4-window perf load (3x foot + 1x vivaldi), one per "
"inward-angled corner. HD-video-friendly: 120s default dead-man, FPS HUD + 5s "
"min/max fps log.");
spawn_client("foot", nullptr, "[foot 1/3]");
spawn_client("foot", nullptr, "[foot 2/3]");
spawn_client("foot", nullptr, "[foot 3/3]");
// vivaldi (Chromium): forced onto Wayland via --ozone-platform=wayland.
// Probe PATH for the binary (vivaldi-stable / vivaldi / vivaldi-snapshot)
// so a missing browser is a loud warning, not a silently-empty corner.
const char* vivaldi_bin = nullptr;
if (const char* path = getenv("PATH"); path != nullptr) {
static const char* const kNames[] = {"vivaldi-stable", "vivaldi", "vivaldi-snapshot"};
std::string p = path, dir;
std::size_t i = 0;
while (i <= p.size() && vivaldi_bin == nullptr) {
if (i == p.size() || p[i] == ':') {
for (const char* name : kNames) {
if (!dir.empty() && ::access((dir + "/" + name).c_str(), X_OK) == 0) {
vivaldi_bin = name;
break;
}
}
dir.clear();
} else {
dir.push_back(p[i]);
}
++i;
}
}
if (vivaldi_bin != nullptr) {
const std::string cmd =
std::string(vivaldi_bin) + " --ozone-platform=wayland --ozone-platform-hint=auto";
spawn_client(cmd.c_str(), nullptr, "[vivaldi 1/1, Wayland]");
} else {
slog("*** WARNING: vivaldi NOT found on PATH (tried vivaldi-stable/vivaldi/"
"vivaldi-snapshot) — the browser corner (slot %d, %s) will stay empty. "
"Continuing with the 3 foot terminals. ***",
kFirefoxCorner, kCorners[kFirefoxCorner].name);
}
} else if (startup_cmd != nullptr && startup_cmd[0] != '\0') {
spawn_client(startup_cmd, nullptr, "[--run client]");
} else {
slog("no startup command — connect your own client to WAYLAND_DISPLAY=%s", socket);
}
// NO-CLIENT watchdog (~5s): if nothing maps a surface by then, scream loudly
// in the log so the "background+marker but no foot" case is unambiguous.
r.client_watchdog = wl_event_loop_add_timer(r.loop, [](void* data) {
auto* rr = static_cast(data);
if (!rr->any_surface_mapped) {
slog("*** NO CLIENT MAPPED — foot did not connect/render within ~5s. ***");
slog(" connects-so-far=%d. If 0: the client could NOT connect (wrong "
"WAYLAND_DISPLAY, client crash, or missing binary). If >0: it connected but "
"produced no buffer (missing fonts, GL/shm failure). Only background+marker "
"will show.",
rr->client_connects);
} else {
slog("client-mapped check OK: at least one surface mapped within ~5s.");
}
return 0; // one-shot
}, &r);
wl_event_source_timer_update(r.client_watchdog, 5000);
slog("entering event loop (wl_display_run)");
wl_display_run(r.display);
slog("event loop exited — tearing down cleanly (wlroots restores the VT to text mode)");
// Teardown. Disconnect every RAII Listener bound to a wlr signal BEFORE the
// wlr objects (cursor/backend/seat) are destroyed — a still-linked listener
// trips wlr_cursor_destroy's `wl_list_empty(listener_list)` assertion (the
// Runner's Listener members would otherwise unsubscribe only at Runner's
// destruction, AFTER these destroys). Also drop per-surface listeners.
for (LiveSurface& s : r.surfaces) {
s.map_l.disconnect();
s.unmap_l.disconnect();
s.commit_l.disconnect();
s.destroy_l.disconnect();
}
r.new_output_l.disconnect();
r.new_input_l.disconnect();
r.frame_l.disconnect();
r.new_toplevel_l.disconnect();
r.new_popup_l.disconnect();
r.new_layer_l.disconnect();
r.cursor_motion_l.disconnect();
r.cursor_motion_abs_l.disconnect();
r.cursor_button_l.disconnect();
r.cursor_axis_l.disconnect();
r.cursor_frame_l.disconnect();
r.touch_down_l.disconnect();
r.touch_up_l.disconnect();
r.touch_motion_l.disconnect();
r.seat_request_cursor_l.disconnect();
r.seat_pointer_focus_change_l.disconnect();
for (Keyboard& kb : r.keyboards) {
kb.key_l.disconnect();
kb.mods_l.disconnect();
kb.destroy_l.disconnect();
}
// The raw client-created wl_listener must not outlive the display.
wl_list_remove(&r.client_created_l.link);
const bool cur = r.gl.make_current();
for (LiveSurface& s : r.surfaces) {
s.live.destroy();
}
r.present.teardown();
if (r.ctx != nullptr) {
Rml::RemoveContext("run");
}
if (cur) {
r.gl.restore_current();
}
r.gl.teardown();
if (r.scene != nullptr) {
wlr_scene_node_destroy(&r.scene->tree.node);
}
if (r.cursor_mgr != nullptr) {
wlr_xcursor_manager_destroy(r.cursor_mgr);
}
if (r.cursor != nullptr) {
wlr_cursor_destroy(r.cursor);
}
if (r.allocator != nullptr) {
wlr_allocator_destroy(r.allocator);
}
if (r.renderer != nullptr) {
wlr_renderer_destroy(r.renderer);
}
if (r.backend != nullptr) {
wlr_backend_destroy(r.backend);
}
if (r.safety_timer != nullptr) {
wl_event_source_remove(r.safety_timer);
}
if (r.client_watchdog != nullptr) {
wl_event_source_remove(r.client_watchdog);
}
if (r.sigint_src != nullptr) {
wl_event_source_remove(r.sigint_src);
}
if (r.sigterm_src != nullptr) {
wl_event_source_remove(r.sigterm_src);
}
wl_display_destroy(r.display);
if (r.fps_log != nullptr) {
std::fflush(r.fps_log);
::fsync(::fileno(r.fps_log));
std::fclose(r.fps_log);
r.fps_log = nullptr;
}
slog("=== rml-compositing-spike --%s EXIT 0 (VT restored) ===", demo ? "demo" : "run");
log_close();
return 0;
}