@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,308 @@
|
|
|
1
|
+
// `joystack replay list` — what each recorded session is, read off its files
|
|
2
|
+
// without replaying anything: when it was played and for how long, who was in
|
|
3
|
+
// it, which browsers measured it and on what device, and which code recorded
|
|
4
|
+
// it (the engine version, the code fingerprint, and the build id with its
|
|
5
|
+
// commit). These are the facts that decide which session a question is about,
|
|
6
|
+
// and whether an export of it describes the game as it is now.
|
|
7
|
+
//
|
|
8
|
+
// And, for each browser that measured, how it went: how many frames were late
|
|
9
|
+
// and where its worst stutter in play was, read off the performance track by
|
|
10
|
+
// the rules the recorded-session method in the debugging docs defines — so a
|
|
11
|
+
// session can be chosen without exporting every candidate. Why a stutter
|
|
12
|
+
// happened stays a question for the exported tables.
|
|
13
|
+
import { lstatSync, readdirSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { listRecordings, streamDir } from './replay-store.mjs';
|
|
16
|
+
import { knownHeaderToken } from './replay-export.mjs';
|
|
17
|
+
import { gameBuild } from './build-id.mjs';
|
|
18
|
+
|
|
19
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
20
|
+
const clean = (v, max = 80) => (typeof v === 'string' ? v.replace(CONTROL, '').slice(0, max) : null);
|
|
21
|
+
const RECORDING_FILE = /^\d{6}\.json$/;
|
|
22
|
+
const TRACK_FILE = /^perf\.([a-z0-9][a-z0-9-]{0,63})\.(\d{6})\.json$/;
|
|
23
|
+
|
|
24
|
+
// Recording files can be copied in from anywhere, so only a plain file of a
|
|
25
|
+
// size a segment can have is read: a link to a device or a pipe would hang
|
|
26
|
+
// the list, and a huge file would stall it.
|
|
27
|
+
const MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
28
|
+
const readJson = (file) => {
|
|
29
|
+
try {
|
|
30
|
+
const st = lstatSync(file);
|
|
31
|
+
if (!st.isFile() || st.size > MAX_FILE_BYTES) return null;
|
|
32
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// The largest time a Date can hold, in milliseconds either side of 1970.
|
|
39
|
+
const MAX_DATE_MS = 8.64e15;
|
|
40
|
+
const isoOf = (ms) => (Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS ? new Date(ms).toISOString() : null);
|
|
41
|
+
const tickOf = (v) => (Number.isSafeInteger(v) ? v : null);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A user agent as the device and browser a person would name: "iPhone, iOS
|
|
45
|
+
* 18.7, Safari 26.5", "Mac, Chrome 153". Unrecognised text is returned cut
|
|
46
|
+
* short and without control characters.
|
|
47
|
+
*/
|
|
48
|
+
export function shortDevice(ua) {
|
|
49
|
+
if (typeof ua !== 'string' || !ua) return null;
|
|
50
|
+
let device = null;
|
|
51
|
+
let m;
|
|
52
|
+
if ((m = /\((iPhone|iPad|iPod)[^)]*?OS (\d+)[_.](\d+)/.exec(ua))) device = `${m[1]}, iOS ${m[2]}.${m[3]}`;
|
|
53
|
+
else if ((m = /Android (\d+(?:\.\d+)?)/.exec(ua))) device = `Android ${m[1]}`;
|
|
54
|
+
else if (/Macintosh/.test(ua)) device = 'Mac';
|
|
55
|
+
else if (/Windows/.test(ua)) device = 'Windows';
|
|
56
|
+
else if (/Linux|X11/.test(ua)) device = 'Linux';
|
|
57
|
+
let browser = null;
|
|
58
|
+
if ((m = /Edg\/(\d+)/.exec(ua))) browser = `Edge ${m[1]}`;
|
|
59
|
+
else if ((m = /Firefox\/(\d+)/.exec(ua))) browser = `Firefox ${m[1]}`;
|
|
60
|
+
else if ((m = /(?:Chrome|CriOS)\/(\d+)/.exec(ua))) browser = `Chrome ${m[1]}`;
|
|
61
|
+
else if ((m = /Version\/(\d+(?:\.\d+)?).*Safari/.exec(ua))) browser = `Safari ${m[1]}`;
|
|
62
|
+
if (!device && !browser) return clean(ua, 60);
|
|
63
|
+
return [device, browser].filter(Boolean).join(', ');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A stutter is late frames less than this far apart, in the measuring
|
|
67
|
+
// browser's milliseconds.
|
|
68
|
+
const STUTTER_GAP_MS = 500;
|
|
69
|
+
// The first seconds after a page loads are always slow — shaders compile, the
|
|
70
|
+
// world spawns — and a player sees that once, as loading. The worst stutter
|
|
71
|
+
// reported is the worst one starting at or past this mark.
|
|
72
|
+
const LOADING_S = 2;
|
|
73
|
+
|
|
74
|
+
// The screen's refresh interval, read off the frames: the shortest interval
|
|
75
|
+
// the browser drew at repeatedly, taken as the mean of every interval within
|
|
76
|
+
// REFRESH_WINDOW_MS above it. "Repeatedly" is at least REFRESH_SHARE of the
|
|
77
|
+
// frames and never fewer than REFRESH_MIN_FRAMES, so a few stray callbacks
|
|
78
|
+
// close together do not set it, even in a recording of a few dozen frames
|
|
79
|
+
// where one frame in a hundred rounds up to one. The window is
|
|
80
|
+
// what keeps a clock of whole milliseconds (16, 17, 16, …) reading as one
|
|
81
|
+
// refresh. A device that is slow for the whole session still draws its cheap
|
|
82
|
+
// frames on the next refresh, which is why the median frame is not the screen's.
|
|
83
|
+
const REFRESH_WINDOW_MS = 1.5;
|
|
84
|
+
const REFRESH_SHARE = 0.01;
|
|
85
|
+
const REFRESH_MIN_FRAMES = 3;
|
|
86
|
+
function refreshInterval(xs) {
|
|
87
|
+
const v = xs.filter((x) => Number.isFinite(x) && x > 0).sort((a, b) => a - b);
|
|
88
|
+
if (!v.length) return null;
|
|
89
|
+
const need = Math.max(REFRESH_MIN_FRAMES, Math.ceil(v.length * REFRESH_SHARE));
|
|
90
|
+
let hi = 0;
|
|
91
|
+
let sum = 0;
|
|
92
|
+
for (let lo = 0; lo < v.length; lo++) {
|
|
93
|
+
while (hi < v.length && v[hi] <= v[lo] + REFRESH_WINDOW_MS) sum += v[hi++];
|
|
94
|
+
if (hi - lo >= need) return sum / (hi - lo);
|
|
95
|
+
sum -= v[lo];
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const round = (x, places) => Math.round(x * 10 ** places) / 10 ** places;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Per browser, how many of its frames were late and its worst stutter in
|
|
103
|
+
* play, from the perf table's frame rows. The rules are the debugging method's:
|
|
104
|
+
* a frame's work is its phases summed, never `wait_ms`; the budget is the
|
|
105
|
+
* screen's refresh interval (`refreshInterval`); a frame is late when its work passed the
|
|
106
|
+
* budget by more than a millisecond or its interval passed one and a half
|
|
107
|
+
* budgets; late frames less than half a second apart are one stutter, ranked
|
|
108
|
+
* by how far their frames ran over in all. A frame that ran no tick is placed
|
|
109
|
+
* at the last tick it had seen.
|
|
110
|
+
*
|
|
111
|
+
* @param {object[]} rows - perf table rows
|
|
112
|
+
* @param {{ startTick?: number, tickRate?: number|null }} clock - the session's
|
|
113
|
+
* first tick and tick rate, which turn ticks into seconds of play
|
|
114
|
+
* @returns {{ conn: string, frames: number, late: number, budgetMs: number|null,
|
|
115
|
+
* worstStutter: { fromS: number|null, toS: number|null, tickFrom: number|null, tickTo: number|null,
|
|
116
|
+
* frames: number, overMs: number } | null }[]}
|
|
117
|
+
*/
|
|
118
|
+
export function lateFrames(rows, { startTick = 0, tickRate = null } = {}) {
|
|
119
|
+
const byConn = new Map();
|
|
120
|
+
for (const r of rows ?? []) {
|
|
121
|
+
if (r?.row !== 'frame' || typeof r.conn !== 'string' || !Number.isFinite(r.at_ms)) continue;
|
|
122
|
+
if (!byConn.has(r.conn)) byConn.set(r.conn, []);
|
|
123
|
+
byConn.get(r.conn).push(r);
|
|
124
|
+
}
|
|
125
|
+
const seconds = (tick) => (tick !== null && tickRate > 0 ? round((tick - startTick) / tickRate, 2) : null);
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const [conn, frames] of byConn) {
|
|
128
|
+
frames.sort((a, b) => a.at_ms - b.at_ms);
|
|
129
|
+
const budget = refreshInterval(frames.map((f) => f.dt));
|
|
130
|
+
let tick = null;
|
|
131
|
+
let late = 0;
|
|
132
|
+
const stutters = [];
|
|
133
|
+
let cur = null;
|
|
134
|
+
for (const f of frames) {
|
|
135
|
+
if (Number.isFinite(f.tick_to)) tick = f.tick_to;
|
|
136
|
+
if (budget === null) continue;
|
|
137
|
+
const busy = ['tick_ms', 'present_ms', 'render_ms', 'sim_ms', 'other_ms'].reduce((n, k) => n + (Number.isFinite(f[k]) ? f[k] : 0), 0);
|
|
138
|
+
const dt = Number.isFinite(f.dt) ? f.dt : 0;
|
|
139
|
+
if (!(busy > budget + 1 || dt > 1.5 * budget)) continue;
|
|
140
|
+
late += 1;
|
|
141
|
+
if (!cur || f.at_ms - cur.lastAt > STUTTER_GAP_MS) {
|
|
142
|
+
cur = { tickFrom: null, tickTo: null, frames: 0, overMs: 0, lastAt: f.at_ms };
|
|
143
|
+
stutters.push(cur);
|
|
144
|
+
}
|
|
145
|
+
// The lowest and highest tick, as the method's min() and max() read
|
|
146
|
+
// them: a browser's ticks need not rise frame by frame.
|
|
147
|
+
if (tick !== null) {
|
|
148
|
+
cur.tickFrom = cur.tickFrom === null ? tick : Math.min(cur.tickFrom, tick);
|
|
149
|
+
cur.tickTo = cur.tickTo === null ? tick : Math.max(cur.tickTo, tick);
|
|
150
|
+
}
|
|
151
|
+
cur.frames += 1;
|
|
152
|
+
cur.overMs += Math.max(busy, dt) - budget;
|
|
153
|
+
cur.lastAt = f.at_ms;
|
|
154
|
+
}
|
|
155
|
+
let worst = null;
|
|
156
|
+
for (const st of stutters) {
|
|
157
|
+
const fromS = seconds(st.tickFrom);
|
|
158
|
+
if (fromS === null || fromS < LOADING_S) continue;
|
|
159
|
+
if (!worst || st.overMs > worst.overMs) worst = st;
|
|
160
|
+
}
|
|
161
|
+
out.push({
|
|
162
|
+
conn, frames: frames.length, late, budgetMs: budget === null ? null : round(budget, 1),
|
|
163
|
+
worstStutter: worst && {
|
|
164
|
+
fromS: seconds(worst.tickFrom), toS: seconds(worst.tickTo), tickFrom: worst.tickFrom, tickTo: worst.tickTo,
|
|
165
|
+
frames: worst.frames, overMs: round(worst.overMs, 1),
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The browsers that wrote a cost track, each described by the header of its
|
|
173
|
+
// earliest segment (every segment of one browser carries the same device).
|
|
174
|
+
function devicesOf(dir, names) {
|
|
175
|
+
const first = new Map();
|
|
176
|
+
for (const name of names) {
|
|
177
|
+
const m = TRACK_FILE.exec(name);
|
|
178
|
+
if (!m) continue;
|
|
179
|
+
const [, producer, seq] = m;
|
|
180
|
+
if (!first.has(producer) || seq < first.get(producer).seq) first.set(producer, { seq, name });
|
|
181
|
+
}
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const [conn, { name }] of first) {
|
|
184
|
+
const d = readJson(join(dir, name))?.header?.device ?? {};
|
|
185
|
+
const vw = d.viewport?.width;
|
|
186
|
+
const vh = d.viewport?.height;
|
|
187
|
+
out.push({
|
|
188
|
+
conn,
|
|
189
|
+
device: shortDevice(d.userAgent),
|
|
190
|
+
cores: Number.isFinite(d.cores) ? d.cores : null,
|
|
191
|
+
touch: typeof d.touch === 'boolean' ? d.touch : null,
|
|
192
|
+
viewport: Number.isFinite(vw) && Number.isFinite(vh) ? `${vw}x${vh}` : null,
|
|
193
|
+
dpr: Number.isFinite(d.screenDpr) ? d.screenDpr : null,
|
|
194
|
+
automated: typeof d.automated === 'boolean' ? d.automated : null,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return out.sort((a, b) => (a.conn < b.conn ? -1 : a.conn > b.conn ? 1 : 0));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// How much of one session's performance track the list reads, in bytes of
|
|
201
|
+
// segment files. A track is written by browsers — in multiplayer, other
|
|
202
|
+
// people's — and can be copied in from anywhere, so the list reads a bounded
|
|
203
|
+
// amount and keeps only the few numbers a late-frame count needs. An hour of
|
|
204
|
+
// play on one device is well inside it; past it, the counts cover the start
|
|
205
|
+
// of the track and say so.
|
|
206
|
+
export const TRACK_BUDGET_BYTES = 128 * 1024 * 1024;
|
|
207
|
+
|
|
208
|
+
const PHASES = ['tick', 'present', 'render', 'sim', 'other'];
|
|
209
|
+
const finite = (v) => (Number.isFinite(v) ? v : null);
|
|
210
|
+
|
|
211
|
+
// A session's frames, as the minimal rows `lateFrames` reads, in segment
|
|
212
|
+
// order. Each browser is named by its segment files' names — the name the
|
|
213
|
+
// device list uses — never by what a header inside claims.
|
|
214
|
+
function trackFrames(dir, names, budgetBytes) {
|
|
215
|
+
const rows = [];
|
|
216
|
+
const cut = new Set();
|
|
217
|
+
let spent = 0;
|
|
218
|
+
// Segment by segment in time, every browser's first segment before any
|
|
219
|
+
// browser's second, so a budget that runs out cuts every browser at about
|
|
220
|
+
// the same moment rather than leaving the last one unread.
|
|
221
|
+
const files = names.map((n) => TRACK_FILE.exec(n)).filter(Boolean)
|
|
222
|
+
.sort((a, b) => (a[2] === b[2] ? (a[1] < b[1] ? -1 : 1) : a[2] < b[2] ? -1 : 1));
|
|
223
|
+
for (const [name, conn] of files) {
|
|
224
|
+
let size = 0;
|
|
225
|
+
try { size = lstatSync(join(dir, name)).size; } catch { continue; }
|
|
226
|
+
if (cut.size || spent + size > budgetBytes) {
|
|
227
|
+
// Past the budget nothing more is read, so every browser with a
|
|
228
|
+
// segment left is counted in part.
|
|
229
|
+
cut.add(conn);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
spent += size;
|
|
233
|
+
const seg = readJson(join(dir, name));
|
|
234
|
+
for (const f of Array.isArray(seg?.frames) ? seg.frames : []) {
|
|
235
|
+
if (!f || typeof f !== 'object') continue;
|
|
236
|
+
const row = { row: 'frame', conn, at_ms: finite(f.at), dt: finite(f.dt),
|
|
237
|
+
tick_to: Array.isArray(f.ticks) && f.ticks.length === 2 ? finite(f.ticks[1]) : null };
|
|
238
|
+
for (const p of PHASES) row[`${p}_ms`] = finite(f[p]);
|
|
239
|
+
rows.push(row);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { rows, cut };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Who was in the room: named in a segment's header, or joined during it.
|
|
246
|
+
function playersOf(dir, names) {
|
|
247
|
+
const conns = new Set();
|
|
248
|
+
let header = null;
|
|
249
|
+
for (const name of names.filter((n) => RECORDING_FILE.test(n)).sort()) {
|
|
250
|
+
const seg = readJson(join(dir, name));
|
|
251
|
+
if (!seg) continue;
|
|
252
|
+
header ??= seg.header ?? null;
|
|
253
|
+
const participants = seg.header?.participants;
|
|
254
|
+
if (participants && typeof participants === 'object') for (const key of Object.keys(participants)) conns.add(key);
|
|
255
|
+
for (const e of Array.isArray(seg.events) ? seg.events : []) {
|
|
256
|
+
if (e?.k === 'jn' && typeof e.connId === 'string') conns.add(e.connId);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { players: conns.size, header };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Every recorded session in a game folder, newest first.
|
|
264
|
+
* @param {string} dir - the game folder
|
|
265
|
+
* @param {{ trackBudgetBytes?: number }} [opts] - how much of each session's
|
|
266
|
+
* performance track to read
|
|
267
|
+
*/
|
|
268
|
+
export function listSessions(dir, { trackBudgetBytes = TRACK_BUDGET_BYTES } = {}) {
|
|
269
|
+
const recordings = listRecordings(dir);
|
|
270
|
+
if (!recordings.length) return [];
|
|
271
|
+
let current = null;
|
|
272
|
+
try { current = gameBuild(dir, { git: false }).id; } catch { /* not a folder of game files */ }
|
|
273
|
+
return recordings.map((r) => {
|
|
274
|
+
const sdir = streamDir(dir, r.stream);
|
|
275
|
+
let names = [];
|
|
276
|
+
try { names = readdirSync(sdir); } catch { /* removed while listing */ }
|
|
277
|
+
const { players, header } = playersOf(sdir, names);
|
|
278
|
+
const clock = { startTick: tickOf(r.startTick) ?? 0, tickRate: Number.isFinite(r.tickRate) ? r.tickRate : null };
|
|
279
|
+
const measured = devicesOf(sdir, names);
|
|
280
|
+
const track = measured.length ? trackFrames(sdir, names, trackBudgetBytes) : { rows: [], cut: new Set() };
|
|
281
|
+
const went = new Map(lateFrames(track.rows, clock).map((x) => [x.conn, x]));
|
|
282
|
+
const devices = measured.map((d) => {
|
|
283
|
+
const { conn: _, ...how } = went.get(d.conn) ?? { frames: 0, late: 0, budgetMs: null, worstStutter: null };
|
|
284
|
+
// True when the track went past what the list reads: the counts are of its start.
|
|
285
|
+
return { ...d, ...how, partial: track.cut.has(d.conn) };
|
|
286
|
+
});
|
|
287
|
+
const build = r.build?.id ?? null;
|
|
288
|
+
return {
|
|
289
|
+
stream: r.stream,
|
|
290
|
+
startedAt: isoOf(r.startedAt),
|
|
291
|
+
seconds: Number.isFinite(r.durationMs) ? Math.round(r.durationMs / 100) / 10 : null,
|
|
292
|
+
ticks: tickOf(r.startTick) != null && tickOf(r.endTick) != null ? [r.startTick, r.endTick] : null,
|
|
293
|
+
players,
|
|
294
|
+
measured: devices.length > 0,
|
|
295
|
+
devices,
|
|
296
|
+
watchable: r.watchable,
|
|
297
|
+
// A room served by `joystack dev` carries a placeholder where a published
|
|
298
|
+
// one carries its engine version; the dev server's stamp names it.
|
|
299
|
+
engine: knownHeaderToken(header?.engineVersion) ?? r.build?.engine ?? null,
|
|
300
|
+
code: knownHeaderToken(header?.codeFingerprint),
|
|
301
|
+
build,
|
|
302
|
+
commit: r.build?.git?.commit ?? null,
|
|
303
|
+
uncommitted: r.build?.git?.dirty ?? null,
|
|
304
|
+
buildIsCurrent: build && current ? build === current : null,
|
|
305
|
+
kept: r.kept,
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
}
|