@woosh/meep-engine 3.14.4 → 3.14.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.
Files changed (89) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/mat4/m4_rigidity_defect.d.ts +30 -0
  3. package/src/core/geom/3d/mat4/m4_rigidity_defect.d.ts.map +1 -0
  4. package/src/core/geom/3d/mat4/m4_rigidity_defect.js +71 -0
  5. package/src/engine/network/orchestrator/NetworkPeer.d.ts +56 -8
  6. package/src/engine/network/orchestrator/NetworkPeer.d.ts.map +1 -1
  7. package/src/engine/network/orchestrator/NetworkPeer.js +263 -67
  8. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts +6 -1
  9. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts.map +1 -1
  10. package/src/engine/network/orchestrator/ServerAuthoritativeClient.js +6 -0
  11. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts +13 -7
  12. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts.map +1 -1
  13. package/src/engine/network/orchestrator/ServerAuthoritativeServer.js +103 -138
  14. package/src/engine/network/replication/Replicator.d.ts +29 -6
  15. package/src/engine/network/replication/Replicator.d.ts.map +1 -1
  16. package/src/engine/network/replication/Replicator.js +265 -108
  17. package/src/engine/network/sim/ActionLog.d.ts +2 -0
  18. package/src/engine/network/sim/ActionLog.d.ts.map +1 -1
  19. package/src/engine/network/sim/ActionLog.js +2 -0
  20. package/src/engine/network/sim/ActionRecordCursor.d.ts +82 -0
  21. package/src/engine/network/sim/ActionRecordCursor.d.ts.map +1 -0
  22. package/src/engine/network/sim/ActionRecordCursor.js +120 -0
  23. package/src/engine/network/sim/RewindEngine.d.ts.map +1 -1
  24. package/src/engine/network/sim/RewindEngine.js +13 -29
  25. package/src/engine/network/sim/SimActionExecutor.d.ts +2 -3
  26. package/src/engine/network/sim/SimActionExecutor.d.ts.map +1 -1
  27. package/src/engine/network/sim/SimActionExecutor.js +10 -11
  28. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  29. package/src/shade/playground/particle_system/particle_scene.d.ts.map +1 -1
  30. package/src/shade/playground/particle_system/particle_scene.js +38 -37
  31. package/src/shade/playground/skinned_mesh_soup/README.md +39 -18
  32. package/src/shade/playground/skinned_mesh_soup/index.html +9 -6
  33. package/src/shade/playground/skinned_mesh_soup/main.d.ts.map +1 -1
  34. package/src/shade/playground/skinned_mesh_soup/main.js +16 -13
  35. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts +8 -2
  36. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts.map +1 -1
  37. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.js +23 -4
  38. package/src/shade/renderer/animation/GPUAnimationManager.d.ts.map +1 -1
  39. package/src/shade/renderer/animation/GPUAnimationManager.js +1958 -1796
  40. package/src/shade/renderer/animation/SKINNING_FRAME_PLAN.md +357 -0
  41. package/src/shade/renderer/animation/skin_bind_transform.d.ts +25 -0
  42. package/src/shade/renderer/animation/skin_bind_transform.d.ts.map +1 -0
  43. package/src/shade/renderer/animation/skin_bind_transform.js +33 -0
  44. package/src/shade/renderer/animation/skin_clip_scale_conflicts.d.ts +67 -0
  45. package/src/shade/renderer/animation/skin_clip_scale_conflicts.d.ts.map +1 -0
  46. package/src/shade/renderer/animation/skin_clip_scale_conflicts.js +199 -0
  47. package/src/shade/renderer/animation/skin_frame_defect.d.ts +34 -0
  48. package/src/shade/renderer/animation/skin_frame_defect.d.ts.map +1 -0
  49. package/src/shade/renderer/animation/skin_frame_defect.js +132 -0
  50. package/src/shade/renderer/animation/skin_joint_matrix.d.ts +24 -0
  51. package/src/shade/renderer/animation/skin_joint_matrix.d.ts.map +1 -0
  52. package/src/shade/renderer/animation/skin_joint_matrix.js +46 -0
  53. package/src/shade/renderer/animation/skin_normalize_mesh_frame.d.ts +70 -0
  54. package/src/shade/renderer/animation/skin_normalize_mesh_frame.d.ts.map +1 -0
  55. package/src/shade/renderer/animation/skin_normalize_mesh_frame.js +216 -0
  56. package/src/shade/renderer/animation/skin_test_fixtures.d.ts +86 -0
  57. package/src/shade/renderer/animation/skin_test_fixtures.d.ts.map +1 -0
  58. package/src/shade/renderer/animation/skin_test_fixtures.js +250 -0
  59. package/src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.d.ts +18 -4
  60. package/src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.d.ts.map +1 -1
  61. package/src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.js +18 -4
  62. package/src/shade/renderer/particles/DESIGN.md +5 -5
  63. package/src/shade/renderer/particles/graph/ParticleNodeDescription.d.ts +43 -0
  64. package/src/shade/renderer/particles/graph/ParticleNodeDescription.d.ts.map +1 -0
  65. package/src/shade/renderer/particles/graph/ParticleNodeDescription.js +44 -0
  66. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.d.ts +33 -59
  67. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.d.ts.map +1 -1
  68. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.js +185 -118
  69. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts +15 -10
  70. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts.map +1 -1
  71. package/src/shade/renderer/particles/graph/compile_particle_graph.js +205 -170
  72. package/src/shade/renderer/particles/graph/particle_graph_authoring.d.ts +44 -0
  73. package/src/shade/renderer/particles/graph/particle_graph_authoring.d.ts.map +1 -0
  74. package/src/shade/renderer/particles/graph/particle_graph_authoring.js +108 -0
  75. package/src/shade/renderer/particles/particle_prototype.d.ts.map +1 -1
  76. package/src/shade/renderer/particles/particle_prototype.js +181 -180
  77. package/src/shade/renderer/particles/runtime/create_particle_effect.d.ts +4 -4
  78. package/src/shade/renderer/particles/runtime/create_particle_effect.d.ts.map +1 -1
  79. package/src/shade/renderer/particles/runtime/create_particle_effect.js +19 -19
  80. package/src/shade/renderer/particles/vm/chunk_particle_vm.js +1 -1
  81. package/src/shade/renderer/scene/SkinnedMesh.d.ts +11 -0
  82. package/src/shade/renderer/scene/SkinnedMesh.d.ts.map +1 -1
  83. package/src/shade/renderer/scene/SkinnedMesh.js +11 -0
  84. package/src/shade/renderer/shader/chunk/geometry/quaternion/dual/chunk_dual_quat_from_m4.d.ts +33 -3
  85. package/src/shade/renderer/shader/chunk/geometry/quaternion/dual/chunk_dual_quat_from_m4.d.ts.map +1 -1
  86. package/src/shade/renderer/shader/chunk/geometry/quaternion/dual/chunk_dual_quat_from_m4.js +58 -37
  87. package/src/shade/renderer/particles/graph/ParticleGraph.d.ts +0 -54
  88. package/src/shade/renderer/particles/graph/ParticleGraph.d.ts.map +0 -1
  89. package/src/shade/renderer/particles/graph/ParticleGraph.js +0 -99
