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
@@ -0,0 +1,52 @@
1
+ import { promises as fs } from "node:fs";
2
+ import * as path from "node:path";
3
+ const MANIFEST = /^vortex\.deployment\.(?:(?<type>[^.]+)\.)?json$/i;
4
+ export async function readVortexManifests(dir) {
5
+ const manifests = [];
6
+ const unreadable = [];
7
+ let names;
8
+ try {
9
+ names = await fs.readdir(dir);
10
+ }
11
+ catch {
12
+ return { manifests, unreadable };
13
+ }
14
+ for (const name of names.sort()) {
15
+ const match = MANIFEST.exec(name);
16
+ if (!match)
17
+ continue;
18
+ const file = path.join(dir, name);
19
+ try {
20
+ const raw = JSON.parse(await fs.readFile(file, "utf8"));
21
+ if (!Array.isArray(raw.files))
22
+ throw new Error("no files array");
23
+ const files = [];
24
+ for (const entry of raw.files) {
25
+ if (typeof entry?.relPath !== "string" || typeof entry.source !== "string")
26
+ continue;
27
+ const deployed = { relPath: entry.relPath, source: entry.source };
28
+ if (Array.isArray(entry.merged))
29
+ deployed.merged = entry.merged.filter((m) => typeof m === "string");
30
+ if (typeof entry.target === "string" && entry.target)
31
+ deployed.target = entry.target;
32
+ if (typeof entry.time === "number")
33
+ deployed.time = entry.time;
34
+ files.push(deployed);
35
+ }
36
+ const manifest = { file, deployDir: dir, modType: match.groups?.type ?? "", files };
37
+ if (typeof raw.gameId === "string")
38
+ manifest.gameId = raw.gameId;
39
+ if (typeof raw.deploymentMethod === "string")
40
+ manifest.deploymentMethod = raw.deploymentMethod;
41
+ if (typeof raw.deploymentTime === "number")
42
+ manifest.deploymentTime = raw.deploymentTime;
43
+ if (typeof raw.stagingPath === "string")
44
+ manifest.stagingPath = raw.stagingPath;
45
+ manifests.push(manifest);
46
+ }
47
+ catch (error) {
48
+ unreadable.push({ file, error: error.message });
49
+ }
50
+ }
51
+ return { manifests, unreadable };
52
+ }
@@ -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,48 @@ 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 not attested anywhere on disk.",
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.",
308
357
  },
309
358
  ];
310
359
  export const PROBE_CATALOG = [...BG3_PROBES, ...CP2077_PROBES];