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.
- tmodloader_mcp/__init__.py +0 -0
- tmodloader_mcp/api.py +262 -0
- tmodloader_mcp/build.py +141 -0
- tmodloader_mcp/captures.py +204 -0
- tmodloader_mcp/commands.py +182 -0
- tmodloader_mcp/config.py +565 -0
- tmodloader_mcp/diag.py +201 -0
- tmodloader_mcp/heartbeat.py +197 -0
- tmodloader_mcp/inventory.py +130 -0
- tmodloader_mcp/logs.py +453 -0
- tmodloader_mcp/saves.py +469 -0
- tmodloader_mcp/server.py +1667 -0
- tmodloader_mcp/session.py +2716 -0
- tmodloader_mcp/triggers.py +542 -0
- tmodloader_mcp-0.6.0.dist-info/METADATA +526 -0
- tmodloader_mcp-0.6.0.dist-info/RECORD +19 -0
- tmodloader_mcp-0.6.0.dist-info/WHEEL +4 -0
- tmodloader_mcp-0.6.0.dist-info/entry_points.txt +2 -0
- tmodloader_mcp-0.6.0.dist-info/licenses/LICENSE +21 -0
|
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
|
+
)
|
tmodloader_mcp/build.py
ADDED
|
@@ -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)
|