hoi4-agent-tools 2.0.0 → 2.1.0

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 (37) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/README.md +4 -3
  3. package/dist/hoi4_agent_tools/core/artifacts.js +109 -4
  4. package/dist/hoi4_agent_tools/core/diagnostic-collector.js +28 -8
  5. package/dist/hoi4_agent_tools/core/diff.js +162 -4
  6. package/dist/hoi4_agent_tools/core/engine.js +2 -0
  7. package/dist/hoi4_agent_tools/core/index.js +17 -6
  8. package/dist/hoi4_agent_tools/core/render-budget.js +5 -3
  9. package/dist/hoi4_agent_tools/core/scanner.js +3 -2
  10. package/dist/hoi4_agent_tools/core/source/limits.js +4 -1
  11. package/dist/hoi4_agent_tools/event/graph.js +28 -6
  12. package/dist/hoi4_agent_tools/event/limits.js +1 -1
  13. package/dist/hoi4_agent_tools/event/service.js +103 -7
  14. package/dist/hoi4_agent_tools/focus/continuous-render.js +17 -8
  15. package/dist/hoi4_agent_tools/focus/presentation.js +96 -41
  16. package/dist/hoi4_agent_tools/focus/render.js +27 -16
  17. package/dist/hoi4_agent_tools/gui/assets.js +9 -2
  18. package/dist/hoi4_agent_tools/gui/inspection-artifact.js +99 -0
  19. package/dist/hoi4_agent_tools/gui/layout.js +7 -3
  20. package/dist/hoi4_agent_tools/gui/source-graph.js +61 -28
  21. package/dist/hoi4_agent_tools/gui/studio.js +23 -8
  22. package/dist/hoi4_agent_tools/map/diagnostic-limit.js +90 -9
  23. package/dist/hoi4_agent_tools/map/model.js +22 -4
  24. package/dist/hoi4_agent_tools/map/service.js +4 -3
  25. package/dist/hoi4_agent_tools/map/validation.js +3 -2
  26. package/dist/hoi4_agent_tools/mcp/server/create.js +1 -1
  27. package/dist/hoi4_agent_tools/mcp/server/output-schemas.js +1 -1
  28. package/dist/hoi4_agent_tools/mcp/tools/focus.js +145 -223
  29. package/dist/hoi4_agent_tools/mcp/tools/gui.js +29 -22
  30. package/dist/hoi4_agent_tools/version.js +1 -1
  31. package/docs/events.md +4 -5
  32. package/docs/focus.md +5 -4
  33. package/docs/gui.md +4 -2
  34. package/docs/map.md +1 -1
  35. package/docs/setup.md +1 -1
  36. package/package.json +1 -1
  37. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,11 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0 - 2026-07-18
4
+
5
+ - Added `hoi4.focus_raster` for decoded focus icons and deterministic PNG reviews while keeping inspection, vector rendering, and rewrites responsive on very large trees.
6
+ - Added public MCP regressions that create, compact, inspect, render, and rasterize 1,024-focus trees, including a separate 1,024-distinct-icon raster workload.
7
+ - Replaced the quadratic large-source diff ceiling with a deterministic linear-memory patience diff so large rewrites still return exact review artifacts.
8
+ - Raised distinct raster-operation capacity for large icon sets and deduplicated GUI sprite-frame raster work by texture and frame.
9
+ - Separated full localisation inventories from connected map topology limits and raised the shared index capacity for installed-game scale.
10
+ - Added automatic generated-artifact retention so long-running agent workflows reclaim older artifacts instead of stopping at the storage ceiling.
11
+ - Made broad GUI inspection index only connected localisation, reuse decoded textures, and return bounded workspace projections for very large source graphs.
12
+ - Made broad event scans structural by default, with helper expansion in focused queries and compact indexed resources for very large graphs.
13
+ - Suppressed unrelated vanilla parser noise while retaining mod and dependency diagnostics that affect the requested content.
14
+ - Added regressions for 500,100 localisation records, 1,024 distinct focus textures, large GUI and event graphs, raster discovery, package installation, and agent workflows.
15
+
3
16
  ## 2.0.0 - 2026-07-15
4
17
 
5
18
  - Removed the workspace inventory tool and made the current mod implicit for local MCP calls.
6
19
  - Starting the server inside any mod now works without a config file or per-mod setup command.
7
20
  - Updated the published client examples, Registry metadata, and installation verification for the twelve-tool surface.
8
- - Rendered unresolved scripted localisation values as `[X]` and applied supported HOI4 `§` text colours in offline GUI previews.
21
+ - Distinguished unresolved numeric values (`[X]`) from unresolved text-returning dynamic localisation (`[dynamic_loc]`) and applied supported HOI4 `§` text colours in offline GUI previews.
9
22
 
10
23
  ## 1.2.0 - 2026-07-13
11
24
 
