
Protocole BLE rétro-conçu pour la CMF Watch Pro 2, documentant la disposition GATT, les trames de commande chiffrées en AES-128-CBC, l'échange d'authentification et la synchronisation des données de santé pour le développement d'une application compagne alternative.
Non officiel. Ce document décrit le protocole Bluetooth Low Energy (BLE) de la CMF Watch Pro 2 (CMF by Nothing), reconstruit par rétro-ingénierie pour une application compagnon alternative. Il n'est ni affilié à, ni approuvé par Nothing/CMF. À utiliser à vos propres risques.
Tous les entiers multi-octets dans l'en-tête de trame et les opcodes sont en big-endian. Les entiers à l'intérieur
des charges utiles de commande sont en little-endian sauf indication contraire (cela reflète le firmware de l'appareil) —
attention aux exceptions (GOALS_SET, GPS_PUSH, décalage/longueur du transfert en masse sont en big-endian).
Chaque affirmation non évidente ci-dessous est étiquetée selon la manière dont elle a été établie :
Lorsqu'une section ultérieure corrige une section antérieure, le texte antérieur est conservé avec un renvoi plutôt que supprimé — savoir quelles lectures ont été essayées et réfutées évite à la personne suivante le même détour.
Appareil de test pour toutes les captures : CMF Watch Pro 2-5485, firmware 1.0.0.73, numéro de série CI04102520008192,
MCU Actions ATS3089C (Cortex-M4), écran 466×360.
Le téléphone est le client GATT ; la montre est le périphérique, diffusant sous le nom CMF Watch Pro 2-XXXX
(4 caractères hexadécimaux).
Activez les notifications en écrivant 01 00 sur chaque CCCD (00002902-…). Le canal de commande
(fff1/fff2) transporte le protocole tramé ci-dessous. Le canal shell (77d4…) transporte du texte
brut de type AT (par ex. AT GETSECRET ; voir §14). Le canal de données (02f0…) transporte de gros blobs
binaires (cadran de montre, firmware, AGPS), coordonnés par des opcodes de contrôle sur le canal de commande.
UUID de services — la montre diffuse ~10 services principaux. Énumérés sur une unité réelle :
0xfff0 (commande), 0x180f (batterie), 0x180a (informations sur l'appareil), 0xefe7, 0xffd0,
02f00000-…ffe0 et 02f00000-…fe00 (données), 77d4e67c-2fe2-2334-0d35-9ccd078f529c (shell /
appairage), e49a3001-f69a-11e8-8eb2-f2801f1b9fd1, f48a23c0-f69a-11e8-8eb2-f2801f1b9fd1.
⚠️ L'UUID du service shell est
77d4e67c-…, et non77d4ff00-…. Les révisions antérieures de ce document supposaient que le service partageait le préfixeff00de ses caractéristiques (77d4ff01/77d4ff02, §14) — ce n'est pas le cas, du moins sur l'unité sur laquelle cela a été vérifié (constat issu de freethinkel/fmc, voir §Sources). Les UUID de caractéristiques sont inchangés. Non vérifié si77d4e67cest stable entre les unités — énumérez plutôt que de coder en dur.
🌐 Remarque Web Bluetooth. Chromium ne découvre jamais que les services que la page a répertoriés dans
optionalServices, même pour un appelgetPrimaryServices()non filtré — une page répertoriant 3 services en voit 3, tandis quechrome://bluetooth-internals(la couche C++ propre de Chrome, non scopée) affiche les 10. Si vous écrivez un client navigateur, listez chaque UUID ci-dessus à l'avance ou l'appairage échouera avec des services qui existent pourtant clairement. Pas de Web Bluetooth dans Firefox/Safari ; nécessite un geste utilisateur + HTTPS/localhost.
✅ Une session réelle complète s'est déroulée sur le seul canal de commande — lors d'une capture de 160 s en usage intensif, il n'y a eu aucun trafic sur les canaux de données/firmware ou shell, sauf lors d'un transfert explicite OTA/cadran de montre.
0xF5)Chaque message du canal de commande est encapsulé dans une ou plusieurs trames à en-tête de 11 octets :``` +------+-----------+--------+-------------+-------------+--------+-------------------+ | 0xF5 | chunkLen | cmd1 | chunkCount | chunkIndex | cmd2 | chunk bytes … | | 1 B | 2 B (BE) | 2 B BE | 2 B BE | 2 B BE | 2 B BE | chunkLen bytes | +------+-----------+--------+-------------+-------------+--------+-------------------+ __________________________ 11-byte header ____________________________/
- `cmd1`/`cmd2` forment ensemble l'**opcode** (voir §6). 🔎 confirmé par rapport au constructeur de trames
de l'application officielle (`C6117b.m30831g`).
- `chunkCount` = nombre total de morceaux pour cette commande ; `chunkIndex` est **basé sur 1**.
- `chunkLen` = nombre d'octets de `chunk` dans cette trame.
- Une seule écriture BLE peut être fragmentée par le MTU de la liaison ; le récepteur met en mémoire
tampon les octets bruts et ré-extrait les trames complètes. Les grandes charges utiles sont divisées
en plusieurs morceaux (même `cmd1/cmd2`, `chunkIndex` croissant) et réassemblées dans l'ordre.
### Convention d'opcode (✅ confirmé sur le fil)
- `cmd1 = 0xFFFF` : `cmd2` dans `0x80xx`/`0x90xx` = téléphone→montre (requête/définition) ; `0x00xx`/`0xa0xx` =
montre→téléphone (réponse). Les paires correspondent par l'octet de poids faible (`0x9055`↔`0xa055`, `0x8051`↔`0x0051`).
- `cmd1` spécifique à une fonctionnalité : suffixe `cmd2` = `0x0001` **SET**, `0x0002` **GET**, `0x0003` **ACK**.
### Corps du morceau
Pour chaque morceau, le corps est `payloadPiece ‖ CRC32_LE(payloadPiece)` (CRC 4 octets, petit-boutiste,
zlib/IEEE). Si la commande est **chiffrée** (voir §3), l'ensemble `payloadPiece ‖ CRC` est ensuite
chiffré en AES-128-CBC/PKCS7 et ce texte chiffré devient le `chunk` de la trame.
**Particularité du texte clair :** pour les opcodes en texte clair, la montre *compte* le CRC de 4 octets
dans `chunkLen` mais ne le **transmet** pas. Ainsi, lors du décodage d'une trame en texte clair, la longueur
réelle des données est `chunkLen − 4`. (Les trames chiffrées transportent le CRC à l'intérieur du texte
chiffré comme d'habitude.)
Dimensionnement des morceaux (pour que les morceaux chiffrés tombent sur les limites de blocs AES), avec
`maxWrite = mtu − 3` :
- chiffré : `floor((maxWrite − 11) / 16) * 16 − 4 − 1`
- texte clair : `maxWrite − 11 − 4 − 1`
✅ Toutes les valeurs `chunkLen` observées pour les trames chiffrées étaient des multiples de 16
(l'alignement des blocs est respecté).
---
## 3. Primitives cryptographiques
- **AES-128-CBC** avec remplissage **PKCS7** et un **IV fixe** (issu du firmware
`CmfCharacteristic.AES_IV`) :
`50 51 52 53 54 55 56 57 60 61 62 63 64 65 66 5A`.
- **CRC32** (zlib/IEEE), émis sous forme de 4 octets petit-boutistes.
- **SHA-256** sur la concaténation des parties.
Dérivation de clé :```
authkey = SHA256( rnd1 ‖ rnd2 ‖ secret )[0..16] // persisted across sessions
sessionKey = SHA256( nonce ‖ authkey )[0..16] // per connection
secret = secret d'appareil de 16 octets (obtenable depuis la montre via la commande shell
AT GETSECRET → GETSECRET:<32-hex>,OK).rnd1 = 16 octets aléatoires choisis par le téléphone ; rnd2 = 16 octets aléatoires provenant de la montre.nonce = octets issus de la réponse nonce de la montre.Une fois la clé définie, toutes les trames du canal de commande sont chiffrées en AES à l'exception des opcodes en clair listés au §5.
✅ Les deux dérivations validées : authkey récupéré depuis le ntwatch.db d'un téléphone rooté correspondait à la
valeur dérivée d'un rnd1/rnd2/secret capturé ; sessionKey reproduit à partir d'un nonce capturé
déchiffre les trames en direct.
Deux chemins d'entrée partagent la même terminaison nonce/confirm.
phone → (shell) AT GETSECRET watch → (shell) GETSECRET:<32hex>,OK phone: rnd1 = random16 ; signed1 = SHA256(rnd1 ‖ secret) phone → AUTH_PAIR_REQUEST (plaintext) payload = rnd1(16) ‖ signed1(32) // 48 B watch → AUTH_PAIR_REPLY (plaintext) payload = rnd2(16) ‖ signed2(32) // 48 B phone verifies signed2 == SHA256(rnd2 ‖ secret) phone: authkey = SHA256(rnd1 ‖ rnd2 ‖ secret)[0..16] → set crypto key = authkey phone → AUTH_PHONE_NAME (encrypted) payload = 0xA5 ‖ model(UTF-8) // e.g. "CMF Watch Pro 2" watch → AUTH_WATCH_MAC (encrypted) phone → AUTH_NONCE_REQUEST (encrypted) payload = 0xA5 watch → AUTH_NONCE_REPLY (encrypted) payload = nonce phone: sessionKey = SHA256(nonce ‖ authkey)[0..16] → set crypto key = sessionKey phone → AUTHENTICATED_CONFIRM_REQUEST (encrypted) payload = 0xA5 watch → AUTHENTICATED_CONFIRM_REPLY (encrypted) → state = Initialized
En cas de `AUTH_FAILED (0xFFFF,0xA061)` ou de non-correspondance de signature, l'authentification échoue.
### 4.2 Reconnexion (authkey déjà connu)```
set crypto key = authkey (persisted)
phone → AUTH_PHONE_NAME (encrypted) payload = 0xA5 ‖ model
watch → AUTH_WATCH_MAC (encrypted)
phone → AUTH_NONCE_REQUEST (encrypted) payload = 0xA5
watch → AUTH_NONCE_REPLY (encrypted) payload = nonce
sessionKey = SHA256(nonce ‖ authkey)[0..16] → set crypto key = sessionKey
phone → AUTHENTICATED_CONFIRM_REQUEST (encrypted) payload = 0xA5
watch → AUTHENTICATED_CONFIRM_REPLY (encrypted) → Initialized
✅ L'ordre de reconnexion (sans trafic shell) a été observé intact sur une capture réelle.
⚠️→✅
TIMEest obligatoire avant les requêtes de données. AprèsInitialized, la montre ne répondra pas àBATTERY,SERIAL_NUMBER_GET, ni à la poignée de mainACTIVITY_FETCH_*tant qu'unTIME (FFFF 8004)n'a pas été envoyé dans la session — sans lui, seul unFIRMWARE_VERSION_RETnon sollicité arrive et tout le reste expire. ✅ confirmé en direct (Pixel 8a) : envoi des trois GET sansTIME→ seul le firmware répond ; envoi deTIMEen premier → la batterie et le numéro de série commencent à répondre.
Ordre recommandé pour la phase 2 : TIME → FIRMWARE_VERSION_GET → SERIAL_NUMBER_GET →
BATTERY (0xA5) → envois de configuration → synchronisation santé (§8).
Il n'existe pas d'opcode « lecture » séparé pour la plupart des réglages. Envoyer un *_GET (cmd2 = 0x0002, payload
0xA5) fait répondre la montre avec l'opcode SET (cmd2 = 0x0001) transportant la valeur actuelle.
Les commandes SET sont acquittées avec cmd2 = 0x0003 et un corps vide.
Les trames sont chiffrées par AES une fois qu'une clé est définie, sauf ces opcodes, qui sont toujours en texte clair :
AUTH_PAIR_REQUEST (FFFF 8047), AUTH_PAIR_REPLY (FFFF 0048)DATA_CHUNK_WRITE_WATCHFACE (FFFF 9064), DATA_CHUNK_WRITE_FIRMWARE (FFFF 9042),
DATA_CHUNK_WRITE_AGPS (FFFF 905F)Les en-têtes de trame (cmd1/cmd2) voyagent toujours en clair, donc la séquence de commandes est visible dans
toute capture même sans la clé — seuls les payloads chiffrés nécessitent sessionKey.
(cmd1, cmd2)GET/SET/REQUEST = téléphone→montre ; RET/REPLY/ACK/RESPONSE/DATA = montre→téléphone.
| Nom | cmd1,cmd2 |
|---|---|
| MUSIC_INFO_SET / _ACK | FFFF 905C / FFFF A05C |
| MUSIC_BUTTON | FFFF A05D |
| Nom | cmd1,cmd2 |
|---|---|
| WEATHER_SET_1 (celui qui fonctionne) | FFFF 906B |
| WEATHER_SET_2 (ignoré sur Pro 2 — voir §9) | 0066 0001 |
Opcodes réservés au JS (
FFFF 8051,FFFF 0051,FFFF 90A2,FFFF 90C5,FFFF A056,FFFF 908A/908Bstatut/support ChatGPT) sont gérés dans le bytecode Hermes de l'application, pas dans la couche Java. Leurs en-têtes apparaissent dans les captures mais la sémantique des payloads est ⚠️ [incertaine].
Cadran / firmware / AGPS utilisent une boucle init → requête/écriture de morceaux → ack de fin :
(tous avec cmd1 = FFFF.) La montre pilote la boucle en émettant DATA_CHUNK_REQUEST_*(offset, length)
(offset/longueur = u32 big-endian) ; le téléphone répond avec DATA_CHUNK_WRITE_* transportant
payload[offset..offset+length] sur la caractéristique de données. Voir §11–§12 pour les détails.
Payload de TIME (FFFF 8004) = epochSeconds(i32, BE) ‖ utcOffsetMillis(i32, BE). Envoyé juste après
l'authentification pour que la montre affiche l'heure locale (et débloque les requêtes de données — voir §4.3).
⚠️ Les horodatages de santé de la montre sont en UTC. L'application compagnon doit ajouter le décalage UTC local avant de dériver le jour calendaire / l'heure locale. (Regrouper la santé par jour UTC brut fait basculer le jour à la mauvaise heure locale.)
Payload de TIME_FORMAT (005F 0001) = 1 octet : 00 = 24 h, 01 = 12 h.
ACTIVITY_FETCH_1 ; la montre répond ACTIVITY_FETCH_ACK_1 (premier octet 01 ⇒ prête).ACTIVITY_FETCH_2 ; la montre pousse ensuite une rafale de trames de données :
ACTIVITY_DATA, HEART_RATE_*, SPO2, STRESS, SLEEP_DATA, WORKOUT_SUMMARY[_V3].La synchronisation est séquentielle (doit suivre TIME ; la montre libère les flux après ACK_2), pas une
rafale unique. Une session lourde pousse ~170–210 trames de notification en ~160 s. ✅
ACTIVITY_DATA (32 octets chacun, LE) ✅Unité de calories : les calories d'activité sont rapportées en cal (calories-grammes). Divisez la somme quotidienne par 1000 pour obtenir des kcal. (Les calories du résumé d'entraînement, en revanche, sont déjà en kcal.)
timestamp(i32 LE) ‖ valeur(i32 LE)
(valeur = bpm / % SpO₂ / indice de stress).00DA 0001) est différente — 5 octets : timestamp(i32 LE) ‖ fc(u8).
✅ exemple en direct 5e dc 29 6a 4e → ts, fc = 78 bpm. Plages du score de stress : 1–29 / 30–59 / 60–79 / 80–99.SLEEP_DATA (en-tête de 18 octets + N × enregistrements de 8 octets) ✅Un SLEEP_DATA = une session de sommeil ; une nuit peut en contenir plusieurs (les micro-réveils divisent les sessions).
En-tête :
Chaque enregistrement de 8 octets : timestamp(u32) ‖ durée_s(u16) ‖ stade(u16).
Codes de stade : 1 = Profond, 2 = Léger/léger, 3 = REM, 4 = Éveillé. ✅ validé sur une nuit complète
(deux sessions, les totaux D/C/R/A se recoupent).
WORKOUT_SUMMARY v1 (54 octets) / _V3 (0160 0001)v1 : start(u32), end(u32), durée_s(u32), puis type/calories/pas/distance/FC-moyenne et un
bloc GPS/étendu. ✅ la disposition v1 est confirmée par rapport au firmware. WORKOUT_SUMMARY_V3 est une disposition plus récente
pour les mêmes données plus un bloc étendu d'environ 40 octets (exerciseLoad, aérobie/anaérobie, recoveryTime,
VO₂max, cadence, PAI, meilleurs temps de course…). L'ensemble des champs est connu (à partir de la base Room de l'application) mais les
décalages d'octets exacts dans ce bloc de 40 octets sont ⚠️ [incertains] — les fermer nécessite une capture brute
d'un entraînement GPS.
Les chaînes sont en UTF-8, tronquées en octets à la taille du champ (la troncature peut diviser un caractère
multi-octets, correspondant au comportement s.encode()[:max] du firmware) ; les champs courts sont complétés par des zéros à droite.
0065 0001) ✅ : iconCode(1) ‖ 0x00 ‖ when(u32 BE) ‖ titleLen(1) ‖ title ‖ body.
iconCode sélectionne l'icône de l'application (WhatsApp=8, Telegram=12, Instagram=18, Gmail=27 ; inconnu=0xFF).
Titre ≤ 20 octets, corps ≤ 128 octets. Envoyé depuis un client → la montre l'a affiché + ACK 0065 0003.005C 0001) ✅ : réponse = niveau(1) ‖ charge(1) (ex. 3b 00 = 59 %, pas en charge).00DE 0001) ✅ : len(1) ‖ ASCII (ex. 10 + "CI04102520008192").0095 0001) ✅ : taille_cm(1) ‖ poids_kg(1) ‖ âge(1) ‖ sexe(1 : 1=M)
(ex. = 172 cm / 73 kg / 31 / homme).now/utc_offset comme paramètres explicites
(déterministes, testables). Le transport fournit l'heure réelle.TIME verrouille tout (§4.3) — envoyez-le en premier ou la montre reste muette sur les requêtes de données.GOALS_SET et
GPS_PUSH sont big-endian, et le décalage/longueur du transfert en masse sont big-endian.La montre prend en charge (a) les cadrans photo/personnalisés (une image de fond + une horloge numérique dessinée par le firmware) et (b) les cadrans structurés (cadrans intégrés / du magasin : un fond plus des couches de sprites positionnées, des aiguilles, et des widgets texte). Les deux transfèrent via le canal de données par la boucle init → morceaux du §6.
Ce qui fonctionne réellement (✅ validé en direct) : construire un cadran photo à partir de n'importe quelle image et l'installer ; installer n'importe lequel des 103 cadrans du magasin hors ligne ; repeindre un cadran structuré (échanger le fond ou n'importe quel sprite non-fond) et déplacer ses couches ; réorganiser / commuter le cadran actif ; et construire un cadran structuré à partir de zéro — l'enveloppe de scène
0x20est décodée et le constructeur est implémenté (§11.7), prouvé hors ligne pour faire un aller-retour sur les 103 cadrans du magasin octet pour octet et pour émettre des conteneurs synthétiques qui passent le validateur du firmware lui-même. 🟡 la seule étape non prouvée est de regarder un rendu synthétique créé de zéro sur l'appareil via9075(la preuve structurelle hors ligne couvre déjà ce qui causait le rejet0a). Il n'y a aucune barrière de codec ou de transport et aucun besoin de la chaîne d'outils du fournisseur. Les anciennes affirmations « le rendu structuré est cuit dans le RES-pack / impossible via BLE » et « codec côté serveur cf=0x1f » étaient fausses (un bug de décalage+octets par pixel) — le firmware rend les cadrans structurés pilotés par les données à partir du fichier que vous envoyez.
DIAL_COMMAND (9055 / a055) ✅a055 = resultat(u8) ‖ selectIndex(u8) ‖ total(u8) ‖ max(u8) ‖ N × dialId(u32 LE) ‖ ffffffff. Exemple : 01 05 06 07 … = actif #5, 6 cadrans, max 7.CHANGE_DIAL (009F 0001) est inerte sur fw 1.0.0.73 (renvoie une constante, ne change pas) — ne l'utilisez
pas.INIT1 8052 (payload A5) → 0052 [0]=01 INIT2 9063 (photo, APPEND) | 9075 (structured, REPLACE) → A063 / A075 [0]=01 [ watch → DATA_CHUNK_REQUEST A064 (offset, length; u32 BE, +progress u8) phone → DATA_CHUNK_WRITE 9064 (bytes[offset..offset+length], plaintext) ] × N FINISH A065 → 9065 (payload A5)
Finish reply byte : `01` = activé et enregistré ; `0a` = stocké mais **non** activé / rejeté. Sur
Android, chaque `DATA_CHUNK_WRITE` doit partir en **un seul write BLE par frame** — concaténer puis
re-découper selon le MTU désynchronise les en-têtes et la montre boucle en demandant l'offset 0.
- **`9063` (photo) = AJOUT.** La liste des cadrans s'agrandit (6→7) ; `watchfaceId = 0xFFFFFFFF` (sentinel
personnalisé) pour ne jamais être rejeté comme doublon, et la montre l'active automatiquement.
- **`9075` (structuré) = REMPLACE** l'emplacement `old_id`. `old_id` **doit** déjà figurer dans la liste
(sinon `0a`). Pour réinstaller un id déjà présent, **supprimez-le d'abord** (9055 liste-moins-id)
puis téléversez « à neuf » — réutiliser un id en place donne `0a`.
### 11.3 Cadran photo / personnalisé — ✅ entièrement validé de bout en bout
**Conteneur** (aller-retour vérifié octet par octet ; tous les champs en little-endian) :```
0x00 magic 6c 8d c4 a5
0x04 count 12 00 00 00 (=18) [constant, NOT an element count]
0x08 00 × 8
0x10 lenFull u32 LE (length of the whole FULL block: tag+len+payload)
0x14 FULL tag 04 48 47 3a ‖ payloadLen(u32 LE) ‖ LZ4(RGB565-LE) → 466×466 [raw 434312 B]
THUMB tag 04 38 c4 21 ‖ payloadLen(u32 LE) ‖ LZ4(RGB565-LE) → 270×270 [raw 145800 B]
EOF-4 magic 6c 8d c4 a5 [trailer = magic repeated]
Codec = bloc LZ4 standard sur RGB565 little-endian, de haut en bas (payloadLen compte à partir du
premier octet LZ4). L'application officielle utilise LZ4-HC et supprime l'en-tête/pied de page de 21 octets du bloc LZ4 ; un
encodeur LZ4 simple avec uniquement des littéraux fonctionne également — la montre accepte tout LZ4 valide, l'identité d'octets n'est pas
requise. Les pixels en dehors du cercle inscrit (centre 233,233, rayon 233) sont définis sur 0x0000.
INIT_2 pour 9063 — en-tête exact (✅ c'est celui qui fonctionne) :```
01 ‖ size(u32 BE) ‖ FF FF FF FF ‖ 01 01 01 ‖ styleId(u16 BE) ‖ posX(u16 BE) ‖ posY(u16 BE) ‖
color565(u16 BE) ‖ FF × 8
`size` = longueur exacte du `.bin` ; `FFFFFFFF` = `watchfaceId` personnalisé ; `styleId` 0–4 sélectionne la disposition
d'horloge numérique intégrée (elle est toujours dessinée — il n'y a pas de mode « off ») ; `posX/posY` la positionnent (valeurs
connues 56 / 77) ; `color565` lui applique une teinte (par ex. `FFFF` = blanc). ⚠️ La forme plus courte `A5 ‖ size ‖ watchfaceId` est
**rejetée** avec le code de fin `0a` — utilisez l'en-tête complet ci-dessus. (Implémentation de référence :
`core-rust/engine.rs::build_wf_init2`, reflétant `C6135t.m31104u` dans l'application officielle.)
**Recette :** redimensionnez l'image à 466×466 (et une vignette 270×270), convertissez-la en RGB565-LE de haut en bas,
mettez éventuellement à zéro les pixels hors du cercle, compressez chaque partie en LZ4, assemblez le conteneur ci-dessus, puis téléversez
via le pipeline `9063` avec `watchfaceId = 0xFFFFFFFF`. (Codec de référence : `core-rust/watchface.rs`,
`work/codec_dfa.py`.)
### 11.4 Cadran structuré / de boutique — conteneur et codecs ✅
**Disposition du fichier** — l'en-tête de 36 octets est répété **octet pour octet comme un pied de page de 36 octets** à la fin du fichier
(✅ vérifié sur 15 cadrans ; un analyseur doit rejeter un fichier où ils diffèrent) :```
[36-byte header][scene TLV (§11.7)][asset pool][36-byte header again]
En-tête (structure identique pour les 103 cadrans de magasin ; tous les champs en little-endian) :``` 0x00 crc_tree u32 LE [CRC32-raw of header[0x04:0x24] ‖ scene section] ✅ see below 0x04 magic 01 00 00 XX [XX = 0x00 or 0x02; both seen, meaning of 0x02 unknown] 0x08 name char[16] [NUL-terminated, e.g. "SlopeTime", "Metaball"; may carry a non-zero tail after the NUL (@0x17) — round-trip it verbatim] 0x18 size_a u32 LE [= filesize − 36 = footer offset = header+body] ✅ 103 dials 0x1c size_b u32 LE [asset-pool length, exactly] ✅ 15 dials 0x20 crc_assets u32 LE [CRC32-raw of the asset pool] ✅ see below 0x24 … [body starts here: the 0x20 scene container, §11.7]
> ⚠️ **Correction (remplace « il n'y a pas de somme de contrôle bloquante »).** Les révisions précédentes lisaient `@0x00` comme un
> identifiant/hash par dialecte et `@0x20` comme « 3× mots u32 d'identifiant/hash \[pas un CRC] », et affirmaient que CRC32/Adler32/
> la somme d'octets ne correspondaient pas. Les deux mots **sont** des CRC32 — les tests précédents les ont manqués parce que la
> variante est non standard, et parce que la lecture « 3 mots à `0x20` » confondait le mot CRC unique
> avec les premiers octets du conteneur de scène qui commence à `0x24` (de même, « nom répété à
> `0x2c` » est le nœud de nom `0x86` de la scène, §11.11). Découverte issue de
> [freethinkel/fmc](https://github.com/freethinkel/fmc) ; re-vérifiée ici.
**CRC32-raw** = polynôme IEEE réfléchi `0xEDB88320`, **`init = 0`**, et **aucun XOR final** — c'est-à-dire
ni le `init=0xFFFFFFFF` ni le `^0xFFFFFFFF` du `crc32` standard. C'est exactement la raison pour laquelle
le CRC32 standard n'a jamais correspondu. Notez la dépendance d'ordre : `crc_assets` se trouve dans la plage
couverte par `crc_tree`, donc **écrivez `@0x20` d'abord, puis calculez `@0x00`**.```python
def crc32_raw(data: bytes) -> int: # tab = standard 0xEDB88320 reflected table
c = 0 # init 0, no final inversion
for b in data: c = tab[(c ^ b) & 0xFF] ^ (c >> 8)
return c & 0xFFFFFFFF
crc_tree = crc32_raw(f[0x04:0x24] + f[0x24:first_asset])
crc_assets = crc32_raw(f[first_asset:len(f)-36])
Verified : 9/9 magasins d'origine correspondent sur les deux mots, et 6/6 des modèles propres de ce dépôt
correspondent sur crc_tree.
🟡 Le firmware ne semble pas appliquer l'un ou l'autre des CRC. Chaque cadran que ce dépôt a installé sur
9075— y compris les reskins produits par le chemin de modification sur place à empreinte identique (§11.6), qui mute les charges utiles d'assets et les octets X/Y sans recalculer l'en-tête — s'est affiché correctement sur l'appareil. Donc un CRC obsolète n'est pas ce qui provoque un rejet0a(c'est l'invariant de la fenêtre du conteneur, §11.7). Traitez les CRC comme à écrire correctement de toute façon : peu coûteux, et le seul champ d'intégrité connu du format. Tout ce qui réécrit la scène ou le pool d'assets doit recalculer les deux mots.
Cadrans stub (~173 B, p. ex. ids 273/274/277) : ce sont des espaces réservés pour des faces cuites dans la ROM : en-tête + répertoire, aucun asset réel.
Assets — chacun est dimsWord(u32 LE) ‖ len(u32 LE) ‖ LZ4(payload), où
cf = dimsWord & 0x1f, w = (dimsWord >> 10) & 0x7FF, h = (dimsWord >> 21) & 0x7FF, et len
compte à partir du premier octet LZ4 (le 1f 00 01 00 que vous voyez souvent là est le premier jeton LZ4 — ne
le sautez pas). Taille décompressée = w·h·bpp :
✅ Les 4151/4151 assets des 103 cadrans se décodent exactement avec un décompresseur lz4.block
standard à w·h·bpp. La transparence est l'octet alpha (cf=5/24) ou 0x0000 (cf=4 en dehors du
cercle) — il n'y a ni RLE ni « échappement ». Encodage = re-raster → LZ4 standard → [dimsWord][len][LZ4].
INIT_2 pour 9075 — corps chiffré AES :```
kind(1) ‖ old_id(u32 LE) ‖ new_id(u32 LE) ‖ file_len(u32 LE)
`kind` = `0x02`/`0x03` ; `old_id` = dial actif courant (depuis `9055`) ; `file_len` = taille réelle du `.bin`
(= `@0x18 + 36`). Installer un `.bin` de store tel quel est le chemin garanti (Ring Data id 359 +
102 autres confirmés). (Référence : `core-rust/engine.rs::build_dial_replace_init`.)
### 11.5 Grammaire structurée du répertoire ✅ (décodée et implémentée — RÉVISÉ 2026-07-02)
> **⚠️ Révision (2026-07-02) : le schéma d'enregistrement plat `61 01 00` ci-dessous était
> systématiquement DÉCALÉ D'UN OCTET.** Le corps de scène est un TLV propre (§11.7) ; un **corps de feuille**
> dessinable (tags `0x30`/`0x38` statiques, `0x70` pointeur) est :
>
> ```
> 01 xx 00 [X u16][Y u16] …attrs… 61 [count u16][base u32][count×id u16] [05 05 00 01 pivX pivY]
> ```
>
> - l'attribut `0x01` ouvre le corps : **X,Y = coin supérieur gauche** sur le canevas 466² (le `s16 x,y` du
> `sty_picture_t` du SDK).
> - la **table de trames `61 …` ferme le corps** (`base` = pointeur d'asset ; `count` 1 = image, 10/11 =
> atlas de chiffres — l'ancien « type d'enregistrement `0a/0b` » était en fait ce count ! — 7/13/2 =
> feuille de trames de complication).
> - extras de pointeur : source+échelle `[src] 00 3c 00` dans l'attribut `0x01` ; pivot dans la
> **remorque** `05 05 00 01 [pivX][pivY]`. **Centre de rotation = `(X+pivX, Y+pivY)` par pointeur** —
> pas un (233,233) fixe : des sous-cadrans excentrés existent (ex. les aiguilles du dial 366 tournent
> autour de 150,150).
>
> Le balayage linéaire pour `61 01 00` cousait la table de trames+pivot de l'élément **N** au X/Y
> (et octet de tag, l'ancien « f3 ») de l'élément **N+1** — cela ne semblait correct que sur les cadrans
> analogiques dont les aiguilles adjacentes partagent une géométrie quasi identique. Le « mur des variantes
> compactes » (spec 24 §24.4.5) était cette même erreur de lecture. Implémenté comme `scan_scene_drawables`
> dans `core-rust/watchface_struct.rs` et `wfweb/src/codec/parse.ts` (scène = source principale pour
> images/pointeurs ; balayage plat conservé pour texte + repli non-enveloppe). Validé par l'oracle
> `wfweb/compare.html` (rendu vs PNG officiels du store, 99 cadrans) : 64→72 bons, 8→5 mauvais, diff
> moyenne 9,3→7,4 %.
Lecture historique d'enregistrement plat (remplacée, conservée pour contexte) :
- **Image statique** (`61 01 00`) : `asset_ptr(u32) ‖ elemId(u16) ‖ 05 05 00 01 ‖ pivotX(u16) ‖
pivotY(u16) ‖ 3B ‖ 01 ‖ 1b 00 ‖ X(u16) ‖ Y(u16)`. Coin supérieur gauche sur le canevas 466² =
`(X−pivotX, Y−pivotY)`.
- **Pointeur/aiguille** — même enregistrement d'image, pivoté à l'exécution. **Centre de rotation =
`(X+pivotX, Y+pivotY)`** (≈ 233,233 sur les cadrans analogiques). La **source de données est un `u8`
au décalage d'enregistrement `+36`**, échelle `u16` à `+38` (=60) : `0x0a`/`0x70` = heure
(`h·30°+m·0,5°`), `0x0e`/`0x71` = minute (`m·6°+s·0,1°`), `0x12`/`0x72` = seconde (`s·6°`). ✅ confirmé
par désassemblage des getters (repli RTC 10:10:30).
- **Widget texte / nombre** (`61 0a 00`) : `asset_ptr(u32) ‖ [10×u16 métriques de police] ‖ 40 01 00 ‖
drapeau ‖ 3B ‖ 01 ‖ u16 ‖ X(u16) ‖ Y(u16)`. `asset_ptr` pointe sur le glyphe « 0 » ; le chiffre *d* =
l'asset à `index("0") + d` (10 sprites cf=5 consécutifs, ex. `0123456789` et ponctuation `,°`). ✅ rendu.
- **Remplissage de complication = index de trame** (✅ confirmé pour les complications chiffre/énumération/
jauge, count>1) : la valeur indexe une **feuille de trames pré-rendues** dans le `.bin` — `frame =
(count−1)·val/100` (pourcentage) ou `frame = value` (chiffre à bascule / énumération). Table de trames =
sous-enregistrement `61 ‖ count(u16) ‖ base(u32) ‖ count×id(u16)`. Ex. la grande heure du 327 Digit Max
est une feuille de 13 trames (chiffres 0–12), `frame = hour`.
- **Anneau de progression / arc = clip sectoriel à l'exécution** (✅ 2026-07-02, **corrige la lecture
« les anneaux sont des feuilles de trames » de la spec 25 §2**) : le tag d'élément **`0x81`** porte un
**disque complet unique** (`61` table de trames `count == 1`), et le coin partiel est ce disque **clippé
en secteur de camembert** (`frac = value/max`, dans le sens horaire depuis 12 h) — vérifié pixel par
pixel sur 322 Glare 2 et `count==1` confirmé sur **20 cadrans**. Sur disque : corps `0x81` = sous `0x01`
(géométrie `x@+0 y@+2 w@+4 h@+6`, `61 1 base` inline = disque) + sous `0x5b` (spec d'arc). Implémenté
dans wfweb (`blendSector`).
⚠️ Le sous-enregistrement `0x5b` n'est **pas** juste « `max` u16 `@+4` » comme documenté précédemment —
cela lisait la moitié basse d'un `max i32` et manquait les **angles de balayage début/fin et la largeur
de trait** qui se trouvent juste après. Il existe aussi un frère procédural `0x80`/`0x5a` (avec un rayon
explicite) que ce document n'a jamais couvert. Disposition complète de l'enregistrement, et ce que
l'hypothèse précédente « dans le sens horaire depuis 12 h » a mal compris : **§11.15**.
- **Id de source de données** — le bloc d'attributs `82` de l'élément se trouve à `delim+3` (après le
dernier `40 01 00`), et **l'id de source est un `u8` à `+0x14`** (aussi `relX@+0x07 s16`,
`relY@+0x09 s16`, `anchor@+0x0C/0E`, `mode@+0x15`, `frame-count@+0x1A`). Anchor < 0 = aligner sur le
bord du parent. 🔎 Le firmware résout l'id via une table de getters à 142 entrées à `0x101f371c`
(chacune appelle `ux2sys_get(type)`).
⚠️ **Préférez lire l'id comme `meta[9]` de la structure (§11.11)** — un champ fixe — plutôt que ce
balayage avant d'attribut `82`, qui est la source du décalage d'un octet décrit en §11.8/§11.9. Table
complète des ids en §16 ; notez que les étiquettes santé/météo que cette section listait précédemment
en ligne (`0x19` FC, `0x1b` batterie, `0x24` température, `0x36` pas) sont **contestées et probablement
fausses** — voir l'encadré ⚠️ en §16. (L'exemple de groupe `0x07:0x0b:0x0f` = HH:MM:SS ci-dessous n'est
pas affecté.)
- **Nœud de groupe** (`0x68`) : imbrique ses enfants dans son propre corps TLV (`0x60` = valeur/texte,
`0x30` = statique) ; chaque `0x60` porte son id de source à `data+16`. Ex. un groupe `0x07:0x0b:0x0f` =
horloge HH:MM:SS. Analyseur d'élément TLV = `0x100db55c` (table de sauts indexée par `tag−0x70`).
### 11.6 Matrice de création
| Chemin | Statut | Notes |
|---|---|---|
| Cadran photo depuis n'importe quelle image | ✅ **fait** | §11.3 ; validé sur appareil |
| Installer l'un des 103 cadrans du store | ✅ **fait** | §11.4 ; `9075`, `old_id`=actif |
| Reskinner le fond cf=4 d'un cadran du store | ✅ **fonctionne en direct** | échanger la charge utile COMPLÈTE en place, définir le `len` de l'asset à la **nouvelle** taille de bloc (≤ ancienne), conserver la même empreinte de fichier, réinstallation propre |
| Ré-écrire par gabarit (échanger les pixels de n'importe quelle couche + déplacer la géométrie) | ✅ **rendu via BLE** | dial 373 : fond→cyan + un sprite cf=5→rouge + X déplacé 224→100, tout rendu, aiguilles en direct |
| Cadran structuré 100 % synthétique de zéro | ✅ **builder fait, validé hors ligne** | builder d'enveloppe `0x20` dans `watchface_struct.rs` (`build_container`/`serialize`/`validate_container`) ; aller-retour sur les 103 cadrans octet-exact + synthétique passe le validateur du firmware (§11.7). 🟡 rendu sur appareil via `9075` pas encore filmé |
| Polices système (`.font`) | ✅ **décodage/rendu (toutes)** | binaire LVGL (non propriétaire) ; 32 polices de chiffres (`num*/nm*`, non compressées) + 24 polices de texte (`font*`, LVGL RLE `comp=1`) toutes décodées — 12208 glyphes, 0 dépassements, ASCII complet. RLE = LVGL v8.3 `lv_font_fmt_txt.c` (SINGLE/REPEATE/COUNTER à 3 états + préfiltre XOR par ligne), porté 1:1, sans désassemblage |
⚠️ Pièges de reskin/ré-écriture qui causent un écran noir ou `0a` : laisser l'**ancien `len` d'asset**
(la montre lit au-delà du bloc → dépassement → noir) ; **agrandir le fichier** (rejeté à l'installation) ;
réutiliser un id **en place** au lieu d'une installation propre ; **réordonner les assets** sans corriger
la chaîne de tailles de blocs de la queue de références (§11.11). Pas fatal aujourd'hui mais écrivez-le
correctement quand même : tout changement de la scène ou du pool d'assets invalide les deux mots **CRC32**
d'en-tête — recalculez les deux (§11.4), `@0x20` avant `@0x00`.
### 11.7 L'enveloppe de scène `0x20` — décodée et builder implémenté ✅
Un cadran **réel** ré-écrit rend parce qu'il préserve l'enveloppe de scène du fichier. Un corps purement
synthétique d'enregistrements plats `61 …` est **rejeté** — l'analyseur du firmware (`WFManager_Parser`,
`0xdb35c`) exige que le corps (depuis le décalage `0x24`) commence par un conteneur de scène `0x20`.
Le fichier complet est :```
[0x00,0x24) header: perDialId@0 · version=1@4 · name[16]@8 · size_a@0x18 · size_b@0x1c · idWord0@0x20
[0x24, fa) scene: 20 <u16 L0> ( 21 <u16 L1> ( 86 <len>=name , 30/70/80/81… drawables ) [ 22 … AOD ] )
[fa, EOF) assets: [dimsWord u32][len u32][payload = 1f 00 01 00 + LZ4] …
size_a = filesize−36 · size_b = filesize−36−first_asset · 0x27+L0 == first_asset
La scène est un TLV imbriqué propre — [tag u8][len u16 LE][corps], les tags conteneurs 0x20/0x21/0x22/0x68
se récursent, les feuilles dessinables 0x30 (statique) / 0x70 (élément/pointeur) / 0x80 / 0x81 / 0x86 (nom).
(Les enregistrements plats 61 01 00 / 61 0a 00 sont des motifs qui vivent à l'intérieur des corps dessinables ; l'
ancien analyseur les trouvait par heuristique — et cousait les corps adjacents ensemble, voir la révision §11.5.
La disposition du corps dessinable est désormais entièrement décodée là-bas.) Le offset+len de chaque enfant doit tenir dans la
fenêtre de son parent ;
le premier octet du corps ≠ 0x20 → erreur d'analyse −16 ; un enfant dépassant sa fenêtre → −2 ; l'un ou l'autre fait
écrire au gestionnaire 9065 (0xeb50c) la fin .
Le constructeur est implémenté et validé hors ligne (core-rust/watchface_struct.rs :
SceneNode / serialize / parse_scene / validate_container / build_container /
build_container_raw ; CLI cmfwatch-wfgen reframe) :
scene_roundtrip_identity — tous les 103 cadrans du store : parse_scene→serialize reproduit la scène
octet pour octet (les len imbriqués recalculés correspondent) et validate_container passe sur chacun.build_reframe_identity / CLI reframe — réassembler le .bin entier à partir de zéro reproduit
le fichier octet pour octet sauf 1 octet de remplissage de nom (@0x17 ; pas une somme de contrôle).build_container_synthetic — compose un nouveau cadran (fond + dessinable imbriqué dans 20→21) qui
passe l'invariant exact du firmware (build_container émet des fenêtres imbriquées correctes).🟡 Encore non prouvé (nécessite la montre, non bloquant) : téléverser un synthétique créé de zéro via 9075
et le regarder s'afficher — la preuve structurelle hors ligne couvre déjà ce qui causait le rejet 0a.
Recoupement du rendu wfweb avec les vignettes officielles du store (oracle de pixels sur les 103 cadrans) et comblé quatre lacunes :
i16 (signés). ✅ Les ancres peuvent être négatives pour les éléments qui
s'étendent hors du canevas — par ex. la aiguille des secondes rouge du 275 se trouve à Y = 0xFFFC = −4 (un sprite 30×281,
source 0x12, pivoté depuis le centre au-dessus du bord supérieur). Lire X/Y comme u16 (65532) faisait
que la garde le supprimait. Analyser les deux comme signés et autoriser une petite plage négative.0x60 de niveau supérieur (pas seulement dans un groupe 0x68), et
la véritable source de données est le u8 au décalage d'enregistrement −5 — le scan avant des attributs 82 est
systématiquement décalé d'un ici et attrape l'attribut du frère suivant (dans le 275, le chiffre des minutes
récupérait le 0x18 du jour de semaine). Le « 10:10 » du 275 = heure 0x07@X≈306 + min @X≈369 avec le
comme statique adjacent entre eux, chacun un atlas de 11 glyphes (). ⚠️ En corrigeant X/Y
depuis , les , sinon une ré-exportation corrompt ces octets (casse
la même empreinte → ).Aussi : les vignettes officielles du store sont rendues à 10:10 (heure marketing classique), pas 10:12 — aligner l'heure de l'oracle sur 10:10 réduit nettement la différence de pixels moyenne. L'analyseur de wfweb fait désormais un aller-retour sur les 103 cadrans octet-exact (la correction des décalages d'écriture X/Y ci-dessus a éliminé les dernières discordances).
0x22 dans sa propre vue. ✅ Le parcours de scène saute déjà 0x22,
mais le scan plat texte/numéro parcourait tout [0x30, premierAsset) — il émettait donc la
variante toujours-active (AOD) de chaque élément comme couche normale. Sur « Gradient », l'atlas de date grise AOD
(décalage dans 0x22) se dessinait par-dessus la rouge normale. Correctif : étiqueter chaque enregistrement 0x22 avec
layer.aod=true (avec son propre ensemble de déduplication) et laisser renderAt(…, aod) les afficher uniquement en mode AOD
(le mode normal masque les couches aod ; le mode AOD masque les normales ; le fond est échangé par setAod
et se dessine toujours). Gain net de l'oracle en mode normal sur le corpus (284 : 31%→21%, +18 autres) —
les variantes AOD surdessinaient de nombreux cadrans — et la bascule AOD de l'éditeur montre désormais la vraie
disposition toujours-active au lieu des normales. La vraie AOD est un écran noir (pas de scène atténuée) :
si le cadran n'a pas de cadre de fond AOD dédié (dial.aod), la scène normale est masquée en mode AOD
pour qu'elle rende du noir + les éléments à leur propre couleur. Les AOD s'analysent via le
parcours de scène aussi (il récurse désormais le conteneur en étiquetant les dessinables , au lieu de les laisser
au scan plat où leur pivot ne correspondait pas → « non positionné ») ; les aiguilles AOD pivotent au
centre du canevas (le porte parfois un x/y d'aiguille hors centre que le firmware ignore — par ex.
l'heure de Gradient ). L'éditeur expose aussi cela comme une
(§UI) : chaque écran ne montre que ses propres couches et les modifications persistent indépendamment. Le rendu en mode normal est
octet-identique partout ; l'aller-retour reste octet-exact sur les 103 cadrans.40 01 00 XX (✅ confirmé par le firmware)Combien de chiffres un img_number dessine est un octet unique dans l'enregistrement de champ — l'octet de données XX
du sous-enregistrement d'attribut 40 01 00 XX de l'élément (le sous-enregistrement 0x40 qui se trouve après la
table de trames 61 [count][base][glyph-ids]) :
XX & 0x0F = nombre d'emplacements de chiffres (0 ⇒ défaut firmware 7).0x80 = remplissage de zéros (afficher les zéros de tête, par ex. « 09 » vs « 9 »).Confirmé par désassemblage du firmware (image XIP 0x10000000 ; routine de rendu 0x100d8e60) :
NDIG = ldrb[40sub+3] & 0x0F (→7 si 0) ; la valeur est bornée value % 10^NDIG et exactement NDIG
glyphes sont dessinés MS en premier, les zéros de tête supprimés sauf si bit7. Le u16 après la source (60 pour
la date, 1000 pour kcal) n'est PAS le compte — il alimente seulement l'insertion du glyphe séparateur de milliers/millions
(cmp #1000/#1000000), c'est pourquoi le modifier ne faisait rien. L'id de source ne plafonne pas non plus.
L'histogramme du corpus sur les 620 champs numériques correspond : les champs à 2 chiffres (heure/min/sec/date/temp/FC) se terminent
40 01 00 02/0x82 ; kcal …04 ; pas …05 ; les divisions d'horloge à un chiffre 0x81. Donc le champ date
40 01 00 82 = 2 chiffres, rempli de zéros — c'est la raison entière pour laquelle une température Fahrenheit rebondie
(≥100) était tronquée.
Correctif / éditeur : wfweb analyse digitCount/digitZeroPad (+digitCountOff) pour les champs numériques,
expose « Chiffres » + « Remplissage de zéros » dans l'inspecteur, écrit l'octet sur place (même empreinte),
et l'aperçu borne/remplit à digitCount pour refléter le firmware. Donc rebondir la source d'un champ et définir son
nombre de chiffres fonctionne pour n'importe quel champ (par ex. date→température °F → Chiffres 3). Oracle en mode normal
inchangé (0 régressions, 3 petites améliorations) ; aller-retour octet-exact sur les 103 cadrans.
(L'hypothèse antérieure « largeur de chiffres »/rectW était fausse — la largeur est uniquement la disposition, pas le compte.)
struct, et la queue de références de ressources ✅La scène (§11.7) est un TLV imbriqué propre — [tag u8][len u16 LE][corps]. Inventaire complet des tags tel qu'
observé sur le corpus :
✅ Cet inventaire est complet pour le corpus. En ne récursant que dans les tags conteneurs ci-dessus, le
TLV de scène des 15 cadrans vérifiés parcourt exactement jusqu'à sa longueur racine déclarée avec zéro tag
inconnu — donc un analyseur qui gère ce tableau gère tout le format, et un tag inconnu signifie une lecture
désalignée, pas un nouveau type de nœud. (Attention : un parcours qui récurse dans chaque nœud dont la longueur
se trouve être ≥ 3 descendra dans les corps struct/0x5b et hallucinera une longue queue de
« tags » ponctuels — les corps de feuilles ne sont pas du TLV.)
💡 Raccourci de création : la disposition automatique
0x48/0x68peut être entièrement sautée — chaque widget peut être placé avec unx,yabsolu directement au niveau supérieur de l'écran, ce que fait le constructeur de zéro (§11.7). Nécessaire uniquement pour lire les cadrans existants. Une largeurmetade0x8000marque un struct comme un enfant de disposition automatique d'un cadre (la position vient du parent, pas dex,y).
Corps 0x01 struct — un préfixe fixe de 18 octets suivi d'une queue optionnelle de références de ressources :```
+0x00 x i16 [signed — can be negative, see §11.8]
+0x02 y i16
+0x04 meta[14] ────────────────────────────────────────────────────
meta[0..1] w u16 [0x8000 = auto-layout child of a frame]
meta[2..3] h u16
meta[4..6] unknown [placeholder-looking (1,0,0)/(4,0,0); see §11.14]
meta[7] accent-tint capability flag — 4 = tintable (§11.14)
meta[9] DATA SOURCE ID (§16)
meta[10] sub / variant
meta[11..13] max u24 LE [the metric's nominal full-scale value]
+0x12 ref tail [61 …] — absent on imageless rings (0x80/0x81, §11.15)
> ✅ Cela unifie les « décalages magiques » des §11.5/§11.8/§11.9. Ces sections localisent les champs *relativement
> à l'octet de la table de trames `0x61`* — qui est simplement `+0x12` de cette structure, donc `−18`/`−16` = `x`/`y` et
> **`−5` = `meta[9]`, l'identifiant source**. Mêmes octets, une seule disposition propre. L'heuristique d'attribut `82`
> à balayage avant qui était systématiquement décalée d'un octet n'est pas du tout nécessaire : lisez `meta[9]` de la structure.
> Le `max` d'un champ numérique est de même simplement `meta[11..13]` (par exemple, les champs de jour du mois portent `max = 99`).
**Queue de réf** (`61`) — comment un nœud pointe vers ses bitmaps :```
+0x00 0x61 [tail type]
+0x01 count u16 [1 = single image · 10/11 = digit atlas · N = pick-list / frame sheet]
+0x03 base u32 [ABSOLUTE FILE OFFSET of the first asset block]
+0x07 count × u16 = the BLOCK SIZE (8 + payload len) of each referenced asset, in order
⚠️ Ces u16 de fin étaient auparavant documentés comme « count×id(u16) » / identifiants de glyphes. Ce sont des tailles de blocs : base, plus la somme cumulée de celles-ci, parcourt le pool d'assets entrée par entrée (✅ vérifié exact pour les 10 entrées d'un atlas de chiffres). Deux conséquences :
count−1 premières tailles sont déterminantes ; la valeur de la dernière entrée n'est jamais suivie, donc les fichiers existants portent parfois une valeur obsolète à cet endroit. Ne traitez pas une incohérence sur l'entrée finale comme une référence cassée.0x28 aperçu — la vignette du magasin/catalogue est intégrée dans le .bin lui-même (27 nœuds d'aperçu sur 15 cadrans), sous forme de pvStruct 0x08 : un préfixe de 5 octets plus la même queue de référence, sans x/y. Utile pour construire une interface de galerie sans livrer de PNG séparés.
0x02 ✅Le frère 0x02 d'un widget le rend conditionnel. Sans lui, le widget est toujours dessiné. Grammaire :```
count u8 , count × ( id u8 , op u8 , val u24 LE signed ) [5 bytes per entry]
`id` est un identifiant de source de données (§16) — y compris les **identifiants de slot synthétiques** du §11.13. Opérateurs, avec
comptages d'occurrences mesurés sur 15 cadrans :
| op | signification | vu |
|---|---|---|
| `0x01` | dessiner si `value == val` | 99 |
| `0x81` | identique à `0x01` (bit `0x80` défini — apparaît sur les variantes mutuellement exclusives) | 48 |
| `0x02` | **masquer** si `value == val` | 7 |
| `0x03` | dessiner si `value == val`, où `val` est un **marqueur de non-données** (ex. HR `1000`) | 13 |
| `0x05` | dessiner si `value >= val` | 58 |
| `0x06` | dessiner si `value <= val` | 50 |
| `0x04` | ⚠️ **inconnu** — 15 occurrences, aucune sémantique confirmée | 15 |
Règle de combinaison (telle qu'implémentée par le moteur de rendu de référence, masque `op & 0x7f`) : les entrées d'égalité
sont combinées en **OU**, puis les entrées masquer/`>=`/`<=` doivent **toutes** être vérifiées.
Ce mécanisme unique couvre la majeure partie de la variabilité d'exécution du format et explique les structures qui ressemblent
à des widgets dupliqués :
- **Dispositions 12h / 24h et métrique / impériale** — deux ensembles de widgets empilés au même endroit, chacun lié à
`id 0x73` (le drapeau d'unités) avec `val` 0 ou 1. Le cadran 275 possède six de ces paires (12 nœuds).
- **Espaces réservés « Pas de données »** — op `0x03` contre une sentinelle, ex. `id 0x5f, val 1000` (cadran 275, deux fois) :
dessiner l'art du tiret cadratin au lieu d'une température lorsque la métrique est indisponible.
- **Mises en évidence par compartiments** — une plage `0x05`/`0x06` appariée, ex. la chaîne de Metaball où chaque maillon s'allume
pour sa propre fenêtre de 5 minutes.
- **Alternatives de slots de complications** — liées aux identifiants de slot synthétiques du §11.13.
### 11.13 Slots de complications configurables — `0x85` + `0x5f` ✅ (remplace le §11.8)
Le §11.8 concluait que la métrique active d'une complication configurable est un état RAM de l'appareil et ne pouvait pas être
récupérée depuis le fichier. **C'était faux** — à la fois le menu de métriques du slot et sa sélection par défaut
se trouvent dans le `.bin`. Chaque nœud `0x85` porte un frère `0x5f` :```
+0x00 slotIndex u8 [0-based position among sibling 0x85 nodes]
+0x01 count u8 [how many metrics this slot offers]
+0x02 activeIdx u8 [index into the list below = the DEFAULT SHOWN METRIC]
+0x03 count × u8 [the metric ids themselves (§16)] … NUL padding
Les variantes réellement dessinées sont des groupes ordinaires 0x68 ailleurs dans l'arbre, chacun conditionné par
une condition 0x02 (§11.12) sur l'id synthétique 0x79 + slotIndex — ainsi les variantes du slot 0 se lient sur
0x79, celles du slot 1 sur 0x7a, et ainsi de suite. Pour rendre un slot : lisez activeIdx, puis dessinez la variante dont
la condition correspond à cet index.
Mesuré sur de vrais cadrans :
Les deux slots à 6 métriques du cadran 275 représentent 12 de ses 26 nœuds 0x02, exactement comme prévu :
01 79 81 0X 00 00 et 01 7a 81 0X 00 00 pour X = 0..5 — six variantes clés sur 0x79 (slot 0)
et six sur 0x7a (slot 1). (Les octets 0x79/0x7a que §11.8 appelait un « octet d'instance » sont ces ids de
liaison.) Ses 14 restants sont sans rapport : 12 sur 0x73 (l'indicateur 24h/unités métriques, val 0 ou 1 — six
paires de widgets basculant entre les mises en page 12h et 24h) et 2 sur 0x5f avec op 0x03 et val = 1000,
l'espace réservé « pas de données » pour la température.
Toujours un véritable état de l'appareil : quoi que l'utilisateur choisisse ensuite dans l'application compagnon, cela remplace
activeIdx à l'exécution, donc un aperçu reproduit la valeur par défaut du fichier, pas nécessairement ce qu'un
cadran donné affiche. imgs[0] d'un nœud 0x85 est un espace réservé « appuyer pour configurer » que le firmware dessine uniquement
dans son propre mode d'édition — ignorez-le lors de l'aperçu de l'affichage normal de l'heure.
meta[7] == 4 ✅Certains cadrans permettent à l'utilisateur de choisir une couleur d'accent sur l'appareil, et le firmware la substitue dans les
bitmaps du widget au moment du rendu. Le commutateur est un octet unique : meta[7] de la structure (§11.11) —
c'est-à-dire l'octet +0x0B du corps 0x01 — égal à 4 marque la ou les ressources de ce widget comme teintables.
.bin livré doit conserver ses pixels d'origine, sinon vous perdez définitivement le choix de l'utilisateur. Appliquez la
teinte uniquement dans le chemin d'aperçu/canvas.⚠️ N'« améliorez » pas cela en une heuristique de couleur — cette voie est une impasse avérée (documentée par fmc après l'avoir fait à la dure). La théorie intuitive est que les pixels marqués sont intégrés dans une couleur d'espace réservé reconnaissable que le firmware échange. Cela ne peut pas fonctionner : l'anneau teintable du cadran 348 Tumbler et les bandes de chiffres ordinaires non teintables des cadrans 282 Radar Sweep / 291 Vertical intègrent le exactement même RGB
(255,72,32)(vérifié de manière exhaustive, chaque pixel) ; et les cadrans 305 Dots (aiguille des heures) et 306 Large Number (chiffres) sont teintables tout en étant intégrés blanc uni, donc un test de couleur les manquerait entièrement. Des raffinements successifs (1 → 4 couleurs de référence, plus une liste d'autorisation de rôle de widget) ont tous échoué. Lisez l'indicateur.Recoupé avec l'appareil réel / l'application compagnon sur 7 cadrans, choisis pour solliciter les deux directions — 349 Theatre, 376 Digits time, 305 Dots, 306 Large Number, 304 Elaborate 2 offrent tous le réglage d'accent et ont tous des widgets
meta[7]==4; 316 Trailing (aiguille rougeâtre, sans réglage), 312 Disc et 295 Vortex n'en offrent aucun et n'ont aucun widget marqué.
meta[4..6] se trouve juste à côté de l'indicateur et semble pouvoir encoder une couleur sur certaines structures
(un RGB d'apparence réelle avec une queue f1=1,f2=255, contre l'espace réservé (1,0,0)/(4,0,0) des structures marquées).
Cela ne corrèle pas avec la capacité d'accent. ⚠️ Non résolu ; ignorez-le.
0x80/0x5a (procédural) et 0x81/0x5b (clippé par image) ✅Les deux variantes d'anneau associent une structure courte (x, y, meta avec l'id de source — généralement sans
queue de référence du tout) à un frère de spécification d'arc. §11.5 documentait uniquement « 0x5b : max u16 @+4 », qui est
la moitié basse d'un max i32 et manque la géométrie du balayage. Enregistrement complet :```
+0x00 min i32 LE [always 0 in the corpus]
+0x04 max i32 LE [100 in the corpus, except dial 332 = 60]
+0x08 start i16 LE [sweep start, units of 0.1° — SIGNED]
+0x0a end i16 LE [sweep end, units of 0.1° — SIGNED]
+0x0c width u16 LE [stroke width in px]
+0x0e radius u16 LE [0x5a ONLY — 0x81 takes its radius from the clipped image]
+0x0e / +0x10 trailer 01 00 kk ⚠️ unresolved (see below)
L'appariement est rigide : `0x80` transporte toujours exactement `0x01` + un `0x5a` de **19 octets**, `0x81` toujours exactement
`0x01` + un `0x5b` de **17 octets** (✅ 26/26 anneaux sur 15 cadrans). ⚠️ Mais le **`0x81` rogné par l'image
domine** — 25 de ces 26. La variante procédurale `0x80`/`0x5a` n'est apparue **qu'une fois** (cadran 273), donc son
champ `radius` et sa disposition de 19 octets reposent sur un seul échantillon ; à traiter avec suspicion jusqu'à nouvel ordre.
`frac = clamp((valeur − min) / (max − min), 0, 1)`, et l'arc rempli va de `start` vers `end`.
L'angle zéro est à **3 heures**, positif dans le sens horaire. Exemples mesurés :
| cadran | tag | min..max | start → end | largeur | rayon |
|---|---|---|---|---|---|
| 273 Activity Mood | `0x5a` | 0..100 | **−102,8° → 102,8°** | 42 | 222 |
| 273 Activity Mood | `0x5b` | 0..100 | 270,0° → 90,0° | 80 | (image) |
| 276 Dichotomy | `0x5b` | 0..100 | 60,0° → −120,0° | 23 | (image) |
| 304 Elaborate 2 | `0x5b` | 0..100 | −2,0° → 358,0° | 24 / 80 | (image) |
| 366 Combo | `0x5b` | 0..100 | 0,0° → 270,0° | 18 | (image) |
| 368 Function | `0x5b` | 0..100 | 0,0° → 360,0° | 20 | (image) |
> ⚠️ **Ceci corrige l'hypothèse « sens horaire à partir de 12 heures »** du §11.5. Ce n'est que le cas
> particulier `start = 0, end = 3600` (un balayage complet, où la convention est inobservable). Les vrais cadrans utilisent
> des **jauges partielles** (l'éventail ±102,8° du 273, l'anneau aux trois quarts de 270° du 366) et des **balayages négatifs**
> (60° → −120° du 276), donc un moteur de rendu qui balaie toujours un cercle complet depuis le haut les dessine mal.
> 🟡 La convention exacte de l'angle zéro et la règle de direction proviennent du moteur de rendu de fmc, recoupées
> avec ces valeurs sur disque — non vérifiées indépendamment pixel par pixel sur l'appareil par ce dépôt.
> Notez que `frac = valeur/max` et le résultat du découpage de secteur `0x81` du §11.5 **ont** été validés (cadran 322).
⚠️ **Non résolu : la fin de 3 octets `01 00 kk`.** `kk` prend des valeurs plausibles (104, 152, 216,
232, 248) et l'hypothèse évidente est un rayon — **testée et réfutée** : le cadran 366 utilise `kk = 104`
pour un anneau de 82×82 et un de 166×166, et le cadran 273 utilise `kk = 232` pour un anneau de 440×440 et un de 284×284.
Pas un rayon, pas un diamètre. Peut-être opacité/style. À faire un aller-retour tel quel.
🟡 **Piège signalé, non vérifié ici :** dans le moteur de rendu de fmc, un nombre `0x60` dont l'id de source est égal à l'id de
**n'importe quel** anneau sur le même écran affiche `round(frac × 100)` au lieu de la valeur brute — donc un
nombre de fréquence cardiaque à côté d'un anneau de fréquence cardiaque affiche `36` au lieu de `71 bpm`. Leur
solution documentée est de placer l'anneau et le nombre sur deux ids **alias** de la même métrique (pas
`0x19`/`0x26`/`0x49`, calories `0x1c`/`0x1e`/`0x48`). Que ce soit un comportement du firmware ou spécifique
à leur moteur de rendu n'est **pas établi** — à vérifier en direct avant de concevoir autour.
---
## 12. Détails du transfert en masse et de l'OTA
La table de transfert est au §6. Points supplémentaires confirmés :
- **AGPS/EPO** ✅ : le premier bloc écrit commence par l'en-tête ASCII `000000010000…`. La boucle complète
init → `[A05F ↔ 905F]×N` → fin a été observée sur le fil (~892 blocs).
- **OTA du firmware** (`9040`–`9042`, fin `9041`) 🔎 : structure cartographiée ; charge utile INIT2 = octets de version
(par ex. `0b 00 00 39` = 11.0.0.57). **Non testé sur le terrain** (l'application désactive la mise à jour du firmware ici). Les images
du firmware semblent être **non signées — l'intégrité n'est que CRC32** (aucune signature asymétrique observée en rétro-ingénierie).
- ⚠️ Parce que l'OTA et `FACTORY_RESET (009A 0001)` partagent la session authentifiée, une seule authentification BLE
valide suffit pour effacer ou (en principe) briquer la montre. À manipuler avec précaution.
---
## 13. Capteurs
✅ Matériel exposé via BLE :
- **PPG optique** — fréquence cardiaque (manuelle/auto/entraînement/repos), SpO₂ et stress dérivé de la VRC.
- **Accéléromètre 3 axes** — pas, distance, calories, phases de sommeil, lever de poignet, cadence.
- **GNSS/GPS** (assisté AGPS) — trace d'entraînement (`WORKOUT_GPS`) et envoi de position (`GPS_PUSH`).
Il n'y a **pas de baromètre/altimètre, boussole, gyroscope ou capteur de température cutanée/corporelle**. Une thermistance
NTC interne (température de la carte/batterie) existe mais n'est lisible **que** via le canal AT
(`AT GETNTCTEMP`, §14) — le flux d'historique de température cutanée `0155` est vide sur ce SKU.
**Détournements de widgets de données** (il n'y a pas de vraie API de complications/liaison de données — voir §11.5) : les
champs de texte existants de la montre peuvent être réutilisés pour afficher des données externes en un coup d'œil. Prouvé ✅ : la
**chaîne de ville** météo (`WEATHER_SET_1`, par ex. `"BRA 2x1 ARG"` est apparue sur le widget) et les champs musique
**titre/artiste** ; la liste **contacts** (20 × nom[32]+numéro[25]) fonctionne comme un panneau de données défilant. Tout
est des envois, pas des complications persistantes.
---
## 14. Canal AT d'usine / shell (`77d4ff01` / `77d4ff02`)
Un canal de commandes AT en texte brut séparé, indépendant du protocole tramé. ✅ testé en direct :
- **Lecture :** `AT GETSECRET` (secret d'appariement de 16 octets), `GETVERSION`, `GETSN`, `GETNAME`, `GETPID`,
`GETBATLV` (mV bruts, par ex. `3853mv`), `GETGSENSOR` (accélération brute en g, `X=… Y=… Z=…`),
`GETNTCTEMP` (°C, NTC interne).
- **Écriture / action :** `AT SETMOTOR=1` (faire vibrer le moteur), `SETHR/SETHRV/SETSPO2=…` (injection de test
capteur), `SETLCDSWITCH/SETGPSSWITCH/SETKEYSWITCH`.
Les réponses se terminent par `,OK`. Les commandes `SET*` s'exécutent généralement mais peuvent ne pas renvoyer `,OK` sur BLE — à confirmer
au cas par cas.
---
## 15. Fonctionnalités verrouillées par le firmware / indisponibles (🔎 rétro-ingénierie du firmware)
Certaines fonctionnalités sont présentes dans le firmware mais désactivées par SKU/région et **ne sont pas accessibles depuis le
téléphone/BLE** — elles nécessitent une modification du firmware, hors de portée ici :
- **Voix ChatGPT** — porte = id de fonctionnalité `ux2sys` `0x9e`, initialisée depuis NVRAM/EFUSE/région au démarrage ; sur
ce SKU, le drapeau de support `908b = 00`. Non influençable par le téléphone, le compte ou le BLE (confirmé par
expérience + rétro-ingénierie). L'application n'est qu'un relais ; l'audio va du téléphone au cloud Nothing.
- **Tension artérielle** — un sous-système complet existe dans le firmware, désactivé par SKU/région.
- **Alipay / paiement NFC** — interface complète présente, SKU Chine uniquement.
- **Absent du matériel/firmware :** ECG, SOS/urgence, NFC générique.
---
## 16. Ids de source de données / getter de complication
Pas nécessaire pour construire un client BLE, mais **essentiel pour créer ou rendre un cadran** : c'est la valeur de
`meta[9]` (§11.11) qui lie un widget à des données en direct, l'`id` d'une condition de visibilité (§11.12), et
les entrées du menu de métriques d'un emplacement (§11.13). Le firmware le résout via une **table de dispatch de getters à 142 entrées**
à `0x101f371c`, chaque entrée appelant `ux2sys_get(type)` (🔎 rétro-ingénierie du firmware).
### Heure / date — ✅ bien établi
| id | signification | id | signification |
|---|---|---|---|
| `0x01` | heure (12/24h selon le réglage de l'appareil) | `0x0f`, `0x12` | seconde (lisse) |
| `0x04` | heure (24h) | `0x10`, `0x11` | dizaines / unités de seconde |
| `0x07` | heure (24h forcé) | `0x71`, `0x72` | seconde (tic-tac / angle d'aiguille) |
| `0x02`, `0x03` | dizaines / unités d'heure-12h | `0x13` | drapeau AM/PM (0 = AM, 1 = PM) |
| `0x05`, `0x06`, `0x08`, `0x09` | dizaines / unités d'heure | `0x15`, `0x16` | mois |
| `0x0a`, `0x70` | angle d'aiguille des heures | `0x17` | jour du mois |
| `0x0b` | minute | `0x18` | jour de la semaine (0 = lundi ⚠️) |
| `0x0c`, `0x0d` | dizaines / unités de minute | `0x0e`, `0x71` | angle d'aiguille des minutes |
Les ids de dizaines/unités dessinent un **chiffre unique** — un widget lié à l'un d'eux affiche un glyphe, pas la
valeur entière (cadran 284 Square). Les ids d'angle d'aiguille (§11.5) sont `0x0a`/`0x70` heure = `h·30° + m·0,5°`,
`0x0e`/`0x71` minute = `m·6° + s·0,1°`, `0x12`/`0x72` seconde = `s·6°` (✅ confirmé par désassemblage
des getters, repli RTC 10:10:30).
### Santé / capteurs / météo — ⚠️ contesté, lisez la colonne de preuves
| id | signification (meilleure lecture actuelle) | preuves |
|---|---|---|
| `0x19` | **pas** | dans le menu d'emplacement du 368 aux côtés de `0x1a` ; correspond au `parse.ts` de ce dépôt |
| `0x1a` | **fréquence cardiaque** | dans le menu d'emplacement du 368 aux côtés de `0x5f` et `0x19` |
| `0x1c` | calories | menu d'emplacement, icône de flamme dans l'application compagnon |
| `0x1e` | calories (alias) | corpus |
| `0x22` / `0x23` | distance km / mi (partie entière) | corpus |
| `0x74` / `0x75` | distance km / mi (partie fractionnaire) | corpus |
| `0x76` | distance (forme d'emplacement) | menu d'emplacement, icône de route |
| `0x24` | **% de batterie** | menu d'emplacement, icône d'éclair |
| `0x30` | % de batterie | corpus |
| `0x36` / `0x5f` | température | `0x5f` = menu d'emplacement, icône nuage-soleil ; 361 TempoG lie un nombre simple |
| `0x48` | stations debout (heures debout) | menu d'emplacement, icône de figure debout |
| `0x8b` | IQA | menu d'emplacement |
| `0x73` | drapeau 24h / unités métriques | corpus |
| `0x25`–`0x27`, `0x49`, `0x6c`, `0x6f` | % d'objectif / alias d'emplacement des pas et calories | corpus |
| `0x6a` | ⚠️ métrique d'emplacement non identifiée | apparaît dans 4 menus d'emplacement |
| `0x79 + slotIndex` | **synthétique** — pas une métrique ; l'id de sélection d'emplacement (§11.13) | ✅ §11.13 |
> ⚠️ **Trois tables de ce dépôt étaient en désaccord ; celle-ci est la réconciliation.** Les révisions antérieures du §16
> et du §11.5 lisaient `0x19` comme fréquence cardiaque, `0x1b` comme batterie, `0x24` comme température et `0x36` comme pas —
> et `wfweb/src/codec/mock.ts` encode encore cette lecture, tandis que `wfweb/src/codec/parse.ts` encode une
> lecture différente (`0x19` pas, `0x24` % d'objectif, `0x48` stations debout, `0x1a` météo). Le tableau ci-dessus suit
> la lecture la mieux étayée (étiquettes calibrées par fmc contre les icônes du menu d'emplacement de widgets de l'application compagnon, voir §Sources). **Le code n'a pas encore été modifié — `mock.ts` et `parse.ts` sont toujours
> incohérents entre eux et avec ce tableau.** Traitez les étiquettes d'ids de santé comme ⚠️ jusqu'à ce que quelqu'un lie
> un champ à chaque id et lise la montre.
>
> La preuve unique la plus solide est **le menu d'emplacement du cadran 368 Function** (§11.13), qui propose
> `0x5f 0x1c 0x19 0x48 0x24 0x76 0x1a 0x8b` comme **huit métriques distinctes sélectionnables par l'utilisateur** dans un seul menu.
> Quelles que soient les étiquettes, deux de ces huit ne peuvent pas être la même métrique — ce qui exclut
> `0x19` = `0x1a` = fréquence cardiaque et `0x24` = `0x5f` = température simultanément.
Les complications d'anneau/arc avec une **feuille de cadres** indexent un cadre pré-rendu (par ex. 50 % = cadre 50 sur 100)
intégré dans le `.bin` que vous envoyez (§11.5), donc aucun pack RES externe n'est nécessaire ; les anneaux sans image sont dessinés
à partir de la spécification d'arc à la place (§11.15).
---
### Cartes rapides (tuiles d'accueil) — `QUICK_CARD (906D)` ✅
Les tuiles d'accueil de la montre. Le téléphone choisit seulement **quelles** tuiles afficher et dans **quel ordre** — les tuiles sont
rendues par le firmware (pas de canal de contenu). Premier octet de charge utile = sous-commande : `00` = GET, `01` =
SET ; **les deux utilisent `0x906D`** (`0x906C` est listé mais non utilisé — l'interroger expire). Réponse = `A06D`.
> 🛑 Envoyer un `assemblyId` inventé **efface les écrans de la montre** (elle accepte la liste, ne peut pas faire correspondre
> les ids, n'affiche rien). N'envoyez que des ids que vous **relisez** via GET ; récupérez via l'application officielle ou une
> réinitialisation d'usine.
**Réponse GET** ✅ : `statut(1) ‖ 00 ‖ N(1) ‖ N × groupe`, groupe = `tag=01 ‖ K(1) ‖ K×(assemblyId, sportId)`.
Trame réelle : `01 00 04 01 02 5d00 6100 01 03 1900 2e00 2300 01 03 5c00 0400 5a02 01 03 4800 5100 5300`
= 4 écrans / 11 cartes (`5a02` = carte Sport, sportId 2).
**Emplacements :** chaque écran a **4 emplacements**. Le type d'une carte définit sa taille — `circulaire`/`carré` = 1 emplacement,
`rectangle` = 2 emplacements. La validation est une simple arithmétique d'emplacements (Σ ≤ 4 par écran) ; aucune carte
mutuellement exclusive. `sportId` est `0` sauf sur les cartes Sport (87–91). Les ids `64` et `95` n'existent pas.
**Catalogue `assemblyId`** (chaque type logique = une plage contiguë de 6 variantes de style `_0`..`_5` ;
`0` = emplacement vide) :
| déc | carte | déc | carte |
|----|----|----|----|
| 0 | emplacement vide | 49–53,97–98 | Météo |
| 1–6 | Pas | 54–58 | Minuteur |
| 7–12 | Calories | 59–62 | Respiration |
| 13–18 | Station debout | 63,65–67 | Chronomètre |
| 19–24 | Activité modérée | 68–71 | Batterie |
| 25–30 | Fréquence cardiaque | 72–76 | Récents |
| 31–36 | SpO₂ | 77–81 | Contacts |
| 37–42 | Stress | 82–86 | Cadran / téléphone |
| 43–48 | Sommeil | 87–91 | Sport (`sportId` ≠ 0) |
| 92 | Musique | 93/94/96 | Enregistrement d'activité / PAI / Cycle |
---
## Annexe A. Catalogue des cadrans de série (id → nom)
Le §11 fait référence aux cadrans par id numérique tout au long (275 SlopeTime, 322 Glare 2, 357 Silhouette, …). Les ids
`273`–`376` sont les faces du magasin/de série ; l'id est ce que `DIAL_COMMAND (9055/a055)` signale comme actif et
ce que `9075` prend comme `old_id` (§11.4). 100 des 103 ids connus sont nommés ci-dessous ; les restants sont des
stubs ROM (§11.4). Noms et regroupement tels qu'affichés par l'application compagnon officielle.
- **Par défaut** (6) — `273` Activity Mood · `274` Sun Circle · `275` SlopeTime · `276` Dichotomy · `277` Prismatic Time · `280` Multifunction
- **Analogique** (34) — `286` Sundial · `287` Simple Dial · `292` City · `294` Sudoku · `305` Dots · `306` Large Number · `309` Gradient · `310` Glare · `311` Bold · `313` Classical · `314` Fragment · `315` Infinite · `316` Trailing · `322` Glare 2 · `326` Chrono Master · `327` Digit Max · `328` Coherent · `329` Wheel · `330` Zenith · `331` Intersection · `335` Time Phase · `336` Energetic · `338` Chronos · `341` Dual View · `346` Time Windmill · `347` Large Panel · `349` Theatre · `352` Elegant Sweep · `360` Explorer · `364` SportPulse · `370` Hemisphere · `371` ActiveTrio · `372` Time Wheel · `373` Traditional Pointer
- **Numérique** (41) — `281` Metaball · `282` Radar Sweep · `283` Radio · `285` Widgets · `288` Type · `289` Rotate · `290` Gradual · `291` Vertical · `293` Stairs · `296` Ladder · `297` Ray · `298` Eclectic · `299` Echo · `300` Mono Dial · `301` Orbit · `302` Calendar · `303` Space · `307` Sprung · `308` Sundial 2 · `319` One Line · `320` Orienteer · `321` Revolution · `323` Dash · `324` Finesse · `325` Metric · `333` Circularity · `334` Globe of Time · `337` Time Finder · `339` Suprematism · `340` Sport Mode · `345` Time Dot · `350` Timeline · `351` Cyclopes · `353` Dual phase · `357` Silhouette · `359` Ring data · `361` TempoG · `362` Steady · `365` Elegance · `369` Solar System · `376` Digits time
- **Multifonction** (10) — `304` Elaborate 2 · `344` InfoMeter · `348` Tumbler · `354` Dual · `363` Vintage · `366` Combo · `367` Complex Figure · `368` Function · `374` Cirquary · `375` InfoHub
- **Créatif** (8) — `284` Square · `312` Disc · `317` Disc 2 · `318` Dominos · `332` Flux · `342` Perfect Match · `343` Progress Day · `358` Asteroid
- **Diwali** (1) — `295` Vortex
---
## Sources
Les dispositions d'octets ci-dessus ont été reconstruites à partir du firmware (1.0.0.73), de l'APK officiel (3.5.7) et de
captures en direct déchiffrées contre un appareil réel. L'implémentation de référence de ce projet vit dans
`core-rust/src/{commands,frame,crypto,health,session}.rs` (Rust), l'éditeur TypeScript `wfweb/`, et
les outils Python `cmftool/` (`pair.py`, `session.py`, `wf_codec.py`, `upload_custom.py`, …).
**Travail indépendant incorporé ici.** [freethinkel/fmc](https://github.com/freethinkel/fmc) — un
éditeur de cadrans SvelteKit + marché pour la même montre — a indépendamment rétro-ingénié le
format `.bin` à partir d'un corpus d'environ 100 faces et a atteint plusieurs résultats que ce document manquait ou avait
faux. Leurs `docs/cmf-protocol.md`, `src/lib/modules/editor/lib/{wf,render}.ts` et
`src/lib/modules/device/lib/ble.ts` valent la peine d'être lus directement. Résultats adoptés, chacun re-vérifié
contre les octets de cadran avant d'être rédigé ici :
| Résultat | Où | Statut ici |
|---|---|---|
| Les deux mots d'en-tête sont CRC32 (variante non standard) | §11.4 | ✅ re-vérifié 9/9 cadrans ; **corrige** « pas de somme de contrôle bloquante » |
| Conditions de visibilité, tag `0x02` | §11.12 | ✅ re-vérifié ; était non documenté |
| Liste de métriques d'emplacement + `activeIdx` par défaut, `0x79 + slotIndex` | §11.13 | ✅ re-vérifié sur 275/368/273/304 ; **remplace** §11.8 |
| Drapeau de capacité de teinte d'accent `meta[7] == 4` | §11.14 | ✅ prévalence re-vérifiée ; était non documenté |
| Spécification complète d'arc (angles de balayage, largeur, rayon) | §11.15 | ✅ re-vérifié ; **corrige** « `max` u16 `@+4` » |
| Les `u16` de fin de réf sont des tailles de blocs, pas des ids de glyphes | §11.11 | ✅ re-vérifié exactement sur un atlas de 10 glyphes |
| Pied de page de 36 octets ; variante d'octet magique `0x02` ; `0x86` = 64 B ; aperçu intégré `0x28` | §11.4, §11.11 | ✅ re-vérifié |
| Étiquettes d'ids santé/météo calibrées contre le menu d'emplacement de l'application compagnon | §16 | ⚠️ adopté comme meilleure lecture ; conflits signalés |
| L'UUID du service shell est `77d4e67c-…`, et la portée `optionalServices` de Web Bluetooth | §1 | ⚠️ rapport mono-unité, non re-vérifié ici |
| L'id de nombre partagé avec un anneau affiche un pourcentage | §11.15 | 🟡 signalé, **non** vérifié ici |
| Catalogue id → nom des cadrans de série | Annexe A | ✅ adopté tel quel |
| Objectif | Service | Caractéristique | Propriétés |
|---|
| Écriture de commande | 0000fff0-0000-1000-8000-00805f9b34fb | 0000fff2-… | Write |
| Notification de commande | 0000fff0-… | 0000fff1-… | Notify |
| Écriture shell (AT) | — | 77d4ff01-2fe2-2334-0d35-9ccd078f529c | Write |
| Notification shell (AT) | — | 77d4ff02-… | Notify |
| Écriture de données en masse | — | 02f00000-0000-0000-0000-00000000ffe1 | Write |
| Notification de données en masse | — | 02f00000-…ffe2 | Notify |
| Nom | cmd1,cmd2 |
|---|
| TIME | FFFF 8004 |
| FIRMWARE_VERSION_GET / _RET | FFFF 8006 / FFFF 0006 |
| SERIAL_NUMBER_GET / _RET | 00DE 0002 / 00DE 0001 |
| BATTERY | 005C 0001 |
| TRIGGER_SYNC | 005C 0002 |
| USER_INFO_SET / _RET 🔎✅ | 0095 0001 / 0095 0003 |
| FACTORY_RESET | 009A 0001 |
| DEVICE_REBOOT 🔎 | FFFF 9080 |
| RESOLUTION_GET 🔎 (→ 466×360) | FFFF 907F |
| GPS_PUSH / _RET | FFFF 906A / FFFF A06A |
| UNBIND_SET / _RET | FFFF 907A / FFFF A07A |
| Nom | cmd1,cmd2 |
|---|
| AUTH_PHONE_NAME | FFFF 8049 |
| AUTH_WATCH_MAC | FFFF 0049 |
| AUTH_PAIR_REQUEST / _REPLY | FFFF 8047 / FFFF 0048 |
| AUTH_NONCE_REQUEST / _REPLY | FFFF 804B / FFFF 004C |
| AUTHENTICATED_CONFIRM_REQUEST / _REPLY | FFFF 804D / FFFF 0004 |
| AUTH_FAILED | FFFF A061 |
| Nom | cmd1,cmd2 |
|---|
| APP_NOTIFICATION | 0065 0001 |
| INCOMING_CALL ⚠️ | 0064 0001 |
| CALL_REMINDER_REQUEST / _RESPONSE | FFFF 9066 / FFFF A066 |
| FIND_PHONE | 005B 0001 |
| FIND_WATCH | 005D 0001 |
| FIND_WATCH_TOGGLE | FFFF 9069 |
| SMS_MESSAGE_PUSH / _RET | FFFF 906E / FFFF A06E |
| QUICK_REPLY_SET / _RET | FFFF 9073 / FFFF A073 |
| Nom | cmd1,cmd2 |
|---|
| ALARMS_SET / _GET | 0063 0001 / 0063 0002 |
| CONTACTS_SET / _GET | 00D5 0001 / 00D5 0002 |
| STANDING_REMINDER_SET / _GET | 0060 0001 / 0060 0002 |
| WATER_REMINDER_SET / _GET | 0061 0001 / 0061 0002 |
| TASK_REMINDER_SET / _RET ⚠️ | FFFF 9072 / FFFF A072 |
| Nom | cmd1,cmd2 |
|---|
| GOALS_SET / _ACK | 005E 0001 / 005E 0003 |
| UNIT_LENGTH / _ACK | FFFF 9067 / FFFF A067 |
| UNIT_TEMPERATURE / _ACK | FFFF 9068 / FFFF A068 |
| TIME_FORMAT / _ACK | 005F 0001 / 005F 0003 |
| WAKE_ON_WRIST_RAISE / _GET / _ACK | 0062 0001 / 0062 0002 / 0062 0003 |
| LANGUAGE_SET / _RET | FFFF 9058 / FFFF A06B |
| HEART_MONITORING_ENABLED_SET / _GET | 009B 0001 / 009B 0002 |
| HEART_MONITORING_ALERTS | FFFF 9059 |
| DO_NOT_DISTURB / _GET | 0099 0001 / 0099 0002 |
| SPORTS_SET / _GET | 00DC 0001 / 00DC 0002 |
| SPORT_LINKAGE_SET / _RET | FFFF 9076 / FFFF A076 |
| SPORT_DATA_SYNC 🔎 (FC/cal/pas en direct) | FFFF 9078 / FFFF A078 |
| FEMALE_CYCLE_SET / _RET | FFFF 9071 / FFFF A071 |
| SLEEP_CONFIG_SET / _RET (min cible) | FFFF 9074 / FFFF A074 |
| WORLD_CLOCK_GET | FFFF 906F |
| WORLD_CLOCK_DST_SET / _RET | FFFF 9083 / FFFF A083 |
| VITALITY_GET / _RET | FFFF 9079 / FFFF A079 |
| VITALITY_SW_SET / _RET | FFFF 9070 / FFFF A070 |
| Nom | cmd1,cmd2 |
|---|
| DIAL_COMMAND_SET / _RET (liste/réorganisation/sélection) | FFFF 9055 / FFFF A055 |
| DIAL_CONFIG_SET / _RET | FFFF 9075 / FFFF A075 |
| CHANGE_DIAL (⚠️ inerte sur 1.0.0.73 — ne pas utiliser) | 009F 0001 |
| QUICK_CARD_SET/GET / _RET (les deux sur 906D) | FFFF 906D / FFFF A06D |
| Nom | cmd1,cmd2 |
|---|
| ACTIVITY_FETCH_1 / _2 | FFFF 8005 / FFFF 9057 |
| ACTIVITY_FETCH_ACK_1 / _2 | FFFF 0005 / FFFF A057 |
| ACTIVITY_DATA | 0056 0001 |
| SLEEP_DATA / _GET | 0058 0001 / 0058 0002 |
| SPO2 | 0055 0001 |
| STRESS | 009D 0001 |
| HEART_RATE_MANUAL_AUTO | 0053 0001 |
| HEART_RATE_RESTING | 00DA 0001 |
| HEART_RATE_WORKOUT | 00E0 0001 |
| SKIN_TEMP_HISTORY 🔎 (vide sur ce SKU) | 0155 0001 / 0155 0002 |
| WORKOUT_SUMMARY / _V3 | 0057 0001 / 0160 0001 |
| WORKOUT_GPS | FFFF A05A |
| Domaine | INIT1 req/rép | INIT2 req/rép | CHUNK req/écriture | FINISH ack1/ack2 |
|---|
| Cadran (photo) | 8052/0052 | 9063/A063 | A064/9064 | A065/9065 |
| Cadran (structuré/commutation) | 8052/0052 | 9075/A075 | A064/9064 | A065/9065 |
| Firmware | 9052/A052 | 9040/A040 | A042/9042 | A041/9041 |
| AGPS/EPO | 905E/A05E | — | A05F/905F | A060/9060 |
| Décalage | Taille | Champ |
|---|
| 0 | 4 | horodatage (s epoch) |
| 4 | 4 | pas |
| 8 | 4 | distance (m) |
| 12 | 4 | calories |
| 16 | 16 | réservé (observé 0) |
| Décalage | Taille | Champ |
|---|
| 0 | 4 | début_session (epoch, UTC) |
| 4 | 4 | réveil (epoch, UTC) |
| 8 | 2 | total_profond_s |
| 10 | 2 | total_léger_s |
| 12 | 2 | total_rem_s |
| 14 | 2 | total_éveillé_s |
| 16 | 2 | ⚠️ [incertain] (id/score de session ? les valeurs observées ne correspondent pas aux sommes des enregistrements) |
ac 49 1f 0100D5 0001) ✅ : N × 57 octets = nom(32) ‖ téléphone(25). L'interface de la montre affiche jusqu'à 20.0063 0001) ✅ — corrige Gadgetbridge (qui plaçait le libellé à la fin,
complété par 0xff — incorrect). 40 octets par alarme, big-endian :
secondesDuJour(i32) ‖ index(u8) ‖ activée(u8) ‖ masque-bits-répétition(u8) ‖ drapeau(u8) ‖ libellé[32] UTF-8.
Le libellé est au décalage 8 et s'affiche sur la montre. répétition = masque de bits des jours de semaine (0 = ponctuel) ;
drapeau est ⚠️ [incertain] (marqueur ponctuel ?). Exemple (13:30, idx 2) : 0000bdd8 02 01 15 00 "Alarm…".005E 0001) ✅ — l'application officielle et l'implémentation de référence utilisent le
DailyTargetBean v1 de 10 octets, big-endian : pas(u32 BE) ‖ distance_m(u32 BE) ‖ calories_kcal(u16 BE). (C'est la forme Gadgetbridge ; les rapports antérieurs selon lesquels la montre « ignorait »
cela étaient un bug de déchiffrement de session obsolète, pas un problème de payload.) 🔎 La rétro-ingénierie du firmware montre aussi une variante
étendue de 29 octets plus longue (ajoute sleep_min/exercise_min/stand_h + 6 drapeaux d'activation, tous u32
BE après un préfixe flag(u16 LE), avec des plages appliquées : pas 2000–30000, dist 1000–99000, cal
100–5000, sommeil 360–720, exercice 30–90, debout 6–16) — pas le chemin par défaut de l'application ; préférez la
forme de 10 octets sauf si vous avez besoin des cibles supplémentaires.0060/0061 0001) ✅ : 11 octets :
activé(1) ‖ seuil_min(u16 LE) ‖ dndStart(u32 LE) ‖ dndEnd(u32 LE). Notez que la « fenêtre active
08:00–22:00 » affichée dans l'interface est une valeur par défaut fixe du firmware et n'est pas transportée dans le payload.00DC 0001) ✅ : count(1) = 36 emplacements ‖ activityTypeCode[36] (codes actifs puis remplissage 00).
Sélectionne quels sports apparaissent dans le menu d'entraînement de la montre.009B 0001) ✅ : octet kind — 01 = FC 24/7, 02 = SpO₂,
04 = stress (mesuré toutes les 30 min).FFFF 9059) ✅ : désactivé = 00 ; activé =
01 ‖ fcBas ‖ fcHaut ‖ fcHautSport ‖ spo2Bas ‖ 00 00 00 00 (une limite 0/255 = « aucune limite »).FFFF 9071) ✅ : 01 ‖ predictionOpen ‖ notifySwitch ‖ cycleStartSwitch ‖ cycleStartNotifyBefore ‖ ovulationStartSwitch ‖ ovulationStartNotifyBefore ‖ fertileStartSwitch ‖ fertileStartNotifyBefore ‖ période(1) ‖ cyclePériode(1) ‖ dateDébutCycle(u32) ‖ markStart(u32) ‖ markEnd(u32) (capturé : période=5, cyclePériode=0x1c=28).FFFF 9073) ✅ : TLV — count(1) ‖ total(1) ‖ [id(1) ‖ len(u16 LE) ‖ msg-UTF8]…
(7 réponses par défaut capturées et déchiffrées).FFFF 906F) ✅ : envoie des ID de ville numériques, pas des noms (01 ‖ count ‖ cityId(2 BE)…) ;
la montre mappe les ID à partir d'une table interne. Config DST FFFF 9083 =
count ‖ [id(u16 LE) ‖ dst(u16 LE) ‖ start(u32 LE) ‖ end(u32 LE)]….FFFF 905C, 131 B) ✅ : état(1 : 0=aucun/1=en pause/2=en lecture) ‖ volume(1) ‖ volumeMax(1) ‖ piste(64) ‖ artiste(64). La montre envoie aussi MUSIC_BUTTON (A05D) en retour.FFFF 906B, 199 B) ✅ — utilisez celui-ci : 7×9 octets de jours + 24×2 octets d'heures +
ville(32) + 7×8 octets de lever/coucher du soleil (LE). Les températures sont encodées comme (temp_c + 100) & 0xFF.
⚠️ Le même payload envoyé sur WEATHER_SET_2 (0066 0001) ne met pas à jour le widget météo sur
Pro 2 — utilisez toujours 906B. (La chaîne de ville est aussi un vecteur avéré de détournement de données — voir §13.)005D 0001) ✅ : payload 0x01 → la montre sonne/vibre (+ ACK 005D 0003).FFFF 906A) ✅ — big-endian, longitude en premier : 16 octets
ts(u32 BE) ‖ lon×1e7(i32 BE) ‖ lat×1e7(i32 BE) ‖ 00 00. Validé sur un emplacement réel.FFFF A05A) ✅ — little-endian, longitude en premier : 12 octets
ts(i32) ‖ lon×1e7(i32) ‖ lat×1e7(i32).FFFF 8004) : voir §7.| cf | bpp | raster (après LZ4) | usage |
|---|
| 4 | 2 | RGB565-LE | fond opaque (FULL/THUMB) |
| 5 | 3 | RGB565-LE (2 B) + alpha (1 B) par px | sprites anticrénelés (glyphes, aiguilles, icônes) |
| 13 (0x0d) | 0.5 | masque alpha 4 bits ; le firmware teinte à l'exécution | atlas de glyphes de chiffres |
| 24 (0x18) | 4 | RGBA8888 | couches pleine couleur (y compris le aodImage toujours actif) |
| 1 | — | JPEG/JFIF (ff d8 ff), à extraire avec n'importe quel décodeur | rares images d'animation |
0avalidate_rejects_bad_containers — rejette un corps plat 0x61 (→ NotEnvelope, le bug historique 0a
) et un enfant qui dépasse sa fenêtre (→ ChildOverflow).0x0b:61 0a 00−18/−160a.bin0x68
empilés au même (x,y), chacun dessiné uniquement lorsqu'une condition de visibilité correspond — cela
était juste. Mais la liste de métriques par emplacement (le 0x1c/0x6a/0x48/0x24/0x19/0x76 du 275) est exactement la
liste de métriques, pas des « ids d'option/style » ; l'index actif par défaut est un octet dans le fichier ; et
l'octet 0x79/0x7a n'est pas un « octet d'instance » mais l'id sur lequel les alternatives sont indexées
(0x79 + slotIndex). Un aperçu statique peut reproduire la valeur par défaut du fichier. Voir §11.13.(446,0), vu sur 275/302/325/365/375)
sont des emplacements que le firmware ne dessine pas dans la vue par défaut — leur valeur ne peut même pas tenir avant le
bord du canevas. Les traiter comme masquées dans l'aperçu.0x220x22aod0x22@69,2090x60 img_number autonome (cnt=10) — source à −5, décalage d'un vers l'avant. ✅ Même décalage d'un
que §11.8 mais pour les nombres non-horloge : la date de « Gradient » se trouvait à (203,80) en haut-centre avec la source
0x17, mais le scan avant 82 attrapait le getter d'angle du pointeur voisin (0x0a) et
la position du pointeur → le nombre se rendait à l'emplacement du pointeur avec une source bidon. Correctif : pour un
img_number 61 0a 00 dans un wrapper 0x60, faire confiance à −5/−18/−16 lorsque la source avant est
impossible pour un nombre (source-0 ou un getter d'angle de pointeur 0x0a/0e/12/70/71/72) et que la
position −18/−16 est valide et non nulle (la garde non-nulle saute les chiffres enfants de groupe avec relX=0).0x17 = date (jour du mois), 0x24 = température — distincts. Le cadran 340 utilise les deux (0x17
« Jun 09 » et une température 0x24 séparée), donc 0x17 est la date, pas la température. Un cadran dont la montre affiche une
température dans un emplacement 0x17 est une complication configurée par l'utilisateur (état de l'appareil), pas la valeur par défaut du fichier.| tag | rôle | conteneur ? | corps |
|---|
0x20 | racine de scène (wrapper de corps, pas un dessinable) | ✅ | enfants |
0x21 | écran normal | ✅ | enfants |
0x22 | écran AOD (§11.9) | ✅ | enfants |
0x28 | vignette d'aperçu de catalogue intégrée | ✅ | un enfant 0x08 |
0x68 | groupe / conteneur de disposition automatique | ✅ | cadre 0x48 + enfants |
0x30 | image statique, ou sélection par valeur parmi N images | ✅ | 0x01 (+0x02) |
0x60 | lecture numérique en direct (bande de chiffres) | ✅ | 0x01 + 0x40 (+0x02) |
0x70 | aiguille rotative | ✅ | 0x01 + pivot 0x05 |
0x80 | anneau de progression, procédural | ✅ | 0x01 + 0x5a (§11.15) |
0x81 | anneau de progression, clippé par image | ✅ | 0x01 + 0x5b (§11.15) |
0x85 | emplacement de complication assignable par l'utilisateur | ✅ | 0x01 + 0x5f (§11.13) |
0x01 | struct — géométrie + attributs (ci-dessous) | — | x,y,meta[14] + queue de références |
0x02 | condition de visibilité (§11.12) | — | liste de conditions |
0x05 | pivot — flag u8, pivotX u16, pivotY u16 | — | 5 o |
0x08 | pvStruct — prefix[5] + queue de références, sans x/y (aperçu uniquement) | — | — |
0x40 | nombre de chiffres / drapeau de remplissage de zéros (§11.10) | — | 1 o |
0x48 | cadre — x,y,w,h,gap,align ligne/colonne de disposition automatique | — | — |
0x5a / 0x5b | spécification d'arc pour 0x80 / 0x81 (§11.15) | — | 19 o / 17 o |
0x5f | liste de métriques d'emplacement pour 0x85 (§11.13) | — | — |
0x86 | nœud de nom d'affichage, toujours exactement 64 octets, terminé par NUL, non dessiné | — | 64 o |
| cadran | slot | count | activeIdx | ids de métriques | → actif |
|---|
| 275 SlopeTime | 0 | 6 | 0 | 1c 6a 48 24 19 76 | 0x1c calories |
| 275 SlopeTime | 1 | 6 | 4 | 1c 6a 48 24 19 76 | 0x19 pas |
| 368 Function | 0 | 8 | 0 | 5f 1c 19 48 24 76 1a 8b | 0x5f température |
| 368 Function | 1 | 8 | 6 | 5f 1c 19 48 24 76 1a 8b | 0x1a fréquence cardiaque |
| 273 Activity Mood | 0 | 4 | 0 | 1c 24 48 6a | 0x1c calories |
| 304 Elaborate 2 | 0/1 | 4 | 0 | 1c 48 6a 24 / 24 1c 6a 48 | 0x1c / 0x24 |