modwright 0.1.3 → 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/README.md CHANGED
@@ -153,7 +153,7 @@ Epic paths where they apply. Override with `installPath` on any tool, or set
153
153
  | `deploy` | Copy a built mod into the game's mod roots as one write plan. It refuses an artifact the build did not produce or one older than its sources, prunes what a previous build left behind, and defers a file the running game holds locked. Every overwrite and delete is backed up first. |
154
154
  | `rollback` | List the deploy backups under a project's `.modwright/backups/`, or restore one, re-checking every file's hash first. |
155
155
  | `reload_plan` | Classify deployed changes into hot / hot-degraded / save-reload / restart / unknown, with the caveat for each and the single most conservative recommendation. |
156
- | `bridge` | The in-game bridge (BG3 Script Extender, Cyberpunk 2077 CET): `stage` the bridge into a project, check its heartbeat with `status`, `arm` a probe request and `poll` for its result. |
156
+ | `bridge` | The in-game bridge (BG3 Script Extender, Cyberpunk 2077 CET): `stage` the bridge into a project, check its heartbeat with `status`, `arm` a probe request and `poll` for its result, and `remove` it from the game when you are done. |
157
157
  | `run_tests` | Run a mod's in-game test plan (`<mod>/tests/*.yaml`) through the bridge. Probe rows are armed and polled; manual rows become a checklist. Results are written back into the plan. |
158
158
  | `ledger` | Track claims and decisions across a fix (`<mod>/ledger/`). A claim moves `authored → checker-clean → built → verified \| contradicted`, and nothing is verified without evidence. |
159
159
  | `convert` | Run one registered conversion: BG3 lsx↔lsf and xml↔loca through divine, Cyberpunk cr2w↔json through WolvenKit CLI. Each one checks its own round trip. |
@@ -305,7 +305,7 @@ What it runs, and where it writes:
305
305
  - **The in-game bridges.** While a verification bridge is deployed and the
306
306
  game runs, anything that can write the bridge's request folder can run Lua
307
307
  in the game. There is no token. Deploy a bridge only while you verify,
308
- remove it afterwards, and never ship it (the release validators block a
308
+ remove it afterwards (`bridge action=remove`), and never ship it (the release validators block a
309
309
  build that contains it). The bridge READMEs under `bridges/` describe each
310
310
  one's trust model.
311
311
 
@@ -93,8 +93,9 @@ from running again; they do not say who wrote it. So:
93
93
 
94
94
  - deploy the bridge only to a machine and a profile you control, and only while you are
95
95
  verifying a mod;
96
- - remove `Mods/ModWrightBridge` from the profile when you are done, before playing normally
97
- or sharing the profile;
96
+ - remove it when you are done, before playing normally or sharing the profile:
97
+ `bridge action=remove path=<project> mode=apply` deletes every folder the project's
98
+ `withBridge` targets deployed, with a backup (a dry run first shows which);
98
99
  - never ship it. `bg3.history.bridge-never-ships` blocks a release build that contains it.
99
100
 
100
101
  ## What this bridge never does
@@ -17,7 +17,7 @@
17
17
  "prune": [
18
18
  "ModWrightBridge/**"
19
19
  ],
20
- "note": "The deploy target a shipped SE mod uses, verbatim: `bridge stage` adds it to the project's deploy targets when no target already names this rootId, into and variant, so a fresh project deploys the bridge without a hand edit. A deploy WITHOUT `--variant withBridge` prunes the folder back out, with backup."
20
+ "note": "The deploy target a shipped SE mod uses, verbatim: `bridge stage` adds it to the project's deploy targets when no target already names this rootId, into and variant, so a fresh project deploys the bridge without a hand edit. A deploy without `--variant withBridge` leaves an installed bridge where it is: a variant's prune runs only in a deploy that writes that variant. Take it out with `bridge action=remove path=<project> mode=apply` when you are done verifying (dry-run first; backed up)."
21
21
  },
