dotfiles
Apps

Bottles: the programs are declared, the prefixes are not

Edit on GitHub

modules/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 seeWhere it livesWhat it holds
the program list on a bottle's own pagebottles/<dir>/bottle.yml, key External_Programsthe exe, its arguments, dxvk/vkd3d per program
the tiles in the Library tablibrary.yml, next to the bottles/ folderone 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 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:

ProgramWhat it runsEvidence
Battle.netBattle.net.exe with its Chromium flagsthe entry that was already there
HearthstoneBattle.net.exe --exec="launch WTCG" plus the same flagsthe entry that was already there
OverwatchOverwatch Launcher.exe --productcode=proits shortcut in the prefix
Cities Skylines IICities2.exethe entry that was already there
Black Flag ResyncedACBlackFlag.exethe entry that was already there
Victoria 3binaries/victoria3.exethe repack's own layout
Warcraft IIIWarcraft III Launcher.exe --productcode=w3its shortcut in the prefix
World of WarcraftWorld of Warcraft Launcher.exe --productcode=wowfBattle.net.config: no shortcut, and wowf (_forever_) is the only flavour installed
ValheimValheim/valheim.exe in its own bottlethe 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.