@@ -1,11 +1,24 @@
1
1
  import { assert } from "../../../core/assert.js";
2
+ import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js";
2
3
  import Signal from "../../../core/events/signal/Signal.js";
3
4
  import { MalformedPacketError } from "../core/MalformedPacketError.js";
5
+ import { ActionRecordCursor } from "../sim/ActionRecordCursor.js";
4
6
 
5
7
  // Bytes of fixed per-action framing that always precede the variable payload:
6
8
  // uint8 action_type_id + uint32 action_payload_len.
7
9
  const ACTION_HEADER_BYTES = 5;
8
10
 
11
+ /**
12
+ * Most slices {@link Replicator#unpack_from_peer} holds per peer while waiting
13
+ * for an earlier slice to land. A sender bursts at most `max_packets_per_tick`
14
+ * slices a tick and re-sends the missing one every tick, so a legitimate
15
+ * backlog is a burst or two; this is a backstop against a sender that never
16
+ * fills the gap. Beyond it the farthest slice is dropped — those frames were
17
+ * acked at the channel level, so the sender will credit them and not re-send
18
+ * them, which is a loss, and the reason the cap is generous.
19
+ */
20
+ const MAX_HELD_SLICES = 256;
21
+
9
22
  /**
10
23
  * Forwards action records between peers.
11
24
  *
@@ -33,33 +46,52 @@ const ACTION_HEADER_BYTES = 5;
33
46
  * uint32: action_payload_len
34
47
  * bytes: action_payload
35
48
  * ```
49
+ * {@link NetworkPeer} prefixes each packet of this with a slice header — the
50
+ * frame range the packet covers and whether it is the first of the sender's
51
+ * tick — which it parses itself and hands to {@link unpack_from_peer} as
52
+ * `slice`, so the receiver can apply the sender's packets in frame order.
36
53
  *
37
54
  * @author Alex Goldring
38
55
  * @copyright Company Named Limited (c) 2026
39
56
  */
