@series-inc/rundot-syncplay 5.26.0-beta.8 → 5.26.0
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/README.md +29 -3
- package/dist/authority-room.d.ts +10 -0
- package/dist/authority-room.js +11 -0
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +1 -1
- package/dist/dev-authority-room.js +8 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/input-authority.d.ts +8 -0
- package/dist/input-authority.js +13 -0
- package/dist/networked-client.d.ts +15 -5
- package/dist/networked-client.js +38 -3
- package/dist/node.d.ts +4 -0
- package/dist/node.js +2 -0
- package/dist/rooms-config-keys.d.ts +11 -0
- package/dist/rooms-config-keys.js +17 -0
- package/dist/runtime-room-metadata.d.ts +47 -0
- package/dist/runtime-room-metadata.js +84 -0
- package/dist/session-snapshot.d.ts +24 -0
- package/dist/session-snapshot.js +24 -0
- package/dist/tools/host-timing.d.ts +8 -0
- package/dist/tools/host-timing.js +36 -0
- package/dist/tools/module-certification.js +10 -18
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -122,9 +122,11 @@ nothing and is a command that executes on import.
|
|
|
122
122
|
`certifyModule(component, fixture)` on `./tools` is the provisional conformance
|
|
123
123
|
gate for `./modules/*`. It delegates straight, rollback, replay, and hydration
|
|
124
124
|
equivalence to `runSyncplaySynctest`, then adds module-only source rules,
|
|
125
|
-
permutation equivalence,
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
permutation equivalence, the median of five warmed p99 batches, and retained
|
|
126
|
+
heap per step. Each batch uses the declared warm-up count and sample count.
|
|
127
|
+
Certification runs the warm-up before each batch. It compares the median with
|
|
128
|
+
the unchanged step-time limit. Run it under Node with `--expose-gc`. Missing GC
|
|
129
|
+
support fails closed instead of reporting a zero allocation.
|
|
128
130
|
|
|
129
131
|
The published JavaScript graph has a committed exhaustive side-effect audit.
|
|
130
132
|
`core/scripts/preflight.mjs` and
|
|
@@ -341,6 +343,30 @@ runner without rewriting those layers:
|
|
|
341
343
|
RTT-adaptive) are forwarded verbatim, and live `netStats` (RTT, timescale, input
|
|
342
344
|
delay) is surfaced on the snapshot + runner (`null` offline) for a netcode HUD.
|
|
343
345
|
|
|
346
|
+
### Simulation, rendering, input, and confirmation
|
|
347
|
+
|
|
348
|
+
One session uses one fixed simulation rate. The rate is part of its runtime and
|
|
349
|
+
replay identity. Rendering follows the host display loop. Rendering does not
|
|
350
|
+
define the simulation rate.
|
|
351
|
+
|
|
352
|
+
A player sends control input for each simulation tick. The input can contain
|
|
353
|
+
movement, aim, and buttons. It does not contain game state or projectile state.
|
|
354
|
+
The deterministic simulation creates projectiles from confirmed fire input. The
|
|
355
|
+
authority orders one canonical input frame for each tick. It can replace late
|
|
356
|
+
input with the defined substitute. Every client receives that confirmed frame.
|
|
357
|
+
A client rolls back only when its prediction differs.
|
|
358
|
+
|
|
359
|
+
Use 30 Hz for a new general-purpose game. Use 20 Hz when simulation cost is high
|
|
360
|
+
or action is low, and 50 ms rule steps are acceptable. Use 60 Hz only when game
|
|
361
|
+
rules need 16.7 ms resolution. Input and confirmation remain per simulation tick
|
|
362
|
+
at all supported rates.
|
|
363
|
+
|
|
364
|
+
When `inputDelay` is omitted, `pumpPaced()` uses an adaptive zero-to-100-ms
|
|
365
|
+
range. It calculates the delay needed to fit RTT plus `targetLeadTicks` inside
|
|
366
|
+
`maxPredictionTicks`. The first RTT sample applies immediately. Later changes
|
|
367
|
+
use three matching samples. Equal explicit `minTicks` and `maxTicks` values
|
|
368
|
+
select a static delay.
|
|
369
|
+
|
|
344
370
|
Direct `NetworkedSyncplayClient` users get the same projection surface:
|
|
345
371
|
`getPresentationFrame()`, `renderAlpha`, `currentFrame`, `caughtUp`, and a
|
|
346
372
|
bounded confirmed-frame queue via the optional `confirmedPresentationBufferSize`
|
package/dist/authority-room.d.ts
CHANGED
|
@@ -179,6 +179,8 @@ export interface AuthorityRoomNetcodeStats {
|
|
|
179
179
|
readonly confirmedThrough: number;
|
|
180
180
|
/** Ticks in which each slot's input had to be substituted (cumulative). */
|
|
181
181
|
readonly substitutedTicksBySlot: Readonly<Record<number, number>>;
|
|
182
|
+
/** Slots with a seated player. Empty slots substitute every tick by design. */
|
|
183
|
+
readonly occupiedSlots: readonly number[];
|
|
182
184
|
readonly clientStats: Readonly<Record<number, AuthorityRoomClientNetStats>>;
|
|
183
185
|
/** Command requests dropped for seat, size, rate, sequence, or stale generation. */
|
|
184
186
|
readonly rejectedCommands: number;
|
|
@@ -239,6 +241,14 @@ export interface DeterministicAuthorityRoom {
|
|
|
239
241
|
* ticks, so this is idempotent.
|
|
240
242
|
*/
|
|
241
243
|
onPlayerReconnected(playerId: string): void;
|
|
244
|
+
/**
|
|
245
|
+
* A player's transport dropped and their seat entered the host's grace
|
|
246
|
+
* window. The slot stays reserved and keeps being substituted, so release
|
|
247
|
+
* the input they were holding — otherwise a player who drops mid-movement
|
|
248
|
+
* keeps moving until the grace window expires. Safe to call for a player
|
|
249
|
+
* with no slot (spectator, unknown id).
|
|
250
|
+
*/
|
|
251
|
+
onPlayerDisconnected(playerId: string): void;
|
|
242
252
|
/** Advance the authority by one server tick and broadcast newly-confirmed frames. */
|
|
243
253
|
onTick(): void;
|
|
244
254
|
/**
|
package/dist/authority-room.js
CHANGED
|
@@ -351,6 +351,11 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
351
351
|
sendCatchUpTo(playerId, plan, 'join');
|
|
352
352
|
return true;
|
|
353
353
|
}
|
|
354
|
+
function onPlayerDisconnected(playerId) {
|
|
355
|
+
const slot = slotByPlayer.get(playerId);
|
|
356
|
+
if (slot !== undefined)
|
|
357
|
+
authority.neutralizeSlot(slot);
|
|
358
|
+
}
|
|
354
359
|
function onPlayerReconnected(playerId) {
|
|
355
360
|
// A spectator reconnect is a full re-greet: its timeline may have forked
|
|
356
361
|
// arbitrarily far and it holds no slot the redundancy window protects.
|
|
@@ -779,6 +784,10 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
779
784
|
// departed player's input; their slot stays reserved because `assignedSlots` never decreases,
|
|
780
785
|
// so the authority substitutes for it rather than handing the number to a new joiner.
|
|
781
786
|
const slot = slotByPlayer.get(playerId);
|
|
787
|
+
// The slot stays reserved and keeps being substituted (see above), so
|
|
788
|
+
// release the departing player's last held input.
|
|
789
|
+
if (slot !== undefined)
|
|
790
|
+
authority.neutralizeSlot(slot);
|
|
782
791
|
slotByPlayer.delete(playerId);
|
|
783
792
|
if (slot !== undefined)
|
|
784
793
|
secretAuthority?.onPlayerLeft(playerId, slot);
|
|
@@ -853,6 +862,7 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
853
862
|
onPlayerSecretMessage,
|
|
854
863
|
onPlayerLeave,
|
|
855
864
|
onPlayerReconnected,
|
|
865
|
+
onPlayerDisconnected,
|
|
856
866
|
onTick,
|
|
857
867
|
snapshot() {
|
|
858
868
|
return {
|
|
@@ -899,6 +909,7 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
899
909
|
return {
|
|
900
910
|
confirmedThrough: authority.confirmedThrough,
|
|
901
911
|
substitutedTicksBySlot: Object.fromEntries(substitutedTicksBySlot),
|
|
912
|
+
occupiedSlots: [...slotByPlayer.values()].sort((a, b) => a - b),
|
|
902
913
|
clientStats: Object.fromEntries(clientNetStatsBySlot),
|
|
903
914
|
rejectedCommands,
|
|
904
915
|
rejectedReconfigures,
|
package/dist/browser.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export { createDeterministicDescriptorBinarySchema, generateSchemaBinarySource,
|
|
|
35
35
|
export { createDeterministicBinarySerializer, deterministicDslMaxPlayers, parseDeterministicSchemaDsl, stableDeterministicDslSchemaJson } from './schema-dsl-binary.js';
|
|
36
36
|
export { createSyncplaySession } from './sdk-session.js';
|
|
37
37
|
export { createSessionFromPromotionSnapshot, exportPromotionSnapshot } from './session-promotion.js';
|
|
38
|
-
export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot } from './session-snapshot.js';
|
|
38
|
+
export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot, MAX_JSON_ESCAPE_EXPANSION, SNAPSHOT_CHUNK_ENVELOPE_OVERHEAD_BYTES } from './session-snapshot.js';
|
|
39
39
|
export { decodeDeterministicSessionMessage, deterministicSessionWireVersion, encodeDeterministicSessionMessage } from './session-wire.js';
|
|
40
40
|
export { createDeterministicSignalBus } from './signals.js';
|
|
41
41
|
export { createDeterministicSparseSet } from './sparse-set.js';
|
package/dist/browser.js
CHANGED
|
@@ -36,7 +36,7 @@ export { createDeterministicDescriptorBinarySchema, generateSchemaBinarySource,
|
|
|
36
36
|
export { createDeterministicBinarySerializer, deterministicDslMaxPlayers, parseDeterministicSchemaDsl, stableDeterministicDslSchemaJson } from './schema-dsl-binary.js';
|
|
37
37
|
export { createSyncplaySession } from './sdk-session.js';
|
|
38
38
|
export { createSessionFromPromotionSnapshot, exportPromotionSnapshot } from './session-promotion.js';
|
|
39
|
-
export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot } from './session-snapshot.js';
|
|
39
|
+
export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot, MAX_JSON_ESCAPE_EXPANSION, SNAPSHOT_CHUNK_ENVELOPE_OVERHEAD_BYTES } from './session-snapshot.js';
|
|
40
40
|
export { decodeDeterministicSessionMessage, deterministicSessionWireVersion, encodeDeterministicSessionMessage } from './session-wire.js';
|
|
41
41
|
export { createDeterministicSignalBus } from './signals.js';
|
|
42
42
|
export { createDeterministicSparseSet } from './sparse-set.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createDeterministicAuthorityRoom, } from './authority-room.js';
|
|
2
|
-
import { SYSTEM_MSG_RECONNECTED } from '@series-inc/rundot-game-sdk/mp-server';
|
|
2
|
+
import { SYSTEM_MSG_DISCONNECTED, SYSTEM_MSG_RECONNECTED } from '@series-inc/rundot-game-sdk/mp-server';
|
|
3
3
|
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
|
|
4
4
|
import { validateSyncplaySecretSystemsConfig } from './secret-config.js';
|
|
5
5
|
import { assertKinetixRuntimeIdentity, kinetixSessionConfigDigest, } from '@series-inc/rundot-syncplay/core/runtime';
|
|
@@ -138,6 +138,13 @@ export class DevSyncplayAuthorityRoom {
|
|
|
138
138
|
this.room.onPlayerReconnected(playerId);
|
|
139
139
|
return;
|
|
140
140
|
}
|
|
141
|
+
// The dev server already emits this on socket loss. Handle it so local
|
|
142
|
+
// play matches production: a player who drops mid-movement stops instead
|
|
143
|
+
// of having their held input repeated through the grace window.
|
|
144
|
+
if (msgType === SYSTEM_MSG_DISCONNECTED) {
|
|
145
|
+
this.room.onPlayerDisconnected(playerId);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
141
148
|
if (msgType === SECRET_MSG_TYPE) {
|
|
142
149
|
const envelope = typeof data === 'string' ? data : JSON.stringify(data);
|
|
143
150
|
this.room.onPlayerSecretMessage(playerId, envelope);
|
package/dist/index.d.ts
CHANGED
|
@@ -44,7 +44,7 @@ export { withRuntimeDeterminismGuards } from './runtime-guards.js';
|
|
|
44
44
|
export { createDeterministicDescriptorBinarySchema, generateSchemaBinarySource, generateSchemaTypes, proveSchemaBinaryRoundTrip } from './schema-codegen.js';
|
|
45
45
|
export { createDeterministicBinarySerializer, deterministicDslMaxPlayers, parseDeterministicSchemaDsl, stableDeterministicDslSchemaJson } from './schema-dsl-binary.js';
|
|
46
46
|
export { createSyncplaySession } from './sdk-session.js';
|
|
47
|
-
export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot } from './session-snapshot.js';
|
|
47
|
+
export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot, MAX_JSON_ESCAPE_EXPANSION, SNAPSHOT_CHUNK_ENVELOPE_OVERHEAD_BYTES } from './session-snapshot.js';
|
|
48
48
|
export { decodeDeterministicSessionMessage, deterministicSessionWireVersion, encodeDeterministicSessionMessage } from './session-wire.js';
|
|
49
49
|
export { createDeterministicSignalBus } from './signals.js';
|
|
50
50
|
export { createDeterministicSparseSet } from './sparse-set.js';
|
package/dist/index.js
CHANGED
|
@@ -45,7 +45,7 @@ export { withRuntimeDeterminismGuards } from './runtime-guards.js';
|
|
|
45
45
|
export { createDeterministicDescriptorBinarySchema, generateSchemaBinarySource, generateSchemaTypes, proveSchemaBinaryRoundTrip } from './schema-codegen.js';
|
|
46
46
|
export { createDeterministicBinarySerializer, deterministicDslMaxPlayers, parseDeterministicSchemaDsl, stableDeterministicDslSchemaJson } from './schema-dsl-binary.js';
|
|
47
47
|
export { createSyncplaySession } from './sdk-session.js';
|
|
48
|
-
export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot } from './session-snapshot.js';
|
|
48
|
+
export { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, DEFAULT_MAX_SNAPSHOT_BYTES, DEFAULT_SNAPSHOT_CHUNK_SIZE, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot, MAX_JSON_ESCAPE_EXPANSION, SNAPSHOT_CHUNK_ENVELOPE_OVERHEAD_BYTES } from './session-snapshot.js';
|
|
49
49
|
export { decodeDeterministicSessionMessage, deterministicSessionWireVersion, encodeDeterministicSessionMessage } from './session-wire.js';
|
|
50
50
|
export { createDeterministicSignalBus } from './signals.js';
|
|
51
51
|
export { createDeterministicSparseSet } from './sparse-set.js';
|
|
@@ -83,6 +83,14 @@ export interface InputAuthority {
|
|
|
83
83
|
* frontier; a no-op at or below the current earliest retained tick.
|
|
84
84
|
*/
|
|
85
85
|
pruneConfirmedBefore(tick: number): void;
|
|
86
|
+
/**
|
|
87
|
+
* Forget a slot's last confirmed input so later substitutions fall back to
|
|
88
|
+
* `neutralInput`. Call this when a slot's player disconnects or leaves: the
|
|
89
|
+
* slot stays reserved and keeps being substituted, so without this the
|
|
90
|
+
* authority repeats whatever they were holding when they dropped for the
|
|
91
|
+
* rest of the match. Out-of-roster slots are ignored.
|
|
92
|
+
*/
|
|
93
|
+
neutralizeSlot(slot: number): void;
|
|
86
94
|
stats(): InputAuthorityStats;
|
|
87
95
|
}
|
|
88
96
|
export declare function createInputAuthority(config: InputAuthorityConfig): InputAuthority;
|
package/dist/input-authority.js
CHANGED
|
@@ -185,6 +185,19 @@ export function createInputAuthority(config) {
|
|
|
185
185
|
}
|
|
186
186
|
earliestRetained = tick;
|
|
187
187
|
},
|
|
188
|
+
neutralizeSlot(slot) {
|
|
189
|
+
if (!Number.isInteger(slot) || slot < 0 || slot >= playerCount)
|
|
190
|
+
return;
|
|
191
|
+
lastConfirmedInput[slot] = config.neutralInput;
|
|
192
|
+
// Resetting the last-confirmed value is not enough on its own: clients
|
|
193
|
+
// queue ahead by `inputDelayTicks`, so a dropping player usually has
|
|
194
|
+
// future ticks already buffered. `confirmTick` reassigns
|
|
195
|
+
// `lastConfirmedInput` from any submitted input, so leaving those in
|
|
196
|
+
// place would resurrect the held input on the very next confirm.
|
|
197
|
+
for (const slots of pending.values()) {
|
|
198
|
+
slots[slot] = undefined;
|
|
199
|
+
}
|
|
200
|
+
},
|
|
188
201
|
stats() {
|
|
189
202
|
return { confirmedTicks, substitutedInputs, droppedLateInputs, rejectedFutureInputs };
|
|
190
203
|
},
|
|
@@ -84,9 +84,11 @@ export interface NetworkedSyncplayClientOptions<State, Input, Checkpoint = unkno
|
|
|
84
84
|
* tick T is bound and sent for tick T+delay, trading a fixed sliver of local
|
|
85
85
|
* latency for inputs that arrive before the authority's deadline (fewer
|
|
86
86
|
* substitutions of THIS client's input, shallower rollbacks for peers).
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
87
|
+
* An omitted value adapts from zero through 100 ms of simulation ticks. It
|
|
88
|
+
* closes the gap between RTT plus the target lead and maxPredictionTicks.
|
|
89
|
+
* With `maxTicks > minTicks`, the supplied range adapts with the same
|
|
90
|
+
* formula. Equal bounds select a static delay. RTT measurement requires
|
|
91
|
+
* driving `pumpPaced`.
|
|
90
92
|
*/
|
|
91
93
|
readonly inputDelay?: NetworkedSyncplayClientInputDelayOptions;
|
|
92
94
|
/** Monotonic-enough clock for RTT measurement. Default Date.now. NEVER used by the sim. */
|
|
@@ -122,9 +124,9 @@ export interface NetworkedSyncplayClientPacingOptions {
|
|
|
122
124
|
readonly maxStepsPerPump?: number;
|
|
123
125
|
}
|
|
124
126
|
export interface NetworkedSyncplayClientInputDelayOptions {
|
|
125
|
-
/** Minimum
|
|
127
|
+
/** Minimum and initial own-input delay in ticks. Default 0. */
|
|
126
128
|
readonly minTicks?: number;
|
|
127
|
-
/** Maximum
|
|
129
|
+
/** Maximum adaptive delay. Equal to minTicks for a static delay. */
|
|
128
130
|
readonly maxTicks?: number;
|
|
129
131
|
}
|
|
130
132
|
/** Live netcode telemetry for the local client (G8). */
|
|
@@ -140,6 +142,14 @@ export interface NetworkedSyncplayClientNetStats {
|
|
|
140
142
|
readonly droppedConfirmedPresentationFrames: number;
|
|
141
143
|
/** Commands this client refused to send (oversized payload). */
|
|
142
144
|
readonly rejectedCommands: number;
|
|
145
|
+
/**
|
|
146
|
+
* Confirmed ticks in which THIS client's own input was substituted because it
|
|
147
|
+
* did not reach the authority in time. Monotonic within a session and reset on
|
|
148
|
+
* session-start. Counted per applied tick, so a batch of confirmations that
|
|
149
|
+
* substitutes some ticks and not the last one still reports every one — a
|
|
150
|
+
* consumer reading `confirmedPlayerPresence` alone sees only the final tick.
|
|
151
|
+
*/
|
|
152
|
+
readonly ownInputSubstitutions: number;
|
|
143
153
|
}
|
|
144
154
|
export type NetworkedSyncplaySlotPresence = 'human' | 'substituted' | 'empty' | 'unknown';
|
|
145
155
|
export interface ConfirmedSyncplayPresentationFrame<Projection> {
|
package/dist/networked-client.js
CHANGED
|
@@ -14,6 +14,7 @@ const DEFAULT_MIN_TIMESCALE = 0.9;
|
|
|
14
14
|
const DEFAULT_MAX_TIMESCALE = 1.1;
|
|
15
15
|
const DEFAULT_CORRECTION_PER_TICK = 0.02;
|
|
16
16
|
const DEFAULT_MAX_STEPS_PER_PUMP = 4;
|
|
17
|
+
const DEFAULT_MAX_INPUT_DELAY_MS = 100;
|
|
17
18
|
/** Frame-advantage error inside this band leaves the timescale at 1 (no hunting on jitter). */
|
|
18
19
|
const TIMESCALE_DEADBAND_TICKS = 1;
|
|
19
20
|
/** RTT smoothing factor (EWMA); ~4 samples to converge. */
|
|
@@ -193,8 +194,11 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
193
194
|
const maxTimescale = options.pacing?.maxTimescale ?? DEFAULT_MAX_TIMESCALE;
|
|
194
195
|
const correctionPerTick = options.pacing?.correctionPerTick ?? DEFAULT_CORRECTION_PER_TICK;
|
|
195
196
|
const maxStepsPerPump = options.pacing?.maxStepsPerPump ?? DEFAULT_MAX_STEPS_PER_PUMP;
|
|
197
|
+
const usesDefaultInputDelayRange = options.inputDelay === undefined;
|
|
196
198
|
const inputDelayMinTicks = options.inputDelay?.minTicks ?? 0;
|
|
197
|
-
const
|
|
199
|
+
const configuredInputDelayMaxTicks = Math.max(options.inputDelay?.maxTicks ?? inputDelayMinTicks, inputDelayMinTicks);
|
|
200
|
+
let inputDelayMaxTicks = configuredInputDelayMaxTicks;
|
|
201
|
+
let firstDefaultDelaySamplePending = usesDefaultInputDelayRange;
|
|
198
202
|
const remoteInputPrediction = options.remoteInputPrediction ?? 'repeat-last';
|
|
199
203
|
if (remoteInputPrediction !== 'repeat-last' && remoteInputPrediction !== 'neutral') {
|
|
200
204
|
throw new Error('SYNCPLAY_REMOTE_INPUT_PREDICTION_INVALID');
|
|
@@ -278,6 +282,7 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
278
282
|
let sessionGeneration = 0;
|
|
279
283
|
let commandSequence = 0;
|
|
280
284
|
let droppedConfirmedPresentationFrames = 0;
|
|
285
|
+
let ownInputSubstitutions = 0;
|
|
281
286
|
let rejectedCommands = 0;
|
|
282
287
|
const ephemeralHandlers = new Set();
|
|
283
288
|
let pendingEphemeral;
|
|
@@ -423,10 +428,21 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
423
428
|
confirmed.clear();
|
|
424
429
|
reportedTicks.length = 0;
|
|
425
430
|
lastAutoReportedTick = -1;
|
|
431
|
+
ownInputSubstitutions = 0;
|
|
426
432
|
transferCollector = createSnapshotTransferCollector(snapshotCollectorOptions);
|
|
427
433
|
outgoingTransferQueue.length = 0;
|
|
428
434
|
tickRateHz = message.tickRateHz;
|
|
429
435
|
frameMs = tickRateHz > 0 ? 1000 / tickRateHz : 0;
|
|
436
|
+
inputDelayMaxTicks = usesDefaultInputDelayRange
|
|
437
|
+
? Math.max(1, Math.ceil(tickRateHz * DEFAULT_MAX_INPUT_DELAY_MS / 1_000))
|
|
438
|
+
: configuredInputDelayMaxTicks;
|
|
439
|
+
inputDelayTicks = inputDelayMinTicks;
|
|
440
|
+
pendingDelayCandidate = inputDelayMinTicks;
|
|
441
|
+
pendingDelayAgreement = 0;
|
|
442
|
+
firstDefaultDelaySamplePending = usesDefaultInputDelayRange;
|
|
443
|
+
rttMs = -1;
|
|
444
|
+
lastPingSentMs = undefined;
|
|
445
|
+
pendingPings.clear();
|
|
430
446
|
ownScheduleCursor = 0;
|
|
431
447
|
ownScheduleCatchUp = true;
|
|
432
448
|
ownScheduleFreshRoom = false;
|
|
@@ -436,7 +452,6 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
436
452
|
// A re-greet resets the sim; pacing estimates must not survive it either
|
|
437
453
|
// (fresh observations arrive with the very next confirmed-input).
|
|
438
454
|
authorityEpochsMs.length = 0;
|
|
439
|
-
pendingPings.clear();
|
|
440
455
|
timescale = 1;
|
|
441
456
|
const runtime = options.runtimeFactory(message.runtimeIdentity, message.sessionConfigBytes.slice());
|
|
442
457
|
if (canonicalStringify(runtime.identity)
|
|
@@ -588,11 +603,25 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
588
603
|
options.transport.send(encodeDeterministicSessionMessage({ kind: 'time-sync-ping', clientSendTick: predictedThrough, nonce: pingNonce }));
|
|
589
604
|
}
|
|
590
605
|
// ── G2: ping-adaptive own-input delay ──
|
|
606
|
+
function desiredInputDelayTicks() {
|
|
607
|
+
if (rttMs < 0 || frameMs <= 0)
|
|
608
|
+
return inputDelayMinTicks;
|
|
609
|
+
const rttTicks = rttMs / frameMs;
|
|
610
|
+
const requiredPredictionSpan = Math.ceil(rttTicks + targetLeadTicks);
|
|
611
|
+
return clamp(requiredPredictionSpan - maxPredictionTicks, inputDelayMinTicks, inputDelayMaxTicks);
|
|
612
|
+
}
|
|
591
613
|
function maybeAdaptInputDelay() {
|
|
592
614
|
if (inputDelayMaxTicks <= inputDelayMinTicks) {
|
|
593
615
|
return;
|
|
594
616
|
}
|
|
595
|
-
const desired =
|
|
617
|
+
const desired = desiredInputDelayTicks();
|
|
618
|
+
if (firstDefaultDelaySamplePending) {
|
|
619
|
+
inputDelayTicks = desired;
|
|
620
|
+
pendingDelayCandidate = desired;
|
|
621
|
+
pendingDelayAgreement = 0;
|
|
622
|
+
firstDefaultDelaySamplePending = false;
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
596
625
|
if (desired === inputDelayTicks) {
|
|
597
626
|
pendingDelayAgreement = 0;
|
|
598
627
|
return;
|
|
@@ -885,6 +914,11 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
885
914
|
for (let playerSlot = 0; playerSlot < playerCount; playerSlot += 1) {
|
|
886
915
|
lastConfirmedBySlot[playerSlot] = confInputs[playerSlot];
|
|
887
916
|
}
|
|
917
|
+
// Per APPLIED tick, not per drained batch: `confirmedPlayerPresence`
|
|
918
|
+
// latches only the last tick of a batch, so a burst that substitutes
|
|
919
|
+
// several ticks and ends on a delivered one would otherwise vanish.
|
|
920
|
+
if (slot >= 0 && confFrame.substitutedSlots.includes(slot))
|
|
921
|
+
ownInputSubstitutions += 1;
|
|
888
922
|
appliedThrough = tick;
|
|
889
923
|
confirmedPlayerPresence = classifyPresence(confFrame);
|
|
890
924
|
const presentationEligible = presentationWasLive || (freshRoomFirstFrame && tick === 0);
|
|
@@ -1205,6 +1239,7 @@ export function createNetworkedSyncplayClient(options) {
|
|
|
1205
1239
|
maxRollbackDepth,
|
|
1206
1240
|
droppedConfirmedPresentationFrames,
|
|
1207
1241
|
rejectedCommands,
|
|
1242
|
+
ownInputSubstitutions,
|
|
1208
1243
|
};
|
|
1209
1244
|
},
|
|
1210
1245
|
get generation() {
|
package/dist/node.d.ts
CHANGED
|
@@ -18,6 +18,10 @@ export * from './secret-authority.js';
|
|
|
18
18
|
export { buildMerkleTree, bytesToHex, concatBytes, constantTimeEqual, createBrowserSyncplaySecretCrypto, hexToBytes, merkleLeafHash, merkleNodeHash, merkleProof, sha256Bytes, signedReceiptBytes, verifyHashChainReveal, verifyMerkleProof, verifySyncplaySignedReceipt, } from './secret-crypto.js';
|
|
19
19
|
export type { SyncplaySecretAuthorityCrypto, SyncplaySecretBrowserCrypto } from './secret-crypto.js';
|
|
20
20
|
export * from './ws-frame.js';
|
|
21
|
+
export { SYNCPLAY_DETERMINISTIC_ROOM_OPTION_KEYS } from './rooms-config-keys.js';
|
|
22
|
+
export { createSyncplayRuntimePin, parseSyncplayRuntimeRoomMetadata, MAX_SYNCPLAY_SESSION_CONFIG_BYTES, } from './runtime-room-metadata.js';
|
|
23
|
+
export type { SyncplayRuntimeIdentity, SyncplayRuntimeRoomMetadata, ParsedSyncplayRuntimeRoomMetadata, SyncplayRuntimeConfigInput, } from './runtime-room-metadata.js';
|
|
24
|
+
export type { SyncplayDeterministicRoomOptionKey } from './rooms-config-keys.js';
|
|
21
25
|
export { assertCookedColliderIsRuntimeValid, cookConvexDecomposition, } from './collider-cooking.js';
|
|
22
26
|
export type { ColliderCookOptions, ColliderSourceMesh, CookedCollider, CookedColliderHull, } from './collider-cooking.js';
|
|
23
27
|
export { DevSyncplayAuthorityRoom } from './dev-authority-room.js';
|
package/dist/node.js
CHANGED
|
@@ -15,6 +15,8 @@ export * from './secret-protocol.js';
|
|
|
15
15
|
export * from './secret-authority.js';
|
|
16
16
|
export { buildMerkleTree, bytesToHex, concatBytes, constantTimeEqual, createBrowserSyncplaySecretCrypto, hexToBytes, merkleLeafHash, merkleNodeHash, merkleProof, sha256Bytes, signedReceiptBytes, verifyHashChainReveal, verifyMerkleProof, verifySyncplaySignedReceipt, } from './secret-crypto.js';
|
|
17
17
|
export * from './ws-frame.js';
|
|
18
|
+
export { SYNCPLAY_DETERMINISTIC_ROOM_OPTION_KEYS } from './rooms-config-keys.js';
|
|
19
|
+
export { createSyncplayRuntimePin, parseSyncplayRuntimeRoomMetadata, MAX_SYNCPLAY_SESSION_CONFIG_BYTES, } from './runtime-room-metadata.js';
|
|
18
20
|
export { assertCookedColliderIsRuntimeValid, cookConvexDecomposition, } from './collider-cooking.js';
|
|
19
21
|
// Dev-server deterministic room class (relocated from the game SDK). A game's
|
|
20
22
|
// vite.config passes this to rundotMultiplayerPlugin({ deterministicRoomClass }).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every option key a deterministic (SyncPlay) room object may carry.
|
|
3
|
+
*
|
|
4
|
+
* This is the ONE list. `server/mp-room-server` enforces it when it loads a
|
|
5
|
+
* bundle, and `server/cloud-run` checks its upload allowlist against it. Before
|
|
6
|
+
* this list existed the two validators were hand-copied and drifted, which left
|
|
7
|
+
* `secrets`, `reconfigurePolicy`, and `commandsPerSecond` honored by the room
|
|
8
|
+
* server but impossible to publish.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SYNCPLAY_DETERMINISTIC_ROOM_OPTION_KEYS: readonly ["protocol", "simulationHooks", "secrets", "runtime", "reconfigurePolicy", "commandsPerSecond"];
|
|
11
|
+
export type SyncplayDeterministicRoomOptionKey = (typeof SYNCPLAY_DETERMINISTIC_ROOM_OPTION_KEYS)[number];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every option key a deterministic (SyncPlay) room object may carry.
|
|
3
|
+
*
|
|
4
|
+
* This is the ONE list. `server/mp-room-server` enforces it when it loads a
|
|
5
|
+
* bundle, and `server/cloud-run` checks its upload allowlist against it. Before
|
|
6
|
+
* this list existed the two validators were hand-copied and drifted, which left
|
|
7
|
+
* `secrets`, `reconfigurePolicy`, and `commandsPerSecond` honored by the room
|
|
8
|
+
* server but impossible to publish.
|
|
9
|
+
*/
|
|
10
|
+
export const SYNCPLAY_DETERMINISTIC_ROOM_OPTION_KEYS = [
|
|
11
|
+
'protocol',
|
|
12
|
+
'simulationHooks',
|
|
13
|
+
'secrets',
|
|
14
|
+
'runtime',
|
|
15
|
+
'reconfigurePolicy',
|
|
16
|
+
'commandsPerSecond',
|
|
17
|
+
];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime a deterministic room simulates: engine identity plus the
|
|
3
|
+
* canonical session config (the game's rules) it was created with.
|
|
4
|
+
*
|
|
5
|
+
* This is the ONE implementation. The build mints a pin with
|
|
6
|
+
* `createSyncplayRuntimePin`, the publish path validates it, and the room
|
|
7
|
+
* server validates it again on room create — all through
|
|
8
|
+
* `parseSyncplayRuntimeRoomMetadata` below. A second copy of these rules is how
|
|
9
|
+
* a pin could pass one gate and fail another.
|
|
10
|
+
*/
|
|
11
|
+
export declare const MAX_SYNCPLAY_SESSION_CONFIG_BYTES: 65536;
|
|
12
|
+
export interface SyncplayRuntimeIdentity {
|
|
13
|
+
readonly abiVersion: 1;
|
|
14
|
+
readonly tickRate: 10 | 20 | 30 | 60;
|
|
15
|
+
readonly inputSchemaId: string;
|
|
16
|
+
readonly stateSchemaId: string;
|
|
17
|
+
readonly deterministicVersion: string;
|
|
18
|
+
readonly engineIdentityHash: string;
|
|
19
|
+
}
|
|
20
|
+
/** Wire shape: `sessionConfigBytes` is canonical base64. */
|
|
21
|
+
export interface SyncplayRuntimeRoomMetadata {
|
|
22
|
+
readonly runtimeIdentity: SyncplayRuntimeIdentity;
|
|
23
|
+
readonly sessionConfigBytes: string;
|
|
24
|
+
readonly sessionConfigDigest: string;
|
|
25
|
+
}
|
|
26
|
+
/** Parsed shape: `sessionConfigBytes` decoded. */
|
|
27
|
+
export interface ParsedSyncplayRuntimeRoomMetadata {
|
|
28
|
+
readonly runtimeIdentity: SyncplayRuntimeIdentity;
|
|
29
|
+
readonly sessionConfigBytes: Uint8Array;
|
|
30
|
+
readonly sessionConfigDigest: string;
|
|
31
|
+
}
|
|
32
|
+
/** A game's runtime config, as its simulation module exports it. */
|
|
33
|
+
export interface SyncplayRuntimeConfigInput {
|
|
34
|
+
readonly runtimeIdentity: SyncplayRuntimeIdentity;
|
|
35
|
+
readonly sessionConfigBytes: Uint8Array;
|
|
36
|
+
readonly sessionConfigDigest: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function parseSyncplayRuntimeRoomMetadata(value: unknown): ParsedSyncplayRuntimeRoomMetadata;
|
|
39
|
+
/**
|
|
40
|
+
* Mint the wire-shaped runtime pin a build stamps into its rooms config.
|
|
41
|
+
*
|
|
42
|
+
* Validates through `parseSyncplayRuntimeRoomMetadata`, so a pin that mints is
|
|
43
|
+
* a pin that publishes and loads. Throws rather than returning a bad pin: the
|
|
44
|
+
* room server treats the pin as authoritative over player-supplied ticket
|
|
45
|
+
* metadata, so a wrong pin is worse than no pin.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createSyncplayRuntimePin(input: SyncplayRuntimeConfigInput): SyncplayRuntimeRoomMetadata;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime a deterministic room simulates: engine identity plus the
|
|
3
|
+
* canonical session config (the game's rules) it was created with.
|
|
4
|
+
*
|
|
5
|
+
* This is the ONE implementation. The build mints a pin with
|
|
6
|
+
* `createSyncplayRuntimePin`, the publish path validates it, and the room
|
|
7
|
+
* server validates it again on room create — all through
|
|
8
|
+
* `parseSyncplayRuntimeRoomMetadata` below. A second copy of these rules is how
|
|
9
|
+
* a pin could pass one gate and fail another.
|
|
10
|
+
*/
|
|
11
|
+
import { kinetixSessionConfigDigest, MAX_KINETIX_SESSION_CONFIG_BYTES, } from '@series-inc/rundot-syncplay/core/runtime';
|
|
12
|
+
export const MAX_SYNCPLAY_SESSION_CONFIG_BYTES = MAX_KINETIX_SESSION_CONFIG_BYTES;
|
|
13
|
+
export function parseSyncplayRuntimeRoomMetadata(value) {
|
|
14
|
+
if (!isRecord(value) || !hasExactKeys(value, ['runtimeIdentity', 'sessionConfigBytes', 'sessionConfigDigest'])) {
|
|
15
|
+
throw new Error('syncplay runtime metadata is missing or malformed');
|
|
16
|
+
}
|
|
17
|
+
const identity = value.runtimeIdentity;
|
|
18
|
+
if (!isRecord(identity)
|
|
19
|
+
|| !hasExactKeys(identity, ['abiVersion', 'tickRate', 'inputSchemaId', 'stateSchemaId', 'deterministicVersion', 'engineIdentityHash'])
|
|
20
|
+
|| identity.abiVersion !== 1
|
|
21
|
+
|| ![10, 20, 30, 60].includes(identity.tickRate)
|
|
22
|
+
|| !isNonEmptyString(identity.inputSchemaId)
|
|
23
|
+
|| !isNonEmptyString(identity.stateSchemaId)
|
|
24
|
+
|| !isNonEmptyString(identity.deterministicVersion)
|
|
25
|
+
|| !isNonEmptyString(identity.engineIdentityHash)) {
|
|
26
|
+
throw new Error('syncplay runtime identity is malformed');
|
|
27
|
+
}
|
|
28
|
+
if (typeof value.sessionConfigBytes !== 'string' || !isCanonicalBase64(value.sessionConfigBytes)) {
|
|
29
|
+
throw new Error('syncplay session config bytes are not canonical base64');
|
|
30
|
+
}
|
|
31
|
+
const bytes = Uint8Array.from(Buffer.from(value.sessionConfigBytes, 'base64'));
|
|
32
|
+
if (bytes.byteLength < 1 || bytes.byteLength > MAX_SYNCPLAY_SESSION_CONFIG_BYTES) {
|
|
33
|
+
throw new Error('syncplay session config bytes are empty or oversized');
|
|
34
|
+
}
|
|
35
|
+
if (typeof value.sessionConfigDigest !== 'string'
|
|
36
|
+
|| value.sessionConfigDigest !== kinetixSessionConfigDigest(bytes)) {
|
|
37
|
+
throw new Error('syncplay session config digest mismatch');
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
runtimeIdentity: identity,
|
|
41
|
+
sessionConfigBytes: bytes,
|
|
42
|
+
sessionConfigDigest: value.sessionConfigDigest,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Mint the wire-shaped runtime pin a build stamps into its rooms config.
|
|
47
|
+
*
|
|
48
|
+
* Validates through `parseSyncplayRuntimeRoomMetadata`, so a pin that mints is
|
|
49
|
+
* a pin that publishes and loads. Throws rather than returning a bad pin: the
|
|
50
|
+
* room server treats the pin as authoritative over player-supplied ticket
|
|
51
|
+
* metadata, so a wrong pin is worse than no pin.
|
|
52
|
+
*/
|
|
53
|
+
export function createSyncplayRuntimePin(input) {
|
|
54
|
+
// Check the caller's own keys before re-shaping: building a fresh object
|
|
55
|
+
// would silently drop a stray top-level key instead of rejecting it.
|
|
56
|
+
if (!isRecord(input)
|
|
57
|
+
|| !hasExactKeys(input, ['runtimeIdentity', 'sessionConfigBytes', 'sessionConfigDigest'])) {
|
|
58
|
+
throw new Error('syncplay runtime config must carry exactly runtimeIdentity, sessionConfigBytes, sessionConfigDigest');
|
|
59
|
+
}
|
|
60
|
+
if (!(input.sessionConfigBytes instanceof Uint8Array)) {
|
|
61
|
+
throw new Error('syncplay runtime config sessionConfigBytes must be a Uint8Array');
|
|
62
|
+
}
|
|
63
|
+
const pin = {
|
|
64
|
+
runtimeIdentity: input.runtimeIdentity,
|
|
65
|
+
sessionConfigBytes: Buffer.from(input.sessionConfigBytes).toString('base64'),
|
|
66
|
+
sessionConfigDigest: input.sessionConfigDigest,
|
|
67
|
+
};
|
|
68
|
+
parseSyncplayRuntimeRoomMetadata(pin);
|
|
69
|
+
return pin;
|
|
70
|
+
}
|
|
71
|
+
function hasExactKeys(value, expected) {
|
|
72
|
+
return Object.keys(value).sort().join('\0') === [...expected].sort().join('\0');
|
|
73
|
+
}
|
|
74
|
+
function isNonEmptyString(value) {
|
|
75
|
+
return typeof value === 'string' && value.length > 0;
|
|
76
|
+
}
|
|
77
|
+
function isCanonicalBase64(value) {
|
|
78
|
+
if (value.length === 0 || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(value))
|
|
79
|
+
return false;
|
|
80
|
+
return Buffer.from(value, 'base64').toString('base64') === value;
|
|
81
|
+
}
|
|
82
|
+
function isRecord(value) {
|
|
83
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
84
|
+
}
|
|
@@ -51,6 +51,30 @@ export declare function encodeDeterministicStateSnapshot(snapshot: Deterministic
|
|
|
51
51
|
export declare function decodeDeterministicStateSnapshot(text: string): DeterministicStateSnapshotDecodeResult;
|
|
52
52
|
export declare function bytesToBase64(bytes: Uint8Array): string;
|
|
53
53
|
export declare function base64ToBytes(text: string): Uint8Array;
|
|
54
|
+
/**
|
|
55
|
+
* Fixed bytes the wire framing adds around a chunk: 225 B for the
|
|
56
|
+
* `snapshot-chunk` envelope plus 76 B for the SDK transport's outer
|
|
57
|
+
* {type,msgType,data} wrapper. Held at 512 B for headroom on transferId and
|
|
58
|
+
* checksum length changes.
|
|
59
|
+
*/
|
|
60
|
+
export declare const SNAPSHOT_CHUNK_ENVELOPE_OVERHEAD_BYTES = 512;
|
|
61
|
+
/**
|
|
62
|
+
* Worst-case multiplicative growth of a chunk string on its way to the wire.
|
|
63
|
+
* `snapshotBytes` is JSON (`encodeDeterministicStateSnapshot` -> canonicalStringify),
|
|
64
|
+
* NOT base64, so it is quote-dense — and it is JSON-escaped TWICE: into the
|
|
65
|
+
* `snapshot-chunk` envelope, then again when the SDK transport stringifies the
|
|
66
|
+
* outer message (run-room-transport `sendRaw` -> RundotServerRoom
|
|
67
|
+
* {type,msgType,data} -> ServerWebSocket `JSON.stringify`). A `"` becomes `\"`
|
|
68
|
+
* becomes `\\\"`. Measured at exactly 4.001x for all-quote and all-backslash
|
|
69
|
+
* payloads; a realistic base64-dominated snapshot lands at ~1.001x.
|
|
70
|
+
*
|
|
71
|
+
* The room server's boot guard requires
|
|
72
|
+
* WS_MAX_MESSAGE_SIZE >= DEFAULT_SNAPSHOT_CHUNK_SIZE * this + the fixed overhead.
|
|
73
|
+
* A cap below that makes `ws` raise "Max payload size exceeded" and close the
|
|
74
|
+
* socket 1006, failing every snapshot transfer for that room — the 2026-08-25
|
|
75
|
+
* lattice incident. See .plans/syncplay-snapshot-cap-and-observability.md.
|
|
76
|
+
*/
|
|
77
|
+
export declare const MAX_JSON_ESCAPE_EXPANSION = 4;
|
|
54
78
|
export declare const DEFAULT_SNAPSHOT_CHUNK_SIZE = 262144;
|
|
55
79
|
export declare const DEFAULT_MAX_SNAPSHOT_BYTES = 4194304;
|
|
56
80
|
export interface CreateSnapshotTransferOptions {
|
package/dist/session-snapshot.js
CHANGED
|
@@ -155,6 +155,30 @@ export function base64ToBytes(text) {
|
|
|
155
155
|
return bytes;
|
|
156
156
|
}
|
|
157
157
|
// ── transfer builder (raw bytes → frozen wire transfer shape) ──
|
|
158
|
+
/**
|
|
159
|
+
* Fixed bytes the wire framing adds around a chunk: 225 B for the
|
|
160
|
+
* `snapshot-chunk` envelope plus 76 B for the SDK transport's outer
|
|
161
|
+
* {type,msgType,data} wrapper. Held at 512 B for headroom on transferId and
|
|
162
|
+
* checksum length changes.
|
|
163
|
+
*/
|
|
164
|
+
export const SNAPSHOT_CHUNK_ENVELOPE_OVERHEAD_BYTES = 512;
|
|
165
|
+
/**
|
|
166
|
+
* Worst-case multiplicative growth of a chunk string on its way to the wire.
|
|
167
|
+
* `snapshotBytes` is JSON (`encodeDeterministicStateSnapshot` -> canonicalStringify),
|
|
168
|
+
* NOT base64, so it is quote-dense — and it is JSON-escaped TWICE: into the
|
|
169
|
+
* `snapshot-chunk` envelope, then again when the SDK transport stringifies the
|
|
170
|
+
* outer message (run-room-transport `sendRaw` -> RundotServerRoom
|
|
171
|
+
* {type,msgType,data} -> ServerWebSocket `JSON.stringify`). A `"` becomes `\"`
|
|
172
|
+
* becomes `\\\"`. Measured at exactly 4.001x for all-quote and all-backslash
|
|
173
|
+
* payloads; a realistic base64-dominated snapshot lands at ~1.001x.
|
|
174
|
+
*
|
|
175
|
+
* The room server's boot guard requires
|
|
176
|
+
* WS_MAX_MESSAGE_SIZE >= DEFAULT_SNAPSHOT_CHUNK_SIZE * this + the fixed overhead.
|
|
177
|
+
* A cap below that makes `ws` raise "Max payload size exceeded" and close the
|
|
178
|
+
* socket 1006, failing every snapshot transfer for that room — the 2026-08-25
|
|
179
|
+
* lattice incident. See .plans/syncplay-snapshot-cap-and-observability.md.
|
|
180
|
+
*/
|
|
181
|
+
export const MAX_JSON_ESCAPE_EXPANSION = 4;
|
|
158
182
|
export const DEFAULT_SNAPSHOT_CHUNK_SIZE = 262_144;
|
|
159
183
|
export const DEFAULT_MAX_SNAPSHOT_BYTES = 4_194_304;
|
|
160
184
|
export function createSnapshotTransferFromBytes(snapshotBytes, options) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const SYNCPLAY_HOST_TIMING_BATCHES = 5;
|
|
2
|
+
export interface RobustHostTimingOptions {
|
|
3
|
+
readonly warmupSteps: number;
|
|
4
|
+
readonly sampleSteps: number;
|
|
5
|
+
readonly run: (index: number) => void;
|
|
6
|
+
readonly now?: () => number;
|
|
7
|
+
}
|
|
8
|
+
export declare function measureRobustHostP99(options: Readonly<RobustHostTimingOptions>): number;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const SYNCPLAY_HOST_TIMING_BATCHES = 5;
|
|
2
|
+
export function measureRobustHostP99(options) {
|
|
3
|
+
if (!Number.isSafeInteger(options.warmupSteps)
|
|
4
|
+
|| options.warmupSteps < 0
|
|
5
|
+
|| !Number.isSafeInteger(options.sampleSteps)
|
|
6
|
+
|| options.sampleSteps < 1) {
|
|
7
|
+
throw new Error('SYNCPLAY_HOST_TIMING_INVALID');
|
|
8
|
+
}
|
|
9
|
+
const now = options.now ?? (() => performance.now());
|
|
10
|
+
const batchP99 = [];
|
|
11
|
+
for (let batch = 0; batch < SYNCPLAY_HOST_TIMING_BATCHES; batch += 1) {
|
|
12
|
+
for (let index = 0; index < options.warmupSteps; index += 1) {
|
|
13
|
+
options.run(index);
|
|
14
|
+
}
|
|
15
|
+
const timings = [];
|
|
16
|
+
for (let index = 0; index < options.sampleSteps; index += 1) {
|
|
17
|
+
const startedAt = now();
|
|
18
|
+
options.run(index);
|
|
19
|
+
const elapsed = now() - startedAt;
|
|
20
|
+
if (!Number.isFinite(elapsed) || elapsed < 0) {
|
|
21
|
+
throw new Error('SYNCPLAY_HOST_TIMING_INVALID');
|
|
22
|
+
}
|
|
23
|
+
timings.push(elapsed);
|
|
24
|
+
}
|
|
25
|
+
batchP99.push(nearestRank(timings, 0.99));
|
|
26
|
+
}
|
|
27
|
+
return nearestRank(batchP99, 0.5);
|
|
28
|
+
}
|
|
29
|
+
function nearestRank(values, percentile) {
|
|
30
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
31
|
+
const index = Math.max(0, Math.ceil(percentile * sorted.length) - 1);
|
|
32
|
+
const selected = sorted[index];
|
|
33
|
+
if (selected === undefined)
|
|
34
|
+
throw new Error('SYNCPLAY_HOST_TIMING_INVALID');
|
|
35
|
+
return selected;
|
|
36
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { encodeCanonicalBytes } from '../canonical.js';
|
|
2
2
|
import { runSyncplaySynctest } from '../synctest.js';
|
|
3
|
+
import { measureRobustHostP99 } from './host-timing.js';
|
|
3
4
|
import { runDeterminismCheck } from './static-checker.js';
|
|
4
5
|
const staticFailureCodes = new Set([
|
|
5
6
|
'SYNCPLAY_MODULE_CERT_STATIC_MATH',
|
|
@@ -119,18 +120,14 @@ export async function certifyModule(component, fixture) {
|
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
try {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
component.step(selected.slice, selected.input, selected.context);
|
|
131
|
-
timings.push(performance.now() - startedAt);
|
|
132
|
-
}
|
|
133
|
-
stepP99Ms = nearestRank(timings, 0.99);
|
|
123
|
+
stepP99Ms = measureRobustHostP99({
|
|
124
|
+
warmupSteps: fixture.profile.warmupSteps,
|
|
125
|
+
sampleSteps: fixture.profile.sampleSteps,
|
|
126
|
+
run: (index) => {
|
|
127
|
+
const selected = requireProfileCase(fixture.profile.caseAt(index));
|
|
128
|
+
component.step(selected.slice, selected.input, selected.context);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
134
131
|
if (!(stepP99Ms < fixture.profile.maxStepP99Ms)) {
|
|
135
132
|
failures.push({
|
|
136
133
|
code: 'SYNCPLAY_MODULE_CERT_TIME_BUDGET',
|
|
@@ -144,7 +141,7 @@ export async function certifyModule(component, fixture) {
|
|
|
144
141
|
failures.push({
|
|
145
142
|
code: 'SYNCPLAY_MODULE_CERT_EXECUTION_ERROR',
|
|
146
143
|
check: 'profile',
|
|
147
|
-
detail: 'profile callback
|
|
144
|
+
detail: 'profile callback, module step, or host timing failed',
|
|
148
145
|
});
|
|
149
146
|
}
|
|
150
147
|
const collectGarbage = globalThis.gc;
|
|
@@ -198,11 +195,6 @@ export async function certifyModule(component, fixture) {
|
|
|
198
195
|
},
|
|
199
196
|
};
|
|
200
197
|
}
|
|
201
|
-
function nearestRank(values, percentile) {
|
|
202
|
-
const sorted = [...values].sort((left, right) => left - right);
|
|
203
|
-
const index = Math.max(0, Math.ceil(percentile * sorted.length) - 1);
|
|
204
|
-
return sorted[index] ?? 0;
|
|
205
|
-
}
|
|
206
198
|
function isStaticFailureCode(value) {
|
|
207
199
|
return staticFailureCodes.has(value);
|
|
208
200
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@series-inc/rundot-syncplay",
|
|
3
|
-
"version": "5.26.0
|
|
3
|
+
"version": "5.26.0",
|
|
4
4
|
"description": "Kinetix orchestration, rollback, replay, late-join, and deterministic networking for RUN.game",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -160,7 +160,8 @@
|
|
|
160
160
|
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
161
161
|
"test": "npm run test:core && npm run test:unit-control && npm run test:entity-registry",
|
|
162
162
|
"test:module-promotion": "node --expose-gc --import tsx --test tests/module-promotion.test.ts tests/module-docs.test.ts tests/module-bundle-budget.test.ts scripts/prove-module-promotion.test.mjs",
|
|
163
|
-
"test:core": "node --expose-gc --import tsx --test --test-concurrency=1 tests/canonical.test.ts tests/canonical-portability.test.ts tests/runtime-session.test.ts tests/kinetix-runtime-session.test.ts tests/runtime-support.test.ts tests/runtime-guards.test.ts tests/r3f-render-adapter.test.ts tests/snapshot-cost.test.ts tests/sdkSession.test.ts tests/input-authority.test.ts tests/session-wire.test.ts tests/session-snapshot.test.ts tests/authority-room.test.ts tests/networked-client-snapshot.test.ts tests/runner.test.ts tests/testing.test.ts tests/browser-session.test.ts tests/secret-config.test.ts tests/secret-protocol.test.ts tests/secret-authority.test.ts tests/secret-client.test.ts tests/late-join-checksum.test.ts tests/late-join-input-backfill.test.ts tests/replay-scoring.test.ts tests/bot-backfill.test.ts tests/latency-simulation.test.ts tests/time-sync.test.ts tests/match-log.test.ts tests/spectator.test.ts tests/synctest.test.ts tests/instant-replay.test.ts tests/session-promotion.test.ts tests/physics3d-broadphase-stats.test.ts tests/entrypoints.test.ts tests/export-surface.test.ts tests/module-seam.test.ts tests/module-contract-portability.test.ts tests/module-identity.test.ts tests/module-corpus.test.ts tests/component-model.test.ts tests/component-model-rollback.test.ts tests/component-model-integration.test.ts tests/actors-ai.test.ts tests/character-3d.test.ts tests/character-3d-certification.test.ts tests/combat-modules.test.ts tests/combat-modules-integration.test.ts tests/firearm.test.ts tests/gameplay-effects.test.ts tests/spatial-interaction.test.ts tests/stats-abilities.test.ts tests/inventory-equipment.test.ts tests/inventory-equipment-certification.test.ts tests/platformer-2d.test.ts tests/platformer-2d-certification.test.ts tests/module-promotion.test.ts tests/module-docs.test.ts tests/module-bundle-budget.test.ts tests/published-side-effects.test.ts tests/test-manifest.test.ts tests/coarse-region.test.ts tests/input-codec.test.ts tests/command-ingress.test.ts tests/session-reconfigure.test.ts tests/prediction-mode.test.ts tests/ephemeral-channel.test.ts tests/runner-surface.test.ts tests/runner-harness.test.ts tests/effects-adapter-bounds.test.ts tests/simulated-match-secrets.test.ts tests/run-glue-surface.test.mjs core/tests/core-boundary.test.mjs tests/math-core-parity.test.ts tests/math-profile.test.ts tests/movement3d-kcc-unit-contract.test.ts tests/movement3d-static-ground.test.ts",
|
|
163
|
+
"test:core": "node --expose-gc --import tsx --test --test-concurrency=1 tests/canonical.test.ts tests/canonical-portability.test.ts tests/runtime-session.test.ts tests/kinetix-runtime-session.test.ts tests/runtime-support.test.ts tests/runtime-guards.test.ts tests/r3f-render-adapter.test.ts tests/snapshot-cost.test.ts tests/sdkSession.test.ts tests/input-authority.test.ts tests/session-wire.test.ts tests/runtime-room-metadata.test.ts tests/session-snapshot.test.ts tests/authority-room.test.ts tests/networked-client-snapshot.test.ts tests/runner.test.ts tests/testing.test.ts tests/browser-session.test.ts tests/secret-config.test.ts tests/secret-protocol.test.ts tests/secret-authority.test.ts tests/secret-client.test.ts tests/late-join-checksum.test.ts tests/late-join-input-backfill.test.ts tests/replay-scoring.test.ts tests/bot-backfill.test.ts tests/latency-simulation.test.ts tests/production-input-budget.test.ts tests/time-sync.test.ts tests/match-log.test.ts tests/spectator.test.ts tests/synctest.test.ts tests/instant-replay.test.ts tests/session-promotion.test.ts tests/physics3d-broadphase-stats.test.ts tests/entrypoints.test.ts tests/export-surface.test.ts tests/module-seam.test.ts tests/module-contract-portability.test.ts tests/module-identity.test.ts tests/module-corpus.test.ts tests/component-model.test.ts tests/component-model-rollback.test.ts tests/component-model-integration.test.ts tests/actors-ai.test.ts tests/character-3d.test.ts tests/character-3d-certification.test.ts tests/combat-modules.test.ts tests/combat-modules-integration.test.ts tests/firearm.test.ts tests/gameplay-effects.test.ts tests/spatial-interaction.test.ts tests/stats-abilities.test.ts tests/inventory-equipment.test.ts tests/inventory-equipment-certification.test.ts tests/platformer-2d.test.ts tests/platformer-2d-certification.test.ts tests/module-promotion.test.ts tests/module-docs.test.ts tests/module-bundle-budget.test.ts tests/published-side-effects.test.ts tests/host-timing.test.ts tests/test-manifest.test.ts tests/coarse-region.test.ts tests/input-codec.test.ts tests/command-ingress.test.ts tests/session-reconfigure.test.ts tests/prediction-mode.test.ts tests/ephemeral-channel.test.ts tests/runner-surface.test.ts tests/runner-harness.test.ts tests/effects-adapter-bounds.test.ts tests/simulated-match-secrets.test.ts tests/run-glue-surface.test.mjs core/tests/core-boundary.test.mjs tests/math-core-parity.test.ts tests/math-profile.test.ts tests/movement3d-kcc-unit-contract.test.ts tests/movement3d-static-ground.test.ts",
|
|
164
|
+
"test:sdk-contract": "node --import tsx --test tests/sdk-runtime-contract.test.ts",
|
|
164
165
|
"test:soak": "tsx --test tests/runtime-soak.test.ts",
|
|
165
166
|
"test:extended": "tsx --test tests/kcc-vehicle-characterization.test.ts tests/animation.test.ts tests/asset-cooking.test.ts tests/backend-proof-checksum-vectors.test.ts tests/bot-documents.test.ts tests/bot-primitives.test.ts tests/collider-cooking.test.ts tests/collision.test.ts tests/command-timeline.test.ts tests/config-bundle.test.ts tests/cosmetic-physics3d.test.ts tests/deterministic-primitives.test.ts tests/ecs-lite.test.ts tests/effects-adapter.test.ts tests/engine-runtime.test.ts tests/errors.test.ts tests/events.test.ts tests/input-button.test.ts tests/load-128-players.test.ts tests/math.test.ts tests/movement.test.ts tests/movement3d.test.ts tests/multiclient-authority.test.ts tests/navigation.test.ts tests/noise.test.ts tests/physics-cert.test.ts tests/physics2d.test.ts tests/physics3d-ccd.test.ts tests/physics3d-coverage.test.ts tests/physics3d-destruction-cert.test.ts tests/physics3d-geometry-limits.test.ts tests/physics3d-heightfield.test.ts tests/physics3d-hull.test.ts tests/physics3d-joints.test.ts tests/physics3d-kinematic-mesh.test.ts tests/physics3d-mass.test.ts tests/physics3d-materials.test.ts tests/physics3d-mesh-onesided.test.ts tests/physics3d-query-index.test.ts tests/physics3d-recipes.test.ts tests/physics3d-rotation-convention.test.ts tests/physics3d-shared.test.ts tests/physics3d-sleeping-manifold-reuse.test.ts tests/physics3d-solver.test.ts tests/physics3d-static-memoization.test.ts tests/physics3d-static-overlap-memo.test.ts tests/physics3d-cylinder-hull.test.ts tests/physics3d-stacking.test.ts tests/physics3d-toi.test.ts tests/physics3d-vehicle.test.ts tests/physics3d-voxel.test.ts tests/physics3d-world-edit.test.ts tests/physics3d.test.ts tests/physics-vector-generator.test.ts tests/random.test.ts tests/rollback-history.test.ts tests/schema-artifacts.test.ts tests/schema-codegen.test.ts tests/schema-dsl-binary.test.ts tests/schema.test.ts tests/signals.test.ts tests/sparse-set.test.ts tests/static-checker.test.ts tests/system-lifecycle.test.ts tests/wire-canonical-sentinels.test.ts tests/ws-frame.test.ts",
|
|
166
167
|
"test:ci": "npm run build && npm run test",
|
|
@@ -189,8 +190,8 @@
|
|
|
189
190
|
"test:inventory-equipment": "node --expose-gc --import tsx --test tests/inventory-equipment.test.ts tests/inventory-equipment-certification.test.ts scripts/prove-inventory-equipment.test.mjs",
|
|
190
191
|
"prove:inventory-equipment": "node --expose-gc --import tsx scripts/prove-inventory-equipment.mjs",
|
|
191
192
|
"test:platformer-2d": "node --expose-gc --import tsx --test tests/platformer-2d.test.ts tests/platformer-2d-certification.test.ts scripts/prove-platformer-2d.test.mjs",
|
|
192
|
-
"test:unit-control": "node --expose-gc --import tsx --test tests/unit-control.test.ts tests/unit-control-certification.test.ts",
|
|
193
|
-
"test:entity-registry": "node --import tsx --test tests/entity-registry.test.ts tests/entity-registry-determinism.test.ts tests/entity-registry-certification.test.ts",
|
|
193
|
+
"test:unit-control": "node --expose-gc --import tsx --test --test-concurrency=1 tests/unit-control.test.ts tests/unit-control-certification.test.ts",
|
|
194
|
+
"test:entity-registry": "node --import tsx --test --test-concurrency=1 tests/entity-registry.test.ts tests/entity-registry-determinism.test.ts tests/entity-registry-certification.test.ts",
|
|
194
195
|
"prove:unit-control": "node --expose-gc --import tsx scripts/prove-unit-control.mjs",
|
|
195
196
|
"prove:entity-registry": "node --import tsx scripts/prove-entity-registry.mjs",
|
|
196
197
|
"prove:platformer-2d": "node --expose-gc --import tsx scripts/prove-platformer-2d.mjs",
|