From 170b0fbc73921a32d247e852c1f295c91fdae1f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 19:17:39 +0000 Subject: [PATCH 1/2] match game profiles by longest common path suffix Authored by Claude (claude-opus-5) A profile was located by an exact match on its ExePath registry value. Under steam/proton the same install is reachable through several drive mappings (Z:\ for the unix root, a steam library drive, plus any hand-made mapping), so one exact ExePath can't cover them and the workaround was a duplicate profile per path. Both the native and managed lookups now fall back to a component-wise suffix match: an exact match still wins outright, otherwise the profile sharing the most trailing path components wins, provided it shares at least two (file name plus one parent dir). Requiring two rules out the case where an unrelated profile is picked up purely because its game happens to ship a generically named exe. Ties are broken by registry order and logged, since they generally mean there are leftover duplicate profiles. Splitting on components also makes the match insensitive to separator style and the \\?\ prefix. Both sides had to change together: native resolves the profile at device creation (before the CLR loads) while managed resolves it again in RegConfig.load, and the managed result is handed back to native as ConfData.ProfileKey. Changing only native left snapshots unable to find the profile. The scoring is split out of the registry I/O on both sides (util::game_profile::pick_best_profile, RegConfig.pickBestProfile) so it can be unit tested; the two test tables mirror each other. RegConfig.findProfilePath stays an exact match: saveProfile falls back to it when the profile key name is empty, and MMLaunch uses it to reject an exe that another profile already claims. A relaxed match there would let creating a profile silently take over an existing one. No interop wire change, so no native code version bump. --- MMManaged/RegConfig.fs | 77 +++++++- Native/util/src/game_profile.rs | 184 +++++++++++++++++++- Test.MMManaged/Test.MMManaged.dotnet.fsproj | 1 + Test.MMManaged/Test.MMManaged.fsproj | 1 + Test.MMManaged/TestRegConfig.fs | 74 ++++++++ 5 files changed, 327 insertions(+), 10 deletions(-) create mode 100644 Test.MMManaged/TestRegConfig.fs diff --git a/MMManaged/RegConfig.fs b/MMManaged/RegConfig.fs index d99ee57d..e8d3898e 100644 --- a/MMManaged/RegConfig.fs +++ b/MMManaged/RegConfig.fs @@ -75,6 +75,24 @@ module RegUtil = sw.Write(s) sw.ToString() + /// Minimum number of trailing path components (including the exe file name) that a profile + /// must share with the target exe to be considered a match. 1 would match on file name + /// alone, which is too weak: many games ship a generically named exe. + let minMatchComponents = 2 + + /// Split a path into components, ignoring separator style and empty parts. + /// Caller is responsible for case normalization. + let pathComponents (p:string) = + p.Split([|'\\'; '/'|]) |> Array.filter (fun s -> s <> "") + + /// Number of trailing components shared by both paths. + let commonSuffixLen (a:string[]) (b:string[]) = + let rec count i = + if i >= a.Length || i >= b.Length then i + elif a.[a.Length - 1 - i] = b.[b.Length - 1 - i] then count (i + 1) + else i + count 0 + /// Utilities for accessing ModelMod specific configuration data. /// /// Note the GameProfile portion of this also now has an implementation in the rust code (util::GameProfile) which @@ -143,6 +161,61 @@ module RegConfig = Some(pBase) // exclude hive else None) + /// Choose the profile whose ExePath best matches exePath, from a list of + /// (profile key, ExePath) pairs in registry order. + /// + /// An exact (case-insensitive) match wins outright; failing that, the profile sharing the + /// most trailing path components wins, as long as it shares at least + /// RegUtil.minMatchComponents. This lets one profile serve a game that is reachable by + /// more than one path, which is normal under proton (the same install is visible as both + /// Z:\ and a steam library drive). Splitting on components also makes the match + /// insensitive to the windows \\?\ prefix. Ties are broken by registry order and logged, + /// since they usually mean there are leftover duplicate profiles. + /// + /// Mirrors util::game_profile::pick_best_profile in the native code; keep the two in sync. + let pickBestProfile (exePath:string) (profiles:(string*string)[]) = + let exeLower = exePath.Trim().ToLowerInvariant() + if exeLower = "" then + None + else + let exeComps = pathComponents exeLower + let profiles = + profiles + |> Array.map (fun (pKey,pExe) -> pKey, pExe.Trim().ToLowerInvariant()) + |> Array.filter (fun (_,pExe) -> pExe <> "") + + let exact = profiles |> Array.tryPick (fun (pKey,pExe) -> if pExe = exeLower then Some(pKey) else None) + match exact with + | Some(_) -> exact + | None -> + let candidates = + profiles + |> Array.map (fun (pKey,pExe) -> commonSuffixLen exeComps (pathComponents pExe), pKey, pExe) + |> Array.filter (fun (score,_,_) -> score >= minMatchComponents) + if candidates.Length = 0 then + None + else + let bestScore = candidates |> Array.map (fun (score,_,_) -> score) |> Array.max + let tied = candidates |> Array.filter (fun (score,_,_) -> score = bestScore) + if tied.Length > 1 then + let tiedPaths = tied |> Array.map (fun (_,_,pExe) -> pExe) + log.Warn "Ambiguous profile match: %d profiles tie at %d component(s): %A; using the first, consider removing the duplicates" tied.Length bestScore tiedPaths + let (score,pKey,pExe) = tied.[0] + log.Info "Matched exe %A to profile %A (%A) on %d trailing component(s)" exeLower pKey pExe score + Some(pKey) + + /// Given an exe path, find its profile key path using the relaxed match above, or None if + /// not found. Deliberately not used by saveProfile or the MMLaunch duplicate-exe check, + /// which need an exact match so that creating a profile can't silently take over an + /// existing one. + let findProfilePathForExe (exePath:string) = + getProfileKeyNames() + |> Array.map (fun pName -> + let pBase = regLoc.ProfRoot @@ pName // exclude hive + let profRoot = regLoc.Hive.Name @@ pBase + pBase, (regget(profRoot, RegKeys.ProfExePath, "") :?> string)) + |> pickBestProfile exePath + /// Fail with exception if write to specified key is not authorized. /// The hardcoded string here is deliberate, so that we don't end up writing /// to willy-nilly places. @@ -337,7 +410,7 @@ module RegConfig = let conf = // Search all profiles for a subkey that has the exe as its ExePath - let targetProfile = findProfilePath exePath + let targetProfile = findProfilePathForExe exePath let targetProfile = // may need to handle windows "object directory" prefix let funkyPrefix = @"\\?\" @@ -347,7 +420,7 @@ module RegConfig = let ep = exePath.Replace(funkyPrefix, "") log.Warn "exePath contains %A prefix, trying without it: %A" funkyPrefix ep // try again without the windows \\? prefix - findProfilePath ep + findProfilePathForExe ep | None -> None let runConfig = match targetProfile with diff --git a/Native/util/src/game_profile.rs b/Native/util/src/game_profile.rs index 3234990d..6f3daf90 100644 --- a/Native/util/src/game_profile.rs +++ b/Native/util/src/game_profile.rs @@ -3,7 +3,8 @@ /// At hook time (device creation), the managed CLR has not yet been loaded, so /// the F# `RegConfig.load` path is unavailable. This module replicates the /// profile-matching logic in pure Rust so that profile settings can influence -/// which functions are hooked. +/// which functions are hooked. `RegConfig.pickBestProfile` is the F# counterpart of +/// `pick_best_profile` below; the two must agree on which profile a given exe resolves to. /// /// Registry layout (all under `HKCU\Software\ModelMod`): /// @@ -126,6 +127,97 @@ unsafe fn reg_enum_subkeys(parent_path: &str) -> Result> { Ok(names) } +/// Minimum number of trailing path components (including the exe file name) that a profile +/// must share with the target before it is considered a match. A value of 1 would match on +/// file name alone, which is too weak: many games ship a generically named exe. +const MIN_MATCH_COMPONENTS: usize = 2; + +/// Split a path into components, ignoring separator style and empty parts. +/// The caller is responsible for case normalization. +fn path_components(p: &str) -> Vec<&str> { + p.split(|c| c == '\\' || c == '/') + .filter(|s| !s.is_empty()) + .collect() +} + +/// Number of trailing components shared by both paths. +fn common_suffix_len(a: &[&str], b: &[&str]) -> usize { + a.iter() + .rev() + .zip(b.iter().rev()) + .take_while(|(x, y)| x == y) + .count() +} + +/// Choose the profile whose `ExePath` best matches `exe_path`. +/// +/// `profiles` is a list of (profile key, ExePath) pairs in registry order. An exact match +/// wins outright; failing that, the profile sharing the most trailing path components wins, +/// as long as it shares at least `MIN_MATCH_COMPONENTS`. This lets a single profile serve a +/// game that is reachable by more than one path, which is normal under proton (the same +/// install is visible as both `Z:\` and a steam library drive). Ties are broken by registry +/// order and logged, since they usually mean there are leftover duplicate profiles. +/// +/// Splitting on components also makes the match insensitive to the `\\?\` prefix. +fn pick_best_profile(exe_path: &str, profiles: &[(String, String)]) -> Option { + let exe_lower = exe_path.trim().to_lowercase(); + if exe_lower.is_empty() { + write_log_file("find_profile_for_exe: empty exe path, no profile lookup possible"); + return None; + } + let exe_comps = path_components(&exe_lower); + + // (score, key, path) for each profile that is close enough to consider. + let mut candidates: Vec<(usize, String, String)> = Vec::new(); + + for (key, prof_exe) in profiles { + let prof_lower = prof_exe.trim().to_lowercase(); + if prof_lower.is_empty() { + continue; + } + if prof_lower == exe_lower { + write_log_file(&format!("find_profile_for_exe: exact match on {}", key)); + return Some(key.clone()); + } + let score = common_suffix_len(&exe_comps, &path_components(&prof_lower)); + if score >= MIN_MATCH_COMPONENTS { + candidates.push((score, key.clone(), prof_lower)); + } + } + + // Sort is stable, so registry order breaks ties. + candidates.sort_by_key(|c| std::cmp::Reverse(c.0)); + + let best_score = match candidates.first() { + None => { + write_log_file(&format!( + "find_profile_for_exe: no profile matches {:?}", exe_lower)); + return None; + } + Some(c) => c.0, + }; + + { + let tied: Vec<&str> = candidates + .iter() + .take_while(|c| c.0 == best_score) + .map(|c| c.2.as_str()) + .collect(); + if tied.len() > 1 { + write_log_file(&format!( + "find_profile_for_exe: ambiguous, {} profiles tie at {} component(s): {:?}; \ + using the first. consider removing the duplicates", + tied.len(), best_score, tied)); + } + } + + let (score, key, path) = candidates.remove(0); + write_log_file(&format!( + "find_profile_for_exe: {:?} matched profile {} ({:?}) on {} trailing component(s)", + exe_lower, key, path, score)); + Some(key) +} + /// Find the profile registry path whose `ExePath` matches the current executable. /// /// Returns the full registry path (e.g. `Software\ModelMod\Profiles\Profile0000`) @@ -134,17 +226,20 @@ unsafe fn find_profile_for_exe(exe_path: &str) -> Result> { let profiles_root = format!("{}\\Profiles", get_mm_reg_key()); let subkeys = reg_enum_subkeys(&profiles_root)?; - let exe_lower = exe_path.trim().to_lowercase(); - + let mut profiles: Vec<(String, String)> = Vec::with_capacity(subkeys.len()); + let mut unreadable = 0; for key_name in &subkeys { let full_key = format!("{}\\{}", profiles_root, key_name); - if let Ok(prof_exe) = reg_query_string(&full_key, "ExePath") { - if prof_exe.trim().to_lowercase() == exe_lower { - return Ok(Some(full_key)); - } + match reg_query_string(&full_key, "ExePath") { + Ok(prof_exe) => profiles.push((full_key, prof_exe)), + Err(_) => unreadable += 1, } } - Ok(None) + write_log_file(&format!( + "find_profile_for_exe: {} profile(s) under {}, {} with no readable ExePath", + profiles.len(), profiles_root, unreadable)); + + Ok(pick_best_profile(exe_path, &profiles)) } /// Read a `GameProfile` from a specific profile registry path. @@ -213,3 +308,76 @@ pub fn load_for_current_exe() -> GameProfile { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn profs(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs.iter().map(|(k, p)| (k.to_string(), p.to_string())).collect() + } + + #[test] + fn exact_match_ignores_case_and_whitespace() { + let p = profs(&[("P0", " C:\\Games\\Foo\\Bin\\Game.exe ")]); + assert_eq!(pick_best_profile("c:\\games\\foo\\bin\\game.exe", &p), Some("P0".into())); + } + + #[test] + fn matches_same_install_through_different_drive_mappings() { + // the profile was created against the Z: (unix root) view; the game is launched + // through a steam library drive. + let p = profs(&[("P0", "Z:\\home\\me\\SteamLibrary\\steamapps\\common\\Foo\\bin\\game.exe")]); + assert_eq!( + pick_best_profile("S:\\steamapps\\common\\Foo\\bin\\game.exe", &p), + Some("P0".into())); + // a hand-made mapping straight at the game dir still shares 3 components. + assert_eq!( + pick_best_profile("C:\\Foo\\bin\\game.exe", &p), + Some("P0".into())); + } + + #[test] + fn separator_style_and_object_dir_prefix_are_ignored() { + let p = profs(&[("P0", "S:/steamapps/common/Foo/bin/game.exe")]); + assert_eq!( + pick_best_profile("\\\\?\\S:\\steamapps\\common\\Foo\\bin\\game.exe", &p), + Some("P0".into())); + } + + #[test] + fn file_name_alone_is_not_enough() { + let p = profs(&[("P0", "C:\\Games\\Bar\\game.exe")]); + assert_eq!(pick_best_profile("C:\\Games\\Foo\\game.exe", &p), None); + } + + #[test] + fn best_score_wins_over_a_weaker_match() { + let p = profs(&[ + ("P0", "C:\\Games\\Bar\\bin\\game.exe"), + ("P1", "Z:\\home\\me\\steamapps\\common\\Foo\\bin\\game.exe"), + ]); + assert_eq!( + pick_best_profile("S:\\steamapps\\common\\Foo\\bin\\game.exe", &p), + Some("P1".into())); + } + + #[test] + fn empty_paths_never_match() { + let p = profs(&[("P0", ""), ("P1", " ")]); + assert_eq!(pick_best_profile("C:\\Games\\Foo\\game.exe", &p), None); + assert_eq!(pick_best_profile("", &profs(&[("P0", "C:\\Games\\Foo\\game.exe")])), None); + } + + #[test] + fn ties_resolve_to_the_first_profile_in_registry_order() { + let p = profs(&[ + ("P0", "S:\\steamapps\\common\\Foo\\bin\\game.exe"), + ("P1", "Z:\\home\\me\\steamapps\\common\\Foo\\bin\\game.exe"), + ]); + // both share "common\Foo\bin\game.exe"; P0 wins on order, not on score. + assert_eq!( + pick_best_profile("C:\\steamapps\\common\\Foo\\bin\\game.exe", &p), + Some("P0".into())); + } +} diff --git a/Test.MMManaged/Test.MMManaged.dotnet.fsproj b/Test.MMManaged/Test.MMManaged.dotnet.fsproj index 778ec10d..ea55785c 100644 --- a/Test.MMManaged/Test.MMManaged.dotnet.fsproj +++ b/Test.MMManaged/Test.MMManaged.dotnet.fsproj @@ -43,6 +43,7 @@ + diff --git a/Test.MMManaged/Test.MMManaged.fsproj b/Test.MMManaged/Test.MMManaged.fsproj index beb905b1..bd5e6f7d 100644 --- a/Test.MMManaged/Test.MMManaged.fsproj +++ b/Test.MMManaged/Test.MMManaged.fsproj @@ -71,6 +71,7 @@ + diff --git a/Test.MMManaged/TestRegConfig.fs b/Test.MMManaged/TestRegConfig.fs new file mode 100644 index 00000000..db1657be --- /dev/null +++ b/Test.MMManaged/TestRegConfig.fs @@ -0,0 +1,74 @@ +module TestRegConfig + +open System + +open NUnit.Framework + +open ModelMod + +/// pickBestProfile is registry-free, so these cases can be checked directly. They mirror the +/// tests on util::game_profile::pick_best_profile in the native code; keep the two in sync. +let private matched exePath profiles = + match RegConfig.pickBestProfile exePath profiles with + | Some key -> key + | None -> "" + +[] +let ``RegConfig: path components ignore separator style``() = + Assert.AreEqual("c:|games|foo|game.exe", + String.Join("|", RegUtil.pathComponents @"c:/games\foo//game.exe")) + Assert.AreEqual("?|s:|foo|game.exe", + String.Join("|", RegUtil.pathComponents @"\\?\s:\foo\game.exe")) + +[] +let ``RegConfig: common suffix length``() = + let a = RegUtil.pathComponents @"s:\steamapps\common\foo\bin\game.exe" + let b = RegUtil.pathComponents @"z:\home\me\steamapps\common\foo\bin\game.exe" + Assert.AreEqual(5, RegUtil.commonSuffixLen a b) + Assert.AreEqual(0, RegUtil.commonSuffixLen a (RegUtil.pathComponents @"c:\other.exe")) + +[] +let ``RegConfig: exact profile match ignores case and whitespace``() = + let p = [| "P0", " C:\\Games\\Foo\\Bin\\Game.exe " |] + Assert.AreEqual("P0", matched @"c:\games\foo\bin\game.exe" p) + +[] +let ``RegConfig: profile matches same install through different drive mappings``() = + // the profile was created against the Z: (unix root) view; the game is launched through a + // steam library drive. + let p = [| "P0", @"Z:\home\me\SteamLibrary\steamapps\common\Foo\bin\game.exe" |] + Assert.AreEqual("P0", matched @"S:\steamapps\common\Foo\bin\game.exe" p) + // a hand-made mapping straight at the game dir still shares 3 components. + Assert.AreEqual("P0", matched @"C:\Foo\bin\game.exe" p) + +[] +let ``RegConfig: profile match ignores separator style and object dir prefix``() = + let p = [| "P0", "S:/steamapps/common/Foo/bin/game.exe" |] + Assert.AreEqual("P0", matched @"\\?\S:\steamapps\common\Foo\bin\game.exe" p) + +[] +let ``RegConfig: profile file name alone is not enough``() = + let p = [| "P0", @"C:\Games\Bar\game.exe" |] + Assert.AreEqual("", matched @"C:\Games\Foo\game.exe" p) + +[] +let ``RegConfig: best profile score wins over a weaker match``() = + let p = [| + "P0", @"C:\Games\Bar\bin\game.exe" + "P1", @"Z:\home\me\steamapps\common\Foo\bin\game.exe" + |] + Assert.AreEqual("P1", matched @"S:\steamapps\common\Foo\bin\game.exe" p) + +[] +let ``RegConfig: empty profile paths never match``() = + Assert.AreEqual("", matched @"C:\Games\Foo\game.exe" [| "P0", ""; "P1", " " |]) + Assert.AreEqual("", matched "" [| "P0", @"C:\Games\Foo\game.exe" |]) + +[] +let ``RegConfig: profile ties resolve to the first in registry order``() = + let p = [| + "P0", @"S:\steamapps\common\Foo\bin\game.exe" + "P1", @"Z:\home\me\steamapps\common\Foo\bin\game.exe" + |] + // both share "common\Foo\bin\game.exe"; P0 wins on order, not on score. + Assert.AreEqual("P0", matched @"C:\steamapps\common\Foo\bin\game.exe" p) From 6e295afda88da86429d1bb54eed0eefe4274a795 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 19:31:29 +0000 Subject: [PATCH 2/2] document the linux dotnet/mono build for the F# projects Authored by Claude (claude-opus-5) The F# note said a linux container probably couldn't build this code. It can: the ubuntu 24.04 dotnet-sdk-8.0 package builds the *.dotnet.fsproj variants, and mono plus NUnit's console runner runs the tests. Records the parts that aren't obvious: paket's restore target shells out to mono and fails the build before the compiler runs (-p:PaketRestoreDisabled=True), the packages/ HintPath deps have to be fetched from nuget by hand, dotnet test can't run net462 because the distro SDK ships no test host for it, FSharp.Core isn't copied to the output dir, and 6 tests fail on linux for unrelated reasons (Util.TestDataDir searches windows-style relative paths). --- CLAUDE.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9e77a44e..016630c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,8 +27,54 @@ This is a Windows-only project, but cross-compilation works from Linux, although ## F# Build (MMManaged.sln) -- If you are running in linux container you probably won't be able to build this code, since it requires some version of visual studio -(2019 or 2022) to be installed and this is generally not feasible on linux. It you have access to the "dotnet" tool you could try building with that, though this isn't how the code is normally built, it may be sufficient to check if it compiles at least. +Normally built with visual studio (2019 or 2022) on windows. The `*.dotnet.fsproj` variants +(and `MMAll.dotnet.sln`) also build and test on linux, which is enough to catch compile errors +in F# changes. This isn't how the code is normally built, so treat a windows build as the +source of truth, but don't skip the linux build just because there's no visual studio. + +Setup, on ubuntu 24.04 (this worked in the claude code web container; the ubuntu archive and +nuget.org were reachable even though `builds.dotnet.microsoft.com` was blocked, so use the +distro package rather than the dotnet-install.sh script): + +``` +apt-get install -y dotnet-sdk-8.0 +``` + +The projects reference third party assemblies by HintPath into `packages/`, which paket would +normally populate. Paket's restore target shells out to mono and will fail the build before +the compiler runs, so disable it and fetch the packages directly: + +``` +dotnet build MMManaged/MMManaged.dotnet.fsproj -p:PaketRestoreDisabled=True +``` + +Packages needed under `packages/` (download the .nupkg from +`https://api.nuget.org/v3-flatcontainer///..nupkg` +and unzip it into the named dir): + +- `packages/MonoGame.Framework.WindowsDX` <- MonoGame.Framework.WindowsDX 3.3.0 (also supplies SharpDX) +- `packages/YamlDotNet` <- YamlDotNet 5.1.0 +- `packages/FsPickler.5.3.2` <- FsPickler 5.3.2 (MMManaged.Engine only) + +The NUnit console runner used below comes from the same place: NUnit.ConsoleRunner 3.16.3, +`tools/nunit3-console.exe` inside the nupkg. It doesn't belong in `packages/`; unzip it +somewhere scratch. + +To run the NUnit tests on linux: `dotnet test` does *not* work, because the distro SDK ships no +net-framework test host. Install `mono-complete`, copy `FSharp.Core.4.4.3.0/FSharp.Core.dll` +into the build output dir (it is referenced with Private=False so it isn't copied there), and +run NUnit's own console runner: + +``` +dotnet build Test.MMManaged/Test.MMManaged.dotnet.fsproj -p:PaketRestoreDisabled=True +cp FSharp.Core.4.4.3.0/FSharp.Core.dll Debug/ +cd Debug && mono /path/to/nunit3-console.exe Test.MMManaged.dll +``` + +Known: 6 tests fail on linux regardless of the change under test (TestMesh, TestMeshTransform +x2, TestModDB, TestModDBInterop, TestYaml). They all die in `Util.TestDataDir`'s static +constructor, which searches windows-style relative paths for TestData. Compare against a +master build before assuming a failure is yours. ## Interop notes