@driftengine/drft 3.63.0 → 4.1.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.
package/dist/drftWrite.js CHANGED
@@ -10,9 +10,15 @@
10
10
  */
11
11
  import { buildClip, buildMorph, buildNodes, buildSkin } from './drftSkin.js';
12
12
  import { validateMeshData } from './meshData.js';
13
- import { ALIGNMENT, ATTR_EMISSIVE_COLOR, ATTR_GRAIN, ATTR_JOINTS, ATTR_RELIEF, ATTR_TANGENT, ATTR_ROUGHNESS, ATTR_SPECULAR, ATTR_UVS, ATTR_WEIGHTS, CHUNK_ENTRY_BYTES, CHUNK_HEAD, CHUNK_ANIM, CHUNK_LODM, CHUNK_LODF, CHUNK_MORP, CHUNK_NODE, CHUNK_SKIN, CHUNK_SPLT, SPLAT_BLOCK_PREFIX, CHUNK_MATL, CHUNK_MESH, CHUNK_REQUIRED, CHUNK_COLL, CHUNK_SUBS, CHUNK_TEXS, MATERIAL_ENTRY_BYTES, DRFT_MAGIC, DRFT_VERSION_MAJOR, DRFT_VERSION_MINOR, DrftError, FLAG_LITTLE_ENDIAN, HEADER_BYTES, align, } from './drftFormat.js';
13
+ import { ALIGNMENT, ATTR_EMISSIVE_COLOR, ATTR_GRAIN, ATTR_JOINTS, ATTR_RELIEF, ATTR_TANGENT, ATTR_ROUGHNESS, ATTR_SPECULAR, ATTR_UVS, ATTR_WEIGHTS, CHUNK_ENTRY_BYTES, CHUNK_HEAD, CHUNK_ANIM, CHUNK_DTEX, CHUNK_ENTS, CHUNK_NAVM, CHUNK_LODM, CHUNK_LODF, CHUNK_MORP, CHUNK_NODE, CHUNK_SKIN, CHUNK_SPLT, SPLAT_BLOCK_PREFIX, CHUNK_MATL, CHUNK_MESH, CHUNK_REQUIRED, CHUNK_COLL, CHUNK_NNET, CHUNK_NGRF, CHUNK_SDFV, CHUNK_SUBS, CHUNK_TEXS, MATERIAL_ENTRY_BYTES, DRFT_MAGIC, DRFT_VERSION_MAJOR, DRFT_VERSION_MINOR, DrftError, FLAG_LITTLE_ENDIAN, HEADER_BYTES, align, } from './drftFormat.js';
14
14
  import { coarseFirstOrder } from './coarseFirst.js';
15
15
  import { buildSubs } from './drftSubs.js';
16
+ import { buildSdfv } from './sdfv.js';
17
+ import { buildNnet } from './nnet.js';
18
+ import { buildDtex } from './dtex.js';
19
+ import { buildEnts } from './ents.js';
20
+ import { buildNavm } from './navm.js';
21
+ import { buildNgrf } from './ngrf.js';
16
22
  import { buildColliders } from './drftColliders.js';
17
23
  /**
18
24
  * The most blocks a capture is split into, and the fewest splats worth having in one.
@@ -313,8 +319,16 @@ export function writeDrft(source) {
313
319
  * ordinary asset and the reader accepts it, so refusing to write one would be the two halves of
314
320
  * the format disagreeing about what a file may contain.
315
321
  */
