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.
Files changed (45) hide show
  1. package/README.md +43 -4
  2. package/bridges/bg3-se/README.md +3 -2
  3. package/bridges/bg3-se/bridge.json +1 -1
  4. package/bridges/cp2077-cet/ModWrightBridge/probes.lua +228 -1
  5. package/bridges/cp2077-cet/README.md +6 -3
  6. package/bridges/cp2077-cet/bridge.json +2 -2
  7. package/dist/core/bridge/remove.js +48 -0
  8. package/dist/core/build/stage.js +3 -2
  9. package/dist/core/ini.js +26 -0
  10. package/dist/core/knowledge/facts.js +0 -12
  11. package/dist/core/logs/registry.js +19 -0
  12. package/dist/core/logs/scan.js +44 -5
  13. package/dist/core/logs/triage.js +79 -2
  14. package/dist/core/managers/mo2.js +121 -0
  15. package/dist/core/managers/vortex.js +52 -0
  16. package/dist/core/ownership/index.js +364 -0
  17. package/dist/core/project/index.js +1 -1
  18. package/dist/core/project/load.js +5 -0
  19. package/dist/core/testplan/registry.js +84 -2
  20. package/dist/core/writes/index.js +206 -0
  21. package/dist/index.js +0 -2
  22. package/dist/server.js +180 -15
  23. package/dist/surfaces/baldursgate3/surface.js +3 -0
  24. package/dist/surfaces/baldursgate3/validators/story.js +3 -3
  25. package/dist/surfaces/baldursgate3/writes.js +152 -0
  26. package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
  27. package/dist/surfaces/cyberpunk2077/logs.js +82 -3
  28. package/dist/surfaces/cyberpunk2077/surface.js +11 -0
  29. package/dist/surfaces/cyberpunk2077/writes.js +67 -0
  30. package/dist/surfaces/skyrimse/loadorder.js +2 -26
  31. package/dist/surfaces/skyrimse/surface.js +1 -0
  32. package/dist/surfaces/stardewvalley/logs.js +41 -0
  33. package/dist/surfaces/stardewvalley/surface.js +2 -0
  34. package/dist/surfaces/stardewvalley/writes.js +136 -0
  35. package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
  36. package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
  37. package/knowledge/cyberpunk2077/cet.natives.yaml +48 -0
  38. package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
  39. package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
  40. package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
  41. package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
  42. package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
  43. package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
  44. package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
  45. package/package.json +1 -1
@@ -37,14 +37,14 @@ function parseScanKey(key) {
37
37
  }
38
38
  export async function scanSource(source, signatures, scope, ctx) {
39
39
  if (!source.exists) {
40
- return { status: { source, linesScanned: 0, findingCount: 0 }, findings: [] };
40
+ return { status: { source, linesScanned: 0, findingCount: 0 }, findings: [], attributions: [] };
41
41
  }
42
42
  let stat;
43
43
  try {
44
44
  stat = await fs.stat(source.path);
45
45
  }
46
46
  catch {
47
- return { status: { source, linesScanned: 0, findingCount: 0 }, findings: [] };
47
+ return { status: { source, linesScanned: 0, findingCount: 0 }, findings: [], attributions: [] };
48
48
  }
49
49
  const sizeBytes = stat.size;
50
50
  const modifiedAt = stat.mtime;
@@ -63,7 +63,7 @@ export async function scanSource(source, signatures, scope, ctx) {
63
63
  };
64
64
  if (stale !== undefined)
65
65
  status.stale = stale;
66
- return { status, findings: [] };
66
+ return { status, findings: [], attributions: [] };
67
67
  }
68
68
  const birthtimeMs = Math.round(stat.birthtimeMs);
69
69
  const mtimeMs = Math.round(stat.mtimeMs);
@@ -125,7 +125,7 @@ export async function scanSource(source, signatures, scope, ctx) {
125
125
  };
126
126
  if (stale !== undefined)
127
127
  status.stale = stale;
128
- return { status, findings: [] };
128
+ return { status, findings: [], attributions: [] };
129
129
  }
130
130
  }
131
131
  if (startOffset === 0 && text.charCodeAt(0) === 0xfeff)
