@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
@@ -3,35 +3,64 @@ package com.glassly.bluetoothsdk.sgcs
3
3
  import java.io.ByteArrayOutputStream
4
4
 
5
5
  /**
6
- * Phone-side model of the Glassly CFW's lease-scoped 64 KiB texture cache
7
- * (g2flash texture_cache.c), plus the mode-12/13/14 wire encoders.
6
+ * Phone-side mirror of the Glassly CFW's asset store (GLASSLYCFW/38, g2flash
7
+ * scene.c): the images, cached fonts and strings that scene objects reference
8
+ * by 32-bit id.
8
9
  *
9
- * The firmware allocates and zeroes the cache on the first mode-12 write and
10
- * frees it when the fbguard lease ends, so this model is only valid for one
11
- * lease/generation: the owner bumps a generation counter whenever the lease
12
- * cycles (or a send fails and residency becomes unknown) and the model
13
- * re-learns from scratch — uploads are cheap (a full mono font is a few KiB).
10
+ * The glasses hold assets in a 256 KiB store behind their own allocator and
11
+ * evict the least recently used unreferenced ones when room is needed, so the
12
+ * wire never carries a storage offset: every asset has an id (a hash of what
13
+ * it is) and a version (a hash of its bytes), and a PUT_ASSET entry uploads it.
14
+ * This mirror remembers which ids it has queued for upload and predicts the
15
+ * store's occupancy; when the prediction overflows it forgets what the glasses
16
+ * are most likely to have evicted. A wrong prediction is harmless: the firmware
17
+ * answers a SHOW that references an absent asset with MISSING, and
18
+ * [forget] makes the next frame re-upload exactly those.
19
+ *
20
+ * The model is valid for one cache generation: the owner bumps a generation
21
+ * counter whenever the on-glass cache is lost (lease end, reset, failed send)
22
+ * and the model re-learns from scratch.
14
23
  *
15
24
  * Cached image format: `[width:u8][height:u8][4bpp RLE]` where the RLE covers
16
25
  * exactly width*height pixels with NO row-pad nibbles (unlike modes 3/6).
17
- * Mode-14 fonts are a 96-entry little-endian u16 image-offset table for
18
- * characters 32..127; a zero entry points at cache offset 0, so the first
19
- * two bytes of the cache are left zero (width 0 ⇒ invalid image) and real
20
- * allocations start past them.
26
+ * A font asset is a 96-entry little-endian u32 table of glyph image offsets
27
+ * (characters 32..127, relative to the asset; 0 = absent) followed by the
28
+ * glyph images, uploaded whole.
21
29
  */
