@triflare/fx 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/fx.js CHANGED
@@ -217,6 +217,8 @@ async function cmdBuild(args) {
217
217
  extensionUrls,
218
218
  useConfigFile: !args.flags["no-config"],
219
219
  });
220
+ for (const warning of result.warnings)
221
+ process.stderr.write(`warning: ${warning}\n`);
220
222
 
221
223
  const explicit = args.flags.o || args.flags.output;
222
224
  const output =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflare/fx",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Code PenguinMod projects with text.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -8,6 +8,10 @@
8
8
  "bin": {
9
9
  "fx": "bin/fx.js"
10
10
  },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/triflare/fx.git"
14
+ },
11
15
  "exports": {
12
16
  ".": "./src/index.js",
13
17
  "./catalog": "./src/catalog.js",
package/product.yml CHANGED
@@ -1,7 +1,7 @@
1
1
  # Identity
2
2
  name: "Triflare Fx"
3
3
  command: "fx"
4
- version: "1.0.1"
4
+ version: "1.0.3"
5
5
  tagline: "Code PenguinMod projects with text."
6
6
  author: "Triflare"
7
7
  homepage: "https://github.com/triflare/fx"
package/src/build.js CHANGED
@@ -98,7 +98,7 @@ function resolveSources(options) {
98
98
 
99
99
  /**
100
100
  * @param {BuildOptions} [options]
101
- * @returns {{project: object, assets: Map<string, Buffer>, sb3: Buffer, config: object, fx: object, ast: object, files: string[]}}
101
+ * @returns {{project: object, assets: Map<string, Buffer>, sb3: Buffer, config: object, fx: object, ast: object, files: string[], warnings: string[]}}
102
102
  */
103
103
  export function build(options = {}) {
104
104
  const { dir, entries } = resolveSources(options);
@@ -155,6 +155,7 @@ export function build(options = {}) {
155
155
  fx: fxConfig,
156
156
  ast,
157
157
  files: entries.map((e) => e.file),
158
+ warnings: compiler.warnings,
158
159
  };
159
160
  }
160
161
 
package/src/catalog.js CHANGED
@@ -1,21 +1,39 @@
1
1
  /**
2
2
  * Triflare Fx block catalog.
3
3
  *
4
- * This module is the single source of truth for how a Triflare Fx
5
- * `category.function(...)` call maps onto a Scratch/PenguinMod opcode: its shape
6
- * (command/reporter/boolean/hat), its inputs, its dropdown fields and any
7
- * mutation it requires. The compiler, the decompiler and the editor tooling all
8
- * read from here instead of hard-coding opcode tables.
4
+ * The single source of truth for how a `category.function(...)` call maps onto
5
+ * a Scratch/PenguinMod opcode: shape, inputs, dropdown fields and mutations.
6
+ * The compiler, the decompiler and the editor tooling all read from here.
9
7
  *
10
- * Input descriptors:
11
- * {type: 'number'|'positive'|'whole'|'integer'|'angle'|'string'|'color'
12
- * |'boolean'|'substack'|'broadcast'|'variable'|'list'}
13
- * {type: 'menu', menu: <shadow opcode>, field: <field name>, default: <value>}
8
+ * Entry conventions:
9
+ * - `kind` is a command unless the entry says otherwise, so only reporters,
10
+ * booleans and hats spell it out (see the default applied below).
11
+ * - `inputs` maps an input name to one of the shared descriptors below.
12
+ * - `fields` maps a dropdown field name to its default value.
13
+ * - `args` describes a hat's parameters (`field` or `input`).
14
+ * - `extension` names the providing extension for non-core opcodes.
15
+ * - `dynamicNext` / `stopBlock` flag blocks with special stack behaviour.
14
16
  *
15
- * Field descriptors are plain objects providing the default dropdown value.
17
+ * Input descriptors:
18
+ * NUMBER, STRING, BOOLEAN, ANGLE, COLOR, INTEGER, POSITIVE, WHOLE, SUBSTACK
19
+ * and BROADCAST are shared `{type}` descriptors;
20
+ * `{type: 'menu', menu, field, default}` wraps a dropdown shadow block;
21
+ * `{type: 'variable'|'list'}` takes a variable/list reference.
16
22
  */
17
23
 
18
- // Common reusable menu descriptors -----------------------------------------
24
+ // Reusable input descriptors -----------------------------------------------
25
+ const NUMBER = { type: "number" };
26
+ const STRING = { type: "string" };
27
+ const BOOLEAN = { type: "boolean" };
28
+ const ANGLE = { type: "angle" };
29
+ const COLOR = { type: "color" };
30
+ const INTEGER = { type: "integer" };
31
+ const POSITIVE = { type: "positive" };
32
+ const WHOLE = { type: "whole" };
33
+ const SUBSTACK = { type: "substack" };
34
+ const BROADCAST = { type: "broadcast" };
35
+
36
+ // Reusable dropdown (shadow menu) descriptors -------------------------------
19
37
  const GOTO_MENU = {
20
38
  type: "menu",
21
39
  menu: "motion_goto_menu",
@@ -85,82 +103,67 @@ const OF_MENU = {
85
103
  default: "_stage_",
86
104
  };
87
105
 
88
- /** @type {Record<string, object>} */
89
- export const CATALOG = {
106
+ const CORE_CATALOG = {
90
107
  // ---------------------------------------------------------------- motion
91
108
  "motion.moveSteps": {
92
109
  opcode: "motion_movesteps",
93
- kind: "command",
94
- inputs: { STEPS: { type: "number" } },
110
+ inputs: { STEPS: NUMBER },
95
111
  },
96
112
  "motion.turnRight": {
97
113
  opcode: "motion_turnright",
98
- kind: "command",
99
- inputs: { DEGREES: { type: "number" } },
114
+ inputs: { DEGREES: NUMBER },
100
115
  },
101
116
  "motion.turnLeft": {
102
117
  opcode: "motion_turnleft",
103
- kind: "command",
104
- inputs: { DEGREES: { type: "number" } },
118
+ inputs: { DEGREES: NUMBER },
105
119
  },
106
120
  "motion.goToXY": {
107
121
  opcode: "motion_gotoxy",
108
- kind: "command",
109
- inputs: { X: { type: "number" }, Y: { type: "number" } },
122
+ inputs: { X: NUMBER, Y: NUMBER },
110
123
  },
111
124
  "motion.goTo": {
112
125
  opcode: "motion_goto",
113
- kind: "command",
114
126
  inputs: { TO: GOTO_MENU },
115
127
  },
116
128
  "motion.glideToXY": {
117
129
  opcode: "motion_glidesecstoxy",
118
- kind: "command",
119
130
  inputs: {
120
- SECS: { type: "number" },
121
- X: { type: "number" },
122
- Y: { type: "number" },
131
+ SECS: NUMBER,
132
+ X: NUMBER,
133
+ Y: NUMBER,
123
134
  },
124
135
  },
125
136
  "motion.glideTo": {
126
137
  opcode: "motion_glideto",
127
- kind: "command",
128
- inputs: { SECS: { type: "number" }, TO: GLIDE_MENU },
138
+ inputs: { SECS: NUMBER, TO: GLIDE_MENU },
129
139
  },
130
140
  "motion.pointInDirection": {
131
141
  opcode: "motion_pointindirection",
132
- kind: "command",
133
- inputs: { DIRECTION: { type: "angle" } },
142
+ inputs: { DIRECTION: ANGLE },
134
143
  },
135
144
  "motion.pointTowards": {
136
145
  opcode: "motion_pointtowards",
137
- kind: "command",
138
146
  inputs: { TOWARDS: POINT_MENU },
139
147
  },
140
148
  "motion.changeX": {
141
149
  opcode: "motion_changexby",
142
- kind: "command",
143
- inputs: { DX: { type: "number" } },
150
+ inputs: { DX: NUMBER },
144
151
  },
145
152
  "motion.setX": {
146
153
  opcode: "motion_setx",
147
- kind: "command",
148
- inputs: { X: { type: "number" } },
154
+ inputs: { X: NUMBER },
149
155
  },
150
156
  "motion.changeY": {
151
157
  opcode: "motion_changeyby",
152
- kind: "command",
153
- inputs: { DY: { type: "number" } },
158
+ inputs: { DY: NUMBER },
154
159
  },
155
160
  "motion.setY": {
156
161
  opcode: "motion_sety",
157
- kind: "command",
158
- inputs: { Y: { type: "number" } },
162
+ inputs: { Y: NUMBER },
159
163
  },
160
- "motion.ifOnEdgeBounce": { opcode: "motion_ifonedgebounce", kind: "command" },
164
+ "motion.ifOnEdgeBounce": { opcode: "motion_ifonedgebounce" },
161
165
  "motion.setRotationStyle": {
162
166
  opcode: "motion_setrotationstyle",
163
- kind: "command",
164
167
  fields: { STYLE: "all around" },
165
168
  },
166
169
  "motion.xPosition": { opcode: "motion_xposition", kind: "reporter" },
@@ -170,79 +173,65 @@ export const CATALOG = {
170
173
  // ----------------------------------------------------------------- looks
171
174
  "looks.say": {
172
175
  opcode: "looks_say",
173
- kind: "command",
174
- inputs: { MESSAGE: { type: "string" } },
176
+ inputs: { MESSAGE: STRING },
175
177
  },
176
178
  "looks.sayForSecs": {
177
179
  opcode: "looks_sayforsecs",
178
- kind: "command",
179
- inputs: { MESSAGE: { type: "string" }, SECS: { type: "number" } },
180
+ inputs: { MESSAGE: STRING, SECS: NUMBER },
180
181
  },
181
182
  "looks.think": {
182
183
  opcode: "looks_think",
183
- kind: "command",
184
- inputs: { MESSAGE: { type: "string" } },
184
+ inputs: { MESSAGE: STRING },
185
185
  },
186
186
  "looks.thinkForSecs": {
187
187
  opcode: "looks_thinkforsecs",
188
- kind: "command",
189
- inputs: { MESSAGE: { type: "string" }, SECS: { type: "number" } },
188
+ inputs: { MESSAGE: STRING, SECS: NUMBER },
190
189
  },
191
190
  "looks.switchCostume": {
192
191
  opcode: "looks_switchcostumeto",
193
- kind: "command",
194
192
  inputs: { COSTUME: COSTUME_MENU },
195
193
  },
196
- "looks.nextCostume": { opcode: "looks_nextcostume", kind: "command" },
194
+ "looks.nextCostume": { opcode: "looks_nextcostume" },
197
195
  "looks.switchBackdrop": {
198
196
  opcode: "looks_switchbackdropto",
199
- kind: "command",
200
197
  inputs: { BACKDROP: BACKDROP_MENU },
201
198
  },
202
199
  "looks.switchBackdropAndWait": {
203
200
  opcode: "looks_switchbackdroptoandwait",
204
- kind: "command",
205
201
  inputs: { BACKDROP: BACKDROP_MENU },
206
202
  },
207
- "looks.nextBackdrop": { opcode: "looks_nextbackdrop", kind: "command" },
203
+ "looks.nextBackdrop": { opcode: "looks_nextbackdrop" },
208
204
  "looks.changeSize": {
209
205
  opcode: "looks_changesizeby",
210
- kind: "command",
211
- inputs: { CHANGE: { type: "number" } },
206
+ inputs: { CHANGE: NUMBER },
212
207
  },
213
208
  "looks.setSize": {
214
209
  opcode: "looks_setsizeto",
215
- kind: "command",
216
- inputs: { SIZE: { type: "number" } },
210
+ inputs: { SIZE: NUMBER },
217
211
  },
218
212
  "looks.changeEffect": {
219
213
  opcode: "looks_changeeffectby",
220
- kind: "command",
221
214
  fields: { EFFECT: "color" },
222
- inputs: { CHANGE: { type: "number" } },
215
+ inputs: { CHANGE: NUMBER },
223
216
  },
224
217
  "looks.setEffect": {
225
218
  opcode: "looks_seteffectto",
226
- kind: "command",
227
219
  fields: { EFFECT: "color" },
228
- inputs: { VALUE: { type: "number" } },
220
+ inputs: { VALUE: NUMBER },
229
221
  },
230
222
  "looks.clearEffects": {
231
223
  opcode: "looks_cleargraphiceffects",
232
- kind: "command",
233
224
  },
234
- "looks.show": { opcode: "looks_show", kind: "command" },
235
- "looks.hide": { opcode: "looks_hide", kind: "command" },
225
+ "looks.show": { opcode: "looks_show" },
226
+ "looks.hide": { opcode: "looks_hide" },
236
227
  "looks.goToFrontLayer": {
237
228
  opcode: "looks_gotofrontback",
238
- kind: "command",
239
229
  fields: { FRONT_BACK: "front" },
240
230
  },
241
231
  "looks.goLayers": {
242
232
  opcode: "looks_goforwardbackwardlayers",
243
- kind: "command",
244
233
  fields: { FORWARD_BACKWARD: "forward" },
245
- inputs: { NUM: { type: "integer" } },
234
+ inputs: { NUM: INTEGER },
246
235
  },
247
236
  "looks.costumeNumber": {
248
237
  opcode: "looks_costumenumbername",
@@ -259,37 +248,31 @@ export const CATALOG = {
259
248
  // ----------------------------------------------------------------- sound
260
249
  "sound.play": {
261
250
  opcode: "sound_play",
262
- kind: "command",
263
251
  inputs: { SOUND_MENU: SOUND_MENU },
264
252
  },
265
253
  "sound.playUntilDone": {
266
254
  opcode: "sound_playuntildone",
267
- kind: "command",
268
255
  inputs: { SOUND_MENU: SOUND_MENU },
269
256
  },
270
- "sound.stopAllSounds": { opcode: "sound_stopallsounds", kind: "command" },
257
+ "sound.stopAllSounds": { opcode: "sound_stopallsounds" },
271
258
  "sound.changeEffect": {
272
259
  opcode: "sound_changeeffectby",
273
- kind: "command",
274
260
  fields: { EFFECT: "pitch" },
275
- inputs: { VALUE: { type: "number" } },
261
+ inputs: { VALUE: NUMBER },
276
262
  },
277
263
  "sound.setEffect": {
278
264
  opcode: "sound_seteffectto",
279
- kind: "command",
280
265
  fields: { EFFECT: "pitch" },
281
- inputs: { VALUE: { type: "number" } },
266
+ inputs: { VALUE: NUMBER },
282
267
  },
283
- "sound.clearEffects": { opcode: "sound_cleareffects", kind: "command" },
268
+ "sound.clearEffects": { opcode: "sound_cleareffects" },
284
269
  "sound.changeVolume": {
285
270
  opcode: "sound_changevolumeby",
286
- kind: "command",
287
- inputs: { VOLUME: { type: "number" } },
271
+ inputs: { VOLUME: NUMBER },
288
272
  },
289
273
  "sound.setVolume": {
290
274
  opcode: "sound_setvolumeto",
291
- kind: "command",
292
- inputs: { VOLUME: { type: "number" } },
275
+ inputs: { VOLUME: NUMBER },
293
276
  },
294
277
  "sound.volume": { opcode: "sound_volume", kind: "reporter" },
295
278
 
@@ -302,12 +285,12 @@ export const CATALOG = {
302
285
  "sensing.touchingColor": {
303
286
  opcode: "sensing_touchingcolor",
304
287
  kind: "boolean",
305
- inputs: { COLOR: { type: "color" } },
288
+ inputs: { COLOR: COLOR },
306
289
  },
307
290
  "sensing.colorIsTouchingColor": {
308
291
  opcode: "sensing_coloristouchingcolor",
309
292
  kind: "boolean",
310
- inputs: { COLOR: { type: "color" }, COLOR2: { type: "color" } },
293
+ inputs: { COLOR: COLOR, COLOR2: COLOR },
311
294
  },
312
295
  "sensing.distanceTo": {
313
296
  opcode: "sensing_distanceto",
@@ -316,8 +299,7 @@ export const CATALOG = {
316
299
  },
317
300
  "sensing.askAndWait": {
318
301
  opcode: "sensing_askandwait",
319
- kind: "command",
320
- inputs: { QUESTION: { type: "string" } },
302
+ inputs: { QUESTION: STRING },
321
303
  },
322
304
  "sensing.answer": { opcode: "sensing_answer", kind: "reporter" },
323
305
  "sensing.keyPressed": {
@@ -330,7 +312,7 @@ export const CATALOG = {
330
312
  "sensing.mouseY": { opcode: "sensing_mousey", kind: "reporter" },
331
313
  "sensing.loudness": { opcode: "sensing_loudness", kind: "reporter" },
332
314
  "sensing.timer": { opcode: "sensing_timer", kind: "reporter" },
333
- "sensing.resetTimer": { opcode: "sensing_resettimer", kind: "command" },
315
+ "sensing.resetTimer": { opcode: "sensing_resettimer" },
334
316
  "sensing.propertyOf": {
335
317
  opcode: "sensing_of",
336
318
  kind: "reporter",
@@ -352,110 +334,107 @@ export const CATALOG = {
352
334
  "operators.add": {
353
335
  opcode: "operator_add",
354
336
  kind: "reporter",
355
- inputs: { NUM1: { type: "number" }, NUM2: { type: "number" } },
337
+ inputs: { NUM1: NUMBER, NUM2: NUMBER },
356
338
  },
357
339
  "operators.subtract": {
358
340
  opcode: "operator_subtract",
359
341
  kind: "reporter",
360
- inputs: { NUM1: { type: "number" }, NUM2: { type: "number" } },
342
+ inputs: { NUM1: NUMBER, NUM2: NUMBER },
361
343
  },
362
344
  "operators.multiply": {
363
345
  opcode: "operator_multiply",
364
346
  kind: "reporter",
365
- inputs: { NUM1: { type: "number" }, NUM2: { type: "number" } },
347
+ inputs: { NUM1: NUMBER, NUM2: NUMBER },
366
348
  },
367
349
  "operators.divide": {
368
350
  opcode: "operator_divide",
369
351
  kind: "reporter",
370
- inputs: { NUM1: { type: "number" }, NUM2: { type: "number" } },
352
+ inputs: { NUM1: NUMBER, NUM2: NUMBER },
371
353
  },
372
354
  "operators.random": {
373
355
  opcode: "operator_random",
374
356
  kind: "reporter",
375
- inputs: { FROM: { type: "number" }, TO: { type: "number" } },
357
+ inputs: { FROM: NUMBER, TO: NUMBER },
376
358
  },
377
359
  "operators.gt": {
378
360
  opcode: "operator_gt",
379
361
  kind: "boolean",
380
- inputs: { OPERAND1: { type: "string" }, OPERAND2: { type: "string" } },
362
+ inputs: { OPERAND1: STRING, OPERAND2: STRING },
381
363
  },
382
364
  "operators.lt": {
383
365
  opcode: "operator_lt",
384
366
  kind: "boolean",
385
- inputs: { OPERAND1: { type: "string" }, OPERAND2: { type: "string" } },
367
+ inputs: { OPERAND1: STRING, OPERAND2: STRING },
386
368
  },
387
369
  "operators.equals": {
388
370
  opcode: "operator_equals",
389
371
  kind: "boolean",
390
- inputs: { OPERAND1: { type: "string" }, OPERAND2: { type: "string" } },
372
+ inputs: { OPERAND1: STRING, OPERAND2: STRING },
391
373
  },
392
374
  "operators.and": {
393
375
  opcode: "operator_and",
394
376
  kind: "boolean",
395
- inputs: { OPERAND1: { type: "boolean" }, OPERAND2: { type: "boolean" } },
377
+ inputs: { OPERAND1: BOOLEAN, OPERAND2: BOOLEAN },
396
378
  },
397
379
  "operators.or": {
398
380
  opcode: "operator_or",
399
381
  kind: "boolean",
400
- inputs: { OPERAND1: { type: "boolean" }, OPERAND2: { type: "boolean" } },
382
+ inputs: { OPERAND1: BOOLEAN, OPERAND2: BOOLEAN },
401
383
  },
402
384
  "operators.not": {
403
385
  opcode: "operator_not",
404
386
  kind: "boolean",
405
- inputs: { OPERAND: { type: "boolean" } },
387
+ inputs: { OPERAND: BOOLEAN },
406
388
  },
407
389
  "operators.join": {
408
390
  opcode: "operator_join",
409
391
  kind: "reporter",
410
- inputs: { STRING1: { type: "string" }, STRING2: { type: "string" } },
392
+ inputs: { STRING1: STRING, STRING2: STRING },
411
393
  },
412
394
  "operators.letterOf": {
413
395
  opcode: "operator_letter_of",
414
396
  kind: "reporter",
415
- inputs: { LETTER: { type: "number" }, STRING: { type: "string" } },
397
+ inputs: { LETTER: NUMBER, STRING: STRING },
416
398
  },
417
399
  "operators.length": {
418
400
  opcode: "operator_length",
419
401
  kind: "reporter",
420
- inputs: { STRING: { type: "string" } },
402
+ inputs: { STRING: STRING },
421
403
  },
422
404
  "operators.contains": {
423
405
  opcode: "operator_contains",
424
406
  kind: "boolean",
425
- inputs: { STRING1: { type: "string" }, STRING2: { type: "string" } },
407
+ inputs: { STRING1: STRING, STRING2: STRING },
426
408
  },
427
409
  "operators.mod": {
428
410
  opcode: "operator_mod",
429
411
  kind: "reporter",
430
- inputs: { NUM1: { type: "number" }, NUM2: { type: "number" } },
412
+ inputs: { NUM1: NUMBER, NUM2: NUMBER },
431
413
  },
432
414
  "operators.round": {
433
415
  opcode: "operator_round",
434
416
  kind: "reporter",
435
- inputs: { NUM: { type: "number" } },
417
+ inputs: { NUM: NUMBER },
436
418
  },
437
419
  "operators.math": {
438
420
  opcode: "operator_mathop",
439
421
  kind: "reporter",
440
422
  fields: { OPERATOR: "abs" },
441
- inputs: { NUM: { type: "number" } },
423
+ inputs: { NUM: NUMBER },
442
424
  },
443
425
 
444
426
  // --------------------------------------------------------------- control
445
427
  "control.wait": {
446
428
  opcode: "control_wait",
447
- kind: "command",
448
- inputs: { DURATION: { type: "positive" } },
429
+ inputs: { DURATION: POSITIVE },
449
430
  },
450
431
  "control.repeat": {
451
432
  opcode: "control_repeat",
452
- kind: "command",
453
- inputs: { TIMES: { type: "whole" }, SUBSTACK: { type: "substack" } },
433
+ inputs: { TIMES: WHOLE, SUBSTACK: SUBSTACK },
454
434
  },
455
435
  "control.forever": {
456
436
  opcode: "control_forever",
457
- kind: "command",
458
- inputs: { SUBSTACK: { type: "substack" } },
437
+ inputs: { SUBSTACK: SUBSTACK },
459
438
  // PenguinMod builds this loop without a next-statement connection and only
460
439
  // creates one from the block's mutation (`hasbreak` — the nub the editor
461
440
  // adds by itself once a block sits below the loop). Blocks stacked under a
@@ -466,54 +445,45 @@ export const CATALOG = {
466
445
  },
467
446
  "control.if": {
468
447
  opcode: "control_if",
469
- kind: "command",
470
- inputs: { CONDITION: { type: "boolean" }, SUBSTACK: { type: "substack" } },
448
+ inputs: { CONDITION: BOOLEAN, SUBSTACK: SUBSTACK },
471
449
  },
472
450
  "control.ifElse": {
473
451
  opcode: "control_if_else",
474
- kind: "command",
475
452
  inputs: {
476
- CONDITION: { type: "boolean" },
477
- SUBSTACK: { type: "substack" },
478
- SUBSTACK2: { type: "substack" },
453
+ CONDITION: BOOLEAN,
454
+ SUBSTACK: SUBSTACK,
455
+ SUBSTACK2: SUBSTACK,
479
456
  },
480
457
  },
481
458
  "control.waitUntil": {
482
459
  opcode: "control_wait_until",
483
- kind: "command",
484
- inputs: { CONDITION: { type: "boolean" } },
460
+ inputs: { CONDITION: BOOLEAN },
485
461
  },
486
462
  "control.repeatUntil": {
487
463
  opcode: "control_repeat_until",
488
- kind: "command",
489
- inputs: { CONDITION: { type: "boolean" }, SUBSTACK: { type: "substack" } },
464
+ inputs: { CONDITION: BOOLEAN, SUBSTACK: SUBSTACK },
490
465
  },
491
466
  "control.stop": {
492
467
  opcode: "control_stop",
493
- kind: "command",
494
468
  fields: { STOP_OPTION: "all" },
495
469
  stopBlock: true,
496
470
  },
497
471
  "control.clone": {
498
472
  opcode: "control_create_clone_of",
499
- kind: "command",
500
473
  inputs: { CLONE_OPTION: CLONE_MENU },
501
474
  },
502
475
  "control.deleteThisClone": {
503
476
  opcode: "control_delete_this_clone",
504
- kind: "command",
505
477
  },
506
478
 
507
479
  // ---------------------------------------------------------------- events
508
480
  "events.broadcast": {
509
481
  opcode: "event_broadcast",
510
- kind: "command",
511
- inputs: { BROADCAST_INPUT: { type: "broadcast" } },
482
+ inputs: { BROADCAST_INPUT: BROADCAST },
512
483
  },
513
484
  "events.broadcastAndWait": {
514
485
  opcode: "event_broadcastandwait",
515
- kind: "command",
516
- inputs: { BROADCAST_INPUT: { type: "broadcast" } },
486
+ inputs: { BROADCAST_INPUT: BROADCAST },
517
487
  },
518
488
 
519
489
  // --------------------------------------------------------------- hats
@@ -574,26 +544,23 @@ export const CATALOG = {
574
544
  },
575
545
 
576
546
  // ------------------------------------------------------------------ pen
577
- "pen.clear": { opcode: "pen_clear", kind: "command", extension: "pen" },
578
- "pen.stamp": { opcode: "pen_stamp", kind: "command", extension: "pen" },
579
- "pen.penDown": { opcode: "pen_penDown", kind: "command", extension: "pen" },
580
- "pen.penUp": { opcode: "pen_penUp", kind: "command", extension: "pen" },
547
+ "pen.clear": { opcode: "pen_clear", extension: "pen" },
548
+ "pen.stamp": { opcode: "pen_stamp", extension: "pen" },
549
+ "pen.penDown": { opcode: "pen_penDown", extension: "pen" },
550
+ "pen.penUp": { opcode: "pen_penUp", extension: "pen" },
581
551
  "pen.setColor": {
582
552
  opcode: "pen_setPenColorToColor",
583
- kind: "command",
584
- inputs: { COLOR: { type: "color" } },
553
+ inputs: { COLOR: COLOR },
585
554
  extension: "pen",
586
555
  },
587
556
  "pen.changeSize": {
588
557
  opcode: "pen_changePenSizeBy",
589
- kind: "command",
590
- inputs: { SIZE: { type: "number" } },
558
+ inputs: { SIZE: NUMBER },
591
559
  extension: "pen",
592
560
  },
593
561
  "pen.setSize": {
594
562
  opcode: "pen_setPenSizeTo",
595
- kind: "command",
596
- inputs: { SIZE: { type: "number" } },
563
+ inputs: { SIZE: NUMBER },
597
564
  extension: "pen",
598
565
  },
599
566
  // COLOR_PARAM is an input whose shadow is `pen_menu_colorParam`, not a
@@ -602,7 +569,6 @@ export const CATALOG = {
602
569
  // wrapping a shadow menu block (field name `colorParam`).
603
570
  "pen.changeColorParam": {
604
571
  opcode: "pen_changePenColorParamBy",
605
- kind: "command",
606
572
  inputs: {
607
573
  COLOR_PARAM: {
608
574
  type: "menu",
@@ -610,13 +576,12 @@ export const CATALOG = {
610
576
  field: "colorParam",
611
577
  default: "color",
612
578
  },
613
- VALUE: { type: "number" },
579
+ VALUE: NUMBER,
614
580
  },
615
581
  extension: "pen",
616
582
  },
617
583
  "pen.setColorParam": {
618
584
  opcode: "pen_setPenColorParamTo",
619
- kind: "command",
620
585
  inputs: {
621
586
  COLOR_PARAM: {
622
587
  type: "menu",
@@ -624,7 +589,7 @@ export const CATALOG = {
624
589
  field: "colorParam",
625
590
  default: "color",
626
591
  },
627
- VALUE: { type: "number" },
592
+ VALUE: NUMBER,
628
593
  },
629
594
  extension: "pen",
630
595
  },
@@ -647,6 +612,18 @@ export const CATALOG = {
647
612
  },
648
613
  };
649
614
 
615
+ /**
616
+ * Catalog entries with the default `kind` filled in: everything is a command
617
+ * unless the table above said otherwise.
618
+ * @type {Record<string, object>}
619
+ */
620
+ export const CATALOG = Object.fromEntries(
621
+ Object.entries(CORE_CATALOG).map(([name, entry]) => [
622
+ name,
623
+ { kind: "command", ...entry },
624
+ ]),
625
+ );
626
+
650
627
  /**
651
628
  * Native Scratch variable/list mutation opcodes used when a list method is
652
629
  * called (`items.push(x)` etc.).
package/src/compiler.js CHANGED
@@ -25,6 +25,11 @@ const HAT_ALIASES = {
25
25
  timerGreaterThan: "events.timerGreaterThan",
26
26
  };
27
27
 
28
+ // `dataFormat` values Scratch 3 can load (see the `VM.Costume` / `VM.Sound`
29
+ // types). Anything else is still written through, but may not load.
30
+ const COSTUME_FORMATS = new Set(["svg", "png", "jpg"]);
31
+ const SOUND_FORMATS = new Set(["wav", "mp3"]);
32
+
28
33
  const NUMBER_TYPES = {
29
34
  number: 4,
30
35
  positive: 5,
@@ -133,6 +138,8 @@ export class Compiler {
133
138
  this.catalog = catalog ? { ...CATALOG, ...catalog } : CATALOG;
134
139
  this.extensionUrls = extensionUrls;
135
140
  this.file = file;
141
+ /** @type {string[]} Non-fatal problems worth surfacing to the user. */
142
+ this.warnings = [];
136
143
 
137
144
  // opcode -> extension id, derived from the (merged) catalog. This lets
138
145
  // synthesized extension blocks declare an id that isn't inferable from
@@ -463,12 +470,20 @@ export class Compiler {
463
470
  applyCostumes(ctx, property) {
464
471
  for (const entry of property.value) {
465
472
  const format = detectFormat(entry.value);
473
+ if (!COSTUME_FORMATS.has(format))
474
+ this.warnings.push(
475
+ `costume ${JSON.stringify(entry.key)} uses dataFormat ` +
476
+ `"${format}"; Scratch 3 costumes are svg, png or jpg`,
477
+ );
466
478
  const buffer = this.readAsset(entry.value, entry.loc);
467
479
  const { width, height } = imageSize(buffer, format);
468
480
  const asset = this.registerAsset(buffer, format);
469
481
  ctx.target.costumes.push({
470
482
  name: entry.key,
471
- bitmapResolution: 1,
483
+ // Scratch 3 saves bitmap costumes at 2x; the VM converts a 1 to 2 on
484
+ // load anyway (`import/load-costume.js`), and the renderer's
485
+ // BitmapSkin defaults to 2. SVGs are always 1.
486
+ bitmapResolution: format === "svg" ? 1 : 2,
472
487
  dataFormat: format,
473
488
  assetId: asset.assetId,
474
489
  md5ext: asset.md5ext,
@@ -481,6 +496,11 @@ export class Compiler {
481
496
  applySounds(ctx, property) {
482
497
  for (const entry of property.value) {
483
498
  const format = detectFormat(entry.value);
499
+ if (!SOUND_FORMATS.has(format))
500
+ this.warnings.push(
501
+ `sound ${JSON.stringify(entry.key)} uses dataFormat ` +
502
+ `"${format}"; Scratch 3 sounds are wav or mp3`,
503
+ );
484
504
  const buffer = this.readAsset(entry.value, entry.loc);
485
505
  const info =
486
506
  format === "wav" ? wavInfo(buffer) : { rate: 48000, sampleCount: 0 };
@@ -577,7 +597,7 @@ export class Compiler {
577
597
  }
578
598
  } else if (spec.input) {
579
599
  inputs[spec.input] = this.buildInputForSpec(
580
- { type: spec.type || "any" },
600
+ { type: spec.type || "any", default: spec.default },
581
601
  argExpr,
582
602
  null,
583
603
  );
@@ -1110,7 +1130,16 @@ export class Compiler {
1110
1130
  }
1111
1131
 
1112
1132
  defaultPrim(spec) {
1113
- switch (spec.type) {
1133
+ const prim = this.defaultPrimForType(spec.type);
1134
+ // Extensions declare the value a call falls back to when it omits an
1135
+ // argument; built-in catalog entries never set `default`.
1136
+ if (spec.default !== undefined && spec.default !== null)
1137
+ prim[1] = String(spec.default);
1138
+ return prim;
1139
+ }
1140
+
1141
+ defaultPrimForType(type) {
1142
+ switch (type) {
1114
1143
  case "number":
1115
1144
  return [4, "0"];
1116
1145
  case "positive":
package/src/decompiler.js CHANGED
@@ -376,13 +376,23 @@ class Decompiler {
376
376
  const proto = this.target.blocks[protoId];
377
377
  const mutation = (proto && proto.mutation) || {};
378
378
  const proccode = mutation.proccode || "procedure";
379
+ const ids = mutation.argumentids ? JSON.parse(mutation.argumentids) : [];
379
380
  const names = mutation.argumentnames
380
381
  ? JSON.parse(mutation.argumentnames)
381
382
  : [];
383
+ // Parameter types live on the prototype's argument reporter shadows, not
384
+ // in the mutation, so read them back or `loud: boolean` decompiles to a
385
+ // string parameter.
386
+ const params = names.map((name, index) => {
387
+ const reporter =
388
+ proto && this.target.blocks[firstOf(proto.inputs[ids[index]])];
389
+ return reporter && reporter.opcode === "argument_reporter_boolean"
390
+ ? `${name}: boolean`
391
+ : name;
392
+ });
382
393
  const baseName = proccode.split(" ")[0];
383
394
  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}`;
395
+ return `${returning}function ${baseName}(${params.join(", ")}) {\n${this.renderStack(defBlock.next, 1)}\n}`;
386
396
  }
387
397
 
388
398
  decompileTarget(target, indent = 0) {
package/src/dev.js CHANGED
@@ -55,6 +55,239 @@ function escapeHtml(text) {
55
55
  .replaceAll('"', "&quot;");
56
56
  }
57
57
 
58
+ /**
59
+ * Elements whose content the HTML tokenizer treats as text rather than markup.
60
+ * `noscript` is included because this page always runs scripts, which is
61
+ * exactly when the parser stops tokenizing its content.
62
+ */
63
+ const RAW_TEXT_TAGS = new Set([
64
+ "script",
65
+ "style",
66
+ "title",
67
+ "textarea",
68
+ "iframe",
69
+ "noembed",
70
+ "noframes",
71
+ "xmp",
72
+ "noscript",
73
+ ]);
74
+
75
+ /**
76
+ * Elements whose contents are parsed into a separate fragment: a `</body>`
77
+ * inside one is ignored by the document tokenizer, so it cannot be the
78
+ * document's body end either.
79
+ */
80
+ const FRAGMENT_TAGS = new Set(["template"]);
81
+
82
+ const TAG_NAME_AT = /<([a-zA-Z][^\s/>]*)/y;
83
+
84
+ /**
85
+ * Index just past the start tag beginning at `start`, skipping quoted attribute
86
+ * values so a `>` inside `srcdoc="…"` cannot end the tag early. Returns -1 when
87
+ * the tag never closes.
88
+ * @param {string} html
89
+ * @param {number} start
90
+ * @returns {number}
91
+ */
92
+ function startTagEnd(html, start) {
93
+ let quote = "";
94
+ for (let i = start + 1; i < html.length; i += 1) {
95
+ const char = html[i];
96
+ if (quote) {
97
+ if (char === quote) quote = "";
98
+ } else if (char === '"' || char === "'") {
99
+ quote = char;
100
+ } else if (char === ">") {
101
+ return i + 1;
102
+ }
103
+ }
104
+ return -1;
105
+ }
106
+
107
+ /**
108
+ * Index just past the first `</name>` at or after `from`, or -1.
109
+ * @param {string} html
110
+ * @param {string} name
111
+ * @param {number} from
112
+ * @returns {number}
113
+ */
114
+ function endTagEnd(html, name, from) {
115
+ const pattern = new RegExp(`</${name}\\s*>`, "gi");
116
+ pattern.lastIndex = from;
117
+ const match = pattern.exec(html);
118
+ return match ? match.index + match[0].length : -1;
119
+ }
120
+
121
+ /**
122
+ * Index just past the `</name>` that closes the element whose start tag ends at
123
+ * `from`, honouring nested `name` elements and skipping comments and raw-text
124
+ * content. Returns -1 when the element never closes.
125
+ * @param {string} html
126
+ * @param {string} name
127
+ * @param {number} from
128
+ * @returns {number}
129
+ */
130
+ function matchingClose(html, name, from) {
131
+ let depth = 1;
132
+ let i = from;
133
+
134
+ while (i < html.length) {
135
+ const tag = html.indexOf("<", i);
136
+ if (tag === -1) return -1;
137
+
138
+ if (html.startsWith("<!--", tag)) {
139
+ const commentEnd = html.indexOf("-->", tag + 4);
140
+ if (commentEnd === -1) return -1;
141
+ i = commentEnd + 3;
142
+ continue;
143
+ }
144
+
145
+ const end = startTagEnd(html, tag);
146
+ if (end === -1) return -1;
147
+
148
+ if (html[tag + 1] === "/") {
149
+ const nameAt = tag + 2;
150
+ if (
151
+ html.slice(nameAt, nameAt + name.length).toLowerCase() === name &&
152
+ " \t\n\f\r/>".includes(html[nameAt + name.length] ?? ">")
153
+ ) {
154
+ depth -= 1;
155
+ if (depth === 0) return end;
156
+ }
157
+ i = end;
158
+ continue;
159
+ }
160
+
161
+ TAG_NAME_AT.lastIndex = tag;
162
+ const match = TAG_NAME_AT.exec(html);
163
+ const inner = match ? match[1].toLowerCase() : "";
164
+
165
+ if (inner === name) {
166
+ depth += 1;
167
+ i = end;
168
+ continue;
169
+ }
170
+
171
+ // `<plaintext>` never closes, so nothing after it can close the fragment.
172
+ if (inner === "plaintext") return -1;
173
+
174
+ // Raw-text content is text, so a `</template>`-looking string in a script
175
+ // inside the fragment cannot close it early.
176
+ if (RAW_TEXT_TAGS.has(inner)) {
177
+ const raw = endTagEnd(html, inner, end);
178
+ if (raw === -1) return -1;
179
+ i = raw;
180
+ continue;
181
+ }
182
+
183
+ i = end;
184
+ }
185
+
186
+ return -1;
187
+ }
188
+
189
+ /**
190
+ * Find the ranges the HTML tokenizer treats as text rather than markup, so tag
191
+ * searches cannot match text that merely looks like markup (a `</body>` inside
192
+ * a script string, a commented-out tag, an attribute value, ...).
193
+ * @param {string} html
194
+ * @returns {{ranges: Array<[number, number]>, textFrom: number}} `ranges` are
195
+ * sorted, non-overlapping `[start, end)` spans; `textFrom` is the offset
196
+ * where a `<plaintext>` or unterminated tag/text element starts, so nothing
197
+ * from there to end of input is markup. -1 when the whole input is markup.
198
+ */
199
+ function nonMarkupRanges(html) {
200
+ /** @type {Array<[number, number]>} */
201
+ const ranges = [];
202
+ let textFrom = -1;
203
+ let i = 0;
204
+
205
+ while (i < html.length) {
206
+ const tag = html.indexOf("<", i);
207
+ if (tag === -1) break;
208
+
209
+ if (html.startsWith("<!--", tag)) {
210
+ const close = html.indexOf("-->", tag + 4);
211
+ const end = close === -1 ? html.length : close + 3;
212
+ ranges.push([tag, end]);
213
+ i = end;
214
+ continue;
215
+ }
216
+
217
+ TAG_NAME_AT.lastIndex = tag;
218
+ const match = TAG_NAME_AT.exec(html);
219
+ const name = match ? match[1].toLowerCase() : "";
220
+ const next = html[tag + 1] ?? "";
221
+
222
+ // A bare `<` (`1 < 2`) opens no tag, so carry on just after it: scanning
223
+ // from here could otherwise let a stray quote in text open an attribute
224
+ // quote and swallow real markup that follows.
225
+ if (!match && next !== "/" && next !== "!" && next !== "?") {
226
+ i = tag + 1;
227
+ continue;
228
+ }
229
+
230
+ const end = startTagEnd(html, tag);
231
+
232
+ // An unterminated tag, and `<plaintext>`, make the rest of the input text.
233
+ if (end === -1 || name === "plaintext") {
234
+ ranges.push([tag, html.length]);
235
+ textFrom = tag;
236
+ break;
237
+ }
238
+
239
+ // End tags and declarations have no attributes that could hide a tag.
240
+ if (name === "") {
241
+ i = end;
242
+ continue;
243
+ }
244
+
245
+ if (!RAW_TEXT_TAGS.has(name) && !FRAGMENT_TAGS.has(name)) {
246
+ // A start tag's quoted attributes are text too, so a `</body>` inside
247
+ // `title="…"` cannot capture the injection.
248
+ ranges.push([tag, end]);
249
+ i = end;
250
+ continue;
251
+ }
252
+
253
+ // Fragment contents (templates) are opaque to the document tokenizer, so a
254
+ // `</body>` inside them — nested templates included — is not the body end.
255
+ const close = FRAGMENT_TAGS.has(name)
256
+ ? matchingClose(html, name, end)
257
+ : endTagEnd(html, name, end);
258
+
259
+ // An element without its end tag also runs to end of input.
260
+ if (close === -1) {
261
+ ranges.push([tag, html.length]);
262
+ textFrom = tag;
263
+ break;
264
+ }
265
+
266
+ ranges.push([tag, close]);
267
+ i = close;
268
+ }
269
+
270
+ return { ranges, textFrom };
271
+ }
272
+
273
+ /**
274
+ * Offset of the last `</body>` outside the given text ranges, or -1.
275
+ * @param {string} html
276
+ * @param {Array<[number, number]>} ranges
277
+ * @returns {number}
278
+ */
279
+ function lastBodyEnd(html, ranges) {
280
+ let bodyEnd = -1;
281
+ let next = 0;
282
+ for (const match of html.matchAll(/<\/body\s*>/gi)) {
283
+ while (next < ranges.length && ranges[next][1] <= match.index) next += 1;
284
+ const range = ranges[next];
285
+ if (range && range[0] <= match.index) continue; // text, not a tag
286
+ bodyEnd = match.index;
287
+ }
288
+ return bodyEnd;
289
+ }
290
+
58
291
  /**
59
292
  * @param {string} html
60
293
  * @returns {string}
@@ -84,9 +317,22 @@ export function injectDevClient(html) {
84
317
  });
85
318
  })();
86
319
  </script>`;
87
- if (/<\/body\s*>/i.test(html))
88
- return html.replace(/<\/body\s*>/i, `${script}\n</body>`);
89
- return `${html}${html.endsWith("\n") ? "" : "\n"}${script}`;
320
+ // Insert before the *last* real closing body tag: packaged pages inline a
321
+ // bundle that can itself contain the literal text `</body>` inside a string,
322
+ // and injecting there would corrupt the script instead of ending the
323
+ // document. Comments, text-element content and attribute values are masked
324
+ // first so a quoted or commented-out `</body>` cannot capture the injection.
325
+ const { ranges, textFrom } = nonMarkupRanges(html);
326
+ const bodyEnd = lastBodyEnd(html, ranges);
327
+
328
+ // Without a usable body end tag, go before the point where the document
329
+ // turns into text (keeping the client a script rather than literal text),
330
+ // or append when the whole input was markup.
331
+ const at =
332
+ bodyEnd === -1 ? (textFrom === -1 ? html.length : textFrom) : bodyEnd;
333
+ const before = html.slice(0, at);
334
+ const separator = before === "" || before.endsWith("\n") ? "" : "\n";
335
+ return `${before}${separator}${script}${html.slice(at)}`;
90
336
  }
91
337
 
92
338
  /**
package/src/extensions.js CHANGED
@@ -165,18 +165,191 @@ export const Color = {
165
165
  hexToDecimal(hex) {
166
166
  return Color.rgbToDecimal(Color.hexToRgb(hex));
167
167
  },
168
+ hsvToRgb(hsv) {
169
+ let h = hsv.h % 360;
170
+ if (h < 0) h += 360;
171
+ const s = Math.max(0, Math.min(hsv.s, 1));
172
+ const v = Math.max(0, Math.min(hsv.v, 1));
173
+
174
+ const i = Math.floor(h / 60);
175
+ const f = h / 60 - i;
176
+ const p = v * (1 - s);
177
+ const q = v * (1 - s * f);
178
+ const t = v * (1 - s * (1 - f));
179
+
180
+ let r;
181
+ let g;
182
+ let b;
183
+ switch (i) {
184
+ default:
185
+ case 0:
186
+ r = v;
187
+ g = t;
188
+ b = p;
189
+ break;
190
+ case 1:
191
+ r = q;
192
+ g = v;
193
+ b = p;
194
+ break;
195
+ case 2:
196
+ r = p;
197
+ g = v;
198
+ b = t;
199
+ break;
200
+ case 3:
201
+ r = p;
202
+ g = q;
203
+ b = v;
204
+ break;
205
+ case 4:
206
+ r = t;
207
+ g = p;
208
+ b = v;
209
+ break;
210
+ case 5:
211
+ r = v;
212
+ g = p;
213
+ b = q;
214
+ break;
215
+ }
216
+
217
+ return {
218
+ r: Math.floor(r * 255),
219
+ g: Math.floor(g * 255),
220
+ b: Math.floor(b * 255),
221
+ };
222
+ },
223
+ rgbToHsv(rgb) {
224
+ const r = rgb.r / 255;
225
+ const g = rgb.g / 255;
226
+ const b = rgb.b / 255;
227
+ const x = Math.min(Math.min(r, g), b);
228
+ const v = Math.max(Math.max(r, g), b);
229
+
230
+ // For greys hue is arbitrarily reported as zero.
231
+ let h = 0;
232
+ let s = 0;
233
+ if (x !== v) {
234
+ const f = r === x ? g - b : g === x ? b - r : r - g;
235
+ const i = r === x ? 3 : g === x ? 5 : 1;
236
+ h = ((i - f / (v - x)) * 60) % 360;
237
+ s = (v - x) / v;
238
+ }
239
+
240
+ return { h: h, s: s, v: v };
241
+ },
242
+ mixRgb(rgb0, rgb1, fraction1) {
243
+ if (fraction1 <= 0) return rgb0;
244
+ if (fraction1 >= 1) return rgb1;
245
+ const fraction0 = 1 - fraction1;
246
+ return {
247
+ r: fraction0 * rgb0.r + fraction1 * rgb1.r,
248
+ g: fraction0 * rgb0.g + fraction1 * rgb1.g,
249
+ b: fraction0 * rgb0.b + fraction1 * rgb1.b,
250
+ };
251
+ },
168
252
  };
169
253
 
170
- /** `Scratch.Cast`. */
254
+ /**
255
+ * `Scratch.Cast` (scratch-vm's `util/cast`, as exposed to extensions).
256
+ *
257
+ * Each member mirrors the VM's implementation, including Scratch's own
258
+ * coercion rules — `Cast.toBoolean("0")` is false, `Cast.compare` is
259
+ * case-insensitive for text and numeric otherwise.
260
+ */
171
261
  export const Cast = {
172
262
  toString: (value) => String(value),
173
263
  toNumber: (value) => {
264
+ if (typeof value === "number") return Number.isNaN(value) ? 0 : value;
174
265
  const n = Number(value);
175
266
  return Number.isNaN(n) ? 0 : n;
176
267
  },
177
- toBoolean: (value) =>
178
- typeof value === "boolean" ? value : String(value) === "true",
179
- toList: (value) => (Array.isArray(value) ? value : [value]),
268
+ toBoolean: (value) => {
269
+ if (typeof value === "boolean") return value;
270
+ if (typeof value === "string") {
271
+ // These exact strings are false in Scratch; everything else is true.
272
+ if (value === "" || value === "0" || value.toLowerCase() === "false")
273
+ return false;
274
+ return true;
275
+ }
276
+ return Boolean(value);
277
+ },
278
+ /** 0 when equal, negative when `a` sorts first, positive otherwise. */
279
+ compare: (a, b) => {
280
+ // Scratch does not treat "0", "\t" or strings containing them as zero.
281
+ const isNotActuallyZero = (value) => {
282
+ if (typeof value !== "string") return false;
283
+ for (let i = 0; i < value.length; i++) {
284
+ const code = value.charCodeAt(i);
285
+ if (code === 48 || code === 9) return false;
286
+ }
287
+ return true;
288
+ };
289
+ let n1 = Number(a);
290
+ let n2 = Number(b);
291
+ if (n1 === 0 && isNotActuallyZero(a)) n1 = NaN;
292
+ else if (n2 === 0 && isNotActuallyZero(b)) n2 = NaN;
293
+ if (Number.isNaN(n1) || Number.isNaN(n2)) {
294
+ const s1 = String(a).toLowerCase();
295
+ const s2 = String(b).toLowerCase();
296
+ if (s1 < s2) return -1;
297
+ if (s1 > s2) return 1;
298
+ return 0;
299
+ }
300
+ if (
301
+ (n1 === Infinity && n2 === Infinity) ||
302
+ (n1 === -Infinity && n2 === -Infinity)
303
+ )
304
+ return 0;
305
+ return n1 - n2;
306
+ },
307
+ toRgbColorList: (value) => {
308
+ const color = Cast.toRgbColorObject(value);
309
+ return [color.r, color.g, color.b];
310
+ },
311
+ toRgbColorObject: (value) => {
312
+ let color;
313
+ if (typeof value === "object") value += "";
314
+ if (typeof value === "string" && value.substring(0, 1) === "#") {
315
+ color = Color.hexToRgb(value);
316
+ // A string that only looks like a hex colour casts to black.
317
+ if (!color) color = { r: 0, g: 0, b: 0, a: 255 };
318
+ } else {
319
+ color = Color.decimalToRgb(Cast.toNumber(value));
320
+ }
321
+ return color;
322
+ },
323
+ isWhiteSpace: (value) =>
324
+ value === null || (typeof value === "string" && value.trim().length === 0),
325
+ isInt: (value) => {
326
+ if (typeof value === "number") {
327
+ if (Number.isNaN(value)) return true;
328
+ return value === Math.floor(value);
329
+ }
330
+ if (typeof value === "boolean") return true;
331
+ if (typeof value === "string") return value.indexOf(".") < 0;
332
+ return false;
333
+ },
334
+ get LIST_INVALID() {
335
+ return "INVALID";
336
+ },
337
+ get LIST_ALL() {
338
+ return "ALL";
339
+ },
340
+ toListIndex: (index, length, acceptAll) => {
341
+ if (typeof index !== "number") {
342
+ if (index === "all") return acceptAll ? Cast.LIST_ALL : Cast.LIST_INVALID;
343
+ if (index === "last") return length > 0 ? length : Cast.LIST_INVALID;
344
+ if (index === "random" || index === "any")
345
+ return length > 0
346
+ ? 1 + Math.floor(Math.random() * length)
347
+ : Cast.LIST_INVALID;
348
+ }
349
+ index = Math.floor(Cast.toNumber(index));
350
+ if (index < 1 || index > length) return Cast.LIST_INVALID;
351
+ return index;
352
+ },
180
353
  };
181
354
 
182
355
  // -------------------------------------------------------------- translation
@@ -184,23 +357,35 @@ export const Cast = {
184
357
  /**
185
358
  * Map a `Scratch.ArgumentType` onto a Triflare Fx input descriptor.
186
359
  * @param {object} arg
187
- * @returns {{type: string}}
360
+ * @returns {{type: string, default?: string|number}}
188
361
  */
189
362
  function argumentSpec(arg) {
190
363
  const type = arg && arg.type;
364
+ let spec;
191
365
  switch (type) {
192
366
  case ArgumentType.BOOLEAN:
193
- return { type: "boolean" };
194
- case ArgumentType.NUMBER:
195
- return { type: "number" };
367
+ spec = { type: "boolean" };
368
+ break;
196
369
  case ArgumentType.ANGLE:
197
- return { type: "angle" };
370
+ spec = { type: "angle" };
371
+ break;
198
372
  case ArgumentType.COLOR:
199
- return { type: "color" };
200
- // Matrix, note, costume, backdrop, sound and string all travel as text.
373
+ spec = { type: "color" };
374
+ break;
375
+ // A note is a MIDI number, so it takes numeric input like `NUMBER`.
376
+ case ArgumentType.NOTE:
377
+ case ArgumentType.NUMBER:
378
+ spec = { type: "number" };
379
+ break;
380
+ // Matrix, costume, sound, PM menus and plain strings all travel as text.
201
381
  default:
202
- return { type: "string" };
382
+ spec = { type: "string" };
203
383
  }
384
+ // Extensions declare the value a call falls back to when it omits the arg.
385
+ if (arg && arg.defaultValue !== undefined && arg.defaultValue !== null) {
386
+ spec.default = arg.defaultValue;
387
+ }
388
+ return spec;
204
389
  }
205
390
 
206
391
  /**
@@ -270,10 +455,14 @@ function registerInfo(catalog, info, sourceName) {
270
455
  extension: id,
271
456
  };
272
457
  if (kind === "hat") {
273
- entry.args = Object.entries(block.arguments || {}).map(([name, arg]) => ({
274
- input: name,
275
- type: argumentSpec(arg).type,
276
- }));
458
+ entry.args = Object.entries(block.arguments || {}).map(([name, arg]) => {
459
+ const spec = argumentSpec(arg);
460
+ return {
461
+ input: name,
462
+ type: spec.type,
463
+ ...(spec.default !== undefined ? { default: spec.default } : {}),
464
+ };
465
+ });
277
466
  } else if (
278
467
  block.blockType === BlockType.CONDITIONAL ||
279
468
  block.blockType === BlockType.LOOP
@@ -331,15 +520,20 @@ function permissive(label) {
331
520
  return proxy;
332
521
  }
333
522
 
334
- function makeSandbox(registered, { unsandboxed = false } = {}) {
523
+ export function makeSandbox(registered, { unsandboxed = false } = {}) {
524
+ /** A function that rejects: the sandbox has no browser or network. */
525
+ const unavailable = (name) => () =>
526
+ Promise.reject(new Error(`${name} is not available while compiling`));
335
527
  const Scratch = {
336
528
  extensions: {
337
529
  register(extension) {
338
530
  registered.push(extension);
339
531
  },
340
- // PenguinMod sets `unsandboxed: true` for unsandboxed loads and exposes
341
- // `isPenguinMod`, so extensions can adapt to whichever loader runs them.
342
- unsandboxed: Boolean(unsandboxed),
532
+ // TurboWarp documents `unsandboxed` as true when unsandboxed and
533
+ // undefined otherwise — never false.
534
+ unsandboxed: unsandboxed || undefined,
535
+ // PenguinMod's unsandboxed loader sets this; exposing it in both modes
536
+ // keeps PenguinMod-only extensions loadable here.
343
537
  isPenguinMod: true,
344
538
  },
345
539
  BlockType,
@@ -351,15 +545,31 @@ function makeSandbox(registered, { unsandboxed = false } = {}) {
351
545
  Clone,
352
546
  Color,
353
547
  Cast,
354
- translate: (value) => {
355
- if (typeof value === "string") return value;
356
- if (
357
- value &&
358
- typeof value === "object" &&
359
- typeof value.default === "string"
360
- )
361
- return value.default;
362
- return value == null ? "" : String(value);
548
+ // tw-l10n exposes `translate(message, args)`, `translate.setup({…})` and a
549
+ // read-only `translate.language` on the same function. Generated l10n
550
+ // bundles call `setup({…})` at module scope before registering.
551
+ translate: Object.defineProperty(
552
+ Object.assign(
553
+ (message) => {
554
+ if (typeof message === "string") return message;
555
+ if (message && typeof message === "object") {
556
+ return typeof message.default === "string" ? message.default : "";
557
+ }
558
+ throw new Error("unsupported data type in translate()");
559
+ },
560
+ { setup() {} },
561
+ ),
562
+ "language",
563
+ { get: () => "en", enumerable: true },
564
+ ),
565
+ // `Scratch.external` loads sibling resources; the sandbox has no browser,
566
+ // so its entries reject instead of resolving like a module.
567
+ external: {
568
+ importModule: unavailable("Scratch.external"),
569
+ fetch: unavailable("Scratch.external"),
570
+ dataURL: unavailable("Scratch.external"),
571
+ blob: unavailable("Scratch.external"),
572
+ evalAndReturn: unavailable("Scratch.external"),
363
573
  },
364
574
  canFetch: () => Promise.resolve(false),
365
575
  fetch: () =>
@@ -2,5 +2,5 @@
2
2
  "name": "@penguinmod/packager",
3
3
  "version": "1.9.2",
4
4
  "commit": "53f868b85ec21d2edab9ce00fd2d709da93da6d6",
5
- "builtAt": "2026-11-03T06:22:44.624Z"
5
+ "builtAt": "2026-11-04T05:52:07.415Z"
6
6
  }