From 7635994fc4fcca29fca665251137f3554185c3c8 Mon Sep 17 00:00:00 2001 From: NevesHenry Date: Wed, 4 Nov 2026 11:04:09 -0300 Subject: [PATCH] Steam downloads: a redistributable's file no longer replaces the game's own copy 8f93433 makes the last depot that lists a file the only one that writes it, as Valve's client does. It compares file names only. Since ddc6c2a, the same prepare() also plans the Steamworks Common Redistributables depots a game names, into their owner's folder (steamapps/common/ Steamworks Shared). They are planned after the game's own depots. So when a game's depot and a redistributable depot list the same relative name (for example _CommonRedist\vcredist\2019\VC_redist.x64.exe), the two files in two folders were treated as one. The game's copy got an empty path: it was never downloaded into the game's folder, and a repair never checked it. The log counted it as an overlap. The rule now compares names within one install folder. Depots that share the game's folder (DLC, language and update depots) behave as before. check-depot-overlap.py has not compiled since it came in with 8f93433: it builds a DepotPlan without installURL and passes installURL to prepare(), the shape from before ddc6c2a. It now builds against the current code. It also has a two-folder case: the game and the redistributable each keep their copy, in their own folder. With main's DepotDownloader.swift that case fails ("the game's own copy is not written to its folder"), while the same-folder cases still pass. Co-Authored-By: Claude Opus 5.5 Signed-off-by: NevesHenry --- .../SwiftSteam/Content/DepotDownloader.swift | 13 +++++---- tests/host/check-depot-overlap.py | 28 ++++++++++++++----- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/app/Madeira/SwiftSteam/Content/DepotDownloader.swift b/app/Madeira/SwiftSteam/Content/DepotDownloader.swift index de3f22ec1f..937c3e5daf 100644 --- a/app/Madeira/SwiftSteam/Content/DepotDownloader.swift +++ b/app/Madeira/SwiftSteam/Content/DepotDownloader.swift @@ -439,11 +439,12 @@ final class DepotDownloader { // the earlier depot's chunks over the later one's, flipping the file // on every repair (Borderlands 2: the same 552 MB on each Repair, and // Steam's CEG step ending with 53, DataCorruption). Only the last - // depot that lists a path writes it. + // depot that lists a path writes it. A redistributable goes to its + // owner's folder, so the same name there is another file. var lastOwner: [String: Int] = [:] for (index, plan) in plans.enumerated() { for file in plan.manifest.files where !file.isDirectory && file.flags & 0x200 == 0 { - lastOwner[Self.overlapKey(file.filename)] = index + lastOwner[Self.overlapKey(file.filename, in: plan.installURL)] = index } } var overridden = 0 @@ -458,7 +459,7 @@ final class DepotDownloader { for (fileIndex, file) in plan.manifest.files.enumerated() { // A later depot lists this file too: its copy is the one installed. if !file.isDirectory, file.flags & 0x200 == 0, - let owner = lastOwner[Self.overlapKey(file.filename)], owner != planIndex { + let owner = lastOwner[Self.overlapKey(file.filename, in: plan.installURL)], owner != planIndex { overridden += 1 paths.append(""); existing.append(false); continue } @@ -514,9 +515,9 @@ final class DepotDownloader { return result } - /// One install path, compared as Windows compares names: any case, either slash. - private nonisolated static func overlapKey(_ filename: String) -> String { - filename.replacingOccurrences(of: "\\", with: "/").lowercased() + /// One install path in one folder, compared as Windows compares names: any case, either slash. + private nonisolated static func overlapKey(_ filename: String, in folder: URL) -> String { + folder.path + "\n" + filename.replacingOccurrences(of: "\\", with: "/").lowercased() } /// Create or resize a file to its manifest length. Existing bytes below diff --git a/tests/host/check-depot-overlap.py b/tests/host/check-depot-overlap.py index 25d51db0c8..0ea6627f50 100644 --- a/tests/host/check-depot-overlap.py +++ b/tests/host/check-depot-overlap.py @@ -10,7 +10,9 @@ (empty path, no pending chunks) and kept by B; - files only one depot lists, and directories, are untouched (a directory's path entry is empty by design: it has no chunks); - - one depot alone is unchanged. + - one depot alone is unchanged; + - a redistributable in its owner's folder and a game depot that list the + same name each write their own copy, in their own folder. Needs swiftc (SWIFTC=wrapper, see the host-tests notes) and python3. """ from pathlib import Path @@ -70,13 +72,13 @@ def block(text, signature): try! FileManager.default.createDirectory(at: jdir, withIntermediateDirectories: true) func chunk() -> DepotManifest.ChunkEntry { .init(sha: Data(count: 20), crc: 0, offset: 0, compressedSize: 8, uncompressedSize: 16) } func file(_ n: String, dir: Bool = false) -> DepotManifest.FileEntry { .init(filename: n, size: dir ? 0 : 16, flags: dir ? 0x40 : 0, chunks: dir ? [] : [chunk()]) } - func plan(_ id: UInt32, _ files: [DepotManifest.FileEntry]) -> DepotPlan { + func plan(_ id: UInt32, _ files: [DepotManifest.FileEntry], into folder: URL? = nil) -> DepotPlan { DepotPlan(depotID: id, manifestGID: UInt64(id), key: Data(), manifest: .init(files: files), hosts: [], auth: [:], - declaredSize: 0, health: ContentHostHealth()) + declaredSize: 0, health: ContentHostHealth(), installURL: folder ?? game) } let a = plan(1, [file("Binaries", dir: true), file("Binaries\\Win32\\Game.exe"), file("WillowGame/CookedPCConsole/a.upk"), file("only-a.txt")]) let b = plan(2, [file("binaries/win32/game.EXE"), file("WillowGame\\CookedPCConsole\\A.upk"), file("only-b.txt")]) - let p = try! prepare(plans: [a, b], installURL: game, journalDir: jdir) + let p = try! prepare(plans: [a, b], journalDir: jdir) var fails = 0 func check(_ ok: Bool, _ what: String) { if !ok { print("FAIL: \(what)"); fails += 1 } } check(p.paths[0][1] == "" && p.paths[0][2] == "", "depot A still writes the shared files: \(p.paths[0])") @@ -86,8 +88,19 @@ def block(text, signature): check(p.paths[1].allSatisfy { !$0.isEmpty }, "depot B lost a file: \(p.paths[1])") check(Set(p.pending[0].map(\.file)) == [3], "depot A pending chunks: \(p.pending[0].map(\.file))") check(Set(p.pending[1].map(\.file)) == [0, 1, 2], "depot B pending chunks: \(p.pending[1].map(\.file))") - let solo = try! prepare(plans: [a], installURL: game, journalDir: jdir) + let solo = try! prepare(plans: [a], journalDir: jdir) check(solo.paths[0].dropFirst().allSatisfy { !$0.isEmpty }, "a lone depot skipped files: \(solo.paths[0])") + // A redistributable goes to its owner's folder (Steamworks Shared), planned after the game's depots. + let shared = dir.appendingPathComponent("Steamworks Shared") + let own = plan(5, [file("_CommonRedist/vcredist/2019/VC_redist.x64.exe"), file("Game.exe")]) + let redist = plan(6, [file("_CommonRedist\\vcredist\\2019\\VC_redist.x64.exe")], into: shared) + let two = try! prepare(plans: [own, redist], journalDir: jdir) + check(two.paths[0][0] == game.appendingPathComponent("_CommonRedist/vcredist/2019/VC_redist.x64.exe").path, + "the game's own copy is not written to its folder: \(two.paths[0])") + check(two.paths[1][0] == shared.appendingPathComponent("_CommonRedist/vcredist/2019/VC_redist.x64.exe").path, + "the redistributable's copy is not written to its owner's folder: \(two.paths[1])") + check(Set(two.pending[0].map(\.file)) == [0, 1] && Set(two.pending[1].map(\.file)) == [0], + "pending chunks across two folders: \(two.pending.map { $0.map(\.file) })") return fails == 0 ? 0 : 1 } } @@ -102,5 +115,6 @@ def block(text, signature): assert r.returncode == 0, r.stdout + r.stderr r = subprocess.run([str(folder / 'check')], capture_output=True, text=True) assert r.returncode == 0, r.stdout + r.stderr - assert '[steam-depot] overlap files=2' in r.stdout, r.stdout -print('PASS: a file two depots list is written only by the later depot (any case, either slash); other files untouched') + assert '[steam-depot] overlap files=2' in r.stdout and 'overlap files=1' not in r.stdout, r.stdout +print('PASS: a file two depots list is written only by the later depot (any case, either slash); other files untouched; ' + 'a redistributable in its owner\'s folder does not replace the game\'s own copy')