@jgengine/core 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/ai/flock.d.ts +36 -0
  3. package/dist/ai/flock.js +182 -0
  4. package/dist/ai/groupAssist.d.ts +19 -0
  5. package/dist/ai/groupAssist.js +59 -0
  6. package/dist/ai/laneSelect.d.ts +21 -0
  7. package/dist/ai/laneSelect.js +37 -0
  8. package/dist/ai/mobBrain.d.ts +68 -0
  9. package/dist/ai/mobBrain.js +115 -0
  10. package/dist/ai/spawnDirector.d.ts +13 -0
  11. package/dist/ai/spawnDirector.js +41 -17
  12. package/dist/ai/spawnPoint.d.ts +5 -0
  13. package/dist/ai/spawnPoint.js +5 -0
  14. package/dist/ai/threat.d.ts +2 -0
  15. package/dist/ai/threat.js +34 -0
  16. package/dist/audio/audioFalloff.d.ts +5 -2
  17. package/dist/audio/music.d.ts +53 -0
  18. package/dist/audio/music.js +34 -0
  19. package/dist/audio/synth.d.ts +60 -0
  20. package/dist/audio/synth.js +8 -0
  21. package/dist/authoring.d.ts +14 -0
  22. package/dist/authoring.js +14 -0
  23. package/dist/cartridge/runtime.d.ts +15 -0
  24. package/dist/cartridge/runtime.js +556 -0
  25. package/dist/cartridge/spec.d.ts +188 -0
  26. package/dist/cartridge/spec.js +19 -0
  27. package/dist/cartridge/validate.d.ts +2 -0
  28. package/dist/cartridge/validate.js +78 -0
  29. package/dist/combat/abilityKit.d.ts +17 -2
  30. package/dist/combat/abilityKit.js +62 -13
  31. package/dist/combat/castRunner.d.ts +40 -0
  32. package/dist/combat/castRunner.js +56 -0
  33. package/dist/combat/comboPoints.d.ts +14 -0
  34. package/dist/combat/comboPoints.js +52 -0
  35. package/dist/combat/defensiveWindow.d.ts +1 -1
  36. package/dist/combat/defensiveWindow.js +5 -3
  37. package/dist/combat/dotField.d.ts +43 -0
  38. package/dist/combat/dotField.js +48 -0
  39. package/dist/combat/projectiles.d.ts +33 -12
  40. package/dist/combat/projectiles.js +214 -129
  41. package/dist/combat/regenShield.d.ts +35 -0
  42. package/dist/combat/regenShield.js +45 -0
  43. package/dist/combat/resistance.d.ts +16 -4
  44. package/dist/combat/resistance.js +43 -7
  45. package/dist/combat/resourcePool.d.ts +20 -0
  46. package/dist/combat/resourcePool.js +55 -0
  47. package/dist/combat/shotOrigin.d.ts +52 -0
  48. package/dist/combat/shotOrigin.js +86 -0
  49. package/dist/combat/telegraph.d.ts +25 -0
  50. package/dist/combat/telegraph.js +47 -0
  51. package/dist/crafting/recipe.js +4 -1
  52. package/dist/devtools/devtools.d.ts +72 -24
  53. package/dist/devtools/devtools.js +449 -101
  54. package/dist/devtools/transformTunables.js +3 -3
  55. package/dist/devtools/tunableSchema.d.ts +140 -0
  56. package/dist/devtools/tunableSchema.js +476 -0
  57. package/dist/editor/commands.d.ts +65 -0
  58. package/dist/editor/commands.js +193 -0
  59. package/dist/editor/document.d.ts +40 -0
  60. package/dist/editor/document.js +153 -0
  61. package/dist/editor/index.d.ts +30 -0
  62. package/dist/editor/index.js +6 -0
  63. package/dist/editor/types.d.ts +69 -0
  64. package/dist/editor/types.js +29 -0
  65. package/dist/faction/factions.d.ts +33 -0
  66. package/dist/faction/factions.js +76 -0
  67. package/dist/faction/reputation.d.ts +32 -0
  68. package/dist/faction/reputation.js +82 -0
  69. package/dist/game/chat.d.ts +2 -0
  70. package/dist/game/chat.js +9 -1
  71. package/dist/game/chatFilter.d.ts +15 -0
  72. package/dist/game/chatFilter.js +55 -0
  73. package/dist/game/connectedPlayers.d.ts +28 -0
  74. package/dist/game/connectedPlayers.js +21 -0
  75. package/dist/game/controlGate.d.ts +4 -0
  76. package/dist/game/controlGate.js +7 -0
  77. package/dist/game/defineGame.d.ts +44 -1
  78. package/dist/game/defineGame.js +1 -0
  79. package/dist/game/events.d.ts +25 -0
  80. package/dist/game/gamePhase.d.ts +20 -0
  81. package/dist/game/gamePhase.js +21 -0
  82. package/dist/game/keyValueStore.d.ts +25 -0
  83. package/dist/game/keyValueStore.js +61 -0
  84. package/dist/game/levelSequence.d.ts +24 -2
  85. package/dist/game/levelSequence.js +51 -2
  86. package/dist/game/lootTable.d.ts +14 -1
  87. package/dist/game/lootTable.js +28 -7
  88. package/dist/game/modelAnimation.d.ts +8 -0
  89. package/dist/game/modelAnimation.js +18 -0
  90. package/dist/game/playableGame.d.ts +116 -4
  91. package/dist/game/playableGame.js +1 -1
  92. package/dist/game/quest.d.ts +7 -3
  93. package/dist/game/quest.js +17 -8
  94. package/dist/game/race.d.ts +39 -1
  95. package/dist/game/race.js +96 -11
  96. package/dist/game/recordBook.d.ts +27 -0
  97. package/dist/game/recordBook.js +86 -0
  98. package/dist/game/talents.d.ts +47 -0
  99. package/dist/game/talents.js +140 -0
  100. package/dist/index.d.ts +8 -0
  101. package/dist/index.js +8 -0
  102. package/dist/input/actionBindings.d.ts +2 -0
  103. package/dist/input/actionBindings.js +1 -0
  104. package/dist/input/axisInput.d.ts +33 -3
  105. package/dist/input/axisInput.js +60 -4
  106. package/dist/input/bindingOverrides.d.ts +25 -0
  107. package/dist/input/bindingOverrides.js +95 -0
  108. package/dist/input/pointerAxis.d.ts +30 -0
  109. package/dist/input/pointerAxis.js +31 -0
  110. package/dist/input/touchScheme.d.ts +6 -0
  111. package/dist/input/touchScheme.js +45 -7
  112. package/dist/interaction/contextMenu.d.ts +1 -0
  113. package/dist/interaction/contextMenu.js +1 -0
  114. package/dist/interaction/proximityPrompt.d.ts +3 -1
  115. package/dist/interaction/proximityPrompt.js +3 -3
  116. package/dist/meta/changelog.d.ts +1 -1
  117. package/dist/meta/changelog.js +44 -1
  118. package/dist/movement/dash.d.ts +3 -0
  119. package/dist/movement/dash.js +19 -5
  120. package/dist/movement/glideModel.d.ts +44 -0
  121. package/dist/movement/glideModel.js +54 -0
  122. package/dist/movement/grappleSwing.d.ts +34 -0
  123. package/dist/movement/grappleSwing.js +56 -0
  124. package/dist/movement/leaderTrail.d.ts +29 -0
  125. package/dist/movement/leaderTrail.js +67 -0
  126. package/dist/movement/movementModel.d.ts +4 -0
  127. package/dist/movement/movementModel.js +13 -0
  128. package/dist/movement/playerMovement.d.ts +41 -0
  129. package/dist/movement/playerMovement.js +181 -0
  130. package/dist/movement/steering.d.ts +27 -0
  131. package/dist/movement/steering.js +40 -0
  132. package/dist/multiplayer/feedWriteGate.d.ts +10 -0
  133. package/dist/multiplayer/feedWriteGate.js +12 -0
  134. package/dist/multiplayer/presenceModel.d.ts +14 -2
  135. package/dist/multiplayer/presenceModel.js +15 -3
  136. package/dist/nav/corridors.d.ts +30 -0
  137. package/dist/nav/corridors.js +69 -0
  138. package/dist/nav/navGrid.js +30 -4
  139. package/dist/nav/railGraph.d.ts +66 -0
  140. package/dist/nav/railGraph.js +129 -0
  141. package/dist/nav/timetable.d.ts +41 -0
  142. package/dist/nav/timetable.js +94 -0
  143. package/dist/physics/buoyancy.d.ts +6 -0
  144. package/dist/physics/buoyancy.js +14 -0
  145. package/dist/physics/flowTube.d.ts +32 -0
  146. package/dist/physics/flowTube.js +64 -0
  147. package/dist/physics/forceVolume.d.ts +27 -0
  148. package/dist/physics/forceVolume.js +38 -0
  149. package/dist/physics/kinematicVehicle.d.ts +52 -0
  150. package/dist/physics/kinematicVehicle.js +81 -0
  151. package/dist/physics/physicsWorld.d.ts +13 -6
  152. package/dist/physics/physicsWorld.js +169 -40
  153. package/dist/physics/ragdoll.js +1 -1
  154. package/dist/physics/vehicleBody.js +2 -1
  155. package/dist/random/rng.d.ts +2 -0
  156. package/dist/random/rng.js +2 -0
  157. package/dist/random/seedLink.d.ts +5 -0
  158. package/dist/random/seedLink.js +35 -0
  159. package/dist/render/postProcessing.d.ts +56 -0
  160. package/dist/render/postProcessing.js +1 -0
  161. package/dist/runtime/adapter.d.ts +19 -0
  162. package/dist/runtime/adapter.js +11 -5
  163. package/dist/runtime/cameraDirector.d.ts +6 -1
  164. package/dist/runtime/cameraDirector.js +6 -0
  165. package/dist/runtime/commandRunner.d.ts +2 -2
  166. package/dist/runtime/commandRunner.js +2 -2
  167. package/dist/runtime/gameContext.d.ts +72 -8
  168. package/dist/runtime/gameContext.js +374 -61
  169. package/dist/runtime/gameRuntime.d.ts +5 -1
  170. package/dist/runtime/gameRuntime.js +13 -12
  171. package/dist/runtime/hostPersistence.d.ts +9 -1
  172. package/dist/runtime/hostPersistence.js +23 -8
  173. package/dist/runtime/hostedGameRunner.d.ts +49 -0
  174. package/dist/runtime/hostedGameRunner.js +62 -0
  175. package/dist/runtime/hostedWorldSession.d.ts +65 -0
  176. package/dist/runtime/hostedWorldSession.js +57 -0
  177. package/dist/runtime/inputSnapshot.d.ts +18 -0
  178. package/dist/runtime/inputSnapshot.js +7 -0
  179. package/dist/runtime/motionIntents.d.ts +16 -6
  180. package/dist/runtime/motionIntents.js +31 -2
  181. package/dist/runtime/snapshot.d.ts +1 -0
  182. package/dist/runtime/snapshot.js +1 -1
  183. package/dist/runtime/visibility.d.ts +74 -0
  184. package/dist/runtime/visibility.js +127 -0
  185. package/dist/runtime/worldChannel.d.ts +58 -0
  186. package/dist/runtime/worldChannel.js +69 -0
  187. package/dist/runtime/worldMirror.d.ts +24 -0
  188. package/dist/runtime/worldMirror.js +31 -0
  189. package/dist/runtime/worldReplication.d.ts +46 -0
  190. package/dist/runtime/worldReplication.js +185 -0
  191. package/dist/runtime/worldSnapshot.d.ts +17 -0
  192. package/dist/runtime/worldSnapshot.js +15 -0
  193. package/dist/scene/assetCatalog.d.ts +1 -0
  194. package/dist/scene/colliders.d.ts +50 -0
  195. package/dist/scene/colliders.js +101 -0
  196. package/dist/scene/entityStats.d.ts +4 -0
  197. package/dist/scene/entityStats.js +21 -0
  198. package/dist/scene/entityStore.d.ts +51 -5
  199. package/dist/scene/entityStore.js +119 -12
  200. package/dist/scene/objectQuery.d.ts +13 -2
  201. package/dist/scene/objectQuery.js +34 -2
  202. package/dist/scene/objectStore.d.ts +2 -0
  203. package/dist/scene/objectStore.js +31 -12
  204. package/dist/scene/sceneRaycast.d.ts +80 -0
  205. package/dist/scene/sceneRaycast.js +284 -0
  206. package/dist/scene/spatial.d.ts +11 -16
  207. package/dist/scene/spatial.js +99 -38
  208. package/dist/sensor/recordingBuffer.d.ts +6 -0
  209. package/dist/sensor/recordingBuffer.js +7 -0
  210. package/dist/sensor/replayLoop.d.ts +39 -0
  211. package/dist/sensor/replayLoop.js +64 -0
  212. package/dist/sensor/visionCone.d.ts +31 -0
  213. package/dist/sensor/visionCone.js +72 -0
  214. package/dist/session/extraction.d.ts +2 -2
  215. package/dist/session/extraction.js +13 -2
  216. package/dist/settings/settingsModel.d.ts +97 -0
  217. package/dist/settings/settingsModel.js +100 -0
  218. package/dist/stats/statModifiers.d.ts +5 -2
  219. package/dist/stats/statModifiers.js +32 -14
  220. package/dist/store/observableKeyedStore.d.ts +4 -0
  221. package/dist/store/observableKeyedStore.js +17 -2
  222. package/dist/survival/decayMeter.d.ts +1 -1
  223. package/dist/survival/decayMeter.js +0 -1
  224. package/dist/tactics/fallingGrid.d.ts +2 -2
  225. package/dist/tactics/fallingGrid.js +2 -2
  226. package/dist/time/beatClock.d.ts +18 -0
  227. package/dist/time/beatClock.js +24 -0
  228. package/dist/time/idleProgress.d.ts +29 -0
  229. package/dist/time/idleProgress.js +31 -0
  230. package/dist/time/serverTick.d.ts +7 -1
  231. package/dist/time/serverTick.js +27 -4
  232. package/dist/time/stateSchedule.d.ts +57 -0
  233. package/dist/time/stateSchedule.js +121 -0
  234. package/dist/turn/commit.d.ts +2 -2
  235. package/dist/turn/commit.js +2 -2
  236. package/dist/ui/gameLayout.d.ts +125 -0
  237. package/dist/ui/gameLayout.js +129 -0
  238. package/dist/ui/hudLayout.d.ts +58 -0
  239. package/dist/ui/hudLayout.js +207 -0
  240. package/dist/ui/hudScale.d.ts +44 -0
  241. package/dist/ui/hudScale.js +55 -0
  242. package/dist/ui/orientation.d.ts +39 -0
  243. package/dist/ui/orientation.js +49 -0
  244. package/dist/ui/swingTimer.d.ts +42 -0
  245. package/dist/ui/swingTimer.js +38 -0
  246. package/dist/visibility/assetStreaming.d.ts +66 -0
  247. package/dist/visibility/assetStreaming.js +201 -0
  248. package/dist/visibility/bounds.d.ts +66 -0
  249. package/dist/visibility/bounds.js +113 -0
  250. package/dist/visibility/camera.d.ts +21 -0
  251. package/dist/visibility/camera.js +3 -0
  252. package/dist/visibility/config.d.ts +21 -0
  253. package/dist/visibility/config.js +1 -0
  254. package/dist/visibility/diagnostics.d.ts +26 -0
  255. package/dist/visibility/diagnostics.js +36 -0
  256. package/dist/visibility/distance.d.ts +11 -0
  257. package/dist/visibility/distance.js +30 -0
  258. package/dist/visibility/frustum.d.ts +47 -0
  259. package/dist/visibility/frustum.js +186 -0
  260. package/dist/visibility/occlusion.d.ts +36 -0
  261. package/dist/visibility/occlusion.js +45 -0
  262. package/dist/visibility/settings.d.ts +75 -0
  263. package/dist/visibility/settings.js +49 -0
  264. package/dist/visibility/simulationCulling.d.ts +34 -0
  265. package/dist/visibility/simulationCulling.js +36 -0
  266. package/dist/visibility/spatialIndex.d.ts +45 -0
  267. package/dist/visibility/spatialIndex.js +212 -0
  268. package/dist/visibility/visibilitySystem.d.ts +90 -0
  269. package/dist/visibility/visibilitySystem.js +257 -0
  270. package/dist/world/buildings.d.ts +6 -0
  271. package/dist/world/buildings.js +152 -0
  272. package/dist/world/carve.d.ts +7 -2
  273. package/dist/world/carve.js +17 -7
  274. package/dist/world/cellStates.d.ts +45 -0
  275. package/dist/world/cellStates.js +103 -0
  276. package/dist/world/environmentSummary.d.ts +21 -4
  277. package/dist/world/environmentSummary.js +22 -3
  278. package/dist/world/features.d.ts +185 -8
  279. package/dist/world/features.js +64 -6
  280. package/dist/world/gridInstances.d.ts +7 -8
  281. package/dist/world/gridInstances.js +145 -7
  282. package/dist/world/lod.d.ts +17 -0
  283. package/dist/world/lod.js +66 -0
  284. package/dist/world/mapLayers.d.ts +60 -0
  285. package/dist/world/mapLayers.js +44 -0
  286. package/dist/world/massing.d.ts +45 -0
  287. package/dist/world/massing.js +581 -0
  288. package/dist/world/minimap.d.ts +13 -1
  289. package/dist/world/minimap.js +21 -2
  290. package/dist/world/roads.d.ts +47 -0
  291. package/dist/world/roads.js +146 -0
  292. package/dist/world/streets.d.ts +80 -0
  293. package/dist/world/streets.js +144 -0
  294. package/dist/world/terrain.d.ts +69 -3
  295. package/dist/world/terrain.js +309 -16
  296. package/dist/world/water.d.ts +7 -0
  297. package/dist/world/water.js +19 -4
  298. package/dist/world/weather.d.ts +3 -18
  299. package/dist/world/weather.js +4 -17
  300. package/dist/world/windZones.d.ts +47 -0
  301. package/dist/world/windZones.js +59 -0
  302. package/llms.txt +3013 -1726
  303. package/package.json +12 -2
