Steam Linux Runtime Stack

8/10/2026, 11:06:39 PM

Valve (and others) seems to be maintaining the most successful, long-term stable runtime for Linux in Steam, Proton, Wine, etc. I don't mind the runtime being a clone of Windows; I'm interested in long-term binary compatibility guarantees that stack offers developers. Assuming I don't need 3D graphics - am focusing on a simple GUI app written in C or Zig - what exactly is the shape of the Steam runtime stack, how would I integrate and build for it, and how would that run on users' machines?

I’ll map this from the developer’s perspective: what the runtime actually is, what ABI surface you target, the build/container story, and what happens on the user’s Steam client at launch.

The shape is roughly: Steam gives you a pinned Linux userland ABI, not a new OS API. You still run on the user’s Linux kernel, drivers, display server, filesystem, input stack, etc.; but your process sees a Valve-curated library set instead of “whatever happens to be installed on Arch/Fedora/Ubuntu this week”.

As of now, for a new native Linux title/app, the target to look at is Steam Linux Runtime 4.0 / steamrt4. Valve’s own README says Steam Runtime 4 is the recommended environment for new native Linux games/apps, while Steam Runtime 3 “sniper” is the older container runtime and Steam Runtime 1 “scout” is the legacy compatibility target. [GitHub]

1. The stack, from bottom to top

Think of it like this:

text
User machine
  Linux kernel
  host GPU/audio/input/display stack
  host Steam client
    Steam app depot: your program + your bundled assets/libs
    Steam Linux Runtime tool app, e.g. steamrt4
      pressure-vessel container launcher
      pinned runtime rootfs / library set
        your executable

The important components are:

Steam client

The ordinary Linux Steam client installed on the user’s distro.

Steam Linux Runtime

A versioned runtime library stack. Valve currently exposes several runtime lines: scout, soldier, sniper, and steamrt4. The Steam Runtime repo describes these as binary-compatible runtime environments for Steam applications on Linux. [GitHub]

pressure-vessel

This is the container launcher. Valve’s docs describe the Steam Linux container runtime as consisting of pressure-vessel, plus a runtime that provides libraries for games/apps to use. [GitHub]

SDK container

This is what you build inside. The SDK is not what users run; it is your build sysroot/toolchain environment. Valve explicitly recommends building inside the Steam Runtime container so libraries from your dev machine do not “leak” into the shipped binary. They mention Toolbx, Distrobox, rootless Podman, and Docker as suitable ways to use the official SDK OCI images. [GitHub]

Your app depot

Your executable, assets, and any libraries you bundle yourself. At launch, Steam combines your depot with the selected runtime.

2. What compatibility guarantee you are really getting

The guarantee is not “Linux ABI is stable forever”. It is more like:

“If you build against this Steam Runtime SDK and configure your Steam app to run under the matching Steam Linux Runtime, Valve will provide that runtime to users and run your app inside it.”

Steamworks docs say native Linux titles may select a specific Steam Linux runtime per branch, and the selected runtime needs to match the SDK used to compile your executables. [Steamworks]

That gives you a Windows-like redistributable runtime model:

text
Build against known SDK → ship binary → user gets matching runtime via Steam

But it is not identical to Windows. You still depend on host-kernel behavior, graphics/input/audio/display integration, sandbox/container support, and Steam’s launcher. For a simple GUI app with no 3D, that is probably a very favorable tradeoff.

3. Which runtime to target

For a new project today:

text
Target: Steam Linux Runtime 4.0 / steamrt4
Fallback consideration: Steam Linux Runtime 3.0 / sniper
Avoid for new work: scout unless you specifically need legacy compatibility

Valve’s README says new native Linux games can run under Steam Linux Runtime 4.0 and that this is recommended for new native Linux games. It also says Proton 11+ uses Steam Runtime 4, while Proton 8–10 use Runtime 3 “sniper”. [GitHub]

So the decision is mostly:

TargetUse when
steamrt4New native Linux app, clean start, modern dependencies
sniperYou need to match older current deployments or dependencies already built for sniper
scoutLegacy native Linux games/apps

For your case — simple C or Zig GUI, no 3D — I would start with steamrt4 unless a required library is only comfortably available in sniper.

4. What to use for a simple GUI

For Steam-targeted Linux apps, the path of least resistance is usually SDL, not GTK/Qt.

Steamworks docs mention SDL, OpenAL, FreeType, and Fontconfig as common libraries included in the Steam Linux Runtime. [Steamworks] SDL’s own SteamOS notes say Steam provides SDL versions and add-on libraries like SDL_image and SDL_mixer to Steam-launched apps. [SDL Wiki]

So for a simple GUI app:

text
C or Zig core
SDL3 window/event/input layer
Dear ImGui / Nuklear / custom immediate UI / your own retained UI
FreeType + Fontconfig if you need text shaping/font access

I would be cautious with GTK or Qt as the primary app GUI unless you are willing to bundle the exact libraries you need. Steam Runtime is not trying to be a complete desktop-app platform like Flatpak’s KDE/GNOME runtimes. It is more game-runtime-shaped.

For a “normal desktop app” outside Steam, Flatpak is the more natural packaging model. For “an app distributed through Steam that wants stable Linux ABI,” Steam Runtime is exactly the interesting option.

5. Build workflow

The intended flow is:

text
1. Pick runtime version: steamrt4
2. Build inside matching SDK OCI image
3. Link only against runtime-provided libs, or bundle your own
4. Upload binary/assets to Steam depot
5. In Steamworks, add a Linux launch option
6. In Installation → Linux Runtime, select Steam Linux Runtime 4.0
7. Test by launching from Steam, not directly from your shell

Valve’s README says to compile in the matching SDK, then set up a Linux Launch Option and select the runtime in the Steamworks partner interface. [GitHub] Steamworks docs say the runtime can be selected per branch and must match the SDK used to compile the executable. [Steamworks]

A conceptual C build might look like this inside the SDK container:

sh
meson setup build \
  --buildtype=release \
  --prefix=/app

meson compile -C build
meson install -C build --destdir "$PWD/package-root"

Or with CMake:

sh
cmake -S . -B build \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_INSTALL_PREFIX=/app

cmake --build build -j
cmake --install build --prefix "$PWD/package-root"

For Zig, I would not try to be too clever at first. Put the Zig compiler inside the SDK container and make sure your build uses the runtime sysroot and pkg-config from inside that container. The important thing is not “Zig can cross-compile”; the important thing is “the final ELF binary must not accidentally depend on your host Fedora/Arch/Ubuntu libc or libraries.”

The mental rule:

text
Anything dynamically linked must be either:
  provided by the selected Steam Runtime,
  bundled next to your executable,
  or intentionally taken from the host through the runtime’s host-integration mechanism.

For app code, avoid depending on random host libraries. Do not dlopen("libgtk-4.so") and hope. Do not build on Arch and ship the binary. Build in the SDK.

6. What you ship

Your Steam depot would contain something like:

text
myapp/
  bin/myapp
  assets/...
  lib/               # only if you bundle extra shared libs
  myapp.sh           # optional launcher script

A launcher script is often useful:

sh
#!/bin/sh
set -eu

HERE="$(dirname "$(readlink -f "$0")")"

export LD_LIBRARY_PATH="$HERE/lib:${LD_LIBRARY_PATH:-}"
exec "$HERE/bin/myapp" "$@"

But if all your dependencies are in the runtime and your binary has clean rpath/RUNPATH, you may not need much of a wrapper.

Useful checks:

sh
readelf -d bin/myapp
ldd bin/myapp

You want to see dependencies that are either in the Steam Runtime or in your shipped lib/, not surprise paths from your build host.

7. What happens on the user’s machine

When a user installs and runs the app from Steam:

text
Steam installs your app depot.
Steam installs the selected Steam Linux Runtime tool if needed.
Steam launches the app through pressure-vessel.
pressure-vessel constructs the container environment.
Your executable runs with the runtime library stack visible.
Host services/devices needed for display/audio/input are bridged in.

The runtime can also be downloaded explicitly via Steam app IDs; Valve lists Steam Linux Runtime 4.0 as app ID 4183110, sniper as 1628350, soldier as 1391110, and scout as 1070560. [GitHub]

For users, this is mostly invisible. They click Play. Steam makes sure the right runtime is present.

8. How this differs from Proton/Wine

There are two different stories that often get conflated:

text
Native Linux app:
  your ELF binary
  Steam Linux Runtime
  pressure-vessel container

Windows app via Proton:
  Windows .exe
  Proton = Wine + DXVK/VKD3D/etc.
  Steam Linux Runtime underneath Proton

You are interested in the first one. You do not need Wine or Proton if you write a native C/Zig Linux binary. Proton itself uses Steam Runtime containers too, but that is a consumer of the runtime, not the development API you target.

9. Practical recommendation for your app

For a simple GUI app in C/Zig:

text
Target steamrt4.
Use SDL3 for the window/event layer.
Use FreeType/Fontconfig if needed.
Use an immediate-mode GUI library if that fits.
Build only inside the steamrt4 SDK OCI container.
Bundle every dependency not guaranteed by the runtime.
Configure the Steam branch to use Steam Linux Runtime 4.0.
Test only through Steam/pressure-vessel before trusting the result.

The architectural shape you get is quite attractive: a stable, curated, Steam-delivered Linux userland, without having to pretend that every user’s distro is a valid deployment target. The cost is that this compatibility story is basically Steam-scoped. It is excellent for software distributed and launched through Steam; it is not a general replacement for Flatpak/AppImage/Nix for arbitrary desktop distribution.

You misunderstood. I want to write a Windows GUI app, while working on Linux, that will then run (with Steam) on both platforms.

Right — you mean: author the app as a Windows program, build/test it from Linux, and rely on Steam + Proton/Wine for Linux users, while Windows users run it natively. I’ll answer in that model.

Yes — that changes the answer quite a lot.

You are not targeting the Steam Linux Runtime as your app ABI. You are targeting the Win32/Win64 ABI, and on Linux Steam runs your Windows build through Proton, which itself runs inside Valve’s Steam Runtime container.

The stack becomes:

text
Windows users
  Steam
    your-app.exe
    your DLLs/assets

Linux / SteamOS / Steam Deck users
  Steam
    Proton compatibility tool
      Wine-derived Win32/Win64 implementation
      bundled Windows-side runtime pieces
      DXVK/VKD3D/etc. mostly irrelevant if no 3D
      Steam Runtime container underneath Proton
        Linux kernel + host display/audio/input integration
          your-app.exe
          your DLLs/assets

Valve’s Steamworks docs describe Proton as the compatibility layer that lets Windows games run on Linux using a modified Wine plus extra high-performance API implementations, and the Proton GitHub README says Proton is for Windows-only games/apps running through the Steam client on Linux. [Steamworks] [GitHub]

The compatibility contract you care about

For your app, the stable target is basically:

text
Windows x86_64 executable + Win32 APIs + bundled DLLs

On Windows: the executable runs normally.

On Linux via Steam: Steam sees a Windows build, chooses Proton, creates a Wine prefix for the app, and launches your .exe inside Proton.

So your “long-term binary compatibility” surface is not glibc, libstdc++, GTK, SDL-for-Linux, etc. It is:

text
PE/COFF executable
Win64 calling convention
Win32 / COM / shell / user32 / gdi32 / dwrite / winmm / xinput / etc.
MSVCRT/UCRT or your bundled/static CRT choice
your shipped DLLs
Steamworks Windows SDK DLL if used

That is a much better fit for your stated goal.

What you should probably build

For a simple C or Zig GUI app, I would make the primary artifact:

text
myapp.exe        # 64-bit Windows
steam_api64.dll # if using Steamworks
assets/...
optional DLLs...

I would avoid depending on:

text
.NET / WPF
MSIX/AppX packaging
Windows WebView2 unless you are prepared to test hard
kernel drivers
low-level hooks
weird shell integrations
Windows services
UWP-only APIs

Valve explicitly calls out .NET / WPF as a known Proton trouble area and recommends standalone technologies such as Qt for launchers, or avoiding separate launchers entirely. [Steamworks]

For a non-3D GUI app, good choices are:

text
Raw Win32 + GDI / Direct2D / DirectWrite
SDL + custom UI / Dear ImGui / Nuklear
Qt for Windows, bundled carefully
maybe raylib/minifb-like stack if you only need a framebuffer-style UI

If you are writing in Zig, the most robust low-level option is probably:

text
Zig → x86_64-windows-gnu or x86_64-windows-msvc
Win32 API directly
bundle nothing except steam_api64.dll and assets

For C from Linux, the common route is:

text
mingw-w64 cross compiler
CMake/Meson/Zig build system
Wine/Proton for local test runs
Steam for final integration tests

Linux development workflow

A practical layout:

text
src/
  main.c or main.zig
  platform_win32.c
assets/
steam/
  app_build.vdf
  depot_build.vdf
build/
dist/
  windows-x64/
    MyApp.exe
    steam_api64.dll
    assets/

For C with MinGW-w64:

sh
x86_64-w64-mingw32-gcc \
  -O2 \
  -municode \
  -mwindows \
  src/main.c \
  -o dist/windows-x64/MyApp.exe \
  -luser32 -lgdi32 -lshell32 -lole32 -ldwrite

For Zig, conceptually:

sh
zig build -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseSafe

or, if you want MSVC ABI compatibility:

sh
zig build -Dtarget=x86_64-windows-msvc -Doptimize=ReleaseSafe

The gnu target is convenient from Linux. The msvc target may be preferable if you consume Windows libraries built with MSVC conventions, but it can complicate SDK/library setup. For a self-contained Win32 app, x86_64-windows-gnu is often enough.

Running it locally on Linux

You have three useful test levels.

First, plain Wine:

sh
wine dist/windows-x64/MyApp.exe

Good for fast iteration, not authoritative for Steam.

Second, Steam “add non-Steam game” and force Proton. This gets closer to how users will run it.

Third, the real Steam app build, installed from your private Steam branch, launched from Steam with Proton. This is the one that matters.

The reason is that Proton is not just “system Wine”. It is Valve’s compatibility tool integrated with the Steam client. The Proton repo says most users should use Proton provided by the Steam client, and the Steam Runtime docs say Proton versions use Steam Runtime container runtimes underneath; Proton 11+ uses Steam Runtime 4, Proton 8–10 use Runtime 3 “sniper”. [GitHub] [GitHub]

