watr 4.6.9 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/optimize.js CHANGED
@@ -1,15 +1,54 @@
1
1
  /**
2
- * AST optimizations for WebAssembly modules.
3
- * Reduces code size and improves runtime performance.
2
+ * WAT AST optimizer size/runtime passes over watr's s-expression IR.
4
3
  *
5
- * @module watr/optimize
4
+ * jz owns its optimizer; watr is used *only* as the WAT→binary encoder.
5
+ * Pairs with src/optimize/ (jz-IR-level) — folder context disambiguates.
6
+ *
7
+ * @module wat/optimize
6
8
  */
7
9
 
8
- import parse from './parse.js'
9
10
  import compile from './compile.js'
10
- import { i32, i64 } from './encode.js'
11
- import { walk, walkPost, clone } from './util.js'
12
- import { resultType } from './const.js'
11
+ import parse from './parse.js'
12
+
13
+ // Fixpoint round caps empirical convergence bounds, not correctness limits.
14
+ // Each pass only makes monotonic progress, so hitting a cap merely leaves a few
15
+ // residual simplifications for the next compile rather than producing wrong output.
16
+ const MAX_PROP_ROUNDS = 6 // forward-prop / set-get / tee fixpoint per scope
17
+ const MAX_INLINE_ROUNDS = 16 // single-caller inline-chain depth (deep generated stdlib)
18
+
19
+ // === WAT optimizer passes ===
20
+
21
+ // — AST helpers (formerly watr/util.js) — every node is an s-expression
22
+ // array `[head, ...args]`; non-arrays are immediates.
23
+ const clone = (node) => Array.isArray(node) ? node.map(clone) : node
24
+
25
+ /** Walk depth-first pre-order, read-only. fn(node, parent, idx). */
26
+ const walk = (node, fn, parent, idx) => {
27
+ fn(node, parent, idx)
28
+ if (Array.isArray(node)) for (let i = 0; i < node.length; i++) walk(node[i], fn, node, i)
29
+ }
30
+
31
+ /** Walk depth-first post-order. fn may return a replacement node or mutate in place. */
32
+ const walkPost = (node, fn, parent, idx) => {
33
+ if (Array.isArray(node)) for (let i = 0; i < node.length; i++) walkPost(node[i], fn, node, i)
34
+ const result = fn(node, parent, idx)
35
+ if (result !== undefined && parent) parent[idx] = result
36
+ return result !== undefined ? result : node
37
+ }
38
+
39
+ /** Result value type of an op from its name prefix (formerly watr/const.js).
40
+ * Comparisons/eqz on scalar int/float collapse to i32; else the name prefix. */
41
+ const resultType = (op) => {
42
+ if (typeof op !== 'string') return null
43
+ const dot = op.indexOf('.')
44
+ if (dot < 0) return null
45
+ const prefix = op.slice(0, dot)
46
+ const scalar = prefix === 'i32' || prefix === 'i64' || prefix === 'f32' || prefix === 'f64'
47
+ if (scalar && /^(eqz?|ne|[lg][te])(_[su])?$/.test(op.slice(dot + 1))) return 'i32'
48
+ if (scalar || prefix === 'v128') return prefix
49
+ if (op === 'memory.size' || op === 'memory.grow') return 'i32'
50
+ return null
51
+ }
13
52
 
14
53
  /**
15
54
  * Recursively count AST nodes — fast size heuristic without compiling.
@@ -234,14 +273,86 @@ const treeshake = (ast) => {
234
273
  const roundEven = (x) => x - Math.floor(x) !== 0.5 ? Math.round(x) : 2 * Math.round(x / 2)
235
274
 
236
275
  // Bit-exact reinterpret helpers (preserve NaN payloads).
276
+ //
277
+ // SELF-HOST CONTRACT: this file runs inside the jz kernel, whose BigInt is a
278
+ // raw mod-2^64 i64 carrier — BigInt64Array views are a legacy f64-value shim
279
+ // (reads return the FLOAT, not the bits), decimal stringification of >2^53
280
+ // values and asIntN/asUintN are unfaithful, and adding 2^64 wraps to +0.
281
+ // Everything here therefore sticks to the verified-faithful surface:
282
+ // Uint32Array aliasing, hex toString(16)/parseInt(,16)/padStart, BigInt('0x…')
283
+ // construction, BigInt ===/< comparison, and string arithmetic for two's
284
+ // complement. The signed canonicalization `v > MAX_I64 → v − 2^64` is exact
285
+ // natively AND a no-op in-kernel (2^64 ≡ 0 there) — correct in both worlds.
237
286
  const _rb8 = new ArrayBuffer(8)
238
287
  const _rf64 = new Float64Array(_rb8)
239
- const _ri64 = new BigInt64Array(_rb8)
288
+ const _ru32 = new Uint32Array(_rb8) // LE halves: [0]=lo, [1]=hi
240
289
  const _rb4 = new ArrayBuffer(4)
241
290
  const _rf32 = new Float32Array(_rb4)
242
291
  const _ri32 = new Int32Array(_rb4)
243
- const i64FromF64 = (x) => { _rf64[0] = x; return _ri64[0] }
244
- const f64FromI64 = (x) => { _ri64[0] = BigInt.asIntN(64, x); return _rf64[0] }
292
+ const _hex8 = (u) => (u >>> 0).toString(16).padStart(8, '0')
293
+ /** Two's complement of a 16-digit hex magnitude pure string math. */
294
+ const _twosComp16 = (mag) => {
295
+ let out = '', carry = 1
296
+ for (let i = 15; i >= 0; i--) {
297
+ const d = (15 - parseInt(mag[i], 16)) + carry
298
+ out = (d & 15).toString(16) + out
299
+ carry = d >> 4
300
+ }
301
+ return out
302
+ }
303
+ /** Bits of an i64 BigInt (any sign) as a 16-digit hex string. Takes BigInt,
304
+ * returns STRING — safe to call across kernel function boundaries (strings
305
+ * are tagged; raw BigInts lose their kind at returns/polymorphic slots). */
306
+ const _i64Hex16 = (v) => {
307
+ const h = v.toString(16)
308
+ return h[0] === '-' ? _twosComp16(h.slice(1).padStart(16, '0')) : h.padStart(16, '0')
309
+ }
310
+ // ============================== i64 VALUE CONTRACT ==========================
311
+ // Within this optimizer an i64 const VALUE is the canonical STRING
312
+ // '0x' + 16 lowercase hex digits (the raw bits). Strings survive every kernel
313
+ // boundary; a BigInt held in a polymorphic slot ({type,value}), an untyped
314
+ // param, or a return value is kind-erased in-kernel and every subsequent
315
+ // BigInt op on it misdispatches. BigInt math is constructed AND consumed
316
+ // inside single folder bodies only; folders return hex strings (or null).
317
+ const ZERO64 = '0x0000000000000000', ONE64 = '0x0000000000000001', NEG164 = '0xffffffffffffffff'
318
+ /** Canonicalize any i64.const node value (number | decimal/hex string | bigint).
319
+ * EVERY _i64Hex16 argument here is a freshly-constructed BigInt: passing the
320
+ * raw polymorphic `val` through would poison _i64Hex16's param kind for ALL
321
+ * callers in-kernel (param types are per-function — one kind-erased call site
322
+ * degrades v.toString(16) to dynamic dispatch on raw bits everywhere). */
323
+ const _i64Canon = (val) => {
324
+ if (typeof val === 'string') {
325
+ const s = val.replaceAll('_', '')
326
+ if (s.length === 18 && s[1] === 'x') return '0x' + s.slice(2).toLowerCase()
327
+ // BigInt() rejects signed-hex ('-0x1' / '+0x2'); split the sign off the magnitude.
328
+ const neg = s[0] === '-', mag = (s[0] === '-' || s[0] === '+') ? s.slice(1) : s
329
+ return '0x' + _i64Hex16(neg ? -BigInt(mag) : BigInt(mag))
330
+ }
331
+ // bigint stragglers (native-only defensive) route through String → fresh BigInt.
332
+ if (typeof val === 'bigint') return '0x' + _i64Hex16(BigInt(String(val)))
333
+ return '0x' + _i64Hex16(BigInt(Math.trunc(val) || 0))
334
+ }
335
+ /** Signed-order key: flip the sign bit, then equal-length hex compares
336
+ * lexicographically in signed order. Pure strings — kernel-safe. */
337
+ const _sb = (h) => (parseInt(h[2], 16) ^ 8).toString(16) + h.slice(3)
338
+ /** Hex-string i64 ops used by several folders — all pure string/number math. */
339
+ const _i64Lo = (h) => parseInt(h.slice(10), 16) | 0
340
+ const _i64HiU = (h) => parseInt(h.slice(2, 10), 16) >>> 0
341
+ /** Hex-encode an i64 fold result (BigInt, any sign/world — see folder note). */
342
+ const _i64Arith = (r) => r == null ? null : '0x' + _i64Hex16(r)
343
+ /** SIGNED i64 BigInt from canon hex — exact natively; the subtract arm is
344
+ * dead in-kernel, where BigInt('0x…') already arrives as the signed carrier. */
345
+ const _sgn = (h) => {
346
+ let v = BigInt(h)
347
+ if (v > 0x7fffffffffffffffn) v = v - 0x8000000000000000n - 0x8000000000000000n
348
+ return v
349
+ }
350
+ const i64FromF64 = (x) => { _rf64[0] = x; return '0x' + _hex8(_ru32[1]) + _hex8(_ru32[0]) }
351
+ const f64FromI64 = (h) => {
352
+ _ru32[1] = parseInt(h.slice(2, 10), 16)
353
+ _ru32[0] = parseInt(h.slice(10), 16)
354
+ return _rf64[0]
355
+ }
245
356
  const i32FromF32 = (x) => { _rf32[0] = x; return _ri32[0] }
246
357
  const f32FromI32 = (x) => { _ri32[0] = x | 0; return _rf32[0] }
247
358
 
@@ -249,10 +360,10 @@ const f32FromI32 = (x) => { _ri32[0] = x | 0; return _rf32[0] }
249
360
  const i32c = (fn) => (a, b) => fn(a, b) ? 1 : 0
250
361
  /** Build unsigned i32 comparison folder */
251
362
  const u32c = (fn) => (a, b) => fn(a >>> 0, b >>> 0) ? 1 : 0
252
- /** Build i64 comparison folder */
253
- const i64c = (fn) => (a, b) => fn(a, b) ? 1 : 0
254
- /** Build unsigned i64 comparison folder */
255
- const u64c = (fn) => (a, b) => fn(BigInt.asUintN(64, a), BigInt.asUintN(64, b)) ? 1 : 0
363
+ /** Signed i64 comparison folder — biased-hex lexicographic (kernel-safe). */
364
+ const i64c = (fn) => (a, b) => fn(_sb(a), _sb(b)) ? 1 : 0
365
+ /** Unsigned i64 comparison folder — canonical hex compares lexicographically. */
366
+ const u64c = (fn) => (a, b) => fn(a, b) ? 1 : 0
256
367
 
257
368
  /**
258
369
  * Constant folders, keyed by op. Each entry is the fold function; the result
@@ -289,26 +400,48 @@ const FOLDABLE = {
289
400
  'i32.clz': (a) => Math.clz32(a),
290
401
  'i32.ctz': (a) => a === 0 ? 32 : 31 - Math.clz32(a & -a),
291
402
  'i32.popcnt': (a) => { let c = 0; while (a) { c += a & 1; a >>>= 1 } return c },
292
- 'i32.wrap_i64': (a) => Number(BigInt.asIntN(32, a)),
403
+ 'i32.wrap_i64': (a) => _i64Lo(a),
293
404
  'i32.extend8_s': (a) => (a << 24) >> 24,
294
405
  'i32.extend16_s': (a) => (a << 16) >> 16,
295
406
 
296
- // i64 (using BigInt)
297
- 'i64.add': (a, b) => BigInt.asIntN(64, a + b),
298
- 'i64.sub': (a, b) => BigInt.asIntN(64, a - b),
299
- 'i64.mul': (a, b) => BigInt.asIntN(64, a * b),
300
- 'i64.div_s': (a, b) => b !== 0n ? BigInt.asIntN(64, a / b) : null,
301
- 'i64.div_u': (a, b) => b !== 0n ? BigInt.asUintN(64, BigInt.asUintN(64, a) / BigInt.asUintN(64, b)) : null,
302
- 'i64.rem_s': (a, b) => b !== 0n ? BigInt.asIntN(64, a % b) : null,
303
- 'i64.rem_u': (a, b) => b !== 0n ? BigInt.asUintN(64, BigInt.asUintN(64, a) % BigInt.asUintN(64, b)) : null,
304
- 'i64.and': (a, b) => BigInt.asIntN(64, a & b),
305
- 'i64.or': (a, b) => BigInt.asIntN(64, a | b),
306
- 'i64.xor': (a, b) => BigInt.asIntN(64, a ^ b),
307
- 'i64.shl': (a, b) => BigInt.asIntN(64, a << (b & 63n)),
308
- 'i64.shr_s': (a, b) => BigInt.asIntN(64, a >> (b & 63n)),
309
- 'i64.shr_u': (a, b) => BigInt.asUintN(64, BigInt.asUintN(64, a) >> (b & 63n)),
310
- 'i64.eq': i64c((a, b) => a === b),
311
- 'i64.ne': i64c((a, b) => a !== b),
407
+ // i64 hex-string in, hex-string out, BOTH-WORLDS-EXACT arithmetic.
408
+ // BigInts construct locally (in-expression kernel kind erasure never
409
+ // applies), but two further kernel facts shape every folder:
410
+ // (1) the kernel's BigInt is the mod-2^64 i64 CARRIER: BigInt('0xffff…')
411
+ // arrives NEGATIVE there, so sign-sensitive ops (>>, /, %, unsigned
412
+ // division) diverge unless the value is sign-canonicalized first;
413
+ // (2) BigInt.asIntN/asUintN are unfaithful in-kernel never used.
414
+ // Ring ops {+,−,×,&,|,^,<<} are mod-2^64-compatible: compute then mask with
415
+ // `& 0xffffffffffffffffn` (native: the wrap; kernel: AND with −1 ≡ no-op).
416
+ // `_sgn` yields the SIGNED value in both worlds (the subtract arm is dead
417
+ // in-kernel same dead-arm trick as slebSize). shr_u is pure u32-half
418
+ // number math. div_u/rem_u fold only below 2^63 (signed==unsigned there);
419
+ // above, they skip — sound degradation, never a wrong constant.
420
+ 'i64.add': (a, b) => _i64Arith((BigInt(a) + BigInt(b)) & 0xffffffffffffffffn),
421
+ 'i64.sub': (a, b) => _i64Arith((BigInt(a) - BigInt(b)) & 0xffffffffffffffffn),
422
+ 'i64.mul': (a, b) => _i64Arith((BigInt(a) * BigInt(b)) & 0xffffffffffffffffn),
423
+ 'i64.div_s': (a, b) => b !== ZERO64 && !(a === '0x8000000000000000' && b === NEG164)
424
+ ? _i64Arith((_sgn(a) / _sgn(b)) & 0xffffffffffffffffn) : null,
425
+ 'i64.div_u': (a, b) => b !== ZERO64 && !(_i64HiU(a) >>> 31) && !(_i64HiU(b) >>> 31)
426
+ ? _i64Arith(BigInt(a) / BigInt(b)) : null,
427
+ 'i64.rem_s': (a, b) => b !== ZERO64
428
+ ? _i64Arith((_sgn(a) % _sgn(b)) & 0xffffffffffffffffn) : null,
429
+ 'i64.rem_u': (a, b) => b !== ZERO64 && !(_i64HiU(a) >>> 31) && !(_i64HiU(b) >>> 31)
430
+ ? _i64Arith(BigInt(a) % BigInt(b)) : null,
431
+ 'i64.and': (a, b) => _i64Arith(BigInt(a) & BigInt(b) & 0xffffffffffffffffn),
432
+ 'i64.or': (a, b) => _i64Arith((BigInt(a) | BigInt(b)) & 0xffffffffffffffffn),
433
+ 'i64.xor': (a, b) => _i64Arith((BigInt(a) ^ BigInt(b)) & 0xffffffffffffffffn),
434
+ 'i64.shl': (a, b) => _i64Arith((BigInt(a) << (BigInt(b) & 63n)) & 0xffffffffffffffffn),
435
+ 'i64.shr_s': (a, b) => _i64Arith((_sgn(a) >> (BigInt(b) & 63n)) & 0xffffffffffffffffn),
436
+ 'i64.shr_u': (a, b) => {
437
+ const s = parseInt(b.slice(10), 16) & 63
438
+ const hi = _i64HiU(a), lo = parseInt(a.slice(10), 16) >>> 0
439
+ const rh = s >= 32 ? 0 : hi >>> s
440
+ const rl = s === 0 ? lo : s >= 32 ? hi >>> (s - 32) : ((lo >>> s) | (hi << (32 - s))) >>> 0
441
+ return '0x' + _hex8(rh) + _hex8(rl)
442
+ },
443
+ 'i64.eq': (a, b) => a === b ? 1 : 0,
444
+ 'i64.ne': (a, b) => a !== b ? 1 : 0,
312
445
  'i64.lt_s': i64c((a, b) => a < b),
313
446
  'i64.lt_u': u64c((a, b) => a < b),
314
447
  'i64.gt_s': i64c((a, b) => a > b),
@@ -317,12 +450,12 @@ const FOLDABLE = {
317
450
  'i64.le_u': u64c((a, b) => a <= b),
318
451
  'i64.ge_s': i64c((a, b) => a >= b),
319
452
  'i64.ge_u': u64c((a, b) => a >= b),
320
- 'i64.eqz': (a) => a === 0n ? 1 : 0,
321
- 'i64.extend_i32_s': (a) => BigInt(a),
322
- 'i64.extend_i32_u': (a) => BigInt(a >>> 0),
323
- 'i64.extend8_s': (a) => BigInt.asIntN(64, BigInt.asIntN(8, a)),
324
- 'i64.extend16_s': (a) => BigInt.asIntN(64, BigInt.asIntN(16, a)),
325
- 'i64.extend32_s': (a) => BigInt.asIntN(64, BigInt.asIntN(32, a)),
453
+ 'i64.eqz': (a) => a === ZERO64 ? 1 : 0,
454
+ 'i64.extend_i32_s': (a) => '0x' + _hex8(a >> 31) + _hex8(a),
455
+ 'i64.extend_i32_u': (a) => '0x00000000' + _hex8(a),
456
+ 'i64.extend8_s': (a) => { const v = (_i64Lo(a) << 24) >> 24; return '0x' + _hex8(v >> 31) + _hex8(v) },
457
+ 'i64.extend16_s': (a) => { const v = (_i64Lo(a) << 16) >> 16; return '0x' + _hex8(v >> 31) + _hex8(v) },
458
+ 'i64.extend32_s': (a) => { const v = _i64Lo(a); return '0x' + _hex8(v >> 31) + _hex8(v) },
326
459
 
327
460
  // f32/f64 (NaN/precision-aware via Math.fround)
328
461
  'f32.add': (a, b) => Math.fround(a + b),
@@ -358,12 +491,14 @@ const FOLDABLE = {
358
491
  // Numeric conversions (value-preserving where representable)
359
492
  'f32.convert_i32_s': (a) => Math.fround(a | 0),
360
493
  'f32.convert_i32_u': (a) => Math.fround(a >>> 0),
361
- 'f32.convert_i64_s': (a) => Math.fround(Number(BigInt.asIntN(64, a))),
362
- 'f32.convert_i64_u': (a) => Math.fround(Number(BigInt.asUintN(64, a))),
494
+ // (hi|0)·2^32 + lo is the exact signed value with ONE rounding at the add —
495
+ // correct f64 conversion semantics, pure number math (kernel-safe).
496
+ 'f32.convert_i64_s': (a) => Math.fround((_i64HiU(a) | 0) * 4294967296 + parseInt(a.slice(10), 16)),
497
+ 'f32.convert_i64_u': (a) => Math.fround(_i64HiU(a) * 4294967296 + parseInt(a.slice(10), 16)),
363
498
  'f64.convert_i32_s': (a) => (a | 0),
364
499
  'f64.convert_i32_u': (a) => (a >>> 0),
365
- 'f64.convert_i64_s': (a) => Number(BigInt.asIntN(64, a)),
366
- 'f64.convert_i64_u': (a) => Number(BigInt.asUintN(64, a)),
500
+ 'f64.convert_i64_s': (a) => (_i64HiU(a) | 0) * 4294967296 + parseInt(a.slice(10), 16),
501
+ 'f64.convert_i64_u': (a) => _i64HiU(a) * 4294967296 + parseInt(a.slice(10), 16),
367
502
  'f32.demote_f64': (a) => Math.fround(a),
368
503
  'f64.promote_f32': (a) => Math.fround(a),
369
504
  }
@@ -375,11 +510,29 @@ const FOLDABLE = {
375
510
  * (jz, etc.) encode their pointer/sentinel bits into. Returns null if `s` is
376
511
  * not a NaN literal so callers can fall through to plain Number parsing.
377
512
  */
