@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
@@ -1,11 +1,24 @@
1
1
  import { assert } from "../../../core/assert.js";
2
+ import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js";
2
3
  import Signal from "../../../core/events/signal/Signal.js";
3
4
  import { MalformedPacketError } from "../core/MalformedPacketError.js";
5
+ import { ActionRecordCursor } from "../sim/ActionRecordCursor.js";
4
6
 
5
7
  // Bytes of fixed per-action framing that always precede the variable payload:
6
8
  // uint8 action_type_id + uint32 action_payload_len.
7
9
  const ACTION_HEADER_BYTES = 5;
8
10
 
11
+ /**
12
+ * Most slices {@link Replicator#unpack_from_peer} holds per peer while waiting
13
+ * for an earlier slice to land. A sender bursts at most `max_packets_per_tick`
14
+ * slices a tick and re-sends the missing one every tick, so a legitimate
15
+ * backlog is a burst or two; this is a backstop against a sender that never
16
+ * fills the gap. Beyond it the farthest slice is dropped — those frames were
17
+ * acked at the channel level, so the sender will credit them and not re-send
18
+ * them, which is a loss, and the reason the cap is generous.
19
+ */
20
+ const MAX_HELD_SLICES = 256;
21
+
9
22
  /**
10
23
  * Forwards action records between peers.
11
24
  *
@@ -33,33 +46,52 @@ const ACTION_HEADER_BYTES = 5;
33
46
  * uint32: action_payload_len
34
47
  * bytes: action_payload
35
48
  * ```
49
+ * {@link NetworkPeer} prefixes each packet of this with a slice header — the
50
+ * frame range the packet covers and whether it is the first of the sender's
51
+ * tick — which it parses itself and hands to {@link unpack_from_peer} as
52
+ * `slice`, so the receiver can apply the sender's packets in frame order.
36
53
  *
37
54
  * @author Alex Goldring
38
55
  * @copyright Company Named Limited (c) 2026
39
56
  */
