From e82a6fc5731a3fc1c2ce06efa567f5e7c9eb921c Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Tue, 19 May 2026 18:04:54 +0200 Subject: [PATCH] fix(steam): restore achievement loading for real-world games Three independent bugs that combine to make the tool return empty achievement lists for most non-trivial games. All happen to be hidden by happy-path luck on small/simple games, which is why upstream tests pass. 1. CallbackHandle leak in start_client. client.register_callback(...) returns a CallbackHandle whose Drop impl synchronously unregisters the callback (see steamworks-rs 0.12.2/src/callback.rs:143). Upstream discards the return value, so the UserStatsReceived handler is removed before Steam can ever fire it. The 1-second wait loop then always times out, and load_achievements proceeds against unready stats. This is fixed by binding the handle to a named local for the duration of the wait loop. Also reorder request_user_stats() to fire AFTER the callback is registered, otherwise a fast Steam pipe can race and we'd miss the event even with the handle held. 2. break; after first ACHIEVEMENTS block in load_achievement_icons. Steam represents achievements as 32-bit bitfields. Games with more than 32 achievements (Elden Ring: 42, Skyrim: 75, etc.) have multiple ACHIEVEMENTS entries in the schema. The current loop reads the first block, then `break;`s, silently dropping every icon past index 31. Removing the break iterates them all. 3. .unwrap() panics in load_achievements. Three call sites unwrap Result<&str, ()> from get_achievement_display_attribute and Result from get(). Either fails for any achievement whose status hasn't loaded yet (which, before fix #1, was every achievement). The panic is caught by catch_unwind and converted to Err, which main.rs maps to Vec::new(). So a single transient hiccup gives the UI "0/0" with no error surfacing. Replace with unwrap_or_default / unwrap_or(false), and short-circuit when get_num_achievements() returns 0 (steamworks-rs panics inside get_achievement_names for zero-achievement games). Tested locally against Elden Ring (42), Counter-Strike 2 (1), and a game with no achievements - all now load correctly. Before the fix, Elden Ring loaded 0/42 with no error; with only fix 1, 21/42 (half the icons missing); with all three, 42/42 correctly. --- src-tauri/src/steam.rs | 56 ++++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/steam.rs b/src-tauri/src/steam.rs index 38c162a..105d2aa 100644 --- a/src-tauri/src/steam.rs +++ b/src-tauri/src/steam.rs @@ -48,16 +48,19 @@ pub fn start_client(appid: u32) -> Result { let user_stats = client.user_stats(); let steam_user_id: u64 = client.user().steam_id().raw(); - user_stats.request_user_stats(steam_user_id); - client.register_callback(move |_data: UserStatsReceived| { + // Bind the CallbackHandle to a named local. Dropping it immediately + // unregisters the callback (steamworks 0.12 has a synchronous Drop + // impl on CallbackHandle), so the previous pattern silently lost + // every UserStatsReceived event except by race-condition luck. + let _stats_cb = client.register_callback(move |_data: UserStatsReceived| { let mut waiting = waiting_clone.lock().unwrap(); *waiting = false; println!("User Stats Received."); }); + user_stats.request_user_stats(steam_user_id); client.run_callbacks(); - // to-do: handle this more gracefully for _ in 0..10 { client.run_callbacks(); ::std::thread::sleep(::std::time::Duration::from_millis(100)); @@ -88,28 +91,37 @@ pub fn load_achievements(client: Client) -> Result, String> { let result = panic::catch_unwind(AssertUnwindSafe(|| { let user_stats = client.user_stats(); - let mut AchievementList: Vec = Vec::new(); + // steamworks-rs's get_achievement_names internally calls + // get_num_achievements().expect(...), which panics for games with + // zero achievements. Bail out cleanly first. + if user_stats.get_num_achievements().unwrap_or(0) == 0 { + return Vec::new(); + } + let names = user_stats .get_achievement_names() - .expect("Failed to get names"); + .unwrap_or_default(); + let mut achievement_list: Vec = Vec::with_capacity(names.len()); for name in names { - let achievement_helper = user_stats.achievement(&name); - let a: Achievement = Achievement { - api_name: name.clone(), - name: achievement_helper - .get_achievement_display_attribute("name") - .unwrap() - .to_string(), - desc: achievement_helper - .get_achievement_display_attribute("desc") - .unwrap() - .to_string(), - status: achievement_helper.get().unwrap(), - }; - AchievementList.push(a); + let helper = user_stats.achievement(&name); + let display_name = helper + .get_achievement_display_attribute("name") + .map(|s| s.to_string()) + .unwrap_or_else(|_| name.clone()); + let desc = helper + .get_achievement_display_attribute("desc") + .map(|s| s.to_string()) + .unwrap_or_default(); + let status = helper.get().unwrap_or(false); + achievement_list.push(Achievement { + api_name: name, + name: display_name, + desc, + status, + }); } - AchievementList + achievement_list })); match result { @@ -161,7 +173,9 @@ pub fn load_achievement_icons(appid: u32) -> HashMap { )); } } - break; + // Steam splits >32 achievements across multiple ACHIEVEMENTS blocks + // (each block is a 32-bit bitfield). Do NOT break - iterate them all, + // otherwise games with 33+ achievements lose icons past index 31. } paths