@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
@@ -8,7 +8,9 @@ import { NetworkIdentity } from "../ecs/components/NetworkIdentity.js";
8
8
  import { make_owner_authorization } from "../ecs/owner_authorization.js";
9
9
  import { Replicator } from "../replication/Replicator.js";
10
10
  import { AlwaysRelevantScope } from "../replication/ScopeFilter.js";
11
+ import { ActionRecordCursor } from "../sim/ActionRecordCursor.js";
11
12
  import { RewindEngine } from "../sim/RewindEngine.js";
13
+ import { SENDER_DERIVED, SENDER_LOCAL } from "../sim/SimActionExecutor.js";
12
14
  import { NetworkPeer } from "./NetworkPeer.js";
13
15
 
14
16
  /**
@@ -19,23 +21,48 @@ import { NetworkPeer } from "./NetworkPeer.js";
19
21
  *
20
22
  * 1. Inbound action packets do NOT execute on arrival. Instead, a deferral
21
23
  * hook on the {@link Replicator} drops them into a per-tick pending log
22
- * tagged with `(client_frame, sender_id, type_id, payload_bytes)`.
24
+ * tagged with `(client_frame, sender_id, type_id, payload_bytes)` —
25
+ * unless the action log already holds that record. The action stream
26
+ * re-sends every unconfirmed frame each tick, so most arrivals at any
27
+ * real latency are copies of applied records; those are dropped where
28
+ * they arrive, and pending holds only input the log does not.
23
29
  * 2. Once per server tick, `tick(current_frame)` consumes the pending log:
24
30
  * - Rejects entries older than the action_log's rewindable window.
25
- * - Determines the oldest pending frame `replay_start`.
31
+ * - Determines `replay_start`: the oldest pending frame, which by the
32
+ * above is the oldest frame whose outcome can change.
26
33
  * - Rewinds the server world back to end-of-(replay_start - 1) via the
27
34
  * action_log's prior-state captures (see {@link RewindEngine}).
28
35
  * - Replays forward `[replay_start, current_frame]`. For each frame `f`:
29
36
  * - Read historical actions out of `action_log[f]` BEFORE
30
37
  * `begin_frame(f)` recycles the buffer.
31
38
  * - Append pending entries for `f`.
39
+ * - Append anything queued by {@link enqueue_action}.
32
40
  * - Stable-sort the merged list by `sender_id` (so multi-client order
33
41
  * is deterministic across peers — relies on per-record sender_id in
34
- * the action log).
35
- * - `executor.execute` each in sorted order, then run the user-
36
- * supplied local sim via {@link onLocalSim}.
42
+ * the action log), and `executor.execute` each in that order.
43
+ * - Run the user-supplied local sim via {@link onLocalSim}.
37
44
  * - Clear pending.
38
45
  *
46
+ * A frame's records have one of two provenances, and the replay treats them
47
+ * differently:
48
+ *
49
+ * - **Input** — actions that arrived from a peer, and actions the server
50
+ * authored via {@link enqueue_action}. Each happened once, at a point in
51
+ * time nothing can recompute, so a replay reapplies it from the log.
52
+ * - **Derived output** — everything {@link onLocalSim} executes. The handler
53
+ * runs again on every replay of the frame, against a world that now
54
+ * includes whatever late input forced the replay, so the replay recomputes
55
+ * it. Reapplying the previous pass's copy as well would apply the effect
56
+ * twice — and three times on the next replay, and so on, without bound.
57
+ * The executor stamps these {@link SENDER_DERIVED} while the handler runs
58
+ * and {@link __read_historical} drops them.
59
+ *
60
+ * Derived records are still written, and still replicate: the prior-state
61
+ * capture is what lets a rewind undo the local sim's effect on replicated
62
+ * components (so derived output need not be idempotent — only deterministic
63
+ * in the frame's post-input state), and the action stream carries them to
64
+ * clients like any other record.
65
+ *
39
66
  * Net effect: a client action tagged at client tick K lands on the server as
40
67
  * if applied against end-of-K-1 server state, regardless of arrival timing
41
68
  * or order. Both peers compute identical world states for identical inputs.
@@ -67,6 +94,15 @@ export class ServerAuthoritativeServer {
67
94
  #historical_scratch = /** @type {Array<{type_id:number, sender_id:number, payload:Uint8Array}>} */ ([]);
68
95
  #sort_scratch = /** @type {Array<{sender_id:number, type_id:number, payload:Uint8Array}>} */ ([]);
