dredless 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -99,6 +99,29 @@ interface Ship {
99
99
  }
100
100
  ```
101
101
 
102
+ Ship names are returned exactly as provided by the Drednot server. In
103
+ particular, `fetchShips()` and `fetchShipList()` preserve leading and trailing
104
+ spaces; it is up to callers to optionally trim those spaces from `name`.
105
+
106
+ ### `InviteMetadata`
107
+
108
+ Returned by `fetchInviteMetadata()`.
109
+
110
+ ```ts
111
+ interface InviteMetadata {
112
+ shipId: string | null;
113
+ name: string | null;
114
+ previewImageUrl: string | null;
115
+ hostname: string;
116
+ inviteCode: string;
117
+ color?: string | null;
118
+ }
119
+ ```
120
+
121
+ Invite metadata also preserves leading and trailing spaces in the
122
+ ship name returned by the invite page. The optional `color` field is included
123
+ only when `includeColor: true` is passed and is sampled from the preview image.
124
+
102
125
  ### `ShipList`
103
126
 
104
127
  Returned by `fetchShipList()`.
@@ -302,6 +325,59 @@ once(type: string, callback: (...args: unknown[]) => void): this;
302
325
 
303
326
  Events are implementation-defined strings emitted by the client event bus.
304
327
 
328
+ ### Context menus: `client.tips`
329
+
330
+ ```js
331
+ const door = client.currentShip().machines.doors()[0];
332
+ const menu = await door.openTips();
333
+ console.log(menu.options);
334
+ client.tips.select("Restrict to Crew"); // Also accepts a zero-based option index.
335
+
336
+ // Open and select in one operation:
337
+ await client.tips.choose(door, "Restrict to Captains");
338
+
339
+ // Dismiss without selecting an option:
340
+ await door.openTips();
341
+ await client.tips.close();
342
+
343
+ // Set access and wait for the server's model update:
344
+ await door.setRank("guest");
345
+ await client.currentShip().machines.spawnPoints()[0].setRank("crew");
346
+ ```
347
+
348
+ `client.tips.open(entity, options?)` accepts an entity handle or an entity id in
349
+ the current world and resolves to a frozen `TipList` with
350
+ `{ entity, world, filter, x, y, options }`. Every entity handle also exposes
351
+ `openTips(options?)`. `client.tips.current` is the latest menu, or `null` after
352
+ selection, an empty menu, or disconnect. The `tip-list` event receives
353
+ `(menu, world)` including empty menus; the existing raw `side-event` is preserved.
354
+ The server does not send an entity id in a tip list: `entity` is inferred from
355
+ the API's requested target and is `null` for unsolicited menus. Avoid mixing raw
356
+ context-menu commands with an API operation in progress.
357
+
358
+ `select(option, command?)` sends the selected index and returns the client.
359
+ `choose(entity, option, options?)` opens a fresh menu and selects by exact text
360
+ or index, resolving to the client once selection is sent. Neither method waits
361
+ for the resulting action to complete.
362
+
363
+ `close(options?)` sends the captured left-click dismissal input with no focused
364
+ entity or selected option, then resolves to the client after the server sends an
365
+ empty tip list for the menu's world. It skips sending when no menu is open and
366
+ uses the same timeout and overlap rules as `open()`. Optional `command` fields
367
+ can supply click coordinates or inventory/view settings.
368
+
369
+ Door and spawn-point `setRank(rank, options?)` accept `"guest"`, `"crew"`,
370
+ `"captain"`, or `0`, `1`, `3`. They resolve to the client after the model confirms
371
+ the requested rank, and skip sending when the rank already matches. These are
372
+ access restrictions; they do not change a player's ship rank.
373
+
374
+ Options are `{ timeout?: number, command?: Command }`. The timeout defaults to
375
+ 5000 milliseconds per response (menu and, for `setRank`, rank confirmation).
376
+ Calls reject on timeout, disconnect, unknown options, invalid ranks, or a target
377
+ outside the current world. Only one asynchronous tip operation may run per
378
+ client; overlapping calls reject. The server still enforces permissions and
379
+ interaction range; these helpers do not move the player or grant access.
380
+
305
381
  ## `client.state()` Output
306
382
 
307
383
  ```ts