Steam depot shape

You normally ship one Windows depot and mark the app as Windows-compatible. Linux/SteamOS users run that Windows depot through Proton.

text
Depot: Windows content
  MyApp.exe
  steam_api64.dll
  assets/
  config/defaults.json

In Steamworks, you define a Windows launch option, roughly:

text
Executable: MyApp.exe
Arguments: optional
OS: Windows
Architecture: 64-bit

On Linux/SteamOS, if the title has no native Linux build, Steam Play/Proton is the route. Valve’s docs say Proton automatically takes the current Windows executable and game data and runs them on SteamOS/Linux-based systems. [Steamworks]

Whether you can or should explicitly configure a recommended Proton version is more Steamworks-policy/UI-specific, but the operational model is: ship Windows build, test with current Proton and Proton Experimental, fix issues, repeat.

The Wine prefix / app data model

On Linux, Proton creates a per-title compatibility prefix, typically under Steam’s compatdata/<appid>/. Your app thinks it sees something like:

text
C:\users\steamuser\AppData\...
C:\Program Files...
Z:\home\...

So inside your Windows code, use normal Windows APIs:

text
SHGetKnownFolderPath(FOLDERID_RoamingAppData, ...)
SHGetKnownFolderPath(FOLDERID_LocalAppData, ...)
GetModuleFileNameW(...)
CreateFileW(...)

Do not special-case Linux paths unless you intentionally expose something through Steam launch options.

For save/config data, the best pattern is:

text
Use Windows known folders.
Then configure Steam Cloud against those Windows paths.
Let Proton map them inside the prefix.

Steamworks API

If you use Steamworks, from your app’s point of view you are using the Windows Steamworks SDK:

text
steam_api64.dll
steam_api64.lib / import library
SteamAPI_Init()
...

On Linux through Proton, your process is still a Windows process, so you do not link against libsteam_api.so. You ship/use steam_api64.dll.

That is one of the benefits of the Windows-only route: you have one Steamworks integration surface.

What not needing 3D buys you

A lot.

Most Proton drama is around:

text
DirectX translation
shader compilation
anti-cheat
media codecs
launchers
DRM
kernel-level stuff
multi-monitor/fullscreen weirdness

A simple GUI app avoids much of that.

But you still need to test:

text
window creation and resizing
DPI scaling
IME / text input if relevant
clipboard
file dialogs
font rendering
controller/mouse/keyboard input
Steam overlay
Steam Deck gamepad-only operation, if you care
virtual keyboard behavior on Deck

A plain Win32 window using Unicode APIs and boring filesystem calls is exactly the kind of thing Wine/Proton tends to handle well.

Recommended design constraints

I would write the app as if targeting “boring Windows 10 x64”:

text
64-bit only
Unicode Win32 APIs only; use W variants, not A variants
No installer required
No registry dependency except optional settings
No Windows service
No admin rights
No shell extension
No global hooks
No kernel drivers
No UWP
No WPF
No required Edge WebView2
No separate launcher

For dependencies:

text
Prefer static linking where license-compatible.
Bundle DLLs next to the exe.
Avoid depending on system-installed VC redistributables.
Avoid COM-heavy desktop frameworks unless tested.

For Zig specifically, that means a nice target is:

text
single .exe
Win32 API
maybe stb-style single-file libs
maybe SDL2/SDL3 Windows DLL if you want cross-platform-ish input/windowing
steam_api64.dll if needed

Build-and-test loop I would use

text
1. Build Windows x64 binary on Linux.
2. Run with Wine for fast smoke test.
3. Run through Steam as a non-Steam app with Proton Experimental.
4. Upload to a private Steam branch.
5. Test on:
   - Windows Steam
   - Linux desktop Steam + Proton
   - Steam Deck / SteamOS if relevant
6. Fix Proton issues by reducing exotic Windows dependencies first, not by adding Linux-specific code.

Use Proton logs when debugging:

sh
PROTON_LOG=1 %command%

In Steam launch options, that generates Proton logs in the user’s home directory. Proton’s README documents runtime configuration through launch-option environment variables such as PROTON_USE_WINED3D=1 %command%, and the same mechanism is commonly used for debug logging. [GitHub]

The key conceptual point

You would not be “integrating with Steam Runtime” directly.

You would be doing this:

text
Author against Windows ABI
↓
Distribute Windows build on Steam
↓
Windows users run it directly
↓
Linux users run the same build via Proton
↓
Proton itself uses Steam Runtime internally

So the long-term compatibility layer you are buying into is effectively:

text
Win32/Win64 as the stable app ABI
+
Valve-maintained Proton as the Linux execution strategy
+
Steam distribution/runtime management

For a simple GUI app in C or Zig, this is quite plausible — arguably more plausible than trying to chase native Linux desktop ABI stability yourself.

Ok, let's assume the app is a paint clone with only pencil tool, so a blank canvas on which you can draw by moving mouse pointer while the button is pressed. The drawing may be ephemeral and destroyed on window resize or any other invalidation event, we don't need to store/restore it. We'll be using Zig and Win64 APIs (is that a real name? Last time I coded for Win was in 3.11 era...) directly. Let's make a menu bar with exit and About... modal dialog. We will be building on Linux and testing as a "non-Steam game" under the Steam client I have installed. Please generate a prompt that will explain the setup to a coding agent.

Yes, “Win64” is a real-enough shorthand, but the official-ish terms you’ll see are usually Win32 API or Windows API even for 64-bit programs. “Win32” names the API family, not necessarily 32-bit code. For a 64-bit .exe, you’re using the Win32 API on x86_64 Windows.

Here’s a prompt you can hand to a coding agent:

:::writing{variant="standard" id="48291"}

We are building a tiny Windows GUI application in Zig, developed on Linux, cross-compiled to a 64-bit Windows executable, and tested on Linux through Steam as a “non-Steam game” using Proton.

The app is intentionally minimal: a Paint-like clone with only a pencil tool. It should open a native Windows window with a menu bar. The canvas is blank white. When the user holds the left mouse button and moves the pointer over the client area, draw continuous black pencil strokes. The drawing does not need persistence: it may be destroyed on window resize, minimize/restore, repaint, or any other invalidation event. There is no file saving/loading. There is no undo. There are no tools beyond pencil.

Use Zig and call the Windows API directly. Do not use SDL, GLFW, Qt, GTK, .NET, WPF, WinForms, DirectX, OpenGL, Vulkan, or any other GUI framework. The goal is to learn and verify the raw Windows API path from Linux to Proton.

Terminology note: use the Win32/Windows API directly, but produce a 64-bit Windows executable. “Win32 API” is still the normal API name even for 64-bit Windows programs.

Target environment:

  • Host development OS: Linux.
  • Language: Zig.
  • Target binary: Windows x86_64 .exe.
  • Runtime test path: Steam client on Linux → add the produced .exe as a non-Steam game → force compatibility tool / Proton → launch.
  • No native Linux build is required.
  • No Steamworks SDK integration is required for this prototype.
  • No installer is required.
  • No external DLLs should be required if reasonably possible.

Implement the app using classic Windows API pieces:

  • WinMain-style entry point, or the Zig equivalent needed for a Windows GUI subsystem program.
  • Register a window class.
  • Create a top-level overlapped window.
  • Use a standard message loop with GetMessageW, TranslateMessage, and DispatchMessageW.
  • Implement a WndProc.
  • Use Unicode / wide Windows APIs where applicable.
  • Use the Windows GUI subsystem so launching the app does not create a console window.
  • Create a native menu bar with:
    • File → Exit
    • Help → About...
  • File → Exit should close the main window.
  • Help → About... should show a modal native MessageBoxW dialog with a simple app name/version/about text.
  • Handle WM_DESTROY by posting quit.
  • Handle painting via WM_PAINT.
  • Handle mouse input:
    • WM_LBUTTONDOWN: start drawing, capture mouse, remember current point.
    • WM_MOUSEMOVE: if drawing and left button is down, draw a line segment from previous point to current point.
    • WM_LBUTTONUP: stop drawing and release capture.
    • Optionally handle WM_CAPTURECHANGED defensively by stopping drawing.
  • For drawing, use GDI directly:
    • Use BeginPaint / EndPaint during WM_PAINT.
    • For the ephemeral version, it is acceptable to draw directly into the window DC while handling mouse movement.
    • Use a black HPEN.
    • Use MoveToEx and LineTo.
    • Fill the background white on repaint.
  • Since persistence is not required, do not implement an off-screen bitmap unless it simplifies code. It is acceptable that the drawing disappears when the window repaints.

Important implementation preferences:

  • Keep the first version simple and explicit.
  • Prefer one small Zig source file plus a small build.zig.
  • Use clear names and comments for the Windows API concepts.
  • Keep resource management correct: delete GDI objects that are created, release DCs, release mouse capture when needed.
  • Avoid clever abstractions at first.
  • Avoid dependencies.
  • Make the build reproducible from Linux.

Provide:

  1. A proposed project layout.
  2. The complete build.zig.
  3. The complete Zig source code.
  4. Exact Linux commands to build the Windows .exe.
  5. Exact steps to test it through Steam as a non-Steam game under Proton.
  6. A small troubleshooting section for:
    • the app opening a console window,
    • missing Windows target support in Zig,
    • Steam launching the wrong executable,
    • Proton logs,
    • mouse drawing not appearing,
    • crashes caused by incorrect Windows API calling conventions or pointer casts.

Assume a recent Zig version. If some Windows API declarations differ between Zig versions, explain what to check and how to adapt, but still provide a concrete implementation rather than only pseudocode.

The final artifact should be a 64-bit Windows GUI executable that opens a blank white window, allows black pencil drawing while dragging with the left mouse button, has File → Exit, and has Help → About... showing a modal dialog.

:::

One extra instruction I’d consider adding, depending on the agent, is: “after generating the code, run zig build locally and fix any compile errors before reporting success.”

Ok - the app is built and runs on both platforms. Now, the agent decided to easchew any of the libraries other than user32 and gdi32 for the initial version, which is fine now, but I don't really want to work with WinAPI directly in 2026 like I did a quarter century ago. Though it was nice to review code and immediately recognize all the patterns, I'd like to introduce some library wrappers, preferably open source, that would simplify the code by hiding the low-level WinAPI plumbing. This is going to be important as the app grows. What options are there for Zig in this project's context?

I’ll treat this as a Windows-first Zig app that must remain Proton-friendly, not as a native Linux GUI app. The useful split is: thin Win32 bindings, immediate-mode UI, full cross-platform windowing/toolkits, and “maybe don’t use Zig for this layer.”

Right direction. I’d separate “less WinAPI boilerplate” from “real GUI toolkit”. In your project, the app remains a Windows .exe distributed through Steam, and Linux users run the same binary through Proton. So libraries should be judged by: Windows build quality, ease of cross-compilation from Linux, Proton behavior, DLL/dependency burden, and whether they still let you ship a boring Win64 executable.

My recommendation

For this specific project, I would probably evolve in stages:

text
Stage 1: keep Win32, but wrap it yourself + use zigwin32
Stage 2: introduce a rendering/UI layer, likely Dear ImGui or Nuklear
Stage 3: only move to SDL/raylib/libui/Qt-like territory if the app becomes much larger

For a drawing app, I would not jump straight to a traditional widget toolkit unless you know you want native controls, complex dialogs, tree views, document tabs, accessibility, etc. A paint-like app is mostly: window, input, canvas, menus, dialogs, drawing surface. That maps very well to a small custom app framework plus a lightweight UI layer.

Option 1: zigwin32 + your own thin app framework

This is the least disruptive next step.

zigwin32 is an autogenerated set of Zig bindings for the Win32 API, generated by zigwin32gen; the repository describes itself as “a complete autogenerated set of Zig bindings for the Win32 API.” [GitHub]

This does not hide the WinAPI model. You still have windows, handles, messages, WndProc, GDI objects, etc. But it can improve correctness and reduce hand-written extern declarations.

You then write your own thin layer:

zig
const App = struct {
    hwnd: HWND,
    canvas: Canvas,
    drawing: bool,

    pub fn onPaint(self: *App, dc: HDC) void {}
    pub fn onMouseDown(self: *App, x: i32, y: i32) void {}
    pub fn onMouseMove(self: *App, x: i32, y: i32) void {}
    pub fn onCommand(self: *App, id: u16) void {}
};

And hide the Windows message plumbing behind something like:

zig
pub fn run(comptime AppType: type, app: *AppType) !void {
    registerWindowClass(...);
    createMainWindow(...);
    messageLoop();
}

The result is still “Win32-native”, but your application code stops looking like 1998 C.

This is my favorite near-term path because it keeps your current Proton behavior almost unchanged. You are already testing a Windows .exe; replacing raw declarations with generated bindings and wrapping the event loop should not introduce new runtime risk.

Pros

Very small dependency surface.

No extra DLLs.

Excellent Proton compatibility expectations.

You understand the underlying model.

You can design Zig-native APIs instead of fighting a C GUI toolkit.

Cons

You still own the framework.

You still need to understand Win32 concepts.

Native widgets beyond menus/dialogs remain manual.

Best use:

text
Main window, menu, dialogs, input, canvas, timers, clipboard, file dialogs,
DPI handling, app state, basic GDI/Direct2D wrappers.

Option 2: Dear ImGui

Dear ImGui is probably the strongest option if you are okay with a non-native immediate-mode UI. The official repository says it has maintained backends for platforms including Win32 and renderers including DirectX 9/10/11/12, OpenGL, Vulkan, SDL renderer, SDL GPU, WebGPU, etc. [GitHub] Its Win32 backend is explicitly for standard Windows API applications, both 32-bit and 64-bit. [GitHub]

For your app, you could keep:

text
Win32 window
Win32 message loop
Dear ImGui for toolbars/panels/dialog-like UI
A canvas region for drawing

You still need a renderer backend. For Proton-friendliness and “not a 3D app”, I’d still consider a simple Direct3D 11 backend because Dear ImGui’s mature examples are built around renderer backends, and Proton’s DXVK/D3D path is very well-trodden. Alternatively, OpenGL works but on Windows-through-Proton it still passes through Wine’s OpenGL path. For a Paint clone, either is likely fine, but D3D11 is the more “normal Windows app/game” route under Proton.

