返回 CodeWhale
pet-sim.ts
根目录 / pet / src / core / pet-sim.ts
1 // PetSim — the portable core of the Codewhale pet.
2 //
3 // This is a faithful, DOM-free port of the consort study's particle engine
4 // (grammar.js). The same code — same constants, same order of operations — is
5 // what the Rust, Swift and Kotlin cores implement. Four rules keep the view
6 // identical on every surface:
7 //
8 // 1. The body is the same 980 points, loaded from whale-points.tsv — never
9 // re-sampled from the image on each platform.
10 // 2. Per-particle jitter phases come from mulberry32(0xC0FFEE), not from the
11 // platform's RNG.
12 // 3. All motion is a pure function of (sim clock, state): nothing accumulates
13 // noise. Two runs fed the same tape produce the same frame.
14 // 4. Colour, hollowness and brightness are computed here, once — renderers
15 // only place and stamp dots.
16 //
17 // Positions stay normalized in body space (roughly [-0.5, 0.5]²). A renderer
18 // maps them through layout() to its own medium.
19
20 export const PET_MAX_SECONDS = 100 * 365 * 86_400;
21
22 export interface PetState {
23 activity: number; // how much work 0..1
24 coherence: number; // converging school vs thrashing 0..1
25 attention: number; // interaction salience 0..1
26 channel: string; // Whalesong semantic category
27 observed: number; // instrumentation coverage 0..1
28 roamX: number; // tank position, -1..1
29 roamY: number;
30 flip: number; // 1 faces right, -1 left, passing 0 = turning edge-on
31 lit: number; // sleep dimmer 0..1
32 }
33
34 export interface PetOpts { motion: boolean; sensitivity: number; podSlots?: readonly (readonly [number, number])[] }
35
36 export const REST_STATE: PetState = {
37 activity: 0.35, coherence: 0.8, attention: 0, channel: 'reasoning',
38 observed: 1, roamX: 0, roamY: 0, flip: 1, lit: 1,
39 };
40
41 const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
42 const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v));
43
44 // ---------------------------------------------------------------------------
45 // Whalesong's vocabulary, carried over unchanged: colour, register, and the
46 // sustained-vs-onset rule live in the same table the instrument uses.
47 export interface Channel { key: string; label: string; color: string; freq: number; sustained: boolean; arch: string; form: string }
48
49 export const ARCH_OF: Record<string, string> = {
50 reasoning: 'gyre', memory: 'gyre',
51 tool: 'strike', code: 'strike', filesystem: 'strike',
52 network: 'cross', communication: 'cross', browser: 'cross',
53 agent: 'pod', orchestration: 'pod',
54 error: 'tear', human: 'address', other: 'drift',
55 };
56
57 export const CHANNELS: Channel[] = [
58 { key: 'reasoning', label: 'Model / reasoning', color: '#73c9b5', freq: 130.81, sustained: true, arch: 'gyre', form: 'gyre · rolling' },
59 { key: 'tool', label: 'Tool calls', color: '#74aadd', freq: 261.63, sustained: false, arch: 'strike', form: 'strike · reaching' },
60 { key: 'memory', label: 'Memory / RAG', color: '#b6a77f', freq: 195.99, sustained: true, arch: 'gyre', form: 'gyre · scanning' },
61 { key: 'code', label: 'Code execution', color: '#9b9ed7', freq: 164.81, sustained: false, arch: 'strike', form: 'strike · along the body' },
62 { key: 'filesystem', label: 'Filesystem', color: '#92b9c9', freq: 440.00, sustained: false, arch: 'strike', form: 'strike · fanning' },
63 { key: 'network', label: 'Network / API', color: '#d3ac74', freq: 523.25, sustained: false, arch: 'cross', form: 'crossing · one way' },
64 { key: 'browser', label: 'Browser / computer', color: '#9ea9df', freq: 349.23, sustained: false, arch: 'cross', form: 'crossing · a sweep' },
65 { key: 'communication', label: 'Agent messages', color: '#83c5c9', freq: 293.66, sustained: false, arch: 'cross', form: 'crossing · two ways' },
66 { key: 'agent', label: 'Subagent activity', color: '#b09acb', freq: 220.00, sustained: true, arch: 'pod', form: 'pod · peers' },
67 { key: 'orchestration', label: 'Orchestration', color: '#6c8798', freq: 98.00, sustained: true, arch: 'pod', form: 'pod · hub' },
68 { key: 'error', label: 'Errors / exceptions',color: '#e79186', freq: 185.00, sustained: false, arch: 'tear', form: 'torn · irregular' },
69 { key: 'human', label: 'Human interaction', color: '#c2b787', freq: 391.99, sustained: false, arch: 'address', form: 'decision · junction' },
70 { key: 'other', label: 'Unclassified', color: '#738492', freq: 146.83, sustained: false, arch: 'drift', form: 'drifting · unformed' },
71 ];
72 export const CHANNEL_INDEX: Record<string, number> = Object.fromEntries(CHANNELS.map((c, i) => [c.key, i]));
73
74 export function validatePetState(value: unknown): asserts value is PetState {
75 const s = value as PetState;
76 if (!s || typeof s !== 'object' || !Object.hasOwn(CHANNEL_INDEX, s.channel)
77 || ![s.activity, s.coherence, s.attention, s.observed, s.lit].every(n => Number.isFinite(n) && n >= 0 && n <= 1)
78 || ![s.roamX, s.roamY, s.flip].every(n => Number.isFinite(n) && Math.abs(n) <= 1))
79 throw new Error('Invalid pet state.');
80 }
81
82 const hex2rgb = (h: string) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
83 const RGB = CHANNELS.map(c => hex2rgb(c.color));
84 const UNKNOWN_RGB = hex2rgb('#738492');
85 const REST_RGB = [122, 214, 240];
86
87 // mulberry32 — a 32-bit seeded PRNG tiny enough to port by hand correctly.
88 export function mulberry32(seed: number) {
89 let a = seed >>> 0;
90 return Object.assign(() => {
91 a = (a + 0x6D2B79F5) >>> 0;
92 let t = a;
93 t = Math.imul(t ^ (t >>> 15), t | 1);
94 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
95 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
96 }, { state: () => a, restore: (state: number) => {
97 if (!Number.isSafeInteger(state) || state < 0 || state > 0xffffffff) throw new Error('Invalid pet random stream.');
98 a = state;
99 } });
100 }
101
102 export interface Particle {
103 x: number; y: number; vx: number; vy: number;
104 s: number; jx: number; jy: number; pod: number;
105 hx: number; hy: number; ang: number; rad: number; tail: number;
106 tx: number; ty: number;
107 }
108
109 export interface Frame {
110 r: number; g: number; b: number; // particle colour, 0..255
111 alpha: number; // uniform per-dot alpha
112 hollow: boolean; // coverage gap: rings, not filled dots
113 channel: string; // active category
114 arch: string; // active archetype
115 work: number; // rest↔work blend actually applied
116 }
117
118 export interface PetSimCheckpoint {
119 version: 1;
120 expressionVersion?: 1 | 2;
121 body: number[][];
122 particles: number[][];
123 phase: number; clock: number; tear: number; previous: number; current: number;
124 color: number[]; frame: Frame;
125 }
126
127 /** Work reorganizes the same particles; no new random draws or invented facts.
128 * These are expressive fields, not diagrams of unobserved network/file topology. */
129 function fieldTarget(q: Particle, t: number, act: number, att: number, key: string): [number, number] | undefined {
130 const u = q.s * 2 - 1, lane = q.pod - 2.5, a = q.s * Math.PI * 2;
131 const flow = t * (.35 + act * .65);
132 if (key === 'reasoning') {
133 const ring = .34 + .105 * Math.cos(a * 3 + flow + lane * .18);
134 return [ring * Math.cos(a * 2 + flow * .3), ring * Math.sin(a * 2 + flow * .3) * .7 + .10 * Math.sin(a * 3 + flow)];
135 }
136 if (key === 'memory') return [.46 * Math.cos(a + lane * .1 + flow * .25), lane * .082 + .052 * Math.sin(a * 2 + flow)];
137 if (key === 'code') return [u * .57, lane * .066 + .12 * Math.sin(u * 7 + flow * 2 + q.pod * Math.PI / 3)];
138 if (key === 'filesystem') {
139 const branch = Math.max(0, (u + .3) / 1.3);
140 return [u * .56, lane * .13 * branch + .025 * Math.sin(u * 8 - flow)];
141 }
142 if (key === 'tool') {
143 const reach = .14 + (u + 1) * .20 + .04 * Math.sin(flow * 3 - u * 4);
144 return [Math.cos(q.pod * Math.PI / 3) * reach, Math.sin(q.pod * Math.PI / 3) * reach * .8 + q.hy * .06];
145 }
146 if (key === 'browser') return [u * .56, lane * .083 + .035 * Math.sin(u * 5 - flow * 2)];
147 if (key === 'network' || key === 'communication') {
148 const direction = key === 'communication' && q.pod % 2 === 1 ? -1 : 1;
149 const phase = a + flow * direction;
150 return [.54 * Math.cos(phase), Math.sin(phase) * (.12 + q.pod * .035) + lane * .024];
151 }
152 if (key === 'human') {
153 const gap = u < 0 ? -.075 : .075;
154 return [u * .47 + gap, lane * .10 * Math.abs(u) + .012 * Math.sin(flow + a) * (1 - att)];
155 }
156 return undefined;
157 }
158
159 // Version 1 retains the original authored gait for existing recordings.
160 function gaitTarget(q: Particle, t: number, act: number, coh: number, att: number, key: string, work: number, podSlots?: PetOpts['podSlots'], expressionVersion = 1): [number, number] {
161 const { hx, hy, ang, rad, tail, s, pod, jx, jy } = q;
162 const omega = lerp(4.6, 5.2 + act * 2.8, work);
163 const breath = 1 + Math.sin(t * 1.85) * lerp(0.048, 0.018, work);
164 const flex = Math.sin(ang * 2.05 + t * omega) * lerp(0.042, 0.016 + act * 0.028, work) * (0.18 + 0.82 * tail);
165 let px = Math.cos(ang + flex) * rad * breath;
166 let py = Math.sin(ang + flex) * rad * breath;
167 px += Math.sin(t * 0.33) * lerp(0.030, 0.014, work);
168 py += Math.cos(t * 0.21) * lerp(0.018, 0.010, work);
169 if (work < 0.02) return [px, py];
170
171 const arch = ARCH_OF[key] || 'drift';
172 let gx = px, gy = py;
173 if (arch === 'gyre') {
174 if (key === 'memory') {
175 const pulse = 1 + Math.sin(t * (2.4 + act * 1.6) - rad * 11) * (0.15 + act * 0.10);
176 gx *= pulse; gy *= pulse;
177 } else {
178 const roll = Math.sin(t * (1.05 + act * 0.35)) * (0.48 + act * 0.32);
179 const c = Math.cos(roll), sn = Math.sin(roll);
180 gx = px * c - py * sn * 0.88;
181 gy = px * sn * 0.88 + py * c;
182 }
183 } else if (arch === 'strike') {
184 if (key === 'tool') {
185 const rate = 2.7 + act * 2.1;
186 const lunge = Math.pow(Math.max(0, Math.sin(t * rate)), 2);
187 gx += lunge * 0.11;
188 if (s > 0.60) {
189 const reach = Math.pow(Math.max(0, Math.sin(t * rate + pod * 0.92)), 4) * (0.30 + act * 0.24);
190 gx += Math.cos(ang) * reach;
191 gy += Math.sin(ang) * reach;
192 }
193 } else if (key === 'code') {
194 const rate = 3.2 + act * 1.8;
195 const wave = Math.sin(t * rate - tail * 7.5);
196 const bump = 0.11 + act * 0.08;
197 gx += Math.cos(ang) * wave * bump;
198 gy += Math.sin(ang) * wave * bump * 1.2;
199 gx += Math.max(0, wave) * 0.07;
200 } else {
201 const rate = 2.15 + act * 1.5;
202 const side = (pod % 2) * 2 - 1;
203 const w = Math.pow(Math.max(0, Math.sin(t * rate + pod * 0.72)), 2);
204 gx += w * 0.055;
205 gy += side * w * (0.17 + act * 0.13);
206 }
207 } else if (arch === 'cross') {
208 if (key === 'browser') {
209 const band = ((t * (0.55 + act * 0.35)) % 1) * 1.28 - 0.64;
210 const inBand = Math.max(0, 1 - Math.abs(hy - band) / 0.08);
211 gx += inBand * (0.24 + act * 0.10);
212 gy += inBand * 0.02;
213 } else {
214 const two = key === 'communication';
215 const courier = s < (two ? 0.44 : 0.32);
216 if (courier) {
217 const dir = two ? (s < 0.22 ? 1 : -1) : 1;
218 const u = (t * (0.38 + act * 0.36) + s * 5.2) % 1;
219 const going = u < 0.5 ? u * 2 : 2 - u * 2;
220 const e = going * going * (3 - 2 * going);
221 gx = lerp(hx, dir * 0.80, e);
222 gy = hy * (1 - e * 0.38) + Math.sin(going * Math.PI) * 0.11 * dir;
223 }
224 }
225 } else if (arch === 'pod') {
226 const n = 6, member = podSlots?.length ? podSlots[pod % podSlots.length] : undefined;
227 const k = member ? member[0] : pod % n;
228 const hub = key === 'orchestration' && k === 0;
229 const spread = 0.30 + act * 0.11;
230 const orbit = t * (0.55 + act * 0.28);
231 if (hub) { gx = px * 0.70; gy = py * 0.70; }
232 else {
233 const slots = key === 'orchestration' ? n - 1 : n;
234 const a = (key === 'orchestration' ? k - 1 : k) * (Math.PI * 2 / slots) + orbit + (member ? member[1] * .04 : 0);
235 const sc = 0.34;
236 gx = hx * sc + Math.cos(a) * spread * 1.28;
237 gy = hy * sc + Math.sin(a) * spread * 0.80;
238 }
239 } else if (arch === 'tear') {
240 const side = hx + hy < 0 ? -1 : 1;
241 gx += side * (0.24 + (1 - coh) * 0.16);
242 gy += side * 0.15;
243 gx += Math.sin(t * 11.4 + s * 40) * (0.045 + act * 0.05);
244 gy += Math.cos(t * 9.2 + s * 31) * (0.040 + act * 0.045);
245 } else if (arch === 'address') {
246 const face = 0.90 + att * 0.08;
247 const th = 0.70;
248 const z = (s - 0.5) * 0.42;
249 let ax = hx * Math.cos(th) + z * Math.sin(th);
250 let ay = hy;
251 const disc = 0.48 * face;
252 ax = lerp(ax, Math.cos(ang) * Math.min(0.36, rad + 0.06) * 0.95, disc);
253 ay = lerp(ay, Math.sin(ang) * Math.min(0.36, rad + 0.06) * 1.08, disc);
254 const grow = 1.20 + Math.sin(t * 1.65) * 0.055;
255 gx = ax * grow; gy = ay * grow;
256 } else {
257 const mill = 0.13 + (1 - coh) * 0.10;
258 gx = hx * 0.52 + Math.sin(t * 0.72 + jx) * mill;
259 gy = hy * 0.52 + Math.cos(t * 0.54 + jy) * mill;
260 }
261 if (expressionVersion === 2) {
262 const field = fieldTarget(q, t, act, att, key);
263 if (field) [gx, gy] = field;
264 }
265 return [lerp(px, gx, work), lerp(py, gy, work)];
266 }
267
268 // Fixed reduced-motion clock per channel, so ticks still point along the gait.
269 const STILL_T: Record<string, number> = {
270 reasoning: 1.15, memory: 0.42, tool: 0.30, code: 0.18, filesystem: 0.48,
271 network: 0.72, browser: 0.95, communication: 0.58, agent: 1.25,
272 orchestration: 0.85, error: 0.35, human: 0.05, other: 0.90,
273 };
274
275 export class PetSim {
276 readonly p: Particle[];
277 private phase = 0;
278 private clock = 0;
279 private tear = 0;
280 private prev: number;
281 private col = [...REST_RGB];
282 private cur: number;
283 frame: Frame = { r: REST_RGB[0], g: REST_RGB[1], b: REST_RGB[2], alpha: 0.3, hollow: false, channel: 'reasoning', arch: 'gyre', work: 0 };
284
285 constructor(points: [number, number][], seed = 0xC0FFEE, readonly expressionVersion: 1 | 2 = 2) {
286 if (expressionVersion !== 1 && expressionVersion !== 2) throw new Error('Unsupported pet expression version.');
287 const rand = mulberry32(seed);
288 this.p = points.map(([hx, hy], i) => {
289 const q: Particle = {
290 x: hx, y: hy, vx: 0, vy: 0,
291 s: rand(), jx: rand() * 6.283, jy: rand() * 6.283, pod: i % 6,
292 hx, hy, ang: 0, rad: 0, tail: 0, tx: hx, ty: hy,
293 };
294 q.ang = Math.atan2(hy, hx);
295 q.rad = Math.hypot(hx, hy);
296 q.tail = clamp(((-hx - hy) * 0.5 + 0.22) / 0.62);
297 return q;
298 });
299 this.cur = this.prev = CHANNEL_INDEX['reasoning'];
300 }
301
302 checkpoint(): PetSimCheckpoint {
303 return { version: 1, expressionVersion: this.expressionVersion, body: this.p.map(p => [p.hx, p.hy, p.s]),
304 particles: this.p.map(p => [p.x, p.y, p.vx, p.vy, p.jx, p.jy, p.tx, p.ty]),
305 phase: this.phase, clock: this.clock, tear: this.tear, previous: this.prev, current: this.cur,
306 color: [...this.col], frame: { ...this.frame } };
307 }
308
309 /** Restore into a newly constructed sim. Authored body and seeded particle
310 * identity must match exactly; a checkpoint cannot replace the whale. */
311 restore(value: unknown): void {
312 const c = value as PetSimCheckpoint;
313 const inRange = (n: number, low: number, high: number) => Number.isFinite(n) && n >= low && n <= high;
314 if (!c || c.version !== 1 || c.expressionVersion !== undefined && ![1, 2].includes(c.expressionVersion) || (c.expressionVersion ?? 1) !== this.expressionVersion || !Array.isArray(c.body) || c.body.length !== this.p.length
315 || c.body.some((v, i) => !Array.isArray(v) || v.length !== 3 || v[0] !== this.p[i].hx || v[1] !== this.p[i].hy || v[2] !== this.p[i].s)
316 || !Array.isArray(c.particles) || c.particles.length !== this.p.length
317 || c.particles.some(v => !Array.isArray(v) || v.length !== 8 || v.some((n, i) => !inRange(n, i === 4 || i === 5 ? 0 : -8, i === 4 || i === 5 ? 2 * PET_MAX_SECONDS : 8)))
318 || !inRange(c.phase, 0, PET_MAX_SECONDS) || !inRange(c.clock, 0, PET_MAX_SECONDS) || !inRange(c.tear, 0, 1)
319 || ![c.previous, c.current].every(n => Number.isInteger(n) && n >= 0 && n < CHANNELS.length)
320 || !Array.isArray(c.color) || c.color.length !== 3 || c.color.some(n => !inRange(n, 0, 255))
321 || !c.frame || ![c.frame.r, c.frame.g, c.frame.b].every(n => inRange(n, 0, 255))
322 || !inRange(c.frame.alpha, 0, 1) || !inRange(c.frame.work, 0, 1) || typeof c.frame.hollow !== 'boolean'
323 || c.frame.channel !== CHANNELS[c.current].key || c.frame.arch !== CHANNELS[c.current].arch)
324 throw new Error('Invalid pet particle checkpoint.');
325 this.phase = c.phase; this.clock = c.clock; this.tear = c.tear; this.prev = c.previous; this.cur = c.current;
326 this.col = [...c.color]; this.frame = { ...c.frame };
327 this.p.forEach((p, i) => { [p.x, p.y, p.vx, p.vy, p.jx, p.jy, p.tx, p.ty] = c.particles[i]; });
328 }
329
330 /** Advance the sim by dt seconds under `state`. Identical math on every port. */
331 step(dt: number, state: PetState, opts: PetOpts): void {
332 const S = (v: number) => lerp(0.5, v, opts.sensitivity);
333 const act = S(state.activity), coh = S(state.coherence), att = S(state.attention);
334 const seen = S(state.observed === undefined ? 1 : state.observed);
335 const motion = opts.motion ? 1 : 0;
336 this.phase += dt * (0.18 + act * 0.55) * motion;
337 this.clock += dt * (opts.motion ? 1 : 0);
338
339 if (CHANNEL_INDEX[state.channel] !== undefined) this.cur = CHANNEL_INDEX[state.channel];
340 const shown = this.cur;
341 const ch = CHANNELS[shown];
342
343 const work = clamp((act - 0.16) / 0.18);
344 const wander = lerp(0.32, 1, Math.pow(1 - coh, 1.15));
345
346 if (shown !== this.prev) { if (shown === CHANNEL_INDEX['error']) this.tear = 1; this.prev = shown; }
347 this.tear = opts.motion ? Math.max(0, this.tear - dt * 1.6) : 0;
348
349 const split = Math.pow(1 - coh, 1.6) * 0.16 + this.tear * 0.10;
350 const blur = Math.pow(1 - coh, 1.45) * 0.22 + this.tear * 0.18;
351 const pull = opts.motion ? (2.2 + coh * 5.2) : 18;
352 const tGait = opts.motion ? this.clock : (STILL_T[ch.key] ?? 0.4);
353
354 for (const q of this.p) {
355 if (opts.motion) {
356 q.jx += dt * (0.40 + act * 1.1);
357 q.jy += dt * (0.34 + act * 0.9);
358 }
359 const [gx, gy] = gaitTarget(q, tGait, act, coh, att, ch.key, work, opts.podSlots, this.expressionVersion);
360 const podAng = q.pod * 1.047 + this.phase * 0.22;
361 const tx = gx + Math.sin(q.jx + q.s * 9) * blur * wander + Math.cos(podAng) * split;
362 const ty = gy + Math.cos(q.jy + q.s * 7) * blur * wander + Math.sin(podAng) * split * 0.55;
363 q.tx = tx; q.ty = ty;
364 if (!opts.motion) { q.x = tx; q.y = ty; q.vx = 0; q.vy = 0; continue; }
365 q.vx += (tx - q.x) * pull * dt; q.vy += (ty - q.y) * pull * dt;
366 q.vx *= 0.90; q.vy *= 0.90;
367 q.x += q.vx * dt * (opts.motion ? 2.6 : 8); q.y += q.vy * dt * (opts.motion ? 2.6 : 8);
368 }
369
370 // ---- visual encoding: the parts of "the same view" that are not motion
371 const want = work > 0.35 ? RGB[shown] : REST_RGB;
372 const k = opts.motion ? Math.min(1, dt * 2.6) : 1;
373 for (let c = 0; c < 3; c++) this.col[c] += (lerp(UNKNOWN_RGB[c], want[c], seen) - this.col[c]) * k;
374 const lit = clamp(state.lit);
375 const alpha = (0.22 + act * 0.10) * lerp(0.50, 1, coh) * lerp(0.55, 1, seen) * lerp(0.35, 1, lit);
376 this.frame = {
377 r: this.col[0], g: this.col[1], b: this.col[2],
378 alpha: Math.min(0.92, alpha * 1.85),
379 hollow: seen < 0.92,
380 channel: ch.key, arch: ch.arch, work,
381 };
382 }
383 }
384
385 /** Body-space → renderer-space. Renderers place each dot at (lx,ly) in pixels/cells. */
386 export function layout(w: number, h: number, state: PetState) {
387 const att = state.attention;
388 const scale = Math.min(w * 0.52, h * 0.92) * (1 + att * 0.07);
389 return {
390 scale,
391 flipX: state.flip,
392 ox: w / 2 + state.roamX * w * 0.30,
393 oy: h / 2 + state.roamY * h * 0.30 + h * att * 0.05,
394 dot: Math.max(1.6, Math.min(w, h) * 0.0092) * (1 + att * 0.18),
395 };
396 }
397
398 // ---------------------------------------------------------------------------
399 // Conformance. A tape is a list of [dt, state] rows; run it and digest the
400 // quantized field every `every` frames. 64×32 cells over [-0.66, 0.66]².
401 // Two implementations that produce the same digest lines drew the same whale.
402 export function digest(sim: PetSim): string {
403 const W = 64, H = 32;
404 const grid = new Uint8Array(W * H);
405 for (const q of sim.p) {
406 const cx = Math.floor((q.x + 0.66) / 1.32 * W);
407 const cy = Math.floor((q.y + 0.66) / 1.32 * H);
408 if (cx >= 0 && cx < W && cy >= 0 && cy < H) grid[cy * W + cx] = Math.min(255, grid[cy * W + cx] + 1);
409 }
410 // FNV-1a 64 over the grid plus the frame encoding (rgb, hollow, alpha byte)
411 // Two unsigned halves also work in embedded engines without BigInt.
412 // FNV's prime is (256 << 32) + 435; these products stay below 2^42,
413 // so every intermediate integer is exactly representable by a JS number.
414 let hi = 0xcbf29ce4, lo = 0x84222325;
415 const mix = (b: number) => {
416 lo = (lo ^ (b & 0xff)) >>> 0;
417 const product = lo * 435;
418 hi = (hi * 435 + lo * 256 + Math.floor(product / 4294967296)) >>> 0;
419 lo = product >>> 0;
420 };
421 for (const v of grid) mix(v);
422 mix(Math.round(sim.frame.r)); mix(Math.round(sim.frame.g)); mix(Math.round(sim.frame.b));
423 mix(Math.round(sim.frame.alpha * 255)); mix(sim.frame.hollow ? 1 : 0);
424 return hi.toString(16).padStart(8, '0') + lo.toString(16).padStart(8, '0');
425 }
426
427 /** Shared tape runner. `rows` are parsed tape.tsv lines. */
428 export function runTape(sim: PetSim, rows: string[], opts: PetOpts): string[] {
429 const out: string[] = [];
430 let f = 0;
431 for (const row of rows) {
432 const c = row.split('\t');
433 if (c.length < 10 || c[0] === 'dt') continue;
434 const st: PetState = {
435 activity: +c[1], coherence: +c[2], attention: +c[3], channel: c[4],
436 observed: +c[5], roamX: +c[6], roamY: +c[7], flip: +c[8], lit: +c[9],
437 };
438 sim.step(+c[0], st, opts);
439 if (f++ % 30 === 0) out.push(`f${String(f - 1).padStart(4, '0')} ${digest(sim)} ${st.channel}`);
440 }
441 out.push(`final ${digest(sim)}`);
442 return out;
443 }
444
444 lines TYPESCRIPT