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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/325-the-bytes-a-frame-is-made-of-are-the-eng → main 3 commits fork
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; | ||
| 150 | 150 | |
| 151 | 151 | ## What is behind the door |
| 152 | 152 | |
| 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. | |
| 154 | 154 | |
| 155 | 155 | Every section below opens with what that group costs a bundle, because "what |
| 156 | 156 | 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, | ||
| 686 | 686 | `Effects.count` live events, and `Effects.head`, the slot the oldest of them is |
| 687 | 687 | in. A game reads the first two of those and leaves the ring arithmetic here. |
| 688 | 688 | |
| 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 | ||
| 689 | 739 | ### renderer |
| 690 | 740 | |
| 691 | 741 | **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_$]*$/; | ||
| 61 | 61 | * this file ignores without needing to be told about it. |
| 62 | 62 | */ |
| 63 | 63 | 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", | |
| 67 | 67 | "addEventListener", "auxclick", |
| 68 | 68 | "cancelAnimationFrame", "createDynamicsCompressor", "createStereoPanner", "de_forge", |
| 69 | 69 | "mousedown", |
src/engine/index.ts +9
| @@ -98,6 +98,15 @@ export type { Camera, Instance, Scene, SceneCapacity, Shadow } from "./scene.ts" | ||
| 98 | 98 | export { createEffects, effectAt, expireEffects, pushEffect, resetEffects } from "./effects.ts"; |
| 99 | 99 | export type { Effects } from "./effects.ts"; |
| 100 | 100 | |
| 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 | ||
| 101 | 110 | /** The WebGL2 draw: an atlas and named meshes in, a frame of instances out. */ |
| 102 | 111 | export { createRenderer } from "./renderer.ts"; |
| 103 | 112 | export type { Renderer, RendererAssets } from "./renderer.ts"; |
src/engine/surface.json +6 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | "version": "0.6.0", | |
| 2 | "version": "0.7.0", | |
| 3 | 3 | "names": [ |
| 4 | 4 | "Activations", |
| 5 | 5 | "Adam", |
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | "Complaint", |
| 15 | 15 | "Controls", |
| 16 | 16 | "Ctor", |
| 17 | "Cursor", | |
| 17 | 18 | "DEFAULT_HYPER", |
| 18 | 19 | "ESCAPE", |
| 19 | 20 | "Effects", |
| @@ -124,6 +125,7 @@ | ||
| 124 | 125 | "fbm", |
| 125 | 126 | "findPath", |
| 126 | 127 | "finish", |
| 128 | "finite", | |
| 127 | 129 | "fold", |
| 128 | 130 | "forward", |
| 129 | 131 | "greedy", |
| @@ -148,6 +150,7 @@ | ||
| 148 | 150 | "normalize", |
| 149 | 151 | "overlay", |
| 150 | 152 | "pack", |
| 153 | "packAngle", | |
| 151 | 154 | "paintAtlas", |
| 152 | 155 | "painter", |
| 153 | 156 | "panOf", |
| @@ -177,6 +180,8 @@ | ||
| 177 | 180 | "tensorsOf", |
| 178 | 181 | "traceWorld", |
| 179 | 182 | "unpack", |
| 183 | "unpackAngle", | |
| 184 | "unpackSigned", | |
| 180 | 185 | "update", |
| 181 | 186 | "v3", |
| 182 | 187 | "viewMatrix", |
src/rooms/wire.test.ts +122 −3
| @@ -10,16 +10,22 @@ | ||
| 10 | 10 | * 544 bytes, 8.7 kB/s a member at 16 Hz — is the whole argument for the rate in |
| 11 | 11 | * `src/rooms/room.ts` and should be read in a diff rather than discovered on |
| 12 | 12 | * 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. | |
| 13 | 18 | */ |
| 14 | 19 | |
| 15 | 20 | import { expect, test } from "bun:test"; |
| 16 | 21 | 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, | |
| 19 | 24 | } from "./wire.ts"; |
| 20 | 25 | import { busy, room, watcher } from "./stage.test-helper.ts"; |
| 26 | import type { Room } from "./room.ts"; | |
| 21 | 27 | 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"; | |
| 23 | 29 | |
| 24 | 30 | // --- the input frame ------------------------------------------------------ |
| 25 | 31 | |
| @@ -187,3 +193,116 @@ test("a control message off the wire is validated field by field", () => { | ||
| 187 | 193 | expect(readDown(JSON.stringify({ k: "log", feed: [], say: [{ at: 1, text: "hi" }] }))?.k).toBe("log"); |
| 188 | 194 | expect(readDown(JSON.stringify({ k: "nonsense" }))).toBe(null); |
| 189 | 195 | }); |
| 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 @@ | ||
| 27 | 27 | * Angles are 16-bit — 0.0055° a step, far under what a screen can show — and |
| 28 | 28 | * everything else is a byte or two of its own natural range. |
| 29 | 29 | * |
| 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 | * | |
| 30 | 37 | * Nothing here knows about a socket, a request or a page: bytes in, plain data |
| 31 | 38 | * out, so every claim in `wire.test.ts` is made without either end running. |
| 32 | 39 | */ |
| @@ -35,7 +42,9 @@ import type { Bomb, BombState } from "../client/bomb.ts"; | ||
| 35 | 42 | import type { Cloud, Grenade } from "../client/grenade.ts"; |
| 36 | 43 | import type { Phase } from "../client/match.ts"; |
| 37 | 44 | 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"; | |
| 39 | 48 | import { WEAPONS, type ThrownId, type WeaponId } from "../client/weapons.ts"; |
| 40 | 49 | import { NO_INPUT, type Effect, type Input, type Player, type Sound, type World } from "../client/world.ts"; |
| 41 | 50 | |
| @@ -65,48 +74,6 @@ export const MAX_GRENADES = 32; | ||
| 65 | 74 | export const MAX_CLOUDS = 16; |
| 66 | 75 | export const MAX_EVENTS = 64; |
| 67 | 76 | |
| 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 | ||
| 110 | 77 | // --- up: one member's input ---------------------------------------------- |
| 111 | 78 | |
| 112 | 79 | /** |
new above
offline