CPU-rendered windowing with vblank-exact frame pacing and one totally ordered
event stream carrying both input and frame boundaries. Zero dependencies: the
crate speaks the X11 and Wayland wire protocols itself over raw syscalls, the
Objective-C runtime on macOS, and the Win32 DLL exports on Windows, so a Linux
build is a single static binary (cargo build --release →
x86_64-unknown-linux-musl, ~4.5 MB).
Design lifted from lingua_brevis/cōdex/fulcra/unix_future_gui.lbc (the
pre-panel-WM version at commit 0752368), minus the tab WM, audio and gamepads.
Plain polling, no callbacks, no closures:
let mut gui = softer_gui::open("title", "com.example.app", 640, 480).unwrap();
let mut ev = Event::default();
loop {
gui.wait(); // sleeps until the ring has something
while gui.next_event(&mut ev) {
match ev.kind {
EVENT_RENDER => {
// ev.t_fs is DISPLAY time (fs); ev.dt_fs the nominal period; ev.width/height/cursor_x/cursor_y.
let mut fb = gui.get_framebuffer(); // fb.pixels null = backpressure, skip this frame
if fb.ok() { draw(fb.slice(), fb.side, fb.width, fb.height, fb.key); gui.submit(); }
}
EVENT_BUTTONS => { if ev.button(KEY_ESC) { return; } } // absolute 512-bit snapshot, evdev codes
EVENT_TEXT => { for c in ev.text() { /* layout-aware codepoints */ } }
EVENT_AXES => { for a in ev.axes() { /* MOUSE_X/Y abs 24.8, SCROLL_V/H 24.8 px, ZOOM/ROTATE 16.16 */ } }
EVENT_CLOSE => return,
_ => {}
}
}
}Event is one flat struct with a kind tag; only the fields for that kind are set.
- Display time. Every event is stamped with a 128-bit femtosecond clock that
advances only at frame boundaries, in whole refresh periods (X11: Present msc;
Wayland: wp_presentation sequence or one per frame callback; macOS: one per
CVDisplayLink tick; Windows: DWM's
cRefreshvblank counter). The period is queried, never measured (RandR mode rational, wl_output/wp_presentation refresh, CVDisplayLink nominal, DWMrateRefreshrational). Equal timestamps mean the same frame: re-render, do not step. - Framebuffer. Two square power-of-two buffers larger than the window, stride =
side, 0xAARRGGBB.
key = generation<<1 | indexis stable per buffer until a realloc, so a renderer can cache what it drew per key and patch the diff. X11: memfd → MIT-SHM AttachFd → shared pixmap → PresentPixmap. Wayland: memfd pool → wl_shm buffers. macOS: IOSurfaces bound as the layer's contents. Windows is the exception and keeps ONE buffer with the index pinned to 0: every Windows present path copies our pixels out synchronously, so there is nothing to alternate, and alternating would leave the buffer you draw frame N into holding frame N-2, which is a stale-tile trap for an incremental renderer. - Pacing. One present per vblank, self-sustaining: the completion of one frame
is the RENDER for the next. On X11 only a complete with a new msc drives a tick
(a same-vblank COPY or a SKIP does not, or two presents stay in flight and the
swapchain runs at 2× the monitor). When the app skips a frame the chain restarts
on buffer release / input /
request_frame(). - Keyboard. Evdev codes in an absolute bitmap; text from the user's real layout
through our own XKB engine (
xkb.rs: text keymaps from Wayland, XkbGetMap on X11) or UCKeyTranslate on macOS or ToUnicodeEx on Windows; dead keys composed in-house except on macOS and Windows, whose own engines compose them and report the armed state; autorepeat is generated by the pump at the system's rate (server repeat is suppressed). - Pointer. X11 uses XInput 2 (smooth scroll valuators, 2.4 pinch/rotate); Wayland uses wl_pointer axes/value120 and zwp_pointer_gestures; macOS scrollingDelta/magnification/rotation; Windows uses WM_MOUSE* and emits no ZOOM/ROTATE (there is no touchpad-gesture equivalent short of raw HID).
Linux: a pump thread owns the socket's read side, produces every event and runs
the frame clock; the app thread consumes and only writes present/attach requests
through a mutex. macOS: open (called on the main thread) captures the caller's
registers and stack, hands them to a new thread which returns from open as the
app, and keeps the real main thread pumping AppKit on a private stack; the
CVDisplayLink thread is the producer. Windows: a pump thread creates the HWND and
owns the message loop (Win32 message queues are per-thread), a vblank thread does
nothing but wait for the next refresh and signal an event the pump waits on
alongside messages, and the app thread only writes pixels. Every Win32 and GDI
call, the blit included, happens on the pump thread, because a window belongs to
its creating thread and that thread can be parked inside DefWindowProc for the
whole of a modal resize drag. Same polling API on every platform.
- X11: verified on Xorg 60.0010 Hz — contiguous msc, one present per vblank, resize/regrow, text (layout groups, shift, repeat), XI2 scroll, buttons, cursor hide.
- Windows, as an APE: the capable presenter through WARP, 360 of 360 intervals at exactly one period, drift -0.0 ms over 6 s.
- Windows, switching presenters live: F9 cycles GDI, D3D11 hardware and D3D11 WARP with the window up, 545 of 545 intervals at exactly one period across eight switches.
- Windows: two presenters, verified on Windows 11 at 60.000 Hz,
x86_64-pc-windows-msvc. The capable one (D3D11 flip model, 8.1+) held 481 of 481 intervals at exactly one period with +1.7 ms drift over 8 s; the compatible one (GDI, Vista baseline) 480 of 480 at -11 ms. Both pass all 28 checks inexamples/wintest.rs. Display time locked to the display (6 s, 0 dropped vblanks, 0 duplicate timestamps, drift stable rather than accumulating); deliberately stalled frames reported as exactly the right number of whole periods; scancode table, layout text, shift, pointer, wheel, resize/regrow and close all checked byexamples/wintest.rs. Content keeps animating through a border drag: inside a real modal resize loop, where DefWindowProc does not return, a timer drives the clock and delivered 42 RENDERs in 1.2 s with strictly increasing timestamps and no faster than the display. - macOS: verified on macOS 15.7.2 / Apple Silicon, 120.0006 Hz — 17,313 frames
with 0 skipped and 0 dropped vblanks, display time tracking wall clock to
−14 ms over 144 s. The main-thread handoff, the IOSurface flip and the
CVDisplayLink clock all work as written. Fixed while getting there:
period_fs()returned the 60 Hz default until the first display-link callback, so a 120 Hz Mac reported 60 Hz to the app for the first frame. Pointer tracking is verified (the cursor readout follows a warped pointer 1:1); keyboard, scroll, pinch, resize and fullscreen are written but not yet exercised on the Mac, since driving them needs a human at the keyboard. - Wayland: written to the protocol from the brevis reference, not yet run (no compositor on the development machine).
The demo is an application crate in demo/ -- cd demo && cargo run for the
ordinary native build -- and that is where everything cosmopolitan lives.
Nothing to check out and nothing to install:
cd demo
cargo build -F ape --release
sh target/cosmo/release/demo.com # x86-64 + arm64 in one file
That is a Linux command, and it has to be: the APE is linked by a shell script,
so Windows answers %1 is not a valid Win32 application (os error 193) and the
build cannot run there. From Windows, build-ape.cmd at the repo root is the
whole thing in one command, driving the build in WSL; wsl.exe inherits and
translates the working directory, so there is nothing to configure. The result
runs on Windows as it is.
Under WSL, run the .com through cosmocc/bin/ape-$(uname -m).elf rather than
by path: binfmt claims PE images there, so a bare APE starts as a Windows
process and a "Linux" test silently measures the wrong OS.
The library itself has no part in this. It takes no cosmo dependency, exposes
no ape feature and has no build script, so a crate that depends on
softer_gui sees none of it -- nothing in its lockfile, nothing to compile.
The APE is a program and the libc shim has to be referenced from the crate that
is linked, so both belong to the application, which is where any consumer of
this library would put them too.
What the demo carries, and what your own application would:
[package.metadata.cosmo]
bin = "demo" # what the .com is built from
[features]
ape = ["dep:cosmo-build"]
[build-dependencies]
cosmo-build = { version = "5", optional = true }
[target.'cfg(cosmo)'.dependencies]
cosmo-compat = "5"// build.rs
fn main() {
#[cfg(feature = "ape")]
cosmo_build::apeify();
}
// src/main.rs
#[cfg(cosmo)]
extern crate cosmo_compat as _;cosmo-build runs the build once per
architecture and fuses the two with apelink. It installs the nightly it needs
(this repo's exact stable pin governs native builds as before) and downloads
cosmopolitan's toolchain into a cache shared across projects, so the first APE
build is slow and the rest are not. With the feature off none of that is
fetched, compiled or run.
Under cfg(cosmo), which cosmo-build sets, src/sys_cosmo.rs replaces the
raw syscalls with calls into cosmo's libc, so cosmo picks syscall numbers,
struct layouts and constants at load time for whatever host the file lands on;
the X11/Wayland wire code is unchanged. The same cfg pulls in the
cosmo-compat libc shim, which is not
optional -- it reconciles std's strerror_r contract with cosmo's and
translates the OS constants rustc baked in. Verified on Linux/X11 at the same
60.0010 Hz as the native build, 0 dropped vblanks.
That same file runs on Windows, verified on Windows 11: 720 frames, 0 dropped vblanks, 0 duplicate timestamps, 60.000 Hz, −16.7 ms drift held flat over 12 s, which is the native MSVC build's own result on that machine. The APE was built on Linux and copied across.
The shape matches the macOS port exactly: open() asks __hostos, and
src/sys_win.rs declares each Win32 import once through a win32! macro that
emits a real #[link] import in a native build and a cosmo_dlsym'd, cached
wrapper in an APE, so src/win.rs reads the same either way and nothing is
dlopen'd unless the program woke up on Windows.
One hazard has no macOS counterpart and is worth stating on its own: the
calling convention. In a cosmo build the target is linux, so extern "system"
means SysV, with arguments in RDI/RSI and no shadow space. Win32 wants the
Microsoft x64 convention. The call therefore arrives inside user32 reading
whatever happened to be in RCX, and segfaults there while holding a perfectly
valid function pointer, which looks like a bad symbol and is not. Every import,
every GetProcAddress'd pointer, and the window procedure Windows calls back
need extern "win64" on x86-64. aarch64 needs nothing: Windows and Linux share
AAPCS64.
That same file runs on macOS, verified on macOS 15.7.2 / Apple Silicon: 1,200 frames, 0 skipped, 0 dropped vblanks, 120.0006 Hz, −8.4 ms drift over 10 s — the same numbers as the native Mac build.
The backend is chosen at run time from cosmo's __hostos, because an APE says
target_os = "linux" wherever it is running. src/mac_sys.rs holds the macOS
foreign symbols twice: linked normally in a native build, resolved through
cosmo_dlopen/cosmo_dlsym/cosmo_dltramp in an APE, behind one
function-shaped API so src/mac.rs reads the same either way. Nothing is
dlopen'd unless the program woke up on XNU, so the Linux path is untouched.
Two things differ inside the APE, both forced by what cosmo can and cannot do across a foreign thread:
- The display-link callback only counts. CoreVideo calls it on a thread AppKit created, where cosmo-compiled code has no cosmo thread state, so the callback is one relaxed atomic increment and a cosmo-owned thread does the production. The vblank count stays exact; only the wake-up costs a sleep.
- Both cosmo threads are created before AppKit is activated. Creating one afterwards corrupts the creator (see cargo_cosmo's README — it is a cosmo bug, not an ordering preference), so they are started early and parked on an atomic until there is work.
cargo cosmo overrides codegen-units and lto for APE builds; the release
profile here sets both to values that break threads under cosmo. Native builds
keep the profile as written.
Every program here takes the same flags: --debug traces what the backend chose
and how it is pacing, --fullscreen starts borderless fullscreen, --x11,
--gdi and --d3d pick a backend, --warp and --hardware pick a D3D11 device.
They are flags rather than environment variables, which needed one thing from the
library: it cannot read a command line. Reading the program's own argv is not a
library's business, so open_with(title, app_id, w, h, Options) takes the
choices and the program decides where they come from. Plain open() still uses
Options::from_env(), which honours the old SOFTER_GUI_* variables, because a
program with no flags to offer still needs some way to be told.
examples/xtest.rs drives a window with XTEST for headless testing;
examples/wintest.rs and examples/winpace.rs are the Windows equivalents and
drive themselves.
.cargo/config.toml makes musl the default target, so Windows builds name theirs:
cargo build --release --target x86_64-pc-windows-msvc
There are two Windows presenters in one binary, picked at run time. They are not a fast path and a slow path; they are a compatible one and a capable one, tuned separately, because the interesting APIs and the wide install base do not overlap.
| compatible | capable | |
|---|---|---|
| floor | Vista | Windows 8.1 |
| pixels | CreateDIBSection + BitBlt |
D3D11 texture into a DXGI flip-model swapchain |
| copies to screen | via DWM's redirection surface | straight to DWM, no redirection copy |
| "render now" signal | vblank thread (DwmFlush) |
the swapchain's waitable object |
| threads | pump + vblank + app | pump + app |
Core::in_flight |
inert | real, from PresentCount |
--gdi forces the compatible one, which is how the Vista-era code stays testable
on a machine that is not Vista; --d3d asks for the capable one.
F9 cycles between them while the window stays up: GDI, then D3D11 on the
hardware driver, then D3D11 on WARP. Both presenters draw from the same CPU
buffer, so nothing about the framebuffer changes and the app never learns it
happened; only the route those pixels take to the screen does, and what the pump
waits on. It is Gui::cycle_backend(), and winpace --cycle switches every
second so the histogram can be checked across the switches: measured, 545 of 545
intervals stayed at exactly one refresh period through eight of them.
The one thing that does not survive a switch is the old presenter, and it has to go first: DXGI will not create a second swapchain for a window that already has one, so building the replacement before releasing the incumbent makes every switch to D3D fail and the cycle quietly degenerates to GDI and back. Left alone, the capable one is tried and any failure at all falls back, because every way it can fail has the same answer.
Measured on Windows 11 at 60 Hz, 8 s, winpace:
- capable: 481 intervals, every one exactly one period, drift +1.7 ms.
- compatible: 480 intervals, every one exactly one period, drift −11 ms.
- both report a deliberately stalled frame as exactly the periods it cost.
Why 8.1 and not 8. Flip model and GetFrameStatistics are Windows 8, but
IDXGISwapChain2::GetFrameLatencyWaitableObject is 8.1, and that wait is the
whole reason to go there: it means "start now to land on the next vblank" rather
than "a vblank happened". 8.0 without 8.1 is a rounding error of an install base,
so the extra floor costs nothing and buys the primitive this crate exists for.
The part that is not obvious, and cost a measurement. The capable path looks
like it should take its clock from the swapchain, since PresentRefreshCount is a
true per-present MSC. It cannot: DXGI updates those counters in bursts, sitting
still for about sixty calls and then jumping sixty at once. Pacing off that delta
ticks 1 while it is stalled and then 60 in one go, which ran display time 931 ms
ahead of the wall clock over eight seconds while every individual frame looked
fine. So each source is used for what it is good at. The swapchain gives the WAIT,
which DWM cannot, and the count of presents still in flight. DWM's cRefresh
gives the CLOCK, live every frame. What the swapchain measures stays the fallback
for a host with no composition.
That split is also what makes this path the closest thing on Windows to the X11
backend: the waitable object is PresentCompleteNotify's "ready for another", the
refresh counter is its msc, and PresentCount is what makes Core::in_flight
mean the same thing it means on Linux.
The compatible path keeps the whole argument for GDI: the display clock does not
have to come from the presentation path. GDI reports no timing at any Windows
version, but DwmGetCompositionTimingInfo (Vista and up) hands out an exact
refresh rational and cRefresh. See the headers of src/win.rs and
src/win_d3d.rs for the rest, including the fallbacks and known limitations.
A cosmopolitan APE gets the capable presenter too, but asks for WARP rather than
the vendor driver. From an APE, D3D11CreateDevice with
D3D_DRIVER_TYPE_HARDWARE raises STATUS_BREAKPOINT and never returns, with the
library loaded, the entry point resolved and the ABI correct; the same call with
D3D_DRIVER_TYPE_WARP succeeds and runs the whole flip-model path. So it is the
vendor user-mode driver's initialisation that objects to something about a cosmo
process, not D3D11 and not cosmo's loader, and native builds are unaffected.
The ordinary hardware-then-WARP fallback cannot help, because the failure is a
crash and not an error return, so an APE never asks for hardware at all.
--hardware retests it on other GPUs; measured here on NVIDIA.
It costs nothing worth having, and that is measured rather than assumed. The GPU work in this crate is one copy per frame, not a scene, so a software rasteriser is not doing less of anything. Pacing on the APE: 360 of 360 intervals at exactly one period, drift -0.0 ms over 6 s.
Latency is a separate question, because pacing does not imply it: a gap histogram
says no frame was dropped and says nothing about how many vblanks a frame sat in
the pipeline first. Measured through DXGI's PresentRefreshCount against the
SyncQPCTime/SyncRefreshCount pair, three runs of 12 s each at 60 Hz:
| mean submit-to-scanout | |
|---|---|
| native, hardware driver | 32.55 ms |
| native, WARP | 32.74 ms |
| APE, WARP | 33.02 ms |
So an APE with WARP costs about 0.5 ms against the best native path, three per cent of one refresh period, and WARP against hardware is 0.2 ms of that. All three sit near two refresh periods, which is what a flip-model present with one frame of allowed latency looks like.
Two caveats. One early hardware run measured 17.6 ms, a single period, and never
reproduced; do not read it as a hardware advantage. And this is submit to
scanout, not input to photons, which also includes the frame the app spends
drawing. --debug prints it, so the same question can be asked on AMD and
Intel.
Those numbers are all windowed, and windowed is the expensive case. A windowed flip-model present goes to DWM, DWM composites it, and the composited result scans out at the FOLLOWING vblank; that second frame is the compositor, not this crate. Cover the screen and DWM can hand the swapchain straight to the display controller instead (independent flip), and the frame disappears.
--fullscreen makes the demo and winpace start borderless fullscreen, which is
the same thing F11 does, so the difference is measurable:
| windowed | fullscreen | |
|---|---|---|
| native, hardware driver | 32.55 ms | 9.6 ms or ~26 ms, see below |
| native, WARP | 32.74 ms | 25.7 ms |
| APE, WARP | 32.65 ms | 28.1 ms |
That fullscreen figure is the good case, and it is not reliable. Three identical runs measured 26.0 ms, 9.55 ms and 26.8 ms: independent flip is DWM's decision, not the program's, and it is granted only when nothing else needs compositing over the window. When it is granted the frame goes almost straight out, best sample 3.3 ms; when it is not, fullscreen is worth about 6 ms rather than 23. Expect the good case on an otherwise idle desktop and neither expect nor promise it in general.
There is a tell, if you are measuring: the granted case reports statistics about five times more often, a few hundred samples in six seconds against a hundred or so, because presents are completing per frame rather than in batches.
WARP gets a smaller share of fullscreen either way, which fits what WARP is: it rasterises into system memory, so something still has to move the result somewhere the display controller can scan out, and that is the part independent flip would otherwise have removed. An APE is inside 2 ms of native WARP.
So the order of the levers is: go fullscreen, and use a hardware driver if you can. An APE cannot (see above), which puts its floor at WARP's.
Below that there is only tearing, DXGI_PRESENT_ALLOW_TEARING with
Present(0, ...), which would go sub-frame and is not implemented: it would
break the property the whole crate is built on, that display time advances in
whole refresh periods.
The backend itself is written to a Vista baseline: everything in a #[link]
block predates Vista, and every newer API (DWM, D3DKMT, the three generations of
DPI-awareness call, GetDpiForWindow) is resolved with GetProcAddress and has a
fallback, so a missing one degrades behaviour instead of stopping the process from
loading. dwmapi.dll does not appear in the import table.
The floor is set by Rust's std, not by this crate. A stock
x86_64-pc-windows-msvc binary imports WaitOnAddress / WakeByAddress* from
api-ms-win-core-synch-l1-2-0.dll, which is Windows 8 and up, so it fails to load
on Vista or 7 before any of our code runs. To go lower, build against the tier-3
Windows 7 target, which needs a nightly toolchain and build-std:
cargo +nightly build -Z build-std=std,panic_abort --release --target x86_64-win7-windows-msvc
That was built and its import table checked: the synch API set is gone, and with
RUSTFLAGS="-C target-feature=+crt-static" the binary imports only KERNEL32,
USER32, GDI32 and ntdll, with no VCRUNTIME and no UCRT, in ~400 KB. Every function
it names exists on Vista (the oldest are the SRW lock and condition-variable
calls, which are Vista's). So the import table resolves on Vista, which is
necessary but not sufficient: nothing has been run on a pre-Windows-10 machine, so
treat Vista and 7 as untested rather than supported.