@homie-rocks/studio 0.46.2 → 0.46.5

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/rules/view.ts CHANGED
@@ -47,9 +47,9 @@ import { createNetplay } from '../netplay/netplay.ts';
47
47
  import type { Netplay, NetplayOptions, RulesHostFactory, RoundInfo, Snapshot, StepEntry } from '../netplay/netplay.ts';
48
48
  import { lab } from '../lab/lab.ts';
49
49
  import { exposePort, type PortProbeOptions } from '../port/probe.ts';
50
- import { BudgetError } from './guard.ts';
51
- import { moveContext } from './math.ts';
52
- import { coerce, dir, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
50
+ import { BudgetError, brand } from './guard.ts';
51
+ import { math, moveContext } from './math.ts';
52
+ import { coerce, coerceFields, dir, initFields, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
53
53
  import type { Unpacked } from './pack.ts';
54
54
  import { compileMap } from './rules.ts';
55
55
  import type { FieldList, MoveFn, Schema, Vec3 } from './rules.ts';
@@ -95,14 +95,50 @@ export interface OpenRoomOptions {
95
95
  game?: GameData;
96
96
  move?: Record<string, MoveFn>;
97
97
  net?: NetplayOptions;
98
+ /**
99
+ * Predict the player's OWN actions (0.46.3), by kind: `predict: { runner(me, input, ctx) { ... } }`. The room runs it
100
+ * for the own body once a predicted tick, after `move`, with that tick's input, and again on a copy between ticks so
101
+ * a press answers on the next drawn frame. `me` holds the body's declared fields and may be written: `room.me` then
102
+ * reads the predicted values. Every snapshot puts the server's values back and the pending inputs are run again,
103
+ * exactly as the own position is. `ctx.ghost` shows a provisional entity and `ctx.effect` a provisional effect until
104
+ * the server confirms or the room retracts them (`room.on('retract', ...)`). The server stays the authority.
105
+ */
106
+ predict?: Record<string, Predictor>;
98
107
  /** Camera axes on the rules' x/y plane for the testing tools. Default: x right, y up. */
99
108
  screenBasis?: PortProbeOptions['basis'];
100
109
  /** Tests: a clock in ms and a way to run without timers. */
101
110
  now?: () => number;
102
111
  timers?: boolean;
103
112
  }
113
+ /** What a predictor (`openRoom({ predict })`) is handed beside the body and the tick's input. */
114
+ export interface PredictContext extends RayQueries {
115
+ readonly tick: number; readonly dt: number;
116
+ /** The same deterministic `math` a `move` is handed (0.46.5): a helper shared with the rules can be called with `ctx`. */
117
+ readonly math: typeof math;
118
+ /**
119
+ * The tick the server will judge this player's ray against when the browser says it aimed at `aimTick` (0.46.5):
120
+ * `aimTick`, held to how far back this seat may look right now (the server says so in every snapshot) and to this
121
+ * tick. Pass it to `ctx.ray(..., { atTick })`: the other bodies are then posed as the server will pose them, from
122
+ * the snapshots this browser holds (between two of them, or carried on from the newest along its velocity).
123
+ */
124
+ judgedTick(aimTick: number): number;
125
+ /** The tick the other bodies are drawn at: what the player is looking at. */
126
+ readonly viewTick: number;
127
+ /** This run draws ahead of the tick; the tick's own run follows. What it shows is shown once. */
128
+ readonly preview: boolean;
129
+ /** A provisional entity, drawn by `room.each` with `provisional: true` until an entity of that kind which `match` accepts (default: within a metre of it) arrives, or the server has run the tick without one. Returns its id. */
130
+ ghost(kind: string, at: { x: number; y: number; z?: number }, fields?: Record<string, unknown>, options?: { seconds?: number; match?: (e: Entity) => boolean; key?: string }): string;
131
+ /**
132
+ * A provisional effect: listeners hear it now with `predicted: true`. The server's own effect of that name which `match` accepts (default: the next one) then arrives with `confirms: true`; if the server ran the tick without one, listeners of `retract` hear it.
133
+ * A tick's prediction runs more than once (a preview between ticks, again whenever the input changes, then the tick itself) and shows each thing once: things are told apart by their name and `key`, so give two effects of one name that mean different things (two sounds) a `key` each.
134
+ */
135
+ effect(name: string, at: { x: number; y: number; z?: number } | string, data?: Record<string, unknown>, options?: { match?: (e: any) => boolean; key?: string }): void;
136
+ }
137
+ export type Predictor = (me: any, input: any, ctx: PredictContext) => void;
104
138
  export interface Room<R = unknown> extends RayQueries {
105
139
  readonly viewTick: number;
140
+ /** Where a body was at a tick, from the snapshots this browser holds (0.46.5); `known: false` past the newest one. */
141
+ poseAt(id: string, tick: number): { pos: Vec3; vel: Vec3; known: boolean } | null;
106
142
  readonly status: RoomStatus;
107
143
  readonly seat: number | null;
108
144
  readonly me: Entity | null;
@@ -159,11 +195,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
159
195
 
160
196
  let status: RoomStatus = 'connecting';
161
197
  let latest: Frame | null = null;
198
+ /** The snapshots this browser holds, oldest first: two seconds of them (see poseAt). */
199
+ const held2: Frame[] = [];
162
200
  let epoch = 0;
163
201
  let round: RoomRound | null = null;
164
202
  let results: RoundInfo['results'] | undefined;
165
203
  let closed = false;
166
- const fxQueue: { tick: number; name: string; at: unknown; data: Record<string, unknown> }[] = [];
204
+ const fxQueue: { tick: number; name: string; at: unknown; data: Record<string, unknown>; confirms?: boolean }[] = [];
167
205
  const setStatus = (s: RoomStatus): void => { if (s !== status) { status = s; emit('status', s); } };
168
206
 
169
207
  /* ---------------------------------------------------------------- frames in */
@@ -186,7 +224,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
186
224
 
187
225
  /* ---------------------------------------------------------------- the own body, on this browser's copy of the clock */
188
226
 
189
- interface Mine { id: string; kind: string; r: number; pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }
227
+ interface Mine { id: string; kind: string; r: number; pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown>; fields: Record<string, unknown> }
190
228
  let mine: Mine | null = null;
191
229
  /** The clock: the room tick this browser is on at `at` (ms), and the newest step it has run. */
192
230
  let base: { tick: number; at: number } | null = null;
@@ -203,8 +241,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
203
241
  let entries: { t: number; row: number[] }[] = [];
204
242
  let hiddenSent = false;
205
243
  const history = new Map<number, Mine>();
206
- let pending: { t: number; input: Readonly<Record<string, unknown>> }[] = [];
244
+ let pending: { t: number; input: Readonly<Record<string, unknown>>; pulses?: Record<string, number> }[] = [];
245
+ /** Valued pulses sent, numbered: the server's control row names the newest it has acted on (0.46.3). */
246
+ let pulseCount = 0;
247
+ let pulseNumber = new Map<string, number>();
207
248
  let replayHeld: Readonly<Record<string, unknown>> = Object.freeze({});
249
+ /** `f.held()` values as the server last had them: those of the newest entry it has acknowledged. */
250
+ let serverHeld: Record<string, number> = {};
208
251
  let speed = 1;
209
252
  let leadAfter = 0;
210
253
  let observedLeadAt = 0;
@@ -294,7 +337,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
294
337
  leadAfter = clock() + 2 * rtt + period;
295
338
  base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
296
339
  stepped = Math.floor(base.tick) - 1;
297
- entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map();
340
+ entries = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
298
341
  speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
299
342
  const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
300
343
  if (mine) {
@@ -305,7 +348,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
305
348
  /** `motion` as this browser's own `move` may change it in place: what a snapshot carries is frozen, so every list, map and struct in it is copied. */
306
349
  const motionOf = (kindName: string, motion: Record<string, unknown>): Record<string, unknown> => thawFields(kindOf.get(kindName)?.motion ?? [], motion);
307
350
  function adopt(u: Unpacked): void {
308
- mine = { id: u.id, kind: u.kind, r: u.r, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, motion: motionOf(u.kind, u.motion) };
351
+ mine = { id: u.id, kind: u.kind, r: u.r, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, motion: motionOf(u.kind, u.motion), fields: predictors[u.kind] ? thawFields(kindOf.get(u.kind)?.fields ?? [], u.fields) : u.fields };
309
352
  }
310
353
  /** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
311
354
  function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body {
@@ -321,8 +364,120 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
321
364
  out.pos = vec3({ x: Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, out.pos.x)), y: Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, out.pos.y)), z: dims === 3 ? Math.max(map.bounds.min.z, Math.min(map.bounds.max.z - height, out.pos.z)) : 0 }, dims);
322
365
  return out;
323
366
  }
367
+ /* ---------------------------------------------------------------- the own actions, predicted (0.46.3) */
368
+
369
+ const predictors = opts.predict ?? {};
370
+ interface Ghost { id: string; kind: string; tick: number; pos: Vec3; fields: Record<string, unknown>; until: number; match?: (e: Entity) => boolean; /** The server's entity that confirmed it: the ghost is drawn until that one is. */ real?: string }
371
+ const ghosts = new Map<string, Ghost>();
372
+ let ghostSeq = 0;
373
+ let provisional: { name: string; tick: number; payload: Record<string, unknown>; match?: (e: any) => boolean }[] = [];
374
+ /** What a tick's prediction has already shown, by tick and by the order it asked: a preview and the tick's own run show a thing once. */
375
+ const shown = new Map<number, Set<string>>();
376
+ let previewKey = '', previewFields: Record<string, unknown> | null = null;
377
+ const ghostEntity = (g: Ghost): Entity => Object.freeze({ id: g.id, kind: g.kind, pos: g.pos, vel: ZERO3, heading: ZERO3, grounded: false, provisional: true, ...g.fields, motion: Object.freeze({}) }) as Entity;
378
+ const ZERO3: Vec3 = Object.freeze({ x: 0, y: 0, z: 0 });
379
+ function dropPredictions(): void {
380
+ for (const g of ghosts.values()) if (!g.real) emit('retract', { ghost: g.id, kind: g.kind, at: g.pos, tick: g.tick });
381
+ for (const x of provisional) emit('retract', { ...x.payload, effect: x.name, tick: x.tick });
382
+ ghosts.clear(); provisional = []; shown.clear(); previewKey = ''; previewFields = null;
383
+ }
384
+ /**
385
+ * The game's predictor for one tick of the own body. `live` is false while a snapshot's pending inputs are run again:
386
+ * that run only brings the fields up to date, and shows nothing.
387
+ */
388
+ function runPredict(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, fields: Record<string, unknown>, input: Readonly<Record<string, unknown>>, t: number, live: boolean, preview: boolean): void {
389
+ const fn = predictors[kindName], kind = kindOf.get(kindName);
390
+ if (!fn || !kind || !mine) return;
391
+ const id = mine.id;
392
+ // What a run asks to show is told apart by what it is (the name and the game's `key`), then by how many of that this
393
+ // run has asked for: never by the order of everything asked, which changes when another action joins the tick.
394
+ const asked = new Map<string, number>();
395
+ const once = (what: string): boolean => { if (!live) return false; let set = shown.get(t); if (!set) { set = new Set(); shown.set(t, set); } const n = asked.get(what) ?? 0; asked.set(what, n + 1); const key = what + '#' + n; if (set.has(key)) return false; set.add(key); return true; };
396
+ // Branded as a world is (0.46.5): a guarded helper of the rules may call its queries, so the server's own aiming
397
+ // code can be run on it instead of being restated in the view.
398
+ const ctx: PredictContext = brand({
399
+ tick: t, dt: 1 / tickHz, preview, math,
400
+ judgedTick: (aimTick) => Math.max(t - rewindAllowance(), Math.min(t, Number(aimTick) || 0)),
401
+ get viewTick() { return viewTickNow(); },
402
+ ray: (from, direction, max, options) => viewRay(from, direction, max, options)[0],
403
+ rays: (options) => raySnapshot(map, viewTargets(atTickOf(options)), dims, options, id),
404
+ rayAll: (from, direction, max, options) => viewRay(from, direction, max, options, true),
405
+ ghost(kindOfGhost, at, data = {}, options = {}) {
406
+ const k = kindOf.get(kindOfGhost);
407
+ if (!k) { console.warn(`[room] predict: no kind "${kindOfGhost}"`); return ''; }
408
+ if (!once('g:' + kindOfGhost + ':' + (options.key ?? ''))) return '';
409
+ const g: Ghost = { id: `~${++ghostSeq}`, kind: kindOfGhost, tick: t, pos: vec3(at, dims), fields: coerceFields(k.fields, { ...initFields(k.fields, dims), ...data }, dims), until: clock() + (options.seconds ?? 2) * 1000, match: options.match };
410
+ ghosts.set(g.id, g);
411
+ emit('enter', ghostEntity(g));
412
+ return g.id;
413
+ },
414
+ effect(name, at, data = {}, options = {}) {
415
+ if (!schema.effects[name]) { console.warn(`[room] predict: no effect "${name}" is declared in shapes.effects`); return; }
416
+ if (!once('e:' + name + ':' + (options.key ?? ''))) return;
417
+ const target = typeof at === 'string' ? at : null;
418
+ const payload = { ...data, at: target ? get(target)?.pos ?? null : vec3(at as Vec3, dims), ...(target ? { id: target } : {}), tick: t };
419
+ // The server's own effect may already be here, waiting for its tick to be drawn (it acted before this browser
420
+ // stepped the tick, and its snapshot is still on the way): that one is this prediction's confirmation.
421
+ const waiting = fxQueue.find((x) => x.name === name && !x.confirms && (!options.match || safe(() => options.match!({ ...x.data, ...(typeof x.at === 'string' ? { id: x.at } : { at: unpackVec(x.at, dims) }), tick: x.tick }))));
422
+ if (waiting) waiting.confirms = true; else provisional.push({ name, tick: t, payload, match: options.match });
423
+ emit(name, Object.freeze({ ...payload, predicted: true }));
424
+ },
425
+ });
426
+ const me = Object.assign(fields, { id, kind: kindName }) as Record<string, unknown>;
427
+ for (const [key, value] of Object.entries({ pos: body.pos, vel: body.vel, heading: body.heading, grounded: body.grounded, motion: body.motion })) Object.defineProperty(me, key, { value, enumerable: false, configurable: true, writable: true });
428
+ try { fn(me, input, ctx); } catch (err) { console.warn('[room] predict', err); }
429
+ // What it wrote is held to the declared types, as the server holds its own writes.
430
+ for (const [name, fd] of kind.fields) fields[name] = coerce(fd, fields[name], dims);
431
+ for (const key of Object.keys(fields)) if (!kind.fields.some(([name]) => name === key)) delete fields[key];
432
+ }
433
+ /** After a snapshot: a ghost or a provisional effect whose tick the server has run without confirming it is taken back. */
434
+ function settlePredictions(f: Frame, before: Frame | null, ack: number): void {
435
+ if (ghosts.size) {
436
+ for (const [id, u] of f.ents) {
437
+ if (before?.ents.has(id)) continue;
438
+ const e = entityOf(u);
439
+ for (const g of ghosts.values()) if (!g.real && g.kind === u.kind && (g.match ? safe(() => g.match!(e)) : distance(u.pos, g.pos) <= 1)) { g.real = u.id; g.until = Infinity; emit('confirm', { ghost: g.id, id: u.id, kind: g.kind, tick: g.tick }); break; }
440
+ }
441
+ const now = clock();
442
+ for (const g of [...ghosts.values()]) if (g.real ? !f.ents.has(g.real) : (ack >= g.tick && f.k >= g.tick) || now >= g.until) { ghosts.delete(g.id); if (!g.real) emit('retract', { ghost: g.id, kind: g.kind, at: g.pos, tick: g.tick }); emit('leave', ghostEntity(g)); }
443
+ }
444
+ if (provisional.length) {
445
+ const gone = provisional.filter((x) => ack >= x.tick && f.k > x.tick);
446
+ if (gone.length) { provisional = provisional.filter((x) => !gone.includes(x)); for (const x of gone) emit('retract', Object.freeze({ ...x.payload, effect: x.name })); }
447
+ }
448
+ for (const t of shown.keys()) if (t < f.k - tickHz) shown.delete(t);
449
+ }
450
+ const safe = (fn: () => boolean): boolean => { try { return fn() === true; } catch { return false; } };
451
+
324
452
  /** The input values the last step held (a press is never held). */
325
453
  let held: Readonly<Record<string, unknown>> = Object.freeze({});
454
+ /*
455
+ * `f.held()` inputs (0.46.3). A step takes one value a tick, read at the tick's edge: a key down for 60 ms of a 50 ms
456
+ * tick then moves the body for one tick or for two, by where the edges fell. A held field is instead the MEAN of what
457
+ * the control read over the tick's own span, so the body moves for the part of the tick the key was down, the same on
458
+ * the server as here. Each field keeps its changes since the last edge it was read up to: [when (ms), value from then].
459
+ */
460
+ const heldTrack = new Map<string, { from: number; changes: [number, number][] }>();
461
+ const heldNumber = (v: unknown, init: unknown): number => { const x = v === undefined ? init : v; return x === true ? 1 : typeof x === 'number' && Number.isFinite(x) ? x : 0; };
462
+ /** When tick `t` begins on this browser's clock, in ms. */
463
+ const edgeOf = (t: number): number => (base ? base.at + (t - base.tick) * period / speed : clock());
464
+ /** The mean of a held field from its last edge to `end`. `advance` moves the edge there; without it the tick is not over, and what is left of it (to `fill`) counts at the value the control reads now. */
465
+ function heldMean(name: string, init: unknown, end: number, advance: boolean, fill = end): number {
466
+ const now = heldNumber(sample[name], init);
467
+ let tr = heldTrack.get(name);
468
+ if (!tr) { tr = { from: end, changes: [[end, now]] }; heldTrack.set(name, tr); }
469
+ const span = fill - tr.from;
470
+ let sum = 0, value = tr.changes[0][1];
471
+ if (span > 0.001) {
472
+ let at = tr.from;
473
+ for (const [when, next] of tr.changes) { const w = Math.min(Math.max(when, tr.from), end); if (w > at) { sum += value * (w - at); at = w; } if (when <= end) value = next; }
474
+ if (end > at) sum += value * (end - at);
475
+ if (fill > end) sum += now * (fill - end);
476
+ }
477
+ const mean = span > 0.001 ? sum / span : now;
478
+ if (advance) { tr.changes = [[end, value], ...tr.changes.filter((c) => c[0] > end)]; tr.from = end; }
479
+ return mean;
480
+ }
326
481
  /** One step of the own body on tick `t`: the input step, `move`, and an entry when the sample or the claim changed. */
327
482
  function step(t: number, fresh = true): void {
328
483
  const kind = myKind();
@@ -331,17 +486,20 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
331
486
  const input: Record<string, unknown> = {};
332
487
  let press = false;
333
488
  for (const [name, fd] of kind.input) {
334
- if (fd.t === 'press' || fd.t === 'pulse') { const on = fresh ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : (fd.t === 'press' ? false : 0); input[name] = on; values.push(fd.t === 'pulse' ? 0 : Number(on)); if (on) press = true; } else { const v = coerce(fd, (fresh ? sample[name] : held[name]) ?? fd.init, dims); input[name] = v; values.push(v === true ? 1 : v === false ? 0 : v as number); }
489
+ if (fd.t === 'press' || fd.t === 'pulse') { const on = fresh ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : (fd.t === 'press' ? false : 0); input[name] = on; values.push(fd.t === 'pulse' ? 0 : Number(on)); if (on) press = true; } else if (fd.t === 'held') { const v = coerce(fd, heldMean(name, fd.init, edgeOf(t), true), dims) as number; input[name] = v; values.push(v); } else { const v = coerce(fd, (fresh ? sample[name] : held[name]) ?? fd.init, dims); input[name] = v; values.push(v === true ? 1 : v === false ? 0 : v as number); }
335
490
  }
336
- if (fresh) pressed = new Map();
491
+ const numbered = fresh && pulseNumber.size ? Object.fromEntries(pulseNumber) : undefined;
492
+ if (fresh) { pressed = new Map(); pulseNumber = new Map(); }
337
493
  held = Object.freeze({ ...input });
338
494
  {
339
495
  const body = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: mine.motion }, held, t);
340
496
  mine.pos = body.pos; mine.vel = body.vel; mine.heading = body.heading; mine.grounded = body.grounded; mine.motion = body.motion;
497
+ runPredict(kind.name, body, mine.fields, held, t, true, false);
498
+ previewKey = '';
341
499
  }
342
500
  const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
343
501
  const sig = JSON.stringify([values, claim]);
344
- if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); pending.push({ t, input: held }); lastSent = sig; }
502
+ if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); pending.push({ t, input: held, ...(numbered ? { pulses: numbered } : {}) }); lastSent = sig; }
345
503
  history.set(t, copyMine(mine));
