hoi4-agent-tools 2.5.2 → 2.6.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 (50) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +5 -5
  3. package/dist/hoi4_agent_tools/core/index.js +22 -6
  4. package/dist/hoi4_agent_tools/focus/compact.js +103 -12
  5. package/dist/hoi4_agent_tools/focus/continuous-render.js +4 -10
  6. package/dist/hoi4_agent_tools/focus/layout.js +15 -7
  7. package/dist/hoi4_agent_tools/focus/render.js +2 -8
  8. package/dist/hoi4_agent_tools/gui/dds.js +5 -2
  9. package/dist/hoi4_agent_tools/gui/layout.js +1 -1
  10. package/dist/hoi4_agent_tools/gui/renderer.js +4 -16
  11. package/dist/hoi4_agent_tools/gui/source-graph.js +9 -8
  12. package/dist/hoi4_agent_tools/gui/studio.js +58 -4
  13. package/dist/hoi4_agent_tools/map/catalog.js +398 -0
  14. package/dist/hoi4_agent_tools/map/index.js +1 -0
  15. package/dist/hoi4_agent_tools/map/operations.js +337 -30
  16. package/dist/hoi4_agent_tools/map/render.js +40 -147
  17. package/dist/hoi4_agent_tools/map/service.js +1 -0
  18. package/dist/hoi4_agent_tools/mcp/tools/map.js +85 -9
  19. package/dist/hoi4_agent_tools/mcp/transports/http.js +4 -5
  20. package/dist/hoi4_agent_tools/mcp/transports/protocol-gate.js +7 -7
  21. package/dist/hoi4_agent_tools/schemas/map.js +69 -2
  22. package/dist/hoi4_agent_tools/technology/render.js +65 -19
  23. package/dist/hoi4_agent_tools/technology/service.js +67 -3
  24. package/dist/hoi4_agent_tools/version.js +1 -1
  25. package/docs/focus.md +1 -1
  26. package/docs/gui.md +2 -0
  27. package/docs/map.md +60 -23
  28. package/docs/technology.md +1 -1
  29. package/package.json +3 -2
  30. package/schemas/configuration.schema.json +1 -1
  31. package/schemas/continuous-focus-palette.schema.json +1 -1
  32. package/schemas/custom-weighted-pool.schema.json +1 -1
  33. package/schemas/event-feature-manifest.schema.json +1 -1
  34. package/schemas/focus-plan.schema.json +1 -1
  35. package/schemas/focus-planning-sidecar.schema.json +1 -1
  36. package/schemas/gui-animation-source.schema.json +1 -1
  37. package/schemas/gui-helper.schema.json +1 -1
  38. package/schemas/gui-scenario.schema.json +1 -1
  39. package/schemas/map-operation.schema.json +228 -74
  40. package/schemas/operation-result.schema.json +1 -1
  41. package/schemas/probability-analysis-result.schema.json +1 -1
  42. package/schemas/probability-compare-input.schema.json +1 -1
  43. package/schemas/probability-evaluate-input.schema.json +1 -1
  44. package/schemas/probability-inspect-input.schema.json +1 -1
  45. package/schemas/probability-render-input.schema.json +1 -1
  46. package/schemas/probability-scenario-set.schema.json +1 -1
  47. package/schemas/probability-sequence-input.schema.json +1 -1
  48. package/schemas/probability-simulate-input.schema.json +1 -1
  49. package/schemas/probability-sweep-input.schema.json +1 -1
  50. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@
4
4
 
5
5
  No unreleased changes.
6
6
 
7
+ ## 2.6.0 - 2026-08-22
8
+
9
+ - Added a shared complete map catalog with resolved names, source locations, geometry, memberships, networks, positions, buildings, ports, adjacency, and localisation evidence.
10
+ - Made map inspection return a full-map overview and a searchable, clickable HTML navigator with exact province hit testing and pixel/map coordinate lookup.
11
+ - Added compact state and province creation defaults, rectangle province geometry, and one-call province, state, and strategic-region ID swapping with connected map-reference and standard localisation updates.
12
+
13
+ ## 2.5.3 - 2026-08-22
14
+
15
+ - Resolved and embedded technology icons through the complete mod, dependency, and installed-game load order, including vanilla sprites used by mod content.
16
+ - Recognized vanilla `SpriteType` capitalization consistently in the shared index and GUI source graph.
17
+ - Decoded uncompressed vanilla DDS textures whose header stores total linear size instead of row pitch.
18
+ - Added deterministic cross-root focus, scripted-GUI, and technology regressions plus an opt-in installed-game DDS render test.
19
+
7
20
  ## 2.5.2 - 2026-08-15