22
30
  internal class G2CfwTextureCache {
23
31
  companion object {
24
- const val CACHE_SIZE = 64 * 1024
32
+ const val STORE_SIZE = 256 * 1024
33
+ const val GRANULE = 16
34
+ const val MAX_ASSETS = 256
25
35
  const val FONT_TABLE_CHARS = 96 // ASCII 32..127
36
+ const val FONT_TABLE_ENTRY_BYTES = 4
26
37
 
27
- /** Keep mode-12 payloads comfortably inside one BLE image message. */
28
- const val MAX_UPLOAD_PAYLOAD_BYTES = 3600
38
+ /** A PUT message is one transport message; keep them a few round trips long. */
39
+ const val MAX_UPLOAD_PAYLOAD_BYTES = 8192
29
40
 
30
41
  /**
31
42
  * 0x0F: identity ramp (top color 15), opaque — a full-cell glyph or a
32
43
  * full-box image drawn opaque self-erases whatever was under it.
33
44
  */
34
45
  const val OPAQUE_DRAW_OPTIONS = 0x0F
46
+ const val KIND_IMAGE = 0
47
+ const val KIND_FONT = 1
48
+ const val KIND_STRING = 2
49
+
50
+ /** Nonzero 31-bit FNV-1a of a key: asset ids and content versions. */
51
+ fun hash31(s: String): Int = hash31(s.toByteArray(Charsets.UTF_8))
52
+
53
+ fun hash31(bytes: ByteArray): Int {
54
+ var h = 0x811C9DC5.toInt()
55
+ for (b in bytes) {
56
+ h = h xor (b.toInt() and 0xFF)
57
+ h *= 0x01000193
58
+ }
59
+ h = h and 0x7FFFFFFF
60
+ return if (h == 0) 1 else h
61
+ }
62
+
63
+ private fun footprint(length: Int): Int = (length + GRANULE - 1) / GRANULE * GRANULE
35
64
 
36
65
  /**
37
66
  * RLE over a flat 8-bit grayscale pixel sequence (quantized to 4bpp),
@@ -39,232 +68,276 @@ internal class G2CfwTextureCache {
39
68
  * contract. Token forms match modes 3/6.
40
69
  */
41
70
  fun rleEncodePixels(gray: ByteArray): ByteArray {
42
- val output = ByteArrayOutputStream()
71
+ val out = ByteArrayOutputStream()
43
72
  var index = 0
44
73
  while (index < gray.size) {
45
- // Same rounded quantization as pack4Bpp: a cached draw must reproduce
46
- // the composited frame's exact levels or the phone-side shadow
47
- // diverges from the panel.
48
- val color = G2CfwRenderer.grayNibble(gray[index].toInt())
74
+ val color = G2CfwRenderer.grayNibble(gray[index].toInt() and 0xFF)
49
75
  var end = index + 1
50
- while (end < gray.size && G2CfwRenderer.grayNibble(gray[end].toInt()) == color) end += 1
76
+ while (end < gray.size && G2CfwRenderer.grayNibble(gray[end].toInt() and 0xFF) == color) end++
51
77
  var remaining = end - index
52
78
  while (remaining > 0) {
53
79
  val chunk = minOf(remaining, 0xFFFF)
54
80
  when {
55
- chunk <= 15 -> output.write((chunk shl 4) or color)
56
- chunk <= 255 -> {
57
- output.write(color)
58
- output.write(chunk)
59
- }
60
- else -> {
61
- output.write(color)
62
- output.write(0)
63
- output.write(chunk and 0xFF)
64
- output.write((chunk ushr 8) and 0xFF)
65
- }
81
+ chunk <= 15 -> out.write((chunk shl 4) or color)
82
+ chunk <= 255 -> { out.write(color); out.write(chunk) }
83
+ else -> { out.write(color); out.write(0); out.write(chunk and 0xFF); out.write((chunk ushr 8) and 0xFF) }
66
84
  }
67
85
  remaining -= chunk
68
86
  }
69
87
  index = end
70
88
  }
71
- return output.toByteArray()
89
+ return out.toByteArray()
72
90
  }
73
91
 
74
- /** Mode-13 cached-image draw: `[13][offset:u16][x:u16][y:u16][options]`. */
75
- fun imageDrawPayload(offset: Int, x: Int, y: Int, options: Int): ByteArray? {
76
- if (offset !in 0..0xFFFF || x !in 0..0xFFFF || y !in 0..0xFFFF) return null
77
- val payload = ByteArrayOutputStream()
78
- payload.write(13)
79
- for (value in intArrayOf(offset, x, y)) {
80
- payload.write(value and 0xFF)
81
- payload.write((value ushr 8) and 0xFF)
92
+ fun ByteArrayOutputStream.writeU32(value: Int) {
93
+ write(value and 0xFF)
94
+ write((value ushr 8) and 0xFF)
95
+ write((value ushr 16) and 0xFF)
96
+ write((value ushr 24) and 0xFF)
97
+ }
98
+
99
+ fun ByteArrayOutputStream.writeI16(value: Int) {
100
+ val v = value.coerceIn(-32768, 32767)
101
+ write(v and 0xFF)
102
+ write((v ushr 8) and 0xFF)
103
+ }
104
+
105
+ /** PUT_ASSET entries for one asset: `[0][id][version][kind][total][offset][len][data]` per chunk. */
106
+ fun putAssetEntries(id: Int, version: Int, kind: Int, data: ByteArray): List<ByteArray> {
107
+ val out = mutableListOf<ByteArray>()
108
+ var offset = 0
109
+ val chunk = MAX_UPLOAD_PAYLOAD_BYTES - 32
110
+ do {
111
+ val len = minOf(chunk, data.size - offset)
112
+ val entry = ByteArrayOutputStream()
113
+ entry.write(0)
114
+ entry.writeU32(id)
115
+ entry.writeU32(version)
116
+ entry.write(kind)
117
+ entry.writeU32(data.size)
118
+ entry.writeU32(offset)
119
+ entry.write(len and 0xFF); entry.write((len ushr 8) and 0xFF)
120
+ entry.write(data, offset, len)
121
+ out.add(entry.toByteArray())
122
+ offset += len
123
+ } while (offset < data.size)
124
+ return out
125
+ }
126
+
127
+ /** Asset ids a [takeUploadPayloads] payload puts, in order. */
128
+ fun assetIdsInUploadPayload(payload: ByteArray): List<Int> {
129
+ if (payload.size <= 5 || payload[0].toInt() != 37) return emptyList()
130
+ val ids = mutableListOf<Int>()
131
+ var i = 5
132
+ while (i + 20 <= payload.size && payload[i].toInt() == 0) {
133
+ val id = (payload[i + 1].toInt() and 0xFF) or ((payload[i + 2].toInt() and 0xFF) shl 8) or
134
+ ((payload[i + 3].toInt() and 0xFF) shl 16) or ((payload[i + 4].toInt() and 0xFF) shl 24)
135
+ if (ids.lastOrNull() != id) ids.add(id)
136
+ i += 20 + ((payload[i + 18].toInt() and 0xFF) or ((payload[i + 19].toInt() and 0xFF) shl 8))
82
137
  }
83
- payload.write(options and 0xFF)
138
+ return ids
139
+ }
140
+
141
+ /** Mode-19 image draw: `[19][asset id:u32][x:i16][y:i16][options]`. */
142
+ fun imageDrawPayload(assetId: Int, x: Int, y: Int, options: Int): ByteArray? {
143
+ if (assetId == 0 || x !in -32768..32767 || y !in -32768..32767) return null
144
+ val payload = ByteArrayOutputStream()
145
+ payload.write(19)
146
+ payload.writeU32(assetId)
147
+ payload.writeI16(x)
148
+ payload.writeI16(y)
149
+ payload.write(options)
84
150
  return payload.toByteArray()
85
151
  }
86
152
 
87
153
  /**
88
- * Mode-14 cached-glyph run:
89
- * `[14][font-offset:u16][x:u16][y:u16][options][strlen:u8][chars]`.
90
- * Every char must be 32..126 and resident (the firmware validates each
154
+ * Mode-20 glyph run: `[20][font asset id:u32][x:i16][y:i16][options][strlen:u8][chars]`.
155
+ * Every char must be 32..126 and present in the font (the firmware validates each
91
156
  * referenced glyph and rejects the whole draw otherwise).
92
157
  */
93
- fun stringRunPayload(
94
- fontTableOffset: Int,
95
- x: Int,
96
- y: Int,
97
- options: Int,
98
- chars: ByteArray,
99
- ): ByteArray? {
100
- if (chars.isEmpty() || chars.size > 255) return null
101
- if (fontTableOffset !in 0..(CACHE_SIZE - FONT_TABLE_CHARS * 2)) return null
102
- if (x !in 0..0xFFFF || y !in 0..0xFFFF) return null
158
+ fun stringRunPayload(fontId: Int, x: Int, y: Int, options: Int, chars: ByteArray): ByteArray? {
159
+ if (chars.isEmpty() || chars.size > 255 || fontId == 0) return null
160
+ if (x !in -32768..32767 || y !in -32768..32767) return null
103
161
  if (chars.any { (it.toInt() and 0xFF) < 32 || (it.toInt() and 0xFF) > 126 }) return null
104
162
  val payload = ByteArrayOutputStream()
105
- payload.write(14)
106
- for (value in intArrayOf(fontTableOffset, x, y)) {
107
- payload.write(value and 0xFF)
108
- payload.write((value ushr 8) and 0xFF)
109
- }
110
- payload.write(options and 0xFF)
163
+ payload.write(20)
164
+ payload.writeU32(fontId)
165
+ payload.writeI16(x)
166
+ payload.writeI16(y)
167
+ payload.write(options)
111
168
  payload.write(chars.size)
112
169
  payload.write(chars)
113
170
  return payload.toByteArray()
114
171
  }
115
172
  }
116
173
 
117
- class FontEntry(val tableOffset: Int, val cellWidth: Int, val lineHeight: Int) {
118
- /** Cache offset per printable char (index = char - 32), null = not resident. */
119
- val glyphOffsets = arrayOfNulls<Int>(FONT_TABLE_CHARS)
120
- }
174
+ class FontEntry(val id: Int, val cellWidth: Int, val lineHeight: Int, val glyphs: Set<Int>)
175
+
176
+ private class Asset(val id: Int, val version: Int, val bytes: Int, var lastUse: Int)
121
177
 
122
178
  var generation = -1
123
179
  private set
124
180
 
125
- private val fonts = mutableMapOf<Int, FontEntry>()
126
- private val images = mutableMapOf<String, Int>()
181
+ /** Bytes the glasses could allocate for the store (the RESET reply says; 256 KiB when unknown). */
182
+ var storeCapacity = STORE_SIZE
127
183
 
128
- /**
129
- * Content hashes seen in earlier frames. Streaming content (a minimap
130
- * re-rendered every frame) never repeats a hash, so it never enters the
131
- * cache; static icons promote on their second sighting.
132
- */
184
+ private val fonts = mutableMapOf<String, FontEntry>()
185
+ private val assets = mutableMapOf<String, Asset>()
186
+ private val byId = mutableMapOf<Int, String>()
133
187
  private val seenImageHashes = ArrayDeque<String>()
188
+ private val pendingEntries = mutableListOf<ByteArray>()
189
+ private var useClock = 0
134
190
 
135
- /** Bump allocator. Bytes 0..1 stay zero as the invalid-image guard. */
136
- private var nextOffset = 2
137
-
138
- /** Mode-12 entries not yet flushed into payloads. */
139
- private val pendingUploads = mutableListOf<Pair<Int, ByteArray>>()
191
+ /** Predicted store occupancy of everything this mirror believes resident. */
192
+ var predictedBytes = 0
193
+ private set
140
194
 
141
- /**
142
- * The bump allocator filled up. The reference client recycles a full
143
- * cache (`TextureCacheState.reset` "or we ran out of space"); without
144
- * recovery every later ensure fails and the texture path silently dies
145
- * for the rest of the lease. The renderer checks this before each frame
146
- * and starts a fresh generation.
147
- */
195
+ /** The prediction overflowed and the mirror dropped its oldest assets; advisory. */
148
196
  var exhausted = false
149
197
  private set
150
198
 
151
- /**
152
- * Forget everything and adopt `generation` (the on-glass cache was freed
153
- * or its state became unknown).
154
- */
199
+ /** Forget everything and adopt `generation` (the on-glass cache was freed or reset). */
155
200
  fun reset(generation: Int) {
156
201
  this.generation = generation
157
202
  fonts.clear()
158
- images.clear()
203
+ assets.clear()
204
+ byId.clear()
159
205
  seenImageHashes.clear()
160
- nextOffset = 2
161
- pendingUploads.clear()
206
+ pendingEntries.clear()
207
+ predictedBytes = 0
208
+ useClock = 0
162
209
  exhausted = false
163
210
  }
164
211
 
165
- private fun allocate(length: Int): Int? {
166
- if (length <= 0 || nextOffset + length > CACHE_SIZE) {
167
- if (length > 0) exhausted = true
168
- return null
212
+ /** The glasses reported these asset ids absent: upload them again on next use. */
213
+ fun forget(assetIds: Collection<Int>) {
214
+ for (id in assetIds) {
215
+ val key = byId.remove(id) ?: continue
216
+ val asset = assets.remove(key) ?: continue
217
+ predictedBytes -= asset.bytes
218
+ fonts.entries.removeAll { it.value.id == id }
169
219
  }
170
- val offset = nextOffset
171
- nextOffset += length
172
- return offset
173
220
  }
174
221
 
175
- /**
176
- * The font table for `fontSize`, allocating (and zero-filling on-glass by
177
- * virtue of the fresh cache) the 192-byte offset table on first use.
178
- */
179
- fun font(fontSize: Int, cellWidth: Int, lineHeight: Int): FontEntry? {
180
- fonts[fontSize]?.let { return it }
181
- val tableOffset = allocate(FONT_TABLE_CHARS * 2) ?: return null
182
- val entry = FontEntry(tableOffset, cellWidth, lineHeight)
183
- fonts[fontSize] = entry
184
- return entry
222
+ fun holds(assetId: Int): Boolean = byId.containsKey(assetId)
223
+
224
+ private fun ensureAsset(key: String, kind: Int, data: ByteArray): Int? {
225
+ useClock += 1
226
+ assets[key]?.let { it.lastUse = useClock; return it.id }
227
+ if (data.isEmpty() || data.size > storeCapacity) return null
228
+ val id = hash31(key)
229
+ val other = byId[id]
230
+ if (other != null && other != key) return null
231
+ val footprint = footprint(data.size)
232
+ makeRoom(footprint)
233
+ val asset = Asset(id, hash31(data), footprint, useClock)
234
+ assets[key] = asset
235
+ byId[id] = key
236
+ predictedBytes += footprint
237
+ pendingEntries.addAll(putAssetEntries(id, asset.version, kind, data))
238
+ return id
185
239
  }
186
240
 
187
- /**
188
- * Make `char` (ASCII 32..126) resident for `font`, uploading its RLE
189
- * raster and patching the font table. `rasterize` supplies the full-cell
190
- * 8-bit grayscale raster (cellWidth × lineHeight) on first upload.
191
- * Returns false when the cache is full or the raster is unavailable.
192
- */
193
- fun ensureGlyph(font: FontEntry, char: Int, rasterize: () -> ByteArray?): Boolean {
194
- if (char < 32 || char > 126) return false
195
- val index = char - 32
196
- if (font.glyphOffsets[index] != null) return true
197
- val gray = rasterize() ?: return false
198
- if (gray.size != font.cellWidth * font.lineHeight) return false
199
- val image = ByteArrayOutputStream()
200
- image.write(font.cellWidth)
201
- image.write(font.lineHeight)
202
- image.write(rleEncodePixels(gray))
203
- val bytes = image.toByteArray()
204
- val offset = allocate(bytes.size) ?: return false
205
- font.glyphOffsets[index] = offset
206
- pendingUploads.add(offset to bytes)
207
- pendingUploads.add(
208
- (font.tableOffset + index * 2) to
209
- byteArrayOf((offset and 0xFF).toByte(), ((offset ushr 8) and 0xFF).toByte())
210
- )
211
- return true
241
+ private fun makeRoom(footprint: Int) {
242
+ while (predictedBytes + footprint > storeCapacity || assets.size >= MAX_ASSETS) {
243
+ val victim = assets.entries.minByOrNull { it.value.lastUse } ?: break
244
+ exhausted = true
245
+ assets.remove(victim.key)
246
+ byId.remove(victim.value.id)
247
+ fonts.entries.removeAll { it.value.id == victim.value.id }
248
+ predictedBytes -= victim.value.bytes
249
+ }
212
250
  }
213
251
 
214
252
  /**
215
- * Record a sighting of an image content hash; true when it was already
216
- * seen in an earlier frame (worth caching).
253
+ * The font asset for `fontSize`, rasterizing every glyph the variant needs and
254
+ * uploading the whole font on first use. `rasterize` supplies the full-cell
255
+ * 8-bit grayscale raster (cellWidth × lineHeight) for a wire code; a code it
256
+ * cannot raster is left absent from the font.
217
257
  */
258
+ fun font(
259
+ fontSize: Int,
260
+ cellWidth: Int,
261
+ lineHeight: Int,
262
+ variant: String = "ascii",
263
+ codes: List<Int> = (32..126).toList(),
264
+ rasterize: (Int) -> ByteArray?,
265
+ ): FontEntry? {
266
+ val key = "font:$fontSize:${cellWidth}x$lineHeight:$variant"
267
+ fonts[key]?.let { existing ->
268
+ if (assets.containsKey(key)) {
269
+ useClock += 1
270
+ assets[key]?.lastUse = useClock
271
+ return existing
272
+ }
273
+ }
274
+ val table = ByteArray(FONT_TABLE_CHARS * FONT_TABLE_ENTRY_BYTES)
275
+ val body = ByteArrayOutputStream()
276
+ val glyphs = mutableSetOf<Int>()
277
+ for (code in codes) {
278
+ if (code < 32 || code > 127) continue
279
+ val gray = rasterize(code) ?: continue
280
+ if (gray.size != cellWidth * lineHeight) continue
281
+ val offset = table.size + body.size()
282
+ val index = (code - 32) * FONT_TABLE_ENTRY_BYTES
283
+ table[index] = (offset and 0xFF).toByte()
284
+ table[index + 1] = ((offset ushr 8) and 0xFF).toByte()
285
+ table[index + 2] = ((offset ushr 16) and 0xFF).toByte()
286
+ table[index + 3] = ((offset ushr 24) and 0xFF).toByte()
287
+ body.write(cellWidth); body.write(lineHeight)
288
+ body.write(rleEncodePixels(gray))
289
+ glyphs.add(code)
290
+ }
291
+ if (glyphs.isEmpty()) return null
292
+ val id = ensureAsset(key, KIND_FONT, table + body.toByteArray()) ?: return null
293
+ val entry = FontEntry(id, cellWidth, lineHeight, glyphs)
294
+ fonts[key] = entry
295
+ return entry
296
+ }
297
+
298
+ /** Record a sighting of an image content hash; true when it was already seen in an earlier frame. */
218
299
  fun noteImageSighting(hash: String): Boolean {
219
- if (images.containsKey(hash)) return true
300
+ if (assets.containsKey(hash)) return true
220
301
  if (hash in seenImageHashes) return true
221
302
  seenImageHashes.addLast(hash)
222
303
  if (seenImageHashes.size > 32) seenImageHashes.removeFirst()
223
304
  return false
224
305
  }
225
306
 
226
- /**
227
- * The cache offset of an image, uploading it on first residency.
228
- * `gray` is the width×height 8-bit grayscale raster.
229
- */
307
+ /** The asset id of an image, uploading it on first residency. `gray` is the width×height 8-bit raster. */
230
308
  fun ensureImage(hash: String, gray: ByteArray, width: Int, height: Int): Int? {
231
- images[hash]?.let { return it }
309
+ assets[hash]?.let { useClock += 1; it.lastUse = useClock; return it.id }
232
310
  if (width !in 1..255 || height !in 1..255 || gray.size != width * height) return null
233
311
  val image = ByteArrayOutputStream()
234
312
  image.write(width)
235
313
  image.write(height)
236
314
  image.write(rleEncodePixels(gray))
237
- val bytes = image.toByteArray()
238
- val offset = allocate(bytes.size) ?: return null
239
- images[hash] = offset
240
- pendingUploads.add(offset to bytes)
241
- return offset
315
+ return ensureAsset(hash, KIND_IMAGE, image.toByteArray())
316
+ }
317
+
318
+ /** The asset id of a string (1..128 bytes of UTF-8, no NUL), uploading it on first use. */
319
+ fun ensureBytes(key: String, bytes: ByteArray): Int? {
320
+ if (bytes.isEmpty() || bytes.size > 128 || bytes.any { it.toInt() == 0 }) return null
321
+ return ensureAsset(key, KIND_STRING, bytes)
242
322
  }
243
323
 
244
324
  /**
245
- * Drain the pending mode-12 entries into `[12][offset16][len16][data]...`
246
- * payloads, each at most [MAX_UPLOAD_PAYLOAD_BYTES]. An entry larger than
247
- * the budget gets a payload of its own (the BLE fragmenter splits it).
325
+ * Drain the pending PUT_ASSET entries into `[37][epoch 0][request 0][entries]`
326
+ * payloads, each at most [MAX_UPLOAD_PAYLOAD_BYTES]; the link stamps the epoch and request.
248
327
  */
249
328
  fun takeUploadPayloads(): List<ByteArray> {
250
- if (pendingUploads.isEmpty()) return emptyList()
329
+ if (pendingEntries.isEmpty()) return emptyList()
251
330
  val payloads = mutableListOf<ByteArray>()
252
- var current = ByteArrayOutputStream().apply { write(12) }
253
- for ((offset, data) in pendingUploads) {
254
- val record = ByteArrayOutputStream()
255
- record.write(offset and 0xFF)
256
- record.write((offset ushr 8) and 0xFF)
257
- record.write(data.size and 0xFF)
258
- record.write((data.size ushr 8) and 0xFF)
259
- record.write(data)
260
- if (current.size() > 1 && current.size() + record.size() > MAX_UPLOAD_PAYLOAD_BYTES) {
331
+ var current = ByteArrayOutputStream().apply { write(byteArrayOf(37, 0, 0, 0, 0)) }
332
+ for (entry in pendingEntries) {
333
+ if (current.size() > 5 && current.size() + entry.size > MAX_UPLOAD_PAYLOAD_BYTES) {
261
334
  payloads.add(current.toByteArray())
262
- current = ByteArrayOutputStream().apply { write(12) }
335
+ current = ByteArrayOutputStream().apply { write(byteArrayOf(37, 0, 0, 0, 0)) }
263
336
  }
264
- record.writeTo(current)
337
+ current.write(entry)
265
338
  }
266
- if (current.size() > 1) payloads.add(current.toByteArray())
267
- pendingUploads.clear()
339
+ if (current.size() > 5) payloads.add(current.toByteArray())
340
+ pendingEntries.clear()
268
341
  return payloads
269
342
  }
270
343
  }