modwright 0.1.2 → 0.1.3
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 +41 -2
- package/bridges/cp2077-cet/ModWrightBridge/probes.lua +113 -0
- package/bridges/cp2077-cet/README.md +3 -1
- package/bridges/cp2077-cet/bridge.json +1 -1
- package/dist/core/build/stage.js +3 -2
- package/dist/core/ini.js +26 -0
- package/dist/core/knowledge/facts.js +0 -12
- package/dist/core/logs/registry.js +19 -0
- package/dist/core/logs/scan.js +44 -5
- package/dist/core/logs/triage.js +79 -2
- package/dist/core/managers/mo2.js +121 -0
- package/dist/core/managers/vortex.js +52 -0
- package/dist/core/ownership/index.js +364 -0
- package/dist/core/project/index.js +1 -1
- package/dist/core/project/load.js +5 -0
- package/dist/core/testplan/registry.js +51 -2
- package/dist/core/writes/index.js +206 -0
- package/dist/index.js +0 -2
- package/dist/server.js +142 -13
- package/dist/surfaces/baldursgate3/surface.js +3 -0
- package/dist/surfaces/baldursgate3/validators/story.js +3 -3
- package/dist/surfaces/baldursgate3/writes.js +152 -0
- package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
- package/dist/surfaces/cyberpunk2077/logs.js +82 -3
- package/dist/surfaces/cyberpunk2077/surface.js +11 -0
- package/dist/surfaces/cyberpunk2077/writes.js +67 -0
- package/dist/surfaces/skyrimse/loadorder.js +2 -26
- package/dist/surfaces/skyrimse/surface.js +1 -0
- package/dist/surfaces/stardewvalley/logs.js +41 -0
- package/dist/surfaces/stardewvalley/surface.js +2 -0
- package/dist/surfaces/stardewvalley/writes.js +136 -0
- package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
- package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
- package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
- package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
- package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
- package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
- package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
- package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
- package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
- package/package.json +1 -1
|
@@ -140,9 +140,9 @@ function extractBindings(node, file, line) {
|
|
|
140
140
|
}
|
|
141
141
|
return bindings;
|
|
142
142
|
}
|
|
143
|
-
export function parseTweakXLYaml(text, file) {
|
|
143
|
+
export function parseTweakXLYaml(text, file, options = {}) {
|
|
144
144
|
const lineCounter = new YAML.LineCounter();
|
|
145
|
-
const doc = YAML.parseDocument(text, { lineCounter });
|
|
145
|
+
const doc = YAML.parseDocument(text, { lineCounter, uniqueKeys: !options.allowDuplicateKeys });
|
|
146
146
|
if (doc.errors.length > 0) {
|
|
147
147
|
const first = doc.errors[0];
|
|
148
148
|
const line = first.pos ? lineCounter.linePos(first.pos[0]).line : 1;
|
|
@@ -355,6 +355,12 @@ const TWEAKXL = [
|
|
|
355
355
|
".yml or .tweak extension — TweakXL never saw it.",
|
|
356
356
|
seenIn: [`${REAL}/tweakxl.log`],
|
|
357
357
|
extract: ["file"],
|
|
358
|
+
attribution: {
|
|
359
|
+
kind: "read-order",
|
|
360
|
+
subjectLabel: "TweakXL tweak files (read order; a later file wins the same flat)",
|
|
361
|
+
actor: "file",
|
|
362
|
+
alsoFinding: true,
|
|
363
|
+
},
|
|
358
364
|
},
|
|
359
365
|
{
|
|
360
366
|
id: "cyberpunk2077.tweakxl.yaml-parse-error",
|
|
@@ -538,11 +544,12 @@ const ARCHIVEXL = [
|
|
|
538
544
|
sources: ["archivexl"],
|
|
539
545
|
severity: "warn",
|
|
540
546
|
category: "resource",
|
|
541
|
-
pattern: re(PRE, String.raw `\[Localization\] Item #(?<item>\d+) overwrites entry (?<entry>\d+)
|
|
547
|
+
pattern: re(PRE, String.raw `\[Localization\] Item #(?<item>\d+) overwrites (?<key>entry (?<entry>\d+))(?: \("(?<secondary>[^"\n]*)"\))?\.`),
|
|
542
548
|
explain: "Two localization sources define the same string key; the later one wins. Harmless " +
|
|
543
549
|
"when intended, but a mod's own name showing up wrong usually traces back to this.",
|
|
544
|
-
seenIn: [`${REAL}/archivexl.log
|
|
545
|
-
extract: ["item", "entry"],
|
|
550
|
+
seenIn: [`${REAL}/archivexl.log`, "psiberx/cp2077-archive-xl src/App/Extensions/Localization/Extension.cpp (secondary-key form)"],
|
|
551
|
+
extract: ["item", "entry", "secondary"],
|
|
552
|
+
attribution: { kind: "overwrite", subject: "key", detail: "secondary", alsoFinding: true },
|
|
546
553
|
systemImpact: false,
|
|
547
554
|
},
|
|
548
555
|
{
|
|
@@ -601,6 +608,49 @@ const ARCHIVEXL = [
|
|
|
601
608
|
],
|
|
602
609
|
extract: ["kind", "path"],
|
|
603
610
|
},
|
|
611
|
+
{
|
|
612
|
+
id: "cyberpunk2077.archivexl.localization-merge-source",
|
|
613
|
+
game: GAME,
|
|
614
|
+
sources: ["archivexl"],
|
|
615
|
+
severity: "info",
|
|
616
|
+
category: "attribution",
|
|
617
|
+
pattern: re(PRE, String.raw `\[Localization\] Merging entries from "(?<file>[^"\n]+)"\.\.\.`),
|
|
618
|
+
explain: "ArchiveXL is merging one localization file; any 'overwrites entry' line after it was " +
|
|
619
|
+
"caused by this file.",
|
|
620
|
+
seenIn: [
|
|
621
|
+
"psiberx/cp2077-archive-xl src/App/Extensions/Localization/Extension.cpp (source-read 2026-09-29; no real sample yet)",
|
|
622
|
+
],
|
|
623
|
+
attribution: { kind: "overwrite", actor: "file", setsContext: true },
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
id: "cyberpunk2077.archivexl.animations-target",
|
|
627
|
+
game: GAME,
|
|
628
|
+
sources: ["archivexl"],
|
|
629
|
+
severity: "info",
|
|
630
|
+
category: "attribution",
|
|
631
|
+
pattern: re(PRE, String.raw `\[EntityAnimation\] Initializing animations for "(?<target>[^"\n]+)"\.\.\.`),
|
|
632
|
+
explain: "ArchiveXL is about to merge extra animation sets into one entity component " +
|
|
633
|
+
"(<template>:<component>); the 'Merging animations from' lines after it name them.",
|
|
634
|
+
seenIn: [
|
|
635
|
+
"psiberx/cp2077-archive-xl src/App/Extensions/Animation/Extension.cpp (source-read 2026-09-29; no real sample yet)",
|
|
636
|
+
],
|
|
637
|
+
attribution: { kind: "merge", subject: "target", setsContext: true },
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
id: "cyberpunk2077.archivexl.animations-merge",
|
|
641
|
+
game: GAME,
|
|
642
|
+
sources: ["archivexl"],
|
|
643
|
+
severity: "info",
|
|
644
|
+
category: "attribution",
|
|
645
|
+
pattern: re(PRE, String.raw `\[EntityAnimation\] Merging animations from "(?<anims>[^"\n]+)" with priority (?<priority>-?\d+)\.\.\.`),
|
|
646
|
+
explain: "One mod's animation set merged into the component named by the preceding " +
|
|
647
|
+
"'Initializing animations for' line, at this priority. Two sets animating the same " +
|
|
648
|
+
"action on one component is the usual 'wrong animation plays' conflict.",
|
|
649
|
+
seenIn: [
|
|
650
|
+
"psiberx/cp2077-archive-xl src/App/Extensions/Animation/Extension.cpp (source-read 2026-09-29; no real sample yet)",
|
|
651
|
+
],
|
|
652
|
+
attribution: { kind: "merge", actor: "anims", detail: "priority" },
|
|
653
|
+
},
|
|
604
654
|
];
|
|
605
655
|
const CET_PRE = String.raw `\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC[+-]\d{2}:\d{2}\] \[info\] \[[^\]\n]*\] \[\d+\] `;
|
|
606
656
|
const LUA_LOCATION = String.raw `(?<file>[^\n]*?(?:mods[\\/](?<mod>[^\\/\n]+)[\\/])?[^\\/\n]*\.lua):(?<line>\d+):\s*`;
|
|
@@ -733,6 +783,34 @@ const CODEWARE = [
|
|
|
733
783
|
seenIn: [`${REAL}/codeware.log`],
|
|
734
784
|
},
|
|
735
785
|
];
|
|
786
|
+
const REDHOTTOOLS = [
|
|
787
|
+
{
|
|
788
|
+
id: "cyberpunk2077.redhottools.resource-not-found",
|
|
789
|
+
game: GAME,
|
|
790
|
+
sources: ["redhottools"],
|
|
791
|
+
severity: "warn",
|
|
792
|
+
category: "resource",
|
|
793
|
+
pattern: re(PRE, String.raw `Resource not found: hash=(?<hash>\d+) path="(?:\?|(?<path>[^"\n]+))" archive="(?:\?|(?<archive>[^"\n]+))"`),
|
|
794
|
+
explain: "The engine asked for a resource that no loaded archive provides. RHT only logs this for " +
|
|
795
|
+
"requests tied to a mod archive (it skips the base game's and DLC's), and names that " +
|
|
796
|
+
"archive — the usual trail from an invisible, purple or 'black and shiny' item back to the " +
|
|
797
|
+
"mod whose file points at something that is not there. Which role the named archive played " +
|
|
798
|
+
"in the request (the referencing mod or only the one searched) is not yet confirmed on a real log.",
|
|
799
|
+
hint: "Check the named archive's depot paths for a typo or a file it forgot to ship, and whether it " +
|
|
800
|
+
"needs another mod (a body framework, an ArchiveXL resource) that is not installed.",
|
|
801
|
+
seenIn: ["psiberx/cp2077-red-hot-tools src/App/Archives/ResourceLogger.cpp"],
|
|
802
|
+
extract: ["hash", "path", "archive"],
|
|
803
|
+
systemImpact: false,
|
|
804
|
+
attribution: {
|
|
805
|
+
kind: "missing-resource",
|
|
806
|
+
subject: "path",
|
|
807
|
+
subjectFallback: "hash",
|
|
808
|
+
actor: "archive",
|
|
809
|
+
detail: "hash",
|
|
810
|
+
alsoFinding: true,
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
];
|
|
736
814
|
export const SIGNATURES = [
|
|
737
815
|
...RED4EXT,
|
|
738
816
|
...REDSCRIPT,
|
|
@@ -740,4 +818,5 @@ export const SIGNATURES = [
|
|
|
740
818
|
...ARCHIVEXL,
|
|
741
819
|
...CET,
|
|
742
820
|
...CODEWARE,
|
|
821
|
+
...REDHOTTOOLS,
|
|
743
822
|
];
|
|
@@ -5,6 +5,7 @@ import { findInSteam } from "../../core/steam.js";
|
|
|
5
5
|
import { dedupeByRoot, logs, roots } from "../../core/surfaceutil.js";
|
|
6
6
|
import { cp2077Index } from "./index/index.js";
|
|
7
7
|
import { SIGNATURES } from "./logs.js";
|
|
8
|
+
import { extractCyberpunkWrites } from "./writes.js";
|
|
8
9
|
import { BRIDGE_VALIDATORS } from "./validators/bridge.js";
|
|
9
10
|
import { GARMENT_VALIDATORS } from "./validators/garments.js";
|
|
10
11
|
import { MESH_VALIDATORS } from "./validators/mesh.js";
|
|
@@ -188,9 +189,19 @@ export const cyberpunk2077 = {
|
|
|
188
189
|
"initialized.' A missing 'is initialized' line means every redscript mod that " +
|
|
189
190
|
"depends on Codeware is running without it.",
|
|
190
191
|
},
|
|
192
|
+
{
|
|
193
|
+
id: "redhottools",
|
|
194
|
+
label: "Red Hot Tools",
|
|
195
|
+
candidates: pluginLogCandidates(install, "RedHotTools"),
|
|
196
|
+
rootId: "red4ext",
|
|
197
|
+
notes: "Red Hot Tools' own log. Besides its hot-reload lines it records 'Resource not found: " +
|
|
198
|
+
"hash=… path=\"…\" archive=\"…\"' for every failed request tied to a mod archive, which " +
|
|
199
|
+
"names the archive behind a missing resource. Only present when Red Hot Tools is installed.",
|
|
200
|
+
},
|
|
191
201
|
]);
|
|
192
202
|
},
|
|
193
203
|
logSignatures: SIGNATURES,
|
|
204
|
+
extractWrites: (mod, root) => extractCyberpunkWrites(mod, root),
|
|
194
205
|
index: cp2077Index,
|
|
195
206
|
validators: [
|
|
196
207
|
...TWEAK_VALIDATORS,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { TweakParseError } from "./index/model.js";
|
|
4
|
+
import { parseTweakFile } from "./index/parsers/tweak.js";
|
|
5
|
+
import { parseTweakXLYaml } from "./index/parsers/tweakxl-yaml.js";
|
|
6
|
+
const TWEAK_EXTENSIONS = new Set([".yaml", ".yml", ".tweak"]);
|
|
7
|
+
const MAX_TWEAK_BYTES = 8 * 1024 * 1024;
|
|
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 " +
|
|
9
|
+
"(or a file directly in r6/tweaks) is taken as one mod — how mods are packaged, not something the game records.";
|
|
10
|
+
function recordWrites(records, file, unit) {
|
|
11
|
+
const writes = [];
|
|
12
|
+
const withUnit = (w) => (unit ? { ...w, unit } : w);
|
|
13
|
+
for (const record of records) {
|
|
14
|
+
if (record.type !== undefined || record.base !== undefined) {
|
|
15
|
+
const value = {};
|
|
16
|
+
if (record.type !== undefined)
|
|
17
|
+
value.$type = record.type;
|
|
18
|
+
if (record.base !== undefined)
|
|
19
|
+
value.$base = record.base;
|
|
20
|
+
writes.push(withUnit({ key: record.name, keyKind: "tweakdb-record", op: "create", value, file, line: record.line, certainty: "definite" }));
|
|
21
|
+
}
|
|
22
|
+
for (const flat of record.flats) {
|
|
23
|
+
writes.push(withUnit({
|
|
24
|
+
key: `${record.name}.${flat.name}`,
|
|
25
|
+
keyKind: "tweakdb-flat",
|
|
26
|
+
op: flat.op,
|
|
27
|
+
value: flat.value,
|
|
28
|
+
file,
|
|
29
|
+
line: record.line,
|
|
30
|
+
certainty: "definite",
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return writes;
|
|
35
|
+
}
|
|
36
|
+
async function parseFile(abs) {
|
|
37
|
+
const stat = await fs.stat(abs);
|
|
38
|
+
if (stat.size > MAX_TWEAK_BYTES)
|
|
39
|
+
throw new Error(`larger than ${MAX_TWEAK_BYTES} bytes; skipped`);
|
|
40
|
+
const text = await fs.readFile(abs, "utf8");
|
|
41
|
+
return path.extname(abs).toLowerCase() === ".tweak" ? parseTweakFile(text, abs) : parseTweakXLYaml(text, abs, { allowDuplicateKeys: true });
|
|
42
|
+
}
|
|
43
|
+
export async function extractCyberpunkWrites(mod, root) {
|
|
44
|
+
let files;
|
|
45
|
+
if (root.id === "tweaks")
|
|
46
|
+
files = mod.files;
|
|
47
|
+
else if (root.id === "redmod")
|
|
48
|
+
files = mod.files.filter((f) => f.toLowerCase().startsWith("tweaks/"));
|
|
49
|
+
else
|
|
50
|
+
return undefined;
|
|
51
|
+
files = files.filter((f) => TWEAK_EXTENSIONS.has(path.extname(f).toLowerCase()));
|
|
52
|
+
const extraction = { writes: [], errors: [] };
|
|
53
|
+
if (root.id === "tweaks")
|
|
54
|
+
extraction.unitNote = TWEAKS_UNIT_NOTE;
|
|
55
|
+
for (const rel of files) {
|
|
56
|
+
const abs = path.join(mod.path, rel);
|
|
57
|
+
const unit = root.id === "tweaks" ? rel.split("/")[0] : undefined;
|
|
58
|
+
try {
|
|
59
|
+
extraction.writes.push(...recordWrites(await parseFile(abs), abs, unit));
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const message = error instanceof TweakParseError ? error.message : `${abs}: ${error.message}`;
|
|
63
|
+
extraction.errors.push({ file: abs, error: message });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return extraction;
|
|
67
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, promises as fs } from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { documents, localAppData, pathExists } from "../../core/fsutil.js";
|
|
4
|
+
import { parseIni } from "../../core/ini.js";
|
|
4
5
|
export const STOREFRONT_FOLDER = {
|
|
5
6
|
steam: "Skyrim Special Edition",
|
|
6
7
|
gog: "Skyrim Special Edition GOG",
|
|
@@ -68,32 +69,7 @@ export async function readCcc(root) {
|
|
|
68
69
|
.map((l) => l.trim())
|
|
69
70
|
.filter((l) => l.length > 0);
|
|
70
71
|
}
|
|
71
|
-
export
|
|
72
|
-
const out = new Map();
|
|
73
|
-
let section = "";
|
|
74
|
-
for (const raw of text.split(/\r?\n/)) {
|
|
75
|
-
const line = raw.trim();
|
|
76
|
-
if (!line || line.startsWith(";") || line.startsWith("#"))
|
|
77
|
-
continue;
|
|
78
|
-
const sec = /^\[(.+)\]$/.exec(line);
|
|
79
|
-
if (sec) {
|
|
80
|
-
section = sec[1].trim().toLowerCase();
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
const eq = line.indexOf("=");
|
|
84
|
-
if (eq < 0)
|
|
85
|
-
continue;
|
|
86
|
-
const key = line.slice(0, eq).trim().toLowerCase();
|
|
87
|
-
let value = line.slice(eq + 1).trim();
|
|
88
|
-
const wrapped = /^@ByteArray\((.*)\)$/.exec(value);
|
|
89
|
-
if (wrapped)
|
|
90
|
-
value = wrapped[1];
|
|
91
|
-
if (!out.has(section))
|
|
92
|
-
out.set(section, new Map());
|
|
93
|
-
out.get(section).set(key, value);
|
|
94
|
-
}
|
|
95
|
-
return out;
|
|
96
|
-
}
|
|
72
|
+
export { parseIni } from "../../core/ini.js";
|
|
97
73
|
function samePath(a, b) {
|
|
98
74
|
const norm = (p) => path.resolve(p.replace(/\\\\/g, "\\")).replace(/[\\/]+$/, "").toLowerCase();
|
|
99
75
|
return norm(a) === norm(b);
|
|
@@ -19,6 +19,7 @@ export const skyrimse = {
|
|
|
19
19
|
aliases: ["skyrim", "skyrimspecialedition", "sse", "skse"],
|
|
20
20
|
summary: "Creation Engine. Plugins (.esp/.esm/.esl) and archives (.bsa) in Data/, " +
|
|
21
21
|
"with load order in Plugins.txt and native extensions via SKSE64.",
|
|
22
|
+
managedDataDir: "Data",
|
|
22
23
|
async detect() {
|
|
23
24
|
const installs = [];
|
|
24
25
|
const override = process.env.MODWRIGHT_SKYRIMSE_ROOT;
|
|
@@ -4,6 +4,9 @@ const SCORE = "SMAPI src/SMAPI/Framework/SCore.cs";
|
|
|
4
4
|
const LOGMANAGER = "SMAPI src/SMAPI/Framework/Logging/LogManager.cs";
|
|
5
5
|
const CP = "Content Patcher ContentPatcher/ModEntry.cs and Framework/PatchLoader.cs";
|
|
6
6
|
const PENDING = "pending a real sample";
|
|
7
|
+
const CONTENT_MANAGER = "SMAPI src/SMAPI/Framework/ContentManagers/GameContentManager.cs (source-read 2026-09-29)";
|
|
8
|
+
const SMAPI_PREFIX = String.raw `^\[(?<time>\d\d[:.]\d\d[:.]\d\d) (?<level>[A-Za-z]+)(?: +screen_(?<screen>\d+))? +SMAPI\] `;
|
|
9
|
+
const ON_BEHALF = String.raw `(?: \(for the '(?<via>.+)' content pack\))?\.$`;
|
|
7
10
|
export const PREFIX = String.raw `^\[(?<time>\d\d[:.]\d\d[:.]\d\d) (?<level>[A-Za-z]+)(?: +screen_(?<screen>\d+))? +(?<source>[^\]]+)\] `;
|
|
8
11
|
function re(body) {
|
|
9
12
|
return new RegExp(PREFIX + body);
|
|
@@ -304,4 +307,42 @@ export const SIGNATURES = [
|
|
|
304
307
|
seenIn: [`${CP}`, PENDING],
|
|
305
308
|
extract: ["pack"],
|
|
306
309
|
}),
|
|
310
|
+
{
|
|
311
|
+
id: "stardewvalley.smapi.asset-edited",
|
|
312
|
+
game: "stardewvalley",
|
|
313
|
+
sources: ["smapi"],
|
|
314
|
+
severity: "info",
|
|
315
|
+
category: "attribution",
|
|
316
|
+
pattern: new RegExp(SMAPI_PREFIX + String.raw `(?<mod>.+?) edited (?<asset>.+?)` + ON_BEHALF),
|
|
317
|
+
explain: "SMAPI applied one mod's edit to a game asset (for a content pack when one is named). Every " +
|
|
318
|
+
"mod that edits an asset gets its own line, in the order the edits were applied, so the list " +
|
|
319
|
+
"for one asset answers 'which mod is changing this'; for the same field, the later editor wins.",
|
|
320
|
+
seenIn: [CONTENT_MANAGER, PENDING],
|
|
321
|
+
attribution: { kind: "edit", subject: "asset", actor: "mod", via: "via" },
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
id: "stardewvalley.smapi.asset-loaded",
|
|
325
|
+
game: "stardewvalley",
|
|
326
|
+
sources: ["smapi"],
|
|
327
|
+
severity: "info",
|
|
328
|
+
category: "attribution",
|
|
329
|
+
pattern: new RegExp(SMAPI_PREFIX + String.raw `(?<mod>.+?) loaded asset '(?<asset>[^']+)'` + ON_BEHALF),
|
|
330
|
+
explain: "One mod supplied a whole asset in place of the game's own (a Content Patcher Load, or a C# " +
|
|
331
|
+
"mod's loader). Only one mod can load a given asset; edits by others then apply on top.",
|
|
332
|
+
seenIn: [CONTENT_MANAGER, PENDING],
|
|
333
|
+
attribution: { kind: "replace", subject: "asset", actor: "mod", via: "via" },
|
|
334
|
+
},
|
|
335
|
+
smapi("asset-edit-crashed", {
|
|
336
|
+
severity: "error",
|
|
337
|
+
category: "resource",
|
|
338
|
+
pattern: re(String.raw `Mod crashed when (?<op>editing|loading) asset '(?<asset>[^']+)'(?: \(for the '(?<via>.+?)' content pack\))?[,.]`),
|
|
339
|
+
explain: "A mod threw while loading or editing a game asset. For a load SMAPI falls back to the game's " +
|
|
340
|
+
"own asset; for an edit the asset keeps whatever the earlier editors did, so the mod's change " +
|
|
341
|
+
"(and anything depending on it) is missing. The mod is the line's source.",
|
|
342
|
+
hint: "The continuation lines carry the exception; update or remove the named mod or content pack.",
|
|
343
|
+
seenIn: [CONTENT_MANAGER, PENDING],
|
|
344
|
+
extract: ["source", "op", "asset", "via"],
|
|
345
|
+
systemImpact: false,
|
|
346
|
+
attribution: { kind: "failed", subject: "asset", actor: "source", via: "via", detail: "op", alsoFinding: true },
|
|
347
|
+
}),
|
|
307
348
|
];
|
|
@@ -7,6 +7,7 @@ import { findInSteam } from "../../core/steam.js";
|
|
|
7
7
|
import { dedupeByRoot, logs, roots } from "../../core/surfaceutil.js";
|
|
8
8
|
import { SIGNATURES } from "./logs.js";
|
|
9
9
|
import { WIKI_FETCH } from "./index/wiki.js";
|
|
10
|
+
import { extractStardewWrites } from "./writes.js";
|
|
10
11
|
import { parseManifest, scanMods } from "./mods.js";
|
|
11
12
|
import { stardewvalleyCompat } from "./compat.js";
|
|
12
13
|
import { readSmapiConfig, stringList } from "./config.js";
|
|
@@ -221,6 +222,7 @@ export const stardewvalley = {
|
|
|
221
222
|
return reasons;
|
|
222
223
|
},
|
|
223
224
|
logSignatures: SIGNATURES,
|
|
225
|
+
extractWrites: (mod, root) => extractStardewWrites(mod, root),
|
|
224
226
|
validators: [...MANIFEST_VALIDATORS, ...CONTENT_VALIDATORS, ...FIELDS_VALIDATORS, ...LOADORDER_VALIDATORS],
|
|
225
227
|
index: stardewvalleyIndex,
|
|
226
228
|
compat: stardewvalleyCompat,
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { hasTokens, parseContentJson, patchesOf, pick, splitLexically } from "./contentpatcher.js";
|
|
4
|
+
const MAX_INCLUDE_DEPTH = 5;
|
|
5
|
+
const obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
6
|
+
function push(ctx, write) {
|
|
7
|
+
const full = { ...write, file: ctx.file };
|
|
8
|
+
if (ctx.unit)
|
|
9
|
+
full.unit = ctx.unit;
|
|
10
|
+
ctx.writes.push(full);
|
|
11
|
+
}
|
|
12
|
+
function patchWrites(cfg, ctx) {
|
|
13
|
+
const includes = [];
|
|
14
|
+
for (const patch of patchesOf(cfg).patches) {
|
|
15
|
+
const when = obj(pick(patch.raw, "When")?.value);
|
|
16
|
+
const condition = when && Object.keys(when).length > 0 ? JSON.stringify(when) : undefined;
|
|
17
|
+
const action = patch.action?.trim().toLowerCase();
|
|
18
|
+
if (action === "include") {
|
|
19
|
+
for (const from of splitLexically(patch.fromFile ?? "")) {
|
|
20
|
+
includes.push(condition ? { fromFile: from, condition } : { fromFile: from });
|
|
21
|
+
}
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const targets = splitLexically(patch.target ?? "");
|
|
25
|
+
for (const target of targets) {
|
|
26
|
+
const certainty = (...parts) => condition || hasTokens(target) || parts.some((p) => hasTokens(p)) ? "conditional" : "definite";
|
|
27
|
+
const base = (extra = {}) => (condition ? { condition, ...extra } : extra);
|
|
28
|
+
if (action === "load") {
|
|
29
|
+
const w = { key: target, keyKind: "asset", op: "load", certainty: certainty(), ...base() };
|
|
30
|
+
if (patch.fromFile)
|
|
31
|
+
w.value = patch.fromFile;
|
|
32
|
+
push(ctx, w);
|
|
33
|
+
}
|
|
34
|
+
else if (action === "editdata") {
|
|
35
|
+
const tf = pick(patch.raw, "TargetField")?.value;
|
|
36
|
+
const prefix = Array.isArray(tf) && tf.length > 0 ? `${target}#${tf.map(String).join(".")}` : undefined;
|
|
37
|
+
const entryKey = (k) => (prefix ? `${prefix}.${k}` : `${target}#${k}`);
|
|
38
|
+
let parts = 0;
|
|
39
|
+
const entries = obj(pick(patch.raw, "Entries")?.value);
|
|
40
|
+
for (const [k, v] of Object.entries(entries ?? {})) {
|
|
41
|
+
push(ctx, { key: entryKey(k), keyKind: "asset-entry", op: v === null ? "remove" : "set", value: v, certainty: certainty(k), ...base() });
|
|
42
|
+
parts++;
|
|
43
|
+
}
|
|
44
|
+
const fields = obj(pick(patch.raw, "Fields")?.value);
|
|
45
|
+
for (const [k, fieldMap] of Object.entries(fields ?? {})) {
|
|
46
|
+
for (const [f, v] of Object.entries(obj(fieldMap) ?? {})) {
|
|
47
|
+
push(ctx, { key: `${entryKey(k)}.${f}`, keyKind: "asset-field", op: v === null ? "remove" : "set", value: v, certainty: certainty(k, f), ...base() });
|
|
48
|
+
parts++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const textOps = pick(patch.raw, "TextOperations")?.value;
|
|
52
|
+
for (const op of Array.isArray(textOps) ? textOps : []) {
|
|
53
|
+
const o = obj(op);
|
|
54
|
+
const opTarget = o ? pick(o, "Target")?.value : undefined;
|
|
55
|
+
if (!Array.isArray(opTarget) || opTarget.length < 2)
|
|
56
|
+
continue;
|
|
57
|
+
const [kind, ...rest] = opTarget.map(String);
|
|
58
|
+
const operation = String((o && pick(o, "Operation")?.value) ?? "").toLowerCase();
|
|
59
|
+
const writeOp = operation === "removedelimited" ? "remove" : operation === "replacedelimited" ? "set" : "append";
|
|
60
|
+
const key = kind.toLowerCase() === "fields" ? `${entryKey(rest[0])}.${rest.slice(1).join(".")}` : entryKey(rest.join("."));
|
|
61
|
+
push(ctx, { key, keyKind: kind.toLowerCase() === "fields" ? "asset-field" : "asset-entry", op: writeOp, value: o && pick(o, "Value")?.value, certainty: certainty(...rest), ...base() });
|
|
62
|
+
parts++;
|
|
63
|
+
}
|
|
64
|
+
const moves = pick(patch.raw, "MoveEntries")?.value;
|
|
65
|
+
for (const move of Array.isArray(moves) ? moves : []) {
|
|
66
|
+
const id = obj(move) && pick(obj(move), "ID")?.value;
|
|
67
|
+
if (typeof id !== "string")
|
|
68
|
+
continue;
|
|
69
|
+
push(ctx, { key: entryKey(id), keyKind: "asset-entry", op: "edit", value: move, certainty: certainty(id), ...base() });
|
|
70
|
+
parts++;
|
|
71
|
+
}
|
|
72
|
+
if (parts === 0)
|
|
73
|
+
push(ctx, { key: target, keyKind: "asset", op: "edit", certainty: certainty(), ...base() });
|
|
74
|
+
}
|
|
75
|
+
else if (action === "editimage" || action === "editmap") {
|
|
76
|
+
const w = { key: target, keyKind: "asset", op: "edit", certainty: certainty(), ...base() };
|
|
77
|
+
const toArea = pick(patch.raw, "ToArea")?.value;
|
|
78
|
+
if (toArea !== undefined)
|
|
79
|
+
w.value = { ToArea: toArea };
|
|
80
|
+
push(ctx, w);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return includes;
|
|
85
|
+
}
|
|
86
|
+
async function readPack(packDir, file, unit, extraction, depth) {
|
|
87
|
+
let cfg;
|
|
88
|
+
try {
|
|
89
|
+
cfg = parseContentJson(await fs.readFile(file, "utf8"));
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
extraction.errors.push({ file, error: error.message });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const ctx = { file, unit, writes: [] };
|
|
96
|
+
const includes = patchWrites(cfg, ctx);
|
|
97
|
+
extraction.writes.push(...ctx.writes);
|
|
98
|
+
for (const include of includes) {
|
|
99
|
+
if (hasTokens(include.fromFile)) {
|
|
100
|
+
extraction.errors.push({ file, error: `Include of "${include.fromFile}" uses tokens; not followed.` });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (depth >= MAX_INCLUDE_DEPTH) {
|
|
104
|
+
extraction.errors.push({ file, error: `Include of "${include.fromFile}" is nested more than ${MAX_INCLUDE_DEPTH} deep; not followed.` });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const target = path.resolve(packDir, include.fromFile);
|
|
108
|
+
if (!target.startsWith(path.resolve(packDir) + path.sep)) {
|
|
109
|
+
extraction.errors.push({ file, error: `Include of "${include.fromFile}" points outside the pack; not followed.` });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const before = extraction.writes.length;
|
|
113
|
+
await readPack(packDir, target, unit, extraction, depth + 1);
|
|
114
|
+
if (include.condition) {
|
|
115
|
+
for (const w of extraction.writes.slice(before)) {
|
|
116
|
+
w.certainty = "conditional";
|
|
117
|
+
w.condition = w.condition ? `${include.condition} and ${w.condition}` : include.condition;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export async function extractStardewWrites(mod, root) {
|
|
123
|
+
if (root.id !== "mods")
|
|
124
|
+
return undefined;
|
|
125
|
+
const packs = mod.files.filter((f) => f.toLowerCase() === "content.json" || f.toLowerCase().endsWith("/content.json"));
|
|
126
|
+
if (packs.length === 0)
|
|
127
|
+
return undefined;
|
|
128
|
+
const extraction = { writes: [], errors: [] };
|
|
129
|
+
for (const rel of packs) {
|
|
130
|
+
const packRel = path.posix.dirname(rel);
|
|
131
|
+
const packDir = path.join(mod.path, packRel);
|
|
132
|
+
const unit = packRel === "." ? undefined : packRel;
|
|
133
|
+
await readPack(packDir, path.join(mod.path, rel), unit, extraction, 0);
|
|
134
|
+
}
|
|
135
|
+
return extraction;
|
|
136
|
+
}
|
|
@@ -384,3 +384,32 @@ facts:
|
|
|
384
384
|
Whether the journal .lsx under Mods/<Mod>/Story/Journals loads as loose .lsx was not separated
|
|
385
385
|
from the missing compile; the RootTemplates rule (loose .lsx not loaded, .lsf needed) suggests
|
|
386
386
|
it does not.
|
|
387
|
+
- id: bg3.dialogue.osiris-goals.prefix-convention-is-a-compiler-warning-in-lslib
|
|
388
|
+
claim: In LSLib's Osiris story compiler a database whose name does not start with "DB", a PROC whose
|
|
389
|
+
name does not start with "PROC" or a query whose name does not start with "QRY" produces a
|
|
390
|
+
warning (W26 DbNamingStyle, W23 RuleNamingStyle), never an error, and W23 is switched off by
|
|
391
|
+
default; the story still compiles.
|
|
392
|
+
status: inferred
|
|
393
|
+
source: Norbyte/lslib@6f5f698 LSLib/LS/Story/Compiler/Compiler.cs VerifyIRRule ("Name of PROC ...
|
|
394
|
+
should start with the prefix PROC", "Name of Query ... should start with the prefix QRY"),
|
|
395
|
+
VerifyDatabases ("Name of database ... should start with the prefix DB");
|
|
396
|
+
CompilationContext.cs DiagnosticCode RuleNamingStyle = "W23", DbNamingStyle = "W26",
|
|
397
|
+
CompilationLog() WarningSwitches[RuleNamingStyle] = false
|
|
398
|
+
tags:
|
|
399
|
+
- osiris
|
|
400
|
+
- naming
|
|
401
|
+
- db-prefix
|
|
402
|
+
- proc-prefix
|
|
403
|
+
- qry-prefix
|
|
404
|
+
- compiler
|
|
405
|
+
- warning
|
|
406
|
+
detail: "This is the community compiler's behaviour, not Larian's in-engine one: there the prefix is
|
|
407
|
+
a style check. Nothing read in that source supports the claim, attributed to a Nexus article,
|
|
408
|
+
that a missing prefix silently disables the whole story component; until the game's own
|
|
409
|
+
compiler is shown to behave differently, the bg3.story.osiris-naming-convention validator rule
|
|
410
|
+
stays informational. The checks compare the first 2-4 characters case-insensitively
|
|
411
|
+
(\"PROC\"/\"QRY\"/\"DB\"), and the PROC and QRY checks skip names no longer than the prefix,
|
|
412
|
+
so \"Proc_X\" and \"dbFoo\" pass them."
|
|
413
|
+
related:
|
|
414
|
+
- id: bg3.dialogue.osiris-goals.qry-proc-color-coding
|
|
415
|
+
detail: Agrees on the convention itself; adds what the compiler does when it is broken.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
game: baldursgate3
|
|
2
|
+
topic: osiris.tags
|
|
3
|
+
title: "Osiris entity tags: SetTag/IsTagged/ClearTag and the tag events, from Larian's modding wiki
|
|
4
|
+
and the generated stubs"
|
|
5
|
+
facts:
|
|
6
|
+
- id: bg3.osiris.tags.settag-istagged-cleartag-signatures
|
|
7
|
+
claim: Osiris tags an entity with the call SetTag((GUIDSTRING)_Target, (TAG)_Tag), tests it with the
|
|
8
|
+
query IsTagged([in](GUIDSTRING)_Target, [in](TAG)_Tag, [out](INTEGER)_Bool), and removes it
|
|
9
|
+
with the call ClearTag((GUIDSTRING)_Source, (TAG)_Tag). In the Script Extender's Osi table
|
|
10
|
+
these are Osi.SetTag(target, tag), Osi.IsTagged(target, tag) -> integer, and
|
|
11
|
+
Osi.ClearTag(target, tag).
|
|
12
|
+
status: verified
|
|
13
|
+
source: wiki pages SetTag ("call SetTag((GUIDSTRING)_Target, (TAG)_Tag)"), IsTagged ("query
|
|
14
|
+
IsTagged([in](GUIDSTRING)_Target, [in](TAG)_Tag, [out](INTEGER)_Bool)"), ClearTag
|
|
15
|
+
("ClearTag((GUIDSTRING)_Source, (TAG)_Tag)"); LaughingLeader/BG3ModdingTools@ab343b6
|
|
16
|
+
generated/ Osi.lua:4273-4275 (SetTag), :1840-1843 (IsTagged, @return integer bool), :2570-2572
|
|
17
|
+
(ClearTag).
|
|
18
|
+
tags:
|
|
19
|
+
- osiris
|
|
20
|
+
- tag
|
|
21
|
+
- settag
|
|
22
|
+
- istagged
|
|
23
|
+
- cleartag
|
|
24
|
+
- signature
|
|
25
|
+
detail: "Verified as declared signatures only; what the calls do at runtime was not observed. The
|
|
26
|
+
wiki's own examples: SetTag(_ParentA, (TAG)_SHADOW_CURSE_IMMUNE) inside a PROC;
|
|
27
|
+
IsTagged(_Player, (TAG)_DARK_URGE, 1) as an AND condition in a QRY (the trailing 1 binds the
|
|
28
|
+
out-param to \"true\"). A TAG is a UUID-keyed resource, referenced in story scripts by a named
|
|
29
|
+
constant; the wiki examples use (TAG)_NAME constants, which the story compiler resolves."
|
|
30
|
+
- id: bg3.osiris.tags.cleartag-only-clears-osiris-set-tags
|
|
31
|
+
claim: ClearTag removes only tags that Osiris itself set. Tags placed via the editor sidebar on a
|
|
32
|
+
root template or on an object instance cannot be cleared this way (a polymorph status replaces
|
|
33
|
+
them with the target template's, but plain Transform calls leave them), and tags set from
|
|
34
|
+
Anubis or from dialogs can only be cleared from those same systems (dialog tags via
|
|
35
|
+
ClearDialogTag).
|
|
36
|
+
status: community
|
|
37
|
+
source: wiki page ClearTag, "Further Information" section (last edited 2024-12-16); ClearDialogTag
|
|
38
|
+
is Osi.ClearDialogTag(target, tag) at LaughingLeader/BG3ModdingTools@ab343b6
|
|
39
|
+
generated/Osi.lua:2522-2524.
|
|
40
|
+
tags:
|
|
41
|
+
- osiris
|
|
42
|
+
- tag
|
|
43
|
+
- cleartag
|
|
44
|
+
- gotcha
|
|
45
|
+
- roottemplate
|
|
46
|
+
- dialog
|
|
47
|
+
related:
|
|
48
|
+
- bg3.osiris.tags.settag-istagged-cleartag-signatures
|
|
49
|
+
detail: "Practical consequence for a probe or test row: IsTagged answers for every tag source, so a
|
|
50
|
+
tag that \"will not clear\" is usually one that was never Osiris-owned — check the root
|
|
51
|
+
template's sidebar tags before treating a failed ClearTag as a scripting bug."
|
|
52
|
+
- id: bg3.osiris.tags.tag-events-and-inventory-tag-queries
|
|
53
|
+
claim: "Osiris declares tag-related events — CharacterTagEvent(character, tag, event),
|
|
54
|
+
TagCleared(target, tag), TagEvent(tag, event), TagSet(target, tag) — and inventory queries
|
|
55
|
+
that take their tags as a string rather than a TAG: GetByTagInInventory(tags, inventoryHolder)
|
|
56
|
+
-> GUIDSTRING, GetItemByTagInInventory(tags, inventoryHolder) -> ITEM, and
|
|
57
|
+
TaggedItemsGetCountInMagicPockets(tags, source) -> integer. HasAppearanceVisualTag(character,
|
|
58
|
+
tag) -> integer tests visual tags separately."
|
|
59
|
+
status: verified
|
|
60
|
+
source: LaughingLeader/BG3ModdingTools@ab343b6 generated/Osi.Events.lua:237
|
|
61
|
+
CharacterTagEvent(character, tag, event); Osi.Events.lua:1197 TagCleared(target, tag);
|
|
62
|
+
Osi.Events.lua:1201 TagEvent(tag, event); Osi.Events.lua:1205 TagSet(target, tag);
|
|
63
|
+
generated/Osi.lua:796-799 (GetByTagInInventory), :1006-1009 (GetItemByTagInInventory),
|
|
64
|
+
:2136-2139 (TaggedItemsGetCountInMagicPockets), :1347-1350 (HasAppearanceVisualTag).
|
|
65
|
+
tags:
|
|
66
|
+
- osiris
|
|
67
|
+
- tag
|
|
68
|
+
- event
|
|
69
|
+
- inventory
|
|
70
|
+
- tag-expression
|
|
71
|
+
related:
|
|
72
|
+
- bg3.osiris.tags.settag-istagged-cleartag-signatures
|
|
73
|
+
detail: Verified as declared signatures only; when the events fire and what the queries return at
|
|
74
|
+
runtime was not observed. The wiki's search-result snippets describe the string argument as a
|
|
75
|
+
tag expression, but its grammar (how several tags combine) is not established by anything read
|
|
76
|
+
here; test it before relying on more than a single tag name. Recorded so a future BG3 probe
|
|
77
|
+
kind for "is the scaffolded entity tagged" has its call shapes on file; none is registered yet
|
|
78
|
+
(bg3.entity.* probes go through the Script Extender's Ext.Entity, not Osiris tags).
|