modwright 0.1.1 → 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 +123 -15
- package/bridges/bg3-se/README.md +15 -0
- package/bridges/cp2077-cet/ModWrightBridge/probes.lua +113 -0
- package/bridges/cp2077-cet/README.md +17 -1
- package/bridges/cp2077-cet/bridge.json +1 -1
- package/dist/core/bridge/pending.js +8 -1
- package/dist/core/bridge/stage.js +2 -2
- package/dist/core/build/run.js +14 -0
- package/dist/core/build/stage.js +3 -2
- package/dist/core/build/steps/core.js +4 -4
- package/dist/core/deploy/plan.js +68 -1
- package/dist/core/deploy/reload.js +5 -3
- package/dist/core/ini.js +26 -0
- package/dist/core/knowledge/facts.js +0 -12
- package/dist/core/ledger/hooks.js +22 -5
- package/dist/core/logs/registry.js +19 -0
- package/dist/core/logs/scan.js +65 -10
- 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 +2 -1
- package/dist/core/project/load.js +7 -25
- package/dist/core/project/outside-root.js +36 -0
- package/dist/core/project/schema.js +38 -1
- package/dist/core/safety/backup.js +2 -2
- package/dist/core/safety/execute.js +12 -4
- package/dist/core/safety/restore.js +62 -0
- package/dist/core/testplan/registry.js +51 -2
- package/dist/core/text/converters/index.js +1 -1
- package/dist/core/text/toolchain.js +2 -2
- package/dist/core/toolchain/locate.js +13 -1
- package/dist/core/toolchain/run.js +6 -4
- package/dist/core/toolchain/types.js +1 -1
- package/dist/core/trust.js +105 -0
- package/dist/core/writes/index.js +206 -0
- package/dist/index.js +33 -9
- package/dist/server.js +252 -34
- 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/compat.js +8 -4
- package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
- package/dist/surfaces/cyberpunk2077/logs.js +86 -6
- package/dist/surfaces/cyberpunk2077/surface.js +13 -1
- package/dist/surfaces/cyberpunk2077/validators/cr2w.js +9 -3
- package/dist/surfaces/cyberpunk2077/writes.js +67 -0
- package/dist/surfaces/eldenring/loaders.js +7 -1
- 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
- package/scaffolds/baldursgate3/new-project.yaml +4 -1
- package/scaffolds/cyberpunk2077/new-project.yaml +4 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { parseStats } from "./index/parsers/stats.js";
|
|
4
|
+
import { localAppData, readTextIfExists } from "../../core/fsutil.js";
|
|
5
|
+
import { parseMetaLsxModuleInfo } from "../../core/lsx.js";
|
|
6
|
+
import { parseModSettings } from "./modsettings.js";
|
|
7
|
+
import { readPakEntry, readPakListing, readPakModuleInfos } from "./pak.js";
|
|
8
|
+
const STATS_FILE = /^Public\/([^/]+)\/Stats\/Generated\/Data\/[^/]+\.txt$/i;
|
|
9
|
+
const MAX_STATS_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
export const LOOSE_UNIT_NOTE = "In the game's Data folder each Public/<Mod> folder with stats files is taken as one unpacked mod.";
|
|
11
|
+
function entryWrites(entries, file, unit) {
|
|
12
|
+
const writes = [];
|
|
13
|
+
const withUnit = (w) => (unit ? { ...w, unit } : w);
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const value = { type: entry.type };
|
|
16
|
+
if (entry.using !== undefined)
|
|
17
|
+
value.using = entry.using;
|
|
18
|
+
writes.push(withUnit({ key: `stats:${entry.name}`, keyKind: "stats-entry", op: "create", value, file, line: entry.line, certainty: "definite" }));
|
|
19
|
+
for (const [field, fieldValue] of Object.entries(entry.fields)) {
|
|
20
|
+
writes.push(withUnit({ key: `stats:${entry.name}.${field}`, keyKind: "stats-field", op: "set", value: fieldValue, file, line: entry.line, certainty: "definite" }));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return writes;
|
|
24
|
+
}
|
|
25
|
+
const MODSETTINGS = localAppData("Larian Studios", "Baldur's Gate 3", "PlayerProfiles", "Public", "modsettings.lsx");
|
|
26
|
+
const activeCache = new Map();
|
|
27
|
+
async function activeModuleUuids(file) {
|
|
28
|
+
let mtimeMs;
|
|
29
|
+
try {
|
|
30
|
+
mtimeMs = (await fs.stat(file)).mtimeMs;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const cached = activeCache.get(file);
|
|
36
|
+
if (cached && cached.mtimeMs === mtimeMs)
|
|
37
|
+
return cached.uuids;
|
|
38
|
+
try {
|
|
39
|
+
const uuids = new Set(parseModSettings(await fs.readFile(file, "utf8")).map((e) => e.uuid.toLowerCase()).filter(Boolean));
|
|
40
|
+
activeCache.set(file, { mtimeMs, uuids });
|
|
41
|
+
return uuids;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function moduleState(infos, modsettings) {
|
|
48
|
+
const modules = infos.filter((i) => i.uuid);
|
|
49
|
+
if (modules.length === 0)
|
|
50
|
+
return {};
|
|
51
|
+
const identity = `bg3-module:${modules.map((i) => i.uuid.toLowerCase()).sort().join("+")}`;
|
|
52
|
+
const active = await activeModuleUuids(modsettings);
|
|
53
|
+
if (!active || modules.some((i) => active.has(i.uuid.toLowerCase())))
|
|
54
|
+
return { identity };
|
|
55
|
+
const named = modules.map((i) => `${i.name ?? i.folder ?? "?"} (${i.uuid})`).join(", ");
|
|
56
|
+
return { identity, inactive: `module ${named} is not in modsettings.lsx, so the game does not load it` };
|
|
57
|
+
}
|
|
58
|
+
async function pakModuleState(pakPath, modsettings) {
|
|
59
|
+
try {
|
|
60
|
+
return moduleState(await readPakModuleInfos(pakPath), modsettings);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function fromPak(pakPath, modsettings) {
|
|
67
|
+
const extraction = { writes: [], errors: [] };
|
|
68
|
+
const state = await pakModuleState(pakPath, modsettings);
|
|
69
|
+
if (state.inactive)
|
|
70
|
+
extraction.inactive = state.inactive;
|
|
71
|
+
if (state.identity)
|
|
72
|
+
extraction.identity = state.identity;
|
|
73
|
+
let listing;
|
|
74
|
+
try {
|
|
75
|
+
listing = await readPakListing(pakPath);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
extraction.errors.push({ file: pakPath, error: error.message });
|
|
79
|
+
return extraction;
|
|
80
|
+
}
|
|
81
|
+
for (const entry of listing.entries) {
|
|
82
|
+
if (!STATS_FILE.test(entry.name))
|
|
83
|
+
continue;
|
|
84
|
+
const file = `${pakPath}!${entry.name}`;
|
|
85
|
+
try {
|
|
86
|
+
const text = (await readPakEntry(pakPath, entry, MAX_STATS_BYTES)).toString("utf8");
|
|
87
|
+
extraction.writes.push(...entryWrites(parseStats(text, file), file, undefined));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
extraction.errors.push({ file, error: error.message });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return extraction;
|
|
94
|
+
}
|
|
95
|
+
async function looseStatsFiles(dataDir) {
|
|
96
|
+
const out = [];
|
|
97
|
+
let mods;
|
|
98
|
+
try {
|
|
99
|
+
mods = await fs.readdir(path.join(dataDir, "Public"));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
for (const folder of mods) {
|
|
105
|
+
const dir = path.join("Public", folder, "Stats", "Generated", "Data");
|
|
106
|
+
let names;
|
|
107
|
+
try {
|
|
108
|
+
names = await fs.readdir(path.join(dataDir, dir));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
for (const name of names) {
|
|
114
|
+
const rel = `${dir}/${name}`.split(path.sep).join("/");
|
|
115
|
+
if (STATS_FILE.test(rel))
|
|
116
|
+
out.push(rel);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
async function fromLooseData(mod, modsettings) {
|
|
122
|
+
const extraction = { writes: [], errors: [], unitNote: LOOSE_UNIT_NOTE, unitStates: {} };
|
|
123
|
+
for (const rel of await looseStatsFiles(mod.path)) {
|
|
124
|
+
const match = STATS_FILE.exec(rel);
|
|
125
|
+
if (!match)
|
|
126
|
+
continue;
|
|
127
|
+
const unit = match[1];
|
|
128
|
+
if (!(unit in extraction.unitStates)) {
|
|
129
|
+
const meta = await readTextIfExists(path.join(mod.path, "Mods", unit, "meta.lsx")).catch(() => undefined);
|
|
130
|
+
const info = meta ? parseMetaLsxModuleInfo(meta) : undefined;
|
|
131
|
+
extraction.unitStates[unit] = info ? await moduleState([info], modsettings) : {};
|
|
132
|
+
}
|
|
133
|
+
const abs = path.join(mod.path, rel);
|
|
134
|
+
try {
|
|
135
|
+
const stat = await fs.stat(abs);
|
|
136
|
+
if (stat.size > MAX_STATS_BYTES)
|
|
137
|
+
throw new Error(`larger than ${MAX_STATS_BYTES} bytes; skipped`);
|
|
138
|
+
extraction.writes.push(...entryWrites(parseStats(await fs.readFile(abs, "utf8"), abs), abs, match[1]));
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
extraction.errors.push({ file: abs, error: error.message });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return extraction;
|
|
145
|
+
}
|
|
146
|
+
export async function extractBg3Writes(mod, root, options = {}) {
|
|
147
|
+
if (root.id === "paks")
|
|
148
|
+
return fromPak(mod.path, options.modsettings ?? MODSETTINGS);
|
|
149
|
+
if (root.id === "loose")
|
|
150
|
+
return fromLooseData(mod, options.modsettings ?? MODSETTINGS);
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
@@ -210,11 +210,15 @@ const RULES = [
|
|
|
210
210
|
requires: [],
|
|
211
211
|
advisory: true,
|
|
212
212
|
severity: "info",
|
|
213
|
-
note: "RED4ext plugins are
|
|
214
|
-
"
|
|
213
|
+
note: "RED4ext plugins are tied to the game build they were compiled for. Before RED4ext 1.30.0 the " +
|
|
214
|
+
"loader itself refuses an unrecognized patch and no plugin loads; from 1.30.0 the loader runs on " +
|
|
215
|
+
"any build and skips each plugin pinned to another runtime (\"is incompatible with the current " +
|
|
216
|
+
"patch\" in red4ext.log), so a patch now breaks plugins one at a time. After a game patch, wait for " +
|
|
217
|
+
"RED4ext and its plugins to publish updated releases before assuming a mod is broken.",
|
|
215
218
|
source: "https://wiki.redmodding.org/cyberpunk-2077-modding/for-mod-users/user-guide-troubleshooting " +
|
|
216
|
-
"(\"prone to breaking with every update\"); RED4ext
|
|
217
|
-
"
|
|
219
|
+
"(\"prone to breaking with every update\"); RED4ext before 1.30.0 refuses a game patch it does " +
|
|
220
|
+
"not recognize (WopsS/RED4ext#33); 1.30.0 moved the check to each plugin's requested runtime " +
|
|
221
|
+
"(WopsS/RED4ext v1.30.0, src/dll/Systems/PluginSystem.cpp).",
|
|
218
222
|
},
|
|
219
223
|
];
|
|
220
224
|
export const cyberpunk2077Compat = {
|
|
@@ -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;
|
|
@@ -137,8 +137,9 @@ const RED4EXT = [
|
|
|
137
137
|
explain: "RED4ext refuses to run against this game build, so no native plugin loads at " +
|
|
138
138
|
"all this launch. Everything downstream (TweakXL tweaks, ArchiveXL .xl files, " +
|
|
139
139
|
"Codeware-dependent scripts) is dead, and none of them will say so.",
|
|
140
|
-
hint: "Update RED4ext
|
|
141
|
-
"to the supported patch named
|
|
140
|
+
hint: "Update RED4ext (1.30.0 and later no longer refuse an unrecognized build; they " +
|
|
141
|
+
"check each plugin instead), or roll the game back to the supported patch named " +
|
|
142
|
+
"on the following line.",
|
|
142
143
|
seenIn: [
|
|
143
144
|
`${FIXTURES}/red4ext.synthetic.log`,
|
|
144
145
|
"https://github.com/WopsS/RED4ext/issues/33",
|
|
@@ -222,7 +223,7 @@ const REDSCRIPT = [
|
|
|
222
223
|
"or its data, not in compilation.",
|
|
223
224
|
seenIn: [
|
|
224
225
|
`${FIXTURES}/redscript_rCURRENT.ok.synthetic.log`,
|
|
225
|
-
"https://wiki.redmodding.org/redscript/help/troubleshooting",
|
|
226
|
+
"https://wiki.redmodding.org/scripting-cyberpunk/redscript/help/troubleshooting",
|
|
226
227
|
],
|
|
227
228
|
extract: ["output"],
|
|
228
229
|
},
|
|
@@ -354,6 +355,12 @@ const TWEAKXL = [
|
|
|
354
355
|
".yml or .tweak extension — TweakXL never saw it.",
|
|
355
356
|
seenIn: [`${REAL}/tweakxl.log`],
|
|
356
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
|
+
},
|
|
357
364
|
},
|
|
358
365
|
{
|
|
359
366
|
id: "cyberpunk2077.tweakxl.yaml-parse-error",
|
|
@@ -537,11 +544,12 @@ const ARCHIVEXL = [
|
|
|
537
544
|
sources: ["archivexl"],
|
|
538
545
|
severity: "warn",
|
|
539
546
|
category: "resource",
|
|
540
|
-
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]*)"\))?\.`),
|
|
541
548
|
explain: "Two localization sources define the same string key; the later one wins. Harmless " +
|
|
542
549
|
"when intended, but a mod's own name showing up wrong usually traces back to this.",
|
|
543
|
-
seenIn: [`${REAL}/archivexl.log
|
|
544
|
-
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 },
|
|
545
553
|
systemImpact: false,
|
|
546
554
|
},
|
|
547
555
|
{
|
|
@@ -600,6 +608,49 @@ const ARCHIVEXL = [
|
|
|
600
608
|
],
|
|
601
609
|
extract: ["kind", "path"],
|
|
602
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
|
+
},
|
|
603
654
|
];
|
|
604
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+\] `;
|
|
605
656
|
const LUA_LOCATION = String.raw `(?<file>[^\n]*?(?:mods[\\/](?<mod>[^\\/\n]+)[\\/])?[^\\/\n]*\.lua):(?<line>\d+):\s*`;
|
|
@@ -732,6 +783,34 @@ const CODEWARE = [
|
|
|
732
783
|
seenIn: [`${REAL}/codeware.log`],
|
|
733
784
|
},
|
|
734
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
|
+
];
|
|
735
814
|
export const SIGNATURES = [
|
|
736
815
|
...RED4EXT,
|
|
737
816
|
...REDSCRIPT,
|
|
@@ -739,4 +818,5 @@ export const SIGNATURES = [
|
|
|
739
818
|
...ARCHIVEXL,
|
|
740
819
|
...CET,
|
|
741
820
|
...CODEWARE,
|
|
821
|
+
...REDHOTTOOLS,
|
|
742
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";
|
|
@@ -59,7 +60,8 @@ export const cyberpunk2077 = {
|
|
|
59
60
|
extensions: [".archive", ".xl"],
|
|
60
61
|
ordered: true,
|
|
61
62
|
conflictScope: "opaque",
|
|
62
|
-
notes: "Loaded
|
|
63
|
+
notes: "Loaded in ASCII order of filename (modlist.txt overrides it when present); on identical " +
|
|
64
|
+
"resource paths the FIRST-loaded archive wins. " +
|
|
63
65
|
"A .xl sidecar is ArchiveXL and must keep the same base name as its .archive.",
|
|
64
66
|
},
|
|
65
67
|
{
|
|
@@ -187,9 +189,19 @@ export const cyberpunk2077 = {
|
|
|
187
189
|
"initialized.' A missing 'is initialized' line means every redscript mod that " +
|
|
188
190
|
"depends on Codeware is running without it.",
|
|
189
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
|
+
},
|
|
190
201
|
]);
|
|
191
202
|
},
|
|
192
203
|
logSignatures: SIGNATURES,
|
|
204
|
+
extractWrites: (mod, root) => extractCyberpunkWrites(mod, root),
|
|
193
205
|
index: cp2077Index,
|
|
194
206
|
validators: [
|
|
195
207
|
...TWEAK_VALIDATORS,
|
|
@@ -169,6 +169,10 @@ const UNIQUE_IDS_EVIDENCE = [
|
|
|
169
169
|
"cp2077.sectors.sector-json-file-uniqueness",
|
|
170
170
|
"cp2077.toolchain.cli-convert-deserialize",
|
|
171
171
|
];
|
|
172
|
+
function idSpace(jsonPath) {
|
|
173
|
+
const at = jsonPath.lastIndexOf(".Data.Chunks[");
|
|
174
|
+
return at === -1 ? "$" : jsonPath.slice(0, at);
|
|
175
|
+
}
|
|
172
176
|
async function checkUniqueIds(text, root, file) {
|
|
173
177
|
const findings = [];
|
|
174
178
|
for (const key of ["HandleId", "BufferId"]) {
|
|
@@ -177,13 +181,15 @@ async function checkUniqueIds(text, root, file) {
|
|
|
177
181
|
for (const hit of hits) {
|
|
178
182
|
if (typeof hit.value !== "string")
|
|
179
183
|
continue;
|
|
180
|
-
const
|
|
184
|
+
const spaceKey = `${idSpace(hit.jsonPath)}\u0000${hit.value}`;
|
|
185
|
+
const list = byValue.get(spaceKey) ?? [];
|
|
181
186
|
list.push(hit);
|
|
182
|
-
byValue.set(
|
|
187
|
+
byValue.set(spaceKey, list);
|
|
183
188
|
}
|
|
184
|
-
for (const [
|
|
189
|
+
for (const [spaceKey, list] of byValue) {
|
|
185
190
|
if (list.length < 2)
|
|
186
191
|
continue;
|
|
192
|
+
const value = spaceKey.slice(spaceKey.indexOf("\u0000") + 1);
|
|
187
193
|
const lines = list.map((_, i) => lineOfOccurrence(text, `"${key}": "${value}"`, i)).filter((l) => l !== undefined);
|
|
188
194
|
findings.push({
|
|
189
195
|
validatorId: "cp2077.vehicles.cr2w-unique-ids",
|
|
@@ -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
|
+
}
|
|
@@ -117,7 +117,13 @@ export async function readModEngine2LoadOrder(install) {
|
|
|
117
117
|
const configPath = path.join(gameDir(install), MODENGINE2_CONFIG);
|
|
118
118
|
if (!(await pathExists(configPath)))
|
|
119
119
|
return { configPath, entries: [] };
|
|
120
|
-
|
|
120
|
+
let config;
|
|
121
|
+
try {
|
|
122
|
+
config = parseModEngine2Config(await fs.readFile(configPath, "utf8"));
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
return { configPath, entries: [], error: `${configPath}: ${err.message}` };
|
|
126
|
+
}
|
|
121
127
|
const modDir = path.resolve(modengine2ModDir(install)).toLowerCase();
|
|
122
128
|
const entries = [];
|
|
123
129
|
let index = 0;
|
|
@@ -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,
|