Zig integration choices are less standardized than C++; you can either:

text
compile Dear ImGui C++ code as part of the Zig build,
use cimgui-style C bindings,
or use a Zig gamedev binding package if it fits your Zig version.

The web results also point to the zig-gamedev ecosystem as a place where Zig ImGui bindings are commonly discussed, but I would treat those bindings as version-sensitive rather than as a forever-stable platform API. [Reddit]

Pros

Very productive for tools.

Good for panes, sliders, buttons, color pickers, debug UI.

Works well for canvas-heavy apps.

Avoids native-control boilerplate.

Widely used and maintained.

Cons

Not native-looking.

Text editing, accessibility, IME, platform polish need evaluation.

Requires renderer integration.

C++ dependency unless using generated C bindings.

Best use:

text
Tool palettes, brush settings, layers panel, status bar, debug windows,
dockable tool UI, prototype-heavy app growth.

For a Paint clone that may grow into a creative/tool app, this is probably the most productive “real” UI layer.

Option 3: Nuklear

Nuklear is a smaller immediate-mode GUI library written in ANSI C; current descriptions emphasize that it is lightweight, portable, self-contained, and single-header-style. [SourceForge] There are Zig bindings such as zig-nuklear, but that project describes itself as WIP bindings, with examples using GLFW/OpenGL. [GitHub]

Nuklear is attractive if you want something simpler than Dear ImGui, easier to vendor, and less C++-shaped.

Pros

C, not C++.

Small.

Good fit for Zig @cImport or thin bindings.

Immediate-mode model fits drawing tools.

Cons

Less polished ecosystem than Dear ImGui.

You may need to own more backend/input/rendering code.

Zig bindings may be less mature.

Native menus/dialogs still need Win32 or your own wrappers.

Best use:

text
Small tool UI, custom panels, minimal dependencies, experiments.

I would consider Nuklear if Dear ImGui feels too large or too C++-heavy.

Option 4: SDL3 + custom UI / ImGui / Nuklear

SDL is not a GUI toolkit; it is a windowing/input/audio/etc. layer. In a 2026 SDL discussion, the answer is blunt: “SDL has no GUI functionality,” so you pair it with your own GUI or something like ImGui. [Simple Directmedia Layer]

That said, SDL3 is very relevant to Steam/Proton-style apps. There is a Zig-build-system port of SDL3, allyourcodebase/SDL3, whose README says it supports cross-compilation and custom platform configuration. [GitHub]

This would change your app from:

text
raw Win32 window + GDI

to:

text
SDL3 window/events
SDL renderer/GPU or software surface
Dear ImGui/Nuklear/custom UI

But remember: you are building a Windows executable. You would link/bundle SDL’s Windows library/DLL, then run that same Windows build under Proton.

Pros

Hides window/message/input plumbing.

Good cross-platform-ish API even though you ship Windows only.

Works naturally with game/tool UI stacks.

Steam-ish ecosystem comfort.

Cons

Not a native Windows widget toolkit.

You lose normal Win32 menu/dialog model unless you bridge back.

Requires bundling SDL DLL unless statically linked.

A bit conceptually odd if you only ever ship Windows.

Best use:

text
Canvas-first app, custom rendering, tablet/controller/input handling,
future audio/video/game-like interaction.

For your Steam context, SDL3 is a serious option. For a classic desktop app with native menus, I would hesitate.

Option 5: raylib + raygui

raylib has a friendly C API and a Zig ecosystem. Search results show Zig/raylib/raygui bindings and examples, including bindings that translate raylib.h, rlgl.h, rcamera.h, and raygui.h to Zig and include build tooling. [GitHub] There are also recent community examples of using raylib + raygui with Zig. [Ziggit]

This is more “game/app canvas framework” than “Windows GUI wrapper”.

Pros

Very simple drawing model.

Good for a Paint clone.

C API is Zig-friendly.

raygui gives you basic immediate UI.

Cons

Not native Windows UI.

May pull in graphics-stack concerns you initially avoided.

Menus/dialogs are app-drawn unless you mix Win32.

Less ideal for complex document-app polish.

Best use:

text
Toy/prototype paint app, game-like editor, custom canvas UI.

If the app is meant to become a serious desktop productivity app, I’d prefer Dear ImGui or a custom Win32 wrapper over raygui. If the app is meant to remain playful/tool-like, raylib is attractive.

Option 6: libui-ng / zig-libui-ng

libui-ng is a C library for portable GUIs using each platform’s native GUI technologies; its repository describes it as simple and portable, using native GUI technologies on supported platforms. [GitHub] There are Zig bindings, zig-libui-ng, described as WIP bindings and a manual cleanup of ui.h cimport. [GitHub]

This is interesting if you want native buttons, boxes, text fields, menus, etc.

But I’d be careful. The libui-ng old-news page still describes libui as mid-alpha, with missing features and stability caveats. [libui-ng.github.io] For a small app, okay. For your long-lived “Steam Windows binary under Proton” idea, I would not bet the architecture on it yet.

Pros

C API.

Native-ish controls.

Zig bindings exist.

Much simpler than raw Win32 for normal widgets.

Cons

Maturity concerns.

Canvas/custom drawing may be limiting.

Bindings are WIP.

Long-term project risk.

Best use:

text
Small native-form-style utilities.

Less ideal for a drawing app whose center is a custom canvas.

Option 7: wxWidgets / Qt / bigger C++ GUI toolkits

wxWidgets is mature and open-source; it describes itself as a C++ library for Windows, macOS, Linux, and other platforms with one codebase. [wxwidgets.org] Its GitHub description says it is a cross-platform C++ framework for advanced GUI apps using native controls. [GitHub]

But from Zig, this means C++ interop or a C wrapper. That is possible, but it adds an integration layer you will own.

Qt is similar but larger and more licensing/build/distribution-heavy. You did not ask for Qt specifically, and for a Windows .exe under Proton I would avoid dragging in a huge framework unless you really want a full desktop app framework.

Pros

Mature desktop app facilities.

Native-ish widgets.

Menus, dialogs, layout, documents, etc.

Cons

C++ integration from Zig is nontrivial.

Large dependency/bundling surface.

More Proton test matrix.

Can dominate your architecture.

Best use:

text
You decide the GUI layer matters more than keeping Zig close to the OS.

At that point I’d honestly ask whether the GUI shell should be C++/Qt or C++/wxWidgets, with Zig used for the core engine/library.

Option 8: IUP

IUP is a portable GUI toolkit with a C API; its site describes it as a multi-platform toolkit for GUI apps with a simple API in C, Lua, and LED. [Tecgraf] It is obscure compared with SDL/ImGui/wx/Qt, but C API plus portability makes it technically plausible.

Pros

C API.

Designed for portable GUI.

Potentially easier from Zig than C++ frameworks.

Cons

Smaller ecosystem.

Less common in Steam/game-adjacent software.

You will need to test Proton behavior yourself.

Long-term availability/community risk.

I would only choose this if you like its API after a spike.

What I would not do first

I would not jump to GTK. You are shipping a Windows executable, so GTK-on-Windows through Proton adds a lot of alien machinery.

I would not use WPF/.NET/WebView2 for this Proton-first path. Valve’s Proton docs specifically call out .NET/WPF as a trouble area and recommend standalone technologies like Qt for launchers or avoiding launchers altogether. [GitHub]

I would not over-index on native Windows controls if the core app is a drawing canvas. Menus, file dialogs, message boxes, and maybe common dialogs can stay native via a small wrapper; the canvas/tool UI can be custom.

Decision table

PathGood fit?Why
zigwin32 + your own wrappersExcellent first stepReduces raw binding pain without changing runtime behavior
Dear ImGuiExcellent for tool UIMature immediate-mode UI, strong backend ecosystem
NuklearGoodSmaller C immediate-mode UI, but less ecosystem polish
SDL3 + ImGui/custom UIGoodHides window/input, good Steam-ish fit, but not native GUI
raylib + rayguiGood for playful/custom appVery simple canvas/rendering model
libui-ngMaybeNative-ish C GUI, but maturity risk
wxWidgets/QtMaybe laterMature, but C++/large dependency burden
Raw Win32 foreverBad for growthYou already know why

My concrete architectural suggestion

Keep the current app as the “known-good Proton baseline”, then do this:

text
src/
  main.zig                 // tiny entry point
  platform/win32_app.zig   // message loop, HWND, menus, dialogs
  platform/win32_gdi.zig   // pens, brushes, DC wrappers, canvas helpers
  app/app.zig              // app state, commands, drawing model
  app/canvas.zig           // pencil strokes / ephemeral drawing logic

Use zigwin32 underneath the platform layer.

Expose your own Zig API like:

zig
pub const AppEvent = union(enum) {
    paint: PaintContext,
    resize: Size,
    mouse_down: Point,
    mouse_move: Point,
    mouse_up: Point,
    command: Command,
    close_requested,
};

pub fn run(app: *App) !void;

Then, when you add a UI toolkit, you can put it behind the same boundary.

For the next real UI step, I’d choose between:

text
A. Dear ImGui if you want fast-growing tool UI.
B. Stay custom Win32 wrappers if you want native menus/dialogs and minimal dependencies.
C. SDL3 + Dear ImGui if you want to stop caring about HWND/WndProc entirely.

For your specific “Windows binary, Linux via Proton, Zig, simple drawing app” context, my strongest recommendation is:

text
Now: zigwin32 + your own thin Zig framework.
Soon: Dear ImGui for non-canvas UI if the app starts needing panels/tools/settings.
Avoid: heavyweight native GUI frameworks until the app proves it needs them.

Ok, let's reformulate the considerations. Let's say we want not a paint clone, but a calc clone. That means we need a read-only text input, some buttons in a grid, and the ability to switch between two sets of buttons (simplified/scientific). We will also consider other languages other than C and C++: especially ones with strong cross-compilation story, like Rust and possibly Swift. I would also prefer a language with a GC or other automatic memory management, although I know this will introduce friction with the platform APIs.

This changes the ranking a lot: a calculator clone is much more “ordinary widgets and layout” than “custom canvas”. I’ll compare the stacks by how well they produce a Windows build from Linux that behaves under Proton, while avoiding manual memory management where possible.

For a calculator clone, I would de-prioritize Zig. Not because Zig is bad, but because this is now mostly stateful UI composition, not “one window and a custom canvas”. A calculator wants layout, buttons, text display, event routing, keyboard input, focus behavior, theme/scaling, and maybe menus/settings. That is exactly where raw WinAPI starts feeling silly.

The important reframing:

text
Paint clone:
  custom drawing surface dominates
  raw Win32 + GDI is tolerable

Calculator clone:
  ordinary UI widgets dominate
  layout/state/event framework matters much more

And since your distribution model is still:

text
build Windows app on Linux
ship Windows build through Steam
Windows users run natively
Linux users run same .exe through Proton

the best choices are probably C#/.NET + Avalonia, Rust + Slint, or Rust + Iced/egui, depending on what tradeoff you want.

Best practical option: C# / F# + Avalonia

This is the first thing I would seriously consider for a calculator-like app.

Avalonia is a cross-platform .NET UI framework for desktop/mobile/web targets; its own docs describe it as a cross-platform framework for Windows, macOS, Linux, iOS, Android, and WebAssembly, and its site positions it as a XAML/C# desktop UI stack. [Avalonia UI] The project README calls Avalonia mature and production-ready, and mentions companies such as JetBrains, Unity, GitHub, and Schneider Electric using it. [GitHub]

For your exact project, the major advantage is that you get:

text
GC
mature layout system
buttons/text fields/grids
styles/themes
data binding / MVVM if wanted
self-contained Windows publish
good developer experience on Linux

A calculator UI maps almost directly to Avalonia:

xml
<TextBox IsReadOnly="True" Text="{Binding Display}" />
<Grid>
  <Button Content="7" Command="{Binding DigitCommand}" CommandParameter="7" />
  ...
</Grid>

Then the logic can be plain C# or F#.

The distribution artifact can be a Windows x64 self-contained .NET app. Microsoft’s .NET docs say single-file apps are OS/architecture-specific and use runtime identifiers such as Windows x64; Avalonia’s docs also show self-contained/single-file publishing for Windows deployment. [Microsoft Learn]

The big question is Proton. A self-contained Avalonia Windows app is still a Windows app running under Proton/Wine, so it should be tested, not assumed. But this is a much more reasonable bet than WPF. Earlier I mentioned Valve warning about .NET/WPF; that caution is specifically about WPF/.NET desktop stacks in Proton-heavy game launchers, not about every .NET executable forever. Avalonia is not WPF, and it can publish self-contained, but you would still need a quick spike.

My suggested spike:

text
Create a tiny Avalonia calculator skeleton.
Publish win-x64 self-contained from Linux.
Add the .exe to Steam as a non-Steam game.
Force Proton Experimental.
Check:
  window opens
  buttons work
  text renders
  resize/DPI okay
  no missing DLL/runtime errors

If this passes, Avalonia is probably the most comfortable long-term option.

C# vs F#

C# is the path of least resistance for Avalonia docs and templates.

F# is very attractive for the calculator core:

fsharp
type Mode =
    | Simple
    | Scientific

type Token =
    | Digit of int
    | Operator of Op
    | Equals
    | Clear

But the Avalonia ecosystem is mostly C#/XAML-first. A good compromise:

text
C# or XAML UI shell
F# core calculator engine

or just use C# until the app outgrows “calculator clone”.

Best Rust option: Rust + Slint

If you want to stay closer to native-code distribution and avoid .NET, I would look at Slint first.

Slint is an open-source declarative GUI toolkit with Rust, C++, JavaScript, and Python integrations; its docs describe it as a set of cross-platform components for desktop applications, and its desktop platform docs list Windows 10/11 x86-64 among tested targets. [GitHub]

A calculator is almost a demo-shaped Slint app:

text
.slint file:
  display text
  grid of buttons
  mode switch
Rust:
  app state
  button callbacks
  expression evaluation

You get declarative UI without a GC. Rust does not have automatic memory management in the GC sense, but for this kind of app it is still mostly “automatic” in practice: ownership, RAII, Rc, Arc, and framework-managed callbacks. It will not feel like manual malloc/free.

Compared to Avalonia:

text
Avalonia:
  more mature desktop-app framework
  GC
  heavier runtime
  C#/XAML world

