@aardworx/wombat.rendering 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/core/command.d.ts +7 -9
  2. package/dist/core/command.d.ts.map +1 -1
  3. package/dist/core/renderTask.d.ts +64 -1
  4. package/dist/core/renderTask.d.ts.map +1 -1
  5. package/dist/core/renderTask.js +3 -1
  6. package/dist/core/renderTask.js.map +1 -1
  7. package/dist/resources/preparedRenderObject.js +3 -3
  8. package/dist/resources/preparedRenderObject.js.map +1 -1
  9. package/dist/resources/uniformBuffer.d.ts.map +1 -1
  10. package/dist/resources/uniformBuffer.js +17 -0
  11. package/dist/resources/uniformBuffer.js.map +1 -1
  12. package/dist/runtime/derivedUniforms/dispatch.d.ts +105 -0
  13. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -0
  14. package/dist/runtime/derivedUniforms/dispatch.js +355 -0
  15. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -0
  16. package/dist/runtime/derivedUniforms/index.d.ts +6 -0
  17. package/dist/runtime/derivedUniforms/index.d.ts.map +1 -0
  18. package/dist/runtime/derivedUniforms/index.js +26 -0
  19. package/dist/runtime/derivedUniforms/index.js.map +1 -0
  20. package/dist/runtime/derivedUniforms/recipes.d.ts +42 -0
  21. package/dist/runtime/derivedUniforms/recipes.d.ts.map +1 -0
  22. package/dist/runtime/derivedUniforms/recipes.js +134 -0
  23. package/dist/runtime/derivedUniforms/recipes.js.map +1 -0
  24. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +56 -0
  25. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -0
  26. package/dist/runtime/derivedUniforms/sceneIntegration.js +171 -0
  27. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -0
  28. package/dist/runtime/derivedUniforms/slotId.d.ts +15 -0
  29. package/dist/runtime/derivedUniforms/slotId.d.ts.map +1 -0
  30. package/dist/runtime/derivedUniforms/slotId.js +30 -0
  31. package/dist/runtime/derivedUniforms/slotId.js.map +1 -0
  32. package/dist/runtime/derivedUniforms/slots.d.ts +49 -0
  33. package/dist/runtime/derivedUniforms/slots.d.ts.map +1 -0
  34. package/dist/runtime/derivedUniforms/slots.js +123 -0
  35. package/dist/runtime/derivedUniforms/slots.js.map +1 -0
  36. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts +13 -0
  37. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts.map +1 -0
  38. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js +218 -0
  39. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js.map +1 -0
  40. package/dist/runtime/flattenTree.d.ts +5 -0
  41. package/dist/runtime/flattenTree.d.ts.map +1 -0
  42. package/dist/runtime/flattenTree.js +45 -0
  43. package/dist/runtime/flattenTree.js.map +1 -0
  44. package/dist/runtime/heapAdapter.d.ts +16 -0
  45. package/dist/runtime/heapAdapter.d.ts.map +1 -0
  46. package/dist/runtime/heapAdapter.js +263 -0
  47. package/dist/runtime/heapAdapter.js.map +1 -0
  48. package/dist/runtime/heapEffect.d.ts +235 -0
  49. package/dist/runtime/heapEffect.d.ts.map +1 -0
  50. package/dist/runtime/heapEffect.js +529 -0
  51. package/dist/runtime/heapEffect.js.map +1 -0
  52. package/dist/runtime/heapEffectIR.d.ts +34 -0
  53. package/dist/runtime/heapEffectIR.d.ts.map +1 -0
  54. package/dist/runtime/heapEffectIR.js +1161 -0
  55. package/dist/runtime/heapEffectIR.js.map +1 -0
  56. package/dist/runtime/heapEligibility.d.ts +9 -0
  57. package/dist/runtime/heapEligibility.d.ts.map +1 -0
  58. package/dist/runtime/heapEligibility.js +195 -0
  59. package/dist/runtime/heapEligibility.js.map +1 -0
  60. package/dist/runtime/heapScene.d.ts +240 -31
  61. package/dist/runtime/heapScene.d.ts.map +1 -1
  62. package/dist/runtime/heapScene.js +3334 -265
  63. package/dist/runtime/heapScene.js.map +1 -1
  64. package/dist/runtime/heapShaderFamily.d.ts +132 -0
  65. package/dist/runtime/heapShaderFamily.d.ts.map +1 -0
  66. package/dist/runtime/heapShaderFamily.js +866 -0
  67. package/dist/runtime/heapShaderFamily.js.map +1 -0
  68. package/dist/runtime/hybridScene.d.ts +133 -0
  69. package/dist/runtime/hybridScene.d.ts.map +1 -0
  70. package/dist/runtime/hybridScene.js +159 -0
  71. package/dist/runtime/hybridScene.js.map +1 -0
  72. package/dist/runtime/index.d.ts +8 -1
  73. package/dist/runtime/index.d.ts.map +1 -1
  74. package/dist/runtime/index.js +9 -1
  75. package/dist/runtime/index.js.map +1 -1
  76. package/dist/runtime/renderTask.d.ts +21 -4
  77. package/dist/runtime/renderTask.d.ts.map +1 -1
  78. package/dist/runtime/renderTask.js +127 -32
  79. package/dist/runtime/renderTask.js.map +1 -1
  80. package/dist/runtime/runtime.d.ts +33 -3
  81. package/dist/runtime/runtime.d.ts.map +1 -1
  82. package/dist/runtime/runtime.js +12 -3
  83. package/dist/runtime/runtime.js.map +1 -1
  84. package/dist/runtime/scenePass.d.ts +28 -3
  85. package/dist/runtime/scenePass.d.ts.map +1 -1
  86. package/dist/runtime/scenePass.js +37 -4
  87. package/dist/runtime/scenePass.js.map +1 -1
  88. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +35 -0
  89. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -0
  90. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +247 -0
  91. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -0
  92. package/dist/runtime/textureAtlas/atlasPool.d.ts +240 -0
  93. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -0
  94. package/dist/runtime/textureAtlas/atlasPool.js +798 -0
  95. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -0
  96. package/dist/runtime/textureAtlas/bvhTree2d.d.ts +33 -0
  97. package/dist/runtime/textureAtlas/bvhTree2d.d.ts.map +1 -0
  98. package/dist/runtime/textureAtlas/bvhTree2d.js +501 -0
  99. package/dist/runtime/textureAtlas/bvhTree2d.js.map +1 -0
  100. package/dist/runtime/textureAtlas/packer.d.ts +50 -0
  101. package/dist/runtime/textureAtlas/packer.d.ts.map +1 -0
  102. package/dist/runtime/textureAtlas/packer.js +390 -0
  103. package/dist/runtime/textureAtlas/packer.js.map +1 -0
  104. package/dist/window/loop.d.ts +14 -0
  105. package/dist/window/loop.d.ts.map +1 -1
  106. package/dist/window/loop.js +18 -5
  107. package/dist/window/loop.js.map +1 -1
  108. package/package.json +6 -5
  109. package/src/core/command.ts +9 -9
  110. package/src/core/renderTask.ts +55 -2
  111. package/src/resources/preparedRenderObject.ts +3 -3
  112. package/src/resources/uniformBuffer.ts +15 -0
  113. package/src/runtime/derivedUniforms/dispatch.ts +407 -0
  114. package/src/runtime/derivedUniforms/index.ts +52 -0
  115. package/src/runtime/derivedUniforms/recipes.ts +159 -0
  116. package/src/runtime/derivedUniforms/sceneIntegration.ts +239 -0
  117. package/src/runtime/derivedUniforms/slotId.ts +35 -0
  118. package/src/runtime/derivedUniforms/slots.ts +155 -0
  119. package/src/runtime/derivedUniforms/uberKernel.wgsl.ts +220 -0
  120. package/src/runtime/flattenTree.ts +46 -0
  121. package/src/runtime/heapAdapter.ts +314 -0
  122. package/src/runtime/heapEffect.ts +728 -0
  123. package/src/runtime/heapEffectIR.ts +1211 -0
  124. package/src/runtime/heapEligibility.ts +189 -0
  125. package/src/runtime/heapScene.ts +3981 -349
  126. package/src/runtime/heapShaderFamily.ts +1102 -0
  127. package/src/runtime/hybridScene.ts +310 -0
  128. package/src/runtime/index.ts +45 -1
  129. package/src/runtime/renderTask.ts +167 -32
  130. package/src/runtime/runtime.ts +37 -4
  131. package/src/runtime/scenePass.ts +39 -4
  132. package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +291 -0
  133. package/src/runtime/textureAtlas/atlasPool.ts +991 -0
  134. package/src/runtime/textureAtlas/bvhTree2d.ts +530 -0
  135. package/src/runtime/textureAtlas/packer.ts +429 -0
  136. package/src/window/loop.ts +32 -6
  137. package/dist/runtime/heapShader.d.ts +0 -12
  138. package/dist/runtime/heapShader.d.ts.map +0 -1
  139. package/dist/runtime/heapShader.js +0 -157
  140. package/dist/runtime/heapShader.js.map +0 -1
  141. package/src/runtime/heapShader.ts +0 -185
@@ -0,0 +1,1211 @@
1
+ // heapEffectIR — IR-level rewriter for the heap render path.
2
+ //
3
+ // Takes a wombat.shader Effect, transforms its IR Module so that:
4
+ // - Vertex attribute reads are replaced with heap-buffer pulls
5
+ // (cyclic via `vid % length`).
6
+ // - Uniform reads (in any stage) are replaced with heap-buffer
7
+ // decodes via per-draw refs from the headers buffer.
8
+ // - FS uniform decodes thread the per-uniform refs through VsOut
9
+ // as flat-interpolated `_h_<name>Ref: u32` varyings.
10
+ // - The heap arena's four typed views (heapU32, headersU32, heapF32,
11
+ // heapV4f) are added as storage-buffer bindings.
12
+ //
13
+ // The result is a fresh Module that compiles via the existing
14
+ // wombat.shader pipeline (DCE, cross-stage link, fragment-output
15
+ // linker, WGSL emit) — no string-regex post-processing.
16
+ //
17
+ // This module knows nothing about the runtime; the BucketLayout it
18
+ // consumes is identical to the one heapScene.ts already builds.
19
+
20
+ import { compileModule, stage as makeStage, effect as makeEffect } from "@aardworx/wombat.shader";
21
+ import type { Effect, CompileOptions } from "@aardworx/wombat.shader";
22
+ import { substituteInputsInStage, readInputs, mapExpr, mapStmt, liftReturns } from "@aardworx/wombat.shader/passes";
23
+ import {
24
+ type Module, type Expr, type Stmt, type Type, type ValueDef,
25
+ type EntryDef, type EntryParameter, type ParamDecoration,
26
+ Tu32, Tf32, Vec,
27
+ } from "@aardworx/wombat.shader/ir";
28
+ import {
29
+ megacallSearchPrelude, atlasVaryingNames,
30
+ generateAtlasBindings, generateAtlasSwitch, generateAtlasPrelude,
31
+ type BucketLayout,
32
+ } from "./heapEffect.js";
33
+ import type { IntrinsicRef } from "@aardworx/wombat.shader/ir";
34
+
35
+ // ─── IR builders ────────────────────────────────────────────────────
36
+
37
+ const Tbool: Type = { kind: "Bool" };
38
+
39
+ const constU32 = (n: number): Expr => ({
40
+ kind: "Const", type: Tu32, value: { kind: "Int", signed: false, value: n },
41
+ });
42
+ const constF32 = (n: number): Expr => ({
43
+ kind: "Const", type: Tf32, value: { kind: "Float", value: n },
44
+ });
45
+ const readScope = (scope: "Uniform" | "Input" | "Builtin", name: string, type: Type): Expr => ({
46
+ kind: "ReadInput", scope, name, type,
47
+ });
48
+ const item = (target: Expr, index: Expr, elemType: Type): Expr => ({
49
+ kind: "Item", target, index, type: elemType,
50
+ });
51
+ const add = (lhs: Expr, rhs: Expr, type: Type): Expr => ({ kind: "Add", lhs, rhs, type });
52
+ const mul = (lhs: Expr, rhs: Expr, type: Type): Expr => ({ kind: "Mul", lhs, rhs, type });
53
+ const div = (lhs: Expr, rhs: Expr, type: Type): Expr => ({ kind: "Div", lhs, rhs, type });
54
+ const mod = (lhs: Expr, rhs: Expr, type: Type): Expr => ({ kind: "Mod", lhs, rhs, type });
55
+ const eqU32 = (lhs: Expr, rhs: Expr): Expr => ({ kind: "Eq", lhs, rhs, type: Tbool });
56
+ const select = (cond: Expr, ifTrue: Expr, ifFalse: Expr, type: Type): Expr => ({
57
+ kind: "Conditional", cond, ifTrue, ifFalse, type,
58
+ });
59
+ const newVec = (components: Expr[], type: Type): Expr => ({
60
+ kind: "NewVector", components, type,
61
+ });
62
+ const matFromRows = (rows: Expr[], type: Type): Expr => ({
63
+ kind: "MatrixFromRows", rows, type,
64
+ });
65
+
66
+ // Runtime-sized array types for the heap storage buffers.
67
+ const arrU32: Type = { kind: "Array", element: Tu32, length: "runtime" } as Type;
68
+ const arrF32: Type = { kind: "Array", element: Tf32, length: "runtime" } as Type;
69
+ const arrVec4: Type = { kind: "Array", element: Vec(Tf32, 4), length: "runtime" } as Type;
70
+ const Tvec2 = Vec(Tf32, 2);
71
+ const Tvec3 = Vec(Tf32, 3);
72
+ const Tvec4 = Vec(Tf32, 4);
73
+
74
+ // ─── Storage-buffer bindings ────────────────────────────────────────
75
+ //
76
+ // Bindings 0..3 are the four typed views of the heap arena. Bindings
77
+ // 4+ (textures/samplers) are added by the layout / runtime as needed.
78
+
79
+ function heapStorageBufferDecls(): ValueDef[] {
80
+ return [
81
+ { kind: "StorageBuffer", binding: { group: 0, slot: 0 }, name: "heapU32", layout: arrU32, access: "read" },
82
+ { kind: "StorageBuffer", binding: { group: 0, slot: 1 }, name: "headersU32", layout: arrU32, access: "read" },
83
+ { kind: "StorageBuffer", binding: { group: 0, slot: 2 }, name: "heapF32", layout: arrF32, access: "read" },
84
+ { kind: "StorageBuffer", binding: { group: 0, slot: 3 }, name: "heapV4f", layout: arrVec4, access: "read" },
85
+ ];
86
+ }
87
+
88
+ // ─── Heap-load IR builders ─────────────────────────────────────────
89
+ //
90
+ // Storage buffers are exposed in IR as `ReadInput("Uniform", name)`
91
+ // (the same scope WGSL uses for all bindings). Indexing is `Item`.
92
+ // Address arithmetic is plain Add/Mul on u32.
93
+
94
+ const heapU32 = readScope("Uniform", "heapU32", arrU32);
95
+ const headersU32 = readScope("Uniform", "headersU32", arrU32);
96
+ const heapF32 = readScope("Uniform", "heapF32", arrF32);
97
+ const heapV4f = readScope("Uniform", "heapV4f", arrVec4);
98
+
99
+ /** `headersU32[drawIdx * stride + offset]`. */
100
+ function loadHeaderRef(drawIdx: Expr, fieldByteOffset: number, strideU32: number): Expr {
101
+ // u32 index = drawIdx * strideU32 + (fieldByteOffset / 4).
102
+ const offU32 = fieldByteOffset / 4;
103
+ const idx = add(mul(drawIdx, constU32(strideU32), Tu32), constU32(offU32), Tu32);
104
+ return item(headersU32, idx, Tu32);
105
+ }
106
+
107
+ /**
108
+ * Cyclic per-element index: `(elemIdx % count) * floatsPerElement`,
109
+ * where count = alloc-header length (u32 at offset 4, i.e. ref/4 + 1).
110
+ * For per-vertex attributes elemIdx = vid; for instance arrays =
111
+ * iidx (length is then the instance count, no cycling needed but the
112
+ * mod is harmless).
113
+ */
114
+ function cyclicElemIdx(refIdent: Expr, elemIdx: Expr, floatsPerElem: number): Expr {
115
+ const count = item(heapU32, add(div(refIdent, constU32(4), Tu32), constU32(1), Tu32), Tu32);
116
+ const cycled = mod(elemIdx, count, Tu32);
117
+ return mul(cycled, constU32(floatsPerElem), Tu32);
118
+ }
119
+
120
+ /**
121
+ * Load a per-draw uniform value from the heap given its `u32` ref.
122
+ * Type-driven: matrix → 4 vec4 columns from heapV4f; vec4 → one
123
+ * heapV4f read; vec3/2/scalar → heapF32 reads + NewVector.
124
+ */
125
+ function loadUniformByRef(refIdent: Expr, wgslType: string): Expr {
126
+ switch (wgslType) {
127
+ case "mat4x4<f32>": {
128
+ // base vec4 index = (ref + 16) / 16
129
+ const base = div(add(refIdent, constU32(16), Tu32), constU32(16), Tu32);
130
+ const cols: Expr[] = [];
131
+ for (let i = 0; i < 4; i++) {
132
+ const idx = i === 0 ? base : add(base, constU32(i), Tu32);
133
+ cols.push(item(heapV4f, idx, Tvec4));
134
+ }
135
+ return matFromRows(cols, { kind: "Matrix", element: Tf32, rows: 4, cols: 4 } as Type);
136
+ }
137
+ case "vec4<f32>":
138
+ return item(heapV4f, div(add(refIdent, constU32(16), Tu32), constU32(16), Tu32), Tvec4);
139
+ case "vec3<f32>": {
140
+ const base = div(add(refIdent, constU32(16), Tu32), constU32(4), Tu32);
141
+ return newVec([
142
+ item(heapF32, base, Tf32),
143
+ item(heapF32, add(base, constU32(1), Tu32), Tf32),
144
+ item(heapF32, add(base, constU32(2), Tu32), Tf32),
145
+ ], Tvec3);
146
+ }
147
+ case "vec2<f32>": {
148
+ const base = div(add(refIdent, constU32(16), Tu32), constU32(4), Tu32);
149
+ return newVec([
150
+ item(heapF32, base, Tf32),
151
+ item(heapF32, add(base, constU32(1), Tu32), Tf32),
152
+ ], Tvec2);
153
+ }
154
+ case "f32":
155
+ return item(heapF32, div(add(refIdent, constU32(16), Tu32), constU32(4), Tu32), Tf32);
156
+ case "u32":
157
+ return item(heapU32, div(add(refIdent, constU32(16), Tu32), constU32(4), Tu32), Tu32);
158
+ default:
159
+ throw new Error(`heapEffectIR: no IR loader for uniform type '${wgslType}'`);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Load an attribute value with cyclic addressing (`(idx % length) *
165
+ * stride`). The mod lets a length-1 broadcast replicate across all
166
+ * vertices. For vec4 the source can be V3-tight (12 B, .w = 1.0) or
167
+ * V4-tight (16 B, .w from data); we read the actual stride from the
168
+ * alloc header at offset 8, then use `select` for the .w fill.
169
+ */
170
+ function loadAttributeByRef(refIdent: Expr, idx: Expr, wgslType: string): Expr {
171
+ const dataF32Base = div(add(refIdent, constU32(16), Tu32), constU32(4), Tu32);
172
+ switch (wgslType) {
173
+ case "vec3<f32>": {
174
+ const off = cyclicElemIdx(refIdent, idx, 3);
175
+ const base = add(dataF32Base, off, Tu32);
176
+ return newVec([
177
+ item(heapF32, base, Tf32),
178
+ item(heapF32, add(base, constU32(1), Tu32), Tf32),
179
+ item(heapF32, add(base, constU32(2), Tu32), Tf32),
180
+ ], Tvec3);
181
+ }
182
+ case "vec4<f32>": {
183
+ // strideF = stride_bytes / 4 (header u32 at ref/4 + 2).
184
+ const strideBytes = item(heapU32, add(div(refIdent, constU32(4), Tu32), constU32(2), Tu32), Tu32);
185
+ const strideF = div(strideBytes, constU32(4), Tu32);
186
+ const cycled = mod(idx, item(heapU32, add(div(refIdent, constU32(4), Tu32), constU32(1), Tu32), Tu32), Tu32);
187
+ const off = mul(cycled, strideF, Tu32);
188
+ const base = add(dataF32Base, off, Tu32);
189
+ const w = select(eqU32(strideF, constU32(4)), item(heapF32, add(base, constU32(3), Tu32), Tf32), constF32(1.0), Tf32);
190
+ return newVec([
191
+ item(heapF32, base, Tf32),
192
+ item(heapF32, add(base, constU32(1), Tu32), Tf32),
193
+ item(heapF32, add(base, constU32(2), Tu32), Tf32),
194
+ w,
195
+ ], Tvec4);
196
+ }
197
+ case "vec2<f32>": {
198
+ const off = cyclicElemIdx(refIdent, idx, 2);
199
+ const base = add(dataF32Base, off, Tu32);
200
+ return newVec([
201
+ item(heapF32, base, Tf32),
202
+ item(heapF32, add(base, constU32(1), Tu32), Tf32),
203
+ ], Tvec2);
204
+ }
205
+ default:
206
+ throw new Error(`heapEffectIR: no IR loader for attribute type '${wgslType}'`);
207
+ }
208
+ }
209
+
210
+ // ─── Instance-index / vertex-index built-in plumbing ────────────────
211
+ //
212
+ // The user's stages don't have @builtin(instance_index)/(vertex_index)
213
+ // inputs — they read inputs by name (vertexAttributes, uniforms).
214
+ // We need those builtins to address the heap. Inject them as
215
+ // EntryParameters on every vertex stage.
216
+
217
+ function entryHasBuiltin(e: EntryDef, semantic: string): boolean {
218
+ return e.inputs.some(p =>
219
+ p.decorations.some(d => d.kind === "Builtin" && d.value === semantic),
220
+ );
221
+ }
222
+
223
+ function builtinParam(semantic: "vertex_index" | "instance_index"): EntryParameter {
224
+ return {
225
+ name: semantic,
226
+ type: Tu32,
227
+ semantic,
228
+ decorations: [{ kind: "Builtin", value: semantic } as ParamDecoration],
229
+ };
230
+ }
231
+
232
+ function injectVsBuiltins(m: Module): Module {
233
+ const values = m.values.map((v): typeof v => {
234
+ if (v.kind !== "Entry" || v.entry.stage !== "vertex") return v;
235
+ let inputs = v.entry.inputs;
236
+ if (!entryHasBuiltin(v.entry, "vertex_index")) {
237
+ inputs = [...inputs, builtinParam("vertex_index")];
238
+ }
239
+ if (!entryHasBuiltin(v.entry, "instance_index")) {
240
+ inputs = [...inputs, builtinParam("instance_index")];
241
+ }
242
+ return { ...v, entry: { ...v.entry, inputs } };
243
+ });
244
+ return { ...m, values };
245
+ }
246
+
247
+ // ─── VS body rewrites ──────────────────────────────────────────────
248
+ //
249
+ // For each surviving uniform field, build a heap-load expression
250
+ // using `instance_index` as drawIdx. For each surviving attribute,
251
+ // use `vertex_index` and cyclic addressing.
252
+ //
253
+ // These map builders extract the (name → Expr) mapping that the VS
254
+ // substitution needs; the actual rewrite is delegated to
255
+ // `Effect.substitute({ vertex: { uniforms, inputs } })` in
256
+ // `compileHeapEffectIR`.
257
+
258
+ /**
259
+ * Per-RO selector expression. Always reads the `heap_drawIdx` local
260
+ * defined by the megacall prelude.
261
+ */
262
+ function drawIdxExprFor(_layout: BucketLayout): Expr {
263
+ return { kind: "Var", var: { name: "heap_drawIdx", type: Tu32, mutable: false } } as Expr;
264
+ }
265
+
266
+ /**
267
+ * `(uniformName → loadUniformByRef(loadHeaderRef(...), wgslType))` for
268
+ * every uniform-ref field in `layout`. Used as the `vertex.uniforms`
269
+ * argument to `Effect.substitute`.
270
+ */
271
+ function buildVertexUniformMap(layout: BucketLayout): Map<string, Expr> {
272
+ const drawIdx: Expr = drawIdxExprFor(layout);
273
+ const iidx: Expr = readScope("Builtin", "instance_index", Tu32);
274
+ const stride = layout.strideU32;
275
+ const out = new Map<string, Expr>();
276
+ for (const f of layout.drawHeaderFields) {
277
+ if (f.kind !== "uniform-ref") continue;
278
+ const refExpr = loadHeaderRef(drawIdx, f.byteOffset, stride);
279
+ const value = layout.perInstanceUniforms.has(f.name)
280
+ ? loadInstanceByRef(refExpr, iidx, f.uniformWgslType ?? "")
281
+ : loadUniformByRef(refExpr, f.uniformWgslType ?? "");
282
+ out.set(f.name, value);
283
+ }
284
+ return out;
285
+ }
286
+
287
+ /**
288
+ * `(attributeName → loadAttributeByRef(loadHeaderRef(...), idx, wgslType))`
289
+ * for every attribute-ref field in `layout`. Used as the
290
+ * `vertex.inputs` argument to `Effect.substitute`.
291
+ */
292
+ function buildVertexAttributeMap(layout: BucketLayout): Map<string, Expr> {
293
+ const drawIdx: Expr = drawIdxExprFor(layout);
294
+ const iidx: Expr = readScope("Builtin", "instance_index", Tu32);
295
+ const vid = readScope("Builtin", "vertex_index", Tu32);
296
+ const stride = layout.strideU32;
297
+ const out = new Map<string, Expr>();
298
+ for (const f of layout.drawHeaderFields) {
299
+ if (f.kind !== "attribute-ref") continue;
300
+ const refExpr = loadHeaderRef(drawIdx, f.byteOffset, stride);
301
+ const value = layout.perInstanceAttributes.has(f.name)
302
+ ? loadAttributeByRef(refExpr, iidx, f.attributeWgslType ?? "")
303
+ : loadAttributeByRef(refExpr, vid, f.attributeWgslType ?? "");
304
+ out.set(f.name, value);
305
+ }
306
+ return out;
307
+ }
308
+
309
+ function mapVertexEntryBodies(m: Module, fn: (body: Stmt) => Stmt): Module {
310
+ return mapStageEntryBodies(m, "vertex", fn);
311
+ }
312
+
313
+ function mapStageEntryBodies(
314
+ m: Module,
315
+ stage: "vertex" | "fragment" | "compute",
316
+ fn: (body: Stmt) => Stmt,
317
+ ): Module {
318
+ const values = m.values.map((v): typeof v => {
319
+ if (v.kind !== "Entry" || v.entry.stage !== stage) return v;
320
+ return { ...v, entry: { ...v.entry, body: fn(v.entry.body) } };
321
+ });
322
+ return { ...m, values };
323
+ }
324
+
325
+ /** Append `extras` to every VS entry's outputs and every FS entry's inputs. */
326
+ function addInterstageParams(
327
+ m: Module,
328
+ extras: readonly EntryParameter[],
329
+ ): Module {
330
+ if (extras.length === 0) return m;
331
+ const values = m.values.map((v): typeof v => {
332
+ if (v.kind !== "Entry") return v;
333
+ if (v.entry.stage === "vertex") {
334
+ return { ...v, entry: { ...v.entry, outputs: [...v.entry.outputs, ...extras] } };
335
+ }
336
+ if (v.entry.stage === "fragment") {
337
+ return { ...v, entry: { ...v.entry, inputs: [...v.entry.inputs, ...extras] } };
338
+ }
339
+ return v;
340
+ });
341
+ return { ...m, values };
342
+ }
343
+
344
+ /**
345
+ * Prepend `stmts` to every VS entry's body. Flatten when the body is
346
+ * already a `Sequential` — CSE numbers `_cse0..N` per Sequential, so
347
+ * nested Sequentials produce colliding declarations in the emitted
348
+ * WGSL.
349
+ */
350
+ function prependVsBodyStmts(m: Module, stmts: readonly Stmt[]): Module {
351
+ if (stmts.length === 0) return m;
352
+ return mapVertexEntryBodies(m, body => {
353
+ if (body.kind === "Sequential") {
354
+ return { kind: "Sequential", body: [...stmts, ...body.body] };
355
+ }
356
+ return { kind: "Sequential", body: [...stmts, body] };
357
+ });
358
+ }
359
+
360
+ // ─── FS uniform threading ──────────────────────────────────────────
361
+ //
362
+ // FS can't access `instance_index` directly, so we thread the per-
363
+ // uniform refs through VsOut as flat-interpolated u32 varyings:
364
+ //
365
+ // - For each FS-used uniform `X` whose layout entry is uniform-ref:
366
+ // VS output: `_h_<X>Ref: u32 @interpolate(flat)`
367
+ // VS body: `WriteOutput("_h_<X>Ref", loadHeaderRef(drawIdx, X))`
368
+ // FS input: `_h_<X>Ref: u32 @interpolate(flat)`
369
+ // FS body: `ReadInput("Uniform", X)` →
370
+ // `loadUniformByRef(ReadInput("Input", "_h_<X>Ref"), …)`
371
+ //
372
+ // For per-instance uniforms (layout.perInstanceUniforms), iidx is also
373
+ // threaded as `_iidx: u32 @interpolate(flat)` and the FS uses
374
+ // `loadInstanceByRef` instead.
375
+
376
+ function wgslTypeFromHeapField(wgslType: string): Type {
377
+ switch (wgslType) {
378
+ case "mat4x4<f32>": return { kind: "Matrix", element: Tf32, rows: 4, cols: 4 } as Type;
379
+ case "vec4<f32>": return Tvec4;
380
+ case "vec3<f32>": return Tvec3;
381
+ case "vec2<f32>": return Tvec2;
382
+ case "f32": return Tf32;
383
+ case "u32": return Tu32;
384
+ default: throw new Error(`heapEffectIR: unknown uniform wgsl type '${wgslType}'`);
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Family-member FS uniform rewrite (direct heap reads).
390
+ *
391
+ * The wrapper module threads `heap_drawIdx` and `instId` as plain u32
392
+ * flat varyings into the FS, then passes them to each per-effect FS
393
+ * helper. Inside the helper, every `ReadInput("Uniform", X)` is
394
+ * substituted with `loadUniformByRef(loadHeaderRef(heap_drawIdx, …), …)`
395
+ * (or `loadInstanceByRef(refExpr, instId, …)` for per-instance
396
+ * uniforms). No VS-side WriteOutput stmts and no `_h_*Ref` varyings —
397
+ * the carrier is the single `heap_drawIdx` u32.
398
+ *
399
+ * `heap_drawIdx` and `instId` are referenced as `Var` exprs; the post-
400
+ * emit `applyFamilyMemberFsShape` rewires the FS entry signature to
401
+ * surface them as plain u32 fn parameters.
402
+ */
403
+ function rewriteFsUniformsDirect(m: Module, layout: BucketLayout): Module {
404
+ const used = new Set<string>();
405
+ for (const v of m.values) {
406
+ if (v.kind !== "Entry" || v.entry.stage !== "fragment") continue;
407
+ for (const sn of readInputs(v.entry.body).values()) {
408
+ if (sn.scope === "Uniform") used.add(sn.name);
409
+ }
410
+ }
411
+ if (used.size === 0) return m;
412
+
413
+ const fieldByName = new Map(layout.drawHeaderFields.map(f => [f.name, f]));
414
+ const drawIdxExpr: Expr = { kind: "Var", var: { name: "heap_drawIdx", type: Tu32, mutable: false } } as Expr;
415
+ const instIdExpr: Expr = { kind: "Var", var: { name: "instId", type: Tu32, mutable: false } } as Expr;
416
+ const fsSubst = new Map<string, Expr>();
417
+ for (const name of used) {
418
+ const f = fieldByName.get(name);
419
+ if (f === undefined || f.kind !== "uniform-ref") continue;
420
+ const refExpr = loadHeaderRef(drawIdxExpr, f.byteOffset, layout.strideU32);
421
+ const value = layout.perInstanceUniforms.has(name)
422
+ ? loadInstanceByRef(refExpr, instIdExpr, f.uniformWgslType ?? "")
423
+ : loadUniformByRef(refExpr, f.uniformWgslType ?? "");
424
+ fsSubst.set(name, value);
425
+ }
426
+ return substituteInputsInStage(m, "fragment", "Uniform", n => fsSubst.get(n));
427
+ }
428
+
429
+ /**
430
+ * Family-member FS atlas rewrite (direct heap reads).
431
+ *
432
+ * Like `rewriteFsAtlasTextures` but instead of threading
433
+ * `_h_<name>{PageRef,FormatBits,Origin,Size}` flat varyings from VS,
434
+ * the substituted `atlasSample(pageRef, formatBits, origin, size, uv)`
435
+ * call reads each header field inline from `headersU32` via
436
+ * `heap_drawIdx`. The wrapper-supplied `heap_drawIdx` u32 fn parameter
437
+ * carries the per-draw header index.
438
+ */
439
+ function rewriteFsAtlasTexturesDirect(m: Module, layout: BucketLayout): Module {
440
+ if (layout.atlasTextureBindings.size === 0) return m;
441
+ const used = new Set<string>();
442
+ for (const v of m.values) {
443
+ if (v.kind !== "Entry" || v.entry.stage !== "fragment") continue;
444
+ visitStmtExprs(v.entry.body, e => {
445
+ const hit = isAtlasSampleCall(e, layout.atlasTextureBindings);
446
+ if (hit !== null) used.add(hit.name);
447
+ });
448
+ }
449
+ if (used.size === 0) return m;
450
+
451
+ const fieldByAtlas = new Map<string, Map<string, ReturnType<typeof byteOffsetOf>>>();
452
+ for (const f of layout.drawHeaderFields) {
453
+ if (f.kind !== "texture-ref" || f.textureBindingName === undefined) continue;
454
+ const sub = f.textureSub!;
455
+ let inner = fieldByAtlas.get(f.textureBindingName);
456
+ if (inner === undefined) { inner = new Map(); fieldByAtlas.set(f.textureBindingName, inner); }
457
+ inner.set(sub, byteOffsetOf(f.byteOffset));
458
+ }
459
+
460
+ const drawIdxExpr: Expr = { kind: "Var", var: { name: "heap_drawIdx", type: Tu32, mutable: false } } as Expr;
461
+ const stride = layout.strideU32;
462
+ const headerU32At = (offU32: number): Expr =>
463
+ item(headersU32, add(mul(drawIdxExpr, constU32(stride), Tu32), constU32(offU32), Tu32), Tu32);
464
+ const headerF32At = (offU32: number): Expr => ({
465
+ kind: "CallIntrinsic",
466
+ op: { name: "bitcast<f32>", returnTypeOf: () => Tf32, pure: true,
467
+ emit: { glsl: "intBitsToFloat", wgsl: "bitcast<f32>" } } as IntrinsicRef,
468
+ args: [headerU32At(offU32)],
469
+ type: Tf32,
470
+ });
471
+
472
+ return mapStageEntryBodies(m, "fragment", body => mapStmt(body, {
473
+ expr: e => mapExpr(e, sub => {
474
+ const hit = isAtlasSampleCall(sub, layout.atlasTextureBindings);
475
+ if (hit === null) return sub;
476
+ const fields = fieldByAtlas.get(hit.name)!;
477
+ const pageRefOff = fields.get("pageRef")!.u32;
478
+ const formatBitsOff = fields.get("formatBits")!.u32;
479
+ const originOff = fields.get("origin")!.u32;
480
+ const sizeOff = fields.get("size")!.u32;
481
+ const args: Expr[] = [
482
+ headerU32At(pageRefOff),
483
+ headerU32At(formatBitsOff),
484
+ newVec([headerF32At(originOff), headerF32At(originOff + 1)], Tvec2),
485
+ newVec([headerF32At(sizeOff), headerF32At(sizeOff + 1)], Tvec2),
486
+ hit.uv,
487
+ ];
488
+ return { kind: "CallIntrinsic", op: ATLAS_SAMPLE_INTRINSIC, args, type: Tvec4 };
489
+ }),
490
+ }));
491
+ }
492
+
493
+ /**
494
+ * FS uniform threading: thread `_h_<name>Ref` (and `_iidx`, when
495
+ * needed) varyings from VS to FS, and rewrite FS `ReadInput("Uniform",
496
+ * X)` into a heap-load through the threaded ref.
497
+ *
498
+ * This pass stays at Module-level rather than going through the
499
+ * Effect.substitute API for two reasons:
500
+ *
501
+ * 1. The VS side adds *Stmts* (WriteOutput) and entry parameters,
502
+ * not Expr substitutions — outside the substitute API's scope.
503
+ * 2. Even the FS Expr substitution can't run pre-merge: the
504
+ * frontend hides `return { outColor: ... }` field exprs in a
505
+ * `_record` carrier that mapExpr doesn't traverse, so any
506
+ * uniform read inside the returned record is invisible until
507
+ * `liftReturns` rewrites it into explicit WriteOutput stmts.
508
+ * We run after liftReturns; the matching VS-side write injection
509
+ * lives in the same pass to keep the `_h_<X>Ref` names paired.
510
+ */
511
+ function rewriteFsUniforms(m: Module, layout: BucketLayout): Module {
512
+ // Scan FS bodies for uniform-scope reads.
513
+ const used = new Set<string>();
514
+ for (const v of m.values) {
515
+ if (v.kind !== "Entry" || v.entry.stage !== "fragment") continue;
516
+ for (const sn of readInputs(v.entry.body).values()) {
517
+ if (sn.scope === "Uniform") used.add(sn.name);
518
+ }
519
+ }
520
+ if (used.size === 0) return m;
521
+
522
+ const fieldByName = new Map(layout.drawHeaderFields.map(f => [f.name, f]));
523
+ const drawIdxExpr = drawIdxExprFor(layout);
524
+ const threadParams: EntryParameter[] = [];
525
+ const vsWrites: Stmt[] = [];
526
+ const fsSubst = new Map<string, Expr>();
527
+
528
+ for (const name of used) {
529
+ const f = fieldByName.get(name);
530
+ if (f === undefined || f.kind !== "uniform-ref") continue;
531
+ const refParamName = `_h_${name}Ref`;
532
+ const refExprWriter = loadHeaderRef(drawIdxExpr, f.byteOffset, layout.strideU32);
533
+ threadParams.push({
534
+ name: refParamName, type: Tu32, semantic: refParamName,
535
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
536
+ });
537
+ vsWrites.push({
538
+ kind: "WriteOutput", name: refParamName,
539
+ value: { kind: "Expr", value: refExprWriter },
540
+ });
541
+ const refReadFs = readScope("Input", refParamName, Tu32);
542
+ const value = layout.perInstanceUniforms.has(name)
543
+ ? loadInstanceByRef(refReadFs, readScope("Input", "_iidx", Tu32), f.uniformWgslType ?? "")
544
+ : loadUniformByRef(refReadFs, f.uniformWgslType ?? "");
545
+ fsSubst.set(name, value);
546
+ }
547
+
548
+ // Per-instance: also thread iidx if any FS-used uniform is per-instance.
549
+ const fsHasPerInstance = [...used].some(n => layout.perInstanceUniforms.has(n));
550
+ if (fsHasPerInstance) {
551
+ threadParams.push({
552
+ name: "_iidx", type: Tu32, semantic: "_iidx",
553
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
554
+ });
555
+ vsWrites.push({
556
+ kind: "WriteOutput", name: "_iidx",
557
+ value: { kind: "Expr", value: readScope("Builtin", "instance_index", Tu32) },
558
+ });
559
+ }
560
+
561
+ let out = m;
562
+ out = addInterstageParams(out, threadParams);
563
+ out = prependVsBodyStmts(out, vsWrites);
564
+ out = substituteInputsInStage(out, "fragment", "Uniform", n => fsSubst.get(n));
565
+ return out;
566
+ }
567
+
568
+ /**
569
+ * Per-instance uniform load: indexes into a packed array allocation
570
+ * by `iidx` (instance index in [0, instanceCount-1]).
571
+ */
572
+ function loadInstanceByRef(refIdent: Expr, iidx: Expr, wgslType: string): Expr {
573
+ const baseV4 = div(add(refIdent, constU32(16), Tu32), constU32(16), Tu32);
574
+ const baseF32 = div(add(refIdent, constU32(16), Tu32), constU32(4), Tu32);
575
+ switch (wgslType) {
576
+ case "mat4x4<f32>": {
577
+ const start = add(baseV4, mul(iidx, constU32(4), Tu32), Tu32);
578
+ const cols: Expr[] = [];
579
+ for (let i = 0; i < 4; i++) {
580
+ cols.push(item(heapV4f, i === 0 ? start : add(start, constU32(i), Tu32), Tvec4));
581
+ }
582
+ return matFromRows(cols, { kind: "Matrix", element: Tf32, rows: 4, cols: 4 } as Type);
583
+ }
584
+ case "vec4<f32>":
585
+ return item(heapV4f, add(baseV4, iidx, Tu32), Tvec4);
586
+ case "vec3<f32>": {
587
+ const off = mul(iidx, constU32(3), Tu32);
588
+ const base = add(baseF32, off, Tu32);
589
+ return newVec([
590
+ item(heapF32, base, Tf32),
591
+ item(heapF32, add(base, constU32(1), Tu32), Tf32),
592
+ item(heapF32, add(base, constU32(2), Tu32), Tf32),
593
+ ], Tvec3);
594
+ }
595
+ case "vec2<f32>": {
596
+ const off = mul(iidx, constU32(2), Tu32);
597
+ const base = add(baseF32, off, Tu32);
598
+ return newVec([
599
+ item(heapF32, base, Tf32),
600
+ item(heapF32, add(base, constU32(1), Tu32), Tf32),
601
+ ], Tvec2);
602
+ }
603
+ case "f32":
604
+ return item(heapF32, add(baseF32, iidx, Tu32), Tf32);
605
+ case "u32":
606
+ return item(heapU32, add(baseF32, iidx, Tu32), Tu32);
607
+ default:
608
+ throw new Error(`heapEffectIR: no IR loader for per-instance uniform type '${wgslType}'`);
609
+ }
610
+ }
611
+
612
+ // `wgslTypeFromHeapField` will be used once we propagate proper types
613
+ // onto the synthesised threading params; for now we only emit u32-typed
614
+ // refs, so the type lookup isn't called from the generated IR. Keep
615
+ // the import alive.
616
+ void wgslTypeFromHeapField;
617
+
618
+ // ─── FS atlas-texture rewrite (textureSample → atlasSample) ─────────
619
+ //
620
+ // For each atlas-routed binding actually called from FS as
621
+ // `textureSample(<name>, smp, uv)`:
622
+ // - Thread 4 flat-interpolated VsOut varyings: `_h_<name>PageRef`,
623
+ // `_h_<name>FormatBits`, `_h_<name>Origin`, `_h_<name>Size`.
624
+ // - VS body writes them from the drawHeader (`headersU32` reads,
625
+ // bitcast for the f32 fields since the headers buffer is u32).
626
+ // - FS substitutes the textureSample call with a `CallIntrinsic`
627
+ // emitting `atlasSample(pageRef, formatBits, origin, size, uv)`.
628
+ //
629
+ // The `atlasSample` helper is declared by the prelude string the post-
630
+ // emit injector prepends to the FS source — the IR doesn't need to
631
+ // know about its body.
632
+
633
+ /** Synthetic `atlasSample` intrinsic for the IR's CallIntrinsic emit hook. */
634
+ const ATLAS_SAMPLE_INTRINSIC: IntrinsicRef = {
635
+ name: "atlasSample",
636
+ returnTypeOf: () => Tvec4,
637
+ pure: true,
638
+ emit: { glsl: "atlasSample", wgsl: "atlasSample" },
639
+ };
640
+
641
+ function isAtlasSampleCall(e: Expr, atlasNames: ReadonlySet<string>): { name: string; uv: Expr } | null {
642
+ if (e.kind !== "CallIntrinsic") return null;
643
+ if (e.op.emit.wgsl !== "textureSample") return null;
644
+ if (e.args.length < 2) return null;
645
+ const tex = e.args[0]!;
646
+ // Pre-legaliseTypes the sampler arg is a Var or ReadInput referencing
647
+ // the Sampler ValueDef by its schema name. Match either shape.
648
+ let name: string | undefined;
649
+ if (tex.kind === "ReadInput" && tex.scope === "Uniform") name = tex.name;
650
+ else if (tex.kind === "Var") name = (tex as { var: { name: string } }).var.name;
651
+ if (name === undefined) return null;
652
+ // The schema (extracted post-legaliseTypes) names the binding
653
+ // `<sampler>_view`; the IR pre-pass sees the user-written sampler
654
+ // name. Match either form so the rewrite fires regardless of which
655
+ // shape the layout's atlas-binding set carries.
656
+ const matched =
657
+ atlasNames.has(name) ? name :
658
+ atlasNames.has(`${name}_view`) ? `${name}_view` :
659
+ undefined;
660
+ if (matched === undefined) return null;
661
+ // texture(sampler, uv) parses as a 2-arg call (no separate sampler/
662
+ // texture pair until splitWgslSamplers runs). The uv is args[1].
663
+ const uv = e.args[1]!;
664
+ return { name: matched, uv };
665
+ }
666
+
667
+ function rewriteFsAtlasTextures(m: Module, layout: BucketLayout): Module {
668
+ if (layout.atlasTextureBindings.size === 0) return m;
669
+ // Scan FS bodies to find which atlas bindings are actually used.
670
+ const used = new Set<string>();
671
+ for (const v of m.values) {
672
+ if (v.kind !== "Entry" || v.entry.stage !== "fragment") continue;
673
+ visitStmtExprs(v.entry.body, e => {
674
+ const hit = isAtlasSampleCall(e, layout.atlasTextureBindings);
675
+ if (hit !== null) used.add(hit.name);
676
+ });
677
+ }
678
+ if (used.size === 0) return m;
679
+
680
+ const fieldByAtlas = new Map<string, Map<string, ReturnType<typeof byteOffsetOf>>>();
681
+ for (const f of layout.drawHeaderFields) {
682
+ if (f.kind !== "texture-ref" || f.textureBindingName === undefined) continue;
683
+ const sub = f.textureSub!;
684
+ let inner = fieldByAtlas.get(f.textureBindingName);
685
+ if (inner === undefined) { inner = new Map(); fieldByAtlas.set(f.textureBindingName, inner); }
686
+ inner.set(sub, byteOffsetOf(f.byteOffset));
687
+ }
688
+
689
+ const drawIdxExpr = drawIdxExprFor(layout);
690
+ const threadParams: EntryParameter[] = [];
691
+ const vsWrites: Stmt[] = [];
692
+
693
+ for (const name of used) {
694
+ const v = atlasVaryingNames(name);
695
+ const fields = fieldByAtlas.get(name)!;
696
+ const stride = layout.strideU32;
697
+ const headerU32At = (offU32: number): Expr =>
698
+ item(headersU32, add(mul(drawIdxExpr, constU32(stride), Tu32), constU32(offU32), Tu32), Tu32);
699
+ const headerF32At = (offU32: number): Expr => ({
700
+ kind: "CallIntrinsic",
701
+ op: { name: "bitcast<f32>", returnTypeOf: () => Tf32, pure: true,
702
+ emit: { glsl: "intBitsToFloat", wgsl: "bitcast<f32>" } } as IntrinsicRef,
703
+ args: [headerU32At(offU32)],
704
+ type: Tf32,
705
+ });
706
+
707
+ const pageRefOff = fields.get("pageRef")!.u32;
708
+ const formatBitsOff = fields.get("formatBits")!.u32;
709
+ const originOff = fields.get("origin")!.u32;
710
+ const sizeOff = fields.get("size")!.u32;
711
+
712
+ threadParams.push({
713
+ name: v.pageRef, type: Tu32, semantic: v.pageRef,
714
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
715
+ });
716
+ threadParams.push({
717
+ name: v.formatBits, type: Tu32, semantic: v.formatBits,
718
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
719
+ });
720
+ threadParams.push({
721
+ name: v.origin, type: Tvec2, semantic: v.origin,
722
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
723
+ });
724
+ threadParams.push({
725
+ name: v.size, type: Tvec2, semantic: v.size,
726
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
727
+ });
728
+
729
+ vsWrites.push({ kind: "WriteOutput", name: v.pageRef,
730
+ value: { kind: "Expr", value: headerU32At(pageRefOff) } });
731
+ vsWrites.push({ kind: "WriteOutput", name: v.formatBits,
732
+ value: { kind: "Expr", value: headerU32At(formatBitsOff) } });
733
+ vsWrites.push({ kind: "WriteOutput", name: v.origin,
734
+ value: { kind: "Expr", value: newVec(
735
+ [headerF32At(originOff), headerF32At(originOff + 1)], Tvec2) } });
736
+ vsWrites.push({ kind: "WriteOutput", name: v.size,
737
+ value: { kind: "Expr", value: newVec(
738
+ [headerF32At(sizeOff), headerF32At(sizeOff + 1)], Tvec2) } });
739
+ }
740
+
741
+ // Rewrite FS textureSample(...) → atlasSample(...) for atlas-routed names.
742
+ let out = m;
743
+ out = addInterstageParams(out, threadParams);
744
+ out = prependVsBodyStmts(out, vsWrites);
745
+ out = mapStageEntryBodies(out, "fragment", body => mapStmt(body, {
746
+ expr: e => mapExpr(e, sub => {
747
+ const hit = isAtlasSampleCall(sub, layout.atlasTextureBindings);
748
+ if (hit === null) return sub;
749
+ const v = atlasVaryingNames(hit.name);
750
+ const args: Expr[] = [
751
+ readScope("Input", v.pageRef, Tu32),
752
+ readScope("Input", v.formatBits, Tu32),
753
+ readScope("Input", v.origin, Tvec2),
754
+ readScope("Input", v.size, Tvec2),
755
+ hit.uv,
756
+ ];
757
+ return { kind: "CallIntrinsic", op: ATLAS_SAMPLE_INTRINSIC, args, type: Tvec4 };
758
+ }),
759
+ }));
760
+ return out;
761
+ }
762
+
763
+ /** Visit every Expr inside a Stmt tree, read-only. */
764
+ function visitStmtExprs(s: Stmt, fn: (e: Expr) => void): void {
765
+ mapStmt(s, {
766
+ expr: e => mapExpr(e, sub => { fn(sub); return sub; }),
767
+ });
768
+ }
769
+
770
+ function byteOffsetOf(byteOffset: number): { u32: number } {
771
+ return { u32: byteOffset / 4 };
772
+ }
773
+
774
+ // ─── Public entry point ────────────────────────────────────────────
775
+
776
+ /**
777
+ * Compile a wombat.shader Effect into heap-rendering WGSL via IR-level
778
+ * substitution.
779
+ *
780
+ * v1 scope:
781
+ * - Adds heap storage buffers + builtin VS params.
782
+ * - Substitutes VS attribute / VS uniform reads with heap loads.
783
+ * - FS uniform substitution still TBD — falls back to current
784
+ * regex rewriter for FS until the cross-stage threading is wired.
785
+ */
786
+ /**
787
+ * Emission mode for `compileHeapEffectIR`.
788
+ *
789
+ * - `"standalone"` (default): emit a self-contained pipeline. The VS
790
+ * entry takes `@builtin(vertex_index) emitIdx` and runs the megacall
791
+ * binary-search prelude inline; storage-buffer bindings 4..6 for
792
+ * drawTable / indexStorage / firstDrawInTile are appended.
793
+ * - `"family-member"`: emit a VS entry shaped to be invoked from a
794
+ * family wrapper. The wrapper performs the megacall search and
795
+ * passes `heap_drawIdx`, `instId`, `vid` down. The emitted entry
796
+ * becomes `@vertex fn vs(heap_drawIdx: u32, instId: u32, vid: u32)`;
797
+ * no megacall prelude or bindings 4..6 are emitted at this level.
798
+ */
799
+ export type HeapEffectEmitMode = "standalone" | "family-member";
800
+
801
+ export function compileHeapEffectIR(
802
+ userEffect: Effect,
803
+ layout: BucketLayout,
804
+ compileOptions: CompileOptions,
805
+ mode: HeapEffectEmitMode = "standalone",
806
+ ): { vs: string; fs: string; preludeWgsl: string; vsEntry: string; fsEntry: string } {
807
+ // VS-side input/uniform substitution: pure read-rewrites, expressed
808
+ // via the Effect.substitute API.
809
+ const heapShaped = userEffect.substitute({
810
+ vertex: {
811
+ inputs: buildVertexAttributeMap(layout),
812
+ uniforms: buildVertexUniformMap(layout),
813
+ },
814
+ });
815
+
816
+ // Build a single Module from all of the effect's stages by
817
+ // composing them. Then apply the remaining heap rewrites to that
818
+ // combined module. The composed module preserves stage entry
819
+ // boundaries.
820
+ let combined: Module = mergeStages(heapShaped);
821
+ // Lift `return { ... }` into explicit WriteOutput stmts so the FS
822
+ // uniform pass below can see (and rewrite) ReadInputs that feed the
823
+ // returned record fields. Without this, ObjectLiteral exprs hide
824
+ // their fields on a `_record` carrier that mapExpr doesn't traverse.
825
+ combined = liftReturns(combined);
826
+ combined = injectVsBuiltins(combined);
827
+ // FS uniform threading must run BEFORE atlas FS rewriting in source
828
+ // order? No — order historically: atlas first, then uniforms. Both
829
+ // add interstage params and VS WriteOutput stmts; their write sets
830
+ // are disjoint, so the order is interchangeable in practice. Keep
831
+ // the original order for byte-for-byte equivalence.
832
+ if (mode === "family-member") {
833
+ // Direct heap-read FS path: substitute uniforms / atlas samples
834
+ // directly using the wrapper-supplied `heap_drawIdx` / `instId`
835
+ // u32 fn parameters. No VS→FS varying threading on the heap side.
836
+ combined = rewriteFsAtlasTexturesDirect(combined, layout);
837
+ combined = rewriteFsUniformsDirect(combined, layout);
838
+ } else {
839
+ combined = rewriteFsAtlasTextures(combined, layout);
840
+ combined = rewriteFsUniforms(combined, layout);
841
+ }
842
+ // Add heap storage buffers as bindings.
843
+ combined = { ...combined, values: [...heapStorageBufferDecls(), ...combined.values] };
844
+
845
+ // Skip frontend resolveHoles / liftReturns since they expect the
846
+ // template not to have been pre-modified. Compile via compileModule
847
+ // directly which still runs assignLocations + linkCrossStage + DCE +
848
+ // emit. (`makeStage` wrapper goes through Effect.compile which calls
849
+ // compileModule too.)
850
+ const compiled = compileModule(combined, compileOptions);
851
+ const vsStage = compiled.stages.find(s => s.stage === "vertex");
852
+ const fsStage = compiled.stages.find(s => s.stage === "fragment");
853
+ let vsSrc = vsStage?.source ?? "";
854
+ let fsSrc = fsStage?.source ?? "";
855
+ if (vsSrc.length > 0) {
856
+ vsSrc = mode === "family-member"
857
+ ? applyFamilyMemberShape(vsSrc)
858
+ : applyMegacallToEmittedVs(vsSrc);
859
+ }
860
+ if (mode === "family-member" && fsSrc.length > 0) {
861
+ fsSrc = applyFamilyMemberFsShape(fsSrc);
862
+ }
863
+ if (layout.atlasTextureBindings.size > 0) {
864
+ // Strip user-emitted texture/sampler binding decls for atlas-routed
865
+ // names — the IR's FS still emits `var <name>: texture_2d<f32>;` even
866
+ // though we've replaced every textureSample call referencing it.
867
+ // The render BGL has no slot for these bindings.
868
+ if (vsSrc.length > 0) vsSrc = stripAtlasTextureSamplerDecls(vsSrc, layout.atlasTextureBindings);
869
+ if (fsSrc.length > 0) {
870
+ fsSrc = stripAtlasTextureSamplerDecls(fsSrc, layout.atlasTextureBindings);
871
+ fsSrc = prependAtlasPrelude(fsSrc);
872
+ }
873
+ }
874
+ return {
875
+ vs: vsSrc,
876
+ fs: fsSrc,
877
+ vsEntry: vsStage?.entryName ?? "vs",
878
+ fsEntry: fsStage?.entryName ?? "fs",
879
+ preludeWgsl: "",
880
+ };
881
+ }
882
+
883
+ /**
884
+ * Drop `@group(N) @binding(M) var <name>: texture_*<...>;` and the
885
+ * matching sampler decl(s) for atlas-routed binding names. We keep
886
+ * other texture/sampler decls intact since they belong to the standalone
887
+ * texture path.
888
+ *
889
+ * Sampler-name detection: WGSL emit pairs samplers with their textures
890
+ * positionally in the IR; the user's WGSL can name them anything. To
891
+ * be safe we also strip any sampler decl that is *only* referenced by
892
+ * a now-rewritten textureSample call. Rather than do data-flow, we
893
+ * pattern-match: if the FS has zero remaining textureSample calls AND
894
+ * a single sampler decl, drop it. Otherwise leave it. Practically the
895
+ * v1 surface has 0..1 atlas-routed binding per shader, so this is
896
+ * sufficient.
897
+ */
898
+ function stripAtlasTextureSamplerDecls(src: string, atlasNames: ReadonlySet<string>): string {
899
+ let out = src;
900
+ for (const name of atlasNames) {
901
+ const re = new RegExp(`@group\\(\\d+\\)\\s*@binding\\(\\d+\\)\\s*var\\s+${name}\\s*:\\s*texture_\\w+(?:<[^>]*>)?\\s*;\\s*`, "g");
902
+ out = out.replace(re, "");
903
+ }
904
+ // If textureSample no longer appears anywhere, drop the (single)
905
+ // sampler decl too. Conservative: we leave samplers alone if they
906
+ // could still be in use.
907
+ if (!/textureSample\s*\(/.test(out)) {
908
+ out = out.replace(/@group\(\d+\)\s*@binding\(\d+\)\s*var\s+\w+\s*:\s*sampler(?:_comparison)?\s*;\s*/g, "");
909
+ }
910
+ return out;
911
+ }
912
+
913
+ /**
914
+ * Prepend the atlas texture-array decls + `atlasSample` helper to
915
+ * the emitted FS source. The helper signatures match the post-
916
+ * rewrite `atlasSample(pageRef, formatBits, origin, size, uv)`
917
+ * calls the IR pass produces.
918
+ */
919
+ function prependAtlasPrelude(fs: string): string {
920
+ // Delegate to the VS prelude generator — both stages need the same
921
+ // atlas-sample helper, and consolidating prevents the two copies
922
+ // from drifting.
923
+ return generateAtlasPrelude() + fs;
924
+ }
925
+
926
+ /**
927
+ * Megacall post-processing for the IR-emitted VS source. The IR uses
928
+ * `@builtin(vertex_index) vertex_index` + `@builtin(instance_index)
929
+ * instance_index` as the param names; rather than rewrite the IR
930
+ * (which would require building the binary-search loop in IR), we
931
+ * post-process the emitted WGSL: strip instance_index from the
932
+ * @vertex signature, rename vertex_index to emitIdx, and inject a
933
+ * prelude that defines `instance_index` + `vertex_index` as locals
934
+ * via the drawTable binary search so existing body references keep
935
+ * resolving. Also injects the drawTable/indexStorage binding decls.
936
+ */
937
+ function applyMegacallToEmittedVs(vs: string): string {
938
+ let s = vs;
939
+ // Megacall storage bindings only — the shared values (`heap_drawIdx`,
940
+ // `instId`, `vid`) are declared as `let` locals in the @vertex body
941
+ // and threaded as parameters into any helper fn that references them.
942
+ // No module-scope `var<private>` (Safari/WebKit rejects reading
943
+ // those from helper fn bodies).
944
+ const decl = `\n@group(0) @binding(4) var<storage, read> drawTable: array<u32>;\n@group(0) @binding(5) var<storage, read> indexStorage: array<u32>;\n@group(0) @binding(6) var<storage, read> firstDrawInTile: array<u32>;\n`;
945
+ // Locate the @vertex fn header, then balance parens manually since
946
+ // params can carry `@builtin(name)` decorations (regex `[^)]*` would
947
+ // halt at the first inner `)`).
948
+ const startRe = /@vertex\s+fn\s+(\w+)\s*\(/;
949
+ const startMatch = s.match(startRe);
950
+ if (startMatch === null) return s;
951
+ const fnName = startMatch[1]!;
952
+ const paramOpen = startMatch.index! + startMatch[0]!.length;
953
+ let depth = 1;
954
+ let i = paramOpen;
955
+ for (; i < s.length && depth > 0; i++) {
956
+ const c = s[i];
957
+ if (c === "(") depth++;
958
+ else if (c === ")") depth--;
959
+ }
960
+ if (depth !== 0) return s;
961
+ const paramList = s.slice(paramOpen, i - 1);
962
+ // After the closing `)` we expect `\s*->\s*<retType>\s*\{`.
963
+ const tailRe = /\s*->\s*([\w<>,\s]+?)\s*\{/y;
964
+ tailRe.lastIndex = i;
965
+ const tailMatch = tailRe.exec(s);
966
+ if (tailMatch === null) return s;
967
+ const retType = tailMatch[1]!.trim();
968
+ const headerEnd = tailRe.lastIndex;
969
+ const headerStart = startMatch.index!;
970
+ const params = paramList.split(",").map(p => p.trim()).filter(p => p.length > 0);
971
+ const kept: string[] = [];
972
+ for (const p of params) {
973
+ if (/@builtin\(\s*instance_index\s*\)/.test(p)) continue;
974
+ if (/@builtin\(\s*vertex_index\s*\)/.test(p)) {
975
+ kept.push("@builtin(vertex_index) emitIdx: u32");
976
+ continue;
977
+ }
978
+ kept.push(p);
979
+ }
980
+ // megacallSearchPrelude exposes `heap_drawIdx`, `instId`, `vid`. The
981
+ // IR rewrites bind header-selector reads to `heap_drawIdx` directly
982
+ // (loadHeaderRef takes an Expr arg); the remaining bindings expose
983
+ // `instance_index` (= per-RO instance idx) and `vertex_index` for
984
+ // user code that reads them by builtin.
985
+ const newHeader = `@vertex fn ${fnName}(${kept.join(", ")}) -> ${retType} {\n${megacallSearchPrelude()} let instance_index: u32 = instId;\n let vertex_index: u32 = vid;\n`;
986
+ s = s.slice(0, headerStart) + newHeader + s.slice(headerEnd);
987
+ s = threadMegacallParamsThroughHelpers(s);
988
+ return decl + s;
989
+ }
990
+
991
+ /**
992
+ * Family-member shape: the wrapper performs the megacall binary search
993
+ * and invokes this VS as a regular function, passing `heap_drawIdx`,
994
+ * `instId`, and `vid` as plain `u32` parameters. We rewrite the emitted
995
+ * @vertex signature so its first three params are exactly those names
996
+ * (no `@builtin` decorations), and inject local aliases so any body
997
+ * references to `vertex_index` / `instance_index` (the names produced
998
+ * by IR `Builtin` reads + `injectVsBuiltins`) resolve to the new
999
+ * parameters.
1000
+ *
1001
+ * The megacall storage-buffer bindings (drawTable / indexStorage /
1002
+ * firstDrawInTile) are NOT emitted — the wrapper module owns them.
1003
+ *
1004
+ * `threadMegacallParamsThroughHelpers` is run unchanged: helper fns
1005
+ * extracted by composeStages may still reference `heap_drawIdx`,
1006
+ * `instId`, `vid` after CSE; threading the params is a pure text walk
1007
+ * and does not depend on the entry shape.
1008
+ */
1009
+ function applyFamilyMemberShape(vs: string): string {
1010
+ let s = vs;
1011
+ // Locate the @vertex fn header, then balance parens manually since
1012
+ // params can carry `@builtin(name)` decorations.
1013
+ const startRe = /@vertex\s+fn\s+(\w+)\s*\(/;
1014
+ const startMatch = s.match(startRe);
1015
+ if (startMatch === null) return s;
1016
+ const fnName = startMatch[1]!;
1017
+ const paramOpen = startMatch.index! + startMatch[0]!.length;
1018
+ let depth = 1;
1019
+ let i = paramOpen;
1020
+ for (; i < s.length && depth > 0; i++) {
1021
+ const c = s[i];
1022
+ if (c === "(") depth++;
1023
+ else if (c === ")") depth--;
1024
+ }
1025
+ if (depth !== 0) return s;
1026
+ const paramList = s.slice(paramOpen, i - 1);
1027
+ const tailRe = /\s*->\s*([\w<>,\s]+?)\s*\{/y;
1028
+ tailRe.lastIndex = i;
1029
+ const tailMatch = tailRe.exec(s);
1030
+ if (tailMatch === null) return s;
1031
+ const retType = tailMatch[1]!.trim();
1032
+ const headerEnd = tailRe.lastIndex;
1033
+ const headerStart = startMatch.index!;
1034
+ // Drop ALL @vertex fn params: the megacall builtins
1035
+ // (instance_index, vertex_index) are not needed because we re-bind
1036
+ // them from incoming u32 args below, and any @location(...) attribute
1037
+ // params have already had their body reads substituted with
1038
+ // heap-load expressions by `substituteInputsInStage` — the params
1039
+ // themselves are dead. The wrapper calls the helper with exactly
1040
+ // three megacall args.
1041
+ const newParamList = ["heap_drawIdx: u32", "instId: u32", "vid: u32"].join(", ");
1042
+ void paramList;
1043
+ // Body aliases: IR-emitted body code reads `vertex_index` and
1044
+ // `instance_index` (the names of the @builtin params standalone mode
1045
+ // adds). Re-bind those names to the incoming `vid` / `instId`.
1046
+ const bodyAliases =
1047
+ ` let instance_index: u32 = instId;\n` +
1048
+ ` let vertex_index: u32 = vid;\n`;
1049
+ const newHeader = `@vertex fn ${fnName}(${newParamList}) -> ${retType} {\n${bodyAliases}`;
1050
+ s = s.slice(0, headerStart) + newHeader + s.slice(headerEnd);
1051
+ s = threadMegacallParamsThroughHelpers(s);
1052
+ return s;
1053
+ }
1054
+
1055
+ /**
1056
+ * Family-member FS shape: rewrite the emitted `@fragment fn` signature
1057
+ * to take `heap_drawIdx: u32, instId: u32` as additional plain u32 fn
1058
+ * parameters (after the existing FsIn struct param). The body uses
1059
+ * `heap_drawIdx` / `instId` as `Var` reads — the wrapper passes them
1060
+ * in from flat-interpolated u32 inputs on the family FsIn.
1061
+ *
1062
+ * `threadMegacallParamsThroughHelpers` is run as well in case any FS
1063
+ * helper fn extracted by composeStages references those identifiers.
1064
+ */
1065
+ function applyFamilyMemberFsShape(fs: string): string {
1066
+ let s = fs;
1067
+ const startRe = /@fragment\s+fn\s+(\w+)\s*\(/;
1068
+ const startMatch = s.match(startRe);
1069
+ if (startMatch === null) return s;
1070
+ const fnName = startMatch[1]!;
1071
+ const paramOpen = startMatch.index! + startMatch[0]!.length;
1072
+ let depth = 1;
1073
+ let i = paramOpen;
1074
+ for (; i < s.length && depth > 0; i++) {
1075
+ const c = s[i];
1076
+ if (c === "(") depth++;
1077
+ else if (c === ")") depth--;
1078
+ }
1079
+ if (depth !== 0) return s;
1080
+ const paramList = s.slice(paramOpen, i - 1);
1081
+ const tailRe = /\s*->\s*([\w<>,\s]+?)\s*\{/y;
1082
+ tailRe.lastIndex = i;
1083
+ const tailMatch = tailRe.exec(s);
1084
+ if (tailMatch === null) return s;
1085
+ const retType = tailMatch[1]!.trim();
1086
+ const headerEnd = tailRe.lastIndex;
1087
+ const headerStart = startMatch.index!;
1088
+ const existing = paramList.split(",").map(p => p.trim()).filter(p => p.length > 0);
1089
+ const newParamList = [...existing, "heap_drawIdx: u32", "instId: u32"].join(", ");
1090
+ const newHeader = `@fragment fn ${fnName}(${newParamList}) -> ${retType} {\n`;
1091
+ s = s.slice(0, headerStart) + newHeader + s.slice(headerEnd);
1092
+ s = threadMegacallParamsThroughHelpers(s);
1093
+ return s;
1094
+ }
1095
+
1096
+ /**
1097
+ * Post-emit text rewrite that threads `heap_drawIdx`, `instId`, and
1098
+ * `vid` through any helper function whose body references them. The
1099
+ * IR's composeStages pass extracts same-stage entries into helper fns
1100
+ * (`fn _<name>(s_in: <State>) -> <State>`) called from the wrapper
1101
+ * `@vertex` fn. Those helpers can carry CSE-extracted expressions that
1102
+ * reference `heap_drawIdx`/`instId`/`vid` — declared as locals in the
1103
+ * wrapper, those identifiers are out of scope inside helpers, so we
1104
+ * append them as `u32` parameters and pass them at every call site.
1105
+ *
1106
+ * Module-scope `var<private>` would also work but Safari/WebKit's WGSL
1107
+ * parser rejects helper-fn reads of module-scope private vars in some
1108
+ * configurations; explicit parameter passing is portable across all
1109
+ * conforming WGSL implementations.
1110
+ */
1111
+ function threadMegacallParamsThroughHelpers(src: string): string {
1112
+ const idents = ["heap_drawIdx", "instId", "vid"] as const;
1113
+ // Find every `fn _<name>(...) -> <ret> { ... }` declaration. We only
1114
+ // touch fns whose name starts with `_` (the convention used by
1115
+ // extractFusedEntry's helpers); user code with a leading-underscore
1116
+ // name is not expected in IR-emitted WGSL.
1117
+ const fnRe = /\bfn\s+(_\w+)\s*\(/g;
1118
+ // Collect (helperName, paramRange, bodyRange, neededIdents).
1119
+ type Edit = { name: string; needed: string[]; paramOpen: number; paramClose: number };
1120
+ const edits: Edit[] = [];
1121
+ let m: RegExpExecArray | null;
1122
+ while ((m = fnRe.exec(src)) !== null) {
1123
+ const name = m[1]!;
1124
+ const paramOpen = m.index + m[0]!.length; // position after `(`
1125
+ // Balance parens to find the matching `)`.
1126
+ let depth = 1;
1127
+ let i = paramOpen;
1128
+ for (; i < src.length && depth > 0; i++) {
1129
+ const c = src[i];
1130
+ if (c === "(") depth++;
1131
+ else if (c === ")") depth--;
1132
+ }
1133
+ if (depth !== 0) continue;
1134
+ const paramClose = i - 1; // position of the matching `)`
1135
+ // Find the function body `{ ... }` after the return type.
1136
+ const afterParens = src.slice(i);
1137
+ const braceIdx = afterParens.indexOf("{");
1138
+ if (braceIdx < 0) continue;
1139
+ const bodyOpen = i + braceIdx;
1140
+ let bdepth = 1;
1141
+ let j = bodyOpen + 1;
1142
+ for (; j < src.length && bdepth > 0; j++) {
1143
+ const c = src[j];
1144
+ if (c === "{") bdepth++;
1145
+ else if (c === "}") bdepth--;
1146
+ }
1147
+ if (bdepth !== 0) continue;
1148
+ const bodyClose = j - 1;
1149
+ const body = src.slice(bodyOpen + 1, bodyClose);
1150
+ const needed = idents.filter(id => new RegExp(`\\b${id}\\b`).test(body));
1151
+ if (needed.length === 0) continue;
1152
+ edits.push({ name, needed, paramOpen, paramClose });
1153
+ }
1154
+ if (edits.length === 0) return src;
1155
+ // Apply param-list edits from the back so earlier offsets stay valid.
1156
+ const helperNeeds = new Map<string, string[]>();
1157
+ let out = src;
1158
+ for (let k = edits.length - 1; k >= 0; k--) {
1159
+ const e = edits[k]!;
1160
+ helperNeeds.set(e.name, e.needed);
1161
+ const existing = out.slice(e.paramOpen, e.paramClose).trim();
1162
+ const extra = e.needed.map(id => `${id}: u32`).join(", ");
1163
+ const newParams = existing.length === 0 ? extra : `${existing}, ${extra}`;
1164
+ out = out.slice(0, e.paramOpen) + newParams + out.slice(e.paramClose);
1165
+ }
1166
+ // Rewrite call sites for each touched helper. Match `<name>(<args>)`
1167
+ // and append the corresponding identifiers. Match the bare name so
1168
+ // we don't accidentally hit a substring inside another identifier.
1169
+ for (const [name, needed] of helperNeeds) {
1170
+ const callRe = new RegExp(`\\b${name}\\s*\\(`, "g");
1171
+ let result = "";
1172
+ let lastIdx = 0;
1173
+ let cm: RegExpExecArray | null;
1174
+ while ((cm = callRe.exec(out)) !== null) {
1175
+ const callOpen = cm.index + cm[0]!.length;
1176
+ // Skip if this is the `fn <name>(` declaration itself (preceded
1177
+ // by `fn`).
1178
+ const before = out.slice(0, cm.index).trimEnd();
1179
+ if (/\bfn$/.test(before)) continue;
1180
+ // Balance parens to find matching `)`.
1181
+ let depth = 1;
1182
+ let p = callOpen;
1183
+ for (; p < out.length && depth > 0; p++) {
1184
+ const c = out[p];
1185
+ if (c === "(") depth++;
1186
+ else if (c === ")") depth--;
1187
+ }
1188
+ if (depth !== 0) continue;
1189
+ const callClose = p - 1;
1190
+ const args = out.slice(callOpen, callClose).trim();
1191
+ const extras = needed.join(", ");
1192
+ const newArgs = args.length === 0 ? extras : `${args}, ${extras}`;
1193
+ result += out.slice(lastIdx, callOpen) + newArgs + ")";
1194
+ lastIdx = p; // p is one past the `)`
1195
+ }
1196
+ if (lastIdx > 0) {
1197
+ result += out.slice(lastIdx);
1198
+ out = result;
1199
+ }
1200
+ }
1201
+ return out;
1202
+ }
1203
+
1204
+ function mergeStages(eff: Effect): Module {
1205
+ // Combine all stages' templates into one module by concatenating
1206
+ // their values + types arrays. The compile pipeline's composeStages
1207
+ // pass will fuse same-stage entries afterwards.
1208
+ const types = eff.stages.flatMap(s => [...s.template.types]);
1209
+ const values = eff.stages.flatMap(s => [...s.template.values]);
1210
+ return { types, values };
1211
+ }