@triflare/fx 2.0.0 → 2.1.0-beta.2

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.
package/src/compiler.js CHANGED
@@ -137,6 +137,7 @@ export class Compiler {
137
137
  this.loadAsset = loadAsset || null;
138
138
  this.catalog = catalog ? { ...CATALOG, ...catalog } : CATALOG;
139
139
  this.extensionUrls = extensionUrls;
140
+ this.requiredExtensions = new Map();
140
141
  this.file = file;
141
142
  /** @type {string[]} Non-fatal problems worth surfacing to the user. */
142
143
  this.warnings = [];
@@ -252,6 +253,8 @@ export class Compiler {
252
253
  // can only be given their mutation once every stack is complete.
253
254
  for (const ctx of contexts) this.applyDynamicNextMutations(ctx);
254
255
 
256
+ this.validateRequiredExtensions();
257
+
255
258
  return { project: this.assembleProject(contexts), assets: this.assets };
256
259
  }
257
260
 
@@ -335,6 +338,11 @@ export class Compiler {
335
338
  }
336
339
 
337
340
  declareTargetVar(ctx, decl) {
341
+ if (decl.name.startsWith("$"))
342
+ this.fail(
343
+ `Names starting with "$" are reserved for temporary variables; rename ${JSON.stringify(decl.name)}.`,
344
+ decl.loc,
345
+ );
338
346
  const global = ctx.isStage;
339
347
  if (global && this.globals.has(decl.name)) {
340
348
  const first = this.globals.get(decl.name);
@@ -380,6 +388,11 @@ export class Compiler {
380
388
  }
381
389
 
382
390
  declareTargetList(ctx, decl) {
391
+ if (decl.name.startsWith("$"))
392
+ this.fail(
393
+ `Names starting with "$" are reserved for temporary variables; rename ${JSON.stringify(decl.name)}.`,
394
+ decl.loc,
395
+ );
383
396
  if (ctx.isStage && this.globals.has(decl.name)) {
384
397
  const first = this.globals.get(decl.name);
385
398
  const where = this.where(first.loc);
@@ -421,6 +434,13 @@ export class Compiler {
421
434
  decl.loc,
422
435
  );
423
436
  }
437
+ for (const param of decl.params) {
438
+ if (param.name.startsWith("$"))
439
+ this.fail(
440
+ `Names starting with "$" are reserved for temporary variables; rename parameter ${JSON.stringify(param.name)}.`,
441
+ decl.loc,
442
+ );
443
+ }
424
444
  const argIds = decl.params.map((_, i) => `arg${i}`);
425
445
  const proccode = decl.params.length
426
446
  ? `${decl.name} ${decl.params.map((param) => (param.boolean ? "%b" : "%s")).join(" ")}`
@@ -785,6 +805,8 @@ export class Compiler {
785
805
  return this.compileForever(ctx, node);
786
806
  case "Return":
787
807
  return this.compileReturn(ctx, node);
808
+ case "TempVarDecl":
809
+ return this.compileTempVarDecl(ctx, node);
788
810
  default:
789
811
  this.fail(`Unsupported statement ${node.type}`, node.loc);
790
812
  }
@@ -824,6 +846,13 @@ export class Compiler {
824
846
  }
825
847
 
826
848
  compileAssign(ctx, node) {
849
+ const sigil = /^(\$\$?)([A-Za-z_][A-Za-z0-9_]*)$/.exec(node.name);
850
+ if (sigil) return this.compileSigilAssign(ctx, node, sigil);
851
+ if (node.name.startsWith("$"))
852
+ this.fail(
853
+ `Invalid temporary variable reference ${JSON.stringify(node.name)}`,
854
+ node.loc,
855
+ );
827
856
  const resolved = ctx.resolveValue(node.name);
828
857
  if (!resolved || resolved.kind !== "var") {
829
858
  this.fail(`Unknown variable ${JSON.stringify(node.name)}`, node.loc);
@@ -880,6 +909,64 @@ export class Compiler {
880
909
  return id;
881
910
  }
882
911
 
912
+ compileTempVarDecl(ctx, node) {
913
+ if (node.name.startsWith("$"))
914
+ this.fail(
915
+ 'Temporary variable names cannot start with "$"; use a plain name.',
916
+ node.loc,
917
+ );
918
+ const name =
919
+ node.kind === "runtime" ? "tempvars.setRuntime" : "tempvars.setThread";
920
+ const entry = this.catalog[name];
921
+ if (!entry)
922
+ this.fail(
923
+ "Temporary variables are not available in this Fx build",
924
+ node.loc,
925
+ );
926
+ return this.emitCatalogCall(
927
+ ctx,
928
+ {
929
+ name,
930
+ segments: name.split("."),
931
+ args: [
932
+ { type: "String", value: node.name, loc: node.loc },
933
+ node.init ?? { type: "String", value: "0", loc: node.loc },
934
+ ],
935
+ loc: node.loc,
936
+ },
937
+ entry,
938
+ null,
939
+ ).id;
940
+ }
941
+
942
+ compileSigilAssign(ctx, node, sigil) {
943
+ if (node.op !== "=" && node.op !== "+=")
944
+ this.fail(
945
+ `Temporary variables only support "=" and "+="; ${JSON.stringify(node.op)} is not available.`,
946
+ node.loc,
947
+ );
948
+ const kind = sigil[1] === "$$" ? "Runtime" : "Thread";
949
+ const action = node.op === "=" ? "set" : "change";
950
+ const name = `tempvars.${action}${kind}`;
951
+ const entry = this.catalog[name];
952
+ if (!entry)
953
+ this.fail(
954
+ "Temporary variables are not available in this Fx build",
955
+ node.loc,
956
+ );
957
+ return this.emitCatalogCall(
958
+ ctx,
959
+ {
960
+ name,
961
+ segments: name.split("."),
962
+ args: [{ type: "String", value: sigil[2], loc: node.loc }, node.value],
963
+ loc: node.loc,
964
+ },
965
+ entry,
966
+ null,
967
+ ).id;
968
+ }
969
+
883
970
  compileIf(ctx, node) {
884
971
  const id = this.addBlock(ctx, {
885
972
  opcode: node.else ? "control_if_else" : "control_if",
@@ -976,6 +1063,11 @@ export class Compiler {
976
1063
  emitCatalogCall(ctx, node, entry, parentId) {
977
1064
  if (entry.kind === "hat")
978
1065
  this.fail(`${node.name} is an event and cannot be called`, node.loc);
1066
+ if (entry.requiresExtension) {
1067
+ const id = entry.extension || extensionForOpcode(entry.opcode);
1068
+ if (id && !this.requiredExtensions.has(id))
1069
+ this.requiredExtensions.set(id, node.loc);
1070
+ }
979
1071
  const id = this.addBlock(ctx, { opcode: entry.opcode, parent: parentId });
980
1072
  const fieldNames = Object.keys(entry.fields || {});
981
1073
  const inputNames = Object.keys(entry.inputs || {});
@@ -1282,6 +1374,33 @@ export class Compiler {
1282
1374
  }
1283
1375
 
1284
1376
  emitIdentifier(ctx, node) {
1377
+ const sigil = /^(\$\$?)([A-Za-z_][A-Za-z0-9_]*)$/.exec(node.name);
1378
+ if (sigil) {
1379
+ const name = sigil[1] === "$$" ? "tempvars.runtime" : "tempvars.thread";
1380
+ const entry = this.catalog[name];
1381
+ if (!entry)
1382
+ this.fail(
1383
+ "Temporary variables are not available in this Fx build",
1384
+ node.loc,
1385
+ );
1386
+ const result = this.emitCatalogCall(
1387
+ ctx,
1388
+ {
1389
+ name,
1390
+ segments: name.split("."),
1391
+ args: [{ type: "String", value: sigil[2], loc: node.loc }],
1392
+ loc: node.loc,
1393
+ },
1394
+ entry,
1395
+ null,
1396
+ );
1397
+ return { k: "block", id: result.id, kind: "reporter" };
1398
+ }
1399
+ if (node.name.startsWith("$"))
1400
+ this.fail(
1401
+ `Invalid temporary variable reference ${JSON.stringify(node.name)}`,
1402
+ node.loc,
1403
+ );
1285
1404
  if (node.segments.length > 1)
1286
1405
  this.fail(
1287
1406
  `Unexpected dotted name ${JSON.stringify(node.name)}`,
@@ -1537,6 +1656,20 @@ export class Compiler {
1537
1656
 
1538
1657
  // ------------------------------------------------------------ assembly
1539
1658
 
1659
+ validateRequiredExtensions() {
1660
+ for (const [id, loc] of this.requiredExtensions) {
1661
+ if (this.extensionUrls[id]) continue;
1662
+ const feature = id === "fxTempVars" ? "temporaryvariables" : null;
1663
+ const hint = feature
1664
+ ? ` Run "fx feature ${feature} enable" to install it.`
1665
+ : "";
1666
+ this.fail(
1667
+ `This project uses blocks from extension ${JSON.stringify(id)}, which is not loaded.${hint}`,
1668
+ loc,
1669
+ );
1670
+ }
1671
+ }
1672
+
1540
1673
  assembleProject(contexts) {
1541
1674
  const targets = [];
1542
1675
  const extensions = new Set();
@@ -615,3 +615,200 @@ export function removeVendorEntry(configPath, name) {
615
615
  writeDocument(configPath, lines.join(""));
616
616
  return { removed: source ?? key };
617
617
  }
618
+
619
+ // -------------------------------------------------------------- feature edits
620
+
621
+ /**
622
+ * Add or replace a `features:` entry without reformatting the rest of fx.yml.
623
+ *
624
+ * @param {string} configPath
625
+ * @param {string} name
626
+ * @param {string} file
627
+ * @returns {{added: boolean, updated: boolean}}
628
+ */
629
+ export function addFeatureEntry(configPath, name, file) {
630
+ const text = fs.readFileSync(configPath, "utf8");
631
+ const lines = splitLines(text);
632
+ const eol = eolOf(text);
633
+ const key = String(name);
634
+ let added = false;
635
+ let updated = false;
636
+ const entryLine = (indent) =>
637
+ `${indent}${quoteKey(key)}: ${quote(file)}${eol}`;
638
+
639
+ const bounds = topLevelBounds(lines, "features");
640
+ if (!bounds) {
641
+ if (lines.length > 0 && !lines[lines.length - 1].endsWith("\n"))
642
+ lines[lines.length - 1] += eol;
643
+ lines.push(`features:${eol}`, entryLine(" "));
644
+ added = true;
645
+ } else if (bounds.inline) {
646
+ const mapping = parseValue(bounds.inline) || {};
647
+ updated = Object.hasOwn(mapping, key);
648
+ added = !updated;
649
+ mapping[key] = file;
650
+ const dumped = yaml
651
+ .dump({ features: mapping }, { lineWidth: -1, noRefs: true })
652
+ .split("\n")
653
+ .filter((line, index, all) => !(index === all.length - 1 && line === ""))
654
+ .map((line) => `${line}${eol}`);
655
+ if (bounds.comment) dumped[0] = `features: ${bounds.comment}${eol}`;
656
+ replaceRange(lines, bounds.index, bounds.index + 1, dumped);
657
+ } else {
658
+ const spans = mappingEntrySpans(lines, bounds.index, bounds.end);
659
+ const existing = spans.find((span) => span.key === key);
660
+ if (existing) {
661
+ replaceRange(lines, existing.index, existing.end, [
662
+ entryLine(existing.indent),
663
+ ]);
664
+ updated = true;
665
+ } else {
666
+ const indent = childIndent(lines, bounds.index, bounds.end);
667
+ insertAt(lines, bounds.end, [entryLine(indent)], eol);
668
+ added = true;
669
+ }
670
+ }
671
+
672
+ if (added || updated) writeDocument(configPath, lines.join(""));
673
+ return { added, updated };
674
+ }
675
+
676
+ /** Read a raw `fx.<key>` value without normalising the rest of the config. */
677
+ export function readFxValue(configPath, key) {
678
+ const raw = yaml.load(fs.readFileSync(configPath, "utf8")) ?? {};
679
+ if (typeof raw !== "object" || Array.isArray(raw)) return undefined;
680
+ const fx = raw.fx;
681
+ if (!fx || typeof fx !== "object" || Array.isArray(fx)) return undefined;
682
+ return fx[key];
683
+ }
684
+
685
+ /**
686
+ * Set a scalar `fx.<key>` value while preserving the rest of the file.
687
+ *
688
+ * A nested block under the key (for example a hand-written `fx.tests:`
689
+ * mapping) is replaced by the scalar, so enabling and disabling a feature
690
+ * always leaves one canonical value behind.
691
+ *
692
+ * @param {string} configPath
693
+ * @param {string} key
694
+ * @param {string|number|boolean} value
695
+ * @returns {{changed: boolean}}
696
+ */
697
+ export function setFxScalar(configPath, key, value) {
698
+ const text = fs.readFileSync(configPath, "utf8");
699
+ const lines = splitLines(text);
700
+ const eol = eolOf(text);
701
+ const scalar = typeof value === "string" ? quote(value) : String(value);
702
+ const fx = topLevel(lines, "fx");
703
+
704
+ if (!fx) {
705
+ if (lines.length > 0 && !lines[lines.length - 1].endsWith("\n"))
706
+ lines[lines.length - 1] += eol;
707
+ lines.push(`fx:${eol}`, ` ${key}: ${scalar}${eol}`);
708
+ writeDocument(configPath, lines.join(""));
709
+ return { changed: true };
710
+ }
711
+
712
+ const { value: rest, comment } = splitComment(fx.rest);
713
+ if (rest !== "") {
714
+ if (!rest.startsWith("{") || !rest.endsWith("}"))
715
+ throw new Error("fx must be a block mapping to edit it");
716
+ const mapping = parseValue(rest);
717
+ if (!mapping || typeof mapping !== "object" || Array.isArray(mapping))
718
+ throw new Error("fx must be a mapping to edit it");
719
+ if (mapping[key] === value) return { changed: false };
720
+ mapping[key] = value;
721
+ const dumped = yaml
722
+ .dump({ fx: mapping }, { lineWidth: -1, noRefs: true })
723
+ .split("\n")
724
+ .filter((line, index, all) => !(index === all.length - 1 && line === ""))
725
+ .map((line) => `${line}${eol}`);
726
+ if (comment) dumped[0] = `fx: ${comment}${eol}`;
727
+ replaceRange(lines, fx.index, fx.index + 1, dumped);
728
+ writeDocument(configPath, lines.join(""));
729
+ return { changed: true };
730
+ }
731
+
732
+ const end = sectionEnd(lines, fx.index);
733
+ const existing = mappingEntrySpans(lines, fx.index, end).find(
734
+ (span) => span.key === key,
735
+ );
736
+ if (existing) {
737
+ let entryEnd = existing.end;
738
+ while (
739
+ entryEnd > existing.index + 1 &&
740
+ /^(?:[ \t]*#.*|[ \t]*)$/.test(content(lines[entryEnd - 1]))
741
+ )
742
+ entryEnd--;
743
+ replaceRange(lines, existing.index, entryEnd, [
744
+ `${existing.indent}${quoteKey(key)}: ${scalar}${eol}`,
745
+ ]);
746
+ } else {
747
+ const indent = childIndent(lines, fx.index, end);
748
+ insertAt(lines, end, [`${indent}${quoteKey(key)}: ${scalar}${eol}`], eol);
749
+ }
750
+ writeDocument(configPath, lines.join(""));
751
+ return { changed: true };
752
+ }
753
+
754
+ /**
755
+ * Remove a `features:` entry.
756
+ * @param {string} configPath
757
+ * @param {string} name
758
+ * @returns {{removed: string|null}}
759
+ */
760
+ export function removeFeatureEntry(configPath, name) {
761
+ const text = fs.readFileSync(configPath, "utf8");
762
+ const lines = splitLines(text);
763
+ const eol = eolOf(text);
764
+ const key = String(name);
765
+ const bounds = topLevelBounds(lines, "features");
766
+ if (!bounds) return { removed: null };
767
+
768
+ if (bounds.inline) {
769
+ const mapping = parseValue(bounds.inline);
770
+ if (!mapping || typeof mapping !== "object" || Array.isArray(mapping))
771
+ return { removed: null };
772
+ if (!Object.hasOwn(mapping, key)) return { removed: null };
773
+ const source = mapping[key];
774
+ delete mapping[key];
775
+ const rest = Object.keys(mapping).length
776
+ ? ` ${JSON.stringify(mapping)}`
777
+ : " {}";
778
+ const comment = bounds.comment ? ` ${bounds.comment}` : "";
779
+ const line = lines[bounds.index];
780
+ lines[bounds.index] = `features:${rest}${comment}${
781
+ /\r?\n$/.test(line) ? ending(line) : eol
782
+ }`;
783
+ writeDocument(configPath, lines.join(""));
784
+ return { removed: source == null ? null : String(source) };
785
+ }
786
+
787
+ const spans = mappingEntrySpans(lines, bounds.index, bounds.end);
788
+ const existing = spans.find((span) => span.key === key);
789
+ if (!existing) return { removed: null };
790
+
791
+ let source = null;
792
+ try {
793
+ const parsed = yaml.load(
794
+ [
795
+ content(lines[bounds.index]),
796
+ ...lines.slice(existing.index, existing.end),
797
+ ].join("\n"),
798
+ );
799
+ const entry = parsed?.features?.[key];
800
+ source =
801
+ typeof entry === "string" ? entry : (entry?.file ?? entry?.path ?? null);
802
+ } catch {
803
+ // The entry is removed either way.
804
+ }
805
+
806
+ replaceRange(lines, existing.index, existing.end, []);
807
+ const end = bounds.end - (existing.end - existing.index);
808
+ const remaining = mappingEntrySpans(lines, bounds.index, end);
809
+ if (remaining.length === 0) {
810
+ replaceRange(lines, bounds.index, end, [`features: {}${eol}`]);
811
+ }
812
+ writeDocument(configPath, lines.join(""));
813
+ return { removed: source ?? key };
814
+ }
package/src/config.js CHANGED
@@ -11,6 +11,7 @@ import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import * as yaml from "js-yaml";
13
13
  import { configFilename, configFilenames, productName } from "./brand.js";
14
+ import { DEFAULT_LINT, normalizeLintConfig } from "./lint/config.js";
14
15
 
15
16
  export const DEFAULT_PROJECT = {
16
17
  name: `${productName()} Project`,
@@ -25,6 +26,19 @@ export const DEFAULT_PROJECT = {
25
26
  unsandboxed: [],
26
27
  };
27
28
 
29
+ /**
30
+ * Deterministic defaults for `fx test`. Virtual time is derived from
31
+ * `ticksPerSecond`; `seed` seeds the pseudo-random generator for the run.
32
+ */
33
+ export const DEFAULT_TESTS = {
34
+ pattern: "test/**/*.test.tfx",
35
+ seed: 12345,
36
+ timeout: 1000,
37
+ ticksPerSecond: 30,
38
+ failOnRuntimeError: true,
39
+ parallel: false,
40
+ };
41
+
28
42
  /**
29
43
  * Fx-only settings that never reach PenguinMod's project runtime config.
30
44
  * Kept in a separate top-level `fx:` block in fx.yml.
@@ -32,8 +46,72 @@ export const DEFAULT_PROJECT = {
32
46
  export const DEFAULT_FX = {
33
47
  partialSprites: false,
34
48
  vendorDir: "extensions",
49
+ tests: false,
50
+ lint: { ...DEFAULT_LINT, rules: { ...DEFAULT_LINT.rules } },
35
51
  };
36
52
 
53
+ /**
54
+ * Normalize the `fx.tests` setting.
55
+ *
56
+ * `true` enables testing with the defaults, `false` (or an absent key)
57
+ * disables it, and a mapping may override any default. The mapping form is
58
+ * what `fx feature tests enable` writes and later edits by hand.
59
+ *
60
+ * @param {unknown} value
61
+ * @returns {false | (typeof DEFAULT_TESTS & {enabled: true})}
62
+ */
63
+ export function normalizeTestConfig(value) {
64
+ if (value == null || value === false) return false;
65
+ if (value === true) return { enabled: true, ...DEFAULT_TESTS };
66
+ if (!isPlainObject(value))
67
+ throw new Error(
68
+ `${configFilename()}: fx.tests must be true, false, or a mapping`,
69
+ );
70
+
71
+ const unknown = Object.keys(value).filter(
72
+ (key) => !Object.hasOwn(DEFAULT_TESTS, key),
73
+ );
74
+ if (unknown.length)
75
+ throw new Error(
76
+ `${configFilename()}: unknown fx.tests setting(s): ${unknown
77
+ .map((key) => `"${key}"`)
78
+ .join(", ")}`,
79
+ );
80
+
81
+ const tests = { enabled: true, ...DEFAULT_TESTS };
82
+ if (value.pattern != null) {
83
+ if (typeof value.pattern !== "string" || value.pattern.trim() === "")
84
+ throw new Error(`${configFilename()}: fx.tests.pattern must be a glob`);
85
+ tests.pattern = value.pattern.trim();
86
+ }
87
+ if (value.seed != null) {
88
+ const seed = Number(value.seed);
89
+ if (!Number.isFinite(seed))
90
+ throw new Error(`${configFilename()}: fx.tests.seed must be a number`);
91
+ tests.seed = seed;
92
+ }
93
+ if (value.timeout != null) {
94
+ const timeout = Number(value.timeout);
95
+ if (!Number.isFinite(timeout) || timeout <= 0)
96
+ throw new Error(
97
+ `${configFilename()}: fx.tests.timeout must be a positive number of milliseconds`,
98
+ );
99
+ tests.timeout = timeout;
100
+ }
101
+ if (value.ticksPerSecond != null) {
102
+ const ticks = Number(value.ticksPerSecond);
103
+ if (!Number.isFinite(ticks) || ticks <= 0)
104
+ throw new Error(
105
+ `${configFilename()}: fx.tests.ticksPerSecond must be positive`,
106
+ );
107
+ tests.ticksPerSecond = ticks;
108
+ }
109
+ if (value.failOnRuntimeError != null)
110
+ tests.failOnRuntimeError = Boolean(value.failOnRuntimeError);
111
+ if (value.parallel != null) tests.parallel = Boolean(value.parallel);
112
+ return tests;
113
+ }
114
+
37
115
  /** Default directory that receives vendored extension sources. */
38
116
  export const DEFAULT_VENDOR_DIR = "extensions";
39
117
 
@@ -121,6 +199,32 @@ export function normalizeVendorEntry(name, raw) {
121
199
  };
122
200
  }
123
201
 
202
+ /**
203
+ * Normalize the top-level `features:` mapping. Values are file paths (or
204
+ * `{ file }` mappings), relative to the config directory.
205
+ *
206
+ * @param {unknown} raw
207
+ * @returns {Array<{name: string, file: string}>}
208
+ */
209
+ export function normalizeFeatures(raw = {}) {
210
+ if (raw == null) return [];
211
+ if (!isPlainObject(raw))
212
+ throw new Error(`${configFilename()}: "features" must be a mapping`);
213
+ return Object.entries(raw).map(([name, value]) => {
214
+ const file =
215
+ typeof value === "string"
216
+ ? value
217
+ : isPlainObject(value) && typeof value.file === "string"
218
+ ? value.file
219
+ : null;
220
+ if (!file || file.trim() === "")
221
+ throw new Error(
222
+ `${configFilename()}: features.${name} must be a file path or { file: "<path>" }`,
223
+ );
224
+ return { name: String(name), file: file.trim() };
225
+ });
226
+ }
227
+
124
228
  /**
125
229
  * Normalize the top-level `vendor:` mapping. Order is load order, so this is an
126
230
  * array rather than a plain object.
@@ -381,7 +485,7 @@ export function normalizeProject(raw = {}) {
381
485
  * @returns {typeof DEFAULT_FX}
382
486
  */
383
487
  export function normalizeFx(raw = {}) {
384
- const fx = { ...DEFAULT_FX };
488
+ const fx = { ...DEFAULT_FX, lint: { ...DEFAULT_LINT, rules: {} } };
385
489
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
386
490
  if (raw.partialSprites != null)
387
491
  fx.partialSprites = Boolean(raw.partialSprites);
@@ -390,6 +494,11 @@ export function normalizeFx(raw = {}) {
390
494
  throw new Error(`${configFilename()}: fx.vendorDir must be a path`);
391
495
  fx.vendorDir = raw.vendorDir.trim();
392
496
  }
497
+ if (raw.tests !== undefined) fx.tests = normalizeTestConfig(raw.tests);
498
+ fx.lint = normalizeLintConfig(
499
+ raw.lint,
500
+ (message) => new Error(`${configFilename()}: ${message}`),
501
+ );
393
502
  }
394
503
  return fx;
395
504
  }
@@ -420,6 +529,7 @@ export function parseConfig(text) {
420
529
  fx,
421
530
  package: normalizePackage(raw.package || {}),
422
531
  vendor: normalizeVendor(raw.vendor || {}),
532
+ features: normalizeFeatures(raw.features || {}),
423
533
  vendorDir: fx.vendorDir,
424
534
  raw,
425
535
  };
@@ -459,6 +569,7 @@ export function loadConfigFrom(dir) {
459
569
  fx,
460
570
  package: pkg,
461
571
  vendor,
572
+ features,
462
573
  vendorDir,
463
574
  raw,
464
575
  } = parseConfig(fs.readFileSync(found, "utf8"));
@@ -467,6 +578,7 @@ export function loadConfigFrom(dir) {
467
578
  fx,
468
579
  package: pkg,
469
580
  vendor,
581
+ features,
470
582
  vendorDir,
471
583
  raw,
472
584
  path: found,
@@ -477,6 +589,7 @@ export function loadConfigFrom(dir) {
477
589
  fx: { ...DEFAULT_FX },
478
590
  package: normalizePackage({}),
479
591
  vendor: [],
592
+ features: [],
480
593
  vendorDir: DEFAULT_VENDOR_DIR,
481
594
  raw: {},
482
595
  path: null,
package/src/decompiler.js CHANGED
@@ -49,6 +49,8 @@ const LIST_OPCODE_METHOD = {
49
49
  data_listcontainsitem: { method: "contains", inputs: ["ITEM"] },
50
50
  };
51
51
 
52
+ const SAFE_TEMP_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
53
+
52
54
  function escapeString(value) {
53
55
  return String(value)
54
56
  .replace(/\\/g, "\\\\")
@@ -172,6 +174,14 @@ class Decompiler {
172
174
  }
173
175
  if (block.opcode === "data_variable") return block.fields.VARIABLE[0];
174
176
  if (block.opcode === "data_listcontents") return block.fields.LIST[0];
177
+ if (block.opcode === "fxTempVars_getThreadVariable") {
178
+ const name = this.tempVarName(block);
179
+ if (name != null && SAFE_TEMP_NAME.test(name)) return `$${name}`;
180
+ }
181
+ if (block.opcode === "fxTempVars_getRuntimeVariable") {
182
+ const name = this.tempVarName(block);
183
+ if (name != null && SAFE_TEMP_NAME.test(name)) return `$$${name}`;
184
+ }
175
185
  if (
176
186
  Object.prototype.hasOwnProperty.call(LIST_OPCODE_METHOD, block.opcode)
177
187
  ) {
@@ -254,6 +264,27 @@ class Decompiler {
254
264
  return lines.filter(Boolean).join("\n");
255
265
  }
256
266
 
267
+ /** Literal name stored in a temporary-variable block's VAR input. */
268
+ tempVarName(block) {
269
+ const raw = (block.inputs || {}).VAR;
270
+ const inner = Array.isArray(raw) ? raw[1] : null;
271
+ if (!Array.isArray(inner) || ![4, 5, 6, 7, 8, 9, 10, 11].includes(inner[0]))
272
+ return null;
273
+ return inner[1] == null ? null : String(inner[1]);
274
+ }
275
+
276
+ /** Render set/change on a temporary variable, with sugar when the name is safe. */
277
+ renderTempVarAssign(block, pad, prefix, op, catalogName) {
278
+ const name = this.tempVarName(block);
279
+ const value = this.renderExpression(
280
+ block.inputs.STRING ?? block.inputs.NUM,
281
+ );
282
+ if (name != null && SAFE_TEMP_NAME.test(name))
283
+ return `${pad}${prefix}${name} ${op} ${value};`;
284
+ const input = this.renderExpression(block.inputs.VAR);
285
+ return `${pad}tempvars.${catalogName}(${input}, ${value});`;
286
+ }
287
+
257
288
  renderStatement(block, indent) {
258
289
  const pad = " ".repeat(indent);
259
290
  const sub = (firstId) => {
@@ -289,6 +320,20 @@ class Decompiler {
289
320
  return `${pad}${this.renderProcedureCall(block)};`;
290
321
  case "control_stop":
291
322
  return `${pad}control.stop("${block.fields.STOP_OPTION[0]}");`;
323
+ case "fxTempVars_setThreadVariable":
324
+ return this.renderTempVarAssign(block, pad, "$", "=", "setThread");
325
+ case "fxTempVars_changeThreadVariable":
326
+ return this.renderTempVarAssign(block, pad, "$", "+=", "changeThread");
327
+ case "fxTempVars_setRuntimeVariable":
328
+ return this.renderTempVarAssign(block, pad, "$$", "=", "setRuntime");
329
+ case "fxTempVars_changeRuntimeVariable":
330
+ return this.renderTempVarAssign(
331
+ block,
332
+ pad,
333
+ "$$",
334
+ "+=",
335
+ "changeRuntime",
336
+ );
292
337
  default: {
293
338
  if (
294
339
  Object.prototype.hasOwnProperty.call(LIST_OPCODE_METHOD, block.opcode)