space-data-module-sdk 0.8.6 → 0.8.8

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 (51) hide show
  1. package/README.md +13 -0
  2. package/docs/AGENTS.md +5 -0
  3. package/docs/browser-wasmedge-isomorphic.md +65 -1
  4. package/docs/language-runtime-matrix.md +2 -2
  5. package/docs/provider-access-abi.md +600 -0
  6. package/package.json +15 -6
  7. package/schemas/orbpro/Propagator.fbs +19 -3
  8. package/src/browser.js +14 -4
  9. package/src/bundle/artifactBytes.js +44 -0
  10. package/src/bundle/index.js +1 -0
  11. package/src/compiler/compileModule.js +24 -1
  12. package/src/flow/flowCompiler.js +141 -2
  13. package/src/flow/flowRuntimeHost.js +7 -1
  14. package/src/flow/isomorphicFlowHost.js +1 -1
  15. package/src/flow/vendor/sdn-flow/MethodRegistry.js +6 -1
  16. package/src/generated/orbpro/propagator/propagator-source-description.js +2 -2
  17. package/src/generated/orbpro/propagator/propagator-source-description.ts +2 -2
  18. package/src/generated/orbpro/propagator/propagator-source-kind.js +13 -2
  19. package/src/generated/orbpro/propagator/propagator-source-kind.ts +13 -2
  20. package/src/generated/spacedatastandards/plg/pluginCategory.d.ts +18 -1
  21. package/src/generated/spacedatastandards/plg/pluginCategory.d.ts.map +1 -1
  22. package/src/generated/spacedatastandards/plg/pluginCategory.js +17 -0
  23. package/src/generated/spacedatastandards/plg/pluginCategory.ts +21 -1
  24. package/src/{testing → host}/browserModuleHarness.js +69 -34
  25. package/src/host/index.js +6 -0
  26. package/src/host/isomorphicLoader.js +17 -45
  27. package/src/host/isomorphicLoaderBrowser.js +47 -0
  28. package/src/host/isomorphicLoaderCore.js +58 -0
  29. package/src/host/nodeBuiltinSpecifier.js +43 -0
  30. package/src/host/providerAccess.js +727 -0
  31. package/src/host/providerAccessAbi.js +403 -0
  32. package/src/host/providerAccessEngineAdapter.js +338 -0
  33. package/src/host/providerAccessFixtureAdapter.js +444 -0
  34. package/src/host/providerAccessTileStoreAdapter.js +366 -0
  35. package/src/host/sabHostcallChannel.js +1 -1
  36. package/src/host/terrainSourceSeam.js +205 -0
  37. package/src/host/wasiThreadHost.js +11 -1
  38. package/src/{testing → host}/workerModuleHarness.js +45 -12
  39. package/src/{testing → host}/workerModuleHarnessWorker.js +6 -5
  40. package/src/index.d.ts +29 -2
  41. package/src/standards/browser.js +95 -0
  42. package/src/standards/catalogCore.js +290 -0
  43. package/src/standards/index.js +27 -251
  44. package/src/testing/AGENTS.md +25 -2
  45. package/src/testing/browser.js +32 -0
  46. package/src/testing/index.d.ts +12 -1
  47. package/src/testing/index.js +3 -3
  48. package/src/testing/parityBrowserRunner.js +9 -2
  49. package/src/testing/parityHarness.js +1 -1
  50. package/templates/provider-access-module/include/space_data_provider_abi.h +227 -0
  51. /package/src/{testing → host}/moduleFlatbufferStreamPump.js +0 -0