22
22
  "sha256": {
23
23
  "algorithm": "sha256",
@@ -1048,6 +1048,114 @@ Probes.handlers = {
1048
1048
  }
1049
1049
  end,
1050
1050
  },
1051
+ -- Workbench probes: session-tier mutations that put the world into a testable state, so
1052
+ -- a common setup is a catalogue row rather than hand-written Lua each time. Listed in
1053
+ -- Probes.MUTATING_KINDS below and gated on the `session` tier like the other mutating rows.
1054
+
1055
+ -- attested 2026-10-04: this row ran live and the entity appeared. The call:
1056
+ -- exEntitySpawner.Spawn('base\characters\entities\gang\gang__wraith_ma.ent',
1057
+ -- Game.GetPlayer():GetWorldTransform(), '')
1058
+ -- returned a userdata handle, and Game.FindEntityByID(handle) resolved the entity.
1059
+ -- Despawn takes the RESOLVED object, not the handle, and the handle does not persist across
1060
+ -- bridge calls, so a despawn belongs in the same cp2077.lua.eval as its spawn.
1061
+ ["cp2077.world.spawn"] = {
1062
+ attestation = "attested 2026-10-04 (ran live; the entity appeared)",
1063
+ citation = "exEntitySpawner.Spawn(entPath, GetPlayer():GetWorldTransform(), appearance), a CET-added native (RTTIExtender.cpp)",
1064
+ effect = "mutate",
1065
+ mutates = true,
1066
+ run = function(probe)
1067
+ local p = params(probe)
1068
+ if p.entPath == nil then
1069
+ return errored("params.entPath is required, e.g. \"base\\characters\\entities\\gang\\gang__wraith_ma.ent\"")
1070
+ end
1071
+ local entPath = tostring(p.entPath)
1072
+ local appearance = p.appearance ~= nil and tostring(p.appearance) or ""
1073
+ if not have("Game") then return absent("the `Game` global", "design §8 step 4") end
1074
+ local pl = player()
1075
+ if pl == nil then return failed("Game.GetPlayer() returned nil — load a save first", nil, { entPath = entPath }) end
1076
+
1077
+ local spawner
1078
+ local okG = pcall(function() spawner = exEntitySpawner end)
1079
+ if not okG or spawner == nil then
1080
+ return missingAttested("`exEntitySpawner`", "exEntitySpawner.Spawn", { entPath = entPath })
1081
+ end
1082
+
1083
+ local transform
1084
+ local okT, e = pcall(function() transform = pl:GetWorldTransform() end)
1085
+ if not okT then return errored("GetPlayer():GetWorldTransform() raised: " .. tostring(e), { entPath = entPath }) end
1086
+
1087
+ local handle
1088
+ local okS, e2 = pcall(function() handle = exEntitySpawner.Spawn(entPath, transform, appearance) end)
1089
+ local evidence = {
1090
+ entPath = entPath,
1091
+ appearance = appearance,
1092
+ spawnedAtPlayerTransform = true,
1093
+ note = "spawned at the player's own transform: the player is inside the entity until they separate. " ..
1094
+ "The entity arrives after this call returns, so `resolved` is normally false here. " ..
1095
+ "The id handle does not persist across bridge calls; despawn within one eval (cp2077.lua.eval) or re-find the entity.",
1096
+ }
1097
+ if not okS then return errored("exEntitySpawner.Spawn raised: " .. tostring(e2), evidence) end
1098
+ evidence.handle = handle ~= nil and tostring(handle) or nil
1099
+ if handle == nil then return failed("exEntitySpawner.Spawn returned nil for \"" .. entPath .. "\"", nil, evidence) end
1100
+ local ent
1101
+ pcall(function() ent = Game.FindEntityByID(handle) end)
1102
+ evidence.resolved = ent ~= nil
1103
+ return ok(tostring(handle), evidence)
1104
+ end,
1105
+ },
1106
+
1107
+ -- attested 2026-10-04: a teleport to x+4 moved the player by exactly that.
1108
+ -- Game.GetTeleportationFacility():Teleport(GetPlayer(), Vector4, EulerAngles)
1109
+ ["cp2077.player.teleport"] = {
1110
+ attestation = "attested 2026-10-04 (ran live, read back)",
1111
+ citation = "Game.GetTeleportationFacility():Teleport(player, Vector4, EulerAngles), from the CET wiki",
1112
+ effect = "mutate",
1113
+ mutates = true,
1114
+ run = function(probe)
1115
+ local p = params(probe)
1116
+ local x, y, z = tonumber(p.x), tonumber(p.y), tonumber(p.z)
1117
+ if x == nil or y == nil or z == nil then
1118
+ return errored("params.x, params.y and params.z (numbers) are required")
1119
+ end
1120
+ local yaw = tonumber(p.yaw) or 0.0
1121
+ if not have("Game") then return absent("the `Game` global", "design §8 step 4") end
1122
+ local pl = player()
1123
+ if pl == nil then return failed("Game.GetPlayer() returned nil — load a save first") end
1124
+ local evidence = {
1125
+ x = x, y = y, z = z, yaw = yaw,
1126
+ call = "Game.GetTeleportationFacility():Teleport(player, Vector4.new(x,y,z,1), EulerAngles.new(0,0,yaw))",
1127
+ note = "the call and the Vector4/EulerAngles construction from the CET wiki; confirmed live 2026-10-04.",
1128
+ }
1129
+ local okTp, e = pcall(function()
1130
+ Game.GetTeleportationFacility():Teleport(pl, Vector4.new(x, y, z, 1.0), EulerAngles.new(0.0, 0.0, yaw))
1131
+ end)
1132
+ if not okTp then return errored("Teleport raised: " .. tostring(e), evidence) end
1133
+ return ok(true, evidence)
1134
+ end,
1135
+ },
1136
+
1137
+ -- attested 2026-10-04: the level followed 15 -> 16 -> 15, read back each time.
1138
+ -- Game.SetLevel(kind, n, 1) (kind defaults to "Level")
1139
+ ["cp2077.player.level"] = {
1140
+ attestation = "attested 2026-10-04 (ran live, read back)",
1141
+ citation = "Game.SetLevel(kind, level, 1), from the CET wiki",
1142
+ effect = "mutate",
1143
+ mutates = true,
1144
+ run = function(probe)
1145
+ local p = params(probe)
1146
+ local lvl = tonumber(p.level)
1147
+ if lvl == nil then return errored("params.level (a number) is required") end
1148
+ local kind = p.kind ~= nil and tostring(p.kind) or "Level"
1149
+ if not have("Game") then return absent("the `Game` global", "design §8 step 4") end
1150
+ local evidence = {
1151
+ level = lvl, kind = kind, call = "Game.SetLevel(kind, level, 1)",
1152
+ note = "from the CET wiki; confirmed live 2026-10-04 (level read back).",
1153
+ }
1154
+ local okL, e = pcall(function() Game.SetLevel(kind, lvl, 1) end)
1155
+ if not okL then return errored("Game.SetLevel raised: " .. tostring(e), evidence) end
1156
+ return ok(true, evidence)
1157
+ end,
1158
+ },
1051
1159
  }
