@series-inc/rundot-syncplay 5.26.0-beta.2 → 5.26.0-beta.3

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.
@@ -9,6 +9,55 @@ import {
9
9
  } from './mass-properties.mjs';
10
10
 
11
11
  const PHYSICS_ABI_VERSION = 14;
12
+
13
+ /**
14
+ * Mirrors `kBroadphaseCapacity` (native/physics/physics.cpp:1858).
15
+ * recordContactTelemetry silently returns at this count, so the exported contact
16
+ * count saturates here and a step that reaches it may be missing contacts. No
17
+ * overflow flag crosses the ABI, so this boundary is the only signal available.
18
+ */
19
+ export const PHYSICS_CONTACT_TELEMETRY_CAP = 100_000;
20
+
21
+ /**
22
+ * Post-step contact contract, published so other runtimes (notably the native
23
+ * C++ player) consume the same semantics without reimplementing physics.
24
+ */
25
+ export const PHYSICS_CONTACT_CONTRACT = Object.freeze({
26
+ entry: Object.freeze([
27
+ 'pair', 'anchorX', 'anchorY', 'anchorZ',
28
+ 'normalImpulse', 'tangentImpulse1', 'tangentImpulse2',
29
+ ]),
30
+ ordering: 'contactCache is sorted by pair id; ordering is canonical world state and feeds checksums',
31
+ authority: Object.freeze({
32
+ solid: 'cpp-telemetry',
33
+ triggerOrSensor: 'js-detect-contacts',
34
+ note: 'the solver never resolves triggers or sensors, so telemetry cannot report them',
35
+ }),
36
+ warmStart: 'contactCache is returned to the solver next step via rundot_kinetix_physics_set_contact_cache',
37
+ budgets: Object.freeze({
38
+ contactTelemetry: Object.freeze({
39
+ limit: PHYSICS_CONTACT_TELEMETRY_CAP,
40
+ onOverflow: 'silently-truncates-in-cpp; detected here by count saturation and raised',
41
+ signal: 'KINETIX_PHYSICS_CONTACT_TELEMETRY_SATURATED',
42
+ }),
43
+ broadphasePairs: Object.freeze({
44
+ limit: 1 << 20,
45
+ onOverflow: 'falls-back-to-all-pairs-in-cpp; lossless, no signal needed',
46
+ signal: null,
47
+ }),
48
+ }),
49
+ errors: Object.freeze(['KINETIX_PHYSICS_CONTACT_TELEMETRY_SATURATED']),
50
+ });
51
+
52
+ /**
53
+ * Tripwire against obviously-wrong geometry (a mis-scaled voxel cook, a units
54
+ * error), NOT a technical limit — the marshaling path has been exercised to
55
+ * 12.6M elements without failing. Set far above any realtime-usable size: at
56
+ * 1M elements (a ~577x577 heightfield) a single body already costs ~22ms/tick,
57
+ * so anything reaching this ceiling is broken rather than merely slow. See
58
+ * SYNCPLAY-PHYSICS.md for the practical per-step budget, which is much lower.
59
+ */
60
+ export const PHYSICS_MAX_SHAPE_GEOMETRY_ELEMENTS = 1_000_000;
12
61
  const PHYSICS_BODY_STRIDE = 44;
13
62
  const PHYSICS_JOINT_STRIDE = 48;
14
63
  const PHYSICS_JOINT_TELEMETRY_STRIDE = 5;
@@ -183,6 +232,23 @@ function cloneOptionalNumber(source, target, key, minimum = Number.NEGATIVE_INFI
183
232
  target[key] = value;
184
233
  }
185
234
 
