@adobe/ecs-persistence 0.0.0-stage → 0.0.1
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/LICENSE +21 -0
- package/README.md +71 -2
- package/dist/index.js +516 -0
- package/dist/index.js.map +1 -0
- package/dist/types/decode.d.ts +8 -0
- package/dist/types/encode.d.ts +6 -0
- package/dist/types/failure.d.ts +4 -0
- package/dist/types/format/format.d.ts +10 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/persist.d.ts +14 -0
- package/dist/types/program/contents.d.ts +17 -0
- package/dist/types/program/program.d.ts +14 -0
- package/dist/types/selection/selection.d.ts +17 -0
- package/dist/types/snapshot.d.ts +18 -0
- package/dist/types/spawn.d.ts +33 -0
- package/dist/types/values/values.d.ts +5 -0
- package/package.json +39 -3
- package/src/decode.ts +15 -0
- package/src/encode.ts +10 -0
- package/src/failure.ts +4 -0
- package/src/format/format.ts +185 -0
- package/src/index.ts +12 -0
- package/src/persist.ts +15 -0
- package/src/program/contents.ts +17 -0
- package/src/program/program.ts +34 -0
- package/src/selection/selection.ts +96 -0
- package/src/snapshot.ts +22 -0
- package/src/spawn.ts +60 -0
- package/src/values/values.ts +97 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Adobe, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,72 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @adobe/ecs-persistence
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Saving, loading and copying the entities of `@adobe/ecs` worlds. A snapshot holds whole archetypes' columns: a struct's
|
|
4
|
+
or a number's bytes as the world stores them, which saving and loading copy as blocks, and the values of strings and
|
|
5
|
+
other data. Its entity values are rewritten as they're loaded, so references between the entities it holds still name
|
|
6
|
+
each other.
|
|
7
|
+
|
|
8
|
+
## Marking what persists
|
|
9
|
+
|
|
10
|
+
A component or resource persists when its declaration names it with the `persist` trait, by a stable name: saved data
|
|
11
|
+
names it so, whatever its module's path or version.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import type { Entity, Vec3 } from "@adobe/ecs";
|
|
15
|
+
import { persist } from "@adobe/ecs-persistence";
|
|
16
|
+
|
|
17
|
+
export const position = Symbol();
|
|
18
|
+
export const parent = Symbol();
|
|
19
|
+
export const score = Symbol();
|
|
20
|
+
declare module "@adobe/ecs" {
|
|
21
|
+
interface Components {
|
|
22
|
+
[position]: { type: Vec3; [persist]: "position" };
|
|
23
|
+
[parent]: { type: Entity; reference: "owner"; [persist]: "parent" };
|
|
24
|
+
}
|
|
25
|
+
interface Resources {
|
|
26
|
+
[score]: { type: number; default: 0; [persist]: "score" };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Only data persists: a value with a schema. A derived component doesn't: its rule derives it again once its entity is
|
|
32
|
+
loaded.
|
|
33
|
+
|
|
34
|
+
## Saving and loading
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { decode, encode, snapshot, spawn } from "@adobe/ecs-persistence";
|
|
38
|
+
|
|
39
|
+
const taken = await snapshot({ world });
|
|
40
|
+
if (taken.failure !== undefined) throw new Error(taken.failure);
|
|
41
|
+
const bytes = encode(taken);
|
|
42
|
+
|
|
43
|
+
const loaded = decode(bytes);
|
|
44
|
+
if (loaded.failure !== undefined) throw new Error(loaded.failure);
|
|
45
|
+
const spawned = await spawn({ world: other, snapshot: loaded });
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `snapshot({ world, ... })` takes every entity holding a persisted component, those matching `with` and `not`, or
|
|
49
|
+
`root` and everything it owns through persisted owner references. Except with `root` or `resources: false`, it
|
|
50
|
+
takes the persisted resources too.
|
|
51
|
+
- `encode` writes it as bytes, and `decode` reads them back, viewing each column's bytes where they are in the buffer.
|
|
52
|
+
It refuses bytes naming a component or resource this program doesn't persist, or persists with another schema.
|
|
53
|
+
- `spawn({ world, snapshot, entities? })` spawns an entity for each of the snapshot's and sets its resources, its entity values
|
|
54
|
+
naming the entities spawned for theirs. An entity value naming an entity outside the snapshot follows what
|
|
55
|
+
despawning that entity would do: an entity it owns isn't spawned, nor what that owns, and the result counts them in
|
|
56
|
+
`dropped`; a weak reference to it is left out; any other value naming it is `Entity.none`. The result's `entities`
|
|
57
|
+
maps each of the snapshot's entities to the one spawned for it, or null; `entities: false` leaves the map out, which
|
|
58
|
+
saves about 85 ms and 50 MB at a million entities.
|
|
59
|
+
- Copying entities from one world into another is a snapshot spawned there: `spawn({ world: to, snapshot: await snapshot({ world: from }) })`, without
|
|
60
|
+
bytes.
|
|
61
|
+
|
|
62
|
+
Each reports why it didn't happen as `{ failure }`, rather than throwing, and `result.failure !== undefined` tells
|
|
63
|
+
which. A snapshot is opaque: only these functions
|
|
64
|
+
read it.
|
|
65
|
+
|
|
66
|
+
## Speed
|
|
67
|
+
|
|
68
|
+
`pnpm test:perf` compares, at 100K and 1M entities with a `Vec3`, a number, an owner reference and a struct holding an
|
|
69
|
+
entity, this package with reading rows and spawning entity by entity. At 1M entities, a snapshot takes about 6 ms
|
|
70
|
+
against 110 ms, encoding 5 ms against 150 ms for `JSON.stringify` of the rows, and decoding views the bytes where they
|
|
71
|
+
are. Spawning takes about 310 ms against 1,450 ms, most of it the world's bookkeeping of owner references at its sync
|
|
72
|
+
point; without references, 1M entities spawn in about 26 ms.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { Component, Resource, System } from "@adobe/ecs";
|
|
2
|
+
//#region src/persist.ts
|
|
3
|
+
/**
|
|
4
|
+
* The trait marking a component or resource to persist, its value the stable name saved data names it by: unlike its
|
|
5
|
+
* key's id, the name stays the same across versions and moves of its module. Not on a derived component, which its
|
|
6
|
+
* rule derives again once its entity is loaded.
|
|
7
|
+
*/
|
|
8
|
+
var persist = Symbol();
|
|
9
|
+
declare$(persist, "trait");
|
|
10
|
+
function declare$(key, kind, declaration) {
|
|
11
|
+
(globalThis[Symbol.for("@adobe/ecs/declarations@1")] ??= /* @__PURE__ */ new Map()).set(key, {
|
|
12
|
+
kind,
|
|
13
|
+
declaration
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/program/program.ts
|
|
18
|
+
/** The keys of `declarations` with a `persist` trait by name, or why one can't persist: it isn't data, or a name is taken. */
|
|
19
|
+
var namedIn = (declarations, kind) => {
|
|
20
|
+
const named = /* @__PURE__ */ new Map();
|
|
21
|
+
for (const [key, declaration] of declarations) {
|
|
22
|
+
const name = declaration[persist];
|
|
23
|
+
if (typeof name !== "string") continue;
|
|
24
|
+
if (declaration.type === void 0) return `The ${kind} persisted as "${name}" isn't data: it has no schema.`;
|
|
25
|
+
if (named.has(name)) return `Two ${kind}s are persisted as "${name}".`;
|
|
26
|
+
named.set(name, {
|
|
27
|
+
name,
|
|
28
|
+
key,
|
|
29
|
+
schema: declaration.type
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return named;
|
|
33
|
+
};
|
|
34
|
+
/** What the program persists, as its modules registered so far declare it, or why it can't. */
|
|
35
|
+
var programOf = () => {
|
|
36
|
+
const components = namedIn(Component.declarations(), "component");
|
|
37
|
+
if (typeof components === "string") return components;
|
|
38
|
+
const resources = namedIn(Resource.declarations(), "resource");
|
|
39
|
+
if (typeof resources === "string") return resources;
|
|
40
|
+
return {
|
|
41
|
+
components,
|
|
42
|
+
resources
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/values/values.ts
|
|
47
|
+
/** A number JSON can't hold, as text: `NaN`, `Infinity`, `-Infinity` or `-0`. */
|
|
48
|
+
var numberToJson = (value) => Number.isNaN(value) ? "NaN" : value === Infinity ? "Infinity" : value === -Infinity ? "-Infinity" : Object.is(value, -0) ? "-0" : value;
|
|
49
|
+
var numberFromJson = (json) => {
|
|
50
|
+
if (typeof json !== "string") return json;
|
|
51
|
+
switch (json) {
|
|
52
|
+
case "NaN": return NaN;
|
|
53
|
+
case "Infinity": return Infinity;
|
|
54
|
+
case "-Infinity": return -Infinity;
|
|
55
|
+
case "-0": return -0;
|
|
56
|
+
default: return json;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
/** Whether a value is of the JSON kind a schema's values are, to pick a union's member. */
|
|
60
|
+
var fits = (schema, value) => {
|
|
61
|
+
if ("const" in schema) return Object.is(schema.const, value) || JSON.stringify(schema.const) === JSON.stringify(value);
|
|
62
|
+
if ("anyOf" in schema) return schema.anyOf.some((member) => fits(member, value));
|
|
63
|
+
if ("allOf" in schema) return schema.allOf.every((member) => fits(member, value));
|
|
64
|
+
if (!("type" in schema)) return true;
|
|
65
|
+
switch (schema.type) {
|
|
66
|
+
case "number":
|
|
67
|
+
case "integer": return typeof value === "number";
|
|
68
|
+
case "string": return typeof value === "string";
|
|
69
|
+
case "boolean": return typeof value === "boolean";
|
|
70
|
+
case "null": return value === null;
|
|
71
|
+
case "array": return Array.isArray(value);
|
|
72
|
+
case "set": return value instanceof Set;
|
|
73
|
+
case "map": return value instanceof Map;
|
|
74
|
+
case "object": return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Set) && !(value instanceof Map);
|
|
75
|
+
default: return true;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
/** A value of `schema` as JSON. */
|
|
79
|
+
var toJson = (schema, value) => {
|
|
80
|
+
if ("anyOf" in schema) {
|
|
81
|
+
const index = schema.anyOf.findIndex((candidate) => fits(candidate, value));
|
|
82
|
+
const member = schema.anyOf[index];
|
|
83
|
+
return member === void 0 ? [-1, value] : [index, toJson(member, value)];
|
|
84
|
+
}
|
|
85
|
+
if (typeof value === "number") return numberToJson(value);
|
|
86
|
+
if (!("type" in schema)) return value;
|
|
87
|
+
switch (schema.type) {
|
|
88
|
+
case "array": return Array.isArray(value) ? value.map((item) => toJson(schema.items, item)) : value;
|
|
89
|
+
case "set": return value instanceof Set ? Array.from(value, (item) => toJson(schema.items, item)) : value;
|
|
90
|
+
case "map": return value instanceof Map ? Array.from(value, ([key, item]) => [toJson(schema.keys, key), toJson(schema.values, item)]) : value;
|
|
91
|
+
case "object": {
|
|
92
|
+
if (typeof value !== "object" || value === null) return value;
|
|
93
|
+
const json = {};
|
|
94
|
+
for (const name of Object.keys(value)) {
|
|
95
|
+
const field = schema.properties[name];
|
|
96
|
+
const item = Reflect.get(value, name);
|
|
97
|
+
if (item !== void 0) json[name] = field === void 0 ? item : toJson(field, item);
|
|
98
|
+
}
|
|
99
|
+
return json;
|
|
100
|
+
}
|
|
101
|
+
default: return value;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/** A value of `schema` read back from its JSON. */
|
|
105
|
+
var fromJson = (schema, json) => {
|
|
106
|
+
if ("anyOf" in schema) {
|
|
107
|
+
if (!Array.isArray(json) || typeof json[0] !== "number") return json;
|
|
108
|
+
const member = schema.anyOf[json[0]];
|
|
109
|
+
return member === void 0 ? json[1] : fromJson(member, json[1]);
|
|
110
|
+
}
|
|
111
|
+
if ("const" in schema) return schema.const;
|
|
112
|
+
if (!("type" in schema)) return json;
|
|
113
|
+
switch (schema.type) {
|
|
114
|
+
case "number":
|
|
115
|
+
case "integer": return numberFromJson(json);
|
|
116
|
+
case "array": return Array.isArray(json) ? json.map((item) => fromJson(schema.items, item)) : json;
|
|
117
|
+
case "set": return Array.isArray(json) ? new Set(json.map((item) => fromJson(schema.items, item))) : json;
|
|
118
|
+
case "map": return Array.isArray(json) ? new Map(json.map((entry) => Array.isArray(entry) ? [fromJson(schema.keys, entry[0]), fromJson(schema.values, entry[1])] : [entry, void 0])) : json;
|
|
119
|
+
case "object": {
|
|
120
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) return json;
|
|
121
|
+
const value = {};
|
|
122
|
+
for (const name of Object.keys(json)) {
|
|
123
|
+
const field = schema.properties[name];
|
|
124
|
+
const item = Reflect.get(json, name);
|
|
125
|
+
value[name] = field === void 0 ? item : fromJson(field, item);
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
default: return json;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/format/format.ts
|
|
134
|
+
/**
|
|
135
|
+
* The binary form of a snapshot, little-endian:
|
|
136
|
+
* - 16 bytes: `ECSP`, the format's version as a u32, the header's length in bytes as a u32, and 4 bytes of zeros;
|
|
137
|
+
* - the header, JSON text padded with spaces to a multiple of 8 bytes: each component and resource by name with its
|
|
138
|
+
* schema, and each table's count and where its entities and columns are;
|
|
139
|
+
* - blocks, each starting at a multiple of 8 bytes: a table's entities as f64s, a column's bytes as its components'
|
|
140
|
+
* layout holds them, and a column of values as JSON text.
|
|
141
|
+
* A loader views the blocks of bytes where they are, in the buffer it's given.
|
|
142
|
+
*/
|
|
143
|
+
var magic = [
|
|
144
|
+
69,
|
|
145
|
+
67,
|
|
146
|
+
83,
|
|
147
|
+
80
|
|
148
|
+
];
|
|
149
|
+
var version = 1;
|
|
150
|
+
var prefix = 16;
|
|
151
|
+
var aligned = (offset) => Math.ceil(offset / 8) * 8;
|
|
152
|
+
var encoder = new TextEncoder();
|
|
153
|
+
var decoder = new TextDecoder();
|
|
154
|
+
/** A snapshot's contents as bytes. */
|
|
155
|
+
var encodeContents = (snapshot) => {
|
|
156
|
+
const blocks = [];
|
|
157
|
+
let offset = 0;
|
|
158
|
+
/** Places a block, returning its offset from the first block. */
|
|
159
|
+
const place = (bytes) => {
|
|
160
|
+
const at = offset;
|
|
161
|
+
blocks.push(bytes);
|
|
162
|
+
offset = aligned(offset + bytes.byteLength);
|
|
163
|
+
return at;
|
|
164
|
+
};
|
|
165
|
+
const indexOf = new Map(snapshot.components.map((persisted, index) => [persisted.key, index]));
|
|
166
|
+
const tables = snapshot.tables.map((table) => {
|
|
167
|
+
const entities = place(new Uint8Array(table.entities.buffer, table.entities.byteOffset, table.entities.byteLength));
|
|
168
|
+
const columns = snapshot.components.flatMap((persisted) => {
|
|
169
|
+
const key = persisted.key;
|
|
170
|
+
const column = Reflect.get(table.columns, key);
|
|
171
|
+
const component = indexOf.get(persisted.key) ?? -1;
|
|
172
|
+
if (column === void 0) return [];
|
|
173
|
+
if (column.kind === "bytes") return [{
|
|
174
|
+
component,
|
|
175
|
+
kind: "bytes",
|
|
176
|
+
offset: place(column.bytes),
|
|
177
|
+
length: column.bytes.byteLength,
|
|
178
|
+
stride: column.stride
|
|
179
|
+
}];
|
|
180
|
+
if (column.kind === "value") return [{
|
|
181
|
+
component,
|
|
182
|
+
kind: "value",
|
|
183
|
+
value: toJson(persisted.schema, column.value)
|
|
184
|
+
}];
|
|
185
|
+
const text = encoder.encode(JSON.stringify(column.values.map((value) => toJson(persisted.schema, value))));
|
|
186
|
+
return [{
|
|
187
|
+
component,
|
|
188
|
+
kind: "values",
|
|
189
|
+
offset: place(text),
|
|
190
|
+
length: text.byteLength
|
|
191
|
+
}];
|
|
192
|
+
});
|
|
193
|
+
return {
|
|
194
|
+
count: table.count,
|
|
195
|
+
entities,
|
|
196
|
+
columns
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
const header = {
|
|
200
|
+
components: snapshot.components.map(({ name, schema }) => ({
|
|
201
|
+
name,
|
|
202
|
+
schema
|
|
203
|
+
})),
|
|
204
|
+
resources: snapshot.resources.map(({ persisted, value }) => ({
|
|
205
|
+
name: persisted.name,
|
|
206
|
+
schema: persisted.schema,
|
|
207
|
+
value: toJson(persisted.schema, value)
|
|
208
|
+
})),
|
|
209
|
+
tables
|
|
210
|
+
};
|
|
211
|
+
const text = encoder.encode(JSON.stringify(header));
|
|
212
|
+
const headerLength = aligned(text.byteLength);
|
|
213
|
+
const bytes = new Uint8Array(prefix + headerLength + offset);
|
|
214
|
+
bytes.set(magic, 0);
|
|
215
|
+
const view = new DataView(bytes.buffer);
|
|
216
|
+
view.setUint32(4, version, true);
|
|
217
|
+
view.setUint32(8, headerLength, true);
|
|
218
|
+
bytes.set(text, prefix);
|
|
219
|
+
bytes.fill(32, prefix + text.byteLength, prefix + headerLength);
|
|
220
|
+
let at = prefix + headerLength;
|
|
221
|
+
for (const block of blocks) {
|
|
222
|
+
bytes.set(block, at);
|
|
223
|
+
at = aligned(at + block.byteLength);
|
|
224
|
+
}
|
|
225
|
+
return bytes;
|
|
226
|
+
};
|
|
227
|
+
var isObject = (value) => typeof value === "object" && value !== null;
|
|
228
|
+
/** The header's JSON, checked enough to read it without casts, or null. */
|
|
229
|
+
var headerOf = (json) => {
|
|
230
|
+
if (!isObject(json)) return null;
|
|
231
|
+
const components = Reflect.get(json, "components");
|
|
232
|
+
const resources = Reflect.get(json, "resources");
|
|
233
|
+
const tables = Reflect.get(json, "tables");
|
|
234
|
+
if (!Array.isArray(components) || !Array.isArray(resources) || !Array.isArray(tables)) return null;
|
|
235
|
+
const named = (item) => isObject(item) && typeof Reflect.get(item, "name") === "string" && isObject(Reflect.get(item, "schema"));
|
|
236
|
+
if (!components.every(named) || !resources.every(named)) return null;
|
|
237
|
+
const isColumn = (item) => {
|
|
238
|
+
if (!isObject(item) || typeof Reflect.get(item, "component") !== "number") return false;
|
|
239
|
+
const kind = Reflect.get(item, "kind");
|
|
240
|
+
if (kind === "value") return true;
|
|
241
|
+
const numbers = kind === "bytes" ? [
|
|
242
|
+
"offset",
|
|
243
|
+
"length",
|
|
244
|
+
"stride"
|
|
245
|
+
] : kind === "values" ? ["offset", "length"] : null;
|
|
246
|
+
return numbers !== null && numbers.every((name) => typeof Reflect.get(item, name) === "number");
|
|
247
|
+
};
|
|
248
|
+
const isTable = (item) => {
|
|
249
|
+
if (!isObject(item) || typeof Reflect.get(item, "count") !== "number" || typeof Reflect.get(item, "entities") !== "number") return false;
|
|
250
|
+
const columns = Reflect.get(item, "columns");
|
|
251
|
+
return Array.isArray(columns) && columns.every(isColumn);
|
|
252
|
+
};
|
|
253
|
+
if (!tables.every(isTable)) return null;
|
|
254
|
+
return json;
|
|
255
|
+
};
|
|
256
|
+
/** Whether two schemas are the same, by their JSON in their properties' order, which places a struct's fields. */
|
|
257
|
+
var sameSchema = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
258
|
+
/**
|
|
259
|
+
* The snapshot contents `bytes` holds, its components and resources by name as this program persists them, its blocks of
|
|
260
|
+
* bytes viewed where they are; or why it can't be read: it isn't one, or names what this program doesn't persist, or
|
|
261
|
+
* persists with another schema.
|
|
262
|
+
*/
|
|
263
|
+
var decodeContents = (bytes) => {
|
|
264
|
+
if (bytes.byteLength < prefix || magic.some((byte, at) => bytes[at] !== byte)) return { failure: "These bytes aren't a snapshot." };
|
|
265
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
266
|
+
const found = view.getUint32(4, true);
|
|
267
|
+
if (found !== version) return { failure: `This snapshot's format is version ${found}; this package reads version ${version}.` };
|
|
268
|
+
const headerLength = view.getUint32(8, true);
|
|
269
|
+
let json;
|
|
270
|
+
try {
|
|
271
|
+
json = JSON.parse(decoder.decode(bytes.subarray(prefix, prefix + headerLength)));
|
|
272
|
+
} catch {
|
|
273
|
+
return { failure: "This snapshot's header isn't JSON." };
|
|
274
|
+
}
|
|
275
|
+
const header = headerOf(json);
|
|
276
|
+
if (header === null) return { failure: "This snapshot's header isn't one this package writes." };
|
|
277
|
+
const program = programOf();
|
|
278
|
+
if (typeof program === "string") return { failure: program };
|
|
279
|
+
const components = [];
|
|
280
|
+
for (const { name, schema } of header.components) {
|
|
281
|
+
const persisted = program.components.get(name);
|
|
282
|
+
if (persisted === void 0) return { failure: `This snapshot holds component "${name}", which this program doesn't persist.` };
|
|
283
|
+
if (!sameSchema(persisted.schema, schema)) return { failure: `Component "${name}" has another schema here than in this snapshot.` };
|
|
284
|
+
components.push(persisted);
|
|
285
|
+
}
|
|
286
|
+
const resources = [];
|
|
287
|
+
for (const { name, schema, value } of header.resources) {
|
|
288
|
+
const persisted = program.resources.get(name);
|
|
289
|
+
if (persisted === void 0) return { failure: `This snapshot holds resource "${name}", which this program doesn't persist.` };
|
|
290
|
+
if (!sameSchema(persisted.schema, schema)) return { failure: `Resource "${name}" has another schema here than in this snapshot.` };
|
|
291
|
+
resources.push({
|
|
292
|
+
persisted,
|
|
293
|
+
value: fromJson(schema, value)
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const blocks = (bytes.byteOffset + prefix + headerLength) % 8 === 0 ? bytes : bytes.slice();
|
|
297
|
+
const base = blocks.byteOffset + prefix + headerLength;
|
|
298
|
+
const end = blocks.byteOffset + blocks.byteLength;
|
|
299
|
+
const inBounds = (offset, length) => offset >= 0 && length >= 0 && base + offset + length <= end;
|
|
300
|
+
const tables = [];
|
|
301
|
+
for (const table of header.tables) {
|
|
302
|
+
if (!inBounds(table.entities, table.count * 8)) return { failure: "This snapshot's blocks are cut short." };
|
|
303
|
+
const columns = {};
|
|
304
|
+
for (const column of table.columns) {
|
|
305
|
+
const persisted = components[column.component];
|
|
306
|
+
if (persisted === void 0) return { failure: "This snapshot names a component it doesn't hold." };
|
|
307
|
+
if (column.kind === "value") columns[persisted.key] = {
|
|
308
|
+
kind: "value",
|
|
309
|
+
value: fromJson(persisted.schema, column.value)
|
|
310
|
+
};
|
|
311
|
+
else if (!inBounds(column.offset, column.length)) return { failure: "This snapshot's blocks are cut short." };
|
|
312
|
+
else if (column.kind === "bytes") columns[persisted.key] = {
|
|
313
|
+
kind: "bytes",
|
|
314
|
+
bytes: new Uint8Array(blocks.buffer, base + column.offset, column.length),
|
|
315
|
+
stride: column.stride
|
|
316
|
+
};
|
|
317
|
+
else {
|
|
318
|
+
let values;
|
|
319
|
+
try {
|
|
320
|
+
values = JSON.parse(decoder.decode(new Uint8Array(blocks.buffer, base + column.offset, column.length)));
|
|
321
|
+
} catch {
|
|
322
|
+
return { failure: `Component "${persisted.name}"'s values in this snapshot aren't JSON.` };
|
|
323
|
+
}
|
|
324
|
+
if (!Array.isArray(values)) return { failure: `Component "${persisted.name}"'s values in this snapshot aren't a list.` };
|
|
325
|
+
columns[persisted.key] = {
|
|
326
|
+
kind: "values",
|
|
327
|
+
values: values.map((value) => fromJson(persisted.schema, value))
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
tables.push({
|
|
332
|
+
count: table.count,
|
|
333
|
+
entities: new Float64Array(blocks.buffer, base + table.entities, table.count),
|
|
334
|
+
columns
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
components,
|
|
339
|
+
tables,
|
|
340
|
+
resources
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
//#endregion
|
|
344
|
+
//#region src/program/contents.ts
|
|
345
|
+
/** Where a snapshot holds what it holds, a key only this package uses. */
|
|
346
|
+
var contents = Symbol("snapshot contents");
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/decode.ts
|
|
349
|
+
/**
|
|
350
|
+
* The snapshot `bytes` holds, its columns of bytes viewed where they are in `bytes`, copied once only when `bytes`
|
|
351
|
+
* doesn't start at a multiple of 8; or why it can't be read: it isn't one, or names a component or resource this
|
|
352
|
+
* program doesn't persist, or persists with another schema.
|
|
353
|
+
*/
|
|
354
|
+
var decode = (bytes) => {
|
|
355
|
+
const decoded = decodeContents(bytes);
|
|
356
|
+
return "failure" in decoded ? decoded : { [contents]: decoded };
|
|
357
|
+
};
|
|
358
|
+
//#endregion
|
|
359
|
+
//#region src/encode.ts
|
|
360
|
+
/**
|
|
361
|
+
* A snapshot as bytes, little-endian: a header naming each persisted component and resource with its schema, then
|
|
362
|
+
* 8-byte-aligned blocks of each table's entities and columns, its bytes columns copied whole.
|
|
363
|
+
*/
|
|
364
|
+
var encode = (snapshot) => encodeContents(snapshot[contents]);
|
|
365
|
+
//#endregion
|
|
366
|
+
//#region src/selection/selection.ts
|
|
367
|
+
/** A readonly system reading the tables `declaration` names. */
|
|
368
|
+
var tablesSystemOf = (declaration) => System.create({
|
|
369
|
+
readonly: true,
|
|
370
|
+
tables: declaration
|
|
371
|
+
}, ({ tables }) => tables);
|
|
372
|
+
/** The rows of `table` whose entities `keep` takes, as a table of their own. */
|
|
373
|
+
var rowsOf = (table, keep) => {
|
|
374
|
+
const rows = [];
|
|
375
|
+
for (let row = 0; row < table.count; row++) if (keep(table.entities[row])) rows.push(row);
|
|
376
|
+
if (rows.length === table.count) return table;
|
|
377
|
+
if (rows.length === 0) return null;
|
|
378
|
+
const columns = {};
|
|
379
|
+
for (const key of Object.getOwnPropertySymbols(table.columns)) {
|
|
380
|
+
const column = Reflect.get(table.columns, key);
|
|
381
|
+
if (column === void 0) continue;
|
|
382
|
+
if (column.kind === "value") columns[key] = column;
|
|
383
|
+
else if (column.kind === "values") columns[key] = {
|
|
384
|
+
kind: "values",
|
|
385
|
+
values: rows.map((row) => column.values[row])
|
|
386
|
+
};
|
|
387
|
+
else {
|
|
388
|
+
const { stride } = column;
|
|
389
|
+
const bytes = new Uint8Array(rows.length * stride);
|
|
390
|
+
rows.forEach((row, at) => bytes.set(column.bytes.subarray(row * stride, (row + 1) * stride), at * stride));
|
|
391
|
+
columns[key] = {
|
|
392
|
+
kind: "bytes",
|
|
393
|
+
bytes,
|
|
394
|
+
stride
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
count: rows.length,
|
|
400
|
+
entities: Float64Array.from(rows, (row) => table.entities[row]),
|
|
401
|
+
columns
|
|
402
|
+
};
|
|
403
|
+
};
|
|
404
|
+
/** The entities `root` owns, itself included, through the owner references among `owners` in the tables. */
|
|
405
|
+
var subtreeOf = (tables, owners, root) => {
|
|
406
|
+
const children = /* @__PURE__ */ new Map();
|
|
407
|
+
for (const table of tables) for (const reference of owners) {
|
|
408
|
+
const key = reference;
|
|
409
|
+
const column = Reflect.get(table.columns, key);
|
|
410
|
+
if (column?.kind !== "bytes") continue;
|
|
411
|
+
const view = new DataView(column.bytes.buffer, column.bytes.byteOffset, column.bytes.byteLength);
|
|
412
|
+
for (let row = 0; row < table.count; row++) {
|
|
413
|
+
const owner = view.getFloat64(row * column.stride, true);
|
|
414
|
+
const list = children.get(owner);
|
|
415
|
+
if (list === void 0) children.set(owner, [table.entities[row]]);
|
|
416
|
+
else list.push(table.entities[row]);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const taken = /* @__PURE__ */ new Set([root]);
|
|
420
|
+
const waiting = [root];
|
|
421
|
+
for (let next = waiting.pop(); next !== void 0; next = waiting.pop()) for (const child of children.get(next) ?? []) {
|
|
422
|
+
if (taken.has(child)) continue;
|
|
423
|
+
taken.add(child);
|
|
424
|
+
waiting.push(child);
|
|
425
|
+
}
|
|
426
|
+
return taken;
|
|
427
|
+
};
|
|
428
|
+
/** The persisted part of the entities `selection` takes from `world`, on its thread, or why it can't be read. */
|
|
429
|
+
var select = async (world, program, selection) => {
|
|
430
|
+
const components = [...program.components.values()];
|
|
431
|
+
const keys = components.map((persisted) => persisted.key);
|
|
432
|
+
const subtree = "root" in selection;
|
|
433
|
+
const resources = subtree || selection.resources === false ? [] : [...program.resources.values()];
|
|
434
|
+
const declaration = subtree ? { components: keys } : {
|
|
435
|
+
components: keys,
|
|
436
|
+
with: selection.with ?? [],
|
|
437
|
+
not: selection.not ?? [],
|
|
438
|
+
resources: resources.map((persisted) => persisted.key)
|
|
439
|
+
};
|
|
440
|
+
const read = await world.run(tablesSystemOf(declaration)).catch((error) => ({ failure: error instanceof Error ? error.message : String(error) }));
|
|
441
|
+
if ("failure" in read) return read;
|
|
442
|
+
let tables = read.archetypes;
|
|
443
|
+
if (subtree) {
|
|
444
|
+
const owners = keys.filter((key) => Component.declarations().get(key)?.reference === "owner");
|
|
445
|
+
const taken = subtreeOf(tables, owners, selection.root);
|
|
446
|
+
tables = tables.flatMap((table) => rowsOf(table, (entity) => taken.has(entity)) ?? []);
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
components,
|
|
450
|
+
tables,
|
|
451
|
+
resources: resources.map((persisted) => ({
|
|
452
|
+
persisted,
|
|
453
|
+
value: read.resources[persisted.key]
|
|
454
|
+
}))
|
|
455
|
+
};
|
|
456
|
+
};
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region src/snapshot.ts
|
|
459
|
+
/**
|
|
460
|
+
* A snapshot of some of `world`'s entities, read on its thread: by default every entity holding a persisted
|
|
461
|
+
* component, and the persisted resources; with `with` and `not`, those a query matches, and the resources unless
|
|
462
|
+
* `resources` is false; with `root`, it and every entity it owns, through persisted owner references, without
|
|
463
|
+
* resources. Fails, as data, when the program's persisted keys can't be saved or the world can't be read.
|
|
464
|
+
*/
|
|
465
|
+
var snapshot = async (input) => {
|
|
466
|
+
const program = programOf();
|
|
467
|
+
if (typeof program === "string") return { failure: program };
|
|
468
|
+
const taken = await select(input.world, program, input);
|
|
469
|
+
return "failure" in taken ? taken : { [contents]: taken };
|
|
470
|
+
};
|
|
471
|
+
//#endregion
|
|
472
|
+
//#region src/spawn.ts
|
|
473
|
+
var isTables = (value) => Array.isArray(value) && value.every((table) => typeof table === "object" && table !== null && typeof Reflect.get(table, "count") === "number" && Reflect.get(table, "entities") instanceof Float64Array && typeof Reflect.get(table, "columns") === "object");
|
|
474
|
+
var isKeyed = (value) => typeof value === "object" && value !== null;
|
|
475
|
+
/** Spawns its input's tables and sets its resources, a snapshot's, rewriting their entities. */
|
|
476
|
+
var spawnTables = System.create({}, ({ commands }, input) => {
|
|
477
|
+
if (!isTables(input.tables) || !isKeyed(input.resources)) throw new Error("A snapshot holds tables and resources.");
|
|
478
|
+
return commands.spawnTables(input.tables, input.resources);
|
|
479
|
+
}, {
|
|
480
|
+
"type": "object",
|
|
481
|
+
"properties": {
|
|
482
|
+
"tables": { "allOf": [] },
|
|
483
|
+
"resources": { "allOf": [] }
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
async function spawn(input) {
|
|
487
|
+
const { tables, resources } = input.snapshot[contents];
|
|
488
|
+
const values = {};
|
|
489
|
+
for (const { persisted, value } of resources) values[persisted.key] = value;
|
|
490
|
+
const spawned = await input.world.run(spawnTables, {
|
|
491
|
+
tables,
|
|
492
|
+
resources: values
|
|
493
|
+
}).catch((error) => ({ failure: error instanceof Error ? error.message : String(error) }));
|
|
494
|
+
if ("failure" in spawned) return spawned;
|
|
495
|
+
if (input.entities === false) return { dropped: spawned.dropped };
|
|
496
|
+
return {
|
|
497
|
+
dropped: spawned.dropped,
|
|
498
|
+
entities: entitiesOf(tables, spawned.entities)
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
/** Each of the tables' entities to the one spawned for it, or null for one that wasn't. */
|
|
502
|
+
var entitiesOf = (tables, spawned) => {
|
|
503
|
+
const entities = /* @__PURE__ */ new Map();
|
|
504
|
+
tables.forEach((table, t) => {
|
|
505
|
+
const made = spawned[t];
|
|
506
|
+
for (let row = 0; row < table.count; row++) {
|
|
507
|
+
const entity = made[row];
|
|
508
|
+
entities.set(table.entities[row], entity === 0 ? null : entity);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
return entities;
|
|
512
|
+
};
|
|
513
|
+
//#endregion
|
|
514
|
+
export { decode, encode, persist, snapshot, spawn };
|
|
515
|
+
|
|
516
|
+
//# sourceMappingURL=index.js.map
|