@pylonsync/realtime 0.22.26 → 0.22.27

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.
@@ -76,6 +76,7 @@ export declare class EntityInterpolator {
76
76
  /** Lives that ended, oldest first, so `record` can drop them when
77
77
  * `update` does not run (a hidden tab keeps receiving frames). */
78
78
  private ended;
79
+ private endedStart;
79
80
  constructor(options?: InterpolationOptions);
80
81
  /** The newest tick recorded, or -1. */
81
82
  get latestTick(): number;
package/dist/wire.d.ts CHANGED
@@ -160,5 +160,5 @@ export declare class StreamFrames {
160
160
  /** Add a chunk and return the frames it completed. */
161
161
  push(chunk: Uint8Array): ArrayBuffer[];
162
162
  private peek;
163
- private take;
163
+ private consume;
164
164
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.22.26",
6
+ "version": "0.22.27",
7
7
  "description": "Pylon realtime shard client pieces with no framework: the wire protocol, codecs, and the entity replication decoder and table.",
8
8
  "type": "module",
9
9
  "main": "./src/index.ts",
@@ -178,4 +178,26 @@ describe("EntityInterpolator", () => {
178
178
  // A render tick past them all draws nothing.
179
179
  expect(h.at(tick).size).toBe(0);
180
180
  });
181
+
182
+ test("cleanup preserves reused ids through compaction and a tick reset", () => {
183
+ const h = harness();
184
+ for (let tick = 1; tick <= 3000; tick++) {
185
+ h.frame(tick, {
186
+ despawn: tick > 1 ? [1, 2] : [],
187
+ spawn: [{ id: 1, pos: [tick, 0, 0] }, { id: 2, pos: [-tick, 0, 0] }],
188
+ });
189
+ }
190
+ expect(h.at(2999.5).get(1)?.x).toBeCloseTo(2999);
191
+ expect(h.at(3000).get(1)?.x).toBeCloseTo(3000);
192
+ expect(h.interp.entered).toEqual([1, 2]);
193
+ expect(h.interp.left).toEqual([1, 2]);
194
+ const retained = h.interp as unknown as { ended: unknown[] };
195
+ expect(retained.ended.length).toBeLessThan(2048);
196
+ h.frame(1, { full: true, spawn: [{ id: 3, pos: [7, 0, 0] }] });
197
+ expect([...h.at(1).keys()]).toEqual([3]);
198
+ h.frame(2, { despawn: [3] });
199
+ h.frame(300, {});
200
+ expect(h.at(300).size).toBe(0);
201
+ expect(retained.ended.length).toBe(0);
202
+ });
181
203
  });