40
57
  export class Replicator {
41
58
  /**
42
- * Pre-allocated scratch for record positions in the current pack pass.
43
- * Each slot holds (record_start, record_end) interleaved.
44
- * Grown on demand.
59
+ * Pre-allocated scratch for the in-scope actions of the current pack
60
+ * pass: `(action_type_id, payload_start, payload_len)` per action,
61
+ * interleaved. Grown on demand.
45
62
  * @type {Int32Array}
46
63
  * @private
47
64
  */
48
- #record_bounds = new Int32Array(64 * 2);
65
+ #in_scope_actions = new Int32Array(64 * 3);
49
66
  /**
50
- * Per-peer high-watermark of the highest frame number already applied
51
- * via {@link unpack_from_peer}'s execute-on-arrival path. The action
52
- * stream re-sends every unacked frame each tick (back-fill for loss
53
- * tolerance), so the same frame arrives multiple times before its ack
54
- * round-trips. Re-running a frame would recycle its action-log slot and
55
- * re-capture prior state from the already-mutated world corrupting the
56
- * rewind baseline. Skipping frames at or below the watermark keeps each
57
- * frame applied exactly once. Only used by the execute-on-arrival path;
58
- * the deferred path ({@link on_pending_action}) dedups downstream.
59
- * @type {Map<number, number>}
67
+ * Per-peer state of {@link unpack_from_peer}'s execute-on-arrival path:
68
+ * `next_frame`, the frame after the newest applied, and `held`, later
69
+ * slices of a sender's tick waiting for an earlier slice still in flight,
70
+ * sorted by `frame_start`.
71
+ *
72
+ * `next_frame` is what keeps each frame applied exactly once. The action
73
+ * stream re-sends every unconfirmed frame each tick (back-fill for loss
74
+ * tolerance), so a frame arrives several times before its ack round-trips,
75
+ * and re-running it would recycle its action-log slot and re-capture prior
76
+ * state from the already-mutated world — corrupting the rewind baseline.
77
+ * Frames below `next_frame` are skipped.
78
+ *
79
+ * One watermark is sound only because frames are applied in order. The
80
+ * sender may split a tick's owed range across several packets
81
+ * ({@link NetworkPeer#flush_outbound}), and when the first is lost the
82
+ * later ones land first. Applied on arrival they would put their frames
83
+ * ahead of the re-sent earlier ones — an absolute-state action from the
84
+ * older frame would then overwrite the newer, a despawn could precede its
85
+ * spawn — and the watermark would take them as proof of everything before,
86
+ * so the re-send would be skipped for good while the sender, seeing it
87
+ * acked, credited those frames as delivered. So a later slice is held
88
+ * until the slices before it have landed. Only the execute-on-arrival path
89
+ * keeps this; the deferred path ({@link on_pending_action}) orders and
90
+ * dedups downstream.
91
+ * @type {Map<number, {next_frame: number, held: Array<{frame_start: number, frame_end: number, buffer: BinaryBuffer, length: number}>}>}
60
92
  * @private
61
93
  */
62
- #applied_through = new Map();
94
+ #inbound = new Map();
63
95
 
64
96
  /**
65
97
  * Fired after each per-frame action group is fully applied via
@@ -69,8 +101,9 @@ export class Replicator {
69
101
  * or anything else that needs to know "frame N from peer P has just
70
102
  * been applied to the local world."
71
103
  *
72
- * Fires in frame-ascending order within a single packet (the order
73
- * the wire format encodes them in).
104
+ * Fires in frame order within a packet, which encodes its frames
105
+ * ascending, and across packets, because a later slice of a sender's tick
106
+ * is held until the slices before it have landed.
74
107
  *
75
108
  * @type {Signal}
76
109
  */
@@ -96,6 +129,13 @@ export class Replicator {
96
129
  */
97
130
  on_pending_action = null;
98
131
 
132
+ /**
133
+ * Walks a frame's records for {@link pack_for_peer}.
134
+ * @type {ActionRecordCursor}
135
+ * @private
136
+ */
137
+ #cursor = new ActionRecordCursor();
138
+
99
139
  /**
100
140
  * @param {{
101
141
  * action_log: ActionLog,
@@ -175,24 +215,20 @@ export class Replicator {
175
215
  continue;
176
216
  }
177
217
 
178
- const buffer = this.action_log.buffer_for(frame);
179
- const end = this.action_log.write_end_for(frame);
180
-
181
- // First pass: walk records, capture (start, end) for those in scope.
218
+ // First pass: walk the records, remembering where the action of
219
+ // each in-scope one sits.
220
+ const cursor = this.#cursor;
221
+ cursor.open(this.action_log, frame);
182
222
  let in_scope_count = 0;
183
- while (buffer.position < end) {
184
- const record_start = buffer.position;
185
- const in_scope = this.#scan_record_in_scope(buffer, peer_id);
186
- const record_end = buffer.position;
187
-
188
- if (in_scope) {
189
- if ((in_scope_count + 1) * 2 > this.#record_bounds.length) {
190
- this.#grow_record_bounds();
191
- }
192
- this.#record_bounds[in_scope_count * 2] = record_start;
193
- this.#record_bounds[in_scope_count * 2 + 1] = record_end;
194
- in_scope_count++;
223
+ while (cursor.next()) {
224
+ if (!this.#record_in_scope(cursor, peer_id)) continue;
225
+ if ((in_scope_count + 1) * 3 > this.#in_scope_actions.length) {
226
+ this.#grow_in_scope_actions();
195
227
  }
228
+ this.#in_scope_actions[in_scope_count * 3] = cursor.action_type_id;
229
+ this.#in_scope_actions[in_scope_count * 3 + 1] = cursor.payload_start;
230
+ this.#in_scope_actions[in_scope_count * 3 + 2] = cursor.payload_len;
231
+ in_scope_count++;
196
232
  }
197
233
 
198
234
  if (in_scope_count === 0) {
@@ -207,10 +243,17 @@ export class Replicator {
207
243
  out_buffer.writeUintVar(frame);
208
244
  out_buffer.writeUintVar(in_scope_count);
209
245
 
246
+ // Wire format per action is (type_id, len, payload). The record's
247
+ // local-only sender_id byte is NOT copied: the receiver derives the
248
+ // sender from the packet's peer_id, and carrying it on the wire
249
+ // would let a hostile peer impersonate other peers' actions.
250
+ const raw_bytes = cursor.buffer.raw_bytes;
210
251
  for (let i = 0; i < in_scope_count; i++) {
211
- const rs = this.#record_bounds[i * 2];
212
- buffer.position = rs;
213
- this.#copy_action_to(buffer, out_buffer);
252
+ const payload_start = this.#in_scope_actions[i * 3 + 1];
253
+ const payload_len = this.#in_scope_actions[i * 3 + 2];
254
+ out_buffer.writeUint8(this.#in_scope_actions[i * 3]);
255
+ out_buffer.writeUint32(payload_len);
256
+ out_buffer.writeBytes(raw_bytes, payload_start, payload_len);
214
257
  }
215
258
 
216
259
  if (wrote_any_group && out_buffer.position > budget_end) {
@@ -234,11 +277,23 @@ export class Replicator {
234
277
  * sender's frame number, ensuring the rewind/replay machinery sees consistent
235
278
  * frame indexing across peers.
236
279
  *
280
+ * `slice` is the sender's description of the packet, from the
281
+ * {@link NetworkPeer} slice header: the frame range it covers and whether
282
+ * it is the first packet of the sender's tick. Frames are applied in frame
283
+ * order. A slice that starts past the frames applied so far and is not a
284
+ * head is a later slice of a burst whose earlier slice has not landed; it
285
+ * is held and applied once the gap before it fills. A head is applied as
286
+ * soon as it arrives, because nothing the sender can still send lies
287
+ * before it — that is also how the receiver moves past frames lost for
288
+ * good. Without `slice` (callers that pack and unpack directly) the packet
289
+ * is taken as a head covering the frames it carries.
290
+ *
237
291
  * @param {number} peer_id
238
292
  * @param {BinaryBuffer} in_buffer
239
293
  * @param {number} in_buffer_end byte position to stop reading at
294
+ * @param {{frame_start: number, frame_end: number, head: boolean}|null} [slice]
240
295
  */
241
- unpack_from_peer(peer_id, in_buffer, in_buffer_end) {
296
+ unpack_from_peer(peer_id, in_buffer, in_buffer_end, slice = null) {
242
297
  assert.isNonNegativeInteger(peer_id, 'peer_id');
243
298
  assert.isNonNegativeInteger(in_buffer_end, 'in_buffer_end');
244
299
 
@@ -275,19 +330,72 @@ export class Replicator {
275
330
  return;
276
331
  }
277
332
 
278
- const registry = this.action_registry;
333
+ let state = this.#inbound.get(peer_id);
334
+ if (state === undefined) {
335
+ state = { next_frame: 0, held: [] };
336
+ this.#inbound.set(peer_id, state);
337
+ }
338
+
339
+ if (slice !== null && !slice.head && slice.frame_start > state.next_frame) {
340
+ this.#hold_slice(peer_id, state, slice, in_buffer, in_buffer_end);
341
+ return;
342
+ }
343
+
344
+ if (slice !== null && slice.head) {
345
+ // Nothing before a head will be sent again. Whatever was held from
346
+ // before it is as complete as it will ever be, so apply it first,
347
+ // in order, rather than letting the head jump past it.
348
+ this.#apply_held_before(peer_id, state, slice.frame_start);
349
+ }
279
350
 
280
- const applied_through = this.#applied_through.get(peer_id);
351
+ const last_frame = this.#apply_groups(
352
+ peer_id, in_buffer, in_buffer_end, state.next_frame,
353
+ slice !== null ? slice.frame_start : 0,
354
+ slice !== null ? slice.frame_end : Infinity,
355
+ );
356
+ const covered_through = slice !== null ? slice.frame_end : last_frame;
357
+ if (covered_through + 1 > state.next_frame) {
358
+ state.next_frame = covered_through + 1;
359
+ }
360
+ this.#apply_held_contiguous(peer_id, state);
361
+ }
362
+
363
+ /**
364
+ * Apply the frame groups in `[in_buffer.position, in_buffer_end)`, skipping
365
+ * any frame below `min_frame` — already applied, or, when `min_frame` is
366
+ * `Infinity`, every frame, which is how a packet is validated before it is
367
+ * held. A skipped group is still walked, so a malformed one throws here
368
+ * rather than later. A group outside `[frame_start, frame_end]`, the range
369
+ * the slice header declared, is malformed too: the watermark is raised to
370
+ * the header's end, so a frame beyond it would be applied again by the
371
+ * slice that genuinely carries it.
372
+ *
373
+ * @param {number} peer_id
374
+ * @param {BinaryBuffer} in_buffer
375
+ * @param {number} in_buffer_end
376
+ * @param {number} min_frame
377
+ * @param {number} frame_start
378
+ * @param {number} frame_end
379
+ * @returns {number} the highest frame number the packet carries, or -1 if none
380
+ * @private
381
+ */
382
+ #apply_groups(peer_id, in_buffer, in_buffer_end, min_frame, frame_start, frame_end) {
383
+ const registry = this.action_registry;
384
+ let last_frame = -1;
281
385
 
282
386
  while (in_buffer.position < in_buffer_end) {
283
387
  const frame_number = in_buffer.readUintVar();
284
388
  const action_count = in_buffer.readUintVar();
389
+ if (frame_number < frame_start || frame_number > frame_end) {
390
+ throw new MalformedPacketError(`Replicator.unpack_from_peer: frame ${frame_number} lies outside the slice [${frame_start}, ${frame_end}]`);
391
+ }
392
+ if (frame_number > last_frame) last_frame = frame_number;
285
393
 
286
394
  // Skip frames already applied from this peer — the action stream
287
- // re-sends every unacked frame each tick, and re-applying would
288
- // corrupt the action-log prior-state capture (see #applied_through).
395
+ // re-sends every unconfirmed frame each tick, and re-applying would
396
+ // corrupt the action-log prior-state capture (see #inbound).
289
397
  // The read cursor must still advance past this frame's actions.
290
- if (applied_through !== undefined && frame_number <= applied_through) {
398
+ if (frame_number < min_frame) {
291
399
  for (let i = 0; i < action_count; i++) {
292
400
  if (in_buffer.position + ACTION_HEADER_BYTES > in_buffer_end) {
293
401
  throw new MalformedPacketError(`Replicator.unpack_from_peer: action header overruns packet (skip; frame ${frame_number}, action ${i}/${action_count})`);
@@ -335,44 +443,135 @@ export class Replicator {
335
443
  this.action_log.end_frame();
336
444
  }
337
445
 
338
- // Advance the per-peer watermark so subsequent back-fill resends of
339
- // this frame are skipped. Frames within a packet are strictly
340
- // ascending, so this only ever moves forward.
341
- this.#applied_through.set(peer_id, frame_number);
342
-
343
446
  // Fire AFTER end_frame so handlers see consistent state and can
344
447
  // safely query the action log for the just-applied frame.
345
448
  this.onFrameApplied.send2(peer_id, frame_number);
346
449
  }
450
+
451
+ return last_frame;
347
452
  }
348
453
 
349
454
  /**
350
- * Forget the per-peer apply watermark. Call when a peer disconnects so a
351
- * later peer reusing the same id (or the same peer resuming a session whose
352
- * frame numbering restarts) is not wrongly deduped against stale state.
455
+ * Keep a slice that arrived ahead of the slices before it, to be applied
456
+ * once they land. The bytes are copied the inbound buffer is shared
457
+ * scratch after a validation walk, so a malformed slice is rejected now,
458
+ * by the packet that carried it, and not during a later drain inside some
459
+ * other packet's handling.
460
+ *
461
+ * @param {number} peer_id
462
+ * @param {{next_frame: number, held: Array<{frame_start: number, frame_end: number, buffer: BinaryBuffer, length: number}>}} state
463
+ * @param {{frame_start: number, frame_end: number}} slice
464
+ * @param {BinaryBuffer} in_buffer
465
+ * @param {number} in_buffer_end
466
+ * @private
467
+ */
468
+ #hold_slice(peer_id, state, slice, in_buffer, in_buffer_end) {
469
+ const start = in_buffer.position;
470
+ this.#apply_groups(peer_id, in_buffer, in_buffer_end, Infinity, slice.frame_start, slice.frame_end);
471
+
472
+ const held = state.held;
473
+ let i = 0;
474
+ while (i < held.length && held[i].frame_start < slice.frame_start) i++;
475
+
476
+ // Slice boundaries move between ticks: the last slice of a burst grows
477
+ // by a frame a tick until it fills a packet, a frame that packs to
478
+ // nothing lets the next one in, a rollback that grows a frame pushes
479
+ // the next one out. So a re-send with the same start is not the same
480
+ // slice. Every copy was acked, and the sender credits every frame any
481
+ // copy covered, so a frame only one copy covers is never sent again:
482
+ // keep the copy that reaches further, and between equals the newer,
483
+ // which carries the sender's latest content for those frames.
484
+ const same_start = i < held.length && held[i].frame_start === slice.frame_start;
485
+ if (same_start && held[i].frame_end > slice.frame_end) return;
486
+ if (!same_start && held.length >= MAX_HELD_SLICES) {
487
+ // Keep the slices nearest the gap — the ones the next head is most
488
+ // likely to release — and drop the farthest.
489
+ if (i === held.length) return;
490
+ held.pop();
491
+ }
492
+
493
+ const length = in_buffer_end - start;
494
+ const buffer = new BinaryBuffer();
495
+ buffer.setCapacity(length);
496
+ buffer.writeBytes(in_buffer.raw_bytes, start, length);
497
+ const entry = { frame_start: slice.frame_start, frame_end: slice.frame_end, buffer, length };
498
+ if (same_start) {
499
+ held[i] = entry;
500
+ } else {
501
+ held.splice(i, 0, entry);
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Apply, in order, every held slice that starts before `frame` — the start
507
+ * of a head, before which nothing will be sent again.
508
+ * @private
509
+ */
510
+ #apply_held_before(peer_id, state, frame) {
511
+ const held = state.held;
512
+ while (held.length > 0 && held[0].frame_start < frame) {
513
+ const h = held.shift();
514
+ if (h.frame_end >= state.next_frame) this.#apply_held(peer_id, state, h);
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Apply held slices for as long as the next one starts at or before the
520
+ * watermark, dropping any the watermark has already passed.
521
+ * @private
522
+ */
523
+ #apply_held_contiguous(peer_id, state) {
524
+ const held = state.held;
525
+ while (held.length > 0) {
526
+ const h = held[0];
527
+ if (h.frame_end < state.next_frame) {
528
+ held.shift();
529
+ continue;
530
+ }
531
+ if (h.frame_start > state.next_frame) break;
532
+ held.shift();
533
+ this.#apply_held(peer_id, state, h);
534
+ }
535
+ }
536
+
537
+ /** @private */
538
+ #apply_held(peer_id, state, h) {
539
+ h.buffer.position = 0;
540
+ this.#apply_groups(peer_id, h.buffer, h.length, state.next_frame, h.frame_start, h.frame_end);
541
+ if (h.frame_end + 1 > state.next_frame) {
542
+ state.next_frame = h.frame_end + 1;
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Forget a peer's inbound state — its apply watermark and any slices held
548
+ * for it. Call when a peer disconnects so a later peer reusing the same id
549
+ * (or the same peer resuming a session whose frame numbering restarts) is
550
+ * not wrongly deduped against stale state.
353
551
  *
354
552
  * @param {number} peer_id
355
553
  */
356
554
  forget_peer(peer_id) {
357
555
  assert.isNonNegativeInteger(peer_id, 'peer_id');
358
- this.#applied_through.delete(peer_id);
556
+ this.#inbound.delete(peer_id);
359
557
  }
360
558
 
361
559
  /**
362
- * Walk a single record from the buffer's current position, advancing past it.
363
- * Returns true if any of the record's affected entities is in scope for `peer_id`,
364
- * or if the record has no affected entities (event-style action always sent).
560
+ * Is the record under `cursor` in scope for `peer_id` does it touch an
561
+ * entity the peer should see? A record with no affected components (an
562
+ * event-style action) is always in scope.
365
563
  *
366
- * @param {BinaryBuffer} buffer
564
+ * @param {ActionRecordCursor} cursor
367
565
  * @param {number} peer_id
368
566
  * @returns {boolean}
369
567
  * @private
370
568
  */
371
- #scan_record_in_scope(buffer, peer_id) {
372
- const prior_count = buffer.readUintVar();
569
+ #record_in_scope(cursor, peer_id) {
570
+ if (cursor.prior_count === 0) return true;
373
571
 
374
- let any_in_scope = false;
375
- for (let i = 0; i < prior_count; i++) {
572
+ const buffer = cursor.buffer;
573
+ buffer.position = cursor.prior_start;
574
+ for (let i = 0; i < cursor.prior_count; i++) {
376
575
  const entity_id = buffer.readUintVar();
377
576
  // Translate local entity_id to network_id for the scope filter, which
378
577
  // operates on peer-shared identifiers. If `network_for` returns -1,
@@ -383,64 +582,22 @@ export class Replicator {
383
582
  // safely apply.
384
583
  const network_id = this.slot_table.network_for(entity_id);
385
584
  if (network_id >= 0 && this.scope_filter.is_entity_in_scope(peer_id, network_id)) {
386
- any_in_scope = true;
585
+ return true;
387
586
  }
388
587
  buffer.readUint8(); // component_type_id
389
- const payload_len = buffer.readUint32();
390
- buffer.position += payload_len; // skip prior payload
588
+ const prior_len = buffer.readUint32();
589
+ buffer.position += prior_len; // skip prior payload
391
590
  }
392
-
393
- // Skip the action payload too.
394
- buffer.readUint8(); // action_type_id
395
- buffer.readUint8(); // sender_id (local-only metadata, not sent)
396
- const action_payload_len = buffer.readUint32();
397
- buffer.position += action_payload_len;
398
-
399
- // Event-style actions (no affected components) are always in scope.
400
- return prior_count === 0 ? true : any_in_scope;
401
- }
402
-
403
- /**
404
- * Buffer's current position is the start of an action record. Skip the
405
- * prior_state section, then copy the action portion (type_id + len + payload)
406
- * to `out_buffer`. Buffer position advances to the end of the record.
407
- *
408
- * @param {BinaryBuffer} buffer source action-log frame
409
- * @param {BinaryBuffer} out_buffer destination packet
410
- * @private
411
- */
412
- #copy_action_to(buffer, out_buffer) {
413
- // Skip prior_state.
414
- const prior_count = buffer.readUintVar();
415
- for (let i = 0; i < prior_count; i++) {
416
- buffer.readUintVar(); // entity_id
417
- buffer.readUint8(); // component_type_id
418
- const len = buffer.readUint32();
419
- buffer.position += len; // skip prior payload
420
- }
421
-
422
- // Copy action portion: type_id + len + payload. The local-only
423
- // sender_id byte is STRIPPED here — over-the-wire format stays
424
- // (type_id, len, payload) and the receiver derives sender from
425
- // the packet's peer_id. Including sender_id on the wire would
426
- // let a hostile peer impersonate other peers' actions.
427
- const action_type_id = buffer.readUint8();
428
- buffer.readUint8(); // sender_id — discarded, not sent on the wire
429
- const action_payload_len = buffer.readUint32();
430
-
431
- out_buffer.writeUint8(action_type_id);
432
- out_buffer.writeUint32(action_payload_len);
433
- out_buffer.writeBytes(buffer.raw_bytes, buffer.position, action_payload_len);
434
- buffer.position += action_payload_len;
591
+ return false;
435
592
  }
436
593
 
437
594
  /**
438
595
  * @private
439
596
  */
440
- #grow_record_bounds() {
441
- const old = this.#record_bounds;
597
+ #grow_in_scope_actions() {
598
+ const old = this.#in_scope_actions;
442
599
  const next = new Int32Array(old.length * 2);
443
600
  next.set(old);
444
- this.#record_bounds = next;
601
+ this.#in_scope_actions = next;
445
602
  }
446
603
  }
@@ -21,6 +21,8 @@
21
21
  * Self-describing: records can be skipped without consulting the action registry,
22
22
  * which means rewind code can walk records forward to find boundaries, then
23
23
  * iterate backward to apply prior states — no need to instantiate any actions.
24
+ * {@link ActionRecordCursor} is that walk; readers use it rather than parsing
25
+ * the layout themselves.
24
26
  *
25
27
  * `sender_id` says where the record came from, which is what a rollback replay
26
28
  * needs in order to decide what to do with it:
@@ -1 +1 @@
1
- {"version":3,"file":"ActionLog.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/sim/ActionLog.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH;IAoBI;;OAEG;IACH,qDAFW;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;KAAE,EAIlE;IAED;;;;OAIG;IACH,sCAEC;IAED;;;;OAIG;IACH,mBAFW,MAAM,QAShB;IAED;;OAEG;IACH,kBASC;IAED;;;;;;OAMG;IACH,kBAFa,OAAO,CAInB;IAED;;;;OAIG;IACH,+BAMC;IAED;;;;;OAKG;IACH,iBAHW,MAAM,GACJ,OAAO,CAInB;IAED;;;;;;;;;;;OAWG;IACH,kBAHW,MAAM,gBAKhB;IAED;;;;OAIG;IACH,qBAHW,MAAM,GACJ,MAAM,CAIlB;;CACJ"}
1
+ {"version":3,"file":"ActionLog.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/sim/ActionLog.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH;IAoBI;;OAEG;IACH,qDAFW;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;KAAE,EAIlE;IAED;;;;OAIG;IACH,sCAEC;IAED;;;;OAIG;IACH,mBAFW,MAAM,QAShB;IAED;;OAEG;IACH,kBASC;IAED;;;;;;OAMG;IACH,kBAFa,OAAO,CAInB;IAED;;;;OAIG;IACH,+BAMC;IAED;;;;;OAKG;IACH,iBAHW,MAAM,GACJ,OAAO,CAInB;IAED;;;;;;;;;;;OAWG;IACH,kBAHW,MAAM,gBAKhB;IAED;;;;OAIG;IACH,qBAHW,MAAM,GACJ,MAAM,CAIlB;;CACJ"}
@@ -23,6 +23,8 @@ import { FrameBufferRing } from "../state/FrameBufferRing.js";
23
23
  * Self-describing: records can be skipped without consulting the action registry,
24
24
  * which means rewind code can walk records forward to find boundaries, then
25
25
  * iterate backward to apply prior states — no need to instantiate any actions.
26
+ * {@link ActionRecordCursor} is that walk; readers use it rather than parsing
27
+ * the layout themselves.
26
28
  *
27
29
  * `sender_id` says where the record came from, which is what a rollback replay
28
30
  * needs in order to decide what to do with it:
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Forward cursor over the records of one closed {@link ActionLog} frame.
3
+ *
4
+ * The record layout is self-describing (see {@link ActionLog}), so any reader
5
+ * can walk it — and five did, each with its own copy of the header parse: the
6
+ * replicator's scope scan and action copy, the rewind engine's boundary pass,
7
+ * and the rollback server's history read and retransmission check. This is
8
+ * that parse in one place. {@link next} reads one record's header and exposes
9
+ * the layout as positions into the frame's buffer; what a reader does with the
10
+ * prior-state entries or the action payload is its own business.
11
+ *
12
+ * Allocation-free after construction: a reader owns one cursor and re-opens
13
+ * it per frame. After `next` returns true the buffer's `position` is at the
14
+ * END of the record, so a reader that wants the prior-state entries sets
15
+ * `position = prior_start` and reads `prior_count` of them; `next` seeks from
16
+ * `record_end` regardless of where the reader left the position.
17
+ *
18
+ * ```
19
+ * cursor.open(action_log, frame);
20
+ * while (cursor.next()) {
21
+ * // cursor.action_type_id, cursor.sender_id,
22
+ * // cursor.buffer.raw_bytes[cursor.payload_start .. + cursor.payload_len]
23
+ * }
24
+ * ```
25
+ *
26
+ * @author Alex Goldring
27
+ * @copyright Company Named Limited (c) 2026
28
+ */
29
+ export class ActionRecordCursor {
30
+ /**
31
+ * The open frame's buffer, positioned at {@link record_end} after each
32
+ * successful {@link next}.
33
+ * @type {BinaryBuffer|null}
34
+ */
35
+ buffer: BinaryBuffer | null;
36
+ /** @type {number} first byte of the current record */
37
+ record_start: number;
38
+ /** @type {number} prior-state entries in the current record */
39
+ prior_count: number;
40
+ /**
41
+ * First byte of the first prior-state entry. Each entry is
42
+ * `varint entity_id, uint8 component_type_id, uint32 len, bytes[len]`.
43
+ * @type {number}
44
+ */
45
+ prior_start: number;
46
+ /** @type {number} */
47
+ action_type_id: number;
48
+ /**
49
+ * The record's provenance byte: a peer id, `SENDER_LOCAL` or
50
+ * `SENDER_DERIVED` (see {@link SimActionExecutor}).
51
+ * @type {number}
52
+ */
53
+ sender_id: number;
54
+ /** @type {number} first byte of the action payload */
55
+ payload_start: number;
56
+ /** @type {number} */
57
+ payload_len: number;
58
+ /**
59
+ * One past the last byte of the current record — and so the first byte of
60
+ * the next, which is where {@link next} resumes. `0` before the first
61
+ * record.
62
+ * @type {number}
63
+ */
64
+ record_end: number;
65
+ /**
66
+ * Position the cursor before the first record of `frame`, which must be
67
+ * closed and present in the log (see {@link ActionLog#buffer_for}).
68
+ *
69
+ * @param {ActionLog} action_log
70
+ * @param {number} frame
71
+ */
72
+ open(action_log: ActionLog, frame: number): void;
73
+ /**
74
+ * Advance to the next record. Returns false once the frame is exhausted,
75
+ * leaving the fields as they were.
76
+ *
77
+ * @returns {boolean}
78
+ */
79
+ next(): boolean;
80
+ #private;
81
+ }
82
+ //# sourceMappingURL=ActionRecordCursor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ActionRecordCursor.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/sim/ActionRecordCursor.js"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH;IACI;;;;OAIG;IACH,QAFU,eAAa,IAAI,CAEb;IAEd,sDAAsD;IACtD,cADW,MAAM,CACA;IAEjB,+DAA+D;IAC/D,aADW,MAAM,CACD;IAEhB;;;;OAIG;IACH,aAFU,MAAM,CAEA;IAEhB,qBAAqB;IACrB,gBADW,MAAM,CACE;IAEnB;;;;OAIG;IACH,WAFU,MAAM,CAEF;IAEd,sDAAsD;IACtD,eADW,MAAM,CACC;IAElB,qBAAqB;IACrB,aADW,MAAM,CACD;IAEhB;;;;;OAKG;IACH,YAFU,MAAM,CAED;IAKf;;;;;;OAMG;IACH,mCAFW,MAAM,QAMhB;IAED;;;;;OAKG;IACH,QAFa,OAAO,CAyBnB;;CACJ"}