@woosh/meep-engine 3.14.3 → 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 (137) 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/Engine.d.ts.map +1 -1
  6. package/src/engine/Engine.js +733 -726
  7. package/src/engine/network/NetworkSession.d.ts +24 -4
  8. package/src/engine/network/NetworkSession.d.ts.map +1 -1
  9. package/src/engine/network/NetworkSession.js +28 -4
  10. package/src/engine/network/orchestrator/NetworkPeer.d.ts +96 -7
  11. package/src/engine/network/orchestrator/NetworkPeer.d.ts.map +1 -1
  12. package/src/engine/network/orchestrator/NetworkPeer.js +392 -68
  13. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts +6 -1
  14. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts.map +1 -1
  15. package/src/engine/network/orchestrator/ServerAuthoritativeClient.js +6 -0
  16. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts +105 -13
  17. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts.map +1 -1
  18. package/src/engine/network/orchestrator/ServerAuthoritativeServer.js +306 -59
  19. package/src/engine/network/replication/Replicator.d.ts +43 -7
  20. package/src/engine/network/replication/Replicator.d.ts.map +1 -1
  21. package/src/engine/network/replication/Replicator.js +309 -111
  22. package/src/engine/network/sim/ActionLog.d.ts +23 -3
  23. package/src/engine/network/sim/ActionLog.d.ts.map +1 -1
  24. package/src/engine/network/sim/ActionLog.js +26 -3
  25. package/src/engine/network/sim/ActionRecordCursor.d.ts +82 -0
  26. package/src/engine/network/sim/ActionRecordCursor.d.ts.map +1 -0
  27. package/src/engine/network/sim/ActionRecordCursor.js +120 -0
  28. package/src/engine/network/sim/RewindEngine.d.ts.map +1 -1
  29. package/src/engine/network/sim/RewindEngine.js +13 -29
  30. package/src/engine/network/sim/SimAction.d.ts +15 -0
  31. package/src/engine/network/sim/SimAction.d.ts.map +1 -1
  32. package/src/engine/network/sim/SimAction.js +15 -0
  33. package/src/engine/network/sim/SimActionExecutor.d.ts +51 -2
  34. package/src/engine/network/sim/SimActionExecutor.d.ts.map +1 -1
  35. package/src/engine/network/sim/SimActionExecutor.js +74 -7
  36. package/src/engine/network/transport/Channel.d.ts +1 -1
  37. package/src/engine/network/transport/Channel.js +1 -1
  38. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  39. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  40. package/src/engine/simulation/Ticker.js +4 -1
  41. package/src/engine/ui/GUIEngine.d.ts.map +1 -1
  42. package/src/engine/ui/GUIEngine.js +6 -0
  43. package/src/shade/playground/particle_system/README.md +98 -0
  44. package/src/shade/playground/particle_system/index.html +124 -0
  45. package/src/shade/playground/particle_system/main.d.ts +2 -0
  46. package/src/shade/playground/particle_system/main.d.ts.map +1 -0
  47. package/src/shade/playground/particle_system/main.js +180 -0
  48. package/src/shade/playground/particle_system/particle_scene.d.ts +52 -0
  49. package/src/shade/playground/particle_system/particle_scene.d.ts.map +1 -0
  50. package/src/shade/playground/particle_system/particle_scene.js +553 -0
  51. package/src/shade/playground/skinned_mesh_soup/README.md +143 -0
  52. package/src/shade/playground/skinned_mesh_soup/index.html +202 -0
  53. package/src/shade/playground/skinned_mesh_soup/main.d.ts +7 -0
  54. package/src/shade/playground/skinned_mesh_soup/main.d.ts.map +1 -0
  55. package/src/shade/playground/skinned_mesh_soup/main.js +567 -0
  56. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts +29 -0
  57. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts.map +1 -0
  58. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.js +428 -0
  59. package/src/shade/renderer/animation/GPUAnimationManager.d.ts.map +1 -1
  60. package/src/shade/renderer/animation/GPUAnimationManager.js +1958 -1796
  61. package/src/shade/renderer/animation/SKINNING_FRAME_PLAN.md +357 -0
  62. package/src/shade/renderer/animation/skin_bind_transform.d.ts +25 -0
  63. package/src/shade/renderer/animation/skin_bind_transform.d.ts.map +1 -0
  64. package/src/shade/renderer/animation/skin_bind_transform.js +33 -0
  65. package/src/shade/renderer/animation/skin_clip_scale_conflicts.d.ts +67 -0
  66. package/src/shade/renderer/animation/skin_clip_scale_conflicts.d.ts.map +1 -0
  67. package/src/shade/renderer/animation/skin_clip_scale_conflicts.js +199 -0
  68. package/src/shade/renderer/animation/skin_frame_defect.d.ts +34 -0
  69. package/src/shade/renderer/animation/skin_frame_defect.d.ts.map +1 -0
  70. package/src/shade/renderer/animation/skin_frame_defect.js +132 -0
  71. package/src/shade/renderer/animation/skin_joint_matrix.d.ts +24 -0
  72. package/src/shade/renderer/animation/skin_joint_matrix.d.ts.map +1 -0
  73. package/src/shade/renderer/animation/skin_joint_matrix.js +46 -0
  74. package/src/shade/renderer/animation/skin_normalize_mesh_frame.d.ts +70 -0
  75. package/src/shade/renderer/animation/skin_normalize_mesh_frame.d.ts.map +1 -0
  76. package/src/shade/renderer/animation/skin_normalize_mesh_frame.js +216 -0
  77. package/src/shade/renderer/animation/skin_test_fixtures.d.ts +86 -0
  78. package/src/shade/renderer/animation/skin_test_fixtures.d.ts.map +1 -0
  79. package/src/shade/renderer/animation/skin_test_fixtures.js +250 -0
  80. package/src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.d.ts +18 -4
  81. package/src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.d.ts.map +1 -1
  82. package/src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.js +18 -4
  83. package/src/shade/renderer/particles/DESIGN.md +5 -5
  84. package/src/shade/renderer/particles/cull/chunk_particle_cull.d.ts.map +1 -1
  85. package/src/shade/renderer/particles/cull/chunk_particle_cull.js +3 -1
  86. package/src/shade/renderer/particles/data/chunk_particle_emitter.js +1 -1
  87. package/src/shade/renderer/particles/graph/ParticleNodeDescription.d.ts +43 -0
  88. package/src/shade/renderer/particles/graph/ParticleNodeDescription.d.ts.map +1 -0
  89. package/src/shade/renderer/particles/graph/ParticleNodeDescription.js +44 -0
  90. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.d.ts +33 -59
  91. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.d.ts.map +1 -1
  92. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.js +185 -118
  93. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts +15 -10
  94. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts.map +1 -1
  95. package/src/shade/renderer/particles/graph/compile_particle_graph.js +205 -170
  96. package/src/shade/renderer/particles/graph/particle_graph_authoring.d.ts +44 -0
  97. package/src/shade/renderer/particles/graph/particle_graph_authoring.d.ts.map +1 -0
  98. package/src/shade/renderer/particles/graph/particle_graph_authoring.js +108 -0
  99. package/src/shade/renderer/particles/isa/InstructionStream.d.ts +89 -0
  100. package/src/shade/renderer/particles/isa/InstructionStream.d.ts.map +1 -0
  101. package/src/shade/renderer/particles/isa/InstructionStream.js +162 -0
  102. package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts +1 -48
  103. package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts.map +1 -1
  104. package/src/shade/renderer/particles/isa/ParticleAssembler.js +25 -114
  105. package/src/shade/renderer/particles/particle_prototype.d.ts.map +1 -1
  106. package/src/shade/renderer/particles/particle_prototype.js +181 -180
  107. package/src/shade/renderer/particles/runtime/create_particle_effect.d.ts +4 -4
  108. package/src/shade/renderer/particles/runtime/create_particle_effect.d.ts.map +1 -1
  109. package/src/shade/renderer/particles/runtime/create_particle_effect.js +19 -19
  110. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.d.ts.map +1 -1
  111. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.js +4 -2
  112. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts +4 -4
  113. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts.map +1 -1
  114. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.js +7 -5
  115. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.d.ts.map +1 -1
  116. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.js +3 -1
  117. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
  118. package/src/shade/renderer/particles/shaders/shader_particle_emit.js +3 -1
  119. package/src/shade/renderer/particles/shaders/shader_particle_finalize.d.ts.map +1 -1
  120. package/src/shade/renderer/particles/shaders/shader_particle_finalize.js +5 -2
  121. package/src/shade/renderer/particles/shaders/shader_particle_render.d.ts.map +1 -1
  122. package/src/shade/renderer/particles/shaders/shader_particle_render.js +9 -2
  123. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
  124. package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +3 -1
  125. package/src/shade/renderer/particles/vm/chunk_particle_vm.js +1 -1
  126. package/src/shade/renderer/scene/SkinnedMesh.d.ts +11 -0
  127. package/src/shade/renderer/scene/SkinnedMesh.d.ts.map +1 -1
  128. package/src/shade/renderer/scene/SkinnedMesh.js +11 -0
  129. package/src/shade/renderer/shader/chunk/geometry/quaternion/dual/chunk_dual_quat_from_m4.d.ts +33 -3
  130. package/src/shade/renderer/shader/chunk/geometry/quaternion/dual/chunk_dual_quat_from_m4.d.ts.map +1 -1
  131. package/src/shade/renderer/shader/chunk/geometry/quaternion/dual/chunk_dual_quat_from_m4.js +58 -37
  132. package/src/shade/renderer/particles/graph/ParticleGraph.d.ts +0 -54
  133. package/src/shade/renderer/particles/graph/ParticleGraph.d.ts.map +0 -1
  134. package/src/shade/renderer/particles/graph/ParticleGraph.js +0 -99
  135. package/src/shade/renderer/particles/prototypeParticleSystem.d.ts +0 -2
  136. package/src/shade/renderer/particles/prototypeParticleSystem.d.ts.map +0 -1
  137. package/src/shade/renderer/particles/prototypeParticleSystem.js +0 -448
