@volter/blender-engine 0.1.147 → 0.1.149

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 (245) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +4 -11
  3. package/browser/addons/vrm/LICENSE_(OPTION1)_MIT.txt +22 -0
  4. package/browser/addons/vrm/LICENSE_(OPTION2)_GPL-3_0.txt +674 -0
  5. package/browser/addons/vrm/LICENSE_MAIN.txt +1 -0
  6. package/browser/addons/vrm/README.txt +3 -0
  7. package/browser/addons/vrm/__init__.py +41 -0
  8. package/browser/addons/vrm/blender_manifest.toml +16 -0
  9. package/browser/addons/vrm/common/__init__.py +1 -0
  10. package/browser/addons/vrm/common/animation.py +58 -0
  11. package/browser/addons/vrm/common/blender_manifest.py +82 -0
  12. package/browser/addons/vrm/common/char.py +16 -0
  13. package/browser/addons/vrm/common/convert.py +287 -0
  14. package/browser/addons/vrm/common/convert_any.py +48 -0
  15. package/browser/addons/vrm/common/debug.py +40 -0
  16. package/browser/addons/vrm/common/deep.py +162 -0
  17. package/browser/addons/vrm/common/error_dialog.py +274 -0
  18. package/browser/addons/vrm/common/fs.py +71 -0
  19. package/browser/addons/vrm/common/gl.py +20 -0
  20. package/browser/addons/vrm/common/gltf.py +1149 -0
  21. package/browser/addons/vrm/common/human_bone_mapper/__init__.py +1 -0
  22. package/browser/addons/vrm/common/human_bone_mapper/biped_mapping.py +84 -0
  23. package/browser/addons/vrm/common/human_bone_mapper/cats_blender_plugin_fix_model_mapping.py +63 -0
  24. package/browser/addons/vrm/common/human_bone_mapper/human_bone_mapper.py +203 -0
  25. package/browser/addons/vrm/common/human_bone_mapper/microsoft_rocketbox_mapping.py +69 -0
  26. package/browser/addons/vrm/common/human_bone_mapper/mixamo_mapping.py +62 -0
  27. package/browser/addons/vrm/common/human_bone_mapper/mmd_mapping.py +115 -0
  28. package/browser/addons/vrm/common/human_bone_mapper/mpfb_mapping.py +120 -0
  29. package/browser/addons/vrm/common/human_bone_mapper/ready_player_me_mapping.py +65 -0
  30. package/browser/addons/vrm/common/human_bone_mapper/reallusion_mapping.py +94 -0
  31. package/browser/addons/vrm/common/human_bone_mapper/rigify_meta_rig_mapping.py +65 -0
  32. package/browser/addons/vrm/common/human_bone_mapper/structure_based_mapping.py +891 -0
  33. package/browser/addons/vrm/common/human_bone_mapper/unreal_mapping.py +62 -0
  34. package/browser/addons/vrm/common/human_bone_mapper/vrm_addon_mapping.py +112 -0
  35. package/browser/addons/vrm/common/human_bone_mapper/vroid_mapping.py +89 -0
  36. package/browser/addons/vrm/common/legacy_gltf.py +16 -0
  37. package/browser/addons/vrm/common/logger.py +84 -0
  38. package/browser/addons/vrm/common/mtoon0.blend +0 -0
  39. package/browser/addons/vrm/common/mtoon1.blend +0 -0
  40. package/browser/addons/vrm/common/mtoon1_outline.blend +0 -0
  41. package/browser/addons/vrm/common/mtoon_auto_setup.blend +0 -0
  42. package/browser/addons/vrm/common/mtoon_unversioned.py +62 -0
  43. package/browser/addons/vrm/common/ops/__init__.py +10 -0
  44. package/browser/addons/vrm/common/ops/export_scene.py +67 -0
  45. package/browser/addons/vrm/common/ops/icyp.py +53 -0
  46. package/browser/addons/vrm/common/ops/import_scene.py +57 -0
  47. package/browser/addons/vrm/common/ops/vrm.py +2380 -0
  48. package/browser/addons/vrm/common/ops/wm.py +167 -0
  49. package/browser/addons/vrm/common/preferences.py +362 -0
  50. package/browser/addons/vrm/common/progress.py +87 -0
  51. package/browser/addons/vrm/common/rotation.py +89 -0
  52. package/browser/addons/vrm/common/safe_removal.py +46 -0
  53. package/browser/addons/vrm/common/scene_watcher.py +230 -0
  54. package/browser/addons/vrm/common/shader.py +2243 -0
  55. package/browser/addons/vrm/common/shape_key_mapper/__init__.py +1 -0
  56. package/browser/addons/vrm/common/shape_key_mapper/arkit_mapping.py +168 -0
  57. package/browser/addons/vrm/common/shape_key_mapper/mmd_mapping.py +62 -0
  58. package/browser/addons/vrm/common/shape_key_mapper/ready_player_me_mapping.py +52 -0
  59. package/browser/addons/vrm/common/shape_key_mapper/vrchat_mapping.py +17 -0
  60. package/browser/addons/vrm/common/third_party_user_extension.py +214 -0
  61. package/browser/addons/vrm/common/transparent_z_write.blend +0 -0
  62. package/browser/addons/vrm/common/version.py +161 -0
  63. package/browser/addons/vrm/common/vrm0/__init__.py +1 -0
  64. package/browser/addons/vrm/common/vrm0/human_bone.py +662 -0
  65. package/browser/addons/vrm/common/vrm0/material_property.py +346 -0
  66. package/browser/addons/vrm/common/vrm1/__init__.py +1 -0
  67. package/browser/addons/vrm/common/vrm1/expression_preset.py +207 -0
  68. package/browser/addons/vrm/common/vrm1/human_bone.py +822 -0
  69. package/browser/addons/vrm/common/workspace.py +227 -0
  70. package/browser/addons/vrm/common/writable_context.py +77 -0
  71. package/browser/addons/vrm/documentation/en-us/animation/index.md +63 -0
  72. package/browser/addons/vrm/documentation/en-us/create-humanoid-vrm-from-scratch/index.md +131 -0
  73. package/browser/addons/vrm/documentation/en-us/create-simple-vrm-from-scratch/index.md +30 -0
  74. package/browser/addons/vrm/documentation/en-us/development/index.md +136 -0
  75. package/browser/addons/vrm/documentation/en-us/index.md +41 -0
  76. package/browser/addons/vrm/documentation/en-us/installation/index.md +152 -0
  77. package/browser/addons/vrm/documentation/en-us/material-mtoon/index.md +122 -0
  78. package/browser/addons/vrm/documentation/en-us/material-pbr/index.md +94 -0
  79. package/browser/addons/vrm/documentation/en-us/scripting-api/index.md +391 -0
  80. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrm/index.md +57 -0
  81. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrma/index.md +17 -0
  82. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrm/index.md +61 -0
  83. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrma/index.md +13 -0
  84. package/browser/addons/vrm/documentation/ja-jp/animation/index.md +57 -0
  85. package/browser/addons/vrm/documentation/ja-jp/create-humanoid-vrm-from-scratch/index.md +129 -0
  86. package/browser/addons/vrm/documentation/ja-jp/create-simple-vrm-from-scratch/index.md +29 -0
  87. package/browser/addons/vrm/documentation/ja-jp/development/index.md +137 -0
  88. package/browser/addons/vrm/documentation/ja-jp/index.md +37 -0
  89. package/browser/addons/vrm/documentation/ja-jp/installation/index.md +150 -0
  90. package/browser/addons/vrm/documentation/ja-jp/material-mtoon/index.md +113 -0
  91. package/browser/addons/vrm/documentation/ja-jp/material-pbr/index.md +90 -0
  92. package/browser/addons/vrm/documentation/ja-jp/scripting-api/index.md +389 -0
  93. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrm/index.md +55 -0
  94. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrma/index.md +19 -0
  95. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrm/index.md +56 -0
  96. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrma/index.md +14 -0
  97. package/browser/addons/vrm/editor/__init__.py +1 -0
  98. package/browser/addons/vrm/editor/extension.py +425 -0
  99. package/browser/addons/vrm/editor/extension_accessor.py +75 -0
  100. package/browser/addons/vrm/editor/handler.py +84 -0
  101. package/browser/addons/vrm/editor/khr_character/__init__.py +1 -0
  102. package/browser/addons/vrm/editor/khr_character/panel.py +69 -0
  103. package/browser/addons/vrm/editor/khr_character/property_group.py +27 -0
  104. package/browser/addons/vrm/editor/khr_xmp_json_ld/__init__.py +1 -0
  105. package/browser/addons/vrm/editor/khr_xmp_json_ld/ops.py +464 -0
  106. package/browser/addons/vrm/editor/khr_xmp_json_ld/panel.py +136 -0
  107. package/browser/addons/vrm/editor/khr_xmp_json_ld/property_group.py +83 -0
  108. package/browser/addons/vrm/editor/khr_xmp_json_ld/ui_list.py +198 -0
  109. package/browser/addons/vrm/editor/make_armature.py +709 -0
  110. package/browser/addons/vrm/editor/menu.py +147 -0
  111. package/browser/addons/vrm/editor/migration.py +297 -0
  112. package/browser/addons/vrm/editor/mtoon1/__init__.py +1 -0
  113. package/browser/addons/vrm/editor/mtoon1/handler.py +20 -0
  114. package/browser/addons/vrm/editor/mtoon1/migration.py +536 -0
  115. package/browser/addons/vrm/editor/mtoon1/ops.py +304 -0
  116. package/browser/addons/vrm/editor/mtoon1/panel.py +442 -0
  117. package/browser/addons/vrm/editor/mtoon1/property_group.py +4977 -0
  118. package/browser/addons/vrm/editor/mtoon1/scene_watcher.py +381 -0
  119. package/browser/addons/vrm/editor/node_constraint1/__init__.py +1 -0
  120. package/browser/addons/vrm/editor/node_constraint1/panel.py +317 -0
  121. package/browser/addons/vrm/editor/node_constraint1/property_group.py +25 -0
  122. package/browser/addons/vrm/editor/ops.py +471 -0
  123. package/browser/addons/vrm/editor/panel.py +315 -0
  124. package/browser/addons/vrm/editor/property_group.py +888 -0
  125. package/browser/addons/vrm/editor/search.py +682 -0
  126. package/browser/addons/vrm/editor/spring_bone1/__init__.py +1 -0
  127. package/browser/addons/vrm/editor/spring_bone1/handler.py +994 -0
  128. package/browser/addons/vrm/editor/spring_bone1/menu.py +246 -0
  129. package/browser/addons/vrm/editor/spring_bone1/migration.py +86 -0
  130. package/browser/addons/vrm/editor/spring_bone1/ops.py +1679 -0
  131. package/browser/addons/vrm/editor/spring_bone1/panel.py +605 -0
  132. package/browser/addons/vrm/editor/spring_bone1/property_group.py +1457 -0
  133. package/browser/addons/vrm/editor/spring_bone1/ui_list.py +408 -0
  134. package/browser/addons/vrm/editor/subscription.py +123 -0
  135. package/browser/addons/vrm/editor/t_pose.py +713 -0
  136. package/browser/addons/vrm/editor/validation.py +1271 -0
  137. package/browser/addons/vrm/editor/vrm0/__init__.py +1 -0
  138. package/browser/addons/vrm/editor/vrm0/gizmo_group.py +63 -0
  139. package/browser/addons/vrm/editor/vrm0/handler.py +23 -0
  140. package/browser/addons/vrm/editor/vrm0/menu.py +145 -0
  141. package/browser/addons/vrm/editor/vrm0/migration.py +761 -0
  142. package/browser/addons/vrm/editor/vrm0/ops.py +2145 -0
  143. package/browser/addons/vrm/editor/vrm0/panel.py +1333 -0
  144. package/browser/addons/vrm/editor/vrm0/property_group.py +1818 -0
  145. package/browser/addons/vrm/editor/vrm0/ui_list.py +688 -0
  146. package/browser/addons/vrm/editor/vrm1/__init__.py +1 -0
  147. package/browser/addons/vrm/editor/vrm1/handler.py +28 -0
  148. package/browser/addons/vrm/editor/vrm1/menu.py +76 -0
  149. package/browser/addons/vrm/editor/vrm1/migration.py +321 -0
  150. package/browser/addons/vrm/editor/vrm1/ops.py +2679 -0
  151. package/browser/addons/vrm/editor/vrm1/panel.py +1243 -0
  152. package/browser/addons/vrm/editor/vrm1/property_group.py +2087 -0
  153. package/browser/addons/vrm/editor/vrm1/scene_watcher.py +90 -0
  154. package/browser/addons/vrm/editor/vrm1/ui_list.py +533 -0
  155. package/browser/addons/vrm/exporter/__init__.py +1 -0
  156. package/browser/addons/vrm/exporter/abstract_base_vrm_exporter.py +628 -0
  157. package/browser/addons/vrm/exporter/export_scene.py +930 -0
  158. package/browser/addons/vrm/exporter/gltf2_export_user_extension.py +45 -0
  159. package/browser/addons/vrm/exporter/khr_character_exporter.py +34 -0
  160. package/browser/addons/vrm/exporter/vrm0_exporter.py +4020 -0
  161. package/browser/addons/vrm/exporter/vrm1_exporter.py +3682 -0
  162. package/browser/addons/vrm/exporter/vrm_animation_exporter.py +1035 -0
  163. package/browser/addons/vrm/external/__init__.py +1 -0
  164. package/browser/addons/vrm/external/auto_rig_pro_support.py +3 -0
  165. package/browser/addons/vrm/external/io_scene_gltf2_support.py +418 -0
  166. package/browser/addons/vrm/external/rigify_support.py +1 -0
  167. package/browser/addons/vrm/importer/__init__.py +1 -0
  168. package/browser/addons/vrm/importer/abstract_base_vrm_importer.py +1548 -0
  169. package/browser/addons/vrm/importer/file_handler.py +34 -0
  170. package/browser/addons/vrm/importer/gltf2_import_user_extension.py +268 -0
  171. package/browser/addons/vrm/importer/import_scene.py +646 -0
  172. package/browser/addons/vrm/importer/license_validation.py +198 -0
  173. package/browser/addons/vrm/importer/vrm0_importer.py +1723 -0
  174. package/browser/addons/vrm/importer/vrm1_importer.py +2014 -0
  175. package/browser/addons/vrm/importer/vrm_animation_importer.py +763 -0
  176. package/browser/addons/vrm/importer/vrm_diff.py +316 -0
  177. package/browser/addons/vrm/locale/__init__.py +1 -0
  178. package/browser/addons/vrm/locale/ja_jp.py +826 -0
  179. package/browser/addons/vrm/locale/ruff.toml +8 -0
  180. package/browser/addons/vrm/locale/translation_dictionary.py +10 -0
  181. package/browser/addons/vrm/locale/zh_hans.py +928 -0
  182. package/browser/addons/vrm/registration.py +670 -0
  183. package/browser/addons/vrm.json +18 -0
  184. package/browser/blender-emscripten-engine.mts +60 -128
  185. package/browser/blender-engine.mts +20 -186
  186. package/browser/blender-wali-engine.mts +35 -81
  187. package/browser/describe-thrown.mts +3 -6
  188. package/browser/document-chunks.mts +4 -16
  189. package/browser/gltf_export.py +3161 -0
  190. package/browser/heap-growth.mts +23 -0
  191. package/browser/index.ts +2 -0
  192. package/browser/protocol.ts +119 -35
  193. package/browser/pull-job.mts +2 -10
  194. package/browser/rna.ts +190 -73
  195. package/browser/runtime.ts +197 -82
  196. package/browser/session-addons.mts +33 -0
  197. package/browser/session-frame.mts +5 -24
  198. package/browser/session.py +1338 -1427
  199. package/browser/three/attach-presenter.ts +11 -39
  200. package/browser/three/blender-base64.ts +1 -36
  201. package/browser/three/blender-corner-normals.ts +1 -23
  202. package/browser/three/blender-display-shaders.json +1 -1
  203. package/browser/three/blender-display-shaders.lut +0 -0
  204. package/browser/three/blender-display-transform.ts +2 -1
  205. package/browser/three/blender-frame-columns.ts +5 -13
  206. package/browser/three/blender-graph-material.ts +18 -3
  207. package/browser/three/blender-node-graph.ts +10 -34
  208. package/browser/three/blender-physical-material.ts +2 -31
  209. package/browser/three/blender-runtime-armature.ts +7 -60
  210. package/browser/three/blender-runtime-frame.ts +9 -23
  211. package/browser/three/blender-runtime-geometry.ts +22 -26
  212. package/browser/three/blender-runtime-lighting.ts +48 -178
  213. package/browser/three/blender-runtime-shadows.ts +187 -1
  214. package/browser/three/blender-runtime-skeleton.ts +53 -1
  215. package/browser/three/blender-runtime-view.ts +505 -283
  216. package/browser/three/blender-runtime-weights.ts +9 -72
  217. package/browser/three/blender-sky.ts +13 -33
  218. package/browser/three/blender-triangulate.ts +7 -35
  219. package/browser/three/blender-viewport-sampling.ts +235 -0
  220. package/browser/three/blender-workbench-material.ts +25 -29
  221. package/browser/three/blender-workbench-outline.ts +199 -0
  222. package/browser/three/blender-world-volume.ts +62 -10
  223. package/browser/three/presenter.ts +10 -26
  224. package/browser/three/release.ts +5 -21
  225. package/browser/three/sky-precompute-worker.ts +1 -18
  226. package/browser/three/sky-worker.ts +5 -23
  227. package/browser/three/world-field-sampler.ts +12 -26
  228. package/browser/volter_three.py +9 -105
  229. package/browser/worker.ts +156 -151
  230. package/package.json +5 -2
  231. package/wasm/BUNDLE.json +17 -17
  232. package/wasm/DEPENDENCY-LICENSES.txt +14 -0
  233. package/wasm/blender_browser.data.br +0 -0
  234. package/wasm/blender_browser.js +1 -1
  235. package/wasm/blender_browser.wasm.br +0 -0
  236. package/browser/frame-transfer.mts +0 -19
  237. package/browser/three/agx-base-srgb.lut +0 -0
  238. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  239. package/browser/three/agx-look-punchy.lut +0 -0
  240. package/browser/three/blender-agx.ts +0 -235
  241. package/browser/three/blender-display-lut.ts +0 -145
  242. package/browser/three/blender-filmic.ts +0 -49
  243. package/browser/three/blender-standard.ts +0 -68
  244. package/browser/three/blender-world-visibility.ts +0 -278
  245. package/browser/three/filmic-srgb.lut +0 -0
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "VRM Add-on for Blender",
3
+ "addon": "VRM format",
4
+ "version": "4.10.3",
5
+ "repository": "https://github.com/saturday06/VRM-Addon-for-Blender",
6
+ "release": "v4.10.3",
7
+ "commit": "8c0fd6aaef3925b125cf35208cee2458e257164c",
8
+ "archive": {
9
+ "name": "VRM_Addon_for_Blender-4_10_3.zip",
10
+ "url": "https://github.com/saturday06/VRM-Addon-for-Blender/releases/download/v4.10.3/VRM_Addon_for_Blender-4_10_3.zip",
11
+ "sha256": "dcf6d55f3d28de8c0de2f72abcc7fe06b2b300195e6b4da2e9cf476fd59165a2",
12
+ "root": "VRM_Addon_for_Blender-release"
13
+ },
14
+ "blender": { "min": "4.2.0", "maxExclusive": "5.3.0" },
15
+ "license": "MIT OR GPL-3.0-or-later",
16
+ "conveyedUnder": "GPL-3.0-or-later",
17
+ "copyright": ["2018 iCyP", "2022 saturday06", "2020 Isamu Mogi"]
18
+ }
@@ -1,33 +1,7 @@
1
- /**
2
- * The STANDALONE skew: Blender in this worker's own WebAssembly module.
3
- *
4
- * `blender-engine.mts` is the seam and says how the skew is chosen; this is
5
- * one of its two implementations, and the one `@volter/editor-blender` ships. It has no
6
- * in-house dependency of any kind and reaches nothing but the editor's own
7
- * routes.
8
- *
9
- * THREE THINGS THE BUILD REQUIRES, each measured rather than assumed:
10
- *
11
- * 1. CROSS-ORIGIN ISOLATION. The build is `-sPROXY_TO_PTHREAD` with pthreads,
12
- * so it needs `SharedArrayBuffer`. The editor's dev server already sets
13
- * `Cross-Origin-Opener-Policy: same-origin` and
14
- * `Cross-Origin-Embedder-Policy: credentialless` ahead of every route
15
- * (`packages/editor/server/dev.ts`) — `credentialless` rather than
16
- * `require-corp` precisely so cross-origin images and iframes keep loading.
17
- * A worker without `crossOriginIsolated` is refused BY NAME here rather
18
- * than left to fail inside the runtime.
19
- *
20
- * 2. THE GLUE IS PATCHED SERVER-SIDE, not here, because the runtime spawns its
21
- * pthreads by loading that same URL as a classic worker
22
- * (`Module.mainScriptUrlOrBlob`). See `blender-wasm-artifact.ts`.
23
- *
24
- * 3. FILES ARE STAGED WITH `FS_createDataFile`, before the runtime starts.
25
- * This is a WasmFS build: `Module.FS` exists and works from this thread,
26
- * but its preload directories come out non-writable, which is why
27
- * `session.py` chmods its own root as its first act.
28
- */
1
+
29
2
 
