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.
Files changed (41) hide show
  1. package/README.md +41 -2
  2. package/bridges/cp2077-cet/ModWrightBridge/probes.lua +113 -0
  3. package/bridges/cp2077-cet/README.md +3 -1
  4. package/bridges/cp2077-cet/bridge.json +1 -1
  5. package/dist/core/build/stage.js +3 -2
  6. package/dist/core/ini.js +26 -0
  7. package/dist/core/knowledge/facts.js +0 -12
  8. package/dist/core/logs/registry.js +19 -0
  9. package/dist/core/logs/scan.js +44 -5
  10. package/dist/core/logs/triage.js +79 -2
  11. package/dist/core/managers/mo2.js +121 -0
  12. package/dist/core/managers/vortex.js +52 -0
  13. package/dist/core/ownership/index.js +364 -0
  14. package/dist/core/project/index.js +1 -1
  15. package/dist/core/project/load.js +5 -0
  16. package/dist/core/testplan/registry.js +51 -2
  17. package/dist/core/writes/index.js +206 -0
  18. package/dist/index.js +0 -2
  19. package/dist/server.js +142 -13
  20. package/dist/surfaces/baldursgate3/surface.js +3 -0
  21. package/dist/surfaces/baldursgate3/validators/story.js +3 -3
  22. package/dist/surfaces/baldursgate3/writes.js +152 -0
  23. package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
  24. package/dist/surfaces/cyberpunk2077/logs.js +82 -3
  25. package/dist/surfaces/cyberpunk2077/surface.js +11 -0
  26. package/dist/surfaces/cyberpunk2077/writes.js +67 -0
  27. package/dist/surfaces/skyrimse/loadorder.js +2 -26
  28. package/dist/surfaces/skyrimse/surface.js +1 -0
  29. package/dist/surfaces/stardewvalley/logs.js +41 -0
  30. package/dist/surfaces/stardewvalley/surface.js +2 -0
  31. package/dist/surfaces/stardewvalley/writes.js +136 -0
  32. package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
  33. package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
  34. package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
  35. package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
  36. package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
  37. package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
  38. package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
  39. package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
  40. package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
  41. 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 function parseIni(text) {
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).