Slint:
  lighter
  Rust-native
  declarative UI
  less huge than .NET
  no GC

For a Steam-distributed Windows .exe under Proton, Slint feels like a clean technical fit. The main thing to test is the Windows build from Linux and whatever renderer/backend Slint uses for your chosen configuration.

Good Rust option: Rust + Iced

Iced describes itself as a cross-platform GUI library for Rust focused on simplicity and type safety. [iced.rs] Its architecture is Elm-ish: application state, messages, update function, view function.

That model is excellent for a calculator:

rust
enum Message {
    Digit(u8),
    Add,
    Subtract,
    Equals,
    Clear,
    ToggleScientific,
}

fn update(&mut self, message: Message) { ... }

fn view(&self) -> Element<Message> { ... }

This is conceptually very nice: the entire UI is a pure-ish projection of calculator state, and button clicks send typed messages.

The drawbacks: Iced is not native widgets, and its renderer/dependency stack is heavier than a raw Win32 wrapper. But for a calculator or small tool, it is clean and pleasant.

I would choose Iced over egui if you want the app to feel like a normal structured GUI app rather than an immediate-mode debug/tool panel.

Good Rust option: egui

egui is great for tools, debug panels, quick apps, and immediate-mode UI. For a calculator, it would work fine:

rust
egui::Grid::new("calculator").show(ui, |ui| {
    if ui.button("7").clicked() { ... }
});

But I would rank it below Slint/Iced for a calculator because the app is just ordinary widgets and state. Immediate-mode UI shines more when the UI is exploratory, dynamic, or tool-like.

That said, egui has a very low ceremony factor. If you want to prototype quickly and do not care about native look, it is appealing.

Rust native Win32 wrappers

There are also Windows-specific Rust wrappers.

native-windows-gui describes itself as a Rust library for native Microsoft Windows desktop GUI apps, wrapping native Win32 windows; the GitHub page says it is a very light wrapper over WinAPI with a safer, more Rust-like interface. [crates.io] Microsoft’s windows-rs repo provides Rust crates for building Windows apps, from low-level API access to higher-level declarative UI. [GitHub]

These are conceptually close to the Zig approach but with Rust safety and better package ecosystem.

I would use these if your goal is:

text
Windows-only UI surface
native controls
minimal runtime dependencies
ship tiny .exe
avoid .NET
avoid custom-rendered UI

The downside is that you are still living in Windows UI concepts. It is less painful than raw C/Zig WinAPI, but still recognizably WinAPI-shaped.

For your Proton model, that is not necessarily bad. Native Win32 controls are among the most Wine-tested APIs in existence. A calculator built from ordinary buttons/edit controls/dialogs should be boringly compatible.

Swift: I would not choose it here

Swift is interesting, but I would not choose it for this project.

There is official documentation for cross-compiling Swift for Windows from a non-Windows host, so the idea is not imaginary. [GitHub] But the Windows GUI ecosystem is the weak point. SwiftUI is Apple-platform-centric; Windows GUI work means either direct Win32 bindings, C interop, or a less-mainstream toolkit. Swift forums also reflect that cross-platform Swift is used successfully for CLI work, while GUI on Windows/Linux remains much less settled. [Swift Forums]

So Swift gives you ARC and a nice language, but not the mature “calculator UI on Windows from Linux” path you want.

I would revisit Swift if:

text
the app core is Swift for other reasons,
you are targeting macOS/iOS too,
or a serious Swift cross-platform GUI framework becomes clearly mature.

For this Steam/Proton Windows-build path, it feels like avoidable friction.

Kotlin / JVM / Compose Desktop?

Since you like Kotlin, it is worth mentioning, but I would not make it my first recommendation for this exact distribution model.

Compose Desktop can build Windows desktop apps, and Kotlin gives you GC and a very nice UI model. But if your plan is “build Windows artifact on Linux, ship through Steam, run under Proton”, you are now shipping a Windows JVM/runtime bundle inside a Windows app that is itself running under Proton on Linux. That may work, but it is awkward compared with:

text
Avalonia/.NET self-contained app
Rust/Slint native exe
Rust/Iced native exe

If this were a normal cross-platform app outside Steam, Kotlin/Compose Desktop would be more attractive. For “one Windows binary through Proton”, I would rank it lower.

Tauri / Dioxus / webview stacks

Tauri 2 advertises building apps for Linux, macOS, Windows, Android, and iOS from a single codebase with Rust backend and web frontend. [Tauri] Dioxus similarly presents itself as a Rust framework for web, desktop, and mobile apps. [Dioxus Labs]

But for your particular goal, webview stacks are not ideal.

Tauri’s own Windows prerequisites mention Microsoft Edge WebView2 for Windows development, and its Windows installer docs say cross-compiling Windows apps on Linux/macOS is possible with caveats and “not as straight forward” or as well-tested as building on Windows. [Tauri]

A calculator clone does not need a browser engine. Under Proton, WebView2 becomes another moving part. I would avoid this unless you specifically want HTML/CSS UI.

Decision ranking for this calculator clone

My ranking would be:

RankStackWhy
1C# + AvaloniaBest fit for “ordinary GUI + GC + Linux dev + Windows artifact”
2Rust + SlintBest native-code/declarative compromise
3Rust + IcedNice typed state/update architecture
4Rust + native-windows-gui / windows-rsNative Win32 controls, minimal runtime, but still Windows-shaped
5Rust + eguiVery easy, but less app-like/native
6Zig + wrappersFine technically, but no automatic memory management and less UI leverage
7SwiftNice language, weak Windows GUI story
8Tauri/Dioxus/webviewOverkill and questionable under Proton for a calculator

My actual recommendation

Given your stated preferences — automatic memory management, less low-level WinAPI, build from Linux, Windows app distributed through Steam and run via Proton — I would do a spike in this order:

text
1. Avalonia + C#
2. Rust + Slint
3. Rust + Iced

For the first spike, the acceptance criteria should be brutally practical:

text
Can I build win-x64 from Linux?
Can I run the produced Windows .exe directly under Wine?
Can I add it to Steam as a non-Steam game and run it under Proton?
Does text/button input work?
Does resizing/DPI behave?
Can I publish it self-contained without installing runtimes?
Is the output size acceptable for Steam?

If Avalonia passes that Proton smoke test, I would probably stop there. For a calculator-style UI, the productivity gain from GC + mature layout + normal widgets is large enough to justify the runtime weight. If it fails or feels too heavy, Rust + Slint is the next best fit.

Let's compare the promises of KMP/Compose Desktop to Proton. I'm not experienced in JVM-based app distribution. The goal is long-term binary compatibility: the app released tomorrow should work on both Linux and Windows, preferably without any tweaks, in 10 and 15 years. This should be reasonably easy on Windows if you bundle the JVM/JRE/JDK - however, on Linux, I'm not sure if a 10-years old bundled JVM has a chance to run.

This is exactly the right comparison: not “which API is nicer”, but “which compatibility contract is stronger when the OS moves underneath me.” I’ll ground it in what is actually bundled, what still leaks to the host, and what tends to break over decade-scale Linux timelines.

For the specific goal — a binary released tomorrow still launching on Windows and Linux in 10–15 years — I would trust the Windows build under Proton/Steam more than a native Linux Compose Desktop bundle, even though I would often prefer Kotlin/Compose as a programming model.

The core asymmetry is this:

text
KMP / Compose Desktop:
  “I ship a Linux application bundle containing a JVM + Skiko/Skia native libs,
   and hope future Linux still runs that old Linux userland-facing stack.”

Proton:
  “I ship a Windows application, and Valve keeps updating the Linux-side
   compatibility layer that runs old Windows applications.”

That second contract is closer to what you want.

Compose Desktop’s promise

Compose Desktop packages JVM applications into native-looking distributions. JetBrains’ docs say the Compose Gradle plugin uses jpackage, creates self-contained installable binaries, and includes the necessary Java runtime components so the target machine does not need a JDK installed. It also uses jlink to include only selected Java modules. [Kotlin]

So a packaged Compose Desktop app is not “please install Java first”. It is more like:

text
Windows package:
  app.exe launcher
  bundled runtime image
  app jars
  Compose/Skiko native libraries

Linux package:
  launcher
  bundled runtime image
  app jars
  Compose/Skiko native libraries
  .deb or .rpm packaging metadata

That is already much better than the old “ship a .jar and pray” model.

But there are two important catches.

First, the Compose docs say native distribution cross-compilation is currently not supported: to build a Windows .exe/.msi, you need Windows; to build Linux .deb/.rpm, you need Linux; to build macOS packages, you need macOS. [Kotlin] That is not fatal, but it weakens the “Linux workstation builds everything” story.

Second, bundling a JVM does not make the Linux package independent of Linux. The bundled JVM itself is a native Linux ELF program. It still depends on the kernel/userspace ABI, dynamic loader, glibc or other system interfaces, fontconfig/Freetype/X11/Wayland/graphics stack details, and whatever native libraries Compose/Skiko need.

So yes: your concern is valid. A 10-year-old bundled Linux JVM may still run — many do — but it is not the same kind of compatibility story as “old Windows app on Windows” or “old Windows app under actively maintained Proton”.

What can break for an old bundled JVM on future Linux?

A bundled JVM reduces dependency on distro package versions, but it does not freeze the whole OS. Failure modes include:

text
glibc / dynamic loader expectations
removed or changed X11/Wayland integration assumptions
fontconfig / Freetype / Harfbuzz behavior
GPU/OpenGL/Vulkan/Skia backend issues
sandbox/desktop portal expectations
filesystem layout assumptions
old TLS/certificate behavior if networking exists
packaging format rot: old .deb/.rpm metadata, maintainer scripts, dependencies
launcher scripts assuming old shell/env behavior

The scariest part is not usually bytecode compatibility. Java bytecode compatibility is relatively good. The risk is the native bottom half:

text
Compose UI
  ↓
Skiko
  ↓
Skia native library
  ↓
graphics/font/windowing stack
  ↓
Linux distro of 2036 or 2041

A calculator-like app is simple, but Compose Desktop is still not “just Swing buttons”. It brings a modern rendering stack.

Windows + bundled JVM is different

On Windows, bundling a JVM is more plausible long-term because Windows has historically treated old user-mode application compatibility as a central platform promise. A self-contained Windows Compose app from 2026 has a reasonable chance of launching on 2036 Windows, assuming no signing/SmartScreen/store-policy issue blocks it.

So Compose Desktop gives you two different stories:

text
Windows:
  fairly strong, because Windows app compatibility is strong

Linux native:
  weaker, because old Linux native binary compatibility is not the distro ecosystem’s main promise

This is exactly why Steam/Proton is interesting.

Proton’s promise

Valve describes Proton as the compatibility layer that lets Windows games run on Linux using a modified Wine plus high-performance graphics API implementations, and says most APIs are already supported, most games work out of the box, and Valve continues improving compatibility. [Steamworks] Proton’s own README says it is a Steam-client compatibility tool for Windows-only games/apps running on Linux and that most users should use the Proton version provided by Steam. [GitHub]

The key point is that your old binary is not directly facing future Linux.

It faces:

text
Win32 / Win64 API
  implemented by future Proton
    running on future Linux

That makes the Linux-side maintenance Valve’s problem, not yours, at least to a much larger extent.

For a simple GUI app, especially if you avoid hostile technologies, this is a good compatibility bet:

text
Win32 windowing
GDI/User32/Common Controls
or a bundled Windows UI runtime
ordinary files
ordinary input
no kernel drivers
no anti-cheat
no shell extensions
no browser runtime requirement

Proton’s long-term incentive is also aligned with you: Valve wants old and new Windows games to keep working on Linux/SteamOS. That means they are actively preserving and extending a Windows-compatibility substrate.

The subtle catch: Proton does not promise your exact old Proton forever

There are two different Proton models:

text
A. “Run my 2026 Windows app under whatever Proton ships in 2036.”
B. “Bundle/freeze a 2026 Proton and expect it to run on 2036 Linux.”

You want A, not B.

Freezing Proton would bring back the same problem as freezing a Linux JVM: that old Linux binary/runtime must still work on future Linux. The more robust model is to ship a boring Windows app and let Steam provide a current Proton implementation.

So the compatibility contract becomes:

text
Keep your app inside the boring, well-supported Windows API subset.
Let Valve/Wine keep adapting that API implementation to future Linux.

That is a stronger Linux-future story than shipping an old native Linux JVM + Skia stack.

KMP/Compose through Proton?

There is a hybrid option:

text
Build only the Windows Compose Desktop package.
Ship that Windows package through Steam.
Run it on Linux via Proton.

That sounds perverse, but for your goal it may actually be more coherent than shipping native Linux Compose.

The stack would be:

text
Your Kotlin/Compose app
  bundled Windows JVM
  Windows Skiko/Skia native libraries
  Win32 window
  Proton
  future Linux

Instead of:

text
Your Kotlin/Compose app
  bundled Linux JVM
  Linux Skiko/Skia native libraries
  future Linux directly

This lets you keep Kotlin/Compose while making the long-term Linux compatibility target Windows ABI via Proton, not native Linux ABI.

Would it work well? It needs testing. But conceptually, it matches your compatibility goal better than native Linux Compose.

The risk is that Compose Desktop’s Windows backend under Wine/Proton might use APIs that are less game-tested than ordinary Win32/GDI apps. Still, if a tiny Compose calculator works under Proton today, it may be a decent path. I would test it early.

Comparison table

CriterionNative KMP/Compose Desktop LinuxWindows app under Proton
Programming comfortExcellent if you like Kotlin/ComposeDepends on stack; can be Kotlin, C#, Rust, Zig, etc.
Automatic memory managementYes, JVM GCYes if using JVM/.NET; no if Zig/Rust
Linux package contains runtimeYes, bundled JVM via jpackage/jlinkNo Linux runtime from you; Steam supplies Proton
Direct exposure to future Linux ABIYesMostly no
Cross-building from LinuxCompose native distributions do not support cross-compilation in the standard pluginWindows cross-build possible with many stacks; JVM Windows packaging may still need care
10–15 year old binary on future LinuxUncertainBetter, if Proton continues improving
10–15 year old binary on WindowsReasonably goodReasonably good
Steam/Deck alignmentNative Linux possible, but less central to Valve’s main compatibility machineVery aligned with Proton/Steam Play
Failure modeOld Linux JVM/native Skia no longer likes future distroFuture Proton regression or unsupported Windows API usage

