@joystack/cli 0.0.0-stage → 0.1.43
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/CHANGELOG.md +738 -0
- package/LICENSE +202 -0
- package/README.md +21 -2
- package/bin/joystack.mjs +300 -0
- package/lib/agent-session.mjs +52 -0
- package/lib/agent-surface.mjs +333 -0
- package/lib/assets-json.mjs +205 -0
- package/lib/assets-page.mjs +184 -0
- package/lib/assets-sheet.mjs +365 -0
- package/lib/build-id.mjs +130 -0
- package/lib/bundle-primitives.mjs +123 -0
- package/lib/changelog.mjs +193 -0
- package/lib/config.mjs +56 -0
- package/lib/create.mjs +241 -0
- package/lib/dev.mjs +435 -0
- package/lib/engine.mjs +198 -0
- package/lib/feedback-sync.mjs +231 -0
- package/lib/feedback.mjs +209 -0
- package/lib/freeze.mjs +108 -0
- package/lib/game-files.mjs +99 -0
- package/lib/history.mjs +612 -0
- package/lib/inbox-cmd.mjs +57 -0
- package/lib/inject.mjs +250 -0
- package/lib/joystack-node-doors.mjs +230 -0
- package/lib/lane.mjs +141 -0
- package/lib/legacy-names.mjs +246 -0
- package/lib/lint.mjs +292 -0
- package/lib/login.mjs +134 -0
- package/lib/model-bake-cli.mjs +17 -0
- package/lib/model-bake.mjs +869 -0
- package/lib/npm.mjs +38 -0
- package/lib/ports.mjs +234 -0
- package/lib/primitives.mjs +393 -0
- package/lib/printable.mjs +12 -0
- package/lib/process-tree.mjs +30 -0
- package/lib/project.mjs +84 -0
- package/lib/publish.mjs +179 -0
- package/lib/replay-cmd.mjs +426 -0
- package/lib/replay-export.mjs +1379 -0
- package/lib/replay-list.mjs +308 -0
- package/lib/replay-profile.mjs +467 -0
- package/lib/replay-store.mjs +719 -0
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +539 -0
- package/lib/self-update.mjs +162 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/share.mjs +149 -0
- package/lib/smoke-gpu-preload.mjs +91 -0
- package/lib/source-path.mjs +33 -0
- package/lib/static-server.mjs +1238 -0
- package/lib/test-cmd.mjs +697 -0
- package/lib/test-policy.mjs +157 -0
- package/lib/test-reporter.mjs +177 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/lib/update.mjs +299 -0
- package/package.json +27 -4
|
@@ -0,0 +1,1379 @@
|
|
|
1
|
+
// The recorded session, turned into a table.
|
|
2
|
+
//
|
|
3
|
+
// A recording stores what the player PRESSED — the world it started from, plus
|
|
4
|
+
// every input stamped with the tick it arrived on. No position, no speed, no
|
|
5
|
+
// score appears anywhere in it. So nothing can point a query at a recording:
|
|
6
|
+
// everything worth asking about has to be re-derived by running the game
|
|
7
|
+
// forward. That is what this does. It steps the session through the game's own
|
|
8
|
+
// runtime and writes one row per (tick, entity), with every declared field as a
|
|
9
|
+
// column — after which the questions are ordinary SQL, and asking a new one
|
|
10
|
+
// costs nothing because the sim does not have to run again.
|
|
11
|
+
//
|
|
12
|
+
// Four tables come out on the tick axis, and a fifth describes the columns:
|
|
13
|
+
//
|
|
14
|
+
// <stream>.state.parquet one row per tick per entity, the wide sparse one
|
|
15
|
+
// <stream>.inputs.parquet one row per wire send — already rows, no replay
|
|
16
|
+
// <stream>.perf.parquet what each frame and second cost on the device
|
|
17
|
+
// that played, when the session was measured
|
|
18
|
+
// <stream>.info.parquet the session, its players and browsers, and the
|
|
19
|
+
// tick ranges the other three cannot vouch for
|
|
20
|
+
// <stream>.fields.parquet one row per state column and per channel: the
|
|
21
|
+
// trait and source line behind it
|
|
22
|
+
//
|
|
23
|
+
// The state table is wide and mostly repeated (a real session measured 8.7% of
|
|
24
|
+
// rows and 1.3% of cells changing between consecutive ticks). Delta-encoding it
|
|
25
|
+
// was considered and rejected: Parquet's columnar compression already absorbs
|
|
26
|
+
// the redundancy — 225 MB of JSONL becomes ~3 MB — and deltas would push
|
|
27
|
+
// last-value-carried-forward semantics into every query anybody writes.
|
|
28
|
+
import { mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { pathToFileURL } from 'node:url';
|
|
31
|
+
import { ParquetWriter, fileWriter } from 'hyparquet-writer';
|
|
32
|
+
import { readTrackLimits, readTrackSegments, readBuildStamp } from './replay-store.mjs';
|
|
33
|
+
import { gameBuild } from './build-id.mjs';
|
|
34
|
+
import { sourcePath } from './source-path.mjs';
|
|
35
|
+
|
|
36
|
+
// The columns every state row carries, before any declared field. `def` is a
|
|
37
|
+
// column rather than a prefix on every field name — which is what lets a query
|
|
38
|
+
// say `WHERE def IN ('prop','saucer')` and read one `body_x`.
|
|
39
|
+
export const STATE_KEYS = ['tick', 'id', 'def', 'owner'];
|
|
40
|
+
const STATE_KEY_TYPES = { tick: 'DOUBLE', id: 'STRING', def: 'STRING', owner: 'STRING' };
|
|
41
|
+
|
|
42
|
+
// The columns every input row carries.
|
|
43
|
+
// `tick` is when a send ARRIVED. A predicting client stamps `targetTick` and the
|
|
44
|
+
// room holds the send in its jitter buffer until exactly that tick, so for any
|
|
45
|
+
// predicted input the tick it arrives at is not the tick its effect shows up at
|
|
46
|
+
// in the state table. Without this column a join on `tick` mis-attributes every
|
|
47
|
+
// one of them, silently.
|
|
48
|
+
const INPUT_KEYS = ['tick', 'target_tick', 'conn', 'seq', 'entity', 'channel', 'payload'];
|
|
49
|
+
const INPUT_KEY_TYPES = {
|
|
50
|
+
tick: 'DOUBLE', target_tick: 'DOUBLE', conn: 'STRING', seq: 'DOUBLE',
|
|
51
|
+
entity: 'STRING', channel: 'STRING', payload: 'JSON',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The logical column type a JS value implies.
|
|
56
|
+
*
|
|
57
|
+
* DOUBLE for every number, never INT: the engine has one numeric type (a JS
|
|
58
|
+
* double), and a column typed from whole-looking values is a column that throws
|
|
59
|
+
* a thousand rows later when a fraction arrives. JSON for anything that is not
|
|
60
|
+
* flat, and for anything that says nothing — a cell has to be a scalar, so a
|
|
61
|
+
* list or an object rides as text rather than being dropped.
|
|
62
|
+
*/
|
|
63
|
+
function typeOf(value) {
|
|
64
|
+
if (value === null || value === undefined) return 'JSON';
|
|
65
|
+
switch (typeof value) {
|
|
66
|
+
case 'number': return 'DOUBLE';
|
|
67
|
+
case 'boolean': return 'BOOLEAN';
|
|
68
|
+
case 'string': return 'STRING';
|
|
69
|
+
default: return 'JSON';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Two declarations of one column name that disagree. One Parquet column cannot
|
|
74
|
+
// be both a double and a string, and dropping it would lose both kinds' data
|
|
75
|
+
// silently — so the column widens to the one type that holds anything.
|
|
76
|
+
const merge = (a, b) => (a === b ? a : 'JSON');
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The state table's shape, read off the lowered bundle.
|
|
80
|
+
*
|
|
81
|
+
* Types come from what each field was DECLARED as, never from a sample of what
|
|
82
|
+
* rows happen to hold. That is the whole reason this reads the manifests rather
|
|
83
|
+
* than the world: a sample is a guess that is right until it is not, and the
|
|
84
|
+
* failure lands mid-write, after the replay has already run.
|
|
85
|
+
*
|
|
86
|
+
* @param {{ manifests: Array<{component: string, owns: object}> }} bundle
|
|
87
|
+
* @returns {{ columns: Array<{name: string, type: string}>, conflicts: string[] }}
|
|
88
|
+
*/
|
|
89
|
+
export function tableSchema(bundle) {
|
|
90
|
+
const declared = new Map();
|
|
91
|
+
const conflicts = new Set();
|
|
92
|
+
for (const manifest of bundle?.manifests ?? []) {
|
|
93
|
+
for (const [field, spec] of Object.entries(manifest.owns ?? {})) {
|
|
94
|
+
const type = typeOf(spec?.default);
|
|
95
|
+
if (!declared.has(field)) {
|
|
96
|
+
declared.set(field, type);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const merged = merge(declared.get(field), type);
|
|
100
|
+
if (merged !== declared.get(field) || merged !== type) conflicts.add(field);
|
|
101
|
+
declared.set(field, merged);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
columns: [
|
|
106
|
+
...STATE_KEYS.map((name) => ({ name, type: STATE_KEY_TYPES[name] })),
|
|
107
|
+
// Sorted so the same game always exports the same column order, whatever
|
|
108
|
+
// order its definitions happened to be lowered in.
|
|
109
|
+
...[...declared.keys()].sort().map((name) => ({ name, type: declared.get(name) })),
|
|
110
|
+
],
|
|
111
|
+
conflicts: [...conflicts].sort(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const FIELD_COLUMNS = [
|
|
116
|
+
['row', 'STRING'], ['kind', 'STRING'], ['name', 'STRING'], ['trait', 'STRING'], ['instance', 'STRING'],
|
|
117
|
+
['field', 'STRING'], ['type', 'STRING'], ['default', 'JSON'], ['source', 'STRING'], ['senders', 'JSON'],
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What each state column and each channel is. One `field` row per (kind,
|
|
122
|
+
* column) and one `channel` row per (kind, channel), each naming the trait
|
|
123
|
+
* behind it, the name the kind gave that trait (a column's prefix), and the
|
|
124
|
+
* file and line it is declared on; a field row adds the field's own name, its
|
|
125
|
+
* declared type and its starting value.
|
|
126
|
+
*
|
|
127
|
+
* Names alone do not say this. `contact_asleep` is the `asleep` field of
|
|
128
|
+
* whatever trait the kind calls `contact`; a channel is named by the file its
|
|
129
|
+
* token is exported from, so `ground-contact_sleep` shares no prefix with the
|
|
130
|
+
* `contact_*` fields its handler changes. Only the lowered game knows which
|
|
131
|
+
* trait is behind each. A name the game lowered without an origin keeps its
|
|
132
|
+
* row, with the origin null.
|
|
133
|
+
*
|
|
134
|
+
* A channel row also says who sends on it (`senders`): each query declared to
|
|
135
|
+
* send on it, by kind, trait and line, and `{ from: 'client' }` when players'
|
|
136
|
+
* browsers may send on it too.
|
|
137
|
+
*
|
|
138
|
+
* A file inside the engine's package is named `@joystack/engine/shared/…`,
|
|
139
|
+
* the same in every install, rather than by where this one sits.
|
|
140
|
+
*
|
|
141
|
+
* @param {{ manifests: Array<{component: string, owns: object}>, fieldOrigins?: object, channelOrigins?: object,
|
|
142
|
+
* channelSenders?: object }} bundle
|
|
143
|
+
* @param {{ dir?: string, sharedDir?: string }} [where] - the game folder, and
|
|
144
|
+
* the engine's shared/ tree the game was lowered with
|
|
145
|
+
*/
|
|
146
|
+
export function fieldsTable(bundle, { dir = null, sharedDir = null } = {}) {
|
|
147
|
+
const originOf = (table, kind, name) => {
|
|
148
|
+
const byName = table?.[kind];
|
|
149
|
+
return byName && typeof byName === 'object' && Object.hasOwn(byName, name) ? byName[name] : null;
|
|
150
|
+
};
|
|
151
|
+
const sourceOf = (o) => {
|
|
152
|
+
const given = text(o?.file);
|
|
153
|
+
const file = sourcePath(given, { dir, sharedDir }) ?? given;
|
|
154
|
+
const line = Number.isSafeInteger(o?.line) && o.line > 0 ? o.line : null;
|
|
155
|
+
return file && line ? `${file}:${line}` : file;
|
|
156
|
+
};
|
|
157
|
+
const rows = [];
|
|
158
|
+
for (const manifest of bundle?.manifests ?? []) {
|
|
159
|
+
const kind = manifest.component;
|
|
160
|
+
for (const [column, spec] of Object.entries(manifest.owns ?? {})) {
|
|
161
|
+
const o = originOf(bundle?.fieldOrigins, kind, column);
|
|
162
|
+
rows.push({
|
|
163
|
+
row: 'field',
|
|
164
|
+
kind: text(kind),
|
|
165
|
+
name: text(column),
|
|
166
|
+
trait: text(o?.trait),
|
|
167
|
+
instance: text(o?.instance),
|
|
168
|
+
field: text(o?.field),
|
|
169
|
+
type: typeOf(spec?.default),
|
|
170
|
+
default: jsonCell(spec?.default ?? null),
|
|
171
|
+
source: sourceOf(o),
|
|
172
|
+
senders: null,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Channels are listed from the manifests, so an engine that records no
|
|
177
|
+
// origins still gives every channel its row.
|
|
178
|
+
for (const manifest of bundle?.manifests ?? []) {
|
|
179
|
+
const kind = manifest.component;
|
|
180
|
+
const names = manifest.channels && typeof manifest.channels === 'object' ? Object.keys(manifest.channels) : [];
|
|
181
|
+
for (const name of names) {
|
|
182
|
+
const o = originOf(bundle?.channelOrigins, kind, name);
|
|
183
|
+
const known = o && typeof o === 'object' ? o : null;
|
|
184
|
+
// An engine that records no senders says nothing about them: null, not
|
|
185
|
+
// an empty list, which would claim nothing sends on the channel.
|
|
186
|
+
const recorded = bundle?.channelSenders && typeof bundle.channelSenders === 'object';
|
|
187
|
+
const declared = originOf(bundle?.channelSenders, kind, name);
|
|
188
|
+
const senders = (Array.isArray(declared) ? declared : [])
|
|
189
|
+
.filter((q) => q && typeof q === 'object')
|
|
190
|
+
.map((q) => ({ from: 'query', kind: text(q.kind), trait: text(q.trait), instance: text(q.instance), source: sourceOf(q) }));
|
|
191
|
+
if (manifest.channels[name]?.from === 'client') senders.push({ from: 'client' });
|
|
192
|
+
rows.push({
|
|
193
|
+
row: 'channel',
|
|
194
|
+
kind: text(kind),
|
|
195
|
+
name: text(name),
|
|
196
|
+
trait: text(known?.trait),
|
|
197
|
+
instance: text(known?.instance),
|
|
198
|
+
field: null,
|
|
199
|
+
type: null,
|
|
200
|
+
default: null,
|
|
201
|
+
source: sourceOf(known),
|
|
202
|
+
senders: recorded ? jsonCell(senders) : null,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const key = (r) => `${r.row === 'field' ? 0 : 1}\u0000${r.kind}\u0000${r.name}`;
|
|
207
|
+
rows.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
|
208
|
+
return { columns: FIELD_COLUMNS.map(([name, type]) => ({ name, type })), rows };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* One cell, as its column's type — or null.
|
|
213
|
+
*
|
|
214
|
+
* A value that does not fit is null rather than coerced. Turning a string into
|
|
215
|
+
* 0, or a NaN into "some number", would put a value in the table that the
|
|
216
|
+
* session never held, and the whole point of this table is that a question
|
|
217
|
+
* asked of it gets the sim's real answer.
|
|
218
|
+
*/
|
|
219
|
+
export function coerce(value, type) {
|
|
220
|
+
if (value === null || value === undefined) return null;
|
|
221
|
+
switch (type) {
|
|
222
|
+
// Number.isFinite rejects NaN and ±Infinity: Parquet has no way to carry
|
|
223
|
+
// them as doubles that any reader agrees on.
|
|
224
|
+
case 'DOUBLE': return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
225
|
+
case 'BOOLEAN': return typeof value === 'boolean' ? value : null;
|
|
226
|
+
case 'STRING': return typeof value === 'string' ? value : null;
|
|
227
|
+
// A value, not a string: the Parquet writer serializes a JSON column
|
|
228
|
+
// once, and serializing it here too stores a JSON string of the JSON,
|
|
229
|
+
// which every reader hands back as a string. See `jsonCell`.
|
|
230
|
+
case 'JSON': return jsonCell(value);
|
|
231
|
+
default: return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Deeper than any value a game or a track has reason to hold, and far short of
|
|
236
|
+
// the depth at which the Parquet writer's recursive walk overflows the stack.
|
|
237
|
+
const JSON_MAX_DEPTH = 64;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A JSON column's cell: a detached copy of the value as it is NOW, or null
|
|
241
|
+
* when JSON cannot carry it or it nests past `JSON_MAX_DEPTH`.
|
|
242
|
+
*
|
|
243
|
+
* A copy, because rows wait in a batch for several ticks before they are
|
|
244
|
+
* written, and a record field may hold a list or object a trait changes in
|
|
245
|
+
* place — a reference would be written with its later value on earlier rows.
|
|
246
|
+
* The depth is read off the serialized text, without recursion, so a value
|
|
247
|
+
* built to be deep cannot overflow this check either.
|
|
248
|
+
*/
|
|
249
|
+
// JSON escapes the C0 controls but writes U+0080–009F as they are, and U+009B
|
|
250
|
+
// is a terminal's CSI. A cell read in DuckDB's csv or list mode reaches the
|
|
251
|
+
// terminal byte for byte, so they are dropped from every string in a cell.
|
|
252
|
+
const C1 = /[\u0080-\u009f]/g;
|
|
253
|
+
const noC1 = (_, v) => (typeof v === 'string' ? v.replace(C1, '') : v);
|
|
254
|
+
|
|
255
|
+
function jsonCell(value) {
|
|
256
|
+
let text;
|
|
257
|
+
try { text = JSON.stringify(value, noC1); } catch { return null; }
|
|
258
|
+
if (text === undefined) return null;
|
|
259
|
+
let depth = 0;
|
|
260
|
+
let inString = false;
|
|
261
|
+
for (let i = 0; i < text.length; i++) {
|
|
262
|
+
const c = text.charCodeAt(i);
|
|
263
|
+
if (inString) {
|
|
264
|
+
if (c === 92) i++; // backslash: skip the escaped character
|
|
265
|
+
else if (c === 34) inString = false;
|
|
266
|
+
} else if (c === 34) inString = true;
|
|
267
|
+
else if (c === 91 || c === 123) { if (++depth > JSON_MAX_DEPTH) return null; }
|
|
268
|
+
else if (c === 93 || c === 125) depth--;
|
|
269
|
+
}
|
|
270
|
+
return JSON.parse(text);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* One state row: the fixed keys, then every declared field this entity holds.
|
|
275
|
+
*
|
|
276
|
+
* A record key is `<kind>.<field>`; the kind is dropped because `def` already
|
|
277
|
+
* carries it. A key the schema does not know is skipped rather than added —
|
|
278
|
+
* Parquet needs its columns fixed before the first row is written, so a column
|
|
279
|
+
* discovered mid-session could not be honoured anyway.
|
|
280
|
+
*/
|
|
281
|
+
export function stateRow(entity, tick, columnIndex, tally) {
|
|
282
|
+
// The key columns go through `coerce` like every other cell. They used to be
|
|
283
|
+
// assigned straight through, so an id that was not a string reached the
|
|
284
|
+
// writer as one and threw a raw type error — after the entire replay had
|
|
285
|
+
// already been paid for.
|
|
286
|
+
const row = {
|
|
287
|
+
tick: coerce(tick, 'DOUBLE'),
|
|
288
|
+
id: coerce(entity.id, 'STRING'),
|
|
289
|
+
def: coerce(entity.def, 'STRING'),
|
|
290
|
+
owner: coerce(entity.owner, 'STRING'),
|
|
291
|
+
};
|
|
292
|
+
for (const [key, value] of Object.entries(entity.record ?? {})) {
|
|
293
|
+
const field = key.slice(key.indexOf('.') + 1);
|
|
294
|
+
const type = columnIndex.get(field);
|
|
295
|
+
if (!type) continue;
|
|
296
|
+
row[field] = coerce(value, type);
|
|
297
|
+
// NaN and ±Infinity are real values a world can hold (M2 carries them out
|
|
298
|
+
// of band through a keyframe for exactly that reason) and Parquet has no
|
|
299
|
+
// double that means them. Null is the right answer and a SILENT null is
|
|
300
|
+
// not: null already means "this kind has no such field", so a dropped NaN
|
|
301
|
+
// would be indistinguishable from an absent column.
|
|
302
|
+
if (tally && row[field] === null && type === 'DOUBLE' && typeof value === 'number') tally.undoubleable += 1;
|
|
303
|
+
}
|
|
304
|
+
return row;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The input table: one row per accepted wire send.
|
|
309
|
+
*
|
|
310
|
+
* No replay is needed — the recording already holds these as rows. A payload
|
|
311
|
+
* has no declared shape, so unlike the state table its columns are typed by
|
|
312
|
+
* reading EVERY event first. That is not the sampling rule 1 forbids: the whole
|
|
313
|
+
* list is in hand before a row is written, so this is an exact read of the data
|
|
314
|
+
* rather than a guess from its first few values.
|
|
315
|
+
*
|
|
316
|
+
* Each scalar payload key becomes `p_<key>`, and the whole payload also rides
|
|
317
|
+
* as JSON — so a question about a field that did not flatten is still one
|
|
318
|
+
* `json_extract` away rather than a re-export.
|
|
319
|
+
*/
|
|
320
|
+
// How many payload keys may become their own column.
|
|
321
|
+
//
|
|
322
|
+
// A recording is UNTRUSTED input. The segment endpoint takes what a room sends
|
|
323
|
+
// and cannot demand an Origin (a multiplayer room posts from a worker that has
|
|
324
|
+
// none), so anything that can reach the dev server can append events — and one
|
|
325
|
+
// column per distinct key, uncapped, turns a 151 KB post into a multi-gigabyte
|
|
326
|
+
// out-of-memory abort that names neither the export nor the recording.
|
|
327
|
+
//
|
|
328
|
+
// Capping loses nothing: the whole payload is on every row as JSON anyway, so a
|
|
329
|
+
// question about a rare key is one `json_extract` away instead of a column. The
|
|
330
|
+
// keys that survive are the ones that appear MOST OFTEN, because a key on every
|
|
331
|
+
// event is the one worth a column and a key on one event is the one that is
|
|
332
|
+
// not.
|
|
333
|
+
const MAX_PAYLOAD_COLUMNS = 256;
|
|
334
|
+
|
|
335
|
+
export function inputSchema(session) {
|
|
336
|
+
const sends = (session?.events ?? []).filter((e) => e?.k === 'w');
|
|
337
|
+
const payloadTypes = new Map();
|
|
338
|
+
const seen = new Map();
|
|
339
|
+
for (const send of sends) {
|
|
340
|
+
for (const [key, value] of Object.entries(send.payload ?? {})) {
|
|
341
|
+
const type = typeOf(value);
|
|
342
|
+
payloadTypes.set(key, payloadTypes.has(key) ? merge(payloadTypes.get(key), type) : type);
|
|
343
|
+
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const ranked = [...seen.keys()].sort((a, b) => (seen.get(b) - seen.get(a)) || a.localeCompare(b));
|
|
347
|
+
// Sorted by name after ranking, so the same recording always exports the same
|
|
348
|
+
// column order.
|
|
349
|
+
const keys = ranked.slice(0, MAX_PAYLOAD_COLUMNS).sort();
|
|
350
|
+
const columns = [
|
|
351
|
+
...INPUT_KEYS.map((name) => ({ name, type: INPUT_KEY_TYPES[name] })),
|
|
352
|
+
...keys.map((k) => ({ name: `p_${k}`, type: payloadTypes.get(k) })),
|
|
353
|
+
];
|
|
354
|
+
return { columns, keys, types: payloadTypes, sends, truncatedKeys: ranked.length - keys.length };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** One input row: the fixed columns, then each flattened payload key. */
|
|
358
|
+
export function inputRow(send, keys, types) {
|
|
359
|
+
const row = {
|
|
360
|
+
tick: coerce(send.t, 'DOUBLE'),
|
|
361
|
+
// Null, not a copy of `tick`: an input with no target tick was never
|
|
362
|
+
// held, and repeating arrival here would invent a fact.
|
|
363
|
+
target_tick: coerce(send.targetTick, 'DOUBLE'),
|
|
364
|
+
conn: coerce(send.connId, 'STRING'),
|
|
365
|
+
seq: coerce(send.seq, 'DOUBLE'),
|
|
366
|
+
entity: coerce(send.entityId, 'STRING'),
|
|
367
|
+
channel: coerce(send.channel, 'STRING'),
|
|
368
|
+
payload: coerce(send.payload ?? null, 'JSON'),
|
|
369
|
+
};
|
|
370
|
+
// Every column on every row, so a field an event did not carry reads as
|
|
371
|
+
// null rather than being absent — a Parquet column has one length.
|
|
372
|
+
for (const k of keys) row[`p_${k}`] = coerce(send.payload?.[k], types.get(k));
|
|
373
|
+
return row;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function inputTable(session) {
|
|
377
|
+
const { columns, keys, types, sends, truncatedKeys } = inputSchema(session);
|
|
378
|
+
return { columns, rows: sends.map((send) => inputRow(send, keys, types)), truncatedKeys };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// The logical types above, as the Parquet schema elements the writer wants.
|
|
382
|
+
// Every column is OPTIONAL because the table is deliberately SPARSE: a kind
|
|
383
|
+
// that does not declare a field leaves it null on that row, and null is a
|
|
384
|
+
// different answer from zero.
|
|
385
|
+
const PARQUET_TYPE = {
|
|
386
|
+
DOUBLE: { type: 'DOUBLE' },
|
|
387
|
+
BOOLEAN: { type: 'BOOLEAN' },
|
|
388
|
+
STRING: { type: 'BYTE_ARRAY', converted_type: 'UTF8' },
|
|
389
|
+
JSON: { type: 'BYTE_ARRAY', converted_type: 'JSON' },
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// ── the performance table (project note ofta45) ─────────────────────────────
|
|
393
|
+
//
|
|
394
|
+
// The third table: what the session COST, from the track the browser recorded
|
|
395
|
+
// beside it. Unlike state, none of this is re-derived — it was measured on the
|
|
396
|
+
// device that played, and no replay on another machine can reproduce it.
|
|
397
|
+
//
|
|
398
|
+
// One row per frame, one per finished second and one per long frame the
|
|
399
|
+
// browser reported (below), told apart by `row`, in one table rather than
|
|
400
|
+
// three: all are stamped with the ticks they cover, and a reader's question
|
|
401
|
+
// ("what was alive while frames were slow") joins the tick axis once. On a
|
|
402
|
+
// `long_frame` row, `at_ms` and `dt` are Chrome's start and duration for the
|
|
403
|
+
// frame's main-thread work, and its ticks are those of the frame record it was
|
|
404
|
+
// joined to. A `kind` row is one entity kind's share of a `second` row: the
|
|
405
|
+
// same second's `at_ms` and ticks, the time that kind's entities spent
|
|
406
|
+
// (`ms`, split into `tick_ms` and `present_ms`), how many trait calls that
|
|
407
|
+
// took and how many of its entities spent any, and how many of that kind were
|
|
408
|
+
// alive in the replayed world at the second's last tick. It is the second
|
|
409
|
+
// row's `kinds` and `traits` JSON, as rows a query can group by. `conn` is
|
|
410
|
+
// which browser measured it — in multiplayer each player
|
|
411
|
+
// writes their own track, and averaging two devices together would describe
|
|
412
|
+
// neither.
|
|
413
|
+
const PERF_KEYS = [
|
|
414
|
+
'row', 'conn', 'seq', 'at_ms', 'tick_from', 'tick_to',
|
|
415
|
+
'dt', 'tick_ms', 'present_ms', 'render_ms', 'sim_ms', 'other_ms', 'wait_ms',
|
|
416
|
+
'window_ms', 'entities', 'calls', 'triangles', 'heap_delta_mb', 'dom', 'geometries', 'textures',
|
|
417
|
+
'spawned', 'despawned', 'programs', 'snapshot', 'channels', 'render', 'traits', 'kinds',
|
|
418
|
+
'blocking_ms', 'render_start', 'layout_start', 'scripts',
|
|
419
|
+
'kind', 'ms', 'trait_calls', 'entities_spent', 'alive', 'alive_max',
|
|
420
|
+
];
|
|
421
|
+
const PERF_KEY_TYPES = {
|
|
422
|
+
row: 'STRING', conn: 'STRING', seq: 'DOUBLE', at_ms: 'DOUBLE', tick_from: 'DOUBLE', tick_to: 'DOUBLE',
|
|
423
|
+
dt: 'DOUBLE', tick_ms: 'DOUBLE', present_ms: 'DOUBLE', render_ms: 'DOUBLE', sim_ms: 'DOUBLE',
|
|
424
|
+
other_ms: 'DOUBLE', wait_ms: 'DOUBLE', window_ms: 'DOUBLE', entities: 'DOUBLE',
|
|
425
|
+
calls: 'DOUBLE', triangles: 'DOUBLE',
|
|
426
|
+
heap_delta_mb: 'DOUBLE', dom: 'DOUBLE', geometries: 'DOUBLE', textures: 'DOUBLE',
|
|
427
|
+
spawned: 'JSON', despawned: 'JSON', programs: 'JSON', snapshot: 'JSON', channels: 'JSON',
|
|
428
|
+
render: 'JSON', traits: 'JSON', kinds: 'JSON',
|
|
429
|
+
blocking_ms: 'DOUBLE', render_start: 'DOUBLE', layout_start: 'DOUBLE', scripts: 'JSON',
|
|
430
|
+
kind: 'STRING', ms: 'DOUBLE', trait_calls: 'DOUBLE', entities_spent: 'DOUBLE', alive: 'DOUBLE', alive_max: 'DOUBLE',
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
434
|
+
// Chrome writes 0 for the render and layout start of a long frame that
|
|
435
|
+
// rendered nothing. A 0 in a time column reads as the page's first
|
|
436
|
+
// millisecond, so it is exported as no value.
|
|
437
|
+
const time = (v) => (num(v) > 0 ? v : null);
|
|
438
|
+
// A JSON column's value, copied and depth-checked; see `jsonCell`.
|
|
439
|
+
const json = (v) => (v === undefined || v === null ? null : jsonCell(v));
|
|
440
|
+
|
|
441
|
+
function perfRow(kind, header, rec, joinedTicks = null) {
|
|
442
|
+
const ticks = joinedTicks ?? (Array.isArray(rec.ticks) && rec.ticks.length === 2 ? rec.ticks : [null, null]);
|
|
443
|
+
return {
|
|
444
|
+
row: kind,
|
|
445
|
+
conn: text(header.producer),
|
|
446
|
+
seq: num(header.seq),
|
|
447
|
+
at_ms: num(rec.at),
|
|
448
|
+
tick_from: num(ticks[0]),
|
|
449
|
+
tick_to: num(ticks[1]),
|
|
450
|
+
dt: num(rec.dt),
|
|
451
|
+
tick_ms: num(rec.tick),
|
|
452
|
+
present_ms: num(rec.present),
|
|
453
|
+
render_ms: num(rec.render),
|
|
454
|
+
sim_ms: num(rec.sim),
|
|
455
|
+
other_ms: num(rec.other),
|
|
456
|
+
wait_ms: num(rec.wait),
|
|
457
|
+
window_ms: num(rec.windowMs),
|
|
458
|
+
entities: num(rec.entities),
|
|
459
|
+
// What the frame asked the GPU for. The only columns that can explain a
|
|
460
|
+
// frame whose time is `wait` with every CPU phase small.
|
|
461
|
+
calls: num(rec.calls),
|
|
462
|
+
triangles: num(rec.triangles),
|
|
463
|
+
heap_delta_mb: num(rec.heapDeltaMB),
|
|
464
|
+
dom: num(rec.dom),
|
|
465
|
+
geometries: num(rec.geometries),
|
|
466
|
+
textures: num(rec.textures),
|
|
467
|
+
spawned: json(rec.spawned),
|
|
468
|
+
despawned: json(rec.despawned),
|
|
469
|
+
programs: json(rec.programs),
|
|
470
|
+
snapshot: json(rec.snapshot),
|
|
471
|
+
channels: json(rec.channels),
|
|
472
|
+
render: json(rec.render && typeof rec.render === 'object' ? rec.render : undefined),
|
|
473
|
+
traits: json(rec.traits),
|
|
474
|
+
kinds: json(rec.kinds),
|
|
475
|
+
blocking_ms: num(rec.blocking),
|
|
476
|
+
render_start: time(rec.renderStart),
|
|
477
|
+
layout_start: time(rec.styleAndLayoutStart),
|
|
478
|
+
scripts: kind === 'long_frame' ? json(Array.isArray(rec.scripts) ? rec.scripts : undefined) : null,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── long frames ─────────────────────────────────────────────────────────────
|
|
483
|
+
//
|
|
484
|
+
// Chrome's Long Animation Frames API reports, for each frame whose main-thread
|
|
485
|
+
// work passed 50 ms, the scripts that ran in it. The browser delivers the
|
|
486
|
+
// report after its frame, and it carries a time rather than a tick, so it is
|
|
487
|
+
// stored beside the frame records (`longFrames` on a track segment) and joined
|
|
488
|
+
// to them here, per browser, by time.
|
|
489
|
+
//
|
|
490
|
+
// The frame record a long frame belongs to is the one whose interval it
|
|
491
|
+
// overlaps most. A frame record's interval runs from the previous
|
|
492
|
+
// animation-frame timestamp to its own (`at - dt` to `at`), and its split is
|
|
493
|
+
// the work that made that interval long. A long frame spans its first task to
|
|
494
|
+
// the end of its paint (`at` to `at + duration`). Its `renderStart` is not a
|
|
495
|
+
// usable key on its own: Chrome stamps an animation frame a few milliseconds
|
|
496
|
+
// before the rendering it belongs to starts, so a long frame's render lands in
|
|
497
|
+
// one interval or the next depending on how late the task before it ran.
|
|
498
|
+
//
|
|
499
|
+
// Frame intervals are taken as never overlapping — each begins no earlier than
|
|
500
|
+
// the previous frame record's timestamp — which is true of a real track and
|
|
501
|
+
// what keeps the scan below short on a posted one that is not.
|
|
502
|
+
|
|
503
|
+
const finite = (v) => num(v) !== null;
|
|
504
|
+
// A long frame overlaps two or three frame records; a scan this long only
|
|
505
|
+
// happens on a track no browser wrote, and is cut rather than followed.
|
|
506
|
+
const JOIN_SCAN = 64;
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Every long-frame record in a track, each with the frame record it describes.
|
|
510
|
+
* A long frame no frame record covers (the tab was hidden, measuring was off
|
|
511
|
+
* for the frame) is still listed, with `frame` null.
|
|
512
|
+
*
|
|
513
|
+
* @param {object[]} segments - track segments, any producer, any order
|
|
514
|
+
* @returns {{ conn: string|null, record: object, frame: object|null }[]}
|
|
515
|
+
*/
|
|
516
|
+
export function joinLongFrames(segments) {
|
|
517
|
+
const byConn = new Map();
|
|
518
|
+
const of = (conn) => {
|
|
519
|
+
if (!byConn.has(conn)) byConn.set(conn, { frames: [], longs: [] });
|
|
520
|
+
return byConn.get(conn);
|
|
521
|
+
};
|
|
522
|
+
for (const seg of segments ?? []) {
|
|
523
|
+
const conn = text(seg?.header?.producer);
|
|
524
|
+
const frames = Array.isArray(seg?.frames) ? seg.frames : [];
|
|
525
|
+
const longs = Array.isArray(seg?.longFrames) ? seg.longFrames : [];
|
|
526
|
+
if (!frames.length && !longs.length) continue;
|
|
527
|
+
const bucket = of(conn);
|
|
528
|
+
for (const f of frames) if (f && typeof f === 'object' && finite(f.at) && finite(f.dt) && f.dt > 0) bucket.frames.push(f);
|
|
529
|
+
for (const l of longs) if (l && typeof l === 'object' && finite(l.at)) bucket.longs.push(l);
|
|
530
|
+
}
|
|
531
|
+
const out = [];
|
|
532
|
+
for (const [conn, { frames, longs }] of byConn) {
|
|
533
|
+
frames.sort((x, y) => x.at - y.at);
|
|
534
|
+
const from = frames.map((f, i) => Math.max(f.at - f.dt, i ? frames[i - 1].at : -Infinity));
|
|
535
|
+
for (const record of longs) {
|
|
536
|
+
const start = record.at;
|
|
537
|
+
const end = start + (finite(record.duration) && record.duration > 0 ? record.duration : 0);
|
|
538
|
+
// The first frame record whose timestamp is past the long frame's start:
|
|
539
|
+
// the interval the long frame begins in, or the next one.
|
|
540
|
+
let lo = 0;
|
|
541
|
+
let hi = frames.length;
|
|
542
|
+
while (lo < hi) {
|
|
543
|
+
const mid = (lo + hi) >> 1;
|
|
544
|
+
if (frames[mid].at > start) hi = mid; else lo = mid + 1;
|
|
545
|
+
}
|
|
546
|
+
let best = null;
|
|
547
|
+
if (end === start) {
|
|
548
|
+
// No duration to overlap with: the interval its start falls in.
|
|
549
|
+
if (lo < frames.length && from[lo] <= start) best = frames[lo];
|
|
550
|
+
} else {
|
|
551
|
+
// That one and every one after it that begins before the long frame
|
|
552
|
+
// ends; on a tie the earlier frame keeps it.
|
|
553
|
+
let bestOverlap = 0;
|
|
554
|
+
for (let i = lo; i < frames.length && i < lo + JOIN_SCAN && from[i] < end; i++) {
|
|
555
|
+
const overlap = Math.min(end, frames[i].at) - Math.max(start, from[i]);
|
|
556
|
+
if (overlap > bestOverlap) { best = frames[i]; bestOverlap = overlap; }
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
out.push({ conn, record, frame: best });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// The most ticks one `second` record can span: a second at the fastest tick
|
|
566
|
+
// rate a game runs, with room to spare. A longer claim is not a second.
|
|
567
|
+
const MAX_SECOND_TICKS = 1000;
|
|
568
|
+
|
|
569
|
+
// A second's ticks as a key: `second` records of different browsers can
|
|
570
|
+
// cover the same ticks, and each gets the count for its own.
|
|
571
|
+
const rangeKey = (from, to) => `${from}:${to}`;
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* How many of each entity kind the replayed world held over each second the
|
|
575
|
+
* performance track records — the `kind` rows' `alive` and `alive_max`. Fed
|
|
576
|
+
* the world once per tick as the replay walks forward (`see`); holds only one
|
|
577
|
+
* running sum and maximum per second and kind.
|
|
578
|
+
*
|
|
579
|
+
* @param {object[]} segments - the track segments
|
|
580
|
+
* @returns {{ see(tick: number, entities: {def: string}[]): void,
|
|
581
|
+
* stats(from: number, to: number): { ticks: number, sum: Map<string, number>, max: Map<string, number> } | null }}
|
|
582
|
+
*/
|
|
583
|
+
export function aliveCounter(segments) {
|
|
584
|
+
// The seconds each browser claims, by browser. A track is written by a
|
|
585
|
+
// browser, so its claims are checked before anything is counted over them:
|
|
586
|
+
// a span longer than any second's, or one overlapping the same browser's
|
|
587
|
+
// previous second, is dropped. What is left is at most one open second per
|
|
588
|
+
// browser at any tick, which is what keeps counting cheap.
|
|
589
|
+
const claimed = new Map();
|
|
590
|
+
for (const seg of segments ?? []) {
|
|
591
|
+
const producer = text(seg?.header?.producer) ?? '';
|
|
592
|
+
if (!claimed.has(producer)) claimed.set(producer, []);
|
|
593
|
+
for (const s of Array.isArray(seg?.seconds) ? seg.seconds : []) {
|
|
594
|
+
const from = Array.isArray(s?.ticks) ? num(s.ticks[0]) : null;
|
|
595
|
+
const to = Array.isArray(s?.ticks) ? num(s.ticks[1]) : null;
|
|
596
|
+
if (from === null || to === null || to < from || to - from > MAX_SECOND_TICKS) continue;
|
|
597
|
+
claimed.get(producer).push({ from, to });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const ranges = new Map();
|
|
601
|
+
for (const list of claimed.values()) {
|
|
602
|
+
list.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
603
|
+
let lastTo = -Infinity;
|
|
604
|
+
for (const { from, to } of list) {
|
|
605
|
+
if (from <= lastTo) continue;
|
|
606
|
+
lastTo = to;
|
|
607
|
+
const key = rangeKey(from, to);
|
|
608
|
+
if (!ranges.has(key)) ranges.set(key, { from, to, ticks: 0, sum: new Map(), max: new Map() });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Seconds in the order they start, walked alongside the replay: a tick only
|
|
612
|
+
// touches the seconds open at it.
|
|
613
|
+
const pending = [...ranges.values()].sort((a, b) => a.from - b.from);
|
|
614
|
+
let next = 0;
|
|
615
|
+
let open = [];
|
|
616
|
+
return {
|
|
617
|
+
see(tick, entities) {
|
|
618
|
+
while (next < pending.length && pending[next].from <= tick) open.push(pending[next++]);
|
|
619
|
+
open = open.filter((r) => r.to >= tick);
|
|
620
|
+
if (!open.length) return;
|
|
621
|
+
const here = new Map();
|
|
622
|
+
for (const e of entities) here.set(e.def, (here.get(e.def) ?? 0) + 1);
|
|
623
|
+
for (const r of open) {
|
|
624
|
+
r.ticks += 1;
|
|
625
|
+
// A kind missing from this tick counts 0 towards its mean: every kind
|
|
626
|
+
// seen in the second is divided by all of the second's ticks.
|
|
627
|
+
for (const [kind, n] of here) {
|
|
628
|
+
r.sum.set(kind, (r.sum.get(kind) ?? 0) + n);
|
|
629
|
+
r.max.set(kind, Math.max(r.max.get(kind) ?? 0, n));
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
stats(from, to) {
|
|
634
|
+
const r = ranges.get(rangeKey(from, to));
|
|
635
|
+
return r && r.ticks ? r : null;
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// One `second` record's `kinds` and `traits`, as a row per kind. Every kind
|
|
641
|
+
// the replayed world held during the second gets a row, whether or not it
|
|
642
|
+
// spent anything, so a count by kind is complete.
|
|
643
|
+
function kindRows(header, sec, alive) {
|
|
644
|
+
const ticks = Array.isArray(sec.ticks) && sec.ticks.length === 2 ? sec.ticks : [null, null];
|
|
645
|
+
const kinds = sec.kinds && typeof sec.kinds === 'object' && !Array.isArray(sec.kinds) ? sec.kinds : {};
|
|
646
|
+
const axes = new Map();
|
|
647
|
+
for (const t of Array.isArray(sec.traits) ? sec.traits : []) {
|
|
648
|
+
if (!t || typeof t !== 'object' || typeof t.comp !== 'string' || num(t.ms) === null) continue;
|
|
649
|
+
const a = axes.get(t.comp) ?? {};
|
|
650
|
+
if (t.axis === 'tick' || t.axis === 'present') a[t.axis] = (a[t.axis] ?? 0) + t.ms;
|
|
651
|
+
axes.set(t.comp, a);
|
|
652
|
+
}
|
|
653
|
+
// The world over the second's ticks, or null when the replay held none of
|
|
654
|
+
// them. Once counted, a kind with none is 0, not unknown.
|
|
655
|
+
const from = num(ticks[0]);
|
|
656
|
+
const to = num(ticks[1]);
|
|
657
|
+
const counted = from !== null && to !== null ? alive?.stats(from, to) ?? null : null;
|
|
658
|
+
const names = new Set([
|
|
659
|
+
...Object.keys(kinds).filter((k) => kinds[k] && typeof kinds[k] === 'object'),
|
|
660
|
+
...(counted?.max.keys() ?? []),
|
|
661
|
+
]);
|
|
662
|
+
const rows = [];
|
|
663
|
+
for (const kind of [...names].sort()) {
|
|
664
|
+
const k = kinds[kind] && typeof kinds[kind] === 'object' ? kinds[kind] : {};
|
|
665
|
+
rows.push({
|
|
666
|
+
row: 'kind',
|
|
667
|
+
conn: text(header.producer),
|
|
668
|
+
seq: num(header.seq),
|
|
669
|
+
at_ms: num(sec.at),
|
|
670
|
+
tick_from: num(ticks[0]),
|
|
671
|
+
tick_to: num(ticks[1]),
|
|
672
|
+
kind: text(kind, 128),
|
|
673
|
+
ms: num(k.ms),
|
|
674
|
+
tick_ms: num(axes.get(kind)?.tick),
|
|
675
|
+
present_ms: num(axes.get(kind)?.present),
|
|
676
|
+
trait_calls: num(k.calls),
|
|
677
|
+
entities_spent: num(k.entities),
|
|
678
|
+
alive: counted ? Math.round(((counted.sum.get(kind) ?? 0) / counted.ticks) * 10) / 10 : null,
|
|
679
|
+
alive_max: counted ? (counted.max.get(kind) ?? 0) : null,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
return rows;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* The performance table for a session's track segments, or null when the
|
|
687
|
+
* session carries no track — a recording made with measuring switched off, or
|
|
688
|
+
* by an engine from before the track existed. Null rather than an empty table
|
|
689
|
+
* so the caller can say the cost was never recorded, which is a different
|
|
690
|
+
* answer from a session that cost nothing.
|
|
691
|
+
*
|
|
692
|
+
* @param {object[]} segments - the track segments, in producer then seq order
|
|
693
|
+
* @param {{ alive?: ReturnType<typeof aliveCounter> }} [opts] - how many of each
|
|
694
|
+
* kind the replayed world held over each second
|
|
695
|
+
*/
|
|
696
|
+
export function perfTable(segments, { alive = null } = {}) {
|
|
697
|
+
if (!segments?.length) return null;
|
|
698
|
+
const rows = [];
|
|
699
|
+
for (const seg of segments) {
|
|
700
|
+
const header = seg?.header ?? {};
|
|
701
|
+
// Only records that ARE records: a track segment is whatever a browser
|
|
702
|
+
// posted, and `frames: {}` unguarded would fail the whole export over its
|
|
703
|
+
// optional third table.
|
|
704
|
+
const recs = (key) => (Array.isArray(seg?.[key]) ? seg[key] : []);
|
|
705
|
+
for (const f of recs('frames')) if (f && typeof f === 'object') rows.push(perfRow('frame', header, f));
|
|
706
|
+
for (const s of recs('seconds')) {
|
|
707
|
+
if (!s || typeof s !== 'object') continue;
|
|
708
|
+
rows.push(perfRow('second', header, s));
|
|
709
|
+
rows.push(...kindRows(header, s, alive));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
for (const { conn, record, frame } of joinLongFrames(segments)) {
|
|
713
|
+
const ticks = Array.isArray(frame?.ticks) && frame.ticks.length === 2 ? frame.ticks : [null, null];
|
|
714
|
+
// Only a long frame's own fields: a record that also carries a frame's
|
|
715
|
+
// keys (`sim`, `traits`) does not get to fill those columns.
|
|
716
|
+
const own = {
|
|
717
|
+
at: record.at, dt: record.duration, blocking: record.blocking,
|
|
718
|
+
renderStart: record.renderStart, styleAndLayoutStart: record.styleAndLayoutStart, scripts: record.scripts,
|
|
719
|
+
};
|
|
720
|
+
rows.push(perfRow('long_frame', { producer: conn }, own, ticks));
|
|
721
|
+
}
|
|
722
|
+
if (!rows.length) return null;
|
|
723
|
+
return { columns: PERF_KEYS.map((name) => ({ name, type: PERF_KEY_TYPES[name] })), rows };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ── the session info table ──────────────────────────────────────────────────
|
|
727
|
+
//
|
|
728
|
+
// The fourth table: what is true of the session as a whole, and of stretches
|
|
729
|
+
// of it, that no row of the other three can say. Which machine each browser
|
|
730
|
+
// measured on. Which ticks a browser measured nothing at all. Where the
|
|
731
|
+
// replay stopped matching what was played, so the state rows there describe a
|
|
732
|
+
// world nobody saw. None of it is a per-tick fact, and all of it changes what
|
|
733
|
+
// a per-tick number means, so it is a table a query joins rather than a line
|
|
734
|
+
// a reader has to remember.
|
|
735
|
+
//
|
|
736
|
+
// One table, told apart by `row`, like the performance table. Every row that
|
|
737
|
+
// covers ticks carries `tick_from` and `tick_to`, both inclusive, so any row
|
|
738
|
+
// kind joins another table the same way:
|
|
739
|
+
//
|
|
740
|
+
// LEFT JOIN info d ON d.row = 'diverged' AND s.tick BETWEEN d.tick_from AND d.tick_to
|
|
741
|
+
//
|
|
742
|
+
// The row kinds:
|
|
743
|
+
// session one row: the recording's span, tick rate, start time,
|
|
744
|
+
// player count, and which engine and game code recorded it
|
|
745
|
+
// against which replayed it
|
|
746
|
+
// player one per stay in the room: `conn`, and the ticks between
|
|
747
|
+
// joining and leaving
|
|
748
|
+
// browser one per browser that wrote a performance track: `conn` and
|
|
749
|
+
// the device it measured on
|
|
750
|
+
// unmeasured a stretch of the recording none of that browser's frames
|
|
751
|
+
// covered: the page was hidden, measuring was off, or its
|
|
752
|
+
// segments never arrived
|
|
753
|
+
// track_cut a browser's track stopped being stored at `tick_from`
|
|
754
|
+
// because the session's track reached its size limit
|
|
755
|
+
// diverged ticks where the replay's world is in doubt: after the last
|
|
756
|
+
// checksum or keyframe that matched and up to the next one
|
|
757
|
+
// unsupported an event the replay cannot reproduce (`what` says which);
|
|
758
|
+
// the world from `tick_from` on is not the one played
|
|
759
|
+
// missing_segment a recording segment that never reached storage; the
|
|
760
|
+
// inputs just before `tick_from` are missing
|
|
761
|
+
const INFO_COLUMNS = [
|
|
762
|
+
['row', 'STRING'], ['conn', 'STRING'], ['tick_from', 'DOUBLE'], ['tick_to', 'DOUBLE'], ['what', 'STRING'],
|
|
763
|
+
['stream', 'STRING'], ['started_at', 'STRING'], ['tick_rate', 'DOUBLE'], ['players', 'DOUBLE'],
|
|
764
|
+
['checksums_verified', 'DOUBLE'],
|
|
765
|
+
['recorded_engine', 'STRING'], ['replayed_engine', 'STRING'],
|
|
766
|
+
['recorded_code', 'STRING'], ['replayed_code', 'STRING'], ['code_changed', 'BOOLEAN'],
|
|
767
|
+
['recorded_build', 'STRING'], ['recorded_commit', 'STRING'], ['recorded_dirty', 'BOOLEAN'],
|
|
768
|
+
['replayed_build', 'STRING'], ['build_changed', 'BOOLEAN'],
|
|
769
|
+
['user_agent', 'STRING'], ['viewport_width', 'DOUBLE'], ['viewport_height', 'DOUBLE'],
|
|
770
|
+
['screen_dpr', 'DOUBLE'], ['cores', 'DOUBLE'], ['touch', 'BOOLEAN'], ['long_frames', 'BOOLEAN'], ['automated', 'BOOLEAN'],
|
|
771
|
+
['limit_bytes', 'DOUBLE'], ['detail', 'JSON'],
|
|
772
|
+
];
|
|
773
|
+
|
|
774
|
+
// Everything below is read off files anything that reaches the dev server can
|
|
775
|
+
// write, so each value is kept only when it is the type its column says. Text
|
|
776
|
+
// loses its control characters too: the table is read in a terminal, and
|
|
777
|
+
// DuckDB's csv and list modes print a cell's bytes as they are, so an escape
|
|
778
|
+
// sequence in a user agent would repaint the screen of whoever reads it.
|
|
779
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
780
|
+
const text = (v, max = 512) => (typeof v === 'string' ? v.replace(CONTROL, '').slice(0, max) : null);
|
|
781
|
+
const flag = (v) => (typeof v === 'boolean' ? v : null);
|
|
782
|
+
const positiveNum = (v) => (num(v) !== null && v > 0 ? v : null);
|
|
783
|
+
const whenOf = (ms) => {
|
|
784
|
+
if (num(ms) === null) return null;
|
|
785
|
+
const d = new Date(ms);
|
|
786
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
787
|
+
};
|
|
788
|
+
const recordsOf = (seg, key) => (Array.isArray(seg?.[key]) ? seg[key] : []);
|
|
789
|
+
const tickRangeOf = (f) => {
|
|
790
|
+
const t = f?.ticks;
|
|
791
|
+
return Array.isArray(t) && num(t[0]) !== null && num(t[1]) !== null ? [t[0], t[1]] : null;
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// A frame's tick range is what the sim stepped during it, so a frame drawn
|
|
795
|
+
// while the sim was between ticks leaves a tick or two uncovered in the
|
|
796
|
+
// ordinary course of playing. Stretches shorter than this are not reported.
|
|
797
|
+
const UNMEASURED_MIN_TICKS = 20;
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* The stretches of the recording, per browser that wrote a track, that none
|
|
801
|
+
* of its frames covered, as inclusive tick ranges. Only browsers that wrote
|
|
802
|
+
* SOMETHING: a browser with no track has no row here, and its missing
|
|
803
|
+
* `browser` row is what says so. A browser whose player the recording saw
|
|
804
|
+
* come and go is only unmeasured while that player was in the room.
|
|
805
|
+
*
|
|
806
|
+
* @param {Map<string, [number, number][]>} [staysByConn] - each player's stays
|
|
807
|
+
*/
|
|
808
|
+
export function unmeasuredStretches(segments, firstTick, endTick, staysByConn = new Map(), minTicks = UNMEASURED_MIN_TICKS) {
|
|
809
|
+
const byConn = new Map();
|
|
810
|
+
for (const seg of segments ?? []) {
|
|
811
|
+
const conn = text(seg?.header?.producer);
|
|
812
|
+
const covered = byConn.get(conn) ?? [];
|
|
813
|
+
for (const f of recordsOf(seg, 'frames')) {
|
|
814
|
+
const range = tickRangeOf(f);
|
|
815
|
+
if (range) covered.push(range);
|
|
816
|
+
}
|
|
817
|
+
byConn.set(conn, covered);
|
|
818
|
+
}
|
|
819
|
+
const out = [];
|
|
820
|
+
const keep = (conn, from, to) => {
|
|
821
|
+
const windows = staysByConn.get(conn) ?? [[firstTick, endTick]];
|
|
822
|
+
for (const [a, b] of windows) {
|
|
823
|
+
const lo = Math.max(from, a);
|
|
824
|
+
const hi = Math.min(to, b);
|
|
825
|
+
if (hi - lo + 1 >= minTicks) out.push({ conn, from: lo, to: hi });
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
for (const [conn, ranges] of byConn) {
|
|
829
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
830
|
+
// The last tick known measured; the stretch before the first frame starts
|
|
831
|
+
// at the session's first tick.
|
|
832
|
+
let cursor = firstTick - 1;
|
|
833
|
+
for (const [from, to] of ranges) {
|
|
834
|
+
if (from > cursor + 1) keep(conn, cursor + 1, from - 1);
|
|
835
|
+
cursor = Math.max(cursor, to);
|
|
836
|
+
}
|
|
837
|
+
if (endTick > cursor) keep(conn, cursor + 1, endTick);
|
|
838
|
+
}
|
|
839
|
+
return out;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Build the session info table.
|
|
844
|
+
*
|
|
845
|
+
* @param {object} o
|
|
846
|
+
* @param {string|null} o.stream
|
|
847
|
+
* @param {object} o.session - the stitched recording ({ header, events })
|
|
848
|
+
* @param {object} o.replayer - the replayer, AFTER it has run to the end
|
|
849
|
+
* @param {number} o.firstTick - the first tick the state table holds
|
|
850
|
+
* @param {number} o.endTick - the recording's last tick
|
|
851
|
+
* @param {object[]} o.segments - the performance track's segments
|
|
852
|
+
* @param {object|null} o.limits - what `readTrackLimits` returned
|
|
853
|
+
*/
|
|
854
|
+
export function infoTable({
|
|
855
|
+
stream, session, replayer, firstTick, endTick, segments = [], limits = null,
|
|
856
|
+
recordedEngine = null, runningEngine = null, recordedCode = null, runningCode = null, codeChanged = false,
|
|
857
|
+
recordedBuild = null, runningBuild = null, buildChanged = null,
|
|
858
|
+
}) {
|
|
859
|
+
const rows = [];
|
|
860
|
+
const header = session?.header ?? {};
|
|
861
|
+
const span = (from, to) => ({ tick_from: num(from) ?? firstTick, tick_to: num(to) ?? endTick });
|
|
862
|
+
|
|
863
|
+
// Stays in the room. A participant in the header was there when recording
|
|
864
|
+
// began; after that, the recording's join and leave events say who came and
|
|
865
|
+
// went.
|
|
866
|
+
const stays = [];
|
|
867
|
+
const open = new Map();
|
|
868
|
+
const participants = header.participants && typeof header.participants === 'object' ? header.participants : {};
|
|
869
|
+
for (const [key, info] of Object.entries(participants)) {
|
|
870
|
+
const conn = text(key);
|
|
871
|
+
if (conn) open.set(conn, { conn, from: firstTick, info });
|
|
872
|
+
}
|
|
873
|
+
for (const e of Array.isArray(session?.events) ? session.events : []) {
|
|
874
|
+
const conn = text(e?.connId);
|
|
875
|
+
if (!conn) continue;
|
|
876
|
+
if (e.k === 'jn' && !open.has(conn)) open.set(conn, { conn, from: num(e.t) ?? firstTick, info: e.info });
|
|
877
|
+
else if (e.k === 'lv' && open.has(conn)) {
|
|
878
|
+
stays.push({ ...open.get(conn), to: num(e.t) ?? endTick });
|
|
879
|
+
open.delete(conn);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
for (const stay of open.values()) stays.push({ ...stay, to: endTick });
|
|
883
|
+
stays.sort((a, b) => a.from - b.from);
|
|
884
|
+
|
|
885
|
+
rows.push({
|
|
886
|
+
row: 'session',
|
|
887
|
+
...span(firstTick, endTick),
|
|
888
|
+
stream: text(stream),
|
|
889
|
+
started_at: whenOf(header.startedAt),
|
|
890
|
+
tick_rate: positiveNum(header.tickRate),
|
|
891
|
+
players: new Set(stays.map((s) => s.conn)).size,
|
|
892
|
+
checksums_verified: num(replayer?.checksumsVerified),
|
|
893
|
+
recorded_engine: recordedEngine,
|
|
894
|
+
replayed_engine: runningEngine,
|
|
895
|
+
recorded_code: recordedCode,
|
|
896
|
+
replayed_code: runningCode,
|
|
897
|
+
code_changed: codeChanged,
|
|
898
|
+
recorded_build: recordedBuild?.id ?? null,
|
|
899
|
+
recorded_commit: recordedBuild?.git?.commit ?? null,
|
|
900
|
+
recorded_dirty: recordedBuild?.git?.dirty ?? null,
|
|
901
|
+
replayed_build: runningBuild?.id ?? null,
|
|
902
|
+
build_changed: buildChanged,
|
|
903
|
+
});
|
|
904
|
+
for (const s of stays) rows.push({ row: 'player', conn: s.conn, ...span(s.from, s.to), detail: json(s.info ?? null) });
|
|
905
|
+
|
|
906
|
+
// The device block is stamped on every segment a browser wrote; the first
|
|
907
|
+
// one carrying it wins, so a segment written before the navigator was
|
|
908
|
+
// readable erases nothing.
|
|
909
|
+
const devices = new Map();
|
|
910
|
+
for (const seg of segments ?? []) {
|
|
911
|
+
const conn = text(seg?.header?.producer);
|
|
912
|
+
const device = seg?.header?.device && typeof seg.header.device === 'object' ? seg.header.device : null;
|
|
913
|
+
if (!devices.has(conn) || (!devices.get(conn) && device)) devices.set(conn, device);
|
|
914
|
+
}
|
|
915
|
+
for (const [conn, d] of devices) {
|
|
916
|
+
const viewport = d?.viewport && typeof d.viewport === 'object' ? d.viewport : {};
|
|
917
|
+
rows.push({
|
|
918
|
+
row: 'browser',
|
|
919
|
+
conn,
|
|
920
|
+
user_agent: text(d?.userAgent),
|
|
921
|
+
viewport_width: positiveNum(viewport.width),
|
|
922
|
+
viewport_height: positiveNum(viewport.height),
|
|
923
|
+
screen_dpr: positiveNum(d?.screenDpr),
|
|
924
|
+
cores: positiveNum(d?.cores),
|
|
925
|
+
touch: flag(d?.touch),
|
|
926
|
+
long_frames: flag(d?.longFrames),
|
|
927
|
+
automated: flag(d?.automated),
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
const staysByConn = new Map();
|
|
931
|
+
for (const st of stays) staysByConn.set(st.conn, [...(staysByConn.get(st.conn) ?? []), [st.from, st.to]]);
|
|
932
|
+
for (const g of unmeasuredStretches(segments, firstTick, endTick, staysByConn)) {
|
|
933
|
+
rows.push({ row: 'unmeasured', conn: g.conn, tick_from: g.from, tick_to: g.to });
|
|
934
|
+
}
|
|
935
|
+
for (const cut of limits?.cuts ?? []) {
|
|
936
|
+
rows.push({
|
|
937
|
+
row: 'track_cut', conn: text(cut.producer), what: text(cut.kind),
|
|
938
|
+
...span(cut.fromTick, endTick), limit_bytes: positiveNum(limits.limitBytes),
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// A divergence is found at a checksum, some ticks after whatever caused it.
|
|
943
|
+
// The ticks in doubt start after the last tick playback matched (`since`)
|
|
944
|
+
// and end at the next one it matched again (`until`). A state row at tick t
|
|
945
|
+
// is the world BEFORE tick t's events, so the end depends on what matched:
|
|
946
|
+
// a checksum that verifies at t vouches for row t, and the range ends at
|
|
947
|
+
// t - 1; a keyframe restore that changed the world at t (a re-anchor)
|
|
948
|
+
// leaves row t drifted, and the range ends at t. An engine whose replayer
|
|
949
|
+
// does not say `since` or `until` is read the conservative way: from the
|
|
950
|
+
// start of the session, up to the first re-anchor at or after the
|
|
951
|
+
// divergence. Divergences that come out with one range are one row.
|
|
952
|
+
const reanchorTicks = new Set((replayer?.reanchors ?? []).map((r) => num(r?.tick)).filter((t) => t !== null));
|
|
953
|
+
const byRange = new Map();
|
|
954
|
+
for (const d of replayer?.divergences ?? []) {
|
|
955
|
+
const found = num(d?.tick);
|
|
956
|
+
let from = 'since' in (d ?? {}) ? (num(d.since) === null ? firstTick : d.since + 1) : firstTick;
|
|
957
|
+
if (found !== null) from = Math.min(from, found);
|
|
958
|
+
let to;
|
|
959
|
+
if ('until' in (d ?? {})) {
|
|
960
|
+
const until = num(d.until);
|
|
961
|
+
to = until === null ? endTick : reanchorTicks.has(until) ? until : until - 1;
|
|
962
|
+
} else {
|
|
963
|
+
to = [...reanchorTicks].filter((t) => found !== null && t >= found).sort((a, b) => a - b)[0] ?? endTick;
|
|
964
|
+
}
|
|
965
|
+
to = Math.max(from, to);
|
|
966
|
+
const key = `${from}:${to}`;
|
|
967
|
+
const seen = byRange.get(key);
|
|
968
|
+
if (seen) {
|
|
969
|
+
seen.checks += num(d?.checks) ?? 1;
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
byRange.set(key, {
|
|
973
|
+
from, to, via: text(d?.via) ?? 'checksum',
|
|
974
|
+
found_at: found, checks: num(d?.checks) ?? 1, expected: d?.expected ?? null, got: d?.got ?? null,
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
for (const r of byRange.values()) {
|
|
978
|
+
rows.push({
|
|
979
|
+
row: 'diverged', what: r.via, tick_from: r.from, tick_to: r.to,
|
|
980
|
+
detail: json({ found_at: r.found_at, checks: r.checks, expected: r.expected, got: r.got }),
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
for (const u of replayer?.unsupported ?? []) {
|
|
984
|
+
const { tick, kind, ...rest } = u ?? {};
|
|
985
|
+
rows.push({ row: 'unsupported', what: text(kind), ...span(tick, endTick), detail: json(rest) });
|
|
986
|
+
}
|
|
987
|
+
// The inputs a missing segment held never reached the replay, so nothing
|
|
988
|
+
// from where it was on is the world played. A later checksum that verified
|
|
989
|
+
// would narrow this, and the recording keeps no list of those.
|
|
990
|
+
for (const g of replayer?.gaps ?? []) {
|
|
991
|
+
rows.push({
|
|
992
|
+
row: 'missing_segment', what: `seq ${num(g?.afterSeq) ?? '?'}→${num(g?.beforeSeq) ?? '?'}`,
|
|
993
|
+
...span(g?.atTick, endTick),
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
return { columns: INFO_COLUMNS.map(([name, type]) => ({ name, type })), rows };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function schemaElements(columns) {
|
|
1000
|
+
return [
|
|
1001
|
+
{ name: 'root', num_children: columns.length },
|
|
1002
|
+
...columns.map((c) => ({ name: c.name, repetition_type: 'OPTIONAL', ...PARQUET_TYPE[c.type] })),
|
|
1003
|
+
];
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// A row-at-a-time front end over a columnar writer: rows go in, and every time
|
|
1007
|
+
// `rowsPerGroup` of them have arrived the batch is handed over as one row group
|
|
1008
|
+
// and dropped. Nothing accumulates across groups, which is the whole point —
|
|
1009
|
+
// materialising every column of every tick before writing is what made the
|
|
1010
|
+
// prototype cost 19 s and a gigabyte for a 27-second session.
|
|
1011
|
+
// A row group is held whole in memory before it is encoded, and its cost is
|
|
1012
|
+
// rows x COLUMNS — so a fixed row count means a 40-column game and a
|
|
1013
|
+
// 5,000-column one buffer two orders of magnitude apart. Budgeting cells keeps
|
|
1014
|
+
// the ceiling flat whatever the table's width. An explicit rowsPerGroup (a
|
|
1015
|
+
// test, a caller who knows) is obeyed as given.
|
|
1016
|
+
export const CELL_BUDGET = 8_000_000;
|
|
1017
|
+
export const DEFAULT_ROWS_PER_GROUP = 20_000;
|
|
1018
|
+
|
|
1019
|
+
function groupFor(columns, requested) {
|
|
1020
|
+
if (requested != null) return requested;
|
|
1021
|
+
return Math.max(500, Math.min(DEFAULT_ROWS_PER_GROUP, Math.floor(CELL_BUDGET / Math.max(1, columns.length))));
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Written under a temporary name and moved into place only once the file is
|
|
1025
|
+
// complete. The writer streams straight to disk, so a throw partway through
|
|
1026
|
+
// would otherwise leave a truncated file sitting at the canonical name — every
|
|
1027
|
+
// later read of which fails with "not a parquet file", and the previous good
|
|
1028
|
+
// export of that session is already gone. A partial answer under the name of a
|
|
1029
|
+
// whole one is the worst of the three outcomes.
|
|
1030
|
+
function rowSink({ file, columns, rowsPerGroup }) {
|
|
1031
|
+
const partial = `${file}.partial`;
|
|
1032
|
+
const writer = fileWriter(partial);
|
|
1033
|
+
const pq = new ParquetWriter({ writer, schema: schemaElements(columns) });
|
|
1034
|
+
// Name → column position, so a row is placed by index rather than by looking
|
|
1035
|
+
// each of the table's columns up on it. The table is WIDE and each row is
|
|
1036
|
+
// narrow — 443 columns, ~49 of them set on any given entity — so reading
|
|
1037
|
+
// every column name off every row costs a property lookup for the ~90% that
|
|
1038
|
+
// are absent. Filling nulls and writing only what is there is the same table
|
|
1039
|
+
// for about a third of the work.
|
|
1040
|
+
const at = new Map(columns.map((c, i) => [c.name, i]));
|
|
1041
|
+
let batch = columns.map(() => []);
|
|
1042
|
+
let rows = 0;
|
|
1043
|
+
const flush = () => {
|
|
1044
|
+
if (!batch[0].length) return;
|
|
1045
|
+
pq.write({
|
|
1046
|
+
columnData: columns.map((c, i) => ({ name: c.name, data: batch[i] })),
|
|
1047
|
+
// One group per batch: the batch IS the group, so the writer is never
|
|
1048
|
+
// asked to hold more than what has already been handed to it.
|
|
1049
|
+
rowGroupSize: batch[0].length,
|
|
1050
|
+
});
|
|
1051
|
+
batch = columns.map(() => []);
|
|
1052
|
+
};
|
|
1053
|
+
return {
|
|
1054
|
+
push(row) {
|
|
1055
|
+
const index = batch[0].length;
|
|
1056
|
+
for (let i = 0; i < columns.length; i++) batch[i].push(null);
|
|
1057
|
+
for (const name in row) {
|
|
1058
|
+
const i = at.get(name);
|
|
1059
|
+
if (i !== undefined && row[name] !== undefined) batch[i][index] = row[name];
|
|
1060
|
+
}
|
|
1061
|
+
rows += 1;
|
|
1062
|
+
if (batch[0].length >= rowsPerGroup) flush();
|
|
1063
|
+
},
|
|
1064
|
+
finish() {
|
|
1065
|
+
flush();
|
|
1066
|
+
pq.finish();
|
|
1067
|
+
renameSync(partial, file);
|
|
1068
|
+
return rows;
|
|
1069
|
+
},
|
|
1070
|
+
// Called when the run did not get that far. Best-effort: a leftover
|
|
1071
|
+
// `.partial` is untidy, an unreadable `.parquet` is a wrong answer.
|
|
1072
|
+
abandon() {
|
|
1073
|
+
try { rmSync(partial, { force: true }); } catch { /* nothing to clean up */ }
|
|
1074
|
+
},
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** A streaming Parquet writer for a table of `columns`, sized as the export sizes its own. */
|
|
1079
|
+
export function rowSinkFor(file, columns, rowsPerGroup) {
|
|
1080
|
+
return rowSink({ file, columns, rowsPerGroup: groupFor(columns, rowsPerGroup) });
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Where the engine's headless replay door lives, in the engine THIS GAME is
|
|
1085
|
+
* pinned to.
|
|
1086
|
+
*
|
|
1087
|
+
* Imported from the resolved bundle rather than by a relative path, for the
|
|
1088
|
+
* same reason the recording's reassembler is: a game can sit on a new CLI and
|
|
1089
|
+
* an older engine, and the honest answer to that is a sentence saying so — not
|
|
1090
|
+
* a module-resolution stack trace that reads, to the person holding it, as "my
|
|
1091
|
+
* recording is broken".
|
|
1092
|
+
*/
|
|
1093
|
+
export async function loadOpenReplay(sharedDir, forWhat = 'export') {
|
|
1094
|
+
const at = join(sharedDir, 'framework', 'replay-open.mjs');
|
|
1095
|
+
try {
|
|
1096
|
+
return (await import(pathToFileURL(at).href)).openReplay;
|
|
1097
|
+
} catch (e) {
|
|
1098
|
+
throw new Error(
|
|
1099
|
+
`this game's pinned engine cannot open a recording headlessly, so there is nothing to ${forWhat} — `
|
|
1100
|
+
+ `run \`joystack update\` and try again (${e?.message ?? e})`,
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// A version or code fingerprint as a recording's header states it, or null
|
|
1106
|
+
// when the value is not one. A header is not first-party text (see
|
|
1107
|
+
// `exportSession`), so a value that is not a plain version-or-digest token is
|
|
1108
|
+
// treated as unknown — a state every reader already handles honestly — rather
|
|
1109
|
+
// than sanitized and printed. The charset covers semver including pre-release
|
|
1110
|
+
// labels (`0.2.0-riven`) and hex digests, and admits no control character,
|
|
1111
|
+
// escape or whitespace.
|
|
1112
|
+
//
|
|
1113
|
+
// `__PUBLISH_VERSION__` is the placeholder the release build substitutes. A
|
|
1114
|
+
// room served straight off a checkout never went through that build, so every
|
|
1115
|
+
// session recorded under `joystack dev` carries the token verbatim. Read as a
|
|
1116
|
+
// version it would print garbage, and would report a mismatch on every single
|
|
1117
|
+
// dev export — a warning that fires every time is one nobody reads by the
|
|
1118
|
+
// third time.
|
|
1119
|
+
const VERSION_TOKEN = /^[A-Za-z0-9._+-]{1,64}$/;
|
|
1120
|
+
export const knownHeaderToken = (v) =>
|
|
1121
|
+
(typeof v === 'string' && VERSION_TOKEN.test(v) && v !== '__PUBLISH_VERSION__' ? v : null);
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Export one recorded session as Parquet tables: state, inputs,
|
|
1125
|
+
* performance (when the session was measured), session info, and the fields
|
|
1126
|
+
* table that says what the state columns and channels are.
|
|
1127
|
+
*
|
|
1128
|
+
* The state table is the expensive one and the reason this exists: a recording
|
|
1129
|
+
* holds inputs, so the only way to know where anything WAS is to run the game
|
|
1130
|
+
* forward again. Doing that once and writing the result down is what turns
|
|
1131
|
+
* every later question into SQL instead of another replay.
|
|
1132
|
+
*
|
|
1133
|
+
* @param {object} opts
|
|
1134
|
+
* @param {string} opts.dir - the game folder
|
|
1135
|
+
* @param {string} [opts.stream] - which recording; defaults to the most recent
|
|
1136
|
+
* @param {string} opts.outDir - where the .parquet files go
|
|
1137
|
+
* @param {string} [opts.sharedDir] - the resolved engine bundle's shared/ tree
|
|
1138
|
+
* @param {number} [opts.rowsPerGroup]
|
|
1139
|
+
* @param {string} [opts.engineVersion] - the engine this export is running on,
|
|
1140
|
+
* compared against the one that recorded the session
|
|
1141
|
+
* @param {(o: object) => Promise<object>} [opts.openReplayImpl] - seam for tests
|
|
1142
|
+
* @param {(msg: string) => void} [opts.log]
|
|
1143
|
+
*/
|
|
1144
|
+
export async function exportSession({
|
|
1145
|
+
dir, stream, outDir, sharedDir, rowsPerGroup, engineVersion, openReplayImpl, log = () => {},
|
|
1146
|
+
}) {
|
|
1147
|
+
const openReplay = openReplayImpl ?? await loadOpenReplay(sharedDir);
|
|
1148
|
+
// The engine's own warnings — a recording missing segments, a session tuned
|
|
1149
|
+
// live mid-play — are written to this sink. Without it they go to the floor
|
|
1150
|
+
// and the export looks clean while describing a world that is not the one
|
|
1151
|
+
// that was played.
|
|
1152
|
+
const opened = await openReplay({ dir, stream, log });
|
|
1153
|
+
const { replayer, bundle, session } = opened;
|
|
1154
|
+
const chosen = opened.stream ?? stream ?? null;
|
|
1155
|
+
|
|
1156
|
+
// Which engine wrote this recording, against the one about to replay it. The
|
|
1157
|
+
// header has carried engineVersion since the first recording and nothing read
|
|
1158
|
+
// it, so the only way a reader learned the two disagreed was a divergence —
|
|
1159
|
+
// which does not fire if the change altered nothing the checksums cover.
|
|
1160
|
+
//
|
|
1161
|
+
// Reported before the replay runs, not after: the replay is the slow part and
|
|
1162
|
+
// this answer is already known. An absent version is UNKNOWN, not different —
|
|
1163
|
+
// every recording made before this shipped has none, and warning on those
|
|
1164
|
+
// would fire on every old session still on disk.
|
|
1165
|
+
// A recording's header is NOT first-party text. Segments reach the dev server
|
|
1166
|
+
// over a lane that deliberately does not require an Origin (a multiplayer
|
|
1167
|
+
// room's worker sends none), and the store validates only the four fields it
|
|
1168
|
+
// routes on — format, seq, stream, events. Everything else is spread through
|
|
1169
|
+
// the stitcher verbatim, so anything printed from a header is attacker-
|
|
1170
|
+
// reachable by whoever can post a segment.
|
|
1171
|
+
//
|
|
1172
|
+
// Printing is what makes that matter. `ESC[2J ESC[H` clears the screen and
|
|
1173
|
+
// repaints, and the line being repainted here is the one a creator — or the
|
|
1174
|
+
// coding agent reading their terminal — uses to decide whether to trust the
|
|
1175
|
+
// exported numbers. An export with no session named takes the newest
|
|
1176
|
+
// recording, so a segment posted a moment ago is the default target.
|
|
1177
|
+
//
|
|
1178
|
+
// So: REJECT, don't sanitize — `knownHeaderToken` above.
|
|
1179
|
+
// The build stamp the dev server wrote beside the recording (replay-store.mjs,
|
|
1180
|
+
// "the build stamp"), and the build this folder holds now. Both are absent
|
|
1181
|
+
// for a recording copied in without its stamp or made before stamps
|
|
1182
|
+
// existed, and then nothing is claimed about whether the files changed.
|
|
1183
|
+
const recordedBuild = chosen ? readBuildStamp(dir, chosen) : null;
|
|
1184
|
+
let runningBuild = null;
|
|
1185
|
+
try { runningBuild = gameBuild(dir, { git: false }); } catch { /* not a folder of game files */ }
|
|
1186
|
+
const buildChanged = recordedBuild?.id && runningBuild?.id ? recordedBuild.id !== runningBuild.id : null;
|
|
1187
|
+
// A room served by `joystack dev` carries a placeholder where a published one
|
|
1188
|
+
// carries its engine version; the dev server's stamp names the engine it
|
|
1189
|
+
// served.
|
|
1190
|
+
const recordedEngine = knownHeaderToken(session?.header?.engineVersion) ?? recordedBuild?.engine ?? null;
|
|
1191
|
+
const runningEngine = knownHeaderToken(engineVersion);
|
|
1192
|
+
if (recordedEngine && runningEngine && recordedEngine !== runningEngine) {
|
|
1193
|
+
log(` ⚠ this session was recorded by engine ${recordedEngine} and is being replayed on `
|
|
1194
|
+
+ `${runningEngine}. A replay runs the code as it is now, so where the two engines `
|
|
1195
|
+
+ 'simulate differently the table holds a world nobody played.');
|
|
1196
|
+
}
|
|
1197
|
+
// And the same question for the game's own code, which is the half that
|
|
1198
|
+
// actually moves. engineVersion changes once per release; a fingerprint
|
|
1199
|
+
// changes the moment a tick does.
|
|
1200
|
+
// Same provenance, same rule: this one comes off the header too.
|
|
1201
|
+
const recordedCode = knownHeaderToken(session?.header?.codeFingerprint);
|
|
1202
|
+
const runningCode = knownHeaderToken(bundle?.fingerprint);
|
|
1203
|
+
const codeChanged = Boolean(recordedCode && runningCode && recordedCode !== runningCode);
|
|
1204
|
+
if (codeChanged) {
|
|
1205
|
+
log(' ⚠ this game has changed since the session was recorded (the simulation it lowers to '
|
|
1206
|
+
+ `is ${runningCode}; the recording was made by ${recordedCode}). The table below is what `
|
|
1207
|
+
+ 'your game does with those inputs TODAY, which is not what happened when you played.');
|
|
1208
|
+
} else if (buildChanged) {
|
|
1209
|
+
// The files moved while the simulation's shape did not: a number inside a
|
|
1210
|
+
// tick, a helper's body. The shape check cannot see that; the build can.
|
|
1211
|
+
log(` ⚠ this game's files have changed since the session was recorded (build ${recordedBuild.id}, `
|
|
1212
|
+
+ `now ${runningBuild.id}), though not the shape of its simulation. The table below is what your `
|
|
1213
|
+
+ 'game does with those inputs TODAY, which may not be what happened when you played.');
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
const { columns, conflicts } = tableSchema(bundle);
|
|
1217
|
+
if (conflicts.length) {
|
|
1218
|
+
log(`note: ${conflicts.length} field name(s) are declared with different types by different entity kinds `
|
|
1219
|
+
+ `and ride as JSON text — ${conflicts.join(', ')}`);
|
|
1220
|
+
}
|
|
1221
|
+
const columnIndex = new Map(columns.map((c) => [c.name, c.type]));
|
|
1222
|
+
|
|
1223
|
+
mkdirSync(outDir, { recursive: true });
|
|
1224
|
+
const stateFile = join(outDir, `${chosen ?? 'session'}.state.parquet`);
|
|
1225
|
+
const inputFile = join(outDir, `${chosen ?? 'session'}.inputs.parquet`);
|
|
1226
|
+
|
|
1227
|
+
// Read before the replay: the seconds its `second` records cover are where
|
|
1228
|
+
// the replay counts each kind alive, for the perf table's `kind` rows.
|
|
1229
|
+
const segments = chosen ? readTrackSegments(dir, chosen) : [];
|
|
1230
|
+
const alive = aliveCounter(segments);
|
|
1231
|
+
|
|
1232
|
+
const sink = rowSinkFor(stateFile, columns, rowsPerGroup);
|
|
1233
|
+
const tally = { undoubleable: 0 };
|
|
1234
|
+
let ticks = 0;
|
|
1235
|
+
let firstTick = null;
|
|
1236
|
+
let lastTick = null;
|
|
1237
|
+
let rows;
|
|
1238
|
+
try {
|
|
1239
|
+
// The world BEFORE any step is a real tick of the session — it is the
|
|
1240
|
+
// keyframe the replay entered at — so it is written before the loop, not
|
|
1241
|
+
// skipped into it.
|
|
1242
|
+
for (;;) {
|
|
1243
|
+
const { tick, entities } = replayer.runtime.serializeEntities();
|
|
1244
|
+
firstTick ??= tick;
|
|
1245
|
+
lastTick = tick;
|
|
1246
|
+
for (const entity of entities) sink.push(stateRow(entity, tick, columnIndex, tally));
|
|
1247
|
+
alive.see(tick, entities);
|
|
1248
|
+
ticks += 1;
|
|
1249
|
+
if (replayer.atEnd) break;
|
|
1250
|
+
replayer.step();
|
|
1251
|
+
}
|
|
1252
|
+
rows = sink.finish();
|
|
1253
|
+
} catch (e) {
|
|
1254
|
+
sink.abandon();
|
|
1255
|
+
throw e;
|
|
1256
|
+
}
|
|
1257
|
+
// The new state table is in place, so a previous export's perf and info
|
|
1258
|
+
// tables of this session no longer describe it. They go now rather than
|
|
1259
|
+
// being overwritten later: the perf table is written only when there is a
|
|
1260
|
+
// track, and a run that fails before the info table is written would leave
|
|
1261
|
+
// the old one beside the new state. A run that fails before this point
|
|
1262
|
+
// leaves the previous export whole.
|
|
1263
|
+
for (const name of ['perf', 'info', 'fields']) rmSync(join(outDir, `${chosen ?? 'session'}.${name}.parquet`), { force: true });
|
|
1264
|
+
|
|
1265
|
+
// Streamed like the state table rather than materialised: the row count comes
|
|
1266
|
+
// from a recording, and a recording is not this machine's to trust.
|
|
1267
|
+
const inputs = inputSchema(session);
|
|
1268
|
+
const inputSink = rowSinkFor(inputFile, inputs.columns, rowsPerGroup);
|
|
1269
|
+
let inputRows;
|
|
1270
|
+
try {
|
|
1271
|
+
for (const send of inputs.sends) inputSink.push(inputRow(send, inputs.keys, inputs.types));
|
|
1272
|
+
inputRows = inputSink.finish();
|
|
1273
|
+
} catch (e) {
|
|
1274
|
+
inputSink.abandon();
|
|
1275
|
+
throw e;
|
|
1276
|
+
}
|
|
1277
|
+
// The third table: what the session cost on the device that played it
|
|
1278
|
+
// (project note ofta45). Read straight off the track segments — nothing here
|
|
1279
|
+
// is replayed, because cost is the one thing a replay cannot reproduce.
|
|
1280
|
+
const perf = perfTable(segments, { alive });
|
|
1281
|
+
let perfFile = null;
|
|
1282
|
+
let perfRows = 0;
|
|
1283
|
+
if (perf) {
|
|
1284
|
+
perfFile = join(outDir, `${chosen ?? 'session'}.perf.parquet`);
|
|
1285
|
+
const perfSink = rowSinkFor(perfFile, perf.columns, rowsPerGroup);
|
|
1286
|
+
try {
|
|
1287
|
+
for (const row of perf.rows) perfSink.push(row);
|
|
1288
|
+
perfRows = perfSink.finish();
|
|
1289
|
+
} catch (e) {
|
|
1290
|
+
perfSink.abandon();
|
|
1291
|
+
throw e;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// The fourth table, written last because two of its row kinds — where the
|
|
1296
|
+
// replay diverged, what it could not reproduce — are only known once the
|
|
1297
|
+
// replay has run to the end.
|
|
1298
|
+
const info = infoTable({
|
|
1299
|
+
stream: chosen, session, replayer,
|
|
1300
|
+
firstTick: firstTick ?? 0,
|
|
1301
|
+
endTick: num(session?.endTick) ?? lastTick ?? 0,
|
|
1302
|
+
segments,
|
|
1303
|
+
limits: chosen ? readTrackLimits(dir, chosen) : null,
|
|
1304
|
+
recordedEngine, runningEngine, recordedCode, runningCode, codeChanged,
|
|
1305
|
+
recordedBuild, runningBuild, buildChanged,
|
|
1306
|
+
});
|
|
1307
|
+
const infoFile = join(outDir, `${chosen ?? 'session'}.info.parquet`);
|
|
1308
|
+
const infoSink = rowSinkFor(infoFile, info.columns, rowsPerGroup);
|
|
1309
|
+
let infoRows;
|
|
1310
|
+
try {
|
|
1311
|
+
for (const row of info.rows) infoSink.push(row);
|
|
1312
|
+
infoRows = infoSink.finish();
|
|
1313
|
+
} catch (e) {
|
|
1314
|
+
infoSink.abandon();
|
|
1315
|
+
throw e;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// What each state column is. Read off the lowered game, like the state
|
|
1319
|
+
// table's schema, so it describes the columns the state table actually has.
|
|
1320
|
+
const fields = fieldsTable(bundle, { dir, sharedDir });
|
|
1321
|
+
const fieldsFile = join(outDir, `${chosen ?? 'session'}.fields.parquet`);
|
|
1322
|
+
const fieldsSink = rowSinkFor(fieldsFile, fields.columns, rowsPerGroup);
|
|
1323
|
+
let fieldsRows;
|
|
1324
|
+
try {
|
|
1325
|
+
for (const row of fields.rows) fieldsSink.push(row);
|
|
1326
|
+
fieldsRows = fieldsSink.finish();
|
|
1327
|
+
} catch (e) {
|
|
1328
|
+
fieldsSink.abandon();
|
|
1329
|
+
throw e;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
if (inputs.truncatedKeys) {
|
|
1333
|
+
log(`note: ${inputs.truncatedKeys} rarely-used payload field(s) did not get their own column `
|
|
1334
|
+
+ `(the cap is ${MAX_PAYLOAD_COLUMNS}) — they are still in the \`payload\` JSON column on every row`);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
return {
|
|
1338
|
+
stream: chosen,
|
|
1339
|
+
stateFile,
|
|
1340
|
+
inputFile,
|
|
1341
|
+
// Null when the session carries no performance track: measuring was off,
|
|
1342
|
+
// or the recording predates the track. The caller says so rather than
|
|
1343
|
+
// implying the session was free.
|
|
1344
|
+
perfFile,
|
|
1345
|
+
perfRows,
|
|
1346
|
+
infoFile,
|
|
1347
|
+
infoRows,
|
|
1348
|
+
fieldsFile,
|
|
1349
|
+
fieldsRows,
|
|
1350
|
+
ticks,
|
|
1351
|
+
rows,
|
|
1352
|
+
columns: columns.length,
|
|
1353
|
+
inputs: inputRows,
|
|
1354
|
+
truncatedKeys: inputs.truncatedKeys,
|
|
1355
|
+
// Three separate ways the table can describe a world that is not the one
|
|
1356
|
+
// that was played, kept apart because they have different causes and
|
|
1357
|
+
// different answers. The ticks each covers are in the info table.
|
|
1358
|
+
divergences: replayer.divergences?.length ?? 0,
|
|
1359
|
+
// One per `diverged` row: the ticks one drift covers, how it was found,
|
|
1360
|
+
// and how many checksums failed inside it.
|
|
1361
|
+
drifts: info.rows.filter((r) => r.row === 'diverged').map((r) => {
|
|
1362
|
+
let checks;
|
|
1363
|
+
const detail = r.detail && typeof r.detail === 'object' ? r.detail : null;
|
|
1364
|
+
checks = Number.isSafeInteger(detail?.checks) ? detail.checks : null;
|
|
1365
|
+
return { from: r.tick_from, to: r.tick_to, via: r.what, checks };
|
|
1366
|
+
}),
|
|
1367
|
+
gaps: replayer.gaps?.length ?? 0,
|
|
1368
|
+
unsupported: replayer.unsupported?.length ?? 0,
|
|
1369
|
+
recordedEngine,
|
|
1370
|
+
runningEngine,
|
|
1371
|
+
recordedCode,
|
|
1372
|
+
runningCode,
|
|
1373
|
+
codeChanged,
|
|
1374
|
+
recordedBuild: recordedBuild?.id ?? null,
|
|
1375
|
+
runningBuild: runningBuild?.id ?? null,
|
|
1376
|
+
buildChanged,
|
|
1377
|
+
undoubleable: tally.undoubleable,
|
|
1378
|
+
};
|
|
1379
|
+
}
|