@bornengine/engine 0.4.16

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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +231 -0
  3. package/native/android/Cargo.lock +1848 -0
  4. package/native/android/Cargo.toml +24 -0
  5. package/native/android/src/lib.rs +702 -0
  6. package/native/ios/Cargo.lock +1690 -0
  7. package/native/ios/Cargo.toml +32 -0
  8. package/native/ios/src/lib.rs +1267 -0
  9. package/native/linux/Cargo.lock +3279 -0
  10. package/native/linux/Cargo.toml +29 -0
  11. package/native/linux/src/lib.rs +1331 -0
  12. package/native/macos/Cargo.lock +3310 -0
  13. package/native/macos/Cargo.toml +46 -0
  14. package/native/macos/src/lib.rs +1302 -0
  15. package/native/shared/Cargo.lock +1899 -0
  16. package/native/shared/Cargo.toml +62 -0
  17. package/native/shared/assets/default_font.ttf +0 -0
  18. package/native/shared/build.rs +270 -0
  19. package/native/shared/shaders/common/clouds.wgsl +122 -0
  20. package/native/shared/shaders/common/fog.wgsl +16 -0
  21. package/native/shared/shaders/common/foliage_wind.wgsl +98 -0
  22. package/native/shared/shaders/common/imposter.wgsl +112 -0
  23. package/native/shared/shaders/common/pbr.wgsl +186 -0
  24. package/native/shared/shaders/common/shadows.wgsl +186 -0
  25. package/native/shared/shaders/common/sky.wgsl +8 -0
  26. package/native/shared/shaders/common/tonemap.wgsl +25 -0
  27. package/native/shared/shaders/impulse_field.wgsl +57 -0
  28. package/native/shared/shaders/material_abi.wgsl +383 -0
  29. package/native/shared/shaders/materials/test_minimal.wgsl +42 -0
  30. package/native/shared/src/anim_mixer.rs +61 -0
  31. package/native/shared/src/attach.rs +263 -0
  32. package/native/shared/src/audio/decode.rs +123 -0
  33. package/native/shared/src/audio/mod.rs +863 -0
  34. package/native/shared/src/audio/render.rs +892 -0
  35. package/native/shared/src/audio/spsc.rs +156 -0
  36. package/native/shared/src/audio/stream.rs +226 -0
  37. package/native/shared/src/custom_shaders.rs +104 -0
  38. package/native/shared/src/decals.rs +245 -0
  39. package/native/shared/src/drs.rs +211 -0
  40. package/native/shared/src/engine.rs +261 -0
  41. package/native/shared/src/ffi.rs +116 -0
  42. package/native/shared/src/ffi_core/assets.rs +388 -0
  43. package/native/shared/src/ffi_core/audio_ffi.rs +184 -0
  44. package/native/shared/src/ffi_core/draw.rs +334 -0
  45. package/native/shared/src/ffi_core/game_loop.rs +577 -0
  46. package/native/shared/src/ffi_core/input.rs +234 -0
  47. package/native/shared/src/ffi_core/mod.rs +127 -0
  48. package/native/shared/src/ffi_core/models.rs +1154 -0
  49. package/native/shared/src/ffi_core/ragdoll_ffi.rs +261 -0
  50. package/native/shared/src/ffi_core/scene.rs +626 -0
  51. package/native/shared/src/ffi_core/vfx.rs +212 -0
  52. package/native/shared/src/ffi_core/visual.rs +691 -0
  53. package/native/shared/src/frame_callbacks.rs +122 -0
  54. package/native/shared/src/geometry.rs +236 -0
  55. package/native/shared/src/handles.rs +182 -0
  56. package/native/shared/src/input.rs +448 -0
  57. package/native/shared/src/jolt_sys.rs +822 -0
  58. package/native/shared/src/lib.rs +55 -0
  59. package/native/shared/src/models.rs +1093 -0
  60. package/native/shared/src/models_gltf.rs +1280 -0
  61. package/native/shared/src/particles.rs +391 -0
  62. package/native/shared/src/physics_jolt.rs +1908 -0
  63. package/native/shared/src/picking.rs +298 -0
  64. package/native/shared/src/postfx.rs +345 -0
  65. package/native/shared/src/profiler.rs +492 -0
  66. package/native/shared/src/ragdoll.rs +474 -0
  67. package/native/shared/src/renderer/atmosphere_lut.rs +573 -0
  68. package/native/shared/src/renderer/brdf_lut.rs +154 -0
  69. package/native/shared/src/renderer/draw2d.rs +143 -0
  70. package/native/shared/src/renderer/formats.rs +822 -0
  71. package/native/shared/src/renderer/froxel.rs +421 -0
  72. package/native/shared/src/renderer/gi_bake.rs +653 -0
  73. package/native/shared/src/renderer/graph.rs +462 -0
  74. package/native/shared/src/renderer/hiz.rs +269 -0
  75. package/native/shared/src/renderer/hot_reload.rs +390 -0
  76. package/native/shared/src/renderer/impulse_field.rs +456 -0
  77. package/native/shared/src/renderer/lighting.rs +154 -0
  78. package/native/shared/src/renderer/material_instancing.rs +171 -0
  79. package/native/shared/src/renderer/material_pipeline.rs +700 -0
  80. package/native/shared/src/renderer/material_system.rs +1996 -0
  81. package/native/shared/src/renderer/material_system_tests.rs +601 -0
  82. package/native/shared/src/renderer/material_system_wasm.rs +41 -0
  83. package/native/shared/src/renderer/mod.rs +12556 -0
  84. package/native/shared/src/renderer/model_draw.rs +641 -0
  85. package/native/shared/src/renderer/occlusion.rs +429 -0
  86. package/native/shared/src/renderer/planar_pass.rs +593 -0
  87. package/native/shared/src/renderer/planar_reflection.rs +499 -0
  88. package/native/shared/src/renderer/post_pass.rs +249 -0
  89. package/native/shared/src/renderer/postfx_chain.rs +728 -0
  90. package/native/shared/src/renderer/pt_pass.rs +577 -0
  91. package/native/shared/src/renderer/scene_pass.rs +607 -0
  92. package/native/shared/src/renderer/shader_include.rs +205 -0
  93. package/native/shared/src/renderer/shader_library.rs +135 -0
  94. package/native/shared/src/renderer/shaders/ao.rs +570 -0
  95. package/native/shared/src/renderer/shaders/core.rs +1243 -0
  96. package/native/shared/src/renderer/shaders/env.rs +907 -0
  97. package/native/shared/src/renderer/shaders/gi.rs +810 -0
  98. package/native/shared/src/renderer/shaders/mod.rs +19 -0
  99. package/native/shared/src/renderer/shaders/post.rs +1558 -0
  100. package/native/shared/src/renderer/shaders/pt.rs +1859 -0
  101. package/native/shared/src/renderer/shaders/ssgi.rs +1586 -0
  102. package/native/shared/src/renderer/shadow_pass.rs +731 -0
  103. package/native/shared/src/renderer/ssgi_pass.rs +392 -0
  104. package/native/shared/src/renderer/ssr_pass.rs +188 -0
  105. package/native/shared/src/renderer/texture_store.rs +473 -0
  106. package/native/shared/src/renderer/transient.rs +591 -0
  107. package/native/shared/src/renderer/types.rs +941 -0
  108. package/native/shared/src/renderer/util.rs +152 -0
  109. package/native/shared/src/scene.rs +1362 -0
  110. package/native/shared/src/sdf_cache.rs +274 -0
  111. package/native/shared/src/shadows.rs +1036 -0
  112. package/native/shared/src/staging.rs +102 -0
  113. package/native/shared/src/string_header.rs +266 -0
  114. package/native/shared/src/text_renderer.rs +502 -0
  115. package/native/shared/src/textures.rs +197 -0
  116. package/native/tvos/Cargo.lock +1693 -0
  117. package/native/tvos/Cargo.toml +36 -0
  118. package/native/tvos/metal-patched/Cargo.toml +178 -0
  119. package/native/tvos/metal-patched/LICENSE-APACHE +201 -0
  120. package/native/tvos/metal-patched/LICENSE-MIT +25 -0
  121. package/native/tvos/metal-patched/src/acceleration_structure.rs +667 -0
  122. package/native/tvos/metal-patched/src/acceleration_structure_pass.rs +108 -0
  123. package/native/tvos/metal-patched/src/argument.rs +366 -0
  124. package/native/tvos/metal-patched/src/blitpass.rs +102 -0
  125. package/native/tvos/metal-patched/src/buffer.rs +71 -0
  126. package/native/tvos/metal-patched/src/capturedescriptor.rs +76 -0
  127. package/native/tvos/metal-patched/src/capturemanager.rs +113 -0
  128. package/native/tvos/metal-patched/src/commandbuffer.rs +192 -0
  129. package/native/tvos/metal-patched/src/commandqueue.rs +44 -0
  130. package/native/tvos/metal-patched/src/computepass.rs +107 -0
  131. package/native/tvos/metal-patched/src/constants.rs +152 -0
  132. package/native/tvos/metal-patched/src/counters.rs +119 -0
  133. package/native/tvos/metal-patched/src/depthstencil.rs +190 -0
  134. package/native/tvos/metal-patched/src/device.rs +2134 -0
  135. package/native/tvos/metal-patched/src/drawable.rs +39 -0
  136. package/native/tvos/metal-patched/src/encoder.rs +2041 -0
  137. package/native/tvos/metal-patched/src/heap.rs +281 -0
  138. package/native/tvos/metal-patched/src/indirect_encoder.rs +344 -0
  139. package/native/tvos/metal-patched/src/lib.rs +657 -0
  140. package/native/tvos/metal-patched/src/library.rs +902 -0
  141. package/native/tvos/metal-patched/src/mps.rs +575 -0
  142. package/native/tvos/metal-patched/src/pipeline/compute.rs +475 -0
  143. package/native/tvos/metal-patched/src/pipeline/mod.rs +71 -0
  144. package/native/tvos/metal-patched/src/pipeline/render.rs +762 -0
  145. package/native/tvos/metal-patched/src/renderpass.rs +443 -0
  146. package/native/tvos/metal-patched/src/resource.rs +182 -0
  147. package/native/tvos/metal-patched/src/sampler.rs +165 -0
  148. package/native/tvos/metal-patched/src/sync.rs +178 -0
  149. package/native/tvos/metal-patched/src/texture.rs +352 -0
  150. package/native/tvos/metal-patched/src/types.rs +90 -0
  151. package/native/tvos/metal-patched/src/vertexdescriptor.rs +250 -0
  152. package/native/tvos/src/audio_backend.rs +197 -0
  153. package/native/tvos/src/lib.rs +1891 -0
  154. package/native/visionos/Cargo.lock +1693 -0
  155. package/native/visionos/Cargo.toml +40 -0
  156. package/native/visionos/src/audio_backend.rs +197 -0
  157. package/native/visionos/src/lib.rs +1887 -0
  158. package/native/watchos/Cargo.lock +16 -0
  159. package/native/watchos/Cargo.toml +19 -0
  160. package/native/watchos/shaders/bloom_postfx.metal +99 -0
  161. package/native/watchos/src/BloomWatchApp.swift +1267 -0
  162. package/native/watchos/src/BloomWatchAudio.swift +179 -0
  163. package/native/watchos/src/audio.rs +55 -0
  164. package/native/watchos/src/draw_list.rs +229 -0
  165. package/native/watchos/src/ffi_stubs.rs +915 -0
  166. package/native/watchos/src/ffi_stubs_manual.rs +35 -0
  167. package/native/watchos/src/lib.rs +1124 -0
  168. package/native/watchos/src/models.rs +746 -0
  169. package/native/watchos/src/postfx.rs +95 -0
  170. package/native/watchos/src/scene.rs +534 -0
  171. package/native/watchos/src/textures.rs +184 -0
  172. package/native/web/Cargo.lock +1657 -0
  173. package/native/web/Cargo.toml +43 -0
  174. package/native/web/bloom_glue.js +695 -0
  175. package/native/web/build.sh +131 -0
  176. package/native/web/index.html +35 -0
  177. package/native/web/jolt_bridge.js +1519 -0
  178. package/native/web/src/input_ffi.rs +286 -0
  179. package/native/web/src/lib.rs +1796 -0
  180. package/native/web/src/material_ffi.rs +710 -0
  181. package/native/web/src/parity_ffi.rs +343 -0
  182. package/native/web/src/physics_ffi.rs +643 -0
  183. package/native/web/src/ragdoll_ffi.rs +250 -0
  184. package/native/web/src/render_settings.rs +98 -0
  185. package/native/windows/Cargo.lock +1815 -0
  186. package/native/windows/Cargo.toml +68 -0
  187. package/native/windows/src/lib.rs +1486 -0
  188. package/package.json +4279 -0
  189. package/src/audio/index.ts +315 -0
  190. package/src/core/colors.ts +63 -0
  191. package/src/core/index.ts +1206 -0
  192. package/src/core/keys.ts +63 -0
  193. package/src/core/types.ts +104 -0
  194. package/src/index.ts +171 -0
  195. package/src/math/index.ts +516 -0
  196. package/src/mobile/index.ts +294 -0
  197. package/src/models/index.ts +1258 -0
  198. package/src/physics/index.ts +1134 -0
  199. package/src/scene/index.ts +698 -0
  200. package/src/shapes/index.ts +120 -0
  201. package/src/text/index.ts +48 -0
  202. package/src/textures/index.ts +187 -0
  203. package/src/vfx/index.ts +191 -0
  204. package/src/world/index.ts +24 -0
  205. package/src/world/loader.ts +423 -0
  206. package/src/world/prefab.ts +217 -0
  207. package/src/world/render.ts +172 -0
  208. package/src/world/saver.ts +108 -0
  209. package/src/world/serialize.ts +301 -0
  210. package/src/world/terrain.ts +355 -0
  211. package/src/world/types.ts +160 -0
  212. package/src/world/validate.ts +319 -0
  213. package/src/world/version.ts +114 -0
