@driftengine/drft 3.63.0 → 4.0.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/src/navm.ts ADDED
@@ -0,0 +1,171 @@
1
+ import { DrftError, align } from './drftFormat.ts';
2
+
3
+ /**
4
+ * `NAVM`: a polygon mesh a character can walk, with the placement it was built at.
5
+ *
6
+ * ```
7
+ * u32 vertexCount
8
+ * u32 polyCount
9
+ * u32 maxVertsPerPoly slots per polygon; unused ones hold -1
10
+ * f32 originX
11
+ * f32 originZ
12
+ * f32 cellSize
13
+ * i32 vertices[vertexCount * 2] x, z in cell units
14
+ * i32 polys[polyCount * maxVertsPerPoly]
15
+ * i32 neighbours[polyCount * maxVertsPerPoly] the polygon across edge i, or -1
16
+ * i32 polyRegion[polyCount]
17
+ * ```
18
+ *
19
+ * **The placement is in the chunk, and that is the whole reason this is not just two tables.** The
20
+ * vertices are cell indices; the origin and the cell size are what turn them into metres. A mesh
21
+ * that travels without them arrives in the wrong units at the wrong place, and every test built at
22
+ * the origin with one-unit cells passes anyway, because there the two are the same number.
23
+ *
24
+ * **Containment is checked here; walkability is not.** Whether these polygons describe a surface
25
+ * anybody can cross is `@driftengine/nav`'s question, in the package that knows what a region is.
26
+ * Whether an index points inside the tables is this one's, because an index past the end is a
27
+ * consumer reading whatever followed.
28
+ *
29
+ * The types are plain arrays that `@driftengine/nav`'s `PolyMesh` satisfies structurally — the same
30
+ * arrangement `DTEX` has with `@driftengine/texture`, and for the same reason: this package has no
31
+ * dependencies and cannot name a type it does not import.
32
+ *
33
+ * Additive on the same terms as `DTEX`: a reader that does not know this code skips it by its
34
+ * length and loses only the navigation, which it had nothing to walk with anyway.
35
+ */
36
+
37
+ /** A polygon mesh and where it stands. `@driftengine/nav`'s `PolyMesh` is one of these. */
38
+ export interface NavPolyMesh {
39
+ /** x, z per vertex, in cell units. */
40
+ readonly vertices: Int32Array;
41
+ readonly vertexCount: number;
42
+ /** `maxVertsPerPoly` slots per polygon, padded with −1. */
43
+ readonly polys: Int32Array;
44
+ /** The polygon across each edge, or −1. Same layout as `polys`. */
45
+ readonly neighbours: Int32Array;
46
+ readonly polyCount: number;
47
+ readonly maxVertsPerPoly: number;
48
+ /** Which region each polygon came from. */
49
+ readonly polyRegion: Int32Array;
50
+ readonly originX: number;
51
+ readonly originZ: number;
52
+ readonly cellSize: number;
53
+ }
54
+
55
+ /** Six: the three counts and the three numbers that place the mesh in the world. */
56
+ const HEADER_WORDS = 6;
57
+ const HEADER = HEADER_WORDS * 4;
58
+
59
+ /** Where each table starts, and the size of the whole. */
60
+ function layoutOf(
61
+ vertexCount: number,
62
+ polyCount: number,
63
+ slots: number,
64
+ ): [number, number, number, number, number] {
65
+ let at = HEADER;
66
+ const vertices = at;
67
+ at += vertexCount * 2 * 4;
68
+ const polys = at;
69
+ at += polyCount * slots * 4;
70
+ const neighbours = at;
71
+ at += polyCount * slots * 4;
72
+ const region = at;
73
+ at += polyCount * 4;
74
+ return [vertices, polys, neighbours, region, at];
75
+ }
76
+
77
+ export function buildNavm(mesh: NavPolyMesh): Uint8Array {
78
+ const { vertexCount, polyCount, maxVertsPerPoly: slots } = mesh;
79
+ if (!Number.isInteger(vertexCount) || vertexCount < 0) {
80
+ throw new DrftError(`NAVM declares ${vertexCount} vertices, which is not a count`);
81
+ }
82
+ if (!Number.isInteger(polyCount) || polyCount < 0) {
83
+ throw new DrftError(`NAVM declares ${polyCount} polygons, which is not a count`);
84
+ }
85
+ if (!Number.isInteger(slots) || slots < 3) {
86
+ throw new DrftError(`NAVM gives each polygon ${slots} slots, and a polygon has three corners`);
87
+ }
88
+ if (mesh.vertices.length !== vertexCount * 2) {
89
+ throw new DrftError(
90
+ `NAVM has ${mesh.vertices.length} vertex words for ${vertexCount} vertices of two`,
91
+ );
92
+ }
93
+ if (mesh.polys.length !== polyCount * slots || mesh.neighbours.length !== polyCount * slots) {
94
+ throw new DrftError(`NAVM's polygon and neighbour tables are not ${polyCount} × ${slots}`);
95
+ }
96
+ if (mesh.polyRegion.length !== polyCount) {
97
+ throw new DrftError(`NAVM has ${mesh.polyRegion.length} regions for ${polyCount} polygons`);
98
+ }
99
+ refuseStrayIndices(mesh.polys, vertexCount, 'polygon', 'vertex');
100
+ refuseStrayIndices(mesh.neighbours, polyCount, 'neighbour', 'polygon');
101
+
102
+ const [vertices, polys, neighbours, region, size] = layoutOf(vertexCount, polyCount, slots);
103
+ const bytes = new Uint8Array(align(size));
104
+ const view = new DataView(bytes.buffer);
105
+ view.setUint32(0, vertexCount, true);
106
+ view.setUint32(4, polyCount, true);
107
+ view.setUint32(8, slots, true);
108
+ view.setFloat32(12, mesh.originX, true);
109
+ view.setFloat32(16, mesh.originZ, true);
110
+ view.setFloat32(20, mesh.cellSize, true);
111
+ new Int32Array(bytes.buffer, vertices, vertexCount * 2).set(mesh.vertices);
112
+ new Int32Array(bytes.buffer, polys, polyCount * slots).set(mesh.polys);
113
+ new Int32Array(bytes.buffer, neighbours, polyCount * slots).set(mesh.neighbours);
114
+ new Int32Array(bytes.buffer, region, polyCount).set(mesh.polyRegion);
115
+ return bytes;
116
+ }
117
+
118
+ /**
119
+ * The mesh a `NAVM` chunk carries, as views over the fetched buffer.
120
+ *
121
+ * Views rather than copies, like every other chunk here, so a load allocates nothing beyond the
122
+ * object holding them.
123
+ */
124
+ export function readNavm(buffer: ArrayBuffer, offset: number, byteLength: number): NavPolyMesh {
125
+ if (byteLength < HEADER) throw new DrftError('NAVM is too short to hold its header');
126
+ const view = new DataView(buffer, offset, byteLength);
127
+ const vertexCount = view.getUint32(0, true);
128
+ const polyCount = view.getUint32(4, true);
129
+ const slots = view.getUint32(8, true);
130
+ if (slots < 3) {
131
+ throw new DrftError(`NAVM gives each polygon ${slots} slots, and a polygon has three corners`);
132
+ }
133
+
134
+ const [vertices, polys, neighbours, region, size] = layoutOf(vertexCount, polyCount, slots);
135
+ if (size > byteLength) {
136
+ throw new DrftError(
137
+ `NAVM declares ${String(size)} bytes of tables in a ${String(byteLength)}-byte chunk`,
138
+ );
139
+ }
140
+
141
+ const mesh: NavPolyMesh = {
142
+ vertices: new Int32Array(buffer, offset + vertices, vertexCount * 2),
143
+ vertexCount,
144
+ polys: new Int32Array(buffer, offset + polys, polyCount * slots),
145
+ neighbours: new Int32Array(buffer, offset + neighbours, polyCount * slots),
146
+ polyCount,
147
+ maxVertsPerPoly: slots,
148
+ polyRegion: new Int32Array(buffer, offset + region, polyCount),
149
+ originX: view.getFloat32(12, true),
150
+ originZ: view.getFloat32(16, true),
151
+ cellSize: view.getFloat32(20, true),
152
+ };
153
+ /*
154
+ * **Checked again on the way in**, and not because the writer is untrusted: a file arrives from
155
+ * a network, a disk and a bake this reader did not run. An index past the end of a table is the
156
+ * one defect that costs a consumer rather than the file — it walks off the mesh.
157
+ */
158
+ refuseStrayIndices(mesh.polys, vertexCount, 'polygon', 'vertex');
159
+ refuseStrayIndices(mesh.neighbours, polyCount, 'neighbour', 'polygon');
160
+ return mesh;
161
+ }
162
+
163
+ function refuseStrayIndices(table: Int32Array, count: number, what: string, of: string): void {
164
+ for (let at = 0; at < table.length; at += 1) {
165
+ const index = table[at] as number;
166
+ if (index === -1) continue;
167
+ if (index < 0 || index >= count) {
168
+ throw new DrftError(`NAVM's ${what} table names ${of} ${index}, and there are ${count}`);
169
+ }
170
+ }
171
+ }
package/src/ngrf.ts ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * `NGRF` — a network as a graph of operators, and its tensors, in either precision.
3
+ *
4
+ * **`NNET` describes a perceptron and cannot describe anything else**: its entry table has room for
5
+ * inputs, eight hidden widths and outputs, and a transformer is attention, normalisation,
6
+ * convolution and resampling between named values. So a graph is its own chunk, beside `NNET`
7
+ * rather than in place of it — `NNET` stays the compact form for the small networks the texture
8
+ * decode and reconstruction carry.
9
+ *
10
+ * **The chunk carries no semantics.** This package has no dependencies and knows nothing of what
11
+ * `linear` or `attention` compute; the structure is plain data that `@driftengine/texture`'s graph
12
+ * satisfies, and texture validates a graph it is handed, naming any operator it lacks — the
13
+ * arrangement `DTEX` already has.
14
+ *
15
+ * **The structure is UTF-8 JSON and the tensors are bytes.** A graph's structure is small and read
16
+ * once; JSON keeps it legible and lets an attribute be added without a layout change. It is checked
17
+ * field by field on the way in, and every tensor's offset against the payload before one is viewed.
18
+ * The tensors are stored as a device uploads them — `Float32Array` values or half-precision bits —
19
+ * each block aligned to four bytes so it can be viewed rather than copied.
20
+ *
21
+ * ### Layout
22
+ *
23
+ * ```text
24
+ * u32 count
25
+ * count x:
26
+ * u32 role a FourCC naming what the graph is for; unique in the chunk
27
+ * u32 structureBytes
28
+ * u32 payloadBytes
29
+ * structure UTF-8 JSON: inputs, outputs, nodes, and a tensor directory of name,
30
+ * shape, precision (16 or 32) and byte offset into the payload;
31
+ * padded to four bytes
32
+ * payload the tensors, each aligned to four bytes
33
+ * ```
34
+ *
35
+ * **No per-chunk version and additive**, as every chunk here: a reader that does not know the code
36
+ * skips it by its length and loses only the graphs. Added at 1.15.
37
+ */
38
+ import { DrftError, align, fourCC, fourCCName } from './drftFormat.ts';
39
+
40
+ export type DrftAttribute = number | string | boolean | readonly number[];
41
+
42
+ export interface DrftGraphValue {
43
+ readonly name: string;
44
+ readonly shape: readonly number[];
45
+ }
46
+
47
+ export interface DrftGraphNode {
48
+ readonly op: string;
49
+ readonly inputs: readonly string[];
50
+ readonly output: string;
51
+ readonly attributes: Readonly<Record<string, DrftAttribute>>;
52
+ }
53
+
54
+ export interface DrftGraphTensor {
55
+ readonly name: string;
56
+ readonly shape: readonly number[];
57
+ /** Single-precision values, or half-precision bits as a device uploads them. */
58
+ readonly data: Float32Array | Uint16Array;
59
+ }
60
+
61
+ export interface DrftGraph {
62
+ /** Four printable characters naming what the graph is for. */
63
+ readonly role: string;
64
+ readonly inputs: readonly DrftGraphValue[];
65
+ readonly outputs: readonly string[];
66
+ readonly nodes: readonly DrftGraphNode[];
67
+ readonly tensors: readonly DrftGraphTensor[];
68
+ }
69
+
70
+ interface DirectoryEntry {
71
+ readonly name: string;
72
+ readonly shape: readonly number[];
73
+ readonly precision: 16 | 32;
74
+ readonly offset: number;
75
+ }
76
+
77
+ /** A dimension past this is a corrupt file rather than a model: a product of four of them is 2^64. */
78
+ const MAX_DIMENSION = 1 << 24;
79
+
80
+ const encoder = new TextEncoder();
81
+ const decoder = new TextDecoder();
82
+
83
+ const count = (shape: readonly number[]): number => shape.reduce((total, d) => total * d, 1);
84
+
85
+ function printableRole(role: string): boolean {
86
+ return (
87
+ role.length === 4 && [...role].every((c) => c.charCodeAt(0) >= 0x20 && c.charCodeAt(0) <= 0x7e)
88
+ );
89
+ }
90
+
91
+ /** Write the chunk. Refuses anything a reader would have to guess about. */
92
+ export function buildNgrf(graphs: readonly DrftGraph[]): Uint8Array {
93
+ const roles = new Set<string>();
94
+ const parts: { role: number; structure: Uint8Array; payload: Uint8Array }[] = [];
95
+ for (const graph of graphs) {
96
+ if (!printableRole(graph.role)) {
97
+ throw new DrftError(
98
+ `NGRF: the role ${JSON.stringify(graph.role)} is not four printable characters`,
99
+ );
100
+ }
101
+ if (roles.has(graph.role)) {
102
+ throw new DrftError(
103
+ `NGRF: two graphs have the role ${graph.role}; a reader finds a graph by it`,
104
+ );
105
+ }
106
+ roles.add(graph.role);
107
+ const directory: DirectoryEntry[] = [];
108
+ let payloadBytes = 0;
109
+ for (const tensor of graph.tensors) {
110
+ if (tensor.data.length !== count(tensor.shape)) {
111
+ throw new DrftError(
112
+ `NGRF ${graph.role}: tensor "${tensor.name}" holds ${tensor.data.length} values and its ` +
113
+ `shape [${tensor.shape.join(', ')}] needs ${count(tensor.shape)}`,
114
+ );
115
+ }
116
+ const precision = tensor.data instanceof Uint16Array ? 16 : 32;
117
+ directory.push({ name: tensor.name, shape: tensor.shape, precision, offset: payloadBytes });
118
+ payloadBytes += align(tensor.data.byteLength);
119
+ }
120
+ const structure = encoder.encode(
121
+ JSON.stringify({
122
+ inputs: graph.inputs,
123
+ outputs: graph.outputs,
124
+ nodes: graph.nodes,
125
+ tensors: directory,
126
+ }),
127
+ );
128
+ const payload = new Uint8Array(payloadBytes);
129
+ for (const [i, tensor] of graph.tensors.entries()) {
130
+ payload.set(
131
+ new Uint8Array(tensor.data.buffer, tensor.data.byteOffset, tensor.data.byteLength),
132
+ (directory[i] as DirectoryEntry).offset,
133
+ );
134
+ }
135
+ parts.push({ role: fourCC(graph.role), structure, payload });
136
+ }
137
+ let total = 4;
138
+ for (const part of parts)
139
+ total += 12 + align(part.structure.byteLength) + part.payload.byteLength;
140
+ const bytes = new Uint8Array(total);
141
+ const view = new DataView(bytes.buffer);
142
+ view.setUint32(0, parts.length, true);
143
+ let at = 4;
144
+ for (const part of parts) {
145
+ view.setUint32(at, part.role, true);
146
+ view.setUint32(at + 4, part.structure.byteLength, true);
147
+ view.setUint32(at + 8, part.payload.byteLength, true);
148
+ bytes.set(part.structure, at + 12);
149
+ at += 12 + align(part.structure.byteLength);
150
+ bytes.set(part.payload, at);
151
+ at += part.payload.byteLength;
152
+ }
153
+ return bytes;
154
+ }
155
+
156
+ function isShape(value: unknown): value is number[] {
157
+ return (
158
+ Array.isArray(value) &&
159
+ value.every((d) => Number.isInteger(d) && (d as number) > 0 && (d as number) <= MAX_DIMENSION)
160
+ );
161
+ }
162
+
163
+ function isAttribute(value: unknown): value is DrftAttribute {
164
+ return (
165
+ typeof value === 'number' ||
166
+ typeof value === 'string' ||
167
+ typeof value === 'boolean' ||
168
+ (Array.isArray(value) && value.every((v) => typeof v === 'number'))
169
+ );
170
+ }
171
+
172
+ /* The structure, checked field by field: JSON from a file is whatever the file says. */
173
+ function readStructure(
174
+ label: string,
175
+ text: string,
176
+ ): {
177
+ inputs: DrftGraphValue[];
178
+ outputs: string[];
179
+ nodes: DrftGraphNode[];
180
+ tensors: DirectoryEntry[];
181
+ } {
182
+ let parsed: unknown;
183
+ try {
184
+ parsed = JSON.parse(text);
185
+ } catch {
186
+ throw new DrftError(`${label}: the structure is not JSON`);
187
+ }
188
+ const s = parsed as Record<string, unknown>;
189
+ const fail = (what: string): never => {
190
+ throw new DrftError(`${label}: ${what}`);
191
+ };
192
+ if (typeof s !== 'object' || s === null) fail('the structure is not an object');
193
+ const inputs = s.inputs;
194
+ const outputs = s.outputs;
195
+ const nodes = s.nodes;
196
+ const tensors = s.tensors;
197
+ if (
198
+ !Array.isArray(inputs) ||
199
+ !inputs.every((i) => typeof i?.name === 'string' && isShape(i?.shape))
200
+ ) {
201
+ fail('inputs must each have a name and a shape');
202
+ }
203
+ if (!Array.isArray(outputs) || !outputs.every((o) => typeof o === 'string'))
204
+ fail('outputs must be names');
205
+ if (
206
+ !Array.isArray(nodes) ||
207
+ !nodes.every(
208
+ (n) =>
209
+ typeof n?.op === 'string' &&
210
+ typeof n?.output === 'string' &&
211
+ Array.isArray(n?.inputs) &&
212
+ (n.inputs as unknown[]).every((i) => typeof i === 'string') &&
213
+ typeof n?.attributes === 'object' &&
214
+ n.attributes !== null &&
215
+ Object.values(n.attributes as object).every(isAttribute),
216
+ )
217
+ ) {
218
+ fail('nodes must each have an operator, input names, an output name and attributes');
219
+ }
220
+ if (
221
+ !Array.isArray(tensors) ||
222
+ !tensors.every(
223
+ (t) =>
224
+ typeof t?.name === 'string' &&
225
+ isShape(t?.shape) &&
226
+ (t?.precision === 16 || t?.precision === 32) &&
227
+ Number.isInteger(t?.offset) &&
228
+ (t.offset as number) >= 0 &&
229
+ (t.offset as number) % 4 === 0,
230
+ )
231
+ ) {
232
+ fail('tensors must each have a name, a shape, a precision of 16 or 32 and an aligned offset');
233
+ }
234
+ return {
235
+ inputs: inputs as DrftGraphValue[],
236
+ outputs: outputs as string[],
237
+ nodes: nodes as DrftGraphNode[],
238
+ tensors: tensors as DirectoryEntry[],
239
+ };
240
+ }
241
+
242
+ /** Read the chunk, refusing every structure and offset a reader would otherwise have to trust. */
243
+ export function readNgrf(buffer: ArrayBuffer, offset: number, byteLength: number): DrftGraph[] {
244
+ if (byteLength < 4) throw new DrftError('NGRF is too short to hold its count');
245
+ const view = new DataView(buffer, offset, byteLength);
246
+ const graphCount = view.getUint32(0, true);
247
+ const graphs: DrftGraph[] = [];
248
+ const roles = new Set<string>();
249
+ let at = 4;
250
+ for (let g = 0; g < graphCount; g += 1) {
251
+ if (at + 12 > byteLength) throw new DrftError(`NGRF graph ${g} runs past the chunk`);
252
+ const role = fourCCName(view.getUint32(at, true));
253
+ const label = `NGRF ${role}`;
254
+ if (roles.has(role))
255
+ throw new DrftError(`${label} repeats a role; a reader finds a graph by it`);
256
+ roles.add(role);
257
+ const structureBytes = view.getUint32(at + 4, true);
258
+ const payloadBytes = view.getUint32(at + 8, true);
259
+ const structureAt = at + 12;
260
+ const payloadAt = structureAt + align(structureBytes);
261
+ if (payloadAt + payloadBytes > byteLength) {
262
+ throw new DrftError(`${label}: its structure and payload run past the chunk`);
263
+ }
264
+ const structure = readStructure(
265
+ label,
266
+ decoder.decode(new Uint8Array(buffer, offset + structureAt, structureBytes)),
267
+ );
268
+ const tensors: DrftGraphTensor[] = [];
269
+ for (const entry of structure.tensors) {
270
+ const bytes = count(entry.shape) * (entry.precision / 8);
271
+ if (entry.offset + bytes > payloadBytes) {
272
+ throw new DrftError(
273
+ `${label}: tensor "${entry.name}" needs ${bytes} bytes at ${entry.offset} and the payload has ${payloadBytes}`,
274
+ );
275
+ }
276
+ const absolute = offset + payloadAt + entry.offset;
277
+ const length = count(entry.shape);
278
+ /* Viewed where the file's alignment allows, and copied where it does not. */
279
+ const data =
280
+ entry.precision === 32
281
+ ? absolute % 4 === 0
282
+ ? new Float32Array(buffer, absolute, length)
283
+ : new Float32Array(buffer.slice(absolute, absolute + bytes))
284
+ : absolute % 2 === 0
285
+ ? new Uint16Array(buffer, absolute, length)
286
+ : new Uint16Array(buffer.slice(absolute, absolute + bytes));
287
+ tensors.push({ name: entry.name, shape: entry.shape, data });
288
+ }
289
+ graphs.push({
290
+ role,
291
+ inputs: structure.inputs,
292
+ outputs: structure.outputs,
293
+ nodes: structure.nodes,
294
+ tensors,
295
+ });
296
+ at = payloadAt + payloadBytes;
297
+ }
298
+ return graphs;
299
+ }