@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,1658 @@
1
+ /**
2
+ * Triflare Fx compiler: AST -> Scratch 3 / PenguinMod project JSON.
3
+ *
4
+ * The compiler is deliberately catalog-driven: every `category.block(...)` is
5
+ * resolved through `catalog.js`, so adding a new block is a data change rather
6
+ * than a code change.
7
+ */
8
+ import { CATALOG, LIST_OPS, extensionForOpcode } from "./catalog.js";
9
+ import { CompileError } from "./errors.js";
10
+ import { configComment } from "./config.js";
11
+ import { md5hex, imageSize, wavInfo, detectFormat } from "./sb3.js";
12
+
13
+ const HAT_ALIASES = {
14
+ greenFlag: "events.greenFlag",
15
+ keyPressed: "events.keyPressed",
16
+ spriteClicked: "events.spriteClicked",
17
+ thisSpriteClicked: "events.spriteClicked",
18
+ stageClicked: "events.stageClicked",
19
+ cloneStart: "events.cloneStart",
20
+ startAsClone: "events.cloneStart",
21
+ broadcast: "events.broadcastReceived",
22
+ broadcastReceived: "events.broadcastReceived",
23
+ backdropSwitchesTo: "events.backdropSwitchesTo",
24
+ loudnessGreaterThan: "events.loudnessGreaterThan",
25
+ timerGreaterThan: "events.timerGreaterThan",
26
+ };
27
+
28
+ const NUMBER_TYPES = {
29
+ number: 4,
30
+ positive: 5,
31
+ whole: 6,
32
+ integer: 7,
33
+ };
34
+
35
+ // Opcodes whose block ends the stack: scratch-blocks gives them "shape_end"
36
+ // (no next-statement connection), so nothing can be serialized below them. The
37
+ // editor dereferences that missing connection while reading a following
38
+ // `<next>` element, which aborts the whole workspace update — so the compiler
39
+ // must refuse to emit statements after one.
40
+ const TERMINAL_OPCODES = new Set([
41
+ "procedures_return",
42
+ "control_delete_this_clone",
43
+ ]);
44
+
45
+ let seed = 0n;
46
+ function uid(prefix = "b") {
47
+ seed += 1n;
48
+ const rand = Math.floor(Math.random() * 0x1000000)
49
+ .toString(16)
50
+ .padStart(6, "0");
51
+ return `${prefix}_${seed.toString(16)}_${rand}`;
52
+ }
53
+
54
+ /** @param {unknown} value */
55
+ function normalizeColor(value) {
56
+ const s = String(value).trim().toLowerCase();
57
+ if (/^#[0-9a-f]{6}$/.test(s)) return s;
58
+ if (/^#[0-9a-f]{3}$/.test(s))
59
+ return `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`;
60
+ if (/^[0-9a-f]{6}$/.test(s)) return `#${s}`;
61
+ return "#000000";
62
+ }
63
+
64
+ function blankSvg(width, height, fill = false) {
65
+ const rect = fill
66
+ ? `<rect width="${width}" height="${height}" fill="#ffffff"/>`
67
+ : "";
68
+ return `<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${rect}</svg>`;
69
+ }
70
+
71
+ class Context {
72
+ constructor(compiler, target, isStage) {
73
+ this.compiler = compiler;
74
+ this.target = target;
75
+ this.isStage = isStage;
76
+ this.vars = new Map();
77
+ this.lists = new Map();
78
+ this.funcs = new Map();
79
+ this.paramScopes = [];
80
+ this.scriptX = 48;
81
+ this.scriptY = 48;
82
+ }
83
+
84
+ nextScriptPosition() {
85
+ const x = this.scriptX;
86
+ const y = this.scriptY;
87
+ this.scriptY += 420;
88
+ if (this.scriptY > 3600) {
89
+ this.scriptY = 48;
90
+ this.scriptX += 500;
91
+ }
92
+ return { x, y };
93
+ }
94
+
95
+ resolveValue(name) {
96
+ for (let i = this.paramScopes.length - 1; i >= 0; i--) {
97
+ const scope = this.paramScopes[i];
98
+ if (scope.has(name)) return { kind: "param", param: scope.get(name) };
99
+ }
100
+ if (this.vars.has(name)) return { kind: "var", ...this.vars.get(name) };
101
+ if (this.lists.has(name)) return { kind: "list", ...this.lists.get(name) };
102
+ const global = this.compiler.globals.get(name);
103
+ if (global) return { kind: global.kind, ...global };
104
+ return null;
105
+ }
106
+
107
+ resolveList(name) {
108
+ if (this.lists.has(name)) return this.lists.get(name);
109
+ const global = this.compiler.globals.get(name);
110
+ if (global && global.kind === "list") return global;
111
+ return null;
112
+ }
113
+ }
114
+
115
+ export class Compiler {
116
+ /**
117
+ * @param {object} options
118
+ * @param {object} options.project normalized fx.yml project config
119
+ * @param {(p: string) => Buffer} [options.loadAsset]
120
+ * @param {Record<string, object>} [options.catalog] extra/overriding catalog entries
121
+ * @param {Record<string, string>} [options.extensionUrls] id -> URL
122
+ * @param {string} [options.file]
123
+ */
124
+ constructor({
125
+ project,
126
+ loadAsset,
127
+ catalog,
128
+ extensionUrls = {},
129
+ file = "<input>",
130
+ } = {}) {
131
+ this.projectConfig = project || {};
132
+ this.loadAsset = loadAsset || null;
133
+ this.catalog = catalog ? { ...CATALOG, ...catalog } : CATALOG;
134
+ this.extensionUrls = extensionUrls;
135
+ this.file = file;
136
+
137
+ // opcode -> extension id, derived from the (merged) catalog. This lets
138
+ // synthesized extension blocks declare an id that isn't inferable from
139
+ // the opcode prefix alone.
140
+ this.opcodeExtensions = new Map();
141
+ for (const entry of Object.values(this.catalog)) {
142
+ if (entry && entry.extension && entry.opcode)
143
+ this.opcodeExtensions.set(entry.opcode, entry.extension);
144
+ }
145
+
146
+ // Opcodes whose next-statement connection only exists once a mutation asks
147
+ // for it (PenguinMod's loop nub), derived from the merged catalog.
148
+ this.dynamicNextOpcodes = new Set();
149
+ for (const entry of Object.values(this.catalog)) {
150
+ if (entry && entry.dynamicNext && entry.opcode)
151
+ this.dynamicNextOpcodes.add(entry.opcode);
152
+ }
153
+
154
+ this.assets = new Map(); // md5ext -> Buffer
155
+ this.globals = new Map(); // name -> {kind:'var'|'list', id, name, cloud?, value?}
156
+ this.broadcasts = new Map(); // name -> id
157
+ this.varCounter = 0;
158
+ this.broadcastCounter = 0;
159
+
160
+ /** @type {Context|null} */
161
+ this.ctx = null;
162
+ }
163
+
164
+ // ------------------------------------------------------------- utilities
165
+
166
+ fail(message, loc) {
167
+ throw new CompileError(message, {
168
+ file: (loc && loc.file) || this.file,
169
+ line: loc && loc.line,
170
+ column: loc && loc.column,
171
+ });
172
+ }
173
+
174
+ /** Human-readable source location for a duplicate declaration. */
175
+ where(loc) {
176
+ if (!loc || !loc.file) return null;
177
+ return loc.line != null ? `${loc.file}:${loc.line}` : loc.file;
178
+ }
179
+
180
+ newVarId() {
181
+ return `v${(++this.varCounter).toString(36)}`;
182
+ }
183
+
184
+ registerAsset(buffer, format) {
185
+ const assetId = md5hex(buffer);
186
+ const md5ext = `${assetId}.${format}`;
187
+ if (!this.assets.has(md5ext)) this.assets.set(md5ext, buffer);
188
+ return { assetId, md5ext, dataFormat: format };
189
+ }
190
+
191
+ declareGlobal(kind, name, extra = {}) {
192
+ const id = this.newVarId();
193
+ const info = { kind, id, name, ...extra };
194
+ this.globals.set(name, info);
195
+ return info;
196
+ }
197
+
198
+ broadcast(name) {
199
+ const clean = String(name);
200
+ if (!this.broadcasts.has(clean)) {
201
+ this.broadcasts.set(clean, uid("bc"));
202
+ }
203
+ return this.broadcasts.get(clean);
204
+ }
205
+
206
+ // --------------------------------------------------------------- entry
207
+
208
+ /**
209
+ * @param {object} ast Program node
210
+ * @returns {{project: object, assets: Map<string, Buffer>}}
211
+ */
212
+ compile(ast) {
213
+ const stageDecl = ast.stage;
214
+ const stageItems = [...(stageDecl ? stageDecl.body : []), ...ast.topItems];
215
+
216
+ this.stageContext = new Context(this, this.makeTarget(null, true), true);
217
+
218
+ const contexts = [];
219
+ this.stageCtx = this.buildTargetContext(
220
+ this.stageContext,
221
+ stageItems,
222
+ "Stage",
223
+ );
224
+ contexts.push(this.stageCtx);
225
+
226
+ for (const sprite of ast.sprites) {
227
+ const target = this.makeTarget(sprite.name, false);
228
+ const ctx = this.buildTargetContext(
229
+ new Context(this, target, false),
230
+ sprite.body,
231
+ sprite.name,
232
+ );
233
+ ctx.sprite = sprite;
234
+ contexts.push(ctx);
235
+ }
236
+
237
+ // Pass 2: emit scripts (hats + function bodies).
238
+ for (const ctx of contexts) {
239
+ this.ctx = ctx;
240
+ this.emitTargetScripts(ctx);
241
+ }
242
+
243
+ // Pass 3: editor-only connection state. "Next" links are wired up as the
244
+ // stacks are emitted, so blocks whose next-statement connection is dynamic
245
+ // can only be given their mutation once every stack is complete.
246
+ for (const ctx of contexts) this.applyDynamicNextMutations(ctx);
247
+
248
+ return { project: this.assembleProject(contexts), assets: this.assets };
249
+ }
250
+
251
+ /**
252
+ * Give every block with a dynamic next-statement connection the mutation the
253
+ * editor itself stores for it.
254
+ *
255
+ * PenguinMod's `control_forever` starts out without a next-statement
256
+ * connection: `Blockly.Blocks.control_forever.init` calls
257
+ * `setNextStatement(false)`, and only `domToMutation` (fed by
258
+ * `<mutation hasbreak="true">`, the nub the editor adds once a block sits
259
+ * below the loop) calls `setNextStatement(true, 'normal')`. A loop with
260
+ * blocks stacked under it but no such mutation makes the editor read
261
+ * `nextConnection.isConnected()` on null while handling the following
262
+ * `<next>` element, which aborts the entire workspace update.
263
+ * @param {Context} ctx
264
+ */
265
+ applyDynamicNextMutations(ctx) {
266
+ if (this.dynamicNextOpcodes.size === 0) return;
267
+ for (const block of Object.values(ctx.target.blocks)) {
268
+ if (!this.dynamicNextOpcodes.has(block.opcode)) continue;
269
+ block.mutation = {
270
+ tagName: "mutation",
271
+ hasbreak: Boolean(block.next),
272
+ children: [],
273
+ };
274
+ }
275
+ }
276
+
277
+ makeTarget(name, isStage) {
278
+ return {
279
+ isStage,
280
+ name: isStage ? "Stage" : name,
281
+ variables: {},
282
+ lists: {},
283
+ broadcasts: {},
284
+ blocks: {},
285
+ comments: {},
286
+ costumes: [],
287
+ sounds: [],
288
+ volume: 100,
289
+ layerOrder: 0,
290
+ visible: true,
291
+ x: 0,
292
+ y: 0,
293
+ size: 100,
294
+ direction: 90,
295
+ draggable: false,
296
+ rotationStyle: "all around",
297
+ };
298
+ }
299
+
300
+ buildTargetContext(ctx, items, targetName) {
301
+ ctx.bodyItems = items;
302
+ ctx.targetName = targetName;
303
+ // ---- pass 1: declarations (hoisted) ------------------------------
304
+ for (const item of items) {
305
+ if (item.type === "PropertyDecl") continue;
306
+ if (item.type === "VarDecl") {
307
+ this.declareTargetVar(ctx, item);
308
+ } else if (item.type === "ListDecl") {
309
+ this.declareTargetList(ctx, item);
310
+ } else if (item.type === "FunctionDecl") {
311
+ this.declareFunction(ctx, item);
312
+ }
313
+ }
314
+
315
+ // ---- costumes / sounds / layer -----------------------------------
316
+ for (const item of items) {
317
+ if (item.type !== "PropertyDecl") continue;
318
+ if (item.key === "costumes") this.applyCostumes(ctx, item);
319
+ else if (item.key === "sounds") this.applySounds(ctx, item);
320
+ else if (item.key === "layer") {
321
+ const value =
322
+ item.value && item.value.type === "Number" ? item.value.value : 0;
323
+ ctx.target.layerOrder = value;
324
+ }
325
+ }
326
+
327
+ return ctx;
328
+ }
329
+
330
+ declareTargetVar(ctx, decl) {
331
+ const global = ctx.isStage;
332
+ if (global && this.globals.has(decl.name)) {
333
+ const first = this.globals.get(decl.name);
334
+ const where = this.where(first.loc);
335
+ this.fail(
336
+ `Duplicate global variable ${JSON.stringify(decl.name)}` +
337
+ (where ? ` (already declared in ${where})` : ""),
338
+ decl.loc,
339
+ );
340
+ }
341
+ if (!global && ctx.vars.has(decl.name)) {
342
+ const first = ctx.vars.get(decl.name);
343
+ const where = this.where(first.loc);
344
+ this.fail(
345
+ `Duplicate variable ${JSON.stringify(decl.name)} in ${ctx.targetName}` +
346
+ (where ? ` (already declared in ${where})` : ""),
347
+ decl.loc,
348
+ );
349
+ }
350
+ let value = 0;
351
+ if (decl.init) value = this.constValue(decl.init, ctx);
352
+ const info = {
353
+ kind: "var",
354
+ id: this.newVarId(),
355
+ name: decl.name,
356
+ cloud: !!decl.cloud,
357
+ value,
358
+ loc: decl.loc,
359
+ };
360
+ if (global) {
361
+ this.globals.set(decl.name, info);
362
+ } else {
363
+ if (decl.cloud)
364
+ this.fail(
365
+ "`cloud let` is only valid at the top level or inside `stage`",
366
+ decl.loc,
367
+ );
368
+ ctx.vars.set(decl.name, info);
369
+ }
370
+ ctx.target.variables[info.id] = info.cloud
371
+ ? [decl.name, value, true]
372
+ : [decl.name, value];
373
+ }
374
+
375
+ declareTargetList(ctx, decl) {
376
+ if (ctx.isStage && this.globals.has(decl.name)) {
377
+ const first = this.globals.get(decl.name);
378
+ const where = this.where(first.loc);
379
+ this.fail(
380
+ `Duplicate global variable ${JSON.stringify(decl.name)}` +
381
+ (where ? ` (already declared in ${where})` : ""),
382
+ decl.loc,
383
+ );
384
+ }
385
+ if (!ctx.isStage && ctx.lists.has(decl.name)) {
386
+ const first = ctx.lists.get(decl.name);
387
+ const where = this.where(first.loc);
388
+ this.fail(
389
+ `Duplicate list ${JSON.stringify(decl.name)} in ${ctx.targetName}` +
390
+ (where ? ` (already declared in ${where})` : ""),
391
+ decl.loc,
392
+ );
393
+ }
394
+ const items = decl.items.map((expr) => String(this.constValue(expr, ctx)));
395
+ const info = {
396
+ kind: "list",
397
+ id: this.newVarId(),
398
+ name: decl.name,
399
+ value: items,
400
+ loc: decl.loc,
401
+ };
402
+ if (ctx.isStage) this.globals.set(decl.name, info);
403
+ else ctx.lists.set(decl.name, info);
404
+ ctx.target.lists[info.id] = [decl.name, items];
405
+ }
406
+
407
+ declareFunction(ctx, decl) {
408
+ if (ctx.funcs.has(decl.name)) {
409
+ const first = ctx.funcs.get(decl.name);
410
+ const where = this.where(first.loc);
411
+ this.fail(
412
+ `Duplicate function ${JSON.stringify(decl.name)} in ${ctx.targetName}` +
413
+ (where ? ` (already declared in ${where})` : ""),
414
+ decl.loc,
415
+ );
416
+ }
417
+ const argIds = decl.params.map((_, i) => `arg${i}`);
418
+ const proccode = decl.params.length
419
+ ? `${decl.name} ${decl.params.map((param) => (param.boolean ? "%b" : "%s")).join(" ")}`
420
+ : decl.name;
421
+ ctx.funcs.set(decl.name, {
422
+ name: decl.name,
423
+ params: decl.params,
424
+ argIds,
425
+ proccode,
426
+ returns: decl.returns,
427
+ warp: !!decl.warp,
428
+ decl,
429
+ loc: decl.loc,
430
+ definitionId: null,
431
+ });
432
+ }
433
+
434
+ constValue(expr, ctx) {
435
+ if (!expr) return 0;
436
+ switch (expr.type) {
437
+ case "Number":
438
+ return expr.value;
439
+ case "String":
440
+ return expr.value;
441
+ case "Boolean":
442
+ return expr.value;
443
+ case "Identifier": {
444
+ const resolved = ctx
445
+ ? ctx.resolveValue(expr.name)
446
+ : this.globals.get(expr.name);
447
+ if (resolved && resolved.kind === "var") {
448
+ const raw = resolved.value ?? this.globals.get(expr.name)?.value;
449
+ return raw === undefined ? 0 : raw;
450
+ }
451
+ return 0;
452
+ }
453
+ case "Unary":
454
+ if (expr.op === "-") return -Number(this.constValue(expr.operand, ctx));
455
+ return 0;
456
+ default:
457
+ return 0;
458
+ }
459
+ }
460
+
461
+ // ----------------------------------------------------------- assets
462
+
463
+ applyCostumes(ctx, property) {
464
+ for (const entry of property.value) {
465
+ const format = detectFormat(entry.value);
466
+ const buffer = this.readAsset(entry.value, entry.loc);
467
+ const { width, height } = imageSize(buffer, format);
468
+ const asset = this.registerAsset(buffer, format);
469
+ ctx.target.costumes.push({
470
+ name: entry.key,
471
+ bitmapResolution: 1,
472
+ dataFormat: format,
473
+ assetId: asset.assetId,
474
+ md5ext: asset.md5ext,
475
+ rotationCenterX: width / 2,
476
+ rotationCenterY: height / 2,
477
+ });
478
+ }
479
+ }
480
+
481
+ applySounds(ctx, property) {
482
+ for (const entry of property.value) {
483
+ const format = detectFormat(entry.value);
484
+ const buffer = this.readAsset(entry.value, entry.loc);
485
+ const info =
486
+ format === "wav" ? wavInfo(buffer) : { rate: 48000, sampleCount: 0 };
487
+ const asset = this.registerAsset(buffer, format);
488
+ ctx.target.sounds.push({
489
+ name: entry.key,
490
+ assetId: asset.assetId,
491
+ dataFormat: format,
492
+ format: "",
493
+ rate: info.rate,
494
+ sampleCount: info.sampleCount,
495
+ md5ext: asset.md5ext,
496
+ });
497
+ }
498
+ }
499
+
500
+ readAsset(filePath, loc) {
501
+ if (!this.loadAsset)
502
+ this.fail(
503
+ `Cannot load asset ${JSON.stringify(filePath)}: no asset loader configured`,
504
+ loc,
505
+ );
506
+ try {
507
+ return this.loadAsset(filePath, loc);
508
+ } catch (err) {
509
+ this.fail(
510
+ `Unable to read asset ${JSON.stringify(filePath)}: ${err.message}`,
511
+ loc,
512
+ );
513
+ }
514
+ }
515
+
516
+ // ------------------------------------------------------------- pass 2
517
+
518
+ emitTargetScripts(ctx) {
519
+ const previous = this.ctx;
520
+ this.ctx = ctx;
521
+ const body = ctx.bodyItems;
522
+ for (const item of body) {
523
+ if (item.type === "HatDecl") {
524
+ this.emitHat(ctx, item);
525
+ } else if (item.type === "FunctionDecl") {
526
+ this.emitFunction(ctx, item);
527
+ } else if (
528
+ item.type === "PropertyDecl" ||
529
+ item.type === "VarDecl" ||
530
+ item.type === "ListDecl"
531
+ ) {
532
+ // handled in pass 1
533
+ } else {
534
+ this.fail(
535
+ "Statements must appear inside a `when ... { }` hat block",
536
+ item.loc,
537
+ );
538
+ }
539
+ }
540
+ this.ctx = previous;
541
+ }
542
+
543
+ emitHat(ctx, decl) {
544
+ const short = decl.event.segments[decl.event.segments.length - 1];
545
+ // Prefer the fully qualified name so extensions can expose hats such as
546
+ // `signals.onMessage`; otherwise fall back to the built-in aliases.
547
+ const fullyQualified = this.catalog[decl.event.name];
548
+ const canonical = HAT_ALIASES[short] || `events.${short}`;
549
+ const entry =
550
+ fullyQualified && fullyQualified.kind === "hat"
551
+ ? fullyQualified
552
+ : this.catalog[canonical];
553
+ if (!entry || entry.kind !== "hat")
554
+ this.fail(`Unknown event ${JSON.stringify(short)}`, decl.loc);
555
+
556
+ const pos = ctx.nextScriptPosition();
557
+ const fields = {};
558
+ const inputs = {};
559
+ const argSpecs = entry.args || [];
560
+ if (decl.event.args.length > argSpecs.length) {
561
+ this.fail(
562
+ `Event ${short} accepts at most ${argSpecs.length} argument(s)`,
563
+ decl.loc,
564
+ );
565
+ }
566
+ argSpecs.forEach((spec, i) => {
567
+ const argExpr = decl.event.args[i];
568
+ if (spec.field) {
569
+ if (spec.type === "broadcast") {
570
+ const name = argExpr
571
+ ? String(this.literalValue(argExpr))
572
+ : spec.default;
573
+ fields[spec.field] = [name, this.broadcast(name)];
574
+ } else {
575
+ const value = argExpr ? this.literalValue(argExpr) : spec.default;
576
+ fields[spec.field] = [String(value ?? spec.value ?? "")];
577
+ }
578
+ } else if (spec.input) {
579
+ inputs[spec.input] = this.buildInputForSpec(
580
+ { type: spec.type || "any" },
581
+ argExpr,
582
+ null,
583
+ );
584
+ }
585
+ });
586
+
587
+ const id = this.addBlock(ctx, {
588
+ opcode: entry.opcode,
589
+ fields,
590
+ inputs,
591
+ topLevel: true,
592
+ x: pos.x,
593
+ y: pos.y,
594
+ });
595
+ // re-parent any inputs that referenced this hat
596
+ this.reparentInputs(ctx, id, inputs);
597
+ const first = this.newStack(ctx, decl.body, id);
598
+ if (first) ctx.target.blocks[id].next = first;
599
+ return id;
600
+ }
601
+
602
+ emitFunction(ctx, decl) {
603
+ const fn = ctx.funcs.get(decl.name);
604
+ const pos = ctx.nextScriptPosition();
605
+ const defId = this.addBlock(ctx, {
606
+ opcode: fn.returns
607
+ ? "procedures_definition_return"
608
+ : "procedures_definition",
609
+ topLevel: true,
610
+ x: pos.x,
611
+ y: pos.y,
612
+ });
613
+
614
+ const protoId = uid("proto");
615
+ const protoInputs = {};
616
+ for (let i = 0; i < fn.params.length; i++) {
617
+ const param = fn.params[i];
618
+ const repId = this.addBlock(ctx, {
619
+ opcode: param.boolean
620
+ ? "argument_reporter_boolean"
621
+ : "argument_reporter_string_number",
622
+ shadow: true,
623
+ parent: protoId,
624
+ fields: { VALUE: [param.name, null] },
625
+ });
626
+ protoInputs[fn.argIds[i]] = [1, repId];
627
+ }
628
+ ctx.target.blocks[protoId] = {
629
+ opcode: "procedures_prototype",
630
+ next: null,
631
+ parent: defId,
632
+ inputs: protoInputs,
633
+ fields: {},
634
+ shadow: true,
635
+ topLevel: false,
636
+ mutation: {
637
+ tagName: "mutation",
638
+ children: [],
639
+ proccode: fn.proccode,
640
+ argumentids: JSON.stringify(fn.argIds),
641
+ argumentnames: JSON.stringify(fn.params.map((p) => p.name)),
642
+ argumentdefaults: JSON.stringify(fn.params.map(() => "")),
643
+ warp: String(fn.warp),
644
+ ...(fn.returns ? { returns: "true" } : {}),
645
+ },
646
+ };
647
+ ctx.target.blocks[defId].inputs.custom_block = [1, protoId];
648
+ fn.definitionId = defId;
649
+
650
+ // Compile body with parameters in scope.
651
+ const scope = new Map();
652
+ for (const param of fn.params) scope.set(param.name, param);
653
+ ctx.paramScopes.push(scope);
654
+ const first = this.newStack(ctx, decl.body, defId);
655
+ ctx.paramScopes.pop();
656
+ if (first) ctx.target.blocks[defId].next = first;
657
+ }
658
+
659
+ // --------------------------------------------------------- block utils
660
+
661
+ reparentInputs(ctx, parentId, inputs) {
662
+ for (const value of Object.values(inputs)) {
663
+ if (!Array.isArray(value)) continue;
664
+ for (let i = 1; i < value.length; i++) {
665
+ const ref = value[i];
666
+ if (typeof ref === "string" && ctx.target.blocks[ref]) {
667
+ ctx.target.blocks[ref].parent = parentId;
668
+ }
669
+ }
670
+ }
671
+ }
672
+
673
+ addBlock(ctx, opts) {
674
+ const id = uid();
675
+ const block = {
676
+ opcode: opts.opcode,
677
+ next: opts.next || null,
678
+ parent: opts.parent || null,
679
+ inputs: opts.inputs || {},
680
+ fields: opts.fields || {},
681
+ shadow: !!opts.shadow,
682
+ topLevel: !!opts.topLevel,
683
+ };
684
+ if (opts.topLevel) {
685
+ block.x = opts.x || 0;
686
+ block.y = opts.y || 0;
687
+ }
688
+ if (opts.mutation) block.mutation = opts.mutation;
689
+ ctx.target.blocks[id] = block;
690
+ return id;
691
+ }
692
+
693
+ newStack(ctx, statements, parentId) {
694
+ let first = null;
695
+ let prev = null;
696
+ for (const statement of statements) {
697
+ if (prev != null && this.blockEndsScript(ctx, prev)) {
698
+ this.fail(
699
+ `No statements can follow ${this.terminalDescription(ctx, prev)}; ` +
700
+ "nothing can connect below a block that ends the script",
701
+ statement.loc,
702
+ );
703
+ }
704
+ const id = this.compileStatement(ctx, statement);
705
+ if (id == null) continue;
706
+ if (first == null) {
707
+ first = id;
708
+ ctx.target.blocks[id].parent = parentId;
709
+ } else {
710
+ ctx.target.blocks[prev].next = id;
711
+ ctx.target.blocks[id].parent = prev;
712
+ }
713
+ prev = id;
714
+ }
715
+ return first;
716
+ }
717
+
718
+ /**
719
+ * Whether a compiled block has no next-statement connection in the editor.
720
+ * `control.forever` is excluded: PenguinMod grows a next connection for it
721
+ * via its `hasbreak` mutation (see `dynamicNext`).
722
+ * @param {Context} ctx
723
+ * @param {string} id
724
+ */
725
+ blockEndsScript(ctx, id) {
726
+ const block = ctx.target.blocks[id];
727
+ if (!block) return false;
728
+ if (TERMINAL_OPCODES.has(block.opcode)) return true;
729
+ if (block.opcode === "control_stop") {
730
+ return !(block.mutation && block.mutation.hasnext);
731
+ }
732
+ return false;
733
+ }
734
+
735
+ /** Human-readable name for a block that ends the script. */
736
+ terminalDescription(ctx, id) {
737
+ const block = ctx.target.blocks[id];
738
+ if (block.opcode === "procedures_return") return "`return`";
739
+ if (block.opcode === "control_delete_this_clone")
740
+ return "`control.deleteThisClone()`";
741
+ if (block.opcode === "control_stop") {
742
+ const option = block.fields.STOP_OPTION
743
+ ? block.fields.STOP_OPTION[0]
744
+ : "all";
745
+ return `\`control.stop(${JSON.stringify(option)})\``;
746
+ }
747
+ return `\`${block.opcode}\``;
748
+ }
749
+
750
+ // ------------------------------------------------------- statements
751
+
752
+ compileStatement(ctx, node) {
753
+ switch (node.type) {
754
+ case "BlockCall":
755
+ return this.compileBlockCallStatement(ctx, node);
756
+ case "Assign":
757
+ return this.compileAssign(ctx, node);
758
+ case "If":
759
+ return this.compileIf(ctx, node);
760
+ case "Repeat":
761
+ return this.compileRepeat(ctx, node);
762
+ case "While":
763
+ return this.compileWhile(ctx, node);
764
+ case "Forever":
765
+ return this.compileForever(ctx, node);
766
+ case "Return":
767
+ return this.compileReturn(ctx, node);
768
+ default:
769
+ this.fail(`Unsupported statement ${node.type}`, node.loc);
770
+ }
771
+ return null;
772
+ }
773
+
774
+ compileBlockCallStatement(ctx, node) {
775
+ const result = this.emitCall(ctx, node, null);
776
+ if (result.kind === "reporter" || result.kind === "boolean") {
777
+ this.fail(
778
+ `${node.name} returns a value and cannot be used as a statement`,
779
+ node.loc,
780
+ );
781
+ }
782
+ if (node.body) {
783
+ const entry = result.entry;
784
+ const subNames = entry
785
+ ? Object.keys(entry.inputs || {}).filter(
786
+ (key) => entry.inputs[key].type === "substack",
787
+ )
788
+ : [];
789
+ if (!subNames.length)
790
+ this.fail(`${node.name} cannot take a block body`, node.loc);
791
+ const first = this.newStack(ctx, node.body, result.id);
792
+ if (first) ctx.target.blocks[result.id].inputs[subNames[0]] = [2, first];
793
+ if (node.else) {
794
+ if (subNames[1]) {
795
+ const firstElse = this.newStack(ctx, node.else, result.id);
796
+ if (firstElse)
797
+ ctx.target.blocks[result.id].inputs[subNames[1]] = [2, firstElse];
798
+ } else {
799
+ this.fail(`${node.name} does not support an else branch`, node.loc);
800
+ }
801
+ }
802
+ }
803
+ return result.id;
804
+ }
805
+
806
+ compileAssign(ctx, node) {
807
+ const resolved = ctx.resolveValue(node.name);
808
+ if (!resolved || resolved.kind !== "var") {
809
+ this.fail(`Unknown variable ${JSON.stringify(node.name)}`, node.loc);
810
+ }
811
+ const variableField = [resolved.name, resolved.id];
812
+ if (node.op === "=") {
813
+ const id = this.addBlock(ctx, {
814
+ opcode: "data_setvariableto",
815
+ fields: { VARIABLE: variableField },
816
+ });
817
+ ctx.target.blocks[id].inputs.VALUE = this.buildInputForSpec(
818
+ { type: "any" },
819
+ node.value,
820
+ id,
821
+ );
822
+ return id;
823
+ }
824
+ if (node.op === "+=") {
825
+ const id = this.addBlock(ctx, {
826
+ opcode: "data_changevariableby",
827
+ fields: { VARIABLE: variableField },
828
+ });
829
+ ctx.target.blocks[id].inputs.VALUE = this.buildInputForSpec(
830
+ { type: "any" },
831
+ node.value,
832
+ id,
833
+ );
834
+ return id;
835
+ }
836
+ // -=, *=, /= become `set x = <op>(x, value)`
837
+ const opMap = {
838
+ "-=": "operators.subtract",
839
+ "*=": "operators.multiply",
840
+ "/=": "operators.divide",
841
+ };
842
+ const id = this.addBlock(ctx, {
843
+ opcode: "data_setvariableto",
844
+ fields: { VARIABLE: variableField },
845
+ });
846
+ const opName = opMap[node.op];
847
+ const operatorId = this.emitBinaryBlock(
848
+ ctx,
849
+ opName,
850
+ id,
851
+ {
852
+ type: "Identifier",
853
+ name: node.name,
854
+ segments: [node.name],
855
+ loc: node.loc,
856
+ },
857
+ node.value,
858
+ );
859
+ ctx.target.blocks[id].inputs.VALUE = [3, operatorId, [10, ""]];
860
+ return id;
861
+ }
862
+
863
+ compileIf(ctx, node) {
864
+ const id = this.addBlock(ctx, {
865
+ opcode: node.else ? "control_if_else" : "control_if",
866
+ });
867
+ ctx.target.blocks[id].inputs.CONDITION = this.booleanInput(
868
+ ctx,
869
+ node.cond,
870
+ id,
871
+ );
872
+ const first = this.newStack(ctx, node.then, id);
873
+ if (first) ctx.target.blocks[id].inputs.SUBSTACK = [2, first];
874
+ if (node.else) {
875
+ const firstElse = this.newStack(ctx, node.else, id);
876
+ if (firstElse) ctx.target.blocks[id].inputs.SUBSTACK2 = [2, firstElse];
877
+ }
878
+ return id;
879
+ }
880
+
881
+ compileRepeat(ctx, node) {
882
+ const id = this.addBlock(ctx, { opcode: "control_repeat" });
883
+ ctx.target.blocks[id].inputs.TIMES = this.buildInputForSpec(
884
+ { type: "whole" },
885
+ node.count,
886
+ id,
887
+ );
888
+ const first = this.newStack(ctx, node.body, id);
889
+ if (first) ctx.target.blocks[id].inputs.SUBSTACK = [2, first];
890
+ return id;
891
+ }
892
+
893
+ compileWhile(ctx, node) {
894
+ const id = this.addBlock(ctx, { opcode: "control_repeat_until" });
895
+ // while (cond) == repeat until (not cond)
896
+ const notId = this.addBlock(ctx, { opcode: "operator_not", parent: id });
897
+ ctx.target.blocks[notId].inputs.OPERAND = this.booleanInput(
898
+ ctx,
899
+ node.cond,
900
+ notId,
901
+ );
902
+ ctx.target.blocks[id].inputs.CONDITION = [2, notId];
903
+ const first = this.newStack(ctx, node.body, id);
904
+ if (first) ctx.target.blocks[id].inputs.SUBSTACK = [2, first];
905
+ return id;
906
+ }
907
+
908
+ compileForever(ctx, node) {
909
+ const id = this.addBlock(ctx, { opcode: "control_forever" });
910
+ const first = this.newStack(ctx, node.body, id);
911
+ if (first) ctx.target.blocks[id].inputs.SUBSTACK = [2, first];
912
+ return id;
913
+ }
914
+
915
+ compileReturn(ctx, node) {
916
+ const id = this.addBlock(ctx, { opcode: "procedures_return" });
917
+ ctx.target.blocks[id].inputs.return = node.value
918
+ ? this.buildInputForSpec({ type: "any" }, node.value, id)
919
+ : [1, [10, ""]];
920
+ return id;
921
+ }
922
+
923
+ // -------------------------------------------------------- call dispatch
924
+
925
+ /**
926
+ * Emit a call node. Returns {id, kind, entry?}.
927
+ */
928
+ emitCall(ctx, node, parentId) {
929
+ const segments = node.segments;
930
+
931
+ // List method: `items.push(x)`, `items.at(i)`, ...
932
+ if (segments.length === 2) {
933
+ const listInfo = ctx.resolveList(segments[0]);
934
+ if (listInfo) return this.emitListCall(ctx, node, listInfo, parentId);
935
+ }
936
+
937
+ const entry = this.catalog[node.name];
938
+ if (entry) return this.emitCatalogCall(ctx, node, entry, parentId);
939
+
940
+ if (segments.length === 1 && ctx.funcs.has(node.name)) {
941
+ return this.emitProcedureCall(
942
+ ctx,
943
+ node,
944
+ ctx.funcs.get(node.name),
945
+ parentId,
946
+ );
947
+ }
948
+
949
+ this.fail(
950
+ `Unknown block or function ${JSON.stringify(node.name)}`,
951
+ node.loc,
952
+ );
953
+ return null;
954
+ }
955
+
956
+ emitCatalogCall(ctx, node, entry, parentId) {
957
+ if (entry.kind === "hat")
958
+ this.fail(`${node.name} is an event and cannot be called`, node.loc);
959
+ const id = this.addBlock(ctx, { opcode: entry.opcode, parent: parentId });
960
+ const fieldNames = Object.keys(entry.fields || {});
961
+ const inputNames = Object.keys(entry.inputs || {});
962
+
963
+ if (node.args.length > inputNames.length + fieldNames.length) {
964
+ this.fail(
965
+ `${node.name} expects at most ${inputNames.length + fieldNames.length} argument(s)`,
966
+ node.loc,
967
+ );
968
+ }
969
+
970
+ const fields = {};
971
+ for (const [name, value] of Object.entries(entry.fields || {})) {
972
+ fields[name] = Array.isArray(value) ? value : [String(value)];
973
+ }
974
+
975
+ // Positional args fill dropdown fields first, then value inputs. This
976
+ // mirrors how mixed blocks read left-to-right in the editor, e.g.
977
+ // `looks.setEffect("ghost", 25)` or `operators.math("sqrt", 9)`.
978
+ const inputs = {};
979
+ let argIndex = 0;
980
+ for (const name of fieldNames) {
981
+ if (node.args[argIndex] !== undefined) {
982
+ fields[name] = [String(this.literalValue(node.args[argIndex]))];
983
+ argIndex += 1;
984
+ }
985
+ }
986
+ for (const name of inputNames) {
987
+ const spec = entry.inputs[name];
988
+ if (spec.type === "substack") continue;
989
+ if (node.args[argIndex] !== undefined) {
990
+ inputs[name] = this.buildInputForSpec(spec, node.args[argIndex], id);
991
+ argIndex += 1;
992
+ } else {
993
+ inputs[name] = this.defaultInput(spec);
994
+ }
995
+ }
996
+
997
+ ctx.target.blocks[id].inputs = inputs;
998
+ ctx.target.blocks[id].fields = fields;
999
+ this.reparentInputs(ctx, id, inputs);
1000
+
1001
+ if (entry.stopBlock) {
1002
+ const stopOption = fields.STOP_OPTION ? fields.STOP_OPTION[0] : "all";
1003
+ const hasNext =
1004
+ stopOption === "other scripts in sprite" ||
1005
+ stopOption === "other scripts in stage";
1006
+ ctx.target.blocks[id].mutation = {
1007
+ tagName: "mutation",
1008
+ hasnext: hasNext,
1009
+ children: [],
1010
+ };
1011
+ }
1012
+
1013
+ return { id, kind: entry.kind, entry };
1014
+ }
1015
+
1016
+ emitListCall(ctx, node, listInfo, parentId) {
1017
+ const method = node.segments[1];
1018
+ let spec = null;
1019
+ let canonical = method;
1020
+ for (const [name, entry] of Object.entries(LIST_OPS)) {
1021
+ if (name === method || (entry.aliases || []).includes(method)) {
1022
+ spec = entry;
1023
+ canonical = name;
1024
+ break;
1025
+ }
1026
+ }
1027
+ if (!spec)
1028
+ this.fail(
1029
+ `Unknown list operation ${JSON.stringify(method)} on list ${JSON.stringify(listInfo.name)}`,
1030
+ node.loc,
1031
+ );
1032
+
1033
+ const fields = { LIST: [listInfo.name, listInfo.id] };
1034
+ const id = this.addBlock(ctx, {
1035
+ opcode: spec.opcode,
1036
+ fields,
1037
+ parent: parentId,
1038
+ });
1039
+ spec.inputs.forEach((inputName, i) => {
1040
+ if (node.args[i] === undefined) {
1041
+ ctx.target.blocks[id].inputs[inputName] = [1, [4, "0"]];
1042
+ } else {
1043
+ const specType =
1044
+ inputName === "INDEX" ? { type: "whole" } : { type: "any" };
1045
+ ctx.target.blocks[id].inputs[inputName] = this.buildInputForSpec(
1046
+ specType,
1047
+ node.args[i],
1048
+ id,
1049
+ );
1050
+ }
1051
+ });
1052
+ this.reparentInputs(ctx, id, ctx.target.blocks[id].inputs);
1053
+ void canonical;
1054
+ return { id, kind: spec.kind, entry: spec };
1055
+ }
1056
+
1057
+ emitProcedureCall(ctx, node, fn, parentId) {
1058
+ const id = this.addBlock(ctx, {
1059
+ opcode: "procedures_call",
1060
+ parent: parentId,
1061
+ });
1062
+ const inputs = {};
1063
+ fn.argIds.forEach((argId, i) => {
1064
+ const param = fn.params[i];
1065
+ const spec = param.boolean ? { type: "boolean" } : { type: "any" };
1066
+ inputs[argId] =
1067
+ node.args[i] !== undefined
1068
+ ? this.buildInputForSpec(spec, node.args[i], id)
1069
+ : this.defaultInput(spec);
1070
+ });
1071
+ const mutation = {
1072
+ tagName: "mutation",
1073
+ children: [],
1074
+ proccode: fn.proccode,
1075
+ argumentids: JSON.stringify(fn.argIds),
1076
+ warp: String(fn.warp),
1077
+ };
1078
+ if (fn.returns) mutation.returns = "true";
1079
+ ctx.target.blocks[id].mutation = mutation;
1080
+ ctx.target.blocks[id].inputs = inputs;
1081
+ this.reparentInputs(ctx, id, inputs);
1082
+ return { id, kind: fn.returns ? "reporter" : "command", entry: {} };
1083
+ }
1084
+
1085
+ // ------------------------------------------------------- input builders
1086
+
1087
+ buildInputForSpec(spec, expr, parentId) {
1088
+ if (expr === undefined || expr === null) return this.defaultInput(spec);
1089
+ if (spec.type === "menu") return this.menuInput(spec, expr, parentId);
1090
+ if (spec.type === "broadcast") return this.broadcastInput(expr, parentId);
1091
+ if (spec.type === "boolean")
1092
+ return this.booleanInput(this.ctx, expr, parentId);
1093
+ const desc = this.emitExpr(this.ctx, expr);
1094
+ return this.valueInput(desc, spec, parentId);
1095
+ }
1096
+
1097
+ defaultInput(spec) {
1098
+ if (spec.type === "boolean")
1099
+ return [2, this.booleanLiteralBlock(this.ctx, true)];
1100
+ if (spec.type === "menu") {
1101
+ const value = spec.default ?? "";
1102
+ const id = this.addBlock(this.ctx, {
1103
+ opcode: spec.menu,
1104
+ shadow: true,
1105
+ fields: { [spec.field]: [String(value)] },
1106
+ });
1107
+ return [1, id];
1108
+ }
1109
+ return [1, this.defaultPrim(spec)];
1110
+ }
1111
+
1112
+ defaultPrim(spec) {
1113
+ switch (spec.type) {
1114
+ case "number":
1115
+ return [4, "0"];
1116
+ case "positive":
1117
+ return [5, "0"];
1118
+ case "whole":
1119
+ return [6, "0"];
1120
+ case "integer":
1121
+ return [7, "0"];
1122
+ case "angle":
1123
+ return [8, "90"];
1124
+ case "color":
1125
+ return [9, "#000000"];
1126
+ case "string":
1127
+ return [10, ""];
1128
+ default:
1129
+ return [10, ""];
1130
+ }
1131
+ }
1132
+
1133
+ menuInput(spec, expr, parentId) {
1134
+ const value = this.literalValue(expr);
1135
+ const id = this.addBlock(this.ctx, {
1136
+ opcode: spec.menu,
1137
+ shadow: true,
1138
+ parent: parentId,
1139
+ fields: { [spec.field]: [String(value ?? spec.default ?? "")] },
1140
+ });
1141
+ return [1, id];
1142
+ }
1143
+
1144
+ broadcastInput(expr, parentId) {
1145
+ const name = String(this.literalValue(expr));
1146
+ const id = this.broadcast(name);
1147
+ void parentId;
1148
+ return [1, [11, name, id]];
1149
+ }
1150
+
1151
+ booleanInput(ctx, expr, parentId) {
1152
+ const desc = this.emitExpr(ctx, expr);
1153
+ if (desc.k === "block") {
1154
+ ctx.target.blocks[desc.id].parent = parentId;
1155
+ return [2, desc.id];
1156
+ }
1157
+ // Literal/primitive in boolean position: compare against "true".
1158
+ const id = this.addBlock(ctx, {
1159
+ opcode: "operator_equals",
1160
+ parent: parentId,
1161
+ });
1162
+ ctx.target.blocks[id].inputs.OPERAND1 = [
1163
+ 1,
1164
+ desc.k === "prim"
1165
+ ? desc.prim
1166
+ : this.primForDesc(desc, { type: "string" }),
1167
+ ];
1168
+ ctx.target.blocks[id].inputs.OPERAND2 = [1, [10, "true"]];
1169
+ return [2, id];
1170
+ }
1171
+
1172
+ booleanLiteralBlock(ctx, value) {
1173
+ const id = this.addBlock(ctx, { opcode: "operator_equals" });
1174
+ ctx.target.blocks[id].inputs.OPERAND1 = [1, [10, value ? "true" : "false"]];
1175
+ ctx.target.blocks[id].inputs.OPERAND2 = [1, [10, "true"]];
1176
+ return id;
1177
+ }
1178
+
1179
+ primForDesc(desc, spec) {
1180
+ if (desc.k === "var") return [12, desc.name, desc.id];
1181
+ if (desc.k === "list") return [13, desc.name, desc.id];
1182
+ return this.defaultPrim(spec);
1183
+ }
1184
+
1185
+ valueInput(desc, spec, parentId) {
1186
+ if (desc.k === "var") {
1187
+ return [3, [12, desc.name, desc.id], this.defaultPrim(spec)];
1188
+ }
1189
+ if (desc.k === "list") {
1190
+ return [3, [13, desc.name, desc.id], this.defaultPrim(spec)];
1191
+ }
1192
+ if (desc.k === "prim") {
1193
+ if (spec.type === "color") return [1, [9, normalizeColor(desc.prim[1])]];
1194
+ if (spec.type === "any") return [1, desc.prim];
1195
+ return [1, this.coercePrim(desc, spec)];
1196
+ }
1197
+ // block
1198
+ if (this.ctx.target.blocks[desc.id])
1199
+ this.ctx.target.blocks[desc.id].parent = parentId;
1200
+ return [3, desc.id, this.defaultPrim(spec)];
1201
+ }
1202
+
1203
+ coercePrim(desc, spec) {
1204
+ const raw = desc.prim[1];
1205
+ if (spec.type === "string") return [10, String(raw)];
1206
+ const num = Number(raw);
1207
+ const value = Number.isFinite(num) ? String(num) : "0";
1208
+ const typeNum = NUMBER_TYPES[spec.type] || 4;
1209
+ return [typeNum, value];
1210
+ }
1211
+
1212
+ literalValue(expr) {
1213
+ if (!expr) return "";
1214
+ switch (expr.type) {
1215
+ case "String":
1216
+ return expr.value;
1217
+ case "Number":
1218
+ return expr.value;
1219
+ case "Boolean":
1220
+ return expr.value;
1221
+ case "Identifier":
1222
+ return expr.name;
1223
+ default:
1224
+ return "";
1225
+ }
1226
+ }
1227
+
1228
+ // -------------------------------------------------------- expressions
1229
+
1230
+ emitExpr(ctx, node) {
1231
+ if (!node) return { k: "prim", prim: [10, ""] };
1232
+ switch (node.type) {
1233
+ case "Number":
1234
+ return { k: "prim", prim: [4, String(node.value)] };
1235
+ case "String":
1236
+ return { k: "prim", prim: [10, node.value] };
1237
+ case "Boolean":
1238
+ return { k: "prim", prim: [10, node.value ? "true" : "false"] };
1239
+ case "Identifier":
1240
+ return this.emitIdentifier(ctx, node);
1241
+ case "Call": {
1242
+ const result = this.emitCall(ctx, node, null);
1243
+ return { k: "block", id: result.id, kind: result.kind };
1244
+ }
1245
+ case "Binary":
1246
+ return this.emitBinary(ctx, node);
1247
+ case "Unary":
1248
+ return this.emitUnary(ctx, node);
1249
+ default:
1250
+ this.fail(`Unsupported expression ${node.type}`, node.loc);
1251
+ }
1252
+ return null;
1253
+ }
1254
+
1255
+ emitIdentifier(ctx, node) {
1256
+ if (node.segments.length > 1)
1257
+ this.fail(
1258
+ `Unexpected dotted name ${JSON.stringify(node.name)}`,
1259
+ node.loc,
1260
+ );
1261
+ const resolved = ctx.resolveValue(node.name);
1262
+ if (!resolved)
1263
+ this.fail(`Unknown variable ${JSON.stringify(node.name)}`, node.loc);
1264
+ if (resolved.kind === "param") {
1265
+ const id = this.addBlock(ctx, {
1266
+ opcode: resolved.param.boolean
1267
+ ? "argument_reporter_boolean"
1268
+ : "argument_reporter_string_number",
1269
+ fields: { VALUE: [resolved.param.name, null] },
1270
+ });
1271
+ return {
1272
+ k: "block",
1273
+ id,
1274
+ kind: resolved.param.boolean ? "boolean" : "reporter",
1275
+ };
1276
+ }
1277
+ if (resolved.kind === "var")
1278
+ return { k: "var", name: resolved.name, id: resolved.id };
1279
+ return { k: "list", name: resolved.name, id: resolved.id };
1280
+ }
1281
+
1282
+ emitBinary(ctx, node) {
1283
+ switch (node.op) {
1284
+ case "and":
1285
+ return {
1286
+ k: "block",
1287
+ id: this.emitBinaryBlock(
1288
+ ctx,
1289
+ "operators.and",
1290
+ null,
1291
+ node.left,
1292
+ node.right,
1293
+ ),
1294
+ kind: "boolean",
1295
+ };
1296
+ case "or":
1297
+ return {
1298
+ k: "block",
1299
+ id: this.emitBinaryBlock(
1300
+ ctx,
1301
+ "operators.or",
1302
+ null,
1303
+ node.left,
1304
+ node.right,
1305
+ ),
1306
+ kind: "boolean",
1307
+ };
1308
+ case "==":
1309
+ return {
1310
+ k: "block",
1311
+ id: this.emitBinaryBlock(
1312
+ ctx,
1313
+ "operators.equals",
1314
+ null,
1315
+ node.left,
1316
+ node.right,
1317
+ ),
1318
+ kind: "boolean",
1319
+ };
1320
+ case "<":
1321
+ return {
1322
+ k: "block",
1323
+ id: this.emitBinaryBlock(
1324
+ ctx,
1325
+ "operators.lt",
1326
+ null,
1327
+ node.left,
1328
+ node.right,
1329
+ ),
1330
+ kind: "boolean",
1331
+ };
1332
+ case ">":
1333
+ return {
1334
+ k: "block",
1335
+ id: this.emitBinaryBlock(
1336
+ ctx,
1337
+ "operators.gt",
1338
+ null,
1339
+ node.left,
1340
+ node.right,
1341
+ ),
1342
+ kind: "boolean",
1343
+ };
1344
+ case "!=":
1345
+ return this.emitNot(ctx, {
1346
+ k: "block",
1347
+ id: this.emitBinaryBlock(
1348
+ ctx,
1349
+ "operators.equals",
1350
+ null,
1351
+ node.left,
1352
+ node.right,
1353
+ ),
1354
+ });
1355
+ case "<=":
1356
+ return this.emitNot(ctx, {
1357
+ k: "block",
1358
+ id: this.emitBinaryBlock(
1359
+ ctx,
1360
+ "operators.gt",
1361
+ null,
1362
+ node.left,
1363
+ node.right,
1364
+ ),
1365
+ });
1366
+ case ">=":
1367
+ return this.emitNot(ctx, {
1368
+ k: "block",
1369
+ id: this.emitBinaryBlock(
1370
+ ctx,
1371
+ "operators.lt",
1372
+ null,
1373
+ node.left,
1374
+ node.right,
1375
+ ),
1376
+ });
1377
+ case "+":
1378
+ return {
1379
+ k: "block",
1380
+ id: this.emitBinaryBlock(
1381
+ ctx,
1382
+ "operators.add",
1383
+ null,
1384
+ node.left,
1385
+ node.right,
1386
+ ),
1387
+ kind: "reporter",
1388
+ };
1389
+ case "-":
1390
+ return {
1391
+ k: "block",
1392
+ id: this.emitBinaryBlock(
1393
+ ctx,
1394
+ "operators.subtract",
1395
+ null,
1396
+ node.left,
1397
+ node.right,
1398
+ ),
1399
+ kind: "reporter",
1400
+ };
1401
+ case "*":
1402
+ return {
1403
+ k: "block",
1404
+ id: this.emitBinaryBlock(
1405
+ ctx,
1406
+ "operators.multiply",
1407
+ null,
1408
+ node.left,
1409
+ node.right,
1410
+ ),
1411
+ kind: "reporter",
1412
+ };
1413
+ case "/":
1414
+ return {
1415
+ k: "block",
1416
+ id: this.emitBinaryBlock(
1417
+ ctx,
1418
+ "operators.divide",
1419
+ null,
1420
+ node.left,
1421
+ node.right,
1422
+ ),
1423
+ kind: "reporter",
1424
+ };
1425
+ case "%":
1426
+ return {
1427
+ k: "block",
1428
+ id: this.emitBinaryBlock(
1429
+ ctx,
1430
+ "operators.mod",
1431
+ null,
1432
+ node.left,
1433
+ node.right,
1434
+ ),
1435
+ kind: "reporter",
1436
+ };
1437
+ default:
1438
+ this.fail(`Unsupported operator ${JSON.stringify(node.op)}`, node.loc);
1439
+ }
1440
+ return null;
1441
+ }
1442
+
1443
+ /**
1444
+ * Emit a catalog binary/boolean block, returning its id.
1445
+ * @param {string} catalogName
1446
+ * @param {string|null} parentId
1447
+ * @param {object} left
1448
+ * @param {object} right
1449
+ */
1450
+ emitBinaryBlock(ctx, catalogName, parentId, left, right) {
1451
+ const entry = this.catalog[catalogName];
1452
+ if (!entry)
1453
+ this.fail(`Internal error: missing catalog entry ${catalogName}`);
1454
+ const inputNames = Object.keys(entry.inputs || {});
1455
+ const id = this.addBlock(ctx, { opcode: entry.opcode, parent: parentId });
1456
+ const exprs = [left, right];
1457
+ inputNames.forEach((name, i) => {
1458
+ const spec = entry.inputs[name];
1459
+ ctx.target.blocks[id].inputs[name] = this.buildInputForSpec(
1460
+ spec,
1461
+ exprs[i],
1462
+ id,
1463
+ );
1464
+ });
1465
+ return id;
1466
+ }
1467
+
1468
+ emitNot(ctx, desc) {
1469
+ const id = this.addBlock(ctx, { opcode: "operator_not" });
1470
+ ctx.target.blocks[id].inputs.OPERAND = [2, desc.id];
1471
+ ctx.target.blocks[desc.id].parent = id;
1472
+ return { k: "block", id, kind: "boolean" };
1473
+ }
1474
+
1475
+ emitUnary(ctx, node) {
1476
+ if (node.op === "not") {
1477
+ const operand = this.emitExpr(ctx, node.operand);
1478
+ if (operand.k === "block") {
1479
+ const id = this.addBlock(ctx, { opcode: "operator_not" });
1480
+ ctx.target.blocks[id].inputs.OPERAND = [2, operand.id];
1481
+ ctx.target.blocks[operand.id].parent = id;
1482
+ return { k: "block", id, kind: "boolean" };
1483
+ }
1484
+ const id = this.addBlock(ctx, { opcode: "operator_not" });
1485
+ ctx.target.blocks[id].inputs.OPERAND = this.booleanInput(
1486
+ ctx,
1487
+ node.operand,
1488
+ id,
1489
+ );
1490
+ return { k: "block", id, kind: "boolean" };
1491
+ }
1492
+ if (node.op === "-") {
1493
+ const id = this.addBlock(ctx, { opcode: "operator_subtract" });
1494
+ ctx.target.blocks[id].inputs.NUM1 = [1, [4, "0"]];
1495
+ ctx.target.blocks[id].inputs.NUM2 = this.buildInputForSpec(
1496
+ { type: "number" },
1497
+ node.operand,
1498
+ id,
1499
+ );
1500
+ return { k: "block", id, kind: "reporter" };
1501
+ }
1502
+ this.fail(
1503
+ `Unsupported unary operator ${JSON.stringify(node.op)}`,
1504
+ node.loc,
1505
+ );
1506
+ return null;
1507
+ }
1508
+
1509
+ // ------------------------------------------------------------ assembly
1510
+
1511
+ assembleProject(contexts) {
1512
+ const targets = [];
1513
+ const extensions = new Set();
1514
+
1515
+ for (const ctx of contexts) {
1516
+ const target = ctx.target;
1517
+ if (target.isStage) {
1518
+ // broadcasts live on the stage
1519
+ for (const [name, id] of this.broadcasts) target.broadcasts[id] = name;
1520
+ this.ensureStageAssets(target);
1521
+ const comment = configComment(this.projectConfig);
1522
+ if (comment) {
1523
+ const commentId = uid("c");
1524
+ target.comments[commentId] = {
1525
+ blockId: null,
1526
+ x: 50,
1527
+ y: 50,
1528
+ width: 350,
1529
+ height: 170,
1530
+ minimized: false,
1531
+ text: comment,
1532
+ };
1533
+ }
1534
+ } else {
1535
+ this.ensureSpriteAssets(target);
1536
+ }
1537
+
1538
+ for (const block of Object.values(target.blocks)) {
1539
+ const ext =
1540
+ this.opcodeExtensions.get(block.opcode) ||
1541
+ extensionForOpcode(block.opcode);
1542
+ if (ext) extensions.add(ext);
1543
+ }
1544
+ targets.push(this.serializeTarget(target));
1545
+ }
1546
+
1547
+ // Sprites with an explicit layer keep it; others are ordered by declaration.
1548
+ targets.forEach((target, index) => {
1549
+ if (
1550
+ !target.isStage &&
1551
+ (target.layerOrder === 0 || target.layerOrder == null)
1552
+ ) {
1553
+ target.layerOrder = index;
1554
+ }
1555
+ });
1556
+
1557
+ const project = {
1558
+ targets,
1559
+ monitors: [],
1560
+ extensions: Array.from(extensions).sort(),
1561
+ meta: { semver: "3.0.0", vm: "0.2.0", agent: "" },
1562
+ };
1563
+
1564
+ const urls = {};
1565
+ for (const id of Object.keys(this.extensionUrls)) {
1566
+ if (extensions.has(id)) urls[id] = this.extensionUrls[id];
1567
+ }
1568
+ // Include configured extensions even if no block references them yet.
1569
+ for (const id of Object.keys(this.extensionUrls)) {
1570
+ if (!project.extensions.includes(id)) project.extensions.push(id);
1571
+ urls[id] = this.extensionUrls[id];
1572
+ }
1573
+ project.extensions.sort();
1574
+ if (Object.keys(urls).length) project.extensionURLs = urls;
1575
+
1576
+ return project;
1577
+ }
1578
+
1579
+ ensureStageAssets(target) {
1580
+ if (target.costumes.length === 0) {
1581
+ const width = this.projectConfig.width || 480;
1582
+ const height = this.projectConfig.height || 360;
1583
+ const buffer = Buffer.from(blankSvg(width, height, true));
1584
+ const asset = this.registerAsset(buffer, "svg");
1585
+ target.costumes.push({
1586
+ name: "backdrop1",
1587
+ bitmapResolution: 1,
1588
+ dataFormat: "svg",
1589
+ assetId: asset.assetId,
1590
+ md5ext: asset.md5ext,
1591
+ rotationCenterX: width / 2,
1592
+ rotationCenterY: height / 2,
1593
+ });
1594
+ }
1595
+ }
1596
+
1597
+ ensureSpriteAssets(target) {
1598
+ if (target.costumes.length === 0) {
1599
+ const buffer = Buffer.from(blankSvg(100, 100, false));
1600
+ const asset = this.registerAsset(buffer, "svg");
1601
+ target.costumes.push({
1602
+ name: "costume1",
1603
+ bitmapResolution: 1,
1604
+ dataFormat: "svg",
1605
+ assetId: asset.assetId,
1606
+ md5ext: asset.md5ext,
1607
+ rotationCenterX: 50,
1608
+ rotationCenterY: 50,
1609
+ });
1610
+ }
1611
+ }
1612
+
1613
+ serializeTarget(target) {
1614
+ const base = {
1615
+ isStage: target.isStage,
1616
+ name: target.isStage ? "Stage" : target.name,
1617
+ variables: target.variables,
1618
+ lists: target.lists,
1619
+ broadcasts: target.broadcasts,
1620
+ blocks: target.blocks,
1621
+ comments: target.comments,
1622
+ currentCostume: 0,
1623
+ costumes: target.costumes,
1624
+ sounds: target.sounds,
1625
+ volume: target.volume,
1626
+ layerOrder: target.layerOrder,
1627
+ };
1628
+
1629
+ if (target.isStage) {
1630
+ return {
1631
+ ...base,
1632
+ tempo: 60,
1633
+ videoTransparency: 50,
1634
+ videoState: "on",
1635
+ textToSpeechLanguage: null,
1636
+ };
1637
+ }
1638
+ return {
1639
+ ...base,
1640
+ visible: target.visible,
1641
+ x: target.x,
1642
+ y: target.y,
1643
+ size: target.size,
1644
+ direction: target.direction,
1645
+ draggable: target.draggable,
1646
+ rotationStyle: target.rotationStyle,
1647
+ };
1648
+ }
1649
+ }
1650
+
1651
+ /**
1652
+ * Convenience helper: compile an AST (or source string) into project JSON.
1653
+ * @param {object} ast
1654
+ * @param {object} options
1655
+ */
1656
+ export function compile(ast, options) {
1657
+ return new Compiler(options).compile(ast);
1658
+ }