rimbisect 0.1.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.
- rimbisect/__init__.py +3 -0
- rimbisect/__main__.py +5 -0
- rimbisect/about.py +109 -0
- rimbisect/cli.py +449 -0
- rimbisect/errors.py +2 -0
- rimbisect/install.py +147 -0
- rimbisect/modlist.py +244 -0
- rimbisect/probe/About/About.xml +11 -0
- rimbisect/probe/Assemblies/RimbisectProbe.dll +0 -0
- rimbisect/report.py +236 -0
- rimbisect/search.py +223 -0
- rimbisect/signature.py +108 -0
- rimbisect/trial.py +424 -0
- rimbisect-0.1.0.dist-info/METADATA +317 -0
- rimbisect-0.1.0.dist-info/RECORD +18 -0
- rimbisect-0.1.0.dist-info/WHEEL +4 -0
- rimbisect-0.1.0.dist-info/entry_points.txt +2 -0
- rimbisect-0.1.0.dist-info/licenses/LICENSE +21 -0
rimbisect/__init__.py
ADDED
rimbisect/__main__.py
ADDED
rimbisect/about.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Read a mod's About/About.xml.
|
|
2
|
+
|
|
3
|
+
Lifted from rim-loadorder-agent's ingest/build.py (parse_about), switched to lxml with
|
|
4
|
+
recover=True so the unescaped ampersands and stray tags some authors ship still parse,
|
|
5
|
+
and changed to follow the game on versioned lists: a <fooByVersion><v1.6> block replaces
|
|
6
|
+
<foo> for that version instead of adding to it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from lxml import etree
|
|
16
|
+
|
|
17
|
+
_PARSER = etree.XMLParser(recover=True, remove_comments=True, resolve_entities=False)
|
|
18
|
+
_WORKSHOP_ID = re.compile(r"(\d{6,})")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Dependency:
|
|
23
|
+
package_id: str
|
|
24
|
+
alternatives: tuple[str, ...] = ()
|
|
25
|
+
name: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Mod:
|
|
30
|
+
package_id: str
|
|
31
|
+
name: str
|
|
32
|
+
folder: Path
|
|
33
|
+
workshop_id: str | None = None
|
|
34
|
+
official: bool = False
|
|
35
|
+
dependencies: list[Dependency] = field(default_factory=list)
|
|
36
|
+
load_after: list[str] = field(default_factory=list)
|
|
37
|
+
load_before: list[str] = field(default_factory=list)
|
|
38
|
+
incompatible_with: list[str] = field(default_factory=list)
|
|
39
|
+
supported_versions: list[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _texts(node, tag: str) -> list[str] | None:
|
|
43
|
+
el = node.find(tag)
|
|
44
|
+
if el is None:
|
|
45
|
+
return None
|
|
46
|
+
return [li.text.strip() for li in el.findall("li") if li.text and li.text.strip()]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _for_version(root, tag: str, version: str):
|
|
50
|
+
"""The <v1.6> element under <tagByVersion>, or None."""
|
|
51
|
+
holder = root.find(tag + "ByVersion")
|
|
52
|
+
return holder.find("v" + version) if holder is not None else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _id_list(root, tag: str, version: str) -> list[str]:
|
|
56
|
+
versioned = _for_version(root, tag, version)
|
|
57
|
+
if versioned is not None:
|
|
58
|
+
items = [li.text.strip() for li in versioned.findall("li") if li.text and li.text.strip()]
|
|
59
|
+
else:
|
|
60
|
+
items = _texts(root, tag) or []
|
|
61
|
+
return [item.lower() for item in items]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _dependencies(root, version: str) -> list[Dependency]:
|
|
65
|
+
holder = _for_version(root, "modDependencies", version)
|
|
66
|
+
if holder is None:
|
|
67
|
+
holder = root.find("modDependencies")
|
|
68
|
+
out: list[Dependency] = []
|
|
69
|
+
for li in holder.findall("li") if holder is not None else []:
|
|
70
|
+
pid = (li.findtext("packageId") or "").strip().lower()
|
|
71
|
+
if not pid:
|
|
72
|
+
continue
|
|
73
|
+
alternatives = tuple(a.lower() for a in (_texts(li, "alternativePackageIds") or []))
|
|
74
|
+
out.append(Dependency(pid, alternatives, (li.findtext("displayName") or "").strip() or None))
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _published_id(about_dir: Path) -> str | None:
|
|
79
|
+
try:
|
|
80
|
+
text = (about_dir / "PublishedFileId.txt").read_text(encoding="utf-8-sig", errors="replace")
|
|
81
|
+
except OSError:
|
|
82
|
+
return None
|
|
83
|
+
match = _WORKSHOP_ID.search(text)
|
|
84
|
+
return match.group(1) if match else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_about(path: Path, version: str, workshop_id: str | None = None, official: bool = False) -> Mod | None:
|
|
88
|
+
"""Parse About.xml; None when it has no packageId (the game skips those too)."""
|
|
89
|
+
root = etree.parse(str(path), _PARSER).getroot()
|
|
90
|
+
if root is None:
|
|
91
|
+
return None
|
|
92
|
+
package_id = (root.findtext("packageId") or "").strip().lower()
|
|
93
|
+
if not package_id:
|
|
94
|
+
return None
|
|
95
|
+
folder = path.parent.parent
|
|
96
|
+
if workshop_id is None:
|
|
97
|
+
workshop_id = _published_id(path.parent) or (folder.name if _WORKSHOP_ID.fullmatch(folder.name) else None)
|
|
98
|
+
return Mod(
|
|
99
|
+
package_id=package_id,
|
|
100
|
+
name=(root.findtext("name") or "").strip() or folder.name,
|
|
101
|
+
folder=folder,
|
|
102
|
+
workshop_id=workshop_id,
|
|
103
|
+
official=official,
|
|
104
|
+
dependencies=_dependencies(root, version),
|
|
105
|
+
load_after=_id_list(root, "loadAfter", version) + _id_list(root, "forceLoadAfter", version),
|
|
106
|
+
load_before=_id_list(root, "loadBefore", version) + _id_list(root, "forceLoadBefore", version),
|
|
107
|
+
incompatible_with=_id_list(root, "incompatibleWith", version),
|
|
108
|
+
supported_versions=_texts(root, "supportedVersions") or [],
|
|
109
|
+
)
|
rimbisect/cli.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import __version__, report
|
|
18
|
+
from .errors import UserError
|
|
19
|
+
from .install import EXE_NAME, Game, default_config_dir, find_game, process_running
|
|
20
|
+
from .modlist import PROBE_FOLDER, LoadOrder, ModsConfig, folder_mtime, read_mods_config, scan_mods
|
|
21
|
+
from .search import Inconclusive, Search, run_answered
|
|
22
|
+
from .signature import Criterion, ErrorGroup, group_errors
|
|
23
|
+
from .trial import CRASH, FAIL, PASS, GameLauncher, Trial, install_probe, remove_probe
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def log(message: str) -> None:
|
|
27
|
+
print(message, file=sys.stderr, flush=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Setup:
|
|
32
|
+
game: Game
|
|
33
|
+
config_path: Path
|
|
34
|
+
config: ModsConfig
|
|
35
|
+
order: LoadOrder
|
|
36
|
+
workdir: Path
|
|
37
|
+
warnings: list[str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load(args) -> Setup:
|
|
41
|
+
game = find_game(args.game)
|
|
42
|
+
config_path = (args.config or default_config_dir() / "ModsConfig.xml").expanduser().resolve()
|
|
43
|
+
if args.config and not config_path.is_file():
|
|
44
|
+
raise UserError(f"--config {args.config}: no such file")
|
|
45
|
+
config = read_mods_config(config_path)
|
|
46
|
+
warnings: list[str] = []
|
|
47
|
+
mods = scan_mods(game, warnings)
|
|
48
|
+
order = LoadOrder.build(config, mods, warnings)
|
|
49
|
+
workdir = getattr(args, "workdir", None) or Path(os.environ.get("LOCALAPPDATA", Path.home())) / "rimbisect"
|
|
50
|
+
workdir = workdir.expanduser().resolve()
|
|
51
|
+
return Setup(game, config_path, config, order, workdir, warnings)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _preflight(setup: Setup) -> None:
|
|
55
|
+
if process_running(EXE_NAME):
|
|
56
|
+
raise UserError("RimWorld is already running. Close it first; rimbisect starts and stops the game itself.")
|
|
57
|
+
workshop = setup.game.workshop_dir
|
|
58
|
+
if workshop and process_running("steam.exe") is False:
|
|
59
|
+
count = sum(setup.order.mods[pid].folder.parent == workshop for pid in setup.order.order)
|
|
60
|
+
if count:
|
|
61
|
+
raise UserError(f"Steam is not running, and without it the game does not load your {count} "
|
|
62
|
+
"Workshop mods. Start Steam and run rimbisect again.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@contextmanager
|
|
66
|
+
def _session(game: Game, launcher: GameLauncher):
|
|
67
|
+
"""The probe is in the game's Mods folder for the duration; the copied Config goes afterwards."""
|
|
68
|
+
try:
|
|
69
|
+
install_probe(game)
|
|
70
|
+
yield
|
|
71
|
+
finally:
|
|
72
|
+
remove_probe(game)
|
|
73
|
+
shutil.rmtree(launcher.savedata, ignore_errors=True)
|
|
74
|
+
if (game.mods_dir / PROBE_FOLDER).exists():
|
|
75
|
+
log(f"warning: could not delete {game.mods_dir / PROBE_FOLDER}; delete it before you play")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _new_run(setup: Setup, args) -> tuple[Path, GameLauncher]:
|
|
79
|
+
stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
80
|
+
run_dir = setup.workdir / "runs" / stamp
|
|
81
|
+
suffix = 1
|
|
82
|
+
while run_dir.exists():
|
|
83
|
+
suffix += 1
|
|
84
|
+
run_dir = setup.workdir / "runs" / f"{stamp}-{suffix}"
|
|
85
|
+
if "=" in str(run_dir):
|
|
86
|
+
raise UserError(f"the run folder {run_dir} contains '=', which RimWorld's -savedatafolder= "
|
|
87
|
+
"cannot take. Pick another with --workdir.")
|
|
88
|
+
try:
|
|
89
|
+
run_dir.mkdir(parents=True)
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
raise UserError(f"could not create the run folder {run_dir}: {exc.strerror or exc}. "
|
|
92
|
+
"Pick another with --workdir.") from exc
|
|
93
|
+
launcher = GameLauncher(setup.game, setup.config, run_dir, default_config_dir(),
|
|
94
|
+
settle=args.settle, timeout=args.timeout * 60, log=log)
|
|
95
|
+
return run_dir, launcher
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _criterion(args) -> Criterion | None:
|
|
99
|
+
if args.match_text == "" or args.match == "":
|
|
100
|
+
raise UserError("--match and --match-text need some text; an empty one matches every line")
|
|
101
|
+
if args.match_text is not None:
|
|
102
|
+
return Criterion(pattern=re.compile(re.escape(args.match_text)), label=args.match_text)
|
|
103
|
+
if args.match is not None:
|
|
104
|
+
try:
|
|
105
|
+
return Criterion(pattern=re.compile(args.match))
|
|
106
|
+
except re.error as exc:
|
|
107
|
+
raise UserError(f"--match is not a valid regular expression: {exc}; "
|
|
108
|
+
"use --match-text to match the text as it is") from exc
|
|
109
|
+
if args.slower_than is not None:
|
|
110
|
+
return Criterion(slower_than=args.slower_than)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
NEED_PICK = "pick one of the errors above with --pick N, or pass --match-text with a piece of it"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _pick(groups: list[ErrorGroup], args) -> ErrorGroup:
|
|
118
|
+
shown = groups[:30]
|
|
119
|
+
log("\nErrors logged by the full mod list, most frequent first:\n")
|
|
120
|
+
for i, group in enumerate(shown, 1):
|
|
121
|
+
log(f" {i:>3}. {group.count:>5}x {group.headline[:140]}")
|
|
122
|
+
if len(groups) > len(shown):
|
|
123
|
+
log(f" ... and {len(groups) - len(shown)} rarer ones")
|
|
124
|
+
if args.pick is not None:
|
|
125
|
+
if not 1 <= args.pick <= len(groups):
|
|
126
|
+
raise UserError(f"--pick {args.pick}: pick a number between 1 and {len(groups)}")
|
|
127
|
+
return groups[args.pick - 1]
|
|
128
|
+
if not sys.stdin.isatty():
|
|
129
|
+
raise UserError(NEED_PICK)
|
|
130
|
+
while True:
|
|
131
|
+
try:
|
|
132
|
+
answer = input(f"\nWhich error should rimbisect hunt? [1-{len(shown)}] ").strip()
|
|
133
|
+
except EOFError:
|
|
134
|
+
raise UserError(NEED_PICK) from None
|
|
135
|
+
if answer.isdigit() and 1 <= int(answer) <= len(groups):
|
|
136
|
+
return groups[int(answer) - 1]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _keep(setup: Setup, extra: list[str]) -> set[str]:
|
|
140
|
+
keep = set(setup.order.official())
|
|
141
|
+
for entry in extra:
|
|
142
|
+
for pid in filter(None, (p.strip().lower() for p in entry.split(","))):
|
|
143
|
+
if pid not in setup.order.spelling:
|
|
144
|
+
raise UserError(f"--keep {pid}: not in the active mod list. `rimbisect mods` lists the packageIds.")
|
|
145
|
+
keep.add(pid)
|
|
146
|
+
return keep
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _last_good_path(setup: Setup) -> Path:
|
|
150
|
+
return setup.workdir / "last-good.json"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _since(args) -> float | None:
|
|
154
|
+
if args.since is None:
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
return datetime.fromisoformat(args.since).timestamp()
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
raise UserError(f"--since takes a date like 2026-09-01 or 2026-09-01T18:30, not {args.since!r}") from exc
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _changed(setup: Setup, candidates: list[str], args) -> tuple[list[str] | None, str | None]:
|
|
163
|
+
"""Mods modified since --since or since the last passing `rimbisect check`, and which of the two."""
|
|
164
|
+
mods = setup.order.mods
|
|
165
|
+
since = _since(args)
|
|
166
|
+
if since is not None:
|
|
167
|
+
changed = [p for p in candidates if folder_mtime(mods[p].folder) > since]
|
|
168
|
+
source = f"since {args.since}"
|
|
169
|
+
else:
|
|
170
|
+
path = _last_good_path(setup)
|
|
171
|
+
if args.ignore_last_good or not path.is_file():
|
|
172
|
+
return None, None
|
|
173
|
+
try:
|
|
174
|
+
saved = json.loads(path.read_text(encoding="utf-8"))["mods"]
|
|
175
|
+
if not all(isinstance(t, (int, float)) for t in saved.values()):
|
|
176
|
+
raise ValueError("folder times are not numbers")
|
|
177
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
|
|
178
|
+
setup.warnings.append(f"ignored {path}: {exc}")
|
|
179
|
+
return None, None
|
|
180
|
+
# Folder times from a copy or restore can shift by a second or two.
|
|
181
|
+
changed = [p for p in candidates if p not in saved or folder_mtime(mods[p].folder) > saved[p] + 2]
|
|
182
|
+
source = "since the last good run"
|
|
183
|
+
log(f"{len(changed)} of {len(candidates)} mods changed {source}")
|
|
184
|
+
if not changed or len(changed) == len(candidates):
|
|
185
|
+
return None, None
|
|
186
|
+
return changed, source
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _save_last_good(setup: Setup) -> Path:
|
|
190
|
+
path = _last_good_path(setup)
|
|
191
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
data = {
|
|
193
|
+
"saved": datetime.now().isoformat(timespec="seconds"),
|
|
194
|
+
"modsConfig": str(setup.config_path),
|
|
195
|
+
"mods": {pid: folder_mtime(setup.order.mods[pid].folder) for pid in setup.order.order},
|
|
196
|
+
}
|
|
197
|
+
path.write_text(json.dumps(data, indent=1), encoding="utf-8")
|
|
198
|
+
return path
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _baseline(launcher: GameLauncher, setup: Setup, criterion: Criterion | None, args) -> Trial:
|
|
202
|
+
"""The full list; with --repeats, up to that many times until it shows the error."""
|
|
203
|
+
mods = setup.order.trial_list(setup.order.order)
|
|
204
|
+
trial = run_answered(launcher, mods, "baseline", criterion, args.crash_is_fail, log)
|
|
205
|
+
if criterion is None:
|
|
206
|
+
return trial
|
|
207
|
+
log(trial.line())
|
|
208
|
+
for _ in range(args.repeats - 1):
|
|
209
|
+
if _reproduced(trial, args):
|
|
210
|
+
break
|
|
211
|
+
trial = run_answered(launcher, mods, "baseline again", criterion, args.crash_is_fail, log)
|
|
212
|
+
log(trial.line())
|
|
213
|
+
return trial
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _warn_not_loaded(trial: Trial, setup: Setup) -> None:
|
|
217
|
+
if trial.not_loaded:
|
|
218
|
+
names = ", ".join(setup.order.written(trial.not_loaded[:10]))
|
|
219
|
+
setup.warnings.append(f"the game did not load {len(trial.not_loaded)} of the active mods even with the "
|
|
220
|
+
f"full list ({names}), so they are not tested")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _reproduced(trial: Trial, args) -> bool:
|
|
224
|
+
return trial.outcome == FAIL or (args.crash_is_fail and trial.outcome == CRASH)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _hunted(baseline: Trial, criterion: Criterion | None, args) -> Criterion | None:
|
|
228
|
+
"""The criterion to search with, or None when the baseline did not reproduce anything."""
|
|
229
|
+
if criterion is not None:
|
|
230
|
+
return criterion if _reproduced(baseline, args) else None
|
|
231
|
+
if args.crash_is_fail and args.pick is None:
|
|
232
|
+
return Criterion() if baseline.outcome == CRASH else None
|
|
233
|
+
groups = group_errors(baseline.errors)
|
|
234
|
+
if not groups:
|
|
235
|
+
return None
|
|
236
|
+
chosen = _pick(groups, args)
|
|
237
|
+
baseline.outcome, baseline.excerpt = FAIL, chosen.example
|
|
238
|
+
return Criterion(signature=chosen.signature, label=chosen.headline)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _emit(data: dict, run_dir: Path, as_json: bool) -> None:
|
|
242
|
+
text = report.write(data, run_dir)
|
|
243
|
+
if as_json:
|
|
244
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
245
|
+
else:
|
|
246
|
+
print(text, end="")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def cmd_bisect(args) -> int:
|
|
250
|
+
setup = load(args)
|
|
251
|
+
_preflight(setup)
|
|
252
|
+
keep = _keep(setup, args.keep)
|
|
253
|
+
kept = setup.order.closure(keep)
|
|
254
|
+
candidates = [pid for pid in setup.order.order if pid not in kept]
|
|
255
|
+
if not candidates:
|
|
256
|
+
raise UserError("every active mod is kept (Core, DLCs, --keep and what they need); there is nothing to search")
|
|
257
|
+
criterion = _criterion(args)
|
|
258
|
+
if args.pick is not None and criterion is not None:
|
|
259
|
+
raise UserError("--pick chooses from the errors of the full list; leave it out with --match, "
|
|
260
|
+
"--match-text or --slower-than")
|
|
261
|
+
_since(args)
|
|
262
|
+
log(f"rimbisect {__version__}: {len(setup.order.order)} active mods, {len(candidates)} to search")
|
|
263
|
+
changed, changed_since = _changed(setup, candidates, args)
|
|
264
|
+
run_dir, launcher = _new_run(setup, args)
|
|
265
|
+
log(f"run folder {run_dir}")
|
|
266
|
+
|
|
267
|
+
fields = dict(run_dir=run_dir, game=setup.game, config_path=setup.config_path, order=setup.order,
|
|
268
|
+
searched=len(candidates), warnings=setup.warnings, trials=launcher.trials,
|
|
269
|
+
crash_is_fail=args.crash_is_fail, settle=args.settle, changed_since=changed_since)
|
|
270
|
+
search: Search | None = None
|
|
271
|
+
with _session(setup.game, launcher):
|
|
272
|
+
try:
|
|
273
|
+
baseline = _baseline(launcher, setup, criterion, args)
|
|
274
|
+
_warn_not_loaded(baseline, setup)
|
|
275
|
+
hunted = _hunted(baseline, criterion, args)
|
|
276
|
+
if criterion is None:
|
|
277
|
+
# Only now is it known whether the picked error counts as a failure.
|
|
278
|
+
log(baseline.line())
|
|
279
|
+
if hunted is None:
|
|
280
|
+
what = "the error" if criterion else "a crash" if args.crash_is_fail and args.pick is None else "any error"
|
|
281
|
+
setup.warnings.append(f"the full mod list did not reproduce {what} (baseline outcome {baseline.outcome})")
|
|
282
|
+
_emit(report.build(status=report.NOT_REPRODUCED, criterion=criterion, **fields), run_dir, args.json)
|
|
283
|
+
return 1
|
|
284
|
+
criterion = hunted
|
|
285
|
+
steps = math.ceil(math.log2(len(candidates))) if len(candidates) > 1 else 0
|
|
286
|
+
# Offsets measured with acceptance/simulate.py at 64, 200 and 400 mods.
|
|
287
|
+
log(f"the full list took {baseline.duration:.0f}s; expect about {steps + 3} more trials "
|
|
288
|
+
f"for a single culprit, {2 * steps + 4} for two mods that only fail together")
|
|
289
|
+
search = Search(setup.order, launcher, criterion, keep, repeats=args.repeats,
|
|
290
|
+
crash_is_fail=args.crash_is_fail, log=log)
|
|
291
|
+
search.record(candidates, True)
|
|
292
|
+
causes = search.locate_all(candidates, changed)
|
|
293
|
+
except KeyboardInterrupt:
|
|
294
|
+
log("interrupted; the game has been closed")
|
|
295
|
+
_emit(report.build(status=report.INTERRUPTED, criterion=criterion, changed=changed,
|
|
296
|
+
causes=search.groups if search else None,
|
|
297
|
+
flaky=search.flaky if search else None, **fields), run_dir, args.json)
|
|
298
|
+
return 130
|
|
299
|
+
except Inconclusive as exc:
|
|
300
|
+
log(f"stopping: {exc}")
|
|
301
|
+
setup.warnings.append(f"{exc}. {exc.advice}")
|
|
302
|
+
_emit(report.build(status=report.INCONCLUSIVE, criterion=criterion, changed=changed,
|
|
303
|
+
causes=search.groups if search else None,
|
|
304
|
+
flaky=search.flaky if search else None, **fields), run_dir, args.json)
|
|
305
|
+
return 1
|
|
306
|
+
status = report.FOUND if causes else report.BASE_GAME_FAILS
|
|
307
|
+
_emit(report.build(status=status, criterion=criterion, causes=causes, base_fails=search.base_fails,
|
|
308
|
+
flaky=search.flaky, changed=changed, **fields), run_dir, args.json)
|
|
309
|
+
return 0 if causes else 1
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def cmd_check(args) -> int:
|
|
313
|
+
setup = load(args)
|
|
314
|
+
_preflight(setup)
|
|
315
|
+
criterion = _criterion(args)
|
|
316
|
+
run_dir, launcher = _new_run(setup, args)
|
|
317
|
+
log(f"rimbisect {__version__}: one trial with all {len(setup.order.order)} active mods")
|
|
318
|
+
fields = dict(run_dir=run_dir, game=setup.game, config_path=setup.config_path, order=setup.order,
|
|
319
|
+
criterion=criterion, trials=launcher.trials, settle=args.settle, warnings=setup.warnings)
|
|
320
|
+
with _session(setup.game, launcher):
|
|
321
|
+
try:
|
|
322
|
+
trial = launcher.run(setup.order.trial_list(setup.order.order), "check", criterion)
|
|
323
|
+
except KeyboardInterrupt:
|
|
324
|
+
log("interrupted; the game has been closed")
|
|
325
|
+
_emit(report.build(status=report.INTERRUPTED, **fields), run_dir, args.json)
|
|
326
|
+
return 130
|
|
327
|
+
log(trial.line())
|
|
328
|
+
_warn_not_loaded(trial, setup)
|
|
329
|
+
passed = trial.outcome == PASS
|
|
330
|
+
if passed:
|
|
331
|
+
log(f"saved this mod list as the last good one: {_save_last_good(setup)}")
|
|
332
|
+
status = report.CHECK_PASSED if passed else report.CHECK_FAILED
|
|
333
|
+
_emit(report.build(status=status, errors=group_errors(trial.errors), **fields), run_dir, args.json)
|
|
334
|
+
return 0 if passed else 1
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def cmd_mods(args) -> int:
|
|
338
|
+
setup = load(args)
|
|
339
|
+
order = setup.order
|
|
340
|
+
rows = []
|
|
341
|
+
for pid in order.order:
|
|
342
|
+
mod = order.mods[pid]
|
|
343
|
+
if mod.official:
|
|
344
|
+
source = "official"
|
|
345
|
+
else:
|
|
346
|
+
source = "workshop" if setup.game.workshop_dir and mod.folder.parent == setup.game.workshop_dir else "local"
|
|
347
|
+
rows.append({"packageId": order.spelling[pid], "name": mod.name, "source": source,
|
|
348
|
+
"workshopId": mod.workshop_id, "folder": str(mod.folder),
|
|
349
|
+
"dependencies": [d.package_id for d in mod.dependencies]})
|
|
350
|
+
if args.json:
|
|
351
|
+
print(json.dumps({"game": {"folder": str(setup.game.root), "version": setup.game.version},
|
|
352
|
+
"modsConfig": str(setup.config_path), "mods": rows, "warnings": setup.warnings},
|
|
353
|
+
indent=2, ensure_ascii=False))
|
|
354
|
+
return 0
|
|
355
|
+
print(f"{setup.game.root} ({setup.game.version})")
|
|
356
|
+
print(f"{setup.config_path}: {len(rows)} active mods\n")
|
|
357
|
+
print(f"{'#':>4} {'packageId':<45} {'source':<9} {'workshop':<11} name")
|
|
358
|
+
for i, row in enumerate(rows, 1):
|
|
359
|
+
print(f"{i:>4} {row['packageId']:<45} {row['source']:<9} {row['workshopId'] or '-':<11} {row['name']}")
|
|
360
|
+
if setup.warnings:
|
|
361
|
+
print(f"\n{len(setup.warnings)} warning(s):")
|
|
362
|
+
for w in setup.warnings:
|
|
363
|
+
print(f" - {w}")
|
|
364
|
+
return 0
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _above_zero(kind):
|
|
368
|
+
def parse(text: str):
|
|
369
|
+
try:
|
|
370
|
+
value = kind(text)
|
|
371
|
+
except ValueError:
|
|
372
|
+
raise argparse.ArgumentTypeError(f"not a {'whole ' if kind is int else ''}number: {text!r}") from None
|
|
373
|
+
if not (math.isfinite(value) and value > 0):
|
|
374
|
+
raise argparse.ArgumentTypeError(f"has to be more than 0, not {text}")
|
|
375
|
+
return value
|
|
376
|
+
return parse
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _parser() -> argparse.ArgumentParser:
|
|
380
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
381
|
+
common.add_argument("--game", type=Path, metavar="PATH",
|
|
382
|
+
help="RimWorld install folder (default: found through Steam)")
|
|
383
|
+
common.add_argument("--config", type=Path, metavar="FILE",
|
|
384
|
+
help="ModsConfig.xml to test (default: the game's own). Only read, never written.")
|
|
385
|
+
common.add_argument("--json", action="store_true", help="print JSON instead of text")
|
|
386
|
+
|
|
387
|
+
trial_opts = argparse.ArgumentParser(add_help=False)
|
|
388
|
+
trial_opts.add_argument("--workdir", type=Path, metavar="PATH",
|
|
389
|
+
help=r"where runs and last-good.json go (default: %%LOCALAPPDATA%%\rimbisect)")
|
|
390
|
+
what = trial_opts.add_mutually_exclusive_group()
|
|
391
|
+
what.add_argument("--match", metavar="REGEX", help="a trial fails when an error or log line matches REGEX")
|
|
392
|
+
what.add_argument("--match-text", metavar="TEXT",
|
|
393
|
+
help="a trial fails when an error or log line contains TEXT, taken literally")
|
|
394
|
+
what.add_argument("--slower-than", type=_above_zero(float), metavar="SECONDS",
|
|
395
|
+
help="a trial fails when the map takes longer than SECONDS to be ready")
|
|
396
|
+
trial_opts.add_argument("--settle", type=_above_zero(float), default=20, metavar="SECONDS",
|
|
397
|
+
help="game seconds to keep the map running before quitting (default 20)")
|
|
398
|
+
trial_opts.add_argument("--timeout", type=_above_zero(float), default=20, metavar="MINUTES",
|
|
399
|
+
help="give up on a trial after this many minutes (default 20)")
|
|
400
|
+
|
|
401
|
+
parser = argparse.ArgumentParser(
|
|
402
|
+
prog="rimbisect", description="Find the RimWorld mod, or combination of mods, behind an error.")
|
|
403
|
+
parser.add_argument("--version", action="version", version=f"rimbisect {__version__}")
|
|
404
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
405
|
+
|
|
406
|
+
what = "run the game on halves of your mod list until the culprit is found"
|
|
407
|
+
bisect = sub.add_parser("bisect", parents=[common, trial_opts], help=what, description=what)
|
|
408
|
+
bisect.add_argument("--pick", type=_above_zero(int), metavar="N",
|
|
409
|
+
help="hunt error N from the full list's errors without asking")
|
|
410
|
+
bisect.add_argument("--keep", action="append", default=[], metavar="ID[,ID]",
|
|
411
|
+
help="packageIds to load in every trial (Core and DLCs always are)")
|
|
412
|
+
bisect.add_argument("--since", metavar="DATE",
|
|
413
|
+
help="first try the list without mods changed after DATE, like 2026-09-01 or 2026-09-01T18:30")
|
|
414
|
+
bisect.add_argument("--ignore-last-good", action="store_true",
|
|
415
|
+
help="do not try the mods changed since the last good run first")
|
|
416
|
+
bisect.add_argument("--repeats", type=_above_zero(int), default=1, metavar="N",
|
|
417
|
+
help="run a passing list up to N times before trusting it, for errors that come and go")
|
|
418
|
+
bisect.add_argument("--crash-is-fail", action="store_true",
|
|
419
|
+
help="count a crash, an early exit or a map that fails to generate as the error")
|
|
420
|
+
bisect.set_defaults(func=cmd_bisect)
|
|
421
|
+
|
|
422
|
+
what = "run the full list once and, if it gets through, save it as the last good one"
|
|
423
|
+
check = sub.add_parser("check", parents=[common, trial_opts], help=what, description=what)
|
|
424
|
+
check.set_defaults(func=cmd_check)
|
|
425
|
+
|
|
426
|
+
what = "show the active mod list as rimbisect reads it, with warnings"
|
|
427
|
+
mods = sub.add_parser("mods", parents=[common], help=what, description=what)
|
|
428
|
+
mods.set_defaults(func=cmd_mods)
|
|
429
|
+
return parser
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def main(argv: list[str] | None = None) -> int:
|
|
433
|
+
# Redirected output would otherwise use the ANSI code page and choke on mod names.
|
|
434
|
+
for stream in (sys.stdout, sys.stderr):
|
|
435
|
+
if hasattr(stream, "reconfigure"):
|
|
436
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
437
|
+
args = _parser().parse_args(argv)
|
|
438
|
+
try:
|
|
439
|
+
return args.func(args)
|
|
440
|
+
except UserError as exc:
|
|
441
|
+
print(f"rimbisect: error: {exc}", file=sys.stderr)
|
|
442
|
+
return 2
|
|
443
|
+
except KeyboardInterrupt:
|
|
444
|
+
print("rimbisect: interrupted", file=sys.stderr)
|
|
445
|
+
return 130
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
if __name__ == "__main__":
|
|
449
|
+
sys.exit(main())
|
rimbisect/errors.py
ADDED