69
96
  #payload_buf;
97
+ // Walks a frame's records, for the history read and the arrival check.
98
+ #cursor = new ActionRecordCursor();
99
+ // Locally-authored actions waiting for the next fresh frame. Parallel
100
+ // arrays, serialized at enqueue time so the caller's instance is not
101
+ // retained (and so a bad action class is rejected at the call site rather
102
+ // than a tick later).
103
+ #local_type_ids = /** @type {number[]} */ ([]);
104
+ #local_payloads = /** @type {Uint8Array[]} */ ([]);
105
+ #local_buf;
70
106
  /**
71
107
  * Count of inbound pending entries dropped by the future-lead / total
72
108
  * bounds. See {@link onPendingActionDropped}.
@@ -92,8 +128,12 @@ export class ServerAuthoritativeServer {
92
128
  * frame_capacity?: number,
93
129
  * initial_buffer_size?: number,
94
130
  * simulation_delay_ticks?: number,
131
+ * max_packets_per_tick?: number,
95
132
  * }} options
96
133
  *
134
+ * `max_packets_per_tick`: forwarded to {@link NetworkPeer}; how many
135
+ * action-stream packets a tick may send one peer.
136
+ *
97
137
  * `simulation_delay_ticks`: server-side input buffer (Overwatch-style).
98
138
  * When > 0, {@link tick}(wall_frame) simulates
99
139
  * `sim_frame = wall_frame - simulation_delay_ticks`. Pending actions
@@ -116,6 +156,7 @@ export class ServerAuthoritativeServer {
116
156
  max_pending_total = 4096,
117
157
  identity_class = NetworkIdentity,
118
158
  server_peer_id = -1,
159
+ max_packets_per_tick,
119
160
  }) {
120
161
  assert.isNonNegativeInteger(simulation_delay_ticks, 'simulation_delay_ticks');
121
162
  assert.isPositiveInteger(max_pending_future_lead, 'max_pending_future_lead');
@@ -129,6 +170,7 @@ export class ServerAuthoritativeServer {
129
170
  scope_filter,
130
171
  frame_capacity,
131
172
  initial_buffer_size,
173
+ max_packets_per_tick,
132
174
  });
133
175
 
134
176
  /** @type {EntityComponentDataset} */
