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.ymlin 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 inbottles/<dir>/grids/<uuid>.pngand the entry saysthumbnail: 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
grepforname: <name>inbottle.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:
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.