@woosh/meep-engine 3.14.3 → 3.14.4

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 (75) hide show
  1. package/package.json +1 -1
  2. package/src/engine/Engine.d.ts.map +1 -1
  3. package/src/engine/Engine.js +733 -726
  4. package/src/engine/network/NetworkSession.d.ts +24 -4
  5. package/src/engine/network/NetworkSession.d.ts.map +1 -1
  6. package/src/engine/network/NetworkSession.js +28 -4
  7. package/src/engine/network/orchestrator/NetworkPeer.d.ts +47 -6
  8. package/src/engine/network/orchestrator/NetworkPeer.d.ts.map +1 -1
  9. package/src/engine/network/orchestrator/NetworkPeer.js +184 -56
  10. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts +97 -11
  11. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts.map +1 -1
  12. package/src/engine/network/orchestrator/ServerAuthoritativeServer.js +304 -22
  13. package/src/engine/network/replication/Replicator.d.ts +14 -1
  14. package/src/engine/network/replication/Replicator.d.ts.map +1 -1
  15. package/src/engine/network/replication/Replicator.js +44 -3
  16. package/src/engine/network/sim/ActionLog.d.ts +21 -3
  17. package/src/engine/network/sim/ActionLog.d.ts.map +1 -1
  18. package/src/engine/network/sim/ActionLog.js +24 -3
  19. package/src/engine/network/sim/SimAction.d.ts +15 -0
  20. package/src/engine/network/sim/SimAction.d.ts.map +1 -1
  21. package/src/engine/network/sim/SimAction.js +15 -0
  22. package/src/engine/network/sim/SimActionExecutor.d.ts +51 -1
  23. package/src/engine/network/sim/SimActionExecutor.d.ts.map +1 -1
  24. package/src/engine/network/sim/SimActionExecutor.js +73 -5
  25. package/src/engine/network/transport/Channel.d.ts +1 -1
  26. package/src/engine/network/transport/Channel.js +1 -1
  27. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  28. package/src/engine/simulation/Ticker.js +4 -1
  29. package/src/engine/ui/GUIEngine.d.ts.map +1 -1
  30. package/src/engine/ui/GUIEngine.js +6 -0
  31. package/src/shade/playground/particle_system/README.md +98 -0
  32. package/src/shade/playground/particle_system/index.html +124 -0
  33. package/src/shade/playground/particle_system/main.d.ts +2 -0
  34. package/src/shade/playground/particle_system/main.d.ts.map +1 -0
  35. package/src/shade/playground/particle_system/main.js +180 -0
  36. package/src/shade/playground/particle_system/particle_scene.d.ts +52 -0
  37. package/src/shade/playground/particle_system/particle_scene.d.ts.map +1 -0
  38. package/src/shade/playground/particle_system/particle_scene.js +552 -0
  39. package/src/shade/playground/skinned_mesh_soup/README.md +122 -0
  40. package/src/shade/playground/skinned_mesh_soup/index.html +199 -0
  41. package/src/shade/playground/skinned_mesh_soup/main.d.ts +7 -0
  42. package/src/shade/playground/skinned_mesh_soup/main.d.ts.map +1 -0
  43. package/src/shade/playground/skinned_mesh_soup/main.js +564 -0
  44. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts +23 -0
  45. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts.map +1 -0
  46. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.js +409 -0
  47. package/src/shade/renderer/particles/cull/chunk_particle_cull.d.ts.map +1 -1
  48. package/src/shade/renderer/particles/cull/chunk_particle_cull.js +3 -1
  49. package/src/shade/renderer/particles/data/chunk_particle_emitter.js +1 -1
  50. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts +1 -1
  51. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts.map +1 -1
  52. package/src/shade/renderer/particles/isa/InstructionStream.d.ts +89 -0
  53. package/src/shade/renderer/particles/isa/InstructionStream.d.ts.map +1 -0
  54. package/src/shade/renderer/particles/isa/InstructionStream.js +162 -0
  55. package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts +1 -48
  56. package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts.map +1 -1
  57. package/src/shade/renderer/particles/isa/ParticleAssembler.js +25 -114
  58. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.d.ts.map +1 -1
  59. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.js +4 -2
  60. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts +4 -4
  61. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts.map +1 -1
  62. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.js +7 -5
  63. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.d.ts.map +1 -1
  64. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.js +3 -1
  65. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
  66. package/src/shade/renderer/particles/shaders/shader_particle_emit.js +3 -1
  67. package/src/shade/renderer/particles/shaders/shader_particle_finalize.d.ts.map +1 -1
  68. package/src/shade/renderer/particles/shaders/shader_particle_finalize.js +5 -2
  69. package/src/shade/renderer/particles/shaders/shader_particle_render.d.ts.map +1 -1
  70. package/src/shade/renderer/particles/shaders/shader_particle_render.js +9 -2
  71. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
  72. package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +3 -1
  73. package/src/shade/renderer/particles/prototypeParticleSystem.d.ts +0 -2
  74. package/src/shade/renderer/particles/prototypeParticleSystem.d.ts.map +0 -1
  75. package/src/shade/renderer/particles/prototypeParticleSystem.js +0 -448