513
+ /** Full 64-bit hex of a WAT f64 NaN literal — pure string/number math, the
514
+ * payload double is NEVER materialized. In the self-host kernel a NaN-box bit
515
+ * pattern held as a raw f64 VALUE is indistinguishable from a live pointer
516
+ * (String / property reads misread it), so reinterpret folding must move the
517
+ * bits as TEXT. Returns '0x…' (16 digits) or null when `s` isn't a NaN literal. */
518
+ const _nanBitsHex = (s) => {
519
+ const i = s?.indexOf?.('nan')
520
+ if (i < 0 || i == null) return null
521
+ const tail = s.slice(i + 4).replaceAll('_', '')
522
+ const payload = (s[i + 3] === ':' && tail !== 'canonical' && tail !== 'arithmetic' ? BigInt(tail) : 0x8000000000000n)
523
+ const h = payload.toString(16).padStart(16, '0')
524
+ const hi = (parseInt(h.slice(0, 8), 16) | 0x7ff00000 | (s[0] === '-' ? 0x80000000 : 0)) >>> 0
525
+ return '0x' + _hex8(hi) + h.slice(8)
526
+ }
527
+
378
528
  const _parseNanF64 = (s, i = s?.indexOf?.('nan')) => {
379
529
  if (i < 0 || i == null) return null
380
- let tail = s.slice(i + 4).replaceAll('_', ''),
381
- bits = (s[i + 3] === ':' && tail !== 'canonical' && tail !== 'arithmetic' ? BigInt(tail) : 0x8000000000000n)
382
- _ri64[0] = BigInt.asIntN(64, bits | 0x7ff0000000000000n | (s[0] === '-' ? 1n << 63n : 0n))
530
+ const tail = s.slice(i + 4).replaceAll('_', '')
531
+ const payload = (s[i + 3] === ':' && tail !== 'canonical' && tail !== 'arithmetic' ? BigInt(tail) : 0x8000000000000n)
532
+ // Assemble exponent/sign on the u32 halves kernel-safe (BigInt <</| are not).
533
+ const h = payload.toString(16).padStart(16, '0')
534
+ _ru32[1] = (parseInt(h.slice(0, 8), 16) | 0x7ff00000 | (s[0] === '-' ? 0x80000000 : 0)) >>> 0
535
+ _ru32[0] = parseInt(h.slice(8), 16)
383
536
  return _rf64[0]
384
537
  }
385
538
  const _parseNanF32 = (s, i = s?.indexOf?.('nan')) => {
@@ -398,15 +551,25 @@ const _parseNanF32 = (s, i = s?.indexOf?.('nan')) => {
398
551
  const getConst = (node) => {
399
552
  if (!Array.isArray(node) || node.length !== 2) return null
400
553
  const [op, val] = node
401
- if (op === 'i32.const') return { type: 'i32', value: (typeof val === 'string' ? i32.parse(val) : val) | 0 }
402
- if (op === 'i64.const') return { type: 'i64', value: typeof val === 'string' ? i64.parse(val) : BigInt(val) }
554
+ if (op === 'i32.const') return { type: 'i32', value: (typeof val === 'string' ? parseInt(val.replaceAll('_', '')) : val) | 0 }
555
+ if (op === 'i64.const') return { type: 'i64', value: _i64Canon(val) }
403
556
  if (op === 'f32.const') {
404
557
  const n = _parseNanF32(val)
405
558
  return { type: 'f32', value: n !== null ? n : Math.fround(Number(val)) }
406
559
  }
407
560
  if (op === 'f64.const') {
408
561
  const n = _parseNanF64(val)
409
- return { type: 'f64', value: n !== null ? n : Number(val) }
562
+ const v = n !== null ? n : Number(val)
563
+ // Normalize ANY NaN to the literal NaN — Number.isNaN, NOT `v !== v`:
564
+ // in-kernel `!==` routes through __eq's bit-equality, where a sign-set
565
+ // qNaN (what x64 wasm arithmetic produces) compares EQUAL to itself (the
566
+ // arm that keeps negative i64-carrier BigInts working), so the !== guard
567
+ // misses it. Number.isNaN unboxes to f64 and uses f64.ne — catches every
568
+ // payload. The literal-NaN assignment rewrites the carrier to the
569
+ // canonical atom, so the value can ride kind-erased slots safely (the
570
+ // linux-x64-only selfhost OOB; arm64 arithmetic NaNs are already
571
+ // canonical). Native no-op.
572
+ return { type: 'f64', value: Number.isNaN(v) ? NaN : v }
410
573
  }
411
574
  return null
412
575
  }
@@ -419,9 +582,14 @@ const getConst = (node) => {
419
582
  */
420
583
  const makeConst = (type, value) => {
421
584
  if (type === 'i32') return ['i32.const', value | 0]
422
- if (type === 'i64') return ['i64.const', value]
423
- if (type === 'f32') return ['f32.const', Math.fround(value)]
424
- if (type === 'f64') return ['f64.const', value]
585
+ if (type === 'i64') return ['i64.const', typeof value === 'number' ? value : _i64Canon(value)] // canonical hex: kernel-safe print, exact round-trip
586
+ // NaN travels as the `nan` TOKEN, never a raw number: the canonical-NaN bit
587
+ // pattern (0x7FF8…) IS the NaN-box ATOM prefix, so a raw NaN node value
588
+ // inside the self-host kernel reads as a pointer and dereferences OOB
589
+ // (same contract as emitNum — folding Math.sqrt(-1) used to trap the
590
+ // kernel's L2 compile). ±Infinity is outside the box space — safe raw.
591
+ if (type === 'f32') { const v = Math.fround(value); return ['f32.const', Number.isNaN(v) ? 'nan' : v] }
592
+ if (type === 'f64') return ['f64.const', Number.isNaN(value) ? 'nan' : value]
425
593
  return null
426
594
  }
427
595
 
@@ -436,20 +604,47 @@ const fold = (ast) => {
436
604
  const fn = FOLDABLE[node[0]]
437
605
  if (!fn) return
438
606
 
607
+ // Arity comes from the NODE — every WAT op is fixed-arity, so node.length
608
+ // fully determines unary vs binary. NEVER from Function.length: the
609
+ // self-host kernel's closures don't carry a faithful `.length`, and the
610
+ // old `fn.length === 1/2` checks silently disabled ALL folding in-kernel
611
+ // (the L2 self-host divergence — unfolded consts fed the vectorizer
612
+ // shapes native never produces).
439
613
  // Unary
440
- if (fn.length === 1 && node.length === 2) {
614
+ if (node.length === 2) {
615
+ // NaN-payload reinterprets fold at the TEXT level — the payload double
616
+ // must never ride as a raw f64 value (see _nanBitsHex). Applies in both
617
+ // directions: nan: literal → i64 bits, and NaN-pattern i64 → nan: literal.
618
+ if (node[0] === 'i64.reinterpret_f64') {
619
+ const inner = node[1]
620
+ if (Array.isArray(inner) && inner.length === 2 && inner[0] === 'f64.const' && typeof inner[1] === 'string') {
621
+ const bits = _nanBitsHex(inner[1])
622
+ if (bits) return ['i64.const', bits]
623
+ }
624
+ }
625
+ if (node[0] === 'f64.reinterpret_i64') {
626
+ const c = getConst(node[1])
627
+ if (c && c.type === 'i64') {
628
+ const h = c.value.slice(2)
629
+ const hi = parseInt(h.slice(0, 8), 16) >>> 0
630
+ const lo = parseInt(h.slice(8), 16) >>> 0
631
+ const isNaN64 = (hi & 0x7ff00000) === 0x7ff00000 && ((hi & 0xfffff) !== 0 || lo !== 0)
632
+ if (isNaN64) return ['f64.const',
633
+ ((hi & 0x80000000) !== 0 ? '-' : '') + 'nan:0x' + (hi & 0xfffff).toString(16).padStart(5, '0') + h.slice(8)]
634
+ }
635
+ }
441
636
  const a = getConst(node[1])
442
637
  if (!a) return
443
638
  const r = fn(a.value)
444
- if (r === null) return
639
+ if (r === null || r === undefined) return
445
640
  return makeConst(resultType(node[0]), r)
446
641
  }
447
642
  // Binary
448
- if (fn.length === 2 && node.length === 3) {
643
+ if (node.length === 3) {
449
644
  const a = getConst(node[1]), b = getConst(node[2])
450
645
  if (!a || !b) return
451
646
  const r = fn(a.value, b.value)
452
- if (r === null) return
647
+ if (r === null || r === undefined) return
453
648
  return makeConst(resultType(node[0]), r)
454
649
  }
455
650
  })
@@ -478,34 +673,34 @@ const rightIdentity = (neutral) => (a, b) => getConst(b)?.value === neutral ? a
478
673
  const IDENTITIES = {
479
674
  // x + 0 → x, 0 + x → x
480
675
  'i32.add': commutativeIdentity(0),
481
- 'i64.add': commutativeIdentity(0n),
676
+ 'i64.add': commutativeIdentity(ZERO64),
482
677
  // x - 0 → x
483
678
  'i32.sub': rightIdentity(0),
484
- 'i64.sub': rightIdentity(0n),
679
+ 'i64.sub': rightIdentity(ZERO64),
485
680
  // x * 1 → x, 1 * x → x
486
681
  'i32.mul': commutativeIdentity(1),
487
- 'i64.mul': commutativeIdentity(1n),
682
+ 'i64.mul': commutativeIdentity(ONE64),
488
683
  // x / 1 → x
489
684
  'i32.div_s': rightIdentity(1),
490
685
  'i32.div_u': rightIdentity(1),
491
- 'i64.div_s': rightIdentity(1n),
492
- 'i64.div_u': rightIdentity(1n),
686
+ 'i64.div_s': rightIdentity(ONE64),
687
+ 'i64.div_u': rightIdentity(ONE64),
493
688
  // x & -1 → x, -1 & x → x (all bits set)
494
689
  'i32.and': commutativeIdentity(-1),
495
- 'i64.and': commutativeIdentity(-1n),
690
+ 'i64.and': commutativeIdentity(NEG164),
496
691
  // x | 0 → x, 0 | x → x
497
692
  'i32.or': commutativeIdentity(0),
498
- 'i64.or': commutativeIdentity(0n),
693
+ 'i64.or': commutativeIdentity(ZERO64),
499
694
  // x ^ 0 → x, 0 ^ x → x
500
695
  'i32.xor': commutativeIdentity(0),
501
- 'i64.xor': commutativeIdentity(0n),
696
+ 'i64.xor': commutativeIdentity(ZERO64),
502
697
  // x << 0 → x, x >> 0 → x
503
698
  'i32.shl': rightIdentity(0),
504
699
  'i32.shr_s': rightIdentity(0),
505
700
  'i32.shr_u': rightIdentity(0),
506
- 'i64.shl': rightIdentity(0n),
507
- 'i64.shr_s': rightIdentity(0n),
508
- 'i64.shr_u': rightIdentity(0n),
701
+ 'i64.shl': rightIdentity(ZERO64),
702
+ 'i64.shr_s': rightIdentity(ZERO64),
703
+ 'i64.shr_u': rightIdentity(ZERO64),
509
704
  // f + 0 → x (careful with -0.0, skip for floats)
510
705
  // f * 1 → x (careful with NaN, skip for floats)
511
706
  }
@@ -552,16 +747,14 @@ const strength = (ast) => {
552
747
  }
553
748
  }
554
749
  if (op === 'i64.mul') {
555
- const cb = getConst(b)
556
- if (cb && cb.value > 0n && (cb.value & (cb.value - 1n)) === 0n) {
557
- const shift = BigInt(cb.value.toString(2).length - 1)
558
- return ['i64.shl', a, ['i64.const', shift]]
559
- }
560
- const ca = getConst(a)
561
- if (ca && ca.value > 0n && (ca.value & (ca.value - 1n)) === 0n) {
562
- const shift = BigInt(ca.value.toString(2).length - 1)
563
- return ['i64.shl', b, ['i64.const', shift]]
564
- }
750
+ // hex value → LOCAL BigInt (in-expression construction is kernel-safe);
751
+ // shift counts emit as plain numbers.
752
+ const cb = getConst(b), vb = cb ? BigInt(cb.value) : null
753
+ if (vb != null && vb > 0n && (vb & (vb - 1n)) === 0n)
754
+ return ['i64.shl', a, ['i64.const', vb.toString(2).length - 1]]
755
+ const ca = getConst(a), va = ca ? BigInt(ca.value) : null
756
+ if (va != null && va > 0n && (va & (va - 1n)) === 0n)
757
+ return ['i64.shl', b, ['i64.const', va.toString(2).length - 1]]
565
758
  }
566
759
 
567
760
  // x / 2^n → x >> n (unsigned only, signed division is more complex)
@@ -573,11 +766,9 @@ const strength = (ast) => {
573
766
  }
574
767
  }
575
768
  if (op === 'i64.div_u') {
576
- const cb = getConst(b)
577
- if (cb && cb.value > 0n && (cb.value & (cb.value - 1n)) === 0n) {
578
- const shift = BigInt(cb.value.toString(2).length - 1)
579
- return ['i64.shr_u', a, ['i64.const', shift]]
580
- }
769
+ const cb = getConst(b), vb = cb ? BigInt(cb.value) : null
770
+ if (vb != null && vb > 0n && (vb & (vb - 1n)) === 0n)
771
+ return ['i64.shr_u', a, ['i64.const', vb.toString(2).length - 1]]
581
772
  }
582
773
 
583
774
  // x % 2^n → x & (2^n - 1) (unsigned only)
@@ -588,10 +779,9 @@ const strength = (ast) => {
588
779
  }
589
780
  }
590
781
  if (op === 'i64.rem_u') {
591
- const cb = getConst(b)
592
- if (cb && cb.value > 0n && (cb.value & (cb.value - 1n)) === 0n) {
593
- return ['i64.and', a, ['i64.const', cb.value - 1n]]
594
- }
782
+ const cb = getConst(b), vb = cb ? BigInt(cb.value) : null
783
+ if (vb != null && vb > 0n && (vb & (vb - 1n)) === 0n)
784
+ return ['i64.and', a, ['i64.const', '0x' + _i64Hex16(vb - 1n)]]
595
785
  }
596
786
  })
597
787
  }
