diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..f9cf85db
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Shell scripts must use LF endings or they break when run on Linux (bash\r).
+*.sh text eol=lf
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 00000000..11cd5fd1
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,26 @@
+name: Build
+
+on: [push, pull_request, workflow_dispatch]
+
+jobs:
+ build-windows:
+ runs-on: windows-2022
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: microsoft/setup-msbuild@v2
+
+ - name: Configure CMake
+ run: cmake -B build -G "Visual Studio 17 2022" -A x64
+
+ - name: Build
+ run: cmake --build build --config Release
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: cloudredirect-windows
+ path: |
+ build/Release/cloud_redirect.dll
+ build/Release/cloud_redirect_cli.exe
+ build/Release/cloud760_tool.exe
+ ui/bin/publish/CloudRedirect.exe
diff --git a/.gitignore b/.gitignore
index 7f879170..718d1c46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,20 @@
build/
build-*/
+dist/
ui/bin/
ui/obj/
+ui/publish/
ui-linux/build/
flatpak/.flatpak-builder/
flatpak/build-dir/
+flatpak/repo/
flatpak/cloud_redirect.so
build.ps1
ui/Resources/cloud_redirect_cli.exe
ui/Resources/payloads/
+tests/
+src/testutil/
+docs/
+tools/
+flatpak/release.sh
+ui/native/
diff --git a/CMakeLists.txt b/CMakeLists.txt
index df3a2e00..8a9d7e7a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -9,10 +9,13 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON)
file(READ "${CMAKE_SOURCE_DIR}/Version.props" VERSION_PROPS_CONTENT)
string(REGEX MATCH "([^<]+)" _ "${VERSION_PROPS_CONTENT}")
set(CR_RELEASE_VERSION "${CMAKE_MATCH_1}")
+string(REGEX MATCH "([^<]*)" _ "${VERSION_PROPS_CONTENT}")
+set(CR_PRERELEASE "${CMAKE_MATCH_1}")
if(NOT CR_RELEASE_VERSION)
set(CR_RELEASE_VERSION "0.0.0")
endif()
+set(CR_RELEASE_VERSION "${CR_RELEASE_VERSION}${CR_PRERELEASE}")
# ── Generate version string with git SHA ────────────────────────────────
# Release builds may pass -DCR_GIT_SHA to pin the build id (e.g. when the build
@@ -51,8 +54,8 @@ set(CR_SO_VERSION "${CR_RELEASE_VERSION}+${GIT_SHA}")
set(COMMON_SOURCES
src/common/protobuf.cpp
src/common/json.cpp
+ src/common/coop_yield.cpp
src/common/vdf.cpp
- src/common/bkv_stats.cpp
src/common/remotecache_repair.cpp
src/common/manifest_store.cpp
src/common/app_state.cpp
@@ -69,10 +72,11 @@ set(COMMON_SOURCES
src/common/cli.cpp
src/common/cloud_provider_base.cpp
src/common/autocloud_scan.cpp
- src/common/autocloud_bootstrap.cpp
src/common/steam_kv_injector.cpp
src/common/parental_bypass.cpp
src/common/metadata_sync.cpp
+ src/common/stats_store.cpp
+ src/common/stats_handlers.cpp
src/common/miniz.c
src/common/miniz_tdef.c
src/common/miniz_tinfl.c
@@ -107,6 +111,12 @@ else()
src/platform/linux/vtable_hook.cpp
src/platform/linux/cloud_hooks.cpp
src/platform/linux/cloud_intercept.cpp
+ src/platform/linux/stats_hooks.cpp
+ src/platform/linux/gamesplayed_hook.cpp
+ src/platform/linux/live_playtime.cpp
+ src/platform/linux/achievement_inject.cpp
+ src/platform/linux/schema_fetch.cpp
+ src/platform/linux/recvpkt_hook.cpp
src/platform/linux/http_transport_linux.cpp
src/platform/linux/token_store_linux.cpp
src/platform/linux/log.cpp
@@ -151,12 +161,19 @@ if(WIN32)
target_link_libraries(cloud_redirect_cli PRIVATE Shell32 Ole32)
# Ensure DLL is built before CLI
add_dependencies(cloud_redirect_cli cloud_redirect)
+
+ # Standalone Steam Cloud file manager for a single AppID (default 760).
+ # Self-contained: resolves the steam_api64.dll flat exports at runtime, so it
+ # needs no Steamworks SDK headers/libs. steam_api64.dll must sit next to it.
+ add_executable(cloud760_tool
+ src/platform/win/cloud760_tool.cpp
+ )
else()
target_include_directories(cloud_redirect PRIVATE src/platform/linux)
target_link_libraries(cloud_redirect PRIVATE dl pthread)
# Steam on Linux is 32-bit; use LINUX_32BIT=ON when cross-compiling on 64-bit host
# Match Steam's pre-C++11 ABI
- target_compile_definitions(cloud_redirect PRIVATE _GLIBCXX_USE_CXX11_ABI=0)
+ target_compile_definitions(cloud_redirect PRIVATE _GLIBCXX_USE_CXX11_ABI=0 _FILE_OFFSET_BITS=64)
if(LINUX_32BIT)
target_compile_options(cloud_redirect PRIVATE -m32)
target_link_options(cloud_redirect PRIVATE -m32)
@@ -200,6 +217,7 @@ endif()
enable_testing()
if(EXISTS ${CMAKE_SOURCE_DIR}/tests/autocloud_native_tests.cpp)
+ add_compile_definitions(CR_RELEASE_VERSION="${CR_RELEASE_VERSION}")
if(WIN32)
add_executable(autocloud_native_tests
tests/autocloud_native_tests.cpp
@@ -235,14 +253,6 @@ if(EXISTS ${CMAKE_SOURCE_DIR}/tests/autocloud_native_tests.cpp)
target_include_directories(remotecache_repair_tests PRIVATE src/common)
add_test(NAME remotecache_repair_tests COMMAND remotecache_repair_tests)
- if(EXISTS ${CMAKE_SOURCE_DIR}/tests/bkv_stats_tests.cpp)
- add_executable(bkv_stats_tests
- tests/bkv_stats_tests.cpp
- src/common/bkv_stats.cpp
- )
- target_include_directories(bkv_stats_tests PRIVATE src/common)
- add_test(NAME bkv_stats_tests COMMAND bkv_stats_tests)
- endif()
add_executable(rpc_handlers_tests
tests/rpc_handlers_tests.cpp
@@ -272,6 +282,7 @@ if(EXISTS ${CMAKE_SOURCE_DIR}/tests/autocloud_native_tests.cpp)
src/common/legacy_metadata_cleanup.cpp
src/common/manifest_store.cpp
src/common/app_state.cpp
+ src/common/coop_yield.cpp
src/common/token_store.cpp
src/common/cloud_staging.cpp
src/common/pending_ops_journal.cpp
@@ -346,15 +357,50 @@ if(EXISTS ${CMAKE_SOURCE_DIR}/tests/autocloud_native_tests.cpp)
)
target_include_directories(vdf_tests PRIVATE src/common)
add_test(NAME vdf_tests COMMAND vdf_tests)
+
+ add_executable(stats_store_tests
+ tests/stats_store_tests.cpp
+ src/common/stats_store.cpp
+ src/common/json.cpp
+ src/common/metadata_sync.cpp
+ src/common/vdf.cpp
+ )
+ target_include_directories(stats_store_tests PRIVATE src/common)
+ if(WIN32)
+ target_include_directories(stats_store_tests PRIVATE src/platform/win)
+ target_sources(stats_store_tests PRIVATE
+ src/platform/win/log.cpp
+ src/platform/win/platform_win.cpp
+ )
+ target_link_libraries(stats_store_tests PRIVATE Shlwapi Advapi32 Crypt32 Shell32 Ole32)
+ else()
+ target_include_directories(stats_store_tests PRIVATE src/platform/linux)
+ target_sources(stats_store_tests PRIVATE
+ src/platform/linux/log.cpp
+ src/platform/linux/platform_linux.cpp
+ src/platform/linux/file_util.cpp
+ )
+ target_link_libraries(stats_store_tests PRIVATE pthread dl)
+ endif()
+ add_test(NAME stats_store_tests COMMAND stats_store_tests)
endif()
# ── UI (Windows only) ───────────────────────────────────────────────────
if(WIN32)
+ # The csproj expects the DLL/CLI at ../build/Release/; copy from the actual
+ # build output dir so out-of-tree builds (build-win) work correctly.
add_custom_target(ui ALL
+ COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_SOURCE_DIR}/build/Release"
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ "$"
+ "${CMAKE_SOURCE_DIR}/build/Release/cloud_redirect.dll"
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ "$"
+ "${CMAKE_SOURCE_DIR}/build/Release/cloud_redirect_cli.exe"
COMMAND dotnet publish -c Release -r win-x64 --self-contained false -o bin/publish
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/ui
COMMENT "Publishing CloudRedirect UI"
- DEPENDS cloud_redirect
+ DEPENDS cloud_redirect cloud_redirect_cli
)
endif()
diff --git a/Version.props b/Version.props
index 7e83ce06..613d2ece 100644
--- a/Version.props
+++ b/Version.props
@@ -2,6 +2,10 @@
2.2.4
+
+
+
1.0
diff --git a/cli-rust/Cargo.toml b/cli-rust/Cargo.toml
index 5fbfbd40..f6454bab 100644
--- a/cli-rust/Cargo.toml
+++ b/cli-rust/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "cloudredirect-cli"
-version = "2.2.2"
+version = "2.1.8"
edition = "2021"
description = "CloudRedirect CLI (STFixer) — Rust port"
diff --git a/cli-rust/src/embedded.rs b/cli-rust/src/embedded.rs
index 8e5b971c..13f55b9b 100644
--- a/cli-rust/src/embedded.rs
+++ b/cli-rust/src/embedded.rs
@@ -37,14 +37,6 @@ const PAYLOADS: &[(i64, &[u8])] = &[
1781041600,
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/embedded/payloads/1781041600/payload")),
),
- (
- 1782257239,
- include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/embedded/payloads/1782257239/payload")),
- ),
- (
- 1782344391,
- include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/embedded/payloads/1782344391/payload")),
- ),
];
pub fn dll_available() -> bool {
diff --git a/cli-rust/src/patcher.rs b/cli-rust/src/patcher.rs
index f2b03a4e..982f8136 100644
--- a/cli-rust/src/patcher.rs
+++ b/cli-rust/src/patcher.rs
@@ -210,7 +210,7 @@ impl Patcher {
// 2. Payload cache.
let mut log = |m: &str| println!("{}", m);
- let mut cache_path = match crypto::find_cache_path(&self.steam_path, &mut log) {
+ let cache_path = match crypto::find_cache_path(&self.steam_path, &mut log) {
Some(p) => p,
None => {
self.log("Payload cache not found. Deploying embedded payload..");
@@ -222,26 +222,12 @@ impl Patcher {
};
self.log("Patching payload (offline setup)..");
- let (mut payload, mut iv) = match self.read_and_decrypt_payload(&cache_path) {
+ let (payload, iv) = match self.read_and_decrypt_payload(&cache_path) {
Ok(v) => v,
Err(e) => return PatchOutcome::fail(e),
};
- let mut resolved_setup = self.resolve_setup_patch_offsets(&payload);
-
- // Payload may be corrupted by a prior CR version; try the embedded copy.
- if resolved_setup.as_ref().map_or(true, |r| r.is_empty()) {
- if let Some((rec_path, rec_payload, rec_iv)) =
- self.try_recover_corrupted_payload(&cache_path, version)
- {
- cache_path = rec_path;
- payload = rec_payload;
- iv = rec_iv;
- resolved_setup = self.resolve_setup_patch_offsets(&payload);
- }
- }
-
- let resolved_setup = match resolved_setup {
+ let resolved_setup = match self.resolve_setup_patch_offsets(&payload) {
Some(r) if !r.is_empty() => r,
_ => {
return PatchOutcome::fail(
@@ -397,36 +383,33 @@ impl Patcher {
/// Resolve payload section bounds: .text and the first non-standard ("obf") section.
fn resolve_payload_sections(&self, payload: &[u8]) -> Option<(i64, i64, i64, i64)> {
let sections = PeSection::parse(payload);
- let text = PeSection::find(§ions, ".text")?;
+ let text = PeSection::find(§ions, ".text");
- const KNOWN: [&str; 8] = [
- ".text", ".rdata", ".data", ".pdata", ".fptable", ".rsrc", ".reloc", ".idata",
+ const KNOWN: [&str; 7] = [
+ ".text", ".rdata", ".data", ".pdata", ".fptable", ".rsrc", ".reloc",
];
let obf = sections.iter().find(|s| !KNOWN.contains(&s.name.as_str()));
- let t_start = text.raw_offset as i64;
- let t_end = (t_start + text.raw_size as i64).min(payload.len() as i64);
- // Fall back to .text bounds if no obfuscated section.
- let (g_start, g_end) = match obf {
- Some(o) => {
- let s = o.raw_offset as i64;
- (s, (s + o.raw_size as i64).min(payload.len() as i64))
+ let (text, obf) = match (text, obf) {
+ (Some(t), Some(o)) => (t, o),
+ _ => {
+ self.log(" Payload: missing expected sections");
+ return None;
}
- None => (t_start, t_end),
};
+
+ let t_start = text.raw_offset as i64;
+ let t_end = (t_start + text.raw_size as i64).min(payload.len() as i64);
+ let g_start = obf.raw_offset as i64;
+ let g_end = (g_start + obf.raw_size as i64).min(payload.len() as i64);
Some((t_start, t_end, g_start, g_end))
}
fn resolve_setup_patch_offsets(&self, payload: &[u8]) -> Option> {
let (t_start, t_end, g_start, g_end) = self.resolve_payload_sections(payload)?;
let mut log = |m: &str| println!("{}", m);
- // V2 first, V1 fallback.
- let new_defs = signatures::payload_setup_defs();
- if let Some(r) = signatures::resolve_pattern_group(payload, &new_defs, t_start, t_end, g_start, g_end, &mut log) {
- return Some(r);
- }
- let old_defs = signatures::payload_setup_defs_v1();
- signatures::resolve_pattern_group(payload, &old_defs, t_start, t_end, g_start, g_end, &mut log)
+ let defs = signatures::payload_setup_defs();
+ signatures::resolve_pattern_group(payload, &defs, t_start, t_end, g_start, g_end, &mut log)
}
/// Read + AES-decrypt + zlib-inflate the payload cache. Returns (payload, iv).
@@ -483,45 +466,6 @@ impl Patcher {
embedded::install_payload(&self.steam_path, version, |m| println!("{}", m))
}
- // Sideline a corrupted cache file, deploy the embedded payload, and re-decrypt.
- fn try_recover_corrupted_payload(
- &self,
- old_path: &Path,
- version: i64,
- ) -> Option<(PathBuf, Vec, [u8; 16])> {
- self.log(" Payload appears corrupted by a previous version. Attempting recovery...");
-
- let corrupt = file_util::with_extension_suffix(old_path, ".corrupt");
- if let Err(e) = std::fs::rename(old_path, &corrupt) {
- self.log(&format!(" Could not rename corrupted payload: {}", e));
- return None;
- }
- self.log(&format!(" Corrupted payload saved to {}", corrupt.display()));
-
- let new_path = match self.deploy_embedded_payload(version) {
- Some(p) => p,
- None => {
- self.log(" Recovery failed: no embedded payload for this build.");
- let _ = std::fs::rename(&corrupt, old_path);
- return None;
- }
- };
-
- match self.read_and_decrypt_payload(&new_path) {
- Ok((payload, iv)) => {
- self.log(&format!(
- " Recovery succeeded: clean payload deployed ({} bytes)",
- payload.len()
- ));
- Some((new_path, payload, iv))
- }
- Err(e) => {
- self.log(&format!(" Recovery failed: {}", e));
- None
- }
- }
- }
-
fn backup(&self, path: &Path) {
let orig = file_util::with_extension_suffix(path, ".orig");
if !orig.exists() {
diff --git a/cli-rust/src/signatures.rs b/cli-rust/src/signatures.rs
index beab05c6..0a9c5dae 100644
--- a/cli-rust/src/signatures.rs
+++ b/cli-rust/src/signatures.rs
@@ -263,48 +263,6 @@ fn p3_resolver(data: &[u8], hit: usize) -> i64 {
-1
}
-fn p1_validator_v2(data: &[u8], hit: usize) -> bool {
- (data[hit + 15] == 0x0F && data[hit + 16] == 0x86)
- || (data[hit + 15] == 0x90 && data[hit + 16] == 0xE9)
-}
-
-fn p2_validator_v2(data: &[u8], hit: usize) -> bool {
- (data[hit + 19] == 0x8B && data[hit + 20] == 0x0D)
- || (data[hit + 19] == 0x31 && data[hit + 20] == 0xC9)
-}
-
-fn p4_validator_v2(data: &[u8], hit: usize) -> bool {
- (data[hit + 2] == 0x0F && data[hit + 3] == 0x95 && data[hit + 4] == 0xC0)
- || (data[hit + 2] == 0xB0 && data[hit + 3] == 0x01 && data[hit + 4] == 0x90)
-}
-
-fn p7_validator_v2(data: &[u8], hit: usize) -> bool {
- (data[hit + 3] == 0x75 && data[hit + 8] == 0x74)
- || (data[hit + 3] == 0x90 && data[hit + 8] == 0xEB)
-}
-
-fn p3_resolver_v2(data: &[u8], hit: usize) -> i64 {
- let search_start = hit + 8;
- let search_end = (search_start + 20).min(data.len());
- let mut i = search_start;
- while i + 5 < search_end {
- if data[i] == 0x89 && data[i + 1] == 0x3D {
- return i as i64;
- }
- if data[i] == 0x90
- && data[i + 1] == 0x90
- && data[i + 2] == 0x90
- && data[i + 3] == 0x90
- && data[i + 4] == 0x90
- && data[i + 5] == 0x90
- {
- return i as i64;
- }
- i += 1;
- }
- -1
-}
-
fn has_bytes(bytes: &[u8], pos: i64, expected: &[u8]) -> bool {
if pos < 0 || pos as usize + expected.len() > bytes.len() {
return false;
@@ -320,7 +278,7 @@ fn skip_optional_bridge(bytes: &[u8], pos: i64) -> i64 {
}
}
-fn p4_resolver_v1(data: &[u8], hit: usize) -> i64 {
+fn p4_resolver(data: &[u8], hit: usize) -> i64 {
let mut pos = hit as i64 + 3;
pos = skip_optional_bridge(data, pos);
if !has_bytes(data, pos, &[0x0F, 0x84]) {
@@ -533,87 +491,15 @@ pub fn payload_p123_defs() -> Vec {
]
}
-/// V2 P1/P2/P3: new plain .text payload (no obfuscator, restructured code).
-pub fn payload_p123_defs_v2() -> Vec {
- vec![
- // P1 V2: cloud rewrite jbe -> nop jmp.
- PatternPatch {
- wildcard_start: 2,
- wildcard_len: 4,
- validator: Some(p1_validator_v2),
- ..PatternPatch::new(
- "P1 (cloud rewrite skip)",
- &[
- 0x85, 0xC0, 0x0F, 0x85, 0x00, 0x00, 0x00, 0x00,
- 0x44, 0x39, 0x25, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x00,
- ],
- &[
- 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00,
- 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x00,
- ],
- 15,
- &[0x0F, 0x86, 0x00, 0x00, 0x00, 0x00],
- &[0x90, 0xE9, 0x00, 0x00, 0x00, 0x00],
- ScanRegion::Text,
- )
- },
- // P2 V2: zero proxy appid load. Registers changed:
- // mov r15,rax / mov r8,rsi (was mov rsi,rax / mov r8,rdi).
- // lea rdx,[r15+rdi] (was lea rdx,[rsi+rdi]).
- PatternPatch {
- wildcard_start: 2,
- wildcard_len: 4,
- validator: Some(p2_validator_v2),
- ..PatternPatch::new(
- "P2 (proxy appid zero)",
- &[
- 0x4C, 0x8B, 0xF8, 0x4C, 0x8B, 0xC6, 0x48, 0x8B, 0x54, 0x24, 0x30,
- 0x48, 0x8B, 0xC8, 0xE8, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x49, 0x8D, 0x14, 0x37, 0x48, 0x81, 0xF9, 0x80, 0x00, 0x00, 0x00,
- ],
- &[
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- ],
- 19,
- &[0x8B, 0x0D, 0x00, 0x00, 0x00, 0x00],
- &[0x31, 0xC9, 0x90, 0x90, 0x90, 0x90],
- ScanRegion::Text,
- )
- },
- // P3 V2: NOP IPC appid preserve. Anchor changed from C7 40 09
- // (mov [rax+9],480) to 41 C7 46 09 (mov [r14+9],480). Now in .text.
- PatternPatch {
- wildcard_start: 2,
- wildcard_len: 4,
- patch_site_resolver: Some(p3_resolver_v2),
- ..PatternPatch::new(
- "P3 (IPC appid preserve)",
- &[0x41, 0xC7, 0x46, 0x09, 0xE0, 0x01, 0x00, 0x00],
- &[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
- 0,
- &[0x89, 0x3D, 0x00, 0x00, 0x00, 0x00],
- &[0x90, 0x90, 0x90, 0x90, 0x90, 0x90],
- ScanRegion::Text,
- )
- },
- ]
-}
-
// Payload setup patches P4/P5/P6
-/// V1 defs: old payload with obfuscated P4 (E9 bridges).
-pub fn payload_setup_defs_v1() -> Vec {
+pub fn payload_setup_defs() -> Vec {
vec![
+ // P4: force activation flag to 1.
PatternPatch {
wildcard_start: 2,
wildcard_len: 4,
- patch_site_resolver: Some(p4_resolver_v1),
+ patch_site_resolver: Some(p4_resolver),
..PatternPatch::new(
"P4 (activation flag)",
&[0x4D, 0x85, 0xC0],
@@ -624,121 +510,6 @@ pub fn payload_setup_defs_v1() -> Vec {
ScanRegion::Obfuscated,
)
},
- // P7 exists in both old and new payloads.
- PatternPatch {
- validator: Some(p7_validator_v2),
- ..PatternPatch::new(
- "P7 (activation confirmed)",
- &[
- 0x4C, 0x3B, 0xC0, 0x75, 0x17, 0x4D, 0x85, 0xC0,
- 0x74, 0x09, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x85,
- 0xC0, 0x75, 0x09, 0xC6, 0x05,
- ],
- &[
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF,
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- ],
- 3,
- &[0x75, 0x17, 0x4D, 0x85, 0xC0, 0x74, 0x09],
- &[0x90, 0x90, 0x4D, 0x85, 0xC0, 0xEB, 0x09],
- ScanRegion::Text,
- )
- },
- PatternPatch {
- validator: Some(p5_validator),
- ..PatternPatch::new(
- "P5 (GetCookie retry skip)",
- &[
- 0x66, 0x48, 0x0F, 0x7E, 0xC7, 0x66, 0x48, 0x0F, 0x7E, 0xCE, 0x48, 0x8D, 0x4D,
- 0x00, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x85, 0xF6, 0x00,
- ],
- &[
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00,
- ],
- 22,
- &[0x75],
- &[0xEB],
- ScanRegion::Text,
- )
- },
- PatternPatch {
- validator: Some(p6_validator),
- ..PatternPatch::new(
- "P6 (GMRC pattern fix)",
- &[
- 0x34, 0x38, 0x20, 0x38, 0x39, 0x20, 0x35, 0x43, 0x20, 0x32, 0x34, 0x20, 0x31,
- 0x38, 0x20, 0x35, 0x35, 0x20,
- ],
- &[
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- ],
- 0,
- &[
- 0x34, 0x38, 0x20, 0x38, 0x39, 0x20, 0x35, 0x43, 0x20, 0x32, 0x34, 0x20, 0x31,
- 0x38, 0x20, 0x35, 0x35, 0x20, 0x35, 0x36, 0x20, 0x35, 0x37, 0x20, 0x34, 0x31,
- 0x20, 0x35, 0x35, 0x20, 0x34, 0x31, 0x20, 0x35, 0x37, 0x20, 0x34, 0x38, 0x20,
- 0x38, 0x44, 0x20, 0x36, 0x43, 0x5E, 0x00, 0x00, 0x00,
- ],
- &[
- 0x34, 0x38, 0x20, 0x38, 0x39, 0x20, 0x35, 0x43, 0x20, 0x32, 0x34, 0x20, 0x31,
- 0x38, 0x20, 0x35, 0x35, 0x20, 0x35, 0x37, 0x20, 0x34, 0x31, 0x20, 0x35, 0x34,
- 0x20, 0x34, 0x31, 0x20, 0x35, 0x36, 0x20, 0x34, 0x31, 0x20, 0x35, 0x37, 0x20,
- 0x34, 0x38, 0x20, 0x38, 0x44, 0x20, 0x36, 0x43, 0x00,
- ],
- ScanRegion::All,
- )
- },
- ]
-}
-
-/// Current defs: new payload with plain .text P4 (no obfuscator).
-pub fn payload_setup_defs() -> Vec {
- vec![
- // P4: force activation flag to 1.
- // New plain .text layout: test edx,edx; setnz al -> mov al,1; nop.
- PatternPatch {
- validator: Some(p4_validator_v2),
- ..PatternPatch::new(
- "P4 (activation flag)",
- &[
- 0x85, 0xD2, 0x00, 0x00, 0x00, 0xEB, 0x02, 0xB0, 0x01, 0x88,
- 0x47, 0x01,
- ],
- &[
- 0xFF, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0xFF,
- ],
- 2,
- &[0x0F, 0x95, 0xC0],
- &[0xB0, 0x01, 0x90],
- ScanRegion::Text,
- )
- },
- // P7: force activation confirmed flag to 1.
- // Bypass server-side hash check: jnz->nop+nop, jz->jmp.
- PatternPatch {
- validator: Some(p7_validator_v2),
- ..PatternPatch::new(
- "P7 (activation confirmed)",
- &[
- 0x4C, 0x3B, 0xC0, 0x75, 0x17, 0x4D, 0x85, 0xC0,
- 0x74, 0x09, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x85,
- 0xC0, 0x75, 0x09, 0xC6, 0x05,
- ],
- &[
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF,
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- ],
- 3,
- &[0x75, 0x17, 0x4D, 0x85, 0xC0, 0x74, 0x09],
- &[0x90, 0x90, 0x4D, 0x85, 0xC0, 0xEB, 0x09],
- ScanRegion::Text,
- )
- },
// P5: skip GetCookie retry.
PatternPatch {
validator: Some(p5_validator),
@@ -902,9 +673,8 @@ mod tests {
}
}
-/// Locate recvPktGlobal: find `lea rcx, SendPkt`, then the nearby
-/// `mov [rip+disp], rcx` that stores the original function pointer.
-/// Backward scan first, forward scan as fallback.
+/// Locate recvPktGlobal: find `lea rcx, SendPkt`, then the following
+/// `mov cs:qword, rcx` that stores RecvPkt. Returns -1 on miss.
pub fn find_recv_pkt_global_rva(
data: &[u8],
sections: &[PeSection],
@@ -930,31 +700,19 @@ pub fn find_recv_pkt_global_rva(
i += 1;
continue;
}
- // Backward-scan for `mov [rip+disp], rcx` (48 89 0D).
- let bstart = search_start.max(i - 0x100);
- let mut j = i - 1;
- while j >= bstart {
- let ju = j as usize;
- if data[ju] == 0x48 && data[ju + 1] == 0x89 && data[ju + 2] == 0x0D {
- let mov_rel = read_i32(data, ju + 3) as i64;
- let mov_rva = PeSection::file_offset_to_rva(sections, j);
- if mov_rva >= 0 {
- return mov_rva + 7 + mov_rel;
- }
- }
- j -= 1;
- }
- // Forward-scan fallback (48 89 xx, modrm rip-relative).
+ // Forward-scan for `mov cs:qword, rcx` (48 89 0D).
let mut j = i + 7;
let jend = (i + 0x100).min(search_end) - 7;
while j < jend {
let ju = j as usize;
- if data[ju] == 0x48 && data[ju + 1] == 0x89 && (data[ju + 2] & 0xC7) == 0x05 {
+ if data[ju] == 0x48 && data[ju + 1] == 0x89 && data[ju + 2] == 0x0D {
let mov_rel = read_i32(data, ju + 3) as i64;
let mov_rva = PeSection::file_offset_to_rva(sections, j);
- if mov_rva >= 0 {
- return mov_rva + 7 + mov_rel;
+ if mov_rva < 0 {
+ j += 1;
+ continue;
}
+ return mov_rva + 7 + mov_rel;
}
j += 1;
}
diff --git a/cli-rust/src/steam_detector.rs b/cli-rust/src/steam_detector.rs
index 92cac9cd..16fe518c 100644
--- a/cli-rust/src/steam_detector.rs
+++ b/cli-rust/src/steam_detector.rs
@@ -2,8 +2,8 @@
use std::path::{Path, PathBuf};
-pub const SUPPORTED_STEAM_VERSIONS: [i64; 8] =
- [1782344391, 1782257239, 1781041600, 1780352834, 1779918128, 1779486452, 1778281814, 1778003620];
+pub const SUPPORTED_STEAM_VERSIONS: [i64; 6] =
+ [1781041600, 1780352834, 1779918128, 1779486452, 1778281814, 1778003620];
pub fn is_supported_steam_version(version: i64) -> bool {
SUPPORTED_STEAM_VERSIONS.contains(&version)
diff --git a/flatpak/build.sh b/flatpak/build.sh
index 6a771c3c..11fcd0ed 100644
--- a/flatpak/build.sh
+++ b/flatpak/build.sh
@@ -1,6 +1,13 @@
#!/bin/bash
-# Build script for CloudRedirect Flatpak
-# Run this on a Linux system with flatpak-builder installed
+# Build script for CloudRedirect Flatpak. LOCAL TEST INSTALL ONLY.
+#
+# This builds and installs the flatpak into your user installation so you can
+# run it locally. It does NOT export or sign a distributable repo.
+#
+# To publish a release to GitHub Pages, use release.sh instead. It builds,
+# SIGNS the OSTree summary, guards against an unsigned repo, and pushes gh-pages.
+# Publishing with this script would produce an unsigned repo that clients reject
+# ("GPG verification enabled, but no summary signatures found").
set -e
diff --git a/flatpak/org.cloudredirect.CloudRedirect.metainfo.xml b/flatpak/org.cloudredirect.CloudRedirect.metainfo.xml
index dbdd1b49..8ba3531e 100644
--- a/flatpak/org.cloudredirect.CloudRedirect.metainfo.xml
+++ b/flatpak/org.cloudredirect.CloudRedirect.metainfo.xml
@@ -38,7 +38,7 @@
-
+
diff --git a/flatpak/org.cloudredirect.CloudRedirect.yml b/flatpak/org.cloudredirect.CloudRedirect.yml
index 484fe64b..99745e82 100644
--- a/flatpak/org.cloudredirect.CloudRedirect.yml
+++ b/flatpak/org.cloudredirect.CloudRedirect.yml
@@ -44,8 +44,11 @@ modules:
buildsystem: cmake-ninja
no-debuginfo: true
config-opts:
+ # Version is read from Version.props (copied into the sandbox below) by
+ # ui-linux/CMakeLists.txt. Do NOT hardcode CR_RELEASE_VERSION here -- it
+ # overrides Version.props and drifts out of sync.
- -DCMAKE_BUILD_TYPE=Release
- - -DCR_RELEASE_VERSION=2.2.2-final
+
sources:
- type: dir
path: ../ui-linux
diff --git a/src/common/app_state.cpp b/src/common/app_state.cpp
index 67d4eeaa..737ed2ae 100644
--- a/src/common/app_state.cpp
+++ b/src/common/app_state.cpp
@@ -1,13 +1,22 @@
#include "app_state.h"
#include "cloud_storage.h"
#include "cloud_metadata_paths.h"
+#include "coop_yield.h"
#include "file_util.h"
#include "json.h"
#include "local_storage.h"
#include "log.h"
#include "manifest_store.h"
+#include
+#include
#include
+#include
+#include
+#include
+#include
+#include
+#include
using CloudIntercept::IsReservedBlobFilename;
@@ -15,6 +24,154 @@ namespace CloudStorage {
static ICloudProvider* g_stateProvider = nullptr;
+// ---- Serve-path cloud-state cache -------------------------------------------
+// Backs FetchCloudStateForServe only. FetchCloudState never reads it; it only
+// refreshes it on each live fetch.
+namespace {
+
+// Staleness ceiling. Covers a download burst (serve runs right after the
+// GetChangelist that warmed the cache) while bounding cross-machine staleness.
+constexpr int64_t kServeCacheMaxAgeMs = 3000;
+
+struct ServeCacheEntry {
+ uint64_t cn = 0;
+ int64_t fetchedAtMs = 0;
+ bool foreignSession = false; // active session in the fetched state
+ StateFetchResult result;
+};
+
+// Leaked, never-destructed: a detached bounded-fetch worker can still touch these
+// during static destruction at exit, so heap-backing them (no destructor) avoids a
+// UAF on a destroyed mutex/map.
+std::mutex& g_serveCacheMtx = *new std::mutex();
+std::unordered_map& g_serveCache =
+ *new std::unordered_map(); // key = (acct<<32)|app
+
+// This client's id (set by NoteOwnClientId). Used to tell our own session from a
+// foreign one: only a foreign session is contention. Counting ours would disable
+// the cache during the very download burst it exists for. Not latched from
+// PublishCloudState -- RMW paths can republish a foreign machine's session.
+std::atomic g_ownClientId{0};
+
+inline uint64_t ServeCacheKey(uint32_t accountId, uint32_t appId) {
+ return (static_cast(accountId) << 32) | appId;
+}
+
+inline int64_t NowMs() {
+ using namespace std::chrono;
+ return duration_cast(steady_clock::now().time_since_epoch()).count();
+}
+
+} // namespace
+
+// Pending publish barrier: CompleteBatch defers the cloud publish to a background
+// thread and stores its future here. ReleaseCloudSession (ExitSyncDone) waits on it
+// before releasing the session lock so cloud state is durable before another machine
+// can acquire. BeginBatch's FetchCloudStateForServe also drains it so the next batch
+// sees the fresh cloud CN.
+namespace {
+struct PendingPublishEntry {
+ uint64_t generation = 0;
+ std::shared_future fut;
+};
+std::mutex& g_pendingPublishMtx = *new std::mutex();
+std::unordered_map& g_pendingPublish =
+ *new std::unordered_map();
+uint64_t g_pendingPublishGen = 0;
+} // namespace
+
+void SetPendingPublish(uint32_t accountId, uint32_t appId,
+ std::shared_future fut) {
+ std::lock_guard lk(g_pendingPublishMtx);
+ PendingPublishEntry entry;
+ entry.generation = ++g_pendingPublishGen;
+ entry.fut = std::move(fut);
+ g_pendingPublish[ServeCacheKey(accountId, appId)] = std::move(entry);
+}
+
+void WaitForPendingPublish(uint32_t accountId, uint32_t appId) {
+ const uint64_t key = ServeCacheKey(accountId, appId);
+ // Loop: PumpUntil yields the job coroutine, so another CompleteBatch for this
+ // same app can run while we wait and replace the map entry with a newer barrier.
+ // We must wait on (and only erase) the exact future we observed -- never blindly
+ // erase, or we'd drop a newer publish barrier and release the session lock while
+ // that publish is still in flight (stale cloud state for the next machine).
+ while (true) {
+ std::shared_future fut;
+ uint64_t gen = 0;
+ {
+ std::lock_guard lk(g_pendingPublishMtx);
+ auto it = g_pendingPublish.find(key);
+ if (it == g_pendingPublish.end()) return;
+ fut = it->second.fut;
+ gen = it->second.generation;
+ }
+ if (fut.valid()) {
+ // Runs on BMainLoop (BeginBatch handler); a hard fut.wait() here starved the
+ // frame watchdog while a prior batch's publish held its barrier. Pump the job
+ // coroutine instead, polling with wait_for(0). Degrades to a plain spin off Steam.
+ CoopYield::PumpUntil([&fut]() {
+ return fut.wait_for(std::chrono::seconds(0)) ==
+ std::future_status::ready;
+ });
+ }
+ {
+ std::lock_guard lk(g_pendingPublishMtx);
+ auto it = g_pendingPublish.find(key);
+ // Only erase if the map still holds the same barrier we just waited on.
+ // A newer CompleteBatch that ran during our yield will have bumped the
+ // generation; loop again to wait on that one before returning.
+ if (it == g_pendingPublish.end()) return;
+ if (it->second.generation != gen) {
+ continue; // a newer barrier appeared; wait on it too
+ }
+ g_pendingPublish.erase(it);
+ return;
+ }
+ }
+}
+
+// See g_ownClientId.
+void NoteOwnClientId(uint64_t clientId) {
+ if (clientId != 0)
+ g_ownClientId.store(clientId, std::memory_order_relaxed);
+}
+
+// Drop the cached entry for an app. Called on every local state mutation so a
+// stale snapshot can never outlive a change CR itself made.
+static void InvalidateServeCache(uint32_t accountId, uint32_t appId) {
+ std::lock_guard lk(g_serveCacheMtx);
+ g_serveCache.erase(ServeCacheKey(accountId, appId));
+}
+
+// Record a live fetch for the serve path. Latest good parse normally wins (even a
+// lower cn -- a legitimate rewind), but concurrent bounded fetches can finish out of
+// order, so reject a lower-cn write while the existing entry is still fresh; a real
+// rewind re-asserts once the duplicates quiesce.
+static constexpr int64_t kConcurrentFetchWindowMs = 10000;
+static void RefreshServeCache(uint32_t accountId, uint32_t appId,
+ const StateFetchResult& result) {
+ if (result.status != StateFetchStatus::Ok) return; // only cache good reads
+ std::lock_guard lk(g_serveCacheMtx);
+ uint64_t key = ServeCacheKey(accountId, appId);
+ auto existing = g_serveCache.find(key);
+ if (existing != g_serveCache.end() &&
+ existing->second.result.status == StateFetchStatus::Ok &&
+ result.state.cn < existing->second.cn &&
+ (NowMs() - existing->second.fetchedAtMs) < kConcurrentFetchWindowMs) {
+ return; // older out-of-order completion -- keep the fresher higher-CN entry
+ }
+ ServeCacheEntry e;
+ e.cn = result.state.cn;
+ e.fetchedAtMs = NowMs();
+ // Unknown own id (0) -> any session counts as foreign (conservative).
+ uint64_t own = g_ownClientId.load(std::memory_order_relaxed);
+ e.foreignSession = result.state.hasActiveSession() &&
+ result.state.session.clientId != own;
+ e.result = result;
+ g_serveCache[key] = std::move(e);
+}
+
void AppState_Init(ICloudProvider* provider) {
g_stateProvider = provider;
}
@@ -180,11 +337,14 @@ bool DeserializeState(const std::string& json, CloudAppState& outState) {
static constexpr const char* kStateFilename = "state.cloudredirect";
static constexpr size_t MAX_STATE_SIZE = 16 * 1024 * 1024; // 16 MB
-StateFetchResult FetchCloudState(uint32_t accountId, uint32_t appId) {
+// allowLegacyMigration=false reads canonical state without migration side effects;
+// used by PublishCloudState's CN-regression re-check to avoid recursive migration.
+static StateFetchResult FetchCloudStateLive(uint32_t accountId, uint32_t appId,
+ bool allowLegacyMigration = true) {
InflightSyncScope guard;
- if (!guard) return { StateFetchStatus::FetchFailed, {}, {} };
+ if (!guard) return { StateFetchStatus::FetchFailed, {} };
if (!g_stateProvider || !g_stateProvider->IsAuthenticated())
- return { StateFetchStatus::FetchFailed, {}, {} };
+ return { StateFetchStatus::FetchFailed, {} };
std::string statePath = CloudMetadataPath(accountId, appId, kStateFilename);
std::vector data;
@@ -192,23 +352,29 @@ StateFetchResult FetchCloudState(uint32_t accountId, uint32_t appId) {
if (data.size() > MAX_STATE_SIZE) {
LOG("[AppState] FetchCloudState app %u: state file too large (%zu bytes)",
appId, data.size());
- return { StateFetchStatus::ParseFailed, {}, {} };
+ return { StateFetchStatus::ParseFailed, {} };
}
std::string json(data.begin(), data.end());
CloudAppState state;
if (!DeserializeState(json, state)) {
LOG("[AppState] FetchCloudState app %u: parse failed", appId);
- return { StateFetchStatus::ParseFailed, {}, {} };
+ return { StateFetchStatus::ParseFailed, {} };
}
// cn>0 with empty files is valid (user deleted all saves).
// AutoCloudImport repopulates from disk if local files exist.
LOG("[AppState] FetchCloudState app %u: loaded state CN=%llu, %zu files",
appId, state.cn, state.files.size());
- return { StateFetchStatus::Ok, std::move(state), {} };
+ return { StateFetchStatus::Ok, std::move(state) };
}
auto existsStatus = g_stateProvider->CheckExists(statePath);
if (existsStatus == ICloudProvider::ExistsStatus::Missing) {
+ // No canonical state: nothing to migrate from when the caller forbids it
+ // (e.g. PublishCloudState's regression re-check). Absent state means there
+ // is no newer cloud CN to regress against, so report NotFound.
+ if (!allowLegacyMigration) {
+ return { StateFetchStatus::NotFound, {} };
+ }
auto legacyResult = FetchCloudManifest(accountId, appId);
uint64_t legacyCN = 0;
@@ -284,20 +450,134 @@ StateFetchResult FetchCloudState(uint32_t accountId, uint32_t appId) {
LOG("[AppState] FetchCloudState app %u: legacy files cleaned up", appId);
}
- return { StateFetchStatus::Ok, std::move(state), {} };
+ return { StateFetchStatus::Ok, std::move(state) };
}
LOG("[AppState] FetchCloudState app %u: no state file and no legacy data", appId);
- return { StateFetchStatus::NotFound, {}, {} };
+ return { StateFetchStatus::NotFound, {} };
}
LOG("[AppState] FetchCloudState app %u: download failed", appId);
- return { StateFetchStatus::FetchFailed, {}, {} };
+ return { StateFetchStatus::FetchFailed, {} };
+}
+
+// Public always-fresh fetch. Performs the live read AND refreshes the serve
+// cache so the serve path always sees CR's most recent observation.
+StateFetchResult FetchCloudState(uint32_t accountId, uint32_t appId) {
+ StateFetchResult result = FetchCloudStateLive(accountId, appId);
+ RefreshServeCache(accountId, appId, result);
+ return result;
+}
+
+// Bounded-fetch coordination. The changelist RPC runs sequentially per app on the
+// main-loop thread, so N timeouts would sum past the 15s watchdog -- the circuit
+// breaker serves local immediately after the first timeout. Per-app dedup + a worker
+// cap bound the thread count.
+// Leaked, never-destructed (same reason as g_serveCache*): the detached worker
+// re-locks g_boundedMtx on completion, possibly during static destruction.
+static std::mutex& g_boundedMtx = *new std::mutex();
+static std::unordered_set& g_boundedInflightKeys =
+ *new std::unordered_set(); // apps with a live worker
+static std::atomic g_boundedWorkerCount{0};
+static std::atomic g_providerSlowUntilMs{0}; // circuit-breaker deadline
+static std::atomic g_consecutiveTimeouts{0}; // reset on any successful fetch
+static constexpr int kMaxBoundedWorkers = 4;
+static constexpr int kCircuitCooldownMs = 8000; // serve-local window once circuit opens
+static constexpr int kCircuitTripThreshold = 2; // consecutive timeouts before opening
+
+StateFetchResult FetchCloudStateBounded(uint32_t accountId, uint32_t appId,
+ int deadlineMs) {
+ uint64_t key = ((uint64_t)accountId << 32) | appId;
+ bool spawn = true;
+ // Circuit open: provider recently timed out -> don't wait, serve local now.
+ if (NowMs() < g_providerSlowUntilMs.load(std::memory_order_relaxed)) {
+ return { StateFetchStatus::Timeout, {} };
+ }
+ {
+ std::lock_guard lk(g_boundedMtx);
+ // Coalesce duplicate fetches and cap total workers; either way, don't block.
+ if (g_boundedInflightKeys.count(key) ||
+ g_boundedWorkerCount.load(std::memory_order_relaxed) >= kMaxBoundedWorkers) {
+ spawn = false;
+ } else {
+ g_boundedInflightKeys.insert(key);
+ g_boundedWorkerCount.fetch_add(1, std::memory_order_relaxed);
+ }
+ }
+ if (!spawn) return { StateFetchStatus::Timeout, {} };
+
+ // Run the blocking live fetch on a detached worker; wait up to deadlineMs. The
+ // shared state outlives this call so a late completion still warms the serve
+ // cache for the next changelist -- it just doesn't hold Steam's thread.
+ auto promise = std::make_shared>();
+ auto future = promise->get_future();
+ std::thread([accountId, appId, key, promise]() {
+ // RAII so the inflight slot is always released even if the fetch throws
+ // (e.g. bad_alloc): otherwise the key/count leak wedges the worker cap, and
+ // an exception escaping a std::thread entry calls std::terminate.
+ struct SlotGuard {
+ uint64_t key;
+ ~SlotGuard() {
+ std::lock_guard lk(g_boundedMtx);
+ g_boundedInflightKeys.erase(key);
+ g_boundedWorkerCount.fetch_sub(1, std::memory_order_relaxed);
+ }
+ } slotGuard{key};
+ try {
+ StateFetchResult r = FetchCloudStateLive(accountId, appId);
+ RefreshServeCache(accountId, appId, r); // warm cache regardless of timeout
+ promise->set_value(std::move(r)); // ignored if caller abandoned
+ } catch (...) {
+ try { promise->set_value({ StateFetchStatus::FetchFailed, {} }); } catch (...) {}
+ }
+ }).detach();
+
+ if (future.wait_for(std::chrono::milliseconds(deadlineMs)) ==
+ std::future_status::ready) {
+ StateFetchResult r = future.get();
+ if (r.status == StateFetchStatus::Ok || r.status == StateFetchStatus::NotFound)
+ g_consecutiveTimeouts.store(0, std::memory_order_relaxed);
+ return r;
+ }
+ // Timed out. Open the circuit only after repeated timeouts so a single slow cold
+ // fetch doesn't blind the whole burst; one timeout is safe (caller serves local
+ // as a delta) but the background fetch still warms the cache for the next call.
+ if (g_consecutiveTimeouts.fetch_add(1, std::memory_order_relaxed) + 1 >= kCircuitTripThreshold) {
+ g_providerSlowUntilMs.store(NowMs() + kCircuitCooldownMs, std::memory_order_relaxed);
+ LOG("[AppState] FetchCloudStateBounded app %u: provider exceeded %dms (%d consecutive) "
+ "-- circuit open %dms, background fetch continues",
+ appId, deadlineMs, kCircuitTripThreshold, kCircuitCooldownMs);
+ } else {
+ LOG("[AppState] FetchCloudStateBounded app %u: provider exceeded %dms -- serving local "
+ "this call, circuit NOT yet open, background fetch continues", appId, deadlineMs);
+ }
+ return { StateFetchStatus::Timeout, {} };
+}
+
+// Serve-path accessor: reuse a recent snapshot when provably safe, else live.
+StateFetchResult FetchCloudStateForServe(uint32_t accountId, uint32_t appId) {
+ {
+ std::lock_guard lk(g_serveCacheMtx);
+ auto it = g_serveCache.find(ServeCacheKey(accountId, appId));
+ if (it != g_serveCache.end()) {
+ const ServeCacheEntry& e = it->second;
+ int64_t ageMs = NowMs() - e.fetchedAtMs;
+ // Reuse only when fresh and the snapshot had no foreign session;
+ // otherwise go live (under contention another machine may publish a
+ // newer state at any moment).
+ if (ageMs >= 0 && ageMs < kServeCacheMaxAgeMs && !e.foreignSession) {
+ LOG("[AppState] FetchCloudStateForServe app %u: cache hit (CN=%llu, age=%lldms)",
+ appId, (unsigned long long)e.cn, (long long)ageMs);
+ return e.result;
+ }
+ }
+ }
+ // Miss / stale / contention -> authoritative live fetch (also refreshes cache).
+ return FetchCloudState(accountId, appId);
}
bool PublishCloudState(uint32_t accountId, uint32_t appId,
- const CloudAppState& state,
- const std::string& /*etag*/) {
+ const CloudAppState& state, bool lockOnly) {
InflightSyncScope guard;
if (!guard) return false;
if (!g_stateProvider || !g_stateProvider->IsAuthenticated()) {
@@ -305,7 +585,42 @@ bool PublishCloudState(uint32_t accountId, uint32_t appId,
return false;
}
- std::string json = SerializeState(state);
+ // Heal or drop any manifest entry whose blob isn't durable on the provider, so
+ // we never publish a state pointing at blobs that 404 elsewhere. lockOnly skips
+ // it: a session-release publish reuses the manifest CompleteBatch just verified.
+ CloudAppState verified = state;
+ if (!lockOnly &&
+ !VerifyAndHealManifestForPublish(accountId, appId, verified)) {
+ LOG("[AppState] PublishCloudState app %u: cannot verify blobs, deferring publish", appId);
+ return false;
+ }
+
+ // Refuse to move the changenumber backward, like the real server. Re-fetch the
+ // cloud CN and reject a stale RMW (e.g. the session lock republish) that would
+ // clobber a newer CN another machine published in the window. Equal CN is fine.
+ {
+ auto current = FetchCloudStateLive(accountId, appId,
+ /*allowLegacyMigration=*/false);
+ if (current.status == StateFetchStatus::Ok && current.state.cn > verified.cn) {
+ LOG("[AppState] PublishCloudState app %u: REFUSED -- cloud CN=%llu is newer than "
+ "publish CN=%llu (would regress changelist); leaving cloud state intact",
+ appId, (unsigned long long)current.state.cn,
+ (unsigned long long)verified.cn);
+ return false;
+ }
+ // Fail-closed: an inconclusive re-fetch can't prove we won't regress a newer
+ // cloud CN. NotFound is genuinely empty (fresh app) so publishing is safe;
+ // Timeout/FetchFailed/ParseFailed are not -- refuse so the caller retries.
+ if (current.status != StateFetchStatus::Ok &&
+ current.status != StateFetchStatus::NotFound) {
+ LOG("[AppState] PublishCloudState app %u: REFUSED -- cannot verify cloud CN "
+ "(status=%d); deferring publish to avoid a blind regression",
+ appId, static_cast(current.status));
+ return false;
+ }
+ }
+
+ std::string json = SerializeState(verified);
std::string statePath = CloudMetadataPath(accountId, appId, kStateFilename);
if (!g_stateProvider->Upload(statePath,
@@ -323,12 +638,21 @@ bool PublishCloudState(uint32_t accountId, uint32_t appId,
reinterpret_cast(cnStr.data()), cnStr.size());
}
+ // A local mutation just landed: the serve cache's snapshot is now stale.
+ // Drop it so the next serve read re-fetches (and re-warms with the new cn).
+ InvalidateServeCache(accountId, appId);
+
LOG("[AppState] PublishCloudState app %u: published CN=%llu, %zu files",
- appId, state.cn, state.files.size());
+ appId, verified.cn, verified.files.size());
return true;
}
void ReleaseCloudSession(uint32_t accountId, uint32_t appId, uint64_t clientId) {
+ // Wait for any deferred CompleteBatch publish to land before releasing the
+ // session. This ensures the cloud state is durable (at the batch CN) before
+ // another machine can acquire and fetch it.
+ WaitForPendingPublish(accountId, appId);
+
// Sync mutex: serialize state RMW to prevent interleaved publishes.
auto syncMtx = AcquireAppSyncMutex(accountId, appId);
std::lock_guard syncLock(*syncMtx);
@@ -338,25 +662,11 @@ void ReleaseCloudSession(uint32_t accountId, uint32_t appId, uint64_t clientId)
auto& state = result.state;
if (state.session.clientId == clientId || clientId == 0) {
- // Reconcile stale file list from local manifest if previous publish failed.
- uint64_t localCN = LocalStorage::GetChangeNumber(accountId, appId);
- if (localCN > state.cn) {
- LOG("[AppState] ReleaseCloudSession app %u: local CN %llu > cloud CN %llu, reconciling file list",
- appId, (unsigned long long)localCN, (unsigned long long)state.cn);
- state.files.clear();
- auto localManifest = LoadLocalManifest(accountId, appId);
- for (const auto& [name, me] : localManifest) {
- FileEntry fe;
- fe.sha = me.sha;
- fe.timestamp = me.timestamp;
- fe.size = me.size;
- state.files[name] = std::move(fe);
- }
- state.cn = localCN;
- }
-
+ // Only release the lock; the file list and CN were already committed by the
+ // upload batch. Don't rebuild the manifest from local blobs here -- that
+ // advertises files before their blobs are durably uploaded.
state.session = {};
- if (!PublishCloudState(accountId, appId, state, result.etag)) {
+ if (!PublishCloudState(accountId, appId, state, /*lockOnly=*/true)) {
LOG("[AppState] ReleaseCloudSession app %u: publish failed (best-effort)", appId);
}
LOG("[AppState] ReleaseCloudSession app %u: session cleared (client=%llu)",
diff --git a/src/common/app_state.h b/src/common/app_state.h
index ad89d205..46944df7 100644
--- a/src/common/app_state.h
+++ b/src/common/app_state.h
@@ -3,6 +3,7 @@
#include "cloud_provider.h"
#include
+#include
#include
#include
#include
@@ -60,31 +61,65 @@ enum class StateFetchStatus {
NotFound, // State file does not exist on provider (new app or pre-migration)
FetchFailed,
ParseFailed,
+ Timeout, // Bounded fetch exceeded its deadline (provider slow); caller should
+ // serve local/last-known state and let the background fetch finish.
};
struct StateFetchResult {
StateFetchStatus status = StateFetchStatus::FetchFailed;
CloudAppState state;
- std::string etag; // For conditional writes (OneDrive)
};
void AppState_Init(ICloudProvider* provider);
void AppState_Shutdown();
// Handles migration from old cn.cloudredirect + manifest.cloudredirect.
+// Always performs a live provider fetch. Read-modify-write callers (any fetch
+// that precedes a PublishCloudState) must use this, not the cached serve
+// accessor, to avoid clobbering a concurrent cross-machine update with a stale base.
StateFetchResult FetchCloudState(uint32_t accountId, uint32_t appId);
-// If etag is non-empty, uses conditional write (OneDrive).
+// Time-bounded live fetch for the serve path (runs on Steam's main-loop thread,
+// where a slow download stalls BMainLoop). Runs the fetch on a worker, waits up to
+// deadlineMs, and on timeout returns Timeout -- the still-running fetch warms the
+// serve cache for next time, matching native's non-blocking yielding job.
+StateFetchResult FetchCloudStateBounded(uint32_t accountId, uint32_t appId,
+ int deadlineMs);
+
+// Cache-aware accessor for the serve path only. Returns a recently-fetched state
+// without re-hitting the provider, only when provably safe:
+// - cached entry younger than the hard max-age, AND
+// - no active foreign session in that snapshot.
+// Otherwise delegates to FetchCloudState (live). Invalidated by every local
+// mutation. Not for read-modify-write callers.
+StateFetchResult FetchCloudStateForServe(uint32_t accountId, uint32_t appId);
+
+// Report this client's own Steam id so the serve cache treats only foreign-client
+// sessions as contention. See g_ownClientId in app_state.cpp.
+void NoteOwnClientId(uint64_t clientId);
+
+// Publishes the app's cloud state. Refuses to regress the changenumber (re-fetches
+// and rejects if the provider already holds a newer CN) -- the only guard against
+// a stale RMW on providers with no conditional-write primitive.
+// lockOnly skips the blob verify/heal pass; use it only on the session-release
+// publish, where the manifest and CN were just committed by the upload batch.
bool PublishCloudState(uint32_t accountId, uint32_t appId,
- const CloudAppState& state,
- const std::string& etag = "");
+ const CloudAppState& state, bool lockOnly = false);
std::string SerializeState(const CloudAppState& state);
bool DeserializeState(const std::string& json, CloudAppState& outState);
// Release the session lock in the cloud state (called on ExitSyncDone).
+// Blocks until any pending async publish completes before releasing.
void ReleaseCloudSession(uint32_t accountId, uint32_t appId, uint64_t clientId);
+// Pending publish barrier: CompleteBatch defers cloud publish to a background
+// thread and registers a future here. ReleaseCloudSession and BeginBatch's
+// FetchCloudStateForServe wait on it to ensure cross-machine consistency.
+void SetPendingPublish(uint32_t accountId, uint32_t appId,
+ std::shared_future fut);
+void WaitForPendingPublish(uint32_t accountId, uint32_t appId);
+
CloudAppState MigrateFromLegacy(uint64_t cn,
const std::unordered_map& legacyFiles);
diff --git a/src/common/autocloud_bootstrap.cpp b/src/common/autocloud_bootstrap.cpp
deleted file mode 100644
index ebf170b1..00000000
--- a/src/common/autocloud_bootstrap.cpp
+++ /dev/null
@@ -1,862 +0,0 @@
-#include "autocloud_bootstrap.h"
-#include "autocloud_scan.h"
-#include "app_state.h"
-#include "batch_tracker.h"
-#include "cloud_intercept.h"
-#include "cloud_storage.h"
-#include "cloud_work_queue.h"
-#include "file_util.h"
-#include "local_storage.h"
-#include "log.h"
-#include "pending_ops_journal.h"
-
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-namespace AutoCloudBootstrap {
-
-// Internal state
-
-// g_importMutex > g_tokenCacheMutex > g_bootstrapMutex. Network/disk I/O runs unlocked.
-static std::mutex g_tokenCacheMutex;
-static std::unordered_map> g_canonicalTokenCache;
-static std::unordered_map g_canonicalTokenGeneration;
-
-static std::mutex g_importMutex;
-
-static std::mutex g_bootstrapMutex;
-static std::condition_variable g_bootstrapCV;
-static std::unordered_set g_attemptedApps;
-static std::unordered_set g_activeApps;
-static std::vector> g_futures;
-static bool g_shuttingDown = false;
-// Live orchestrator frames; shutdown waits on this + active.empty().
-static int g_orchestratorCount = 0;
-// Cap concurrent bootstrap workers to avoid OOM on large libraries.
-static constexpr int kMaxConcurrentBootstraps = 8;
-static std::atomic g_activeWorkerCount{0};
-
-
-// Helpers
-
-static uint64_t MakeAppKey(uint32_t accountId, uint32_t appId) {
- return CloudIntercept::MakeAppAccountKey(accountId, appId);
-}
-
-static bool LooksLikeForeignAppPollution(const std::string& filename, uint32_t appId) {
- size_t pos = filename.find_first_of("/\\");
- if (pos != std::string::npos && pos >= 3 && pos <= 10) {
- const std::string prefix = filename.substr(0, pos);
- if (std::all_of(prefix.begin(), prefix.end(), [](unsigned char c) { return c >= '0' && c <= '9'; })) {
- try {
- unsigned long parsed = std::stoul(prefix);
- if (parsed > 0xFFFFFFFFUL) return false; // Not a valid app ID
- uint32_t embeddedAppId = static_cast(parsed);
- if (embeddedAppId != 0 && embeddedAppId != appId) return true;
- } catch (...) {}
- }
- }
-
- size_t underscore = filename.find("_%");
- if (underscore != std::string::npos && underscore >= 3 && underscore <= 10) {
- const std::string prefix = filename.substr(0, underscore);
- if (std::all_of(prefix.begin(), prefix.end(), [](unsigned char c) { return c >= '0' && c <= '9'; })) {
- try {
- unsigned long parsed = std::stoul(prefix);
- if (parsed > 0xFFFFFFFFUL) return false; // Not a valid app ID
- uint32_t embeddedAppId = static_cast(parsed);
- if (embeddedAppId != 0 && embeddedAppId != appId) return true;
- } catch (...) {}
- }
- }
-
- return false;
-}
-
-static std::vector ReadWholeFile(const std::string& path, bool& ok) {
- ok = false;
- std::ifstream f(FileUtil::Utf8ToPath(path), std::ios::binary | std::ios::ate);
- if (!f) return {};
- auto size = f.tellg();
- if (size < 0) return {};
- if (!f.seekg(0, std::ios::beg)) return {};
- std::vector data(static_cast(size));
- if (!data.empty() && !f.read(reinterpret_cast(data.data()), size)) return {};
- ok = true;
- return data;
-}
-
-// Token cache management
-
-static void CacheCanonicalTokens(uint32_t accountId, uint32_t appId,
- const std::vector& candidates,
- uint64_t generation) {
- std::unordered_map tokens;
- for (const auto& fe : candidates) {
- if (!fe.relativePath.empty()) tokens.emplace(fe.relativePath, fe.rootToken);
- }
- std::lock_guard lock(g_tokenCacheMutex);
- uint64_t key = MakeAppKey(accountId, appId);
- if (g_canonicalTokenGeneration[key] != generation) return;
- g_canonicalTokenCache[key] = std::move(tokens);
-}
-
-static void ClearCanonicalTokens(uint32_t accountId, uint32_t appId, uint64_t generation) {
- std::lock_guard lock(g_tokenCacheMutex);
- uint64_t key = MakeAppKey(accountId, appId);
- if (g_canonicalTokenGeneration[key] != generation) return;
- g_canonicalTokenCache.erase(key);
-}
-
-static uint64_t GetTokenGeneration(uint32_t accountId, uint32_t appId) {
- std::lock_guard lock(g_tokenCacheMutex);
- return g_canonicalTokenGeneration[MakeAppKey(accountId, appId)];
-}
-
-// Bootstrap lifecycle
-
-static bool TryBeginBootstrap(uint32_t accountId, uint32_t appId) {
- uint64_t appKey = MakeAppKey(accountId, appId);
- std::lock_guard lock(g_bootstrapMutex);
-
- // Prune completed futures
- for (auto it = g_futures.begin(); it != g_futures.end(); ) {
- if (it->wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
- try { it->get(); } catch (...) {}
- it = g_futures.erase(it);
- } else {
- ++it;
- }
- }
-
- if (g_shuttingDown || g_attemptedApps.count(appKey) || g_activeApps.count(appKey)) {
- return false;
- }
- g_activeApps.insert(appKey);
- return true;
-}
-
-static void FinishBootstrap(uint32_t accountId, uint32_t appId,
- bool markAttempted, uint64_t /*generation*/) {
- uint64_t appKey = MakeAppKey(accountId, appId);
- std::lock_guard lock(g_bootstrapMutex);
- g_activeApps.erase(appKey);
- // Mark unconditionally to prevent re-import loops.
- if (markAttempted) g_attemptedApps.insert(appKey);
- g_bootstrapCV.notify_all();
-}
-
-static void WaitForBootstrapInternal(uint32_t accountId, uint32_t appId) {
- uint64_t appKey = MakeAppKey(accountId, appId);
- std::unique_lock lock(g_bootstrapMutex);
- g_bootstrapCV.wait(lock, [&] {
- return !g_activeApps.count(appKey);
- });
-}
-
-static bool IsBootstrapActiveInternal(uint32_t accountId, uint32_t appId) {
- std::lock_guard lock(g_bootstrapMutex);
- return g_activeApps.count(MakeAppKey(accountId, appId)) != 0;
-}
-
-static bool IsShuttingDownInternal() {
- std::lock_guard lock(g_bootstrapMutex);
- return g_shuttingDown;
-}
-
-// Worker implementation
-
-static void BootstrapWorker(uint32_t accountId, uint32_t appId, uint64_t cacheGeneration) {
- struct FinishGuard {
- uint32_t accountId;
- uint32_t appId;
- uint64_t generation;
- bool markAttempted;
- bool fired;
- ~FinishGuard() {
- if (!fired) {
- LOG("[AutoCloudImport] Worker aborted via exception for app %u -- releasing bootstrap slot", appId);
- FinishBootstrap(accountId, appId, markAttempted, generation);
- }
- }
- };
- FinishGuard guard{accountId, appId, cacheGeneration, /*markAttempted=*/false, /*fired=*/false};
- auto finish = [&](bool markAttempted, uint64_t generation) {
- guard.fired = true;
- FinishBootstrap(accountId, appId, markAttempted, generation);
- };
-
- if (IsShuttingDownInternal()) {
- LOG("[AutoCloudImport] Aborting bootstrap for app %u -- shutdown in progress", appId);
- ClearCanonicalTokens(accountId, appId, cacheGeneration);
- finish(false, cacheGeneration);
- return;
- }
-
- // Defer import while upload is pending (blobs may not be on provider).
- if (PendingOpsJournal::HasPendingUpload(accountId, appId)) {
- LOG("[AutoCloudImport] Pending upload exists for app %u; deferring import", appId);
- finish(false, cacheGeneration);
- return;
- }
-
- AutoCloudScan::ScanResult scan;
- try {
- scan = AutoCloudScan::GetFileList(CloudIntercept::GetSteamPath(), accountId, appId);
- } catch (const std::exception& ex) {
- LOG("[AutoCloudImport] Scan failed for app %u: %s", appId, ex.what());
- ClearCanonicalTokens(accountId, appId, cacheGeneration);
- finish(false, cacheGeneration);
- return;
- } catch (...) {
- LOG("[AutoCloudImport] Scan failed for app %u", appId);
- ClearCanonicalTokens(accountId, appId, cacheGeneration);
- finish(false, cacheGeneration);
- return;
- }
-
- if (scan.hasRootCollision) {
- LOG("[AutoCloudImport] Root collision detected for app %u; aborting bootstrap", appId);
- ClearCanonicalTokens(accountId, appId, cacheGeneration);
- finish(true, cacheGeneration);
- return;
- }
- // Reject incomplete scans (resource cap or root collision).
- if (!scan.complete) {
- LOG("[AutoCloudImport] Scan limit hit for app %u (%zu files observed); "
- "refusing partial import, preserving canonical token cache",
- appId, scan.files.size());
- finish(false, cacheGeneration);
- return;
- }
-
- std::vector& candidates = scan.files;
- if (candidates.empty()) {
- ClearCanonicalTokens(accountId, appId, cacheGeneration);
- finish(true, cacheGeneration);
- return;
- }
-
- // Check for obvious pollution (files from other apps)
- size_t definitePollution = 0;
- for (const auto& fe : candidates) {
- if (LooksLikeForeignAppPollution(fe.relativePath, appId)) {
- LOG("[AutoCloudImport] Definite pollution candidate for app %u: %s", appId, fe.relativePath.c_str());
- ++definitePollution;
- }
- }
- if (definitePollution > 0) {
- LOG("[AutoCloudImport] Aborting import for app %u: %zu obvious pollution file(s) detected",
- appId, definitePollution);
- ClearCanonicalTokens(accountId, appId, cacheGeneration);
- finish(true, cacheGeneration);
- return;
- }
-
- // Build map of existing cached files
- std::unordered_map existing;
- for (const auto& fe : LocalStorage::GetFileList(accountId, appId)) {
- existing[fe.filename] = fe;
- }
-
- auto fileTokens = CloudStorage::LoadFileTokens(accountId, appId);
- auto rootTokens = CloudStorage::LoadRootTokens(accountId, appId);
- std::unordered_set remoteBlobNames;
- if (!CloudStorage::ListRemoteBlobNames(accountId, appId, remoteBlobNames)) {
- LOG("[AutoCloudImport] Aborting import for app %u: could not list remote blobs",
- appId);
- ClearCanonicalTokens(accountId, appId, cacheGeneration);
- finish(false, cacheGeneration);
- return;
- }
-
- struct PendingImport {
- std::string filename;
- std::string sourcePath;
- uint64_t timestamp = 0;
- std::string rootToken;
- bool refresh = false;
- std::vector expectedSha;
- // hasContent: scan retained these bytes, so commit stores them without a
- // re-read (distinguishes a captured zero-byte file from an unread one).
- bool hasContent = false;
- std::vector content;
- };
- std::vector pendingImports;
- bool tokenMetadataChanged = false;
-
- for (auto& fe : candidates) {
- if (IsShuttingDownInternal()) {
- LOG("[AutoCloudImport] Aborting existence checks for app %u -- shutdown in progress", appId);
- ClearCanonicalTokens(accountId, appId, cacheGeneration);
- finish(false, cacheGeneration);
- return;
- }
- if (fe.relativePath.empty() || fe.fullPath.empty()) continue;
- if (CloudIntercept::IsInternalMetadataFile(fe.relativePath)) continue;
-
- auto it = existing.find(fe.relativePath);
- bool isRefresh = false;
- if (it != existing.end()) {
- const bool contentMatches =
- (it->second.sha == fe.sha && it->second.rawSize == fe.size);
- if (!contentMatches) {
- // Prefer non-zero local file over zero-byte cloud stub.
- const bool cloudIsZeroByteStub = (it->second.rawSize == 0 && fe.size > 0);
- const bool diskIsNewer = (fe.modifiedTime > it->second.timestamp);
- const bool timestampsEqual = (fe.modifiedTime == it->second.timestamp);
-
- if (diskIsNewer || cloudIsZeroByteStub || timestampsEqual) {
- LOG("[AutoCloudImport] Refreshing app %u file %s: disk mtime %llu vs cached %llu (size %llu->%llu)%s%s",
- appId, fe.relativePath.c_str(),
- (unsigned long long)fe.modifiedTime,
- (unsigned long long)it->second.timestamp,
- (unsigned long long)it->second.rawSize,
- (unsigned long long)fe.size,
- cloudIsZeroByteStub ? " [cloud zero-byte stub]" : "",
- timestampsEqual ? " [equal timestamps, preferring disk]" : "");
- isRefresh = true;
- } else {
- LOG("[AutoCloudImport] Skipping existing app %u file %s: disk mtime %llu < cached %llu",
- appId, fe.relativePath.c_str(),
- (unsigned long long)fe.modifiedTime,
- (unsigned long long)it->second.timestamp);
- continue;
- }
- } else {
- // Identical content: reconcile root-token metadata only.
- auto existingToken = fileTokens.find(fe.relativePath);
- if (existingToken == fileTokens.end() || existingToken->second != fe.rootToken) {
- fileTokens[fe.relativePath] = fe.rootToken;
- tokenMetadataChanged = true;
- LOG("[AutoCloudImport] Canonical root token for app %u file %s: '%s'",
- appId, fe.relativePath.c_str(), fe.rootToken.c_str());
- }
- if (!fe.rootToken.empty() && !rootTokens.count(fe.rootToken)) {
- rootTokens.insert(fe.rootToken);
- tokenMetadataChanged = true;
- }
- continue;
- }
- }
-
- if (!isRefresh) {
- if (remoteBlobNames.count(fe.relativePath) > 0) {
- LOG("[AutoCloudImport] Skipping app %u file %s because blob already exists in cache/cloud",
- appId, fe.relativePath.c_str());
- continue;
- }
- }
-
- // Captured iff retained bytes equal the file size (covers zero-byte files).
- const bool captured = (fe.content.size() == fe.size);
- pendingImports.push_back({ fe.relativePath, fe.fullPath, fe.modifiedTime,
- fe.rootToken, isRefresh, fe.sha,
- captured, std::move(fe.content) });
- }
-
- if (pendingImports.empty() && !tokenMetadataChanged) {
- finish(true, cacheGeneration);
- return;
- }
-
- uint64_t publishGeneration = 0;
- size_t imported = 0;
- uint64_t cn = 0;
-
- // Import lock covers local writes only; network runs unlocked.
- std::unique_lock importLock(g_importMutex);
-
- // Bump generation atomically
- bool generationStale = false;
- {
- std::lock_guard lock(g_tokenCacheMutex);
- uint64_t key = MakeAppKey(accountId, appId);
- if (g_canonicalTokenGeneration[key] != cacheGeneration) {
- generationStale = true;
- } else {
- g_canonicalTokenCache.erase(key);
- publishGeneration = ++g_canonicalTokenGeneration[key];
- }
- }
- if (generationStale) {
- finish(false, cacheGeneration);
- return;
- }
-
- // Last abort point before StoreBlob writes.
- if (IsShuttingDownInternal()) {
- LOG("[AutoCloudImport] Aborting pre-commit for app %u -- shutdown in progress", appId);
- ClearCanonicalTokens(accountId, appId, publishGeneration);
- finish(false, publishGeneration);
- return;
- }
-
- for (auto& pending : pendingImports) {
- if (IsShuttingDownInternal()) {
- LOG("[AutoCloudImport] Aborting mid-import for app %u -- shutdown in progress", appId);
- break;
- }
- if (!pending.refresh && CloudStorage::HasLocalBlob(accountId, appId, pending.filename)) {
- LOG("[AutoCloudImport] Skipping app %u file %s because blob appeared before commit",
- appId, pending.filename.c_str());
- continue;
- }
-
- if (pending.expectedSha.empty()) {
- LOG("[AutoCloudImport] Skipping app %u file %s: no SHA from scan",
- appId, pending.filename.c_str());
- continue;
- }
-
- std::vector data;
- if (pending.hasContent) {
- // Use the bytes captured at scan time; matches expectedSha by construction.
- data = std::move(pending.content);
- } else {
- // Not retained: re-read and re-verify against the scan SHA.
- bool readOk = false;
- data = ReadWholeFile(pending.sourcePath, readOk);
- if (!readOk) {
- LOG("[AutoCloudImport] Failed to read source before commit for app %u: %s",
- appId, pending.sourcePath.c_str());
- continue;
- }
- const uint8_t* vptr = data.empty() ? nullptr : data.data();
- if (LocalStorage::SHA1(vptr, data.size()) != pending.expectedSha) {
- LOG("[AutoCloudImport] Skipping app %u file %s: source content changed between scan and commit",
- appId, pending.filename.c_str());
- continue;
- }
- }
-
- const uint8_t* ptr = data.empty() ? nullptr : data.data();
-
- if (!CloudStorage::StoreBlob(accountId, appId, pending.filename, ptr, data.size())) {
- LOG("[AutoCloudImport] Failed to cache app %u file %s", appId, pending.filename.c_str());
- continue;
- }
-
- // Restore original timestamp to cached file.
- LocalStorage::SetFileTimestamp(accountId, appId, pending.filename, pending.timestamp);
-
- // Update manifest with original file metadata from scan.
- if (!CloudStorage::UpdateManifestEntry(accountId, appId, pending.filename,
- pending.expectedSha, pending.timestamp, data.size())) {
- LOG("[AutoCloudImport] Manifest update FAILED for app %u file %s",
- appId, pending.filename.c_str());
- LocalStorage::DeleteFile(accountId, appId, pending.filename);
- continue;
- }
-
- auto existingToken = fileTokens.find(pending.filename);
- if (existingToken == fileTokens.end() || existingToken->second != pending.rootToken) {
- fileTokens[pending.filename] = pending.rootToken;
- tokenMetadataChanged = true;
- LOG("[AutoCloudImport] Canonical root token for app %u file %s: '%s'",
- appId, pending.filename.c_str(), pending.rootToken.c_str());
- }
- if (!pending.rootToken.empty() && !rootTokens.count(pending.rootToken)) {
- rootTokens.insert(pending.rootToken);
- tokenMetadataChanged = true;
- }
- ++imported;
- LOG("[AutoCloudImport] %s app %u file %s",
- pending.refresh ? "Refreshed" : "Imported",
- appId, pending.filename.c_str());
- }
-
- if (imported == 0 && !tokenMetadataChanged) {
- finish(true, publishGeneration);
- return;
- }
-
- if (!rootTokens.empty() && !CloudStorage::SaveRootTokens(accountId, appId, rootTokens)) {
- LOG("[AutoCloudImport] root_token.dat local persist FAILED app %u -- next restart will load stale set", appId);
- }
- if ((!fileTokens.empty() || tokenMetadataChanged) &&
- !CloudStorage::SaveFileTokens(accountId, appId, fileTokens)) {
- LOG("[AutoCloudImport] file_tokens.dat local persist FAILED app %u -- next restart will load stale set", appId);
- }
-
- importLock.unlock();
-
- if (GetTokenGeneration(accountId, appId) != publishGeneration) {
- finish(false, publishGeneration);
- return;
- }
-
- // Drain blob uploads before publishing state -- don't advertise un-uploaded blobs.
- if (!CloudWorkQueue::DrainQueueForApp(accountId, appId)) {
- LOG("[AutoCloudImport] Blob drain FAILED for app %u; aborting commit", appId);
- ClearCanonicalTokens(accountId, appId, publishGeneration);
- finish(false, publishGeneration);
- return;
- }
-
- // Abort if batch in progress -- CompleteBatch owns CN/state publish.
- if (CloudIntercept::BatchTracker_ActiveId(accountId, appId) != 0) {
- LOG("[AutoCloudImport] Active batch detected for app %u; deferring import", appId);
- ClearCanonicalTokens(accountId, appId, publishGeneration);
- finish(false, publishGeneration);
- return;
- }
-
- // Sync mutex: serialize CN increment + state publish.
- auto syncMtx = CloudStorage::AcquireAppSyncMutex(accountId, appId);
- std::lock_guard syncLock(*syncMtx);
-
- uint64_t oldCN = LocalStorage::GetChangeNumber(accountId, appId);
- cn = LocalStorage::IncrementChangeNumber(accountId, appId);
- if (cn <= oldCN) {
- LOG("[AutoCloudImport] CN increment FAILED for app %u; aborting commit", appId);
- ClearCanonicalTokens(accountId, appId, publishGeneration);
- finish(false, publishGeneration);
- return;
- }
- // Publish unified state (CN + manifest atomically)
- {
- CloudStorage::CloudAppState state;
- state.cn = cn;
- auto localManifest = CloudStorage::LoadLocalManifest(accountId, appId);
- for (const auto& [name, me] : localManifest) {
- CloudStorage::FileEntry fe;
- fe.sha = me.sha;
- fe.timestamp = me.timestamp;
- fe.size = me.size;
- state.files[name] = std::move(fe);
- }
- CloudStorage::PublishCloudState(accountId, appId, state);
- }
-
- // Re-check generation; concurrent invalidation bumps it.
- if (GetTokenGeneration(accountId, appId) != publishGeneration) {
- finish(false, publishGeneration);
- return;
- }
-
- CacheCanonicalTokens(accountId, appId, candidates, publishGeneration);
- LOG("[AutoCloudImport] Imported %zu AutoCloud file(s), updatedTokens=%u for app %u, CN=%llu",
- imported, tokenMetadataChanged ? 1 : 0, appId, cn);
- finish(true, publishGeneration);
-}
-
-// Public API
-
-void Bootstrap(uint32_t accountId, uint32_t appId, bool wait) {
- uint64_t cacheGeneration = GetTokenGeneration(accountId, appId);
- if (!TryBeginBootstrap(accountId, appId)) {
- if (wait) WaitForBootstrapInternal(accountId, appId);
- return;
- }
-
- if (wait) {
- BootstrapWorker(accountId, appId, cacheGeneration);
- return;
- }
-
- if (IsShuttingDownInternal()) {
- FinishBootstrap(accountId, appId, /*markAttempted=*/false, cacheGeneration);
- return;
- }
-
- // Claim slot before spawn so shutdown waits on post-spawn ops.
- {
- std::lock_guard lock(g_bootstrapMutex);
- ++g_orchestratorCount;
- }
- struct OrchestratorGuard {
- ~OrchestratorGuard() {
- std::lock_guard lock(g_bootstrapMutex);
- --g_orchestratorCount;
- g_bootstrapCV.notify_all();
- }
- } orchestratorGuard;
-
- // Cap concurrent bootstrap workers to avoid OOM on large libraries.
- if (g_activeWorkerCount.load(std::memory_order_relaxed) >= kMaxConcurrentBootstraps) {
- LOG("[AutoCloudImport] Bootstrap deferred for app %u: %d/%d workers active",
- appId, g_activeWorkerCount.load(), kMaxConcurrentBootstraps);
- FinishBootstrap(accountId, appId, /*markAttempted=*/false, cacheGeneration);
- return;
- }
-
- // Spawn unlocked; thread creation can block 100s of ms on Windows.
- std::future future;
- try {
- g_activeWorkerCount.fetch_add(1, std::memory_order_relaxed);
- future = std::async(std::launch::async, [accountId, appId, cacheGeneration]() {
- BootstrapWorker(accountId, appId, cacheGeneration);
- g_activeWorkerCount.fetch_sub(1, std::memory_order_relaxed);
- });
- } catch (...) {
- g_activeWorkerCount.fetch_sub(1, std::memory_order_relaxed);
- LOG("[AutoCloudImport] Failed to spawn bootstrap worker for app %u", appId);
- FinishBootstrap(accountId, appId, /*markAttempted=*/false, cacheGeneration);
- return;
- }
-
- // Stash for shutdown join.
- {
- std::unique_lock lock(g_bootstrapMutex);
- if (!g_shuttingDown) {
- g_futures.push_back(std::move(future));
- return;
- }
- }
- try {
- future.wait();
- } catch (...) {}
-}
-
-void WaitFor(uint32_t accountId, uint32_t appId) {
- WaitForBootstrapInternal(accountId, appId);
-}
-
-bool IsActive(uint32_t accountId, uint32_t appId) {
- return IsBootstrapActiveInternal(accountId, appId);
-}
-
-std::string CanonicalizeToken(uint32_t accountId, uint32_t appId,
- const std::string& cleanName,
- const std::string& fallbackToken) {
- if (cleanName.empty()) return fallbackToken;
-
- std::lock_guard lock(g_tokenCacheMutex);
- auto appIt = g_canonicalTokenCache.find(MakeAppKey(accountId, appId));
- if (appIt != g_canonicalTokenCache.end()) {
- auto tokenIt = appIt->second.find(cleanName);
- if (tokenIt != appIt->second.end()) {
- if (tokenIt->second != fallbackToken) {
- LOG("[NS-TOK] Canonicalized token for account %u app %u file %s: %s -> %s",
- accountId, appId, cleanName.c_str(), fallbackToken.c_str(), tokenIt->second.c_str());
- }
- return tokenIt->second;
- }
- }
- return fallbackToken;
-}
-
-uint64_t GetCacheGeneration(uint32_t accountId, uint32_t appId) {
- return GetTokenGeneration(accountId, appId);
-}
-
-std::unordered_map GetCachedTokens(uint32_t accountId, uint32_t appId) {
- std::lock_guard lock(g_tokenCacheMutex);
- auto it = g_canonicalTokenCache.find(MakeAppKey(accountId, appId));
- if (it != g_canonicalTokenCache.end()) {
- return it->second;
- }
- return {};
-}
-
-void InvalidateCache(uint32_t accountId, uint32_t appId) {
- std::lock_guard importLock(g_importMutex);
- uint64_t key = MakeAppKey(accountId, appId);
- {
- std::lock_guard lock(g_tokenCacheMutex);
- g_canonicalTokenCache.erase(key);
- ++g_canonicalTokenGeneration[key];
- }
- // Do NOT reset g_attemptedApps -- Steam imports once per process; resetting causes re-import loops.
-}
-
-void ResetAttempted(uint32_t accountId, uint32_t appId) {
- std::lock_guard lock(g_bootstrapMutex);
- g_attemptedApps.erase(MakeAppKey(accountId, appId));
-}
-
-int RestoreBlobsToGameFolder(uint32_t accountId, uint32_t appId,
- const std::string& steamPath,
- const std::unordered_map* cloudFiles) {
- auto fileTokens = CloudStorage::LoadFileTokens(accountId, appId);
- if (fileTokens.empty()) {
- LOG("[AutoCloudRestore] app %u: no file tokens, skipping restore", appId);
- return 0;
- }
-
- auto rootDirs = AutoCloudScan::GetRootTokenDirectories(steamPath, appId, accountId);
- if (rootDirs.empty()) {
- LOG("[AutoCloudRestore] app %u: no root directories resolved", appId);
- return 0;
- }
-
- auto files = LocalStorage::GetFileList(accountId, appId);
- if (files.empty()) {
- LOG("[AutoCloudRestore] app %u: no blobs in local storage", appId);
- return 0;
- }
-
- // Build list of files that need restoring (local checks only, no I/O).
- struct RestoreJob {
- std::string filename;
- std::string targetPath;
- uint64_t timestamp;
- uint64_t rawSize;
- std::string expectedShaHex; // from cloud state; empty if not available
- };
- std::vector jobs;
-
- for (const auto& fe : files) {
- if (fe.deleted) continue;
- if (CloudIntercept::IsInternalMetadataFile(fe.filename)) continue;
-
- auto tokenIt = fileTokens.find(fe.filename);
- if (tokenIt == fileTokens.end() || tokenIt->second.empty()) continue;
-
- auto dirIt = rootDirs.find(tokenIt->second);
- if (dirIt == rootDirs.end() || dirIt->second.empty()) {
- LOG("[AutoCloudRestore] app %u file %s: unknown root token '%s'",
- appId, fe.filename.c_str(), tokenIt->second.c_str());
- continue;
- }
-
- std::string targetPath = dirIt->second + fe.filename;
- for (auto& c : targetPath) {
- if (c == '/') {
-#ifdef _WIN32
- c = '\\';
-#endif
- }
- }
-
- if (!FileUtil::IsPathWithin(dirIt->second, targetPath)) {
- LOG("[AutoCloudRestore] app %u file %s: path traversal blocked (root=%s)",
- appId, fe.filename.c_str(), dirIt->second.c_str());
- continue;
- }
-
- std::error_code ec;
- auto targetFsPath = FileUtil::Utf8ToPath(targetPath);
- bool exists = std::filesystem::exists(targetFsPath, ec);
-
- if (exists && !ec) {
- auto diskTime = std::filesystem::last_write_time(targetFsPath, ec);
- if (!ec) {
- auto diskSeconds = AutoCloudUtil::FileTimeToUnixSeconds(diskTime);
- std::error_code sizeEc;
- auto diskSize = std::filesystem::file_size(targetFsPath, sizeEc);
- if (diskSeconds >= fe.timestamp && !sizeEc && diskSize > 0) {
- continue;
- }
- }
- }
-
- std::string shaHex;
- if (cloudFiles) {
- auto cit = cloudFiles->find(fe.filename);
- if (cit != cloudFiles->end() && !cit->second.sha.empty()) {
- const auto& sha = cit->second.sha;
- static const char kHex[] = "0123456789abcdef";
- shaHex.reserve(sha.size() * 2);
- for (uint8_t b : sha) {
- shaHex += kHex[b >> 4];
- shaHex += kHex[b & 0xf];
- }
- }
- }
- jobs.push_back({fe.filename, std::move(targetPath), fe.timestamp, fe.rawSize, std::move(shaHex)});
- }
-
- if (jobs.empty()) return 0;
-
- // Fetch blobs in parallel (up to 8 concurrent), then write sequentially.
- constexpr size_t kMaxParallel = 8;
- std::vector> blobResults(jobs.size());
- size_t totalJobs = jobs.size();
-
- for (size_t base = 0; base < totalJobs; base += kMaxParallel) {
- size_t batchEnd = (std::min)(base + kMaxParallel, totalJobs);
- std::vector>> futures;
- futures.reserve(batchEnd - base);
-
- for (size_t i = base; i < batchEnd; ++i) {
- uint32_t acct = accountId;
- uint32_t app = appId;
- const std::string& fname = jobs[i].filename;
- const std::string& fsha = jobs[i].expectedShaHex;
- futures.push_back(std::async(std::launch::async,
- [acct, app, &fname, &fsha]() {
- return CloudStorage::RetrieveBlob(acct, app, fname, nullptr, fsha);
- }));
- }
-
- for (size_t i = 0; i < futures.size(); ++i) {
- blobResults[base + i] = futures[i].get();
- }
- }
-
- // Write to disk sequentially (filesystem ops are fast, atomicity matters).
- int restored = 0;
- for (size_t i = 0; i < totalJobs; ++i) {
- auto& job = jobs[i];
- auto& blobData = blobResults[i];
-
- if (blobData.empty() && job.rawSize > 0) {
- LOG("[AutoCloudRestore] app %u file %s: blob unavailable (local+cloud)",
- appId, job.filename.c_str());
- continue;
- }
-
- auto targetFsPath = FileUtil::Utf8ToPath(job.targetPath);
- auto parentDir = targetFsPath.parent_path();
- if (!parentDir.empty()) {
- std::error_code ec;
- std::filesystem::create_directories(parentDir, ec);
- if (ec) {
- LOG("[AutoCloudRestore] app %u file %s: failed to create directory %s: %s",
- appId, job.filename.c_str(),
- FileUtil::PathToUtf8(parentDir).c_str(), ec.message().c_str());
- continue;
- }
- }
-
- if (!FileUtil::AtomicWriteBinary(job.targetPath, blobData.data(), blobData.size())) {
- LOG("[AutoCloudRestore] app %u file %s: failed to write: %s",
- appId, job.filename.c_str(), job.targetPath.c_str());
- continue;
- }
-
- if (job.timestamp > 0) {
- std::error_code ec;
- auto targetTime = AutoCloudUtil::UnixSecondsToFileTime(job.timestamp);
- std::filesystem::last_write_time(targetFsPath, targetTime, ec);
- }
-
- restored++;
- LOG("[AutoCloudRestore] app %u: restored %s -> %s (%zu bytes, ts=%llu)",
- appId, job.filename.c_str(), job.targetPath.c_str(),
- blobData.size(), (unsigned long long)job.timestamp);
- }
-
- if (restored > 0) {
- LOG("[AutoCloudRestore] app %u: restored %d file(s) to game folder", appId, restored);
- }
- return restored;
-}
-
-void Shutdown() {
- std::vector> futures;
- {
- std::lock_guard lock(g_bootstrapMutex);
- g_shuttingDown = true;
- futures.swap(g_futures);
- }
- for (auto& future : futures) {
- try { future.get(); } catch (...) {}
- }
- std::unique_lock lock(g_bootstrapMutex);
- g_bootstrapCV.wait(lock, [] {
- return g_activeApps.empty() && g_orchestratorCount == 0;
- });
-}
-
-} // namespace AutoCloudBootstrap
diff --git a/src/common/autocloud_bootstrap.h b/src/common/autocloud_bootstrap.h
deleted file mode 100644
index 4d2c184a..00000000
--- a/src/common/autocloud_bootstrap.h
+++ /dev/null
@@ -1,36 +0,0 @@
-#pragma once
-// AutoCloud bootstrap - imports pre-existing save files into cloud storage
-// on first launch per app.
-
-#include "app_state.h"
-#include
-#include
-#include
-
-namespace AutoCloudBootstrap {
-
-void Bootstrap(uint32_t accountId, uint32_t appId, bool wait = false);
-
-void WaitFor(uint32_t accountId, uint32_t appId);
-
-bool IsActive(uint32_t accountId, uint32_t appId);
-
-std::string CanonicalizeToken(uint32_t accountId, uint32_t appId,
- const std::string& cleanName,
- const std::string& fallbackToken);
-
-uint64_t GetCacheGeneration(uint32_t accountId, uint32_t appId);
-
-std::unordered_map GetCachedTokens(uint32_t accountId, uint32_t appId);
-
-void InvalidateCache(uint32_t accountId, uint32_t appId);
-
-void ResetAttempted(uint32_t accountId, uint32_t appId);
-
-int RestoreBlobsToGameFolder(uint32_t accountId, uint32_t appId,
- const std::string& steamPath,
- const std::unordered_map* cloudFiles = nullptr);
-
-void Shutdown();
-
-} // namespace AutoCloudBootstrap
diff --git a/src/common/autocloud_scan.cpp b/src/common/autocloud_scan.cpp
index 0354f3f8..65499d53 100644
--- a/src/common/autocloud_scan.cpp
+++ b/src/common/autocloud_scan.cpp
@@ -487,6 +487,9 @@ static std::vector LoadAutoCloudRules(const std::string& st
if (!overrideRule.root.empty() &&
(!overrideRule.useInstead.empty() || !overrideRule.addPath.empty() ||
!overrideRule.pathTransforms.empty())) {
+ LOG("GetAutoCloudFileList: app %u parsed override root='%s' os='%s' oscompare='%s' useinstead='%s'",
+ appId, overrideRule.root.c_str(), overrideRule.os.c_str(),
+ overrideRule.osCompare.c_str(), overrideRule.useInstead.c_str());
overrides.push_back(std::move(overrideRule));
}
}
@@ -576,8 +579,8 @@ static std::vector LoadAutoCloudRules(const std::string& st
// SHA1 for files
-// Read a whole file and compute its SHA1 in one pass; returns the bytes in
-// outBytes so the caller can avoid a second read at commit time.
+// Read a whole file and SHA1 it in one pass, returning the bytes in outBytes so
+// the caller can reuse them without a second read. Returns empty on error.
static std::vector ReadAndHashFile(const std::string& path,
std::vector& outBytes) {
outBytes.clear();
@@ -632,6 +635,16 @@ ScanResult GetFileList(const std::string& steamPath,
}
outResult.hasRules = true;
+ // Final rule set after overrides. Two rules with the same root/path here were
+ // not collapsed by the override.
+ LOG("GetAutoCloudFileList: app %u final rule set (%zu rules) after overrides:", appId, rules.size());
+ for (size_t i = 0; i < rules.size(); ++i) {
+ const auto& r = rules[i];
+ LOG(" rule[%zu]: root='%s' cloudRoot='%s' path='%s' resolvedPath='%s' pattern='%s' recursive=%u platforms=0x%X siblings=%zu",
+ i, r.root.c_str(), r.cloudRoot.c_str(), r.path.c_str(), r.resolvedPath.c_str(),
+ r.pattern.c_str(), r.recursive ? 1u : 0u, r.platforms, r.siblings.size());
+ }
+
std::filesystem::path appUserdataDir = FileUtil::Utf8ToPath(steamPath) / "userdata" /
std::to_string(accountId) / std::to_string(appId);
@@ -651,7 +664,7 @@ ScanResult GetFileList(const std::string& steamPath,
uint64_t rawSize = (uint64_t)fileEntry.file_size(ec);
if (ec) return;
- std::vector bytes;
+ std::vector bytes; // read once for SHA; not retained on the entry
auto sha = ReadAndHashFile(FileUtil::PathToUtf8(fileEntry.path()), bytes);
if (sha.empty()) {
LOG("GetAutoCloudFileList: skipping app %u file %s (SHA1 read error)",
@@ -871,6 +884,7 @@ ScanResult GetFileList(const std::string& steamPath,
std::unordered_map seenRootsByCloudPath;
// Sibling dedupe; separate from primary so siblings can't trip the abort.
std::unordered_set emittedSiblings;
+
bool hasRootCollision = false;
bool scanLimitHit = false;
size_t visitedFiles = 0;
@@ -1389,4 +1403,15 @@ std::unordered_map GetRootTokenDirectories(
return result;
}
+std::string GetAppName(const std::string& steamPath, uint32_t appId) {
+ return GetAppNameFromAppInfo(steamPath, appId);
+}
+
+#ifdef CLOUDREDIRECT_TESTING
+std::vector TestReadAndHashFile(const std::string& path,
+ std::vector& outBytes) {
+ return ReadAndHashFile(path, outBytes);
+}
+#endif
+
} // namespace AutoCloudScan
diff --git a/src/common/autocloud_scan.h b/src/common/autocloud_scan.h
index 43cb0e5a..a6cf29a2 100644
--- a/src/common/autocloud_scan.h
+++ b/src/common/autocloud_scan.h
@@ -29,6 +29,12 @@ struct ScanResult {
bool complete = false; // true if scan completed without truncation or collision
bool hasRules = false; // true if app has AutoCloud rules in appinfo.vdf
bool hasRootCollision = false; // true if two rules resolved to same path under different roots
+
+ // `files` is the unique set, cross-rule deduped by cloud-relative path. Steam's
+ // exit walk logs "Persisting" per matching rule but dedups by cloud path at the
+ // remotecache layer ("Skipping un-modified file"), so this is one entry per
+ // distinct cloud path. (Note: native's over-quota eviction does count per rule-
+ // instance against maxnumfiles -- see ApplyNativeOverQuotaEviction.)
};
// Scan AutoCloud rules for an app and return matching files from disk.
@@ -52,4 +58,16 @@ std::vector GetRootOverrides(
std::unordered_map GetRootTokenDirectories(
const std::string& steamPath, uint32_t appId, uint32_t accountId = 0);
+// Look up a game's display name from Steam's appinfo.vdf cache.
+// Returns empty string if not found.
+std::string GetAppName(const std::string& steamPath, uint32_t appId);
+
+#ifdef CLOUDREDIRECT_TESTING
+// Test seam: read a file once, returning its SHA1 and (in outBytes) the exact
+// bytes read. Underpins the scan->commit race fix (bytes are captured during
+// hashing so the commit never re-reads). Returns empty SHA on error.
+std::vector TestReadAndHashFile(const std::string& path,
+ std::vector& outBytes);
+#endif
+
} // namespace AutoCloudScan
diff --git a/src/common/autocloud_util.h b/src/common/autocloud_util.h
index 5501309d..26ff0dc5 100644
--- a/src/common/autocloud_util.h
+++ b/src/common/autocloud_util.h
@@ -10,6 +10,7 @@
#include
#include
#include
+#include
#include
#ifdef _WIN32
@@ -34,7 +35,7 @@ static constexpr uintmax_t kMaxAppInfoBytes = 512ULL * 1024 * 1024;
static constexpr uint32_t kMaxAppInfoStrings = 200000;
static constexpr size_t kMaxAutoCloudScanFiles = 20000;
static constexpr int kMaxAutoCloudScanMillis = 5000;
-// No per-file size cap; imports are bounded by the app's UFS quota.
+// No per-file size cap; storage is bounded by the app's cloud quota.
// Wildcard matching caps against exponential backtracking.
static constexpr size_t kMaxWildcardPatternLen = 1024;
@@ -91,20 +92,34 @@ inline bool IsSafeRelativePath(const std::string& path) {
// Filesystem time conversion
+// Convert a filesystem mtime to whole Unix seconds, rounded to nearest. Flooring
+// recorded a time_stamp one second below the mtime Steam reads off the same file,
+// so the sync-state eval saw the local copy as newer and showed a wrong arrow.
inline uint64_t FileTimeToUnixSeconds(std::filesystem::file_time_type ftime) {
+#if defined(__cpp_lib_chrono) && __cpp_lib_chrono >= 201907L
+ auto sysTime = std::chrono::clock_cast(ftime);
+#else
+ // Pre-C++20: paired clock reads back-to-back to keep epoch jitter sub-second.
auto fileNow = std::filesystem::file_time_type::clock::now();
- auto sysNow = std::chrono::system_clock::now();
- auto sctp = std::chrono::time_point_cast(
- ftime - fileNow + sysNow
- );
- return (uint64_t)sctp.time_since_epoch().count();
+ auto sysNow = std::chrono::system_clock::now();
+ auto sysTime = sysNow + std::chrono::duration_cast(
+ ftime - fileNow);
+#endif
+ auto secs = std::chrono::duration_cast(
+ sysTime.time_since_epoch()).count();
+ return (uint64_t)((secs + 500) / 1000);
}
inline std::filesystem::file_time_type UnixSecondsToFileTime(uint64_t unixSeconds) {
auto sysTime = std::chrono::system_clock::from_time_t((time_t)unixSeconds);
- auto sysNow = std::chrono::system_clock::now();
+#if defined(__cpp_lib_chrono) && __cpp_lib_chrono >= 201907L
+ return std::chrono::clock_cast(sysTime);
+#else
+ auto sysNow = std::chrono::system_clock::now();
auto fileNow = std::filesystem::file_time_type::clock::now();
- return fileNow + (sysTime - sysNow);
+ return fileNow + std::chrono::duration_cast(
+ sysTime - sysNow);
+#endif
}
// Platform-specific path resolution
@@ -377,8 +392,13 @@ inline void ApplyRootOverridesForPlatform(AutoCloudRuleNative& rule,
const std::vector& overrides,
AutoCloudEffectivePlatform platform) {
for (const auto& overrideRule : overrides) {
- if (!IsRootOverrideActiveForPlatform(overrideRule, platform)) continue;
- if (_stricmp(rule.root.c_str(), overrideRule.root.c_str()) != 0) continue;
+ bool active = IsRootOverrideActiveForPlatform(overrideRule, platform);
+ bool rootMatch = _stricmp(rule.root.c_str(), overrideRule.root.c_str()) == 0;
+ LOG("ApplyRootOverride: rule.root='%s' vs override.root='%s' os='%s' active=%d rootMatch=%d -> useinstead='%s'",
+ rule.root.c_str(), overrideRule.root.c_str(), overrideRule.os.c_str(),
+ active ? 1 : 0, rootMatch ? 1 : 0, overrideRule.useInstead.c_str());
+ if (!active) continue;
+ if (!rootMatch) continue;
if (!overrideRule.useInstead.empty()) {
rule.root = overrideRule.useInstead;
diff --git a/src/common/batch_tracker.cpp b/src/common/batch_tracker.cpp
index cad67a83..91c7dde3 100644
--- a/src/common/batch_tracker.cpp
+++ b/src/common/batch_tracker.cpp
@@ -39,13 +39,16 @@ void BatchTracker_Begin(uint32_t accountId, uint32_t appId, uint64_t batchId, ui
}
void BatchTracker_RecordUpload(uint32_t accountId, uint32_t appId,
- const std::string& filename) {
+ const std::string& filename,
+ const std::vector& sha,
+ uint64_t size, uint64_t timestamp) {
uint64_t key = MakeAppAccountKey(accountId, appId);
std::lock_guard lock(g_uploadBatchMutex);
auto it = g_activeUploadBatches.find(key);
if (it == g_activeUploadBatches.end()) return;
it->second.deletes.erase(filename);
it->second.uploads.insert(filename);
+ it->second.uploadMeta[filename] = { sha, size, timestamp };
}
void BatchTracker_RecordDelete(uint32_t accountId, uint32_t appId,
diff --git a/src/common/batch_tracker.h b/src/common/batch_tracker.h
index 4821c538..8f24afe7 100644
--- a/src/common/batch_tracker.h
+++ b/src/common/batch_tracker.h
@@ -3,6 +3,7 @@
#include
#include
#include
+#include
#include
#include
@@ -10,6 +11,14 @@ namespace CloudIntercept {
uint64_t MakeAppAccountKey(uint32_t accountId, uint32_t appId);
+// SHA/size/timestamp captured from the exact bytes received at CommitFileUpload,
+// so CompleteBatch publishes what was uploaded rather than re-stat'ing disk.
+struct UploadFileMeta {
+ std::vector sha; // SHA-1 over the uploaded bytes
+ uint64_t size = 0;
+ uint64_t timestamp = 0;
+};
+
struct UploadBatchState {
uint64_t batchId = 0;
uint64_t assignedCN = 0; // CN assigned by BeginAppUploadBatch (= currentCN + 1)
@@ -17,6 +26,7 @@ struct UploadBatchState {
std::unordered_set uploads;
std::unordered_set deletes;
std::unordered_map filePlatforms; // filename -> platforms_to_sync
+ std::unordered_map uploadMeta; // filename -> uploaded sha/size/ts
};
// Allocate the next unique batch ID (monotonic per process).
@@ -28,9 +38,13 @@ uint64_t BatchTracker_ActiveId(uint32_t accountId, uint32_t appId);
// Create a new batch for this (account, app) with the given batch ID.
void BatchTracker_Begin(uint32_t accountId, uint32_t appId, uint64_t batchId, uint64_t assignedCN, uint64_t appBuildId);
-// Record a file upload or delete in the active batch. No-op if no active batch.
+// Record a file upload in the active batch with the uploaded bytes' SHA/size/ts.
+// No-op if no active batch.
void BatchTracker_RecordUpload(uint32_t accountId, uint32_t appId,
- const std::string& filename);
+ const std::string& filename,
+ const std::vector& sha,
+ uint64_t size, uint64_t timestamp);
+// Record a file delete in the active batch. No-op if no active batch.
void BatchTracker_RecordDelete(uint32_t accountId, uint32_t appId,
const std::string& filename);
diff --git a/src/common/bkv_stats.cpp b/src/common/bkv_stats.cpp
deleted file mode 100644
index 14982da1..00000000
--- a/src/common/bkv_stats.cpp
+++ /dev/null
@@ -1,122 +0,0 @@
-// Upload-side predicate: rejects empty cache{crc,PendingChanges}+END skeletons
-// and all-zero-data blobs so UploadStatsOnExit can't clobber a richer cloud copy.
-// Reader mirrors BkvRead in rpc_handlers.cpp; keep them in sync.
-
-#include "rpc_handlers.h"
-
-#include
-#include
-#include
-#include
-
-namespace CloudIntercept {
-namespace {
-
-enum BkvType : uint8_t {
- BKV_SECTION = 0x00,
- BKV_STRING = 0x01,
- BKV_INT = 0x02,
- BKV_FLOAT = 0x03,
- BKV_UINT64 = 0x07,
- BKV_END = 0x08,
- BKV_INT64 = 0x0A,
-};
-
-struct BkvNode {
- BkvType type;
- std::string name;
- uint32_t intVal = 0;
- float floatVal = 0.0f;
- uint64_t uint64Val = 0;
- int64_t int64Val = 0;
- std::string strVal;
- std::vector children;
-};
-
-constexpr int BKV_MAX_DEPTH = 128;
-constexpr size_t BKV_MAX_NODES = 100000;
-
-bool BkvRead(const uint8_t* data, size_t len, size_t& pos,
- std::vector& out, int depth, size_t& totalNodes) {
- if (depth > BKV_MAX_DEPTH) return false;
- while (pos < len) {
- uint8_t tag = data[pos++];
- if (tag == BKV_END) return true;
-
- BkvNode node;
- node.type = static_cast(tag);
-
- const char* nameStart = reinterpret_cast(data + pos);
- size_t nameEnd = pos;
- while (nameEnd < len && data[nameEnd] != 0) nameEnd++;
- if (nameEnd >= len) return false;
- node.name.assign(nameStart, nameEnd - pos);
- pos = nameEnd + 1;
-
- switch (node.type) {
- case BKV_SECTION:
- if (!BkvRead(data, len, pos, node.children, depth + 1, totalNodes))
- return false;
- break;
- case BKV_STRING: {
- const char* s = reinterpret_cast(data + pos);
- size_t end = pos;
- while (end < len && data[end] != 0) end++;
- if (end >= len) return false;
- node.strVal.assign(s, end - pos);
- pos = end + 1;
- break;
- }
- case BKV_INT:
- case BKV_FLOAT:
- if (pos + 4 > len) return false;
- if (node.type == BKV_INT)
- std::memcpy(&node.intVal, data + pos, 4);
- else
- std::memcpy(&node.floatVal, data + pos, 4);
- pos += 4;
- break;
- case BKV_UINT64:
- if (pos + 8 > len) return false;
- std::memcpy(&node.uint64Val, data + pos, 8);
- pos += 8;
- break;
- case BKV_INT64:
- if (pos + 8 > len) return false;
- std::memcpy(&node.int64Val, data + pos, 8);
- pos += 8;
- break;
- default:
- return false;
- }
- if (++totalNodes > BKV_MAX_NODES) return false;
- out.push_back(std::move(node));
- }
- return depth == 0;
-}
-
-bool HasNonZeroStatsData(const std::vector& nodes) {
- for (const auto& n : nodes) {
- if (n.name == "data") {
- if (n.type == BKV_INT && n.intVal != 0) return true;
- if (n.type == BKV_FLOAT && n.floatVal != 0.0f) return true;
- if (n.type == BKV_UINT64 && n.uint64Val != 0) return true;
- if (n.type == BKV_INT64 && n.int64Val != 0) return true;
- }
- if (!n.children.empty() && HasNonZeroStatsData(n.children)) return true;
- }
- return false;
-}
-
-} // namespace
-
-bool StatsBlobHasUnlocks(const uint8_t* data, size_t len) {
- if (!data || len == 0) return false;
- size_t pos = 0;
- size_t nodeCount = 0;
- std::vector nodes;
- if (!BkvRead(data, len, pos, nodes, 0, nodeCount)) return false;
- return HasNonZeroStatsData(nodes);
-}
-
-} // namespace CloudIntercept
diff --git a/src/common/cli.cpp b/src/common/cli.cpp
index a1f07772..6b822350 100644
--- a/src/common/cli.cpp
+++ b/src/common/cli.cpp
@@ -17,6 +17,11 @@
#include