hoi4-agent-tools 3.2.0 → 3.4.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 (99) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -0
  3. package/dist/hoi4_agent_tools/core/analysis-job-operations.js +11 -1
  4. package/dist/hoi4_agent_tools/core/condition-evaluator.js +18 -9
  5. package/dist/hoi4_agent_tools/core/helper-expansion.js +3 -0
  6. package/dist/hoi4_agent_tools/core/job-worker-host.js +3 -0
  7. package/dist/hoi4_agent_tools/core/operation-tasks.js +7 -0
  8. package/dist/hoi4_agent_tools/core/scenario-model.js +85 -0
  9. package/dist/hoi4_agent_tools/event/comparison-selection.js +78 -0
  10. package/dist/hoi4_agent_tools/event/helper-expansion.js +48 -0
  11. package/dist/hoi4_agent_tools/event/operations.js +16 -0
  12. package/dist/hoi4_agent_tools/event/service.js +21 -2
  13. package/dist/hoi4_agent_tools/focus/compact.js +241 -27
  14. package/dist/hoi4_agent_tools/focus/layout.js +119 -8
  15. package/dist/hoi4_agent_tools/focus/render.js +62 -0
  16. package/dist/hoi4_agent_tools/focus/scenario.js +99 -0
  17. package/dist/hoi4_agent_tools/focus/tool-operations.js +30 -4
  18. package/dist/hoi4_agent_tools/gui/assets.js +54 -4
  19. package/dist/hoi4_agent_tools/gui/layout.js +935 -104
  20. package/dist/hoi4_agent_tools/gui/renderer.js +47 -17
  21. package/dist/hoi4_agent_tools/gui/scenario-conditions.js +2 -23
  22. package/dist/hoi4_agent_tools/gui/shader.js +515 -0
  23. package/dist/hoi4_agent_tools/gui/source-graph.js +166 -6
  24. package/dist/hoi4_agent_tools/gui/studio.js +245 -19
  25. package/dist/hoi4_agent_tools/gui/tool-operations.js +9 -0
  26. package/dist/hoi4_agent_tools/gui/validators.js +38 -12
  27. package/dist/hoi4_agent_tools/map/operations.js +70 -1
  28. package/dist/hoi4_agent_tools/map/render.js +70 -1
  29. package/dist/hoi4_agent_tools/map/service.js +30 -6
  30. package/dist/hoi4_agent_tools/map/tool-operations.js +67 -1
  31. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  32. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +3 -0
  33. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +90 -0
  34. package/dist/hoi4_agent_tools/mcp/tools/event.js +10 -1
  35. package/dist/hoi4_agent_tools/mcp/tools/focus.js +14 -4
  36. package/dist/hoi4_agent_tools/mcp/tools/gui.js +5 -1
  37. package/dist/hoi4_agent_tools/mcp/tools/map.js +12 -2
  38. package/dist/hoi4_agent_tools/mcp/tools/probability.js +5 -0
  39. package/dist/hoi4_agent_tools/mechanic/assertions.js +112 -0
  40. package/dist/hoi4_agent_tools/mechanic/interpreter.js +619 -0
  41. package/dist/hoi4_agent_tools/mechanic/service.js +145 -0
  42. package/dist/hoi4_agent_tools/mechanic/source.js +89 -0
  43. package/dist/hoi4_agent_tools/package-check/service.js +231 -0
  44. package/dist/hoi4_agent_tools/probability/evaluation.js +6 -1
  45. package/dist/hoi4_agent_tools/probability/operations.js +8 -0
  46. package/dist/hoi4_agent_tools/probability/service.js +33 -12
  47. package/dist/hoi4_agent_tools/probability/source-analysis.js +1 -0
  48. package/dist/hoi4_agent_tools/scenario-suite/service.js +341 -0
  49. package/dist/hoi4_agent_tools/schemas/event.js +8 -0
  50. package/dist/hoi4_agent_tools/schemas/focus-requests.js +40 -1
  51. package/dist/hoi4_agent_tools/schemas/gui-requests.js +8 -0
  52. package/dist/hoi4_agent_tools/schemas/map-requests.js +22 -1
  53. package/dist/hoi4_agent_tools/schemas/scenarios.js +264 -0
  54. package/dist/hoi4_agent_tools/schemas/technology.js +7 -0
  55. package/dist/hoi4_agent_tools/technology/graph.js +25 -10
  56. package/dist/hoi4_agent_tools/technology/render.js +286 -31
  57. package/dist/hoi4_agent_tools/technology/service.js +55 -8
  58. package/dist/hoi4_agent_tools/technology/source-analysis.js +166 -3
  59. package/dist/hoi4_agent_tools/version.js +1 -1
  60. package/docs/README.md +1 -0
  61. package/docs/events.md +3 -1
  62. package/docs/focus.md +10 -0
  63. package/docs/gui.md +49 -3
  64. package/docs/map.md +8 -0
  65. package/docs/mechanics.md +70 -0
  66. package/docs/probability.md +3 -1
  67. package/docs/technology.md +7 -2
  68. package/package.json +2 -1
  69. package/schemas/configuration.schema.json +1 -1
  70. package/schemas/continuous-focus-palette.schema.json +1 -1
  71. package/schemas/custom-weighted-pool.schema.json +1 -1
  72. package/schemas/decision-inspect-input.schema.json +1 -1
  73. package/schemas/event-feature-manifest.schema.json +1 -1
  74. package/schemas/focus-plan.schema.json +1 -1
  75. package/schemas/focus-planning-sidecar.schema.json +1 -1
  76. package/schemas/gui-animation-source.schema.json +1 -1
  77. package/schemas/gui-helper.schema.json +1 -1
  78. package/schemas/gui-scenario.schema.json +1 -1
  79. package/schemas/helper-expansion-request.schema.json +1 -1
  80. package/schemas/helper-expansion-summary.schema.json +1 -1
  81. package/schemas/impact-inspect-input.schema.json +1 -1
  82. package/schemas/map-operation.schema.json +1 -1
  83. package/schemas/mechanic-test-input.schema.json +650 -0
  84. package/schemas/operation-result.schema.json +1 -1
  85. package/schemas/package-check-input.schema.json +164 -0
  86. package/schemas/package-manifest.schema.json +142 -0
  87. package/schemas/probability-analysis-result.schema.json +1 -1
  88. package/schemas/probability-compare-input.schema.json +1 -1
  89. package/schemas/probability-evaluate-input.schema.json +1 -1
  90. package/schemas/probability-inspect-input.schema.json +1 -1
  91. package/schemas/probability-render-input.schema.json +1 -1
  92. package/schemas/probability-scenario-set.schema.json +1 -1
  93. package/schemas/probability-sequence-input.schema.json +1 -1
  94. package/schemas/probability-simulate-input.schema.json +1 -1
  95. package/schemas/probability-sweep-input.schema.json +1 -1
  96. package/schemas/scenario-suite.schema.json +1004 -0
  97. package/schemas/scenario-test-input.schema.json +1057 -0
  98. package/schemas/shared-scenario.schema.json +237 -0
  99. package/server.json +2 -2
