An open runtime feature stack for GPUs on Linux — the part of AMD Software: Adrenalin Edition that Linux does not have.
Radiant is a Vulkan layer that applies spatial upscaling to any game, native or Proton, with no wrapper process, no nested compositor, and no launch options, driven by per-application profiles from a daemon.
Working name. Placeholder until something better turns up.
GPU tuning on Linux is solved — LACT and CoreCtrl cover clocks, voltage, fan curves and power limits. What does not exist is the half of Adrenalin that sits in the game's present path: Radeon Super Resolution, Chill, Anti-Lag, per-app profiles.
The nearest equivalents each fall short in a different way. gamescope is a nested compositor you wrap a game in. Proton's fullscreen hack is Proton-only and is being retired. vkBasalt sharpens but does not upscale. OptiScaler drops files into game directories. LSFG-VK has the right architecture but depends on a proprietary Windows DLL.
Radiant's thesis: the interception layer is the product; the control panel is a client of it.
Design rationale, decisions and risks are in DESIGN.md. Test procedures and
the findings that shaped the implementation are in TESTING.md.
Working, and verified on AMD RDNA 4 (RX 9070 XT, RADV, Mesa 26.1) under both X11 and Wayland.
About 4,100 lines of Rust and 105 of GLSL wrapping AMD's FidelityFX headers.
Zero dependencies outside ash, and no systemd requirement. The layer
loads into every Vulkan process on the machine, and the daemon will eventually
run as root on systems that may have no systemd, no dbus and no polkit — so
both are kept dependency-free deliberately. They link nothing but libc, not
even libvulkan, since a layer receives its function pointers through the loader
chain:
libradiant_layer.so -> libc, libm, libgcc_s
radiantd -> libc, libgcc_s
The control panel takes a GTK4 dependency, and it is the only component that does.
Both properties are enforced rather than intended: scripts/check-deps.sh
fails the build if either binary links beyond the C runtime, and a CI job
builds and tests the daemon in a container with no systemd present.
FSR 1 at 2560×1412 output on an RX 9070 XT: 0.245 ms GPU, which extrapolates to roughly 0.55 ms at 4K. The linear blit is 0.008 ms at 500×500 against FSR's 0.028 ms at the same size.
Everything measured so far comes from vkcube — three flat faces and one
texture, which flatters every upscaler and hides most settings. No real game,
no Proton title, and no anti-cheat title has been run. DESIGN.md §6 tracks
that as an outstanding risk rather than an oversight.
- Steam games are not covered yet. The layer works on anything launched
directly, including native Linux titles started from a terminal — verified
end to end, with settings applied and the process tracked. Steam runs games
inside pressure-vessel, which imports host Vulkan layers into its container
on its own terms. Tracked as M7;
DESIGN.md§13 records what has been ruled out. - Windowed resize on Wayland is not tracked. Wayland does not state a surface size, so once the layer offers a reduced extent the application's requests no longer reveal the window size. Fullscreen is correct; X11 is correct. Fixing the windowed case needs compositor cooperation.
- BGRA and sRGB swapchains take a slower path. RCAS cannot write them directly, so it writes an intermediate that is then copied — one extra full-resolution copy.
- No fp16 shader path. Measured and deliberately skipped: it would save ~0.25 ms at 4K in exchange for shader variants, device feature injection and a hardware support matrix. Revisit when something else needs that machinery.
- Settings resolve once per process. Changing a profile takes effect on the next launch.
| Milestone | Delivered | |
|---|---|---|
| M0 | Layer skeleton | Loader negotiation, vkGetPhysicalDeviceProcAddr, instance and device dispatch chaining, process denylist, three-symbol export gate, leave-no-trace CI check |
| M1 | Swapchain interposition | Proxy images with 1:1 index mapping, surface-capability spoofing, present rewriting, layout transitions, SetDeviceLoaderData, per-swapchain fences, device-owned command pools, linear blit |
| M2 | Upscaler backends | Upscaler vtable, passthrough and FSR 1 (EASU + RCAS compute), direct and blit output paths, mip LOD bias injection, diagnostic overlays, frame timing and GPU cost measurement |
| M3 | Daemon and profiles | radiantd, query socket, pull-based activation, per-game and global profiles with a shared option surface, resolution order, filename scheme, config validation, display extent from DRM connector state |
| M4 | radiantctl |
Profile management and diagnostics from a terminal — list, show, set, export, import, status, reload, doctor. Edits preserve comments and layout |
| M6 | GUI | GTK4 / libadwaita control panel — game library with generated cover art, per-game and global settings pages, every schema key as a control, apply-timing on every row, reasons on every disabled one, saves through the same in-place editor radiantctl uses |
| M5 | Lifecycle tracking | SO_PEERCRED process identification, pidfd exit detection with a /proc fallback, refcounting across launcher and render processes, revert on daemon shutdown, running list in status and doctor, GpuTuning trait with a logging no-op implementation |
Nothing. M6 is the current head, and M7 — Steam support — is the next thing worth doing: scanning Steam's library matters much less while Steam's games cannot be reached. Twelve tests cover the config surface and the in-place editor, because the GUI will be writing these files and any ambiguity becomes a support burden rather than a bug report.
| Milestone | Scope | Blocks | |
|---|---|---|---|
| M8 | Game library | Steam library scanning with no dependencies, real cover art from Steam's local cache, installed games shown whether or not they have a profile, learned executables, and a Create profile button | |
| M7 | Steam support | Get the layer reliably into games launched through Steam. It works on anything launched directly, including native Linux titles; pressure-vessel is the obstacle. See DESIGN.md §13 for what has been established and what has not |
— |
| M8b | Lutris and Heroic | Their libraries live in SQLite and JSON respectively; Steam's are plain text, which is why it went first | M8 |
| M9 | More spatial backends | NIS, integer, Lanczos behind the existing vtable | — |
| M10 | FSR 3.1 / FSR 4 support | Capability probe, integration detection, middleware management, version matrix, honest status, anti-cheat warnings | M6 |
| M11 | FFX API interposer | Hook the FidelityFX Vulkan calls native games already make, and redirect them | M10 |
| M12 | Control socket and GPU tuning | Privileged second socket, LACT fork for clocks, power limits and fan curves, SO_PEERCRED auth with polkit where available |
M5, M6 |
It is the only part of this project that cannot be developed or reviewed without the hardware in front of it, and the only part where being wrong costs more than a corrupted frame. A fan curve that does not revert, or a voltage offset left applied after a game exits, is hardware misbehaving after the software has stopped — a different category of bug from anything the layer can produce.
So it goes last, and everything before it is built to receive it:
| Milestone | What it must already carry |
|---|---|
| M3 ✅ | [tuning] parses and validates in both global and per-game profiles, is reported at startup as requested-but-unapplied, and never crosses the query socket the layer reads |
| M4 ✅ | radiantctl set <profile> tuning.power_cap_w=260 already works; show already prints tuning keys and says they are not applied |
| M5 ✅ | Start and exit events are correct and refcounted with nothing attached, and GpuTuning exists with a no-op implementation that logs what it would do |
| M6 ✅ | A GPU tuning section in both the per-game and global pages, rendering the keys already in the schema, disabled and carrying the reason — the same mechanism that greys out FSR 4 |
By the time M11 starts, the schema, the CLI, the UI and the lifecycle hooks are all in place and tested. What remains is implementing one trait against sysfs, which is the part that needs a GPU on the desk.
M5 covers the polite exits — it cannot cover SIGKILL, a segfault or a power
cut. Those run no handler, and whatever was written to sysfs stays written.
The fix is state on disk written before acting and replayed at startup, not a
better signal handler. DESIGN.md §12 records that and the other tuning safety
requirements, while tuning is still a no-op and the constraints can be designed
for rather than retrofitted.
Not scheduled; done when something else justifies the machinery or a measurement changes.
| Item | Why it is waiting |
|---|---|
| fp16 shader path | Measured at ~0.25 ms saved at 4K. Needs shader variants, device feature injection and a hardware matrix — build it when something else needs that machinery |
| BGRA direct write | Needs VK_KHR_swapchain_mutable_format and a swizzled store, to save one full-resolution copy |
| sRGB correctness | FSR 1 wants perceptual-space input; an _SRGB swapchain defeats it on sample. Needs a mutable-format proxy view |
| Chill, Anti-Lag | Mesa now ships VK_LAYER_MESA_anti_lag; the job may be to expose it rather than build it |
| Windowed Wayland resize | Needs compositor cooperation. Fullscreen and X11 are already correct |
- Reimplementing GPU tuning from scratch — LACT already carries the per-generation quirk knowledge.
- A compositor. gamescope remains the right answer for HDR, VRR and compositor-side scaling.
- Native FSR 4 — RDNA 4 only, DirectX 12 only, and AMD has not committed to a Vulkan backend.
- Frame generation.
The version numbers imply a ladder. They are not one.
FSR 1 is spatial. Its input is a single colour image, which is why it can run at present time and work on any game ever shipped. That is also why Radeon Super Resolution is FSR 1 and stays FSR 1 — not because AMD got lazy, but because it is the only thing the interception point can support.
FSR 2, 3 and 4 are temporal. Per frame they need depth, motion vectors, the
sub-pixel jitter the camera was rendered with, exposure, a reactive mask, and
colour before UI compositing. None of that exists at vkQueuePresentKHR —
there is one flattened backbuffer with the HUD already burned in. No amount of
layer engineering conjures motion vectors out of it.
So a layer cannot implement them, and neither does AMD. Adrenalin's FSR 4 toggle is a backend swap: it intercepts games that already integrate FSR 3.1 through the FidelityFX API and routes those calls to a newer model. The game still does all the temporal work. OptiScaler is the third-party version of the same idea.
Yes — but it does something different from the FSR 1 setting, and the difference is worth being blunt about.
FSR 1: Radiant does the upscaling. It works on every game, always, with no cooperation from the game at all. That is the whole point of the layer.
FSR 2, 3.1 and 4: Radiant does not upscale anything. It sets the game up to use a better upscaler the game already supports. If the game has no such support, the option cannot work and will say so.
In the control panel that looks like one dropdown where most entries are usually unavailable, each with a reason:
Upscaler ( FSR 1 — Radiant ▾ )
FSR 1 — Radiant always available
FSR 3.1 unavailable: game has no FSR 3.1 support
FSR 4 unavailable: needs a Radeon RX 9000 series
DLSS → FSR 3.1 unavailable: game has no DLSS support
For a game that does ship FSR 3.1 — a growing number do — picking FSR 4 gets you genuine temporal upscaling, far better than FSR 1. Radiant's job there is detecting the integration, checking your GPU and driver versions, placing and configuring the middleware, and telling you plainly when it cannot work. Today users do all of that by hand from forum posts.
The Upscaler trait already declares required_inputs and hw_requirements
for exactly this reason. A temporal backend declares that it needs depth,
motion vectors and jitter, and the profile engine refuses to offer it without
an input provider — so the UI greys the option out with a stated reason
rather than silently substituting something worse. Quietly falling back to FSR
1 when a user picked FSR 4 would be the worst possible behaviour.
Native FSR 4 implemented by Radiant itself is not planned: it is RDNA 4 only, DirectX 12 only, and AMD has not committed to a Vulkan backend.
- A Vulkan 1.1+ driver. Developed against RADV; the layer itself is vendor-neutral and contains no AMD-specific code.
vulkan-icd-loader1.3.261 or newer forVK_ADD_IMPLICIT_LAYER_PATH. Older loaders work viaXDG_DATA_DIRS, whichscripts/dev-env.shalso sets.
- Rust 1.75+ and cargo.
- glslangValidator or glslang — shaders are compiled to SPIR-V at build time and embedded, so no shader compiler is linked into the layer itself.
# Arch
sudo pacman -S --needed rust glslang vulkan-icd-loader vulkan-tools gtk4 libadwaita
# Debian / Ubuntu
sudo apt install rustc cargo glslang-tools libvulkan1 vulkan-tools \
libgtk-4-dev libadwaita-1-dev
# Fedora
sudo dnf install rust cargo glslang vulkan-loader vulkan-tools \
gtk4-devel libadwaita-develvulkan-tools provides vkcube and vulkaninfo, which every procedure in
TESTING.md uses. GTK and libadwaita are needed only for the control panel;
the layer, daemon and CLI build without them.
Optional but recommended while developing: vulkan-validation-layers. Several
real bugs in this project were caught only by validation and tolerated silently
by the driver.
git clone <repo> radiant && cd radiant
cargo build
./scripts/check-symbols.shcheck-symbols.sh is not politeness. A Rust cdylib will otherwise export std
symbols into processes that have their own allocator and their own Vulkan
symbols, and the failure mode is a crash in an unrelated application. The layer
must export exactly three symbols and CI should enforce it.
find crates -name '*.rs' -exec touch {} +
cargo buildArchive members carry the timestamps they were packed with, which can be older
than the last build. Cargo compares mtimes, quite reasonably concludes nothing
changed, and skips the crate — so you run the previous binary while looking at
the new source. The symptom is cargo build recompiling some crates and not
the one you just replaced.
git pull sets mtimes correctly, so this only applies to tarballs and zips.
cargo clean -p <crate> is the targeted alternative.
During development nothing is installed and nothing needs root. That is a
property of the developer workflow, not of the finished product — a packaged
install will place a layer manifest in /usr/share/vulkan/implicit_layer.d,
binaries in /usr/bin, and a service definition, and the daemon will need
privileges once GPU tuning arrives. DESIGN.md §10.6 enumerates every path a
real install will touch, so uninstall is a known quantity rather than
archaeology.
The development workflow deliberately stays out of all of it, because an implicit Vulkan layer loads into every Vulkan process on the machine — including the compositor. A half-built layer that installs system-wide costs you a login session, not a test run.
mkdir -p ~/.config/radiant/profiles
cp examples/config/global.toml ~/.config/radiant/
cp examples/config/profiles/*.toml ~/.config/radiant/profiles/
./target/debug/radiantd & # foreground, unprivileged
eval "$(scripts/dev-env.sh)" # stages the layer under .devroot/
vkcubedev-env.sh writes a manifest into .devroot/ (gitignored) and prints the
environment to use it. /usr, /etc and the real ~/.local/share are never
touched; close the shell to undo it. scripts/leave-no-trace.sh enforces that
property rather than leaving it to good intentions.
Without a profile, or without the daemon running, the layer is inert and the application behaves exactly as if it were not installed.
Every setting also has an environment variable, and the environment always wins:
RADIANT_SCALE=0.5 RADIANT_BACKEND=fsr1 vkcubeThe development workflow above installs nothing, which is right for development and wrong for Steam: games run inside the Steam Linux Runtime container, which imports implicit layers from system paths only.
sudo -E scripts/install.sh
scripts/uninstall.shDESIGN.md §10.6 enumerates every path this touches.
RADIANT_DISABLE=1 |
loader skips the layer; never dlopened |
VK_LOADER_LAYERS_DISABLE=VK_LAYER_RADIANT_upscale |
loader-level, by name |
VK_LOADER_LAYERS_DISABLE=~implicit~ |
recovery — disables every implicit layer |
rm -rf .devroot |
gone (development staging only) |
The third is the one to remember from a TTY if a future change ever wedges a session.
One file per game in ~/.config/radiant/profiles/, plus a global.toml for
everything else.
Two ways a profile gets there, with different owners:
- Written by the control panel. Saved as
<game-id>.toml, derived from the canonical game id with:replaced —steam-1091500.toml,lutris-cyberpunk-2077.toml,exe-a1b2c3d4e5f6a7b8.toml. - Written by hand, or shared by someone else. Any other filename. Drop it
in
profiles/and it is picked up on the nextreloador daemon start.
Keeping generated names in one predictable shape is what stops the two colliding: the control panel only ever writes files it can name itself, so it will never overwrite something a user placed by hand.
| Rule | |
|---|---|
| Extension | .toml only. Anything else is ignored, so renaming to .toml.off disables a profile without deleting it |
| Matching | comes from the match key inside the file, never from the filename |
| Display name | the optional name key, falling back to the filename |
| Load order | lexical by filename |
| Collisions | two profiles claiming the same executable is reported at startup, naming both files; the lexically last one wins |
Note that digits sort before letters, so a 50- style prefix makes a file lose
to an unprefixed one rather than win. There is deliberately no priority
mechanism: if two profiles claim the same game, the answer is to fix one of
them, and the startup warning tells you which two to look at.
A profile is self-contained, which is what makes it shareable — nothing it needs lives anywhere else.
name = "Cyberpunk 2077" # display only; optional
match = "Cyberpunk2077.exe" # what this actually applies to
[upscale]
enabled = true
backend = "fsr1"
scale = 0.667
sharpness = 0.3Unrecognised keys are reported rather than ignored. A silently swallowed
sharpnes = 0.3 is exactly the sort of thing that costs somebody an evening,
and hand-written and shared files are where it will happen.
running application
|
has a matching profile? ----yes----> use that profile
|
no
|
is global enabled? --------yes----> use global settings
|
no
|
layer stays inert
Three points that follow from it:
- A profile is self-contained. It does not inherit from global. What the file says is what the game gets; unset keys take documented defaults, not global's values. Either the profile applies or global does, never a blend.
- A profile with
enabled = falsestops at step one. It does not fall through to global — otherwise there would be no way to exclude a single game from a global setting. - Writing a profile is a statement of intent, so its presence enables it.
Omit
enabledand it is on; setenabled = falseto turn a game off explicitly.
Global is disabled by default. Silently changing the resolution of every application on the machine is not a reasonable thing to do without being asked.
match = "vkcube, /usr/bin/vkcube" # basenames or absolute paths; longest wins
[upscale]
enabled = true
backend = "fsr1" # passthrough | fsr1
scale = 0.5 # render resolution as a fraction of the display
sharpness = 0.2 # RCAS, in stops. 0.0 is maximum
mip_bias = "auto" # auto | off | <float>
[display]
extent = "auto" # auto (daemon reads DRM) | WxH
[debug]
overlay = "off" # off | split | rcas | rcasdiff
stats = false # frame timing and GPU cost, logged every 2sEvery key is intended to be a control in a UI. Nothing exists only as a developer environment variable, because that is how features end up undiscoverable and impossible to expose.
Clocks, power limits and fan curves belong in the same profile as the upscaler settings — one file, one page in the UI, one thing to reason about:
[tuning]
power_profile = "3d_fullscreen"
power_cap_w = 260
gpu_clock_max = 2800
voltage_offset = -50
fan_curve = "quiet"Applied when the application starts and reverted when it exits. The layer's own announcement is the "started" signal — it is exact, it carries the pid, and it fires only for processes that actually create a Vulkan instance, which is a better trigger than watching every exec on the machine.
Three deliberate decisions here:
- Keys are passed through to the tuning backend rather than re-invented.
They follow LACT's naming, because LACT already carries the per-generation
quirk knowledge — kernel version floors for RDNA 4 overclocking, zero-RPM fan
configuration,
ppfeaturemaskbootstrapping — and duplicating that is a mistake. Radiant parses[tuning]as an unstructured map and validates it against the backend's declared capabilities. - Tuning never crosses the query socket. That socket is read by the layer,
which runs inside untrusted game processes. It has no business knowing the
fan curve, let alone being able to read one. Tuning stays daemon-internal and
will be written through a separate, privileged control socket. There is a
unit test asserting nothing from
[tuning]leaks into what the layer is told. - Requested-but-unimplemented is reported, not ignored. The daemon parses
[tuning]today and says so at startup — "profile 'x' requests tuning (3 keys); not applied until the control socket exists" — rather than reading a config key and silently doing nothing with it.
debug.overlay |
Shows |
|---|---|
split |
upscaled left half, plain linear blit right half, same frame |
rcas |
upscaled left half, EASU output right half — isolates sharpening |
rcasdiff |
the amplified difference RCAS made; black means it did nothing |
debug.stats = true logs frame time and a GPU timestamp bracketing the upscale
every two seconds. The absolute millisecond figure is the number that matters;
the percentage is meaningless in an uncapped or vsync-pinned application.
Poke the daemon directly when a profile does not seem to apply:
printf 'query /usr/bin/vkcube\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/radiant/query.sock
printf 'reload\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/radiant/query.sockcrates/radiant-layer/ Vulkan implicit layer (cdylib)
src/loader.rs vk_layer.h bindings — not in any binding generator
src/dispatch.rs instance/device tables, keyed on the dispatch pointer
src/swapchain.rs proxy images, present rewriting, fences
src/backends/ the upscaler vtable
src/config.rs profile pull from the daemon
shaders/ GLSL wrappers + AMD's FidelityFX headers (MIT)
CONTRIBUTING.md invariants, principles and gotchas
crates/radiant-gui/ radiant — GTK4 control panel
crates/radiant-cli/ radiantctl — profile management and diagnostics
crates/radiant-daemon/ lib: config schema, paths and system probing
bin: radiantd — profile store, query socket
src/config.rs profile parsing, resolution order, validation
src/display.rs display extent from /sys/class/drm
scripts/ dev environment, symbol gate, leave-no-trace check
examples/config/ sample profiles
.github/workflows/ build, test, fmt, clippy, symbol gate, dependency gate,
and a job proving the daemon works with no systemd
FSR 3.1 and FSR 4 already work on Linux today, for games that integrate them. They are just unusable: buried under Proton, VKD3D and Mesa version pinning, DLL placement, prefix settings and per-game workarounds gathered from forum posts. Turning that into a dropdown is a real project, and it decomposes into six pieces — none of which involve writing an upscaler.
- Capability probe. GPU generation (RDNA 4 for FSR 4, via device ID and
VK_KHR_cooperative_matrix), Mesa version, installed Proton versions, VKD3D version. Runtime and data-driven, because this matrix moves monthly. - Integration detection. Whether a given game already ships an upscaler
interface at all — FidelityFX,
nvngx, XeSS — by file detection in the game directory, backed by a known-titles list. Without an existing integration there is nothing to swap. - Middleware management. Installing, versioning and wiring OptiScaler or equivalent: file placement, config generation, environment. The unglamorous part, and the part users currently do by hand from forum posts.
- Version matrix resolution. Turning "this game, this GPU, this Mesa, this Proton" into either a working configuration or a specific reason it will not work.
- Honest status reporting. Per game and per tier: available, or unavailable
with a stated reason — "needs RDNA 4", "game has no FSR 3.1 integration",
"requires Proton 11+". The
Upscalertrait'srequired_inputsandhw_requirementsexist for exactly this. - Anti-cheat warnings. OptiScaler's own documentation warns against using it in online titles. That warning belongs in front of the user at the point of enabling, not in a README.
The one piece that is layer work is a FidelityFX API interposer for native Linux games that ship FSR 2 or 3.1 through the FFX Vulkan backend — hook the calls the game already makes and redirect them to a different implementation. The install base for that is currently tiny, which is why it is not first.
Project code: MIT.
crates/radiant-layer/shaders/vendor/ffx_a.h and ffx_fsr1.h are AMD
FidelityFX, MIT, copyright Advanced Micro Devices, Inc. Unmodified.