@@ -93,7 +93,8 @@ export class EntityInterpolator {
93
93
  private drawn = -1;
94
94
  /** Lives that ended, oldest first, so `record` can drop them when
95
95
  * `update` does not run (a hidden tab keeps receiving frames). */
96
- private ended: Array<{ id: number; life: Life }> = [];
96
+ private ended: Array<{ id: number; life: Life } | undefined> = [];
97
+ private endedStart = 0;
97
98
 
98
99
  constructor(options: InterpolationOptions = {}) {
99
100
  this.snapDistance = options.snapDistance ?? Infinity;
@@ -120,6 +121,7 @@ export class EntityInterpolator {
120
121
  clear(): void {
121
122
  this.lives.clear();
122
123
  this.ended = [];
124
+ this.endedStart = 0;
123
125
  this.lastTick = -1;
124
126
  this.floor = -Infinity;
125
127
  this.drawn = -1;
@@ -215,18 +217,26 @@ export class EntityInterpolator {
215
217
  * when it runs; this bounds them when it does not.
216
218
  */
217
219
  private dropEnded(tick: number): void {
218
- let n = 0;
219
- while (n < this.ended.length && (this.ended[n].life.despawnTick as number) < tick - ENDED_TICKS) {
220
- const { id, life } = this.ended[n];
220
+ while (this.endedStart < this.ended.length) {
221
+ const { id, life } = this.ended[this.endedStart]!;
222
+ const expired = (life.despawnTick as number) < tick - ENDED_TICKS;
223
+ if (!expired) break;
221
224
  const lives = this.lives.get(id);
222
225
  if (lives) {
223
226
  const i = lives.indexOf(life);
224
227
  if (i >= 0) lives.splice(i, 1);
225
228
  if (lives.length === 0) this.lives.delete(id);
226
229
  }
227
- n++;
230
+ // Release the life now, without shifting the retained entries.
231
+ this.ended[this.endedStart++] = undefined;
232
+ }
233
+ if (this.endedStart === this.ended.length) {
234
+ this.ended = [];
235
+ this.endedStart = 0;
236
+ } else if (this.endedStart >= 1024 && this.endedStart >= this.ended.length / 2) {
237
+ this.ended = this.ended.slice(this.endedStart);
238
+ this.endedStart = 0;
228
239
  }
229
- if (n > 0) this.ended.splice(0, n);
230
240
  }
231
241
 
232
242
  private openLife(id: number): Life | null {
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import { replicationPayload } from "../test/frames";
3
4
  import { EntityTable, ReplicationError } from "./replication";
4
5
  import fixtures from "./replication.fixtures.json";
5
6
 
@@ -13,6 +14,76 @@ function hex(b: Uint8Array): string {
13
14
  return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
14
15
  }
15
16
 
17
+ describe("stream position updates", () => {
18
+ test("empty and sparse frames do not read unchanged positions", () => {
19
+ const table = new EntityTable();
20
+ table.apply(replicationPayload({
21
+ full: true,
22
+ spawn: [
23
+ { id: 1, pos: [1, 2, 3] },
24
+ { id: 2, pos: [4, 5, 6] },
25
+ ],
26
+ }));
27
+ const quiet = table.get(2)!;
28
+ let reads = 0;
29
+ const qx = quiet.qx;
30
+ Object.defineProperty(quiet, "qx", {
31
+ get() {
32
+ reads++;
33
+ return qx;
34
+ },
35
+ });
36
+ table.apply(replicationPayload({}), 2);
37
+ const summary = table.apply(replicationPayload({
38
+ update: [{ id: 1, from: [1, 2, 3], pos: [-1, 8, 3] }],
39
+ }), 3);
40
+ expect(reads).toBe(0);
41
+ expect(summary.updated).toEqual([1]);
42
+ const moved = table.get(1)!;
43
+ expect([moved.x, moved.y, moved.z]).toEqual([-100, 800, 300].map((q) => q * table.precision));
44
+ expect([quiet.x, quiet.y, quiet.z]).toEqual([400, 500, 600].map((q) => q * table.precision));
45
+ expect(table.streamTick).toBe(3);
46
+ });
47
+
48
+ test("precision changes rescale unchanged entities", () => {
49
+ const table = new EntityTable();
50
+ table.apply(replicationPayload({
51
+ spawn: [
52
+ { id: 1, pos: [1, 2, -3] },
53
+ { id: 2, pos: [4, 5, 6] },
54
+ ],
55
+ }));
56
+ const frame = replicationPayload({ update: [{ id: 1, from: [1, 2, -3], pos: [2, 2, -3] }] });
57
+ new DataView(frame.buffer).setFloat32(2, 0.5, true);
58
+ table.apply(frame);
59
+ expect([table.get(1)!.x, table.get(1)!.y, table.get(1)!.z]).toEqual([100, 100, -150]);
60
+ expect([table.get(2)!.x, table.get(2)!.y, table.get(2)!.z]).toEqual([200, 250, 300]);
61
+ const empty = replicationPayload({});
62
+ new DataView(empty.buffer).setFloat32(2, 0.25, true);
63
+ table.apply(empty);
64
+ expect(table.get(2)!.x).toBe(100);
65
+ });
66
+
67
+ test("mixed frames and full replacements retain positions and components", () => {
68
+ const table = new EntityTable();
69
+ table.apply(replicationPayload({ spawn: [{ id: 1 }, { id: 2 }] }));
70
+ table.apply(replicationPayload({
71
+ despawn: [1],
72
+ spawn: [{ id: 3, pos: [7, -8, 9] }],
73
+ update: [
74
+ { id: 2, components: { 4: [1, 2] } },
75
+ { id: 3, from: [7, -8, 9], pos: [8, -8, 9] },
76
+ ],
77
+ }));
78
+ expect(table.get(1)).toBeUndefined();
79
+ expect(table.get(2)!.components.get(4)).toEqual(new Uint8Array([1, 2]));
80
+ expect(table.get(3)!.x).toBe(800 * table.precision);
81
+ table.apply(replicationPayload({ full: true, spawn: [{ id: 4, pos: [-2, 3, 4] }] }));
82
+ expect([...table.entities.keys()]).toEqual([4]);
83
+ expect(table.get(4)!.x).toBe(-200 * table.precision);
84
+ });
85
+ });
86
+
16
87
  describe("the Rust encoder's frames (replication.fixtures.json)", () => {
17
88
  test("apply to the same tables the Rust decoder holds", () => {
18
89
  const table = new EntityTable();
@@ -275,6 +275,7 @@ export class EntityTable {
275
275
  this.datagramFrames.clear();
276
276
  this.spawnTicks.clear();
277
277
  }
278
+ const precisionChanged = precision !== this.precision;
278
279
  this.precision = precision;
279
280
  const summary: ReplicationSummary = { full, spawned: [], updated: [], despawned: [] };
280
281
 
@@ -324,15 +325,20 @@ export class EntityTable {
324
325
  if (mask & MASK_Y) e.qy = toSafe(BigInt(e.qy) + r.zigzag(), "position");
325
326
  if (mask & MASK_Z) e.qz = toSafe(BigInt(e.qz) + r.zigzag(), "position");
326
327
  if (mask & MASK_COMPONENTS) readComponents(r, e.components);
328
+ e.x = e.qx * precision;
329
+ e.y = e.qy * precision;
330
+ e.z = e.qz * precision;
327
331
  summary.updated.push(last);
328
332
  }
329
333
  if (!r.done) throw new ReplicationError("trailing bytes");
330
334
 
331
- // Positions follow the frame's precision (a full frame may change it).
332
- for (const e of this.entities.values()) {
333
- e.x = e.qx * precision;
334
- e.y = e.qy * precision;
335
- e.z = e.qz * precision;
335
+ // A precision change also affects entities absent from this frame.
336
+ if (precisionChanged) {
337
+ for (const e of this.entities.values()) {
338
+ e.x = e.qx * precision;
339
+ e.y = e.qy * precision;
340
+ e.z = e.qz * precision;
341
+ }
336
342
  }
337
343
  if (tick !== undefined) this.streamTick = tick;
338
344
  return summary;
package/src/wire.test.ts CHANGED
@@ -6,13 +6,52 @@ import {
6
6
  SHARD_HEADER_LEN,
7
7
  ShardCodec,
8
8
  ShardFrameKind,
9
+ StreamFrames,
9
10
  decodeShardPayload,
10
11
  decodeShardRejection,
11
12
  encodeDatagramAckBatches,
12
13
  encodeShardInput,
13
14
  parseShardFrame,
15
+ lengthPrefixed,
14
16
  } from "./wire";
15
17
 
18
+ describe("StreamFrames", () => {
19
+ test("decodes fragmented headers, empty frames, and combined frames", () => {
20
+ const messages = [new Uint8Array(), new Uint8Array([1, 2, 3]), new Uint8Array([4])];
21
+ const wire = new Uint8Array(messages.reduce((sum, value) => sum + value.length + 4, 0));
22
+ let at = 0;
23
+ for (const message of messages) {
24
+ wire.set(lengthPrefixed(message), at);
25
+ at += message.length + 4;
26
+ }
27
+ for (const size of [1, 2, 3, 4, 7, wire.length]) {
28
+ const decoder = new StreamFrames();
29
+ const frames: ArrayBuffer[] = [];
30
+ expect(decoder.push(new Uint8Array())).toEqual([]);
31
+ for (let offset = 0; offset < wire.length; offset += size) {
32
+ frames.push(...decoder.push(wire.subarray(offset, offset + size)));
33
+ }
34
+ expect(frames.map((value) => [...new Uint8Array(value)])).toEqual(messages.map((value) => [...value]));
35
+ }
36
+ });
37
+
38
+ test("completed frames own their bytes for contiguous and fragmented inputs", () => {
39
+ for (const split of [1, 5, 10]) {
40
+ const source = lengthPrefixed(new Uint8Array([1, 2, 3, 4, 5, 6]));
41
+ const decoder = new StreamFrames();
42
+ const frames = [...decoder.push(source.subarray(0, split)), ...decoder.push(source.subarray(split))];
43
+ source.fill(0);
44
+ expect([...new Uint8Array(frames[0])]).toEqual([1, 2, 3, 4, 5, 6]);
45
+ }
46
+ });
47
+
48
+ test("rejects oversized frames before buffering their body", () => {
49
+ const header = new Uint8Array(4);
50
+ new DataView(header.buffer).setUint32(0, 64 * 1024 * 1024 + 1);
51
+ expect(() => new StreamFrames().push(header)).toThrow("stream frame");
52
+ });
53
+ });
54
+
16
55
  function frame(kind: number, codec: number, tick: number, ack: number, payload: Uint8Array) {
17
56
  const buf = new ArrayBuffer(SHARD_HEADER_LEN + payload.length);
18
57
  const view = new DataView(buf);
package/src/wire.ts CHANGED
@@ -323,6 +323,7 @@ export class StreamFrames {
323
323
 
324
324
  /** Add a chunk and return the frames it completed. */
325
325
  push(chunk: Uint8Array): ArrayBuffer[] {
326
+ if (chunk.length === 0) return [];
326
327
  this.chunks.push(chunk);
327
328
  this.buffered += chunk.length;
328
329
  const frames: ArrayBuffer[] = [];
@@ -331,9 +332,10 @@ export class StreamFrames {
331
332
  const len = new DataView(head.buffer, head.byteOffset, 4).getUint32(0);
332
333
  if (len > MAX_STREAM_FRAME) throw new Error(`a ${len}-byte stream frame`);
333
334
  if (this.buffered < 4 + len) break;
334
- this.take(4);
335
- const frame = this.take(len);
336
- frames.push(frame.buffer.slice(frame.byteOffset, frame.byteOffset + frame.length) as ArrayBuffer);
335
+ this.consume(4);
336
+ const frame = new Uint8Array(len);
337
+ this.consume(len, frame);
338
+ frames.push(frame.buffer);
337
339
  }
338
340
  return frames;
339
341
  }
@@ -351,11 +353,11 @@ export class StreamFrames {
351
353
  return out;
352
354
  }
353
355
 
354
- private take(n: number): Uint8Array {
355
- const out = this.peek(n);
356
+ private consume(n: number, output?: Uint8Array): void {
356
357
  let left = n;
357
358
  while (left > 0) {
358
359
  const c = this.chunks[0];
360
+ if (output) output.set(c.subarray(0, Math.min(c.length, left)), n - left);
359
361
  if (c.length <= left) {
360
362
  this.chunks.shift();
361
363
  left -= c.length;
@@ -365,6 +367,5 @@ export class StreamFrames {
365
367
  }
366
368
  }
367
369
  this.buffered -= n;
368
- return out;
369
370
  }
370
371
  }