From fc361f67e61e989d72170a2b90d89f9f073ba473 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sat, 6 Jun 2026 23:16:19 -0700 Subject: [PATCH] recording: consumer side of lockstep capture (single fixed-dt clock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to daslang's lockstep-recording change. With the host clock in fixed-dt mode during recording, the two consumer-side clocks must follow it: - harness: drive ImGui io.DeltaTime from the master clock (get_dt) in the windowed path instead of glfw's own clock, so under lockstep recording io.DeltaTime is the fixed step too. In normal mode get_dt() equals the wall dt, so this is a no-op there. - playwright: content-time gating. During a recording session the host clock advances a fixed 1/fps per captured frame (decoupled from wall-clock), so a beat must be held by captured-frame count, not sleep(ms). The blocking say() now holds via hold_content; new helpers wait_content_frames / hold_content / hold_remainder_content / record_frame_count poll record_status.frames; g_record_fps is set around the recording body in both attach and spawn paths. Outside a recording session they fall back to sleep. The cursor lerp (move_to) needs no change: advance_mouse_timeline already reads get_uptime(), which is the fixed clock under lockstep. Validated by re-recording display_widgets: uniform 30fps, voices (frames 78/445/864) never overlap, recording self-verification passed. Incidental lint cleanup in harness (already in the diff): require opengl/opengl directly instead of opengl/opengl_boost (STYLE029); suppress STYLE030 on require live/opengl_live (a side-effect require that loads the APNG recorder). The say_begin + manual hold drivers (record_*.das across this repo, node-editor and implot) still pace those beats by wall-clock; converting them to hold_remainder_content is a mechanical follow-up sweep. Review/CI fixes: - wait_content_frames hardening. The target is now anchored on the first VALID status read (a transient null read can no longer lower it and shorten the hold to ~0 frames), and a null read keeps polling instead of ending the hold early. - macOS CI hang fix + graceful degradation. Content-time gating couples the driver's progress to the app's frame production; on headless macOS the unfocused window is OS-throttled, so frames don't advance during an input-less hold and the wait spun to its 120s cap (× N says = a hang). wait_content_frames now detects a capture stall (frames idle RECORD_STALL_US = 2s) and degrades to a wall-clock sleep for the remaining beat, with a one-shot warning. Uniform video stays where capture works (Windows/ubuntu); macOS reverts to wall-clock pacing (throwaway CI recordings). The real fix — render continuously during recording on macOS — is dasImgui issue #190. - imgui2rst: register the four new public helpers under a "Content-time gating" doc group so the Uncategorized gate passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- utils/imgui2rst.das | 1 + widgets/imgui_harness.das | 11 +++- widgets/imgui_playwright.das | 110 ++++++++++++++++++++++++++++++++++- 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/utils/imgui2rst.das b/utils/imgui2rst.das index 492089f9..db83491f 100644 --- a/utils/imgui2rst.das +++ b/utils/imgui2rst.das @@ -150,6 +150,7 @@ def document_module_imgui_playwright() { group_by_regex("Locators / queries", mod, %regex~^(find_widget|widget_exists|widget_payload_field|widget_rendered|widget_click_point|widget_component_point|get_text)$%%), group_by_regex("Actions", mod, %regex~^(click|click_at|right_click|type_text|key_tap|force_set_value|force_set_verified|drag|drag_along|drag_to|focus|open_widget|close_widget|reload|reset|move_to|reset_cursor_pos|resize_window)$%%), group_by_regex("Recording / narration", mod, %regex~^(say|say_begin|say_hash|hold_through_voice|drag_through_voice|type_into_voice|combo_pick_voice|toggle_tree_voice|close_button_voice|click_render_voice|menu_pick_voice|record_check_value|record_check_changed|record_check_unchanged|record_check_rendered|record_check_kind_count|record_check)$%%), + group_by_regex("Content-time gating", mod, %regex~^(wait_content_frames|hold_content|record_frame_count|hold_remainder_content)$%%), group_by_regex("Snapshots", mod, %regex~^(snapshot|expect_value|expect_render).*%%), group_by_regex("Polling / await", mod, %regex~^(wait_until.*|wait_for_.*|await_quiescent|await_probe)$%%), group_by_regex("Pause control", mod, %regex~^(pause|unpause|with_paused)$%%), diff --git a/widgets/imgui_harness.das b/widgets/imgui_harness.das index be2f7ea1..565ecf66 100644 --- a/widgets/imgui_harness.das +++ b/widgets/imgui_harness.das @@ -38,9 +38,9 @@ module imgui_harness shared public require imgui public require imgui_app require glfw/glfw_boost -require opengl/opengl_boost +require opengl/opengl require live/glfw_live -require live/opengl_live +require live/opengl_live // nolint:STYLE030 — loads the APNG recorder (record_* live commands + the [before_update] record_tick) by side effect; no direct symbol use // Live-host + boost-runtime stack — re-exported public. require live/live_api public @@ -164,6 +164,13 @@ def public harness_begin_frame() : bool { begin_frame() ImGui_ImplOpenGL3_NewFrame() ImGui_ImplGlfw_NewFrame() + // Single clock: drive ImGui DeltaTime from the master clock (get_dt), so lockstep + // recording uses the fixed step; normal mode get_dt()==wall dt (no-op). Guard >0. + let mdt = get_dt() + if (mdt > 0.0f) { + var io & = unsafe(GetIO()) + io.DeltaTime = mdt + } advance_coroutines() return true } diff --git a/widgets/imgui_playwright.das b/widgets/imgui_playwright.das index 95ce66c2..00db1bdb 100644 --- a/widgets/imgui_playwright.das +++ b/widgets/imgui_playwright.das @@ -862,13 +862,113 @@ def public say(app : ImguiApp; text : string; target : string = ""; read_ms : ui //! imgui_visual_aids::narrate (the host-side overlay function), which differs //! only in the first arg type (string vs ImguiApp). For a click/drag that must land under //! the voice, use ``say_begin`` + a manual dwell split instead. - sleep(say_begin(app, text, target, read_ms, voice)) + hold_content(app, say_begin(app, text, target, read_ms, voice)) } def public say(app : ImguiApp; text : string; targets : array; read_ms : uint = NARRATE_READ_MS; voice : string = "") { //! Multi-target ``say``: caption points at ``targets[0]`` and HARD-avoids the union of all //! ``targets`` (see the ``say_begin`` list overload), then sleeps the dwell. - sleep(say_begin(app, text, targets, read_ms, voice)) + hold_content(app, say_begin(app, text, targets, read_ms, voice)) +} + +// ===== Lockstep content-time gating ===== +// During a recording session the host clock runs in fixed-dt lockstep (1/fps per +// captured frame), decoupled from wall-clock — so a beat must be held by CONTENT +// time (captured-frame count), not sleep(ms): the app may render faster or slower +// than realtime, but every captured frame is exactly 1/fps of video. These poll +// record_status.frames; outside a recording session they fall back to sleep. + +var private g_record_fps : int = 0 // > 0 while a with_recording_app session is active +var private g_stall_warned = false // one-shot per session: capture-stall -> wall-clock degrade + +let private RECORD_POLL_MS = 4u +let private RECORD_HOLD_TIMEOUT_US = 120 * 1000000 // 120s wall-clock safety cap +// Frames idle this long => capture isn't advancing (the app isn't rendering during this +// input-less hold — e.g. an OS-throttled unfocused window, as on headless macOS CI). Stop +// waiting on content and degrade to wall-clock for the remaining beat so CI never hangs. +// See https://github.com/borisbat/dasImgui/issues/190. +let private RECORD_STALL_US = 2 * 1000000 + +def private warn_capture_stalled() { + return if (g_stall_warned) + g_stall_warned = true + print("[record] frame capture stalled (app not rendering during a hold) — content-time " + + "gating degraded to wall-clock for this session. See dasImgui issue #190.\n") +} + +def private record_frames_now(app : ImguiApp) : int { + // Captured-frame counter from a VALID active status. Tolerates a transient null read + // (a 0 here would corrupt a hold anchor) by briefly retrying; 0 only if never valid. + let start = ref_time_ticks() + while (get_time_usec(start) < RECORD_STALL_US) { + let st = post_command(app, "record_status", null) + if (st != null && (st?["active"] ?? false)) return st?["frames"] ?? 0 + sleep(RECORD_POLL_MS) + } + return 0 +} + +def public wait_content_frames(app : ImguiApp; frames : int) { + //! Block until the recorder captures `frames` more frames (= frames/fps seconds of video). + //! No-op outside a recording session. The target is anchored on the first VALID status + //! read (a transient null never lowers it), and a null read keeps polling rather than + //! ending the hold early. If capture stalls (frames don't advance for RECORD_STALL_US — + //! the app isn't rendering during this hold), degrade to a wall-clock sleep for the + //! remaining span so the beat still lands and CI never hangs. + if (g_record_fps <= 0 || frames <= 0) return + let start = ref_time_ticks() + var target = -1 // resolved from the first valid status read + var last_frames = -1 + var last_progress = ref_time_ticks() // reset whenever the frame counter advances + while (get_time_usec(start) < RECORD_HOLD_TIMEOUT_US) { + let st = post_command(app, "record_status", null) + let active = st != null && (st?["active"] ?? false) + let now_frames = active ? (st?["frames"] ?? -1) : -1 + if (now_frames >= 0) { + if (target < 0) { + target = now_frames + frames + } + break if (now_frames >= target) + if (now_frames > last_frames) { + last_frames = now_frames + last_progress = ref_time_ticks() + } + } + if (get_time_usec(last_progress) >= RECORD_STALL_US) { + warn_capture_stalled() + let remaining = target < 0 ? frames : max(0, target - last_frames) + sleep(uint(float(remaining) / float(g_record_fps) * 1000.0f)) + return + } + sleep(RECORD_POLL_MS) + } +} + +def public hold_content(app : ImguiApp; ms : uint) { + //! Hold for `ms` of CONTENT time: under lockstep recording wait for ms/1000*fps captured + //! frames; otherwise a plain wall-clock sleep(ms). + if (g_record_fps <= 0) { + sleep(ms) + return + } + wait_content_frames(app, int((float(ms) / 1000.0f) * float(g_record_fps) + 0.5f)) +} + +def public record_frame_count(app : ImguiApp) : int { + //! Current captured-frame count (content-time index). Capture right after say_begin to + //! anchor a hold_remainder_content. Returns 0 outside a recording session. + if (g_record_fps <= 0) return 0 + return record_frames_now(app) +} + +def public hold_remainder_content(app : ImguiApp; dwell_ms : uint; start_frames : int) { + //! Pad a beat to `dwell_ms` of CONTENT time: wait until the recorder reaches + //! start_frames + dwell_ms/1000*fps (start_frames from record_frame_count right after + //! say_begin). Work done mid-dwell consumes content frames; this holds the rest. The + //! content-clock replacement for a driver's wall-clock hold_remainder. No-op when idle. + if (g_record_fps <= 0) return + let target = start_frames + int((float(dwell_ms) / 1000.0f) * float(g_record_fps) + 0.5f) + wait_content_frames(app, target - record_frames_now(app)) } def private is_button_kind(kind : string) : bool { @@ -1594,8 +1694,11 @@ def public with_recording_app(feature_path : string; fps = fps, max_seconds = eff_max_seconds ))) + g_record_fps = fps // lockstep: gate say/hold_content by captured-frame count + g_stall_warned = false invoke(body, app) let stopped = post_command(app, "record_stop", null) + g_record_fps = 0 let dump = stopped != null ? write_json(stopped) : "" print("[record] record_stop -> {dump}\n") print("[record] APNG saved to {output_apng_path}\n") @@ -1658,8 +1761,11 @@ def public with_recording_app(feature_path : string; // g_record_failures (recovering-and-continuing from a daslang panic isn't supported, so we // can't catch mid-body and still shut down cleanly). So record_stop + /shutdown always run; // the misses are surfaced after popen drains, below. + g_record_fps = fps // lockstep: gate say/hold_content by captured-frame count + g_stall_warned = false invoke(body, app) let stopped = post_command(app, "record_stop", null) + g_record_fps = 0 let dump = stopped != null ? write_json(stopped) : "" print("[record] record_stop -> {dump}\n") print("[record] APNG saved to {output_apng_path}\n")