modwright 0.1.4 → 0.1.5
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.
- package/dist/core/build/run.js +5 -0
- package/dist/core/deploy/plan.js +61 -0
- package/dist/core/safety/retention.js +76 -0
- package/dist/core/writes/index.js +9 -0
- package/dist/index.js +42 -2
- package/dist/server.js +77 -5
- package/dist/surfaces/baldursgate3/validators/bridge.js +1 -1
- package/dist/surfaces/baldursgate3/validators/templates.js +1 -1
- package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +29 -0
- package/dist/surfaces/cyberpunk2077/validators/bridge.js +1 -1
- package/dist/surfaces/cyberpunk2077/validators/cr2w.js +1 -1
- package/dist/surfaces/cyberpunk2077/validators/modsettings.js +1 -1
- package/dist/surfaces/cyberpunk2077/validators/templates.js +1 -1
- package/dist/surfaces/cyberpunk2077/validators/tweaks.js +11 -4
- package/dist/surfaces/cyberpunk2077/writes.js +8 -3
- package/dist/surfaces/skyrimse/validators/archives.js +9 -7
- package/dist/surfaces/skyrimse/validators/plugins.js +28 -21
- package/knowledge/cyberpunk2077/tweakxl.yaml-syntax.yaml +21 -1
- package/package.json +1 -1
package/dist/core/build/run.js
CHANGED
|
@@ -5,6 +5,7 @@ import { normalizeBuildSteps } from "../project/build-steps.js";
|
|
|
5
5
|
import { outsideRootPaths } from "../project/outside-root.js";
|
|
6
6
|
import { locateTool as coreLocateTool, TOOL_SPECS } from "../toolchain/index.js";
|
|
7
7
|
import { describeNeeds, isProjectTrusted, trustRefusal } from "../trust.js";
|
|
8
|
+
import { pruneDated, STAGE_RETENTION } from "../safety/retention.js";
|
|
8
9
|
import { buildContext, runValidators } from "../validate/index.js";
|
|
9
10
|
import { runAssertAbsentStep, runAssertPresentStep, runExecStep, runZipStep } from "./steps/core.js";
|
|
10
11
|
import { runBlenderExportStep, runVerifyArchiveListingStep, runWolvenkitImportStep, runWolvenkitPackStep, } from "./steps/cyberpunk.js";
|
|
@@ -187,6 +188,7 @@ export async function runBuild(project, surface, options) {
|
|
|
187
188
|
? path.resolve(project.root, project.build.staging)
|
|
188
189
|
: path.join(project.root, ".modwright", "cache", "build");
|
|
189
190
|
const stageRoot = path.join(stagingDir, buildStamp());
|
|
191
|
+
const stagesPruned = options.mode === "apply" ? (await pruneDated(stagingDir, "stage", STAGE_RETENTION, { mode: "apply" })).expired : [];
|
|
190
192
|
const outDirAbs = project.build.absOutDir;
|
|
191
193
|
const locateTool = options.locateTool ?? defaultLocateTool(project, options.mode);
|
|
192
194
|
const projectExclude = project.build.exclude ?? [];
|
|
@@ -310,6 +312,7 @@ export async function runBuild(project, surface, options) {
|
|
|
310
312
|
failureReason,
|
|
311
313
|
...(failedStep ? { failedStep } : {}),
|
|
312
314
|
skippedOptional,
|
|
315
|
+
...(stagesPruned.length > 0 ? { stagesPruned } : {}),
|
|
313
316
|
...(options.mode === "dry-run" ? { skippedVariant } : {}),
|
|
314
317
|
outDirAbs,
|
|
315
318
|
};
|
|
@@ -323,6 +326,7 @@ export async function runBuild(project, surface, options) {
|
|
|
323
326
|
plan: dryRunPlan,
|
|
324
327
|
failed: false,
|
|
325
328
|
skippedOptional,
|
|
329
|
+
...(stagesPruned.length > 0 ? { stagesPruned } : {}),
|
|
326
330
|
skippedVariant,
|
|
327
331
|
outDirAbs,
|
|
328
332
|
};
|
|
@@ -351,6 +355,7 @@ export async function runBuild(project, surface, options) {
|
|
|
351
355
|
manifestPath,
|
|
352
356
|
failed: false,
|
|
353
357
|
skippedOptional,
|
|
358
|
+
...(stagesPruned.length > 0 ? { stagesPruned } : {}),
|
|
354
359
|
outDirAbs,
|
|
355
360
|
};
|
|
356
361
|
}
|
package/dist/core/deploy/plan.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import { isDirectory, pathExists, walk } from "../fsutil.js";
|
|
4
4
|
import { globToRegExp, isExcludedByGlobs } from "../validate/index.js";
|
|
5
5
|
import { outsideRootPaths } from "../project/outside-root.js";
|
|
6
|
+
import { BACKUP_RETENTION, countBackupRuns } from "../safety/retention.js";
|
|
6
7
|
import { findRunningProcesses } from "../process.js";
|
|
7
8
|
import { executePlan, isIdenticalFile, planCopy, planDelete, planJunction, planMkdir, planUnlink, sha256File } from "../safety/index.js";
|
|
8
9
|
import { classifyReload } from "./reload.js";
|
|
@@ -116,8 +117,14 @@ export async function planDeploy(inputs) {
|
|
|
116
117
|
const mode = inputs.mode ?? "dry-run";
|
|
117
118
|
const deployMode = inputs.deployMode ?? "pak";
|
|
118
119
|
const backupRoot = path.join(project.root, ".modwright", "backups");
|
|
120
|
+
const backupRuns = await countBackupRuns(backupRoot);
|
|
121
|
+
const backupWarning = backupRuns > BACKUP_RETENTION.keepRuns
|
|
122
|
+
? `${backupRuns} backup runs under ${backupRoot}; rollback action=prune keeps the newest ${BACKUP_RETENTION.keepRuns} and anything under ${BACKUP_RETENTION.maxAgeDays} days old.`
|
|
123
|
+
: undefined;
|
|
119
124
|
const blocked = [];
|
|
120
125
|
const warnings = [];
|
|
126
|
+
if (backupWarning)
|
|
127
|
+
warnings.push(backupWarning);
|
|
121
128
|
const assumptions = [];
|
|
122
129
|
const verifiedBy = [];
|
|
123
130
|
const targets = [];
|
|
@@ -535,6 +542,17 @@ export async function planDeploy(inputs) {
|
|
|
535
542
|
...toWrite.filter((t) => !t.locked).flatMap((t) => t.copies.map((c) => planCopy(c.from, c.to))),
|
|
536
543
|
...toWrite.filter((t) => t.locked).flatMap((t) => t.copies.map((c) => planCopy(c.from, c.to))),
|
|
537
544
|
];
|
|
545
|
+
const throughLinks = await writesThroughLinks(operations, roots, junctions.map((j) => j.linkPath));
|
|
546
|
+
if (throughLinks.size > 0) {
|
|
547
|
+
blocked.push({
|
|
548
|
+
code: "write-through-link",
|
|
549
|
+
message: `${[...throughLinks.keys()].join(", ")} ${throughLinks.size === 1 ? "is a link" : "are links"} inside a mod root, and this deploy would write through ` +
|
|
550
|
+
`${throughLinks.size === 1 ? "it" : "them"} into wherever ${throughLinks.size === 1 ? "it points" : "they point"} ` +
|
|
551
|
+
`(${[...throughLinks.values()].flat().slice(0, 3).join(", ")}${[...throughLinks.values()].flat().length > 3 ? ", ..." : ""}). ` +
|
|
552
|
+
`Remove the link, or declare it under deploy.junctions if the project owns it.`,
|
|
553
|
+
detail: { links: Object.fromEntries([...throughLinks].map(([link, paths]) => [link, paths.slice(0, 20)])) },
|
|
554
|
+
});
|
|
555
|
+
}
|
|
538
556
|
const confidence = {
|
|
539
557
|
level: assumptions.length > 0 ? "medium" : "high",
|
|
540
558
|
assumptions,
|
|
@@ -562,6 +580,49 @@ export async function planDeploy(inputs) {
|
|
|
562
580
|
backupRoot,
|
|
563
581
|
};
|
|
564
582
|
}
|
|
583
|
+
async function writesThroughLinks(operations, roots, allowed) {
|
|
584
|
+
const isLink = new Map();
|
|
585
|
+
const linkAt = async (dir) => {
|
|
586
|
+
const key = normalizeForCompare(dir);
|
|
587
|
+
let known = isLink.get(key);
|
|
588
|
+
if (known === undefined) {
|
|
589
|
+
try {
|
|
590
|
+
known = (await fs.lstat(dir)).isSymbolicLink();
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
known = false;
|
|
594
|
+
}
|
|
595
|
+
isLink.set(key, known);
|
|
596
|
+
}
|
|
597
|
+
return known;
|
|
598
|
+
};
|
|
599
|
+
const allowedKeys = new Set(allowed.map(normalizeForCompare));
|
|
600
|
+
const rootPaths = roots.map((r) => path.resolve(r.path));
|
|
601
|
+
const found = new Map();
|
|
602
|
+
for (const op of operations) {
|
|
603
|
+
const dest = op.kind === "copy" ? op.to : "path" in op ? op.path : undefined;
|
|
604
|
+
if (!dest || op.kind === "junction" || op.kind === "unlink")
|
|
605
|
+
continue;
|
|
606
|
+
const abs = path.resolve(dest);
|
|
607
|
+
const root = rootPaths.find((r) => {
|
|
608
|
+
const rel = path.relative(r, abs);
|
|
609
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
610
|
+
});
|
|
611
|
+
if (!root)
|
|
612
|
+
continue;
|
|
613
|
+
for (let dir = path.dirname(abs); dir !== root && dir.length > root.length; dir = path.dirname(dir)) {
|
|
614
|
+
if (allowedKeys.has(normalizeForCompare(dir)))
|
|
615
|
+
break;
|
|
616
|
+
if (await linkAt(dir)) {
|
|
617
|
+
const list = found.get(dir) ?? [];
|
|
618
|
+
list.push(abs);
|
|
619
|
+
found.set(dir, list);
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return found;
|
|
625
|
+
}
|
|
565
626
|
function copyKey(from, to) {
|
|
566
627
|
return `${normalizeForCompare(from)}\0${normalizeForCompare(to)}`;
|
|
567
628
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
const BACKUP_NAME = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})(?:-\d+)?$/;
|
|
4
|
+
const STAGE_NAME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-\d{3}Z$/;
|
|
5
|
+
export const BACKUP_RETENTION = { keepRuns: 20, maxAgeDays: 14 };
|
|
6
|
+
export const STAGE_RETENTION = { keepRuns: 3, maxAgeDays: 0 };
|
|
7
|
+
function runOf(name, kind) {
|
|
8
|
+
const m = (kind === "backup" ? BACKUP_NAME : STAGE_NAME).exec(name);
|
|
9
|
+
if (!m)
|
|
10
|
+
return undefined;
|
|
11
|
+
const time = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]));
|
|
12
|
+
return { run: kind === "backup" ? name.slice(0, 15) : name, time };
|
|
13
|
+
}
|
|
14
|
+
export function selectExpired(names, kind, policy, now = new Date()) {
|
|
15
|
+
const byRun = new Map();
|
|
16
|
+
const ignored = [];
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
const r = runOf(name, kind);
|
|
19
|
+
if (!r) {
|
|
20
|
+
ignored.push(name);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const entry = byRun.get(r.run) ?? { time: r.time, names: [] };
|
|
24
|
+
entry.names.push(name);
|
|
25
|
+
byRun.set(r.run, entry);
|
|
26
|
+
}
|
|
27
|
+
const runs = [...byRun.values()].sort((a, b) => b.time - a.time);
|
|
28
|
+
const cutoff = now.getTime() - policy.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
29
|
+
const expired = [];
|
|
30
|
+
const kept = [];
|
|
31
|
+
runs.forEach((run, i) => {
|
|
32
|
+
const keep = i < policy.keepRuns || (policy.maxAgeDays > 0 && run.time >= cutoff);
|
|
33
|
+
(keep ? kept : expired).push(...run.names);
|
|
34
|
+
});
|
|
35
|
+
return { expired: expired.sort(), kept: kept.sort(), ignored: ignored.sort(), runs: runs.length };
|
|
36
|
+
}
|
|
37
|
+
async function folderNames(root) {
|
|
38
|
+
try {
|
|
39
|
+
return (await fs.readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function pruneDated(root, kind, policy, options = {}) {
|
|
46
|
+
const mode = options.mode ?? "dry-run";
|
|
47
|
+
const selection = selectExpired(await folderNames(root), kind, policy, options.now);
|
|
48
|
+
const report = {
|
|
49
|
+
mode,
|
|
50
|
+
root,
|
|
51
|
+
applied: false,
|
|
52
|
+
policy,
|
|
53
|
+
runs: selection.runs,
|
|
54
|
+
expired: selection.expired,
|
|
55
|
+
keptCount: selection.kept.length,
|
|
56
|
+
ignored: selection.ignored,
|
|
57
|
+
};
|
|
58
|
+
if (mode !== "apply")
|
|
59
|
+
return report;
|
|
60
|
+
const failed = [];
|
|
61
|
+
for (const name of selection.expired) {
|
|
62
|
+
try {
|
|
63
|
+
await fs.rm(path.join(root, name), { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
failed.push({ name, error: error.message });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
report.applied = true;
|
|
70
|
+
if (failed.length > 0)
|
|
71
|
+
report.failed = failed;
|
|
72
|
+
return report;
|
|
73
|
+
}
|
|
74
|
+
export async function countBackupRuns(root) {
|
|
75
|
+
return selectExpired(await folderNames(root), "backup", { keepRuns: Infinity, maxAgeDays: 0 }).runs;
|
|
76
|
+
}
|
|
@@ -47,6 +47,15 @@ export async function collectWrites(surface, install, options = {}) {
|
|
|
47
47
|
}
|
|
48
48
|
for (const err of extraction.errors)
|
|
49
49
|
collection.coverage.errors.push({ writerId: mod.id, ...err });
|
|
50
|
+
for (const r of extraction.repeatedKeys ?? []) {
|
|
51
|
+
(collection.repeatedKeys ??= []).push({
|
|
52
|
+
writerId: r.unit ? `${mod.id}/${r.unit}` : mod.id,
|
|
53
|
+
name: r.unit ?? mod.name,
|
|
54
|
+
file: r.file,
|
|
55
|
+
key: r.key,
|
|
56
|
+
lines: r.lines,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
50
59
|
for (const write of extraction.writes) {
|
|
51
60
|
const writerId = write.unit ? `${mod.id}/${write.unit}` : mod.id;
|
|
52
61
|
scanned.add(writerId);
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { promises as fs, watchFile, unwatchFile } from "node:fs";
|
|
3
|
+
import { appendFileSync, mkdirSync, promises as fs, watchFile, unwatchFile } from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { StringDecoder } from "node:string_decoder";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { userDataDir } from "./core/home.js";
|
|
7
8
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
const SERVER_ENTRY = process.env.MODWRIGHT_SERVER_ENTRY ?? path.join(here, "server.js");
|
|
9
10
|
const STAMP_FILE = process.env.MODWRIGHT_BUILD_STAMP ?? path.join(here, ".build-stamp");
|
|
10
11
|
const STAMP_POLL_MS = Number(process.env.MODWRIGHT_STAMP_POLL_MS ?? 2000);
|
|
11
12
|
const RETIRE_GRACE_MS = Number(process.env.MODWRIGHT_RETIRE_GRACE_MS ?? 15 * 60_000);
|
|
13
|
+
const SHUTDOWN_GRACE_MS = Number(process.env.MODWRIGHT_SHUTDOWN_GRACE_MS ?? 5000);
|
|
12
14
|
const INIT_TIMEOUT_MS = 20_000;
|
|
13
15
|
const RESTART_BASE_MS = Number(process.env.MODWRIGHT_RESTART_BASE_MS ?? 500);
|
|
14
16
|
const RESTART_MAX_DELAY_MS = 30_000;
|
|
@@ -17,6 +19,16 @@ const SUPERVISOR_INIT_ID = "modwright-supervisor-initialize";
|
|
|
17
19
|
function log(msg) {
|
|
18
20
|
process.stderr.write(`[modwright supervisor] ${msg}\n`);
|
|
19
21
|
}
|
|
22
|
+
function incident(msg) {
|
|
23
|
+
log(msg);
|
|
24
|
+
try {
|
|
25
|
+
const dir = userDataDir();
|
|
26
|
+
mkdirSync(dir, { recursive: true });
|
|
27
|
+
appendFileSync(path.join(dir, "supervisor-incidents.log"), `${new Date().toISOString()} ${msg}\n`);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
}
|
|
31
|
+
}
|
|
20
32
|
function idKey(id) {
|
|
21
33
|
return `${typeof id}:${String(id)}`;
|
|
22
34
|
}
|
|
@@ -49,6 +61,10 @@ class ServerProcess {
|
|
|
49
61
|
child;
|
|
50
62
|
generation;
|
|
51
63
|
pending = new Set();
|
|
64
|
+
pendingMethod = new Map();
|
|
65
|
+
pendingMethods() {
|
|
66
|
+
return [...this.pending].map((k) => this.pendingMethod.get(k) ?? "?");
|
|
67
|
+
}
|
|
52
68
|
retired = false;
|
|
53
69
|
exited = false;
|
|
54
70
|
splitter = new LineSplitter();
|
|
@@ -115,6 +131,7 @@ class Supervisor {
|
|
|
115
131
|
swapping = false;
|
|
116
132
|
swapQueued = false;
|
|
117
133
|
closing = false;
|
|
134
|
+
retiredServers = new Set();
|
|
118
135
|
initializeRaw;
|
|
119
136
|
initializedSeen = false;
|
|
120
137
|
routes = new Map();
|
|
@@ -194,11 +211,13 @@ class Supervisor {
|
|
|
194
211
|
if (key !== undefined) {
|
|
195
212
|
this.routes.set(key, active);
|
|
196
213
|
active.pending.add(key);
|
|
214
|
+
active.pendingMethod.set(key, msg.method);
|
|
197
215
|
}
|
|
198
216
|
if (!active.send(raw)) {
|
|
199
217
|
if (key !== undefined) {
|
|
200
218
|
this.routes.delete(key);
|
|
201
219
|
active.pending.delete(key);
|
|
220
|
+
active.pendingMethod.delete(key);
|
|
202
221
|
}
|
|
203
222
|
answerNotRunning();
|
|
204
223
|
}
|
|
@@ -212,6 +231,7 @@ class Supervisor {
|
|
|
212
231
|
const key = idKey(msg.id);
|
|
213
232
|
this.routes.delete(key);
|
|
214
233
|
server.pending.delete(key);
|
|
234
|
+
server.pendingMethod.delete(key);
|
|
215
235
|
process.stdout.write(`${raw}\n`);
|
|
216
236
|
if (server.retired && server.pending.size === 0)
|
|
217
237
|
server.kill();
|
|
@@ -239,6 +259,7 @@ class Supervisor {
|
|
|
239
259
|
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
240
260
|
}
|
|
241
261
|
server.pending.clear();
|
|
262
|
+
server.pendingMethod.clear();
|
|
242
263
|
if (this.closing || server.retired || server !== this.active)
|
|
243
264
|
return;
|
|
244
265
|
log(`server exited unexpectedly (code ${code ?? "null"}, signal ${signal ?? "none"}); restarting`);
|
|
@@ -301,6 +322,7 @@ class Supervisor {
|
|
|
301
322
|
log(`swapped to server generation ${next.generation} (${reason})`);
|
|
302
323
|
if (previous && previous !== next) {
|
|
303
324
|
previous.retired = true;
|
|
325
|
+
this.retiredServers.add(previous);
|
|
304
326
|
if (previous.pending.size === 0)
|
|
305
327
|
previous.kill();
|
|
306
328
|
else
|
|
@@ -356,7 +378,25 @@ class Supervisor {
|
|
|
356
378
|
clearTimeout(this.restartTimer);
|
|
357
379
|
unwatchFile(STAMP_FILE);
|
|
358
380
|
this.active?.kill();
|
|
359
|
-
|
|
381
|
+
const busy = [...this.retiredServers].filter((s) => !s.exited && s.pending.size > 0);
|
|
382
|
+
for (const s of this.retiredServers)
|
|
383
|
+
if (!busy.includes(s))
|
|
384
|
+
s.kill();
|
|
385
|
+
for (const s of busy) {
|
|
386
|
+
incident(`shutdown: retired server generation ${s.generation} still answering ${s.pending.size} request(s) (${s.pendingMethods().join(", ")}); ` +
|
|
387
|
+
`killing it in ${SHUTDOWN_GRACE_MS} ms`);
|
|
388
|
+
}
|
|
389
|
+
if (busy.length > 0) {
|
|
390
|
+
setTimeout(() => {
|
|
391
|
+
for (const s of busy) {
|
|
392
|
+
if (s.exited)
|
|
393
|
+
continue;
|
|
394
|
+
incident(`shutdown: retired server generation ${s.generation} killed with ${s.pending.size} request(s) unanswered`);
|
|
395
|
+
s.kill();
|
|
396
|
+
}
|
|
397
|
+
}, SHUTDOWN_GRACE_MS);
|
|
398
|
+
}
|
|
399
|
+
setTimeout(() => process.exit(0), Math.max(2500, busy.length > 0 ? SHUTDOWN_GRACE_MS + 500 : 0)).unref();
|
|
360
400
|
}
|
|
361
401
|
}
|
|
362
402
|
async function readStamp() {
|
package/dist/server.js
CHANGED
|
@@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { pathExists, tail } from "./core/fsutil.js";
|
|
6
6
|
import { describeProject, findProjectFile, loadProject, locateProjectFile } from "./core/project/index.js";
|
|
7
|
-
import { userDataDir } from "./core/home.js";
|
|
7
|
+
import { packageRoot, userDataDir } from "./core/home.js";
|
|
8
8
|
import { applyDataFetch, applyManagedInstall, checkToolchain, featureReadiness, locateTool, planDataFetch, planDataMemory, planManagedInstall, planToolMemory, readinessSummary, TOOL_SPECS, toolsToLocate } from "./core/toolchain/index.js";
|
|
9
9
|
import { loadUserConfig } from "./core/userconfig.js";
|
|
10
10
|
import { describeNeeds, isProjectTrusted, planTrust, projectTrustNeeds, trustRefusal } from "./core/trust.js";
|
|
@@ -15,6 +15,7 @@ import { runBuild } from "./core/build/index.js";
|
|
|
15
15
|
import { classifyReload, detectRedHotTools, runDeploy } from "./core/deploy/index.js";
|
|
16
16
|
import { armBridge, bridgeCandidateDirs, bridgeStatus, cetLogCandidates, listPendingArms, PROTOCOL_VERSION as BRIDGE_PROTOCOL_VERSION, pollCyberpunk, pollForResult, probeSchema as bridgeProbeSchema, readPendingArm, stageBridge } from "./core/bridge/index.js";
|
|
17
17
|
import { removeBridge } from "./core/bridge/remove.js";
|
|
18
|
+
import { BACKUP_RETENTION, pruneDated } from "./core/safety/retention.js";
|
|
18
19
|
import { runTestPlan } from "./core/testplan/index.js";
|
|
19
20
|
import { applyBuildLedgerHook, applyClaimTransition, applyDecisionRecord, applyValidateLedgerHook, checkLedger, claimsFilePath, listClaims, loadClaims, loadDecisions, saveClaims, saveDecisions, showClaim, } from "./core/ledger/index.js";
|
|
20
21
|
import { IndexStore, defaultCacheDir, findLegacyIndex, indexFile, legacyIndexHint, loadKnowledge, resolveIndexFile, searchFacts, } from "./core/knowledge/index.js";
|
|
@@ -30,7 +31,27 @@ import { SURFACES, getSurface, installGameVersion, resolveInstall, surfaceIds }
|
|
|
30
31
|
import { CONVERTERS, convert as runConvert, defaultLocateTool as defaultTextLocateTool, extractTemplate, fieldDiff, getTemplate, invalidateTemplate, listTemplates, } from "./core/text/index.js";
|
|
31
32
|
import { bootstrapProject, describe as describeScaffold, findScaffold, findSequence, generateClaudeAssets, loadAllScaffolds, loadAllSequences, planSequence, runScaffold, summarize, summarizeSequences } from "./core/scaffold/index.js";
|
|
32
33
|
import { loadSkillPromptCatalog, renderSkillPrompt } from "./core/prompts.js";
|
|
33
|
-
const server = new McpServer({ name: "modwright", version:
|
|
34
|
+
const server = new McpServer({ name: "modwright", version: await serverVersion() });
|
|
35
|
+
async function serverVersion() {
|
|
36
|
+
try {
|
|
37
|
+
const pkg = JSON.parse(await fs.readFile(path.join(packageRoot(), "package.json"), "utf8"));
|
|
38
|
+
return pkg.version ?? "0.0.0";
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return "0.0.0";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
{
|
|
45
|
+
const register = server.registerTool.bind(server);
|
|
46
|
+
server.registerTool = (name, config, handler) => register(name, config, async (...args) => {
|
|
47
|
+
try {
|
|
48
|
+
return await handler(...args);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return fail(`${String(name)} failed: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
34
55
|
const registry = new SignatureRegistry();
|
|
35
56
|
for (const surface of SURFACES) {
|
|
36
57
|
if (surface.logSignatures?.length)
|
|
@@ -313,6 +334,7 @@ server.registerTool("who_touches", {
|
|
|
313
334
|
const collection = await collectWrites(surface, install, rootIds ? { rootIds } : {});
|
|
314
335
|
const { errors, ...coverage } = collection.coverage;
|
|
315
336
|
const copies = duplicateCopies(collection);
|
|
337
|
+
const repeats = (collection.repeatedKeys ?? []).filter((r) => key ? r.key.toLowerCase().startsWith(key.toLowerCase()) : mod ? r.writerId.toLowerCase().includes(mod.toLowerCase()) || r.name.toLowerCase().includes(mod.toLowerCase()) : true);
|
|
316
338
|
const result = key
|
|
317
339
|
? { mode: "key", ...whoTouches(collection, key, limit) }
|
|
318
340
|
: mod
|
|
@@ -328,6 +350,16 @@ server.registerTool("who_touches", {
|
|
|
328
350
|
errors: errors.slice(0, 25),
|
|
329
351
|
},
|
|
330
352
|
...(copies.length > 0 ? { duplicateCopies: copies } : {}),
|
|
353
|
+
...(repeats.length > 0
|
|
354
|
+
? {
|
|
355
|
+
repeatedKeys: {
|
|
356
|
+
total: repeats.length,
|
|
357
|
+
items: repeats.slice(0, 50),
|
|
358
|
+
note: "Each key is repeated within one mapping in that file: invalid YAML that TweakXL reads without an error. Which " +
|
|
359
|
+
"occurrence takes effect is not established, so one of the edits may be silently lost.",
|
|
360
|
+
},
|
|
361
|
+
}
|
|
362
|
+
: {}),
|
|
331
363
|
...result,
|
|
332
364
|
note: "Static: read from the mods' files, never from the running game. 'contested' means two or more mods write the " +
|
|
333
365
|
"key and not only by appending; which one the game ends up with depends on load order, which this tool does " +
|
|
@@ -1300,19 +1332,59 @@ server.registerTool("rollback", {
|
|
|
1300
1332
|
"allowOutsideRoots is set.",
|
|
1301
1333
|
inputSchema: {
|
|
1302
1334
|
path: z.string().optional().describe("modwright.json path or a directory to search from. Default: current directory."),
|
|
1335
|
+
action: z
|
|
1336
|
+
.enum(["list", "restore", "prune"])
|
|
1337
|
+
.optional()
|
|
1338
|
+
.describe("list (the default without backupId), restore (the default with one), or prune: delete old backup runs, keeping the newest 20 and anything under 14 days old. Only ModWright's own dated folders are ever pruned."),
|
|
1303
1339
|
backupId: z.string().optional().describe("The stamp naming one backup directory under .modwright/backups (from this tool's own listing). Omit to list."),
|
|
1304
|
-
|
|
1340
|
+
backupRoot: z
|
|
1341
|
+
.string()
|
|
1342
|
+
.optional()
|
|
1343
|
+
.describe("prune only: another backups folder to prune instead of the project's, e.g. one an older version left under a working directory. Must be a .modwright/backups folder or ModWright's per-user backups."),
|
|
1344
|
+
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("dry-run reports what would be restored or pruned without changing anything."),
|
|
1305
1345
|
installPath: installPathArg,
|
|
1306
1346
|
allowOutsideRoots: z
|
|
1307
1347
|
.boolean()
|
|
1308
1348
|
.default(false)
|
|
1309
1349
|
.describe("Restore entries outside the project and the game's mod roots too. Off by default: a backup that names any other path is refused whole."),
|
|
1310
1350
|
},
|
|
1311
|
-
}, async ({ path: start, backupId, mode, installPath, allowOutsideRoots }) => {
|
|
1351
|
+
}, async ({ path: start, action, backupId, backupRoot: otherRoot, mode, installPath, allowOutsideRoots }) => {
|
|
1352
|
+
if (action === "prune") {
|
|
1353
|
+
let root;
|
|
1354
|
+
if (otherRoot) {
|
|
1355
|
+
const abs = path.resolve(otherRoot);
|
|
1356
|
+
const posix = abs.split(path.sep).join("/").toLowerCase();
|
|
1357
|
+
const perUser = path.join(userDataDir(), "backups").split(path.sep).join("/").toLowerCase();
|
|
1358
|
+
if (!posix.endsWith("/.modwright/backups") && posix !== perUser) {
|
|
1359
|
+
return fail(`rollback prune: ${abs} is not a .modwright/backups folder or ModWright's per-user backups (${path.join(userDataDir(), "backups")}).`);
|
|
1360
|
+
}
|
|
1361
|
+
root = abs;
|
|
1362
|
+
}
|
|
1363
|
+
else {
|
|
1364
|
+
const project = await projectOrFail(start);
|
|
1365
|
+
if ("isError" in project)
|
|
1366
|
+
return project;
|
|
1367
|
+
root = path.join(project.root, ".modwright", "backups");
|
|
1368
|
+
}
|
|
1369
|
+
const report = await pruneDated(root, "backup", BACKUP_RETENTION, { mode });
|
|
1370
|
+
return json({
|
|
1371
|
+
action,
|
|
1372
|
+
...report,
|
|
1373
|
+
expired: report.expired.slice(0, 100),
|
|
1374
|
+
expiredCount: report.expired.length,
|
|
1375
|
+
note: report.expired.length === 0
|
|
1376
|
+
? `Nothing to prune: ${report.runs} backup run(s), all within the newest ${BACKUP_RETENTION.keepRuns} or under ${BACKUP_RETENTION.maxAgeDays} days old.`
|
|
1377
|
+
: report.applied
|
|
1378
|
+
? `Pruned ${report.expired.length - (report.failed?.length ?? 0)} folder(s); ${report.keptCount} kept.`
|
|
1379
|
+
: `Dry run: ${report.expired.length} folder(s) would be deleted outright (they are the backups); ${report.keptCount} kept. Repeat with mode 'apply'.`,
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1312
1382
|
const resolved = await projectOrFail(start);
|
|
1313
1383
|
if ("isError" in resolved)
|
|
1314
1384
|
return resolved;
|
|
1315
1385
|
const backupRoot = path.join(resolved.root, ".modwright", "backups");
|
|
1386
|
+
if (action === "restore" && !backupId)
|
|
1387
|
+
return fail("rollback restore needs backupId (from this tool's listing).");
|
|
1316
1388
|
if (!backupId) {
|
|
1317
1389
|
const backups = await listBackups(backupRoot);
|
|
1318
1390
|
return json({
|
|
@@ -1686,7 +1758,7 @@ server.registerTool("ledger", {
|
|
|
1686
1758
|
scopeFiles: z.array(z.string()).optional().describe("'record' kind:\"claim\": files this claim is about, for a new claim."),
|
|
1687
1759
|
scopeEntries: z.array(z.string()).optional().describe("'record' kind:\"claim\": named entries (stat/tweak ids) this claim is about, for a new claim."),
|
|
1688
1760
|
toStatus: z.enum(["authored", "checker-clean", "built", "verified", "contradicted"]).optional().describe("'record' kind:\"claim\": the status to move this claim to."),
|
|
1689
|
-
tool: z.enum(["validate", "build", "run_tests", "ledger"]).optional().describe("'record' kind:\"claim\":
|
|
1761
|
+
tool: z.enum(["validate", "build", "run_tests", "ledger"]).optional().describe("'record' kind:\"claim\": whose transition rule applies. \"ledger\" (the default) is a manual record or correction: any move is allowed, with a note when it skips a stage. Naming validate, build or run_tests applies that tool's rule instead: only the status that tool writes, and only forward. A name never widens what is allowed and never stands in for the tool's own checks."),
|
|
1690
1762
|
by: z.string().optional().describe("'record': who or what made this write. Default \"ledger\"."),
|
|
1691
1763
|
note: z.string().optional().describe("'record': why, for the history entry."),
|
|
1692
1764
|
evidence: z
|
|
@@ -2,6 +2,6 @@ import { bridgeNeverShipValidator } from "../../../core/bridge/index.js";
|
|
|
2
2
|
export const bridgeNeverShipsValidator = bridgeNeverShipValidator({
|
|
3
3
|
id: "bg3.history.bridge-never-ships",
|
|
4
4
|
game: "baldursgate3",
|
|
5
|
-
evidence: ["ModWright
|
|
5
|
+
evidence: ["ModWright's own rule, decided 2026-09-08: the in-game verification bridge is an authoring aid and never ships in a release build."],
|
|
6
6
|
});
|
|
7
7
|
export const BRIDGE_VALIDATORS = [bridgeNeverShipsValidator];
|
|
@@ -2,6 +2,6 @@ import { vanillaExtractNeverShipValidator } from "../../../core/text/validate.js
|
|
|
2
2
|
export const vanillaExtractNeverShipsValidator = vanillaExtractNeverShipValidator({
|
|
3
3
|
id: "bg3.history.vanilla-extract-never-ships",
|
|
4
4
|
game: "baldursgate3",
|
|
5
|
-
evidence: ["ModWright
|
|
5
|
+
evidence: ["ModWright's own rule, decided 2026-09-08: a template extracted from the user's own install is for authoring only, and a release never ships one verbatim (redistributing game data)."],
|
|
6
6
|
});
|
|
7
7
|
export const TEMPLATE_VALIDATORS = [vanillaExtractNeverShipsValidator];
|
|
@@ -181,3 +181,32 @@ export function parseTweakXLYaml(text, file, options = {}) {
|
|
|
181
181
|
}
|
|
182
182
|
return ctx.records;
|
|
183
183
|
}
|
|
184
|
+
export function findRepeatedKeys(text) {
|
|
185
|
+
const lineCounter = new YAML.LineCounter();
|
|
186
|
+
const doc = YAML.parseDocument(text, { lineCounter, uniqueKeys: false });
|
|
187
|
+
if (doc.errors.length > 0)
|
|
188
|
+
return [];
|
|
189
|
+
const out = [];
|
|
190
|
+
const visit = (node, prefix) => {
|
|
191
|
+
if (YAML.isMap(node)) {
|
|
192
|
+
const seen = new Map();
|
|
193
|
+
for (const pair of node.items) {
|
|
194
|
+
if (!YAML.isScalar(pair.key))
|
|
195
|
+
continue;
|
|
196
|
+
const name = String(pair.key.value);
|
|
197
|
+
const line = pair.key.range ? lineCounter.linePos(pair.key.range[0]).line : 0;
|
|
198
|
+
seen.set(name, [...(seen.get(name) ?? []), line]);
|
|
199
|
+
visit(pair.value, prefix ? `${prefix}.${name}` : name);
|
|
200
|
+
}
|
|
201
|
+
for (const [name, lines] of seen)
|
|
202
|
+
if (lines.length > 1)
|
|
203
|
+
out.push({ key: prefix ? `${prefix}.${name}` : name, lines });
|
|
204
|
+
}
|
|
205
|
+
else if (YAML.isSeq(node)) {
|
|
206
|
+
for (const item of node.items)
|
|
207
|
+
visit(item, prefix);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
visit(doc.contents, "");
|
|
211
|
+
return out.sort((a, b) => a.lines[0] - b.lines[0]);
|
|
212
|
+
}
|
|
@@ -2,6 +2,6 @@ import { bridgeNeverShipValidator } from "../../../core/bridge/index.js";
|
|
|
2
2
|
export const bridgeNeverShipsValidator = bridgeNeverShipValidator({
|
|
3
3
|
id: "cp2077.history.bridge-never-ships",
|
|
4
4
|
game: "cyberpunk2077",
|
|
5
|
-
evidence: ["ModWright
|
|
5
|
+
evidence: ["ModWright's own rule, decided 2026-09-08: the in-game verification bridge is an authoring aid and never ships in a release build."],
|
|
6
6
|
});
|
|
7
7
|
export const BRIDGE_VALIDATORS = [bridgeNeverShipsValidator];
|
|
@@ -264,7 +264,7 @@ function checkResourcePathShape(root, file) {
|
|
|
264
264
|
const GAMEVERSION_EVIDENCE = [
|
|
265
265
|
"cp2077.toolchain.gameversion-header",
|
|
266
266
|
"cp2077.packaging.game-version-compatibility",
|
|
267
|
-
"observed: a WolvenKit 8.20.0 en-us.source.json header carries GameVersion 2310",
|
|
267
|
+
"observed by 2026-09-07: a WolvenKit 8.20.0 en-us.source.json header carries GameVersion 2310",
|
|
268
268
|
];
|
|
269
269
|
function expectedGameVersion(marketingVersion) {
|
|
270
270
|
const m = /^(\d+)\.(\d{1,2})$/.exec(marketingVersion.trim());
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
const ID = "cp2077.history.modsettings-annotation-keys";
|
|
3
|
-
const EVIDENCE = ["
|
|
3
|
+
const EVIDENCE = ["cp2077.ui.modsettings.universal-annotation-keys", "cp2077.ui.modsettings.type-specific-annotation-keys", "cp2077.ui.modsettings.unannotated-property-is-invisible"];
|
|
4
4
|
const REQUIRED_KEYS = ["mod", "category", "displayName"];
|
|
5
5
|
function isWithin(parent, child) {
|
|
6
6
|
const rel = path.relative(parent, child);
|
|
@@ -2,6 +2,6 @@ import { vanillaExtractNeverShipValidator } from "../../../core/text/validate.js
|
|
|
2
2
|
export const vanillaExtractNeverShipsValidator = vanillaExtractNeverShipValidator({
|
|
3
3
|
id: "cp2077.history.vanilla-extract-never-ships",
|
|
4
4
|
game: "cyberpunk2077",
|
|
5
|
-
evidence: ["ModWright
|
|
5
|
+
evidence: ["ModWright's own rule, decided 2026-09-08: a template extracted from the user's own install is for authoring only, and a release never ships one verbatim (redistributing game data)."],
|
|
6
6
|
});
|
|
7
7
|
export const TEMPLATE_VALIDATORS = [vanillaExtractNeverShipsValidator];
|
|
@@ -85,7 +85,11 @@ function finding(validatorId, severity, file, message, evidence, extra = {}) {
|
|
|
85
85
|
const YAML_LINT_ID = "cp2077.tweaks.yaml-lint";
|
|
86
86
|
const YAML_LINT_EVIDENCE = [
|
|
87
87
|
"cp2077.tweakdb.melee.tweakxl-log-required-after-yaml-changes",
|
|
88
|
+
"cp2077.tweakxl.yaml-syntax.duplicate-keys-do-not-fail-the-parse",
|
|
88
89
|
];
|
|
90
|
+
const DUPLICATE_KEY_NOTE = "That is invalid YAML. TweakXL reads the file without an error, but which occurrence takes effect is not established, " +
|
|
91
|
+
"so one of the two edits can be silently lost.";
|
|
92
|
+
const DUPLICATE_KEY_FIX = "Write everything this key sets in one block, and delete the repeat.";
|
|
89
93
|
export const yamlLintValidator = {
|
|
90
94
|
id: YAML_LINT_ID,
|
|
91
95
|
game: GAME,
|
|
@@ -113,9 +117,12 @@ export const yamlLintValidator = {
|
|
|
113
117
|
const doc = YAML.parseDocument(text, { lineCounter });
|
|
114
118
|
return doc.errors.map((err) => {
|
|
115
119
|
const pos = err.pos ? lineCounter.linePos(err.pos[0]) : undefined;
|
|
116
|
-
|
|
120
|
+
const first = err.message.split("\n")[0] ?? err.message;
|
|
121
|
+
const duplicate = /map keys must be unique/i.test(first);
|
|
122
|
+
return finding(YAML_LINT_ID, "block", file, duplicate ? `${first} ${DUPLICATE_KEY_NOTE}` : first, YAML_LINT_EVIDENCE, {
|
|
117
123
|
line: pos?.line,
|
|
118
124
|
column: pos?.col,
|
|
125
|
+
...(duplicate ? { fix: DUPLICATE_KEY_FIX } : {}),
|
|
119
126
|
});
|
|
120
127
|
});
|
|
121
128
|
},
|
|
@@ -164,7 +171,7 @@ export const baseExistsValidator = {
|
|
|
164
171
|
},
|
|
165
172
|
};
|
|
166
173
|
const TYPE_KNOWN_ID = "cp2077.tweaks.type-known";
|
|
167
|
-
const TYPE_KNOWN_EVIDENCE = ["
|
|
174
|
+
const TYPE_KNOWN_EVIDENCE = ["cp2077.tweakxl.yaml-syntax.record-names-must-be-unique", "https://github.com/psiberx/cp2077-tweak-xl/wiki/YAML-Tweaks"];
|
|
168
175
|
export const typeKnownValidator = {
|
|
169
176
|
id: TYPE_KNOWN_ID,
|
|
170
177
|
game: GAME,
|
|
@@ -252,7 +259,7 @@ export const appendOnListFlatValidator = {
|
|
|
252
259
|
},
|
|
253
260
|
};
|
|
254
261
|
const INSTANCES_ID = "cp2077.tweaks.instances-resolve";
|
|
255
|
-
const INSTANCES_EVIDENCE = ["
|
|
262
|
+
const INSTANCES_EVIDENCE = ["https://github.com/psiberx/cp2077-tweak-xl/wiki/YAML-Tweaks"];
|
|
256
263
|
const VAR_RE = /\$\(([^)]+)\)/g;
|
|
257
264
|
function collectVarNamesFromText(text, out) {
|
|
258
265
|
for (const m of text.matchAll(VAR_RE))
|
|
@@ -553,7 +560,7 @@ const REPLACER_BASE = "Character.Player_Replacer_Puppet_Base";
|
|
|
553
560
|
const REPLACER_TEMPLATE_EVIDENCE = [
|
|
554
561
|
"cp2077.player.control.replacers-are-other-player-bodies",
|
|
555
562
|
"cp2077.player.control.android-replacer-is-v-in-the-android-app",
|
|
556
|
-
"
|
|
563
|
+
"observed 2026-10-04 in a TweakXL 1.11.4 log: `<record>.entityTemplatePath refers to a non-existent resource.` is logged as a warning naming the record",
|
|
557
564
|
];
|
|
558
565
|
async function derivesFromReplacerBase(ctx, start, projectBases) {
|
|
559
566
|
let current = start;
|
|
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { TweakParseError } from "./index/model.js";
|
|
4
4
|
import { parseTweakFile } from "./index/parsers/tweak.js";
|
|
5
|
-
import { parseTweakXLYaml } from "./index/parsers/tweakxl-yaml.js";
|
|
5
|
+
import { findRepeatedKeys, parseTweakXLYaml } from "./index/parsers/tweakxl-yaml.js";
|
|
6
6
|
const TWEAK_EXTENSIONS = new Set([".yaml", ".yml", ".tweak"]);
|
|
7
7
|
const MAX_TWEAK_BYTES = 8 * 1024 * 1024;
|
|
8
8
|
export const TWEAKS_UNIT_NOTE = "r6/tweaks is one shared folder and TweakXL has no notion of mods, so each top-level folder there " +
|
|
@@ -38,7 +38,9 @@ async function parseFile(abs) {
|
|
|
38
38
|
if (stat.size > MAX_TWEAK_BYTES)
|
|
39
39
|
throw new Error(`larger than ${MAX_TWEAK_BYTES} bytes; skipped`);
|
|
40
40
|
const text = await fs.readFile(abs, "utf8");
|
|
41
|
-
|
|
41
|
+
if (path.extname(abs).toLowerCase() === ".tweak")
|
|
42
|
+
return { records: parseTweakFile(text, abs), repeats: [] };
|
|
43
|
+
return { records: parseTweakXLYaml(text, abs, { allowDuplicateKeys: true }), repeats: findRepeatedKeys(text) };
|
|
42
44
|
}
|
|
43
45
|
export async function extractCyberpunkWrites(mod, root) {
|
|
44
46
|
let files;
|
|
@@ -56,7 +58,10 @@ export async function extractCyberpunkWrites(mod, root) {
|
|
|
56
58
|
const abs = path.join(mod.path, rel);
|
|
57
59
|
const unit = root.id === "tweaks" ? rel.split("/")[0] : undefined;
|
|
58
60
|
try {
|
|
59
|
-
|
|
61
|
+
const { records, repeats } = await parseFile(abs);
|
|
62
|
+
extraction.writes.push(...recordWrites(records, abs, unit));
|
|
63
|
+
for (const r of repeats)
|
|
64
|
+
(extraction.repeatedKeys ??= []).push({ file: abs, ...(unit ? { unit } : {}), key: r.key, lines: r.lines });
|
|
60
65
|
}
|
|
61
66
|
catch (error) {
|
|
62
67
|
const message = error instanceof TweakParseError ? error.message : `${abs}: ${error.message}`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import { BSA_VERSION_OBLIVION, BSA_VERSION_SKYRIM_LE, BSA_VERSION_SKYRIM_SE, BsaHeaderError, readBsaHeader } from "../archives.js";
|
|
3
|
-
const
|
|
3
|
+
const HEADER_EVIDENCE = ["sse.archives.bsa.version-105"];
|
|
4
4
|
const APPLIES = "every .bsa file under a project source";
|
|
5
5
|
function isWithin(parent, child) {
|
|
6
6
|
const rel = path.relative(parent, child);
|
|
@@ -23,11 +23,12 @@ async function headerOrFinding(id, file) {
|
|
|
23
23
|
file,
|
|
24
24
|
message: `${path.basename(file)} has no readable BSA header (${reason}): ${err.message}`,
|
|
25
25
|
fix: "Repack the archive with BSArch or the Creation Kit's archive tool.",
|
|
26
|
-
evidence:
|
|
26
|
+
evidence: HEADER_EVIDENCE,
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
const isFinding = (v) => v.validatorId !== undefined;
|
|
31
|
+
const ARCHIVEVERSION_EVIDENCE = ["sse.archives.bsa.version-105", "sse.archives.bsa.v105-uses-lz4", "sse.archives.bsa.folder-record-is-24-bytes-in-v105"];
|
|
31
32
|
const archiveVersion = {
|
|
32
33
|
id: "sse.archives.version",
|
|
33
34
|
game: "skyrimse",
|
|
@@ -39,7 +40,7 @@ const archiveVersion = {
|
|
|
39
40
|
needs: { project: true },
|
|
40
41
|
cost: "edit",
|
|
41
42
|
severity: "block",
|
|
42
|
-
evidence:
|
|
43
|
+
evidence: ARCHIVEVERSION_EVIDENCE,
|
|
43
44
|
async run(ctx) {
|
|
44
45
|
if (ctx.target.kind !== "file")
|
|
45
46
|
return [];
|
|
@@ -59,11 +60,12 @@ const archiveVersion = {
|
|
|
59
60
|
? `${name} is a version ${header.version} archive, ${known}; Skyrim SE reads version 105.`
|
|
60
61
|
: `${name} has BSA version ${header.version}; Skyrim SE reads version 105.`,
|
|
61
62
|
fix: "Extract and repack the archive for Skyrim SE (BSArch with the SSE format, or the Creation Kit).",
|
|
62
|
-
evidence:
|
|
63
|
+
evidence: ARCHIVEVERSION_EVIDENCE,
|
|
63
64
|
},
|
|
64
65
|
];
|
|
65
66
|
},
|
|
66
67
|
};
|
|
68
|
+
const ARCHIVENAMEFLAGS_EVIDENCE = ["sse.archives.bsa.directory-and-file-name-flags-are-required"];
|
|
67
69
|
const archiveNameFlags = {
|
|
68
70
|
id: "sse.archives.name-flags",
|
|
69
71
|
game: "skyrimse",
|
|
@@ -75,7 +77,7 @@ const archiveNameFlags = {
|
|
|
75
77
|
needs: { project: true },
|
|
76
78
|
cost: "edit",
|
|
77
79
|
severity: "warn",
|
|
78
|
-
evidence:
|
|
80
|
+
evidence: ARCHIVENAMEFLAGS_EVIDENCE,
|
|
79
81
|
async run(ctx) {
|
|
80
82
|
if (ctx.target.kind !== "file")
|
|
81
83
|
return [];
|
|
@@ -91,7 +93,7 @@ const archiveNameFlags = {
|
|
|
91
93
|
file: ctx.target.path,
|
|
92
94
|
message: `${path.basename(ctx.target.path)} lacks ${missing.join(" and ")} in its archive flags; UESP notes the game may not load a BSA without them, and every official archive sets both.`,
|
|
93
95
|
fix: "Repack with names included (BSArch's default).",
|
|
94
|
-
evidence:
|
|
96
|
+
evidence: ARCHIVENAMEFLAGS_EVIDENCE,
|
|
95
97
|
});
|
|
96
98
|
}
|
|
97
99
|
if (header.xbox360) {
|
|
@@ -101,7 +103,7 @@ const archiveNameFlags = {
|
|
|
101
103
|
file: ctx.target.path,
|
|
102
104
|
message: `${path.basename(ctx.target.path)} has the Xbox 360 flag (0x40): big-endian numbers and hashes, which the PC game does not read.`,
|
|
103
105
|
fix: "Repack for PC.",
|
|
104
|
-
evidence:
|
|
106
|
+
evidence: ARCHIVENAMEFLAGS_EVIDENCE,
|
|
105
107
|
});
|
|
106
108
|
}
|
|
107
109
|
return findings;
|
|
@@ -3,7 +3,7 @@ import { readPeFileVersion } from "../../../core/compat/pe.js";
|
|
|
3
3
|
import { compareVersions, parseVersion } from "../../../core/compat/version.js";
|
|
4
4
|
import { FORM_VERSION_LE, FORM_VERSION_SSE, HEADER_VERSION_EXPANDED_LIGHT_RANGE, MASTER_LIMIT_SSE, PluginHeaderError, VALID_HEADER_VERSIONS_SSE, pluginExtension, readPluginHeader, } from "../plugins.js";
|
|
5
5
|
import { RUNTIME_EXPANDED_LIGHT_RANGE } from "../compat.js";
|
|
6
|
-
const
|
|
6
|
+
const HEADER_EVIDENCE = ["sse.plugins.format.record-header-is-24-bytes", "sse.plugins.format.hedr-is-twelve-bytes"];
|
|
7
7
|
function isPluginUnderSource(target, project) {
|
|
8
8
|
if (target.kind !== "file" || pluginExtension(target.path) === undefined)
|
|
9
9
|
return false;
|
|
@@ -26,7 +26,7 @@ async function headerOrFinding(id, file) {
|
|
|
26
26
|
file,
|
|
27
27
|
message: `${path.basename(file)} has no readable TES4 header (${reason}): ${err.message}`,
|
|
28
28
|
fix: "A plugin must begin with a TES4 record; re-save it from the Creation Kit or xEdit, or check the file is not truncated.",
|
|
29
|
-
evidence:
|
|
29
|
+
evidence: HEADER_EVIDENCE,
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -42,6 +42,7 @@ function findEntry(order, name) {
|
|
|
42
42
|
const key = name.toLowerCase();
|
|
43
43
|
return order.find((e) => e.name.toLowerCase() === key);
|
|
44
44
|
}
|
|
45
|
+
const MASTERSPRESENT_EVIDENCE = ["sse.plugins.format.mast-data-pairs-are-the-master-list", "sse.loadorder.files.five-masters-are-implicit-and-absent"];
|
|
45
46
|
const mastersPresent = {
|
|
46
47
|
id: "sse.plugins.masters-present",
|
|
47
48
|
game: "skyrimse",
|
|
@@ -53,7 +54,7 @@ const mastersPresent = {
|
|
|
53
54
|
needs: { project: true, install: true },
|
|
54
55
|
cost: "edit",
|
|
55
56
|
severity: "block",
|
|
56
|
-
evidence:
|
|
57
|
+
evidence: MASTERSPRESENT_EVIDENCE,
|
|
57
58
|
async run(ctx) {
|
|
58
59
|
if (ctx.target.kind !== "file")
|
|
59
60
|
return [];
|
|
@@ -79,12 +80,13 @@ const mastersPresent = {
|
|
|
79
80
|
? `Master "${master}" is under Data but not in the load order at all; ${path.basename(ctx.target.path)} depends on it.`
|
|
80
81
|
: `Master "${master}" is neither in the load order nor under Data; ${path.basename(ctx.target.path)} cannot load without it.`,
|
|
81
82
|
fix: entry ? `Activate ${master} (a * line in Plugins.txt).` : `Install ${master} and activate it ahead of ${path.basename(ctx.target.path)}.`,
|
|
82
|
-
evidence:
|
|
83
|
+
evidence: MASTERSPRESENT_EVIDENCE,
|
|
83
84
|
});
|
|
84
85
|
}
|
|
85
86
|
return findings;
|
|
86
87
|
},
|
|
87
88
|
};
|
|
89
|
+
const MASTERORDER_EVIDENCE = ["sse.plugins.format.mast-data-pairs-are-the-master-list", "sse.loadorder.files.master-hoisting"];
|
|
88
90
|
const masterOrder = {
|
|
89
91
|
id: "sse.plugins.master-order",
|
|
90
92
|
game: "skyrimse",
|
|
@@ -96,7 +98,7 @@ const masterOrder = {
|
|
|
96
98
|
needs: { project: true, install: true },
|
|
97
99
|
cost: "edit",
|
|
98
100
|
severity: "block",
|
|
99
|
-
evidence: [...
|
|
101
|
+
evidence: [...MASTERORDER_EVIDENCE],
|
|
100
102
|
async run(ctx) {
|
|
101
103
|
if (ctx.target.kind !== "file")
|
|
102
104
|
return [];
|
|
@@ -114,7 +116,7 @@ const masterOrder = {
|
|
|
114
116
|
severity: "info",
|
|
115
117
|
file: ctx.target.path,
|
|
116
118
|
message: `${path.basename(ctx.target.path)} is not in the load order, so its masters' positions relative to it cannot be checked yet.`,
|
|
117
|
-
evidence:
|
|
119
|
+
evidence: MASTERORDER_EVIDENCE,
|
|
118
120
|
},
|
|
119
121
|
];
|
|
120
122
|
}
|
|
@@ -129,12 +131,13 @@ const masterOrder = {
|
|
|
129
131
|
file: ctx.target.path,
|
|
130
132
|
message: `Master "${master}" loads at position ${entry.index}, after ${path.basename(ctx.target.path)} at ${self.index}; a plugin must load after every master it depends on.`,
|
|
131
133
|
fix: `Move ${master} above ${path.basename(ctx.target.path)} in Plugins.txt (LOOT does this).`,
|
|
132
|
-
evidence: [...
|
|
134
|
+
evidence: [...MASTERORDER_EVIDENCE],
|
|
133
135
|
});
|
|
134
136
|
}
|
|
135
137
|
return findings;
|
|
136
138
|
},
|
|
137
139
|
};
|
|
140
|
+
const ESLHEADERVERSION_EVIDENCE = ["sse.plugins.esl.hedr-1-71-unlocks-the-expanded-range", "sse.plugins.esl.masters-at-1-71-force-the-plugin-to-1-71", "sse.plugins.esl.1-71-plugins-crash-old-game-builds"];
|
|
138
141
|
const eslHeaderVersion = {
|
|
139
142
|
id: "sse.plugins.esl-header-version",
|
|
140
143
|
game: "skyrimse",
|
|
@@ -146,7 +149,7 @@ const eslHeaderVersion = {
|
|
|
146
149
|
needs: { project: true },
|
|
147
150
|
cost: "edit",
|
|
148
151
|
severity: "block",
|
|
149
|
-
evidence:
|
|
152
|
+
evidence: ESLHEADERVERSION_EVIDENCE,
|
|
150
153
|
async run(ctx) {
|
|
151
154
|
if (ctx.target.kind !== "file")
|
|
152
155
|
return [];
|
|
@@ -161,7 +164,7 @@ const eslHeaderVersion = {
|
|
|
161
164
|
severity: "block",
|
|
162
165
|
file: ctx.target.path,
|
|
163
166
|
message: `${path.basename(ctx.target.path)} has no HEDR subrecord; every plugin's TES4 record carries one.`,
|
|
164
|
-
evidence:
|
|
167
|
+
evidence: ESLHEADERVERSION_EVIDENCE,
|
|
165
168
|
});
|
|
166
169
|
return findings;
|
|
167
170
|
}
|
|
@@ -172,7 +175,7 @@ const eslHeaderVersion = {
|
|
|
172
175
|
file: ctx.target.path,
|
|
173
176
|
message: `HEDR version ${version.toFixed(2)} is not one Skyrim SE accepts (${VALID_HEADER_VERSIONS_SSE.map((v) => v.toFixed(2)).join(", ")}).`,
|
|
174
177
|
fix: "Re-save the plugin with the Skyrim SE Creation Kit or xEdit in SSE mode.",
|
|
175
|
-
evidence:
|
|
178
|
+
evidence: ESLHEADERVERSION_EVIDENCE,
|
|
176
179
|
});
|
|
177
180
|
return findings;
|
|
178
181
|
}
|
|
@@ -185,7 +188,7 @@ const eslHeaderVersion = {
|
|
|
185
188
|
file: ctx.target.path,
|
|
186
189
|
message: `${path.basename(ctx.target.path)} has HEDR version ${version.toFixed(2)}, which needs game runtime ${RUNTIME_EXPANDED_LIGHT_RANGE} or later; this install is ${runtime}.`,
|
|
187
190
|
fix: "Update the game, or re-save the plugin with a 1.70 header if it uses no FormID below 0x800.",
|
|
188
|
-
evidence:
|
|
191
|
+
evidence: ESLHEADERVERSION_EVIDENCE,
|
|
189
192
|
});
|
|
190
193
|
}
|
|
191
194
|
}
|
|
@@ -204,7 +207,7 @@ const eslHeaderVersion = {
|
|
|
204
207
|
file: ctx.target.path,
|
|
205
208
|
message: `Master "${master}" is a ${mh.headerVersion.toFixed(2)} plugin while ${path.basename(ctx.target.path)} is ${version.toFixed(2)}; referencing or overriding that master's records in the extended FormID range (001-7FF) will error.`,
|
|
206
209
|
fix: "Re-save this plugin with a 1.71 header (xEdit 4.1.5b or later, or the current Creation Kit).",
|
|
207
|
-
evidence:
|
|
210
|
+
evidence: ESLHEADERVERSION_EVIDENCE,
|
|
208
211
|
});
|
|
209
212
|
}
|
|
210
213
|
}
|
|
@@ -217,6 +220,7 @@ const eslHeaderVersion = {
|
|
|
217
220
|
return findings;
|
|
218
221
|
},
|
|
219
222
|
};
|
|
223
|
+
const FLAGEXTENSIONMISMATCH_EVIDENCE = ["sse.plugins.esl.extension-does-not-determine-lightness", "sse.plugins.format.tes4-flag-bits"];
|
|
220
224
|
const flagExtensionMismatch = {
|
|
221
225
|
id: "sse.plugins.flag-extension-mismatch",
|
|
222
226
|
game: "skyrimse",
|
|
@@ -228,7 +232,7 @@ const flagExtensionMismatch = {
|
|
|
228
232
|
needs: { project: true },
|
|
229
233
|
cost: "edit",
|
|
230
234
|
severity: "warn",
|
|
231
|
-
evidence:
|
|
235
|
+
evidence: FLAGEXTENSIONMISMATCH_EVIDENCE,
|
|
232
236
|
async run(ctx) {
|
|
233
237
|
if (ctx.target.kind !== "file")
|
|
234
238
|
return [];
|
|
@@ -245,7 +249,7 @@ const flagExtensionMismatch = {
|
|
|
245
249
|
file: ctx.target.path,
|
|
246
250
|
message: `${name} has the ESM flag set: the game treats it as a master regardless of the .esp extension, so it loads among the masters and every header-reading tool disagrees with its name.`,
|
|
247
251
|
fix: "Clear the ESM flag in xEdit, or rename the file .esm so the extension says what the flag does.",
|
|
248
|
-
evidence:
|
|
252
|
+
evidence: FLAGEXTENSIONMISMATCH_EVIDENCE,
|
|
249
253
|
},
|
|
250
254
|
];
|
|
251
255
|
}
|
|
@@ -257,13 +261,14 @@ const flagExtensionMismatch = {
|
|
|
257
261
|
file: ctx.target.path,
|
|
258
262
|
message: `${name} has no ESM flag: the .esm extension makes the game load it as a master anyway, but tools reading the header will not.`,
|
|
259
263
|
fix: "Set the ESM flag in xEdit so the header and the extension agree.",
|
|
260
|
-
evidence:
|
|
264
|
+
evidence: FLAGEXTENSIONMISMATCH_EVIDENCE,
|
|
261
265
|
},
|
|
262
266
|
];
|
|
263
267
|
}
|
|
264
268
|
return [];
|
|
265
269
|
},
|
|
266
270
|
};
|
|
271
|
+
const ESLEXTENSIONIMPLIESFLAG_EVIDENCE = ["sse.plugins.esl.flag-is-bit-9-of-the-tes4-header", "sse.plugins.esl.extension-does-not-determine-lightness"];
|
|
267
272
|
const eslExtensionImpliesFlag = {
|
|
268
273
|
id: "sse.plugins.esl-extension-implies-flag",
|
|
269
274
|
game: "skyrimse",
|
|
@@ -275,7 +280,7 @@ const eslExtensionImpliesFlag = {
|
|
|
275
280
|
needs: { project: true },
|
|
276
281
|
cost: "edit",
|
|
277
282
|
severity: "warn",
|
|
278
|
-
evidence:
|
|
283
|
+
evidence: ESLEXTENSIONIMPLIESFLAG_EVIDENCE,
|
|
279
284
|
async run(ctx) {
|
|
280
285
|
if (ctx.target.kind !== "file")
|
|
281
286
|
return [];
|
|
@@ -291,11 +296,12 @@ const eslExtensionImpliesFlag = {
|
|
|
291
296
|
file: ctx.target.path,
|
|
292
297
|
message: `${path.basename(ctx.target.path)} is an .esl without the light flag (0x200) in its header; the game treats it as light because of the extension, header-reading tools do not.`,
|
|
293
298
|
fix: "Set the ESL flag in xEdit (it checks the plugin's new records fit the light range first).",
|
|
294
|
-
evidence:
|
|
299
|
+
evidence: ESLEXTENSIONIMPLIESFLAG_EVIDENCE,
|
|
295
300
|
},
|
|
296
301
|
];
|
|
297
302
|
},
|
|
298
303
|
};
|
|
304
|
+
const FORMVERSION_EVIDENCE = ["sse.plugins.format.form-version-44-for-sse"];
|
|
299
305
|
const formVersion = {
|
|
300
306
|
id: "sse.plugins.form-version",
|
|
301
307
|
game: "skyrimse",
|
|
@@ -307,7 +313,7 @@ const formVersion = {
|
|
|
307
313
|
needs: { project: true },
|
|
308
314
|
cost: "edit",
|
|
309
315
|
severity: "block",
|
|
310
|
-
evidence:
|
|
316
|
+
evidence: FORMVERSION_EVIDENCE,
|
|
311
317
|
async run(ctx) {
|
|
312
318
|
if (ctx.target.kind !== "file")
|
|
313
319
|
return [];
|
|
@@ -326,11 +332,12 @@ const formVersion = {
|
|
|
326
332
|
? `${path.basename(ctx.target.path)} has form version 43: a Skyrim Legendary Edition plugin, not converted for Special Edition.`
|
|
327
333
|
: `${path.basename(ctx.target.path)} has form version ${header.formVersion}; Skyrim SE plugins carry 44.`,
|
|
328
334
|
fix: le ? "Open and re-save the plugin in the Skyrim SE Creation Kit to convert it (and re-pack any LE .bsa)." : "Re-save the plugin with the Skyrim SE Creation Kit or xEdit.",
|
|
329
|
-
evidence:
|
|
335
|
+
evidence: FORMVERSION_EVIDENCE,
|
|
330
336
|
},
|
|
331
337
|
];
|
|
332
338
|
},
|
|
333
339
|
};
|
|
340
|
+
const MASTERCOUNT_EVIDENCE = ["sse.plugins.esl.master-list-limit-is-253"];
|
|
334
341
|
const masterCount = {
|
|
335
342
|
id: "sse.plugins.master-count",
|
|
336
343
|
game: "skyrimse",
|
|
@@ -342,7 +349,7 @@ const masterCount = {
|
|
|
342
349
|
needs: { project: true },
|
|
343
350
|
cost: "edit",
|
|
344
351
|
severity: "block",
|
|
345
|
-
evidence:
|
|
352
|
+
evidence: MASTERCOUNT_EVIDENCE,
|
|
346
353
|
async run(ctx) {
|
|
347
354
|
if (ctx.target.kind !== "file")
|
|
348
355
|
return [];
|
|
@@ -358,7 +365,7 @@ const masterCount = {
|
|
|
358
365
|
file: ctx.target.path,
|
|
359
366
|
message: `${path.basename(ctx.target.path)} names ${header.masters.length} masters; Skyrim SE allows ${MASTER_LIMIT_SSE}.`,
|
|
360
367
|
fix: "Clean unused masters in xEdit, or split the plugin.",
|
|
361
|
-
evidence:
|
|
368
|
+
evidence: MASTERCOUNT_EVIDENCE,
|
|
362
369
|
},
|
|
363
370
|
];
|
|
364
371
|
},
|
|
@@ -60,7 +60,7 @@ facts:
|
|
|
60
60
|
- id: cp2077.tweakxl.yaml-syntax.duplicate-keys-break-file
|
|
61
61
|
claim: A duplicate key at the same nesting level in a TweakXL yaml file breaks the whole file's
|
|
62
62
|
parse; the community-recommended check is to run the file through yamllint.com before packing.
|
|
63
|
-
status:
|
|
63
|
+
status: contradicted
|
|
64
64
|
source: CDPR-Modding-Documentation/Cyberpunk-Modding-Docs
|
|
65
65
|
for-mod-creators-theory/core-mods-explained/tweakxl/tweakxl-changing-game-records/how-to-yaml-tweak-modding-basics.md,
|
|
66
66
|
section "Key Uniqueness"
|
|
@@ -69,6 +69,26 @@ facts:
|
|
|
69
69
|
- yaml
|
|
70
70
|
- duplicate-key
|
|
71
71
|
- validator
|
|
72
|
+
detail: "Contradicted for TweakXL 1.11.4: on 2026-10-04 it read seven installed files that repeat a
|
|
73
|
+
top-level key and logged no error for any of them, while it does log `[error] yaml-cpp: ...`
|
|
74
|
+
for a file that fails to parse
|
|
75
|
+
(cp2077.tweakxl.hotreload.parse-error-still-ends-in-import-completed). See
|
|
76
|
+
cp2077.tweakxl.yaml-syntax.duplicate-keys-do-not-fail-the-parse."
|
|
77
|
+
- id: cp2077.tweakxl.yaml-syntax.duplicate-keys-do-not-fail-the-parse
|
|
78
|
+
claim: "TweakXL 1.11.4 reads a yaml file that repeats a top-level key without a parse error: its log
|
|
79
|
+
shows the file being read and no error line, and the rest of the import goes on. Which
|
|
80
|
+
occurrence wins, or whether both apply, is not established."
|
|
81
|
+
status: verified
|
|
82
|
+
verified_on: "2.31"
|
|
83
|
+
source: ModWright field verification
|
|
84
|
+
supersedes: cp2077.tweakxl.yaml-syntax.duplicate-keys-break-file
|
|
85
|
+
tags:
|
|
86
|
+
- tweakxl
|
|
87
|
+
- yaml
|
|
88
|
+
- duplicate-key
|
|
89
|
+
detail: ModWright's write census (who_touches) reads such a file with every occurrence, in file
|
|
90
|
+
order. The yaml-lint rule still reports a repeated key in a project's own file, where it is
|
|
91
|
+
usually a mistake.
|
|
72
92
|
- id: cp2077.tweakxl.yaml-syntax.record-names-must-be-unique
|
|
73
93
|
claim: Every TweakXL record (a named container grouping properties, which may themselves be other
|
|
74
94
|
records or flats) must have a globally unique name; two records sharing a name overwrite each
|