@@ -611,12 +801,18 @@ const branch = (ast) => {
611
801
  // (if (i32.const 0) then else) → else
612
802
  // (if (i32.const N) then else) → then (N != 0)
613
803
  if (op === 'if') {
614
- const { cond, thenBranch, elseBranch } = parseIf(node)
804
+ const { condIdx, cond, thenBranch, elseBranch } = parseIf(node)
615
805
  const c = getConst(cond)
616
806
  if (!c) return
617
- const taken = c.value !== 0 && c.value !== 0n ? thenBranch : elseBranch
807
+ const taken = c.value !== 0 && c.value !== ZERO64 ? thenBranch : elseBranch
618
808
  if (taken && taken.length > 1) {
619
809
  const contents = taken.slice(1)
810
+ // Preserve the if's block type (result/param). A typed `if` leaves a value
811
+ // on the stack; collapsing it to the taken branch must keep that branch's
812
+ // value in a same-typed block, else the contents land in a void context and
813
+ // the value is left dangling → "expected 0 elements on the stack for fallthru".
814
+ const blockType = node.slice(1, condIdx).filter(p => Array.isArray(p) && (p[0] === 'result' || p[0] === 'param'))
815
+ if (blockType.length) return ['block', ...blockType, ...contents]
620
816
  return contents.length === 1 ? contents[0] : ['block', ...contents]
621
817
  }
622
818
  return ['nop']
@@ -628,7 +824,7 @@ const branch = (ast) => {
628
824
  const cond = node[node.length - 1]
629
825
  const c = getConst(cond)
630
826
  if (!c) return
631
- if (c.value === 0 || c.value === 0n) return ['nop']
827
+ if (c.value === 0 || c.value === ZERO64) return ['nop']
632
828
  return ['br', node[1]]
633
829
  }
634
830
 
@@ -638,12 +834,209 @@ const branch = (ast) => {
638
834
  const cond = node[node.length - 1]
639
835
  const c = getConst(cond)
640
836
  if (!c) return
641
- if (c.value === 0 || c.value === 0n) return node[2] // b
837
+ if (c.value === 0 || c.value === ZERO64) return node[2] // b
642
838
  return node[1] // a
643
839
  }
644
840
  })
645
841
  }
646
842
 
843
+ // ==================== GUARD-AWARE TAG REFINEMENT ====================
844
+
845
+ /**
846
+ * Fold NaN-box tag reads under dominating tag guards (jz-domain knowledge).
847
+ *
848
+ * jz reads a value's 4-bit NaN-box tag in three equivalent forms:
849
+ * A. (i32.and (i32.wrap_i64 (i64.shr_u PTR (i64.const 47))) (i32.const 15))
850
+ * B. (i32.wrap_i64 (i64.and (i64.shr_u PTR (i64.const 47)) (i64.const 15)))
851
+ * C. (call $__ptr_type PTR)
852
+ * where PTR is (i64.reinterpret_f64 (local.get $X)) or an i64 local copy of it.
853
+ *
854
+ * After `inlineOnce` splices a generic helper (e.g. $__len's 5-way tag
855
+ * dispatch) into an arm already guarded by `tag(X) == K`, the recomputed tag
856
+ * is a known constant — but no structural pass can see it: forms A and B
857
+ * differ shape-wise, and the value flows through reinterpret/copy locals.
858
+ * This pass tracks tag-of-X facts through if-arms and folds tag reads to
859
+ * constants; the regular fold/branch/vacuum passes then delete the dead
860
+ * dispatch arms. This is the single biggest source of wasm-opt's remaining
861
+ * slack on jz output (~10% on typed-array modules).
862
+ *
863
+ * Soundness model — facts and aliases are keyed by the f64 SOURCE local $X
864
+ * (tags live in the value's bits, so only local writes can invalidate, never
865
+ * calls/stores):
866
+ * - any local.set/tee of $X kills its fact and every alias derived from it
867
+ * - leaving a block kills facts/aliases for locals written inside it
868
+ * (a br may have skipped the write)
869
+ * - entering a loop kills facts for locals written anywhere in it
870
+ * (the back edge re-enters after the write)
871
+ * - then/else facts are layered over a snapshot and restored on exit;
872
+ * writes inside either arm kill outer facts afterward
873
+ * - within straight-line code, sequential registration is exact: wasm has
874
+ * no goto, so execution between branch points is linear
875
+ */
876
+ const guardRefine = (ast) => {
877
+ if (Array.isArray(ast)) for (const node of ast) if (Array.isArray(node) && node[0] === 'func') refineGuards(node)
878
+ return ast
879
+ }
880
+
881
+ const EMPTY_SET = new Set()
882
+
883
+ const refineGuards = (fn) => {
884
+ const ptrAlias = new Map() // i64 local → f64 source local (reinterpret copy)
885
+ const tagAlias = new Map() // i32 local → f64 source local (holds tag(X))
886
+ const eqFact = new Map() // f64 local → known tag K
887
+ const neFact = new Map() // f64 local → Set of excluded tags
888
+
889
+ const intVal = (n) => {
890
+ if (!Array.isArray(n) || n.length !== 2 || (n[0] !== 'i32.const' && n[0] !== 'i64.const')) return null
891
+ const v = typeof n[1] === 'string' ? Number(n[1].replaceAll('_', '')) : Number(n[1])
892
+ return Number.isFinite(v) ? v : null
893
+ }
894
+ const i32Val = (n) => Array.isArray(n) && n[0] === 'i32.const' ? intVal(n) : null
895
+
896
+ // PTR node → f64 source local, or null.
897
+ const ptrSrc = (n) => {
898
+ if (!Array.isArray(n)) return null
899
+ if (n[0] === 'i64.reinterpret_f64' && Array.isArray(n[1]) && n[1][0] === 'local.get' && typeof n[1][1] === 'string') return n[1][1]
900
+ if (n[0] === 'local.get' && typeof n[1] === 'string') return ptrAlias.get(n[1]) ?? null
901
+ return null
902
+ }
903
+ // tag-of-X node (forms A/B/C or a tag-alias local read) → X, or null.
904
+ const tagSrc = (n) => {
905
+ if (!Array.isArray(n)) return null
906
+ const op = n[0]
907
+ if (op === 'local.get' && typeof n[1] === 'string') return tagAlias.get(n[1]) ?? null
908
+ if (op === 'call' && n[1] === '$__ptr_type' && n.length === 3) return ptrSrc(n[2])
909
+ const shifted = (m) => Array.isArray(m) && m[0] === 'i64.shr_u' && intVal(m[2]) === 47 ? ptrSrc(m[1]) : null
910
+ if (op === 'i32.and' && n.length === 3) { // form A (mask either side)
911
+ const [a, b] = i32Val(n[2]) === 15 ? [n[1], null] : i32Val(n[1]) === 15 ? [n[2], null] : [null, null]
912
+ if (a && Array.isArray(a) && a[0] === 'i32.wrap_i64') return shifted(a[1])
913
+ }
914
+ if (op === 'i32.wrap_i64' && Array.isArray(n[1]) && n[1][0] === 'i64.and') { // form B
915
+ const m = n[1]
916
+ if (intVal(m[2]) === 15) return shifted(m[1])
917
+ if (intVal(m[1]) === 15) return shifted(m[2])
918
+ }
919
+ return null
920
+ }
921
+
922
+ const killLocal = (name) => {
923
+ ptrAlias.delete(name); tagAlias.delete(name); eqFact.delete(name); neFact.delete(name)
924
+ for (const [p, x] of ptrAlias) if (x === name) ptrAlias.delete(p)
925
+ for (const [t, x] of tagAlias) if (x === name) tagAlias.delete(t)
926
+ }
927
+ // Write-sets are queried per if/loop/block; memoize bottom-up so each node is
928
+ // visited once per function, not once per enclosing construct. Keyed by node
929
+ // identity — sound because walkSeq only ever *replaces* whole subtrees
930
+ // (parent[idx] = const), never adds writes to an existing one.
931
+ const writesMemo = new Map()
932
+ const writesOf = (n) => {
933
+ if (!Array.isArray(n)) return EMPTY_SET
934
+ let s = writesMemo.get(n)
935
+ if (s) return s
936
+ s = new Set()
937
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string') s.add(n[1])
938
+ for (let i = 1; i < n.length; i++) for (const w of writesOf(n[i])) s.add(w)
939
+ writesMemo.set(n, s)
940
+ return s
941
+ }
942
+ const snap = () => [new Map(eqFact), new Map([...neFact].map(([k, s]) => [k, new Set(s)])), new Map(ptrAlias), new Map(tagAlias)]
943
+ const reset = (m, src) => { m.clear(); for (const [k, v] of src) m.set(k, v) }
944
+ const restore = ([e, n, p, t]) => { reset(eqFact, e); reset(neFact, n); reset(ptrAlias, p); reset(tagAlias, t) }
945
+
946
+ // Facts implied by `cond` being truthy (sense=true) / falsy (sense=false).
947
+ const condFacts = (cond, sense, out) => {
948
+ if (!Array.isArray(cond)) return out
949
+ const op = cond[0]
950
+ if (op === 'i32.eqz') return condFacts(cond[1], !sense, out)
951
+ if (op === 'i32.and' && sense && cond.length === 3) { condFacts(cond[1], true, out); condFacts(cond[2], true, out); return out }
952
+ if (op === 'i32.or' && !sense && cond.length === 3) { condFacts(cond[1], false, out); condFacts(cond[2], false, out); return out }
953
+ if ((op === 'i32.eq' || op === 'i32.ne') && cond.length === 3) {
954
+ let x = tagSrc(cond[1]), k = i32Val(cond[2])
955
+ if (x == null || k == null) { x = tagSrc(cond[2]); k = i32Val(cond[1]) }
956
+ if (x != null && k != null) out.push({ x, k, eq: (op === 'i32.eq') === sense })
957
+ return out
958
+ }
959
+ const x = tagSrc(cond) // bare tag as condition: truthy ⇒ tag≠0, falsy ⇒ tag==0
960
+ if (x != null) out.push({ x, k: 0, eq: !sense })
961
+ return out
962
+ }
963
+ const addFacts = (fs) => {
964
+ for (const { x, k, eq } of fs) {
965
+ if (eq) eqFact.set(x, k)
966
+ else { let s = neFact.get(x); if (!s) neFact.set(x, s = new Set()); s.add(k) }
967
+ }
968
+ }
969
+
970
+ const walkSeq = (node, parent, idx) => {
971
+ if (!Array.isArray(node)) return
972
+ const op = node[0]
973
+
974
+ if (op === 'local.set' || op === 'local.tee') {
975
+ if (Array.isArray(node[2])) walkSeq(node[2], node, 2)
976
+ const name = node[1]
977
+ if (typeof name !== 'string') return
978
+ killLocal(name)
979
+ const v = node[2]
980
+ if (Array.isArray(v)) {
981
+ if (v[0] === 'i64.reinterpret_f64' && Array.isArray(v[1]) && v[1][0] === 'local.get' && typeof v[1][1] === 'string') ptrAlias.set(name, v[1][1])
982
+ else if (v[0] === 'local.get' && typeof v[1] === 'string' && ptrAlias.has(v[1])) ptrAlias.set(name, ptrAlias.get(v[1]))
983
+ else { const tx = tagSrc(v); if (tx != null) tagAlias.set(name, tx) }
984
+ }
985
+ return
986
+ }
987
+
988
+ if (op === 'if') {
989
+ const { condIdx } = parseIf(node)
990
+ if (Array.isArray(node[condIdx])) walkSeq(node[condIdx], node, condIdx)
991
+ const cond = node[condIdx] // re-read: the walk may have folded it
992
+ const { thenBranch, elseBranch } = parseIf(node)
993
+ const writes = writesOf(node)
994
+ const pre = snap()
995
+ addFacts(condFacts(cond, true, []))
996
+ if (thenBranch) for (let i = 1; i < thenBranch.length; i++) walkSeq(thenBranch[i], thenBranch, i)
997
+ restore(pre)
998
+ addFacts(condFacts(cond, false, []))
999
+ if (elseBranch) for (let i = 1; i < elseBranch.length; i++) walkSeq(elseBranch[i], elseBranch, i)
1000
+ restore(pre)
1001
+ for (const w of writes) killLocal(w)
1002
+ return
1003
+ }
1004
+
1005
+ if (op === 'loop') {
1006
+ const writes = writesOf(node)
1007
+ for (const w of writes) killLocal(w)
1008
+ for (let i = 1; i < node.length; i++) walkSeq(node[i], node, i)
1009
+ for (const w of writes) killLocal(w)
1010
+ return
1011
+ }
1012
+
1013
+ if (op === 'block') {
1014
+ for (let i = 1; i < node.length; i++) walkSeq(node[i], node, i)
1015
+ for (const w of writesOf(node)) killLocal(w)
1016
+ return
1017
+ }
1018
+
1019
+ // Whole-node tag read under an equality fact → constant.
1020
+ const tx = tagSrc(node)
1021
+ if (tx != null && eqFact.has(tx) && parent) { parent[idx] = ['i32.const', eqFact.get(tx)]; return }
1022
+
1023
+ // eq/ne against a constant under a ne-fact (eq-facts are covered by the
1024
+ // tag-read fold above plus the regular `fold` pass).
1025
+ if ((op === 'i32.eq' || op === 'i32.ne') && node.length === 3) {
1026
+ let x = tagSrc(node[1]), k = i32Val(node[2])
1027
+ if (x == null || k == null) { x = tagSrc(node[2]); k = i32Val(node[1]) }
1028
+ if (x != null && k != null && neFact.get(x)?.has(k) && parent) {
1029
+ parent[idx] = ['i32.const', op === 'i32.eq' ? 0 : 1]
1030
+ return
1031
+ }
1032
+ }
1033
+
1034
+ for (let i = 1; i < node.length; i++) walkSeq(node[i], node, i)
1035
+ }
1036
+
1037
+ for (let i = 1; i < fn.length; i++) walkSeq(fn[i], fn, i)
1038
+ }
1039
+
647
1040
  // ==================== DEAD CODE ELIMINATION ====================
648
1041
 
649
1042
  /** Control flow terminators */
@@ -814,6 +1207,38 @@ const isPure = (node) => {
814
1207
  return true
815
1208
  }
816
1209
 
1210
+ // Structured / control-flow forms: they do NOT evaluate all children eagerly
1211
+ // (an `if` runs one arm; a `block`/`loop` scopes branches), so their side effects
1212
+ // can't be flattened to the children's — they stay whole under a drop. (`br*`,
1213
+ // `try_table` are already in IMPURE_OPS.)
1214
+ const STRUCTURED_OPS = new Set(['if', 'then', 'else', 'block', 'loop', 'try'])
1215
+
1216
+ // `op` is an EAGER value operation: it evaluates every operand unconditionally
1217
+ // and only computes a result (arithmetic, compare, convert, select, load) — so
1218
+ // discarding its value leaves just the operands' side effects. Excludes impure
1219
+ // ops and the structured forms above.
1220
+ const isEagerValueOp = (op) => typeof op === 'string' && !IMPURE_OPS.has(op) &&
1221
+ !STRUCTURED_OPS.has(op) && !IMPURE_SUBSTRINGS.some(s => op.includes(s))
1222
+
1223
+ // Statements that preserve `node`'s side effects when its VALUE is discarded.
1224
+ // A fully-pure value contributes nothing; an eager value op contributes only its
1225
+ // operands' effects (the op result is dead); a `local.tee` keeps the store as a
1226
+ // `local.set`; anything else (call, store-expr, structured form) stays under a drop.
1227
+ // This turns `drop(i32.sub(tee X V, 1))` — the post-increment's dropped old value
1228
+ // — into the bare `local.set X V`, eliminating dead arithmetic the plain
1229
+ // `drop(PURE)→nop` rule can't (the tee makes the whole subtree impure).
1230
+ const dropEffects = (node) => {
1231
+ if (!Array.isArray(node) || isPure(node)) return []
1232
+ const op = node[0]
1233
+ if (op === 'local.tee' && node.length === 3) return [['local.set', node[1], node[2]]]
1234
+ if (isEagerValueOp(op)) {
1235
+ const eff = []
1236
+ for (let i = 1; i < node.length; i++) eff.push(...dropEffects(node[i]))
1237
+ return eff
1238
+ }
1239
+ return [['drop', node]]
1240
+ }
1241
+
817
1242
  /** Count all local.get/set/tee occurrences in one walk */
818
1243
  const countLocalUses = (node) => {
819
1244
  const counts = new Map()
@@ -835,16 +1260,30 @@ const isTinyConst = (node) => {
835
1260
  const c = getConst(node)
836
1261
  if (!c) return false
837
1262
  if (c.type === 'i32') { const v = c.value | 0; return v >= -64 && v <= 63 }
838
- if (c.type === 'i64') { const v = typeof c.value === 'bigint' ? c.value : BigInt(c.value); return v >= -64n && v <= 63n }
1263
+ if (c.type === 'i64') { const v = BigInt(c.value); return v <= 63n || v >= 0xffffffffffffffc0n } // unsigned bits: [0,63] or two's-comp [−64,−1]
839
1264
  return false
840
1265
  }
841
1266
 
1267
+ /** A pure local→local copy value `(local.get $src)`, with $src ≠ the local being set.
1268
+ * Substituting it for a `(local.get $dst)` is byte-neutral (local.get for local.get),
1269
+ * so — unlike a reused wide constant — it can never grow an instruction, and it turns
1270
+ * the copy `$dst = $src` into a dead store the next pass drops. Self-copies are
1271
+ * excluded: they're no-ops that would re-trigger `changed` every round. (Propagating a
1272
+ * copy lengthens $src's live range, which can rarely cost coalesceLocals a slot — a
1273
+ * few bytes — but net-shrinks across the corpus, e.g. −1.7 KB on the watr self-host.) */
1274
+ const isLocalCopy = (val, dest) =>
1275
+ Array.isArray(val) && val[0] === 'local.get' && val.length === 2 &&
1276
+ typeof val[1] === 'string' && val[1] !== dest
1277
+
842
1278
  /** Can this tracked value be substituted for a local.get?
843
1279
  * - single use of a pure value: always shrinks (drops the set, the lone get, the decl);
844
- * - any use of a tiny constant: byte-neutral at worst, still drops the set + decl.
1280
+ * - any use of a tiny constant: byte-neutral at worst, still drops the set + decl;
1281
+ * - any use of a pure local copy: byte-neutral, frees the copy as a dead store.
845
1282
  * Anything else (a wide constant reused many times, an impure expr) could inflate
846
- * or reorder side effects, so it's left alone. */
847
- const canSubst = (k) => (k.pure && k.singleUse) || isTinyConst(k.val)
1283
+ * or reorder side effects, so it's left alone. Copy validity (the source not being
1284
+ * reassigned between copy and use) is enforced by the same purgeRefs/branch-clear
1285
+ * machinery that guards every tracked value. */
1286
+ const canSubst = (k) => (k.pure && k.singleUse) || isTinyConst(k.val) || k.copy
848
1287
 