My ranking for your goal

For “release tomorrow, run in 10–15 years on Windows and Linux via Steam”:

text
1. Boring Windows app under current/future Proton
2. Windows JVM/.NET app under current/future Proton
3. Native Windows + native Linux app, both updated occasionally
4. Native Linux Compose Desktop bundle frozen for 10–15 years

If you accept periodic updates, native Compose Desktop becomes much more attractive. But if the bar is “the old release artifact itself keeps working”, native Linux is the weak side.

Practical implication for KMP/Compose

KMP/Compose Desktop is promising if your compatibility story is:

text
We will maintain and republish the app over time.
We can rebuild with newer JDK/Compose/Skiko.
We can produce per-OS packages.
We care about a modern Kotlin UI codebase.

Proton is more promising if your compatibility story is:

text
The artifact shipped in 2026 should still be launchable in 2036.
Linux users get compatibility updates from Steam/Proton, not from our app rebuilds.
We are okay targeting Windows as the stable binary ABI.

That is the main distinction.

What I would test next

I would run two spikes:

text
Spike A:
  Kotlin/Compose Desktop Windows package
  bundled Windows runtime
  add .exe to Steam on Linux
  force Proton
  test calculator UI

Spike B:
  Kotlin/Compose Desktop Linux package
  bundled Linux runtime
  run it inside a deliberately old/new distro matrix:
    current Fedora/Arch/Ubuntu
    SteamOS/Bazzite
    minimal container or distrobox where possible

If Spike A works cleanly under Proton, I would strongly consider the odd-sounding but coherent strategy:

text
Use Kotlin/Compose for developer productivity.
Ship the Windows build on Steam.
Let Windows run it natively.
Let Linux run it through Proton.
Do not ship a native Linux build unless there is a clear user benefit.

That gives you the programming model you like while still betting on the stronger long-term compatibility layer.

It still depends on the kernel/userspace ABI, dynamic loader, glibc or other system interfaces, fontconfig/Freetype/X11/Wayland/graphics stack details, and whatever native libraries Compose/Skiko need.

Yeah, while the kernel ABI is stable, the rest on Linux is... well, not. And that's indeed fatal.

There is a hybrid option:

Obviously, but then I need to build (even for development/testing!) on Windows, which means either a VM, or remote host, both of which add overhead I wish to avoid.

Undisputably, though, Compose is nice. Not only am I already familiar with it, it's powerful, expressive, and indeed composable (mostly due to values in-callbacks out model) more than most other GUI frameworks. It's also relatively self-contained in its implementation, so it seems less costly to integrate and bundle than something like Qt.

How would dotNet fare here? I know they went multiplatform and there should also be AOT compiler there. It it possible to build long-term-viable bundles that would run after a decade on Linux and Windows?

The .NET case is subtler than JVM/Compose because .NET gives you several deployment modes: framework-dependent, self-contained, single-file, trimmed, and Native AOT. The long-term answer depends heavily on which one you choose and which GUI stack sits on top.

.NET fares better than native Linux Compose/JVM in some ways, but it does not solve the fundamental Linux problem if you ship a native Linux bundle and expect that exact artifact to keep working for 10–15 years.

The short version of my conclusion:

text
id="jv6wnz"
Windows .NET bundle:
  plausible long-term viability.

Linux .NET bundle:
  better than “random native Linux app”,
  but still not a 10–15 year binary-compatibility contract.

Linux via Proton running the Windows .NET bundle:
  probably the stronger long-term Steam-oriented strategy,
  if it works today and avoids exotic APIs.

.NET deployment modes that matter

There are four relevant .NET deployment styles.

1. Framework-dependent

text
id="umztyx"
Your app
+ requires user/system to have compatible .NET runtime installed

Bad for your goal. It depends on the machine having the right runtime.

2. Self-contained

text
id="7p5p5d"
Your app
+ bundled .NET runtime
+ managed assemblies
+ native runtime pieces

This is the normal “bundle the runtime” option. Microsoft’s docs say self-contained deployment creates a platform-specific executable and includes all required .NET files, but does not include the native dependencies of .NET, which must already be present in the environment. They also note that the bundled runtime does not roll forward to newer .NET security patches unless you ship a new app version. [Microsoft Learn]

That means self-contained .NET is not hermetic on Linux.

3. Single-file self-contained

text
id="b2zf37"
One executable-ish bundle
+ still OS/architecture-specific
+ may extract or map internal components

Useful for distribution neatness, but not a magic compatibility layer. Microsoft’s docs explicitly say single-file apps are OS/architecture-specific, such as linux-x64, linux-arm64, win-x64, etc. [Microsoft Learn]

4. Native AOT

text
id="u760xv"
Ahead-of-time compiled native binary
+ no JIT at runtime
+ self-contained
+ smaller/faster in some cases

Native AOT is real and increasingly useful. Microsoft says Native AOT compiles IL to native code at publish time, does not use a JIT at runtime, can run without a .NET runtime installed, and targets a specific runtime environment such as linux-x64 or win-x64. [Microsoft Learn]

But again: on Linux, it is still a Linux native binary. Microsoft’s Native AOT docs state that a Native AOT binary produced on a Linux machine is only going to work on the same or newer Linux version; their example says an Ubuntu 20.04-produced binary runs on Ubuntu 20.04 and later, but not Ubuntu 18.04. [Microsoft Learn]

That “same or newer” rule is useful for deployment today, but it is not a 15-year promise.

The fatal Linux issue remains

Self-contained .NET and Native AOT both reduce what you depend on, but they do not remove Linux userspace dependency.

For self-contained .NET, Microsoft’s own docs say native dependencies are not included and must exist on the target environment. [Microsoft Learn] Their Linux self-contained-app guidance also talks about gathering native dependencies and recommends using the oldest supported distro as the source so the packaged dependencies do not require libc/libstdc++ symbols only available in newer versions. [GitHub]

So the compatibility problem becomes:

text
id="os286p"
Can my old bundled .NET runtime / AOT binary / GUI native library
still run against the Linux userspace and desktop stack of 2036 or 2041?

For a console tool, maybe.

For a GUI app, less certain.

A .NET GUI app still has a bottom half:

text
id="exzogb"
Avalonia / MAUI / other UI stack
  ↓
Skia / native graphics / windowing backend
  ↓
fontconfig / freetype / x11 / wayland / glibc / libstdc++ / GPU drivers
  ↓
future Linux distro

This is the same shape as Compose/Skiko, just with .NET/Avalonia instead of JVM/Compose.

Avalonia specifically

Avalonia is probably the .NET UI stack most relevant here. It supports Native AOT for desktop applications, and its docs say Native AOT allows Avalonia apps to be published as self-contained executables with native performance characteristics. [Avalonia Docs]

But the same docs also list AOT-specific constraints: compiled XAML, avoiding dynamic XAML loading, using static resources where possible, avoiding reflection-based service location, and watching out for third-party controls that may not be AOT-compatible. [Avalonia Docs]

So Avalonia Native AOT is attractive, but not “turn on a switch and all dynamic desktop-app patterns remain fine”.

For a calculator clone, though, this is actually manageable:

text
id="wilab3"
fixed views
static controls
little/no reflection
small view model graph
no plugin system
no dynamic XAML loading

That is a pretty good AOT-shaped app.

Cross-compilation story

This is one place where .NET is more attractive than Compose Desktop packaging.

.NET uses Runtime Identifiers, or RIDs, such as linux-x64, win-x64, and osx-x64, to select platform-specific assets and publish targets. [Microsoft Learn] In ordinary .NET CLI workflows, publishing for a RID is first-class:

sh
id="7db8av"
dotnet publish -c Release -r win-x64 --self-contained true
dotnet publish -c Release -r linux-x64 --self-contained true

For Native AOT, cross-targeting is more constrained because you need native toolchains/linkers and platform libraries. But compared with Compose Desktop’s jpackage-based native packaging model, .NET’s RID-based publishing model is much more naturally designed around producing target-specific outputs.

For your “develop on Linux, avoid Windows VM for dev/test” preference, .NET looks better than Compose if your chosen UI stack and publish mode cross-build cleanly in practice.

Windows story

On Windows, .NET self-contained or Native AOT is fairly plausible for decade-scale execution.

A Windows .exe produced by:

sh
id="63tlai"
dotnet publish -c Release -r win-x64 --self-contained true

or:

xml
id="n6ac7c"
<PublishAot>true</PublishAot>

has a reasonable compatibility story because it faces the Windows user-mode compatibility layer, not the Linux distro ABI churn.

Native AOT may improve the “old runtime” concern because you are not bundling a full JIT runtime in the same way, but it does not make Windows compatibility absolute. Still, Windows is the friendlier side here.

Linux native story

For native Linux .NET bundles, I would rank the deployment modes like this for long-term launchability:

text
id="3tgj7u"
Best:  Native AOT built on an intentionally old baseline distro
Next:  self-contained built on an intentionally old baseline distro
Worst: framework-dependent

But even the best option is only “best among native Linux bundles”. It is not equivalent to a stable Linux desktop ABI.

If you build a Native AOT Avalonia app on, say, an old supported baseline, bundle the right dependencies, and avoid fancy desktop integration, I would expect it to survive a decent while. Would I bet on the exact 2026 artifact launching unchanged in 2041 on mainstream Linux desktops? No.

The failure mode may be something stupid and fatal:

text
id="i1c482"
old libSkia wants an old fontconfig behavior
old app assumes X11 path, user is Wayland-only
old bundled native library needs removed/renamed system library
glibc symbol/versioning mismatch in some dependency
GPU/renderer backend issue

Containers/AppImage/Flatpak-like packaging can reduce this, but they introduce their own long-term runtime assumptions. And if you rely on Steam anyway, Proton is the container/compatibility story that Valve is actually incentivized to maintain.

.NET Windows build under Proton

This is the interesting option:

text
id="u0vvp1"
Develop on Linux
↓
Cross-publish win-x64 .NET app
↓
Test the Windows .exe under Proton as a non-Steam game
↓
Ship Windows build on Steam
↓
Windows users run it natively
↓
Linux users run it through current/future Proton

For Avalonia, the stack would be:

text
id="r8e6he"
Your C#/F# app
Avalonia
Windows .NET runtime or Native AOT
Windows Skia/native bits
Win32 windowing
Proton
future Linux

That pushes the Linux compatibility problem from “old Linux-native runtime” to “future Proton still runs this old Windows app”.

For Steam distribution, I still think that is the better long-term bet.

The practical caveat: test it immediately. Do not assume Avalonia-on-Windows-under-Proton is as boring as raw Win32 buttons. Make a tiny calculator shell and test:

text
id="fe7cr4"
self-contained win-x64 JIT version under Proton
Native AOT win-x64 version under Proton
text rendering
button clicks
keyboard input
window resize
DPI scaling
Steam overlay

If both work, I would favor the Windows/Proton strategy.

Does Native AOT help with Proton?

Maybe, but not automatically.

Native AOT advantages:

text
id="w94hh0"
no JIT
less runtime machinery
possibly smaller deployment
faster startup
fewer dynamic-code surprises under compatibility layers

Native AOT disadvantages:

text
id="qobfsm"
trimming constraints
reflection constraints
some libraries incompatible
more build/linker complexity
less forgiving app architecture

For a calculator clone, Native AOT is a good fit. For a large UI app with plugins, dynamic loading, reflection-heavy DI, runtime XAML tricks, or lots of third-party controls, self-contained JIT may be easier.

So I would not start with “AOT or bust”. I would start with:

text
id="jtthpi"
First: self-contained win-x64
Then: Native AOT win-x64
Compare output size, startup, Proton behavior, and dev friction.

Compared with Kotlin/Compose

For your exact constraints:

QuestionKotlin/Compose Desktop.NET/Avalonia
Nice declarative-ish UIYes, veryYes, though different model
GC / automatic memory mgmtYesYes unless Native AOT still has GC, but compiled native; memory remains managed
Linux dev experienceExcellentGood
Cross-publish Windows from LinuxCompose native packaging is weak hereBetter RID-based story
Native Linux long-term bundleWeak/uncertainAlso uncertain, perhaps slightly better with AOT
Windows artifact under ProtonWorth testingVery worth testing
Static/small native outputNoPossible with Native AOT, within limits
UI framework self-containednessCompose + SkikoAvalonia + Skia/native bits

Important detail: Native AOT does not mean “no GC” in the C/Rust/Zig sense. It is still managed .NET semantics, but compiled ahead of time. That fits your preference: you get automatic memory management without shipping a JIT-dependent app.

My concrete recommendation

If your goal remains 10–15 year launchability through Steam, I would not pursue native Linux .NET as the primary artifact.

I would test this instead:

text
id="czwujh"
C# or F#
Avalonia
Publish win-x64 self-contained
Publish win-x64 Native AOT
Run both under Steam Proton on Linux

Then decide:

text
id="r5b59k"
If Avalonia Windows builds behave under Proton:
  ship Windows build only, even to Linux Steam users.

If Avalonia under Proton is flaky:
  consider Rust + Slint/Iced Windows build under Proton.

If you need native Linux outside Steam:
  build native Linux .NET/Avalonia too, but treat it as a maintained artifact,
  not a 15-year frozen binary promise.

For native Linux specifically, .NET is not a magic escape hatch. It is a good modern runtime with good deployment tooling and real AOT, but the Linux GUI stack underneath still moves. For Steam-oriented long-term compatibility, the most robust shape still looks like:

text
id="h8i05q"
Windows ABI as the artifact contract
+
current/future Proton as Linux compatibility layer
+
boring dependencies and aggressive early Proton testing

Ok. Let's write a prompt for protocalc setup. We will use dotnet, Avalonia, and F# for logic, with C# where necessary. We will build on Linux, for Windows, and test under Proton. We will ignore Native AOT compilation for now, but will focus on bundling what we need and using what's already provided/compatible with Proton. We should also leverage the Linux-native build for development and testing, so the agent should build both versions. The agent should build and run the Linux build directly before finishing - but this is for development only, for full testing and for future Linux users, we still target a "non-Steam game" path.

Here’s a coding-agent prompt tuned for the protocalc setup and the exact compatibility experiment you described.

:::writing{variant="standard" id="73918"}

We are creating a new prototype project named protocalc.

