spoint 0.1.658 → 0.1.660

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.658",
3
+ "version": "0.1.660",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -0,0 +1,87 @@
1
+ // Pure binary wire-format primitives for SnapshotEncoder.js: the fixed 23-byte numeric record
2
+ // (position/velocity/rotation/scale/flags) and the 32-bit packed-quaternion encode/decode. No
3
+ // closure/instance state -- split out as the one genuinely self-contained piece of that file.
4
+ // See SnapshotEncoder.js's own header comment for the full wire-layout rationale.
5
+
6
+ const Q1 = 100
7
+ const QSCALE = 511 * Math.SQRT2
8
+
9
+ // --- Binary numeric record (DataView, replaces JS-array-through-msgpackr for the fixed numeric
10
+ // fields: position/velocity/rotation/scale). id/model/bodyType/custom stay as native JS values
11
+ // alongside this buffer -- they are variable-shape (strings, arbitrary objects) and packing them
12
+ // into a fixed byte layout would be strictly worse (lossy or unbounded), not a real win. Position/
13
+ // velocity are int16 at the existing Q1=100 (1cm) scale, clamped to +-327.67m -- entity/player
14
+ // positions are always encoded relative to a region/session-local origin already (no planetary
15
+ // float32 range concern here; see AGENTS.md floating-origin-camera-relative-rendering row for the
16
+ // separate concern of >10km world coordinates, which is a rendering-layer issue, not a wire-format
17
+ // one). Scale uses uint16 unsigned at the same Q1 scale (0..655.35, entities are never negatively
18
+ // scaled). Rotation reuses packQuat's existing 32-bit packed representation verbatim -- not
19
+ // reinvented. Fixed player/entity record is 23 bytes: 3*i16 pos + 3*i16 vel + u32 quat + 3*u16
20
+ // scale + 1 flags byte = 6+6+4+6+1 = 23.
21
+ export const BIN_RECORD_BYTES = 23
22
+ export const POS_I16_MAX = 32767 / Q1 // 327.67
23
+ export const SCALE_U16_MAX = 65535 / Q1 // 655.35
24
+
25
+ export function clampI16(v) { return Math.max(-32767, Math.min(32767, Math.round((v || 0) * Q1))) }
26
+ export function clampU16Scale(v) { return Math.max(0, Math.min(65535, Math.round((v ?? 1) * Q1))) }
27
+
28
+ // Packs the fixed numeric fields of one entity/player record into a fresh 23-byte Uint8Array.
29
+ // flags: caller-supplied bitfield (onGround/sleeping/etc for players/entities respectively).
30
+ export function packBinRecord(px, py, pz, qrot, vx, vy, vz, sx, sy, sz, flags) {
31
+ const buf = new Uint8Array(BIN_RECORD_BYTES)
32
+ const dv = new DataView(buf.buffer)
33
+ dv.setInt16(0, clampI16(px), true); dv.setInt16(2, clampI16(py), true); dv.setInt16(4, clampI16(pz), true)
34
+ dv.setInt16(6, clampI16(vx), true); dv.setInt16(8, clampI16(vy), true); dv.setInt16(10, clampI16(vz), true)
35
+ dv.setUint32(12, qrot >>> 0, true)
36
+ dv.setUint16(16, clampU16Scale(sx), true); dv.setUint16(18, clampU16Scale(sy), true); dv.setUint16(20, clampU16Scale(sz), true)
37
+ dv.setUint8(22, flags & 0xFF)
38
+ return buf
39
+ }
40
+
41
+ export function unpackBinRecord(buf, out) {
42
+ const dv = buf instanceof DataView ? buf : new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
43
+ out.px = dv.getInt16(0, true) / Q1; out.py = dv.getInt16(2, true) / Q1; out.pz = dv.getInt16(4, true) / Q1
44
+ out.vx = dv.getInt16(6, true) / Q1; out.vy = dv.getInt16(8, true) / Q1; out.vz = dv.getInt16(10, true) / Q1
45
+ out.qrot = dv.getUint32(12, true)
46
+ out.sx = dv.getUint16(16, true) / Q1; out.sy = dv.getUint16(18, true) / Q1; out.sz = dv.getUint16(20, true) / Q1
47
+ out.flags = dv.getUint8(22)
48
+ return out
49
+ }
50
+
51
+ export function packQuat(rx, ry, rz, rw) {
52
+ const arx = Math.abs(rx), ary = Math.abs(ry), arz = Math.abs(rz), arw = Math.abs(rw)
53
+ let maxIdx = 0, maxAbs = arx
54
+ if (ary > maxAbs) { maxIdx = 1; maxAbs = ary }
55
+ if (arz > maxAbs) { maxIdx = 2; maxAbs = arz }
56
+ if (arw > maxAbs) { maxIdx = 3; maxAbs = arw }
57
+ const mval = maxIdx === 0 ? rx : maxIdx === 1 ? ry : maxIdx === 2 ? rz : rw
58
+ const sign = mval < 0 ? -1 : 1
59
+ let packed = maxIdx
60
+ if (maxIdx !== 0) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((rx * sign + Math.SQRT1_2) * QSCALE)))
61
+ if (maxIdx !== 1) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((ry * sign + Math.SQRT1_2) * QSCALE)))
62
+ if (maxIdx !== 2) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((rz * sign + Math.SQRT1_2) * QSCALE)))
63
+ if (maxIdx !== 3) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((rw * sign + Math.SQRT1_2) * QSCALE)))
64
+ return packed >>> 0
65
+ }
66
+
67
+ // Unrolled per maxIdx branch (was a QUAT_IDX[] lookup + generic loop over `indices`) -- this runs
68
+ // once per entity/player per snapshot, client-side, the hottest per-frame decode path, so the extra
69
+ // array indirection through QUAT_IDX plus a 3-iteration loop with a data-dependent out-index write
70
+ // is worth trading for 4 flat, branch-predictable unpacks. Each branch reads the same three 10-bit
71
+ // fields off `packed` in the same bit order (most-significant first, j=2..0) as the original loop,
72
+ // just with the literal QUAT_IDX[maxIdx] destination slots inlined instead of indexed.
73
+ export function unpackQuat(packed, out) {
74
+ const maxIdx = (packed >>> 30) & 0x3
75
+ const c2 = (packed & 0x3FF) / QSCALE - Math.SQRT1_2; packed = packed >>> 10
76
+ const c1 = (packed & 0x3FF) / QSCALE - Math.SQRT1_2; packed = packed >>> 10
77
+ const c0 = (packed & 0x3FF) / QSCALE - Math.SQRT1_2
78
+ const sumSq = c0 * c0 + c1 * c1 + c2 * c2
79
+ const m = Math.sqrt(Math.max(0, 1 - sumSq))
80
+ switch (maxIdx) {
81
+ case 0: out[1] = c0; out[2] = c1; out[3] = c2; out[0] = m; break
82
+ case 1: out[0] = c0; out[2] = c1; out[3] = c2; out[1] = m; break
83
+ case 2: out[0] = c0; out[1] = c1; out[3] = c2; out[2] = m; break
84
+ default: out[0] = c0; out[1] = c1; out[2] = c2; out[3] = m; break
85
+ }
86
+ return out
87
+ }
@@ -1,90 +1,16 @@
1
1
  import { getComponentSchema, encodeCustomFields, decodeCustomFields } from '../../apps/_lib/ComponentSchema.js'