30
3
  import { checkpointStream } from './pull-job.mts';
4
+ import { watchHeapGrowthSource } from './heap-growth.mts';
31
5
  import { writeStreamedFile, type StreamFileSystem } from './stream-file.mts';
32
6
  import {
33
7
  artifactUrl,
@@ -44,6 +18,9 @@ import {
44
18
  sleep,
45
19
  } from './blender-engine.mts';
46
20
  import sessionPython from './session.py?raw';
21
+ // Imported by session.py’s gltf-export operation from the same directory.
22
+ import gltfExportPython from './gltf_export.py?raw';
23
+ import { sessionAddonFiles } from './session-addons.mts';
47
24
 
48
25
  /** What the module factory is, in the only shape this file uses. */
49
26
  interface BlenderModule {
@@ -59,13 +36,8 @@ interface BlenderModule {
59
36
  ENV: Record<string, string>;
60
37
  /** The export door's arena, read by `readArena`. */
61
38
  HEAPU8: Uint8Array;
62
- /**
63
- * Hand back the packed `.data` payload, once. Linked in by the bundle's
64
- * `--post-js` (the spike's `recipe/release-preloaded-file-data.js`, whose
65
- * header carries the measurement); absent means the module predates it,
66
- * which is why the call below is guarded and says so.
67
- */
68
- releasePreloadedFileData?(): { files: number; bytes: number };
39
+ /** Release the packed preload payload after WasmFS has copied its files into linear memory. */
40
+ releasePreloadedFileData(): { files: number; bytes: number };
69
41
  _blender_web_export_buffer(): number;
70
42
  _blender_web_export_buffer_size(): number;
71
43
  FS_createPath(parent: string, name: string, canRead: boolean, canWrite: boolean): void;
@@ -100,6 +72,19 @@ async function loadFactory(glueUrl: string): Promise<BlenderModuleFactory> {
100
72
  return factory;
101
73
  }
102
74
 
75
+ /** Report uncaught pthread stacks through Module.printErr before the error crosses to the parent worker. Wasm function indices can be resolved with a symbol map from the same build. */
76
+ function pthreadEntry(glueUrl: string): string {
77
+ const source = `${watchHeapGrowthSource("(line) => postMessage({ cmd: 9, handler: 'printErr', args: [line] })")}
78
+ self.addEventListener('error', (event) => {
79
+ const error = event.error;
80
+ const stack = error && error.stack ? String(error.stack) : String(event.message) + ' at ' + event.filename + ':' + event.lineno + ':' + event.colno;
81
+ try { postMessage({ cmd: 9, handler: 'printErr', args: ['Blender thread crashed: ' + stack] }); } catch (_) {}
82
+ });
83
+ importScripts(${JSON.stringify(new URL(glueUrl, self.location.href).href)});
84
+ `;
85
+ return URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
86
+ }
87
+
103
88
  const utf8 = new TextEncoder();
104
89
 
105
90
  function mkdirp(module: BlenderModule, directory: string): void {
@@ -117,7 +102,11 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
117
102
  return {
118
103
  readFile: async (path) => FS.readFile(path),
119
104
  // Not `async`: a write that throws throws here, so `request` never starts `.done` after a failed `.json`.
120
- writeFile: (path, data) => { FS.writeFile(path, data); return Promise.resolve(); },
105
+ writeFile: (path, data) => {
106
+ try { FS.unlink(path); } catch { /* nothing there to replace */ }
107
+ FS.writeFile(path, data);
108
+ return Promise.resolve();
109
+ },
121
110
  writeFileStream: (path, body, size) => writeStreamedFile(FS, path, body, size),
122
111
  mkdirTree: async (path) => FS.mkdirTree(path),
123
112
  readdir: async (path) => FS.readdir(path),
@@ -139,28 +128,7 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
139
128
  };
140
129
  }
141
130
 
142
- /**
143
- * BLENDER'S ARTIFACTS ARE FETCHED ONCE PER BUILD -- WHERE THE SERVER RUNS IN THE TAB. The wasm
144
- * (86 MB decoded), the `.data` package (53 MB) and the Essentials payload are immutable for a
145
- * build, and a page whose editor server runs in its own tab (browser-substrate; the door says so
146
- * as `status.inTab`) keeps them in Cache Storage under the digest the door reports, so a later
147
- * open reads them at the browser's own speed instead of from that server. Measured there: the
148
- * three took 7.2 s per open (4.2 s for the wasm alone), about half of it decoding their brotli
149
- * in the tab. A file the door gives no digest for is fetched as it always was.
150
- *
151
- * NOT WHERE THE SERVER IS A PROCESS ON THIS MACHINE. There the door answers from local disk over
152
- * loopback with `no-cache` and an ETag, brotli as stored to a client that accepts it
153
- * (`editor-blender/serving/blender-routes.ts`), so the browser's HTTP cache keeps the bytes as
154
- * sent (~38 MB) and a repeat open is a 304 and the browser's own decode. Cache Storage there
155
- * held a second, decoded copy (~144 MiB) per origin, and an editor origin is per project folder
156
- * (`editor-<id>.localhost:<port>`, before 0.5.190 `127.0.0.1:<port>`): measured 2026-10-07 in
157
- * one Chrome profile, 113 origins held ~16.3 GB. The HTTP cache is size-bounded and evicted by the browser; Cache Storage is
158
- * kept until the origin deletes it. So such a page fetches through the HTTP cache and releases
159
- * any copy an earlier build left in its origin ({@link releaseArtifactCache}).
160
- *
161
- * The cache was never made persistent (nothing here calls `navigator.storage.persist`), so it is
162
- * best-effort storage Chrome may evict under storage pressure; it stays that way.
163
- */
131
+ /** Cache immutable artifacts by digest when the server runs in the tab. Process-backed sessions use the browser HTTP cache and release this origin’s artifact-cache copies. Cache Storage remains best-effort storage. */
164
132
  const ARTIFACT_CACHE = 'volter-blender-artifacts';
165
133
 
166
134
  /**
@@ -179,12 +147,7 @@ function releaseArtifactCache(note: (text: string) => void): void {
179
147
  );
180
148
  }
181
149
 
182
- /**
183
- * THE CACHE SPEEDS AN OPEN UP; IT NEVER DECIDES WHETHER BLENDER OPENS. A Cache Storage that cannot
184
- * open or answer (measured 2026-10-07, headless Chrome on Windows: `caches.open` threw "Unexpected
185
- * internal error", and the model failed to open on it) means the artifact is fetched from the editor
186
- * as it would be with no digest; bytes that cannot be kept are used anyway. `note` says which, per artifact, as page output (not an unresolved console entry).
187
- */
150
+ /** Fetch from the editor when Cache Storage cannot answer; report cache failures as page output without preventing Blender from opening. */
188
151
  async function cachedArtifact(file: string, digest: string | undefined, checkpoint: () => Promise<void>, note: (text: string) => void): Promise<Response> {
189
152
  const bounded = (response: Response): Response => response.ok && response.body
190
153
  ? new Response(checkpointStream(response.body, checkpoint), { status: response.status, headers: response.headers })
@@ -223,11 +186,12 @@ export async function startEmscriptenBlenderEngine(
223
186
  options: BlenderEngineOptions,
224
187
  status: BlenderArtifactStatus,
225
188
  ): Promise<BlenderEngine> {
226
- if (!self.crossOriginIsolated)
189
+ if (!self.crossOriginIsolated || typeof SharedArrayBuffer === 'undefined')
227
190
  throw new Error(
228
- 'Headless Blender needs SharedArrayBuffer, so the editor page has to be cross-origin ' +
229
- 'isolated (Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy on ' +
230
- 'every response). This worker reports crossOriginIsolated=false.',
191
+ 'Blender needs browser isolation and shared memory that this page does not provide. ' +
192
+ 'Open this address in a current desktop Chrome or Edge. If this message remains there, ' +
193
+ 'the site must serve Cross-Origin-Opener-Policy: same-origin and a ' +
194
+ 'Cross-Origin-Embedder-Policy on every response before Blender can start.',
231
195
  );
232
196
  const glueUrl = artifactUrl('blender_browser.js');
233
197
  // Page output, not an editor-console condition: nothing in the project can resolve the browser's cache.
@@ -241,36 +205,26 @@ export async function startEmscriptenBlenderEngine(
241
205
  const artifact = (file: string) => cachedArtifact(file, digests[file], () => checkpoint(`artifact/${file}`), noteCache);
242
206
  // The `.data` package is handed to the glue whole (`getPreloadedPackage`),
243
207
  // so it is read before the module starts; the wasm streams in beside it.
244
- const [factory, preloaded] = await Promise.all([
208
+ const [factory, preloaded, addonFiles] = await Promise.all([
245
209
  loadFactory(glueUrl),
246
210
  artifact('blender_browser.data').then(async (response) => {
247
211
  const url = artifactUrl('blender_browser.data');
248
212
  if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
249
213
  return startupOperation(url, 'read data package body', () => response.arrayBuffer());
250
214
  }),
215
+ sessionAddonFiles(),
251
216
  ]);
252
217
  await checkpoint('runtime-package-ready');
253
218
  let bootError: unknown = null;
254
219
  const started = performance.now();
255
220
  let readyLine: string | null = null;
256
- // BLENDER'S OWN STREAMS ARE PAGE OUTPUT, NOT EDITOR-CONSOLE CONDITIONS.
257
- // Measured on the first live run: routing `printErr` at `error` put every
258
- // ordinary startup line -- the WebGPU preinit notice, a Python
259
- // DeprecationWarning, the ready banner itself -- into the session's
260
- // unresolved console set, which is the set an agent must drive to zero. The
261
- // editor's console door is for conditions somebody must resolve, so only the
262
- // session's OWN named conditions go there.
221
+ // Route Blender’s stdout and stderr to page output; session conditions use their own reporting channel.
263
222
  const say = (_level: 'log' | 'error', text: string) => {
264
223
  if (text.startsWith('@@VOLTER-READY ')) readyLine = text.slice('@@VOLTER-READY '.length);
265
224
  options.log(sessionLevel(text), text);
266
225
  };
267
- // THE HEAP ITSELF, not a view of it. Blender runs on a pthread, so when its heap grows there
268
- // this worker's `module.HEAPU8` keeps the length it had until the glue next refreshes it -- and
269
- // no export the door calls does. MEASURED 2026-09-29 on the Stoneguard scene from
270
- // game-benchmarks main: a mesh's columns at 1.14 GB were read through a 433 MB view, and the
271
- // pull failed "outside the export arena". The glue builds its memory with `new
272
- // WebAssembly.Memory` and exports no handle to it, so that constructor is observed while the
273
- // factory runs; every read below takes a fresh view of `memory.buffer`.
226
+ // Observe the factory’s WebAssembly.Memory and read a fresh buffer view after pthread heap growth. Module.HEAPU8 can retain its previous length until the glue refreshes it.
227
+ new Function('report', watchHeapGrowthSource('report'))((line: string) => say('error', line));
274
228
  const Memory = WebAssembly.Memory;
275
229
  let memory: WebAssembly.Memory | null = null;
276
230
  (WebAssembly as { Memory: unknown }).Memory = function (descriptor: WebAssembly.MemoryDescriptor) {
@@ -294,8 +248,9 @@ export async function startEmscriptenBlenderEngine(
294
248
  })().catch((error: unknown) => { bootError = error; });
295
249
  return {};
296
250
  },
297
- // The pthreads load the SAME patched glue this worker just evaluated.
298
- mainScriptUrlOrBlob: glueUrl,
251
+ // The pthreads load the SAME patched glue this worker just evaluated, behind a line that sends
252
+ // a crashing thread's own stack here (`pthreadEntry`).
253
+ mainScriptUrlOrBlob: pthreadEntry(glueUrl),
299
254
  print: (text: string) => say('log', text),
300
255
  printErr: (text: string) => say('error', text),
301
256
  preRun: [
@@ -334,6 +289,12 @@ export async function startEmscriptenBlenderEngine(
334
289
  true,
335
290
  true,
336
291
  );
292
+ mod.FS_createDataFile(SESSION_ROOT, 'gltf_export.py', utf8.encode(gltfExportPython), true, true, true);
293
+ for (const { path, bytes } of addonFiles) {
294
+ const at = path.lastIndexOf('/');
295
+ mkdirp(mod, path.slice(0, at));
296
+ mod.FS_createDataFile(path.slice(0, at), path.slice(at + 1), bytes, true, true, true);
297
+ }
337
298
  },
338
299
  ],
339
300
  }).finally(() => {
@@ -349,43 +310,18 @@ export async function startEmscriptenBlenderEngine(
349
310
  const bootMs = performance.now() - started;
350
311
  const banner = JSON.parse(readyLine) as { blender: string; engines: string[] };
351
312
 
352
- // THE PAYLOAD EXISTS TWICE UNTIL THIS CALL. `--preload-file` reads the whole
353
- // `.data` package into one ArrayBuffer, WasmFS copies every file out of it
354
- // into the wasm heap during its own init, and then emscripten keeps the
355
- // subarrays forever in `wasmFSPreloadedFiles` -- so a booted session holds
356
- // ~55 MB of JS-side bytes that are already in linear memory and will never
357
- // be read again. Measured 2026-09-18: Node's `arrayBuffers` sat at 55 MB
358
- // from `factory resolved` through every later call, and two forced GCs did
359
- // not move it. Here is the one moment it is safe to drop: WasmFS flushed
360
- // long before the ready line, and anything staged after this takes
361
- // `FS_createDataFile`'s post-flush branch, which never consults the array.
362
- let releasedPayloadBytes: number | null = null;
363
- if (typeof module.releasePreloadedFileData === 'function') {
364
- const released = module.releasePreloadedFileData();
365
- releasedPayloadBytes = released.bytes;
366
- options.log(
367
- 'log',
368
- `blender: released the preloaded ${(released.bytes / 1048576).toFixed(0)} MB ` +
369
- `payload (${released.files} files); WasmFS already holds it`,
370
- );
371
- } else {
372
- options.log(
373
- 'error',
374
- 'blender: this bundle has no releasePreloadedFileData, so the ~55 MB packed ' +
375
- '.data payload stays resident beside the copy WasmFS made of it — relink with ' +
376
- "the recipe's --post-js (recipe/release-preloaded-file-data.js).",
377
- );
378
- }
313
+ // WasmFS has copied the preload package before the ready line. Release its redundant JS buffers before staging project files through the post-flush filesystem path.
314
+ const released = module.releasePreloadedFileData();
315
+ const releasedPayloadBytes = released.bytes;
316
+ options.log(
317
+ 'log',
318
+ `blender: released the preloaded ${(released.bytes / 1048576).toFixed(0)} MB ` +
319
+ `payload (${released.files} files); WasmFS already holds it`,
320
+ );
379
321
 
380
322
  const FS = module.FS;
381
323
 
382
- // A DIRECTORY MADE BY THE PRELOAD DOOR IS NOT WRITABLE. `FS_createPath`
383
- // ignores its `canWrite` argument in this build, so the project root and
384
- // everything above it come out read-only and the first `mkdirTree` under
385
- // them raises `ErrnoError` -- measured on the first live round trip
386
- // (2026-09-17: `stageProjectFiles` threw before any project file was
387
- // staged). The session chmods its own root from Python for the same reason;
388
- // this is the other half, for the paths Python never touches.
324
+ // FS_createPath ignores canWrite in this build. Make preloaded ancestors writable before staging project files.
389
325
  let climb = '';
390
326
  for (const part of `${options.project}`.split('/').filter(Boolean)) {
391
327
  climb += `/${part}`;
@@ -413,12 +349,7 @@ export async function startEmscriptenBlenderEngine(
413
349
  banner,
414
350
  files,
415
351
  request,
416
- // A VIEW, NOT A COPY. The arena is wasm linear memory and under
417
- // `-sPROXY_TO_PTHREAD` that memory is SHARED, so `session-frame.mts`
418
- // slices every column it reads out of here; this call itself is free.
419
- // A DIRECT ARENA (address 0) IS THE WHOLE HEAP, at its length now: the size the door reports
420
- // is `emscripten_get_heap_size()`, which is Blender's pthread's own view of the heap and is as
421
- // stale as this worker's was (measured: 431 MB while a column lay at 2.0 GB).
352
+ // Return a view of shared wasm memory; session-frame.mts copies the transferred columns. A direct arena spans the current memory buffer.
422
353
  readArena: async () => {
423
354
  const base = module._blender_web_export_buffer();
424
355
  return base === 0 ? heap() : heap().subarray(base, base + module._blender_web_export_buffer_size());
@@ -429,9 +360,10 @@ export async function startEmscriptenBlenderEngine(
429
360
  };
430
361
  }
431
362
 
432
- /** Assets are data, not a second engine. Ship them separately so a data update
433
- * does not relink the 86 MB Wasm binary. Both payload and per-file bounds are
434
- * checked before anything enters Blender's filesystem. */
363
+ /**
364
+ * Load assets separately from the engine so data updates do not require relinking Wasm. Check
365
+ * payload and per-file bounds before writing Blender’s filesystem.
366
+ */
435
367
  async function mountEssentials(files: BlenderFiles, digest: string | undefined, checkpoint: (phase: string) => Promise<void>, noteCache: (text: string) => void): Promise<void> {
436
368
  const indexUrl = artifactUrl('essentials.json');
437
369
  const payloadUrl = artifactUrl('essentials.bin');
@@ -1,47 +1,4 @@
1
- /**
2
- * Headless Blender, running in the editor tab's worker -- THE SEAM, and the
3
- * choice of which Blender answers it.
4
- *
5
- * THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
6
- * 2026-09-17), and since the 2026-09-18 amendment there are TWO BUILDS of it
7
- * and neither retires the other:
8
- *
9
- * emscripten -- the STANDALONE skew. Blender in this worker's own module,
10
- * `@volter/editor-blender` with zero in-house dependencies. What the
11
- * package ships under `wasm/`.
12
- * wali -- the SUBSTRATE skew. The same Blender linked for
13
- * `wasm32-wali-linux-musl` and run as an ordinary Linux
14
- * program through `@volter/browser-wali`: argv, stdio, an
15
- * exit code, syscalls, a filesystem.
16
- *
17
- * ONE GATE, BOTH SKEWS: the scene battery passes on each at the same counts,
18
- * from the same `session.py`. A skew may REFUSE a capability by name; it may
19
- * not answer the same question differently. So this file is where the two
20
- * meet and everything above it -- `worker.ts`, `session-frame.mts`,
21
- * `blender-runtime-host.ts`, the presenter, the battery, the document -- is
22
- * written once.
23
- *
24
- * HOW THE SKEW IS CHOSEN: IT IS NOT. It is READ off the artifact this editor
25
- * serves, through the door that already exists for "is there a Blender here at
26
- * all" (`/__editor/blender-wasm/status`, `blender-wasm-artifact.ts`). One
27
- * editor serves one Blender; the served directory holds either a
28
- * `blender_browser.js` bundle or a `blender.wasm` beside its `runtime/` tree,
29
- * and that IS the answer. There is no manifest field, no setting and no flag,
30
- * because nothing a user does should depend on the skew -- if it did, the
31
- * skews would be answering differently, which the ruling forbids.
32
- * `VOLTER_BLENDER_WASM_DIR` is the existing door for pointing the editor at a
33
- * different build and is how the substrate skew is reached in development.
34
- *
35
- * WHAT DIFFERS, and it is four things, all below this line:
36
- * 1. how the module is booted (a `--post-js` glue factory / a WALI program);
37
- * 2. where the session's `in`/`out`/`ask` files live (`Module.FS` / the
38
- * program's filesystem);
39
- * 3. how the export door's arena is read (`HEAPU8` / the file
40
- * `export_frame`'s `buffer_path` names -- see `readArena`);
41
- * 4. cross-origin isolation, which only the Emscripten build asks of the page.
42
- * Everything else, including the request/ask protocol below, is one
43
- * implementation over {@link BlenderFiles}.
44
- */
1
+
45
2
 
46
3
  import { describeThrown } from './describe-thrown.mts';
47
4
 
@@ -49,22 +6,16 @@ export const SESSION_ROOT = '/work/.volter-session';
49
6
  export const SESSION_SCRIPT = `${SESSION_ROOT}/session.py`;
50
7
 
51
8
  /**
52
- * THE TWO CHANNEL DIRECTORIES THIS PAGE OWNS, and the reason they are a
53
- * constant instead of two literals per engine.
54
- *
55
- * ONE WRITER PER DIRECTORY is the channel's ownership rule (see
56
- * {@link openSessionChannel}), and a directory's owner is also the side that
57
- * CREATES it: `in/` and `reply/` are made here, before the program starts;
58
- * `out/` and `ask/` are made by `session.py`, which owns them. An engine that
59
- * pre-created all four put a guest `mkdir`/`chmod` on a path the page had
60
- * already touched, which is the same crossing edit at a smaller scale.
9
+ * The page creates and writes the in/ and reply/ directories before the program starts. session.py
10
+ * creates and writes out/ and ask/. Keep one writer per directory so snapshot-backed filesystem
11
+ * patches do not cross another writer’s edits.
61
12
  */
62
13
  export const PAGE_OWNED_DIRECTORIES = [`${SESSION_ROOT}/in`, `${SESSION_ROOT}/reply`] as const;
63
14
 
64
15
  /** Which build of Blender this editor serves. */
65
- export type BlenderSkew = 'emscripten' | 'wali';
16
+ type BlenderSkew = 'emscripten' | 'wali';
66
17
 
67
- export interface BlenderFileStat {
18
+ interface BlenderFileStat {
68
19
  size: number;
69
20
  /** The raw mode, S_IFDIR and all: `listModuleFiles` reads the type bits. */
70
21
  mode: number;
@@ -78,14 +29,11 @@ export interface BlenderFileStat {
78
29
  * a `BrowserFileSystem` whose bytes are a promise by contract. The Emscripten
79
30
  * side answers from `Module.FS` and resolves immediately.
80
31
  *
81
- * `stat` ANSWERS NULL FOR AN ABSENT PATH rather than throwing. The poll loop
82
- * asks "is the answer there yet" thousands of times per call, and the version
83
- * of this that threw sorted a genuine failure from an expected absence by
84
- * matching `/ENOENT|no such|FS error|errno/i` against the message -- a filter
85
- * that swallows any real error whose text happens to say "errno".
32
+ * `stat` returns null for an absent path and propagates other filesystem failures.
86
33
  */
87
34
  export interface BlenderFiles {
88
35
  readFile(path: string): Promise<Uint8Array>;
36
+ /** Replace the contents at path, including when the file already exists. */
89
37
  writeFile(path: string, data: string | Uint8Array): Promise<void>;
90
38
  /** Stream a known-size file into the engine without a second whole-file JS buffer.
91
39
  * A failed transfer leaves the previous file intact. */
@@ -95,17 +43,7 @@ export interface BlenderFiles {
95
43
  /** Null when the path is not there. */
96
44
  stat(path: string): Promise<BlenderFileStat | null>;
97
45
  unlink(path: string): Promise<void>;
98
- /**
99
- * Resolves at the next change to this filesystem or after `within` ms,
100
- * whichever is first, when the engine can say so. The call loop waits on it
101
- * in place of its poll's sleep, so an answer is read when it lands rather
102
- * than at the next timer: a browser clamps a chain of `setTimeout(1)` to
103
- * 4 ms, which was most of a no-op call's return leg (measured 2026-09-28:
104
- * 6-7 ms of 8-10). Each wait is its own and is gone when it resolves; racing
105
- * one shared promise against every poll's sleep kept a reaction per poll
106
- * until the next change (review of d36c8004: 43 MB in an hour-long call).
107
- * Without it the loop sleeps as before.
108
- */
46
+ /** Resolve on the next filesystem change or after within milliseconds. Each wait releases its own subscription when it settles; without this capability, the call loop polls. */
109
47
  changed?(within: number): Promise<void>;
110
48
  }
111
49
 
@@ -128,7 +66,7 @@ export interface BlenderEngineOptions {
128
66
  * each of them (`mesh` or `image`, with its `piece`), then the frame again with `present`, which is
129
67
  * answered as a whole frame is.
130
68
  */
131
- export interface SessionAsk {
69
+ interface SessionAsk {
132
70
  evaluation?: 'render';
133
71
  checkpoint?: string;
134
72
  frame?: unknown;
@@ -142,9 +80,7 @@ export interface SessionAsk {
142
80
  }
143
81
 
144
82
  export interface BlenderEngine {
145
- /** Which build answered. Reported out of `blender-start`, because a number
146
- * that differs between the skews is a defect in one of them and the board
147
- * has to be able to say which one it measured. */
83
+ /** The engine build identity reported by blender-start. */
148
84
  readonly skew: BlenderSkew;
149
85
  readonly banner: { blender: string; engines: string[] };
150
86
  /** The session's filesystem. */
@@ -193,22 +129,7 @@ export function sessionLevel(line: string): 'log' | 'warn' | 'error' {
193
129
  return 'log';
194
130
  }
195
131
 
196
- /**
197
- * An artifact URL, resolved against THE MODULE — never against `self.location`.
198
- *
199
- * A worker's `location` is where its SCRIPT was loaded from, and that is not
200
- * always an http url of this editor: when the page is a frame this editor does
201
- * not own (the Code-OSS workbench, docs/CODE-OSS.md §Boot, DESKTOP), a
202
- * cross-origin worker can only be constructed through a same-origin `blob:`
203
- * doorway, so `self.location.href` reads `blob:vscode-file://vscode-app/<uuid>`
204
- * — an opaque base against which `new URL('/__editor/...', base)` THROWS
205
- * `TypeError: Invalid URL`. Measured 2026-09-19: that is what `blender-start`
206
- * answered on the desktop shape once its message reached the worker at all.
207
- *
208
- * `import.meta.url` is this module's own url and is always the http one the
209
- * editor served, whatever loaded the worker. Every artifact url is built from
210
- * it, and nothing here reads `self.location`.
211
- */
132
+ /** Resolve artifacts against import.meta.url. Cross-origin workers may run through a blob URL, which cannot resolve the editor’s artifact paths. */
212
133
  export function artifactUrl(file: string): string {
213
134
  return new URL(`${ARTIFACT_BASE}/${file}`, import.meta.url).href;
214
135
  }
@@ -239,7 +160,7 @@ export interface BlenderArtifactStatus {
239
160
  /** Emscripten: each file's SHA-256 as it decodes, the key a page keeps it under. */
240
161
  digests?: Record<string, string>;
241
162
  /** Emscripten: the server answering runs in this tab (browser-substrate). Only then are the
242
- * digests used to keep the files in Cache Storage; absent (an older server) means false. */
163
+ * digests used to keep the files in Cache Storage; absent means false. */
243
164
  inTab?: boolean;
244
165
  /** WALI only: the `sha256-` SRI of `blender.wasm`, which the program loader
245
166
  * requires for any non-blob URL. */
@@ -249,7 +170,7 @@ export interface BlenderArtifactStatus {
249
170
  workers?: { pool: number; blender: number };
250
171
  }
251
172
 
252
- export async function artifactStatus(): Promise<BlenderArtifactStatus> {
173
+ async function artifactStatus(): Promise<BlenderArtifactStatus> {
253
174
  const url = artifactUrl('status');
254
175
  const answer = await startupOperation(url, 'fetch status', () => fetch(url));
255
176
  if (!answer.ok)
@@ -303,83 +224,11 @@ export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve
303
224
 
304
225
  const decoder = new TextDecoder();
305
226
 
306
- /**
307
- * THE DIRECTORY CHANNEL, written once for both skews.
308
- *
309
- * `session.py`'s header states the other half and says why it is a directory
310
- * rather than stdin. Nothing here is toolchain-specific: a request is
311
- * `in/<id>.json` then `in/<id>.done`, an answer is `out/<id>.json` +
312
- * `out/<id>.done`, and an `ask` the session raises mid-call is served from
313
- * inside this poll loop -- which is why `saveDocument` cannot be raised from
314
- * Python's idle loop (`worker.ts` holds that clock).
315
- *
316
- * ONE WRITER PER DIRECTORY. This is the channel's single structural rule and
317
- * it is what keeps the WALI skew alive for a whole session:
318
- *
319
- * in/ the PAGE writes and the PAGE unlinks; the guest only reads.
320
- * out/ the GUEST writes and the GUEST unlinks; the page only reads.
321
- * ask/ the GUEST writes and the GUEST unlinks; the page only reads.
322
- * reply/ the PAGE writes and the PAGE unlinks; the guest only reads.
323
- *
324
- * WHY IT IS STRUCTURAL AND NOT A TUNING. Under WALI the two sides are two
325
- * snapshot-backed views of one tree, reconciled by patches: the guest sends
326
- * the paths IT changed, and `applyProcessFilesystemPatch` refuses the patch
327
- * (`assertPatchBase`) when a path in it no longer holds what the guest's base
328
- * said -- after which the host TERMINATES the program's worker. A path can
329
- * only disagree if somebody other than the patch's author moved it. So with
330
- * the sets of paths the two sides mutate DISJOINT, the assertion has nothing
331
- * left to fire on. MEASURED 2026-09-19 (WS-X): with this page unlinking four
332
- * files in the guest's own `out/` after every answer, a render loop died at
333
- * call 37 (`base`: a file, `current`: null) and at call 14 (a different
334
- * mtime), and narrowing the window moved the death 1 -> 13 -> 37 without
335
- * closing it.
336
- *
337
- * THE ACK IS THE REQUEST FILE'S DISAPPEARANCE, so it costs no file and no
338
- * extra write. Each side needs to know when the other has taken what it left:
339
- *
340
- * - this page unlinks `in/<id>.json` and then `in/<id>.done` ONLY after it
341
- * has read `out/<id>.json`. So `in/<id>.done` being gone IS "the page
342
- * took the answer" -- a fact the guest reads out of a directory it never
343
- * writes to, from the `listdir` its loop already does. It then removes
344
- * its own `out/<id>.*`.
345
- * - the session unlinks `ask/<id>.json` and then `ask/<id>.done` only after
346
- * it has read `reply/<id>.json`, so `ask/<id>.done` being gone is "the
347
- * guest took the reply" -- read here out of the `readdir` `serveAsks`
348
- * already does, after which this page removes its own `reply/<id>.*`.
349
- *
350
- * `.done` IS ALWAYS UNLINKED LAST, on both sides, because `.done` is the
351
- * token the other side watches: a `.done` without its `.json` would be a
352
- * half-visible message.
353
- *
354
- * NOTHING HERE CATCHES ITS OWN UNLINK. These are files this page wrote into a
355
- * directory nothing else writes to; an ENOENT would mean the ownership rule
356
- * had been broken, and the loud failure of the call that found it is the
357
- * proof the rule holds.
358
- */
227
+ /** Directory protocol for both engines: requests use in/<id>.json then in/<id>.done; answers use out/<id>.json then out/<id>.done. The page owns writes and cleanup in in/ and reply/; the guest owns out/ and ask/. Each writer removes .done last after the peer has acknowledged by removing its request. Disjoint writer sets keep WALI filesystem patches consistent. */
359
228
  export function openSessionChannel(
360
229
  files: BlenderFiles,
361
230
  options: BlenderEngineOptions,
362
- /**
363
- * WHY THE POLL LOOP NEEDS A LIVENESS PREDICATE, and it is the difference
364
- * between a board and a wasted afternoon.
365
- *
366
- * The loop below waits for `out/<id>.done` and nothing else. When the
367
- * program behind the directory DIES, that file is never written, so the
368
- * call waits forever: `cyclotron status` reports it IN FLIGHT for as long as
369
- * anyone looks, `blender-start` keeps answering from the host's cached
370
- * banner, and the harness has no answer to time out against. MEASURED
371
- * 2026-09-19 three times on the WALI skew -- 507 s, 621 s and 964 s of a
372
- * battery model spent waiting on a Blender that had already ended with
373
- * `[object WebAssembly.Exception]`, each ending in a killed process and no
374
- * result record at all.
375
- *
376
- * This returns null while the program is alive and the reason it ended
377
- * otherwise. A caller that supplies one turns that infinite wait into a
378
- * named failure naming the recovery door. The standalone skew supplies
379
- * none: its module is the worker, so a death there ends the worker rather
380
- * than stranding a reader, and inventing a predicate for a failure nobody
381
- * has measured would be a fiction.
382
- */
231
+ /** Return null while the program is alive, or its termination reason. The WALI call loop uses this to refuse a request whose program ended; standalone failures end the worker itself. */
383
232
  programEnded?: () => string | null,
384
233
  ): {
385
234
  request(payload: Record<string, unknown>): Promise<unknown>;
@@ -440,13 +289,7 @@ export function openSessionChannel(
440
289
 
441
290
  async function request(payload: Record<string, unknown>): Promise<unknown> {
442
291
  const id = String(++sequence);
443
- // BOTH WRITES BEGIN IN ONE TURN, `.done` second. Both engines write
444
- // synchronously inside `writeFile` and throw from it, so the order is kept
445
- // and a failed `.json` never leaves a `.done` behind; and the WALI
446
- // engine carries one turn's writes to the program as one patch, where an
447
- // await between them sent the request as two imports in series, the
448
- // second waiting for the first's acknowledgement (measured 2026-09-28:
449
- // most of a no-op call's 1.4-1.9 ms inbound leg).
292
+ // Begin the JSON and .done writes in one turn, in that order. Both engines write synchronously, and WALI carries that turn as one filesystem patch.
450
293
  await Promise.all([
451
294
  files.writeFile(`${SESSION_ROOT}/in/${id}.json`, JSON.stringify(payload)),
452
295
  files.writeFile(`${SESSION_ROOT}/in/${id}.done`, '1'),
@@ -458,18 +301,9 @@ export function openSessionChannel(
458
301
  const body = JSON.parse(
459
302
  decoder.decode(await files.readFile(`${SESSION_ROOT}/out/${id}.json`)),
460
303
  ) as { result?: unknown; error?: string };
461
- // THE PAGE UNLINKS WHAT THE PAGE WROTE, AND ONLY THAT. `out/<id>.*`
462
- // is the session's to remove; this loop does not touch it. Doing so
463
- // is what killed a WALI session mid-run (see the ownership rule
464
- // above): the guest's patch for `out/<id>.done` crossed this page's
465
- // unlink of it, `assertPatchBase` refused the patch, and the host
466
- // terminated the program's worker -- no guest exit path ran, which is
467
- // why every instrument on `SYS_exit` was silent through it.
468
- //
469
- // These two unlinks are also the ACK. The session watches for
470
- // `in/<id>.done` to vanish and takes that as "the page has the
471
- // answer", so `.done` goes last and neither is attempted before
472
- // `out/<id>.json` has been read.
304
+ // Unlink only the page-owned in/ request files after reading out/<id>.json. Removing
305
+ // in/<id>.done last acknowledges the answer to the session, which removes its own out/
306
+ // files.
473
307
  await files.unlink(`${SESSION_ROOT}/in/${id}.json`);
474
308
  await files.unlink(`${SESSION_ROOT}/in/${id}.done`);
475
309
  if (body.error) throw new Error(body.error);