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 +2 -2
- package/bridges/bg3-se/README.md +3 -2
- package/bridges/bg3-se/bridge.json +1 -1
- package/bridges/cp2077-cet/ModWrightBridge/probes.lua +115 -1
- package/bridges/cp2077-cet/README.md +3 -2
- package/bridges/cp2077-cet/bridge.json +2 -2
- package/dist/core/bridge/remove.js +48 -0
- 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/testplan/registry.js +33 -0
- package/dist/core/writes/index.js +9 -0
- package/dist/index.js +42 -2
- package/dist/server.js +115 -7
- 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/cet.natives.yaml +48 -0
- package/knowledge/cyberpunk2077/tweakxl.yaml-syntax.yaml +21 -1
- package/package.json +1 -1
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
|
|
package/bridges/bg3-se/README.md
CHANGED
|
@@ -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
|
|
97
|
-
|
|
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
|
|
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 = {
|
|
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
|
|
171
|
-
|
|
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
|
|
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": "
|
|
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
|
+
}
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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() {
|