hoi4-agent-tools 2.5.3 → 2.7.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 (39) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +5 -5
  3. package/dist/hoi4_agent_tools/gui/assets.js +15 -3
  4. package/dist/hoi4_agent_tools/gui/layout.js +270 -75
  5. package/dist/hoi4_agent_tools/gui/renderer.js +185 -22
  6. package/dist/hoi4_agent_tools/gui/source-graph.js +40 -11
  7. package/dist/hoi4_agent_tools/gui/studio.js +35 -7
  8. package/dist/hoi4_agent_tools/map/catalog.js +398 -0
  9. package/dist/hoi4_agent_tools/map/index.js +1 -0
  10. package/dist/hoi4_agent_tools/map/operations.js +337 -30
  11. package/dist/hoi4_agent_tools/map/render.js +40 -147
  12. package/dist/hoi4_agent_tools/map/service.js +1 -0
  13. package/dist/hoi4_agent_tools/mcp/tools/map.js +85 -9
  14. package/dist/hoi4_agent_tools/schemas/map.js +69 -2
  15. package/dist/hoi4_agent_tools/version.js +1 -1
  16. package/docs/gui.md +5 -1
  17. package/docs/map.md +60 -23
  18. package/package.json +1 -1
  19. package/schemas/configuration.schema.json +1 -1
  20. package/schemas/continuous-focus-palette.schema.json +1 -1
  21. package/schemas/custom-weighted-pool.schema.json +1 -1
  22. package/schemas/event-feature-manifest.schema.json +1 -1
  23. package/schemas/focus-plan.schema.json +1 -1
  24. package/schemas/focus-planning-sidecar.schema.json +1 -1
  25. package/schemas/gui-animation-source.schema.json +1 -1
  26. package/schemas/gui-helper.schema.json +1 -1
  27. package/schemas/gui-scenario.schema.json +1 -1
  28. package/schemas/map-operation.schema.json +228 -74
  29. package/schemas/operation-result.schema.json +1 -1
  30. package/schemas/probability-analysis-result.schema.json +1 -1
  31. package/schemas/probability-compare-input.schema.json +1 -1
  32. package/schemas/probability-evaluate-input.schema.json +1 -1
  33. package/schemas/probability-inspect-input.schema.json +1 -1
  34. package/schemas/probability-render-input.schema.json +1 -1
  35. package/schemas/probability-scenario-set.schema.json +1 -1
  36. package/schemas/probability-sequence-input.schema.json +1 -1
  37. package/schemas/probability-simulate-input.schema.json +1 -1
  38. package/schemas/probability-sweep-input.schema.json +1 -1
  39. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@
4
4
 
5
5
  No unreleased changes.
6
6
 
7
+ ## 2.7.0 - 2026-08-24
8
+
9
+ - Made scripted-GUI layout follow Clausewitz orientation, element-origin, center-position, relative-dimension, local-scale, container-clipping, and fixed text-bound behavior.
10
+ - Added deterministic nine-slice cornered tiles, filled/background progress bars, masked shields, and inline localisation GFX icons.
11
+ - Resolved ordinary and inline GUI assets case-insensitively through mod, dependency, and installed-game layers so vanilla graphics remain visible in mod renders.
12
+ - Removed synthetic state-color overlays from normal renders and retained actual sprite-frame evidence for the requested state.
13
+
14
+ ## 2.6.0 - 2026-08-22
15
+
16
+ - Added a shared complete map catalog with resolved names, source locations, geometry, memberships, networks, positions, buildings, ports, adjacency, and localisation evidence.
17
+ - Made map inspection return a full-map overview and a searchable, clickable HTML navigator with exact province hit testing and pixel/map coordinate lookup.
18
+ - 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.
19
+
7
20
  ## 2.5.3 - 2026-08-22
8
21
 
