@jgengine/core 0.7.0 → 0.9.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 (329) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +2 -0
  3. package/dist/ai/flock.d.ts +36 -0
  4. package/dist/ai/flock.js +182 -0
  5. package/dist/ai/groupAssist.d.ts +19 -0
  6. package/dist/ai/groupAssist.js +59 -0
  7. package/dist/ai/laneSelect.d.ts +21 -0
  8. package/dist/ai/laneSelect.js +37 -0
  9. package/dist/ai/mobBrain.d.ts +68 -0
  10. package/dist/ai/mobBrain.js +115 -0
  11. package/dist/ai/spawnDirector.d.ts +5 -0
  12. package/dist/ai/spawnDirector.js +14 -2
  13. package/dist/ai/threat.d.ts +2 -0
  14. package/dist/ai/threat.js +34 -0
  15. package/dist/anim/easing.d.ts +14 -0
  16. package/dist/anim/easing.js +55 -0
  17. package/dist/behaviour/behaviour.d.ts +136 -0
  18. package/dist/behaviour/behaviour.js +388 -0
  19. package/dist/cards/cardPile.d.ts +1 -0
  20. package/dist/cards/cardPile.js +12 -3
  21. package/dist/cartridge/runtime.d.ts +15 -0
  22. package/dist/cartridge/runtime.js +556 -0
  23. package/dist/cartridge/spec.d.ts +188 -0
  24. package/dist/cartridge/spec.js +19 -0
  25. package/dist/cartridge/validate.d.ts +2 -0
  26. package/dist/cartridge/validate.js +78 -0
  27. package/dist/combat/abilityKit.d.ts +22 -2
  28. package/dist/combat/abilityKit.js +72 -13
  29. package/dist/combat/castRunner.d.ts +40 -0
  30. package/dist/combat/castRunner.js +56 -0
  31. package/dist/combat/comboPoints.d.ts +14 -0
  32. package/dist/combat/comboPoints.js +52 -0
  33. package/dist/combat/defensiveWindow.d.ts +1 -1
  34. package/dist/combat/defensiveWindow.js +5 -3
  35. package/dist/combat/effects.d.ts +1 -1
  36. package/dist/combat/effects.js +9 -6
  37. package/dist/combat/projectiles.d.ts +64 -6
  38. package/dist/combat/projectiles.js +232 -74
  39. package/dist/combat/resistance.d.ts +16 -4
  40. package/dist/combat/resistance.js +43 -7
  41. package/dist/combat/resourcePool.d.ts +20 -0
  42. package/dist/combat/resourcePool.js +55 -0
  43. package/dist/combat/shotOrigin.d.ts +40 -0
  44. package/dist/combat/shotOrigin.js +57 -0
  45. package/dist/combat/telegraph.d.ts +30 -1
  46. package/dist/combat/telegraph.js +61 -0
  47. package/dist/commands/commandRegistry.d.ts +1 -1
  48. package/dist/commands/commandRegistry.js +2 -1
  49. package/dist/crafting/recipe.js +4 -1
  50. package/dist/data/dataSource.d.ts +26 -0
  51. package/dist/data/dataSource.js +93 -0
  52. package/dist/data/devProxy.d.ts +11 -0
  53. package/dist/data/devProxy.js +38 -0
  54. package/dist/data/fetchJson.d.ts +20 -0
  55. package/dist/data/fetchJson.js +43 -0
  56. package/dist/data/jsonDataSource.d.ts +4 -0
  57. package/dist/data/jsonDataSource.js +6 -0
  58. package/dist/devtools/devtools.d.ts +205 -0
  59. package/dist/devtools/devtools.js +832 -0
  60. package/dist/devtools/transformTunables.d.ts +14 -0
  61. package/dist/devtools/transformTunables.js +38 -0
  62. package/dist/devtools/tunableSchema.d.ts +140 -0
  63. package/dist/devtools/tunableSchema.js +476 -0
  64. package/dist/faction/factions.d.ts +33 -0
  65. package/dist/faction/factions.js +76 -0
  66. package/dist/faction/reputation.d.ts +32 -0
  67. package/dist/faction/reputation.js +82 -0
  68. package/dist/game/chat.d.ts +68 -0
  69. package/dist/game/chat.js +173 -0
  70. package/dist/game/chatFilter.d.ts +15 -0
  71. package/dist/game/chatFilter.js +55 -0
  72. package/dist/game/controlGate.d.ts +4 -0
  73. package/dist/game/controlGate.js +7 -0
  74. package/dist/game/defineGame.d.ts +10 -2
  75. package/dist/game/defineGame.js +2 -1
  76. package/dist/game/events.d.ts +35 -0
  77. package/dist/game/gamePhase.d.ts +20 -0
  78. package/dist/game/gamePhase.js +21 -0
  79. package/dist/game/levelSequence.d.ts +44 -0
  80. package/dist/game/levelSequence.js +78 -0
  81. package/dist/game/lootTable.d.ts +10 -1
  82. package/dist/game/lootTable.js +27 -7
  83. package/dist/game/playableGame.d.ts +286 -6
  84. package/dist/game/playableGame.js +6 -1
  85. package/dist/game/progression.d.ts +1 -0
  86. package/dist/game/progression.js +23 -2
  87. package/dist/game/quest.d.ts +7 -3
  88. package/dist/game/quest.js +17 -8
  89. package/dist/game/race.d.ts +44 -1
  90. package/dist/game/race.js +116 -11
  91. package/dist/game/recordBook.d.ts +29 -0
  92. package/dist/game/recordBook.js +86 -0
  93. package/dist/game/social.d.ts +42 -0
  94. package/dist/game/social.js +122 -0
  95. package/dist/game/spawnPoints.d.ts +19 -0
  96. package/dist/game/spawnPoints.js +23 -0
  97. package/dist/game/talents.d.ts +47 -0
  98. package/dist/game/talents.js +140 -0
  99. package/dist/game/worldItem.d.ts +3 -0
  100. package/dist/game/worldItem.js +12 -6
  101. package/dist/index.d.ts +1 -0
  102. package/dist/index.js +1 -0
  103. package/dist/input/actionBindings.d.ts +10 -0
  104. package/dist/input/actionBindings.js +14 -0
  105. package/dist/input/axisInput.d.ts +26 -3
  106. package/dist/input/axisInput.js +44 -4
  107. package/dist/input/bindingOverrides.d.ts +25 -0
  108. package/dist/input/bindingOverrides.js +95 -0
  109. package/dist/input/gestureSurface.d.ts +31 -0
  110. package/dist/input/gestureSurface.js +120 -0
  111. package/dist/input/pointer.d.ts +45 -0
  112. package/dist/input/pointer.js +48 -0
  113. package/dist/input/pointerAxis.d.ts +30 -0
  114. package/dist/input/pointerAxis.js +31 -0
  115. package/dist/input/touchScheme.d.ts +89 -0
  116. package/dist/input/touchScheme.js +153 -0
  117. package/dist/item/affix.d.ts +1 -1
  118. package/dist/item/affix.js +1 -19
  119. package/dist/meta/changelog.d.ts +1 -1
  120. package/dist/meta/changelog.js +59 -1
  121. package/dist/movement/dash.d.ts +3 -0
  122. package/dist/movement/dash.js +19 -5
  123. package/dist/movement/glideModel.d.ts +44 -0
  124. package/dist/movement/glideModel.js +54 -0
  125. package/dist/movement/grappleSwing.d.ts +34 -0
  126. package/dist/movement/grappleSwing.js +56 -0
  127. package/dist/movement/leaderTrail.d.ts +29 -0
  128. package/dist/movement/leaderTrail.js +67 -0
  129. package/dist/movement/movementModel.d.ts +36 -3
  130. package/dist/movement/movementModel.js +81 -5
  131. package/dist/movement/steering.d.ts +27 -0
  132. package/dist/movement/steering.js +40 -0
  133. package/dist/movement/voxelController.d.ts +61 -0
  134. package/dist/movement/voxelController.js +229 -0
  135. package/dist/multiplayer/chatContract.d.ts +42 -0
  136. package/dist/multiplayer/chatContract.js +57 -0
  137. package/dist/multiplayer/feedWriteGate.d.ts +10 -0
  138. package/dist/multiplayer/feedWriteGate.js +12 -0
  139. package/dist/multiplayer/identity.d.ts +13 -0
  140. package/dist/multiplayer/identity.js +20 -0
  141. package/dist/multiplayer/poseSyncGate.d.ts +1 -0
  142. package/dist/multiplayer/poseSyncGate.js +13 -1
  143. package/dist/multiplayer/presenceModel.d.ts +17 -2
  144. package/dist/multiplayer/presenceModel.js +30 -4
  145. package/dist/multiplayer/voiceContract.d.ts +44 -0
  146. package/dist/multiplayer/voiceContract.js +108 -0
  147. package/dist/nav/corridors.d.ts +30 -0
  148. package/dist/nav/corridors.js +69 -0
  149. package/dist/nav/navConstrain.d.ts +14 -0
  150. package/dist/nav/navConstrain.js +26 -0
  151. package/dist/nav/navFromEnvironment.d.ts +11 -0
  152. package/dist/nav/navFromEnvironment.js +16 -0
  153. package/dist/nav/navGrid.d.ts +10 -0
  154. package/dist/nav/navGrid.js +48 -5
  155. package/dist/nav/pathFollow.d.ts +4 -2
  156. package/dist/nav/pathFollow.js +5 -3
  157. package/dist/nav/railGraph.d.ts +66 -0
  158. package/dist/nav/railGraph.js +129 -0
  159. package/dist/nav/timetable.d.ts +41 -0
  160. package/dist/nav/timetable.js +94 -0
  161. package/dist/physics/ballisticSweep.d.ts +18 -0
  162. package/dist/physics/ballisticSweep.js +49 -0
  163. package/dist/physics/buoyancy.d.ts +6 -0
  164. package/dist/physics/buoyancy.js +14 -0
  165. package/dist/physics/flowTube.d.ts +32 -0
  166. package/dist/physics/flowTube.js +64 -0
  167. package/dist/physics/forceVolume.d.ts +27 -0
  168. package/dist/physics/forceVolume.js +43 -5
  169. package/dist/physics/kinematicVehicle.d.ts +52 -0
  170. package/dist/physics/kinematicVehicle.js +81 -0
  171. package/dist/physics/physicsWorld.d.ts +54 -8
  172. package/dist/physics/physicsWorld.js +400 -79
  173. package/dist/physics/ragdoll.js +1 -1
  174. package/dist/physics/spatialGrid.d.ts +1 -1
  175. package/dist/physics/spatialGrid.js +5 -0
  176. package/dist/physics/vehicleBody.d.ts +6 -0
  177. package/dist/physics/vehicleBody.js +18 -18
  178. package/dist/puzzle/cellGrid.d.ts +26 -0
  179. package/dist/puzzle/cellGrid.js +110 -0
  180. package/dist/puzzle/fallingPiece.d.ts +24 -0
  181. package/dist/puzzle/fallingPiece.js +51 -0
  182. package/dist/random/nameGen.d.ts +18 -0
  183. package/dist/random/nameGen.js +35 -0
  184. package/dist/random/rng.d.ts +2 -0
  185. package/dist/random/rng.js +23 -0
  186. package/dist/random/seedLink.d.ts +5 -0
  187. package/dist/random/seedLink.js +35 -0
  188. package/dist/runtime/adapter.d.ts +35 -0
  189. package/dist/runtime/adapter.js +37 -1
  190. package/dist/runtime/cameraDirector.d.ts +15 -0
  191. package/dist/runtime/cameraDirector.js +31 -0
  192. package/dist/runtime/commandRunner.d.ts +2 -2
  193. package/dist/runtime/commandRunner.js +2 -2
  194. package/dist/runtime/gameContext.d.ts +65 -2
  195. package/dist/runtime/gameContext.js +280 -9
  196. package/dist/runtime/gameRuntime.d.ts +5 -1
  197. package/dist/runtime/gameRuntime.js +13 -12
  198. package/dist/runtime/hostPersistence.d.ts +30 -1
  199. package/dist/runtime/hostPersistence.js +70 -5
  200. package/dist/runtime/inputSnapshot.d.ts +12 -0
  201. package/dist/runtime/inputSnapshot.js +17 -0
  202. package/dist/runtime/motionIntents.d.ts +33 -0
  203. package/dist/runtime/motionIntents.js +43 -0
  204. package/dist/runtime/snapshot.d.ts +1 -0
  205. package/dist/runtime/snapshot.js +1 -1
  206. package/dist/runtime/transport.d.ts +32 -0
  207. package/dist/runtime/visibility.d.ts +74 -0
  208. package/dist/runtime/visibility.js +127 -0
  209. package/dist/scene/colliders.d.ts +44 -0
  210. package/dist/scene/colliders.js +77 -0
  211. package/dist/scene/entityStore.d.ts +30 -5
  212. package/dist/scene/entityStore.js +83 -13
  213. package/dist/scene/movementSpeed.d.ts +35 -0
  214. package/dist/scene/movementSpeed.js +20 -0
  215. package/dist/scene/objectQuery.d.ts +29 -0
  216. package/dist/scene/objectQuery.js +140 -0
  217. package/dist/scene/objectStore.d.ts +13 -0
  218. package/dist/scene/objectStore.js +130 -6
  219. package/dist/scene/paintLayer.d.ts +15 -0
  220. package/dist/scene/paintLayer.js +53 -0
  221. package/dist/scene/sceneRaycast.d.ts +80 -0
  222. package/dist/scene/sceneRaycast.js +284 -0
  223. package/dist/scene/spatial.d.ts +17 -1
  224. package/dist/scene/spatial.js +140 -17
  225. package/dist/sensor/concealment.d.ts +21 -0
  226. package/dist/sensor/concealment.js +63 -0
  227. package/dist/sensor/freezeMonitor.d.ts +17 -0
  228. package/dist/sensor/freezeMonitor.js +35 -0
  229. package/dist/sensor/recordingBuffer.d.ts +6 -0
  230. package/dist/sensor/recordingBuffer.js +7 -0
  231. package/dist/sensor/replayLoop.d.ts +39 -0
  232. package/dist/sensor/replayLoop.js +64 -0
  233. package/dist/sensor/visionCone.d.ts +31 -0
  234. package/dist/sensor/visionCone.js +72 -0
  235. package/dist/session/extraction.d.ts +2 -2
  236. package/dist/session/extraction.js +13 -2
  237. package/dist/session/roles.d.ts +6 -0
  238. package/dist/session/roles.js +32 -0
  239. package/dist/session/roundState.d.ts +41 -17
  240. package/dist/session/roundState.js +67 -38
  241. package/dist/settings/settingsModel.d.ts +97 -0
  242. package/dist/settings/settingsModel.js +100 -0
  243. package/dist/stats/statModifiers.d.ts +5 -2
  244. package/dist/stats/statModifiers.js +32 -14
  245. package/dist/store/observableKeyedStore.js +8 -2
  246. package/dist/survival/decayMeter.d.ts +1 -1
  247. package/dist/survival/decayMeter.js +0 -1
  248. package/dist/tactics/fallingGrid.d.ts +62 -0
  249. package/dist/tactics/fallingGrid.js +158 -0
  250. package/dist/tactics/tacticalGrid.d.ts +11 -0
  251. package/dist/tactics/tacticalGrid.js +17 -0
  252. package/dist/time/beatClock.d.ts +18 -0
  253. package/dist/time/beatClock.js +24 -0
  254. package/dist/time/idleProgress.d.ts +29 -0
  255. package/dist/time/idleProgress.js +31 -0
  256. package/dist/time/serverTick.d.ts +7 -1
  257. package/dist/time/serverTick.js +27 -4
  258. package/dist/time/simClock.d.ts +25 -1
  259. package/dist/time/simClock.js +28 -3
  260. package/dist/time/stateSchedule.d.ts +57 -0
  261. package/dist/time/stateSchedule.js +121 -0
  262. package/dist/turn/commit.d.ts +2 -2
  263. package/dist/turn/commit.js +2 -2
  264. package/dist/turn/intent.d.ts +14 -0
  265. package/dist/turn/intent.js +29 -0
  266. package/dist/turn/turnLoop.d.ts +2 -0
  267. package/dist/turn/turnLoop.js +8 -1
  268. package/dist/ui/hudLayout.d.ts +58 -0
  269. package/dist/ui/hudLayout.js +207 -0
  270. package/dist/ui/hudScale.d.ts +44 -0
  271. package/dist/ui/hudScale.js +55 -0
  272. package/dist/visibility/assetStreaming.d.ts +66 -0
  273. package/dist/visibility/assetStreaming.js +201 -0
  274. package/dist/visibility/bounds.d.ts +66 -0
  275. package/dist/visibility/bounds.js +113 -0
  276. package/dist/visibility/camera.d.ts +21 -0
  277. package/dist/visibility/camera.js +3 -0
  278. package/dist/visibility/config.d.ts +21 -0
  279. package/dist/visibility/config.js +1 -0
  280. package/dist/visibility/diagnostics.d.ts +26 -0
  281. package/dist/visibility/diagnostics.js +36 -0
  282. package/dist/visibility/distance.d.ts +11 -0
  283. package/dist/visibility/distance.js +30 -0
  284. package/dist/visibility/frustum.d.ts +47 -0
  285. package/dist/visibility/frustum.js +186 -0
  286. package/dist/visibility/occlusion.d.ts +36 -0
  287. package/dist/visibility/occlusion.js +45 -0
  288. package/dist/visibility/settings.d.ts +75 -0
  289. package/dist/visibility/settings.js +49 -0
  290. package/dist/visibility/simulationCulling.d.ts +34 -0
  291. package/dist/visibility/simulationCulling.js +36 -0
  292. package/dist/visibility/spatialIndex.d.ts +45 -0
  293. package/dist/visibility/spatialIndex.js +198 -0
  294. package/dist/visibility/visibilitySystem.d.ts +90 -0
  295. package/dist/visibility/visibilitySystem.js +257 -0
  296. package/dist/world/buildings.d.ts +6 -0
  297. package/dist/world/buildings.js +152 -0
  298. package/dist/world/carve.d.ts +7 -2
  299. package/dist/world/carve.js +17 -7
  300. package/dist/world/cellStates.d.ts +45 -0
  301. package/dist/world/cellStates.js +103 -0
  302. package/dist/world/environmentSummary.d.ts +61 -0
  303. package/dist/world/environmentSummary.js +138 -0
  304. package/dist/world/features.d.ts +138 -9
  305. package/dist/world/features.js +73 -2
  306. package/dist/world/gridInstances.d.ts +12 -0
  307. package/dist/world/gridInstances.js +160 -0
  308. package/dist/world/lod.d.ts +17 -0
  309. package/dist/world/lod.js +66 -0
  310. package/dist/world/mapLayers.d.ts +60 -0
  311. package/dist/world/mapLayers.js +44 -0
  312. package/dist/world/massing.d.ts +45 -0
  313. package/dist/world/massing.js +581 -0
  314. package/dist/world/minimap.d.ts +13 -1
  315. package/dist/world/minimap.js +21 -2
  316. package/dist/world/placementController.d.ts +22 -1
  317. package/dist/world/placementController.js +56 -0
  318. package/dist/world/terrain.d.ts +62 -1
  319. package/dist/world/terrain.js +253 -9
  320. package/dist/world/voxelField.d.ts +54 -0
  321. package/dist/world/voxelField.js +241 -0
  322. package/dist/world/water.d.ts +7 -0
  323. package/dist/world/water.js +19 -4
  324. package/dist/world/weather.d.ts +3 -18
  325. package/dist/world/weather.js +4 -17
  326. package/dist/world/windZones.d.ts +47 -0
  327. package/dist/world/windZones.js +59 -0
  328. package/llms.txt +4983 -0
  329. package/package.json +14 -3
