hoi4-agent-tools 2.5.2 → 2.5.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 (42) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/hoi4_agent_tools/core/index.js +22 -6
  3. package/dist/hoi4_agent_tools/focus/compact.js +103 -12
  4. package/dist/hoi4_agent_tools/focus/continuous-render.js +4 -10
  5. package/dist/hoi4_agent_tools/focus/layout.js +15 -7
  6. package/dist/hoi4_agent_tools/focus/render.js +2 -8
  7. package/dist/hoi4_agent_tools/gui/dds.js +5 -2
  8. package/dist/hoi4_agent_tools/gui/layout.js +1 -1
  9. package/dist/hoi4_agent_tools/gui/renderer.js +4 -16
  10. package/dist/hoi4_agent_tools/gui/source-graph.js +9 -8
  11. package/dist/hoi4_agent_tools/gui/studio.js +58 -4
  12. package/dist/hoi4_agent_tools/map/render.js +1 -1
  13. package/dist/hoi4_agent_tools/mcp/transports/http.js +4 -5
  14. package/dist/hoi4_agent_tools/mcp/transports/protocol-gate.js +7 -7
  15. package/dist/hoi4_agent_tools/technology/render.js +65 -19
  16. package/dist/hoi4_agent_tools/technology/service.js +67 -3
  17. package/dist/hoi4_agent_tools/version.js +1 -1
  18. package/docs/focus.md +1 -1
  19. package/docs/gui.md +2 -0
  20. package/docs/technology.md +1 -1
  21. package/package.json +3 -2
  22. package/schemas/configuration.schema.json +1 -1
  23. package/schemas/continuous-focus-palette.schema.json +1 -1
  24. package/schemas/custom-weighted-pool.schema.json +1 -1
  25. package/schemas/event-feature-manifest.schema.json +1 -1
  26. package/schemas/focus-plan.schema.json +1 -1
  27. package/schemas/focus-planning-sidecar.schema.json +1 -1
  28. package/schemas/gui-animation-source.schema.json +1 -1
  29. package/schemas/gui-helper.schema.json +1 -1
  30. package/schemas/gui-scenario.schema.json +1 -1
  31. package/schemas/map-operation.schema.json +1 -1
  32. package/schemas/operation-result.schema.json +1 -1
  33. package/schemas/probability-analysis-result.schema.json +1 -1
  34. package/schemas/probability-compare-input.schema.json +1 -1
  35. package/schemas/probability-evaluate-input.schema.json +1 -1
  36. package/schemas/probability-inspect-input.schema.json +1 -1
  37. package/schemas/probability-render-input.schema.json +1 -1
  38. package/schemas/probability-scenario-set.schema.json +1 -1
  39. package/schemas/probability-sequence-input.schema.json +1 -1
  40. package/schemas/probability-simulate-input.schema.json +1 -1
  41. package/schemas/probability-sweep-input.schema.json +1 -1
  42. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@
4
4
 
5
5
  No unreleased changes.
6
6
 
7
+ ## 2.5.3 - 2026-08-22
8
+
9
+ - Resolved and embedded technology icons through the complete mod, dependency, and installed-game load order, including vanilla sprites used by mod content.
10
+ - Recognized vanilla `SpriteType` capitalization consistently in the shared index and GUI source graph.
11
+ - Decoded uncompressed vanilla DDS textures whose header stores total linear size instead of row pitch.
12
+ - Added deterministic cross-root focus, scripted-GUI, and technology regressions plus an opt-in installed-game DDS render test.
13
+
7
14
  ## 2.5.2 - 2026-08-15
8
15
 
9
16
  - Distinguished HOI4 small and large technology layouts in graph data and source-positioned folder renders, including exact 72×72 small items selected by `force_use_small_tech_layout`.
@@ -2,6 +2,22 @@ import { compareCodeUnits } from './canonical.js';
2
2
  import path from 'node:path';
3
3
  import { sortDiagnostics } from './diagnostics.js';
4
4
  import { parseClausewitz, parseLocalisation, assignments, childBlocks, firstScalar, locationFor, sourcePartialLimitDiagnostics, SOURCE_MAX_BYTES, SOURCE_MAX_NESTING, nodeLocation, } from './source/index.js';
5
+ const spriteDefinitionKeys = new Set([
6
+ 'spritetype',
7
+ 'textspritetype',
8
+ 'frameanimatedspritetype',
9
+ 'corneredtilespritetype',
10
+ 'maskedshieldtype',
11
+ ]);
12
+ function firstScalarInsensitiveNode(block, ...keys) {
13
+ const normalizedKeys = new Set(keys.map((key) => key.toLowerCase()));
14
+ for (const assignment of assignments(block)) {
15
+ if (assignment.value.type === 'scalar' &&
16
+ normalizedKeys.has(assignment.key.value.toLowerCase()))
17
+ return assignment.value;
18
+ }
19
+ return undefined;
20
+ }
5
21
  const guiElementKeys = new Set([
6
22
  'containerWindowType',
7
23
  'windowType',
@@ -992,10 +1008,11 @@ export class SymbolIndex {
992
1008
  metadata: {},
993
1009
  });
994
1010
  }