Goal: build a small calculator-like GUI app using .NET, Avalonia, and F# for application/domain logic, with C# where Avalonia tooling or interop makes that easier. Development happens on Linux. We must produce both:

  1. a Linux-native development build, used for fast local development/testing only;
  2. a Windows x64 build, produced from Linux, intended to be tested on Linux through Steam as a “non-Steam game” using Proton.

The long-term distribution experiment is: ship the Windows build through Steam and rely on Proton for Linux users. The Linux-native build is not the long-term compatibility target; it exists only to make development convenient.

Do not use Native AOT yet. Use ordinary .NET self-contained publishing where appropriate. Focus on bundling what the app needs and avoiding dependencies that are likely to be problematic under Proton. Prefer boring, self-contained, Proton-friendly choices.

Project requirements:

  • App name: ProtoCalc
  • Repository/project root: protocalc
  • UI framework: Avalonia
  • Primary UI shell language: C# if that is the path of least resistance for Avalonia templates/tooling
  • Logic language: F#
  • Calculator logic should live in an F# project/library
  • UI should call into the F# logic library
  • Target framework: use a current stable .NET SDK installed on the machine
  • Do not use Native AOT
  • Do not use WPF, WinForms, MAUI, WebView, Electron, Tauri, GTK, Qt, or browser-based UI
  • Do not require a system-installed .NET runtime for the Windows distribution build
  • Do not require Steamworks SDK integration
  • Do not create an installer yet
  • Do not optimize for package size yet
  • Keep the first version simple and explicit

The application itself should be a minimal calculator clone:

  • One read-only display field at the top
  • A grid of buttons
  • Basic mode with:
    • digits 09
    • decimal point
    • +, -, *, /
    • =
    • clear button
    • backspace/delete button if easy
  • Scientific mode toggle
  • Scientific mode should show a second set of buttons or expanded grid with a few placeholder operations such as:
    • sin
    • cos
    • tan
    • sqrt
    • pow
    • parentheses, if easy
  • The scientific operations do not need to be mathematically complete in the first pass. It is acceptable for some to be stubs, but the UI mode switching must work.
  • Keyboard input is nice to have, but not required for the first version.
  • The UI should be boring, clear, and functional rather than visually polished.

Architecture:

  • Use a solution file.
  • Suggested projects:
    • ProtoCalc.Core — F# class library containing calculator state, messages/commands, and update/evaluation logic.
    • ProtoCalc.App — Avalonia desktop app, likely C#, referencing ProtoCalc.Core.
    • Optional ProtoCalc.Core.Tests — F# or C# tests for the calculator core, if quick to add.
  • Keep the calculator logic independent of Avalonia.
  • Model calculator input as typed commands/messages rather than having UI buttons directly mutate arbitrary strings everywhere.
  • Prefer immutable or mostly immutable F# data structures for calculator state.
  • Keep UI state synchronization simple.

Suggested logical model:

  • F# discriminated union for calculator commands, for example:
    • Digit of int
    • DecimalPoint
    • Operator of operator
    • Equals
    • Clear
    • Backspace
    • ToggleScientific
    • ScientificOp of scientific operation
  • F# record for calculator state:
    • display text
    • current mode: Basic or Scientific
    • any accumulator/operator/input state needed
  • One main update function:
    • update : CalculatorCommand -> CalculatorState -> CalculatorState
  • One initial state value/function.
  • The C# Avalonia view model should call the F# update function and expose observable properties for the display and mode.

Build/publish requirements:

  • The agent must verify normal local Linux development build:
    • restore
    • build
    • run the Linux-native app directly
  • The agent must build the Windows x64 app from Linux.
  • The Windows publish should be self-contained for win-x64.
  • The Linux development publish/build can be framework-dependent or self-contained, but direct local run must work.
  • Use commands that are reproducible from a clean checkout on Linux.
  • Provide exact commands.

Expected commands should be roughly like:

sh
dotnet restore
dotnet build
dotnet run --project src/ProtoCalc.App/ProtoCalc.App.csproj
dotnet publish src/ProtoCalc.App/ProtoCalc.App.csproj -c Release -r win-x64 --self-contained true -o dist/win-x64
dotnet publish src/ProtoCalc.App/ProtoCalc.App.csproj -c Release -r linux-x64 --self-contained true -o dist/linux-x64

Adjust exact paths/project names as needed.

Important: before finishing, the agent must actually build the solution and run the Linux-native app directly. If a GUI display is unavailable in the execution environment, the agent must still perform restore/build/publish and clearly say that direct GUI launch could not be verified due to missing display/session, rather than pretending it ran.

Steam/Proton test instructions to include in the final result:

  • Use the Windows output directory, not the Linux one.
  • In Steam on Linux:
    • Games → Add a Non-Steam Game to My Library
    • Browse to dist/win-x64/ProtoCalc.exe
    • Add it
    • Open Properties for the non-Steam entry
    • Compatibility → force a Steam Play compatibility tool
    • Start with Proton Experimental or the latest stable Proton
    • Launch from Steam
  • Optionally set launch options:
    • PROTON_LOG=1 %command%
  • Explain where to look for Proton logs if known, but do not rely on logs for normal use.
  • The Linux-native build is only for development convenience; final Linux-user validation should use the Windows build through Proton.

Proton-friendly constraints:

  • Avoid Windows-only APIs outside what Avalonia/.NET needs.
  • Avoid WebView2.
  • Avoid WPF.
  • Avoid native plugins.
  • Avoid reflection-heavy plugin systems.
  • Avoid dynamic loading tricks.
  • Avoid writing to the install directory.
  • Store any future app data under normal user app-data locations, not next to the executable.
  • Keep file paths and environment assumptions boring.

Deliverables:

  1. Create the project structure.
  2. Add the F# core library.
  3. Add the Avalonia app.
  4. Wire UI buttons to the F# calculator logic.
  5. Add a basic/scientific mode toggle.
  6. Add a README with:
    • project purpose
    • architecture
    • Linux development commands
    • Windows publish command from Linux
    • Linux publish command for development
    • Steam/Proton non-Steam-game test steps
    • known limitations
  7. Build the solution.
  8. Run the Linux-native app directly if possible.
  9. Publish the Windows x64 self-contained build.
  10. Report exactly what commands were run and whether they succeeded.

Do not stop after only generating code. Actually create the project, build it, and fix compile/runtime errors encountered during the setup.

If Avalonia templates are not installed, install or invoke the standard Avalonia template package using dotnet new install Avalonia.Templates, then create the app. If template names differ on the installed version, inspect available templates and adapt.

Keep the first implementation small. The priority is proving the build/development/distribution shape:

Linux development → Avalonia app runs natively for fast iteration → Windows x64 self-contained artifact builds from Linux → Windows artifact can be tested through Steam/Proton.

:::

I’d keep the first pass deliberately “boring”: no AOT, no Steamworks, no installer, no custom packaging. Once the Windows self-contained Avalonia build works under Proton, then it makes sense to tighten publish layout, logging, versioning, and reproducibility.

After having added the .exe as a non-Steam game, is there a convenient CLI way of starting it under Steam/Proton?

I’ll answer this as “already added to Steam, now launch the existing shortcut via Steam”, not as “run Proton directly”. The former preserves the same compat-tool/settings path you actually want to test.

Yes. Once it is already in Steam, the convenient path is to launch the Steam shortcut, not Proton directly:

sh
xdg-open 'steam://rungameid/<ID>'

or sometimes:

sh
steam 'steam://rungameid/<ID>'

steam://rungameid/<id> is the Steam browser-protocol command intended to launch games, including non-Steam shortcuts. Valve’s wiki documents rungameid as supporting “mods and non-Steam shortcuts”; -applaunch <appID> is for Steam apps and does not apply to non-Steam shortcuts. [Valve Developer Community] [Valve Developer Community]

Easiest way to get the ID

In Steam:

  1. Right-click the non-Steam game.
  2. Manage → Add desktop shortcut.
  3. Inspect the generated .desktop file.

It should contain an Exec= line similar to:

desktop
Exec=steam steam://rungameid/12345678901234567890

Then you can run:

sh
xdg-open 'steam://rungameid/12345678901234567890'

or put that in a script:

sh
#!/usr/bin/env bash
set -euo pipefail

xdg-open 'steam://rungameid/12345678901234567890'

With Proton logging

For a non-Steam shortcut, you usually set this in the shortcut’s Steam launch options:

text
PROTON_LOG=1 %command%

Then launch it from CLI with the same steam://rungameid/... URL. Steam will still use the shortcut’s configured compatibility tool and launch options.

Where the shortcut data lives

Non-Steam shortcuts are stored in Steam’s shortcuts.vdf, commonly under:

text
~/.local/share/Steam/userdata/<your-steam-user-id>/config/shortcuts.vdf

Valve’s wiki notes that non-Steam games are stored in userdata/<User ID>/config/shortcuts.vdf. [Valve Developer Community]

You can script extraction from that file, but for one app the desktop-shortcut method is much simpler and less brittle.

Important limitation

This launches the already-configured Steam shortcut. It is not a good way to pass arbitrary arguments dynamically. For non-Steam shortcuts, set arguments/environment in Steam’s Properties → Launch Options instead; community reports and Steam discussions note that external rungameid launch does not conveniently support extra arguments the way a shell command would. [steamcommunity.com]

Okay. The project works, but it's kind of hilarious: the protopaint binary is 1.7Mb and runs on raw wine without problems; the dotnet bundle is 207Mb and only runs through Proton under steam.

Yeah, that result is very unsurprising — and also a useful sanity check.

You compared two almost-opposite deployment models:

text
ProtoPaint:
  tiny Win64 executable
  direct Win32/user32/gdi32
  no framework runtime
  Wine already implements the needed API surface
  ≈ 1.7 MB

ProtoCalc:
  Avalonia app
  bundled .NET runtime
  managed assemblies
  Avalonia assemblies
  Skia/Skiko-ish native rendering stack
  platform assets
  framework support code
  ≈ 207 MB

The funny part is that the 207 MB bundle is the price of “not writing WinAPI by hand”. It buys you layout, styling, bindings, a modern widget/rendering model, GC, C#/F# ergonomics, and a large runtime. The raw Win32 app buys you excellent artifact simplicity but gives you 1990s UI plumbing back.

The more important signal is this:

text
Raw Wine runs ProtoPaint.
Steam/Proton runs ProtoCalc.
Raw Wine does not run ProtoCalc.

That suggests the .NET/Avalonia Windows bundle is relying on something that Proton sets up better than your system Wine: likely graphics, font/rendering, runtime DLL behavior, environment setup, or Wine version/config differences. It does not necessarily mean the app is fragile under Proton; it does mean “plain Wine compatibility” is not the same target.

For the long-term Steam goal, I would interpret this as:

text
Raw Win32/GDI:
  strongest compatibility + tiny artifact + high manual UI cost

.NET/Avalonia:
  good development ergonomics + huge artifact + compatibility depends on Proton quality

Native Linux .NET/Avalonia:
  useful dev path, but not your long-term frozen-binary story

Windows .NET/Avalonia under Proton:
  plausible, but now you are betting on Proton supporting a fairly complex Windows desktop stack

207 MB is large, but on Steam it is not automatically disqualifying. For a calculator-like app it feels absurd; for a real GUI app it may be acceptable. The real question is whether the dependency stack remains boring enough that Proton will continue to carry it.

I’d probably keep both prototypes as reference points:

text
protopaint = compatibility/minimalism baseline
protocalc  = productivity/framework baseline

Then test a middle option. The obvious candidates are:

text
Rust + native Win32 wrappers:
  probably still small, native controls, better safety than C/Zig WinAPI

Rust + Slint:
  much smaller than .NET likely, declarative UI, native-code deployment

Rust + Iced/egui:
  convenient, but custom-rendered UI and bigger graphics stack

Zig + zigwin32 + small self-written UI wrapper:
  tiny and robust, but you still own the framework

C#/F# + Win32 bindings:
  GC and F# logic, much smaller than Avalonia maybe, but UI plumbing returns

For your specific “calculator clone” test, the most interesting next experiment would be Rust + Slint, because it occupies the space between the two extremes:

text
not raw WinAPI
not a 200 MB managed desktop bundle
cross-buildable from Linux
Windows artifact usable under Proton
declarative GUI model

If Slint produces, say, a 5–30 MB Windows bundle and runs under raw Wine/Proton, that would be a very strong data point.

The deeper lesson is: Proton is an excellent ABI preservation strategy, but it does not make heavyweight app frameworks cheap. It preserves the Windows-facing contract; it does not remove the cost of whatever runtime you decide to bring through that contract.

Obviously, the dotNet bundle is far from optimized for size, too. But at this artifact sizes, even things like Electron-wrapped web pages passing as apps don't seem THAT crazy (Overton Window at work). Let's try to generate a Rust + Slint version of ProtoCalc, just to check its viability. Generate the prompt for a coding agents setup.

Here’s a prompt for a Rust + Slint protocalc viability spike, keeping the same Steam/Proton distribution assumption.

:::writing{variant="standard" id="18462"}

We are creating a new prototype project named protocalc-slint.

Goal: build the same minimal calculator-like GUI app as the previous .NET/Avalonia ProtoCalc experiment, but using Rust + Slint. We are evaluating whether Rust + Slint gives us a smaller, simpler, Proton-friendly Windows artifact while still avoiding raw WinAPI GUI plumbing.

Development happens on Linux. We must produce both:

  1. a Linux-native development build, used for fast local development/testing only;
  2. a Windows x64 build, produced from Linux, intended to be tested on Linux through Steam as a “non-Steam game” using Proton.

The long-term distribution experiment is still: ship the Windows build through Steam and rely on Proton for Linux users. The Linux-native build is only for development convenience.

Project constraints:

  • App name: ProtoCalc Slint
  • Repository/project root: protocalc-slint
  • Language: Rust
  • UI framework: Slint
  • Do not use Tauri, Electron, WebView, GTK, Qt, Avalonia, .NET, JVM, WPF, WinForms, SDL, GLFW, egui, iced, or raw WinAPI except what Slint uses internally.
  • Do not use Native AOT-like or exotic compiler modes; use normal Rust release builds first.
  • No Steamworks SDK integration.
  • No installer yet.
  • No external runtime dependency should be required for the Windows artifact beyond what is normally present/provided by Windows/Proton or bundled by Rust/Slint.
  • Keep the first version small, explicit, and easy to inspect.
  • Prefer a static or mostly self-contained Windows .exe if feasible.
  • Do not optimize for minimum size until the normal build works, but record artifact sizes.

