Skip to content

feat: start titles from a game bundle, because macOS only puts a process in Game Mode when its bundle says it is a game - #11

Open
KAMEDAkyosuke wants to merge 1 commit into
typester:mainfrom
KAMEDAkyosuke:feat/game-bundle
Open

KAMEDAkyosuke wants to merge 1 commit into
typester:mainfrom
KAMEDAkyosuke:feat/game-bundle

Conversation

@KAMEDAkyosuke

@KAMEDAkyosuke KAMEDAkyosuke commented Sep 24, 2026 •

Copy link
Copy Markdown

macOS puts a process in Game Mode only when its bundle says it is a game, and the
engine's wine has none. Without a bundle the Dock already showed each program under its
own name and icon; once a process is in one, the Dock and the Game Overlay take both from
the bundle instead, so every program needs a bundle of its own. A title now starts from
engine/SakeGame.app, a copy of Wine's unix side with LSApplicationCategoryType games and
LSUIElement, and patches/0007 has Wine start every program the title starts from a bundle
of its own in engine/SakePrograms//.app: hard links to the parent's bundle,
named after the exe, keyed by a hash of its path, and carrying the exe's icon from before
its first launch. Measured on 2026-09-23: Diablo IV started from Battle.net shows as
"Diablo IV" with its icon in the Dock and the Game Overlay, and goes into Game Mode
full-screen. docs/runtime.md has why each piece is there; the patch header has the
measurements.

What changes in the architecture

Before

A title ran the engine's own wine, and so did everything it started. No process had a
bundle, and nothing wrote to the engine once it was built. The Dock already showed each
program under its own name and icon; what was missing was Game Mode.

 sake ──exec──▶ <engine>/bin/wine  Battle.net.exe
                        │
                        │  next loader = dirname(realpath(ntdll.so))
                        ▼
                <engine>/lib/wine/x86_64-unix/wine ──▶ Agent.exe, Diablo IV.exe, …

 <engine>/                         (build output; nothing writes to it at runtime)
 ├── bin/wine
 └── lib/wine/x86_64-unix/{wine, ntdll.so, …}

 Dock: each program's own name and icon (e.g. "Diablo IV")      Game Mode: never

After

Game Mode requires a bundle. macOS turns it on only for a process whose executable sits
in an .app with LSApplicationCategoryType ending in games, and there is no other key
for it (docs/runtime.md). A bare executable like bin/wine never qualifies.

A bundle takes away the name and icon, and patches/0007 is what gives them back. Once
a process runs from a bundle, the Dock labels it with the bundle's file name and nothing
else, and the Game Overlay shows the icon the bundle had when macOS first registered it.
With SakeGame.app alone, every process of a title — Battle.net, Diablo IV — would have
shown as that one bundle, losing what Before displayed correctly. So each program a title
starts gets a bundle of its own, named after its exe and carrying its icon.

