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