2
+ import {
3
+ BIN_RECORD_BYTES, POS_I16_MAX, SCALE_U16_MAX, clampI16, clampU16Scale,
4
+ packBinRecord, unpackBinRecord, packQuat, unpackQuat
5
+ } from './SnapshotBinFormat.js'
6
+
7
+ // Re-exported from SnapshotBinFormat.js for backward compatibility -- AnimationClipCache.js,
8
+ // TickHandler.js, and edge/cf-do/do-client-probe.mjs all import these from this file's own path.
9
+ export { unpackBinRecord, packQuat, unpackQuat }
2
10
 
3
- const Q1=100
4
11
  const TAU = 2 * Math.PI, HALF_PI = Math.PI / 2
5
12
  const VEL_ZERO = [0,0,0]
6
13
  const SCALE_ONE = [1,1,1]
7
- const QSCALE = 511 * Math.SQRT2
8
-
9
- // --- Binary numeric record (DataView, replaces JS-array-through-msgpackr for the fixed numeric
10
- // fields: position/velocity/rotation/scale). id/model/bodyType/custom stay as native JS values
11
- // alongside this buffer -- they are variable-shape (strings, arbitrary objects) and packing them
12
- // into a fixed byte layout would be strictly worse (lossy or unbounded), not a real win. Position/
13
- // velocity are int16 at the existing Q1=100 (1cm) scale, clamped to +-327.67m -- entity/player
14
- // positions are always encoded relative to a region/session-local origin already (no planetary
15
- // float32 range concern here; see AGENTS.md floating-origin-camera-relative-rendering row for the
16
- // separate concern of >10km world coordinates, which is a rendering-layer issue, not a wire-format
17
- // one). Scale uses uint16 unsigned at the same Q1 scale (0..655.35, entities are never negatively
18
- // scaled). Rotation reuses packQuat's existing 32-bit packed representation verbatim -- not
19
- // reinvented. Fixed player/entity record is 23 bytes: 3*i16 pos + 3*i16 vel + u32 quat + 3*u16
20
- // scale + 1 flags byte = 6+6+4+6+1 = 23.
21
- const BIN_RECORD_BYTES = 23
22
- const POS_I16_MAX = 32767 / Q1 // 327.67
23
- const SCALE_U16_MAX = 65535 / Q1 // 655.35
24
-
25
- function clampI16(v) { return Math.max(-32767, Math.min(32767, Math.round((v || 0) * Q1))) }
26
- function clampU16Scale(v) { return Math.max(0, Math.min(65535, Math.round((v ?? 1) * Q1))) }
27
-
28
- // Packs the fixed numeric fields of one entity/player record into a fresh 23-byte Uint8Array.
29
- // flags: caller-supplied bitfield (onGround/sleeping/etc for players/entities respectively).
30
- function packBinRecord(px, py, pz, qrot, vx, vy, vz, sx, sy, sz, flags) {
31
- const buf = new Uint8Array(BIN_RECORD_BYTES)
32
- const dv = new DataView(buf.buffer)
33
- dv.setInt16(0, clampI16(px), true); dv.setInt16(2, clampI16(py), true); dv.setInt16(4, clampI16(pz), true)
34
- dv.setInt16(6, clampI16(vx), true); dv.setInt16(8, clampI16(vy), true); dv.setInt16(10, clampI16(vz), true)
35
- dv.setUint32(12, qrot >>> 0, true)
36
- dv.setUint16(16, clampU16Scale(sx), true); dv.setUint16(18, clampU16Scale(sy), true); dv.setUint16(20, clampU16Scale(sz), true)
37
- dv.setUint8(22, flags & 0xFF)
38
- return buf
39
- }
40
-
41
- export function unpackBinRecord(buf, out) {
42
- const dv = buf instanceof DataView ? buf : new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
43
- out.px = dv.getInt16(0, true) / Q1; out.py = dv.getInt16(2, true) / Q1; out.pz = dv.getInt16(4, true) / Q1
44
- out.vx = dv.getInt16(6, true) / Q1; out.vy = dv.getInt16(8, true) / Q1; out.vz = dv.getInt16(10, true) / Q1
45
- out.qrot = dv.getUint32(12, true)
46
- out.sx = dv.getUint16(16, true) / Q1; out.sy = dv.getUint16(18, true) / Q1; out.sz = dv.getUint16(20, true) / Q1
47
- out.flags = dv.getUint8(22)
48
- return out
49
- }
50
-
51
- export function packQuat(rx, ry, rz, rw) {
52
- const arx = Math.abs(rx), ary = Math.abs(ry), arz = Math.abs(rz), arw = Math.abs(rw)
53
- let maxIdx = 0, maxAbs = arx
54
- if (ary > maxAbs) { maxIdx = 1; maxAbs = ary }
55
- if (arz > maxAbs) { maxIdx = 2; maxAbs = arz }
56
- if (arw > maxAbs) { maxIdx = 3; maxAbs = arw }
57
- const mval = maxIdx === 0 ? rx : maxIdx === 1 ? ry : maxIdx === 2 ? rz : rw
58
- const sign = mval < 0 ? -1 : 1
59
- let packed = maxIdx
60
- if (maxIdx !== 0) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((rx * sign + Math.SQRT1_2) * QSCALE)))
61
- if (maxIdx !== 1) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((ry * sign + Math.SQRT1_2) * QSCALE)))
62
- if (maxIdx !== 2) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((rz * sign + Math.SQRT1_2) * QSCALE)))
63
- if (maxIdx !== 3) packed = (packed << 10) | Math.max(0, Math.min(1022, Math.round((rw * sign + Math.SQRT1_2) * QSCALE)))
64
- return packed >>> 0
65
- }
66
-
67
- // Unrolled per maxIdx branch (was a QUAT_IDX[] lookup + generic loop over `indices`) -- this runs
68
- // once per entity/player per snapshot, client-side, the hottest per-frame decode path, so the extra
69
- // array indirection through QUAT_IDX plus a 3-iteration loop with a data-dependent out-index write
70
- // is worth trading for 4 flat, branch-predictable unpacks. Each branch reads the same three 10-bit
71
- // fields off `packed` in the same bit order (most-significant first, j=2..0) as the original loop,
72
- // just with the literal QUAT_IDX[maxIdx] destination slots inlined instead of indexed.
73
- export function unpackQuat(packed, out) {
74
- const maxIdx = (packed >>> 30) & 0x3
75
- const c2 = (packed & 0x3FF) / QSCALE - Math.SQRT1_2; packed = packed >>> 10
76
- const c1 = (packed & 0x3FF) / QSCALE - Math.SQRT1_2; packed = packed >>> 10
77
- const c0 = (packed & 0x3FF) / QSCALE - Math.SQRT1_2
78
- const sumSq = c0 * c0 + c1 * c1 + c2 * c2
79
- const m = Math.sqrt(Math.max(0, 1 - sumSq))
80
- switch (maxIdx) {
81
- case 0: out[1] = c0; out[2] = c1; out[3] = c2; out[0] = m; break
82
- case 1: out[0] = c0; out[2] = c1; out[3] = c2; out[1] = m; break
83
- case 2: out[0] = c0; out[1] = c1; out[3] = c2; out[2] = m; break
84
- default: out[0] = c0; out[1] = c1; out[2] = c2; out[3] = m; break
85
- }
86
- return out
87
- }
88
14
 