40
57
  export class Replicator {
41
58
  /**
42
- * Pre-allocated scratch for record positions in the current pack pass.
43
- * Each slot holds (record_start, record_end) interleaved.
44
- * Grown on demand.
59
+ * Pre-allocated scratch for the in-scope actions of the current pack
60
+ * pass: `(action_type_id, payload_start, payload_len)` per action,
61
+ * interleaved. Grown on demand.
45
62
  * @type {Int32Array}
46
63
  * @private
47
64
  */
48
- #record_bounds = new Int32Array(64 * 2);
65
+ #in_scope_actions = new Int32Array(64 * 3);
49
66
  /**
50
- * Per-peer high-watermark of the highest frame number already applied
51
- * via {@link unpack_from_peer}'s execute-on-arrival path. The action
52
- * stream re-sends every unacked frame each tick (back-fill for loss
53
- * tolerance), so the same frame arrives multiple times before its ack
54
- * round-trips. Re-running a frame would recycle its action-log slot and
55
- * re-capture prior state from the already-mutated world corrupting the
56
- * rewind baseline. Skipping frames at or below the watermark keeps each
57
- * frame applied exactly once. Only used by the execute-on-arrival path;
58
- * the deferred path ({@link on_pending_action}) dedups downstream.
59
- * @type {Map<number, number>}
67
+ * Per-peer state of {@link unpack_from_peer}'s execute-on-arrival path:
68
+ * `next_frame`, the frame after the newest applied, and `held`, later
69
+ * slices of a sender's tick waiting for an earlier slice still in flight,
70
+ * sorted by `frame_start`.
71
+ *
72
+ * `next_frame` is what keeps each frame applied exactly once. The action
73
+ * stream re-sends every unconfirmed frame each tick (back-fill for loss
74
+ * tolerance), so a frame arrives several times before its ack round-trips,
75
+ * and re-running it would recycle its action-log slot and re-capture prior
76
+ * state from the already-mutated world — corrupting the rewind baseline.
77
+ * Frames below `next_frame` are skipped.
78
+ *
79
+ * One watermark is sound only because frames are applied in order. The
80
+ * sender may split a tick's owed range across several packets
81
+ * ({@link NetworkPeer#flush_outbound}), and when the first is lost the
82
+ * later ones land first. Applied on arrival they would put their frames
83
+ * ahead of the re-sent earlier ones — an absolute-state action from the
84
+ * older frame would then overwrite the newer, a despawn could precede its
85
+ * spawn — and the watermark would take them as proof of everything before,
86
+ * so the re-send would be skipped for good while the sender, seeing it
87
+ * acked, credited those frames as delivered. So a later slice is held
88
+ * until the slices before it have landed. Only the execute-on-arrival path
89
+ * keeps this; the deferred path ({@link on_pending_action}) orders and
90
+ * dedups downstream.
91
+ * @type {Map<number, {next_frame: number, held: Array<{frame_start: number, frame_end: number, buffer: BinaryBuffer, length: number}>}>}
60
92
  * @private
61
93
  */
62
- #applied_through = new Map();
94
+ #inbound = new Map();
63
95
 
64
96
  /**
65
97
  * Fired after each per-frame action group is fully applied via
@@ -69,8 +101,9 @@ export class Replicator {
69
101
  * or anything else that needs to know "frame N from peer P has just
70
102
  * been applied to the local world."
71
103
  *
72
- * Fires in frame-ascending order within a single packet (the order
73
- * the wire format encodes them in).
104
+ * Fires in frame order within a packet, which encodes its frames
105
+ * ascending, and across packets, because a later slice of a sender's tick
106
+ * is held until the slices before it have landed.
74
107
  *
75
108
  * @type {Signal}
76
109
  */
@@ -96,6 +129,13 @@ export class Replicator {
96
129
  */
97
130
  on_pending_action = null;
98
131
 
132
+ /**
133
+ * Walks a frame's records for {@link pack_for_peer}.
134
+ * @type {ActionRecordCursor}
135
+ * @private
136
+ */
137
+ #cursor = new ActionRecordCursor();
138
+
99
139
  /**
100
140
  * @param {{
101
141
  * action_log: ActionLog,
@@ -140,51 +180,95 @@ export class Replicator {
140
180
  *
141
181
  * Writes nothing if no in-scope actions are found.
142
182
  *
183
+ * `max_bytes` caps how many bytes this call may add. Frame groups are
184
+ * written whole or not at all: when the next group would cross the cap, the
185
+ * pack stops before it and the return value reports how far it got, so the
186
+ * caller can credit exactly what it sent and pick the rest up on a later
187
+ * pass. The one exception is a first group that does not fit by itself —
188
+ * that one is written anyway, because no smaller pack could ever carry the
189
+ * frame and dropping it would strand those actions forever.
190
+ *
143
191
  * @param {number} peer_id
144
192
  * @param {number} start_frame inclusive; if no frame in range is in the log, that frame is skipped
145
193
  * @param {number} end_frame inclusive
146
194
  * @param {BinaryBuffer} out_buffer
195
+ * @param {number} [max_bytes] byte budget for this call; unbounded by default
196
+ * @returns {number} the last frame this pack covers, or `start_frame - 1` if
197
+ * it covers nothing. A frame that was skipped — absent from the log, or
198
+ * holding nothing in scope — counts as covered: the receiver has nothing
199
+ * to apply for it, so the caller may credit it.
147
200
  */
148
- pack_for_peer(peer_id, start_frame, end_frame, out_buffer) {
201
+ pack_for_peer(peer_id, start_frame, end_frame, out_buffer, max_bytes = Number.MAX_SAFE_INTEGER) {
149
202
  assert.isNonNegativeInteger(peer_id, 'peer_id');
150
203
  assert.isNonNegativeInteger(start_frame, 'start_frame');
151
204
  assert.isNonNegativeInteger(end_frame, 'end_frame');
152
205
 
153
- for (let frame = start_frame; frame <= end_frame; frame++) {
154
- if (!this.action_log.has_frame(frame)) continue;
206
+ // Budget is measured from wherever the caller had already written to,
207
+ // so a caller that prefixed a packet-type byte gets it counted.
208
+ const budget_end = out_buffer.position + max_bytes;
209
+ let last_packed = start_frame - 1;
210
+ let wrote_any_group = false;
155
211
 
156
- const buffer = this.action_log.buffer_for(frame);
157
- const end = this.action_log.write_end_for(frame);
212
+ for (let frame = start_frame; frame <= end_frame; frame++) {
213
+ if (!this.action_log.has_frame(frame)) {
214
+ last_packed = frame;
215
+ continue;
216
+ }
158
217
 
159
- // First pass: walk records, capture (start, end) for those in scope.
218
+ // First pass: walk the records, remembering where the action of
219
+ // each in-scope one sits.
220
+ const cursor = this.#cursor;
221
+ cursor.open(this.action_log, frame);
160
222
  let in_scope_count = 0;
161
- while (buffer.position < end) {
162
- const record_start = buffer.position;
163
- const in_scope = this.#scan_record_in_scope(buffer, peer_id);
164
- const record_end = buffer.position;
165
-
166
- if (in_scope) {
167
- if ((in_scope_count + 1) * 2 > this.#record_bounds.length) {
168
- this.#grow_record_bounds();
169
- }
170
- this.#record_bounds[in_scope_count * 2] = record_start;
171
- this.#record_bounds[in_scope_count * 2 + 1] = record_end;
172
- in_scope_count++;
223
+ while (cursor.next()) {
224
+ if (!this.#record_in_scope(cursor, peer_id)) continue;
225
+ if ((in_scope_count + 1) * 3 > this.#in_scope_actions.length) {
226
+ this.#grow_in_scope_actions();
173
227
  }
228
+ this.#in_scope_actions[in_scope_count * 3] = cursor.action_type_id;
229
+ this.#in_scope_actions[in_scope_count * 3 + 1] = cursor.payload_start;
230
+ this.#in_scope_actions[in_scope_count * 3 + 2] = cursor.payload_len;
231
+ in_scope_count++;
174
232
  }
175
233
 
176
- if (in_scope_count === 0) continue;
234
+ if (in_scope_count === 0) {
235
+ last_packed = frame;
236
+ continue;
237
+ }
177
238
 
178
239
  // Second pass: write the frame header + each in-scope action's payload only.
240
+ // The group's encoded size is only known once written (varint frame
241
+ // numbers and counts), so write it and roll back if it overran.
242
+ const group_start = out_buffer.position;
179
243
  out_buffer.writeUintVar(frame);
180
244
  out_buffer.writeUintVar(in_scope_count);
181
245
 
246
+ // Wire format per action is (type_id, len, payload). The record's
247
+ // local-only sender_id byte is NOT copied: the receiver derives the
248
+ // sender from the packet's peer_id, and carrying it on the wire
249
+ // would let a hostile peer impersonate other peers' actions.
250
+ const raw_bytes = cursor.buffer.raw_bytes;
182
251
  for (let i = 0; i < in_scope_count; i++) {
183
- const rs = this.#record_bounds[i * 2];
184
- buffer.position = rs;
185
- this.#copy_action_to(buffer, out_buffer);
252
+ const payload_start = this.#in_scope_actions[i * 3 + 1];
253
+ const payload_len = this.#in_scope_actions[i * 3 + 2];
254
+ out_buffer.writeUint8(this.#in_scope_actions[i * 3]);
255
+ out_buffer.writeUint32(payload_len);
256
+ out_buffer.writeBytes(raw_bytes, payload_start, payload_len);
257
+ }
258
+
259
+ if (wrote_any_group && out_buffer.position > budget_end) {
260
+ // Doesn't fit alongside what we already have. Un-write it and
261
+ // stop — the caller credits only up to the previous frame and
262
+ // this one leads the next pack.
263
+ out_buffer.position = group_start;
264
+ break;
186
265
  }
266
+
267
+ wrote_any_group = true;
268
+ last_packed = frame;
187
269
  }
270
+
271
+ return last_packed;
188
272
  }
189
273
 
190
274
  /**
@@ -193,11 +277,23 @@ export class Replicator {
193
277
  * sender's frame number, ensuring the rewind/replay machinery sees consistent
194
278
  * frame indexing across peers.
195
279
  *
280
+ * `slice` is the sender's description of the packet, from the
281
+ * {@link NetworkPeer} slice header: the frame range it covers and whether
282
+ * it is the first packet of the sender's tick. Frames are applied in frame
283
+ * order. A slice that starts past the frames applied so far and is not a
284
+ * head is a later slice of a burst whose earlier slice has not landed; it
285
+ * is held and applied once the gap before it fills. A head is applied as
286
+ * soon as it arrives, because nothing the sender can still send lies
287
+ * before it — that is also how the receiver moves past frames lost for
288
+ * good. Without `slice` (callers that pack and unpack directly) the packet
289
+ * is taken as a head covering the frames it carries.
290
+ *
196
291
  * @param {number} peer_id
197
292
  * @param {BinaryBuffer} in_buffer
198
293
  * @param {number} in_buffer_end byte position to stop reading at
294
+ * @param {{frame_start: number, frame_end: number, head: boolean}|null} [slice]
199
295
  */
200
- unpack_from_peer(peer_id, in_buffer, in_buffer_end) {
296
+ unpack_from_peer(peer_id, in_buffer, in_buffer_end, slice = null) {
201
297
  assert.isNonNegativeInteger(peer_id, 'peer_id');
202
298
  assert.isNonNegativeInteger(in_buffer_end, 'in_buffer_end');
203
299
 
@@ -234,19 +330,72 @@ export class Replicator {
234
330
  return;
235
331
  }
236
332
 
237
- const registry = this.action_registry;
333
+ let state = this.#inbound.get(peer_id);
334
+ if (state === undefined) {
335
+ state = { next_frame: 0, held: [] };
336
+ this.#inbound.set(peer_id, state);
337
+ }
338
+
339
+ if (slice !== null && !slice.head && slice.frame_start > state.next_frame) {
340
+ this.#hold_slice(peer_id, state, slice, in_buffer, in_buffer_end);
341
+ return;
342
+ }
343
+
344
+ if (slice !== null && slice.head) {
345
+ // Nothing before a head will be sent again. Whatever was held from
346
+ // before it is as complete as it will ever be, so apply it first,
347
+ // in order, rather than letting the head jump past it.
348
+ this.#apply_held_before(peer_id, state, slice.frame_start);
349
+ }
350
+
351
+ const last_frame = this.#apply_groups(
352
+ peer_id, in_buffer, in_buffer_end, state.next_frame,
353
+ slice !== null ? slice.frame_start : 0,
354
+ slice !== null ? slice.frame_end : Infinity,
355
+ );
356
+ const covered_through = slice !== null ? slice.frame_end : last_frame;
357
+ if (covered_through + 1 > state.next_frame) {
358
+ state.next_frame = covered_through + 1;
359
+ }
360
+ this.#apply_held_contiguous(peer_id, state);
361
+ }
238
362
 
239
- const applied_through = this.#applied_through.get(peer_id);
363
+ /**
364
+ * Apply the frame groups in `[in_buffer.position, in_buffer_end)`, skipping
365
+ * any frame below `min_frame` — already applied, or, when `min_frame` is
366
+ * `Infinity`, every frame, which is how a packet is validated before it is
367
+ * held. A skipped group is still walked, so a malformed one throws here
368
+ * rather than later. A group outside `[frame_start, frame_end]`, the range
369
+ * the slice header declared, is malformed too: the watermark is raised to
370
+ * the header's end, so a frame beyond it would be applied again by the
371
+ * slice that genuinely carries it.
372
+ *
373
+ * @param {number} peer_id
374
+ * @param {BinaryBuffer} in_buffer
375
+ * @param {number} in_buffer_end
376
+ * @param {number} min_frame
377
+ * @param {number} frame_start
378
+ * @param {number} frame_end
379
+ * @returns {number} the highest frame number the packet carries, or -1 if none
380
+ * @private
381
+ */
382
+ #apply_groups(peer_id, in_buffer, in_buffer_end, min_frame, frame_start, frame_end) {
383
+ const registry = this.action_registry;
384
+ let last_frame = -1;
240
385
 
241
386
  while (in_buffer.position < in_buffer_end) {
242
387
  const frame_number = in_buffer.readUintVar();
243
388
  const action_count = in_buffer.readUintVar();
389
+ if (frame_number < frame_start || frame_number > frame_end) {
390
+ throw new MalformedPacketError(`Replicator.unpack_from_peer: frame ${frame_number} lies outside the slice [${frame_start}, ${frame_end}]`);
391
+ }
392
+ if (frame_number > last_frame) last_frame = frame_number;
244
393
 
245
394
  // Skip frames already applied from this peer — the action stream
246
- // re-sends every unacked frame each tick, and re-applying would
247
- // corrupt the action-log prior-state capture (see #applied_through).
395
+ // re-sends every unconfirmed frame each tick, and re-applying would
396
+ // corrupt the action-log prior-state capture (see #inbound).
248
397
  // The read cursor must still advance past this frame's actions.
249
- if (applied_through !== undefined && frame_number <= applied_through) {
398
+ if (frame_number < min_frame) {
250
399
  for (let i = 0; i < action_count; i++) {
251
400
  if (in_buffer.position + ACTION_HEADER_BYTES > in_buffer_end) {
252
401
  throw new MalformedPacketError(`Replicator.unpack_from_peer: action header overruns packet (skip; frame ${frame_number}, action ${i}/${action_count})`);
@@ -294,44 +443,135 @@ export class Replicator {
294
443
  this.action_log.end_frame();
295
444
  }
296
445
 
297
- // Advance the per-peer watermark so subsequent back-fill resends of
298
- // this frame are skipped. Frames within a packet are strictly
299
- // ascending, so this only ever moves forward.
300
- this.#applied_through.set(peer_id, frame_number);
301
-
302
446
  // Fire AFTER end_frame so handlers see consistent state and can
303
447
  // safely query the action log for the just-applied frame.
304
448
  this.onFrameApplied.send2(peer_id, frame_number);
305
449
  }
450
+
451
+ return last_frame;
452
+ }
453
+
454
+ /**
455
+ * Keep a slice that arrived ahead of the slices before it, to be applied
456
+ * once they land. The bytes are copied — the inbound buffer is shared
457
+ * scratch — after a validation walk, so a malformed slice is rejected now,
458
+ * by the packet that carried it, and not during a later drain inside some
459
+ * other packet's handling.
460
+ *
461
+ * @param {number} peer_id
462
+ * @param {{next_frame: number, held: Array<{frame_start: number, frame_end: number, buffer: BinaryBuffer, length: number}>}} state
463
+ * @param {{frame_start: number, frame_end: number}} slice
464
+ * @param {BinaryBuffer} in_buffer
465
+ * @param {number} in_buffer_end
466
+ * @private
467
+ */
468
+ #hold_slice(peer_id, state, slice, in_buffer, in_buffer_end) {
469
+ const start = in_buffer.position;
470
+ this.#apply_groups(peer_id, in_buffer, in_buffer_end, Infinity, slice.frame_start, slice.frame_end);
471
+
472
+ const held = state.held;
473
+ let i = 0;
474
+ while (i < held.length && held[i].frame_start < slice.frame_start) i++;
475
+
476
+ // Slice boundaries move between ticks: the last slice of a burst grows
477
+ // by a frame a tick until it fills a packet, a frame that packs to
478
+ // nothing lets the next one in, a rollback that grows a frame pushes
479
+ // the next one out. So a re-send with the same start is not the same
480
+ // slice. Every copy was acked, and the sender credits every frame any
481
+ // copy covered, so a frame only one copy covers is never sent again:
482
+ // keep the copy that reaches further, and between equals the newer,
483
+ // which carries the sender's latest content for those frames.
484
+ const same_start = i < held.length && held[i].frame_start === slice.frame_start;
485
+ if (same_start && held[i].frame_end > slice.frame_end) return;
486
+ if (!same_start && held.length >= MAX_HELD_SLICES) {
487
+ // Keep the slices nearest the gap — the ones the next head is most
488
+ // likely to release — and drop the farthest.
489
+ if (i === held.length) return;
490
+ held.pop();
491
+ }
492
+
493
+ const length = in_buffer_end - start;
494
+ const buffer = new BinaryBuffer();
495
+ buffer.setCapacity(length);
496
+ buffer.writeBytes(in_buffer.raw_bytes, start, length);
497
+ const entry = { frame_start: slice.frame_start, frame_end: slice.frame_end, buffer, length };
498
+ if (same_start) {
499
+ held[i] = entry;
500
+ } else {
501
+ held.splice(i, 0, entry);
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Apply, in order, every held slice that starts before `frame` — the start
507
+ * of a head, before which nothing will be sent again.
508
+ * @private
509
+ */
510
+ #apply_held_before(peer_id, state, frame) {
511
+ const held = state.held;
512
+ while (held.length > 0 && held[0].frame_start < frame) {
513
+ const h = held.shift();
514
+ if (h.frame_end >= state.next_frame) this.#apply_held(peer_id, state, h);
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Apply held slices for as long as the next one starts at or before the
520
+ * watermark, dropping any the watermark has already passed.
521
+ * @private
522
+ */
523
+ #apply_held_contiguous(peer_id, state) {
524
+ const held = state.held;
525
+ while (held.length > 0) {
526
+ const h = held[0];
527
+ if (h.frame_end < state.next_frame) {
528
+ held.shift();
529
+ continue;
530
+ }
531
+ if (h.frame_start > state.next_frame) break;
532
+ held.shift();
533
+ this.#apply_held(peer_id, state, h);
534
+ }
535
+ }
536
+
537
+ /** @private */
538
+ #apply_held(peer_id, state, h) {
539
+ h.buffer.position = 0;
540
+ this.#apply_groups(peer_id, h.buffer, h.length, state.next_frame, h.frame_start, h.frame_end);
541
+ if (h.frame_end + 1 > state.next_frame) {
542
+ state.next_frame = h.frame_end + 1;
543
+ }
306
544
  }
307
545
 
308
546
  /**
309
- * Forget the per-peer apply watermark. Call when a peer disconnects so a
310
- * later peer reusing the same id (or the same peer resuming a session whose
311
- * frame numbering restarts) is not wrongly deduped against stale state.
547
+ * Forget a peer's inbound state its apply watermark and any slices held
548
+ * for it. Call when a peer disconnects so a later peer reusing the same id
549
+ * (or the same peer resuming a session whose frame numbering restarts) is
550
+ * not wrongly deduped against stale state.
312
551
  *
313
552
  * @param {number} peer_id
314
553
  */
315
554
  forget_peer(peer_id) {
316
555
  assert.isNonNegativeInteger(peer_id, 'peer_id');
317
- this.#applied_through.delete(peer_id);
556
+ this.#inbound.delete(peer_id);
318
557
  }
319
558
 
320
559
  /**
321
- * Walk a single record from the buffer's current position, advancing past it.
322
- * Returns true if any of the record's affected entities is in scope for `peer_id`,
323
- * or if the record has no affected entities (event-style action always sent).
560
+ * Is the record under `cursor` in scope for `peer_id` does it touch an
561
+ * entity the peer should see? A record with no affected components (an
562
+ * event-style action) is always in scope.
324
563
  *
325
- * @param {BinaryBuffer} buffer
564
+ * @param {ActionRecordCursor} cursor
326
565
  * @param {number} peer_id
327
566
  * @returns {boolean}
328
567
  * @private
329
568
  */
330
- #scan_record_in_scope(buffer, peer_id) {
331
- const prior_count = buffer.readUintVar();
569
+ #record_in_scope(cursor, peer_id) {
570
+ if (cursor.prior_count === 0) return true;
332
571
 
333
- let any_in_scope = false;
334
- for (let i = 0; i < prior_count; i++) {
572
+ const buffer = cursor.buffer;
573
+ buffer.position = cursor.prior_start;
574
+ for (let i = 0; i < cursor.prior_count; i++) {
335
575
  const entity_id = buffer.readUintVar();
336
576
  // Translate local entity_id to network_id for the scope filter, which
337
577
  // operates on peer-shared identifiers. If `network_for` returns -1,
@@ -342,64 +582,22 @@ export class Replicator {
342
582
  // safely apply.
343
583
  const network_id = this.slot_table.network_for(entity_id);
344
584
  if (network_id >= 0 && this.scope_filter.is_entity_in_scope(peer_id, network_id)) {
345
- any_in_scope = true;
585
+ return true;
346
586
  }
347
587
  buffer.readUint8(); // component_type_id
348
- const payload_len = buffer.readUint32();
349
- buffer.position += payload_len; // skip prior payload
350
- }
351
-
352
- // Skip the action payload too.
353
- buffer.readUint8(); // action_type_id
354
- buffer.readUint8(); // sender_id (local-only metadata, not sent)
355
- const action_payload_len = buffer.readUint32();
356
- buffer.position += action_payload_len;
357
-
358
- // Event-style actions (no affected components) are always in scope.
359
- return prior_count === 0 ? true : any_in_scope;
360
- }
361
-
362
- /**
363
- * Buffer's current position is the start of an action record. Skip the
364
- * prior_state section, then copy the action portion (type_id + len + payload)
365
- * to `out_buffer`. Buffer position advances to the end of the record.
366
- *
367
- * @param {BinaryBuffer} buffer source action-log frame
368
- * @param {BinaryBuffer} out_buffer destination packet
369
- * @private
370
- */
371
- #copy_action_to(buffer, out_buffer) {
372
- // Skip prior_state.
373
- const prior_count = buffer.readUintVar();
374
- for (let i = 0; i < prior_count; i++) {
375
- buffer.readUintVar(); // entity_id
376
- buffer.readUint8(); // component_type_id
377
- const len = buffer.readUint32();
378
- buffer.position += len; // skip prior payload
588
+ const prior_len = buffer.readUint32();
589
+ buffer.position += prior_len; // skip prior payload
379
590
  }
380
-
381
- // Copy action portion: type_id + len + payload. The local-only
382
- // sender_id byte is STRIPPED here — over-the-wire format stays
383
- // (type_id, len, payload) and the receiver derives sender from
384
- // the packet's peer_id. Including sender_id on the wire would
385
- // let a hostile peer impersonate other peers' actions.
386
- const action_type_id = buffer.readUint8();
387
- buffer.readUint8(); // sender_id — discarded, not sent on the wire
388
- const action_payload_len = buffer.readUint32();
389
-
390
- out_buffer.writeUint8(action_type_id);
391
- out_buffer.writeUint32(action_payload_len);
392
- out_buffer.writeBytes(buffer.raw_bytes, buffer.position, action_payload_len);
393
- buffer.position += action_payload_len;
591
+ return false;
394
592
  }
395
593
 
396
594
  /**
397
595
  * @private
398
596
  */
399
- #grow_record_bounds() {
400
- const old = this.#record_bounds;
597
+ #grow_in_scope_actions() {
598
+ const old = this.#in_scope_actions;
401
599
  const next = new Int32Array(old.length * 2);
402
600
  next.set(old);
403
- this.#record_bounds = next;
601
+ this.#in_scope_actions = next;
404
602
  }
405
603
  }
@@ -12,7 +12,7 @@
12
12
  * uint32: prior_payload_len
13
13
  * bytes: prior_payload (adapter.serialize of the component's prior state)
14
14
  * uint8: action_type_id
15
- * uint8: sender_id (peer that originated the action; local-only,
15
+ * uint8: sender_id (provenance of the record; local-only,
16
16
  * STRIPPED by Replicator before send)
17
17
  * uint32: action_payload_len
18
18
  * bytes: action_payload (the action's own serialize output)
@@ -21,9 +21,21 @@
21
21
  * Self-describing: records can be skipped without consulting the action registry,
22
22
  * which means rewind code can walk records forward to find boundaries, then
23
23
  * iterate backward to apply prior states — no need to instantiate any actions.
24
+ * {@link ActionRecordCursor} is that walk; readers use it rather than parsing
25
+ * the layout themselves.
24
26
  *
25
- * Note that `sender_id` is recorded in-buffer for local rollback orchestrators
26
- * (stable-sort tie-breaking by sender on replay) but never crosses the wire —
27
+ * `sender_id` says where the record came from, which is what a rollback replay
28
+ * needs in order to decide what to do with it:
29
+ *
30
+ * - a peer id `[0, 253]` — arrived from that peer;
31
+ * - `SENDER_LOCAL` (255) — authored on this peer;
32
+ * - `SENDER_DERIVED` (254) — computed by this peer's simulation from the
33
+ * frame's own state. A replay of the frame recomputes these rather than
34
+ * reapplying them (see `ServerAuthoritativeServer.__read_historical`), so
35
+ * the sim's output does not accumulate a copy per replay.
36
+ *
37
+ * It also gives the replay a stable sort key, so multi-client action order is
38
+ * deterministic across peers. It never crosses the wire —
27
39
  * {@link Replicator#pack_for_peer} strips it and the receiver derives sender
28
40
  * from the inbound packet's peer_id, so a hostile peer cannot impersonate.
29
41
  *
@@ -60,6 +72,14 @@ export class ActionLog {
60
72
  * Close the currently-open frame.
61
73
  */
62
74
  end_frame(): void;
75
+ /**
76
+ * Is a frame open for writing? {@link current_buffer} — and therefore
77
+ * `SimActionExecutor.execute` — throws when it is not, so a caller that can
78
+ * run either inside or outside a frame checks this to pick its path.
79
+ *
80
+ * @returns {boolean}
81
+ */
82
+ has_open_frame(): boolean;
63
83
  /**
64
84
  * Get the buffer for the currently-open frame for direct writing.
65
85
  *
@@ -1 +1 @@
1
- {"version":3,"file":"ActionLog.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/sim/ActionLog.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH;IAoBI;;OAEG;IACH,qDAFW;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;KAAE,EAIlE;IAED;;;;OAIG;IACH,sCAEC;IAED;;;;OAIG;IACH,mBAFW,MAAM,QAShB;IAED;;OAEG;IACH,kBASC;IAED;;;;OAIG;IACH,+BAMC;IAED;;;;;OAKG;IACH,iBAHW,MAAM,GACJ,OAAO,CAInB;IAED;;;;;;;;;;;OAWG;IACH,kBAHW,MAAM,gBAKhB;IAED;;;;OAIG;IACH,qBAHW,MAAM,GACJ,MAAM,CAIlB;;CACJ"}
1
+ {"version":3,"file":"ActionLog.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/sim/ActionLog.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH;IAoBI;;OAEG;IACH,qDAFW;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;KAAE,EAIlE;IAED;;;;OAIG;IACH,sCAEC;IAED;;;;OAIG;IACH,mBAFW,MAAM,QAShB;IAED;;OAEG;IACH,kBASC;IAED;;;;;;OAMG;IACH,kBAFa,OAAO,CAInB;IAED;;;;OAIG;IACH,+BAMC;IAED;;;;;OAKG;IACH,iBAHW,MAAM,GACJ,OAAO,CAInB;IAED;;;;;;;;;;;OAWG;IACH,kBAHW,MAAM,gBAKhB;IAED;;;;OAIG;IACH,qBAHW,MAAM,GACJ,MAAM,CAIlB;;CACJ"}