perf(render): synchronized output (DEC 2026) across all frame renders - #149
Conversation
c266f71 to
790c265
Compare
Reauthored additively on top of the appearance-diff stack (#146), replacing the earlier #149 which was branched from main and rewrote render.lg back to direct-emit — that would have reverted the pen + appearance buffer. This keeps both wins: fewer escapes (pen/diff) AND atomic frames (sync). Wraps the frames that repaint in succession — render-full, render-dirty, and animate-vfx! (one BSU/ESU pair per animation frame; render-vfx-frame no longer flushes, the loop presents). One-shot modal screens stay on a plain flush: nothing streams after them, so there is nothing to tear. Render-only, so determinism and replay are untouched; the golden tests drive render-map-tile and don't see the frame-level sync bytes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
790c265 to
731167b
Compare
731167b to
426a0af
Compare
|
Reviews complete:
All three PR heads are mergeable and their CI test runs are green. The stack wiring is correct: #145 → #146 → #149. After merging each lower PR, retarget/rebase the next PR onto |
426a0af to
0993a11
Compare
|
Review is valid - only Fixed by hoisting the sync helpers into
One bonus fix en route: the final vfx frame opened a synchronized update but closed with a bare Verified across a full boot-to-game session with the title and descent animations included: begin and end counts match exactly (75/75), strictly alternating, no unpaired begin. Full suite green (288 tests, 2745 assertions). Force-pushed — new head |
|
7bd4e08 to
933504c
Compare
0993a11 to
36ced34
Compare
36ced34 to
99d84e0
Compare
A render pass emits many escapes; without synchronization the terminal can
repaint mid-stream and show a half-drawn frame — tearing. It's worst on frames
that repaint in succession (map scroll, animation, the pulsing title/descent/
death screens) and most visible on xterm.js, where a pan clearly tore. A single
full-screen paint is itself a burst of escapes that can be caught mid-draw, so
every full-screen render benefits. Wrap each in DEC private mode 2026: the
terminal holds the previous frame while we emit, then presents the batch at once.
The sync helpers live in xsofy.screenfx (the shared screen-effects ns that both
render and title already depend on, and which reaches into neither), so every
full-screen emitter shares one implementation:
- game frames — render-full, render-dirty, animate-vfx! (per frame),
render-death-screen, and the six modal screens (inventory, messages, help,
quick-menu, rune-codex, inscribe)
- the animated title (title/draw-frame) and descent screen (per frame)
- screenfx/clear-screen (a full-screen paint on its own)
Only render-hazard-prompt stays on a plain flush — a one-line overlay, not a
frame. A sync-begin! opens each frame and a flush-frame! (ESU + flush) closes it.
Also fixes an unpaired begin: the final vfx frame opened a synchronized update
but closed with a bare flush, leaving the mode on until the terminal's timeout.
Design: mode 2026 is a boolean, not a nesting counter, so only top-level frames
are wrapped (never inner helpers) and multi-frame animations get one pair per
frame. No feature detection (the DECRQM probe is fragile; unsupported terminals
ignore the sequences). sync-output? (default true) is a source opt-out. Render-
only, so determinism/replay are untouched. Verified live: every frame emits one
balanced begin/end pair (BSU == ESU across boot animations, moves, all modal
screens, and combat).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
99d84e0 to
735e277
Compare
|
👍 Re-reviewed after the rebase — the earlier concern is fully addressed. The sync helpers no longer live in
So the three paths called out before (title, descent, Rebased onto latest LGTM. |
|
@nnunley Double checked, this one is now rebased and ready for a final look. |
nnunley
left a comment
There was a problem hiding this comment.
So. I think having something like (with-frame ...) that controls the boundaries of the sync, with a (finally...) block that completes the action might avoid having a stack of accidentally nested syncs, or dangling syncs.
|
Follow-up on the earlier suggestion — the |
|
@nnunley Thanks again for the nudge, it's definitely better. If you're good with it, I'll merge this one after re-approval, and we can continue the discussion and land #174 - curious if you lean a certain way on whether to explicitly handle nesting/depth. (I think we probably should but there are nuances) |
…es (#174) Replaces the hand-written (sfx/sync-begin!) ... (sfx/flush-frame!) pairs at every full-screen frame boundary with a single macro: (defmacro with-frame [& body] `(do (xsofy.screenfx/sync-begin!) (try ~@Body (finally (xsofy.screenfx/flush-frame!))))) Every frame emitter becomes (sfx/with-frame ...): render-full, render-dirty, animate-vfx!, the six modal screens, screenfx/clear-screen, and title's draw-frame + descent loop. Addresses the review note on #149: a with-frame with a finally makes the sync boundary structural instead of a matched pair a future edit can split. #149's own design note admitted a frame that throws before end-sync "leaves the mode on, but the terminal's own 2026 timeout releases it" -- the finally now closes it immediately, no reliance on that timeout, and begin/end can't drift apart since they're generated as one form. Not nesting-safe, by design: mode 2026 is a boolean, so an inner with-frame's finally would close the outer frame early. Preserves #149's "wrap only top-level frames" discipline rather than changing it; documented in the macro's docstring. commit-camera! hoisted just past the frame boundary in render-full/render-dirty (state-only, no terminal output, behavior-preserving). Rebased onto main after #149 landed as a squash merge, which broke the branch's ancestry and required a manual conflict resolution (cherry-pick + reapply) -- verified byte-identical in diff size to the original, full suite re-run clean (307 tests / 2803 assertions, 0 failures). Co-authored-by: Cursor <cursoragent@cursor.com>
What
Wraps every full-screen frame render in DEC private mode 2026 (begin/end synchronized update). The terminal holds the previous frame while we emit, then presents the whole batch at once, so a player never sees a half-drawn frame.
Reference + compat notes: https://github.com/contour-terminal/vt-extensions/blob/master/synchronized-output.md
Stacked on the appearance-diff PR (
perf/render-appearance-diff, #146); review/merge that first. It's additive: #145/#146 cut how much we send, this makes what we send land in one piece. Both wins compose.Why
A render pass emits a lot of escapes. If the terminal repaints partway through, the result is tearing: part of the screen at the new state, part still at the old. It's worst on the frames that repaint in succession — a map scroll, VFX, the pulsing death screen — and most visible on xterm.js (the web build), where a pan clearly tore.
The trap is thinking only those repeating frames tear. A single full-screen paint is itself a burst of escapes, and the terminal can composite it mid-draw — so a one-shot modal that clears and redraws a full inventory can also show a half-built frame. Every full-screen render is a candidate, so every one gets wrapped.
Coverage
The sync helpers live in
xsofy.screenfx— the shared screen-effects namespace that bothrenderandtitlealready depend on and that reaches into neither — so every full-screen emitter shares one implementation rather thanrenderowning it:xsofy.render):render-full,render-dirty,animate-vfx!(per frame),render-death-screen(animated — it pulses per frame through the ui run-loop), and the six modal screens (render-inventory-screen,render-messages-screen,render-help-screen,render-quick-menu,render-rune-codex,render-inscribe-screen)xsofy.title): the animated title (draw-frame) and the descent screen (per frame)screenfx/clear-screen— a full-screen paint on its ownrender-vfx-frameno longer flushes on its own; the animation loop presents each frame. Onlyrender-hazard-promptstays on a plain flush — it's a one-line overlay, not a frame. Async-begin!opens each frame; aflush-frame!(end-sync then flush) closes it.Also fixes an unpaired begin: the final vfx frame opened a synchronized update but closed with a bare flush, leaving the mode set until the terminal's own timeout.
Design notes
animate-vfx!gets one begin/end pair per frame rather than one around the whole loop.CSI ?2026$p) needs a fragile async read at boot, and unsupported terminals ignore the sequences, so worst case is a couple of wasted bytes per frame.sync-output?(default true) is a source opt-out for a terminal that composites 2026 poorly; the web target wants it on.Safety
Render-only, with no effect on the seed, action log, or world state, so determinism and replay are unaffected. The change is confined to frame boundaries; the per-cell render path is untouched, so the golden tests that drive
render-map-tilesee no synchronization bytes. Verified live: every frame emits exactly one balanced begin/end pair (equal counts across moves, all modal screens, and combat), with no unpaired begin that could freeze the display. Escape-sequence guard tests and the existing suite pass.