@@ -7,7 +7,7 @@ import { Replicator } from "../replication/Replicator.js";
7
7
  import { AlwaysRelevantScope } from "../replication/ScopeFilter.js";
8
8
  import { ActionLog } from "../sim/ActionLog.js";
9
9
  import { ReplicatedComponentRegistry } from "../sim/ReplicatedComponentRegistry.js";
10
- import { SimActionExecutor } from "../sim/SimActionExecutor.js";
10
+ import { MAX_PEER_ID, SimActionExecutor } from "../sim/SimActionExecutor.js";
11
11
  import { SimActionRegistry } from "../sim/SimActionRegistry.js";
12
12
  import { snapshotter_emit } from "../sim/Snapshotter.js";
13
13
  import { Baseline } from "../state/Baseline.js";
@@ -27,6 +27,19 @@ import { ReliableCommandPipeline } from "../transport/ReliableCommandPipeline.js
27
27
  const DISCONNECT_TEXT_ENCODER = new TextEncoder();
28
28
  const DISCONNECT_TEXT_DECODER = new TextDecoder();
29
29
 
30
+ // ACTION_STREAM slice header, after the packet-type byte: uint32 frame_start,
31
+ // uint32 frame_end, uint8 head. See NetworkPacketType.ACTION_STREAM.
32
+ const ACTION_STREAM_SLICE_HEADER_BYTES = 9;
33
+
34
+ // Widest slice a header may claim. A slice covers frames from the pack start
35
+ // to the last frame packed, so it never spans more than the sender's action
36
+ // log holds — a ring of tens or hundreds of frames. The receiver raises its
37
+ // apply watermark to a head's frame_end, so a header claiming 2^32 frames
38
+ // would have it skip every frame the sender ever sends after; that is a
39
+ // sender bug or a sender lying about its own stream, and either is dropped as
40
+ // malformed rather than obeyed.
41
+ const MAX_ACTION_STREAM_SLICE_FRAMES = 65536;
42
+
30
43
  /**
31
44
  * Wire packet types. The first byte of every NetworkPeer-routed payload picks
32
45
  * the dispatch path. New types can be added without breaking the action stream
@@ -36,6 +49,20 @@ const DISCONNECT_TEXT_DECODER = new TextDecoder();
36
49
  * @enum {number}
37
50
  */