995
- else if ((key === 'spriteType' || key === 'frameAnimatedSpriteType') &&
996
- firstScalar(child, 'name') !== undefined) {
997
- const name = firstScalar(child, 'name');
998
- const texture = firstScalar(child, 'texturefile');
1011
+ else if (sourcePath.endsWith('.gfx') &&
1012
+ spriteDefinitionKeys.has(key.toLowerCase()) &&
1013
+ firstScalarInsensitiveNode(child, 'name') !== undefined) {
1014
+ const name = firstScalarInsensitiveNode(child, 'name');
1015
+ const texture = firstScalarInsensitiveNode(child, 'texturefile', 'textureFile');
999
1016
  this.addSymbol({
1000
1017
  kind: 'sprite',
1001
1018
  id: name.value,
@@ -1006,8 +1023,7 @@ export class SymbolIndex {
1006
1023
  metadata: {
1007
1024
  spriteType: key,
1008
1025
  texture: texture?.value,
1009
- frames: numeric(firstScalar(child, 'noOfFrames')?.value) ??
1010
- numeric(firstScalar(child, 'noofframes')?.value),
1026
+ frames: numeric(firstScalarInsensitiveNode(child, 'noOfFrames', 'noofframes')?.value),
1011
1027
  },
1012
1028
  });
1013
1029
  if (texture !== undefined)
@@ -90,6 +90,51 @@ function compactStructuralAnchorX(plan, focus, parents, preferredX, laneBases) {
90
90
  }
91
91
  return laneBases.get(focusLaneId(plan, focus)) ?? 0;
92
92
  }
93
+ function structuralRows(plan) {
94
+ const focusIds = new Set(plan.focuses.map(({ id }) => id));
95
+ const parentsByFocus = new Map(plan.focuses.map((focus) => [
96
+ focus.id,
97
+ [...new Set(prerequisiteIds(plan, focus.id).filter((parentId) => focusIds.has(parentId)))],
98
+ ]));
99
+ const childrenByFocus = new Map();
100
+ const unresolvedParents = new Map();
101
+ for (const focus of plan.focuses) {
102
+ const parents = parentsByFocus.get(focus.id) ?? [];
103
+ unresolvedParents.set(focus.id, parents.length);
104
+ for (const parentId of parents) {
105
+ const children = childrenByFocus.get(parentId) ?? [];
106
+ children.push(focus.id);
107
+ childrenByFocus.set(parentId, children);
108
+ }
109
+ }
110
+ for (const children of childrenByFocus.values())
111
+ children.sort((left, right) => compareCodeUnits(left, right));
112
+ const rows = new Map();
113
+ const pending = [...plan.focuses]
114
+ .map(({ id }) => id)
115
+ .filter((focusId) => (unresolvedParents.get(focusId) ?? 0) === 0)
116
+ .sort((left, right) => compareCodeUnits(right, left));
117
+ for (const focusId of pending)
118
+ rows.set(focusId, 0);
119
+ let visited = 0;
120
+ while (pending.length > 0) {
121
+ const focusId = pending.pop();
122
+ if (focusId === undefined)
123
+ continue;
124
+ visited += 1;
125
+ const nextRow = (rows.get(focusId) ?? 0) + 1;
126
+ for (const childId of childrenByFocus.get(focusId) ?? []) {
127
+ rows.set(childId, Math.max(rows.get(childId) ?? 0, nextRow));
128
+ const remaining = (unresolvedParents.get(childId) ?? 0) - 1;
129
+ unresolvedParents.set(childId, remaining);
130
+ if (remaining === 0)
131
+ pending.push(childId);
132
+ }
133
+ }
134
+ // The layout engine reports dependency cycles. Keep authored row preferences
135
+ // for a cyclic graph instead of inventing a partial topological reflow.
136
+ return visited === plan.focuses.length ? rows : undefined;
137
+ }
93
138
  function mirrorSiblingCohorts(plan, preferredX, preferredY) {
94
139
  const cohorts = new Map();
95
140
  const laneBases = compactLaneBases(plan);
@@ -182,7 +227,7 @@ function straightenLinearChains(plan, preferredX, preferredY) {
182
227
  }
183
228
  }
184
229
  function compactCandidate(plan, layout, strategy) {
185
- const { scale, compressRows, mirrorSiblings, straightenChains } = strategy;
230
+ const { scale, compressRows, structuralRows: useStructuralRows, mirrorSiblings, straightenChains, } = strategy;
186
231
  const minimumX = Math.min(...layout.nodes.map(({ x }) => x));
187
232
  const maximumX = Math.max(...layout.nodes.map(({ x }) => x));
188
233
  const minimumY = Math.min(...layout.nodes.map(({ y }) => y));
@@ -194,11 +239,19 @@ function compactCandidate(plan, layout, strategy) {
194
239
  node.id,
195
240
  compressRows ? (compactRows.get(node.y) ?? node.y - minimumY) : node.y - minimumY,
196
241
  ]));
242
+ if (useStructuralRows) {
243
+ const normalizedRows = structuralRows(plan);
244
+ if (normalizedRows !== undefined) {
245
+ for (const [focusId, row] of normalizedRows)
246
+ preferredY.set(focusId, row);
247
+ }
248
+ }
197
249
  const rows = new Map();
198
250
  for (const node of layout.nodes) {
199
- const row = rows.get(node.y) ?? [];
251
+ const y = preferredY.get(node.id) ?? node.y;
252
+ const row = rows.get(y) ?? [];
200
253
  row.push(node);
201
- rows.set(node.y, row);
254
+ rows.set(y, row);
202
255
  }
203
256
  for (const row of rows.values()) {
204
257
  row.sort((left, right) => left.x - right.x || compareCodeUnits(left.id, right.id));
@@ -249,12 +302,12 @@ function compactScore(layout) {
249
302
  metrics.symmetry.offAnchorSiblingCohortCount,
250
303
  metrics.symmetry.totalSiblingDeviation,
251
304
  metrics.symmetry.totalSiblingAnchorDeviation,
252
- metrics.bounds.rowCount,
253
- metrics.bounds.columnCount,
254
305
  metrics.connectors.maximumHorizontalSpan,
255
- metrics.connectors.totalHorizontalSpan,
256
306
  metrics.connectors.maximumManhattanSpan,
307
+ metrics.connectors.totalHorizontalSpan,
257
308
  metrics.connectors.totalVerticalSpan,
309
+ metrics.bounds.rowCount,
310
+ metrics.bounds.columnCount,
258
311
  layout.layoutHash,
259
312
  ];
260
313
  }
@@ -281,14 +334,50 @@ function compactScales(focusCount) {
281
334
  }
282
335
  function compactStrategies(focusCount) {
283
336
  const preservationStrategies = [
284
- { scale: 1, compressRows: false, mirrorSiblings: false, straightenChains: false },
285
- { scale: 1, compressRows: true, mirrorSiblings: false, straightenChains: false },
286
- { scale: 1, compressRows: false, mirrorSiblings: false, straightenChains: true },
287
- { scale: 1, compressRows: false, mirrorSiblings: true, straightenChains: false },
337
+ {
338
+ scale: 1,
339
+ compressRows: false,
340
+ structuralRows: false,
341
+ mirrorSiblings: false,
342
+ straightenChains: false,
343
+ },
344
+ {
345
+ scale: 1,
346
+ compressRows: true,
347
+ structuralRows: false,
348
+ mirrorSiblings: false,
349
+ straightenChains: false,
350
+ },
351
+ {
352
+ scale: 1,
353
+ compressRows: false,
354
+ structuralRows: false,
355
+ mirrorSiblings: false,
356
+ straightenChains: true,
357
+ },
358
+ {
359
+ scale: 1,
360
+ compressRows: false,
361
+ structuralRows: false,
362
+ mirrorSiblings: true,
363
+ straightenChains: false,
364
+ },
288
365
  ];
289
366
  const compactStrategies = compactScales(focusCount).flatMap((scale) => [
290
- { scale, compressRows: false, mirrorSiblings: true, straightenChains: true },
291
- { scale, compressRows: true, mirrorSiblings: true, straightenChains: true },
367
+ {
368
+ scale,
369
+ compressRows: false,
370
+ structuralRows: false,
371
+ mirrorSiblings: true,
372
+ straightenChains: true,
373
+ },
374
+ {
375
+ scale,
376
+ compressRows: false,
377
+ structuralRows: true,
378
+ mirrorSiblings: true,
379
+ straightenChains: true,
380
+ },
292
381
  ]);
293
382
  return [...preservationStrategies, ...compactStrategies];
294
383
  }
@@ -307,6 +396,7 @@ export function compactFocusTreePlan(plan) {
307
396
  ? compactCandidate(plan, currentLayout, {
308
397
  scale: 1,
309
398
  compressRows: false,
399
+ structuralRows: false,
310
400
  mirrorSiblings: false,
311
401
  straightenChains: false,
312
402
  })
@@ -358,6 +448,7 @@ export async function compactFocusTreePlanAsync(plan, options = {}) {
358
448
  ? compactCandidate(plan, currentLayout, {
359
449
  scale: 1,
360
450
  compressRows: false,
451
+ structuralRows: false,
361
452
  mirrorSiblings: false,
362
453
  straightenChains: false,
363
454
  })
@@ -42,7 +42,7 @@ export async function renderContinuousFocusPalette(plan, diagnostics, options =
42
42
  const rows = Math.max(1, Math.ceil(plan.focuses.length / columns));
43
43
  const usedColumns = Math.max(1, Math.min(columns, plan.focuses.length));
44
44
  const width = padding * 2 + usedColumns * cardWidth + Math.max(0, usedColumns - 1) * gap;
45
- const height = padding * 2 + 84 + rows * cardHeight + Math.max(0, rows - 1) * gap;
45
+ const height = padding * 2 + 52 + rows * cardHeight + Math.max(0, rows - 1) * gap;
46
46
  if (options.rasterize !== false) {
47
47
  budget.reserve(width, height, 'continuous focus palette PNG');
48
48
  }
@@ -61,7 +61,7 @@ export async function renderContinuousFocusPalette(plan, diagnostics, options =
61
61
  const column = index % columns;
62
62
  const row = Math.floor(index / columns);
63
63
  const x = padding + column * (cardWidth + gap);
64
- const y = padding + 84 + row * (cardHeight + gap);
64
+ const y = padding + 52 + row * (cardHeight + gap);
65
65
  const entry = options.presentation?.entries[focus.id];
66
66
  const iconSprite = focus.icons[0]?.sprite;
67
67
  const icon = iconSprite === undefined ? undefined : options.presentation?.icons[iconSprite];
@@ -80,13 +80,7 @@ export async function renderContinuousFocusPalette(plan, diagnostics, options =
80
80
  weight: 700,
81
81
  fill: '#f3f6f8',
82
82
  });
83
- const notice = toolText.render('OFFLINE SOURCE MODEL \u00b7 NOT HOI4', {
84
- x: padding,
85
- y: padding + 27,
86
- fontSize: 12,
87
- fill: '#d7b65b',
88
- });
89
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><title>${escapeXml(plan.id)} continuous focus palette</title><desc>Offline source-derived continuous focus palette; not an in-game screenshot.</desc><defs>${toolText.definitions()}</defs><rect width="${width}" height="${height}" fill="#101820"/>${heading}${notice}${cards.join('')}</svg>`;
83
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><title>${escapeXml(plan.id)} continuous focus palette</title><desc>${plan.focuses.length} continuous focuses.</desc><defs>${toolText.definitions()}</defs><rect width="${width}" height="${height}" fill="#101820"/>${heading}${cards.join('')}</svg>`;
90
84
  const compiled = compileContinuousFocusPaletteWithSourceMap(plan);
91
85
  const json = `${canonicalJson({
92
86
  schemaVersion: 1,
@@ -122,7 +116,7 @@ export async function renderContinuousFocusPalette(plan, diagnostics, options =
122
116
  },
123
117
  renderProfile: options.renderProfile ?? {},
124
118
  })}\n`;
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>`;
119
+ 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}svg{max-width:100%;height:auto;border:1px solid #385064}pre{white-space:pre-wrap}</style></head><body><h1>${escapeXml(plan.id)}</h1>${svg}<details><summary>Structured source model</summary><pre>${escapeXml(json)}</pre></details></body></html>`;
126
120
  options.signal?.throwIfAborted();
127
121
  let png = Buffer.alloc(0);
128
122
  if (options.rasterize !== false) {
@@ -1529,6 +1529,7 @@ function siblingBatchConflicts(context, cohort, override) {
1529
1529
  }
1530
1530
  function* refineSiblingCohorts(context, edges, initial) {
1531
1531
  let current = initial;
1532
+ let evaluatedEdges = edges;
1532
1533
  for (let pass = 0; pass < 2; pass += 1) {
1533
1534
  let accepted = false;
1534
1535
  for (const [cohortIndex, cohort] of context.siblingCohorts.entries()) {
@@ -1558,7 +1559,7 @@ function* refineSiblingCohorts(context, edges, initial) {
1558
1559
  continue;
1559
1560
  if (siblingBatchConflicts(context, cohortIds, override))
1560
1561
  continue;
1561
- const candidateEdges = edgesWithXOverride(edges, override);
1562
+ const candidateEdges = edgesWithXOverride(evaluatedEdges, override);
1562
1563
  const crossingSummary = yield* connectorCrossings(candidateEdges, context);
1563
1564
  if (crossingSummary.count > current.crossingCount)
1564
1565
  continue;
@@ -1587,6 +1588,7 @@ function* refineSiblingCohorts(context, edges, initial) {
1587
1588
  movePlacedFocusX(context, node, x);
1588
1589
  }
1589
1590
  current = best.score;
1591
+ evaluatedEdges = edgesWithXOverride(evaluatedEdges, best.override);
1590
1592
  accepted = true;
1591
1593
  }
1592
1594
  if (!accepted)
@@ -1644,13 +1646,19 @@ const CONNECTOR_REFINEMENT_WORK_MAX = 7_000_000;
1644
1646
  function* refineConnectorQualityWithinBudget(context) {
1645
1647
  const maximumAcceptedMoves = context.aggressiveAestheticRepair ? 32 : 24;
1646
1648
  const maximumCandidateNodes = context.aggressiveAestheticRepair ? 48 : 32;
1647
- const edges = yield* connectorEdges(context);
1648
- const crossingSummary = yield* connectorCrossings(edges, context);
1649
- const intersectionSummary = yield* connectorNodeIntersectionSummary(context, edges);
1650
- const symmetry = siblingQuality(context);
1649
+ let edges = yield* connectorEdges(context);
1650
+ let crossingSummary = yield* connectorCrossings(edges, context);
1651
+ let intersectionSummary = yield* connectorNodeIntersectionSummary(context, edges);
1652
+ let symmetry = siblingQuality(context);
1651
1653
  let current = connectorQualityScore(context, edges, crossingSummary.count, intersectionSummary.count, symmetry);
1652
- if (context.aggressiveAestheticRepair)
1653
- current = yield* refineSiblingCohorts(context, edges, current);
1654
+ if (context.aggressiveAestheticRepair) {
1655
+ yield* refineSiblingCohorts(context, edges, current);
1656
+ edges = yield* connectorEdges(context);
1657
+ crossingSummary = yield* connectorCrossings(edges, context);
1658
+ intersectionSummary = yield* connectorNodeIntersectionSummary(context, edges);
1659
+ symmetry = siblingQuality(context);
1660
+ current = connectorQualityScore(context, edges, crossingSummary.count, intersectionSummary.count, symmetry);
1661
+ }
1654
1662
  const candidateIds = new Set(intersectionSummary.involvedFocusIds);
1655
1663
  const longFocusIds = new Set();
1656
1664
  for (const edge of edges.filter(longConnector)) {
@@ -216,7 +216,7 @@ function svgDocument(plan, layout, diagnostics, options) {
216
216
  const parts = [
217
217
  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${logicalWidth} ${logicalHeight}" role="img" aria-labelledby="title description">`,
218
218
  `<title id="title">${escapeXml(plan.id)} focus tree</title>`,
219
- `<desc id="description">Offline HOI4 Agent Tools representation. ${plan.focuses.length} focuses.</desc>`,
219
+ `<desc id="description">${plan.focuses.length} focuses.</desc>`,
220
220
  '<defs>',
221
221
  '__DETERMINISTIC_FONT_DEFINITIONS__',
222
222
  '<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#8da2bc"/></marker>',
@@ -224,12 +224,6 @@ function svgDocument(plan, layout, diagnostics, options) {
224
224
  '</defs>',
225
225
  '<rect width="100%" height="100%" fill="#111723"/>',
226
226
  toolText.render(plan.id, { x: padding, y: 36, fontSize: 22, weight: 700, fill: '#e8edf5' }),
227
- toolText.render(`Offline agent render · layout ${layout.layoutHash.slice(0, 12)}`, {
228
- x: padding,
229
- y: 58,
230
- fontSize: 12,
231
- fill: '#95a4b8',
232
- }),
233
227
  '<g id="prerequisites">',
234
228
  ];
235
229
  for (const edge of prerequisiteEdges) {
@@ -403,7 +397,7 @@ function htmlDocument(plan, svg, graphJson, diagnostics) {
403
397
  <body>
404
398
  <main class="canvas" aria-label="Focus tree artifact">${svg}</main>
405
399
  <aside class="panel">
406
- <div class="controls"><h1>${escapeXml(plan.id)}</h1><p class="notice">Offline HOI4 Agent Tools representation — not an in-game screenshot or editor.</p><label>Find focus<input id="search" type="search" placeholder="ID, title, branch, or lane"></label></div>
400
+ <div class="controls"><h1>${escapeXml(plan.id)}</h1><label>Find focus<input id="search" type="search" placeholder="ID, title, branch, or lane"></label></div>
407
401
  <h2>Diagnostics</h2><ol>${diagnosticsHtml}</ol>
408
402
  <p class="muted">Source revision: <code>${escapeXml(plan.provenance.sourceHash)}</code></p>
409
403
  </aside>
@@ -1,6 +1,7 @@
1
1
  import { RenderBudget, RENDER_MAX_DECODED_PIXELS } from '../core/render-budget.js';
2
2
  import { sha256Bytes } from '../core/canonical.js';
3
3
  const DDS_MAGIC = 0x2053_4444;
4
+ const DDSD_PITCH = 0x8;
4
5
  const DDPF_FOURCC = 0x4;
5
6
  const DDPF_RGB = 0x40;
6
7
  function fourCc(bytes, offset) {
@@ -180,7 +181,9 @@ export function decodeDds(bytes, budget = new RenderBudget(), operationKey = `dd
180
181
  budget.reserve(width, height, 'DDS texture decode', {
181
182
  maximumPixels: RENDER_MAX_DECODED_PIXELS,
182
183
  });
183
- const pitch = bytes.readUInt32LE(20);
184
+ const headerFlags = bytes.readUInt32LE(8);
185
+ const pitchOrLinearSize = bytes.readUInt32LE(20);
186
+ const rowPitch = (headerFlags & DDSD_PITCH) === 0 ? 0 : pitchOrLinearSize;
184
187
  const flags = bytes.readUInt32LE(80);
185
188
  const code = fourCc(bytes, 84).toUpperCase();
186
189
  if ((flags & DDPF_FOURCC) !== 0) {
@@ -236,7 +239,7 @@ export function decodeDds(bytes, budget = new RenderBudget(), operationKey = `dd
236
239
  bytes.readUInt32LE(100),
237
240
  bytes.readUInt32LE(104),
238
241
  ];
239
- const output = decodeRgb(bytes, 128, width, height, bitCount, pitch, masks);
242
+ const output = decodeRgb(bytes, 128, width, height, bitCount, rowPitch, masks);
240
243
  const format = bitCount === 24 ? 'rgb24' : 'rgba32';
241
244
  return output === undefined
242
245
  ? unsupported(format, 'The DDS RGB pixel data is truncated.')
@@ -750,7 +750,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
750
750
  }
751
751
  }
752
752
  }
753
- /** Build a deterministic, offline approximation of a GUI window without launching HOI4. */
753
+ /** Build a deterministic GUI scene from the connected source graph and scenario. */
754
754
  export async function buildGuiScene(graph, scannedFiles, windowName, scenario, catalog = new GuiAssetCatalog(graph, scannedFiles)) {
755
755
  if (graph.nodes.length > GUI_GRAPH_MAX_NODES ||
756
756
  graph.edges.length > GUI_GRAPH_MAX_EDGES ||
@@ -214,13 +214,7 @@ export function sceneToSvg(scene, variant) {
214
214
  .map((element) => renderOverlay(element, variant, toolText))
215
215
  .join('');
216
216
  const fidelity = escapeXml(canonicalJson(scene.fidelity));
217
- const banner = toolText.render('OFFLINE APPROXIMATION \u00b7 NOT HOI4', {
218
- x: view.viewBox.x + 15,
219
- y: view.viewBox.y + 22,
220
- fontSize: 11,
221
- fill: '#f1c75b',
222
- });
223
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" viewBox="${finite(view.viewBox.x)} ${finite(view.viewBox.y)} ${finite(view.viewBox.width)} ${finite(view.viewBox.height)}"><metadata data-renderer="hoi4-agent-tools" data-mode="offline">${fidelity}</metadata><defs>${clipDefinitions.join('')}${sceneGlyphDefinitions(scene)}${toolText.definitions()}</defs><rect ${rectAttributes(view.viewBox)} fill="#17202a"/>${body}${overlays}<g><rect x="${finite(view.viewBox.x + 8)}" y="${finite(view.viewBox.y + 8)}" width="215" height="20" rx="3" fill="#05080c" fill-opacity="0.82"/>${banner}</g></svg>`;
217
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" viewBox="${finite(view.viewBox.x)} ${finite(view.viewBox.y)} ${finite(view.viewBox.width)} ${finite(view.viewBox.height)}"><metadata data-renderer="hoi4-agent-tools" data-mode="offline">${fidelity}</metadata><defs>${clipDefinitions.join('')}${sceneGlyphDefinitions(scene)}${toolText.definitions()}</defs><rect ${rectAttributes(view.viewBox)} fill="#17202a"/>${body}${overlays}</svg>`;
224
218
  }
225
219
  async function cooperativeParts(values, render, signal) {
226
220
  const output = [];
@@ -244,13 +238,7 @@ async function sceneToSvgCooperative(scene, variant, signal) {
244
238
  const body = await cooperativeParts(scene.elements, (element, index) => renderBaseElement(element, element.clipRect === undefined ? undefined : `clip-${index}`, toolText), signal);
245
239
  const overlays = await cooperativeParts(scene.elements, (element) => renderOverlay(element, variant, toolText), signal);
246
240
  const fidelity = escapeXml(canonicalJson(scene.fidelity));
247
- const banner = toolText.render('OFFLINE APPROXIMATION \u00b7 NOT HOI4', {
248
- x: view.viewBox.x + 15,
249
- y: view.viewBox.y + 22,
250
- fontSize: 11,
251
- fill: '#f1c75b',
252
- });
253
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" viewBox="${finite(view.viewBox.x)} ${finite(view.viewBox.y)} ${finite(view.viewBox.width)} ${finite(view.viewBox.height)}"><metadata data-renderer="hoi4-agent-tools" data-mode="offline">${fidelity}</metadata><defs>${clipDefinitions}${sceneGlyphDefinitions(scene)}${toolText.definitions()}</defs><rect ${rectAttributes(view.viewBox)} fill="#17202a"/>${body}${overlays}<g><rect x="${finite(view.viewBox.x + 8)}" y="${finite(view.viewBox.y + 8)}" width="215" height="20" rx="3" fill="#05080c" fill-opacity="0.82"/>${banner}</g></svg>`;
241
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" viewBox="${finite(view.viewBox.x)} ${finite(view.viewBox.y)} ${finite(view.viewBox.width)} ${finite(view.viewBox.height)}"><metadata data-renderer="hoi4-agent-tools" data-mode="offline">${fidelity}</metadata><defs>${clipDefinitions}${sceneGlyphDefinitions(scene)}${toolText.definitions()}</defs><rect ${rectAttributes(view.viewBox)} fill="#17202a"/>${body}${overlays}</svg>`;
254
242
  }
255
243
  export function hierarchyToSvg(scene) {
256
244
  const rows = scene.elements.toSorted((left, right) => left.depth - right.depth || left.zIndex - right.zIndex || compareCodeUnits(left.id, right.id));
@@ -283,7 +271,7 @@ export function hierarchyToSvg(scene) {
283
271
  return `${parentLine}<circle cx="${x}" cy="${y - 8}" r="4" fill="${colour}"/>${nameText}${detailText}`;
284
272
  })
285
273
  .join('');
286
- const heading = toolText.render(`OFFLINE HIERARCHY \u00b7 ${scene.windowName}`, {
274
+ const heading = toolText.render(`${scene.windowName} \u00b7 HIERARCHY`, {
287
275
  x: 16,
288
276
  y: 20,
289
277
  fontSize: 12,
@@ -393,7 +381,7 @@ export function renderGallerySvg(title, items) {
393
381
  return `<g>${toolText.render(item.label, { x, y: y + 14, fontSize: 12, fill: '#f1c75b' })}<image x="${x}" y="${y + 22}" width="${finite(item.width * scale)}" height="${finite(item.height * scale)}" href="data:image/png;base64,${item.png.toString('base64')}"/></g>`;
394
382
  })
395
383
  .join('');
396
- const heading = toolText.render(`${title} \u00b7 OFFLINE APPROXIMATION`, {
384
+ const heading = toolText.render(title, {
397
385
  x: 12,
398
386
  y: 24,
399
387
  fontSize: 15,
@@ -31,12 +31,12 @@ const explicitGuiElementTypes = new Set([
31
31
  'mapIconType',
32
32
  ]);
33
33
  const spriteTypes = new Set([
34
- 'spriteType',
35
- 'textSpriteType',
36
- 'frameAnimatedSpriteType',
37
- 'corneredTileSpriteType',
38
- 'progressbarType',
39
- 'maskedShieldType',
34
+ 'spritetype',
35
+ 'textspritetype',
36
+ 'frameanimatedspritetype',
37
+ 'corneredtilespritetype',
38
+ 'progressbartype',
39
+ 'maskedshieldtype',
40
40
  ]);
41
41
  const modelledElementAttributes = new Set([
42
42
  'name',
@@ -330,7 +330,8 @@ function indexSpritesAndFonts(document, file, fileNodeId, nodes, edges, sprites,
330
330
  if (assignment.value.type !== 'block')
331
331
  continue;
332
332
  const child = assignment.value;
333
- if (spriteTypes.has(assignment.key.value)) {
333
+ const spriteType = assignment.key.value.toLowerCase();
334
+ if (spriteTypes.has(spriteType)) {
334
335
  const name = firstScalarInsensitive(child, 'name');
335
336
  if (name !== undefined) {
336
337
  const id = deterministicId('gui_sprite', { path: file.displayPath, name });
@@ -352,7 +353,7 @@ function indexSpritesAndFonts(document, file, fileNodeId, nodes, edges, sprites,
352
353
  ...(texturePath === undefined ? {} : { texturePath }),
353
354
  ...(texturePath2 === undefined ? {} : { texturePath2 }),
354
355
  frameCount: Math.trunc(numberScalar(child, 'noOfFrames', 'noofframes') ?? 1),
355
- frameAnimated: assignment.key.value === 'frameAnimatedSpriteType',
356
+ frameAnimated: spriteType === 'frameanimatedspritetype',
356
357
  ...(animationRateFps === undefined ? {} : { animationRateFps }),
357
358
  ...(looping === undefined ? {} : { looping }),
358
359
  ...(playOnShow === undefined ? {} : { playOnShow }),
@@ -11,6 +11,7 @@ import { ServiceError } from '../core/result.js';
11
11
  import { WorkspaceScanner } from '../core/scanner.js';
12
12
  import { applyReplacements, decodeSource, encodeSource, parseClausewitz, parseLocalisation, SOURCE_MAX_BYTES, sourcePartialLimitDiagnostics, } from '../core/source/index.js';
13
13
  import { readDependenciesFromScannedFiles, } from '../core/transactions.js';
14
+ import { TRANSACTION_MAX_DIAGNOSTICS } from '../core/transaction-limits.js';
14
15
  import { PACKAGE_VERSION } from '../version.js';
15
16
  import { isSafeAnimationSourcePath } from './animation-manifest.js';
16
17
  import { GuiAssetCatalog, parseBmFont } from './assets.js';
@@ -30,6 +31,43 @@ const staticGuiDefinitionPatterns = [
30
31
  ];
31
32
  export const GUI_TEXT_PACKAGE_MAX_FILES = 32;
32
33
  export const GUI_TEXT_PACKAGE_MAX_BYTES = 16_777_216;
34
+ const GUI_TRANSACTION_PHASE_DIAGNOSTIC_BUDGET = Math.floor(TRANSACTION_MAX_DIAGNOSTICS / 3);
35
+ function hardDiagnostic(diagnostic) {
36
+ return diagnostic.severity === 'error' || diagnostic.severity === 'blocker';
37
+ }
38
+ export function boundedGuiTransactionDiagnostics(diagnostics, maximumDiagnostics, evidenceArtifactName) {
39
+ if (diagnostics.length <= maximumDiagnostics)
40
+ return [...diagnostics];
41
+ if (maximumDiagnostics <= 0)
42
+ return [];
43
+ const retainedLimit = maximumDiagnostics - 1;
44
+ const hard = diagnostics.filter(hardDiagnostic);
45
+ const retainedHard = hard.slice(0, retainedLimit);
46
+ const retainedHardSet = new Set(retainedHard);
47
+ const retainedSoft = diagnostics
48
+ .filter((diagnostic) => !hardDiagnostic(diagnostic) && !retainedHardSet.has(diagnostic))
49
+ .slice(0, retainedLimit - retainedHard.length);
50
+ const omittedHard = hard.length - retainedHard.length;
51
+ return [
52
+ ...retainedHard,
53
+ ...retainedSoft,
54
+ {
55
+ code: 'GUI_REWRITE_DIAGNOSTICS_TRUNCATED',
56
+ severity: omittedHard > 0 ? 'blocker' : 'warning',
57
+ category: 'validation',
58
+ message: evidenceArtifactName === undefined
59
+ ? `GUI rewrite validation produced ${diagnostics.length} diagnostics; transaction evidence retains the highest-priority ${maximumDiagnostics}`
60
+ : `GUI rewrite validation produced ${diagnostics.length} diagnostics; complete evidence is stored in ${evidenceArtifactName}`,
61
+ details: {
62
+ total: diagnostics.length,
63
+ returned: maximumDiagnostics,
64
+ omitted: diagnostics.length - retainedLimit,
65
+ omittedHard,
66
+ ...(evidenceArtifactName === undefined ? {} : { evidenceArtifactName }),
67
+ },
68
+ },
69
+ ];
70
+ }
33
71
  function normalizeConfiguredRoot(value) {
34
72
  const normalized = value.replaceAll('\\', '/').replace(/^\.\//u, '').replace(/\/$/u, '');
35
73
  if (!isSafeAnimationSourcePath(normalized)) {
@@ -439,7 +477,7 @@ export function guiArtifactProvenance(graph, kind, primaryScene, scenarioScenes
439
477
  })),
440
478
  },
441
479
  metadata: {
442
- rendererLabel: 'OFFLINE APPROXIMATION · NOT HOI4',
480
+ rendererLabel: 'HOI4 Agent Tools deterministic GUI render',
443
481
  ...metadata,
444
482
  sourceHashInventory: sourceEvidence.inventory,
445
483
  },
@@ -859,6 +897,7 @@ export class ScriptedGuiStudio {
859
897
  const preflightDiagnostics = [];
860
898
  const preflightChecks = [];
861
899
  const preflightArtifacts = [];
900
+ let rewriteEvidenceArtifactName;
862
901
  let dependencyFiles = baseline.files;
863
902
  if (input.windowName !== undefined && input.scenario !== undefined) {
864
903
  let proposedFiles = overlayGuiTextPackageFiles(baseline.files, prepared);
@@ -942,6 +981,7 @@ export class ScriptedGuiStudio {
942
981
  }
943
982
  const comparison = await compareGuiImages(baselinePng, proposedImage.png, renderBudget, input.signal);
944
983
  const slug = safeSlug(input.windowName);
984
+ rewriteEvidenceArtifactName = `${slug}-rewrite-validation.json`;
945
985
  const metadata = {
946
986
  relativePath: input.relativePath,
947
987
  packageFiles: prepared.map(({ relativePath }) => relativePath),
@@ -982,10 +1022,24 @@ export class ScriptedGuiStudio {
982
1022
  })}\n`,
983
1023
  provenance: guiArtifactProvenance(proposedGraph, 'gui-proposed-fidelity', proposedScene, [proposedScene], metadata),
984
1024
  },
1025
+ {
1026
+ name: rewriteEvidenceArtifactName,
1027
+ mimeType: 'application/json',
1028
+ content: `${canonicalJson({
1029
+ sourceDiagnostics: proposedSourceDiagnostics,
1030
+ graphDiagnostics: proposedGraph.diagnostics,
1031
+ sceneDiagnostics: proposedScene.diagnostics,
1032
+ validation: proposedValidation,
1033
+ transactionDiagnostics: preflightDiagnostics,
1034
+ transactionChecks: preflightChecks,
1035
+ })}\n`,
1036
+ provenance: guiArtifactProvenance(proposedGraph, 'gui-rewrite-validation', proposedScene, [proposedScene], metadata),
1037
+ },
985
1038
  ];
986
1039
  const stored = await this.artifacts.withAtomicWrites(workspace, writes, (artifacts) => Promise.resolve([...artifacts]), input.signal);
987
1040
  preflightArtifacts.push(...stored.map(publicArtifactLink));
988
1041
  }
1042
+ const transactionPreflightDiagnostics = boundedGuiTransactionDiagnostics(preflightDiagnostics, GUI_TRANSACTION_PHASE_DIAGNOSTIC_BUDGET, rewriteEvidenceArtifactName);
989
1043
  const operationId = deterministicId('gui_source_change', {
990
1044
  relativePath: input.relativePath,
991
1045
  files: prepared.map(({ relativePath, bytes }) => ({
@@ -1020,7 +1074,7 @@ export class ScriptedGuiStudio {
1020
1074
  })),
1021
1075
  readDependencies: readDependenciesFromScannedFiles(dependencyFiles),
1022
1076
  artifacts: preflightArtifacts,
1023
- diagnostics: preflightDiagnostics,
1077
+ diagnostics: transactionPreflightDiagnostics,
1024
1078
  validate: (proposed, signal) => {
1025
1079
  const diagnostics = [];
1026
1080
  let present = true;
@@ -1049,7 +1103,7 @@ export class ScriptedGuiStudio {
1049
1103
  diagnostics.push(...blocking.map((diagnostic) => ({ ...diagnostic, operationId })));
1050
1104
  }
1051
1105
  return Promise.resolve({
1052
- diagnostics,
1106
+ diagnostics: boundedGuiTransactionDiagnostics(diagnostics, GUI_TRANSACTION_PHASE_DIAGNOSTIC_BUDGET, rewriteEvidenceArtifactName),
1053
1107
  checks: [
1054
1108
  ...preflightChecks,
1055
1109
  {
@@ -1077,7 +1131,7 @@ export class ScriptedGuiStudio {
1077
1131
  const scanned = await this.scan(workspaceId, principal, validationSignal);
1078
1132
  const passed = !scanned.graph.diagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker');
1079
1133
  return {
1080
- diagnostics: scanned.graph.diagnostics,
1134
+ diagnostics: boundedGuiTransactionDiagnostics(scanned.graph.diagnostics, Math.max(0, TRANSACTION_MAX_DIAGNOSTICS - _manifest.diagnostics.length), _manifest.artifacts.find(({ name }) => name.endsWith('-rewrite-validation.json'))?.name),
1081
1135
  checks: [
1082
1136
  {
1083
1137
  id: 'post-write-gui-graph',
@@ -433,7 +433,7 @@ function htmlDocument(title, png, json) {
433
433
  .replaceAll('<', '&lt;')
434
434
  .replaceAll('>', '&gt;');
435
435
  const escapedJson = json.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
436
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapedTitle}</title><style>html{background:#10151b;color:#edf2f7;font:14px system-ui}body{margin:20px}.controls{display:flex;gap:8px;align-items:center;margin:12px 0}button{background:#263241;color:#edf2f7;border:1px solid #526173;border-radius:4px;padding:6px 10px}.viewport{height:70vh;overflow:hidden;border:1px solid #48515c;background:#080b0f;position:relative;touch-action:none}.viewport img{image-rendering:pixelated;transform-origin:0 0;position:absolute;max-width:none;cursor:grab;user-select:none}.viewport img.dragging{cursor:grabbing}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style></head><body><h1>${escapedTitle}</h1><p>Offline deterministic map representation; not an in-game screenshot or source editor.</p><div class="controls"><button id="out" type="button">-</button><button id="reset" type="button">Reset</button><button id="in" type="button">+</button><output id="zoom">100%</output></div><div class="viewport" id="viewport"><img id="map" draggable="false" alt="${escapedTitle}" src="data:image/png;base64,${png.toString('base64')}"></div><details><summary>Render metadata</summary><pre>${escapedJson}</pre></details><script>(()=>{const image=document.getElementById('map'),viewport=document.getElementById('viewport'),label=document.getElementById('zoom');let scale=1,x=0,y=0,drag=false,lastX=0,lastY=0;const draw=()=>{image.style.transform='translate('+x+'px,'+y+'px) scale('+scale+')';label.value=Math.round(scale*100)+'%'};const zoom=(factor,cx=viewport.clientWidth/2,cy=viewport.clientHeight/2)=>{const next=Math.min(16,Math.max(.1,scale*factor));x=cx-(cx-x)*(next/scale);y=cy-(cy-y)*(next/scale);scale=next;draw()};document.getElementById('in').onclick=()=>zoom(1.25);document.getElementById('out').onclick=()=>zoom(.8);document.getElementById('reset').onclick=()=>{scale=1;x=0;y=0;draw()};viewport.addEventListener('wheel',event=>{event.preventDefault();const bounds=viewport.getBoundingClientRect();zoom(event.deltaY<0?1.15:.87,event.clientX-bounds.left,event.clientY-bounds.top)},{passive:false});image.addEventListener('pointerdown',event=>{drag=true;lastX=event.clientX;lastY=event.clientY;image.setPointerCapture(event.pointerId);image.classList.add('dragging')});image.addEventListener('pointermove',event=>{if(!drag)return;x+=event.clientX-lastX;y+=event.clientY-lastY;lastX=event.clientX;lastY=event.clientY;draw()});image.addEventListener('pointerup',()=>{drag=false;image.classList.remove('dragging')});draw()})();</script></body></html>`;
436
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapedTitle}</title><style>html{background:#10151b;color:#edf2f7;font:14px system-ui}body{margin:20px}.controls{display:flex;gap:8px;align-items:center;margin:12px 0}button{background:#263241;color:#edf2f7;border:1px solid #526173;border-radius:4px;padding:6px 10px}.viewport{height:70vh;overflow:hidden;border:1px solid #48515c;background:#080b0f;position:relative;touch-action:none}.viewport img{image-rendering:pixelated;transform-origin:0 0;position:absolute;max-width:none;cursor:grab;user-select:none}.viewport img.dragging{cursor:grabbing}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style></head><body><h1>${escapedTitle}</h1><div class="controls"><button id="out" type="button">-</button><button id="reset" type="button">Reset</button><button id="in" type="button">+</button><output id="zoom">100%</output></div><div class="viewport" id="viewport"><img id="map" draggable="false" alt="${escapedTitle}" src="data:image/png;base64,${png.toString('base64')}"></div><details><summary>Render metadata</summary><pre>${escapedJson}</pre></details><script>(()=>{const image=document.getElementById('map'),viewport=document.getElementById('viewport'),label=document.getElementById('zoom');let scale=1,x=0,y=0,drag=false,lastX=0,lastY=0;const draw=()=>{image.style.transform='translate('+x+'px,'+y+'px) scale('+scale+')';label.value=Math.round(scale*100)+'%'};const zoom=(factor,cx=viewport.clientWidth/2,cy=viewport.clientHeight/2)=>{const next=Math.min(16,Math.max(.1,scale*factor));x=cx-(cx-x)*(next/scale);y=cy-(cy-y)*(next/scale);scale=next;draw()};document.getElementById('in').onclick=()=>zoom(1.25);document.getElementById('out').onclick=()=>zoom(.8);document.getElementById('reset').onclick=()=>{scale=1;x=0;y=0;draw()};viewport.addEventListener('wheel',event=>{event.preventDefault();const bounds=viewport.getBoundingClientRect();zoom(event.deltaY<0?1.15:.87,event.clientX-bounds.left,event.clientY-bounds.top)},{passive:false});image.addEventListener('pointerdown',event=>{drag=true;lastX=event.clientX;lastY=event.clientY;image.setPointerCapture(event.pointerId);image.classList.add('dragging')});image.addEventListener('pointermove',event=>{if(!drag)return;x+=event.clientX-lastX;y+=event.clientY-lastY;lastX=event.clientX;lastY=event.clientY;draw()});image.addEventListener('pointerup',()=>{drag=false;image.classList.remove('dragging')});draw()})();</script></body></html>`;
437
437
  }
438
438
  async function encodePng(raw, width, height, scale) {
439
439
  assertRenderDimensions(width, height, 'map Sharp source raster');