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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
55 changes: 53 additions & 2 deletions src/MkPFS.Core/AMPR/AMPRGlob.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.RegularExpressions;

Expand All @@ -11,18 +12,38 @@ namespace MkPFS.Core.AMPR;
/// </summary>
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<string, Regex> Cache = new(StringComparer.Ordinal);
private static readonly ConditionalWeakTable<IReadOnlyCollection<string>, PatternSet> Sets = new();

/// <summary>Python <c>glob_matches</c>: normalize separators, strip leading <c>/</c>, match any pattern.</summary>
/// <param name="path">Relative path.</param>
/// <param name="patterns">Patterns.</param>
/// <returns><see langword="true"/> when any pattern matches.</returns>
public static bool Matches(string path, IEnumerable<string> patterns)
{
string normalized = path.Replace('\\', '/').TrimStart('/');
return patterns.Any(pattern => FnMatchCase(normalized, pattern.Replace('\\', '/').TrimStart('/')));
string normalized = Normalize(path);
if (patterns is IReadOnlyCollection<string> { 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('/');

/// <summary>Python <c>fnmatch.fnmatchcase(name, pattern)</c>.</summary>
/// <param name="name">Name.</param>
/// <param name="pattern">Pattern.</param>
Expand Down Expand Up @@ -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<string> _literals = new(StringComparer.Ordinal);
private readonly List<string> _globs = [];

public PatternSet(IReadOnlyCollection<string> 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));
}
}
31 changes: 31 additions & 0 deletions tests/MkPFS.Tests/AMPR/AMPRGlobTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> 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<string> 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")]
Expand Down
Loading