Game Mode also requires full screen. A bundle only makes the process eligible; macOS
turns Game Mode on when its window goes full screen in the macOS sense. For Diablo IV,
the game's own full-screen setting does not do that — Game Mode stays off. The window's
green button has to be pressed. (Observed by the PR author on this branch.)

 sake ──exec──▶ <engine>/SakeGame.app/Contents/MacOS/wine  start.exe Battle.net.exe
                        │                          (LSApplicationCategoryType games,
                        │                           LSUIElement; SAKE_GAME_BUNDLES set)
                        │  patches/0007: loader is in a bundle
                        ▼
      <engine>/SakePrograms/<key(Battle.net.exe)>/Battle.net.app/Contents/MacOS/wine
                        │
                        ▼
      <engine>/SakePrograms/<key(Diablo IV.exe)>/Diablo IV.app/Contents/MacOS/wine

 <engine>/
 ├── bin/wine, lib/…                        unchanged
 ├── SakeGame.app/                          made by GameBundle (Swift) when a title starts
 │   ├── Contents/Info.plist
 │   ├── Contents/MacOS/{wine, ntdll.so, …} COPY of lib/wine/x86_64-unix  (5.6 MB)
 │   ├── Contents/MacOS/x86_64-unix ──▶ .
 │   ├── Contents/MacOS/*-windows   ──▶ <engine>/lib/wine/*-windows
 │   └── *.dylib, external, share, bin ──▶ <engine>/…
 └── SakePrograms/                          made by ntdll at runtime  ← new: runtime state
     └── <fnv1a(exe unix path)>/<program>.app/
         ├── Contents/Info.plist            its own (name, identifier, SakeBundleFormat 2)
         ├── Contents/Resources/AppIcon.ico from the exe's RT_GROUP_ICON
         └── everything else                HARD LINKS to the parent's bundle
                                            (same inodes as SakeGame.app/Contents/MacOS/*)

 Dock / Game Overlay: "Diablo IV" with its icon      Game Mode: on, full-screen
  • GameBundle copies the unix side rather than linking it, because Wine takes the next
    loader from realpath(ntdll.so) and a link would lead back into the engine. It is
    rebuilt only when it no longer matches the engine, since running titles execute from it.
  • TitleLauncher.command(wine:) runs that bundle's wine and sets SAKE_GAME_BUNDLES. If
    the bundle cannot be made, the title starts from bin/wine as before and only Game Mode
    is lost.
  • A program bundle is current when its loader is the same inode as the running one.
    CrossOver's Hack 22144 is skipped for these programs.

Known problems, not addressed here

  1. DiskUsage counts the hard links again for every program bundle (confirmed from the code). It sums fileAllocatedSize without deduplicating by inode, so the engine size the Uninstall sheet shows grows by about 5.6 MB per program bundle. That contradicts layout.md's "nothing on disk". Small fix: deduplicate by (st_dev, st_ino).
  2. Program bundles are never cleaned up. The key includes the bottle's path, so renaming or deleting a bottle leaves its bundles behind in the engine. Launchers that keep exes in versioned directories may add a new key on every update (not measured). The sweep removes only .stale-*, .staging-* and the pre-key layout. The LaunchServices registrations stay behind as well.
  3. Shared inodes. Nothing writes these files in place today (GameBundle replaces the directory as a whole), but that is only a convention. An in-place write — codesign, re-patching — would change every program bundle at once, and the inode currency check would still report them as current.
  4. Hard links need one volume. This is part of why SakePrograms is in the engine. An engine moved with a copy that does not preserve hard links (cp -R) turns them into full copies: it heals itself by remaking the bundles, but disk is wasted until then.
  5. One executable, many bundle identifiers (speculative, not measured). With ad-hoc signing every program bundle has the same cdhash under a different identifier. TCC prompts — input monitoring, microphone — may repeat per bundle or be inconsistent.

Alternatives worth considering

  • clonefile(2) instead of link(2). realpath() still stays inside the bundle and no disk is used, but each file gets its own inode, which removes problem 3 and makes lsof readable again. sake already clones for imports. The currency check would need a stamp of its own, for example a generation ID written into SakeGame.app's Info.plist and carried into each program bundle. Fall back to link or a copy where cloning fails.
  • Mirror less. Patch init_paths() to derive the loader from the running executable rather than from realpath(ntdll.so). A program bundle then needs only Contents/MacOS/wine, its Info.plist and its icon, with symlinks for everything else. That is a simpler failure surface, but a deeper change to Wine to carry across upgrades.

Either way problem 2 needs its own fix: record the exe path in each bundle's Info.plist and sweep keys whose exe is gone, or drop a bottle's keys when the bottle is renamed or deleted.

…ess in Game Mode when its bundle says it is a game

macOS puts a process in Game Mode only when its bundle says it is a game, and the
engine's wine has none. Without a bundle the Dock already showed each program under its
own name and icon; once a process is in one, the Dock and the Game Overlay take both from
the bundle instead, so every program needs a bundle of its own. A title now starts from
engine/SakeGame.app, a copy of Wine's unix side with LSApplicationCategoryType games and
LSUIElement, and patches/0007 has Wine start every program the title starts from a bundle
of its own in engine/SakePrograms/<key>/<program>.app: hard links to the parent's bundle,
named after the exe, keyed by a hash of its path, and carrying the exe's icon from before
its first launch. Measured on 2026-09-23: Diablo IV started from Battle.net shows as
"Diablo IV" with its icon in the Dock and the Game Overlay, and goes into Game Mode
full-screen. docs/runtime.md has why each piece is there; the patch header has the
measurements.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant