@triflare/fx 0.0.0-stage → 1.0.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 (39) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +73 -2
  3. package/bin/fx.js +1166 -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 +551 -0
  13. package/src/dev.js +390 -0
  14. package/src/errors.js +56 -0
  15. package/src/extensions.js +747 -0
  16. package/src/index.js +35 -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,551 @@
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
+ /** Reverse lookup for a merged catalog: opcode -> canonical catalog name. */
66
+ function buildByOpcode(catalog) {
67
+ const map = new Map();
68
+ for (const [name, entry] of Object.entries(catalog)) {
69
+ if (!map.has(entry.opcode)) map.set(entry.opcode, name);
70
+ }
71
+ return map;
72
+ }
73
+
74
+ class Decompiler {
75
+ constructor(project, options = {}) {
76
+ this.project = project;
77
+ this.assetPrefix = options.assetPrefix || "";
78
+ // Extension entries come from `loadExtensions`, so projects that use
79
+ // extensions decompile into real calls instead of "unsupported block".
80
+ this.catalog = options.catalog
81
+ ? { ...CATALOG, ...options.catalog }
82
+ : CATALOG;
83
+ this.byOpcode = options.catalog ? buildByOpcode(this.catalog) : BY_OPCODE;
84
+ this.returningProccodes = new Set();
85
+ for (const target of project.targets) {
86
+ for (const block of Object.values(target.blocks)) {
87
+ if (
88
+ block.opcode === "procedures_call" &&
89
+ block.mutation &&
90
+ (block.mutation.returns === "true" || block.mutation.return)
91
+ ) {
92
+ this.returningProccodes.add(block.mutation.proccode);
93
+ }
94
+ if (
95
+ block.opcode === "procedures_definition" ||
96
+ block.opcode === "procedures_definition_return"
97
+ ) {
98
+ const proto = target.blocks[firstOf(block.inputs.custom_block)];
99
+ const proccode = proto && proto.mutation && proto.mutation.proccode;
100
+ if (
101
+ proccode &&
102
+ (block.opcode === "procedures_definition_return" ||
103
+ (proto.mutation && proto.mutation.returns === "true") ||
104
+ stackHasReturn(target, block.next))
105
+ )
106
+ this.returningProccodes.add(proccode);
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ targetVars(target) {
113
+ const map = new Map();
114
+ for (const [id, desc] of Object.entries(target.variables || {}))
115
+ map.set(id, desc[0]);
116
+ for (const [id, desc] of Object.entries(target.lists || {}))
117
+ map.set(id, desc[0]);
118
+ return map;
119
+ }
120
+
121
+ renderExpression(value) {
122
+ if (value == null) return '""';
123
+ if (!Array.isArray(value)) return this.renderBlock(value);
124
+ const type = value[0];
125
+ switch (type) {
126
+ case 1:
127
+ case 3:
128
+ return this.renderInputRef(value[1]);
129
+ case 2:
130
+ return this.renderInputRef(value[1]);
131
+ case 4:
132
+ case 5:
133
+ case 6:
134
+ case 7:
135
+ return numberText(value[1]);
136
+ case 8:
137
+ return numberText(value[1]);
138
+ case 9:
139
+ return `"${escapeString(value[1])}"`;
140
+ case 10:
141
+ return `"${escapeString(value[1])}"`;
142
+ case 11:
143
+ return `"${escapeString(value[1])}"`;
144
+ case 12:
145
+ return String(value[1]);
146
+ case 13:
147
+ return String(value[1]);
148
+ default:
149
+ return "0";
150
+ }
151
+ }
152
+
153
+ renderInputRef(ref) {
154
+ if (Array.isArray(ref)) return this.renderExpression(ref);
155
+ if (ref == null) return "0";
156
+ return this.renderBlock(ref);
157
+ }
158
+
159
+ renderBlock(id) {
160
+ const block = this.target.blocks[id];
161
+ if (!block) return "0";
162
+
163
+ if (MENU_OPCODES.has(block.opcode)) {
164
+ const field = Object.values(block.fields || {})[0];
165
+ return field ? `"${escapeString(field[0])}"` : '""';
166
+ }
167
+ // Extension menus are shadow blocks the catalog never describes; their
168
+ // chosen option is the only thing worth emitting.
169
+ if (block.shadow) {
170
+ const field = Object.values(block.fields || {})[0];
171
+ if (field) return `"${escapeString(field[0])}"`;
172
+ }
173
+ if (block.opcode === "data_variable") return block.fields.VARIABLE[0];
174
+ if (block.opcode === "data_listcontents") return block.fields.LIST[0];
175
+ if (
176
+ Object.prototype.hasOwnProperty.call(LIST_OPCODE_METHOD, block.opcode)
177
+ ) {
178
+ const { method, inputs } = LIST_OPCODE_METHOD[block.opcode];
179
+ const listName = block.fields.LIST[0];
180
+ const args = inputs.map((input) =>
181
+ this.renderExpression(block.inputs[input]),
182
+ );
183
+ return `${listName}.${method}(${args.join(", ")})`;
184
+ }
185
+ if (
186
+ block.opcode === "argument_reporter_string_number" ||
187
+ block.opcode === "argument_reporter_boolean"
188
+ ) {
189
+ const value = block.fields.VALUE ? block.fields.VALUE[0] : "";
190
+ // TurboWarp/PenguinMod encode a few "PenguinMod" reporters as modified
191
+ // argument reporters; see docs.turbowarp.org/blocks and the VM compiler.
192
+ const magic = {
193
+ "last key pressed": "pm.lastKeyPressed()",
194
+ "is compiled?": "pm.isCompiled()",
195
+ "is penguinmod?": "pm.isPenguinMod()",
196
+ }[value];
197
+ return magic || value;
198
+ }
199
+ if (block.opcode === "procedures_call") {
200
+ return this.renderProcedureCall(block);
201
+ }
202
+ if (Object.prototype.hasOwnProperty.call(BINARY_OPCODES, block.opcode)) {
203
+ const left = this.renderExpression(
204
+ block.inputs.OPERAND1 || block.inputs.NUM1,
205
+ );
206
+ const right = this.renderExpression(
207
+ block.inputs.OPERAND2 || block.inputs.NUM2,
208
+ );
209
+ return `(${left} ${BINARY_OPCODES[block.opcode]} ${right})`;
210
+ }
211
+ if (block.opcode === "operator_not") {
212
+ return `(not ${this.renderExpression(block.inputs.OPERAND)})`;
213
+ }
214
+
215
+ const name = this.byOpcode.get(block.opcode);
216
+ if (name) return this.renderCatalogCall(name, block);
217
+ return `/* unknown ${block.opcode} */`;
218
+ }
219
+
220
+ renderCatalogCall(name, block) {
221
+ const entry = this.catalog[name];
222
+ const args = [];
223
+ for (const key of Object.keys(entry.fields || {})) {
224
+ const field = block.fields[key];
225
+ if (field) args.push(`"${escapeString(field[0])}"`);
226
+ }
227
+ for (const [inputName, spec] of Object.entries(entry.inputs || {})) {
228
+ if (spec.type === "substack") continue;
229
+ args.push(this.renderExpression(block.inputs[inputName]));
230
+ }
231
+ return `${name}(${args.join(", ")})`;
232
+ }
233
+
234
+ renderProcedureCall(block) {
235
+ const mutation = block.mutation || {};
236
+ const proccode = mutation.proccode || "";
237
+ const name = proccode.split(" ")[0];
238
+ const ids = mutation.argumentids ? JSON.parse(mutation.argumentids) : [];
239
+ const args = ids.map((argId) => this.renderExpression(block.inputs[argId]));
240
+ return `${name}(${args.join(", ")})`;
241
+ }
242
+
243
+ // ---------------------------------------------------------- statements
244
+
245
+ renderStack(firstId, indent) {
246
+ const lines = [];
247
+ let id = firstId;
248
+ while (id) {
249
+ const block = this.target.blocks[id];
250
+ if (!block) break;
251
+ lines.push(this.renderStatement(block, indent));
252
+ id = block.next;
253
+ }
254
+ return lines.filter(Boolean).join("\n");
255
+ }
256
+
257
+ renderStatement(block, indent) {
258
+ const pad = " ".repeat(indent);
259
+ const sub = (firstId) => {
260
+ if (!firstId) return "";
261
+ const body = this.renderStack(firstId, indent + 1);
262
+ return body ? `\n${body}` : "";
263
+ };
264
+
265
+ switch (block.opcode) {
266
+ case "data_setvariableto":
267
+ return `${pad}${block.fields.VARIABLE[0]} = ${this.renderExpression(block.inputs.VALUE)};`;
268
+ case "data_changevariableby":
269
+ return `${pad}${block.fields.VARIABLE[0]} += ${this.renderExpression(block.inputs.VALUE)};`;
270
+ case "control_if": {
271
+ const cond = this.renderExpression(block.inputs.CONDITION);
272
+ return `${pad}if (${stripParen(cond)}) {${sub(firstOf(block.inputs.SUBSTACK))}\n${pad}}`;
273
+ }
274
+ case "control_if_else": {
275
+ const cond = this.renderExpression(block.inputs.CONDITION);
276
+ return `${pad}if (${stripParen(cond)}) {${sub(firstOf(block.inputs.SUBSTACK))}\n${pad}} else {${sub(firstOf(block.inputs.SUBSTACK2))}\n${pad}}`;
277
+ }
278
+ case "control_forever":
279
+ return `${pad}control.forever {${sub(firstOf(block.inputs.SUBSTACK))}\n${pad}}`;
280
+ case "control_repeat":
281
+ return `${pad}repeat (${this.renderExpression(block.inputs.TIMES)}) {${sub(firstOf(block.inputs.SUBSTACK))}\n${pad}}`;
282
+ case "control_repeat_until":
283
+ return `${pad}control.repeatUntil(${stripParen(this.renderExpression(block.inputs.CONDITION))}) {${sub(firstOf(block.inputs.SUBSTACK))}\n${pad}}`;
284
+ case "control_wait_until":
285
+ return `${pad}control.waitUntil(${stripParen(this.renderExpression(block.inputs.CONDITION))});`;
286
+ case "procedures_return":
287
+ return `${pad}return ${this.renderExpression(block.inputs.return ?? block.inputs.VALUE)};`;
288
+ case "procedures_call":
289
+ return `${pad}${this.renderProcedureCall(block)};`;
290
+ case "control_stop":
291
+ return `${pad}control.stop("${block.fields.STOP_OPTION[0]}");`;
292
+ default: {
293
+ if (
294
+ Object.prototype.hasOwnProperty.call(LIST_OPCODE_METHOD, block.opcode)
295
+ ) {
296
+ const { method, inputs } = LIST_OPCODE_METHOD[block.opcode];
297
+ const listName = block.fields.LIST[0];
298
+ const args = inputs.map((input) =>
299
+ this.renderExpression(block.inputs[input]),
300
+ );
301
+ return `${pad}${listName}.${method}(${args.join(", ")});`;
302
+ }
303
+ const name = this.byOpcode.get(block.opcode);
304
+ if (!name) return `${pad}// unsupported block ${block.opcode}`;
305
+ const entry = this.catalog[name];
306
+ const substacks = Object.entries(entry.inputs || {})
307
+ .filter(([, spec]) => spec.type === "substack")
308
+ .map(([input]) => input);
309
+ if (substacks.length === 0)
310
+ return `${pad}${this.renderCatalogCall(name, block)};`;
311
+ const primary = firstOf(block.inputs[substacks[0]]);
312
+ const alternate = substacks[1]
313
+ ? firstOf(block.inputs[substacks[1]])
314
+ : null;
315
+ const otherwise = alternate
316
+ ? ` else {${sub(firstOf(block.inputs[substacks[1]]))}\n${pad}}`
317
+ : "";
318
+ return `${pad}${this.renderCatalogCall(name, block)} {${sub(primary)}${otherwise}\n${pad}}`;
319
+ }
320
+ }
321
+ }
322
+
323
+ renderHat(block, indent) {
324
+ const pad = "";
325
+ void indent;
326
+ const body = (firstId) => this.renderStack(firstId, 1);
327
+ switch (block.opcode) {
328
+ case "event_whenflagclicked":
329
+ return `${pad}when greenFlag {\n${body(block.next)}\n}`;
330
+ case "event_whenkeypressed": {
331
+ const key = block.fields.KEY_OPTION
332
+ ? block.fields.KEY_OPTION[0]
333
+ : "space";
334
+ return `${pad}when keyPressed("${escapeString(key)}") {\n${body(block.next)}\n}`;
335
+ }
336
+ case "event_whenthisspriteclicked":
337
+ return `${pad}when spriteClicked {\n${body(block.next)}\n}`;
338
+ case "event_whenstageclicked":
339
+ return `${pad}when stageClicked {\n${body(block.next)}\n}`;
340
+ case "control_start_as_clone":
341
+ return `${pad}when cloneStart {\n${body(block.next)}\n}`;
342
+ case "event_whenbroadcastreceived":
343
+ return `${pad}when broadcast("${escapeString(block.fields.BROADCAST_OPTION[0])}") {\n${body(block.next)}\n}`;
344
+ case "event_whenbackdropswitchesto":
345
+ return `${pad}when backdropSwitchesTo("${escapeString(block.fields.BACKDROP[0])}") {\n${body(block.next)}\n}`;
346
+ case "event_whengreaterthan": {
347
+ const option = String(
348
+ (block.fields.WHENGREATERTHANMENU || ["LOUDNESS"])[0],
349
+ ).toLowerCase();
350
+ const kind =
351
+ option === "timer" ? "timerGreaterThan" : "loudnessGreaterThan";
352
+ return `${pad}when ${kind}(${this.renderExpression(block.inputs.VALUE)}) {\n${body(block.next)}\n}`;
353
+ }
354
+ default:
355
+ return this.renderExtensionHat(block, pad, body);
356
+ }
357
+ }
358
+
359
+ /** Render hats contributed by extensions, e.g. `when signals.onBang("x") {`. */
360
+ renderExtensionHat(block, pad, body) {
361
+ const name = this.byOpcode.get(block.opcode);
362
+ const entry = name ? this.catalog[name] : null;
363
+ if (!entry || entry.kind !== "hat") return null;
364
+ const args = (entry.args || []).map((spec) => {
365
+ if (spec.field) {
366
+ const field = (block.fields || {})[spec.field];
367
+ return `"${escapeString(field ? field[0] : "")}"`;
368
+ }
369
+ return this.renderExpression((block.inputs || {})[spec.input]);
370
+ });
371
+ return `${pad}when ${name}(${args.join(", ")}) {\n${body(block.next)}\n}`;
372
+ }
373
+
374
+ renderFunction(defBlock) {
375
+ const protoId = firstOf(defBlock.inputs.custom_block);
376
+ const proto = this.target.blocks[protoId];
377
+ const mutation = (proto && proto.mutation) || {};
378
+ const proccode = mutation.proccode || "procedure";
379
+ const names = mutation.argumentnames
380
+ ? JSON.parse(mutation.argumentnames)
381
+ : [];
382
+ const baseName = proccode.split(" ")[0];
383
+ const returning = this.returningProccodes.has(proccode) ? "returning " : "";
384
+ const params = names.join(", ");
385
+ return `${returning}function ${baseName}(${params}) {\n${this.renderStack(defBlock.next, 1)}\n}`;
386
+ }
387
+
388
+ decompileTarget(target, indent = 0) {
389
+ const previous = this.target;
390
+ this.target = target;
391
+ const pad = " ".repeat(indent);
392
+ const sections = [];
393
+
394
+ // costumes / sounds
395
+ const costumeEntries = (target.costumes || [])
396
+ .map(
397
+ (c) => ` ${safeKey(c.name)}: "${this.assetPrefix}${c.md5ext}"`,
398
+ )
399
+ .join(",\n");
400
+ const soundEntries = (target.sounds || [])
401
+ .map(
402
+ (s) => ` ${safeKey(s.name)}: "${this.assetPrefix}${s.md5ext}"`,
403
+ )
404
+ .join(",\n");
405
+ if (costumeEntries)
406
+ sections.push(`${pad}costumes: {\n${costumeEntries}\n${pad}};`);
407
+ if (soundEntries)
408
+ sections.push(`${pad}sounds: {\n${soundEntries}\n${pad}};`);
409
+ if (target.layerOrder != null && !target.isStage)
410
+ sections.push(`${pad}layer: ${target.layerOrder};`);
411
+
412
+ // variables + lists
413
+ for (const [, desc] of Object.entries(target.variables || {})) {
414
+ const cloud = desc[2] ? "cloud " : "";
415
+ sections.push(`${pad}${cloud}let ${desc[0]} = ${literalText(desc[1])};`);
416
+ }
417
+ for (const [, desc] of Object.entries(target.lists || {})) {
418
+ const items = (desc[1] || []).map((v) => literalText(v)).join(", ");
419
+ sections.push(`${pad}list ${desc[0]} = [${items}];`);
420
+ }
421
+
422
+ // blocks
423
+ const blockIds = Object.keys(target.blocks || {});
424
+ const hats = blockIds.filter((id) => {
425
+ const b = target.blocks[id];
426
+ return (
427
+ b.topLevel &&
428
+ b.opcode !== "procedures_definition" &&
429
+ b.opcode !== "procedures_definition_return"
430
+ );
431
+ });
432
+ const defs = blockIds.filter((id) => {
433
+ const opcode = target.blocks[id].opcode;
434
+ return (
435
+ opcode === "procedures_definition" ||
436
+ opcode === "procedures_definition_return"
437
+ );
438
+ });
439
+
440
+ for (const id of defs) {
441
+ sections.push(indentText(this.renderFunction(target.blocks[id]), pad));
442
+ }
443
+ for (const id of hats) {
444
+ const rendered = this.renderHat(target.blocks[id], indent);
445
+ if (rendered) sections.push(indentText(rendered, pad));
446
+ else
447
+ sections.push(`${pad}// unsupported block ${target.blocks[id].opcode}`);
448
+ }
449
+
450
+ this.target = previous;
451
+ return sections.join("\n\n");
452
+ }
453
+
454
+ decompile() {
455
+ const out = [];
456
+ const stage = this.project.targets.find((t) => t.isStage);
457
+ const sprites = this.project.targets.filter((t) => !t.isStage);
458
+
459
+ if (stage) {
460
+ const body = this.decompileTarget(stage);
461
+ if (body.trim()) out.push(body);
462
+ }
463
+ for (const sprite of sprites) {
464
+ out.push(
465
+ `sprite "${escapeString(sprite.name)}" {\n${this.decompileTarget(sprite, 1)}\n}`,
466
+ );
467
+ }
468
+ return `${out.join("\n\n")}\n`;
469
+ }
470
+ }
471
+
472
+ function firstOf(input) {
473
+ if (!input) return null;
474
+ if (Array.isArray(input))
475
+ return typeof input[1] === "string" ? input[1] : null;
476
+ return null;
477
+ }
478
+
479
+ /** Walk a statement stack (including substacks) looking for `return`. */
480
+ function stackHasReturn(target, firstId) {
481
+ let id = firstId;
482
+ while (id) {
483
+ const block = target.blocks[id];
484
+ if (!block) break;
485
+ if (block.opcode === "procedures_return") return true;
486
+ for (const [name, input] of Object.entries(block.inputs || {})) {
487
+ if (!name.startsWith("SUBSTACK")) continue;
488
+ const child = firstOf(input);
489
+ if (child && stackHasReturn(target, child)) return true;
490
+ }
491
+ id = block.next;
492
+ }
493
+ return false;
494
+ }
495
+
496
+ function stripParen(text) {
497
+ return text;
498
+ }
499
+
500
+ function literalText(value) {
501
+ if (typeof value === "number") return String(value);
502
+ if (value === true || value === false) return String(value);
503
+ return `"${escapeString(value)}"`;
504
+ }
505
+
506
+ function safeKey(name) {
507
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)
508
+ ? name
509
+ : `"${escapeString(name)}"`;
510
+ }
511
+
512
+ function indentText(text, prefix) {
513
+ if (!text) return "";
514
+ return text
515
+ .split("\n")
516
+ .map((line) => `${prefix}${line}`)
517
+ .join("\n");
518
+ }
519
+
520
+ /**
521
+ * Decompile project JSON into Triflare Fx source.
522
+ * @param {object} project
523
+ * @param {{assetPrefix?: string, catalog?: Record<string, object>}} [options]
524
+ * `catalog` adds extension entries from `loadExtensions`.
525
+ * @returns {string}
526
+ */
527
+ export function decompileProject(project, options) {
528
+ return new Decompiler(project, options).decompile();
529
+ }
530
+
531
+ /**
532
+ * Decompile a `.pmp`/`.sb3` buffer (or project JSON) into Triflare Fx source.
533
+ * @param {Buffer|Uint8Array|object} input
534
+ * @param {{assetPrefix?: string, catalog?: Record<string, object>}} [options]
535
+ * @returns {string}
536
+ */
537
+ export function decompile(input, options) {
538
+ if (
539
+ input &&
540
+ typeof input === "object" &&
541
+ !Buffer.isBuffer(input) &&
542
+ !(input instanceof Uint8Array) &&
543
+ input.targets
544
+ ) {
545
+ return decompileProject(input, options);
546
+ }
547
+ const files = unzipSync(input);
548
+ const json = files.get("project.json");
549
+ if (!json) throw new Error("Not a Scratch project: missing project.json");
550
+ return decompileProject(JSON.parse(json.toString("utf8")), options);
551
+ }