vimp-engine 0.34.0 → 0.34.2

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/core/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.20.0"
3
+ version = "0.22.1"
4
4
  edition = "2024"
5
5
  description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.34.0",
3
+ "version": "0.34.2",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,6 +32,10 @@ const RECORDED_PORTS = {
32
32
  sendTechInform: 'techInform',
33
33
  };
34
34
 
35
+ // Сообщения, перед которыми отложенные кадры клиента доставляются сразу:
36
+ // они сбрасывают или заменяют его мир (см. routeFrame).
37
+ const FLUSHING_METHODS = new Set(['sendClear', 'sendMap', 'sendFirstShot']);
38
+
35
39
  // В headless-контуре нет ни мастера, ни auth-сервиса — участник стартует с
36
40
  // пустым профилем (lib/offlinePlayerData.js, общее со standalone/dedicated).
37
41
  const emptyProfileFetch = offlinePlayerData();
@@ -133,6 +137,12 @@ export async function runScenario(rawScenario, options = {}) {
133
137
 
134
138
  async function execute(scenario, plugin, virtualClock, { captureFrames }) {
135
139
  const clients = new Map(); // socketId → VirtualClient
140
+ // sendShot не вталкивается в клиента сразу: кадр момента T, доставленный
141
+ // до render(T), сравнивался бы детектором рассинхрона с репликой на
142
+ // T − timeStep — ложный дрейф v · timeStep. Кадры копятся здесь и
143
+ // доставляются после рендер-тика (docs/*/debugging.md, «Prediction
144
+ // divergence detector»)
145
+ const pendingShots = new Map(); // socketId → [bytes]
136
146
  const byParticipant = new Map(); // id сценария → { socketId, gameId }
137
147
  const participantLog = []; // [{ who, socketId, gameId, joinTick, leaveTick }]
138
148
  const mapChanges = [];
@@ -165,7 +175,8 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
165
175
  // получить те же порты и ту же игровую параметризацию
166
176
  createSocketManager: (ports, gameOpts) => {
167
177
  socketManager = new RecordingSocketManager(ports, gameOpts, {
168
- onFrame: frame => routeFrame(frame, clients, virtualClock, host),
178
+ onFrame: frame =>
179
+ routeFrame(frame, clients, pendingShots, virtualClock, host),
169
180
  });
170
181
 
171
182
  return socketManager;
@@ -211,6 +222,7 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
211
222
  clientCfg,
212
223
  plugin,
213
224
  clients,
225
+ pendingShots,
214
226
  byParticipant,
215
227
  participantLog,
216
228
  inputSeq,
@@ -224,8 +236,9 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
224
236
  await virtualClock.advance(stepMs);
225
237
  }
226
238
 
227
- for (const client of clients.values()) {
239
+ for (const [socketId, client] of clients) {
228
240
  client.render(virtualClock.monotonic());
241
+ deliverShots(socketId, clients, pendingShots, virtualClock);
229
242
  }
230
243
 
231
244
  if (dumpTicks.has(tick)) {
@@ -240,6 +253,16 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
240
253
  }
241
254
  }
242
255
 
256
+ // кадры последнего advance доставляются уже после финального дампа (срез
257
+ // сцены не меняется), но ещё раз проходят рендер: без него проверки
258
+ // finiteValues/hotLayout/renderCoverage не видели бы последнего тика, а
259
+ // агрегаты детектора (render вычерпывает их первым делом) — его кадров.
260
+ // Время то же — реплика не шагает повторно
261
+ for (const [socketId, client] of clients) {
262
+ deliverShots(socketId, clients, pendingShots, virtualClock);
263
+ client.render(virtualClock.monotonic());
264
+ }
265
+
243
266
  return buildReport({
244
267
  scenario,
245
268
  plugin,
@@ -261,13 +284,31 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
261
284
 
262
285
  // исходящий кадр хоста → клиент-получатель. sendShot несёт байты, которые в
263
286
  // проде ушли бы в data channel — их и скармливаем клиентскому ядру
264
- function routeFrame(frame, clients, virtualClock, host) {
287
+ function routeFrame(frame, clients, pendingShots, virtualClock, host) {
265
288
  const client = clients.get(frame.socketId);
266
289
 
267
290
  if (!client) {
268
291
  return;
269
292
  }
270
293
 
294
+ // кадр ждёт рендер-тика своего момента (см. pendingShots в runScenario)
295
+ if (frame.method === 'sendShot') {
296
+ if (!pendingShots.has(frame.socketId)) {
297
+ pendingShots.set(frame.socketId, []);
298
+ }
299
+
300
+ pendingShots.get(frame.socketId).push(frame.args[0]);
301
+ return;
302
+ }
303
+
304
+ // отложенные кадры выпускают только сообщения, меняющие мир клиента: кадр
305
+ // старой карты не должен доехать после CLEAR/MAP/первого снапшота. Пинг
306
+ // (каждые rttPingInterval), панель и прочие очередь не трогают — иначе
307
+ // кадр их тика доставлялся бы до рендера, на шаг раньше реплики
308
+ if (FLUSHING_METHODS.has(frame.method)) {
309
+ deliverShots(frame.socketId, clients, pendingShots, virtualClock);
310
+ }
311
+
271
312
  // pong отвечается мгновенно: латентность headless-прогона равна нулю, и
272
313
  // это единственная честная её модель
273
314
  if (frame.method === 'sendPing') {
@@ -275,11 +316,6 @@ function routeFrame(frame, clients, virtualClock, host) {
275
316
  return;
276
317
  }
277
318
 
278
- if (frame.method === 'sendShot') {
279
- client.pushFrame(frame.args[0], virtualClock.monotonic());
280
- return;
281
- }
282
-
283
319
  // первый снапшот мира: в браузере он применяется сразу (applyShot), а не
284
320
  // едет через буфер интерполяции — сущность, доехавшая только им, иначе не
285
321
  // попадёт ни в сцену, ни в проверки
@@ -307,10 +343,27 @@ function routeFrame(frame, clients, virtualClock, host) {
307
343
  }
308
344
  }
309
345
 
346
+ function deliverShots(socketId, clients, pendingShots, virtualClock) {
347
+ const queue = pendingShots.get(socketId);
348
+
349
+ if (!queue?.length) {
350
+ return;
351
+ }
352
+
353
+ pendingShots.delete(socketId);
354
+
355
+ const client = clients.get(socketId);
356
+
357
+ for (const bytes of queue) {
358
+ client?.pushFrame(bytes, virtualClock.monotonic());
359
+ }
360
+ }
361
+
310
362
  async function applyOp(op, ctx) {
311
363
  const {
312
364
  host,
313
365
  clients,
366
+ pendingShots,
314
367
  byParticipant,
315
368
  participantLog,
316
369
  inputSeq,
@@ -333,6 +386,7 @@ async function applyOp(op, ctx) {
333
386
  // текучкой участников растит процесс
334
387
  clients.get(entry.socketId)?.destroy();
335
388
  clients.delete(entry.socketId);
389
+ pendingShots.delete(entry.socketId);
336
390
  byParticipant.delete(op.who);
337
391
 
338
392
  const logged = participantLog.find(
@@ -222,7 +222,7 @@ class VirtualClient {
222
222
  render(localNow) {
223
223
  // записи копятся в ядре на push_frame, поэтому вычерпываются до sample —
224
224
  // иначе тик с пустым hot-буфером (ранний выход ниже) их бы потерял
225
- this._drainDivergence();
225
+ this.drainDivergence();
226
226
 
227
227
  const len = this.core.sample(localNow);
228
228
 
@@ -299,8 +299,9 @@ class VirtualClient {
299
299
  }
300
300
 
301
301
  // Записи детектора рассинхрона предикта из ядра. Агрегаты накопительные,
302
- // поэтому просто перезаписываются; записи — вычерпываются.
303
- _drainDivergence() {
302
+ // поэтому просто перезаписываются; записи — вычерпываются. Публичный:
303
+ // раннер вычерпывает кадры, доставленные после последнего рендер-тика.
304
+ drainDivergence() {
304
305
  if (typeof this.core.take_divergence !== 'function') {
305
306
  return;
306
307
  }
@@ -40,6 +40,10 @@ export default class FakeClientCore {
40
40
  this._inputs = []; // [{ time, vy }] в локальном времени
41
41
  this._base = null; // { x, y, time, vy } — последнее авторитетное, в серверном
42
42
  this._offset = 0;
43
+ // локальное время последнего sample(): реплика Rust-ядра шагает только
44
+ // в нём, и predicted_state() отдаёт состояние на этот момент, а не на
45
+ // момент кадра — фикстура сравнивает так же, чтобы сдвиг был виден
46
+ this._sampledAt = null;
43
47
  }
44
48
 
45
49
  push_frame(data, localNow = 0) {
@@ -66,7 +70,9 @@ export default class FakeClientCore {
66
70
  return 0;
67
71
  }
68
72
 
69
- sample() {
73
+ sample(localNow = 0) {
74
+ this._sampledAt = localNow;
75
+
70
76
  if (!this._frame || !this._hotKey) {
71
77
  this._hot = new Float32Array(0);
72
78
 
@@ -157,7 +163,11 @@ export default class FakeClientCore {
157
163
  const time = frame.serverTime;
158
164
 
159
165
  if (this._base) {
160
- const predicted = this._predictAt(time);
166
+ // до первого sample() момента реплики ещё нет — сравнение на момент
167
+ // кадра, как было до учёта sample (сдвига в этот момент нет)
168
+ const predicted = this._predictAt(
169
+ this._sampledAt === null ? time : this._sampledAt + this._offset,
170
+ );
161
171
 
162
172
  this._compare(predicted, authoritative, frame);
163
173
  }
@@ -273,6 +283,7 @@ export default class FakeClientCore {
273
283
  this._hot = new Float32Array(0);
274
284
  this._base = null;
275
285
  this._inputs = [];
286
+ this._sampledAt = null;
276
287
  }
277
288
 
278
289
  // зеркало ClientState::debug_json; у фикстуры буфер — один последний