tmodloader-mcp 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
tmodloader_mcp/api.py ADDED
@@ -0,0 +1,262 @@
1
+ """Searching the tModLoader API surface without a browser or a guess.
2
+
3
+ WHY THIS EXISTS. Writing mod-side code here has meant answering two questions
4
+ over and over: does this member exist, and what does it take. Both have been
5
+ answered badly. "Does `Main.cloudAlpha` exist" was answered by GREPPING A 21MB
6
+ DLL FOR A SUBSTRING, which finds the name and cannot say what owns it, whether
7
+ it is a field or a method, or what type it is. "What does `QuickSpawnItem`
8
+ take" was answered by writing the call and letting the compiler decide - exact,
9
+ but only for code already written, and a build cycle per attempt.
10
+
11
+ Neither answers the question you have BEFORE writing anything: what is there.
12
+ `Main.maxRaining` is only findable if you already suspect the name.
13
+
14
+ WHAT THIS IS NOT: documentation. It is the assembly's own public surface, read
15
+ from its metadata, so it cannot drift from the version installed - which is the
16
+ failure mode of every wiki page and every model's recollection. It carries no
17
+ prose, because it is not trying to explain anything.
18
+
19
+ The index is built by `tools/ApiIndex`, which reads metadata WITHOUT loading or
20
+ running the assembly, and cached against the DLL it came from so a tModLoader
21
+ update invalidates it by construction rather than by anybody remembering.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import shutil
28
+ import subprocess
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ from .config import Config
33
+
34
+ #: One line per member: `path`, `kind`, `type`. Tab separated because the paths
35
+ #: contain every other punctuation character a signature can hold.
36
+ _FIELDS = 3
37
+
38
+ #: What a caller may filter by, which is the second column of the index.
39
+ KINDS = ("type", "field", "property", "method")
40
+
41
+
42
+ class ApiError(RuntimeError):
43
+ """The index cannot be built or read, and the message says what to do."""
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Member:
48
+ """One line of the index, split.
49
+
50
+ `path` is what is searched and what a caller reads back — the fully
51
+ qualified name, with a method's parameters attached. Namespaces are kept:
52
+ `Terraria.Main` and a mod's own `Main` are different types, and an index
53
+ that called both "Main" would confidently answer about the wrong one.
54
+ """
55
+
56
+ path: str
57
+ kind: str
58
+ type: str
59
+
60
+ @property
61
+ def name(self) -> str:
62
+ """The short name, without the namespace or the parameter list.
63
+
64
+ What a human means when they say "cloudAlpha". Used for ranking, so an
65
+ exact hit on the thing somebody typed sorts above a signature that
66
+ merely mentions it somewhere.
67
+ """
68
+ head = self.path.split("(", 1)[0]
69
+ return head.rsplit(".", 1)[-1]
70
+
71
+
72
+ def parse(text: str) -> list[Member]:
73
+ """Split an index into members, skipping anything malformed.
74
+
75
+ Skipped rather than raised on: an index is thousands of lines produced by a
76
+ separate program, and one unreadable line is not a reason to refuse every
77
+ question about the other thirty-six thousand.
78
+ """
79
+ out: list[Member] = []
80
+ for line in text.splitlines():
81
+ parts = line.split("\t")
82
+ if len(parts) != _FIELDS:
83
+ continue
84
+ out.append(Member(path=parts[0], kind=parts[1], type=parts[2]))
85
+ return out
86
+
87
+
88
+ def search(
89
+ members: list[Member], query: str, *, kind: str | None = None, limit: int = 40
90
+ ) -> list[Member]:
91
+ """Members matching `query`, best first.
92
+
93
+ RANKED, because an unranked substring search over 36,000 members answers
94
+ "rain" with `slimeRainKillCount` before `raining` and buries the thing
95
+ somebody asked for. The order is: the short name IS the query, then the
96
+ short name contains it, then it appears anywhere else — a match inside a
97
+ parameter list is a real match and a worse one.
98
+
99
+ Case-insensitive throughout, matching every other filter here.
100
+ """
101
+ if not query or not query.strip():
102
+ raise ValueError(
103
+ "search needs something to look for. An empty query matches all "
104
+ "36,000 members, which is the index rather than an answer"
105
+ )
106
+
107
+ if kind is not None and kind not in KINDS:
108
+ raise ValueError(f"{kind!r} is not one of {', '.join(KINDS)}")
109
+
110
+ needle = query.strip().lower()
111
+ scored: list[tuple[int, int, str, Member]] = []
112
+
113
+ for member in members:
114
+ if kind is not None and member.kind != kind:
115
+ continue
116
+
117
+ name = member.name.lower()
118
+ path = member.path.lower()
119
+
120
+ if name == needle:
121
+ rank = 0
122
+ elif needle in name:
123
+ rank = 1
124
+ elif needle in path:
125
+ rank = 2
126
+ elif needle in member.type.lower():
127
+ rank = 3
128
+ else:
129
+ continue
130
+
131
+ # Shorter paths first within a rank: `Terraria.Main.raining` before
132
+ # `Terraria.GameContent.Something.rainingHelper`, because the shallow
133
+ # one is nearly always the one being asked about.
134
+ scored.append((rank, len(member.path), member.path, member))
135
+
136
+ scored.sort(key=lambda row: (row[0], row[1], row[2]))
137
+ return [row[3] for row in scored[:limit]]
138
+
139
+
140
+ def index_path_for(cfg: Config) -> Path:
141
+ """Where this install's index is cached, keyed by the DLL it describes.
142
+
143
+ Size and mtime rather than a hash: the point is to notice a tModLoader
144
+ update, and reading 21MB to answer a question about a cache is a cost paid
145
+ on every call to avoid one paid on the rare occasion the game changes.
146
+
147
+ Under the user's cache directory rather than in the install or the
148
+ repository. The install is somebody else's, and a generated file of tens of
149
+ thousands of lines does not belong in version control.
150
+ """
151
+ dll = cfg.tml_dll
152
+ stamp = dll.stat() if dll.is_file() else None
153
+ key = f"{stamp.st_size}-{int(stamp.st_mtime)}" if stamp else "absent"
154
+
155
+ root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
156
+ return root / "tmodloader-mcp" / f"api-{key}.txt"
157
+
158
+
159
+ def _dotnet() -> str:
160
+ """The SDK used to build and run the indexer, or a refusal naming the need.
161
+
162
+ NOT `cfg.dotnet`, which is tModLoader's own bundled runtime: that is a
163
+ Windows executable for running the game, and this is a build of a small
164
+ Linux console tool. Different tool, same word.
165
+ """
166
+ for candidate in ("dotnet", str(Path.home() / ".dotnet" / "dotnet")):
167
+ found = shutil.which(candidate) or (
168
+ candidate if Path(candidate).is_file() else None
169
+ )
170
+ if found:
171
+ return found
172
+
173
+ raise ApiError(
174
+ "no dotnet SDK on PATH, and the API index is built by a small C# tool "
175
+ "(tools/ApiIndex). Install the .NET 8 SDK, or answer the question with "
176
+ "a build instead - the compiler is exact about signatures."
177
+ )
178
+
179
+
180
+ def ensure_index(cfg: Config, *, timeout: float = 300.0) -> Path:
181
+ """The cached index for this install, building it if it is not there.
182
+
183
+ Built once per tModLoader version and then read from disk. The build is a
184
+ `dotnet build` plus a metadata read of the game assembly, which is seconds
185
+ rather than minutes, but it is not something to do on every question.
186
+ """
187
+ cached = index_path_for(cfg)
188
+ if cached.is_file() and cached.stat().st_size > 0:
189
+ return cached
190
+
191
+ dll = cfg.tml_dll
192
+ if not dll.is_file():
193
+ raise ApiError(
194
+ f"there is no tModLoader.dll at {dll}, so there is no API to index. "
195
+ "Check TMODLOADER_DIR."
196
+ )
197
+
198
+ tool = Path(__file__).resolve().parent.parent.parent / "tools" / "ApiIndex"
199
+ if not (tool / "ApiIndex.csproj").is_file():
200
+ raise ApiError(f"the indexer is missing from {tool}")
201
+
202
+ dotnet = _dotnet()
203
+ cached.parent.mkdir(parents=True, exist_ok=True)
204
+
205
+ built = tool / "bin" / "Debug" / "net8.0" / "ApiIndex.dll"
206
+ if not built.is_file():
207
+ _run(
208
+ [dotnet, "build", str(tool), "-v", "q", "--nologo"],
209
+ timeout=timeout,
210
+ what="building the API indexer",
211
+ )
212
+
213
+ # INDEXED UNDER A STAGING NAME AND RENAMED, because the validity check
214
+ # above is `is_file and size > 0` - which a file killed mid-write passes
215
+ # forever. A SIGTERM landing during the one build this cache ever gets
216
+ # (and `finally` does not run on SIGTERM here, measured) left a truncated
217
+ # index that every later call served: `parse` skips the one torn line, so
218
+ # api_search answered "not found" about every member in the missing tail -
219
+ # the misleading absence this module exists to prevent, installed
220
+ # permanently by the process meant to prevent it.
221
+ staging = cached.with_name(f"{cached.name}.{os.getpid()}.partial")
222
+ try:
223
+ _run(
224
+ [dotnet, str(built), str(dll), str(staging)],
225
+ timeout=timeout,
226
+ what="indexing the API",
227
+ )
228
+
229
+ if not staging.is_file() or staging.stat().st_size == 0:
230
+ raise ApiError(
231
+ f"the indexer reported success and wrote nothing to {staging}, "
232
+ "so there is no index to search"
233
+ )
234
+
235
+ staging.replace(cached)
236
+ finally:
237
+ staging.unlink(missing_ok=True)
238
+
239
+ return cached
240
+
241
+
242
+ def _run(command: list[str], *, timeout: float, what: str) -> None:
243
+ """Run a step, and fail LOUD with what it actually said.
244
+
245
+ `check=False` plus an explicit raise rather than `check=True`: a
246
+ CalledProcessError carries the return code and hides the output, and the
247
+ output is the only part anybody can act on.
248
+ """
249
+ try:
250
+ proc = subprocess.run(
251
+ command, capture_output=True, text=True, timeout=timeout, check=False
252
+ )
253
+ except subprocess.TimeoutExpired:
254
+ raise ApiError(f"{what} did not finish within {timeout:.0f}s") from None
255
+ except OSError as broken:
256
+ raise ApiError(f"{what} could not start: {broken}") from None
257
+
258
+ if proc.returncode != 0:
259
+ raise ApiError(
260
+ f"{what} failed ({proc.returncode}):\n"
261
+ f"{(proc.stdout + proc.stderr).strip()[-2000:]}"
262
+ )
@@ -0,0 +1,141 @@
1
+ """Building the mod, and reading tModLoader's build output honestly.
2
+
3
+ WHY THIS IS NOT JUST A subprocess CALL
4
+
5
+ tModLoader refuses to build while the game is open, and says so with
6
+ `Mod Build error TML003`. That is a completely recoverable situation with an
7
+ obvious fix, and it arrives buried in output that otherwise looks like a
8
+ compiler failure. Surfacing it as itself, with the fix, is most of this module's
9
+ value — the alternative is reading it as a broken build and going looking for a
10
+ syntax error that is not there.
11
+
12
+ The other half is that "did it succeed" is a QUESTION ABOUT THE OUTPUT, not
13
+ about the exit code, which tModLoader does not use reliably here.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import subprocess
19
+ from dataclasses import dataclass
20
+
21
+ from .config import Config, ConfigError
22
+
23
+ #: The line tModLoader prints when it will not build over a running game.
24
+ GAME_OPEN_MARKER = "TML003"
25
+
26
+ #: The line that means the compiler finished, with its counts.
27
+ SUCCESS_MARKER = "Compilation finished with"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class BuildResult:
32
+ ok: bool
33
+ errors: int
34
+ warnings: int
35
+ game_was_open: bool
36
+ output: str
37
+ #: The build never finished, so there are no counts to report. A state the
38
+ #: other fields cannot express: a timeout arrives as `errors=0, warnings=0`,
39
+ #: which is shaped exactly like a build that completed with nothing wrong.
40
+ #: Recorded rather than inferred, because `summary` was reading those zeros
41
+ #: as a verdict and announcing a compile failure with no errors in it.
42
+ timed_out: bool = False
43
+
44
+ @property
45
+ def summary(self) -> str:
46
+ if self.game_was_open:
47
+ return (
48
+ "tModLoader refused to build because the game is open. Close "
49
+ "tModLoader and build again - this is not a compile failure."
50
+ )
51
+ if self.timed_out:
52
+ # Ahead of the counts, because here they are zero for want of a
53
+ # build rather than for want of anything wrong with the code.
54
+ return self.output
55
+ if not self.ok:
56
+ return f"build failed: {self.errors} error(s), {self.warnings} warning(s)"
57
+ return f"Compilation finished with {self.errors} errors and {self.warnings} warnings"
58
+
59
+
60
+ def _counts(line: str) -> tuple[int, int]:
61
+ """Pull the error and warning counts out of the success line."""
62
+ words = line.replace(",", " ").split()
63
+ errors = warnings = 0
64
+ for i, w in enumerate(words):
65
+ if w.startswith("error") and i:
66
+ errors = int(words[i - 1]) if words[i - 1].isdigit() else errors
67
+ if w.startswith("warning") and i:
68
+ warnings = int(words[i - 1]) if words[i - 1].isdigit() else warnings
69
+ return errors, warnings
70
+
71
+
72
+ def interpret(output: str) -> BuildResult:
73
+ """Decide what a build's output means.
74
+
75
+ Pure, so the interpretation is testable without building anything - which
76
+ matters because the interesting cases (game open, warnings but no errors)
77
+ are annoying to reproduce on demand.
78
+ """
79
+ game_open = GAME_OPEN_MARKER in output
80
+
81
+ for line in output.splitlines():
82
+ if SUCCESS_MARKER in line:
83
+ errors, warnings = _counts(line)
84
+ # A build can print the success line AND still have been refused,
85
+ # if a previous step got that far. The refusal wins: no .tmod was
86
+ # written, so reporting success would be a lie about the artifact.
87
+ return BuildResult(
88
+ ok=errors == 0 and not game_open,
89
+ errors=errors,
90
+ warnings=warnings,
91
+ game_was_open=game_open,
92
+ output=output,
93
+ )
94
+
95
+ return BuildResult(
96
+ ok=False, errors=0, warnings=0, game_was_open=game_open, output=output
97
+ )
98
+
99
+
100
+ def build(cfg: Config, *, timeout: float = 600.0) -> BuildResult:
101
+ """Build the configured mod source."""
102
+ if cfg.mod_source_win is None:
103
+ # Refused here rather than spelled into the command line, where it would
104
+ # arrive as the literal word "None" and come back as tModLoader failing
105
+ # to find a directory nobody ever named.
106
+ raise ConfigError(
107
+ f"{cfg.mod_source} has no known Windows path, so there is nothing to "
108
+ "hand `-build`. Set TMODLOADER_MOD_SOURCE_WIN."
109
+ )
110
+
111
+ try:
112
+ proc = subprocess.run(
113
+ [
114
+ str(cfg.tml_dir / "dotnet" / "dotnet.exe"),
115
+ "tModLoader.dll",
116
+ "-server",
117
+ "-build",
118
+ cfg.mod_source_win,
119
+ ],
120
+ cwd=str(cfg.tml_dir),
121
+ capture_output=True,
122
+ text=True,
123
+ timeout=timeout,
124
+ # Success is read from the OUTPUT, not the exit code, which is not
125
+ # reliable here - so a non-zero exit must not raise past the parser.
126
+ check=False,
127
+ )
128
+ except subprocess.TimeoutExpired:
129
+ return BuildResult(
130
+ ok=False,
131
+ errors=0,
132
+ warnings=0,
133
+ game_was_open=False,
134
+ output=(
135
+ f"the build did not finish within {timeout:.0f}s, so nothing is "
136
+ "known about whether the code compiles"
137
+ ),
138
+ timed_out=True,
139
+ )
140
+
141
+ return interpret(proc.stdout + proc.stderr)
@@ -0,0 +1,204 @@
1
+ """Reading a capture back out of the save directory, and refusing the rest.
2
+
3
+ WHY THIS IS NOT `path.read_bytes()`
4
+
5
+ `shot` hands back a filesystem path, which is worth nothing to an agent that is
6
+ not running on this machine. Returning the bytes is the fix, and doing it
7
+ carelessly is worse than the problem: a tool that opens whatever path it is
8
+ given and returns the contents is a file-exfiltration primitive with an MCP
9
+ interface in front of it.
10
+
11
+ That is the exact failure this project was built to avoid, arriving from the
12
+ other end. OS-level screen capture was tried first and came back with a Teams
13
+ inbox and a Discord friend list in frame. The answer was to read the game's own
14
+ back buffer, which cannot contain another window BY CONSTRUCTION. A reader that
15
+ will open any file on request gives that guarantee away again.
16
+
17
+ So containment is structural rather than advisory:
18
+
19
+ - the caller supplies a NAME, never a path;
20
+ - the result is RESOLVED and its parent compared to the resolved save directory,
21
+ which is what catches `..`, an absolute path, and a symlink alike. Scanning
22
+ the name for `..` is a blacklist, and blacklists lose to the first spelling
23
+ nobody thought of - a symlink contains no `..` at all;
24
+ - and the name must look like a capture, because the save directory also holds
25
+ diag dumps, heartbeats, trigger files and the world itself. Being in the right
26
+ directory is not the same as being the right kind of file.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ from pathlib import Path
33
+
34
+ from .triggers import PLAYER_TOKEN_GRAMMAR, artifacts_for
35
+
36
+
37
+ def capture_pattern(mod_name: str) -> re.Pattern[str]:
38
+ """What `shot` writes for ONE mod: its artifact prefix, the player token,
39
+ then the index and region this harness renames the drop box to.
40
+
41
+ Anchored at both ends — an unanchored match would accept
42
+ `evil-biomancy-shot-001-full.png.exe`. Built per mod rather than fixed,
43
+ because two mods share one save directory and neither should be served the
44
+ other's captures.
45
+
46
+ The token's grammar is PINNED rather than left as `[a-z0-9-]+`. An open
47
+ token is greedy across dashes and digits alike, so it would swallow the
48
+ three-digit index and leave the region matching what was meant to be the
49
+ index — the pattern would still match, and would extract the wrong fields.
50
+ The four-hex tail is what makes the boundary findable at all.
51
+
52
+ Imports `PLAYER_TOKEN_GRAMMAR` from `triggers` rather than holding its own
53
+ copy: two hand-written copies of a regex are two regexes, and this one has
54
+ to stay the SAME grammar `triggers` uses or the boundary against the
55
+ three-digit index stops being findable.
56
+
57
+ The PREFIX comes from `artifacts_for` for the same reason and was the same
58
+ hand-copy one line further down - `mod_name.lower()` is the naming rule
59
+ restated, and a rule restated is a rule that can diverge from the names
60
+ actually written.
61
+ """
62
+ return re.compile(
63
+ rf"^{re.escape(artifacts_for(mod_name).prefix)}"
64
+ rf"-shot-{PLAYER_TOKEN_GRAMMAR}-\d{{3}}-[a-z]+\.png$"
65
+ )
66
+
67
+
68
+ class CaptureError(RuntimeError):
69
+ """The named capture cannot be served, and the message says which rule."""
70
+
71
+
72
+ def available(save_dir: Path, mod_name: str) -> list[str]:
73
+ """Every capture THIS MOD wrote, sorted, names only.
74
+
75
+ Names rather than paths for the same reason `read` takes one: a path handed
76
+ out is a path that can come back changed.
77
+ """
78
+ if not save_dir.is_dir():
79
+ return []
80
+
81
+ pattern = capture_pattern(mod_name)
82
+ return sorted(
83
+ entry.name for entry in save_dir.iterdir() if pattern.match(entry.name)
84
+ )
85
+
86
+
87
+ def contained(
88
+ save_dir: Path, mod_name: str, name: str, *, missing_ok: bool = False
89
+ ) -> Path:
90
+ """The path this name resolves to, or `CaptureError` saying why not.
91
+
92
+ THE ONE CONTAINMENT CHECK, because there is now more than one caller and a
93
+ second copy is how one of them ends up weaker — which this module's own
94
+ header says about having two paths to one file. `read` returns bytes and
95
+ `prune` DELETES, so the dangerous caller is the newer one; giving it its
96
+ own rules is exactly the mistake to avoid.
97
+
98
+ The three refusals stay distinct: "that is not a capture name", "that is
99
+ not inside the save directory", and "that capture is not there" are
100
+ different mistakes, and collapsing them makes a typo look like a security
101
+ refusal.
102
+
103
+ `missing_ok` relaxes ONLY the third - the name and containment rules hold
104
+ regardless. It exists for `prune`, whose target set is a listing another
105
+ session may be shrinking at the same time: a file that vanished between
106
+ the listing and this check is a delete that is already done, and refusing
107
+ the whole prune over it turned concurrent housekeeping into a spurious
108
+ "there is no capture" error.
109
+ """
110
+ if not capture_pattern(mod_name).match(name):
111
+ raise CaptureError(
112
+ f"{name!r} is not a capture name for {mod_name}. Only files this "
113
+ f"harness wrote - {artifacts_for(mod_name).prefix}"
114
+ "-shot-<token>-<index>-<region>.png "
115
+ "- can be read back; the save directory also holds diag dumps, "
116
+ "heartbeats, the world, and any other mod's captures."
117
+ )
118
+
119
+ root = save_dir.resolve()
120
+ target = (root / name).resolve()
121
+
122
+ # Compared AFTER resolving, so `..`, an absolute path and a symlink are all
123
+ # the same case rather than three checks and a fourth nobody wrote.
124
+ if target.parent != root:
125
+ raise CaptureError(
126
+ f"{name!r} resolves to {target}, which is outside {root}. Captures "
127
+ "are served from the save directory only."
128
+ )
129
+
130
+ if not target.is_file() and not missing_ok:
131
+ raise CaptureError(f"there is no capture called {name!r} in {root}")
132
+
133
+ return target
134
+
135
+
136
+ def read(save_dir: Path, mod_name: str, name: str) -> bytes:
137
+ """The bytes of one capture, or `CaptureError` saying why not."""
138
+ return contained(save_dir, mod_name, name).read_bytes()
139
+
140
+
141
+ def prune(save_dir: Path, mod_name: str, *, keep: int) -> list[str]:
142
+ """Delete all but the newest `keep` captures, and say which went.
143
+
144
+ Captures accumulated forever. `shot` writes one per call and nothing ever
145
+ removed them, so an agent photographing in a loop grew the user's SAVE
146
+ DIRECTORY without bound — the folder holding their worlds and characters,
147
+ which is not a cache and not somewhere to leave litter.
148
+
149
+ Every deletion goes through `contained`, so a file is removed only when it
150
+ matches this mod's capture pattern AND resolves to a direct child of the
151
+ save directory. Listing and deleting through different rules is how a
152
+ delete ends up looser than the read beside it.
153
+
154
+ Ordered by MTIME rather than by the name's index, because the index is this
155
+ harness's own counter and the file's timestamp is the disk's account of
156
+ what actually happened. Ties break on name so the result is deterministic.
157
+
158
+ `keep=0` is honoured — "remove all of them" is a real request — but there is
159
+ deliberately no default here. The caller chooses; a module-level default
160
+ would be this file deciding how much of someone else's directory to throw
161
+ away.
162
+ """
163
+ if keep < 0:
164
+ raise CaptureError(f"keep must be 0 or more, not {keep}")
165
+
166
+ if not save_dir.is_dir():
167
+ return []
168
+
169
+ # TOLERANT OF THE OTHER SESSION THROUGHOUT, because two sessions sharing a
170
+ # save directory is the supported arrangement and pruning is not
171
+ # coordinated between them. A file can vanish between the listing, the
172
+ # sort's stat, the validation and the unlink - at each point "already
173
+ # gone" means the work is done, not that this prune failed. A vanished
174
+ # file's mtime sorts as oldest, which only ever makes it MORE likely to be
175
+ # aimed at, where the unlink finds nothing to do.
176
+ def _mtime(path: Path) -> float:
177
+ try:
178
+ return path.stat().st_mtime
179
+ except OSError:
180
+ return 0.0
181
+
182
+ pattern = capture_pattern(mod_name)
183
+ found = [entry for entry in save_dir.iterdir() if pattern.match(entry.name)]
184
+ found.sort(key=lambda p: (_mtime(p), p.name))
185
+
186
+ doomed = found if keep == 0 else found[:-keep]
187
+
188
+ # VALIDATED IN FULL BEFORE ANYTHING IS UNLINKED. Checking each file as it is
189
+ # deleted would leave a half-finished prune behind whenever one of them is
190
+ # refused - some files gone, an exception raised, and no record of where it
191
+ # stopped. Deciding first and acting second means a refusal costs nothing.
192
+ #
193
+ # `iterdir` yields symlinks, and one named like a capture passes the pattern
194
+ # while resolving somewhere else entirely. `contained` is what notices.
195
+ # `missing_ok` covers only absence - see its docstring - so the validation
196
+ # is exactly as strict about names and escapes as the read beside it.
197
+ targets = [
198
+ contained(save_dir, mod_name, path.name, missing_ok=True) for path in doomed
199
+ ]
200
+
201
+ for target in targets:
202
+ target.unlink(missing_ok=True)
203
+
204
+ return sorted(path.name for path in doomed)