@@ -180,6 +222,16 @@ export class ServerAuthoritativeServer {
180
222
  */
181
223
  this.simulation_delay_ticks = simulation_delay_ticks;
182
224
 
225
+ /**
226
+ * Peer id of this authoritative server, or `-1` when the session has no
227
+ * privileged peer. Actions from {@link enqueue_action} are tagged with
228
+ * it so they pass the owner-authorization gate on any entity (see
229
+ * {@link make_owner_authorization}), which is the documented convention
230
+ * for the server's own mutations.
231
+ * @type {number}
232
+ */
233
+ this.server_peer_id = server_peer_id;
234
+
183
235
  /**
184
236
  * Upper bound on how far ahead of the server's own simulation a peer
185
237
  * may tag an inbound action. `frame_number` is an attacker-controlled
@@ -215,6 +267,9 @@ export class ServerAuthoritativeServer {
215
267
  this.#payload_buf = new BinaryBuffer();
216
268
  this.#payload_buf.setCapacity(64);
217
269
 
270
+ this.#local_buf = new BinaryBuffer();
271
+ this.#local_buf.setCapacity(64);
272
+
218
273
  // Switch the Replicator to deferred-execute mode: incoming actions
219
274
  // accumulate in pending until tick() drains them.
220
275
  this.peer.replicator.on_pending_action = (peer_id, frame_number, type_id, buf, off, len) => {
@@ -231,13 +286,35 @@ export class ServerAuthoritativeServer {
231
286
  };
232
287
 
233
288
  /**
234
- * Fires once per frame during the replay loop, AFTER all merged
235
- * actions for that frame have been applied and BEFORE the action_log
236
- * frame is closed. Use this for server-authoritative local sim
237
- * (clamps, collision, AI step, etc.). The current world state
238
- * reflects all inputs for this frame; the local sim should be
239
- * idempotent under repeated application (because it may run many
240
- * times under rollback).
289
+ * Fires once per execution of a frame, AFTER all input for that frame
290
+ * has been applied and BEFORE the action_log frame is closed. Use it
291
+ * for server-authoritative local sim: clamps, collision, AI step,
292
+ * damage resolution, spawning the effects a hit produces.
293
+ *
294
+ * **This is a derivation, not an event.** The handler runs again on
295
+ * every replay of the frame, and must produce the same result for the
296
+ * same post-input world state — it is a function OF that state, not a
297
+ * one-shot that happens to be called from it. Concretely:
298
+ *
299
+ * - Decide from world state and the frame number. Both are restored
300
+ * before the handler re-runs.
301
+ * - Do NOT latch on handler-local flags (`if (already_fired) return`)
302
+ * to make something happen once. The replay needs the handler to
303
+ * re-emit; a latch makes it emit nothing, and the action is lost
304
+ * the first time a rollback crosses its frame. For a genuine
305
+ * one-shot — a lobby spawn, an admin command, anything decided
306
+ * outside the simulation — use {@link enqueue_action}, which is
307
+ * recorded as input and replayed rather than recomputed.
308
+ *
309
+ * Actions the handler executes are recorded as derived output: they
310
+ * replicate to clients and their prior state is captured so a rewind
311
+ * undoes them, but a replay recomputes them instead of reapplying the
312
+ * previous pass's copy. So they need not be idempotent — a handler may
313
+ * subtract hit points — only deterministic in the frame's post-input
314
+ * state. Components the handler mutates DIRECTLY, without going through
315
+ * an action, have no prior-state capture and are not undone by a
316
+ * rewind; those must additionally be idempotent, which is why a clamp
317
+ * is safe to write that way and `hp -= 10` is not.
241
318
  *
242
319
  * Args: `(frame_number)`.
243
320
  * @type {Signal}
@@ -334,6 +411,71 @@ export class ServerAuthoritativeServer {
334
411
  this.peer.send_time_dilation_feedback(peer_id, this.buffer_depth_for_peer(peer_id));
335
412
  }
336
413
 
414
+ /**
415
+ * Author a server-side action, to be applied on the next simulated frame.
416
+ *
417
+ * This is the supported path for a mutation the simulation cannot
418
+ * recompute — a spawn triggered by a lobby event, an admin command, a
419
+ * scripted trigger — as opposed to {@link onLocalSim}, which derives a
420
+ * frame's output from its state and is re-run on every replay.
421
+ *
422
+ * The action is serialized immediately (so the caller may reuse or discard
423
+ * the instance) and applied exactly once, on the next frame {@link tick}
424
+ * simulates for the first time, merged into that frame's input and ordered
425
+ * with it by sender. From then on it is ordinary history: replayed from the
426
+ * log like any other input, and never re-queued.
427
+ *
428
+ * Callable at any time EXCEPT from inside {@link onLocalSim}, which throws:
429
+ * that handler re-runs on every replay of its frame, so it would queue a
430
+ * fresh copy each time. (`executor.execute` is the opposite — it requires
431
+ * an open action_log frame, so it only works from inside the handler.)
432
+ *
433
+ * Recorded with {@link server_peer_id} as the sender when the session has
434
+ * one, so it passes the owner-authorization gate on any entity; with
435
+ * {@link SENDER_LOCAL} otherwise, which bypasses the gate.
436
+ *
437
+ * @param {SimAction} action
438
+ */
439
+ enqueue_action(action) {
440
+ assert.ok(action && action.isSimAction, 'enqueue_action: action must be a SimAction');
441
+ // Authoring from inside a derivation is always wrong, and unlike a
442
+ // latched handler it is exactly detectable, so it fails loudly here.
443
+ // onLocalSim runs again on every replay of its frame; each run would
444
+ // queue another copy, and each copy is recorded as input, so the
445
+ // applications compound per replay — the very shape this provenance
446
+ // split exists to stop. A handler that wants an effect every time the
447
+ // frame runs should `executor.execute` it as derived output instead.
448
+ // Real runtime check: asserts strip in prod.
449
+ if (this.executor.deriving) {
450
+ throw new Error(
451
+ 'ServerAuthoritativeServer.enqueue_action: cannot author an action from inside onLocalSim — the handler re-runs on every replay, so the action would be queued again each time. Execute it through the executor to record it as derived output, or enqueue it from outside the local sim.',
452
+ );
453
+ }
454
+ const type_id = action.constructor.type_id;
455
+ assert.ok(
456
+ type_id >= 0,
457
+ `enqueue_action: action class '${action.constructor.action_type_name ?? action.constructor.name}' has no wire type_id — register it before enqueueing it`,
458
+ );
459
+
460
+ this.#local_buf.position = 0;
461
+ action.serialize(this.#local_buf);
462
+ const len = this.#local_buf.position;
463
+
464
+ const payload = new Uint8Array(len);
465
+ payload.set(this.#local_buf.raw_bytes.subarray(0, len));
466
+
467
+ this.#local_type_ids.push(type_id);
468
+ this.#local_payloads.push(payload);
469
+ }
470
+
471
+ /**
472
+ * Number of locally-authored actions waiting for the next simulated frame.
473
+ * @returns {number}
474
+ */
475
+ queued_local_action_count() {
476
+ return this.#local_type_ids.length;
477
+ }
478
+
337
479
  /**
338
480
  * Drive the rollback flow for one tick.
339
481
  *
@@ -360,8 +502,13 @@ export class ServerAuthoritativeServer {
360
502
  this.#pending_trim_to_window(window_oldest);
361
503
  }
362
504
 
363
- // 2. Pick the replay window. `__pending_referenced_frames` is
364
- // sorted ascending; entries `> sim_frame` stay buffered.
505
+ // 2. Pick the replay window: the OLDEST pending frame at or below
506
+ // `sim_frame`. Pending holds only input the log does not already
507
+ // hold — a retransmission of an applied record is dropped on
508
+ // arrival, see __pending_add — so the oldest pending frame is the
509
+ // oldest frame whose outcome can change, and a rewind to it is
510
+ // never wasted. `__pending_referenced_frames` is sorted
511
+ // ascending; entries `> sim_frame` stay buffered.
365
512
  let replay_start = sim_frame;
366
513
  const refs = this.#pending_referenced_frames;
367
514
  if (refs.length > 0 && refs[0] <= sim_frame) {
@@ -375,9 +522,11 @@ export class ServerAuthoritativeServer {
375
522
  this.onRewind.send3(committed_top, replay_start - 1, committed_top - (replay_start - 1));
376
523
  }
377
524
 
378
- // 4. Replay [replay_start, sim_frame].
525
+ // 4. Replay [replay_start, sim_frame]. Only `sim_frame` is being
526
+ // simulated for the first time; the rest are re-executions of
527
+ // frames the log already holds.
379
528
  for (let f = replay_start; f <= sim_frame; f++) {
380
- this.#replay_frame(f);
529
+ this.#replay_frame(f, f === sim_frame);
381
530
  }
382
531
 
383
532
  // 5. Keep only `frame > sim_frame` pending (future buffer).
@@ -406,8 +555,57 @@ export class ServerAuthoritativeServer {
406
555
  return max - this.current_sim_frame;
407
556
  }
408
557
 
409
- /** @private */
410
- #replay_frame(f) {
558
+ /**
559
+ * Walk `action_log[frame_number]` looking for a record identical to
560
+ * `(sender_id, type_id, payload)`. Compares the payload in place against
561
+ * the log's own bytes — no copy, unlike {@link __read_historical}, because
562
+ * this runs once per arriving action.
563
+ *
564
+ * The key can only recognise records the log actually holds. An action
565
+ * the executor REJECTED (owner authorization) writes nothing, so each of
566
+ * its retransmissions still reads as new input and still costs a rewind —
567
+ * self-limiting, because the sender stops re-sending the frame once it is
568
+ * acked.
569
+ *
570
+ * Caller must have checked `action_log.has_frame(frame_number)`, and that
571
+ * the frame is closed.
572
+ *
573
+ * @param {number} frame_number
574
+ * @param {number} sender_id
575
+ * @param {number} type_id
576
+ * @param {Uint8Array} payload
577
+ * @returns {boolean}
578
+ * @private
579
+ */
580
+ #log_holds_record(frame_number, sender_id, type_id, payload) {
581
+ const cursor = this.#cursor;
582
+ cursor.open(this.action_log, frame_number);
583
+ const bytes = cursor.buffer.raw_bytes;
584
+ while (cursor.next()) {
585
+ if (cursor.action_type_id !== type_id) continue;
586
+ if (cursor.sender_id !== sender_id) continue;
587
+ if (cursor.payload_len !== payload.length) continue;
588
+
589
+ const payload_start = cursor.payload_start;
590
+ let same = true;
591
+ for (let i = 0; i < payload.length; i++) {
592
+ if (bytes[payload_start + i] !== payload[i]) { same = false; break; }
593
+ }
594
+ if (same) return true;
595
+ }
596
+ return false;
597
+ }
598
+
599
+ /**
600
+ * Execute frame `f`: its history, its pending input and — when `fresh`,
601
+ * meaning this is the first time the frame is simulated — the
602
+ * locally-authored queue, merged and sorted, then the local sim.
603
+ *
604
+ * @param {number} f
605
+ * @param {boolean} fresh
606
+ * @private
607
+ */
608
+ #replay_frame(f, fresh) {
411
609
  this.#sort_scratch.length = 0;
412
610
 
413
611
  // Read historical actions out of action_log[f] BEFORE begin_frame(f)
@@ -420,15 +618,13 @@ export class ServerAuthoritativeServer {
420
618
  this.#sort_scratch.push({ sender_id: h.sender_id, type_id: h.type_id, payload: h.payload });
421
619
  }
422
620
 
423
- // Append newly-arrived pending actions for this frame, deduplicating
424
- // against historical entries. Retransmissions are routine on the
425
- // wire `Replicator.pack_for_peer` packs every frame in
426
- // [last_acked + 1, current_frame] each tick, so an action lives in
427
- // every outgoing packet for the round-trip duration of its ack.
428
- // The server receives each retransmission as a fresh pending entry;
429
- // without dedup, the rollback flow merges it with the historical
430
- // record left over from the previous apply and executes the action
431
- // twice (and three times, four times… until the ack arrives).
621
+ // Append the pending actions for this frame. Retransmissions of
622
+ // records the log already holds are dropped on arrival (see
623
+ // __pending_add), so in the supported call pattern nothing here
624
+ // duplicates history. The check stays as a safety net: a duplicate
625
+ // that reached this point would merge with the historical record and
626
+ // execute the action twice, diverging the world, and the cost of the
627
+ // check is a scan of a small frame's copied history.
432
628
  //
433
629
  // Dedup key: (sender_id, type_id, payload bytes). Two actions are
434
630
  // "the same arrival" if all three match. (Same sender_id and
@@ -450,6 +646,26 @@ export class ServerAuthoritativeServer {
450
646
  this.#sort_scratch.push({ sender_id: p_sender, type_id: p_type, payload: p_payload });
451
647
  }
452
648
 
649
+ // Locally-authored actions join the merge as input, and only on the
650
+ // frame being simulated for the first time — a replayed frame already
651
+ // holds, in the history read above, the ones it was given when it was
652
+ // fresh. They go in BEFORE the sort so a frame's input order does not
653
+ // depend on whether it is being simulated fresh or replayed: on replay
654
+ // these come back through __read_historical and are sorted with
655
+ // everything else, so the fresh pass has to sort them the same way.
656
+ if (fresh && this.#local_type_ids.length > 0) {
657
+ const sender = this.server_peer_id >= 0 ? this.server_peer_id : SENDER_LOCAL;
658
+ for (let i = 0; i < this.#local_type_ids.length; i++) {
659
+ this.#sort_scratch.push({
660
+ sender_id: sender,
661
+ type_id: this.#local_type_ids[i],
662
+ payload: this.#local_payloads[i],
663
+ });
664
+ }
665
+ this.#local_type_ids.length = 0;
666
+ this.#local_payloads.length = 0;
667
+ }
668
+
453
669
  // Stable sort by sender. Array.prototype.sort is stable since ES2019;