The app should be a minimal calculator clone:

  • One read-only display field at the top.
  • A grid of buttons.
  • Basic mode with:
    • digits 09
    • decimal point
    • +, -, *, /
    • =
    • clear button
    • backspace/delete button if easy
  • Scientific mode toggle.
  • Scientific mode should switch to an expanded or alternate button set with a few operations:
    • sin
    • cos
    • tan
    • sqrt
    • pow
    • parentheses, if easy
  • Scientific operations may be minimal or stubbed in the first pass, but the mode switching must be visible and functional.
  • Keyboard input is optional.
  • UI should be plain, readable, and functional.

Architecture:

  • Keep calculator logic independent of Slint where practical.
  • Suggested layout:
text
protocalc-slint/
  Cargo.toml
  build.rs
  src/
    main.rs
    calc.rs
  ui/
    protocalc.slint
  README.md
  • calc.rs should define the calculator state and typed commands.
  • UI event handlers in main.rs should convert button presses into calculator commands.
  • The calculator core should not directly depend on Slint types unless avoiding that would overcomplicate the prototype.

Suggested Rust model:

rust
pub enum Mode {
    Basic,
    Scientific,
}

pub enum Operator {
    Add,
    Subtract,
    Multiply,
    Divide,
}

pub enum ScientificOp {
    Sin,
    Cos,
    Tan,
    Sqrt,
    Pow,
}

pub enum Command {
    Digit(u8),
    DecimalPoint,
    Operator(Operator),
    Equals,
    Clear,
    Backspace,
    ToggleScientific,
    Scientific(ScientificOp),
    OpenParen,
    CloseParen,
}

pub struct CalculatorState {
    pub display: String,
    pub mode: Mode,
    // add accumulator/operator/input state as needed
}

impl CalculatorState {
    pub fn new() -> Self;
    pub fn update(&mut self, command: Command);
}

The arithmetic implementation can be simple. It does not need to be a full expression parser. A sequential calculator model is enough:

text
12 + 3 = 15
15 * 2 = 30

For scientific operations:

  • sqrt may operate immediately on the displayed value.
  • sin, cos, tan may operate immediately on the displayed value using radians.
  • pow may be a placeholder or binary operation if easy.
  • Parentheses may be shown but stubbed if a parser is not implemented.
  • If an operation is unimplemented, display a short harmless placeholder such as Not implemented or leave it as a no-op, but document it.

Slint UI requirements:

  • Use a .slint file for the UI.
  • Define callbacks for button actions.
  • Define properties for:
    • display text
    • current mode / whether scientific buttons are visible
  • Main window should have:
    • display at top
    • button grid below
    • mode toggle button
  • The visual layout should resize reasonably, but does not need polish.
  • Avoid custom native menus for this prototype unless Slint makes them trivial.

Build requirements:

  • Add the necessary Slint crate/build setup.
  • Use slint-build if needed.
  • Use current stable Rust.
  • The agent must build the Linux-native version.
  • The agent must run the Linux-native app directly before finishing if a GUI display is available.
  • The agent must build a Windows x64 release artifact from Linux.
  • The agent must report the artifact size of the produced Linux and Windows release binaries.
  • The agent must record the exact commands used.

Likely commands:

sh
cargo build
cargo run
cargo build --release
cargo build --release --target x86_64-pc-windows-gnu

If the Windows target is missing, install it:

sh
rustup target add x86_64-pc-windows-gnu

If a linker/toolchain is missing, install or document the required MinGW-w64 package for the local distro. On Debian/Ubuntu-like systems this is usually something like:

sh
sudo apt install mingw-w64

On Fedora-like systems this may be something like:

sh
sudo dnf install mingw64-gcc

Do not assume package manager access is available. If the toolchain cannot be installed in the environment, still create the project, build the Linux version, and clearly document what is missing for the Windows build.

Windows/Proton testing instructions to include in README:

  • Use the Windows artifact, not the Linux binary.
  • In Steam on Linux:
    • Games → Add a Non-Steam Game to My Library
    • Browse to the produced Windows .exe, likely under:
      • target/x86_64-pc-windows-gnu/release/protocalc-slint.exe
    • Add it.
    • Open Properties for the non-Steam entry.
    • Compatibility → force a Steam Play compatibility tool.
    • Start with Proton Experimental or the latest stable Proton.
    • Launch from Steam.
  • Optional launch option:
    • PROTON_LOG=1 %command%
  • The Linux-native build is only for development convenience. Final Linux-user validation should use the Windows build through Proton.

Also include a raw Wine smoke-test step if Wine is installed:

sh
wine target/x86_64-pc-windows-gnu/release/protocalc-slint.exe

But do not treat raw Wine failure as final failure if Proton works.

Deliverables:

  1. Create the full Rust + Slint project.
  2. Implement the calculator UI.
  3. Implement the independent calculator core.
  4. Build and run the Linux-native app if possible.
  5. Build the Windows x64 release artifact from Linux if the toolchain is available.
  6. Report:
    • Rust version
    • Slint crate version
    • exact commands run
    • whether Linux build succeeded
    • whether Linux app was launched
    • whether Windows cross-build succeeded
    • Windows .exe path
    • Windows .exe size
    • Linux release binary path
    • Linux release binary size
  7. Add a README with:
    • project purpose
    • architecture
    • Linux dev commands
    • Windows cross-build commands
    • Steam/Proton non-Steam-game test steps
    • raw Wine smoke-test command
    • known limitations
    • notes on artifact size

Do not stop after only generating code. Actually create the project, build it, and fix compile errors encountered during the setup. If GUI launch is impossible because no display is available, say so explicitly and continue with build/publish verification.

:::

A useful extra acceptance criterion: have the agent compare the final Windows .exe size against the earlier .NET bundle size in the README, without claiming victory until it also launches under Steam/Proton.

It worked. Rust + Slint, with mingw toolchain, produced windows binaries that work under wine and proton. Initially, only proton worked, but embedding missing fonts solved the problem. The release binary/exe is 29 MB on Windows, 20MB on Linux. The linux build loads a bunch of dynamic libraries/.so - how can I inspect the windows build to list libraries it loads, while working on linux, with mingw and windows-gnu target added to rust?

You have two different questions:

text
1. What DLLs are statically imported by the .exe?
2. What DLLs are actually loaded at runtime under Wine/Proton?

You want both. The first is like ldd for PE/Windows binaries. The second catches dynamic loads, font/rendering backends, plugins, LoadLibrary, Wine builtin/native choices, etc.

1. Static PE import table: objdump -p

With MinGW installed, this is usually the first command:

sh
x86_64-w64-mingw32-objdump -p target/x86_64-pc-windows-gnu/release/protocalc-slint.exe \
  | sed -n '/DLL Name:/p'

Example output shape:

text
DLL Name: KERNEL32.dll
        DLL Name: USER32.dll
        DLL Name: GDI32.dll
        DLL Name: ADVAPI32.dll
        DLL Name: SHELL32.dll
        DLL Name: MSVCRT.dll
        DLL Name: libgcc_s_seh-1.dll
        DLL Name: libwinpthread-1.dll

Cleaner:

sh
x86_64-w64-mingw32-objdump -p target/x86_64-pc-windows-gnu/release/protocalc-slint.exe \
  | awk -F': ' '/DLL Name:/ { print $2 }' \
  | sort -u

This only lists direct imports. If your .exe imports foo.dll, and foo.dll imports bar.dll, you need to inspect foo.dll too.

For every .exe/.dll in the release directory:

sh
find target/x86_64-pc-windows-gnu/release -maxdepth 1 \
  \( -name '*.exe' -o -name '*.dll' \) \
  -print0 |
while IFS= read -r -d '' f; do
  echo
  echo "== $f =="
  x86_64-w64-mingw32-objdump -p "$f" |
    awk -F': ' '/DLL Name:/ { print "  " $2 }' |
    sort -u
done

2. Better structured static inspection: llvm-readobj

If you have LLVM tools:

sh
llvm-readobj --coff-imports target/x86_64-pc-windows-gnu/release/protocalc-slint.exe

or:

sh
llvm-objdump -p target/x86_64-pc-windows-gnu/release/protocalc-slint.exe

I usually prefer objdump -p for quick checks and llvm-readobj --coff-imports when I want less human-oriented output.

3. Runtime DLL loading under Wine

Wine can log DLL loading:

sh
WINEDEBUG=+loaddll wine target/x86_64-pc-windows-gnu/release/protocalc-slint.exe 2>&1 \
  | tee wine-dll-load.log

Then extract DLL-ish lines:

sh
grep -i 'load.*dll\|builtin\|native' wine-dll-load.log

This is the one that will reveal things the static import table misses: dynamically loaded font/rendering/system libraries, Wine builtin DLLs, etc.

Useful focused variant:

sh
WINEDEBUG=+loaddll,+module wine target/x86_64-pc-windows-gnu/release/protocalc-slint.exe 2>&1 \
  | tee wine-modules.log

Expect a lot of noise. That noise is sometimes the point.

4. Runtime loading under Steam/Proton

For the Steam non-Steam shortcut, set launch options:

text
PROTON_LOG=1 WINEDEBUG=+loaddll %command%

Then launch via Steam.

The Proton log is usually written in your home directory as something like:

text
~/steam-<shortcut-id>.log

or for real Steam apps:

text
~/steam-<appid>.log

Then inspect:

sh
grep -i 'loaddll\|builtin\|native' ~/steam-*.log | less

If that is too noisy:

sh
grep -i 'protocalc\|slint\|font\|gdi\|dwrite\|user32\|kernel32\|ucrt\|msvcrt' ~/steam-*.log

5. Check whether Rust/MinGW pulled in GCC/pthread DLLs

A Rust x86_64-pc-windows-gnu build can depend on MinGW runtime DLLs depending on linking mode and crates. Look specifically for:

text
libgcc_s_seh-1.dll
libstdc++-6.dll
libwinpthread-1.dll

Run:

sh
x86_64-w64-mingw32-objdump -p target/x86_64-pc-windows-gnu/release/protocalc-slint.exe \
  | grep -Ei 'dll name|gcc|stdc|pthread|ucrt|msvcrt'

If you see libwinpthread-1.dll or libgcc_s_seh-1.dll, make sure they are either:

text
- next to the .exe in the shipped directory, or
- not needed after changing static-link options.

For a Steam artifact, I would prefer to make the release directory explicit:

text
dist/win-x64/
  protocalc-slint.exe
  any-required.dll
  embedded fonts/assets

Then inspect dist/win-x64, not just target/....

6. Trace native Linux libraries Wine itself loads

This is different from Windows DLLs. It tells you what Linux .sos Wine loads while running your Windows app:

sh
strace -f -e trace=openat,open,access wine target/x86_64-pc-windows-gnu/release/protocalc-slint.exe 2>&1 \
  | grep -E '\.so|\.dll|fonts' \
  | tee wine-file-loads.log

This is useful for diagnosing “works on my Wine but not another Wine” or font/rendering problems, but it is not the same as the app’s Windows-side DLL dependency list.

7. Quick “ldd equivalent” script for PE files

Create pe-imports.sh:

sh
#!/usr/bin/env bash
set -euo pipefail

for f in "$@"; do
  echo "== $f =="
  x86_64-w64-mingw32-objdump -p "$f" |
    awk -F': ' '/DLL Name:/ { print $2 }' |
    sort -u
  echo
done

Use:

sh
chmod +x pe-imports.sh

./pe-imports.sh \
  target/x86_64-pc-windows-gnu/release/protocalc-slint.exe

For all PE files in a directory:

sh
find dist/win-x64 -maxdepth 1 \( -name '*.exe' -o -name '*.dll' \) -exec ./pe-imports.sh {} +

8. What I’d do for your Slint build

For your current case, I’d run these three:

sh
EXE=target/x86_64-pc-windows-gnu/release/protocalc-slint.exe

x86_64-w64-mingw32-objdump -p "$EXE" \
  | awk -F': ' '/DLL Name:/ { print $2 }' \
  | sort -u

WINEDEBUG=+loaddll wine "$EXE" 2>&1 | tee wine-dll-load.log

grep -i 'loaddll\|builtin\|native\|font\|dwrite\|gdi' wine-dll-load.log | less

The static import table tells you what must be resolvable before startup. The Wine/Proton log tells you what the app and the runtime actually pull in while running.

Both mingw objdump and +loaddll wine option agree that only c:\windows\system32 libraries are loaded. The linux build also only loads system-ish shared libs:

NEEDED libfontconfig.so.1

NEEDED libgcc_s.so.1

NEEDED libm.so.6

NEEDED libc.so.6

NEEDED ld-linux-x86-64.so.2

Meanwhile, the project declares slint as its dependency, which transitively depdends on a lot of packages. I assume these are all compiled into the executable by the rust build, right? What if one of the dependencies wasn't pure-Rust, for example was a wrapper to a C library? What options for including such a dependency would we have in this specific context?

Yes. In Rust, a dependency being present in Cargo.toml does not imply a runtime .dll/.so dependency.

Most Rust crates are compiled into intermediate object code / rlibs and then linked into the final executable. So if your final Windows .exe imports only C:\windows\system32... DLLs, and your Linux binary has only those NEEDED entries, then Slint and most/all of its Rust dependency graph are effectively part of your binary.

A good mental model:

text
Cargo dependency graph:
  source-level / build-time dependency graph

Final binary dependency graph:
  native dynamic libraries actually needed at runtime

Those are very different graphs.

Why “many crates” became “one executable”

For ordinary Rust crates:

text
crate A
crate B
crate C
...
↓
compiled by rustc
↓
linked into final executable

They do not become separate runtime libraries unless you deliberately build/use dylib, cdylib, plugin loading, or native dynamic dependencies.

So a large transitive Rust dependency graph usually means:

text
larger compile time
larger final binary
more code to audit
more potential build issues

but not necessarily:

text
more runtime DLLs/.so files

Your inspection results are exactly what I would hope to see.

What changes when a crate wraps a C library?

Then there are several possibilities.

A Rust crate can depend on a native C/C++ library in roughly these ways:

