spoint 0.1.660 → 0.1.662
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/client/editor/EditorShell.js +3 -171
- package/client/editor/EditorShellMenus.js +183 -0
- package/package.json +1 -1
- package/src/sdk/TickHandler.js +779 -995
- package/src/sdk/TickHandlerAOI.js +228 -0
package/src/sdk/TickHandler.js
CHANGED
|
@@ -1,995 +1,779 @@
|
|
|
1
|
-
import { MSG } from '../protocol/MessageTypes.js'
|
|
2
|
-
import { SnapshotEncoder, unpackBinRecord, TombstoneLog, updateTombstones, PLAYER_LOD_REDUCED_HZ } from '../netcode/SnapshotEncoder.js'
|
|
3
|
-
import { pack } from '../protocol/msgpack.js'
|
|
4
|
-
import { applyMovement as _applyMovement, DEFAULT_MOVEMENT as _DEFAULT_MOVEMENT } from '../shared/movement.js'
|
|
5
|
-
import { applyPlayerCollisions } from '../netcode/CollisionSystem.js'
|
|
6
|
-
import { worldToCell, packCellKey, neighborCells } from '../terrain/CubeSphereCells.js'
|
|
7
|
-
import { createServerTimeOfDay } from './ServerTimeOfDay.js'
|
|
8
|
-
import { createServerWeather } from './ServerWeather.js'
|
|
9
|
-
import { enforceMovementEnvelope } from '../netcode/InputGuard.js'
|
|
10
|
-
import { checksumBodies } from '../netcode/LockstepChecksum.js'
|
|
11
|
-
import { recordSnapshotBytes, recordTickPhase } from './Metrics.js'
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const SNAP_RATE_MIN_HZ = 8
|
|
21
|
-
const SNAP_RATE_MAX_HZ = 30
|
|
22
|
-
const SNAP_RATE_IDLE_HZ = 4
|
|
23
|
-
const SNAP_RATE_ADJUST_INTERVAL = 64
|
|
24
|
-
const AUTO_SAVE_INTERVAL = 300
|
|
25
|
-
const SNAP_PLAYER_LOW = 4
|
|
26
|
-
const SNAP_PLAYER_HIGH = 16
|
|
27
|
-
const SNAP_RTT_LOW = 50
|
|
28
|
-
const SNAP_RTT_HIGH = 200
|
|
29
|
-
// Fraction of the per-tick time budget (1000/tickRate ms) that measured snapshot-build cost must exceed
|
|
30
|
-
// to count as "expensive" -- mirrors the SNAP_RTT_LOW/HIGH pattern but on the real compute-cost axis.
|
|
31
|
-
const SNAP_COST_LOW_FRAC = 0.15
|
|
32
|
-
const SNAP_COST_HIGH_FRAC = 0.35
|
|
33
|
-
// Below this many nearby players, tiering's sort+classify overhead isn't worth it -- every nearby
|
|
34
|
-
// player already gets FULL state via the plain filterEncodedPlayersWithSelf path, same as before.
|
|
35
|
-
const PLAYER_LOD_FULL_COUNT_THRESHOLD = 30
|
|
36
|
-
// Per-client outgoing-bytes-per-tick cap for the SNAPSHOT payload's entities[] array. This bounds the
|
|
37
|
-
// worst case (a player standing in a dense cluster of relevant dynamic entities, all within
|
|
38
|
-
// PRIORITY_ENTITY_BUDGET's count cap but each carrying a large `custom` payload) instead of only
|
|
39
|
-
// capping entity COUNT -- a count cap alone still lets total bytes balloon per-entity. Real UDP-class
|
|
40
|
-
// unreliable transports fragment/drop above ~1200 safe-MTU bytes per datagram; 900 leaves headroom for
|
|
41
|
-
// msgpack framing + the players[]/removed[]/seq/tick/serverTime wrapper fields already sharing the
|
|
42
|
-
// same packet, and mirrors this file's own SNAP_UNRELIABLE assumption (this is the unreliable-channel
|
|
43
|
-
// snapshot path, not a reliable stream where fragmentation is free). Never applied to players[] (always
|
|
44
|
-
// sent in full -- they're the highest-priority, typically-small payload) or the static entities[] head
|
|
45
|
-
// (already deduped/rare-changing, and dropping map geometry updates would desync collision-relevant
|
|
46
|
-
// state); only trims the DYNAMIC tail, farthest-from-viewer first, reusing the same distance-priority
|
|
47
|
-
// signal getPlayerPriorityIds already computes.
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
player.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
player.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const yaw =
|
|
80
|
-
|
|
81
|
-
st.
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
if (
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
}
|
|
781
|
-
} catch (_) {}
|
|
782
|
-
if (avgRtt > SNAP_RTT_HIGH) targetHz = Math.max(SNAP_RATE_MIN_HZ, Math.round(targetHz * 0.5))
|
|
783
|
-
else if (avgRtt > SNAP_RTT_LOW) targetHz = Math.round(targetHz * 0.75)
|
|
784
|
-
if (avgRtt < SNAP_RTT_LOW && targetHz < SNAP_RATE_MAX_HZ) targetHz = Math.min(SNAP_RATE_MAX_HZ, targetHz + 2)
|
|
785
|
-
// Real-cost throttle: a dense/clustered crowd measured expensive to snapshot (regardless of what the
|
|
786
|
-
// player-count band alone would pick) pulls the rate down further, same direction+shape as the RTT
|
|
787
|
-
// adjustment above but driven by actual measured compute, not an assumed-uniform per-player cost.
|
|
788
|
-
const tickBudgetMs = 1000 / tickRate
|
|
789
|
-
if (_snapCostEmaMs > tickBudgetMs * SNAP_COST_HIGH_FRAC) targetHz = Math.max(SNAP_RATE_MIN_HZ, Math.round(targetHz * 0.5))
|
|
790
|
-
else if (_snapCostEmaMs > tickBudgetMs * SNAP_COST_LOW_FRAC) targetHz = Math.round(targetHz * 0.75)
|
|
791
|
-
return Math.max(1, Math.round(tickRate / Math.max(SNAP_RATE_IDLE_HZ, Math.min(SNAP_RATE_MAX_HZ, targetHz))))
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// simulateTick: the PURE deterministic-simulation subset of a tick -- movement -> player collisions ->
|
|
795
|
-
// physics.step -> appRuntime.tick -- with ZERO network I/O side effects (no snapshot build, no
|
|
796
|
-
// connections.broadcast/emit, no networkState.setTick/rate-adjust bookkeeping). This is exactly the
|
|
797
|
-
// slice rollback-tickhandler-resimulate-loop's rewind+replay-forward orchestration (RollbackLoop.js)
|
|
798
|
-
// needs to call once per resimulated tick: onTick's snapshot/broadcast half is a real one-time-only
|
|
799
|
-
// wire side effect (it would double-send stale snapshots for every already-broadcast historical tick
|
|
800
|
-
// if replayed) and must never re-run, but the physics/app simulation half is exactly what a correct
|
|
801
|
-
// GGPO-style resimulate pass re-executes with corrected input. Returns nothing; mutates players/physics/
|
|
802
|
-
// appRuntime state in place, identically to what onTick's own inline sequence below does -- onTick
|
|
803
|
-
// calls this function rather than duplicating the sequence, so the two can never drift apart.
|
|
804
|
-
function simulateTick(tick, dt, players) {
|
|
805
|
-
processPlayerMovement(players, mvDeps, tick, dt, playerIdleCounts, playerAccumDt)
|
|
806
|
-
const cellSz = physicsIntegration.config.capsuleRadius * 8, minDist = physicsIntegration.config.capsuleRadius * 2
|
|
807
|
-
applyPlayerCollisions(players, grid, gridCells, cellSz, minDist * minDist, minDist, dt, physicsIntegration)
|
|
808
|
-
// must run before physics.step: drains VegPhysics/RockPhysics streamer-queued collider add/remove into Jolt's broadphase
|
|
809
|
-
if (typeof physics.drainBodyQueue === 'function') physics.drainBodyQueue()
|
|
810
|
-
physics.step(dt)
|
|
811
|
-
appRuntime.tick(tick, dt)
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
function onTick(tick, dt) {
|
|
815
|
-
const t0 = performance.now()
|
|
816
|
-
const serverNow = Date.now()
|
|
817
|
-
networkState.setTick(tick, serverNow)
|
|
818
|
-
const players = playerManager.getConnectedPlayers()
|
|
819
|
-
|
|
820
|
-
if (tick - _snapRateAdjustTick >= SNAP_RATE_ADJUST_INTERVAL) {
|
|
821
|
-
_snapRateAdjustTick = tick
|
|
822
|
-
_snapshotInterval = _computeSnapshotInterval(players, tick)
|
|
823
|
-
if (players.length > 0 && connections) {
|
|
824
|
-
_lastSnapRate = Math.round(tickRate / _snapshotInterval)
|
|
825
|
-
connections.emit('snapshot-rate', { rate: _lastSnapRate, tick, interval: _snapshotInterval })
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
const t1pre = performance.now()
|
|
830
|
-
simulateTick(tick, dt, players)
|
|
831
|
-
const t4 = performance.now()
|
|
832
|
-
// sub-phase split points (mv/col/phys) are no longer individually measurable now that simulateTick is
|
|
833
|
-
// one opaque call shared with the rollback resimulate path (simulateTick must stay a single indivisible
|
|
834
|
-
// unit so the resimulate loop replays EXACTLY what onTick would have run, never a hand-picked subset of
|
|
835
|
-
// its internal phases) -- t1/t2 collapse to t1pre and t3 to t4 so the profiler's mv/col/phys buckets
|
|
836
|
-
// report the combined simulateTick total under `phys` rather than silently reporting a fabricated
|
|
837
|
-
// (always-zero) split; sync/respawn/etc's OWN sub-timers (appRuntime._lastSyncMs etc, logged separately
|
|
838
|
-
// below) still carry the fine-grained post-simulateTick detail.
|
|
839
|
-
const t1 = t1pre, t2 = t1pre, t3 = t4
|
|
840
|
-
if (players.length > 0 && tick % _snapshotInterval === 0) {
|
|
841
|
-
snapshotSeq++
|
|
842
|
-
buildAndSendSnapshots(players, appRuntime, snapDeps, tick, snapshotSeq, snapshotSeq % KEYFRAME_INTERVAL === 0, snapState, serverNow)
|
|
843
|
-
// EMA of the REAL measured snapshot-build wall time, isolated to just this call (not the cleanup
|
|
844
|
-
// loop/auto-save below) -- feeds _computeSnapshotInterval's real-cost throttle so a dense/clustered
|
|
845
|
-
// crowd that's expensive to snapshot self-corrects even when raw connected-player count is low.
|
|
846
|
-
const _snapCostMs = performance.now() - t4
|
|
847
|
-
_snapCostEmaMs = _snapCostEmaMs === 0 ? _snapCostMs : (_snapCostEmaMs * (1 - SNAP_COST_EMA_ALPHA) + _snapCostMs * SNAP_COST_EMA_ALPHA)
|
|
848
|
-
}
|
|
849
|
-
// ~1Hz broadcast of every connected client's server-measured RTT (the same EWMA client.rtt already
|
|
850
|
-
// computed per-HEARTBEAT in ServerHandlers.js, reused here rather than re-measuring). This is the data
|
|
851
|
-
// a P2P/wireweave room's host-migration election (client/HostMigration.js) needs: in a star topology
|
|
852
|
-
// (only the host has an RTC data channel to each joiner) there is no peer-to-peer ping mesh, so every
|
|
853
|
-
// joiner learning the SAME server-observed RTT numbers is the only way they can all independently agree
|
|
854
|
-
// on the same "lowest-ping remaining peer" winner without a vote round-trip. Harmless on the plain WS
|
|
855
|
-
// server path too (clients that never look at PEER_RTT_TABLE simply ignore it) -- kept unconditional
|
|
856
|
-
// rather than gated on a P2P flag so a WS-hosted room could reuse the same election code path later.
|
|
857
|
-
if (players.length > 0 && tick % tickRate === 0) {
|
|
858
|
-
const rttTable = {}, pubkeys = {}
|
|
859
|
-
for (const p of players) {
|
|
860
|
-
const c = connections.getClient(p.id)
|
|
861
|
-
if (!c) continue
|
|
862
|
-
if (c.rtt != null) rttTable[p.id] = c.rtt
|
|
863
|
-
// Only populated for wireweave P2P peers (see ConnectionManager.addClient) -- lets every joiner
|
|
864
|
-
// resolve a server playerId from this table back to the wireweave pubkey it needs to reconnect a
|
|
865
|
-
// data channel to during host migration (client/HostMigration.js). Absent/empty on the plain WS path.
|
|
866
|
-
if (c.peerPubkey) pubkeys[p.id] = c.peerPubkey
|
|
867
|
-
}
|
|
868
|
-
connections.broadcast(MSG.PEER_RTT_TABLE, { rtt: rttTable, pubkeys })
|
|
869
|
-
}
|
|
870
|
-
// Server-authoritative day-cycle clock (server-clock-synced-time-of-day-network-sync): advance every
|
|
871
|
-
// tick (real elapsed dt, matching TimeOfDay.js's own local update() formula) so the fraction stays
|
|
872
|
-
// correct regardless of snapshot/broadcast cadence, but only BROADCAST the coarse correction on
|
|
873
|
-
// serverTimeOfDay's own ~5s real-time cadence (see ServerTimeOfDay.js's shouldBroadcast). Both calls
|
|
874
|
-
// are no-ops when the world never opted in (worldDef.terrain.timeOfDay.serverAuthoritative!==true).
|
|
875
|
-
serverTimeOfDay.tick(dt)
|
|
876
|
-
if (players.length > 0 && serverTimeOfDay.shouldBroadcast()) {
|
|
877
|
-
connections.broadcast(MSG.TIME_OF_DAY_SYNC, serverTimeOfDay.getSyncPayload())
|
|
878
|
-
}
|
|
879
|
-
// Server-authoritative weather state (weather-server-driven-state-and-multiplayer-sync): no per-tick
|
|
880
|
-
// advance (unlike serverTimeOfDay above) -- shouldBroadcast only returns true once per real state
|
|
881
|
-
// CHANGE (first activation, or a future setState() call from an admin/game-mode toggle), so this is a
|
|
882
|
-
// cheap dirty-flag check every tick, not a real broadcast most ticks. No-op when the world never
|
|
883
|
-
// opted in (worldDef.terrain.weather.serverAuthoritative!==true).
|
|
884
|
-
if (players.length > 0 && serverWeather.shouldBroadcast()) {
|
|
885
|
-
connections.broadcast(MSG.WEATHER_SYNC, serverWeather.getSyncPayload())
|
|
886
|
-
}
|
|
887
|
-
if (tick % (tickRate * AUTO_SAVE_INTERVAL) === 0 && tick > 0) {
|
|
888
|
-
try { deps.onAutoSave?.() } catch (_) {}
|
|
889
|
-
}
|
|
890
|
-
for (const id of snapDeps.playerEntityMaps.keys()) { if (!playerManager.getPlayer(id)) { snapDeps.playerEntityMaps.delete(id); playerIdleCounts.delete(id); playerAccumDt.delete(id); _priorityAccumulators.delete(id); playerScratch.delete(id); snapState.playerLastTick.delete(id); snapState.playerCell.delete(id) } }
|
|
891
|
-
const t5 = performance.now()
|
|
892
|
-
try { appRuntime._drainReloadQueue() } catch (e) { console.error('[TickHandler] reload queue error:', e.message) }
|
|
893
|
-
if (players.length > 0) {
|
|
894
|
-
profileSum += t5-t0; profileSumSnap += t5-t4; profileSumPhys += t3-t2; profileSumMv += t1-t0; profileCount++
|
|
895
|
-
// server-scale-prometheus-metrics-endpoint-dashboard: same real per-phase durations the existing
|
|
896
|
-
// profileSum* accumulators/console.log(_PROFILE) already compute, additionally fed into the
|
|
897
|
-
// Prometheus histogram registry so a scrape sees the full distribution, not just a periodic log line.
|
|
898
|
-
recordTickPhase('total', t5-t0); recordTickPhase('mv', t1-t0); recordTickPhase('phys', t3-t2); recordTickPhase('snap', t5-t4)
|
|
899
|
-
}
|
|
900
|
-
// rate-limited overrun warning: silent tick overrun is what causes pacing to fall behind under load with no visibility
|
|
901
|
-
const tickBudgetMs = 1000 / tickRate
|
|
902
|
-
if (t5 - t0 > tickBudgetMs * 2 && serverNow - _lastBudgetWarnMs > 1000) {
|
|
903
|
-
_lastBudgetWarnMs = serverNow
|
|
904
|
-
console.warn(`[TickHandler] tick ${tick} overran budget: ${(t5-t0).toFixed(2)}ms > ${(tickBudgetMs*2).toFixed(2)}ms (budget ${tickBudgetMs.toFixed(2)}ms) players:${players.length}`)
|
|
905
|
-
}
|
|
906
|
-
if (_PROFILE && ++profileLog % KEYFRAME_INTERVAL === 0) {
|
|
907
|
-
const total=t5-t0, mem=typeof process!=='undefined'?process.memoryUsage():{heapUsed:0,rss:0,external:0,arrayBuffers:0}, avg=n => profileCount>0?(n/profileCount).toFixed(2):'0'
|
|
908
|
-
const mb=n=>(n/1048576).toFixed(1)
|
|
909
|
-
const dynIds=appRuntime._dynamicEntityIds?.size||0, activeDyn=appRuntime.getActiveDynamicIds()?.size||0
|
|
910
|
-
const avgTotal=avg(profileSum),avgSnap=avg(profileSumSnap),avgPhys=avg(profileSumPhys),avgMv=avg(profileSumMv)
|
|
911
|
-
profileSum=0; profileSumSnap=0; profileSumPhys=0; profileSumMv=0; profileCount=0
|
|
912
|
-
let idleSkipped = 0; if (players.length > 0) for (const c of playerIdleCounts.values()) if (c >= 2) idleSkipped++
|
|
913
|
-
const physSkipped = players.length > 0 ? playerAccumDt.size : 0
|
|
914
|
-
try { console.log(`[tick-profile] tick:${tick} players:${players.length} idle:${idleSkipped} physSkip:${physSkipped} entities:${appRuntime.entities.size} dynIds:${dynIds} activeDyn:${activeDyn} total:${total.toFixed(2)}ms(avg:${avgTotal}) | mv:${(t1-t0).toFixed(2)}(avg:${avgMv}) col:${(t2-t1).toFixed(2)} phys:${(t3-t2).toFixed(2)}(avg:${avgPhys}) app:${(t4-t3).toFixed(2)} sync:${(appRuntime._lastSyncMs||0).toFixed(2)} respawn:${(appRuntime._lastRespawnMs||0).toFixed(2)} spatial:${(appRuntime._lastSpatialMs||0).toFixed(2)} col2:${(appRuntime._lastCollisionMs||0).toFixed(2)} int:${(appRuntime._lastInteractMs||0).toFixed(2)} snap:${(t5-t4).toFixed(2)}(avg:${avgSnap}) | heap:${mb(mem.heapUsed)}MB rss:${mb(mem.rss)}MB ext:${mb(mem.external)}MB ab:${mb(mem.arrayBuffers)}MB`) } catch (_) {}
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
// Attached (not just closed-over) so a late-joining player's connect handler -- ServerHandlers.js's
|
|
919
|
-
// onClientConnect, which runs OUTSIDE this closure -- can read the CURRENT fraction for a one-time
|
|
920
|
-
// join-time send, mirroring the existing ctx._terrainStreamer attach-after-create convention (see
|
|
921
|
-
// WorkerEntry.js/ServerAPI.js). onTick itself is unused as a namespace by any caller today (setTickHandler
|
|
922
|
-
// only ever calls it as a plain function), so this adds a read surface without touching that contract.
|
|
923
|
-
onTick.serverTimeOfDay = serverTimeOfDay
|
|
924
|
-
// server-scale-prometheus-metrics-endpoint-dashboard: a live read of the SAME profileSum*/profileCount
|
|
925
|
-
// accumulators the existing _PROFILE console.log path already computes unconditionally every tick (see
|
|
926
|
-
// the profileSum block above -- computed regardless of _PROFILE, only the console.log itself is gated).
|
|
927
|
-
// Deliberately does NOT reset the accumulators on read (unlike the console.log path, which resets every
|
|
928
|
-
// KEYFRAME_INTERVAL ticks) -- a Prometheus scrape is pull-based and may poll at an arbitrary cadence
|
|
929
|
-
// uncoordinated with KEYFRAME_INTERVAL, so resetting on read here would make one scraper's read starve
|
|
930
|
-
// a concurrent scraper's window; ServerAPI.js's /metrics route instead reads this on every request and
|
|
931
|
-
// reports the average over however many ticks have accumulated since the last natural profileLog reset.
|
|
932
|
-
onTick.getMetrics = () => ({
|
|
933
|
-
avgTotalMs: profileCount > 0 ? profileSum / profileCount : 0,
|
|
934
|
-
avgMvMs: profileCount > 0 ? profileSumMv / profileCount : 0,
|
|
935
|
-
avgPhysMs: profileCount > 0 ? profileSumPhys / profileCount : 0,
|
|
936
|
-
avgSnapMs: profileCount > 0 ? profileSumSnap / profileCount : 0,
|
|
937
|
-
sampleCount: profileCount,
|
|
938
|
-
})
|
|
939
|
-
// rollback-tickhandler-resimulate-loop: exposes the pure deterministic-simulation subset (see
|
|
940
|
-
// simulateTick's own header comment) so RollbackLoop.js can replay ticks with corrected input without
|
|
941
|
-
// re-triggering this handler's network-broadcast side effects. Same attach-after-create convention as
|
|
942
|
-
// serverTimeOfDay/serverWeather below -- a plain function property on the returned onTick closure.
|
|
943
|
-
onTick.simulateTick = simulateTick
|
|
944
|
-
// rollback-tickhandler-resimulate-loop: playerIdleCounts/playerAccumDt (processPlayerMovement's physics-
|
|
945
|
-
// decimation scheduling state, see the isIdle/accumDt block above) are tick-history-dependent hidden state
|
|
946
|
-
// that is NOT part of PhysicsWorld.snapshotBodies/snapshotCharacters -- a real bug found+fixed while
|
|
947
|
-
// building RollbackLoop.js's live witness: resimulating from a restored physics snapshot WITHOUT also
|
|
948
|
-
// restoring these two maps to their tick-30 values reproduced a real 0.27m/1.6(m/s) divergence even when
|
|
949
|
-
// replaying the IDENTICAL scripted input the original forward run used, because the maps still held their
|
|
950
|
-
// post-tick-40 values from the original run (an idle player who had already accumulated 9 skipped ticks'
|
|
951
|
-
// worth of decimation state by tick 40 does not skip-decimate the same way on a resim starting fresh from
|
|
952
|
-
// tick 30). snapshotSimState/restoreSimState expose exactly these two Maps (cloned, never the live
|
|
953
|
-
// reference) so a rollback caller saves/restores them in lockstep with the physics snapshot every tick.
|
|
954
|
-
onTick.snapshotSimState = () => ({ playerIdleCounts: new Map(playerIdleCounts), playerAccumDt: new Map(playerAccumDt) })
|
|
955
|
-
onTick.restoreSimState = (s) => {
|
|
956
|
-
if (!s) return
|
|
957
|
-
playerIdleCounts.clear(); for (const [k, v] of s.playerIdleCounts) playerIdleCounts.set(k, v)
|
|
958
|
-
playerAccumDt.clear(); for (const [k, v] of s.playerAccumDt) playerAccumDt.set(k, v)
|
|
959
|
-
}
|
|
960
|
-
// Same attach-after-create convention as serverTimeOfDay above, for the identical reason: ServerHandlers.js's
|
|
961
|
-
// onClientConnect (outside this closure) needs to read the CURRENT weather state for a one-time
|
|
962
|
-
// join-time backfill send.
|
|
963
|
-
onTick.serverWeather = serverWeather
|
|
964
|
-
// lockstep-desync-wireweave-transport-and-tickhandler-wiring: the TickHandler-side half of wiring
|
|
965
|
-
// DesyncDetector.js/LockstepChecksum.js into a real lockstep peer's tick loop, reusing simulateTick
|
|
966
|
-
// exactly as RollbackLoop.js's resimulate path already does above (the pure deterministic-simulation
|
|
967
|
-
// subset, zero network-broadcast side effects -- a lockstep peer's own tick loop, unlike this file's
|
|
968
|
-
// own onTick, never calls buildAndSendSnapshots at all: a P2P mesh peer has no "clients to snapshot",
|
|
969
|
-
// every peer IS a full simulation, see LockstepTickSystem.js's header comment for why this driver
|
|
970
|
-
// exists as a wholly separate onTick(tick,dt) consumer from the server-authoritative one above).
|
|
971
|
-
//
|
|
972
|
-
// simulateTickWithChecksum(tick, dt, players): runs simulateTick unchanged, then -- only on ticks
|
|
973
|
-
// detector.isChecksumTick(tick) selects (see DesyncDetector.js's own cadence-owning comment) --
|
|
974
|
-
// computes this peer's own checksumBodies(tick, physics.snapshotBodies()) and reports it through
|
|
975
|
-
// `desyncTransport.reportLocalChecksum`, which both broadcasts it to the mesh AND feeds the local
|
|
976
|
-
// detector, matching submitLocalInput's identical local-write-goes-through-the-same-path discipline
|
|
977
|
-
// in LockstepInputTransport.js. Returns the detector's resolution result ({status:'verified'|'desync',
|
|
978
|
-
// ...}) on a checksum tick that JUST became fully resolved by this peer's OWN report (the common case
|
|
979
|
-
// when this peer is the last of the roster to report), or null on every other tick -- a caller that
|
|
980
|
-
// wants to observe every resolution (including ones resolved by a remote peer's LATER-arriving report)
|
|
981
|
-
// should use detector.onVerified/onDesync instead, exactly as constructed; this return value is a
|
|
982
|
-
// same-tick convenience for a caller that only cares about its own report's synchronous outcome.
|
|
983
|
-
onTick.attachDesyncChecksum = (desyncTransport, checksumFn) => {
|
|
984
|
-
const detector = desyncTransport.detector
|
|
985
|
-
const physics_ = desyncTransport.physics
|
|
986
|
-
const computeChecksum = checksumFn || ((t) => checksumBodies(t, physics_.snapshotBodies()))
|
|
987
|
-
return function simulateTickWithChecksum(tick, dt, players) {
|
|
988
|
-
simulateTick(tick, dt, players)
|
|
989
|
-
if (!detector.isChecksumTick(tick)) return null
|
|
990
|
-
const checksum = computeChecksum(tick)
|
|
991
|
-
return desyncTransport.reportLocalChecksum(tick, checksum)
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
return onTick
|
|
995
|
-
}
|
|
1
|
+
import { MSG } from '../protocol/MessageTypes.js'
|
|
2
|
+
import { SnapshotEncoder, unpackBinRecord, TombstoneLog, updateTombstones, PLAYER_LOD_REDUCED_HZ } from '../netcode/SnapshotEncoder.js'
|
|
3
|
+
import { pack } from '../protocol/msgpack.js'
|
|
4
|
+
import { applyMovement as _applyMovement, DEFAULT_MOVEMENT as _DEFAULT_MOVEMENT } from '../shared/movement.js'
|
|
5
|
+
import { applyPlayerCollisions } from '../netcode/CollisionSystem.js'
|
|
6
|
+
import { worldToCell, packCellKey, neighborCells } from '../terrain/CubeSphereCells.js'
|
|
7
|
+
import { createServerTimeOfDay } from './ServerTimeOfDay.js'
|
|
8
|
+
import { createServerWeather } from './ServerWeather.js'
|
|
9
|
+
import { enforceMovementEnvelope } from '../netcode/InputGuard.js'
|
|
10
|
+
import { checksumBodies } from '../netcode/LockstepChecksum.js'
|
|
11
|
+
import { recordSnapshotBytes, recordTickPhase } from './Metrics.js'
|
|
12
|
+
import { PRIORITY_ENTITY_BUDGET, PRIORITY_DECAY, BANDWIDTH_BUDGET_BYTES_PER_TICK, trimEntitiesToBudget, estimateEntityBytes, computeRingRelevantIds, getPlayerPriorityIds, _spatialCache, _cellPackCache, _ringCache } from './TickHandlerAOI.js'
|
|
13
|
+
export { PRIORITY_ENTITY_BUDGET, PRIORITY_DECAY, BANDWIDTH_BUDGET_BYTES_PER_TICK, trimEntitiesToBudget, estimateEntityBytes, getPlayerPriorityIds } from './TickHandlerAOI.js'
|
|
14
|
+
|
|
15
|
+
const MAX_SENDS_PER_TICK = 25
|
|
16
|
+
const INPUT_BACKLOG_DRAIN = 2
|
|
17
|
+
const PHYSICS_PLAYER_DIVISOR = 3
|
|
18
|
+
const PHYSICS_MAX_ACCUM_DT = 1 / 20
|
|
19
|
+
const SNAP_UNRELIABLE = true
|
|
20
|
+
const SNAP_RATE_MIN_HZ = 8
|
|
21
|
+
const SNAP_RATE_MAX_HZ = 30
|
|
22
|
+
const SNAP_RATE_IDLE_HZ = 4
|
|
23
|
+
const SNAP_RATE_ADJUST_INTERVAL = 64
|
|
24
|
+
const AUTO_SAVE_INTERVAL = 300
|
|
25
|
+
const SNAP_PLAYER_LOW = 4
|
|
26
|
+
const SNAP_PLAYER_HIGH = 16
|
|
27
|
+
const SNAP_RTT_LOW = 50
|
|
28
|
+
const SNAP_RTT_HIGH = 200
|
|
29
|
+
// Fraction of the per-tick time budget (1000/tickRate ms) that measured snapshot-build cost must exceed
|
|
30
|
+
// to count as "expensive" -- mirrors the SNAP_RTT_LOW/HIGH pattern but on the real compute-cost axis.
|
|
31
|
+
const SNAP_COST_LOW_FRAC = 0.15
|
|
32
|
+
const SNAP_COST_HIGH_FRAC = 0.35
|
|
33
|
+
// Below this many nearby players, tiering's sort+classify overhead isn't worth it -- every nearby
|
|
34
|
+
// player already gets FULL state via the plain filterEncodedPlayersWithSelf path, same as before.
|
|
35
|
+
const PLAYER_LOD_FULL_COUNT_THRESHOLD = 30
|
|
36
|
+
// Per-client outgoing-bytes-per-tick cap for the SNAPSHOT payload's entities[] array. This bounds the
|
|
37
|
+
// worst case (a player standing in a dense cluster of relevant dynamic entities, all within
|
|
38
|
+
// PRIORITY_ENTITY_BUDGET's count cap but each carrying a large `custom` payload) instead of only
|
|
39
|
+
// capping entity COUNT -- a count cap alone still lets total bytes balloon per-entity. Real UDP-class
|
|
40
|
+
// unreliable transports fragment/drop above ~1200 safe-MTU bytes per datagram; 900 leaves headroom for
|
|
41
|
+
// msgpack framing + the players[]/removed[]/seq/tick/serverTime wrapper fields already sharing the
|
|
42
|
+
// same packet, and mirrors this file's own SNAP_UNRELIABLE assumption (this is the unreliable-channel
|
|
43
|
+
// snapshot path, not a reliable stream where fragmentation is free). Never applied to players[] (always
|
|
44
|
+
// sent in full -- they're the highest-priority, typically-small payload) or the static entities[] head
|
|
45
|
+
// (already deduped/rare-changing, and dropping map geometry updates would desync collision-relevant
|
|
46
|
+
// state); only trims the DYNAMIC tail, farthest-from-viewer first, reusing the same distance-priority
|
|
47
|
+
// signal getPlayerPriorityIds already computes.
|
|
48
|
+
// Below this entity count, the trim's own sort+repack overhead isn't worth paying -- a handful of
|
|
49
|
+
// entities is already comfortably under budget in the overwhelming majority of real snapshots.
|
|
50
|
+
const BANDWIDTH_TRIM_MIN_ENTITIES = 6
|
|
51
|
+
// Hard cap on trim iterations so a pathological single-entity-over-budget payload (one enormous
|
|
52
|
+
// `custom` blob) can't loop forever -- degrade gracefully to "as few entities as it takes, or give up
|
|
53
|
+
// after this many drops" rather than an unbounded while loop.
|
|
54
|
+
const BANDWIDTH_TRIM_MAX_ITERATIONS = 32
|
|
55
|
+
|
|
56
|
+
let _lastYaw = NaN, _lastSinHalf = 0, _lastCosHalf = 1
|
|
57
|
+
|
|
58
|
+
function processPlayerMovement(players, deps, tick, dt, playerIdleCounts, playerAccumDt) {
|
|
59
|
+
const { playerManager, physicsIntegration, lagCompensator, networkState, applyMovement, movement, eventLog, transformRingWriter } = deps
|
|
60
|
+
for (const player of players) {
|
|
61
|
+
const inputs = playerManager.getInputs(player.id)
|
|
62
|
+
const st = player.state
|
|
63
|
+
// backlog <= INPUT_BACKLOG_DRAIN: apply latest immediately (steady state). Larger backlog: drain one/tick so a burst plays out smoothly instead of collapsing to last-only. Always ack the sequence actually applied.
|
|
64
|
+
if (inputs.length > 0) {
|
|
65
|
+
if (inputs.length <= INPUT_BACKLOG_DRAIN) {
|
|
66
|
+
const last = inputs[inputs.length - 1]
|
|
67
|
+
player.lastInput = last.data
|
|
68
|
+
if (last.sequence != null) player.ackSequence = last.sequence
|
|
69
|
+
playerManager.clearInputs(player.id)
|
|
70
|
+
} else {
|
|
71
|
+
const next = inputs.shift()
|
|
72
|
+
player.lastInput = next.data
|
|
73
|
+
if (next.sequence != null) player.ackSequence = next.sequence
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const inp = player.lastInput || null
|
|
77
|
+
if (inp) {
|
|
78
|
+
const yaw = inp.yaw || 0
|
|
79
|
+
if (yaw !== _lastYaw) { const half = yaw / 2; _lastSinHalf = Math.sin(half); _lastCosHalf = Math.cos(half); _lastYaw = yaw }
|
|
80
|
+
st.rotation[0] = 0; st.rotation[1] = _lastSinHalf; st.rotation[2] = 0; st.rotation[3] = _lastCosHalf
|
|
81
|
+
st.crouch = inp.crouch ? 1 : 0; st.lookPitch = inp.pitch || 0; st.lookYaw = yaw
|
|
82
|
+
// Compact viseme/emote expression code (animation-vrm-spring-bone-lod-expression-wire): a plain
|
|
83
|
+
// u8 (see client/core/ExpressionCodes.js) piggybacked on PLAYER_INPUT, same flow as st.crouch from
|
|
84
|
+
// inp.crouch just above -- stored server-side then rebroadcast via networkState.updatePlayer/
|
|
85
|
+
// SnapshotEncoder.encodePlayer below so every OTHER client can apply it to this player's remote avatar.
|
|
86
|
+
st.expr = inp.expr || 0
|
|
87
|
+
}
|
|
88
|
+
applyMovement(st, inp, movement, dt, playerManager.getMovementOverride?.(player.id) || null)
|
|
89
|
+
if (inp) physicsIntegration.setCrouch(player.id, !!inp.crouch)
|
|
90
|
+
const wishedVx = st.velocity[0], wishedVz = st.velocity[2]
|
|
91
|
+
const hasInput = inp && (inp.forward || inp.backward || inp.left || inp.right || inp.jump)
|
|
92
|
+
const isIdle = !hasInput && st.onGround && wishedVx * wishedVx + wishedVz * wishedVz < 1e-4
|
|
93
|
+
const idleCount = playerIdleCounts.get(player.id) || 0
|
|
94
|
+
if (isIdle && idleCount >= 1) { playerIdleCounts.set(player.id, idleCount + 1); playerAccumDt.delete(player.id) }
|
|
95
|
+
else {
|
|
96
|
+
const accumDt = Math.min(PHYSICS_MAX_ACCUM_DT, (playerAccumDt.get(player.id) || 0) + dt)
|
|
97
|
+
// decimate physics only for idle players; an active/airborne player must step every tick or reconciliation reads as jumpy
|
|
98
|
+
if (hasInput || inp?.jump || !st.onGround || (tick + player.id) % PHYSICS_PLAYER_DIVISOR === 0) {
|
|
99
|
+
physicsIntegration.updatePlayerPhysics(player.id, st, accumDt); st.velocity[0] = wishedVx; st.velocity[2] = wishedVz; playerAccumDt.delete(player.id)
|
|
100
|
+
} else { playerAccumDt.set(player.id, accumDt) }
|
|
101
|
+
playerIdleCounts.set(player.id, isIdle ? idleCount + 1 : 0)
|
|
102
|
+
}
|
|
103
|
+
// Movement envelope check (anticheat-server-envelope-checks, docs/anticheat.md): a second,
|
|
104
|
+
// independent layer beneath applyMovement's own structural speed caps -- see InputGuard.js's
|
|
105
|
+
// enforceMovementEnvelope header comment for why this exists as defense-in-depth rather than
|
|
106
|
+
// the primary mechanism. A legitimate player can never reach this branch; every real occurrence
|
|
107
|
+
// is worth an operator-visible eventLog entry (non-blocking, mirrors the statistical-outlier
|
|
108
|
+
// flags below -- flag, never auto-punish, since a false positive here would be a real player
|
|
109
|
+
// losing speed for no visible reason).
|
|
110
|
+
if (enforceMovementEnvelope(st, movement)) {
|
|
111
|
+
eventLog?.record('anticheat_envelope_clamp', { playerId: player.id, position: [...st.position] }, { actor: player.id, reason: 'movement_envelope' })
|
|
112
|
+
}
|
|
113
|
+
lagCompensator.recordPlayerPosition(player.id, st.position, st.rotation, st.velocity, tick)
|
|
114
|
+
// crouch wire slot is bit-packed (bit0=crouch, bit1=swimming) rather than a new protocol field --
|
|
115
|
+
// every consumer of this value (anim locoState threshold, XR capsule-height check, collider shrink)
|
|
116
|
+
// only ever tests it for truthiness, never `=== 1`, so packing a second flag into unused bits is a
|
|
117
|
+
// zero-wire-shape-change addition. See SnapshotEncoder.js's encode/decode for the packed shape.
|
|
118
|
+
const crouchFlags = (st.crouch ? 1 : 0) | (st.swimming ? 2 : 0)
|
|
119
|
+
// Compact equipped-weapon code (animation-weapon-signal-clientside-wiring, see
|
|
120
|
+
// src/shared/WeaponCodes.js): server-authoritative, set via AppRuntime.setPlayerWeapon (never from
|
|
121
|
+
// client input, unlike st.expr/st.crouch above) -- just read through here into the snapshot wire,
|
|
122
|
+
// same optional-numeric-field discipline as st.expr.
|
|
123
|
+
networkState.updatePlayer(player.id, st.position, st.rotation, st.velocity, st.onGround, st.health, player.ackSequence ?? player.inputSequence, crouchFlags, st.lookPitch||0, st.lookYaw||0, st.expr||0, st.weapon||0)
|
|
124
|
+
// SharedArrayBuffer transform-ring hot path (physics-dedicated-worker-transform-offload):
|
|
125
|
+
// best-effort, non-authoritative -- the networkState.updatePlayer call above (feeding the existing
|
|
126
|
+
// postMessage SNAPSHOT channel) remains the single source of truth for every consumer; this write
|
|
127
|
+
// just ALSO publishes the same transform into shared memory for a consumer able to read it with
|
|
128
|
+
// zero postMessage round-trip. transformRingWriter is undefined/null whenever the ring is
|
|
129
|
+
// unavailable (see TransformRing.js's isRingAvailable) -- always guarded, never assumed present.
|
|
130
|
+
if (transformRingWriter) transformRingWriter.write(player.id, st.position, st.rotation, st.velocity)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
function packSnapshot(seq, encoded) {
|
|
136
|
+
_packPayload.seq = seq; _packPayload.tick = encoded.tick; _packPayload.serverTime = encoded.serverTime
|
|
137
|
+
_packPayload.players = encoded.players; _packPayload.entities = encoded.entities
|
|
138
|
+
_packPayload.removed = encoded.removed; _packPayload.delta = encoded.delta
|
|
139
|
+
// dots: DOT-tier player-LOD crowd aggregate (see filterEncodedPlayersTiered) -- undefined on every
|
|
140
|
+
// non-tiered snapshot (the common case), so msgpackr elides the key entirely, same as `removed`.
|
|
141
|
+
_packPayload.dots = encoded.dots
|
|
142
|
+
_packWrapper.payload = _packPayload
|
|
143
|
+
const buf = pack(_packWrapper)
|
|
144
|
+
// server-scale-prometheus-metrics-endpoint-dashboard: this is the single choke point every outgoing
|
|
145
|
+
// snapshot payload passes through (shared-cell fast path, per-viewer delta path, and the legacy
|
|
146
|
+
// relevanceRadius===0 broadcast path all call packSnapshot) -- the real SnapshotEncoder.js output length
|
|
147
|
+
// the PRD row named, counted here rather than re-measured at each of the 3 call sites.
|
|
148
|
+
recordSnapshotBytes(buf.length)
|
|
149
|
+
return buf
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildAndSendSnapshots(players, appRuntime, deps, tick, snapshotSeq, isKeyframe, state, serverNow) {
|
|
153
|
+
const { connections, stageLoader, getRelevanceRadius, networkState, playerEntityMaps } = deps
|
|
154
|
+
const playerSnap = networkState.getSnapshot()
|
|
155
|
+
const playerCount = players.length
|
|
156
|
+
const snapGroups = Math.max(1, Math.ceil(playerCount / 50))
|
|
157
|
+
const curGroup = tick % snapGroups
|
|
158
|
+
const activeStage = stageLoader ? stageLoader.getActiveStage() : null
|
|
159
|
+
const relevanceRadius = activeStage ? activeStage.spatial.relevanceRadius : (getRelevanceRadius ? getRelevanceRadius() : 0)
|
|
160
|
+
// planetRadius > 0 opts a world into curved-space cube-sphere cell addressing (see the cellKey
|
|
161
|
+
// branch below); absent/0 keeps the flat Euclidean XZ grid, the correct default for a single
|
|
162
|
+
// non-reanchoring tangent-plane world (PlanetFrame.js). Stage.js/StageLoader.js thread this
|
|
163
|
+
// through from worldDef.planetRadius.
|
|
164
|
+
const planetRadius = activeStage ? (activeStage.spatial.planetRadius || 0) : 0
|
|
165
|
+
|
|
166
|
+
if (relevanceRadius > 0) {
|
|
167
|
+
const curStaticVersion = appRuntime._staticVersion
|
|
168
|
+
// sph-fluid-3d-client-render-verification: _staticVersion alone (spawn/destroy/body-type-change
|
|
169
|
+
// only) is blind to a static-bodyType entity mutating its OWN entity.custom every tick (apps/
|
|
170
|
+
// fluid-source, apps/fluid3d-source) -- getStaticCustomVersionSum() is a cheap O(staticCount)
|
|
171
|
+
// integer-sum comparison (not the full O(staticCount) encode below) that catches that case too,
|
|
172
|
+
// live-reproduced+fixed via a real browser-verb witness (see that function's own comment for detail).
|
|
173
|
+
const curStaticCustomSum = appRuntime.getStaticCustomVersionSum ? appRuntime.getStaticCustomVersionSum() : 0
|
|
174
|
+
let activeStaticEntries = null
|
|
175
|
+
if (isKeyframe || curStaticVersion !== state.lastStaticVersion || curStaticCustomSum !== state.lastStaticCustomSum) {
|
|
176
|
+
const staticSnap = appRuntime.getStaticSnapshot()
|
|
177
|
+
const prevStaticMap = isKeyframe ? new Map() : state.staticEntityMap
|
|
178
|
+
const { staticEntries, changedEntries, staticMap, staticChanged } = SnapshotEncoder.encodeStaticEntities(staticSnap.entities, prevStaticMap)
|
|
179
|
+
state.lastStaticEntries = staticEntries
|
|
180
|
+
if (staticChanged || isKeyframe) { state.staticEntityMap = staticMap; state.staticEntityIds = SnapshotEncoder.buildStaticIds(staticMap); activeStaticEntries = isKeyframe ? staticEntries : changedEntries }
|
|
181
|
+
state.lastStaticVersion = curStaticVersion
|
|
182
|
+
state.lastStaticCustomSum = curStaticCustomSum
|
|
183
|
+
}
|
|
184
|
+
// BUGFIX (found live via this task's own removal-propagation witness): state.knownIds must NOT be
|
|
185
|
+
// reset to null on every _staticVersion bump. _staticVersion increments on EVERY entity spawn/
|
|
186
|
+
// destroy/body-type-change (AppRuntime.js), which is exactly the same tick a real removal needs its
|
|
187
|
+
// tombstone recorded on. updateTombstones(..., prevKnownIds) is a no-op whenever prevKnownIds is
|
|
188
|
+
// null (nothing to diff against yet), so nulling it here silently swallowed the tombstone for
|
|
189
|
+
// whatever entity just disappeared on THIS tick, every single time -- a removal was only ever
|
|
190
|
+
// caught if it happened to coincide with an UNRELATED already-in-flight known-id set from a prior
|
|
191
|
+
// tick. dynCache/prevDynCache still gets a full, correct rebuild here (buildDynamicCache, unrelated
|
|
192
|
+
// to this bug) -- only the known-id diff baseline must survive across a version bump so this tick's
|
|
193
|
+
// real removal is compared against last tick's real known set. Reset ONLY on isKeyframe: a keyframe
|
|
194
|
+
// tick ships every client a full snapshot (not a delta) and also clears playerLastTick, so any
|
|
195
|
+
// client reading the tombstone log after a keyframe starts from clientLastTick=0 and replays full
|
|
196
|
+
// history anyway -- losing one tick's worth of already-covered-by-the-keyframe diff there is safe.
|
|
197
|
+
if (isKeyframe || curStaticVersion !== state.lastDynVersion) { state.prevDynCache = null; state.lastDynVersion = curStaticVersion }
|
|
198
|
+
if (isKeyframe) { state.knownIds = null; state.playerLastTick.clear() }
|
|
199
|
+
const allEncodedPlayers = SnapshotEncoder.encodePlayersOnce(playerSnap.players)
|
|
200
|
+
// Player-LOD tiering (see SnapshotEncoder.js classifyPlayerTiers/filterEncodedPlayersTiered):
|
|
201
|
+
// built once per tick, shared across every viewer below -- a Map<id,player> lookup, not a
|
|
202
|
+
// per-viewer rebuild. reducedTickMod derives the ~PLAYER_LOD_REDUCED_HZ on-wire rate for
|
|
203
|
+
// REDUCED-tier players from this tick's actual (adaptive) snapshot cadence -- a player at the
|
|
204
|
+
// current send rate of e.g. 20Hz gets reducedTickMod=4 so REDUCED updates land at ~5Hz.
|
|
205
|
+
const playersById = _playersByIdScratch; playersById.clear()
|
|
206
|
+
for (const p of playerSnap.players) playersById.set(p.id, p)
|
|
207
|
+
const snapshotHz = deps.getSnapshotHz ? deps.getSnapshotHz() : 20
|
|
208
|
+
const reducedTickMod = Math.max(1, Math.round(snapshotHz / PLAYER_LOD_REDUCED_HZ))
|
|
209
|
+
_spatialCache.clear()
|
|
210
|
+
_cellPackCache.clear()
|
|
211
|
+
_ringCache.clear()
|
|
212
|
+
let dynCache = null
|
|
213
|
+
let unmanagedIds = null
|
|
214
|
+
for (const player of players) {
|
|
215
|
+
if (player.snapGroup % snapGroups !== curGroup) continue
|
|
216
|
+
if (dynCache === null) {
|
|
217
|
+
const activeIds = appRuntime.getActiveDynamicIds()
|
|
218
|
+
unmanagedIds = appRuntime.getUnmanagedDynamicIds()
|
|
219
|
+
if (state.prevDynCache === null) { state.prevDynCache = SnapshotEncoder.buildDynamicCache(activeIds, appRuntime.getSleepingDynamicIds(), appRuntime.getSuspendedEntityIds(), appRuntime.entities, state.prevDynCache, unmanagedIds) }
|
|
220
|
+
else { SnapshotEncoder.refreshDynamicCache(state.prevDynCache, activeIds, appRuntime.entities, appRuntime.getSleepingDynamicIds(), appRuntime.getSuspendedEntityIds(), unmanagedIds) }
|
|
221
|
+
dynCache = state.prevDynCache
|
|
222
|
+
// Once per tick (not per client): diff this tick's known-id set (dynCache + static) against
|
|
223
|
+
// last tick's to append exactly the entities that dropped out to the global tombstone log --
|
|
224
|
+
// see updateTombstones/TombstoneLog in SnapshotEncoder.js. Each client below then diffs only
|
|
225
|
+
// the tombstone slice newer than its own last-built tick, instead of re-scanning its full
|
|
226
|
+
// prevEntityMap every tick.
|
|
227
|
+
state.knownIds = updateTombstones(state.tombstoneLog, tick, dynCache, state.staticEntityIds, state.knownIds)
|
|
228
|
+
}
|
|
229
|
+
const isNewPlayer = !playerEntityMaps.has(player.id)
|
|
230
|
+
const viewerPos = player.state.position
|
|
231
|
+
// CURVED-SPACE CELL ADDRESSING (planetRadius configured on the active stage): the flat Euclidean
|
|
232
|
+
// XZ cellKey below is exactly right for a single non-reanchoring tangent-plane world (the common
|
|
233
|
+
// case -- see PlanetFrame.js), but breaks down once a world's relevanceRadius-sized interest
|
|
234
|
+
// cells actually span cube-sphere face boundaries (a full-planet server, or a world large enough
|
|
235
|
+
// that the tangent-plane's flatness error matters at cell-boundary scale): two players a few
|
|
236
|
+
// meters apart straddling a face seam would hash to wildly different flat cellKeys despite being
|
|
237
|
+
// spatially adjacent, defeating the whole point of interest-cell payload sharing at that seam.
|
|
238
|
+
// worldToCell/packCellKey resolve the player's world position to its real cube-sphere face+cell
|
|
239
|
+
// (cross-face-correct at edges and cube corners -- see CubeSphereCells.js), and cellViewerPos is
|
|
240
|
+
// re-derived from that SAME face-local cell (not a flat XZ average), so the per-cell distance-tier
|
|
241
|
+
// origin two seam-adjacent clients compute is geometrically consistent across the seam too.
|
|
242
|
+
let cellKey, cellViewerPos, cellFace = -1, cellCx = 0, cellCy = 0, cellsPerFace = 0
|
|
243
|
+
if (planetRadius > 0) {
|
|
244
|
+
const c = worldToCell(viewerPos[0], viewerPos[1], viewerPos[2], planetRadius, relevanceRadius)
|
|
245
|
+
cellFace = c.face; cellCx = c.cx; cellCy = c.cy
|
|
246
|
+
cellsPerFace = Math.ceil((2 * planetRadius) / relevanceRadius)
|
|
247
|
+
cellKey = packCellKey(cellFace, cellCx, cellCy, cellsPerFace)
|
|
248
|
+
// cellViewerPos: reproject the face-local cell CENTER back out along the same ray direction the
|
|
249
|
+
// player sits on, at the player's own radial distance -- gives a real world-space point near the
|
|
250
|
+
// cell center on the curved surface (not a flat-plane average that would cut through the sphere).
|
|
251
|
+
const ATAN_K = Math.PI / 4.0
|
|
252
|
+
const foX = (cellCx + 0.5) * relevanceRadius - planetRadius
|
|
253
|
+
const foY = (cellCy + 0.5) * relevanceRadius - planetRadius
|
|
254
|
+
const wx = planetRadius * Math.tan((foX / planetRadius) * ATAN_K)
|
|
255
|
+
const wy = planetRadius * Math.tan((foY / planetRadius) * ATAN_K)
|
|
256
|
+
const dist = Math.hypot(viewerPos[0], viewerPos[1], viewerPos[2]) || planetRadius
|
|
257
|
+
cellViewerPos = _cellCenterWorld(cellFace, wx, wy, planetRadius, dist)
|
|
258
|
+
} else {
|
|
259
|
+
const cx = Math.floor(viewerPos[0] / relevanceRadius), cz = Math.floor(viewerPos[2] / relevanceRadius)
|
|
260
|
+
cellKey = (cx * 65536 + cz) | 0
|
|
261
|
+
// cellViewerPos: the cell's own center point (not this player's exact position) -- used ONLY as
|
|
262
|
+
// the distance-tier origin (proper-multi-tier-distance-lod-schedule-for-snapshot-updates), so
|
|
263
|
+
// every client sharing a cell computes an IDENTICAL near/mid/far tier verdict per entity. This is
|
|
264
|
+
// what makes per-viewer-encode-sharing-by-interest-cell sound: two clients in the same cell no
|
|
265
|
+
// longer just share relevantIds/nearbyPlayerIds (pre-existing), they now also derive the same
|
|
266
|
+
// tier decision, so their full entities[]/removed[] OUTPUT is identical whenever they also share
|
|
267
|
+
// a delta baseline tick (see cellEncodeCache below) -- real payload sharing, not just id-set reuse.
|
|
268
|
+
cellViewerPos = [(cx + 0.5) * relevanceRadius, viewerPos[1], (cz + 0.5) * relevanceRadius]
|
|
269
|
+
}
|
|
270
|
+
let cached = _spatialCache.get(cellKey)
|
|
271
|
+
if (!cached) {
|
|
272
|
+
cached = { nearbyPlayerIds: appRuntime.nearbyPlayerIds(viewerPos, relevanceRadius), relevantIds: appRuntime.getRelevantDynamicIds(viewerPos, relevanceRadius), cellViewerPos }
|
|
273
|
+
_spatialCache.set(cellKey, cached)
|
|
274
|
+
}
|
|
275
|
+
// Player-LOD tiering: FULL state for the ~PLAYER_LOD_FULL_COUNT nearest players, position+yaw
|
|
276
|
+
// at ~PLAYER_LOD_REDUCED_HZ for the next ring, and everything further aggregated into `dots`
|
|
277
|
+
// (grid-bucketed counts, no per-player wire cost at all) -- see SnapshotEncoder.js. Falls back
|
|
278
|
+
// to the un-tiered filterEncodedPlayersWithSelf ONLY when nearbyPlayerIds is small enough that
|
|
279
|
+
// tiering can't help (avoids the sort+classify cost for the common few-player case).
|
|
280
|
+
// reducedTickMod gates on snapshotSeq (increments by exactly 1 per buildAndSendSnapshots call),
|
|
281
|
+
// NOT the raw physics `tick` counter -- `tick` advances by _snapshotInterval (often >1) between
|
|
282
|
+
// calls here (buildAndSendSnapshots only runs on tick % _snapshotInterval === 0), so `tick %
|
|
283
|
+
// reducedTickMod` would gate at the wrong cadence (reducedTickMod is derived from snapshot Hz,
|
|
284
|
+
// meaningful only against a counter that increments once per snapshot).
|
|
285
|
+
let preEncodedPlayers, playerDots
|
|
286
|
+
if (cached.nearbyPlayerIds && cached.nearbyPlayerIds.length > PLAYER_LOD_FULL_COUNT_THRESHOLD) {
|
|
287
|
+
const tiered = SnapshotEncoder.filterEncodedPlayersTiered(allEncodedPlayers, playersById, cached.nearbyPlayerIds, player.id, viewerPos, snapshotSeq, reducedTickMod)
|
|
288
|
+
preEncodedPlayers = tiered.players; playerDots = tiered.dots.length ? tiered.dots : undefined
|
|
289
|
+
} else {
|
|
290
|
+
preEncodedPlayers = SnapshotEncoder.filterEncodedPlayersWithSelf(allEncodedPlayers, cached.nearbyPlayerIds, player.id)
|
|
291
|
+
}
|
|
292
|
+
const scratch = deps.getPlayerScratch(player.id)
|
|
293
|
+
const prevPlayerMap = isNewPlayer ? new Map() : playerEntityMaps.get(player.id)
|
|
294
|
+
// Ring-of-cells subscription: union relevantIds/nearbyPlayerIds across the cell + its Moore
|
|
295
|
+
// neighborhood (see computeRingRelevantIds) so an entity just across a neighbor cell's border is
|
|
296
|
+
// never missed for a player standing near the shared edge -- a single-cell query alone only
|
|
297
|
+
// guarantees coverage of relevanceRadius from the CELL CENTER, not from every point inside the
|
|
298
|
+
// cell out to its own edges.
|
|
299
|
+
const ring = computeRingRelevantIds(cellKey, cellFace, cellCx, cellCy, cellsPerFace, planetRadius, relevanceRadius, appRuntime)
|
|
300
|
+
// Cube-sphere cell-grid AOI, shared per-cell encoded payload: when the ring's relevant-id count
|
|
301
|
+
// fits inside the per-tick entity budget, EVERY player homed to this cell (not just a newly
|
|
302
|
+
// joining one) shares ONE encode of entities[]/removed[] this tick, built once against a per-CELL
|
|
303
|
+
// delta baseline (state.cellEntityMaps) rather than each player's own prevEntityMap, and diffed
|
|
304
|
+
// for removals via a per-cell tombstone cursor (state.cellLastTick) instead of each player's own
|
|
305
|
+
// last-tick. This is the real hot-path win: encode cost amortizes over every player sharing a
|
|
306
|
+
// cell, not just id-set/nearbyPlayerIds reuse (which was the pre-existing partial win) and not
|
|
307
|
+
// just brand-new joiners (the prior narrower special case). A cell whose ring exceeds the budget
|
|
308
|
+
// (a dense/crowded region) falls back to the existing per-player priority-decayed path below --
|
|
309
|
+
// sharing a budget-exceeding set would defeat the whole point of the budget (bounding worst-case
|
|
310
|
+
// per-client payload size), so that fallback is a deliberate, honest limit, not an oversight.
|
|
311
|
+
const useSharedCell = ring.relevantIds.size <= PRIORITY_ENTITY_BUDGET
|
|
312
|
+
let encoded, entityMap
|
|
313
|
+
if (useSharedCell) {
|
|
314
|
+
let cellMap = state.cellEntityMaps.get(cellKey)
|
|
315
|
+
if (!cellMap) { cellMap = new Map(); state.cellEntityMaps.set(cellKey, cellMap) }
|
|
316
|
+
let shared = cached.sharedEncode
|
|
317
|
+
if (!shared || shared.tick !== tick) {
|
|
318
|
+
let relevantIds = ring.relevantIds
|
|
319
|
+
if (unmanagedIds && unmanagedIds.length) {
|
|
320
|
+
const relSet = relevantIds === ring.relevantIds ? new Set(relevantIds) : relevantIds
|
|
321
|
+
for (const id of unmanagedIds) relSet.add(id)
|
|
322
|
+
relevantIds = relSet
|
|
323
|
+
}
|
|
324
|
+
const cellLastTick = state.cellLastTick.get(cellKey) || 0
|
|
325
|
+
// Static entries for the ONGOING per-cell delta stream are always the tick's true incremental
|
|
326
|
+
// changed set (activeStaticEntries) -- never state.lastStaticEntries (a full re-send), and
|
|
327
|
+
// never conditioned on which player happens to trigger the rebuild this tick (that would make
|
|
328
|
+
// the shared payload's shape depend on iteration order, breaking the "one encode per cell"
|
|
329
|
+
// invariant this whole path exists for). A freshly-joined player's need for the FULL static
|
|
330
|
+
// set is handled separately below, from state.lastStaticEntries directly.
|
|
331
|
+
const r = SnapshotEncoder.encodeDeltaFromCache(playerSnap.tick, serverNow, dynCache, relevantIds, cellMap, [], activeStaticEntries, state.staticEntityMap, state.staticEntityIds, snapshotSeq, cached.cellViewerPos, null, state.tombstoneLog, cellLastTick, snapshotHz)
|
|
332
|
+
shared = { tick, entities: r.encoded.entities, removed: r.encoded.removed, entityMap: r.entityMap }
|
|
333
|
+
cached.sharedEncode = shared
|
|
334
|
+
state.cellEntityMaps.set(cellKey, r.entityMap)
|
|
335
|
+
state.cellLastTick.set(cellKey, tick)
|
|
336
|
+
}
|
|
337
|
+
// A player who was NOT already tracking this cell's baseline (just joined, or just crossed into
|
|
338
|
+
// this cell from another) cannot safely receive a DELTA against the cell's ongoing baseline --
|
|
339
|
+
// they never saw the earlier ticks that baseline's deltas assume as their starting state. Give
|
|
340
|
+
// such a player the cell's FULL current entity set instead (cached.sharedFull, refreshed
|
|
341
|
+
// alongside the shared delta every time it's rebuilt, itself also shared across every player
|
|
342
|
+
// freshly joining the SAME cell this same tick) exactly once, then they ride the shared delta
|
|
343
|
+
// stream from the next tick onward -- a real keyframe/delta-reset per (player,cell) transition,
|
|
344
|
+
// the same correctness contract encodeDeltaFromCache's own prevEntityMap gives per-player today.
|
|
345
|
+
const isFreshToCell = state.playerCell.get(player.id) !== cellKey
|
|
346
|
+
entityMap = new Map(shared.entityMap)
|
|
347
|
+
if (isFreshToCell) {
|
|
348
|
+
let full = cached.sharedFull
|
|
349
|
+
if (!full || full.tick !== tick) {
|
|
350
|
+
const dynEntities = Array.from(shared.entityMap.values()).map(v => v[3]).filter(Boolean)
|
|
351
|
+
const staticEnts = state.lastStaticEntries || []
|
|
352
|
+
full = { tick, entities: staticEnts.map(se => se.enc).concat(dynEntities) }
|
|
353
|
+
cached.sharedFull = full
|
|
354
|
+
}
|
|
355
|
+
encoded = { tick: playerSnap.tick || 0, serverTime: serverNow, players: preEncodedPlayers || [], entities: full.entities, removed: undefined, delta: 1 }
|
|
356
|
+
} else {
|
|
357
|
+
encoded = { tick: playerSnap.tick || 0, serverTime: serverNow, players: preEncodedPlayers || [], entities: shared.entities, removed: shared.removed, delta: 1 }
|
|
358
|
+
}
|
|
359
|
+
state.playerCell.set(player.id, cellKey)
|
|
360
|
+
} else {
|
|
361
|
+
let relevantIds = getPlayerPriorityIds(player.id, ring.relevantIds, dynCache, viewerPos, tick)
|
|
362
|
+
// Unmanaged (physics-body-less) dynamic entities are ALWAYS forced relevant, independent of the
|
|
363
|
+
// spatial octree's distance verdict -- Stage.syncPositions() keeps the octree in sync every tick
|
|
364
|
+
// now, but this is a deliberate belt-and-suspenders guard: such an entity's octree entry could
|
|
365
|
+
// still read stale for one tick around a relevance-radius boundary crossing (index update
|
|
366
|
+
// happens before the relevance query in the same tick, but a future ordering change or a
|
|
367
|
+
// skipped sync tick would silently reintroduce the freeze this bug was about). Cheap -- there
|
|
368
|
+
// are typically very few physics-body-less dynamic entities in a world.
|
|
369
|
+
if (unmanagedIds && unmanagedIds.length) {
|
|
370
|
+
const relSet = relevantIds instanceof Set ? relevantIds : new Set(relevantIds)
|
|
371
|
+
for (const id of unmanagedIds) relSet.add(id)
|
|
372
|
+
relevantIds = relSet
|
|
373
|
+
}
|
|
374
|
+
const clientLastTick = isNewPlayer ? 0 : (state.playerLastTick.get(player.id) || 0)
|
|
375
|
+
const staticEntriesForCall = isNewPlayer ? state.lastStaticEntries : activeStaticEntries
|
|
376
|
+
const r = SnapshotEncoder.encodeDeltaFromCache(playerSnap.tick, serverNow, dynCache, relevantIds, prevPlayerMap, preEncodedPlayers, staticEntriesForCall, state.staticEntityMap, state.staticEntityIds, snapshotSeq, viewerPos, scratch, state.tombstoneLog, clientLastTick, snapshotHz)
|
|
377
|
+
encoded = r.encoded; entityMap = r.entityMap
|
|
378
|
+
scratch.spareMap = prevPlayerMap
|
|
379
|
+
state.playerCell.delete(player.id)
|
|
380
|
+
// Per-client outgoing-bytes-per-tick budget: this is the one path with both a real per-viewer
|
|
381
|
+
// entities[] array (not shared across players like the useSharedCell branch above, whose payload
|
|
382
|
+
// must stay byte-identical for every viewer of the cell) and a known viewerPos to prioritize by
|
|
383
|
+
// distance -- see trimEntitiesToBudget. Static entries always sit at the front of encoded.entities
|
|
384
|
+
// (encodeDeltaFromCache pushes them before any dynamic entry) and are never trimmed.
|
|
385
|
+
const staticCountForTrim = staticEntriesForCall ? staticEntriesForCall.length : 0
|
|
386
|
+
if (encoded.entities.length - staticCountForTrim >= BANDWIDTH_TRIM_MIN_ENTITIES) {
|
|
387
|
+
const trim = trimEntitiesToBudget(encoded.entities, staticCountForTrim, viewerPos)
|
|
388
|
+
if (trim.trimmedCount > 0) encoded.entities = trim.entities
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// playerDots: DOT-tier crowd aggregate for this viewer (see filterEncodedPlayersTiered above).
|
|
392
|
+
// Attached post-hoc rather than threaded through encodeDeltaFromCache's already-long positional
|
|
393
|
+
// signature -- it is purely a function of (nearbyPlayerIds, viewerPos), independent of the
|
|
394
|
+
// entity-delta machinery encodeDeltaFromCache owns. Bypasses the shared _cellPackCache below:
|
|
395
|
+
// that cache assumes byte-identical packed output across every player sharing a cellKey this
|
|
396
|
+
// tick, which playerDots (per-viewer, derived from each player's own distance to every nearby
|
|
397
|
+
// player) breaks -- caching a dots-bearing pack under one cellKey would leak one viewer's dot
|
|
398
|
+
// aggregate onto every other player sharing that cell's empty-entities fast path.
|
|
399
|
+
if (playerDots) encoded.dots = playerDots
|
|
400
|
+
state.playerLastTick.set(player.id, tick)
|
|
401
|
+
playerEntityMaps.set(player.id, entityMap)
|
|
402
|
+
if (encoded.entities.length === 0 && !encoded.removed && !playerDots) {
|
|
403
|
+
let cellPack = _cellPackCache.get(cellKey)
|
|
404
|
+
if (!cellPack) {
|
|
405
|
+
cellPack = packSnapshot(snapshotSeq, encoded)
|
|
406
|
+
_cellPackCache.set(cellKey, cellPack)
|
|
407
|
+
}
|
|
408
|
+
connections.sendPacked(player.id, cellPack, SNAP_UNRELIABLE, MSG.SNAPSHOT)
|
|
409
|
+
} else {
|
|
410
|
+
const packedData = packSnapshot(snapshotSeq, encoded)
|
|
411
|
+
connections.sendPacked(player.id, packedData, SNAP_UNRELIABLE, MSG.SNAPSHOT)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Prune the tombstone log to the oldest tick any currently-connected client OR any live per-cell
|
|
415
|
+
// baseline might still need -- bounds its memory to "removals since the slowest reader's last
|
|
416
|
+
// snapshot" rather than growing forever. Cheap: runs once per tick, only when dynCache actually ran
|
|
417
|
+
// this tick (dynCache !== null guards groups where no player in this tick's snapGroup triggered a
|
|
418
|
+
// dynCache (re)build). Per-cell baselines are also pruned here: a cell nobody sits in anymore (no
|
|
419
|
+
// player's playerCell entry references it) is dropped so cellEntityMaps/cellLastTick don't grow
|
|
420
|
+
// unbounded as players roam across a large or planet-scale world.
|
|
421
|
+
if (dynCache !== null && (state.playerLastTick.size > 0 || state.cellLastTick.size > 0)) {
|
|
422
|
+
let minTick = tick
|
|
423
|
+
for (const t of state.playerLastTick.values()) { if (t < minTick) minTick = t }
|
|
424
|
+
for (const t of state.cellLastTick.values()) { if (t < minTick) minTick = t }
|
|
425
|
+
state.tombstoneLog.pruneBefore(minTick)
|
|
426
|
+
if (state.cellLastTick.size > 0) {
|
|
427
|
+
const liveCells = new Set(state.playerCell.values())
|
|
428
|
+
for (const key of state.cellLastTick.keys()) {
|
|
429
|
+
if (!liveCells.has(key)) { state.cellLastTick.delete(key); state.cellEntityMaps.delete(key) }
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
} else {
|
|
434
|
+
// No per-viewer relevanceRadius/AOI configured for this world -- every connected player is sent the
|
|
435
|
+
// SAME encoded payload (one shared pack, `data` below), by design, with no per-viewer viewerPos to
|
|
436
|
+
// prioritize a distance-based trim against. trimEntitiesToBudget is deliberately NOT applied on this
|
|
437
|
+
// path for the same reason it's skipped on the useSharedCell per-cell path above: a byte-budget trim
|
|
438
|
+
// is only meaningful (and safe -- never silently desyncing one viewer's state from another's) when it
|
|
439
|
+
// can be computed per-viewer; this broadcast path's entire point is that every viewer gets an
|
|
440
|
+
// identical payload. A relevanceRadius-configured world is the one this budgeter targets.
|
|
441
|
+
const entitySnap = appRuntime.getSnapshot()
|
|
442
|
+
const combined = { tick: playerSnap.tick, players: playerSnap.players, entities: entitySnap.entities, serverTime: serverNow }
|
|
443
|
+
const prevMap = (isKeyframe || state.broadcastEntityMap.size === 0) ? new Map() : state.broadcastEntityMap
|
|
444
|
+
const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, prevMap)
|
|
445
|
+
state.broadcastEntityMap = entityMap
|
|
446
|
+
const data = packSnapshot(snapshotSeq, encoded)
|
|
447
|
+
for (const player of players) {
|
|
448
|
+
if (!isKeyframe && player.snapGroup % snapGroups !== curGroup) continue
|
|
449
|
+
connections.sendPacked(player.id, data, SNAP_UNRELIABLE, MSG.SNAPSHOT)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function createTickHandler(deps) {
|
|
455
|
+
// 60Hz default (was 128) -- mirrors src/sdk/server.js's config.tickRate||60; every real caller passes
|
|
456
|
+
// tickRate explicitly, this is only a defensive fallback.
|
|
457
|
+
const { networkState, playerManager, physicsIntegration, lagCompensator, physics, appRuntime, connections, movement: m = {}, stageLoader, getRelevanceRadius, _movement, tickRate = 60, getWorldTimeOfDayConfig, getWorldWeatherConfig } = deps
|
|
458
|
+
// Server-authoritative day-cycle clock (server-clock-synced-time-of-day-network-sync). Passed the LIVE
|
|
459
|
+
// getWorldTimeOfDayConfig ACCESSOR (not a pre-resolved value) -- ServerTimeOfDay.js re-reads it lazily on
|
|
460
|
+
// every tick, since ctx.currentWorldDef is NOT yet populated at TickHandler-construction time (see
|
|
461
|
+
// ServerTimeOfDay.js's header comment for the real bug this fixes: a construction-time-only read always
|
|
462
|
+
// saw worldDef===undefined and permanently disabled itself, live-witnessed with a real 2-client WS
|
|
463
|
+
// harness against tps-game before this fix). Absent getWorldTimeOfDayConfig, or a config with
|
|
464
|
+
// serverAuthoritative!==true, both leave this fully inert -- a caller that never passes it (or a world
|
|
465
|
+
// without terrain.timeOfDay) sees zero behavior change.
|
|
466
|
+
const serverTimeOfDay = createServerTimeOfDay(getWorldTimeOfDayConfig)
|
|
467
|
+
// Server-authoritative weather state (weather-server-driven-state-and-multiplayer-sync). Same lazy-
|
|
468
|
+
// accessor discipline as serverTimeOfDay immediately above (getWorldWeatherConfig re-read on every
|
|
469
|
+
// isEnabled()/getSyncPayload() call, not resolved once at construction) for the identical reason: this
|
|
470
|
+
// module is constructed before ctx.currentWorldDef is populated. Unlike serverTimeOfDay, ServerWeather
|
|
471
|
+
// has no per-tick advance step -- it is a discrete state broadcast on CHANGE (see shouldBroadcast's
|
|
472
|
+
// dirty flag), not a continuously-advancing clock re-broadcast on a fixed cadence.
|
|
473
|
+
const serverWeather = createServerWeather(getWorldWeatherConfig)
|
|
474
|
+
const KEYFRAME_INTERVAL = tickRate * 10
|
|
475
|
+
let _snapshotInterval = 1
|
|
476
|
+
let _snapRateAdjustTick = 0
|
|
477
|
+
let _lastSnapRate = tickRate
|
|
478
|
+
// opt-in: process.memoryUsage() + template string per keyframe log is real cost, gated off by default; SPOINT_TICK_PROFILE=1 or deps.enableProfiling enables
|
|
479
|
+
const _PROFILE = deps.enableProfiling || (typeof process !== 'undefined' && process.env?.SPOINT_TICK_PROFILE === '1')
|
|
480
|
+
const applyMovement = _movement?.applyMovement || _applyMovement
|
|
481
|
+
const DEFAULT_MOVEMENT = _movement?.DEFAULT_MOVEMENT || _DEFAULT_MOVEMENT
|
|
482
|
+
const movement = { ...DEFAULT_MOVEMENT, ...m }
|
|
483
|
+
const mvDeps = { playerManager, physicsIntegration, lagCompensator, networkState, applyMovement, movement, eventLog: deps.eventLog, transformRingWriter: deps.transformRingWriter || null }
|
|
484
|
+
// playerScratch: per-player pooled { entities:[], removed:[], spareMap:Map } reused every tick instead
|
|
485
|
+
// of allocating fresh entities/removed arrays and a fresh nextMap per player per tick (128Hz x N
|
|
486
|
+
// clients -- the dominant GC-pressure source this pools away). spareMap is the OTHER half of a
|
|
487
|
+
// double-buffer with playerEntityMaps.get(id): each tick, encodeDeltaFromCache writes into spareMap
|
|
488
|
+
// while reading the current playerEntityMaps entry as prevEntityMap, then the two are swapped -- so a
|
|
489
|
+
// map is never cleared/reused while it is still this call's prevEntityMap (that would erase the very
|
|
490
|
+
// data the delta is being computed against), and it only becomes the write target again once it has
|
|
491
|
+
// aged out one full tick as the (now-stale, already-consumed) prevEntityMap.
|
|
492
|
+
const playerScratch = new Map()
|
|
493
|
+
function getPlayerScratch(id) {
|
|
494
|
+
let s = playerScratch.get(id)
|
|
495
|
+
if (!s) { s = { entities: [], removed: [], spareMap: new Map() }; playerScratch.set(id, s) }
|
|
496
|
+
return s
|
|
497
|
+
}
|
|
498
|
+
// getSnapshotHz: a live accessor (not a captured value) so player-LOD REDUCED-tier throttling
|
|
499
|
+
// (see buildAndSendSnapshots) always derives its ~5Hz on-wire cadence from the CURRENT adaptive
|
|
500
|
+
// snapshot rate (_lastSnapRate, updated by _computeSnapshotInterval below as player count/RTT/cost
|
|
501
|
+
// change), not a stale boot-time tickRate.
|
|
502
|
+
const snapDeps = { connections, stageLoader, getRelevanceRadius, networkState, playerEntityMaps: new Map(), playerScratch, getPlayerScratch, getSnapshotHz: () => _lastSnapRate }
|
|
503
|
+
// cellEntityMaps/cellLastTick: the per-CELL delta baseline + tombstone cursor that makes shared
|
|
504
|
+
// per-cell encoding real (see the useSharedCell branch in buildAndSendSnapshots) -- one Map/tick
|
|
505
|
+
// number per unique AOI cell any player currently occupies, NOT per player. playerCell tracks which
|
|
506
|
+
// cell each player's own last-received snapshot was baselined against, so a player who just joined a
|
|
507
|
+
// cell (or crossed into it from another) is detected and given a one-time full resync instead of an
|
|
508
|
+
// unsafe delta against baseline ticks they never saw.
|
|
509
|
+
const snapState = { broadcastEntityMap: new Map(), staticEntityMap: new Map(), staticEntityIds: null, lastStaticEntries: null, lastStaticVersion: -1, lastStaticCustomSum: -1, lastDynVersion: -1, prevDynCache: null, tombstoneLog: new TombstoneLog(), knownIds: null, playerLastTick: new Map(), cellEntityMaps: new Map(), cellLastTick: new Map(), playerCell: new Map() }
|
|
510
|
+
const playerIdleCounts = new Map(), playerAccumDt = new Map()
|
|
511
|
+
const grid = new Map(), gridCells = new Map()
|
|
512
|
+
let snapshotSeq = 0, profileLog = 0, profileSum = 0, profileSumSnap = 0, profileSumPhys = 0, profileSumMv = 0, profileCount = 0
|
|
513
|
+
let _lastBudgetWarnMs = 0
|
|
514
|
+
|
|
515
|
+
let _lastBandHz = tickRate
|
|
516
|
+
let _rateChangeTick = 0
|
|
517
|
+
// Real measured per-tick snapshot-build cost (EMA), fed from buildAndSendSnapshots' own wall time on
|
|
518
|
+
// every tick a snapshot actually sends -- see _snapCostEmaMs update in onTick below. Player COUNT alone
|
|
519
|
+
// is a proxy for "how expensive is this tick's snapshot work" that silently diverges from the real
|
|
520
|
+
// driver: buildAndSendSnapshots' cost scales with relevance-filtered nearby-player/entity PAIRS within
|
|
521
|
+
// each viewer's radius, not raw connected-player count -- a small dense crowd (everyone clustered,
|
|
522
|
+
// mutually relevant) can cost far more per tick than a larger but spread-out population where most
|
|
523
|
+
// players fall outside each other's relevanceRadius and get filtered out cheaply. SNAP_COST_HIGH_FRAC/
|
|
524
|
+
// SNAP_COST_LOW_FRAC mirror the existing avgRtt high/low thresholds' shape (a real-measurement throttle
|
|
525
|
+
// layered on top of the player-count band, not a replacement -- the band still provides a safe
|
|
526
|
+
// cold-start default before any snapshot has been measured).
|
|
527
|
+
let _snapCostEmaMs = 0
|
|
528
|
+
const SNAP_COST_EMA_ALPHA = 0.2
|
|
529
|
+
|
|
530
|
+
function _computeSnapshotInterval(players, tick) {
|
|
531
|
+
const pc = players.length
|
|
532
|
+
let bandHz = tickRate
|
|
533
|
+
if (pc === 0) {
|
|
534
|
+
bandHz = SNAP_RATE_IDLE_HZ
|
|
535
|
+
} else if (pc <= SNAP_PLAYER_LOW) {
|
|
536
|
+
bandHz = SNAP_RATE_MAX_HZ
|
|
537
|
+
} else if (pc >= SNAP_PLAYER_HIGH) {
|
|
538
|
+
bandHz = SNAP_RATE_MIN_HZ
|
|
539
|
+
} else {
|
|
540
|
+
const t = (pc - SNAP_PLAYER_LOW) / (SNAP_PLAYER_HIGH - SNAP_PLAYER_LOW)
|
|
541
|
+
bandHz = Math.round(SNAP_RATE_MAX_HZ - t * (SNAP_RATE_MAX_HZ - SNAP_RATE_MIN_HZ))
|
|
542
|
+
}
|
|
543
|
+
const rateDiff = bandHz - _lastBandHz
|
|
544
|
+
const tickSinceChange = tick - _rateChangeTick
|
|
545
|
+
// Hysteresis applies ONLY to the player-count BAND (damps flapping as players join/leave near a band
|
|
546
|
+
// edge) -- it must never gate whether RTT/real-cost gets RE-EVALUATED, or a population that settles
|
|
547
|
+
// into a stable band (the common case) permanently freezes the RTT/cost throttles at whatever they
|
|
548
|
+
// read the one time the band last changed. (Found live: a population stable at pc=3 for its whole
|
|
549
|
+
// session never re-read avgRtt after the initial band settle, even after RTT spiked to 300ms well
|
|
550
|
+
// past SNAP_RTT_HIGH=200 -- the rate stayed pinned at the pre-spike value forever.) So bandHz is
|
|
551
|
+
// damped here, but avgRtt/_snapCostEmaMs are read and applied fresh on EVERY call.
|
|
552
|
+
const targetHzBase = (Math.abs(rateDiff) <= 2 || tickSinceChange < tickRate * 2) ? _lastBandHz : bandHz
|
|
553
|
+
if (targetHzBase !== _lastBandHz) { _lastBandHz = targetHzBase; _rateChangeTick = tick }
|
|
554
|
+
let targetHz = targetHzBase
|
|
555
|
+
let avgRtt = 0
|
|
556
|
+
try {
|
|
557
|
+
const conns = connections?.clients
|
|
558
|
+
if (conns && conns.size > 0) {
|
|
559
|
+
let rttSum = 0, rttCount = 0
|
|
560
|
+
for (const client of conns.values()) {
|
|
561
|
+
if (client.rtt != null) { rttSum += client.rtt; rttCount++ }
|
|
562
|
+
}
|
|
563
|
+
if (rttCount > 0) avgRtt = rttSum / rttCount
|
|
564
|
+
}
|
|
565
|
+
} catch (_) {}
|
|
566
|
+
if (avgRtt > SNAP_RTT_HIGH) targetHz = Math.max(SNAP_RATE_MIN_HZ, Math.round(targetHz * 0.5))
|
|
567
|
+
else if (avgRtt > SNAP_RTT_LOW) targetHz = Math.round(targetHz * 0.75)
|
|
568
|
+
if (avgRtt < SNAP_RTT_LOW && targetHz < SNAP_RATE_MAX_HZ) targetHz = Math.min(SNAP_RATE_MAX_HZ, targetHz + 2)
|
|
569
|
+
// Real-cost throttle: a dense/clustered crowd measured expensive to snapshot (regardless of what the
|
|
570
|
+
// player-count band alone would pick) pulls the rate down further, same direction+shape as the RTT
|
|
571
|
+
// adjustment above but driven by actual measured compute, not an assumed-uniform per-player cost.
|
|
572
|
+
const tickBudgetMs = 1000 / tickRate
|
|
573
|
+
if (_snapCostEmaMs > tickBudgetMs * SNAP_COST_HIGH_FRAC) targetHz = Math.max(SNAP_RATE_MIN_HZ, Math.round(targetHz * 0.5))
|
|
574
|
+
else if (_snapCostEmaMs > tickBudgetMs * SNAP_COST_LOW_FRAC) targetHz = Math.round(targetHz * 0.75)
|
|
575
|
+
return Math.max(1, Math.round(tickRate / Math.max(SNAP_RATE_IDLE_HZ, Math.min(SNAP_RATE_MAX_HZ, targetHz))))
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// simulateTick: the PURE deterministic-simulation subset of a tick -- movement -> player collisions ->
|
|
579
|
+
// physics.step -> appRuntime.tick -- with ZERO network I/O side effects (no snapshot build, no
|
|
580
|
+
// connections.broadcast/emit, no networkState.setTick/rate-adjust bookkeeping). This is exactly the
|
|
581
|
+
// slice rollback-tickhandler-resimulate-loop's rewind+replay-forward orchestration (RollbackLoop.js)
|
|
582
|
+
// needs to call once per resimulated tick: onTick's snapshot/broadcast half is a real one-time-only
|
|
583
|
+
// wire side effect (it would double-send stale snapshots for every already-broadcast historical tick
|
|
584
|
+
// if replayed) and must never re-run, but the physics/app simulation half is exactly what a correct
|
|
585
|
+
// GGPO-style resimulate pass re-executes with corrected input. Returns nothing; mutates players/physics/
|
|
586
|
+
// appRuntime state in place, identically to what onTick's own inline sequence below does -- onTick
|
|
587
|
+
// calls this function rather than duplicating the sequence, so the two can never drift apart.
|
|
588
|
+
function simulateTick(tick, dt, players) {
|
|
589
|
+
processPlayerMovement(players, mvDeps, tick, dt, playerIdleCounts, playerAccumDt)
|
|
590
|
+
const cellSz = physicsIntegration.config.capsuleRadius * 8, minDist = physicsIntegration.config.capsuleRadius * 2
|
|
591
|
+
applyPlayerCollisions(players, grid, gridCells, cellSz, minDist * minDist, minDist, dt, physicsIntegration)
|
|
592
|
+
// must run before physics.step: drains VegPhysics/RockPhysics streamer-queued collider add/remove into Jolt's broadphase
|
|
593
|
+
if (typeof physics.drainBodyQueue === 'function') physics.drainBodyQueue()
|
|
594
|
+
physics.step(dt)
|
|
595
|
+
appRuntime.tick(tick, dt)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function onTick(tick, dt) {
|
|
599
|
+
const t0 = performance.now()
|
|
600
|
+
const serverNow = Date.now()
|
|
601
|
+
networkState.setTick(tick, serverNow)
|
|
602
|
+
const players = playerManager.getConnectedPlayers()
|
|
603
|
+
|
|
604
|
+
if (tick - _snapRateAdjustTick >= SNAP_RATE_ADJUST_INTERVAL) {
|
|
605
|
+
_snapRateAdjustTick = tick
|
|
606
|
+
_snapshotInterval = _computeSnapshotInterval(players, tick)
|
|
607
|
+
if (players.length > 0 && connections) {
|
|
608
|
+
_lastSnapRate = Math.round(tickRate / _snapshotInterval)
|
|
609
|
+
connections.emit('snapshot-rate', { rate: _lastSnapRate, tick, interval: _snapshotInterval })
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const t1pre = performance.now()
|
|
614
|
+
simulateTick(tick, dt, players)
|
|
615
|
+
const t4 = performance.now()
|
|
616
|
+
// sub-phase split points (mv/col/phys) are no longer individually measurable now that simulateTick is
|
|
617
|
+
// one opaque call shared with the rollback resimulate path (simulateTick must stay a single indivisible
|
|
618
|
+
// unit so the resimulate loop replays EXACTLY what onTick would have run, never a hand-picked subset of
|
|
619
|
+
// its internal phases) -- t1/t2 collapse to t1pre and t3 to t4 so the profiler's mv/col/phys buckets
|
|
620
|
+
// report the combined simulateTick total under `phys` rather than silently reporting a fabricated
|
|
621
|
+
// (always-zero) split; sync/respawn/etc's OWN sub-timers (appRuntime._lastSyncMs etc, logged separately
|
|
622
|
+
// below) still carry the fine-grained post-simulateTick detail.
|
|
623
|
+
const t1 = t1pre, t2 = t1pre, t3 = t4
|
|
624
|
+
if (players.length > 0 && tick % _snapshotInterval === 0) {
|
|
625
|
+
snapshotSeq++
|
|
626
|
+
buildAndSendSnapshots(players, appRuntime, snapDeps, tick, snapshotSeq, snapshotSeq % KEYFRAME_INTERVAL === 0, snapState, serverNow)
|
|
627
|
+
// EMA of the REAL measured snapshot-build wall time, isolated to just this call (not the cleanup
|
|
628
|
+
// loop/auto-save below) -- feeds _computeSnapshotInterval's real-cost throttle so a dense/clustered
|
|
629
|
+
// crowd that's expensive to snapshot self-corrects even when raw connected-player count is low.
|
|
630
|
+
const _snapCostMs = performance.now() - t4
|
|
631
|
+
_snapCostEmaMs = _snapCostEmaMs === 0 ? _snapCostMs : (_snapCostEmaMs * (1 - SNAP_COST_EMA_ALPHA) + _snapCostMs * SNAP_COST_EMA_ALPHA)
|
|
632
|
+
}
|
|
633
|
+
// ~1Hz broadcast of every connected client's server-measured RTT (the same EWMA client.rtt already
|
|
634
|
+
// computed per-HEARTBEAT in ServerHandlers.js, reused here rather than re-measuring). This is the data
|
|
635
|
+
// a P2P/wireweave room's host-migration election (client/HostMigration.js) needs: in a star topology
|
|
636
|
+
// (only the host has an RTC data channel to each joiner) there is no peer-to-peer ping mesh, so every
|
|
637
|
+
// joiner learning the SAME server-observed RTT numbers is the only way they can all independently agree
|
|
638
|
+
// on the same "lowest-ping remaining peer" winner without a vote round-trip. Harmless on the plain WS
|
|
639
|
+
// server path too (clients that never look at PEER_RTT_TABLE simply ignore it) -- kept unconditional
|
|
640
|
+
// rather than gated on a P2P flag so a WS-hosted room could reuse the same election code path later.
|
|
641
|
+
if (players.length > 0 && tick % tickRate === 0) {
|
|
642
|
+
const rttTable = {}, pubkeys = {}
|
|
643
|
+
for (const p of players) {
|
|
644
|
+
const c = connections.getClient(p.id)
|
|
645
|
+
if (!c) continue
|
|
646
|
+
if (c.rtt != null) rttTable[p.id] = c.rtt
|
|
647
|
+
// Only populated for wireweave P2P peers (see ConnectionManager.addClient) -- lets every joiner
|
|
648
|
+
// resolve a server playerId from this table back to the wireweave pubkey it needs to reconnect a
|
|
649
|
+
// data channel to during host migration (client/HostMigration.js). Absent/empty on the plain WS path.
|
|
650
|
+
if (c.peerPubkey) pubkeys[p.id] = c.peerPubkey
|
|
651
|
+
}
|
|
652
|
+
connections.broadcast(MSG.PEER_RTT_TABLE, { rtt: rttTable, pubkeys })
|
|
653
|
+
}
|
|
654
|
+
// Server-authoritative day-cycle clock (server-clock-synced-time-of-day-network-sync): advance every
|
|
655
|
+
// tick (real elapsed dt, matching TimeOfDay.js's own local update() formula) so the fraction stays
|
|
656
|
+
// correct regardless of snapshot/broadcast cadence, but only BROADCAST the coarse correction on
|
|
657
|
+
// serverTimeOfDay's own ~5s real-time cadence (see ServerTimeOfDay.js's shouldBroadcast). Both calls
|
|
658
|
+
// are no-ops when the world never opted in (worldDef.terrain.timeOfDay.serverAuthoritative!==true).
|
|
659
|
+
serverTimeOfDay.tick(dt)
|
|
660
|
+
if (players.length > 0 && serverTimeOfDay.shouldBroadcast()) {
|
|
661
|
+
connections.broadcast(MSG.TIME_OF_DAY_SYNC, serverTimeOfDay.getSyncPayload())
|
|
662
|
+
}
|
|
663
|
+
// Server-authoritative weather state (weather-server-driven-state-and-multiplayer-sync): no per-tick
|
|
664
|
+
// advance (unlike serverTimeOfDay above) -- shouldBroadcast only returns true once per real state
|
|
665
|
+
// CHANGE (first activation, or a future setState() call from an admin/game-mode toggle), so this is a
|
|
666
|
+
// cheap dirty-flag check every tick, not a real broadcast most ticks. No-op when the world never
|
|
667
|
+
// opted in (worldDef.terrain.weather.serverAuthoritative!==true).
|
|
668
|
+
if (players.length > 0 && serverWeather.shouldBroadcast()) {
|
|
669
|
+
connections.broadcast(MSG.WEATHER_SYNC, serverWeather.getSyncPayload())
|
|
670
|
+
}
|
|
671
|
+
if (tick % (tickRate * AUTO_SAVE_INTERVAL) === 0 && tick > 0) {
|
|
672
|
+
try { deps.onAutoSave?.() } catch (_) {}
|
|
673
|
+
}
|
|
674
|
+
for (const id of snapDeps.playerEntityMaps.keys()) { if (!playerManager.getPlayer(id)) { snapDeps.playerEntityMaps.delete(id); playerIdleCounts.delete(id); playerAccumDt.delete(id); _priorityAccumulators.delete(id); playerScratch.delete(id); snapState.playerLastTick.delete(id); snapState.playerCell.delete(id) } }
|
|
675
|
+
const t5 = performance.now()
|
|
676
|
+
try { appRuntime._drainReloadQueue() } catch (e) { console.error('[TickHandler] reload queue error:', e.message) }
|
|
677
|
+
if (players.length > 0) {
|
|
678
|
+
profileSum += t5-t0; profileSumSnap += t5-t4; profileSumPhys += t3-t2; profileSumMv += t1-t0; profileCount++
|
|
679
|
+
// server-scale-prometheus-metrics-endpoint-dashboard: same real per-phase durations the existing
|
|
680
|
+
// profileSum* accumulators/console.log(_PROFILE) already compute, additionally fed into the
|
|
681
|
+
// Prometheus histogram registry so a scrape sees the full distribution, not just a periodic log line.
|
|
682
|
+
recordTickPhase('total', t5-t0); recordTickPhase('mv', t1-t0); recordTickPhase('phys', t3-t2); recordTickPhase('snap', t5-t4)
|
|
683
|
+
}
|
|
684
|
+
// rate-limited overrun warning: silent tick overrun is what causes pacing to fall behind under load with no visibility
|
|
685
|
+
const tickBudgetMs = 1000 / tickRate
|
|
686
|
+
if (t5 - t0 > tickBudgetMs * 2 && serverNow - _lastBudgetWarnMs > 1000) {
|
|
687
|
+
_lastBudgetWarnMs = serverNow
|
|
688
|
+
console.warn(`[TickHandler] tick ${tick} overran budget: ${(t5-t0).toFixed(2)}ms > ${(tickBudgetMs*2).toFixed(2)}ms (budget ${tickBudgetMs.toFixed(2)}ms) players:${players.length}`)
|
|
689
|
+
}
|
|
690
|
+
if (_PROFILE && ++profileLog % KEYFRAME_INTERVAL === 0) {
|
|
691
|
+
const total=t5-t0, mem=typeof process!=='undefined'?process.memoryUsage():{heapUsed:0,rss:0,external:0,arrayBuffers:0}, avg=n => profileCount>0?(n/profileCount).toFixed(2):'0'
|
|
692
|
+
const mb=n=>(n/1048576).toFixed(1)
|
|
693
|
+
const dynIds=appRuntime._dynamicEntityIds?.size||0, activeDyn=appRuntime.getActiveDynamicIds()?.size||0
|
|
694
|
+
const avgTotal=avg(profileSum),avgSnap=avg(profileSumSnap),avgPhys=avg(profileSumPhys),avgMv=avg(profileSumMv)
|
|
695
|
+
profileSum=0; profileSumSnap=0; profileSumPhys=0; profileSumMv=0; profileCount=0
|
|
696
|
+
let idleSkipped = 0; if (players.length > 0) for (const c of playerIdleCounts.values()) if (c >= 2) idleSkipped++
|
|
697
|
+
const physSkipped = players.length > 0 ? playerAccumDt.size : 0
|
|
698
|
+
try { console.log(`[tick-profile] tick:${tick} players:${players.length} idle:${idleSkipped} physSkip:${physSkipped} entities:${appRuntime.entities.size} dynIds:${dynIds} activeDyn:${activeDyn} total:${total.toFixed(2)}ms(avg:${avgTotal}) | mv:${(t1-t0).toFixed(2)}(avg:${avgMv}) col:${(t2-t1).toFixed(2)} phys:${(t3-t2).toFixed(2)}(avg:${avgPhys}) app:${(t4-t3).toFixed(2)} sync:${(appRuntime._lastSyncMs||0).toFixed(2)} respawn:${(appRuntime._lastRespawnMs||0).toFixed(2)} spatial:${(appRuntime._lastSpatialMs||0).toFixed(2)} col2:${(appRuntime._lastCollisionMs||0).toFixed(2)} int:${(appRuntime._lastInteractMs||0).toFixed(2)} snap:${(t5-t4).toFixed(2)}(avg:${avgSnap}) | heap:${mb(mem.heapUsed)}MB rss:${mb(mem.rss)}MB ext:${mb(mem.external)}MB ab:${mb(mem.arrayBuffers)}MB`) } catch (_) {}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Attached (not just closed-over) so a late-joining player's connect handler -- ServerHandlers.js's
|
|
703
|
+
// onClientConnect, which runs OUTSIDE this closure -- can read the CURRENT fraction for a one-time
|
|
704
|
+
// join-time send, mirroring the existing ctx._terrainStreamer attach-after-create convention (see
|
|
705
|
+
// WorkerEntry.js/ServerAPI.js). onTick itself is unused as a namespace by any caller today (setTickHandler
|
|
706
|
+
// only ever calls it as a plain function), so this adds a read surface without touching that contract.
|
|
707
|
+
onTick.serverTimeOfDay = serverTimeOfDay
|
|
708
|
+
// server-scale-prometheus-metrics-endpoint-dashboard: a live read of the SAME profileSum*/profileCount
|
|
709
|
+
// accumulators the existing _PROFILE console.log path already computes unconditionally every tick (see
|
|
710
|
+
// the profileSum block above -- computed regardless of _PROFILE, only the console.log itself is gated).
|
|
711
|
+
// Deliberately does NOT reset the accumulators on read (unlike the console.log path, which resets every
|
|
712
|
+
// KEYFRAME_INTERVAL ticks) -- a Prometheus scrape is pull-based and may poll at an arbitrary cadence
|
|
713
|
+
// uncoordinated with KEYFRAME_INTERVAL, so resetting on read here would make one scraper's read starve
|
|
714
|
+
// a concurrent scraper's window; ServerAPI.js's /metrics route instead reads this on every request and
|
|
715
|
+
// reports the average over however many ticks have accumulated since the last natural profileLog reset.
|
|
716
|
+
onTick.getMetrics = () => ({
|
|
717
|
+
avgTotalMs: profileCount > 0 ? profileSum / profileCount : 0,
|
|
718
|
+
avgMvMs: profileCount > 0 ? profileSumMv / profileCount : 0,
|
|
719
|
+
avgPhysMs: profileCount > 0 ? profileSumPhys / profileCount : 0,
|
|
720
|
+
avgSnapMs: profileCount > 0 ? profileSumSnap / profileCount : 0,
|
|
721
|
+
sampleCount: profileCount,
|
|
722
|
+
})
|
|
723
|
+
// rollback-tickhandler-resimulate-loop: exposes the pure deterministic-simulation subset (see
|
|
724
|
+
// simulateTick's own header comment) so RollbackLoop.js can replay ticks with corrected input without
|
|
725
|
+
// re-triggering this handler's network-broadcast side effects. Same attach-after-create convention as
|
|
726
|
+
// serverTimeOfDay/serverWeather below -- a plain function property on the returned onTick closure.
|
|
727
|
+
onTick.simulateTick = simulateTick
|
|
728
|
+
// rollback-tickhandler-resimulate-loop: playerIdleCounts/playerAccumDt (processPlayerMovement's physics-
|
|
729
|
+
// decimation scheduling state, see the isIdle/accumDt block above) are tick-history-dependent hidden state
|
|
730
|
+
// that is NOT part of PhysicsWorld.snapshotBodies/snapshotCharacters -- a real bug found+fixed while
|
|
731
|
+
// building RollbackLoop.js's live witness: resimulating from a restored physics snapshot WITHOUT also
|
|
732
|
+
// restoring these two maps to their tick-30 values reproduced a real 0.27m/1.6(m/s) divergence even when
|
|
733
|
+
// replaying the IDENTICAL scripted input the original forward run used, because the maps still held their
|
|
734
|
+
// post-tick-40 values from the original run (an idle player who had already accumulated 9 skipped ticks'
|
|
735
|
+
// worth of decimation state by tick 40 does not skip-decimate the same way on a resim starting fresh from
|
|
736
|
+
// tick 30). snapshotSimState/restoreSimState expose exactly these two Maps (cloned, never the live
|
|
737
|
+
// reference) so a rollback caller saves/restores them in lockstep with the physics snapshot every tick.
|
|
738
|
+
onTick.snapshotSimState = () => ({ playerIdleCounts: new Map(playerIdleCounts), playerAccumDt: new Map(playerAccumDt) })
|
|
739
|
+
onTick.restoreSimState = (s) => {
|
|
740
|
+
if (!s) return
|
|
741
|
+
playerIdleCounts.clear(); for (const [k, v] of s.playerIdleCounts) playerIdleCounts.set(k, v)
|
|
742
|
+
playerAccumDt.clear(); for (const [k, v] of s.playerAccumDt) playerAccumDt.set(k, v)
|
|
743
|
+
}
|
|
744
|
+
// Same attach-after-create convention as serverTimeOfDay above, for the identical reason: ServerHandlers.js's
|
|
745
|
+
// onClientConnect (outside this closure) needs to read the CURRENT weather state for a one-time
|
|
746
|
+
// join-time backfill send.
|
|
747
|
+
onTick.serverWeather = serverWeather
|
|
748
|
+
// lockstep-desync-wireweave-transport-and-tickhandler-wiring: the TickHandler-side half of wiring
|
|
749
|
+
// DesyncDetector.js/LockstepChecksum.js into a real lockstep peer's tick loop, reusing simulateTick
|
|
750
|
+
// exactly as RollbackLoop.js's resimulate path already does above (the pure deterministic-simulation
|
|
751
|
+
// subset, zero network-broadcast side effects -- a lockstep peer's own tick loop, unlike this file's
|
|
752
|
+
// own onTick, never calls buildAndSendSnapshots at all: a P2P mesh peer has no "clients to snapshot",
|
|
753
|
+
// every peer IS a full simulation, see LockstepTickSystem.js's header comment for why this driver
|
|
754
|
+
// exists as a wholly separate onTick(tick,dt) consumer from the server-authoritative one above).
|
|
755
|
+
//
|
|
756
|
+
// simulateTickWithChecksum(tick, dt, players): runs simulateTick unchanged, then -- only on ticks
|
|
757
|
+
// detector.isChecksumTick(tick) selects (see DesyncDetector.js's own cadence-owning comment) --
|
|
758
|
+
// computes this peer's own checksumBodies(tick, physics.snapshotBodies()) and reports it through
|
|
759
|
+
// `desyncTransport.reportLocalChecksum`, which both broadcasts it to the mesh AND feeds the local
|
|
760
|
+
// detector, matching submitLocalInput's identical local-write-goes-through-the-same-path discipline
|
|
761
|
+
// in LockstepInputTransport.js. Returns the detector's resolution result ({status:'verified'|'desync',
|
|
762
|
+
// ...}) on a checksum tick that JUST became fully resolved by this peer's OWN report (the common case
|
|
763
|
+
// when this peer is the last of the roster to report), or null on every other tick -- a caller that
|
|
764
|
+
// wants to observe every resolution (including ones resolved by a remote peer's LATER-arriving report)
|
|
765
|
+
// should use detector.onVerified/onDesync instead, exactly as constructed; this return value is a
|
|
766
|
+
// same-tick convenience for a caller that only cares about its own report's synchronous outcome.
|
|
767
|
+
onTick.attachDesyncChecksum = (desyncTransport, checksumFn) => {
|
|
768
|
+
const detector = desyncTransport.detector
|
|
769
|
+
const physics_ = desyncTransport.physics
|
|
770
|
+
const computeChecksum = checksumFn || ((t) => checksumBodies(t, physics_.snapshotBodies()))
|
|
771
|
+
return function simulateTickWithChecksum(tick, dt, players) {
|
|
772
|
+
simulateTick(tick, dt, players)
|
|
773
|
+
if (!detector.isChecksumTick(tick)) return null
|
|
774
|
+
const checksum = computeChecksum(tick)
|
|
775
|
+
return desyncTransport.reportLocalChecksum(tick, checksum)
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return onTick
|
|
779
|
+
}
|