hoi4-agent-tools 3.3.0 → 3.4.1

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 (89) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/hoi4_agent_tools/core/condition-evaluator.js +20 -8
  3. package/dist/hoi4_agent_tools/core/helper-expansion.js +3 -0
  4. package/dist/hoi4_agent_tools/core/index.js +1 -0
  5. package/dist/hoi4_agent_tools/core/job-worker-host.js +19 -1
  6. package/dist/hoi4_agent_tools/core/operation-result.js +4 -1
  7. package/dist/hoi4_agent_tools/event/comparison-selection.js +78 -0
  8. package/dist/hoi4_agent_tools/event/helper-expansion.js +48 -0
  9. package/dist/hoi4_agent_tools/event/operations.js +32 -10
  10. package/dist/hoi4_agent_tools/event/service.js +21 -2
  11. package/dist/hoi4_agent_tools/focus/compact.js +241 -27
  12. package/dist/hoi4_agent_tools/focus/layout.js +119 -8
  13. package/dist/hoi4_agent_tools/focus/render.js +62 -0
  14. package/dist/hoi4_agent_tools/focus/scenario.js +99 -0
  15. package/dist/hoi4_agent_tools/focus/tool-operations.js +30 -4
  16. package/dist/hoi4_agent_tools/gui/assets.js +54 -4
  17. package/dist/hoi4_agent_tools/gui/layout.js +935 -104
  18. package/dist/hoi4_agent_tools/gui/renderer.js +47 -17
  19. package/dist/hoi4_agent_tools/gui/shader.js +515 -0
  20. package/dist/hoi4_agent_tools/gui/source-graph.js +166 -6
  21. package/dist/hoi4_agent_tools/gui/studio.js +245 -19
  22. package/dist/hoi4_agent_tools/gui/tool-operations.js +9 -0
  23. package/dist/hoi4_agent_tools/gui/validators.js +41 -12
  24. package/dist/hoi4_agent_tools/map/operations.js +70 -1
  25. package/dist/hoi4_agent_tools/map/render.js +70 -1
  26. package/dist/hoi4_agent_tools/map/service.js +30 -6
  27. package/dist/hoi4_agent_tools/map/tool-operations.js +67 -1
  28. package/dist/hoi4_agent_tools/mcp/tools/event.js +10 -1
  29. package/dist/hoi4_agent_tools/mcp/tools/focus.js +14 -4
  30. package/dist/hoi4_agent_tools/mcp/tools/gui.js +5 -1
  31. package/dist/hoi4_agent_tools/mcp/tools/map.js +12 -2
  32. package/dist/hoi4_agent_tools/mcp/tools/probability.js +5 -0
  33. package/dist/hoi4_agent_tools/mechanic/interpreter.js +91 -30
  34. package/dist/hoi4_agent_tools/mechanic/source.js +19 -4
  35. package/dist/hoi4_agent_tools/probability/evaluation.js +3 -0
  36. package/dist/hoi4_agent_tools/probability/operations.js +8 -0
  37. package/dist/hoi4_agent_tools/probability/service.js +33 -12
  38. package/dist/hoi4_agent_tools/probability/source-analysis.js +1 -0
  39. package/dist/hoi4_agent_tools/schemas/event.js +8 -0
  40. package/dist/hoi4_agent_tools/schemas/focus-requests.js +40 -1
  41. package/dist/hoi4_agent_tools/schemas/gui-requests.js +8 -0
  42. package/dist/hoi4_agent_tools/schemas/map-requests.js +22 -1
  43. package/dist/hoi4_agent_tools/schemas/technology.js +7 -0
  44. package/dist/hoi4_agent_tools/technology/graph.js +25 -10
  45. package/dist/hoi4_agent_tools/technology/operations.js +29 -1
  46. package/dist/hoi4_agent_tools/technology/render.js +287 -31
  47. package/dist/hoi4_agent_tools/technology/service.js +56 -8
  48. package/dist/hoi4_agent_tools/technology/source-analysis.js +166 -3
  49. package/dist/hoi4_agent_tools/version.js +1 -1
  50. package/docs/events.md +4 -2
  51. package/docs/focus.md +10 -0
  52. package/docs/gui.md +50 -3
  53. package/docs/jobs.md +1 -0
  54. package/docs/map.md +8 -0
  55. package/docs/mechanics.md +2 -1
  56. package/docs/probability.md +3 -1
  57. package/docs/technology.md +11 -2
  58. package/package.json +1 -1
  59. package/schemas/configuration.schema.json +1 -1
  60. package/schemas/continuous-focus-palette.schema.json +1 -1
  61. package/schemas/custom-weighted-pool.schema.json +1 -1
  62. package/schemas/decision-inspect-input.schema.json +1 -1
  63. package/schemas/event-feature-manifest.schema.json +1 -1
  64. package/schemas/focus-plan.schema.json +1 -1
  65. package/schemas/focus-planning-sidecar.schema.json +1 -1
  66. package/schemas/gui-animation-source.schema.json +1 -1
  67. package/schemas/gui-helper.schema.json +1 -1
  68. package/schemas/gui-scenario.schema.json +1 -1
  69. package/schemas/helper-expansion-request.schema.json +1 -1
  70. package/schemas/helper-expansion-summary.schema.json +1 -1
  71. package/schemas/impact-inspect-input.schema.json +1 -1
  72. package/schemas/map-operation.schema.json +1 -1
  73. package/schemas/mechanic-test-input.schema.json +1 -1
  74. package/schemas/operation-result.schema.json +1 -1
  75. package/schemas/package-check-input.schema.json +1 -1
  76. package/schemas/package-manifest.schema.json +1 -1
  77. package/schemas/probability-analysis-result.schema.json +1 -1
  78. package/schemas/probability-compare-input.schema.json +1 -1
  79. package/schemas/probability-evaluate-input.schema.json +1 -1
  80. package/schemas/probability-inspect-input.schema.json +1 -1
  81. package/schemas/probability-render-input.schema.json +1 -1
  82. package/schemas/probability-scenario-set.schema.json +1 -1
  83. package/schemas/probability-sequence-input.schema.json +1 -1
  84. package/schemas/probability-simulate-input.schema.json +1 -1
  85. package/schemas/probability-sweep-input.schema.json +1 -1
  86. package/schemas/scenario-suite.schema.json +1 -1
  87. package/schemas/scenario-test-input.schema.json +1 -1
  88. package/schemas/shared-scenario.schema.json +1 -1
  89. package/server.json +2 -2
@@ -6,6 +6,9 @@ import { GuiAssetCatalog } from './assets.js';
6
6
  import { GUI_GRAPH_MAX_EDGES, GUI_GRAPH_MAX_ELEMENTS, GUI_GRAPH_MAX_NODES, GUI_SCENE_MAX_DEPTH, GUI_SCENE_MAX_ELEMENTS, GUI_SCENE_MAX_TEXT_CHARACTERS, GUI_SCENE_MAX_TEXT_LAYOUT_OPERATIONS, GUI_SCENE_MAX_WORK, GUI_TEXT_MAX_CHARACTERS, GUI_TEXT_MAX_MISSING_GLYPH_SAMPLES, } from './limits.js';
7
7
  import { guiElementAttributeFidelity } from './source-graph.js';
8
8
  import { emptyFidelityReport } from './types.js';
9
+ import { ClausewitzEvaluationDefinitions } from '../core/clausewitz-evaluation.js';
10
+ import { evaluateGuiCondition, guiConditionScenario } from './scenario-conditions.js';
11
+ import { parseClausewitz } from '../core/source/index.js';
9
12
  const clickableTypes = /(?:button|checkbox|editbox|scrollbar|progressbar)/iu;
10
13
  const numericDynamicPlaceholder = '[X]';
11
14
  const textDynamicPlaceholder = '[dynamic_loc]';
@@ -25,6 +28,8 @@ function scalarString(value) {
25
28
  return undefined;
26
29
  }
27
30
  const defaultTextColour = '#f5f2e8';