38
51
  export const NetworkPacketType = {
52
+ /**
53
+ * A slice of the action stream. Wire layout (after this type byte):
54
+ *
55
+ * uint32 frame_start first frame the slice covers
56
+ * uint32 frame_end last frame the slice covers
57
+ * uint8 head 1 if this is the first slice of the sender's tick
58
+ * bytes groups {@link Replicator} wire format
59
+ *
60
+ * A tick may send several slices, and the receiver applies them in frame
61
+ * order: a slice that is not a head and starts past what the receiver has
62
+ * applied is held until the slices before it land. A head's start is the
63
+ * earliest frame the sender will send again, so it is applied at once.
64
+ * A packet of just the type byte carries nothing and is ignored.
65
+ */
39
66
  ACTION_STREAM: 0,
40
67
  RECOVERY_REQUEST: 1,
41
68
  STATE_BURST: 2,
@@ -168,14 +195,40 @@ export const ResumeRejectReason = Object.freeze({
168
195
  NoSessionToken: 5,
169
196
  });
170
197
 
198
+ /**
199
+ * One outbound action-stream send, awaiting confirmation.
200
+ *
201
+ * A send occupies one transport seq when it fits the MTU and N consecutive
202
+ * seqs when it has to be fragmented. All of those seqs map to the SAME record:
203
+ * the receiver reassembles a fragmented message all-or-nothing, so the frame
204
+ * range is confirmed only when `unacked` reaches zero without `abandoned`
205
+ * having been set by a loss along the way.
206
+ *
207
+ * A tick may make several sends to one peer, each carrying the next slice of
208
+ * the owed range (see {@link NetworkPeer#flush_outbound}). They confirm
209
+ * independently, and the baseline advances only through slices confirmed in
210
+ * order — a later slice landing says nothing about an earlier one still in
211
+ * flight. `head` marks the first slice of a tick, whose start is the pack
212
+ * start itself; see {@link NetworkPeer#__credit_confirmed}.
213
+ *
214
+ * @typedef {{frame_start: number, frame_end: number, head: boolean, unacked: number, abandoned: boolean}} PendingAck
215
+ * @property {number} frame_start first frame in the packed range
216
+ * @property {number} frame_end last frame in the packed range
217
+ * @property {boolean} head this send began at the pack start, so nothing
218
+ * sendable lies before it and its confirmation credits outright
219
+ * @property {number} unacked seqs of this send not yet acked or declared lost
220
+ * @property {boolean} abandoned at least one seq was declared lost, so the
221
+ * message is forfeit and must never advance the baseline
222
+ */
223
+
171
224
  /**
172
225
  * Per-tick orchestrator that wires the action infrastructure to one or more
173
226
  * peers over a Transport.
174
227
  *
175
228
  * Holds a single world (`EntityComponentDataset`) plus the action machinery
176
229
  * (executor, replicator, action log). For each connected peer it owns a
177
- * `Channel` and a `seq → frame_end` map so that ack notifications advance the
178
- * `Baseline.last_acked(peer_id)` watermark.
230
+ * `Channel` and a `seq`{@link PendingAck} map so that ack notifications
231
+ * advance the `Baseline.last_acked(peer_id)` watermark.
179
232
  *
180
233
  * Use as both server (many connected peers) and client (one peer = the server).
181
234
  * The asymmetry is in *who* connects — the orchestrator itself is symmetric.
@@ -222,6 +275,25 @@ export class NetworkPeer {
222
275
  */
223
276
  #malformed_packets_dropped = 0;
224
277
 
278
+ /**
279
+ * Scratch list of the transport seqs one send produced — one entry for a
280
+ * single-packet send, one per fragment for a fragmented one. Reused across
281
+ * sends so the common path allocates nothing; only valid until the next
282
+ * `__send_to_peer` call.
283
+ * @type {number[]}
284
+ * @private
285
+ */
286
+ #sent_seqs = [];
287
+
288
+ /**
289
+ * Scratch for the slice header of the ACTION_STREAM packet being
290
+ * dispatched, handed to `Replicator.unpack_from_peer`, which copies what
291
+ * it keeps. Only valid for the duration of that call.
292
+ * @type {{frame_start: number, frame_end: number, head: boolean}}
293
+ * @private
294
+ */
295
+ #slice = { frame_start: 0, frame_end: 0, head: false };
296
+
225
297
  /**
226
298
  * @param {{
227
299
  * world: EntityComponentDataset,
@@ -233,6 +305,7 @@ export class NetworkPeer {
233
305
  * initial_buffer_size?: number,
234
306
  * mutation_ledger?: MutationLedger|null,
235
307
  * changed_set_capacity?: number,
308
+ * max_packets_per_tick?: number,
236
309
  * }} options
237
310
  */
238
311
  constructor({
@@ -245,11 +318,13 @@ export class NetworkPeer {
245
318
  initial_buffer_size = 1024,
246
319
  mutation_ledger = null,
247
320
  changed_set_capacity = 256,
321
+ max_packets_per_tick = 8,
248
322
  }) {
249
323
  assert.ok(world && typeof world.createEntity === 'function', 'world must be an EntityComponentDataset');
250
324
  assert.ok(binary_registry, 'binary_registry required');
251
325
  assert.isFunction(replicated_components.forEach, 'replicated_components must be iterable');
252
326
  assert.isFunction(action_classes.forEach, 'action_classes must be iterable');
327
+ assert.isPositiveInteger(max_packets_per_tick, 'max_packets_per_tick');
253
328
 
254
329
  /** @type {EntityComponentDataset} */
255
330
  this.world = world;
@@ -272,6 +347,33 @@ export class NetworkPeer {
272
347
  /** @type {ActionLog} */
273
348
  this.action_log = new ActionLog({ frame_capacity, initial_buffer_size });
274
349
 
350
+ /**
351
+ * Most action-stream packets {@link flush_outbound} may send one peer
352
+ * in a tick. Each carries the next slice of what the peer is owed and
353
+ * is confirmed on its own, so this caps how fast a peer that has
354
+ * fallen behind catches up: a slice is re-sent every tick until its ack
355
+ * returns, so a peer is confirmed at most this many packets of frames
356
+ * per round trip, against the one frame per tick the simulation
357
+ * produces. One packet a tick keeps up only while a round trip's frames
358
+ * fit one packet; past that the owed range grows until it meets the
359
+ * ring floor and frames are lost.
360
+ *
361
+ * It is also the bandwidth ceiling under catch-up — this many MTUs per
362
+ * tick per peer — and it has to stay inside the channel's ack window.
363
+ * A peer's ack header covers its latest 33 seqs, so with a peer that
364
+ * replies once a tick, `33 / max_packets_per_tick` consecutive lost
365
+ * replies leave a delivered packet outside the window and it is
366
+ * declared lost: a wasted re-send rather than a defect, since the
367
+ * receiver skips frames it has already applied. Eight tolerates three
368
+ * such losses in a row (one tick in sixteen at 50% loss, one in ten
369
+ * thousand at 10%), and lets a 60 Hz stream at 100 ms RTT carry a full
370
+ * packet of actions per frame. Lower it for peers that reply less often
371
+ * than every tick.
372
+ *
373
+ * @type {number}
374
+ */
375
+ this.max_packets_per_tick = max_packets_per_tick;
376
+
275
377
  /**
276
378
  * Optional per-tick mutation tracker. When a `MutationLedger` is supplied,
277
379
  * this peer records each tick's affected network IDs into a fresh
@@ -317,8 +419,8 @@ export class NetworkPeer {
317
419
  this.baseline = new Baseline();
318
420
 
319
421
  /**
320
- * peer_id → { channel, transport, seq_to_frame_end, on_payload, on_acked }
321
- * @type {Map<number, { channel: Channel, transport: Object, seq_to_frame_end: Map<number, number>, on_payload: Function, on_acked: Function }>}
422
+ * peer_id → { channel, transport, seq_to_pending, confirmed, on_payload, on_acked }
423
+ * @type {Map<number, { channel: Channel, transport: Object, seq_to_pending: Map<number, PendingAck>, confirmed: Int32Array, on_payload: Function, on_acked: Function }>}
322
424
  * @private
323
425
  */
324
426
  this.#peers = new Map();
@@ -463,13 +565,38 @@ export class NetworkPeer {
463
565
  */
464
566
  connect_peer(peer_id, transport) {
465
567
  assert.isNonNegativeInteger(peer_id, 'peer_id');
568
+ // The action log records a peer's id as the provenance of its actions,
569
+ // in one byte whose top two values are reserved for locally-authored
570
+ // and sim-derived records. A peer holding one of those would have its
571
+ // input read back as the local sim's own output and dropped by the
572
+ // first rollback replay of its frame, so refuse the connection rather
573
+ // than lose its input later. Real runtime check — asserts strip in prod.
574
+ if (peer_id > MAX_PEER_ID) {
575
+ throw new Error(`NetworkPeer.connect_peer: peer id ${peer_id} is out of range; must be in [0, ${MAX_PEER_ID}] — 254 and 255 are reserved provenance markers in the action log`);
576
+ }
466
577
  if (this.#peers.has(peer_id)) {
467
578
  throw new Error(`NetworkPeer.connect_peer: peer ${peer_id} already connected`);
468
579
  }
469
580
 
470
581
  const channel = new Channel({ transport });
471
- const seq_to_frame_end = new Map();
472
- const fragment_assembler = new FragmentAssembler();
582
+ const seq_to_pending = new Map();
583
+ // Frames this peer has confirmed that the baseline has not reached yet,
584
+ // because a slice before them is still in flight. A ring indexed by
585
+ // frame modulo capacity holding the frame number — see
586
+ // __credit_confirmed.
587
+ const confirmed = new Int32Array(this.action_log.frame_capacity).fill(-1);
588
+ // The reassembly window is sized from the burst. A frame too big for
589
+ // one packet goes out as a fragmented slice, and a tick may send
590
+ // `max_packets_per_tick` of them; jitter that holds a message's later
591
+ // fragments behind the next ticks' first fragments leaves that many
592
+ // partial messages per tick in flight at once. The assembler evicts
593
+ // the oldest partial when its window is full, both fragments of the
594
+ // evicted message are still acked, and the sender credits the slice —
595
+ // a silent loss. Four ticks of burst keeps the window ahead of any
596
+ // jitter the NACK timer would not already have caught.
597
+ const fragment_assembler = new FragmentAssembler({
598
+ max_pending_messages: Math.max(8, 4 * this.max_packets_per_tick),
599
+ });
473
600
  const fragment_retention = new FragmentRetention();
474
601
  const reliable_pipeline = new ReliableCommandPipeline({
475
602
  channel,
@@ -513,11 +640,25 @@ export class NetworkPeer {
513
640
  const packet_type = this.#recv_buffer.readUint8();
514
641
 
515
642
  switch (packet_type) {
516
- case NetworkPacketType.ACTION_STREAM:
517
- // The Replicator opens a frame on the action log per packet
518
- // (one per sender frame).
519
- this.replicator.unpack_from_peer(peer_id, this.#recv_buffer, length);
643
+ case NetworkPacketType.ACTION_STREAM: {
644
+ // Just the type byte: an empty stream, nothing to apply.
645
+ if (length === 1) break;
646
+ if (length < 1 + ACTION_STREAM_SLICE_HEADER_BYTES) {
647
+ throw new MalformedPacketError(`NetworkPeer.dispatch: ACTION_STREAM truncated before its slice header`);
648
+ }
649
+ const slice = this.#slice;
650
+ slice.frame_start = this.#recv_buffer.readUint32();
651
+ slice.frame_end = this.#recv_buffer.readUint32();
652
+ slice.head = this.#recv_buffer.readUint8() !== 0;
653
+ if (slice.frame_end < slice.frame_start
654
+ || slice.frame_end - slice.frame_start >= MAX_ACTION_STREAM_SLICE_FRAMES) {
655
+ throw new MalformedPacketError(`NetworkPeer.dispatch: ACTION_STREAM slice [${slice.frame_start}, ${slice.frame_end}] is inverted or wider than any action log`);
656
+ }
657
+ // The Replicator opens a frame on the action log per group
658
+ // (one per sender frame), applying slices in frame order.
659
+ this.replicator.unpack_from_peer(peer_id, this.#recv_buffer, length, slice);
520
660
  break;
661
+ }
521
662
  case NetworkPacketType.RECOVERY_REQUEST: {
522
663
  const start_tick = this.#recv_buffer.readUintVar();
523
664
  const end_tick = this.#recv_buffer.readUintVar();
@@ -639,22 +780,43 @@ export class NetworkPeer {
639
780
  }
640
781
  };
641
782
 
642
- // Ack: advance Baseline to the highest acked frame_end for this peer.
783
+ // Ack: a send is confirmed once every seq it went out under has been
784
+ // acked. For a single-packet send that is the one seq. For a
785
+ // fragmented send it is all N fragments, because the receiver applies
786
+ // nothing until it has reassembled the whole message; advancing on the
787
+ // first fragment's ack would retire a frame range the receiver never
788
+ // saw, from the only mechanism that could still re-send it. What a
789
+ // confirmed send credits to the baseline is decided in order, by
790
+ // __credit_confirmed.
643
791
  const on_acked = (seq) => {
644
- const frame_end = seq_to_frame_end.get(seq);
645
- if (frame_end !== undefined) {
646
- this.baseline.set_acked(peer_id, frame_end);
647
- seq_to_frame_end.delete(seq);
792
+ const pending = seq_to_pending.get(seq);
793
+ if (pending === undefined) return;
794
+ seq_to_pending.delete(seq);
795
+ pending.unacked--;
796
+ if (pending.unacked === 0 && !pending.abandoned) {
797
+ this.#credit_confirmed(peer_id, confirmed, pending);
648
798
  }
649
799
  };
650
800
 
651
801
  // Loss: a packet that aged out of the ack window without being acked
652
- // leaves a dead seq→frame_end entry. The baseline must NOT advance (the
653
- // frame was never confirmed; back-fill resends it under a fresh seq), but
654
- // the stale mapping should be reclaimed otherwise lost-packet entries
655
- // accumulate across the session until the seq value is reused on wrap.
802
+ // leaves a dead seq→record entry. The baseline must NOT advance (the
803
+ // frame range was never confirmed; the back-fill re-sends it under a
804
+ // fresh seq), and the record is marked abandoned so that siblings
805
+ // acking afterwards cannot complete it one lost fragment forfeits the
806
+ // whole message. The mapping is reclaimed either way, otherwise
807
+ // lost-packet entries accumulate across the session until the seq value
808
+ // is reused on wrap.
809
+ //
810
+ // Every outstanding seq is resolved by Channel as exactly one of acked
811
+ // or lost — via the ack bitfield, the 33-seq window age-out, or the
812
+ // max_in_flight eviction — so no record can be stranded by a seq that
813
+ // never reports.
656
814
  const on_lost = (seq) => {
657
- seq_to_frame_end.delete(seq);
815
+ const pending = seq_to_pending.get(seq);
816
+ if (pending === undefined) return;
817
+ seq_to_pending.delete(seq);
818
+ pending.unacked--;
819
+ pending.abandoned = true;
658
820
  };
659
821
 
660
822
  // Untrusted-input boundary. A structurally-malformed or truncated
@@ -688,7 +850,7 @@ export class NetworkPeer {
688
850
  channel.onPacketLost.add(on_lost);
689
851
 
690
852
  this.#peers.set(peer_id, {
691
- channel, transport, seq_to_frame_end,
853
+ channel, transport, seq_to_pending, confirmed,
692
854
  on_payload: payload_handler, on_acked, on_lost, on_nack,
693
855
  fragment_assembler, fragment_retention,
694
856
  reliable_pipeline,
@@ -764,6 +926,21 @@ export class NetworkPeer {
764
926
  return conn === undefined ? 0 : conn.inbound_count;
765
927
  }
766
928
 
929
+ /**
930
+ * Number of outbound action-stream seqs for `peer_id` still awaiting an
931
+ * ack-or-lost verdict. Returns 0 for an unknown peer.
932
+ *
933
+ * Diagnostics: this is bounded by the channel's in-flight window and must
934
+ * fall back to 0 once a stream of acks has resolved everything sent. A
935
+ * value that only grows is a leak in the ack accounting.
936
+ * @param {number} peer_id
937
+ * @returns {number}
938
+ */
939
+ pending_ack_count(peer_id) {
940
+ const conn = this.#peers.get(peer_id);
941
+ return conn === undefined ? 0 : conn.seq_to_pending.size;
942
+ }
943
+
767
944
  /**
768
945
  * Direct access to a peer's channel (for sending acks-only packets, raw payloads, etc.).
769
946
  * @param {number} peer_id
@@ -797,8 +974,8 @@ export class NetworkPeer {
797
974
  /**
798
975
  * Close the current tick and send pending action records to every peer.
799
976
  * For each peer, packs frames `[Baseline.last_acked(peer) + 1, current_frame]`
800
- * and sends via the channel; records the seq→frame_end mapping so the next
801
- * ack from that peer advances the baseline.
977
+ * and sends via the channel; records the seqs the send went out under so
978
+ * that acks for all of them advance the baseline.
802
979
  *
803
980
  * If a {@link MutationLedger} was supplied at construction, this tick's
804
981
  * accumulated changed-entity set is also compacted into the ledger.
@@ -835,8 +1012,10 @@ export class NetworkPeer {
835
1012
  * by the caller. Records the tick's accumulated mutations into the
836
1013
  * MutationLedger (if configured), then packs frames
837
1014
  * `[last_acked+1, current_frame]` for each peer via
838
- * `Replicator.pack_for_peer`. Empty packs (no in-scope actions) are
839
- * not sent. Clears the per-tick changed-entity set after recording.
1015
+ * `Replicator.pack_for_peer` into up to {@link max_packets_per_tick}
1016
+ * channel packets, each the next slice of that range. Empty packs (no
1017
+ * in-scope actions) are not sent. Clears the per-tick changed-entity set
1018
+ * after recording.
840
1019
  *
841
1020
  * @param {number} current_frame
842
1021
  */
@@ -853,15 +1032,14 @@ export class NetworkPeer {
853
1032
 
854
1033
  for (const [peer_id, conn] of this.#peers) {
855
1034
  const last_acked = this.baseline.last_acked(peer_id);
856
- // Pack-range start.
1035
+ // Pack-range start: whichever is newer of "catch up since you
1036
+ // last confirmed receipt" and "the oldest frame the log still
1037
+ // holds."
857
1038
  //
858
- // For peers with at least one acked frame, this is just
859
- // last_acked+1 the standard "catch up since you last
860
- // confirmed receipt."
861
- //
862
- // For freshly-connected peers (no ack yet), we cover the
863
- // action_log's full ring: max(0, current_frame -
864
- // frame_capacity + 1). Two competing constraints:
1039
+ // last_acked+1 is the ordinary catch-up point. A freshly-
1040
+ // connected peer has no ack, so its -1+1 falls through to the
1041
+ // ring floor and it covers the log's full content. Two competing
1042
+ // constraints shaped that floor:
865
1043
  //
866
1044
  // - Don't pack `current_frame` only: under any non-zero
867
1045
  // latency, A's actions tagged at frame X land on the
@@ -877,28 +1055,120 @@ export class NetworkPeer {
877
1055
  //
878
1056
  // `current_frame - frame_capacity + 1` is the oldest frame
879
1057
  // the ring could still hold, so we naturally cap at the
880
- // ring's actual content. Actions referencing entities the
881
- // peer's slot_table doesn't yet know about silently no-op
882
- // (Replicator translates via network_for -1; SimAction
883
- // apply hits the `if (local < 0) return` guard). For first-
884
- // connect scenarios the app is still expected to send a
885
- // state-burst seed; this just makes the action-stream
886
- // bootstrap work in addition to that.
887
- const start = last_acked < 0
888
- ? Math.max(0, current_frame - this.action_log.frame_capacity + 1)
889
- : last_acked + 1;
1058
+ // ring's actual content.
1059
+ //
1060
+ // The floor binds acked peers too, not just fresh ones: a peer
1061
+ // that acked once and then stopped leaves last_acked pinned while
1062
+ // current_frame runs on, and without the floor the loop walks
1063
+ // every frame since that ack to emit at most a ring's worth —
1064
+ // work that grows without bound the longer the peer stays quiet.
1065
+ // Skipping those frames costs nothing, because they are already
1066
+ // gone from the log and can never be sent whatever the ack says;
1067
+ // `pack_for_peer` credits them as covered the moment it reaches
1068
+ // them anyway, so the frame this pack confirms is the same
1069
+ // either way.
1070
+ //
1071
+ // Actions referencing entities the peer's slot_table doesn't yet
1072
+ // know about silently no-op (Replicator translates via
1073
+ // network_for → -1; SimAction apply hits the `if (local < 0)
1074
+ // return` guard). For first-connect scenarios the app is still
1075
+ // expected to send a state-burst seed; this just makes the
1076
+ // action-stream bootstrap work in addition to that.
1077
+ const start = Math.max(
1078
+ last_acked + 1,
1079
+ current_frame - this.action_log.frame_capacity + 1,
1080
+ 0,
1081
+ );
890
1082
  if (start > current_frame) continue;
891
1083
 
892
- this.#send_buffer.position = 0;
893
- // Packet-type prefix.
894
- this.#send_buffer.writeUint8(NetworkPacketType.ACTION_STREAM);
895
- this.replicator.pack_for_peer(peer_id, start, current_frame, this.#send_buffer);
896
- const length = this.#send_buffer.position;
897
- // Just the prefix no in-scope actions to send.
898
- if (length === 1) continue;
899
-
900
- const seq = this.#send_to_peer(conn, this.#send_buffer.raw_bytes, length);
901
- conn.seq_to_frame_end.set(seq, current_frame);
1084
+ // Up to `max_packets_per_tick` sends, each bounded to one channel
1085
+ // packet and each taking the next slice of the owed range.
1086
+ //
1087
+ // Bounding a send to one packet is load-bearing. A fragmented
1088
+ // action stream is confirmed only if EVERY fragment survives, so a
1089
+ // send that grows with the owed range is self-defeating under
1090
+ // loss: one lost fragment pins the baseline, which widens the next
1091
+ // pack, which raises the fragment count N, which drops
1092
+ // P(all arrive) = (1-p)^N geometrically — the range then widens
1093
+ // faster than it drains and delivery collapses. Split into whole
1094
+ // packets instead, each slice is confirmed on its own and a loss
1095
+ // forfeits one slice, not the tick.
1096
+ //
1097
+ // Sending more than one is what keeps the stream up with the
1098
+ // simulation. A slice is re-sent every tick until its ack returns,
1099
+ // so the baseline moves by at most a tick's worth of slices per
1100
+ // round trip, while the sim produces a frame per tick. One packet
1101
+ // a tick therefore keeps up only while a round trip's frames fit
1102
+ // one packet; past that the owed range grows until it meets the
1103
+ // ring floor, a ring of frames is lost crossing it, and everything
1104
+ // after arrives a ring late — on a link that loses nothing. The
1105
+ // pack is oldest-owed-first, which is what the range order already
1106
+ // gives.
1107
+ //
1108
+ // The budget is what is left of one payload after the prefix and
1109
+ // the slice header, read from the buffer rather than written as a
1110
+ // constant: `pack_for_peer` measures its budget from the current
1111
+ // position, so a hard-coded allowance would have to be edited in
1112
+ // step with every byte added here, and getting that wrong puts
1113
+ // each full pack one byte over the limit and quietly fragments it
1114
+ // again.
1115
+ let from = start;
1116
+ for (let n = 0; n < this.max_packets_per_tick && from <= current_frame; n++) {
1117
+ this.#send_buffer.position = 0;
1118
+ // Packet-type prefix, then the slice header: the range this
1119
+ // packet covers and whether it is the tick's first, which is
1120
+ // what lets the receiver apply slices in frame order (see
1121
+ // Replicator.unpack_from_peer). frame_end is back-patched once
1122
+ // the pack has decided how far it got.
1123
+ this.#send_buffer.writeUint8(NetworkPacketType.ACTION_STREAM);
1124
+ this.#send_buffer.writeUint32(from);
1125
+ const frame_end_position = this.#send_buffer.position;
1126
+ this.#send_buffer.writeUint32(0);
1127
+ this.#send_buffer.writeUint8(n === 0 ? 1 : 0);
1128
+ const packed_through = this.replicator.pack_for_peer(
1129
+ peer_id, from, current_frame, this.#send_buffer,
1130
+ MAX_CHANNEL_PAYLOAD_BYTES - this.#send_buffer.position,
1131
+ );
1132
+ const length = this.#send_buffer.position;
1133
+ // Just the prefix and header → nothing in scope left to send
1134
+ // this peer.
1135
+ if (length === 1 + ACTION_STREAM_SLICE_HEADER_BYTES) break;
1136
+ this.#send_buffer.position = frame_end_position;
1137
+ this.#send_buffer.writeUint32(packed_through);
1138
+ this.#send_buffer.position = length;
1139
+
1140
+ this.#send_to_peer(conn, this.#send_buffer.raw_bytes, length, this.#sent_seqs);
1141
+
1142
+ // One record shared by every seq the send produced. Frames
1143
+ // [from, packed_through] are confirmed only when all of those
1144
+ // seqs ack; a loss on any of them abandons the record and
1145
+ // leaves the range owed, so a later tick's pack picks it up
1146
+ // again.
1147
+ //
1148
+ // frame_end is what the pack actually held, NOT current_frame:
1149
+ // the budget may have stopped it short, and crediting frames
1150
+ // that were left out of the packet would retire them from the
1151
+ // back-fill unsent — the same defect as crediting a whole
1152
+ // message to its first fragment, one layer up.
1153
+ //
1154
+ // Only the first slice starts at the pack start. The rest start
1155
+ // where the previous slice stopped, and their confirmation is
1156
+ // held until everything before them is confirmed too — see
1157
+ // __credit_confirmed.
1158
+ const seq_count = this.#sent_seqs.length;
1159
+ const pending = {
1160
+ frame_start: from,
1161
+ frame_end: packed_through,
1162
+ head: n === 0,
1163
+ unacked: seq_count,
1164
+ abandoned: false,
1165
+ };
1166
+ for (let i = 0; i < seq_count; i++) {
1167
+ conn.seq_to_pending.set(this.#sent_seqs[i], pending);
1168
+ }
1169
+
1170
+ from = packed_through + 1;
1171
+ }
902
1172
  }
903
1173
 
904
1174
  // Reset the per-tick changed-entity set so the next tick starts clean.
@@ -906,28 +1176,84 @@ export class NetworkPeer {
906
1176
  if (this.#changed_entities !== null) this.#changed_entities.clear();
907
1177
  }
908
1178
 
1179
+ /**
1180
+ * A send has been confirmed in full — every seq it went out under acked,
1181
+ * none lost — so the receiver has applied every frame it carried. Record
1182
+ * those frames, then advance the peer's baseline as far as the frames
1183
+ * confirmed so far run contiguously.
1184
+ *
1185
+ * The order matters because a tick may send several slices and they
1186
+ * confirm independently. A later slice landing while an earlier one is
1187
+ * still in flight must not credit the earlier one's frames: they are
1188
+ * still owed, and crediting them would retire them from the back-fill
1189
+ * unsent. So a slice is credited only once the baseline has reached the
1190
+ * frame before it. The exception is the head of a tick's burst. Its start
1191
+ * IS the pack start — `last_acked + 1` or the ring floor, whichever is
1192
+ * newer — so nothing sendable lies before it and its confirmation credits
1193
+ * outright. That is also how the baseline gets past frames the ring has
1194
+ * already retired, which no ack could ever confirm.
1195
+ *
1196
+ * `confirmed` is a ring indexed by frame modulo capacity that stores the
1197
+ * frame number, so a slot whose frame has since been overwritten reads as
1198
+ * unconfirmed rather than as its successor (the {@link FrameBufferRing}
1199
+ * trick). A mark can only be overwritten by a frame a full ring later,
1200
+ * and a frame that far behind is below the ring floor — no longer owed,
1201
+ * and no longer needed to advance the baseline, which a head crosses it
1202
+ * with.
1203
+ *
1204
+ * @param {number} peer_id
1205
+ * @param {Int32Array} confirmed the peer's confirmed-frame ring
1206
+ * @param {PendingAck} pending the record that just drained
1207
+ * @private
1208
+ */
1209
+ #credit_confirmed(peer_id, confirmed, pending) {
1210
+ const capacity = confirmed.length;
1211
+ for (let f = pending.frame_start; f <= pending.frame_end; f++) {
1212
+ confirmed[f % capacity] = f;
1213
+ }
1214
+
1215
+ const before = this.baseline.last_acked(peer_id);
1216
+ let last_acked = before;
1217
+ if (pending.head && pending.frame_end > last_acked) {
1218
+ last_acked = pending.frame_end;
1219
+ }
1220
+ while (confirmed[(last_acked + 1) % capacity] === last_acked + 1) {
1221
+ last_acked++;
1222
+ }
1223
+ if (last_acked > before) {
1224
+ this.baseline.set_acked(peer_id, last_acked);
1225
+ }
1226
+ }
1227
+
909
1228
  /**
910
1229
  * Send a logical payload to a connected peer, fragmenting transparently
911
1230
  * if it exceeds the channel's single-packet capacity.
912
1231
  *
913
- * Returns the seq of the FIRST transport packet emitted. For non-
914
- * fragmented sends that's the only packet; for fragmented sends, that
915
- * seq is what callers should record in `seq_to_frame_end` — when it
916
- * acks, the first fragment arrived, which is a sufficient signal that
917
- * the message is in flight. Fragments either all arrive (and the
918
- * receiver reassembles) or the message is lost; the action-stream's
919
- * back-fill mechanism naturally recovers from the latter on subsequent
920
- * sends.
1232
+ * Fragments either all arrive (and the receiver reassembles) or the
1233
+ * message is lost there is no partial delivery, and the receiver never
1234
+ * applies a message it could not reassemble.
1235
+ *
1236
+ * A caller that ties delivery state to the ack must therefore wait on
1237
+ * EVERY seq the send produced, not just the first. The action stream's
1238
+ * back-fill does recover a lost message, but only for as long as the
1239
+ * frames stay owed: crediting the range to the first fragment's ack
1240
+ * retires it from the back-fill, and a message whose later fragments were
1241
+ * dropped is then never re-sent and never applied. Pass `seqs_out` to
1242
+ * collect the seqs; {@link NetworkPeer#flush_outbound} does exactly that.
921
1243
  *
922
1244
  * @param {object} conn entry from __peers
923
1245
  * @param {Uint8Array} payload starts with the packet-type byte
924
1246
  * @param {number} length
925
- * @returns {number} seq of the first packet sent
1247
+ * @param {number[]} [seqs_out] when given, cleared and then filled with the
1248
+ * seq of every transport packet emitted, in send order
926
1249
  * @private
927
1250
  */
928
- #send_to_peer(conn, payload, length) {
1251
+ #send_to_peer(conn, payload, length, seqs_out = null) {
1252
+ if (seqs_out !== null) seqs_out.length = 0;
929
1253
  if (length <= MAX_CHANNEL_PAYLOAD_BYTES) {
930
- return conn.channel.send(payload, length);
1254
+ const seq = conn.channel.send(payload, length);
1255
+ if (seqs_out !== null) seqs_out.push(seq);
1256
+ return;
931
1257
  }
932
1258
  const message_id = this.#next_fragment_message_id;
933
1259
  this.#next_fragment_message_id = seq16_advance(this.#next_fragment_message_id);
@@ -935,16 +1261,14 @@ export class NetworkPeer {
935
1261
  // Copy happens inside retain(); `payload` is the orchestrator's
936
1262
  // shared send buffer and will be overwritten by the next call.
937
1263
  conn.fragment_retention.retain(message_id, payload, length, performance.now());
938
- let first_seq = -1;
939
1264
  send_fragmented(
940
1265
  payload, length, message_id,
941
1266
  NetworkPacketType.FRAGMENT, this.#fragment_scratch,
942
1267
  (chunk_bytes, chunk_length) => {
943
1268
  const seq = conn.channel.send(chunk_bytes, chunk_length);
944
- if (first_seq < 0) first_seq = seq;
1269
+ if (seqs_out !== null) seqs_out.push(seq);
945
1270
  },
946
1271
  );
947
- return first_seq;
948
1272
  }
949
1273
 
950
1274
  /**