346
504
  for (const k of history.keys()) if (k < t - tickHz && (catchTick === null || k < Math.floor(catchTick))) history.delete(k);
347
505
  }
@@ -363,12 +521,18 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
363
521
  function keepalive(t: number): void {
364
522
  const kind = myKind();
365
523
  if (!mine || !kind) return;
366
- const values = kind.input.map(([name, fd]) => ((fd.t === 'press' || fd.t === 'pulse') ? 0 : Number(coerce(fd, sample[name] ?? fd.init, dims))));
524
+ const values = kind.input.map(([name, fd]) => ((fd.t === 'press' || fd.t === 'pulse') ? 0 : fd.t === 'held' ? Number(coerce(fd, heldNumber(sample[name], fd.init), dims)) : Number(coerce(fd, sample[name] ?? fd.init, dims))));
367
525
  const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
368
526
  entries = [{ t, row: [...values, ...claim] }]; pending.push({ t, input: Object.fromEntries(kind.input.map(([name, fd], i) => [name, fd.t === 'press' ? false : values[i]])) }); lastSent = JSON.stringify([values, claim]);
369
527
  }
528
+ let pumping = false;
370
529
  function pump(): void {
371
- if (closed) return;
530
+ // A predictor's own queries read the room, and reading the room pumps: a step is never begun inside a step.
531
+ if (closed || pumping) return;
532
+ pumping = true;
533
+ try { pumpOnce(); } finally { pumping = false; }
534
+ }
535
+ function pumpOnce(): void {
372
536
  if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline');
373
537
  const now = clock();
374
538
  checkSolo(now);
@@ -379,7 +543,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
379
543
  if (hidden) {
380
544
  // A hidden tab's timers are too slow to keep input alive, and a body must not run on without its player:
381
545
  // one neutral entry at once (every field at its init, no press), then no more steps until the tab shows again.
382
- if (!hiddenSent) { hiddenSent = true; sample = {}; pressed = new Map(); const t = Math.floor(tickAt(now)) + 1; keepalive(t); lastSendAt = 0; flush(now, t); }
546
+ if (!hiddenSent) { hiddenSent = true; sample = {}; pressed = new Map(); heldTrack.clear(); const t = Math.floor(tickAt(now)) + 1; keepalive(t); lastSendAt = 0; flush(now, t); }
383
547
  base = null;
384
548
  return;
385
549
  }
@@ -403,6 +567,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
403
567
  const before = latest;
404
568
  if (before && before.e === f.e && f.k <= before.k) return;
405
569
  latest = f;
570
+ if (before && before.e !== f.e) held2.length = 0;
571
+ held2.push(f); if (held2.length > 2 * tickHz) held2.shift();
406
572
  if (!before || before.e !== f.e) collisionHistory = [];
407
573
  const collision = f.collision;
408
574
  if (collision) {
@@ -425,6 +591,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
425
591
  if (placed) emit('placed', entityOf(me, true));
426
592
  } else if (kindOf.get(me.kind)?.owner) {
427
593
  mine!.motion = motionOf(me.kind, me.motion);
594
+ if (predictors[me.kind]) mine!.fields = thawFields(kindOf.get(me.kind)?.fields ?? [], me.fields);
428
595
  } else {
429
596
  const previous = mine!.pos;
430
597
  const shownBody = meNow();
@@ -434,13 +601,35 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
434
601
  adopt(me);
435
602
  history.set(f.k, copyMine(mine!));
436
603
  const ack = row?.[2] ?? 0;
604
+ // A numbered pulse is still to come while the server's row has not reached its number (0.46.3): its own message
605
+ // can be later than the input frame of its tick, so the input's acknowledgement says nothing about it.
606
+ const taken = typeof row?.[4] === 'number' ? row[4] : null;
607
+ const owed = (entry: (typeof pending)[number], name: string): boolean => { const n = entry.pulses?.[name]; return taken !== null && n !== undefined ? n > taken : entry.t > ack; };
608
+ const fresh = (entry: (typeof pending)[number]): boolean => f.k + 1 - entry.t <= Math.ceil(tickHz / 4);
437
609
  const late = new Map<string, unknown>();
610
+ // Held input the server had not received when it ran its tick (0.46.3): those ticks ran on the values before, and
611
+ // the server settles the difference on its next ticks (host.ts). The replay owes the same, so the body is
612
+ // predicted where the server will bring it and not where a late frame left it for a moment.
613
+ const heldFields = myKind()!.input.filter(([, fd]) => fd.t === 'held').map(([name]) => name);
614
+ const debt: Record<string, number> = {};
615
+ if (heldFields.length) {
616
+ for (const entry of pending) if (entry.t <= ack) for (const name of heldFields) serverHeld[name] = Number(entry.input[name]) || 0;
617
+ const waiting = pending.filter((entry) => entry.t > ack && entry.t <= f.k && fresh(entry));
618
+ if (waiting.length) for (let i = waiting[0].t; i <= f.k; i++) {
619
+ let meant: Readonly<Record<string, unknown>> | null = null;
620
+ for (const entry of waiting) if (entry.t <= i) meant = entry.input;
621
+ if (meant) for (const name of heldFields) debt[name] = (debt[name] ?? 0) + (Number(meant[name]) || 0) - (serverHeld[name] ?? 0);
622
+ }
623
+ }
438
624
  for (const entry of pending) if (entry.t <= f.k) {
439
625
  replayHeld = entry.input;
440
- if (entry.t > ack && f.k + 1 - entry.t <= Math.ceil(tickHz / 4))
441
- for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name]) late.set(name, entry.input[name]);
626
+ if (fresh(entry))
627
+ for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name] && (fd.t === 'pulse' ? owed(entry, name) : entry.t > ack)) late.set(name, entry.input[name]);
442
628
  }