@@ -1009,6 +1085,13 @@ interface LoaderFullConfig extends LoaderConfig {
1009
1085
 
1010
1086
  State output:
1011
1087
 
1088
+ Loader state uses the official component defaults when configuration fields
1089
+ are omitted: pick `3` (middle-left), place `4` (middle-right), normal priority,
1090
+ stack `16`, cycle `1` second, and both flags disabled. Cycle times are converted
1091
+ from ticks at 20 ticks per second. Flag updates set their absolute boolean
1092
+ values; repeated updates do not toggle them. Raw loader configuration is stored
1093
+ in model table `69` (wire section `162`), separately from navigation state.
1094
+
1012
1095
  ```ts
1013
1096
  interface LoaderSummary {
1014
1097
  entity: number;
@@ -1364,6 +1447,11 @@ interface CargoEjectorHandle extends MachineHandle {
1364
1447
 
1365
1448
  Associated summary shapes:
1366
1449
 
1450
+ Cannon summaries expose `relativeAngleRadians` relative to the mount and
1451
+ `absoluteAngleRadians` in ship coordinates, normalized to `[0, 2π)`, replacing
1452
+ the raw `aim` field. The relative angle is `null` when aim is unavailable; the
1453
+ absolute angle is `null` when either aim or mounting rotation is unavailable.
1454
+
1367
1455
  ```ts
1368
1456
  interface CannonSummary {
1369
1457
  entity: number;
@@ -1372,7 +1460,8 @@ interface CannonSummary {
1372
1460
  ammoItemId: number | null;
1373
1461
  ammoName: string | null;
1374
1462
  ammoCount: number;
1375
- aim: number | null;
1463
+ relativeAngleRadians: number | null; // Barrel angle relative to its mount.
1464
+ absoluteAngleRadians: number | null; // Ship coordinates, normalized to [0, 2π).
1376
1465
  recoil: number | null;
1377
1466
  recoil2: number | null;
1378
1467
  recoils: [number | null, number | null];
package/docs/NODE_API.md CHANGED
@@ -44,6 +44,7 @@ interface DredlessNamespace {
44
44
  fetchServers: typeof fetchServers;
45
45
  fetchShips: typeof fetchShips;
46
46
  fetchShipList: typeof fetchShipList;
47
+ fetchInviteMetadata: typeof fetchInviteMetadata;
47
48
  joinShip: typeof joinShip;
48
49
  startShip: typeof startShip;
49
50
  startNewShip: typeof startNewShip;
@@ -133,6 +134,9 @@ function fetchShips(session: Session, server: ServerRef): Promise<Ship[]>;
133
134
 
134
135
  Authenticated convenience wrapper around `session.fetchShips(server)`.
135
136
 
137
+ Ship names in the returned objects are canonical Drednot server data. Leading
138
+ and trailing spaces are preserved exactly; callers should not trim them.
139
+
136
140
  ### `Dredless.fetchShipList(session, server)`
137
141
 
138
142
  ```ts
@@ -141,6 +145,22 @@ function fetchShipList(session: Session, server: ServerRef): Promise<ShipList>;
141
145
 
142
146
  Authenticated convenience wrapper around `session.fetchShipList(server)`.
143
147
 
148
+ Ship names in the returned objects are canonical Drednot server data. Leading
149
+ and trailing spaces are preserved exactly; callers should not trim them.
150
+
151
+ ### `Dredless.fetchInviteMetadata(invite, options?)`
152
+
153
+ ```ts
154
+ function fetchInviteMetadata(
155
+ invite: string | URL,
156
+ options?: { includeColor?: boolean }
157
+ ): Promise<InviteMetadata>;
158
+ ```
159
+
160
+ Fetches and parses an invite page. The returned ship name preserves leading
161
+ and trailing spaces exactly as returned by Drednot. Set `includeColor: true`
162
+ to sample the preview image’s top-left pixel; otherwise no image is fetched.
163
+
144
164
  ### `Dredless.joinShip(server, ship?, session?)`
145
165
 
146
166
  ```ts
@@ -371,4 +391,3 @@ interface ConnectionSnapshot {
371
391
  session: SessionSnapshot;
372
392
  }
373
393
  ```
374
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dredless",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "A fully-featured and from-scratch headless drednot.io client implementation written in pure js.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/certainhuman/dredless#readme",
package/src/browser.d.ts CHANGED
@@ -82,6 +82,9 @@ export {
82
82
  } from "./index.js";
83
83
 
84
84
  export type {
85
+ TipDomain,
86
+ TipList,
87
+ TipOptions,
85
88
  Server,
86
89
  Ship,
87
90
  ShipList,
@@ -1,6 +1,7 @@
1
1
  import {INITIAL_OUTFIT_MESSAGE, JOIN_USER_AGENT, KEEPALIVE_INTERVAL_MS, KEEPALIVE_MESSAGE} from "../constants.js";
2
2
  import {getWebSocket, isNode} from "../runtime.js";
3
3
  import {EventBus} from "../events.js";
4
+ import {TipDomain} from "./domains/tips.js";
4
5
  import {cookieHeader} from "../network/cookies.js";
5
6
  import {fetchServers} from "../network/servers.js";
6
7
  import {Connection} from "../network/connection.js";
@@ -143,6 +144,7 @@ export class DredlessClient extends EventBus {
143
144
  this.debug = new ClientDebugDomain(this);
144
145
  this.player = new PlayerDomain(this);
145
146
  this.inventory = new InventoryDomain(this);
147
+ this.tips = new TipDomain(this);
146
148
  this.management = new ShipManagementDomain(this);
147
149
  this.cpuLoad = null;
148
150
  this.inventoryState = normalizeInventoryEvent({type: "inventory", general_slots: 5});
@@ -1042,6 +1044,7 @@ export class DredlessClient extends EventBus {
1042
1044
 
1043
1045
  #handleSideEvent(event, world) {
1044
1046
  if (!event || typeof event !== "object") return;
1047
+ if (event.type === "tip_list") this.tips.receive(event, world);
1045
1048
  if (event.type === "inventory") {
1046
1049
  this.inventoryState = normalizeInventoryEvent(event);
1047
1050
  this.emit("inventory", this.inventoryState, world);
@@ -835,6 +835,10 @@ export class EntityHandle {
835
835
  use(options = {}, command = {}) {
836
836
  return this.client.player.useEntity(this.id, options, command);
837
837
  }
838
+
839
+ openTips(options = {}) {
840
+ return this.client.tips.open(this, options);
841
+ }
838
842
  }
839
843
 
840
844
  export class MachineHandle extends EntityHandle {
@@ -1228,6 +1232,10 @@ export class HelmHandle extends MachineHandle {
1228
1232
  }
1229
1233
 
1230
1234
  export class DoorHandle extends MachineHandle {
1235
+ setRank(rank, options = {}) {
1236
+ return this.client.tips.setRank(this, rank, options);
1237
+ }
1238
+
1231
1239
  get state() {
1232
1240
  return summaryForEntity(this.client, this.scope, this.id)?.contents?.door || null;
1233
1241
  }
@@ -1242,6 +1250,10 @@ export class DoorHandle extends MachineHandle {
1242
1250
  }
1243
1251
 
1244
1252
  export class SpawnPointHandle extends MachineHandle {
1253
+ setRank(rank, options = {}) {
1254
+ return this.client.tips.setRank(this, rank, options);
1255
+ }
1256
+
1245
1257
  get state() {
1246
1258
  return summaryForEntity(this.client, this.scope, this.id)?.contents?.spawnPoint || null;
1247
1259
  }
@@ -0,0 +1,153 @@
1
+ import {normalizePlayerRank} from "../../protocol/outbound/ship-management.js";
2
+
3
+ function timeoutValue(options) {
4
+ const timeout = options.timeout ?? 5000;
5
+ if (!Number.isFinite(timeout) || timeout <= 0) throw new RangeError("timeout must be positive milliseconds");
6
+ return timeout;
7
+ }
8
+
9
+ function waitFor(client, eventType, read, timeout, label, send = null) {
10
+ return new Promise((resolve, reject) => {
11
+ const cleanup = () => {
12
+ clearTimeout(timer);
13
+ client.off(eventType, check);
14
+ client.off("close", closed);
15
+ };
16
+ const check = (...args) => {
17
+ const value = read(...args);
18
+ if (value) { cleanup(); resolve(value); }
19
+ };
20
+ const closed = () => { cleanup(); reject(new Error(`Disconnected while waiting for ${label}`)); };
21
+ const timer = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for ${label}`)); }, timeout);
22
+ client.on(eventType, check);
23
+ client.on("close", closed);
24
+ if (send) {
25
+ try { send(); } catch (error) { cleanup(); reject(error); return; }
26
+ }
27
+ check();
28
+ });
29
+ }
30
+
31
+ export class TipDomain {
32
+ #busy = false;
33
+ #target = null;
34
+ #current = null;
35
+
36
+ constructor(client) {
37
+ this.client = client;
38
+ client.on("close", () => { this.#current = null; this.#target = null; });
39
+ }
40
+
41
+ get current() {
42
+ return this.#current;
43
+ }
44
+
45
+ receive(event, world) {
46
+ const options = Array.isArray(event.options) ? event.options.map(String) : [];
47
+ const entity = this.#target?.world === world?.id ? this.#target.entity : null;
48
+ const menu = Object.freeze({entity, world: world?.id ?? null, filter: event.filter ?? null,
49
+ x: event.x ?? 0, y: event.y ?? 0, options: Object.freeze(options)});
50
+ this.#current = options.length ? menu : null;
51
+ this.client.emit("tip-list", menu, world);
52
+ }
53
+
54
+ async #exclusive(action) {
55
+ if (this.#busy) throw new Error("A tip menu operation is already in progress");
56
+ this.#busy = true;
57
+ try {
58
+ return await action();
59
+ } catch (error) {
60
+ this.#current = null;
61
+ this.#target = null;
62
+ throw error;
63
+ } finally { this.#busy = false; }
64
+ }
65
+
66
+ async #open(entity, options) {
67
+ if (!this.client.ready || !this.client.connected) throw new Error("Client must be connected and ready to open tips");
68
+ const timeout = timeoutValue(options);
69
+ const handle = typeof entity === "number" ? this.client.currentShip()?.entities.get(entity) : entity;
70
+ if (!handle || handle.client !== this.client || !handle.exists()) throw new Error("Tip target must be an existing entity on this client");
71
+ const world = this.client.worlds.currentWorldId;
72
+ const scopeWorld = handle.scope === "ship" ? this.client.worlds.shipWorld()
73
+ : handle.scope === "current" ? this.client.worlds.worlds.get(world)
74
+ : handle.scope === "overworld" ? this.client.worlds.overworld()
75
+ : this.client.worlds.worlds.get(Number(handle.scope));
76
+ if (scopeWorld?.id !== world) {
77
+ throw new Error("Tip target must be in the current world");
78
+ }
79
+ const position = handle.position;
80
+ if (!position) throw new Error("Tip target has no position");
81
+ this.#current = null;
82
+ this.#target = {entity: handle.id, world, command: {...options.command,
83
+ focus_ent: handle.id, mx: position.x, my: position.y}};
84
+ // Subscribe before sending: attached sockets can deliver a reply synchronously.
85
+ return waitFor(this.client, "tip-list", () => {
86
+ return this.#current?.entity === handle.id && this.#current.world === world ? this.#current : null;
87
+ }, timeout, "tip menu", () => {
88
+ this.client.send({...this.#target.command, act1: false, act2: true, tip_select: null});
89
+ });
90
+ }
91
+
92
+ open(entity, options = {}) {
93
+ return this.#exclusive(() => this.#open(entity, options));
94
+ }
95
+
96
+ close(options = {}) {
97
+ return this.#exclusive(async () => {
98
+ const timeout = timeoutValue(options);
99
+ const menu = this.#current;
100
+ if (!menu) return this.client;
101
+ if (!this.client.connected || !this.client.ready) throw new Error("Client must be connected and ready to close tips");
102
+ if (menu.world !== this.client.worlds.currentWorldId) throw new Error("Tip menu belongs to another world");
103
+ await waitFor(this.client, "tip-list", (update) => update?.world === menu.world
104
+ && update.options.length === 0, timeout, "tip menu dismissal", () => {
105
+ // The official client dismisses with a normal left click outside an entity.
106
+ this.client.send({...options.command, focus_ent: null, config_ent: null,
107
+ act1: true, act1_held: true, act2: false, tip_select: null});
108
+ });
109
+ this.#target = null;
110
+ return this.client;
111
+ });
112
+ }
113
+
114
+ select(option, command = {}) {
115
+ if (this.#busy) throw new Error("A tip menu operation is already in progress");
116
+ return this.#select(option, command);
117
+ }
118
+
119
+ #select(option, command = {}) {
120
+ if (!this.client.connected || !this.client.ready) throw new Error("Client must be connected and ready to select tips");
121
+ const menu = this.#current;
122
+ if (!menu || menu.entity == null || !this.#target) throw new Error("Open an entity's tip menu before selecting an option");
123
+ if (menu.world !== this.client.worlds.currentWorldId) throw new Error("Tip menu belongs to another world");
124
+ const index = typeof option === "string" ? menu.options.indexOf(option) : option;
125
+ if (!Number.isInteger(index) || index < 0 || index >= menu.options.length) throw new RangeError("Tip option is not in the current menu");
126
+ this.client.send({...this.#target.command, ...command, focus_ent: menu.entity,
127
+ act1: false, act2: false, tip_select: index});
128
+ this.#current = null;
129
+ this.#target = null;
130
+ return this.client;
131
+ }
132
+
133
+ choose(entity, option, options = {}) {
134
+ return this.#exclusive(async () => {
135
+ await this.#open(entity, options);
136
+ return this.#select(option);
137
+ });
138
+ }
139
+
140
+ setRank(handle, rank, options = {}) {
141
+ return this.#exclusive(async () => {
142
+ const value = normalizePlayerRank(rank);
143
+ const timeout = timeoutValue(options);
144
+ if (!handle.exists()) throw new Error("Rank target no longer exists");
145
+ if (handle.rank === value) return this.client;
146
+ const text = {0: "Restrict to Guests", 1: "Restrict to Crew", 3: "Restrict to Captains"}[value];
147
+ await this.#open(handle, options);
148
+ await waitFor(this.client, "model", () => handle.rank === value, timeout, "access rank update",
149
+ () => this.#select(text));
150
+ return this.client;
151
+ });
152
+ }
153
+ }
@@ -1,2 +1,2 @@
1
1
  export {DredlessClient, WrenchMode, TurretMode} from "../index.js";
