@glassly/bluetooth-sdk 0.1.0-dev.99 → 0.2.8-dev.444

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +47 -6
  2. package/android/build.gradle +7 -7
  3. package/android/lc3Lib/build.gradle +2 -2
  4. package/android/src/main/assets/G2Firmware/README.md +27 -2
  5. package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  6. package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  7. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
  8. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
  9. package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
  10. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
  11. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
  12. package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
  13. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
  14. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
  15. package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
  16. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1065 -255
  17. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
  18. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
  19. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
  20. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
  21. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
  22. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
  23. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
  24. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
  25. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
  26. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
  27. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
  28. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
  29. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
  30. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
  31. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
  32. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
  33. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
  34. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
  35. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
  36. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
  37. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
  38. package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
  39. package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
  40. package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
  41. package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
  42. package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
  43. package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
  44. package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
  45. package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
  46. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
  47. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
  48. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
  49. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
  50. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
  51. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
  52. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
  53. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
  54. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
  55. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
  56. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
  57. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
  58. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
  59. package/build/BluetoothSdk.types.d.ts +209 -20
  60. package/build/BluetoothSdk.types.d.ts.map +1 -1
  61. package/build/BluetoothSdk.types.js.map +1 -1
  62. package/build/_internal.d.ts +1 -1
  63. package/build/_internal.d.ts.map +1 -1
  64. package/build/_internal.js.map +1 -1
  65. package/build/_private/BluetoothSdkModule.d.ts +30 -1
  66. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  67. package/build/_private/BluetoothSdkModule.js.map +1 -1
  68. package/build/generated/changelogCatalog.d.ts +28 -1
  69. package/build/generated/changelogCatalog.d.ts.map +1 -1
  70. package/build/generated/changelogCatalog.js +37 -1
  71. package/build/generated/changelogCatalog.js.map +1 -1
  72. package/build/generated/releaseMetadata.js +4 -4
  73. package/build/generated/releaseMetadata.js.map +1 -1
  74. package/build/index.d.ts +1 -1
  75. package/build/index.d.ts.map +1 -1
  76. package/build/index.js +14 -0
  77. package/build/index.js.map +1 -1
  78. package/ios/BluetoothSdkModule.swift +102 -13
  79. package/ios/GlasslyBluetoothSDK.podspec +1 -1
  80. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
  81. package/ios/Source/BluetoothSDK.swift +53 -0
  82. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  83. package/ios/Source/Bridge.swift +13 -0
  84. package/ios/Source/DeviceManager.swift +151 -33
  85. package/ios/Source/DeviceStore.swift +28 -14
  86. package/ios/Source/DisplayPacer.swift +180 -0
  87. package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
  88. package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
  89. package/ios/Source/GlassesNotifications.swift +181 -0
  90. package/ios/Source/ObservableStore.swift +14 -2
  91. package/ios/Source/Resources/G2Firmware/README.md +27 -2
  92. package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  93. package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  94. package/ios/Source/controllers/R1.swift +0 -1
  95. package/ios/Source/internal/BluetoothAvailability.swift +31 -0
  96. package/ios/Source/sgcs/G2.swift +1420 -495
  97. package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
  98. package/ios/Source/sgcs/G2BleLink.swift +102 -0
  99. package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
  100. package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
  101. package/ios/Source/sgcs/G2CfwScene.swift +779 -0
  102. package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
  103. package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
  104. package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
  105. package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
  106. package/ios/Source/sgcs/G2Flasher.swift +9 -9
  107. package/ios/Source/sgcs/G2Keyboard.swift +780 -0
  108. package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
  109. package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
  110. package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
  111. package/ios/Source/sgcs/G2RingBattery.swift +22 -0
  112. package/ios/Source/sgcs/G2Sensors.swift +75 -0
  113. package/ios/Source/sgcs/G2TouchInput.swift +51 -0
  114. package/ios/Source/sgcs/G2WritePacing.swift +18 -0
  115. package/ios/Source/sgcs/MentraNex.swift +44 -44
  116. package/ios/Source/sgcs/SGCManager.swift +120 -13
  117. package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
  118. package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
  119. package/ios/Source/status/DeviceStatus.swift +27 -0
  120. package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
  121. package/ios/Source/utils/Enums.swift +0 -1
  122. package/ios/Source/utils/G1Text.swift +0 -1
  123. package/ios/Source/utils/G2NotifyFile.swift +20 -10
  124. package/ios/Source/utils/JSCExperiment.swift +6 -6
  125. package/ios/Source/utils/Models.swift +0 -1
  126. package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
  127. package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
  128. package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
  129. package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
  130. package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
  131. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
  132. package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
  133. package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
  134. package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
  135. package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
  136. package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
  137. package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
  138. package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
  139. package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
  140. package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
  141. package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
  142. package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
  143. package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
  144. package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
  145. package/package.json +1 -1
  146. package/plugin/build/withAndroid.js +2 -2
  147. package/plugin/build/withIos.js +1 -1
  148. package/scripts/gen-nex-proto.sh +12 -0
  149. package/scripts/inject-ios-sdk-version.mjs +1 -1
  150. package/src/BluetoothSdk.types.ts +214 -19
  151. package/src/_internal.ts +1 -1
  152. package/src/_private/BluetoothSdkModule.ts +40 -0
  153. package/src/generated/changelogCatalog.ts +37 -1
  154. package/src/generated/releaseMetadata.ts +4 -4
  155. package/src/index.ts +24 -0
@@ -0,0 +1,673 @@
1
+ package com.glassly.bluetoothsdk.sgcs
2
+
3
+ import com.glassly.bluetoothsdk.sgcs.G2CfwTextureCache.Companion.writeI16
4
+ import com.glassly.bluetoothsdk.sgcs.G2CfwTextureCache.Companion.writeU32
5
+ import java.io.ByteArrayOutputStream
6
+
7
+ /**
8
+ * Retained object cache encoder for the Glassly CFW (GLASSLYCFW/38, modes 37-41).
9
+ *
10
+ * Every scene element becomes one or more OBJECTS with stable 32-bit ids
11
+ * (derived from the element id and the object's position within the element):
12
+ * vector shapes map 1:1; default and builtin text becomes one inline TEXT
13
+ * object per line drawn by the glasses' font; mono text becomes TEXT_CACHED
14
+ * runs drawn with a font asset (uploaded whole, once per cache generation);
15
+ * images are rasterized on the phone and drawn as IMAGE objects tiled to the
16
+ * 255 px limit, keyed by their pixels so identical images share one asset.
17
+ *
18
+ * A frame is one SHOW: the ordered (id, version) references of what is on
19
+ * screen, the embedded PUTs for objects the glasses do not hold yet (new, or
20
+ * changed content: a version is a hash of the record), and TWEEN/ROTATE ops for
21
+ * animated changes of geometry the glasses already hold. Objects that leave a
22
+ * frame stay cached on the glasses (and in this mirror) until the firmware
23
+ * evicts them least-recently-used, so a view coming back after a HIDE is pure
24
+ * references. The firmware validates every reference; a MISSING reply names
25
+ * what to redefine ([forget]).
26
+ *
27
+ * One instance mirrors one lens. Element depth is a stereo disparity: a lens
28
+ * mirror encoding with parallax ±1 shifts each element by depth × parallax px,
29
+ * so the renderer keeps a left and a right mirror once a frame uses depth and
30
+ * sends each lens its own SHOW ([G2CfwRenderer]).
31
+ */
32
+ internal class G2CfwScene {
33
+
34
+ companion object {
35
+ const val MAX_OBJECTS = 256
36
+ const val FRAME_PERIOD_MS = 33.0
37
+ const val TEXT_LINE_HEIGHT = 27
38
+ const val TEXT_INSET = 2
39
+ const val MAX_TEXT_BYTES = 64
40
+ const val MAX_INLINE_BYTES = 128
41
+ const val MAX_TILE_SIZE = 255
42
+ /** Mode-15/20 options: identity ramp, transparent source color 0. */
43
+ const val TEXT_OPTIONS = 0x1F
44
+ const val IMAGE_OPTIONS = 0x0F
45
+ const val ALL_OBJECTS = -1
46
+
47
+ const val TYPE_LINE = 1
48
+ const val TYPE_RECT = 2
49
+ const val TYPE_RECT_FILL = 3
50
+ const val TYPE_CIRCLE = 4
51
+ const val TYPE_CIRCLE_FILL = 5
52
+ const val TYPE_TRI = 6
53
+ const val TYPE_TRI_FILL = 7
54
+ const val TYPE_QUAD = 8
55
+ const val TYPE_QUAD_FILL = 9
56
+ const val TYPE_BEZIER2 = 10
57
+ const val TYPE_BEZIER3 = 11
58
+ const val TYPE_ARC = 12
59
+ const val TYPE_PIE = 13
60
+ const val TYPE_IMAGE = 14
61
+ const val TYPE_TEXT = 15
62
+ const val TYPE_TEXT_CACHED = 16
63
+ const val TYPE_TEXT_INLINE = 17
64
+ const val TYPE_PATH = 18
65
+
66
+ /** Wire codes of the half-block grid font (mode 20 indexes a font by byte, so glyphs need not be ASCII). */
67
+ val BLOCK_CODES: Map<Int, Int> = mapOf(32 to 32, 0x2580 to 65, 0x2584 to 66, 0x2588 to 67)
68
+
69
+ /** The warm-up dot: a hidden two-frame tween sent once per cache generation right after the RESET. */
70
+ val WARM_UP_ID: Int = G2CfwTextureCache.hash31("warm-up")
71
+ val WARM_UP_SHOW: ByteArray by lazy {
72
+ val dot = Slot(TYPE_CIRCLE_FILL, 0, 0, listOf(0, 0, 1))
73
+ showPayload(
74
+ puts = listOf(putObject(WARM_UP_ID, 1, objectRecord(dot))),
75
+ refs = listOf(WARM_UP_ID to 1),
76
+ ops = listOf(tweenOp(WARM_UP_ID, 0x01, 2, intArrayOf(0, 0, 255, 255), listOf(1))),
77
+ request = 0,
78
+ )
79
+ }
80
+ /** Mode 39: freeze, blank and keep everything cached. */
81
+ val HIDE_PAYLOAD: ByteArray = byteArrayOf(39, 0, 0, 0, 0, 0x01, 0)
82
+ /** Mode 41 sub 0: empty both caches and adopt the epoch the link stamps in. */
83
+ val RESET_PAYLOAD: ByteArray = byteArrayOf(41, 0, 0, 0, 0, 0)
84
+
85
+ /** Geometry parameters a TWEEN may interpolate, per type. Asset ids never tween. */
86
+ fun tweenableMask(type: Int): Int =
87
+ when (type) {
88
+ TYPE_PATH -> 0x07
89
+ TYPE_LINE -> 0x0F
90
+ TYPE_RECT, TYPE_RECT_FILL, TYPE_ARC, TYPE_PIE -> 0x1F
91
+ TYPE_CIRCLE, TYPE_CIRCLE_FILL -> 0x07
92
+ TYPE_TRI, TYPE_TRI_FILL, TYPE_BEZIER2 -> 0x3F
93
+ TYPE_QUAD, TYPE_QUAD_FILL, TYPE_BEZIER3 -> 0xFF
94
+ TYPE_IMAGE, TYPE_TEXT, TYPE_TEXT_CACHED -> 0x03
95
+ TYPE_TEXT_INLINE -> 0x0F
96
+ else -> 0
97
+ }
98
+
99
+ /** FNV-1a 64 of a tile's pixels: the asset key, so identical pixels share one upload. */
100
+ fun pixelHash(pixels: ByteArray): String {
101
+ var hash = -0x340d631b7bdddcdbL // 0xcbf29ce484222325
102
+ for (byte in pixels) {
103
+ hash = hash xor (byte.toLong() and 0xFF)
104
+ hash *= 0x100000001b3L
105
+ }
106
+ return java.lang.Long.toUnsignedString(hash, 36)
107
+ }
108
+
109
+ fun easingCurve(easing: String): IntArray =
110
+ when (easing) {
111
+ "linear" -> intArrayOf(0, 0, 255, 255)
112
+ "ease-in" -> intArrayOf(107, 0, 255, 255)
113
+ "ease-out" -> intArrayOf(0, 0, 148, 255)
114
+ "ease-in-out" -> intArrayOf(107, 0, 148, 255)
115
+ else -> intArrayOf(64, 26, 64, 255) // CSS "ease"
116
+ }
117
+
118
+ fun transitionFrames(ms: Int): Int {
119
+ if (ms <= 0) return 0
120
+ val frames = Math.round(ms / FRAME_PERIOD_MS).toInt()
121
+ return frames.coerceIn(2, 255)
122
+ }
123
+
124
+ /** UTF-8 bytes of a line without firmware control bytes (1..31), cut at a codepoint boundary. */
125
+ fun textBytes(line: String, limit: Int = MAX_TEXT_BYTES): ByteArray {
126
+ val out = ByteArrayOutputStream()
127
+ var i = 0
128
+ while (i < line.length) {
129
+ val cp = line.codePointAt(i)
130
+ i += Character.charCount(cp)
131
+ if (cp < 32) continue
132
+ val encoded = String(Character.toChars(cp)).toByteArray(Charsets.UTF_8)
133
+ if (out.size() + encoded.size > limit) break
134
+ out.write(encoded)
135
+ }
136
+ return out.toByteArray()
137
+ }
138
+
139
+ /** Object id of the k-th slot of an element: stable across frames and reopenings. */
140
+ fun objectId(elementId: String, k: Int): Int = G2CfwTextureCache.hash31("$elementId#$k")
141
+
142
+ /** Content version of a slot: a hash of the record the glasses store. */
143
+ fun versionOf(slot: Slot): Int = G2CfwTextureCache.hash31(objectRecord(slot))
144
+
145
+ private fun clampCoord(v: Int): Int = v.coerceIn(-1024, 1023)
146
+
147
+ private fun gray(element: SceneElement): Int = if (element.color < 0) 15 else element.color.coerceIn(0, 15)
148
+
149
+ private fun stroke(element: SceneElement): Int = element.border.coerceIn(1, 255)
150
+
151
+ /** The shape record the object cache stores for a slot (shapes.h layouts). */
152
+ fun objectRecord(slot: Slot): ByteArray {
153
+ val out = ByteArrayOutputStream()
154
+ when (slot.type) {
155
+ TYPE_PATH -> {
156
+ out.write(slot.type); out.write(1); out.write(slot.color); out.write(slot.fillRule)
157
+ for (k in 0..2) out.writeI16(slot.params[k])
158
+ out.write(slot.path.size and 0xFF); out.write((slot.path.size ushr 8) and 0xFF)
159
+ out.write(slot.path)
160
+ }
161
+ TYPE_TEXT_INLINE -> {
162
+ out.write(slot.type); out.write(1); out.write(slot.color); out.write(0)
163
+ for (k in 0..3) out.writeI16(slot.params[k])
164
+ val bytes = slot.text.take(MAX_INLINE_BYTES).toByteArray()
165
+ out.write(bytes.size); out.write(bytes)
166
+ }
167
+ TYPE_IMAGE, TYPE_TEXT, TYPE_TEXT_CACHED -> {
168
+ out.write(slot.type); out.write(1); out.write(slot.color); out.write(0)
169
+ for (k in 0..1) out.writeI16(slot.params[k])
170
+ out.writeU32(slot.asset)
171
+ if (slot.type == TYPE_TEXT_CACHED) out.writeU32(slot.font)
172
+ }
173
+ else -> {
174
+ out.write(slot.type); out.write(1); out.write(slot.color); out.write(slot.width)
175
+ for (k in 0 until 8) out.writeI16(slot.params[k])
176
+ }
177
+ }
178
+ return out.toByteArray()
179
+ }
180
+
181
+ /** `[1][id:u32][version:u32][record]` */
182
+ fun putObject(id: Int, version: Int, record: ByteArray): ByteArray {
183
+ val out = ByteArrayOutputStream()
184
+ out.write(1); out.writeU32(id); out.writeU32(version); out.write(record)
185
+ return out.toByteArray()
186
+ }
187
+
188
+ /** `[38][epoch 0][request][flags][bg][puts][put entries][count:u16][(id, version) x count][ops]` */
189
+ fun showPayload(puts: List<ByteArray>, refs: List<Pair<Int, Int>>, ops: List<ByteArray>, request: Int, tag: Boolean = false, bg: Int = 0): ByteArray {
190
+ val out = ByteArrayOutputStream()
191
+ out.write(38); out.write(0); out.write(0)
192
+ out.write(request and 0xFF); out.write((request ushr 8) and 0xFF)
193
+ out.write(if (tag) 0x05 else 0x01); out.write(bg and 0x0F); out.write(minOf(255, puts.size))
194
+ for (put in puts) out.write(put)
195
+ out.write(refs.size and 0xFF); out.write((refs.size ushr 8) and 0xFF)
196
+ for ((id, version) in refs) { out.writeU32(id); out.writeU32(version) }
197
+ for (op in ops) out.write(op)
198
+ return out.toByteArray()
199
+ }
200
+
201
+ /** `[9][id:u32][angle:i32][pivotX:i16][pivotY:i16][durationMs:u16][curve x4]` */
202
+ fun rotationOp(slot: Slot, id: Int, duration: Int, curve: IntArray): ByteArray {
203
+ if (!slot.rotatable) return ByteArray(0)
204
+ val out = ByteArrayOutputStream()
205
+ out.write(9); out.writeU32(id); out.writeU32(slot.angle)
206
+ out.writeI16(slot.pivotX); out.writeI16(slot.pivotY)
207
+ val ms = duration.coerceIn(0, 65535)
208
+ out.write(ms and 0xFF); out.write((ms ushr 8) and 0xFF)
209
+ for (i in 0..3) out.write(curve[i])
210
+ return out.toByteArray()
211
+ }
212
+
213
+ /** `[5][id:u32][mask:u16][frames][curve x4][value:i16 per set bit]` */
214
+ fun tweenOp(id: Int, mask: Int, frames: Int, curve: IntArray, values: List<Int>): ByteArray {
215
+ val out = ByteArrayOutputStream()
216
+ out.write(5); out.writeU32(id)
217
+ out.write(mask and 0xFF); out.write((mask ushr 8) and 0xFF); out.write(frames.coerceIn(0, 255))
218
+ for (i in 0..3) out.write(curve[i])
219
+ for (value in values) out.writeI16(value)
220
+ return out.toByteArray()
221
+ }
222
+
223
+ /**
224
+ * A TWEEN from `old` (what the glasses show) to `new`, or null when the change
225
+ * is not a pure geometry/stroke change of the same shape type. The mask covers
226
+ * every tweenable parameter (plus color and width where the type allows), not
227
+ * only the ones that changed: the firmware takes an unmasked parameter's end
228
+ * value from the object's CURRENT value, which is mid-flight while an earlier
229
+ * tween is still running.
230
+ */
231
+ fun tweenOp(old: Slot, new: Slot, id: Int, frames: Int, curve: IntArray): ByteArray? {
232
+ if (old.type != new.type || !old.text.contentEquals(new.text) || !old.path.contentEquals(new.path) ||
233
+ old.fillRule != new.fillRule || old.asset != new.asset || old.font != new.font
234
+ ) return null
235
+ val tweenable = tweenableMask(new.type)
236
+ for (k in 0 until 8) {
237
+ if (old.params[k] != new.params[k] && tweenable and (1 shl k) == 0) return null
238
+ }
239
+ val isTexture = new.type >= TYPE_IMAGE && new.type != TYPE_PATH
240
+ val textureFade = isTexture && (old.color and 0xF0) == (new.color and 0xF0)
241
+ if (old.color != new.color && isTexture && !textureFade) return null
242
+ if (old.width != new.width && isTexture) return null
243
+ var mask = tweenable
244
+ val values = mutableListOf<Int>()
245
+ for (k in 0 until 8) if (tweenable and (1 shl k) != 0) values.add(new.params[k])
246
+ if (!isTexture || textureFade) { mask = mask or 0x100; values.add(new.color) }
247
+ if (!isTexture && new.type != TYPE_PATH) { mask = mask or 0x200; values.add(new.width) }
248
+ if (mask == 0) return null
249
+ return tweenOp(id, mask, frames, curve, values)
250
+ }
251
+
252
+ /** Changing bitmaps benefit from compressed dirty rectangles; transitions still need objects. */
253
+ fun prefersRaster(frame: SceneFrame): Boolean =
254
+ frame.elements.isNotEmpty() && frame.elements.all { it.type == "image" && it.transitionMs == 0 && it.depth == 0 }
255
+ }
256
+
257
+ /**
258
+ * One object's record in decoded form: type, 4-bit color (or options byte), stroke width,
259
+ * eight int16 parameters, inline text bytes, path commands, and asset ids.
260
+ */
261
+ class Slot(
262
+ val type: Int, val color: Int, val width: Int, params: List<Int>,
263
+ val text: ByteArray = ByteArray(0), val path: ByteArray = ByteArray(0), val fillRule: Int = 0,
264
+ val asset: Int = 0, val font: Int = 0,
265
+ ) {
266
+ var angle: Int = 0
267
+ var pivotX: Int = 0
268
+ var pivotY: Int = 0
269
+ val rotatable: Boolean get() = type <= 13 || type == TYPE_PATH
270
+ val params: IntArray = IntArray(8) { params.getOrElse(it) { 0 } }
271
+
272
+ override fun equals(other: Any?): Boolean =
273
+ other is Slot && sameGeometry(other) && other.angle == angle && other.pivotX == pivotX && other.pivotY == pivotY
274
+
275
+ fun sameGeometry(other: Slot): Boolean = type == other.type && color == other.color && width == other.width &&
276
+ params.contentEquals(other.params) && text.contentEquals(other.text) && path.contentEquals(other.path) &&
277
+ fillRule == other.fillRule && asset == other.asset && font == other.font
278
+
279
+ override fun hashCode(): Int =
280
+ (((type * 31 + color) * 31 + width) * 31 + params.contentHashCode()) * 31 + text.contentHashCode()
281
+ }
282
+
283
+ /** What this mirror knows the glasses hold for one object. */
284
+ class Known(var version: Int, var definition: Slot, var state: Slot?, var animEndsAt: Long)
285
+
286
+ class Encoded(val payload: ByteArray, val uploads: List<ByteArray>, val changed: Boolean = true, val puts: Int = 0, val hits: Int = 0, val tweens: Int = 0)
287
+
288
+ class MonoFace(
289
+ val metrics: (Int) -> Pair<Int, Int>,
290
+ val glyph: (Int, Int, Int, Int) -> ByteArray?,
291
+ )
292
+
293
+ var generation = -1
294
+ private set
295
+ val known = mutableMapOf<Int, Known>()
296
+ var active: List<Int> = emptyList()
297
+ private set
298
+ var objectIds: List<List<Int>> = emptyList()
299
+ private set
300
+
301
+ fun reset(generation: Int) {
302
+ this.generation = generation
303
+ known.clear()
304
+ active = emptyList()
305
+ objectIds = emptyList()
306
+ }
307
+
308
+ /** Residency is unknown (a send failed): the next frame redefines everything it shows. */
309
+ fun invalidate() {
310
+ known.clear()
311
+ active = emptyList()
312
+ }
313
+
314
+ /** The panel went blank: objects animating right now froze mid-flight, so they are re-snapped when shown again. */
315
+ fun hidden() {
316
+ val now = System.currentTimeMillis()
317
+ for (id in active) known[id]?.let { if (it.animEndsAt > now) it.state = null }
318
+ active = emptyList()
319
+ }
320
+
321
+ /** The glasses reported these object ids absent (or at another version). */
322
+ fun forget(objectIds: Collection<Int>) {
323
+ for (id in objectIds) known.remove(id)
324
+ }
325
+
326
+ /**
327
+ * Become a copy of [other]: the second lens mirror starts from the shared
328
+ * state the moment the lenses diverge (the first frame with element depth).
329
+ */
330
+ fun adopt(other: G2CfwScene) {
331
+ generation = other.generation
332
+ known.clear()
333
+ for ((id, k) in other.known) known[id] = Known(k.version, k.definition, k.state, k.animEndsAt)
334
+ active = other.active
335
+ objectIds = other.objectIds
336
+ }
337
+
338
+ private fun point(element: SceneElement, i: Int, ox: Int, oy: Int): Pair<Int, Int> {
339
+ if (element.points.size < 2 * i + 2) return 0 to 0
340
+ return clampCoord(element.points[2 * i] + ox) to clampCoord(element.points[2 * i + 1] + oy)
341
+ }
342
+
343
+ private fun circleGeometry(element: SceneElement, ox: Int, oy: Int): Triple<Int, Int, Int> {
344
+ val r = maxOf(0, (minOf(element.w, element.h) - 1) / 2)
345
+ return Triple(clampCoord(element.x + element.w / 2 + ox), clampCoord(element.y + element.h / 2 + oy), r)
346
+ }
347
+
348
+ /** Slots for one element, or null when the asset store cannot hold its content. */
349
+ fun slotsFor(
350
+ element: SceneElement,
351
+ ox: Int,
352
+ oy: Int,
353
+ inlineText: Boolean,
354
+ cache: G2CfwTextureCache,
355
+ rasterize: (SceneElement, Int, Int) -> ByteArray?,
356
+ mono: MonoFace? = null,
357
+ ): List<Slot>? {
358
+ val x = clampCoord(element.x + ox)
359
+ val y = clampCoord(element.y + oy)
360
+ val w = element.w.coerceIn(0, 2047)
361
+ val h = element.h.coerceIn(0, 2047)
362
+ val color = gray(element)
363
+ val radius = element.radius.coerceIn(0, 2047)
364
+ return when (element.type) {
365
+ "svg" -> {
366
+ if (element.path.isEmpty() || element.path.size > 8192 || element.pathScale !in 0..2048) return null
367
+ listOf(Slot(TYPE_PATH, color, 0, listOf(x, y, element.pathScale), path = element.path.map { it.toByte() }.toByteArray(), fillRule = element.fillRule))
368
+ }
369
+ "rect" -> {
370
+ val out = mutableListOf<Slot>()
371
+ if (element.fill) {
372
+ out.add(Slot(TYPE_RECT_FILL, color, 0, listOf(x, y, w, h, radius)))
373
+ if (element.border > 0) out.add(Slot(TYPE_RECT, color, stroke(element), listOf(x, y, w, h, radius)))
374
+ } else {
375
+ out.add(Slot(TYPE_RECT, color, stroke(element), listOf(x, y, w, h, radius)))
376
+ }
377
+ out
378
+ }
379
+ "circle" -> {
380
+ val (cx, cy, r) = circleGeometry(element, ox, oy)
381
+ if (element.fill) listOf(Slot(TYPE_CIRCLE_FILL, color, 0, listOf(cx, cy, r)))
382
+ else listOf(Slot(TYPE_CIRCLE, color, stroke(element), listOf(cx, cy, r)))
383
+ }
384
+ "arc", "pie" -> {
385
+ val (cx, cy, r) = circleGeometry(element, ox, oy)
386
+ val a0 = clampCoord(element.startDeg)
387
+ val a1 = clampCoord(element.endDeg)
388
+ if (element.type == "pie") listOf(Slot(TYPE_PIE, color, 0, listOf(cx, cy, r, a0, a1)))
389
+ else listOf(Slot(TYPE_ARC, color, stroke(element), listOf(cx, cy, r, a0, a1)))
390
+ }
391
+ "line" -> {
392
+ val (x1, y1) = point(element, 0, ox, oy)
393
+ val (x2, y2) = point(element, 1, ox, oy)
394
+ listOf(Slot(TYPE_LINE, color, stroke(element), listOf(x1, y1, x2, y2)))
395
+ }
396
+ "triangle", "quad" -> {
397
+ val n = if (element.type == "triangle") 3 else 4
398
+ val params = (0 until n).flatMap { point(element, it, ox, oy).toList() }
399
+ val filled = if (n == 3) TYPE_TRI_FILL else TYPE_QUAD_FILL
400
+ val stroked = if (n == 3) TYPE_TRI else TYPE_QUAD
401
+ if (element.fill) listOf(Slot(filled, color, 0, params))
402
+ else listOf(Slot(stroked, color, stroke(element), params))
403
+ }
404
+ "bezier" -> {
405
+ val n = element.points.size / 2
406
+ if (n != 3 && n != 4) return emptyList()
407
+ val params = (0 until n).flatMap { point(element, it, ox, oy).toList() }
408
+ listOf(Slot(if (n == 3) TYPE_BEZIER2 else TYPE_BEZIER3, color, stroke(element), params))
409
+ }
410
+ "text" ->
411
+ when {
412
+ element.font == "mono" ->
413
+ if (mono == null) rasterSlots(element, x, y, cache, rasterize)
414
+ else monoTextSlots(element, x, y, cache, mono, rasterize)
415
+ inlineText -> inlineTextSlots(element, x, y)
416
+ element.font == "builtin" -> builtinTextSlots(element, x, y, cache)
417
+ else -> rasterSlots(element, x, y, cache, rasterize)
418
+ }
419
+ "image" -> rasterSlots(element, x, y, cache, rasterize)
420
+ else -> emptyList()
421
+ }
422
+ }
423
+
424
+ /** One TEXT_INLINE object per line, drawn by the glasses' font at the phone-raster 2 px inset, clipped to the box. */
425
+ private fun inlineTextSlots(element: SceneElement, x: Int, y: Int): List<Slot> {
426
+ val out = mutableListOf<Slot>()
427
+ val w = element.w.coerceIn(0, 2047)
428
+ val h = element.h.coerceIn(0, 2047)
429
+ if (element.border > 0) {
430
+ out.add(Slot(TYPE_RECT, gray(element), stroke(element), listOf(x, y, w, h, element.radius.coerceIn(0, 2047))))
431
+ }
432
+ val options = 0x10 or gray(element)
433
+ val maxLines = maxOf(1, element.h / TEXT_LINE_HEIGHT)
434
+ val lines = (element.text ?: "").split("\n").take(maxLines)
435
+ for ((index, line) in lines.withIndex()) {
436
+ val bytes = textBytes(line, MAX_INLINE_BYTES)
437
+ if (bytes.isEmpty()) continue
438
+ val lineY = y + TEXT_INSET + index * TEXT_LINE_HEIGHT
439
+ val clipW = maxOf(0, element.w - TEXT_INSET).coerceAtMost(2047)
440
+ val clipH = maxOf(0, element.h - TEXT_INSET - index * TEXT_LINE_HEIGHT).coerceAtMost(2047)
441
+ out.add(Slot(TYPE_TEXT_INLINE, options, 0, listOf(clampCoord(x + TEXT_INSET), clampCoord(lineY), clipW, clipH, bytes.size), bytes))
442
+ }
443
+ return out
444
+ }
445
+
446
+ private fun builtinTextSlots(element: SceneElement, x: Int, y: Int, cache: G2CfwTextureCache): List<Slot>? {
447
+ val out = mutableListOf<Slot>()
448
+ if (element.border > 0) {
449
+ out.add(Slot(TYPE_RECT, 15, stroke(element), listOf(x, y, element.w.coerceIn(0, 2047), element.h.coerceIn(0, 2047), element.radius.coerceIn(0, 2047))))
450
+ }
451
+ val maxLines = maxOf(1, element.h / TEXT_LINE_HEIGHT)
452
+ val lines = (element.text ?: "").split("\n").take(maxLines)
453
+ for ((index, line) in lines.withIndex()) {
454
+ val bytes = textBytes(line)
455
+ if (bytes.isEmpty()) continue
456
+ val asset = cache.ensureBytes("t:$line", bytes) ?: return null
457
+ out.add(Slot(TYPE_TEXT, TEXT_OPTIONS, 0, listOf(x, clampCoord(y + index * TEXT_LINE_HEIGHT)), asset = asset))
458
+ }
459
+ return out
460
+ }
461
+
462
+ /**
463
+ * Mono text as TEXT_CACHED runs: each line is a string asset drawn with the font asset for
464
+ * its size, so glyphs upload once per generation and a changed string costs only its bytes.
465
+ * Cells sit on the raster path's grid (2 px inset, partial edge cells kept); runs split at
466
+ * the 64-byte limit. A separate font maps half-block grids to printable wire bytes; other
467
+ * non-ASCII text keeps the image-tile path.
468
+ */
469
+ private fun monoTextSlots(
470
+ element: SceneElement,
471
+ x: Int,
472
+ y: Int,
473
+ cache: G2CfwTextureCache,
474
+ mono: MonoFace,
475
+ rasterize: (SceneElement, Int, Int) -> ByteArray?,
476
+ ): List<Slot>? {
477
+ val (cellW, lineH) = mono.metrics(element.fontSize)
478
+ if (cellW <= 0 || lineH <= 0) return emptyList()
479
+ val cols = maxOf(0, (element.w - TEXT_INSET + cellW - 1) / cellW)
480
+ val rows = maxOf(0, (element.h - TEXT_INSET + lineH - 1) / lineH)
481
+ val lines = (element.text ?: "").split('\n').take(rows).map { it.codePoints().toArray().take(cols) }
482
+ val blockGrid = lines.any { line -> line.any { it > 126 } } && lines.all { line -> line.all { BLOCK_CODES.containsKey(it) } }
483
+ if (!blockGrid && lines.any { line -> line.any { it < 32 || it > 126 } }) return rasterSlots(element, x, y, cache, rasterize)
484
+ val variant = if (blockGrid) "blocks" else "ascii"
485
+ val codes = if (blockGrid) listOf(32, 65, 66, 67) else (32..126).toList()
486
+ val scalarFor: (Int) -> Int? = { code -> if (blockGrid) BLOCK_CODES.entries.firstOrNull { it.value == code }?.key else code }
487
+ val font = cache.font(element.fontSize, cellW, lineH, variant, codes) { code ->
488
+ scalarFor(code)?.let { mono.glyph(it, element.fontSize, cellW, lineH) }
489
+ } ?: return null
490
+ // Opaque cells (options bit 4 clear): the grid owns its cells, as the raster path's blits do.
491
+ val options = gray(element)
492
+ val out = mutableListOf<Slot>()
493
+ for ((row, line) in lines.withIndex()) {
494
+ if (line.isEmpty()) continue
495
+ val lineY = clampCoord(y + TEXT_INSET + row * lineH)
496
+ var start = 0
497
+ while (start < line.size) {
498
+ val run = line.subList(start, minOf(line.size, start + MAX_TEXT_BYTES))
499
+ val bytes = ByteArray(run.size) { (if (blockGrid) BLOCK_CODES[run[it]]!! else run[it]).toByte() }
500
+ // A glyph the font could not raster keeps the tile path for the element.
501
+ if (bytes.any { val c = it.toInt() and 0xFF; c != 32 && c !in font.glyphs }) return rasterSlots(element, x, y, cache, rasterize)
502
+ val text = String(run.toIntArray(), 0, run.size)
503
+ val asset = cache.ensureBytes("t:$variant:$text", bytes) ?: return null
504
+ out.add(Slot(TYPE_TEXT_CACHED, options, 0, listOf(clampCoord(x + TEXT_INSET + start * cellW), lineY), asset = asset, font = font.id))
505
+ start += run.size
506
+ }
507
+ }
508
+ return out
509
+ }
510
+
511
+ private fun rasterSlots(
512
+ element: SceneElement,
513
+ x: Int,
514
+ y: Int,
515
+ cache: G2CfwTextureCache,
516
+ rasterize: (SceneElement, Int, Int) -> ByteArray?,
517
+ ): List<Slot>? {
518
+ val w = element.w
519
+ val h = element.h
520
+ if (w <= 0 || h <= 0) return emptyList()
521
+ val gray = rasterize(element, w, h)?.takeIf { it.size == w * h } ?: return emptyList()
522
+ // An image's ink is the ramp's top color; rasterized text keeps its ink in the pixels.
523
+ val options = if (element.type == "image") gray(element) else IMAGE_OPTIONS
524
+ val out = mutableListOf<Slot>()
525
+ var ty = 0
526
+ while (ty < h) {
527
+ val th = minOf(MAX_TILE_SIZE, h - ty)
528
+ var tx = 0
529
+ while (tx < w) {
530
+ val tw = minOf(MAX_TILE_SIZE, w - tx)
531
+ val tile = ByteArray(tw * th)
532
+ for (row in 0 until th) System.arraycopy(gray, (ty + row) * w + tx, tile, row * tw, tw)
533
+ val key = "px:${pixelHash(tile)}@${tw}x$th"
534
+ val asset = cache.ensureImage(key, tile, tw, th) ?: return null
535
+ out.add(Slot(TYPE_IMAGE, options, 0, listOf(clampCoord(x + tx), clampCoord(y + ty)), asset = asset))
536
+ tx += tw
537
+ }
538
+ ty += th
539
+ }
540
+ return out
541
+ }
542
+
543
+ private class Desired(val id: String, val slots: List<Slot>, val frames: Int, val curve: IntArray, val duration: Int)
544
+
545
+ /**
546
+ * One SHOW (embedded object PUTs, references in paint order, ops) and the asset PUT
547
+ * messages that must precede it. Null when the frame does not fit the object table or
548
+ * the asset store; the caller then falls back to the raster path. `tag` (the host frame
549
+ * number) becomes the SHOW's request id with the TAG flag, so settings field 129 reports
550
+ * it once the scene settles.
551
+ */
552
+ fun encode(
553
+ frame: SceneFrame,
554
+ originX: Int,
555
+ originY: Int,
556
+ cache: G2CfwTextureCache,
557
+ rasterize: (SceneElement, Int, Int) -> ByteArray?,
558
+ inlineText: Boolean = false,
559
+ mono: MonoFace? = null,
560
+ tag: Int? = null,
561
+ /** +1 for the left lens mirror, -1 for the right: each element shifts by depth × parallax px; 0 draws flat. */
562
+ parallax: Int = 0,
563
+ ): Encoded? {
564
+ val desired = mutableListOf<Desired>()
565
+ for (element in frame.elements) {
566
+ val ox = originX + element.depth * parallax
567
+ val slots = slotsFor(element, ox, originY, inlineText, cache, rasterize, mono) ?: return null
568
+ slots.filter { it.rotatable }.forEach { slot ->
569
+ slot.angle = element.rotationQ8
570
+ slot.pivotX = ((element.pivotX ?: (element.x + element.w / 2)) + ox).coerceIn(-32768, 32767)
571
+
572
+ slot.pivotY = ((element.pivotY ?: (element.y + element.h / 2)) + originY).coerceIn(-32768, 32767)
573
+ }
574
+ desired.add(Desired(element.id, slots, transitionFrames(element.transitionMs), easingCurve(element.easing), element.transitionMs))
575
+ }
576
+ if (desired.sumOf { it.slots.size } > MAX_OBJECTS) return null
577
+
578
+ val now = System.currentTimeMillis()
579
+ val puts = mutableListOf<ByteArray>()
580
+ val refs = mutableListOf<Pair<Int, Int>>()
581
+ val ops = mutableListOf<ByteArray>()
582
+ var putCount = 0
583
+ var hits = 0
584
+ var tweens = 0
585
+ val ids = mutableListOf<List<Int>>()
586
+ val seen = mutableSetOf<Int>()
587
+ // Only objects the previous frame showed may animate: one coming back to
588
+ // the frame appears where it is asked to be, not tweened from where it left.
589
+ val onScreen = active.toSet()
590
+ for (item in desired) {
591
+ val mine = mutableListOf<Int>()
592
+ for ((k, slot) in item.slots.withIndex()) {
593
+ val id = objectId(item.id, k)
594
+ if (!seen.add(id)) continue
595
+ mine.add(id)
596
+ val version = versionOf(slot)
597
+ fun put() {
598
+ puts.add(putObject(id, version, objectRecord(slot)))
599
+ if (slot.angle != 0) ops.add(rotationOp(slot, id, 0, item.curve))
600
+ putCount += 1
601
+ known[id] = Known(version, slot, slot, 0)
602
+ refs.add(id to version)
603
+ }
604
+ val k0 = known[id]
605
+ if (k0 == null) { put(); continue }
606
+ val animate = id in onScreen
607
+ val current = k0.state
608
+ if (current != null) {
609
+ if (current == slot) { hits += 1; refs.add(id to k0.version); continue }
610
+ val rotationChanged = current.angle != slot.angle ||
611
+ ((current.angle != 0 || slot.angle != 0) && (current.pivotX != slot.pivotX || current.pivotY != slot.pivotY))
612
+ if (current.sameGeometry(slot)) {
613
+ if (rotationChanged) {
614
+ val duration = if (animate) item.duration else 0
615
+ ops.add(rotationOp(slot, id, duration, item.curve))
616
+ k0.animEndsAt = now + duration
617
+ }
618
+ k0.state = slot
619
+ hits += 1
620
+ refs.add(id to k0.version)
621
+ continue
622
+ }
623
+ if (animate && item.frames >= 2) {
624
+ val tween = tweenOp(current, slot, id, item.frames, item.curve)
625
+ if (tween != null) {
626
+ ops.add(tween)
627
+ if (rotationChanged) ops.add(rotationOp(slot, id, item.duration, item.curve))
628
+ tweens += 1
629
+ k0.state = slot
630
+ k0.animEndsAt = now + (item.frames * FRAME_PERIOD_MS).toLong()
631
+ refs.add(id to k0.version)
632
+ continue
633
+ }
634
+ }
635
+ // The glasses hold this exact definition and earlier ops only moved it:
636
+ // a same-version PUT is a no-op there, so snap the state instead.
637
+ if (k0.version == version) {
638
+ val snap = tweenOp(current, slot, id, 0, item.curve)
639
+ if (snap != null) {
640
+ ops.add(snap)
641
+ if (rotationChanged) ops.add(rotationOp(slot, id, 0, item.curve))
642
+ k0.state = slot
643
+ k0.animEndsAt = 0
644
+ hits += 1
645
+ refs.add(id to k0.version)
646
+ continue
647
+ }
648
+ }
649
+ put()
650
+ continue
651
+ }
652
+
653
+ // Frozen mid-flight by a hide: snap it back to the geometry it should show.
654
+ val snap = tweenOp(k0.definition, slot, id, 0, item.curve)
655
+ if (snap != null) {
656
+ ops.add(snap)
657
+ if (slot.angle != 0 || k0.definition.angle != 0) ops.add(rotationOp(slot, id, 0, item.curve))
658
+ k0.state = slot
659
+ hits += 1
660
+ refs.add(id to k0.version)
661
+ continue
662
+ }
663
+ put()
664
+ }
665
+ ids.add(mine)
666
+ }
667
+ val listChanged = refs.map { it.first } != active
668
+ active = refs.map { it.first }
669
+ objectIds = ids
670
+ val payload = showPayload(puts, refs, ops, request = (tag ?: 0) and 0xFFFF, tag = (tag ?: 0) > 0)
671
+ return Encoded(payload, cache.takeUploadPayloads(), listChanged || puts.isNotEmpty() || ops.isNotEmpty(), putCount, hits, tweens)
672
+ }
673
+ }