629
+ // After a reset nothing is held yet: the replay runs on every field's own zero, never on an input with no fields
630
+ // (a `move` that reads `input.yaw` would be handed `undefined`).
443
631
  let input = replayHeld;
632
+ if (!Object.keys(input).length) { const zero: Record<string, unknown> = {}; for (const [name, fd] of myKind()!.input) zero[name] = fd.t === 'press' ? false : fd.t === 'pulse' ? 0 : coerce(fd, fd.init, dims); input = Object.freeze(zero); }
444
633
  for (let t = f.k + 1; t <= stepped; t++) {
445
634
  const presses = new Map(t === f.k + 1 ? late : []);
446
635
  for (const entry of pending) if (entry.t === t) {
@@ -449,10 +638,12 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
449
638
  }
450
639
  const stepInput = { ...input };
451
640
  for (const [name, fd] of myKind()!.input) if (fd.t === 'press' || fd.t === 'pulse') stepInput[name] = presses.get(name) ?? (fd.t === 'press' ? false : 0);
641
+ for (const name of heldFields) if (debt[name]) { const level = Number(stepInput[name]) || 0, range = Math.max(1, Math.abs(level)), want = level + debt[name], paid = Math.max(-range, Math.min(range, want)); stepInput[name] = paid; debt[name] = want - paid; }
452
642
  Object.assign(mine!, runMove(me.kind, mine!, Object.freeze(stepInput), t));
643
+ runPredict(me.kind, mine!, mine!.fields, stepInput, t, false, false);
453
644
  history.set(t, copyMine(mine!));
454
645
  }
