@triflare/fx 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +73 -2
  3. package/bin/fx.js +949 -0
  4. package/package.json +64 -4
  5. package/product.yml +28 -0
  6. package/src/brand.js +105 -0
  7. package/src/build.js +181 -0
  8. package/src/catalog.js +753 -0
  9. package/src/compiler.js +1658 -0
  10. package/src/config-edit.js +338 -0
  11. package/src/config.js +400 -0
  12. package/src/decompiler.js +504 -0
  13. package/src/dev.js +390 -0
  14. package/src/errors.js +56 -0
  15. package/src/extensions.js +707 -0
  16. package/src/index.js +31 -0
  17. package/src/language.js +12 -0
  18. package/src/lexer.js +202 -0
  19. package/src/log.js +70 -0
  20. package/src/package.js +202 -0
  21. package/src/packager.js +177 -0
  22. package/src/parser.js +608 -0
  23. package/src/product.js +27 -0
  24. package/src/project.js +156 -0
  25. package/src/sb3.js +340 -0
  26. package/src/sprite.js +81 -0
  27. package/vendor/pm-packager-dist/.fx-build.json +6 -0
  28. package/vendor/pm-packager-dist/LICENSE +202 -0
  29. package/vendor/pm-packager-dist/NOTICE +6 -0
  30. package/vendor/pm-packager-dist/dist/290e09e569a1cab8e61ba93b0d23863f.png +0 -0
  31. package/vendor/pm-packager-dist/dist/icns.js +1 -0
  32. package/vendor/pm-packager-dist/dist/packager.js +154 -0
  33. package/vendor/pm-packager-dist/dist/scaffolding/addons.js +2 -0
  34. package/vendor/pm-packager-dist/dist/scaffolding/extension worker.js +2 -0
  35. package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-full.js +93 -0
  36. package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-min.js +93 -0
  37. package/vendor/pm-packager-dist/dist/scaffolding/vendors~iframe-extension-worker.js +2 -0
  38. package/vendor/pm-packager-dist/dist/vendors~sha256.js +3 -0
  39. package/vendor/pm-packager-dist/package.json +3 -0
