redscript-mc 2.1.1 → 2.2.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 (47) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +50 -21
  3. package/README.zh.md +61 -61
  4. package/dist/src/__tests__/e2e/basic.test.js +25 -0
  5. package/dist/src/__tests__/e2e/coroutine.test.js +22 -0
  6. package/dist/src/__tests__/mc-integration.test.js +25 -13
  7. package/dist/src/__tests__/schedule.test.js +105 -0
  8. package/dist/src/__tests__/typechecker.test.js +63 -0
  9. package/dist/src/emit/compile.js +1 -0
  10. package/dist/src/emit/index.js +3 -1
  11. package/dist/src/lir/lower.js +26 -0
  12. package/dist/src/mir/lower.js +341 -12
  13. package/dist/src/mir/types.d.ts +10 -0
  14. package/dist/src/optimizer/copy_prop.js +4 -0
  15. package/dist/src/optimizer/coroutine.d.ts +2 -0
  16. package/dist/src/optimizer/coroutine.js +33 -1
  17. package/dist/src/optimizer/dce.js +7 -1
  18. package/dist/src/optimizer/lir/const_imm.js +1 -1
  19. package/dist/src/optimizer/lir/dead_slot.js +1 -1
  20. package/dist/src/typechecker/index.d.ts +2 -0
  21. package/dist/src/typechecker/index.js +29 -0
  22. package/docs/ROADMAP.md +35 -0
  23. package/editors/vscode/package-lock.json +3 -3
  24. package/editors/vscode/package.json +1 -1
  25. package/examples/coroutine-demo.mcrs +11 -10
  26. package/jest.config.js +19 -0
  27. package/package.json +1 -1
  28. package/src/__tests__/e2e/basic.test.ts +27 -0
  29. package/src/__tests__/e2e/coroutine.test.ts +23 -0
  30. package/src/__tests__/fixtures/array-test.mcrs +21 -22
  31. package/src/__tests__/fixtures/counter.mcrs +17 -0
  32. package/src/__tests__/fixtures/foreach-at-test.mcrs +9 -10
  33. package/src/__tests__/mc-integration.test.ts +25 -13
  34. package/src/__tests__/schedule.test.ts +112 -0
  35. package/src/__tests__/typechecker.test.ts +68 -0
  36. package/src/emit/compile.ts +1 -0
  37. package/src/emit/index.ts +3 -1
  38. package/src/lir/lower.ts +27 -0
  39. package/src/mir/lower.ts +355 -9
  40. package/src/mir/types.ts +4 -0
  41. package/src/optimizer/copy_prop.ts +4 -0
  42. package/src/optimizer/coroutine.ts +37 -1
  43. package/src/optimizer/dce.ts +6 -1
  44. package/src/optimizer/lir/const_imm.ts +1 -1
  45. package/src/optimizer/lir/dead_slot.ts +1 -1
  46. package/src/stdlib/timer.mcrs +10 -5
  47. package/src/typechecker/index.ts +39 -0
package/src/mir/lower.ts CHANGED
@@ -61,16 +61,18 @@ export function lowerToMIR(hir: HIRModule, sourceFile?: string): MIRModule {
61
61
  }
62
62
  }
63
63
 
64
+ const timerCounter = { count: 0, timerId: 0 }
65
+
64
66
  const allFunctions: MIRFunction[] = []
65
67
  for (const f of hir.functions) {
66
- const { fn, helpers } = lowerFunction(f, hir.namespace, structDefs, implMethods, macroInfo, fnParamInfo, enumDefs, sourceFile)
68
+ const { fn, helpers } = lowerFunction(f, hir.namespace, structDefs, implMethods, macroInfo, fnParamInfo, enumDefs, sourceFile, timerCounter)
67
69
  allFunctions.push(fn, ...helpers)
68
70
  }
69
71
 
70
72
  // Lower impl block methods
71
73
  for (const ib of hir.implBlocks) {
72
74
  for (const m of ib.methods) {
73
- const { fn, helpers } = lowerImplMethod(m, ib.typeName, hir.namespace, structDefs, implMethods, macroInfo, fnParamInfo, enumDefs, sourceFile)
75
+ const { fn, helpers } = lowerImplMethod(m, ib.typeName, hir.namespace, structDefs, implMethods, macroInfo, fnParamInfo, enumDefs, sourceFile, timerCounter)
74
76
  allFunctions.push(fn, ...helpers)
75
77
  }
76
78
  }
