@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,779 @@
1
+ import Foundation
2
+
3
+ /// Retained object cache encoder for the Glassly CFW (GLASSLYCFW/38, modes 37-41).
4
+ ///
5
+ /// Every scene element becomes one or more OBJECTS with stable 32-bit ids
6
+ /// (derived from the element id and the object's position within the element):
7
+ /// vector shapes map 1:1; default and builtin text becomes one inline TEXT
8
+ /// object per line drawn by the glasses' font; mono text becomes TEXT_CACHED
9
+ /// runs drawn with a font asset (uploaded whole, once per cache generation);
10
+ /// images are rasterized on the phone and drawn as IMAGE objects tiled to the
11
+ /// 255 px limit, keyed by their pixels so identical images share one asset.
12
+ ///
13
+ /// A frame is one SHOW: the ordered (id, version) references of what is on
14
+ /// screen, the embedded PUTs for objects the glasses do not hold yet (new, or
15
+ /// changed content: a version is a hash of the record), and TWEEN/ROTATE ops for
16
+ /// animated changes of geometry the glasses already hold. Objects that leave a
17
+ /// frame stay cached on the glasses (and in this mirror) until the firmware
18
+ /// evicts them least-recently-used, so a view coming back after a HIDE is pure
19
+ /// references. The firmware validates every reference; a MISSING reply names
20
+ /// what to redefine (`forget(objectIds:)`).
21
+ ///
22
+ /// One instance mirrors one lens. Element `depth` is a stereo disparity: a
23
+ /// lens mirror encoding with `parallax` ±1 shifts each element by
24
+ /// `depth × parallax` px, so the renderer keeps a left and a right mirror once
25
+ /// a frame uses depth and sends each lens its own SHOW (`G2CfwRenderer`).
26
+ final class G2CfwScene {
27
+ static let maxObjects = 256
28
+ static let framePeriodMs = 33.0
29
+ static let textLineHeight = 27
30
+ static let textInset = 2
31
+ static let maxTextBytes = 64
32
+ static let maxInlineBytes = 128
33
+ static let maxTileSize = 255
34
+ /// Mode-15/20 options: identity ramp, transparent source color 0.
35
+ static let textOptions: UInt8 = 0x1F
36
+ static let imageOptions: UInt8 = 0x0F
37
+ static let allObjects: UInt32 = 0xFFFF_FFFF
38
+
39
+ enum ShapeType: UInt8 {
40
+ case line = 1, rect, rectFill, circle, circleFill, tri, triFill, quad, quadFill
41
+ case bezier2, bezier3, arc, pie, image, text, textCached, textInline, path
42
+ }
43
+
44
+ /// The id of the warm-up dot and its SHOW: a hidden two-frame tween on a
45
+ /// black 1 px dot, sent once per cache generation right after the RESET so
46
+ /// the first visible transition of a session is not also the first
47
+ /// animation the firmware ever runs (it creates its timer lazily).
48
+ static let warmUpId = G2CfwTextureCache.hash31("warm-up")
49
+ static let warmUpShow: Data = {
50
+ let dot = Slot(.circleFill, color: 0, [0, 0, 1])
51
+ return showPayload(
52
+ puts: [putObject(id: warmUpId, version: 1, record: objectRecord(dot))],
53
+ refs: [(warmUpId, 1)],
54
+ ops: [tweenOp(id: warmUpId, mask: 0x01, frames: 2, curve: [0, 0, 255, 255], values: [1])],
55
+ request: 0
56
+ )
57
+ }()
58
+ /// Mode 39: freeze, blank and keep everything cached.
59
+ static let hidePayload = Data([39, 0, 0, 0, 0, 0x01, 0])
60
+ /// Mode 41 sub 0: empty both caches and adopt the epoch the link stamps in.
61
+ static let resetPayload = Data([41, 0, 0, 0, 0, 0])
62
+
63
+ struct Slot: Equatable {
64
+ var type: UInt8
65
+ var color: UInt8
66
+ var width: UInt8
67
+ var params: [Int16]
68
+ /// TEXT_INLINE bytes carried in the record.
69
+ var text: [UInt8]
70
+ var path: [UInt8]
71
+ var fillRule: UInt8
72
+ /// Asset ids for IMAGE (image), TEXT (string), TEXT_CACHED (string + font).
73
+ var asset: UInt32 = 0
74
+ var font: UInt32 = 0
75
+ var angle: Int32 = 0
76
+ var pivotX: Int16 = 0
77
+ var pivotY: Int16 = 0
78
+
79
+ func sameGeometry(as other: Slot) -> Bool {
80
+ type == other.type && color == other.color && width == other.width && params == other.params
81
+ && text == other.text && path == other.path && fillRule == other.fillRule
82
+ && asset == other.asset && font == other.font
83
+ }
84
+
85
+ var rotatable: Bool {
86
+ type <= 13 || type == 18
87
+ }
88
+
89
+ init(_ type: ShapeType, color: UInt8, width: UInt8 = 0, _ params: [Int16], text: [UInt8] = [], path: [UInt8] = [],
90
+ fillRule: UInt8 = 0, asset: UInt32 = 0, font: UInt32 = 0) {
91
+ self.type = type.rawValue
92
+ self.color = color
93
+ self.width = width
94
+ var padded = params
95
+ while padded.count < 8 {
96
+ padded.append(0)
97
+ }
98
+ self.params = Array(padded.prefix(8))
99
+ self.text = text
100
+ self.path = path
101
+ self.fillRule = fillRule
102
+ self.asset = asset
103
+ self.font = font
104
+ }
105
+ }
106
+
107
+ /// What this mirror knows the glasses hold for one object.
108
+ struct Known {
109
+ /// Version of the definition resident on the glasses and what it defines.
110
+ var version: UInt32
111
+ var definition: Slot
112
+ /// The geometry the glasses currently show (ops move it without a new
113
+ /// version); nil after a hide mid-flight, when it must be re-snapped.
114
+ var state: Slot?
115
+ var animEndsAt: TimeInterval
116
+ }
117
+
118
+ struct Encoded {
119
+ /// The SHOW.
120
+ let payload: Data
121
+ /// Standalone PUT messages (asset uploads) that must precede it.
122
+ let uploads: [Data]
123
+ /// False when the frame is the one already on screen (nothing to send).
124
+ let changed: Bool
125
+ /// Objects the frame redefined / referenced from the cache / tweened.
126
+ let puts: Int
127
+ let hits: Int
128
+ let tweens: Int
129
+ }
130
+
131
+ /// Changing bitmaps benefit from compressed dirty rectangles, without
132
+ /// allocating new retained textures every frame. Transitions still need objects.
133
+ static func prefersRaster(_ frame: SceneFrame) -> Bool {
134
+ !frame.elements.isEmpty && frame.elements.allSatisfy { $0.type == "image" && $0.transitionMs == 0 && $0.depth == 0 }
135
+ }
136
+
137
+ /// The phone's monospaced face for TEXT_CACHED runs: fixed cell metrics per
138
+ /// font size, and the full-cell glyph raster (cellWidth × lineHeight, 8-bit
139
+ /// gray, white ink) for one char — the same raster the frame blits use.
140
+ struct MonoFace {
141
+ let metrics: (Int32) -> (cellWidth: Int, lineHeight: Int)
142
+ let glyph: (Unicode.Scalar, Int32, Int, Int) -> Data?
143
+ }
144
+
145
+ private(set) var generation = -1
146
+ private(set) var known: [UInt32: Known] = [:]
147
+ /// Object ids of the frame on screen, in paint order; empty after a hide.
148
+ private(set) var active: [UInt32] = []
149
+ /// Element id → its object ids, for the last encoded frame.
150
+ private(set) var objectIds: [[UInt32]] = []
151
+
152
+ /// Forget everything: the on-glass cache is empty or unknown.
153
+ func reset(generation: Int) {
154
+ self.generation = generation
155
+ known.removeAll()
156
+ active.removeAll()
157
+ objectIds.removeAll()
158
+ }
159
+
160
+ /// The residency of every object is unknown (a send failed): the next frame redefines everything it shows.
161
+ func invalidate() {
162
+ known.removeAll()
163
+ active.removeAll()
164
+ }
165
+
166
+ /// The panel went blank (HIDE or a raster takeover): objects animating right
167
+ /// now froze mid-flight on the glasses, so they are re-snapped when shown again.
168
+ func hidden() {
169
+ let now = Date().timeIntervalSince1970
170
+ for id in active {
171
+ if let k = known[id], k.animEndsAt > now {
172
+ known[id]?.state = nil
173
+ }
174
+ }
175
+ active.removeAll()
176
+ }
177
+
178
+ /// The glasses reported these object ids absent (or at another version).
179
+ func forget(objectIds ids: [UInt32]) {
180
+ for id in ids {
181
+ known.removeValue(forKey: id)
182
+ }
183
+ }
184
+
185
+ /// Become a copy of `other`: the second lens mirror starts from the shared
186
+ /// state the moment the lenses diverge (the first frame with depth).
187
+ func adopt(_ other: G2CfwScene) {
188
+ generation = other.generation
189
+ known = other.known
190
+ active = other.active
191
+ objectIds = other.objectIds
192
+ }
193
+
194
+ // MARK: - Element → slots
195
+
196
+ /// Geometry parameters a TWEEN may interpolate, per type. Asset ids never tween.
197
+ static func tweenableMask(_ type: UInt8) -> UInt16 {
198
+ switch ShapeType(rawValue: type) {
199
+ case .path: return 0x07
200
+ case .line: return 0x0F
201
+ case .rect, .rectFill, .arc, .pie: return 0x1F
202
+ case .circle, .circleFill: return 0x07
203
+ case .tri, .triFill, .bezier2: return 0x3F
204
+ case .quad, .quadFill, .bezier3: return 0xFF
205
+ case .image, .text, .textCached: return 0x03
206
+ case .textInline: return 0x0F
207
+ case .none: return 0
208
+ }
209
+ }
210
+
211
+ static func easingCurve(_ easing: String) -> [UInt8] {
212
+ switch easing {
213
+ case "linear": return [0, 0, 255, 255]
214
+ case "ease-in": return [107, 0, 255, 255]
215
+ case "ease-out": return [0, 0, 148, 255]
216
+ case "ease-in-out": return [107, 0, 148, 255]
217
+ default: return [64, 26, 64, 255] // CSS "ease"
218
+ }
219
+ }
220
+
221
+ /// FNV-1a 64 of a tile's pixels: the asset key, so elements with the same
222
+ /// pixels (one icon on several cards, at any ink) share one upload.
223
+ static func pixelHash(_ pixels: Data) -> String {
224
+ var hash: UInt64 = 0xCBF2_9CE4_8422_2325
225
+ for byte in pixels {
226
+ hash ^= UInt64(byte)
227
+ hash = hash &* 0x0000_0100_0000_01B3
228
+ }
229
+ return String(hash, radix: 36)
230
+ }
231
+
232
+ static func transitionFrames(ms: Int32) -> UInt8 {
233
+ guard ms > 0 else { return 0 }
234
+ let frames = Int((Double(ms) / framePeriodMs).rounded())
235
+ return UInt8(max(2, min(255, frames)))
236
+ }
237
+
238
+ /// Object id of the k-th slot of an element: stable across frames and reopenings.
239
+ static func objectId(_ elementId: String, _ k: Int) -> UInt32 {
240
+ G2CfwTextureCache.hash31("\(elementId)#\(k)")
241
+ }
242
+
243
+ /// Content version of a slot: a hash of the record the glasses store.
244
+ static func version(of slot: Slot) -> UInt32 {
245
+ G2CfwTextureCache.hash31(objectRecord(slot))
246
+ }
247
+
248
+ private static func gray(_ element: SceneElement) -> UInt8 {
249
+ element.color < 0 ? 15 : UInt8(max(0, min(15, element.color)))
250
+ }
251
+
252
+ private static func stroke(_ element: SceneElement) -> UInt8 {
253
+ UInt8(max(1, min(255, element.border)))
254
+ }
255
+
256
+ private static func point(_ element: SceneElement, _ i: Int, ox: Int, oy: Int) -> (Int16, Int16) {
257
+ guard element.points.count >= 2 * i + 2 else { return (0, 0) }
258
+ return (clampCoord(Int(element.points[2 * i]) + ox), clampCoord(Int(element.points[2 * i + 1]) + oy))
259
+ }
260
+
261
+ private static func clampCoord(_ v: Int) -> Int16 {
262
+ Int16(max(-1024, min(1023, v)))
263
+ }
264
+
265
+ private static func circleGeometry(_ element: SceneElement, ox: Int, oy: Int) -> (Int16, Int16, Int16) {
266
+ let w = Int(element.w), h = Int(element.h)
267
+ let r = max(0, (min(w, h) - 1) / 2)
268
+ return (clampCoord(Int(element.x) + w / 2 + ox), clampCoord(Int(element.y) + h / 2 + oy), Int16(r))
269
+ }
270
+
271
+ /// Slots for one element, or nil when the asset store cannot hold its content.
272
+ /// `inlineText` selects TEXT_INLINE records for default/builtin text; without
273
+ /// `mono`, mono text falls back to image tiles.
274
+ func slots(
275
+ for element: SceneElement, originX ox: Int, originY oy: Int, inlineText: Bool,
276
+ mono: MonoFace? = nil, cache: G2CfwTextureCache, rasterize: (SceneElement, Int, Int) -> Data?
277
+ ) -> [Slot]? {
278
+ let x = Self.clampCoord(Int(element.x) + ox)
279
+ let y = Self.clampCoord(Int(element.y) + oy)
280
+ let w = Int16(max(0, min(2047, Int(element.w))))
281
+ let h = Int16(max(0, min(2047, Int(element.h))))
282
+ let color = Self.gray(element)
283
+ let radius = Int16(max(0, min(2047, Int(element.radius))))
284
+ switch element.type {
285
+ case "svg":
286
+ guard !element.path.isEmpty, element.path.count <= 8192, (0 ... 2048).contains(element.pathScale) else { return nil }
287
+ return [Slot(.path, color: color, [x, y, Int16(element.pathScale)], path: element.path, fillRule: UInt8(clamping: element.fillRule))]
288
+ case "rect":
289
+ var out: [Slot] = []
290
+ if element.fill {
291
+ out.append(Slot(.rectFill, color: color, [x, y, w, h, radius]))
292
+ if element.border > 0 {
293
+ out.append(Slot(.rect, color: color, width: Self.stroke(element), [x, y, w, h, radius]))
294
+ }
295
+ } else {
296
+ out.append(Slot(.rect, color: color, width: Self.stroke(element), [x, y, w, h, radius]))
297
+ }
298
+ return out
299
+ case "circle":
300
+ let (cx, cy, r) = Self.circleGeometry(element, ox: ox, oy: oy)
301
+ if element.fill {
302
+ return [Slot(.circleFill, color: color, [cx, cy, r])]
303
+ }
304
+ return [Slot(.circle, color: color, width: Self.stroke(element), [cx, cy, r])]
305
+ case "arc", "pie":
306
+ let (cx, cy, r) = Self.circleGeometry(element, ox: ox, oy: oy)
307
+ let a0 = Self.clampCoord(Int(element.startDeg)), a1 = Self.clampCoord(Int(element.endDeg))
308
+ if element.type == "pie" {
309
+ return [Slot(.pie, color: color, [cx, cy, r, a0, a1])]
310
+ }
311
+ return [Slot(.arc, color: color, width: Self.stroke(element), [cx, cy, r, a0, a1])]
312
+ case "line":
313
+ let (x1, y1) = Self.point(element, 0, ox: ox, oy: oy)
314
+ let (x2, y2) = Self.point(element, 1, ox: ox, oy: oy)
315
+ return [Slot(.line, color: color, width: Self.stroke(element), [x1, y1, x2, y2])]
316
+ case "triangle", "quad":
317
+ let n = element.type == "triangle" ? 3 : 4
318
+ var params: [Int16] = []
319
+ for i in 0 ..< n {
320
+ let (px, py) = Self.point(element, i, ox: ox, oy: oy)
321
+ params.append(contentsOf: [px, py])
322
+ }
323
+ let filled: ShapeType = n == 3 ? .triFill : .quadFill
324
+ let stroked: ShapeType = n == 3 ? .tri : .quad
325
+ if element.fill {
326
+ return [Slot(filled, color: color, params)]
327
+ }
328
+ return [Slot(stroked, color: color, width: Self.stroke(element), params)]
329
+ case "bezier":
330
+ let n = element.points.count / 2
331
+ guard n == 3 || n == 4 else { return [] }
332
+ var params: [Int16] = []
333
+ for i in 0 ..< n {
334
+ let (px, py) = Self.point(element, i, ox: ox, oy: oy)
335
+ params.append(contentsOf: [px, py])
336
+ }
337
+ return [Slot(n == 3 ? .bezier2 : .bezier3, color: color, width: Self.stroke(element), params)]
338
+ case "text" where element.font == "mono":
339
+ guard let mono = mono else { return rasterSlots(element, x: x, y: y, cache: cache, rasterize: rasterize) }
340
+ return monoTextSlots(element, x: x, y: y, cache: cache, mono: mono, rasterize: rasterize)
341
+ case "text" where inlineText:
342
+ return inlineTextSlots(element, x: x, y: y)
343
+ case "text" where element.font == "builtin":
344
+ return builtinTextSlots(element, x: x, y: y, cache: cache)
345
+ case "text", "image":
346
+ return rasterSlots(element, x: x, y: y, cache: cache, rasterize: rasterize)
347
+ default:
348
+ return []
349
+ }
350
+ }
351
+
352
+ private func builtinTextSlots(_ element: SceneElement, x: Int16, y: Int16, cache: G2CfwTextureCache) -> [Slot]? {
353
+ var out: [Slot] = []
354
+ if element.border > 0 {
355
+ out.append(Slot(.rect, color: 15, width: Self.stroke(element),
356
+ [x, y, Int16(min(2047, Int(element.w))), Int16(min(2047, Int(element.h))),
357
+ Int16(max(0, min(2047, Int(element.radius))))]))
358
+ }
359
+ let maxLines = max(1, Int(element.h) / Self.textLineHeight)
360
+ let lines = (element.text ?? "").split(separator: "\n", omittingEmptySubsequences: false).prefix(maxLines)
361
+ for (index, line) in lines.enumerated() {
362
+ let bytes = Self.textBytes(String(line))
363
+ if bytes.isEmpty {
364
+ continue
365
+ }
366
+ guard let asset = cache.ensureBytes(key: "t:" + String(line), bytes: bytes) else { return nil }
367
+ let lineY = Int(y) + index * Self.textLineHeight
368
+ out.append(Slot(.text, color: Self.textOptions, [x, Self.clampCoord(lineY)], asset: asset))
369
+ }
370
+ return out
371
+ }
372
+
373
+ /// One TEXT_INLINE object per line, drawn by the glasses' font at the same 2 px
374
+ /// inset as phone-rasterized text, clipped to the box.
375
+ private func inlineTextSlots(_ element: SceneElement, x: Int16, y: Int16) -> [Slot] {
376
+ var out: [Slot] = []
377
+ let w = Int16(max(0, min(2047, Int(element.w))))
378
+ let h = Int16(max(0, min(2047, Int(element.h))))
379
+ if element.border > 0 {
380
+ out.append(Slot(.rect, color: Self.gray(element), width: Self.stroke(element),
381
+ [x, y, w, h, Int16(max(0, min(2047, Int(element.radius))))]))
382
+ }
383
+ let options = UInt8(0x10) | Self.gray(element)
384
+ let maxLines = max(1, Int(element.h) / Self.textLineHeight)
385
+ let lines = (element.text ?? "").split(separator: "\n", omittingEmptySubsequences: false).prefix(maxLines)
386
+ for (index, line) in lines.enumerated() {
387
+ let bytes = Self.textBytes(String(line), max: Self.maxInlineBytes)
388
+ if bytes.isEmpty {
389
+ continue
390
+ }
391
+ let lineY = Int(y) + Self.textInset + index * Self.textLineHeight
392
+ let clipW = max(0, Int(element.w) - Self.textInset)
393
+ let clipH = max(0, Int(element.h) - Self.textInset - index * Self.textLineHeight)
394
+ out.append(Slot(.textInline, color: options,
395
+ [Self.clampCoord(Int(x) + Self.textInset), Self.clampCoord(lineY),
396
+ Int16(min(2047, clipW)), Int16(min(2047, clipH)), Int16(bytes.count)],
397
+ text: bytes))
398
+ }
399
+ return out
400
+ }
401
+
402
+ /// UTF-8 bytes of a line without firmware control bytes (1..31), cut at a codepoint boundary.
403
+ static func textBytes(_ line: String, max limit: Int = maxTextBytes) -> [UInt8] {
404
+ let cleaned = line.unicodeScalars.filter { $0.value >= 32 }
405
+ var bytes: [UInt8] = []
406
+ for scalar in cleaned {
407
+ let encoded = Array(String(scalar).utf8)
408
+ if bytes.count + encoded.count > limit {
409
+ break
410
+ }
411
+ bytes.append(contentsOf: encoded)
412
+ }
413
+ return bytes
414
+ }
415
+
416
+ /// Wire codes of the half-block grid font: mode 20 indexes a font by
417
+ /// ASCII byte, so its glyphs need not be ASCII. Kept apart from the real ASCII font.
418
+ static let blockCodes: [UInt32: UInt8] = [32: 32, 0x2580: 65, 0x2584: 66, 0x2588: 67]
419
+
420
+ /// Mono text as TEXT_CACHED runs: each line is a string asset drawn with
421
+ /// the font asset for its size, so glyphs upload once per generation and a
422
+ /// changed string costs only its bytes. Cells sit on the raster path's
423
+ /// grid (2 px inset, partial edge cells kept); runs split at the 64-byte
424
+ /// limit. A separate font maps half-block grids to printable wire bytes;
425
+ /// other non-ASCII text keeps the image-tile path.
426
+ private func monoTextSlots(
427
+ _ element: SceneElement, x: Int16, y: Int16, cache: G2CfwTextureCache, mono: MonoFace,
428
+ rasterize: (SceneElement, Int, Int) -> Data?
429
+ ) -> [Slot]? {
430
+ let metrics = mono.metrics(element.fontSize)
431
+ let cellW = metrics.cellWidth, lineH = metrics.lineHeight
432
+ guard cellW > 0, lineH > 0 else { return [] }
433
+ let cols = max(0, (Int(element.w) - Self.textInset + cellW - 1) / cellW)
434
+ let rows = max(0, (Int(element.h) - Self.textInset + lineH - 1) / lineH)
435
+ let lines: [[Unicode.Scalar]] = (element.text ?? "")
436
+ .split(separator: "\n", omittingEmptySubsequences: false).prefix(rows)
437
+ .map { Array($0.unicodeScalars.prefix(cols)) }
438
+ let blockGrid = lines.contains { $0.contains { $0.value > 126 } }
439
+ && lines.allSatisfy { $0.allSatisfy { Self.blockCodes[$0.value] != nil } }
440
+ guard blockGrid || lines.allSatisfy({ $0.allSatisfy { $0.value >= 32 && $0.value <= 126 } }) else {
441
+ return rasterSlots(element, x: x, y: y, cache: cache, rasterize: rasterize)
442
+ }
443
+ let variant = blockGrid ? "blocks" : "ascii"
444
+ let codes: [UInt8] = blockGrid ? [32, 65, 66, 67] : Array(32 ... 126)
445
+ let scalarFor: (UInt8) -> Unicode.Scalar? = { code in
446
+ if blockGrid {
447
+ return Self.blockCodes.first { $0.value == code }.flatMap { Unicode.Scalar($0.key) }
448
+ }
449
+ return Unicode.Scalar(UInt32(code))
450
+ }
451
+ guard let font = cache.font(fontSize: element.fontSize, cellWidth: cellW, lineHeight: lineH, variant: variant, codes: codes, rasterize: { code in
452
+ scalarFor(code).flatMap { mono.glyph($0, element.fontSize, cellW, lineH) }
453
+ }) else { return nil }
454
+ // Opaque cells (options bit 4 clear): the grid owns its cells, as the raster path's blits do.
455
+ let options = Self.gray(element)
456
+ var out: [Slot] = []
457
+ for (row, line) in lines.enumerated() where !line.isEmpty {
458
+ let lineY = Self.clampCoord(Int(y) + Self.textInset + row * lineH)
459
+ var start = 0
460
+ while start < line.count {
461
+ let run = Array(line[start ..< min(line.count, start + Self.maxTextBytes)])
462
+ let bytes = run.map { blockGrid ? Self.blockCodes[$0.value]! : UInt8($0.value) }
463
+ // A glyph the font could not raster keeps the tile path for the element.
464
+ guard bytes.allSatisfy({ $0 == 32 || font.glyphs.contains($0) }) else {
465
+ return rasterSlots(element, x: x, y: y, cache: cache, rasterize: rasterize)
466
+ }
467
+ guard let asset = cache.ensureBytes(key: "t:\(variant):" + String(run.map { Character($0) }), bytes: bytes)
468
+ else { return nil }
469
+ out.append(Slot(.textCached, color: options,
470
+ [Self.clampCoord(Int(x) + Self.textInset + start * cellW), lineY],
471
+ asset: asset, font: font.id))
472
+ start += run.count
473
+ }
474
+ }
475
+ return out
476
+ }
477
+
478
+ private func rasterSlots(
479
+ _ element: SceneElement, x: Int16, y: Int16, cache: G2CfwTextureCache,
480
+ rasterize: (SceneElement, Int, Int) -> Data?
481
+ ) -> [Slot]? {
482
+ let w = Int(element.w), h = Int(element.h)
483
+ guard w > 0, h > 0, let gray = rasterize(element, w, h), gray.count == w * h else { return [] }
484
+ // An image's ink is the ramp's top color; rasterized text keeps its ink in the pixels.
485
+ let options = element.type == "image" ? Self.gray(element) : Self.imageOptions
486
+ var out: [Slot] = []
487
+ var ty = 0
488
+ while ty < h {
489
+ let th = min(Self.maxTileSize, h - ty)
490
+ var tx = 0
491
+ while tx < w {
492
+ let tw = min(Self.maxTileSize, w - tx)
493
+ var tile = Data(capacity: tw * th)
494
+ for row in 0 ..< th {
495
+ let start = (ty + row) * w + tx
496
+ tile.append(gray.subdata(in: start ..< start + tw))
497
+ }
498
+ let key = "px:\(Self.pixelHash(tile))@\(tw)x\(th)"
499
+ guard let asset = cache.ensureImage(hash: key, gray: tile, width: tw, height: th) else { return nil }
500
+ out.append(Slot(.image, color: options,
501
+ [Self.clampCoord(Int(x) + tx), Self.clampCoord(Int(y) + ty)], asset: asset))
502
+ tx += tw
503
+ }
504
+ ty += th
505
+ }
506
+ return out
507
+ }
508
+
509
+ // MARK: - Frame encoding
510
+
511
+ /// One SHOW (embedded object PUTs, references in paint order, ops) and the
512
+ /// asset PUT messages that must precede it. Nil when the frame does not fit
513
+ /// the object table or the asset store; the caller then falls back to the
514
+ /// raster path. `tag` (the host frame number) becomes the SHOW's request id
515
+ /// with the TAG flag, so settings field 129 reports it once the scene settles.
516
+ /// `parallax` is +1 for the left lens mirror and -1 for the right: each
517
+ /// element shifts by `depth × parallax` px; 0 draws every element flat.
518
+ func encode(
519
+ _ frame: SceneFrame, originX: Int, originY: Int, inlineText: Bool = false, mono: MonoFace? = nil,
520
+ tag: Int? = nil, parallax: Int = 0, cache: G2CfwTextureCache, rasterize: (SceneElement, Int, Int) -> Data?
521
+ ) -> Encoded? {
522
+ struct Desired {
523
+ let id: String
524
+ let slots: [Slot]
525
+ let frames: UInt8
526
+ let duration: Int32
527
+ let curve: [UInt8]
528
+ }
529
+ var desired: [Desired] = []
530
+ for element in frame.elements {
531
+ let ox = originX + Int(element.depth) * parallax
532
+ guard var slots = slots(for: element, originX: ox, originY: originY, inlineText: inlineText,
533
+ mono: mono, cache: cache, rasterize: rasterize)
534
+ else { return nil }
535
+ for i in slots.indices where slots[i].rotatable {
536
+ slots[i].angle = element.rotationQ8
537
+ slots[i].pivotX = Int16(clamping: Int(element.pivotX ?? (element.x + element.w / 2)) + ox)
538
+ slots[i].pivotY = Int16(clamping: Int(element.pivotY ?? (element.y + element.h / 2)) + originY)
539
+ }
540
+
541
+ desired.append(Desired(
542
+ id: element.id, slots: slots,
543
+ frames: Self.transitionFrames(ms: element.transitionMs), duration: element.transitionMs,
544
+ curve: Self.easingCurve(element.easing)
545
+ ))
546
+ }
547
+ guard desired.reduce(0, { $0 + $1.slots.count }) <= Self.maxObjects else { return nil }
548
+
549
+ let now = Date().timeIntervalSince1970
550
+ var puts: [Data] = []
551
+ var refs: [(UInt32, UInt32)] = []
552
+ var ops = Data()
553
+ var putCount = 0, hits = 0, tweens = 0
554
+ var ids: [[UInt32]] = []
555
+ var seen = Set<UInt32>()
556
+ // Only objects the previous frame showed may animate: one coming back to
557
+ // the frame appears where it is asked to be, not tweened from where it left.
558
+ let onScreen = Set(active)
559
+ for item in desired {
560
+ var mine: [UInt32] = []
561
+ for (k, slot) in item.slots.enumerated() {
562
+ let id = Self.objectId(item.id, k)
563
+ guard !seen.contains(id) else { continue }
564
+ seen.insert(id)
565
+ mine.append(id)
566
+ let version = Self.version(of: slot)
567
+ func put() {
568
+ puts.append(Self.putObject(id: id, version: version, record: Self.objectRecord(slot)))
569
+ if slot.angle != 0 {
570
+ ops.append(Self.rotationOp(slot, id: id, duration: 0, curve: item.curve))
571
+ }
572
+ putCount += 1
573
+ known[id] = Known(version: version, definition: slot, state: slot, animEndsAt: 0)
574
+ refs.append((id, version))
575
+ }
576
+ guard var k0 = known[id] else { put(); continue }
577
+ let animate = onScreen.contains(id)
578
+ if let current = k0.state {
579
+ if current == slot {
580
+ hits += 1
581
+ refs.append((id, k0.version))
582
+ continue
583
+ }
584
+ let rotationChanged = current.angle != slot.angle
585
+ || ((current.angle != 0 || slot.angle != 0) && (current.pivotX != slot.pivotX || current.pivotY != slot.pivotY))
586
+ if current.sameGeometry(as: slot) {
587
+ if rotationChanged {
588
+ let duration = animate ? item.duration : 0
589
+ ops.append(Self.rotationOp(slot, id: id, duration: duration, curve: item.curve))
590
+ k0.animEndsAt = now + Double(duration) / 1000
591
+ }
592
+ k0.state = slot
593
+ known[id] = k0
594
+ hits += 1
595
+ refs.append((id, k0.version))
596
+ continue
597
+ }
598
+ if animate, item.frames >= 2, let tween = Self.tweenOp(from: current, to: slot, id: id, frames: item.frames, curve: item.curve) {
599
+ ops.append(tween)
600
+ if rotationChanged {
601
+ ops.append(Self.rotationOp(slot, id: id, duration: item.duration, curve: item.curve))
602
+ }
603
+ tweens += 1
604
+ k0.state = slot
605
+ k0.animEndsAt = now + Double(Int(item.frames)) * Self.framePeriodMs / 1000
606
+ known[id] = k0
607
+ refs.append((id, k0.version))
608
+ continue
609
+ }
610
+ // The glasses hold this exact definition and earlier ops only moved
611
+ // it: a same-version PUT is a no-op there, so snap the state instead.
612
+ if k0.version == version, let snap = Self.tweenOp(from: current, to: slot, id: id, frames: 0, curve: item.curve) {
613
+ ops.append(snap)
614
+ if rotationChanged {
615
+ ops.append(Self.rotationOp(slot, id: id, duration: 0, curve: item.curve))
616
+ }
617
+ k0.state = slot
618
+ k0.animEndsAt = 0
619
+ known[id] = k0
620
+ hits += 1
621
+ refs.append((id, k0.version))
622
+ continue
623
+ }
624
+ put()
625
+ continue
626
+ }
627
+
628
+ // Frozen mid-flight by a hide: snap it back to the geometry it should show.
629
+ if let snap = Self.tweenOp(from: k0.definition, to: slot, id: id, frames: 0, curve: item.curve) {
630
+ ops.append(snap)
631
+ if slot.angle != 0 || k0.definition.angle != 0 {
632
+ ops.append(Self.rotationOp(slot, id: id, duration: 0, curve: item.curve))
633
+ }
634
+ k0.state = slot
635
+ known[id] = k0
636
+ hits += 1
637
+ refs.append((id, k0.version))
638
+ continue
639
+ }
640
+ put()
641
+ }
642
+ ids.append(mine)
643
+ }
644
+ let listChanged = refs.map { $0.0 } != active
645
+ active = refs.map { $0.0 }
646
+ objectIds = ids
647
+ let payload = Self.showPayload(puts: puts, refs: refs, ops: [ops], request: tag.map { UInt16(truncatingIfNeeded: $0) } ?? 0,
648
+ tag: (tag ?? 0) > 0)
649
+ return Encoded(payload: payload, uploads: cache.takeUploadPayloads(),
650
+ changed: listChanged || !puts.isEmpty || !ops.isEmpty, puts: putCount, hits: hits, tweens: tweens)
651
+ }
652
+
653
+ // MARK: - Wire records
654
+
655
+ private static func appendI16(_ value: Int16, to out: inout Data) {
656
+ let bits = UInt16(bitPattern: value)
657
+ out.append(UInt8(bits & 0xFF))
658
+ out.append(UInt8(bits >> 8))
659
+ }
660
+
661
+ /// The shape record the object cache stores for a slot (shapes.h layouts).
662
+ static func objectRecord(_ slot: Slot) -> Data {
663
+ switch ShapeType(rawValue: slot.type) {
664
+ case .path:
665
+ var out = Data([slot.type, 1, slot.color, slot.fillRule])
666
+ for value in slot.params.prefix(3) { appendI16(value, to: &out) }
667
+ out.append(UInt8(slot.path.count & 255)); out.append(UInt8(slot.path.count >> 8))
668
+ out.append(contentsOf: slot.path)
669
+ return out
670
+ case .textInline:
671
+ var out = Data([slot.type, 1, slot.color, 0])
672
+ for value in slot.params.prefix(4) { appendI16(value, to: &out) }
673
+ out.append(UInt8(min(slot.text.count, maxInlineBytes)))
674
+ out.append(contentsOf: slot.text.prefix(maxInlineBytes))
675
+ return out
676
+ case .image, .text, .textCached:
677
+ var out = Data([slot.type, 1, slot.color, 0])
678
+ for value in slot.params.prefix(2) { appendI16(value, to: &out) }
679
+ G2CfwTextureCache.appendU32(Int(slot.asset), to: &out)
680
+ if slot.type == ShapeType.textCached.rawValue { G2CfwTextureCache.appendU32(Int(slot.font), to: &out) }
681
+ return out
682
+ default:
683
+ var out = Data([slot.type, 1, slot.color, slot.width])
684
+ for value in slot.params { appendI16(value, to: &out) }
685
+ return out
686
+ }
687
+ }
688
+
689
+ /// `[1][id:u32][version:u32][record]`
690
+ static func putObject(id: UInt32, version: UInt32, record: Data) -> Data {
691
+ var out = Data([1])
692
+ G2CfwTextureCache.appendU32(Int(id), to: &out)
693
+ G2CfwTextureCache.appendU32(Int(version), to: &out)
694
+ out.append(record)
695
+ return out
696
+ }
697
+
698
+ /// `[38][epoch 0][request][flags][bg][puts][put entries][count:u16][(id, version) x count][ops]`
699
+ static func showPayload(puts: [Data], refs: [(UInt32, UInt32)], ops: [Data], request: UInt16, tag: Bool = false, bg: UInt8 = 0) -> Data {
700
+ var out = Data([38, 0, 0, UInt8(request & 0xFF), UInt8(request >> 8), tag ? 0x05 : 0x01, bg & 0x0F, UInt8(min(255, puts.count))])
701
+ for put in puts { out.append(put) }
702
+ out.append(UInt8(refs.count & 0xFF)); out.append(UInt8(refs.count >> 8))
703
+ for (id, version) in refs {
704
+ G2CfwTextureCache.appendU32(Int(id), to: &out)
705
+ G2CfwTextureCache.appendU32(Int(version), to: &out)
706
+ }
707
+ for op in ops { out.append(op) }
708
+ return out
709
+ }
710
+
711
+ /// `[9][id:u32][angle:i32][pivotX:i16][pivotY:i16][durationMs:u16][curve x4]`
712
+ static func rotationOp(_ slot: Slot, id: UInt32, duration: Int32, curve: [UInt8]) -> Data {
713
+ guard slot.rotatable else { return Data() }
714
+ var out = Data([9])
715
+ G2CfwTextureCache.appendU32(Int(id), to: &out)
716
+ let bits = UInt32(bitPattern: slot.angle)
717
+ for shift in stride(from: 0, through: 24, by: 8) {
718
+ out.append(UInt8((bits >> shift) & 255))
719
+ }
720
+ for value in [UInt16(bitPattern: slot.pivotX), UInt16(bitPattern: slot.pivotY), UInt16(clamping: duration)] {
721
+ out.append(UInt8(value & 255)); out.append(UInt8(value >> 8))
722
+ }
723
+ out.append(contentsOf: curve.prefix(4))
724
+ return out
725
+ }
726
+
727
+ /// `[5][id:u32][mask:u16][frames][curve x4][value:i16 per set bit]`
728
+ static func tweenOp(id: UInt32, mask: UInt16, frames: UInt8, curve: [UInt8], values: [Int16]) -> Data {
729
+ var out = Data([5])
730
+ G2CfwTextureCache.appendU32(Int(id), to: &out)
731
+ out.append(UInt8(mask & 0xFF)); out.append(UInt8(mask >> 8)); out.append(frames)
732
+ out.append(contentsOf: curve.prefix(4))
733
+ for value in values { appendI16(value, to: &out) }
734
+ return out
735
+ }
736
+
737
+ /// A TWEEN from `old` (what the glasses show) to `new`, or nil when the change
738
+ /// is not a pure geometry/stroke change of the same shape type.
739
+ ///
740
+ /// The mask covers every tweenable parameter (plus color and width where the
741
+ /// type allows), not only the ones that changed: the firmware takes an unmasked
742
+ /// parameter's end value from the object's CURRENT value, which is mid-flight
743
+ /// while an earlier tween is still running, so a changed-only mask would freeze
744
+ /// that axis wherever the earlier tween had got to when a second move lands.
745
+ static func tweenOp(from old: Slot, to new: Slot, id: UInt32, frames: UInt8, curve: [UInt8]) -> Data? {
746
+ guard old.type == new.type, old.text == new.text, old.path == new.path, old.fillRule == new.fillRule,
747
+ old.asset == new.asset, old.font == new.font else { return nil }
748
+ let tweenable = tweenableMask(new.type)
749
+ for k in 0 ..< 8 where old.params[k] != new.params[k] {
750
+ guard tweenable & (1 << k) != 0 else { return nil }
751
+ }
752
+ let isTexture = new.type >= ShapeType.image.rawValue && new.type != ShapeType.path.rawValue
753
+ // Textures fade through the options byte's low nibble (the ramp's top
754
+ // color) as long as the option bits stay put; the firmware lerps the
755
+ // whole byte, so a changed option bit would pass through wrong states.
756
+ let textureFade = isTexture && (old.color & 0xF0) == (new.color & 0xF0)
757
+ if old.color != new.color {
758
+ guard !isTexture || textureFade else { return nil }
759
+ }
760
+ if old.width != new.width {
761
+ guard !isTexture else { return nil }
762
+ }
763
+ var mask: UInt16 = tweenable
764
+ var values: [Int16] = []
765
+ for k in 0 ..< 8 where tweenable & (1 << k) != 0 {
766
+ values.append(new.params[k])
767
+ }
768
+ if !isTexture || textureFade {
769
+ mask |= 0x100
770
+ values.append(Int16(new.color))
771
+ }
772
+ if !isTexture && new.type != ShapeType.path.rawValue {
773
+ mask |= 0x200
774
+ values.append(Int16(new.width))
775
+ }
776
+ guard mask != 0 else { return nil }
777
+ return tweenOp(id: id, mask: mask, frames: frames, curve: curve, values: values)
778
+ }
779
+ }