849
1288
  /** Drop tracked values that read `$name`: rewriting `$name` makes them stale. */
850
1289
  const purgeRefs = (known, name) => {
@@ -855,6 +1294,19 @@ const purgeRefs = (known, name) => {
855
1294
  }
856
1295
  }
857
1296
 
1297
+ /** Drop tracked values that read global `$name`: a `global.set $name` makes them stale.
1298
+ * The local-only {@link purgeRefs} misses this — so a value captured from a global
1299
+ * (`let s = f`, where `f` is a reassignable module-level binding) would survive an
1300
+ * intervening `f = …` and substitute the NEW global. That silently breaks the canonical
1301
+ * pointer swap `let s = f; f = g; g = s` (g would read post-swap f, i.e. itself). */
1302
+ const purgeGlobalRefs = (known, name) => {
1303
+ for (const [key, tracked] of known) {
1304
+ let refs = false
1305
+ walk(tracked.val, n => { if (Array.isArray(n) && n[0] === 'global.get' && n[1] === name) refs = true })
1306
+ if (refs) known.delete(key)
1307
+ }
1308
+ }
1309
+
858
1310
  /** True if `node` recursively contains an op that may read linear memory.
859
1311
  * Tracked values whose RHS reads memory go stale after any intervening
860
1312
  * memory-mutating op (`*.store`, `memory.copy/fill/init`, atomic stores/rmw). */
@@ -915,7 +1367,7 @@ const substGets = (node, known) => {
915
1367
  return node
916
1368
  }
917
1369
  let inner = known
918
- if (op === 'block' || op === 'loop' || op === 'if') {
1370
+ if (isBranchScope(op)) {
919
1371
  let cloned = null
920
1372
  walk(node, n => {
921
1373
  if (!Array.isArray(n)) return
@@ -942,6 +1394,9 @@ const substGets = (node, known) => {
942
1394
  if (inner === known) inner = new Map(known)
943
1395
  inner.delete(n[1])
944
1396
  purgeRefs(inner, n[1])
1397
+ } else if (n[0] === 'global.set' && typeof n[1] === 'string') { // same staleness as a local write — a sibling operand's
1398
+ if (inner === known) inner = new Map(known) // global write invalidates a later operand's global-sourced copy
1399
+ purgeGlobalRefs(inner, n[1])
945
1400
  }
946
1401
  })
947
1402
  }
@@ -984,6 +1439,7 @@ const forwardPropagate = (funcNode, params, useCounts) => {
984
1439
  if (!Array.isArray(n)) return
985
1440
  if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
986
1441
  { known.delete(n[1]); purgeRefs(known, n[1]) }
1442
+ else if (n[0] === 'global.set' && typeof n[1] === 'string') purgeGlobalRefs(known, n[1])
987
1443
  })
988
1444
  const uses = getUseCount(instr[1])
989
1445
  purgeRefs(known, instr[1]) // entries that read this local just went stale
@@ -996,13 +1452,14 @@ const forwardPropagate = (funcNode, params, useCounts) => {
996
1452
  known.set(instr[1], {
997
1453
  val: instr[2], pure: isPure(instr[2]),
998
1454
  readsMem: readsMemory(instr[2]),
999
- singleUse: uses.gets <= 1 && uses.sets <= 1 && uses.tees === 0
1455
+ singleUse: uses.gets <= 1 && uses.sets <= 1 && uses.tees === 0,
1456
+ copy: isLocalCopy(instr[2], instr[1])
1000
1457
  })
1001
1458
  continue
1002
1459
  }
1003
1460
 
1004
1461
  // Invalidate at control-flow boundaries
1005
- if (op === 'block' || op === 'loop' || op === 'if') known.clear()
1462
+ if (isBranchScope(op)) known.clear()
1006
1463
  // Calls invalidate tracked values that read state a callee can mutate
1007
1464
  // (memory, globals, tables, nested calls). Pure expressions over locals
1008
1465
  // and constants survive — callees can't reach caller locals.