1052
1160
  -- MODWRIGHT-PROBE-TABLE-END
1053
1161
 
@@ -1138,7 +1246,13 @@ end
1138
1246
  --- The ids allowed to mutate anything: the inventory row it always had, and
1139
1247
  --- (since 2026-09-11) the eval row. Named
1140
1248
  --- as a constant so the gate below reads as the rule it enforces.
1141
- Probes.MUTATING_KINDS = { ["cp2077.inventory.add"] = true, ["cp2077.lua.eval"] = true }
1249
+ Probes.MUTATING_KINDS = {
1250
+ ["cp2077.inventory.add"] = true,
1251
+ ["cp2077.lua.eval"] = true,
1252
+ ["cp2077.world.spawn"] = true,
1253
+ ["cp2077.player.teleport"] = true,
1254
+ ["cp2077.player.level"] = true,
1255
+ }
1142
1256
 
1143
1257
  --- Run one probe. NEVER throws: one probe failing can never abort the batch, and a handler
1144
1258
  --- that raises is reported as `error` with the raised message.
@@ -167,8 +167,9 @@ the request grants the `session` tier. There is no token. The nonce and the requ
167
167
  only stop a consumed or stale request from running again; they do not say who wrote it. So:
168
168
 
169
169
  - deploy the bridge only to an install you control, and only while you are verifying a mod;
