hoi4-agent-tools 1.2.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 (44) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +6 -7
  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/core/workspace.js +31 -3
  12. package/dist/hoi4_agent_tools/event/graph.js +28 -6
  13. package/dist/hoi4_agent_tools/event/limits.js +1 -1
  14. package/dist/hoi4_agent_tools/event/service.js +103 -7
  15. package/dist/hoi4_agent_tools/focus/continuous-render.js +17 -8
  16. package/dist/hoi4_agent_tools/focus/presentation.js +96 -41
  17. package/dist/hoi4_agent_tools/focus/render.js +27 -16
  18. package/dist/hoi4_agent_tools/gui/assets.js +9 -2
  19. package/dist/hoi4_agent_tools/gui/inspection-artifact.js +99 -0
  20. package/dist/hoi4_agent_tools/gui/layout.js +126 -27
  21. package/dist/hoi4_agent_tools/gui/renderer.js +16 -0
  22. package/dist/hoi4_agent_tools/gui/source-graph.js +61 -28
  23. package/dist/hoi4_agent_tools/gui/studio.js +23 -8
  24. package/dist/hoi4_agent_tools/map/diagnostic-limit.js +90 -9
  25. package/dist/hoi4_agent_tools/map/model.js +22 -4
  26. package/dist/hoi4_agent_tools/map/service.js +4 -3
  27. package/dist/hoi4_agent_tools/map/validation.js +3 -2
  28. package/dist/hoi4_agent_tools/mcp/server/create.js +1 -3
  29. package/dist/hoi4_agent_tools/mcp/server/output-schemas.js +1 -1
  30. package/dist/hoi4_agent_tools/mcp/tools/event.js +6 -6
  31. package/dist/hoi4_agent_tools/mcp/tools/focus.js +149 -224
  32. package/dist/hoi4_agent_tools/mcp/tools/gui.js +35 -25
  33. package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -3
  34. package/dist/hoi4_agent_tools/runtime.js +112 -2
  35. package/dist/hoi4_agent_tools/schemas/common.js +5 -1
  36. package/dist/hoi4_agent_tools/version.js +1 -1
  37. package/docs/events.md +4 -5
  38. package/docs/focus.md +5 -4
  39. package/docs/gui.md +5 -1
  40. package/docs/map.md +1 -1
  41. package/docs/setup.md +9 -6
  42. package/package.json +1 -1
  43. package/server.json +2 -2
  44. package/dist/hoi4_agent_tools/mcp/tools/mods.js +0 -54
@@ -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;
@@ -42,11 +42,75 @@ class BoundedEventFragmentCache {
42
42
  }
43
43
  }
44
44
  }
45
+ function eventGraphCacheKey(workspaceId, projectHelpers) {
46
+ return `${workspaceId}\0${projectHelpers ? 'expanded' : 'structural'}`;
47
+ }
45
48
  const serviceState = new WeakMap();
46
49
  const EVENT_GRAPH_ARTIFACT_MAX_BYTES = 67_108_864;
47
50
  const EVENT_GRAPH_ARTIFACT_MAX_CHUNKS = 1_024;
51
+ const EVENT_SCAN_FULL_GRAPH_RECORD_LIMIT = 100_000;
52
+ const EVENT_SCAN_SUMMARY_SAMPLE_LIMIT = 100;
48
53
  const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
49
54
  const graphHashCache = new WeakMap();