@@ -1029,8 +1486,10 @@ const forwardPropagate = (funcNode, params, useCounts) => {
1029
1486
  // pre-write tracked value (correct), but later reads must see the new
1030
1487
  // (untracked) value, not the stale constant.
1031
1488
  walk(instr, n => {
1032
- if (Array.isArray(n) && (n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
1489
+ if (!Array.isArray(n)) return
1490
+ if ((n[0] === 'local.set' || n[0] === 'local.tee') && typeof n[1] === 'string')
1033
1491
  { known.delete(n[1]); purgeRefs(known, n[1]) }
1492
+ else if (n[0] === 'global.set' && typeof n[1] === 'string') purgeGlobalRefs(known, n[1])
1034
1493
  })
1035
1494
  // Memory write in this statement (any nested store / memory.copy / etc.)
1036
1495
  // invalidates every tracked value whose RHS reads memory: inlining one
@@ -1145,6 +1604,35 @@ const eliminateDeadStores = (funcNode, params, useCounts) => {
1145
1604
  return changed
1146
1605
  }
1147
1606
 
1607
+ /**
1608
+ * Drop `(local.set $x A)` when the very next statement re-sets $x without reading it
1609
+ * first (A pure). The two writes are adjacent, so A's value is overwritten before any
1610
+ * observation — it's dead. The whole-function {@link eliminateDeadStores} misses this:
1611
+ * it only fires when $x is read NOWHERE, whereas here $x is live later, just not
1612
+ * between these two writes. Pairs with copy-propagation, which rewrites
1613
+ * `$x=$y; $x=f($x)` to `$x=$y; $x=f($y)` — an adjacent dead store this removes,
1614
+ * collapsing the round-trip jz's value-model lowering leaves behind.
1615
+ * @param {Array} funcNode a straight-line scope (body / block / loop / then / else)
1616
+ * @param {Set<string>} params
1617
+ */
1618
+ const eliminateAdjacentDeadStores = (funcNode, params) => {
1619
+ let changed = false
1620
+ for (let i = 1; i < funcNode.length - 1; i++) {
1621
+ const a = funcNode[i], b = funcNode[i + 1]
1622
+ // `a` must be a plain set (a tee leaves its value on the stack — not removable);
1623
+ // `b` may be a set OR a tee (both overwrite the local before `a`'s value is read).
1624
+ if (!Array.isArray(a) || a[0] !== 'local.set' || a.length !== 3) continue
1625
+ if (!Array.isArray(b) || (b[0] !== 'local.set' && b[0] !== 'local.tee') || b.length !== 3 || b[1] !== a[1]) continue
1626
+ if (params.has(a[1]) || !isPure(a[2])) continue
1627
+ // Dead only if b's value doesn't read $x before overwriting it.
1628
+ let reads = false
1629
+ walk(b[2], n => { if (Array.isArray(n) && (n[0] === 'local.get' || n[0] === 'local.tee') && n[1] === a[1]) reads = true })
1630
+ if (reads) continue
1631
+ funcNode.splice(i, 1); changed = true; i--
1632
+ }
1633
+ return changed
1634
+ }
1635
+
1148
1636
  /**
1149
1637
  * Propagate values through locals and eliminate single-use/dead locals.
1150
1638
  * Constants propagate to all uses; pure single-use exprs inline into get site.
@@ -1154,6 +1642,9 @@ const eliminateDeadStores = (funcNode, params, useCounts) => {
1154
1642
  const isScopeNode = (n) => Array.isArray(n) &&
1155
1643
  (n[0] === 'func' || n[0] === 'block' || n[0] === 'loop' || n[0] === 'then' || n[0] === 'else')
1156
1644
 
1645
+ /** Branch-target scopes: ops that carry an optional label/result header and can be jumped to via br/br_if. */
1646
+ const isBranchScope = (op) => op === 'block' || op === 'loop' || op === 'if'
1647
+
1157
1648
  const propagate = (ast) => {
1158
1649
  walk(ast, (funcNode) => {
1159
1650
  if (!Array.isArray(funcNode) || funcNode[0] !== 'func') return
@@ -1176,7 +1667,7 @@ const propagate = (ast) => {
1176
1667
  // (skip a not-yet-provably-dead store, decline a not-yet-provably-single use) —
1177
1668
  // never wrongly. The next round re-counts and mops up. (Recounting per sub-pass
1178
1669
  // per scope is O(scopes·funcSize) and crippling on big modules.)
1179
- for (let round = 0; round < 6; round++) {
1670
+ for (let round = 0; round < MAX_PROP_ROUNDS; round++) {
1180
1671
  const useCounts = countLocalUses(funcNode)
1181
1672
  let progressed = false
1182
1673
  for (const scope of scopes) {
@@ -1184,6 +1675,7 @@ const propagate = (ast) => {
1184
1675
  if (eliminateSetGetPairs(scope, params, useCounts)) progressed = true
1185
1676
  if (createLocalTees(scope, params, useCounts)) progressed = true
1186
1677
  if (eliminateDeadStores(scope, params, useCounts)) progressed = true
1678
+ if (eliminateAdjacentDeadStores(scope, params)) progressed = true
1187
1679
  }
1188
1680
  if (!progressed) break
1189
1681
  }
@@ -1194,113 +1686,545 @@ const propagate = (ast) => {
1194
1686
 
1195
1687
  // ==================== FUNCTION INLINING ====================
1196
1688
 
1689
+ // Shared inliner primitives, used by BOTH passes below: `inline` (duplicates tiny
1690
+ // multi-caller bodies — size-for-speed) and `inlineOnce` (splices single-caller
1691
+ // bodies, never duplicates). The lift technique is identical — rename the callee's
1692
+ // params/locals/labels to fresh `$__inlN_*` names, evaluate args once into the
1693
+ // renamed param locals, turn `return X` into `br $__inlN X`, wrap the body in a
1694
+ // `(block $__inlN (result T)? …)`. Only the SELECTION policy differs (one caller vs
1695
+ // every caller of a small body), so the lift lives here once.
1696
+
1697
+ let inlineUid = 0
1698
+ const INL_HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
1699
+ const inlBodyStart = (fn) => {
1700
+ let i = 2
1701
+ while (i < fn.length && (typeof fn[i] === 'string' || (Array.isArray(fn[i]) && INL_HEAD.has(fn[i][0])))) i++
1702
+ return i
1703
+ }
1704
+ const inlIsBranch = op => op === 'br' || op === 'br_if' || op === 'br_table'
1705
+ // A subtree we can't lift into a (block …): depth-relative branch labels (which would
1706
+ // shift under the added nesting) or tail calls (which would escape the wrapping block).
1707
+ const inlUnsafe = (n) => {
1708
+ if (!Array.isArray(n)) return false
1709
+ const op = n[0]
1710
+ if (op === 'return_call' || op === 'return_call_indirect' || op === 'return_call_ref') return true
1711
+ if (op === 'try' || op === 'try_table' || op === 'delegate' || op === 'rethrow') return true // exception labels — not handled by the relabeler below
1712
+ if (inlIsBranch(op)) for (let i = 1; i < n.length; i++) if (typeof n[i] === 'number' || (typeof n[i] === 'string' && /^\d+$/.test(n[i]))) return true
1713
+ for (let i = 1; i < n.length; i++) if (inlUnsafe(n[i])) return true
1714
+ return false
1715
+ }
1716
+ const inlCallsSelf = (n, name) => {
1717
+ if (!Array.isArray(n)) return false
1718
+ if ((n[0] === 'call' || n[0] === 'return_call') && n[1] === name) return true
1719
+ for (let i = 1; i < n.length; i++) if (inlCallsSelf(n[i], name)) return true
1720
+ return false
1721
+ }
1722
+ // Per-call zero-init constant for a callee local re-entered from a caller loop.
1723
+ // null ⇒ a type we can't safely zero here (skip inlining such a callee).
1724
+ const inlZeroFor = (t) => {
1725
+ if (t === 'i32') return ['i32.const', 0]
1726
+ if (t === 'i64') return ['i64.const', 0]
1727
+ if (t === 'f32') return ['f32.const', 0]
1728
+ if (t === 'f64') return ['f64.const', 0]
1729
+ if (t === 'v128') return ['v128.const', 'i64x2', '0', '0'] // STRING lanes — watr's v128 encoder calls .replaceAll
1730
+ return null
1731
+ }
1732
+ // A callee local needs a per-entry reset only if some path reads it before any
1733
+ // unconditional write (so it relied on the callee's fresh zero-init). Mirrors
1734
+ // coalesceLocals' readsZero heuristic; unconditionally-written scratch needs none.
1735
+ const inlNeedsReset = (body, name) => {
1736
+ let seen = false, conditional = false, depth = 0
1737
+ const visit = (n) => {
1738
+ if (seen || !Array.isArray(n)) return
1739
+ const op = n[0]
1740
+ const isSet = op === 'local.set' || op === 'local.tee'
1741
+ if ((isSet || op === 'local.get') && n[1] === name) {
1742
+ if (isSet) for (let i = 2; i < n.length && !seen; i++) visit(n[i])
1743
+ if (seen) return
1744
+ seen = true
1745
+ if (op === 'local.get' || depth > 0) conditional = true
1746
+ return
1747
+ }
1748
+ const isIf = op === 'if'
1749
+ for (let i = 1; i < n.length && !seen; i++) {
1750
+ const c = n[i]
1751
+ const cond = isIf && Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')
1752
+ if (cond) depth++
1753
+ visit(c)
1754
+ if (cond) depth--
1755
+ }
1756
+ }
1757
+ for (const n of body) { if (seen) break; visit(n) }
1758
+ if (!seen) return false
1759
+ return conditional
1760
+ }
1761
+ // Module-level references that pin a function (can't be inlined-away/removed).
1762
+ const inlCollectPinned = (n, pinned) => {
1763
+ if (!Array.isArray(n)) return
1764
+ const op = n[0]
1765
+ if (op === 'export' && Array.isArray(n[2]) && n[2][0] === 'func' && typeof n[2][1] === 'string') pinned.add(n[2][1])
1766
+ else if (op === 'start' && typeof n[1] === 'string') pinned.add(n[1])
1767
+ else if (op === 'ref.func' && typeof n[1] === 'string') pinned.add(n[1])
1768
+ else if (op === 'elem') for (const c of n) if (typeof c === 'string' && c[0] === '$') pinned.add(c)
1769
+ for (const c of n) inlCollectPinned(c, pinned)
1770
+ }
1771
+
1772
+ /** Pinned function names (export/start/ref.func/elem targets) across the module's
1773
+ * non-func nodes — a Set the inliner must never dissolve.
1774
+ *
1775
+ * KEEP THIS EXTRACTED — do not inline back into inlineOnce. The self-host kernel
1776
+ * (jz compiling jz) mis-compiles this `new Set()` + scan when it lives inside the
1777
+ * oversized inlineOnce scope: the `pinned` value's pointer is zeroed, so the first
1778
+ * `pinned.add` traps in `__set_add` ("memory access out of bounds") on every L2
1779
+ * compile of a program with an inlinable helper. Building it in this small scope
1780
+ * keeps the local count under the threshold that triggers the miscompile. Pinned by
1781
+ * test/selfhost.js "level-2 inliner is sound". (Underlying large-function self-host
1782
+ * codegen bug is tracked separately; this is the surgical dodge.) */
1783
+ const inlBuildPinned = (ast) => {
1784
+ const pinned = new Set()
1785
+ for (const n of ast) if (!Array.isArray(n) || n[0] !== 'func') inlCollectPinned(n, pinned)
1786
+ return pinned
1787
+ }
1788
+
1789
+ // Parse a func node into { params, locals, inlResult } once, enforcing the
1790
+ // liftability contract (named params/locals, zero-init-able local types, ≤1
1791
+ // result, no inline export). Returns null if the func can't be lifted.
1792
+ const inlParse = (fn) => {
1793
+ const params = [], locals = []
1794
+ let inlResult = null, ok = true, nResult = 0
1795
+ for (let i = 2; i < fn.length; i++) {
1796
+ const c = fn[i]
1797
+ if (typeof c === 'string') continue
1798
+ if (!Array.isArray(c)) { ok = false; break }
1799
+ if (c[0] === 'param') { if (typeof c[1] !== 'string' || c[1][0] !== '$') { ok = false; break } params.push({ name: c[1], type: c[2] }) }
1800
+ else if (c[0] === 'local') { if (typeof c[1] !== 'string' || c[1][0] !== '$' || !inlZeroFor(c[2])) { ok = false; break } locals.push({ name: c[1], type: c[2] }) }
1801
+ else if (c[0] === 'result') { nResult += c.length - 1; if (c.length > 1) inlResult = c[1] }
1802
+ else if (c[0] === 'export') { ok = false; break }
1803
+ else if (c[0] === 'type') continue
1804
+ else break
1805
+ }
1806
+ if (nResult > 1) ok = false
1807
+ return ok ? { params, locals, inlResult } : null
1808
+ }
1809
+
1810
+ // IR-node count of a callee body — the cheap size proxy gating multi-caller inline.
1811
+ const inlBodySize = (fn) => {
1812
+ let n = 0
1813
+ const count = (x) => { if (!Array.isArray(x)) return; n++; for (let i = 1; i < x.length; i++) count(x[i]) }
1814
+ for (let i = inlBodyStart(fn); i < fn.length; i++) count(fn[i])
1815
+ return n
1816
+ }
1817
+
1197
1818
  /**
1198
- * Inline tiny functions (single expression, no locals, no params or simple params).
1819
+ * Lift one callee into ONE `(call …)` node. Returns `{ block, decls }` `block`
1820
+ * replaces the call; `decls` are the renamed param+local declarations to splice into
1821
+ * the caller's local list. A fresh uid per invocation keeps every inlined copy's
1822
+ * locals/labels unique, so the same body can be lifted into many sites.
1823
+ *
1824
+ * (call $f a0 a1 …) → (block $__inlN (result T)?
1825
+ * (local.set $__inlN_p0 a0) … ;; args evaluated once, in order
1826
+ * (local.set $__inlN_l reset) … ;; only locals that rely on zero-init
1827
+ * …body, renamed, `return X` → `br $__inlN X`…)
1828
+ */
1829
+ const buildInline = (params, locals, inlResult, cBody, args) => {
1830
+ const uid = ++inlineUid
1831
+ const exit = `$__inl${uid}`
1832
+ const rename = new Map()
1833
+ for (const p of params) rename.set(p.name, `$__inl${uid}_${p.name.slice(1)}`)
1834
+ for (const l of locals) rename.set(l.name, `$__inl${uid}_${l.name.slice(1)}`)
1835
+ // The callee's own block/loop/if labels would shadow same-named caller labels (and
1836
+ // break depth resolution) under the added nesting — give them fresh names too.
1837
+ const labelRename = new Map()
1838
+ const collectLabels = (n) => {
1839
+ if (!Array.isArray(n)) return
1840
+ if (isBranchScope(n[0]) && typeof n[1] === 'string' && n[1][0] === '$' && !labelRename.has(n[1]))
1841
+ labelRename.set(n[1], `$__inl${uid}L_${n[1].slice(1)}`)
1842
+ for (let i = 1; i < n.length; i++) collectLabels(n[i])
1843
+ }
1844
+ for (const n of cBody) collectLabels(n)
1845
+ const sub = (n) => {
1846
+ if (!Array.isArray(n)) return n
1847
+ const op = n[0]
1848
+ if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && rename.has(n[1]))
1849
+ return [op, rename.get(n[1]), ...n.slice(2).map(sub)]
1850
+ if (op === 'return') return ['br', exit, ...n.slice(1).map(sub)]
1851
+ if (isBranchScope(op) && typeof n[1] === 'string' && labelRename.has(n[1]))
1852
+ return [op, labelRename.get(n[1]), ...n.slice(2).map(sub)]
1853
+ if (inlIsBranch(op)) return [op, ...n.slice(1).map(c => (typeof c === 'string' && labelRename.has(c)) ? labelRename.get(c) : sub(c))]
1854
+ return n.map((c, i) => i === 0 ? c : sub(c))
1855
+ }
1856
+ const setup = params.map((p, k) => ['local.set', rename.get(p.name), args[k]])
1857
+ const resets = locals.filter(l => inlNeedsReset(cBody, l.name)).map(l => ['local.set', rename.get(l.name), inlZeroFor(l.type)])
1858
+ const inner = cBody.map(sub)
1859
+ const block = inlResult
1860
+ ? ['block', exit, ['result', inlResult], ...setup, ...resets, ...inner]
1861
+ : ['block', exit, ...setup, ...resets, ...inner]
1862
+ const decls = [...params, ...locals].map(p => ['local', rename.get(p.name), p.type])
1863
+ return { block, decls }
1864
+ }
1865
+
1866
+ /**
1867
+ * Inline SMALL functions into every caller, then delete them — the multi-caller
1868
+ * complement to {@link inlineOnce}. inlineOnce only fires for a lone caller (so it
1869
+ * never duplicates); this duplicates a tiny body across ALL its sites, trading a
1870
+ * bounded amount of size to remove call overhead from hot inner loops (e.g. a
1871
+ * raymarcher's per-step SDF, evaluated 4-wide but still paying a wasm call each
1872
+ * march step). Size-for-speed — opt-in, on at the 'speed' level only.
1873
+ *
1874
+ * A callee qualifies when it is small (≤ INLINE_MAX_NODES IR nodes), named with
1875
+ * named params/locals, single-result-or-void, non-recursive, not pinned
1876
+ * (export/start/elem/ref.func), not a SIMD_PROTECTED transcendental, and free of
1877
+ * depth-relative branches / tail calls (the inlParse + inlUnsafe liftability
1878
+ * contract). Runs to a fixpoint so small-helper chains (sdf → sdRep) collapse.
1879
+ *
1880
+ * `simdOnly` (the speed-level default) restricts inlining to pure SIMD helpers —
1881
+ * every param and the result are `v128`. That targets the case this exists for —
1882
+ * a hand-vectorized hot loop's per-step helper (a raymarcher's SDF), where the call
1883
+ * overhead is paid every iteration and V8's wasm JIT won't inline it — while leaving
1884
+ * SCALAR helpers untouched: those are where jz's codegen-shape/size tuning and the
1885
+ * auto-vectorizer's call-lifting (plasma's fbm → sin2) live, and duplicating them
1886
+ * both bloats and perturbs that machinery for no gain (V8's JIT inlines scalar
1887
+ * helpers itself). The unrestricted form stays available as `watr: { inline: true }`.
1888
+ *
1199
1889
  * @param {Array} ast
1890
+ * @param {{simdOnly?: boolean}} [opts]
1200
1891
  * @returns {Array}
1201
1892
  */
1202
- const inline = (ast) => {
1893
+ const INLINE_MAX_NODES = 90
1894
+ const isV128SimdHelper = (params, inlResult) =>
1895
+ inlResult === 'v128' && params.length > 0 && params.every(p => p.type === 'v128')
1896
+ const inline = (ast, { simdOnly = false } = {}) => {
1203
1897
  if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1204
1898
 
1205
- // Collect inlinable functions
1206
- const inlinable = new Map() // $name { body, params }
1899
+ const skip = new Set() // callees with a non-inlinable site (arity mismatch) — don't re-pick
1900
+ for (let round = 0; round < MAX_INLINE_ROUNDS; round++) {
1901
+ const funcs = ast.filter(n => Array.isArray(n) && n[0] === 'func')
1902
+ const funcByName = new Map()
1903
+ for (const n of funcs) if (typeof n[1] === 'string') funcByName.set(n[1], n)
1207
1904
 
1208
- for (const node of ast.slice(1)) {
1209
- if (!Array.isArray(node) || node[0] !== 'func') continue
1905
+ const callRefs = new Map(), otherRef = new Set()
1906
+ const countRefs = (n) => {
1907
+ if (!Array.isArray(n)) return
1908
+ const op = n[0]
1909
+ if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
1910
+ else if (op === 'return_call' && typeof n[1] === 'string') otherRef.add(n[1])
1911
+ for (let i = 1; i < n.length; i++) countRefs(n[i])
1912
+ }
1913
+ countRefs(ast)
1914
+ const pinned = new Set()
1915
+ for (const n of ast) if (!Array.isArray(n) || n[0] !== 'func') inlCollectPinned(n, pinned)
1210
1916
 
1211
- const name = typeof node[1] === 'string' && node[1][0] === '$' ? node[1] : null
1212
- if (!name) continue
1917
+ // Pick a small, liftable, non-recursive callee with 1 plain-call site.
1918
+ let calleeName = null, parsed = null
1919
+ for (const [name, fn] of funcByName) {
1920
+ if (skip.has(name) || pinned.has(name) || otherRef.has(name) || SIMD_PROTECTED.has(name)) continue
1921
+ if (!(callRefs.get(name) >= 1)) continue
1922
+ if (inlBodySize(fn) > INLINE_MAX_NODES) continue
1923
+ if (inlCallsSelf(fn, name)) continue
1924
+ const p = inlParse(fn)
1925
+ if (!p) continue
1926
+ if (simdOnly && !isV128SimdHelper(p.params, p.inlResult)) continue
1927
+ let bad = false
1928
+ for (let i = inlBodyStart(fn); i < fn.length; i++) if (inlUnsafe(fn[i])) { bad = true; break }
1929
+ if (bad) continue
1930
+ calleeName = name; parsed = p; break
1931
+ }
1932
+ if (!calleeName) break
1213
1933
 
1214
- // Check if function is small enough to inline
1215
- let params = []
1216
- let body = []
1217
- let hasLocals = false
1218
- let hasExport = false
1934
+ const callee = funcByName.get(calleeName)
1935
+ const { params, locals, inlResult } = parsed
1936
+ const cBody = callee.slice(inlBodyStart(callee))
1937
+ const expected = callRefs.get(calleeName) || 0 // callee is non-recursive ⇒ all sites are in other funcs
1938
+ let replaced = 0
1219
1939
 
1220
- for (let i = 1; i < node.length; i++) {
1221
- const sub = node[i]
1222
- if (!Array.isArray(sub)) continue
1223
- if (sub[0] === 'param') {
1224
- // Collect param names and types
1225
- if (typeof sub[1] === 'string' && sub[1][0] === '$') {
1226
- params.push({ name: sub[1], type: sub[2] })
1227
- } else {
1228
- // Unnamed params - harder to inline
1229
- params = null
1230
- break
1231
- }
1232
- } else if (sub[0] === 'local') {
1233
- hasLocals = true
1234
- } else if (sub[0] === 'export') {
1235
- hasExport = true
1236
- } else if (sub[0] !== 'result' && sub[0] !== 'type') {
1237
- body.push(sub)
1940
+ // Splice into EVERY caller. A body that itself still calls an as-yet-uninlined
1941
+ // helper is fine — later rounds collapse it (or it stays a call).
1942
+ for (const fn of funcs) {
1943
+ if (fn === callee) continue
1944
+ const addDecls = []
1945
+ for (let i = inlBodyStart(fn); i < fn.length; i++) {
1946
+ fn[i] = walkPost(fn[i], (n) => {
1947
+ if (!Array.isArray(n) || n[0] !== 'call' || n[1] !== calleeName) return
1948
+ const args = n.slice(2)
1949
+ if (args.length !== params.length) return // arity mismatch — leave the call
1950
+ const { block, decls } = buildInline(params, locals, inlResult, cBody, args)
1951
+ addDecls.push(...decls)
1952
+ replaced++
1953
+ return block
1954
+ })
1238
1955
  }
1956
+ if (addDecls.length) fn.splice(inlBodyStart(fn), 0, ...addDecls)
1239
1957
  }
1240
1958
 
1241
- // Inline: no locals, <= 4 params, single expression body, not exported
1242
- if (params && !hasLocals && !hasExport && params.length <= 4 && body.length === 1) {
1243
- // Check if function mutates any of its params (local.set/tee on param),
1244
- // or contains a control-transfer op (`return`, `return_call`,
1245
- // `return_call_indirect`). Inlining such bodies into a different-typed
1246
- // caller would propagate the transfer to the caller, returning from the
1247
- // wrong function with the wrong type. Lifting the body into a
1248
- // `(block $exit ...)` and rewriting returns to `(br $exit X)` would
1249
- // unlock these — left for a future pass.
1250
- const paramNames = new Set(params.map(p => p.name))
1251
- let mutatesParam = false
1252
- let hasReturn = false
1253
- walk(body[0], (n) => {
1254
- if (!Array.isArray(n)) return
1255
- if ((n[0] === 'local.set' || n[0] === 'local.tee') && paramNames.has(n[1])) {
1256
- mutatesParam = true
1257
- }
1258
- if (n[0] === 'return' || n[0] === 'return_call' || n[0] === 'return_call_indirect') {
1259
- hasReturn = true
1260
- }
1261
- })
1262
- if (!mutatesParam && !hasReturn) {
1263
- inlinable.set(name, { body: body[0], params })
1264
- }
1265
- }
1959
+ // Drop the callee only if every site inlined; else keep it and stop re-picking it.
1960
+ if (replaced === expected) { const idx = ast.indexOf(callee); if (idx >= 0) ast.splice(idx, 1) }
1961
+ else skip.add(calleeName)
1266
1962
  }
1267
1963
 
1268
- // Replace calls with inlined body
1269
- if (inlinable.size === 0) return ast
1270
-
1271
- walkPost(ast, (node) => {
1272
- if (!Array.isArray(node) || node[0] !== 'call') return
1273
- const fname = node[1]
1274
- if (!inlinable.has(fname)) return
1964
+ return ast
1965
+ }
1275
1966
 
1276
- const { body, params } = inlinable.get(fname)
1277
- const args = node.slice(2)
1967
+ // ==================== INLINE-ONCE ====================
1278
1968
 
1279
- // Simple case: no params
1280
- if (params.length === 0) {
1281
- return clone(body)
1969
+ /**
1970
+ * Devirtualize `call_indirect` through NaN-boxed closure values with a statically
1971
+ * known candidate set. `let f = c ? a : b; … f(x)` emits a select of two i64
1972
+ * closure constants into an f64 local; every call site then derives the table
1973
+ * slot from that local's bits:
1974
+ * (i32.wrap_i64 (i64.and (i64.shr_u (i64.reinterpret_f64 (local.get $f))
1975
+ * (i64.const 32)) (i64.const 32767)))
1976
+ * When EVERY write to $f in the function is such a constant set (≤2 candidates),
1977
+ * each call site becomes a guarded direct call —
1978
+ * (if (result …) (i64.eq (i64.reinterpret_f64 (local.get $f)) (i64.const C1))
1979
+ * (then (call $tramp1 …args)) (else <next guard | original call_indirect>))
1980
+ * — with the ORIGINAL call_indirect kept as the final arm, so unknown flows
1981
+ * (zero-init paths the analysis can't see) behave exactly as before: the rewrite
1982
+ * is a pure branch-predicted fast path, ~25% on callback loops, and the direct
1983
+ * calls participate in inlining. A trivially-constant slot ((i32.const N) after
1984
+ * fold) becomes a bare direct call with no guard.
1985
+ *
1986
+ * Soundness: the guard compares the SAME bits the slot extraction reads, so
1987
+ * whichever constant flows to the call dispatches identically in both forms;
1988
+ * candidates that don't resolve to an elem entry (or whose target's signature
1989
+ * differs from the call's type — would-be runtime trap) disable the site. Any
1990
+ * table mutation op in the module disables the pass entirely. The function
1991
+ * table is exported for host-side closure invocation (reads); host mutation of
1992
+ * it is outside the ABI contract, same as the closure-constant model itself.
1993
+ */
1994
+ const devirt = (ast) => {
1995
+ if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1996
+ // Module facts: elem slot → func name (constant offsets only), type defs,
1997
+ // named funcs. Bail on dynamic elem offsets or table mutation anywhere.
1998
+ const slots = new Map(), typeDefs = new Map(), funcsByName = new Map(), allFuncs = []
1999
+ let tableMutated = false
2000
+ walk(ast, n => {
2001
+ if (Array.isArray(n) && typeof n[0] === 'string' &&
2002
+ (n[0] === 'table.set' || n[0] === 'table.grow' || n[0] === 'table.init' ||
2003
+ n[0] === 'table.copy' || n[0] === 'table.fill')) tableMutated = true
2004
+ })
2005
+ if (tableMutated) return ast
2006
+ for (const node of ast.slice(1)) {
2007
+ if (!Array.isArray(node)) continue
2008
+ if (node[0] === 'elem') {
2009
+ const off = node[1]
2010
+ if (!Array.isArray(off) || off[0] !== 'i32.const') return ast
2011
+ let base = Number(off[1])
2012
+ for (let i = 2; i < node.length; i++)
2013
+ if (typeof node[i] === 'string' && node[i][0] === '$') slots.set(base++, node[i])
2014
+ }
2015
+ else if (node[0] === 'type' && typeof node[1] === 'string') typeDefs.set(node[1], node[2])
2016
+ else if (node[0] === 'func') { allFuncs.push(node); if (typeof node[1] === 'string') funcsByName.set(node[1], node) }
2017
+ }
2018
+ if (!slots.size) return ast
2019
+
2020
+ // Closure-valued GLOBALS: multiProp function-property slots dissolve into
2021
+ // f64 module globals (plan/scope.js flattenFuncNamespaces) — the subscript
2022
+ // hook pattern (`parse.space = fn`, overridden per feature at module init).
2023
+ // Every observed `global.set $G <closure-const>` contributes a candidate;
2024
+ // a non-const store poisons the global. The guard ladder stays SOUND even
2025
+ // with an incomplete set — unknown values take the original call_indirect
2026
+ // fallback arm — so candidates only need to cover the hot value.
2027
+ const globalCands = new Map()
2028
+
2029
+ // All i64 const handling is canonical-hex STRING math (see the i64 VALUE
2030
+ // CONTRACT above): a helper RETURNING a BigInt is kind-erased in-kernel and
2031
+ // every op on it misdispatches — devirt silently no-ops.
2032
+ const isC64 = (n, hex) => Array.isArray(n) && n[0] === 'i64.const' && _i64Canon(n[1]) === hex
2033
+ const MASK15 = '0x0000000000007fff', SHIFT32 = '0x0000000000000020'
2034
+ // Collect the i64 constants reachable through reinterpret/select arms.
2035
+ const boxConsts = (v, out) => {
2036
+ if (!Array.isArray(v)) return false
2037
+ if (v[0] === 'i64.const') { out.push(v); return true }
2038
+ // f64-carrier closure const (`f64.const nan:0xHEX`) — the form module-init
2039
+ // global.set stores for hook slots; normalize to its i64 bits.
2040
+ if (v[0] === 'f64.const' && typeof v[1] === 'string' && v[1].startsWith('nan:')) {
2041
+ out.push(['i64.const', _i64Canon(v[1].slice(4))])
2042
+ return true
2043
+ }
2044
+ if (v[0] === 'f64.reinterpret_i64' && v.length === 2) return boxConsts(v[1], out)
2045
+ if (v[0] === 'select' && v.length === 4) return boxConsts(v[1], out) && boxConsts(v[2], out)
2046
+ return false
2047
+ }
2048
+ // Per-global write VALUES collected first; candidates resolved by fixpoint so
2049
+ // the hook-alias pattern works: `baseSpace = parse.space ?? default` stores a
2050
+ // select/if whose arms are a GLOBAL READ of another const slot plus a const —
2051
+ // candidates = union through the alias edge. Soundness is unchanged (the
2052
+ // guard ladder keeps the original indirect fallback for unknown values); the
2053
+ // fixpoint only widens the candidate set. `if (result f64)` arms and
2054
+ // `__is_nullish`-style guard CONDITIONS are skipped — only VALUE positions
2055
+ // contribute. A write that contains anything else poisons the global.
2056
+ const globalWrites = new Map()
2057
+ walk(ast, n => {
2058
+ if (!Array.isArray(n) || n[0] !== 'global.set' || typeof n[1] !== 'string') return
2059
+ if (!globalWrites.has(n[1])) globalWrites.set(n[1], [])
2060
+ globalWrites.get(n[1]).push(n[2])
2061
+ })
2062
+ // Value-position scan: consts and global.get leaves, through reinterprets,
2063
+ // select arms and if/result arms. Returns false (poison) on anything else.
2064
+ const candLeaves = (v, consts, reads) => {
2065
+ if (!Array.isArray(v)) return false
2066
+ if (v[0] === 'i64.const') { consts.push(v); return true }
2067
+ if (v[0] === 'f64.const' && typeof v[1] === 'string' && v[1].startsWith('nan:')) {
2068
+ consts.push(['i64.const', _i64Canon(v[1].slice(4))]); return true
2069
+ }
2070
+ if ((v[0] === 'f64.reinterpret_i64' || v[0] === 'i64.reinterpret_f64') && v.length === 2)
2071
+ return candLeaves(v[1], consts, reads)
2072
+ if (v[0] === 'global.get' && typeof v[1] === 'string') { reads.push(v[1]); return true }
2073
+ if (v[0] === 'local.get' || v[0] === 'local.tee') {
2074
+ // a tee'd copy of one of the above — the tee VALUE was already scanned
2075
+ // where it was written; the bare read alone proves nothing → poison
2076
+ return v[0] === 'local.tee' && v.length === 3 ? candLeaves(v[2], consts, reads) : false
2077
+ }
2078
+ if (v[0] === 'select' && v.length === 4)
2079
+ return candLeaves(v[1], consts, reads) && candLeaves(v[2], consts, reads)
2080
+ if (v[0] === 'if') {
2081
+ // (if (result T) COND (then A) (else B)) — arms are value positions
2082
+ let ok = true, seenArm = false
2083
+ for (let i = 1; i < v.length; i++) {
2084
+ const p = v[i]
2085
+ if (!Array.isArray(p)) continue
2086
+ if (p[0] === 'then' || p[0] === 'else') {
2087
+ seenArm = true
2088
+ if (p.length !== 2 || !candLeaves(p[1], consts, reads)) ok = false
2089
+ }
2090
+ }
2091
+ return ok && seenArm
1282
2092
  }
1283
-
1284
- // Substitute params with args
1285
- const substituted = walkPost(clone(body), (n) => {
1286
- if (!Array.isArray(n) || n[0] !== 'local.get') return
1287
- const local = n[1]
1288
- const paramIdx = params.findIndex(p => p.name === local)
1289
- if (paramIdx !== -1 && args[paramIdx]) {
1290
- return clone(args[paramIdx])
2093
+ return false
2094
+ }
2095
+ const writeFacts = new Map() // global → { consts: [...], reads: [...] } | null
2096
+ for (const [g, ws] of globalWrites) {
2097
+ let consts = [], reads = [], ok = true
2098
+ for (const w of ws) if (!candLeaves(w, consts, reads)) { ok = false; break }
2099
+ writeFacts.set(g, ok ? { consts, reads } : null)
2100
+ }
2101
+ // Fixpoint: a global's candidates = its const writes ∪ candidates of every
2102
+ // global it reads in value position. A poisoned alias poisons the reader.
2103
+ let changed = true
2104
+ const resolved = new Map()
2105
+ while (changed) {
2106
+ changed = false
2107
+ for (const [g, f] of writeFacts) {
2108
+ if (resolved.get(g) === null) continue
2109
+ if (f === null) { if (resolved.get(g) !== null) { resolved.set(g, null); changed = true } continue }
2110
+ const m = resolved.get(g) || new Map()
2111
+ const before = m.size
2112
+ let poisoned = false
2113
+ for (const c of f.consts) m.set(_i64Canon(c[1]), c)
2114
+ for (const r of f.reads) {
2115
+ if (writeFacts.get(r) === null || resolved.get(r) === null) { poisoned = true; break }
2116
+ const rm = resolved.get(r)
2117
+ if (rm) for (const [hex, c] of rm) m.set(hex, c)
1291
2118
  }
1292
- })
2119
+ if (poisoned) { resolved.set(g, null); changed = true; continue }
2120
+ if (!resolved.has(g) || m.size !== before) { resolved.set(g, m); changed = true }
2121
+ }
2122
+ }
2123
+ for (const [g, m] of resolved) globalCands.set(g, m)
2124
+
2125
+ // The slot-extraction idiom — returns the source local name or null.
2126
+ const matchSlotOfLocal = (e) => {
2127
+ if (!Array.isArray(e) || e[0] !== 'i32.wrap_i64') return null
2128
+ const a = e[1]
2129
+ if (!Array.isArray(a) || a[0] !== 'i64.and') return null
2130
+ let sh = a[1], mk = a[2]
2131
+ if (!isC64(mk, MASK15)) { sh = a[2]; mk = a[1] }
2132
+ if (!isC64(mk, MASK15) || !Array.isArray(sh) || sh[0] !== 'i64.shr_u' || !isC64(sh[2], SHIFT32)) return null
2133
+ const ri = sh[1]
2134
+ if (!Array.isArray(ri) || ri[0] !== 'i64.reinterpret_f64') return null
2135
+ const leaf = ri[1]
2136
+ if (Array.isArray(leaf) && leaf[0] === 'local.get' && typeof leaf[1] === 'string') return { local: leaf[1] }
2137
+ if (Array.isArray(leaf) && leaf[0] === 'global.get' && typeof leaf[1] === 'string') return { global: leaf[1] }
2138
+ return null
2139
+ }
2140
+ // Canonical "params -> results" token string for signature comparison.
2141
+ const tokSig = (parts) => {
2142
+ const ps = [], rs = []
2143
+ for (const p of parts) {
2144
+ if (!Array.isArray(p)) continue
2145
+ if (p[0] === 'param') { for (const t of p.slice(1)) if (typeof t === 'string' && t[0] !== '$') ps.push(t) }
2146
+ else if (p[0] === 'result') rs.push(...p.slice(1))
2147
+ }
2148
+ return ps.join(',') + '->' + rs.join(',')
2149
+ }
1293
2150
 
1294
- return substituted
1295
- })
2151
+ for (const fn of allFuncs) { // rewrite call_indirect in EVERY func, named or not
2152
+ // Candidate sets: local → Map<bits, constNode>, or null once poisoned.
2153
+ // Params are poisoned (incoming value unknown).
2154
+ const cands = new Map()
2155
+ for (const part of fn)
2156
+ if (Array.isArray(part) && part[0] === 'param' && typeof part[1] === 'string') cands.set(part[1], null)
2157
+ walk(fn, n => {
2158
+ if (!Array.isArray(n) || (n[0] !== 'local.set' && n[0] !== 'local.tee') || typeof n[1] !== 'string') return
2159
+ if (cands.get(n[1]) === null) return
2160
+ const out = []
2161
+ if (boxConsts(n[2], out)) {
2162
+ const m = cands.get(n[1]) || new Map()
2163
+ for (const c of out) m.set(_i64Canon(c[1]), c)
2164
+ cands.set(n[1], m)
2165
+ } else if (Array.isArray(n[2]) && n[2][0] === 'global.get' && typeof n[2][1] === 'string'
2166
+ && globalCands.get(n[2][1])) {
2167
+ // promoteGlobals snapshot (`$_pg = global.get $G`) — inherit G's set
2168
+ const g = globalCands.get(n[2][1])
2169
+ const m = cands.get(n[1]) || new Map()
2170
+ for (const [hex, c] of g) m.set(hex, c)
2171
+ cands.set(n[1], m)
2172
+ } else cands.set(n[1], null)
2173
+ })
1296
2174
 
2175
+ walkPost(fn, (n, parent) => {
2176
+ if (!Array.isArray(n) || n[0] !== 'call_indirect') return
2177
+ // A call_indirect sitting directly under an `else` is (or looks exactly
2178
+ // like) the fallback arm of an existing guard — never re-wrap it, so the
2179
+ // pass is idempotent across repeated optimize() runs.
2180
+ if (parent && parent[0] === 'else') return
2181
+ const typeUse = Array.isArray(n[1]) && n[1][0] === 'type' ? n[1] : null
2182
+ if (!typeUse) return
2183
+ const sig = typeDefs.get(typeUse[1])
2184
+ const callSig = Array.isArray(sig) ? tokSig(sig.slice(1)) : null
2185
+ if (callSig == null) return
2186
+ const results = []
2187
+ for (const s of sig.slice(1)) if (Array.isArray(s) && s[0] === 'result') results.push(...s.slice(1))
2188
+ const args = n.slice(2, -1)
2189
+ const idx = n[n.length - 1]
2190
+ const sigOk = (name) => {
2191
+ const target = funcsByName.get(name)
2192
+ if (!target) return false
2193
+ const tu = target.find(p => Array.isArray(p) && p[0] === 'type')
2194
+ if (tu) return tu[1] === typeUse[1] ||
2195
+ (typeDefs.get(tu[1]) && tokSig(typeDefs.get(tu[1]).slice(1)) === callSig)
2196
+ return tokSig(target.slice(2)) === callSig
2197
+ }
2198
+ // Constant slot → bare direct call.
2199
+ if (Array.isArray(idx) && idx[0] === 'i32.const') {
2200
+ const name = slots.get(Number(idx[1]))
2201
+ return name && sigOk(name) ? ['call', name, ...args] : undefined
2202
+ }
2203
+ const f = matchSlotOfLocal(idx)
2204
+ if (!f) return
2205
+ const m = f.local != null ? cands.get(f.local) : globalCands.get(f.global)
2206
+ if (!m || m.size === 0 || m.size > 4) return
2207
+ const arms = []
2208
+ for (const cNode of m.values()) {
2209
+ const name = slots.get(_i64HiU(_i64Canon(cNode[1])) & 32767)
2210
+ if (!name || !sigOk(name)) return
2211
+ arms.push([cNode, name])
2212
+ }
2213
+ const readBack = f.local != null ? ['local.get', f.local] : ['global.get', f.global]
2214
+ let out = n
2215
+ for (let i = arms.length - 1; i >= 0; i--) {
2216
+ const [cNode, name] = arms[i]
2217
+ out = ['if', ...(results.length ? [['result', ...results]] : []),
2218
+ ['i64.eq', ['i64.reinterpret_f64', clone(readBack)], clone(cNode)],
2219
+ ['then', ['call', name, ...args.map(clone)]],
2220
+ ['else', out]]
2221
+ }
2222
+ return out
2223
+ })
2224
+ }
1297
2225
  return ast
1298
2226
  }
1299
2227
 
1300
- // ==================== INLINE-ONCE ====================
1301
-
1302
- let inlineUid = 0
1303
-
1304
2228
  /**
1305
2229
  * Inline functions that are called from exactly one place into their lone caller,
1306
2230
  * then delete them. Unlike {@link inline} (which duplicates tiny stateless bodies),
@@ -1330,101 +2254,20 @@ let inlineUid = 0
1330
2254
  * @param {Array} ast
1331
2255
  * @returns {Array}
1332
2256
  */
2257
+ // Scalar transcendental helpers the auto-vectorizer rewrites to f64x2 mirrors (PPC_CALL2 in
2258
+ // src/optimize/vectorize.js). inlineOnce must NOT dissolve their call nodes when single-caller —
2259
+ // the post-phase lift needs the call to rewrite it. Keep in sync with PPC_CALL2's keys.
2260
+ const SIMD_PROTECTED = new Set(['$math.sin_core', '$math.cos_core', '$math.sin', '$math.cos', '$math.pow', '$math.atan2', '$math.hypot', '$math.log'])
2261
+
1333
2262
  const inlineOnce = (ast) => {
1334
2263
  if (!Array.isArray(ast) || ast[0] !== 'module') return ast
1335
2264
 
1336
- const HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
1337
- const bodyStart = (fn) => {
1338
- let i = 2
1339
- while (i < fn.length && (typeof fn[i] === 'string' || (Array.isArray(fn[i]) && HEAD.has(fn[i][0])))) i++
1340
- return i
1341
- }
1342
- const isBranch = op => op === 'br' || op === 'br_if' || op === 'br_table'
1343
- // A subtree we can't lift into a (block …): depth-relative branch labels (shift
1344
- // under added nesting) or tail calls (would escape the wrapping block).
1345
- const unsafe = (n) => {
1346
- if (!Array.isArray(n)) return false
1347
- const op = n[0]
1348
- if (op === 'return_call' || op === 'return_call_indirect' || op === 'return_call_ref') return true
1349
- if (op === 'try' || op === 'try_table' || op === 'delegate' || op === 'rethrow') return true // exception labels — not handled by the relabeler below
1350
- if (isBranch(op)) for (let i = 1; i < n.length; i++) if (typeof n[i] === 'number' || (typeof n[i] === 'string' && /^\d+$/.test(n[i]))) return true
1351
- for (let i = 1; i < n.length; i++) if (unsafe(n[i])) return true
1352
- return false
1353
- }
1354
- const callsSelf = (n, name) => {
1355
- if (!Array.isArray(n)) return false
1356
- if ((n[0] === 'call' || n[0] === 'return_call') && n[1] === name) return true
1357
- for (let i = 1; i < n.length; i++) if (callsSelf(n[i], name)) return true
1358
- return false
1359
- }
1360
- // Locals must be re-zeroed each time the inlined block is entered IF the
1361
- // callee body actually relies on zero-init — i.e. some path reads the local
1362
- // before any unconditional write. In the original callee they got fresh
1363
- // zero-init per call; after inlining they're outer-func locals, zeroed only
1364
- // at outer entry, so a caller-loop that re-enters the inlined block reads
1365
- // stale values otherwise. Returns null for any type we can't safely
1366
- // zero-init here (skip inlining such callees).
1367
- const zeroFor = (t) => {
1368
- if (t === 'i32') return ['i32.const', 0]
1369
- if (t === 'i64') return ['i64.const', 0n]
1370
- if (t === 'f32') return ['f32.const', 0]
1371
- if (t === 'f64') return ['f64.const', 0]
1372
- if (t === 'v128') return ['v128.const', 'i64x2', 0n, 0n]
1373
- // Nullable ref types (`(ref null …)`, `funcref`, `externref`, `anyref`, etc.)
1374
- // zero-init to `ref.null …` per call; emitting that here would need the exact
1375
- // heap-type. Non-nullable refs aren't zero-init at all (codegen must seed
1376
- // them). Either way, skip — let the call survive.
1377
- return null
1378
- }
2265
+ // Lift primitives are shared with `inline` (defined once above buildInline). inlineOnce
2266
+ // splices into a SINGLE caller (never duplicating); `inline` duplicates into every caller.
2267
+ const bodyStart = inlBodyStart, callsSelf = inlCallsSelf, unsafe = inlUnsafe, isBranch = inlIsBranch
2268
+ const zeroFor = inlZeroFor, needsReset = inlNeedsReset
1379
2269
 
1380
- // Locals whose first observed use is a read — or whose first write is inside
1381
- // a conditional branch, where the alternate path bypasses it — depend on
1382
- // zero-init and need a reset when inlined into a caller-loop. Locals that
1383
- // are unconditionally written before any read (the common scratch pattern,
1384
- // e.g. `(local.set $bits (local.get $ptr))` opening a helper) don't, and
1385
- // emitting a spurious reset would only inflate that local's set-count and
1386
- // block downstream propagation/coalescing. Mirrors `coalesceLocals`'
1387
- // `readsZero` heuristic.
1388
- const needsReset = (body, name) => {
1389
- let seen = false, conditional = false, depth = 0
1390
- const visit = (n) => {
1391
- if (seen || !Array.isArray(n)) return
1392
- const op = n[0]
1393
- const isSet = op === 'local.set' || op === 'local.tee'
1394
- if ((isSet || op === 'local.get') && n[1] === name) {
1395
- if (isSet) for (let i = 2; i < n.length && !seen; i++) visit(n[i])
1396
- if (seen) return
1397
- seen = true
1398
- if (op === 'local.get' || depth > 0) conditional = true
1399
- return
1400
- }
1401
- const isIf = op === 'if'
1402
- for (let i = 1; i < n.length && !seen; i++) {
1403
- const c = n[i]
1404
- const cond = isIf && Array.isArray(c) && (c[0] === 'then' || c[0] === 'else')
1405
- if (cond) depth++
1406
- visit(c)
1407
- if (cond) depth--
1408
- }
1409
- }
1410
- for (const n of body) { if (seen) break; visit(n) }
1411
- // If the local is never used (dead), no reset; the dead decl will be pruned.
1412
- if (!seen) return false
1413
- return conditional
1414
- }
1415
-
1416
- // Module-level references that pin a function (can't be removed/inlined-away).
1417
- const collectPinned = (n, pinned) => {
1418
- if (!Array.isArray(n)) return
1419
- const op = n[0]
1420
- if (op === 'export' && Array.isArray(n[2]) && n[2][0] === 'func' && typeof n[2][1] === 'string') pinned.add(n[2][1])
1421
- else if (op === 'start' && typeof n[1] === 'string') pinned.add(n[1])
1422
- else if (op === 'ref.func' && typeof n[1] === 'string') pinned.add(n[1])
1423
- else if (op === 'elem') for (const c of n) if (typeof c === 'string' && c[0] === '$') pinned.add(c)
1424
- for (const c of n) collectPinned(c, pinned)
1425
- }
1426
-
1427
- for (let round = 0; round < 16; round++) {
2270
+ for (let round = 0; round < MAX_INLINE_ROUNDS; round++) {
1428
2271
  const funcs = ast.filter(n => Array.isArray(n) && n[0] === 'func')
1429
2272
  const funcByName = new Map()
1430
2273
  for (const n of funcs) if (typeof n[1] === 'string') funcByName.set(n[1], n)
@@ -1440,8 +2283,7 @@ const inlineOnce = (ast) => {
1440
2283
  for (let i = 1; i < n.length; i++) countRefs(n[i])
1441
2284
  }
1442
2285
  countRefs(ast)
1443
- const pinned = new Set()
1444
- for (const n of ast) if (!Array.isArray(n) || n[0] !== 'func') collectPinned(n, pinned)
2286
+ const pinned = inlBuildPinned(ast)
1445
2287
  // a func may carry its own (export "name") — the signature scan below rejects those too
1446
2288
 
1447
2289
  // Pick a callee.
@@ -1449,6 +2291,11 @@ const inlineOnce = (ast) => {
1449
2291
  for (const [name, fn] of funcByName) {
1450
2292
  if (pinned.has(name) || otherRef.has(name)) continue
1451
2293
  if (callRefs.get(name) !== 1) continue
2294
+ // Keep the scalar transcendentals that the auto-vectorizer maps to f64x2 mirrors (PPC_CALL2 in
2295
+ // src/optimize/vectorize.js): inlining a single-caller $math.atan2/hypot/log would dissolve the
2296
+ // call node before the post-phase lift can rewrite it to $math.atan2_2/hypot_2/log_v. Keep in
2297
+ // sync with PPC_CALL2's keys.
2298
+ if (SIMD_PROTECTED.has(name)) continue
1452
2299
  if (callsSelf(fn, name)) continue
1453
2300
  // named params/locals only (we'll rename them); reject locals with types
1454
2301
  // we can't zero-init on block re-entry.
@@ -1495,11 +2342,10 @@ const inlineOnce = (ast) => {
1495
2342
  for (const l of locals) rename.set(l.name, `$__inl${uid}_${l.name.slice(1)}`)
1496
2343
  // The callee's own block/loop/if labels would shadow same-named labels in the
1497
2344
  // caller after nesting (and break depth resolution) — give them fresh names too.
1498
- const isBlockLabel = op => op === 'block' || op === 'loop' || op === 'if'
1499
2345
  const labelRename = new Map()
1500
2346
  const collectLabels = (n) => {
1501
2347
  if (!Array.isArray(n)) return
1502
- if (isBlockLabel(n[0]) && typeof n[1] === 'string' && n[1][0] === '$' && !labelRename.has(n[1]))
2348
+ if (isBranchScope(n[0]) && typeof n[1] === 'string' && n[1][0] === '$' && !labelRename.has(n[1]))
1503
2349
  labelRename.set(n[1], `$__inl${uid}L_${n[1].slice(1)}`)
1504
2350
  for (let i = 1; i < n.length; i++) collectLabels(n[i])
1505
2351
  }
@@ -1510,7 +2356,7 @@ const inlineOnce = (ast) => {
1510
2356
  if ((op === 'local.get' || op === 'local.set' || op === 'local.tee') && typeof n[1] === 'string' && rename.has(n[1]))
1511
2357
  return [op, rename.get(n[1]), ...n.slice(2).map(sub)]
1512
2358
  if (op === 'return') return ['br', exit, ...n.slice(1).map(sub)]
1513
- if (isBlockLabel(op) && typeof n[1] === 'string' && labelRename.has(n[1]))
2359
+ if (isBranchScope(op) && typeof n[1] === 'string' && labelRename.has(n[1]))
1514
2360
  return [op, labelRename.get(n[1]), ...n.slice(2).map(sub)]
1515
2361
  if (isBranch(op)) return [op, ...n.slice(1).map(c => (typeof c === 'string' && labelRename.has(c)) ? labelRename.get(c) : sub(c))]
1516
2362
  return n.map((c, i) => i === 0 ? c : sub(c))
@@ -1832,13 +2678,20 @@ const vacuum = (ast) => {
1832
2678
  // Remove nop entirely (return array marker; parent or post-pass cleans it)
1833
2679
  if (op === 'nop') return ['nop']
1834
2680
 
1835
- // (drop PURE) → nop
1836
- if (op === 'drop' && node.length === 2 && isPure(node[1])) {
1837
- return ['nop']
2681
+ // (drop V) → just V's side effects. Pure V vanishes (→ nop); a pure op over
2682
+ // a `local.tee` collapses to the bare store (kills the post-increment's dead
2683
+ // old-value arithmetic); impure V is kept under a drop.
2684
+ if (op === 'drop' && node.length === 2) {
2685
+ const eff = dropEffects(node[1])
2686
+ if (eff.length === 0) return ['nop']
2687
+ if (eff.length === 1) return eff[0]
2688
+ return ['block', ...eff]
1838
2689
  }
1839
2690
 
1840
- // (select x x cond) → x
1841
- if (op === 'select' && node.length >= 4 && equal(node[1], node[2])) return node[1]
2691
+ // (select x x cond) → x — only when cond is PURE. An impure cond may set a
2692
+ // local that a later op reads (e.g. an address `local.tee` the matching store
2693
+ // reuses); dropping it would leave that local stale. Keep the select otherwise.
2694
+ if (op === 'select' && node.length >= 4 && equal(node[1], node[2]) && isPure(node[3])) return node[1]
1842
2695
 
1843
2696
  if (op === 'if') {
1844
2697
  const { cond, thenBranch, elseBranch } = parseIf(node)
@@ -1855,18 +2708,25 @@ const vacuum = (ast) => {
1855
2708
  }
1856
2709
 
1857
2710
  // Clean out nops, drop-of-pure sequences, and empty annotations from blocks
1858
- if (op === 'func' || op === 'block' || op === 'loop' || op === 'then' || op === 'else') {
2711
+ if (isScopeNode(node)) {
1859
2712
  const cleaned = [op]
1860
2713
  for (let i = 1; i < node.length; i++) {
1861
2714
  const child = node[i]
1862
2715
  if (child === 'nop' || (Array.isArray(child) && child[0] === 'nop')) continue
1863
- // Pure expression followed by standalone drop remove both
2716
+ // Stack-form `EXPR drop`: a pure EXPR drops out entirely; a bare
2717
+ // `tee X V drop` keeps just the store (`set X V`) — the dropped value
2718
+ // was the only reason it was a tee.
1864
2719
  const next = node[i + 1]
1865
2720
  const isDrop = next === 'drop' || (Array.isArray(next) && next[0] === 'drop' && next.length === 1)
1866
- if (Array.isArray(child) && isPure(child) && isDrop) {
2721
+ if (Array.isArray(child) && isDrop && isPure(child)) {
1867
2722
  i++ // skip the drop too
1868
2723
  continue
1869
2724
  }
2725
+ if (Array.isArray(child) && isDrop && child[0] === 'local.tee' && child.length === 3) {
2726
+ cleaned.push(['local.set', child[1], child[2]])
2727
+ i++ // skip the drop
2728
+ continue
2729
+ }
1870
2730
  cleaned.push(child)
1871
2731
  }
1872
2732
  if (cleaned.length !== node.length) return cleaned
@@ -1876,67 +2736,74 @@ const vacuum = (ast) => {
1876
2736
 
1877
2737
  // ==================== PEEPHOLE ====================
1878
2738
 
1879
- /** Peephole optimizations: simple algebraic identities */
2739
+ /** Peephole optimizations: simple algebraic identities.
2740
+ * Every rule that DROPS an operand guards on isPure: an impure operand must still
2741
+ * be evaluated for its side effects. The load-bearing case is a typed-array element
2742
+ * store, whose address is a `local.tee` inside the value expression (the element's
2743
+ * own read); dropping that operand (e.g. `(a[i] op a[i]) & 0`) would strand the
2744
+ * store with a stale address — a silent miscompile. When impure, keep the op (it
2745
+ * still yields the same value AND runs the operand). */
2746
+ const selfFold = (val) => (a, b) => equal(a, b) && isPure(a) ? val : null
1880
2747
  const PEEPHOLE = {
1881
- // Self-cancelling / tautological binary ops
1882
- 'i32.sub': (a, b) => equal(a, b) ? ['i32.const', 0] : null,
1883
- 'i64.sub': (a, b) => equal(a, b) ? ['i64.const', 0n] : null,
1884
- 'i32.xor': (a, b) => equal(a, b) ? ['i32.const', 0] : null,
1885
- 'i64.xor': (a, b) => equal(a, b) ? ['i64.const', 0n] : null,
1886
- 'i32.eq': (a, b) => equal(a, b) ? ['i32.const', 1] : null,
1887
- 'i64.eq': (a, b) => equal(a, b) ? ['i32.const', 1] : null,
1888
- 'i32.ne': (a, b) => equal(a, b) ? ['i32.const', 0] : null,
1889
- 'i64.ne': (a, b) => equal(a, b) ? ['i32.const', 0] : null,
1890
- 'i32.lt_s': (a, b) => equal(a, b) ? ['i32.const', 0] : null,
1891
- 'i32.lt_u': (a, b) => equal(a, b) ? ['i32.const', 0] : null,
1892
- 'i32.gt_s': (a, b) => equal(a, b) ? ['i32.const', 0] : null,
1893
- 'i32.gt_u': (a, b) => equal(a, b) ? ['i32.const', 0] : null,
1894
- 'i32.le_s': (a, b) => equal(a, b) ? ['i32.const', 1] : null,
1895
- 'i32.le_u': (a, b) => equal(a, b) ? ['i32.const', 1] : null,
1896
- 'i32.ge_s': (a, b) => equal(a, b) ? ['i32.const', 1] : null,
1897
- 'i32.ge_u': (a, b) => equal(a, b) ? ['i32.const', 1] : null,
1898
- 'i64.lt_s': (a, b) => equal(a, b) ? ['i32.const', 0] : null,
1899
- 'i64.lt_u': (a, b) => equal(a, b) ? ['i32.const', 0] : null,
1900
- 'i64.gt_s': (a, b) => equal(a, b) ? ['i32.const', 0] : null,
1901
- 'i64.gt_u': (a, b) => equal(a, b) ? ['i32.const', 0] : null,
1902
- 'i64.le_s': (a, b) => equal(a, b) ? ['i32.const', 1] : null,
1903
- 'i64.le_u': (a, b) => equal(a, b) ? ['i32.const', 1] : null,
1904
- 'i64.ge_s': (a, b) => equal(a, b) ? ['i32.const', 1] : null,
1905
- 'i64.ge_u': (a, b) => equal(a, b) ? ['i32.const', 1] : null,
1906
-
1907
- // Zero/all-bits absorption
2748
+ // Self-cancelling / tautological binary ops — drop both (equal) operands.
2749
+ 'i32.sub': selfFold(['i32.const', 0]),
2750
+ 'i64.sub': selfFold(['i64.const', 0]),
2751
+ 'i32.xor': selfFold(['i32.const', 0]),
2752
+ 'i64.xor': selfFold(['i64.const', 0]),
2753
+ 'i32.eq': selfFold(['i32.const', 1]),
2754
+ 'i64.eq': selfFold(['i32.const', 1]),
2755
+ 'i32.ne': selfFold(['i32.const', 0]),
2756
+ 'i64.ne': selfFold(['i32.const', 0]),
2757
+ 'i32.lt_s': selfFold(['i32.const', 0]),
2758
+ 'i32.lt_u': selfFold(['i32.const', 0]),
2759
+ 'i32.gt_s': selfFold(['i32.const', 0]),
2760
+ 'i32.gt_u': selfFold(['i32.const', 0]),
2761
+ 'i32.le_s': selfFold(['i32.const', 1]),
2762
+ 'i32.le_u': selfFold(['i32.const', 1]),
2763
+ 'i32.ge_s': selfFold(['i32.const', 1]),
2764
+ 'i32.ge_u': selfFold(['i32.const', 1]),
2765
+ 'i64.lt_s': selfFold(['i32.const', 0]),
2766
+ 'i64.lt_u': selfFold(['i32.const', 0]),
2767
+ 'i64.gt_s': selfFold(['i32.const', 0]),
2768
+ 'i64.gt_u': selfFold(['i32.const', 0]),
2769
+ 'i64.le_s': selfFold(['i32.const', 1]),
2770
+ 'i64.le_u': selfFold(['i32.const', 1]),
2771
+ 'i64.ge_s': selfFold(['i32.const', 1]),
2772
+ 'i64.ge_u': selfFold(['i32.const', 1]),
2773
+
2774
+ // Zero/all-bits absorption — drops the NON-const operand, so guard its purity.
1908
2775
  'i32.mul': (a, b) => {
1909
- const ca = getConst(a), cb = getConst(b)
1910
- if (ca?.value === 0 || cb?.value === 0) return ['i32.const', 0]
2776
+ if (getConst(b)?.value === 0 && isPure(a)) return ['i32.const', 0]
2777
+ if (getConst(a)?.value === 0 && isPure(b)) return ['i32.const', 0]
1911
2778
  return null
1912
2779
  },
1913
2780
  'i64.mul': (a, b) => {
1914
- const ca = getConst(a), cb = getConst(b)
1915
- if (ca?.value === 0n || cb?.value === 0n) return ['i64.const', 0n]
2781
+ if (getConst(b)?.value === ZERO64 && isPure(a)) return ['i64.const', 0]
2782
+ if (getConst(a)?.value === ZERO64 && isPure(b)) return ['i64.const', 0]
1916
2783
  return null
1917
2784
  },
1918
2785
  'i32.and': (a, b) => {
1919
- if (equal(a, b)) return a
1920
- const ca = getConst(a), cb = getConst(b)
1921
- if (ca?.value === 0 || cb?.value === 0) return ['i32.const', 0]
2786
+ if (equal(a, b) && isPure(b)) return a
2787
+ if (getConst(b)?.value === 0 && isPure(a)) return ['i32.const', 0]
2788
+ if (getConst(a)?.value === 0 && isPure(b)) return ['i32.const', 0]
1922
2789
  return null
1923
2790
  },
1924
2791
  'i64.and': (a, b) => {
1925
- if (equal(a, b)) return a
1926
- const ca = getConst(a), cb = getConst(b)
1927
- if (ca?.value === 0n || cb?.value === 0n) return ['i64.const', 0n]
2792
+ if (equal(a, b) && isPure(b)) return a
2793
+ if (getConst(b)?.value === ZERO64 && isPure(a)) return ['i64.const', 0]
2794
+ if (getConst(a)?.value === ZERO64 && isPure(b)) return ['i64.const', 0]
1928
2795
  return null
1929
2796
  },
1930
2797
  'i32.or': (a, b) => {
1931
- if (equal(a, b)) return a
1932
- const ca = getConst(a), cb = getConst(b)
1933
- if (ca?.value === -1 || cb?.value === -1) return ['i32.const', -1]
2798
+ if (equal(a, b) && isPure(b)) return a
2799
+ if (getConst(b)?.value === -1 && isPure(a)) return ['i32.const', -1]
2800
+ if (getConst(a)?.value === -1 && isPure(b)) return ['i32.const', -1]
1934
2801
  return null
1935
2802
  },
1936
2803
  'i64.or': (a, b) => {
1937
- if (equal(a, b)) return a
1938
- const ca = getConst(a), cb = getConst(b)
1939
- if (ca?.value === -1n || cb?.value === -1n) return ['i64.const', -1n]
2804
+ if (equal(a, b) && isPure(b)) return a
2805
+ if (getConst(b)?.value === NEG164 && isPure(a)) return ['i64.const', -1]
2806
+ if (getConst(a)?.value === NEG164 && isPure(b)) return ['i64.const', -1]
1940
2807
  return null
1941
2808
  },
1942
2809
 
@@ -1963,7 +2830,12 @@ const peephole = (ast) => {
1963
2830
 
1964
2831
  /** Bytes a signed-LEB128 integer encodes to. */
1965
2832
  const slebSize = (v) => {
1966
- let x = typeof v === 'bigint' ? v : BigInt(Math.trunc(Number(v) || 0))
2833
+ let x = typeof v === 'bigint' ? v
2834
+ : typeof v === 'string' ? BigInt(v.replaceAll('_', ''))
2835
+ : BigInt(Math.trunc(Number(v) || 0))
2836
+ // Signed view of raw bits — exact natively; in-kernel the arm is dead
2837
+ // (BigInt('0x…') already arrives as the signed i64 carrier there).
2838
+ if (x > 0x7fffffffffffffffn) x = x - 0x8000000000000000n - 0x8000000000000000n
1967
2839
  let n = 1
1968
2840
  while (true) {
1969
2841
  const b = x & 0x7fn
@@ -2433,7 +3305,7 @@ const dedupe = (ast) => {
2433
3305
  walk(node, (n) => {
2434
3306
  if (!Array.isArray(n) || typeof n[1] !== 'string' || n[1][0] !== '$') return
2435
3307
  const op = n[0]
2436
- if (op === 'param' || op === 'local' || op === 'block' || op === 'loop' || op === 'if') {
3308
+ if (op === 'param' || op === 'local' || isBranchScope(op)) {
2437
3309
  localNames.add(n[1])
2438
3310
  }
2439
3311
  })
@@ -2561,53 +3433,62 @@ const dedupTypes = (ast) => {
2561
3433
 
2562
3434
  // ==================== DATA SEGMENT PACKING ====================
2563
3435
 
2564
- /** Parse a WAT data string literal into Uint8Array */
3436
+ /** Parse a WAT data string literal into a plain byte array. Plain arrays —
3437
+ * not Uint8Array — throughout the data codecs: typed-array views/methods have
3438
+ * spotty native lowerings (subarray dispatches to the HOST, in-situ variable-
3439
+ * index reads misread in the kernel), while plain number arrays are the
3440
+ * optimizer's lingua franca and proven kernel-faithful. */
2565
3441
  const parseDataString = (str) => {
2566
- if (typeof str !== 'string' || str.length < 2 || str[0] !== '"') return new Uint8Array()
2567
- const inner = str.slice(1, -1)
3442
+ if (typeof str !== 'string' || str.length < 2 || str[0] !== '"') return []
2568
3443
  const bytes = []
2569
- for (let i = 0; i < inner.length; i++) {
2570
- if (inner[i] === '\\') {
2571
- const next = inner[++i]
2572
- if (next === 'x' || next === 'X') {
2573
- bytes.push(parseInt(inner.slice(i + 1, i + 3), 16))
2574
- i += 2
2575
- } else if (/[0-9a-fA-F]/.test(next) && /[0-9a-fA-F]/.test(inner[i + 1])) {
2576
- bytes.push(parseInt(inner.slice(i, i + 2), 16))
2577
- i++
2578
- } else if (next === 'n') bytes.push(10)
2579
- else if (next === 't') bytes.push(9)
2580
- else if (next === 'r') bytes.push(13)
2581
- else if (next === '\\') bytes.push(92)
2582
- else if (next === '"') bytes.push(34)
2583
- else bytes.push(next.charCodeAt(0))
3444
+ // Hex digit value by char code, −1 for non-hex — pure number math (no
3445
+ // regex/slice/parseInt on string views; see contract note above).
3446
+ const hexv = (c) => c >= 48 && c <= 57 ? c - 48 : c >= 97 && c <= 102 ? c - 87 : c >= 65 && c <= 70 ? c - 55 : -1
3447
+ const end = str.length - 1 // skip surrounding quotes
3448
+ for (let i = 1; i < end; i++) {
3449
+ const c = str.charCodeAt(i)
3450
+ if (c !== 92) { bytes.push(c); continue }
3451
+ const n = str.charCodeAt(++i)
3452
+ if (n === 120 || n === 88) { // \xHH
3453
+ bytes.push((hexv(str.charCodeAt(i + 1)) << 4) | hexv(str.charCodeAt(i + 2)))
3454
+ i += 2
2584
3455
  } else {
2585
- bytes.push(inner.charCodeAt(i))
3456
+ const h1 = hexv(n), h2 = i + 1 < end ? hexv(str.charCodeAt(i + 1)) : -1
3457
+ if (h1 >= 0 && h2 >= 0) { bytes.push((h1 << 4) | h2); i++ }
3458
+ else if (n === 110) bytes.push(10) // \n
3459
+ else if (n === 116) bytes.push(9) // \t
3460
+ else if (n === 114) bytes.push(13) // \r
3461
+ else bytes.push(n) // \\ \" and any other escaped char
2586
3462
  }
2587
3463
  }
2588
- return new Uint8Array(bytes)
3464
+ return bytes
2589
3465
  }
2590
3466
 
2591
- /** Encode Uint8Array as WAT data string literal */
2592
- const encodeDataString = (bytes) => {
3467
+ /** Encode a plain byte array as a WAT data string literal; `end` bounds the
3468
+ * bytes (always passed explicitly — see parseDataString's contract note).
3469
+ * (`b` comes from a plain-array element read, i.e. an untyped receiver — the
3470
+ * `.toString(16)` here is exactly the dispatch the tryRuntimeNumberMethod /
3471
+ * runtime-string-fork number arm exists for; it used to yield `undefined`
3472
+ * in-kernel and zeroed every escaped byte of the emitted data segment.) */
3473
+ const encodeDataString = (bytes, end) => {
2593
3474
  let str = '"'
2594
- for (let i = 0; i < bytes.length; i++) {
3475
+ for (let i = 0; i < end; i++) {
2595
3476
  const b = bytes[i]
2596
- if (b >= 32 && b < 127 && b !== 34 && b !== 92) {
2597
- str += String.fromCharCode(b)
2598
- } else {
2599
- str += '\\' + b.toString(16).padStart(2, '0')
2600
- }
3477
+ if (b >= 32 && b < 127 && b !== 34 && b !== 92) str += String.fromCharCode(b)
3478
+ else str += '\\' + b.toString(16).padStart(2, '0')
2601
3479
  }
2602
3480
  return str + '"'
2603
3481
  }
2604
3482
 
2605
- /** Trim trailing zeros from data content items */
3483
+ /** Trim trailing zeros from data content items. Per-byte pushes (never
3484
+ * push(...spread) — a segment can be hundreds of KB and spreading overflows
3485
+ * V8's argument stack; never Uint8Array — see parseDataString's note). */
2606
3486
  const trimTrailingZeros = (items) => {
2607
3487
  const bytes = []
2608
3488
  for (const item of items) {
2609
3489
  if (typeof item === 'string') {
2610
- bytes.push(...parseDataString(item))
3490
+ const chunk = parseDataString(item)
3491
+ for (let i = 0; i < chunk.length; i++) bytes.push(chunk[i])
2611
3492
  } else if (Array.isArray(item) && item[0] === 'i8') {
2612
3493
  for (let i = 1; i < item.length; i++) bytes.push(Number(item[i]) & 0xff)
2613
3494
  } else {
@@ -2618,7 +3499,7 @@ const trimTrailingZeros = (items) => {
2618
3499
  while (end > 0 && bytes[end - 1] === 0) end--
2619
3500
  if (end === bytes.length) return items
2620
3501
  if (end === 0) return []
2621
- return [encodeDataString(new Uint8Array(bytes.slice(0, end)))]
3502
+ return [encodeDataString(bytes, end)]
2622
3503
  }
2623
3504
 
2624
3505
  /** Extract { memidx, offset } from an active data segment with constant offset */
@@ -2676,11 +3557,9 @@ const mergeDataSegments = (a, b) => {
2676
3557
  typeof aContent[0] === 'string' && typeof bContent[0] === 'string') {
2677
3558
  const aBytes = parseDataString(aContent[0])
2678
3559
  const bBytes = parseDataString(bContent[0])
2679
- const merged = new Uint8Array(aBytes.length + bBytes.length)
2680
- merged.set(aBytes)
2681
- merged.set(bBytes, aBytes.length)
3560
+ for (let i = 0; i < bBytes.length; i++) aBytes.push(bBytes[i])
2682
3561
  a.length = aIdx
2683
- a.push(encodeDataString(merged))
3562
+ a.push(encodeDataString(aBytes, aBytes.length))
2684
3563
  return true
2685
3564
  }
2686
3565
 
@@ -2865,12 +3744,14 @@ const reorder = (ast) => {
2865
3744
  const PASSES = [
2866
3745
  ['stripmut', stripmut, true, 'strip mut from never-written globals'],
2867
3746
  ['globals', globals, true, 'propagate immutable global constants'],
3747
+ ['guardRefine', guardRefine, false, 'fold NaN-box tag reads under dominating tag guards (jz NaN-box-specific; opt-in)'],
2868
3748
  ['fold', fold, true, 'constant folding'],
2869
3749
  ['identity', identity, true, 'remove identity ops (x + 0 → x)'],
2870
3750
  ['peephole', peephole, true, 'x-x→0, x&0→0, etc.'],
2871
3751
  ['strength', strength, true, 'strength reduction (x * 2 → x << 1)'],
2872
3752
  ['branch', branch, true, 'simplify constant branches'],
2873
3753
  ['propagate', propagate, true, 'forward-propagate single-use locals & tiny consts (never inflates)'],
3754
+ ['devirt', devirt, false, 'call_indirect with a constant or known closure-const index → direct/guarded calls — grows bytes for speed'],
2874
3755
  ['inlineOnce', inlineOnce, true, 'inline single-call functions into their lone caller (never duplicates)'],
2875
3756
  ['inline', inline, false, 'inline tiny functions — can duplicate bodies'],
2876
3757
  ['offset', offset, true, 'fold add+const into load/store offset'],
@@ -2928,8 +3809,62 @@ const normalize = (opts) => {
2928
3809
  * optimize(ast, 'treeshake') // only treeshake
2929
3810
  * optimize(ast, { fold: true }) // explicit
2930
3811
  */
3812
+ /**
3813
+ * Could `inlineOnce`/`inline` grow the binary on this module? They are the only
3814
+ * size-*increasing* passes: splicing a callee body plus its `block`/param-setup
3815
+ * wrapper can exceed the `call` it removes. Every other pass strictly shrinks or
3816
+ * holds. So if no function is even a candidate (called exactly once, not pinned
3817
+ * by export/start/elem/ref.func, not its own exporter), nothing can inflate and
3818
+ * the size guard is dead weight. Cheap over-approximation of inlineOnce's own
3819
+ * gating — a false positive only costs the guarded path (correct, just slower).
3820
+ */
3821
+ const mayInline = (ast) => {
3822
+ if (!Array.isArray(ast)) return false
3823
+ const callRefs = new Map(), pinned = new Set(), other = new Set()
3824
+ const scan = (n) => {
3825
+ if (!Array.isArray(n)) return
3826
+ const op = n[0]
3827
+ if (op === 'call' && typeof n[1] === 'string') callRefs.set(n[1], (callRefs.get(n[1]) || 0) + 1)
3828
+ else if (op === 'return_call' && typeof n[1] === 'string') other.add(n[1])
3829
+ else if (op === 'ref.func' && typeof n[1] === 'string') pinned.add(n[1])
3830
+ else if (op === 'export' && Array.isArray(n[2]) && n[2][0] === 'func' && typeof n[2][1] === 'string') pinned.add(n[2][1])
3831
+ else if (op === 'start' && typeof n[1] === 'string') pinned.add(n[1])
3832
+ else if (op === 'elem') for (const c of n) if (typeof c === 'string' && c[0] === '$') pinned.add(c)
3833
+ for (let i = 1; i < n.length; i++) scan(n[i])
3834
+ }
3835
+ scan(ast)
3836
+ for (const n of ast) {
3837
+ if (!Array.isArray(n) || n[0] !== 'func' || typeof n[1] !== 'string') continue
3838
+ const name = n[1]
3839
+ if (callRefs.get(name) !== 1 || pinned.has(name) || other.has(name)) continue
3840
+ if (n.some(c => Array.isArray(c) && c[0] === 'export')) continue // self-exporting func
3841
+ return true
3842
+ }
3843
+ return false
3844
+ }
3845
+
3846
+ // A `__start` whose body DCE emptied down to nothing — plus its `(start)`
3847
+ // directive — is pure noise: the directive invokes a no-op. jz's buildStartFn
3848
+ // only emits `__start` when it has content, so an empty one is always a post-DCE
3849
+ // artifact (e.g. a top-level `1 + 2;` whose dropped value the dead-code pass
3850
+ // removed). Drop both. Header nodes (param/result/local/export/type) don't count
3851
+ // as a body — a function carrying only locals still does nothing.
3852
+ const START_HEAD = new Set(['export', 'type', 'param', 'result', 'local'])
3853
+ function pruneEmptyStart(ast) {
3854
+ if (!Array.isArray(ast) || ast[0] !== 'module') return ast
3855
+ const fn = ast.find(n => Array.isArray(n) && n[0] === 'func' && n[1] === '$__start')
3856
+ if (!fn) return ast
3857
+ // Skip the name (index 1) and header nodes; a bare-string node past them is a
3858
+ // real instruction (`drop`/`nop`/`unreachable`), so it stops the scan.
3859
+ let b = 2
3860
+ while (b < fn.length && Array.isArray(fn[b]) && START_HEAD.has(fn[b][0])) b++
3861
+ if (b < fn.length) return ast // real instructions remain
3862
+ return ast.filter(n => !(Array.isArray(n) &&
3863
+ ((n[0] === 'func' && n[1] === '$__start') || (n[0] === 'start' && n[1] === '$__start'))))
3864
+ }
3865
+
2931
3866
  export default function optimize(ast, opts = true) {
2932
- if (typeof ast === 'string') ast = parse(ast)
3867
+ if (typeof ast === 'string') ast = parse(ast) // accept WAT source directly
2933
3868
  const strictGuard = opts === true // default: zero tolerance for bloat
2934
3869
  opts = normalize(opts)
2935
3870
 
@@ -2937,48 +3872,88 @@ export default function optimize(ast, opts = true) {
2937
3872
  const verbose = opts.verbose || opts.log
2938
3873
 
2939
3874
  ast = clone(ast)
2940
- let beforeRound = null
2941
3875
 
2942
- // Size guard works on encoded bytes, not AST node count: passes like
2943
- // `globals` / `inlineOnce` are node-count-neutral yet move real bytes
2944
- // (a `global.get` a fat `f64.const`; a `call` an inlined body), so a
2945
- // node-count guard can't tell when a round bloated or shrank. `binarySize`
2946
- // also returns Infinity if a round produced invalid wat, so a broken round
2947
- // reverts instead of escaping.
3876
+ // devirt trades bytes for speed by design (guards + duplicated args), so it
3877
+ // runs ONCE after the rounds its candidate shape (select of two i64 closure
3878
+ // constants) only emerges from fold/propagate, and its intended growth must
3879
+ // not trip the size-guard into reverting a whole round. A single sweep is
3880
+ // complete: every call_indirect is visited; rewritten sites keep the original
3881
+ // as the guarded fallback arm.
3882
+ // `inline` (multi-caller, size-for-speed) is like `devirt`: it INTENTIONALLY grows
3883
+ // the binary, so it must run OUTSIDE the per-round size-revert guard below (which
3884
+ // would otherwise undo it). Run it once after the rounds converge, then tidy the
3885
+ // (block (local.set $p arg) … body) wrappers it leaves with the same cleanup passes
3886
+ // a normal round would. opt-in (speed level); a no-op when no small callee qualifies.
3887
+ const runInline = (a) => {
3888
+ if (!opts.inline) return a
3889
+ // `inline: 'simd'` → SIMD-helper-only (jz's speed tier, avoids general bloat);
3890
+ // `inline: true` / `'all'` → general inlining of tiny functions.
3891
+ a = inline(a, { simdOnly: opts.inline === 'simd' })
3892
+ if (opts.propagate) a = propagate(a)
3893
+ if (opts.mergeBlocks) a = mergeBlocks(a)
3894
+ if (opts.vacuum) a = vacuum(a)
3895
+ if (opts.coalesceLocals) a = coalesceLocals(a)
3896
+ return a
3897
+ }
3898
+ const finish = (a) => { a = runInline(a); return pruneEmptyStart(opts.devirt ? devirt(a) : a) }
3899
+
3900
+ // Fast path: jz owns this optimizer and feeds it a controlled, type-aware IR.
3901
+ // The only passes that can *grow* the binary are inlineOnce/inline; when no
3902
+ // function is an inline candidate (the common case for scalar REPL kernels)
3903
+ // nothing can inflate, so we skip watr's per-round `binarySize` re-compile
3904
+ // guard — up to four full encodes per call — and iterate to a fixpoint with
3905
+ // zero compiles. A round that changes nothing is the natural exit.
3906
+ if (!((opts.inlineOnce || opts.inline) && mayInline(ast))) {
3907
+ // inlineOnce/inline can't fire here, so skip them — their candidate scan
3908
+ // (a 16-round whole-module walk) is the second-costliest thing after
3909
+ // propagate, and it would only confirm what `mayInline` already proved.
3910
+ for (let round = 0; round < 3; round++) {
3911
+ const beforeRound = clone(ast)
3912
+ for (const [key, fn] of PASSES) if (opts[key] && key !== 'inlineOnce' && key !== 'inline' && key !== 'devirt') ast = fn(ast)
3913
+ if (equal(beforeRound, ast)) break // fixpoint
3914
+ if (verbose) log(` round ${round + 1} applied`)
3915
+ }
3916
+ return finish(ast)
3917
+ }
3918
+
3919
+ // Guarded path: inlining can inflate (a body bigger than the call it replaces),
3920
+ // so score each round on encoded bytes and revert any that grows the binary.
3921
+ // `binarySize` returns Infinity for invalid wat, so a broken round reverts too.
3922
+ // A round's starting size equals the prior round's ending size, so carry it
3923
+ // forward and compile once per round.
3924
+ let beforeRound = null
3925
+ let sizeBefore = binarySize(ast)
2948
3926
  for (let round = 0; round < 3; round++) {
2949
3927
  beforeRound = clone(ast)
2950
- const sizeBefore = binarySize(ast)
2951
3928
 
2952
- for (const [key, fn] of PASSES) if (opts[key]) ast = fn(ast)
3929
+ for (const [key, fn] of PASSES) if (opts[key] && key !== 'devirt' && key !== 'inline') ast = fn(ast)
2953
3930
  // Second propagate sweep: `inlineOnce`/`inline` (above) leave fresh
2954
3931
  // `(local.set $p arg) … (local.get $p)` wrappers around each inlined call;
2955
3932
  // re-running propagation collapses them within this same round, so the size
2956
- // guard scores the cleaned result instead of waiting a round (which it may
2957
- // never get if `equal()` declares a fixpoint first).
3933
+ // guard scores the cleaned result.
2958
3934
  if (opts.propagate && (opts.inlineOnce || opts.inline)) ast = propagate(ast)
2959
3935
 
3936
+ // A round that changed nothing can't have inflated — check convergence
3937
+ // before compiling so the fixpoint-confirming round costs zero compiles.
3938
+ if (equal(beforeRound, ast)) break
3939
+
2960
3940
  const sizeAfter = binarySize(ast)
2961
3941
  const delta = sizeAfter - sizeBefore
3942
+ if (verbose || delta !== 0) log(` round ${round + 1}: ${delta > 0 ? '+' : ''}${delta} bytes`, delta)
2962
3943
 
2963
- if (verbose || delta !== 0) {
2964
- log(` round ${round + 1}: ${delta > 0 ? '+' : ''}${delta} bytes`, delta)
2965
- }
2966
-
2967
- // Size guard: default optimize must never inflate. Explicit passes get a
2968
- // little leniency (a round may grow a few bytes setting up a bigger win).
3944
+ // Default optimize must never inflate; explicit passes get slight leniency.
2969
3945
  const tolerance = strictGuard ? 0 : 16
2970
3946
  if (delta > tolerance) {
2971
3947
  if (verbose) log(` ⚠ round ${round + 1} inflated by ${delta} bytes, reverting`, delta)
2972
3948
  ast = beforeRound
2973
3949
  break
2974
3950
  }
2975
-
2976
- if (equal(beforeRound, ast)) break
3951
+ sizeBefore = sizeAfter
2977
3952
  }
2978
3953
 
2979
- return ast
3954
+ return finish(ast)
2980
3955
  }
2981
3956
 
2982
3957
  /** Count AST nodes (fast size heuristic). */
2983
3958
  export { count as size, count, binarySize }
2984
- export { optimize, treeshake, fold, deadcode, localReuse, identity, strength, branch, propagate, inline, inlineOnce, normalize, OPTS, vacuum, peephole, globals, offset, unbranch, loopify, stripmut, brif, foldarms, dedupe, reorder, dedupTypes, packData, minifyImports, mergeBlocks, coalesceLocals }
3959
+ export { optimize, treeshake, fold, deadcode, localReuse, identity, strength, branch, propagate, inline, inlineOnce, devirt, normalize, OPTS, vacuum, peephole, globals, offset, unbranch, loopify, stripmut, brif, foldarms, dedupe, reorder, dedupTypes, packData, minifyImports, mergeBlocks, coalesceLocals }