modwright 0.1.2 → 0.1.4
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 +43 -4
- package/bridges/bg3-se/README.md +3 -2
- package/bridges/bg3-se/bridge.json +1 -1
- package/bridges/cp2077-cet/ModWrightBridge/probes.lua +228 -1
- package/bridges/cp2077-cet/README.md +6 -3
- package/bridges/cp2077-cet/bridge.json +2 -2
- package/dist/core/bridge/remove.js +48 -0
- 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 +84 -2
- package/dist/core/writes/index.js +206 -0
- package/dist/index.js +0 -2
- package/dist/server.js +180 -15
- 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.natives.yaml +48 -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
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isDirectory, pathExists, walk } from "../fsutil.js";
|
|
4
|
+
import { findMo2Instance, readMo2ModMeta, readMo2Modlist } from "../managers/mo2.js";
|
|
5
|
+
import { readVortexManifests } from "../managers/vortex.js";
|
|
6
|
+
import { listMods } from "../mods.js";
|
|
7
|
+
const MANAGER_EVIDENCE = new Set(["mo2", "mo2-overwrite", "vortex"]);
|
|
8
|
+
function isManagerBookkeeping(relative) {
|
|
9
|
+
const base = relative.slice(relative.lastIndexOf("/") + 1).toLowerCase();
|
|
10
|
+
return /^vortex\.deployment\.(?:[^.]+\.)?json$/.test(base) || base === "__folder_managed_by_vortex";
|
|
11
|
+
}
|
|
12
|
+
function keyOf(p) {
|
|
13
|
+
return p.toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
function gamePath(install, abs) {
|
|
16
|
+
const rel = path.relative(install.root, abs);
|
|
17
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
|
|
18
|
+
return abs.split(path.sep).join("/");
|
|
19
|
+
return rel.split(path.sep).join("/");
|
|
20
|
+
}
|
|
21
|
+
export async function buildOwnershipIndex(surface, install, options = {}) {
|
|
22
|
+
const perMod = options.fileLimitPerMod ?? 20000;
|
|
23
|
+
const index = {
|
|
24
|
+
install: install.root,
|
|
25
|
+
sources: [],
|
|
26
|
+
unattributedRoots: [],
|
|
27
|
+
looseRoots: [],
|
|
28
|
+
entries: new Map(),
|
|
29
|
+
physical: new Map(),
|
|
30
|
+
};
|
|
31
|
+
const add = (display, claim) => {
|
|
32
|
+
const key = keyOf(display);
|
|
33
|
+
let entry = index.entries.get(key);
|
|
34
|
+
if (!entry) {
|
|
35
|
+
entry = { path: display, claims: [] };
|
|
36
|
+
index.entries.set(key, entry);
|
|
37
|
+
}
|
|
38
|
+
entry.claims.push(claim);
|
|
39
|
+
if (claim.physicalPath)
|
|
40
|
+
index.physical.set(keyOf(claim.physicalPath), key);
|
|
41
|
+
};
|
|
42
|
+
const { mods, roots } = await listMods(surface, install, { fileLimit: perMod });
|
|
43
|
+
if (surface.looseUnitOf)
|
|
44
|
+
index.looseUnitOf = (rootId, rel) => surface.looseUnitOf(rootId, rel);
|
|
45
|
+
const rootById = new Map(roots.map((r) => [r.id, r]));
|
|
46
|
+
const modRootsReport = { id: "mod-roots", active: true, detail: "", mods: 0, files: 0 };
|
|
47
|
+
const flatRoots = [];
|
|
48
|
+
for (const mod of mods) {
|
|
49
|
+
const root = rootById.get(mod.rootId);
|
|
50
|
+
if (!root)
|
|
51
|
+
continue;
|
|
52
|
+
if (root.layout === "flat") {
|
|
53
|
+
flatRoots.push({ rootId: root.id, path: root.path, files: mod.files });
|
|
54
|
+
if (surface.looseUnitOf) {
|
|
55
|
+
index.looseRoots.push({ rootId: root.id, path: root.path });
|
|
56
|
+
for (const file of mod.files) {
|
|
57
|
+
const unit = surface.looseUnitOf(root.id, file);
|
|
58
|
+
if (!unit)
|
|
59
|
+
continue;
|
|
60
|
+
add(gamePath(install, path.join(root.path, file)), { mod: unit, modId: `${root.id}:${unit}`, enabled: true, evidence: "loose-unit" });
|
|
61
|
+
modRootsReport.files++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
modRootsReport.mods++;
|
|
67
|
+
if (mod.warnings?.some((w) => w.includes("truncated")))
|
|
68
|
+
modRootsReport.truncated = true;
|
|
69
|
+
const base = { mod: mod.name, modId: mod.id, enabled: true };
|
|
70
|
+
if (mod.manifest?.version)
|
|
71
|
+
base.version = mod.manifest.version;
|
|
72
|
+
if (root.layout === "file") {
|
|
73
|
+
add(gamePath(install, mod.path), { ...base, evidence: "mod-file" });
|
|
74
|
+
modRootsReport.files++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const file of mod.files) {
|
|
78
|
+
add(gamePath(install, path.join(mod.path, file)), { ...base, evidence: "mod-folder" });
|
|
79
|
+
modRootsReport.files++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
modRootsReport.detail =
|
|
83
|
+
`${modRootsReport.mods} mod(s) in ${surface.name}'s own mod roots: a mod folder owns the files under it, ` +
|
|
84
|
+
"and in a file-per-mod root the file is the mod.";
|
|
85
|
+
index.sources.push(modRootsReport);
|
|
86
|
+
let mo2Covered = false;
|
|
87
|
+
if (surface.managedDataDir !== undefined) {
|
|
88
|
+
const report = { id: "mo2", active: false, detail: "", mods: 0, files: 0 };
|
|
89
|
+
const findOptions = {};
|
|
90
|
+
if (options.mo2Instance)
|
|
91
|
+
findOptions.instancePath = options.mo2Instance;
|
|
92
|
+
if (options.mo2InstancesRoot)
|
|
93
|
+
findOptions.instancesRoot = options.mo2InstancesRoot;
|
|
94
|
+
const instance = await findMo2Instance(install, findOptions);
|
|
95
|
+
if (!instance) {
|
|
96
|
+
report.detail = options.mo2Instance
|
|
97
|
+
? `No ModOrganizer.ini at ${options.mo2Instance}.`
|
|
98
|
+
: "No global Mod Organizer 2 instance manages this install. A portable instance is only found " +
|
|
99
|
+
"when named (mo2Instance).";
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const modlist = await readMo2Modlist(instance.profileDir);
|
|
103
|
+
if (!modlist) {
|
|
104
|
+
report.detail = `MO2 instance ${instance.instanceDir} found, but profile "${instance.profileName}" has no readable modlist.txt.`;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
report.active = true;
|
|
108
|
+
mo2Covered = true;
|
|
109
|
+
const problems = [];
|
|
110
|
+
const dataDir = path.join(install.root, surface.managedDataDir);
|
|
111
|
+
let budget = options.totalFileLimit ?? 400000;
|
|
112
|
+
const walkInto = async (dir, claim) => {
|
|
113
|
+
const result = await walk(dir, { limit: Math.min(perMod, Math.max(budget, 0)) });
|
|
114
|
+
if (result.truncated)
|
|
115
|
+
report.truncated = true;
|
|
116
|
+
let count = 0;
|
|
117
|
+
for (const file of result.files) {
|
|
118
|
+
if (file.toLowerCase() === "meta.ini")
|
|
119
|
+
continue;
|
|
120
|
+
add(gamePath(install, path.join(dataDir, file)), { ...claim, physicalPath: path.join(dir, file) });
|
|
121
|
+
count++;
|
|
122
|
+
}
|
|
123
|
+
budget -= result.files.length;
|
|
124
|
+
report.files += count;
|
|
125
|
+
return count;
|
|
126
|
+
};
|
|
127
|
+
for (const entry of modlist) {
|
|
128
|
+
if (entry.foreign || entry.separator)
|
|
129
|
+
continue;
|
|
130
|
+
if (budget <= 0) {
|
|
131
|
+
report.truncated = true;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
const modDir = path.join(instance.modsDir, entry.name);
|
|
135
|
+
if (!(await isDirectory(modDir))) {
|
|
136
|
+
problems.push(`"${entry.name}" is in modlist.txt but has no folder in ${instance.modsDir}.`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const meta = await readMo2ModMeta(modDir);
|
|
140
|
+
const claim = {
|
|
141
|
+
mod: entry.name,
|
|
142
|
+
modId: `mo2:${entry.name}`,
|
|
143
|
+
evidence: "mo2",
|
|
144
|
+
enabled: entry.enabled,
|
|
145
|
+
priority: entry.priority,
|
|
146
|
+
};
|
|
147
|
+
if (meta.nexusModId !== undefined)
|
|
148
|
+
claim.nexusModId = meta.nexusModId;
|
|
149
|
+
if (meta.version)
|
|
150
|
+
claim.version = meta.version;
|
|
151
|
+
await walkInto(modDir, claim);
|
|
152
|
+
report.mods++;
|
|
153
|
+
}
|
|
154
|
+
if (await pathExists(instance.overwriteDir)) {
|
|
155
|
+
const top = modlist.reduce((max, e) => Math.max(max, e.priority), 0) + 1;
|
|
156
|
+
await walkInto(instance.overwriteDir, {
|
|
157
|
+
mod: "Overwrite",
|
|
158
|
+
modId: "mo2:Overwrite",
|
|
159
|
+
evidence: "mo2-overwrite",
|
|
160
|
+
enabled: true,
|
|
161
|
+
priority: top,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
report.detail =
|
|
165
|
+
`MO2 ${instance.portable ? "portable" : "global"} instance ${instance.instanceDir}, profile ` +
|
|
166
|
+
`"${instance.profileName}": ${report.mods} mod folder(s) mapped onto ${surface.managedDataDir}/ in ` +
|
|
167
|
+
"modlist.txt priority order (top line wins; Overwrite above all). The real game folder under MO2 " +
|
|
168
|
+
"holds only the base game and files MO2 does not manage.";
|
|
169
|
+
if (problems.length > 0)
|
|
170
|
+
report.problems = problems.slice(0, 25);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
index.sources.push(report);
|
|
174
|
+
}
|
|
175
|
+
const vortexDirs = [install.root];
|
|
176
|
+
if (surface.managedDataDir)
|
|
177
|
+
vortexDirs.push(path.join(install.root, surface.managedDataDir));
|
|
178
|
+
const vortex = { id: "vortex", active: false, detail: "", mods: 0, files: 0 };
|
|
179
|
+
const vortexMods = new Set();
|
|
180
|
+
const vortexProblems = [];
|
|
181
|
+
const manifestNames = [];
|
|
182
|
+
for (const dir of [...new Set(vortexDirs)]) {
|
|
183
|
+
const { manifests, unreadable } = await readVortexManifests(dir);
|
|
184
|
+
for (const bad of unreadable)
|
|
185
|
+
vortexProblems.push(`${bad.file}: ${bad.error}`);
|
|
186
|
+
for (const manifest of manifests) {
|
|
187
|
+
manifestNames.push(gamePath(install, manifest.file));
|
|
188
|
+
for (const file of manifest.files) {
|
|
189
|
+
const targetDir = file.target
|
|
190
|
+
? path.isAbsolute(file.target)
|
|
191
|
+
? file.target
|
|
192
|
+
: path.join(manifest.deployDir, file.target)
|
|
193
|
+
: manifest.deployDir;
|
|
194
|
+
const claim = { mod: file.source, modId: `vortex:${file.source}`, evidence: "vortex", enabled: true };
|
|
195
|
+
if (manifest.stagingPath)
|
|
196
|
+
claim.physicalPath = path.join(manifest.stagingPath, file.source, file.relPath);
|
|
197
|
+
if (file.merged?.length)
|
|
198
|
+
claim.merged = file.merged;
|
|
199
|
+
add(gamePath(install, path.join(targetDir, file.relPath)), claim);
|
|
200
|
+
vortexMods.add(file.source);
|
|
201
|
+
vortex.files++;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
vortex.mods = vortexMods.size;
|
|
206
|
+
if (manifestNames.length > 0) {
|
|
207
|
+
vortex.active = true;
|
|
208
|
+
vortex.detail =
|
|
209
|
+
`Vortex deployment manifest(s) ${manifestNames.join(", ")}: each deployed file with the mod it came ` +
|
|
210
|
+
"from. Vortex resolved conflicts before deploying, so a manifest names the winner only.";
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
vortex.detail = "No Vortex deployment manifest in the game folder.";
|
|
214
|
+
}
|
|
215
|
+
if (vortexProblems.length > 0)
|
|
216
|
+
vortex.problems = vortexProblems;
|
|
217
|
+
index.sources.push(vortex);
|
|
218
|
+
if (!mo2Covered) {
|
|
219
|
+
for (const root of flatRoots) {
|
|
220
|
+
const unclaimed = root.files.filter((f) => !isManagerBookkeeping(f) && !index.entries.has(keyOf(gamePath(install, path.join(root.path, f)))));
|
|
221
|
+
if (unclaimed.length > 0)
|
|
222
|
+
index.unattributedRoots.push({ rootId: root.rootId, path: root.path, files: unclaimed.length });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return index;
|
|
226
|
+
}
|
|
227
|
+
export function verdictFor(entry) {
|
|
228
|
+
const claims = entry.claims;
|
|
229
|
+
const mo2 = claims.filter((c) => c.evidence === "mo2" || c.evidence === "mo2-overwrite");
|
|
230
|
+
const vortex = claims.filter((c) => c.evidence === "vortex");
|
|
231
|
+
const local = claims.filter((c) => !MANAGER_EVIDENCE.has(c.evidence));
|
|
232
|
+
const inactive = claims.filter((c) => !c.enabled);
|
|
233
|
+
if (mo2.length > 0) {
|
|
234
|
+
const enabled = mo2.filter((c) => c.enabled).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
235
|
+
const winner = enabled[0];
|
|
236
|
+
const overridden = winner ? [...enabled.slice(1), ...local] : [];
|
|
237
|
+
const verdict = {
|
|
238
|
+
path: entry.path,
|
|
239
|
+
overridden,
|
|
240
|
+
inactive,
|
|
241
|
+
contested: enabled.length + local.length > 1,
|
|
242
|
+
basis: winner
|
|
243
|
+
? "MO2 priority: the highest enabled mod in the profile's modlist.txt wins (Overwrite above all), and every mod folder overrides the real game folder."
|
|
244
|
+
: "Only disabled MO2 mods provide this path; the game sees " +
|
|
245
|
+
(local.length > 0 ? "the real game folder's copy." : "no file here."),
|
|
246
|
+
};
|
|
247
|
+
if (winner)
|
|
248
|
+
verdict.winner = winner;
|
|
249
|
+
else if (local.length === 1)
|
|
250
|
+
verdict.winner = local[0];
|
|
251
|
+
return verdict;
|
|
252
|
+
}
|
|
253
|
+
if (vortex.length > 0) {
|
|
254
|
+
const verdict = {
|
|
255
|
+
path: entry.path,
|
|
256
|
+
winner: vortex[vortex.length - 1],
|
|
257
|
+
overridden: [],
|
|
258
|
+
inactive,
|
|
259
|
+
contested: new Set(vortex.map((c) => c.modId)).size > 1,
|
|
260
|
+
basis: "Vortex deployment manifest: this is the copy Vortex deployed. Mods that lost the conflict are not recorded there" +
|
|
261
|
+
(vortex.some((c) => c.merged?.length) ? "; the file was merged from the mods in `merged`." : "."),
|
|
262
|
+
};
|
|
263
|
+
return verdict;
|
|
264
|
+
}
|
|
265
|
+
const distinct = new Set(local.map((c) => c.modId));
|
|
266
|
+
if (distinct.size <= 1) {
|
|
267
|
+
const verdict = {
|
|
268
|
+
path: entry.path,
|
|
269
|
+
overridden: [],
|
|
270
|
+
inactive,
|
|
271
|
+
contested: false,
|
|
272
|
+
basis: local[0]?.evidence === "mod-file"
|
|
273
|
+
? "The file is itself the mod (a file-per-mod root)."
|
|
274
|
+
: local[0]?.evidence === "loose-unit"
|
|
275
|
+
? "The unpacked mod whose own folder in the loose-file root holds this path (e.g. BG3's Data/Mods/<Mod>); no manager deployed it."
|
|
276
|
+
: "The only mod whose folder holds this path.",
|
|
277
|
+
};
|
|
278
|
+
if (local[0])
|
|
279
|
+
verdict.winner = local[0];
|
|
280
|
+
return verdict;
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
path: entry.path,
|
|
284
|
+
overridden: [],
|
|
285
|
+
inactive,
|
|
286
|
+
contested: true,
|
|
287
|
+
basis: "Several mods provide this path and ModWright does not model which one the game reads here.",
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function looseFallback(index, query, q) {
|
|
291
|
+
if (!index.looseUnitOf)
|
|
292
|
+
return undefined;
|
|
293
|
+
const absolute = path.isAbsolute(query) || /^[a-z]:\//i.test(q);
|
|
294
|
+
for (const root of index.looseRoots) {
|
|
295
|
+
const candidates = absolute ? [path.resolve(query)] : [path.resolve(index.install, q), path.resolve(root.path, q)];
|
|
296
|
+
for (const abs of candidates) {
|
|
297
|
+
const rel = path.relative(root.path, abs);
|
|
298
|
+
if (rel.startsWith("..") || path.isAbsolute(rel) || !existsSync(abs))
|
|
299
|
+
continue;
|
|
300
|
+
const unit = index.looseUnitOf(root.rootId, rel.split(path.sep).join("/"));
|
|
301
|
+
if (!unit)
|
|
302
|
+
continue;
|
|
303
|
+
const display = path.relative(index.install, abs).split(path.sep).join("/");
|
|
304
|
+
return verdictFor({ path: display, claims: [{ mod: unit, modId: `${root.rootId}:${unit}`, enabled: true, evidence: "loose-unit" }] });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
export function lookupOwnership(index, query, limit = 20) {
|
|
310
|
+
const q = query.trim().replace(/\\/g, "/");
|
|
311
|
+
const found = (matchedBy, keys) => {
|
|
312
|
+
const results = keys.slice(0, limit).map((k) => verdictFor(index.entries.get(k)));
|
|
313
|
+
const out = { query, matchedBy, results };
|
|
314
|
+
if (keys.length > limit)
|
|
315
|
+
out.omitted = keys.length - limit;
|
|
316
|
+
return out;
|
|
317
|
+
};
|
|
318
|
+
if (path.isAbsolute(query) || /^[a-z]:\//i.test(q)) {
|
|
319
|
+
const physicalKey = index.physical.get(keyOf(path.resolve(query)));
|
|
320
|
+
if (physicalKey)
|
|
321
|
+
return found("physical", [physicalKey]);
|
|
322
|
+
const rel = path.relative(index.install, path.resolve(query));
|
|
323
|
+
const key = keyOf(!rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("/") : q);
|
|
324
|
+
if (index.entries.has(key))
|
|
325
|
+
return found("path", [key]);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
const key = keyOf(q.replace(/^\.?\//, ""));
|
|
329
|
+
if (index.entries.has(key))
|
|
330
|
+
return found("path", [key]);
|
|
331
|
+
if (key.includes("/")) {
|
|
332
|
+
const suffix = `/${key}`;
|
|
333
|
+
const keys = [...index.entries.keys()].filter((k) => k.endsWith(suffix));
|
|
334
|
+
if (keys.length > 0)
|
|
335
|
+
return found("suffix", keys);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const keys = [...index.entries.keys()].filter((k) => k.slice(k.lastIndexOf("/") + 1) === key);
|
|
339
|
+
if (keys.length > 0)
|
|
340
|
+
return found("basename", keys);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const loose = looseFallback(index, query, q);
|
|
344
|
+
if (loose)
|
|
345
|
+
return { query, matchedBy: "path", results: [loose] };
|
|
346
|
+
return {
|
|
347
|
+
query,
|
|
348
|
+
matchedBy: "none",
|
|
349
|
+
results: [],
|
|
350
|
+
note: "No mod claims this path. It is a base-game file, was placed by hand into a loose-file folder with " +
|
|
351
|
+
"no manager (see unattributedRoots), sits inside a packed archive (not indexed yet), or the path is misspelled.",
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
export function managerConflicts(index, limit = 100) {
|
|
355
|
+
const contested = [];
|
|
356
|
+
for (const entry of index.entries.values()) {
|
|
357
|
+
const enabledMo2 = entry.claims.filter((c) => (c.evidence === "mo2" || c.evidence === "mo2-overwrite") && c.enabled);
|
|
358
|
+
if (enabledMo2.length < 2)
|
|
359
|
+
continue;
|
|
360
|
+
contested.push(verdictFor(entry));
|
|
361
|
+
}
|
|
362
|
+
contested.sort((a, b) => b.overridden.length - a.overridden.length || a.path.localeCompare(b.path));
|
|
363
|
+
return { total: contested.length, conflicts: contested.slice(0, limit) };
|
|
364
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
2
|
export { modProjectSchema, parseProject } from "./schema.js";
|
|
3
|
-
export { findProjectFile, loadProject } from "./load.js";
|
|
3
|
+
export { findProjectFile, loadProject, locateProjectFile } from "./load.js";
|
|
4
4
|
export { escapesRoot, outsideRootPaths } from "./outside-root.js";
|
|
5
5
|
export { describeProject } from "./describe.js";
|
|
6
6
|
export { defaultArtifactName, normalizeBuildSteps } from "./build-steps.js";
|
|
@@ -17,6 +17,11 @@ export async function findProjectFile(startDir) {
|
|
|
17
17
|
dir = parent;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
export async function locateProjectFile(start) {
|
|
21
|
+
if (start.toLowerCase().endsWith(".json"))
|
|
22
|
+
return (await pathExists(start)) ? start : undefined;
|
|
23
|
+
return findProjectFile(start);
|
|
24
|
+
}
|
|
20
25
|
function isWithin(parent, child) {
|
|
21
26
|
const rel = path.relative(parent, child);
|
|
22
27
|
return rel === "" || (!(rel === ".." || rel.startsWith(`..${path.sep}`)) && !path.isAbsolute(rel));
|
|
@@ -262,7 +262,11 @@ const CP2077_PROBES = [
|
|
|
262
262
|
summary: "Where an entity is. Attested for item objects; unverified for a sector-placed prop.",
|
|
263
263
|
citation: "cp2077.entity.transform",
|
|
264
264
|
paramsSchema: z.object({ entityRef: str() }).strict(),
|
|
265
|
-
evidence: "Observed in game: a test mod's own init.lua comment says its tag \"removes legacy dynamic cases from older experiments\" and the real case is a static sector prop, so GetTaggedIDs on it returned 0 twice at two locations — not a location miss. That tag marks legacy dev spawns only and is the wrong entry point for a sector-placed prop; a sector-placed prop needs a different probe mechanism, not just a re-run of this one. Stays unverified."
|
|
265
|
+
evidence: "Observed in game: a test mod's own init.lua comment says its tag \"removes legacy dynamic cases from older experiments\" and the real case is a static sector prop, so GetTaggedIDs on it returned 0 twice at two locations — not a location miss. That tag marks legacy dev spawns only and is the wrong entry point for a sector-placed prop; a sector-placed prop needs a different probe mechanism, not just a re-run of this one. Stays unverified. " +
|
|
266
|
+
"RTTI (rayshader/cp2077-nativedb b5d29af, game 2.31): both calls are declared on entEntity itself — " +
|
|
267
|
+
"GetWorldPosition() -> Vector4, GetWorldOrientation() -> Quaternion — and a loot-container prop's entity class " +
|
|
268
|
+
"(LootContainerObjectAnimatedByTransform) reaches entEntity through gameLootContainerBase/gameObject/entGameEntity, " +
|
|
269
|
+
"so such a prop has both methods by inheritance. The methods were never in doubt; getting a handle to the prop is.",
|
|
266
270
|
},
|
|
267
271
|
{
|
|
268
272
|
kind: "cp2077.entity.appearance",
|
|
@@ -273,6 +277,12 @@ const CP2077_PROBES = [
|
|
|
273
277
|
summary: "Acceptance regression, differential: did the sector node's appearanceName resolve?",
|
|
274
278
|
citation: "cp2077.entity.appearance",
|
|
275
279
|
paramsSchema: z.object({ tag: str(), baselineId: str() }).strict(),
|
|
280
|
+
evidence: "Composite of cp2077.entity.tagged + .components + .transform against a baseline; blocked the same way " +
|
|
281
|
+
"cp2077.entity.tagged is for a sector-placed prop (its first step). RTTI (rayshader/cp2077-nativedb " +
|
|
282
|
+
"b5d29af, game 2.31): entEntity::GetCurrentAppearanceName() -> CName exists, inherited by loot-container " +
|
|
283
|
+
"props, and entIComponent::GetAppearanceName() -> CName exists per component. That is a direct read of the " +
|
|
284
|
+
"question this row asks (did appearanceName resolve?) and a smaller call than the composite, but no CET mod " +
|
|
285
|
+
"or wiki page calls it from Lua, so it is a candidate for the next live run, not a redesign of this row.",
|
|
276
286
|
},
|
|
277
287
|
{
|
|
278
288
|
kind: "cp2077.tweakdb.record",
|
|
@@ -302,9 +312,81 @@ const CP2077_PROBES = [
|
|
|
302
312
|
context: "cet",
|
|
303
313
|
effect: "read",
|
|
304
314
|
status: "unverified",
|
|
305
|
-
summary: "An interaction component is on the entity. The component name is
|
|
315
|
+
summary: "An interaction component is on the entity. The component name is per-entity: a loot-container template " +
|
|
316
|
+
"names it \"interactions\" (class gameinteractionsComponent); nothing calls it from Lua yet.",
|
|
306
317
|
citation: "cp2077.interaction.present",
|
|
307
318
|
paramsSchema: z.object({ entityRef: str() }).strict(),
|
|
319
|
+
evidence: "A loot-container entity template's components list { $type: gameinteractionsComponent, name: \"interactions\" } " +
|
|
320
|
+
"alongside inventory, scanning, workspotMapper, root, audioSound, vision, UI_Slots, GameplayRole, state_fxes and " +
|
|
321
|
+
"targeting. RTTI (rayshader/cp2077-nativedb b5d29af): gameinteractionsComponent -> entIPlacedComponent -> " +
|
|
322
|
+
"entIComponent; entEntity::FindComponentByName(componentName: CName) -> ref<entIComponent>; no generic " +
|
|
323
|
+
"enumerate-by-class call exists on entEntity/gameObject, so exact names stay the only path. A design-time " +
|
|
324
|
+
"template field is not an in-engine observation that the component resolves on the streamed instance: stays unverified.",
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
kind: "cp2077.entity.nearby",
|
|
328
|
+
game: "cyberpunk2077",
|
|
329
|
+
context: "cet",
|
|
330
|
+
effect: "read",
|
|
331
|
+
status: "unverified",
|
|
332
|
+
summary: "Entities within a radius of an anchor entity (the player by default). filter \"npc\" (default) asks " +
|
|
333
|
+
"GetNPCsAroundObject; filter \"objects\" asks GetEntitiesAroundObject with an Obj_*-wide target search " +
|
|
334
|
+
"filter, the shape that could find a placed static prop.",
|
|
335
|
+
citation: "cp2077.entity.nearby",
|
|
336
|
+
paramsSchema: z
|
|
337
|
+
.object({
|
|
338
|
+
radius: z.number().positive(),
|
|
339
|
+
filter: z.enum(["npc", "objects"]).optional(),
|
|
340
|
+
entityRef: str().optional(),
|
|
341
|
+
tag: str().optional(),
|
|
342
|
+
index: z.number().int().positive().optional(),
|
|
343
|
+
})
|
|
344
|
+
.strict(),
|
|
345
|
+
evidence: "rayshader/cp2077-nativedb at b5d29af (v2.1.0, game 2.31; RTTI dumped by WopsS/RED4.RTTIDumper) classes.json: " +
|
|
346
|
+
"gameObject (parent entGameEntity) carries GetNPCsAroundObject(range: Float) -> array<ref<NPCPuppet>> and " +
|
|
347
|
+
"GetEntitiesAroundObject(range: Float, searchFilter: gameTargetSearchFilter) -> array<ref<entEntity>>; " +
|
|
348
|
+
"globals.json lists the filter constructors TSF_All/TSF_Any/TSF_Not/TSF_And/TSF_Or/TSF_NPC/TSF_EnemyNPC/" +
|
|
349
|
+
"TSF_Quickhackable and enums.json the mask members (TSFMV). No shipped CET mod or wiki page calls any of it, " +
|
|
350
|
+
"but CET's own source (yamashi/CyberEngineTweaks 9a8522f; knowledge facts cp2077.cet.*) pins the Lua side: " +
|
|
351
|
+
"globals are exposed bare and via Game.*, a returned struct passes back as a parameter, an undeclared enum value " +
|
|
352
|
+
"is coerced to 0 silently (so the handler builds the filter from declared members via Enum.new and TSF_Or, never " +
|
|
353
|
+
"an ORed mask), and array returns are 1-based tables with nil holes for null handles. What no source can supply " +
|
|
354
|
+
"is the live result: TSF_Any/TSF_Or semantics, whether a sector-placed prop is in the targeting set (a " +
|
|
355
|
+
"loot-container template does carry a gameTargetingComponent), and whether the game marks TSF_* isExec. Never " +
|
|
356
|
+
"run in game; stays unverified until a live run exercises it.",
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
kind: "cp2077.world.spawn",
|
|
360
|
+
game: "cyberpunk2077",
|
|
361
|
+
context: "cet",
|
|
362
|
+
effect: "mutate",
|
|
363
|
+
status: "attested",
|
|
364
|
+
summary: "Spawn an entity from a resource path at the player's transform (exEntitySpawner.Spawn), returning the entity id handle; Game.FindEntityByID resolves it.",
|
|
365
|
+
citation: "cp2077.world.spawn; exEntitySpawner is a CET-added native (RTTIExtender.cpp); attested 2026-10-04",
|
|
366
|
+
paramsSchema: z.object({ entPath: str(), appearance: z.string().optional() }).strict(),
|
|
367
|
+
evidence: "Ran live 2026-10-04 (request req-muugz1b6-94911e04, game 3.0.80.51928): spawning base\\characters\\entities\\gang\\gang__wraith_ma.ent returned a handle and the NPC appeared where the player stood. Game.FindEntityByID(handle) in the same call returned nothing: the entity arrives after the call returns, so resolved:false is the normal result, not a failure.",
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
kind: "cp2077.player.teleport",
|
|
371
|
+
game: "cyberpunk2077",
|
|
372
|
+
context: "cet",
|
|
373
|
+
effect: "mutate",
|
|
374
|
+
status: "attested",
|
|
375
|
+
summary: "Teleport the player to a world position (Game.GetTeleportationFacility():Teleport). Optional yaw.",
|
|
376
|
+
citation: "cp2077.player.teleport (CET wiki); attested 2026-10-04",
|
|
377
|
+
paramsSchema: z.object({ x: z.number(), y: z.number(), z: z.number(), yaw: z.number().optional() }).strict(),
|
|
378
|
+
evidence: "Ran live 2026-10-04 (request req-muugz2is-a30b1095, game 3.0.80.51928): a teleport to x+4 moved the player from -2249.30,2790.43,7.00 to -2245.30,2790.43,7.01, read back through cp2077.lua.eval.",
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
kind: "cp2077.player.level",
|
|
382
|
+
game: "cyberpunk2077",
|
|
383
|
+
context: "cet",
|
|
384
|
+
effect: "mutate",
|
|
385
|
+
status: "attested",
|
|
386
|
+
summary: "Set a player progression level (Game.SetLevel(kind, n, 1)); kind defaults to \"Level\".",
|
|
387
|
+
citation: "cp2077.player.level (CET wiki); attested 2026-10-04",
|
|
388
|
+
paramsSchema: z.object({ level: z.number().int(), kind: z.string().optional() }).strict(),
|
|
389
|
+
evidence: "Ran live 2026-10-04 (requests req-muugz2is-a30b1095 and req-muugz457-1ded47cb, game 3.0.80.51928): Game.SetLevel(\"Level\", n, 1) moved the player level 15 to 16 and back to 15, each read back with StatsSystem.GetStatValue(player, gamedataStatType.Level).",
|
|
308
390
|
},
|
|
309
391
|
];
|
|
310
392
|
export const PROBE_CATALOG = [...BG3_PROBES, ...CP2077_PROBES];
|