| 1 | // PetSim.swift — the Codewhale pet core, Swift port. |
| 2 | // |
| 3 | // A faithful, dependency-free port of PetSim.ts. Same 980-point body from |
| 4 | // whale-points.tsv, same mulberry32(0xC0FFEE) jitter, same gait field and |
| 5 | // spring integration, same colour/hollow/brightness encoding. Pure |
| 6 | // Foundation — works on iOS, macOS, and Linux Swift alike. |
| 7 | |
| 8 | import Foundation |
| 9 | |
| 10 | public struct PetState: Codable, Equatable { |
| 11 | public var activity: Double = 0.35 // how much work 0..1 |
| 12 | public var coherence: Double = 0.8 // converging school vs thrashing |
| 13 | public var attention: Double = 0 // interaction salience |
| 14 | public var channel: String = "reasoning" |
| 15 | public var observed: Double = 1 // instrumentation coverage |
| 16 | public var roamX: Double = 0 // tank position -1..1 |
| 17 | public var roamY: Double = 0 |
| 18 | public var flip: Double = 1 // 1 faces right, -1 left, 0 edge-on |
| 19 | public var lit: Double = 1 // sleep dimmer |
| 20 | public init() {} |
| 21 | } |
| 22 | |
| 23 | public struct Channel { |
| 24 | public let key: String |
| 25 | public let label: String |
| 26 | public let rgb: (Double, Double, Double) |
| 27 | public let arch: String |
| 28 | public let form: String |
| 29 | } |
| 30 | |
| 31 | public let CHANNELS: [Channel] = [ |
| 32 | Channel(key: "reasoning", label: "Model / reasoning", rgb: (0x73, 0xc9, 0xb5), arch: "gyre", form: "gyre · rolling"), |
| 33 | Channel(key: "tool", label: "Tool calls", rgb: (0x74, 0xaa, 0xdd), arch: "strike", form: "strike · reaching"), |
| 34 | Channel(key: "memory", label: "Memory / RAG", rgb: (0xb6, 0xa7, 0x7f), arch: "gyre", form: "gyre · scanning"), |
| 35 | Channel(key: "code", label: "Code execution", rgb: (0x9b, 0x9e, 0xd7), arch: "strike", form: "strike · along the body"), |
| 36 | Channel(key: "filesystem", label: "Filesystem", rgb: (0x92, 0xb9, 0xc9), arch: "strike", form: "strike · fanning"), |
| 37 | Channel(key: "network", label: "Network / API", rgb: (0xd3, 0xac, 0x74), arch: "cross", form: "crossing · one way"), |
| 38 | Channel(key: "browser", label: "Browser / computer", rgb: (0x9e, 0xa9, 0xdf), arch: "cross", form: "crossing · a sweep"), |
| 39 | Channel(key: "communication", label: "Agent messages", rgb: (0x83, 0xc5, 0xc9), arch: "cross", form: "crossing · two ways"), |
| 40 | Channel(key: "agent", label: "Subagent activity", rgb: (0xb0, 0x9a, 0xcb), arch: "pod", form: "pod · peers"), |
| 41 | Channel(key: "orchestration", label: "Orchestration", rgb: (0x6c, 0x87, 0x98), arch: "pod", form: "pod · hub"), |
| 42 | Channel(key: "error", label: "Errors / exceptions", rgb: (0xe7, 0x91, 0x86), arch: "tear", form: "torn · irregular"), |
| 43 | Channel(key: "human", label: "Human interaction", rgb: (0xc2, 0xb7, 0x87), arch: "address", form: "decision · junction"), |
| 44 | Channel(key: "other", label: "Unclassified", rgb: (0x73, 0x84, 0x92), arch: "drift", form: "drifting · unformed"), |
| 45 | ] |
| 46 | |
| 47 | public func channelIndex(_ key: String) -> Int? { CHANNELS.firstIndex { $0.key == key } } |
| 48 | |
| 49 | func archOf(_ key: String) -> String { |
| 50 | switch key { |
| 51 | case "reasoning", "memory": return "gyre" |
| 52 | case "tool", "code", "filesystem": return "strike" |
| 53 | case "network", "communication", "browser": return "cross" |
| 54 | case "agent", "orchestration": return "pod" |
| 55 | case "error": return "tear" |
| 56 | case "human": return "address" |
| 57 | default: return "drift" |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | let UNKNOWN_RGB = (115.0, 132.0, 146.0) |
| 62 | let REST_RGB = (122.0, 214.0, 240.0) |
| 63 | |
| 64 | @inline(__always) func lerp(_ a: Double, _ b: Double, _ t: Double) -> Double { a + (b - a) * t } |
| 65 | @inline(__always) func clamp01(_ v: Double) -> Double { min(1, max(0, v)) } |
| 66 | |
| 67 | /// mulberry32 — the same 32-bit sequence as every other port. |
| 68 | public struct Mulberry32 { |
| 69 | var a: UInt32 |
| 70 | public init(seed: UInt32) { a = seed } |
| 71 | public mutating func next() -> Double { |
| 72 | a = a &+ 0x6D2B79F5 |
| 73 | var t = a |
| 74 | t = (t ^ (t >> 15)) &* (t | 1) |
| 75 | t = t ^ (t &+ ((t ^ (t >> 7)) &* (t | 61))) |
| 76 | return Double(t ^ (t >> 14)) / 4294967296.0 |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | public struct Particle { |
| 81 | public var x: Double = 0, y: Double = 0, vx: Double = 0, vy: Double = 0 |
| 82 | public var s: Double = 0, jx: Double = 0, jy: Double = 0 |
| 83 | public var pod: Int = 0 |
| 84 | public var hx: Double = 0, hy: Double = 0, ang: Double = 0, rad: Double = 0, tail: Double = 0 |
| 85 | public var tx: Double = 0, ty: Double = 0 |
| 86 | public init() {} |
| 87 | } |
| 88 | |
| 89 | public struct Frame: Codable { |
| 90 | public var r: Double = 122, g: Double = 214, b: Double = 240 |
| 91 | public var alpha: Double = 0.3 |
| 92 | public var hollow: Bool = false |
| 93 | public var channel: String = "reasoning" |
| 94 | public var arch: String = "gyre" |
| 95 | public var work: Double = 0 |
| 96 | public init() {} |
| 97 | public init(r: Double, g: Double, b: Double, alpha: Double, hollow: Bool, |
| 98 | channel: String, arch: String, work: Double) { |
| 99 | self.r = r; self.g = g; self.b = b; self.alpha = alpha |
| 100 | self.hollow = hollow; self.channel = channel; self.arch = arch; self.work = work |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /// The canonical JavaScript checkpoint is validated before this projection is |
| 105 | /// decoded. Swift restores its existing particle renderer from that same state. |
| 106 | struct PetParticleCheckpoint: Decodable { |
| 107 | let version: Int |
| 108 | let expressionVersion: Int? |
| 109 | let body: [[Double]], particles: [[Double]] |
| 110 | let phase: Double, clock: Double, tear: Double |
| 111 | let previous: Int, current: Int, color: [Double] |
| 112 | let frame: Frame |
| 113 | } |
| 114 | |
| 115 | // Version 2 expresses work as fields while preserving seeded particle identity. |
| 116 | func fieldTarget(_ q: Particle, _ t: Double, _ act: Double, _ att: Double, _ key: String) -> (Double, Double)? { |
| 117 | let u = q.s * 2 - 1, lane = Double(q.pod) - 2.5, a = q.s * Double.pi * 2 |
| 118 | let flow = t * (0.35 + act * 0.65) |
| 119 | switch key { |
| 120 | case "reasoning": |
| 121 | let ring = 0.34 + 0.105 * cos(a * 3 + flow + lane * 0.18) |
| 122 | return (ring * cos(a * 2 + flow * 0.3), ring * sin(a * 2 + flow * 0.3) * 0.7 + 0.10 * sin(a * 3 + flow)) |
| 123 | case "memory": return (0.46 * cos(a + lane * 0.1 + flow * 0.25), lane * 0.082 + 0.052 * sin(a * 2 + flow)) |
| 124 | case "code": return (u * 0.57, lane * 0.066 + 0.12 * sin(u * 7 + flow * 2 + Double(q.pod) * Double.pi / 3)) |
| 125 | case "filesystem": |
| 126 | let branch = max(0, (u + 0.3) / 1.3) |
| 127 | return (u * 0.56, lane * 0.13 * branch + 0.025 * sin(u * 8 - flow)) |
| 128 | case "tool": |
| 129 | let reach = 0.14 + (u + 1) * 0.20 + 0.04 * sin(flow * 3 - u * 4) |
| 130 | return (cos(Double(q.pod) * Double.pi / 3) * reach, sin(Double(q.pod) * Double.pi / 3) * reach * 0.8 + q.hy * 0.06) |
| 131 | case "browser": return (u * 0.56, lane * 0.083 + 0.035 * sin(u * 5 - flow * 2)) |
| 132 | case "network", "communication": |
| 133 | let direction = key == "communication" && q.pod % 2 == 1 ? -1.0 : 1.0 |
| 134 | let phase = a + flow * direction |
| 135 | return (0.54 * cos(phase), sin(phase) * (0.12 + Double(q.pod) * 0.035) + lane * 0.024) |
| 136 | case "human": |
| 137 | let gap = u < 0 ? -0.075 : 0.075 |
| 138 | return (u * 0.47 + gap, lane * 0.10 * abs(u) + 0.012 * sin(flow + a) * (1 - att)) |
| 139 | default: return nil |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | /// Version 1 is retained for saved recordings. |
| 144 | /// Ported line-for-line from PetSim.ts gaitTarget(). |
| 145 | func gaitTarget(_ q: Particle, _ t: Double, _ act: Double, _ coh: Double, |
| 146 | _ att: Double, _ key: String, _ work: Double, _ podSlots: [(Int, Double)]? = nil, _ expressionVersion: Int = 1) -> (Double, Double) { |
| 147 | let omega = lerp(4.6, 5.2 + act * 2.8, work) |
| 148 | let breath = 1 + sin(t * 1.85) * lerp(0.048, 0.018, work) |
| 149 | let flex = sin(q.ang * 2.05 + t * omega) * lerp(0.042, 0.016 + act * 0.028, work) * (0.18 + 0.82 * q.tail) |
| 150 | var px = cos(q.ang + flex) * q.rad * breath |
| 151 | var py = sin(q.ang + flex) * q.rad * breath |
| 152 | px += sin(t * 0.33) * lerp(0.030, 0.014, work) |
| 153 | py += cos(t * 0.21) * lerp(0.018, 0.010, work) |
| 154 | if work < 0.02 { return (px, py) } |
| 155 | |
| 156 | var gx = px, gy = py |
| 157 | switch archOf(key) { |
| 158 | case "gyre": |
| 159 | if key == "memory" { |
| 160 | let pulse = 1 + sin(t * (2.4 + act * 1.6) - q.rad * 11) * (0.15 + act * 0.10) |
| 161 | gx *= pulse; gy *= pulse |
| 162 | } else { |
| 163 | let roll = sin(t * (1.05 + act * 0.35)) * (0.48 + act * 0.32) |
| 164 | let c = cos(roll), sn = sin(roll) |
| 165 | gx = px * c - py * sn * 0.88 |
| 166 | gy = px * sn * 0.88 + py * c |
| 167 | } |
| 168 | case "strike": |
| 169 | if key == "tool" { |
| 170 | let rate = 2.7 + act * 2.1 |
| 171 | let lunge = pow(max(0, sin(t * rate)), 2) |
| 172 | gx += lunge * 0.11 |
| 173 | if q.s > 0.60 { |
| 174 | let reach = pow(max(0, sin(t * rate + Double(q.pod) * 0.92)), 4) * (0.30 + act * 0.24) |
| 175 | gx += cos(q.ang) * reach |
| 176 | gy += sin(q.ang) * reach |
| 177 | } |
| 178 | } else if key == "code" { |
| 179 | let rate = 3.2 + act * 1.8 |
| 180 | let wave = sin(t * rate - q.tail * 7.5) |
| 181 | let bump = 0.11 + act * 0.08 |
| 182 | gx += cos(q.ang) * wave * bump |
| 183 | gy += sin(q.ang) * wave * bump * 1.2 |
| 184 | gx += max(0, wave) * 0.07 |
| 185 | } else { |
| 186 | let rate = 2.15 + act * 1.5 |
| 187 | let side = Double(q.pod % 2) * 2 - 1 |
| 188 | let w = pow(max(0, sin(t * rate + Double(q.pod) * 0.72)), 2) |
| 189 | gx += w * 0.055 |
| 190 | gy += side * w * (0.17 + act * 0.13) |
| 191 | } |
| 192 | case "cross": |
| 193 | if key == "browser" { |
| 194 | let band = (t * (0.55 + act * 0.35)).truncatingRemainder(dividingBy: 1) * 1.28 - 0.64 |
| 195 | let inBand = max(0, 1 - abs(q.hy - band) / 0.08) |
| 196 | gx += inBand * (0.24 + act * 0.10) |
| 197 | gy += inBand * 0.02 |
| 198 | } else { |
| 199 | let two = key == "communication" |
| 200 | let courier = q.s < (two ? 0.44 : 0.32) |
| 201 | if courier { |
| 202 | let dir: Double = two ? (q.s < 0.22 ? 1 : -1) : 1 |
| 203 | let u = (t * (0.38 + act * 0.36) + q.s * 5.2).truncatingRemainder(dividingBy: 1) |
| 204 | let going = u < 0.5 ? u * 2 : 2 - u * 2 |
| 205 | let e = going * going * (3 - 2 * going) |
| 206 | gx = lerp(q.hx, dir * 0.80, e) |
| 207 | gy = q.hy * (1 - e * 0.38) + sin(going * .pi) * 0.11 * dir |
| 208 | } |
| 209 | } |
| 210 | case "pod": |
| 211 | let n = 6 |
| 212 | let member = podSlots.flatMap { $0.isEmpty ? nil : $0[q.pod % $0.count] } |
| 213 | let k = member?.0 ?? q.pod % n |
| 214 | let hub = key == "orchestration" && k == 0 |
| 215 | let spread = 0.30 + act * 0.11 |
| 216 | let orbit = t * (0.55 + act * 0.28) |
| 217 | if hub { |
| 218 | gx = px * 0.70; gy = py * 0.70 |
| 219 | } else { |
| 220 | let slots = key == "orchestration" ? n - 1 : n |
| 221 | let a = Double(key == "orchestration" ? k - 1 : k) * (.pi * 2 / Double(slots)) + orbit + (member.map { $0.1 * 0.04 } ?? 0) |
| 222 | let sc = 0.34 |
| 223 | gx = q.hx * sc + cos(a) * spread * 1.28 |
| 224 | gy = q.hy * sc + sin(a) * spread * 0.80 |
| 225 | } |
| 226 | case "tear": |
| 227 | let side: Double = q.hx + q.hy < 0 ? -1 : 1 |
| 228 | gx += side * (0.24 + (1 - coh) * 0.16) |
| 229 | gy += side * 0.15 |
| 230 | gx += sin(t * 11.4 + q.s * 40) * (0.045 + act * 0.05) |
| 231 | gy += cos(t * 9.2 + q.s * 31) * (0.040 + act * 0.045) |
| 232 | case "address": |
| 233 | let face = 0.90 + att * 0.08 |
| 234 | let th = 0.70 |
| 235 | let z = (q.s - 0.5) * 0.42 |
| 236 | var ax = q.hx * cos(th) + z * sin(th) |
| 237 | var ay = q.hy |
| 238 | let disc = 0.48 * face |
| 239 | ax = lerp(ax, cos(q.ang) * min(0.36, q.rad + 0.06) * 0.95, disc) |
| 240 | ay = lerp(ay, sin(q.ang) * min(0.36, q.rad + 0.06) * 1.08, disc) |
| 241 | let grow = 1.20 + sin(t * 1.65) * 0.055 |
| 242 | gx = ax * grow; gy = ay * grow |
| 243 | default: |
| 244 | let mill = 0.13 + (1 - coh) * 0.10 |
| 245 | gx = q.hx * 0.52 + sin(t * 0.72 + q.jx) * mill |
| 246 | gy = q.hy * 0.52 + cos(t * 0.54 + q.jy) * mill |
| 247 | } |
| 248 | if expressionVersion == 2, let field = fieldTarget(q, t, act, att, key) { gx = field.0; gy = field.1 } |
| 249 | return (lerp(px, gx, work), lerp(py, gy, work)) |
| 250 | } |
| 251 | |
| 252 | func stillT(_ key: String) -> Double { |
| 253 | switch key { |
| 254 | case "reasoning": return 1.15; case "memory": return 0.42; case "tool": return 0.30 |
| 255 | case "code": return 0.18; case "filesystem": return 0.48; case "network": return 0.72 |
| 256 | case "browser": return 0.95; case "communication": return 0.58; case "agent": return 1.25 |
| 257 | case "orchestration": return 0.85; case "error": return 0.35; case "human": return 0.05 |
| 258 | case "other": return 0.90; default: return 0.4 |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | public final class PetSim { |
| 263 | public private(set) var p: [Particle] |
| 264 | public private(set) var expressionVersion: Int |
| 265 | var phase = 0.0 |
| 266 | var clock = 0.0 |
| 267 | var tear = 0.0 |
| 268 | var prev: Int |
| 269 | var col = REST_RGB |
| 270 | var cur: Int |
| 271 | public private(set) var frame = Frame() |
| 272 | |
| 273 | public init(points: [(Double, Double)], seed: UInt32 = 0xC0FFEE, expressionVersion: Int = 2) { |
| 274 | precondition(expressionVersion == 1 || expressionVersion == 2) |
| 275 | self.expressionVersion = expressionVersion |
| 276 | var rng = Mulberry32(seed: seed) |
| 277 | p = points.enumerated().map { (i, pt) in |
| 278 | var q = Particle() |
| 279 | q.hx = pt.0; q.hy = pt.1 |
| 280 | q.x = pt.0; q.y = pt.1; q.tx = pt.0; q.ty = pt.1 |
| 281 | q.s = rng.next(); q.jx = rng.next() * 6.283; q.jy = rng.next() * 6.283 |
| 282 | q.pod = i % 6 |
| 283 | q.ang = atan2(q.hy, q.hx) |
| 284 | q.rad = (q.hx * q.hx + q.hy * q.hy).squareRoot() |
| 285 | q.tail = clamp01(((-q.hx - q.hy) * 0.5 + 0.22) / 0.62) |
| 286 | return q |
| 287 | } |
| 288 | cur = channelIndex("reasoning")! |
| 289 | prev = cur |
| 290 | } |
| 291 | |
| 292 | func restoreValidated(_ checkpoint: PetParticleCheckpoint) throws { |
| 293 | // Array and identity checks also protect this native boundary if its |
| 294 | // caller changes. Mutation starts only after the complete shape passes. |
| 295 | guard checkpoint.version == 1, [1, 2].contains(checkpoint.expressionVersion ?? 1), checkpoint.body.count == p.count, |
| 296 | checkpoint.particles.count == p.count, checkpoint.color.count == 3, |
| 297 | CHANNELS.indices.contains(checkpoint.previous), CHANNELS.indices.contains(checkpoint.current), |
| 298 | checkpoint.body.enumerated().allSatisfy({ i, v in |
| 299 | v.count == 3 && v[0] == p[i].hx && v[1] == p[i].hy && v[2] == p[i].s |
| 300 | }), checkpoint.particles.allSatisfy({ $0.count == 8 && $0.allSatisfy(\.isFinite) }) |
| 301 | else { throw NSError(domain: "CodewhalePet", code: 1, userInfo: [NSLocalizedDescriptionKey: "The particle checkpoint does not match this whale."]) } |
| 302 | expressionVersion = checkpoint.expressionVersion ?? 1 |
| 303 | phase = checkpoint.phase; clock = checkpoint.clock; tear = checkpoint.tear |
| 304 | prev = checkpoint.previous; cur = checkpoint.current |
| 305 | col = (checkpoint.color[0], checkpoint.color[1], checkpoint.color[2]); frame = checkpoint.frame |
| 306 | for i in p.indices { |
| 307 | let v = checkpoint.particles[i] |
| 308 | p[i].x = v[0]; p[i].y = v[1]; p[i].vx = v[2]; p[i].vy = v[3] |
| 309 | p[i].jx = v[4]; p[i].jy = v[5]; p[i].tx = v[6]; p[i].ty = v[7] |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | /// Advance the sim by dt seconds under `state`. Identical math to PetSim.ts. |
| 314 | public func step(dt: Double, state: PetState, motion: Bool = true, sensitivity: Double = 1, podSlots: [(Int, Double)]? = nil) { |
| 315 | let s: (Double) -> Double = { lerp(0.5, $0, sensitivity) } |
| 316 | let act = s(state.activity), coh = s(state.coherence), att = s(state.attention) |
| 317 | let seen = s(state.observed) |
| 318 | let mot = motion ? 1.0 : 0.0 |
| 319 | phase += dt * (0.18 + act * 0.55) * mot |
| 320 | if motion { clock += dt } |
| 321 | |
| 322 | if let i = channelIndex(state.channel) { cur = i } |
| 323 | let shown = cur |
| 324 | let ch = CHANNELS[shown] |
| 325 | |
| 326 | let work = clamp01((act - 0.16) / 0.18) |
| 327 | let wander = lerp(0.32, 1, pow(1 - coh, 1.15)) |
| 328 | |
| 329 | if shown != prev { |
| 330 | if shown == channelIndex("error")! { tear = 1 } |
| 331 | prev = shown |
| 332 | } |
| 333 | tear = motion ? max(0, tear - dt * 1.6) : 0 |
| 334 | |
| 335 | let split = pow(1 - coh, 1.6) * 0.16 + tear * 0.10 |
| 336 | let blur = pow(1 - coh, 1.45) * 0.22 + tear * 0.18 |
| 337 | let pull = motion ? (2.2 + coh * 5.2) : 18.0 |
| 338 | let tGait = motion ? clock : stillT(ch.key) |
| 339 | |
| 340 | for i in p.indices { |
| 341 | if motion { |
| 342 | p[i].jx += dt * (0.40 + act * 1.1) |
| 343 | p[i].jy += dt * (0.34 + act * 0.9) |
| 344 | } |
| 345 | let (gx, gy) = gaitTarget(p[i], tGait, act, coh, att, ch.key, work, podSlots, expressionVersion) |
| 346 | let podAng = Double(p[i].pod) * 1.047 + phase * 0.22 |
| 347 | let tx = gx + sin(p[i].jx + p[i].s * 9) * blur * wander + cos(podAng) * split |
| 348 | let ty = gy + cos(p[i].jy + p[i].s * 7) * blur * wander + sin(podAng) * split * 0.55 |
| 349 | p[i].tx = tx; p[i].ty = ty |
| 350 | if !motion { p[i].x = tx; p[i].y = ty; p[i].vx = 0; p[i].vy = 0; continue } |
| 351 | p[i].vx += (tx - p[i].x) * pull * dt |
| 352 | p[i].vy += (ty - p[i].y) * pull * dt |
| 353 | p[i].vx *= 0.90; p[i].vy *= 0.90 |
| 354 | let speed = motion ? 2.6 : 8.0 |
| 355 | p[i].x += p[i].vx * dt * speed |
| 356 | p[i].y += p[i].vy * dt * speed |
| 357 | } |
| 358 | |
| 359 | let want = work > 0.35 ? CHANNELS[shown].rgb : REST_RGB |
| 360 | let k = motion ? min(1, dt * 2.6) : 1 |
| 361 | col = (col.0 + (lerp(UNKNOWN_RGB.0, want.0, seen) - col.0) * k, |
| 362 | col.1 + (lerp(UNKNOWN_RGB.1, want.1, seen) - col.1) * k, |
| 363 | col.2 + (lerp(UNKNOWN_RGB.2, want.2, seen) - col.2) * k) |
| 364 | let lit = clamp01(state.lit) |
| 365 | let alpha = (0.22 + act * 0.10) * lerp(0.50, 1, coh) * lerp(0.55, 1, seen) * lerp(0.35, 1, lit) |
| 366 | frame = Frame(r: col.0, g: col.1, b: col.2, |
| 367 | alpha: min(0.92, alpha * 1.85), |
| 368 | hollow: seen < 0.92, |
| 369 | channel: ch.key, arch: ch.arch, work: work) |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | /// Body-space → renderer-space, same as PetSim.ts layout(). |
| 374 | public struct PetLayout { |
| 375 | public let scale: Double, flipX: Double, ox: Double, oy: Double, dot: Double |
| 376 | } |
| 377 | public func petLayout(w: Double, h: Double, state: PetState) -> PetLayout { |
| 378 | let att = state.attention |
| 379 | let scale = min(w * 0.52, h * 0.92) * (1 + att * 0.07) |
| 380 | return PetLayout( |
| 381 | scale: scale, flipX: state.flip, |
| 382 | ox: w / 2 + state.roamX * w * 0.30, |
| 383 | oy: h / 2 + state.roamY * h * 0.30 + h * att * 0.05, |
| 384 | dot: max(1.6, min(w, h) * 0.0092) * (1 + att * 0.18)) |
| 385 | } |
| 386 | |
| 387 | /// Conformance digest — the same 64×32 quantization + FNV-1a as every port. |
| 388 | public func petDigest(_ sim: PetSim) -> String { |
| 389 | let W = 64, H = 32 |
| 390 | var grid = [UInt8](repeating: 0, count: W * H) |
| 391 | for q in sim.p { |
| 392 | let cx = Int(((q.x + 0.66) / 1.32 * Double(W)).rounded(.down)) |
| 393 | let cy = Int(((q.y + 0.66) / 1.32 * Double(H)).rounded(.down)) |
| 394 | if cx >= 0 && cx < W && cy >= 0 && cy < H { |
| 395 | let i = cy * W + cx |
| 396 | grid[i] = grid[i] == 255 ? 255 : grid[i] + 1 |
| 397 | } |
| 398 | } |
| 399 | var h: UInt64 = 0xcbf29ce484222325 |
| 400 | func mix(_ b: UInt64) { h ^= b & 0xff; h = h &* 0x100000001b3 } |
| 401 | for v in grid { mix(UInt64(v)) } |
| 402 | mix(UInt64(sim.frame.r.rounded())) |
| 403 | mix(UInt64(sim.frame.g.rounded())) |
| 404 | mix(UInt64(sim.frame.b.rounded())) |
| 405 | mix(UInt64((sim.frame.alpha * 255).rounded())) |
| 406 | mix(sim.frame.hollow ? 1 : 0) |
| 407 | return String(format: "%016llx", h) |
| 408 | } |
| 409 |