55
+ function groupedCounts(values, key) {
56
+ const counts = new Map();
57
+ for (const value of values) {
58
+ const group = key(value);
59
+ counts.set(group, (counts.get(group) ?? 0) + 1);
60
+ }
61
+ return Object.fromEntries([...counts].sort(([left], [right]) => compareCodeUnits(left, right)));
62
+ }
63
+ export function eventScanReport(graph, fullGraphRecordLimit = EVENT_SCAN_FULL_GRAPH_RECORD_LIMIT) {
64
+ const recordCount = graph.nodes.length +
65
+ graph.edges.length +
66
+ graph.stateAccesses.length +
67
+ graph.stateLinks.length +
68
+ graph.issues.length +
69
+ graph.diagnostics.length +
70
+ graph.unresolved.length;
71
+ if (recordCount <= fullGraphRecordLimit)
72
+ return { graph };
73
+ const sourceEvidence = boundedSourceHashEvidence(graph.sourceHashes);
74
+ return {
75
+ graphSummary: {
76
+ schemaVersion: graph.schemaVersion,
77
+ parserVersion: graph.parserVersion,
78
+ workspaceId: graph.workspaceId,
79
+ workspaceIdentity: graph.workspaceIdentity,
80
+ revision: graph.revision,
81
+ complete: graph.complete,
82
+ statistics: graph.statistics,
83
+ filesScanned: graph.filesScanned.length,
84
+ skippedSourceCount: graph.skippedSourceCount,
85
+ sourceHashInventory: sourceEvidence.inventory,
86
+ recordCounts: {
87
+ nodes: graph.nodes.length,
88
+ edges: graph.edges.length,
89
+ stateAccesses: graph.stateAccesses.length,
90
+ stateLinks: graph.stateLinks.length,
91
+ issues: graph.issues.length,
92
+ diagnostics: graph.diagnostics.length,
93
+ unresolved: graph.unresolved.length,
94
+ },
95
+ nodeKinds: groupedCounts(graph.nodes, ({ kind }) => kind),
96
+ edgeReasons: groupedCounts(graph.edges, ({ reason }) => reason),
97
+ issueCodes: groupedCounts(graph.issues, ({ code }) => code),
98
+ issueSeverities: groupedCounts(graph.issues, ({ severity }) => severity),
99
+ diagnosticCodes: groupedCounts(graph.diagnostics, ({ code }) => code),
100
+ diagnosticSeverities: groupedCounts(graph.diagnostics, ({ severity }) => severity),
101
+ unresolvedKinds: groupedCounts(graph.unresolved, ({ kind }) => kind),
102
+ },
103
+ issueSamples: graph.issues.slice(0, EVENT_SCAN_SUMMARY_SAMPLE_LIMIT),
104
+ diagnosticSamples: graph.diagnostics.slice(0, EVENT_SCAN_SUMMARY_SAMPLE_LIMIT),
105
+ unresolvedSamples: graph.unresolved.slice(0, EVENT_SCAN_SUMMARY_SAMPLE_LIMIT),
106
+ artifactProjection: {
107
+ mode: 'large-scan-summary',
108
+ fullGraphRecordCount: recordCount,
109
+ fullGraphRecordLimit,
110
+ detailQueries: ['roots', 'trace', 'explain_path', 'state_flow', 'impact'],
111
+ },
112
+ };
113
+ }
50
114
  function cachedEventGraphHash(graph, signal) {
51
115
  signal?.throwIfAborted();
52
116
  const cached = graphHashCache.get(graph);
@@ -553,11 +617,16 @@ export class EventChainViewer {
553
617
  // principal bypass the resolver check for a later caller.
554
618
  const workspace = this.engine.resolver.get(workspaceId, options.principal);
555
619
  const generation = engineGeneration(this.engine, workspaceId);
556
- const cached = this.#state.current.get(workspaceId);
620
+ const projectHelpers = options.projectHelpers ?? true;
621
+ const cacheKey = eventGraphCacheKey(workspaceId, projectHelpers);
622
+ const cached = this.#state.current.get(cacheKey);
557
623
  if (options.refresh !== true && cached?.generation === generation) {
558
624
  return cached.graph;
559
625
  }
560
- const snapshot = await this.engine.scan(workspaceId, {}, options.principal, options.signal);
626
+ const sibling = this.#state.current.get(eventGraphCacheKey(workspaceId, !projectHelpers));
627
+ const snapshot = options.refresh !== true && sibling?.generation === generation
628
+ ? sibling.snapshot
629
+ : await this.engine.scan(workspaceId, {}, options.principal, options.signal);
561
630
  options.signal?.throwIfAborted();
562
631
  if (cached?.snapshot.revision === snapshot.revision && cached.generation === generation) {
563
632
  return cached.graph;
@@ -566,8 +635,9 @@ export class EventChainViewer {
566
635
  ...(options.signal === undefined ? {} : { signal: options.signal }),
567
636
  cache: this.#state.fragments,
568
637
  workspaceIdentity: workspace.workspaceIdentity,
638
+ projectHelpers,
569
639
  });
570
- this.#state.current.set(workspaceId, { generation, snapshot, graph });
640
+ this.#state.current.set(cacheKey, { generation, snapshot, graph });
571
641
  rememberGraph(this.#state, workspaceId, graph);
572
642
  return graph;
573
643
  }
@@ -575,6 +645,7 @@ export class EventChainViewer {
575
645
  input.signal?.throwIfAborted();
576
646
  const graph = await this.scan(input.workspaceId, {
577
647
  refresh: input.refresh ?? input.mode === 'scan',
648
+ projectHelpers: input.mode !== 'scan' && input.mode !== 'roots',
578
649
  ...(input.principal === undefined ? {} : { principal: input.principal }),
579
650
  ...(input.signal === undefined ? {} : { signal: input.signal }),
580
651
  });
@@ -597,7 +668,7 @@ export class EventChainViewer {
597
668
  let report;
598
669
  switch (input.mode) {
599
670
  case 'scan':
600
- report = { graph };
671
+ report = eventScanReport(graph);
601
672
  break;
602
673
  case 'roots':
603
674
  report = {
@@ -662,6 +733,16 @@ export class EventChainViewer {
662
733
  }
663
734
  const graphHash = cachedEventGraphHash(graph, input.signal);
664
735
  const name = `${safeSlug(`event-${input.mode}`)}-${graph.revision.slice(0, 12)}.json`;
736
+ const projectedScan = input.mode === 'scan' &&
737
+ typeof report === 'object' &&
738
+ report !== null &&
739
+ 'artifactProjection' in report;
740
+ const artifactSourceEvidence = projectedScan
741
+ ? boundedSourceHashEvidence(graph.sourceHashes)
742
+ : undefined;
743
+ const artifactUnresolved = projectedScan
744
+ ? graph.unresolved.slice(0, EVENT_SCAN_SUMMARY_SAMPLE_LIMIT)
745
+ : graph.unresolved;
665
746
  const reportJson = `${canonicalJson({
666
747
  schemaVersion: 'event-analysis.v1',
667
748
  graphSchemaVersion: graph.schemaVersion,
@@ -671,10 +752,22 @@ export class EventChainViewer {
671
752
  workspaceIdentity: graph.workspaceIdentity,
672
753
  graphRevision: graph.revision,
673
754
  graphHash,
674
- sourceHashes: graph.sourceHashes,
755
+ sourceHashes: artifactSourceEvidence?.sourceHashes ?? graph.sourceHashes,
756
+ ...(artifactSourceEvidence === undefined
757
+ ? {}
758
+ : { sourceHashInventory: artifactSourceEvidence.inventory }),
675
759
  complete: graph.complete,
676
760
  filters: analysisFilters,
677
- unresolved: graph.unresolved,
761
+ unresolved: artifactUnresolved,
762
+ ...(projectedScan
763
+ ? {
764
+ unresolvedInventory: {
765
+ count: graph.unresolved.length,
766
+ retainedCount: artifactUnresolved.length,
767
+ truncated: artifactUnresolved.length !== graph.unresolved.length,
768
+ },
769
+ }
770
+ : {}),
678
771
  resources: [{ name, mimeType: 'application/json' }],
679
772
  report,
680
773
  })}\n`;
@@ -695,6 +788,7 @@ export class EventChainViewer {
695
788
  async renderAndStore(input) {
696
789
  const graph = await this.scan(input.workspaceId, {
697
790
  ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
791
+ projectHelpers: true,
698
792
  ...(input.principal === undefined ? {} : { principal: input.principal }),
699
793
  ...(input.signal === undefined ? {} : { signal: input.signal }),
700
794
  });
@@ -892,7 +986,7 @@ export class EventChainViewer {
892
986
  if (sources.length === 0)
893
987
  throw new ServiceError('EVENT_PROPOSED_SOURCE_REQUIRED', 'Proposed comparison source list is empty');
894
988
  assertProposedSourceBounds(sources);
895
- const cached = this.#state.current.get(workspaceId);
989
+ const cached = this.#state.current.get(eventGraphCacheKey(workspaceId, false));
896
990
  if (cached === undefined)
897
991
  throw new ServiceError('EVENT_BASELINE_MISSING', 'Scan the workspace before comparing proposed source');
898
992
  const paths = sources.map(({ relativePath }) => validRelativeSourcePath(relativePath));
@@ -974,6 +1068,7 @@ export class EventChainViewer {
974
1068
  ...(signal === undefined ? {} : { signal }),
975
1069
  cache: this.#state.fragments,
976
1070
  workspaceIdentity: workspace.workspaceIdentity,
1071
+ projectHelpers: false,
977
1072
  });
978
1073
  }
979
1074
  async #resolveGraphReference(workspaceId, reference, current, principal, signal) {
@@ -1005,6 +1100,7 @@ export class EventChainViewer {
1005
1100
  input.after === undefined);
1006
1101
  const current = await this.scan(input.workspaceId, {
1007
1102
  refresh,
1103
+ projectHelpers: false,
1008
1104
  ...(input.principal === undefined ? {} : { principal: input.principal }),
1009
1105
  ...(input.signal === undefined ? {} : { signal: input.signal }),
1010
1106
  });
@@ -31,7 +31,9 @@ export async function renderContinuousFocusPalette(plan, diagnostics, options =
31
31
  const dataUri = iconSprite === undefined ? undefined : options.presentation?.icons[iconSprite]?.dataUri;
32
32
  return dataUri === undefined ? [] : [dataUri];
33
33
  });
34
- await admitFocusIconDataUris(options, budget, renderedDataUris);
34
+ if (options.rasterize !== false) {
35
+ await admitFocusIconDataUris(options, budget, renderedDataUris);
36
+ }
35
37
  const columns = Math.max(1, Math.min(12, options.columns ?? 5));
36
38
  const padding = Math.max(24, options.padding ?? 64);
37
39
  const cardWidth = 190;
@@ -41,7 +43,9 @@ export async function renderContinuousFocusPalette(plan, diagnostics, options =
41
43
  const usedColumns = Math.max(1, Math.min(columns, plan.focuses.length));
42
44
  const width = padding * 2 + usedColumns * cardWidth + Math.max(0, usedColumns - 1) * gap;
43
45
  const height = padding * 2 + 84 + rows * cardHeight + Math.max(0, rows - 1) * gap;
44
- budget.reserve(width, height, 'continuous focus palette PNG');
46
+ if (options.rasterize !== false) {
47
+ budget.reserve(width, height, 'continuous focus palette PNG');
48
+ }
45
49
  const diagnosticsById = new Map();
46
50
  const toolText = new DeterministicSvgTextRenderer();
47
51
  for (const diagnostic of diagnostics) {
@@ -120,10 +124,13 @@ export async function renderContinuousFocusPalette(plan, diagnostics, options =
120
124
  })}\n`;
121
125
  const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeXml(plan.id)} continuous focus palette</title><style>html{background:#101820;color:#f3f6f8;font:14px system-ui}body{margin:0;padding:24px}.notice{color:#d7b65b}svg{max-width:100%;height:auto;border:1px solid #385064}pre{white-space:pre-wrap}</style></head><body><h1>${escapeXml(plan.id)}</h1><p class="notice">Offline source-derived review artifact &mdash; not an in-game screenshot or editor.</p>${svg}<details><summary>Structured source model</summary><pre>${escapeXml(json)}</pre></details></body></html>`;
122
126
  options.signal?.throwIfAborted();
123
- assertRenderDimensions(width, height, 'continuous focus palette Sharp raster');
124
- const png = await sharp(Buffer.from(svg, 'utf8'), { limitInputPixels: RENDER_MAX_PIXELS })
125
- .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
126
- .toBuffer();
127
+ let png = Buffer.alloc(0);
128
+ if (options.rasterize !== false) {
129
+ assertRenderDimensions(width, height, 'continuous focus palette Sharp raster');
130
+ png = await sharp(Buffer.from(svg, 'utf8'), { limitInputPixels: RENDER_MAX_PIXELS })
131
+ .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
132
+ .toBuffer();
133
+ }
127
134
  options.signal?.throwIfAborted();
128
135
  return {
129
136
  html,
@@ -133,7 +140,7 @@ export async function renderContinuousFocusPalette(plan, diagnostics, options =
133
140
  hashes: {
134
141
  html: sha256Bytes(html),
135
142
  svg: sha256Bytes(svg),
136
- png: sha256Bytes(png),
143
+ ...(options.rasterize === false ? {} : { png: sha256Bytes(png) }),
137
144
  json: sha256Bytes(json),
138
145
  },
139
146
  sourceMap: compiled.sourceMap,
@@ -158,7 +165,9 @@ export async function storeContinuousFocusRenderArtifacts(workspace, store, plan
158
165
  const entries = [
159
166
  [`${stem}.continuous.html`, 'text/html', bundle.html, 'interactive-review'],
160
167
  [`${stem}.continuous.svg`, 'image/svg+xml', bundle.svg, 'vector-render'],
161
- [`${stem}.continuous.png`, 'image/png', bundle.png, 'raster-render'],
168
+ ...(options.rasterize === false
169
+ ? []
170
+ : [[`${stem}.continuous.png`, 'image/png', bundle.png, 'raster-render']]),
162
171
  [`${stem}.continuous.json`, 'application/json', bundle.json, 'structured-render'],
163
172
  [
164
173
  `${stem}.continuous.source-map.json`,
@@ -70,15 +70,34 @@ function diagnosticLocation(target) {
70
70
  : { location: target.sourceLocation }
71
71
  : { location: target.iconLocation };
72
72
  }
73
+ async function mapWithConcurrency(values, concurrency, callback) {
74
+ const results = new Array(values.length);
75
+ let next = 0;
76
+ await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => {
77
+ while (next < values.length) {
78
+ const index = next;
79
+ next += 1;
80
+ results[index] = await callback(values[index]);
81
+ }
82
+ }));
83
+ return results;
84
+ }
73
85
  export async function resolveFocusPresentation(input) {
74
86
  input.signal?.throwIfAborted();
75
87
  const language = input.language ?? 'l_english';
76
88
  const palettes = input.palettes ?? [];
77
89
  const targets = presentationTargets(input.plans, palettes);
78
- const baseGraph = buildGuiSourceGraph(input.files, input.index);
79
90
  const spriteNames = [
80
91
  ...new Set(targets.flatMap(({ sprite }) => (sprite === undefined ? [] : [sprite]))),
81
92
  ];
93
+ // Focus presentation needs sprite declarations, not the entire GUI, decision, and
94
+ // localisation graph. Localised focus text is resolved directly through the shared index.
95
+ const spriteSourcePaths = new Set(spriteNames.flatMap((spriteName) => {
96
+ const symbol = input.index.find('sprite', spriteName);
97
+ return symbol === undefined ? [] : [symbol.path];
98
+ }));
99
+ const presentationFiles = input.files.filter(({ displayPath }) => spriteSourcePaths.has(displayPath));
100
+ const baseGraph = buildGuiSourceGraph(presentationFiles, input.index);
82
101
  const texturePaths = new Set();
83
102
  for (const spriteName of spriteNames) {
84
103
  const sprite = relevantSprite(spriteName, baseGraph.sprites, input.index);
@@ -97,16 +116,17 @@ export async function resolveFocusPresentation(input) {
97
116
  ...(input.signal === undefined ? {} : { signal: input.signal }),
98
117
  });
99
118
  const combined = new Map();
100
- for (const file of [...input.files, ...assets])
119
+ for (const file of [...presentationFiles, ...assets])
101
120
  combined.set(file.displayPath, file);
102
121
  const files = [...combined.values()].sort((left, right) => compareCodeUnits(left.displayPath, right.displayPath));
103
- const graph = buildGuiSourceGraph(files, input.index.rebuild(files));
122
+ const graph = baseGraph;
104
123
  const catalog = new GuiAssetCatalog(graph, files, input.budget);
105
124
  const diagnostics = [];
106
125
  const entries = {};
107
126
  const icons = {};
108
127
  const sourceHashes = {};
109
128
  const relevantPaths = new Set();
129
+ const iconTargets = new Map();
110
130
  for (const target of targets) {
111
131
  input.signal?.throwIfAborted();
112
132
  const title = input.index.find('localisation', `${language}:${target.titleKey}`);
@@ -125,6 +145,8 @@ export async function resolveFocusPresentation(input) {
125
145
  : { descriptionSourceLocation: description.location }),
126
146
  ...(target.sprite === undefined ? {} : { iconSprite: target.sprite }),
127
147
  };
148
+ if (target.sprite !== undefined && !iconTargets.has(target.sprite))
149
+ iconTargets.set(target.sprite, target);
128
150
  for (const [key, symbol] of [
129
151
  [target.titleKey, title],
130
152
  [target.descriptionKey, description],
@@ -147,54 +169,87 @@ export async function resolveFocusPresentation(input) {
147
169
  details: { language, key, focusId: target.id },
148
170
  });
149
171
  }
150
- if (target.sprite === undefined || icons[target.sprite] !== undefined)
151
- continue;
152
- const sprite = relevantSprite(target.sprite, graph.sprites, input.index);
172
+ }
173
+ const iconEntries = [...iconTargets.entries()];
174
+ const iconResults = await mapWithConcurrency(iconEntries, 8, async ([spriteName, target]) => {
175
+ const sprite = relevantSprite(spriteName, graph.sprites, input.index);
153
176
  if (sprite === undefined) {
154
177
  const partial = input.index.hasSkippedSourceForKind('sprite');
155
- diagnostics.push({
156
- code: partial ? 'FOCUS_ICON_REFERENCE_PARTIAL' : 'FOCUS_ICON_REFERENCE_MISSING',
157
- severity: partial ? 'warning' : 'error',
158
- category: 'reference',
159
- message: partial
160
- ? `The partial shared inventory cannot verify sprite ${target.sprite} for focus ${target.id}`
161
- : `Focus ${target.id} references missing sprite ${target.sprite}`,
162
- ...diagnosticLocation(target),
163
- details: { focusId: target.id, sprite: target.sprite },
164
- });
165
- continue;
178
+ return {
179
+ paths: [],
180
+ diagnostic: {
181
+ code: partial ? 'FOCUS_ICON_REFERENCE_PARTIAL' : 'FOCUS_ICON_REFERENCE_MISSING',
182
+ severity: partial ? 'warning' : 'error',
183
+ category: 'reference',
184
+ message: partial
185
+ ? `The partial shared inventory cannot verify sprite ${spriteName} for focus ${target.id}`
186
+ : `Focus ${target.id} references missing sprite ${spriteName}`,
187
+ ...diagnosticLocation(target),
188
+ details: { focusId: target.id, sprite: spriteName },
189
+ },
190
+ };
191
+ }
192
+ const paths = [sprite.sourcePath];
193
+ if (input.decodeIcons === false) {
194
+ const texturePath = sprite.texturePath;
195
+ const asset = texturePath === undefined ? undefined : catalog.resolveFile(texturePath, sprite.sourcePath);
196
+ if (asset !== undefined)
197
+ return { paths: [...paths, asset.displayPath] };
198
+ return {
199
+ paths,
200
+ diagnostic: {
201
+ code: 'FOCUS_ICON_TEXTURE_MISSING',
202
+ severity: 'error',
203
+ category: 'reference',
204
+ message: `Focus ${target.id} cannot resolve sprite texture ${texturePath ?? '<missing>'}`,
205
+ ...diagnosticLocation(target),
206
+ details: { focusId: target.id, sprite: spriteName, texturePath: texturePath ?? null },
207
+ },
208
+ };
166
209
  }
167
- relevantPaths.add(sprite.sourcePath);
168
210
  const frame = await catalog.loadSpriteFrame(sprite, 0);
169
211
  if (frame?.supported !== true || frame.dataUri === undefined) {
170
- diagnostics.push({
171
- code: 'FOCUS_ICON_TEXTURE_MISSING',
172
- severity: 'error',
173
- category: 'reference',
174
- message: `Focus ${target.id} cannot load sprite texture ${sprite.texturePath ?? '<missing>'}: ${frame?.reason ?? 'texturefile is missing'}`,
175
- ...diagnosticLocation(target),
176
- details: {
177
- focusId: target.id,
178
- sprite: target.sprite,
179
- texturePath: sprite.texturePath ?? null,
212
+ return {
213
+ paths,
214
+ diagnostic: {
215
+ code: 'FOCUS_ICON_TEXTURE_MISSING',
216
+ severity: 'error',
217
+ category: 'reference',
218
+ message: `Focus ${target.id} cannot load sprite texture ${sprite.texturePath ?? '<missing>'}: ${frame?.reason ?? 'texturefile is missing'}`,
219
+ ...diagnosticLocation(target),
220
+ details: {
221
+ focusId: target.id,
222
+ sprite: spriteName,
223
+ texturePath: sprite.texturePath ?? null,
224
+ },
180
225
  },
181
- });
182
- continue;
226
+ };
183
227
  }
184
228
  const asset = catalog.resolveFile(frame.texturePath, sprite.sourcePath);
185
229
  if (asset !== undefined)
186
- relevantPaths.add(asset.displayPath);
187
- icons[target.sprite] = {
188
- sprite: target.sprite,
189
- sourcePath: sprite.sourcePath,
190
- texturePath: frame.texturePath,
191
- frame: frame.frame,
192
- frameCount: frame.frameCount,
193
- width: frame.width,
194
- height: frame.height,
195
- format: frame.format,
196
- dataUri: frame.dataUri,
230
+ paths.push(asset.displayPath);
231
+ return {
232
+ paths,
233
+ icon: {
234
+ sprite: spriteName,
235
+ sourcePath: sprite.sourcePath,
236
+ texturePath: frame.texturePath,
237
+ frame: frame.frame,
238
+ frameCount: frame.frameCount,
239
+ width: frame.width,
240
+ height: frame.height,
241
+ format: frame.format,
242
+ dataUri: frame.dataUri,
243
+ },
197
244
  };
245
+ });
246
+ for (const [index, result] of iconResults.entries()) {
247
+ for (const sourcePath of result.paths)
248
+ relevantPaths.add(sourcePath);
249
+ if (result.diagnostic !== undefined)
250
+ diagnostics.push(result.diagnostic);
251
+ if (result.icon !== undefined)
252
+ icons[iconEntries[index][0]] = result.icon;
198
253
  }
199
254
  for (const file of files) {
200
255
  if (relevantPaths.has(file.displayPath))
@@ -168,7 +168,9 @@ function svgDocument(plan, layout, diagnostics, options) {
168
168
  const logicalHeight = Math.max(240, (maximumY - minimumY) * vertical + nodeHeight + padding * 2);
169
169
  const width = Math.max(1, Math.round(logicalWidth * outputScale));
170
170
  const height = Math.max(1, Math.round(logicalHeight * outputScale));
171
- (options.budget ?? new RenderBudget()).reserve(width, height, 'focus tree PNG');
171
+ if (options.rasterize !== false) {
172
+ (options.budget ?? new RenderBudget()).reserve(width, height, 'focus tree PNG');
173
+ }
172
174
  const pixel = new Map(nodes.map((node) => [
173
175
  node.id,
174
176
  focusNodeOrigin(node, minimumX, minimumY, padding, horizontal, vertical),
@@ -415,18 +417,23 @@ export async function renderFocusTree(plan, layout, diagnostics, options = {}) {
415
417
  const dataUri = focusIconDataUri(focus, resolvedOptions);
416
418
  return dataUri === undefined ? [] : [dataUri];
417
419
  });
418
- await admitFocusIconDataUris(resolvedOptions, budget, renderedDataUris);
420
+ if (options.rasterize !== false) {
421
+ await admitFocusIconDataUris(resolvedOptions, budget, renderedDataUris);
422
+ }
419
423
  const compiled = compileFocusTreeWithSourceMap(plan, layout);
420
424
  const rendered = svgDocument(plan, layout, diagnostics, resolvedOptions);
421
425
  const json = `${canonicalJson({ ...rendered.graph, generatedSourceMap: compiled.sourceMap })}\n`;
422
426
  const html = htmlDocument(plan, rendered.svg, json, diagnostics);
423
- assertRenderDimensions(rendered.width, rendered.height, 'focus tree Sharp raster');
424
- budget.reserveRasterOperation(`focus-tree:${sha256Bytes(rendered.svg)}`, 'focus tree SVG rasterization');
425
- const png = await sharp(Buffer.from(rendered.svg, 'utf8'), {
426
- limitInputPixels: RENDER_MAX_PIXELS,
427
- })
428
- .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
429
- .toBuffer();
427
+ let png = Buffer.alloc(0);
428
+ if (options.rasterize !== false) {
429
+ assertRenderDimensions(rendered.width, rendered.height, 'focus tree Sharp raster');
430
+ budget.reserveRasterOperation(`focus-tree:${sha256Bytes(rendered.svg)}`, 'focus tree SVG rasterization');
431
+ png = await sharp(Buffer.from(rendered.svg, 'utf8'), {
432
+ limitInputPixels: RENDER_MAX_PIXELS,
433
+ })
434
+ .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
435
+ .toBuffer();
436
+ }
430
437
  options.signal?.throwIfAborted();
431
438
  return {
432
439
  html,
@@ -436,7 +443,7 @@ export async function renderFocusTree(plan, layout, diagnostics, options = {}) {
436
443
  hashes: {
437
444
  html: sha256Bytes(html),
438
445
  svg: sha256Bytes(rendered.svg),
439
- png: sha256Bytes(png),
446
+ ...(options.rasterize === false ? {} : { png: sha256Bytes(png) }),
440
447
  json: sha256Bytes(json),
441
448
  },
442
449
  sourceMap: compiled.sourceMap,
@@ -478,12 +485,16 @@ export async function storeFocusRenderArtifacts(workspace, artifacts, plan, bund
478
485
  content: bundle.svg,
479
486
  provenance: { ...provenance, kind: 'focus-svg' },
480
487
  },
481
- {
482
- name: `${stem}.focus.png`,
483
- mimeType: 'image/png',
484
- content: bundle.png,
485
- provenance: { ...provenance, kind: 'focus-png' },
486
- },
488
+ ...(options.rasterize === false
489
+ ? []
490
+ : [
491
+ {
492
+ name: `${stem}.focus.png`,
493
+ mimeType: 'image/png',
494
+ content: bundle.png,
495
+ provenance: { ...provenance, kind: 'focus-png' },
496
+ },
497
+ ]),
487
498
  {
488
499
  name: `${stem}.focus.json`,
489
500
  mimeType: 'application/json',
@@ -219,13 +219,20 @@ export class GuiAssetCatalog {
219
219
  return Promise.resolve(undefined);
220
220
  const frameCount = Math.max(1, sprite.frameCount);
221
221
  const frame = Math.max(0, Math.min(frameCount - 1, Math.trunc(requestedFrame)));
222
- const key = `${sprite.id}:${texturePath}:${frameCount}:${frame}`;
222
+ // Different sprite names frequently reuse the same frame strip. Cache by resolved texture and
223
+ // crop instead of sprite identity so large surfaces do not rasterize identical pixels hundreds
224
+ // of times.
225
+ const textureFile = this.resolveFile(texturePath, sprite.sourcePath);
226
+ const textureIdentity = textureFile === undefined
227
+ ? `${sprite.sourcePath}:${texturePath}`
228
+ : `${textureFile.displayPath}:${textureFile.sha256}`;
229
+ const key = `${textureIdentity}:${frameCount}:${frame}`;
223
230
  let promise = this.frames.get(key);
224
231
  if (promise === undefined) {
225
232
  promise = this.decodeSpriteFrame(sprite, texturePath, frameCount, frame, key);
226
233
  this.frames.set(key, promise);
227
234
  }
228
- return promise;
235
+ return promise.then((decoded) => decoded === undefined ? undefined : { ...decoded, spriteName: sprite.name });
229
236
  }
230
237
  async decodeSpriteFrame(sprite, texturePath, frameCount, frame, operationKey) {
231
238
  const raster = await this.loadRaster(texturePath, sprite.sourcePath);