| 1 | import Foundation |
| 2 | import JavaScriptCore |
| 3 | |
| 4 | public struct PetVoice: Codable { |
| 5 | public let id: String, start: Double, duration: Double, frequency: Double, gain: Double, pan: Double, kind: String |
| 6 | } |
| 7 | public struct PetPeer: Decodable { |
| 8 | public let id: String, slot: Int, phase: Double, present: Bool |
| 9 | } |
| 10 | public struct PetFood: Decodable { public let x: Double, y: Double, life: Double } |
| 11 | public struct PetWorldFrame: Decodable { |
| 12 | public let timeMs: Double, behaviour: String, state: PetState, needs: String |
| 13 | public let pod: [PetPeer], surface: Double, caustic: Double, food: PetFood? |
| 14 | public let voices: [PetVoice], digest: String |
| 15 | } |
| 16 | public enum PetCoreError: Error, LocalizedError { |
| 17 | case invalid(String) |
| 18 | public var errorDescription: String? { switch self { case .invalid(let s): return s } } |
| 19 | } |
| 20 | |
| 21 | /// JavaScriptCore runs the same event-independent world and score bundle as web. |
| 22 | /// Swift owns rasterization and audio output. No native telemetry reclassification. |
| 23 | @MainActor public final class PetNativeCore { |
| 24 | public let sim: PetSim |
| 25 | public private(set) var frame: PetWorldFrame |
| 26 | private let context: JSContext |
| 27 | private let world: JSValue |
| 28 | private var failure: String? |
| 29 | |
| 30 | public init(points: [(Double, Double)], bundle: URL, tape: String = "", interactions: String = "[]", live: Bool = false, saved: Data? = nil, expressionVersion: Int = 2) throws { |
| 31 | guard let context = JSContext() else { throw PetCoreError.invalid("Unable to create the pet runtime.") } |
| 32 | self.context = context |
| 33 | let script = try String(contentsOf: bundle, encoding: .utf8) |
| 34 | context.evaluateScript(script) |
| 35 | if let error = context.exception { throw PetCoreError.invalid(error.toString()) } |
| 36 | let pointData = try JSONSerialization.data(withJSONObject: points.map { [$0.0, $0.1] }) |
| 37 | guard let constructor = context.objectForKeyedSubscript("PetNative"), |
| 38 | let world = constructor.construct(withArguments: [String(decoding: pointData, as: UTF8.self), tape, interactions, live, expressionVersion]), context.exception == nil |
| 39 | else { throw PetCoreError.invalid(context.exception?.toString() ?? "Invalid pet recording.") } |
| 40 | if let saved { |
| 41 | guard saved.count <= 8 * 1024 * 1024, let text = String(data: saved, encoding: .utf8) else { throw PetCoreError.invalid("Invalid pet habitat file.") } |
| 42 | world.invokeMethod("restoreRecording", withArguments: [text]) |
| 43 | if let error = context.exception { throw PetCoreError.invalid(error.toString()) } |
| 44 | if live { |
| 45 | world.invokeMethod("resumeEngine", withArguments: []) |
| 46 | if let error = context.exception { throw PetCoreError.invalid(error.toString()) } |
| 47 | } |
| 48 | } |
| 49 | guard let text = world.invokeMethod("snapshot", withArguments: [])?.toString(), context.exception == nil |
| 50 | else { throw PetCoreError.invalid(context.exception?.toString() ?? "Invalid pet recording.") } |
| 51 | self.world = world |
| 52 | self.frame = try JSONDecoder().decode(PetWorldFrame.self, from: Data(text.utf8)) |
| 53 | self.sim = PetSim(points: points, expressionVersion: expressionVersion) |
| 54 | if saved != nil { |
| 55 | try restoreProjection() |
| 56 | } |
| 57 | context.exceptionHandler = { [weak self] _, error in self?.failure = error?.toString() ?? "Pet runtime failed." } |
| 58 | } |
| 59 | |
| 60 | @discardableResult public func tick(motion: Bool) throws -> PetWorldFrame { |
| 61 | failure = nil |
| 62 | guard let text = world.invokeMethod("step", withArguments: [1.0 / 30, motion])?.toString(), failure == nil |
| 63 | else { throw PetCoreError.invalid(failure ?? "Unable to advance the pet.") } |
| 64 | frame = try JSONDecoder().decode(PetWorldFrame.self, from: Data(text.utf8)) |
| 65 | let peers = frame.pod.filter { $0.present } |
| 66 | let slots = peers.count >= 3 ? peers.map { ([0, 2, 4, 1, 3, 5][$0.slot], $0.phase) } : nil |
| 67 | sim.step(dt: 1.0 / 30, state: frame.state, motion: motion, podSlots: slots) |
| 68 | return frame |
| 69 | } |
| 70 | public func interact(food: Bool, x: Double = 0.2, y: Double = -0.15) throws { |
| 71 | failure = nil |
| 72 | world.invokeMethod("interact", withArguments: [food ? "food" : "attention", x, y]) |
| 73 | if let failure { throw PetCoreError.invalid(failure) } |
| 74 | } |
| 75 | public func interactions() throws -> String { |
| 76 | failure = nil |
| 77 | let value = world.invokeMethod("interactions", withArguments: [])?.toString() |
| 78 | guard let value, failure == nil else { throw PetCoreError.invalid(failure ?? "Unable to save pet interactions.") } |
| 79 | return value |
| 80 | } |
| 81 | public func accept(packet: String) throws { |
| 82 | failure = nil |
| 83 | world.invokeMethod("accept", withArguments: [packet]) |
| 84 | if let failure { throw PetCoreError.invalid(failure) } |
| 85 | } |
| 86 | @discardableResult public func acceptLiveTail(_ text: String) throws -> Bool { |
| 87 | failure = nil |
| 88 | guard let value = world.invokeMethod("acceptLiveTail", withArguments: [text]), failure == nil else { throw PetCoreError.invalid(failure ?? "Unable to read local telemetry.") } |
| 89 | return value.toBool() |
| 90 | } |
| 91 | public func resumeLiveInput() throws { |
| 92 | failure = nil |
| 93 | world.invokeMethod("resetLiveInput", withArguments: []) |
| 94 | guard failure == nil, let text = world.invokeMethod("snapshot", withArguments: [])?.toString(), failure == nil else { throw PetCoreError.invalid(failure ?? "Unable to resume live telemetry.") } |
| 95 | frame = try JSONDecoder().decode(PetWorldFrame.self, from: Data(text.utf8)) |
| 96 | try restoreProjection() |
| 97 | } |
| 98 | private func restoreProjection() throws { |
| 99 | struct Projection: Decodable { let sim: PetParticleCheckpoint } |
| 100 | guard let text = world.invokeMethod("checkpoint", withArguments: [])?.toString(), context.exception == nil |
| 101 | else { throw PetCoreError.invalid(context.exception?.toString() ?? "Unable to restore the particle renderer.") } |
| 102 | try sim.restoreValidated(JSONDecoder().decode(Projection.self, from: Data(text.utf8)).sim) |
| 103 | guard petDigest(sim) == frame.digest else { throw PetCoreError.invalid("The restored particle renderer differs from the shared world.") } |
| 104 | } |
| 105 | public func recording(checkpoint: Bool = false) throws -> Data { |
| 106 | failure = nil |
| 107 | guard let value = world.invokeMethod("recording", withArguments: [checkpoint])?.toString(), failure == nil else { |
| 108 | throw PetCoreError.invalid(failure ?? "Unable to save the pet recording.") |
| 109 | } |
| 110 | return Data(value.utf8) |
| 111 | } |
| 112 | public func prepareSegment() throws -> Data? { |
| 113 | failure = nil |
| 114 | guard let needed = world.invokeMethod("needsSegment", withArguments: []), failure == nil else { throw PetCoreError.invalid(failure ?? "Unable to inspect recording history.") } |
| 115 | if !needed.toBool() { return nil } |
| 116 | guard let text = world.invokeMethod("prepareSegment", withArguments: [])?.toString(), failure == nil else { throw PetCoreError.invalid(failure ?? "Unable to prepare recording history.") } |
| 117 | let data = Data(text.utf8) |
| 118 | guard data.count <= 8 * 1024 * 1024 else { throw PetCoreError.invalid("The active recording exceeds 8 MiB.") } |
| 119 | return data |
| 120 | } |
| 121 | public func commitSegment() throws { |
| 122 | failure = nil; world.invokeMethod("commitSegment", withArguments: []) |
| 123 | if let failure { throw PetCoreError.invalid(failure) } |
| 124 | } |
| 125 | /// The host owns the world for this entire export. The private autosave and |
| 126 | /// native import remain bounded to 8 MiB; larger exports open in the browser. |
| 127 | public func exportRecording(completed: Bool = false) throws -> Data { |
| 128 | var data = Data(), index = 0 |
| 129 | while true { |
| 130 | failure = nil |
| 131 | guard let value = world.invokeMethod("recordingChunk", withArguments: [index, completed]), failure == nil else { |
| 132 | throw PetCoreError.invalid(failure ?? "Unable to export the pet recording.") |
| 133 | } |
| 134 | if value.isNull { return data } |
| 135 | guard value.isString, let text = value.toString() else { throw PetCoreError.invalid("Invalid pet export chunk.") } |
| 136 | let bytes = Data(text.utf8) |
| 137 | guard data.count + bytes.count <= 64 * 1024 * 1024 else { throw PetCoreError.invalid("Recording exceeds the 64 MiB export limit. The current world was kept.") } |
| 138 | data.append(bytes); index += 1 |
| 139 | } |
| 140 | } |
| 141 | public func pcm(voice: PetVoice, startSample: Int, length: Int, rate: Int) throws -> [[Float]] { |
| 142 | let voices = String(decoding: try JSONEncoder().encode([voice]), as: UTF8.self) |
| 143 | failure = nil |
| 144 | guard let channels = world.invokeMethod("pcmChannels", withArguments: [voices, startSample, length, rate]), failure == nil, |
| 145 | channels.isArray, channels.forProperty("length")?.toInt32() == 2 |
| 146 | else { throw PetCoreError.invalid(failure ?? "Unable to render pet audio.") } |
| 147 | return try (0..<2).map { index in |
| 148 | guard let channel = channels.atIndex(index) else { throw PetCoreError.invalid("Missing pet audio channel.") } |
| 149 | let ctx = context.jsGlobalContextRef |
| 150 | var exception: JSValueRef? |
| 151 | guard JSValueGetTypedArrayType(ctx, channel.jsValueRef, &exception) == kJSTypedArrayTypeFloat32Array, |
| 152 | exception == nil, let object = JSValueToObject(ctx, channel.jsValueRef, &exception), exception == nil, |
| 153 | JSObjectGetTypedArrayLength(ctx, object, &exception) == length, exception == nil |
| 154 | else { throw PetCoreError.invalid("Invalid pet PCM buffer.") } |
| 155 | if length == 0 { return [] } |
| 156 | guard let bytes = JSObjectGetTypedArrayBytesPtr(ctx, object, &exception), exception == nil |
| 157 | else { throw PetCoreError.invalid("Pet PCM buffer is unavailable.") } |
| 158 | // JavaScriptCore guarantees this pointer only until its next API |
| 159 | // call. Copy immediately; no JS-backed memory escapes the method. |
| 160 | let samples = withExtendedLifetime(channel) { |
| 161 | Array(UnsafeBufferPointer(start: bytes.assumingMemoryBound(to: Float.self), count: length)) |
| 162 | } |
| 163 | guard samples.allSatisfy(\.isFinite) else { throw PetCoreError.invalid("Invalid pet PCM samples.") } |
| 164 | return samples |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 |