@@ -1,32 +1,20 @@
1
1
  import { createChangeSignal } from "../store/changeSignal.js";
2
+ import { CONTROL_SCHEMA_VERSION, MAX_SCAN_DEPTH, MAX_SCAN_TARGETS, MAX_TABLE_ENTRIES, OVERRIDES_FORMAT_VERSION, choiceValues, cloneValue, discoverableKind, expandAxisMeta, inferKind, isScannableContainer, joinTunablePath, normalizeColorValue, ownWritableDataKeys, parseOverridesPayload, resolveChoices, sliderBounds, validateControlValue, } from "./tunableSchema.js";
3
+ export { CONTROL_SCHEMA_VERSION, OVERRIDES_FORMAT_VERSION, convertAngle, escapePathSegment, formatColor, joinTunablePath, normalizeAngle, normalizeColorValue, parseColor, parseOverridesPayload, splitTunablePath, unescapePathSegment, validateControlValue, } from "./tunableSchema.js";
2
4
  const FRAME_CAPACITY = 240;
3
5
  const RECENT_FRAMES = 60;
4
- const LONG_FRAME_MS = 33.4;
6
+ export const LONG_FRAME_MS = 33.4;
5
7
  const LATENCY_CAPACITY = 120;
6
8
  const LOG_CAPACITY = 200;
