Skip to content

Repository files navigation

softer_gui

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 --releasex86_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.

Model

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 cRefresh vblank counter). The period is queried, never measured (RandR mode rational, wl_output/wp_presentation refresh, CVDisplayLink nominal, DWM rateRefresh rational). 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 | index is 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).

Threads

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.

Status

  • 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 in examples/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 by examples/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).

One universal binary (Cosmopolitan APE)

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.

Windows

.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.

Getting the latency down

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.

How far down it actually runs

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages