@irtio/runtime 0.8.0 → 0.10.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.
@@ -250,7 +250,7 @@ function createCallProxy(core, clientId) {
250
250
  }
251
251
  );
252
252
  }
253
- function createBroadcastProxy(core) {
253
+ function createBroadcastProxy(core, except) {
254
254
  return new Proxy(
255
255
  {},
256
256
  {
@@ -259,13 +259,19 @@ function createBroadcastProxy(core) {
259
259
  return (params) => {
260
260
  const desc = descFor(core, prop);
261
261
  for (const entry of core.clients.values()) {
262
- if (entry.connected) sendCall(core, entry.clientId, desc, params ?? {});
262
+ if (!entry.connected) continue;
263
+ if (except?.has(entry.clientId)) continue;
264
+ sendCall(core, entry.clientId, desc, params ?? {});
263
265
  }
264
266
  };
265
267
  }
266
268
  }
267
269
  );
268
270
  }
271
+ function createBroadcastExceptProxy(core, except) {
272
+ const ids = typeof except === "string" ? [except] : except;
273
+ return createBroadcastProxy(core, new Set(ids));
274
+ }
269
275
  function handleReply(core, clientId, payload) {
270
276
  let reply;
271
277
  try {
@@ -956,6 +962,7 @@ function decodeMatterBodies(bytes) {
956
962
  function bodyKey(collection, id) {
957
963
  return `${collection}\0${id}`;
958
964
  }
965
+ var PLANAR_CHANNELS = /* @__PURE__ */ new Set(["x", "y", "qz", "qw", "vx", "vy", "wz"]);
959
966
  var MatterRuntime = class {
960
967
  engineKind = "matter2d";
961
968
  /** rapier3d only; present so both runtimes satisfy one internal shape. */
@@ -981,6 +988,15 @@ var MatterRuntime = class {
981
988
  restore;
982
989
  stepMs;
983
990
  sleepSynced = /* @__PURE__ */ new Set();
991
+ /**
992
+ * The plain record object each body was built from. See `PhysicsRuntime.sourceRecords`: `add()`
993
+ * installs a new record object, so an id removed and re-added between two reconciles (a pooled
994
+ * projectile) is identifiable by identity, and is the one case where a live body has to be
995
+ * rebuilt so the new row's spawn pose and velocity are honoured.
996
+ */
997
+ sourceRecords = /* @__PURE__ */ new Map();
998
+ /** `collection.field` pairs already warned about a channel a plane cannot hold. */
999
+ warnedChannels = /* @__PURE__ */ new Set();
984
1000
  constructor(core, matter, options) {
985
1001
  const config = core.definition.config.physics;
986
1002
  if (!config) throw new Error("MatterRuntime: the room config declares no physics");
@@ -1050,6 +1066,7 @@ var MatterRuntime = class {
1050
1066
  } else {
1051
1067
  this.applyRecordToBody(desc, spec.body, record);
1052
1068
  }
1069
+ this.sourceRecords.set(key, record);
1053
1070
  return spec.body;
1054
1071
  }
1055
1072
  // ---- M6 lane F: rewind ----
@@ -1062,7 +1079,8 @@ var MatterRuntime = class {
1062
1079
  const plainColl = plainEntity(this.core.plain, desc.name);
1063
1080
  for (const id of plainColl.ids()) {
1064
1081
  const attached = this.bodies.get(bodyKey(desc.name, id));
1065
- if (attached) fn(desc.name, id, attached.body);
1082
+ if (attached)
1083
+ fn(desc.name, id, attached.body, plainColl.get(id));
1066
1084
  }
1067
1085
  }
1068
1086
  }
@@ -1089,6 +1107,17 @@ var MatterRuntime = class {
1089
1107
  for (const [channel, field] of physics.channels) {
1090
1108
  const raw = record[field];
1091
1109
  if (typeof raw !== "number") continue;
1110
+ if (raw !== 0 && !PLANAR_CHANNELS.has(channel)) {
1111
+ const key = `${desc.name}.${field}`;
1112
+ if (!this.warnedChannels.has(key)) {
1113
+ this.warnedChannels.add(key);
1114
+ this.core.log(
1115
+ "warn",
1116
+ `irtio: ${desc.name}.${field} maps body channel ${channel}, which a matter2d room cannot hold (the world is a plane); the ${raw} it was added with was dropped`
1117
+ );
1118
+ }
1119
+ continue;
1120
+ }
1092
1121
  applyChannel2d(channel, raw, t);
1093
1122
  }
1094
1123
  this.applyState(body, {
@@ -1110,8 +1139,21 @@ var MatterRuntime = class {
1110
1139
  for (const id of coll.ids()) {
1111
1140
  const key = bodyKey(desc.name, id);
1112
1141
  live.add(key);
1113
- if (this.bodies.has(key)) continue;
1114
1142
  const record = coll.get(id);
1143
+ const existing = this.bodies.get(key);
1144
+ if (existing) {
1145
+ if (record === void 0) continue;
1146
+ const source = this.sourceRecords.get(key);
1147
+ if (source === void 0) {
1148
+ this.sourceRecords.set(key, record);
1149
+ continue;
1150
+ }
1151
+ if (source === record) continue;
1152
+ this.bodies.delete(key);
1153
+ this.sleepSynced.delete(key);
1154
+ this.sourceRecords.delete(key);
1155
+ this.matter.Composite.remove(this.engine.world, [existing.body, ...existing.constraints]);
1156
+ }
1115
1157
  if (record !== void 0) this.create(desc, id, record);
1116
1158
  }
1117
1159
  }
@@ -1119,6 +1161,7 @@ var MatterRuntime = class {
1119
1161
  if (live.has(key)) continue;
1120
1162
  this.bodies.delete(key);
1121
1163
  this.sleepSynced.delete(key);
1164
+ this.sourceRecords.delete(key);
1122
1165
  this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
1123
1166
  }
1124
1167
  }
@@ -1349,6 +1392,19 @@ var PhysicsRuntime = class {
1349
1392
  * world rebuilt from schema state would wake it with a kick). Found by the drift check.
1350
1393
  */
1351
1394
  sleepSynced = /* @__PURE__ */ new Set();
1395
+ /**
1396
+ * The plain record object each body was built from, by body key. `add()` always installs a
1397
+ * **new** record object (`normalizeRecord` builds one), so an id that is removed and re-added
1398
+ * between two reconciles — the pooled-projectile shape — is identifiable by identity alone,
1399
+ * which is the only way to see it from here: both edits happened before this module looked.
1400
+ * Without it the old body kept its pose and velocity and the new row's spawn values were lost.
1401
+ *
1402
+ * Unknown means adopt, never rebuild: a world restored from a hibernation blob has bodies with
1403
+ * no recorded source, and rebuilding those would throw away exactly what the blob preserved.
1404
+ */
1405
+ sourceRecords = /* @__PURE__ */ new Map();
1406
+ /** `collection.field` pairs already warned about a channel value the engine would not take. */
1407
+ warnedChannels = /* @__PURE__ */ new Set();
1352
1408
  constructor(core, rapier, options) {
1353
1409
  const config = core.definition.config.physics;
1354
1410
  if (!config) throw new Error("PhysicsRuntime: the room config declares no physics");
@@ -1423,10 +1479,50 @@ var PhysicsRuntime = class {
1423
1479
  }
1424
1480
  const body = this.world.createRigidBody(spec.body);
1425
1481
  for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
1482
+ this.warnLockedChannels(desc, spec, record);
1426
1483
  this.applyRecordToBody(desc, body, record);
1427
1484
  this.bodies.set(bodyKey2(desc.name, id), body);
1485
+ this.sourceRecords.set(bodyKey2(desc.name, id), record);
1428
1486
  return body;
1429
1487
  }
1488
+ /**
1489
+ * A spawn value the engine will not take, said once per field.
1490
+ *
1491
+ * Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
1492
+ * accepts one on a fixed body and then ignores it — both read back as "the field was applied"
1493
+ * and behave as if it never was. The desc says which, before the body exists, so the check is
1494
+ * on the desc: the usual case is the 2D-in-3D recipe (`enabledTranslations(true, true, false)`)
1495
+ * meeting a row that spawns with a `vz`. Named by field, because the field is what the author
1496
+ * wrote and what they will grep for.
1497
+ */
1498
+ warnLockedChannels(desc, spec, record) {
1499
+ const physics = desc.physics;
1500
+ if (!physics) return;
1501
+ const b = spec.body;
1502
+ const frozen = b.status !== 0;
1503
+ const enabled = {
1504
+ vx: b.translationsEnabledX,
1505
+ vy: b.translationsEnabledY,
1506
+ vz: b.translationsEnabledZ,
1507
+ wx: b.rotationsEnabledX,
1508
+ wy: b.rotationsEnabledY,
1509
+ wz: b.rotationsEnabledZ
1510
+ };
1511
+ for (const [channel, field] of physics.channels) {
1512
+ const allowed = enabled[channel];
1513
+ if (allowed === void 0) continue;
1514
+ const raw = record[field];
1515
+ if (typeof raw !== "number" || raw === 0) continue;
1516
+ if (allowed && !frozen) continue;
1517
+ const key = `${desc.name}.${field}`;
1518
+ if (this.warnedChannels.has(key)) continue;
1519
+ this.warnedChannels.add(key);
1520
+ this.core.log(
1521
+ "warn",
1522
+ `irtio: ${desc.name}.${field} (body channel ${channel}) was added as ${raw}, but ` + (frozen ? `physics.bodies.${desc.name} builds a non-dynamic body, which never moves under it` : `that degree of freedom is disabled by physics.bodies.${desc.name}`) + " \u2014 the value was dropped"
1523
+ );
1524
+ }
1525
+ }
1430
1526
  // ---- M6 lane F: rewind ----
1431
1527
  /**
1432
1528
  * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
@@ -1439,7 +1535,7 @@ var PhysicsRuntime = class {
1439
1535
  const plainColl = plainEntity(this.core.plain, desc.name);
1440
1536
  for (const id of plainColl.ids()) {
1441
1537
  const body = this.bodies.get(bodyKey2(desc.name, id));
1442
- if (body) fn(desc.name, id, body);
1538
+ if (body) fn(desc.name, id, body, plainColl.get(id));
1443
1539
  }
1444
1540
  }
1445
1541
  }
@@ -1522,8 +1618,21 @@ var PhysicsRuntime = class {
1522
1618
  for (const id of coll.ids()) {
1523
1619
  const key = bodyKey2(desc.name, id);
1524
1620
  live.add(key);
1525
- if (this.bodies.has(key)) continue;
1526
1621
  const record = coll.get(id);
1622
+ const existing = this.bodies.get(key);
1623
+ if (existing) {
1624
+ if (record === void 0) continue;
1625
+ const source = this.sourceRecords.get(key);
1626
+ if (source === void 0) {
1627
+ this.sourceRecords.set(key, record);
1628
+ continue;
1629
+ }
1630
+ if (source === record) continue;
1631
+ this.bodies.delete(key);
1632
+ this.sleepSynced.delete(key);
1633
+ this.sourceRecords.delete(key);
1634
+ this.world.removeRigidBody(existing);
1635
+ }
1527
1636
  if (record !== void 0) this.create(desc, id, record);
1528
1637
  }
1529
1638
  }
@@ -1531,6 +1640,7 @@ var PhysicsRuntime = class {
1531
1640
  if (live.has(key)) continue;
1532
1641
  this.bodies.delete(key);
1533
1642
  this.sleepSynced.delete(key);
1643
+ this.sourceRecords.delete(key);
1534
1644
  this.world.removeRigidBody(body);
1535
1645
  }
1536
1646
  }
@@ -1630,6 +1740,14 @@ function channelValue(channel, t, r, v, w) {
1630
1740
  }
1631
1741
 
1632
1742
  // src/core/random.ts
1743
+ function seedFromRoomId(roomId) {
1744
+ let h = 2166136261;
1745
+ for (let i = 0; i < roomId.length; i++) {
1746
+ h ^= roomId.charCodeAt(i);
1747
+ h = Math.imul(h, 16777619) >>> 0;
1748
+ }
1749
+ return h === 0 ? 2654435769 : h >>> 0;
1750
+ }
1633
1751
  var Mulberry32 = class {
1634
1752
  /** Current internal state (u32). Survives hibernation. */
1635
1753
  state;
@@ -1691,6 +1809,16 @@ var Rapier2dRuntime = class {
1691
1809
  bodies = /* @__PURE__ */ new Map();
1692
1810
  /** See `PhysicsRuntime.sleepSynced`: the one extra sync a body owes on the tick it sleeps. */
1693
1811
  sleepSynced = /* @__PURE__ */ new Set();
1812
+ /**
1813
+ * See `PhysicsRuntime.sourceRecords`: the plain record object each body was built from. `add()`
1814
+ * always installs a new record object, so an id removed and re-added between two reconciles —
1815
+ * the pooled-projectile shape — is identifiable by identity alone, and its body is rebuilt so
1816
+ * the new row's spawn pose and velocity are what the world gets. Unknown means adopt, never
1817
+ * rebuild: a world restored from a hibernation blob has bodies with no recorded source.
1818
+ */
1819
+ sourceRecords = /* @__PURE__ */ new Map();
1820
+ /** `collection.field` pairs already warned about a channel value the engine would not take. */
1821
+ warnedChannels = /* @__PURE__ */ new Set();
1694
1822
  constructor(core, rapier, options) {
1695
1823
  const config = core.definition.config.physics;
1696
1824
  if (!config) throw new Error("Rapier2dRuntime: the room config declares no physics");
@@ -1760,10 +1888,48 @@ var Rapier2dRuntime = class {
1760
1888
  }
1761
1889
  const body = this.world.createRigidBody(spec.body);
1762
1890
  for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
1891
+ this.warnLockedChannels(desc, spec, record);
1763
1892
  this.applyRecordToBody(desc, body, record);
1764
1893
  this.bodies.set(bodyKey3(desc.name, id), body);
1894
+ this.sourceRecords.set(bodyKey3(desc.name, id), record);
1765
1895
  return body;
1766
1896
  }
1897
+ /**
1898
+ * A spawn value the engine will not take, said once per field — the planar half of
1899
+ * `PhysicsRuntime.warnLockedChannels`.
1900
+ *
1901
+ * Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
1902
+ * accepts one on a fixed body and then ignores it: both read back as "the field was applied"
1903
+ * and behave as if it never was. The desc says which, before the body exists, so the check is on
1904
+ * the desc. In the plane there are three movable channels — `vx`, `vy` and the one rotation
1905
+ * `wz` — and the desc carries exactly those three flags. Named by field, because the field is
1906
+ * what the author wrote and what they will grep for.
1907
+ */
1908
+ warnLockedChannels(desc, spec, record) {
1909
+ const physics = desc.physics;
1910
+ if (!physics) return;
1911
+ const b = spec.body;
1912
+ const frozen = b.status !== 0;
1913
+ const enabled = {
1914
+ vx: b.translationsEnabledX,
1915
+ vy: b.translationsEnabledY,
1916
+ wz: b.rotationsEnabled
1917
+ };
1918
+ for (const [channel, field] of physics.channels) {
1919
+ const allowed = enabled[channel];
1920
+ if (allowed === void 0) continue;
1921
+ const raw = record[field];
1922
+ if (typeof raw !== "number" || raw === 0) continue;
1923
+ if (allowed && !frozen) continue;
1924
+ const key = `${desc.name}.${field}`;
1925
+ if (this.warnedChannels.has(key)) continue;
1926
+ this.warnedChannels.add(key);
1927
+ this.core.log(
1928
+ "warn",
1929
+ `irtio: ${desc.name}.${field} (body channel ${channel}) was added as ${raw}, but ` + (frozen ? `physics.bodies.${desc.name} builds a non-dynamic body, which never moves under it` : `that degree of freedom is disabled by physics.bodies.${desc.name}`) + " \u2014 the value was dropped"
1930
+ );
1931
+ }
1932
+ }
1767
1933
  // ---- M6 lane F: rewind ----
1768
1934
  /**
1769
1935
  * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
@@ -1774,7 +1940,7 @@ var Rapier2dRuntime = class {
1774
1940
  const plainColl = plainEntity(this.core.plain, desc.name);
1775
1941
  for (const id of plainColl.ids()) {
1776
1942
  const body = this.bodies.get(bodyKey3(desc.name, id));
1777
- if (body) fn(desc.name, id, body);
1943
+ if (body) fn(desc.name, id, body, plainColl.get(id));
1778
1944
  }
1779
1945
  }
1780
1946
  }
@@ -1814,8 +1980,21 @@ var Rapier2dRuntime = class {
1814
1980
  for (const id of coll.ids()) {
1815
1981
  const key = bodyKey3(desc.name, id);
1816
1982
  live.add(key);
1817
- if (this.bodies.has(key)) continue;
1818
1983
  const record = coll.get(id);
1984
+ const existing = this.bodies.get(key);
1985
+ if (existing) {
1986
+ if (record === void 0) continue;
1987
+ const source = this.sourceRecords.get(key);
1988
+ if (source === void 0) {
1989
+ this.sourceRecords.set(key, record);
1990
+ continue;
1991
+ }
1992
+ if (source === record) continue;
1993
+ this.bodies.delete(key);
1994
+ this.sleepSynced.delete(key);
1995
+ this.sourceRecords.delete(key);
1996
+ this.world.removeRigidBody(existing);
1997
+ }
1819
1998
  if (record !== void 0) this.create(desc, id, record);
1820
1999
  }
1821
2000
  }
@@ -1823,6 +2002,7 @@ var Rapier2dRuntime = class {
1823
2002
  if (live.has(key)) continue;
1824
2003
  this.bodies.delete(key);
1825
2004
  this.sleepSynced.delete(key);
2005
+ this.sourceRecords.delete(key);
1826
2006
  this.world.removeRigidBody(body);
1827
2007
  }
1828
2008
  }
@@ -2144,8 +2324,8 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
2144
2324
  // src/core/snapshot.ts
2145
2325
  import { withBuiltins } from "@irtio/protocol";
2146
2326
  import { ByteReader as ByteReader3, ByteWriter as ByteWriter3, decodeSnapshot } from "@irtio/schema";
2147
- var SNAPSHOT_FORMAT_VERSION = 2;
2148
- var READABLE_SNAPSHOT_VERSIONS = [1, 2];
2327
+ var SNAPSHOT_FORMAT_VERSION = 3;
2328
+ var READABLE_SNAPSHOT_VERSIONS = [1, 2, 3];
2149
2329
  function parseHibernationBlob(bytes) {
2150
2330
  const r = new ByteReader3(bytes);
2151
2331
  const version = r.u8();
@@ -2156,22 +2336,31 @@ function parseHibernationBlob(bytes) {
2156
2336
  const rngState = r.u32();
2157
2337
  const tick = r.u32();
2158
2338
  const mode = r.u8() === 1 ? "event" : "tick";
2339
+ const schemaHash8 = version >= 3 ? r.bytes(8).slice() : void 0;
2159
2340
  let physics;
2160
2341
  if (version >= 2) {
2161
2342
  const section = r.blob();
2162
2343
  if (section.length > 0) physics = section;
2163
2344
  }
2164
- return { version, seed, rngState, tick, mode, physics, snapshot: r.rest() };
2345
+ return { version, seed, rngState, tick, mode, schemaHash8, physics, snapshot: r.rest() };
2346
+ }
2347
+ function sameSchemaHash(a, b) {
2348
+ if (!a || !b) return true;
2349
+ if (a.length !== b.length) return false;
2350
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
2351
+ return true;
2165
2352
  }
2166
2353
  function writeHibernationBlob(header, snapshot, physics) {
2167
- const version = physics ? 2 : 1;
2168
- const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + 24);
2354
+ const hash = header.schemaHash8;
2355
+ const version = hash ? 3 : physics ? 2 : 1;
2356
+ const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + 32);
2169
2357
  w.u8(version);
2170
2358
  w.u32(header.seed >>> 0);
2171
2359
  w.u32(header.rngState >>> 0);
2172
2360
  w.u32(header.tick >>> 0);
2173
2361
  w.u8(header.mode === "event" ? 1 : 0);
2174
- if (physics) w.blob(physics);
2362
+ if (hash) w.bytes(hash);
2363
+ if (version >= 2) w.blob(physics ?? new Uint8Array(0));
2175
2364
  w.bytes(snapshot);
2176
2365
  return w.finish();
2177
2366
  }
@@ -2268,23 +2457,45 @@ import {
2268
2457
 
2269
2458
  // src/core/history.ts
2270
2459
  var HISTORY_MAX_TICKS = 240;
2460
+ var HISTORY_MAX_CHANNELS = 8;
2271
2461
  var STRIDE = 13;
2272
2462
  function emptyPose() {
2273
2463
  return { x: 0, y: 0, z: 0, qx: 0, qy: 0, qz: 0, qw: 1, vx: 0, vy: 0, vz: 0, wx: 0, wy: 0, wz: 0 };
2274
2464
  }
2275
2465
  function newEntry() {
2276
- return { tick: -1, count: 0, collections: [], ids: [], values: new Float64Array(0) };
2466
+ return {
2467
+ tick: -1,
2468
+ count: 0,
2469
+ collections: [],
2470
+ ids: [],
2471
+ values: new Float64Array(0),
2472
+ channels: new Float32Array(0)
2473
+ };
2277
2474
  }
2278
2475
  var PoseHistory = class {
2279
2476
  depth;
2477
+ /** Declared channels by collection. Empty in a room that declared none. */
2478
+ channels;
2479
+ /**
2480
+ * Slots per body in `entry.channels`: the widest declared count, so a body's channels sit at
2481
+ * `i * channelStride` under the same body index the pose uses. `0` when nothing is declared, and
2482
+ * that zero is what short-circuits the whole channel path per body.
2483
+ */
2484
+ channelStride;
2280
2485
  entries = [];
2281
2486
  /** Index of the newest entry in `entries`, or -1 when nothing has been captured. */
2282
2487
  head = -1;
2283
2488
  size = 0;
2284
2489
  /** Reused across every body of every capture: the capture path allocates nothing per body. */
2285
2490
  scratchPose = emptyPose();
2286
- constructor(depth) {
2491
+ /** Collections whose `read` hook has already thrown, so the log is one line, not one per tick. */
2492
+ hookFailed = /* @__PURE__ */ new Set();
2493
+ constructor(depth, channels) {
2287
2494
  this.depth = Math.max(1, Math.min(HISTORY_MAX_TICKS, Math.floor(depth)));
2495
+ this.channels = channels ?? /* @__PURE__ */ new Map();
2496
+ let stride = 0;
2497
+ for (const spec of this.channels.values()) stride = Math.max(stride, spec.count);
2498
+ this.channelStride = stride;
2288
2499
  for (let i = 0; i < this.depth; i++) this.entries.push(newEntry());
2289
2500
  }
2290
2501
  get length() {
@@ -2314,14 +2525,14 @@ var PoseHistory = class {
2314
2525
  entry.count = 0;
2315
2526
  const pose = this.scratchPose;
2316
2527
  const kind = physics.engineKind;
2317
- physics.eachTrackedBody((collection, id, body) => {
2528
+ physics.eachTrackedBody((collection, id, body, instance) => {
2318
2529
  if (kind === "rapier3d") readRapierPose(body, pose);
2319
2530
  else if (kind === "rapier2d") readRapier2dPose(body, pose);
2320
2531
  else readMatterPose(body, pose);
2321
- this.push(entry, collection, id, pose);
2532
+ this.push(entry, collection, id, pose, instance);
2322
2533
  });
2323
2534
  }
2324
- push(entry, collection, id, pose) {
2535
+ push(entry, collection, id, pose, instance) {
2325
2536
  const i = entry.count;
2326
2537
  const need = (i + 1) * STRIDE;
2327
2538
  if (entry.values.length < need) {
@@ -2329,6 +2540,34 @@ var PoseHistory = class {
2329
2540
  grown.set(entry.values);
2330
2541
  entry.values = grown;
2331
2542
  }
2543
+ const stride = this.channelStride;
2544
+ if (stride > 0) {
2545
+ const want = (i + 1) * stride;
2546
+ if (entry.channels.length < want) {
2547
+ const grown = new Float32Array(Math.max(want, entry.channels.length * 2, stride * 8));
2548
+ grown.set(entry.channels);
2549
+ entry.channels = grown;
2550
+ }
2551
+ const o2 = i * stride;
2552
+ entry.channels.fill(0, o2, o2 + stride);
2553
+ const spec = this.channels.get(collection);
2554
+ if (spec) {
2555
+ const scratch = spec.scratch;
2556
+ scratch.fill(0);
2557
+ try {
2558
+ spec.read(instance, scratch);
2559
+ entry.channels.set(scratch, o2);
2560
+ } catch (err) {
2561
+ if (!this.hookFailed.has(collection)) {
2562
+ this.hookFailed.add(collection);
2563
+ console.error(
2564
+ `physics.history.channels.${collection}.read threw; that body's channels are zeros`,
2565
+ err
2566
+ );
2567
+ }
2568
+ }
2569
+ }
2570
+ }
2332
2571
  entry.collections[i] = collection;
2333
2572
  entry.ids[i] = id;
2334
2573
  const v = entry.values;
@@ -2399,6 +2638,10 @@ function poseAt(entry, index, into) {
2399
2638
  into.wy = v[o + 11];
2400
2639
  into.wz = v[o + 12];
2401
2640
  }
2641
+ function channelsAt(entry, index, stride, count) {
2642
+ const o = index * stride;
2643
+ return entry.channels.subarray(o, o + count);
2644
+ }
2402
2645
  function readRapierPose(body, into) {
2403
2646
  const t = body.translation();
2404
2647
  const r = body.rotation();
@@ -2451,10 +2694,20 @@ function readMatterPose(body, into) {
2451
2694
  into.wy = 0;
2452
2695
  into.wz = body.angularVelocity;
2453
2696
  }
2454
- function historyDepthOf(config) {
2697
+ function historyOf(config) {
2455
2698
  const declared = config?.history;
2456
- if (typeof declared !== "number" || !Number.isFinite(declared) || declared <= 0) return void 0;
2457
- return Math.min(HISTORY_MAX_TICKS, Math.floor(declared));
2699
+ const raw = typeof declared === "number" ? declared : typeof declared === "object" && declared !== null ? declared.depth : void 0;
2700
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return void 0;
2701
+ const depth = Math.min(HISTORY_MAX_TICKS, Math.floor(raw));
2702
+ const channels = /* @__PURE__ */ new Map();
2703
+ if (typeof declared === "object" && declared !== null) {
2704
+ const spec = declared.channels;
2705
+ for (const [name, s] of Object.entries(spec ?? {})) {
2706
+ const count = Math.min(HISTORY_MAX_CHANNELS, Math.floor(s.count));
2707
+ channels.set(name, { count, read: s.read, scratch: new Float32Array(count) });
2708
+ }
2709
+ }
2710
+ return { depth, channels };
2458
2711
  }
2459
2712
  function keyOf(collection, id) {
2460
2713
  return `${collection} ${id}`;
@@ -2804,8 +3057,8 @@ var RewindState = class {
2804
3057
  history;
2805
3058
  scratch;
2806
3059
  inside = false;
2807
- constructor(depth) {
2808
- this.history = new PoseHistory(depth);
3060
+ constructor(setup) {
3061
+ this.history = new PoseHistory(setup.depth, setup.channels);
2809
3062
  }
2810
3063
  free() {
2811
3064
  this.scratch?.free();
@@ -2824,7 +3077,29 @@ var RewindState = class {
2824
3077
  );
2825
3078
  }
2826
3079
  this.scratch ??= physics.engineKind === "rapier3d" ? new RapierScratch(physics, this.history.depth, tickNow) : physics.engineKind === "rapier2d" ? new Rapier2dScratch(physics, this.history.depth, tickNow) : new MatterScratch(physics, this.history.depth, tickNow);
2827
- const view = this.scratch.view(resolved.entry, resolved.tick, requested, resolved.clamped);
3080
+ const engineView = this.scratch.view(
3081
+ resolved.entry,
3082
+ resolved.tick,
3083
+ requested,
3084
+ resolved.clamped
3085
+ );
3086
+ const entry = resolved.entry;
3087
+ const history = this.history;
3088
+ const view = {
3089
+ ...engineView,
3090
+ // A linear scan of the entry rather than an index: an entry holds one room's tracked bodies,
3091
+ // and building a map per rewind would cost more than the scan it saved.
3092
+ channels: (collection, id) => {
3093
+ const spec = history.channels.get(collection);
3094
+ if (!spec) return void 0;
3095
+ for (let i = 0; i < entry.count; i++) {
3096
+ if (entry.collections[i] === collection && entry.ids[i] === id) {
3097
+ return channelsAt(entry, i, history.channelStride, spec.count);
3098
+ }
3099
+ }
3100
+ return void 0;
3101
+ }
3102
+ };
2828
3103
  this.inside = true;
2829
3104
  try {
2830
3105
  return fn(view);
@@ -3284,9 +3559,20 @@ function createRoomApi(core, publicUrl) {
3284
3559
  get id() {
3285
3560
  return core.roomId;
3286
3561
  },
3562
+ /**
3563
+ * The same tracked state object handlers get. The one caller that has no other way to it is
3564
+ * `physics.setup`, which runs before `onCreate` on a fresh room and after the snapshot is
3565
+ * decoded on a rebuild — see the doc on `Room.state`.
3566
+ */
3567
+ get state() {
3568
+ return core.anyState;
3569
+ },
3287
3570
  get link() {
3288
3571
  return link();
3289
3572
  },
3573
+ get seed() {
3574
+ return core.seed;
3575
+ },
3290
3576
  get tick() {
3291
3577
  return core.tick;
3292
3578
  },
@@ -3477,6 +3763,9 @@ function createRoomApi(core, publicUrl) {
3477
3763
  },
3478
3764
  get broadcast() {
3479
3765
  return broadcast;
3766
+ },
3767
+ broadcastExcept(clientId) {
3768
+ return createBroadcastExceptProxy(core, clientId);
3480
3769
  }
3481
3770
  };
3482
3771
  const broadcast = createBroadcastProxy(core);
@@ -3665,8 +3954,13 @@ var RoomCore = class _RoomCore {
3665
3954
  }
3666
3955
  let restored;
3667
3956
  let plain;
3668
- if (options.restoreFrom) {
3669
- restored = parseHibernationBlob(options.restoreFrom);
3957
+ const candidate = options.restoreFrom ? parseHibernationBlob(options.restoreFrom) : void 0;
3958
+ if (candidate && !sameSchemaHash(candidate.schemaHash8, this.ext.hash8)) {
3959
+ host.log("warn", [`irtio: room ${options.roomId}: schema changed, snapshot discarded`]);
3960
+ } else if (candidate) {
3961
+ restored = candidate;
3962
+ }
3963
+ if (restored) {
3670
3964
  plain = decodeSnapshot2(this.ext, restored.snapshot).state;
3671
3965
  const presence = plainEntity(plain, PRESENCE_COLLECTION);
3672
3966
  for (const id of [...presence.ids()]) presence.remove(id);
@@ -3681,7 +3975,7 @@ var RoomCore = class _RoomCore {
3681
3975
  this.plain = plain;
3682
3976
  this.tracked = track(this.ext, plain);
3683
3977
  this.anyState = this.tracked.state;
3684
- this.seed = restored?.seed ?? options.seed ?? 1;
3978
+ this.seed = restored?.seed ?? options.seed ?? seedFromRoomId(this.roomId);
3685
3979
  this.rng = new Mulberry32(restored?.rngState ?? this.seed);
3686
3980
  this.tick = restored?.tick ?? 0;
3687
3981
  const self = this;
@@ -3689,8 +3983,8 @@ var RoomCore = class _RoomCore {
3689
3983
  this.loop = new Loop(self);
3690
3984
  this.api = createRoomApi(self, options.publicUrl ?? DEFAULT_PUBLIC_URL);
3691
3985
  this.physics = this.buildPhysics(restored);
3692
- const historyDepth = this.physics ? historyDepthOf(definition.config.physics) : void 0;
3693
- this.rewindState = historyDepth === void 0 ? void 0 : new RewindState(historyDepth);
3986
+ const historySetup = this.physics ? historyOf(definition.config.physics) : void 0;
3987
+ this.rewindState = historySetup === void 0 ? void 0 : new RewindState(historySetup);
3694
3988
  this.subscribeDeclaredChannels();
3695
3989
  if (restored) {
3696
3990
  const onWake = definition.config.onWake;
@@ -3777,7 +4071,7 @@ var RoomCore = class _RoomCore {
3777
4071
  const engine4 = loadedPhysics();
3778
4072
  if (!engine4) {
3779
4073
  throw new Error(
3780
- "RoomCore: this room declares physics but the engine is not initialized \u2014 the host must `await initPhysics()` (from '@irtio/runtime') before constructing the room; handlers are synchronous, so the WASM cannot be loaded later"
4074
+ "RoomCore: this room declares physics but the engine is not initialized \u2014 the host must `await initPhysics()` (in tests: from '@irtio/testing') before constructing the room; handlers are synchronous, so the WASM cannot be loaded later"
3781
4075
  );
3782
4076
  }
3783
4077
  let section;
@@ -4025,7 +4319,13 @@ var RoomCore = class _RoomCore {
4025
4319
  snapshot() {
4026
4320
  const physics = this.physics ? encodeSection(this.physics) : void 0;
4027
4321
  return writeHibernationBlob(
4028
- { seed: this.seed, rngState: this.rng.state, tick: this.tick, mode: this.mode },
4322
+ {
4323
+ seed: this.seed,
4324
+ rngState: this.rng.state,
4325
+ tick: this.tick,
4326
+ mode: this.mode,
4327
+ schemaHash8: this.ext.hash8
4328
+ },
4029
4329
  encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
4030
4330
  physics
4031
4331
  );
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  parseHibernationBlob,
3
3
  writeHibernationBlob
4
- } from "./chunk-ZCD2TBXB.js";
4
+ } from "./chunk-KGZEQTJR.js";
5
5
 
6
6
  // src/migrate.ts
7
7
  import { PRESENCE_COLLECTION, withBuiltins } from "@irtio/protocol";
@@ -53,7 +53,15 @@ function migrateSnapshot(blob, chain, options) {
53
53
  }
54
54
  plain = fromMigrationState(withBuiltins(schema), state, last.version);
55
55
  const bytes = writeHibernationBlob(
56
- { seed: parsed.seed, rngState: parsed.rngState, tick: parsed.tick, mode: parsed.mode },
56
+ {
57
+ seed: parsed.seed,
58
+ rngState: parsed.rngState,
59
+ tick: parsed.tick,
60
+ mode: parsed.mode,
61
+ // Stamped with the *target* schema: these bytes are now that schema's, and a room waking on
62
+ // it must not see them as a mismatch.
63
+ schemaHash8: withBuiltins(schema).hash8
64
+ },
57
65
  encodeSnapshot(withBuiltins(schema), plain, { tick: parsed.tick })
58
66
  );
59
67
  return {
@@ -302,7 +302,11 @@ interface RoomHost {
302
302
  }
303
303
  interface RoomCoreOptions {
304
304
  readonly roomId: string;
305
- /** Seed for `room.random()`; default 1. */
305
+ /**
306
+ * Seed for `room.random()`. Defaults to a stable 32-bit hash of `roomId`, so two rooms of the
307
+ * same game generate different worlds; pass a number to pin the sequence instead. A room
308
+ * restored from a snapshot ignores both and keeps the seed it was created with.
309
+ */
306
310
  readonly seed?: number;
307
311
  /** Base URL for `room.link` (`<publicUrl>?room=<id>`); default `http://localhost/`. */
308
312
  readonly publicUrl?: string;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { D as DEFAULT_TIMELINE_MAX_RECORDS, a as DEFAULT_TIMELINE_MAX_TICKS, E as EVENT_RING_SIZE, H as HOST_CALL_TIMEOUT_MS, b as HostCall, c as HostCallResult, J as JoinOptions, d as JoinResult, L as LogLevel, R as RoomCoreApi, e as RoomCoreOptions, f as RoomEvent, g as RoomEventKind, h as RoomFullError, i as RoomHost, j as RoomInspection, k as RoomStats, T as TimelineDump, l as TimelineFrame, m as TimelineRecorder, n as TimelineRecorderOptions, o as inspectState } from './contract-CRilLVdx.js';
2
- export { C as CRASH_AFTER_THROWS, M as MAX_CATCHUP, a as MatterBodyRecord, b as MatterSection, c as Mulberry32, P as PhysicsEngineTag, d as PhysicsSection, R as RoomCore, e as decodeMatterBodies, f as decodeMatterSectionEnvelope, g as decodePhysicsSection, h as decodeRapier2dSectionEnvelope, i as encodeMatterBodies, j as encodeMatterSectionEnvelope, k as encodePhysicsSection, l as encodeRapier2dSectionEnvelope, m as initMatter, n as initPhysics, o as initRapier2d, p as loadedMatter, q as loadedPhysics, r as loadedRapier2d, s as physicsSectionEngine, t as resetMatterForTests, u as resetPhysicsForTests, v as resetRapier2dForTests } from './room-YJGz6Caw.js';
1
+ export { D as DEFAULT_TIMELINE_MAX_RECORDS, a as DEFAULT_TIMELINE_MAX_TICKS, E as EVENT_RING_SIZE, H as HOST_CALL_TIMEOUT_MS, b as HostCall, c as HostCallResult, J as JoinOptions, d as JoinResult, L as LogLevel, R as RoomCoreApi, e as RoomCoreOptions, f as RoomEvent, g as RoomEventKind, h as RoomFullError, i as RoomHost, j as RoomInspection, k as RoomStats, T as TimelineDump, l as TimelineFrame, m as TimelineRecorder, n as TimelineRecorderOptions, o as inspectState } from './contract-C5aqs49-.js';
2
+ export { C as CRASH_AFTER_THROWS, M as MAX_CATCHUP, a as MatterBodyRecord, b as MatterSection, c as Mulberry32, P as PhysicsEngineTag, d as PhysicsSection, R as RoomCore, e as decodeMatterBodies, f as decodeMatterSectionEnvelope, g as decodePhysicsSection, h as decodeRapier2dSectionEnvelope, i as encodeMatterBodies, j as encodeMatterSectionEnvelope, k as encodePhysicsSection, l as encodeRapier2dSectionEnvelope, m as initMatter, n as initPhysics, o as initRapier2d, p as loadedMatter, q as loadedPhysics, r as loadedRapier2d, s as physicsSectionEngine, t as resetMatterForTests, u as resetPhysicsForTests, v as resetRapier2dForTests } from './room-Db3iPBKM.js';
3
3
  import { CollectionDesc, AnySchema, PlainState, DirtySet } from '@irtio/schema';
4
4
  import '@irtio/protocol';
5
5
  import '@irtio/server';
@@ -70,9 +70,16 @@ declare function catchUpDirty(ext: AnySchema, plain: PlainState, fromRole: strin
70
70
  * u32 rng state
71
71
  * u32 tick
72
72
  * u8 mode (1 = event, 0 = tick)
73
+ * [v3] 8 bytes schema hash (`withBuiltins(schema).hash8`)
73
74
  * [v2] blob physics section (varint length + bytes; length 0 = no world)
74
75
  * ... encodeSnapshot(withBuiltins(schema), state)
75
76
  *
77
+ * **v3** adds the schema hash. Before it, restoring a snapshot written under a different schema
78
+ * fed the old bytes to the new decoder and surfaced as a `TypeError` from deep inside the string
79
+ * decoder; the room could only be started again by hand-deleting `.irtio/snapshots`. The hash is
80
+ * in the envelope (not only in the codec payload) so the check costs no decode: a mismatch is
81
+ * seen, logged and the snapshot discarded before anything is parsed against the wrong schema.
82
+ *
76
83
  * **v2 (week 9, D22)** adds the physics section: the Rapier world snapshot plus the entity↔handle
77
84
  * map (`core/physics.ts` encodes it). It rides *inside* this blob rather than beside it so that
78
85
  * wake is atomic — state and world always come back from the same bytes, and every store, cache
@@ -89,8 +96,8 @@ declare function catchUpDirty(ext: AnySchema, plain: PlainState, fromRole: strin
89
96
  * instead of parsing bytes it may not yet know how to read.
90
97
  */
91
98
 
92
- /** Written when the blob carries a physics world; v1 otherwise (and v1 is still read). */
93
- declare const SNAPSHOT_FORMAT_VERSION = 2;
99
+ /** Written when the header carries a schema hash (every room does); older versions still read. */
100
+ declare const SNAPSHOT_FORMAT_VERSION = 3;
94
101
  /** Versions this build can parse. */
95
102
  declare const READABLE_SNAPSHOT_VERSIONS: readonly number[];
96
103
  type SnapshotMode = 'tick' | 'event';
@@ -99,6 +106,12 @@ interface SnapshotHeader {
99
106
  readonly rngState: number;
100
107
  readonly tick: number;
101
108
  readonly mode: SnapshotMode;
109
+ /**
110
+ * `withBuiltins(schema).hash8` of the schema these bytes were written under. Omitted only by a
111
+ * writer that has no schema in hand; a restore that finds it absent cannot check and proceeds
112
+ * as pre-v3 builds did.
113
+ */
114
+ readonly schemaHash8?: Uint8Array | undefined;
102
115
  }
103
116
  interface ParsedSnapshot extends SnapshotHeader {
104
117
  /** The blob's own format version (1 or 2). */
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  fromMigrationState,
3
3
  migrateSnapshot,
4
4
  toMigrationState
5
- } from "./chunk-DBGMG2S3.js";
5
+ } from "./chunk-MTBMGUGG.js";
6
6
  import {
7
7
  CRASH_AFTER_THROWS,
8
8
  DEFAULT_TIMELINE_MAX_RECORDS,
@@ -47,7 +47,7 @@ import {
47
47
  visibleNames,
48
48
  visibleTo,
49
49
  writeHibernationBlob
50
- } from "./chunk-ZCD2TBXB.js";
50
+ } from "./chunk-KGZEQTJR.js";
51
51
  export {
52
52
  CRASH_AFTER_THROWS,
53
53
  DEFAULT_TIMELINE_MAX_RECORDS,
@@ -1,12 +1,8 @@
1
1
  import { AttributeOptions, ProfileSnapshot } from '@irtio/protocol';
2
2
  import { DirtySet, AnySchema, PlainState, Tracked, State } from '@irtio/schema';
3
3
  import { RoomDefinition, RoomMode, Room, RewindView, Ctx, MatterModule, MatterEngine, MatterBody, RapierModule, RapierWorld, RapierRigidBody, Rapier2dModule, Rapier2dWorld, Rapier2dRigidBody, ResolvedRoomConfig, LeaveReason } from '@irtio/server';
4
- import { i as RoomHost, k as RoomStats, L as LogLevel, R as RoomCoreApi, e as RoomCoreOptions, g as RoomEventKind, n as TimelineRecorderOptions, T as TimelineDump, j as RoomInspection, J as JoinOptions, d as JoinResult, c as HostCallResult } from './contract-CRilLVdx.js';
4
+ import { i as RoomHost, k as RoomStats, L as LogLevel, R as RoomCoreApi, e as RoomCoreOptions, g as RoomEventKind, n as TimelineRecorderOptions, T as TimelineDump, j as RoomInspection, J as JoinOptions, d as JoinResult, c as HostCallResult } from './contract-C5aqs49-.js';
5
5
 
6
- /**
7
- * `room.random()` — mulberry32 over a u32 seed. Deterministic, tiny, and serializable: the
8
- * generator's whole state is one u32, so `serialize()`/`restore()` round-trip it exactly.
9
- */
10
6
  declare class Mulberry32 {
11
7
  /** Current internal state (u32). Survives hibernation. */
12
8
  state: number;
@@ -109,7 +105,7 @@ interface PhysicsApi {
109
105
  * the only thing outside the engine runtimes that sees a live body other than through
110
106
  * `bodyFor`, and it never hands out the map itself.
111
107
  */
112
- eachTrackedBody(fn: (collection: string, id: string, body: unknown) => void): void;
108
+ eachTrackedBody(fn: (collection: string, id: string, body: unknown, instance: Record<string, unknown>) => void): void;
113
109
  /** rapier3d and rapier2d: the engine namespace. `engineKind` says which module it is. */
114
110
  readonly rapier: unknown;
115
111
  /** rapier3d and rapier2d: the live `World`. */
@@ -139,6 +135,8 @@ interface RoomInternals {
139
135
  readonly ext: AnySchema;
140
136
  readonly host: RoomHost;
141
137
  readonly roomId: string;
138
+ /** The resolved RNG seed: explicit option, else derived from the room id; restored on wake. */
139
+ readonly seed: number;
142
140
  readonly mode: RoomMode;
143
141
  /** The plain state the tracked proxies wrap (what the codec reads). */
144
142
  readonly plain: PlainState;
@@ -346,6 +344,15 @@ declare class MatterRuntime {
346
344
  private readonly restore;
347
345
  private readonly stepMs;
348
346
  private readonly sleepSynced;
347
+ /**
348
+ * The plain record object each body was built from. See `PhysicsRuntime.sourceRecords`: `add()`
349
+ * installs a new record object, so an id removed and re-added between two reconciles (a pooled
350
+ * projectile) is identifiable by identity, and is the one case where a live body has to be
351
+ * rebuilt so the new row's spawn pose and velocity are honoured.
352
+ */
353
+ private readonly sourceRecords;
354
+ /** `collection.field` pairs already warned about a channel a plane cannot hold. */
355
+ private readonly warnedChannels;
349
356
  constructor(core: RoomInternals, matter: MatterModule, options: MatterRuntimeOptions);
350
357
  get timestep(): number;
351
358
  runSetup(room: Room): void;
@@ -356,7 +363,7 @@ declare class MatterRuntime {
356
363
  * D72: every tracked body, in the order `sync()` walks them. The matter half of the same
357
364
  * read-only accessor `PhysicsRuntime` carries; see its comment for what calls it and when.
358
365
  */
359
- eachTrackedBody(fn: (collection: string, id: string, body: MatterBody) => void): void;
366
+ eachTrackedBody(fn: (collection: string, id: string, body: MatterBody, instance: Record<string, unknown>) => void): void;
360
367
  private applyState;
361
368
  private applyRecordToBody;
362
369
  reconcile(): void;
@@ -488,6 +495,19 @@ declare class PhysicsRuntime {
488
495
  * world rebuilt from schema state would wake it with a kick). Found by the drift check.
489
496
  */
490
497
  private readonly sleepSynced;
498
+ /**
499
+ * The plain record object each body was built from, by body key. `add()` always installs a
500
+ * **new** record object (`normalizeRecord` builds one), so an id that is removed and re-added
501
+ * between two reconciles — the pooled-projectile shape — is identifiable by identity alone,
502
+ * which is the only way to see it from here: both edits happened before this module looked.
503
+ * Without it the old body kept its pose and velocity and the new row's spawn values were lost.
504
+ *
505
+ * Unknown means adopt, never rebuild: a world restored from a hibernation blob has bodies with
506
+ * no recorded source, and rebuilding those would throw away exactly what the blob preserved.
507
+ */
508
+ private readonly sourceRecords;
509
+ /** `collection.field` pairs already warned about a channel value the engine would not take. */
510
+ private readonly warnedChannels;
491
511
  constructor(core: RoomInternals, rapier: RapierModule, options: PhysicsRuntimeOptions);
492
512
  get timestep(): number;
493
513
  /** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
@@ -496,13 +516,24 @@ declare class PhysicsRuntime {
496
516
  /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
497
517
  bodyFor(collection: string, id: string): RapierRigidBody | undefined;
498
518
  private create;
519
+ /**
520
+ * A spawn value the engine will not take, said once per field.
521
+ *
522
+ * Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
523
+ * accepts one on a fixed body and then ignores it — both read back as "the field was applied"
524
+ * and behave as if it never was. The desc says which, before the body exists, so the check is
525
+ * on the desc: the usual case is the 2D-in-3D recipe (`enabledTranslations(true, true, false)`)
526
+ * meeting a row that spawns with a `vz`. Named by field, because the field is what the author
527
+ * wrote and what they will grep for.
528
+ */
529
+ private warnLockedChannels;
499
530
  /**
500
531
  * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
501
532
  * order). Read-only: it hands out the live bodies and nothing else, and the map stays private.
502
533
  * The pose history calls this once per tick and the rewind scratch calls it per rewind; a room
503
534
  * that declares no `physics.history` never calls it at all.
504
535
  */
505
- eachTrackedBody(fn: (collection: string, id: string, body: RapierRigidBody) => void): void;
536
+ eachTrackedBody(fn: (collection: string, id: string, body: RapierRigidBody, instance: Record<string, unknown>) => void): void;
506
537
  private applyRecordToBody;
507
538
  /**
508
539
  * Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
@@ -585,6 +616,16 @@ declare class Rapier2dRuntime {
585
616
  private readonly bodies;
586
617
  /** See `PhysicsRuntime.sleepSynced`: the one extra sync a body owes on the tick it sleeps. */
587
618
  private readonly sleepSynced;
619
+ /**
620
+ * See `PhysicsRuntime.sourceRecords`: the plain record object each body was built from. `add()`
621
+ * always installs a new record object, so an id removed and re-added between two reconciles —
622
+ * the pooled-projectile shape — is identifiable by identity alone, and its body is rebuilt so
623
+ * the new row's spawn pose and velocity are what the world gets. Unknown means adopt, never
624
+ * rebuild: a world restored from a hibernation blob has bodies with no recorded source.
625
+ */
626
+ private readonly sourceRecords;
627
+ /** `collection.field` pairs already warned about a channel value the engine would not take. */
628
+ private readonly warnedChannels;
588
629
  constructor(core: RoomInternals, rapier: Rapier2dModule, options: Rapier2dRuntimeOptions);
589
630
  get timestep(): number;
590
631
  /** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
@@ -593,11 +634,23 @@ declare class Rapier2dRuntime {
593
634
  /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
594
635
  bodyFor(collection: string, id: string): Rapier2dRigidBody | undefined;
595
636
  private create;
637
+ /**
638
+ * A spawn value the engine will not take, said once per field — the planar half of
639
+ * `PhysicsRuntime.warnLockedChannels`.
640
+ *
641
+ * Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
642
+ * accepts one on a fixed body and then ignores it: both read back as "the field was applied"
643
+ * and behave as if it never was. The desc says which, before the body exists, so the check is on
644
+ * the desc. In the plane there are three movable channels — `vx`, `vy` and the one rotation
645
+ * `wz` — and the desc carries exactly those three flags. Named by field, because the field is
646
+ * what the author wrote and what they will grep for.
647
+ */
648
+ private warnLockedChannels;
596
649
  /**
597
650
  * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
598
651
  * order). The rapier2d third of the same read-only accessor the other two runtimes carry.
599
652
  */
600
- eachTrackedBody(fn: (collection: string, id: string, body: Rapier2dRigidBody) => void): void;
653
+ eachTrackedBody(fn: (collection: string, id: string, body: Rapier2dRigidBody, instance: Record<string, unknown>) => void): void;
601
654
  private applyRecordToBody;
602
655
  /**
603
656
  * Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
@@ -668,7 +721,7 @@ declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S
668
721
  * it again over its next `history` ticks.
669
722
  */
670
723
  private readonly rewindState;
671
- private readonly seed;
724
+ readonly seed: number;
672
725
  private readonly api;
673
726
  private readonly internals;
674
727
  private started;
@@ -1,8 +1,8 @@
1
- import { R as RoomCore } from '../room-YJGz6Caw.js';
2
- export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-YJGz6Caw.js';
1
+ import { R as RoomCore } from '../room-Db3iPBKM.js';
2
+ export { m as initMatter, n as initPhysics, o as initRapier2d } from '../room-Db3iPBKM.js';
3
3
  import { ErrorCodeName, FrameType } from '@irtio/protocol';
4
4
  import { NpcConfig, LeaveReason, Room, RoomDefinition } from '@irtio/server';
5
- import { i as RoomHost, L as LogLevel, R as RoomCoreApi, b as HostCall, c as HostCallResult, k as RoomStats } from '../contract-CRilLVdx.js';
5
+ import { i as RoomHost, L as LogLevel, R as RoomCoreApi, b as HostCall, c as HostCallResult, k as RoomStats } from '../contract-C5aqs49-.js';
6
6
  import { AnySchema, PlainState, EntityCollection, State } from '@irtio/schema';
7
7
 
8
8
  /**
@@ -331,7 +331,7 @@ interface FakeClient<S extends AnySchema = AnySchema> {
331
331
  */
332
332
 
333
333
  interface HarnessOptions {
334
- /** `room.random()` seed; default 1 (same as `RoomCore`). */
334
+ /** `room.random()` seed; defaults to a hash of `roomId`, exactly as `RoomCore` does. */
335
335
  readonly seed?: number;
336
336
  /** Base URL for `room.link`. */
337
337
  readonly publicUrl?: string;
@@ -5,7 +5,7 @@ import {
5
5
  initPhysics,
6
6
  initRapier2d,
7
7
  visibleNames
8
- } from "../chunk-ZCD2TBXB.js";
8
+ } from "../chunk-KGZEQTJR.js";
9
9
 
10
10
  // src/test/clock.ts
11
11
  var FakeClock = class {
@@ -677,7 +677,7 @@ var Harness = class {
677
677
  this.host.onSend = (clientId, frame) => this.deliver(clientId, frame);
678
678
  this.coreRef = new RoomCore(definition, this.host, {
679
679
  roomId: options.roomId ?? "test-room",
680
- seed: options.seed ?? 1,
680
+ ...options.seed !== void 0 ? { seed: options.seed } : {},
681
681
  ...options.publicUrl !== void 0 ? { publicUrl: options.publicUrl } : {},
682
682
  ...options.restoreFrom !== void 0 ? { restoreFrom: options.restoreFrom } : {}
683
683
  });
@@ -963,7 +963,7 @@ var Harness = class {
963
963
  this.nextClientId = 1;
964
964
  this.coreRef = RoomCore.restore(this.definition, bytes, this.host, {
965
965
  roomId: this.options.roomId ?? "test-room",
966
- seed: this.options.seed ?? 1,
966
+ ...this.options.seed !== void 0 ? { seed: this.options.seed } : {},
967
967
  ...this.options.publicUrl !== void 0 ? { publicUrl: this.options.publicUrl } : {}
968
968
  });
969
969
  this.coreRef.start();
@@ -1,4 +1,4 @@
1
- import { L as LogLevel, T as TimelineDump, k as RoomStats, b as HostCall, c as HostCallResult, R as RoomCoreApi, i as RoomHost } from '../contract-CRilLVdx.js';
1
+ import { L as LogLevel, T as TimelineDump, k as RoomStats, b as HostCall, c as HostCallResult, R as RoomCoreApi, i as RoomHost } from '../contract-C5aqs49-.js';
2
2
  import { ErrorCodeName, ProfileSnapshot } from '@irtio/protocol';
3
3
  import { NpcConfig, LeaveReason } from '@irtio/server';
4
4
  import '@irtio/schema';
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  migrateSnapshot
3
- } from "../chunk-DBGMG2S3.js";
3
+ } from "../chunk-MTBMGUGG.js";
4
4
  import {
5
5
  RoomCore,
6
6
  RoomFullError,
@@ -9,7 +9,7 @@ import {
9
9
  initRapier2d,
10
10
  onFirstRapierStep,
11
11
  rapierHasStepped
12
- } from "../chunk-ZCD2TBXB.js";
12
+ } from "../chunk-KGZEQTJR.js";
13
13
 
14
14
  // src/worker/index.ts
15
15
  import { getHeapStatistics } from "v8";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/runtime",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "irtio room runtime: RoomCore, worker_threads host, in-process test harness",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -31,9 +31,9 @@
31
31
  "@dimforge/rapier2d-compat": "0.20.0",
32
32
  "@dimforge/rapier3d-compat": "0.20.0",
33
33
  "matter-js": "0.20.0",
34
- "@irtio/protocol": "0.8.0",
35
- "@irtio/schema": "0.8.0",
36
- "@irtio/server": "0.8.0"
34
+ "@irtio/protocol": "0.10.0",
35
+ "@irtio/schema": "0.10.0",
36
+ "@irtio/server": "0.10.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/matter-js": "0.20.2"