@@ -0,0 +1,832 @@
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";
4
+ const FRAME_CAPACITY = 240;
5
+ const RECENT_FRAMES = 60;
6
+ export const LONG_FRAME_MS = 33.4;
7
+ const LATENCY_CAPACITY = 120;
8
+ const LOG_CAPACITY = 200;
9
+ const LOG_MESSAGE_MAX = 400;
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;
14
+ function nowMs() {
15
+ const perf = globalThis.performance;
16
+ return perf !== undefined ? perf.now() : Date.now();
17
+ }
18
+ function formatLogArg(arg) {
19
+ if (typeof arg === "string")
20
+ return arg;
21
+ if (arg instanceof Error)
22
+ return `${arg.name}: ${arg.message}`;
23
+ try {
24
+ return JSON.stringify(arg) ?? String(arg);
25
+ }
26
+ catch {
27
+ return String(arg);
28
+ }
29
+ }
30
+ export function formatLogMessage(args) {
31
+ const message = args.map(formatLogArg).join(" ");
32
+ return message.length > LOG_MESSAGE_MAX ? `${message.slice(0, LOG_MESSAGE_MAX)}…` : message;
33
+ }
34
+ function percentile(sorted, fraction) {
35
+ if (sorted.length === 0)
36
+ return 0;
37
+ const index = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction));
38
+ return sorted[index];
39
+ }
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
+ };
94
+ }
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;
159
+ }
160
+ return { targets: out, skipped };
161
+ }
162
+ export function createDevtools() {
163
+ const signal = createChangeSignal();
164
+ const frameMsBuffer = new Float64Array(FRAME_CAPACITY);
165
+ const simMsBuffer = new Float64Array(FRAME_CAPACITY);
166
+ const outsideMsBuffer = new Float64Array(FRAME_CAPACITY);
167
+ let frameIndex = 0;
168
+ let frameCount = 0;
169
+ let renderSample = null;
170
+ const logEntries = [];
171
+ let consoleCaptured = false;
172
+ const latencySamples = [];
173
+ const longFrameEvents = [];
174
+ const phaseAccumulators = new Map();
175
+ let currentPhases = {};
176
+ let currentPhaseCount = 0;
177
+ const controlRecords = new Map();
178
+ const discoveredRecords = new Map();
179
+ const probeReaders = new Map();
180
+ let boundProps = new WeakMap();
181
+ let discoverySkips = [];
182
+ const readProbes = () => {
183
+ const values = {};
184
+ for (const [name, read] of probeReaders) {
185
+ try {
186
+ values[name] = read();
187
+ }
188
+ catch (error) {
189
+ values[name] = `probe failed: ${formatLogArg(error)}`;
190
+ }
191
+ }
192
+ return values;
193
+ };
194
+ const pushLog = (level, message) => {
195
+ logEntries.push({ at: Date.now(), level, message });
196
+ if (logEntries.length > LOG_CAPACITY)
197
+ logEntries.splice(0, logEntries.length - LOG_CAPACITY);
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
+ };
249
+ const frameStats = () => {
250
+ if (frameCount === 0)
251
+ return null;
252
+ const size = Math.min(frameCount, FRAME_CAPACITY);
253
+ const frames = [];
254
+ const sims = [];
255
+ const outsides = [];
256
+ for (let offset = 0; offset < size; offset += 1) {
257
+ const at = (frameIndex - size + offset + FRAME_CAPACITY) % FRAME_CAPACITY;
258
+ frames.push(frameMsBuffer[at]);
259
+ sims.push(simMsBuffer[at]);
260
+ outsides.push(outsideMsBuffer[at]);
261
+ }
262
+ const recent = frames.slice(-RECENT_FRAMES);
263
+ const recentAvg = recent.reduce((sum, ms) => sum + ms, 0) / recent.length;
264
+ const sorted = [...frames].sort((a, b) => a - b);
265
+ const avgSimMs = sims.reduce((sum, ms) => sum + ms, 0) / sims.length;
266
+ return {
267
+ fps: recentAvg > 0 ? 1000 / recentAvg : 0,
268
+ avgFrameMs: frames.reduce((sum, ms) => sum + ms, 0) / frames.length,
269
+ p95FrameMs: percentile(sorted, 0.95),
270
+ maxFrameMs: sorted[sorted.length - 1],
271
+ avgSimMs,
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),
275
+ longFrames: frames.filter((ms) => ms > LONG_FRAME_MS).length,
276
+ samples: size,
277
+ recentFrameMs: recent,
278
+ phases: phaseStatsList(avgSimMs),
279
+ };
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
+ };
317
+ const latencyStats = () => {
318
+ if (latencySamples.length === 0)
319
+ return null;
320
+ let min = Number.POSITIVE_INFINITY;
321
+ let max = 0;
322
+ let sum = 0;
323
+ for (const ms of latencySamples) {
324
+ min = Math.min(min, ms);
325
+ max = Math.max(max, ms);
326
+ sum += ms;
327
+ }
328
+ return {
329
+ lastMs: latencySamples[latencySamples.length - 1],
330
+ avgMs: sum / latencySamples.length,
331
+ minMs: min,
332
+ maxMs: max,
333
+ samples: latencySamples.length,
334
+ };
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
+ });
348
+ const toControl = (record) => ({
349
+ name: record.name,
350
+ kind: record.kind,
351
+ label: record.label,
352
+ group: record.group,
353
+ initial: record.initial,
354
+ min: record.min,
355
+ max: record.max,
356
+ step: record.step,
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,
369
+ read: () => record.value,
370
+ write: (value) => writeControl(record, value),
371
+ reset: () => {
372
+ writeControl(record, cloneValue(record.initial));
373
+ },
374
+ });
375
+ const writeControl = (record, raw) => {
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;
389
+ }
390
+ for (const listener of record.listeners)
391
+ listener(record.value);
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
+ }
440
+ };
441
+ const register = (name, initial, options) => {
442
+ const kind = inferKind(initial, options);
443
+ const existing = controlRecords.get(name);
444
+ const slashIndex = name.indexOf("/");
445
+ const derivedGroup = slashIndex > 0 ? name.slice(0, slashIndex) : "general";
446
+ const derivedLabel = slashIndex > 0 ? name.slice(slashIndex + 1) : name;
447
+ const record = existing !== undefined && existing.kind === kind
448
+ ? existing
449
+ : {
450
+ name,
451
+ kind,
452
+ label: "",
453
+ group: "",
454
+ initial: cloneValue(initial),
455
+ value: cloneValue(initial),
456
+ schemaVersion: CONTROL_SCHEMA_VERSION,
457
+ listeners: new Set(),
458
+ };
459
+ if (existing === undefined || existing.kind !== kind) {
460
+ record.value = cloneValue(initial);
461
+ }
462
+ record.label = options?.label ?? derivedLabel;
463
+ record.group = options?.group ?? derivedGroup;
464
+ hydrateRecord(record, initial, options, kind);
465
+ if (options?.onChange !== undefined)
466
+ record.listeners.add(options.onChange);
467
+ controlRecords.set(name, record);
468
+ signal.notify();
469
+ return {
470
+ name,
471
+ kind,
472
+ initial: record.initial,
473
+ get value() {
474
+ return record.value;
475
+ },
476
+ set: (value) => writeControl(record, value),
477
+ reset: () => {
478
+ writeControl(record, cloneValue(record.initial));
479
+ },
480
+ subscribe: (listener) => {
481
+ record.listeners.add(listener);
482
+ return () => record.listeners.delete(listener);
483
+ },
484
+ };
485
+ };
486
+ const bindDiscovered = (table, key, kind, accessor, source, options) => {
487
+ const id = `${table}/${key}`;
488
+ const existing = discoveredRecords.get(id);
489
+ if (existing === undefined) {
490
+ discoveredRecords.set(id, {
491
+ id,
492
+ table,
493
+ key,
494
+ kind,
495
+ initial: cloneValue(accessor.initial),
496
+ enabled: false,
497
+ source,
498
+ options,
499
+ get: accessor.get,
500
+ set: accessor.set,
501
+ });
502
+ return true;
503
+ }
504
+ if (source !== null && existing.source === source)
505
+ return false;
506
+ existing.source = source;
507
+ existing.get = accessor.get;
508
+ existing.set = accessor.set;
509
+ existing.kind = kind;
510
+ existing.options = options ?? existing.options;
511
+ const control = controlRecords.get(id);
512
+ if (existing.enabled && control !== undefined)
513
+ accessor.set(cloneValue(control.value));
514
+ return true;
515
+ };
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)
526
+ return 0;
527
+ let changed = false;
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;
533
+ const accessor = {
534
+ initial: cloneValue(target[key]),
535
+ get: () => target[key],
536
+ set: (value) => {
537
+ target[key] = value;
538
+ },
539
+ };
540
+ if (bindDiscovered(table, path, kind, accessor, target, options))
541
+ changed = true;
542
+ bound += 1;
543
+ if (boundKeys === undefined)
544
+ boundProps.set(target, new Set([key]));
545
+ else
546
+ boundKeys.add(key);
547
+ }
548
+ if (changed)
549
+ signal.notify();
550
+ return bound;
551
+ };
552
+ const enableDiscovered = (id) => {
553
+ const record = discoveredRecords.get(id);
554
+ if (record === undefined || record.enabled)
555
+ return;
556
+ record.enabled = true;
557
+ const current = record.get();
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))
566
+ handle.set(current);
567
+ };
568
+ return {
569
+ frame: {
570
+ record: recordFrame,
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,
594
+ },
595
+ render: {
596
+ record(sample) {
597
+ renderSample = sample;
598
+ },
599
+ latest: () => renderSample,
600
+ },
601
+ logs: {
602
+ push: pushLog,
603
+ captureConsole() {
604
+ if (consoleCaptured)
605
+ return;
606
+ const globalConsole = globalThis.console;
607
+ if (globalConsole === undefined)
608
+ return;
609
+ consoleCaptured = true;
610
+ const levels = ["log", "info", "warn", "error"];
611
+ for (const level of levels) {
612
+ const original = globalConsole[level].bind(globalConsole);
613
+ globalConsole[level] = (...args) => {
614
+ pushLog(level, formatLogMessage(args));
615
+ original(...args);
616
+ };
617
+ }
618
+ },
619
+ list: () => logEntries,
620
+ clear() {
621
+ logEntries.length = 0;
622
+ signal.notify();
623
+ },
624
+ },
625
+ latency: {
626
+ record(ms) {
627
+ latencySamples.push(ms);
628
+ if (latencySamples.length > LATENCY_CAPACITY)
629
+ latencySamples.splice(0, latencySamples.length - LATENCY_CAPACITY);
630
+ },
631
+ stats: latencyStats,
632
+ },
633
+ controls: {
634
+ register,
635
+ list: () => [...controlRecords.values()].map(toControl),
636
+ get: (name) => {
637
+ const record = controlRecords.get(name);
638
+ return record === undefined ? null : toControl(record);
639
+ },
640
+ remove(name) {
641
+ if (controlRecords.delete(name))
642
+ signal.notify();
643
+ },
644
+ resetAll() {
645
+ for (const record of controlRecords.values())
646
+ writeControl(record, cloneValue(record.initial));
647
+ },
648
+ },
649
+ discover: {
650
+ bind(table, key, accessor, options) {
651
+ const kind = discoverableKind(accessor.initial, options);
652
+ if (kind === null)
653
+ return;
654
+ if (bindDiscovered(table, key, kind, accessor, null, options))
655
+ signal.notify();
656
+ },
657
+ scanTable,
658
+ scanModule(exports, meta) {
659
+ let total = 0;
660
+ for (const [name, value] of Object.entries(exports)) {
661
+ total += scanTable(name, value, meta);
662
+ }
663
+ return total;
664
+ },
665
+ list: () => [...discoveredRecords.values()].map((record) => ({
666
+ id: record.id,
667
+ table: record.table,
668
+ key: record.key,
669
+ kind: record.kind,
670
+ initial: record.initial,
671
+ enabled: record.enabled,
672
+ read: () => record.get(),
673
+ })),
674
+ skipped: () => discoverySkips,
675
+ enable(id) {
676
+ enableDiscovered(id);
677
+ signal.notify();
678
+ },
679
+ disable(id) {
680
+ const record = discoveredRecords.get(id);
681
+ if (record === undefined || !record.enabled)
682
+ return;
683
+ record.enabled = false;
684
+ record.set(cloneValue(record.initial));
685
+ controlRecords.delete(id);
686
+ signal.notify();
687
+ },
688
+ clear() {
689
+ for (const record of discoveredRecords.values()) {
690
+ if (record.enabled) {
691
+ record.set(cloneValue(record.initial));
692
+ controlRecords.delete(record.id);
693
+ }
694
+ }
695
+ discoveredRecords.clear();
696
+ discoverySkips = [];
697
+ boundProps = new WeakMap();
698
+ signal.notify();
699
+ },
700
+ },
701
+ overrides: {
702
+ export() {
703
+ const enabled = [...discoveredRecords.values()].filter((r) => r.enabled).map((r) => r.id);
704
+ const values = {};
705
+ const schemas = {};
706
+ for (const record of controlRecords.values()) {
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);
716
+ }
717
+ return {
718
+ version: OVERRIDES_FORMAT_VERSION,
719
+ enabled,
720
+ values,
721
+ schemas,
722
+ };
723
+ },
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
+ }
738
+ enableDiscovered(id);
739
+ applied += 1;
740
+ }
741
+ for (const [name, value] of Object.entries(overrides.values)) {
742
+ const record = controlRecords.get(name);
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;
757
+ }
758
+ signal.notify();
759
+ return { applied, skipped, diagnostics };
760
+ },
761
+ },
762
+ probes: {
763
+ register(name, read) {
764
+ probeReaders.set(name, read);
765
+ signal.notify();
766
+ return () => {
767
+ probeReaders.delete(name);
768
+ signal.notify();
769
+ };
770
+ },
771
+ read: readProbes,
772
+ },
773
+ signal,
774
+ snapshot() {
775
+ return {
776
+ at: Date.now(),
777
+ frame: frameStats(),
778
+ render: renderSample,
779
+ latency: latencyStats(),
780
+ longFrames: [...longFrameEvents],
781
+ logs: [...logEntries],
782
+ probes: readProbes(),
783
+ controls: [...controlRecords.values()].map((record) => ({
784
+ name: record.name,
785
+ kind: record.kind,
786
+ value: record.value,
787
+ initial: record.initial,
788
+ })),
789
+ discovered: [...discoveredRecords.values()].map((record) => ({
790
+ id: record.id,
791
+ kind: record.kind,
792
+ value: record.get(),
793
+ enabled: record.enabled,
794
+ })),
795
+ skipped: [...discoverySkips],
796
+ };
797
+ },
798
+ };
799
+ }
800
+ export const devtools = createDevtools();
801
+ export function tunable(name, initial, options) {
802
+ return devtools.controls.register(name, initial, options);
803
+ }
804
+ export function snapshotDevtools() {
805
+ return devtools.snapshot();
806
+ }
807
+ export function measureProfile(name, fn) {
808
+ return devtools.profile.measure(name, fn);
809
+ }
810
+ export function instrumentLatency(target, methods, record = devtools.latency.record) {
811
+ const wrapped = { ...target };
812
+ for (const method of methods) {
813
+ const fn = target[method];
814
+ if (typeof fn !== "function")
815
+ continue;
816
+ wrapped[method] = ((...args) => {
817
+ const start = nowMs();
818
+ const result = fn.call(target, ...args);
819
+ if (result instanceof Promise) {
820
+ return result.then((value) => {
821
+ record(nowMs() - start);
822
+ return value;
823
+ }, (error) => {
824
+ record(nowMs() - start);
825
+ throw error;
826
+ });
827
+ }
828
+ return result;
829
+ });
830
+ }
831
+ return wrapped;
832
+ }