316
- if (source.meshes.length === 0 && (source.splats === undefined || source.splats.count === 0)) {
317
- throw new DrftError('an asset must contain at least one mesh or a capture with splats in it');
322
+ /*
323
+ * **And networks, since 1.15**, on the same argument: a converted model is weights and nothing
324
+ * else, and refusing it for the absence of geometry would be the format telling a consumer what
325
+ * an asset may be made of.
326
+ */
327
+ if (source.meshes.length === 0 &&
328
+ (source.splats === undefined || source.splats.count === 0) &&
329
+ (source.networks?.length ?? 0) === 0 &&
330
+ (source.graphs?.length ?? 0) === 0) {
331
+ throw new DrftError('an asset must contain at least one mesh, a capture with splats in it, or a network');
318
332
  }
319
333
  const materials = source.materials;
320
334
  if (materials !== undefined && materials.length !== source.meshes.length) {
@@ -378,6 +392,24 @@ export function writeDrft(source) {
378
392
  for (const clip of source.clips ?? []) {
379
393
  chunks.push({ code: CHUNK_ANIM, flags: 0, bytes: buildClip(clip) });
380
394
  }
395
+ for (const entry of source.dtex ?? []) {
396
+ /*
397
+ * **Refused here rather than at the reader**, because a decode program for a material the file
398
+ * does not carry is a bake that went wrong, and the writer is where the author can still be
399
+ * told. A reader meeting one has nothing useful to do with it.
400
+ */
401
+ if (entry.material >= (source.materials?.length ?? 0)) {
402
+ throw new DrftError(`drft: DTEX names material ${entry.material} and the file carries ` +
403
+ `${source.materials?.length ?? 0}`);
404
+ }
405
+ chunks.push({ code: CHUNK_DTEX, flags: 0, bytes: buildDtex(entry) });
406
+ }
407
+ if (source.navigation !== undefined) {
408
+ chunks.push({ code: CHUNK_NAVM, flags: 0, bytes: buildNavm(source.navigation) });
409
+ }
410
+ if (source.entities !== undefined) {
411
+ chunks.push({ code: CHUNK_ENTS, flags: 0, bytes: buildEnts(source.entities) });
412
+ }
381
413
  source.meshes.forEach((mesh, ordinal) => {
382
414
  /*
383
415
  * **Its deltas immediately before it, not after.** A streaming consumer uploads a mesh the
@@ -410,6 +442,30 @@ export function writeDrft(source) {
410
442
  bytes: buildSubs({ entries: source.substances }),
411
443
  });
412
444
  }
445
+ /* And one `SDFV`, on exactly the same terms. */
446
+ if ((source.fields?.length ?? 0) > 0) {
447
+ chunks.push({
448
+ code: CHUNK_SDFV,
449
+ flags: 0,
450
+ bytes: buildSdfv({ entries: source.fields }),
451
+ });
452
+ }
453
+ /* And one `NNET`, on the same terms again. */
454
+ if ((source.networks?.length ?? 0) > 0) {
455
+ chunks.push({
456
+ code: CHUNK_NNET,
457
+ flags: 0,
458
+ bytes: buildNnet({ networks: source.networks }),
459
+ });
460
+ }
461
+ /* And one `NGRF`, for the networks a perceptron's table cannot describe. */
462
+ if ((source.graphs?.length ?? 0) > 0) {
463
+ chunks.push({
464
+ code: CHUNK_NGRF,
465
+ flags: 0,
466
+ bytes: buildNgrf(source.graphs),
467
+ });
468
+ }
413
469
  /*
414
470
  * `COLL` last among the small chunks, because collision is the one thing a viewer never needs and
415
471
  * a consumer that simulates has already waited for the whole file by the time it builds a world.
package/dist/dtex.d.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `DTEX`: a material as a decode program over a latent, rather than as pictures.
3
+ *
4
+ * **The chunk carries no semantics and that is deliberate.** `@driftengine/drft` is the container
5
+ * and has no dependencies — it does not know what a `DecodeGraph` means, only what shape it is. So
6
+ * the types here are plain arrays that `@driftengine/texture`'s own types satisfy structurally,
7
+ * the same arrangement `Terrain` has with `Heightfield` across the physics boundary: a rule that
8
+ * cannot be shared as code is still shared as a test.
9
+ *
10
+ * ```
11
+ * u32 material which MATL entry this decodes
12
+ * u32 latentWidth
13
+ * u32 latentHeight
14
+ * u32 latentComponents
15
+ * u32 channelCount
16
+ * u32 nodeCount decode graph nodes, four words each
17
+ * u32 resultRegister
18
+ * u32 addressMode 0 and 1 lattice, 2 and 3 texel-centre; clamp, then wrap
19
+ * u32 networkInputs
20
+ * u32 networkOutputs
21
+ * u32 hiddenCount
22
+ * u32 weightCount
23
+ * u32 tileCount
24
+ * u32 tileSize texels on a tile's edge, or 0 for a payload that is one image
25
+ * u32 hidden[hiddenCount]
26
+ * u32 channels[channelCount] (semanticIndex << 4) | component, the packing REMAP_CHANNEL uses
27
+ * u32 nodes[nodeCount * 4]
28
+ * u32 tileOffset[tileCount] byte offset into the latent payload
29
+ * u32 tileLength[tileCount]
30
+ * u32 tileHash[tileCount * 2] `hashTile`'s 64 bits, high word first
31
+ * f32 weights[weightCount]
32
+ * u8 latent[...] padded to the container's alignment
33
+ * ```
34
+ *
35
+ * **No per-chunk version field, which is where this departs from the plan.** The plan's layout
36
+ * opened with a header and a version; every chunk in this container already answers to the file's
37
+ * own version, and `MSHL` states the reason in one line — a second version is a second thing to
38
+ * keep in step, and the one that drifts is the one nobody reads.
39
+ *
40
+ * **The tile table is the streaming unit, and until 2026-09-20 it could not be.** It was a flat
41
+ * list of offsets into a row-major image with a 32-bit hash that nothing wrote — and a tile of a
42
+ * row-major image is not contiguous, so no offset and length could name one. Three things make it
43
+ * real, and they are one decision rather than three:
44
+ *
45
+ * - **`tileSize` is in the header**, so a reader can work out the grid. Without it the table is a
46
+ * list of byte ranges nobody can address by tile.
47
+ * - **The payload is tile-major when there are tiles**: each tile's texels run together, in the
48
+ * grid's own order — row-major across, then down — which is exactly the order and the extent
49
+ * `@driftengine/texture`'s `latentTileGrid` cuts and `tilesForView` asks for. `tileCount` of 0
50
+ * means the payload is one row-major image, which is what every material small enough not to
51
+ * stream still is.
52
+ * - **The hash is `hashTile`'s 64 bits**, the same number residency addresses a tile by, so a tile
53
+ * that arrived for one material is already resident for every other material sharing it. Thirty
54
+ * two bits is about a one in ten thousand collision over sixty thousand tiles, and a collision
55
+ * here is one surface wearing another's texture.
56
+ *
57
+ * **What it gives up**: a reader that wants the whole image must put the tiles back together, which
58
+ * is a copy the row-major form does not need. That is why `tileCount` of 0 stays a legal chunk
59
+ * rather than being tidied away. **What would make it wrong** is a mip chain, which this chunk does
60
+ * not carry — `latentTileGrid` cuts one level per mip and a `DTEX` has one level, so the table is
61
+ * that level's tiles and nothing is implied about the others.
62
+ *
63
+ * **What is validated here is containment, not meaning.** A tile whose bytes run past the payload
64
+ * and a result register no node ever writes are refused, because those make a reader hand back
65
+ * arbitrary memory or an uninitialised value. Whether the program is one an interpreter can *run*
66
+ * is `validateDecodeGraph`'s question, in the package that knows what an operation is.
67
+ *
68
+ * Additive, and additive is what keeps `FORMAT.md`'s freeze intact: a reader that does not know
69
+ * this code skips it by its length and loses only the material, which it could not have decoded.
70
+ */
71
+ /** Registers the decode vocabulary has. Mirrors `MAX_REGISTERS`; asserted equal by a test. */
72
+ export declare const DTEX_REGISTERS = 16;
73
+ /** Address modes the decode vocabulary has. Mirrors `ADDRESS_MODE_COUNT`; asserted equal by a test. */
74
+ export declare const DTEX_ADDRESS_MODES = 4;
75
+ /** How many tiles one material may carry. Well past what a streaming budget permits. */
76
+ export declare const DTEX_MAX_TILES: number;
77
+ /** A material's decode program, and which `MATL` entry it belongs to. */
78
+ export interface DtexEntry {
79
+ /** The index of the `MATL` entry this decodes. */
80
+ readonly material: number;
81
+ readonly texture: DtexMaterial;
82
+ }
83
+ export interface DtexMaterial {
84
+ latentWidth: number;
85
+ latentHeight: number;
86
+ latentComponents: number;
87
+ /** `(semanticIndex << 4) | component` per channel. */
88
+ channels: Uint32Array;
89
+ /** Four words a node: op, a, b, out. */
90
+ nodes: Uint32Array;
91
+ resultRegister: number;
92
+ addressMode: number;
93
+ networkInputs: number;
94
+ networkOutputs: number;
95
+ hidden: Uint32Array;
96
+ weights: Float32Array;
97
+ tileOffset: Uint32Array;
98
+ tileLength: Uint32Array;
99
+ /** Two words a tile, high first, as `hashTile` splits its 64 bits. */
100
+ tileHash: Uint32Array;
101
+ /**
102
+ * Texels on a tile's edge, or 0 where the payload is one row-major image.
103
+ *
104
+ * A grid rather than a count, because what streams is a rectangle of texels and a reader has to
105
+ * be able to say which one it is looking at.
106
+ */
107
+ tileSize: number;
108
+ latent: Uint8Array;
109
+ }
110
+ /** How many tiles across a latent of `width` texels is cut into at this tile size. */
111
+ export declare function dtexTilesAcross(width: number, tileSize: number): number;
112
+ /**
113
+ * How many tiles the grid holds, which is what the table's length has to be.
114
+ *
115
+ * **The order is the grid's own**: across a row, then down, the same order
116
+ * `@driftengine/texture`'s `latentTileGrid` cuts in — so a reader with a tile coordinate reads
117
+ * `ty * dtexTilesAcross(width, tileSize) + tx` and gets the entry for it.
118
+ */
119
+ export declare function dtexTileCount(width: number, height: number, tileSize: number): number;
120
+ export declare function buildDtex(entry: DtexEntry): Uint8Array;
121
+ /**
122
+ * The material a `DTEX` chunk carries, as views over the fetched buffer.
123
+ *
124
+ * Views rather than copies, like every other chunk here, so a load allocates nothing beyond the
125
+ * object holding them.
126
+ */
127
+ export declare function readDtex(buffer: ArrayBuffer, offset: number, byteLength: number): DtexEntry;
package/dist/dtex.js ADDED
@@ -0,0 +1,324 @@
1
+ import { DrftError, align } from './drftFormat.js';
2
+ /**
3
+ * `DTEX`: a material as a decode program over a latent, rather than as pictures.
4
+ *
5
+ * **The chunk carries no semantics and that is deliberate.** `@driftengine/drft` is the container
6
+ * and has no dependencies — it does not know what a `DecodeGraph` means, only what shape it is. So
7
+ * the types here are plain arrays that `@driftengine/texture`'s own types satisfy structurally,
8
+ * the same arrangement `Terrain` has with `Heightfield` across the physics boundary: a rule that
9
+ * cannot be shared as code is still shared as a test.
10
+ *
11
+ * ```
12
+ * u32 material which MATL entry this decodes
13
+ * u32 latentWidth
14
+ * u32 latentHeight
15
+ * u32 latentComponents
16
+ * u32 channelCount
17
+ * u32 nodeCount decode graph nodes, four words each
18
+ * u32 resultRegister
19
+ * u32 addressMode 0 and 1 lattice, 2 and 3 texel-centre; clamp, then wrap
20
+ * u32 networkInputs
21
+ * u32 networkOutputs
22
+ * u32 hiddenCount
23
+ * u32 weightCount
24
+ * u32 tileCount
25
+ * u32 tileSize texels on a tile's edge, or 0 for a payload that is one image
26
+ * u32 hidden[hiddenCount]
27
+ * u32 channels[channelCount] (semanticIndex << 4) | component, the packing REMAP_CHANNEL uses
28
+ * u32 nodes[nodeCount * 4]
29
+ * u32 tileOffset[tileCount] byte offset into the latent payload
30
+ * u32 tileLength[tileCount]
31
+ * u32 tileHash[tileCount * 2] `hashTile`'s 64 bits, high word first
32
+ * f32 weights[weightCount]
33
+ * u8 latent[...] padded to the container's alignment
34
+ * ```
35
+ *
36
+ * **No per-chunk version field, which is where this departs from the plan.** The plan's layout
37
+ * opened with a header and a version; every chunk in this container already answers to the file's
38
+ * own version, and `MSHL` states the reason in one line — a second version is a second thing to
39
+ * keep in step, and the one that drifts is the one nobody reads.
40
+ *
41
+ * **The tile table is the streaming unit, and until 2026-09-20 it could not be.** It was a flat
42
+ * list of offsets into a row-major image with a 32-bit hash that nothing wrote — and a tile of a
43
+ * row-major image is not contiguous, so no offset and length could name one. Three things make it
44
+ * real, and they are one decision rather than three:
45
+ *
46
+ * - **`tileSize` is in the header**, so a reader can work out the grid. Without it the table is a
47
+ * list of byte ranges nobody can address by tile.
48
+ * - **The payload is tile-major when there are tiles**: each tile's texels run together, in the
49
+ * grid's own order — row-major across, then down — which is exactly the order and the extent
50
+ * `@driftengine/texture`'s `latentTileGrid` cuts and `tilesForView` asks for. `tileCount` of 0
51
+ * means the payload is one row-major image, which is what every material small enough not to
52
+ * stream still is.
53
+ * - **The hash is `hashTile`'s 64 bits**, the same number residency addresses a tile by, so a tile
54
+ * that arrived for one material is already resident for every other material sharing it. Thirty
55
+ * two bits is about a one in ten thousand collision over sixty thousand tiles, and a collision
56
+ * here is one surface wearing another's texture.
57
+ *
58
+ * **What it gives up**: a reader that wants the whole image must put the tiles back together, which
59
+ * is a copy the row-major form does not need. That is why `tileCount` of 0 stays a legal chunk
60
+ * rather than being tidied away. **What would make it wrong** is a mip chain, which this chunk does
61
+ * not carry — `latentTileGrid` cuts one level per mip and a `DTEX` has one level, so the table is
62
+ * that level's tiles and nothing is implied about the others.
63
+ *
64
+ * **What is validated here is containment, not meaning.** A tile whose bytes run past the payload
65
+ * and a result register no node ever writes are refused, because those make a reader hand back
66
+ * arbitrary memory or an uninitialised value. Whether the program is one an interpreter can *run*
67
+ * is `validateDecodeGraph`'s question, in the package that knows what an operation is.
68
+ *
69
+ * Additive, and additive is what keeps `FORMAT.md`'s freeze intact: a reader that does not know
70
+ * this code skips it by its length and loses only the material, which it could not have decoded.
71
+ */
72
+ /** Registers the decode vocabulary has. Mirrors `MAX_REGISTERS`; asserted equal by a test. */
73
+ export const DTEX_REGISTERS = 16;
74
+ /** Address modes the decode vocabulary has. Mirrors `ADDRESS_MODE_COUNT`; asserted equal by a test. */
75
+ export const DTEX_ADDRESS_MODES = 4;
76
+ /** How many tiles one material may carry. Well past what a streaming budget permits. */
77
+ export const DTEX_MAX_TILES = 1 << 16;
78
+ /*
79
+ * Fourteen: the thirteen the layout above lists after `material`, and `material` itself.
80
+ *
81
+ * **The pairing is in the chunk rather than in the order the chunks appear**, because a file may
82
+ * carry a decode program for some of its materials and not others — a scene where one surface came
83
+ * from a capture and the rest were authored — and a reader that paired by position would give the
84
+ * wrong material the wrong texture without anything failing.
85
+ */
86
+ const HEADER_WORDS = 14;
87
+ const HEADER = HEADER_WORDS * 4;
88
+ function layoutOf(material) {
89
+ const hiddenCount = material.hidden.length;
90
+ const channelCount = material.channels.length;
91
+ const nodeWords = material.nodes.length;
92
+ const tiles = material.tileOffset.length;
93
+ let at = HEADER;
94
+ const hidden = at;
95
+ at += hiddenCount * 4;
96
+ const channels = at;
97
+ at += channelCount * 4;
98
+ const nodes = at;
99
+ at += nodeWords * 4;
100
+ const tileOffset = at;
101
+ at += tiles * 4;
102
+ const tileLength = at;
103
+ at += tiles * 4;
104
+ const tileHash = at;
105
+ /* Two words a tile: `hashTile` is 64 bits and JavaScript has no integer that wide. */
106
+ at += tiles * 8;
107
+ const weights = at;
108
+ at += material.weights.length * 4;
109
+ const latent = at;
110
+ at += material.latent.length;
111
+ return { hidden, channels, nodes, tileOffset, tileLength, tileHash, weights, latent, size: at };
112
+ }
113
+ /** How many tiles across a latent of `width` texels is cut into at this tile size. */
114
+ export function dtexTilesAcross(width, tileSize) {
115
+ return tileSize > 0 ? Math.ceil(width / tileSize) : 0;
116
+ }
117
+ /**
118
+ * How many tiles the grid holds, which is what the table's length has to be.
119
+ *
120
+ * **The order is the grid's own**: across a row, then down, the same order
121
+ * `@driftengine/texture`'s `latentTileGrid` cuts in — so a reader with a tile coordinate reads
122
+ * `ty * dtexTilesAcross(width, tileSize) + tx` and gets the entry for it.
123
+ */
124
+ export function dtexTileCount(width, height, tileSize) {
125
+ return dtexTilesAcross(width, tileSize) * dtexTilesAcross(height, tileSize);
126
+ }
127
+ /** A tile table has to be the grid, or nothing can be addressed by where it is. */
128
+ function refuseStrayGrid(material, tiles) {
129
+ const { tileSize, latentWidth, latentHeight } = material;
130
+ if (tiles === 0) {
131
+ if (tileSize !== 0) {
132
+ throw new DrftError(`DTEX declares a tile edge of ${tileSize} texels and carries no tiles`);
133
+ }
134
+ return;
135
+ }
136
+ if (!Number.isInteger(tileSize) || tileSize < 1) {
137
+ throw new DrftError(`DTEX carries ${tiles} tiles and a tile edge of ${tileSize} texels`);
138
+ }
139
+ const wanted = dtexTileCount(latentWidth, latentHeight, tileSize);
140
+ if (tiles !== wanted) {
141
+ throw new DrftError(`DTEX carries ${tiles} tiles where a ${latentWidth} by ${latentHeight} latent cut at ` +
142
+ `${tileSize} is ${wanted}`);
143
+ }
144
+ }
145
+ export function buildDtex(entry) {
146
+ const { texture: material } = entry;
147
+ if (!Number.isInteger(entry.material) || entry.material < 0) {
148
+ throw new DrftError(`DTEX names material ${entry.material}, which is not an index`);
149
+ }
150
+ const tiles = material.tileOffset.length;
151
+ if (tiles > DTEX_MAX_TILES) {
152
+ throw new DrftError(`DTEX carries ${tiles} tiles, past the ${DTEX_MAX_TILES} cap`);
153
+ }
154
+ if (material.tileLength.length !== tiles || material.tileHash.length !== tiles * 2) {
155
+ throw new DrftError('DTEX was given a tile table whose three columns are different lengths');
156
+ }
157
+ refuseStrayGrid(material, tiles);
158
+ if (material.nodes.length === 0 || material.nodes.length % 4 !== 0) {
159
+ throw new DrftError(`DTEX was given ${material.nodes.length} node words, which is not whole nodes`);
160
+ }
161
+ if (material.latentComponents < 1 || material.latentComponents > 4) {
162
+ throw new DrftError(`DTEX latent has ${material.latentComponents} components, not one to four`);
163
+ }
164
+ /*
165
+ * **Only where the payload is one image.** With tiles it is tile-major and two identical tiles
166
+ * share one run, so a chunk whose payload is *smaller* than its grid is a chunk that deduplicated
167
+ * — which is the point. What holds there is the per-tile containment check below.
168
+ */
169
+ if (tiles === 0 &&
170
+ material.latent.length <
171
+ material.latentWidth * material.latentHeight * material.latentComponents) {
172
+ throw new DrftError('DTEX latent payload is smaller than the grid it declares');
173
+ }
174
+ /*
175
+ * Every tile is checked to lie inside the payload before anything is written. A tile running
176
+ * past the end is a subarray of arbitrary memory on the way back in, which is exactly what
177
+ * `MSHL` checks its cluster table for.
178
+ */
179
+ for (let t = 0; t < tiles; t += 1) {
180
+ const start = material.tileOffset[t];
181
+ const length = material.tileLength[t];
182
+ if (start + length > material.latent.length) {
183
+ throw new DrftError(`DTEX tile ${t} runs past the ${material.latent.length}-byte payload`);
184
+ }
185
+ }
186
+ /* A result register nothing writes hands a reader whatever that register happened to hold. */
187
+ let written = false;
188
+ for (let n = 0; n * 4 < material.nodes.length; n += 1) {
189
+ const out = material.nodes[n * 4 + 3];
190
+ if (out >= DTEX_REGISTERS) {
191
+ throw new DrftError(`DTEX node ${n} writes register ${out}, past the ${DTEX_REGISTERS} there are`);
192
+ }
193
+ if (out === material.resultRegister)
194
+ written = true;
195
+ }
196
+ if (!written) {
197
+ throw new DrftError(`DTEX names register ${material.resultRegister} as its result, and no node writes it`);
198
+ }
199
+ const layout = layoutOf(material);
200
+ const bytes = new Uint8Array(align(layout.size));
201
+ const view = new DataView(bytes.buffer);
202
+ view.setUint32(0, entry.material, true);
203
+ view.setUint32(4, material.latentWidth, true);
204
+ view.setUint32(8, material.latentHeight, true);
205
+ view.setUint32(12, material.latentComponents, true);
206
+ view.setUint32(16, material.channels.length, true);
207
+ view.setUint32(20, material.nodes.length / 4, true);
208
+ view.setUint32(24, material.resultRegister, true);
209
+ view.setUint32(28, material.addressMode, true);
210
+ view.setUint32(32, material.networkInputs, true);
211
+ view.setUint32(36, material.networkOutputs, true);
212
+ view.setUint32(40, material.hidden.length, true);
213
+ view.setUint32(44, material.weights.length, true);
214
+ view.setUint32(48, tiles, true);
215
+ view.setUint32(52, material.tileSize, true);
216
+ new Uint32Array(bytes.buffer, layout.hidden, material.hidden.length).set(material.hidden);
217
+ new Uint32Array(bytes.buffer, layout.channels, material.channels.length).set(material.channels);
218
+ new Uint32Array(bytes.buffer, layout.nodes, material.nodes.length).set(material.nodes);
219
+ new Uint32Array(bytes.buffer, layout.tileOffset, tiles).set(material.tileOffset);
220
+ new Uint32Array(bytes.buffer, layout.tileLength, tiles).set(material.tileLength);
221
+ new Uint32Array(bytes.buffer, layout.tileHash, tiles * 2).set(material.tileHash);
222
+ new Float32Array(bytes.buffer, layout.weights, material.weights.length).set(material.weights);
223
+ bytes.set(material.latent, layout.latent);
224
+ return bytes;
225
+ }
226
+ /**
227
+ * The material a `DTEX` chunk carries, as views over the fetched buffer.
228
+ *
229
+ * Views rather than copies, like every other chunk here, so a load allocates nothing beyond the
230
+ * object holding them.
231
+ */
232
+ export function readDtex(buffer, offset, byteLength) {
233
+ if (byteLength < HEADER)
234
+ throw new DrftError('DTEX is too short to hold its header');
235
+ const view = new DataView(buffer, offset, byteLength);
236
+ const materialIndex = view.getUint32(0, true);
237
+ const latentWidth = view.getUint32(4, true);
238
+ const latentHeight = view.getUint32(8, true);
239
+ const latentComponents = view.getUint32(12, true);
240
+ const channelCount = view.getUint32(16, true);
241
+ const nodeCount = view.getUint32(20, true);
242
+ const resultRegister = view.getUint32(24, true);
243
+ const addressMode = view.getUint32(28, true);
244
+ const networkInputs = view.getUint32(32, true);
245
+ const networkOutputs = view.getUint32(36, true);
246
+ const hiddenCount = view.getUint32(40, true);
247
+ const weightCount = view.getUint32(44, true);
248
+ const tiles = view.getUint32(48, true);
249
+ const tileSize = view.getUint32(52, true);
250
+ if (nodeCount === 0)
251
+ throw new DrftError('DTEX carries no decode program');
252
+ if (latentComponents < 1 || latentComponents > 4) {
253
+ throw new DrftError(`DTEX latent declares ${latentComponents} components, not one to four`);
254
+ }
255
+ if (addressMode >= DTEX_ADDRESS_MODES) {
256
+ throw new DrftError(`DTEX declares address mode ${String(addressMode)}; this reader knows 0 to ${String(DTEX_ADDRESS_MODES - 1)}`);
257
+ }
258
+ const shape = {
259
+ latentWidth,
260
+ latentHeight,
261
+ latentComponents,
262
+ channels: new Uint32Array(channelCount),
263
+ nodes: new Uint32Array(nodeCount * 4),
264
+ resultRegister,
265
+ addressMode,
266
+ networkInputs,
267
+ networkOutputs,
268
+ hidden: new Uint32Array(hiddenCount),
269
+ weights: new Float32Array(weightCount),
270
+ tileOffset: new Uint32Array(tiles),
271
+ tileLength: new Uint32Array(tiles),
272
+ tileHash: new Uint32Array(tiles * 2),
273
+ tileSize,
274
+ latent: new Uint8Array(0),
275
+ };
276
+ const layout = layoutOf(shape);
277
+ const latentBytes = byteLength - layout.latent;
278
+ if (latentBytes < 0) {
279
+ throw new DrftError(`DTEX declares ${String(layout.latent)} bytes of tables in a ${String(byteLength)}-byte chunk`);
280
+ }
281
+ const base = offset;
282
+ const material = {
283
+ latentWidth,
284
+ latentHeight,
285
+ latentComponents,
286
+ channels: new Uint32Array(buffer, base + layout.channels, channelCount),
287
+ nodes: new Uint32Array(buffer, base + layout.nodes, nodeCount * 4),
288
+ resultRegister,
289
+ addressMode,
290
+ networkInputs,
291
+ networkOutputs,
292
+ hidden: new Uint32Array(buffer, base + layout.hidden, hiddenCount),
293
+ weights: new Float32Array(buffer, base + layout.weights, weightCount),
294
+ tileOffset: new Uint32Array(buffer, base + layout.tileOffset, tiles),
295
+ tileLength: new Uint32Array(buffer, base + layout.tileLength, tiles),
296
+ tileHash: new Uint32Array(buffer, base + layout.tileHash, tiles * 2),
297
+ tileSize,
298
+ latent: new Uint8Array(buffer, base + layout.latent, latentBytes),
299
+ };
300
+ refuseStrayGrid(material, tiles);
301
+ if (tiles === 0 && latentBytes < latentWidth * latentHeight * latentComponents) {
302
+ throw new DrftError('DTEX latent payload is smaller than the grid it declares');
303
+ }
304
+ for (let t = 0; t < tiles; t += 1) {
305
+ const start = material.tileOffset[t];
306
+ const length = material.tileLength[t];
307
+ if (start + length > latentBytes) {
308
+ throw new DrftError(`DTEX tile ${t} runs past the ${String(latentBytes)}-byte payload`);
309
+ }
310
+ }
311
+ let written = false;
312
+ for (let n = 0; n < nodeCount; n += 1) {
313
+ const out = material.nodes[n * 4 + 3];
314
+ if (out >= DTEX_REGISTERS) {
315
+ throw new DrftError(`DTEX node ${n} writes register ${out}, past the ${DTEX_REGISTERS} there are`);
316
+ }
317
+ if (out === resultRegister)
318
+ written = true;
319
+ }
320
+ if (!written) {
321
+ throw new DrftError(`DTEX names register ${resultRegister} as its result, and no node writes it`);
322
+ }
323
+ return { material: materialIndex, texture: material };
324
+ }
package/dist/ents.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `ENTS`: the things in a scene, as the entity model wrote them.
3
+ *
4
+ * ```
5
+ * u32 byteLength of the text that follows
6
+ * u8 scene[byteLength] UTF-8 JSON, padded to the container's alignment
7
+ * ```
8
+ *
9
+ * **Text, in a binary container, on purpose.** Every other chunk here holds numbers whose meaning
10
+ * this package knows — positions, indices, weights. A scene holds a *consumer's* component fields,
11
+ * whose types are declared in that consumer's own code and in its `.drs` files. A binary encoding
12
+ * for them would be an encoding of somebody else's type system, extended every time they add a
13
+ * field, and wrong in the direction that reads as working: a field the container did not know how
14
+ * to write would silently not be there. What `serializeWorld` produces is already a value; this
15
+ * carries it unchanged.
16
+ *
17
+ * **The scene's own version is inside it and is not repeated.** `MSHL` states the reason in one
18
+ * line — a second version is a second thing to keep in step, and the one that drifts is the one
19
+ * nobody reads.
20
+ *
21
+ * **What is checked is that this is a scene, not that it is a scene anybody can load.** Whether a
22
+ * component named here exists, and whether its schema still matches, is `deserializeWorld`'s
23
+ * question and it answers in words. Refusing text that is not a scene at all belongs here, so a
24
+ * consumer meets a sentence rather than `undefined.entities` three calls later.
25
+ *
26
+ * Additive on the same terms as `DTEX`: a reader that does not know this code skips it by its
27
+ * length and loses only the scene, which it had no entity model to load into anyway.
28
+ */
29
+ /** A world as data. `@driftengine/entities`' `SerializedScene` is one of these. */
30
+ export interface EntsScene {
31
+ readonly version: number;
32
+ /** What each component type looked like when this was written. */
33
+ readonly schemas: Readonly<Record<string, unknown>>;
34
+ readonly entities: readonly {
35
+ /** Component name → field id → value. */
36
+ readonly components: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
37
+ }[];
38
+ }
39
+ export declare function buildEnts(scene: EntsScene): Uint8Array;
40
+ /**
41
+ * The scene an `ENTS` chunk carries.
42
+ *
43
+ * **A copy rather than a view, and it is the only chunk here that has to be.** Text is not a typed
44
+ * array over the buffer; parsing it is what makes it a scene. A consumer that loads one is about
45
+ * to create every entity in it anyway, so the copy is not the cost that matters.
46
+ */
47
+ export declare function readEnts(buffer: ArrayBuffer, offset: number, byteLength: number): EntsScene;
package/dist/ents.js ADDED
@@ -0,0 +1,60 @@
1
+ import { DrftError, align } from './drftFormat.js';
2
+ const HEADER = 4;
3
+ const encoder = new TextEncoder();
4
+ const decoder = new TextDecoder();
5
+ export function buildEnts(scene) {
6
+ refuseNonScene(scene);
7
+ const text = encoder.encode(JSON.stringify(scene));
8
+ const bytes = new Uint8Array(align(HEADER + text.length));
9
+ new DataView(bytes.buffer).setUint32(0, text.length, true);
10
+ bytes.set(text, HEADER);
11
+ return bytes;
12
+ }
13
+ /**
14
+ * The scene an `ENTS` chunk carries.
15
+ *
16
+ * **A copy rather than a view, and it is the only chunk here that has to be.** Text is not a typed
17
+ * array over the buffer; parsing it is what makes it a scene. A consumer that loads one is about
18
+ * to create every entity in it anyway, so the copy is not the cost that matters.
19
+ */
20
+ export function readEnts(buffer, offset, byteLength) {
21
+ if (byteLength < HEADER)
22
+ throw new DrftError('ENTS is too short to hold its header');
23
+ const view = new DataView(buffer, offset, byteLength);
24
+ const length = view.getUint32(0, true);
25
+ if (HEADER + length > byteLength) {
26
+ throw new DrftError(`ENTS declares ${String(length)} bytes of scene in a ${String(byteLength)}-byte chunk`);
27
+ }
28
+ const text = decoder.decode(new Uint8Array(buffer, offset + HEADER, length));
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(text);
32
+ }
33
+ catch (error) {
34
+ throw new DrftError(`ENTS does not hold a scene: ${error.message}`);
35
+ }
36
+ refuseNonScene(parsed);
37
+ return parsed;
38
+ }
39
+ /** The shape a scene has, checked on both sides so neither end can write one the other refuses. */
40
+ function refuseNonScene(scene) {
41
+ if (typeof scene !== 'object' || scene === null) {
42
+ throw new DrftError('ENTS holds no scene at all');
43
+ }
44
+ const held = scene;
45
+ if (!Number.isInteger(held.version) || held.version < 1) {
46
+ throw new DrftError(`ENTS holds a scene at version ${String(held.version)}`);
47
+ }
48
+ if (typeof held.schemas !== 'object' || held.schemas === null) {
49
+ throw new DrftError('ENTS holds a scene with no schemas, so nothing in it could be matched up');
50
+ }
51
+ if (!Array.isArray(held.entities)) {
52
+ throw new DrftError('ENTS holds a scene whose entities are not a list');
53
+ }
54
+ for (let at = 0; at < held.entities.length; at += 1) {
55
+ const entity = held.entities[at];
56
+ if (typeof entity !== 'object' || entity === null || typeof entity.components !== 'object') {
57
+ throw new DrftError(`ENTS holds an entity at ${at} with no components`);
58
+ }
59
+ }
60
+ }