7
9
  const LOG_MESSAGE_MAX = 400;
8
- const COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
10
+ const LONG_FRAME_CAPACITY = 40;
11
+ const MAX_PHASES_PER_FRAME = 24;
12
+ const MAX_LONG_FRAME_PHASES = 8;
13
+ const PHASE_HOT_MS = 2;
9
14
  function nowMs() {
10
15
  const perf = globalThis.performance;
11
16
  return perf !== undefined ? perf.now() : Date.now();
12
17
  }
13
- function inferKind(initial, hasOptions) {
14
- if (hasOptions)
15
- return "select";
16
- if (typeof initial === "number")
17
- return "slider";
18
- if (typeof initial === "boolean")
19
- return "toggle";
20
- if (typeof initial === "string" && COLOR_PATTERN.test(initial))
21
- return "color";
22
- return "text";
23
- }
24
- function sliderBounds(initial, options) {
25
- const min = options?.min ?? (initial < 0 ? initial * 2 : 0);
26
- const max = options?.max ?? (initial > 0 ? initial * 2 : initial < 0 ? 0 : 1);
27
- const step = options?.step ?? (max - min) / 100;
28
- return { min, max, step };
29
- }
30
18
  function formatLogArg(arg) {
31
19
  if (typeof arg === "string")
32
20
  return arg;
@@ -49,45 +37,148 @@ function percentile(sorted, fraction) {
49
37
  const index = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction));
