@glassly/bluetooth-sdk 0.1.0-dev.105 → 0.1.0-dev.106
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.
- package/android/src/main/assets/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +17 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +3 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +66 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +35 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +415 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +10 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +2 -2
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +16 -2
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +2 -2
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +175 -0
- package/build/BluetoothSdk.types.d.ts +2 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/generated/releaseMetadata.js +3 -3
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +16 -2
- package/ios/Source/GeneratedReleaseMetadata.swift +3 -3
- package/ios/Source/Resources/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
- package/ios/Source/sgcs/G2.swift +48 -3
- package/ios/Source/sgcs/G2CfwRenderer.swift +37 -0
- package/ios/Source/sgcs/G2CfwScene.swift +394 -0
- package/ios/Source/sgcs/G2CfwTextureCache.swift +10 -0
- package/ios/Source/sgcs/G2Flasher.swift +2 -2
- package/ios/Source/sgcs/SGCManager.swift +34 -3
- package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +187 -0
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +2 -0
- package/src/generated/releaseMetadata.ts +3 -3
|
Binary file
|
|
@@ -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 =
|
|
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.
|
|
7
|
-
const val RELEASE_SET_ID: String = "glassly-0.1.0-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "0.1.0-dev.106"
|
|
7
|
+
const val RELEASE_SET_ID: String = "glassly-0.1.0-dev.106"
|
|
8
|
+
const val SOURCE_COMMIT: String = "a98050b8f0c6d44cef52b09ebb187f6bf49edaf4"
|
|
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 {
|
|
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 = "
|
|
54
|
-
size =
|
|
53
|
+
sha256 = "fe6051085507269c8007bc1245bfe8bd10d5c566540389ae976daaf753f536f9",
|
|
54
|
+
size = 4_527_476,
|
|
55
55
|
),
|
|
56
56
|
)
|
|
57
57
|
|