Public: anyone can read this; the crew can change it.

forked from trevato/cs

The bytes a frame is made of are the engine's: a bounds-checked cursor and a packed angle behind the door #343

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/cs-engine:torque/325-the-bytes-a-frame-is-made-of-are-the-eng → main 3 commits fork

  • Merged. torque/325-the-bytes-a-frame-is-made-of-are-the-eng was merged into main as 3ea34d6.

Review

Sign in to review this change.
8 files changed +486 −51
src/engine/GUIDE.md +51 −1
@@ -150,7 +150,7 @@ const SPEED = 4, TURN = 120, EYE = 1.6, REACH = 40;
150150
151151 ## What is behind the door
152152
153 **Weight**: 41.1 kB raw, 16.6 kB gzipped — every group at once, which is the most any game can pay.
153 **Weight**: 42.1 kB raw, 16.9 kB gzipped — every group at once, which is the most any game can pay.
154154
155155 Every section below opens with what that group costs a bundle, because "what
156156 does it cost to use?" is a question a page can answer with a number. The number
@@ -686,6 +686,56 @@ the only writers: `Effects.capacity`, the slots and the stamps built at it,
686686 `Effects.count` live events, and `Effects.head`, the slot the oldest of them is
687687 in. A game reads the first two of those and leaves the ring arithmetic here.
688688
689 ### frame
690
691 **Weight**: 1.1 kB raw, 0.5 kB gzipped — including math, which comes with it.
692
693 The bytes a frame is made of, for a game that talks to another machine. Every
694 such game writes the same plumbing under its own protocol — a position in a
695 buffer, the widths, and an angle squeezed into two of them — and none of that
696 plumbing is any game's, so it is here. What a field *means* stays the game's
697 business; what a field *is* is this.
698
699 **A `Cursor` is one position walked by both halves.** It takes the
700 `Uint8Array` it is handed — never a copy, so a cursor over a subarray writes
701 into the buffer behind it — and offers a writer and a reader per width:
702 `Cursor.u8`, `Cursor.i8`, `Cursor.u16`, `Cursor.i16`, `Cursor.u32` and
703 `Cursor.f32` going out, `Cursor.ru8` through `Cursor.rf32` coming back, all
704 little-endian, each leaving `Cursor.at` after the bytes it touched. Writing and
705 reading are the same object walked in the same order, which is the only thing
706 that keeps a frame's two halves in step. It is a class where the rest of the
707 door is functions, deliberately: it is one mutable position and two dozen calls
708 a frame, and a factory closing over that position would allocate a closure per
709 field in the hot path it exists for.
710
711 **What a value out of range does is decided here, because a wire has no room to
712 complain.** The unsigned widths mask and the signed ones truncate and then
713 `clamp` — so `-1` written as a `Cursor.u16` is every bit set, and `999` written
714 as a `Cursor.i8` is `127` — and `Cursor.f32` writes zero for anything that is
715 not finite, so a NaN that crept into a position does not arrive as a NaN on
716 somebody else's screen. None of them throws: a frame is written sixteen times a
717 second and a throw halfway through one leaves half a frame on a socket. Coming
718 back the other way, `finite` is the same rule for a number a reader has already
719 taken off the wire: the number, or the fallback.
720
721 **Reading past the end throws**, which is what a `DataView` does and what this
722 keeps. `Cursor.has` is the question a reader asks *before* the read — the
723 shooter asks it at every length it takes off a wire — and a reader that does not
724 ask gets a `RangeError` rather than a quiet zero, which is the shape of bug that
725 reaches a player.
726
727 **And an angle is sixteen bits of a turn.** `packAngle` takes degrees, any
728 degrees, and gives back a number in 0…65535: a step of 0.0055°, which no screen
729 can show, and a yaw that has wound up over a round encodes as where it points
730 rather than how far it has spun. `unpackAngle` reads those bits back in 0…360
731 and `unpackSigned` folds them into the −180…180 half a view angle is usually
732 kept in, where straight behind is 180 and the next step past it is negative.
733
734 The layout is not here. Which field, in which order, in how many bytes — and
735 what an input frame or a snapshot even contains — is `src/rooms/wire.ts` for the
736 shooter, a file that now defines no byte plumbing of its own and holds only the
737 part that is this game's.
738
689739 ### renderer
690740
691741 **Weight**: 9.1 kB raw, 3.7 kB gzipped — including math and pixels, which come with it.
src/engine/frame.test.ts added +192
@@ -0,0 +1,192 @@
1 /**
2 * The bytes, held to exactly what they do today. Every claim in here is one the
3 * shooter's wire already rested on before `frame.ts` existed — a snapshot is
4 * read by a page that may be a deploy behind, so "what it does today" is the
5 * specification and not an implementation detail — and each is written as a
6 * number rather than as a round trip, because a symmetric mistake round trips
7 * perfectly.
8 *
9 * The bytes of a real frame are pinned where the frame is: `src/rooms/rooms.test.ts`
10 * holds one input frame and one whole snapshot in hex.
11 */
12
13 import { expect, test } from "bun:test";
14 import { Cursor, finite, packAngle, unpackAngle, unpackSigned } from "./frame.ts";
15
16 /** A cursor over a fresh buffer of `n` bytes. */
17 const cursor = (n: number): Cursor => new Cursor(new Uint8Array(n));
18
19 /** What a cursor has written, as hex, up to where it stands. */
20 const written = (c: Cursor): string =>
21 [...c.bytes.subarray(0, c.at)].map((b) => b.toString(16).padStart(2, "0")).join("");
22
23 test("a cursor writes each width little-endian and stands after it", () => {
24 const c = cursor(32);
25 c.u8(0x12);
26 c.u16(0x3456);
27 c.u32(0x789abcde);
28 expect(c.at).toBe(7);
29 expect(written(c)).toBe("12" + "5634" + "debc9a78");
30 c.i8(-2);
31 c.i16(-2);
32 expect(written(c)).toBe("12" + "5634" + "debc9a78" + "fe" + "feff");
33 expect(c.at).toBe(10);
34 });
35
36 test("a value past the width masks or clamps, exactly as it does today", () => {
37 const c = cursor(16);
38 // Unsigned: masked. `u16(-1)` is every bit set, which is what a wire gets.
39 c.u16(-1);
40 expect(written(c)).toBe("ffff");
41 c.at = 0;
42 c.u8(0x1ff);
43 expect(c.bytes[0]).toBe(0xff);
44 c.at = 0;
45 c.u32(-1);
46 expect(written(c)).toBe("ffffffff");
47 // Signed: truncated to a whole number, then clamped into the range.
48 c.at = 0;
49 c.i8(999);
50 expect(c.bytes[0]).toBe(127);
51 c.at = 0;
52 c.i8(-999);
53 expect(c.bytes[0]).toBe(0x80); // -128
54 c.at = 0;
55 c.i8(1.9);
56 expect(c.bytes[0]).toBe(1);
57 c.at = 0;
58 c.i16(40000);
59 expect(written(c)).toBe("ff7f"); // 32767
60 c.at = 0;
61 c.i16(-40000);
62 expect(written(c)).toBe("0080"); // -32768
63 // None of them throws: a throw halfway through a frame leaves half a frame.
64 expect(() => { const d = cursor(16); d.u8(NaN); d.i8(NaN); d.u16(NaN); d.i16(NaN); d.u32(NaN); }).not.toThrow();
65 });
66
67 test("f32 writes zero for anything that is not finite, so a NaN cannot travel", () => {
68 const c = cursor(16);
69 c.f32(NaN);
70 c.f32(Infinity);
71 c.f32(-Infinity);
72 expect(written(c)).toBe("00000000".repeat(3));
73 c.at = 0;
74 c.f32(1.5);
75 expect(written(c)).toBe("0000c03f");
76 c.at = 0;
77 expect(c.rf32()).toBe(1.5);
78 });
79
80 test("every reader reads back what its writer wrote", () => {
81 const c = cursor(32);
82 c.u8(200); c.i8(-100); c.u16(65535); c.i16(-32768); c.u32(4294967295); c.f32(-0.25);
83 const end = c.at;
84 c.at = 0;
85 expect(c.ru8()).toBe(200);
86 expect(c.ri8()).toBe(-100);
87 expect(c.ru16()).toBe(65535);
88 expect(c.ri16()).toBe(-32768);
89 expect(c.ru32()).toBe(4294967295);
90 expect(c.rf32()).toBe(-0.25);
91 expect(c.at).toBe(end);
92 });
93
94 test("a cursor at the end of its buffer refuses `has`, which is the check before every read", () => {
95 const c = cursor(4);
96 expect(c.has(4)).toBe(true);
97 expect(c.has(5)).toBe(false);
98 c.u32(1);
99 expect(c.at).toBe(4);
100 expect(c.has(0)).toBe(true);
101 expect(c.has(1)).toBe(false);
102 // A cursor over a subarray is bounded by the subarray and not by the buffer
103 // behind it: `has` reads the length it was handed.
104 const window = new Cursor(new Uint8Array(16).subarray(2, 5));
105 expect(window.has(3)).toBe(true);
106 expect(window.has(4)).toBe(false);
107 });
108
109 /**
110 * **Reading past the end throws**, stated in words because it is the one thing
111 * in this module that is not a returned value: a `DataView` raises a
112 * `RangeError` and this does nothing to soften it. A reader asks `has(n)`
113 * first — `readInput` and `applySnapshot` in `src/rooms/wire.ts` do at every
114 * length they take off a wire — and a reader that does not gets a throw rather
115 * than a quiet zero, which is the failure that reaches a player.
116 */
117 test("a read past the end throws rather than answering quietly", () => {
118 const c = cursor(2);
119 c.at = 2;
120 expect(() => c.ru8()).toThrow(RangeError);
121 c.at = 1;
122 expect(() => c.ru16()).toThrow(RangeError);
123 c.at = 0;
124 expect(() => c.rf32()).toThrow(RangeError);
125 // And so does a write, for the same reason.
126 c.at = 2;
127 expect(() => c.u8(1)).toThrow(RangeError);
128 });
129
130 /** How far apart two bearings are, the short way round: 359.9999° is near 0°. */
131 const apart = (a: number, b: number): number => Math.abs(((a - b + 540) % 360) - 180);
132
133 test("an angle is sixteen bits of a turn and comes back inside 0.0056°", () => {
134 // The step this encoding promises: 360 / 65536 degrees.
135 const step = 360 / 65536;
136 expect(step).toBeLessThan(0.0056);
137 for (const degrees of [0, 0.0001, 1, 45, 89.9, 90, 179.999, 180, 270, 359.9999]) {
138 const back = unpackAngle(packAngle(degrees));
139 expect(apart(back, degrees)).toBeLessThanOrEqual(step / 2 + 1e-9);
140 }
141 expect(packAngle(0)).toBe(0);
142 expect(packAngle(90)).toBe(16384);
143 expect(packAngle(180)).toBe(32768);
144 expect(packAngle(270)).toBe(49152);
145 // A turn either way is the same bearing: 360 wraps to 0 rather than to 65536.
146 expect(packAngle(360)).toBe(0);
147 expect(packAngle(-90)).toBe(49152);
148 expect(packAngle(720 + 45)).toBe(packAngle(45));
149 expect(packAngle(-720 - 45)).toBe(packAngle(-45));
150 // Sixteen bits, whatever arrives: nothing here can write a seventeenth.
151 for (const wild of [1e9, -1e9, 359.99999999, 0.0000001])
152 expect(packAngle(wild)).toBeGreaterThanOrEqual(0);
153 for (const wild of [1e9, -1e9, 359.99999999, 0.0000001])
154 expect(packAngle(wild)).toBeLessThanOrEqual(0xffff);
155 });
156
157 test("unpackSigned comes back into −180…180 in all four quadrants", () => {
158 for (const degrees of [-179.5, -135, -90, -45, -0.01, 0, 0.01, 45, 90, 135, 179.5]) {
159 const back = unpackSigned(packAngle(degrees));
160 expect(back).toBeGreaterThanOrEqual(-180);
161 expect(back).toBeLessThanOrEqual(180);
162 expect(apart(back, degrees)).toBeLessThan(0.0056);
163 }
164 // The edge, named rather than left to a reader: the half this comes back into
165 // is −180 *exclusive* to 180 inclusive, because the fold is `a > 180`. So
166 // straight behind is 180 and the first step past it is the negative half.
167 expect(unpackSigned(packAngle(180))).toBe(180);
168 expect(unpackSigned(packAngle(180) + 1)).toBeLessThan(-179.99);
169 expect(unpackSigned(packAngle(179.99))).toBeGreaterThan(0);
170 expect(unpackSigned(packAngle(-90))).toBeCloseTo(-90, 6);
171 expect(unpackSigned(0)).toBe(0);
172 expect(unpackSigned(0xffff)).toBeCloseTo(-360 / 65536, 9);
173 });
174
175 test("finite is a number or the fallback, and nothing else", () => {
176 expect(finite(1.5)).toBe(1.5);
177 expect(finite(0)).toBe(0);
178 expect(finite(-0)).toBe(-0);
179 expect(finite(NaN)).toBe(0);
180 expect(finite(Infinity)).toBe(0);
181 expect(finite(-Infinity)).toBe(0);
182 expect(finite(NaN, -1)).toBe(-1);
183 expect(finite(Infinity, 42)).toBe(42);
184 expect(finite(7, 42)).toBe(7);
185 });
186
187 test("the frame knows no game: not a weapon, not a seat, not a tick", async () => {
188 const source = await Bun.file(new URL("./frame.ts", import.meta.url)).text();
189 const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
190 for (const word of ["weapon", "seat", "tick", "player", "snapshot", "team"])
191 expect(code.toLowerCase()).not.toContain(word);
192 });
src/engine/frame.ts added +93
@@ -0,0 +1,93 @@
1 /**
2 * The bytes a frame is made of: a bounds-checked cursor over a buffer, and the
3 * quantiser that puts an angle in two of those bytes. Every game that talks
4 * over a socket needs this and nothing about it is that game's — the shooter
5 * hand-rolled it in `src/rooms/wire.ts` for its input frame and its snapshot,
6 * and what was generic in there is the whole of this file.
7 *
8 * It knows no weapon, no seat and no tick. What a field *means* is the game's
9 * business; what a field *is* — a byte, a short, a float, an angle — is this.
10 *
11 * **Writing and reading are the same object walked in the same order.** A frame
12 * is written field by field and read field by field, and the only thing that
13 * keeps the two halves in step is that they are read side by side, so both use
14 * one cursor rather than a writer and a reader that can drift apart.
15 *
16 * **What a value out of range does, on purpose, because a wire has no room to
17 * complain.** `u8`, `u16` and `u32` mask — `u16(-1)` writes `0xffff` — and `i8`
18 * and `i16` clamp and truncate, so `i8(999)` writes `127`. `f32` writes zero
19 * for anything that is not finite, so a NaN that crept into a position does not
20 * become a NaN on somebody else's screen. None of these throw: a frame is
21 * written sixteen times a second and a throw in the middle of one leaves half a
22 * frame on a socket.
23 *
24 * **Reading past the end throws**, which is what a `DataView` does and what
25 * this keeps: `has(n)` is the question asked *before* the read, and a reader
26 * that asks it — as `readInput` and `applySnapshot` do at every length they
27 * take off the wire — never reaches the end. A reader that does not ask gets a
28 * `RangeError` rather than a quiet zero, and a quiet zero is the shape of bug
29 * that reaches a player.
30 *
31 * `Cursor` is a class and the rest of the engine is functions. It is a class
32 * because it is one mutable position walked by two dozen calls in a row: a
33 * factory closing over `at` would allocate a dozen closures per frame in the
34 * hot path this exists for, and a plain-data cursor would make every call read
35 * `c.at = u8(view, c.at, v)`. A class here is the small shape, not the big one.
36 */
37
38 import { clamp } from "./math.ts";
39
40 /**
41 * A position in a buffer, and the reads and writes that move it. `at` is the
42 * next byte; `bytes` and `view` are the ones handed in, never a copy — a cursor
43 * over a subarray writes into the buffer behind it.
44 *
45 * Everything is little-endian, which is the byte order of every machine a
46 * browser runs on today, so neither end pays to swap.
47 */
48 export class Cursor {
49 view: DataView;
50 at = 0;
51 constructor(public bytes: Uint8Array) {
52 this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
53 }
54 u8(v: number): void { this.view.setUint8(this.at++, v & 0xff); }
55 i8(v: number): void { this.view.setInt8(this.at++, clamp(v | 0, -128, 127)); }
56 u16(v: number): void { this.view.setUint16(this.at, v & 0xffff, true); this.at += 2; }
57 i16(v: number): void { this.view.setInt16(this.at, clamp(v | 0, -32768, 32767), true); this.at += 2; }
58 u32(v: number): void { this.view.setUint32(this.at, v >>> 0, true); this.at += 4; }
59 f32(v: number): void { this.view.setFloat32(this.at, Number.isFinite(v) ? v : 0, true); this.at += 4; }
60 ru8(): number { return this.view.getUint8(this.at++); }
61 ri8(): number { return this.view.getInt8(this.at++); }
62 ru16(): number { const v = this.view.getUint16(this.at, true); this.at += 2; return v; }
63 ri16(): number { const v = this.view.getInt16(this.at, true); this.at += 2; return v; }
64 ru32(): number { const v = this.view.getUint32(this.at, true); this.at += 4; return v; }
65 rf32(): number { const v = this.view.getFloat32(this.at, true); this.at += 4; return v; }
66 /** Room for `n` more bytes. A frame that claims more than it carries is refused. */
67 has(n: number): boolean { return this.at + n <= this.bytes.byteLength; }
68 }
69
70 /**
71 * An angle as 16 bits of a turn: 0.0055° a step, which no screen can show. Any
72 * degrees in, the same sixteen bits out — a turn either way lands on the same
73 * number, so a yaw that has wound up over a round encodes as where it points.
74 */
75 export const packAngle = (degrees: number): number =>
76 Math.round(((degrees % 360) + 360) % 360 / 360 * 65536) & 0xffff;
77
78 /** Those sixteen bits back, in 0…360. */
79 export const unpackAngle = (bits: number): number => (bits / 65536) * 360;
80
81 /** The same, read back into the −180…180 half a view angle is usually in. */
82 export const unpackSigned = (bits: number): number => {
83 const a = unpackAngle(bits);
84 return a > 180 ? a - 360 : a;
85 };
86
87 /**
88 * A number off a wire, or the fallback: NaN and Infinity are not numbers here.
89 * The mirror of what `f32` does on the way out, for the way back in — a float
90 * a proxy or a bug corrupted becomes zero rather than poisoning the position it
91 * is read into.
92 */
93 export const finite = (v: number, fallback = 0): number => (Number.isFinite(v) ? v : fallback);
src/engine/guide.test.ts +3 −3
@@ -61,9 +61,9 @@ const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
6161 * this file ignores without needing to be told about it.
6262 */
6363 const NOT_THE_ENGINES = new Set([
64 "AudioContext", "DynamicsCompressorNode", "Float32Array", "Float64Array", "GainNode",
65 "Int32Array", "StereoPannerNode",
66 "TEXTURE_2D_ARRAY",
64 "AudioContext", "DataView", "DynamicsCompressorNode", "Float32Array", "Float64Array", "GainNode",
65 "Int32Array", "RangeError", "StereoPannerNode",
66 "TEXTURE_2D_ARRAY", "Uint8Array",
6767 "addEventListener", "auxclick",
6868 "cancelAnimationFrame", "createDynamicsCompressor", "createStereoPanner", "de_forge",
6969 "mousedown",
src/engine/index.ts +9
@@ -98,6 +98,15 @@ export type { Camera, Instance, Scene, SceneCapacity, Shadow } from "./scene.ts"
9898 export { createEffects, effectAt, expireEffects, pushEffect, resetEffects } from "./effects.ts";
9999 export type { Effects } from "./effects.ts";
100100
101 /**
102 * The bytes a frame is made of: a bounds-checked `Cursor` over a buffer, the
103 * angle quantised into two of those bytes, and the two numbers that keep a
104 * wire honest — `finite` on the way in, and what `f32` does to a NaN on the way
105 * out. Nothing in here knows a weapon, a seat or a tick: what a field means is
106 * the game's, what a field is is this.
107 */
108 export { Cursor, finite, packAngle, unpackAngle, unpackSigned } from "./frame.ts";
109
101110 /** The WebGL2 draw: an atlas and named meshes in, a frame of instances out. */
102111 export { createRenderer } from "./renderer.ts";
103112 export type { Renderer, RendererAssets } from "./renderer.ts";
src/engine/surface.json +6 −1
@@ -1,5 +1,5 @@
11 {
2 "version": "0.6.0",
2 "version": "0.7.0",
33 "names": [
44 "Activations",
55 "Adam",
@@ -14,6 +14,7 @@
1414 "Complaint",
1515 "Controls",
1616 "Ctor",
17 "Cursor",
1718 "DEFAULT_HYPER",
1819 "ESCAPE",
1920 "Effects",
@@ -124,6 +125,7 @@
124125 "fbm",
125126 "findPath",
126127 "finish",
128 "finite",
127129 "fold",
128130 "forward",
129131 "greedy",
@@ -148,6 +150,7 @@
148150 "normalize",
149151 "overlay",
150152 "pack",
153 "packAngle",
151154 "paintAtlas",
152155 "painter",
153156 "panOf",
@@ -177,6 +180,8 @@
177180 "tensorsOf",
178181 "traceWorld",
179182 "unpack",
183 "unpackAngle",
184 "unpackSigned",
180185 "update",
181186 "v3",
182187 "viewMatrix",
src/rooms/wire.test.ts +122 −3
@@ -10,16 +10,22 @@
1010 * 544 bytes, 8.7 kB/s a member at 16 Hz — is the whole argument for the rate in
1111 * `src/rooms/room.ts` and should be read in a diff rather than discovered on
1212 * somebody's phone.
13 *
14 * The last section is the layout itself, pinned: one input frame and one whole
15 * snapshot of a room whose every number is a literal, written out in hex. A
16 * round trip cannot catch a symmetric mistake and a page open on a previous
17 * deploy is talking to these exact bytes, so they are read in a diff.
1318 */
1419
1520 import { expect, test } from "bun:test";
1621 import {
17 applySnapshot, readInput, writeInput, writeSnapshot, BUY_ITEMS, INPUT_BYTES, SNAPSHOT_MAX,
18 SWITCH_PRIMARY, readDown, forget,
22 applySnapshot, readInput, writeInput, writeSnapshot, BUY_ITEMS, INPUT_BYTES, PLAYER_BYTES,
23 SNAPSHOT_MAX, SWITCH_PRIMARY, readDown, forget,
1924 } from "./wire.ts";
2025 import { busy, room, watcher } from "./stage.test-helper.ts";
26 import type { Room } from "./room.ts";
2127 import { NO_INPUT, type Input, type Player } from "../client/world.ts";
22 import { pushEffect } from "../engine/index.ts";
28 import { pushEffect, resetEffects } from "../engine/index.ts";
2329
2430 // --- the input frame ------------------------------------------------------
2531
@@ -187,3 +193,116 @@ test("a control message off the wire is validated field by field", () => {
187193 expect(readDown(JSON.stringify({ k: "log", feed: [], say: [{ at: 1, text: "hi" }] }))?.k).toBe("log");
188194 expect(readDown(JSON.stringify({ k: "nonsense" }))).toBe(null);
189195 });
196
197 // --- the bytes, pinned ----------------------------------------------------
198
199 /** Bytes as the hex a person can read in a diff: two characters a byte. */
200 const hex = (bytes: Uint8Array): string =>
201 [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
202
203 /**
204 * A room whose every number is written down here rather than played: the ten
205 * bodies, the bomb, a grenade, a cloud and the two things that just happened
206 * are all literals, so what the hex below is about is the *encoding* and never
207 * how a bot moved. Fixed seed, fixed tick, fixed clock.
208 */
209 function pinnedRoom(): Room {
210 const r = room();
211 const w = r.world;
212 w.time = 12.5;
213 w.match.phase = "planted";
214 w.match.round = 7;
215 w.match.score.t = 3;
216 w.match.score.ct = 4;
217 w.match.timer = 24.25;
218 w.match.matchWinner = null;
219 w.bomb.state = "planted";
220 w.bomb.pos = [512, -256, 4];
221 w.bomb.plantProgress = 1;
222 w.bomb.defuseProgress = 0.375;
223 w.bomb.site = "A";
224 w.players.forEach((p, i) => {
225 p.pos = [100 * i, -50 * i, 64];
226 p.vel = [i, 2 * i, -i];
227 p.yaw = i * 36 - 180;
228 p.pitch = i * 7 - 30;
229 p.health = 100 - 7 * i;
230 p.armor = 10 * i;
231 p.alive = i !== 4;
232 p.onGround = i % 2 === 0;
233 p.ducking = i % 3 === 0;
234 p.duckAmount = (i % 4) / 4;
235 p.hasBomb = i === 1;
236 p.helmet = i % 2 === 1;
237 p.defuseKit = i > 5;
238 p.scoped = i === 9;
239 p.weapon = "ak47";
240 p.primary = i === 0 ? null : "ak47";
241 p.secondary = "glock";
242 p.ammo.ak47 = { mag: 30 - i, reserve: 90 };
243 p.money = 800 * i;
244 p.blind = i / 8;
245 p.pocket = { he: i % 2, flash: (i + 1) % 2, smoke: i % 3 === 0 ? 1 : 0 };
246 p.kills = i;
247 p.deaths = 9 - i;
248 p.damage = 100 * i;
249 });
250 w.grenades.length = 0;
251 w.grenades.push({
252 pos: [64, 128, 96], vel: [10, 20, 30], onGround: false, ducking: false, duckAmount: 0,
253 weapon: "he", owner: 2, team: "t", fuse: 1.25,
254 });
255 w.clouds.length = 0;
256 w.clouds.push({ pos: [500, 500, 40], born: 11.5, owner: 3 });
257 resetEffects(w.effects);
258 pushEffect(w.effects, { kind: "shot", at: 12.25, pos: [1, 2, 3], weapon: "ak47", mine: false, by: 3 }, 12.25);
259 pushEffect(w.effects, { kind: "sound", at: 12.375, pos: [4, 5, 6], name: "step", by: 7 }, 12.375);
260 return r;
261 }
262
263 /**
264 * One whole snapshot of the room below, as it came off this module before the
265 * move: 582 bytes of it, wrapped so a diff reads.
266 */
267 const SNAPSHOT_HEX = [
268 "020700000000004841020703040000c241000200000044000080c3000080400000803f0000c03e010a00000000000000",
269 "80000080420000000000000000000000800080abea64000d0000ff041e005a0000000014000900000000c842000048c2",
270 "000080420000803f00000040000080bf9a99a5ef5d0a33400000041d005a002003040101086400000048430000c8c200",
271 "0080420000004000008040000000c033b39ff4561407800000041c005a00400608040207c80000009643000016c30000",
272 "8042000040400000c040000040c0cdcc9af94f1e2bbf0000041b005a0060090c1103062c010000c843000048c3000080",
273 "420000804000000041000080c066e694fe482806000000041a005a00800c1004040590010000fa4300007ac300008042",
274 "0000a040000020410000a0c000008e034132234000000419005a00a00f14010504f40100001644000096c30000804200",
275 "00c040000040410000c0c09a1989083a3c4d8000000418005a00c01218140603580200002f440000afc3000080420000",
276 "e040000060410000e0c03333830d334661bf00000417005a00e0151c010702bc02000048440000c8c300008042000000",
277 "4100008041000000c1cd4c7d122c50450000000416005a000019200408012003000061440000e1c30000804200001041",
278 "00009041000010c166667717255ae94000000415005a00201c2411090084030100008042000000430000c04201020128",
279 "010000fa430000fa430000204210030203000044410000803f000000400000404000030500004641000080400000a040",
280 "0000c0400807",
281 ].join("");
282
283 /**
284 * **The wire does not move a byte.** These two literals were recorded off this
285 * module before #325 moved the cursor and the angle quantiser behind the
286 * engine's door, and they are what comes out after: the same frame, byte for
287 * byte, from code that now lives somewhere else. A test that says "it round
288 * trips" cannot say that — a symmetric mistake round trips perfectly — and a
289 * page open on a previous deploy is talking to this layout.
290 *
291 * If either literal has to change, the wire changed and every client that is
292 * already running is the reason to say so in the pull rather than re-record it.
293 */
294 test("the wire does not move a byte: one input frame and one whole snapshot, in hex", () => {
295 const input = writeInput({
296 tick: 4242, input: { ...NO_INPUT, forward: 1, side: -1, jump: true, fire: true, throwing: "smoke" },
297 yaw: -91.25, pitch: 37.5, switchCode: SWITCH_PRIMARY, buy: "ak47",
298 });
299 expect(input.byteLength).toBe(INPUT_BYTES);
300 expect(hex(input)).toBe("0192100000097f811cbfab1a0f01");
301
302 const r = pinnedRoom();
303 const bytes = writeSnapshot(r.world, 7, 0, new Uint8Array(SNAPSHOT_MAX));
304 expect(hex(bytes)).toBe(SNAPSHOT_HEX);
305 // And the shapes the rest of this module is sized against are those shapes.
306 expect(INPUT_BYTES).toBe(14);
307 expect(PLAYER_BYTES).toBe(44);
308 });
src/rooms/wire.ts +10 −43
@@ -27,6 +27,13 @@
2727 * Angles are 16-bit — 0.0055° a step, far under what a screen can show — and
2828 * everything else is a byte or two of its own natural range.
2929 *
30 * The byte plumbing under all of that is the engine's and not this file's:
31 * `Cursor`, `packAngle`, `unpackSigned`, `finite` and `clamp` come through
32 * `src/engine/index.ts`, because every game that ever talks over a socket needs
33 * the same ones and none of them knows a weapon, a seat or a tick. What is
34 * left in here is the layout — which field, in which order, in how many bytes —
35 * which is exactly the part that is this game's.
36 *
3037 * Nothing here knows about a socket, a request or a page: bytes in, plain data
3138 * out, so every claim in `wire.test.ts` is made without either end running.
3239 */
@@ -35,7 +42,9 @@ import type { Bomb, BombState } from "../client/bomb.ts";
3542 import type { Cloud, Grenade } from "../client/grenade.ts";
3643 import type { Phase } from "../client/match.ts";
3744 import { emptyAmmo, type BuyItem } from "../client/economy.ts";
38 import { pushEffect, resetEffects, type Vec3 } from "../engine/index.ts";
45 import {
46 Cursor, clamp, finite, packAngle, pushEffect, resetEffects, unpackSigned, type Vec3,
47 } from "../engine/index.ts";
3948 import { WEAPONS, type ThrownId, type WeaponId } from "../client/weapons.ts";
4049 import { NO_INPUT, type Effect, type Input, type Player, type Sound, type World } from "../client/world.ts";
4150
@@ -65,48 +74,6 @@ export const MAX_GRENADES = 32;
6574 export const MAX_CLOUDS = 16;
6675 export const MAX_EVENTS = 64;
6776
68 // --- reading and writing bytes --------------------------------------------
69
70 /**
71 * A cursor over bytes. Both directions use one, because a frame is written and
72 * read field by field in one order and the two halves of that have to be read
73 * side by side to stay the same order.
74 */
75 class Cursor {
76 view: DataView;
77 at = 0;
78 constructor(public bytes: Uint8Array) {
79 this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
80 }
81 u8(v: number): void { this.view.setUint8(this.at++, v & 0xff); }
82 i8(v: number): void { this.view.setInt8(this.at++, Math.max(-128, Math.min(127, v | 0))); }
83 u16(v: number): void { this.view.setUint16(this.at, v & 0xffff, true); this.at += 2; }
84 i16(v: number): void { this.view.setInt16(this.at, Math.max(-32768, Math.min(32767, v | 0)), true); this.at += 2; }
85 u32(v: number): void { this.view.setUint32(this.at, v >>> 0, true); this.at += 4; }
86 f32(v: number): void { this.view.setFloat32(this.at, Number.isFinite(v) ? v : 0, true); this.at += 4; }
87 ru8(): number { return this.view.getUint8(this.at++); }
88 ri8(): number { return this.view.getInt8(this.at++); }
89 ru16(): number { const v = this.view.getUint16(this.at, true); this.at += 2; return v; }
90 ri16(): number { const v = this.view.getInt16(this.at, true); this.at += 2; return v; }
91 ru32(): number { const v = this.view.getUint32(this.at, true); this.at += 4; return v; }
92 rf32(): number { const v = this.view.getFloat32(this.at, true); this.at += 4; return v; }
93 /** Room for `n` more bytes. A frame that claims more than it carries is refused. */
94 has(n: number): boolean { return this.at + n <= this.bytes.byteLength; }
95 }
96
97 /** An angle as 16 bits of a turn: 0.0055° a step, which no screen can show. */
98 const packAngle = (degrees: number): number => Math.round(((degrees % 360) + 360) % 360 / 360 * 65536) & 0xffff;
99 const unpackAngle = (bits: number): number => (bits / 65536) * 360;
100 /** The same, read back into the −180…180 half the view uses. */
101 const unpackSigned = (bits: number): number => {
102 const a = unpackAngle(bits);
103 return a > 180 ? a - 360 : a;
104 };
105
106 const clamp = (v: number, lo: number, hi: number): number => (v < lo ? lo : v > hi ? hi : v);
107 /** A number off a wire, or the fallback: NaN and Infinity are not numbers here. */
108 const finite = (v: number, fallback = 0): number => (Number.isFinite(v) ? v : fallback);
109
11077 // --- up: one member's input ----------------------------------------------
11178
11279 /**

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs