@descix/frqtl-node 0.0.0-stage → 0.6.0-p0.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 (217) hide show
  1. package/CLAUDE.md +87 -0
  2. package/README.md +84 -2
  3. package/dist/dist-manifest.json +218 -0
  4. package/dist/eyes/index.js +109 -0
  5. package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
  6. package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
  7. package/dist/fat/pkg-node/package.json +12 -0
  8. package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
  9. package/dist/frqtl/compat/boot-node.js +34 -0
  10. package/dist/frqtl/compat/engine.js +127 -0
  11. package/dist/frqtl/compat/package-root.js +24 -0
  12. package/dist/frqtl/compat/providers/wasm-engine.js +2501 -0
  13. package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
  14. package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
  15. package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
  16. package/dist/frqtl/compat/renderer/Filters.js +163 -0
  17. package/dist/frqtl/compat/renderer/RenderFamily.js +85 -0
  18. package/dist/frqtl/compat/renderer/RenderMesh.js +100 -0
  19. package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
  20. package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
  21. package/dist/frqtl/compat/renderer/captureStill-common.js +259 -0
  22. package/dist/frqtl/compat/renderer/display-slot.js +96 -0
  23. package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
  24. package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
  25. package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
  26. package/dist/frqtl/compat/renderer/providers/index.js +22 -0
  27. package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
  28. package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
  29. package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
  30. package/dist/frqtl/compat/renderer/providers/three-provider.js +2474 -0
  31. package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
  32. package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
  33. package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
  34. package/dist/frqtl/compat/vendor.js +196 -0
  35. package/dist/frqtl/node-entry.js +17 -0
  36. package/dist/frqtl/objects/FrqtlApp.js +2922 -0
  37. package/dist/frqtl/objects/ObjectFactory.js +297 -0
  38. package/dist/frqtl/objects/RecipeContext.js +322 -0
  39. package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
  40. package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
  41. package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
  42. package/dist/frqtl/objects/instruments/capture-host.js +453 -0
  43. package/dist/frqtl/objects/instruments/capture.js +711 -0
  44. package/dist/frqtl/objects/instruments/hosted-eyes.js +985 -0
  45. package/dist/frqtl/objects/kinds-entry.js +14 -0
  46. package/dist/frqtl/objects/kinds.js +190 -0
  47. package/dist/frqtl/objects/objects/Box.js +273 -0
  48. package/dist/frqtl/objects/objects/Bullet.js +155 -0
  49. package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
  50. package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
  51. package/dist/frqtl/objects/objects/DirectedTunnel.js +412 -0
  52. package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
  53. package/dist/frqtl/objects/objects/LightSource.js +354 -0
  54. package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
  55. package/dist/frqtl/objects/objects/Tube.js +471 -0
  56. package/dist/frqtl/objects/objects/absorbRadiation.js +310 -0
  57. package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
  58. package/dist/frqtl/objects/objects/controlAction.js +89 -0
  59. package/dist/frqtl/objects/objects/controlBind.js +278 -0
  60. package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
  61. package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
  62. package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
  63. package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
  64. package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
  65. package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
  66. package/dist/frqtl/objects/objects/makeControlsForObject.js +824 -0
  67. package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
  68. package/dist/frqtl/objects/objects/slitLinerScatter.js +263 -0
  69. package/dist/frqtl/objects/objects/sustainedSource.js +299 -0
  70. package/dist/frqtl/objects/objects/tagger.js +228 -0
  71. package/dist/frqtl/objects/schema/engine-scopes.js +469 -0
  72. package/dist/frqtl/objects/schema/index.js +256 -0
  73. package/dist/frqtl/objects/schema/scopes.js +457 -0
  74. package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
  75. package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
  76. package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
  77. package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
  78. package/dist/frqtl/objects/setup/_worldInit.js +162 -0
  79. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
  80. package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
  81. package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
  82. package/dist/frqtl/objects/setup/setupConeEmitter3D.js +251 -0
  83. package/dist/frqtl/objects/setup/setupDoubleSlit.js +327 -0
  84. package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
  85. package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
  86. package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
  87. package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
  88. package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
  89. package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
  90. package/dist/frqtl/objects/setup/setupLightSourceLab.js +280 -0
  91. package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
  92. package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
  93. package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
  94. package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1259 -0
  95. package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
  96. package/dist/frqtl/objects/setup/setupTunnelLab.js +242 -0
  97. package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
  98. package/dist/frqtl/objects/setup/setupWaveInterference.js +178 -0
  99. package/dist/frqtl/objects/v1-replacements.js +30 -0
  100. package/dist/frqtl/package-core.js +23 -0
  101. package/dist/frqtl/parts/CameraControls.js +643 -0
  102. package/dist/frqtl/parts/CameraPath.js +575 -0
  103. package/dist/frqtl/parts/CollisionActions.js +35 -0
  104. package/dist/frqtl/parts/CollisionTypes.js +31 -0
  105. package/dist/frqtl/parts/DetectorWall.js +496 -0
  106. package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
  107. package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
  108. package/dist/frqtl/parts/FrqtlVision.js +818 -0
  109. package/dist/frqtl/parts/GraphingDetectorWall.js +490 -0
  110. package/dist/frqtl/parts/LargeObject.js +406 -0
  111. package/dist/frqtl/parts/LightColor.js +255 -0
  112. package/dist/frqtl/parts/LiveChart.js +285 -0
  113. package/dist/frqtl/parts/LogicGate.js +720 -0
  114. package/dist/frqtl/parts/OvenBox.js +612 -0
  115. package/dist/frqtl/parts/PointSource.js +763 -0
  116. package/dist/frqtl/parts/Rectangle.js +200 -0
  117. package/dist/frqtl/parts/SlitLightSource.js +234 -0
  118. package/dist/frqtl/parts/SlitScreen.js +628 -0
  119. package/dist/frqtl/parts/SphereEmitter.js +946 -0
  120. package/dist/frqtl/parts/Trace.js +508 -0
  121. package/dist/frqtl/parts/Tunnel.js +1057 -0
  122. package/dist/frqtl/parts/Wall.js +1211 -0
  123. package/dist/frqtl/parts/index.js +33 -0
  124. package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
  125. package/dist/frqtl/scenes/absorbing-wall.js +32 -0
  126. package/dist/frqtl/scenes/atomic-model.js +88 -0
  127. package/dist/frqtl/scenes/big-bang.js +46 -0
  128. package/dist/frqtl/scenes/blackbody.js +81 -0
  129. package/dist/frqtl/scenes/circuit-sat.js +127 -0
  130. package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
  131. package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
  132. package/dist/frqtl/scenes/counting-wall.js +38 -0
  133. package/dist/frqtl/scenes/double-slit.js +31 -0
  134. package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
  135. package/dist/frqtl/scenes/dse-one-slit.js +114 -0
  136. package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
  137. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +244 -0
  138. package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
  139. package/dist/frqtl/scenes/dse-two-slits.js +132 -0
  140. package/dist/frqtl/scenes/empty.js +14 -0
  141. package/dist/frqtl/scenes/laser-buildup.js +49 -0
  142. package/dist/frqtl/scenes/mirror-3d.js +56 -0
  143. package/dist/frqtl/scenes/mirror-route.js +140 -0
  144. package/dist/frqtl/scenes/mirror.js +38 -0
  145. package/dist/frqtl/scenes/omni-3d.js +27 -0
  146. package/dist/frqtl/scenes/omni-source.js +22 -0
  147. package/dist/frqtl/scenes/self-interference.js +39 -0
  148. package/dist/frqtl/scenes/smoking-gun.js +122 -0
  149. package/dist/frqtl/scenes/straight-walk.js +22 -0
  150. package/dist/frqtl/scenes/sun.js +39 -0
  151. package/dist/frqtl/scenes/two-source.js +37 -0
  152. package/dist/frqtl/scenes/wave.js +26 -0
  153. package/dist/shim/bundle/frqnb.js +112 -0
  154. package/dist/shim/bundle/index.js +121 -0
  155. package/dist/shim/bundle/indexHtml.js +107 -0
  156. package/dist/shim/bundle/manifest.js +95 -0
  157. package/dist/shim/bundle/zip.js +243 -0
  158. package/dist/shim/bus.js +67 -0
  159. package/dist/shim/controls/index.js +627 -0
  160. package/dist/shim/controls/registry.js +274 -0
  161. package/dist/shim/engine/artifact-shell.js +212 -0
  162. package/dist/shim/engine/boot-phases.js +44 -0
  163. package/dist/shim/engine/engine-stack-boot.js +59 -0
  164. package/dist/shim/engine/router.js +692 -0
  165. package/dist/shim/experiment-frame/index.js +296 -0
  166. package/dist/shim/expr/ast-schema.js +369 -0
  167. package/dist/shim/field-descriptor/index.js +326 -0
  168. package/dist/shim/frame-data/index.js +224 -0
  169. package/dist/shim/index.js +67 -0
  170. package/dist/shim/notebook/canvas-guard.js +82 -0
  171. package/dist/shim/notebook/catalog.js +35 -0
  172. package/dist/shim/notebook/format.js +736 -0
  173. package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
  174. package/dist/shim/notebook/kernel.js +672 -0
  175. package/dist/shim/notebook/knob.js +151 -0
  176. package/dist/shim/notebook/module-resolver.js +260 -0
  177. package/dist/shim/notebook/notebook-knobs.js +65 -0
  178. package/dist/shim/notebook/produced-bindings.js +67 -0
  179. package/dist/shim/notebook/realm-registry.js +121 -0
  180. package/dist/shim/notebook/realm-transport.js +73 -0
  181. package/dist/shim/notebook/setup-builder.js +510 -0
  182. package/dist/shim/notebook/store.js +159 -0
  183. package/dist/shim/notebook/vendor-importmap.js +134 -0
  184. package/dist/shim/notebook/view-builder.js +189 -0
  185. package/dist/shim/notebook/view-ctx.js +100 -0
  186. package/dist/shim/notebook/view-handle.js +202 -0
  187. package/dist/shim/notebook/warmup.js +102 -0
  188. package/dist/shim/package.json +4 -0
  189. package/dist/shim/page-params/index.js +214 -0
  190. package/dist/shim/project/identity.js +112 -0
  191. package/dist/shim/project/index.js +19 -0
  192. package/dist/shim/project/link.js +402 -0
  193. package/dist/shim/project/node-specifier-hook.mjs +35 -0
  194. package/dist/shim/project/register-node.mjs +17 -0
  195. package/dist/shim/project/specifiers.js +106 -0
  196. package/dist/shim/registry/copy-gate.js +110 -0
  197. package/dist/shim/registry/exhibits.js +455 -0
  198. package/dist/shim/registry/experiments.js +215 -0
  199. package/dist/shim/registry/index.js +19 -0
  200. package/dist/shim/registry/scene-source.js +157 -0
  201. package/dist/shim/registry/walkthrough.js +807 -0
  202. package/dist/shim/render/capture-envelope.js +279 -0
  203. package/dist/shim/render/heat-colormap.js +92 -0
  204. package/dist/shim/render/render-stages.js +855 -0
  205. package/dist/shim/render/sun-look.js +33 -0
  206. package/dist/shim/render-state/apply.js +262 -0
  207. package/dist/shim/render-state/index.js +74 -0
  208. package/dist/shim/rpc/index.js +207 -0
  209. package/dist/shim/runtime/index.js +1023 -0
  210. package/dist/shim/storage/boot.js +69 -0
  211. package/dist/shim/storage/index.js +169 -0
  212. package/dist/shim/storage/local-storage-provider.js +230 -0
  213. package/dist/shim/storage/make-namespace-store.js +132 -0
  214. package/dist/shim/storage/modules.js +249 -0
  215. package/dist/shim/storage/projects.js +150 -0
  216. package/llms.txt +12 -0
  217. package/package.json +62 -4
@@ -0,0 +1,1747 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * actionCompiler.js — the op-program compiler. TWO entry points, one output.
5
+ *
6
+ * This is the ONE place that lowers a declared collision behaviour into the op-program AST
7
+ * (lib/shim/expr/ast-schema.js). The engine has NO behaviour vocabulary: it evaluates the
8
+ * program's trigger, applies `set` ops, and provides the min/max/floor/random/intersects
9
+ * primitives. Everything a wall "does" is decided here, at compile time.
10
+ *
11
+ * compileWallV2(spec) — the V2 vocabulary (collisionBehavior +
12
+ * bounceDirection + hooks). Walls speak ONLY this.
13
+ * compileActions(actions, …) — the V1 CollisionActions[] + collision_type path,
14
+ * still live for the non-Wall parts (Box, Bullet,
15
+ * Tube, CircuitGate/Probe, SlitScreen's slits).
16
+ *
17
+ * ## Call order (both paths)
18
+ *
19
+ * The compiler runs BEFORE registration, not after: `LargeObject.js::LargeObject` compiles,
20
+ * puts the program INTO the spec, and only then calls `universe.registerLargeObject(spec)`.
21
+ * (The V2 Wall path registers with an empty spec and pushes its program immediately after,
22
+ * via updateObjectProperty(handle,'LargeObject','program', …) — the AST must cross the WASM
23
+ * boundary as a JSON string, so it cannot ride the spec object itself.)
24
+ *
25
+ * ## There is no native dispatch, so nothing may be left uncompiled
26
+ *
27
+ * `universe.rs::process_large_object_collisions` has no action or collision_type dispatch,
28
+ * and `egpt_wasm::LargeObjectJsSpec` has no `actions` field — serde drops the key. An action
29
+ * this compiler does not lower therefore vanishes at the wire with no error: a typo, a LOG,
30
+ * or a BLOCK with no eject face produced a wall that silently did nothing. So `compileActions`
31
+ * THROWS on anything it cannot compile, naming the action and its replacement.
32
+ *
33
+ * ## Action → op-program map
34
+ *
35
+ * | Action | Op-program form | Notes |
36
+ * |--------------|----------------------------------------------------|----------------------------------|
37
+ * | ABSORB | set 0.is_alive = 0 | kills the colliding frame |
38
+ * | COUNT | set 1.count = 1.count + 1 | increments the LO hit counter |
39
+ * | MARK/DETECT | Tagger.tagOps(loName) | the MARK part (tagger.js) |
40
+ * | BLOCK | set 0.left/top = eject expr (named dir OR closest) | mirrored from OvenBox §10.3 |
41
+ * | BOUNCE | eject + velocity flip (named dir OR closest) | same as OvenBox L4 |
42
+ *
43
+ * ## 'closest' eject (Cond-tree)
44
+ *
45
+ * 'closest' picks the nearest face of the LO to the frame's top-left corner:
46
+ * dL = |LO.left - frame.left|, dR = |LO.right - frame.left|
47
+ * dT = |LO.top - frame.top |, dB = |LO.bottom - frame.top |
48
+ * minD = min(dL, dR, dT, dB); priority L > R > T > B on ties (mirrors native _eject).
49
+ *
50
+ * abs(x) is expressed as Fn('max', [x, Bin('-', Lit(0), x)]).
51
+ * The pick is a nested Cond tree:
52
+ * isHorizMin = (min(dL,dR) < min(dT,dB)) → pick left or right face
53
+ * within horiz: isLeft = (dL <= dR) → eject to left or right
54
+ * within vert: isTop = (dT <= dB) → eject to top or bottom
55
+ * Two set ops: one for frame.left (used when horiz wins), one for frame.top (vert wins).
56
+ *
57
+ * ## Actions that are NOT compilable (each throws)
58
+ *
59
+ * | Action | Why, and what to use instead |
60
+ * |---------------------|----------------------------------------------------------------|
61
+ * | LOG | no logging op exists; read TickData.objects[handle].frames |
62
+ * | RECOLOR | the engine owns no colour; use the v2 `recolor` hook (a tag) |
63
+ * | BLOCK/BOUNCE, no eject face | pass `reposition`, or use compileWallV2 |
64
+ * | anything unrecognised | a typo — it would be dropped at the wire |
65
+ *
66
+ * ## No-double-execution guarantee
67
+ *
68
+ * Structural: the op-program is the ONLY execution path. Every action either becomes an op
69
+ * here or throws here, so there is no second place a behaviour could also run from.
70
+ *
71
+ * ## Arrow legality
72
+ *
73
+ * lib/frqtl/objects/ → lib/shim/expr/ is a legal downward arrow.
74
+ * AstSchema is a static import.
75
+ */
76
+
77
+ import * as AstSchema from '../../../shim/expr/ast-schema.js';
78
+ import * as SlabFace from '../geometry/SlabFace.js';
79
+ import * as Tagger from './tagger.js';
80
+
81
+
82
+
83
+
84
+ // ── Trigger expr builder (G2b) ──────────────────────────────────────────────────
85
+
86
+ /**
87
+ * Build the trigger expression for an op-program (G2b).
88
+ *
89
+ * For overlap-only actions (ABSORB/COUNT/MARK/BLOCK/BOUNCE/RANDOM/MOMENTUM):
90
+ * trigger = Fn('intersects', [Lit(HANDLE_FRAME), Lit(HANDLE_SELF)])
91
+ *
92
+ * For WALL (directional eject, where frames may have slipped past the face):
93
+ * trigger = Cond(Fn('intersects',...), Lit(1), past_edge_cmp)
94
+ * where past_edge_cmp tests whether the frame has passed through the face
95
+ * this tick (replacing is_frame_past_wall in the engine).
96
+ *
97
+ * The past-edge test is derived from `reposition` (the wall exit direction):
98
+ * 'left' → frame_right < lo_left = Cmp('<', Ref(0,'right'), Ref(1,'left'))
99
+ * 'right' → frame_left > lo_right = Cmp('>', Ref(0,'left'), Ref(1,'right'))
100
+ * 'top' → frame_bottom < lo_top = Cmp('<', Ref(0,'bottom'),Ref(1,'top'))
101
+ * 'bottom' → frame_top > lo_bottom = Cmp('>', Ref(0,'top'), Ref(1,'bottom'))
102
+ * (no direction / 'closest') → intersects-only (no past-edge)
103
+ *
104
+ * Returns the trigger ExprAST.
105
+ *
106
+ * @param {string|null} reposition - Eject direction ('left'|'right'|'top'|'bottom'|'closest'|null)
107
+ * @param {boolean} withPastEdge - true for WALL (test past-edge too); false for all others
108
+ * @param {number} HF - HANDLE_FRAME (0)
109
+ * @param {number} HS - HANDLE_SELF (1)
110
+ * @param {Function} Lit
111
+ * @param {Function} Ref
112
+ * @param {Function} Cmp
113
+ * @param {Function} Cond
114
+ * @param {Function} Fn
115
+ * @returns {ExprAST}
116
+ */
117
+ function _buildTriggerExpr(reposition, withPastEdge, HF, HS, Lit, Ref, Cmp, Cond, Fn) {
118
+ var intersectsExpr = Fn('intersects', [Lit(HF), Lit(HS)]);
119
+ if (!withPastEdge) {
120
+ return intersectsExpr;
121
+ }
122
+ // WALL: trigger = intersects OR past_edge_cmp
123
+ // Expressed as: Cond(intersectsExpr, Lit(1), past_edge_cmp)
124
+ // This is 1 when overlapping, otherwise falls through to past_edge_cmp (1 or 0).
125
+ //
126
+ // Past-edge semantics: the wall EJECTS in the `reposition` direction, so the wall
127
+ // itself is on the OPPOSITE side. A frame that slips THROUGH the wall exits the
128
+ // box on the wall's exterior face. Mirror of G2 is_frame_past_wall() keyed on the
129
+ // LO name prefix (LEFT/RIGHT/TOP/BOTTOM → which exterior face to test):
130
+ //
131
+ // reposition='left' → wall is the RIGHT wall (ejects inward-left).
132
+ // Past: frame slipped right-of-wall → frame.left > wall.right
133
+ // reposition='right' → wall is the LEFT wall (ejects inward-right).
134
+ // Past: frame slipped left-of-wall → frame.right < wall.left
135
+ // reposition='top' → wall is the BOTTOM wall (ejects inward-up).
136
+ // Past: frame slipped below-wall → frame.top > wall.bottom
137
+ // reposition='bottom' → wall is the TOP wall (ejects inward-down).
138
+ // Past: frame slipped above-wall → frame.bottom < wall.top
139
+ var pastEdge;
140
+ if (reposition === 'left') {
141
+ // RIGHT wall — frame slipped to the right of the wall exterior
142
+ pastEdge = Cmp('>', Ref(HF, 'left'), Ref(HS, 'right'));
143
+ } else if (reposition === 'right') {
144
+ // LEFT wall — frame slipped to the left of the wall exterior
145
+ pastEdge = Cmp('<', Ref(HF, 'right'), Ref(HS, 'left'));
146
+ } else if (reposition === 'top') {
147
+ // BOTTOM wall — frame slipped below the wall exterior
148
+ pastEdge = Cmp('>', Ref(HF, 'top'), Ref(HS, 'bottom'));
149
+ } else if (reposition === 'bottom') {
150
+ // TOP wall — frame slipped above the wall exterior
151
+ pastEdge = Cmp('<', Ref(HF, 'bottom'), Ref(HS, 'top'));
152
+ } else {
153
+ // 'closest' or unknown: no directional past-edge test — intersects only.
154
+ return intersectsExpr;
155
+ }
156
+ // trigger = if intersects then 1 else pastEdge
157
+ return Cond(intersectsExpr, Lit(1), pastEdge);
158
+ }
159
+
160
+ /**
161
+ * Attach a program-level trigger to an op-program (G2b — program-level trigger design).
162
+ *
163
+ * Sets program.trigger = trigger so the Rust engine evaluates the trigger ONCE before
164
+ * running any ops. If trigger evaluates to 0 (false), ALL ops are skipped.
165
+ *
166
+ * This replaces the old per-op Cond wrapping approach, which caused a critical bug:
167
+ * when the eject op (op 1) ran and updated scope.left, the bounce op's (op 2) trigger
168
+ * re-evaluated Fn('intersects') using the post-eject position, returning false and
169
+ * silently skipping the velocity flip.
170
+ *
171
+ * The ops are NOT wrapped; they remain unconditional expressions. The trigger gates
172
+ * the entire program atomically.
173
+ *
174
+ * @param {{ on: string, ops: Array }} program — the program to annotate in place
175
+ * @param {ExprAST} trigger — the trigger ExprAST (from _buildTriggerExpr)
176
+ * @returns {{ on: string, ops: Array, trigger: ExprAST }} the annotated program
177
+ */
178
+ function _applyProgramTrigger(program, trigger) {
179
+ program.trigger = trigger;
180
+ return program;
181
+ }
182
+
183
+ // ── X/Y-only intersects trigger — RETIRED (FRQTL-MAINT, #61, 2026-07-01) ────────────
184
+ //
185
+ // A manual x/y-only overlap trigger (`_buildXYIntersectsTrigger`) used to exist here for
186
+ // thin end-cap walls (Tube's LEFT/RIGHT breech/muzzle), working around a real engine
187
+ // wire-contract gap: every LargeObject's z-slab was hardcoded to `[0, own-x-width]`
188
+ // (`egpt_wasm/src/lib.rs` `LargeObjectJsSpec::into_spec()`), so a thin end-cap's z-slab
189
+ // (`[0, wallThickness]`) never caught a z-drifted frame via the native 3D `Fn('intersects')`
190
+ // test. That gap is now CLOSED: the LO wire contract accepts an explicit 6-element
191
+ // `[left, top, z, width, height, depth]` rect (`rect_to_z_capable_rectangle`,
192
+ // `egpt_wasm/src/lib.rs`, landed commit `1c349ab`) — a wall built with a real z/d that
193
+ // spans the tube's z cross-section makes the native 3D `intersects` trigger correct again,
194
+ // so every wall (including end-caps and the new NEAR/FAR faces) now uses the SAME
195
+ // standard per-face-LO construction + trigger as TOP/BOTTOM (see Tube.js). The manual
196
+ // workaround is deleted, not kept alongside (DRY — superseded code is removed).
197
+
198
+ // ── Eject op builders (extracted from OvenBox.makeEjectProgram — shared authority) ─
199
+
200
+ /**
201
+ * Build the L3 eject op (reposition) for a named wall direction.
202
+ * For 'closest', returns TWO ops (for frame.left and frame.top) that implement
203
+ * the nearest-face pick as a nested Cond tree.
204
+ * Returns an array of AST ops, or null when direction is unknown.
205
+ *
206
+ * Eject formulas (bit-identical to native lo._eject, proven EP22-EP25):
207
+ * 'left' → set frame.left = self.left - frame.w - 1
208
+ * 'right' → set frame.left = self.right + 1
209
+ * 'top' → set frame.top = self.top - frame.h - 1
210
+ * 'bottom' → set frame.top = self.bottom + 1
211
+ *
212
+ * 'closest' nearest-face Cond tree:
213
+ * abs(x) = max(x, 0-x)
214
+ * dL = abs(LO.left - frame.left), dR = abs(LO.right - frame.left)
215
+ * dT = abs(LO.top - frame.top), dB = abs(LO.bottom - frame.top)
216
+ * isHoriz = min(dL,dR) < min(dT,dB) → horizontal face is closer than vertical
217
+ * within horiz: isLeft = dL <= dR → eject left or right
218
+ * within vert: isTop = dT <= dB → eject top or bottom
219
+ * Two set ops: frame.left (horiz wins), frame.top (vert wins).
220
+ * Priority matches native (L > R > T > B on ties): '<' prefers horiz, NOT '<='.
221
+ *
222
+ * @param {string} reposition - 'left'|'right'|'top'|'bottom'|'near'|'far'|'closest'
223
+ * @param {number} HANDLE_FRAME
224
+ * @param {number} HANDLE_SELF
225
+ * @param {Function} Lit
226
+ * @param {Function} Ref
227
+ * @param {Function} Bin
228
+ * @param {Function} Cmp
229
+ * @param {Function} Cond
230
+ * @param {Function} Fn
231
+ * @returns {Array<object>|null} Array of ops (1 for named dir, 2 for 'closest'), or null
232
+ */
233
+ function _buildEjectOps(reposition, HANDLE_FRAME, HANDLE_SELF, Lit, Ref, Bin, Cmp, Cond, Fn) {
234
+ if (reposition === 'left') {
235
+ return [{ set: HANDLE_FRAME + '.left',
236
+ to: Bin('-', Bin('-', Ref(HANDLE_SELF, 'left'), Ref(HANDLE_FRAME, 'w')), Lit(1)) }];
237
+ } else if (reposition === 'right') {
238
+ return [{ set: HANDLE_FRAME + '.left',
239
+ to: Bin('+', Ref(HANDLE_SELF, 'right'), Lit(1)) }];
240
+ } else if (reposition === 'top') {
241
+ return [{ set: HANDLE_FRAME + '.top',
242
+ to: Bin('-', Bin('-', Ref(HANDLE_SELF, 'top'), Ref(HANDLE_FRAME, 'h')), Lit(1)) }];
243
+ } else if (reposition === 'bottom') {
244
+ return [{ set: HANDLE_FRAME + '.top',
245
+ to: Bin('+', Ref(HANDLE_SELF, 'bottom'), Lit(1)) }];
246
+ } else if (reposition === 'near') {
247
+ // FRQTL-MAINT (2026-07-01, #61): the z NEAR|FAR named-direction case — unblocked
248
+ // by the landed 6-element rect ([left,top,z,w,h,d]) wire contract, which makes
249
+ // `self.near`/`self.far` (the LO's OWN z-slab) meaningful for the first time (was
250
+ // always the origin-pinned [0,w] before — see the RETIRED section above). Formula
251
+ // mirrors 'left'/'right'/'top'/'bottom' exactly, one axis over: push the frame just
252
+ // outside this wall's OWN near face (−z side) — used by a wall positioned at the
253
+ // FAR side of the interior, ejecting intruders back toward −z. Reuses the same
254
+ // shape as the 'closest3d' ejectNear3 formula below (self.near - frame.w/2 - 1).
255
+ return [{ set: HANDLE_FRAME + '.z',
256
+ to: Bin('-', Bin('-', Ref(HANDLE_SELF, 'near'), Bin('*', Ref(HANDLE_FRAME, 'w'), Lit(0.5))), Lit(1)) }];
257
+ } else if (reposition === 'far') {
258
+ // Mirror of 'near': push the frame just outside this wall's OWN far face (+z side)
259
+ // — used by a wall positioned at the NEAR side of the interior, ejecting intruders
260
+ // back toward +z. Reuses the 'closest3d' ejectFar3 formula shape.
261
+ return [{ set: HANDLE_FRAME + '.z',
262
+ to: Bin('+', Bin('+', Ref(HANDLE_SELF, 'far'), Bin('*', Ref(HANDLE_FRAME, 'w'), Lit(0.5))), Lit(1)) }];
263
+ } else if (reposition === 'closest') {
264
+ // Helper: abs(expr) = max(expr, 0 - expr)
265
+ function Abs(e) { return Fn('max', [e, Bin('-', Lit(0), e)]); }
266
+ // Distances: |LO.face - frame.corner|
267
+ // Each used twice → no side-effects so safe to duplicate in AST.
268
+ var dL = Abs(Bin('-', Ref(HANDLE_SELF, 'left'), Ref(HANDLE_FRAME, 'left')));
269
+ var dR = Abs(Bin('-', Ref(HANDLE_SELF, 'right'), Ref(HANDLE_FRAME, 'left')));
270
+ var dT = Abs(Bin('-', Ref(HANDLE_SELF, 'top'), Ref(HANDLE_FRAME, 'top')));
271
+ var dB = Abs(Bin('-', Ref(HANDLE_SELF, 'bottom'), Ref(HANDLE_FRAME, 'top')));
272
+ // Duplicate refs for the second copy (AST is immutable, sharing is safe but
273
+ // clarity-best-practice is separate literals for each occurrence).
274
+ var dL2 = Abs(Bin('-', Ref(HANDLE_SELF, 'left'), Ref(HANDLE_FRAME, 'left')));
275
+ var dR2 = Abs(Bin('-', Ref(HANDLE_SELF, 'right'), Ref(HANDLE_FRAME, 'left')));
276
+ var dT2 = Abs(Bin('-', Ref(HANDLE_SELF, 'top'), Ref(HANDLE_FRAME, 'top')));
277
+ var dB2 = Abs(Bin('-', Ref(HANDLE_SELF, 'bottom'), Ref(HANDLE_FRAME, 'top')));
278
+ var dL3 = Abs(Bin('-', Ref(HANDLE_SELF, 'left'), Ref(HANDLE_FRAME, 'left')));
279
+ var dR3 = Abs(Bin('-', Ref(HANDLE_SELF, 'right'), Ref(HANDLE_FRAME, 'left')));
280
+ var dT3 = Abs(Bin('-', Ref(HANDLE_SELF, 'top'), Ref(HANDLE_FRAME, 'top')));
281
+ var dB3 = Abs(Bin('-', Ref(HANDLE_SELF, 'bottom'), Ref(HANDLE_FRAME, 'top')));
282
+
283
+ // Eject positions
284
+ var ejectLeft = Bin('-', Bin('-', Ref(HANDLE_SELF, 'left'), Ref(HANDLE_FRAME, 'w')), Lit(1));
285
+ var ejectRight = Bin('+', Ref(HANDLE_SELF, 'right'), Lit(1));
286
+ var ejectTop = Bin('-', Bin('-', Ref(HANDLE_SELF, 'top'), Ref(HANDLE_FRAME, 'h')), Lit(1));
287
+ var ejectBottom = Bin('+', Ref(HANDLE_SELF, 'bottom'), Lit(1));
288
+
289
+ // isHoriz: min(dL,dR) < min(dT,dB) → horizontal face is strictly closer
290
+ // Priority: horiz < vert → L/R wins on tie with T/B (matches native if-else order L>R>T>B
291
+ // because native sets newLeft first (overrides newTop), then only changes newTop if
292
+ // neither L nor R won; using strict '<' gives horiz priority over vert on exact tie).
293
+ var minHoriz = Fn('min', [dL, dR]);
294
+ var minVert = Fn('min', [dT, dB]);
295
+ var isHoriz = Cmp('<', minHoriz, minVert);
296
+
297
+ // isLeft (given horiz): dL <= dR ↔ NOT dR < dL ↔ Cmp('<',dR,dL)==0
298
+ // Use: Cond(isHoriz, Cond(isLeft, ejectLeft, ejectRight), keep)
299
+ // isLeft = dL <= dR = NOT (dR < dL) = Cmp('<',dR2,dL2) gives 0 if dL<=dR
300
+ // We want isLeft=1 when dL<=dR, so invert: Cond(Cmp('<',dR2,dL2), ejectRight, ejectLeft)
301
+ // (if dR<dL → right is closer → ejectRight; else dL<=dR → left is closer → ejectLeft)
302
+ var horizEject = Cond(Cmp('<', dR2, dL2), ejectRight, ejectLeft);
303
+
304
+ // op1: set frame.left = if isHoriz → horizEject else → frame.left (no-op)
305
+ var leftOp = {
306
+ set: HANDLE_FRAME + '.left',
307
+ to: Cond(isHoriz, horizEject, Ref(HANDLE_FRAME, 'left'))
308
+ };
309
+
310
+ // isTop (given vert): dT <= dB → Cond(Cmp('<',dB3,dT3), ejectBottom, ejectTop)
311
+ var vertEject = Cond(Cmp('<', dB3, dT3), ejectBottom, ejectTop);
312
+
313
+ // op2: set frame.top = if isHoriz → frame.top (no-op) else → vertEject
314
+ // NOT isHoriz = Cond(isHoriz2, Lit(0), Lit(1)) = invert
315
+ // But we don't have logical NOT directly. Use Cond(isHoriz, keepTop, vertEject).
316
+ var minHoriz2 = Fn('min', [dL3, dR3]);
317
+ var minVert2 = Fn('min', [dT3, dB3]);
318
+ var isHoriz2 = Cmp('<', minHoriz2, minVert2);
319
+ var topOp = {
320
+ set: HANDLE_FRAME + '.top',
321
+ to: Cond(isHoriz2, Ref(HANDLE_FRAME, 'top'), vertEject)
322
+ };
323
+
324
+ return [leftOp, topOp];
325
+
326
+ } else if (reposition === 'closest3d') {
327
+ // 3D 'closest' — the 6-face generalization (adds near/far z faces). Used by 3D sugar
328
+ // (e.g. the Bullet) once the z substrate is exposed in the op scope bag (z/near/far).
329
+ // 2D-PARITY note: kept SEPARATE from 'closest' (2-face) so existing 2D scenes are
330
+ // byte-identical; 3D objects OPT IN via reposition:'closest3d'. Safe because the
331
+ // collision trigger (intersects) is now 3D — only frames inside the LO's z-slab fire,
332
+ // so a z-eject can never teleport a far-z frame.
333
+ //
334
+ // WASM-ONLY PATH (architect Gate-2): the canonical Rust/WASM engine exposes z/near/far
335
+ // in the op scope bag; the FROZEN JS reference (EGPTfrqtl.js evalExpr) is 2D-only —
336
+ // z/near/far read 0 there — so closest3d is exercised against WASM only. Conformance
337
+ // C19 (lib/frqtl/conformance/collide_scenarios.cjs — a WASM-only oracle) covers it; do NOT
338
+ // add closest3d to the JS-cross-checked suites (action_compiler_scenarios) without first
339
+ // mirroring z/near/far into the JS-ref scope bag (which the frozen-fixture rule forbids).
340
+ // NIT-2 (architect, non-blocking, next substantive edit): factor the four x/y eject-position
341
+ // expressions (ejectLeft/Right/Top/Bottom) shared with the 'closest' branch above into one
342
+ // helper; closest3d then adds only near/far + the 3-axis priority. Deferred to avoid
343
+ // touching the green 'closest' path at closure.
344
+ function Abs3(e) { return Fn('max', [e, Bin('-', Lit(0), e)]); }
345
+ // Distance factories (fresh AST per use; sharing is safe but fresh matches house style).
346
+ // Reference corner: frame.left/top/near vs the LO's six faces.
347
+ function dL() { return Abs3(Bin('-', Ref(HANDLE_SELF, 'left'), Ref(HANDLE_FRAME, 'left'))); }
348
+ function dR() { return Abs3(Bin('-', Ref(HANDLE_SELF, 'right'), Ref(HANDLE_FRAME, 'left'))); }
349
+ function dT() { return Abs3(Bin('-', Ref(HANDLE_SELF, 'top'), Ref(HANDLE_FRAME, 'top'))); }
350
+ function dB() { return Abs3(Bin('-', Ref(HANDLE_SELF, 'bottom'), Ref(HANDLE_FRAME, 'top'))); }
351
+ function dN() { return Abs3(Bin('-', Ref(HANDLE_SELF, 'near'), Ref(HANDLE_FRAME, 'near'))); }
352
+ function dF() { return Abs3(Bin('-', Ref(HANDLE_SELF, 'far'), Ref(HANDLE_FRAME, 'near'))); }
353
+ function minH() { return Fn('min', [dL(), dR()]); }
354
+ function minV() { return Fn('min', [dT(), dB()]); }
355
+ function minZ() { return Fn('min', [dN(), dF()]); }
356
+
357
+ // Eject targets. x/y write the EDGE (left/top — writable, adjusts center); z writes the
358
+ // CENTER (z is the writable center accessor), so the z formulas use w/2 (depth == w).
359
+ var ejectLeft3 = Bin('-', Bin('-', Ref(HANDLE_SELF, 'left'), Ref(HANDLE_FRAME, 'w')), Lit(1));
360
+ var ejectRight3 = Bin('+', Ref(HANDLE_SELF, 'right'), Lit(1));
361
+ var ejectTop3 = Bin('-', Bin('-', Ref(HANDLE_SELF, 'top'), Ref(HANDLE_FRAME, 'h')), Lit(1));
362
+ var ejectBottom3 = Bin('+', Ref(HANDLE_SELF, 'bottom'), Lit(1));
363
+ // near: place frame fully in front of the near face → z = LO.near - frame.w/2 - 1
364
+ var ejectNear3 = Bin('-', Bin('-', Ref(HANDLE_SELF, 'near'), Bin('*', Ref(HANDLE_FRAME, 'w'), Lit(0.5))), Lit(1));
365
+ // far: place frame fully beyond the far face → z = LO.far + frame.w/2 + 1
366
+ var ejectFar3 = Bin('+', Bin('+', Ref(HANDLE_SELF, 'far'), Bin('*', Ref(HANDLE_FRAME, 'w'), Lit(0.5))), Lit(1));
367
+
368
+ // Within-axis pick (closer face wins; '<' only, so test the strict side).
369
+ var horizEject3 = Cond(Cmp('<', dR(), dL()), ejectRight3, ejectLeft3);
370
+ var vertEject3 = Cond(Cmp('<', dB(), dT()), ejectBottom3, ejectTop3);
371
+ var zEject3 = Cond(Cmp('<', dF(), dN()), ejectFar3, ejectNear3);
372
+
373
+ // Axis selection, priority x > y > z (ties to the earlier axis). No '<=' op, so encode
374
+ // "minH wins ties" as: x loses ONLY if minV or minZ is STRICTLY smaller than minH.
375
+ // isH = NOT(minV < minH) AND NOT(minZ < minH)
376
+ // isV = NOT isH AND NOT(minZ < minV)
377
+ // isZ = NOT isH AND (minZ < minV)
378
+ function isH() {
379
+ return Cond(Cmp('<', minV(), minH()), Lit(0),
380
+ Cond(Cmp('<', minZ(), minH()), Lit(0), Lit(1)));
381
+ }
382
+ // "V wins, given not H" = NOT(minZ < minV)
383
+ function vGivenNotH() { return Cond(Cmp('<', minZ(), minV()), Lit(0), Lit(1)); }
384
+
385
+ var leftOp3 = {
386
+ set: HANDLE_FRAME + '.left',
387
+ to: Cond(isH(), horizEject3, Ref(HANDLE_FRAME, 'left'))
388
+ };
389
+ var topOp3 = {
390
+ set: HANDLE_FRAME + '.top',
391
+ to: Cond(Cond(isH(), Lit(0), vGivenNotH()), vertEject3, Ref(HANDLE_FRAME, 'top'))
392
+ };
393
+ var zOp3 = {
394
+ set: HANDLE_FRAME + '.z',
395
+ to: Cond(Cond(isH(), Lit(0), Cond(vGivenNotH(), Lit(0), Lit(1))), zEject3, Ref(HANDLE_FRAME, 'z'))
396
+ };
397
+ return [leftOp3, topOp3, zOp3];
398
+ }
399
+ return null; // unknown direction
400
+ }
401
+
402
+ /**
403
+ * Build the L4 velocity-flip op for a named wall direction.
404
+ * Returns a single AST op object, or null when wallName has no directional prefix.
405
+ *
406
+ * Velocity-flip formula (bit-identical to native BOUNCE):
407
+ * LEFT|RIGHT → set frame.vx = 0 - frame.vx
408
+ * TOP|BOTTOM → set frame.vy = 0 - frame.vy
409
+ * NEAR|FAR → set frame.vz = 0 - frame.vz (FRQTL-MAINT 2026-07-01, #61 — unblocked
410
+ * by the landed z/depth wire contract; mirrors LEFT/RIGHT/TOP/BOTTOM
411
+ * one axis over)
412
+ *
413
+ * @param {string} loName - LO name string (e.g. 'TOP', 'LEFT_T', 'wall1')
414
+ * @param {number} HANDLE_FRAME
415
+ * @param {Function} Lit
416
+ * @param {Function} Ref
417
+ * @param {Function} Bin
418
+ * @returns {object|null}
419
+ */
420
+ function _buildVelocityFlipOp(loName, HANDLE_FRAME, Lit, Ref, Bin) {
421
+ var wallDir = loName ? loName.split('_')[0] : '';
422
+ if (wallDir === 'LEFT' || wallDir === 'RIGHT') {
423
+ return { set: HANDLE_FRAME + '.vx',
424
+ to: Bin('-', Lit(0), Ref(HANDLE_FRAME, 'vx')) };
425
+ } else if (wallDir === 'TOP' || wallDir === 'BOTTOM') {
426
+ return { set: HANDLE_FRAME + '.vy',
427
+ to: Bin('-', Lit(0), Ref(HANDLE_FRAME, 'vy')) };
428
+ } else if (wallDir === 'NEAR' || wallDir === 'FAR') {
429
+ return { set: HANDLE_FRAME + '.vz',
430
+ to: Bin('-', Lit(0), Ref(HANDLE_FRAME, 'vz')) };
431
+ }
432
+ return null;
433
+ }
434
+
435
+ // ── Z-containment workaround — RETIRED (FRQTL-MAINT, #61, 2026-07-01) ───────────────
436
+ //
437
+ // `_buildZContainmentOps` used to build a manual combined vz-reflect/z-clamp program on
438
+ // ONE full-footprint LO (literal JS-computed z bounds, never `self.near`/`self.far`),
439
+ // working around the same origin-pinned z-slab gap described above (every LO's z was
440
+ // hardcoded to 0.0, depth to its own x-width — `egpt_wasm/src/lib.rs`
441
+ // `LargeObjectJsSpec::into_spec()`, pre-commit `1c349ab`). That gap is closed: the wire
442
+ // contract now accepts an explicit 6-element `[left, top, z, width, height, depth]` rect
443
+ // (`rect_to_z_capable_rectangle`), so a real NEAR wall LO and a real FAR wall LO — each
444
+ // positioned at their own z-slab exactly like TOP/BOTTOM are positioned in y — now work
445
+ // through the SAME standard per-face-LO construction (`new LargeObject(...)` auto-compile
446
+ // via `collisionType`+`reposition`) as every other face, using the `'near'`/`'far'` cases
447
+ // added to `_buildEjectOps`/`_buildVelocityFlipOp` above. Tube.js builds these two extra
448
+ // faces directly; the manual workaround is deleted, not kept alongside (DRY).
449
+
450
+ // ── Collision LEXICON v2 builders (Collision Lab, CEO lexicon v2) ────────────────
451
+ //
452
+ // These are ADDITIVE, pure op-program builders for the v2 wall vocabulary
453
+ // (behavior nothing|block|bounce|absorb|destroy; bounceDirection reflect|random;
454
+ // hooks mark|count). They compose the SAME closed NODE_KINDS the rest of this
455
+ // file uses — ZERO engine change (theorist v2 advisory, all RUN-verified pkg-node).
456
+ // The existing compileActions() path is UNTOUCHED (byte-identity preserved); v2
457
+ // callers (Wall.js via resolveWallSpec) go through compileWallV2() below.
458
+ //
459
+ // AST logic helpers — the closed vocab has '<','>','==' (no '<=','>=') and no
460
+ // boolean AND/OR, so these are the canonical encodings used throughout:
461
+ // LE(a,b) = a<=b = NOT(a>b) = Cond(Cmp('>',a,b), 0, 1)
462
+ // GE(a,b) = a>=b = NOT(a<b) = Cond(Cmp('<',a,b), 0, 1)
463
+ // OR(x,y) (x,y ∈ {0,1}) = Cond(x, 1, y)
464
+ // AND(x,y) = Cond(x, y, 0)
465
+ // NOT(x) = Cond(x, 0, 1)
466
+
467
+ /**
468
+ * B2 — incidence-aware single-LO Reflect (CEO P1, RATIFIED). Replaces the W1
469
+ * name-prefix flip (`_buildVelocityFlipOp` keyed on the LO NAME) with a Cond tree
470
+ * over the frame's PREVIOUS position: the axis whose move carried the frame INTO
471
+ * the wall this tick is the one whose velocity is inverted — the physical
472
+ * axis-of-incidence, on ONE monolithic wall (no per-face LO decomposition needed).
473
+ *
474
+ * prev_x/prev_y are CENTER coords already in the frame scope bag (universe.rs:2262,
475
+ * read today by MOMENTUM_BASED at :670); w/h are the frame extents; left/right/top/
476
+ * bottom are the frame's CURRENT edges; self.left/right/top/bottom are the wall faces.
477
+ *
478
+ * wasOutsideX = the frame's x-extent did NOT overlap the wall's x-span LAST tick
479
+ * (fully left: prev_x + w/2 <= self.left ; fully right: prev_x - w/2 >= self.right).
480
+ * Corner tie-break (both axes newly-overlap in one ±2-stack tick) = DEEPER-PENETRATION
481
+ * axis (larger current overlap), BOTH on exact tie:
482
+ * penX = min(frame.right, self.right) - max(frame.left, self.left)
483
+ * penY = min(frame.bottom, self.bottom) - max(frame.top, self.top)
484
+ * flipX ⇔ wasOutsideX AND (NOT wasOutsideY OR penX >= penY)
485
+ * flipY ⇔ wasOutsideY AND (NOT wasOutsideX OR penY >= penX) (>= gives both-on-tie)
486
+ *
487
+ * Composes with an eject (the caller prepends `_buildEjectOps('closest')`) — Reflect
488
+ * ALWAYS fuses with eject (a flip-without-reposition re-triggers next tick = in-wall
489
+ * jitter; round-1 §3). Returns ONLY the two flip ops (eject is the caller's).
490
+ *
491
+ * @returns {Array<object>} [flipVxOp, flipVyOp]
492
+ */
493
+ function _buildIncidenceReflectOps(HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn) {
494
+ function NOT(x) { return Cond(x, Lit(0), Lit(1)); }
495
+ function OR(x, y) { return Cond(x, Lit(1), y); }
496
+ function AND(x, y){ return Cond(x, y, Lit(0)); }
497
+ function LE(a, b) { return Cond(Cmp('>', a, b), Lit(0), Lit(1)); }
498
+ function GE(a, b) { return Cond(Cmp('<', a, b), Lit(0), Lit(1)); }
499
+ function halfW() { return Bin('*', Ref(HF, 'w'), Lit(0.5)); }
500
+ function halfH() { return Bin('*', Ref(HF, 'h'), Lit(0.5)); }
501
+ // prev extents (center ± half): fresh AST per use (immutable, no side effects).
502
+ function prevRight() { return Bin('+', Ref(HF, 'prev_x'), halfW()); }
503
+ function prevLeft() { return Bin('-', Ref(HF, 'prev_x'), halfW()); }
504
+ function prevBottom(){ return Bin('+', Ref(HF, 'prev_y'), halfH()); }
505
+ function prevTop() { return Bin('-', Ref(HF, 'prev_y'), halfH()); }
506
+ function wasOutsideX() { return OR(LE(prevRight(), Ref(HS, 'left')), GE(prevLeft(), Ref(HS, 'right'))); }
507
+ function wasOutsideY() { return OR(LE(prevBottom(), Ref(HS, 'top')), GE(prevTop(), Ref(HS, 'bottom'))); }
508
+ // current-tick overlap depth per axis (deeper = larger).
509
+ function penX() {
510
+ return Bin('-', Fn('min', [Ref(HF, 'right'), Ref(HS, 'right')]),
511
+ Fn('max', [Ref(HF, 'left'), Ref(HS, 'left')]));
512
+ }
513
+ function penY() {
514
+ return Bin('-', Fn('min', [Ref(HF, 'bottom'), Ref(HS, 'bottom')]),
515
+ Fn('max', [Ref(HF, 'top'), Ref(HS, 'top')]));
516
+ }
517
+ var flipXcond = AND(wasOutsideX(), OR(NOT(wasOutsideY()), GE(penX(), penY())));
518
+ var flipYcond = AND(wasOutsideY(), OR(NOT(wasOutsideX()), GE(penY(), penX())));
519
+ return [
520
+ { set: HF + '.vx', to: Cond(flipXcond, Bin('-', Lit(0), Ref(HF, 'vx')), Ref(HF, 'vx')) },
521
+ { set: HF + '.vy', to: Cond(flipYcond, Bin('-', Lit(0), Ref(HF, 'vy')), Ref(HF, 'vy')) }
522
+ ];
523
+ }
524
+
525
+ /**
526
+ * Z1 — 3D incidence-aware Reflect (CEO UX-pass 2026-07-24; theorist Q2 PROVEN as the
527
+ * op-data generalization on top of the landed prev_z scope-bag key — C26). The exact
528
+ * 3-axis lift of `_buildIncidenceReflectOps`: adds the z-axis (near/far faces), flipping
529
+ * whichever of the SIX faces' axis carried the frame INTO the wall's z-slab this tick.
530
+ *
531
+ * prev_z is a CENTER coord in the frame scope bag (universe.rs, WASM-only — the frozen
532
+ * JS fixture has NO prev_* family, so this whole builder is WASM-only, declared alongside
533
+ * the closest3d/C19 precedent). Frames are cubes ⇒ the z half-extent is w/2 (depth==w,
534
+ * matching the `near = z − w/2` derivation), exactly as prev_x uses w/2 in 2D.
535
+ *
536
+ * wasOutsideZ = the frame's z-extent did NOT overlap the wall's z-slab LAST tick
537
+ * (fully near: prev_z + w/2 <= self.near ; fully far: prev_z − w/2 >= self.far).
538
+ * penZ = min(frame.far, self.far) − max(frame.near, self.near) (current overlap depth)
539
+ *
540
+ * 3-AXIS deeper-penetration tie-break (generalizes the ratified 2-axis rule): flip axis A
541
+ * iff A was outside AND A's penetration ≥ EVERY OTHER axis that was ALSO outside (an axis
542
+ * that was already inside does not compete). `>=` gives both-on-exact-tie, exactly as the
543
+ * 2D rule does. Composes with `_buildEjectOps('closest3d')` prepended by the caller (a
544
+ * z-flip without a z-eject re-triggers next tick = in-wall jitter, same as the 2D rule).
545
+ *
546
+ * @returns {Array<object>} [flipVxOp, flipVyOp, flipVzOp]
547
+ */
548
+ function _buildIncidenceReflectOps3D(HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn) {
549
+ function NOT(x) { return Cond(x, Lit(0), Lit(1)); }
550
+ function OR(x, y) { return Cond(x, Lit(1), y); }
551
+ function AND(x, y){ return Cond(x, y, Lit(0)); }
552
+ function LE(a, b) { return Cond(Cmp('>', a, b), Lit(0), Lit(1)); }
553
+ function GE(a, b) { return Cond(Cmp('<', a, b), Lit(0), Lit(1)); }
554
+ function halfW() { return Bin('*', Ref(HF, 'w'), Lit(0.5)); }
555
+ function halfH() { return Bin('*', Ref(HF, 'h'), Lit(0.5)); }
556
+ // z half-extent: frames are cubes, depth == w (the near=z−w/2 derivation), so w/2.
557
+ function halfD() { return Bin('*', Ref(HF, 'w'), Lit(0.5)); }
558
+ function prevRight() { return Bin('+', Ref(HF, 'prev_x'), halfW()); }
559
+ function prevLeft() { return Bin('-', Ref(HF, 'prev_x'), halfW()); }
560
+ function prevBottom(){ return Bin('+', Ref(HF, 'prev_y'), halfH()); }
561
+ function prevTop() { return Bin('-', Ref(HF, 'prev_y'), halfH()); }
562
+ function prevFar() { return Bin('+', Ref(HF, 'prev_z'), halfD()); }
563
+ function prevNear() { return Bin('-', Ref(HF, 'prev_z'), halfD()); }
564
+ function wasOutsideX() { return OR(LE(prevRight(), Ref(HS, 'left')), GE(prevLeft(), Ref(HS, 'right'))); }
565
+ function wasOutsideY() { return OR(LE(prevBottom(), Ref(HS, 'top')), GE(prevTop(), Ref(HS, 'bottom'))); }
566
+ function wasOutsideZ() { return OR(LE(prevFar(), Ref(HS, 'near')), GE(prevNear(), Ref(HS, 'far'))); }
567
+ function penX() {
568
+ return Bin('-', Fn('min', [Ref(HF, 'right'), Ref(HS, 'right')]),
569
+ Fn('max', [Ref(HF, 'left'), Ref(HS, 'left')]));
570
+ }
571
+ function penY() {
572
+ return Bin('-', Fn('min', [Ref(HF, 'bottom'), Ref(HS, 'bottom')]),
573
+ Fn('max', [Ref(HF, 'top'), Ref(HS, 'top')]));
574
+ }
575
+ function penZ() {
576
+ return Bin('-', Fn('min', [Ref(HF, 'far'), Ref(HS, 'far')]),
577
+ Fn('max', [Ref(HF, 'near'), Ref(HS, 'near')]));
578
+ }
579
+ // flipA ⇔ wasOutsideA AND (NOT wasOutsideB OR penA>=penB) AND (NOT wasOutsideC OR penA>=penC)
580
+ var flipXcond = AND(wasOutsideX(),
581
+ AND(OR(NOT(wasOutsideY()), GE(penX(), penY())), OR(NOT(wasOutsideZ()), GE(penX(), penZ()))));
582
+ var flipYcond = AND(wasOutsideY(),
583
+ AND(OR(NOT(wasOutsideX()), GE(penY(), penX())), OR(NOT(wasOutsideZ()), GE(penY(), penZ()))));
584
+ var flipZcond = AND(wasOutsideZ(),
585
+ AND(OR(NOT(wasOutsideX()), GE(penZ(), penX())), OR(NOT(wasOutsideY()), GE(penZ(), penY()))));
586
+ return [
587
+ { set: HF + '.vx', to: Cond(flipXcond, Bin('-', Lit(0), Ref(HF, 'vx')), Ref(HF, 'vx')) },
588
+ { set: HF + '.vy', to: Cond(flipYcond, Bin('-', Lit(0), Ref(HF, 'vy')), Ref(HF, 'vy')) },
589
+ { set: HF + '.vz', to: Cond(flipZcond, Bin('-', Lit(0), Ref(HF, 'vz')), Ref(HF, 'vz')) }
590
+ ];
591
+ }
592
+
593
+ /**
594
+ * R1 — ARBITRARY-ANGLE mirror reflection (CEO UX-pass ruling, theorist P3b PROVEN
595
+ * EXACT). A mirror is a REFLECTION of the velocity about a mirror LINE at angle α
596
+ * (the mirror SURFACE angle from horizontal, = the wall's `orientationDeg`):
597
+ *
598
+ * R(α) = [ cos2α sin2α ] reflected(+x) = (cos2α, sin2α)
599
+ * [ sin2α −cos2α ] ⇒ slope = tan(2α) (standard optics: a mirror
600
+ * surface at α deflects the beam by 2α).
601
+ *
602
+ * This is incidence-INDEPENDENT (a fixed linear map — physically correct for a
603
+ * genuine tilted mirror plane, so NO Cond-tree on the velocity part). Trig is legal
604
+ * HOST-side at compile time (the SAME pattern the constrained-cone endpoints already
605
+ * bake). Two-op sequential form (op2 reads the NEW vx written by op1, exploiting the
606
+ * write-propagation into the scope bag — theorist v2 P3):
607
+ * op1: vx' = c2·vx + s2·vy (reads both OLD values)
608
+ * op2: vy' = (s2/c2)·vx' − (1/c2)·vy_old ≡ s2·vx_old − c2·vy_old (exact)
609
+ * Requires c2 = cos2α ≠ 0 (the α ≡ 45° mod 90° singularity routes to the exact swap
610
+ * builder below). @returns {Array<object>} [vxOp, vyOp]
611
+ */
612
+ function _buildAngledReflectOps(HF, alphaDeg, Lit, Ref, Bin) {
613
+ var a = alphaDeg * Math.PI / 180;
614
+ var c2 = Math.cos(2 * a), s2 = Math.sin(2 * a);
615
+ return [
616
+ { set: HF + '.vx', to: Bin('+', Bin('*', Lit(c2), Ref(HF, 'vx')),
617
+ Bin('*', Lit(s2), Ref(HF, 'vy'))) },
618
+ { set: HF + '.vy', to: Bin('-', Bin('*', Lit(s2 / c2), Ref(HF, 'vx')),
619
+ Bin('*', Lit(1 / c2), Ref(HF, 'vy'))) }
620
+ ];
621
+ }
622
+
623
+ /**
624
+ * R1 — the α ≡ 45° mod 90° diagonal mirror, EXACT (theorist P1/P2 PROVEN). At these
625
+ * orientations cos2α = 0, so R(α) = [0, s2; s2, 0] with s2 = ±1 — the 2-op form above
626
+ * divides by zero. The exact realization is the 3-op add/subtract SWAP (a naive
627
+ * `vx=vy; vy=vx` is WRONG — the second op reads the already-overwritten vx; theorist
628
+ * v2 P3), then a negate for the s2 = −1 branch:
629
+ * α ≡ 45° mod 180° (s2=+1): pure swap vx' = vy, vy' = vx (+x → +y)
630
+ * α ≡ 135° mod 180° (s2=−1): swap + negate vx' = −vy, vy' = −vx (+x → −y)
631
+ * @returns {Array<object>} [.. 3 swap ops (+2 negate ops when s2<0) ..]
632
+ */
633
+ function _buildSwapReflectOps(HF, alphaDeg, Lit, Ref, Bin) {
634
+ var s2 = Math.round(Math.sin(2 * alphaDeg * Math.PI / 180)); // +1 or −1
635
+ var ops = [
636
+ { set: HF + '.vx', to: Bin('+', Ref(HF, 'vx'), Ref(HF, 'vy')) }, // vx ← vx+vy
637
+ { set: HF + '.vy', to: Bin('-', Ref(HF, 'vx'), Ref(HF, 'vy')) }, // vy ← (vx+vy)−vy = old vx
638
+ { set: HF + '.vx', to: Bin('-', Ref(HF, 'vx'), Ref(HF, 'vy')) } // vx ← (vx+vy)−old vx = old vy
639
+ ];
640
+ if (s2 < 0) {
641
+ ops.push({ set: HF + '.vx', to: Bin('-', Lit(0), Ref(HF, 'vx')) });
642
+ ops.push({ set: HF + '.vy', to: Bin('-', Lit(0), Ref(HF, 'vy')) });
643
+ }
644
+ return ops;
645
+ }
646
+
647
+ /**
648
+ * R1/Z1 — build the ORIENTATION-AWARE reflection ops for a mirror at `orientationDeg`.
649
+ * The ONE dispatch authority (compileWallV2's reflect branch calls this):
650
+ * • orientation ≡ 0 mod 90 (0/90/180/270) → the proven axis-aligned incidence-flip
651
+ * tree (a BOX reflector handling BOTH faces). Orientation 0 is BYTE-IDENTICAL to
652
+ * the pre-rotation program (SU1) — the incidence tree ignores orientation entirely.
653
+ * When `is3D` (a wall with a real z-slab), the 3-axis lift (`_buildIncidenceReflectOps3D`)
654
+ * handles the near/far faces too — 2D≡3D, the same law with a third axis (WASM-only,
655
+ * rides prev_z / C26).
656
+ * • orientation ≡ 45 mod 90 (45/135/225/315) → the exact swap / swap+negate ops.
657
+ * • any other α → the 2-op host-baked-trig fixed-plane reflection.
658
+ *
659
+ * 3D ORIENTATION SCOPE (CEO ruling — keep to what the advisory PROVED): a non-cardinal
660
+ * `orientationDeg` is orientation about the Z-AXIS (rotation in the xy-plane) — the 2D
661
+ * swap/angled law LIFTED to a slab, with vz passing straight through (the beam's z-component
662
+ * is untouched). This is the proven, exact case. FULL 3D orientation (a SECOND tilt angle —
663
+ * e.g. an (x,z)-plane periscope that swaps vx↔vz) is a FLAGGED FOLLOW-UP, not implemented
664
+ * here (it needs a second angle param + the (x,z)/(y,z) swap patterns; no math is invented).
665
+ * @returns {Array<object>}
666
+ */
667
+ function _buildReflectOps(orientationDeg, HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn, is3D) {
668
+ var o = (typeof orientationDeg === 'number' && isFinite(orientationDeg)) ? orientationDeg : 0;
669
+ var onorm = ((o % 360) + 360) % 360;
670
+ var EPS = 1e-9;
671
+ var mod90 = onorm % 90;
672
+ // DRY — the cardinal gate AND the drawn-face reflection base come from the ONE
673
+ // SlabFace authority (circulation §1). SlabFace normalizes orientationDeg the SAME way
674
+ // (`((o%360)+360)%360`) and returns reflectBaseDeg = (90 + onorm) % 360, so this repoint
675
+ // is BYTE-IDENTICAL to the previous inline `alphaScreen = (90 + onorm) % 360` + the
676
+ // inline cardinal check. (w/h are not passed here — the reflection base does not depend
677
+ // on slab aspect; the OBB region is built separately from SlabFace in Wall.compileWallV2.)
678
+ var slab = SlabFace.resolveSlabFace({ orientationDeg: onorm });
679
+ if (slab.isCardinal) {
680
+ // 0/90/180/270 → axis-aligned box: the proven incidence tree (byte-identical at 0).
681
+ // A 3D wall reflects off all six faces (the z-axis lift); a 2D wall stays 2-axis.
682
+ // ORIENTATION-INDEPENDENT → the drawn-face base below does NOT touch this case
683
+ // (bench + cardinal-reflect byte-identity preserved).
684
+ return is3D
685
+ ? _buildIncidenceReflectOps3D(HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn)
686
+ : _buildIncidenceReflectOps(HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn);
687
+ }
688
+ // DRAWN-FACE REFLECTION (CEO 2026-07-25, Living Log 3d510a0a — supersedes the FIX-3
689
+ // screenα=−orientationDeg guess). The mirror's reflecting surface is the LONG face of the
690
+ // slab the renderer actually draws. The Wall mirror is a TALL slab (h > w): its long face
691
+ // is VERTICAL at orientation 0, so a +x beam bounces straight back (−x) — EXACTLY what the
692
+ // cardinal incidence tree already does at orientation 0. p5 draws that slab rotated by
693
+ // +orientationDeg (rect(-w/2,-h/2,w,h) with h>w; local y-axis long face), so the drawn face
694
+ // LINE sits at (90 + orientationDeg) in the engine's (y-DOWN) velocity coordinates. The
695
+ // exact reflection about the drawn face is therefore R(90 + orientationDeg) — reflect about
696
+ // the line the user SEES and calibrates against.
697
+ //
698
+ // Why the −orientationDeg guess was wrong (proven, scratchpad/reflect_matrix.cjs): 90+o and
699
+ // −o are congruent mod 180 ONLY at o ≡ 45 (mod 90), so R(−o) coincided with the drawn face
700
+ // at 45/135/225/315 (and, via the separate incidence tree, at cardinals) but reflected a +x
701
+ // beam to the OPPOSITE horizontal side at every OTHER angle (30/37/60/120/150…). That off-
702
+ // face deflection at non-45 tilt is the "reflection isn't correct all the time" the CEO saw
703
+ // when ROTATING the mirror. This change is BYTE-IDENTICAL at cardinals (untouched incidence
704
+ // tree) and at 45/135/225/315 (90+o ≡ −o mod 180 ⇒ same swap ops), and corrects every
705
+ // arbitrary angle for the tall mirror. NOTE (flagged, not fixed here): a WIDE mirror slab
706
+ // (w > h, e.g. 1D mirrorB) has a HORIZONTAL long face (base 0, not 90); an aspect-blind law
707
+ // cannot honor both bases — a wide mirror at a non-45 angle needs the aspect passed in, or
708
+ // the experiment retuned to a tall slab. See the deliverable's residual section.
709
+ var alphaScreen = slab.reflectBaseDeg; // (90 + onorm) % 360 — from the ONE SlabFace authority.
710
+ if (Math.abs(mod90 - 45) < EPS) {
711
+ // 45/135/225/315 → exact swap (cos2α = 0 singularity of the 2-op form). For a 3D
712
+ // wall this is z-axis orientation — an xy-plane swap; vz passes through unchanged.
713
+ return _buildSwapReflectOps(HF, alphaScreen, Lit, Ref, Bin);
714
+ }
715
+ // arbitrary α → fixed tilted-plane reflection, exact velocity map (xy-plane; z-axis
716
+ // orientation for a 3D wall — vz untouched).
717
+ return _buildAngledReflectOps(HF, alphaScreen, Lit, Ref, Bin);
718
+ }
719
+
720
+ /**
721
+ * R1-GUARD — the ONE-SIDED FACE-CROSSING GUARD for a TILTED reflect mirror (theorist
722
+ * red-team §2, MEASURED single-reflection optics; CEO 2026-07-25 drawn-face ruling).
723
+ *
724
+ * ## Why this exists (the tilted-reflect LEAK-THROUGH the AABB trigger caused)
725
+ *
726
+ * The cardinal incidence tree (`_buildIncidenceReflectOps`) is GUARDED by a `wasOutside`
727
+ * prev-coord Cond, so it flips a leaf's velocity exactly ONCE per AABB entry. The tilted
728
+ * swap/angled maps (`_buildSwapReflectOps`/`_buildAngledReflectOps`) had NO such guard: the
729
+ * program trigger was plain AABB `intersects`, so the fixed-plane reflection — an INVOLUTION
730
+ * R(α)∘R(α)=identity — re-fired on EVERY overlap tick. The velocity OSCILLATED while the leaf
731
+ * sat in the padded collision zone; when its corner crossed the wall midline the `'closest'`
732
+ * eject flipped to the FAR face and pushed it THROUGH. The leak scaled with leaf size (bench
733
+ * Dim 2/3) and worsened at 135° / thin slabs (isolation report: 78/3600 at Dim 2, 135°).
734
+ *
735
+ * ## The guard (fires the reflect ONCE per genuine SURFACE crossing)
736
+ *
737
+ * The mirror's reflecting surfaces are the two long FACES of the drawn slab — the lines at
738
+ * signed normal distance ±hw (half the slab thickness) from the LO center `C=(cx,cy)` along
739
+ * the unit face normal `n`, BOTH taken VERBATIM from the ONE SlabFace authority
740
+ * (`resolveSlabFace(...).obb.normal` / `.obb.hw` — the SAME geometry the reflect law + the
741
+ * engine OBB region share; NEVER a second inline cos/sin). The signed side value is
742
+ *
743
+ * s(P) = n · (P − C) // signed normal distance from the slab mid-line
744
+ *
745
+ * The program fires iff (AABB broad-phase) AND the leaf crossed a SURFACE this tick while
746
+ * APPROACHING it:
747
+ * front: s(prev) < −hw AND s(now) > −hw AND v·n > 0
748
+ * back : s(prev) > +hw AND s(now) < +hw AND v·n < 0
749
+ * Firing at the SURFACE (not the mid-line) means a beam reflects the moment it touches the
750
+ * face the user SEES — and, fused with the same-tick face-normal exit eject below, a
751
+ * reflected leaf is placed back OUTSIDE the slab in the SAME tick, so no post-tick snapshot
752
+ * ever renders a reflected particle INSIDE the slab (CEO visual ruling, 2026-07-25: reflect
753
+ * must reposition outside the collided edge in the same tick — particles seen rendering
754
+ * inside the LO then walking through were the bug). The approach term (v·n sign) is what
755
+ * kills the rattle the theorist MEASURED for a naive two-sided crossing test: after the
756
+ * reflection v·n flips and the eject holds the leaf outside its incident surface, so neither
757
+ * jitter re-crossings nor the reflected step satisfy the guard again. Handling BOTH surfaces
758
+ * makes the wall a scene-agnostic two-faced mirror (a generic Wall never knows the beam's
759
+ * approach side); a ±2-stacked jump that clears BOTH surfaces in one tick still satisfies
760
+ * the entry condition (s(now) is past −hw when it is past +hw too) and is caught. n and hw
761
+ * are pure functions of the wall spec, so a live re-aim re-bakes this via the setV2Prop
762
+ * recompile. Zero RNG draws.
763
+ *
764
+ * ## Z-FACES extension (3D tilted slab — opt-in, `withZFaces`)
765
+ *
766
+ * A 3D tilted slab (z-axis orientationDeg = rotation in the xy-plane; the z-slab is
767
+ * UNROTATED) has two more reflecting surfaces: its near/far z-faces — axis-aligned planes
768
+ * at `self.near`/`self.far`, exactly the planes the cardinal incidence-3D tree already
769
+ * bounces (C27). The xy-only guard let a leaf entering THROUGH a z-face (xy already inside
770
+ * the slab footprint, so no xy surface crossing ever happens) march straight through the
771
+ * mirror with zero response — measured pkg-node: 135°/Dim-2 z-entry beam, velocity never
772
+ * changed, 140 in-slab render snapshots, maxZ 198 past a far face at 60 (the 3D leak class).
773
+ * With `withZFaces` the trigger ALSO fires on a genuine z-PLANE crossing while approaching:
774
+ * zFront: prev_z < near AND z >= near AND vz > 0
775
+ * zBack : prev_z > far AND z <= far AND vz < 0
776
+ * (center-coord form, the exact z-lift of front/back above; `near`/`far` are the LIVE
777
+ * engine-recentered slab z-band — the broad-phase superset rect widens only x/y, so these
778
+ * self refs stay the TRUE z-faces). The NOW side is INCLUSIVE (>= via the canonical
779
+ * NOT(<) Cond encoding) because z steps are INTEGER pixels and the z-faces sit on integer
780
+ * planes — a 1px step lands EXACTLY ON the plane (prev=near−1 → now=near), so a strict
781
+ * two-sided test never sees the crossing (measured: the z-entry beam marched through with
782
+ * the strict form). Inclusive-now still fires exactly once: the next tick's prev is ON the
783
+ * plane, failing the strict prev-side test. Omitted/falsy ⇒ byte-identical 2D trigger (SU1).
784
+ * `withZFaces` ALSO relaxes the xy approach terms to inclusive (v·n >= 0 / <= 0) so a
785
+ * zero-xy-momentum leaf wander-crossing the drawn surface is positionally excluded (see
786
+ * the inline comment at the approach terms — no rattle risk, 2D byte-identical).
787
+ *
788
+ * @param {{x:number,y:number}} n unit DRAWN-face normal (SlabFace obb.normal — baked Lit).
789
+ * @param {number} hw TRUE slab half-thickness (SlabFace obb.hw); 0 when the slab width is
790
+ * unknown (degenerates to the mid-line crossing form — still leak-free, weaker visually).
791
+ * @param {boolean} [withZFaces] opt-in z-face crossing branches (3D tilted slab). Default
792
+ * omitted → byte-identical 2D trigger.
793
+ * @returns {ExprAST} the guarded program trigger.
794
+ */
795
+ function _buildTiltedReflectGuardTrigger(n, hw, HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn, withZFaces) {
796
+ function AND(x, y) { return Cond(x, y, Lit(0)); }
797
+ function OR(x, y) { return Cond(x, Lit(1), y); }
798
+ // s(P) = n·(P − C) for (xProp,yProp) ∈ {(prev_x,prev_y),(x,y)} — C = LO center (cx,cy).
799
+ function S(xp, yp) {
800
+ return Bin('+', Bin('*', Lit(n.x), Bin('-', Ref(HF, xp), Ref(HS, 'cx'))),
801
+ Bin('*', Lit(n.y), Bin('-', Ref(HF, yp), Ref(HS, 'cy'))));
802
+ }
803
+ var vDotN = Bin('+', Bin('*', Lit(n.x), Ref(HF, 'vx')), Bin('*', Lit(n.y), Ref(HF, 'vy')));
804
+ // Approach terms. 2D: strict (v·n > 0 / < 0) — byte-identical. 3D (`withZFaces`): the
805
+ // INCLUSIVE forms (>= / <= via the canonical NOT-encodings) so a ZERO-xy-momentum leaf
806
+ // (a pure-z beam inside the z-band whose TRANSVERSE WAVE wanders its center across the
807
+ // drawn surface — the wave moves the leaf with v·n exactly 0) still fires: the eject
808
+ // holds it outside the slab body (positional exclusion — what a solid does) while the
809
+ // reflect law maps its (0,0) xy velocity to (0,0), a no-op. No rattle risk: there is no
810
+ // xy velocity to oscillate, and leaves WITH momentum keep the exact 2D semantics (the
811
+ // inclusive form only admits v·n == 0). Measured: without this, ~half a pure-z beam's
812
+ // leaves were swept-culled at their z-plane crossing tick (center outside ±hw for one
813
+ // tick), then wander-entered the slab body and rendered inside it.
814
+ var frontApproach = withZFaces
815
+ ? Cond(Cmp('<', vDotN, Lit(0)), Lit(0), Lit(1)) // v·n >= 0
816
+ : Cmp('>', vDotN, Lit(0));
817
+ var backApproach = withZFaces
818
+ ? Cond(Cmp('>', vDotN, Lit(0)), Lit(0), Lit(1)) // v·n <= 0
819
+ : Cmp('<', vDotN, Lit(0));
820
+ var frontCross = AND(Cmp('<', S('prev_x', 'prev_y'), Lit(-hw)),
821
+ AND(Cmp('>', S('x', 'y'), Lit(-hw)),
822
+ frontApproach));
823
+ var backCross = AND(Cmp('>', S('prev_x', 'prev_y'), Lit(hw)),
824
+ AND(Cmp('<', S('x', 'y'), Lit(hw)),
825
+ backApproach));
826
+ var crossExpr = OR(frontCross, backCross);
827
+ if (withZFaces) {
828
+ // GE/LE via the canonical NOT-encodings (closed vocab has no '<='/'>='):
829
+ // inclusive NOW side — integer z steps land exactly ON the integer z-plane.
830
+ function GE(a, b) { return Cond(Cmp('<', a, b), Lit(0), Lit(1)); }
831
+ function LE(a, b) { return Cond(Cmp('>', a, b), Lit(0), Lit(1)); }
832
+ var zFront = AND(Cmp('<', Ref(HF, 'prev_z'), Ref(HS, 'near')),
833
+ AND(GE(Ref(HF, 'z'), Ref(HS, 'near')),
834
+ Cmp('>', Ref(HF, 'vz'), Lit(0))));
835
+ var zBack = AND(Cmp('>', Ref(HF, 'prev_z'), Ref(HS, 'far')),
836
+ AND(LE(Ref(HF, 'z'), Ref(HS, 'far')),
837
+ Cmp('<', Ref(HF, 'vz'), Lit(0))));
838
+ crossExpr = OR(crossExpr, OR(zFront, zBack));
839
+ }
840
+ return Cond(Fn('intersects', [Lit(HF), Lit(HS)]),
841
+ crossExpr,
842
+ Lit(0));
843
+ }
844
+
845
+ /**
846
+ * R1-GUARD eject — the PREV-ANCHORED FACE-NORMAL EXIT (the ONE eject story for a tilted
847
+ * reflect; CEO same-tick-exteriority ruling, 2026-07-25). Places the leaf's WHOLE BODY just
848
+ * outside the slab on its INCIDENT side, in the SAME tick as the velocity flip:
849
+ *
850
+ * P' = P_prev + (T − s(P_prev)) · n, T = −sign(v·n) · (hw + w/2 + 1)
851
+ *
852
+ * i.e. anchor at the pre-move position (already on the incident side of the surface — the
853
+ * guard requires it) and push along the face normal to clearance |s| = hw + w/2 + 1: half
854
+ * slab thickness + half leaf width + 1px. The incident side is −sign(v·n) because the eject
855
+ * ops run BEFORE the reflect ops, while v is still the INCOMING velocity. This supersedes
856
+ * BOTH earlier stories: the plain prev-restore (theorist §2) only guaranteed the leaf was on
857
+ * the incident side of the MID-LINE — a restored leaf could still sit up to hw INSIDE the
858
+ * slab body and render there while walking out (the CEO saw exactly that); and the AABB
859
+ * 'closest' eject scattered or far-face-ejected (the original leak). Exit clearance holds for
860
+ * the swap (45/135) AND arbitrary-angle paths identically — the ops are angle-generic data.
861
+ *
862
+ * ## Why there is NO op cross-coupling (no scratch register, no division)
863
+ *
864
+ * Both ops read ONLY `prev_x`/`prev_y`/`vx`/`vy` — refs that NEITHER op writes (and the
865
+ * reflect ops, which run after, touch only vx/vy). So op1 (set x) and op2 (set y) evaluate
866
+ * the IDENTICAL displacement Δ = T − s(P_prev) and land exactly at P_prev + Δ·n; s(P') = T
867
+ * by construction. Compare the withdrawn OBB-normal eject (ae55c414), which computed s from
868
+ * the CURRENT x/y and needed a vz-scratch to survive its own eviction of x — a trick that
869
+ * breaks on a 3D wall where vz is real momentum. prev-anchoring dissolves the problem.
870
+ * WASM-only (prev_* scope family), like the cardinal incidence tree. z passes straight
871
+ * through (rotation is xy-plane; exteriority in xy IS exteriority of the z-extruded slab).
872
+ *
873
+ * ## `anchorPrevSide` (3D tilted variant — opt-in)
874
+ *
875
+ * The incident-side sign has two equivalent derivations WHEN the xy guard branch fired:
876
+ * −sign(v·n) (v incoming — the 2D form) ≡ sign(s(P_prev)) (the side the leaf came from —
877
+ * the guard requires |s(prev)| > hw). The 3D program gates its ops per-op on PREV-ONLY
878
+ * class flags (see `_tilted3DGates`), and in the rare corner where the trigger fired via a
879
+ * z-face crossing while prev was ALSO outside an xy surface, v·n's sign is not tied to the
880
+ * xy incident side — so the 3D variant anchors the side on sign(s(P_prev)) instead:
881
+ * prev-only, always the genuine incident side, byte-identical displacement whenever the xy
882
+ * guard branch fired. Omitted/falsy ⇒ the 2D −sign(v·n) form, byte-identical (SU1).
883
+ *
884
+ * @param {{x:number,y:number}} n unit face normal (SlabFace obb.normal — baked Lit).
885
+ * @param {number} hw TRUE slab half-thickness (SlabFace obb.hw; 0 fallback → exits to
886
+ * 1 + w/2 past the mid-line — still outside the reflecting line by a full margin).
887
+ * @param {boolean} [anchorPrevSide] opt-in prev-side incident anchor (3D tilted variant).
888
+ * @returns {Array<object>} [setXOp, setYOp]
889
+ */
890
+ function _buildFaceExitEjectOps(n, hw, HF, HS, Lit, Ref, Bin, Cmp, Cond, anchorPrevSide) {
891
+ function S(xp, yp) {
892
+ return Bin('+', Bin('*', Lit(n.x), Bin('-', Ref(HF, xp), Ref(HS, 'cx'))),
893
+ Bin('*', Lit(n.y), Bin('-', Ref(HF, yp), Ref(HS, 'cy'))));
894
+ }
895
+ var vDotN = Bin('+', Bin('*', Lit(n.x), Ref(HF, 'vx')), Bin('*', Lit(n.y), Ref(HF, 'vy')));
896
+ // T = ±(hw + w/2 + 1): the clearance on the INCIDENT side. Side sign: −sign(v·n) (2D
897
+ // form, v is incoming here) or sign(s(P_prev)) (prev-anchored 3D form — see docstring).
898
+ var clearance = Bin('+', Lit(hw + 1), Bin('*', Ref(HF, 'w'), Lit(0.5)));
899
+ var side = anchorPrevSide
900
+ ? Cond(Cmp('<', S('prev_x', 'prev_y'), Lit(0)), Lit(-1), Lit(1))
901
+ : Cond(Cmp('<', vDotN, Lit(0)), Lit(1), Lit(-1));
902
+ var T = Bin('*', side, clearance);
903
+ // Δ = T − s(P_prev): identical in both ops (prev refs are never written by any op).
904
+ function delta() { return Bin('-', T, S('prev_x', 'prev_y')); }
905
+ return [
906
+ { set: HF + '.x', to: Bin('+', Ref(HF, 'prev_x'), Bin('*', delta(), Lit(n.x))) },
907
+ { set: HF + '.y', to: Bin('+', Ref(HF, 'prev_y'), Bin('*', delta(), Lit(n.y))) }
908
+ ];
909
+ }
910
+
911
+ /**
912
+ * R1-3D — PREV-ONLY response-class gates for the 3D tilted-reflect program (the z-face
913
+ * completion of the guarded tilted reflect; measured 3D z-entry leak class, 2026-07-25).
914
+ *
915
+ * ## Why per-op gating is SAFE here (vs a program-wide trigger)
916
+ *
917
+ * The retired G2b per-op Cond wrapping broke because its gate re-read values the program's
918
+ * OWN ops mutate (the eject moved x/y, so the gate re-evaluated false mid-program — the
919
+ * bounce-skip bug). These gates are immune BY CONSTRUCTION: they read ONLY `prev_x/prev_y/
920
+ * prev_z` (never written by any op) and `self.cx/cy/near/far` (LO geometry, never written
921
+ * by a frame op) — so every op in the sequence evaluates the IDENTICAL gate value no matter
922
+ * what earlier ops wrote. This is the same no-cross-coupling discipline the prev-anchored
923
+ * eject already uses.
924
+ *
925
+ * ## The two classes (a tilted 3D slab has TWO kinds of reflecting surface)
926
+ *
927
+ * xprev() — prev was outside an xy DRAWN surface (|s(prev)| > hw): the xy face response
928
+ * runs (face-exit eject + the 2θ reflect law); z passes through.
929
+ * zclass() — NOT xprev AND prev was outside the z-band (prev_z beyond near/far): the
930
+ * z-face response runs (z exit eject + vz flip — the SAME near/far bounce the
931
+ * cardinal incidence-3D tree gives an axis-aligned slab, C27); xy passes
932
+ * through. Given the guard trigger fired, `zclass` ⟺ the z-branch fired
933
+ * (an xy branch requires |s(prev)| > hw = xprev). xy has priority on the
934
+ * corner case (prev outside BOTH — mirror-face-dominant, the drawn face wins).
935
+ *
936
+ * Layer 2 — physics: ZERO new physics (SU1) — this is op-program DATA selecting which of
937
+ * the two proven bounce laws (2θ drawn-face / z-plane flip) answers a given crossing.
938
+ * @returns {{xprev:Function, zclass:Function}} fresh-AST gate factories.
939
+ */
940
+ function _tilted3DGates(n, hw, HF, HS, Lit, Ref, Bin, Cmp, Cond) {
941
+ function S_prev() {
942
+ return Bin('+', Bin('*', Lit(n.x), Bin('-', Ref(HF, 'prev_x'), Ref(HS, 'cx'))),
943
+ Bin('*', Lit(n.y), Bin('-', Ref(HF, 'prev_y'), Ref(HS, 'cy'))));
944
+ }
945
+ function xprev() { // |s(prev)| > hw (OR via Cond)
946
+ return Cond(Cmp('<', S_prev(), Lit(-hw)), Lit(1), Cmp('>', S_prev(), Lit(hw)));
947
+ }
948
+ function zOutPrev() {
949
+ return Cond(Cmp('<', Ref(HF, 'prev_z'), Ref(HS, 'near')), Lit(1),
950
+ Cmp('>', Ref(HF, 'prev_z'), Ref(HS, 'far')));
951
+ }
952
+ function zclass() { // NOT xprev AND zOutPrev
953
+ return Cond(xprev(), Lit(0), zOutPrev());
954
+ }
955
+ return { xprev: xprev, zclass: zclass };
956
+ }
957
+
958
+ /**
959
+ * R1-3D — the z-face EXIT eject for a 3D tilted slab: place the leaf's whole body just
960
+ * outside the z-face it entered through, in the SAME tick as the vz flip (the CEO
961
+ * same-tick-exteriority ruling, z-lifted):
962
+ * z' = cz + sign(prev_z − cz) · (hz + w/2 + 1)
963
+ * with cz/hz derived LIVE from `self.near`/`self.far` (the engine-recentered z-band —
964
+ * the broad-phase superset rect widens only x/y, so near/far are the TRUE z-faces) and
965
+ * w/2 the frame's z half-extent (cube convention, same as closest3d). prev-anchored side
966
+ * (prev_z is on the incident side by the guard's z branch), no scratch, no division.
967
+ * @returns {object} the single set-z op (ungated — caller gates on `zclass`).
968
+ */
969
+ function _buildZFaceExitEjectOp(HF, HS, Lit, Ref, Bin, Cmp, Cond) {
970
+ function cz() { return Bin('*', Bin('+', Ref(HS, 'near'), Ref(HS, 'far')), Lit(0.5)); }
971
+ function hz() { return Bin('*', Bin('-', Ref(HS, 'far'), Ref(HS, 'near')), Lit(0.5)); }
972
+ var clearance = Bin('+', Bin('+', hz(), Bin('*', Ref(HF, 'w'), Lit(0.5))), Lit(1));
973
+ var side = Cond(Cmp('<', Ref(HF, 'prev_z'), cz()), Lit(-1), Lit(1));
974
+ return { set: HF + '.z', to: Bin('+', cz(), Bin('*', side, clearance)) };
975
+ }
976
+
977
+ /**
978
+ * R1-3D — gate a list of ops on a prev-only class flag: each op's `to` becomes
979
+ * Cond(gate, to, keep-current-value). Gate factories MUST read only never-op-written refs
980
+ * (see `_tilted3DGates` — the G2b immunity argument). Only used for simple scalar props
981
+ * (x/y/z/vx/vy/vz — `op.set` is HF + '.<prop>').
982
+ * @returns {Array<object>} the gated ops (fresh objects; inputs not mutated).
983
+ */
984
+ function _gateOps(ops, gateFactory, HF, Ref, Cond) {
985
+ return ops.map(function (op) {
986
+ var prop = op.set.slice((HF + '.').length);
987
+ return { set: op.set, to: Cond(gateFactory(), op.to, Ref(HF, prop)) };
988
+ });
989
+ }
990
+
991
+ // ── PITCHED (two-axis) mirror builders — CEO ruling 2026-07-26 (Living Log ce4fb0c9) ──
992
+ //
993
+ // A PITCHED plate (pitchDeg ≠ 0 about its own long axis) reflects by the TRUE 3-space law
994
+ // R = I − 2nnᵀ about the plate normal n (SlabFace plate3.n — the ONE authority). This
995
+ // supersedes the vz-passes-through z-axis contract FOR PITCHED PLATES ONLY: pitch 0 keeps
996
+ // every existing program byte-identical (the pitched branch is opt-in). A pitched plate has
997
+ // NO engine ObbRegion (z-theta-only contract): the broad phase is the plate's 3D AABB rect
998
+ // and the guard trigger below IS the narrow phase (face-plane crossing + extent).
999
+
1000
+ /**
1001
+ * R2AX — the 3-space reflection ops about unit normal n: v' = v − 2(v·n)n, host-baked.
1002
+ *
1003
+ * ## Sequential-write realization (no scratch, no runtime division)
1004
+ * Each op writes ONE velocity component, so later ops see earlier writes. Let D = n·v_old.
1005
+ * After writing component a: n·v_now = D(1−2na²) ⇒ D = k1·(n·v_now), k1 = 1/(1−2na²)
1006
+ * (k1 HOST-baked — the same baked-reciprocal pattern as `_buildAngledReflectOps`). After b:
1007
+ * D = k2·(n·v_now), k2 = 1/(1−2na²−2nb²) = −1/(1−2nc²). So:
1008
+ * op-a: va' = va − 2na·(n·v)
1009
+ * op-b: vb' = vb − (2nb·k1)·(n·v) — n·v read AFTER op-a
1010
+ * op-c: vc' = vc − (2nc·k2)·(n·v) — n·v read AFTER op-a,b
1011
+ * Pivot order (a,b,c) is chosen HOST-side: a = the smallest-|n| component (na² ≤ 1/3 < 1/2
1012
+ * always), c = the remaining component with c² farthest from 1/2 — valid unless TWO
1013
+ * components have square exactly 1/2 (⇒ the third is 0): the DIAGONAL class, realized
1014
+ * exactly by the proven add/sub SWAP pattern instead (v_p' = −σpσq·v_q, v_q' = −σpσq·v_p,
1015
+ * σ = component signs). Zero RNG; exact f64; deterministic across engines (same op order).
1016
+ *
1017
+ * Layer 2 — physics: standard mirror optics in 3-space — the velocity component along the
1018
+ * plate normal inverts, the tangential components are untouched. At pitch 0 this equals the
1019
+ * 2D law with vz through (not used there — routing keeps pitch-0 byte-identical).
1020
+ * @param {{x:number,y:number,z:number}} n unit plate normal (SlabFace plate3.n).
1021
+ * @returns {Array<object>} 3 ops (generic) or 3-5 ops (diagonal swap class).
1022
+ */
1023
+ function _buildPitchedReflectOps(n, HF, Lit, Ref, Bin) {
1024
+ var comps = [{ k: 'vx', c: n.x }, { k: 'vy', c: n.y }, { k: 'vz', c: n.z }];
1025
+ var EPS = 1e-9;
1026
+ var halfIdx = [];
1027
+ for (var i = 0; i < 3; i++) { if (Math.abs(comps[i].c * comps[i].c - 0.5) < EPS) { halfIdx.push(i); } }
1028
+ function nDotV() {
1029
+ return Bin('+', Bin('+', Bin('*', Lit(n.x), Ref(HF, 'vx')), Bin('*', Lit(n.y), Ref(HF, 'vy'))),
1030
+ Bin('*', Lit(n.z), Ref(HF, 'vz')));
1031
+ }
1032
+ if (halfIdx.length === 2) {
1033
+ // DIAGONAL class: n = (±√½, ±√½) on axes p,q; third 0. Exact swap (+negate when
1034
+ // σpσq > 0) via the proven 3-op add/sub pattern (never `p=q; q=p`).
1035
+ var pk = comps[halfIdx[0]].k, qk = comps[halfIdx[1]].k;
1036
+ var s = Math.sign(comps[halfIdx[0]].c) * Math.sign(comps[halfIdx[1]].c);
1037
+ var ops = [
1038
+ { set: HF + '.' + pk, to: Bin('+', Ref(HF, pk), Ref(HF, qk)) },
1039
+ { set: HF + '.' + qk, to: Bin('-', Ref(HF, pk), Ref(HF, qk)) },
1040
+ { set: HF + '.' + pk, to: Bin('-', Ref(HF, pk), Ref(HF, qk)) }
1041
+ ];
1042
+ if (s > 0) {
1043
+ ops.push({ set: HF + '.' + pk, to: Bin('-', Lit(0), Ref(HF, pk)) });
1044
+ ops.push({ set: HF + '.' + qk, to: Bin('-', Lit(0), Ref(HF, qk)) });
1045
+ }
1046
+ return ops;
1047
+ }
1048
+ // Generic elimination order: a = min |n_c|; c = remaining with |c²−1/2| max; b = other.
1049
+ var order = [0, 1, 2].sort(function (x, y) { return Math.abs(comps[x].c) - Math.abs(comps[y].c); });
1050
+ var aI = order[0];
1051
+ var rest = [order[1], order[2]].sort(function (x, y) {
1052
+ return Math.abs(comps[y].c * comps[y].c - 0.5) - Math.abs(comps[x].c * comps[x].c - 0.5);
1053
+ });
1054
+ var cI = rest[0], bI = rest[1];
1055
+ var na = comps[aI].c, nb = comps[bI].c, nc = comps[cI].c;
1056
+ var k1 = 1 / (1 - 2 * na * na);
1057
+ var k2 = 1 / (1 - 2 * na * na - 2 * nb * nb);
1058
+ return [
1059
+ { set: HF + '.' + comps[aI].k, to: Bin('-', Ref(HF, comps[aI].k), Bin('*', Lit(2 * na), nDotV())) },
1060
+ { set: HF + '.' + comps[bI].k, to: Bin('-', Ref(HF, comps[bI].k), Bin('*', Lit(2 * nb * k1), nDotV())) },
1061
+ { set: HF + '.' + comps[cI].k, to: Bin('-', Ref(HF, comps[cI].k), Bin('*', Lit(2 * nc * k2), nDotV())) }
1062
+ ];
1063
+ }
1064
+
1065
+ /**
1066
+ * R2AX — the 3-space one-sided guard trigger for a PITCHED plate: the n-dot form of the
1067
+ * measured 2D cure, with the plate EXTENT as the narrow phase (a pitched plate has no
1068
+ * engine region — the AABB broad phase is a superset; this trigger culls it to the plate).
1069
+ * s(P) = n·(P − C), C = (self.cx, self.cy, (self.near+self.far)/2)
1070
+ * front: s(prev) < −hw AND s(now) >= −hw AND v·n >= 0
1071
+ * back : s(prev) > hw AND s(now) <= hw AND v·n <= 0
1072
+ * extent(now): |u·(P−C)| < hh AND |v·(P−C)| < hz
1073
+ * Inclusive now-side + inclusive approach per the 3D lattice/zero-momentum findings (the
1074
+ * pitch=±90 face is z-lattice-aligned; a zero-normal-momentum wander-cross is positionally
1075
+ * excluded). All refs are prev_*, self props, and current pos — the trigger evaluates ONCE
1076
+ * before any op runs, so current-pos reads are safe here.
1077
+ * @returns {ExprAST}
1078
+ */
1079
+ function _buildPitchedGuardTrigger(plate, HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn) {
1080
+ var n = plate.n, u = plate.u, v = plate.v;
1081
+ var hw = plate.hw, hh = plate.hh, hz = plate.hz;
1082
+ function AND(x, y) { return Cond(x, y, Lit(0)); }
1083
+ function OR(x, y) { return Cond(x, Lit(1), y); }
1084
+ function GE(a, b) { return Cond(Cmp('<', a, b), Lit(0), Lit(1)); }
1085
+ function LE(a, b) { return Cond(Cmp('>', a, b), Lit(0), Lit(1)); }
1086
+ function Abs(e) { return Fn('max', [e, Bin('-', Lit(0), e)]); }
1087
+ function CZ() { return Bin('*', Bin('+', Ref(HS, 'near'), Ref(HS, 'far')), Lit(0.5)); }
1088
+ function DOT(a, xp, yp, zp) {
1089
+ return Bin('+', Bin('+',
1090
+ Bin('*', Lit(a.x), Bin('-', Ref(HF, xp), Ref(HS, 'cx'))),
1091
+ Bin('*', Lit(a.y), Bin('-', Ref(HF, yp), Ref(HS, 'cy')))),
1092
+ Bin('*', Lit(a.z), Bin('-', Ref(HF, zp), CZ())));
1093
+ }
1094
+ var vDotN = Bin('+', Bin('+', Bin('*', Lit(n.x), Ref(HF, 'vx')), Bin('*', Lit(n.y), Ref(HF, 'vy'))),
1095
+ Bin('*', Lit(n.z), Ref(HF, 'vz')));
1096
+ var front = AND(Cmp('<', DOT(n, 'prev_x', 'prev_y', 'prev_z'), Lit(-hw)),
1097
+ AND(GE(DOT(n, 'x', 'y', 'z'), Lit(-hw)),
1098
+ GE(vDotN, Lit(0))));
1099
+ var back = AND(Cmp('>', DOT(n, 'prev_x', 'prev_y', 'prev_z'), Lit(hw)),
1100
+ AND(LE(DOT(n, 'x', 'y', 'z'), Lit(hw)),
1101
+ LE(vDotN, Lit(0))));
1102
+ var extent = AND(Cmp('<', Abs(DOT(u, 'x', 'y', 'z')), Lit(hh)),
1103
+ Cmp('<', Abs(DOT(v, 'x', 'y', 'z')), Lit(hz)));
1104
+ return Cond(Fn('intersects', [Lit(HF), Lit(HS)]),
1105
+ AND(extent, OR(front, back)),
1106
+ Lit(0));
1107
+ }
1108
+
1109
+ /**
1110
+ * R2AX — the prev-anchored 3-space face exit for a PITCHED plate (same-tick exteriority):
1111
+ * P' = P_prev + (T − s(P_prev))·n, T = sign(s(P_prev))·(hw + w/2 + 1)
1112
+ * The exact n-dot lift of `_buildFaceExitEjectOps` with the prev-side anchor. All three ops
1113
+ * read ONLY prev_* refs, self props, and w — refs no op writes — so they compute the same displacement
1114
+ * (no scratch, no division; the established discipline). Ungated: the pitched program has
1115
+ * ONE response class (the plate normal IS the reflection axis — no separate z-face).
1116
+ * @returns {Array<object>} [setXOp, setYOp, setZOp]
1117
+ */
1118
+ function _buildPitchedExitEjectOps(plate, HF, HS, Lit, Ref, Bin, Cmp, Cond) {
1119
+ var n = plate.n, hw = plate.hw;
1120
+ function CZ() { return Bin('*', Bin('+', Ref(HS, 'near'), Ref(HS, 'far')), Lit(0.5)); }
1121
+ function SPrev() {
1122
+ return Bin('+', Bin('+',
1123
+ Bin('*', Lit(n.x), Bin('-', Ref(HF, 'prev_x'), Ref(HS, 'cx'))),
1124
+ Bin('*', Lit(n.y), Bin('-', Ref(HF, 'prev_y'), Ref(HS, 'cy')))),
1125
+ Bin('*', Lit(n.z), Bin('-', Ref(HF, 'prev_z'), CZ())));
1126
+ }
1127
+ var clearance = Bin('+', Lit(hw + 1), Bin('*', Ref(HF, 'w'), Lit(0.5)));
1128
+ function T() { return Bin('*', Cond(Cmp('<', SPrev(), Lit(0)), Lit(-1), Lit(1)), clearance); }
1129
+ function delta() { return Bin('-', T(), SPrev()); }
1130
+ return [
1131
+ { set: HF + '.x', to: Bin('+', Ref(HF, 'prev_x'), Bin('*', delta(), Lit(n.x))) },
1132
+ { set: HF + '.y', to: Bin('+', Ref(HF, 'prev_y'), Bin('*', delta(), Lit(n.y))) },
1133
+ { set: HF + '.z', to: Bin('+', Ref(HF, 'prev_z'), Bin('*', delta(), Lit(n.z))) }
1134
+ ];
1135
+ }
1136
+
1137
+ /**
1138
+ * B3 — omni direction-only Random (CEO round-2 (a)): redraw the exit direction
1139
+ * uniformly over the square, DIRECTION ONLY, NO decoherence. This is also the
1140
+ * round-1 half-plane fix (the legacy RANDOM_XY drew vx ∈ [0,1) = forward hemisphere).
1141
+ * Uniform-over-square (not angle-uniform — ops cannot loop ⇒ no in-tick rejection);
1142
+ * that is the pure-data reality (angle-uniform would need a host tick-fn). 2 RNG draws.
1143
+ * @returns {Array<object>} [vxOp, vyOp]
1144
+ */
1145
+ function _buildOmniRandomOps(HF, Lit, Bin, Fn) {
1146
+ return [
1147
+ { set: HF + '.vx', to: Bin('-', Bin('*', Fn('random', []), Lit(2)), Lit(1)) },
1148
+ { set: HF + '.vy', to: Bin('-', Bin('*', Fn('random', []), Lit(2)), Lit(1)) }
1149
+ ];
1150
+ }
1151
+
1152
+ /**
1153
+ * B3 — angle-CONSTRAINED Random (CEO round-2 (b)): redraw the exit direction over a
1154
+ * cone bounded by two endpoint direction vectors A=(ax,ay), B=(bx,by), computed
1155
+ * HOST-SIDE from the wall's INITIAL orientation + [minDeg,maxDeg] and BAKED as Lits
1156
+ * (immune to later spin by construction — a re-aim must recompile). Uses the theorist
1157
+ * single-draw scratch-prop trick (proven P3, 60/60 exact on the consistency law):
1158
+ * every Fn('random') is a fresh draw, so store ONE t in a scratch prop (frame.vx),
1159
+ * read it in BOTH lerps. op2 (vy) reads t BEFORE op3 (vx) overwrites it. 1 RNG draw.
1160
+ * vy = ay + (by-ay)*t ; vx = ax + (bx-ax)*t
1161
+ * Chord-lerp is t-uniform, not angle-uniform (bias grows with cone width — negligible
1162
+ * for narrow "laser" cones); constrain sugar to <180° (near-antipodal endpoints pass
1163
+ * near the zero vector). @returns {Array<object>} [storeT, vyOp, vxOp]
1164
+ */
1165
+ function _buildConstrainedRandomOps(HF, ax, ay, bx, by, Lit, Ref, Bin, Fn) {
1166
+ return [
1167
+ // scratch: store t = random() in frame.vx (overwritten by op3 below).
1168
+ { set: HF + '.vx', to: Fn('random', []) },
1169
+ // vy = ay + (by-ay)*t (reads t from vx BEFORE it is overwritten)
1170
+ { set: HF + '.vy', to: Bin('+', Lit(ay), Bin('*', Bin('-', Lit(by), Lit(ay)), Ref(HF, 'vx'))) },
1171
+ // vx = ax + (bx-ax)*t (overwrites the scratch t with the real vx LAST)
1172
+ { set: HF + '.vx', to: Bin('+', Lit(ax), Bin('*', Bin('-', Lit(bx), Lit(ax)), Ref(HF, 'vx'))) }
1173
+ ];
1174
+ }
1175
+
1176
+ /**
1177
+ * B3 — decoherence as an INDEPENDENT toggle (CEO round-2 (c)): the phase reset
1178
+ * (`local_time=0`, offset-decoherence) + charge redraw (sign-decoherence, bench Q1)
1179
+ * that the legacy RANDOM_XY bundled in — here SEPARABLE, stackable onto any behavior.
1180
+ * Proven separable (theorist P1/P2). 1 RNG draw (charge). @returns {Array<object>}
1181
+ */
1182
+ function _buildDecoherenceOps(HF, Lit, Cmp, Cond, Fn) {
1183
+ return [
1184
+ { set: HF + '.local_time', to: Lit(0) },
1185
+ { set: HF + '.charge', to: Cond(Cmp('<', Fn('random', []), Lit(0.5)), Lit(0), Lit(1)) }
1186
+ ];
1187
+ }
1188
+
1189
+ // ── ABSORB v2 tally — WITHDRAWN (theorist round-3, CEO 384ef5ee) ─────────────────
1190
+ // The v1 `count`-as-virtual-mass tally op was the duplicative parallel LO-prop surface
1191
+ // the CEO's mass-surface ruling rejected. ABSORB v2 now accumulates into the LO's REAL
1192
+ // backing-frame mass via the existing frame accessors (the "LO extends Frame" composition;
1193
+ // under MANDATORY BACKING the LO's backing frame is created at registration and READ via
1194
+ // getLargeObjectFrameId) — see `absorbRadiation.js`. The compileWallV2 absorb branch
1195
+ // emits ONLY the kill op (+collect_collided); the host tick-fn does the real-mass tally +
1196
+ // radiation. No op-writable LO mass ledger anywhere.
1197
+
1198
+ // ── Main compiler ──────────────────────────────────────────────────────────────
1199
+
1200
+ /**
1201
+ * Compile a CollisionActions array (and optional collisionType) to an op-program
1202
+ * and a reduced actions list.
1203
+ *
1204
+ * G2: collisionType is now COMPILED, not informational. When non-NONE, velocity
1205
+ * ops are appended LAST to the ops array (after all action ops) to preserve the
1206
+ * global RNG draw order:
1207
+ * - action ops (ABSORB, COUNT, MARK, BLOCK, BOUNCE) draw 0 RNG values each
1208
+ * - RANDOM_X draws 1, RANDOM_Y draws 2, RANDOM_XY draws 3 (matching the
1209
+ * native dispatch_collision_type draw order: velocity first, then reset() charge)
1210
+ * - MOMENTUM_BASED and WALL draw 0 RNG values
1211
+ *
1212
+ * ## RNG discipline (from EGPTfrqtl.js native dispatch, MUST MATCH):
1213
+ *
1214
+ * RANDOM_X: 1 draw — vx = _rng()*2-1, vy = 1 (no charge draw)
1215
+ * RANDOM_Y: 2 draws — vy = _rng()*2-1, then reset() draws _rng() for charge
1216
+ * RANDOM_XY: 3 draws — vx = _rng(), vy = _rng()*2-1, then reset() draws _rng()
1217
+ * MOMENTUM_BASED: 0 draws — restores pre-move position via prev_x/prev_y
1218
+ * WALL: 0 draws — directional eject (same formulas as BLOCK)
1219
+ * BOUNCE: 0 draws — handled by BOUNCE action above; collisionType=BOUNCE
1220
+ * is a no-op here (action already compiled)
1221
+ * NONE: 0 draws — nothing emitted
1222
+ *
1223
+ * ## charge draw (reset(), EGPTfrqtl.js:2564)
1224
+ * `_rng() < 0.5 ? Charge.POSITIVE : Charge.NEGATIVE`
1225
+ * AST: Cond(Cmp('<', Fn('random',[]), Lit(0.5)), Lit(0), Lit(1))
1226
+ * (0 = POSITIVE, 1 = NEGATIVE in both JS and Rust)
1227
+ *
1228
+ * ## Sugar migration (G2):
1229
+ * Sugar objects (SlitScreen, Box, CircuitGate, LogicGate) pass their
1230
+ * CollisionType to this function AND pass CollisionTypes.NONE to the LO spec,
1231
+ * so the engine receives no native collision_type and all velocity comes from
1232
+ * the compiled op-program.
1233
+ *
1234
+ * @param {string[]} actions - CollisionActions array (SCREAMING_SNAKE).
1235
+ * @param {string} loName - LO name (used for MARK/DETECT detector_name and velocity-flip direction).
1236
+ * @param {string} reposition - Eject direction: 'left'|'right'|'top'|'bottom'|'closest'.
1237
+ * @param {string} [collisionType] - G2: CollisionType to compile. When non-NONE, velocity
1238
+ * ops are appended last. Pass CollisionTypes.NONE when
1239
+ * collisionType is already handled by an action (e.g. BOUNCE).
1240
+ *
1241
+ * @returns {{ program: {on:'collision', ops:Array, trigger:Object}|null }}
1242
+ * program — the compiled op-program to attach to the LO (null when nothing compiled).
1243
+ *
1244
+ * @throws on ANY action this compiler does not lower to an op. There is nowhere else for an
1245
+ * action to go: the engine has no action dispatch and `LargeObjectJsSpec` has no `actions`
1246
+ * field, so an uncompiled action is silently discarded at the wire. The error names the
1247
+ * action and its V2 replacement.
1248
+ */
1249
+ /**
1250
+ * FAIL LOUD for an action this compiler cannot lower to an op.
1251
+ *
1252
+ * There is no fallback path to leave it on: the engine has no action dispatch and the
1253
+ * registration spec has no `actions` field, so an uncompiled action is dropped silently at
1254
+ * the WASM boundary and the caller gets a wall that does nothing, with no error anywhere.
1255
+ *
1256
+ * @param {string} action - the offending action, as the caller spelled it.
1257
+ * @param {string} why - the reason AND the replacement to reach for.
1258
+ */
1259
+ function _uncompilable(action, why) {
1260
+ throw new Error('[actionCompiler] cannot compile CollisionAction ' +
1261
+ JSON.stringify(action) + ': ' + why + '. Nothing is left uncompiled — the engine has ' +
1262
+ 'no action dispatch, so an op-program is the only way a behaviour can run.');
1263
+ }
1264
+
1265
+ function compileActions(actions, loName, reposition, collisionType) {
1266
+ var ast = AstSchema;
1267
+ var Lit = ast.Lit;
1268
+ var Ref = ast.Ref;
1269
+ var Bin = ast.Bin;
1270
+ var Cmp = ast.Cmp;
1271
+ var Cond = ast.Cond;
1272
+ var Fn = ast.Fn;
1273
+ // NOTE: no local StrLit binding — every string write in this compiler is a TAG, and
1274
+ // the tag-op shape is owned by tagger.js (Tagger.tagOps). Re-binding StrLit here
1275
+ // would invite a fourth hand-spelled `0.data.<key>` op.
1276
+ var HANDLE_FRAME = ast.HANDLE_FRAME; // 0
1277
+ var HANDLE_SELF = ast.HANDLE_SELF; // 1
1278
+
1279
+ var ops = [];
1280
+ // G2b: track whether WALL velocity is being compiled (needs past-edge trigger).
1281
+ var isWall = false;
1282
+
1283
+ // Phase 1 — compile actions (0-draw each; order matches native dispatch_collision_actions).
1284
+ var actionsList = actions || [];
1285
+ for (var i = 0; i < actionsList.length; i++) {
1286
+ var action = actionsList[i];
1287
+
1288
+ if (action === 'ABSORB') {
1289
+ // ABSORB → set 0.is_alive = 0 (kills the colliding frame).
1290
+ // The engine's native ABSORB calls p.kill() which sets is_alive=false.
1291
+ // Op-program: set frame.is_alive = 0 (0 → false via !!value in setFrameProperty).
1292
+ ops.push({ set: HANDLE_FRAME + '.is_alive', to: Lit(0) });
1293
+
1294
+ } else if (action === 'COUNT') {
1295
+ // COUNT → set 1.count = 1.count + 1 (LO hit counter, same as OvenBox counter).
1296
+ // Mirrors the OvenBox oven-counter program (§2.1, slice 3).
1297
+ ops.push({ set: HANDLE_SELF + '.count',
1298
+ to: Bin('+', Ref(HANDLE_SELF, 'count'), Lit(1)) });
1299
+
1300
+ } else if (action === 'MARK' || action === 'DETECT') {
1301
+ // MARK/DETECT → a Tagger stamping the LO name under the canonical provenance
1302
+ // key: `set 0.data.detector_name = StrLit(loName)`. The op SHAPE is owned by
1303
+ // tagger.js (the MARK part) and FERRIED here — this compiler no longer spells
1304
+ // the '0.data.' path or the key name. The op-apply layer (_runOpProgram /
1305
+ // run_op_program_with_frame) intercepts StrLit before calling eval_expr and
1306
+ // routes it to the string_map write path; DetectorWall reads it back through
1307
+ // the SAME part (Tagger.readTag). No in-engine string comparison needed.
1308
+ var markName = loName || '';
1309
+ ops = ops.concat(Tagger.tagOps(markName)); // bare string ⇒ {detector_name: markName}
1310
+
1311
+ } else if (action === 'BLOCK') {
1312
+ // BLOCK → eject to nearest face (supports 'closest' via Cond-tree since StrLit landed).
1313
+ // Native: if pastWall → _eject(force=true); else → rect.eject(overlap).
1314
+ // Op-program: equivalent to _eject (force=true path, §10.3 formula).
1315
+ // 'closest': two Cond set ops (frame.left + frame.top) pick nearest face at runtime.
1316
+ if (!reposition) { _uncompilable(action, 'it needs an eject face: pass a reposition ' +
1317
+ 'of left|right|top|bottom|near|far|closest, or build the wall with V2 ' +
1318
+ "compileWallV2({behavior:'block', reposition})"); }
1319
+ var blockEjectOps = _buildEjectOps(reposition, HANDLE_FRAME, HANDLE_SELF, Lit, Ref, Bin, Cmp, Cond, Fn);
1320
+ if (!blockEjectOps) { _uncompilable(action, 'reposition "' + reposition + '" is not an ' +
1321
+ 'eject face (left|right|top|bottom|near|far|closest|closest3d)'); }
1322
+ for (var bi = 0; bi < blockEjectOps.length; bi++) ops.push(blockEjectOps[bi]);
1323
+
1324
+ } else if (action === 'BOUNCE') {
1325
+ // BOUNCE action → eject + velocity flip (named dir OR 'closest').
1326
+ // Same as OvenBox L4 (EP27-EP28). 'closest' Cond-tree now supported.
1327
+ if (!reposition) { _uncompilable(action, 'it needs an eject face: pass a reposition ' +
1328
+ 'of left|right|top|bottom|near|far|closest, or build the wall with V2 ' +
1329
+ "compileWallV2({behavior:'bounce', bounceDirection:'reflect', reposition})"); }
1330
+ var bounceEjectOps = _buildEjectOps(reposition, HANDLE_FRAME, HANDLE_SELF, Lit, Ref, Bin, Cmp, Cond, Fn);
1331
+ var flipOp2 = _buildVelocityFlipOp(loName || '', HANDLE_FRAME, Lit, Ref, Bin);
1332
+ if (!bounceEjectOps && !flipOp2) { _uncompilable(action, 'neither reposition "' +
1333
+ reposition + '" nor the LO name "' + (loName || '') + '" names a face to bounce off'); }
1334
+ if (bounceEjectOps) {
1335
+ for (var bj = 0; bj < bounceEjectOps.length; bj++) ops.push(bounceEjectOps[bj]);
1336
+ }
1337
+ if (flipOp2) ops.push(flipOp2);
1338
+
1339
+ } else if (action === 'LOG') {
1340
+ _uncompilable(action, 'there is no logging op and no native dispatch to fall back ' +
1341
+ 'to; read the collided frames from TickData.objects[handle].frames instead ' +
1342
+ "(compileWallV2({behavior:'nothing', hooks:['count']}) collects them)");
1343
+
1344
+ } else if (action === 'RECOLOR') {
1345
+ _uncompilable(action, 'the engine owns no colour; stamp render.color as a tag ' +
1346
+ "instead — compileWallV2({..., hooks:['recolor'], color:[r,g,b]})");
1347
+
1348
+ } else if (action === 'KILL') {
1349
+ // KILL: was a Rust-only divergence, deleted from canonical action set.
1350
+ // Collapse to ABSORB's op (kill the frame) for backward compat.
1351
+ ops.push({ set: HANDLE_FRAME + '.is_alive', to: Lit(0) });
1352
+
1353
+ } else {
1354
+ _uncompilable(action, 'it is not a CollisionAction this compiler knows ' +
1355
+ '(ABSORB|COUNT|MARK|DETECT|BLOCK|BOUNCE|KILL) — check the spelling, or use ' +
1356
+ 'the V2 wall vocabulary via compileWallV2({behavior, bounceDirection, hooks})');
1357
+ }
1358
+ }
1359
+
1360
+ // Phase 2 — G2: compile velocity ops from collisionType (APPENDED LAST to preserve
1361
+ // global RNG draw order: all 0-draw ops first, then RNG draws at the end, matching
1362
+ // the native dispatch_collision_type which ran AFTER dispatch_collision_actions).
1363
+ var ct = collisionType || 'NONE';
1364
+
1365
+ if (ct === 'RANDOM_X') {
1366
+ // RANDOM_X: 1 RNG draw — vx = _rng()*2-1, vy = 1 (no charge reset).
1367
+ // Native (EGPTfrqtl.js:1155-1165): vx = _rng()*2-1; vy = 1;
1368
+ ops.push({ set: HANDLE_FRAME + '.vx',
1369
+ to: Bin('-', Bin('*', Fn('random', []), Lit(2)), Lit(1)) });
1370
+ ops.push({ set: HANDLE_FRAME + '.vy',
1371
+ to: Lit(1) });
1372
+
1373
+ } else if (ct === 'RANDOM_Y') {
1374
+ // RANDOM_Y: 2 RNG draws — vy = _rng()*2-1, then reset() draws charge.
1375
+ // Native (EGPTfrqtl.js:1167-1175): vy = _rng()*2-1; vx = 1; reset() → charge draw.
1376
+ // reset() also clears local_time=0 (wave-phase reset — G2 mirrors this with a 0-draw op).
1377
+ ops.push({ set: HANDLE_FRAME + '.vy',
1378
+ to: Bin('-', Bin('*', Fn('random', []), Lit(2)), Lit(1)) });
1379
+ ops.push({ set: HANDLE_FRAME + '.vx',
1380
+ to: Lit(1) });
1381
+ // Reset wave phase (reset() side-effect — no RNG draw).
1382
+ ops.push({ set: HANDLE_FRAME + '.local_time', to: Lit(0) });
1383
+ // charge draw: _rng() < 0.5 ? Charge.POSITIVE : Charge.NEGATIVE (0 or 1)
1384
+ ops.push({ set: HANDLE_FRAME + '.charge',
1385
+ to: Cond(Cmp('<', Fn('random', []), Lit(0.5)), Lit(0), Lit(1)) });
1386
+
1387
+ } else if (ct === 'RANDOM_XY') {
1388
+ // RANDOM_XY: 3 RNG draws — vx = _rng(), vy = _rng()*2-1, then reset() draws charge.
1389
+ // Native (EGPTfrqtl.js:1155-1170): vx = _rng(); vy = _rng()*2-1; reset() → charge.
1390
+ // reset() also clears local_time=0 (wave-phase reset — G2 mirrors this with a 0-draw op).
1391
+ ops.push({ set: HANDLE_FRAME + '.vx',
1392
+ to: Fn('random', []) });
1393
+ ops.push({ set: HANDLE_FRAME + '.vy',
1394
+ to: Bin('-', Bin('*', Fn('random', []), Lit(2)), Lit(1)) });
1395
+ // Reset wave phase (reset() side-effect — no RNG draw).
1396
+ ops.push({ set: HANDLE_FRAME + '.local_time', to: Lit(0) });
1397
+ // charge draw: _rng() < 0.5 ? Charge.POSITIVE : Charge.NEGATIVE (0 or 1)
1398
+ ops.push({ set: HANDLE_FRAME + '.charge',
1399
+ to: Cond(Cmp('<', Fn('random', []), Lit(0.5)), Lit(0), Lit(1)) });
1400
+
1401
+ } else if (ct === 'MOMENTUM_BASED') {
1402
+ // MOMENTUM_BASED: 0 RNG draws — restores pre-move position (move_undo equivalent).
1403
+ // Native: p.move_undo() → p.x = p.prev_x, p.y = p.prev_y.
1404
+ // G2: prev_x/prev_y are exposed in the frame scope bag (frame_to_scope_bag).
1405
+ ops.push({ set: HANDLE_FRAME + '.x',
1406
+ to: Ref(HANDLE_FRAME, 'prev_x') });
1407
+ ops.push({ set: HANDLE_FRAME + '.y',
1408
+ to: Ref(HANDLE_FRAME, 'prev_y') });
1409
+
1410
+ } else if (ct === 'WALL') {
1411
+ // WALL: 0 RNG draws — directional eject only (no velocity flip).
1412
+ // Same eject formulas as BLOCK; the wall direction is the LO name prefix.
1413
+ // Native: _eject(p.rect, reposition, false, pastWall) → positional eject only.
1414
+ // G2b: WALL needs past-edge trigger (frames can slip through).
1415
+ if (reposition) {
1416
+ var wallEjectOps = _buildEjectOps(reposition, HANDLE_FRAME, HANDLE_SELF, Lit, Ref, Bin, Cmp, Cond, Fn);
1417
+ if (wallEjectOps) {
1418
+ for (var wi = 0; wi < wallEjectOps.length; wi++) ops.push(wallEjectOps[wi]);
1419
+ isWall = true; // trigger needs past-edge test
1420
+ }
1421
+ }
1422
+
1423
+ } else if (ct === 'BOUNCE') {
1424
+ // BOUNCE collision_type: only compile if no BOUNCE action was already compiled.
1425
+ // Sugar objects (Box) use BOUNCE actions; collision_type=BOUNCE was redundant.
1426
+ // After migration, Box passes NONE here — but if somehow both arrive, skip
1427
+ // to avoid double eject+flip.
1428
+ var hasBounceAction = actionsList.indexOf('BOUNCE') >= 0;
1429
+ if (!hasBounceAction && reposition) {
1430
+ var ctBounceEjectOps = _buildEjectOps(reposition, HANDLE_FRAME, HANDLE_SELF, Lit, Ref, Bin, Cmp, Cond, Fn);
1431
+ var ctFlipOp = _buildVelocityFlipOp(loName || '', HANDLE_FRAME, Lit, Ref, Bin);
1432
+ if (ctBounceEjectOps) {
1433
+ for (var bk = 0; bk < ctBounceEjectOps.length; bk++) ops.push(ctBounceEjectOps[bk]);
1434
+ }
1435
+ if (ctFlipOp) ops.push(ctFlipOp);
1436
+ }
1437
+
1438
+ }
1439
+ // NONE: nothing appended.
1440
+
1441
+ // G2b: attach a program-level trigger so the Rust engine evaluates it ONCE before
1442
+ // running any ops. If trigger == 0 (false), ALL ops are skipped atomically.
1443
+ //
1444
+ // This replaces the old per-op Cond wrapping approach. The old approach re-evaluated
1445
+ // Fn('intersects') for EACH op, but after the eject op (op 1) updated scope.left,
1446
+ // the bounce op's (op 2) intersects check used the post-eject position, returning false
1447
+ // and silently skipping the velocity flip — a critical bug (G2b per-op Cond bug).
1448
+ //
1449
+ // WALL uses the extended trigger (intersects OR past_edge).
1450
+ // All other action types use the overlap-only trigger.
1451
+ var program = ops.length > 0
1452
+ ? { on: 'collision', ops: ops }
1453
+ : null;
1454
+
1455
+ if (program !== null) {
1456
+ var trigger = _buildTriggerExpr(
1457
+ reposition || null,
1458
+ isWall,
1459
+ HANDLE_FRAME, HANDLE_SELF,
1460
+ Lit, Ref, Cmp, Cond, Fn
1461
+ );
1462
+ _applyProgramTrigger(program, trigger);
1463
+ }
1464
+
1465
+ return { program: program };
1466
+ }
1467
+
1468
+ /**
1469
+ * Compile the Collision LEXICON v2 wall spec → an op-program (the SINGLE compiler
1470
+ * authority for the v2 vocabulary; the W1 compileActions() path is untouched).
1471
+ *
1472
+ * @param {Object} spec
1473
+ * @param {string} spec.behavior - nothing | block | bounce | absorb | destroy
1474
+ * @param {string} [spec.bounceDirection] - reflect | random (when behavior==bounce)
1475
+ * @param {Array<number>} [spec.angleConstraint] - [minDeg,maxDeg] offsets around the
1476
+ * wall's INITIAL orientation (random only). Omitted/full ⇒ omni.
1477
+ * @param {number} [spec.orientationDeg=0]- the wall's INITIAL orientation (baked; a
1478
+ * later spin must recompile, per CEO — the cone endpoints are compile-time Lits).
1479
+ * @param {boolean}[spec.decoherence=false]- append the phase/charge reset (random only).
1480
+ * @param {Array<string>}[spec.hooks] - ['mark','count'] (composable with any behavior).
1481
+ * @param {string} [spec.markTag] - the user string MARK stamps (default '').
1482
+ * @param {number} [spec.capacity=64] - ABSORB mass ceiling + 1/capacity radiation.
1483
+ * @param {number} [spec.emissivity] - ABSORB radiating efficiency, a fullness fraction
1484
+ * in (0,1] applied to the emitted leaf. Omitted ⇒ absorbRadiation's default (0.5).
1485
+ * @param {number[]}[spec.color] - the wall's drawn [r,g,b(,a)] — the ONE authority
1486
+ * the `recolor` hook derives its render.color tag from (never a parallel color field).
1487
+ * @param {string} [spec.reposition='closest'] - eject face for bounce.
1488
+ * @returns {{ program:Object|null, collectCollided:boolean,
1489
+ * radiation:{capacity:number, emissivity?:number}|null }}
1490
+ */
1491
+ // _rgbaCsv([r,g,b(,a)]) → 'r,g,b,a' (the render.color CSV the providers read). Clamps to
1492
+ // ints in [0,255]; default alpha 255. Returns null for a missing/malformed color so the
1493
+ // recolor hook is a no-op rather than stamping garbage. This mirrors the exact 4-element
1494
+ // CSV shape colored emitter frames already ride (makeLeafFrame → rgba.join(',')).
1495
+ function _rgbaCsv(color) {
1496
+ if (!Array.isArray(color) || color.length < 3) { return null; }
1497
+ function _b(n) { n = Math.round(Number(n)); return isNaN(n) ? null : Math.max(0, Math.min(255, n)); }
1498
+ var r = _b(color[0]), g = _b(color[1]), b = _b(color[2]);
1499
+ if (r === null || g === null || b === null) { return null; }
1500
+ var a = (color.length >= 4) ? _b(color[3]) : 255;
1501
+ if (a === null) { a = 255; }
1502
+ return r + ',' + g + ',' + b + ',' + a;
1503
+ }
1504
+
1505
+ function compileWallV2(spec) {
1506
+ var ast = AstSchema;
1507
+ // No StrLit binding — the mark/recolor hooks are Taggers (tagger.js owns the op shape).
1508
+ var Lit = ast.Lit, Ref = ast.Ref, Bin = ast.Bin, Cmp = ast.Cmp, Cond = ast.Cond,
1509
+ Fn = ast.Fn, HF = ast.HANDLE_FRAME, HS = ast.HANDLE_SELF;
1510
+
1511
+ spec = spec || {};
1512
+ var behavior = spec.behavior || 'nothing';
1513
+ var reposition = spec.reposition || 'closest';
1514
+ // Z1 — a 3D wall (a real z-slab, is3D) ejects on all SIX faces and reflects on all
1515
+ // three axes. Auto-promote the DEFAULT 'closest' eject to the 6-face 'closest3d' so a
1516
+ // frame entering via the near/far face is z-ejected (else in-wall z jitter); an explicit
1517
+ // reposition is honored verbatim. 2D walls (is3D falsy) are entirely unchanged (SU1).
1518
+ var is3D = (spec.is3D === true);
1519
+ if (is3D && reposition === 'closest') { reposition = 'closest3d'; }
1520
+ var hooks = Array.isArray(spec.hooks) ? spec.hooks : [];
1521
+ var collectCollided = false;
1522
+ var radiation = null;
1523
+ var ops = [];
1524
+
1525
+ // ── Motion / life response (the ONE behavior axis) ───────────────────────
1526
+ // TILTED REFLECT GUARD (theorist §2 + CEO same-tick-exteriority ruling, 2026-07-25): a
1527
+ // NON-cardinal REFLECT mirror is fired ONCE per genuine drawn-SURFACE crossing (the guard
1528
+ // trigger, applied below) and ejects by the PREV-ANCHORED FACE-NORMAL EXIT — the ONE
1529
+ // coherent eject story that replaces BOTH the interim OBB-normal eject (ae55c414) and the
1530
+ // theorist's plain prev-restore. Cardinal / random-bounce / non-reflect walls keep the
1531
+ // AABB 'closest' eject + plain-intersects trigger — BYTE-IDENTICAL (SU1). The guard normal
1532
+ // is CONSUMED from the ONE SlabFace authority (obb.normal = the −1-eigenvector the reflect
1533
+ // law flips, engine y-down frame) — never a second inline sin/cos convention. Applies to
1534
+ // BOTH 2D and 3D (a non-cardinal orientation is rotation in the xy-plane; z passes
1535
+ // through — 2D≡3D). slabW/slabH ride along so the resolved obb is fully formed; the guard
1536
+ // itself reads only obb.normal (the slab EXTENT is enforced by the engine's swept-OBB
1537
+ // region, which culls candidates outside the rotated slab before the trigger runs).
1538
+ var _reflectSlab = (behavior === 'bounce' && spec.bounceDirection !== 'random')
1539
+ ? SlabFace.resolveSlabFace({ orientationDeg: spec.orientationDeg,
1540
+ w: spec.slabW, h: spec.slabH,
1541
+ pitchDeg: spec.pitchDeg, d: spec.slabD }) : null;
1542
+ // R2AX — a PITCHED plate (two-axis mirror, CEO ruling 2026-07-26) takes the true
1543
+ // 3-space reflection path (R = I − 2nnᵀ about SlabFace plate3.n + the n-dot guard +
1544
+ // the prev-anchored 3-space exit). pitch 0/absent never sets `pitched` — every
1545
+ // existing program stays byte-identical.
1546
+ var pitchedReflect = !!(_reflectSlab && _reflectSlab.pitched);
1547
+ if (pitchedReflect && !is3D) {
1548
+ throw new Error('[actionCompiler] pitchDeg requires a 3D wall (a real z/depth) — ' +
1549
+ 'a 2D wall has no z axis to pitch into.');
1550
+ }
1551
+ var tiltedReflect = !!(_reflectSlab && !_reflectSlab.isCardinal && !pitchedReflect);
1552
+ if (behavior === 'bounce') {
1553
+ if (pitchedReflect) {
1554
+ // ONE response class: face exit along n (prev-anchored, same-tick exteriority)
1555
+ // then the 3-space reflection about n. The guard trigger (below) is the narrow
1556
+ // phase (plane crossing + plate extent); the engine gets NO region (broad phase
1557
+ // = the plate's 3D AABB rect, emitted by the Wall sugar).
1558
+ ops = ops.concat(_buildPitchedExitEjectOps(_reflectSlab.plate3, HF, HS, Lit, Ref, Bin, Cmp, Cond));
1559
+ ops = ops.concat(_buildPitchedReflectOps(_reflectSlab.plate3.n, HF, Lit, Ref, Bin));
1560
+ } else if (tiltedReflect) {
1561
+ // GUARDED tilted reflect: the face-normal EXIT eject FIRST (it reads the INCOMING
1562
+ // velocity to pick the incident side), then the reflection law (position and
1563
+ // velocity are independent writes). The whole program fires only when the guard
1564
+ // trigger (below) confirms a surface crossing, so the involution runs exactly once
1565
+ // per hit AND the leaf's rendered position is outside the slab the SAME tick — no
1566
+ // oscillation, no far-face eject, no leak-through, no in-slab rendering.
1567
+ var _ghw = (_reflectSlab.obb && isFinite(_reflectSlab.obb.hw)) ? _reflectSlab.obb.hw : 0;
1568
+ if (is3D) {
1569
+ // 3D TILTED slab — the z-face COMPLETION (measured 3D z-entry leak class,
1570
+ // 2026-07-25): the slab has TWO kinds of reflecting surface — the rotated xy
1571
+ // DRAWN faces (the 2θ law) and the UNROTATED near/far z-faces (the C27 z-plane
1572
+ // flip). One program handles both: every op is gated on a PREV-ONLY class flag
1573
+ // (`_tilted3DGates` — immune to the retired G2b re-evaluation bug because the
1574
+ // gates read only prev_*/self.* refs no op writes). Order matters only for the
1575
+ // documented sequential-swap reads; the gates are constant across the sequence.
1576
+ // xy class: prev-side-anchored face exit + the swap/angled reflect (vz through)
1577
+ // z class: z-face exit + vz flip (xy untouched)
1578
+ var _g3 = _tilted3DGates(_reflectSlab.obb.normal, _ghw, HF, HS, Lit, Ref, Bin, Cmp, Cond);
1579
+ var _ejXY = _buildFaceExitEjectOps(_reflectSlab.obb.normal, _ghw, HF, HS, Lit, Ref, Bin, Cmp, Cond, true);
1580
+ ops = ops.concat(_gateOps(_ejXY, _g3.xprev, HF, Ref, Cond));
1581
+ ops = ops.concat(_gateOps([_buildZFaceExitEjectOp(HF, HS, Lit, Ref, Bin, Cmp, Cond)], _g3.zclass, HF, Ref, Cond));
1582
+ ops = ops.concat(_gateOps(_buildReflectOps(spec.orientationDeg, HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn, is3D), _g3.xprev, HF, Ref, Cond));
1583
+ ops = ops.concat(_gateOps([{ set: HF + '.vz', to: Bin('-', Lit(0), Ref(HF, 'vz')) }], _g3.zclass, HF, Ref, Cond));
1584
+ } else {
1585
+ ops = ops.concat(_buildFaceExitEjectOps(_reflectSlab.obb.normal, _ghw, HF, HS, Lit, Ref, Bin, Cmp, Cond));
1586
+ ops = ops.concat(_buildReflectOps(spec.orientationDeg, HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn, is3D));
1587
+ }
1588
+ } else {
1589
+ // Cardinal / random-bounce: AABB eject (penetration-stop) THEN the direction op —
1590
+ // BYTE-IDENTICAL to the pre-guard program (SU1).
1591
+ var ejectOps = _buildEjectOps(reposition, HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn) || [];
1592
+ for (var e = 0; e < ejectOps.length; e++) { ops.push(ejectOps[e]); }
1593
+ if (spec.bounceDirection === 'random') {
1594
+ var ac = spec.angleConstraint;
1595
+ if (Array.isArray(ac) && ac.length === 2 && isFinite(ac[0]) && isFinite(ac[1]) &&
1596
+ (Math.abs(ac[1] - ac[0]) < 360)) {
1597
+ // Cone endpoints baked HOST-SIDE from the INITIAL orientation (trig legal here).
1598
+ var o = (typeof spec.orientationDeg === 'number') ? spec.orientationDeg : 0;
1599
+ var a0 = (o + ac[0]) * Math.PI / 180, a1 = (o + ac[1]) * Math.PI / 180;
1600
+ ops = ops.concat(_buildConstrainedRandomOps(
1601
+ HF, Math.cos(a0), Math.sin(a0), Math.cos(a1), Math.sin(a1), Lit, Ref, Bin, Fn));
1602
+ } else {
1603
+ ops = ops.concat(_buildOmniRandomOps(HF, Lit, Bin, Fn));
1604
+ }
1605
+ if (spec.decoherence === true) {
1606
+ ops = ops.concat(_buildDecoherenceOps(HF, Lit, Cmp, Cond, Fn));
1607
+ }
1608
+ } else {
1609
+ // reflect (cardinal): the proven incidence box tree (byte-identical at 0 — SU1);
1610
+ // fused with the AABB eject above. A live orientation edit re-bakes via setV2Prop.
1611
+ ops = ops.concat(_buildReflectOps(spec.orientationDeg, HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn, is3D));
1612
+ }
1613
+ }
1614
+ } else if (behavior === 'block') {
1615
+ // BLOCK — the particle is STOPPED at the surface. ONE response, composed entirely
1616
+ // from the SHARED eject authority (`_buildEjectOps`): reposition the frame to the
1617
+ // CLOSEST position OUTSIDE the wall, velocity UNTOUCHED. That is literally the
1618
+ // `bounce` op-set minus the reflect ops, and byte-identical to the op-set the
1619
+ // legacy `CollisionActions.BLOCK` compiled — so porting a legacy `block` caller
1620
+ // moves ZERO physics.
1621
+ //
1622
+ // WHY "closest outside" and NOT "revert the last move" (the CEO gave both options;
1623
+ // measured head-to-head on the canonical WASM engine, wall x∈[200,220]):
1624
+ // - approaching leaf (x=150, vx=+1): the two are equivalent in outcome — the leaf
1625
+ // stops dead at the face and stays (eject parks at x=198.5, revert at x=199) for
1626
+ // every remaining tick, velocity kept at +1. Either satisfies "block".
1627
+ // - leaf whose PREVIOUS position was ALSO inside (spawned/tunnelled in at x=210):
1628
+ // eject-to-closest is outside in ONE tick (0/30 ticks inside); revert-last-move
1629
+ // restores it to a position that is itself inside, so the guard re-fires forever
1630
+ // and the leaf is PINNED INSIDE THE WALL (30/30 ticks inside). Revert has no
1631
+ // exteriority guarantee; eject does, from any state. That decides it.
1632
+ // The composition also inherits `reposition`'s 3D promotion for free: an is3D wall
1633
+ // already auto-promotes 'closest' → 'closest3d' above, so a z-slab block wall ejects
1634
+ // on all six faces (2D≡3D, no block-specific 3D code).
1635
+ //
1636
+ // TILT: like `bounce/random`, a non-cardinal block wall uses the AABB 'closest'
1637
+ // eject (the prev-anchored face-normal exit is a REFLECT-only construction — it
1638
+ // reads the incoming velocity to pick the incident side, which a velocity-preserving
1639
+ // stop has no use for). A tilted block wall therefore stops the particle at the
1640
+ // bounding-box face, not the drawn face.
1641
+ var blockEjectOps = _buildEjectOps(reposition, HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn) || [];
1642
+ for (var bq = 0; bq < blockEjectOps.length; bq++) { ops.push(blockEjectOps[bq]); }
1643
+ } else if (behavior === 'absorb') {
1644
+ // Track A (CEO mass-surface ruling 384ef5ee): the collision op just KILLS; the host
1645
+ // radiation tick-fn (absorbRadiation.js) tallies the collected killed masses into the
1646
+ // LO's REAL BACKING-FRAME mass (via the existing frame accessors) and radiates per the
1647
+ // conservation contract. The v1 count-as-virtual-mass tally op is WITHDRAWN (theorist
1648
+ // round-3 — it was exactly the duplicative parallel surface the CEO flagged).
1649
+ var cap = (typeof spec.capacity === 'number' && spec.capacity > 0) ? spec.capacity : 64;
1650
+ ops.push({ set: HF + '.is_alive', to: Lit(0) });
1651
+ collectCollided = true; // buffer killed frames' masses for the host tally
1652
+ // EMISSIVITY rides the radiation bag beside the clock capacity: it is the wall's
1653
+ // radiating efficiency as a FULLNESS fraction of the emitted leaf, so it sets what the
1654
+ // re-emitted quantum IS (its speed and its wavelength together). Omitted ⇒ the sugar's
1655
+ // own default (absorbRadiation.DEFAULT_EMISSIVITY); this compiler does not mirror the
1656
+ // default, it just declines to state one.
1657
+ radiation = { capacity: cap };
1658
+ if (spec.emissivity !== undefined && spec.emissivity !== null) {
1659
+ radiation.emissivity = spec.emissivity;
1660
+ }
1661
+ } else if (behavior === 'destroy') {
1662
+ ops.push({ set: HF + '.is_alive', to: Lit(0) });
1663
+ }
1664
+ // 'nothing' → no motion/life op (pass-through).
1665
+
1666
+ // ── Hooks (composable with ANY behavior) ─────────────────────────────────
1667
+ if (hooks.indexOf('mark') !== -1) {
1668
+ // The `mark` hook IS a Tagger: the user string under the canonical provenance key.
1669
+ // Shape owned by tagger.js, ferried here (`''` is a legal tag value — a mark hook
1670
+ // with no markTag has always stamped the empty string; the part preserves that).
1671
+ ops = ops.concat(Tagger.tagOps(spec.markTag || ''));
1672
+ }
1673
+ if (hooks.indexOf('count') !== -1) {
1674
+ ops.push({ set: HS + '.count', to: Bin('+', Ref(HS, 'count'), Lit(1)) });
1675
+ collectCollided = true; // buffer frames for the between-tick stats reader
1676
+ }
1677
+ // recolor — a RENDER-INTENT hook (MARK's sibling): stamp the wall's OWN drawn color
1678
+ // into the collided frame's opaque string_map under 'render.color' (CSV 'r,g,b,a').
1679
+ // The engine carries the string_map key VERBATIM and interprets nothing (I1 — the
1680
+ // engine has no color); the renderer providers already read string_map['render.color']
1681
+ // (highest-priority render intent), so a colliding beam RE-COLORS to this wall's color
1682
+ // with ZERO renderer change. The color is derived at compile time from the ONE authority
1683
+ // — the wall's `color` property (spec.color) — never a parallel color field. StrLit is
1684
+ // the same string-map write path MARK uses; strKey = prop.slice(5) keeps the dotted
1685
+ // 'render.color' key intact.
1686
+ if (hooks.indexOf('recolor') !== -1) {
1687
+ var _csv = _rgbaCsv(spec.color);
1688
+ // recolor is MARK's sibling and therefore the SAME part with a different key:
1689
+ // a Tagger whose key ('render.color') happens to be one the renderer reads. The
1690
+ // dotted key survives the tag path whole (strKey = prop.slice(5)).
1691
+ if (_csv) { ops = ops.concat(Tagger.tagOps({ 'render.color': _csv })); }
1692
+ }
1693
+
1694
+ var program = ops.length > 0 ? { on: 'collision', ops: ops } : null;
1695
+ if (program !== null) {
1696
+ if (pitchedReflect) {
1697
+ // R2AX — the pitched guard IS the narrow phase (plane crossing + extent along
1698
+ // the plate's u/v axes; no engine region for a pitched plate).
1699
+ _applyProgramTrigger(program, _buildPitchedGuardTrigger(
1700
+ _reflectSlab.plate3, HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn));
1701
+ } else if (tiltedReflect) {
1702
+ // GUARDED tilted reflect: fire the reflection ONCE per drawn-SURFACE crossing
1703
+ // (surface-crossing + approach guard — theorist §2 upgraded to the ±hw surfaces
1704
+ // per the CEO same-tick-exteriority ruling). The crossing NORMAL is `obb.normal`
1705
+ // and the half-thickness `obb.hw`, BOTH from the ONE SlabFace authority: the
1706
+ // normal is the axis R(reflectBaseDeg) actually FLIPS (its −1-eigenvector,
1707
+ // engine y-down frame) — the SAME derivation the reflect law, the renderer's
1708
+ // drawn face, and the engine OBB region share (circulation §1: never a second
1709
+ // inline sin/cos convention; the guard logic is invariant under n → −n because
1710
+ // it handles both surfaces symmetrically). The finite slab EXTENT along the
1711
+ // face line is enforced by the engine's swept-OBB narrow-phase, which culls
1712
+ // candidates outside the rotated slab BEFORE this trigger runs.
1713
+ var _ghw2 = (_reflectSlab.obb && isFinite(_reflectSlab.obb.hw)) ? _reflectSlab.obb.hw : 0;
1714
+ // `withZFaces = is3D`: a 3D tilted slab's trigger ALSO fires on a genuine z-face
1715
+ // crossing (the z-entry leak completion); 2D stays byte-identical (SU1).
1716
+ _applyProgramTrigger(program, _buildTiltedReflectGuardTrigger(
1717
+ _reflectSlab.obb.normal, _ghw2, HF, HS, Lit, Ref, Bin, Cmp, Cond, Fn, is3D));
1718
+ } else {
1719
+ // Overlap-only trigger (frame is inside the wall when any v2 op fires) — SU1.
1720
+ _applyProgramTrigger(program, _buildTriggerExpr(reposition, false, HF, HS, Lit, Ref, Cmp, Cond, Fn));
1721
+ }
1722
+ }
1723
+ return { program: program, collectCollided: collectCollided, radiation: radiation };
1724
+ }
1725
+
1726
+ // ── Export ─────────────────────────────────────────────────────────────────────
1727
+
1728
+ // Collision LEXICON v2 builders (theorist-proven; consumed by compileWallV2 and driven RAW by the bench
1729
+ // LO-bites — the SINGLE authority for these shapes) and the shared eject/flip builders (OvenBox, Tube).
1730
+ export {
1731
+ compileActions,
1732
+ compileWallV2,
1733
+ _buildIncidenceReflectOps as buildIncidenceReflectOps,
1734
+ _buildIncidenceReflectOps3D as buildIncidenceReflectOps3D,
1735
+ _buildAngledReflectOps as buildAngledReflectOps,
1736
+ _buildSwapReflectOps as buildSwapReflectOps,
1737
+ _buildReflectOps as buildReflectOps,
1738
+ _buildTiltedReflectGuardTrigger as buildTiltedReflectGuardTrigger,
1739
+ _buildFaceExitEjectOps as buildFaceExitEjectOps,
1740
+ _buildOmniRandomOps as buildOmniRandomOps,
1741
+ _buildConstrainedRandomOps as buildConstrainedRandomOps,
1742
+ _buildDecoherenceOps as buildDecoherenceOps,
1743
+ _buildTriggerExpr as buildTriggerExpr,
1744
+ _applyProgramTrigger as applyProgramTrigger,
1745
+ _buildEjectOps as buildEjectOps,
1746
+ _buildVelocityFlipOp as buildVelocityFlipOp
1747
+ };