@woosh/meep-engine 3.10.0 → 3.11.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 (142) hide show
  1. package/README.md +168 -152
  2. package/package.json +1 -1
  3. package/src/REVIEW_2026_08_06.md +610 -610
  4. package/src/core/geom/3d/shape/ConvexHullShape3D.d.ts.map +1 -1
  5. package/src/core/geom/3d/shape/ConvexHullShape3D.js +28 -4
  6. package/src/engine/graphics/render/frame_graph/FrameGraph.d.ts +26 -0
  7. package/src/engine/graphics/render/frame_graph/FrameGraph.d.ts.map +1 -1
  8. package/src/engine/graphics/render/frame_graph/FrameGraph.js +47 -0
  9. package/src/engine/physics/ecs/PhysicsSystem.d.ts +0 -3
  10. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  11. package/src/engine/physics/ecs/PhysicsSystem.js +0 -3
  12. package/src/shade/device/ShadeGPUCommandContext.d.ts +21 -0
  13. package/src/shade/device/ShadeGPUCommandContext.d.ts.map +1 -1
  14. package/src/shade/device/ShadeGPUCommandContext.js +940 -749
  15. package/src/shade/device/timing/GPUTimerArray.d.ts +22 -4
  16. package/src/shade/device/timing/GPUTimerArray.d.ts.map +1 -1
  17. package/src/shade/device/timing/GPUTimerArray.js +88 -14
  18. package/src/shade/device/timing/GPU_PROFILER_PROPOSAL_2026_08_28.md +349 -98
  19. package/src/shade/device/timing/profile/GPUFrameRecorder.d.ts +93 -0
  20. package/src/shade/device/timing/profile/GPUFrameRecorder.d.ts.map +1 -0
  21. package/src/shade/device/timing/profile/GPUFrameRecorder.js +297 -0
  22. package/src/shade/device/timing/profile/GPUProfileCapture.d.ts +62 -0
  23. package/src/shade/device/timing/profile/GPUProfileCapture.d.ts.map +1 -0
  24. package/src/shade/device/timing/profile/GPUProfileCapture.js +77 -0
  25. package/src/shade/device/timing/profile/GPUProfileFrame.d.ts +73 -0
  26. package/src/shade/device/timing/profile/GPUProfileFrame.d.ts.map +1 -0
  27. package/src/shade/device/timing/profile/GPUProfileFrame.js +88 -0
  28. package/src/shade/device/timing/profile/GPUProfileLevel.d.ts +20 -0
  29. package/src/shade/device/timing/profile/GPUProfileLevel.d.ts.map +1 -0
  30. package/src/shade/device/timing/profile/GPUProfileLevel.js +50 -0
  31. package/src/shade/device/timing/profile/GPUProfileMeta.d.ts +63 -0
  32. package/src/shade/device/timing/profile/GPUProfileMeta.d.ts.map +1 -0
  33. package/src/shade/device/timing/profile/GPUProfileMeta.js +73 -0
  34. package/src/shade/device/timing/profile/GPUProfileSession.d.ts +173 -0
  35. package/src/shade/device/timing/profile/GPUProfileSession.d.ts.map +1 -0
  36. package/src/shade/device/timing/profile/GPUProfileSession.js +470 -0
  37. package/src/shade/device/timing/profile/GPUProfileSpan.d.ts +60 -0
  38. package/src/shade/device/timing/profile/GPUProfileSpan.d.ts.map +1 -0
  39. package/src/shade/device/timing/profile/GPUProfileSpan.js +65 -0
  40. package/src/shade/device/timing/profile/GPUProfileSpanKind.d.ts +19 -0
  41. package/src/shade/device/timing/profile/GPUProfileSpanKind.d.ts.map +1 -0
  42. package/src/shade/device/timing/profile/GPUProfileSpanKind.js +25 -0
  43. package/src/shade/device/timing/profile/GPUProfileTopology.d.ts +159 -0
  44. package/src/shade/device/timing/profile/GPUProfileTopology.d.ts.map +1 -0
  45. package/src/shade/device/timing/profile/GPUProfileTopology.js +202 -0
  46. package/src/shade/device/timing/profile/GPUProfileWork.d.ts +90 -0
  47. package/src/shade/device/timing/profile/GPUProfileWork.d.ts.map +1 -0
  48. package/src/shade/device/timing/profile/GPUProfileWork.js +115 -0
  49. package/src/shade/device/timing/profile/SGPTDefect.d.ts +43 -0
  50. package/src/shade/device/timing/profile/SGPTDefect.d.ts.map +1 -0
  51. package/src/shade/device/timing/profile/SGPTDefect.js +65 -0
  52. package/src/shade/device/timing/profile/SGPTHeader.d.ts +38 -0
  53. package/src/shade/device/timing/profile/SGPTHeader.d.ts.map +1 -0
  54. package/src/shade/device/timing/profile/SGPTHeader.js +46 -0
  55. package/src/shade/device/timing/profile/SGPTRecordRef.d.ts +37 -0
  56. package/src/shade/device/timing/profile/SGPTRecordRef.d.ts.map +1 -0
  57. package/src/shade/device/timing/profile/SGPTRecordRef.js +45 -0
  58. package/src/shade/device/timing/profile/SGPTWriteStream.d.ts +103 -0
  59. package/src/shade/device/timing/profile/SGPTWriteStream.d.ts.map +1 -0
  60. package/src/shade/device/timing/profile/SGPTWriteStream.js +287 -0
  61. package/src/shade/device/timing/profile/SGPT_DEFECT.d.ts +19 -0
  62. package/src/shade/device/timing/profile/SGPT_DEFECT.d.ts.map +1 -0
  63. package/src/shade/device/timing/profile/SGPT_DEFECT.js +26 -0
  64. package/src/shade/device/timing/profile/SGPT_FLAG.d.ts +8 -0
  65. package/src/shade/device/timing/profile/SGPT_FLAG.d.ts.map +1 -0
  66. package/src/shade/device/timing/profile/SGPT_FLAG.js +15 -0
  67. package/src/shade/device/timing/profile/SGPT_FORMAT.md +345 -0
  68. package/src/shade/device/timing/profile/SGPT_FORMAT_VERSION.d.ts +10 -0
  69. package/src/shade/device/timing/profile/SGPT_FORMAT_VERSION.d.ts.map +1 -0
  70. package/src/shade/device/timing/profile/SGPT_FORMAT_VERSION.js +9 -0
  71. package/src/shade/device/timing/profile/SGPT_FRAME_SLACK.d.ts +17 -0
  72. package/src/shade/device/timing/profile/SGPT_FRAME_SLACK.d.ts.map +1 -0
  73. package/src/shade/device/timing/profile/SGPT_FRAME_SLACK.js +16 -0
  74. package/src/shade/device/timing/profile/SGPT_HEADER_CHECKSUM_COVERAGE.d.ts +16 -0
  75. package/src/shade/device/timing/profile/SGPT_HEADER_CHECKSUM_COVERAGE.d.ts.map +1 -0
  76. package/src/shade/device/timing/profile/SGPT_HEADER_CHECKSUM_COVERAGE.js +15 -0
  77. package/src/shade/device/timing/profile/SGPT_HEADER_OFFSET.d.ts +15 -0
  78. package/src/shade/device/timing/profile/SGPT_HEADER_OFFSET.d.ts.map +1 -0
  79. package/src/shade/device/timing/profile/SGPT_HEADER_OFFSET.js +29 -0
  80. package/src/shade/device/timing/profile/SGPT_HEADER_SIZE.d.ts +7 -0
  81. package/src/shade/device/timing/profile/SGPT_HEADER_SIZE.d.ts.map +1 -0
  82. package/src/shade/device/timing/profile/SGPT_HEADER_SIZE.js +6 -0
  83. package/src/shade/device/timing/profile/SGPT_MAGIC.d.ts +7 -0
  84. package/src/shade/device/timing/profile/SGPT_MAGIC.d.ts.map +1 -0
  85. package/src/shade/device/timing/profile/SGPT_MAGIC.js +6 -0
  86. package/src/shade/device/timing/profile/SGPT_MIN_READER_VERSION.d.ts +16 -0
  87. package/src/shade/device/timing/profile/SGPT_MIN_READER_VERSION.d.ts.map +1 -0
  88. package/src/shade/device/timing/profile/SGPT_MIN_READER_VERSION.js +15 -0
  89. package/src/shade/device/timing/profile/SGPT_RECORD_HEADER_SIZE.d.ts +7 -0
  90. package/src/shade/device/timing/profile/SGPT_RECORD_HEADER_SIZE.d.ts.map +1 -0
  91. package/src/shade/device/timing/profile/SGPT_RECORD_HEADER_SIZE.js +6 -0
  92. package/src/shade/device/timing/profile/SGPT_RECORD_OFFSET.d.ts +13 -0
  93. package/src/shade/device/timing/profile/SGPT_RECORD_OFFSET.d.ts.map +1 -0
  94. package/src/shade/device/timing/profile/SGPT_RECORD_OFFSET.js +23 -0
  95. package/src/shade/device/timing/profile/SGPT_RECORD_SYNC.d.ts +12 -0
  96. package/src/shade/device/timing/profile/SGPT_RECORD_SYNC.d.ts.map +1 -0
  97. package/src/shade/device/timing/profile/SGPT_RECORD_SYNC.js +11 -0
  98. package/src/shade/device/timing/profile/SGPT_RECORD_TYPE.d.ts +18 -0
  99. package/src/shade/device/timing/profile/SGPT_RECORD_TYPE.d.ts.map +1 -0
  100. package/src/shade/device/timing/profile/SGPT_RECORD_TYPE.js +46 -0
  101. package/src/shade/device/timing/profile/frame_graph_extract_topology.d.ts +20 -0
  102. package/src/shade/device/timing/profile/frame_graph_extract_topology.d.ts.map +1 -0
  103. package/src/shade/device/timing/profile/frame_graph_extract_topology.js +174 -0
  104. package/src/shade/device/timing/profile/make_profiling_pass_encoder.d.ts +21 -0
  105. package/src/shade/device/timing/profile/make_profiling_pass_encoder.d.ts.map +1 -0
  106. package/src/shade/device/timing/profile/make_profiling_pass_encoder.js +114 -0
  107. package/src/shade/device/timing/profile/parse_workgroup_size.d.ts +20 -0
  108. package/src/shade/device/timing/profile/parse_workgroup_size.d.ts.map +1 -0
  109. package/src/shade/device/timing/profile/parse_workgroup_size.js +43 -0
  110. package/src/shade/device/timing/profile/sgpt_fourcc.d.ts +18 -0
  111. package/src/shade/device/timing/profile/sgpt_fourcc.d.ts.map +1 -0
  112. package/src/shade/device/timing/profile/sgpt_fourcc.js +34 -0
  113. package/src/shade/device/timing/profile/sgpt_frame_codec.d.ts +42 -0
  114. package/src/shade/device/timing/profile/sgpt_frame_codec.d.ts.map +1 -0
  115. package/src/shade/device/timing/profile/sgpt_frame_codec.js +172 -0
  116. package/src/shade/device/timing/profile/sgpt_meta_codec.d.ts +20 -0
  117. package/src/shade/device/timing/profile/sgpt_meta_codec.d.ts.map +1 -0
  118. package/src/shade/device/timing/profile/sgpt_meta_codec.js +65 -0
  119. package/src/shade/device/timing/profile/sgpt_read_capture.d.ts +19 -0
  120. package/src/shade/device/timing/profile/sgpt_read_capture.d.ts.map +1 -0
  121. package/src/shade/device/timing/profile/sgpt_read_capture.js +134 -0
  122. package/src/shade/device/timing/profile/sgpt_read_header.d.ts +22 -0
  123. package/src/shade/device/timing/profile/sgpt_read_header.d.ts.map +1 -0
  124. package/src/shade/device/timing/profile/sgpt_read_header.js +94 -0
  125. package/src/shade/device/timing/profile/sgpt_read_records.d.ts +27 -0
  126. package/src/shade/device/timing/profile/sgpt_read_records.d.ts.map +1 -0
  127. package/src/shade/device/timing/profile/sgpt_read_records.js +153 -0
  128. package/src/shade/device/timing/profile/sgpt_topology_codec.d.ts +54 -0
  129. package/src/shade/device/timing/profile/sgpt_topology_codec.d.ts.map +1 -0
  130. package/src/shade/device/timing/profile/sgpt_topology_codec.js +329 -0
  131. package/src/shade/device/timing/profile/sgpt_write_header.d.ts +22 -0
  132. package/src/shade/device/timing/profile/sgpt_write_header.d.ts.map +1 -0
  133. package/src/shade/device/timing/profile/sgpt_write_header.js +47 -0
  134. package/src/shade/device/timing/profile/sgpt_write_record.d.ts +23 -0
  135. package/src/shade/device/timing/profile/sgpt_write_record.d.ts.map +1 -0
  136. package/src/shade/device/timing/profile/sgpt_write_record.js +62 -0
  137. package/src/shade/renderer/Renderer.d.ts +21 -0
  138. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  139. package/src/shade/renderer/Renderer.js +2513 -2455
  140. package/src/engine/save/GameStateLoader.d.ts +0 -49
  141. package/src/engine/save/GameStateLoader.d.ts.map +0 -1
  142. package/src/engine/save/GameStateLoader.js +0 -168