text
1. It compiles bundled C/C++ source into the final binary.
2. It links statically against a native library.
3. It links dynamically against a native library.
4. It dlopen/LoadLibrary-loads a native library at runtime.
5. It expects the library to exist in the system/toolchain environment.

Each has a different deployment story.

Option 1: bundled C source, compiled statically

This is often the nicest option.

Example shape:

text
Rust crate
  build.rs
    uses cc crate
    compiles vendor/foo/foo.c
    emits static library
  Rust code links against that static lib
↓
final .exe contains the C code

In Cargo terms, this usually looks like a *-sys crate with a vendored feature, or a crate that uses the cc crate in build.rs.

Result:

text
Windows .exe:
  no extra foo.dll

Linux binary:
  no extra libfoo.so

This is the best match for your Proton/Steam experiment.

Pros:

text
single artifact or near-single artifact
no system package dependency
good Proton story
good long-term binary story

Cons:

text
larger executable
native build complexity
cross-compilation can require target C compiler
C library license must permit static linking / redistribution
security updates require rebuilding your app

For your Rust + Slint setup, this is the preferred mode when available.

Option 2: static link against a native library

Instead of compiling vendored C source directly, the build may link a .a archive:

text
libfoo.a
↓
linked into protocalc-slint.exe

For Windows GNU target, that would usually be a MinGW-compatible static library:

text
libfoo.a

For Linux:

text
libfoo.a

Again, if it is truly statically linked, it will not appear in objdump -p or readelf -d as a runtime dependency.

Good signs:

sh
x86_64-w64-mingw32-objdump -p app.exe | grep 'DLL Name'
readelf -d app | grep NEEDED

If foo is not listed, it is not a dynamic runtime dependency.

This is also a good option, but slightly more annoying than fully vendored source because you need to provide/build the correct archive per target.

Option 3: dynamic link and ship the DLL/.so next to the app

This is the normal desktop-app approach.

Windows layout:

text
dist/win-x64/
  protocalc-slint.exe
  foo.dll
  bar.dll
  assets/
  fonts/

Linux layout:

text
dist/linux-x64/
  protocalc-slint
  libfoo.so
  libbar.so
  assets/
  fonts/

On Windows and under Proton, placing DLLs next to the .exe is usually the most straightforward. Windows DLL search rules include the application directory very early, so this works well for a Steam depot.

Your Windows inspection would then show:

text
DLL Name: foo.dll

and your runtime Wine log would show it being loaded from your app directory instead of C:\windows\system32.

For Steam/Proton, this is acceptable, but the long-term story is weaker than static linking because you now own a bundle of native DLLs.

Pros:

text
simple when upstream distributes DLLs
smaller .exe
can update native library independently
common for Windows apps

Cons:

text
more files to ship
DLL search/path mistakes
ABI/version mismatch risk
must ensure all transitive DLL dependencies are included
Linux .so bundling is much more fragile than Windows DLL bundling

For your model, dynamic DLLs on the Windows build under Proton are much less scary than native Linux .so bundles. They face the Windows ABI / Proton layer. Native Linux .so bundles face future Linux directly.

Option 4: runtime loading with LoadLibrary / dlopen

Some crates do not link to the library at build time. Instead they load it dynamically at runtime.

Examples conceptually:

text
Windows:
  LoadLibraryW("foo.dll")
  GetProcAddress(...)

Linux:
  dlopen("libfoo.so")
  dlsym(...)

This will not necessarily appear in the static import table.

So objdump -p app.exe may look clean, but Wine logs reveal:

text
loading foo.dll

or the app fails at runtime when it cannot find it.

This is why your WINEDEBUG=+loaddll check matters. It catches dynamic loading that the PE import table does not.

Pros:

text
optional features
fallbacks
plugin-style behavior
can degrade gracefully

Cons:

text
harder to audit
failure is runtime-only
packaging has to account for paths/env vars

For this project, I would avoid dependencies that use runtime dynamic loading unless they are very well understood.

Option 5: system library dependency

This is the bad one for your long-term goal.

Linux example:

text
NEEDED libfoo.so.3

Windows example:

text
DLL Name: foo.dll

but foo.dll is neither in system32 nor shipped next to your app.

For normal Linux distro packaging, this is fine. For “frozen artifact should still run in 10–15 years”, it is bad.

For native Linux builds, system dependencies are the exact thing you were trying to avoid:

text
future distro may not have it
future distro may have incompatible version
library may move/rename
behavior may change

For the Windows build under Proton, system DLLs are fine only if they are part of the Windows API surface that Wine/Proton implements:

text
kernel32.dll
user32.dll
gdi32.dll
advapi32.dll
shell32.dll
ole32.dll
dwrite.dll
d2d1.dll
comctl32.dll
ucrtbase.dll maybe

But random third-party DLLs should be shipped or statically linked.

In your specific context, prefer this order

For the Windows artifact that will be run through Proton, I would rank options like this:

text
1. Pure Rust dependency compiled into .exe
2. Vendored C/C++ compiled statically into .exe
3. Prebuilt or locally-built static .a linked into .exe
4. Shipped DLLs next to .exe
5. Runtime-loaded optional DLLs, only if explicitly controlled
6. Depend on user/system-installed third-party DLLs: avoid

For the Linux-native development build, I would rank them slightly differently:

text
1. Pure Rust dependency
2. Vendored/static native dependency
3. System library dependency if it is only for dev convenience
4. Bundled .so with rpath/runpath if needed

But since Linux-native is only your dev path, a few system-ish .sos are not fatal. The Windows build is the real artifact for the Steam/Proton experiment.

How to detect what happened

For Linux native:

sh
readelf -d target/release/protocalc-slint | grep NEEDED

or:

sh
lddtree target/release/protocalc-slint

If lddtree is available, it is better than ldd because it shows the dependency tree without executing the program.

For Windows static imports:

sh
x86_64-w64-mingw32-objdump -p target/x86_64-pc-windows-gnu/release/protocalc-slint.exe \
  | awk -F': ' '/DLL Name:/ { print $2 }' \
  | sort -u

For Windows runtime dynamic loads under Wine:

sh
WINEDEBUG=+loaddll wine target/x86_64-pc-windows-gnu/release/protocalc-slint.exe 2>&1 \
  | tee wine-dll-load.log

For static native code folded into the binary, you can sometimes inspect symbols:

sh
nm -C target/release/protocalc-slint | less

For Windows:

sh
x86_64-w64-mingw32-nm -C target/x86_64-pc-windows-gnu/release/protocalc-slint.exe | less

Release builds may strip symbols, so this is not always useful.

How Cargo native dependencies usually signal this

Look for crates ending in:

text
-sys

Examples:

text
openssl-sys
zlib-sys
libsqlite3-sys
fontconfig-sys
freetype-sys

A *-sys crate usually means “bindings to a native library”. Then check its features. Common patterns:

toml
[dependencies]
foo-sys = { version = "...", features = ["vendored"] }

or:

toml
openssl = { version = "...", features = ["vendored"] }
rusqlite = { version = "...", features = ["bundled"] }

Names vary:

text
vendored
bundled
static
buildtime_bindgen

The crate’s build.rs decides what happens. It may use:

text
pkg-config        # common on Linux, means system library discovery
vcpkg             # common for Windows native libs
cc                # compile bundled C/C++ code
cmake             # build bundled native project
bindgen           # generate Rust FFI bindings from C headers

For your goals, be suspicious of:

text
pkg-config-only crates
system package assumptions
unconditional dynamic linking
runtime dlopen of optional platform libraries

and happier with:

text
pure Rust crates
vendored feature
bundled feature
static feature
cc-built native code

Concrete example: SQLite

Suppose you add SQLite.

Bad-for-your-goal version:

toml
rusqlite = "..."

Depending on configuration, this may use system SQLite on Linux.

Better frozen-artifact version:

toml
rusqlite = { version = "...", features = ["bundled"] }

That makes libsqlite3-sys build SQLite from bundled source and link it in. Then you inspect:

sh
readelf -d target/release/protocalc-slint | grep sqlite
x86_64-w64-mingw32-objdump -p target/x86_64-pc-windows-gnu/release/protocalc-slint.exe | grep -i sqlite

Ideally: no runtime SQLite dependency.

Concrete example: OpenSSL

OpenSSL is the classic headache.

Options:

text
Use system OpenSSL:
  bad for long-term Linux bundles

Use vendored OpenSSL:
  better, but native build complexity

Use rustls instead:
  often best, pure Rust TLS stack

For your project, when a pure-Rust alternative exists, prefer it.

text
openssl → rustls
native image codecs → pure Rust codecs where feasible
system database client libs → pure Rust client or bundled native lib

Windows GNU-specific issue: MinGW runtime DLLs

With x86_64-pc-windows-gnu, check specifically for these:

text
libgcc_s_seh-1.dll
libwinpthread-1.dll
libstdc++-6.dll

If they appear as imports, you have choices:

text
ship those DLLs next to the exe
or adjust build/linking to make them static if possible
or avoid the dependency that pulled them in

Rust itself usually does not force a libstdc++ dependency unless C++ enters the picture. But C++ wrappers often do.

If a native dependency is C++, dynamic libstdc++-6.dll is a common thing to watch for under MinGW.

For Slint specifically

Your output suggests Slint in this configuration is very nicely packaged:

text
Windows:
  Slint code and renderer stack compiled into exe
  only system32 DLLs loaded

Linux:
  no random Slint .so dependency
  only fontconfig/gcc/m/libc/loader

That means its transitive crate graph is not equivalent to a runtime dependency graph. Most of it is statically linked Rust code.

The libfontconfig.so.1 Linux dependency is not surprising for font discovery. Since Linux-native is dev-only for you, that is acceptable. For the Windows build, you solved the more important problem by embedding fonts.

Best policy for this project

I would add a dependency acceptance rule to the project README:

text
For the Windows/Proton artifact:
  - Pure Rust dependencies are preferred.
  - Native dependencies must either be statically linked or shipped in dist/win-x64.
  - Runtime-loaded DLLs must be documented and tested under Wine and Proton.
  - No dependency may require user-installed third-party DLLs.
  - Every release must include:
      objdump import list
      Wine +loaddll smoke log
      final dist directory listing

For the Linux dev artifact:

text
Linux-native builds may use common system libraries,
but they are not considered the long-term distribution target.

That distinction keeps you honest without overengineering the dev path.

One thing I'm unsure about: what is libgcc_s.so? And why is it needed in Rust/Linux build? That's distinct from libc, libm, etc. which I expected.

libgcc_s.so.1 is GCC’s low-level runtime support shared library. It is not the C standard library. It contains compiler/runtime helper routines that generated machine code may need, especially around:

text
stack unwinding
exception handling metadata support
some arithmetic/compiler builtins
thread-local/runtime support details on some targets

The most important one for Rust on Linux is usually stack unwinding.

Why a Rust binary can need libgcc_s.so.1

On Linux GNU targets, Rust commonly uses the platform’s unwinding ABI. Even though Rust is not C++, Rust panics may unwind the stack unless you compile with panic = "abort".

So your dependency chain is roughly:

text
Rust code
  may panic with unwinding
    needs unwinder support
      provided by libgcc_s.so.1 on many Linux GNU setups

This is why you may see:

text
NEEDED libgcc_s.so.1

even in a pure Rust program.

It is not because you wrote GCC-specific code. It is because the final binary needs the system unwinder runtime used by the GNU/Linux ABI/toolchain environment.

Why it is separate from libc

libc.so.6 is glibc: C/POSIX runtime, syscalls wrappers, malloc, pthread integration, locale pieces, etc.

libgcc_s.so.1 is the GCC runtime support library. Historically it provides things compilers need that do not belong in libc. The _s means the shared version.

So:

text
libc.so.6
  C/POSIX runtime

libm.so.6
  math library

ld-linux-x86-64.so.2
  dynamic loader

libfontconfig.so.1
  font discovery/config

libgcc_s.so.1
  compiler runtime / unwinder support

Is this bad?

For a normal Linux binary: no. libgcc_s.so.1 is extremely common on GNU/Linux systems.

For your “frozen binary for 15 years” concern: it is another reminder that the Linux-native build is not hermetic. But among Linux dependencies, libgcc_s.so.1 is one of the least surprising and least scary. If a system can run ordinary C++/Rust-ish GNU/Linux programs, it probably has it.

Can you remove it?

Often, yes.

The main lever is panic strategy:

toml
[profile.release]
panic = "abort"

Then rebuild:

sh
cargo clean
cargo build --release
readelf -d target/release/protocalc-slint | grep NEEDED

With panic = "abort", Rust does not need to unwind Rust stack frames during panics; it just terminates. That can remove the need for unwinder-related runtime pieces, sometimes including libgcc_s.so.1.

But it depends on your dependency graph and target. Some libraries, build settings, or native components may still pull it in.

You can also inspect why it is there by checking symbols:

sh
readelf -Ws target/release/protocalc-slint | grep -E 'Unwind|gcc|personality|_Unwind|rust_eh'

If you see things like:

text
_Unwind_Resume
_Unwind_Backtrace
__gcc_personality_v0
rust_eh_personality

then it is unwind-related.

Should you use panic = "abort"?

For this kind of GUI app, I would seriously consider it for release builds.

Pros:

text
smaller binary
fewer runtime dependencies sometimes
simpler failure mode
often better for “appliance-like” apps

Cons:

text
panic terminates immediately
no catching panics across boundaries
less graceful recovery from internal bugs
some test/debug workflows prefer unwinding

A common setup is:

toml
[profile.dev]
panic = "unwind"

[profile.release]
panic = "abort"

For your Steam/Proton target, the Windows build probably matters more; but for the Linux dev build, this is a useful experiment.

Is Rust using GCC?

Not necessarily. Even if Rust uses LLVM for codegen, on *-unknown-linux-gnu targets it is still participating in the GNU/Linux toolchain/runtime world. The final binary may use glibc, the GNU dynamic loader, and GCC-compatible unwinding conventions. libgcc_s can appear because of that ABI/runtime choice, not because gcc compiled your Rust code.

So the concise answer is:

text
libgcc_s.so.1 is the GNU compiler runtime shared library.
Rust/Linux GNU binaries often need it for stack unwinding/panic support.
It is separate from libc.
Try release panic = "abort" if you want to see whether it disappears.