235
+ /**
236
+ * Flat geometry element count without materializing the geometry — an oversized
237
+ * shape must fail before it allocates the array that would have blown up. The
238
+ * per-child term must match `childStrideValues` in shapeGeometryValues; an
239
+ * over-count here would reject shapes that marshal fine.
240
+ */
241
+ function shapeGeometryElementCount(shape) {
242
+ if (shape.type === 'mesh' || shape.type === 'hull') return shape.vertices.length * 3;
243
+ if (shape.type === 'heightfield') return 3 + shape.columns * shape.rows * 3;
244
+ if (shape.type === 'compound') {
245
+ let total = 0;
246
+ for (const child of shape.children) total += 18 + shapeGeometryElementCount(child.shape);
247
+ return total;
248
+ }
249
+ return 0;
250
+ }
251
+
186
252
  function normalizeBody(body) {
187
253
  if (!isRecord(body) || typeof body.id !== 'string' || body.id.length === 0 || body.id.includes('|')) {
188
254
  fail('KINETIX_PHYSICS_BODY_ID_INVALID');
@@ -190,6 +256,11 @@ function normalizeBody(body) {
190
256
  if (body.kind !== 'static' && body.kind !== 'dynamic' && body.kind !== 'kinematic') {
191
257
  fail('KINETIX_PHYSICS_BODY_KIND_INVALID');
192
258
  }
259
+ const shape = cloneShape(body.shape);
260
+ const geometryElements = shapeGeometryElementCount(shape);
261
+ if (geometryElements > PHYSICS_MAX_SHAPE_GEOMETRY_ELEMENTS) {
262
+ fail(`KINETIX_PHYSICS_SHAPE_GEOMETRY_TOO_LARGE ${body.id} ${geometryElements}`);
263
+ }
193
264
  const normalized = {
194
265
  id: body.id,
195
266
  kind: body.kind,
@@ -199,7 +270,7 @@ function normalizeBody(body) {
199
270
  vx: finite(body.vx, 'VX'),
200
271
  vy: finite(body.vy, 'VY'),
201
272
  vz: finite(body.vz, 'VZ'),
202
- shape: cloneShape(body.shape),
273
+ shape,
203
274
  layer: bitMask(body.layer ?? 1, 'LAYER'),
204
275
  mask: bitMask(body.mask ?? 1, 'MASK'),
205
276
  };
@@ -404,7 +475,23 @@ function minMaxVertices(vertices) {
404
475
  return { minX, minY, minZ, maxX, maxY, maxZ };
405
476
  }
406
477
 
478
+ /**
479
+ * Bounds are a pure function of a frozen shape, and marshalBodyData recomputes
480
+ * them per static body per step via shapeLocalCenter — recursing over every
481
+ * compound child each time. Identity-keyed memoization is sound and removes
482
+ * that entirely.
483
+ */
484
+ let shapeLocalBoundsCache = new WeakMap();
485
+
407
486
  function shapeLocalBounds(shape) {
487
+ const memoized = shapeLocalBoundsCache.get(shape);
488
+ if (memoized !== undefined) return memoized;
489
+ const computed = computeShapeLocalBounds(shape);
490
+ shapeLocalBoundsCache.set(shape, computed);
491
+ return computed;
492
+ }
493
+
494
+ function computeShapeLocalBounds(shape) {
408
495
  if (shape.type === 'box') {
409
496
  return {
410
497
  minX: -shape.halfX,
@@ -769,11 +856,21 @@ function bodiesContactingAny(bodies, changedBodies) {
769
856
  return affectedIds;
770
857
  }
771
858
 
859
+ /**
860
+ * Values already deep-frozen by us. Without this, createWorld re-walks every
861
+ * static body's full shape tree on every step — the dominant per-step cost once
862
+ * checksums are off (32 statics x 16 compound children: 0.80 -> 0.13 ms/tick).
863
+ * A frozen value cannot change, so a hit is always sound.
864
+ */
865
+ let deepFrozenValues = new WeakSet();
866
+
772
867
  function deepFreeze(value, seen = new WeakSet()) {
773
868
  if (!isRecord(value) && !Array.isArray(value)) return value;
869
+ if (deepFrozenValues.has(value)) return value;
774
870
  if (seen.has(value)) return value;
775
871
  seen.add(value);
776
872
  for (const child of Object.values(value)) deepFreeze(child, seen);
873
+ deepFrozenValues.add(value);
777
874
  return Object.freeze(value);
778
875
  }
779
876
 
@@ -1052,11 +1149,25 @@ function shapeGeometryValues(shape) {
1052
1149
  return undefined;
1053
1150
  }
1054
1151
 
1152
+ /**
1153
+ * Bounded append. `push(...values)` passes every element as a call argument and
1154
+ * overflows the engine's argument limit around ~100k elements, surfacing as an
1155
+ * opaque RangeError from inside stepPhysicsWorld rather than a typed physics
1156
+ * error. Geometry is 3 values per element, so a 192x192 heightfield is enough.
1157
+ */
1158
+ function appendGeometryValues(target, values) {
1159
+ const base = target.length;
1160
+ target.length = base + values.length;
1161
+ for (let index = 0; index < values.length; index += 1) {
1162
+ target[base + index] = values[index];
1163
+ }
1164
+ }
1165
+
1055
1166
  function shapeFields(shape, geometryValues) {
1056
1167
  const materialize = (cached) => {
1057
1168
  if (cached.geometry !== undefined) {
1058
1169
  const geometryOffset = geometryValues.length / 3;
1059
- geometryValues.push(...cached.geometry);
1170
+ appendGeometryValues(geometryValues, cached.geometry);
1060
1171
  return Object.freeze([
1061
1172
  cached.shapeKind,
1062
1173
  geometryOffset,
@@ -1110,7 +1221,7 @@ function shapeFields(shape, geometryValues) {
1110
1221
  }
1111
1222
  if (geometry !== undefined) {
1112
1223
  const geometryOffset = geometryValues.length / 3;
1113
- geometryValues.push(...geometry);
1224
+ appendGeometryValues(geometryValues, geometry);
1114
1225
  return Object.freeze([shapeKind, geometryOffset, geometry.length / 3, geometryFormat]);
1115
1226
  }
1116
1227
  return fields;
@@ -1434,7 +1545,10 @@ function nativeContactTelemetry(bodies) {
1434
1545
  const bodyA = bodies[values[offset]];
1435
1546
  const bodyB = bodies[values[offset + 1]];
1436
1547
  if (bodyA === undefined || bodyB === undefined) continue;
1437
- telemetry.set(bodyA.id + '|' + bodyB.id, {
1548
+ // pairKey, not raw concatenation: cache membership now depends on these keys
1549
+ // matching JS pair keys, which today holds only because C++ normalizes pairs
1550
+ // with min/max on indices and createPhysicsWorld sorts bodies by id.
1551
+ telemetry.set(pairKey(bodyA, bodyB), {
1438
1552
  normalImpulse: values[offset + 2],
1439
1553
  tangentImpulse1: values[offset + 3],
1440
1554
  tangentImpulse2: values[offset + 4],
@@ -2731,27 +2845,44 @@ export function stepPhysicsWorld(world, options) {
2731
2845
  && (bodyById.get(contact.a)?.kind === 'dynamic' || bodyById.get(contact.b)?.kind === 'dynamic'));
2732
2846
  const preSolverContacts = preContacts.filter((contact) => !contact.trigger && !contact.sensor
2733
2847
  && (bodyById.get(contact.a)?.kind === 'dynamic' || bodyById.get(contact.b)?.kind === 'dynamic'));
2734
- const activePairs = currentContacts.map((contact) => contact.pair);
2735
- const activePairMetadata = currentContacts.map((contact) => ({
2848
+ if (nativeTelemetry.size >= PHYSICS_CONTACT_TELEMETRY_CAP) {
2849
+ fail(`KINETIX_PHYSICS_CONTACT_TELEMETRY_SATURATED ${PHYSICS_CONTACT_TELEMETRY_CAP}`);
2850
+ }
2851
+ // The solver owns solid contacts: telemetry holds exactly the pairs it
2852
+ // resolved, so membership comes from there rather than a second JS narrowphase.
2853
+ // Sorted because contactCache is canonical world state and feeds checksums.
2854
+ const contactCache = [...nativeTelemetry.entries()]
2855
+ .filter(([pair]) => !disabledPairs.has(pair))
2856
+ .map(([pair, measured]) => ({
2857
+ pair,
2858
+ anchorX: measured.anchorX,
2859
+ anchorY: measured.anchorY,
2860
+ anchorZ: measured.anchorZ,
2861
+ normalImpulse: measured.normalImpulse,
2862
+ tangentImpulse1: measured.tangentImpulse1,
2863
+ tangentImpulse2: measured.tangentImpulse2,
2864
+ }))
2865
+ .sort((left, right) => (left.pair < right.pair ? -1 : left.pair > right.pair ? 1 : 0));
2866
+ // Events union telemetry with JS detection. Telemetry omits static-static and
2867
+ // both-asleep pairs, so filtering to it would drop their events and re-fire a
2868
+ // spurious `enter` on wake; JS alone misses solver contacts it did not detect.
2869
+ const contactByPair = new Map(currentContacts.map((contact) => [contact.pair, contact]));
2870
+ const reconciledContacts = [...currentContacts, ...contactCache
2871
+ .filter((entry) => !contactByPair.has(entry.pair))
2872
+ .map((entry) => {
2873
+ const [a, b] = entry.pair.split('|');
2874
+ return { pair: entry.pair, a, b, trigger: false, sensor: false };
2875
+ })]
2876
+ .sort((left, right) => (left.pair < right.pair ? -1 : left.pair > right.pair ? 1 : 0));
2877
+ const activePairs = reconciledContacts.map((contact) => contact.pair);
2878
+ const activePairMetadata = reconciledContacts.map((contact) => ({
2736
2879
  pair: contact.pair,
2737
2880
  a: contact.a,
2738
2881
  b: contact.b,
2739
2882
  trigger: contact.trigger,
2740
2883
  sensor: contact.sensor,
2741
2884
  }));
2742
- const contactCache = solverContacts.map((contact) => {
2743
- const measured = nativeTelemetry.get(contact.pair);
2744
- return {
2745
- pair: contact.pair,
2746
- anchorX: measured?.anchorX ?? contact.point.x,
2747
- anchorY: measured?.anchorY ?? contact.point.y,
2748
- anchorZ: measured?.anchorZ ?? contact.point.z,
2749
- normalImpulse: measured?.normalImpulse ?? 0,
2750
- tangentImpulse1: measured?.tangentImpulse1 ?? 0,
2751
- tangentImpulse2: measured?.tangentImpulse2 ?? 0,
2752
- };
2753
- });
2754
- const events = currentContacts.map((contact) => ({
2885
+ const events = reconciledContacts.map((contact) => ({
2755
2886
  type: world.activePairs.includes(contact.pair) ? 'stay' : 'enter',
2756
2887
  pair: contact.pair,
2757
2888
  a: contact.a,
@@ -2816,6 +2947,10 @@ export function stepPhysicsWorld(world, options) {
2816
2947
  postPhysicsScheduledQueryResults,
2817
2948
  broadphasePairs,
2818
2949
  narrowphaseContacts: solverContacts.length,
2950
+ // Read from the solver's exported contact count rather than derived from
2951
+ // contactCache, so the two remain independently checkable and callers can
2952
+ // see how close a step is running to PHYSICS_CONTACT_TELEMETRY_CAP.
2953
+ contactTelemetryCount: nativeTelemetry.size,
2819
2954
  solverCorrections,
2820
2955
  checksum: stepOptions.computeChecksum ? worldChecksum({ nextWorld, events: visibleEvents, scheduledQueryResults, solverCorrections }) : '',
2821
2956
  });
@@ -3167,6 +3302,8 @@ export function resetPhysics3DCache() {
3167
3302
  staticGeometryCache.clear();
3168
3303
  staticGeometryCacheBytes = 0;
3169
3304
  staticGeometryIdentityCache = new WeakMap();
3305
+ deepFrozenValues = new WeakSet();
3306
+ shapeLocalBoundsCache = new WeakMap();
3170
3307
  }
3171
3308
 
3172
3309
  export const createDeterministicPhysicsWorld3D = createPhysicsWorld;