@@ -0,0 +1,172 @@
1
+ // Shared spawn helpers for the parts of a world that are not entities: water
2
+ // volumes and rivers.
3
+ //
4
+ // Both the runtime loader (`instantiateWorld`, used by games) and the world
5
+ // editor call these, so a river looks the same in the editor as it does in the
6
+ // game. Anything that renders world data belongs here rather than in either
7
+ // consumer — the editor previously drew its own translucent debug cubes, which
8
+ // is exactly how the two drift apart.
9
+ //
10
+ // Colour convention: the world schema stores RGBA as 0-1 floats, while the
11
+ // scene API takes 0-255. The conversion lives here, once.
12
+
13
+ import {
14
+ createSceneNode, setSceneNodeVisible, setSceneNodeWaterMaterial,
15
+ attachModelToNode, setSceneNodeColor, addPointLight,
16
+ SceneNodeHandle,
17
+ } from '../scene/index';
18
+ import {
19
+ genMeshCube, genMeshSplineRibbon, setAmbientLight, setDirectionalLight,
20
+ } from '../models/index';
21
+ import { setFog } from '../core/index';
22
+ import { vec3 } from '../math/index';
23
+ import { setSceneNodeTransform } from '../scene/index';
24
+ import { WorldData, WaterVolume, RiverSpline } from './types';
25
+
26
+ // Re-submit the world's point lights.
27
+ //
28
+ // MUST be called every frame, not once at load: the renderer clears its
29
+ // lighting block in begin_frame (the same reason games re-apply sun and ambient
30
+ // each frame). Calling it once at startup produces a world that is lit for
31
+ // exactly one frame and then goes dark.
32
+ //
33
+ // Colour components are 0-1 in both the schema and `addPointLight`.
34
+ export function applyWorldLights(world: WorldData): void {
35
+ for (let i = 0; i < world.lights.length; i++) {
36
+ const l = world.lights[i];
37
+ addPointLight(
38
+ l.position[0], l.position[1], l.position[2],
39
+ l.range,
40
+ l.color[0], l.color[1], l.color[2],
41
+ l.intensity,
42
+ );
43
+ }
44
+ }
45
+
46
+ // Re-apply the world's ambient light, sun, and fog.
47
+ //
48
+ // MUST be called every frame, for the same reason as `applyWorldLights`: the
49
+ // renderer clears its lighting block in begin_frame. `instantiateWorld` applies
50
+ // the environment once so the FIRST frame is right, but a consumer that never
51
+ // calls this per frame gets a world that goes dark on frame two. (Shadows are
52
+ // deliberately NOT here — enabling/disabling swaps render passes and belongs
53
+ // at load or on explicit change, which instantiateWorld already handles.)
54
+ //
55
+ // The world editor and the world-viewer example both drive their frames with
56
+ // exactly this call, so lighting cannot look different in-game than in-editor.
57
+ export function applyWorldEnvironment(world: WorldData): void {
58
+ const env = world.environment;
59
+ if (!env) return;
60
+
61
+ setAmbientLight(
62
+ {
63
+ r: Math.floor(env.ambientColor[0] * 255),
64
+ g: Math.floor(env.ambientColor[1] * 255),
65
+ b: Math.floor(env.ambientColor[2] * 255),
66
+ a: 255,
67
+ },
68
+ env.ambientIntensity,
69
+ );
70
+
71
+ setDirectionalLight(
72
+ vec3(env.sunDirection[0], env.sunDirection[1], env.sunDirection[2]),
73
+ {
74
+ r: Math.floor(env.sunColor[0] * 255),
75
+ g: Math.floor(env.sunColor[1] * 255),
76
+ b: Math.floor(env.sunColor[2] * 255),
77
+ a: 255,
78
+ },
79
+ env.sunIntensity,
80
+ );
81
+
82
+ applyWorldLights(world);
83
+
84
+ // The engine's fog is exponential height fog; the schema stores a linear
85
+ // start/end pair. Approximate with a density reaching ~95% extinction at
86
+ // fogEnd, near-uniform over height.
87
+ if (env.fogEnd > 0.0001) {
88
+ setFog(env.fogColor[0], env.fogColor[1], env.fogColor[2], 3.0 / env.fogEnd, 0, 0.02);
89
+ } else {
90
+ setFog(env.fogColor[0], env.fogColor[1], env.fogColor[2], 0, 0, 0.02);
91
+ }
92
+ }
93
+
94
+ // A water volume renders as a box whose *top face* sits at `surfaceHeight`
95
+ // (the schema's `center.y` positions the body of water; the surface is what the
96
+ // player sees and what the wave shader animates).
97
+ export function spawnWaterVolume(volume: WaterVolume): SceneNodeHandle {
98
+ const node = createSceneNode();
99
+ const cube = genMeshCube(1, 1, 1);
100
+ attachModelToNode(node, cube.handle, 0);
101
+
102
+ const sx = volume.size[0];
103
+ const sy = volume.size[1];
104
+ const sz = volume.size[2];
105
+
106
+ // Column-major TRS: scale on the diagonal, translation in the last column.
107
+ // Y is placed so the top face lands on surfaceHeight.
108
+ const cy = volume.surfaceHeight - sy / 2;
109
+ setSceneNodeTransform(node, [
110
+ sx, 0, 0, 0,
111
+ 0, sy, 0, 0,
112
+ 0, 0, sz, 0,
113
+ volume.center[0], cy, volume.center[2], 1,
114
+ ]);
115
+
116
+ const c = volume.color;
117
+ setSceneNodeWaterMaterial(
118
+ node,
119
+ volume.waveAmplitude, volume.waveSpeed,
120
+ c[0] * 255, c[1] * 255, c[2] * 255, c[3] * 255,
121
+ );
122
+ setSceneNodeVisible(node, true);
123
+ return node;
124
+ }
125
+
126
+ // A river renders as a ribbon mesh swept along its control points, dropped by
127
+ // `depth` so it sits in its channel rather than on top of the terrain. Widths
128
+ // are per control point; a river with fewer widths than points repeats the last.
129
+ export function spawnRiver(river: RiverSpline): SceneNodeHandle {
130
+ const pointCount = river.controlPoints.length;
131
+ if (pointCount < 2) return 0;
132
+
133
+ const points: number[] = [];
134
+ for (let i = 0; i < pointCount; i++) {
135
+ const p = river.controlPoints[i];
136
+ points.push(p[0]);
137
+ points.push(p[1] - river.depth);
138
+ points.push(p[2]);
139
+ }
140
+
141
+ const widths: number[] = [];
142
+ for (let i = 0; i < pointCount; i++) {
143
+ const w = i < river.widths.length
144
+ ? river.widths[i]
145
+ : (river.widths.length > 0 ? river.widths[river.widths.length - 1] : 1);
146
+ widths.push(w);
147
+ }
148
+
149
+ const ribbon = genMeshSplineRibbon(points, widths);
150
+ if (ribbon.handle === 0) return 0;
151
+
152
+ const node = createSceneNode();
153
+ attachModelToNode(node, ribbon.handle, 0);
154
+
155
+ const c = river.color;
156
+ // Flow speed drives the same wave animation as a water volume; a river with
157
+ // no flow still ripples gently rather than reading as a flat plastic strip.
158
+ setSceneNodeWaterMaterial(
159
+ node,
160
+ 0.05, river.flowSpeed,
161
+ c[0] * 255, c[1] * 255, c[2] * 255, c[3] * 255,
162
+ );
163
+ setSceneNodeVisible(node, true);
164
+ return node;
165
+ }
166
+
167
+ // Editor-only: tint a water/river node to show selection. Games never call this.
168
+ export function setWaterHighlight(handle: SceneNodeHandle, selected: boolean): void {
169
+ if (selected) {
170
+ setSceneNodeColor(handle, 255, 220, 120, 255);
171
+ }
172
+ }
@@ -0,0 +1,108 @@
1
+ // World saver — serializes a `WorldData` to disk as pretty-printed JSON.
2
+ // Called by the editor's File -> Save / Save As commands and by any tooling
3
+ // that programmatically generates worlds.
4
+ //
5
+ // The saver always validates before writing so the editor can't produce
6
+ // corrupt files. On validation failure, returns false and populates the
7
+ // `errors` array; on success, writes the file and returns true.
8
+ //
9
+ // CRASH SAFETY (2026-07-17): a save is a user's level — the one file that
10
+ // must never be half-written. There is no rename FFI, so true atomic replace
11
+ // isn't available; instead every save goes through `safeWrite`:
12
+ //
13
+ // 1. write `<path>.tmp` and READ IT BACK — a byte mismatch fails the save
14
+ // before the real file is touched (this is the check that would have
15
+ // caught the era when writeFile wrote 0 bytes and reported success);
16
+ // 2. snapshot the current `<path>` to `<path>.bak`;
17
+ // 3. write `<path>`.
18
+ //
19
+ // A crash at any point leaves a good copy in `.bak` (the previous save) or
20
+ // `.tmp` (the new one, already verified) — losing data requires the disk to
21
+ // fail three times in a row. The `.tmp`/`.bak` siblings are cheap litter;
22
+ // gitignore them in game repos.
23
+
24
+ import { readFile, writeFile, fileExists } from '../core/index';
25
+ import { WORLD_SCHEMA_VERSION, WorldData, PrefabData } from './types';
26
+ import { validateWorld, validatePrefab, formatValidationErrors } from './validate';
27
+ import { serializeWorld, serializePrefab } from './serialize';
28
+
29
+ export interface SaveResult {
30
+ ok: boolean;
31
+ errors: string[];
32
+ }
33
+
34
+ // Verified, backup-keeping write. Returns an error string, or null on success.
35
+ function safeWrite(path: string, json: string): string | null {
36
+ const tmpPath = path + '.tmp';
37
+ if (!writeFile(tmpPath, json)) {
38
+ return 'could not write ' + tmpPath;
39
+ }
40
+ const readBack = readFile(tmpPath);
41
+ if (readBack !== json) {
42
+ return 'readback mismatch on ' + tmpPath + ' (wrote ' + json.length +
43
+ ' chars, read ' + (readBack ? readBack.length : 0) + ') — disk or FFI fault, original untouched';
44
+ }
45
+
46
+ if (fileExists(path)) {
47
+ const prev = readFile(path);
48
+ if (prev && prev.length > 0) {
49
+ // Best-effort: a failed backup shouldn't block the save itself.
50
+ writeFile(path + '.bak', prev);
51
+ }
52
+ }
53
+
54
+ if (!writeFile(path, json)) {
55
+ return 'could not write ' + path + ' — previous version is in ' + path +
56
+ '.bak, the new one in ' + tmpPath;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ // Write a world file. On success, returns `{ ok: true, errors: [] }`.
62
+ // On validation failure, returns the errors without touching the filesystem.
63
+ // On write failure (disk full, permissions), returns `{ ok: false, errors: [...] }`.
64
+ export function saveWorld(path: string, world: WorldData): SaveResult {
65
+ // Always stamp the current schema version on save so stale copies don't
66
+ // drift across editor sessions.
67
+ world.schemaVersion = WORLD_SCHEMA_VERSION;
68
+
69
+ const check = validateWorld(world);
70
+ if (!check.ok) {
71
+ return { ok: false, errors: check.errors };
72
+ }
73
+
74
+ // NOT JSON.stringify — see serialize.ts. On Perry 0.5.x it corrupts a large object
75
+ // graph that came from JSON.parse, the corrupt string fails the FFI's UTF-8 check,
76
+ // and writeFile then wrote a ZERO-BYTE FILE AND RETURNED SUCCESS. Saving a world
77
+ // destroyed it, silently.
78
+ const json = serializeWorld(world);
79
+ const err = safeWrite(path, json);
80
+ if (err !== null) {
81
+ return { ok: false, errors: ['saveWorld: ' + err] };
82
+ }
83
+ return { ok: true, errors: [] };
84
+ }
85
+
86
+ // Write a prefab file. Same semantics as `saveWorld` but for prefabs.
87
+ export function savePrefab(path: string, prefab: PrefabData): SaveResult {
88
+ prefab.schemaVersion = WORLD_SCHEMA_VERSION;
89
+
90
+ const check = validatePrefab(prefab);
91
+ if (!check.ok) {
92
+ return { ok: false, errors: check.errors };
93
+ }
94
+
95
+ const json = serializePrefab(prefab);
96
+ const err = safeWrite(path, json);
97
+ if (err !== null) {
98
+ return { ok: false, errors: ['savePrefab: ' + err] };
99
+ }
100
+ return { ok: true, errors: [] };
101
+ }
102
+
103
+ // Convenience: format a SaveResult's errors as a single human-readable string.
104
+ // The editor uses this for status bar / dialog messages.
105
+ export function formatSaveError(result: SaveResult): string {
106
+ if (result.ok) return '';
107
+ return formatValidationErrors(result.errors);
108
+ }
@@ -0,0 +1,301 @@
1
+ // Hand-written JSON emitter for WorldData / PrefabData.
2
+ //
3
+ // WHY THIS EXISTS, AND WHY IT IS NOT `JSON.stringify`.
4
+ //
5
+ // `JSON.stringify` CORRUPTS a large object graph that came from `JSON.parse` on
6
+ // Perry 0.5.x. Minimal repro, no engine code involved:
7
+ //
8
+ // const text = readFile('assets/worlds/arena_02.world.json'); // 324 KB, fine
9
+ // const o = JSON.parse(text); // fine
10
+ // const re = JSON.stringify(o, null, 2);
11
+ // // -> `re` contains 5,296 characters above U+00FF, in a JSON document whose
12
+ // // source is almost pure ASCII. It is garbage.
13
+ //
14
+ // The corruption is invisible until the string crosses the FFI: the engine's
15
+ // `str_from_header` fails its UTF-8 check, returns "", and `writeFile` then writes
16
+ // a ZERO-BYTE FILE AND REPORTS SUCCESS.
17
+ //
18
+ // So `saveWorld` did not save. It emptied the file and said "ok". Every world the
19
+ // editor has ever "saved" was destroyed by saving it — which is why the editor has
20
+ // only ever been used to look at worlds that some other tool produced.
21
+ //
22
+ // It is not size (a fresh 1 MB world-shaped object stringifies fine), not floats,
23
+ // not nulls, not `Record` keys, not non-ASCII — every one of those was ruled out by
24
+ // probe. It is specifically the parsed graph. A manual deep clone does not escape it.
25
+ //
26
+ // So the save path does not get to use `JSON.stringify`. This emitter walks the
27
+ // schema by LITERAL KEY and builds the document by concatenation, which is the same
28
+ // discipline the shooter's `settings.ts` already adopted ("Perry's object handling
29
+ // is only trustworthy with literal keys") and which the probes show is reliable.
30
+ //
31
+ // Being schema-explicit is not only a workaround, it is a feature: an unknown field
32
+ // cannot be silently dropped by a serializer that never knew about it — the schema
33
+ // and the writer change together, or validation fails.
34
+
35
+ import {
36
+ WorldData, PrefabData, PrefabChild, EntityData, LightData,
37
+ WaterVolume, RiverSpline, TerrainData, TerrainLayer, EnvironmentData,
38
+ TransformData, Vec3Lit, Vec4Lit,
39
+ } from './types';
40
+
41
+ // --- primitives --------------------------------------------------------------
42
+
43
+ /// JSON string escaping. Control characters must be escaped or the document is
44
+ /// invalid; everything else is emitted as-is (the file is UTF-8).
45
+ function str(s: string): string {
46
+ let out = '"';
47
+ for (let i = 0; i < s.length; i++) {
48
+ const c = s.charCodeAt(i);
49
+ if (c === 34) out = out + '\\"';
50
+ else if (c === 92) out = out + '\\\\';
51
+ else if (c === 10) out = out + '\\n';
52
+ else if (c === 13) out = out + '\\r';
53
+ else if (c === 9) out = out + '\\t';
54
+ else if (c === 8) out = out + '\\b';
55
+ else if (c === 12) out = out + '\\f';
56
+ else if (c < 32) {
57
+ // \u00XX — the only escapes JSON *requires*.
58
+ let h = c.toString(16);
59
+ while (h.length < 4) h = '0' + h;
60
+ out = out + '\\u' + h;
61
+ } else {
62
+ out = out + s.charAt(i);
63
+ }
64
+ }
65
+ return out + '"';
66
+ }
67
+
68
+ /// Numbers. NaN and Infinity are not JSON; emitting them produces a file that will
69
+ /// not parse, so they become 0 rather than a document nobody can open.
70
+ function num(n: number): string {
71
+ if (n !== n) return '0'; // NaN
72
+ if (n === Infinity || n === -Infinity) return '0';
73
+ // Integers stay integers — `1` not `1.0` — so a hand-written world file and a
74
+ // round-tripped one look the same in a diff.
75
+ if (n === Math.floor(n) && Math.abs(n) < 1e15) return '' + n;
76
+ return '' + n;
77
+ }
78
+
79
+ // Written with `if` as a precaution, not a fix for an observed bug: a helper whose
80
+ // entire body is one ternary return is a shape Perry has miscompiled elsewhere
81
+ // (perry-quirks #8). `bool()` itself was verified CORRECT in both forms — a world
82
+ // saved with `shadowsEnabled: false` does write `false`.
83
+ function bool(b: boolean): string {
84
+ if (b) return 'true';
85
+ return 'false';
86
+ }
87
+
88
+ function vec3(v: Vec3Lit): string {
89
+ return '[' + num(v[0]) + ', ' + num(v[1]) + ', ' + num(v[2]) + ']';
90
+ }
91
+ function vec4(v: Vec4Lit): string {
92
+ return '[' + num(v[0]) + ', ' + num(v[1]) + ', ' + num(v[2]) + ', ' + num(v[3]) + ']';
93
+ }
94
+ function nums(a: number[]): string {
95
+ let out = '[';
96
+ for (let i = 0; i < a.length; i++) {
97
+ if (i > 0) out = out + ', ';
98
+ out = out + num(a[i]);
99
+ }
100
+ return out + ']';
101
+ }
102
+ function strs(a: string[]): string {
103
+ let out = '[';
104
+ for (let i = 0; i < a.length; i++) {
105
+ if (i > 0) out = out + ', ';
106
+ out = out + str(a[i]);
107
+ }
108
+ return out + ']';
109
+ }
110
+
111
+ function ind(n: number): string {
112
+ let s = '';
113
+ for (let i = 0; i < n; i++) s = s + ' ';
114
+ return s;
115
+ }
116
+
117
+ /// A `Record<string, string>`. Keys come from the data, so this is the one place we
118
+ /// cannot use literal keys — but `Object.keys` on a *fresh* object is sound, and
119
+ /// callers hand us the parsed map directly.
120
+ function record(r: Record<string, string>, depth: number): string {
121
+ const keys = Object.keys(r);
122
+ if (keys.length === 0) return '{}';
123
+ let out = '{\n';
124
+ for (let i = 0; i < keys.length; i++) {
125
+ const k = keys[i];
126
+ out = out + ind(depth + 1) + str(k) + ': ' + str(r[k]);
127
+ if (i < keys.length - 1) out = out + ',';
128
+ out = out + '\n';
129
+ }
130
+ return out + ind(depth) + '}';
131
+ }
132
+
133
+ // --- schema ------------------------------------------------------------------
134
+
135
+ function transform(t: TransformData, d: number): string {
136
+ return '{\n'
137
+ + ind(d + 1) + '"position": ' + vec3(t.position) + ',\n'
138
+ + ind(d + 1) + '"rotation": ' + vec3(t.rotation) + ',\n'
139
+ + ind(d + 1) + '"scale": ' + vec3(t.scale) + '\n'
140
+ + ind(d) + '}';
141
+ }
142
+
143
+ function entity(e: EntityData, d: number): string {
144
+ let s = '{\n';
145
+ s = s + ind(d + 1) + '"id": ' + str(e.id) + ',\n';
146
+ s = s + ind(d + 1) + '"name": ' + str(e.name) + ',\n';
147
+ s = s + ind(d + 1) + '"modelRef": ' + (e.modelRef === null ? 'null' : str(e.modelRef)) + ',\n';
148
+ s = s + ind(d + 1) + '"prefabRef": ' + (e.prefabRef === null ? 'null' : str(e.prefabRef)) + ',\n';
149
+ s = s + ind(d + 1) + '"transform": ' + transform(e.transform, d + 1) + ',\n';
150
+ s = s + ind(d + 1) + '"tint": ' + (e.tint === null ? 'null' : vec4(e.tint)) + ',\n';
151
+ s = s + ind(d + 1) + '"tags": ' + strs(e.tags) + ',\n';
152
+ s = s + ind(d + 1) + '"userData": ' + record(e.userData, d + 1) + '\n';
153
+ return s + ind(d) + '}';
154
+ }
155
+
156
+ function light(l: LightData, d: number): string {
157
+ return '{\n'
158
+ + ind(d + 1) + '"id": ' + str(l.id) + ',\n'
159
+ + ind(d + 1) + '"name": ' + str(l.name) + ',\n'
160
+ + ind(d + 1) + '"kind": ' + str(l.kind) + ',\n'
161
+ + ind(d + 1) + '"position": ' + vec3(l.position) + ',\n'
162
+ + ind(d + 1) + '"color": ' + vec3(l.color) + ',\n'
163
+ + ind(d + 1) + '"intensity": ' + num(l.intensity) + ',\n'
164
+ + ind(d + 1) + '"range": ' + num(l.range) + '\n'
165
+ + ind(d) + '}';
166
+ }
167
+
168
+ function water(w: WaterVolume, d: number): string {
169
+ return '{\n'
170
+ + ind(d + 1) + '"id": ' + str(w.id) + ',\n'
171
+ + ind(d + 1) + '"kind": ' + str(w.kind) + ',\n'
172
+ + ind(d + 1) + '"center": ' + vec3(w.center) + ',\n'
173
+ + ind(d + 1) + '"size": ' + vec3(w.size) + ',\n'
174
+ + ind(d + 1) + '"surfaceHeight": ' + num(w.surfaceHeight) + ',\n'
175
+ + ind(d + 1) + '"color": ' + vec4(w.color) + ',\n'
176
+ + ind(d + 1) + '"waveAmplitude": ' + num(w.waveAmplitude) + ',\n'
177
+ + ind(d + 1) + '"waveSpeed": ' + num(w.waveSpeed) + '\n'
178
+ + ind(d) + '}';
179
+ }
180
+
181
+ function river(r: RiverSpline, d: number): string {
182
+ let pts = '[';
183
+ for (let i = 0; i < r.controlPoints.length; i++) {
184
+ if (i > 0) pts = pts + ', ';
185
+ pts = pts + vec3(r.controlPoints[i]);
186
+ }
187
+ pts = pts + ']';
188
+ return '{\n'
189
+ + ind(d + 1) + '"id": ' + str(r.id) + ',\n'
190
+ + ind(d + 1) + '"controlPoints": ' + pts + ',\n'
191
+ + ind(d + 1) + '"widths": ' + nums(r.widths) + ',\n'
192
+ + ind(d + 1) + '"depth": ' + num(r.depth) + ',\n'
193
+ + ind(d + 1) + '"flowSpeed": ' + num(r.flowSpeed) + ',\n'
194
+ + ind(d + 1) + '"color": ' + vec4(r.color) + '\n'
195
+ + ind(d) + '}';
196
+ }
197
+
198
+ function terrainLayer(l: TerrainLayer, d: number): string {
199
+ return '{\n'
200
+ + ind(d + 1) + '"id": ' + str(l.id) + ',\n'
201
+ + ind(d + 1) + '"textureRef": ' + str(l.textureRef) + ',\n'
202
+ + ind(d + 1) + '"weights": ' + nums(l.weights) + ',\n'
203
+ + ind(d + 1) + '"tileScale": ' + num(l.tileScale) + '\n'
204
+ + ind(d) + '}';
205
+ }
206
+
207
+ function terrain(t: TerrainData, d: number): string {
208
+ let layers = '[]';
209
+ if (t.layers.length > 0) {
210
+ layers = '[\n';
211
+ for (let i = 0; i < t.layers.length; i++) {
212
+ layers = layers + ind(d + 2) + terrainLayer(t.layers[i], d + 2);
213
+ if (i < t.layers.length - 1) layers = layers + ',';
214
+ layers = layers + '\n';
215
+ }
216
+ layers = layers + ind(d + 1) + ']';
217
+ }
218
+ return '{\n'
219
+ + ind(d + 1) + '"width": ' + num(t.width) + ',\n'
220
+ + ind(d + 1) + '"depth": ' + num(t.depth) + ',\n'
221
+ + ind(d + 1) + '"cellSize": ' + num(t.cellSize) + ',\n'
222
+ + ind(d + 1) + '"origin": ' + vec3(t.origin) + ',\n'
223
+ + ind(d + 1) + '"heights": ' + nums(t.heights) + ',\n'
224
+ + ind(d + 1) + '"layers": ' + layers + '\n'
225
+ + ind(d) + '}';
226
+ }
227
+
228
+ function environment(e: EnvironmentData, d: number): string {
229
+ return '{\n'
230
+ + ind(d + 1) + '"skyColor": ' + vec3(e.skyColor) + ',\n'
231
+ + ind(d + 1) + '"ambientColor": ' + vec3(e.ambientColor) + ',\n'
232
+ + ind(d + 1) + '"ambientIntensity": ' + num(e.ambientIntensity) + ',\n'
233
+ + ind(d + 1) + '"sunDirection": ' + vec3(e.sunDirection) + ',\n'
234
+ + ind(d + 1) + '"sunColor": ' + vec3(e.sunColor) + ',\n'
235
+ + ind(d + 1) + '"sunIntensity": ' + num(e.sunIntensity) + ',\n'
236
+ + ind(d + 1) + '"fogStart": ' + num(e.fogStart) + ',\n'
237
+ + ind(d + 1) + '"fogEnd": ' + num(e.fogEnd) + ',\n'
238
+ + ind(d + 1) + '"fogColor": ' + vec3(e.fogColor) + ',\n'
239
+ + ind(d + 1) + '"shadowsEnabled": ' + bool(e.shadowsEnabled) + '\n'
240
+ + ind(d) + '}';
241
+ }
242
+
243
+ function arr<T>(items: T[], d: number, fn: (x: T, d: number) => string): string {
244
+ if (items.length === 0) return '[]';
245
+ let out = '[\n';
246
+ for (let i = 0; i < items.length; i++) {
247
+ out = out + ind(d + 1) + fn(items[i], d + 1);
248
+ if (i < items.length - 1) out = out + ',';
249
+ out = out + '\n';
250
+ }
251
+ return out + ind(d) + ']';
252
+ }
253
+
254
+ // --- entry points ------------------------------------------------------------
255
+
256
+ export function serializeWorld(w: WorldData): string {
257
+ let s = '{\n';
258
+ s = s + ind(1) + '"schemaVersion": ' + num(w.schemaVersion) + ',\n';
259
+ s = s + ind(1) + '"name": ' + str(w.name) + ',\n';
260
+ s = s + ind(1) + '"id": ' + str(w.id) + ',\n';
261
+ s = s + ind(1) + '"bounds": {\n'
262
+ + ind(2) + '"min": ' + vec3(w.bounds.min) + ',\n'
263
+ + ind(2) + '"max": ' + vec3(w.bounds.max) + '\n'
264
+ + ind(1) + '},\n';
265
+ s = s + ind(1) + '"environment": ' + environment(w.environment, 1) + ',\n';
266
+ s = s + ind(1) + '"terrain": ' + (w.terrain === null ? 'null' : terrain(w.terrain, 1)) + ',\n';
267
+ s = s + ind(1) + '"entities": ' + arr(w.entities, 1, entity) + ',\n';
268
+ s = s + ind(1) + '"lights": ' + arr(w.lights, 1, light) + ',\n';
269
+ s = s + ind(1) + '"water": ' + arr(w.water, 1, water) + ',\n';
270
+ s = s + ind(1) + '"rivers": ' + arr(w.rivers, 1, river) + ',\n';
271
+ s = s + ind(1) + '"metadata": ' + record(w.metadata, 1) + '\n';
272
+ return s + '}\n';
273
+ }
274
+
275
+ function prefabChild(c: PrefabChild, d: number): string {
276
+ let s = '{\n';
277
+ s = s + ind(d + 1) + '"id": ' + str(c.id) + ',\n';
278
+ s = s + ind(d + 1) + '"modelRef": ' + (c.modelRef === null ? 'null' : str(c.modelRef)) + ',\n';
279
+ s = s + ind(d + 1) + '"prefabRef": ' + (c.prefabRef === null ? 'null' : str(c.prefabRef)) + ',\n';
280
+ s = s + ind(d + 1) + '"transform": ' + transform(c.transform, d + 1) + ',\n';
281
+ s = s + ind(d + 1) + '"tint": ' + (c.tint === null ? 'null' : vec4(c.tint)) + ',\n';
282
+ s = s + ind(d + 1) + '"tags": ' + strs(c.tags) + '\n';
283
+ return s + ind(d) + '}';
284
+ }
285
+
286
+ export function serializePrefab(p: PrefabData): string {
287
+ // schemaVersion and bounds were MISSING here until 2026-07-15 — every prefab
288
+ // ever saved lost both (schemaVersion was silently backfilled by migration on
289
+ // reload; bounds came back as undefined). If you add a field to PrefabData,
290
+ // it must be added here too, or savePrefab drops it.
291
+ let s = '{\n';
292
+ s = s + ind(1) + '"schemaVersion": ' + num(p.schemaVersion) + ',\n';
293
+ s = s + ind(1) + '"id": ' + str(p.id) + ',\n';
294
+ s = s + ind(1) + '"name": ' + str(p.name) + ',\n';
295
+ s = s + ind(1) + '"children": ' + arr(p.children, 1, prefabChild) + ',\n';
296
+ s = s + ind(1) + '"bounds": {\n'
297
+ + ind(2) + '"min": ' + vec3(p.bounds.min) + ',\n'
298
+ + ind(2) + '"max": ' + vec3(p.bounds.max) + '\n'
299
+ + ind(1) + '}\n';
300
+ return s + '}\n';
301
+ }