@@ -241,8 +241,8 @@ function renderCorneredTile(element) {
241
241
  if (sprite?.supported !== true ||
242
242
  sprite.dataUri === undefined ||
243
243
  border === undefined ||
244
- border.width <= 0 ||
245
- border.height <= 0)
244
+ border.width < 0 ||
245
+ border.height < 0)
246
246
  return sprite === undefined ? '' : renderSpriteImage(sprite, rect);
247
247
  const sourceBorderX = Math.min(border.width, sprite.width / 2);
248
248
  const sourceBorderY = Math.min(border.height, sprite.height / 2);
@@ -299,11 +299,6 @@ function renderProgressbar(element) {
299
299
  const sprite = element.sprite;
300
300
  const secondary = element.secondarySprite;
301
301
  const ratio = Math.max(0, Math.min(1, element.progressRatio ?? 1));
302
- const background = secondary === undefined ? '' : renderSpriteImage(secondary, rect);
303
- if (sprite?.supported !== true || sprite.dataUri === undefined)
304
- return background;
305
- if (ratio <= 0)
306
- return background;
307
302
  const horizontal = element.progressHorizontal !== false;
308
303
  const filled = horizontal
309
304
  ? { x: rect.x, y: rect.y, width: rect.width * ratio, height: rect.height }
@@ -313,15 +308,36 @@ function renderProgressbar(element) {
313
308
  width: rect.width,
314
309
  height: rect.height * ratio,
315
310
  };
316
- const source = horizontal
317
- ? { x: 0, y: 0, width: sprite.width * ratio, height: sprite.height }
318
- : {
319
- x: 0,
320
- y: sprite.height * (1 - ratio),
321
- width: sprite.width,
322
- height: sprite.height * ratio,
311
+ const remainder = horizontal
312
+ ? { x: rect.x + filled.width, y: rect.y, width: rect.width - filled.width, height: rect.height }
313
+ : { x: rect.x, y: rect.y, width: rect.width, height: rect.height - filled.height };
314
+ // A threshold shader selects a texture once per pixel. Painting the filled region
315
+ // over a complete secondary layer would incorrectly blend both sources at partial alpha.
316
+ const layer = (texture, region, colour) => {
317
+ if (region.width <= 0 || region.height <= 0)
318
+ return '';
319
+ if (colour !== undefined)
320
+ return `<rect ${rectAttributes(region)} fill="${escapeXml(colour)}"/>`;
321
+ if (texture?.supported !== true ||
322
+ texture.dataUri === undefined ||
323
+ rect.width <= 0 ||
324
+ rect.height <= 0)
325
+ return '';
326
+ const source = {
327
+ x: ((region.x - rect.x) / rect.width) * texture.width,
328
+ y: ((region.y - rect.y) / rect.height) * texture.height,
329
+ width: (region.width / rect.width) * texture.width,
330
+ height: (region.height / rect.height) * texture.height,
323
331
  };
324
- return `${background}${renderSpriteSlice(sprite, source, filled)}`;
332
+ return renderSpriteSlice(texture, source, region);
333
+ };
334
+ const content = `${layer(secondary, remainder, element.progressColours?.second)}${layer(sprite, filled, element.progressColours?.first)}`;
335
+ const filtering = element.progressShader?.textureFiltering;
336
+ if (filtering === undefined)
337
+ return content;
338
+ const shrinking = [sprite, secondary].some((texture) => texture !== undefined && (rect.width < texture.width || rect.height < texture.height));
339
+ const mode = filtering[shrinking ? 'minification' : 'magnification'];
340
+ return `<g image-rendering="${mode === 'nearest' ? 'optimizeSpeed' : 'optimizeQuality'}">${content}</g>`;
325
341
  }
326
342
  function renderMaskedShield(element) {
327
343
  const rect = element.unclippedRect;
@@ -351,8 +367,22 @@ function renderBaseElement(element, clipId, toolText) {
351
367
  const rect = element.unclippedRect;
352
368
  const clip = clipId === undefined ? '' : ` clip-path="url(#${clipId})"`;
353
369
  const content = [];
354
- if (element.sprite?.supported === true && element.sprite.dataUri !== undefined) {
355
- content.push(renderElementSprite(element));
370
+ if (element.spriteRenderMode === 'progressbar' && element.progressColours !== undefined) {
371
+ content.push(renderProgressbar(element));
372
+ }
373
+ else if (element.sprite?.supported === true && element.sprite.dataUri !== undefined) {
374
+ const sprite = renderElementSprite(element);
375
+ if (element.spriteShader === undefined)
376
+ content.push(sprite);
377
+ else {
378
+ const id = `gui-shader-${sha256Bytes(element.id).slice(0, 20)}`;
379
+ const matrix = element.spriteShader.colourMatrix
380
+ .map((coefficient) => Number(coefficient.toFixed(9)))
381
+ .join(' ');
382
+ const shrinking = rect.width < element.sprite.width || rect.height < element.sprite.height;
383
+ const sampling = element.spriteShader.textureFiltering[shrinking ? 'minification' : 'magnification'];
384
+ content.push(`<defs><filter id="${id}" filterUnits="userSpaceOnUse" ${rectAttributes(rect)} color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="${matrix}"/></filter></defs><g filter="url(#${id})" image-rendering="${sampling === 'nearest' ? 'optimizeSpeed' : 'optimizeQuality'}">${sprite}</g>`);
385
+ }
356
386
  }
357
387
  else if (element.sprite !== undefined) {
358
388
  content.push(`<rect ${rectAttributes(rect)} fill="#331f3f" stroke="#ff42d0" stroke-width="1"/><path d="M ${finite(rect.x)} ${finite(rect.y)} L ${finite(rect.x + rect.width)} ${finite(rect.y + rect.height)} M ${finite(rect.x + rect.width)} ${finite(rect.y)} L ${finite(rect.x)} ${finite(rect.y + rect.height)}" stroke="#ff42d0"/>`);
@@ -1,6 +1,7 @@
1
1
  import { ClausewitzEvaluationDefinitions } from '../core/clausewitz-evaluation.js';
2
2
  import { hasBlockingDiagnostics } from '../core/diagnostics.js';
3
3
  import { evaluateTriggerBlock } from '../core/condition-evaluator.js';
4
+ import { scenarioFromGui } from '../core/scenario-model.js';
4
5
  import { resolveScopeContext, rootScopeContext } from '../core/scenario-state.js';
5
6
  import { assignments, firstScalar, parseClausewitz } from '../core/source/index.js';
6
7
  /** Keep declared scoped scalars out of exploratory value generation. */
@@ -30,29 +31,7 @@ function parsedCondition(expression) {
30
31
  };
31
32
  }
32
33
  export function guiConditionScenario(base, values = base.values) {
33
- const state = { ...base.stateValues, ...base.variables, ...values };
34
- for (const [key, value] of Object.entries(base.flags))
35
- state[`flag.${key}`] = value;
36
- const scopes = structuredClone(base.scopes ?? {});
37
- for (const [key, value] of Object.entries(state)) {
38
- const match = /^(ROOT|THIS|FROM(?:\.FROM)*|PREV)\.(.+)$/u.exec(key);
39
- if (match === null)
40
- continue;
41
- if (match[1] === 'ROOT' || match[1] === 'THIS')
42
- state[match[2]] ??= value;
43
- else {
44
- const scope = (scopes[match[1]] ??= { id: match[1], state: {} });
45
- scope.state[match[2]] ??= value;
46
- }
47
- }
48
- const actor = base.country?.tag ?? base.country?.countryTag ?? base.country?.GetTag;
49
- return {
50
- id: base.id,
51
- state,
52
- scopes,
53
- closedFlags: false,
54
- ...(typeof actor === 'string' ? { actor } : {}),
55
- };
34
+ return scenarioFromGui(base, values);
56
35
  }
57
36
  export function evaluateGuiCondition(expression, scenario, subject, definitions = new ClausewitzEvaluationDefinitions(), scope = 'ROOT') {
58
37
  const context = resolveScopeContext(scenario, scope, rootScopeContext(scenario));
@@ -0,0 +1,515 @@
1
+ // This is a bounded colour-expression interpreter, never a JavaScript/HLSL executor.
2
+ // Unsupported sampling, geometry, branches and nonlinear colour operations stay explicit.
3
+ export const GUI_SHADER_MAX_BYTES = 262_144;
4
+ const maxTokens = 4_096;
5
+ const maxTokenLength = 256;
6
+ const maxDepth = 64;
7
+ const maxStatements = 128;
8
+ const maxCoefficient = 1_000_000;
9
+ const constant = (value) => [0, 0, 0, 0, value];
10
+ const isConstant = (value) => value.slice(0, 4).every((entry) => entry === 0);
11
+ const identity = [
12
+ [1, 0, 0, 0, 0],
13
+ [0, 1, 0, 0, 0],
14
+ [0, 0, 1, 0, 0],
15
+ [0, 0, 0, 1, 0],
16
+ ];
17
+ class UnsupportedShader extends Error {
18
+ }
19
+ function unsupported(reason) {
20
+ throw new UnsupportedShader(reason.length > 512 ? `${reason.slice(0, 509)}...` : reason);
21
+ }
22
+ function checked(value) {
23
+ if (value.length !== 5 ||
24
+ value.some((entry) => !Number.isFinite(entry) || Math.abs(entry) > maxCoefficient))
25
+ unsupported('Shader colour arithmetic exceeds its finite coefficient budget.');
26
+ return value;
27
+ }
28
+ function binary(left, right, operator) {
29
+ const width = Math.max(left.length, right.length);
30
+ if (![1, width].includes(left.length) || ![1, width].includes(right.length))
31
+ unsupported('Shader vector widths do not agree.');
32
+ return Array.from({ length: width }, (_, index) => {
33
+ const a = left[left.length === 1 ? 0 : index];
34
+ const b = right[right.length === 1 ? 0 : index];
35
+ if (operator === '+' || operator === '-')
36
+ return checked(a.map((entry, component) => entry + (operator === '+' ? 1 : -1) * b[component]));
37
+ if (operator === '*') {
38
+ if (isConstant(a))
39
+ return checked(b.map((entry) => entry * a[4]));
40
+ if (isConstant(b))
41
+ return checked(a.map((entry) => entry * b[4]));
42
+ unsupported('Multiplication of two sampled colour expressions is nonlinear.');
43
+ }
44
+ if (operator === '/' && isConstant(b) && b[4] !== 0)
45
+ return checked(a.map((entry) => entry / b[4]));
46
+ return unsupported('Shader division requires a finite nonzero constant divisor.');
47
+ });
48
+ }
49
+ function components(swizzle, width) {
50
+ const alphabet = /^[rgba]+$/u.test(swizzle) ? 'rgba' : 'xyzw';
51
+ const result = Array.from({ length: swizzle.length }, (_, index) => alphabet.indexOf(swizzle.charAt(index)));
52
+ if (result.length === 0 ||
53
+ result.length > 4 ||
54
+ result.some((index) => index < 0 || index >= width))
55
+ unsupported('Shader swizzle is outside its vector.');
56
+ return result;
57
+ }
58
+ function withoutComments(source) {
59
+ const stripped = source.replace(/\/\*[\s\S]*?\*\//gu, ' ').replace(/\/\/[^\r\n]*/gu, '');
60
+ if (stripped.includes('/*'))
61
+ unsupported('Shader has an unterminated comment.');
62
+ return stripped;
63
+ }
64
+ function inactiveFeatures(source) {
65
+ const stack = [];
66
+ let active = true;
67
+ const output = [];
68
+ for (const line of source.split(/\r?\n/u)) {
69
+ const directive = /^\s*[#@](\w+)(?:\s+([\s\S]*?))?\s*$/u.exec(line);
70
+ if (directive === null) {
71
+ if (active)
72
+ output.push(line);
73
+ continue;
74
+ }
75
+ const [, command, argument] = directive;
76
+ if (command === 'ifdef' || command === 'ifndef') {
77
+ if (!['ANIMATED', 'MASKING'].includes(argument ?? ''))
78
+ unsupported(`Shader feature ${argument ?? '(missing)'} has no supported binding.`);
79
+ if (stack.length >= maxDepth)
80
+ unsupported('Shader preprocessor nesting exceeds its budget.');
81
+ const condition = command === 'ifndef';
82
+ stack.push({ parent: active, condition, alternative: false });
83
+ active = active && condition;
84
+ }
85
+ else if (command === 'else' && argument === undefined) {
86
+ const branch = stack.at(-1);
87
+ if (branch === undefined || branch.alternative)
88
+ unsupported('Shader has an unmatched else.');
89
+ branch.alternative = true;
90
+ active = branch.parent && !branch.condition;
91
+ }
92
+ else if (command === 'endif' && argument === undefined) {
93
+ const branch = stack.pop();
94
+ if (branch === undefined)
95
+ unsupported('Shader has an unmatched endif.');
96
+ active = branch.parent;
97
+ }
98
+ else
99
+ unsupported(`Shader directive ${command ?? '(missing)'} is not supported.`);
100
+ }
101
+ if (stack.length !== 0)
102
+ unsupported('Shader has an unterminated conditional feature.');
103
+ return output.join('\n');
104
+ }
105
+ function tokenize(source) {
106
+ const tokens = [];
107
+ const pattern = /\s+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[fF]?|[A-Za-z_]\w*|[+*/-]=|[{}()[\],;.=+*/-]/guy;
108
+ let offset = 0;
109
+ while (offset < source.length) {
110
+ pattern.lastIndex = offset;
111
+ const token = pattern.exec(source)?.[0];
112
+ if (token === undefined)
113
+ unsupported('Shader contains unsupported expression syntax.');
114
+ offset += token.length;
115
+ if (token.trim().length === 0)
116
+ continue;
117
+ if (token.length > maxTokenLength)
118
+ unsupported('Shader token length exceeds its budget.');
119
+ tokens.push(token);
120
+ if (tokens.length > maxTokens)
121
+ unsupported('Shader token count exceeds its budget.');
122
+ }
123
+ return tokens;
124
+ }
125
+ class ColourInterpreter {
126
+ tokens;
127
+ inputName;
128
+ offset = 0;
129
+ depth = 0;
130
+ variables = new Map();
131
+ constructor(tokens, inputName, uniforms) {
132
+ this.tokens = tokens;
133
+ this.inputName = inputName;
134
+ this.variables.set('Color', (uniforms.colour ?? [1, 1, 1, 1]).map(constant));
135
+ this.variables.set('Time', [constant(uniforms.time)]);
136
+ this.variables.set('AnimationTime', [constant(uniforms.animationTime)]);
137
+ }
138
+ peek() {
139
+ return this.tokens[this.offset];
140
+ }
141
+ take() {
142
+ const token = this.tokens[this.offset++];
143
+ return token ?? unsupported('Shader expression ended unexpectedly.');
144
+ }
145
+ require(token) {
146
+ if (this.take() !== token)
147
+ unsupported(`Shader expected ${token}.`);
148
+ }
149
+ identifier() {
150
+ const token = this.take();
151
+ return /^[A-Za-z_]\w*$/u.test(token) ? token : unsupported('Shader expected an identifier.');
152
+ }
153
+ functionCall(name) {
154
+ this.require('(');
155
+ if (name === 'tex2D') {
156
+ this.require('MapTexture');
157
+ this.require(',');
158
+ this.require(this.inputName);
159
+ this.require('.');
160
+ this.require('vTexCoord');
161
+ this.require(')');
162
+ return identity;
163
+ }
164
+ const args = [];
165
+ if (this.peek() !== ')') {
166
+ for (;;) {
167
+ args.push(this.expression());
168
+ if (args.length > 4)
169
+ unsupported('Shader call exceeds its argument budget.');
170
+ if (this.peek() !== ',')
171
+ break;
172
+ this.take();
173
+ }
174
+ }
175
+ this.require(')');
176
+ const constructor = /^float([1-4])?$/u.exec(name);
177
+ if (constructor !== null) {
178
+ const width = Number(constructor[1] ?? 1);
179
+ const channels = args.flat();
180
+ if (channels.length === 1)
181
+ return Array.from({ length: width }, () => channels[0]);
182
+ if (channels.length !== width)
183
+ unsupported('Shader vector constructor has the wrong width.');
184
+ return channels;
185
+ }
186
+ if (name === 'dot' && args.length === 2 && args[0].length === args[1].length) {
187
+ const product = binary(args[0], args[1], '*');
188
+ return [product.reduce((sum, channel) => binary([sum], [channel], '+')[0], constant(0))];
189
+ }
190
+ if (name === 'lerp' && args.length === 3)
191
+ return binary(args[0], binary(binary(args[1], args[0], '-'), args[2], '*'), '+');
192
+ if (['saturate', 'abs'].includes(name) && args.length === 1)
193
+ return args[0].map((channel) => {
194
+ if (!isConstant(channel))
195
+ unsupported(`${name} of sampled colour is nonlinear.`);
196
+ return constant(name === 'abs' ? Math.abs(channel[4]) : Math.max(0, Math.min(1, channel[4])));
197
+ });
198
+ if (['min', 'max', 'pow'].includes(name) &&
199
+ args.length === 2 &&
200
+ args.every((arg) => arg.length === 1 && isConstant(arg[0]))) {
201
+ const a = args[0][0][4];
202
+ const b = args[1][0][4];
203
+ return [
204
+ checked([
205
+ 0,
206
+ 0,
207
+ 0,
208
+ 0,
209
+ name === 'min' ? Math.min(a, b) : name === 'max' ? Math.max(a, b) : Math.pow(a, b),
210
+ ]),
211
+ ];
212
+ }
213
+ return unsupported(`Shader call ${name} is not a supported affine colour operation.`);
214
+ }
215
+ primary() {
216
+ const token = this.take();
217
+ let value;
218
+ if (token === '(') {
219
+ value = this.expression();
220
+ this.require(')');
221
+ }
222
+ else if (token === '-' || token === '+') {
223
+ value = binary([constant(token === '-' ? -1 : 1)], this.expression(3), '*');
224
+ }
225
+ else if (/^(?:\d|\.)/u.test(token)) {
226
+ value = [checked([0, 0, 0, 0, Number(token.replace(/[fF]$/u, ''))])];
227
+ }
228
+ else if (this.peek() === '(') {
229
+ value = this.functionCall(token);
230
+ }
231
+ else {
232
+ value = this.variables.get(token) ?? unsupported(`Shader value ${token} is unresolved.`);
233
+ }
234
+ while (this.peek() === '.') {
235
+ this.take();
236
+ value = components(this.identifier(), value.length).map((index) => value[index]);
237
+ }
238
+ return value;
239
+ }
240
+ expression(minimumPrecedence = 0) {
241
+ this.depth += 1;
242
+ if (this.depth > maxDepth)
243
+ unsupported('Shader expression nesting exceeds its budget.');
244
+ try {
245
+ let value = this.primary();
246
+ for (;;) {
247
+ const operator = this.peek();
248
+ const precedence = operator === '+' || operator === '-' ? 1 : operator === '*' || operator === '/' ? 2 : -1;
249
+ if (precedence < minimumPrecedence)
250
+ return value;
251
+ this.take();
252
+ value = binary(value, this.expression(precedence + 1), operator);
253
+ }
254
+ }
255
+ finally {
256
+ this.depth -= 1;
257
+ }
258
+ }
259
+ run() {
260
+ for (let statement = 0; statement < maxStatements; statement += 1) {
261
+ const first = this.identifier();
262
+ if (first === 'return') {
263
+ const value = this.expression();
264
+ this.require(';');
265
+ if (value.length !== 4 || this.offset !== this.tokens.length)
266
+ unsupported('Shader must finish with one four-channel colour return.');
267
+ return value.flat();
268
+ }
269
+ const declaration = /^float([1-4])?$/u.exec(first);
270
+ const name = declaration === null ? first : this.identifier();
271
+ if (['Color', 'Time', 'AnimationTime'].includes(name))
272
+ unsupported('Shader cannot assign to a uniform.');
273
+ if (declaration !== null && this.variables.has(name))
274
+ unsupported('Shader redeclares a value.');
275
+ const existing = this.variables.get(name);
276
+ let swizzle;
277
+ if (this.peek() === '.') {
278
+ this.take();
279
+ swizzle = components(this.identifier(), existing?.length ?? 0);
280
+ if (new Set(swizzle).size !== swizzle.length)
281
+ unsupported('Shader assignment repeats a swizzle component.');
282
+ }
283
+ const operator = this.take();
284
+ if (!['=', '+=', '-=', '*=', '/='].includes(operator))
285
+ unsupported('Shader statement is not an assignment.');
286
+ const width = declaration === null ? (swizzle?.length ?? existing?.length) : Number(declaration[1] ?? 1);
287
+ if (width === undefined)
288
+ unsupported(`Shader assignment to ${name} is undeclared.`);
289
+ let value = this.expression();
290
+ this.require(';');
291
+ if (value.length === 1)
292
+ value = Array.from({ length: width }, () => value[0]);
293
+ if (value.length !== width)
294
+ unsupported('Shader assignment changes vector width.');
295
+ if (operator !== '=') {
296
+ if (existing === undefined || declaration !== null)
297
+ unsupported('Shader compound assignment has no prior value.');
298
+ value = binary(swizzle === undefined ? existing : swizzle.map((index) => existing[index]), value, operator[0]);
299
+ }
300
+ if (swizzle === undefined)
301
+ this.variables.set(name, value);
302
+ else {
303
+ const replaced = [...existing];
304
+ swizzle.forEach((index, component) => {
305
+ replaced[index] = value[component];
306
+ });
307
+ this.variables.set(name, replaced);
308
+ }
309
+ }
310
+ return unsupported('Shader statement count exceeds its budget.');
311
+ }
312
+ }
313
+ function mainCodes(source) {
314
+ const codes = new Map();
315
+ for (const match of source.matchAll(/\bMainCode\s+(\w+)\s*\[\[([\s\S]*?)\]\]/gu)) {
316
+ if (codes.has(match[1]))
317
+ unsupported('Shader has duplicate named programs.');
318
+ codes.set(match[1], match[2]);
319
+ }
320
+ return codes;
321
+ }
322
+ function assertNativeVertex(source) {
323
+ const program = inactiveFeatures(source).replace(/\s+/gu, '');
324
+ const match = /^VS_OUTPUTmain\((?:const)?VS_INPUT(\w+)\)\{VS_OUTPUT(\w+);([\s\S]*)\}$/u.exec(program);
325
+ if (match === null)
326
+ unsupported('Shader vertex program is not the supported native sprite transform.');
327
+ const [, input, output, body] = match;
328
+ const prefix = `${output}.vPosition=mul(WorldViewProjectionMatrix,float4(${input}.vPosition.xyz,1));${output}.vTexCoord=${input}.vTexCoord;`;
329
+ const endings = ['', `${output}.vTexCoord+=Offset;`, `${output}.vTexCoord.x+=vXOffset;`];
330
+ if (!endings.some((offset) => body === `${prefix}${offset}return${output};`))
331
+ unsupported('Shader changes sprite geometry or texture coordinates beyond native atlas offsets.');
332
+ }
333
+ function fields(source) {
334
+ const result = new Map();
335
+ const remainder = source.replace(/\b(\w+)\s*=\s*(?:"([^"\r\n]*)"|([A-Za-z_][A-Za-z0-9_]*|[+-]?(?:\d+(?:\.\d*)?|\.\d+)))/gu, (_match, name, quoted, scalar) => {
336
+ if (result.has(name))
337
+ unsupported('Shader render state has a duplicate field.');
338
+ result.set(name, quoted ?? scalar);
339
+ return '';
340
+ });
341
+ if (remainder.trim().length > 0)
342
+ unsupported('Shader render state contains unsupported syntax.');
343
+ return result;
344
+ }
345
+ function textureFiltering(source) {
346
+ const samplers = [...source.matchAll(/\bMapTexture\s*=\s*\{([^{}]*)\}/gu)];
347
+ if (samplers.length !== 1)
348
+ unsupported('Shader must bind one primary MapTexture sampler.');
349
+ const sampler = fields(samplers[0][1]);
350
+ if (sampler.get('Index') !== '0' ||
351
+ sampler.get('AddressU') !== 'Clamp' ||
352
+ sampler.get('AddressV') !== 'Clamp' ||
353
+ sampler.get('MipFilter') !== 'None')
354
+ unsupported('Shader sampler requires primary texture slot zero, clamp addressing and no mip filtering.');
355
+ if ([...sampler.keys()].some((key) => ![
356
+ 'Index',
357
+ 'MagFilter',
358
+ 'MinFilter',
359
+ 'MipFilter',
360
+ 'AddressU',
361
+ 'AddressV',
362
+ 'MipMapLodBias',
363
+ ].includes(key)))
364
+ unsupported('Shader sampler has unsupported fields.');
365
+ const mode = (key) => sampler.get(key) === 'Linear'
366
+ ? 'linear'
367
+ : sampler.get(key) === 'Point'
368
+ ? 'nearest'
369
+ : unsupported(`Shader ${key} is not a supported texture filter.`);
370
+ return { magnification: mode('MagFilter'), minification: mode('MinFilter') };
371
+ }
372
+ function assertStraightAlphaBlend(source) {
373
+ const blends = [...source.matchAll(/\bBlendState\s+\w+\s*\{([^{}]*)\}/gu)];
374
+ const blendFields = blends.length === 1 ? fields(blends[0][1]) : new Map();
375
+ if (blends.length !== 1 ||
376
+ blendFields.get('BlendEnable') !== 'yes' ||
377
+ blendFields.get('SourceBlend')?.toLowerCase() !== 'src_alpha' ||
378
+ blendFields.get('DestBlend')?.toLowerCase() !== 'inv_src_alpha' ||
379
+ (blendFields.has('AlphaTest') && blendFields.get('AlphaTest') !== 'no') ||
380
+ (blendFields.has('BlendOp') && blendFields.get('BlendOp')?.toLowerCase() !== 'add') ||
381
+ [...blendFields.keys()].some((key) => !['BlendEnable', 'SourceBlend', 'DestBlend', 'AlphaTest', 'BlendOp'].includes(key)))
382
+ unsupported('Shader does not use supported straight-alpha sprite blending.');
383
+ }
384
+ /** Recognize the native two-source threshold operation, never execute arbitrary shader code. */
385
+ export function compileGuiProgressEffect(source, provenance, effect) {
386
+ try {
387
+ if (Buffer.byteLength(source, 'utf8') > GUI_SHADER_MAX_BYTES)
388
+ unsupported('Shader source exceeds its byte budget.');
389
+ const clean = withoutComments(source);
390
+ if (/^\s*[#@]/mu.test(clean) || /\bIncludes\s*=\s*\{\s*[^\s}]/u.test(clean))
391
+ unsupported('Progress shader has unbound includes or feature directives.');
392
+ const effects = [...clean.matchAll(/\bEffect\s+(\w+)\s*\{([^{}]*)\}/gu)].filter((match) => match[1] === effect);
393
+ if (effects.length !== 1)
394
+ unsupported(`Shader must define exactly one ${effect} effect.`);
395
+ const effectFields = fields(effects[0][2]);
396
+ const pixel = effectFields.get('PixelShader');
397
+ const vertex = effectFields.get('VertexShader');
398
+ if (effectFields.size !== 2 || pixel === undefined || vertex === undefined)
399
+ unsupported('Progress shader effect has unsupported render-state overrides.');
400
+ const programs = mainCodes(clean);
401
+ const vertexSource = programs.get(vertex)?.replace(/\s+/gu, '');
402
+ const vertexProgram = vertexSource === undefined
403
+ ? null
404
+ : /^VS_OUTPUTmain\((?:const)?VS_INPUT(\w+)\)\{VS_OUTPUT(\w+);([\s\S]*)\}$/u.exec(vertexSource);
405
+ if (vertexProgram === null)
406
+ unsupported('Progress vertex program is not a native transform.');
407
+ const [, input, output, body] = vertexProgram;
408
+ if (body !==
409
+ `${output}.vPosition=mul(WorldViewProjectionMatrix,${input}.vPosition);${output}.vTexCoord0=${input}.vTexCoord;${output}.vTexCoord0.y=-${output}.vTexCoord0.y;return${output};`)
410
+ unsupported('Progress shader changes native geometry or texture coordinates.');
411
+ const pixelSource = programs.get(pixel);
412
+ const program = pixelSource === undefined
413
+ ? null
414
+ : /^\s*float4\s+main\(\s*(?:const\s+)?VS_OUTPUT\s+(\w+)\s*\)\s*:\s*PDX_COLOR\s*\{([\s\S]*)\}\s*$/u.exec(pixelSource);
415
+ if (program === null)
416
+ unsupported('Progress shader pixel program is missing or unsupported.');
417
+ const variable = program[1];
418
+ const first = effect === 'Color' ? 'vFirstColor' : `tex2D(TextureOne,${variable}.vTexCoord0.xy)`;
419
+ const second = effect === 'Color' ? 'vSecondColor' : `tex2D(TextureTwo,${variable}.vTexCoord0.xy)`;
420
+ const pixelBody = program[2].replace(/\s+/gu, '');
421
+ if (pixelBody !== `if(${variable}.vTexCoord0.x<=CurrentState)return${first};elsereturn${second};`)
422
+ unsupported('Progress pixel program is not a supported two-source threshold operation.');
423
+ assertStraightAlphaBlend(clean);
424
+ let filtering;
425
+ if (effect === 'Texture') {
426
+ const modes = ['TextureOne', 'TextureTwo'].map((name, index) => {
427
+ const declarations = [
428
+ ...clean.matchAll(new RegExp(`\\b${name}\\s*=\\s*\\{([^{}]*)\\}`, 'gu')),
429
+ ];
430
+ if (declarations.length !== 1)
431
+ unsupported(`Progress shader must bind one ${name} sampler.`);
432
+ const sampler = fields(declarations[0][1]);
433
+ if (sampler.get('Index') !== String(index) ||
434
+ sampler.get('MipFilter') !== 'None' ||
435
+ sampler.get('AddressU') !== 'Wrap' ||
436
+ sampler.get('AddressV') !== 'Wrap' ||
437
+ [...sampler.keys()].some((key) => !['Index', 'MagFilter', 'MinFilter', 'MipFilter', 'AddressU', 'AddressV'].includes(key)))
438
+ unsupported('Progress samplers require their declared slots, wrap addressing and no mip filtering.');
439
+ const mode = (key) => sampler.get(key) === 'Point'
440
+ ? 'nearest'
441
+ : sampler.get(key) === 'Linear'
442
+ ? 'linear'
443
+ : unsupported(`Unsupported progress ${key}.`);
444
+ return { magnification: mode('MagFilter'), minification: mode('MinFilter') };
445
+ });
446
+ if (modes[0].magnification !== modes[1].magnification ||
447
+ modes[0].minification !== modes[1].minification)
448
+ unsupported('Progress textures require matching filtering modes.');
449
+ filtering = modes[0];
450
+ }
451
+ return {
452
+ supported: true,
453
+ shader: {
454
+ sourcePath: provenance.path,
455
+ sourceHash: provenance.sha256,
456
+ effect,
457
+ entryPoint: pixel,
458
+ composition: 'threshold',
459
+ ...(filtering === undefined ? {} : { textureFiltering: filtering }),
460
+ },
461
+ };
462
+ }
463
+ catch (error) {
464
+ if (!(error instanceof UnsupportedShader))
465
+ throw error;
466
+ return { supported: false, reason: error.message };
467
+ }
468
+ }
469
+ export function compileGuiShaderEffect(source, provenance, effect, uniforms) {
470
+ try {
471
+ if (Buffer.byteLength(source, 'utf8') > GUI_SHADER_MAX_BYTES)
472
+ unsupported('Shader source exceeds its byte budget.');
473
+ if ([uniforms.time, uniforms.animationTime, ...(uniforms.colour ?? [])].some((value) => !Number.isFinite(value) || Math.abs(value) > maxCoefficient))
474
+ unsupported('Shader uniforms must be finite and within their coefficient budget.');
475
+ const clean = withoutComments(source);
476
+ if (/^\s*[#@](?:define|undef|include|if|elif|pragma)\b/mu.test(clean))
477
+ unsupported('Shader contains unbound preprocessor definitions or expressions.');
478
+ const effects = [...clean.matchAll(/\bEffect\s+(\w+)\s*\{([^{}]*)\}/gu)].filter((match) => match[1] === effect);
479
+ if (effects.length !== 1)
480
+ unsupported(`Shader must define exactly one ${effect} effect.`);
481
+ const effectFields = fields(effects[0][2]);
482
+ const pixel = effectFields.get('PixelShader');
483
+ const vertex = effectFields.get('VertexShader');
484
+ if (effectFields.size !== 2 || pixel === undefined || vertex === undefined)
485
+ unsupported('Shader effect has unsupported render-state overrides.');
486
+ const programs = mainCodes(clean);
487
+ const pixelSource = programs.get(pixel);
488
+ const vertexSource = programs.get(vertex);
489
+ if (pixelSource === undefined || vertexSource === undefined)
490
+ unsupported('Shader effect entry point is missing.');
491
+ assertNativeVertex(vertexSource);
492
+ assertStraightAlphaBlend(clean);
493
+ const program = /^\s*float4\s+main\(\s*(?:const\s+)?VS_OUTPUT\s+(\w+)\s*\)\s*:\s*PDX_COLOR\s*\{([^{}]*)\}\s*$/u.exec(inactiveFeatures(pixelSource));
494
+ if (program === null)
495
+ unsupported('Shader pixel program has unsupported control flow.');
496
+ const colourMatrix = new ColourInterpreter(tokenize(program[2]), program[1], uniforms).run();
497
+ const filtering = textureFiltering(clean);
498
+ return {
499
+ supported: true,
500
+ shader: {
501
+ sourcePath: provenance.path,
502
+ sourceHash: provenance.sha256,
503
+ effect,
504
+ entryPoint: pixel,
505
+ textureFiltering: filtering,
506
+ colourMatrix,
507
+ },
508
+ };
509
+ }
510
+ catch (error) {
511
+ if (!(error instanceof UnsupportedShader))
512
+ throw error;
513
+ return { supported: false, reason: error.message };
514
+ }
515
+ }