@@ -367,10 +367,30 @@ export class NetworkSession {
367
367
  */
368
368
  private normalize_if_dirty;
369
369
  /**
370
- * Dispatch one user-defined action. Equivalent to
371
- * `peer.executor.execute(action, this.local_peer_id)` but exposed
372
- * at the session level so callers don't need a reference to the
373
- * executor or to know the local peer id.
370
+ * Dispatch one user-defined action, without needing a reference to the
371
+ * executor or knowledge of the local peer id.
372
+ *
373
+ * Called with a frame open from an `onLocalSim` handler on a host, or an
374
+ * `onPredict` handler on a client — the action executes immediately, into
375
+ * that frame. Its provenance follows its context: on a host, actions a
376
+ * local-sim handler executes are the frame's DERIVED output, recomputed on
377
+ * every replay of the frame rather than replayed from the log. That is what
378
+ * a handler that sends every tick wants, and it is why such a handler does
379
+ * not accumulate copies under rollback.
380
+ *
381
+ * Called with NO frame open — between ticks, from a lobby event, a timer, a
382
+ * console command — a host queues the action via
383
+ * {@link ServerAuthoritativeServer#enqueue_action}: it applies once, on the
384
+ * next frame the server simulates, and is recorded as INPUT, so replays
385
+ * take it from the log. This is the path for a one-shot. Do NOT reach for a
386
+ * latched local-sim handler (`if (already_sent) return`) instead: the
387
+ * handler's output is derived, so the replay expects it to be re-emitted,
388
+ * and a latch that stops it re-emitting loses the action the first time a
389
+ * rollback crosses its frame.
390
+ *
391
+ * (A client outside a prediction frame has nowhere to put the action and
392
+ * still throws — client actions are re-derived from the InputRing during
393
+ * reconciliation, so they belong in an `onPredict` handler.)
374
394
  *
375
395
  * @param {SimAction} action_instance
376
396
  */
@@ -1 +1 @@
1
- {"version":3,"file":"NetworkSession.d.ts","sourceRoot":"","sources":["../../../../src/engine/network/NetworkSession.js"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"NetworkSession.d.ts","sourceRoot":"","sources":["../../../../src/engine/network/NetworkSession.js"],"names":[],"mappings":"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"}
@@ -1151,10 +1151,30 @@ export class NetworkSession {
1151
1151
  }
1152
1152
 
1153
1153
  /**
1154
- * Dispatch one user-defined action. Equivalent to
1155
- * `peer.executor.execute(action, this.local_peer_id)` but exposed
1156
- * at the session level so callers don't need a reference to the
1157
- * executor or to know the local peer id.
1154
+ * Dispatch one user-defined action, without needing a reference to the
1155
+ * executor or knowledge of the local peer id.
1156
+ *
1157
+ * Called with a frame open from an `onLocalSim` handler on a host, or an
1158
+ * `onPredict` handler on a client — the action executes immediately, into
1159
+ * that frame. Its provenance follows its context: on a host, actions a
1160
+ * local-sim handler executes are the frame's DERIVED output, recomputed on
1161
+ * every replay of the frame rather than replayed from the log. That is what
1162
+ * a handler that sends every tick wants, and it is why such a handler does
1163
+ * not accumulate copies under rollback.
1164
+ *
1165
+ * Called with NO frame open — between ticks, from a lobby event, a timer, a
1166
+ * console command — a host queues the action via
1167
+ * {@link ServerAuthoritativeServer#enqueue_action}: it applies once, on the
1168
+ * next frame the server simulates, and is recorded as INPUT, so replays
1169
+ * take it from the log. This is the path for a one-shot. Do NOT reach for a
1170
+ * latched local-sim handler (`if (already_sent) return`) instead: the
1171
+ * handler's output is derived, so the replay expects it to be re-emitted,
1172
+ * and a latch that stops it re-emitting loses the action the first time a
1173
+ * rollback crosses its frame.
1174
+ *
1175
+ * (A client outside a prediction frame has nowhere to put the action and
1176
+ * still throws — client actions are re-derived from the InputRing during
1177
+ * reconciliation, so they belong in an `onPredict` handler.)
1158
1178
  *
1159
1179
  * @param {SimAction} action_instance
1160
1180
  */
@@ -1163,6 +1183,10 @@ export class NetworkSession {
1163
1183
  if (!action_instance || action_instance.isSimAction !== true) {
1164
1184
  throw new Error("NetworkSession.send: argument must be a SimAction instance");
1165
1185
  }
1186
+ if (this.#server !== null && !this.#peer.action_log.has_open_frame()) {
1187
+ this.#server.enqueue_action(action_instance);
1188
+ return;
1189
+ }
1166
1190
  this.#peer.executor.execute(action_instance, this.local_peer_id);
1167
1191
  }
1168
1192
 
@@ -50,14 +50,29 @@ export const ResumeRejectReason: Readonly<{
50
50
  */
51
51
  NoSessionToken: 5;
52
52
  }>;
53
+ /**
54
+ * One outbound action-stream send, awaiting confirmation.
55
+ *
56
+ * A send occupies one transport seq when it fits the MTU and N consecutive
57
+ * seqs when it has to be fragmented. All of those seqs map to the SAME record:
58
+ * the receiver reassembles a fragmented message all-or-nothing, so the frame
59
+ * range is confirmed only when `unacked` reaches zero without `abandoned`
60
+ * having been set by a loss along the way.
61
+ *
62
+ * @typedef {{frame_end: number, unacked: number, abandoned: boolean}} PendingAck
63
+ * @property {number} frame_end last frame in the packed range `[start, frame_end]`
64
+ * @property {number} unacked seqs of this send not yet acked or declared lost
65
+ * @property {boolean} abandoned at least one seq was declared lost, so the
66
+ * message is forfeit and must never advance the baseline
67
+ */
53
68
  /**
54
69
  * Per-tick orchestrator that wires the action infrastructure to one or more
55
70
  * peers over a Transport.
56
71
  *
57
72
  * Holds a single world (`EntityComponentDataset`) plus the action machinery
58
73
  * (executor, replicator, action log). For each connected peer it owns a
59
- * `Channel` and a `seq → frame_end` map so that ack notifications advance the
60
- * `Baseline.last_acked(peer_id)` watermark.
74
+ * `Channel` and a `seq`{@link PendingAck} map so that ack notifications
75
+ * advance the `Baseline.last_acked(peer_id)` watermark.
61
76
  *
62
77
  * Use as both server (many connected peers) and client (one peer = the server).
63
78
  * The asymmetry is in *who* connects — the orchestrator itself is symmetric.
@@ -262,6 +277,17 @@ export class NetworkPeer {
262
277
  * @returns {number}
263
278
  */
264
279
  inbound_count(peer_id: number): number;
280
+ /**
281
+ * Number of outbound action-stream seqs for `peer_id` still awaiting an
282
+ * ack-or-lost verdict. Returns 0 for an unknown peer.
283
+ *
284
+ * Diagnostics: this is bounded by the channel's in-flight window and must
285
+ * fall back to 0 once a stream of acks has resolved everything sent. A
286
+ * value that only grows is a leak in the ack accounting.
287
+ * @param {number} peer_id
288
+ * @returns {number}
289
+ */
290
+ pending_ack_count(peer_id: number): number;
265
291
  /**
266
292
  * Direct access to a peer's channel (for sending acks-only packets, raw payloads, etc.).
267
293
  * @param {number} peer_id
@@ -280,8 +306,8 @@ export class NetworkPeer {
280
306
  /**
281
307
  * Close the current tick and send pending action records to every peer.
282
308
  * For each peer, packs frames `[Baseline.last_acked(peer) + 1, current_frame]`
283
- * and sends via the channel; records the seq→frame_end mapping so the next
284
- * ack from that peer advances the baseline.
309
+ * and sends via the channel; records the seqs the send went out under so
310
+ * that acks for all of them advance the baseline.
285
311
  *
286
312
  * If a {@link MutationLedger} was supplied at construction, this tick's
287
313
  * accumulated changed-entity set is also compacted into the ledger.
@@ -299,8 +325,9 @@ export class NetworkPeer {
299
325
  * by the caller. Records the tick's accumulated mutations into the
300
326
  * MutationLedger (if configured), then packs frames
301
327
  * `[last_acked+1, current_frame]` for each peer via
302
- * `Replicator.pack_for_peer`. Empty packs (no in-scope actions) are
303
- * not sent. Clears the per-tick changed-entity set after recording.
328
+ * `Replicator.pack_for_peer`, bounded to one channel packet. Empty packs
329
+ * (no in-scope actions) are not sent. Clears the per-tick changed-entity
330
+ * set after recording.
304
331
  *
305
332
  * @param {number} current_frame
306
333
  */
@@ -421,6 +448,20 @@ export class NetworkPeer {
421
448
  send_reliable_command(peer_id: number, payload: Uint8Array, length: number): number;
422
449
  #private;
423
450
  }
451
+ /**
452
+ * One outbound action-stream send, awaiting confirmation.
453
+ *
454
+ * A send occupies one transport seq when it fits the MTU and N consecutive
455
+ * seqs when it has to be fragmented. All of those seqs map to the SAME record:
456
+ * the receiver reassembles a fragmented message all-or-nothing, so the frame
457
+ * range is confirmed only when `unacked` reaches zero without `abandoned`
458
+ * having been set by a loss along the way.
459
+ */
460
+ export type PendingAck = {
461
+ frame_end: number;
462
+ unacked: number;
463
+ abandoned: boolean;
464
+ };
424
465
  import { ReplicatedComponentRegistry } from "../sim/ReplicatedComponentRegistry.js";
425
466
  import { SimActionRegistry } from "../sim/SimActionRegistry.js";
426
467
  import { ReplicationSlotTable } from "../state/ReplicationSlotTable.js";
@@ -1 +1 @@
1
- {"version":3,"file":"NetworkPeer.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/orchestrator/NetworkPeer.js"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"NetworkPeer.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/orchestrator/NetworkPeer.js"],"names":[],"mappings":"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"}
@@ -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";
@@ -168,14 +168,30 @@ export const ResumeRejectReason = Object.freeze({
168
168
  NoSessionToken: 5,
169
169
  });
170
170
 
171
+ /**
172
+ * One outbound action-stream send, awaiting confirmation.
173
+ *
174
+ * A send occupies one transport seq when it fits the MTU and N consecutive
175
+ * seqs when it has to be fragmented. All of those seqs map to the SAME record:
176
+ * the receiver reassembles a fragmented message all-or-nothing, so the frame
177
+ * range is confirmed only when `unacked` reaches zero without `abandoned`
178
+ * having been set by a loss along the way.
179
+ *
180
+ * @typedef {{frame_end: number, unacked: number, abandoned: boolean}} PendingAck
181
+ * @property {number} frame_end last frame in the packed range `[start, frame_end]`
182
+ * @property {number} unacked seqs of this send not yet acked or declared lost
183
+ * @property {boolean} abandoned at least one seq was declared lost, so the
184
+ * message is forfeit and must never advance the baseline
185
+ */
186
+
171
187
  /**
172
188
  * Per-tick orchestrator that wires the action infrastructure to one or more
173
189
  * peers over a Transport.
174
190
  *
175
191
  * Holds a single world (`EntityComponentDataset`) plus the action machinery
176
192
  * (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.
193
+ * `Channel` and a `seq`{@link PendingAck} map so that ack notifications
194
+ * advance the `Baseline.last_acked(peer_id)` watermark.
179
195
  *
180
196
  * Use as both server (many connected peers) and client (one peer = the server).
181
197
  * The asymmetry is in *who* connects — the orchestrator itself is symmetric.
@@ -222,6 +238,16 @@ export class NetworkPeer {
222
238
  */
223
239
  #malformed_packets_dropped = 0;
224
240
 
241
+ /**
242
+ * Scratch list of the transport seqs one send produced — one entry for a
243
+ * single-packet send, one per fragment for a fragmented one. Reused across
244
+ * sends so the common path allocates nothing; only valid until the next
245
+ * `__send_to_peer` call.
246
+ * @type {number[]}
247
+ * @private
248
+ */
249
+ #sent_seqs = [];
250
+
225
251
  /**
226
252
  * @param {{
227
253
  * world: EntityComponentDataset,
@@ -317,8 +343,8 @@ export class NetworkPeer {
317
343
  this.baseline = new Baseline();
318
344
 
319
345
  /**
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 }>}
346
+ * peer_id → { channel, transport, seq_to_pending, on_payload, on_acked }
347
+ * @type {Map<number, { channel: Channel, transport: Object, seq_to_pending: Map<number, PendingAck>, on_payload: Function, on_acked: Function }>}
322
348
  * @private
323
349
  */
324
350
  this.#peers = new Map();
@@ -463,12 +489,21 @@ export class NetworkPeer {
463
489
  */
464
490
  connect_peer(peer_id, transport) {
465
491
  assert.isNonNegativeInteger(peer_id, 'peer_id');
492
+ // The action log records a peer's id as the provenance of its actions,
493
+ // in one byte whose top two values are reserved for locally-authored
494
+ // and sim-derived records. A peer holding one of those would have its
495
+ // input read back as the local sim's own output and dropped by the
496
+ // first rollback replay of its frame, so refuse the connection rather
497
+ // than lose its input later. Real runtime check — asserts strip in prod.
498
+ if (peer_id > MAX_PEER_ID) {
499
+ 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`);
500
+ }
466
501
  if (this.#peers.has(peer_id)) {
467
502
  throw new Error(`NetworkPeer.connect_peer: peer ${peer_id} already connected`);
468
503
  }
469
504
 
470
505
  const channel = new Channel({ transport });
471
- const seq_to_frame_end = new Map();
506
+ const seq_to_pending = new Map();
472
507
  const fragment_assembler = new FragmentAssembler();
473
508
  const fragment_retention = new FragmentRetention();
474
509
  const reliable_pipeline = new ReliableCommandPipeline({
@@ -639,22 +674,42 @@ export class NetworkPeer {
639
674
  }
640
675
  };
641
676
 
642
- // Ack: advance Baseline to the highest acked frame_end for this peer.
677
+ // Ack: advance Baseline to the record's frame_end but only once every
678
+ // seq that send went out under has been acked. For a single-packet send
679
+ // that is the one seq. For a fragmented send it is all N fragments,
680
+ // because the receiver applies nothing until it has reassembled the
681
+ // whole message; advancing on the first fragment's ack would retire a
682
+ // frame range the receiver never saw, from the only mechanism that
683
+ // could still re-send it.
643
684
  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);
685
+ const pending = seq_to_pending.get(seq);
686
+ if (pending === undefined) return;
687
+ seq_to_pending.delete(seq);
688
+ pending.unacked--;
689
+ if (pending.unacked === 0 && !pending.abandoned) {
690
+ this.baseline.set_acked(peer_id, pending.frame_end);
648
691
  }
649
692
  };
650
693
 
651
694
  // 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.
695
+ // leaves a dead seq→record entry. The baseline must NOT advance (the
696
+ // frame range was never confirmed; the back-fill re-sends it under a
697
+ // fresh seq), and the record is marked abandoned so that siblings
698
+ // acking afterwards cannot complete it one lost fragment forfeits the
699
+ // whole message. The mapping is reclaimed either way, otherwise
700
+ // lost-packet entries accumulate across the session until the seq value
701
+ // is reused on wrap.
702
+ //
703
+ // Every outstanding seq is resolved by Channel as exactly one of acked
704
+ // or lost — via the ack bitfield, the 33-seq window age-out, or the
705
+ // max_in_flight eviction — so no record can be stranded by a seq that
706
+ // never reports.
656
707
  const on_lost = (seq) => {
657
- seq_to_frame_end.delete(seq);
708
+ const pending = seq_to_pending.get(seq);
709
+ if (pending === undefined) return;
710
+ seq_to_pending.delete(seq);
711
+ pending.unacked--;
712
+ pending.abandoned = true;
658
713
  };
659
714
 
660
715
  // Untrusted-input boundary. A structurally-malformed or truncated
@@ -688,7 +743,7 @@ export class NetworkPeer {
688
743
  channel.onPacketLost.add(on_lost);
689
744
 
690
745
  this.#peers.set(peer_id, {
691
- channel, transport, seq_to_frame_end,
746
+ channel, transport, seq_to_pending,
692
747
  on_payload: payload_handler, on_acked, on_lost, on_nack,
693
748
  fragment_assembler, fragment_retention,
694
749
  reliable_pipeline,
@@ -764,6 +819,21 @@ export class NetworkPeer {
764
819
  return conn === undefined ? 0 : conn.inbound_count;
765
820
  }
766
821
 
822
+ /**
823
+ * Number of outbound action-stream seqs for `peer_id` still awaiting an
824
+ * ack-or-lost verdict. Returns 0 for an unknown peer.
825
+ *
826
+ * Diagnostics: this is bounded by the channel's in-flight window and must
827
+ * fall back to 0 once a stream of acks has resolved everything sent. A
828
+ * value that only grows is a leak in the ack accounting.
829
+ * @param {number} peer_id
830
+ * @returns {number}
831
+ */
832
+ pending_ack_count(peer_id) {
833
+ const conn = this.#peers.get(peer_id);
834
+ return conn === undefined ? 0 : conn.seq_to_pending.size;
835
+ }
836
+
767
837
  /**
768
838
  * Direct access to a peer's channel (for sending acks-only packets, raw payloads, etc.).
769
839
  * @param {number} peer_id
@@ -797,8 +867,8 @@ export class NetworkPeer {
797
867
  /**
798
868
  * Close the current tick and send pending action records to every peer.
799
869
  * 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.
870
+ * and sends via the channel; records the seqs the send went out under so
871
+ * that acks for all of them advance the baseline.
802
872
  *
803
873
  * If a {@link MutationLedger} was supplied at construction, this tick's
804
874
  * accumulated changed-entity set is also compacted into the ledger.
@@ -835,8 +905,9 @@ export class NetworkPeer {
835
905
  * by the caller. Records the tick's accumulated mutations into the
836
906
  * MutationLedger (if configured), then packs frames
837
907
  * `[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.
908
+ * `Replicator.pack_for_peer`, bounded to one channel packet. Empty packs
909
+ * (no in-scope actions) are not sent. Clears the per-tick changed-entity
910
+ * set after recording.
840
911
  *
841
912
  * @param {number} current_frame
842
913
  */
@@ -853,15 +924,14 @@ export class NetworkPeer {
853
924
 
854
925
  for (const [peer_id, conn] of this.#peers) {
855
926
  const last_acked = this.baseline.last_acked(peer_id);
856
- // Pack-range start.
927
+ // Pack-range start: whichever is newer of "catch up since you
928
+ // last confirmed receipt" and "the oldest frame the log still
929
+ // holds."
857
930
  //
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:
931
+ // last_acked+1 is the ordinary catch-up point. A freshly-
932
+ // connected peer has no ack, so its -1+1 falls through to the
933
+ // ring floor and it covers the log's full content. Two competing
934
+ // constraints shaped that floor:
865
935
  //
866
936
  // - Don't pack `current_frame` only: under any non-zero
867
937
  // latency, A's actions tagged at frame X land on the
@@ -877,28 +947,81 @@ export class NetworkPeer {
877
947
  //
878
948
  // `current_frame - frame_capacity + 1` is the oldest frame
879
949
  // 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;
950
+ // ring's actual content.
951
+ //
952
+ // The floor binds acked peers too, not just fresh ones: a peer
953
+ // that acked once and then stopped leaves last_acked pinned while
954
+ // current_frame runs on, and without the floor the loop walks
955
+ // every frame since that ack to emit at most a ring's worth —
956
+ // work that grows without bound the longer the peer stays quiet.
957
+ // Skipping those frames costs nothing, because they are already
958
+ // gone from the log and can never be sent whatever the ack says;
959
+ // `pack_for_peer` credits them as covered the moment it reaches
960
+ // them anyway, so the frame this pack confirms is the same
961
+ // either way.
962
+ //
963
+ // Actions referencing entities the peer's slot_table doesn't yet
964
+ // know about silently no-op (Replicator translates via
965
+ // network_for → -1; SimAction apply hits the `if (local < 0)
966
+ // return` guard). For first-connect scenarios the app is still
967
+ // expected to send a state-burst seed; this just makes the
968
+ // action-stream bootstrap work in addition to that.
969
+ const start = Math.max(
970
+ last_acked + 1,
971
+ current_frame - this.action_log.frame_capacity + 1,
972
+ 0,
973
+ );
890
974
  if (start > current_frame) continue;
891
975
 
892
976
  this.#send_buffer.position = 0;
893
977
  // Packet-type prefix.
894
978
  this.#send_buffer.writeUint8(NetworkPacketType.ACTION_STREAM);
895
- this.replicator.pack_for_peer(peer_id, start, current_frame, this.#send_buffer);
979
+ // Bound the pack to a single channel packet.
980
+ //
981
+ // A fragmented action stream is confirmed only if EVERY fragment
982
+ // survives, so letting the pack grow with the owed range is
983
+ // self-defeating under loss: one lost fragment pins the baseline,
984
+ // which widens the next pack, which raises the fragment count N,
985
+ // which drops P(all arrive) = (1-p)^N geometrically — the range
986
+ // then widens faster than it drains and delivery collapses.
987
+ //
988
+ // Bounded, the back-fill degrades into "catch up by a packet each
989
+ // tick": every send occupies one seq, so any packet that lands
990
+ // advances the baseline, and a peer behind by more than one packet
991
+ // converges instead of spiralling. The pack is oldest-owed-first,
992
+ // which is what the range order already gives.
993
+ //
994
+ // The budget is what is left of one payload after the prefix
995
+ // above, read from the buffer rather than written as a constant:
996
+ // `pack_for_peer` measures its budget from the current position,
997
+ // so a hard-coded allowance would have to be edited in step with
998
+ // every byte added here, and getting that wrong puts each full
999
+ // pack one byte over the limit and quietly fragments it again.
1000
+ const packed_through = this.replicator.pack_for_peer(
1001
+ peer_id, start, current_frame, this.#send_buffer,
1002
+ MAX_CHANNEL_PAYLOAD_BYTES - this.#send_buffer.position,
1003
+ );
896
1004
  const length = this.#send_buffer.position;
897
1005
  // Just the prefix → no in-scope actions to send.
898
1006
  if (length === 1) continue;
899
1007
 
900
- const seq = this.#send_to_peer(conn, this.#send_buffer.raw_bytes, length);
901
- conn.seq_to_frame_end.set(seq, current_frame);
1008
+ this.#send_to_peer(conn, this.#send_buffer.raw_bytes, length, this.#sent_seqs);
1009
+
1010
+ // One record shared by every seq the send produced. Frames
1011
+ // [start, packed_through] are confirmed only when all of those seqs
1012
+ // ack; a loss on any of them abandons the record and leaves the
1013
+ // range owed, so the next tick's pack picks it up again.
1014
+ //
1015
+ // frame_end is what the pack actually held, NOT current_frame: the
1016
+ // budget may have stopped it short, and crediting frames that were
1017
+ // left out of the packet would retire them from the back-fill
1018
+ // unsent — the same defect as crediting a whole message to its
1019
+ // first fragment, one layer up.
1020
+ const seq_count = this.#sent_seqs.length;
1021
+ const pending = { frame_end: packed_through, unacked: seq_count, abandoned: false };
1022
+ for (let i = 0; i < seq_count; i++) {
1023
+ conn.seq_to_pending.set(this.#sent_seqs[i], pending);
1024
+ }
902
1025
  }
903
1026
 
904
1027
  // Reset the per-tick changed-entity set so the next tick starts clean.
@@ -910,24 +1033,31 @@ export class NetworkPeer {
910
1033
  * Send a logical payload to a connected peer, fragmenting transparently
911
1034
  * if it exceeds the channel's single-packet capacity.
912
1035
  *
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.
1036
+ * Fragments either all arrive (and the receiver reassembles) or the
1037
+ * message is lost there is no partial delivery, and the receiver never
1038
+ * applies a message it could not reassemble.
1039
+ *
1040
+ * A caller that ties delivery state to the ack must therefore wait on
1041
+ * EVERY seq the send produced, not just the first. The action stream's
1042
+ * back-fill does recover a lost message, but only for as long as the
1043
+ * frames stay owed: crediting the range to the first fragment's ack
1044
+ * retires it from the back-fill, and a message whose later fragments were
1045
+ * dropped is then never re-sent and never applied. Pass `seqs_out` to
1046
+ * collect the seqs; {@link NetworkPeer#flush_outbound} does exactly that.
921
1047
  *
922
1048
  * @param {object} conn entry from __peers
923
1049
  * @param {Uint8Array} payload starts with the packet-type byte
924
1050
  * @param {number} length
925
- * @returns {number} seq of the first packet sent
1051
+ * @param {number[]} [seqs_out] when given, cleared and then filled with the
1052
+ * seq of every transport packet emitted, in send order
926
1053
  * @private
927
1054
  */
928
- #send_to_peer(conn, payload, length) {
1055
+ #send_to_peer(conn, payload, length, seqs_out = null) {
1056
+ if (seqs_out !== null) seqs_out.length = 0;
929
1057
  if (length <= MAX_CHANNEL_PAYLOAD_BYTES) {
930
- return conn.channel.send(payload, length);
1058
+ const seq = conn.channel.send(payload, length);
1059
+ if (seqs_out !== null) seqs_out.push(seq);
1060
+ return;
931
1061
  }
932
1062
  const message_id = this.#next_fragment_message_id;
933
1063
  this.#next_fragment_message_id = seq16_advance(this.#next_fragment_message_id);
@@ -935,16 +1065,14 @@ export class NetworkPeer {
935
1065
  // Copy happens inside retain(); `payload` is the orchestrator's
936
1066
  // shared send buffer and will be overwritten by the next call.
937
1067
  conn.fragment_retention.retain(message_id, payload, length, performance.now());
938
- let first_seq = -1;
939
1068
  send_fragmented(
940
1069
  payload, length, message_id,
941
1070
  NetworkPacketType.FRAGMENT, this.#fragment_scratch,
942
1071
  (chunk_bytes, chunk_length) => {
943
1072
  const seq = conn.channel.send(chunk_bytes, chunk_length);
944
- if (first_seq < 0) first_seq = seq;
1073
+ if (seqs_out !== null) seqs_out.push(seq);
945
1074
  },
946
1075
  );
947
- return first_seq;
948
1076
  }
949
1077
 
950
1078
  /**