170
- - remove `bin/x64/plugins/cyber_engine_tweaks/mods/ModWrightBridge` when you are done,
171
- before playing normally;
170
+ - remove it when you are done, before playing normally:
171
+ `bridge action=remove path=<project> mode=apply` deletes every folder the project's
172
+ `withBridge` targets deployed, with a backup (a dry run first shows which);
172
173
  - never ship it. `cp2077.history.bridge-never-ships` blocks a release build that contains it.
173
174
 
174
175
  ## What this bridge never does
@@ -26,7 +26,7 @@
26
26
  "prune": [
27
27
  "ModWrightBridge/**"
28
28
  ],
29
- "note": "The deploy target `bridge stage` adds to the project's deploy targets when no target already names this rootId, into and variant, so a fresh project deploys the bridge without a hand edit; paste it in yourself only if you want it there before staging. A deploy WITHOUT `--variant withBridge` prunes the folder back out, with backup."
29
+ "note": "The deploy target `bridge stage` adds to the project's deploy targets when no target already names this rootId, into and variant, so a fresh project deploys the bridge without a hand edit; paste it in yourself only if you want it there before staging. A deploy without `--variant withBridge` leaves an installed bridge where it is: a variant's prune runs only in a deploy that writes that variant. Take it out with `bridge action=remove path=<project> mode=apply` when you are done verifying (dry-run first; backed up)."
30
30
  },
31
31
  "sha256": {
32
32
  "algorithm": "sha256",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  {
45
45
  "path": "ModWrightBridge/probes.lua",
46
- "sha256": "169a31b158edcefda6721c3b90656c59fd9f7d7bf928a96d03ef3645a5eb1991"
46
+ "sha256": "759015ac7c1167faecbc5d29e91f567d7c45446164544ad7d72029c115a60df6"
47
47
  },
48
48
  {
49
49
  "path": "ModWrightBridge/protocol.lua",
@@ -0,0 +1,48 @@
1
+ import * as path from "node:path";
2
+ import { pathExists } from "../fsutil.js";
3
+ import { escapesRoot } from "../project/outside-root.js";
4
+ import { executePlan, planDelete } from "../safety/index.js";
5
+ export const BRIDGE_VARIANT = "withBridge";
6
+ export async function planRemoveBridge(project, roots) {
7
+ const removals = [];
8
+ const operations = [];
9
+ for (const target of project.project.deploy?.targets ?? []) {
10
+ if (target.variant !== BRIDGE_VARIANT || !target.into)
11
+ continue;
12
+ const base = { rootId: target.rootId, into: target.into };
13
+ if (escapesRoot(target.into)) {
14
+ removals.push({ ...base, action: "outside-root" });
15
+ continue;
16
+ }
17
+ const root = roots.find((r) => r.id === target.rootId);
18
+ if (!root) {
19
+ removals.push({ ...base, action: "unknown-root" });
20
+ continue;
21
+ }
22
+ const dest = path.join(root.path, target.into);
23
+ if (await pathExists(dest)) {
24
+ operations.push(planDelete(dest));
25
+ removals.push({ ...base, path: dest, action: "remove" });
26
+ }
27
+ else {
28
+ removals.push({ ...base, path: dest, action: "absent" });
29
+ }
30
+ }
31
+ return {
32
+ plan: {
33
+ purpose: `remove the deployed bridge of ${project.project.name}`,
34
+ operations,
35
+ backupRoot: path.join(project.root, ".modwright", "backups"),
36
+ },
37
+ removals,
38
+ };
39
+ }
40
+ export async function removeBridge(project, roots, options = {}) {
41
+ const { plan, removals } = await planRemoveBridge(project, roots);
42
+ const report = await executePlan(plan, {
43
+ mode: options.mode ?? "dry-run",
44
+ ...(options.now ? { now: options.now } : {}),
45
+ confidence: { level: "high", assumptions: [], verifiedBy: ["the project's withBridge deploy targets", "the game's mod roots"] },
46
+ });
47
+ return { ...report, removals };
48
+ }
@@ -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
  }
@@ -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
+ }
@@ -355,6 +355,39 @@ const CP2077_PROBES = [
355
355
  "loot-container template does carry a gameTargetingComponent), and whether the game marks TSF_* isExec. Never " +
356
356
  "run in game; stays unverified until a live run exercises it.",
357
357
  },
