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.
Files changed (84) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +27 -53
  3. package/dist/hoi4_agent_tools/core/condition-evaluator.js +18 -0
  4. package/dist/hoi4_agent_tools/core/job-worker-host.js +115 -27
  5. package/dist/hoi4_agent_tools/core/job-worker.js +7 -2
  6. package/dist/hoi4_agent_tools/core/temporary-directories.js +293 -0
  7. package/dist/hoi4_agent_tools/core/worker-exit.js +80 -0
  8. package/dist/hoi4_agent_tools/gui/assets.js +3 -0
  9. package/dist/hoi4_agent_tools/gui/dds.js +8 -4
  10. package/dist/hoi4_agent_tools/gui/layout.js +45 -5
  11. package/dist/hoi4_agent_tools/gui/native-state.js +244 -0
  12. package/dist/hoi4_agent_tools/gui/renderer.js +12 -7
  13. package/dist/hoi4_agent_tools/gui/source-graph.js +17 -4
  14. package/dist/hoi4_agent_tools/gui/studio.js +33 -21
  15. package/dist/hoi4_agent_tools/mcp/prompts/probability.js +2 -2
  16. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  17. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +8 -8
  18. package/dist/hoi4_agent_tools/mcp/tools/event.js +3 -3
  19. package/dist/hoi4_agent_tools/mcp/tools/focus.js +5 -5
  20. package/dist/hoi4_agent_tools/mcp/tools/gui.js +6 -6
  21. package/dist/hoi4_agent_tools/mcp/tools/job.js +4 -4
  22. package/dist/hoi4_agent_tools/mcp/tools/map.js +4 -4
  23. package/dist/hoi4_agent_tools/mcp/tools/probability.js +4 -4
  24. package/dist/hoi4_agent_tools/mcp/tools/reference.js +12 -12
  25. package/dist/hoi4_agent_tools/mcp/tools/technology.js +3 -3
  26. package/dist/hoi4_agent_tools/runtime.js +14 -1
  27. package/dist/hoi4_agent_tools/schemas/map-requests.js +1 -1
  28. package/dist/hoi4_agent_tools/version.js +1 -1
  29. package/docs/analysis.md +2 -2
  30. package/docs/development.md +2 -0
  31. package/docs/events.md +2 -3
  32. package/docs/examples.md +8 -23
  33. package/docs/focus.md +3 -2
  34. package/docs/gui.md +7 -4
  35. package/docs/helper-expansion.md +1 -1
  36. package/docs/jobs.md +5 -2
  37. package/docs/map.md +5 -4
  38. package/docs/mechanics.md +2 -2
  39. package/docs/probability.md +5 -2
  40. package/docs/reference.md +2 -1
  41. package/docs/setup.md +2 -4
  42. package/docs/technology.md +2 -3
  43. package/docs/tools.md +12 -13
  44. package/docs/troubleshooting.md +1 -1
  45. package/npm-shrinkwrap.json +2 -2
  46. package/package.json +2 -2
  47. package/schemas/configuration.schema.json +1 -1
  48. package/schemas/continuous-focus-palette.schema.json +1 -1
  49. package/schemas/custom-weighted-pool.schema.json +1 -1
  50. package/schemas/decision-inspect-input.schema.json +1 -1
  51. package/schemas/error-log-input.schema.json +1 -1
  52. package/schemas/event-feature-manifest.schema.json +1 -1
  53. package/schemas/focus-plan.schema.json +1 -1
  54. package/schemas/focus-planning-sidecar.schema.json +1 -1
  55. package/schemas/gui-animation-source.schema.json +1 -1
  56. package/schemas/gui-helper.schema.json +1 -1
  57. package/schemas/gui-scenario.schema.json +1 -1
  58. package/schemas/helper-expansion-request.schema.json +1 -1
  59. package/schemas/helper-expansion-summary.schema.json +1 -1
  60. package/schemas/impact-inspect-input.schema.json +1 -1
  61. package/schemas/map-operation.schema.json +1 -1
  62. package/schemas/mechanic-test-input.schema.json +1 -1
  63. package/schemas/mod-index-input.schema.json +1 -1
  64. package/schemas/operation-result.schema.json +1 -1
  65. package/schemas/package-check-input.schema.json +1 -1
  66. package/schemas/package-manifest.schema.json +1 -1
  67. package/schemas/probability-analysis-result.schema.json +1 -1
  68. package/schemas/probability-compare-input.schema.json +1 -1
  69. package/schemas/probability-evaluate-input.schema.json +1 -1
  70. package/schemas/probability-inspect-input.schema.json +1 -1
  71. package/schemas/probability-render-input.schema.json +1 -1
  72. package/schemas/probability-scenario-set.schema.json +1 -1
  73. package/schemas/probability-sequence-input.schema.json +1 -1
  74. package/schemas/probability-simulate-input.schema.json +1 -1
  75. package/schemas/probability-sweep-input.schema.json +1 -1
  76. package/schemas/reference-context-input.schema.json +1 -1
  77. package/schemas/reference-read-input.schema.json +1 -1
  78. package/schemas/reference-search-input.schema.json +1 -1
  79. package/schemas/scenario-suite.schema.json +1 -1
  80. package/schemas/scenario-test-input.schema.json +1 -1
  81. package/schemas/script-validate-input.schema.json +1 -1
  82. package/schemas/shared-scenario.schema.json +1 -1
  83. package/schemas/source-lookup-input.schema.json +1 -1
  84. 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
- : byte(bytes, source) | (byte(bytes, source + 1) << 8) | (byte(bytes, source + 2) << 16);
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
- (typeof scripted === 'boolean' ? scripted : undefined));
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 = scalarBoolean(property(bar.attributes, 'visible')) ??
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 declaredHidden = scalarBoolean(property(definition.attributes, 'hide', 'hidden')) ?? false;
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,