@@ -105,6 +107,8 @@ class FnContext {
105
107
  readonly structVars = new Map<string, { typeName: string; fields: Map<string, Temp> }>()
106
108
  /** Tuple variable tracking: varName → array of element temps (index = slot) */
107
109
  readonly tupleVars = new Map<string, Temp[]>()
110
+ /** Array variable tracking: varName → { ns, pathPrefix } for NBT-backed int[] */
111
+ readonly arrayVars = new Map<string, { ns: string; pathPrefix: string }>()
108
112
  /** Macro function info for all functions in the module */
109
113
  readonly macroInfo: Map<string, MacroFunctionInfo>
110
114
  /** Function parameter info for call_macro generation */
@@ -117,6 +121,10 @@ class FnContext {
117
121
  currentSourceLoc: SourceLoc | undefined = undefined
118
122
  /** Source file path for the module being compiled */
119
123
  sourceFile: string | undefined = undefined
124
+ /** Shared counter for setTimeout/setInterval callback naming and Timer static IDs (module-wide) */
125
+ readonly timerCounter: { count: number; timerId: number }
126
+ /** Tracks temps whose values are known compile-time constants (for Timer static ID propagation) */
127
+ readonly constTemps = new Map<Temp, number>()
120
128
 
121
129
  constructor(
122
130
  namespace: string,
@@ -126,6 +134,7 @@ class FnContext {
126
134
  macroInfo: Map<string, MacroFunctionInfo> = new Map(),
127
135
  fnParamInfo: Map<string, HIRParam[]> = new Map(),
128
136
  enumDefs: Map<string, Map<string, number>> = new Map(),
137
+ timerCounter: { count: number; timerId: number } = { count: 0, timerId: 0 },
129
138
  ) {
130
139
  this.namespace = namespace
131
140
  this.fnName = fnName
@@ -135,6 +144,7 @@ class FnContext {
135
144
  this.fnParamInfo = fnParamInfo
136
145
  this.currentMacroParams = macroInfo.get(fnName)?.macroParams ?? new Set()
137
146
  this.enumDefs = enumDefs
147
+ this.timerCounter = timerCounter
138
148
  const entry = this.makeBlock('entry')
139
149
  this.currentBlock = entry
140
150
  }
@@ -214,8 +224,9 @@ function lowerFunction(
214
224
  fnParamInfo: Map<string, HIRParam[]> = new Map(),
215
225
  enumDefs: Map<string, Map<string, number>> = new Map(),
216
226
  sourceFile?: string,
227
+ timerCounter: { count: number; timerId: number } = { count: 0, timerId: 0 },
217
228
  ): { fn: MIRFunction; helpers: MIRFunction[] } {
218
- const ctx = new FnContext(namespace, fn.name, structDefs, implMethods, macroInfo, fnParamInfo, enumDefs)
229
+ const ctx = new FnContext(namespace, fn.name, structDefs, implMethods, macroInfo, fnParamInfo, enumDefs, timerCounter)
219
230
  ctx.sourceFile = sourceFile
220
231
  const fnMacroInfo = macroInfo.get(fn.name)
221
232
 
@@ -267,9 +278,10 @@ function lowerImplMethod(
267
278
  fnParamInfo: Map<string, HIRParam[]> = new Map(),
268
279
  enumDefs: Map<string, Map<string, number>> = new Map(),
269
280
  sourceFile?: string,
281
+ timerCounter: { count: number; timerId: number } = { count: 0, timerId: 0 },
270
282
  ): { fn: MIRFunction; helpers: MIRFunction[] } {
271
283
  const fnName = `${typeName}::${method.name}`
272
- const ctx = new FnContext(namespace, fnName, structDefs, implMethods, macroInfo, fnParamInfo, enumDefs)
284
+ const ctx = new FnContext(namespace, fnName, structDefs, implMethods, macroInfo, fnParamInfo, enumDefs, timerCounter)
273
285
  ctx.sourceFile = sourceFile
274
286
  const fields = structDefs.get(typeName) ?? []
275
287
  const hasSelf = method.params.length > 0 && method.params[0].name === 'self'
@@ -444,6 +456,12 @@ function lowerStmt(
444
456
  const t = ctx.freshTemp()
445
457
  ctx.emit({ kind: 'copy', dst: t, src: { kind: 'temp', name: `__rf_${fieldName}` } })
446
458
  fieldTemps.set(fieldName, t)
459
+ // Propagate compile-time constants from return slots (e.g. Timer._id from Timer::new)
460
+ const rfSlot = `__rf_${fieldName}`
461
+ const constVal = ctx.constTemps.get(rfSlot)
462
+ if (constVal !== undefined) {
463
+ ctx.constTemps.set(t, constVal)
464
+ }
447
465
  }
448
466
  ctx.structVars.set(stmt.name, { typeName: stmt.type.name, fields: fieldTemps })
449
467
  } else {
@@ -452,6 +470,33 @@ function lowerStmt(
452
470
  ctx.emit({ kind: 'copy', dst: t, src: valOp })
453
471
  scope.set(stmt.name, t)
454
472
  }
473
+ } else if (stmt.init.kind === 'array_lit') {
474
+ // Array literal: write to NBT storage, track the var for index access
475
+ const ns = `${ctx.getNamespace()}:arrays`
476
+ const pathPrefix = stmt.name
477
+ ctx.arrayVars.set(stmt.name, { ns, pathPrefix })
478
+ const elems = stmt.init.elements
479
+ // Check if all elements are pure integer literals (no side-effects)
480
+ const allConst = elems.every(e => e.kind === 'int_lit')
481
+ if (allConst) {
482
+ // Emit a single raw 'data modify ... set value [...]' to initialize the whole list
483
+ const vals = elems.map(e => (e as { kind: 'int_lit'; value: number }).value).join(', ')
484
+ ctx.emit({ kind: 'call', dst: null, fn: `__raw:data modify storage ${ns} ${pathPrefix} set value [${vals}]`, args: [] })
485
+ } else {
486
+ // Initialize with zeros, then overwrite dynamic elements
487
+ const zeros = elems.map(() => '0').join(', ')
488
+ ctx.emit({ kind: 'call', dst: null, fn: `__raw:data modify storage ${ns} ${pathPrefix} set value [${zeros}]`, args: [] })
489
+ for (let i = 0; i < elems.length; i++) {
490
+ const elemOp = lowerExpr(elems[i], ctx, scope)
491
+ if (elemOp.kind !== 'const' || (elems[i].kind !== 'int_lit')) {
492
+ ctx.emit({ kind: 'nbt_write', ns, path: `${pathPrefix}[${i}]`, type: 'int', scale: 1, src: elemOp })
493
+ }
494
+ }
495
+ }
496
+ // Store array length as a temp in scope (for .len access)
497
+ const lenTemp = ctx.freshTemp()
498
+ ctx.emit({ kind: 'const', dst: lenTemp, value: elems.length })
499
+ scope.set(stmt.name, lenTemp)
455
500
  } else {
456
501
  const valOp = lowerExpr(stmt.init, ctx, scope)
457
502
  const t = ctx.freshTemp()
@@ -730,6 +775,40 @@ function lowerStmt(
730
775
  if (isPlaceholderTerm(ctx.current().term)) {
731
776
  ctx.terminate({ kind: 'jump', target: mergeBlock.id })
732
777
  }
778
+ } else if (arm.pattern.kind === 'range_lit') {
779
+ // Range pattern: e.g. 0..59 => emit ge/le comparisons
780
+ const range = arm.pattern.range
781
+ const armBody = ctx.newBlock('match_arm')
782
+ const nextArm = ctx.newBlock('match_next')
783
+
784
+ // Chain checks: if min defined, check matchVal >= min; if max defined, check matchVal <= max
785
+ // Each failed check jumps to nextArm
786
+ const checks: Array<{ op: 'ge' | 'le'; bound: number }> = []
787
+ if (range.min !== undefined) checks.push({ op: 'ge', bound: range.min })
788
+ if (range.max !== undefined) checks.push({ op: 'le', bound: range.max })
789
+
790
+ if (checks.length === 0) {
791
+ // Open range — always matches
792
+ ctx.terminate({ kind: 'jump', target: armBody.id })
793
+ } else {
794
+ // Emit checks sequentially; each check passes → continue to next or armBody
795
+ for (let ci = 0; ci < checks.length; ci++) {
796
+ const { op, bound } = checks[ci]
797
+ const cmpTemp = ctx.freshTemp()
798
+ ctx.emit({ kind: 'cmp', dst: cmpTemp, op, a: matchVal, b: { kind: 'const', value: bound } })
799
+ const passBlock = ci === checks.length - 1 ? armBody : ctx.newBlock('match_range_check')
800
+ ctx.terminate({ kind: 'branch', cond: { kind: 'temp', name: cmpTemp }, then: passBlock.id, else: nextArm.id })
801
+ if (ci < checks.length - 1) ctx.switchTo(passBlock)
802
+ }
803
+ }
804
+
805
+ ctx.switchTo(armBody)
806
+ lowerBlock(arm.body, ctx, new Map(scope))
807
+ if (isPlaceholderTerm(ctx.current().term)) {
808
+ ctx.terminate({ kind: 'jump', target: mergeBlock.id })
809
+ }
810
+
811
+ ctx.switchTo(nextArm)
733
812
  } else {
734
813
  const patOp = lowerExpr(arm.pattern, ctx, scope)
735
814
  const cmpTemp = ctx.freshTemp()
@@ -996,14 +1075,116 @@ function lowerExpr(
996
1075
  }
997
1076
 
998
1077
  case 'index': {
1078
+ // Check if obj is a tracked array variable with a constant index
1079
+ if (expr.obj.kind === 'ident') {
1080
+ const arrInfo = ctx.arrayVars.get(expr.obj.name)
1081
+ if (arrInfo && expr.index.kind === 'int_lit') {
1082
+ const t = ctx.freshTemp()
1083
+ ctx.emit({ kind: 'nbt_read', dst: t, ns: arrInfo.ns, path: `${arrInfo.pathPrefix}[${expr.index.value}]`, scale: 1 })
1084
+ return { kind: 'temp', name: t }
1085
+ }
1086
+ }
999
1087
  const obj = lowerExpr(expr.obj, ctx, scope)
1000
- const idx = lowerExpr(expr.index, ctx, scope)
1088
+ lowerExpr(expr.index, ctx, scope)
1001
1089
  const t = ctx.freshTemp()
1002
1090
  ctx.emit({ kind: 'copy', dst: t, src: obj })
1003
1091
  return { kind: 'temp', name: t }
1004
1092
  }
1005
1093
 
1006
1094
  case 'call': {
1095
+ // Handle scoreboard_get / score — read from vanilla MC scoreboard
1096
+ if (expr.fn === 'scoreboard_get' || expr.fn === 'score') {
1097
+ const player = hirExprToStringLiteral(expr.args[0])
1098
+ const obj = hirExprToStringLiteral(expr.args[1])
1099
+ const t = ctx.freshTemp()
1100
+ ctx.emit({ kind: 'score_read', dst: t, player, obj })
1101
+ return { kind: 'temp', name: t }
1102
+ }
1103
+
1104
+ // Handle scoreboard_set — write to vanilla MC scoreboard
1105
+ if (expr.fn === 'scoreboard_set') {
1106
+ const player = hirExprToStringLiteral(expr.args[0])
1107
+ const obj = hirExprToStringLiteral(expr.args[1])
1108
+ const src = lowerExpr(expr.args[2], ctx, scope)
1109
+ ctx.emit({ kind: 'score_write', player, obj, src })
1110
+ const t = ctx.freshTemp()
1111
+ ctx.emit({ kind: 'const', dst: t, value: 0 })
1112
+ return { kind: 'temp', name: t }
1113
+ }
1114
+
1115
+ // Handle setTimeout/setInterval: lift lambda arg to a named helper function
1116
+ if ((expr.fn === 'setTimeout' || expr.fn === 'setInterval') && expr.args.length === 2) {
1117
+ const ticksArg = expr.args[0]
1118
+ const callbackArg = expr.args[1]
1119
+ const ns = ctx.getNamespace()
1120
+ const id = ctx.timerCounter.count++
1121
+ const callbackName = `__timeout_callback_${id}`
1122
+
1123
+ // Extract ticks value for the schedule command
1124
+ let ticksLiteral: number | null = null
1125
+ if (ticksArg.kind === 'int_lit') {
1126
+ ticksLiteral = ticksArg.value
1127
+ }
1128
+
1129
+ // Build the callback MIRFunction from the lambda body
1130
+ if (callbackArg.kind === 'lambda') {
1131
+ const cbCtx = new FnContext(
1132
+ ns,
1133
+ callbackName,
1134
+ ctx.structDefs,
1135
+ ctx.implMethods,
1136
+ ctx.macroInfo,
1137
+ ctx.fnParamInfo,
1138
+ ctx.enumDefs,
1139
+ ctx.timerCounter,
1140
+ )
1141
+ cbCtx.sourceFile = ctx.sourceFile
1142
+
1143
+ const cbBody = Array.isArray(callbackArg.body) ? callbackArg.body : [{ kind: 'expr' as const, expr: callbackArg.body }]
1144
+
1145
+ // For setInterval: reschedule at end of body
1146
+ const bodyStmts: typeof cbBody = [...cbBody]
1147
+ if (expr.fn === 'setInterval' && ticksLiteral !== null) {
1148
+ // Append: raw `schedule function ns:callbackName ticksT`
1149
+ bodyStmts.push({
1150
+ kind: 'raw' as const,
1151
+ cmd: `schedule function ${ns}:${callbackName} ${ticksLiteral}t`,
1152
+ } as any)
1153
+ }
1154
+
1155
+ lowerBlock(bodyStmts, cbCtx, new Map())
1156
+ const cbCur = cbCtx.current()
1157
+ if (isPlaceholderTerm(cbCur.term)) {
1158
+ cbCtx.terminate({ kind: 'return', value: null })
1159
+ }
1160
+ const cbReachable = computeReachable(cbCtx.blocks, 'entry')
1161
+ const cbLiveBlocks = cbCtx.blocks.filter(b => cbReachable.has(b.id))
1162
+ computePreds(cbLiveBlocks)
1163
+ const cbFn: MIRFunction = {
1164
+ name: callbackName,
1165
+ params: [],
1166
+ blocks: cbLiveBlocks,
1167
+ entry: 'entry',
1168
+ isMacro: false,
1169
+ }
1170
+ ctx.helperFunctions.push(cbFn, ...cbCtx.helperFunctions)
1171
+ }
1172
+
1173
+ // Emit: schedule function ns:callbackName ticksT
1174
+ if (ticksLiteral !== null) {
1175
+ ctx.emit({ kind: 'call', dst: null, fn: `__raw:schedule function ${ns}:${callbackName} ${ticksLiteral}t`, args: [] })
1176
+ } else {
1177
+ // Dynamic ticks: lower ticks operand and emit a raw schedule (best-effort)
1178
+ const ticksOp = lowerExpr(ticksArg, ctx, scope)
1179
+ ctx.emit({ kind: 'call', dst: null, fn: `__raw:schedule function ${ns}:${callbackName} 1t`, args: [ticksOp] })
1180
+ }
1181
+
1182
+ // setTimeout returns void (0), setInterval returns an int ID (0 for now)
1183
+ const t = ctx.freshTemp()
1184
+ ctx.emit({ kind: 'const', dst: t, value: 0 })
1185
+ return { kind: 'temp', name: t }
1186
+ }
1187
+
1007
1188
  // Handle builtin calls → raw MC commands
1008
1189
  if (BUILTIN_SET.has(expr.fn)) {
1009
1190
  const cmd = formatBuiltinCall(expr.fn, expr.args, ctx.currentMacroParams)
@@ -1017,6 +1198,14 @@ function lowerExpr(
1017
1198
  if (expr.args.length > 0 && expr.args[0].kind === 'ident') {
1018
1199
  const sv = ctx.structVars.get(expr.args[0].name)
1019
1200
  if (sv) {
1201
+ // Intercept Timer method calls when _id is a known compile-time constant
1202
+ if (sv.typeName === 'Timer') {
1203
+ const idTemp = sv.fields.get('_id')
1204
+ const timerId = idTemp !== undefined ? ctx.constTemps.get(idTemp) : undefined
1205
+ if (timerId !== undefined) {
1206
+ return lowerTimerMethod(expr.fn, timerId, sv, ctx, scope, expr.args.slice(1))
1207
+ }
1208
+ }
1020
1209
  const methodInfo = ctx.implMethods.get(sv.typeName)?.get(expr.fn)
1021
1210
  if (methodInfo?.hasSelf) {
1022
1211
  // Build args: self fields first, then remaining explicit args
@@ -1069,6 +1258,14 @@ function lowerExpr(
1069
1258
  if (expr.callee.kind === 'member' && expr.callee.obj.kind === 'ident') {
1070
1259
  const sv = ctx.structVars.get(expr.callee.obj.name)
1071
1260
  if (sv) {
1261
+ // Intercept Timer method calls when _id is a known compile-time constant
1262
+ if (sv.typeName === 'Timer') {
1263
+ const idTemp = sv.fields.get('_id')
1264
+ const timerId = idTemp !== undefined ? ctx.constTemps.get(idTemp) : undefined
1265
+ if (timerId !== undefined) {
1266
+ return lowerTimerMethod(expr.callee.field, timerId, sv, ctx, scope, expr.args)
1267
+ }
1268
+ }
1072
1269
  const methodInfo = ctx.implMethods.get(sv.typeName)?.get(expr.callee.field)
1073
1270
  if (methodInfo?.hasSelf) {
1074
1271
  // Build args: self fields first, then explicit args
@@ -1094,6 +1291,24 @@ function lowerExpr(
1094
1291
  }
1095
1292
 
1096
1293
  case 'static_call': {
1294
+ // Intercept Timer::new() to statically allocate a unique ID
1295
+ if (expr.type === 'Timer' && expr.method === 'new' && expr.args.length === 1) {
1296
+ const id = ctx.timerCounter.timerId++
1297
+ const ns = ctx.getNamespace()
1298
+ const playerName = `__timer_${id}`
1299
+ // Emit scoreboard initialization: ticks=0, active=0
1300
+ ctx.emit({ kind: 'score_write', player: `${playerName}_ticks`, obj: ns, src: { kind: 'const', value: 0 } })
1301
+ ctx.emit({ kind: 'score_write', player: `${playerName}_active`, obj: ns, src: { kind: 'const', value: 0 } })
1302
+ // Lower the duration argument
1303
+ const durationOp = lowerExpr(expr.args[0], ctx, scope)
1304
+ // Return fields via __rf_ slots (Timer has fields: _id, _duration)
1305
+ ctx.emit({ kind: 'const', dst: '__rf__id', value: id })
1306
+ ctx.constTemps.set('__rf__id', id)
1307
+ ctx.emit({ kind: 'copy', dst: '__rf__duration', src: durationOp })
1308
+ const t = ctx.freshTemp()
1309
+ ctx.emit({ kind: 'const', dst: t, value: 0 })
1310
+ return { kind: 'temp', name: t }
1311
+ }
1097
1312
  const args = expr.args.map(a => lowerExpr(a, ctx, scope))
1098
1313
  const t = ctx.freshTemp()
1099
1314
  ctx.emit({ kind: 'call', dst: t, fn: `${expr.type}::${expr.method}`, args })
@@ -1198,6 +1413,98 @@ function lowerShortCircuitOr(
1198
1413
  return { kind: 'temp', name: result }
1199
1414
  }
1200
1415
 
1416
+ // ---------------------------------------------------------------------------
1417
+ // Timer method inlining
1418
+ // ---------------------------------------------------------------------------
1419
+
1420
+ /**
1421
+ * Inline a Timer instance method call using the statically-assigned timer ID.
1422
+ * Emits scoreboard operations directly, bypassing the Timer::* function calls.
1423
+ */
1424
+ function lowerTimerMethod(
1425
+ method: string,
1426
+ timerId: number,
1427
+ sv: { typeName: string; fields: Map<string, Temp> },
1428
+ ctx: FnContext,
1429
+ scope: Map<string, Temp>,
1430
+ extraArgs: HIRExpr[],
1431
+ ): Operand {
1432
+ const ns = ctx.getNamespace()
1433
+ const player = `__timer_${timerId}`
1434
+ const t = ctx.freshTemp()
1435
+
1436
+ if (method === 'start') {
1437
+ ctx.emit({ kind: 'score_write', player: `${player}_active`, obj: ns, src: { kind: 'const', value: 1 } })
1438
+ ctx.emit({ kind: 'const', dst: t, value: 0 })
1439
+ } else if (method === 'pause') {
1440
+ ctx.emit({ kind: 'score_write', player: `${player}_active`, obj: ns, src: { kind: 'const', value: 0 } })
1441
+ ctx.emit({ kind: 'const', dst: t, value: 0 })
1442
+ } else if (method === 'reset') {
1443
+ ctx.emit({ kind: 'score_write', player: `${player}_ticks`, obj: ns, src: { kind: 'const', value: 0 } })
1444
+ ctx.emit({ kind: 'const', dst: t, value: 0 })
1445
+ } else if (method === 'tick') {
1446
+ const durationTemp = sv.fields.get('_duration')
1447
+ const activeTemp = ctx.freshTemp()
1448
+ const ticksTemp = ctx.freshTemp()
1449
+ ctx.emit({ kind: 'score_read', dst: activeTemp, player: `${player}_active`, obj: ns })
1450
+ ctx.emit({ kind: 'score_read', dst: ticksTemp, player: `${player}_ticks`, obj: ns })
1451
+ const innerThen = ctx.newBlock('timer_tick_inner')
1452
+ const innerMerge = ctx.newBlock('timer_tick_after_lt')
1453
+ const outerMerge = ctx.newBlock('timer_tick_done')
1454
+ const activeCheck = ctx.freshTemp()
1455
+ ctx.emit({ kind: 'cmp', op: 'eq', dst: activeCheck, a: { kind: 'temp', name: activeTemp }, b: { kind: 'const', value: 1 } })
1456
+ ctx.terminate({ kind: 'branch', cond: { kind: 'temp', name: activeCheck }, then: innerThen.id, else: outerMerge.id })
1457
+ ctx.switchTo(innerThen)
1458
+ const lessCheck = ctx.freshTemp()
1459
+ if (durationTemp) {
1460
+ ctx.emit({ kind: 'cmp', op: 'lt', dst: lessCheck, a: { kind: 'temp', name: ticksTemp }, b: { kind: 'temp', name: durationTemp } })
1461
+ } else {
1462
+ ctx.emit({ kind: 'const', dst: lessCheck, value: 0 })
1463
+ }
1464
+ const doIncBlock = ctx.newBlock('timer_tick_inc')
1465
+ ctx.terminate({ kind: 'branch', cond: { kind: 'temp', name: lessCheck }, then: doIncBlock.id, else: innerMerge.id })
1466
+ ctx.switchTo(doIncBlock)
1467
+ const newTicks = ctx.freshTemp()
1468
+ ctx.emit({ kind: 'add', dst: newTicks, a: { kind: 'temp', name: ticksTemp }, b: { kind: 'const', value: 1 } })
1469
+ ctx.emit({ kind: 'score_write', player: `${player}_ticks`, obj: ns, src: { kind: 'temp', name: newTicks } })
1470
+ ctx.terminate({ kind: 'jump', target: innerMerge.id })
1471
+ ctx.switchTo(innerMerge)
1472
+ ctx.terminate({ kind: 'jump', target: outerMerge.id })
1473
+ ctx.switchTo(outerMerge)
1474
+ ctx.emit({ kind: 'const', dst: t, value: 0 })
1475
+ } else if (method === 'done') {
1476
+ const durationTemp = sv.fields.get('_duration')
1477
+ const ticksTemp = ctx.freshTemp()
1478
+ ctx.emit({ kind: 'score_read', dst: ticksTemp, player: `${player}_ticks`, obj: ns })
1479
+ if (durationTemp) {
1480
+ ctx.emit({ kind: 'cmp', op: 'ge', dst: t, a: { kind: 'temp', name: ticksTemp }, b: { kind: 'temp', name: durationTemp } })
1481
+ } else {
1482
+ ctx.emit({ kind: 'const', dst: t, value: 0 })
1483
+ }
1484
+ } else if (method === 'elapsed') {
1485
+ ctx.emit({ kind: 'score_read', dst: t, player: `${player}_ticks`, obj: ns })
1486
+ } else if (method === 'remaining') {
1487
+ const durationTemp = sv.fields.get('_duration')
1488
+ const ticksTemp = ctx.freshTemp()
1489
+ ctx.emit({ kind: 'score_read', dst: ticksTemp, player: `${player}_ticks`, obj: ns })
1490
+ if (durationTemp) {
1491
+ ctx.emit({ kind: 'sub', dst: t, a: { kind: 'temp', name: durationTemp }, b: { kind: 'temp', name: ticksTemp } })
1492
+ } else {
1493
+ ctx.emit({ kind: 'const', dst: t, value: 0 })
1494
+ }
1495
+ } else {
1496
+ // Unknown Timer method — emit regular call
1497
+ const fields = ['_id', '_duration']
1498
+ const selfArgs: Operand[] = fields.map(f => {
1499
+ const temp = sv.fields.get(f)
1500
+ return temp ? { kind: 'temp' as const, name: temp } : { kind: 'const' as const, value: 0 }
1501
+ })
1502
+ const explicitArgs = extraArgs.map(a => lowerExpr(a, ctx, scope))
1503
+ ctx.emit({ kind: 'call', dst: t, fn: `Timer::${method}`, args: [...selfArgs, ...explicitArgs] })
1504
+ }
1505
+ return { kind: 'temp', name: t }
1506
+ }
1507
+
1201
1508
  // ---------------------------------------------------------------------------
1202
1509
  // Execute subcommand lowering
1203
1510
  // ---------------------------------------------------------------------------
@@ -1310,13 +1617,32 @@ function formatBuiltinCall(
1310
1617
  break
1311
1618
  }
1312
1619
  case 'setblock': {
1313
- const [x, y, z, block] = strs
1314
- cmd = `setblock ${x} ${y} ${z} ${block}`
1620
+ // args: blockpos, block expand blockpos to x y z
1621
+ const [posOrX, blockOrY] = args
1622
+ if (posOrX?.kind === 'blockpos') {
1623
+ const px = coordStr(posOrX.x)
1624
+ const py = coordStr(posOrX.y)
1625
+ const pz = coordStr(posOrX.z)
1626
+ const blk = exprToCommandArg(blockOrY, macroParams).str
1627
+ cmd = `setblock ${px} ${py} ${pz} ${blk}`
1628
+ } else {
1629
+ const [x, y, z, block] = strs
1630
+ cmd = `setblock ${x} ${y} ${z} ${block}`
1631
+ }
1315
1632
  break
1316
1633
  }
1317
1634
  case 'fill': {
1318
- const [x1, y1, z1, x2, y2, z2, block] = strs
1319
- cmd = `fill ${x1} ${y1} ${z1} ${x2} ${y2} ${z2} ${block}`
1635
+ // args: blockpos1, blockpos2, block expand both blockpos
1636
+ const [p1, p2, blkArg] = args
1637
+ if (p1?.kind === 'blockpos' && p2?.kind === 'blockpos') {
1638
+ const x1 = coordStr(p1.x); const y1 = coordStr(p1.y); const z1 = coordStr(p1.z)
1639
+ const x2 = coordStr(p2.x); const y2 = coordStr(p2.y); const z2 = coordStr(p2.z)
1640
+ const blk = exprToCommandArg(blkArg, macroParams).str
1641
+ cmd = `fill ${x1} ${y1} ${z1} ${x2} ${y2} ${z2} ${blk}`
1642
+ } else {
1643
+ const [x1, y1, z1, x2, y2, z2, block] = strs
1644
+ cmd = `fill ${x1} ${y1} ${z1} ${x2} ${y2} ${z2} ${block}`
1645
+ }
1320
1646
  break
1321
1647
  }
1322
1648
  case 'say': cmd = `say ${strs[0] ?? ''}`; break
@@ -1355,6 +1681,15 @@ function formatBuiltinCall(
1355
1681
  }
1356
1682
 
1357
1683
  /** Convert an HIR expression to its MC command string representation */
1684
+ /** Convert a CoordComponent to a MC coordinate string */
1685
+ function coordStr(c: import('../ast/types').CoordComponent): string {
1686
+ switch (c.kind) {
1687
+ case 'absolute': return String(c.value)
1688
+ case 'relative': return c.offset === 0 ? '~' : `~${c.offset}`
1689
+ case 'local': return c.offset === 0 ? '^' : `^${c.offset}`
1690
+ }
1691
+ }
1692
+
1358
1693
  function exprToCommandArg(
1359
1694
  expr: HIRExpr,
1360
1695
  macroParams: Set<string>,
@@ -1401,3 +1736,14 @@ function exprToCommandArg(
1401
1736
  return { str: '~', isMacro: false }
1402
1737
  }
1403
1738
  }
1739
+
1740
+ /** Extract a string literal from a HIR expression for use in MC commands */
1741
+ function hirExprToStringLiteral(expr: HIRExpr): string {
1742
+ switch (expr.kind) {
1743
+ case 'str_lit': return expr.value
1744
+ case 'mc_name': return expr.value
1745
+ case 'selector': return expr.raw
1746
+ case 'int_lit': return String(expr.value)
1747
+ default: return ''
1748
+ }
1749
+ }
package/src/mir/types.ts CHANGED
@@ -80,6 +80,10 @@ export type MIRInstr = MIRInstrBase & (
80
80
  | { kind: 'nbt_read'; dst: Temp; ns: string; path: string; scale: number }
81
81
  | { kind: 'nbt_write'; ns: string; path: string; type: NBTType; scale: number; src: Operand }
82
82
 
83
+ // ── Vanilla scoreboard interop ────────────────────────────────────────────
84
+ | { kind: 'score_read'; dst: Temp; player: string; obj: string }
85
+ | { kind: 'score_write'; player: string; obj: string; src: Operand }
86
+
83
87
  // ── Function calls ────────────────────────────────────────────────────────
84
88
  | { kind: 'call'; dst: Temp | null; fn: string; args: Operand[] }
85
89
  | { kind: 'call_macro'; dst: Temp | null; fn: string; args: { name: string; value: Operand; type: NBTType; scale: number }[] }
@@ -85,6 +85,8 @@ function rewriteUses(instr: MIRInstr, copies: Map<Temp, Operand>): MIRInstr {
85
85
  return { ...instr, cond: resolve(instr.cond, copies) }
86
86
  case 'return':
87
87
  return { ...instr, value: instr.value ? resolve(instr.value, copies) : null }
88
+ case 'score_write':
89
+ return { ...instr, src: resolve(instr.src, copies) }
88
90
  default:
89
91
  return instr
90
92
  }
@@ -100,6 +102,8 @@ function getDst(instr: MIRInstr): Temp | null {
100
102
  return instr.dst
101
103
  case 'call': case 'call_macro':
102
104
  return instr.dst
105
+ case 'score_read':
106
+ return instr.dst
103
107
  default:
104
108
  return null
105
109
  }
@@ -40,6 +40,8 @@ export interface CoroutineResult {
40
40
  module: MIRModule
41
41
  /** Names of generated @tick dispatcher functions (caller must add to tick list). */
42
42
  generatedTickFunctions: string[]
43
+ /** Warning messages for skipped transforms. */
44
+ warnings: string[]
43
45
  }
44
46
 
45
47
  /**
@@ -51,11 +53,12 @@ export function coroutineTransform(
51
53
  mod: MIRModule,
52
54
  infos: CoroutineInfo[],
53
55
  ): CoroutineResult {
54
- if (infos.length === 0) return { module: mod, generatedTickFunctions: [] }
56
+ if (infos.length === 0) return { module: mod, generatedTickFunctions: [], warnings: [] }
55
57
 
56
58
  const infoMap = new Map(infos.map(i => [i.fnName, i]))
57
59
  const newFunctions: MIRFunction[] = []
58
60
  const tickFns: string[] = []
61
+ const warnings: string[] = []
59
62
 
60
63
  for (const fn of mod.functions) {
61
64
  const info = infoMap.get(fn.name)
@@ -64,6 +67,17 @@ export function coroutineTransform(
64
67
  continue
65
68
  }
66
69
 
70
+ // Skip transform if function contains macro calls — continuations are called
71
+ // directly (not via `function ... with storage`) so macro variables like
72
+ // ${px} would not be substituted, causing MC parse errors.
73
+ if (fnContainsMacroCalls(fn)) {
74
+ warnings.push(
75
+ `@coroutine cannot be applied to functions containing macro calls (skipped: ${fn.name})`,
76
+ )
77
+ newFunctions.push(fn)
78
+ continue
79
+ }
80
+
67
81
  const transformed = transformCoroutine(fn, info, mod.objective)
68
82
  newFunctions.push(transformed.initFn)
69
83
  newFunctions.push(...transformed.continuations)
@@ -74,7 +88,29 @@ export function coroutineTransform(
74
88
  return {
75
89
  module: { ...mod, functions: newFunctions },
76
90
  generatedTickFunctions: tickFns,
91
+ warnings,
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Returns true if any instruction in the function requires macro processing.
97
+ * This includes:
98
+ * - call_macro: explicit macro function invocations
99
+ * - call with fn = '__raw:\x01...': builtin calls (particle, summon, etc.) with macro params
100
+ * - call with fn = '__raw:<cmd>' where cmd contains '${': raw() commands with variable interpolation
101
+ */
102
+ function fnContainsMacroCalls(fn: MIRFunction): boolean {
103
+ for (const block of fn.blocks) {
104
+ for (const instr of [...block.instrs, block.term]) {
105
+ if (instr.kind === 'call_macro') return true
106
+ if (instr.kind === 'call' && instr.fn.startsWith('__raw:')) {
107
+ const cmd = instr.fn.slice(6)
108
+ // \x01 sentinel: builtin with macro params; '${': raw() with variable interpolation
109
+ if (cmd.startsWith('\x01') || cmd.includes('${')) return true
110
+ }
111
+ }
77
112
  }
113
+ return false
78
114
  }
79
115
 
80
116
  // ---------------------------------------------------------------------------
@@ -78,7 +78,8 @@ function recomputePreds(blocks: MIRBlock[]): MIRBlock[] {
78
78
 
79
79
  function hasSideEffects(instr: MIRInstr): boolean {
80
80
  if (instr.kind === 'call' || instr.kind === 'call_macro' ||
81
- instr.kind === 'call_context' || instr.kind === 'nbt_write') return true
81
+ instr.kind === 'call_context' || instr.kind === 'nbt_write' ||
82
+ instr.kind === 'score_write') return true
82
83
  // Return field temps (__rf_) write to global return slots — not dead even if unused locally
83
84
  // Option slot temps (__opt_) write observable scoreboard state — preserve even if var unused
84
85
  const dst = getDst(instr)
@@ -104,6 +105,8 @@ function getDst(instr: MIRInstr): Temp | null {
104
105
  return instr.dst
105
106
  case 'call': case 'call_macro':
106
107
  return instr.dst
108
+ case 'score_read':
109
+ return instr.dst
107
110
  default:
108
111
  return null
109
112
  }
@@ -129,6 +132,8 @@ function getUsedTemps(instr: MIRInstr): Temp[] {
129
132
  addOp(instr.cond); break
130
133
  case 'return':
131
134
  if (instr.value) addOp(instr.value); break
135
+ case 'score_write':
136
+ addOp(instr.src); break
132
137
  }
133
138
  return temps
134
139
  }
@@ -28,7 +28,7 @@ function countSlotUses(instrs: LIRInstr[], target: string): number {
28
28
 
29
29
  function extractSlotsFromRaw(cmd: string): Slot[] {
30
30
  const slots: Slot[] = []
31
- const re = /(\$[\w.]+)\s+(\S+)/g
31
+ const re = /(\$[\w.:]+)\s+(\S+)/g
32
32
  let m
33
33
  while ((m = re.exec(cmd)) !== null) {
34
34
  slots.push({ player: m[1], obj: m[2] })
@@ -24,7 +24,7 @@ function slotKey(s: Slot): string {
24
24
  function extractSlotsFromRaw(cmd: string): Slot[] {
25
25
  const slots: Slot[] = []
26
26
  // Match $<player> <obj> patterns (scoreboard slot references)
27
- const re = /(\$[\w.]+)\s+(\S+)/g
27
+ const re = /(\$[\w.:]+)\s+(\S+)/g
28
28
  let m
29
29
  while ((m = re.exec(cmd)) !== null) {
30
30
  slots.push({ player: m[1], obj: m[2] })