@@ -1,6 +1,6 @@
1
1
  # A graphical GPU profiler for Shade — recorder, format, inspector
2
2
 
3
- Proposal, 2026-08-28. Alex Goldring / Company Named Limited.
3
+ Proposal, 2026-08-28. Decisions folded in 2026-08-29. Alex Goldring / Company Named Limited.
4
4
 
5
5
  Scope: a **recording** side that ships with the engine, a **binary container** that carries a
6
6
  capture, and an **inspector** web application under `packages/gpu-inspector-tool/` that reads the
@@ -9,6 +9,11 @@ container and nothing else.
9
9
  > The request spelled the directory `gpu-inespector-tool`. Reading that as a typo and using
10
10
  > `packages/gpu-inspector-tool/` throughout. Say the word if the misspelling was deliberate.
11
11
 
12
+ **Four decisions are settled and written into the design below** — npm workspaces with the existing
13
+ tree moved to conform (§4.2), a user-instantiated recorder passed in rather than a renderer-owned one
14
+ (§6.1), the minimal `FrameGraph` bracket via `Signal` (§1.4), and an open-ended uncapped stream of
15
+ self-contained frame records rather than a ring buffer (§5.8). §11 records them and what each costs.
16
+
12
17
  ---
13
18
 
14
19
  ## 0. Verdict