31
+ // Native text-box default, independently visible in installed inlays without maxWidth.
32
+ const nativeTextBoxWidth = 200;
28
33
  const hoi4TextColours = {
29
34
  C: '#23ceff',
30
35
  L: '#c3b091',
@@ -117,9 +122,9 @@ function scaledNumber(value, reference, scale) {
117
122
  function scalarBoolean(value) {
118
123
  if (typeof value === 'boolean')
119
124
  return value;
120
- if (value === 'yes' || value === 'true')
125
+ if (value === 'yes' || value === 'true' || value === 1)
121
126
  return true;
122
- if (value === 'no' || value === 'false')
127
+ if (value === 'no' || value === 'false' || value === 0)
123
128
  return false;
124
129
  return undefined;
125
130
  }
@@ -483,7 +488,7 @@ function colourRunsForLine(catalog, fontName, line, colours, fontSize, work, inl
483
488
  }
484
489
  return runs;
485
490
  }
486
- function frameFor(element, sprite, scenario, rowValues) {
491
+ function frameFor(element, sprite, scenario, rowValues, effectiveState) {
487
492
  const frameCount = Math.max(1, sprite.frameCount);
488
493
  const selected = scenario.selectedFrames[element.name] ?? scenario.selectedFrames[sprite.name];
489
494
  if (selected !== undefined)
@@ -493,6 +498,11 @@ function frameFor(element, sprite, scenario, rowValues) {
493
498
  scenario.scriptedGui[`${element.name}.frame`];
494
499
  if (typeof scriptedFrame === 'number' && Number.isFinite(scriptedFrame))
495
500
  return Math.min(frameCount - 1, Math.max(0, Math.trunc(scriptedFrame > 0 ? scriptedFrame - 1 : scriptedFrame)));
501
+ const state = effectiveState ?? scenario.elementStates[element.name] ?? scenario.state;
502
+ const checked = rowValues?.[`${element.name}.checked`] ?? scenario.values[`${element.name}.checked`];
503
+ if (/checkbox/iu.test(element.elementType) &&
504
+ (typeof checked === 'boolean' || state === 'selected' || state === 'active'))
505
+ return Math.min(frameCount - 1, checked === false ? 0 : 1);
496
506
  const explicit = scalarNumber(property(element.attributes, 'frame'), frameCount);
497
507
  if (explicit !== undefined)
498
508
  return Math.min(frameCount - 1, Math.max(0, Math.trunc(explicit > 0 ? explicit - 1 : explicit)));
@@ -510,7 +520,10 @@ function frameFor(element, sprite, scenario, rowValues) {
510
520
  ? frameCount - 1
511
521
  : Math.min(frameCount - 1, Math.floor(phase * framesPerSecond));
512
522
  }
513
- const state = scenario.elementStates[element.name] ?? scenario.state;
523
+ // Effects select a shader entry point, not an unrelated atlas frame.
524
+ // Explicit frames and frame animation above remain independent of button state.
525
+ if (sprite.effectFile !== undefined || /checkbox/iu.test(element.elementType))
526
+ return 0;
514
527
  const mapped = state === 'hover'
515
528
  ? 1
516
529
  : state === 'selected' || state === 'active'
@@ -525,7 +538,9 @@ function textIconSpriteCandidates(token) {
525
538
  return [normalized, `${normalized}_texticon`, `${normalized}_text_icon`];
526
539
  }
527
540
  function alignment(attributes, buttonText) {
528
- const explicitFormat = scalarString(property(attributes, 'format'))?.toLowerCase();
541
+ const explicitFormat = scalarString(property(attributes, 'format'))
542
+ ?.toLowerCase()
543
+ .replaceAll('centre', 'center');
529
544
  const format = explicitFormat ?? '';
530
545
  const horizontalAlignment = format.includes('center')
531
546
  ? 'center'
@@ -534,13 +549,18 @@ function alignment(attributes, buttonText) {
534
549
  : buttonText && explicitFormat === undefined
535
550
  ? 'center'
536
551
  : 'left';
537
- const verticalAlignment = format.includes('bottom')
538
- ? 'bottom'
539
- : format.includes('center')
540
- ? 'center'
541
- : buttonText && explicitFormat === undefined
552
+ const vertical = scalarString(property(attributes, 'vertical_alignment'))
553
+ ?.toLowerCase()
554
+ .replaceAll('centre', 'center');
555
+ const verticalAlignment = vertical === 'top'
556
+ ? 'top'
557
+ : vertical === 'bottom' || format.includes('bottom')
558
+ ? 'bottom'
559
+ : vertical === 'center'
542
560
  ? 'center'
543
- : 'top';
561
+ : buttonText && explicitFormat === undefined
562
+ ? 'center'
563
+ : 'top';
544
564
  return { horizontalAlignment, verticalAlignment };
545
565
  }
546
566
  function anchorAxes(value) {
@@ -570,10 +590,109 @@ function anchoredCoordinate(start, span, offset, anchor) {
570
590
  ? start + span / 2 + offset
571
591
  : start + offset;
572
592
  }
593
+ function legacyRowSuffix(instanceSuffix) {
594
+ return instanceSuffix.replace(/#list-[^#]+/gu, '');
595
+ }
596
+ function explicitElementVisibility(definition, scenario, rowValues) {
597
+ const scripted = rowValues?.[`${definition.name}.visible`] ??
598
+ scenario.values[`${definition.name}.visible`] ??
599
+ scenario.scriptedGui[`${definition.name}.visible`];
600
+ return (scenario.visibility[definition.name] ??
601
+ scenario.visibility[definition.id] ??
602
+ (definition.sourceOwnerId === undefined
603
+ ? undefined
604
+ : scenario.visibility[definition.sourceOwnerId]) ??
605
+ (typeof scripted === 'boolean' ? scripted : undefined));
606
+ }
607
+ function clippedByChain(chain) {
608
+ let result;
609
+ for (const clip of chain)
610
+ result =
611
+ result === undefined
612
+ ? clip
613
+ : (rectIntersection(result, clip) ?? { ...clip, width: 0, height: 0 });
614
+ return result;
615
+ }
616
+ function refreshSceneClips(context) {
617
+ for (const element of context.output) {
618
+ const geometry = context.geometry.get(element.id);
619
+ context.work.spend('resolved scroll clipping', geometry.clipChain.length + 1);
620
+ const clip = clippedByChain(geometry.clipChain);
621
+ const intersection = clip === undefined ? element.unclippedRect : rectIntersection(element.unclippedRect, clip);
622
+ if (clip === undefined)
623
+ delete element.clipRect;
624
+ else
625
+ element.clipRect = { ...clip };
626
+ element.rect =
627
+ intersection === undefined
628
+ ? { ...element.unclippedRect, width: 0, height: 0 }
629
+ : { ...intersection };
630
+ element.clipped =
631
+ clip !== undefined &&
632
+ (intersection === undefined || !equalRect(element.unclippedRect, intersection));
633
+ element.visible = geometry.appearanceVisible && intersection !== undefined;
634
+ element.visibilityReason = element.visible
635
+ ? 'visible'
636
+ : geometry.appearanceVisible
637
+ ? 'outside_clip'
638
+ : (element.visibilityReason ?? 'visibility_false');
639
+ if (clickableTypes.test(element.elementType)) {
640
+ const reason = element.visible ? geometry.interactionBlock : 'hidden';
641
+ if (reason === undefined)
642
+ delete element.disabledReason;
643
+ else
644
+ element.disabledReason = reason;
645
+ element.clickable = reason === undefined;
646
+ }
647
+ }
648
+ }
649
+ function attachedScrollbars(definition, context) {
650
+ if (!/^(?:containerwindowtype|windowtype)$/iu.test(definition.elementType))
651
+ return [];
652
+ const result = [];
653
+ const add = (candidate, horizontal, reference) => {
654
+ if (candidate?.elementType.toLowerCase() !== 'extendedscrollbartype') {
655
+ context.diagnostics.push(diagnostic('GUI_SCROLLBAR_TEMPLATE_MISSING', 'warning', `Container ${definition.name} cannot resolve extended scrollbar ${reference}.`, definition));
656
+ addFidelity(context.fidelity, 'missing', 'attached_scrollbar', `Extended scrollbar ${reference} is missing.`, definition);
657
+ return;
658
+ }
659
+ if ((scalarBoolean(property(candidate.attributes, 'horizontal')) ?? false) !== horizontal) {
660
+ context.diagnostics.push(diagnostic('GUI_SCROLLBAR_AXIS_MISMATCH', 'warning', `Scrollbar ${reference} has the wrong axis for container ${definition.name}.`, definition));
661
+ addFidelity(context.fidelity, 'unsupported', 'attached_scrollbar', `Scrollbar ${reference} has the wrong axis.`, definition);
662
+ return;
663
+ }
664
+ if (result.some((entry) => entry.horizontal === horizontal)) {
665
+ context.diagnostics.push(diagnostic('GUI_SCROLLBAR_AXIS_DUPLICATE', 'warning', `Container ${definition.name} assigns more than one ${horizontal ? 'horizontal' : 'vertical'} scrollbar.`, definition));
666
+ addFidelity(context.fidelity, 'unsupported', 'attached_scrollbar', 'Multiple scrollbars target the same container axis.', definition);
667
+ return;
668
+ }
669
+ result.push({ definition: candidate, horizontal });
670
+ };
671
+ for (const horizontal of [false, true]) {
672
+ const name = scalarString(property(definition.attributes, horizontal ? 'horizontalScrollbar' : 'verticalScrollbar'));
673
+ if (name !== undefined) {
674
+ const candidate = context.elementsByName.get(name);
675
+ add(candidate?.parentId === undefined ? candidate : undefined, horizontal, name);
676
+ }
677
+ }
678
+ for (const id of definition.childIds) {
679
+ const child = context.elementsById.get(id);
680
+ if (child?.elementType.toLowerCase() === 'extendedscrollbartype')
681
+ add(child, scalarBoolean(property(child.attributes, 'horizontal')) ?? false, child.name);
682
+ }
683
+ return result;
684
+ }
573
685
  function dynamicCountryFlag(elementName, expression, scenario, rowValues) {
574
- if (typeof expression !== 'string')
686
+ const explicitTag = rowValues?.[`${elementName}.countryTag`] ??
687
+ scenario.values[`${elementName}.countryTag`] ??
688
+ scenario.scriptedGui[`${elementName}.countryTag`];
689
+ // Native engine-owned flag slots have no scripted GetFlag property. Require an
690
+ // explicit per-element binding; a generic country context must not replace every icon.
691
+ if (typeof expression !== 'string' && explicitTag === undefined)
575
692
  return undefined;
576
- const match = /^\[\?([^\]|]+)(?:\|[^\]]+)?\]$|^\[([^\]]+)\]$/u.exec(expression);
693
+ const match = typeof expression === 'string'
694
+ ? /^\[\?([^\]|]+)(?:\|[^\]]+)?\]$|^\[([^\]]+)\]$/u.exec(expression)
695
+ : null;
577
696
  const token = match?.[1] ?? match?.[2];
578
697
  const shortToken = token?.slice(token.lastIndexOf('.') + 1);
579
698
  const tagCandidates = [
@@ -590,7 +709,9 @@ function dynamicCountryFlag(elementName, expression, scenario, rowValues) {
590
709
  scenario.country?.GetTag,
591
710
  scenario.country?.tag,
592
711
  ];
593
- const rawTag = tagCandidates.find((candidate) => typeof candidate === 'string' && /^[A-Za-z0-9_]{2,64}$/u.test(candidate));
712
+ const rawTag = explicitTag === undefined
713
+ ? tagCandidates.find((candidate) => typeof candidate === 'string' && /^[A-Za-z0-9_]{2,64}$/u.test(candidate))
714
+ : String(explicitTag);
594
715
  if (rawTag === undefined)
595
716
  return undefined;
596
717
  const ideologyCandidates = [
@@ -652,10 +773,403 @@ class GuiSceneWorkBudget {
652
773
  this.textLayoutOperations += amount;
653
774
  }
654
775
  }
655
- async function layoutElement(definition, parentRect, inheritedClip, parentScale, depth, context, instanceSuffix = '', rowIndex, rowValues, parentInstanceId, inheritedOrientation = 'upper_left', inheritedVisible = true) {
776
+ /** Native scrollbar children are role templates; their authored coordinates are not layout. */
777
+ async function extendedScrollbarChildren(definition, children, rect, scale, context) {
778
+ const horizontal = scalarBoolean(property(definition.attributes, 'horizontal')) ?? false;
779
+ const roles = new Map();
780
+ for (const role of ['track', 'slider', 'decreaseButton', 'increaseButton']) {
781
+ const value = property(definition.attributes, role);
782
+ if (value === undefined)
783
+ continue;
784
+ const attributes = objectProperty(value);
785
+ if (attributes === undefined) {
786
+ addFidelity(context.fidelity, 'unsupported', 'extended_scrollbar_role', `${role} must be a single inline role definition.`, definition);
787
+ continue;
788
+ }
789
+ const child = {
790
+ ...definition,
791
+ id: `${definition.id}:role-${role}`,
792
+ sourceOwnerId: definition.sourceOwnerId ?? definition.id,
793
+ parentId: definition.id,
794
+ name: scalarString(property(attributes, 'name')) ?? `${definition.name}_${role}`,
795
+ elementType: role === 'track' ? 'iconType' : 'buttonType',
796
+ attributes,
797
+ childIds: [],
798
+ unsupportedAttributes: Object.keys(attributes).filter((key) => guiElementAttributeFidelity(key, 'buttonType') === 'unsupported'),
799
+ };
800
+ const spriteName = scalarString(property(attributes, 'spriteType', 'quadTextureSprite'));
801
+ const sprite = spriteName === undefined ? undefined : context.spritesByName.get(spriteName.toLowerCase());
802
+ const frame = sprite === undefined ? undefined : await context.catalog.loadSpriteFrame(sprite, 0);
803
+ const size = objectProperty(property(attributes, 'size'));
804
+ const position = objectProperty(property(attributes, 'position'));
805
+ const width = scaledNumber(size === undefined ? undefined : property(size, 'width', 'x'), rect.width, scale) ?? (frame?.width ?? 0) * scale;
806
+ const height = scaledNumber(size === undefined ? undefined : property(size, 'height', 'y'), rect.height, scale) ?? (frame?.height ?? 0) * scale;
807
+ roles.set(role, {
808
+ definition: child,
809
+ width,
810
+ height,
811
+ x: scaledNumber(position === undefined ? undefined : property(position, 'x'), rect.width, scale) ?? (role === 'increaseButton' ? -width : 0),
812
+ y: scaledNumber(position === undefined ? undefined : property(position, 'y'), rect.height, scale) ?? (role === 'increaseButton' ? -height : 0),
813
+ });
814
+ }
815
+ const length = horizontal ? rect.width : rect.height;
816
+ const decrease = roles.get('decreaseButton');
817
+ const increase = roles.get('increaseButton');
818
+ const start = Math.max(0, Math.min(length, decrease === undefined
819
+ ? 0
820
+ : horizontal
821
+ ? decrease.x + decrease.width
822
+ : decrease.y + decrease.height));
823
+ const end = Math.max(start, Math.min(length, increase === undefined ? length : length + (horizontal ? increase.x : increase.y)));
824
+ const minimum = scalarNumber(property(definition.attributes, 'minValue'), 1) ?? 0;
825
+ const maximum = scalarNumber(property(definition.attributes, 'maxValue'), 1) ?? 1;
826
+ const value = scalarNumber(property(definition.attributes, 'startValue'), 1) ?? minimum;
827
+ const ratio = maximum > minimum ? Math.max(0, Math.min(1, (value - minimum) / (maximum - minimum))) : 0;
828
+ const track = roles.get('track');
829
+ const trackAxis = start + (track === undefined ? 0 : horizontal ? track.x : track.y);
830
+ const trackLength = Math.max(0, end - trackAxis);
831
+ const result = [];
832
+ for (const [role, part] of roles) {
833
+ let x = part.x;
834
+ let y = part.y;
835
+ let width = part.width;
836
+ let height = part.height;
837
+ if (role === 'increaseButton') {
838
+ x += rect.width;
839
+ y += rect.height;
840
+ }
841
+ else if (role === 'track') {
842
+ if (horizontal) {
843
+ x = trackAxis;
844
+ width = trackLength;
845
+ }
846
+ else {
847
+ y = trackAxis;
848
+ height = trackLength;
849
+ }
850
+ }
851
+ else if (role === 'slider') {
852
+ const axis = trackAxis + Math.max(0, trackLength - (horizontal ? width : height)) * ratio;
853
+ if (horizontal) {
854
+ x += axis;
855
+ y += track?.y ?? 0;
856
+ }
857
+ else {
858
+ x += track?.x ?? 0;
859
+ y += axis;
860
+ }
861
+ }
862
+ result.push({
863
+ ...part.definition,
864
+ attributes: {
865
+ ...part.definition.attributes,
866
+ position: { x: x / scale, y: y / scale },
867
+ size: { width: width / scale, height: height / scale },
868
+ orientation: 'upper_left',
869
+ origo: 'upper_left',
870
+ centerposition: false,
871
+ ...(maximum <= minimum ? { enabled: false } : {}),
872
+ },
873
+ });
874
+ }
875
+ addFidelity(context.fidelity, 'modelled', 'native_extended_scrollbar_roles', `Composed inline ${horizontal ? 'horizontal' : 'vertical'} scrollbar artwork with source role offsets and ${ratio} of its value range.`, definition);
876
+ return [...result, ...children];
877
+ }
878
+ async function scrollbarChildren(definition, children, rect, scale, context, rowValues) {
879
+ if (definition.elementType.toLowerCase() === 'extendedscrollbartype')
880
+ return extendedScrollbarChildren(definition, children, rect, scale, context);
881
+ if (definition.elementType.toLowerCase() !== 'scrollbartype')
882
+ return children;
883
+ const horizontal = scalarBoolean(property(definition.attributes, 'horizontal')) ?? false;
884
+ const length = horizontal ? rect.width : rect.height;
885
+ const crossLength = horizontal ? rect.height : rect.width;
886
+ const roles = new Map();
887
+ for (const role of ['track', 'leftbutton', 'rightbutton', 'slider']) {
888
+ const name = scalarString(property(definition.attributes, role));
889
+ if (name === undefined)
890
+ continue;
891
+ const child = children.find((candidate) => candidate.name === name);
892
+ if (child === undefined) {
893
+ context.diagnostics.push(diagnostic('GUI_SCROLLBAR_ROLE_MISSING', 'warning', `Scrollbar ${definition.name} cannot resolve ${role} template ${name}.`, definition));
894
+ addFidelity(context.fidelity, 'missing', 'scrollbar_role', `${role} template ${name} is missing.`, definition);
895
+ continue;
896
+ }
897
+ const spriteName = scalarString(property(child.attributes, 'spriteType', 'quadTextureSprite'));
898
+ const sprite = spriteName === undefined ? undefined : context.spritesByName.get(spriteName.toLowerCase());
899
+ const frame = sprite === undefined
900
+ ? undefined
901
+ : await context.catalog.loadSpriteFrame(sprite, frameFor(child, sprite, context.scenario, rowValues));
902
+ const size = objectProperty(property(child.attributes, 'size'));
903
+ roles.set(role, {
904
+ definition: child,
905
+ width: scaledNumber(size === undefined ? undefined : property(size, 'width', 'x'), rect.width, scale) ?? (frame?.width ?? 0) * scale,
906
+ height: scaledNumber(size === undefined ? undefined : property(size, 'height', 'y'), rect.height, scale) ?? (frame?.height ?? 0) * scale,
907
+ });
908
+ }
909
+ const border = objectProperty(property(definition.attributes, 'borderSize'));
910
+ const borderValue = scaledNumber(border === undefined
911
+ ? undefined
912
+ : property(border, horizontal ? 'x' : 'y', horizontal ? 'width' : 'height'), length, scale);
913
+ const extent = (role) => horizontal ? (roles.get(role)?.width ?? 0) : (roles.get(role)?.height ?? 0);
914
+ const start = Math.max(0, Math.min(length, borderValue ?? extent('leftbutton')));
915
+ const end = Math.max(0, Math.min(length - start, borderValue ?? extent('rightbutton')));
916
+ const trackLength = Math.max(0, length - start - end);
917
+ const minimum = scalarNumber(property(definition.attributes, 'minValue'), 1) ?? 0;
918
+ const maximum = scalarNumber(property(definition.attributes, 'maxValue'), 1) ?? 100;
919
+ const explicitValue = rowValues?.[`${definition.name}.value`] ??
920
+ rowValues?.[definition.name] ??
921
+ context.scenario.values[`${definition.name}.value`] ??
922
+ context.scenario.values[definition.name] ??
923
+ context.scenario.scriptedGui[definition.name];
924
+ const state = context.scenario.elementStates[definition.name] ?? context.scenario.state;
925
+ const value = state === 'minimum-value'
926
+ ? minimum
927
+ : state === 'maximum-value'
928
+ ? maximum
929
+ : typeof explicitValue === 'number'
930
+ ? explicitValue
931
+ : (scalarNumber(property(definition.attributes, 'startValue'), 1) ?? minimum);
932
+ const ratio = maximum > minimum ? Math.max(0, Math.min(1, (value - minimum) / (maximum - minimum))) : 0;
933
+ const result = [];
934
+ const controlledIds = new Set();
935
+ for (const [role, part] of roles) {
936
+ controlledIds.add(part.definition.id);
937
+ const partLength = role === 'track' ? trackLength : Math.min(length, extent(role));
938
+ const crossSize = role === 'track' ? crossLength : horizontal ? part.height : part.width;
939
+ const axis = role === 'track'
940
+ ? start
941
+ : role === 'rightbutton'
942
+ ? length - partLength
943
+ : role === 'slider'
944
+ ? start + Math.max(0, trackLength - partLength) * ratio
945
+ : 0;
946
+ const cross = (crossLength - crossSize) / 2;
947
+ result.push({
948
+ ...part.definition,
949
+ attributes: {
950
+ ...part.definition.attributes,
951
+ position: {
952
+ x: (horizontal ? axis : cross) / scale,
953
+ y: (horizontal ? cross : axis) / scale,
954
+ },
955
+ size: {
956
+ width: (horizontal ? partLength : crossSize) / scale,
957
+ height: (horizontal ? crossSize : partLength) / scale,
958
+ },
959
+ orientation: 'upper_left',
960
+ origo: 'upper_left',
961
+ centerposition: false,
962
+ ...(maximum <= minimum ? { enabled: false } : {}),
963
+ },
964
+ });
965
+ }
966
+ addFidelity(context.fidelity, 'modelled', 'native_scrollbar_roles', `Composed ${horizontal ? 'horizontal' : 'vertical'} track, end buttons, and thumb at ${ratio} of its value range.`, definition);
967
+ return [...result, ...children.filter(({ id }) => !controlledIds.has(id))];
968
+ }
969
+ async function layoutAttachedScrollbars(definition, scrollbars, owner, childOutput, context, depth, scale, rowIndex, rowValues, instanceSuffix) {
970
+ const geometry = context.geometry.get(owner.id);
971
+ const viewport = geometry.contentRect;
972
+ const attachmentRect = owner.unclippedRect;
973
+ const attachmentWidth = viewport.width > 0 ? attachmentRect.width : 0;
974
+ const attachmentHeight = viewport.height > 0 ? attachmentRect.height : 0;
975
+ const directChildren = childOutput.filter((child) => child.parentId === owner.id);
976
+ const fixedChildren = new Set(directChildren
977
+ .filter((child) => {
978
+ const rect = child.unclippedRect;
979
+ const outer = rectIntersection(rect, owner.unclippedRect);
980
+ return (outer !== undefined &&
981
+ equalRect(rect, outer) &&
982
+ rectIntersection(rect, viewport) === undefined);
983
+ })
984
+ .map(({ id }) => id));
985
+ let right = viewport.x + viewport.width;
986
+ let bottom = viewport.y + viewport.height;
987
+ for (const child of directChildren) {
988
+ const childGeometry = context.geometry.get(child.id);
989
+ if (!childGeometry.appearanceVisible || fixedChildren.has(child.id))
990
+ continue;
991
+ const measured = childGeometry.measuredRect;
992
+ right = Math.max(right, measured.x + measured.width);
993
+ bottom = Math.max(bottom, measured.y + measured.height);
994
+ }
995
+ const maximumX = scrollbars.some(({ horizontal }) => horizontal)
996
+ ? Math.max(0, right - viewport.x - viewport.width)
997
+ : 0;
998
+ const maximumY = scrollbars.some(({ horizontal }) => !horizontal)
999
+ ? Math.max(0, bottom - viewport.y - viewport.height)
1000
+ : 0;
1001
+ const requestedOffset = (horizontal) => {
1002
+ const requestedState = context.scenario.elementStates[definition.name] ?? context.scenario.state;
1003
+ if (requestedState === 'minimum-value')
1004
+ return 0;
1005
+ if (requestedState === 'maximum-value')
1006
+ return horizontal ? maximumX : maximumY;
1007
+ const key = `${definition.name}.scroll${horizontal ? 'X' : 'Y'}`;
1008
+ const explicit = rowValues?.[key] ?? context.scenario.values[key] ?? context.scenario.scriptedGui[key];
1009
+ if (typeof explicit === 'number' && Number.isFinite(explicit))
1010
+ return Math.max(0, explicit) * scale;
1011
+ return horizontal
1012
+ ? 0
1013
+ : (context.scenario.scrollOffsets[`${definition.name}${instanceSuffix}`] ??
1014
+ context.scenario.scrollOffsets[`${definition.name}${legacyRowSuffix(instanceSuffix)}`] ??
1015
+ context.scenario.scrollOffsets[definition.name] ??
1016
+ context.scenario.scrollOffsets[definition.id] ??
1017
+ 0) * scale;
1018
+ };
1019
+ const offsetX = Math.min(maximumX, requestedOffset(true));
1020
+ const offsetY = Math.min(maximumY, requestedOffset(false));
1021
+ owner.scroll = {
1022
+ viewport,
1023
+ contentWidth: right - viewport.x,
1024
+ contentHeight: bottom - viewport.y,
1025
+ offsetX,
1026
+ offsetY,
1027
+ maximumX,
1028
+ maximumY,
1029
+ };
1030
+ let fixed = false;
1031
+ for (const child of childOutput) {
1032
+ context.work.spend('container scroll translation');
1033
+ if (child.parentId === owner.id)
1034
+ fixed = fixedChildren.has(child.id);
1035
+ const childGeometry = context.geometry.get(child.id);
1036
+ if (fixed) {
1037
+ // A child wholly in the fixed margin keeps the outer window clip, not the scrolled inner clip.
1038
+ childGeometry.clipChain = childGeometry.clipChain.map((clip) => clip === viewport ? owner.unclippedRect : clip);
1039
+ continue;
1040
+ }
1041
+ for (const rect of new Set([
1042
+ child.unclippedRect,
1043
+ childGeometry.contentRect,
1044
+ childGeometry.measuredRect,
1045
+ ])) {
1046
+ rect.x -= offsetX;
1047
+ rect.y -= offsetY;
1048
+ }
1049
+ }
1050
+ addFidelity(context.fidelity, 'modelled', 'native_container_scrolling', `Resolved content extents ${right - viewport.x} by ${bottom - viewport.y}; offsets ${offsetX},${offsetY} are clamped to ${maximumX},${maximumY}. Nested container bounds, not their overflowing descendants, contribute to the parent range.`, definition);
1051
+ if (property(definition.attributes, 'margin') !== undefined)
1052
+ addFidelity(context.fidelity, 'modelled', 'margin', 'Container margins preserve fixed border content and inset the scrolling clip without adding child-layout padding.', definition);
1053
+ const autohide = scalarBoolean(property(definition.attributes, 'autohide_scrollbars')) ?? true;
1054
+ const barLayouts = [];
1055
+ for (const [index, attached] of scrollbars.entries()) {
1056
+ const maximum = attached.horizontal ? maximumX : maximumY;
1057
+ const offset = attached.horizontal ? offsetX : offsetY;
1058
+ const size = objectProperty(property(attached.definition.attributes, 'size'));
1059
+ const barScale = scale * (scalarNumber(property(attached.definition.attributes, 'scale'), 1) ?? 1);
1060
+ if (!Number.isFinite(barScale) || barScale <= 0) {
1061
+ addFidelity(context.fidelity, 'unsupported', 'attached_scrollbar_scale', 'Attached scrollbar scale must be positive and finite.', attached.definition);
1062
+ continue;
1063
+ }
1064
+ const barWidth = attached.horizontal
1065
+ ? attachmentWidth
1066
+ : (scaledNumber(size === undefined ? undefined : property(size, 'width', 'x'), attachmentWidth, barScale) ?? 0);
1067
+ const barHeight = attached.horizontal
1068
+ ? (scaledNumber(size === undefined ? undefined : property(size, 'height', 'y'), attachmentHeight, barScale) ?? 0)
1069
+ : attachmentHeight;
1070
+ const position = objectProperty(property(attached.definition.attributes, 'position'));
1071
+ const axis = anchorAxes(scalarString(property(attached.definition.attributes, 'orientation'))?.toLowerCase() ??
1072
+ 'upper_left');
1073
+ const origin = scalarBoolean(property(attached.definition.attributes, 'centerposition')) === true
1074
+ ? { horizontal: 'center', vertical: 'center' }
1075
+ : anchorAxes(scalarString(property(attached.definition.attributes, 'origo'))?.toLowerCase() ??
1076
+ 'upper_left');
1077
+ const coordinate = (horizontal) => {
1078
+ const key = `${attached.definition.name}.${horizontal ? 'x' : 'y'}`;
1079
+ const explicit = rowValues?.[key] ?? context.scenario.values[key] ?? context.scenario.scriptedGui[key];
1080
+ const span = horizontal ? attachmentWidth : attachmentHeight;
1081
+ const offset = typeof explicit === 'number' && Number.isFinite(explicit)
1082
+ ? explicit * scale
1083
+ : (scaledNumber(position === undefined ? undefined : property(position, horizontal ? 'x' : 'y'), span, scale) ?? 0);
1084
+ const anchor = horizontal ? axis.horizontal : axis.vertical;
1085
+ const origo = horizontal ? origin.horizontal : origin.vertical;
1086
+ const length = horizontal ? barWidth : barHeight;
1087
+ return (anchoredCoordinate(horizontal ? attachmentRect.x : attachmentRect.y, span, offset, anchor) -
1088
+ (origo === 'center' ? length / 2 : origo === 'right' || origo === 'bottom' ? length : 0));
1089
+ };
1090
+ const bar = {
1091
+ ...attached.definition,
1092
+ id: `${attached.definition.id}:attached-${definition.id}-${index}`,
1093
+ sourceOwnerId: attached.definition.id,
1094
+ parentId: definition.id,
1095
+ attributes: {
1096
+ ...attached.definition.attributes,
1097
+ size: {
1098
+ width: attached.horizontal
1099
+ ? attachmentWidth / barScale
1100
+ : ((size === undefined ? undefined : property(size, 'width', 'x')) ?? 0),
1101
+ height: attached.horizontal
1102
+ ? ((size === undefined ? undefined : property(size, 'height', 'y')) ?? 0)
1103
+ : attachmentHeight / barScale,
1104
+ },
1105
+ minValue: 0,
1106
+ maxValue: maximum,
1107
+ startValue: offset,
1108
+ ...(maximum === 0 ? { enabled: false } : {}),
1109
+ },
1110
+ };
1111
+ const sourceShown = scalarBoolean(property(bar.attributes, 'visible')) ??
1112
+ !(scalarBoolean(property(bar.attributes, 'hide', 'hidden')) ?? false);
1113
+ barLayouts.push({
1114
+ definition: bar,
1115
+ rect: { x: coordinate(true), y: coordinate(false), width: barWidth, height: barHeight },
1116
+ scale: barScale,
1117
+ horizontal: attached.horizontal,
1118
+ shown: explicitElementVisibility(bar, context.scenario, rowValues) ??
1119
+ (sourceShown && (!autohide || maximum > 0)),
1120
+ index,
1121
+ });
1122
+ }
1123
+ const horizontal = barLayouts.find((bar) => bar.horizontal && bar.shown);
1124
+ const vertical = barLayouts.find((bar) => !bar.horizontal && bar.shown);
1125
+ if (horizontal !== undefined &&
1126
+ vertical !== undefined &&
1127
+ rectIntersection(horizontal.rect, vertical.rect) !== undefined) {
1128
+ const horizontalRect = { ...horizontal.rect };
1129
+ const verticalRect = { ...vertical.rect };
1130
+ // Each visible orthogonal scrollbar reserves its actual anchored gutter, including its inset.
1131
+ if (verticalRect.x + verticalRect.width / 2 >=
1132
+ owner.unclippedRect.x + owner.unclippedRect.width / 2)
1133
+ horizontal.rect.width = Math.max(0, verticalRect.x - horizontalRect.x);
1134
+ else {
1135
+ horizontal.rect.x = Math.max(horizontalRect.x, verticalRect.x + verticalRect.width);
1136
+ horizontal.rect.width = Math.max(0, horizontalRect.x + horizontalRect.width - horizontal.rect.x);
1137
+ }
1138
+ if (horizontalRect.y + horizontalRect.height / 2 >=
1139
+ owner.unclippedRect.y + owner.unclippedRect.height / 2)
1140
+ vertical.rect.height = Math.max(0, horizontalRect.y - verticalRect.y);
1141
+ else {
1142
+ vertical.rect.y = Math.max(verticalRect.y, horizontalRect.y + horizontalRect.height);
1143
+ vertical.rect.height = Math.max(0, verticalRect.y + verticalRect.height - vertical.rect.y);
1144
+ }
1145
+ addFidelity(context.fidelity, 'modelled', 'orthogonal_scrollbar_gutters', 'Visible horizontal and vertical scrollbars reserve their anchored cross-axis gutters instead of drawing their end buttons over the same corner.', definition);
1146
+ }
1147
+ for (const bar of barLayouts) {
1148
+ await layoutElement({
1149
+ ...bar.definition,
1150
+ attributes: {
1151
+ ...bar.definition.attributes,
1152
+ position: {
1153
+ x: (bar.rect.x - owner.unclippedRect.x) / scale,
1154
+ y: (bar.rect.y - owner.unclippedRect.y) / scale,
1155
+ },
1156
+ size: { width: bar.rect.width / bar.scale, height: bar.rect.height / bar.scale },
1157
+ orientation: 'upper_left',
1158
+ origo: 'upper_left',
1159
+ centerposition: false,
1160
+ },
1161
+ }, owner.unclippedRect, clippedByChain(geometry.clipChain), scale, depth + 1, context, `${instanceSuffix}#scrollbar-${definition.id}-${bar.index}`, rowIndex, {
1162
+ ...rowValues,
1163
+ [`${bar.definition.name}.x`]: (bar.rect.x - owner.unclippedRect.x) / scale,
1164
+ [`${bar.definition.name}.y`]: (bar.rect.y - owner.unclippedRect.y) / scale,
1165
+ }, owner.id, 'upper_left', geometry.appearanceVisible && bar.shown, geometry.clipChain);
1166
+ }
1167
+ }
1168
+ async function layoutElement(definition, parentRect, inheritedClip, parentScale, depth, context, instanceSuffix = '', rowIndex, rowValues, parentInstanceId, inheritedOrientation = 'upper_left', inheritedVisible = true, inheritedClipChain) {
656
1169
  context.work.admitElement(depth);
657
1170
  const { scenario, catalog, fidelity, diagnostics } = context;
658
1171
  const instanceId = instanceSuffix.length === 0 ? definition.id : `${definition.id}${instanceSuffix}`;
1172
+ const clipChain = inheritedClipChain ?? (inheritedClip === undefined ? [] : [inheritedClip]);
659
1173
  const localScale = scalarNumber(property(definition.attributes, 'scale'), 1) ?? 1;
660
1174
  const scale = parentScale * localScale;
661
1175
  const position = objectProperty(property(definition.attributes, 'position'));
@@ -676,16 +1190,44 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
676
1190
  : undefined) ??
677
1191
  scaledNumber(position === undefined ? undefined : property(position, 'y'), parentRect.height, parentScale) ??
678
1192
  0;
679
- let width = scaledNumber(size === undefined ? undefined : property(size, 'width', 'x'), parentRect.width, scale) ?? 0;
680
- let height = scaledNumber(size === undefined ? undefined : property(size, 'height', 'y'), parentRect.height, scale) ?? 0;
681
- const scriptedVisible = rowValues?.[`${definition.name}.visible`] ??
682
- scenario.values[`${definition.name}.visible`] ??
683
- scenario.scriptedGui[`${definition.name}.visible`];
684
- const explicitlyVisible = scenario.visibility[definition.name] ??
685
- scenario.visibility[definition.id] ??
686
- (typeof scriptedVisible === 'boolean' ? scriptedVisible : undefined);
687
- const resolveVisibleAppearance = inheritedVisible && explicitlyVisible !== false;
688
- const background = objectProperty(property(definition.attributes, 'background'));
1193
+ const orientation = scalarString(property(definition.attributes, 'orientation'))?.toLowerCase() ??
1194
+ inheritedOrientation;
1195
+ const origo = scalarString(property(definition.attributes, 'origo'))?.toLowerCase() ?? 'upper_left';
1196
+ const orientationAxes = anchorAxes(orientation);
1197
+ const anchorX = anchoredCoordinate(parentRect.x, parentRect.width, localX, orientationAxes.horizontal);
1198
+ const anchorY = anchoredCoordinate(parentRect.y, parentRect.height, localY, orientationAxes.vertical);
1199
+ const widthValue = size === undefined ? undefined : property(size, 'width', 'x');
1200
+ const heightValue = size === undefined ? undefined : property(size, 'height', 'y');
1201
+ let width = scaledNumber(widthValue, parentRect.width, scale) ?? 0;
1202
+ let height = scaledNumber(heightValue, parentRect.height, scale) ?? 0;
1203
+ // A doubled percent names a far-edge coordinate in the parent, whereas a single
1204
+ // percent is a proportional extent. Resolve the anchor first, including screen-centred roots.
1205
+ if (typeof widthValue === 'string' && widthValue.endsWith('%%'))
1206
+ width = Math.max(0, width - (anchorX - parentRect.x));
1207
+ if (typeof heightValue === 'string' && heightValue.endsWith('%%'))
1208
+ height = Math.max(0, height - (anchorY - parentRect.y));
1209
+ const inferWidth = !(typeof widthValue === 'string' && widthValue.endsWith('%%'));
1210
+ const inferHeight = !(typeof heightValue === 'string' && heightValue.endsWith('%%'));
1211
+ // Native negative extents specify an inset from the parent's far edge.
1212
+ if (width < 0)
1213
+ width = Math.max(0, parentRect.width - localX + width);
1214
+ if (height < 0)
1215
+ height = Math.max(0, parentRect.height - localY + height);
1216
+ const explicitlyVisible = explicitElementVisibility(definition, scenario, rowValues);
1217
+ const declaredHidden = scalarBoolean(property(definition.attributes, 'hide', 'hidden')) ?? false;
1218
+ const sourceVisible = scalarBoolean(property(definition.attributes, 'visible')) ?? !declaredHidden;
1219
+ const resolveVisibleAppearance = inheritedVisible && (explicitlyVisible ?? sourceVisible);
1220
+ const scriptedEnabled = rowValues?.[`${definition.name}.enabled`] ??
1221
+ scenario.values[`${definition.name}.enabled`] ??
1222
+ scenario.scriptedGui[`${definition.name}.enabled`] ??
1223
+ context.constantElementEnabled[definition.name] ??
1224
+ scalarBoolean(property(definition.attributes, 'enabled'));
1225
+ const requestedState = scenario.elementStates[definition.name] ?? scenario.state;
1226
+ const state = scriptedEnabled === false ? 'disabled' : requestedState;
1227
+ const backgroundValue = property(definition.attributes, 'background');
1228
+ const backgrounds = (Array.isArray(backgroundValue) ? backgroundValue : [backgroundValue])
1229
+ .map(objectProperty)
1230
+ .filter((value) => value !== undefined);
689
1231
  const scriptedImage = rowValues?.[`${definition.name}.image`] ??
690
1232
  rowValues?.[definition.name] ??
691
1233
  scenario.values[`${definition.name}.image`] ??
@@ -693,22 +1235,22 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
693
1235
  scenario.scriptedGui[`${definition.name}.image`] ??
694
1236
  scenario.scriptedGui[definition.name];
695
1237
  const spriteName = (typeof scriptedImage === 'string' && scriptedImage.length > 0 ? scriptedImage : undefined) ??
696
- scalarString(property(definition.attributes, 'spriteType', 'quadTextureSprite')) ??
697
- scalarString(background === undefined
698
- ? undefined
699
- : property(background, 'spriteType', 'quadTextureSprite'));
1238
+ scalarString(property(definition.attributes, 'spriteType', 'quadTextureSprite'));
700
1239
  const spriteDefinition = spriteName === undefined ? undefined : context.spritesByName.get(spriteName.toLowerCase());
701
1240
  let sprite;
702
1241
  let secondarySprite;
703
1242
  let spriteRenderMode;
1243
+ let spriteShader;
1244
+ let progressShader;
1245
+ let progressColours;
704
1246
  const countryFlag = dynamicCountryFlag(definition.name, context.countryFlagProperties.get(definition.name), scenario, rowValues);
705
1247
  if (countryFlag !== undefined && resolveVisibleAppearance) {
706
1248
  sprite = await catalog.loadCountryFlag(countryFlag.tag, spriteDefinition, countryFlag.ideology, spriteName);
707
1249
  spriteRenderMode = 'stretch';
708
1250
  if (sprite.supported) {
709
- if (width === 0)
1251
+ if (width === 0 && inferWidth)
710
1252
  width = sprite.width * scale;
711
- if (height === 0)
1253
+ if (height === 0 && inferHeight)
712
1254
  height = sprite.height * scale;
713
1255
  addFidelity(fidelity, 'modelled', 'dynamic_country_flag', `${definition.name} resolved the ${countryFlag.tag} flag from scenario country context.`, definition);
714
1256
  }
@@ -738,25 +1280,78 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
738
1280
  : spriteType === 'maskedshieldtype'
739
1281
  ? 'masked-shield'
740
1282
  : 'stretch';
741
- if (spriteDefinition.effectFile !== undefined) {
742
- const detail = `Effect ${spriteDefinition.effectFile} is retained in the source graph but is not executed by the offline renderer.`;
743
- addFidelity(fidelity, 'unsupported', 'effectFile', detail, definition);
744
- partialAppearance.push(detail);
1283
+ const colourProgress = spriteRenderMode === 'progressbar' &&
1284
+ spriteDefinition.texturePath === undefined &&
1285
+ spriteDefinition.texturePath2 === undefined &&
1286
+ spriteDefinition.primaryColour !== undefined &&
1287
+ spriteDefinition.secondaryColour !== undefined;
1288
+ if (colourProgress) {
1289
+ progressColours = {
1290
+ first: spriteDefinition.primaryColour,
1291
+ second: spriteDefinition.secondaryColour,
1292
+ };
1293
+ if (width === 0 && inferWidth)
1294
+ width = (spriteDefinition.declaredSize?.width ?? 0) * scale;
1295
+ if (height === 0 && inferHeight)
1296
+ height = (spriteDefinition.declaredSize?.height ?? 0) * scale;
1297
+ }
1298
+ if (spriteDefinition.effectFile !== undefined && spriteRenderMode === 'progressbar') {
1299
+ const shader = catalog.progressShader(spriteDefinition, colourProgress ? 'Color' : 'Texture');
1300
+ if (shader.supported) {
1301
+ progressShader = shader.shader;
1302
+ addFidelity(fidelity, 'modelled', 'progress_shader_threshold', `${progressShader.sourcePath} :: ${progressShader.entryPoint} selects one of two ${colourProgress ? 'colours' : 'textures'} at the supplied progress.`, definition);
1303
+ }
1304
+ else {
1305
+ const detail = `Effect ${spriteDefinition.effectFile}: ${shader.reason}`;
1306
+ addFidelity(fidelity, 'unsupported', 'effectFile', detail, definition);
1307
+ partialAppearance.push(detail);
1308
+ }
1309
+ }
1310
+ else if (spriteDefinition.effectFile !== undefined) {
1311
+ const pressed = rowValues?.[`${definition.name}.pressed`] ?? scenario.values[`${definition.name}.pressed`];
1312
+ const effect = state === 'disabled' || state === 'locked'
1313
+ ? 'Disable'
1314
+ : pressed === true
1315
+ ? 'Down'
1316
+ : state === 'hover'
1317
+ ? 'Over'
1318
+ : 'Up';
1319
+ const elapsed = rowValues?.[`${definition.name}.stateTimeSeconds`] ??
1320
+ scenario.values[`${definition.name}.stateTimeSeconds`] ??
1321
+ scenario.animationTimeSeconds;
1322
+ const shader = catalog.spriteShader(spriteDefinition, effect, {
1323
+ time: typeof elapsed === 'number'
1324
+ ? Math.max(0, Math.min(86_400, elapsed))
1325
+ : scenario.animationTimeSeconds,
1326
+ animationTime: 0,
1327
+ });
1328
+ if (shader.supported) {
1329
+ spriteShader = shader.shader;
1330
+ addFidelity(fidelity, 'modelled', 'sprite_shader_colour', `${spriteShader.sourcePath} :: ${spriteShader.entryPoint} supplies the ${effect} colour transform.`, definition);
1331
+ }
1332
+ else {
1333
+ const detail = `Effect ${spriteDefinition.effectFile}: ${shader.reason}`;
1334
+ addFidelity(fidelity, 'unsupported', 'effectFile', detail, definition);
1335
+ partialAppearance.push(detail);
1336
+ }
745
1337
  }
746
1338
  if (partialAppearance.length > 0)
747
1339
  diagnostics.push(diagnostic('GUI_SPRITE_RENDER_PARTIAL', 'warning', `Element ${definition.name} has a partial sprite appearance: ${partialAppearance.join(' ')}`, definition));
748
- const frame = frameFor(definition, spriteDefinition, scenario, rowValues);
1340
+ const frame = frameFor(definition, spriteDefinition, scenario, rowValues, state);
749
1341
  sprite = await catalog.loadSpriteFrame(spriteDefinition, frame);
750
1342
  if (spriteDefinition.texturePath2 !== undefined)
751
1343
  secondarySprite = await catalog.loadSecondarySpriteFrame(spriteDefinition, frame);
752
- if (!sprite?.supported) {
1344
+ if (colourProgress) {
1345
+ addFidelity(fidelity, partialAppearance.length === 0 ? 'modelled' : 'approximated', 'progressbar_composition', `Progress bar ${spriteDefinition.name} uses its declared primary and secondary colours without a texture.`, definition);
1346
+ }
1347
+ else if (!sprite?.supported) {
753
1348
  addFidelity(fidelity, 'unsupported', 'texture', sprite?.reason ?? `Sprite ${spriteDefinition.name} has no texture.`, definition);
754
1349
  diagnostics.push(diagnostic('GUI_TEXTURE_UNSUPPORTED', 'warning', sprite?.reason ?? `Sprite ${spriteDefinition.name} has no texture.`, definition));
755
1350
  }
756
1351
  else {
757
- if (width === 0)
1352
+ if (width === 0 && inferWidth)
758
1353
  width = sprite.width * scale;
759
- if (height === 0)
1354
+ if (height === 0 && inferHeight)
760
1355
  height = sprite.height * scale;
761
1356
  addFidelity(fidelity, partialAppearance.length === 0 ? 'modelled' : 'approximated', 'sprite_frame', partialAppearance.length === 0
762
1357
  ? `${spriteDefinition.name} frame ${sprite.frame + 1}/${sprite.frameCount}`
@@ -794,11 +1389,18 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
794
1389
  if (typeof scriptedX === 'number' || typeof scriptedY === 'number')
795
1390
  addFidelity(fidelity, 'modelled', 'scripted_gui_position', `Scenario properties positioned ${definition.name} at ${localX},${localY} before anchoring.`, definition);
796
1391
  const rawButtonText = scalarString(property(definition.attributes, 'buttonText'));
797
- const rawText = scalarString(property(definition.attributes, 'text')) ?? rawButtonText;
1392
+ const overrideText = rowValues?.[`${definition.name}.text`] ??
1393
+ scenario.values[`${definition.name}.text`] ??
1394
+ scenario.scriptedGui[`${definition.name}.text`];
1395
+ const rawText = (typeof overrideText === 'string' || typeof overrideText === 'number'
1396
+ ? String(overrideText)
1397
+ : undefined) ??
1398
+ scalarString(property(definition.attributes, 'text', 'context_aware_text')) ??
1399
+ rawButtonText;
798
1400
  const embeddedButtonText = rawText !== undefined &&
799
1401
  (rawButtonText !== undefined || definition.elementType.toLowerCase().includes('button'));
800
1402
  let text;
801
- if (rawText !== undefined && resolveVisibleAppearance) {
1403
+ if (rawText !== undefined && rawText.length > 0 && resolveVisibleAppearance) {
802
1404
  const resolved = resolveTokenText(rawText, scenario, context.localisation, rowValues);
803
1405
  let displayText = resolved.text;
804
1406
  const state = scenario.elementStates[definition.name] ?? scenario.state;
@@ -849,9 +1451,11 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
849
1451
  }
850
1452
  const declaredMaxWidth = scaledNumber(property(definition.attributes, 'maxWidth'), parentRect.width, scale);
851
1453
  const declaredMaxHeight = scaledNumber(property(definition.attributes, 'maxHeight'), parentRect.height, scale);
852
- if (width === 0 && declaredMaxWidth !== undefined)
1454
+ if (width === 0 && inferWidth && declaredMaxWidth !== undefined)
853
1455
  width = declaredMaxWidth;
854
- if (height === 0 && declaredMaxHeight !== undefined)
1456
+ if (width === 0 && inferWidth && /textbox/iu.test(definition.elementType))
1457
+ width = nativeTextBoxWidth * scale;
1458
+ if (height === 0 && inferHeight && declaredMaxHeight !== undefined)
855
1459
  height = declaredMaxHeight;
856
1460
  const maxWidth = declaredMaxWidth ?? width;
857
1461
  const wrapped = wrapText(catalog, fontName, displayText, visibleText.colours, fontSize * scale, maxWidth, context.work, resolvedInlineIcons);
@@ -866,9 +1470,9 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
866
1470
  ? wrapped.lines.map((line, index) => colourRunsForLine(catalog, fontName, line, wrapped.colourLines[index] ?? [], fontSize * scale, context.work, resolvedInlineIcons, fontDefinition?.colour ?? defaultTextColour))
867
1471
  : undefined;
868
1472
  const maximumLineWidth = wrapped.widths.reduce((maximum, value) => Math.max(maximum, value), 0);
869
- if (width === 0)
1473
+ if (width === 0 && inferWidth)
870
1474
  width = maximumLineWidth;
871
- if (height === 0)
1475
+ if (height === 0 && inferHeight)
872
1476
  height = wrapped.lines.length * wrapped.lineHeight;
873
1477
  const measuredWidth = maximumLineWidth;
874
1478
  const measuredHeight = wrapped.lines.length * wrapped.lineHeight;
@@ -914,17 +1518,13 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
914
1518
  : 'Deterministic project font paths substitute for unavailable workspace glyph data.', definition);
915
1519
  if (resolvedFontMetrics.source === 'bmfont')
916
1520
  addFidelity(fidelity, resolvedFontMetrics.baselineModelled ? 'modelled' : 'approximated', 'font_native_metrics', `BMFont native size ${resolvedFontMetrics.nativeSize}, line height ${resolvedFontMetrics.nativeLineHeight}, and baseline ${resolvedFontMetrics.nativeBaseline}${explicitFontSize === undefined ? ' determine this element layout' : ' are scaled to the element fontSize'}.`, definition);
917
- if (resolved.missingLocalisation)
1521
+ if (resolved.missingLocalisation && overrideText === undefined)
918
1522
  addFidelity(fidelity, 'missing', 'localisation', `No ${scenario.language} localisation for ${rawText}.`, definition);
919
1523
  if (resolved.unresolved.length > 0)
920
1524
  addFidelity(fidelity, 'unresolved', 'dynamic_text', `Unresolved tokens: ${resolved.unresolved.join(', ')}.`, definition);
921
1525
  }
922
- const orientation = scalarString(property(definition.attributes, 'orientation'))?.toLowerCase() ??
923
- inheritedOrientation;
924
- const origo = scalarString(property(definition.attributes, 'origo'))?.toLowerCase() ?? 'upper_left';
925
- const orientationAxes = anchorAxes(orientation);
926
- let x = anchoredCoordinate(parentRect.x, parentRect.width, localX, orientationAxes.horizontal);
927
- let y = anchoredCoordinate(parentRect.y, parentRect.height, localY, orientationAxes.vertical);
1526
+ let x = anchorX;
1527
+ let y = anchorY;
928
1528
  const centerPosition = scalarBoolean(property(definition.attributes, 'centerposition')) ?? false;
929
1529
  const origoAxes = centerPosition
930
1530
  ? { horizontal: 'center', vertical: 'center' }
@@ -938,26 +1538,60 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
938
1538
  y -= height / 2;
939
1539
  else if (origoAxes.vertical === 'bottom')
940
1540
  y -= height;
941
- const scrollOffset = (scenario.scrollOffsets[definition.name] ?? scenario.scrollOffsets[definition.id] ?? 0) * scale;
942
1541
  const unclippedRect = { x, y, width: Math.max(0, width), height: Math.max(0, height) };
943
1542
  const ownClipping = scalarBoolean(property(definition.attributes, 'clipping')) ??
944
- (background !== undefined && /(?:containerwindow|windowtype)/iu.test(definition.elementType));
1543
+ (backgrounds.length > 0 && /(?:containerwindow|windowtype)/iu.test(definition.elementType));
1544
+ let scrollbars = attachedScrollbars(definition, context);
1545
+ if (scrollbars.length > 0 && (!ownClipping || backgrounds.length === 0)) {
1546
+ diagnostics.push(diagnostic('GUI_SCROLLBAR_CONTAINER_BOUNDARY_MISSING', 'warning', `Container ${definition.name} needs a background and clipping for attached scrolling.`, definition));
1547
+ addFidelity(fidelity, 'unsupported', 'attached_scrollbar', 'The container has no active clipped background boundary.', definition);
1548
+ scrollbars = [];
1549
+ }
1550
+ const margin = scrollbars.length === 0 ? undefined : objectProperty(property(definition.attributes, 'margin'));
1551
+ const inset = (name, reference) => Math.max(0, Math.min(reference, scaledNumber(margin === undefined ? undefined : property(margin, name), reference, scale) ??
1552
+ 0));
1553
+ const left = inset('left', unclippedRect.width);
1554
+ const top = inset('top', unclippedRect.height);
1555
+ const contentRect = {
1556
+ x: x + left,
1557
+ y: y + top,
1558
+ width: Math.max(0, unclippedRect.width - left - inset('right', unclippedRect.width)),
1559
+ height: Math.max(0, unclippedRect.height - top - inset('bottom', unclippedRect.height)),
1560
+ };
1561
+ const scrollOffset = scrollbars.length > 0
1562
+ ? 0
1563
+ : (scenario.scrollOffsets[`${definition.name}${instanceSuffix}`] ??
1564
+ scenario.scrollOffsets[`${definition.name}${legacyRowSuffix(instanceSuffix)}`] ??
1565
+ scenario.scrollOffsets[definition.name] ??
1566
+ scenario.scrollOffsets[definition.id] ??
1567
+ 0) * scale;
945
1568
  const availableClip = inheritedClip === undefined ? unclippedRect : rectIntersection(inheritedClip, unclippedRect);
946
1569
  const clipRect = inheritedClip;
947
1570
  const clipped = clipRect !== undefined &&
948
1571
  (availableClip === undefined || !equalRect(unclippedRect, availableClip));
949
- const visible = inheritedVisible &&
950
- (explicitlyVisible ?? (availableClip !== undefined || inheritedClip === undefined));
1572
+ const visible = resolveVisibleAppearance && (availableClip !== undefined || inheritedClip === undefined);
951
1573
  const clickThrough = scalarBoolean(property(definition.attributes, 'clickThrough', 'alwaystransparent', 'allwaystransparent')) ?? false;
952
- const scriptedEnabled = rowValues?.[`${definition.name}.enabled`] ??
953
- scenario.values[`${definition.name}.enabled`] ??
954
- scenario.scriptedGui[`${definition.name}.enabled`] ??
955
- context.constantElementEnabled[definition.name];
956
- const clickable = clickableTypes.test(definition.elementType) && !clickThrough && scriptedEnabled !== false;
957
- const state = scenario.elementStates[definition.name] ?? scenario.state;
958
- const zPriority = scalarNumber(property(definition.attributes, 'priority'), 0) ?? 0;
1574
+ const interactive = clickableTypes.test(definition.elementType);
1575
+ const interactionBlock = !interactive
1576
+ ? undefined
1577
+ : clickThrough
1578
+ ? 'click_through'
1579
+ : scriptedEnabled === false
1580
+ ? 'scripted_enabled_false'
1581
+ : requestedState === 'disabled' || requestedState === 'locked'
1582
+ ? 'scenario_state'
1583
+ : undefined;
1584
+ const disabledReason = interactive && !visible ? 'hidden' : interactionBlock;
1585
+ const clickable = interactive && disabledReason === undefined;
1586
+ const visibilityReason = visible
1587
+ ? 'visible'
1588
+ : !inheritedVisible
1589
+ ? 'parent_hidden'
1590
+ : explicitlyVisible === false
1591
+ ? 'visibility_false'
1592
+ : 'outside_clip';
959
1593
  let progressRatio;
960
- if (/progressbar/iu.test(definition.elementType)) {
1594
+ if (/progressbar/iu.test(definition.elementType) || spriteRenderMode === 'progressbar') {
961
1595
  const minimum = scalarNumber(property(definition.attributes, 'minValue'), 1) ?? 0;
962
1596
  const maximum = scalarNumber(property(definition.attributes, 'maxValue'), 1) ?? 100;
963
1597
  const startValueToken = scalarString(property(definition.attributes, 'startValue'));
@@ -978,10 +1612,15 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
978
1612
  value = maximum;
979
1613
  progressRatio =
980
1614
  maximum === minimum ? 0 : Math.max(0, Math.min(1, (value - minimum) / (maximum - minimum)));
1615
+ const explicitProgress = rowValues?.[`${definition.name}.progress`] ??
1616
+ scenario.values[`${definition.name}.progress`] ??
1617
+ scenario.scriptedGui[`${definition.name}.progress`];
1618
+ if (typeof explicitProgress === 'number')
1619
+ progressRatio = Math.max(0, Math.min(1, explicitProgress));
981
1620
  }
982
1621
  const sceneElement = {
983
1622
  id: instanceId,
984
- sourceId: definition.id,
1623
+ sourceId: definition.sourceOwnerId ?? definition.id,
985
1624
  name: definition.name,
986
1625
  elementType: definition.elementType,
987
1626
  ...(parentInstanceId === undefined
@@ -994,9 +1633,11 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
994
1633
  }
995
1634
  : { parentId: parentInstanceId }),
996
1635
  depth,
997
- zIndex: Math.trunc(zPriority * 1_000_000 + definition.definitionOrder),
1636
+ zIndex: context.output.length,
998
1637
  visible,
1638
+ visibilityReason,
999
1639
  clickable,
1640
+ ...(disabledReason === undefined ? {} : { disabledReason }),
1000
1641
  clickThrough,
1001
1642
  rect: availableClip ?? { x: unclippedRect.x, y: unclippedRect.y, width: 0, height: 0 },
1002
1643
  unclippedRect,
@@ -1008,6 +1649,9 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1008
1649
  ...(sprite === undefined ? {} : { sprite }),
1009
1650
  ...(secondarySprite === undefined ? {} : { secondarySprite }),
1010
1651
  ...(spriteRenderMode === undefined ? {} : { spriteRenderMode }),
1652
+ ...(spriteShader === undefined ? {} : { spriteShader }),
1653
+ ...(progressShader === undefined ? {} : { progressShader }),
1654
+ ...(progressColours === undefined ? {} : { progressColours }),
1011
1655
  ...(spriteDefinition?.borderSize === undefined
1012
1656
  ? {}
1013
1657
  : { spriteBorderSize: spriteDefinition.borderSize }),
@@ -1025,9 +1669,44 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1025
1669
  };
1026
1670
  context.output.push(sceneElement);
1027
1671
  context.instancesById.set(instanceId, sceneElement);
1672
+ context.geometry.set(instanceId, {
1673
+ appearanceVisible: resolveVisibleAppearance,
1674
+ ...(interactionBlock === undefined ? {} : { interactionBlock }),
1675
+ clipChain,
1676
+ contentRect,
1677
+ measuredRect: unclippedRect,
1678
+ });
1679
+ for (const [index, attributes] of backgrounds.entries()) {
1680
+ const backgroundSprite = scalarString(property(attributes, 'spriteType', 'quadTextureSprite'));
1681
+ const nativeSprite = backgroundSprite === undefined
1682
+ ? undefined
1683
+ : context.spritesByName.get(backgroundSprite.toLowerCase());
1684
+ const tiled = nativeSprite?.spriteType.toLowerCase() === 'corneredtilespritetype';
1685
+ const backgroundDefinition = {
1686
+ ...definition,
1687
+ id: `${definition.id}:background-${index}`,
1688
+ sourceOwnerId: definition.sourceOwnerId ?? definition.id,
1689
+ name: scalarString(property(attributes, 'name')) ?? `${definition.name}_background_${index}`,
1690
+ elementType: 'background',
1691
+ parentId: definition.id,
1692
+ childIds: [],
1693
+ attributes: {
1694
+ ...attributes,
1695
+ ...(tiled && property(attributes, 'size') === undefined
1696
+ ? { size: { width: width / scale, height: height / scale } }
1697
+ : {}),
1698
+ alwaystransparent: true,
1699
+ },
1700
+ unsupportedAttributes: Object.keys(attributes).filter((key) => guiElementAttributeFidelity(key) === 'unsupported'),
1701
+ };
1702
+ await layoutElement(backgroundDefinition, unclippedRect, inheritedClip, scale, depth + 1, context, instanceSuffix, rowIndex, rowValues, instanceId, 'upper_left', resolveVisibleAppearance, clipChain);
1703
+ addFidelity(fidelity, 'modelled', 'native_background', tiled
1704
+ ? 'Cornered background fills its container; fixed source borders retain their native size.'
1705
+ : 'Fixed background retains its native texture size and local position independently of the container bounds.', definition);
1706
+ }
1028
1707
  addFidelity(fidelity, 'modelled', 'nested_offset', `Positioned ${definition.name} at ${x},${y} with scale ${scale}.`, definition);
1029
1708
  for (const attribute of Object.keys(definition.attributes).sort((left, right) => compareCodeUnits(left, right))) {
1030
- const classification = guiElementAttributeFidelity(attribute);
1709
+ const classification = guiElementAttributeFidelity(attribute, definition.elementType);
1031
1710
  if (classification === 'structural' || classification === 'unsupported')
1032
1711
  continue;
1033
1712
  addFidelity(fidelity, classification, attribute, classification === 'modelled'
@@ -1041,29 +1720,73 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1041
1720
  const childParentRect = { ...unclippedRect, y: unclippedRect.y - scrollOffset };
1042
1721
  const childClip = ownClipping
1043
1722
  ? inheritedClip === undefined
1044
- ? unclippedRect
1045
- : rectIntersection(inheritedClip, unclippedRect)
1723
+ ? contentRect
1724
+ : (rectIntersection(inheritedClip, contentRect) ?? {
1725
+ ...contentRect,
1726
+ width: 0,
1727
+ height: 0,
1728
+ })
1046
1729
  : inheritedClip;
1047
- const childDefinitions = definition.childIds
1730
+ const childClipChain = ownClipping ? [...clipChain, contentRect] : clipChain;
1731
+ const childDefinitions = await scrollbarChildren(definition, definition.childIds
1048
1732
  .map((id) => context.elementsById.get(id))
1049
- .filter((element) => element !== undefined);
1050
- let rows = scenario.lists[definition.name] ?? scenario.lists[definition.id];
1733
+ .filter((element) => element !== undefined)
1734
+ .filter((element) => !/^(?:containerwindowtype|windowtype)$/iu.test(definition.elementType) ||
1735
+ element.elementType.toLowerCase() !== 'extendedscrollbartype')
1736
+ .sort((left, right) => (scalarNumber(property(left.attributes, 'priority'), 0) ?? 0) -
1737
+ (scalarNumber(property(right.attributes, 'priority'), 0) ?? 0) ||
1738
+ left.definitionOrder - right.definitionOrder), unclippedRect, scale, context, rowValues);
1739
+ const rowList = rowValues?.[`${definition.name}.list`];
1740
+ let rows = (typeof rowList === 'string' ? scenario.lists[rowList] : undefined) ??
1741
+ scenario.lists[`${definition.name}${instanceSuffix}`] ??
1742
+ scenario.lists[`${definition.name}${legacyRowSuffix(instanceSuffix)}`] ??
1743
+ scenario.lists[definition.name] ??
1744
+ scenario.lists[definition.id];
1051
1745
  if (state === 'empty-list')
1052
1746
  rows = [];
1053
1747
  if (state === 'full-list' && rows === undefined)
1054
1748
  rows = Array.from({ length: 12 }, (_unused, index) => ({ index }));
1055
1749
  const dynamicList = context.dynamicListsByName.get(definition.name);
1056
- const listElement = /(?:grid|listbox|scroll)/iu.test(definition.elementType);
1750
+ const listElement = /(?:grid|listbox|overlappingelements)/iu.test(definition.elementType);
1057
1751
  const slotSize = objectProperty(property(definition.attributes, 'slotsize'));
1058
1752
  const slotHeight = scaledNumber(slotSize === undefined ? undefined : property(slotSize, 'height', 'y'), height, scale);
1753
+ const slotWidth = scaledNumber(slotSize === undefined ? undefined : property(slotSize, 'width', 'x'), width, scale);
1754
+ const spacingValue = objectProperty(property(definition.attributes, 'spacing'));
1755
+ const spacingY = (scalarNumber(spacingValue === undefined
1756
+ ? property(definition.attributes, 'spacing')
1757
+ : property(spacingValue, 'y'), height) ?? 0) * scale;
1758
+ const spacingX = (scalarNumber(spacingValue === undefined
1759
+ ? property(definition.attributes, 'spacing')
1760
+ : property(spacingValue, 'x'), width) ?? 0) * scale;
1761
+ const maxHorizontal = scalarNumber(property(definition.attributes, 'max_slots_horizontal'), 1);
1762
+ const maxVertical = scalarNumber(property(definition.attributes, 'max_slots_vertical'), 1);
1763
+ const grid = /grid/iu.test(definition.elementType);
1764
+ const rowPosition = (index, fallbackY) => {
1765
+ if (!grid || slotWidth === undefined || slotHeight === undefined)
1766
+ return { x: 0, y: fallbackY };
1767
+ if (maxVertical !== undefined && maxVertical > 0) {
1768
+ const rowsPerColumn = Math.max(1, Math.trunc(maxVertical));
1769
+ return {
1770
+ x: Math.floor(index / rowsPerColumn) * (slotWidth + spacingX),
1771
+ y: (index % rowsPerColumn) * (slotHeight + spacingY),
1772
+ };
1773
+ }
1774
+ const fit = Math.max(1, Math.floor((width + spacingX) / Math.max(1, slotWidth + spacingX)));
1775
+ const columns = maxHorizontal === undefined || maxHorizontal <= 0
1776
+ ? fit
1777
+ : Math.max(1, Math.min(fit, Math.trunc(maxHorizontal)));
1778
+ return {
1779
+ x: (index % columns) * (slotWidth + spacingX),
1780
+ y: Math.floor(index / columns) * (slotHeight + spacingY),
1781
+ };
1782
+ };
1783
+ const firstChildOutput = context.output.length;
1784
+ const rowSuffix = (index) => `${instanceSuffix}#list-${definition.id}#row-${index}`;
1059
1785
  if (rows !== undefined && childDefinitions.length > 0 && listElement) {
1060
- const spacingValue = objectProperty(property(definition.attributes, 'spacing'));
1061
- const spacingY = scalarNumber(spacingValue === undefined
1062
- ? property(definition.attributes, 'spacing')
1063
- : property(spacingValue, 'y'), height) ?? 0;
1064
1786
  let rowY = 0;
1065
1787
  for (const [index, row] of rows.entries()) {
1066
1788
  context.work.spend('scenario list row expansion');
1789
+ const offset = rowPosition(index, rowY);
1067
1790
  let rowHeight = 0;
1068
1791
  for (const child of childDefinitions) {
1069
1792
  context.work.spend('scenario list child expansion');
@@ -1071,17 +1794,23 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1071
1794
  const originalY = scalarNumber(childPosition === undefined ? undefined : property(childPosition, 'y'), height) ?? 0;
1072
1795
  const shiftedParent = {
1073
1796
  ...childParentRect,
1074
- y: childParentRect.y + rowY - originalY * scale,
1797
+ x: childParentRect.x + offset.x,
1798
+ y: childParentRect.y + offset.y - originalY * scale,
1799
+ ...(slotWidth === undefined ? {} : { width: slotWidth }),
1800
+ ...(slotHeight === undefined ? {} : { height: slotHeight }),
1075
1801
  };
1076
- await layoutElement(child, shiftedParent, childClip, scale, depth + 1, context, `${instanceSuffix}#row-${index}`, index, row, instanceId, origo, visible);
1077
- const rendered = context.instancesById.get(`${child.id}${instanceSuffix}#row-${index}`);
1802
+ await layoutElement(child, shiftedParent, childClip, scale, depth + 1, context, rowSuffix(index), index, row, instanceId, origo, resolveVisibleAppearance, childClipChain);
1803
+ const rendered = context.instancesById.get(`${child.id}${rowSuffix(index)}`);
1078
1804
  rowHeight = Math.max(rowHeight, rendered?.unclippedRect.height ?? 0);
1079
1805
  }
1080
- rowY += (slotHeight ?? rowHeight) + spacingY * scale;
1806
+ rowY += (slotHeight ?? rowHeight) + spacingY;
1081
1807
  }
1082
1808
  addFidelity(fidelity, 'modelled', 'scroll_rows', `Expanded ${rows.length} scenario rows for ${definition.name}.`, definition);
1083
1809
  }
1084
- else if (rows !== undefined && dynamicList !== undefined && listElement) {
1810
+ else if (rows !== undefined &&
1811
+ listElement &&
1812
+ (dynamicList !== undefined ||
1813
+ rows.some(({ entryContainer }) => typeof entryContainer === 'string'))) {
1085
1814
  let rowY = 0;
1086
1815
  let renderedRows = 0;
1087
1816
  for (const [index, row] of rows.entries()) {
@@ -1091,8 +1820,8 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1091
1820
  ? explicitTemplate
1092
1821
  : undefined) ??
1093
1822
  (row.countryScope === true
1094
- ? (dynamicList.countryScopeEntryContainer ?? dynamicList.entryContainer)
1095
- : (dynamicList.entryContainer ?? dynamicList.countryScopeEntryContainer));
1823
+ ? (dynamicList?.countryScopeEntryContainer ?? dynamicList?.entryContainer)
1824
+ : (dynamicList?.entryContainer ?? dynamicList?.countryScopeEntryContainer));
1096
1825
  const template = templateName === undefined ? undefined : context.elementsByName.get(templateName);
1097
1826
  if (template === undefined) {
1098
1827
  diagnostics.push(diagnostic('GUI_DYNAMIC_LIST_TEMPLATE_MISSING', 'error', templateName === undefined
@@ -1101,24 +1830,41 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1101
1830
  continue;
1102
1831
  }
1103
1832
  const templatePosition = objectProperty(property(template.attributes, 'position'));
1833
+ const offset = rowPosition(index, rowY);
1834
+ const originalX = scalarNumber(templatePosition === undefined ? undefined : property(templatePosition, 'x'), width) ?? 0;
1104
1835
  const originalY = scalarNumber(templatePosition === undefined ? undefined : property(templatePosition, 'y'), height) ?? 0;
1105
1836
  const shiftedParent = {
1106
1837
  ...childParentRect,
1107
- y: childParentRect.y + rowY - originalY * scale,
1838
+ x: childParentRect.x + offset.x - originalX * scale,
1839
+ y: childParentRect.y + offset.y - originalY * scale,
1840
+ ...(slotWidth === undefined ? {} : { width: slotWidth }),
1841
+ ...(slotHeight === undefined ? {} : { height: slotHeight }),
1108
1842
  };
1109
- await layoutElement(template, shiftedParent, childClip, scale, depth + 1, context, `${instanceSuffix}#row-${index}`, index, row, instanceId, origo, visible);
1110
- const rendered = context.instancesById.get(`${template.id}${instanceSuffix}#row-${index}`);
1111
- rowY += slotHeight ?? rendered?.unclippedRect.height ?? 0;
1843
+ await layoutElement(template, shiftedParent, childClip, scale, depth + 1, context, rowSuffix(index), index, row, instanceId, origo, resolveVisibleAppearance, childClipChain);
1844
+ const rendered = context.instancesById.get(`${template.id}${rowSuffix(index)}`);
1845
+ rowY += (slotHeight ?? rendered?.unclippedRect.height ?? 0) + spacingY;
1112
1846
  renderedRows += 1;
1113
1847
  }
1114
- addFidelity(fidelity, renderedRows === rows.length ? 'modelled' : 'missing', 'scripted_gui_dynamic_list', `Expanded ${renderedRows}/${rows.length} scenario rows for ${definition.name} through its scripted-GUI entry containers.`, definition);
1848
+ addFidelity(fidelity, renderedRows === rows.length ? 'modelled' : 'missing', dynamicList === undefined ? 'native_dynamic_list' : 'scripted_gui_dynamic_list', `Expanded ${renderedRows}/${rows.length} scenario rows for ${definition.name} through ${dynamicList === undefined ? 'explicit native' : 'scripted-GUI'} entry containers.`, definition);
1115
1849
  }
1116
1850
  else {
1117
1851
  for (const child of childDefinitions) {
1118
1852
  context.work.spend('nested child expansion');
1119
- await layoutElement(child, childParentRect, childClip, scale, depth + 1, context, instanceSuffix, rowIndex, rowValues, instanceId, origo, visible);
1853
+ await layoutElement(child, childParentRect, childClip, scale, depth + 1, context, instanceSuffix, rowIndex, rowValues, instanceId, origo, resolveVisibleAppearance, childClipChain);
1120
1854
  }
1121
1855
  }
1856
+ const childOutput = context.output.slice(firstChildOutput);
1857
+ context.work.spend('child content measurement', childOutput.length);
1858
+ const directChildren = childOutput.filter((child) => child.parentId === instanceId && context.geometry.get(child.id).appearanceVisible);
1859
+ if (listElement && rows !== undefined) {
1860
+ // Declared list bounds remain a minimum even when its runtime row collection is short.
1861
+ context.geometry.get(instanceId).measuredRect = unionRects([
1862
+ unclippedRect,
1863
+ ...directChildren.map((child) => child.unclippedRect),
1864
+ ]);
1865
+ }
1866
+ if (scrollbars.length > 0)
1867
+ await layoutAttachedScrollbars(definition, scrollbars, sceneElement, childOutput, context, depth, scale, rowIndex, rowValues, instanceSuffix);
1122
1868
  }
1123
1869
  /** Build a deterministic GUI scene from the connected source graph and scenario. */
1124
1870
  export async function buildGuiScene(graph, scannedFiles, windowName, scenario, catalog = new GuiAssetCatalog(graph, scannedFiles)) {
@@ -1141,9 +1887,8 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
1141
1887
  const candidates = graph.elements.filter((element) => element.name === windowName);
1142
1888
  const root = candidates.toSorted((left, right) => compareCodeUnits(right.sourcePath, left.sourcePath) ||
1143
1889
  right.definitionOrder - left.definitionOrder)[0];
1144
- const baseScale = Math.min(scenario.resolution.width / 1920, scenario.resolution.height / 1080) *
1145
- scenario.uiScale;
1146
- addFidelity(fidelity, 'approximated', 'resolution_scale', `Coordinates use a 1920x1080 reference with UI scale ${scenario.uiScale}.`);
1890
+ const baseScale = scenario.uiScale;
1891
+ addFidelity(fidelity, 'modelled', 'resolution_scale', `Pixel dimensions use UI scale ${scenario.uiScale}; resolution changes the viewport and anchors, not the pixel scale.`);
1147
1892
  if (root === undefined) {
1148
1893
  diagnostics.push({
1149
1894
  code: 'GUI_WINDOW_MISSING',
@@ -1194,13 +1939,47 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
1194
1939
  const resolvedScriptedProperties = {};
1195
1940
  const dynamicListsByName = new Map();
1196
1941
  const countryFlagProperties = new Map();
1942
+ const conditionDefinitions = relevantScriptedGuiNames.size === 0
1943
+ ? undefined
1944
+ : ClausewitzEvaluationDefinitions.build({ files: [...scannedFiles] });
1945
+ const conditionScenario = guiConditionScenario(scenario);
1197
1946
  for (const scripted of graph.scriptedGuis.filter(({ name }) => relevantScriptedGuiNames.has(name))) {
1947
+ const source = scannedFiles.find(({ displayPath }) => displayPath === scripted.sourcePath);
1948
+ const subject = {
1949
+ id: scripted.id,
1950
+ provenance: [],
1951
+ ...(source === undefined
1952
+ ? {}
1953
+ : { document: parseClausewitz(source.bytes, source.displayPath) }),
1954
+ };
1955
+ for (const image of scripted.imageDefinitions ?? []) {
1956
+ let selected;
1957
+ let unresolved = false;
1958
+ for (const choice of image.choices) {
1959
+ const result = evaluateGuiCondition(choice.conditionExpression, conditionScenario, subject, conditionDefinitions);
1960
+ if (result.state === 'unresolved') {
1961
+ unresolved = true;
1962
+ addFidelity(fidelity, 'unresolved', 'focus_inlay_image', `${scripted.name}.${image.elementName} requires more scenario facts before selecting a sprite.`);
1963
+ break;
1964
+ }
1965
+ if (result.state === 'true') {
1966
+ selected = choice.spriteName;
1967
+ break;
1968
+ }
1969
+ }
1970
+ if (unresolved)
1971
+ continue;
1972
+ constantElementVisibility[image.elementName] = selected !== undefined;
1973
+ if (selected !== undefined)
1974
+ resolvedScriptedProperties[`${image.elementName}.image`] = selected;
1975
+ addFidelity(fidelity, 'modelled', 'focus_inlay_image', `${scripted.name}.${image.elementName} ${selected === undefined ? 'has no matching sprite and is hidden' : `selects ${selected}`}.`);
1976
+ }
1198
1977
  for (const dynamicList of scripted.dynamicListDefinitions)
1199
1978
  dynamicListsByName.set(dynamicList.name, dynamicList);
1200
1979
  for (const propertyDefinition of scripted.propertyDefinitions) {
1201
1980
  for (const [attribute, expression] of Object.entries(propertyDefinition.attributes)) {
1202
1981
  const suffix = attribute.toLowerCase();
1203
- if (!['image', 'frame', 'x', 'y', 'visible', 'enabled'].includes(suffix))
1982
+ if (!['image', 'frame', 'x', 'y', 'visible', 'enabled', 'progress'].includes(suffix))
1204
1983
  continue;
1205
1984
  const countryFlagExpression = suffix === 'image' &&
1206
1985
  typeof expression === 'string' &&
@@ -1209,7 +1988,10 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
1209
1988
  countryFlagProperties.set(propertyDefinition.elementName, expression);
1210
1989
  if (countryFlagExpression)
1211
1990
  continue;
1212
- const resolved = scenarioExpressionValue(expression, scenario.values);
1991
+ const resolved = scenarioExpressionValue(expression, {
1992
+ ...scenario.variables,
1993
+ ...scenario.values,
1994
+ });
1213
1995
  if (resolved === undefined)
1214
1996
  continue;
1215
1997
  const key = `${propertyDefinition.elementName}.${suffix}`;
@@ -1219,28 +2001,44 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
1219
2001
  }
1220
2002
  }
1221
2003
  for (const trigger of scripted.triggerDefinitions) {
1222
- if (trigger.constantResult === undefined)
2004
+ const evaluated = trigger.conditionExpression === undefined
2005
+ ? undefined
2006
+ : evaluateGuiCondition(trigger.conditionExpression, conditionScenario, subject, conditionDefinitions);
2007
+ const result = evaluated === undefined
2008
+ ? trigger.constantResult
2009
+ : evaluated.state === 'unresolved'
2010
+ ? undefined
2011
+ : evaluated.state === 'true';
2012
+ if (result === undefined) {
2013
+ addFidelity(fidelity, 'unresolved', 'scripted_gui_trigger', `${trigger.name} requires more scenario facts.`);
1223
2014
  continue;
2015
+ }
1224
2016
  if (trigger.name.endsWith('_visible'))
1225
- constantElementVisibility[trigger.elementName] = trigger.constantResult;
2017
+ constantElementVisibility[trigger.elementName] = result;
1226
2018
  if (trigger.name.endsWith('_click_enabled'))
1227
- constantElementEnabled[trigger.elementName] = trigger.constantResult;
1228
- addFidelity(fidelity, 'modelled', 'scripted_gui_constant_trigger', `${trigger.name} resolves to ${trigger.constantResult ? 'yes' : 'no'}.`);
2019
+ constantElementEnabled[trigger.elementName] = result;
2020
+ addFidelity(fidelity, 'modelled', trigger.constantResult === undefined
2021
+ ? 'scripted_gui_trigger'
2022
+ : 'scripted_gui_constant_trigger', `${trigger.name} resolves to ${result ? 'yes' : 'no'}.`);
1229
2023
  }
1230
2024
  addFidelity(fidelity, 'modelled', 'scripted_gui_context', `${scripted.name} uses ${scripted.contextType ?? '<unspecified>'} context.`);
1231
2025
  if (scripted.visibleExpression === undefined)
1232
2026
  continue;
2027
+ const evaluatedVisibility = evaluateGuiCondition(scripted.visibleExpression, conditionScenario, subject, conditionDefinitions);
1233
2028
  const mockedVisibility = scenario.visibility[scripted.name] ??
1234
2029
  scenario.values[`${scripted.name}.visible`] ??
1235
- scenario.scriptedGui[`${scripted.name}.visible`];
2030
+ scenario.scriptedGui[`${scripted.name}.visible`] ??
2031
+ (evaluatedVisibility.state === 'unresolved'
2032
+ ? undefined
2033
+ : evaluatedVisibility.state === 'true');
1236
2034
  if (typeof mockedVisibility === 'boolean') {
1237
2035
  if (scripted.windowName !== undefined)
1238
2036
  scriptedWindowVisibility[scripted.windowName] =
1239
2037
  (scriptedWindowVisibility[scripted.windowName] ?? true) && mockedVisibility;
1240
- addFidelity(fidelity, 'modelled', 'scripted_gui_visibility', `${scripted.name}.visible was supplied by the preview scenario.`);
2038
+ addFidelity(fidelity, 'modelled', 'scripted_gui_visibility', `${scripted.name}.visible resolves to ${mockedVisibility}.`);
1241
2039
  }
1242
2040
  else {
1243
- addFidelity(fidelity, 'unresolved', 'scripted_gui_visibility', `${scripted.name}.visible requires an explicit scenario mock; the offline renderer leaves it visible.`);
2041
+ addFidelity(fidelity, 'unresolved', 'scripted_gui_visibility', `${scripted.name}.visible requires more scenario facts; its source visibility remains unresolved.`);
1244
2042
  }
1245
2043
  }
1246
2044
  const layoutScenario = {
@@ -1276,6 +2074,7 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
1276
2074
  localisation,
1277
2075
  output,
1278
2076
  instancesById: new Map(),
2077
+ geometry: new Map(),
1279
2078
  work: new GuiSceneWorkBudget(),
1280
2079
  baseScale,
1281
2080
  };
@@ -1308,7 +2107,7 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
1308
2107
  if (parentScene === undefined || attachedDefinition === undefined)
1309
2108
  continue;
1310
2109
  if (!context.instancesById.has(attachedDefinition.id)) {
1311
- await layoutElement(attachedDefinition, parentScene.unclippedRect, parentScene.unclippedRect, parentScene.scale, parentScene.depth + 1, context, '', undefined, undefined, parentScene.id, 'upper_left', parentScene.visible);
2110
+ await layoutElement(attachedDefinition, parentScene.unclippedRect, parentScene.unclippedRect, parentScene.scale, parentScene.depth + 1, context, '', undefined, undefined, parentScene.id, 'upper_left', context.geometry.get(parentScene.id).appearanceVisible, [...context.geometry.get(parentScene.id).clipChain, parentScene.unclippedRect]);
1312
2111
  addFidelity(fidelity, 'modelled', 'parent_scripted_gui_attachment', `${scripted.windowName} is attached to ${parentWindowName} through ${scripted.parentScriptedGui}.`, attachedDefinition);
1313
2112
  }
1314
2113
  pendingAttached.splice(index, 1);
@@ -1324,8 +2123,40 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
1324
2123
  ...(scripted.location === undefined ? {} : { location: scripted.location }),
1325
2124
  });
1326
2125
  }
2126
+ refreshSceneClips(context);
1327
2127
  output.sort((left, right) => left.zIndex - right.zIndex || compareCodeUnits(left.id, right.id));
1328
- const bounds = unionRects(output.filter(({ visible }) => visible).map(({ unclippedRect }) => unclippedRect));
2128
+ const bounds = unionRects(output.flatMap((element) => {
2129
+ if (!element.visible)
2130
+ return [];
2131
+ const painted = element.sprite === undefined && element.progressColours === undefined ? [] : [element.rect];
2132
+ const text = element.text;
2133
+ if (text !== undefined && text.text.length > 0) {
2134
+ const rect = element.unclippedRect;
2135
+ let textRect = {
2136
+ x: rect.x +
2137
+ (text.horizontalAlignment === 'center'
2138
+ ? (rect.width - text.measuredWidth) / 2
2139
+ : text.horizontalAlignment === 'right'
2140
+ ? rect.width - text.measuredWidth
2141
+ : 0),
2142
+ y: rect.y +
2143
+ (text.verticalAlignment === 'center'
2144
+ ? (rect.height - text.measuredHeight) / 2
2145
+ : text.verticalAlignment === 'bottom'
2146
+ ? rect.height - text.measuredHeight
2147
+ : 0),
2148
+ width: text.measuredWidth,
2149
+ height: text.measuredHeight,
2150
+ };
2151
+ if (text.fixedSize)
2152
+ textRect = rectIntersection(textRect, rect);
2153
+ if (textRect !== undefined && element.clipRect !== undefined)
2154
+ textRect = rectIntersection(textRect, element.clipRect);
2155
+ if (textRect !== undefined)
2156
+ painted.push(textRect);
2157
+ }
2158
+ return painted;
2159
+ }));
1329
2160
  return {
1330
2161
  windowName,
1331
2162
  scenario,