455
- pending = pending.filter(e => e.t > f.k || e.t > ack && f.k + 1 - e.t <= Math.ceil(tickHz / 4));
646
+ pending = pending.filter(e => e.t > f.k || fresh(e) && (e.t > ack || Object.keys(e.pulses ?? {}).some((name) => owed(e, name))));
456
647
  if (shownBody && distance(oldHeading, mine!.heading) > 0.0001) headingBlend = { from: shownBody.heading, at: clock() };
457
648
  const error = distance(previous, mine!.pos);
458
649
  correction.last = error;
@@ -504,7 +695,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
504
695
  steer(now);
505
696
  }
506
697
  }
507
- } else mine = null;
698
+ previewKey = '';
699
+ settlePredictions(f, before && before.e === f.e ? before : null, row?.[2] ?? 0);
700
+ } else { if (mine) dropPredictions(); mine = null; }
508
701
  if (before) {
509
702
  for (const [id, u] of f.ents) { const old = before.ents.get(id); if (!old) emit('enter', entityOf(u)); else if (old.r !== u.r && id !== mine?.id) emit('placed', entityOf(u)); }
510
703
  for (const [id, u] of before.ents) if (!f.ents.has(id)) emit('leave', entityOf(u));
@@ -532,11 +725,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
532
725
  for (let i = 0; i < fxQueue.length;) {
533
726
  const x = fxQueue[i];
534
727
  const ownTick = catchTick === null ? tickAt(clock()) : catchTick + Math.max(0, clock() - catchAt) / period * predict.catchUp;
535
- if (x.tick > (x.at === mine?.id ? Math.min(latest?.k ?? 0, ownTick) : drawn)) { i++; continue; }
728
+ if (!x.confirms && x.tick > (x.at === mine?.id ? Math.min(latest?.k ?? 0, ownTick) : drawn)) { i++; continue; }
536
729
  fxQueue.splice(i, 1);
537
730
  const id = typeof x.at === 'string' ? x.at : null;
538
731
  const where = id ? get(id)?.pos ?? null : unpackVec(x.at, dims);
539
- emit(x.name, Object.freeze({ ...x.data, at: where, ...(id ? { id } : {}), tick: x.tick }));
732
+ emit(x.name, Object.freeze({ ...x.data, at: where, ...(id ? { id } : {}), tick: x.tick, ...(x.confirms ? { confirms: true } : {}) }));
540
733
  }
541
734
  }
542
735
 
@@ -549,7 +742,18 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
549
742
  net.on('event', (e) => {
550
743
  if (e.k === 'fx' && Array.isArray(e.d)) {
551
744
  const [tick, list] = e.d as [number, [number, unknown, unknown][]];
552
- for (const [i, at, data] of Array.isArray(list) ? list : []) { const name = schema.effectNames[i]; if (name) fxQueue.push({ tick: Number(tick) || 0, name, at, data: unpackFields(schema.effects[name] as FieldList, data, dims) }); }
745
+ for (const [i, at, data] of Array.isArray(list) ? list : []) {
746
+ const name = schema.effectNames[i];
747
+ if (!name) continue;
748
+ const item: (typeof fxQueue)[number] = { tick: Number(tick) || 0, name, at, data: unpackFields(schema.effects[name] as FieldList, data, dims) };
749
+ // The server's word on something this browser already showed (a predictor's `ctx.effect`): it confirms it.
750
+ if (provisional.length) {
751
+ const probe = { ...item.data, ...(typeof at === 'string' ? { id: at } : { at: unpackVec(at, dims) }), tick: item.tick };
752
+ const n = provisional.findIndex((x) => x.name === name && (!x.match || safe(() => x.match!(probe))));
753
+ if (n >= 0) { provisional.splice(n, 1); item.confirms = true; }
754
+ }
755
+ fxQueue.push(item);
756
+ }
553
757
  return;
554
758
  }
555
759
  // A player's speech and emotes, and a guide's lines and goals, as the relay carries them today.
@@ -594,14 +798,30 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
594
798
  if (kind && base) {
595
799
  const a = Math.max(0, Math.min(1, tickAt(clock()) - stepped));
596
800
  const now: Record<string, unknown> = {};
597
- for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : coerce(fd, sample[name] ?? fd.init, dims);
801
+ // A held field previews as its mean over the part of the tick that has passed. The body is drawn that fraction of
802
+ // the way to the preview, so it stands exactly where the control has carried it: it starts from rest at a press
803
+ // and stops dead at a release, with nothing to blend away afterwards.
804
+ for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : fd.t === 'held' ? coerce(fd, heldMean(name, fd.init, Math.min(clock(), edgeOf(stepped + 1)), false), dims) : coerce(fd, sample[name] ?? fd.init, dims);
598
805
  const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
599
- pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, a) }, dims);
806
+ // A landing does not take the whole tick (0.46.5): the body met the ground part way through it. How far through is
807
+ // read from the fall it would have made at the speed it entered the tick with; it is drawn down to the ground by
808
+ // then and standing from then, not sinking until the tick's end.
809
+ let av = a, landed = false;
810
+ if (dims === 3 && !mine.grounded && next.grounded && mine.vel.z < 0) {
811
+ const free = -mine.vel.z / tickHz, contact = free > 1e-6 ? Math.max(0.05, Math.min(1, (mine.pos.z - next.pos.z) / free)) : 1;
812
+ av = Math.min(1, a / contact); landed = a >= contact;
813
+ }
814
+ pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, av) }, dims);
600
815
  heading = blendHeading(mine.heading, next.heading, a, dims);