@@ -141,21 +146,25 @@ feature, which additionally needs the device to expose `timestamp-query`.
141
146
  passes. **The median pass is below the quantum.** Unmitigated, the tool reports a histogram of zeros
142
147
  with occasional 100 µs spikes, and every conclusion drawn from it is noise.
143
148
 
144
- Three consequences, all of which belong in the design rather than in a README:
145
-
146
- - **Measure the quantum, do not assume it.** At session start, run a calibration: submit a known
147
- workload repeatedly, collect raw timestamp values, take the GCD of the deltas. Store the result as
148
- `timestamp_period_ns` in the recording header.
149
- - **Stamp it into the container** so the inspector knows what it is looking at without being told.
150
- - **The inspector refuses to draw a precise-looking picture from imprecise data.** If
151
- `timestamp_period_ns > 1000`, the timeline renders spans with explicit uncertainty bars and a
152
- persistent banner naming the flag. Not a footnote in a tooltip.
153
-
154
- There is a real mitigation beyond the flag: **aggregate across frames.** 600 frames of a 40 µs pass
155
- quantized to 100 µs still average toward ~40 µs, because the quantization is in practice a floor
156
- applied to a free-running counter rather than a deterministic snap of the duration. The inspector's
157
- statistical view is therefore not a nicety on a default browser it is the *only* trustworthy view.
158
- This shapes the format: keep every frame's raw values, never pre-aggregate on the recorder side.
149
+ **The decision is to leave it entirely alone.** Not measure it, not calibrate against it, not
150
+ correct for it, not store a period in the container. Rejected 2026-08-29, and the reasoning is
151
+ worth keeping because it is not obvious:
152
+
153
+ - Recovering the quantum — GCD over observed timestamps, or any equivalent — **is the timing attack
154
+ the mitigation exists to prevent.** Doing it *well* means defeating a browser security control on
155
+ purpose, inside code we ship to other people.
156
+ - Doing it *badly* is worse than not doing it. A period that is a multiple of the real one, or a
157
+ stale one from a browser update, becomes a correction applied to every number in the capture. A
158
+ profiler that silently skews its own data is worse than one that reports coarse data honestly.
159
+ - Either way it is standing complexity and a source of fragility, bought for something the reader
160
+ can be told in one sentence.
161
+
162
+ So: **note it, document it, do not treat it.** The capture records what the device reported
163
+ zeros included. `GPUProfileMeta` documents that browsers quantize and names the flag. The inspector
164
+ says so where a reader will see it. What to make of a coarse capture is the reader's judgement, and
165
+ the honest advice is the same either way: **aggregate across frames rather than trusting any single
166
+ one.** That shapes the format in exactly one way, which it already did — keep every frame's raw
167
+ values and never pre-aggregate on the recorder side.
159
168
 
160
169
  ### 2.2 Pass granularity is the floor
161
170
 
@@ -274,7 +283,7 @@ Without this a shared recording is unreadable:
274
283
  - Adapter info (`vendor`, `architecture`, `device`, `description`), features, the limits that matter.
275
284
  - Engine version, git revision, build flags, `ENV_PRODUCTION`.
276
285
  - Renderer settings: internal vs output resolution, upscaler, which features are on.
277
- - `timestamp_period_ns` (§2.1) and whether the developer-features flag was detected.
286
+ - Nothing about timestamp quantization: see §2.1 for why that is a deliberate absence.
278
287
  - Wall-clock start, session id, user-supplied note.
279
288
  - Scene scale: mesh/instance/light counts, resident material and geometry bytes.
280
289
 
@@ -287,7 +296,7 @@ Without this a shared recording is unreadable:
287
296
  ───────────────────── ───────── ───────────────────────────────
288
297
  GPUTimerArray ────┐
289
298
  FrameGraph ───────┤ ┌── timeline / flame graph
290
- proxy encoders ───┼──> GPUProfileRecorder ──> .sgpt ──drop──> ├── dependency graph
299
+ proxy encoders ───┼──> GPUProfileSession ───> .sgpt ──drop──> ├── dependency graph
291
300
  GraphicsContext ──┘ │ bytes ├── resource table
292
301
  │ ├── pass detail
293
302
  sgpt_write_* └── frame comparison
@@ -322,13 +331,75 @@ A capture is a trade between fidelity and cost. Four levels, recorded in the hea
322
331
  Levels 0–2 are the product. Level 3 is for us, on a repro, and the inspector should say so when it
323
332
  opens one.
324
333
 