package/README.md CHANGED
@@ -14,7 +14,7 @@ HOI4 Agent Tools is an MCP server for coding agents to analyze Hearts of Iron IV
14
14
  Requires Node.js 22 or 24.
15
15
 
16
16
  ```bash
17
- npm install --global hoi4-agent-tools@2.0.0
17
+ npm install --global hoi4-agent-tools@latest
18
18
  ```
19
19
 
20
20
  Start the MCP with its working directory set to the mod you are editing. No config or per-mod registration is required: the server finds that mod automatically. Run `hoi4-agent-tools-setup --init` only when you want persistent discovery for several mod roots, a custom game path, or a remote deployment; see [Setup](docs/setup.md).
@@ -32,7 +32,8 @@ Paste the printed Codex or generic global-install entry into your MCP client, th
32
32
  | Tool | Purpose |
33
33
  | -------------------- | --------------------------------------------------------------------------------------- |
34
34
  | `hoi4.focus_inspect` | Read focus trees and report structural or reference problems. |
35
- | `hoi4.focus_render` | Render an existing focus tree or continuous palette. |
35
+ | `hoi4.focus_render` | Produce fast HTML, SVG, JSON, and source-linked layout artifacts. |
36
+ | `hoi4.focus_raster` | Produce a high-fidelity PNG review with decoded source icons. |
36
37
  | `hoi4.focus_rewrite` | Create or update a focus tree. |
37
38
  | `hoi4.gui_inspect` | Read a scripted GUI and its linked assets and logic. |
38
39
  | `hoi4.gui_render` | Render GUI states and resolutions offline. |
@@ -54,7 +55,7 @@ Connecting and listing tools does not scan mod source. Compact tool schemas and
54
55
 
55
56
  ## Create or clean content
56
57
 
57
- Ask your agent in normal task language. A typical workflow is inspect, render, rewrite, then inspect the result.
58
+ Ask your agent in normal task language. A typical workflow is inspect, render, rewrite, then inspect the result. The agent can call a raster tool when a pixel review is useful without paying that cost during every structural operation.
58
59
 
59
60
  - Focus trees: "Create a complete national focus tree for this route specification," or "Compact this existing tree into a balanced, readable layout." Existing trees can use a plan-free compact reflow; new trees use a complete plan. See [Focus trees](docs/focus.md).
60
61
  - Scripted GUIs: "Create a scripted GUI for this mechanic," or "Render this window at common resolutions and fix clipping, missing assets, and click-region conflicts." See [Scripted GUIs](docs/gui.md).
@@ -462,6 +462,114 @@ export class ArtifactStore {
462
462
  this.maxEntries = maxEntries;
463
463
  this.maxSingleBytes = maxSingleBytes;
464
464
  }