50
38
  return sorted[index];
51
39
  }
52
- const MAX_TABLE_ENTRIES = 64;
53
- function discoverableKind(value) {
54
- if (typeof value === "number" && Number.isFinite(value))
55
- return "slider";
56
- if (typeof value === "boolean")
57
- return "toggle";
58
- if (typeof value === "string" && COLOR_PATTERN.test(value))
59
- return "color";
60
- return null;
40
+ function clampMs(value) {
41
+ return value > 0 && Number.isFinite(value) ? value : 0;
42
+ }
43
+ function rankPhases(phases) {
44
+ const entries = Object.entries(phases)
45
+ .filter(([, ms]) => Number.isFinite(ms) && ms > 0)
46
+ .map(([name, ms]) => ({ name, ms }))
47
+ .sort((a, b) => b.ms - a.ms)
48
+ .slice(0, MAX_LONG_FRAME_PHASES);
49
+ const total = entries.reduce((sum, entry) => sum + entry.ms, 0);
50
+ return entries.map((entry) => ({
51
+ name: entry.name,
52
+ ms: entry.ms,
53
+ pct: total > 0 ? (entry.ms / total) * 100 : 0,
54
+ }));
55
+ }
56
+ function explainLongFrame(frameMs, simMs, outsideMs, ranked) {
57
+ const top = ranked[0] ?? null;
58
+ const topPhaseMs = top?.ms ?? 0;
59
+ const phaseBits = ranked
60
+ .filter((entry) => entry.ms >= PHASE_HOT_MS)
61
+ .slice(0, 3)
62
+ .map((entry) => `${entry.name} ${entry.ms.toFixed(1)}ms`);
63
+ const outsideDominates = outsideMs > topPhaseMs && outsideMs >= simMs * 0.55 && outsideMs >= frameMs * 0.4;
64
+ if (outsideDominates) {
65
+ const detail = phaseBits.length > 0 ? ` · sim was ${phaseBits.join(", ")}` : simMs > 1 ? ` · sim ${simMs.toFixed(1)}ms` : "";
66
+ return {
67
+ topPhase: top?.name ?? null,
68
+ culprit: "outside-sim",
69
+ reason: `display/render/browser hitch ${outsideMs.toFixed(1)}ms outside sim${detail}`,
70
+ };
71
+ }
72
+ if (top !== null && top.ms >= PHASE_HOT_MS) {
73
+ const rest = phaseBits.slice(1);
74
+ const restText = rest.length > 0 ? ` · also ${rest.join(", ")}` : "";
75
+ const outsideText = outsideMs >= PHASE_HOT_MS ? ` · +${outsideMs.toFixed(1)}ms outside sim` : "";
76
+ return {
77
+ topPhase: top.name,
78
+ culprit: top.name,
79
+ reason: `${top.name} ${top.ms.toFixed(1)}ms (${top.pct.toFixed(0)}% of timed sim)${restText}${outsideText}`,
80
+ };
81
+ }
82
+ if (simMs >= frameMs * 0.5) {
83
+ return {
84
+ topPhase: null,
85
+ culprit: "sim",
86
+ reason: `sim ${simMs.toFixed(1)}ms with no named phase ≥${PHASE_HOT_MS}ms — wrap hot work in measure("name", fn)`,
87
+ };
88
+ }
89
+ return {
90
+ topPhase: null,
91
+ culprit: "outside-sim",
92
+ reason: `frame ${frameMs.toFixed(1)}ms · sim ${simMs.toFixed(1)}ms · outside ${outsideMs.toFixed(1)}ms (render/React/GC/tab throttle)`,
93
+ };
61
94
  }
62
- function discoverableEntries(candidate) {
63
- if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate))
64
- return [];
65
- const proto = Object.getPrototypeOf(candidate);
66
- if (proto !== Object.prototype && proto !== null)
67
- return [];
68
- const entries = [];
69
- for (const [key, value] of Object.entries(candidate)) {
70
- const kind = discoverableKind(value);
71
- if (kind !== null)
72
- entries.push([key, kind]);
73
- if (entries.length > MAX_TABLE_ENTRIES)
74
- return [];
95
+ function collectScanTargets(root, meta) {
96
+ const skipped = [];
97
+ if (!isScannableContainer(root)) {
98
+ if (root !== null && typeof root === "object") {
99
+ skipped.push({ path: "", reason: "not a plain object or array" });
100
+ }
101
+ return { targets: [], skipped };
102
+ }
103
+ const out = [];
104
+ const visited = new Set([root]);
105
+ let frontier = [{ path: "", target: root }];
106
+ for (let depth = 0; depth <= MAX_SCAN_DEPTH && frontier.length > 0; depth += 1) {
107
+ const next = [];
108
+ for (const { path, target } of frontier) {
109
+ const keys = ownWritableDataKeys(target);
110
+ let scalarCount = 0;
111
+ for (const key of keys) {
112
+ const value = target[key];
113
+ const childPath = joinTunablePath(path, key);
114
+ const fieldMeta = meta?.[childPath];
115
+ const kind = discoverableKind(value, fieldMeta);
116
+ if (kind !== null) {
117
+ scalarCount += 1;
118
+ if (scalarCount > MAX_TABLE_ENTRIES) {
119
+ skipped.push({ path: path === "" ? "*" : path, reason: `more than ${MAX_TABLE_ENTRIES} scalar entries` });
120
+ scalarCount = -1;
121
+ break;
122
+ }
123
+ }
124
+ }
125
+ if (scalarCount < 0)
126
+ continue;
127
+ for (const key of keys) {
128
+ const value = target[key];
129
+ const childPath = joinTunablePath(path, key);
130
+ const fieldMeta = meta?.[childPath];
131
+ const kind = discoverableKind(value, fieldMeta);
132
+ if (kind !== null) {
133
+ if (out.length >= MAX_SCAN_TARGETS) {
134
+ skipped.push({ path: childPath, reason: `scan cap ${MAX_SCAN_TARGETS}` });
135
+ return { targets: out, skipped };
136
+ }
137
+ out.push({ path: childPath, target, key, kind, options: fieldMeta });
138
+ continue;
139
+ }
140
+ if (value !== null && typeof value === "object") {
141
+ if (!isScannableContainer(value)) {
142
+ skipped.push({ path: childPath, reason: "unsupported structure" });
143
+ continue;
144
+ }
145
+ if (visited.has(value)) {
146
+ skipped.push({ path: childPath, reason: "cycle" });
147
+ continue;
148
+ }
149
+ if (depth === MAX_SCAN_DEPTH) {
150
+ skipped.push({ path: childPath, reason: `depth > ${MAX_SCAN_DEPTH}` });
151
+ continue;
152
+ }
153
+ visited.add(value);
154
+ next.push({ path: childPath, target: value });
155
+ }
156
+ }
157
+ }
158
+ frontier = next;
75
159
  }
76
- return entries;
160
+ return { targets: out, skipped };
77
161
  }
78
162
  export function createDevtools() {
79
163
  const signal = createChangeSignal();
80
164
  const frameMsBuffer = new Float64Array(FRAME_CAPACITY);
81
165
  const simMsBuffer = new Float64Array(FRAME_CAPACITY);
166
+ const outsideMsBuffer = new Float64Array(FRAME_CAPACITY);
82
167
  let frameIndex = 0;
83
168
  let frameCount = 0;
84
169
  let renderSample = null;
85
170
  const logEntries = [];
86
171
  let consoleCaptured = false;
87
172
  const latencySamples = [];
173
+ const longFrameEvents = [];
174
+ const phaseAccumulators = new Map();
175
+ let currentPhases = {};
176
+ let currentPhaseCount = 0;
88
177
  const controlRecords = new Map();
89
178
  const discoveredRecords = new Map();
90
179
  const probeReaders = new Map();
180
+ let boundProps = new WeakMap();
181
+ let discoverySkips = [];
91
182
  const readProbes = () => {
92
183
  const values = {};
93
184
  for (const [name, read] of probeReaders) {
@@ -105,32 +196,124 @@ export function createDevtools() {
105
196
  if (logEntries.length > LOG_CAPACITY)
106
197
  logEntries.splice(0, logEntries.length - LOG_CAPACITY);
107
198
  };
199
+ const addPhaseMs = (name, ms) => {
200
+ const elapsed = clampMs(ms);
201
+ if (elapsed === 0 || name.length === 0)
202
+ return;
203
+ if (currentPhases[name] === undefined) {
204
+ if (currentPhaseCount >= MAX_PHASES_PER_FRAME)
205
+ return;
206
+ currentPhaseCount += 1;
207
+ currentPhases[name] = elapsed;
208
+ return;
209
+ }
210
+ currentPhases[name] = currentPhases[name] + elapsed;
211
+ };
212
+ const resetCurrentPhases = () => {
213
+ currentPhases = {};
214
+ currentPhaseCount = 0;
215
+ };
216
+ const recordPhaseStats = (phases) => {
217
+ for (const [name, ms] of Object.entries(phases)) {
218
+ const elapsed = clampMs(ms);
219
+ if (elapsed === 0)
220
+ continue;
221
+ const existing = phaseAccumulators.get(name);
222
+ if (existing === undefined) {
223
+ phaseAccumulators.set(name, { sumMs: elapsed, maxMs: elapsed, lastMs: elapsed, samples: 1 });
224
+ }
225
+ else {
226
+ existing.sumMs += elapsed;
227
+ existing.maxMs = Math.max(existing.maxMs, elapsed);
228
+ existing.lastMs = elapsed;
229
+ existing.samples += 1;
230
+ }
231
+ }
232
+ };
233
+ const phaseStatsList = (avgSimMs) => {
234
+ const list = [];
235
+ for (const [name, acc] of phaseAccumulators) {
236
+ const avgMs = acc.sumMs / acc.samples;
237
+ list.push({
238
+ name,
239
+ avgMs,
240
+ maxMs: acc.maxMs,
241
+ lastMs: acc.lastMs,
242
+ samples: acc.samples,
243
+ pctOfSim: avgSimMs > 0 ? (avgMs / avgSimMs) * 100 : 0,
244
+ });
245
+ }
246
+ list.sort((a, b) => b.avgMs - a.avgMs);
247
+ return list;
248
+ };
108
249
  const frameStats = () => {
109
250
  if (frameCount === 0)
110
251
  return null;
111
252
  const size = Math.min(frameCount, FRAME_CAPACITY);
112
253
  const frames = [];
113
254
  const sims = [];
255
+ const outsides = [];
114
256
  for (let offset = 0; offset < size; offset += 1) {
115
257
  const at = (frameIndex - size + offset + FRAME_CAPACITY) % FRAME_CAPACITY;
116
258
  frames.push(frameMsBuffer[at]);
117
259
  sims.push(simMsBuffer[at]);
260
+ outsides.push(outsideMsBuffer[at]);
118
261
  }
119
262
  const recent = frames.slice(-RECENT_FRAMES);
120
263
  const recentAvg = recent.reduce((sum, ms) => sum + ms, 0) / recent.length;
121
264
  const sorted = [...frames].sort((a, b) => a - b);
265
+ const avgSimMs = sims.reduce((sum, ms) => sum + ms, 0) / sims.length;
122
266
  return {
123
267
  fps: recentAvg > 0 ? 1000 / recentAvg : 0,
124
268
  avgFrameMs: frames.reduce((sum, ms) => sum + ms, 0) / frames.length,
125
269
  p95FrameMs: percentile(sorted, 0.95),
126
270
  maxFrameMs: sorted[sorted.length - 1],
127
- avgSimMs: sims.reduce((sum, ms) => sum + ms, 0) / sims.length,
271
+ avgSimMs,
128
272
  maxSimMs: sims.reduce((max, ms) => Math.max(max, ms), 0),
273
+ avgOutsideMs: outsides.reduce((sum, ms) => sum + ms, 0) / outsides.length,
274
+ maxOutsideMs: outsides.reduce((max, ms) => Math.max(max, ms), 0),
129
275
  longFrames: frames.filter((ms) => ms > LONG_FRAME_MS).length,
130
276
  samples: size,
131
277
  recentFrameMs: recent,
278
+ phases: phaseStatsList(avgSimMs),
132
279
  };
133
280
  };
281
+ const recordFrame = (sample) => {
282
+ const frameMs = clampMs(sample.frameMs);
283
+ const simMs = clampMs(sample.simMs);
284
+ const outsideMs = clampMs(frameMs - simMs);
285
+ const phases = sample.phases !== undefined
286
+ ? Object.fromEntries(Object.entries(sample.phases)
287
+ .filter(([, ms]) => Number.isFinite(ms) && ms > 0)
288
+ .slice(0, MAX_PHASES_PER_FRAME))
289
+ : { ...currentPhases };
290
+ frameMsBuffer[frameIndex] = frameMs;
291
+ simMsBuffer[frameIndex] = simMs;
292
+ outsideMsBuffer[frameIndex] = outsideMs;
293
+ frameIndex = (frameIndex + 1) % FRAME_CAPACITY;
294
+ frameCount += 1;
295
+ recordPhaseStats(phases);
296
+ if (frameMs > LONG_FRAME_MS) {
297
+ const ranked = rankPhases(phases);
298
+ const explained = explainLongFrame(frameMs, simMs, outsideMs, ranked);
299
+ longFrameEvents.push({
300
+ at: Date.now(),
301
+ frameMs,
302
+ simMs,
303
+ outsideMs,
304
+ phases: ranked,
305
+ topPhase: explained.topPhase,
306
+ culprit: explained.culprit,
307
+ reason: explained.reason,
308
+ probes: readProbes(),
309
+ render: renderSample,
310
+ });
311
+ if (longFrameEvents.length > LONG_FRAME_CAPACITY) {
312
+ longFrameEvents.splice(0, longFrameEvents.length - LONG_FRAME_CAPACITY);
313
+ }
314
+ }
315
+ resetCurrentPhases();
316
+ };
134
317
  const latencyStats = () => {
135
318
  if (latencySamples.length === 0)
136
319
  return null;
@@ -150,6 +333,18 @@ export function createDevtools() {
150
333
  samples: latencySamples.length,
151
334
  };
152
335
  };
336
+ const validationContext = (record) => ({
337
+ min: record.min,
338
+ max: record.max,
339
+ step: record.step,
340
+ integer: record.integer,
341
+ unit: record.unit,
342
+ wrap: record.wrap,
343
+ choices: record.choices,
344
+ options: record.options,
345
+ axisBounds: record.axisBounds,
346
+ alpha: record.hasAlpha,
347
+ });
153
348
  const toControl = (record) => ({
154
349
  name: record.name,
155
350
  kind: record.kind,
@@ -159,33 +354,96 @@ export function createDevtools() {
159
354
  min: record.min,
160
355
  max: record.max,
161
356
  step: record.step,
162
- options: record.options,
357
+ options: record.options ?? choiceValues(record.choices),
358
+ choices: record.choices,
359
+ unit: record.unit,
360
+ displayUnit: record.displayUnit,
361
+ wrap: record.wrap,
362
+ integer: record.integer,
363
+ axisLabels: record.axisBounds?.labels,
364
+ axisMin: record.axisBounds?.min,
365
+ axisMax: record.axisBounds?.max,
366
+ axisStep: record.axisBounds?.step,
367
+ hasAlpha: record.hasAlpha,
368
+ schemaVersion: record.schemaVersion,
163
369
  read: () => record.value,
164
370
  write: (value) => writeControl(record, value),
165
- reset: () => writeControl(record, record.initial),
371
+ reset: () => {
372
+ writeControl(record, cloneValue(record.initial));
373
+ },
166
374
  });
167
375
  const writeControl = (record, raw) => {
168
- let next = raw;
169
- if (record.kind === "slider" && typeof next === "number") {
170
- if (record.min !== undefined)
171
- next = Math.max(record.min, next);
172
- if (record.max !== undefined)
173
- next = Math.min(record.max, next);
376
+ const validated = validateControlValue(record.kind, raw, validationContext(record));
377
+ if (!validated.ok)
378
+ return false;
379
+ const next = validated.value;
380
+ if (record.kind === "vec2" || record.kind === "vec3" || record.kind === "vec4" || record.kind === "interval") {
381
+ if (JSON.stringify(record.value) === JSON.stringify(next))
382
+ return true;
383
+ record.value = cloneValue(next);
384
+ }
385
+ else {
386
+ if (Object.is(record.value, next))
387
+ return true;
388
+ record.value = next;
174
389
  }
175
- if (Object.is(record.value, next))
176
- return;
177
- record.value = next;
178
390
  for (const listener of record.listeners)
179
- listener(next);
391
+ listener(record.value);
180
392
  signal.notify();
393
+ return true;
394
+ };
395
+ const hydrateRecord = (record, initial, options, kind) => {
396
+ record.kind = kind;
397
+ record.initial = cloneValue(initial);
398
+ record.min = undefined;
399
+ record.max = undefined;
400
+ record.step = undefined;
401
+ record.options = options?.options;
402
+ record.choices = resolveChoices(options?.options, options?.choices);
403
+ record.unit = options?.unit;
404
+ record.displayUnit = options?.displayUnit ?? options?.unit;
405
+ record.wrap = options?.wrap;
406
+ record.integer = options?.integer;
407
+ record.axisBounds = undefined;
408
+ record.hasAlpha = undefined;
409
+ record.schemaVersion = CONTROL_SCHEMA_VERSION;
410
+ if (kind === "slider" || kind === "angle") {
411
+ const bounds = sliderBounds(initial, options);
412
+ record.min = bounds.min;
413
+ record.max = bounds.max;
414
+ record.step = bounds.step;
415
+ if (kind === "angle") {
416
+ record.unit = options?.unit ?? "rad";
417
+ record.displayUnit = options?.displayUnit ?? "deg";
418
+ }
419
+ }
420
+ else if (kind === "vec2" || kind === "vec3" || kind === "vec4") {
421
+ const sample = Array.isArray(initial) ? initial : [];
422
+ record.axisBounds = expandAxisMeta(sample.length, options?.axisLabels, options?.axisMin ?? options?.min, options?.axisMax ?? options?.max, options?.axisStep ?? options?.step, sample);
423
+ }
424
+ else if (kind === "interval") {
425
+ record.min = options?.min;
426
+ record.max = options?.max;
427
+ record.step = options?.step;
428
+ record.integer = options?.integer;
429
+ }
430
+ else if (kind === "color") {
431
+ const normalized = normalizeColorValue(initial, options?.alpha);
432
+ if (normalized !== null) {
433
+ record.initial = normalized;
434
+ if (Object.is(record.value, initial) || record.value === initial) {
435
+ record.value = normalized;
436
+ }
437
+ record.hasAlpha = options?.alpha === true || normalized.length === 9;
438
+ }
439
+ }
181
440
  };
182
441
  const register = (name, initial, options) => {
183
- const kind = inferKind(initial, options?.options !== undefined);
442
+ const kind = inferKind(initial, options);
184
443
  const existing = controlRecords.get(name);
185
444
  const slashIndex = name.indexOf("/");
186
445
  const derivedGroup = slashIndex > 0 ? name.slice(0, slashIndex) : "general";
187
446
  const derivedLabel = slashIndex > 0 ? name.slice(slashIndex + 1) : name;
188
- const bounds = kind === "slider" ? sliderBounds(initial, options) : undefined;
189
447
  const record = existing !== undefined && existing.kind === kind
190
448
  ? existing
191
449
  : {
@@ -193,17 +451,17 @@ export function createDevtools() {
193
451
  kind,
194
452
  label: "",
195
453
  group: "",
196
- initial,
197
- value: initial,
454
+ initial: cloneValue(initial),
455
+ value: cloneValue(initial),
456
+ schemaVersion: CONTROL_SCHEMA_VERSION,
198
457
  listeners: new Set(),
199
458
  };
459
+ if (existing === undefined || existing.kind !== kind) {
460
+ record.value = cloneValue(initial);
461
+ }
200
462
  record.label = options?.label ?? derivedLabel;
201
463
  record.group = options?.group ?? derivedGroup;
202
- record.initial = initial;
203
- record.min = bounds?.min;
204
- record.max = bounds?.max;
205
- record.step = bounds?.step;
206
- record.options = options?.options;
464
+ hydrateRecord(record, initial, options, kind);
207
465
  if (options?.onChange !== undefined)
208
466
  record.listeners.add(options.onChange);
209
467
  controlRecords.set(name, record);
@@ -211,19 +469,21 @@ export function createDevtools() {
211
469
  return {
212
470
  name,
213
471
  kind,
214
- initial,
472
+ initial: record.initial,
215
473
  get value() {
216
474
  return record.value;
217
475
  },
218
476
  set: (value) => writeControl(record, value),
219
- reset: () => writeControl(record, record.initial),
477
+ reset: () => {
478
+ writeControl(record, cloneValue(record.initial));
479
+ },
220
480
  subscribe: (listener) => {
221
481
  record.listeners.add(listener);
222
482
  return () => record.listeners.delete(listener);
223
483
  },
224
484
  };
225
485
  };
226
- const bindDiscovered = (table, key, kind, accessor, source) => {
486
+ const bindDiscovered = (table, key, kind, accessor, source, options) => {
227
487
  const id = `${table}/${key}`;
228
488
  const existing = discoveredRecords.get(id);
229
489
  if (existing === undefined) {
@@ -232,9 +492,10 @@ export function createDevtools() {
232
492
  table,
233
493
  key,
234
494
  kind,
235
- initial: accessor.initial,
495
+ initial: cloneValue(accessor.initial),
236
496
  enabled: false,
237
497
  source,
498
+ options,
238
499
  get: accessor.get,
239
500
  set: accessor.set,
240
501
  });
@@ -245,31 +506,48 @@ export function createDevtools() {
245
506
  existing.source = source;
246
507
  existing.get = accessor.get;
247
508
  existing.set = accessor.set;
509
+ existing.kind = kind;
510
+ existing.options = options ?? existing.options;
248
511
  const control = controlRecords.get(id);
249
512
  if (existing.enabled && control !== undefined)
250
- accessor.set(control.value);
513
+ accessor.set(cloneValue(control.value));
251
514
  return true;
252
515
  };
253
- const scanTable = (table, candidate) => {
254
- const entries = discoverableEntries(candidate);
255
- if (entries.length === 0)
516
+ const scanTable = (table, candidate, meta) => {
517
+ const { targets, skipped } = collectScanTargets(candidate, meta);
518
+ discoverySkips = [
519
+ ...discoverySkips.filter((entry) => entry.path !== table && !entry.path.startsWith(`${table}/`)),
520
+ ...skipped.map((entry) => ({
521
+ path: entry.path === "" ? table : `${table}/${entry.path}`,
522
+ reason: entry.reason,
523
+ })),
524
+ ];
525
+ if (targets.length === 0)
256
526
  return 0;
257
- const target = candidate;
258
527
  let changed = false;
259
- for (const [key, kind] of entries) {
528
+ let bound = 0;
529
+ for (const { path, target, key, kind, options } of targets) {
530
+ const boundKeys = boundProps.get(target);
531
+ if (boundKeys?.has(key) === true && !discoveredRecords.has(`${table}/${path}`))
532
+ continue;
260
533
  const accessor = {
261
- initial: target[key],
534
+ initial: cloneValue(target[key]),
262
535
  get: () => target[key],
263
536
  set: (value) => {
264
537
  target[key] = value;
265
538
  },
266
539
  };
267
- if (bindDiscovered(table, key, kind, accessor, target))
540
+ if (bindDiscovered(table, path, kind, accessor, target, options))
268
541
  changed = true;
542
+ bound += 1;
543
+ if (boundKeys === undefined)
544
+ boundProps.set(target, new Set([key]));
545
+ else
546
+ boundKeys.add(key);
269
547
  }
270
548
  if (changed)
271
549
  signal.notify();
272
- return entries.length;
550
+ return bound;
273
551
  };
274
552
  const enableDiscovered = (id) => {
275
553
  const record = discoveredRecords.get(id);
@@ -277,20 +555,42 @@ export function createDevtools() {
277
555
  return;
278
556
  record.enabled = true;
279
557
  const current = record.get();
280
- const handle = register(id, record.initial, { group: record.table, label: record.key });
281
- handle.subscribe((value) => record.set(value));
282
- if (!Object.is(current, record.initial))
558
+ const handle = register(id, record.initial, {
559
+ ...(record.options ?? {}),
560
+ group: record.options?.group ?? record.table,
561
+ label: record.options?.label ?? record.key,
562
+ kind: record.kind,
563
+ });
564
+ handle.subscribe((value) => record.set(cloneValue(value)));
565
+ if (JSON.stringify(current) !== JSON.stringify(record.initial))
283
566
  handle.set(current);
284
567
  };
285
568
  return {
286
569
  frame: {
287
- record({ frameMs, simMs }) {
288
- frameMsBuffer[frameIndex] = frameMs;
289
- simMsBuffer[frameIndex] = simMs;
290
- frameIndex = (frameIndex + 1) % FRAME_CAPACITY;
291
- frameCount += 1;
292
- },
570
+ record: recordFrame,
293
571
  stats: frameStats,
572
+ longFrames: () => longFrameEvents,
573
+ clearLongFrames() {
574
+ longFrameEvents.length = 0;
575
+ },
576
+ },
577
+ profile: {
578
+ begin(name) {
579
+ const start = nowMs();
580
+ return () => addPhaseMs(name, nowMs() - start);
581
+ },
582
+ measure(name, fn) {
583
+ const start = nowMs();
584
+ try {
585
+ return fn();
586
+ }
587
+ finally {
588
+ addPhaseMs(name, nowMs() - start);
589
+ }
590
+ },
591
+ add: addPhaseMs,
592
+ current: () => ({ ...currentPhases }),
593
+ reset: resetCurrentPhases,
294
594
  },
295
595
  render: {
296
596
  record(sample) {
@@ -343,22 +643,22 @@ export function createDevtools() {
343
643
  },
344
644
  resetAll() {
345
645
  for (const record of controlRecords.values())
346
- writeControl(record, record.initial);
646
+ writeControl(record, cloneValue(record.initial));
347
647
  },
348
648
  },
349
649
  discover: {
350
- bind(table, key, accessor) {
351
- const kind = discoverableKind(accessor.initial);
650
+ bind(table, key, accessor, options) {
651
+ const kind = discoverableKind(accessor.initial, options);
352
652
  if (kind === null)
353
653
  return;
354
- if (bindDiscovered(table, key, kind, accessor, null))
654
+ if (bindDiscovered(table, key, kind, accessor, null, options))
355
655
  signal.notify();
356
656
  },
357
657
  scanTable,
358
- scanModule(exports) {
658
+ scanModule(exports, meta) {
359
659
  let total = 0;
360
660
  for (const [name, value] of Object.entries(exports)) {
361
- total += scanTable(name, value);
661
+ total += scanTable(name, value, meta);
362
662
  }
363
663
  return total;
364
664
  },
@@ -371,6 +671,7 @@ export function createDevtools() {
371
671
  enabled: record.enabled,
372
672
  read: () => record.get(),
373
673
  })),
674
+ skipped: () => discoverySkips,
374
675
  enable(id) {
375
676
  enableDiscovered(id);
376
677
  signal.notify();
@@ -380,18 +681,20 @@ export function createDevtools() {
380
681
  if (record === undefined || !record.enabled)
381
682
  return;
382
683
  record.enabled = false;
383
- record.set(record.initial);
684
+ record.set(cloneValue(record.initial));
384
685
  controlRecords.delete(id);
385
686
  signal.notify();
386
687
  },
387
688
  clear() {
388
689
  for (const record of discoveredRecords.values()) {
389
690
  if (record.enabled) {
390
- record.set(record.initial);
691
+ record.set(cloneValue(record.initial));
391
692
  controlRecords.delete(record.id);
392
693
  }
393
694
  }
394
695
  discoveredRecords.clear();
696
+ discoverySkips = [];
697
+ boundProps = new WeakMap();
395
698
  signal.notify();
396
699
  },
397
700
  },
@@ -399,21 +702,61 @@ export function createDevtools() {
399
702
  export() {
400
703
  const enabled = [...discoveredRecords.values()].filter((r) => r.enabled).map((r) => r.id);
401
704
  const values = {};
705
+ const schemas = {};
402
706
  for (const record of controlRecords.values()) {
403
- if (!Object.is(record.value, record.initial))
404
- values[record.name] = record.value;
707
+ schemas[record.name] = { kind: record.kind, schemaVersion: record.schemaVersion };
708
+ const same = record.kind === "vec2" ||
709
+ record.kind === "vec3" ||
710
+ record.kind === "vec4" ||
711
+ record.kind === "interval"
712
+ ? JSON.stringify(record.value) === JSON.stringify(record.initial)
713
+ : Object.is(record.value, record.initial);
714
+ if (!same)
715
+ values[record.name] = cloneValue(record.value);
405
716
  }
406
- return { enabled, values };
717
+ return {
718
+ version: OVERRIDES_FORMAT_VERSION,
719
+ enabled,
720
+ values,
721
+ schemas,
722
+ };
407
723
  },
408
- apply(overrides) {
409
- for (const id of overrides.enabled)
724
+ apply(raw) {
725
+ const parsed = parseOverridesPayload(raw);
726
+ const diagnostics = [...parsed.diagnostics];
727
+ const skipped = [];
728
+ if (parsed.overrides === null) {
729
+ return { applied: 0, skipped, diagnostics };
730
+ }
731
+ const overrides = parsed.overrides;
732
+ let applied = 0;
733
+ for (const id of overrides.enabled) {
734
+ if (!discoveredRecords.has(id) && !controlRecords.has(id)) {
735
+ skipped.push({ id, reason: "unknown control id" });
736
+ continue;
737
+ }
410
738
  enableDiscovered(id);
739
+ applied += 1;
740
+ }
411
741
  for (const [name, value] of Object.entries(overrides.values)) {
412
742
  const record = controlRecords.get(name);
413
- if (record !== undefined)
414
- writeControl(record, value);
743
+ if (record === undefined) {
744
+ skipped.push({ id: name, reason: "control not registered" });
745
+ continue;
746
+ }
747
+ const schema = overrides.schemas?.[name];
748
+ if (schema !== undefined && schema.kind !== record.kind) {
749
+ skipped.push({ id: name, reason: `kind mismatch stored=${schema.kind} current=${record.kind}` });
750
+ continue;
751
+ }
752
+ if (!writeControl(record, value)) {
753
+ skipped.push({ id: name, reason: "value failed validation" });
754
+ continue;
755
+ }
756
+ applied += 1;
415
757
  }
416
758
  signal.notify();
759
+ return { applied, skipped, diagnostics };
417
760
  },
418
761
  },
419
762
  probes: {
@@ -434,6 +777,7 @@ export function createDevtools() {
434
777
  frame: frameStats(),
435
778
  render: renderSample,
436
779
  latency: latencyStats(),
780
+ longFrames: [...longFrameEvents],
437
781
  logs: [...logEntries],
438
782
  probes: readProbes(),
439
783
  controls: [...controlRecords.values()].map((record) => ({
@@ -448,6 +792,7 @@ export function createDevtools() {
448
792
  value: record.get(),
449
793
  enabled: record.enabled,
450
794
  })),
795
+ skipped: [...discoverySkips],
451
796
  };
452
797
  },
453
798
  };
@@ -459,6 +804,9 @@ export function tunable(name, initial, options) {
459
804
  export function snapshotDevtools() {
460
805
  return devtools.snapshot();
461
806
  }
807
+ export function measureProfile(name, fn) {
808
+ return devtools.profile.measure(name, fn);
809
+ }
462
810
  export function instrumentLatency(target, methods, record = devtools.latency.record) {
463
811
  const wrapped = { ...target };
464
812
  for (const method of methods) {