Skip to content

Bottles: the programs are declared, the prefixes are not

home/apps/bottles.nix owns the package, the programs each bottle lists and the tiles in the Library tab. games-disk.md covers where the game DATA lives.

TWO FILES, and confusing them costs an afternoon

"The library" is two different things in Bottles, they live in two files, and declaring one does nothing for the other:

What you see Where it lives What it holds
the program list on a bottle's own page bottles/<dir>/bottle.yml, key External_Programs the exe, its arguments, dxvk/vkd3d per program
the tiles in the Library tab library.yml, next to the bottles/ folder one entry per tile, pointing at a program by UUID

MEASURED the hard way on 08/09/2026: all eight programs were declared and registered, every one of them visible on its bottle's page, and the Library tab still showed THREE tiles. library.yml had never been touched, because nothing in External_Programs implies a tile.

The Library entry references the program by the UUID bottles-cli add generated, which is why it cannot simply be declared: the id is created at add time and lives only in the prefix. So the entry is built by reading that uuid back out of bottle.yml, and the module can only ever say "this program belongs in the Library", never "this is its id".

bottles-cli has no library subcommand at all (info list programs add tools reg reg-rules edit new run standalone shell), so this is the one file here written without the app's own API, by bottles-library-add. It is a package and not activation text because the parsing needs awk, and rule 7 wants that logic in the build where shellcheck sees it.

Two things about it worth knowing before it surprises you:

  • Close Bottles before a rebuild that will add tiles. The app holds library.yml in memory and writes the whole file, so an append made while it is open is lost the next time it saves. The next rebuild puts the tiles back, which makes this annoying rather than dangerous.
  • The cover art is fetched when the tile is created, and only then. Bottles resolves it through a proxy of its own, steamgrid.usebottles.com/api/search/<name>, which answers with a SteamGridDB url; the image lands in bottles/<dir>/grids/<uuid>.png and the entry says thumbnail: grid:<file>. The request is BEST EFFORT with an 8 second cap, because a rebuild must never hang on somebody's CDN: on a failure the tile is a text tile, exactly as it was before, and the way to retry is to remove the tile so the next rebuild recreates it. Steady state makes ZERO requests, since an entry that already exists is skipped before any of this.

The split, and why it is not arbitrary

A bottle is a Wine prefix, which is STATE (rule 6): it is created once, the runner patches it, and it comes back from restic and not from a rebuild. Declaring one would mean owning a tree of tens of thousands of files that Wine rewrites at will.

What IS declarable is the answer to "which programs does this bottle list, and which of them get a tile", because that is a short list of names, paths and command lines, and it is exactly what a restore loses: the prefix comes back from backup with its programs intact only if the backup caught it, and nothing says what SHOULD be there.

So the module declares my.games.library and an activation inserts what is missing, into both files. It never OWNS either of them.

Why activation and not a managed file

Bottles rewrites bottle.yml on every change: adding a program, changing a runner, even opening the bottle's settings updates Update_Date. A home.file on that path would be a second owner (rule 14), and the failure mode of two owners is not a crash, it is silent drift.

The activation has the same shape as dolphinPlaces: for each declared program, if the bottle exists and its bottle.yml does not already name that program, run bottles-cli add. The entry it writes is byte-identical in shape to the one the GUI produces (folder, path, dxvk, vkd3d, dxvk_nvapi and a fresh uuid), which was checked before trusting it.

The launch options go in as --launch-options=<value>, never as -l <value>. argparse reads a value that starts with -- as another option, and every Battle.net command line here starts with one, so -l '--productcode=pro' dies with expected one argument and takes the whole home-manager activation down with it. MEASURED on the first real switch: the entry that had been added by hand went in fine, and the identical one generated by the module did not.

Two limits worth stating instead of discovering:

  • A program deleted in the GUI comes back on the next rebuild. That is the declaration working, not a bug, but it means the way to remove one for good is to remove it from the module.
  • The check is a grep for name: <name> in bottle.yml. Renaming a program in the GUI makes the declared name absent, so the next rebuild adds it again and the bottle ends up with both.

The activation SKIPS a bottle whose bottle.yml is not there, instead of failing. On a fresh machine there are no prefixes at all, and a rebuild that dies because a game is not installed yet would be the tail wagging the dog.

The trap: the name it shows is not the directory it lives in

bottles-cli add -b Battlenet answers Bottle Battlenet not found, and the bottle is right there. The directory is Battlenet, and inside its bottle.yml the Name: field says Battle.net. The CLI takes the NAME; every path takes the directory.

That is why the activation reads the name back out of the file (sed -n 's/^Name: //p') instead of the module carrying it: one of the two is already written down in the prefix, and repeating it here would be a literal that can rot (rule 11).

Bottles derives the directory from the name at CREATION time by stripping what a path cannot hold, so the two agree for every other bottle here and will disagree again for any name with a dot in it.

Where each command line came from

Nothing in the list was invented. The five programs that already existed were read back out of bottle.yml, and the two that were missing came from the .lnk files Battle.net itself wrote inside the prefix, decoded from drive_c/users/Public/Desktop:

Program What it runs Evidence
Battle.net Battle.net.exe with its Chromium flags the entry that was already there
Hearthstone Battle.net.exe --exec="launch WTCG" plus the same flags the entry that was already there
Diablo IV Diablo IV Launcher.exe, no arguments its shortcut in the prefix
Overwatch Overwatch Launcher.exe --productcode=pro its shortcut in the prefix
Cities Skylines II Cities2.exe the entry that was already there
Black Flag Resynced ACBlackFlag.exe the entry that was already there
Ascension Launcher cmd.exe /c start "" "...\Ascension Launcher.exe" the entry that was already there
Bodycam Bodycam.exe the release's own layout

Hearthstone goes through Battle.net and the other two do not, which looks inconsistent and is not. Hearthstone's entry predates this module and it works, so it was transcribed rather than rewritten. If either shim turns out to fail under Wine, the fallback is Hearthstone's route with that game's product code, and Battle.net's own config names the installed products (hs_beta, fenris, prometheus) if the code ever has to be looked up.

The start "" in the Ascension entry is an EMPTY WINDOW TITLE and not a stray pair of quotes: start reads a first quoted argument as the title, so dropping it would make it try to open the launcher's path as a window name. cmd.exe is there because the launcher exits as soon as it has spawned the real process, and Bottles would otherwise call the program dead.

The paths are not repeated, they are looked up

A library entry for a game names the game and its exe, never the bottle or the folder:

"Bodycam" = inGame "Bodycam" "Bodycam.exe";

inGame searches my.games.linked for the entry pointing at that game on the Windows disk and takes the bottle and the prefix path from its key (rule 11). Two things follow, and the second is the point: a game that is not linked FAILS AT EVAL with a message saying so, instead of producing a library entry aimed at a path that does not exist.

inBottle is the escape hatch for a program that is not a game's own exe, which is Battle.net, Hearthstone and the Ascension cmd.exe.

A leftover that will confuse the next reader

~/.local/share/bottles/bottles/ holds SIX directories and Bottles lists FIVE. Battle.net (with the dot) is a prefix with no bottle.yml, 688 MiB, last touched on 05/07/2026, and it is invisible to the app for exactly that reason: what makes a directory a bottle is that file.

It is almost certainly the first attempt at the Battle.net bottle, kept when the second one was created under the name that produced the Battlenet directory. Nothing here references it, and it sits inside @home, so it is 688 MiB of snapshot weight for nothing. It is listed here rather than deleted because deleting somebody's prefix on a hunch is how a save disappears.

An OnlineFix repack needs two things Wine does not give it

Bodycam is a STEAMRIP release whose multiplayer runs through OnlineFix, and getting it to open cost two distinct fixes. Both are declared in my.games.onlineFix, which is a list of bottle directories, so the next repack is one entry and not another afternoon.

winmm has to resolve to the proxy next to the exe. The repack ships a winmm.dll in Binaries/Win64 with a dlllist.txt naming OnlineFix64.dll: on Windows that loads because the application directory comes first in the DLL search order, and Wine prefers its BUILTIN for a system name, so nothing ever loads the fix. The symptom is a dialog saying Failed to get OnlineFix interface, which reads like the fix is broken when it was never loaded. The bottle carries WINEDLLOVERRIDES=winmm=native,builtin, set through bottles-cli edit, because the CLI has no DLL-override flag; winecommand.py merges that environment variable into the bottle's own DLL_Overrides rather than replacing it, which is what makes the env-var route safe.

The fix then LoadLibrary's the real steamclient by its registry path. Missing, it says Failed to load original steamclient. Error code: 126, which is ERROR_MOD_NOT_FOUND. So HKCU\Software\Valve\Steam\ActiveProcess gets SteamClientDll64 and SteamClientDll, and the two Windows DLLs are placed at C:\Program Files (x86)\Steam\ as out-of-store symlinks into the Steam that is already installed on this machine. Symlinks and not copies: they weigh 47 MiB together and this way they follow Steam's updates instead of going stale.

What the load trace proved, and why no Steam goes inside the bottle

MEASURED with WINEDEBUG=+loaddll on 08/09/2026, in this order:

Module Where from Kind
WINMM.dll the game's Binaries/Win64 native
OnlineFix64.dll same folder, loaded BY the proxy native
steamclient64.dll C:\Program Files (x86)\Steam native
lsteamclient.dll C:\windows\system32 builtin

That last line is the answer to the question the error messages made look hard. lsteamclient is GE-Proton's own bridge, and it forwards Steam API traffic to the Steam client running on the HOST, which is why the game came up the moment the ordinary Linux Steam was open and why nothing has to be installed inside the prefix. The fixme:steamclient:manual_convert_... line in the same trace is that bridge doing the conversions.

The cost of that is a runtime dependency worth stating plainly: this game wants the Linux Steam client RUNNING. It is not declared anywhere, because it is not config, it is something to remember.

The idempotency checks are the same kind as everywhere else here: the env var is looked for BY KEY in bottle.yml, so a value changed by hand is left alone, and SteamClientDll64 is looked for in user.reg. The DLL symlinks need no check at all, since home-manager owns those two paths.