601
816
  velocity = vec3({ x: lerp(mine.vel.x, next.vel.x, a), y: lerp(mine.vel.y, next.vel.y, a), z: lerp(mine.vel.z, next.vel.z, a) }, dims);
602
817
  // A takeoff is airborne as soon as it rises; a landing waits for its contact.
603
- grounded = mine.grounded && (a === 0 || next.grounded);
818
+ grounded = landed || mine.grounded && (a === 0 || next.grounded);
604
819
  preview = next;
820
+ if (predictors[kind.name]) {
821
+ // The own actions between ticks: the predictor on a copy of the fields, for the tick about to be stepped.
822
+ const key = stepped + JSON.stringify(now);
823
+ if (key !== previewKey) { previewKey = key; previewFields = thawFields(kind.fields, mine.fields); runPredict(kind.name, next, previewFields, Object.freeze(now), stepped + 1, true, true); }
824
+ }
605
825
  settled = distance(next.pos, mine.pos) < 0.00001;
606
826
  }
607
827
  if (catchTick !== null && base) {
@@ -625,7 +845,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
625
845
  heading = blendHeading(headingBlend.from, heading, t, dims);
626
846
  if (t >= 1) headingBlend = null;
627
847
  }
628
- return entityOf({ ...server, pos, vel: velocity, heading, grounded, motion }, true);
848
+ return entityOf({ ...server, ...(kind && predictors[kind.name] ? { fields: (base ? previewFields : null) ?? mine.fields } : {}), pos, vel: velocity, heading, grounded, motion }, true);
629
849
  }