454
670
  // equal sender_ids preserve insertion order, with historical entries
455
671
  // appearing before newly-arrived pending entries for the same sender.
@@ -461,10 +677,24 @@ export class ServerAuthoritativeServer {
461
677
  const s = this.#sort_scratch[i];
462
678
  this.#apply_payload(s.type_id, s.sender_id, s.payload);
463
679
  }
464
- // Notify user code so server-side local sim runs at the right point
465
- // in the frame (after inputs applied, before frame closed). Idempotent
466
- // under repeated application across rollback replays.
467
- this.onLocalSim.send1(f);
680
+
681
+ // Notify user code so server-side local sim runs at the right point in
682
+ // the frame: after all input for the frame is applied, before the frame
683
+ // is closed.
684
+ //
685
+ // Everything the handler executes is DERIVED output — the handler runs
686
+ // again on every replay of this frame, so the replay recomputes it
687
+ // rather than reapplying it. `deriving` makes the executor stamp those
688
+ // records SENDER_DERIVED, which is how __read_historical tells them
689
+ // from the input records above. Cleared in a finally: a handler that
690
+ // throws must not leave the executor stamping every later record as
691
+ // derived, which would silently drop that input from every replay.
692
+ this.executor.deriving = true;
693
+ try {
694
+ this.onLocalSim.send1(f);
695
+ } finally {
696
+ this.executor.deriving = false;
697
+ }
468
698
  this.action_log.end_frame();