334
+ Because a session is now **uncapped by default** (§5.8), those per-frame figures are also a rate.
335
+ At 60 Hz:
336
+
337
+ | Level | Per second | Per minute |
338
+ |---|---|---|
339
+ | 0 `TIMING` | ~120 KB | ~7 MB |
340
+ | 1 `STRUCTURE` | ~120 KB | ~7 MB |
341
+ | 2 `WORKLOAD` | ~360 KB | ~21 MB |
342
+ | 3 `VERBOSE` | ~2.4 MB | ~144 MB |
343
+
344
+ Levels 0–2 will run for many minutes without anyone noticing. **Level 3 will not**, and the session
345
+ should say so — see the byte-budget warning in §5.8. This is the honest cost of "full and uncapped
346
+ explicit history", and it is the right trade at levels 0–2.
347
+
348
+ ### 4.2 Repository layout
349
+
350
+ The repo becomes an npm workspace root, and the engine moves down a level so the two packages are
351
+ siblings rather than one being nested inside the other.
352
+
353
+ ```
354
+ meep/
355
+ package.json workspace root: { "private": true, "workspaces": ["packages/*"] }
356
+ .gitlab-ci.yml
357
+ LICENSE, README.md, CONTRIBUTING.md, CHANGELOG.md, ...
358
+ packages/
359
+ meep-engine/ @woosh/meep-engine — everything that is published today
360
+ package.json unchanged `exports`, `files`, version
361
+ src/ editor/ samples/ tools/
362
+ rollup.config.js vite.config.mjs vitest.config.mjs tsconfig.types.json babel.config.cjs
363
+ gpu-inspector-tool/ the inspector, published separately
364
+ package.json index.html src/ fixtures/
365
+ ```
366
+
367
+ `@woosh/meep-engine`'s own `exports` map (`./src/*`, `./editor/*`) is relative to its own
368
+ `package.json`, so **consumers installing from npm see no change at all**. The paths that move are
369
+ in-repo and in anything vendoring this tree by path.
370
+
371
+ **The cost is in `moh`, not here.** This tree is vendored into the game at
372
+ `app/src/mir-engine/meep/`, and that is where the restructure is felt: every engine path becomes
373
+ `app/src/mir-engine/meep/packages/meep-engine/src/...`. Two ways to absorb it, and this is the one
374
+ call the restructure actually needs:
375
+
376
+ - **Re-point the vendoring one level deeper** — mount `packages/meep-engine` as
377
+ `app/src/mir-engine/meep`. Every path in `moh` stays byte-identical, and the whole cost collapses
378
+ to one submodule/copy path. **This is the one to take** unless something in `moh` needs the
379
+ workspace root itself.
380
+ - **Update paths in `moh`** — mechanical, wide, and it churns the ~20 design documents in this tree
381
+ that quote `app/src/mir-engine/meep/src/...` as a source root.
382
+
383
+ In-repo edits either way: `vitest.config.mjs` `include`/`coverage.include` globs, `tsconfig.types.json`
384
+ `include`/`rootDir`, `rollup.config.js` `input` paths, `.gitlab-ci.yml` script and artifact paths, and
385
+ `vite.config.mjs`. All of them are prefix changes, and all of them get shorter, not longer, because
386
+ they become relative to the package rather than the root.
387
+
388
+ > Worth knowing before committing: npm lets a workspace root *also* be a package, so
389
+ > `packages/gpu-inspector-tool` could have been added with the engine left exactly where it is and
390
+ > zero paths touched. That option is on the table if the `moh` re-pointing turns out to be more
391
+ > awkward than it looks. Proceeding with the move as decided — it is the cleaner end state and the
392
+ > re-pointing above makes it cheap.
393
+
325
394
  ---
326
395
 
327
396
  ## 5. The `.sgpt` container
328
397
 
329
- **S**hade **G**PU **P**rofile **T**race. Magic `0x54505347` — the ASCII bytes `S G P T` read as a
398
+ **S**hade **G**PU **P**rofile **T**race. Magic `0x54504753` — the ASCII bytes `S G P T` read as a
330
399
  little-endian u32 at offset 0, matching the `VGEO_MAGIC` convention in
331
- [VGEO_MAGIC.js](src/shade/renderer/geometry/virtual/format/header/VGEO_MAGIC.js).
400
+ [VGEO_MAGIC.js](packages/meep-engine/src/shade/renderer/geometry/virtual/format/header/VGEO_MAGIC.js).
401
+ `sgpt_fourcc.js` computes these and a spec asserts every literal against it, because hand-deriving
402
+ a little-endian FourCC is exactly the kind of arithmetic that is wrong once and then forever.
332
403
 
333
404
  ### 5.1 Why binary, concretely
334
405
 
@@ -347,31 +418,29 @@ can pull the header and the frame index without reading the payload.
347
418
  ```
348
419
  offset size field
349
420
  ──────────────────────────────────────────────────────────────────
350
- 0 4 magic u32 0x54505347
421
+ 0 4 magic u32 0x54504753
351
422
  4 2 format_version u16
352
423
  6 2 min_reader_version u16
353
- 8 4 flags u32 bit0 = payload deflated
354
- 12 4 header_checksum u32 over bytes [0, 12)
355
- 16 8 directory_offset u64
424
+ 8 4 flags u32 bit0 = CLOSED (writer reached stop)
425
+ 12 4 header_checksum u32 over bytes [0, 8) only — see below
426
+ 16 8 directory_offset u64 0 when the session never stopped
356
427
  24 8 directory_byte_length u64
357
- 32 ... chunks, in write order
358
- ... ... directory
428
+ 32 ... records, in write order
429
+ ... ... directory record
359
430
  ```
360
431
 
361
- Chunk framing, uniform:
362
-
363
- ```
364
- 0 4 chunk_type u32 FourCC
365
- 4 8 payload_byte_length u64
366
- 12 ... payload
367
- ```
432
+ **The checksum stops at byte 8, short of `flags`.** Everything from `flags` onward is patched at
433
+ stop — the CLOSED bit and the directory pointer — and a checksum covering them would have to be
434
+ recomputed then. Fine for a capture that stops; exactly wrong for one that does not, which would
435
+ carry a checksum over bytes never written and read as corrupt. What stays under it is what a reader
436
+ must trust before it can do anything at all: is this an `.sgpt`, and can this build read it.
368
437
 
369
438
  ### 5.3 Chunks
370
439
 
371
440
  | FourCC | Purpose | Cardinality |
372
441
  |---|---|---|
373
- | `META` | §3.5 context. Adapter, engine version, settings, `timestamp_period_ns`, verbosity level, session note. | 1 |
374
- | `STRS` | String table. Every name, label, format, scope namewritten once, referenced by u32 index. | 1 |
442
+ | `META` | §3.5 context. Adapter, engine version, settings, verbosity level, session note. | 1 |
443
+ | `SYMS` | Symbol block. Names, labels, formats, scope namesemitted **inside the record that first needs them** and referenced by u32 index thereafter, so a truncated capture still resolves every name it references (§5.8). | 0..n |
375
444
  | `PIPE` | Pipeline table: id, label, kind, shader module name, `@workgroup_size`, entry point, vertex layout digest. | 1 |
376
445
  | `TOPO` | Frame-graph **topologies**: pass list, resource node list, edges, scopes, descriptors. Content-hashed and deduplicated across frames (§5.4). | 0..n |
377
446
  | `FRAM` | One per frame: topology id, CPU timestamps, span array, dispatch/draw array, counters. | 0..n |
@@ -430,41 +499,155 @@ is precisely why it is an export and not the storage format.
430
499
 
431
500
  ### 5.7 String encoding
432
501
 
