返回 CodeWhale
PetNativeCore.kt
根目录 / pet / android / src / main / kotlin / codewhale / pet / PetNativeCore.kt
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
176 lines Plain Text