9
22
  - Resolved and embedded technology icons through the complete mod, dependency, and installed-game load order, including vanilla sprites used by mod content.
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).
@@ -12,6 +12,7 @@ function normalizeAssetPath(value) {
12
12
  return value
13
13
  .replaceAll('\\', '/')
14
14
  .replace(/^\/+|^\.\//u, '')
15
+ .replace(/\/+/gu, '/')
15
16
  .toLowerCase();
16
17
  }
17
18
  function parseFields(line) {
@@ -214,7 +215,12 @@ export class GuiAssetCatalog {
214
215
  return promise;
215
216
  }
216
217
  loadSpriteFrame(sprite, requestedFrame) {
217
- const texturePath = sprite.texturePath;
218
+ return this.loadSpriteTextureFrame(sprite, sprite.texturePath, requestedFrame, 'primary');
219
+ }
220
+ loadSecondarySpriteFrame(sprite, requestedFrame) {
221
+ return this.loadSpriteTextureFrame(sprite, sprite.texturePath2, requestedFrame, 'secondary');
222
+ }
223
+ loadSpriteTextureFrame(sprite, texturePath, requestedFrame, layer) {
218
224
  if (texturePath === undefined)
219
225
  return Promise.resolve(undefined);
220
226
  const frameCount = Math.max(1, sprite.frameCount);
@@ -226,13 +232,19 @@ export class GuiAssetCatalog {
226
232
  const textureIdentity = textureFile === undefined
227
233
  ? `${sprite.sourcePath}:${texturePath}`
228
234
  : `${textureFile.displayPath}:${textureFile.sha256}`;
229
- const key = `${textureIdentity}:${frameCount}:${frame}`;
235
+ const key = `${textureIdentity}:${frameCount}:${frame}:${layer}`;
230
236
  let promise = this.frames.get(key);
231
237
  if (promise === undefined) {
232
238
  promise = this.decodeSpriteFrame(sprite, texturePath, frameCount, frame, key);
233
239
  this.frames.set(key, promise);
234
240
  }
235
- return promise.then((decoded) => decoded === undefined ? undefined : { ...decoded, spriteName: sprite.name });
241
+ return promise.then((decoded) => decoded === undefined
242
+ ? undefined
243
+ : {
244
+ ...decoded,
245
+ spriteName: layer === 'primary' ? sprite.name : `${sprite.name}#secondary`,
246
+ texturePath,
247
+ });
236
248
  }
237
249
  async decodeSpriteFrame(sprite, texturePath, frameCount, frame, operationKey) {
238
250
  const raster = await this.loadRaster(texturePath, sprite.sourcePath);
@@ -9,24 +9,6 @@ import { emptyFidelityReport } from './types.js';
9
9
  const clickableTypes = /(?:button|checkbox|editbox|scrollbar|progressbar)/iu;
10
10
  const numericDynamicPlaceholder = '[X]';
11
11
  const textDynamicPlaceholder = '[dynamic_loc]';
12
- const partialSpriteSemantics = {
13
- textSpriteType: {
14
- field: 'text_sprite_semantics',
15
- detail: 'Engine text-sprite generation is not modelled; only the resolved primary texture is shown.',
16
- },
17
- corneredTileSpriteType: {
18
- field: 'cornered_tile_semantics',
19
- detail: 'Corner and edge tiling is not modelled; the primary texture is stretched as one frame.',
20
- },
21
- progressbarType: {
22
- field: 'progressbar_sprite_semantics',
23
- detail: 'Progressbar sprite composition is not modelled; only the resolved primary texture is shown.',
24
- },
25
- maskedShieldType: {
26
- field: 'masked_shield_semantics',
27
- detail: 'Mask, shield, and secondary-texture composition is not modelled; only the resolved primary texture is shown.',
28
- },
29
- };
30
12
  function property(attributes, ...names) {
31
13
  const lowered = new Set(names.map((name) => name.toLowerCase()));
32
14
  const entry = Object.entries(attributes).find(([name]) => lowered.has(name.toLowerCase()));
@@ -58,9 +40,12 @@ const hoi4TextColours = {
58
40
  b: '#4aa3df',
59
41
  g: '#87939e',
60
42
  };
43
+ const inlineIconMarkerStart = 0xe000;
44
+ const inlineIconMarkerEnd = 0xf8ff;
61
45
  function visibleHoiText(value) {
62
46
  const characters = [];
63
47
  const colours = [];
48
+ const inlineIcons = [];
64
49
  let colour;
65
50
  let hasColourMarkup = false;
66
51
  for (let index = 0; index < value.length; index += 1) {
@@ -76,8 +61,18 @@ function visibleHoiText(value) {
76
61
  let cursor = index + 1;
77
62
  while (cursor < value.length && !/[\s\u00a3]/u.test(value[cursor] ?? ''))
78
63
  cursor += 1;
79
- characters.push('\u25c6');
80
- colours.push(colour);
64
+ const token = value.slice(index + 1, cursor);
65
+ const markerCodePoint = inlineIconMarkerStart + inlineIcons.length;
66
+ if (token.length === 0 || markerCodePoint > inlineIconMarkerEnd) {
67
+ characters.push('\u25c6');
68
+ colours.push(colour);
69
+ }
70
+ else {
71
+ const marker = String.fromCodePoint(markerCodePoint);
72
+ inlineIcons.push({ marker, token });
73
+ characters.push(marker);
74
+ colours.push(colour);
75
+ }
81
76
  index = cursor - 1;
82
77
  continue;
83
78
  }
@@ -90,20 +85,26 @@ function visibleHoiText(value) {
90
85
  characters.push(character);
91
86
  colours.push(colour);
92
87
  }
93
- return { text: characters.join(''), colours, hasColourMarkup };
88
+ return { text: characters.join(''), colours, hasColourMarkup, inlineIcons };
94
89
  }
95
90
  function scalarNumber(value, reference) {
96
91
  if (typeof value === 'number')
97
92
  return value;
98
93
  if (typeof value !== 'string')
99
94
  return undefined;
100
- if (value.endsWith('%')) {
101
- const percent = Number(value.slice(0, -1));
95
+ if (/%%?$/u.test(value)) {
96
+ const percent = Number(value.replace(/%%?$/u, ''));
102
97
  return Number.isFinite(percent) ? (reference * percent) / 100 : undefined;
103
98
  }
104
99
  const parsed = Number(value);
105
100
  return Number.isFinite(parsed) ? parsed : undefined;
106
101
  }
102
+ function scaledNumber(value, reference, scale) {
103
+ const resolved = scalarNumber(value, reference);
104
+ if (resolved === undefined)
105
+ return undefined;
106
+ return typeof value === 'string' && /%%?$/u.test(value) ? resolved : resolved * scale;
107
+ }
107
108
  function scalarBoolean(value) {
108
109
  if (typeof value === 'boolean')
109
110
  return value;
@@ -240,7 +241,100 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
240
241
  }, 'GUI scope substitution');
241
242
  return { text, unresolved: [...new Set(unresolved)].sort(), missingLocalisation };
242
243
  }
243
- function wrapText(catalog, fontName, text, colours, fontSize, maximumWidth, work) {
244
+ function measureTextWithInlineIcons(catalog, fontName, text, fontSize, inlineIcons) {
245
+ if (inlineIcons.size === 0 || ![...inlineIcons.keys()].some((marker) => text.includes(marker)))
246
+ return catalog.measureText(fontName, text, fontSize);
247
+ let width = 0;
248
+ let lineHeight = fontSize * 1.2;
249
+ let source = 'approximation';
250
+ const missingGlyphs = new Set();
251
+ let segment = '';
252
+ const commit = () => {
253
+ if (segment.length === 0)
254
+ return;
255
+ const measured = catalog.measureText(fontName, segment, fontSize);
256
+ width += measured.width;
257
+ lineHeight = Math.max(lineHeight, measured.lineHeight);
258
+ source = measured.source;
259
+ for (const codePoint of measured.missingGlyphs)
260
+ missingGlyphs.add(codePoint);
261
+ segment = '';
262
+ };
263
+ for (const character of text) {
264
+ const icon = inlineIcons.get(character);
265
+ if (icon === undefined)
266
+ segment += character;
267
+ else {
268
+ commit();
269
+ width += icon.width;
270
+ lineHeight = Math.max(lineHeight, icon.height);
271
+ }
272
+ }
273
+ commit();
274
+ return { width, lineHeight, source, missingGlyphs: [...missingGlyphs] };
275
+ }
276
+ async function shapeTextWithInlineIcons(catalog, fontName, text, fontSize, lineIndex, inlineIcons) {
277
+ if (inlineIcons.size === 0 || ![...inlineIcons.keys()].some((marker) => text.includes(marker)))
278
+ return { glyphLine: await catalog.shapeText(fontName, text, fontSize), icons: [] };
279
+ const base = await catalog.shapeText(fontName, Array.from(text)
280
+ .filter((character) => !inlineIcons.has(character))
281
+ .join(''), fontSize);
282
+ const glyphs = [];
283
+ const missingGlyphs = new Set();
284
+ const sourceHashes = new Set([base.sourceHash]);
285
+ const sources = new Set([base.source]);
286
+ let baseline = base.baseline;
287
+ let baselineModelled = base.baselineModelled;
288
+ let penX = 0;
289
+ let segment = '';
290
+ const icons = [];
291
+ const commit = async () => {
292
+ if (segment.length === 0)
293
+ return;
294
+ const shaped = await catalog.shapeText(fontName, segment, fontSize);
295
+ sourceHashes.add(shaped.sourceHash);
296
+ sources.add(shaped.source);
297
+ baseline = Math.max(baseline, shaped.baseline);
298
+ baselineModelled &&= shaped.baselineModelled;
299
+ for (const codePoint of shaped.missingGlyphs)
300
+ missingGlyphs.add(codePoint);
301
+ glyphs.push(...shaped.glyphs.map((glyph) => ({ ...glyph, x: glyph.x + penX })));
302
+ penX += shaped.width;
303
+ segment = '';
304
+ };
305
+ for (const character of text) {
306
+ const icon = inlineIcons.get(character);
307
+ if (icon === undefined)
308
+ segment += character;
309
+ else {
310
+ await commit();
311
+ icons.push({
312
+ token: icon.token,
313
+ spriteName: icon.spriteName,
314
+ lineIndex,
315
+ offsetX: penX,
316
+ width: icon.width,
317
+ height: icon.height,
318
+ ...(icon.sprite === undefined ? {} : { sprite: icon.sprite }),
319
+ });
320
+ penX += icon.width;
321
+ }
322
+ }
323
+ await commit();
324
+ return {
325
+ glyphLine: {
326
+ source: sources.size === 1 ? [...sources][0] : 'deterministic-fallback',
327
+ sourceHash: sourceHashes.size === 1 ? [...sourceHashes][0] : hashCanonical([...sourceHashes].sort()),
328
+ width: penX,
329
+ baseline,
330
+ baselineModelled,
331
+ glyphs,
332
+ missingGlyphs: [...missingGlyphs].sort((left, right) => left - right),
333
+ },
334
+ icons,
335
+ };
336
+ }
337
+ function wrapText(catalog, fontName, text, colours, fontSize, maximumWidth, work, inlineIcons = new Map()) {
244
338
  const lines = [];
245
339
  const widths = [];
246
340
  const colourLines = [];
@@ -267,7 +361,7 @@ function wrapText(catalog, fontName, text, colours, fontSize, maximumWidth, work
267
361
  }
268
362
  const committedText = characters.join('');
269
363
  work.spendTextLayout('committed text line measurement');
270
- const committed = catalog.measureText(fontName, committedText, fontSize);
364
+ const committed = measureTextWithInlineIcons(catalog, fontName, committedText, fontSize, inlineIcons);
271
365
  lines.push(committedText);
272
366
  widths.push(committed.width);
273
367
  colourLines.push(lineColours);
@@ -300,12 +394,12 @@ function wrapText(catalog, fontName, text, colours, fontSize, maximumWidth, work
300
394
  let previousWordWidth = 0;
301
395
  for (const word of words) {
302
396
  work.spendTextLayout('text word measurement');
303
- const measuredWord = catalog.measureText(fontName, word.text, fontSize);
397
+ const measuredWord = measureTextWithInlineIcons(catalog, fontName, word.text, fontSize, inlineIcons);
304
398
  retainMissingGlyphs(measuredWord.missingGlyphs);
305
399
  let candidateWidth = measuredWord.width;
306
400
  if (previousWord !== undefined) {
307
401
  work.spendTextLayout('text word-boundary measurement');
308
- const boundary = catalog.measureText(fontName, `${previousWord} ${word.text}`, fontSize);
402
+ const boundary = measureTextWithInlineIcons(catalog, fontName, `${previousWord} ${word.text}`, fontSize, inlineIcons);
309
403
  retainMissingGlyphs(boundary.missingGlyphs);
310
404
  candidateWidth = currentWidth + boundary.width - previousWordWidth;
311
405
  }
@@ -332,20 +426,28 @@ function wrapText(catalog, fontName, text, colours, fontSize, maximumWidth, work
332
426
  missingGlyphs: [...missingGlyphs].sort((a, b) => a - b),
333
427
  };
334
428
  }
335
- function colourRunsForLine(catalog, fontName, line, colours, fontSize, work) {
429
+ function colourRunsForLine(catalog, fontName, line, colours, fontSize, work, inlineIcons = new Map()) {
336
430
  if (!colours.some((colour) => colour !== undefined) || line.length === 0)
337
431
  return [];
338
432
  const runs = [];
339
433
  let start = 0;
340
434
  let offsetX = 0;
341
435
  while (start < line.length) {
436
+ const inlineIcon = inlineIcons.get(line[start] ?? '');
437
+ if (inlineIcon !== undefined) {
438
+ offsetX += inlineIcon.width;
439
+ start += 1;
440
+ continue;
441
+ }
342
442
  const colour = colours[start] ?? defaultTextColour;
343
443
  let end = start + 1;
344
- while (end < line.length && (colours[end] ?? defaultTextColour) === colour)
444
+ while (end < line.length &&
445
+ !inlineIcons.has(line[end] ?? '') &&
446
+ (colours[end] ?? defaultTextColour) === colour)
345
447
  end += 1;
346
448
  const text = line.slice(start, end);
347
449
  work.spendTextLayout('localisation colour-run measurement');
348
- const width = catalog.measureText(fontName, text, fontSize).width;
450
+ const width = measureTextWithInlineIcons(catalog, fontName, text, fontSize, inlineIcons).width;
349
451
  runs.push({ text, colour, offsetX, width });
350
452
  offsetX += width;
351
453
  start = end;
@@ -384,6 +486,10 @@ function frameFor(element, sprite, scenario) {
384
486
  : 0;
385
487
  return Math.min(frameCount - 1, mapped);
386
488
  }
489
+ function textIconSpriteCandidates(token) {
490
+ const normalized = token.startsWith('GFX_') ? token : `GFX_${token}`;
491
+ return [normalized, `${normalized}_texticon`, `${normalized}_text_icon`];
492
+ }
387
493
  function alignment(attributes, buttonText) {
388
494
  const explicitFormat = scalarString(property(attributes, 'format'))?.toLowerCase();
389
495
  const format = explicitFormat ?? '';
@@ -403,6 +509,33 @@ function alignment(attributes, buttonText) {
403
509
  : 'top';
404
510
  return { horizontalAlignment, verticalAlignment };
405
511
  }
512
+ function anchorAxes(value) {
513
+ const normalized = value.toLowerCase().replaceAll('-', '_');
514
+ if (normalized === 'center_left')
515
+ return { horizontal: 'left', vertical: 'center' };
516
+ if (normalized === 'center_right')
517
+ return { horizontal: 'right', vertical: 'center' };
518
+ if (normalized === 'center_up' || normalized === 'center_upper')
519
+ return { horizontal: 'center', vertical: 'top' };
520
+ if (normalized === 'center_down' || normalized === 'center_lower')
521
+ return { horizontal: 'center', vertical: 'bottom' };
522
+ if (normalized === 'upper_right')
523
+ return { horizontal: 'right', vertical: 'top' };
524
+ if (normalized === 'lower_left')
525
+ return { horizontal: 'left', vertical: 'bottom' };
526
+ if (normalized === 'lower_right')
527
+ return { horizontal: 'right', vertical: 'bottom' };
528
+ if (normalized === 'center')
529
+ return { horizontal: 'center', vertical: 'center' };
530
+ return { horizontal: 'left', vertical: 'top' };
531
+ }
532
+ function anchoredCoordinate(start, span, offset, anchor) {
533
+ return anchor === 'right' || anchor === 'bottom'
534
+ ? start + span + offset
535
+ : anchor === 'center'
536
+ ? start + span / 2 + offset
537
+ : start + offset;
538
+ }
406
539
  class GuiSceneWorkBudget {
407
540
  work = 0;
408
541
  elements = 0;
@@ -450,7 +583,7 @@ class GuiSceneWorkBudget {
450
583
  this.textLayoutOperations += amount;
451
584
  }
452
585
  }
453
- async function layoutElement(definition, parentRect, inheritedClip, parentScale, depth, context, instanceSuffix = '', rowIndex, rowValues, parentInstanceId) {
586
+ async function layoutElement(definition, parentRect, inheritedClip, parentScale, depth, context, instanceSuffix = '', rowIndex, rowValues, parentInstanceId, inheritedOrientation = 'upper_left') {
454
587
  context.work.admitElement(depth);
455
588
  const { scenario, catalog, fidelity, diagnostics } = context;
456
589
  const instanceId = instanceSuffix.length === 0 ? definition.id : `${definition.id}${instanceSuffix}`;
@@ -458,12 +591,10 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
458
591
  const scale = parentScale * localScale;
459
592
  const position = objectProperty(property(definition.attributes, 'position'));
460
593
  const size = objectProperty(property(definition.attributes, 'size'));
461
- const localX = scalarNumber(position === undefined ? undefined : property(position, 'x'), parentRect.width) ??
462
- 0;
463
- const localY = scalarNumber(position === undefined ? undefined : property(position, 'y'), parentRect.height) ??
464
- 0;
465
- let width = (scalarNumber(size === undefined ? undefined : property(size, 'width', 'x'), parentRect.width) ?? 0) * scale;
466
- let height = (scalarNumber(size === undefined ? undefined : property(size, 'height', 'y'), parentRect.height) ?? 0) * scale;
594
+ const localX = scaledNumber(position === undefined ? undefined : property(position, 'x'), parentRect.width, parentScale) ?? 0;
595
+ const localY = scaledNumber(position === undefined ? undefined : property(position, 'y'), parentRect.height, parentScale) ?? 0;
596
+ let width = scaledNumber(size === undefined ? undefined : property(size, 'width', 'x'), parentRect.width, scale) ?? 0;
597
+ let height = scaledNumber(size === undefined ? undefined : property(size, 'height', 'y'), parentRect.height, scale) ?? 0;
467
598
  const background = objectProperty(property(definition.attributes, 'background'));
468
599
  const spriteName = scalarString(property(definition.attributes, 'spriteType', 'quadTextureSprite')) ??
469
600
  scalarString(background === undefined
@@ -471,6 +602,8 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
471
602
  : property(background, 'spriteType', 'quadTextureSprite'));
472
603
  const spriteDefinition = spriteName === undefined ? undefined : context.spritesByName.get(spriteName.toLowerCase());
473
604
  let sprite;
605
+ let secondarySprite;
606
+ let spriteRenderMode;
474
607
  if (spriteName !== undefined && spriteDefinition === undefined) {
475
608
  const partialInventory = context.graph.edges.some((edge) => edge.kind === 'uses_sprite' &&
476
609
  edge.from === definition.id &&
@@ -483,16 +616,15 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
483
616
  }
484
617
  else if (spriteDefinition !== undefined) {
485
618
  const partialAppearance = [];
486
- const specialSemantics = partialSpriteSemantics[spriteDefinition.spriteType];
487
- if (specialSemantics !== undefined) {
488
- addFidelity(fidelity, 'unsupported', specialSemantics.field, specialSemantics.detail, definition);
489
- partialAppearance.push(specialSemantics.detail);
490
- }
491
- if (spriteDefinition.texturePath2 !== undefined) {
492
- const detail = `Secondary texture ${spriteDefinition.texturePath2} is retained in the source graph but is not composited.`;
493
- addFidelity(fidelity, 'unsupported', 'textureFile2', detail, definition);
494
- partialAppearance.push(detail);
495
- }
619
+ const spriteType = spriteDefinition.spriteType.toLowerCase();
620
+ spriteRenderMode =
621
+ spriteType === 'corneredtilespritetype'
622
+ ? 'cornered-tile'
623
+ : spriteType === 'progressbartype'
624
+ ? 'progressbar'
625
+ : spriteType === 'maskedshieldtype'
626
+ ? 'masked-shield'
627
+ : 'stretch';
496
628
  if (spriteDefinition.effectFile !== undefined) {
497
629
  const detail = `Effect ${spriteDefinition.effectFile} is retained in the source graph but is not executed by the offline renderer.`;
498
630
  addFidelity(fidelity, 'unsupported', 'effectFile', detail, definition);
@@ -502,6 +634,8 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
502
634
  diagnostics.push(diagnostic('GUI_SPRITE_RENDER_PARTIAL', 'warning', `Element ${definition.name} has a partial sprite appearance: ${partialAppearance.join(' ')}`, definition));
503
635
  const frame = frameFor(definition, spriteDefinition, scenario);
504
636
  sprite = await catalog.loadSpriteFrame(spriteDefinition, frame);
637
+ if (spriteDefinition.texturePath2 !== undefined)
638
+ secondarySprite = await catalog.loadSecondarySpriteFrame(spriteDefinition, frame);
505
639
  if (!sprite?.supported) {
506
640
  addFidelity(fidelity, 'unsupported', 'texture', sprite?.reason ?? `Sprite ${spriteDefinition.name} has no texture.`, definition);
507
641
  diagnostics.push(diagnostic('GUI_TEXTURE_UNSUPPORTED', 'warning', sprite?.reason ?? `Sprite ${spriteDefinition.name} has no texture.`, definition));
@@ -514,6 +648,20 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
514
648
  addFidelity(fidelity, partialAppearance.length === 0 ? 'modelled' : 'approximated', 'sprite_frame', partialAppearance.length === 0
515
649
  ? `${spriteDefinition.name} frame ${sprite.frame + 1}/${sprite.frameCount}`
516
650
  : `${spriteDefinition.name} frame ${sprite.frame + 1}/${sprite.frameCount} shows only the primary resolved texture; additional sprite semantics are omitted.`, definition);
651
+ if (spriteRenderMode === 'cornered-tile')
652
+ addFidelity(fidelity, spriteDefinition.borderSize === undefined ? 'approximated' : 'modelled', 'cornered_tile_composition', spriteDefinition.borderSize === undefined
653
+ ? `${spriteDefinition.name} has no borderSize, so the full texture fills the element.`
654
+ : `${spriteDefinition.name} uses fixed corners and edges with a ${spriteDefinition.borderSize.width}x${spriteDefinition.borderSize.height} source border${spriteDefinition.tilingCenter === true ? ' and a tiled center' : ''}.`, definition);
655
+ if (spriteRenderMode === 'progressbar' || spriteRenderMode === 'masked-shield') {
656
+ const detail = secondarySprite?.supported === true
657
+ ? `Primary and secondary textures for ${spriteDefinition.name} are composited.`
658
+ : `Secondary texture for ${spriteDefinition.name} could not be rendered.`;
659
+ addFidelity(fidelity, secondarySprite?.supported === true ? 'modelled' : 'missing', spriteRenderMode === 'progressbar'
660
+ ? 'progressbar_composition'
661
+ : 'masked_shield_composition', detail, definition);
662
+ if (secondarySprite !== undefined && !secondarySprite.supported)
663
+ diagnostics.push(diagnostic('GUI_SECONDARY_TEXTURE_UNSUPPORTED', 'warning', secondarySprite.reason ?? detail, definition));
664
+ }
517
665
  if (spriteDefinition.frameAnimated) {
518
666
  addFidelity(fidelity, spriteDefinition.looping === undefined ? 'approximated' : 'modelled', 'animation_looping', spriteDefinition.looping === false
519
667
  ? 'Animation stops on its final frame.'
@@ -549,14 +697,50 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
549
697
  resolvedFontMetrics.nativeSize ??
550
698
  context.catalog.fontDefinition(fontName ?? '')?.size ??
551
699
  16;
552
- const maxWidth = (scalarNumber(property(definition.attributes, 'maxWidth'), width / scale) ?? width / scale) *
553
- scale;
554
- const wrapped = wrapText(catalog, fontName, displayText, visibleText.colours, fontSize * scale, maxWidth, context.work);
700
+ const resolvedInlineIcons = new Map();
701
+ for (const inlineIcon of visibleText.inlineIcons) {
702
+ const candidates = textIconSpriteCandidates(inlineIcon.token);
703
+ const iconDefinition = candidates
704
+ .map((candidate) => context.spritesByName.get(candidate.toLowerCase()))
705
+ .find((candidate) => candidate !== undefined);
706
+ const iconHeight = fontSize * scale;
707
+ const iconSprite = iconDefinition === undefined ? undefined : await catalog.loadSpriteFrame(iconDefinition, 0);
708
+ const iconWidth = iconSprite?.supported === true && iconSprite.height > 0
709
+ ? iconHeight * (iconSprite.width / iconSprite.height)
710
+ : iconHeight;
711
+ const spriteName = iconDefinition?.name ?? candidates[0];
712
+ resolvedInlineIcons.set(inlineIcon.marker, {
713
+ marker: inlineIcon.marker,
714
+ token: inlineIcon.token,
715
+ spriteName,
716
+ width: iconWidth,
717
+ height: iconHeight,
718
+ ...(iconSprite === undefined ? {} : { sprite: iconSprite }),
719
+ });
720
+ if (iconSprite?.supported === true)
721
+ addFidelity(fidelity, 'modelled', 'inline_text_icon', `Localisation icon £${inlineIcon.token} uses ${spriteName}.`, definition);
722
+ else {
723
+ addFidelity(fidelity, 'missing', 'inline_text_icon', iconSprite?.reason ?? `No sprite resolves localisation icon £${inlineIcon.token}.`, definition);
724
+ diagnostics.push(diagnostic('GUI_TEXT_ICON_MISSING', 'warning', iconSprite?.reason ?? `No sprite resolves localisation icon £${inlineIcon.token}.`, definition));
725
+ }
726
+ }
727
+ const declaredMaxWidth = scaledNumber(property(definition.attributes, 'maxWidth'), parentRect.width, scale);
728
+ const declaredMaxHeight = scaledNumber(property(definition.attributes, 'maxHeight'), parentRect.height, scale);
729
+ if (width === 0 && declaredMaxWidth !== undefined)
730
+ width = declaredMaxWidth;
731
+ if (height === 0 && declaredMaxHeight !== undefined)
732
+ height = declaredMaxHeight;
733
+ const maxWidth = declaredMaxWidth ?? width;
734
+ const wrapped = wrapText(catalog, fontName, displayText, visibleText.colours, fontSize * scale, maxWidth, context.work, resolvedInlineIcons);
555
735
  const glyphLines = [];
556
- for (const line of wrapped.lines)
557
- glyphLines.push(await catalog.shapeText(fontName, line, fontSize * scale));
736
+ const inlineIcons = [];
737
+ for (const [lineIndex, line] of wrapped.lines.entries()) {
738
+ const shaped = await shapeTextWithInlineIcons(catalog, fontName, line, fontSize * scale, lineIndex, resolvedInlineIcons);
739
+ glyphLines.push(shaped.glyphLine);
740
+ inlineIcons.push(...shaped.icons);
741
+ }
558
742
  const colourRuns = visibleText.hasColourMarkup
559
- ? wrapped.lines.map((line, index) => colourRunsForLine(catalog, fontName, line, wrapped.colourLines[index] ?? [], fontSize * scale, context.work))
743
+ ? wrapped.lines.map((line, index) => colourRunsForLine(catalog, fontName, line, wrapped.colourLines[index] ?? [], fontSize * scale, context.work, resolvedInlineIcons))
560
744
  : undefined;
561
745
  const maximumLineWidth = wrapped.widths.reduce((maximum, value) => Math.max(maximum, value), 0);
562
746
  if (width === 0)
@@ -579,8 +763,10 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
579
763
  glyphLines,
580
764
  overflowX: width > 0 && measuredWidth > width + 0.01,
581
765
  overflowY: height > 0 && measuredHeight > height + 0.01,
766
+ fixedSize: scalarBoolean(property(definition.attributes, 'fixedsize')) ?? false,
582
767
  unresolvedTokens: resolved.unresolved,
583
768
  ...(colourRuns === undefined ? {} : { colourRuns }),
769
+ ...(inlineIcons.length === 0 ? {} : { inlineIcons }),
584
770
  };
585
771
  if (wrapped.metricSource === 'approximation')
586
772
  addFidelity(fidelity, 'approximated', 'font_metrics', `No supplied font metrics for ${fontName ?? '<default>'}.`, definition);
@@ -604,31 +790,29 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
604
790
  if (resolved.unresolved.length > 0)
605
791
  addFidelity(fidelity, 'unresolved', 'dynamic_text', `Unresolved tokens: ${resolved.unresolved.join(', ')}.`, definition);
606
792
  }
607
- const orientation = scalarString(property(definition.attributes, 'orientation'))?.toLowerCase() ?? 'upper_left';
793
+ const orientation = scalarString(property(definition.attributes, 'orientation'))?.toLowerCase() ??
794
+ inheritedOrientation;
608
795
  const origo = scalarString(property(definition.attributes, 'origo'))?.toLowerCase() ?? 'upper_left';
609
- let x = parentRect.x + localX * scale;
610
- let y = parentRect.y + localY * scale;
611
- if (orientation.includes('right'))
612
- x = parentRect.x + parentRect.width - localX * scale - width;
613
- else if (orientation.includes('center'))
614
- x = parentRect.x + parentRect.width / 2 + localX * scale;
615
- if (orientation.includes('lower') || orientation.includes('bottom'))
616
- y = parentRect.y + parentRect.height - localY * scale - height;
617
- else if (orientation.includes('center'))
618
- y = parentRect.y + parentRect.height / 2 + localY * scale;
619
- if (origo.includes('center')) {
796
+ const orientationAxes = anchorAxes(orientation);
797
+ let x = anchoredCoordinate(parentRect.x, parentRect.width, localX, orientationAxes.horizontal);
798
+ let y = anchoredCoordinate(parentRect.y, parentRect.height, localY, orientationAxes.vertical);
799
+ const centerPosition = scalarBoolean(property(definition.attributes, 'centerposition')) ?? false;
800
+ const origoAxes = centerPosition
801
+ ? { horizontal: 'center', vertical: 'center' }
802
+ : anchorAxes(origo);
803
+ if (origoAxes.horizontal === 'center') {
620
804
  x -= width / 2;
621
- y -= height / 2;
622
- }
623
- else {
624
- if (origo.includes('right'))
625
- x -= width;
626
- if (origo.includes('lower') || origo.includes('bottom'))
627
- y -= height;
628
805
  }
806
+ else if (origoAxes.horizontal === 'right')
807
+ x -= width;
808
+ if (origoAxes.vertical === 'center')
809
+ y -= height / 2;
810
+ else if (origoAxes.vertical === 'bottom')
811
+ y -= height;
629
812
  const scrollOffset = (scenario.scrollOffsets[definition.name] ?? scenario.scrollOffsets[definition.id] ?? 0) * scale;
630
813
  const unclippedRect = { x, y, width: Math.max(0, width), height: Math.max(0, height) };
631
- const ownClipping = scalarBoolean(property(definition.attributes, 'clipping')) ?? false;
814
+ const ownClipping = scalarBoolean(property(definition.attributes, 'clipping')) ??
815
+ (background !== undefined && /(?:containerwindow|windowtype)/iu.test(definition.elementType));
632
816
  const availableClip = inheritedClip === undefined ? unclippedRect : rectIntersection(inheritedClip, unclippedRect);
633
817
  const clipRect = inheritedClip;
634
818
  const clipped = clipRect !== undefined &&
@@ -681,6 +865,17 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
681
865
  state,
682
866
  ...(progressRatio === undefined ? {} : { progressRatio }),
683
867
  ...(sprite === undefined ? {} : { sprite }),
868
+ ...(secondarySprite === undefined ? {} : { secondarySprite }),
869
+ ...(spriteRenderMode === undefined ? {} : { spriteRenderMode }),
870
+ ...(spriteDefinition?.borderSize === undefined
871
+ ? {}
872
+ : { spriteBorderSize: spriteDefinition.borderSize }),
873
+ ...(spriteDefinition?.tilingCenter === undefined
874
+ ? {}
875
+ : { spriteTilingCenter: spriteDefinition.tilingCenter }),
876
+ ...(spriteDefinition === undefined
877
+ ? {}
878
+ : { progressHorizontal: spriteDefinition.horizontal ?? true }),
684
879
  ...(text === undefined ? {} : { text }),
685
880
  sourcePath: definition.sourcePath,
686
881
  ...(definition.location === undefined ? {} : { location: definition.location }),
@@ -735,7 +930,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
735
930
  ...childParentRect,
736
931
  y: childParentRect.y + rowY - originalY * scale,
737
932
  };
738
- await layoutElement(child, shiftedParent, childClip, scale, depth + 1, context, `${instanceSuffix}#row-${index}`, index, row, instanceId);
933
+ await layoutElement(child, shiftedParent, childClip, scale, depth + 1, context, `${instanceSuffix}#row-${index}`, index, row, instanceId, origo);
739
934
  const rendered = context.instancesById.get(`${child.id}${instanceSuffix}#row-${index}`);
740
935
  rowHeight = Math.max(rowHeight, rendered?.unclippedRect.height ?? 0);
741
936
  }
@@ -746,7 +941,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
746
941
  else {
747
942
  for (const child of childDefinitions) {
748
943
  context.work.spend('nested child expansion');
749
- await layoutElement(child, childParentRect, childClip, scale, depth + 1, context, instanceSuffix, rowIndex, rowValues, instanceId);
944
+ await layoutElement(child, childParentRect, childClip, scale, depth + 1, context, instanceSuffix, rowIndex, rowValues, instanceId, origo);
750
945
  }
751
946
  }
752
947
  }