| 1 | package codewhale.pet |
| 2 | |
| 3 | import app.cash.zipline.EngineApi |
| 4 | import app.cash.zipline.InterruptHandler |
| 5 | import app.cash.zipline.QuickJs |
| 6 | import org.json.JSONArray |
| 7 | import org.json.JSONObject |
| 8 | import kotlin.math.roundToLong |
| 9 | import java.io.OutputStream |
| 10 | |
| 11 | data class PetAppearance(val background: List<Int>, val backgroundTop: List<Int>, val dotScale: Float, val glow: Float, val environment: Boolean) |
| 12 | |
| 13 | data class PetFood(val x: Float, val y: Float, val life: Float) |
| 14 | data class PetScene( |
| 15 | val timeMs: Double, val state: PetState, val style: Frame, val dots: FloatArray, |
| 16 | val behaviour: String, val needs: String, val surface: Float, val caustic: Float, |
| 17 | val food: PetFood?, val peers: Int, val digest: String, val appearance: PetAppearance? = null, val activity: String? = null, |
| 18 | ) |
| 19 | |
| 20 | /** Confined to one worker. QuickJS runs the committed world, validation and |
| 21 | * score; Kotlin only projects its state into the existing particle renderer. */ |
| 22 | @OptIn(EngineApi::class) |
| 23 | class PetNativeCore(bundle: String, pointsText: String, tape: String = "", saved: String? = null, live: Boolean = false) : AutoCloseable { |
| 24 | private val js = QuickJs.create() |
| 25 | private var deadline = Long.MAX_VALUE |
| 26 | val sim: PetSim |
| 27 | private var frame: JSONObject |
| 28 | val timeMs get() = frame.getDouble("timeMs") |
| 29 | val voices: JSONArray get() = frame.getJSONArray("voices") |
| 30 | |
| 31 | init { |
| 32 | try { |
| 33 | js.memoryLimit = 64L * 1024 * 1024 |
| 34 | js.interruptHandler = InterruptHandler { System.nanoTime() > deadline || Thread.currentThread().isInterrupted } |
| 35 | // The binding's QuickJS predates these two standard ES2022 methods. |
| 36 | // Only platform shims; the packaged world is byte-identical to Apple/TUI. |
| 37 | evaluate(""" |
| 38 | if (!Object.hasOwn) Object.defineProperty(Object, 'hasOwn', {value: (o, k) => Object.prototype.hasOwnProperty.call(o, k)}); |
| 39 | if (!Array.prototype.at) Object.defineProperty(Array.prototype, 'at', {value: function(i) { i = Math.trunc(Number(i) || 0); return this[i < 0 ? this.length + i : i]; }}); |
| 40 | """.trimIndent()) |
| 41 | evaluate(bundle) |
| 42 | val points = pointsText.lineSequence().filter { it.isNotBlank() }.map { row -> |
| 43 | val p = row.split('\t').map(String::toDouble) |
| 44 | require(p.size == 2 && p.all { it.isFinite() && it in -1.0..1.0 }) |
| 45 | p[0] to p[1] |
| 46 | }.toList() |
| 47 | require(points.size == 980) |
| 48 | val body = JSONArray(points.map { listOf(it.first, it.second) }).toString() |
| 49 | evaluate("globalThis.pet = new PetNative(${quote(body)}, ${quote(tape)}, '[]', $live); undefined") |
| 50 | if (saved != null) { |
| 51 | require(saved.toByteArray(Charsets.UTF_8).size <= MAX_HABITAT_BYTES) { "Habitat exceeds 8 MiB." } |
| 52 | evaluate("pet.restoreRecording(${quote(saved)})", 10_000) |
| 53 | if (live) evaluate("pet.resetLiveInput()") |
| 54 | } |
| 55 | frame = JSONObject(string("pet.snapshot()")) |
| 56 | sim = PetSim(points) |
| 57 | if (saved != null) restoreProjection() |
| 58 | } catch (error: Throwable) { |
| 59 | js.close() |
| 60 | throw error |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | fun tick(motion: Boolean): PetScene { |
| 65 | frame = JSONObject(string("pet.step(1 / 30, $motion)")) |
| 66 | val state = decodeState(frame.getJSONObject("state")) |
| 67 | val pod = frame.getJSONArray("pod") |
| 68 | val peers = (0 until pod.length()).map(pod::getJSONObject).filter { it.getBoolean("present") } |
| 69 | val slots = peers.takeIf { it.size >= 3 }?.map { |
| 70 | listOf(0, 2, 4, 1, 3, 5)[it.getInt("slot")] to it.getDouble("phase") |
| 71 | } |
| 72 | sim.step(1.0 / 30, state, motion, podSlots = slots) |
| 73 | return scene() |
| 74 | } |
| 75 | |
| 76 | fun scene(): PetScene { |
| 77 | val food = frame.optJSONObject("food")?.let { |
| 78 | PetFood(it.getDouble("x").toFloat(), it.getDouble("y").toFloat(), it.getDouble("life").toFloat()) |
| 79 | } |
| 80 | val pod = frame.getJSONArray("pod") |
| 81 | return PetScene(timeMs, decodeState(frame.getJSONObject("state")), sim.frame.copy(), |
| 82 | FloatArray(sim.p.size * 2) { i -> (if (i % 2 == 0) sim.p[i / 2].x else sim.p[i / 2].y).toFloat() }, |
| 83 | frame.getString("behaviour"), frame.getString("needs"), frame.getDouble("surface").toFloat(), |
| 84 | frame.getDouble("caustic").toFloat(), food, |
| 85 | (0 until pod.length()).count { pod.getJSONObject(it).getBoolean("present") }, frame.getString("digest")) |
| 86 | } |
| 87 | |
| 88 | fun interact(food: Boolean) { evaluate("pet.interact('${if (food) "food" else "attention"}', 0.2, -0.15)") } |
| 89 | fun acceptLiveTail(text: String): Boolean = evaluate("pet.acceptLiveTail(${quote(text)})") == true |
| 90 | fun resumeLiveInput() { |
| 91 | evaluate("pet.resetLiveInput()") |
| 92 | frame = JSONObject(string("pet.snapshot()")); restoreProjection() |
| 93 | } |
| 94 | private fun restoreProjection() { |
| 95 | sim.restoreValidated(decodeCheckpoint(JSONObject(string("pet.checkpoint()")).getJSONObject("sim"))) |
| 96 | check(petDigest(sim) == frame.getString("digest")) { "Restored particles differ from the shared world." } |
| 97 | } |
| 98 | fun recording(): String = string("pet.recording(true)").also { |
| 99 | require(it.toByteArray(Charsets.UTF_8).size <= MAX_HABITAT_BYTES) { "Habitat exceeds 8 MiB; export the recording." } |
| 100 | } |
| 101 | fun prepareSegment(): String? { |
| 102 | if (evaluate("pet.needsSegment()") != true) return null |
| 103 | return string("pet.prepareSegment()").also { require(it.toByteArray(Charsets.UTF_8).size <= MAX_HABITAT_BYTES) { "The active recording exceeds 8 MiB." } } |
| 104 | } |
| 105 | fun commitSegment() { evaluate("pet.commitSegment()") } |
| 106 | /** Called synchronously on the world worker, so the chunks share one clock. |
| 107 | * A larger recovery export never materializes one giant string in QuickJS. */ |
| 108 | fun exportRecording(output: OutputStream, completed: Boolean = false): Long { |
| 109 | var size = 0L |
| 110 | var index = 0 |
| 111 | while (true) { |
| 112 | val value = evaluate("pet.recordingChunk(${index++}, $completed)") ?: return size |
| 113 | val bytes = (value as? String ?: error("Invalid pet export chunk.")).toByteArray(Charsets.UTF_8) |
| 114 | check(size + bytes.size <= 64L * 1024 * 1024) { "Recording exceeds the 64 MiB export limit. The current world was kept." } |
| 115 | output.write(bytes); size += bytes.size |
| 116 | } |
| 117 | } |
| 118 | fun pcm(voices: JSONArray, start: Long, length: Int): FloatArray { |
| 119 | require(start >= 0 && length in 1..24_000 && voices.length() <= 128) |
| 120 | val channels = JSONArray(string("pet.pcm(${quote(voices.toString())}, $start, $length, 48000)", 500)) |
| 121 | require(channels.length() == 2) |
| 122 | val left = channels.getJSONArray(0); val right = channels.getJSONArray(1) |
| 123 | require(left.length() == length && right.length() == length) |
| 124 | return FloatArray(length * 2) { i -> |
| 125 | (if (i % 2 == 0) left else right).getDouble(i / 2).toFloat().also { |
| 126 | require(it.isFinite() && it in -1f..1f) { "Invalid pet audio sample." } |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | private fun string(script: String, budgetMs: Long = 5_000) = evaluate(script, budgetMs) as? String |
| 131 | ?: error("The pet runtime did not return a valid snapshot.") |
| 132 | private fun evaluate(script: String, budgetMs: Long = 5_000): Any? { |
| 133 | deadline = System.nanoTime() + budgetMs * 1_000_000 |
| 134 | return try { js.evaluate(script, "pet-host.js") } finally { deadline = Long.MAX_VALUE } |
| 135 | } |
| 136 | override fun close() = js.close() |
| 137 | |
| 138 | companion object { |
| 139 | const val MAX_HABITAT_BYTES = 8 * 1024 * 1024 |
| 140 | private fun quote(text: String): String = JSONObject.quote(text) |
| 141 | internal fun decodeState(s: JSONObject) = PetState(s.getDouble("activity"), s.getDouble("coherence"), |
| 142 | s.getDouble("attention"), s.getString("channel"), s.getDouble("observed"), s.getDouble("roamX"), |
| 143 | s.getDouble("roamY"), s.getDouble("flip"), s.getDouble("lit")) |
| 144 | internal fun decodeStyle(f: JSONObject) = Frame(f.getDouble("r"), f.getDouble("g"), f.getDouble("b"), |
| 145 | f.getDouble("alpha"), f.getBoolean("hollow"), f.getString("channel"), f.getString("arch"), f.getDouble("work")) |
| 146 | private fun decodeCheckpoint(c: JSONObject): PetParticleCheckpoint { |
| 147 | fun list(a: JSONArray) = (0 until a.length()).map(a::getDouble) |
| 148 | fun rows(name: String): List<List<Double>> = c.getJSONArray(name).let { a -> |
| 149 | (0 until a.length()).map { list(a.getJSONArray(it)) } |
| 150 | } |
| 151 | return PetParticleCheckpoint(c.getInt("version"), c.optInt("expressionVersion", 1), rows("body"), rows("particles"), |
| 152 | c.getDouble("phase"), c.getDouble("clock"), c.getDouble("tear"), c.getInt("previous"), c.getInt("current"), |
| 153 | list(c.getJSONArray("color")), decodeStyle(c.getJSONObject("frame"))) |
| 154 | } |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | /** Continuously address the shared PCM by simulation sample; retaining a voice |
| 159 | * never schedules it again. Reopening sound begins at the current clock. */ |
| 160 | class PetAudioCursor(timeMs: Double) { |
| 161 | private var sample = (timeMs * 48).roundToLong() |
| 162 | private val active = linkedMapOf<String, JSONObject>() |
| 163 | fun next(core: PetNativeCore): FloatArray? { |
| 164 | val end = (core.timeMs * 48).roundToLong() |
| 165 | require(end >= sample && end - sample <= 24_000) |
| 166 | for (i in 0 until core.voices.length()) { |
| 167 | val voice = core.voices.getJSONObject(i) |
| 168 | active[voice.getString("id")] = voice |
| 169 | } |
| 170 | active.entries.removeAll { (_, v) -> (v.getDouble("start") + v.getDouble("duration")) * 48_000 <= sample } |
| 171 | require(active.size <= 128) |
| 172 | if (end == sample) return null |
| 173 | return core.pcm(JSONArray(active.values.toList()), sample, (end - sample).toInt()).also { sample = end } |
| 174 | } |
| 175 | } |
| 176 |