返回 CodeWhale
pet_sim.rs
根目录 / crates / tui / src / tui / ambient_life / pet_sim.rs
1 //! pet_sim.rs — the Codewhale pet core, Rust port.
2 //!
3 //! A faithful, dependency-free port of PetSim.ts (which ports grammar.js's
4 //! consort engine). Same 980-point body from whale-points.tsv, same
5 //! mulberry32(0xC0FFEE) jitter, same gait field and spring integration, same
6 //! colour/hollow/brightness encoding. If a tape produces the same digest here
7 //! as in TypeScript or Swift, every surface drew the same whale.
8
9 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10 #[repr(usize)]
11 pub enum ChannelId {
12 Reasoning,
13 Tool,
14 Memory,
15 Code,
16 Filesystem,
17 Network,
18 Browser,
19 Communication,
20 Agent,
21 Orchestration,
22 Error,
23 Human,
24 #[default]
25 Other,
26 }
27
28 impl ChannelId {
29 pub const fn as_str(self) -> &'static str {
30 match self {
31 Self::Reasoning => "reasoning",
32 Self::Tool => "tool",
33 Self::Memory => "memory",
34 Self::Code => "code",
35 Self::Filesystem => "filesystem",
36 Self::Network => "network",
37 Self::Browser => "browser",
38 Self::Communication => "communication",
39 Self::Agent => "agent",
40 Self::Orchestration => "orchestration",
41 Self::Error => "error",
42 Self::Human => "human",
43 Self::Other => "other",
44 }
45 }
46 }
47 impl std::fmt::Display for ChannelId {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 f.write_str(self.as_str())
50 }
51 }
52
53 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54 #[repr(usize)]
55 pub enum Archetype {
56 Gyre,
57 Strike,
58 Cross,
59 Pod,
60 Tear,
61 Address,
62 #[default]
63 Drift,
64 }
65
66 impl Archetype {
67 pub const fn as_str(self) -> &'static str {
68 match self {
69 Self::Gyre => "gyre",
70 Self::Strike => "strike",
71 Self::Cross => "cross",
72 Self::Pod => "pod",
73 Self::Tear => "tear",
74 Self::Address => "address",
75 Self::Drift => "drift",
76 }
77 }
78 }
79 impl std::fmt::Display for Archetype {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.write_str(self.as_str())
82 }
83 }
84
85 impl ChannelId {
86 pub fn from_key(key: &str) -> Option<Self> {
87 CHANNELS
88 .iter()
89 .find(|c| c.key.as_str() == key)
90 .map(|c| c.key)
91 }
92 }
93
94 pub const N_CHANNELS: usize = 13;
95
96 #[derive(Clone, Copy, Default)]
97 pub struct PetState {
98 pub activity: f64,
99 pub coherence: f64,
100 pub attention: f64,
101 pub channel: ChannelId,
102 pub observed: f64,
103 pub roam_x: f64,
104 pub roam_y: f64,
105 pub flip: f64,
106 pub lit: f64,
107 }
108
109 impl PetState {
110 pub fn rest() -> Self {
111 PetState {
112 activity: 0.35,
113 coherence: 0.8,
114 attention: 0.0,
115 channel: ChannelId::Reasoning,
116 observed: 1.0,
117 roam_x: 0.0,
118 roam_y: 0.0,
119 flip: 1.0,
120 lit: 1.0,
121 }
122 }
123 }
124
125 #[derive(Clone, Copy)]
126 pub struct Channel {
127 pub key: ChannelId,
128 pub label: &'static str,
129 pub rgb: [u8; 3],
130 pub freq: f64,
131 pub sustained: bool,
132 pub arch: Archetype,
133 pub form: &'static str,
134 }
135
136 pub const CHANNELS: [Channel; N_CHANNELS] = [
137 Channel {
138 key: ChannelId::Reasoning,
139 label: "Model / reasoning",
140 rgb: [0x73, 0xc9, 0xb5],
141 freq: 130.81,
142 sustained: true,
143 arch: Archetype::Gyre,
144 form: "gyre · rolling",
145 },
146 Channel {
147 key: ChannelId::Tool,
148 label: "Tool calls",
149 rgb: [0x74, 0xaa, 0xdd],
150 freq: 261.63,
151 sustained: false,
152 arch: Archetype::Strike,
153 form: "strike · reaching",
154 },
155 Channel {
156 key: ChannelId::Memory,
157 label: "Memory / RAG",
158 rgb: [0xb6, 0xa7, 0x7f],
159 freq: 195.99,
160 sustained: true,
161 arch: Archetype::Gyre,
162 form: "gyre · scanning",
163 },
164 Channel {
165 key: ChannelId::Code,
166 label: "Code execution",
167 rgb: [0x9b, 0x9e, 0xd7],
168 freq: 164.81,
169 sustained: false,
170 arch: Archetype::Strike,
171 form: "strike · along the body",
172 },
173 Channel {
174 key: ChannelId::Filesystem,
175 label: "Filesystem",
176 rgb: [0x92, 0xb9, 0xc9],
177 freq: 440.00,
178 sustained: false,
179 arch: Archetype::Strike,
180 form: "strike · fanning",
181 },
182 Channel {
183 key: ChannelId::Network,
184 label: "Network / API",
185 rgb: [0xd3, 0xac, 0x74],
186 freq: 523.25,
187 sustained: false,
188 arch: Archetype::Cross,
189 form: "crossing · one way",
190 },
191 Channel {
192 key: ChannelId::Browser,
193 label: "Browser / computer",
194 rgb: [0x9e, 0xa9, 0xdf],
195 freq: 349.23,
196 sustained: false,
197 arch: Archetype::Cross,
198 form: "crossing · a sweep",
199 },
200 Channel {
201 key: ChannelId::Communication,
202 label: "Agent messages",
203 rgb: [0x83, 0xc5, 0xc9],
204 freq: 293.66,
205 sustained: false,
206 arch: Archetype::Cross,
207 form: "crossing · two ways",
208 },
209 Channel {
210 key: ChannelId::Agent,
211 label: "Subagent activity",
212 rgb: [0xb0, 0x9a, 0xcb],
213 freq: 220.00,
214 sustained: true,
215 arch: Archetype::Pod,
216 form: "pod · peers",
217 },
218 Channel {
219 key: ChannelId::Orchestration,
220 label: "Orchestration",
221 rgb: [0x6c, 0x87, 0x98],
222 freq: 98.00,
223 sustained: true,
224 arch: Archetype::Pod,
225 form: "pod · hub",
226 },
227 Channel {
228 key: ChannelId::Error,
229 label: "Errors / exceptions",
230 rgb: [0xe7, 0x91, 0x86],
231 freq: 185.00,
232 sustained: false,
233 arch: Archetype::Tear,
234 form: "torn · irregular",
235 },
236 Channel {
237 key: ChannelId::Human,
238 label: "Human interaction",
239 rgb: [0xc2, 0xb7, 0x87],
240 freq: 391.99,
241 sustained: false,
242 arch: Archetype::Address,
243 form: "decision · junction",
244 },
245 Channel {
246 key: ChannelId::Other,
247 label: "Unclassified",
248 rgb: [0x73, 0x84, 0x92],
249 freq: 146.83,
250 sustained: false,
251 arch: Archetype::Drift,
252 form: "drifting · unformed",
253 },
254 ];
255
256 const UNKNOWN_RGB: [f64; 3] = [0x73 as f64, 0x84 as f64, 0x92 as f64];
257 const REST_RGB: [f64; 3] = [122.0, 214.0, 240.0];
258
259 fn lerp(a: f64, b: f64, t: f64) -> f64 {
260 a + (b - a) * t
261 }
262 fn clamp(v: f64, a: f64, b: f64) -> f64 {
263 v.min(b).max(a)
264 }
265
266 /// mulberry32 — the same 32-bit sequence as every other port.
267 pub struct Mulberry32 {
268 a: u32,
269 }
270 impl Mulberry32 {
271 pub fn new(seed: u32) -> Self {
272 Mulberry32 { a: seed }
273 }
274 pub fn next_f64(&mut self) -> f64 {
275 self.a = self.a.wrapping_add(0x6D2B79F5);
276 let mut t = self.a;
277 t = (t ^ (t >> 15)).wrapping_mul(t | 1);
278 t ^= t.wrapping_add((t ^ (t >> 7)).wrapping_mul(t | 61));
279 ((t ^ (t >> 14)) as f64) / 4294967296.0
280 }
281 }
282
283 #[derive(Clone, Copy, Default)]
284 pub struct Particle {
285 pub x: f64,
286 pub y: f64,
287 pub vx: f64,
288 pub vy: f64,
289 pub s: f64,
290 pub jx: f64,
291 pub jy: f64,
292 pub pod: u32,
293 pub hx: f64,
294 pub hy: f64,
295 pub ang: f64,
296 pub rad: f64,
297 pub tail: f64,
298 pub tx: f64,
299 pub ty: f64,
300 }
301
302 #[derive(Clone, Copy, Default)]
303 pub struct Frame {
304 pub r: f64,
305 pub g: f64,
306 pub b: f64,
307 pub alpha: f64,
308 pub hollow: bool,
309 pub channel: ChannelId,
310 pub arch: Archetype,
311 pub work: f64,
312 }
313
314 /// Version 2 fields preserve particle identity and consume no random draws.
315 fn field_target(q: &Particle, t: f64, act: f64, att: f64, key: ChannelId) -> Option<(f64, f64)> {
316 let u = q.s * 2.0 - 1.0;
317 let lane = f64::from(q.pod) - 2.5;
318 let a = q.s * std::f64::consts::PI * 2.0;
319 let flow = t * (0.35 + act * 0.65);
320 Some(match key {
321 ChannelId::Reasoning => {
322 let ring = 0.34 + 0.105 * (a * 3.0 + flow + lane * 0.18).cos();
323 (
324 ring * (a * 2.0 + flow * 0.3).cos(),
325 ring * (a * 2.0 + flow * 0.3).sin() * 0.7 + 0.10 * (a * 3.0 + flow).sin(),
326 )
327 }
328 ChannelId::Memory => (
329 0.46 * (a + lane * 0.1 + flow * 0.25).cos(),
330 lane * 0.082 + 0.052 * (a * 2.0 + flow).sin(),
331 ),
332 ChannelId::Code => (
333 u * 0.57,
334 lane * 0.066
335 + 0.12
336 * (u * 7.0 + flow * 2.0 + f64::from(q.pod) * std::f64::consts::PI / 3.0).sin(),
337 ),
338 ChannelId::Filesystem => {
339 let branch = ((u + 0.3) / 1.3).max(0.0);
340 (
341 u * 0.56,
342 lane * 0.13 * branch + 0.025 * (u * 8.0 - flow).sin(),
343 )
344 }
345 ChannelId::Tool => {
346 let reach = 0.14 + (u + 1.0) * 0.20 + 0.04 * (flow * 3.0 - u * 4.0).sin();
347 (
348 (f64::from(q.pod) * std::f64::consts::PI / 3.0).cos() * reach,
349 (f64::from(q.pod) * std::f64::consts::PI / 3.0).sin() * reach * 0.8 + q.hy * 0.06,
350 )
351 }
352 ChannelId::Browser => (
353 u * 0.56,
354 lane * 0.083 + 0.035 * (u * 5.0 - flow * 2.0).sin(),
355 ),
356 ChannelId::Network | ChannelId::Communication => {
357 let direction = if key == ChannelId::Communication && q.pod % 2 == 1 {
358 -1.0
359 } else {
360 1.0
361 };
362 let phase = a + flow * direction;
363 (
364 0.54 * phase.cos(),
365 phase.sin() * (0.12 + f64::from(q.pod) * 0.035) + lane * 0.024,
366 )
367 }
368 ChannelId::Human => {
369 let gap = if u < 0.0 { -0.075 } else { 0.075 };
370 (
371 u * 0.47 + gap,
372 lane * 0.10 * u.abs() + 0.012 * (flow + a).sin() * (1.0 - att),
373 )
374 }
375 _ => return None,
376 })
377 }
378
379 /// Version 1 is retained for saved recordings.
380 /// Ported line-for-line from PetSim.ts gaitTarget().
381 // Keep the numeric port signature aligned with the TypeScript reference.
382 #[allow(clippy::too_many_arguments)]
383 fn gait_target(
384 q: &Particle,
385 t: f64,
386 act: f64,
387 coh: f64,
388 att: f64,
389 key: ChannelId,
390 work: f64,
391 legacy_expression: bool,
392 ) -> (f64, f64) {
393 let omega = lerp(4.6, 5.2 + act * 2.8, work);
394 let breath = 1.0 + (t * 1.85).sin() * lerp(0.048, 0.018, work);
395 let flex = (q.ang * 2.05 + t * omega).sin()
396 * lerp(0.042, 0.016 + act * 0.028, work)
397 * (0.18 + 0.82 * q.tail);
398 let mut px = (q.ang + flex).cos() * q.rad * breath;
399 let mut py = (q.ang + flex).sin() * q.rad * breath;
400 px += (t * 0.33).sin() * lerp(0.030, 0.014, work);
401 py += (t * 0.21).cos() * lerp(0.018, 0.010, work);
402 if work < 0.02 {
403 return (px, py);
404 }
405
406 let arch = CHANNELS[key as usize].arch;
407 let mut gx = px;
408 let mut gy = py;
409 match arch {
410 Archetype::Gyre => {
411 if key == ChannelId::Memory {
412 let pulse =
413 1.0 + (t * (2.4 + act * 1.6) - q.rad * 11.0).sin() * (0.15 + act * 0.10);
414 gx *= pulse;
415 gy *= pulse;
416 } else {
417 let roll = (t * (1.05 + act * 0.35)).sin() * (0.48 + act * 0.32);
418 let (c, sn) = (roll.cos(), roll.sin());
419 gx = px * c - py * sn * 0.88;
420 gy = px * sn * 0.88 + py * c;
421 }
422 }
423 Archetype::Strike => {
424 if key == ChannelId::Tool {
425 let rate = 2.7 + act * 2.1;
426 let lunge = (t * rate).sin().max(0.0).powi(2);
427 gx += lunge * 0.11;
428 if q.s > 0.60 {
429 let reach = (t * rate + q.pod as f64 * 0.92).sin().max(0.0).powi(4)
430 * (0.30 + act * 0.24);
431 gx += q.ang.cos() * reach;
432 gy += q.ang.sin() * reach;
433 }
434 } else if key == ChannelId::Code {
435 let rate = 3.2 + act * 1.8;
436 let wave = (t * rate - q.tail * 7.5).sin();
437 let bump = 0.11 + act * 0.08;
438 gx += q.ang.cos() * wave * bump;
439 gy += q.ang.sin() * wave * bump * 1.2;
440 gx += wave.max(0.0) * 0.07;
441 } else {
442 let rate = 2.15 + act * 1.5;
443 let side = (q.pod % 2) as f64 * 2.0 - 1.0;
444 let w = (t * rate + q.pod as f64 * 0.72).sin().max(0.0).powi(2);
445 gx += w * 0.055;
446 gy += side * w * (0.17 + act * 0.13);
447 }
448 }
449 Archetype::Cross => {
450 if key == ChannelId::Browser {
451 let band = ((t * (0.55 + act * 0.35)) % 1.0) * 1.28 - 0.64;
452 let in_band = (1.0 - (q.hy - band).abs() / 0.08).max(0.0);
453 gx += in_band * (0.24 + act * 0.10);
454 gy += in_band * 0.02;
455 } else {
456 let two = key == ChannelId::Communication;
457 let courier = q.s < if two { 0.44 } else { 0.32 };
458 if courier {
459 let dir = if two {
460 if q.s < 0.22 { 1.0 } else { -1.0 }
461 } else {
462 1.0
463 };
464 let u = (t * (0.38 + act * 0.36) + q.s * 5.2) % 1.0;
465 let going = if u < 0.5 { u * 2.0 } else { 2.0 - u * 2.0 };
466 let e = going * going * (3.0 - 2.0 * going);
467 gx = lerp(q.hx, dir * 0.80, e);
468 gy =
469 q.hy * (1.0 - e * 0.38) + (going * std::f64::consts::PI).sin() * 0.11 * dir;
470 }
471 }
472 }
473 Archetype::Pod => {
474 let n = 6u32;
475 let k = q.pod % n;
476 let hub = key == ChannelId::Orchestration && k == 0;
477 let spread = 0.30 + act * 0.11;
478 let orbit = t * (0.55 + act * 0.28);
479 if hub {
480 gx = px * 0.70;
481 gy = py * 0.70;
482 } else {
483 let slots = if key == ChannelId::Orchestration {
484 n - 1
485 } else {
486 n
487 };
488 let a = (if key == ChannelId::Orchestration {
489 k as f64 - 1.0
490 } else {
491 k as f64
492 }) * (std::f64::consts::PI * 2.0 / slots as f64)
493 + orbit;
494 let sc = 0.34;
495 gx = q.hx * sc + a.cos() * spread * 1.28;
496 gy = q.hy * sc + a.sin() * spread * 0.80;
497 }
498 }
499 Archetype::Tear => {
500 let side = if q.hx + q.hy < 0.0 { -1.0 } else { 1.0 };
501 gx += side * (0.24 + (1.0 - coh) * 0.16);
502 gy += side * 0.15;
503 gx += (t * 11.4 + q.s * 40.0).sin() * (0.045 + act * 0.05);
504 gy += (t * 9.2 + q.s * 31.0).cos() * (0.040 + act * 0.045);
505 }
506 Archetype::Address => {
507 let face = 0.90 + att * 0.08;
508 let th: f64 = 0.70;
509 let z = (q.s - 0.5) * 0.42;
510 let mut ax = q.hx * th.cos() + z * th.sin();
511 let mut ay = q.hy;
512 let disc = 0.48 * face;
513 ax = lerp(ax, q.ang.cos() * (0.36_f64).min(q.rad + 0.06) * 0.95, disc);
514 ay = lerp(ay, q.ang.sin() * (0.36_f64).min(q.rad + 0.06) * 1.08, disc);
515 let grow = 1.20 + (t * 1.65).sin() * 0.055;
516 gx = ax * grow;
517 gy = ay * grow;
518 }
519 _ => {
520 let mill = 0.13 + (1.0 - coh) * 0.10;
521 gx = q.hx * 0.52 + (t * 0.72 + q.jx).sin() * mill;
522 gy = q.hy * 0.52 + (t * 0.54 + q.jy).cos() * mill;
523 }
524 }
525 if !legacy_expression && let Some((x, y)) = field_target(q, t, act, att, key) {
526 gx = x;
527 gy = y;
528 }
529 (lerp(px, gx, work), lerp(py, gy, work))
530 }
531
532 fn still_t(key: ChannelId) -> f64 {
533 match key {
534 ChannelId::Reasoning => 1.15,
535 ChannelId::Memory => 0.42,
536 ChannelId::Tool => 0.30,
537 ChannelId::Code => 0.18,
538 ChannelId::Filesystem => 0.48,
539 ChannelId::Network => 0.72,
540 ChannelId::Browser => 0.95,
541 ChannelId::Communication => 0.58,
542 ChannelId::Agent => 1.25,
543 ChannelId::Orchestration => 0.85,
544 ChannelId::Error => 0.35,
545 ChannelId::Human => 0.05,
546 ChannelId::Other => 0.90,
547 }
548 }
549
550 pub struct PetSim {
551 pub p: Vec<Particle>,
552 legacy_expression: bool,
553 phase: f64,
554 clock: f64,
555 tear: f64,
556 prev: ChannelId,
557 col: [f64; 3],
558 pub frame: Frame,
559 }
560
561 impl PetSim {
562 /// Select the authored v1 field for historical conformance tapes.
563 pub fn legacy_whale() -> Self {
564 let mut sim = Self::whale();
565 sim.legacy_expression = true;
566 sim
567 }
568
569 /// Authored body embedded once; all Rust surfaces use these same points.
570 pub fn whale() -> Self {
571 let points: Vec<(f64, f64)> = include_str!("whale-points.tsv")
572 .lines()
573 .map(|line| {
574 let (x, y) = line.split_once('\t').expect("baked whale point");
575 (x.parse().expect("baked x"), y.parse().expect("baked y"))
576 })
577 .collect();
578 Self::new(&points, 0xC0FFEE)
579 }
580
581 // These rounded constants are part of the cross-language tape contract.
582 #[allow(clippy::approx_constant)]
583 pub fn new(points: &[(f64, f64)], seed: u32) -> Self {
584 let mut rng = Mulberry32::new(seed);
585 let p = points
586 .iter()
587 .enumerate()
588 .map(|(i, &(hx, hy))| {
589 let mut q = Particle {
590 x: hx,
591 y: hy,
592 vx: 0.0,
593 vy: 0.0,
594 s: rng.next_f64(),
595 jx: rng.next_f64() * 6.283,
596 jy: rng.next_f64() * 6.283,
597 pod: (i % 6) as u32,
598 hx,
599 hy,
600 ..Default::default()
601 };
602 q.tx = hx;
603 q.ty = hy;
604 q.ang = hy.atan2(hx);
605 q.rad = hx.hypot(hy);
606 q.tail = clamp(((-hx - hy) * 0.5 + 0.22) / 0.62, 0.0, 1.0);
607 q
608 })
609 .collect();
610 let cur = ChannelId::Reasoning;
611 PetSim {
612 p,
613 legacy_expression: false,
614 phase: 0.0,
615 clock: 0.0,
616 tear: 0.0,
617 prev: cur,
618 col: REST_RGB,
619 frame: Frame {
620 r: REST_RGB[0],
621 g: REST_RGB[1],
622 b: REST_RGB[2],
623 alpha: 0.3,
624 hollow: false,
625 channel: ChannelId::Reasoning,
626 arch: Archetype::Gyre,
627 work: 0.0,
628 },
629 }
630 }
631
632 /// Advance the sim by dt seconds under `state`. Identical math to PetSim.ts.
633 pub fn step(&mut self, dt: f64, state: &PetState, motion: bool, sensitivity: f64) {
634 let s = |v: f64| lerp(0.5, v, sensitivity);
635 let act = s(state.activity);
636 let coh = s(state.coherence);
637 let att = s(state.attention);
638 let seen = s(state.observed);
639 let mot = if motion { 1.0 } else { 0.0 };
640 self.phase += dt * (0.18 + act * 0.55) * mot;
641 if motion {
642 self.clock += dt;
643 }
644
645 let shown = state.channel;
646 let ch = CHANNELS[shown as usize];
647
648 let work = clamp((act - 0.16) / 0.18, 0.0, 1.0);
649 let wander = lerp(0.32, 1.0, (1.0 - coh).powf(1.15));
650
651 if shown != self.prev {
652 if shown == ChannelId::Error {
653 self.tear = 1.0;
654 }
655 self.prev = shown;
656 }
657 self.tear = if motion {
658 (self.tear - dt * 1.6).max(0.0)
659 } else {
660 0.0
661 };
662
663 let split = (1.0 - coh).powf(1.6) * 0.16 + self.tear * 0.10;
664 let blur = (1.0 - coh).powf(1.45) * 0.22 + self.tear * 0.18;
665 let pull = if motion { 2.2 + coh * 5.2 } else { 18.0 };
666 let t_gait = if motion { self.clock } else { still_t(ch.key) };
667
668 for q in self.p.iter_mut() {
669 if motion {
670 q.jx += dt * (0.40 + act * 1.1);
671 q.jy += dt * (0.34 + act * 0.9);
672 }
673 let (gx, gy) = gait_target(
674 q,
675 t_gait,
676 act,
677 coh,
678 att,
679 ch.key,
680 work,
681 self.legacy_expression,
682 );
683 let pod_ang = q.pod as f64 * 1.047 + self.phase * 0.22;
684 let tx = gx + (q.jx + q.s * 9.0).sin() * blur * wander + pod_ang.cos() * split;
685 let ty = gy + (q.jy + q.s * 7.0).cos() * blur * wander + pod_ang.sin() * split * 0.55;
686 q.tx = tx;
687 q.ty = ty;
688 if !motion {
689 q.x = tx;
690 q.y = ty;
691 q.vx = 0.0;
692 q.vy = 0.0;
693 continue;
694 }
695 q.vx += (tx - q.x) * pull * dt;
696 q.vy += (ty - q.y) * pull * dt;
697 q.vx *= 0.90;
698 q.vy *= 0.90;
699 let speed = if motion { 2.6 } else { 8.0 };
700 q.x += q.vx * dt * speed;
701 q.y += q.vy * dt * speed;
702 }
703
704 let want = if work > 0.35 {
705 [
706 CHANNELS[shown as usize].rgb[0] as f64,
707 CHANNELS[shown as usize].rgb[1] as f64,
708 CHANNELS[shown as usize].rgb[2] as f64,
709 ]
710 } else {
711 REST_RGB
712 };
713 let k = if motion { (dt * 2.6).min(1.0) } else { 1.0 };
714 for c in 0..3 {
715 self.col[c] += (lerp(UNKNOWN_RGB[c], want[c], seen) - self.col[c]) * k;
716 }
717 let lit = clamp(state.lit, 0.0, 1.0);
718 let alpha = (0.22 + act * 0.10)
719 * lerp(0.50, 1.0, coh)
720 * lerp(0.55, 1.0, seen)
721 * lerp(0.35, 1.0, lit);
722 self.frame = Frame {
723 r: self.col[0],
724 g: self.col[1],
725 b: self.col[2],
726 alpha: (alpha * 1.85).min(0.92),
727 hollow: seen < 0.92,
728 channel: ch.key,
729 arch: ch.arch,
730 work,
731 };
732 }
733 }
734
735 /// Body-space → renderer-space, same as PetSim.ts layout().
736 pub struct Layout {
737 pub scale: f64,
738 pub flip_x: f64,
739 pub ox: f64,
740 pub oy: f64,
741 pub dot: f64,
742 }
743 pub fn layout(w: f64, h: f64, state: &PetState) -> Layout {
744 let att = state.attention;
745 let scale = (w * 0.52).min(h * 0.92) * (1.0 + att * 0.07);
746 Layout {
747 scale,
748 flip_x: state.flip,
749 ox: w / 2.0 + state.roam_x * w * 0.30,
750 oy: h / 2.0 + state.roam_y * h * 0.30 + h * att * 0.05,
751 dot: (1.6_f64).max(w.min(h) * 0.0092) * (1.0 + att * 0.18),
752 }
753 }
754
755 /// Conformance digest: quantize the field onto a 64×32 grid over
756 /// [-0.66, 0.66]², then FNV-1a the counts plus the frame encoding.
757 pub fn digest(sim: &PetSim) -> String {
758 const W: usize = 64;
759 const H: usize = 32;
760 let mut grid = [0u8; W * H];
761 for q in &sim.p {
762 let cx = ((q.x + 0.66) / 1.32 * W as f64).floor() as i32;
763 let cy = ((q.y + 0.66) / 1.32 * H as f64).floor() as i32;
764 if cx >= 0 && cx < W as i32 && cy >= 0 && cy < H as i32 {
765 let i = cy as usize * W + cx as usize;
766 grid[i] = grid[i].saturating_add(1);
767 }
768 }
769 let mut h: u64 = 0xcbf29ce484222325;
770 let mut mix = |b: u64| {
771 h ^= b & 0xff;
772 h = h.wrapping_mul(0x100000001b3);
773 };
774 for &v in &grid {
775 mix(v as u64);
776 }
777 mix(sim.frame.r.round() as u64);
778 mix(sim.frame.g.round() as u64);
779 mix(sim.frame.b.round() as u64);
780 mix((sim.frame.alpha * 255.0).round() as u64);
781 mix(if sim.frame.hollow { 1 } else { 0 });
782 format!("{:016x}", h)
783 }
784
785 // ---------------------------------------------------------------------------
786 // Braille raster — the terminal's renderer medium. Each cell is a 2×4 dot
787 // matrix (Unicode U+2800 + bits). Hollow frames stamp odd particles only:
788 // the body is still shown, visibly not asserted — the ASCII hollow.
789 pub const BRAILLE_BITS: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
790
791 /// Rasterize into a W×H grid of braille cells. Returns packed braille bits.
792 pub fn braille(sim: &PetSim, cells_w: usize, cells_h: usize, state: &PetState) -> Vec<u8> {
793 let lay = layout(cells_w as f64 * 2.0, cells_h as f64 * 4.0, state);
794 let mut grid = vec![0u8; cells_w * cells_h];
795 for (i, q) in sim.p.iter().enumerate() {
796 if sim.frame.hollow && i % 2 == 1 {
797 continue;
798 }
799 let dx = lay.ox + q.x * lay.scale * lay.flip_x;
800 let dy = lay.oy + q.y * lay.scale;
801 let dx = dx.round() as i32;
802 let dy = dy.round() as i32;
803 if dx < 0 || dy < 0 {
804 continue;
805 }
806 let (dcx, dcy) = (dx as usize / 2, dy as usize / 4);
807 if dcx >= cells_w || dcy >= cells_h {
808 continue;
809 }
810 grid[dcy * cells_w + dcx] |= BRAILLE_BITS[(dy as usize) % 4][(dx as usize) % 2];
811 }
812 grid
813 }
814
815 /// Text form of a braille grid — for snapshots and conformance output.
816 pub fn braille_text(grid: &[u8], cells_w: usize, cells_h: usize) -> String {
817 let mut out = String::new();
818 for y in 0..cells_h {
819 for x in 0..cells_w {
820 let b = grid[y * cells_w + x];
821 out.push(if b == 0 {
822 ' '
823 } else {
824 char::from_u32(0x2800 + b as u32).unwrap_or(' ')
825 });
826 }
827 out.push('\n');
828 }
829 out
830 }
831
832 #[cfg(test)]
833 #[test]
834 fn constant_input_is_fully_still_in_every_channel() {
835 for channel in CHANNELS {
836 let mut sim = PetSim::whale();
837 let state = PetState {
838 channel: channel.key,
839 activity: 0.8,
840 coherence: 0.4,
841 ..PetState::rest()
842 };
843 sim.step(1.0 / 30.0, &state, false, 1.0);
844 let before = digest(&sim);
845 for _ in 0..180 {
846 sim.step(1.0 / 30.0, &state, false, 1.0);
847 }
848 assert_eq!(before, digest(&sim), "{}", channel.key);
849 }
850 }
851
851 lines RUST