@@ -0,0 +1,504 @@
1
+ /**
2
+ * Triflare Fx decompiler: Scratch 3 / PenguinMod project JSON -> `.tfx` source.
3
+ *
4
+ * This is the inverse of `compiler.js`. It leans on the same catalog (reverse
5
+ * opcode lookup) so the two stay in sync.
6
+ */
7
+ import { CATALOG, BY_OPCODE } from "./catalog.js";
8
+ import { unzipSync } from "./sb3.js";
9
+
10
+ const MENU_OPCODES = new Set([
11
+ "motion_goto_menu",
12
+ "motion_glideto_menu",
13
+ "motion_pointtowards_menu",
14
+ "sensing_touchingobjectmenu",
15
+ "sensing_distancetomenu",
16
+ "sensing_keyoptions",
17
+ "looks_costume",
18
+ "looks_backdrops",
19
+ "sound_sounds_menu",
20
+ "control_create_clone_of_menu",
21
+ "sensing_of_object_menu",
22
+ "pen_menu_colorParam",
23
+ ]);
24
+
25
+ const BINARY_OPCODES = {
26
+ operator_add: "+",
27
+ operator_subtract: "-",
28
+ operator_multiply: "*",
29
+ operator_divide: "/",
30
+ operator_mod: "%",
31
+ operator_gt: ">",
32
+ operator_lt: "<",
33
+ operator_equals: "==",
34
+ operator_and: "and",
35
+ operator_or: "or",
36
+ };
37
+
38
+ const LIST_OPCODE_METHOD = {
39
+ data_addtolist: { method: "push", inputs: ["ITEM"] },
40
+ data_insertatlist: { method: "insert", inputs: ["INDEX", "ITEM"] },
41
+ data_replaceitemoflist: { method: "replace", inputs: ["INDEX", "ITEM"] },
42
+ data_deleteoflist: { method: "deleteAt", inputs: ["INDEX"] },
43
+ data_deletealloflist: { method: "deleteAll", inputs: [] },
44
+ data_showlist: { method: "show", inputs: [] },
45
+ data_hidelist: { method: "hide", inputs: [] },
46
+ data_itemoflist: { method: "itemAt", inputs: ["INDEX"] },
47
+ data_itemnumoflist: { method: "itemNumber", inputs: ["ITEM"] },
48
+ data_lengthoflist: { method: "length", inputs: [] },
49
+ data_listcontainsitem: { method: "contains", inputs: ["ITEM"] },
50
+ };
51
+
52
+ function escapeString(value) {
53
+ return String(value)
54
+ .replace(/\\/g, "\\\\")
55
+ .replace(/"/g, '\\"')
56
+ .replace(/\n/g, "\\n");
57
+ }
58
+
59
+ function numberText(value) {
60
+ const n = Number(value);
61
+ if (Number.isFinite(n) && String(n) === String(value)) return String(value);
62
+ return String(value);
63
+ }
64
+
65
+ class Decompiler {
66
+ constructor(project, options = {}) {
67
+ this.project = project;
68
+ this.assetPrefix = options.assetPrefix || "";
69
+ this.returningProccodes = new Set();
70
+ for (const target of project.targets) {
71
+ for (const block of Object.values(target.blocks)) {
72
+ if (
73
+ block.opcode === "procedures_call" &&
74
+ block.mutation &&
75
+ (block.mutation.returns === "true" || block.mutation.return)
76
+ ) {
77
+ this.returningProccodes.add(block.mutation.proccode);
78
+ }
79
+ if (
80
+ block.opcode === "procedures_definition" ||
81
+ block.opcode === "procedures_definition_return"
82
+ ) {
83
+ const proto = target.blocks[firstOf(block.inputs.custom_block)];
84
+ const proccode = proto && proto.mutation && proto.mutation.proccode;
85
+ if (
86
+ proccode &&
87
+ (block.opcode === "procedures_definition_return" ||
88
+ (proto.mutation && proto.mutation.returns === "true") ||
89
+ stackHasReturn(target, block.next))
90
+ )
91
+ this.returningProccodes.add(proccode);
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ targetVars(target) {
98
+ const map = new Map();
99
+ for (const [id, desc] of Object.entries(target.variables || {}))
100
+ map.set(id, desc[0]);
101
+ for (const [id, desc] of Object.entries(target.lists || {}))
102
+ map.set(id, desc[0]);
103
+ return map;
104
+ }
105
+
106
+ renderExpression(value) {
107
+ if (value == null) return '""';
108
+ if (!Array.isArray(value)) return this.renderBlock(value);
109
+ const type = value[0];
110
+ switch (type) {
111
+ case 1:
112
+ case 3:
113
+ return this.renderInputRef(value[1]);
114
+ case 2:
115
+ return this.renderInputRef(value[1]);
116
+ case 4:
117
+ case 5:
118
+ case 6:
119
+ case 7:
120
+ return numberText(value[1]);
121
+ case 8:
122
+ return numberText(value[1]);
123
+ case 9:
124
+ return `"${escapeString(value[1])}"`;
125
+ case 10:
126
+ return `"${escapeString(value[1])}"`;
127
+ case 11:
128
+ return `"${escapeString(value[1])}"`;
129
+ case 12:
130
+ return String(value[1]);
131
+ case 13:
132
+ return String(value[1]);
133
+ default:
134
+ return "0";
135
+ }
136
+ }
137
+
138
+ renderInputRef(ref) {
139
+ if (Array.isArray(ref)) return this.renderExpression(ref);
140
+ if (ref == null) return "0";
141
+ return this.renderBlock(ref);
142
+ }
143
+
144
+ renderBlock(id) {
145
+ const block = this.target.blocks[id];
146
+ if (!block) return "0";
147
+
148
+ if (MENU_OPCODES.has(block.opcode)) {
149
+ const field = Object.values(block.fields || {})[0];
150
+ return field ? `"${escapeString(field[0])}"` : '""';
151
+ }
152
+ if (block.opcode === "data_variable") return block.fields.VARIABLE[0];
153
+ if (block.opcode === "data_listcontents") return block.fields.LIST[0];
154
+ if (
155
+ Object.prototype.hasOwnProperty.call(LIST_OPCODE_METHOD, block.opcode)
156
+ ) {
157
+ const { method, inputs } = LIST_OPCODE_METHOD[block.opcode];
158
+ const listName = block.fields.LIST[0];
159
+ const args = inputs.map((input) =>
160
+ this.renderExpression(block.inputs[input]),
161
+ );
162
+ return `${listName}.${method}(${args.join(", ")})`;
163
+ }
164
+ if (
165
+ block.opcode === "argument_reporter_string_number" ||
166
+ block.opcode === "argument_reporter_boolean"
167
+ ) {
168
+ const value = block.fields.VALUE ? block.fields.VALUE[0] : "";
169
+ // TurboWarp/PenguinMod encode a few "PenguinMod" reporters as modified
170
+ // argument reporters; see docs.turbowarp.org/blocks and the VM compiler.
171
+ const magic = {
172
+ "last key pressed": "pm.lastKeyPressed()",
173
+ "is compiled?": "pm.isCompiled()",
174
+ "is penguinmod?": "pm.isPenguinMod()",
175
+ }[value];
176
+ return magic || value;
177
+ }
178
+ if (block.opcode === "procedures_call") {
179
+ return this.renderProcedureCall(block);
180
+ }
181
+ if (Object.prototype.hasOwnProperty.call(BINARY_OPCODES, block.opcode)) {
182
+ const left = this.renderExpression(
183
+ block.inputs.OPERAND1 || block.inputs.NUM1,
184
+ );
185
+ const right = this.renderExpression(
186
+ block.inputs.OPERAND2 || block.inputs.NUM2,
187
+ );
188
+ return `(${left} ${BINARY_OPCODES[block.opcode]} ${right})`;
189
+ }
190
+ if (block.opcode === "operator_not") {
191
+ return `(not ${this.renderExpression(block.inputs.OPERAND)})`;
192
+ }
193
+
194
+ const name = BY_OPCODE.get(block.opcode);
195
+ if (name) return this.renderCatalogCall(name, block);
196
+ return `/* unknown ${block.opcode} */`;
197
+ }
198
+
199
+ renderCatalogCall(name, block) {
200
+ const entry = CATALOG[name];
201
+ const args = [];
202
+ for (const key of Object.keys(entry.fields || {})) {
203
+ const field = block.fields[key];
204
+ if (field) args.push(`"${escapeString(field[0])}"`);
205
+ }
206
+ for (const [inputName, spec] of Object.entries(entry.inputs || {})) {
207
+ if (spec.type === "substack") continue;
208
+ args.push(this.renderExpression(block.inputs[inputName]));
209
+ }
210
+ return `${name}(${args.join(", ")})`;
211
+ }
212
+
213
+ renderProcedureCall(block) {
214
+ const mutation = block.mutation || {};
215
+ const proccode = mutation.proccode || "";
216
+ const name = proccode.split(" ")[0];
217
+ const ids = mutation.argumentids ? JSON.parse(mutation.argumentids) : [];
218
+ const args = ids.map((argId) => this.renderExpression(block.inputs[argId]));
219
+ return `${name}(${args.join(", ")})`;
220
+ }
221
+
222
+ // ---------------------------------------------------------- statements
223
+
224
+ renderStack(firstId, indent) {
225
+ const lines = [];
226
+ let id = firstId;
227
+ while (id) {
228
+ const block = this.target.blocks[id];
229
+ if (!block) break;
230
+ lines.push(this.renderStatement(block, indent));
231
+ id = block.next;
232
+ }
233
+ return lines.filter(Boolean).join("\n");
234
+ }
235
+
236
+ renderStatement(block, indent) {
237
+ const pad = " ".repeat(indent);
238
+ const sub = (firstId) => {
239
+ if (!firstId) return "";
240
+ const body = this.renderStack(firstId, indent + 1);
241
+ return body ? `\n${body}` : "";
242
+ };
243
+
244
+ switch (block.opcode) {
245
+ case "data_setvariableto":
246
+ return `${pad}${block.fields.VARIABLE[0]} = ${this.renderExpression(block.inputs.VALUE)};`;
247
+ case "data_changevariableby":
248
+ return `${pad}${block.fields.VARIABLE[0]} += ${this.renderExpression(block.inputs.VALUE)};`;
249
+ case "control_if": {
250
+ const cond = this.renderExpression(block.inputs.CONDITION);
251
+ return `${pad}if (${stripParen(cond)}) {${sub(firstOf(block.inputs.SUBSTACK))}\n${pad}}`;
252
+ }
253
+ case "control_if_else": {
254
+ const cond = this.renderExpression(block.inputs.CONDITION);
255
+ return `${pad}if (${stripParen(cond)}) {${sub(firstOf(block.inputs.SUBSTACK))}\n${pad}} else {${sub(firstOf(block.inputs.SUBSTACK2))}\n${pad}}`;
256
+ }
257
+ case "control_forever":
258
+ return `${pad}control.forever {${sub(firstOf(block.inputs.SUBSTACK))}\n${pad}}`;
259
+ case "control_repeat":
260
+ return `${pad}repeat (${this.renderExpression(block.inputs.TIMES)}) {${sub(firstOf(block.inputs.SUBSTACK))}\n${pad}}`;
261
+ case "control_repeat_until":
262
+ return `${pad}control.repeatUntil(${stripParen(this.renderExpression(block.inputs.CONDITION))}) {${sub(firstOf(block.inputs.SUBSTACK))}\n${pad}}`;
263
+ case "control_wait_until":
264
+ return `${pad}control.waitUntil(${stripParen(this.renderExpression(block.inputs.CONDITION))});`;
265
+ case "procedures_return":
266
+ return `${pad}return ${this.renderExpression(block.inputs.return ?? block.inputs.VALUE)};`;
267
+ case "procedures_call":
268
+ return `${pad}${this.renderProcedureCall(block)};`;
269
+ case "control_stop":
270
+ return `${pad}control.stop("${block.fields.STOP_OPTION[0]}");`;
271
+ default: {
272
+ if (
273
+ Object.prototype.hasOwnProperty.call(LIST_OPCODE_METHOD, block.opcode)
274
+ ) {
275
+ const { method, inputs } = LIST_OPCODE_METHOD[block.opcode];
276
+ const listName = block.fields.LIST[0];
277
+ const args = inputs.map((input) =>
278
+ this.renderExpression(block.inputs[input]),
279
+ );
280
+ return `${pad}${listName}.${method}(${args.join(", ")});`;
281
+ }
282
+ const name = BY_OPCODE.get(block.opcode);
283
+ if (name && CATALOG[name].kind === "command") {
284
+ return `${pad}${this.renderCatalogCall(name, block)};`;
285
+ }
286
+ if (name) return `${pad}${this.renderCatalogCall(name, block)};`;
287
+ return `${pad}// unsupported block ${block.opcode}`;
288
+ }
289
+ }
290
+ }
291
+
292
+ renderHat(block, indent) {
293
+ const pad = "";
294
+ void indent;
295
+ const body = (firstId) => this.renderStack(firstId, 1);
296
+ switch (block.opcode) {
297
+ case "event_whenflagclicked":
298
+ return `${pad}when greenFlag {\n${body(block.next)}\n}`;
299
+ case "event_whenkeypressed": {
300
+ const key = block.fields.KEY_OPTION
301
+ ? block.fields.KEY_OPTION[0]
302
+ : "space";
303
+ return `${pad}when keyPressed("${escapeString(key)}") {\n${body(block.next)}\n}`;
304
+ }
305
+ case "event_whenthisspriteclicked":
306
+ return `${pad}when spriteClicked {\n${body(block.next)}\n}`;
307
+ case "event_whenstageclicked":
308
+ return `${pad}when stageClicked {\n${body(block.next)}\n}`;
309
+ case "control_start_as_clone":
310
+ return `${pad}when cloneStart {\n${body(block.next)}\n}`;
311
+ case "event_whenbroadcastreceived":
312
+ return `${pad}when broadcast("${escapeString(block.fields.BROADCAST_OPTION[0])}") {\n${body(block.next)}\n}`;
313
+ case "event_whenbackdropswitchesto":
314
+ return `${pad}when backdropSwitchesTo("${escapeString(block.fields.BACKDROP[0])}") {\n${body(block.next)}\n}`;
315
+ case "event_whengreaterthan": {
316
+ const option = String(
317
+ (block.fields.WHENGREATERTHANMENU || ["LOUDNESS"])[0],
318
+ ).toLowerCase();
319
+ const kind =
320
+ option === "timer" ? "timerGreaterThan" : "loudnessGreaterThan";
321
+ return `${pad}when ${kind}(${this.renderExpression(block.inputs.VALUE)}) {\n${body(block.next)}\n}`;
322
+ }
323
+ default:
324
+ return null;
325
+ }
326
+ }
327
+
328
+ renderFunction(defBlock) {
329
+ const protoId = firstOf(defBlock.inputs.custom_block);
330
+ const proto = this.target.blocks[protoId];
331
+ const mutation = (proto && proto.mutation) || {};
332
+ const proccode = mutation.proccode || "procedure";
333
+ const names = mutation.argumentnames
334
+ ? JSON.parse(mutation.argumentnames)
335
+ : [];
336
+ const baseName = proccode.split(" ")[0];
337
+ const returning = this.returningProccodes.has(proccode) ? "returning " : "";
338
+ const params = names.join(", ");
339
+ return `${returning}function ${baseName}(${params}) {\n${this.renderStack(defBlock.next, 1)}\n}`;
340
+ }
341
+
342
+ decompileTarget(target, indent = 0) {
343
+ const previous = this.target;
344
+ this.target = target;
345
+ const pad = " ".repeat(indent);
346
+ const sections = [];
347
+
348
+ // costumes / sounds
349
+ const costumeEntries = (target.costumes || [])
350
+ .map(
351
+ (c) => ` ${safeKey(c.name)}: "${this.assetPrefix}${c.md5ext}"`,
352
+ )
353
+ .join(",\n");
354
+ const soundEntries = (target.sounds || [])
355
+ .map(
356
+ (s) => ` ${safeKey(s.name)}: "${this.assetPrefix}${s.md5ext}"`,
357
+ )
358
+ .join(",\n");
359
+ if (costumeEntries)
360
+ sections.push(`${pad}costumes: {\n${costumeEntries}\n${pad}};`);
361
+ if (soundEntries)
362
+ sections.push(`${pad}sounds: {\n${soundEntries}\n${pad}};`);
363
+ if (target.layerOrder != null && !target.isStage)
364
+ sections.push(`${pad}layer: ${target.layerOrder};`);
365
+
366
+ // variables + lists
367
+ for (const [, desc] of Object.entries(target.variables || {})) {
368
+ const cloud = desc[2] ? "cloud " : "";
369
+ sections.push(`${pad}${cloud}let ${desc[0]} = ${literalText(desc[1])};`);
370
+ }
371
+ for (const [, desc] of Object.entries(target.lists || {})) {
372
+ const items = (desc[1] || []).map((v) => literalText(v)).join(", ");
373
+ sections.push(`${pad}list ${desc[0]} = [${items}];`);
374
+ }
375
+
376
+ // blocks
377
+ const blockIds = Object.keys(target.blocks || {});
378
+ const hats = blockIds.filter((id) => {
379
+ const b = target.blocks[id];
380
+ return (
381
+ b.topLevel &&
382
+ b.opcode !== "procedures_definition" &&
383
+ b.opcode !== "procedures_definition_return"
384
+ );
385
+ });
386
+ const defs = blockIds.filter((id) => {
387
+ const opcode = target.blocks[id].opcode;
388
+ return (
389
+ opcode === "procedures_definition" ||
390
+ opcode === "procedures_definition_return"
391
+ );
392
+ });
393
+
394
+ for (const id of defs) {
395
+ sections.push(indentText(this.renderFunction(target.blocks[id]), pad));
396
+ }
397
+ for (const id of hats) {
398
+ const rendered = this.renderHat(target.blocks[id], indent);
399
+ if (rendered) sections.push(indentText(rendered, pad));
400
+ else
401
+ sections.push(`${pad}// unsupported block ${target.blocks[id].opcode}`);
402
+ }
403
+
404
+ this.target = previous;
405
+ return sections.join("\n\n");
406
+ }
407
+
408
+ decompile() {
409
+ const out = [];
410
+ const stage = this.project.targets.find((t) => t.isStage);
411
+ const sprites = this.project.targets.filter((t) => !t.isStage);
412
+
413
+ if (stage) {
414
+ const body = this.decompileTarget(stage);
415
+ if (body.trim()) out.push(body);
416
+ }
417
+ for (const sprite of sprites) {
418
+ out.push(
419
+ `sprite "${escapeString(sprite.name)}" {\n${this.decompileTarget(sprite, 1)}\n}`,
420
+ );
421
+ }
422
+ return `${out.join("\n\n")}\n`;
423
+ }
424
+ }
425
+
426
+ function firstOf(input) {
427
+ if (!input) return null;
428
+ if (Array.isArray(input))
429
+ return typeof input[1] === "string" ? input[1] : null;
430
+ return null;
431
+ }
432
+
433
+ /** Walk a statement stack (including substacks) looking for `return`. */
434
+ function stackHasReturn(target, firstId) {
435
+ let id = firstId;
436
+ while (id) {
437
+ const block = target.blocks[id];
438
+ if (!block) break;
439
+ if (block.opcode === "procedures_return") return true;
440
+ for (const [name, input] of Object.entries(block.inputs || {})) {
441
+ if (!name.startsWith("SUBSTACK")) continue;
442
+ const child = firstOf(input);
443
+ if (child && stackHasReturn(target, child)) return true;
444
+ }
445
+ id = block.next;
446
+ }
447
+ return false;
448
+ }
449
+
450
+ function stripParen(text) {
451
+ return text;
452
+ }
453
+
454
+ function literalText(value) {
455
+ if (typeof value === "number") return String(value);
456
+ if (value === true || value === false) return String(value);
457
+ return `"${escapeString(value)}"`;
458
+ }
459
+
460
+ function safeKey(name) {
461
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)
462
+ ? name
463
+ : `"${escapeString(name)}"`;
464
+ }
465
+
466
+ function indentText(text, prefix) {
467
+ if (!text) return "";
468
+ return text
469
+ .split("\n")
470
+ .map((line) => `${prefix}${line}`)
471
+ .join("\n");
472
+ }
473
+
474
+ /**
475
+ * Decompile project JSON into Triflare Fx source.
476
+ * @param {object} project
477
+ * @param {{assetPrefix?: string}} [options]
478
+ * @returns {string}
479
+ */
480
+ export function decompileProject(project, options) {
481
+ return new Decompiler(project, options).decompile();
482
+ }
483
+
484
+ /**
485
+ * Decompile a `.pmp`/`.sb3` buffer (or project JSON) into Triflare Fx source.
486
+ * @param {Buffer|Uint8Array|object} input
487
+ * @param {{assetPrefix?: string}} [options]
488
+ * @returns {string}
489
+ */
490
+ export function decompile(input, options) {
491
+ if (
492
+ input &&
493
+ typeof input === "object" &&
494
+ !Buffer.isBuffer(input) &&
495
+ !(input instanceof Uint8Array) &&
496
+ input.targets
497
+ ) {
498
+ return decompileProject(input, options);
499
+ }
500
+ const files = unzipSync(input);
501
+ const json = files.get("project.json");
502
+ if (!json) throw new Error("Not a Scratch project: missing project.json");
503
+ return decompileProject(JSON.parse(json.toString("utf8")), options);
504
+ }