hoi4-agent-tools 4.0.0 → 4.0.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/CHANGELOG.md +35 -0
- package/README.md +27 -53
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +18 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +115 -27
- package/dist/hoi4_agent_tools/core/job-worker.js +7 -2
- package/dist/hoi4_agent_tools/core/temporary-directories.js +293 -0
- package/dist/hoi4_agent_tools/core/worker-exit.js +80 -0
- package/dist/hoi4_agent_tools/gui/assets.js +3 -0
- package/dist/hoi4_agent_tools/gui/dds.js +8 -4
- package/dist/hoi4_agent_tools/gui/layout.js +45 -5
- package/dist/hoi4_agent_tools/gui/native-state.js +244 -0
- package/dist/hoi4_agent_tools/gui/renderer.js +12 -7
- package/dist/hoi4_agent_tools/gui/source-graph.js +17 -4
- package/dist/hoi4_agent_tools/gui/studio.js +33 -21
- package/dist/hoi4_agent_tools/mcp/prompts/probability.js +2 -2
- package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +8 -8
- package/dist/hoi4_agent_tools/mcp/tools/event.js +3 -3
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +5 -5
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +6 -6
- package/dist/hoi4_agent_tools/mcp/tools/job.js +4 -4
- package/dist/hoi4_agent_tools/mcp/tools/map.js +4 -4
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +4 -4
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +12 -12
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +3 -3
- package/dist/hoi4_agent_tools/runtime.js +14 -1
- package/dist/hoi4_agent_tools/schemas/map-requests.js +1 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/analysis.md +2 -2
- package/docs/development.md +2 -0
- package/docs/events.md +2 -3
- package/docs/examples.md +8 -23
- package/docs/focus.md +3 -2
- package/docs/gui.md +7 -4
- package/docs/helper-expansion.md +1 -1
- package/docs/jobs.md +5 -2
- package/docs/map.md +5 -4
- package/docs/mechanics.md +2 -2
- package/docs/probability.md +5 -2
- package/docs/reference.md +2 -1
- package/docs/setup.md +2 -4
- package/docs/technology.md +2 -3
- package/docs/tools.md +12 -13
- package/docs/troubleshooting.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +3 -3
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstatSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
3
|
+
import { lstat, open, readFile, readdir, realpath, rm } from 'node:fs/promises';
|
|
4
|
+
import { hostname } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { z } from 'zod/v4';
|
|
7
|
+
const OWNER_FILE = '.hoi4-temp-owner.json';
|
|
8
|
+
const SWEEP_LOCK = '.hoi4-temp-sweep.lock';
|
|
9
|
+
export const TEMPORARY_MAX_BYTES = 256 * 1024 * 1024;
|
|
10
|
+
export const TEMPORARY_STALE_MS = 3 * 60 * 60 * 1000;
|
|
11
|
+
// Dated event evaluation directories contain retained benchmark evidence, not scratch files.
|
|
12
|
+
const scratchName = /^hoi4-(?:reference-source|event-eval)-[A-Za-z0-9]{6}$/u;
|
|
13
|
+
const ownerSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
version: z.literal(1),
|
|
16
|
+
pid: z.number().int().positive(),
|
|
17
|
+
host: z.string().min(1).max(256),
|
|
18
|
+
token: z.uuid(),
|
|
19
|
+
})
|
|
20
|
+
.strict();
|
|
21
|
+
function alive(pid) {
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
return error.code !== 'ESRCH';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function owner(directory, name = OWNER_FILE) {
|
|
31
|
+
const file = path.join(directory, name);
|
|
32
|
+
try {
|
|
33
|
+
const metadata = await lstat(file);
|
|
34
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 4096)
|
|
35
|
+
return undefined;
|
|
36
|
+
const result = ownerSchema.safeParse(JSON.parse(await readFile(file, 'utf8')));
|
|
37
|
+
return result.success ? result.data : undefined;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return error.code === 'ENOENT' ? null : undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function inventory(directory) {
|
|
44
|
+
const result = { bytes: 0, modified: 0, locked: false };
|
|
45
|
+
const pending = [directory];
|
|
46
|
+
let entries = 0;
|
|
47
|
+
while (pending.length > 0) {
|
|
48
|
+
const current = pending.pop();
|
|
49
|
+
const metadata = await lstat(current);
|
|
50
|
+
if (++entries > 100_000)
|
|
51
|
+
throw new Error('Temporary directory inventory limit exceeded');
|
|
52
|
+
result.modified = Math.max(result.modified, metadata.mtimeMs, metadata.birthtimeMs);
|
|
53
|
+
if (metadata.isSymbolicLink())
|
|
54
|
+
continue;
|
|
55
|
+
if (metadata.isFile())
|
|
56
|
+
result.bytes += metadata.size;
|
|
57
|
+
else if (metadata.isDirectory()) {
|
|
58
|
+
for (const name of await readdir(current)) {
|
|
59
|
+
if (name === OWNER_FILE)
|
|
60
|
+
continue;
|
|
61
|
+
if (/\.(?:lock|lease)$/u.test(name))
|
|
62
|
+
result.locked = true;
|
|
63
|
+
pending.push(path.join(current, name));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
function overlaps(left, right) {
|
|
70
|
+
const normalize = (value) => process.platform === 'win32' ? path.resolve(value).toLowerCase() : path.resolve(value);
|
|
71
|
+
const a = normalize(left);
|
|
72
|
+
const b = normalize(right);
|
|
73
|
+
const contains = (parent, child) => {
|
|
74
|
+
const relative = path.relative(parent, child);
|
|
75
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
76
|
+
};
|
|
77
|
+
return contains(a, b) || contains(b, a);
|
|
78
|
+
}
|
|
79
|
+
/** Trusted startup maintenance only; never follows a junction or accepts an MCP path. */
|
|
80
|
+
export async function sweepTemporaryDirectories(parent, options = {}) {
|
|
81
|
+
const now = options.now ?? Date.now();
|
|
82
|
+
const staleMs = options.staleMs ?? TEMPORARY_STALE_MS;
|
|
83
|
+
const maxBytes = options.maxBytes ?? TEMPORARY_MAX_BYTES;
|
|
84
|
+
const currentUid = options.currentUid ?? (process.platform === 'win32' ? undefined : process.geteuid?.());
|
|
85
|
+
if (!Number.isFinite(now) ||
|
|
86
|
+
!Number.isSafeInteger(staleMs) ||
|
|
87
|
+
staleMs < 0 ||
|
|
88
|
+
!Number.isSafeInteger(maxBytes) ||
|
|
89
|
+
maxBytes < 0 ||
|
|
90
|
+
(currentUid !== undefined && (!Number.isSafeInteger(currentUid) || currentUid < 0)))
|
|
91
|
+
throw new RangeError('Invalid temporary storage limits');
|
|
92
|
+
const root = await realpath(parent);
|
|
93
|
+
// A Windows runner reaches %TEMP% through an 8.3 alias; match protected roots by both spellings.
|
|
94
|
+
const protectedRoots = (await Promise.all((options.protectedRoots ?? []).map(async (protectedRoot) => {
|
|
95
|
+
try {
|
|
96
|
+
return [protectedRoot, await realpath(protectedRoot)];
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return [protectedRoot];
|
|
100
|
+
}
|
|
101
|
+
}))).flat();
|
|
102
|
+
const candidates = [];
|
|
103
|
+
for (const name of (await readdir(root)).filter((name) => scratchName.test(name)).sort()) {
|
|
104
|
+
const directory = path.join(root, name);
|
|
105
|
+
if (protectedRoots.some((protectedRoot) => overlaps(directory, protectedRoot)))
|
|
106
|
+
continue;
|
|
107
|
+
try {
|
|
108
|
+
const metadata = await lstat(directory);
|
|
109
|
+
if (!metadata.isDirectory() ||
|
|
110
|
+
metadata.isSymbolicLink() ||
|
|
111
|
+
(currentUid !== undefined && metadata.uid !== currentUid) ||
|
|
112
|
+
(await realpath(directory)) !== directory)
|
|
113
|
+
continue;
|
|
114
|
+
const record = await owner(directory);
|
|
115
|
+
// An interrupted sweep must not leave a permanent lock. Malformed or live locks
|
|
116
|
+
// stay protected; only a confirmed dead local owner can relinquish its lock.
|
|
117
|
+
const previousSweep = await owner(directory, SWEEP_LOCK);
|
|
118
|
+
const deadSweep = previousSweep?.host === hostname() &&
|
|
119
|
+
!(options.isAlive ?? alive)(previousSweep.pid) &&
|
|
120
|
+
(await owner(directory, SWEEP_LOCK))?.token === previousSweep.token;
|
|
121
|
+
if (deadSweep)
|
|
122
|
+
await rm(path.join(directory, SWEEP_LOCK));
|
|
123
|
+
const data = await inventory(directory);
|
|
124
|
+
const dead = record?.host === hostname() && !(options.isAlive ?? alive)(record.pid);
|
|
125
|
+
candidates.push({
|
|
126
|
+
directory,
|
|
127
|
+
...data,
|
|
128
|
+
eligible: !data.locked && (dead || deadSweep || (record === null && now - data.modified > staleMs)),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Sharing violations, malformed leases and incomplete inventories fail closed.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
let retainedBytes = candidates.reduce((sum, entry) => sum + entry.bytes, 0);
|
|
136
|
+
let removed = 0;
|
|
137
|
+
candidates.sort((a, b) => a.modified - b.modified || (a.directory < b.directory ? -1 : 1));
|
|
138
|
+
for (const entry of candidates) {
|
|
139
|
+
if (!entry.eligible || (now - entry.modified <= staleMs && retainedBytes <= maxBytes))
|
|
140
|
+
continue;
|
|
141
|
+
const lockPath = path.join(entry.directory, SWEEP_LOCK);
|
|
142
|
+
let lock;
|
|
143
|
+
let claimed = false;
|
|
144
|
+
const token = randomUUID();
|
|
145
|
+
try {
|
|
146
|
+
lock = await open(lockPath, 'wx', 0o600);
|
|
147
|
+
claimed = true;
|
|
148
|
+
await lock.writeFile(JSON.stringify({ version: 1, pid: process.pid, host: hostname(), token }));
|
|
149
|
+
const record = await owner(entry.directory);
|
|
150
|
+
if (record === undefined ||
|
|
151
|
+
(record !== null && (record.host !== hostname() || (options.isAlive ?? alive)(record.pid))))
|
|
152
|
+
continue;
|
|
153
|
+
// Check containment again immediately before recursive removal.
|
|
154
|
+
if ((await lstat(entry.directory)).isSymbolicLink() ||
|
|
155
|
+
(await realpath(entry.directory)) !== entry.directory)
|
|
156
|
+
continue;
|
|
157
|
+
if ((await owner(entry.directory, SWEEP_LOCK))?.token !== token)
|
|
158
|
+
continue;
|
|
159
|
+
await lock.close();
|
|
160
|
+
lock = undefined;
|
|
161
|
+
await rm(entry.directory, { recursive: true, force: true });
|
|
162
|
+
retainedBytes -= entry.bytes;
|
|
163
|
+
removed += 1;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Windows may still hold a file open. Leave it for a later sweep.
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
await lock?.close().catch(() => undefined);
|
|
170
|
+
if (claimed && (await owner(entry.directory, SWEEP_LOCK))?.token === token)
|
|
171
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { removed, retainedBytes };
|
|
175
|
+
}
|
|
176
|
+
/** Own only directories allocated by this process; exit cleanup never uses a prefix sweep. */
|
|
177
|
+
export class TemporaryDirectoryLifetime {
|
|
178
|
+
maxBytes;
|
|
179
|
+
#owned = new Map();
|
|
180
|
+
#reservedFiles = new Map();
|
|
181
|
+
#reservedBytes = 0;
|
|
182
|
+
#onExit = () => this.disposeSync();
|
|
183
|
+
constructor(maxBytes = TEMPORARY_MAX_BYTES) {
|
|
184
|
+
this.maxBytes = maxBytes;
|
|
185
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
|
186
|
+
throw new RangeError('Invalid temporary storage budget');
|
|
187
|
+
process.once('exit', this.#onExit);
|
|
188
|
+
}
|
|
189
|
+
/** Reject an oversized fixture before truncate allocates its disk blocks on Windows. */
|
|
190
|
+
assertFileSize(file, bytes) {
|
|
191
|
+
const requested = path.resolve(file);
|
|
192
|
+
// Fixture paths are canonical, so resolve an aliased parent before matching them.
|
|
193
|
+
let absolute = requested;
|
|
194
|
+
try {
|
|
195
|
+
absolute = path.join(realpathSync.native(path.dirname(requested)), path.basename(requested));
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// A file in a directory that does not exist yet cannot be inside an owned fixture.
|
|
199
|
+
}
|
|
200
|
+
if (!this.paths().some((directory) => absolute.startsWith(`${directory}${path.sep}`)))
|
|
201
|
+
return;
|
|
202
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.maxBytes)
|
|
203
|
+
throw new RangeError('Temporary fixture exceeds the disk budget; use a small injected limit');
|
|
204
|
+
const previous = this.#reservedFiles.get(absolute) ?? 0;
|
|
205
|
+
if (this.#reservedBytes - previous + bytes > this.maxBytes)
|
|
206
|
+
throw new RangeError('Temporary fixtures exceed the disk budget');
|
|
207
|
+
// Reserve before the caller writes, so concurrent allocations cannot oversubscribe.
|
|
208
|
+
// Failed writes remain reserved until teardown, which fails safely rather than undercounts.
|
|
209
|
+
this.#reservedBytes += bytes - previous;
|
|
210
|
+
this.#reservedFiles.set(absolute, bytes);
|
|
211
|
+
}
|
|
212
|
+
async own(directory) {
|
|
213
|
+
const canonical = await realpath(directory);
|
|
214
|
+
// The parent may be reached through an alias, such as a Windows 8.3 short name for the user
|
|
215
|
+
// profile; the fixture directory itself must be a real directory, not a link.
|
|
216
|
+
const resolved = path.resolve(directory);
|
|
217
|
+
const viaParent = path.join(await realpath(path.dirname(resolved)), path.basename(resolved));
|
|
218
|
+
if ((await lstat(directory)).isSymbolicLink() || canonical !== viaParent)
|
|
219
|
+
throw new Error('Temporary directory must be canonical and unlinked');
|
|
220
|
+
const sweepLock = path.join(canonical, SWEEP_LOCK);
|
|
221
|
+
if (await lstat(sweepLock).catch(() => undefined))
|
|
222
|
+
throw new Error('Temporary directory is being swept');
|
|
223
|
+
const token = randomUUID();
|
|
224
|
+
const handle = await open(path.join(canonical, OWNER_FILE), 'wx', 0o600);
|
|
225
|
+
try {
|
|
226
|
+
await handle.writeFile(JSON.stringify({ version: 1, pid: process.pid, host: hostname(), token }));
|
|
227
|
+
this.#owned.set(canonical, token);
|
|
228
|
+
if (await lstat(sweepLock).catch(() => undefined))
|
|
229
|
+
throw new Error('Temporary directory is being swept');
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
await handle.close();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
paths() {
|
|
236
|
+
return [...this.#owned.keys()];
|
|
237
|
+
}
|
|
238
|
+
async release(requested) {
|
|
239
|
+
let directory = requested;
|
|
240
|
+
if (!this.#owned.has(directory)) {
|
|
241
|
+
// Owned paths are canonical; a caller may still hold an 8.3 or symlinked spelling.
|
|
242
|
+
try {
|
|
243
|
+
directory = await realpath(requested);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const token = this.#owned.get(directory);
|
|
250
|
+
if (token === undefined)
|
|
251
|
+
return;
|
|
252
|
+
if ((await owner(directory))?.token !== token) {
|
|
253
|
+
this.#owned.delete(directory);
|
|
254
|
+
this.#forgetFiles(directory);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if ((await lstat(directory)).isSymbolicLink() || (await realpath(directory)) !== directory)
|
|
258
|
+
throw new Error('Temporary directory changed before cleanup');
|
|
259
|
+
await rm(directory, { recursive: true, force: true });
|
|
260
|
+
this.#owned.delete(directory);
|
|
261
|
+
this.#forgetFiles(directory);
|
|
262
|
+
}
|
|
263
|
+
#forgetFiles(directory) {
|
|
264
|
+
for (const [file, bytes] of this.#reservedFiles)
|
|
265
|
+
if (file.startsWith(`${directory}${path.sep}`)) {
|
|
266
|
+
this.#reservedBytes -= bytes;
|
|
267
|
+
this.#reservedFiles.delete(file);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async dispose() {
|
|
271
|
+
for (const directory of this.paths())
|
|
272
|
+
await this.release(directory);
|
|
273
|
+
process.removeListener('exit', this.#onExit);
|
|
274
|
+
}
|
|
275
|
+
disposeSync() {
|
|
276
|
+
for (const [directory, token] of this.#owned) {
|
|
277
|
+
try {
|
|
278
|
+
if (lstatSync(directory).isSymbolicLink() || realpathSync.native(directory) !== directory)
|
|
279
|
+
continue;
|
|
280
|
+
const parsed = ownerSchema.safeParse(JSON.parse(readFileSync(path.join(directory, OWNER_FILE), 'utf8')));
|
|
281
|
+
if (parsed.success && parsed.data.token === token)
|
|
282
|
+
rmSync(directory, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// A crash or sharing violation is recovered by the next stale sweep.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
this.#owned.clear();
|
|
289
|
+
this.#reservedFiles.clear();
|
|
290
|
+
this.#reservedBytes = 0;
|
|
291
|
+
process.removeListener('exit', this.#onExit);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { totalmem } from 'node:os';
|
|
2
|
+
/** Report an uncaught failure without logging its potentially private message or stack. */
|
|
3
|
+
export function workerErrorMarker(error) {
|
|
4
|
+
let code;
|
|
5
|
+
try {
|
|
6
|
+
code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
// Even an exceptional code getter must not prevent a bounded failure marker.
|
|
10
|
+
}
|
|
11
|
+
return `worker-error: ${typeof code === 'string' && /^[A-Z0-9_]{1,64}$/u.test(code) ? code : 'UNCLASSIFIED'}\n`;
|
|
12
|
+
}
|
|
13
|
+
/** Windows status codes that a crashed native module or the C runtime leaves as the exit code. */
|
|
14
|
+
const WINDOWS_CRASH_CODES = new Map([
|
|
15
|
+
[0xc0000005, 'access violation'],
|
|
16
|
+
[0xc00000fd, 'native stack overflow'],
|
|
17
|
+
[0xc0000409, 'fast-fail abort'],
|
|
18
|
+
[0xc0000374, 'heap corruption'],
|
|
19
|
+
[0x80000003, 'breakpoint abort'],
|
|
20
|
+
]);
|
|
21
|
+
const CRASH_SIGNALS = new Set(['SIGSEGV', 'SIGABRT', 'SIGILL', 'SIGBUS', 'SIGFPE']);
|
|
22
|
+
const HEAP_OOM = /JavaScript heap out of memory|Reached heap limit|Ineffective mark-compacts/iu;
|
|
23
|
+
const NATIVE_OOM = /Allocation failed - process out of memory|std::bad_alloc|Array buffer allocation failed|out of memory/iu;
|
|
24
|
+
function unsignedExitCode(code) {
|
|
25
|
+
return code < 0 ? code >>> 0 : code;
|
|
26
|
+
}
|
|
27
|
+
/** Decide, from the exit status and the worker's last stderr, why a worker stopped. */
|
|
28
|
+
export function classifyWorkerExit(evidence) {
|
|
29
|
+
const status = evidence.signal === null
|
|
30
|
+
? `exit code ${evidence.code ?? 'none'}`
|
|
31
|
+
: `signal ${evidence.signal}${evidence.code === null ? '' : `, exit code ${evidence.code}`}`;
|
|
32
|
+
if (evidence.stoppedByHost)
|
|
33
|
+
return {
|
|
34
|
+
cause: 'stopped_by_host',
|
|
35
|
+
message: `The worker was stopped by its supervising server (${status})`,
|
|
36
|
+
};
|
|
37
|
+
if (HEAP_OOM.test(evidence.stderrTail))
|
|
38
|
+
return {
|
|
39
|
+
cause: 'heap_out_of_memory',
|
|
40
|
+
message: `The worker exhausted its V8 heap limit${evidence.heapLimitMiB === undefined ? '' : ` of ${evidence.heapLimitMiB} MiB`} (${status}); narrow the request or raise jobWorkerMaxHeapMiB`,
|
|
41
|
+
};
|
|
42
|
+
if (NATIVE_OOM.test(evidence.stderrTail))
|
|
43
|
+
return {
|
|
44
|
+
cause: 'native_out_of_memory',
|
|
45
|
+
message: `The worker could not allocate native memory (${status}); close other memory-heavy processes or request fewer or smaller render variants`,
|
|
46
|
+
};
|
|
47
|
+
const crash = evidence.code === null ? undefined : WINDOWS_CRASH_CODES.get(unsignedExitCode(evidence.code));
|
|
48
|
+
if (crash !== undefined || (evidence.signal !== null && CRASH_SIGNALS.has(evidence.signal)))
|
|
49
|
+
return {
|
|
50
|
+
cause: 'native_crash',
|
|
51
|
+
message: `The worker crashed in native code${crash === undefined ? '' : ` (${crash})`} (${status}); this is not a memory-limit failure`,
|
|
52
|
+
};
|
|
53
|
+
if (evidence.signal !== null)
|
|
54
|
+
return {
|
|
55
|
+
cause: 'terminated_externally',
|
|
56
|
+
message: `The worker was terminated from outside the server (${status})`,
|
|
57
|
+
};
|
|
58
|
+
const reported = /^worker-error: ([A-Z0-9_]{1,64})$/mu.exec(evidence.stderrTail)?.[1];
|
|
59
|
+
if (reported !== undefined)
|
|
60
|
+
return {
|
|
61
|
+
cause: 'uncaught_error',
|
|
62
|
+
message: `The worker stopped on an uncaught job error ${reported} (${status}); the retained stderr log records its diagnostic code without source text`,
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
cause: 'exited',
|
|
66
|
+
message: `The worker exited without publishing a terminal job outcome (${status})`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const MIB = 1024 * 1024;
|
|
70
|
+
const MINIMUM_DEFAULT_HEAP_MIB = 2_048;
|
|
71
|
+
const MAXIMUM_DEFAULT_HEAP_MIB = 8_192;
|
|
72
|
+
/**
|
|
73
|
+
* V8 heap ceiling for job workers when none is configured. Node's own default is capped at
|
|
74
|
+
* about 4 GiB, which a large GUI or event analysis can exceed on a machine with plenty of memory.
|
|
75
|
+
* A quarter of physical memory, within fixed bounds, keeps one worker from using the machine.
|
|
76
|
+
*/
|
|
77
|
+
export function defaultWorkerHeapMiB(physicalBytes = totalmem()) {
|
|
78
|
+
const quarter = Math.floor(physicalBytes / MIB / 4);
|
|
79
|
+
return Math.min(MAXIMUM_DEFAULT_HEAP_MIB, Math.max(MINIMUM_DEFAULT_HEAP_MIB, quarter));
|
|
80
|
+
}
|
|
@@ -184,6 +184,9 @@ export class GuiAssetCatalog {
|
|
|
184
184
|
const languageMatch = definition.languages.some((candidate) => candidate.toLocaleLowerCase('en-US') === normalizedLanguage);
|
|
185
185
|
return [
|
|
186
186
|
languageMatch ? 1 : 0,
|
|
187
|
+
// A .fnt asset supplies glyphs, not a replacement for the named font's
|
|
188
|
+
// declared colour and styling. Its mod load order must not erase those.
|
|
189
|
+
definition.kind === 'bitmapfont' ? 1 : 0,
|
|
187
190
|
sourceLoadOrder.get(definition.sourcePath) ?? 0,
|
|
188
191
|
languageMatch === definition.override ? 1 : 0,
|
|
189
192
|
];
|
|
@@ -157,7 +157,11 @@ function decodeRgb(bytes, dataOffset, width, height, bitCount, pitch, masks) {
|
|
|
157
157
|
const source = dataOffset + y * rowPitch + x * bytesPerPixel;
|
|
158
158
|
const pixel = bitCount === 32
|
|
159
159
|
? bytes.readUInt32LE(source)
|
|
160
|
-
:
|
|
160
|
+
: bitCount === 16
|
|
161
|
+
? bytes.readUInt16LE(source)
|
|
162
|
+
: byte(bytes, source) |
|
|
163
|
+
(byte(bytes, source + 1) << 8) |
|
|
164
|
+
(byte(bytes, source + 2) << 16);
|
|
161
165
|
const target = (y * width + x) * 4;
|
|
162
166
|
output[target] = extractChannel(pixel, masks[0], 0);
|
|
163
167
|
output[target + 1] = extractChannel(pixel, masks[1], 0);
|
|
@@ -231,8 +235,8 @@ export function decodeDds(bytes, budget = new RenderBudget(), operationKey = `dd
|
|
|
231
235
|
if ((flags & DDPF_RGB) === 0)
|
|
232
236
|
return unsupported('non-rgb', 'The DDS is neither RGB nor a recognised compressed format.');
|
|
233
237
|
const bitCount = bytes.readUInt32LE(88);
|
|
234
|
-
if (bitCount !== 24 && bitCount !== 32)
|
|
235
|
-
return unsupported(`rgb${bitCount}`, `RGB DDS bit depth ${bitCount} is unsupported; expected 24 or 32.`);
|
|
238
|
+
if (bitCount !== 16 && bitCount !== 24 && bitCount !== 32)
|
|
239
|
+
return unsupported(`rgb${bitCount}`, `RGB DDS bit depth ${bitCount} is unsupported; expected 16, 24 or 32.`);
|
|
236
240
|
const masks = [
|
|
237
241
|
bytes.readUInt32LE(92),
|
|
238
242
|
bytes.readUInt32LE(96),
|
|
@@ -240,7 +244,7 @@ export function decodeDds(bytes, budget = new RenderBudget(), operationKey = `dd
|
|
|
240
244
|
bytes.readUInt32LE(104),
|
|
241
245
|
];
|
|
242
246
|
const output = decodeRgb(bytes, 128, width, height, bitCount, rowPitch, masks);
|
|
243
|
-
const format = bitCount === 24 ? 'rgb24' : 'rgba32';
|
|
247
|
+
const format = bitCount === 16 ? 'rgb16' : bitCount === 24 ? 'rgb24' : 'rgba32';
|
|
244
248
|
return output === undefined
|
|
245
249
|
? unsupported(format, 'The DDS RGB pixel data is truncated.')
|
|
246
250
|
: { width, height, data: output, format };
|
|
@@ -2,6 +2,7 @@ import { compareCodeUnits, hashCanonical } from '../core/canonical.js';
|
|
|
2
2
|
import { assertRenderDimensions } from '../core/render-budget.js';
|
|
3
3
|
import { sortDiagnostics } from '../core/diagnostics.js';
|
|
4
4
|
import { ServiceError } from '../core/result.js';
|
|
5
|
+
import { nativeWindowState } from './native-state.js';
|
|
5
6
|
import { GuiAssetCatalog } from './assets.js';
|
|
6
7
|
import { GUI_GRAPH_MAX_EDGES, GUI_GRAPH_MAX_ELEMENTS, GUI_GRAPH_MAX_NODES, GUI_SCENE_MAX_DEPTH, GUI_SCENE_MAX_ELEMENTS, GUI_SCENE_MAX_TEXT_CHARACTERS, GUI_SCENE_MAX_TEXT_LAYOUT_OPERATIONS, GUI_SCENE_MAX_WORK, GUI_TEXT_MAX_CHARACTERS, GUI_TEXT_MAX_MISSING_GLYPH_SAMPLES, } from './limits.js';
|
|
7
8
|
import { guiElementAttributeFidelity } from './source-graph.js';
|
|
@@ -593,6 +594,24 @@ function anchoredCoordinate(start, span, offset, anchor) {
|
|
|
593
594
|
function legacyRowSuffix(instanceSuffix) {
|
|
594
595
|
return instanceSuffix.replace(/#list-[^#]+/gu, '');
|
|
595
596
|
}
|
|
597
|
+
/**
|
|
598
|
+
* Read a visibility value the way the game reads one: booleans, the script words yes/no and
|
|
599
|
+
* true/false, visible/hidden/shown, and the numbers 0 and 1. Anything else is not a decision.
|
|
600
|
+
*/
|
|
601
|
+
function visibilityValue(value) {
|
|
602
|
+
if (typeof value === 'boolean')
|
|
603
|
+
return value;
|
|
604
|
+
if (typeof value === 'number')
|
|
605
|
+
return value === 0 ? false : value === 1 ? true : undefined;
|
|
606
|
+
if (typeof value !== 'string')
|
|
607
|
+
return undefined;
|
|
608
|
+
const word = value.trim().toLowerCase();
|
|
609
|
+
if (['yes', 'true', '1', 'visible', 'shown', 'show'].includes(word))
|
|
610
|
+
return true;
|
|
611
|
+
if (['no', 'false', '0', 'hidden', 'hide'].includes(word))
|
|
612
|
+
return false;
|
|
613
|
+
return undefined;
|
|
614
|
+
}
|
|
596
615
|
function explicitElementVisibility(definition, scenario, rowValues) {
|
|
597
616
|
const scripted = rowValues?.[`${definition.name}.visible`] ??
|
|
598
617
|
scenario.values[`${definition.name}.visible`] ??
|
|
@@ -602,7 +621,14 @@ function explicitElementVisibility(definition, scenario, rowValues) {
|
|
|
602
621
|
(definition.sourceOwnerId === undefined
|
|
603
622
|
? undefined
|
|
604
623
|
: scenario.visibility[definition.sourceOwnerId]) ??
|
|
605
|
-
(
|
|
624
|
+
visibilityValue(scripted));
|
|
625
|
+
}
|
|
626
|
+
/** Source visibility: `visible` and `show` mean shown when true, `hide` and `hidden` when false. */
|
|
627
|
+
function declaredVisibility(attributes) {
|
|
628
|
+
const visible = scalarBoolean(property(attributes, 'visible', 'show'));
|
|
629
|
+
if (visible !== undefined)
|
|
630
|
+
return visible;
|
|
631
|
+
return !(scalarBoolean(property(attributes, 'hide', 'hidden')) ?? false);
|
|
606
632
|
}
|
|
607
633
|
function clippedByChain(chain) {
|
|
608
634
|
let result;
|
|
@@ -1124,8 +1150,7 @@ async function layoutAttachedScrollbars(definition, scrollbars, owner, childOutp
|
|
|
1124
1150
|
...(maximum === 0 ? { enabled: false } : {}),
|
|
1125
1151
|
},
|
|
1126
1152
|
};
|
|
1127
|
-
const sourceShown =
|
|
1128
|
-
!(scalarBoolean(property(bar.attributes, 'hide', 'hidden')) ?? false);
|
|
1153
|
+
const sourceShown = declaredVisibility(bar.attributes);
|
|
1129
1154
|
barLayouts.push({
|
|
1130
1155
|
definition: bar,
|
|
1131
1156
|
rect: { x: coordinate(true), y: coordinate(false), width: barWidth, height: barHeight },
|
|
@@ -1253,8 +1278,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
1253
1278
|
const parentUnresolvedVisibility = parentSceneElement?.visibilityStatus === 'unresolved'
|
|
1254
1279
|
? parentSceneElement.visibilityStatusReason
|
|
1255
1280
|
: undefined;
|
|
1256
|
-
const
|
|
1257
|
-
const sourceVisible = scalarBoolean(property(definition.attributes, 'visible')) ?? !declaredHidden;
|
|
1281
|
+
const sourceVisible = declaredVisibility(definition.attributes);
|
|
1258
1282
|
const resolveVisibleAppearance = inheritedVisible && (explicitlyVisible ?? sourceVisible);
|
|
1259
1283
|
const scriptedEnabled = rowValues?.[`${definition.name}.enabled`] ??
|
|
1260
1284
|
scenario.values[`${definition.name}.enabled`] ??
|
|
@@ -2158,13 +2182,29 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
|
|
|
2158
2182
|
addFidelity(fidelity, 'unresolved', 'scripted_gui_visibility', `${scripted.name}.visible requires more scenario facts; its source visibility remains unresolved.`);
|
|
2159
2183
|
}
|
|
2160
2184
|
}
|
|
2185
|
+
// Engine-controlled branches and icon frames come from the scenario country; scripted state,
|
|
2186
|
+
// then every explicit scenario value, outranks them.
|
|
2187
|
+
const native = nativeWindowState(windowName, new Set(graph.elements.map(({ name }) => name)), scenario, scannedFiles);
|
|
2188
|
+
if (native !== undefined) {
|
|
2189
|
+
for (const note of native.notes)
|
|
2190
|
+
addFidelity(fidelity, 'modelled', 'native_window_state', note);
|
|
2191
|
+
const { sources } = native.state;
|
|
2192
|
+
if (native.state.tag === undefined ||
|
|
2193
|
+
sources.faction === 'default' ||
|
|
2194
|
+
sources.balanceOfPower === 'default')
|
|
2195
|
+
addFidelity(fidelity, 'approximated', 'native_window_state', native.state.tag === undefined
|
|
2196
|
+
? 'The scenario names no country, so the window shows the state of a country in no faction and without a balance of power; set country.tag or the country faction, balanceOfPower and ideology facts.'
|
|
2197
|
+
: `No history for ${native.state.tag} was available, so its faction and balance of power default to none; set country.faction, country.balanceOfPower and country.ideology to override.`);
|
|
2198
|
+
}
|
|
2161
2199
|
const layoutScenario = {
|
|
2162
2200
|
...scenario,
|
|
2163
2201
|
values: {
|
|
2164
2202
|
...resolvedScriptedProperties,
|
|
2203
|
+
...(native?.values ?? {}),
|
|
2165
2204
|
...scenario.values,
|
|
2166
2205
|
},
|
|
2167
2206
|
visibility: {
|
|
2207
|
+
...(native?.visibility ?? {}),
|
|
2168
2208
|
...scriptedWindowVisibility,
|
|
2169
2209
|
...constantElementVisibility,
|
|
2170
2210
|
...scenario.visibility,
|