469
699
  }
470
700
 
@@ -491,23 +721,20 @@ export class ServerAuthoritativeServer {
491
721
  #read_historical(frame_number, out_array) {
492
722
  out_array.length = 0;
493
723
  if (!this.action_log.has_frame(frame_number)) return;
494
- const buf = this.action_log.buffer_for(frame_number); // sets position = 0
495
- const end = this.action_log.write_end_for(frame_number);
496
- while (buf.position < end) {
497
- const prior_count = buf.readUintVar();
498
- for (let i = 0; i < prior_count; i++) {
499
- buf.readUintVar(); // entity_id
500
- buf.readUint8(); // component_type_id
501
- const plen = buf.readUint32();
502
- buf.position += plen;
503
- }
504
- const type_id = buf.readUint8();
505
- const sender_id = buf.readUint8();
506
- const payload_len = buf.readUint32();
507
- const payload = new Uint8Array(payload_len);
508
- payload.set(buf.raw_bytes.subarray(buf.position, buf.position + payload_len));
509
- buf.position += payload_len;
510
- out_array.push({ type_id, sender_id, payload });
724
+ const cursor = this.#cursor;
725
+ cursor.open(this.action_log, frame_number);
726
+ const bytes = cursor.buffer.raw_bytes;
727
+ while (cursor.next()) {
728
+ // Derived records are the previous pass's local-sim output. The
729
+ // caller re-runs onLocalSim for this frame, which recomputes them
730
+ // against a world that now includes the late input that forced the
731
+ // replay; carrying the stale copy forward as well would apply the
732
+ // effect twice, and the copies would accumulate one per replay.
733
+ if (cursor.sender_id === SENDER_DERIVED) continue;
734
+
735
+ const payload = new Uint8Array(cursor.payload_len);
736
+ payload.set(bytes.subarray(cursor.payload_start, cursor.payload_start + cursor.payload_len));
737
+ out_array.push({ type_id: cursor.action_type_id, sender_id: cursor.sender_id, payload });
511
738
  }
512
739
  }
513
740
 
@@ -542,17 +769,17 @@ export class ServerAuthoritativeServer {
542
769
  return;
543
770
  }
544
771
 
545
- // Dedup against other pending entries for the same frame. Multiple
546
- // retransmission packets routinely arrive in the same c2s.tick
547
- // window when ack RTT exceeds tick period; each carries the same
548
- // action records for the same frames. The __replay_frame dedup
549
- // catches retransmissions across server ticks (where historical
550
- // already has the record), but the SAME server tick can collect
551
- // multiple identical pending entries before historical exists. So
552
- // also dedup here, against everything already in pending for this
553
- // frame. Match on (sender_id, type_id, payload bytes) — distinct
554
- // payloads from the same sender at the same frame are legitimately
555
- // different actions (move + fire) and both flow through.
772
+ // Retransmissions are routine on the wire: `Replicator.pack_for_peer`
773
+ // packs every frame in [last_acked + 1, current_frame] each tick, so
774
+ // an action arrives once per tick until its ack round-trips, and
775
+ // several copies can land in the same server tick when the round trip
776
+ // exceeds the tick period. A copy is recognised by its whole identity,
777
+ // (sender_id, type_id, payload bytes) distinct payloads from the
778
+ // same sender at the same frame are legitimately different actions
779
+ // (move + fire) and both flow through.
780
+ //
781
+ // First against everything already pending for the frame, which
782
+ // catches the copies that arrive before the frame is simulated.
556
783
  for (let i = 0; i < this.#pending_frames.length; i++) {
557
784
  if (this.#pending_frames[i] !== frame_number) continue;
558
785
  if (this.#pending_senders[i] !== sender_id) continue;
@@ -560,6 +787,26 @@ export class ServerAuthoritativeServer {
560
787
  if (fastArrayEquals(this.#pending_payloads[i], payload)) return; // duplicate already in pending; drop
561
788
  }
562
789
 
790
+ // Then against the action log, which catches the copies that arrive
791
+ // after it. A record the log already holds is not input — applying it
792
+ // again would apply the action twice, and rewinding for it would
793
+ // replay the world into the state it is already in — so it is dropped
794
+ // here, and pending holds only input the log does not. That is what
795
+ // lets `tick` take the oldest pending frame as its replay window
796
+ // without a second look.
797
+ //
798
+ // Frames are only open inside `tick`, and packets arrive between
799
+ // ticks, so an open frame here means a call pattern this orchestrator
800
+ // does not support (a packet delivered from inside a local-sim
801
+ // handler). The check is skipped rather than reading an open frame's
802
+ // stale length; the copy then costs one spurious rewind, and
803
+ // `__replay_frame`'s own dedup keeps it from applying twice.
804
+ if (this.action_log.has_frame(frame_number)
805
+ && !this.action_log.has_open_frame()
806
+ && this.#log_holds_record(frame_number, sender_id, type_id, payload)) {
807
+ return;
808
+ }
809
+
563
810
  this.#pending_frames.push(frame_number);
564
811
  this.#pending_senders.push(sender_id);
565
812
  this.#pending_type_ids.push(type_id);
@@ -25,6 +25,10 @@
25
25
  * uint32: action_payload_len
26
26
  * bytes: action_payload
27
27
  * ```
28
+ * {@link NetworkPeer} prefixes each packet of this with a slice header — the
29
+ * frame range the packet covers and whether it is the first of the sender's
30
+ * tick — which it parses itself and hands to {@link unpack_from_peer} as
31
+ * `slice`, so the receiver can apply the sender's packets in frame order.
28
32
  *
29
33
  * @author Alex Goldring
30
34
  * @copyright Company Named Limited (c) 2026
@@ -56,8 +60,9 @@ export class Replicator {
56
60
  * or anything else that needs to know "frame N from peer P has just
57
61
  * been applied to the local world."
58
62
  *
59
- * Fires in frame-ascending order within a single packet (the order
60
- * the wire format encodes them in).
63
+ * Fires in frame order within a packet, which encodes its frames
64
+ * ascending, and across packets, because a later slice of a sender's tick
65
+ * is held until the slices before it have landed.
61
66
  *
62
67
  * @type {Signal}
63
68
  */
@@ -111,27 +116,57 @@ export class Replicator {
111
116
  *
112
117
  * Writes nothing if no in-scope actions are found.
113
118
  *
119
+ * `max_bytes` caps how many bytes this call may add. Frame groups are
120
+ * written whole or not at all: when the next group would cross the cap, the
121
+ * pack stops before it and the return value reports how far it got, so the
122
+ * caller can credit exactly what it sent and pick the rest up on a later
123
+ * pass. The one exception is a first group that does not fit by itself —
124
+ * that one is written anyway, because no smaller pack could ever carry the
125
+ * frame and dropping it would strand those actions forever.
126
+ *
114
127
  * @param {number} peer_id
115
128
  * @param {number} start_frame inclusive; if no frame in range is in the log, that frame is skipped
116
129
  * @param {number} end_frame inclusive
117
130
  * @param {BinaryBuffer} out_buffer
131
+ * @param {number} [max_bytes] byte budget for this call; unbounded by default
132
+ * @returns {number} the last frame this pack covers, or `start_frame - 1` if
133
+ * it covers nothing. A frame that was skipped — absent from the log, or
134
+ * holding nothing in scope — counts as covered: the receiver has nothing
135
+ * to apply for it, so the caller may credit it.
118
136
  */
119
- pack_for_peer(peer_id: number, start_frame: number, end_frame: number, out_buffer: BinaryBuffer): void;
137
+ pack_for_peer(peer_id: number, start_frame: number, end_frame: number, out_buffer: BinaryBuffer, max_bytes?: number): number;
120
138
  /**
121
139
  * Parse and apply actions from `in_buffer` into the local world via the executor.
122
140
  * Each frame in the packet opens a new entry in the local action log under the
123
141
  * sender's frame number, ensuring the rewind/replay machinery sees consistent
124
142
  * frame indexing across peers.
125
143
  *
144
+ * `slice` is the sender's description of the packet, from the
145
+ * {@link NetworkPeer} slice header: the frame range it covers and whether
146
+ * it is the first packet of the sender's tick. Frames are applied in frame
147
+ * order. A slice that starts past the frames applied so far and is not a
148
+ * head is a later slice of a burst whose earlier slice has not landed; it
149
+ * is held and applied once the gap before it fills. A head is applied as
150
+ * soon as it arrives, because nothing the sender can still send lies
151
+ * before it — that is also how the receiver moves past frames lost for
152
+ * good. Without `slice` (callers that pack and unpack directly) the packet
153
+ * is taken as a head covering the frames it carries.
154
+ *
126
155
  * @param {number} peer_id
127
156
  * @param {BinaryBuffer} in_buffer
128
157
  * @param {number} in_buffer_end byte position to stop reading at
158
+ * @param {{frame_start: number, frame_end: number, head: boolean}|null} [slice]
129
159
  */
130
- unpack_from_peer(peer_id: number, in_buffer: BinaryBuffer, in_buffer_end: number): void;
160
+ unpack_from_peer(peer_id: number, in_buffer: BinaryBuffer, in_buffer_end: number, slice?: {
161
+ frame_start: number;
162
+ frame_end: number;
163
+ head: boolean;
164
+ } | null): void;
131
165
  /**
132
- * Forget the per-peer apply watermark. Call when a peer disconnects so a
133
- * later peer reusing the same id (or the same peer resuming a session whose
134
- * frame numbering restarts) is not wrongly deduped against stale state.
166
+ * Forget a peer's inbound state its apply watermark and any slices held
167
+ * for it. Call when a peer disconnects so a later peer reusing the same id
168
+ * (or the same peer resuming a session whose frame numbering restarts) is
169
+ * not wrongly deduped against stale state.
135
170
  *
136
171
  * @param {number} peer_id
137
172
  */
@@ -139,4 +174,5 @@ export class Replicator {
139
174
  #private;
140
175
  }
141
176
  import Signal from "../../../core/events/signal/Signal.js";
177
+ import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js";
142
178
  //# sourceMappingURL=Replicator.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Replicator.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/replication/Replicator.js"],"names":[],"mappings":"AAQA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH;IA2DI;;;;;;;;OAQG;IACH;;;;;;wCAHiD,MAAM,cAAc,MAAM,GAAG,OAAO;;OA8BpF;IAvED;;;;;;;;;;;;OAYG;IACH,gBAFU,MAAM,CAEc;IAE9B;;;;;;;;;;;;;;;;;OAiBG;IACH,6BAFqB,MAAM,gBAAgB,MAAM,kBAAkB,MAAM,2CAA2C,MAAM,eAAe,MAAM,KAAK,IAAI,CAE/H;IAYrB;;OAEG;IACH,sBAA4B;IAE5B;;OAEG;IACH,mCAAsC;IAEtC;;OAEG;IACH,4BAAwB;IAExB;;OAEG;IACH,iCAA4B;IAE5B;;;;OAIG;IACH;oCAFwC,MAAM,cAAc,MAAM,GAAG,OAAO;MAE5C;IAGpC;;;;;;;;;;OAUG;IACH,uBALW,MAAM,eACN,MAAM,aACN,MAAM,kCA2ChB;IAED;;;;;;;;;OASG;IACH,0BAJW,MAAM,0CAEN,MAAM,QA4GhB;IAED;;;;;;OAMG;IACH,qBAFW,MAAM,QAKhB;;CAuFJ;mBAnZkB,uCAAuC"}
1
+ {"version":3,"file":"Replicator.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/replication/Replicator.js"],"names":[],"mappings":"AAqBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH;IAkFI;;;;;;;;OAQG;IACH;;;;;;wCAHiD,MAAM,cAAc,MAAM,GAAG,OAAO;;OA8BpF;IA/ED;;;;;;;;;;;;;OAaG;IACH,gBAFU,MAAM,CAEc;IAE9B;;;;;;;;;;;;;;;;;OAiBG;IACH,6BAFqB,MAAM,gBAAgB,MAAM,kBAAkB,MAAM,aAAa,YAAY,kBAAkB,MAAM,eAAe,MAAM,KAAK,IAAI,CAE/H;IAmBrB;;OAEG;IACH,sBAA4B;IAE5B;;OAEG;IACH,mCAAsC;IAEtC;;OAEG;IACH,4BAAwB;IAExB;;OAEG;IACH,iCAA4B;IAE5B;;;;OAIG;IACH;oCAFwC,MAAM,cAAc,MAAM,GAAG,OAAO;MAE5C;IAGpC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,uBAVW,MAAM,eACN,MAAM,aACN,MAAM,cACN,YAAY,cACZ,MAAM,GACJ,MAAM,CA4ElB;IAED;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,0BALW,MAAM,aACN,YAAY,iBACZ,MAAM,UACN;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAC,GAAC,IAAI,QAmEtE;IAyLD;;;;;;;OAOG;IACH,qBAFW,MAAM,QAKhB;;CA8CJ;mBAxlBkB,uCAAuC;6BAD7B,sCAAsC"}