8
21
 
9
22
  - 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`.
package/README.md CHANGED
@@ -6,7 +6,7 @@ HOI4 Agent Tools is an MCP server for coding agents to understand Hearts of Iron
6
6
 
7
7
  - Focus trees: inspect structure and references, render layouts, create trees, and reorganize existing branches.
8
8
  - Scripted GUIs: trace GUI, GFX, scripted-GUI, and localisation links; render value-driven variants, states, resolutions, hierarchy, and click regions; diagnose alignment, visibility, clipping, panel containment, and button-label centering; create or repair interface source.
9
- - Maps: inspect provinces, states, regions, adjacency, supply, and railways; render layers; create and repair exact map data.
9
+ - Maps: navigate the complete rendered map by ID or localised name; inspect provinces, states, regions, adjacency, supply, railways, and positions; create states and provinces; change IDs; and repair connected map data.
10
10
  - Event chains: scan definitions and call sites, trace routes and state flow, lint references, render graphs, and compare revisions without editing event source.
11
11
  - Technology trees: reconstruct technology and doctrine paths, folder layouts, unlocks, bonuses, grants, metadata, assets, and structural changes.
12
12
  - AI and MTTH: evaluate weighted choices and timing across explicit scenarios, sweep uncertain inputs, simulate distributions, compare patches, and analyze declared stateful pools.
@@ -40,9 +40,9 @@ Paste the printed Codex or generic global-install entry into your MCP client, th
40
40
  | `hoi4.gui_inspect` | Read a scripted GUI and its linked assets and logic. |
41
41
  | `hoi4.gui_render` | Render GUI scenario variants, states, resolutions, hierarchy, and layout diagnostics. |
42
42
  | `hoi4.gui_rewrite` | Create or update a GUI source package. |
43
- | `hoi4.map_inspect` | Read map, state, province, region, supply, and railway data. |
44
- | `hoi4.map_render` | Render map layers and overlays. |
45
- | `hoi4.map_rewrite` | Create or update map data from an ordered list of exact changes. |
43
+ | `hoi4.map_inspect` | Search, click, navigate, and inspect the complete rendered map and its linked data. |
44
+ | `hoi4.map_render` | Render full-map layers, overlays, names, IDs, coordinates, and source-linked catalogs. |
45
+ | `hoi4.map_rewrite` | Create or update states, provinces, IDs, networks, positions, and connected map data. |
46
46
  | `hoi4.event_inspect` | Scan, trace, explain, lint, or assess event chains and their state flow. |
47
47
  | `hoi4.event_render` | Render source-linked event routes, options, timing, state, scope, and unresolved edges. |
48
48
  | `hoi4.event_compare` | Compare event-chain topology and diagnostics between revisions. |
@@ -71,7 +71,7 @@ Ask your agent in normal task language. A typical workflow is inspect, render, r
71
71
 
72
72
  - 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).
73
73
  - Scripted GUIs: "Create a scripted GUI for this mechanic," or "Render every value-driven version of this window and fix hidden controls, off-center button text, background alignment, clipping, and click-region conflicts." See [Scripted GUIs](docs/gui.md).
74
- - Maps: "Create a state from these exact provinces," or "Inspect this state and split these provinces while keeping supply and railway references valid." See [Maps](docs/map.md).
74
+ - Maps: "Render the whole map and find this state by name," "Create a state from these provinces," "Create a province inside this exact rectangle," or "Swap these state IDs and update connected references." See [Maps](docs/map.md).
75
75
  - Event chains: "Trace every route from this event and explain where its flags and variables change," or "Compare the workspace event graph with its previous revision and render the affected routes." See [Event chains](docs/events.md).
76
76
  - Technology trees: "Explain everything this technology requires and unlocks," or "Compare this technology patch and render every affected folder and doctrine branch." See [Technology trees](docs/technology.md).
77
77
  - AI and MTTH: "Compare these focus weights across peace, defensive-war, and low-stability scenarios," or "Show when this MTTH event becomes likely and which unknown inputs control the result." See [AI and MTTH analysis](docs/probability.md).
@@ -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 }),