modwright 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -15
- package/bridges/bg3-se/README.md +15 -0
- package/bridges/cp2077-cet/ModWrightBridge/probes.lua +113 -0
- package/bridges/cp2077-cet/README.md +17 -1
- package/bridges/cp2077-cet/bridge.json +1 -1
- package/dist/core/bridge/pending.js +8 -1
- package/dist/core/bridge/stage.js +2 -2
- package/dist/core/build/run.js +14 -0
- package/dist/core/build/stage.js +3 -2
- package/dist/core/build/steps/core.js +4 -4
- package/dist/core/deploy/plan.js +68 -1
- package/dist/core/deploy/reload.js +5 -3
- package/dist/core/ini.js +26 -0
- package/dist/core/knowledge/facts.js +0 -12
- package/dist/core/ledger/hooks.js +22 -5
- package/dist/core/logs/registry.js +19 -0
- package/dist/core/logs/scan.js +65 -10
- package/dist/core/logs/triage.js +79 -2
- package/dist/core/managers/mo2.js +121 -0
- package/dist/core/managers/vortex.js +52 -0
- package/dist/core/ownership/index.js +364 -0
- package/dist/core/project/index.js +2 -1
- package/dist/core/project/load.js +7 -25
- package/dist/core/project/outside-root.js +36 -0
- package/dist/core/project/schema.js +38 -1
- package/dist/core/safety/backup.js +2 -2
- package/dist/core/safety/execute.js +12 -4
- package/dist/core/safety/restore.js +62 -0
- package/dist/core/testplan/registry.js +51 -2
- package/dist/core/text/converters/index.js +1 -1
- package/dist/core/text/toolchain.js +2 -2
- package/dist/core/toolchain/locate.js +13 -1
- package/dist/core/toolchain/run.js +6 -4
- package/dist/core/toolchain/types.js +1 -1
- package/dist/core/trust.js +105 -0
- package/dist/core/writes/index.js +206 -0
- package/dist/index.js +33 -9
- package/dist/server.js +252 -34
- package/dist/surfaces/baldursgate3/surface.js +3 -0
- package/dist/surfaces/baldursgate3/validators/story.js +3 -3
- package/dist/surfaces/baldursgate3/writes.js +152 -0
- package/dist/surfaces/cyberpunk2077/compat.js +8 -4
- package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
- package/dist/surfaces/cyberpunk2077/logs.js +86 -6
- package/dist/surfaces/cyberpunk2077/surface.js +13 -1
- package/dist/surfaces/cyberpunk2077/validators/cr2w.js +9 -3
- package/dist/surfaces/cyberpunk2077/writes.js +67 -0
- package/dist/surfaces/eldenring/loaders.js +7 -1
- package/dist/surfaces/skyrimse/loadorder.js +2 -26
- package/dist/surfaces/skyrimse/surface.js +1 -0
- package/dist/surfaces/stardewvalley/logs.js +41 -0
- package/dist/surfaces/stardewvalley/surface.js +2 -0
- package/dist/surfaces/stardewvalley/writes.js +136 -0
- package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
- package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
- package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
- package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
- package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
- package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
- package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
- package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
- package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
- package/package.json +1 -1
- package/scaffolds/baldursgate3/new-project.yaml +4 -1
- package/scaffolds/cyberpunk2077/new-project.yaml +4 -1
|
@@ -20,18 +20,6 @@ export function idPrefixFor(game) {
|
|
|
20
20
|
}
|
|
21
21
|
const ID_PATTERN = /^[a-z0-9]+(\.[a-z0-9-]+)+$/;
|
|
22
22
|
const TOPIC_PATTERN = /^[a-z0-9]+(\.[a-z0-9-]+)*$/;
|
|
23
|
-
const KNOWN_FACT_KEYS = [
|
|
24
|
-
"id",
|
|
25
|
-
"claim",
|
|
26
|
-
"status",
|
|
27
|
-
"verified_on",
|
|
28
|
-
"verified_by",
|
|
29
|
-
"source",
|
|
30
|
-
"tags",
|
|
31
|
-
"detail",
|
|
32
|
-
"supersedes",
|
|
33
|
-
"related",
|
|
34
|
-
];
|
|
35
23
|
const factStatusSchema = z.enum(FACT_STATUSES);
|
|
36
24
|
const optionalString = () => z
|
|
37
25
|
.string()
|
|
@@ -5,16 +5,33 @@ const NONE = { skipped: true, applied: [], refused: [] };
|
|
|
5
5
|
function toPosix(p) {
|
|
6
6
|
return p.split(path.sep).join("/");
|
|
7
7
|
}
|
|
8
|
+
function relPosix(projectRoot, f) {
|
|
9
|
+
return toPosix(path.isAbsolute(f) ? path.relative(projectRoot, f) : f);
|
|
10
|
+
}
|
|
8
11
|
function claimFileSet(claim, projectRoot) {
|
|
9
12
|
const files = claim.scope?.files ?? [];
|
|
10
|
-
return new Set(files.map((f) =>
|
|
13
|
+
return new Set(files.map((f) => relPosix(projectRoot, f)));
|
|
14
|
+
}
|
|
15
|
+
function sameFile(a, b) {
|
|
16
|
+
if (a === b)
|
|
17
|
+
return true;
|
|
18
|
+
const lower = (x) => (process.platform === "win32" ? x.toLowerCase() : x);
|
|
19
|
+
const x = lower(a);
|
|
20
|
+
const y = lower(b);
|
|
21
|
+
return x.endsWith(`/${y}`) || y.endsWith(`/${x}`);
|
|
22
|
+
}
|
|
23
|
+
function anyMatch(files, f) {
|
|
24
|
+
for (const other of files)
|
|
25
|
+
if (sameFile(other, f))
|
|
26
|
+
return true;
|
|
27
|
+
return false;
|
|
11
28
|
}
|
|
12
29
|
export async function applyValidateLedgerHook(projectRoot, blockingFindingFiles, ranFiles, tool = "validate", options = {}) {
|
|
13
30
|
const loaded = await loadClaims(projectRoot);
|
|
14
31
|
if (!loaded.exists)
|
|
15
32
|
return NONE;
|
|
16
|
-
const blocked =
|
|
17
|
-
const ran = ranFiles ?
|
|
33
|
+
const blocked = blockingFindingFiles.map((f) => relPosix(projectRoot, f));
|
|
34
|
+
const ran = ranFiles ? ranFiles.map((f) => relPosix(projectRoot, f)) : undefined;
|
|
18
35
|
const by = options.by ?? "validate";
|
|
19
36
|
let claims = loaded.claims;
|
|
20
37
|
const applied = [];
|
|
@@ -25,10 +42,10 @@ export async function applyValidateLedgerHook(projectRoot, blockingFindingFiles,
|
|
|
25
42
|
const files = claimFileSet(claim, projectRoot);
|
|
26
43
|
if (files.size === 0)
|
|
27
44
|
continue;
|
|
28
|
-
const allRan = !ran || [...files].every((f) => ran
|
|
45
|
+
const allRan = !ran || [...files].every((f) => anyMatch(ran, f));
|
|
29
46
|
if (!allRan)
|
|
30
47
|
continue;
|
|
31
|
-
const anyBlocked = [...files].some((f) => blocked
|
|
48
|
+
const anyBlocked = [...files].some((f) => anyMatch(blocked, f));
|
|
32
49
|
if (anyBlocked)
|
|
33
50
|
continue;
|
|
34
51
|
const result = applyClaimTransition({ claims, id: claim.id, to: "checker-clean", tool, by, now: options.now });
|
|
@@ -21,6 +21,25 @@ export class SignatureRegistry {
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
if (sig.attribution) {
|
|
25
|
+
const spec = sig.attribution;
|
|
26
|
+
const groups = namedGroupsOf(sig.pattern);
|
|
27
|
+
for (const field of ["subject", "subjectFallback", "actor", "via", "detail"]) {
|
|
28
|
+
const name = spec[field];
|
|
29
|
+
if (name !== undefined && !groups.has(name)) {
|
|
30
|
+
throw new Error(`Signature "${sig.id}" attribution ${field} "${name}" has no named group "(?<${name}>...)" in its pattern`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (spec.setsContext && spec.subject === undefined && spec.actor === undefined) {
|
|
34
|
+
throw new Error(`Signature "${sig.id}" sets attribution context but captures neither subject nor actor`);
|
|
35
|
+
}
|
|
36
|
+
if (spec.setsContext && spec.alsoFinding) {
|
|
37
|
+
throw new Error(`Signature "${sig.id}" cannot both set attribution context and report a finding`);
|
|
38
|
+
}
|
|
39
|
+
if (spec.subject !== undefined && spec.subjectLabel !== undefined) {
|
|
40
|
+
throw new Error(`Signature "${sig.id}" attribution names both a subject group and a subjectLabel`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
24
43
|
seenInBatch.add(sig.id);
|
|
25
44
|
}
|
|
26
45
|
for (const sig of signatures) {
|
package/dist/core/logs/scan.js
CHANGED
|
@@ -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);
|
|
@@ -102,14 +102,30 @@ export async function scanSource(source, signatures, scope, ctx) {
|
|
|
102
102
|
let text = "";
|
|
103
103
|
const length = sizeBytes - startOffset;
|
|
104
104
|
if (length > 0) {
|
|
105
|
-
const handle = await fs.open(source.path, "r");
|
|
106
105
|
try {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
const handle = await fs.open(source.path, "r");
|
|
107
|
+
try {
|
|
108
|
+
const buffer = Buffer.alloc(length);
|
|
109
|
+
await handle.read(buffer, 0, length, startOffset);
|
|
110
|
+
text = buffer.toString("utf8");
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await handle.close();
|
|
114
|
+
}
|
|
110
115
|
}
|
|
111
|
-
|
|
112
|
-
|
|
116
|
+
catch (err) {
|
|
117
|
+
const status = {
|
|
118
|
+
source,
|
|
119
|
+
sizeBytes,
|
|
120
|
+
modifiedAt: modifiedAt.toISOString(),
|
|
121
|
+
zeroByte,
|
|
122
|
+
linesScanned: 0,
|
|
123
|
+
findingCount: 0,
|
|
124
|
+
unreadable: err.message,
|
|
125
|
+
};
|
|
126
|
+
if (stale !== undefined)
|
|
127
|
+
status.stale = stale;
|
|
128
|
+
return { status, findings: [], attributions: [] };
|
|
113
129
|
}
|
|
114
130
|
}
|
|
115
131
|
if (startOffset === 0 && text.charCodeAt(0) === 0xfeff)
|
|
@@ -117,14 +133,18 @@ export async function scanSource(source, signatures, scope, ctx) {
|
|
|
117
133
|
let lines = splitLines(text);
|
|
118
134
|
if (clipped && lines.length > 0)
|
|
119
135
|
lines.shift();
|
|
136
|
+
let headTrimmed = clipped;
|
|
120
137
|
if ((!hadOffset || rotated) && lines.length > maxLines) {
|
|
121
138
|
lines = lines.slice(lines.length - maxLines);
|
|
139
|
+
headTrimmed = true;
|
|
122
140
|
}
|
|
123
141
|
const sinceDate = scope.since ? new Date(scope.since) : undefined;
|
|
124
142
|
let anyTimestampSeen = false;
|
|
125
143
|
let lastTimestamp;
|
|
126
144
|
const findingsBySignature = new Map();
|
|
127
145
|
const findings = [];
|
|
146
|
+
const attributions = [];
|
|
147
|
+
const context = {};
|
|
128
148
|
for (let i = 0; i < lines.length; i++) {
|
|
129
149
|
const line = lines[i];
|
|
130
150
|
if (sinceDate) {
|
|
@@ -141,6 +161,39 @@ export async function scanSource(source, signatures, scope, ctx) {
|
|
|
141
161
|
const match = sig.pattern.exec(line);
|
|
142
162
|
if (!match)
|
|
143
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
|
+
}
|
|
144
197
|
const existing = findingsBySignature.get(sig.id);
|
|
145
198
|
if (existing) {
|
|
146
199
|
existing.repeats = (existing.repeats ?? 0) + 1;
|
|
@@ -191,5 +244,7 @@ export async function scanSource(source, signatures, scope, ctx) {
|
|
|
191
244
|
status.rewritten = true;
|
|
192
245
|
if (sinceDate && !anyTimestampSeen)
|
|
193
246
|
status.timestamps = false;
|
|
194
|
-
|
|
247
|
+
if (headTrimmed)
|
|
248
|
+
status.headTrimmed = true;
|
|
249
|
+
return { status, findings, attributions };
|
|
195
250
|
}
|
package/dist/core/logs/triage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|