@@ -0,0 +1,600 @@
1
+ # Provider Access ABI
2
+
3
+ **Status:** v1 — owner directive 2026-08-07, graph task `sdk-provider-access-abi`.
4
+
5
+ One generalized port that lets a WASM module **control** imagery and terrain
6
+ providers and **read the already-decoded bytes** those providers hold in
7
+ memory — identical import names and signatures in the browser, in native
8
+ WasmEdge, and in the Docker WasmEdge container.
9
+
10
+ This is a *provider access* port, not a *Cesium* port and not an *OrbPro* port.
11
+ Nothing in the guest ABI names an engine, a tile scheme, or a vendor. The same
12
+ three imports serve a browser engine's live quadtree cache, a host-side tile
13
+ store, and a deterministic test fixture.
14
+
15
+ ## Table of contents
16
+
17
+ - [Doctrine](#doctrine)
18
+ - [Capability](#capability)
19
+ - [The import set](#the-import-set)
20
+ - [Tile descriptor](#tile-descriptor)
21
+ - [Acquire requests](#acquire-requests)
22
+ - [Control operations](#control-operations)
23
+ - [Error codes](#error-codes)
24
+ - [Copy contract](#copy-contract)
25
+ - [Runtime satisfaction](#runtime-satisfaction)
26
+ - [Parity envelope](#parity-envelope)
27
+ - [Guest usage](#guest-usage)
28
+
29
+ ## Doctrine
30
+
31
+ Four rules govern every line below.
32
+
33
+ 1. **One port, two planes.** Control is metadata and rides the existing
34
+ `space_data_module_host` sync hostcall bridge as `provider.*` operations.
35
+ Data is bulk bytes and rides three dedicated imports that write straight
36
+ into guest linear memory. Control never carries pixels; data never carries
37
+ JSON.
38
+ 2. **No runtime detection in module code.** A module that reads terrain never
39
+ asks which runtime it is in. Every difference is absorbed in the SDK host
40
+ shims.
41
+ 3. **Present everywhere, honest everywhere.** The imports link in *every*
42
+ runtime. There is no lane where `space_data_provider.acquire` is missing —
43
+ a missing import is a link-time divergence, the worst class. When nothing
44
+ can serve a request the port returns a **value** (a negative error code),
45
+ never a trap, and the same code in every runtime.
46
+ 4. **Decoded or nothing, by default.** The port hands over buffers the provider
47
+ already decoded. It never re-fetches and never re-parses *unless the caller
48
+ explicitly raised its cost ceiling*. Every acquire carries a declared
49
+ [cost class](#cost-classes); the default ceiling admits only already-resident
50
+ bytes, and an adapter that would have to exceed it returns
51
+ `SDM_PROVIDER_E_UNSUPPORTED` rather than quietly paying it.
52
+
53
+ ## Capability
54
+
55
+ The port is gated by the existing coarse capability, with a scope:
56
+
57
+ ```json
58
+ {
59
+ "capability": "scene_access",
60
+ "scope": "provider.v1",
61
+ "required": false,
62
+ "description": "Read and control imagery/terrain providers."
63
+ }
64
+ ```
65
+
66
+ `scene_access` is already in the SDK recommended capability vocabulary
67
+ (`src/capabilities.js`) and already maps to the typed
68
+ `CapabilityKind.SCENE_ACCESS` PLG enum (`src/manifest/normalize.js`). **This
69
+ ABI introduces no new host capability** — it is a scope on an existing one,
70
+ exactly as `gpu_compute` uses `scope: "webgpu.v1"`. No owner sign-off gate,
71
+ no SDS enum change, no new generic hook.
72
+
73
+ `required: false` is the recommended posture. A module whose analysis degrades
74
+ gracefully without provider bytes must declare it optional and handle
75
+ `SDM_PROVIDER_E_NO_PROVIDER` as a first-class outcome, because that is the
76
+ answer it will get on a headless node with no tile store configured.
77
+
78
+ ## The import set
79
+
80
+ Import module: **`space_data_provider`**. Three functions. Every parameter and
81
+ every result is `i32`.
82
+
83
+ ```wat
84
+ (import "space_data_provider" "acquire"
85
+ (func (param i32 i32 i32) (result i32)))
86
+ (import "space_data_provider" "read"
87
+ (func (param i32 i32 i32 i32 i32) (result i32)))
88
+ (import "space_data_provider" "release"
89
+ (func (param i32) (result i32)))
90
+ ```
91
+
92
+ | function | params | result |
93
+ | --------- | -------------------------------------------------- | ------ |
94
+ | `acquire` | `reqPtr, reqLen, descPtr` | handle `> 0`, or negative error code |
95
+ | `read` | `handle, plane, srcOffset, dstPtr, dstLen` | bytes written `>= 0`, or negative error code |
96
+ | `release` | `handle` | `0`, or negative error code |
97
+
98
+ Open, read, close. Three doors.
99
+
100
+ **No `i64` appears anywhere in the boundary signature, and no `f64` either.**
101
+ This is deliberate. The i64 legalization trap measured on this SDK — a
102
+ 64-bit-parameter import legalizes differently depending on how the host
103
+ instantiates it, and the mismatch surfaces as a link failure or a silently
104
+ truncated argument in exactly one runtime — is sidestepped by construction, not
105
+ by convention. Every 64-bit quantity in this ABI (bounding rectangles, height
106
+ extrema, sample coordinates) travels either inside the JSON acquire request or
107
+ inside the descriptor struct in guest memory, where it is plain
108
+ little-endian IEEE-754 the guest reads with a normal load. The boundary itself
109
+ carries only pointers, lengths, indices and codes.
110
+
111
+ - `acquire(reqPtr, reqLen, descPtr)` — `reqPtr/reqLen` is a UTF-8 JSON request
112
+ (see [Acquire requests](#acquire-requests)). `descPtr` is a guest pointer to
113
+ at least `SDM_PROVIDER_TILE_DESC_BYTES` (128) writable bytes; on success the
114
+ host fills it with the [tile descriptor](#tile-descriptor). The returned
115
+ handle pins the provider's decoded buffer on the host side until `release`.
116
+ The call blocks until the tile is resident (browser: `Atomics.wait` on the
117
+ SAB hostcall channel, the same mechanism `http` and `storage` already use;
118
+ WasmEdge: a blocking host call). `descPtr` is left untouched on failure.
119
+ - `read(handle, plane, srcOffset, dstPtr, dstLen)` — copies at most `dstLen`
120
+ bytes of `plane`, starting at `srcOffset` bytes into that plane, into guest
121
+ memory at `dstPtr`. Returns the number of bytes written, which is
122
+ `min(dstLen, planeByteLength - srcOffset)` and may legally be less than
123
+ `dstLen` at the tail. Reading a plane in several chunks is supported and
124
+ yields exactly the same bytes as one whole-plane read.
125
+ - `release(handle)` — unpins. Releasing an already-released handle returns
126
+ `SDM_PROVIDER_E_BAD_HANDLE`, in every runtime. Handles are per-instance and
127
+ per-thread-group; they are never valid across an instance restart.
128
+
129
+ Error detail (a message, a name, the failing operation) is retrieved through
130
+ the control plane with `provider.lastError`. It is deliberately not an extra
131
+ import: strings are metadata and metadata rides the existing bridge.
132
+
133
+ ## Tile descriptor
134
+
135
+ `SDM_PROVIDER_TILE_DESC_BYTES = 128`. Little-endian. `u32` fields are 4-byte
136
+ aligned; `f64` fields start at offset 48 and are all 8-byte aligned, so a
137
+ guest may read them with aligned loads on every target.
138
+
139
+ | offset | type | field | meaning |
140
+ | ------ | ----- | ----------------- | ------- |
141
+ | 0 | u32 | `magic` | `0x53445054` (`'SDPT'`) |
142
+ | 4 | u32 | `version` | `1` |
143
+ | 8 | u32 | `kind` | `1` terrain, `2` imagery |
144
+ | 12 | u32 | `encoding` | see below |
145
+ | 16 | u32 | `width` | elements per row |
146
+ | 20 | u32 | `height` | rows |
147
+ | 24 | u32 | `planeCount` | number of readable planes, `>= 1` |
148
+ | 28 | u32 | `bytesPerElement` | of plane 0 |
149
+ | 32 | u32 | `rowStrideBytes` | of plane 0; `>= width * bytesPerElement` |
150
+ | 36 | u32 | `byteLength` | of plane 0 |
151
+ | 40 | u32 | `flags` | see below |
152
+ | 44 | u32 | `level` | source level, or `0xFFFFFFFF` for derived tiles |
153
+ | 48 | f64 | `west` | radians |
154
+ | 56 | f64 | `south` | radians |
155
+ | 64 | f64 | `east` | radians |
156
+ | 72 | f64 | `north` | radians |
157
+ | 80 | f64 | `minValue` | terrain: min height, metres. imagery: `0` |
158
+ | 88 | f64 | `maxValue` | terrain: max height, metres. imagery: `0` |
159
+ | 96 | u32 | `tileX` | source tile X, or `0xFFFFFFFF` |
160
+ | 100 | u32 | `tileY` | source tile Y, or `0xFFFFFFFF` |
161
+ | 104 | u32 | `hostCopies` | host→guest copies this acquire will cost per whole-plane read |
162
+ | 108 | u32 | `sourceId` | FNV-1a 32 of the provider id — stable, comparable across runtimes |
163
+ | 112 | u32 | `costClass` | what this acquire actually cost — see [Cost classes](#cost-classes) |
164
+ | 116 | u32 | `strategy` | how the level was chosen: `0` default, `1` grid-matched-level, `2` most-detailed, `3` fixed-level |
165
+ | 120 | u32[2]| `reserved` | zero |
166
+
167
+ `encoding`:
168
+
169
+ | value | name | element |
170
+ | ----- | ---- | ------- |
171
+ | 1 | `SDM_PROVIDER_ENC_HEIGHT_F32` | `float` metres above the ellipsoid |
172
+ | 2 | `SDM_PROVIDER_ENC_HEIGHT_F64` | `double` metres above the ellipsoid |
173
+ | 16 | `SDM_PROVIDER_ENC_RGBA8` | 4 × `uint8_t` |
174
+ | 17 | `SDM_PROVIDER_ENC_RGB8` | 3 × `uint8_t` |
175
+ | 18 | `SDM_PROVIDER_ENC_GRAY8` | 1 × `uint8_t` |
176
+ | 19 | `SDM_PROVIDER_ENC_GRAY16` | 1 × `uint16_t` |
177
+ | 20 | `SDM_PROVIDER_ENC_RGBA_F32` | 4 × `float`, 0..1 |
178
+
179
+ **Terrain heights are always delivered in metres**, as `f32` or `f64`. A
180
+ provider's on-the-wire encoding — a 16-bit heightmap with
181
+ `{heightScale, heightOffset, elementsPerHeight, stride, elementMultiplier,
182
+ isBigEndian}` structure, or a quantized-mesh's zig-zag-encoded `u16`
183
+ vertices — is the *provider's* business. The adapter dequantizes into metres
184
+ exactly once and pins the result. Guests never see a scale/offset field
185
+ because guests must never reimplement a provider's quantization; that is the
186
+ single most likely place for two runtimes to disagree by one ULP, and the ABI
187
+ removes the opportunity rather than documenting it.
188
+
189
+ `flags`:
190
+
191
+ | bit | name | meaning |
192
+ | --- | ---- | ------- |
193
+ | 0 | `SDM_PROVIDER_FLAG_INTERPOLATED` | values were interpolated from a coarser level than requested |
194
+ | 1 | `SDM_PROVIDER_FLAG_STAGED` | host had to stage a copy; `hostCopies` is `2` |
195
+ | 2 | `SDM_PROVIDER_FLAG_PARTIAL` | some elements have no data and hold the no-data sentinel |
196
+ | 3 | `SDM_PROVIDER_FLAG_DERIVED` | not a source tile (profile/region request) |
197
+ | 4 | `SDM_PROVIDER_FLAG_FIXTURE` | served by the deterministic fixture adapter |
198
+
199
+ Bit 4 exists so a parity test can *prove* it compared fixture bytes against
200
+ fixture bytes, rather than assuming it.
201
+
202
+ ### The no-data sentinel
203
+
204
+ A terrain sample with no data is **not** absent, **not** `0`, and **not** NaN.
205
+ It is an exact bit pattern:
206
+
207
+ | encoding | sentinel | bits |
208
+ | -------- | -------- | ---- |
209
+ | `HEIGHT_F32` | `-FLT_MAX` | `0xFF7FFFFF` |
210
+ | `HEIGHT_F64` | `-DBL_MAX` | `0xFFEFFFFFFFFFFFFF` |
211
+
212
+ NaN is specifically rejected. WebAssembly does not canonicalize NaN payloads
213
+ across all producing operations, so two runtimes can hold *different bits* for
214
+ "a NaN" and a byte-identical-output assertion would fail on a value that is
215
+ semantically equal. A sentinel with one exact encoding cannot do that.
216
+
217
+ This also closes a real defect class in the existing engine seam, where "no
218
+ data" is expressed as `Cartographic.height === undefined` — a JS-object-only
219
+ state that a typed array cannot represent at all, and which therefore silently
220
+ becomes `0` (sea level) the moment anyone packs those samples into a buffer.
221
+ Sea level under a ridge is exactly the failure the RF terrain solver was filed
222
+ for. The sentinel is defined here, in the ABI, so no consumer has to invent it.
223
+
224
+ ### Cost classes
225
+
226
+ `costClass` states what an acquire actually cost. The acquire request carries
227
+ `"maxCost"`, and an adapter that cannot serve within it returns
228
+ `SDM_PROVIDER_E_UNSUPPORTED`.
229
+
230
+ | value | name | meaning |
231
+ | ----- | ---- | ------- |
232
+ | 0 | `SDM_PROVIDER_COST_RESIDENT` | bytes already decoded and resident; pure memcpy |
233
+ | 1 | `SDM_PROVIDER_COST_DEQUANTIZE` | resident, but dequantized to metres by the adapter |
234
+ | 2 | `SDM_PROVIDER_COST_REDECODE` | adapter re-decoded already-fetched source data |
235
+ | 3 | `SDM_PROVIDER_COST_REFETCH` | adapter went to the network |
236
+ | 4 | `SDM_PROVIDER_COST_READBACK` | adapter stalled the GPU to read a texture back |
237
+
238
+ **`maxCost` defaults to `1`.** That default *is* the owner's "never re-fetch or
239
+ re-parse" rule, enforced by the ABI rather than by discipline. A caller who
240
+ genuinely wants the expensive path must raise the ceiling and thereby say so in
241
+ its own source.
242
+
243
+ Class 1 exists because it is unavoidable and honest: a quantized-mesh terrain
244
+ tile holds `uint16` vertices and **no metre-space array exists anywhere** in
245
+ the provider. The adapter dequantizes with the provider's own formula, once,
246
+ into the pinned buffer. That is not a re-decode — nothing is re-parsed and
247
+ nothing is re-fetched — but it is not free either, so it gets its own class
248
+ instead of being hidden inside class 0.
249
+
250
+ ## Acquire requests
251
+
252
+ The JSON request selects one of three shapes. All angles are **radians**.
253
+
254
+ **`tile`** — one source tile, exactly as the provider decoded it. Zero
255
+ resampling, zero interpolation.
256
+
257
+ ```json
258
+ {"op":"tile","providerId":"terrain.fixture","level":9,"x":123,"y":45}
259
+ ```
260
+
261
+ **`profile`** — heights along a path. The seam the RF terrain solver consumes:
262
+ one call, one copy, `width = samples`, `height = 1`,
263
+ `encoding = SDM_PROVIDER_ENC_HEIGHT_F64`.
264
+
265
+ ```json
266
+ {"op":"profile","providerId":"terrain.fixture",
267
+ "start":[-1.9,0.65],"end":[-1.88,0.66],"samples":256,"level":"mostDetailed"}
268
+ ```
269
+
270
+ or with explicit positions:
271
+
272
+ ```json
273
+ {"op":"profile","providerId":"terrain.fixture",
274
+ "positions":[[-1.9,0.65],[-1.899,0.6501]]}
275
+ ```
276
+
277
+ **Raster in.** A coverage field is commonly 512x512 = **262,144 positions**.
278
+ Encoding those as JSON would be a multi-megabyte request string, parsed on
279
+ every call — a worse cost than the read it is asking for, and it would make the
280
+ control plane carry bulk data, which this ABI exists to prevent. So positions
281
+ may instead be a pointer:
282
+
283
+ ```json
284
+ {"op":"profile","providerId":"terrain.fixture",
285
+ "positionsPtr":1048576,"positionsCount":262144,"spacing":30}
286
+ ```
287
+
288
+ The host reads interleaved f64 lon/lat pairs straight out of guest memory with
289
+ ONE copy, symmetric with the way results come back. The request string stays
290
+ under 200 bytes no matter how large the field is. A pointer or count outside
291
+ linear memory is `SDM_PROVIDER_E_BOUNDS` — a value, never a trap and never a
292
+ silent short read.
293
+
294
+ **`spacing` beats `level`.** Any shape may carry `"spacing": <metres>` — the
295
+ sample stride the caller intends to march at — and the port picks the coarsest
296
+ level whose sample spacing is at or below it. This is the preferred form:
297
+ a consumer knows its own stride, it does not know a provider's level scheme.
298
+ Asking for `"mostDetailed"` everywhere is what makes a solve slow; asking for
299
+ coarser than the march stride is what makes it wrong. Level selection lives in
300
+ one shared helper (`providerLevelForSpacing`) used by every adapter, so the
301
+ browser and a host tile store resolve the same spacing to the same level — two
302
+ adapters doing their own arithmetic would sample different ground and lose byte
303
+ parity for a reason no diff would show.
304
+
305
+ The level actually used comes back in `descriptor.level`, always. A consumer
306
+ that cannot see which level answered cannot tell a solve that resolved the
307
+ ridges from one that interpolated them away.
308
+
309
+ `level` is an integer, `"mostDetailed"`, or omitted (adapter's default). The
310
+ sampled positions are great-circle-interpolated between `start` and `end` by
311
+ the *host adapter*, using the adapter's native sampler, so that two runtimes
312
+ sampling the same source agree byte-for-byte. A guest that interpolates its
313
+ own positions and passes them explicitly gets exactly what it asked for.
314
+
315
+ **`region`** — a rectangle resampled onto a `width × height` grid. What a
316
+ viewshed, a coverage raster, or a cloud-mask lane wants.
317
+
318
+ ```json
319
+ {"op":"region","providerId":"imagery.fixture",
320
+ "rectangle":[-1.91,0.64,-1.87,0.67],"width":256,"height":256,"level":9}
321
+ ```
322
+
323
+ `region` on a terrain provider yields heights; on an imagery provider it
324
+ yields pixels. The port does not care which — that is the whole point.
325
+
326
+ Every shape accepts `"maxCost": N` (default `1`) and `"plane": "height" |
327
+ "pixels" | N`.
328
+
329
+ ### Imagery is not terrain, and the ABI says so
330
+
331
+ Terrain can honour `maxCost: 0..1` because a decoded terrain tile stays
332
+ resident in the provider for as long as the tile is loaded. **Imagery cannot**,
333
+ on the current engine tree: the CPU-side pixel buffer is released immediately
334
+ after the texture is uploaded to the GPU, so for any tile the renderer has
335
+ finished with, the decoded pixels are simply gone.
336
+
337
+ The consequence is stated here rather than discovered later:
338
+
339
+ - imagery `acquire` at the default `maxCost: 1` succeeds **only** for tiles
340
+ caught in the window between decode and upload, and returns
341
+ `SDM_PROVIDER_E_UNSUPPORTED` otherwise;
342
+ - imagery at `maxCost: 4` may be served by GPU readback — but those are the
343
+ *reprojected* pixels the renderer holds, not the source pixels, and the
344
+ descriptor says so via `FLAG_DERIVED`;
345
+ - imagery at `maxCost: 3` re-fetches and re-decodes from the provider.
346
+
347
+ An adapter that wants to offer resident imagery must tap the pixels *before*
348
+ upload; that is an engine-side change and belongs to the engine owner, not to
349
+ this ABI. Until it exists, **`SDM_PROVIDER_E_UNSUPPORTED` is the correct and
350
+ final answer for resident imagery pixels**, and it is the same answer in all
351
+ three runtimes. The ABI does not promise imagery parity with terrain, because
352
+ the engine cannot currently deliver it, and a port that promised it would be
353
+ lying in exactly the way this SDK exists to prevent.
354
+
355
+ ## Control operations
356
+
357
+ Ride the existing `space_data_module_host.call` bridge. No new imports.
358
+
359
+ | operation | request | response |
360
+ | --------- | ------- | -------- |
361
+ | `provider.list` | `{"kind":"terrain"\|"imagery"\|null}` | `{"providers":[{id,kind,name,ready,minLevel,maxLevel,tileWidth,tileHeight,encoding,credit}]}` |
362
+ | `provider.describe` | `{"id":"..."}` | the same record, plus adapter-specific `attributes` |
363
+ | `provider.select` | `{"kind":"terrain","id":"..."}` | `{"selected":"..."}` |
364
+ | `provider.configure` | `{"id":"...","settings":{...}}` | `{"applied":["alpha","show"],"rejected":[]}` |
365
+ | `provider.availability` | `{"id":"...","level":9,"x":1,"y":2}` | `{"available":true}` |
366
+ | `provider.prefetch` | `{"id":"...","rectangle":[w,s,e,n],"level":9}` | `{"requested":N,"pending":M,"supported":bool}` |
367
+ | `provider.await` | `{"id":"...","rectangle":[...],"level":9,"timeoutMs":30000}` | `{"ready":true,"pending":0}` |
368
+ | `provider.lastError` | `{}` | `{"code":-3,"name":"...","message":"...","operation":"..."}` |
369
+ | `provider.stats` | `{}` | `{"acquires":N,"reads":N,"bytesCopied":N,"hostCopies":N,"pinned":N}` |
370
+
371
+ `provider.list` returning `{"providers":[]}` is a **success**, not an error. A
372
+ headless node with nothing configured is a legitimate, enumerable state; making
373
+ it an error would mean the "nothing here" path differs from the "something
374
+ here" path in shape, and modules would grow runtime-shaped branches. They
375
+ return the same shape, so they don't.
376
+
377
+ `provider.configure` is intentionally a key/value bag with an explicit
378
+ `applied`/`rejected` split. Adapters apply what their underlying surface
379
+ natively supports and *report* the rest as rejected. An adapter must never
380
+ emulate a setting its provider does not have.
381
+
382
+ `provider.stats.hostCopies` is what a copy-count assertion reads. It is
383
+ maintained identically by every adapter.
384
+
385
+ `provider.prefetch` carries `"supported"` because **region prefetch is a
386
+ genuine engine LACK**, ruled by the engine owner: the browser engine loads
387
+ tiles from camera position only, and its one region-shaped API loads
388
+ *availability metadata*, not geometry. Rather than invent an engine API — which
389
+ the native-API law forbids — the port reports `"supported": false` with
390
+ `"requested": 0`. A host-side tile-store adapter, which has no camera and can
391
+ simply fetch, reports `"supported": true`. This is a real capability difference
392
+ and it is **declared in the response, not hidden**: a module reads one boolean
393
+ and does not branch on runtime. Closing the gap is engine work, tracked
394
+ separately as `orbpro-provider-access-port`.
395
+
396
+ ## Error codes
397
+
398
+ Negative `i32`, returned from `acquire`, `read` and `release`. Identical value,
399
+ identical meaning, identical trap class (none — these are values) in every
400
+ runtime.
401
+
402
+ | code | name | when |
403
+ | ---- | ---- | ---- |
404
+ | `-1` | `SDM_PROVIDER_E_INVALID_REQUEST` | malformed JSON, unknown `op`, out-of-range field |
405
+ | `-2` | `SDM_PROVIDER_E_NO_CAPABILITY` | `scene_access`/`provider.v1` not granted |
406
+ | `-3` | `SDM_PROVIDER_E_NO_PROVIDER` | no provider with that id or kind |
407
+ | `-4` | `SDM_PROVIDER_E_NOT_READY` | provider exists but is not ready; `provider.await` first |
408
+ | `-5` | `SDM_PROVIDER_E_NOT_AVAILABLE` | no data at that level/x/y |
409
+ | `-6` | `SDM_PROVIDER_E_BOUNDS` | pointer/length outside guest memory, or offset past the plane |
410
+ | `-7` | `SDM_PROVIDER_E_BAD_HANDLE` | unknown or already-released handle |
411
+ | `-8` | `SDM_PROVIDER_E_BAD_PLANE` | `plane >= planeCount` |
412
+ | `-9` | `SDM_PROVIDER_E_UNSUPPORTED` | adapter cannot serve this without re-fetch or re-decode |
413
+ | `-10` | `SDM_PROVIDER_E_TIMEOUT` | load did not complete in the adapter's budget |
414
+ | `-11` | `SDM_PROVIDER_E_HOST` | adapter threw; detail via `provider.lastError` |
415
+ | `-12` | `SDM_PROVIDER_E_PORT_UNAVAILABLE`| no provider port bound in this runtime at all |
416
+
417
+ `-9` is the honesty code. An adapter that *could* produce the answer by
418
+ re-downloading a tile or re-decoding a PNG must return `-9` rather than pay a
419
+ cost the caller did not ask for. A caller that wants the expensive path asks
420
+ for it explicitly through `provider.prefetch` + `provider.await`.
421
+
422
+ ## Copy contract
423
+
424
+ The owner's budget is **one copy host→wasm, zero re-decode**. The port meets it
425
+ where the memory topology permits and reports honestly where it does not.
426
+ `descriptor.hostCopies` and `provider.stats.hostCopies` are the measurement,
427
+ readable from inside the guest, identical field in every runtime.
428
+
429
+ | lane | copies for a whole-plane `read` | why |
430
+ | ---- | ------------------------------- | --- |
431
+ | WasmEdge native | **1** | host writes the pinned decoded buffer into linear memory |
432
+ | WasmEdge in Docker | **1** | same host path |
433
+ | Browser, wasi-threads guest | **1** | guest memory is a `SharedArrayBuffer`; the controlling thread that owns the provider caches writes into it directly at `dstPtr` |
434
+ | Browser, sequential guest | **2** (`FLAG_STAGED`) | guest memory is not shared, so the controlling thread stages into the SAB and the worker performs the final copy |
435
+
436
+ Zero of these paths encode the bytes into a hostcall envelope. That matters:
437
+ the generic `space_data_module_host` envelope route costs **five** copies for a
438
+ tile in the browser worker topology (provider array → envelope encode → SAB
439
+ `data.set` → worker `new Uint8Array(length)` → bridge `lastResponseBytes` →
440
+ `read_response` into linear memory). A 262 KB heightmap through the generic
441
+ route is over a megabyte of memcpy per tile. That measured cost is the entire
442
+ justification for three dedicated imports instead of one more `provider.*`
443
+ operation on the existing bridge.
444
+
445
+ The sequential-browser `2` is the **already-sanctioned browser-sequential
446
+ throughput divergence** and nothing more. Bytes are identical, error codes are
447
+ identical, `FLAG_STAGED` and `hostCopies` state the difference out loud, and a
448
+ module that wants to assert `hostCopies == 1` can — it will simply be asserting
449
+ that it is running threaded. It closes when `browser-worker-topology` lands.
450
+
451
+ ## Runtime satisfaction
452
+
453
+ The imports are always present. What is bound behind them varies, and only in
454
+ the SDK host shims.
455
+
456
+ **Browser, engine present.** `createEngineProviderAdapter({ scene })` satisfies
457
+ the port from the engine's live provider objects, through engine-native APIs
458
+ only. It binds in two tiers, and which tier answered is visible to the guest in
459
+ `descriptor.costClass`:
460
+
461
+ - **Tier A — the engine's own `ProviderAccessPort`,** when the engine exposes
462
+ one. This is the `costClass 0/1` path: it walks the loaded tile cache and
463
+ hands over the decoded terrain data the engine already holds. Reaching into
464
+ a provider's private fields is *engine* work, not SDK work — those fields
465
+ rot on every upstream pin advance, and the engine's owner keeps them tested
466
+ inside the engine's own gates. The SDK calls the public port and nothing
467
+ else.
468
+ - **Tier B — public engine API only,** when no port is present. Terrain is
469
+ served through the engine's exported most-detailed terrain sampler, which
470
+ re-requests and re-decodes: `costClass 2`. Under the default `maxCost: 1`
471
+ this tier therefore **refuses** with `SDM_PROVIDER_E_UNSUPPORTED`, and a
472
+ caller gets bytes only by explicitly raising its ceiling.
473
+
474
+ Tier B works today against real provider objects with no engine change, which
475
+ is what makes the port usable before the engine port lands; Tier A is what
476
+ makes it cheap afterwards. Neither tier ever silently upgrades its cost.
477
+
478
+ **WasmEdge, native or Docker.** There is no engine. The ruling, explicitly:
479
+
480
+ > The server-side satisfaction is a **host-side tile-store adapter**, not a
481
+ > refusal — and not an engine port. `createTileStoreProviderAdapter(...)`
482
+ > serves the identical operations from tile sources reachable by the host,
483
+ > using **only capabilities that already exist**: `filesystem` for a local
484
+ > tileset directory, `http` for a remote tile service. It introduces no new
485
+ > generic hook, so it needs no owner sign-off and no new connector. Where the
486
+ > SDN node wants to expose its own curated terrain/imagery sources through
487
+ > this port, that is a node-side *configuration* of this adapter — a `sdn`
488
+ > escalation for wiring, never a change to this ABI.
489
+
490
+ **Any runtime with nothing bound.** The port answers and does not lie:
491
+ `provider.list` → `{"providers":[]}`; `acquire` → `SDM_PROVIDER_E_NO_PROVIDER`;
492
+ with the capability withheld entirely, `SDM_PROVIDER_E_NO_CAPABILITY`; with no
493
+ port installed by the host at all, `SDM_PROVIDER_E_PORT_UNAVAILABLE`. All three
494
+ are values, none is a trap, and **all three are reachable in the browser too** —
495
+ ask a browser adapter for a provider id that does not exist and you get `-3`,
496
+ the same `-3`. The failure path is therefore parity-testable in every lane
497
+ rather than being a WasmEdge-only special case, which is the difference between
498
+ a mirror and an excuse.
499
+
500
+ A module never branches on runtime. It branches on `-3`, and that branch is
501
+ exercised in all three lanes.
502
+
503
+ ## Parity envelope
504
+
505
+ **Inside the envelope** — byte-identical across browser, native WasmEdge and
506
+ Docker WasmEdge, at any thread count:
507
+
508
+ - every descriptor field for a given request against a given source
509
+ - every byte of every plane read from the **fixture provider**
510
+ - every error code, for every malformed and unsatisfiable request
511
+ - `provider.list` / `describe` / `availability` shapes and ordering
512
+ - the digest returned by the `provider-digest` demo module
513
+
514
+ The fixture provider is a deterministic analytic source (a closed-form height
515
+ field and a closed-form pixel field, both functions of tile coordinates only).
516
+ It is bound in the browser as a real engine-shaped provider object and under
517
+ WasmEdge as a fixture tile store, so both lanes exercise the *adapter* code
518
+ paths rather than a shared shortcut. `FLAG_FIXTURE` proves which one answered.
519
+
520
+ **Outside the envelope** — and this is stated so it is never mistaken for a
521
+ defect: bytes from *live* network providers. Two runtimes reading a live
522
+ world-terrain service are not obliged to hold the same tiles at the same
523
+ instant, and asserting so would be false rigor. Their **ABI behaviour** —
524
+ codes, descriptor shapes, plane counts, bounds handling, copy accounting — is
525
+ fully inside the envelope.
526
+
527
+ **Divergence in anything listed as inside the envelope is a P1 SDK defect.**
528
+ Not a platform quirk. File, block, fix.
529
+
530
+ ## Consumer seam — terrain source
531
+
532
+ The immediate consumer is the RF terrain solver, and the point of defining the
533
+ terrain half first is that it must not ship a private terrain path.
534
+
535
+ `src/host/terrainSourceSeam.js` defines the one JS interface both the engine
536
+ port and the SDK adapters implement, plus
537
+ `assertTerrainSourceConformance(source)` so a consumer can prove at wiring time
538
+ that whatever it was handed is the real seam:
539
+
540
+ ```js
541
+ {
542
+ id, // stable provider id
543
+ costClass, // what readHeights will cost
544
+ readHeights(positions, out, opts) -> Promise<Float64Array>, // bulk metres
545
+ readProfile(positions, opts) -> Promise<{ // + provenance
546
+ heights, level, strategy, costClass, partial, interpolated
547
+ }>,
548
+ sampleCompat(provider, positions) -> Promise<Cartographic[]>, // legacy shape
549
+ }
550
+ ```
551
+
552
+ `opts` carries `spacing` (metres — the caller's march stride, preferred),
553
+ `level`, `maxCost`, and `out`. One call carries a whole field: the solver's
554
+ coverage rasters reach 512×512 = 262,144 points, and a per-point call cannot
555
+ survive a boundary crossing at that count, which is why the seam is batched and
556
+ in-place rather than scalar.
557
+
558
+ `sampleCompat` is signature-compatible with the sampler the solver's injectable
559
+ statics already hold, so the seam can be assigned on day one with no call-site
560
+ change. `readHeights` is the real entry: contiguous metres, no per-sample JS
561
+ object, no-data expressed as the sentinel rather than `undefined`.
562
+
563
+ The cutover is therefore two recorded steps, not a rewrite:
564
+
565
+ 1. **Now** — the solver keeps its injectable statics and receives a
566
+ conforming seam through them. No private terrain path is introduced.
567
+ 2. **On engine-port landing** — the terrain source becomes an explicit
568
+ parameter on the analysis entry points (the pluggable-propagation law
569
+ applied to terrain) and call sites move from `sampleCompat` to
570
+ `readHeights`. Tracked as `orbpro-provider-access-port`.
571
+
572
+ ## Guest usage
573
+
574
+ `templates/provider-access-module/include/space_data_provider_abi.h` declares
575
+ the imports and the descriptor for `clang --target=wasm32-wasip1-threads`. The
576
+ module is EH-free and compiles identically for every lane; there is exactly one
577
+ `dist/isomorphic/module.wasm`.
578
+
579
+ ```c
580
+ #include "space_data_provider_abi.h"
581
+
582
+ sdm_provider_tile_desc_t desc;
583
+ const char *req =
584
+ "{\"op\":\"profile\",\"providerId\":\"terrain.fixture\","
585
+ "\"start\":[-1.9,0.65],\"end\":[-1.88,0.66],\"samples\":256,\"maxCost\":1}";
586
+
587
+ int32_t h = sdm_provider_acquire(req, (int32_t)strlen(req), &desc);
588
+ if (h < 0) {
589
+ /* -3 here on a node with no terrain configured. Same branch, every lane. */
590
+ return handle_no_terrain(h);
591
+ }
592
+ double heights[256];
593
+ int32_t n = sdm_provider_read(h, 0, 0, (int32_t)(uintptr_t)heights,
594
+ (int32_t)sizeof heights);
595
+ sdm_provider_release(h);
596
+ ```
597
+
598
+ `desc.hostCopies` is `1` on WasmEdge and on a threaded browser guest, `2` on a
599
+ sequential browser guest, and the 2048 bytes in `heights` are identical in all
600
+ of them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "space-data-module-sdk",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "Module SDK for building, validating, signing, and deploying WebAssembly modules on the Space Data Network.",
5
5
  "type": "module",
6
6
  "types": "./src/index.d.ts",
@@ -50,14 +50,22 @@
50
50
  "./http": "./src/http/index.js",
51
51
  "./host/browser-edge-shims": "./src/host/browserEdgeShims.js",
52
52
  "./host/wasi-shim": "./src/host/wasiShim.js",
53
- "./host/isomorphic": "./src/host/isomorphicLoader.js",
54
- "./testing/browser": "./src/testing/browserModuleHarness.js",
53
+ "./host/isomorphic": {
54
+ "browser": "./src/host/isomorphicLoaderBrowser.js",
55
+ "default": "./src/host/isomorphicLoader.js"
56
+ },
57
+ "./host/browser-module": "./src/host/browserModuleHarness.js",
58
+ "./host/worker-module": "./src/host/workerModuleHarness.js",
59
+ "./testing/browser": "./src/testing/browser.js",
55
60
  "./testing/module-flatbuffer-stream-pump": {
56
61
  "types": "./src/index.d.ts",
57
- "default": "./src/testing/moduleFlatbufferStreamPump.js"
62
+ "default": "./src/host/moduleFlatbufferStreamPump.js"
58
63
  },
59
64
  "./testing": "./src/testing/index.js",
60
- "./standards": "./src/standards/index.js",
65
+ "./standards": {
66
+ "browser": "./src/standards/browser.js",
67
+ "default": "./src/standards/index.js"
68
+ },
61
69
  "./utils/wasm-crypto": "./src/utils/wasmCrypto.js",
62
70
  "./schemas/*": "./schemas/*",
63
71
  "./compat": "./src/compat/index.js"
@@ -82,6 +90,7 @@
82
90
  "start:lab": "node ./lab/server.js",
83
91
  "check:nav": "node scripts/check-sdn-stack-nav.mjs module-sdk docs/index.html docs/styles.css",
84
92
  "check:compliance": "node ./bin/space-data-module.js check --repo-root .",
93
+ "check:generated-bindings": "node scripts/check-generated-bindings.mjs",
85
94
  "generate:vectors": "node ./examples/single-file-bundle/generate-vectors.mjs",
86
95
  "prepublishOnly": "npm test && npm run check:compliance"
87
96
  },
@@ -91,7 +100,7 @@
91
100
  "flatsql": "^0.4.2",
92
101
  "hd-wallet-wasm": "2.0.28",
93
102
  "sdn-emception": "1.0.0",
94
- "spacedatastandards.org": "https://github.com/DigitalArsenal/spacedatastandards.org/archive/84f7d65ae07321d5314dedb0f5dce11a6e0919cd.tar.gz"
103
+ "spacedatastandards.org": "https://github.com/DigitalArsenal/spacedatastandards.org/archive/06deda5079204a46bd97a7ce6ac2868e991c6b8f.tar.gz"
95
104
  },
96
105
  "devDependencies": {
97
106
  "esbuild": "^0.28.0",