630
850
  function drawn(id: string): Entity | null {
631
851
  if (solo) return solo.get(id);
@@ -641,7 +861,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
641
861
  const h = blendHeading(ea.heading, eb.heading, t, dims);
642
862
  return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, dims), vel: vec3({ x: lerp(ea.vel.x, eb.vel.x, t), y: lerp(ea.vel.y, eb.vel.y, t), z: lerp(ea.vel.z, eb.vel.z, t) }, dims), heading: h });
643
863
  }
644
- function get(id: string): Entity | null { return drawn(id); }
864
+ function get(id: string): Entity | null { const g = ghosts.get(id); return g ? ghostEntity(g) : drawn(id); }
645
865
  function each(kind: string, fn: (e: Entity) => void): void {
646
866
  pump();
647
867
  if (solo) { solo.each(kind, fn); return; }
@@ -653,6 +873,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
653
873
  for (const f of [a, s && s.alpha >= 1 ? b : null, a ? null : b]) if (f) for (const u of f.ents.values()) if (u.kind === kind) ids.add(u.id);
654
874
  if (mine && mine.kind === kind) ids.add(mine.id);
655
875
  for (const id of ids) { const e = drawn(id); if (e) fn(e); }
876
+ // A confirmed ghost stands in until the server's own entity is drawn (it arrives a moment behind, interpolated).
877
+ for (const g of [...ghosts.values()]) if (g.kind === kind) { if (g.real && ids.has(g.real)) { ghosts.delete(g.id); emit('leave', ghostEntity(g)); } else fn(ghostEntity(g)); }
656
878
  }
657
879
  function roster(): RosterRow[] {
658
880
  if (solo) return solo.roster;
@@ -684,7 +906,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
684
906
  // traffic, writes no room save to the server, and closes as soon as a body arrives.
685
907
  const g = globalThis as unknown as Record<string, unknown>;
686
908
  const probes = [g.__homieNet, g.__homiePort];
687
- solo = openRoom<R>({ game, move: moves, now: opts.now, timers: opts.timers, net: { config: null, post: null, linkOverlay: false } });
909
+ solo = openRoom<R>({ game, move: moves, predict: opts.predict, now: opts.now, timers: opts.timers, net: { config: null, post: null, linkOverlay: false } });
688
910
  [g.__homieNet, g.__homiePort] = probes;
689
911
  for (const name of listeners.keys()) forwardSolo(name);
690
912
  net.line(net.full ? 'This room is full · playing on your own until a seat is free' : 'Playing on your own until the next round');
@@ -715,24 +937,53 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
715
937
  const onShow = (): void => { if (typeof document !== 'undefined' && !document.hidden) { base = null; if (latest) rebase(latest.k); } };
716
938
  if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onShow);
717
939
 
