Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id-lowercased>/<ver>/<id-lowercased>.<ver>.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

Expand Down
77 changes: 75 additions & 2 deletions MMManaged/RegConfig.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = @"\\?\"
Expand All @@ -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
Expand Down
184 changes: 176 additions & 8 deletions Native/util/src/game_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`):
///
Expand Down Expand Up @@ -126,6 +127,97 @@ unsafe fn reg_enum_subkeys(parent_path: &str) -> Result<Vec<String>> {
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<String> {
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`)
Expand All @@ -134,17 +226,20 @@ unsafe fn find_profile_for_exe(exe_path: &str) -> Result<Option<String>> {
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.
Expand Down Expand Up @@ -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()));
}
}
1 change: 1 addition & 0 deletions Test.MMManaged/Test.MMManaged.dotnet.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
<Compile Include="TestModDBInterop.fs" />
<Compile Include="TestWriters.fs" />
<Compile Include="TestMeshRelation.fs" />
<Compile Include="TestRegConfig.fs" />
</ItemGroup>

<ItemGroup>
Expand Down
1 change: 1 addition & 0 deletions Test.MMManaged/Test.MMManaged.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
<Compile Include="TestModDBInterop.fs" />
<Compile Include="TestWriters.fs" />
<Compile Include="TestMeshRelation.fs" />
<Compile Include="TestRegConfig.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MMManaged\MMManaged.fsproj">
Expand Down
Loading
Loading