jz 0.8.0 → 0.9.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.
Files changed (73) hide show
  1. package/README.md +56 -9
  2. package/bench/README.md +121 -50
  3. package/bench/bench.svg +27 -27
  4. package/cli.js +3 -1
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +7541 -6178
  7. package/index.js +165 -74
  8. package/interop.js +189 -17
  9. package/jzify/arguments.js +32 -1
  10. package/jzify/async.js +409 -0
  11. package/jzify/classes.js +136 -18
  12. package/jzify/generators.js +639 -0
  13. package/jzify/hoist-vars.js +73 -1
  14. package/jzify/index.js +123 -4
  15. package/jzify/names.js +1 -0
  16. package/jzify/transform.js +239 -1
  17. package/layout.js +48 -3
  18. package/module/array.js +318 -43
  19. package/module/atomics.js +144 -0
  20. package/module/collection.js +1429 -155
  21. package/module/core.js +431 -40
  22. package/module/date.js +142 -120
  23. package/module/fs.js +144 -0
  24. package/module/function.js +6 -3
  25. package/module/index.js +4 -1
  26. package/module/json.js +270 -49
  27. package/module/math.js +1032 -145
  28. package/module/number.js +532 -163
  29. package/module/object.js +353 -95
  30. package/module/regex.js +157 -7
  31. package/module/schema.js +100 -2
  32. package/module/string.js +428 -93
  33. package/module/typedarray.js +264 -72
  34. package/module/web.js +36 -0
  35. package/package.json +5 -5
  36. package/src/abi/string.js +82 -11
  37. package/src/ast.js +11 -5
  38. package/src/autoload.js +28 -1
  39. package/src/bridge.js +7 -2
  40. package/src/compile/analyze-scans.js +22 -7
  41. package/src/compile/analyze.js +136 -30
  42. package/src/compile/dyn-closure-tables.js +277 -0
  43. package/src/compile/emit-assign.js +281 -15
  44. package/src/compile/emit.js +1055 -70
  45. package/src/compile/index.js +277 -29
  46. package/src/compile/infer.js +42 -4
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/loop-recurrence.js +167 -0
  49. package/src/compile/narrow.js +555 -18
  50. package/src/compile/peel-stencil.js +5 -0
  51. package/src/compile/plan/index.js +45 -3
  52. package/src/compile/plan/inline.js +8 -1
  53. package/src/compile/plan/literals.js +39 -0
  54. package/src/compile/plan/scope.js +430 -23
  55. package/src/compile/program-facts.js +732 -38
  56. package/src/ctx.js +64 -9
  57. package/src/helper-counters.js +7 -1
  58. package/src/ir.js +113 -5
  59. package/src/kind-traits.js +66 -3
  60. package/src/kind.js +84 -12
  61. package/src/op-policy.js +7 -4
  62. package/src/optimize/index.js +1179 -704
  63. package/src/optimize/recurse.js +2 -2
  64. package/src/optimize/vectorize.js +982 -67
  65. package/src/prepare/index.js +809 -67
  66. package/src/prepare/math-kernel.js +331 -0
  67. package/src/prepare/pre-eval.js +714 -0
  68. package/src/snapshot.js +194 -0
  69. package/src/type.js +1170 -56
  70. package/src/wat/assemble.js +403 -65
  71. package/src/wat/codegen.js +74 -14
  72. package/transform.js +113 -4
  73. package/wasi.js +3 -0
@@ -21,7 +21,7 @@ import { ctx, inc, resolveIncludes, err, PTR, LAYOUT, HEAP, declGlobal } from '.
21
21
  // it re-tokenizes ~KB of text every compile. Parse once per distinct resolved
22
22
  // string, then hand out a deep clone (downstream passes mutate nodes in place).
23
23
  // Module-level on purpose: the cache persists across compile() calls.