465
+ async pruneForAdmission(workspace, usage, additionalBytes, additionalEntries, protectedManifests, protectedTargets, signal) {
466
+ if (usage.entries + additionalEntries <= this.maxEntries &&
467
+ usage.bytes <= this.maxBytes - additionalBytes) {
468
+ return usage;
469
+ }
470
+ // Reclaim to a low-water mark so a busy agent workflow does not rescan and evict one artifact
471
+ // for every subsequent tool call. The incoming batch itself remains protected and atomic.
472
+ const targetBytes = Math.max(0, Math.min(this.maxBytes - additionalBytes, Math.floor(this.maxBytes * 0.75)));
473
+ const targetEntries = Math.max(0, Math.min(this.maxEntries - additionalEntries, Math.floor(this.maxEntries * 0.75)));
474
+ const retained = [];
475
+ const walk = async (directory) => {
476
+ signal?.throwIfAborted();
477
+ let children;
478
+ try {
479
+ children = await readdir(directory, { withFileTypes: true });
480
+ }
481
+ catch (error) {
482
+ if (error.code === 'ENOENT')
483
+ return;
484
+ throw error;
485
+ }
486
+ for (const child of children) {
487
+ signal?.throwIfAborted();
488
+ if (child.isSymbolicLink()) {
489
+ throw new ServiceError('ARTIFACT_STORAGE_UNSAFE', 'Artifact storage contains a symbolic link or junction');
490
+ }
491
+ const candidate = await containedGeneratedPath(workspace.artifactRoot, path.relative(workspace.artifactRoot, path.join(directory, child.name)));
492
+ if (child.isDirectory()) {
493
+ await walk(candidate);
494
+ continue;
495
+ }
496
+ if (!child.isFile() || !child.name.endsWith('.manifest.json'))
497
+ continue;
498
+ const metadata = await lstat(candidate);
499
+ const manifest = await readArtifactManifest(candidate, signal);
500
+ if (child.name !== `${manifest.name}.${manifest.provenanceHash}.manifest.json`) {
501
+ throw new ServiceError('ARTIFACT_MANIFEST_INTEGRITY_FAILED', 'Artifact provenance manifest filename does not match its contents');
502
+ }
503
+ assertManifestIntegrity(manifest, {
504
+ sha256: path.basename(directory),
505
+ provenanceHash: manifest.provenanceHash,
506
+ name: manifest.name,
507
+ }, workspace);
508
+ const targetPath = await containedGeneratedPath(workspace.artifactRoot, path.relative(workspace.artifactRoot, path.join(directory, manifest.name)));
509
+ let targetBytes = 0;
510
+ try {
511
+ targetBytes = (await stat(targetPath)).size;
512
+ }
513
+ catch (error) {
514
+ if (error.code !== 'ENOENT')
515
+ throw error;
516
+ }
517
+ retained.push({
518
+ manifest,
519
+ manifestPath: candidate,
520
+ manifestBytes: metadata.size,
521
+ modifiedAt: metadata.mtimeMs,
522
+ targetPath,
523
+ targetBytes,
524
+ });
525
+ }
526
+ };
527
+ await walk(await containedGeneratedPath(workspace.artifactRoot));
528
+ retained.sort((left, right) => left.modifiedAt - right.modifiedAt ||
529
+ compareCodeUnits(left.manifestPath, right.manifestPath));
530
+ const current = { ...usage };
531
+ for (const candidate of retained) {
532
+ signal?.throwIfAborted();
533
+ if (current.bytes <= targetBytes && current.entries <= targetEntries)
534
+ break;
535
+ if (protectedManifests.has(candidate.manifestPath))
536
+ continue;
537
+ try {
538
+ await unlink(candidate.manifestPath);
539
+ }
540
+ catch (error) {
541
+ if (error.code === 'ENOENT')
542
+ continue;
543
+ throw error;
544
+ }
545
+ current.entries = Math.max(0, current.entries - 1);
546
+ current.bytes = Math.max(0, current.bytes - candidate.manifestBytes);
547
+ if (protectedTargets.has(candidate.targetPath))
548
+ continue;
549
+ const prefix = `${candidate.manifest.name}.`;
550
+ const hasManifest = (await readdir(path.dirname(candidate.targetPath), { withFileTypes: true })).some((entry) => entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith('.manifest.json'));
551
+ if (hasManifest)
552
+ continue;
553
+ try {
554
+ await unlink(candidate.targetPath);
555
+ current.bytes = Math.max(0, current.bytes - candidate.targetBytes);
556
+ }
557
+ catch (error) {
558
+ if (error.code !== 'ENOENT')
559
+ throw error;
560
+ }
561
+ const verificationPrefix = `${workspace.workspaceIdentity}\0${workspace.ownerIdentity}\0${candidate.targetPath}\0`;
562
+ for (const key of this.#verifiedContent.keys()) {
563
+ if (key.startsWith(verificationPrefix))
564
+ this.#verifiedContent.delete(key);
565
+ }
566
+ }
567
+ if (current.entries + additionalEntries > this.maxEntries ||
568
+ current.bytes > this.maxBytes - additionalBytes) {
569
+ throw new ServiceError('ARTIFACT_STORAGE_LIMIT', 'Artifact batch cannot fit after reclaiming expired artifacts');
570
+ }
571
+ return current;
572
+ }
465
573
  async list(workspace, signal) {
466
574
  signal?.throwIfAborted();
467
575
  const usage = await artifactUsage(workspace.artifactRoot, signal);
@@ -902,10 +1010,7 @@ export class ArtifactStore {
902
1010
  }
903
1011
  const usage = await artifactUsage(workspace.artifactRoot, signal);
904
1012
  const additionalEntries = [...uniqueManifests.values()].filter((artifact) => !artifact.exists).length;
905
- if (usage.entries + additionalEntries > this.maxEntries ||
906
- usage.bytes > this.maxBytes - additionalBytes) {
907
- throw new ServiceError('ARTIFACT_STORAGE_LIMIT', 'Artifact storage retention limit has been reached');
908
- }
1013
+ await this.pruneForAdmission(workspace, usage, additionalBytes, additionalEntries, new Set(uniqueManifests.keys()), new Set(uniqueTargets.keys()), signal);
909
1014
  const createdTargets = [];
910
1015
  const createdManifests = [];
911
1016
  try {
@@ -4,6 +4,8 @@ export class DiagnosticCollector {
4
4
  truncation;
5
5
  items = [];
6
6
  dropped = 0;
7
+ droppedBySeverity = {};
8
+ droppedByCode = {};
7
9
  constructor(maximum, truncation) {
8
10
  this.maximum = maximum;
9
11
  this.truncation = truncation;
@@ -17,27 +19,45 @@ export class DiagnosticCollector {
17
19
  if (this.items.length < Math.max(0, this.maximum - 1))
18
20
  this.items.push(diagnostic);
19
21
  else {
20
- this.dropped += 1;
21
- if (diagnostic.severity === 'blocker' &&
22
- !this.items.some(({ code, severity }) => code === diagnostic.code && severity === 'blocker')) {
23
- const replacement = this.items.findLastIndex(({ severity }) => severity !== 'blocker');
24
- if (replacement >= 0)
25
- this.items[replacement] = diagnostic;
22
+ const hard = diagnostic.severity === 'error' || diagnostic.severity === 'blocker';
23
+ const replacement = hard
24
+ ? this.items.findLastIndex(({ severity }) => severity !== 'error' && severity !== 'blocker')
25
+ : -1;
26
+ if (replacement >= 0) {
27
+ const replaced = this.items[replacement];
28
+ this.items[replacement] = diagnostic;
29
+ this.recordDropped(replaced);
30
+ }
31
+ else {
32
+ this.recordDropped(diagnostic);
26
33
  }
27
34
  }
28
35
  }
29
36
  }
37
+ recordDropped(diagnostic) {
38
+ this.dropped += 1;
39
+ this.droppedBySeverity[diagnostic.severity] =
40
+ (this.droppedBySeverity[diagnostic.severity] ?? 0) + 1;
41
+ this.droppedByCode[diagnostic.code] = (this.droppedByCode[diagnostic.code] ?? 0) + 1;
42
+ }
30
43
  values() {
31
44
  if (this.dropped === 0)
32
45
  return [...this.items];
46
+ const hardDropped = (this.droppedBySeverity.error ?? 0) + (this.droppedBySeverity.blocker ?? 0) > 0;
33
47
  return [
34
48
  ...this.items,
35
49
  {
36
50
  code: this.truncation.code,
37
- severity: 'blocker',
51
+ severity: hardDropped ? 'blocker' : 'info',
38
52
  category: this.truncation.category,
39
53
  message: this.truncation.message,
40
- details: { retained: this.items.length, dropped: this.dropped, maximum: this.maximum },
54
+ details: {
55
+ retained: this.items.length,
56
+ dropped: this.dropped,
57
+ maximum: this.maximum,
58
+ droppedBySeverity: this.droppedBySeverity,
59
+ droppedByCode: this.droppedByCode,
60
+ },
41
61
  },
42
62
  ];
43
63
  }
@@ -25,9 +25,13 @@ function lcsDiff(oldLines, newLines, options) {
25
25
  const columns = newLines.length + 1;
26
26
  const cells = (oldLines.length + 1) * columns;
27
27
  const maxMatrixCells = options.maxMatrixCells ?? defaultMaxMatrixCells;
28
- if (!Number.isSafeInteger(cells) || cells > maxMatrixCells) {
29
- throw new ServiceError('DIFF_COMPLEXITY_LIMIT', 'Exact source diff exceeds the configured deterministic memory bound', { oldLines: oldLines.length, newLines: newLines.length, cells, maxMatrixCells });
30
- }
28
+ if (!Number.isSafeInteger(cells) || cells > maxMatrixCells)
29
+ return patienceDiff(oldLines, newLines, maxMatrixCells, options.signal);
30
+ return matrixDiff(oldLines, newLines, options);
31
+ }
32
+ function matrixDiff(oldLines, newLines, options) {
33
+ const columns = newLines.length + 1;
34
+ const cells = (oldLines.length + 1) * columns;
31
35
  const table = new Uint32Array(cells);
32
36
  for (let oldIndex = oldLines.length - 1; oldIndex >= 0; oldIndex -= 1) {
33
37
  if ((oldIndex & 0x3f) === 0)
@@ -68,6 +72,161 @@ function lcsDiff(oldLines, newLines, options) {
68
72
  }
69
73
  return result;
70
74
  }
75
+ function uniqueLineIndexes(lines, start, end) {
76
+ const indexes = new Map();
77
+ const repeated = new Set();
78
+ for (let index = start; index < end; index += 1) {
79
+ const line = lines[index];
80
+ if (repeated.has(line))
81
+ continue;
82
+ if (indexes.has(line)) {
83
+ indexes.delete(line);
84
+ repeated.add(line);
85
+ }
86
+ else
87
+ indexes.set(line, index);
88
+ }
89
+ return indexes;
90
+ }
91
+ function patienceAnchors(oldLines, newLines, range, signal) {
92
+ const oldUnique = uniqueLineIndexes(oldLines, range.oldStart, range.oldEnd);
93
+ const newUnique = uniqueLineIndexes(newLines, range.newStart, range.newEnd);
94
+ const candidates = [];
95
+ let visited = 0;
96
+ for (const [line, oldIndex] of oldUnique) {
97
+ if ((visited & 0x3ff) === 0)
98
+ signal?.throwIfAborted();
99
+ visited += 1;
100
+ const newIndex = newUnique.get(line);
101
+ if (newIndex !== undefined)
102
+ candidates.push({ oldIndex, newIndex });
103
+ }
104
+ candidates.sort((left, right) => left.oldIndex - right.oldIndex);
105
+ if (candidates.length <= 1)
106
+ return candidates;
107
+ const tails = [];
108
+ const previous = new Int32Array(candidates.length);
109
+ previous.fill(-1);
110
+ for (let index = 0; index < candidates.length; index += 1) {
111
+ if ((index & 0x3ff) === 0)
112
+ signal?.throwIfAborted();
113
+ const newIndex = candidates[index].newIndex;
114
+ let low = 0;
115
+ let high = tails.length;
116
+ while (low < high) {
117
+ const middle = (low + high) >>> 1;
118
+ if (candidates[tails[middle]].newIndex < newIndex)
119
+ low = middle + 1;
120
+ else
121
+ high = middle;
122
+ }
123
+ if (low > 0)
124
+ previous[index] = tails[low - 1];
125
+ tails[low] = index;
126
+ }
127
+ const anchors = new Array(tails.length);
128
+ let current = tails.at(-1);
129
+ for (let index = anchors.length - 1; index >= 0; index -= 1) {
130
+ anchors[index] = candidates[current];
131
+ current = previous[current];
132
+ }
133
+ return anchors;
134
+ }
135
+ function patienceDiff(oldLines, newLines, matrixThreshold, signal) {
136
+ const result = [];
137
+ const tasks = [
138
+ {
139
+ kind: 'range',
140
+ oldStart: 0,
141
+ oldEnd: oldLines.length,
142
+ newStart: 0,
143
+ newEnd: newLines.length,
144
+ },
145
+ ];
146
+ let iterations = 0;
147
+ while (tasks.length > 0) {
148
+ if ((iterations & 0xff) === 0)
149
+ signal?.throwIfAborted();
150
+ iterations += 1;
151
+ const task = tasks.pop();
152
+ if (task.kind !== 'range') {
153
+ const lines = task.kind === 'add' ? newLines : oldLines;
154
+ const prefix = task.kind === 'same' ? ' ' : task.kind === 'add' ? '+' : '-';
155
+ for (let index = task.start; index < task.end; index += 1)
156
+ result.push(`${prefix}${lineContent(lines[index])}`);
157
+ continue;
158
+ }
159
+ let { oldStart, oldEnd, newStart, newEnd } = task;
160
+ while (oldStart < oldEnd && newStart < newEnd && oldLines[oldStart] === newLines[newStart]) {
161
+ result.push(` ${lineContent(oldLines[oldStart])}`);
162
+ oldStart += 1;
163
+ newStart += 1;
164
+ }
165
+ let commonSuffix = 0;
166
+ while (oldStart < oldEnd - commonSuffix &&
167
+ newStart < newEnd - commonSuffix &&
168
+ oldLines[oldEnd - commonSuffix - 1] === newLines[newEnd - commonSuffix - 1])
169
+ commonSuffix += 1;
170
+ oldEnd -= commonSuffix;
171
+ newEnd -= commonSuffix;
172
+ if (commonSuffix > 0)
173
+ tasks.push({ kind: 'same', start: oldEnd, end: oldEnd + commonSuffix });
174
+ if (oldStart === oldEnd) {
175
+ if (newStart < newEnd)
176
+ tasks.push({ kind: 'add', start: newStart, end: newEnd });
177
+ continue;
178
+ }
179
+ if (newStart === newEnd) {
180
+ tasks.push({ kind: 'delete', start: oldStart, end: oldEnd });
181
+ continue;
182
+ }
183
+ const rangeCells = (oldEnd - oldStart + 1) * (newEnd - newStart + 1);
184
+ if (Number.isSafeInteger(rangeCells) && rangeCells <= matrixThreshold) {
185
+ const rows = matrixDiff(oldLines.slice(oldStart, oldEnd), newLines.slice(newStart, newEnd), {
186
+ maxMatrixCells: matrixThreshold,
187
+ ...(signal === undefined ? {} : { signal }),
188
+ });
189
+ result.push(...rows);
190
+ continue;
191
+ }
192
+ const range = { kind: 'range', oldStart, oldEnd, newStart, newEnd };
193
+ const anchors = patienceAnchors(oldLines, newLines, range, signal);
194
+ if (anchors.length === 0) {
195
+ tasks.push({ kind: 'add', start: newStart, end: newEnd });
196
+ tasks.push({ kind: 'delete', start: oldStart, end: oldEnd });
197
+ continue;
198
+ }
199
+ const ordered = [];
200
+ let previousOld = oldStart;
201
+ let previousNew = newStart;
202
+ for (const anchor of anchors) {
203
+ if (previousOld < anchor.oldIndex || previousNew < anchor.newIndex) {
204
+ ordered.push({
205
+ kind: 'range',
206
+ oldStart: previousOld,
207
+ oldEnd: anchor.oldIndex,
208
+ newStart: previousNew,
209
+ newEnd: anchor.newIndex,
210
+ });
211
+ }
212
+ ordered.push({ kind: 'same', start: anchor.oldIndex, end: anchor.oldIndex + 1 });
213
+ previousOld = anchor.oldIndex + 1;
214
+ previousNew = anchor.newIndex + 1;
215
+ }
216
+ if (previousOld < oldEnd || previousNew < newEnd) {
217
+ ordered.push({
218
+ kind: 'range',
219
+ oldStart: previousOld,
220
+ oldEnd,
221
+ newStart: previousNew,
222
+ newEnd,
223
+ });
224
+ }
225
+ for (let index = ordered.length - 1; index >= 0; index -= 1)
226
+ tasks.push(ordered[index]);
227
+ }
228
+ return result;
229
+ }
71
230
  export function binaryDiff(oldBytes, newBytes) {
72
231
  const length = Math.max(oldBytes.length, newBytes.length);
73
232
  let changedBytes = 0;
@@ -89,4 +248,3 @@ export function binaryDiff(oldBytes, newBytes) {
89
248
  : { firstChangedOffset, lastChangedOffset: lastChangedOffset }),
90
249
  };
91
250
  }
92
- import { ServiceError } from './result.js';
@@ -119,6 +119,7 @@ export class CoreEngine {
119
119
  const requestKey = `${workspaceId}:${generation}:${hashCanonical({
120
120
  patterns: [...patterns].sort(),
121
121
  ignore: [...(ignore ?? [])].sort(),
122
+ rootKinds: [...(options.rootKinds ?? [])].sort(),
122
123
  maxFiles: options.maxFiles ?? this.resolver.config().scanMaxFiles,
123
124
  maxBytes: options.maxBytes ?? this.resolver.config().scanMaxBytes,
124
125
  })}`;
@@ -130,6 +131,7 @@ export class CoreEngine {
130
131
  const files = await this.scanner.scan(workspace, {
131
132
  patterns,
132
133
  ...(ignore === undefined ? {} : { ignore }),
134
+ ...(options.rootKinds === undefined ? {} : { rootKinds: options.rootKinds }),
133
135
  ...(options.maxFiles === undefined ? {} : { maxFiles: options.maxFiles }),
134
136
  ...(options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }),
135
137
  signal: controller.signal,
@@ -39,7 +39,7 @@ const decisionCategoryFields = new Set([
39
39
  // before the higher-precedence mod roots are reached. Keep the inventory
40
40
  // bounded, but leave enough headroom for the supported game build and a large
41
41
  // external workspace so valid mod symbols are never discarded behind vanilla.
42
- const INDEX_RECORD_LIMIT = 500_000;
42
+ const INDEX_RECORD_LIMIT = 2_000_000;
43
43
  const INDEX_DIAGNOSTIC_LIMIT = 10_000;
44
44
  const INDEX_RELATED_LOCATION_LIMIT = 100;
45
45
  const INDEX_TABLE_RECORD_LIMIT = 100_000;
@@ -125,6 +125,9 @@ function normalizedRelative(value) {
125
125
  function sourceIdentity(file) {
126
126
  return `${file.rootKind}:${file.loadOrder}`;
127
127
  }
128
+ function isWorkspaceOwnedSource(file) {
129
+ return file?.rootKind === 'mod' || file?.rootKind === 'fixture';
130
+ }
128
131
  function selectedDefinitionFiles(files) {
129
132
  const defaults = files.filter(({ relativePath }) => path.posix.basename(normalizedRelative(relativePath)) === 'default.map');
130
133
  const directories = [
@@ -257,11 +260,15 @@ export class SymbolIndex {
257
260
  recordSkippedSource(file, diagnostics) {
258
261
  if (file.shadowedBy !== undefined || this.#skippedSourcePaths.has(file.displayPath))
259
262
  return;
263
+ const candidates = possibleSymbolKinds(file, this.#definitionFiles);
264
+ // Files outside indexed HOI4 domains may still match broad text globs. A parsing ceiling in one
265
+ // of those files does not make the symbol inventory partial.
266
+ if (candidates.length === 0)
267
+ return;
260
268
  this.#skippedSourcePaths.add(file.displayPath);
261
269
  this.#complete = false;
262
270
  this.#skippedSourceCount += 1;
263
271
  const reasonCodes = [...new Set(diagnostics.map(({ code }) => code))].sort((left, right) => compareCodeUnits(left, right));
264
- const candidates = possibleSymbolKinds(file, this.#definitionFiles);
265
272
  for (const kind of candidates)
266
273
  this.#skippedPossibleSymbolKinds.add(kind);
267
274
  if (this.skippedSources.length >= INDEX_SKIPPED_SOURCE_SAMPLE_LIMIT)
@@ -361,7 +368,8 @@ export class SymbolIndex {
361
368
  this.recordSkippedSource(file, limitDiagnostics);
362
369
  return;
363
370
  }
364
- this.addDiagnostics(document.diagnostics);
371
+ if (isWorkspaceOwnedSource(file))
372
+ this.addDiagnostics(document.diagnostics);
365
373
  for (const entry of document.entries) {
366
374
  this.addSymbol({
367
375
  kind: 'localisation',
@@ -401,7 +409,8 @@ export class SymbolIndex {
401
409
  this.recordSkippedSource(file, limitDiagnostics);
402
410
  return;
403
411
  }
404
- this.addDiagnostics(document.diagnostics);
412
+ if (isWorkspaceOwnedSource(file))
413
+ this.addDiagnostics(document.diagnostics);
405
414
  this.walkBlock(document, document.root, file, []);
406
415
  if (this.#currentFileShadowed) {
407
416
  this.diagnostics.splice(diagnosticStart);
@@ -932,7 +941,9 @@ export class SymbolIndex {
932
941
  candidate.overridden = additiveCategory ? candidate.sourceShadowed : candidate !== active;
933
942
  }
934
943
  const sameLevel = candidates.filter(({ loadOrder }) => loadOrder === active.loadOrder);
935
- if (!additiveCategory && sameLevel.length > 1) {
944
+ if (!additiveCategory &&
945
+ sameLevel.length > 1 &&
946
+ sameLevel.some((candidate) => isWorkspaceOwnedSource(this.files.get(candidate.path)))) {
936
947
  this.addDiagnostic({
937
948
  code: 'INDEX_SYMBOL_COLLISION',
938
949
  severity: 'error',
@@ -945,7 +956,7 @@ export class SymbolIndex {
945
956
  });
946
957
  }
947
958
  }
948
- for (const reference of this.unresolvedReferences()) {
959
+ for (const reference of this.unresolvedReferences().filter((candidate) => isWorkspaceOwnedSource(this.files.get(candidate.path)))) {
949
960
  const mayResolveFromSkippedSource = this.#skippedPossibleSymbolKinds.has(reference.toKind);
950
961
  this.addDiagnostic({
951
962
  code: mayResolveFromSkippedSource
@@ -3,7 +3,7 @@ import { ServiceError } from './result.js';
3
3
  * Fixed process-safety ceilings for decoded rasters and offline render artifacts.
4
4
  *
5
5
  * The per-artifact ceiling permits the vanilla 5632x2048 map at scale 2 and the
6
- * committed 255-focus benchmark. Aggregate reservations account for every
6
+ * committed 1,024-focus benchmarks. Aggregate reservations account for every
7
7
  * variant in one service request and cap their combined RGBA-equivalent area.
8
8
  */
9
9
  export const RENDER_MAX_DIMENSION = 16_384;
@@ -14,8 +14,10 @@ export const RENDER_MAX_DECODED_PIXELS = 16_777_216;
14
14
  export const RENDER_MAX_ENCODED_IMAGE_BYTES = 33_554_432;
15
15
  // 64 Mi pixels preserves one vanilla scale-1 before/proposed/diff map request.
16
16
  export const RENDER_MAX_AGGREGATE_PIXELS = 67_108_864;
17
- // A selected scene may reuse assets freely, but cannot trigger graph-scale decoder work.
18
- export const RENDER_MAX_DISTINCT_RASTER_OPERATIONS = 512;
17
+ // Large focus and GUI surfaces can legitimately contain hundreds of distinct textures and frames.
18
+ // The aggregate pixel budget remains the primary memory bound while this ceiling prevents runaway
19
+ // decoder fan-out from malformed graphs.
20
+ export const RENDER_MAX_DISTINCT_RASTER_OPERATIONS = 4_096;
19
21
  /** Return a deterministic violation without allocating or throwing. */
20
22
  export function renderDimensionViolation(width, height, label = 'render artifact', limits = {}) {
21
23
  const maximumDimension = Math.min(RENDER_MAX_DIMENSION, limits.maximumDimension ?? RENDER_MAX_DIMENSION);
@@ -43,10 +43,11 @@ export class WorkspaceScanner {
43
43
  throw new ServiceError('SCAN_LIMIT_EXCEEDS_POLICY', 'Requested scan limits exceed the configured server ceiling');
44
44
  }
45
45
  const roots = workspace.roots
46
- .filter((root) => root.kind === 'game' ||
46
+ .filter((root) => (root.kind === 'game' ||
47
47
  root.kind === 'dependency' ||
48
48
  root.kind === 'mod' ||
49
- root.kind === 'fixture')
49
+ root.kind === 'fixture') &&
50
+ (options.rootKinds === undefined || options.rootKinds.includes(root.kind)))
50
51
  .sort((a, b) => a.loadOrder - b.loadOrder || compareCodeUnits(a.path, b.path));
51
52
  const result = [];
52
53
  let totalBytes = 0;
@@ -1,7 +1,10 @@
1
1
  export const SOURCE_DIAGNOSTIC_LIMIT = 100;
2
2
  export const SOURCE_MAX_NESTING = 256;
3
3
  export const SOURCE_MAX_BYTES = 8_388_608;
4
- export const SOURCE_TOKEN_LIMIT = 250_000;
4
+ // Large authored HOI4 focus and scripted-effect files can exceed 250k tokens while remaining well
5
+ // below the byte and nesting ceilings. Keep enough room for those files without allowing an
6
+ // unbounded token inventory.
7
+ export const SOURCE_TOKEN_LIMIT = 1_000_000;
5
8
  export const SOURCE_ENTRY_LIMIT = 100_000;
6
9
  export const SOURCE_LINE_LIMIT = 250_000;
7
10
  /**
@@ -451,6 +451,26 @@ function helperProjections(graph, budget) {
451
451
  if (projectedKeys.has(projectionKey))
452
452
  continue;
453
453
  projectedKeys.add(projectionKey);
454
+ if (projected.length >= EVENT_GRAPH_MAX_HELPER_PROJECTIONS) {
455
+ addIssue(graph.issues, {
456
+ code: 'EVENT_HELPER_PROJECTION_LIMIT',
457
+ classification: 'unresolved_analysis',
458
+ severity: 'blocker',
459
+ message: 'Collapsed helper projection reached its materialization ceiling; structural helper calls remain available.',
460
+ confidence: 'unresolved',
461
+ location: start.location,
462
+ blockers: [
463
+ {
464
+ code: 'HELPER_PROJECTION_LIMIT',
465
+ message: 'Use a bounded helper-expanded trace for paths omitted from the collapsed workspace graph.',
466
+ location: start.location,
467
+ details: { maximum: EVENT_GRAPH_MAX_HELPER_PROJECTIONS },
468
+ },
469
+ ],
470
+ subjectIds: [start.from, start.to],
471
+ });
472
+ return stableUnique(projected);
473
+ }
454
474
  projected.push({
455
475
  id: `edge:helper-projection:${hashCanonical({
456
476
  from: start.from,
@@ -480,9 +500,6 @@ function helperProjections(graph, budget) {
480
500
  terminalDispatchLocation: locationKey(final.location),
481
501
  },
482
502
  });
483
- if (projected.length > EVENT_GRAPH_MAX_HELPER_PROJECTIONS) {
484
- throw new ServiceError('EVENT_HELPER_PROJECTION_LIMIT', 'Event-chain helper expansion exceeds the fixed projection ceiling', { maximum: EVENT_GRAPH_MAX_HELPER_PROJECTIONS });
485
- }
486
503
  }
487
504
  }
488
505
  }
@@ -1523,7 +1540,8 @@ export function buildEventGraph(snapshot, options = {}) {
1523
1540
  const fragments = analyzeFragments(snapshot, files, catalog, options, budget);
1524
1541
  const graph = mergeFragments(fragments);
1525
1542
  const partial = partialInventoryEvidence(snapshot);
1526
- graph.unresolved.push(...partial.unresolved);
1543
+ for (const unresolved of partial.unresolved)
1544
+ graph.unresolved.push(unresolved);
1527
1545
  assertGraphLimit(graph.nodes.length, EVENT_GRAPH_MAX_NODES, 'EVENT_NODE_LIMIT', 'node count');
1528
1546
  assertGraphLimit(graph.edges.length, EVENT_GRAPH_MAX_EDGES, 'EVENT_EDGE_LIMIT', 'edge count');
1529
1547
  assertGraphLimit(graph.stateAccesses.length, EVENT_GRAPH_MAX_STATE_ACCESSES, 'EVENT_STATE_ACCESS_LIMIT', 'state-access count');
@@ -1531,8 +1549,12 @@ export function buildEventGraph(snapshot, options = {}) {
1531
1549
  normalizePublicEventNodeIds(graph);
1532
1550
  normalizeCrossFileEdges(graph);
1533
1551
  ensureReferencedNodes(graph);
1534
- graph.edges.push(...helperProjections(graph, budget));
1535
- graph.stateAccesses.push(...projectHelperStateAccesses(graph, budget));
1552
+ if (options.projectHelpers !== false) {
1553
+ for (const edge of helperProjections(graph, budget))
1554
+ graph.edges.push(edge);
1555
+ for (const access of projectHelperStateAccesses(graph, budget))
1556
+ graph.stateAccesses.push(access);
1557
+ }
1536
1558
  addTerminalNodes(graph);
1537
1559
  graph.nodes = sortNodes(graph.nodes);
1538
1560
  graph.edges = stableUnique(graph.edges);
@@ -1,6 +1,6 @@
1
1
  import { ServiceError } from '../core/result.js';
2
2
  export const EVENT_GRAPH_MAX_NODES = 100_000;
3
- export const EVENT_GRAPH_MAX_EDGES = 250_000;
3
+ export const EVENT_GRAPH_MAX_EDGES = 500_000;
4
4
  export const EVENT_GRAPH_MAX_STATE_ACCESSES = 250_000;
5
5
  export const EVENT_GRAPH_MAX_STATE_LINKS = 250_000;
6
6
  export const EVENT_GRAPH_MAX_STATE_LINK_CANDIDATES = 500_000;