718
- const viewTargets = () => {
940
+ function viewTickNow(): number { const s=net.sample();const a=s?frameOf(s.a):latest,b=s?frameOf(s.b):latest;return a&&b?a.k+(b.k-a.k)*(s?.alpha??0):latest?.k??0; }
941
+ /*
942
+ * WHERE A BODY WAS, OR WILL BE JUDGED TO BE (0.46.5). The server judges a rewound ray against poses it kept by
943
+ * tick. A browser holds the snapshots it has received: a pose at a tick between two of them is the point between;
944
+ * past the newest it is that snapshot's pose carried on along its velocity (`known: false`: a guess); before the
945
+ * oldest it is the oldest. A placement between the two is not glided across.
946
+ */
947
+ function poseAt(id: string, at: number): { pos: Vec3; vel: Vec3; known: boolean } | null {
948
+ let before: Frame | null = null, after: Frame | null = null;
949
+ for (const f of held2) { if (!f.ents.has(id)) continue; if (f.k <= at) before = f; if (f.k >= at && !after) after = f; }
950
+ const a = before?.ents.get(id), b = after?.ents.get(id);
951
+ if (a && b) { if (before === after || a.r !== b.r) return { pos: b.pos, vel: b.vel, known: true }; const m = (at - before!.k) / (after!.k - before!.k); return { pos: vec3({ x: lerp(a.pos.x, b.pos.x, m), y: lerp(a.pos.y, b.pos.y, m), z: lerp(a.pos.z, b.pos.z, m) }, dims), vel: b.vel, known: true }; }
952
+ if (a) { const s = Math.min(tickHz, at - before!.k) / tickHz; return { pos: vec3({ x: a.pos.x + a.vel.x * s, y: a.pos.y + a.vel.y * s, z: a.pos.z + a.vel.z * s }, dims), vel: a.vel, known: false }; }
953
+ return b ? { pos: b.pos, vel: b.vel, known: false } : null;
954
+ }
955
+ const atTickOf = (options: unknown): number | undefined => { const v = options !== null && typeof options === 'object' ? (options as { atTick?: unknown }).atTick : undefined; return typeof v === 'number' && Number.isFinite(v) ? v : undefined; };
956
+ /** How many ticks back this seat's ray may look: the server's own figure from the newest snapshot, else the rule it follows with this browser's round trip. */
957
+ function rewindAllowance(): number {
958
+ const seat = net.seat, row = seat === null ? undefined : latest?.rows.find((x) => x[0] === seat);
959
+ const history = Math.ceil((schema.rewind?.historySeconds ?? 0) * tickHz);
960
+ if (typeof row?.[5] === 'number') return row[5];
961
+ const cap = schema.rewind?.capSeconds !== undefined ? Math.ceil(schema.rewind.capSeconds * tickHz) : history;
962
+ return Math.min(history, cap, Math.ceil((authorityRtt() + 150) / period) + 1);
963
+ }
964
+ const viewTargets = (atTick?: number) => {
719
965
  pump();
720
966
  const targets: QueryTarget[] = collisionTargets(collisionMap(map, collisionHistory.at(-1)?.[2] ?? []));
721
967
  for(const k of schema.kinds) {
722
968
  if(k.collider || !k.radius)continue;
723
- each(k.name,e=>targets.push({id:e.id,kind:k.name,query:k.query,fields:e as Record<string,unknown>,geometry:false,solid:solidAt(e.pos,{shape:k.shape??'circle',radius:k.radius,height:k.height??0}),at:e.pos}));
969
+ each(k.name,e=>{
970
+ // At a tick: where the server will have the body, with the fields as they are now (the server filters on current fields too).
971
+ const at = atTick !== undefined && e.id !== mine?.id && !e.provisional ? poseAt(e.id, atTick)?.pos ?? e.pos : e.pos;
972
+ targets.push({id:e.id,kind:k.name,query:k.query,fields:e as Record<string,unknown>,geometry:false,solid:solidAt(at,{shape:k.shape??'circle',radius:k.radius,height:k.height??0}),at});
973
+ });
724
974
  }
725
975
  return targets;
726
976
  };
727
- const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(),dims,from,direction,max,options,mine?.id,all);
977
+ const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(atTickOf(options)),dims,from,direction,max,options,mine?.id,all);
728
978
  return {
729
- get viewTick() { if(solo)return solo.viewTick;const s=net.sample();const a=s?frameOf(s.a):latest,b=s?frameOf(s.b):latest;return a&&b?a.k+(b.k-a.k)*(s?.alpha??0):latest?.k??0; },
979
+ get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
730
980
  get status() { return solo ? 'offline' : status; },
731
981
  get seat() { return solo ? solo.seat : net.seat; },
732
982
  get me() { pump(); return meNow(); },
733
983
  each, get,
734
984
  ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
735
- rays: options => solo ? solo.rays(options) : raySnapshot(map,viewTargets(),dims,options,mine?.id),
985
+ rays: options => solo ? solo.rays(options) : raySnapshot(map,viewTargets(atTickOf(options)),dims,options,mine?.id),
986
+ poseAt: (id, tick) => solo ? solo.poseAt(id, tick) : poseAt(id, tick),
736
987
  rayAll: (from,direction,max,options) => solo ? solo.rayAll(from,direction,max,options) : viewRay(from,direction,max,options,true),
737
988
  on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
738
989
  get shared() { if (solo) return solo.shared; return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
@@ -748,9 +999,16 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
748
999
  // A fresh control can replace a preview partway through a tick (especially
749
1000
  // a jump). Keep the pose at the instant of the press; only subsequent time
750
1001
  // may advance the new path, rather than drawing elapsed time with new input.
1002
+ // A held control changed NOW, before anything else here takes time: the steps this call runs must already know it.
1003
+ const at = clock();
1004
+ for (const [name, fd] of kind?.input ?? []) if (fd.t === 'held') {
1005
+ const v = heldNumber(next[name], fd.init), tr = heldTrack.get(name);
1006
+ if (!tr) heldTrack.set(name, { from: at, changes: [[at, v]] });
1007
+ else if (tr.changes[tr.changes.length - 1][1] !== v) { tr.changes.push([at, v]); if (tr.changes.length > 256) tr.changes.splice(1, tr.changes.length - 256); }
1008
+ }
751
1009
  if (changed) pump();
752
1010
  const shown = changed ? meNow()?.pos : null;
753
- for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'&&!pressed.has(name))net.send('pulse',[epoch,name,value]); pressed.set(name,value); }
1011
+ for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'&&!pressed.has(name)){pulseCount+=1;pulseNumber.set(name,pulseCount);net.send('pulse',[epoch,name,value,stepped+1,pulseCount]);} pressed.set(name,value); }
754
1012
  sample = next;
755
1013
  if (shown) {
756
1014
  const now = clock(), nextShown = meNow()?.pos;