433
- Use an explicit `STRS` table with u32 indices rather than `EncodingBinaryBuffer`. The latter
434
- deduplicates by writing back-references to **absolute buffer positions**
435
- ([EncodingBinaryBuffer.js:21](src/core/binary/EncodingBinaryBuffer.js:21)), which is correct for a
436
- single flat buffer and wrong the moment chunks are written out of order, compressed independently, or
437
- skipped by a reader that does not understand them. An index table costs one indirection and is
438
- robust against all three.
502
+ Symbols live in `SYMS` blocks with u32 indices, emitted inside the record that first needs them
503
+ (§5.8). Not `EncodingBinaryBuffer`: it deduplicates by writing back-references to **absolute buffer
504
+ positions** ([EncodingBinaryBuffer.js:21](src/core/binary/EncodingBinaryBuffer.js:21)), which is
505
+ correct for a single flat buffer and wrong the moment records are skipped by a reader that does not
506
+ understand them, resynchronised after corruption, or read from a truncated file all three of which
507
+ this format is explicitly built to survive. An index table costs one indirection and holds up under
508
+ all of them.
509
+
510
+ ### 5.8 Streaming: self-contained frame records
511
+
512
+ The session is an **open-ended forward stream with an explicit stop**. No ring, no cap: `frame_limit`
513
+ defaults to `Infinity`, `stop()` ends it early, and everything recorded is kept.
514
+
515
+ **A frame is written as one complete, self-describing record and pushed when it closes.** That is
516
+ the organising decision, and three properties follow from it:
517
+
518
+ - **It is resynchronisable.** Every record opens with a sync marker, its type, its payload length and
519
+ a checksum. A reader that lands on garbage scans forward for the next marker and carries on. A
520
+ capture cut off mid-session — tab killed, GPU reset, `device.lost` — stays readable up to the last
521
+ intact record, and those are exactly the captures worth having.
522
+ - **Symbols travel with the record that introduces them.** A record carrying a new topology carries
523
+ the names that topology needs; a frame record reusing a known topology carries none. There is no
524
+ global string table to finalise, which is what would otherwise force a tail chunk and make a
525
+ truncated file unreadable — the failure mode where the one capture of the crash resolves no names.
526
+ - **Nothing is back-patched except the header's directory pointer**, and even that is optional: a
527
+ reader can recover the whole stream by scanning records when the directory is missing or the file
528
+ was truncated before `stop()` ran.
529
+
530
+ Order on the wire:
531
+
532
+ ```
533
+ header magic, versions, flags; directory_offset patched at stop, 0 if never stopped
534
+ META at start — adapter, engine version, renderer settings, level
535
+ TOPO #0 + its symbols first frame's topology
536
+ FRAM #0 topology id + spans + counts
537
+ FRAM #1 same topology → id only
538
+ ...
539
+ TOPO #1 + its symbols shadow refresh changed the graph
540
+ FRAM #57
541
+ ...
542
+ CNTR, DIRE at stop — the directory is an index, not a dependency
543
+ ```
544
+
545
+ Record framing, every record identical:
546
+
547
+ ```
548
+ 0 4 sync u32 0x43455253 ('SREC')
549
+ 4 4 record_type u32 FourCC
550
+ 8 4 payload_byte_length u32
551
+ 12 4 payload_checksum u32 crc32 over the payload
552
+ 16 ... payload
553
+ ```
554
+
555
+ u32 for the length rather than u64: a record describes one frame's control flow, and resource
556
+ *contents* are a non-goal, so nothing in one scales with the size of what it describes. Four
557
+ gigabytes is not a ceiling anything can approach.
558
+
559
+ **The sync marker alone is not enough to resynchronise on**, and the reader does not treat it as
560
+ though it were. Four bytes of payload can spell `SREC` — a pass label could — so a candidate counts
561
+ only when its length also fits and its payload also checksums. There is a spec for exactly that
562
+ case.
563
+
564
+ **Reallocation is a non-issue, and the slack reservation settles what is left of it.** We are
565
+ measuring GPU time, not CPU time; a `setCapacity` copy is a heap allocation and a memcpy that may
566
+ evict some cache, which at worst makes the next GPU upload marginally slower. That is a rounding
567
+ error against what is being measured. To keep even that out of the frame interior, the writer
568
+ reserves at the frame boundary:
569
+
570
+ ```js
571
+ buffer.ensureCapacity(buffer.position + FRAME_SLACK); // FRAME_SLACK = 1 MiB
572
+ ```
573
+
574
+ Any growth then happens between frames, never inside a record. 1 MiB against level 2's ~6 KB per
575
+ frame is three orders of magnitude of headroom, so the reservation only actually forces a grow once
576
+ every ~170 frames. One `BinaryBuffer`, no segmentation, no assembly pass.
577
+
578
+ **Scope note, and it is what makes the above generous rather than marginal: GPU buffer and texture
579
+ contents are a non-goal.** The stream carries control flow and dataflow metadata — passes, edges,
580
+ descriptors, counts, timings. Nothing in it scales with the size of the resources it describes. That
581
+ is why a frame record is kilobytes and why 1 MiB of slack is never the binding constraint.
582
+
583
+ **Uncapped still means uncapped, so the session stays legible about it.** `bytes_written` is readable
584
+ at any time and `onBytesWritten` fires per frame, so an application can show a counter. A configurable
585
+ `byte_budget` — default 512 MB — does not stop the recording; it warns once, naming the level and the
586
+ observed rate. Silent unbounded growth in a debug tool is how you lose a browser tab and the capture
587
+ with it.
439
588
 
440
589
  ---
441
590
 
442
591
  ## 6. The recorder
443
592
 
444
- ### 6.1 Surface
593
+ ### 6.1 Surface — the caller owns the session
594
+
595
+ **The engine never constructs a profile session.** The caller builds one and hands it in; the
596
+ renderer holds a nullable reference and nothing more. Nothing in `Renderer` imports the profiler.
445
597
 
446
598
  ```js
599
+ import { GPUProfileSession } from "@woosh/meep-engine/src/shade/device/timing/profile/GPUProfileSession.js";
600
+ import { GPUProfileLevel } from "@woosh/meep-engine/src/shade/device/timing/profile/GPUProfileLevel.js";
601
+
447
602
  const session = new GPUProfileSession({
448
- graphics,
449
603
  level: GPUProfileLevel.WORKLOAD,
450
- max_frames: 600,
604
+ frame_limit: Infinity, // default; a number caps it
451
605
  note: "hitch on shadow refresh, RTX 3070"
452
606
  });
453
607
 
454
- session.start();
455
- // ... frames run ...
456
- const bytes = await session.stop(); // ArrayBuffer, ready to save
608
+ renderer.profile_session = session; // nullable field, that is the whole API
609
+
610
+ session.start(); // META is written here; populate meta first
611
+ // ... frames run, for as long as you like ...
612
+ const bytes = await session.stop(); // ArrayBuffer, ready to save
613
+
614
+ renderer.profile_session = null;
457
615
  ```