358
+ {
359
+ kind: "cp2077.world.spawn",
360
+ game: "cyberpunk2077",
361
+ context: "cet",
362
+ effect: "mutate",
363
+ status: "attested",
364
+ summary: "Spawn an entity from a resource path at the player's transform (exEntitySpawner.Spawn), returning the entity id handle; Game.FindEntityByID resolves it.",
365
+ citation: "cp2077.world.spawn; exEntitySpawner is a CET-added native (RTTIExtender.cpp); attested 2026-10-04",
366
+ paramsSchema: z.object({ entPath: str(), appearance: z.string().optional() }).strict(),
367
+ evidence: "Ran live 2026-10-04 (request req-muugz1b6-94911e04, game 3.0.80.51928): spawning base\\characters\\entities\\gang\\gang__wraith_ma.ent returned a handle and the NPC appeared where the player stood. Game.FindEntityByID(handle) in the same call returned nothing: the entity arrives after the call returns, so resolved:false is the normal result, not a failure.",
368
+ },
369
+ {
370
+ kind: "cp2077.player.teleport",
371
+ game: "cyberpunk2077",
372
+ context: "cet",
373
+ effect: "mutate",
374
+ status: "attested",
375
+ summary: "Teleport the player to a world position (Game.GetTeleportationFacility():Teleport). Optional yaw.",
376
+ citation: "cp2077.player.teleport (CET wiki); attested 2026-10-04",
377
+ paramsSchema: z.object({ x: z.number(), y: z.number(), z: z.number(), yaw: z.number().optional() }).strict(),
378
+ evidence: "Ran live 2026-10-04 (request req-muugz2is-a30b1095, game 3.0.80.51928): a teleport to x+4 moved the player from -2249.30,2790.43,7.00 to -2245.30,2790.43,7.01, read back through cp2077.lua.eval.",
379
+ },
380
+ {
381
+ kind: "cp2077.player.level",
382
+ game: "cyberpunk2077",
383
+ context: "cet",
384
+ effect: "mutate",
385
+ status: "attested",
386
+ summary: "Set a player progression level (Game.SetLevel(kind, n, 1)); kind defaults to \"Level\".",
387
+ citation: "cp2077.player.level (CET wiki); attested 2026-10-04",
388
+ paramsSchema: z.object({ level: z.number().int(), kind: z.string().optional() }).strict(),
389
+ evidence: "Ran live 2026-10-04 (requests req-muugz2is-a30b1095 and req-muugz457-1ded47cb, game 3.0.80.51928): Game.SetLevel(\"Level\", n, 1) moved the player level 15 to 16 and back to 15, each read back with StatsSystem.GetStatValue(player, gamedataStatType.Level).",
390
+ },
358
391
  ];
359
392
  export const PROBE_CATALOG = [...BG3_PROBES, ...CP2077_PROBES];
360
393
  const BY_KIND = new Map(PROBE_CATALOG.map((entry) => [entry.kind, entry]));
@@ -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
- setTimeout(() => process.exit(0), 2500).unref();
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() {