From 24ff9f73d9035593cb74de815903b068e985d829 Mon Sep 17 00:00:00 2001 From: Arsenic <55256796+danthespal@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:56:21 +0300 Subject: [PATCH 1/2] Match exact AMPR rule paths with a hash set Trace-derived include_from lists can hold tens of thousands of exact paths. Matching each one as a regex for every file made planning quadratic (60k patterns x 95k files for GT7 never finished). Lists of 16+ patterns are now compiled once per list instance: patterns without '*', '?' or '[' go into an ordinal hash set, where fnmatchcase is plain equality, and only real globs stay regexes. Results are unchanged. --- src/MkPFS.Core/AMPR/AMPRGlob.cs | 55 ++++++++++++++++++++++++- tests/MkPFS.Tests/AMPR/AMPRGlobTests.cs | 31 ++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/MkPFS.Core/AMPR/AMPRGlob.cs b/src/MkPFS.Core/AMPR/AMPRGlob.cs index ab70b40..01a5897 100644 --- a/src/MkPFS.Core/AMPR/AMPRGlob.cs +++ b/src/MkPFS.Core/AMPR/AMPRGlob.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Runtime.CompilerServices; using System.Text; using System.Text.RegularExpressions; @@ -11,7 +12,11 @@ namespace MkPFS.Core.AMPR; /// public static class AMPRGlob { + // Lists at least this long are compiled once into a PatternSet; shorter ones (often built per call) are matched directly. + private const int PatternSetThreshold = 16; + private static readonly ConcurrentDictionary Cache = new(StringComparer.Ordinal); + private static readonly ConditionalWeakTable, PatternSet> Sets = new(); /// Python glob_matches: normalize separators, strip leading /, match any pattern. /// Relative path. @@ -19,10 +24,26 @@ public static class AMPRGlob /// when any pattern matches. public static bool Matches(string path, IEnumerable patterns) { - string normalized = path.Replace('\\', '/').TrimStart('/'); - return patterns.Any(pattern => FnMatchCase(normalized, pattern.Replace('\\', '/').TrimStart('/'))); + string normalized = Normalize(path); + if (patterns is IReadOnlyCollection { Count: >= PatternSetThreshold } collection) + { + // Trace-derived include_from lists hold tens of thousands of exact paths; matching each one as a regex + // per file is quadratic. Recompile if the list changed size since it was cached. + PatternSet set = Sets.GetValue(collection, static c => new PatternSet(c)); + if (set.Count != collection.Count) + { + set = new PatternSet(collection); + Sets.AddOrUpdate(collection, set); + } + + return set.Matches(normalized); + } + + return patterns.Any(pattern => FnMatchCase(normalized, Normalize(pattern))); } + private static string Normalize(string path) => path.Replace('\\', '/').TrimStart('/'); + /// Python fnmatch.fnmatchcase(name, pattern). /// Name. /// Pattern. @@ -176,4 +197,34 @@ private static int TranslateSet(string pat, int i, StringBuilder res) return j + 1; } + + // A pattern without '*', '?' or '[' translates to an anchored, fully escaped regex, so fnmatchcase on it is plain + // ordinal equality; those go into a hash set and only real globs are matched as regexes. + private sealed class PatternSet + { + private readonly HashSet _literals = new(StringComparer.Ordinal); + private readonly List _globs = []; + + public PatternSet(IReadOnlyCollection patterns) + { + Count = patterns.Count; + foreach (string pattern in patterns) + { + string normalized = Normalize(pattern); + if (normalized.AsSpan().IndexOfAny('*', '?', '[') < 0) + { + _literals.Add(normalized); + } + else + { + _globs.Add(normalized); + } + } + } + + public int Count { get; } + + public bool Matches(string normalizedPath) => + _literals.Contains(normalizedPath) || _globs.Exists(glob => FnMatchCase(normalizedPath, glob)); + } } diff --git a/tests/MkPFS.Tests/AMPR/AMPRGlobTests.cs b/tests/MkPFS.Tests/AMPR/AMPRGlobTests.cs index 6965675..41deb49 100644 --- a/tests/MkPFS.Tests/AMPR/AMPRGlobTests.cs +++ b/tests/MkPFS.Tests/AMPR/AMPRGlobTests.cs @@ -59,6 +59,37 @@ public void Matches_normalizes_separators_and_leading_slashes() Assert.False(AMPRGlob.Matches("assets/a.bin", [])); } + [Fact] + public void Matches_large_list_agrees_with_per_pattern_fnmatch() + { + // Large lists take the compiled PatternSet path (exact paths in a hash set, globs as regexes). + List patterns = [.. Enumerable.Range(0, 64).Select(i => $"contents/{i:X2}/file{i}"), + "/abs/lead.bin", "win\\sep.bin", "a.b", "a+b", "glob/*.dat", "set/a[0-9]", "q/?x"]; + string[] paths = + [ + "contents/00/file0", "contents/3F/file63", "contents/3F/file62", "CONTENTS/00/file0", "abs/lead.bin", + "/abs/lead.bin", "win/sep.bin", "a.b", "axb", "a+b", "aab", "glob/x.dat", "glob/sub/x.dat", "glob/x.da", + "set/a5", "set/ab", "q/ax", "q/axx", "", + ]; + + foreach (string path in paths) + { + string normalized = path.Replace('\\', '/').TrimStart('/'); + bool expected = patterns.Any(p => AMPRGlob.FnMatchCase(normalized, p.Replace('\\', '/').TrimStart('/'))); + Assert.Equal(expected, AMPRGlob.Matches(path, patterns)); + } + } + + [Fact] + public void Matches_large_list_recompiles_after_the_list_grows() + { + List patterns = [.. Enumerable.Range(0, 20).Select(i => $"p{i}")]; + Assert.False(AMPRGlob.Matches("late/added.bin", patterns)); + + patterns.Add("late/*.bin"); + Assert.True(AMPRGlob.Matches("late/added.bin", patterns)); + } + [Theory] [InlineData(0.01, "0.01")] [InlineData(0.125, "0.125")] From ba64001f0f432f6672ba124bcd6094ddbe93aade Mon Sep 17 00:00:00 2001 From: Arsenic <55256796+danthespal@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:57:04 +0300 Subject: [PATCH 2/2] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e86c1e2..778ebbe 100644 --- a/README.md +++ b/README.md @@ -437,7 +437,9 @@ through AMPR. MkPFS turns those recordings into rules. run, so the emulator can write into the game folder. 2. Play a session, then quit the game from the PS5 menu. 3. Copy `ampr_commands.bin` and `ampr_emu.index` into a subfolder of a trace folder (for example - `traces/session1/`) and delete them from the game folder. More sessions cover more of the game. + `traces/session1/`) before starting the game again. Leave both in the game folder: the emulator needs the + index (keep it unchanged until every session and pack is done), and starts a new journal on each launch. + More sessions cover more of the game. 4. Put the normal emulator back and set `mount_read_only=1` again. 5. Pick the trace folder in **Trace folder** (`--traces traces`); the page shows how many sessions it found.