458
616
 
459
- `Renderer` gets `begin_profile(options)` / `end_profile()` wrapping it, so the common case is two
460
- calls and no wiring. `add_debug_frame` and `onFrameDebug` stay as they are — cheap, synchronous,
461
- console-shaped, and a different tool for a different question.
617
+ Three things fall out of the caller owning it, and all three are why this is the right shape:
618
+
619
+ - **It is genuinely optional.** The profiler is a leaf module nothing in the engine imports. An
620
+ application that never imports `GPUProfileSession` does not have it in its bundle — the answer to
621
+ "does this ship in production builds" is *it ships, and it costs nothing to anyone who does not ask
622
+ for it*. No build flag, no strip-plugin interaction, no dead-code branch to keep honest.
623
+ - **Lifetime is explicit.** A session that outlives a device, or two sessions at once, are the
624
+ caller's problem to not create, and `start()` asserts against both rather than papering over them.
625
+ - **The engine surface is one nullable field.** `Renderer.profile_session`, forwarded to the
626
+ `GraphicsContext` so command contexts can find it. That is the entire integration.
627
+
628
+ `frame_limit` reaching zero stops the session exactly as `stop()` does. Both resolve the same
629
+ promise, so a caller that wants "500 frames or until I say" writes one `await`.
630
+
631
+ `add_debug_frame` and `onFrameDebug` stay exactly as they are — cheap, synchronous, console-shaped,
632
+ and a different tool for a different question.
462
633
 
463
634
  ### 6.2 The five hooks
464
635
 
465
- 1. **`FrameGraph.execute`** — bracket `node.execute(...)`. Notify the context which graph pass is
466
- open; take CPU timestamps either side; push/pop a debug group. The only edit outside `src/shade/`.
467
- Guarded so a graph with no listener does nothing.
636
+ 1. **`FrameGraph.execute`** — bracket `node.execute(...)`. Notify listeners which graph pass is open;
637
+ take CPU timestamps either side; push/pop a debug group. The only edit outside `src/shade/`.
638
+
639
+ Two `Signal`s on `FrameGraph`, `onPassBegin` / `onPassEnd`, alongside the `onExecuted` that is
640
+ already there. **Signal, not a nullable callback field** — it is the project's standardised
641
+ observer interface, it supports more than one listener, and `remove` is symmetric with `add`.
642
+ A bare nullable callback lets the second consumer silently clobber the first, which is exactly
643
+ the bug that does not announce itself.
644
+
645
+ Guard the dispatch with `hasHandlers()`: `send1` on an empty signal still bumps `generation` and
646
+ walks a Map iterator, and this fires twice per pass — a couple of hundred passes a frame. One
647
+ `Map.size` comparison ahead of it keeps the disabled path honest.
648
+
649
+ `FrameGraph` still learns nothing about what a profiler is; it announces its own pass boundaries
650
+ and the profiler is one possible listener.
468
651
  2. **`ShadeGPUCommandContext`** — a `#profile_sink` field. When set, `beginComputePass` /
469
652
  `beginRenderPass` report `{label, kind, graph_pass_id, query_slot, query_set_id}` and wrap the
470
653
  returned encoder in the recording proxy.
@@ -478,10 +661,14 @@ console-shaped, and a different tool for a different question.
478
661
 
479
662
  ### 6.3 Cost when off
480
663
 
481
- Every hook is a null check against a field that is `undefined` in normal operation. No allocation and
482
- no proxy construction; the frame-graph bracket is two comparisons per pass. This must stay true — a
664
+ Every hook is a null check against a field that is `null` in normal operation. No allocation and no
665
+ proxy construction; the frame-graph bracket is two comparisons per pass. This must stay true — a
483
666
  profiler that costs something when disabled will be disabled at the build level and then rot.
484
667
 
668
+ Because the session is caller-constructed (§6.1), an application that never imports it pays not even
669
+ that: the profiler modules are unreachable from any engine entry point and drop out of the bundle
670
+ entirely. The null checks are the only residue, and they are the price of the feature existing.
671
+
485
672
  ### 6.4 Cost when on
486
673
 
487
674
  Level 0–2 add: one `performance.now()` pair per graph pass, one small record per GPU pass, one per
@@ -514,8 +701,8 @@ other engine import.
514
701
  | View | Answers |
515
702
  |---|---|
516
703
  | **Frame strip** | Which frame is interesting. Frame time over the session, GPU and CPU overlaid, hitches marked, brush to select a range. Drawn from the directory index alone, so it appears before the payload finishes parsing. |
517
- | **Timeline / flame graph** | Where the time went in *this* frame. Spans on a GPU track, nested by `FrameGraphScope`; a CPU track above with encode time; hover for exact ns; click to select. Uncertainty rendering when `timestamp_period_ns` is coarse (§2.1). |
518
- | **Statistics** | Where the time goes *in general*. Per-pass min/median/p95/max/total across the selected frame range, sorted by total contribution. **On a default-quantization capture this is the only honest view**, and the inspector should open on it in that case rather than on the timeline. |
704
+ | **Timeline / flame graph** | Where the time went in *this* frame. Spans on a GPU track, nested by `FrameGraphScope`; a CPU track above with encode time; hover for exact ns; click to select. |
705
+ | **Statistics** | Where the time goes *in general*. Per-pass min/median/p95/max/total across the selected frame range, sorted by total contribution. **On a quantized capture this is the only honest view** and since the tool does not measure quantization (§2.1), it cannot detect that case and switch by itself. It says so plainly instead, always. |
519
706
  | **Dependency graph** | Why this pass runs, and what it waits on. Passes and resource nodes, culled ones greyed, edges directed. Select a pass → highlight its transitive inputs. Select a resource → its version chain and every reader. |
520
707
  | **Resource table** | What memory costs. Every resource node with declared bytes, format, usage, transient/imported, lifetime span, peak concurrent footprint. Sorted by size. Declared total vs `gpu_memory_usage` side by side. |
521
708
  | **Pass detail** | Everything about one pass. Timings across frames as a sparkline, dispatch/draw counts, derived total invocations, pipeline and workgroup size, attachments, bindings at level 3. |
@@ -523,8 +710,9 @@ other engine import.
523
710
 
524
711
  ### 7.3 What it must refuse to do
525
712
 
526
- - **Never interpolate across the quantum.** If the data is 100 µs-granular, no view draws a 12 µs
527
- span. Draw the quantum.
713
+ - **Never invent precision the data does not have.** Durations are drawn as reported. Where many
714
+ spans read as exactly zero, that is shown as what it is — a quantized capture — with a note
715
+ naming the browser flag, not smoothed into plausible-looking small numbers.
528
716
  - **Never present the CPU and GPU tracks as one clock** (§2.3). Two tracks, anchored per frame,