24
- const stdlibParseCache = new Map() // resolved WAT string → pristine parsed tree
24
+ let stdlibParseCache = new Map() // resolved WAT string → pristine parsed tree
25
25
  const cloneTemplate = (node) => {
26
26
  if (!Array.isArray(node)) return node
27
27
  const copy = node.map(cloneTemplate)
@@ -33,12 +33,18 @@ const parseTemplate = (str) => {
33
33
  if (tmpl === undefined) stdlibParseCache.set(str, tmpl = parseWat(str))
34
34
  return cloneTemplate(tmpl)
35
35
  }
36
+ // Self-host-only: see clearDollar (src/ir.js) — same dangling-arena-pointer hazard,
37
+ // and the same fix: swap in a fresh Map, don't just `.clear()` the old one (its
38
+ // backing table is itself an arena allocation `_clear` invalidates). Must run every
39
+ // compile in a warm-instance loop (see scripts/self.js setupSelf).
40
+ export const clearStdlibParseCache = () => { stdlibParseCache = new Map() }
36
41
  import { T } from '../ast.js'
37
42
  import { analyzeValTypes, analyzeBody } from '../compile/analyze.js'
38
43
  import { VAL } from '../reps.js'
39
- import { optimizeFunc, collectVolatileGlobals, collectReachableGlobalWrites, hoistGlobalPtrOffset, stablePtrGlobalNames, hoistConstantPool, specializeMkptr, specializePtrBase, sortStrPoolByFreq, arenaRewindModule } from '../optimize/index.js'
44
+ import { optimizeFunc, collectVolatileGlobals, collectReachableGlobalWrites, hoistGlobalPtrOffset, hoistLoopGlobalPtrOffset, stablePtrGlobalNames, hoistConstantPool, specializeMkptr, arenaRewindModule, buildPureFuncMap, inlinePureFnsInFn } from '../optimize/index.js'
40
45
  import { emit, emitVoid } from '../compile/emit.js'
41
- import { mkPtrIR, MAX_CLOSURE_ARITY, MEM_OPS, findBodyStart } from '../ir.js'
46
+ import { mkPtrIR, MAX_CLOSURE_ARITY, MEM_OPS, findBodyStart, extractF64Bits, asF64, appendStaticSlots } from '../ir.js'
47
+ import { staticArrayPtr } from '../../module/array.js'
42
48
  import { installHelperCounters, instrumentHelperCounter } from '../helper-counters.js'
43
49
 
44
50
  // NaN-prefix top-13-bits as BigInt — used by the static-prefix-strip pass
@@ -105,15 +111,20 @@ function applyArenaRewind(func, fn, safeCallees) {
105
111
  const restore = () => heapSetIR(['local.get', save])
106
112
  const resultType = func.sig.results[0]
107
113
 
114
+ // Rewrite the return's VALUE, not the return: `return` is stack-polymorphic
115
+ // (never falls through), so it validates in statement AND value position alike.
116
+ // The old form — a value-typed block AROUND the return — reified `(result T)`
117
+ // even where the return was a statement, leaving a phantom value on the stack
118
+ // of a void enclosing frame (a `return` inside try_table failed validation:
119
+ // "expected 0 elements on the stack for fallthru, found 1").
108
120
  const rewriteReturns = node => {
109
121
  if (!Array.isArray(node)) return node
110
122
  if (node[0] === 'return' && node.length > 1) {
111
- return ['block',
123
+ return ['return', ['block',
112
124
  ['result', resultType],
113
125
  ['local.set', ret, node[1]],
114
126
  restore(),
115
- ['return', ['local.get', ret]],
116
- ['unreachable']]
127
+ ['local.get', ret]]]
117
128
  }
118
129
  for (let i = 1; i < node.length; i++) node[i] = rewriteReturns(node[i])
119
130
  return node
@@ -222,6 +233,8 @@ export function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
222
233
  // table holding only empty schemas is pure dead weight there. __json_obj has
223
234
  // no such guard — it must read the table whenever stringify is in play.
224
235
  const tblConsumed = hasStringify ||
236
+ // Inline readers (object.js enumeration scaffolds) — no named helper to count.
237
+ ctx.runtime.schemaTblConsumed ||
225
238
  ctx.core.includes.has('__obj_clone') ||
226
239
  ctx.core.includes.has('__dyn_get') ||
227
240
  ctx.core.includes.has('__dyn_get_t') ||
@@ -245,29 +258,63 @@ export function buildStartFn(ast, sec, closureFuncs, compilePendingClosures) {
245
258
  if (needsSchemaTbl) {
246
259
  const nSchemas = ctx.schema.list.length
247
260
  const runtimeReserve = hasJpObj ? 256 : 0
248
- const stbl = `${T}stbl`
249
- const sarr = `${T}sarr`
250
- ctx.func.locals.set(stbl, 'i32')
251
- ctx.func.locals.set(sarr, 'i32')
252
- inc('__alloc', '__alloc_hdr', '__mkptr')
253
- schemaInit.push(
254
- ['local.set', `$${stbl}`, ['call', '$__alloc', ['i32.const', (nSchemas + runtimeReserve) * 8]]],
255
- ['global.set', '$__schema_tbl', ['local.get', `$${stbl}`]])
256
- if (runtimeReserve) {
257
- schemaInit.push(['global.set', '$__schema_next', ['i32.const', nSchemas]])
261
+ // Pre-eval tier 2: the schema NAME TABLE is compile-time data — every key is a
262
+ // static string literal, every keys-array a constant, the table an array of
263
+ // constant boxed pointers. Lay it out in the data segment (staticArrayPtr per
264
+ // schema + one slot run for the table incl. a zeroed runtime reserve) and bake
265
+ // __schema_tbl/__schema_next as GLOBAL INITS: zero init code, and the reserve
266
+ // (JSON.parse-registered runtime schemas) lives in writable data the globals
267
+ // sweep correctly rewinds at _clear (runtime schemas are round state).
268
+ let staticBits = ctx.memory.shared ? null : []
269
+ if (staticBits) for (const keys of ctx.schema.list) {
270
+ const bits = keys.map(k => extractF64Bits(asF64(emit(['str', String(k)]))))
271
+ if (bits.some(b => b == null)) { staticBits = null; break }
272
+ staticBits.push(extractF64Bits(staticArrayPtr(bits)))
258
273
  }
259
- for (let s = 0; s < nSchemas; s++) {
260
- const keys = ctx.schema.list[s]
261
- const n = keys.length
274
+ if (staticBits) {
275
+ const tblOff = appendStaticSlots([...staticBits, ...Array(runtimeReserve).fill('0x0000000000000000')])
276
+ // The consumers declGlobal '__schema_tbl' lazily at TEMPLATE EXPANSION
277
+ // (pullStdlib) — AFTER this runs. Declare it here so the static offset
278
+ // lands as the initializer instead of silently missing a not-yet-declared
279
+ // global (which then defaulted 0 and the `$__schema_tbl == 0` guards
280
+ // disabled the whole schema arm — for-in enumerated zero keys).
281
+ if (!ctx.scope.globals.has('__schema_tbl')) declGlobal('__schema_tbl', 'i32')
282
+ const tblG = ctx.scope.globals.get('__schema_tbl')
283
+ if (tblG) tblG.init = tblOff
284
+ // Raw-i32 global init pointing into static data: stripStaticDataPrefix
285
+ // patches BOXED slots via staticPtrSlots, but a global's declared init
286
+ // needs its own shift (same as __internBase's bespoke re-declare).
287
+ ;(ctx.runtime.staticI32GlobalInits ??= []).push('__schema_tbl')
288
+ if (runtimeReserve) {
289
+ if (!ctx.scope.globals.has('__schema_next')) declGlobal('__schema_next', 'i32')
290
+ const nextG = ctx.scope.globals.get('__schema_next')
291
+ if (nextG) nextG.init = nSchemas
292
+ }
293
+ } else {
294
+ const stbl = `${T}stbl`
295
+ const sarr = `${T}sarr`
296
+ ctx.func.locals.set(stbl, 'i32')
297
+ ctx.func.locals.set(sarr, 'i32')
298
+ inc('__alloc', '__alloc_hdr', '__mkptr')
262
299
  schemaInit.push(
263
- ['local.set', `$${sarr}`, ['call', '$__alloc_hdr', ['i32.const', n], ['i32.const', n]]])
264
- for (let k = 0; k < n; k++)
300
+ ['local.set', `$${stbl}`, ['call', '$__alloc', ['i32.const', (nSchemas + runtimeReserve) * 8]]],
301
+ ['global.set', '$__schema_tbl', ['local.get', `$${stbl}`]])
302
+ if (runtimeReserve) {
303
+ schemaInit.push(['global.set', '$__schema_next', ['i32.const', nSchemas]])
304
+ }
305
+ for (let s = 0; s < nSchemas; s++) {
306
+ const keys = ctx.schema.list[s]
307
+ const n = keys.length
265
308
  schemaInit.push(
266
- ['f64.store', ['i32.add', ['local.get', `$${sarr}`], ['i32.const', k * 8]],
267
- emit(['str', String(keys[k])])])
268
- schemaInit.push(
269
- ['f64.store', ['i32.add', ['local.get', `$${stbl}`], ['i32.const', s * 8]],
270
- mkPtrIR(PTR.ARRAY, 0, ['local.get', `$${sarr}`])])
309
+ ['local.set', `$${sarr}`, ['call', '$__alloc_hdr', ['i32.const', n], ['i32.const', n]]])
310
+ for (let k = 0; k < n; k++)
311
+ schemaInit.push(
312
+ ['f64.store', ['i32.add', ['local.get', `$${sarr}`], ['i32.const', k * 8]],
313
+ emit(['str', String(keys[k])])])
314
+ schemaInit.push(
315
+ ['f64.store', ['i32.add', ['local.get', `$${stbl}`], ['i32.const', s * 8]],
316
+ mkPtrIR(PTR.ARRAY, 0, ['local.get', `$${sarr}`])])
317
+ }
271
318
  }
272
319
  }
273
320
 
@@ -339,8 +386,15 @@ export function hoistConstGlobalInits(sec) {
339
386
  }
340
387
  // Hoisting can empty `__start`. The O2 watr pass prunes a bodyless start, but at
341
388
  // O0/O1 nothing else does — drop it (func + directive) here so a const-only module
342
- // carries no start at all.
343
- if (findBodyStart(startFn) >= startFn.length)
389
+ // carries no start at all. A body reduced to ONLY the `__heap_reset` capture
390
+ // (injected before this hoist ran) counts as empty too: with every init store
391
+ // folded away, nothing allocated, so the capture would store exactly the
392
+ // data-end seed `__heap_reset` already declares — tier-2 static-tree modules
393
+ // ship with no start section at all.
394
+ const bs = findBodyStart(startFn)
395
+ const captureOnly = startFn.length - bs === 1 &&
396
+ Array.isArray(startFn[bs]) && startFn[bs][0] === 'global.set' && startFn[bs][1] === '$__heap_reset'
397
+ if (bs >= startFn.length || captureOnly)
344
398
  for (let j = sec.start.length - 1; j >= 0; j--)
345
399
  if (Array.isArray(sec.start[j]) && sec.start[j][1] === '$__start') sec.start.splice(j, 1)
346
400
  }
@@ -529,13 +583,20 @@ function reachableStdlib(sec) {
529
583
  // (PPC_CALL2). These are the ONLY helpers appendLateStdlib may add; restricting to them avoids
530
584
  // touching helpers that live in other module sections (ext-stdlib, imports) where a blind
531
585
  // referenced-but-absent scan would wrongly re-append and duplicate them.
532
- const LATE_VEC_HELPERS = new Set(['math.sin2', 'math.cos2', 'math.pow2', 'math.atan2_2', 'math.hypot_2', 'math.log_v', 'math.exp_v', 'math.exp2_v'])
586
+ const LATE_VEC_HELPERS = new Set(['math.sin2', 'math.cos2', 'math.pow2', 'math.atan2_2', 'math.hypot_2', 'math.log_v', 'math.exp_v', 'math.exp2_v', 'math.cbrt_v', 'math.fifthroot_v',
587
+ // math.pow_fold (scalar) is normally eager-included by emitPow's own const-exponent fold (which
588
+ // always `inc()`s it before the vectorizer ever runs, under optimize.crPow — see module/math.js).
589
+ // It's ALSO listed here for the one path where that eager inc doesn't fire: a genuine runtime
590
+ // $math.pow(x,y) whose y is proven constant only during vectorization (vectorize.js's
591
+ // `$math.pow(x,c)` lift) — that rewrite calls pow_fold_v directly, so pow_fold_v's own
592
+ // dependency needs the same fixpoint append. Both only ever exist in the stdlib under crPow.
593
+ 'math.pow_fold_v', 'math.pow_fold'])
533
594
 
534
595
  // A late pass can reference one of the f64x2 mirrors that wasn't present when the stdlib was first
535
596
  // assembled. Append any referenced-but-missing mirror body (fixpoint over their own calls, though
536
597
  // the trig mirrors call nothing). moduleArr is mutated in place; non-mirror references are left for
537
598
  // watr to resolve (a genuine missing helper is the kernel's own pull, already satisfied).
538
- export function appendLateStdlib(moduleArr) {
599
+ export function appendLateStdlib(moduleArr, pushTarget = moduleArr) {
539
600
  const stdlib = ctx.core.stdlib
540
601
  const have = new Set()
541
602
  for (const n of moduleArr) if (Array.isArray(n) && n[0] === 'func' && typeof n[1] === 'string') have.add(n[1])
@@ -549,7 +610,12 @@ export function appendLateStdlib(moduleArr) {
549
610
  const name = ref.slice(1)
550
611
  if (have.has(ref) || !LATE_VEC_HELPERS.has(name) || stdlib[name] == null) continue
551
612
  const node = parseTemplate(typeof stdlib[name] === 'function' ? stdlib[name]() : stdlib[name])
552
- moduleArr.push(node[0] === 'module' ? node[1] : node)
613
+ const body = node[0] === 'module' ? node[1] : node
614
+ pushTarget.push(body)
615
+ // Keep the scan array in sync so the fixpoint can resolve a mirror that itself
616
+ // calls another mirror (cbrt_v → log_v/exp_v). When pushTarget IS moduleArr the
617
+ // single push already did this.
618
+ if (pushTarget !== moduleArr) moduleArr.push(body)
553
619
  have.add(ref)
554
620
  added = true
555
621
  }
@@ -584,7 +650,9 @@ export function pullStdlib(sec) {
584
650
  // host marshals across the boundary). A data segment with no memory is invalid wasm,
585
651
  // so memory can't be gated on allocation alone.
586
652
  const ALLOC_FUNCS = ['__alloc', '__alloc_hdr', '__alloc_hdr_n']
587
- const needsAlloc = !!ctx.runtime.strPool || ALLOC_FUNCS.some(a => reachable.has(a))
653
+ const needsAlloc = !!ctx.runtime.strPool || ALLOC_FUNCS.some(a => reachable.has(a)) ||
654
+ // shared memory memory.init's the static region into __alloc'd space at start
655
+ !!(ctx.memory.shared && ctx.runtime.data)
588
656
  // Memory ops can be emitted *inline* into user/start funcs (a heap-path char read
589
657
  // loads without calling a stdlib helper), so scan the emitted bodies too.
590
658
  const hasMemOp = (node) => Array.isArray(node) &&
@@ -609,25 +677,47 @@ export function pullStdlib(sec) {
609
677
  // are pulled in on demand, never eagerly, so they're already minimal and never pruned
610
678
  // here (guarding against any reachability blind spot in a dotted-name template).
611
679
  for (const n of [...ctx.core.includes]) if (n.startsWith('__') && !reachable.has(n)) ctx.core.includes.delete(n)
612
- // Lazy Eisel-Lemire table injection: only when __dec_to_f64 (correctly-rounded
613
- // decimal→f64, module/number.js) survived pruning append its trimmed 131-entry
614
- // (~2KB) power-of-10 table and declare $__el_tbl = that offset. Must run HERE so
615
- // dataPages (below) accounts for the addition; keeps it out of programs that never
616
- // parse decimals at runtime.
617
- if (ctx.core.includes.has('__dec_to_f64') && ctx.runtime.elTable) {
618
- const elBefore = ctx.runtime.data.length
680
+ // Lazy data-table injection Eisel-Lemire decimal→f64 (~2KB) and Ryū
681
+ // float→decimal (~9.7KB), module/number.js. Each table is appended only when
682
+ // its owning function survived pruning, and its base global declared at the
683
+ // offset. Must run HERE so dataPages (below) accounts for the addition; keeps
684
+ // the tables out of programs that never convert decimals at runtime.
685
+ //
686
+ // Reachability here OVER-counts: a dead inlined helper's `arr[i] | 0` on an
687
+ // untyped param pulls __to_num → __dec_to_f64, landing a table even when no
688
+ // LIVE code uses it. Record each span (they are the data tail — the last
689
+ // appends) so stripDeadLazyTables can excise dead ones post-lowering, once
690
+ // reachability is exact. Base globals register in staticI32GlobalInits so a
691
+ // later static-prefix strip shifts them like every other static offset.
692
+ ctx.runtime.lazySpans = []
693
+ const injectTable = (fn, global, bytes) => {
694
+ if (!ctx.core.includes.has(fn) || !bytes) return false
695
+ // ctx.runtime.data is normally already a string by here because module/number.js's
696
+ // setup (which seeds the static NaN/Infinity/… stringify prefix) runs unconditionally —
697
+ // but ONLY for a program that pulls in something from number.js. A program reaching
698
+ // this via a module with no such dependency (e.g. module/math.js's CR-pow tables, needed
699
+ // by any `**`/Math.pow call, independent of number formatting) can hit pullStdlib with
700
+ // ctx.runtime.data still at its unset default — initialize defensively.
701
+ ctx.runtime.data ??= ''
702
+ const start = ctx.runtime.data.length
619
703
  while (ctx.runtime.data.length % 8 !== 0) ctx.runtime.data += '\0'
620
- const elTblOff = ctx.runtime.data.length
621
- ctx.runtime.data += ctx.runtime.elTable
622
- declGlobal('__el_tbl', 'i32', elTblOff, { mut: false })
623
- ctx.runtime.elTable = null // prevent double-injection on re-entry (null-sentinel; jz forbids delete)
624
- // Reachability here OVER-counts __dec_to_f64: a dead inlined helper's `arr[i] | 0`
625
- // on an untyped param pulls __to_num → __dec_to_f64, landing the table even when no
626
- // LIVE code parses decimals. Record the appended span (padding + table, always the
627
- // data tail — it's the last append) so stripDeadElTable can drop it post-lowering,
628
- // once reachability is exact. See stripDeadElTable.
629
- ctx.runtime.elTableLen = ctx.runtime.data.length - elBefore
704
+ // Shared memory: the table lands via memory.init at a runtime base, so the
705
+ // global is MUTABLE and re-pointed at start (compile/index.js); its declared
706
+ // init meanwhile holds the offset WITHIN the static region.
707
+ declGlobal(global, 'i32', ctx.runtime.data.length, ctx.memory.shared ? undefined : { mut: false })
708
+ if (ctx.memory.shared && !ctx.scope.globals.has('__staticBase')) declGlobal('__staticBase', 'i32')
709
+ ctx.runtime.data += bytes
710
+ ;(ctx.runtime.staticI32GlobalInits ??= []).push(global)
711
+ ctx.runtime.lazySpans.push({ fn: '$' + fn, global, start, bytes })
712
+ return true
630
713
  }
714
+ // prevent double-injection on re-entry (null-sentinel; jz forbids delete)
715
+ if (injectTable('__dec_to_f64', '__el_tbl', ctx.runtime.elTable)) ctx.runtime.elTable = null
716
+ if (injectTable('__ftoa_shortest', '__ryu_tbl', ctx.runtime.ryuTable)) ctx.runtime.ryuTable = null
717
+ // CR-pow log2/exp2 breakpoint tables (module/math.js's $math.pow_transcend) — both gated on
718
+ // the SAME owning function since the shared kernel always needs both tables together.
719
+ if (injectTable('math.pow_transcend', 'math.pow_log2_tbl', ctx.runtime.powLog2Table)) ctx.runtime.powLog2Table = null
720
+ if (injectTable('math.pow_transcend', 'math.pow_exp2_tbl', ctx.runtime.powExp2Table)) ctx.runtime.powExp2Table = null
631
721
  if (!needsAlloc) { ctx.scope.globals.delete('__heap'); ctx.scope.globals.delete('__heap_reset') }
632
722
  if (needsMemory && ctx.module.modules.core) {
633
723
  if (needsAlloc) {
@@ -641,15 +731,174 @@ export function pullStdlib(sec) {
641
731
  // GLOBALS/atom tables), not into it. Done here — where `__heap` is known to
642
732
  // survive — as the last `__start` action before any non-returning timer loop.
643
733
  // No `__start` ⇒ no init allocations ⇒ `__heap_reset`'s data-end seed is right.
734
+ // Module-global snapshot sweep: `__clear` rewinds the arena, so ANY mutable module
735
+ // global still holding a pointer into it dangles — the whole warm-reuse landmine
736
+ // class (a lazy `let CACHE = null` cache, json's `__jbuf` stringify buffer, watr's
737
+ // in-kernel NCLS dict, a memoized string…). Fixing sites one at a time is
738
+ // whack-a-mole (eager-NCLS peeled "Unknown memory end" only to expose the next
739
+ // dangler behind it); the class fix is a CONTRACT: `_clear` restores every
740
+ // runtime-written module global to its post-`__start` value — warm behaves as
741
+ // fresh, minus the init cost. Blanket restore beats an is-ephemeral-pointer test:
742
+ // it also heals SCALAR poisoning (a cached length/hash derived from round-1 arena
743
+ // content is stale garbage even though it's no pointer). Mechanics: reserve one
744
+ // durable slab slot per candidate (allocated at `__start` tail — BEFORE the
745
+ // `__heap_reset` capture, so the slab sits under the watermark), store each
746
+ // global's post-init value there, and re-load it in `__clear`. Only globals the
747
+ // write-scan sees mutated OUTSIDE `__start` participate (read-only globals cannot
748
+ // dangle, and rooting them here would defeat watr's dead-global pruning); with no
749
+ // `__start` a global's post-init value IS its declared init — restore the constant
750
+ // directly, no slot. Excluded: the runtime-protocol globals (each has its own
751
+ // reset right here in `__clear` — resetting `__heap_reset` itself would be
752
+ // self-defeating), `__tof_*` coercion scratch (written-before-read within one
753
+ // expression, can never carry state across a round) and `__hc_*` helper counters
754
+ // (diagnostics must observe rounds, not be reset by them).
755
+ const globalRestores = []
644
756
  if (!ctx.memory.shared && ctx.scope.globals.has('__heap_reset')) {
645
757
  const startFn = sec.start.find(n => Array.isArray(n) && n[0] === 'func' && n[1] === '$__start')
758
+ const SNAP_PROTOCOL = new Set(['__heap', '__heap_reset', '__heap_start', '__dyn_props', '__dyn_props_filter',
759
+ '__dyn_get_cache_off', '__dyn_get_cache_props', '__durable_fwd_buf', '__durable_fwd_n', '__gsnap_base',
760
+ '__enumc_off', '__enumc_len', '__enumc_arr'])
761
+ const runtimeWritten = new Set()
762
+ const scanSet = (node) => {
763
+ if (!Array.isArray(node)) return
764
+ if (node[0] === 'global.set' && typeof node[1] === 'string' && node[1][0] === '$') runtimeWritten.add(node[1].slice(1))
765
+ for (const c of node) scanSet(c)
766
+ }
767
+ for (const fn of sec.funcs) scanSet(fn)
768
+ // stdlib bodies are still WAT text here (parseTemplate runs later) — scan textually.
769
+ // Helpers write registry globals too: collection's __seq, json's __jbuf/__jstack….
770
+ // Thunked templates expand ONCE by contract (expansion-time ctx reads) — memoize
771
+ // the expansion back into the registry so the later parseTemplate pass reuses this
772
+ // exact string instead of expanding a second time.
773
+ for (const name of ctx.core.includes) {
774
+ let src = ctx.core.stdlib[name]
775
+ if (typeof src === 'function') ctx.core.stdlib[name] = src = src()
776
+ if (typeof src !== 'string') continue
777
+ for (const m of src.matchAll(/\(global\.set \$([A-Za-z0-9_.$]+)/g)) runtimeWritten.add(m[1])
778
+ }
779
+ // Self-host divergence diagnostics (see resolveIncludes' twin block).
780
+ if (ctx.core.diagSink) ctx.core.diagSink.sweep = {
781
+ includes: [...ctx.core.includes].sort().join(' '),
782
+ runtimeWritten: [...runtimeWritten].sort().join(' '),
783
+ }
784
+ const SNAP_TYPES = { i32: 8, i64: 8, f32: 8, f64: 8, v128: 16 }
785
+ const snapSlots = [] // [name, type, slabOffset]
786
+ let slabBytes = 0
787
+ for (const name of runtimeWritten) {
788
+ const g = ctx.scope.globals.get(name)
789
+ if (!g || !g.mut || !SNAP_TYPES[g.type]) continue
790
+ if (SNAP_PROTOCOL.has(name) || name.startsWith('__tof_') || name.startsWith('__hc_')) continue
791
+ if (startFn) { snapSlots.push([name, g.type, slabBytes]); slabBytes += SNAP_TYPES[g.type] }
792
+ // no __start ⇒ post-init value = declared init: restore the constant, no slot
793
+ else globalRestores.push(`(global.set $${name} (${g.type}.const ${g.init ?? 0}))`)
794
+ }
795
+ if (ctx.core.diagSink?.sweep) {
796
+ ctx.core.diagSink.sweep.snapSlots = snapSlots.map(([n]) => n).sort().join(' ')
797
+ ctx.core.diagSink.sweep.restores = globalRestores.slice().sort().join(' ')
798
+ ctx.core.diagSink.sweep.hasStart = !!startFn
799
+ }
646
800
  if (startFn) {
801
+ // Tier 2 payoff: when module init folded away entirely (static trees,
802
+ // static schema table) and no global needs a snapshot slot, the ONLY
803
+ // thing left to do is the __heap_reset capture — whose value is exactly
804
+ // the seeded data-end init. Drop __start altogether.
805
+ if (!snapSlots.length && findBodyStart(startFn) >= startFn.length) {
806
+ const dirIdx = sec.start.findIndex(n => Array.isArray(n) && n[0] === 'start')
807
+ sec.start.length = 0
808
+ if (dirIdx !== -1) { /* directive lived in sec.start — cleared above */ }
809
+ } else {
647
810
  const capture = ['global.set', '$__heap_reset', ['global.get', '$__heap']]
811
+ const inject = [capture]
812
+ if (snapSlots.length) {
813
+ declGlobal('__gsnap_base', 'i32')
814
+ inject.unshift(['global.set', '$__gsnap_base', ['call', '$__alloc', ['i32.const', String(slabBytes)]]],
815
+ ...snapSlots.map(([name, type, off]) =>
816
+ [`${type}.store`, `offset=${off}`, ['global.get', '$__gsnap_base'], ['global.get', `$${name}`]]))
817
+ for (const [name, type, off] of snapSlots)
818
+ globalRestores.push(`(global.set $${name} (${type}.load offset=${off} (global.get $__gsnap_base)))`)
819
+ }
648
820
  const tail = startFn[startFn.length - 1]
649
- if (Array.isArray(tail) && tail[0] === 'call' && tail[1] === '$__timer_loop') startFn.splice(startFn.length - 1, 0, capture)
650
- else startFn.push(capture)
821
+ if (Array.isArray(tail) && tail[0] === 'call' && tail[1] === '$__timer_loop') startFn.splice(startFn.length - 1, 0, ...inject)
822
+ else startFn.push(...inject)
823
+ }
651
824
  }
652
825
  }
826
+ // __dyn_props reset: __clear rewinds the bump arena, but __dyn_props /
827
+ // __dyn_get_cache_off / __dyn_get_cache_props (module/collection.js) cache
828
+ // pointers/offsets INTO that arena across calls — a warm compile-clear-
829
+ // compile loop (self-host kernel: one instance, `_clear()` between compiles)
830
+ // needs them reset too, or a later compile can read a dangling pointer or,
831
+ // worse, alias a stale cached OFFSET onto a freshly-reused arena address
832
+ // (an ABA hazard, not just a dangling one). Only patched in when __dyn_set
833
+ // (the sole writer of __dyn_props) actually SURVIVED reachability pruning
834
+ // (line ~616, just above) — those globals are declared unconditionally
835
+ // whenever the collection module loads, so gating on mere declaration
836
+ // (`ctx.scope.globals.has`) would inject a dead `global.set $__dyn_props`
837
+ // into every such program, wasting bytes and leaking the __dyn_get_cache_*
838
+ // names into WAT text that never otherwise mentions dynamic props (tripping
839
+ // coarse `!/__dyn_get/.test(wat)`-style assertions — see test/closures.js).
840
+ // Both blocks below extend the SAME `__clear` body — accumulate into one
841
+ // shared list and rebuild once, so whichever runs second doesn't clobber the
842
+ // other's addition (a program can need both: dyn-props AND durable-growth
843
+ // relocation both reach here independent of each other).
844
+ const resets = []
845
+ if (ctx.core.includes.has('__dyn_set')) {
846
+ if (ctx.scope.globals.has('__dyn_props')) resets.push(`(global.set $__dyn_props (f64.const 0))`)
847
+ // The membership filter mirrors the table: emptying __dyn_props makes every
848
+ // set bit a stale false-positive — safe, but a warm compile-clear loop would
849
+ // saturate the filter and erode its skip rate. Reset them together.
850
+ if (ctx.scope.globals.has('__dyn_props_filter')) resets.push(`(global.set $__dyn_props_filter (i64.const 0))`)
851
+ if (ctx.scope.globals.has('__dyn_get_cache_off')) resets.push(`(global.set $__dyn_get_cache_off (i32.const -1))`)
852
+ if (ctx.scope.globals.has('__dyn_get_cache_props')) resets.push(`(global.set $__dyn_get_cache_props (f64.const 0))`)
853
+ }
854
+ // for-in enum cache (core.js __hash_keys_ro / object.js ro-enumeration):
855
+ // the cache keys a boxed array by table offset — both live in the arena
856
+ // __clear rewinds, so a later round could re-issue the cached offset to a
857
+ // NEW table and false-hit onto reclaimed memory (same ABA hazard as
858
+ // __dyn_get_cache_off above). Gated on enumcConsumed, not reachability:
859
+ // the OBJECT-arm fill sites are inline IR (no named helper to count).
860
+ if (ctx.runtime.enumcConsumed)
861
+ resets.push(`(global.set $__enumc_off (i32.const 0))`)
862
+ // Durable relocation heal (collection.js's durableFwdLogIR / core.js's
863
+ // __durable_fwd_log/__durable_fwd_heal): only reachable when some growable
864
+ // ARRAY/HASH/SET/MAP relocation site actually logged a durable→ephemeral
865
+ // forward this build — see durableFwdLogIR's header comment for the full
866
+ // rationale. Must run before the next round can allocate over the logged
867
+ // ephemeral targets, so it belongs in `__clear` alongside the arena rewind
868
+ // (order vs the rewind itself doesn't matter — `_clear` never zeroes memory,
869
+ // only moves the bump pointer — but keeping it grouped with the other resets
870
+ // reads as "finish with this round's bookkeeping, then reclaim its arena").
871
+ if (ctx.core.includes.has('__durable_fwd_log')) {
872
+ // __durable_fwd_heal is called ONLY from this injected `__clear` text — it has
873
+ // no OTHER call site for reachableStdlib (line ~582, already run) to have found
874
+ // it through, so (unlike __durable_fwd_log itself, whose deps() edges at every
875
+ // grow/shift call site make it self-host-robust — see test/selfhost-includes.js)
876
+ // it needs an explicit include here, mirroring the `__alloc`/`__alloc_hdr`/
877
+ // `__clear` late-add just above. `inc()`, not a raw `ctx.core.includes.add()`:
878
+ // the former is what test/selfhost-includes.js's source-scan recognizes as an
879
+ // explicit (self-host-safe) edge. No further resolveIncludes() needed:
880
+ // __durable_fwd_heal's body calls nothing else (raw i32 loads/stores + global
881
+ // get/set only).
882
+ inc('__durable_fwd_heal')
883
+ resets.push(`(call $__durable_fwd_heal)`)
884
+ }
885
+ // Durable SLOT heal (core.js __durable_slot_log/__durable_slot_heal — the
886
+ // entry/value sibling of the relocation heal above): every logged durable
887
+ // collection slot written this round is healed (inserted entries zombied +
888
+ // len decremented, overwritten values read undefined) before the arena
889
+ // rewinds. Ordered AFTER the fwd heal: a grown-then-healed table's len must
890
+ // already be its restored pre-grow value when the zombie decrements land.
891
+ // Same explicit-include pattern (its ONLY call site is this injected text).
892
+ if (ctx.core.includes.has('__durable_slot_log')) {
893
+ inc('__durable_slot_heal')
894
+ resets.push(`(call $__durable_slot_heal)`)
895
+ }
896
+ // Global-snapshot restores (see the sweep above) join the same rebuilt body.
897
+ // Order is free — restores touch only globals + the durable slab, which the
898
+ // rewind never moves — but bookkeeping-then-rewind-then-restore reads naturally.
899
+ if (resets.length || globalRestores.length) ctx.core.stdlib['__clear'] = `(func $__clear
900
+ (global.set $__heap (global.get $__heap_reset))
901
+ ${[...resets, ...globalRestores].join('\n ')})`
653
902
  }
654
903
  // Initial pages must cover the static data segment (it loads at instantiation), not
655
904
  // just the default 1 — otherwise a module whose constants exceed 64 KiB emits a data
@@ -659,7 +908,13 @@ export function pullStdlib(sec) {
659
908
  const dataPages = ctx.memory.shared ? 0 : Math.ceil((ctx.runtime.data?.length || 0) / 65536)
660
909
  const pages = Math.max(ctx.memory.pages || 1, dataPages)
661
910
  const max = ctx.memory.max || 0 // 0 = no maximum (unbounded growth)
662
- if (ctx.memory.shared) sec.imports.push(['import', '"env"', '"memory"', max ? ['memory', pages, max] : ['memory', pages]])
911
+ // Truly-shared memory (opts.sharedMemory) declares the `shared` memtype
912
+ // the spec requires an explicit max there (default: the wasm32 page ceiling).
913
+ // Plain imported memory (opts.importMemory / a Memory-valued opts.memory)
914
+ // stays non-shared, or a host passing an ordinary Memory could never link.
915
+ if (ctx.memory.shared) sec.imports.push(['import', '"env"', '"memory"',
916
+ ctx.memory.atomic ? ['memory', pages, max || 65536, 'shared']
917
+ : max ? ['memory', pages, max] : ['memory', pages]])
663
918
  else sec.memory.push(max ? ['memory', ['export', '"memory"'], pages, max] : ['memory', ['export', '"memory"'], pages])
664
919
  if (needsAlloc && ctx.transform.alloc !== false && ctx.core._allocRawFuncs)
665
920
  sec.funcs.push(...ctx.core._allocRawFuncs.map(parseTemplate))
@@ -696,13 +951,9 @@ export function optimizeModule(sec, profiler) {
696
951
  const cfg = ctx.transform.optimize
697
952
  if (!cfg || cfg.specializeMkptr !== false) t('specializeMkptr', () =>
698
953
  specializeMkptr([...sec.funcs, ...sec.stdlib, ...sec.start], wat => sec.stdlib.push(parseWat(wat)), parseWat))
699
- if (!cfg || cfg.specializePtrBase !== false) t('specializePtrBase', () =>
700
- specializePtrBase([...sec.funcs, ...sec.stdlib, ...sec.start], wat => sec.stdlib.push(parseWat(wat)), parseWat))
701
- if (ctx.runtime.strPool && (!cfg || cfg.sortStrPoolByFreq !== false)) t('sortStrPool', () => {
702
- const poolRef = { pool: ctx.runtime.strPool }
703
- sortStrPoolByFreq([...sec.funcs, ...sec.stdlib, ...sec.start], poolRef, ctx.runtime.strPoolDedup)
704
- ctx.runtime.strPool = poolRef.pool
705
- })
954
+ // (specializePtrBase and sortStrPoolByFreq deleted: byte-identical output with
955
+ // both disabled across the bench + examples corpora AND the self-host kernel at
956
+ // every watr tier watr's own inlining/offset folding subsumed them. ~350ms/corpus.)
706
957
  // (globalTypes backfill gone: declGlobal sets the type at declaration.)
707
958
  // Build global name→type map from ctx.scope.globalTypes (keys without $) for promoteGlobals
708
959
  const globalTypesMap = ctx.scope.globalTypes ? new Map([...ctx.scope.globalTypes].map(([k, v]) => [`$${k}`, v])) : null
@@ -718,7 +969,64 @@ export function optimizeModule(sec, profiler) {
718
969
  const stable = stablePtrGlobalNames()
719
970
  if (stable.size) for (const s of allFuncs) hoistGlobalPtrOffset(s, stable, reachableWrites)
720
971
  })
721
- t('optimizeFuncs', () => { for (const s of allFuncs) optimizeFunc(s, cfg, globalTypesMap, volatileGlobals, 'pre', reachableWrites) })
972
+ // Per-loop complement: a function the whole-function pass above declined
973
+ // (an unrelated call_indirect / write ANYWHERE in the function poisons
974
+ // every global for it) may still have individual loops that are clean on
975
+ // their own narrower scope — e.g. a char-scan loop inside a devirtualized
976
+ // Pratt-loop trampoline that also inlines unrelated operator dispatch.
977
+ if (!cfg || cfg.hoistLoopGlobalPtrOffset !== false) t('hoistLoopGlobalPtr', () => {
978
+ const stable = stablePtrGlobalNames()
979
+ if (stable.size) for (const s of allFuncs) hoistLoopGlobalPtrOffset(s, stable, reachableWrites)
980
+ })
981
+ // Build the pure-function map for tryPerPixelColor's Phase-2 lane inline BEFORE the
982
+ // per-function vectorizer runs — the vectorizer is jz lowering (pre-watr), so it needs
983
+ // its inline candidates now, not after watr. Bodies are still clean scalar here.
984
+ if (cfg && cfg.vectorizeLaneLocal === true) {
985
+ const pureFuncMap = buildPureFuncMap(allFuncs)
986
+ if (pureFuncMap.size) {
987
+ cfg._pureFuncMap = pureFuncMap
988
+ // jz semantic inlining (LOWERING) — inline pure user functions into their call sites BEFORE the
989
+ // vectorizer, so it sees the callee arithmetic (the pow/decode a colour helper hides). jz owns
990
+ // this because the decision is purity+type-driven; watr keeps only mechanical residual inlining.
991
+ // Gated to SINGLE-CALLER pure functions: inlining the sole call site is a guaranteed win (removes
992
+ // the call AND the now-dead function, zero size cost). Multi-caller small helpers stay watr's
993
+ // size-gated mechanical job at the speed tier — jz doesn't duplicate that.
994
+ // SMALL single-caller only: inlining a small pure helper (a `spow`/`decode` colour term) into its
995
+ // sole caller exposes its arithmetic to the vectorizer at zero size cost. Inlining a LARGE function
996
+ // (a whole conversion loop) is neutral-to-harmful (worse layout/regalloc, measured on colorpq), and
997
+ // watr's own inlineOnce already handles the mechanical single-caller case — so jz stays out of it.
998
+ // OPT-IN (default off): correct + fuzz-clean, but inlining across the corpus changes a lot of
999
+ // pinned output-shape assertions for no measured bench win (the current regressions are outer-strip/
1000
+ // widening recognition + watr wasm-opt-class, not inlining). Kept as the architectural home for
1001
+ // semantic inlining, enabled per-compile via `optimize.inlinePureFns: true`, until a real case pays.
1002
+ if (cfg.inlinePureFns === true) t('inlinePureFns', () => {
1003
+ const callCount = new Map()
1004
+ const countCalls = (n) => { if (!Array.isArray(n)) return; if ((n[0] === 'call' || n[0] === 'return_call') && typeof n[1] === 'string') callCount.set(n[1], (callCount.get(n[1]) || 0) + 1); for (let i = 1; i < n.length; i++) countCalls(n[i]) }
1005
+ for (const s of allFuncs) countCalls(s)
1006
+ const nodeCount = (n) => !Array.isArray(n) ? 0 : 1 + n.reduce((a, c, i) => a + (i > 0 ? nodeCount(c) : 0), 0)
1007
+ const INLINE_MAX = 48
1008
+ const canInline = new Set([...pureFuncMap.keys()].filter(name =>
1009
+ callCount.get(name) === 1 && nodeCount(pureFuncMap.get(name)) <= INLINE_MAX))
1010
+ if (canInline.size) { const idRef = { next: 0 }; for (const s of allFuncs) inlinePureFnsInFn(s, pureFuncMap, idRef, canInline) }
1011
+ })
1012
+ }
1013
+ }
1014
+ // Candidate bodies for devirt arm inlining and block-narrowing
1015
+ // (devirtConstFnArrayCalls): the UNFILTERED name→fn map of const-fn-array
1016
+ // element bodies. Built here — the pass runs per-function inside optimizeFunc
1017
+ // and can't see sibling functions. No purity filter: the inliner enforces
1018
+ // straight-line shape itself, and an arm executes exactly when the original
1019
+ // call did, so side-effecting bodies substitute safely.
1020
+ if (ctx.scope.constFnArrays?.size) {
1021
+ const candNames = new Set()
1022
+ for (const list of ctx.scope.constFnArrays.values()) for (const c of list) candNames.add(`$${c.name}`)
1023
+ ctx.scope.dvArmFns = new Map(allFuncs.filter(f => Array.isArray(f) && candNames.has(f[1])).map(f => [f[1], f]))
1024
+ }
1025
+ t('optimizeFuncs', () => { for (const s of allFuncs) optimizeFunc(s, cfg, globalTypesMap, volatileGlobals, reachableWrites) })
1026
+ // The lane vectorizer can inject f64x2 stdlib mirrors ($math.log_v, $math.cos2, …)
1027
+ // absent from the already-pulled+treeshaken module. Append any now-referenced mirror
1028
+ // body to sec.stdlib — the pre-watr analogue of index.js's post-watr appendLateStdlib.
1029
+ if (cfg && cfg.vectorizeLaneLocal === true) t('appendLateStdlib', () => appendLateStdlib(allFuncs, sec.stdlib))
722
1030
  if (!cfg || cfg.arenaRewind !== false) {
723
1031
  const safeCallees = arenaRewindModule([...sec.funcs, ...sec.stdlib, ...sec.start])
724
1032
  const fnByName = new Map()
@@ -776,8 +1084,9 @@ export function optimizeModule(sec, profiler) {
776
1084
  * watr, which already removes them. Keeps correctly-rounded decimal parsing wherever it is
777
1085
  * genuinely live (parseFloat, the self-host compiler's `Number()` on source literals).
778
1086
  */
779
- export function stripDeadElTable(sec) {
780
- if (!ctx.runtime.elTableLen) return
1087
+ export function stripDeadLazyTables(sec) {
1088
+ const spans = ctx.runtime.lazySpans
1089
+ if (!spans || !spans.length) return
781
1090
  const byName = new Map()
782
1091
  for (const arr of [sec.funcs, sec.stdlib, sec.start])
783
1092
  for (const f of arr || []) if (Array.isArray(f) && f[0] === 'func' && typeof f[1] === 'string') byName.set(f[1], f)
@@ -795,9 +1104,28 @@ export function stripDeadElTable(sec) {
795
1104
  for (const c of n) { if (typeof c === 'string' && c[0] === '$') mark(c); else scan(c) }
796
1105
  }
797
1106
  while (work.length) scan(byName.get(work.pop()))
798
- if (live.has('$__dec_to_f64')) return // genuinely parses decimals at runtime keep it
799
- ctx.runtime.data = ctx.runtime.data.slice(0, ctx.runtime.data.length - ctx.runtime.elTableLen)
800
- ctx.runtime.elTableLen = 0
1107
+ if (spans.every(s => live.has(s.fn))) { ctx.runtime.lazySpans = ctx.memory.shared ? spans : [] ; return }
1108
+ // Rebuild the tail (the spans are the last data appends, in order): truncate
1109
+ // to the first span's pre-pad start, re-append live tables, re-point their
1110
+ // base globals — both the scope entry and the already-pushed sec.globals IR.
1111
+ const setInit = (name, off) => {
1112
+ const g = ctx.scope.globals.get(name)
1113
+ if (g) g.init = off
1114
+ for (const node of sec.globals) if (Array.isArray(node) && node[1] === '$' + name) {
1115
+ const c = node[node.length - 1]
1116
+ if (Array.isArray(c) && c[0] === 'i32.const') c[1] = off
1117
+ }
1118
+ }
1119
+ ctx.runtime.data = ctx.runtime.data.slice(0, spans[0].start)
1120
+ for (const s of spans) {
1121
+ if (!live.has(s.fn)) { setInit(s.global, 0); continue }
1122
+ while (ctx.runtime.data.length % 8 !== 0) ctx.runtime.data += '\0'
1123
+ setInit(s.global, ctx.runtime.data.length)
1124
+ ctx.runtime.data += s.bytes
1125
+ }
1126
+ // Shared memory needs the survivor list after this pass — the start-time
1127
+ // rebase (compile/index.js) re-points each surviving table global.
1128
+ ctx.runtime.lazySpans = ctx.memory.shared ? spans.filter(s => live.has(s.fn)) : []
801
1129
  }
802
1130
 
803
1131
  /**
@@ -840,6 +1168,16 @@ export function stripStaticDataPrefix(sec) {
840
1168
  ctx.runtime.internTable.base = base - prefix
841
1169
  declGlobal('__internBase', 'i32', base - prefix, { mut: false })
842
1170
  }
1171
+ // Raw-i32 globals whose INIT is a static-data offset (static schema table, …):
1172
+ // shift the declared init by the stripped prefix, like every boxed slot above.
1173
+ if (ctx.runtime.staticI32GlobalInits) for (const name of ctx.runtime.staticI32GlobalInits) {
1174
+ const g = ctx.scope.globals.get(name)
1175
+ if (g && typeof g.init === 'number' && g.init >= prefix) g.init -= prefix
1176
+ }
1177
+ // Lazy-table spans (EL/Ryū) sit at the data tail — keep their recorded starts
1178
+ // in post-strip coordinates so stripDeadLazyTables truncates at the right base.
1179
+ if (ctx.runtime.lazySpans) for (const s of ctx.runtime.lazySpans)
1180
+ if (s.start >= prefix) s.start -= prefix
843
1181
  let s = ''
844
1182
  for (let i = prefix; i < buf.length; i++) s += String.fromCharCode(buf[i])
845
1183
  ctx.runtime.data = s