@glassly/bluetooth-sdk 0.1.0-dev.105 → 0.1.0-dev.107

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 (30) hide show
  1. package/android/src/main/assets/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
  2. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +17 -1
  3. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +3 -3
  4. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +66 -3
  5. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +35 -0
  6. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +415 -0
  7. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +10 -0
  8. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +2 -2
  9. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +16 -2
  10. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +2 -2
  11. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +175 -0
  12. package/build/BluetoothSdk.types.d.ts +2 -0
  13. package/build/BluetoothSdk.types.d.ts.map +1 -1
  14. package/build/BluetoothSdk.types.js.map +1 -1
  15. package/build/generated/releaseMetadata.js +3 -3
  16. package/build/generated/releaseMetadata.js.map +1 -1
  17. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  18. package/ios/Source/DeviceManager.swift +16 -2
  19. package/ios/Source/GeneratedReleaseMetadata.swift +3 -3
  20. package/ios/Source/Resources/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
  21. package/ios/Source/sgcs/G2.swift +48 -3
  22. package/ios/Source/sgcs/G2CfwRenderer.swift +37 -0
  23. package/ios/Source/sgcs/G2CfwScene.swift +394 -0
  24. package/ios/Source/sgcs/G2CfwTextureCache.swift +10 -0
  25. package/ios/Source/sgcs/G2Flasher.swift +2 -2
  26. package/ios/Source/sgcs/SGCManager.swift +34 -3
  27. package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +187 -0
  28. package/package.json +1 -1
  29. package/src/BluetoothSdk.types.ts +2 -0
  30. package/src/generated/releaseMetadata.ts +3 -3
