modwright 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -2
- package/bridges/cp2077-cet/ModWrightBridge/probes.lua +113 -0
- package/bridges/cp2077-cet/README.md +3 -1
- package/bridges/cp2077-cet/bridge.json +1 -1
- package/dist/core/build/stage.js +3 -2
- package/dist/core/ini.js +26 -0
- package/dist/core/knowledge/facts.js +0 -12
- package/dist/core/logs/registry.js +19 -0
- package/dist/core/logs/scan.js +44 -5
- package/dist/core/logs/triage.js +79 -2
- package/dist/core/managers/mo2.js +121 -0
- package/dist/core/managers/vortex.js +52 -0
- package/dist/core/ownership/index.js +364 -0
- package/dist/core/project/index.js +1 -1
- package/dist/core/project/load.js +5 -0
- package/dist/core/testplan/registry.js +51 -2
- package/dist/core/writes/index.js +206 -0
- package/dist/index.js +0 -2
- package/dist/server.js +142 -13
- package/dist/surfaces/baldursgate3/surface.js +3 -0
- package/dist/surfaces/baldursgate3/validators/story.js +3 -3
- package/dist/surfaces/baldursgate3/writes.js +152 -0
- package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
- package/dist/surfaces/cyberpunk2077/logs.js +82 -3
- package/dist/surfaces/cyberpunk2077/surface.js +11 -0
- package/dist/surfaces/cyberpunk2077/writes.js +67 -0
- package/dist/surfaces/skyrimse/loadorder.js +2 -26
- package/dist/surfaces/skyrimse/surface.js +1 -0
- package/dist/surfaces/stardewvalley/logs.js +41 -0
- package/dist/surfaces/stardewvalley/surface.js +2 -0
- package/dist/surfaces/stardewvalley/writes.js +136 -0
- package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
- package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
- package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
- package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
- package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
- package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
- package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
- package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
- package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
- package/package.json +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { listMods } from "../mods.js";
|
|
2
|
+
const VALUE_CHARS = 200;
|
|
3
|
+
export function shortValue(value) {
|
|
4
|
+
if (value === undefined)
|
|
5
|
+
return undefined;
|
|
6
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
7
|
+
if (text === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
if (text.length <= VALUE_CHARS)
|
|
10
|
+
return value;
|
|
11
|
+
return `${text.slice(0, VALUE_CHARS)}…`;
|
|
12
|
+
}
|
|
13
|
+
export async function collectWrites(surface, install, options = {}) {
|
|
14
|
+
const collection = {
|
|
15
|
+
writers: [],
|
|
16
|
+
coverage: { modsScanned: 0, writesFound: 0, rootsScanned: [], errors: [], unitNotes: [] },
|
|
17
|
+
};
|
|
18
|
+
if (!surface.extractWrites)
|
|
19
|
+
return collection;
|
|
20
|
+
const listOptions = { fileLimit: 20000 };
|
|
21
|
+
if (options.rootIds)
|
|
22
|
+
listOptions.rootIds = options.rootIds;
|
|
23
|
+
const { mods, roots } = await listMods(surface, install, listOptions);
|
|
24
|
+
const rootById = new Map(roots.map((r) => [r.id, r]));
|
|
25
|
+
const scannedRoots = new Set();
|
|
26
|
+
const byWriter = new Map();
|
|
27
|
+
const scanned = new Set();
|
|
28
|
+
for (const mod of mods) {
|
|
29
|
+
const root = rootById.get(mod.rootId);
|
|
30
|
+
if (!root)
|
|
31
|
+
continue;
|
|
32
|
+
let extraction;
|
|
33
|
+
try {
|
|
34
|
+
extraction = await surface.extractWrites(mod, root, install);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
collection.coverage.errors.push({ writerId: mod.id, file: mod.path, error: error.message });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!extraction)
|
|
41
|
+
continue;
|
|
42
|
+
scannedRoots.add(root.id);
|
|
43
|
+
if (extraction.writes.length === 0)
|
|
44
|
+
scanned.add(mod.id);
|
|
45
|
+
if (extraction.unitNote && !collection.coverage.unitNotes.includes(extraction.unitNote)) {
|
|
46
|
+
collection.coverage.unitNotes.push(extraction.unitNote);
|
|
47
|
+
}
|
|
48
|
+
for (const err of extraction.errors)
|
|
49
|
+
collection.coverage.errors.push({ writerId: mod.id, ...err });
|
|
50
|
+
for (const write of extraction.writes) {
|
|
51
|
+
const writerId = write.unit ? `${mod.id}/${write.unit}` : mod.id;
|
|
52
|
+
scanned.add(writerId);
|
|
53
|
+
let writer = byWriter.get(writerId);
|
|
54
|
+
if (!writer) {
|
|
55
|
+
writer = { writerId, name: write.unit ?? mod.name, rootId: mod.rootId, writes: [] };
|
|
56
|
+
const state = write.unit ? extraction.unitStates?.[write.unit] : undefined;
|
|
57
|
+
const inactive = state?.inactive ?? extraction.inactive;
|
|
58
|
+
const identity = state?.identity ?? extraction.identity;
|
|
59
|
+
if (inactive)
|
|
60
|
+
writer.inactive = inactive;
|
|
61
|
+
if (identity)
|
|
62
|
+
writer.identity = identity;
|
|
63
|
+
byWriter.set(writerId, writer);
|
|
64
|
+
}
|
|
65
|
+
writer.writes.push(write);
|
|
66
|
+
collection.coverage.writesFound++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
collection.writers = [...byWriter.values()];
|
|
70
|
+
collection.coverage.modsScanned = scanned.size;
|
|
71
|
+
const inactive = collection.writers.filter((w) => w.inactive).length;
|
|
72
|
+
if (inactive > 0)
|
|
73
|
+
collection.coverage.inactiveWriters = inactive;
|
|
74
|
+
collection.coverage.rootsScanned = [...scannedRoots];
|
|
75
|
+
return collection;
|
|
76
|
+
}
|
|
77
|
+
function groupByKey(collection, keep) {
|
|
78
|
+
const byKey = new Map();
|
|
79
|
+
for (const writer of collection.writers) {
|
|
80
|
+
for (const write of writer.writes) {
|
|
81
|
+
if (!keep(write))
|
|
82
|
+
continue;
|
|
83
|
+
const k = write.key.toLowerCase();
|
|
84
|
+
let entry = byKey.get(k);
|
|
85
|
+
if (!entry) {
|
|
86
|
+
entry = { key: write.key, keyKind: write.keyKind, contested: false, writers: [] };
|
|
87
|
+
byKey.set(k, entry);
|
|
88
|
+
}
|
|
89
|
+
const row = {
|
|
90
|
+
writerId: writer.writerId,
|
|
91
|
+
name: writer.name,
|
|
92
|
+
op: write.op,
|
|
93
|
+
file: write.file,
|
|
94
|
+
certainty: write.certainty,
|
|
95
|
+
};
|
|
96
|
+
const value = shortValue(write.value);
|
|
97
|
+
if (value !== undefined)
|
|
98
|
+
row.value = value;
|
|
99
|
+
if (write.line !== undefined)
|
|
100
|
+
row.line = write.line;
|
|
101
|
+
if (write.condition !== undefined)
|
|
102
|
+
row.condition = write.condition;
|
|
103
|
+
if (writer.inactive)
|
|
104
|
+
row.inactive = writer.inactive;
|
|
105
|
+
if (writer.identity)
|
|
106
|
+
row.identity = writer.identity;
|
|
107
|
+
entry.writers.push(row);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const entry of byKey.values()) {
|
|
111
|
+
const live = entry.writers.filter((w) => !w.inactive);
|
|
112
|
+
const writers = new Set(live.map((w) => w.identity ?? w.writerId));
|
|
113
|
+
if (writers.size < 2)
|
|
114
|
+
continue;
|
|
115
|
+
if (new Set(live.map((w) => JSON.stringify([w.op, w.value]))).size === 1) {
|
|
116
|
+
entry.agree = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (live.every((w) => w.op === "append" || w.op === "remove")) {
|
|
120
|
+
const items = (op) => new Set(live.filter((w) => w.op === op).flatMap((w) => (Array.isArray(w.value) ? w.value : [w.value])).map((v) => JSON.stringify(v)));
|
|
121
|
+
const appended = items("append");
|
|
122
|
+
entry.contested = [...items("remove")].some((v) => appended.has(v));
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
entry.contested = true;
|
|
126
|
+
}
|
|
127
|
+
return byKey;
|
|
128
|
+
}
|
|
129
|
+
export function whoTouches(collection, query, limit = 50) {
|
|
130
|
+
const q = query.trim().toLowerCase();
|
|
131
|
+
const all = groupByKey(collection, () => true);
|
|
132
|
+
const pick = (matchedBy, keys) => {
|
|
133
|
+
keys.sort((a, b) => Number(b.contested) - Number(a.contested) || a.key.localeCompare(b.key));
|
|
134
|
+
const result = { query, matchedBy, keys: keys.slice(0, limit) };
|
|
135
|
+
if (keys.length > limit)
|
|
136
|
+
result.omittedKeys = keys.length - limit;
|
|
137
|
+
return result;
|
|
138
|
+
};
|
|
139
|
+
const exact = all.get(q);
|
|
140
|
+
if (exact) {
|
|
141
|
+
const under = [...all.entries()].filter(([k]) => k !== q && isUnder(k, q)).map(([, v]) => v);
|
|
142
|
+
return pick("exact", [exact, ...under]);
|
|
143
|
+
}
|
|
144
|
+
const prefixed = [...all.entries()].filter(([k]) => isUnder(k, q)).map(([, v]) => v);
|
|
145
|
+
if (prefixed.length > 0)
|
|
146
|
+
return pick("prefix", prefixed);
|
|
147
|
+
const containing = [...all.entries()].filter(([k]) => k.includes(q)).map(([, v]) => v);
|
|
148
|
+
if (containing.length > 0)
|
|
149
|
+
return pick("substring", containing);
|
|
150
|
+
return { query, matchedBy: "none", keys: [] };
|
|
151
|
+
}
|
|
152
|
+
function isUnder(key, parent) {
|
|
153
|
+
if (!key.startsWith(parent) || key.length === parent.length)
|
|
154
|
+
return false;
|
|
155
|
+
const next = key[parent.length];
|
|
156
|
+
return next === "." || next === "#" || next === "/";
|
|
157
|
+
}
|
|
158
|
+
export function whatTouches(collection, modQuery, limit = 100) {
|
|
159
|
+
const q = modQuery.trim().toLowerCase();
|
|
160
|
+
const exact = collection.writers.filter((w) => w.writerId.toLowerCase() === q || w.name.toLowerCase() === q);
|
|
161
|
+
const matches = exact.length > 0 ? exact : collection.writers.filter((w) => w.writerId.toLowerCase().includes(q) || w.name.toLowerCase().includes(q));
|
|
162
|
+
const byKey = groupByKey(collection, () => true);
|
|
163
|
+
return matches.map((writer) => {
|
|
164
|
+
const sharedWith = [];
|
|
165
|
+
const seen = new Set();
|
|
166
|
+
for (const write of writer.writes) {
|
|
167
|
+
const k = write.key.toLowerCase();
|
|
168
|
+
if (seen.has(k))
|
|
169
|
+
continue;
|
|
170
|
+
seen.add(k);
|
|
171
|
+
const entry = byKey.get(k);
|
|
172
|
+
if (!entry?.contested)
|
|
173
|
+
continue;
|
|
174
|
+
const others = [...new Set(entry.writers.filter((w) => w.writerId !== writer.writerId).map((w) => w.name))];
|
|
175
|
+
if (others.length > 0)
|
|
176
|
+
sharedWith.push({ key: entry.key, others });
|
|
177
|
+
}
|
|
178
|
+
const summary = {
|
|
179
|
+
writerId: writer.writerId,
|
|
180
|
+
name: writer.name,
|
|
181
|
+
rootId: writer.rootId,
|
|
182
|
+
writeCount: writer.writes.length,
|
|
183
|
+
sharedWith,
|
|
184
|
+
writes: writer.writes.slice(0, limit).map((w) => ({ ...w, value: shortValue(w.value) })),
|
|
185
|
+
};
|
|
186
|
+
if (writer.writes.length > limit)
|
|
187
|
+
summary.omittedWrites = writer.writes.length - limit;
|
|
188
|
+
return summary;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
export function duplicateCopies(collection) {
|
|
192
|
+
const byIdentity = new Map();
|
|
193
|
+
for (const writer of collection.writers) {
|
|
194
|
+
if (!writer.identity || writer.inactive)
|
|
195
|
+
continue;
|
|
196
|
+
const names = byIdentity.get(writer.identity) ?? [];
|
|
197
|
+
names.push(writer.name);
|
|
198
|
+
byIdentity.set(writer.identity, names);
|
|
199
|
+
}
|
|
200
|
+
return [...byIdentity].filter(([, names]) => names.length > 1).map(([identity, writers]) => ({ identity, writers: writers.sort() }));
|
|
201
|
+
}
|
|
202
|
+
export function contestedKeys(collection, limit = 100) {
|
|
203
|
+
const keys = [...groupByKey(collection, () => true).values()].filter((k) => k.contested);
|
|
204
|
+
keys.sort((a, b) => new Set(b.writers.map((w) => w.writerId)).size - new Set(a.writers.map((w) => w.writerId)).size || a.key.localeCompare(b.key));
|
|
205
|
+
return { total: keys.length, keys: keys.slice(0, limit) };
|
|
206
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -116,7 +116,6 @@ class Supervisor {
|
|
|
116
116
|
swapQueued = false;
|
|
117
117
|
closing = false;
|
|
118
118
|
initializeRaw;
|
|
119
|
-
initializeId;
|
|
120
119
|
initializedSeen = false;
|
|
121
120
|
routes = new Map();
|
|
122
121
|
serverRequests = new Map();
|
|
@@ -151,7 +150,6 @@ class Supervisor {
|
|
|
151
150
|
const active = this.active;
|
|
152
151
|
if (msg.method === "initialize" && msg.id !== undefined && msg.id !== null) {
|
|
153
152
|
this.initializeRaw = raw;
|
|
154
|
-
this.initializeId = msg.id;
|
|
155
153
|
}
|
|
156
154
|
else if (msg.method === "notifications/initialized") {
|
|
157
155
|
this.initializedSeen = true;
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { pathExists, tail } from "./core/fsutil.js";
|
|
6
|
-
import { describeProject, findProjectFile, loadProject } from "./core/project/index.js";
|
|
6
|
+
import { describeProject, findProjectFile, loadProject, locateProjectFile } from "./core/project/index.js";
|
|
7
7
|
import { userDataDir } from "./core/home.js";
|
|
8
8
|
import { applyDataFetch, applyManagedInstall, checkToolchain, featureReadiness, locateTool, planDataFetch, planDataMemory, planManagedInstall, planToolMemory, readinessSummary, TOOL_SPECS, toolsToLocate } from "./core/toolchain/index.js";
|
|
9
9
|
import { loadUserConfig } from "./core/userconfig.js";
|
|
@@ -22,6 +22,8 @@ import { readTextIfExists } from "./core/fsutil.js";
|
|
|
22
22
|
import * as path from "node:path";
|
|
23
23
|
import { promises as fs } from "node:fs";
|
|
24
24
|
import { crossCheckIdentities, findConflicts, listMods, sharedIdentities } from "./core/mods.js";
|
|
25
|
+
import { buildOwnershipIndex, lookupOwnership, managerConflicts } from "./core/ownership/index.js";
|
|
26
|
+
import { collectWrites, contestedKeys, duplicateCopies, whatTouches, whoTouches } from "./core/writes/index.js";
|
|
25
27
|
import { resolveLogs, resolveRoots } from "./core/surfaceutil.js";
|
|
26
28
|
import { SURFACES, getSurface, installGameVersion, resolveInstall, surfaceIds } from "./surfaces/index.js";
|
|
27
29
|
import { CONVERTERS, convert as runConvert, defaultLocateTool as defaultTextLocateTool, extractTemplate, fieldDiff, getTemplate, invalidateTemplate, listTemplates, } from "./core/text/index.js";
|
|
@@ -183,8 +185,12 @@ server.registerTool("find_conflicts", {
|
|
|
183
185
|
installPath: installPathArg,
|
|
184
186
|
rootIds: z.array(z.string()).optional().describe("Restrict to these mod root ids."),
|
|
185
187
|
limit: z.number().int().positive().default(100).describe("Max conflicts to return."),
|
|
188
|
+
mo2Instance: z
|
|
189
|
+
.string()
|
|
190
|
+
.optional()
|
|
191
|
+
.describe("A portable Mod Organizer 2 instance (its folder or ModOrganizer.ini); global instances are found automatically."),
|
|
186
192
|
},
|
|
187
|
-
}, async ({ game, installPath, rootIds, limit }) => {
|
|
193
|
+
}, async ({ game, installPath, rootIds, limit, mo2Instance }) => {
|
|
188
194
|
const surface = getSurface(game);
|
|
189
195
|
if (!surface)
|
|
190
196
|
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
@@ -202,10 +208,22 @@ server.registerTool("find_conflicts", {
|
|
|
202
208
|
identityUnreadableCount = scanned.unreadable.length;
|
|
203
209
|
identityUnreadable = scanned.unreadable.slice(0, IDENTITY_UNREADABLE_LIMIT);
|
|
204
210
|
}
|
|
211
|
+
let managed;
|
|
212
|
+
if (surface.managedDataDir !== undefined) {
|
|
213
|
+
const index = await buildOwnershipIndex(surface, install, mo2Instance ? { mo2Instance } : {});
|
|
214
|
+
const mo2 = index.sources.find((src) => src.id === "mo2");
|
|
215
|
+
if (mo2?.active) {
|
|
216
|
+
const found = managerConflicts(index, limit);
|
|
217
|
+
managed = { source: mo2.detail, total: found.total, conflicts: found.conflicts };
|
|
218
|
+
if (found.total > limit)
|
|
219
|
+
managed.truncated = true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
205
222
|
return json({
|
|
206
223
|
game: surface.id,
|
|
207
224
|
modsAnalyzed: mods.length,
|
|
208
225
|
conflictCount: conflicts.length,
|
|
226
|
+
managerConflicts: managed,
|
|
209
227
|
skippedRoots,
|
|
210
228
|
suppressedNoiseFiles,
|
|
211
229
|
truncated: conflicts.length > limit,
|
|
@@ -215,10 +233,108 @@ server.registerTool("find_conflicts", {
|
|
|
215
233
|
identityUnreadable,
|
|
216
234
|
identityUnreadableTruncated: identityUnreadableCount > IDENTITY_UNREADABLE_LIMIT ? true : undefined,
|
|
217
235
|
note: "File-level analysis, and only for roots where mods share one deploy tree. " +
|
|
218
|
-
"See skippedRoots for what was excluded and why."
|
|
236
|
+
"See skippedRoots for what was excluded and why." +
|
|
237
|
+
(managed
|
|
238
|
+
? " managerConflicts are between Mod Organizer 2's mod folders, each with the winner the game sees and the mods it overrides."
|
|
239
|
+
: ""),
|
|
219
240
|
guidance: surface.guidance,
|
|
220
241
|
});
|
|
221
242
|
});
|
|
243
|
+
server.registerTool("owner_of", {
|
|
244
|
+
title: "Which mod owns this file",
|
|
245
|
+
description: "Say which installed mod a file comes from, and which copy the game actually sees when several provide it. " +
|
|
246
|
+
"Accepts absolute paths, paths relative to the game folder, a trailing part of a path (SKSE/Plugins/foo.dll), " +
|
|
247
|
+
"or a bare file name — e.g. a DLL named in a crash log, a path from triage_logs. Sources: the game's own mod " +
|
|
248
|
+
"roots (a mod folder owns what is under it), Mod Organizer 2 (the active profile's mod folders in priority " +
|
|
249
|
+
"order, plus Overwrite; for games where the mapping is known), and Vortex deployment manifests. Each result " +
|
|
250
|
+
"says how the winner was decided; `sources` says what each source could see, and `unattributedRoots` lists " +
|
|
251
|
+
"loose-file folders whose files no source can attribute. Files inside packed archives are not indexed yet.",
|
|
252
|
+
inputSchema: {
|
|
253
|
+
game: gameArg,
|
|
254
|
+
installPath: installPathArg,
|
|
255
|
+
paths: z.array(z.string().min(1)).min(1).max(50).describe("Files to attribute."),
|
|
256
|
+
mo2Instance: z
|
|
257
|
+
.string()
|
|
258
|
+
.optional()
|
|
259
|
+
.describe("A portable Mod Organizer 2 instance (its folder or ModOrganizer.ini); global instances are found automatically."),
|
|
260
|
+
limit: z.number().int().positive().max(200).default(20).describe("Max matches per path (a bare file name can match many)."),
|
|
261
|
+
},
|
|
262
|
+
}, async ({ game, installPath, paths, mo2Instance, limit }) => {
|
|
263
|
+
const surface = getSurface(game);
|
|
264
|
+
if (!surface)
|
|
265
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
266
|
+
const install = await resolveInstall(surface, installPath);
|
|
267
|
+
if (!install)
|
|
268
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
269
|
+
const index = await buildOwnershipIndex(surface, install, mo2Instance ? { mo2Instance } : {});
|
|
270
|
+
return json({
|
|
271
|
+
game: surface.id,
|
|
272
|
+
install: install.root,
|
|
273
|
+
sources: index.sources,
|
|
274
|
+
indexedPaths: index.entries.size,
|
|
275
|
+
unattributedRoots: index.unattributedRoots.length > 0 ? index.unattributedRoots : undefined,
|
|
276
|
+
results: paths.map((p) => lookupOwnership(index, p, limit)),
|
|
277
|
+
note: "Owners come only from what is on disk: a mod folder, a manager's own records. Nothing is inferred from " +
|
|
278
|
+
"file names. A path no mod claims is reported as such, with the likely reasons.",
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
server.registerTool("who_touches", {
|
|
282
|
+
title: "Which mods write this record or asset",
|
|
283
|
+
description: "Read every installed mod's data and report who writes a given key — at the level the game merges on, not the " +
|
|
284
|
+
"file level: a TweakDB record or flat (Cyberpunk 2077, TweakXL YAML and .tweak files), a stats entry or field " +
|
|
285
|
+
"(Baldur's Gate 3, inside paks and unpacked mods), an asset or data entry/field (Stardew Valley Content Patcher " +
|
|
286
|
+
"packs, Includes followed). Give `key` for who writes it (a record returns its flats too; an asset its entries), " +
|
|
287
|
+
"`mod` for everything one mod writes and where another mod writes the same key, or neither for every key two or " +
|
|
288
|
+
"more mods contend for. This says who touches a key, not who wins: the winner depends on the game's load order. " +
|
|
289
|
+
"Writes behind a Content Patcher When, or with {{tokens}}, are marked conditional.",
|
|
290
|
+
inputSchema: {
|
|
291
|
+
game: gameArg,
|
|
292
|
+
installPath: installPathArg,
|
|
293
|
+
key: z.string().min(1).optional().describe("A record, flat, stats entry, asset or entry, e.g. Items.Preset_Masamune_Default, stats:Projectile_Fireball, Data/Objects#128."),
|
|
294
|
+
mod: z.string().min(1).optional().describe("A mod (or unit) id or name, for what it writes."),
|
|
295
|
+
rootIds: z.array(z.string()).optional().describe("Restrict to these mod root ids."),
|
|
296
|
+
limit: z.number().int().positive().max(500).default(50).describe("Max keys (or writes per mod) to return."),
|
|
297
|
+
},
|
|
298
|
+
}, async ({ game, installPath, key, mod, rootIds, limit }) => {
|
|
299
|
+
const surface = getSurface(game);
|
|
300
|
+
if (!surface)
|
|
301
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
302
|
+
const install = await resolveInstall(surface, installPath);
|
|
303
|
+
if (!install)
|
|
304
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
305
|
+
if (!surface.extractWrites) {
|
|
306
|
+
return json({
|
|
307
|
+
game: surface.id,
|
|
308
|
+
supported: false,
|
|
309
|
+
note: `No write-set extraction for ${surface.name} yet (supported: Cyberpunk 2077 tweaks, Baldur's Gate 3 stats, Stardew Valley Content Patcher).`,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const collection = await collectWrites(surface, install, rootIds ? { rootIds } : {});
|
|
313
|
+
const { errors, ...coverage } = collection.coverage;
|
|
314
|
+
const copies = duplicateCopies(collection);
|
|
315
|
+
const result = key
|
|
316
|
+
? { mode: "key", ...whoTouches(collection, key, limit) }
|
|
317
|
+
: mod
|
|
318
|
+
? { mode: "mod", query: mod, writers: whatTouches(collection, mod, limit) }
|
|
319
|
+
: { mode: "contested", ...contestedKeys(collection, limit) };
|
|
320
|
+
return json({
|
|
321
|
+
game: surface.id,
|
|
322
|
+
install: install.root,
|
|
323
|
+
coverage: {
|
|
324
|
+
...coverage,
|
|
325
|
+
writers: collection.writers.length,
|
|
326
|
+
errorCount: errors.length,
|
|
327
|
+
errors: errors.slice(0, 25),
|
|
328
|
+
},
|
|
329
|
+
...(copies.length > 0 ? { duplicateCopies: copies } : {}),
|
|
330
|
+
...result,
|
|
331
|
+
note: "Static: read from the mods' files, never from the running game. 'contested' means two or more mods write the " +
|
|
332
|
+
"key and not only by appending; which one the game ends up with depends on load order, which this tool does " +
|
|
333
|
+
"not decide. A mod the game does not load (marked 'inactive') is listed but never contests, and copies of one " +
|
|
334
|
+
"mod ('duplicateCopies') count once: the game loads one of them. Code that writes at runtime (redscript, CET, " +
|
|
335
|
+
"SMAPI C# mods, Script Extender Lua) is not seen here.",
|
|
336
|
+
});
|
|
337
|
+
});
|
|
222
338
|
server.registerTool("load_order", {
|
|
223
339
|
title: "Read load order",
|
|
224
340
|
description: "Read the game's explicit load order and cross-check it against what is " +
|
|
@@ -340,9 +456,7 @@ server.registerTool("project_info", {
|
|
|
340
456
|
}, async ({ path: start }) => {
|
|
341
457
|
const from = start ?? process.cwd();
|
|
342
458
|
try {
|
|
343
|
-
const file =
|
|
344
|
-
? from
|
|
345
|
-
: await findProjectFile(from);
|
|
459
|
+
const file = await locateProjectFile(from);
|
|
346
460
|
if (!file) {
|
|
347
461
|
return json({
|
|
348
462
|
found: false,
|
|
@@ -601,7 +715,11 @@ server.registerTool("triage_logs", {
|
|
|
601
715
|
"which failed and why, with the first error's explanation and usual fix. Pass the " +
|
|
602
716
|
"returned offsets and offsetKeys back as offsets/offsetKeys for incremental triage after " +
|
|
603
717
|
"the next launch (the keys let a re-created log of identical size be read again). " +
|
|
604
|
-
"Stale and zero-byte logs are reported as such rather than trusted."
|
|
718
|
+
"Stale and zero-byte logs are reported as such rather than trusted. Where a framework " +
|
|
719
|
+
"logs who did what (SMAPI's 'X edited Data/Objects', TweakXL's file read order, ArchiveXL " +
|
|
720
|
+
"merges and localization overwrites, Red Hot Tools' missing resources), the verdict's " +
|
|
721
|
+
"`attribution` groups those lines by subject, contested subjects first: the log's own answer " +
|
|
722
|
+
"to 'which mod is doing this'. Pass attributionFor to narrow it to one asset, record or mod.",
|
|
605
723
|
inputSchema: {
|
|
606
724
|
game: gameArg,
|
|
607
725
|
installPath: installPathArg,
|
|
@@ -619,15 +737,20 @@ server.registerTool("triage_logs", {
|
|
|
619
737
|
.describe("Per log id file identities (offsetKeys) from the same previous result."),
|
|
620
738
|
maxLines: z.number().int().positive().max(20000).optional(),
|
|
621
739
|
includeFindings: z.boolean().default(true),
|
|
740
|
+
attributionFor: z
|
|
741
|
+
.string()
|
|
742
|
+
.optional()
|
|
743
|
+
.describe("Keep only attribution entries whose subject, actor or content pack contains this text " +
|
|
744
|
+
"(case-insensitive): an asset or record for 'who touched this', a mod name for 'what did it touch'."),
|
|
622
745
|
},
|
|
623
|
-
}, async ({ game, installPath, since, offsets, offsetKeys, maxLines, includeFindings }) => {
|
|
746
|
+
}, async ({ game, installPath, since, offsets, offsetKeys, maxLines, includeFindings, attributionFor }) => {
|
|
624
747
|
const surface = getSurface(game);
|
|
625
748
|
if (!surface)
|
|
626
749
|
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
627
750
|
const install = await resolveInstall(surface, installPath);
|
|
628
751
|
if (!install)
|
|
629
752
|
return fail(`No install detected for ${surface.name}.`);
|
|
630
|
-
const verdict = await triageLogs(surface, install, registry, { since, offsets, offsetKeys, maxLines });
|
|
753
|
+
const verdict = await triageLogs(surface, install, registry, { since, offsets, offsetKeys, maxLines }, { attributionFor });
|
|
631
754
|
return json({
|
|
632
755
|
...verdict,
|
|
633
756
|
signatureCount: registry.forGame(surface.id).length,
|
|
@@ -677,7 +800,7 @@ server.registerTool("load_order_diff", {
|
|
|
677
800
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
678
801
|
let snapshotDir = path.join(userDataDir(), "snapshots");
|
|
679
802
|
if (projectPath) {
|
|
680
|
-
const projectFile =
|
|
803
|
+
const projectFile = await locateProjectFile(projectPath);
|
|
681
804
|
if (!projectFile)
|
|
682
805
|
return fail(`No modwright.json found at or above ${projectPath}.`);
|
|
683
806
|
snapshotDir = path.join(path.dirname(path.resolve(projectFile)), ".modwright", "snapshots");
|
|
@@ -912,7 +1035,7 @@ server.registerTool("build_index", {
|
|
|
912
1035
|
let toolOverrides;
|
|
913
1036
|
if (projectPath) {
|
|
914
1037
|
try {
|
|
915
|
-
const file =
|
|
1038
|
+
const file = await locateProjectFile(projectPath);
|
|
916
1039
|
if (!file)
|
|
917
1040
|
return fail(`No modwright.json found at or above ${projectPath}.`);
|
|
918
1041
|
const loaded = await loadProject(file);
|
|
@@ -1312,12 +1435,18 @@ server.registerTool("bridge", {
|
|
|
1312
1435
|
timeoutSeconds: z.number().positive().max(300).optional().describe("'poll': override the computed budget; still capped at 300s."),
|
|
1313
1436
|
},
|
|
1314
1437
|
}, async ({ action, game, installPath, path: start, mode, probes, armForSeconds, grant, allowMutate, windowSeconds, planId, planPath, runId, id, nonce, probeCount, timeoutSeconds }) => {
|
|
1438
|
+
const planFields = { planId, planPath, runId };
|
|
1439
|
+
const givenPlan = Object.entries(planFields).filter(([, v]) => v !== undefined);
|
|
1440
|
+
if (givenPlan.length > 0 && givenPlan.length < 3) {
|
|
1441
|
+
const missing = Object.entries(planFields).filter(([, v]) => v === undefined).map(([k]) => k);
|
|
1442
|
+
return fail(`planId, planPath and runId go together; ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} missing.`);
|
|
1443
|
+
}
|
|
1315
1444
|
let projectGame;
|
|
1316
1445
|
let projectRoot;
|
|
1317
1446
|
if (start) {
|
|
1318
1447
|
try {
|
|
1319
1448
|
const from = start;
|
|
1320
|
-
const file =
|
|
1449
|
+
const file = await locateProjectFile(from);
|
|
1321
1450
|
if (!file)
|
|
1322
1451
|
return fail(`No modwright.json found at or above ${from}.`);
|
|
1323
1452
|
const loaded = await loadProject(file);
|
|
@@ -1618,7 +1747,7 @@ const TEMPLATE_SURFACES = ["baldursgate3", "cyberpunk2077"];
|
|
|
1618
1747
|
const TEMPLATE_PERMISSIONS = ["never-redistribute", "maintainer-own", "declared-dependency"];
|
|
1619
1748
|
async function resolveProject(start) {
|
|
1620
1749
|
const from = start ?? process.cwd();
|
|
1621
|
-
const file =
|
|
1750
|
+
const file = await locateProjectFile(from);
|
|
1622
1751
|
if (!file)
|
|
1623
1752
|
throw new NoProjectError(`No modwright.json found at or above ${from}.`);
|
|
1624
1753
|
return loadProject(file);
|
|
@@ -6,6 +6,7 @@ import { dedupeByRoot, logs, roots } from "../../core/surfaceutil.js";
|
|
|
6
6
|
import { bg3Index } from "./index/index.js";
|
|
7
7
|
import { scanModIdentities as scanBg3ModIdentities } from "./identity.js";
|
|
8
8
|
import { SIGNATURES } from "./logs.js";
|
|
9
|
+
import { extractBg3Writes } from "./writes.js";
|
|
9
10
|
import { describeModSettingsDiff, diffModSettings, parseModSettings } from "./modsettings.js";
|
|
10
11
|
import { BRIDGE_VALIDATORS } from "./validators/bridge.js";
|
|
11
12
|
import { FILE_VALIDATORS } from "./validators/files.js";
|
|
@@ -112,6 +113,8 @@ export const baldursgate3 = {
|
|
|
112
113
|
};
|
|
113
114
|
},
|
|
114
115
|
logSignatures: SIGNATURES,
|
|
116
|
+
extractWrites: (mod, root) => extractBg3Writes(mod, root),
|
|
117
|
+
looseUnitOf: (rootId, rel) => (rootId === "loose" ? /^(?:Mods|Public|Generated\/Public)\/([^/]+)\//i.exec(rel)?.[1] : undefined),
|
|
115
118
|
index: bg3Index,
|
|
116
119
|
validators: [
|
|
117
120
|
...ITEM_VALIDATORS,
|
|
@@ -228,8 +228,9 @@ const osirisNamingConvention = {
|
|
|
228
228
|
const base = path.posix.basename(lower(ctx.target.path), ".txt");
|
|
229
229
|
if (!/^[a-z0-9]+(_[a-z0-9]+){2,}$/i.test(base)) {
|
|
230
230
|
findings.push(finding(osirisNamingConvention.id, "info", ctx.target.path, `Goal file name '${path.basename(ctx.target.path)}' does not obviously follow the Act_Region_Situation convention. ` +
|
|
231
|
-
`This is a naming convention, not a known build failure
|
|
232
|
-
`
|
|
231
|
+
`This is a naming convention, not a known build failure: LSLib's story compiler only warns on a name missing ` +
|
|
232
|
+
`its DB/PROC/QRY prefix (bg3.dialogue.osiris-goals.prefix-convention-is-a-compiler-warning-in-lslib), ` +
|
|
233
|
+
`so this stays informational.`, osirisNamingConvention.evidence));
|
|
233
234
|
}
|
|
234
235
|
const text = stripBom(await ctx.readText(ctx.target.path)).replace(/\r\n/g, "\n");
|
|
235
236
|
const badPrefixRe = /\b(?!DB_|QRY_|PROC_)([A-Za-z][A-Za-z0-9_]*)\s*\(/g;
|
|
@@ -482,7 +483,6 @@ const questAddQuestCloseDiscouraged = {
|
|
|
482
483
|
return goal.questAddClose.map((occ) => finding(questAddQuestCloseDiscouraged.id, "info", targetPath, `${occ.name}(...) is used here. The documentation recommends relying on QuestUpdate instead of manually adding/closing quests.`, questAddQuestCloseDiscouraged.evidence, { line: occ.line }));
|
|
483
484
|
},
|
|
484
485
|
};
|
|
485
|
-
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
486
486
|
function collectOrigins(doc) {
|
|
487
487
|
const origins = [];
|
|
488
488
|
for (const region of doc.regions) {
|
|
@@ -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
|
+
}
|