529
717
  labelled as nominal.
530
718
  - **Never hide dropped spans.** If the recorder dropped passes on slot exhaustion (§1.2.1), say how
@@ -542,41 +730,101 @@ Before any of the below. Instrument one Sponza capture and answer three question
542
730
  size estimates and one whole design decision rest on them:
543
731
 
544
732
  1. How many distinct frame-graph topologies over 600 frames? (§5.4)
545
- 2. What is the observed `timestamp_period_ns` on our dev machines, flag on and flag off? (§2.1)
733
+ 2. ~~What is the observed timestamp period~~ dropped; the tool does not measure this (§2.1).
546
734
  3. How many GPU passes per frame, actually? (`GPUTimerArray` default is 1024 slots; §1.2.1)
547
735
 
548
736
  Write the answers into this document.
549
737
 
550
- ### M1Timing spine (2–3 days)
738
+ ### M0.5Workspace restructure **DONE**
551
739
 
552
- `GPUTimerArray` pooling and bound checks; the `FrameGraph.execute` bracket and debug groups;
553
- `GPUProfileSession` at level 0; `META`/`STRS`/`FRAM`/`DIRE` chunks; round-trip test on
554
- `SoftwareGPUDevice`. Deliverable: a `.sgpt` with real spans and no viewer.
740
+ Landed 2026-08-29. `packages/meep-engine/` holds the engine, the workspace root holds the two
741
+ `.gitignore` halves, the CI paths and a README. Suite green at 2156 files / 14580 tests. One spec
742
+ had to move with it: `meep_three_free.spec.js` asserts the `*.d.ts` ignore rule at the engine root,
743
+ so that rule lives in the package's `.gitignore` rather than the workspace's.
555
744
 
556
- ### M2Inspector skeleton (2–3 days)
745
+ **`moh` still needs its vendoring re-pointed one level deeper** mount `packages/meep-engine` where
746
+ it currently mounts the repository root, and every path inside it stays byte-identical.
557
747
 
558
- Package scaffold, format readers, drop target, frame strip, timeline, pass detail. Deliverable: a
559
- capture you can look at.
748
+ <details><summary>Original plan</summary>
560
749
 
561
- ### M3 Structure (2–3 days)
750
+ §4.2. Independent of everything else and worth landing first so no profiler work has to be moved
751
+ afterwards. Order: create `packages/meep-engine/`, `git mv` the tree, fix the five config files,
752
+ green the suite, re-point `moh`'s vendoring one level deeper, green `moh`. Land as its own commit —
753
+ a pure move with no content changes, so the diff stays reviewable and a bisect through it is honest.
562
754
 
563
- `TOPO` chunk with deduplication; resource descriptors; scopes; cull state. Dependency graph and
564
- resource table in the inspector. Deliverable: the "why is this pass here" question becomes
565
- answerable.
755
+ </details>
566
756
 
567
- ### M4Workload (2–3 days)
757
+ ### M1Timing spine — **DONE**
568
758
 
569
- Proxy encoders; `PIPE` chunk; dispatch/draw counts; derived invocation counts. Deliverable: the "is
570
- this dispatch the right size" question becomes answerable.
759
+ Landed:
571
760
 
572
- ### M5 Analysis (2–3 days)
761
+ - `FrameGraph.onPassBegin` / `onPassEnd`, guarded by `hasHandlers()`, closed in a `finally` so a
762
+ throwing pass still ends its bracket. Five specs including the exception path.
763
+ - `GPUTimerArray`: bound check (a full array now refuses a slot instead of addressing past its query
764
+ set), `dropped_count`, `traverse_results` for raw `BigInt` timestamps. Seven specs.
765
+ - `ShadeGPUCommandContext`: `#with_timestamp_writes` — copies the descriptor instead of stamping the
766
+ caller's, tolerates a missing descriptor, and leaves `timestampWrites` off when no slot is free.
767
+ - The whole `.sgpt` container: header, framing, resync reader, defect model, symbol interning,
768
+ `META`/`SYMS`/`FRAM`/`DIRE` codecs, `GPUProfileSession`, `snapshot()`. 26 specs.
769
+ - `GPUFrameRecorder` — holds the graph-pass ↔ timer-slot join, accumulates across every command
770
+ context in a frame, and rebases spans onto the frame epoch at close.
573
771
 
574
- Statistics view, frame comparison, counter tracks, Chrome Trace Event export.
772
+ Removed again on 2026-08-29: a `timestamp_period_ns` field and the GCD estimator that filled it.
773
+ See §2.1 — quantization is documented and left alone.
575
774
 
576
- ### M6 Publish (1–2 days)
775
+ Wired end to end: `Renderer.profile_session`, `GPUFrameRecorder` holding the graph-pass ↔ timer-slot
776
+ join, and `ShadeGPUCommandContext.profiling_absorbed` — which exists because `done` resolves at
777
+ submit, before any timestamp has been read back.
577
778
 
578
- Build target, static hosting, a format specification document written for someone outside the
579
- company, and the sharing story: what a user sends us and what we can read from it.
779
+ The calibration step is gone rather than outstanding: see §2.1.
780
+
781
+ <details><summary>Original plan</summary>
782
+
783
+ `GPUTimerArray` pooling and bound checks; `FrameGraph.onPassBegin`/`onPassEnd` and debug groups;
784
+ `GPUProfileSession` at level 0, caller-constructed, streaming framed records with per-frame slack;
785
+ `META`/`SYMS`/`FRAM`/`DIRE` records; round-trip and truncation-recovery tests on `SoftwareGPUDevice`.
786
+ Deliverable: a `.sgpt` with real spans and no viewer.
787
+
788
+ </details>
789
+
790
+ ### M2 — Inspector skeleton — **DONE**
791
+
792
+ `packages/gpu-inspector-tool`. Plain ES modules, Canvas 2D, no runtime dependencies. Session strip,
793
+ timeline, statistics, pass detail. `make_demo_capture` builds a synthetic capture through the
794
+ engine's own writer, so the tool works without a GPU and the format's two halves are exercised
795
+ against each other.
796
+
797
+ ### M3 — Structure — **DONE**
798
+
799
+ `TOPO` with content-hash deduplication — 50 frames of an unchanged graph come to under 4 KB. Scopes,
800
+ cull state, resource descriptors with footprints computed at record time. The inspector's Structure
801
+ view is two cross-linked tables rather than a node-link diagram: two hundred passes over as many
802
+ resource versions do not lay out into anything readable, and the questions actually asked are local.
803
+
804
+ ### M4 — Workload — **DONE**
805
+
806
+ `make_profiling_pass_encoder` is a `Proxy` rather than a hand-written forwarder: a pass encoder has
807
+ around twenty methods and WebGPU keeps adding them, and one silently dropped would break the
808
+ renderer only while profiling. Dispatch and draw counts, pipeline identity, and `@workgroup_size`
809
+ parsed from WGSL where it is a literal — an override expression records as *unknown*, which is not
810
+ the same as zero.
811
+
812
+ No `PIPE` table in the end: a pass's pipeline label and workgroup size ride in its own workload
813
+ block, which costs a symbol reference and avoids a second id space.
814
+
815
+ ### M5 — Analysis — **partly done**
816
+
817
+ Statistics view and Chrome Trace Event export are in. Frame comparison and counter tracks are not;
818
+ `CNTR` is reserved in the format for the latter.
819
+
820
+ ### M6 — Publish — **partly done**
821
+
822
+ [`SGPT_FORMAT.md`](profile/SGPT_FORMAT.md) is the normative format specification, written for
823
+ somebody outside the company, with a conformance summary for readers and writers. The inspector's
824
+ README covers recording, levels, and the reading caveats.
825
+
826
+ Outstanding: a hosting target for the built site, and deciding the licence the inspector ships
827
+ under.
580
828
 
