| 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 |