Bottles: the programs are declared, the prefixes are not
Edit on GitHubmodules/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 programs that already existed were read back out of
bottle.yml, and Overwatch, which was missing, came from the .lnk file 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 |
| 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 |
| Victoria 3 | binaries/victoria3.exe | the repack's own layout |
| Warcraft III | Warcraft III Launcher.exe --productcode=w3 | its shortcut in the prefix |
| World of Warcraft | World of Warcraft Launcher.exe --productcode=wowf | Battle.net.config: no shortcut, and wowf (_forever_) is the only flavour installed |
| Valheim | Valheim/valheim.exe in its own bottle | the entry the GUI had added as valheim |
Valheim was adopted, not added. The GUI entry was named valheim, and the check is a
case-sensitive grep (see the limits above), so declaring Valheim would have produced a twin.
The name: of that one entry was renamed in bottle.yml on 28/10/2026, with Bottles closed, and
the module took it from there.
Hearthstone goes through Battle.net and Overwatch does not, which looks inconsistent and is not. Hearthstone's entry predates this module and it works, so it was transcribed rather than rewritten. If the shim turns out to fail under Wine, the fallback is Hearthstone's route with the product code, and Battle.net's own config names the installed products if the code ever has to be looked up.
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:
"Victoria 3" = inGame "Victoria 3" "binaries/victoria3.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
and Hearthstone.
In the app launcher
Every my.games.library program also gets a launcher entry (xdg.desktopEntries,
bottles-<slug>.desktop, category Game), so SUPER+Q finds "Hearthstone" and starts it without
opening Bottles. The entry runs bottles-launch <slug>, which reads the bottle's NAME out of
bottle.yml (the trap above) and execs bottles-cli run -b <name> -p <program>. That is the same
program the Library tab starts, with the arguments and the dxvk/vkd3d switches stored there:
run -p keeps the program's arguments unless --args-replace is passed. The slug, and not the name, is what
the .desktop Exec line carries, so an apostrophe never needs desktop-spec quoting. Vicinae
launches it through app2unit, so the game and its wineserver get their own scope.
The icon is pulled out of the exe by exe-thumbnailer (icoextract, what Bottles uses) at
activation, once, into bottles/<dir>/icons/<program>.png: the path Bottles' own "Add Desktop
Entry" writes, so the two share it. Hearthstone runs Battle.net's exe, so its entry says
icon = Hearthstone.exe. It is BEST EFFORT: with the Windows disk unmounted there is no exe to
read, the entry shows a generic icon, and the next rebuild fills it in. To redo an icon, delete
the PNG and rebuild.
Diablo IV is a program in the Battle.net bottle (added from the GUI) but not in the module, and its folder is gone from the prefix, so it has no launcher entry.
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.