-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathsandbox.py
More file actions
229 lines (195 loc) · 8.4 KB
/
Copy pathsandbox.py
File metadata and controls
229 lines (195 loc) · 8.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
"""
sandbox.py — the single source of sandbox truth for verify.py.
Owns the ``.debug/`` sandbox bootstrap (the mkdir tree, the live working-tree
symlink, the copied mod-list.json / mod-settings.dat, and the generated
config.ini) so every verify.py layer (load, behavior, debug) stands up exactly
the same isolated Factorio environment without re-implementing it or drifting.
Paths are derived from this file's own location (its parent is the mod root);
nothing is read from environment variables.
"""
import json
import subprocess
from dataclasses import dataclass
from pathlib import Path
# ---------------------------------------------------------------------------
# Self-locating paths (derived from this file's own location)
# ---------------------------------------------------------------------------
ROOT = Path(__file__).resolve().parent
FACTORIO_BIN = (ROOT / ".." / ".." / "bin" / "x64" / "factorio").resolve()
MODS_DEV_DIR = (ROOT / "..").resolve()
INFO = json.loads((ROOT / "info.json").read_text())
VERSIONED_NAME = f"{INFO['name']}_{INFO['version']}"
DEBUG_DIR = ROOT / ".debug"
MODS_DIR = DEBUG_DIR / "mods"
SAVES_DIR = DEBUG_DIR / "saves"
CONFIG_DIR = DEBUG_DIR / "config"
SCRIPT_OUTPUT_DIR = DEBUG_DIR / "script-output"
SAVE_FILE = SAVES_DIR / "debug-save.zip"
CONFIG_FILE = CONFIG_DIR / "config.ini"
GAME_LOG = DEBUG_DIR / "factorio-current.log"
CONSOLE_LOG = DEBUG_DIR / "console.log"
RCON_PORT = 27015
RCON_PASS = "factorio-debug"
CONFIG_TEMPLATE = """\
; Factorio debug config — generated by sandbox.py (verify.py)
; https://wiki.factorio.com/Factorio_wiki
[path]
read-data=__PATH__executable__/../../data
write-data={write_data}
"""
@dataclass(frozen=True)
class Sandbox:
"""Resolved paths for the bootstrapped ``.debug/`` sandbox."""
debug_dir: Path
mods_dir: Path
saves_dir: Path
config_dir: Path
config_file: Path
save_file: Path
game_log: Path
console_log: Path
script_output_dir: Path
mod_symlink: Path
rcon_port: int
rcon_password: str
def bootstrap_sandbox(clean: bool = False) -> Sandbox:
"""Stand up the ``.debug/`` sandbox.
- Creates the mods/saves/config/script-output tree.
- Refreshes the live working tree symlink at ``.debug/mods/<VERSIONED_NAME>``
(always re-pointed in case the version changed; stale differently-versioned
symlinks are removed so only the current version is present).
- Copies ``../mod-list.json`` (required) and ``../mod-settings.dat`` (optional)
into the sandbox mods dir.
- Writes the generated ``config.ini`` pointing write-data at ``.debug/``.
When ``clean`` is True the existing save is deleted so the next ``--create``
rebuilds it from scratch.
"""
for directory in (MODS_DIR, SAVES_DIR, CONFIG_DIR, SCRIPT_OUTPUT_DIR):
directory.mkdir(parents=True, exist_ok=True)
# Remove stale, differently-versioned creative-mod symlinks so the sandbox
# only ever exposes the current version.
prefix = f"{INFO['name']}_"
for entry in MODS_DIR.iterdir():
if entry.name.startswith(prefix) and entry.name != VERSIONED_NAME and entry.is_symlink():
entry.unlink()
# Always (re)point the live-tree symlink (an idempotent `ln -sfn`).
mod_symlink = MODS_DIR / VERSIONED_NAME
if mod_symlink.is_symlink() or mod_symlink.exists():
mod_symlink.unlink()
mod_symlink.symlink_to(ROOT)
# Copy mod-list.json (required) and mod-settings.dat (optional).
src_mod_list = MODS_DEV_DIR / "mod-list.json"
if not src_mod_list.is_file():
raise FileNotFoundError(f"mod-list.json not found at {src_mod_list}")
(MODS_DIR / "mod-list.json").write_bytes(src_mod_list.read_bytes())
src_mod_settings = MODS_DEV_DIR / "mod-settings.dat"
if src_mod_settings.is_file():
(MODS_DIR / "mod-settings.dat").write_bytes(src_mod_settings.read_bytes())
# Write the generated config.ini (write-data -> .debug/).
CONFIG_FILE.write_text(CONFIG_TEMPLATE.format(write_data=DEBUG_DIR))
if clean and SAVE_FILE.exists():
SAVE_FILE.unlink()
return Sandbox(
debug_dir=DEBUG_DIR,
mods_dir=MODS_DIR,
saves_dir=SAVES_DIR,
config_dir=CONFIG_DIR,
config_file=CONFIG_FILE,
save_file=SAVE_FILE,
game_log=GAME_LOG,
console_log=CONSOLE_LOG,
script_output_dir=SCRIPT_OUTPUT_DIR,
mod_symlink=mod_symlink,
rcon_port=RCON_PORT,
rcon_password=RCON_PASS,
)
def run_create(sandbox: Sandbox, timeout: float) -> str:
"""Run the bounded ``--create`` data+control stage and return the log text.
Runs the ``--create <save> --disable-audio`` step always under a hard timeout
so the call returns. The captured combined stdout/stderr is the same stream
that otherwise lands in factorio-current.log.
``--create`` always runs (it overwrites an existing save in place): the
data+control stage *is* the load test, and re-running it is what produces a
fresh ``CREATIVE_MOD_CONTROL_OK`` sentinel and a fresh error scan. It stays
fast because creating an empty map is cheap, and reuses the same save path
(the sandbox/symlink/config are reused — only the map run repeats).
"""
try:
proc = subprocess.run(
[
str(FACTORIO_BIN),
"--config",
str(sandbox.config_file),
"--mod-directory",
str(sandbox.mods_dir),
"--create",
str(sandbox.save_file),
"--disable-audio",
],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
captured = (exc.stdout or "") + (exc.stderr or "")
if isinstance(captured, bytes):
captured = captured.decode("utf-8", "replace")
return captured + f"\nError verify.py: --create timed out after {timeout}s\n"
# The subprocess stdout/stderr is the authoritative single-run stream (the
# same text that lands in the log) — use it directly so a stale
# factorio-current.log from an unrelated prior run can never leak in.
return (proc.stdout or "") + "\n" + (proc.stderr or "")
def start_server(sandbox: Sandbox) -> subprocess.Popen:
"""Launch the headless server non-interactively and return the Popen handle.
Uses the standard ``--start-server`` invocation (config + mod dir + RCON
port/password + console log + disabled audio), but as a background process
the caller owns: verify.py polls RCON until the server answers, runs its
assertion batch, then terminates and reaps this handle under a watchdog.
This never ``exec``s/blocks — the process is detached into a new session so
the whole tree (and any children) can be signalled and reaped cleanly even
if the call is interrupted.
"""
return subprocess.Popen( # noqa: S603 — fixed, self-located argv; no shell
[
str(FACTORIO_BIN),
"--config",
str(sandbox.config_file),
"--mod-directory",
str(sandbox.mods_dir),
"--start-server",
str(sandbox.save_file),
"--rcon-port",
str(sandbox.rcon_port),
"--rcon-password",
sandbox.rcon_password,
"--console-log",
str(sandbox.console_log),
"--disable-audio",
],
# Detach from our stdin: the headless server reads its console from
# stdin and would otherwise steal the REPL's piped input (shell mode).
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
start_new_session=True,
)
def start_gui(sandbox: Sandbox) -> subprocess.Popen:
"""Launch the full graphical client against the debug save (manual escape hatch).
Launches the interactive client with ``--load-game`` against the sandbox
save and the same config/mod directory. This is a manual-only
tool — it blocks on the GUI and needs a graphical display — so it is
deliberately NOT bounded or reaped here; the caller waits on it.
"""
return subprocess.Popen( # noqa: S603 — fixed, self-located argv; no shell
[
str(FACTORIO_BIN),
"--config",
str(sandbox.config_file),
"--mod-directory",
str(sandbox.mods_dir),
"--load-game",
str(sandbox.save_file),
],
)