dredless 0.2.0 → 0.2.1

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.
Files changed (77) hide show
  1. package/README.md +48 -48
  2. package/docs/API_REFERENCE.md +2481 -2481
  3. package/docs/BROWSER_API.md +98 -98
  4. package/docs/NODE_API.md +359 -359
  5. package/package.json +34 -34
  6. package/src/browser.d.ts +212 -212
  7. package/src/browser.js +133 -133
  8. package/src/client/core.js +1202 -1202
  9. package/src/client/domains/index.js +1818 -1818
  10. package/src/client/index.d.ts +2 -2
  11. package/src/client/index.js +1 -1
  12. package/src/compression/lz4.d.ts +1 -1
  13. package/src/compression/lz4.js +112 -112
  14. package/src/constants.js +74 -74
  15. package/src/crypto/chacha-native.d.ts +9 -9
  16. package/src/crypto/chacha-native.js +48 -48
  17. package/src/crypto/chacha.d.ts +25 -25
  18. package/src/crypto/chacha.js +274 -274
  19. package/src/events.js +39 -39
  20. package/src/index.d.ts +2277 -2277
  21. package/src/index.js +125 -125
  22. package/src/network/browser-session.js +291 -291
  23. package/src/network/connection.d.ts +1 -1
  24. package/src/network/connection.js +31 -31
  25. package/src/network/cookies.js +85 -85
  26. package/src/network/http.js +34 -34
  27. package/src/network/index.d.ts +4 -4
  28. package/src/network/index.js +4 -4
  29. package/src/network/servers.d.ts +7 -7
  30. package/src/network/servers.js +133 -133
  31. package/src/network/session.d.ts +7 -7
  32. package/src/network/session.js +320 -320
  33. package/src/network/ships.d.ts +12 -12
  34. package/src/network/ships.js +73 -73
  35. package/src/node.d.ts +2 -2
  36. package/src/node.js +8 -8
  37. package/src/protocol/codec/binary.d.ts +3 -3
  38. package/src/protocol/codec/binary.js +52 -52
  39. package/src/protocol/codec/msgpack.d.ts +3 -3
  40. package/src/protocol/codec/msgpack.js +555 -555
  41. package/src/protocol/inbound/frame.d.ts +1 -1
  42. package/src/protocol/inbound/frame.js +8 -8
  43. package/src/protocol/inbound/world-payload.d.ts +3 -3
  44. package/src/protocol/inbound/world-payload.js +21 -21
  45. package/src/protocol/index.d.ts +11 -11
  46. package/src/protocol/index.js +19 -19
  47. package/src/protocol/outbound/blueprint.d.ts +22 -22
  48. package/src/protocol/outbound/blueprint.js +36 -36
  49. package/src/protocol/outbound/commands.d.ts +3 -3
  50. package/src/protocol/outbound/commands.js +149 -149
  51. package/src/protocol/outbound/comms.d.ts +28 -28
  52. package/src/protocol/outbound/comms.js +40 -40
  53. package/src/protocol/outbound/inventory.d.ts +64 -64
  54. package/src/protocol/outbound/inventory.js +147 -147
  55. package/src/protocol/outbound/ship-management.d.ts +84 -84
  56. package/src/protocol/outbound/ship-management.js +221 -221
  57. package/src/protocol/outbound/sign.d.ts +18 -18
  58. package/src/protocol/outbound/sign.js +45 -45
  59. package/src/protocol/outbound/ui-config.d.ts +157 -157
  60. package/src/protocol/outbound/ui-config.js +578 -578
  61. package/src/runtime.js +36 -36
  62. package/src/state/generator-maze.d.ts +32 -32
  63. package/src/state/generator-maze.js +370 -370
  64. package/src/state/index.d.ts +3 -3
  65. package/src/state/index.js +3 -3
  66. package/src/state/items.js +20 -20
  67. package/src/state/model/decoder.js +1554 -1554
  68. package/src/state/model/index.d.ts +33 -33
  69. package/src/state/model/index.js +2 -2
  70. package/src/state/model/loader-config.js +1020 -1020
  71. package/src/state/model/reader.js +69 -69
  72. package/src/state/model/state.js +1257 -1257
  73. package/src/state/overworld.js +72 -72
  74. package/src/state/tilesets.js +53 -53
  75. package/src/state/world/index.d.ts +29 -29
  76. package/src/state/world/index.js +180 -180
  77. package/src/state/world/state.js +297 -297
@@ -1,1257 +1,1257 @@
1
- import * as modelDecoder from "./decoder.js";
2
- import {LoaderConfigTracker} from "./loader-config.js";
3
- import {navigationDestinationFromEncodedValue} from "../overworld.js";
4
-
5
- const {
6
- ModelReader,
7
- numericFields,
8
- TWO_FIELD_SPEC,
9
- LABEL_STATE_SPEC,
10
- SIMPLE_LABEL_STATE_SPEC,
11
- peekStreamInt,
12
- nextValueLooksLikeTextBlob,
13
- MODEL_TABLE_SPECS,
14
- TABLE_78_NUMERIC_FIELDS,
15
- WIRE_TAG_TABLES,
16
- MASK_ONLY_TABLES,
17
- ENTITY_FOOTPRINTS,
18
- PLACED_ENTITY_TYPE_IDS,
19
- FabricatorType,
20
- FABRICATOR_TYPES,
21
- HELM_TYPE_IDS,
22
- COMMS_STATION_TYPE_ID,
23
- COMMS_STATION_MAX_CHARGES,
24
- CARGO_HATCH_TYPE_IDS,
25
- THRUSTER_TYPE_IDS,
26
- EXPANDO_BOX_TYPE_ID,
27
- ITEM_LAUNCHER_TYPE_ID,
28
- LOADER_TYPE_ID,
29
- NAVIGATION_UNIT_TYPE_ID,
30
- entityTypeIdFromRecord,
31
- CANNON_AMMO_COLOR_ITEM_IDS,
32
- CANNON_TYPE_IDS,
33
- THRUSTER_FACING_NAMES,
34
- DEFAULT_OVERWORLD_WARP_DURATION_SECONDS,
35
- OVERWORLD_WARP_TICKS_PER_SECOND,
36
- MARKER_TYPE_IDS,
37
- TEAM_RANK_NAMES,
38
- PLAYER_SHIP_RANKS,
39
- PUSHER_MODE_NAMES,
40
- SIGN_DISPLAY_MODE_NAMES,
41
- SHIELD_GENERATOR_BOOST_STATE_NAMES,
42
- NAVIGATION_DEFAULT_DESTINATION,
43
- navigationDestinationName,
44
- isNavigationDestination,
45
- numberOrNull,
46
- qKey,
47
- blobKey,
48
- cloneRecord,
49
- firstRecord,
50
- TABLE_78_FIELD_BITS,
51
- table78DeltaRecord,
52
- isSemanticLoaderDelta,
53
- decodeText,
54
- entityNameFromType,
55
- markerTypeIdForTables,
56
- itemSummary,
57
- summarizeItemHolder,
58
- boundedProgress,
59
- summarizeFabricator,
60
- summarizeCargoEjector,
61
- summarizeCannon,
62
- summarizeThruster,
63
- summarizeHelm,
64
- navigationDestinationFromRecord,
65
- summarizeNavigationUnit,
66
- summarizeCommsStation,
67
- summarizeHealth,
68
- summarizeBot,
69
- isBoulderBotExclusion,
70
- botClassFromState,
71
- isCowardBossState,
72
- isOrangeFoolBotState,
73
- isZombieBossBotState,
74
- isRedSentryBotState,
75
- isBlueRusherBotState,
76
- isRedSniperBotState,
77
- isYellowHunterBotState,
78
- isAquaShielderBotState,
79
- isLazerEnthusiastBotState,
80
- isYellowMineGuardBotState,
81
- isShieldHelperBotState,
82
- isShieldMasterBotState,
83
- botIdentifierFromState,
84
- summarizeShieldGenerator,
85
- summarizeShipControl,
86
- summarizeShipSize,
87
- summarizeShipWorldMetadata,
88
- summarizeItemCrate,
89
- scaledSizeSummary,
90
- summarizeExpandoBox,
91
- summarizeHoverOutline,
92
- summarizeMapMarker,
93
- summarizeDockingSpring,
94
- summarizeHugeThruster,
95
- summarizeLoader,
96
- summarizeCargoHatch,
97
- summarizePusher,
98
- summarizePusherBeam,
99
- normalizeDegrees,
100
- summarizeItemLauncher,
101
- summarizeSign,
102
- summarizeShieldProjector,
103
- summarizeSpawnPoint,
104
- summarizeDoor,
105
- enumValueName,
106
- colorToCss,
107
- previewActionName,
108
- bitOffsets,
109
- summarizeBlueprintPreview,
110
- summarizePlayerPreview,
111
- summarizePlayer,
112
- patronTierName,
113
- mergeContents,
114
- MAX_HOVER_OUTLINE_FOOTPRINT_CELLS,
115
- entityFootprint,
116
- entityLabel,
117
- entityCategory,
118
- BLOCK_KEY_OFFSET,
119
- BLOCK_KEY_STRIDE,
120
- blockKey,
121
- EMPTY_TABLE,
122
- MAX_RETAINED_REMOVALS,
123
- MAX_RETAINED_ERRORS,
124
- pushCapped,
125
- defineLazyProperty,
126
- } = modelDecoder;
127
-
128
- export class ModelState {
129
- #loaderConfig = new LoaderConfigTracker();
130
- #blueprintItems = null;
131
- #helmOccupied = new Map();
132
- #commsStationOccupied = new Map();
133
- #navigationUnitAutoWarp = new Map();
134
-
135
- constructor({isOverworld = null} = {}) {
136
- this.isOverworld = isOverworld == null ? null : Boolean(isOverworld);
137
- this.generation = null;
138
- this.tables = new Map();
139
- this.removedEntities = [];
140
- this.lastUpdate = null;
141
- this.errors = [];
142
- // Retained arrays are capped; the totals remain exact.
143
- this.totalRemovedCount = 0;
144
- this.totalErrorCount = 0;
145
- this._derived = null;
146
- }
147
-
148
- setWorldKind(isOverworld) {
149
- const next = isOverworld == null ? null : Boolean(isOverworld);
150
- if (this.isOverworld === next) return;
151
- this.isOverworld = next;
152
- this.#invalidateDerived();
153
- }
154
-
155
- table(id) {
156
- // Shared empty map: #summarizeEntity performs ~32 record() lookups per
157
- // entity, most against absent tables, and each miss used to allocate.
158
- return this.tables.get(Number(id)) || EMPTY_TABLE;
159
- }
160
-
161
- record(tableId, entityId) {
162
- return this.table(tableId).get(Number(entityId)) || null;
163
- }
164
-
165
- entity(entityId) {
166
- const id = Number(entityId);
167
- if (!Number.isFinite(id)) return null;
168
- return this.#derivedState().entitiesById.get(id) || null;
169
- }
170
-
171
- entities() {
172
- return this.#derivedState().entities.slice();
173
- }
174
-
175
- blocks() {
176
- this.#derivedState();
177
- return this.#derivedBlocks().slice();
178
- }
179
-
180
- // O(1) point lookup. Callers previously had to materialise and scan the whole
181
- // block list to answer this.
182
- blockAt(x, y) {
183
- this.#derivedState();
184
- return this.#blocksIndex().get(blockKey(x, y)) || null;
185
- }
186
-
187
- apply(bytes, {full = false} = {}) {
188
- this.#blueprintItems = null;
189
- const reader = new ModelReader(bytes);
190
- const update = {
191
- generation: null,
192
- full: Boolean(full),
193
- changedEntities: new Set(),
194
- sections: [],
195
- removals: [],
196
- unknownTags: [],
197
- error: null
198
- };
199
-
200
- try {
201
- update.generation = reader.readStreamInt();
202
- this.generation = update.generation;
203
-
204
- while (reader.remaining > 0) {
205
- if (reader.trailingZeroOnly()) break;
206
- let tag;
207
- try {
208
- tag = reader.readStreamInt();
209
- } catch (error) {
210
- throw new Error(`model section tag offset ${reader.offset}: ${error.message}`);
211
- }
212
- if (tag === 0) break;
213
- if (tag === 57005) {
214
- update.removals.push(...this.#readRemovals(reader));
215
- continue;
216
- }
217
-
218
- const tableId = WIRE_TAG_TABLES.get(tag);
219
- if (tableId == null) {
220
- update.unknownTags.push({tag, offset: reader.offset});
221
- // Some live captures end with a terminal, empty section tag we do not
222
- // yet have a table mapping for. If the rest of the packet is just
223
- // zero terminators, keep the decoded sections and stop cleanly rather
224
- // than turning the whole frame into a decode error.
225
- if (reader.trailingZeroOnly()) break;
226
- throw new Error(`unsupported model_data section tag ${tag}`);
227
- }
228
-
229
- const section = this.#readSection(reader, tag, tableId);
230
- update.sections.push(section);
231
- for (const record of section.records) update.changedEntities.add(record.entity);
232
- }
233
- } catch (error) {
234
- update.error = error;
235
- this.totalErrorCount += 1;
236
- pushCapped(this.errors, {message: error.message, generation: update.generation}, MAX_RETAINED_ERRORS);
237
- }
238
-
239
- this.#remapIndexedLoaderConfig(update);
240
- this.#updateLoaderConfig(update);
241
- this.#updateNavigationUnitAutoWarp(update);
242
- this.#updateHelmOccupancy(update);
243
- this.#updateCommsStationOccupancy(update);
244
- this.lastUpdate = update;
245
- this.#updateDerived(update);
246
- return update;
247
- }
248
-
249
- // `blocks` is opt-in: materialising and sorting the per-cell index is the most
250
- // expensive part of a snapshot, and most callers never read it. Mirrors how
251
- // `includeTiles` already works on WorldState.snapshot().
252
- snapshot({includeTables = false, includeBlocks = false} = {}) {
253
- const derived = this.#derivedState();
254
- return {
255
- generation: this.generation,
256
- tableCount: this.tables.size,
257
- entityCount: derived.entityCount,
258
- removedEntities: this.removedEntities.slice(-50),
259
- lastUpdate: this.lastUpdate ? summarizeUpdate(this.lastUpdate) : null,
260
- errors: this.errors.slice(-10),
261
- entities: derived.entities.slice(),
262
- blocks: includeBlocks ? this.#derivedBlocks().slice() : undefined,
263
- transforms: derived.transforms.slice(),
264
- players: derived.players.slice(),
265
- shipControls: derived.shipControls.slice(),
266
- machines: this.#machinesSnapshot(derived.machines),
267
- tables: includeTables ? this.tablesSnapshot() : derived.tableSummaries.slice()
268
- };
269
- }
270
-
271
- tablesSnapshot() {
272
- return [...this.tables.entries()].map(([id, records]) => ({
273
- id,
274
- name: MODEL_TABLE_SPECS.get(id)?.name || null,
275
- records: [...records.entries()].map(([entity, record]) => ({entity, ...cloneRecord(record)}))
276
- }));
277
- }
278
-
279
- transforms() {
280
- return this.#derivedState().transforms.slice();
281
- }
282
-
283
- itemHolders() {
284
- return this.#records(6).map((entry) => summarizeItemHolder(entry.entity, entry));
285
- }
286
-
287
- fabricators() {
288
- return this.#records(53).map((entry) => summarizeFabricator(entry.entity, entry, this.record(6, entry.entity), this.record(7, entry.entity)));
289
- }
290
-
291
- players() {
292
- return this.#derivedState().players.slice();
293
- }
294
-
295
- shipControls() {
296
- return this.#derivedState().shipControls.slice();
297
- }
298
-
299
- shipMetadata() {
300
- if (this.isOverworld) return null;
301
- const lockdownRecord = firstRecord(this.table(11));
302
- const shipRecord = firstRecord(this.table(16), (record) => record?.blob20 && typeof record?.q32 === "number" && typeof record?.q36 === "number");
303
- return summarizeShipWorldMetadata(lockdownRecord, shipRecord);
304
- }
305
-
306
- machines() {
307
- return this.#machinesSnapshot(this.#derivedState().machines);
308
- }
309
-
310
- #machinesSnapshot(machines) {
311
- return {
312
- itemHolders: machines.itemHolders.slice(),
313
- fabricators: machines.fabricators.slice(),
314
- cargoEjectors: machines.cargoEjectors.slice(),
315
- cannons: machines.cannons.slice(),
316
- thrusters: machines.thrusters.slice(),
317
- pushers: machines.pushers.slice(),
318
- pusherBeams: machines.pusherBeams.slice(),
319
- launchers: machines.launchers.slice(),
320
- health: machines.health.slice(),
321
- loaders: machines.loaders.slice(),
322
- cargoHatches: machines.cargoHatches.slice(),
323
- navigationUnits: machines.navigationUnits.slice(),
324
- commsStations: machines.commsStations.slice(),
325
- fluidTanks: machines.fluidTanks.slice(),
326
- shieldGenerators: machines.shieldGenerators.slice(),
327
- shieldProjectors: machines.shieldProjectors.slice(),
328
- helms: machines.helms.slice(),
329
- signs: machines.signs.slice(),
330
- spawnPoints: machines.spawnPoints.slice(),
331
- doors: machines.doors.slice(),
332
- expandoBoxes: machines.expandoBoxes.slice()
333
- };
334
- }
335
-
336
- #invalidateDerived() {
337
- this._derived = null;
338
- }
339
-
340
- #updateDerived(update) {
341
- if (!this._derived) return;
342
- if (!update.removals.length && !update.changedEntities.size) return;
343
-
344
- for (const section of update.sections) {
345
- for (const record of section.records || []) this.#addDerivedEntityTable(record.entity, section.table);
346
- }
347
-
348
- if (update.sections.some((section) => section.table === 12)) {
349
- for (const entityId of this.table(55).keys()) update.changedEntities.add(entityId);
350
- }
351
- for (const entityId of update.removals) this.#removeDerivedEntity(entityId);
352
- for (const entityId of update.changedEntities) this.#refreshDerivedEntity(entityId);
353
- this._derived.summariesDirty = true;
354
- this._derived.tableSummariesDirty = true;
355
- }
356
-
357
- #updateLoaderConfig(update) {
358
- const seenEntities = new Set();
359
- for (const section of update.sections || []) {
360
- if (section.table !== 78) continue;
361
- for (const changed of section.records || []) {
362
- if (changed.indexedLoaderConfig) {
363
- this.#loaderConfig.updateIndexedSnapshotRecord(null, changed.entity, changed.record, changed.mask, changed.cumulativeRecord, changed.configEntity);
364
- continue;
365
- }
366
- const typeId = entityTypeIdFromRecord(this.record(7, changed.entity));
367
- this.#loaderConfig.updateRecord(null, changed.entity, changed.record ?? this.record(78, changed.entity), changed.mask, changed.previous, {
368
- repeatedInUpdate: seenEntities.has(changed.entity),
369
- allowSparseBaseline: typeId === LOADER_TYPE_ID,
370
- repeatedInSection: changed.repeatedInSection,
371
- semanticSnapshot: Boolean(update.full),
372
- semanticDelta: isSemanticLoaderDelta(changed, update.full),
373
- deltaRecord: table78DeltaRecord(changed)
374
- });
375
- seenEntities.add(changed.entity);
376
- }
377
- }
378
- }
379
-
380
- #remapIndexedLoaderConfig(update) {
381
- if (!update.full || update.error) return;
382
- const rows = [];
383
- for (const section of update.sections || []) {
384
- if (section.table !== 78) continue;
385
- for (const changed of section.records || []) rows.push(changed);
386
- }
387
- if (!rows.length) return;
388
-
389
- const loaderIds = [...this.table(7).entries()]
390
- .filter(([, record]) => entityTypeIdFromRecord(record) === LOADER_TYPE_ID)
391
- .map(([entity]) => entity)
392
- .sort((a, b) => a - b);
393
- if (loaderIds.length !== rows.length) return;
394
- if (rows.every((row, index) => row.entity === loaderIds[index])) return;
395
-
396
- const remappedTable = new Map();
397
- for (const [index, changed] of rows.entries()) {
398
- const rawRecord = table78DeltaRecord(changed);
399
- changed.configEntity = changed.entity;
400
- changed.entity = loaderIds[index];
401
- changed.cumulativeRecord = cloneRecord(changed.record || {});
402
- changed.record = cloneRecord(rawRecord);
403
- delete changed.previous;
404
- changed.indexedLoaderConfig = true;
405
- remappedTable.set(changed.entity, cloneRecord(rawRecord));
406
- update.changedEntities.add(changed.entity);
407
- }
408
- this.tables.set(78, remappedTable);
409
- }
410
-
411
- #updateNavigationUnitAutoWarp(update) {
412
- const trackedBeforeUpdate = new Set(this.#navigationUnitAutoWarp.keys());
413
- const initializedThisUpdate = new Set();
414
-
415
- for (const section of update.sections || []) {
416
- if (section.table !== 78) continue;
417
- for (const changed of section.records || []) {
418
- const typeId = entityTypeIdFromRecord(this.record(7, changed.entity));
419
- if (typeId !== NAVIGATION_UNIT_TYPE_ID) continue;
420
-
421
- const record = this.record(78, changed.entity);
422
- if (!record) continue;
423
-
424
- if (!trackedBeforeUpdate.has(changed.entity)) {
425
- if (initializedThisUpdate.has(changed.entity)) continue;
426
- const q32Destination = navigationDestinationFromEncodedValue(record.q32);
427
- const q36Destination = navigationDestinationFromEncodedValue(record.q36);
428
- const q24Destination = navigationDestinationFromEncodedValue(record.q24);
429
- const q20Destination = navigationDestinationFromEncodedValue(record.q20);
430
- const q32DestinationRow = (changed.mask & 9) === 9 && record.q20 === 0 && record.q24 == null && q32Destination != null;
431
- const destinationBase = q32DestinationRow
432
- ? q32Destination
433
- : (changed.mask & 17) === 17 && record.q20 === 0 && record.q24 == null && record.q32 == null && q36Destination != null
434
- ? q36Destination
435
- : record.q20 === 0 && q24Destination != null
436
- ? q24Destination
437
- : null;
438
- const q36DestinationRow = destinationBase === q36Destination && q36Destination != null;
439
- const pureDestinationRow = changed.mask === 1 && q20Destination != null;
440
- const noCaptainsOnlyRow = changed.mask === 16 && record.q36 === 0;
441
- this.#navigationUnitAutoWarp.set(changed.entity, {
442
- destination: navigationDestinationFromRecord(record, changed.mask),
443
- destinationBase,
444
- shieldFailure: pureDestinationRow || q36DestinationRow || noCaptainsOnlyRow ? true : q32DestinationRow ? false : record.q32 == null ? (record.q24 == null ? null : true) : false,
445
- noCaptains: q32DestinationRow && changed.mask === 9
446
- ? true
447
- : pureDestinationRow ? true
448
- : noCaptainsOnlyRow ? false
449
- : q36DestinationRow ? false : record.q36 == null ? (record.q24 == null ? null : true) : false
450
- });
451
- initializedThisUpdate.add(changed.entity);
452
- continue;
453
- }
454
-
455
- const current = this.#navigationUnitAutoWarp.get(changed.entity);
456
- if (!current) continue;
457
- const q32Destination = navigationDestinationFromEncodedValue(record.q32);
458
- const q36Destination = navigationDestinationFromEncodedValue(record.q36);
459
- const q20Destination = navigationDestinationFromEncodedValue(record.q20);
460
- const q32DestinationRow = (changed.mask & 9) === 9 && record.q20 === 0 && record.q24 == null && q32Destination != null;
461
- const q36DestinationRow = (changed.mask & 17) === 17 && record.q20 === 0 && record.q24 == null && record.q32 == null && q36Destination != null;
462
- const baselineRow = (changed.mask & 27) === 27 && record.q20 === 0 && record.q24 != null && record.q32 != null && record.q36 != null;
463
- if (q32DestinationRow) {
464
- current.destination = q32Destination;
465
- current.destinationBase = q32Destination;
466
- current.shieldFailure = false;
467
- if (record.q36 != null) current.noCaptains = record.q36 !== 0;
468
- else if (changed.mask === 9) current.noCaptains = true;
469
- continue;
470
- }
471
- if (q36DestinationRow) {
472
- current.destination = q36Destination;
473
- current.destinationBase = q36Destination;
474
- continue;
475
- }
476
- if (baselineRow) {
477
- current.shieldFailure = false;
478
- current.noCaptains = record.q36 !== 0;
479
- continue;
480
- }
481
- if (changed.mask === 1) {
482
- const relativeDestination = current.destinationBase == null || typeof record.q20 !== "number"
483
- ? null
484
- : current.destinationBase + record.q20;
485
- current.destination = q20Destination ?? (isNavigationDestination(relativeDestination) ? relativeDestination : current.destination);
486
- } else if ((changed.mask & 1) && record.q20 != null && record.q20 !== 0) {
487
- current.destination = q20Destination ?? (isNavigationDestination(record.q20) ? record.q20 : current.destination);
488
- }
489
- if (changed.mask & 8) current.shieldFailure = !current.shieldFailure;
490
- if (changed.mask & 16) current.noCaptains = !current.noCaptains;
491
- }
492
- }
493
- }
494
-
495
- #updateHelmOccupancy(update) {
496
- const table7 = update.sections.find((section) => section.table === 7);
497
- if (!table7) return;
498
-
499
- const changedPilotState = new Map();
500
- for (const section of update.sections) {
501
- if (section.table !== 55) continue;
502
- for (const changed of section.records) {
503
- if (!(changed.mask & 16)) continue;
504
- const player = this.record(55, changed.entity);
505
- changedPilotState.set(changed.entity, Boolean(player?.q107));
506
- }
507
- }
508
-
509
- const activePilotCount = [...this.table(55).values()].filter((record) => record?.q107).length;
510
- for (const changed of table7.records) {
511
- const record = this.record(7, changed.entity);
512
- const typeId = entityTypeIdFromRecord(record);
513
- if (!HELM_TYPE_IDS.has(typeId)) continue;
514
-
515
- if (activePilotCount > 0 && record?.q20 === 0 && HELM_TYPE_IDS.has(Number(record?.q32))) {
516
- this.#helmOccupied.set(changed.entity, true);
517
- }
518
-
519
- if (!(changed.mask & 8) || changedPilotState.size === 0) continue;
520
- this.#helmOccupied.set(changed.entity, [...changedPilotState.values()].some(Boolean));
521
- }
522
- }
523
-
524
- #updateCommsStationOccupancy(update) {
525
- const table7 = update.sections.find((section) => section.table === 7);
526
- if (!table7) return;
527
-
528
- for (const changed of table7.records) {
529
- const record = this.record(7, changed.entity);
530
- const typeId = entityTypeIdFromRecord(record);
531
- if (typeId !== COMMS_STATION_TYPE_ID) continue;
532
-
533
- const hasAbsoluteTypeState = update.full || (changed.mask & 1);
534
- if (hasAbsoluteTypeState && record?.q20 === 0 && Number(record?.q32) === COMMS_STATION_TYPE_ID) {
535
- this.#commsStationOccupied.set(changed.entity, true);
536
- continue;
537
- }
538
-
539
- if (hasAbsoluteTypeState && Number(record?.q20) === COMMS_STATION_TYPE_ID) {
540
- this.#commsStationOccupied.set(changed.entity, false);
541
- }
542
-
543
- if (changed.mask & 8) {
544
- this.#commsStationOccupied.set(changed.entity, !this.#commsStationOccupied.get(changed.entity));
545
- }
546
- }
547
- }
548
-
549
- #derivedState() {
550
- if (this._derived) {
551
- this.#ensureDerivedSummaries();
552
- return this._derived;
553
- }
554
-
555
- const entityIds = [];
556
- const seenEntities = new Set();
557
- const entityTableIds = new Map();
558
- const tableSummaries = [];
559
-
560
- for (const [tableId, records] of this.tables.entries()) {
561
- const name = MODEL_TABLE_SPECS.get(tableId)?.name || null;
562
- tableSummaries.push({id: tableId, name, count: records.size});
563
- for (const entity of records.keys()) {
564
- if (!entityTableIds.has(entity)) entityTableIds.set(entity, []);
565
- entityTableIds.get(entity).push(tableId);
566
- if (!seenEntities.has(entity)) {
567
- seenEntities.add(entity);
568
- entityIds.push(entity);
569
- }
570
- }
571
- }
572
-
573
- entityIds.sort((a, b) => a - b);
574
- const entities = entityIds.map((entityId) => this.#summarizeEntity(entityId, this.#tableRowsForEntity(entityId)));
575
- const entitiesById = new Map(entities.map((entity) => [entity.entity, entity]));
576
-
577
- this._derived = {
578
- entityCount: entityIds.length,
579
- entityIds,
580
- entityTableIds,
581
- entitiesById,
582
- blocksByKey: null,
583
- blocks: null,
584
- blocksDirty: true,
585
- tableSummaries,
586
- summariesDirty: true,
587
- tableSummariesDirty: false
588
- };
589
- this.#refreshDerivedSummaries();
590
- return this._derived;
591
- }
592
-
593
- #tableRowsForEntity(entityId) {
594
- const rows = [];
595
- const tableIds = this._derived?.entityTableIds?.get(entityId) || this.tables.keys();
596
- for (const tableId of tableIds) {
597
- const record = this.tables.get(tableId)?.get(entityId);
598
- if (record) rows.push({tableId, name: MODEL_TABLE_SPECS.get(tableId)?.name || null, record});
599
- }
600
- return rows;
601
- }
602
-
603
- #addDerivedEntityTable(entityId, tableId) {
604
- const derived = this._derived;
605
- if (!derived) return;
606
- let tableIds = derived.entityTableIds.get(entityId);
607
- if (!tableIds) {
608
- tableIds = [];
609
- derived.entityTableIds.set(entityId, tableIds);
610
- }
611
- if (!tableIds.includes(tableId)) insertSorted(tableIds, tableId);
612
- }
613
-
614
- #removeDerivedEntity(entityId) {
615
- const derived = this._derived;
616
- const old = derived.entitiesById.get(entityId);
617
- if (old) this.#removeEntityFromDerivedBlocks(derived, old);
618
- derived.entitiesById.delete(entityId);
619
- derived.entityTableIds.delete(entityId);
620
- const index = derived.entityIds.indexOf(entityId);
621
- if (index >= 0) derived.entityIds.splice(index, 1);
622
- derived.entityCount = derived.entityIds.length;
623
- }
624
-
625
- #refreshDerivedEntity(entityId) {
626
- const rows = this.#tableRowsForEntity(entityId);
627
- if (!rows.length) {
628
- this.#removeDerivedEntity(entityId);
629
- return;
630
- }
631
-
632
- const derived = this._derived;
633
- const old = derived.entitiesById.get(entityId);
634
- if (old) this.#removeEntityFromDerivedBlocks(derived, old);
635
-
636
- const next = this.#summarizeEntity(entityId, rows);
637
- derived.entitiesById.set(entityId, next);
638
- if (!derived.entityIds.includes(entityId)) insertSorted(derived.entityIds, entityId);
639
- this.#addEntityToDerivedBlocks(derived, next);
640
- derived.entityCount = derived.entityIds.length;
641
- }
642
-
643
- #tableSummaries() {
644
- return [...this.tables.entries()].map(([id, records]) => ({
645
- id,
646
- name: MODEL_TABLE_SPECS.get(id)?.name || null,
647
- count: records.size
648
- }));
649
- }
650
-
651
- // The per-cell block index is the most expensive derived product to build --
652
- // it walks every occupied cell of every entity. Most consumers never ask for
653
- // blocks, so it is constructed on first use rather than during the rebuild.
654
- #blocksIndex() {
655
- const derived = this._derived;
656
- if (derived.blocksByKey) return derived.blocksByKey;
657
- const blocks = new Map();
658
- for (const entityId of derived.entityIds) {
659
- const entity = derived.entitiesById.get(entityId);
660
- if (entity) this.#addEntityToBlockMap(blocks, entity);
661
- }
662
- derived.blocksByKey = blocks;
663
- derived.blocksDirty = true;
664
- return blocks;
665
- }
666
-
667
- #addEntityToDerivedBlocks(derived, entity) {
668
- // Nothing to maintain until something has asked for the index; it will be
669
- // built from current entities on demand.
670
- if (!derived.blocksByKey) return;
671
- this.#addEntityToBlockMap(derived.blocksByKey, entity);
672
- derived.blocksDirty = true;
673
- }
674
-
675
- #addEntityToBlockMap(blocks, entity) {
676
- if (!entity.transform || !Number.isFinite(entity.transform.x) || !Number.isFinite(entity.transform.y)) return;
677
- const footprint = entity.footprint || {width: 1, height: 1};
678
- const startX = Math.floor(entity.transform.x);
679
- const startY = Math.floor(entity.transform.y);
680
- for (let dx = 0; dx < footprint.width; dx++) {
681
- for (let dy = 0; dy < footprint.height; dy++) {
682
- const x = startX + dx;
683
- const y = startY + dy;
684
- const key = blockKey(x, y);
685
- let block = blocks.get(key);
686
- if (block === undefined) {
687
- block = {x, y, entities: []};
688
- blocks.set(key, block);
689
- }
690
- block.entities.push(entity);
691
- }
692
- }
693
- }
694
-
695
- #removeEntityFromDerivedBlocks(derived, entity) {
696
- if (!derived.blocksByKey) return;
697
- const id = entity.entity;
698
- for (const cell of entity.occupies || []) {
699
- const key = blockKey(cell.x, cell.y);
700
- const block = derived.blocksByKey.get(key);
701
- if (!block) continue;
702
- // Splice in place rather than allocating a filtered array per cell.
703
- const list = block.entities;
704
- for (let i = list.length - 1; i >= 0; i--) {
705
- if (list[i].entity === id) list.splice(i, 1);
706
- }
707
- if (!list.length) derived.blocksByKey.delete(key);
708
- derived.blocksDirty = true;
709
- }
710
- }
711
-
712
- #ensureDerivedSummaries() {
713
- const derived = this._derived;
714
- if (!derived) return;
715
- if (derived.tableSummariesDirty) {
716
- derived.tableSummaries = this.#tableSummaries();
717
- derived.tableSummariesDirty = false;
718
- }
719
- if (derived.summariesDirty) this.#refreshDerivedSummaries();
720
- }
721
-
722
- #refreshDerivedSummaries() {
723
- const derived = this._derived;
724
- const entities = derived.entityIds.map((entityId) => derived.entitiesById.get(entityId)).filter(Boolean);
725
- const machines = {
726
- itemHolders: [],
727
- fabricators: [],
728
- cargoEjectors: [],
729
- cannons: [],
730
- thrusters: [],
731
- pushers: [],
732
- pusherBeams: [],
733
- launchers: [],
734
- health: [],
735
- loaders: [],
736
- cargoHatches: [],
737
- navigationUnits: [],
738
- commsStations: [],
739
- fluidTanks: [],
740
- shieldGenerators: [],
741
- shieldProjectors: [],
742
- helms: [],
743
- signs: [],
744
- spawnPoints: [],
745
- doors: [],
746
- expandoBoxes: []
747
- };
748
- const players = [];
749
- const shipControls = [];
750
- const transforms = [];
751
-
752
- for (const entity of entities) {
753
- if (entity.transform) transforms.push(entity.transform);
754
- const contents = entity.contents;
755
- if (!contents) continue;
756
- if (contents.itemHolder) machines.itemHolders.push(contents.itemHolder);
757
- if (contents.fabricator) machines.fabricators.push(contents.fabricator);
758
- if (contents.cargoEjector) machines.cargoEjectors.push(contents.cargoEjector);
759
- if (contents.cannon) machines.cannons.push(contents.cannon);
760
- if (contents.thruster) machines.thrusters.push(contents.thruster);
761
- if (contents.pusher) machines.pushers.push(contents.pusher);
762
- if (contents.pusherBeam) machines.pusherBeams.push(contents.pusherBeam);
763
- if (contents.launcher) machines.launchers.push(contents.launcher);
764
- if (contents.health) machines.health.push(contents.health);
765
- if (contents.loader) machines.loaders.push(contents.loader);
766
- if (contents.cargoHatch) machines.cargoHatches.push(contents.cargoHatch);
767
- if (contents.navigationUnit) machines.navigationUnits.push(contents.navigationUnit);
768
- if (contents.commsStation) machines.commsStations.push(contents.commsStation);
769
- if (contents.fluidTank) machines.fluidTanks.push(contents.fluidTank);
770
- if (contents.shieldGenerator) machines.shieldGenerators.push(contents.shieldGenerator);
771
- if (contents.shieldProjector) machines.shieldProjectors.push(contents.shieldProjector);
772
- if (contents.helm) machines.helms.push(contents.helm);
773
- if (contents.sign) machines.signs.push(contents.sign);
774
- if (contents.spawnPoint) machines.spawnPoints.push(contents.spawnPoint);
775
- if (contents.door) machines.doors.push(contents.door);
776
- if (contents.expandoBox) machines.expandoBoxes.push(contents.expandoBox);
777
- if (contents.player) players.push(contents.player);
778
- if (contents.shipControl) shipControls.push(contents.shipControl);
779
- }
780
-
781
- derived.entities = entities;
782
- derived.transforms = transforms;
783
- derived.players = players;
784
- derived.shipControls = shipControls;
785
- derived.machines = machines;
786
- derived.summariesDirty = false;
787
- }
788
-
789
- // The sorted block list is the single most expensive derived product (a sort
790
- // over every occupied cell in the world). Build it only when a caller actually
791
- // asks for blocks, rather than as a side effect of every summary refresh.
792
- #derivedBlocks() {
793
- const derived = this._derived;
794
- const index = this.#blocksIndex();
795
- if (derived.blocksDirty || !derived.blocks) {
796
- derived.blocks = [...index.values()].sort((a, b) => (a.y - b.y) || (a.x - b.x));
797
- derived.blocksDirty = false;
798
- }
799
- return derived.blocks;
800
- }
801
-
802
- // Identical for every entity in a refresh pass, but was rebuilt inside each
803
- // #summarizeEntity call -- O(entities x table12). Table 12 only changes during
804
- // apply(), which clears this cache.
805
- #blueprintPreviewItems() {
806
- if (this.#blueprintItems) return this.#blueprintItems;
807
- const items = [];
808
- for (const [entity, record] of this.table(12)) {
809
- const summary = summarizeBlueprintPreview(entity, record, this.record(0, entity));
810
- if (summary) items.push(summary);
811
- }
812
- this.#blueprintItems = items;
813
- return items;
814
- }
815
-
816
- #summarizeEntity(entityId, tableRows = []) {
817
- // tableRows already lists exactly the tables this entity appears in -- the
818
- // same source the public `tables` field is built from. Reading through it
819
- // avoids ~32 lookups against tables the entity is not in.
820
- const byTable = new Map();
821
- for (const row of tableRows) byTable.set(row.tableId, row.record);
822
- const rec = (tableId) => byTable.get(tableId) ?? null;
823
- const transformRecord = rec(0);
824
- const itemHolderRecord = rec(6);
825
- const healthRecord = rec(5);
826
- const fabricatorRecord = rec(53);
827
- const cargoEjectorRecord = rec(49);
828
- const cannonRecord = rec(54);
829
- const pusherRecord = rec(72);
830
- const pusherBeamRecord = rec(42);
831
- const launcherRecord = rec(44);
832
- const loaderRecord = rec(78);
833
- const loaderFilterRecord = rec(76);
834
- const loaderFilterSlotsRecord = rec(77);
835
- const commsStationRecord = rec(39);
836
- const fluidTankRecord = rec(60);
837
- const shieldRecord = rec(61);
838
- const shieldGeneratorBoostRecord = rec(75);
839
- const shieldProjectorRecord = rec(62);
840
- const playerRecord = rec(55);
841
- const shipControlRecord = rec(20);
842
- const signRecord = rec(41);
843
- const spawnPointRecord = rec(8);
844
- const doorRecord = rec(47);
845
- const thrusterRecord = rec(50);
846
- const labelRecord = rec(9);
847
- const zoneLabelRecord = rec(25);
848
- const dockingSpringRecord = rec(26);
849
- const hugeThrusterRecord = rec(23);
850
- const bodyStateRecord = rec(1);
851
- const typeRecord = rec(7);
852
- const crateSizeRecord = rec(3);
853
- const blueprintPreviewRecord = rec(12);
854
- const expandoSizeRecord = rec(51);
855
- const crateItemRecord = rec(19);
856
- const markerTableIds = [73].filter((tableId) => this.record(tableId, entityId));
857
- const markerTypeId = markerTypeIdForTables(markerTableIds);
858
- const markerTypeName = entityNameFromType(markerTypeId);
859
- const looseItemMarker = Boolean(rec(37));
860
- const dynamicBody = bodyStateRecord?.q20 === -4;
861
- const typeId = entityTypeIdFromRecord(typeRecord);
862
- const itemHolder = summarizeItemHolder(entityId, itemHolderRecord);
863
- const isExpandoBox = typeId === EXPANDO_BOX_TYPE_ID || markerTypeId === EXPANDO_BOX_TYPE_ID;
864
- const health = summarizeHealth(entityId, healthRecord);
865
- const fabricator = summarizeFabricator(entityId, fabricatorRecord, itemHolderRecord, typeRecord);
866
- const cargoEjector = summarizeCargoEjector(entityId, typeId, cargoEjectorRecord);
867
- const cannon = summarizeCannon(entityId, cannonRecord, typeId);
868
- const thruster = summarizeThruster(entityId, typeId, thrusterRecord);
869
- const pusher = summarizePusher(entityId, pusherRecord, loaderFilterSlotsRecord);
870
- const pusherBeam = summarizePusherBeam(entityId, pusherBeamRecord);
871
- const launcher = summarizeItemLauncher(entityId, typeId, launcherRecord);
872
- const loader = (typeId === LOADER_TYPE_ID || (typeId == null && (loaderRecord || loaderFilterRecord || loaderFilterSlotsRecord)))
873
- ? summarizeLoader(entityId, loaderRecord, loaderFilterRecord, loaderFilterSlotsRecord, this.#loaderConfig, typeId === LOADER_TYPE_ID, itemHolderRecord)
874
- : null;
875
- const cargoHatch = summarizeCargoHatch(entityId, typeId, loaderFilterRecord, loaderFilterSlotsRecord, rec(45));
876
- const navigationUnit = summarizeNavigationUnit(entityId, typeId, loaderRecord, this.#navigationUnitAutoWarp.get(entityId));
877
- const fluidTank = fluidTankRecord ? {
878
- entity: entityId,
879
- amount: fluidTankRecord.q24 ?? null,
880
- state: cloneRecord(fluidTankRecord)
881
- } : null;
882
- const shieldGenerator = typeId === 256 ? summarizeShieldGenerator(entityId, shieldRecord, itemHolderRecord, shieldGeneratorBoostRecord) : null;
883
- const shieldProjector = typeId === 257 ? summarizeShieldProjector(entityId, shieldProjectorRecord) : null;
884
- const blueprintPreview = summarizeBlueprintPreview(entityId, blueprintPreviewRecord, transformRecord);
885
- const player = summarizePlayer(entityId, playerRecord, rec(14), this.#blueprintPreviewItems());
886
- const shipControl = summarizeShipControl(entityId, shipControlRecord);
887
- const helm = summarizeHelm(entityId, typeId, this.#helmOccupied.get(entityId));
888
- const commsStation = summarizeCommsStation(entityId, typeId, commsStationRecord, this.#commsStationOccupied.get(entityId));
889
- const sign = typeId === 218 ? summarizeSign(entityId, signRecord) : null;
890
- const spawnPoint = typeId === 219 ? summarizeSpawnPoint(entityId, spawnPointRecord) : null;
891
- const door = typeId === 220 ? summarizeDoor(entityId, spawnPointRecord, doorRecord) : null;
892
- const shipSize = shipControl && this.isOverworld ? summarizeShipSize(entityId, rec(3)) : null;
893
- const hoverOutline = summarizeHoverOutline(entityId, crateSizeRecord);
894
- const expandoBox = isExpandoBox ? summarizeExpandoBox(entityId, itemHolderRecord, expandoSizeRecord) : null;
895
- const mapMarker = this.isOverworld ? summarizeMapMarker(entityId, labelRecord, zoneLabelRecord, crateSizeRecord) : null;
896
- const dockingSpring = this.isOverworld
897
- ? summarizeDockingSpring(entityId, dockingSpringRecord, bodyStateRecord, crateSizeRecord)
898
- : null;
899
- const hugeThruster = this.isOverworld
900
- ? summarizeHugeThruster(entityId, hugeThrusterRecord, crateSizeRecord)
901
- : null;
902
- const itemCrate = this.isOverworld && !rec(2) && !rec(18) && health && crateSizeRecord
903
- ? summarizeItemCrate(entityId, crateSizeRecord, crateItemRecord, healthRecord)
904
- : null;
905
- const bot = this.isOverworld
906
- ? summarizeBot(entityId, {
907
- health,
908
- table2Record: rec(2),
909
- smallRecord: crateSizeRecord,
910
- combatRecord: rec(18),
911
- motionRecord: crateItemRecord,
912
- table10Record: rec(10),
913
- table51Record: rec(51),
914
- itemCrate,
915
- player,
916
- shipControl
917
- })
918
- : null;
919
- const transform = transformRecord ? {
920
- entity: entityId,
921
- x: numberOrNull(transformRecord.q20, 40),
922
- y: numberOrNull(transformRecord.q24, 40),
923
- rot: numberOrNull(transformRecord.q28, 127.324),
924
- flags: [transformRecord.q33, transformRecord.q34, transformRecord.q35].filter((value) => value != null)
925
- } : null;
926
- const contents = mergeContents({
927
- itemHolder, expandoBox, hoverOutline, itemCrate, mapMarker, dockingSpring,
928
- hugeThruster, blueprintPreview, health, bot, fabricator, cargoEjector,
929
- cannon, thruster, pusher, pusherBeam, launcher, loader, cargoHatch,
930
- navigationUnit, commsStation, fluidTank, shieldGenerator, shieldProjector,
931
- helm, player, shipControl, sign, spawnPoint, door, shipSize
932
- });
933
- const footprint = entityFootprint({
934
- entity: entityId,
935
- typeId,
936
- markerTypeId,
937
- itemHolder,
938
- expandoBox,
939
- hoverOutline,
940
- itemCrate,
941
- hugeThruster,
942
- fabricator,
943
- cargoEjector,
944
- cannon,
945
- thruster,
946
- pusher,
947
- launcher,
948
- loader,
949
- cargoHatch,
950
- navigationUnit,
951
- commsStation,
952
- fluidTank,
953
- shieldGenerator,
954
- shieldProjector,
955
- helm,
956
- player,
957
- shipControl
958
- });
959
- const typeName = entityNameFromType(typeId);
960
- const category = entityCategory({
961
- typeId,
962
- markerTypeId,
963
- looseItemMarker,
964
- dynamicBody,
965
- transform,
966
- itemHolder,
967
- itemCrate,
968
- mapMarker,
969
- dockingSpring,
970
- hugeThruster,
971
- blueprintPreview,
972
- fabricator,
973
- cargoEjector,
974
- cannon,
975
- pusher,
976
- launcher,
977
- loader,
978
- cargoHatch,
979
- navigationUnit,
980
- commsStation,
981
- fluidTank,
982
- shieldGenerator,
983
- shieldProjector,
984
- helm,
985
- player,
986
- shipControl
987
- });
988
- const summary = {
989
- entity: entityId,
990
- category,
991
- typeId,
992
- typeName,
993
- markerTypeId,
994
- markerTypeName,
995
- label: entityLabel({
996
- category,
997
- typeId,
998
- typeName,
999
- markerTypeName,
1000
- mapMarker,
1001
- dockingSpring,
1002
- hugeThruster,
1003
- blueprintPreview,
1004
- expandoBox,
1005
- hoverOutline,
1006
- itemHolder,
1007
- itemCrate,
1008
- fabricator,
1009
- cargoEjector,
1010
- cannon,
1011
- thruster,
1012
- pusher,
1013
- pusherBeam,
1014
- launcher,
1015
- loader,
1016
- cargoHatch,
1017
- navigationUnit,
1018
- fluidTank,
1019
- shieldGenerator,
1020
- shieldProjector,
1021
- player,
1022
- shipControl,
1023
- sign,
1024
- spawnPoint,
1025
- door,
1026
- isOverworld: this.isOverworld
1027
- }),
1028
- kind: [
1029
- transform ? "transform" : null,
1030
- dynamicBody ? "dynamic_body" : null,
1031
- itemHolder ? "item_holder" : null,
1032
- health ? "health" : null,
1033
- bot ? "bot" : null,
1034
- itemCrate ? "item_crate" : null,
1035
- expandoBox ? "expando_box" : null,
1036
- hoverOutline ? "hover_outline" : null,
1037
- mapMarker ? "map_marker" : null,
1038
- dockingSpring ? "docking_spring" : null,
1039
- hugeThruster ? "huge_thruster" : null,
1040
- blueprintPreview ? "blueprint_preview" : null,
1041
- markerTableIds.length ? "marker" : null,
1042
- looseItemMarker ? "loose_item_marker" : null,
1043
- fabricator ? "fabricator" : null,
1044
- cargoEjector ? "cargo_ejector" : null,
1045
- cannon ? "cannon" : null,
1046
- thruster ? "thruster" : null,
1047
- pusher ? "pusher" : null,
1048
- pusherBeam ? "pusher_beam" : null,
1049
- launcher ? "launcher" : null,
1050
- loader ? "loader" : null,
1051
- cargoHatch ? "cargo_hatch" : null,
1052
- navigationUnit ? "navigation_unit" : null,
1053
- commsStation ? "comms_station" : null,
1054
- fluidTank ? "fluid_tank" : null,
1055
- shieldGenerator ? "shield_generator" : null,
1056
- shieldProjector ? "shield_projector" : null,
1057
- helm ? "helm" : null,
1058
- player ? "player" : null,
1059
- shipControl ? "ship_control" : null,
1060
- sign ? "sign" : null,
1061
- spawnPoint ? "spawn_point" : null,
1062
- door ? "door" : null,
1063
- shipControl && this.isOverworld ? "overworld_ship" : null
1064
- ].filter(Boolean),
1065
- transform,
1066
- footprint,
1067
- contents,
1068
- tables: tableRows.map(({tableId, name, record}) => ({
1069
- tableId,
1070
- name,
1071
- record: cloneRecord(record)
1072
- }))
1073
- };
1074
- // Lazy: most entities are never asked for their occupied cells, and a large
1075
- // footprint materialises hundreds of objects per refresh. Defined as an
1076
- // enumerable memoising accessor so spread, JSON and structuredClone all see
1077
- // the same array they saw before.
1078
- defineLazyProperty(summary, "occupies", transform ? () => this.#occupiedBlocks(transform, footprint) : () => []);
1079
- return summary;
1080
- }
1081
-
1082
- #occupiedBlocks(transform, footprint) {
1083
- const startX = Math.floor(transform.x ?? 0);
1084
- const startY = Math.floor(transform.y ?? 0);
1085
- const cells = [];
1086
- for (let dx = 0; dx < footprint.width; dx++) {
1087
- for (let dy = 0; dy < footprint.height; dy++) {
1088
- cells.push({x: startX + dx, y: startY + dy});
1089
- }
1090
- }
1091
- return cells;
1092
- }
1093
-
1094
- #records(tableId) {
1095
- return [...this.table(tableId).entries()].map(([entity, record]) => ({entity, ...record}));
1096
- }
1097
-
1098
- #readRemovals(reader) {
1099
- const removals = [];
1100
- let entity = 0;
1101
- while (reader.remaining > 0) {
1102
- const delta = reader.readStreamInt();
1103
- if (delta === 0) break;
1104
- entity += delta;
1105
- removals.push(entity);
1106
- // Delete only from tables the entity actually appears in when the derived
1107
- // index is available; fall back to a full scan otherwise.
1108
- const known = this._derived?.entityTableIds?.get(entity);
1109
- if (known) {
1110
- for (const tableId of known) this.tables.get(tableId)?.delete(entity);
1111
- } else {
1112
- for (const records of this.tables.values()) records.delete(entity);
1113
- }
1114
- this.#loaderConfig.delete(null, entity);
1115
- this.#helmOccupied.delete(entity);
1116
- this.#commsStationOccupied.delete(entity);
1117
- this.#navigationUnitAutoWarp.delete(entity);
1118
- }
1119
- this.totalRemovedCount += removals.length;
1120
- for (const id of removals) pushCapped(this.removedEntities, id, MAX_RETAINED_REMOVALS);
1121
- return removals;
1122
- }
1123
-
1124
- #readSection(reader, tag, tableId) {
1125
- const spec = MODEL_TABLE_SPECS.get(tableId);
1126
- return this.#readSectionRecords(reader, tag, tableId, spec);
1127
- }
1128
-
1129
- #readSectionRecords(reader, tag, tableId, spec) {
1130
- const section = {tag, table: tableId, name: spec?.name || null, records: []};
1131
- let entity = 0;
1132
- const seenEntities = tableId === 78 ? new Set() : null;
1133
- while (reader.remaining > 0) {
1134
- const recordOffset = reader.offset;
1135
- let delta;
1136
- try {
1137
- delta = reader.readStreamInt();
1138
- } catch (error) {
1139
- throw new Error(`model table ${tableId} tag ${tag} record offset ${recordOffset}: ${error.message}`);
1140
- }
1141
- if (delta === 0) break;
1142
- entity += delta;
1143
- let mask;
1144
- try {
1145
- mask = reader.readUnsigned();
1146
- } catch (error) {
1147
- throw new Error(`model table ${tableId} tag ${tag} entity ${entity} mask offset ${reader.offset}: ${error.message}`);
1148
- }
1149
- const record = this.#getRecord(tableId, entity);
1150
- const previous = tableId === 78 ? cloneRecord(record) : null;
1151
- record.lastMask = mask;
1152
-
1153
- try {
1154
- if (spec) this.#applyRecordSpec(reader, record, mask, spec);
1155
- else if (!MASK_ONLY_TABLES.has(tableId)) throw new Error(`missing model table spec ${tableId}`);
1156
- } catch (error) {
1157
- throw new Error(`model table ${tableId} tag ${tag} entity ${entity} mask ${mask} offset ${reader.offset}: ${error.message}`);
1158
- }
1159
-
1160
- const changed = {entity, mask};
1161
- if (previous) changed.previous = previous;
1162
- if (seenEntities?.has(entity)) changed.repeatedInSection = true;
1163
- seenEntities?.add(entity);
1164
- if (tableId === 78) changed.record = cloneRecord(record);
1165
- section.records.push(changed);
1166
- }
1167
- return section;
1168
- }
1169
-
1170
- #getRecord(tableId, entity) {
1171
- if (!this.tables.has(tableId)) this.tables.set(tableId, new Map());
1172
- const records = this.tables.get(tableId);
1173
- if (!records.has(entity)) records.set(entity, {});
1174
- return records.get(entity);
1175
- }
1176
-
1177
- #applyRecordSpec(reader, record, mask, spec) {
1178
- if (typeof spec.read === "function") {
1179
- spec.read(reader, record, mask);
1180
- if (spec.scale) record.scaled = spec.scale(record);
1181
- return;
1182
- }
1183
-
1184
- const hasPacked = Boolean(
1185
- (spec.packedMask && (mask & spec.packedMask)) ||
1186
- spec.packedBits?.some((item) => mask & item.bit)
1187
- );
1188
- let packed = null;
1189
- if (hasPacked && spec.packedBeforeValues) packed = reader.readUnsigned();
1190
-
1191
- let packedIndex = 0;
1192
- if (spec.orderedValues) {
1193
- for (const item of spec.orderedItems) {
1194
- if (!(mask & item.bit)) continue;
1195
- if (item.kind === "blob") record[item.key] = reader.readBlob();
1196
- else record[item.key] = (record[item.key] || 0) + reader.readFieldDelta();
1197
- }
1198
- } else {
1199
- for (const blob of spec.blobs || []) {
1200
- if (mask & blob.bit) record[blob.key] = reader.readBlob();
1201
- }
1202
- }
1203
-
1204
- if (hasPacked && !spec.packedBeforeValues) packed = reader.readUnsigned();
1205
-
1206
- for (const item of spec.packedBits || []) {
1207
- if (mask & item.bit) record[item.key] = (packed >> packedIndex++) & 1;
1208
- }
1209
-
1210
- if (packed != null && spec.packedOffsetKeys) {
1211
- for (const key of spec.packedOffsetKeys) {
1212
- record[key] = (packed >> packedIndex++) & 1;
1213
- }
1214
- }
1215
-
1216
- if (!spec.orderedValues) {
1217
- for (const field of spec.fields || []) {
1218
- if (!(mask & field.bit)) continue;
1219
- record[field.key] = (record[field.key] || 0) + reader.readFieldDelta();
1220
- }
1221
- }
1222
-
1223
- if (spec.scale) record.scaled = spec.scale(record);
1224
- }
1225
- }
1226
-
1227
- function summarizeUpdate(update) {
1228
- return {
1229
- generation: update.generation,
1230
- sectionCount: update.sections.length,
1231
- removals: update.removals.length,
1232
- unknownTags: update.unknownTags,
1233
- error: update.error?.message || null,
1234
- sections: update.sections.map((section) => ({
1235
- tag: section.tag,
1236
- table: section.table,
1237
- name: section.name,
1238
- records: section.records.length
1239
- }))
1240
- };
1241
- }
1242
-
1243
- function insertSorted(values, value) {
1244
- let low = 0;
1245
- let high = values.length;
1246
- while (low < high) {
1247
- const mid = (low + high) >> 1;
1248
- if (values[mid] < value) low = mid + 1;
1249
- else high = mid;
1250
- }
1251
- values.splice(low, 0, value);
1252
- }
1253
-
1254
- export function decodeModelData(bytes) {
1255
- const state = new ModelState();
1256
- return state.apply(bytes);
1257
- }
1
+ import * as modelDecoder from "./decoder.js";
2
+ import {LoaderConfigTracker} from "./loader-config.js";
3
+ import {navigationDestinationFromEncodedValue} from "../overworld.js";
4
+
5
+ const {
6
+ ModelReader,
7
+ numericFields,
8
+ TWO_FIELD_SPEC,
9
+ LABEL_STATE_SPEC,
10
+ SIMPLE_LABEL_STATE_SPEC,
11
+ peekStreamInt,
12
+ nextValueLooksLikeTextBlob,
13
+ MODEL_TABLE_SPECS,
14
+ TABLE_78_NUMERIC_FIELDS,
15
+ WIRE_TAG_TABLES,
16
+ MASK_ONLY_TABLES,
17
+ ENTITY_FOOTPRINTS,
18
+ PLACED_ENTITY_TYPE_IDS,
19
+ FabricatorType,
20
+ FABRICATOR_TYPES,
21
+ HELM_TYPE_IDS,
22
+ COMMS_STATION_TYPE_ID,
23
+ COMMS_STATION_MAX_CHARGES,
24
+ CARGO_HATCH_TYPE_IDS,
25
+ THRUSTER_TYPE_IDS,
26
+ EXPANDO_BOX_TYPE_ID,
27
+ ITEM_LAUNCHER_TYPE_ID,
28
+ LOADER_TYPE_ID,
29
+ NAVIGATION_UNIT_TYPE_ID,
30
+ entityTypeIdFromRecord,
31
+ CANNON_AMMO_COLOR_ITEM_IDS,
32
+ CANNON_TYPE_IDS,
33
+ THRUSTER_FACING_NAMES,
34
+ DEFAULT_OVERWORLD_WARP_DURATION_SECONDS,
35
+ OVERWORLD_WARP_TICKS_PER_SECOND,
36
+ MARKER_TYPE_IDS,
37
+ TEAM_RANK_NAMES,
38
+ PLAYER_SHIP_RANKS,
39
+ PUSHER_MODE_NAMES,
40
+ SIGN_DISPLAY_MODE_NAMES,
41
+ SHIELD_GENERATOR_BOOST_STATE_NAMES,
42
+ NAVIGATION_DEFAULT_DESTINATION,
43
+ navigationDestinationName,
44
+ isNavigationDestination,
45
+ numberOrNull,
46
+ qKey,
47
+ blobKey,
48
+ cloneRecord,
49
+ firstRecord,
50
+ TABLE_78_FIELD_BITS,
51
+ table78DeltaRecord,
52
+ isSemanticLoaderDelta,
53
+ decodeText,
54
+ entityNameFromType,
55
+ markerTypeIdForTables,
56
+ itemSummary,
57
+ summarizeItemHolder,
58
+ boundedProgress,
59
+ summarizeFabricator,
60
+ summarizeCargoEjector,
61
+ summarizeCannon,
62
+ summarizeThruster,
63
+ summarizeHelm,
64
+ navigationDestinationFromRecord,
65
+ summarizeNavigationUnit,
66
+ summarizeCommsStation,
67
+ summarizeHealth,
68
+ summarizeBot,
69
+ isBoulderBotExclusion,
70
+ botClassFromState,
71
+ isCowardBossState,
72
+ isOrangeFoolBotState,
73
+ isZombieBossBotState,
74
+ isRedSentryBotState,
75
+ isBlueRusherBotState,
76
+ isRedSniperBotState,
77
+ isYellowHunterBotState,
78
+ isAquaShielderBotState,
79
+ isLazerEnthusiastBotState,
80
+ isYellowMineGuardBotState,
81
+ isShieldHelperBotState,
82
+ isShieldMasterBotState,
83
+ botIdentifierFromState,
84
+ summarizeShieldGenerator,
85
+ summarizeShipControl,
86
+ summarizeShipSize,
87
+ summarizeShipWorldMetadata,
88
+ summarizeItemCrate,
89
+ scaledSizeSummary,
90
+ summarizeExpandoBox,
91
+ summarizeHoverOutline,
92
+ summarizeMapMarker,
93
+ summarizeDockingSpring,
94
+ summarizeHugeThruster,
95
+ summarizeLoader,
96
+ summarizeCargoHatch,
97
+ summarizePusher,
98
+ summarizePusherBeam,
99
+ normalizeDegrees,
100
+ summarizeItemLauncher,
101
+ summarizeSign,
102
+ summarizeShieldProjector,
103
+ summarizeSpawnPoint,
104
+ summarizeDoor,
105
+ enumValueName,
106
+ colorToCss,
107
+ previewActionName,
108
+ bitOffsets,
109
+ summarizeBlueprintPreview,
110
+ summarizePlayerPreview,
111
+ summarizePlayer,
112
+ patronTierName,
113
+ mergeContents,
114
+ MAX_HOVER_OUTLINE_FOOTPRINT_CELLS,
115
+ entityFootprint,
116
+ entityLabel,
117
+ entityCategory,
118
+ BLOCK_KEY_OFFSET,
119
+ BLOCK_KEY_STRIDE,
120
+ blockKey,
121
+ EMPTY_TABLE,
122
+ MAX_RETAINED_REMOVALS,
123
+ MAX_RETAINED_ERRORS,
124
+ pushCapped,
125
+ defineLazyProperty,
126
+ } = modelDecoder;
127
+
128
+ export class ModelState {
129
+ #loaderConfig = new LoaderConfigTracker();
130
+ #blueprintItems = null;
131
+ #helmOccupied = new Map();
132
+ #commsStationOccupied = new Map();
133
+ #navigationUnitAutoWarp = new Map();
134
+
135
+ constructor({isOverworld = null} = {}) {
136
+ this.isOverworld = isOverworld == null ? null : Boolean(isOverworld);
137
+ this.generation = null;
138
+ this.tables = new Map();
139
+ this.removedEntities = [];
140
+ this.lastUpdate = null;
141
+ this.errors = [];
142
+ // Retained arrays are capped; the totals remain exact.
143
+ this.totalRemovedCount = 0;
144
+ this.totalErrorCount = 0;
145
+ this._derived = null;
146
+ }
147
+
148
+ setWorldKind(isOverworld) {
149
+ const next = isOverworld == null ? null : Boolean(isOverworld);
150
+ if (this.isOverworld === next) return;
151
+ this.isOverworld = next;
152
+ this.#invalidateDerived();
153
+ }
154
+
155
+ table(id) {
156
+ // Shared empty map: #summarizeEntity performs ~32 record() lookups per
157
+ // entity, most against absent tables, and each miss used to allocate.
158
+ return this.tables.get(Number(id)) || EMPTY_TABLE;
159
+ }
160
+
161
+ record(tableId, entityId) {
162
+ return this.table(tableId).get(Number(entityId)) || null;
163
+ }
164
+
165
+ entity(entityId) {
166
+ const id = Number(entityId);
167
+ if (!Number.isFinite(id)) return null;
168
+ return this.#derivedState().entitiesById.get(id) || null;
169
+ }
170
+
171
+ entities() {
172
+ return this.#derivedState().entities.slice();
173
+ }
174
+
175
+ blocks() {
176
+ this.#derivedState();
177
+ return this.#derivedBlocks().slice();
178
+ }
179
+
180
+ // O(1) point lookup. Callers previously had to materialise and scan the whole
181
+ // block list to answer this.
182
+ blockAt(x, y) {
183
+ this.#derivedState();
184
+ return this.#blocksIndex().get(blockKey(x, y)) || null;
185
+ }
186
+
187
+ apply(bytes, {full = false} = {}) {
188
+ this.#blueprintItems = null;
189
+ const reader = new ModelReader(bytes);
190
+ const update = {
191
+ generation: null,
192
+ full: Boolean(full),
193
+ changedEntities: new Set(),
194
+ sections: [],
195
+ removals: [],
196
+ unknownTags: [],
197
+ error: null
198
+ };
199
+
200
+ try {
201
+ update.generation = reader.readStreamInt();
202
+ this.generation = update.generation;
203
+
204
+ while (reader.remaining > 0) {
205
+ if (reader.trailingZeroOnly()) break;
206
+ let tag;
207
+ try {
208
+ tag = reader.readStreamInt();
209
+ } catch (error) {
210
+ throw new Error(`model section tag offset ${reader.offset}: ${error.message}`);
211
+ }
212
+ if (tag === 0) break;
213
+ if (tag === 57005) {
214
+ update.removals.push(...this.#readRemovals(reader));
215
+ continue;
216
+ }
217
+
218
+ const tableId = WIRE_TAG_TABLES.get(tag);
219
+ if (tableId == null) {
220
+ update.unknownTags.push({tag, offset: reader.offset});
221
+ // Some live captures end with a terminal, empty section tag we do not
222
+ // yet have a table mapping for. If the rest of the packet is just
223
+ // zero terminators, keep the decoded sections and stop cleanly rather
224
+ // than turning the whole frame into a decode error.
225
+ if (reader.trailingZeroOnly()) break;
226
+ throw new Error(`unsupported model_data section tag ${tag}`);
227
+ }
228
+
229
+ const section = this.#readSection(reader, tag, tableId);
230
+ update.sections.push(section);
231
+ for (const record of section.records) update.changedEntities.add(record.entity);
232
+ }
233
+ } catch (error) {
234
+ update.error = error;
235
+ this.totalErrorCount += 1;
236
+ pushCapped(this.errors, {message: error.message, generation: update.generation}, MAX_RETAINED_ERRORS);
237
+ }
238
+
239
+ this.#remapIndexedLoaderConfig(update);
240
+ this.#updateLoaderConfig(update);
241
+ this.#updateNavigationUnitAutoWarp(update);
242
+ this.#updateHelmOccupancy(update);
243
+ this.#updateCommsStationOccupancy(update);
244
+ this.lastUpdate = update;
245
+ this.#updateDerived(update);
246
+ return update;
247
+ }
248
+
249
+ // `blocks` is opt-in: materialising and sorting the per-cell index is the most
250
+ // expensive part of a snapshot, and most callers never read it. Mirrors how
251
+ // `includeTiles` already works on WorldState.snapshot().
252
+ snapshot({includeTables = false, includeBlocks = false} = {}) {
253
+ const derived = this.#derivedState();
254
+ return {
255
+ generation: this.generation,
256
+ tableCount: this.tables.size,
257
+ entityCount: derived.entityCount,
258
+ removedEntities: this.removedEntities.slice(-50),
259
+ lastUpdate: this.lastUpdate ? summarizeUpdate(this.lastUpdate) : null,
260
+ errors: this.errors.slice(-10),
261
+ entities: derived.entities.slice(),
262
+ blocks: includeBlocks ? this.#derivedBlocks().slice() : undefined,
263
+ transforms: derived.transforms.slice(),
264
+ players: derived.players.slice(),
265
+ shipControls: derived.shipControls.slice(),
266
+ machines: this.#machinesSnapshot(derived.machines),
267
+ tables: includeTables ? this.tablesSnapshot() : derived.tableSummaries.slice()
268
+ };
269
+ }
270
+
271
+ tablesSnapshot() {
272
+ return [...this.tables.entries()].map(([id, records]) => ({
273
+ id,
274
+ name: MODEL_TABLE_SPECS.get(id)?.name || null,
275
+ records: [...records.entries()].map(([entity, record]) => ({entity, ...cloneRecord(record)}))
276
+ }));
277
+ }
278
+
279
+ transforms() {
280
+ return this.#derivedState().transforms.slice();
281
+ }
282
+
283
+ itemHolders() {
284
+ return this.#records(6).map((entry) => summarizeItemHolder(entry.entity, entry));
285
+ }
286
+
287
+ fabricators() {
288
+ return this.#records(53).map((entry) => summarizeFabricator(entry.entity, entry, this.record(6, entry.entity), this.record(7, entry.entity)));
289
+ }
290
+
291
+ players() {
292
+ return this.#derivedState().players.slice();
293
+ }
294
+
295
+ shipControls() {
296
+ return this.#derivedState().shipControls.slice();
297
+ }
298
+
299
+ shipMetadata() {
300
+ if (this.isOverworld) return null;
301
+ const lockdownRecord = firstRecord(this.table(11));
302
+ const shipRecord = firstRecord(this.table(16), (record) => record?.blob20 && typeof record?.q32 === "number" && typeof record?.q36 === "number");
303
+ return summarizeShipWorldMetadata(lockdownRecord, shipRecord);
304
+ }
305
+
306
+ machines() {
307
+ return this.#machinesSnapshot(this.#derivedState().machines);
308
+ }
309
+
310
+ #machinesSnapshot(machines) {
311
+ return {
312
+ itemHolders: machines.itemHolders.slice(),
313
+ fabricators: machines.fabricators.slice(),
314
+ cargoEjectors: machines.cargoEjectors.slice(),
315
+ cannons: machines.cannons.slice(),
316
+ thrusters: machines.thrusters.slice(),
317
+ pushers: machines.pushers.slice(),
318
+ pusherBeams: machines.pusherBeams.slice(),
319
+ launchers: machines.launchers.slice(),
320
+ health: machines.health.slice(),
321
+ loaders: machines.loaders.slice(),
322
+ cargoHatches: machines.cargoHatches.slice(),
323
+ navigationUnits: machines.navigationUnits.slice(),
324
+ commsStations: machines.commsStations.slice(),
325
+ fluidTanks: machines.fluidTanks.slice(),
326
+ shieldGenerators: machines.shieldGenerators.slice(),
327
+ shieldProjectors: machines.shieldProjectors.slice(),
328
+ helms: machines.helms.slice(),
329
+ signs: machines.signs.slice(),
330
+ spawnPoints: machines.spawnPoints.slice(),
331
+ doors: machines.doors.slice(),
332
+ expandoBoxes: machines.expandoBoxes.slice()
333
+ };
334
+ }
335
+
336
+ #invalidateDerived() {
337
+ this._derived = null;
338
+ }
339
+
340
+ #updateDerived(update) {
341
+ if (!this._derived) return;
342
+ if (!update.removals.length && !update.changedEntities.size) return;
343
+
344
+ for (const section of update.sections) {
345
+ for (const record of section.records || []) this.#addDerivedEntityTable(record.entity, section.table);
346
+ }
347
+
348
+ if (update.sections.some((section) => section.table === 12)) {
349
+ for (const entityId of this.table(55).keys()) update.changedEntities.add(entityId);
350
+ }
351
+ for (const entityId of update.removals) this.#removeDerivedEntity(entityId);
352
+ for (const entityId of update.changedEntities) this.#refreshDerivedEntity(entityId);
353
+ this._derived.summariesDirty = true;
354
+ this._derived.tableSummariesDirty = true;
355
+ }
356
+
357
+ #updateLoaderConfig(update) {
358
+ const seenEntities = new Set();
359
+ for (const section of update.sections || []) {
360
+ if (section.table !== 78) continue;
361
+ for (const changed of section.records || []) {
362
+ if (changed.indexedLoaderConfig) {
363
+ this.#loaderConfig.updateIndexedSnapshotRecord(null, changed.entity, changed.record, changed.mask, changed.cumulativeRecord, changed.configEntity);
364
+ continue;
365
+ }
366
+ const typeId = entityTypeIdFromRecord(this.record(7, changed.entity));
367
+ this.#loaderConfig.updateRecord(null, changed.entity, changed.record ?? this.record(78, changed.entity), changed.mask, changed.previous, {
368
+ repeatedInUpdate: seenEntities.has(changed.entity),
369
+ allowSparseBaseline: typeId === LOADER_TYPE_ID,
370
+ repeatedInSection: changed.repeatedInSection,
371
+ semanticSnapshot: Boolean(update.full),
372
+ semanticDelta: isSemanticLoaderDelta(changed, update.full),
373
+ deltaRecord: table78DeltaRecord(changed)
374
+ });
375
+ seenEntities.add(changed.entity);
376
+ }
377
+ }
378
+ }
379
+
380
+ #remapIndexedLoaderConfig(update) {
381
+ if (!update.full || update.error) return;
382
+ const rows = [];
383
+ for (const section of update.sections || []) {
384
+ if (section.table !== 78) continue;
385
+ for (const changed of section.records || []) rows.push(changed);
386
+ }
387
+ if (!rows.length) return;
388
+
389
+ const loaderIds = [...this.table(7).entries()]
390
+ .filter(([, record]) => entityTypeIdFromRecord(record) === LOADER_TYPE_ID)
391
+ .map(([entity]) => entity)
392
+ .sort((a, b) => a - b);
393
+ if (loaderIds.length !== rows.length) return;
394
+ if (rows.every((row, index) => row.entity === loaderIds[index])) return;
395
+
396
+ const remappedTable = new Map();
397
+ for (const [index, changed] of rows.entries()) {
398
+ const rawRecord = table78DeltaRecord(changed);
399
+ changed.configEntity = changed.entity;
400
+ changed.entity = loaderIds[index];
401
+ changed.cumulativeRecord = cloneRecord(changed.record || {});
402
+ changed.record = cloneRecord(rawRecord);
403
+ delete changed.previous;
404
+ changed.indexedLoaderConfig = true;
405
+ remappedTable.set(changed.entity, cloneRecord(rawRecord));
406
+ update.changedEntities.add(changed.entity);
407
+ }
408
+ this.tables.set(78, remappedTable);
409
+ }
410
+
411
+ #updateNavigationUnitAutoWarp(update) {
412
+ const trackedBeforeUpdate = new Set(this.#navigationUnitAutoWarp.keys());
413
+ const initializedThisUpdate = new Set();
414
+
415
+ for (const section of update.sections || []) {
416
+ if (section.table !== 78) continue;
417
+ for (const changed of section.records || []) {
418
+ const typeId = entityTypeIdFromRecord(this.record(7, changed.entity));
419
+ if (typeId !== NAVIGATION_UNIT_TYPE_ID) continue;
420
+
421
+ const record = this.record(78, changed.entity);
422
+ if (!record) continue;
423
+
424
+ if (!trackedBeforeUpdate.has(changed.entity)) {
425
+ if (initializedThisUpdate.has(changed.entity)) continue;
426
+ const q32Destination = navigationDestinationFromEncodedValue(record.q32);
427
+ const q36Destination = navigationDestinationFromEncodedValue(record.q36);
428
+ const q24Destination = navigationDestinationFromEncodedValue(record.q24);
429
+ const q20Destination = navigationDestinationFromEncodedValue(record.q20);
430
+ const q32DestinationRow = (changed.mask & 9) === 9 && record.q20 === 0 && record.q24 == null && q32Destination != null;
431
+ const destinationBase = q32DestinationRow
432
+ ? q32Destination
433
+ : (changed.mask & 17) === 17 && record.q20 === 0 && record.q24 == null && record.q32 == null && q36Destination != null
434
+ ? q36Destination
435
+ : record.q20 === 0 && q24Destination != null
436
+ ? q24Destination
437
+ : null;
438
+ const q36DestinationRow = destinationBase === q36Destination && q36Destination != null;
439
+ const pureDestinationRow = changed.mask === 1 && q20Destination != null;
440
+ const noCaptainsOnlyRow = changed.mask === 16 && record.q36 === 0;
441
+ this.#navigationUnitAutoWarp.set(changed.entity, {
442
+ destination: navigationDestinationFromRecord(record, changed.mask),
443
+ destinationBase,
444
+ shieldFailure: pureDestinationRow || q36DestinationRow || noCaptainsOnlyRow ? true : q32DestinationRow ? false : record.q32 == null ? (record.q24 == null ? null : true) : false,
445
+ noCaptains: q32DestinationRow && changed.mask === 9
446
+ ? true
447
+ : pureDestinationRow ? true
448
+ : noCaptainsOnlyRow ? false
449
+ : q36DestinationRow ? false : record.q36 == null ? (record.q24 == null ? null : true) : false
450
+ });
451
+ initializedThisUpdate.add(changed.entity);
452
+ continue;
453
+ }
454
+
455
+ const current = this.#navigationUnitAutoWarp.get(changed.entity);
456
+ if (!current) continue;
457
+ const q32Destination = navigationDestinationFromEncodedValue(record.q32);
458
+ const q36Destination = navigationDestinationFromEncodedValue(record.q36);
459
+ const q20Destination = navigationDestinationFromEncodedValue(record.q20);
460
+ const q32DestinationRow = (changed.mask & 9) === 9 && record.q20 === 0 && record.q24 == null && q32Destination != null;
461
+ const q36DestinationRow = (changed.mask & 17) === 17 && record.q20 === 0 && record.q24 == null && record.q32 == null && q36Destination != null;
462
+ const baselineRow = (changed.mask & 27) === 27 && record.q20 === 0 && record.q24 != null && record.q32 != null && record.q36 != null;
463
+ if (q32DestinationRow) {
464
+ current.destination = q32Destination;
465
+ current.destinationBase = q32Destination;
466
+ current.shieldFailure = false;
467
+ if (record.q36 != null) current.noCaptains = record.q36 !== 0;
468
+ else if (changed.mask === 9) current.noCaptains = true;
469
+ continue;
470
+ }
471
+ if (q36DestinationRow) {
472
+ current.destination = q36Destination;
473
+ current.destinationBase = q36Destination;
474
+ continue;
475
+ }
476
+ if (baselineRow) {
477
+ current.shieldFailure = false;
478
+ current.noCaptains = record.q36 !== 0;
479
+ continue;
480
+ }
481
+ if (changed.mask === 1) {
482
+ const relativeDestination = current.destinationBase == null || typeof record.q20 !== "number"
483
+ ? null
484
+ : current.destinationBase + record.q20;
485
+ current.destination = q20Destination ?? (isNavigationDestination(relativeDestination) ? relativeDestination : current.destination);
486
+ } else if ((changed.mask & 1) && record.q20 != null && record.q20 !== 0) {
487
+ current.destination = q20Destination ?? (isNavigationDestination(record.q20) ? record.q20 : current.destination);
488
+ }
489
+ if (changed.mask & 8) current.shieldFailure = !current.shieldFailure;
490
+ if (changed.mask & 16) current.noCaptains = !current.noCaptains;
491
+ }
492
+ }
493
+ }
494
+
495
+ #updateHelmOccupancy(update) {
496
+ const table7 = update.sections.find((section) => section.table === 7);
497
+ if (!table7) return;
498
+
499
+ const changedPilotState = new Map();
500
+ for (const section of update.sections) {
501
+ if (section.table !== 55) continue;
502
+ for (const changed of section.records) {
503
+ if (!(changed.mask & 16)) continue;
504
+ const player = this.record(55, changed.entity);
505
+ changedPilotState.set(changed.entity, Boolean(player?.q107));
506
+ }
507
+ }
508
+
509
+ const activePilotCount = [...this.table(55).values()].filter((record) => record?.q107).length;
510
+ for (const changed of table7.records) {
511
+ const record = this.record(7, changed.entity);
512
+ const typeId = entityTypeIdFromRecord(record);
513
+ if (!HELM_TYPE_IDS.has(typeId)) continue;
514
+
515
+ if (activePilotCount > 0 && record?.q20 === 0 && HELM_TYPE_IDS.has(Number(record?.q32))) {
516
+ this.#helmOccupied.set(changed.entity, true);
517
+ }
518
+
519
+ if (!(changed.mask & 8) || changedPilotState.size === 0) continue;
520
+ this.#helmOccupied.set(changed.entity, [...changedPilotState.values()].some(Boolean));
521
+ }
522
+ }
523
+
524
+ #updateCommsStationOccupancy(update) {
525
+ const table7 = update.sections.find((section) => section.table === 7);
526
+ if (!table7) return;
527
+
528
+ for (const changed of table7.records) {
529
+ const record = this.record(7, changed.entity);
530
+ const typeId = entityTypeIdFromRecord(record);
531
+ if (typeId !== COMMS_STATION_TYPE_ID) continue;
532
+
533
+ const hasAbsoluteTypeState = update.full || (changed.mask & 1);
534
+ if (hasAbsoluteTypeState && record?.q20 === 0 && Number(record?.q32) === COMMS_STATION_TYPE_ID) {
535
+ this.#commsStationOccupied.set(changed.entity, true);
536
+ continue;
537
+ }
538
+
539
+ if (hasAbsoluteTypeState && Number(record?.q20) === COMMS_STATION_TYPE_ID) {
540
+ this.#commsStationOccupied.set(changed.entity, false);
541
+ }
542
+
543
+ if (changed.mask & 8) {
544
+ this.#commsStationOccupied.set(changed.entity, !this.#commsStationOccupied.get(changed.entity));
545
+ }
546
+ }
547
+ }
548
+
549
+ #derivedState() {
550
+ if (this._derived) {
551
+ this.#ensureDerivedSummaries();
552
+ return this._derived;
553
+ }
554
+
555
+ const entityIds = [];
556
+ const seenEntities = new Set();
557
+ const entityTableIds = new Map();
558
+ const tableSummaries = [];
559
+
560
+ for (const [tableId, records] of this.tables.entries()) {
561
+ const name = MODEL_TABLE_SPECS.get(tableId)?.name || null;
562
+ tableSummaries.push({id: tableId, name, count: records.size});
563
+ for (const entity of records.keys()) {
564
+ if (!entityTableIds.has(entity)) entityTableIds.set(entity, []);
565
+ entityTableIds.get(entity).push(tableId);
566
+ if (!seenEntities.has(entity)) {
567
+ seenEntities.add(entity);
568
+ entityIds.push(entity);
569
+ }
570
+ }
571
+ }
572
+
573
+ entityIds.sort((a, b) => a - b);
574
+ const entities = entityIds.map((entityId) => this.#summarizeEntity(entityId, this.#tableRowsForEntity(entityId)));
575
+ const entitiesById = new Map(entities.map((entity) => [entity.entity, entity]));
576
+
577
+ this._derived = {
578
+ entityCount: entityIds.length,
579
+ entityIds,
580
+ entityTableIds,
581
+ entitiesById,
582
+ blocksByKey: null,
583
+ blocks: null,
584
+ blocksDirty: true,
585
+ tableSummaries,
586
+ summariesDirty: true,
587
+ tableSummariesDirty: false
588
+ };
589
+ this.#refreshDerivedSummaries();
590
+ return this._derived;
591
+ }
592
+
593
+ #tableRowsForEntity(entityId) {
594
+ const rows = [];
595
+ const tableIds = this._derived?.entityTableIds?.get(entityId) || this.tables.keys();
596
+ for (const tableId of tableIds) {
597
+ const record = this.tables.get(tableId)?.get(entityId);
598
+ if (record) rows.push({tableId, name: MODEL_TABLE_SPECS.get(tableId)?.name || null, record});
599
+ }
600
+ return rows;
601
+ }
602
+
603
+ #addDerivedEntityTable(entityId, tableId) {
604
+ const derived = this._derived;
605
+ if (!derived) return;
606
+ let tableIds = derived.entityTableIds.get(entityId);
607
+ if (!tableIds) {
608
+ tableIds = [];
609
+ derived.entityTableIds.set(entityId, tableIds);
610
+ }
611
+ if (!tableIds.includes(tableId)) insertSorted(tableIds, tableId);
612
+ }
613
+
614
+ #removeDerivedEntity(entityId) {
615
+ const derived = this._derived;
616
+ const old = derived.entitiesById.get(entityId);
617
+ if (old) this.#removeEntityFromDerivedBlocks(derived, old);
618
+ derived.entitiesById.delete(entityId);
619
+ derived.entityTableIds.delete(entityId);
620
+ const index = derived.entityIds.indexOf(entityId);
621
+ if (index >= 0) derived.entityIds.splice(index, 1);
622
+ derived.entityCount = derived.entityIds.length;
623
+ }
624
+
625
+ #refreshDerivedEntity(entityId) {
626
+ const rows = this.#tableRowsForEntity(entityId);
627
+ if (!rows.length) {
628
+ this.#removeDerivedEntity(entityId);
629
+ return;
630
+ }
631
+
632
+ const derived = this._derived;
633
+ const old = derived.entitiesById.get(entityId);
634
+ if (old) this.#removeEntityFromDerivedBlocks(derived, old);
635
+
636
+ const next = this.#summarizeEntity(entityId, rows);
637
+ derived.entitiesById.set(entityId, next);
638
+ if (!derived.entityIds.includes(entityId)) insertSorted(derived.entityIds, entityId);
639
+ this.#addEntityToDerivedBlocks(derived, next);
640
+ derived.entityCount = derived.entityIds.length;
641
+ }
642
+
643
+ #tableSummaries() {
644
+ return [...this.tables.entries()].map(([id, records]) => ({
645
+ id,
646
+ name: MODEL_TABLE_SPECS.get(id)?.name || null,
647
+ count: records.size
648
+ }));
649
+ }
650
+
651
+ // The per-cell block index is the most expensive derived product to build --
652
+ // it walks every occupied cell of every entity. Most consumers never ask for
653
+ // blocks, so it is constructed on first use rather than during the rebuild.
654
+ #blocksIndex() {
655
+ const derived = this._derived;
656
+ if (derived.blocksByKey) return derived.blocksByKey;
657
+ const blocks = new Map();
658
+ for (const entityId of derived.entityIds) {
659
+ const entity = derived.entitiesById.get(entityId);
660
+ if (entity) this.#addEntityToBlockMap(blocks, entity);
661
+ }
662
+ derived.blocksByKey = blocks;
663
+ derived.blocksDirty = true;
664
+ return blocks;
665
+ }
666
+
667
+ #addEntityToDerivedBlocks(derived, entity) {
668
+ // Nothing to maintain until something has asked for the index; it will be
669
+ // built from current entities on demand.
670
+ if (!derived.blocksByKey) return;
671
+ this.#addEntityToBlockMap(derived.blocksByKey, entity);
672
+ derived.blocksDirty = true;
673
+ }
674
+
675
+ #addEntityToBlockMap(blocks, entity) {
676
+ if (!entity.transform || !Number.isFinite(entity.transform.x) || !Number.isFinite(entity.transform.y)) return;
677
+ const footprint = entity.footprint || {width: 1, height: 1};
678
+ const startX = Math.floor(entity.transform.x);
679
+ const startY = Math.floor(entity.transform.y);
680
+ for (let dx = 0; dx < footprint.width; dx++) {
681
+ for (let dy = 0; dy < footprint.height; dy++) {
682
+ const x = startX + dx;
683
+ const y = startY + dy;
684
+ const key = blockKey(x, y);
685
+ let block = blocks.get(key);
686
+ if (block === undefined) {
687
+ block = {x, y, entities: []};
688
+ blocks.set(key, block);
689
+ }
690
+ block.entities.push(entity);
691
+ }
692
+ }
693
+ }
694
+
695
+ #removeEntityFromDerivedBlocks(derived, entity) {
696
+ if (!derived.blocksByKey) return;
697
+ const id = entity.entity;
698
+ for (const cell of entity.occupies || []) {
699
+ const key = blockKey(cell.x, cell.y);
700
+ const block = derived.blocksByKey.get(key);
701
+ if (!block) continue;
702
+ // Splice in place rather than allocating a filtered array per cell.
703
+ const list = block.entities;
704
+ for (let i = list.length - 1; i >= 0; i--) {
705
+ if (list[i].entity === id) list.splice(i, 1);
706
+ }
707
+ if (!list.length) derived.blocksByKey.delete(key);
708
+ derived.blocksDirty = true;
709
+ }
710
+ }
711
+
712
+ #ensureDerivedSummaries() {
713
+ const derived = this._derived;
714
+ if (!derived) return;
715
+ if (derived.tableSummariesDirty) {
716
+ derived.tableSummaries = this.#tableSummaries();
717
+ derived.tableSummariesDirty = false;
718
+ }
719
+ if (derived.summariesDirty) this.#refreshDerivedSummaries();
720
+ }
721
+
722
+ #refreshDerivedSummaries() {
723
+ const derived = this._derived;
724
+ const entities = derived.entityIds.map((entityId) => derived.entitiesById.get(entityId)).filter(Boolean);
725
+ const machines = {
726
+ itemHolders: [],
727
+ fabricators: [],
728
+ cargoEjectors: [],
729
+ cannons: [],
730
+ thrusters: [],
731
+ pushers: [],
732
+ pusherBeams: [],
733
+ launchers: [],
734
+ health: [],
735
+ loaders: [],
736
+ cargoHatches: [],
737
+ navigationUnits: [],
738
+ commsStations: [],
739
+ fluidTanks: [],
740
+ shieldGenerators: [],
741
+ shieldProjectors: [],
742
+ helms: [],
743
+ signs: [],
744
+ spawnPoints: [],
745
+ doors: [],
746
+ expandoBoxes: []
747
+ };
748
+ const players = [];
749
+ const shipControls = [];
750
+ const transforms = [];
751
+
752
+ for (const entity of entities) {
753
+ if (entity.transform) transforms.push(entity.transform);
754
+ const contents = entity.contents;
755
+ if (!contents) continue;
756
+ if (contents.itemHolder) machines.itemHolders.push(contents.itemHolder);
757
+ if (contents.fabricator) machines.fabricators.push(contents.fabricator);
758
+ if (contents.cargoEjector) machines.cargoEjectors.push(contents.cargoEjector);
759
+ if (contents.cannon) machines.cannons.push(contents.cannon);
760
+ if (contents.thruster) machines.thrusters.push(contents.thruster);
761
+ if (contents.pusher) machines.pushers.push(contents.pusher);
762
+ if (contents.pusherBeam) machines.pusherBeams.push(contents.pusherBeam);
763
+ if (contents.launcher) machines.launchers.push(contents.launcher);
764
+ if (contents.health) machines.health.push(contents.health);
765
+ if (contents.loader) machines.loaders.push(contents.loader);
766
+ if (contents.cargoHatch) machines.cargoHatches.push(contents.cargoHatch);
767
+ if (contents.navigationUnit) machines.navigationUnits.push(contents.navigationUnit);
768
+ if (contents.commsStation) machines.commsStations.push(contents.commsStation);
769
+ if (contents.fluidTank) machines.fluidTanks.push(contents.fluidTank);
770
+ if (contents.shieldGenerator) machines.shieldGenerators.push(contents.shieldGenerator);
771
+ if (contents.shieldProjector) machines.shieldProjectors.push(contents.shieldProjector);
772
+ if (contents.helm) machines.helms.push(contents.helm);
773
+ if (contents.sign) machines.signs.push(contents.sign);
774
+ if (contents.spawnPoint) machines.spawnPoints.push(contents.spawnPoint);
775
+ if (contents.door) machines.doors.push(contents.door);
776
+ if (contents.expandoBox) machines.expandoBoxes.push(contents.expandoBox);
777
+ if (contents.player) players.push(contents.player);
778
+ if (contents.shipControl) shipControls.push(contents.shipControl);
779
+ }
780
+
781
+ derived.entities = entities;
782
+ derived.transforms = transforms;
783
+ derived.players = players;
784
+ derived.shipControls = shipControls;
785
+ derived.machines = machines;
786
+ derived.summariesDirty = false;
787
+ }
788
+
789
+ // The sorted block list is the single most expensive derived product (a sort
790
+ // over every occupied cell in the world). Build it only when a caller actually
791
+ // asks for blocks, rather than as a side effect of every summary refresh.
792
+ #derivedBlocks() {
793
+ const derived = this._derived;
794
+ const index = this.#blocksIndex();
795
+ if (derived.blocksDirty || !derived.blocks) {
796
+ derived.blocks = [...index.values()].sort((a, b) => (a.y - b.y) || (a.x - b.x));
797
+ derived.blocksDirty = false;
798
+ }
799
+ return derived.blocks;
800
+ }
801
+
802
+ // Identical for every entity in a refresh pass, but was rebuilt inside each
803
+ // #summarizeEntity call -- O(entities x table12). Table 12 only changes during
804
+ // apply(), which clears this cache.
805
+ #blueprintPreviewItems() {
806
+ if (this.#blueprintItems) return this.#blueprintItems;
807
+ const items = [];
808
+ for (const [entity, record] of this.table(12)) {
809
+ const summary = summarizeBlueprintPreview(entity, record, this.record(0, entity));
810
+ if (summary) items.push(summary);
811
+ }
812
+ this.#blueprintItems = items;
813
+ return items;
814
+ }
815
+
816
+ #summarizeEntity(entityId, tableRows = []) {
817
+ // tableRows already lists exactly the tables this entity appears in -- the
818
+ // same source the public `tables` field is built from. Reading through it
819
+ // avoids ~32 lookups against tables the entity is not in.
820
+ const byTable = new Map();
821
+ for (const row of tableRows) byTable.set(row.tableId, row.record);
822
+ const rec = (tableId) => byTable.get(tableId) ?? null;
823
+ const transformRecord = rec(0);
824
+ const itemHolderRecord = rec(6);
825
+ const healthRecord = rec(5);
826
+ const fabricatorRecord = rec(53);
827
+ const cargoEjectorRecord = rec(49);
828
+ const cannonRecord = rec(54);
829
+ const pusherRecord = rec(72);
830
+ const pusherBeamRecord = rec(42);
831
+ const launcherRecord = rec(44);
832
+ const loaderRecord = rec(78);
833
+ const loaderFilterRecord = rec(76);
834
+ const loaderFilterSlotsRecord = rec(77);
835
+ const commsStationRecord = rec(39);
836
+ const fluidTankRecord = rec(60);
837
+ const shieldRecord = rec(61);
838
+ const shieldGeneratorBoostRecord = rec(75);
839
+ const shieldProjectorRecord = rec(62);
840
+ const playerRecord = rec(55);
841
+ const shipControlRecord = rec(20);
842
+ const signRecord = rec(41);
843
+ const spawnPointRecord = rec(8);
844
+ const doorRecord = rec(47);
845
+ const thrusterRecord = rec(50);
846
+ const labelRecord = rec(9);
847
+ const zoneLabelRecord = rec(25);
848
+ const dockingSpringRecord = rec(26);
849
+ const hugeThrusterRecord = rec(23);
850
+ const bodyStateRecord = rec(1);
851
+ const typeRecord = rec(7);
852
+ const crateSizeRecord = rec(3);
853
+ const blueprintPreviewRecord = rec(12);
854
+ const expandoSizeRecord = rec(51);
855
+ const crateItemRecord = rec(19);
856
+ const markerTableIds = [73].filter((tableId) => this.record(tableId, entityId));
857
+ const markerTypeId = markerTypeIdForTables(markerTableIds);
858
+ const markerTypeName = entityNameFromType(markerTypeId);
859
+ const looseItemMarker = Boolean(rec(37));
860
+ const dynamicBody = bodyStateRecord?.q20 === -4;
861
+ const typeId = entityTypeIdFromRecord(typeRecord);
862
+ const itemHolder = summarizeItemHolder(entityId, itemHolderRecord);
863
+ const isExpandoBox = typeId === EXPANDO_BOX_TYPE_ID || markerTypeId === EXPANDO_BOX_TYPE_ID;
864
+ const health = summarizeHealth(entityId, healthRecord);
865
+ const fabricator = summarizeFabricator(entityId, fabricatorRecord, itemHolderRecord, typeRecord);
866
+ const cargoEjector = summarizeCargoEjector(entityId, typeId, cargoEjectorRecord);
867
+ const cannon = summarizeCannon(entityId, cannonRecord, typeId);
868
+ const thruster = summarizeThruster(entityId, typeId, thrusterRecord);
869
+ const pusher = summarizePusher(entityId, pusherRecord, loaderFilterSlotsRecord);
870
+ const pusherBeam = summarizePusherBeam(entityId, pusherBeamRecord);
871
+ const launcher = summarizeItemLauncher(entityId, typeId, launcherRecord);
872
+ const loader = (typeId === LOADER_TYPE_ID || (typeId == null && (loaderRecord || loaderFilterRecord || loaderFilterSlotsRecord)))
873
+ ? summarizeLoader(entityId, loaderRecord, loaderFilterRecord, loaderFilterSlotsRecord, this.#loaderConfig, typeId === LOADER_TYPE_ID, itemHolderRecord)
874
+ : null;
875
+ const cargoHatch = summarizeCargoHatch(entityId, typeId, loaderFilterRecord, loaderFilterSlotsRecord, rec(45));
876
+ const navigationUnit = summarizeNavigationUnit(entityId, typeId, loaderRecord, this.#navigationUnitAutoWarp.get(entityId));
877
+ const fluidTank = fluidTankRecord ? {
878
+ entity: entityId,
879
+ amount: fluidTankRecord.q24 ?? null,
880
+ state: cloneRecord(fluidTankRecord)
881
+ } : null;
882
+ const shieldGenerator = typeId === 256 ? summarizeShieldGenerator(entityId, shieldRecord, itemHolderRecord, shieldGeneratorBoostRecord) : null;
883
+ const shieldProjector = typeId === 257 ? summarizeShieldProjector(entityId, shieldProjectorRecord) : null;
884
+ const blueprintPreview = summarizeBlueprintPreview(entityId, blueprintPreviewRecord, transformRecord);
885
+ const player = summarizePlayer(entityId, playerRecord, rec(14), this.#blueprintPreviewItems());
886
+ const shipControl = summarizeShipControl(entityId, shipControlRecord);
887
+ const helm = summarizeHelm(entityId, typeId, this.#helmOccupied.get(entityId));
888
+ const commsStation = summarizeCommsStation(entityId, typeId, commsStationRecord, this.#commsStationOccupied.get(entityId));
889
+ const sign = typeId === 218 ? summarizeSign(entityId, signRecord) : null;
890
+ const spawnPoint = typeId === 219 ? summarizeSpawnPoint(entityId, spawnPointRecord) : null;
891
+ const door = typeId === 220 ? summarizeDoor(entityId, spawnPointRecord, doorRecord) : null;
892
+ const shipSize = shipControl && this.isOverworld ? summarizeShipSize(entityId, rec(3)) : null;
893
+ const hoverOutline = summarizeHoverOutline(entityId, crateSizeRecord);
894
+ const expandoBox = isExpandoBox ? summarizeExpandoBox(entityId, itemHolderRecord, expandoSizeRecord) : null;
895
+ const mapMarker = this.isOverworld ? summarizeMapMarker(entityId, labelRecord, zoneLabelRecord, crateSizeRecord) : null;
896
+ const dockingSpring = this.isOverworld
897
+ ? summarizeDockingSpring(entityId, dockingSpringRecord, bodyStateRecord, crateSizeRecord)
898
+ : null;
899
+ const hugeThruster = this.isOverworld
900
+ ? summarizeHugeThruster(entityId, hugeThrusterRecord, crateSizeRecord)
901
+ : null;
902
+ const itemCrate = this.isOverworld && !rec(2) && !rec(18) && health && crateSizeRecord
903
+ ? summarizeItemCrate(entityId, crateSizeRecord, crateItemRecord, healthRecord)
904
+ : null;
905
+ const bot = this.isOverworld
906
+ ? summarizeBot(entityId, {
907
+ health,
908
+ table2Record: rec(2),
909
+ smallRecord: crateSizeRecord,
910
+ combatRecord: rec(18),
911
+ motionRecord: crateItemRecord,
912
+ table10Record: rec(10),
913
+ table51Record: rec(51),
914
+ itemCrate,
915
+ player,
916
+ shipControl
917
+ })
918
+ : null;
919
+ const transform = transformRecord ? {
920
+ entity: entityId,
921
+ x: numberOrNull(transformRecord.q20, 40),
922
+ y: numberOrNull(transformRecord.q24, 40),
923
+ rot: numberOrNull(transformRecord.q28, 127.324),
924
+ flags: [transformRecord.q33, transformRecord.q34, transformRecord.q35].filter((value) => value != null)
925
+ } : null;
926
+ const contents = mergeContents({
927
+ itemHolder, expandoBox, hoverOutline, itemCrate, mapMarker, dockingSpring,
928
+ hugeThruster, blueprintPreview, health, bot, fabricator, cargoEjector,
929
+ cannon, thruster, pusher, pusherBeam, launcher, loader, cargoHatch,
930
+ navigationUnit, commsStation, fluidTank, shieldGenerator, shieldProjector,
931
+ helm, player, shipControl, sign, spawnPoint, door, shipSize
932
+ });
933
+ const footprint = entityFootprint({
934
+ entity: entityId,
935
+ typeId,
936
+ markerTypeId,
937
+ itemHolder,
938
+ expandoBox,
939
+ hoverOutline,
940
+ itemCrate,
941
+ hugeThruster,
942
+ fabricator,
943
+ cargoEjector,
944
+ cannon,
945
+ thruster,
946
+ pusher,
947
+ launcher,
948
+ loader,
949
+ cargoHatch,
950
+ navigationUnit,
951
+ commsStation,
952
+ fluidTank,
953
+ shieldGenerator,
954
+ shieldProjector,
955
+ helm,
956
+ player,
957
+ shipControl
958
+ });
959
+ const typeName = entityNameFromType(typeId);
960
+ const category = entityCategory({
961
+ typeId,
962
+ markerTypeId,
963
+ looseItemMarker,
964
+ dynamicBody,
965
+ transform,
966
+ itemHolder,
967
+ itemCrate,
968
+ mapMarker,
969
+ dockingSpring,
970
+ hugeThruster,
971
+ blueprintPreview,
972
+ fabricator,
973
+ cargoEjector,
974
+ cannon,
975
+ pusher,
976
+ launcher,
977
+ loader,
978
+ cargoHatch,
979
+ navigationUnit,
980
+ commsStation,
981
+ fluidTank,
982
+ shieldGenerator,
983
+ shieldProjector,
984
+ helm,
985
+ player,
986
+ shipControl
987
+ });
988
+ const summary = {
989
+ entity: entityId,
990
+ category,
991
+ typeId,
992
+ typeName,
993
+ markerTypeId,
994
+ markerTypeName,
995
+ label: entityLabel({
996
+ category,
997
+ typeId,
998
+ typeName,
999
+ markerTypeName,
1000
+ mapMarker,
1001
+ dockingSpring,
1002
+ hugeThruster,
1003
+ blueprintPreview,
1004
+ expandoBox,
1005
+ hoverOutline,
1006
+ itemHolder,
1007
+ itemCrate,
1008
+ fabricator,
1009
+ cargoEjector,
1010
+ cannon,
1011
+ thruster,
1012
+ pusher,
1013
+ pusherBeam,
1014
+ launcher,
1015
+ loader,
1016
+ cargoHatch,
1017
+ navigationUnit,
1018
+ fluidTank,
1019
+ shieldGenerator,
1020
+ shieldProjector,
1021
+ player,
1022
+ shipControl,
1023
+ sign,
1024
+ spawnPoint,
1025
+ door,
1026
+ isOverworld: this.isOverworld
1027
+ }),
1028
+ kind: [
1029
+ transform ? "transform" : null,
1030
+ dynamicBody ? "dynamic_body" : null,
1031
+ itemHolder ? "item_holder" : null,
1032
+ health ? "health" : null,
1033
+ bot ? "bot" : null,
1034
+ itemCrate ? "item_crate" : null,
1035
+ expandoBox ? "expando_box" : null,
1036
+ hoverOutline ? "hover_outline" : null,
1037
+ mapMarker ? "map_marker" : null,
1038
+ dockingSpring ? "docking_spring" : null,
1039
+ hugeThruster ? "huge_thruster" : null,
1040
+ blueprintPreview ? "blueprint_preview" : null,
1041
+ markerTableIds.length ? "marker" : null,
1042
+ looseItemMarker ? "loose_item_marker" : null,
1043
+ fabricator ? "fabricator" : null,
1044
+ cargoEjector ? "cargo_ejector" : null,
1045
+ cannon ? "cannon" : null,
1046
+ thruster ? "thruster" : null,
1047
+ pusher ? "pusher" : null,
1048
+ pusherBeam ? "pusher_beam" : null,
1049
+ launcher ? "launcher" : null,
1050
+ loader ? "loader" : null,
1051
+ cargoHatch ? "cargo_hatch" : null,
1052
+ navigationUnit ? "navigation_unit" : null,
1053
+ commsStation ? "comms_station" : null,
1054
+ fluidTank ? "fluid_tank" : null,
1055
+ shieldGenerator ? "shield_generator" : null,
1056
+ shieldProjector ? "shield_projector" : null,
1057
+ helm ? "helm" : null,
1058
+ player ? "player" : null,
1059
+ shipControl ? "ship_control" : null,
1060
+ sign ? "sign" : null,
1061
+ spawnPoint ? "spawn_point" : null,
1062
+ door ? "door" : null,
1063
+ shipControl && this.isOverworld ? "overworld_ship" : null
1064
+ ].filter(Boolean),
1065
+ transform,
1066
+ footprint,
1067
+ contents,
1068
+ tables: tableRows.map(({tableId, name, record}) => ({
1069
+ tableId,
1070
+ name,
1071
+ record: cloneRecord(record)
1072
+ }))
1073
+ };
1074
+ // Lazy: most entities are never asked for their occupied cells, and a large
1075
+ // footprint materialises hundreds of objects per refresh. Defined as an
1076
+ // enumerable memoising accessor so spread, JSON and structuredClone all see
1077
+ // the same array they saw before.
1078
+ defineLazyProperty(summary, "occupies", transform ? () => this.#occupiedBlocks(transform, footprint) : () => []);
1079
+ return summary;
1080
+ }
1081
+
1082
+ #occupiedBlocks(transform, footprint) {
1083
+ const startX = Math.floor(transform.x ?? 0);
1084
+ const startY = Math.floor(transform.y ?? 0);
1085
+ const cells = [];
1086
+ for (let dx = 0; dx < footprint.width; dx++) {
1087
+ for (let dy = 0; dy < footprint.height; dy++) {
1088
+ cells.push({x: startX + dx, y: startY + dy});
1089
+ }
1090
+ }
1091
+ return cells;
1092
+ }
1093
+
1094
+ #records(tableId) {
1095
+ return [...this.table(tableId).entries()].map(([entity, record]) => ({entity, ...record}));
1096
+ }
1097
+
1098
+ #readRemovals(reader) {
1099
+ const removals = [];
1100
+ let entity = 0;
1101
+ while (reader.remaining > 0) {
1102
+ const delta = reader.readStreamInt();
1103
+ if (delta === 0) break;
1104
+ entity += delta;
1105
+ removals.push(entity);
1106
+ // Delete only from tables the entity actually appears in when the derived
1107
+ // index is available; fall back to a full scan otherwise.
1108
+ const known = this._derived?.entityTableIds?.get(entity);
1109
+ if (known) {
1110
+ for (const tableId of known) this.tables.get(tableId)?.delete(entity);
1111
+ } else {
1112
+ for (const records of this.tables.values()) records.delete(entity);
1113
+ }
1114
+ this.#loaderConfig.delete(null, entity);
1115
+ this.#helmOccupied.delete(entity);
1116
+ this.#commsStationOccupied.delete(entity);
1117
+ this.#navigationUnitAutoWarp.delete(entity);
1118
+ }
1119
+ this.totalRemovedCount += removals.length;
1120
+ for (const id of removals) pushCapped(this.removedEntities, id, MAX_RETAINED_REMOVALS);
1121
+ return removals;
1122
+ }
1123
+
1124
+ #readSection(reader, tag, tableId) {
1125
+ const spec = MODEL_TABLE_SPECS.get(tableId);
1126
+ return this.#readSectionRecords(reader, tag, tableId, spec);
1127
+ }
1128
+
1129
+ #readSectionRecords(reader, tag, tableId, spec) {
1130
+ const section = {tag, table: tableId, name: spec?.name || null, records: []};
1131
+ let entity = 0;
1132
+ const seenEntities = tableId === 78 ? new Set() : null;
1133
+ while (reader.remaining > 0) {
1134
+ const recordOffset = reader.offset;
1135
+ let delta;
1136
+ try {
1137
+ delta = reader.readStreamInt();
1138
+ } catch (error) {
1139
+ throw new Error(`model table ${tableId} tag ${tag} record offset ${recordOffset}: ${error.message}`);
1140
+ }
1141
+ if (delta === 0) break;
1142
+ entity += delta;
1143
+ let mask;
1144
+ try {
1145
+ mask = reader.readUnsigned();
1146
+ } catch (error) {
1147
+ throw new Error(`model table ${tableId} tag ${tag} entity ${entity} mask offset ${reader.offset}: ${error.message}`);
1148
+ }
1149
+ const record = this.#getRecord(tableId, entity);
1150
+ const previous = tableId === 78 ? cloneRecord(record) : null;
1151
+ record.lastMask = mask;
1152
+
1153
+ try {
1154
+ if (spec) this.#applyRecordSpec(reader, record, mask, spec);
1155
+ else if (!MASK_ONLY_TABLES.has(tableId)) throw new Error(`missing model table spec ${tableId}`);
1156
+ } catch (error) {
1157
+ throw new Error(`model table ${tableId} tag ${tag} entity ${entity} mask ${mask} offset ${reader.offset}: ${error.message}`);
1158
+ }
1159
+
1160
+ const changed = {entity, mask};
1161
+ if (previous) changed.previous = previous;
1162
+ if (seenEntities?.has(entity)) changed.repeatedInSection = true;
1163
+ seenEntities?.add(entity);
1164
+ if (tableId === 78) changed.record = cloneRecord(record);
1165
+ section.records.push(changed);
1166
+ }
1167
+ return section;
1168
+ }
1169
+
1170
+ #getRecord(tableId, entity) {
1171
+ if (!this.tables.has(tableId)) this.tables.set(tableId, new Map());
1172
+ const records = this.tables.get(tableId);
1173
+ if (!records.has(entity)) records.set(entity, {});
1174
+ return records.get(entity);
1175
+ }
1176
+
1177
+ #applyRecordSpec(reader, record, mask, spec) {
1178
+ if (typeof spec.read === "function") {
1179
+ spec.read(reader, record, mask);
1180
+ if (spec.scale) record.scaled = spec.scale(record);
1181
+ return;
1182
+ }
1183
+
1184
+ const hasPacked = Boolean(
1185
+ (spec.packedMask && (mask & spec.packedMask)) ||
1186
+ spec.packedBits?.some((item) => mask & item.bit)
1187
+ );
1188
+ let packed = null;
1189
+ if (hasPacked && spec.packedBeforeValues) packed = reader.readUnsigned();
1190
+
1191
+ let packedIndex = 0;
1192
+ if (spec.orderedValues) {
1193
+ for (const item of spec.orderedItems) {
1194
+ if (!(mask & item.bit)) continue;
1195
+ if (item.kind === "blob") record[item.key] = reader.readBlob();
1196
+ else record[item.key] = (record[item.key] || 0) + reader.readFieldDelta();
1197
+ }
1198
+ } else {
1199
+ for (const blob of spec.blobs || []) {
1200
+ if (mask & blob.bit) record[blob.key] = reader.readBlob();
1201
+ }
1202
+ }
1203
+
1204
+ if (hasPacked && !spec.packedBeforeValues) packed = reader.readUnsigned();
1205
+
1206
+ for (const item of spec.packedBits || []) {
1207
+ if (mask & item.bit) record[item.key] = (packed >> packedIndex++) & 1;
1208
+ }
1209
+
1210
+ if (packed != null && spec.packedOffsetKeys) {
1211
+ for (const key of spec.packedOffsetKeys) {
1212
+ record[key] = (packed >> packedIndex++) & 1;
1213
+ }
1214
+ }
1215
+
1216
+ if (!spec.orderedValues) {
1217
+ for (const field of spec.fields || []) {
1218
+ if (!(mask & field.bit)) continue;
1219
+ record[field.key] = (record[field.key] || 0) + reader.readFieldDelta();
1220
+ }
1221
+ }
1222
+
1223
+ if (spec.scale) record.scaled = spec.scale(record);
1224
+ }
1225
+ }
1226
+
1227
+ function summarizeUpdate(update) {
1228
+ return {
1229
+ generation: update.generation,
1230
+ sectionCount: update.sections.length,
1231
+ removals: update.removals.length,
1232
+ unknownTags: update.unknownTags,
1233
+ error: update.error?.message || null,
1234
+ sections: update.sections.map((section) => ({
1235
+ tag: section.tag,
1236
+ table: section.table,
1237
+ name: section.name,
1238
+ records: section.records.length
1239
+ }))
1240
+ };
1241
+ }
1242
+
1243
+ function insertSorted(values, value) {
1244
+ let low = 0;
1245
+ let high = values.length;
1246
+ while (low < high) {
1247
+ const mid = (low + high) >> 1;
1248
+ if (values[mid] < value) low = mid + 1;
1249
+ else high = mid;
1250
+ }
1251
+ values.splice(low, 0, value);
1252
+ }
1253
+
1254
+ export function decodeModelData(bytes) {
1255
+ const state = new ModelState();
1256
+ return state.apply(bytes);
1257
+ }