2
- export type {DredlessClientOptions, ClientSnapshot} from "../index.js";
2
+ export type {DredlessClientOptions, ClientSnapshot, TipDomain, TipList, TipOptions} from "../index.js";
package/src/index.d.ts CHANGED
@@ -479,6 +479,7 @@ export class DredlessClient {
479
479
  debug: ClientDebugDomain;
480
480
  player: PlayerDomain;
481
481
  inventory: InventoryDomain;
482
+ tips: TipDomain;
482
483
  management: ShipManagementDomain;
483
484
  readyPromise: Promise<this>;
484
485
 
@@ -512,13 +513,41 @@ export class DredlessClient {
512
513
 
513
514
  entity(entityId: number, scope?: number | string): EntitySnapshot | null;
514
515
 
516
+ on(type: "tip-list", callback: (menu: TipList, world: WorldState) => void): this;
515
517
  on(type: string, callback: (...args: unknown[]) => void): this;
516
518
 
519
+ off(type: "tip-list", callback: (menu: TipList, world: WorldState) => void): this;
517
520
  off(type: string, callback: (...args: unknown[]) => void): this;
518
521
 
522
+ once(type: "tip-list", callback: (menu: TipList, world: WorldState) => void): this;
519
523
  once(type: string, callback: (...args: unknown[]) => void): this;
520
524
  }
521
525
 
526
+ export interface TipList {
527
+ /** Target inferred from tips.open(); null for unsolicited menus. */
528
+ readonly entity: number | null;
529
+ readonly world: number | null;
530
+ readonly filter: number | null;
531
+ readonly x: number;
532
+ readonly y: number;
533
+ readonly options: readonly string[];
534
+ }
535
+
536
+ export interface TipOptions {
537
+ /** Milliseconds to wait per server response; default 5000. */
538
+ timeout?: number;
539
+ command?: Command;
540
+ }
541
+
542
+ export interface TipDomain {
543
+ readonly current: TipList | null;
544
+ open(entity: number | EntityHandle, options?: TipOptions): Promise<TipList>;
545
+ /** Dismiss the menu and wait for the server's empty tip list. */
546
+ close(options?: TipOptions): Promise<DredlessClient>;
547
+ select(option: number | string, command?: Command): DredlessClient;
548
+ choose(entity: number | EntityHandle, option: number | string, options?: TipOptions): Promise<DredlessClient>;
549
+ }
550
+
522
551
  export interface ClientNetDomain {
523
552
  connected: boolean;
524
553
  ready: boolean;
@@ -952,6 +981,7 @@ export interface EntityHandle {
952
981
  asExpandoBox(): ExpandoBoxHandle | null;
953
982
 
954
983
  use(options?: { invSlot?: number; hold?: boolean }, command?: Command): DredlessClient;
984
+ openTips(options?: TipOptions): Promise<TipList>;
955
985
  }
956
986
 
957
987
  export interface MachineHandle extends EntityHandle {
@@ -1137,11 +1167,13 @@ export interface DoorHandle extends EntityHandle {
1137
1167
  state: DoorSummary | null;
1138
1168
  rank: number | undefined;
1139
1169
  open: boolean | undefined;
1170
+ setRank(rank: ShipPlayerRank, options?: TipOptions): Promise<DredlessClient>;
1140
1171
  }
1141
1172
 
1142
1173
  export interface SpawnPointHandle extends MachineHandle {
1143
1174
  state: SpawnPointSummary | null;
1144
1175
  rank: number | undefined;
1176
+ setRank(rank: ShipPlayerRank, options?: TipOptions): Promise<DredlessClient>;
1145
1177
  }
1146
1178
 
1147
1179
  export interface ShieldProjectorHandle extends MachineHandle {
@@ -1800,7 +1832,10 @@ export interface CannonSummary {
1800
1832
  ammoItemId: number | null;
1801
1833
  ammoName: string | null;
1802
1834
  ammoCount: number;
1803
- aim: number | null;
1835
+ /** Barrel angle relative to its mount, in radians. */
1836
+ relativeAngleRadians: number | null;
1837
+ /** Barrel direction in ship coordinates, normalized to [0, 2π); null if aim or mounting rotation is unavailable. */
1838
+ absoluteAngleRadians: number | null;
1804
1839
  recoil: number | null;
1805
1840
  recoil2: number | null;
1806
1841
  recoils: [number | null, number | null];
@@ -66,7 +66,7 @@ function normalizeInviteUrl(invite) {
66
66
  }
67
67
 
68
68
  function parseInviteTitle(title) {
69
- const match = title.match(/^Invite:\s?(.*?)\s*\{([^{}]+)}(?:\s*-\s*drednot\.io)?\s*$/i);
69
+ const match = title.match(/^Invite:\s?(.+?) \{([^{}]+)}(?:\s*-\s*drednot\.io)?\s*$/i);
70
70
  return match ? {name: decodeHtml(match[1]), shipId: decodeHtml(match[2])} : {name: null, shipId: null};
71
71
  }
72
72
 
@@ -2,8 +2,7 @@ import {decoder} from "../../constants.js";
2
2
  import {
3
3
  LOADER_FILTER_MODE_NAMES,
4
4
  LOADER_POSITION_NAMES,
5
- LOADER_PRIORITY_NAMES,
6
- LoaderConfigTracker
5
+ LOADER_PRIORITY_NAMES
7
6
  } from "./loader-config.js";
8
7
  import {navigationDestinationFromEncodedValue, navigationZoneFromBaseId} from "../overworld.js";
9
8
  import {maybeSolveGeneratorMazeSeed} from "../generator-maze.js";
@@ -284,7 +283,11 @@ export const MODEL_TABLE_SPECS = new Map([
284
283
  [63, {name: "numeric_pair", ...TWO_FIELD_SPEC}],
285
284
  [67, {name: "numeric_single", fields: numericFields({1: 20})}],
286
285
  [69, {
287
- name: "rare_snapshot",
286
+ name: "loader_config",
287
+ // The official loader component starts with these values. Wire section
288
+ // 162 sends a packed absolute flag word before the five numeric deltas.
289
+ defaults: {q20: 3, q24: 4, q28: 1, q32: 16, q36: 20, q41: 0, q42: 0},
290
+ packedBeforeValues: true,
288
291
  packedBits: [{bit: 32, offset: 41}, {bit: 64, offset: 42}],
289
292
  fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})
290
293
  }],
@@ -407,7 +410,7 @@ export const WIRE_TAG_TABLES = new Map([
407
410
  [158, 75],
408
411
  [160, 76],
409
412
  [161, 77],
410
- [162, 78],
413
+ [162, 69], // Loader configuration; distinct from navigation state (168).
411
414
  [163, 72],
412
415
  [164, 73],
413
416
  [165, 74],
@@ -574,32 +577,6 @@ export function firstRecord(records, predicate = null) {
574
577
  return null;
575
578
  }
576
579
 
577
- export const TABLE_78_FIELD_BITS = [
578
- ["q20", 1],
579
- ["q24", 2],
580
- ["q28", 4],
581
- ["q32", 8],
582
- ["q36", 16],
583
- ["q40", 32],
584
- ["q44", 64]
585
- ];
586
-
587
- export function table78DeltaRecord(changed) {
588
- const record = {lastMask: changed.mask};
589
- for (const [field, bit] of TABLE_78_FIELD_BITS) {
590
- if (!(changed.mask & bit)) continue;
591
- if (field === "q44" && ((changed.mask & 32) || changed.record?.q44 == null)) continue;
592
- record[field] = (changed.record?.[field] ?? 0) - (changed.previous?.[field] ?? 0);
593
- }
594
- return record;
595
- }
596
-
597
- export function isSemanticLoaderDelta(changed, full) {
598
- return !full &&
599
- changed.previous != null &&
600
- (changed.mask & 96) === 96;
601
- }
602
-
603
580
  export function decodeText(blob) {
604
581
  if (!(blob instanceof Uint8Array)) return null;
605
582
  try {
@@ -679,8 +656,14 @@ export function summarizeCargoEjector(entity, typeId, record) {
679
656
  };
680
657
  }
681
658
 
682
- export function summarizeCannon(entity, record, typeId = null) {
659
+ export function summarizeCannon(entity, record, typeId = null, transformRecord = null) {
683
660
  if (!record || !CANNON_TYPE_IDS.has(typeId)) return null;
661
+ const relativeAngleRadians = numberOrNull(record.q32, 200);
662
+ const mountingAngleRadians = numberOrNull(transformRecord?.q28, 127.324);
663
+ const tau = 2 * Math.PI;
664
+ const absoluteAngleRadians = relativeAngleRadians == null || mountingAngleRadians == null
665
+ ? null
666
+ : (((mountingAngleRadians + relativeAngleRadians) % tau) + tau) % tau;
684
667
  const ammoItemId = record.q24 == null ? null : CANNON_AMMO_COLOR_ITEM_IDS.get(record.q24) ?? null;
685
668
  const charge = record.q40 ?? null;
686
669
  const recoil = record.q36 ?? null;
@@ -692,7 +675,8 @@ export function summarizeCannon(entity, record, typeId = null) {
692
675
  ammoItemId,
693
676
  ammoName: entityNameFromType(ammoItemId),
694
677
  ammoCount: record.q28 ?? 0,
695
- aim: record.q32 ?? null,
678
+ relativeAngleRadians,
679
+ absoluteAngleRadians,
696
680
  recoil,
697
681
  recoil2,
698
682
  recoils: [recoil, recoil2],
@@ -1142,15 +1126,14 @@ export function summarizeHugeThruster(entity, markerRecord, sizeRecord = null) {
1142
1126
  };
1143
1127
  }
1144
1128
 
1145
- export function summarizeLoader(entity, loaderRecord, loaderFilterRecord = null, filterSlotsRecord = null, tracker = null, includeDefaults = false, itemHolderRecord = null) {
1129
+ export function summarizeLoader(entity, loaderRecord, loaderFilterRecord = null, filterSlotsRecord = null, includeDefaults = false, itemHolderRecord = null) {
1146
1130
  if (!loaderRecord && !loaderFilterRecord && !includeDefaults) return null;
1147
- const config = tracker?.getConfig(null, entity, loaderRecord, loaderFilterRecord, filterSlotsRecord) ?? {};
1148
- const hasLoaderState = Boolean(loaderRecord);
1149
- const hasPositionConfig = Boolean(tracker?.hasPositionConfig(null, entity));
1150
- const pick = hasLoaderState && hasPositionConfig ? config.pick ?? null : null;
1151
- const place = hasLoaderState && hasPositionConfig ? config.place ?? null : null;
1152
- const priority = config.priority ?? 0;
1153
- const filterMode = config.filterMode ?? null;
1131
+ // Match the official component defaults, then apply the already decoded state.
1132
+ const loader = {...MODEL_TABLE_SPECS.get(69).defaults, ...loaderRecord};
1133
+ const pick = loader.q20;
1134
+ const place = loader.q24;
1135
+ const priority = loader.q28 - 1;
1136
+ const filterMode = loaderFilterRecord?.q20 ?? 0;
1154
1137
  const heldItem = itemSummary(itemHolderRecord?.q20, itemHolderRecord?.q24 ?? null);
1155
1138
  return {
1156
1139
  entity,
@@ -1160,13 +1143,15 @@ export function summarizeLoader(entity, loaderRecord, loaderFilterRecord = null,
1160
1143
  placeName: enumValueName(LOADER_POSITION_NAMES, place),
1161
1144
  priority,
1162
1145
  priorityName: enumValueName(LOADER_PRIORITY_NAMES, priority),
1163
- requireOutput: config.requireOutput ?? false,
1164
- waitForStack: config.waitForStack ?? false,
1165
- stack: config.stack ?? 16,
1166
- cycle: Math.max(1, config.cycle ?? 1),
1146
+ requireOutput: Boolean(loader.q41),
1147
+ waitForStack: Boolean(loader.q42),
1148
+ stack: loader.q32,
1149
+ cycle: loader.q36 / 20,
1167
1150
  filterMode,
1168
1151
  filterModeName: enumValueName(LOADER_FILTER_MODE_NAMES, filterMode),
1169
- filterSlots: config.filterSlots ?? null,
1152
+ filterSlots: filterSlotsRecord
1153
+ ? [filterSlotsRecord.q20, filterSlotsRecord.q24, filterSlotsRecord.q28].map(value => value || null)
1154
+ : null,
1170
1155
  heldItemId: heldItem.itemId,
1171
1156
  heldItemName: heldItem.itemName,
1172
1157
  heldCount: heldItem.count,
@@ -1206,7 +1191,7 @@ export function summarizePusher(entity, pusherRecord, filterSlotsRecord = null)
1206
1191
  filteredModeName: enumValueName(PUSHER_MODE_NAMES, filteredMode),
1207
1192
  angle: numberOrNull(pusherRecord.q28, 10),
1208
1193
  speed: pusherRecord.q32 == null ? 20 : (pusherRecord.q32 / 100) + 20,
1209
- length: pusherRecord.q40 == null ? 1000 : ((pusherRecord.q40 + 50) / 10) + 995,
1194
+ length: 1000 + (pusherRecord.q40 ?? 0) / 10,
1210
1195
  filterInventory: pusherRecord.q36 === 1,
1211
1196
  filterSlots: filterSlotsRecord ? [filterSlotsRecord.q20 ?? null, filterSlotsRecord.q24 ?? null, filterSlotsRecord.q28 ?? null] : null,
1212
1197
  state: cloneRecord(pusherRecord),