89
15
  // p[12]: 8 bits pitch (range [-pi/2,pi/2]) + 8 bits yaw (range [0,2pi)) packed into one uint16
90
16
  // enc layout (unchanged JS-value slots kept native; numeric fixed fields moved into enc[1] as a
@@ -0,0 +1,337 @@
1
+ // Vehicle constraint methods for PhysicsWorld (World.js): real Jolt VehicleConstraint
2
+ // (WheeledVehicleController + TrackedVehicleController). Split out as World.js's largest
3
+ // self-contained block -- every method here only touches PhysicsWorld's own class-level state
4
+ // (this.physicsSystem/this._getBody/this.bodyInterface/this._vehicles/this.Jolt) through the same
5
+ // accessors the rest of that class uses, so mixing these onto PhysicsWorld.prototype changes
6
+ // nothing about the public API or the WASM-interop discipline documented inline below.
7
+ //
8
+ // WASM-CRASH-AVOIDANCE RULES THIS FILE MUST NOT VIOLATE (see each method's own comment for the
9
+ // live-reproduced finding that established the rule):
10
+ // - removeVehicle: do NOT J.destroy(v.constraint) or J.destroy(v.tester) after RemoveConstraint
11
+ // has run -- RemoveConstraint's own destructor chain already drops both refs; destroying either
12
+ // afterward is a real use-after-free WASM trap.
13
+ // - createTrackedVehicle: do NOT J.destroy() a VehicleTrackSettings handle returned by
14
+ // get_mTracks() after set_mTracks() has copied it in -- these are copy-semantics value handles
15
+ // with no Jolt-side ref to release, unlike the constraint/tester RefTarget objects above.
16
+ // - setVehicleDriverInput/setTrackedVehicleDriverInput: a sleeping chassis silently ignores driver
17
+ // input (no error) unless explicitly reactivated first.
18
+
19
+ const LAYER_DYNAMIC = 1
20
+
21
+ export function installVehiclePhysics(PhysicsWorld) {
22
+ const proto = PhysicsWorld.prototype
23
+
24
+ // Real Jolt VehicleConstraint (WheeledVehicleController) -- vehicles-jolt-wheeled-constraints-app.
25
+ // AGENTS.md's ragdoll-brawl-arena-no-joint-api caveat (2026-07-07) said "no joint/constraint
26
+ // primitive anywhere in World.js" -- that was already stale by the time addConstraint (TwoBody
27
+ // fixed/point/distance/hinge) landed, and a live probe against the ACTUAL jolt-physics 1.1.0
28
+ // WASM build this session (both wasm and wasm-compat -- the .d.ts ships with zero Vehicle* entries,
29
+ // a real type-definition gap, but the compiled WASM module itself exports the full upstream Jolt
30
+ // VehicleConstraint/WheeledVehicleController/TrackedVehicleController surface, ~280 distinct
31
+ // Vehicle*-prefixed bindings) confirms it IS available. A minimal real vehicle (box chassis body +
32
+ // 4 WheelSettingsWV + one rear-wheel-drive VehicleDifferentialSettings + VehicleCollisionTesterRay)
33
+ // was built, stepped 120 real ticks, and drove forward ~4.65m under sustained throttle -- see
34
+ // AGENTS.md audit log entry for this session for the full probe transcript. The single sharpest
35
+ // real gotcha found: WheeledVehicleControllerSettings.mDifferentials defaults to an EMPTY array --
36
+ // with zero differentials configured, engine torque never reaches ANY wheel (a silent no-op: the
37
+ // constraint builds fine, the wheels spin at 0 RPM, the chassis never moves) -- at least one
38
+ // differential entry (mLeftWheel/mRightWheel wheel INDEXES into mWheels, matching the order wheels
39
+ // were push_back'd) is mandatory for a driveable vehicle, not merely a tuning nicety.
40
+ //
41
+ // createWheeledVehicle(chassisBodyId, wheelDefs, opts): wheelDefs is an array of
42
+ // {position:[x,y,z] (chassis-local), radius, width, suspensionMin, suspensionMax, maxSteerAngle,
43
+ // maxBrakeTorque, maxHandBrakeTorque, steer:bool, drive:bool}. opts.up/opts.forward default to
44
+ // [0,1,0]/[0,0,1] (matches this project's Z-forward convention already used by player rotation/yaw
45
+ // elsewhere in this file's caller). Returns a vehicleId (opaque, keyed into this._vehicles) or null.
46
+ proto.createWheeledVehicle = function (chassisBodyId, wheelDefs, opts = {}) {
47
+ if (!this.physicsSystem) return null
48
+ const chassis = this._getBody(chassisBodyId); if (!chassis) return null
49
+ if (!Array.isArray(wheelDefs) || wheelDefs.length === 0) return null
50
+ const J = this.Jolt
51
+ let vcs = null, wheelSettingsList = [], constraint = null, tester = null, stepListener = null
52
+ try {
53
+ vcs = new J.VehicleConstraintSettings()
54
+ const up = opts.up || [0, 1, 0], fwd = opts.forward || [0, 0, 1]
55
+ vcs.mUp = new J.Vec3(up[0], up[1], up[2])
56
+ vcs.mForward = new J.Vec3(fwd[0], fwd[1], fwd[2])
57
+ if (opts.maxPitchRollAngle != null) vcs.mMaxPitchRollAngle = opts.maxPitchRollAngle
58
+
59
+ const wheelsArr = vcs.mWheels
60
+ const driveIdxL = [], driveIdxR = []
61
+ for (let i = 0; i < wheelDefs.length; i++) {
62
+ const w = wheelDefs[i] || {}
63
+ const ws = new J.WheelSettingsWV()
64
+ const p = w.position || [0, 0, 0]
65
+ ws.mPosition = new J.Vec3(p[0], p[1], p[2])
66
+ if (w.suspensionDirection) { const sd = w.suspensionDirection; ws.mSuspensionDirection = new J.Vec3(sd[0], sd[1], sd[2]) }
67
+ ws.mRadius = w.radius ?? 0.35
68
+ ws.mWidth = w.width ?? 0.25
69
+ ws.mSuspensionMinLength = w.suspensionMin ?? 0.3
70
+ ws.mSuspensionMaxLength = w.suspensionMax ?? 0.5
71
+ ws.mMaxSteerAngle = w.steer ? (w.maxSteerAngle ?? 0.6) : 0
72
+ ws.mMaxBrakeTorque = w.maxBrakeTorque ?? 1500
73
+ ws.mMaxHandBrakeTorque = w.maxHandBrakeTorque ?? 0
74
+ wheelSettingsList.push(ws)
75
+ wheelsArr.push_back(ws)
76
+ // Left/right classified by local X sign (chassis-local wheel position) -- matches the probe's
77
+ // convention and every real 4-wheel layout (negative X = left, positive X = right).
78
+ if (w.drive) { if (p[0] < 0) driveIdxL.push(i); else driveIdxR.push(i) }
79
+ }
80
+ vcs.mWheels = wheelsArr
81
+
82
+ const controllerSettings = new J.WheeledVehicleControllerSettings()
83
+ const diffs = controllerSettings.mDifferentials
84
+ // opts.differentials lets a caller fully hand-author the diff list (tracked-style split-per-axle
85
+ // setups); default is one differential per drive axle pairing left/right drive wheels 1:1 by
86
+ // position order (covers the common RWD/FWD/AWD single-or-dual-axle case with zero caller config).
87
+ if (Array.isArray(opts.differentials) && opts.differentials.length) {
88
+ for (const d of opts.differentials) {
89
+ const vd = new J.VehicleDifferentialSettings()
90
+ vd.mLeftWheel = d.leftWheel ?? -1; vd.mRightWheel = d.rightWheel ?? -1
91
+ if (d.differentialRatio != null) vd.mDifferentialRatio = d.differentialRatio
92
+ if (d.limitedSlipRatio != null) vd.mLimitedSlipRatio = d.limitedSlipRatio
93
+ if (d.engineTorqueRatio != null) vd.mEngineTorqueRatio = d.engineTorqueRatio
94
+ diffs.push_back(vd)
95
+ }
96
+ } else {
97
+ const n = Math.max(driveIdxL.length, driveIdxR.length)
98
+ for (let i = 0; i < n; i++) {
99
+ const vd = new J.VehicleDifferentialSettings()
100
+ vd.mLeftWheel = driveIdxL[i] ?? -1; vd.mRightWheel = driveIdxR[i] ?? -1
101
+ vd.mEngineTorqueRatio = 1 / n
102
+ diffs.push_back(vd)
103
+ }
104
+ }
105
+ controllerSettings.mDifferentials = diffs
106
+ if (opts.engine) {
107
+ if (opts.engine.maxTorque != null) controllerSettings.mEngine.mMaxTorque = opts.engine.maxTorque
108
+ if (opts.engine.maxRPM != null) controllerSettings.mEngine.mMaxRPM = opts.engine.maxRPM
109
+ if (opts.engine.minRPM != null) controllerSettings.mEngine.mMinRPM = opts.engine.minRPM
110
+ }
111
+ vcs.mController = controllerSettings
112
+
113
+ constraint = new J.VehicleConstraint(chassis, vcs)
114
+ tester = new J.VehicleCollisionTesterRay(LAYER_DYNAMIC, new J.Vec3(up[0], up[1], up[2]))
115
+ constraint.SetVehicleCollisionTester(tester)
116
+ this.physicsSystem.AddConstraint(constraint)
117
+ stepListener = new J.VehicleConstraintStepListener(constraint)
118
+ this.physicsSystem.AddStepListener(stepListener)
119
+
120
+ const controller = J.castObject(constraint.GetController(), J.WheeledVehicleController)
121
+ const vid = (this._nextVehicleId = (this._nextVehicleId || 0) + 1)
122
+ if (!this._vehicles) this._vehicles = new Map()
123
+ this._vehicles.set(vid, { constraint, controller, tester, stepListener, chassisBodyId, wheelCount: wheelDefs.length })
124
+ return vid
125
+ } catch (e) {
126
+ console.error('[physics] createWheeledVehicle failed:', e?.message || e)
127
+ return null
128
+ } finally { if (vcs) J.destroy(vcs) }
129
+ }
130
+
131
+ // driverInput: forward/right in -1..1, brake/handbrake in 0..1 -- straight passthrough to Jolt's own
132
+ // WheeledVehicleController.SetDriverInput, which internally handles engine RPM/torque/transmission
133
+ // gear-shift simulation every physics step via the registered VehicleConstraintStepListener.
134
+ //
135
+ // REAL BUG independently found+fixed by two sibling sessions the same day (vehicles-tracked-controller-
136
+ // follow-up and vehicles-wheel-visual-wire-sync, both live-reproduced via a real booted server +
137
+ // Playwright drive test): a parked vehicle that settles onto the ground goes to sleep via Jolt's own
138
+ // island-based sleep logic (this project's own aggressive World.js init() sleep tuning --
139
+ // mTimeBeforeSleep=0.25s -- makes a resting chassis fall asleep FAST) same as any other dynamic body,
140
+ // and SetDriverInput alone does NOT wake a sleeping body -- driver input reaches the controller every
141
+ // tick (confirmed via a call-count probe) but a sleeping VehicleConstraint's step listener still runs
142
+ // against an inactive body and produces zero motion, silently -- no error, no thrown exception, the
143
+ // constraint simply has nothing to move. A real player mounting a vehicle that already settled to sleep
144
+ // before they pressed a drive key (the overwhelmingly common case -- a vehicle sits parked for more
145
+ // than ~0.25s before anyone drives it) would find it completely unresponsive. Fix: wake the body on any
146
+ // driver-input call carrying real forward/right/handbrake input -- brake alone is intentionally
147
+ // excluded, since braking an already-sleeping/at-rest vehicle has nothing to do and must not fight the
148
+ // sleep optimization by re-waking it every tick a parked driver holds the brake. Gated on isActive()
149
+ // first so an already-awake vehicle (the common case, mid-drive) pays zero extra native call per tick.
150
+ proto.setVehicleDriverInput = function (vehicleId, forward, right, brake = 0, handbrake = 0) {
151
+ const v = this._vehicles && this._vehicles.get(vehicleId); if (!v) return false
152
+ if ((forward || right || handbrake) && this.bodyInterface.ActivateBody) {
153
+ const chassis = this._getBody(v.chassisBodyId)
154
+ if (chassis && !chassis.IsActive()) this.bodyInterface.ActivateBody(chassis.GetID())
155
+ }
156
+ v.controller.SetDriverInput(forward, right, brake, handbrake)
157
+ return true
158
+ }
159
+
160
+ proto.getVehicleWheelTransform = function (vehicleId, wheelIndex) {
161
+ const v = this._vehicles && this._vehicles.get(vehicleId); if (!v) return null
162
+ const J = this.Jolt
163
+ // (bodyRotation, wheelRotationAxis) -- GetWheelWorldTransform's 3rd param is the local rotation
164
+ // axis wheels spin about; [1,0,0] matches the WheelSettingsWV convention (wheel spin axis = local X).
165
+ const t = v.constraint.GetWheelWorldTransform(wheelIndex, new J.Vec3(1, 0, 0), new J.Vec3(0, 1, 0))
166
+ const pos = t.GetTranslation(), rot = t.GetQuaternion()
167
+ const out = { position: [pos.GetX(), pos.GetY(), pos.GetZ()], rotation: [rot.GetX(), rot.GetY(), rot.GetZ(), rot.GetW()] }
168
+ J.destroy(t)
169
+ return out
170
+ }
171
+ proto.getVehicleWheelSpeed = function (vehicleId, wheelIndex) {
172
+ const v = this._vehicles && this._vehicles.get(vehicleId); if (!v) return 0
173
+ const w = v.constraint.GetWheel(wheelIndex)
174
+ return w ? w.GetAngularVelocity() : 0
175
+ }
176
+ proto.isVehicleWheelGrounded = function (vehicleId, wheelIndex) {
177
+ const v = this._vehicles && this._vehicles.get(vehicleId); if (!v) return false
178
+ const w = v.constraint.GetWheel(wheelIndex)
179
+ return w ? w.HasContact() : false
180
+ }
181
+ proto.removeVehicle = function (vehicleId) {
182
+ const v = this._vehicles && this._vehicles.get(vehicleId); if (!v || !this.physicsSystem) return false
183
+ const J = this.Jolt
184
+ try {
185
+ this.physicsSystem.RemoveStepListener(v.stepListener)
186
+ J.destroy(v.stepListener)
187
+ // v.constraint and v.tester are BOTH Jolt-side ref-counted objects (RefTarget -- same family as
188
+ // the Shape.AddRef/Release convention already documented in addStaticTrimeshAsync). RemoveConstraint's
189
+ // own native destructor chain drops the constraint's ref (which in turn drops its ref on the
190
+ // collision tester it holds) -- live-witnessed: a manual J.destroy(v.constraint) or
191
+ // J.destroy(v.tester) AFTER RemoveConstraint has already run is a real use-after-free ("memory
192
+ // access out of bounds" / "table index is out of bounds" WASM traps, not a benign double-free
193
+ // warning). Do NOT call J.destroy on either -- RemoveConstraint alone is the complete, correct
194
+ // teardown for both.
195
+ this.physicsSystem.RemoveConstraint(v.constraint)
196
+ } catch (e) { console.error('[physics] removeVehicle cleanup error:', e?.message || e) }
197
+ this._vehicles.delete(vehicleId)
198
+ return true
199
+ }
200
+
201
+ // Real Jolt TrackedVehicleController -- vehicles-tracked-controller-follow-up, sibling to
202
+ // createWheeledVehicle above. jolt-physics 1.1.0's compiled WASM build was probed live this session
203
+ // (Object.keys(Jolt) against a real `import('jolt-physics/wasm-compat')`) and confirmed to export the
204
+ // full TrackedVehicleController/TrackedVehicleControllerSettings/VehicleTrack/VehicleTrackSettings
205
+ // surface, same undocumented-in-.d.ts situation as the wheeled case.
206
+ //
207
+ // MATERIAL DIFFERENCE from the wheeled settings shape (the row's own instruction to audit before
208
+ // assuming 1:1 parity): TrackedVehicleControllerSettings.mTracks is NOT a push_back-able vector like
209
+ // WheeledVehicleControllerSettings.mDifferentials -- it is a fixed C++ array of exactly 2
210
+ // VehicleTrackSettings (upstream Jolt: `VehicleTrackSettings mTracks[2]`), and VehicleTrackSettings
211
+ // itself has no public constructor (`new J.VehicleTrackSettings()` throws "no constructor in IDL").
212
+ // The embind wrapper exposes this as get_mTracks(index)/set_mTracks(index, value) COPY-semantics
213
+ // accessors (live-probed): get_mTracks(0) returns an independent mutable copy of track 0, mutating
214
+ // that copy does NOT affect get_mTracks(1)'s copy, and the mutated copy must be written back via
215
+ // set_mTracks(index, track) to take effect -- get-mutate-set, not get-and-keep-reference. Track index
216
+ // 0 = left, 1 = right (matches Jolt's own sample/doc convention and this wrapper's driveIdx classification
217
+ // below). Each track's mWheels IS a real push_back-able vector of wheel INDEXES (into mWheels on the
218
+ // parent VehicleConstraintSettings, same indexing convention as the wheeled mDifferentials wheel refs).
219
+ //
220
+ // wheelDefs: array of {position:[x,y,z] chassis-local, radius, width, suspensionMin, suspensionMax,
221
+ // maxBrakeTorque, side:'left'|'right', driven:bool}. Wheels use WheelSettingsTV (Tracked Vehicle) not
222
+ // WheelSettingsWV (Wheeled) -- no steer angle field (tracks steer via differential left/right ratio,
223
+ // not wheel-turn angle). At least one wheel per side must have driven:true set as that side's
224
+ // mDrivenWheel (the wheel index the engine torque/track tension is actually applied through) --
225
+ // otherwise, mirroring the wheeled mDifferentials-empty gotcha, a side with no explicit driven wheel
226
+ // silently defaults mDrivenWheel to 0 (this wrapper's own first-wheel-of-side fallback below), so a
227
+ // caller SHOULD mark one wheel per side driven:true rather than relying on the fallback.
228
+ proto.createTrackedVehicle = function (chassisBodyId, wheelDefs, opts = {}) {
229
+ if (!this.physicsSystem) return null
230
+ const chassis = this._getBody(chassisBodyId); if (!chassis) return null
231
+ if (!Array.isArray(wheelDefs) || wheelDefs.length === 0) return null
232
+ const J = this.Jolt
233
+ let vcs = null, wheelSettingsList = [], constraint = null, tester = null, stepListener = null
234
+ try {
235
+ vcs = new J.VehicleConstraintSettings()
236
+ const up = opts.up || [0, 1, 0], fwd = opts.forward || [0, 0, 1]
237
+ vcs.mUp = new J.Vec3(up[0], up[1], up[2])
238
+ vcs.mForward = new J.Vec3(fwd[0], fwd[1], fwd[2])
239
+ if (opts.maxPitchRollAngle != null) vcs.mMaxPitchRollAngle = opts.maxPitchRollAngle
240
+
241
+ const wheelsArr = vcs.mWheels
242
+ const leftIdx = [], rightIdx = [], leftDrivenIdx = [], rightDrivenIdx = []
243
+ for (let i = 0; i < wheelDefs.length; i++) {
244
+ const w = wheelDefs[i] || {}
245
+ const ws = new J.WheelSettingsTV()
246
+ const p = w.position || [0, 0, 0]
247
+ ws.mPosition = new J.Vec3(p[0], p[1], p[2])
248
+ if (w.suspensionDirection) { const sd = w.suspensionDirection; ws.mSuspensionDirection = new J.Vec3(sd[0], sd[1], sd[2]) }
249
+ ws.mRadius = w.radius ?? 0.35
250
+ ws.mWidth = w.width ?? 0.4
251
+ ws.mSuspensionMinLength = w.suspensionMin ?? 0.3
252
+ ws.mSuspensionMaxLength = w.suspensionMax ?? 0.5
253
+ if (w.maxBrakeTorque != null) ws.mMaxBrakeTorque = w.maxBrakeTorque
254
+ wheelSettingsList.push(ws)
255
+ wheelsArr.push_back(ws)
256
+ // side classified explicitly (w.side) if given, else by local-X sign (negative = left, matching
257
+ // createWheeledVehicle's own left/right convention) -- same fallback discipline as the wheeled case.
258
+ const side = w.side || (p[0] < 0 ? 'left' : 'right')
259
+ if (side === 'left') { leftIdx.push(i); if (w.driven) leftDrivenIdx.push(i) }
260
+ else { rightIdx.push(i); if (w.driven) rightDrivenIdx.push(i) }
261
+ }
262
+ vcs.mWheels = wheelsArr
263
+
264
+ const controllerSettings = new J.TrackedVehicleControllerSettings()
265
+ if (opts.engine) {
266
+ if (opts.engine.maxTorque != null) controllerSettings.mEngine.mMaxTorque = opts.engine.maxTorque
267
+ if (opts.engine.maxRPM != null) controllerSettings.mEngine.mMaxRPM = opts.engine.maxRPM
268
+ if (opts.engine.minRPM != null) controllerSettings.mEngine.mMinRPM = opts.engine.minRPM
269
+ }
270
+ // opts.tracks lets a caller fully hand-author both tracks (explicit wheel-index lists / driven
271
+ // wheel / brake torque), matching createWheeledVehicle's opts.differentials override pattern.
272
+ // Default: classify by side above, driven wheel = first driven:true wheel on that side, or the
273
+ // side's first wheel if none was marked driven (fallback documented in the header comment).
274
+ const buildTrack = (trackIndex, idxList, drivenList, override) => {
275
+ const t = controllerSettings.get_mTracks(trackIndex)
276
+ const wv = t.mWheels
277
+ const list = (override && Array.isArray(override.wheels)) ? override.wheels : idxList
278
+ for (const wi of list) wv.push_back(wi)
279
+ t.mWheels = wv
280
+ const drivenWheel = override && override.drivenWheel != null ? override.drivenWheel : (drivenList[0] ?? idxList[0] ?? 0)
281
+ t.mDrivenWheel = drivenWheel
282
+ if ((override && override.maxBrakeTorque != null)) t.mMaxBrakeTorque = override.maxBrakeTorque
283
+ if ((override && override.differentialRatio != null)) t.mDifferentialRatio = override.differentialRatio
284
+ return t
285
+ }
286
+ const leftOverride = opts.tracks && opts.tracks.left
287
+ const rightOverride = opts.tracks && opts.tracks.right
288
+ const leftTrack = buildTrack(0, leftIdx, leftDrivenIdx, leftOverride)
289
+ controllerSettings.set_mTracks(0, leftTrack)
290
+ const rightTrack = buildTrack(1, rightIdx, rightDrivenIdx, rightOverride)
291
+ controllerSettings.set_mTracks(1, rightTrack)
292
+ // Deliberately NOT calling J.destroy(leftTrack)/J.destroy(rightTrack) here -- live-probed: destroying
293
+ // either track handle AFTER set_mTracks has copied it in corrupts Jolt's WASM state, surfacing as a
294
+ // "memory access out of bounds" RuntimeError on the NEXT physicsSystem.Step() call (not immediately,
295
+ // making it easy to misattribute) -- same failure-mode CLASS as the documented trimesh-ShapeResult
296
+ // and vehicle-constraint-teardown use-after-free lessons above, but here the correct fix is the
297
+ // opposite of those: never destroy at all rather than destroy-after-use, since get_mTracks(index)
298
+ // copy semantics mean these two small JS wrapper handles have no Jolt-side ref to release (unlike
299
+ // the constraint/tester RefTarget objects, which DO need RemoveConstraint).
300
+ vcs.mController = controllerSettings
301
+
302
+ constraint = new J.VehicleConstraint(chassis, vcs)
303
+ tester = new J.VehicleCollisionTesterRay(LAYER_DYNAMIC, new J.Vec3(up[0], up[1], up[2]))
304
+ constraint.SetVehicleCollisionTester(tester)
305
+ this.physicsSystem.AddConstraint(constraint)
306
+ stepListener = new J.VehicleConstraintStepListener(constraint)
307
+ this.physicsSystem.AddStepListener(stepListener)
308
+
309
+ const controller = J.castObject(constraint.GetController(), J.TrackedVehicleController)
310
+ const vid = (this._nextVehicleId = (this._nextVehicleId || 0) + 1)
311
+ if (!this._vehicles) this._vehicles = new Map()
312
+ this._vehicles.set(vid, { constraint, controller, tester, stepListener, chassisBodyId, wheelCount: wheelDefs.length, tracked: true })
313
+ return vid
314
+ } catch (e) {
315
+ console.error('[physics] createTrackedVehicle failed:', e?.message || e)
316
+ return null
317
+ } finally { if (vcs) J.destroy(vcs) }
318
+ }
319
+
320
+ // driverInput for a tracked vehicle: forward in -1..1 (throttle/reverse), leftRatio/rightRatio in
321
+ // -1..1 (per-track power ratio -- equal ratios drive straight, differing ratios steer/pivot, matching
322
+ // Jolt's own TrackedVehicleController::SetDriverInput(forward, leftRatio, rightRatio, brake) signature
323
+ // live-confirmed via the probe this session), brake in 0..1. Deliberately a DIFFERENT shape from
324
+ // setVehicleDriverInput's forward/right/brake/handbrake (the row's own instruction: tracks steer via
325
+ // per-side power ratio, not a wheel-turn angle, so a shared signature would be misleading).
326
+ proto.setTrackedVehicleDriverInput = function (vehicleId, forward, leftRatio, rightRatio, brake = 0) {
327
+ const v = this._vehicles && this._vehicles.get(vehicleId); if (!v || !v.tracked) return false
328
+ // Same sleeping-body wake fix as setVehicleDriverInput above -- see that method's header comment
329
+ // for the full live-reproduced finding (a settled/sleeping vehicle ignores driver input silently
330
+ // with zero error until its body is explicitly reactivated).
331
+ if ((forward || leftRatio || rightRatio || brake) && this.bodyInterface?.ActivateBody) {
332
+ const b = this._getBody(v.chassisBodyId); if (b) this.bodyInterface.ActivateBody(b.GetID())
333
+ }
334
+ v.controller.SetDriverInput(forward, leftRatio, rightRatio, brake)
335
+ return true
336
+ }
337
+ }