@@ -133,14 +133,18 @@ export async function scanSource(source, signatures, scope, ctx) {
133
133
  let lines = splitLines(text);
134
134
  if (clipped && lines.length > 0)
135
135
  lines.shift();
136
+ let headTrimmed = clipped;
136
137
  if ((!hadOffset || rotated) && lines.length > maxLines) {
137
138
  lines = lines.slice(lines.length - maxLines);
139
+ headTrimmed = true;
138
140
  }
139
141
  const sinceDate = scope.since ? new Date(scope.since) : undefined;
140
142
  let anyTimestampSeen = false;
141
143
  let lastTimestamp;
142
144
  const findingsBySignature = new Map();
143
145
  const findings = [];
146
+ const attributions = [];
147
+ const context = {};
144
148
  for (let i = 0; i < lines.length; i++) {
145
149
  const line = lines[i];
146
150
  if (sinceDate) {
@@ -157,6 +161,39 @@ export async function scanSource(source, signatures, scope, ctx) {
157
161
  const match = sig.pattern.exec(line);
158
162
  if (!match)
159
163
  continue;
164
+ const spec = sig.attribution;
165
+ if (spec) {
166
+ const group = (name) => name === undefined ? undefined : match.groups?.[name];
167
+ if (spec.setsContext) {
168
+ const subject = group(spec.subject);
169
+ const actor = group(spec.actor);
170
+ if (subject !== undefined)
171
+ context.subject = subject;
172
+ if (actor !== undefined)
173
+ context.actor = actor;
174
+ continue;
175
+ }
176
+ const subject = group(spec.subject) ?? group(spec.subjectFallback) ?? spec.subjectLabel ?? context.subject;
177
+ if (subject !== undefined) {
178
+ const record = {
179
+ sourceId: source.id,
180
+ signatureId: sig.id,
181
+ kind: spec.kind,
182
+ subject,
183
+ actor: group(spec.actor) ?? context.actor ?? "?",
184
+ lineNumber: i + 1,
185
+ };
186
+ const via = group(spec.via);
187
+ if (via !== undefined)
188
+ record.via = via;
189
+ const detail = group(spec.detail);
190
+ if (detail !== undefined)
191
+ record.detail = detail;
192
+ attributions.push(record);
193
+ }
194
+ if (!spec.alsoFinding)
195
+ continue;
196
+ }
160
197
  const existing = findingsBySignature.get(sig.id);
161
198
  if (existing) {
162
199
  existing.repeats = (existing.repeats ?? 0) + 1;
@@ -207,5 +244,7 @@ export async function scanSource(source, signatures, scope, ctx) {
207
244
  status.rewritten = true;
208
245
  if (sinceDate && !anyTimestampSeen)
209
246
  status.timestamps = false;
210
- return { status, findings };
247
+ if (headTrimmed)
248
+ status.headTrimmed = true;
249
+ return { status, findings, attributions };
211
250
  }
@@ -3,7 +3,9 @@ import { findRunningProcesses } from "../process.js";
3
3
  import { resolveLogs } from "../surfaceutil.js";
4
4
  import { scanSource } from "./scan.js";
5
5
  const FINDINGS_CAP = 200;
6
- export async function triageLogs(surface, install, registry, scope = {}) {
6
+ const ATTRIBUTION_SUBJECT_CAP = 150;
7
+ const ATTRIBUTION_ACTOR_CAP = 100;
8
+ export async function triageLogs(surface, install, registry, scope = {}, options = {}) {
7
9
  const sources = await resolveLogs(surface, install);
8
10
  const processResult = await findRunningProcesses(surface.processNames ?? []);
9
11
  const gameRunning = processResult.checked ? processResult.running.length > 0 : undefined;
@@ -34,15 +36,17 @@ export async function triageLogs(surface, install, registry, scope = {}) {
34
36
  }
35
37
  const statuses = [];
36
38
  const allFindings = [];
39
+ const allAttributions = [];
37
40
  const offsets = {};
38
41
  const offsetKeys = {};
39
42
  for (const source of sources) {
40
43
  const signatures = registry.forSource(surface.id, source.id);
41
- const { status, findings } = await scanSource(source, signatures, scope, { lastLaunch });
44
+ const { status, findings, attributions } = await scanSource(source, signatures, scope, { lastLaunch });
42
45
  if (!source.exists && missingReasons[source.id])
43
46
  status.missingReason = missingReasons[source.id];
44
47
  statuses.push(status);
45
48
  allFindings.push(...findings);
49
+ allAttributions.push(...attributions);
46
50
  if (status.endOffset !== undefined)
47
51
  offsets[source.id] = status.endOffset;
48
52
  if (status.key !== undefined)
@@ -104,8 +108,81 @@ export async function triageLogs(surface, install, registry, scope = {}) {
104
108
  verdict.truncated = true;
105
109
  if (Object.keys(systemNotes).length > 0)
106
110
  verdict.systemNotes = systemNotes;
111
+ if (allAttributions.length > 0) {
112
+ verdict.attribution = buildAttributionReport(allAttributions, statuses, options.attributionFor);
113
+ }
107
114
  return verdict;
108
115
  }
116
+ export function buildAttributionReport(records, statuses, filter) {
117
+ const needle = filter?.trim().toLowerCase();
118
+ const kept = needle
119
+ ? records.filter((r) => [r.subject, r.actor, r.via].some((v) => v !== undefined && v.toLowerCase().includes(needle)))
120
+ : records;
121
+ const bySubject = new Map();
122
+ for (const record of kept) {
123
+ let entry = bySubject.get(record.subject);
124
+ if (!entry) {
125
+ entry = { subject: record.subject, kinds: [], actors: [], order: bySubject.size };
126
+ bySubject.set(record.subject, entry);
127
+ }
128
+ if (!entry.kinds.includes(record.kind))
129
+ entry.kinds.push(record.kind);
130
+ const same = entry.actors.find((a) => a.actor === record.actor && a.via === record.via && a.kind === record.kind && a.detail === record.detail);
131
+ if (same) {
132
+ same.count++;
133
+ continue;
134
+ }
135
+ const actor = {
136
+ actor: record.actor,
137
+ kind: record.kind,
138
+ sourceId: record.sourceId,
139
+ firstLine: record.lineNumber,
140
+ count: 1,
141
+ };
142
+ if (record.via !== undefined)
143
+ actor.via = record.via;
144
+ if (record.detail !== undefined)
145
+ actor.detail = record.detail;
146
+ entry.actors.push(actor);
147
+ }
148
+ const distinctActors = (subject) => new Set(subject.actors.map((a) => `${a.actor}\u0000${a.via ?? ""}`)).size;
149
+ const isContested = (subject) => !subject.kinds.every((k) => k === "read-order") && distinctActors(subject) > 1;
150
+ const all = [...bySubject.values()];
151
+ const contested = all.filter(isContested).length;
152
+ all.sort((a, b) => Number(isContested(b)) - Number(isContested(a)) || a.order - b.order);
153
+ const subjects = all.slice(0, ATTRIBUTION_SUBJECT_CAP).map(({ order: _order, ...subject }) => {
154
+ if (subject.actors.length <= ATTRIBUTION_ACTOR_CAP)
155
+ return subject;
156
+ return {
157
+ ...subject,
158
+ actors: subject.actors.slice(0, ATTRIBUTION_ACTOR_CAP),
159
+ omittedActors: subject.actors.length - ATTRIBUTION_ACTOR_CAP,
160
+ };
161
+ });
162
+ const report = {
163
+ records: records.length,
164
+ contested,
165
+ subjects,
166
+ note: "Straight from the logs: each actor is who the log line itself names, listed in the order " +
167
+ "the log shows them acting, so for edits and reads the later actor applied last. Only what " +
168
+ "the frameworks chose to log is here; a mod that changes something without the loader " +
169
+ "logging it will not appear.",
170
+ };
171
+ if (needle)
172
+ report.filter = filter.trim();
173
+ if (all.length > ATTRIBUTION_SUBJECT_CAP)
174
+ report.omittedSubjects = all.length - ATTRIBUTION_SUBJECT_CAP;
175
+ const partial = statuses
176
+ .filter((st) => st.headTrimmed && records.some((r) => r.sourceId === st.source.id))
177
+ .map((st) => st.source.id);
178
+ if (partial.length > 0) {
179
+ report.partialSources = partial;
180
+ report.note +=
181
+ ` The start of ${partial.join(", ")} was not read (tail cap), so earlier attribution lines ` +
182
+ "are missing; pass a larger maxLines (up to 20000) for the whole run.";
183
+ }
184
+ return report;
185
+ }
109
186
  function buildHeadline(sources, statuses, findings, systems) {
110
187
  if (sources.length === 0) {
111
188
  return "No log sources are declared for this game; triage has nothing to read.";
@@ -0,0 +1,121 @@
1
+ import { promises as fs } from "node:fs";
2
+ import * as path from "node:path";
3
+ import { localAppData, pathExists } from "../fsutil.js";
4
+ import { parseIni } from "../ini.js";
5
+ function iniPath(value) {
6
+ return value.replace(/\\\\/g, "\\");
7
+ }
8
+ function samePath(a, b) {
9
+ const norm = (p) => path.resolve(iniPath(p)).replace(/[\\/]+$/, "").toLowerCase();
10
+ return norm(a) === norm(b);
11
+ }
12
+ export async function readMo2Instance(iniFile, portable) {
13
+ let ini;
14
+ try {
15
+ ini = parseIni(await fs.readFile(iniFile, "utf8"));
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ const instanceDir = path.dirname(iniFile);
21
+ const general = ini.get("general");
22
+ const settings = ini.get("settings");
23
+ const base = iniPath(settings?.get("base_directory") ?? instanceDir);
24
+ const configured = (key, fallback) => path.resolve(instanceDir, iniPath(settings?.get(key) ?? `%BASE_DIR%/${fallback}`).replace(/%BASE_DIR%/gi, base));
25
+ const profileName = general?.get("selected_profile") ?? "Default";
26
+ const profilesDir = configured("profiles_directory", "profiles");
27
+ const gamePath = general?.get("gamepath");
28
+ const instance = {
29
+ instanceDir,
30
+ iniPath: iniFile,
31
+ portable,
32
+ profileName,
33
+ profileDir: path.join(profilesDir, profileName),
34
+ modsDir: configured("mod_directory", "mods"),
35
+ overwriteDir: configured("overwrite_directory", "overwrite"),
36
+ downloadsDir: configured("download_directory", "downloads"),
37
+ };
38
+ if (gamePath)
39
+ instance.gamePath = iniPath(gamePath);
40
+ return instance;
41
+ }
42
+ export async function findMo2Instance(install, options = {}) {
43
+ if (options.instancePath) {
44
+ const iniFile = options.instancePath.toLowerCase().endsWith(".ini")
45
+ ? options.instancePath
46
+ : path.join(options.instancePath, "ModOrganizer.ini");
47
+ if (await pathExists(iniFile))
48
+ return readMo2Instance(iniFile, true);
49
+ return undefined;
50
+ }
51
+ const root = options.instancesRoot ?? localAppData("ModOrganizer");
52
+ let names;
53
+ try {
54
+ names = await fs.readdir(root);
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ for (const name of names) {
60
+ const iniFile = path.join(root, name, "ModOrganizer.ini");
61
+ if (!(await pathExists(iniFile)))
62
+ continue;
63
+ const instance = await readMo2Instance(iniFile, false);
64
+ if (instance?.gamePath && samePath(instance.gamePath, install.root))
65
+ return instance;
66
+ }
67
+ return undefined;
68
+ }
69
+ export function parseMo2Modlist(text) {
70
+ const lines = [];
71
+ const seen = new Set();
72
+ for (const raw of text.split(/\r?\n/)) {
73
+ const line = raw.replace(/^/, "");
74
+ if (!line.trim() || line.startsWith("#"))
75
+ continue;
76
+ const marker = line[0];
77
+ const hasMarker = marker === "+" || marker === "-" || marker === "*";
78
+ const name = (hasMarker ? line.slice(1) : line).trim();
79
+ if (!name || seen.has(name))
80
+ continue;
81
+ seen.add(name);
82
+ lines.push({ name, enabled: marker !== "-", foreign: marker === "*" });
83
+ }
84
+ return lines.map((entry, index) => ({
85
+ ...entry,
86
+ separator: entry.name.endsWith("_separator"),
87
+ priority: lines.length - index,
88
+ }));
89
+ }
90
+ export async function readMo2Modlist(profileDir) {
91
+ try {
92
+ return parseMo2Modlist(await fs.readFile(path.join(profileDir, "modlist.txt"), "utf8"));
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ }
98
+ export async function readMo2ModMeta(modDir) {
99
+ let ini;
100
+ try {
101
+ ini = parseIni(await fs.readFile(path.join(modDir, "meta.ini"), "utf8"));
102
+ }
103
+ catch {
104
+ return {};
105
+ }
106
+ const general = ini.get("general");
107
+ const meta = {};
108
+ const modId = Number(general?.get("modid"));
109
+ if (Number.isInteger(modId) && modId > 0)
110
+ meta.nexusModId = modId;
111
+ const version = general?.get("version");
112
+ if (version)
113
+ meta.version = version;
114
+ const installationFile = general?.get("installationfile");
115
+ if (installationFile)
116
+ meta.installationFile = installationFile;
117
+ const gameName = general?.get("gamename");
118
+ if (gameName)
119
+ meta.gameName = gameName;
120
+ return meta;
121
+ }
@@ -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
+ }