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
|
@@ -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
|
},
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
game: cyberpunk2077
|
|
2
|
+
topic: cet.natives
|
|
3
|
+
title: CET-added native functions used to set up a test (spawn, teleport, level)
|
|
4
|
+
facts:
|
|
5
|
+
- id: cp2077.cet.natives.exentityspawner-spawn-despawn
|
|
6
|
+
claim: "`exEntitySpawner` is a native that CET adds. `exEntitySpawner.Spawn(entPath, transform,
|
|
7
|
+
appearance)` spawns an entity from a resource path (backslash form, e.g.
|
|
8
|
+
`base\\characters\\entities\\gang\\gang__wraith_ma.ent`) at the given WorldTransform and
|
|
9
|
+
returns an entity id handle (userdata); `Game.FindEntityByID(handle)` resolves it to the
|
|
10
|
+
entity object. `exEntitySpawner.Despawn(entity)` despawns it, and takes the resolved object,
|
|
11
|
+
not the id handle."
|
|
12
|
+
status: verified
|
|
13
|
+
verified_on: "2.31"
|
|
14
|
+
source: ModWright field verification
|
|
15
|
+
tags:
|
|
16
|
+
- cet
|
|
17
|
+
- native
|
|
18
|
+
- spawn
|
|
19
|
+
- despawn
|
|
20
|
+
- workbench
|
|
21
|
+
- exentityspawner
|
|
22
|
+
detail: "Backs the probe cp2077.world.spawn. Two caveats from the 2026-09-12 run: spawning at the
|
|
23
|
+
player's own transform puts the player inside the spawned entity until they separate; and the
|
|
24
|
+
id handle does not survive across bridge calls (each cp2077.lua.eval is a fresh state), so a
|
|
25
|
+
despawn or respawn has to resolve the entity within the same call. No call is known that turns
|
|
26
|
+
a stored id string back into a live EntityID. CET's WorldFunctionalTests
|
|
27
|
+
(SpawnEntity/DespawnEntity) is the other spawn native it adds; this fact is about
|
|
28
|
+
exEntitySpawner, the one exercised. On 2026-10-04 the spawned entity appeared, but
|
|
29
|
+
`Game.FindEntityByID(handle)` in the same call returned nothing: the entity arrives after
|
|
30
|
+
`Spawn` returns, so resolve it in a later call."
|
|
31
|
+
- id: cp2077.cet.natives.teleport-and-setlevel-work-from-cet
|
|
32
|
+
claim: From CET Lua, `Game.GetTeleportationFacility():Teleport(Game.GetPlayer(), Vector4.new(x, y,
|
|
33
|
+
z, 1), EulerAngles.new(0, 0, yaw))` moves the player to that position, and
|
|
34
|
+
`Game.SetLevel("Level", n, 1)` sets the player level; both take effect at once. The level
|
|
35
|
+
reads back as `Game.GetStatsSystem():GetStatValue(Game.GetPlayer():GetEntityID(),
|
|
36
|
+
gamedataStatType.Level)`.
|
|
37
|
+
status: verified
|
|
38
|
+
verified_on: "2.31"
|
|
39
|
+
source: ModWright field verification
|
|
40
|
+
tags:
|
|
41
|
+
- cet
|
|
42
|
+
- native
|
|
43
|
+
- teleport
|
|
44
|
+
- level
|
|
45
|
+
- workbench
|
|
46
|
+
detail: Backs the probes cp2077.player.teleport and cp2077.player.level. The teleport's z settled
|
|
47
|
+
0.01 above the target, which is the ground under the player. A save that has been teleported
|
|
48
|
+
or levelled this way should not be kept.
|
|
@@ -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
|