From 5c8dff8ef54208784e4de8094dc12b601079ab57 Mon Sep 17 00:00:00 2001 From: Daisuke Murase Date: Wed, 30 Sep 2026 11:54:41 -0700 Subject: [PATCH] fix: leave macOS's AppleDouble files out of directory listings in a bottle, because on exFAT Minecraft Dungeons II read one as its settings and reset them every launch --- Tests/SakeKitTests/PatchTests.swift | 3 +- docs/runtime.md | 60 +++++++++- docs/wine-build.md | 7 +- ...uble-files-out-of-directory-listings.patch | 105 ++++++++++++++++++ patches/README.md | 3 +- 5 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 patches/0009-ntdll-leave-AppleDouble-files-out-of-directory-listings.patch diff --git a/Tests/SakeKitTests/PatchTests.swift b/Tests/SakeKitTests/PatchTests.swift index 9111fca..a0e0366 100644 --- a/Tests/SakeKitTests/PatchTests.swift +++ b/Tests/SakeKitTests/PatchTests.swift @@ -139,7 +139,7 @@ private func read(_ tree: URL) throws -> String { try String(contentsOf: tree.appending(path: "dlls/ntdll/unix/loader.c"), encoding: .utf8) } -@Test func theRepositoryCarriesTheEightPatchesAndSaysTheyAreNotMIT() throws { +@Test func theRepositoryCarriesTheNinePatchesAndSaysTheyAreNotMIT() throws { let patcher = WinePatcher(directory: repositoryPatches) let patches = try patcher.patches() @@ -152,6 +152,7 @@ private func read(_ tree: URL) throws -> String { "0006-winemac-give-D3DMetal-a-hosted-swapchain-for-a-window-it-does-not-own.patch", "0007-winhttp-stub-WINHTTP_OPTION_DECOMPRESSION.patch", "0008-winhttp-stub-WINHTTP_OPTION_IPV6_FAST_FALLBACK.patch", + "0009-ntdll-leave-AppleDouble-files-out-of-directory-listings.patch", ]) // Each one says what it does on its first line, which is where the reasoning starts, // and names the module it changes the way a Wine commit does. diff --git a/docs/runtime.md b/docs/runtime.md index 0fa7d9d..8b72a22 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -207,12 +207,13 @@ and the renderer that draws it was still alive seventy-five seconds later. ## Two patches to ntdll -sake carries eight patches in `patches/`, all LGPL-2.1-or-later because all are derivatives -of Wine. The two in ntdll are this section's; they came from the prototype unchanged and go -in before configure. The four in winemac.drv arrived with Steam on 2026-09-20 and are in the -Steam section below, and the two in winhttp arrived with Minecraft Dungeons II on 2026-09-29 -and are in the GDK section after it. The build side of patching is in `wine-build.md` and -the licence side in `licensing.md`. +sake carries nine patches in `patches/`, all LGPL-2.1-or-later because all are derivatives +of Wine. Two of the three in ntdll are this section's; they came from the prototype unchanged +and go in before configure. The four in winemac.drv arrived with Steam on 2026-09-20 and are +in the Steam section below, the two in winhttp arrived with Minecraft Dungeons II on +2026-09-29 and are in the GDK section after it, and the third in ntdll came with the same +game a day later and has the exFAT section after that. The build side of patching is in +`wine-build.md` and the licence side in `licensing.md`. **sake measured both on 2026-09-19**, against its own engine and bottle, the day it started carrying them. The prototype's numbers are kept beside sake's because they are the @@ -478,6 +479,48 @@ game is the sign-in going through with both of the stand-in's hooks removed, whi the rebuilt engine: Microsoft's sign-in, then `LoginWithSteam`, then character select, with every reply the stand-in logged, 20 of them, a 200 and no option refused. +## A bottle on exFAT: the `._` files macOS writes are not the game's + +Measured in sake on 2026-09-30 in the `ex` bottle, a symlink into a directory on an exFAT +disk, with Minecraft Dungeons II started through Steam. The game had reset its settings on +every launch since 2026-09-29, with the community stand-in and with sake's own runtime alike. + +**macOS writes a second file beside nearly every file there.** exFAT cannot store extended +attributes itself — `getattrlist` reports `VOL_CAP_INT_EXTENDED_ATTR` unset for that disk and +set for the internal APFS volume — so macOS keeps a file's attributes in a 4096-byte +AppleDouble file named `._` and the file's own name. On this Mac a file is given one as soon as +it is written, because it is given `com.apple.provenance`: the game's saves were, and so was a +file written from a shell. The bottle held 6,259 of them. Wine lists these as ordinary files, +marked hidden because their names start with a dot, and its sorted listing puts each before +the file it belongs to. + +**The game read one as its settings.** It lists `Saved\SaveGames\*.*`, opened +`._GlobalSaveDataDefault.sav`, read its 4096 bytes and never opened `GlobalSaveDataDefault.sav` +at all. It then showed SETTINGS FILE DAMAGED and sent the person through the initial setup +again, although the file it had written is sound: JSON with every byte one lower. With the +five companions in `SaveGames` removed by hand, the same file loaded and the game went +straight to play; its next save brought all five back. Steam's client in that bottle had been +syncing `._sharedconfig.vdf` to Steam Cloud as one of its configuration files: its +`logs/cloud_log.txt` reports it in sync nine times before the patch. + +**`patches/0009` leaves such a file out of a directory listing** when the file it belongs to is +beside it and the volume has no native extended attributes. A `._` file on APFS, or one with +nothing beside it, is still listed, and a name asked for exactly is still found. + +**How to tell it worked.** `WINEDEBUG=+file` prints `leaving out` and the name for each file +left out, and the listing after it holds none of them. On the rebuilt engine, the same day and +with the five companions back on disk, the game's listing of `SaveGames` left them out and +returned the five saves, it read `GlobalSaveDataDefault.sav`, and it started with no dialog. +Steam's next sync named `sharedconfig.vdf` alone and found nothing to download. In +`wine cmd /c dir`, a `._` file with nothing beside it on the exFAT disk and a `._` file on APFS +were both listed, and `rmdir /s /q` removed an exFAT directory holding two companions it had +not been shown, since macOS removes a companion with its file. Starting `cmd` in that bottle +left out 854 names. + +Not measured: FAT and SMB volumes, which macOS treats the same way when they lack native +extended attributes, and whether Steam ever removes the copy of `._sharedconfig.vdf` its +cloud still holds. + ## Killing wineserver leaves the prefix's own services running **Measured in sake on 2026-09-20.** A title was started from the library and stopped again. @@ -623,6 +666,11 @@ started. Cut `argv[0]` at its first `.exe` and check what that ends with. - **`WINEDEBUG=+pid` before anything else with more than one process.** Without it every trace prefix is a thread id, and four Chromium processes cannot be told apart. Learned on Steam, 2026-09-20. +- **`WINEDEBUG=:+` traces one process.** An option with a name and a colon in + front applies only where the executable has that name (`parse_options` in + `dlls/ntdll/unix/debug.c`), so a game started by Steam can be traced without Steam's own + processes. `-all,Dungeons-Win64-Shipping.exe:+pid,Dungeons-Win64-Shipping.exe:+file` in + Steam's environment gave 24 MB by the time the game showed its first dialog, 2026-09-30. - **`+macdrv_d3dmtl` is D3DMetal's half of the conversation.** It is the channel of the glue in `dlls/winemac.drv/d3dmetal.c`, the only code D3DMetal calls in Wine. A `get_win_data` with no `create_metal_device` after it means winemac returned NULL, and the six calls of a diff --git a/docs/wine-build.md b/docs/wine-build.md index e8dfc16..3f002ff 100644 --- a/docs/wine-build.md +++ b/docs/wine-build.md @@ -58,8 +58,8 @@ Notes that cost time to find: ## The patches go in before configure -`patches/` holds the changes sake makes to Wine's own code — eight of them as of 2026-09-29, -two in ntdll, four in winemac.drv and two in winhttp, all LGPL-2.1-or-later rather than this +`patches/` holds the changes sake makes to Wine's own code — nine of them as of 2026-09-30, +three in ntdll, four in winemac.drv and two in winhttp, all LGPL-2.1-or-later rather than this repository's MIT. Four are upstream Wine commits carried only until the CrossOver sources sake builds catch up — two of the winemac.drv ones with wine-11.11, the winhttp ones with wine-11.4 and wine-11.7 — one is the reference implementation attached to Wine bug 60263, @@ -92,7 +92,8 @@ rest of it fits, and says nothing. Upstream's hunk for 0008 did that on 2026-09- context is a case that CrossOver's `session_set_option` does not have yet, and it applied cleanly inside `connect_query_option`, where it stubbed nothing. `WinePatcher` passes `-F0`, so a hunk like that stops the build as one that applies to neither form of the tree. All -eight patches applied to pristine 26.3.0 files with `-F0` exactly as they did without it. +eight patches applied to pristine 26.3.0 files with `-F0` exactly as they did without it, and +so did 0009 when it was added on 2026-09-30. **A build with no patches is stopped rather than allowed.** Wine without them configures, compiles, installs and passes every check in this document. What it cannot do is start a diff --git a/patches/0009-ntdll-leave-AppleDouble-files-out-of-directory-listings.patch b/patches/0009-ntdll-leave-AppleDouble-files-out-of-directory-listings.patch new file mode 100644 index 0000000..d610bbf --- /dev/null +++ b/patches/0009-ntdll-leave-AppleDouble-files-out-of-directory-listings.patch @@ -0,0 +1,105 @@ +ntdll: leave AppleDouble files out of directory listings. + +On a volume that cannot store extended attributes itself, macOS keeps a file's +attributes in a second file beside it, named "._" followed by the file's own name. +exFAT is such a volume. On the machine this was measured on, a file got one as soon as it +was written, because macOS tags what applications write with com.apple.provenance; the +bottle below held 6,259. readdir() returns these companions like any other name, so a +Windows program listing a directory takes them for files of its own. + +Measured in sake on 2026-09-30, in a bottle whose drive_c is on an exFAT disk. +Minecraft Dungeons II lists its SaveGames directory with FindFirstFileExW("*.*"), and +the sorted listing puts "._GlobalSaveDataDefault.sav" before +"GlobalSaveDataDefault.sav". The game opened the first, read its 4096 bytes and never +opened the second, then said its settings file was damaged and reset every setting. +It did that on every launch, because macOS writes the companion again as soon as the +game saves. With the companions removed by hand the same settings file loaded, and the +next save brought them back. Steam's client in that bottle had been syncing +"._sharedconfig.vdf" to Steam Cloud as a configuration file. + +A "._X" is left out only when an "X" is beside it and the volume's capabilities say it +has no native extended attributes (VOL_CAP_INT_EXTENDED_ATTR), which is when macOS +makes one. On APFS a "._X" is an ordinary file, and one with no "X" belongs to nothing, +so both stay visible and deletable. A name asked for exactly, with no wildcard, is +found as before. WINEDEBUG=+file names each one left out. + +--- a/dlls/ntdll/unix/file.c ++++ b/dlls/ntdll/unix/file.c +@@ -2637,8 +2637,57 @@ + return STATUS_SUCCESS; + } + ++ ++#if defined(HAVE_GETATTRLIST) && defined(ATTR_VOL_CAPABILITIES) && \ ++ defined(VOL_CAPABILITIES_FORMAT) && defined(VOL_CAP_FMT_CASE_SENSITIVE) && \ ++ defined(VOL_CAP_INT_EXTENDED_ATTR) + + /*********************************************************************** ++ * is_appledouble_file ++ * ++ * Checks whether a name in the current directory is an AppleDouble file: "._X" beside ++ * an "X", on a volume that cannot store extended attributes itself, where macOS keeps ++ * X's attributes in that file. native_xattr holds the volume's answer for one listing, ++ * -1 until it has been asked. ++ */ ++static BOOL is_appledouble_file( const char *name, int *native_xattr ) ++{ ++ struct attrlist attr; ++ struct vol_caps caps; ++ struct statfs stfs; ++ struct stat st; ++ ++ if (name[0] != '.' || name[1] != '_' || !name[2]) return FALSE; ++ ++ if (*native_xattr == -1) ++ { ++ memset( &attr, 0, sizeof(attr) ); ++ attr.bitmapcount = ATTR_BIT_MAP_COUNT; ++ attr.volattr = ATTR_VOL_INFO | ATTR_VOL_CAPABILITIES; ++ /* getattrlist() only answers for volume attributes at the volume's root */ ++ if (statfs( ".", &stfs ) == -1 || ++ getattrlist( stfs.f_mntonname, &attr, &caps, sizeof(caps), 0 ) == -1 || ++ caps.size != sizeof(caps) || ++ !(caps.caps.valid[VOL_CAPABILITIES_INTERFACES] & VOL_CAP_INT_EXTENDED_ATTR)) ++ *native_xattr = 1; ++ else ++ *native_xattr = !!(caps.caps.capabilities[VOL_CAPABILITIES_INTERFACES] & ++ VOL_CAP_INT_EXTENDED_ATTR); ++ } ++ return !*native_xattr && !lstat( name + 2, &st ); ++} ++ ++#else ++ ++static BOOL is_appledouble_file( const char *name, int *native_xattr ) ++{ ++ return FALSE; ++} ++ ++#endif ++ ++ ++/*********************************************************************** + * read_directory_readdir + * + * Read a directory using the POSIX readdir interface; helper for NtQueryDirectoryFile. +@@ -2648,6 +2697,7 @@ + struct dirent *de; + NTSTATUS status = STATUS_NO_MEMORY; + DIR *dir = opendir( "." ); ++ int native_xattr = -1; + + if (!dir) return STATUS_NO_SUCH_FILE; + +@@ -2656,6 +2706,11 @@ + while ((de = readdir( dir ))) + { + if (!strcmp( de->d_name, "." ) || !strcmp( de->d_name, ".." )) continue; ++ if (is_appledouble_file( de->d_name, &native_xattr )) ++ { ++ TRACE( "leaving out %s\n", debugstr_a(de->d_name) ); ++ continue; ++ } + if (!append_entry( data, de->d_name, NULL, mask )) goto done; + } + status = STATUS_SUCCESS; diff --git a/patches/README.md b/patches/README.md index 4de11fa..a8ff514 100644 --- a/patches/README.md +++ b/patches/README.md @@ -16,7 +16,8 @@ worked. Three kinds of file live here, and the header of each says which it is: -- **sake's own** (`0001`, `0002`, `0006`): written here, against something measured here. +- **sake's own** (`0001`, `0002`, `0006`, `0009`): written here, against something measured + here. - **Upstream Wine commits carried early** (`0003`, `0004`, `0007`, `0008`): named by hash and author in the header, applied as upstream wrote them apart from hunks the header says were moved. They exist because CrossOver's sources lag upstream, and **each is dropped the