@@ -1703,6 +1703,15 @@ class DeviceManager {
1703
1703
  elementsRaw.mapNotNull { el ->
1704
1704
  val box = el["box"] as? Map<String, Any> ?: return@mapNotNull null
1705
1705
  val style = el["style"] as? Map<String, Any>
1706
+ val transition = el["transition"] as? Map<String, Any>
1707
+ val points =
1708
+ (el["points"] as? List<*>)?.flatMap { p ->
1709
+ val point = p as? Map<*, *>
1710
+ listOf((point?.get("x") as? Number)?.toInt() ?: 0, (point?.get("y") as? Number)?.toInt() ?: 0)
1711
+ } ?: emptyList()
1712
+ // Open strokes carry their width as `width`; closed shapes and boxes as `border`.
1713
+ val border =
1714
+ (style?.get("border") as? Number)?.toInt() ?: (style?.get("width") as? Number)?.toInt() ?: 0
1706
1715
  SceneElement(
1707
1716
  id = el["id"] as? String ?: return@mapNotNull null,
1708
1717
  type = el["type"] as? String ?: return@mapNotNull null,
@@ -1712,12 +1721,19 @@ class DeviceManager {
1712
1721
  h = (box["h"] as? Number)?.toInt() ?: 0,
1713
1722
  text = (el["text"] as? String)?.let { parsePlaceholders(it) },
1714
1723
  data = el["data"] as? String,
1715
- border = (style?.get("border") as? Number)?.toInt() ?: 0,
1724
+ border = border,
1716
1725
  radius = (style?.get("radius") as? Number)?.toInt() ?: 0,
1717
1726
  change = el["change"] as? String ?: "created",
1718
1727
  contentHash = el["contentHash"] as? String ?: "",
1719
1728
  font = style?.get("font") as? String ?: "default",
1720
1729
  fontSize = (style?.get("fontSize") as? Number)?.toInt() ?: 0,
1730
+ fill = style?.get("fill") as? Boolean ?: false,
1731
+ color = (style?.get("color") as? Number)?.toInt() ?: -1,
1732
+ points = points,
1733
+ startDeg = (el["startDeg"] as? Number)?.toInt() ?: 0,
1734
+ endDeg = (el["endDeg"] as? Number)?.toInt() ?: 0,
1735
+ transitionMs = (transition?.get("durationMs") as? Number)?.toInt() ?: 0,
1736
+ easing = transition?.get("easing") as? String ?: "ease",
1721
1737
  )
1722
1738
  }
1723
1739
  val canvas = sceneMap["canvas"] as? Map<String, Any>
@@ -3,9 +3,9 @@ package com.glassly.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "0.1.0"
6
- const val RELEASE_IDENTITY: String = "0.1.0-dev.105"
7
- const val RELEASE_SET_ID: String = "glassly-0.1.0-dev.105"
8
- const val SOURCE_COMMIT: String = "efce299cebb9bd86929450ade940c2a26386ea7e"
6
+ const val RELEASE_IDENTITY: String = "0.1.0-dev.107"
7
+ const val RELEASE_SET_ID: String = "glassly-0.1.0-dev.107"
8
+ const val SOURCE_COMMIT: String = "31a8a12eed22fc38c0f4df333a15590709fd37c5"
9
9
  const val OTA_MANIFEST_URL: String = ""
10
10
  const val OTA_MANIFEST_SHA256: String = ""
11
11
  }
@@ -1476,7 +1476,7 @@ class G2 : SGCManager() {
1476
1476
  * fulls/deltas. SEED (mode-6 black) and MOVE_BUNDLE (blank of an old
1477
1477
  * window position) always survive.
1478
1478
  */
1479
- private enum class CfwPayloadKind { SEED, FULL, DELTA, MOVE_BUNDLE, UPLOAD }
1479
+ private enum class CfwPayloadKind { SEED, FULL, DELTA, MOVE_BUNDLE, UPLOAD, SCENE }
1480
1480
 
1481
1481
  private class CfwPayload(val kind: CfwPayloadKind, val data: ByteArray)
1482
1482
 
@@ -1572,6 +1572,15 @@ class G2 : SGCManager() {
1572
1572
  private val supportsCfwTextureImages: Boolean
1573
1573
  get() = "texcache12" in cfwFeatures && "teximg13" in cfwFeatures
1574
1574
 
1575
+ /** Retained vector scene with on-glass animation (modes 16-18). */
1576
+ private val supportsCfwVectorScene: Boolean
1577
+ get() =
1578
+ supportsCfwSceneRendering &&
1579
+ listOf("scene17", "shapes16", "anim18", "texcache12", "teximg13", "font15").all { it in cfwFeatures }
1580
+
1581
+ /** The on-glass slot table must be re-SET on the next vector frame. */
1582
+ private var cfwSceneInvalid = true
1583
+
1575
1584
  // BLE
1576
1585
  private val context: Context
1577
1586
  get() = Bridge.getContext()
@@ -2938,6 +2947,7 @@ class G2 : SGCManager() {
2938
2947
 
2939
2948
  private fun cfwResetShadow() {
2940
2949
  cfwPendingPayloads.clear()
2950
+ cfwSceneInvalid = true
2941
2951
  cfwShadowSeeded = false
2942
2952
  cfwLastBoxes = null
2943
2953
  cfwPrevRaster = null
@@ -3080,7 +3090,43 @@ class G2 : SGCManager() {
3080
3090
  * change is large, the queue backed up, or on the periodic insurance
3081
3091
  * keyframe. Seeds the shadow with mode-6 black when it is fresh.
3082
3092
  */
3093
+ /**
3094
+ * Queue [frame] as a retained-scene patch: the firmware keeps the slots,
3095
+ * tweens transitions itself, and renders every frame into its own buffer.
3096
+ * Returns false when the frame does not fit, so the raster path takes it.
3097
+ */
3098
+ private suspend fun enqueueCfwVectorFrame(frame: SceneFrame): Boolean {
3099
+ val boxes = cfwBoxes(frame, pinDepth = true)
3100
+ val backlogged = cfwPendingPayloads.count { it.kind == CfwPayloadKind.SCENE } >= CFW_MAX_QUEUED_UPDATES
3101
+ if (backlogged) {
3102
+ cfwPendingPayloads.removeAll { it.kind == CfwPayloadKind.SCENE }
3103
+ cfwSceneInvalid = true
3104
+ }
3105
+ if (cfwLastBoxes != boxes) cfwSceneInvalid = true
3106
+ val invalidate = cfwSceneInvalid
3107
+ val encoded =
3108
+ kotlinx.coroutines.withContext(Dispatchers.Default) {
3109
+ G2CfwRenderer.encodeVector(frame, boxes, cfwCacheGeneration, invalidate)
3110
+ }
3111
+ if (encoded == null) {
3112
+ Bridge.log("G2: vector scene does not fit; using the raster path for this frame")
3113
+ cfwSceneInvalid = true
3114
+ return false
3115
+ }
3116
+ cfwSceneInvalid = false
3117
+ cfwLastBoxes = boxes
3118
+ // A later raster frame must start from a keyframe: the panel shows the scene, not the shadow.
3119
+ cfwDeltasSinceFull = CFW_MAX_DELTAS_BETWEEN_FULL_FRAMES
3120
+ for (upload in encoded.uploads) {
3121
+ cfwPendingPayloads.addLast(CfwPayload(CfwPayloadKind.UPLOAD, upload))
3122
+ }
3123
+ cfwPendingPayloads.addLast(CfwPayload(CfwPayloadKind.SCENE, encoded.payload))
3124
+ cfwLastPayloadQueuedAt = System.currentTimeMillis()
3125
+ return true
3126
+ }
3127
+
3083
3128
  private suspend fun enqueueCfwFrame(frame: SceneFrame): Boolean {
3129
+ if (supportsCfwVectorScene && enqueueCfwVectorFrame(frame)) return true
3084
3130
  val useBuiltinText =
3085
3131
  supportsCfwBuiltinText && frame.elements.any { it.type == "text" && it.font == "builtin" }
3086
3132
  // Modes 13/14 have no per-lens variant, so they need the lens-equal window.
@@ -3179,7 +3225,8 @@ class G2 : SGCManager() {
3179
3225
  cfwShadowSeeded = true
3180
3226
  if (textDraws.isEmpty()) {
3181
3227
  cfwPendingPayloads.removeAll {
3182
- it.kind == CfwPayloadKind.FULL || it.kind == CfwPayloadKind.DELTA
3228
+ it.kind == CfwPayloadKind.FULL || it.kind == CfwPayloadKind.DELTA ||
3229
+ it.kind == CfwPayloadKind.SCENE
3183
3230
  }
3184
3231
  cfwLastPayloadQueuedAt = System.currentTimeMillis()
3185
3232
  return true
@@ -3193,7 +3240,9 @@ class G2 : SGCManager() {
3193
3240
  }
3194
3241
  if (update.usedFullFrame) {
3195
3242
  // A full overwrites the window; seeds, uploads, and move-blanks must still survive.
3196
- cfwPendingPayloads.removeAll { it.kind == CfwPayloadKind.FULL || it.kind == CfwPayloadKind.DELTA }
3243
+ cfwPendingPayloads.removeAll {
3244
+ it.kind == CfwPayloadKind.FULL || it.kind == CfwPayloadKind.DELTA || it.kind == CfwPayloadKind.SCENE
3245
+ }
3197
3246
  val kind = if (movedBoxes) CfwPayloadKind.MOVE_BUNDLE else CfwPayloadKind.FULL
3198
3247
  cfwPendingPayloads.addLast(CfwPayload(kind, payload))
3199
3248
  } else {
@@ -6368,6 +6417,20 @@ class G2 : SGCManager() {
6368
6417
  cfwFeatures.clear()
6369
6418
  cfwProtocolVersion = 0
6370
6419
  }
6420
+ publishSceneCanvas()
6421
+ }
6422
+
6423
+ /**
6424
+ * Publish the CFW scene canvas and vector-scene support into the glasses
6425
+ * status once classification lands; zeros/false on stock so a stale value
6426
+ * cannot survive a CFW→stock swap.
6427
+ */
6428
+ private fun publishSceneCanvas() {
6429
+ if (!cfwClassified) return
6430
+ val active = supportsCfwSceneRendering
6431
+ DeviceStore.apply("glasses", "sceneCanvasWidth", if (active) 576 else 0)
6432
+ DeviceStore.apply("glasses", "sceneCanvasHeight", if (active) 288 else 0)
6433
+ DeviceStore.apply("glasses", "sceneVector", active && supportsCfwVectorScene)
6371
6434
  }
6372
6435
 
6373
6436
  /**
@@ -74,6 +74,41 @@ internal object G2CfwRenderer {
74
74
  * glyph raster memo survives resets — rasters don't change.
75
75
  */
76
76
  private val textureCache = G2CfwTextureCache()
77
+ /** Retained vector scene (modes 16-18); shares the texture cache with the raster path. */
78
+ private val scene = G2CfwScene()
79
+
80
+ /**
81
+ * Encode [frame] as a retained-scene patch (plus its mode-12 uploads).
82
+ * [invalidate] forces a full re-SET (the on-glass slot table is unknown).
83
+ * Null when the frame does not fit the slot table or the texture cache.
84
+ */
85
+ fun encodeVector(
86
+ frame: SceneFrame,
87
+ boxes: LensBoxes,
88
+ cacheGeneration: Int,
89
+ invalidate: Boolean,
90
+ ): G2CfwScene.Encoded? {
91
+ if (textureCache.generation != cacheGeneration || textureCache.exhausted) {
92
+ textureCache.reset(cacheGeneration)
93
+ }
94
+ if (scene.generation != textureCache.generation) {
95
+ scene.reset(textureCache.generation)
96
+ } else if (invalidate) {
97
+ scene.invalidate()
98
+ }
99
+ val rasterizeOne: (SceneElement, Int, Int) -> ByteArray? = { element, w, h ->
100
+ rasterize(
101
+ SceneFrame(frame.appId, frame.epoch, false, listOf(element.atOrigin()), emptyList(), w, h),
102
+ w,
103
+ h,
104
+ )
105
+ }
106
+ scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne)?.let { return it }
107
+ // Out of texture cache mid-frame: start a fresh cache and repack once.
108
+ textureCache.reset(cacheGeneration)
109
+ scene.reset(cacheGeneration)
110
+ return scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne)
111
+ }
77
112
  private val glyphRasterMemo = mutableMapOf<String, ByteArray>()
78
113
 
79
114
  private fun monoPaint(fontSize: Float): Paint =
@@ -0,0 +1,415 @@
1
+ package com.glassly.bluetoothsdk.sgcs
2
+
3
+ import java.io.ByteArrayOutputStream
4
+
5
+ /**
6
+ * Retained vector scene for the Glassly CFW (firmware modes 16-18).
7
+ *
8
+ * Every scene element becomes one or more firmware slots (paint order = slot
9
+ * order): vector shapes map 1:1, builtin-font text becomes one TEXT slot per
10
+ * line with its bytes in the texture cache, and everything else (proportional
11
+ * text, images) is rasterized on the phone and drawn as cached IMAGE slots
12
+ * tiled to the 255 px slot limit. Frames diff against the last committed
13
+ * layout: unchanged slots cost nothing, geometry changes on an element with a
14
+ * transition become firmware TWEENs, and anything else is re-SET. Slot indices
15
+ * stay stable per element id; a reorder or an over-budget frame repacks.
16
+ */
17
+ internal class G2CfwScene {
18
+ companion object {
19
+ const val MAX_SLOTS = 128
20
+ const val RECORD_BYTES = 20
21
+ const val FRAME_PERIOD_MS = 33.0
22
+ const val TEXT_LINE_HEIGHT = 27
23
+ const val MAX_TEXT_BYTES = 64
24
+ const val MAX_TILE_SIZE = 255
25
+ /** Mode-13/15 options: identity ramp, transparent source color 0. */
26
+ const val TEXT_OPTIONS = 0x1F
27
+ const val IMAGE_OPTIONS = 0x0F
28
+
29
+ const val TYPE_LINE = 1
30
+ const val TYPE_RECT = 2
31
+ const val TYPE_RECT_FILL = 3
32
+ const val TYPE_CIRCLE = 4
33
+ const val TYPE_CIRCLE_FILL = 5
34
+ const val TYPE_TRI = 6
35
+ const val TYPE_TRI_FILL = 7
36
+ const val TYPE_QUAD = 8
37
+ const val TYPE_QUAD_FILL = 9
38
+ const val TYPE_BEZIER2 = 10
39
+ const val TYPE_BEZIER3 = 11
40
+ const val TYPE_ARC = 12
41
+ const val TYPE_PIE = 13
42
+ const val TYPE_IMAGE = 14
43
+ const val TYPE_TEXT = 15
44
+ const val TYPE_TEXT_CACHED = 16
45
+
46
+ /** Geometry parameters a TWEEN may interpolate, per type. Texture offsets and lengths never tween. */
47
+ fun tweenableMask(type: Int): Int =
48
+ when (type) {
49
+ TYPE_LINE -> 0x0F
50
+ TYPE_RECT, TYPE_RECT_FILL, TYPE_ARC, TYPE_PIE -> 0x1F
51
+ TYPE_CIRCLE, TYPE_CIRCLE_FILL -> 0x07
52
+ TYPE_TRI, TYPE_TRI_FILL, TYPE_BEZIER2 -> 0x3F
53
+ TYPE_QUAD, TYPE_QUAD_FILL, TYPE_BEZIER3 -> 0xFF
54
+ TYPE_IMAGE, TYPE_TEXT, TYPE_TEXT_CACHED -> 0x03
55
+ else -> 0
56
+ }
57
+
58
+ fun easingCurve(easing: String): IntArray =
59
+ when (easing) {
60
+ "linear" -> intArrayOf(0, 0, 255, 255)
61
+ "ease-in" -> intArrayOf(107, 0, 255, 255)
62
+ "ease-out" -> intArrayOf(0, 0, 148, 255)
63
+ "ease-in-out" -> intArrayOf(107, 0, 148, 255)
64
+ else -> intArrayOf(64, 26, 64, 255) // CSS "ease"
65
+ }
66
+
67
+ fun transitionFrames(ms: Int): Int {
68
+ if (ms <= 0) return 0
69
+ val frames = Math.round(ms / FRAME_PERIOD_MS).toInt()
70
+ return frames.coerceIn(2, 255)
71
+ }
72
+
73
+ /** UTF-8 bytes of a line without firmware control bytes (1..31), cut at a codepoint boundary. */
74
+ fun textBytes(line: String): ByteArray {
75
+ val out = ByteArrayOutputStream()
76
+ var i = 0
77
+ while (i < line.length) {
78
+ val cp = line.codePointAt(i)
79
+ i += Character.charCount(cp)
80
+ if (cp < 32) continue
81
+ val encoded = String(Character.toChars(cp)).toByteArray(Charsets.UTF_8)
82
+ if (out.size() + encoded.size > MAX_TEXT_BYTES) break
83
+ out.write(encoded)
84
+ }
85
+ return out.toByteArray()
86
+ }
87
+
88
+ private fun clampCoord(v: Int): Int = v.coerceIn(-1024, 1023)
89
+
90
+ private fun gray(element: SceneElement): Int = if (element.color < 0) 15 else element.color.coerceIn(0, 15)
91
+
92
+ private fun stroke(element: SceneElement): Int = element.border.coerceIn(1, 255)
93
+
94
+ private fun le(out: ByteArrayOutputStream, value: Int) {
95
+ out.write(value and 0xFF)
96
+ out.write((value ushr 8) and 0xFF)
97
+ }
98
+
99
+ /** `[0][slot][type][flags=visible][color][width][p0..p7 LE]` */
100
+ fun setRecord(slot: Slot, index: Int): ByteArray {
101
+ val out = ByteArrayOutputStream()
102
+ out.write(0)
103
+ out.write(index)
104
+ out.write(slot.type)
105
+ out.write(1)
106
+ out.write(slot.color)
107
+ out.write(slot.width)
108
+ for (value in slot.params) le(out, value)
109
+ return out.toByteArray()
110
+ }
111
+
112
+ /**
113
+ * `[5][slot][mask:u16][frames][curve x4][value:i16 per set bit]`, or null when the
114
+ * change is not a pure geometry/stroke change of the same shape type.
115
+ */
116
+ fun tweenRecord(old: Slot, new: Slot, index: Int, frames: Int, curve: IntArray): ByteArray? {
117
+ if (old.type != new.type) return null
118
+ val tweenable = tweenableMask(new.type)
119
+ var mask = 0
120
+ val values = mutableListOf<Int>()
121
+ for (k in 0 until 8) {
122
+ if (old.params[k] == new.params[k]) continue
123
+ if (tweenable and (1 shl k) == 0) return null
124
+ mask = mask or (1 shl k)
125
+ values.add(new.params[k])
126
+ }
127
+ val isTexture = new.type >= TYPE_IMAGE
128
+ if (old.color != new.color) {
129
+ if (isTexture) return null
130
+ mask = mask or 0x100
131
+ values.add(new.color)
132
+ }
133
+ if (old.width != new.width) {
134
+ if (isTexture) return null
135
+ mask = mask or 0x200
136
+ values.add(new.width)
137
+ }
138
+ if (mask == 0) return null
139
+ val out = ByteArrayOutputStream()
140
+ out.write(5)
141
+ out.write(index)
142
+ le(out, mask)
143
+ out.write(frames)
144
+ for (i in 0 until 4) out.write(curve[i])
145
+ for (value in values) le(out, value)
146
+ return out.toByteArray()
147
+ }
148
+ }
149
+
150
+ /** One firmware slot: type, 4-bit color (or options byte), stroke width, eight int16 parameters. */
151
+ class Slot(val type: Int, val color: Int, val width: Int, params: List<Int>) {
152
+ val params: IntArray = IntArray(8) { params.getOrElse(it) { 0 } }
153
+
154
+ override fun equals(other: Any?): Boolean =
155
+ other is Slot && other.type == type && other.color == color && other.width == width &&
156
+ other.params.contentEquals(params)
157
+
158
+ override fun hashCode(): Int = ((type * 31 + color) * 31 + width) * 31 + params.contentHashCode()
159
+ }
160
+
161
+ class ElementState(val id: String, val contentHash: String, val slots: List<Slot>, val indices: List<Int>)
162
+
163
+ class Encoded(val payload: ByteArray, val uploads: List<ByteArray>)
164
+
165
+ var generation = -1
166
+ private set
167
+ var elements: List<ElementState> = emptyList()
168
+ private set
169
+ private var needsRepack = true
170
+
171
+ fun reset(generation: Int) {
172
+ this.generation = generation
173
+ elements = emptyList()
174
+ needsRepack = true
175
+ }
176
+
177
+ /** The on-glass slot table is unknown (a send failed, the window moved): the next frame re-SETs everything. */
178
+ fun invalidate() {
179
+ needsRepack = true
180
+ }
181
+
182
+ private fun point(element: SceneElement, i: Int, ox: Int, oy: Int): Pair<Int, Int> {
183
+ if (element.points.size < 2 * i + 2) return 0 to 0
184
+ return clampCoord(element.points[2 * i] + ox) to clampCoord(element.points[2 * i + 1] + oy)
185
+ }
186
+
187
+ private fun circleGeometry(element: SceneElement, ox: Int, oy: Int): Triple<Int, Int, Int> {
188
+ val r = maxOf(0, (minOf(element.w, element.h) - 1) / 2)
189
+ return Triple(clampCoord(element.x + element.w / 2 + ox), clampCoord(element.y + element.h / 2 + oy), r)
190
+ }
191
+
192
+ /** Slots for one element, or null when the texture cache cannot hold its content. */
193
+ fun slotsFor(
194
+ element: SceneElement,
195
+ ox: Int,
196
+ oy: Int,
197
+ cache: G2CfwTextureCache,
198
+ rasterize: (SceneElement, Int, Int) -> ByteArray?,
199
+ ): List<Slot>? {
200
+ val x = clampCoord(element.x + ox)
201
+ val y = clampCoord(element.y + oy)
202
+ val w = element.w.coerceIn(0, 2047)
203
+ val h = element.h.coerceIn(0, 2047)
204
+ val color = gray(element)
205
+ val radius = element.radius.coerceIn(0, 2047)
206
+ return when (element.type) {
207
+ "rect" -> {
208
+ val out = mutableListOf<Slot>()
209
+ if (element.fill) {
210
+ out.add(Slot(TYPE_RECT_FILL, color, 0, listOf(x, y, w, h, radius)))
211
+ if (element.border > 0) out.add(Slot(TYPE_RECT, color, stroke(element), listOf(x, y, w, h, radius)))
212
+ } else {
213
+ out.add(Slot(TYPE_RECT, color, stroke(element), listOf(x, y, w, h, radius)))
214
+ }
215
+ out
216
+ }
217
+ "circle" -> {
218
+ val (cx, cy, r) = circleGeometry(element, ox, oy)
219
+ if (element.fill) listOf(Slot(TYPE_CIRCLE_FILL, color, 0, listOf(cx, cy, r)))
220
+ else listOf(Slot(TYPE_CIRCLE, color, stroke(element), listOf(cx, cy, r)))
221
+ }
222
+ "arc", "pie" -> {
223
+ val (cx, cy, r) = circleGeometry(element, ox, oy)
224
+ val a0 = clampCoord(element.startDeg)
225
+ val a1 = clampCoord(element.endDeg)
226
+ if (element.type == "pie") listOf(Slot(TYPE_PIE, color, 0, listOf(cx, cy, r, a0, a1)))
227
+ else listOf(Slot(TYPE_ARC, color, stroke(element), listOf(cx, cy, r, a0, a1)))
228
+ }
229
+ "line" -> {
230
+ val (x1, y1) = point(element, 0, ox, oy)
231
+ val (x2, y2) = point(element, 1, ox, oy)
232
+ listOf(Slot(TYPE_LINE, color, stroke(element), listOf(x1, y1, x2, y2)))
233
+ }
234
+ "triangle", "quad" -> {
235
+ val n = if (element.type == "triangle") 3 else 4
236
+ val params = (0 until n).flatMap { point(element, it, ox, oy).toList() }
237
+ val filled = if (n == 3) TYPE_TRI_FILL else TYPE_QUAD_FILL
238
+ val stroked = if (n == 3) TYPE_TRI else TYPE_QUAD
239
+ if (element.fill) listOf(Slot(filled, color, 0, params))
240
+ else listOf(Slot(stroked, color, stroke(element), params))
241
+ }
242
+ "bezier" -> {
243
+ val n = element.points.size / 2
244
+ if (n != 3 && n != 4) return emptyList()
245
+ val params = (0 until n).flatMap { point(element, it, ox, oy).toList() }
246
+ listOf(Slot(if (n == 3) TYPE_BEZIER2 else TYPE_BEZIER3, color, stroke(element), params))
247
+ }
248
+ "text" -> if (element.font == "builtin") builtinTextSlots(element, x, y, cache)
249
+ else rasterSlots(element, x, y, cache, rasterize)
250
+ "image" -> rasterSlots(element, x, y, cache, rasterize)
251
+ else -> emptyList()
252
+ }
253
+ }
254
+
255
+ private fun builtinTextSlots(element: SceneElement, x: Int, y: Int, cache: G2CfwTextureCache): List<Slot>? {
256
+ val out = mutableListOf<Slot>()
257
+ if (element.border > 0) {
258
+ out.add(
259
+ Slot(
260
+ TYPE_RECT, 15, stroke(element),
261
+ listOf(x, y, element.w.coerceIn(0, 2047), element.h.coerceIn(0, 2047), element.radius.coerceIn(0, 2047)),
262
+ ),
263
+ )
264
+ }
265
+ val maxLines = maxOf(1, element.h / TEXT_LINE_HEIGHT)
266
+ val lines = (element.text ?: "").split("\n").take(maxLines)
267
+ for ((index, line) in lines.withIndex()) {
268
+ val bytes = textBytes(line)
269
+ if (bytes.isEmpty()) continue
270
+ val offset = cache.ensureBytes("t:$line", bytes) ?: return null
271
+ out.add(Slot(TYPE_TEXT, TEXT_OPTIONS, 0, listOf(x, clampCoord(y + index * TEXT_LINE_HEIGHT), offset, bytes.size)))
272
+ }
273
+ return out
274
+ }
275
+
276
+ private fun rasterSlots(
277
+ element: SceneElement,
278
+ x: Int,
279
+ y: Int,
280
+ cache: G2CfwTextureCache,
281
+ rasterize: (SceneElement, Int, Int) -> ByteArray?,
282
+ ): List<Slot>? {
283
+ val w = element.w
284
+ val h = element.h
285
+ if (w <= 0 || h <= 0) return emptyList()
286
+ val gray = rasterize(element, w, h)?.takeIf { it.size == w * h } ?: return emptyList()
287
+ val out = mutableListOf<Slot>()
288
+ var ty = 0
289
+ while (ty < h) {
290
+ val th = minOf(MAX_TILE_SIZE, h - ty)
291
+ var tx = 0
292
+ while (tx < w) {
293
+ val tw = minOf(MAX_TILE_SIZE, w - tx)
294
+ val tile = ByteArray(tw * th)
295
+ for (row in 0 until th) {
296
+ System.arraycopy(gray, (ty + row) * w + tx, tile, row * tw, tw)
297
+ }
298
+ val key = "${element.contentHash}@${w}x$h:$tx,$ty"
299
+ val offset = cache.ensureImage(key, tile, tw, th) ?: return null
300
+ out.add(Slot(TYPE_IMAGE, IMAGE_OPTIONS, 0, listOf(clampCoord(x + tx), clampCoord(y + ty), offset)))
301
+ tx += tw
302
+ }
303
+ ty += th
304
+ }
305
+ return out
306
+ }
307
+
308
+ private class Desired(
309
+ val id: String,
310
+ val contentHash: String,
311
+ val slots: List<Slot>,
312
+ val frames: Int,
313
+ val curve: IntArray,
314
+ )
315
+
316
+ /**
317
+ * One mode-17 patch (COMMIT, plus CLEAR on a repack) and the mode-12 uploads
318
+ * that must precede it. Null when the frame does not fit the slot table or
319
+ * the texture cache; the caller then falls back to the raster path.
320
+ */
321
+ fun encode(
322
+ frame: SceneFrame,
323
+ originX: Int,
324
+ originY: Int,
325
+ cache: G2CfwTextureCache,
326
+ rasterize: (SceneElement, Int, Int) -> ByteArray?,
327
+ ): Encoded? {
328
+ val desired = mutableListOf<Desired>()
329
+ for (element in frame.elements) {
330
+ val slots = slotsFor(element, originX, originY, cache, rasterize) ?: return null
331
+ desired.add(
332
+ Desired(element.id, element.contentHash, slots, transitionFrames(element.transitionMs), easingCurve(element.easing)),
333
+ )
334
+ }
335
+ if (desired.sumOf { it.slots.size } > MAX_SLOTS) return null
336
+
337
+ val previous = elements.associateBy { it.id }
338
+ var repack = needsRepack || frame.replay
339
+ val assigned = mutableListOf<List<Int>>()
340
+ if (!repack) {
341
+ val used = mutableSetOf<Int>()
342
+ for (item in desired) {
343
+ previous[item.id]?.takeIf { it.slots.size == item.slots.size }?.let { used.addAll(it.indices) }
344
+ }
345
+ var next = (used.maxOrNull() ?: -1) + 1
346
+ var lastIndex = -1
347
+ for (item in desired) {
348
+ val prev = previous[item.id]
349
+ val indices =
350
+ if (prev != null && prev.slots.size == item.slots.size) {
351
+ prev.indices
352
+ } else {
353
+ List(item.slots.size) { next + it }.also { next += item.slots.size }
354
+ }
355
+ val first = indices.firstOrNull()
356
+ if (first != null && first <= lastIndex) {
357
+ repack = true
358
+ break
359
+ }
360
+ indices.lastOrNull()?.let { lastIndex = it }
361
+ assigned.add(indices)
362
+ }
363
+ if (next > MAX_SLOTS) repack = true
364
+ }
365
+ if (repack) {
366
+ assigned.clear()
367
+ var next = 0
368
+ for (item in desired) {
369
+ assigned.add(List(item.slots.size) { next + it })
370
+ next += item.slots.size
371
+ }
372
+ }
373
+
374
+ val ops = ByteArrayOutputStream()
375
+ if (!repack) {
376
+ val kept = desired.mapTo(HashSet()) { it.id }
377
+ for (state in elements) {
378
+ if (state.id in kept) continue
379
+ for (index in state.indices) {
380
+ ops.write(1)
381
+ ops.write(index)
382
+ }
383
+ }
384
+ }
385
+ val committed = mutableListOf<ElementState>()
386
+ for ((item, indices) in desired.zip(assigned)) {
387
+ val prev = if (repack) null else previous[item.id]
388
+ for ((k, slot) in item.slots.withIndex()) {
389
+ val index = indices[k]
390
+ if (prev != null && prev.slots.size == item.slots.size) {
391
+ val old = prev.slots[k]
392
+ if (old == slot) continue
393
+ if (item.frames >= 2) {
394
+ val tween = tweenRecord(old, slot, index, item.frames, item.curve)
395
+ if (tween != null) {
396
+ ops.write(tween)
397
+ continue
398
+ }
399
+ }
400
+ }
401
+ ops.write(setRecord(slot, index))
402
+ }
403
+ committed.add(ElementState(item.id, item.contentHash, item.slots, indices))
404
+ }
405
+
406
+ val payload = ByteArrayOutputStream()
407
+ payload.write(17)
408
+ payload.write(if (repack) 0x03 else 0x01)
409
+ payload.write(0)
410
+ payload.write(ops.toByteArray())
411
+ elements = committed
412
+ needsRepack = false
413
+ return Encoded(payload.toByteArray(), cache.takeUploadPayloads())
414
+ }
415
+ }
@@ -227,6 +227,16 @@ internal class G2CfwTextureCache {
227
227
  * The cache offset of an image, uploading it on first residency.
228
228
  * `gray` is the width×height 8-bit grayscale raster.
229
229
  */
230
+ /** The cache offset of a raw byte string (builtin-font text for scene TEXT slots), uploading it on first use. */
231
+ fun ensureBytes(key: String, bytes: ByteArray): Int? {
232
+ images[key]?.let { return it }
233
+ if (bytes.isEmpty()) return null
234
+ val offset = allocate(bytes.size) ?: return null
235
+ images[key] = offset
236
+ pendingUploads.add(offset to bytes)
237
+ return offset
238
+ }
239
+
230
240
  fun ensureImage(hash: String, gray: ByteArray, width: Int, height: Int): Int? {
231
241
  images[hash]?.let { return it }
232
242
  if (width !in 1..255 || height !in 1..255 || gray.size != width * height) return null
@@ -50,8 +50,8 @@ internal object G2FirmwareCatalog {
50
50
  displayName = "Glassly CFW",
51
51
  version = "2.2.9.22 CFW",
52
52
  fileName = "g2-glassly-cfw-2.2.9.22.evenota.bin",
53
- sha256 = "ceeede999529505ebc0a1369f57ab3c941273379a90b16669ccd39cc6bd99135",
54
- size = 4_515_846,
53
+ sha256 = "fe6051085507269c8007bc1245bfe8bd10d5c566540389ae976daaf753f536f9",
54
+ size = 4_527_476,
55
55
  ),
56
56
  )
57
57