581
829
  **Not in scope, listed so it stays that way:** buffer/texture content capture, shader source in the
582
830
  container, replay, live attach to a running session, anything requiring a browser extension.
@@ -596,30 +844,33 @@ Per the established tiers:
596
844
 
597
845
  ---
598
846
 
599
- ## 10. Risks and open questions
847
+ ## 10. Risks
600
848
 
601
849
  | Risk | Severity | Handling |
602
850
  |---|---|---|
603
- | **Timestamp quantization makes captures useless on default browsers** (§2.1) | **High** | Measure and record the period; make the statistics view the default when it is coarse; document the flag prominently. Accept that shared user captures will mostly be coarse. |
851
+ | **Timestamp quantization makes per-frame timings unreadable on default browsers** (§2.1) | **High, and deliberately untreated** | Documented, not measured and not corrected measuring it is the timing attack the mitigation prevents, and measuring it badly skews everything. The inspector names the flag and steers toward cross-frame aggregation, which is the honest reading of a coarse capture. |
604
852
  | Topology dedup does not pay off | Medium | M0 measures it. Design degrades gracefully; the size claims do not. |
605
853
  | Proxy encoder overhead distorts what it measures | Medium | Level-gate it; record measured overhead in `META`; forward-only methods, no allocation per call. |
606
854
  | One `GPUTimerArray` per context means several query sets per frame (§2.4) | Low–Medium | Record `query_set_id`. Consider a per-frame shared array as a follow-up, which would also simplify pooling. |
607
855
  | Format churn during development invalidates captures | Low | `min_reader_version` from day one; the inspector reads every version it ever supported; fixtures in the test suite. |
608
856
  | Publishing a proprietary-engine tool separately | Low, but real | The §4 rule-1 boundary is what makes this tractable. Enforce it in the build. Confirm the licensing intent for the published inspector before M6. |
609
-
610
- **Open questions for you:**
611
-
612
- 1. **Monorepo or not.** `packages/` does not exist and this repo has no workspaces. Options: (a) npm
613
- workspaces with `packages/gpu-inspector-tool` as a real workspace — cleanest, touches the root
614
- `package.json` and CI; (b) an unreferenced directory with its own `package.json`, built manually —
615
- zero risk to the engine build, worse ergonomics. **Recommendation: (a)**, but it is your call
616
- whether you want workspaces in this repo at all.
617
- 2. **Does the recorder ship in production builds?** Rollup runs `@rollup/plugin-strip`. Shipping it
618
- means users can send us captures, which is the stated goal; it also means the hooks survive
619
- stripping and the null-check cost must genuinely be zero.
620
- 3. **How much does the frame-graph bracket in `FrameGraph.execute` bother you?** It is the one edit
621
- outside `src/shade/` and outside the profiler's own files. The alternative — inferring attribution
622
- from labels — does not work (§1.4).
623
- 4. **Recording length policy.** Ring buffer of the last N frames (good for "capture the hitch that
624
- just happened") or explicit start/stop (good for reproducible benchmarks)? Both are cheap; the
625
- ring buffer is more useful in the field. **Recommendation: both, ring by default.**
857
+ | **Uncapped session exhausts memory on a long level-3 capture** (§4.1, §5.8) | Medium | `bytes_written` readable and signalled; a `byte_budget` that warns rather than truncates. Accepted deliberately — full history is the point, and buffer/texture contents are a non-goal so nothing scales with resource size. |
858
+ | Workspace restructure churns `moh` (§4.2) | Medium | Re-point the vendoring one level deeper so every `moh` path stays identical. Land as a pure move, separately. |
859
+
860
+ ## 11. Decisions taken 2026-08-29
861
+
862
+ | # | Question | Decision | Where it lives |
863
+ |---|---|---|---|
864
+ | 1 | Monorepo or not | **npm workspaces, and the existing tree moves to conform.** `packages/meep-engine/` + `packages/gpu-inspector-tool/`, symmetric siblings. | §4.2, M0.5 |
865
+ | 2 | Does the recorder ship in production? | **Yes, but optional the caller instantiates a `GPUProfileSession` and passes it in.** The engine never constructs one, so an application that does not import it does not carry it. | §6.1, §6.3 |
866
+ | 3 | The `FrameGraph.execute` bracket | **Agreed, kept minimal — via `Signal`.** `onPassBegin` / `onPassEnd` beside the existing `onExecuted`, dispatch guarded by `hasHandlers()`. `Signal` is the project's standardised observer interface and a nullable callback field would let a second consumer silently clobber the first. | §1.4, §6.2 hook 1 |
867
+ | 4 | Recording length policy | **Open-ended forward stream of self-contained frame records. `frame_limit` of N (1..∞) with an explicit `stop()`.** No ring buffer — full, uncapped, explicit history is the desired behaviour. Records are self-describing and checksummed, so a truncated capture stays readable; 1 MiB of slack reserved per frame keeps reallocation out of the record interior. | §5.8 |
868
+
869
+ Two consequences of (4) worth carrying forward:
870
+
871
+ - **There is no global string table.** Symbols are emitted inside the record that first needs them
872
+ (`SYMS`, §5.3), not gathered into a `STRS` chunk. A tail table would have made a truncated capture
873
+ resolve no names at all the failure mode that hits precisely the capture of the crash you were
874
+ trying to record.
875
+ - **Records are framed and checksummed** so a reader can resynchronise. This is what buys corruption
876
+ and truncation resilience, and it costs 20 bytes per frame.