Skip to content
KitploitKITPLOIT
FerramentasBlog
Enviar
FerramentasBlog
Enviar

Ferramentas de Hacking, PenTest e Cibersegurança para o seu Arsenal de Segurança!

Kitploit é um diretório de ferramentas de hacking, cibersegurança e pentesting. Descubra as últimas atualizações de projetos para encontrar vulnerabilidades, analisar sistemas, automatizar testes e fortalecer sua segurança.

··Feeds·Contato·Privacidade·© 2026 Kitploit

Diretório de Ferramentas

Categorias

Ver todas as categorias
Loading categories
CMF-Watch-Pro-2-BLE-Protocol — Protocolo BLE com engenharia reversa para o CMF Watch Pro 2, documentando layout GATT, quadros de comando criptografados AES-128-CBC, handshake de autenticação e sincronização de dados de saúde para desenvolvimento de aplicativo companheiro alternativo. | Kitploit
Ferramentas/GitHubGitHub/joshuapassos/cmf-watch-pro-2-ble-protocol
Segurança de Sistemas EmbarcadosSegurança BluetoothSegurança IoTEngenharia ReversaSegurança Sem FioCriptografiaSegurança MóvelSegurança de Hardware e IoTAnálise de Firmware

Mais Populares

Ver todos →

Descubra as ferramentas mais usadas pela nossa comunidade.

Explore todas as ferramentas

Navegue pela nossa coleção de ferramentas

Ver todas as ferramentas →
Compartilhar
GitHubjoshuapassos/cmf-watch-pro-2-ble-protocol

CMF-Watch-Pro-2-BLE-Protocol

Protocolo BLE com engenharia reversa para o CMF Watch Pro 2, documentando layout GATT, quadros de comando criptografados AES-128-CBC, handshake de autenticação e sincronização de dados de saúde para desenvolvimento de aplicativo companheiro alternativo.

Ver RepositórioSite
328há 1 mêsAinda não revisado

CMF Watch Pro 2 — Protocolo BLE (engenharia reversa)

Não oficial. Este documento descreve o protocolo Bluetooth Low Energy (BLE) do CMF Watch Pro 2 (CMF by Nothing), reconstruído por engenharia reversa para um aplicativo complementar alternativo. Não é afiliado nem endossado pela Nothing/CMF. Use por sua conta e risco.

Todos os inteiros multibyte no cabeçalho do quadro e opcodes são big-endian. Inteiros dentro de payloads de comando são little-endian, salvo indicação em contrário (isso reflete o firmware do dispositivo) — cuidado com as exceções (GOALS_SET, GPS_PUSH, offset/comprimento de transferência em massa são big-endian).

Marcadores de confiança

Cada afirmação não óbvia abaixo é marcada com a forma como foi estabelecida:

  • ✅ validado no dispositivo — observado em uma captura ao vivo descriptografada ou testado contra um relógio real.
  • 🔎 de firmware / RE do APK — extraído por descompilação do firmware (1.0.0.73) ou do APK oficial (3.5.7); consistente com o código, mas não testado em tempo de execução.
  • 🟡 parcialmente comprovado — estabelecido estruturalmente (offline, em todo o corpus ou por RE), mas a etapa restante precisa do relógio e ainda não foi executada.
  • ⚠️ [incerto] — inferido, não confirmado; pode estar errado.

Quando uma seção posterior corrige uma anterior, o texto anterior é mantido com um ponteiro em vez de ser excluído — saber quais leituras foram tentadas e refutadas poupa à próxima pessoa o mesmo desvio.

Dispositivo de teste para todas as capturas: CMF Watch Pro 2-5485, fw 1.0.0.73, serial CI04102520008192, MCU Actions ATS3089C (Cortex-M4), tela 466×360.


1. Layout GATT

O telefone é o cliente GATT; o relógio é o periférico, anunciando-se como CMF Watch Pro 2-XXXX (4 caracteres hexadecimais).

Ative as notificações escrevendo 01 00 em cada CCCD (00002902-…). O canal de comando (fff1/fff2) transporta o protocolo enquadrado abaixo. O canal de shell (77d4…) transporta texto simples no estilo AT (ex.: AT GETSECRET; ver §14). O canal de dados (02f0…) transporta grandes blobs binários (mostrador, firmware, AGPS), coordenados por opcodes de controle no canal de comando.

UUIDs de serviço — o relógio anuncia ~10 serviços primários. Enumerados em uma unidade real: 0xfff0 (comando), 0x180f (bateria), 0x180a (informações do dispositivo), 0xefe7, 0xffd0, 02f00000-…ffe0 e 02f00000-…fe00 (dados), 77d4e67c-2fe2-2334-0d35-9ccd078f529c (shell / pareamento), e49a3001-f69a-11e8-8eb2-f2801f1b9fd1, f48a23c0-f69a-11e8-8eb2-f2801f1b9fd1.

⚠️ O serviço de shell tem UUID 77d4e67c-…, não 77d4ff00-…. Revisões anteriores deste documento presumiam que o serviço compartilhava o prefixo ff00 de suas características (77d4ff01/77d4ff02, §14) — não compartilha, pelo menos na unidade em que isso foi verificado (descoberta de freethinkel/fmc, ver §Fontes). Os UUIDs das características são inalterados. Não verificado se 77d4e67c é estável entre unidades — enumere em vez de codificar.

🌐 Observação sobre Web Bluetooth. O Chromium apenas descobre serviços que a página listou em optionalServices, mesmo para uma chamada getPrimaryServices() sem filtro — uma página que lista 3 serviços vê 3, enquanto chrome://bluetooth-internals (a própria camada C++ do Chrome, sem escopo) mostra todos os 10. Se você escrever um cliente de navegador, liste todos os UUIDs acima antecipadamente ou o pareamento falhará com serviços que claramente existem. Sem Web Bluetooth no Firefox/Safari; requer um gesto do usuário + HTTPS/localhost.

✅ Uma sessão real inteira rodou no único canal de comando — durante uma captura de 160 s de uso intenso, não houve tráfego nos canais de dados/firmware ou shell, exceto durante uma transferência explícita de OTA/mostrador.


2. Formato de quadro (0xF5)

Toda mensagem do canal de comando é encapsulada em um ou mais quadros com cabeçalho de 11 bytes:``` +------+-----------+--------+-------------+-------------+--------+-------------------+ | 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 ____________________________/

root@kitploit:~
- `cmd1`/`cmd2` juntos formam o **opcode** (ver §6). 🔎 confirmado no construtor de frames do app oficial (`C6117b.m30831g`).
- `chunkCount` = total de chunks para este comando; `chunkIndex` é **baseado em 1**.
- `chunkLen` = número de bytes de `chunk` neste frame.
- Uma única escrita BLE pode ser fragmentada pelo MTU do link; o receptor armazena os bytes brutos e re-extrai frames completos. Payloads grandes são divididos em múltiplos chunks (mesmo `cmd1/cmd2`, com `chunkIndex` crescente) e remontados em ordem.

### Convenção de opcode (✅ confirmado no wire)

- `cmd1 = 0xFFFF`: `cmd2` em `0x80xx`/`0x90xx` = telefone→relógio (requisição/definição); `0x00xx`/`0xa0xx` = relógio→telefone (resposta). Os pares correspondem pelo byte baixo (`0x9055`↔`0xa055`, `0x8051`↔`0x0051`).
- `cmd1` específico de funcionalidade: sufixo de `cmd2` = `0x0001` **SET**, `0x0002` **GET**, `0x0003` **ACK**.

### Corpo do chunk

Para cada chunk, o corpo é `payloadPiece ‖ CRC32_LE(payloadPiece)` (CRC de 4 bytes, little-endian, zlib/IEEE). Se o comando for **criptografado** (ver §3), todo o `payloadPiece ‖ CRC` é então criptografado com AES-128-CBC/PKCS7 e esse ciphertext torna-se o `chunk` do frame.

**Peculiaridade do plaintext:** para opcodes em plaintext, o relógio *conta* o CRC de 4 bytes em `chunkLen`, mas **não** o transmite. Portanto, ao decodificar um frame em plaintext, o comprimento real dos dados é `chunkLen − 4`. (Frames criptografados carregam o CRC dentro do ciphertext, como de costume.)

Dimensionamento do chunk (para que chunks criptografados caiam em limites de bloco AES), com `maxWrite = mtu − 3`:
- criptografado: `floor((maxWrite − 11) / 16) * 16 − 4 − 1`
- plaintext: `maxWrite − 11 − 4 − 1`

✅ Todos os valores de `chunkLen` observados em frames criptografados eram múltiplos de 16 (o alinhamento de bloco é mantido).

---

## 3. Primitivas criptográficas

- **AES-128-CBC** com preenchimento **PKCS7** e um **IV fixo** (do firmware `CmfCharacteristic.AES_IV`):
  `50 51 52 53 54 55 56 57 60 61 62 63 64 65 66 5A`.
- **CRC32** (zlib/IEEE), emitido como 4 bytes little-endian.
- **SHA-256** sobre a concatenação das partes.

Derivação de chave:```
authkey      = SHA256( rnd1 ‖ rnd2 ‖ secret )[0..16]      // persisted across sessions
sessionKey   = SHA256( nonce ‖ authkey )[0..16]           // per connection
  • secret = segredo do dispositivo de 16 bytes (obtido do relógio via comando de shell AT GETSECRET → GETSECRET:<32-hex>,OK).
  • rnd1 = 16 bytes aleatórios escolhidos pelo telefone; rnd2 = 16 bytes aleatórios do relógio.
  • nonce = bytes da resposta de nonce do relógio.

Após a chave ser definida, todos os quadros do canal de comandos são criptografados com AES, exceto os opcodes de texto simples listados na §5.

✅ Ambas as derivações validadas: authkey recuperado do ntwatch.db de um telefone com root correspondeu ao valor derivado de um rnd1/rnd2/secret capturado; sessionKey reproduzido a partir de um nonce capturado descriptografa quadros ao vivo.


4. Handshake de autenticação / pareamento

Dois caminhos de entrada compartilham a mesma cauda de nonce/confirmação.

4.1 Pareamento pela primeira vez (tendo o segredo do dispositivo)```

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

root@kitploit:~
Em `AUTH_FAILED (0xFFFF,0xA061)` ou incompatibilidade de assinatura, a autenticação falha.

### 4.2 Reconexão (authkey já conhecida)```
        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

✅ A ordem de reconexão (sem tráfego de shell) foi observada intacta em uma captura real.

4.3 Inicialização pós-auth (fase 2)

⚠️→✅ TIME é obrigatório antes de consultas de dados. Após Initialized, o relógio não responderá a BATTERY, SERIAL_NUMBER_GET ou ao handshake ACTIVITY_FETCH_* até que um TIME (FFFF 8004) tenha sido enviado na sessão — sem ele, apenas um FIRMWARE_VERSION_RET não solicitado chega e todo o resto expira. ✅ confirmado ao vivo (Pixel 8a): enviar os três GETs sem TIME → apenas respostas de firmware; enviar TIME primeiro → bateria e serial começam a responder.

Ordem recomendada para a fase 2: TIME → FIRMWARE_VERSION_GET → SERIAL_NUMBER_GET → BATTERY (0xA5) → envios de configuração → sincronização de saúde (§8).

4.4 Padrão de eco GET → SET (✅)

Não há um opcode separado de "leitura" para a maioria das configurações. Enviar um *_GET (cmd2 = 0x0002, payload 0xA5) faz o relógio responder com o opcode SET (cmd2 = 0x0001) carregando o valor atual. Comandos SET são confirmados com cmd2 = 0x0003 e corpo vazio.


5. Texto simples vs criptografado

Os quadros são criptografados com AES uma vez que uma chave é definida, exceto estes opcodes, que estão sempre em texto simples:

  • 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)

Os cabeçalhos dos quadros (cmd1/cmd2) sempre viajam em claro, então a sequência de comandos é visível em qualquer captura mesmo sem a chave — apenas payloads criptografados precisam de sessionKey.


6. Referência de opcodes (cmd1, cmd2)

GET/SET/REQUEST = telefone→relógio; RET/REPLY/ACK/RESPONSE/DATA = relógio→telefone.

Sessão / dispositivo

Autenticação

Notificações / chamada / localizar

Música

Nomecmd1,cmd2
MUSIC_INFO_SET / _ACKFFFF 905C / FFFF A05C
MUSIC_BUTTONFFFF A05D

Alarmes / contatos / lembretes

Configuração

Clima

Nomecmd1,cmd2
WEATHER_SET_1 (o que funciona)FFFF 906B
WEATHER_SET_2 (ignorado no Pro 2 — ver §9)0066 0001

Mostradores / dials

Saúde / sincronização

Opcodes somente-JS (FFFF 8051, FFFF 0051, FFFF 90A2, FFFF 90C5, FFFF A056, FFFF 908A/908B status/suporte ChatGPT) são tratados no bytecode Hermes do app, não na camada Java. Seus cabeçalhos aparecem em capturas, mas a semântica do payload é ⚠️ [incerta].

Transferência de dados em massa (canal de dados)

Mostrador / firmware / AGPS usam um loop de init → solicitação/gravação de chunk → ack de conclusão:

(todos com cmd1 = FFFF.) O relógio conduz o loop emitindo DATA_CHUNK_REQUEST_*(offset, length) (offset/length = u32 big-endian); o telefone responde com DATA_CHUNK_WRITE_* carregando payload[offset..offset+length] na característica de dados. Ver §11–§12 para detalhes.


7. Hora e fuso horário

Payload de TIME (FFFF 8004) = epochSeconds(i32, BE) ‖ utcOffsetMillis(i32, BE). Enviado logo após a autenticação para que o relógio mostre a hora local (e desbloqueie consultas de dados — ver §4.3).

⚠️ Os timestamps de saúde do relógio são UTC. O app complementar deve adicionar o offset UTC local antes de derivar o dia calendário / hora do dia local. (Agrupar saúde pelo dia UTC bruto faz o dia virar na hora local errada.)

Payload de TIME_FORMAT (005F 0001) = 1 byte: 00 = 24h, 01 = 12h.


8. Sincronização de saúde

  1. O telefone envia ACTIVITY_FETCH_1; o relógio responde ACTIVITY_FETCH_ACK_1 (primeiro byte 01 ⇒ pronto).
  2. O telefone envia ACTIVITY_FETCH_2; o relógio então envia uma rajada de quadros de dados: ACTIVITY_DATA, HEART_RATE_*, SPO2, STRESS, SLEEP_DATA, WORKOUT_SUMMARY[_V3].
  3. Cada um é analisado em amostras por minuto / sessões e agregado pelo dia local.

A sincronização é sequencial (deve seguir TIME; o relógio libera os fluxos após ACK_2), não uma rajada única. Uma sessão pesada envia ~170–210 quadros de notificação em ~160 s. ✅

8.1 Registro de atividade — ACTIVITY_DATA (32 bytes cada, LE) ✅

Unidade de caloria: as calorias de atividade são relatadas em cal (calorias-grama). Divida a soma diária por 1000 para obter kcal. (As calorias do resumo de treino, em contraste, já estão em kcal.)

8.2 Amostras de FC / SpO₂ / estresse ✅

  • FC manual/auto, FC de treino, SpO₂, estresse = 8 bytes cada: timestamp(i32 LE) ‖ valor(i32 LE) (valor = bpm / % SpO₂ / índice de estresse).
  • FC em repouso (00DA 0001) é diferente — 5 bytes: timestamp(i32 LE) ‖ fc(u8). ✅ exemplo ao vivo 5e dc 29 6a 4e → ts, fc = 78 bpm. Faixas de pontuação de estresse: 1–29 / 30–59 / 60–79 / 80–99.

8.3 Sono — SLEEP_DATA (cabeçalho de 18 bytes + N × registros de 8 bytes) ✅

Um SLEEP_DATA = uma sessão de sono; uma noite pode conter várias (micro-despertares dividem sessões).

Cabeçalho:

Cada registro de 8 bytes: timestamp(u32) ‖ duração_s(u16) ‖ estágio(u16). Códigos de estágio: 1 = Profundo, 2 = Leve/leve, 3 = REM, 4 = Acordado. ✅ validado contra uma noite inteira (duas sessões, totais D/C/R/A reconciliam).

8.4 Resumo de treino — WORKOUT_SUMMARY v1 (54 bytes) / _V3 (0160 0001)

v1: início(u32), fim(u32), duração_s(u32), depois tipo/calorias/passos/distância/FC-média e um bloco GPS/estendido. ✅ layout v1 confirmado contra o firmware. WORKOUT_SUMMARY_V3 é um layout mais novo para os mesmos dados mais um bloco estendido de ~40 bytes (exerciseLoad, aeróbico/anaeróbico, recoveryTime, VO₂max, cadência, PAI, melhores tempos de corrida…). O conjunto de campos é conhecido (do Room DB do app), mas os offsets exatos de bytes dentro desse bloco de 40 bytes são ⚠️ [incertos] — fechá-los precisa de uma captura bruta de um treino com GPS.


9. Payloads de comandos selecionados

Strings são UTF-8, truncadas em bytes para o tamanho do campo (o truncamento pode dividir um caractere multibyte, correspondendo ao comportamento de s.encode()[:max] do firmware); campos curtos são preenchidos com zeros à direita.

  • APP_NOTIFICATION (0065 0001) ✅: iconCode(1) ‖ 0x00 ‖ when(u32 BE) ‖ titleLen(1) ‖ título ‖ corpo. iconCode seleciona o ícone do app (WhatsApp=8, Telegram=12, Instagram=18, Gmail=27; desconhecido=0xFF). Título ≤ 20 bytes, corpo ≤ 128 bytes. Enviado de um cliente → o relógio exibiu + ACK 0065 0003.
  • BATTERY (005C 0001) ✅: resposta = nível(1) ‖ carregando(1) (ex.: 3b 00 = 59 %, não carregando).
  • SERIAL_NUMBER_RET (00DE 0001) ✅: len(1) ‖ ASCII (ex.: 10 + "CI04102520008192").
  • USER_INFO (0095 0001) ✅: altura_cm(1) ‖ peso_kg(1) ‖ idade(1) ‖ gênero(1: 1=M) (ex.: = 172 cm / 73 kg / 31 / masculino).

10. Notas de implementação e peculiaridades

  • Sem relógio de sistema nos codecs: os codificadores recebem now/utc_offset como parâmetros explícitos (determinístico, testável). O transporte fornece a hora real.
  • TIME controla tudo (§4.3) — envie-o primeiro ou o relógio permanece mudo em consultas de dados.
  • Contagem de CRC em texto simples (§2) é fácil de errar — quadros em texto simples anunciam mas omitem o CRC.
  • Endianness: cabeçalho + opcodes BE; inteiros de payload LE; exceções — GOALS_SET e GPS_PUSH são big-endian, e offset/length de transferência em massa são big-endian.
  • MTU: os tamanhos de chunk são calculados para que chunks criptografados se alinhem a blocos AES de 16 bytes.
  • authkey é persistível (armazene-a após o primeiro pareamento); sessionKey é por conexão e derivada do nonce do relógio em cada reconexão.

11. Mostradores / dials — criação

O relógio suporta (a) dials de foto/personalizados (uma imagem de fundo + um relógio digital desenhado pelo firmware) e (b) dials estruturados (faces integradas / da loja: um fundo mais camadas de sprites posicionadas, ponteiros e widgets de texto). Ambos transferem pelo canal de dados via o loop de init → chunk na §6.

O que realmente funciona (✅ validado ao vivo): construir um dial de foto a partir de qualquer imagem e instalá-lo; instalar qualquer um dos 103 dials da loja offline; revestir um dial estruturado (trocar o fundo ou qualquer sprite não-fundo) e mover suas camadas; reordenar / alternar a face ativa; e construir um dial estruturado do zero — o envelope de cena 0x20 é decodificado e o construtor é implementado (§11.7), comprovado offline para fazer round-trip de todos os 103 dials da loja byte por byte e para emitir contêineres sintéticos que passam o próprio validador do firmware. 🟡 o único passo não comprovado é ver um render sintético do zero no dispositivo via 9075 (a prova estrutural offline já cobre o que costumava causar a rejeição 0a). Não há barreira de codec ou transporte e nenhuma necessidade do toolchain do fornecedor. As antigas alegações de "render estruturado é embutido em RES-pack / impossível via BLE" e "codec do lado do servidor cf=0x1f" estavam erradas (um bug de offset+bytes-por-pixel) — o firmware renderiza dials estruturados orientados por dados a partir do arquivo que você envia.

11.1 Gerenciamento de dials — DIAL_COMMAND (9055 / a055) ✅

  • tipo 0 = consultar a lista. Resposta a055 = resultado(u8) ‖ selectIndex(u8) ‖ total(u8) ‖ max(u8) ‖ N × dialId(u32 LE) ‖ ffffffff. Exemplo: 01 05 06 07 … = ativo #5, 6 dials, máx 7.
  • tipo 1 = reordenar / selecionar ativo: reenvie a lista inteira com o dial alvo no índice 0 (é assim que o app oficial alterna faces; não há opcode dedicado de "definir ativo").
  • Excluir um dial = reenvie a lista sem seu id.
  • CHANGE_DIAL (009F 0001) é inerte no fw 1.0.0.73 (retorna uma constante, não alterna) — não o use.

11.2 Fluxo de transferência ✅```

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)

root@kitploit:~
Finish reply byte: `01` = ativado e guardado; `0a` = guardado mas **não** ativado / rejeitado. No
Android, cada `DATA_CHUNK_WRITE` tem de sair como **uma escrita BLE por frame** — concatenar e
voltar a fatiar por MTU dessincroniza os cabeçalhos e o relógio fica em loop a pedir o offset 0.

- **`9063` (foto) = APPEND.** A lista de mostradores cresce (6→7); `watchfaceId = 0xFFFFFFFF` (sentinel
  personalizado) para nunca ser rejeitado como duplicado, e o relógio ativa-o automaticamente.
- **`9075` (estruturado) = SUBSTITUI** o slot `old_id`. O `old_id` **tem** de já estar na lista
  (caso contrário `0a`). Para reinstalar um id já presente, **apague-o primeiro** (9055 lista-menos-id)
  e depois carregue "de novo" — reutilizar um id no lugar dá `0a`.

### 11.3 Mostrador de foto / personalizado — ✅ totalmente validado de ponta a ponta

**Contentor** (round-trip verificado por byte; todos os campos 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 = bloco LZ4 padrão sobre RGB565 little-endian, top-down (payloadLen conta a partir do primeiro byte LZ4). O aplicativo oficial usa LZ4-HC e remove o cabeçalho/rodapé de 21 bytes do bloco LZ4; um codificador LZ4 apenas com literais também funciona — o relógio aceita qualquer LZ4 válido, a identidade de bytes não é necessária. Pixels fora do círculo inscrito (centro 233,233, raio 233) são definidos como 0x0000.

INIT_2 para 9063 — cabeçalho exato (✅ este é o que funciona):``` 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

root@kitploit:~
`size` = comprimento exato do `.bin`; `FFFFFFFF` = `watchfaceId` personalizado; `styleId` 0–4 seleciona o layout
de relógio digital integrado (é sempre desenhado — não há opção "desligado"); `posX/posY` posicionam-no (valores
conhecidos como bons: 56 / 77); `color565` aplica-lhe uma tonalidade (ex.: `FFFF` = branco). ⚠️ A forma mais curta
`A5 ‖ size ‖ watchfaceId` é **rejeitada** com o finish `0a` — use o cabeçalho completo acima. (Implementação de
referência: `core-rust/engine.rs::build_wf_init2`, espelhando `C6135t.m31104u` na aplicação oficial.)

**Receita:** redimensionar a imagem para 466×466 (e uma miniatura de 270×270), converter para RGB565-LE de cima
para baixo, opcionalmente zerar os pixels fora do círculo, comprimir cada uma com LZ4, montar o contentor acima e
carregar através do pipeline `9063` com `watchfaceId = 0xFFFFFFFF`. (Codec de referência: `core-rust/watchface.rs`,
`work/codec_dfa.py`.)

### 11.4 Mostrador estruturado / de loja — contentor e codecs ✅

**Estrutura do ficheiro** — o cabeçalho de 36 bytes é repetido **byte a byte de forma idêntica como um rodapé de 36 bytes** no EOF
(✅ verificado em 15 mostradores; um analisador deve rejeitar um ficheiro onde estes difiram):```
[36-byte header][scene TLV (§11.7)][asset pool][36-byte header again]

Cabeçalho (estrutura idêntica em todos os 103 mostradores da loja; todos os campos em 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]

root@kitploit:~
> ⚠️ **Correção (substitui "não há checksum de bloqueio").** Revisões anteriores liam `@0x00` como um
> id/hash por dialeto e `@0x20` como "3× palavras u32 de id/hash \[não um CRC]", e afirmavam que CRC32/Adler32/
> soma de bytes falhavam todos na correspondência. Ambas as palavras **são** CRC32 — os testes anteriores não as detectaram porque a
> variante é não padronizada, e porque a leitura de "3 palavras em `0x20`" confundia a única palavra
> CRC com os primeiros bytes do contêiner de cena que começa em `0x24` (da mesma forma, "nome repetido em
> `0x2c`" é o nó de nome `0x86` da cena, §11.11). Descoberta de
> [freethinkel/fmc](https://github.com/freethinkel/fmc); reverificada aqui.

**CRC32-raw** = polinômio IEEE refletido `0xEDB88320`, **`init = 0`**, e **sem XOR final** — ou seja,
nem o `init=0xFFFFFFFF` nem o `^0xFFFFFFFF` do `crc32` padrão. Essa é exatamente a razão pela qual
o CRC32 pronto para uso nunca correspondeu. Observe a dependência de ordem: `crc_assets` fica dentro do intervalo
coberto por `crc_tree`, então **escreva `@0x20` primeiro, depois calcule `@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])

Verificado: 9/9 discos de loja imaculados correspondem em ambas as palavras, e 6/6 dos modelos deste próprio repositório correspondem em crc_tree.

🟡 O firmware não parece impor nenhum dos CRCs. Cada disco que este repositório instalou sobre 9075 — incluindo reskins produzidos pelo mesmo caminho de edição in-place de mesmo footprint (§11.6), que muta payloads de ativos e bytes X/Y sem recomputar o cabeçalho — renderizou bem no dispositivo. Portanto, um CRC obsoleto não é o que causa uma rejeição 0a (essa é a invariante da janela do contêiner, §11.7). Trate os CRCs como escreva-correto-de-qualquer-forma: baratos, e o único campo de integridade conhecido no formato. Qualquer coisa que reescreva a cena ou o pool de ativos deve recomputar ambas as palavras.

Discos stub (~173 B, ex.: ids 273/274/277) são placeholders para faces gravadas na ROM: cabeçalho + diretório, sem ativos reais.

Ativos — cada um é dimsWord(u32 LE) ‖ len(u32 LE) ‖ LZ4(payload), onde cf = dimsWord & 0x1f, w = (dimsWord >> 10) & 0x7FF, h = (dimsWord >> 21) & 0x7FF, e len conta a partir do primeiro byte LZ4 (o 1f 00 01 00 que você costuma ver ali é o primeiro token LZ4 — não pule-o). Tamanho descomprimido = w·h·bpp:

✅ Todos os 4151/4151 ativos nos 103 discos decodificam exatamente com um descompressor padrão lz4.block em w·h·bpp. Transparência é o byte alpha (cf=5/24) ou 0x0000 (cf=4 fora do círculo) — não há RLE nem "escape". Codificação = re-raster → LZ4 padrão → [dimsWord][len][LZ4].

INIT_2 para 9075 — corpo criptografado com AES:``` kind(1) ‖ old_id(u32 LE) ‖ new_id(u32 LE) ‖ file_len(u32 LE)

root@kitploit:~
`kind` = `0x02`/`0x03`; `old_id` = dial ativo atual (de `9055`); `file_len` = tamanho real do `.bin`
(= `@0x18 + 36`). Instalar um `.bin` de loja como está é o caminho garantido (Ring Data id 359 +
102 outros confirmados). (Referência: `core-rust/engine.rs::build_dial_replace_init`.)

### 11.5 Gramática estruturada de diretório ✅ (decodificada e implementada — REVISADO 2026-07-02)

> **⚠️ Revisão (2026-07-02): o esquema de registro plano `61 01 00` abaixo estava sistematicamente
> OFF-BY-ONE.** O corpo da cena é um TLV limpo (§11.7); um **corpo de folha** desenhável (tags `0x30`/`0x38`
> estáticas, ponteiro `0x70`) é:
>
> ```
> 01 xx 00 [X u16][Y u16] …attrs… 61 [count u16][base u32][count×id u16] [05 05 00 01 pivX pivY]
> ```
>
> - o atributo `0x01` abre o corpo: **X,Y = canto superior esquerdo** na tela 466² (o `s16 x,y` do
>   `sty_picture_t` do SDK).
> - a **tabela de quadros `61 …` fecha o corpo** (`base` = ptr do asset; `count` 1 = imagem, 10/11 =
>   atlas de dígitos — o antigo "tipo de registro `0a/0b`" era na verdade essa contagem! — 7/13/2 =
>   folha de quadros de complicação).
> - extras de ponteiro: origem+escala `[src] 00 3c 00` dentro do atributo `0x01`; pivô no
>   **trailer** `05 05 00 01 [pivX][pivY]`. **Centro de rotação = `(X+pivX, Y+pivY)` por ponteiro** —
>   não um (233,233) fixo: existem subdials descentralizados (ex.: os ponteiros do dial 366 giram em torno de 150,150).
>
> A varredura linear por `61 01 00` estava costurando a tabela de quadros+pivô do elemento **N** ao X/Y
> (e byte de tag, o antigo "f3") do elemento **N+1** — só *parecia* correto em dials analógicos cujos
> ponteiros adjacentes compartilham geometria quase idêntica. A "parede de variante compacta" (spec 24 §24.4.5) era
> esse mesmo erro de leitura. Implementado como `scan_scene_drawables` em `core-rust/watchface_struct.rs`
> e `wfweb/src/codec/parse.ts` (cena = fonte primária para imagens/ponteiros; varredura plana mantida para
> texto + fallback sem envelope). Validado pelo oráculo `wfweb/compare.html` (render vs PNGs oficiais da loja, 99 dials): 64→72 bons, 8→5 ruins, diff média 9.3→7.4%.

Leitura histórica de registro plano (substituída, mantida para contexto):

- **Imagem estática** (`61 01 00`): `asset_ptr(u32) ‖ elemId(u16) ‖ 05 05 00 01 ‖ pivotX(u16) ‖
  pivotY(u16) ‖ 3B ‖ 01 ‖ 1b 00 ‖ X(u16) ‖ Y(u16)`. Canto superior esquerdo na tela 466² = `(X−pivotX, Y−pivotY)`.
- **Ponteiro/agulha** — mesmo registro de imagem, rotacionado em tempo de execução. **Centro de rotação = `(X+pivotX, Y+pivotY)`**
  (≈ 233,233 em dials analógicos). A **fonte de dados é um `u8` no offset `+36` do registro**, escala `u16` em
  `+38` (=60): `0x0a`/`0x70` = hora (`h·30°+m·0.5°`), `0x0e`/`0x71` = minuto (`m·6°+s·0.1°`),
  `0x12`/`0x72` = segundo (`s·6°`). ✅ confirmado por desmontagem dos getters (fallback RTC 10:10:30).
- **Widget de texto/número** (`61 0a 00`): `asset_ptr(u32) ‖ [10×u16 métricas de fonte] ‖ 40 01 00 ‖ flag ‖
  3B ‖ 01 ‖ u16 ‖ X(u16) ‖ Y(u16)`. `asset_ptr` aponta para o glifo "0"; dígito *d* = o asset em
  `index("0") + d` (10 sprites cf=5 consecutivos, ex.: `0123456789` e pontuação `,°`). ✅ renderizado.
- **Preenchimento de complicação = índice de quadro** (✅ confirmado para complicações de dígito/enum/medidor, count>1): o
  valor indexa uma **folha de quadros pré-renderizada** no `.bin` — `frame = (count−1)·val/100` (percentual) ou
  `frame = value` (dígito flip / enum). Tabela de quadros = sub-registro `61 ‖ count(u16) ‖ base(u32) ‖
  count×id(u16)`. Ex.: a hora grande do 327 Digit Max é uma folha de 13 quadros (números 0–12), `frame = hour`.
- **Anel/arco de progresso = recorte de setor em tempo de execução** (✅ 2026-07-02, **corrige a leitura "anéis são folhas de quadros"
  na spec 25 §2**): a tag de elemento **`0x81`** carrega um **único** disco completo (`61` tabela de quadros
  `count == 1`), e a cunha parcial é esse disco **recortado em um setor de pizza** (`frac = value/max`,
  no sentido horário a partir das 12 horas) — verificado pixel por pixel no 322 Glare 2 e confirmado `count==1` em
  **20 dials**. No disco: corpo `0x81` = sub `0x01` (geometria `x@+0 y@+2 w@+4 h@+6`, `61 1 base` inline
  = disco) + sub `0x5b` (spec de arco). Implementado no wfweb (`blendSector`).
  ⚠️ O sub-registro `0x5b` **não** é apenas "`max` u16 `@+4`" como documentado anteriormente — isso lia a
  metade baixa de um `max i32` e perdia os **ângulos de varredura inicial/final e a largura do traço** que ficam logo
  depois. Há também um irmão procedural `0x80`/`0x5a` (com raio explícito) que este
  documento nunca cobriu. Layout completo do registro, e o que a suposição anterior "sentido horário a partir das 12 horas"
  errou: **§11.15**.
- **Id da fonte de dados** — o bloco de atributos `82` do elemento fica em `delim+3` (após o último `40 01 00`),
  e o **id da fonte é um `u8` em `+0x14`** (também `relX@+0x07 s16`, `relY@+0x09 s16`, `anchor@+0x0C/0E`,
  `mode@+0x15`, `frame-count@+0x1A`). Anchor < 0 = alinhar à borda do pai. 🔎 O firmware resolve
  o id por meio de uma tabela de getters de 142 entradas em `0x101f371c` (cada uma chama `ux2sys_get(type)`).
  ⚠️ **Prefira ler o id como `meta[9]` da struct (§11.11)** — um campo fixo — em vez desta varredura
  de `82`-attr para frente, que é a fonte do off-by-one descrito em §11.8/§11.9. Tabela completa de ids em §16; observe
  que os rótulos de saúde/clima que esta seção listava inline anteriormente (`0x19` FC, `0x1b` bateria,
  `0x24` temperatura, `0x36` passos) são **contestados e provavelmente errados** — veja a caixa ⚠️ em §16.
  (O exemplo de grupo `0x07:0x0b:0x0f` = HH:MM:SS abaixo não é afetado.)
- **Nó de grupo** (`0x68`): aninha seus filhos dentro do próprio corpo TLV (`0x60` = valor/texto,
  `0x30` = estático); cada `0x60` carrega seu id de fonte em `data+16`. Ex.: um grupo `0x07:0x0b:0x0f` =
  relógio HH:MM:SS. Parser de elemento TLV = `0x100db55c` (tabela de salto indexada por `tag−0x70`).

### 11.6 Matriz de autoria

| Caminho | Status | Notas |
|---|---|---|
| Dial de foto a partir de qualquer imagem | ✅ **feito** | §11.3; validado no dispositivo |
| Instalar qualquer um dos 103 dials da loja | ✅ **feito** | §11.4; `9075`, `old_id`=ativo |
| Revestir o fundo cf=4 de um dial da loja | ✅ **funciona ao vivo** | trocar o payload COMPLETO no lugar, definir o `len` do asset para o **novo** tamanho de bloco (≤ antigo), manter a mesma pegada de arquivo, instalação limpa |
| Re-autorar por modelagem (trocar pixels de qualquer camada + mover geometria) | ✅ **renderiza via BLE** | dial 373: bg→ciano + um sprite cf=5→vermelho + X movido 224→100, tudo renderizado, agulhas ao vivo |
| Dial estruturado 100 %-sintético do zero | ✅ **builder feito, validado offline** | builder de envelope `0x20` em `watchface_struct.rs` (`build_container`/`serialize`/`validate_container`); round-trip de todos os 103 dials byte-exato + sintético passa no validador do firmware (§11.7). 🟡 render no dispositivo sobre `9075` ainda não filmado |
| Fontes do sistema (`.font`) | ✅ **decode/render (todas)** | bin LVGL (não proprietário); 32 fontes numéricas (`num*/nm*`, descomprimidas) + 24 fontes de texto (`font*`, LVGL RLE `comp=1`) todas decodificadas — 12208 glifos, 0 estouros, ASCII completo. RLE = LVGL v8.3 `lv_font_fmt_txt.c` (SINGLE/REPEATE/COUNTER de 3 estados + pré-filtro XOR por linha), portado 1:1, sem desmontagem |

⚠️ Armadilhas de revestimento/re-autoria que causam tela preta ou `0a`: deixar o **`len` antigo do asset** (o
relógio lê além do bloco → estouro → preto); **aumentar o arquivo** (rejeitado na instalação); reutilizar um
id **no lugar** em vez de uma instalação limpa; **reordenar assets** sem corrigir a cadeia de tamanho de bloco da cauda de referência
(§11.11). Não é fatal hoje, mas escreva corretamente de qualquer forma: qualquer mudança na cena ou no pool de assets
invalida as duas palavras **CRC32** do cabeçalho — recalcule ambas (§11.4), `@0x20` antes de `@0x00`.

### 11.7 O envelope de cena `0x20` — decodificado e builder implementado ✅

Um dial **real** re-autorado renderiza porque preserva o envelope de cena do arquivo. Um corpo puramente sintético
de registros planos `61 …` é **rejeitado** — o parser do firmware (`WFManager_Parser`, `0xdb35c`)
exige que o corpo (do offset `0x24`) comece com um contêiner de cena `0x20`. O arquivo completo é:```
[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

A cena é um TLV aninhado limpo — [tag u8][len u16 LE][body], tags de contêiner 0x20/0x21/0x22/0x68 recursivas, drawables folha 0x30 (estático) / 0x70 (elemento/ponteiro) / 0x80 / 0x81 / 0x86 (nome). (Os registros planos 61 01 00 / 61 0a 00 são padrões que vivem dentro dos corpos dos drawables; o parser antigo os encontrava heuristicamente — e costurava corpos adjacentes, veja a revisão §11.5. O layout do corpo do drawable agora está totalmente decodificado lá.) O offset+len de cada filho deve caber dentro da janela do pai; primeiro byte do corpo ≠ 0x20 → erro de parser −16; um filho ultrapassando sua janela → −2; qualquer um faz o handler 9065 (0xeb50c) escrever o final .

O builder está implementado e validado offline (core-rust/watchface_struct.rs: SceneNode / serialize / parse_scene / validate_container / build_container / build_container_raw; CLI cmfwatch-wfgen reframe):

  • scene_roundtrip_identity — todos os 103 mostradores da loja: parse_scene→serialize reproduz a cena byte por byte (os len aninhados recalculados coincidem) e validate_container passa em todos.
  • build_reframe_identity / CLI reframe — remontar o .bin inteiro do zero reproduz o arquivo byte por byte exceto 1 byte de padding de nome (@0x17; não é um checksum).
  • build_container_synthetic — compõe um novo mostrador (fundo + drawable aninhado em 20→21) que passa a invariante exata do firmware (build_container emite janelas aninhadas corretas).
  • validate_rejects_bad_containers — rejeita um corpo plano (→ , o bug histórico ) e um filho que ultrapassa sua janela (→ ).

🟡 Ainda não comprovado (precisa do relógio, não bloqueante): enviar um sintético do zero via 9075 e vê-lo renderizar — a prova estrutural offline já cobre o que causou a rejeição 0a.

11.8 Refinamentos de fidelidade de renderização (2026-07-02, mostrador 275 "SlopeTime")

Cruzou-se a renderização do wfweb com as miniaturas oficiais da loja (oráculo de pixels sobre todos os 103 mostradores) e fechou-se quatro lacunas:

  • X/Y de drawable/ponteiro são i16 (com sinal). ✅ Âncoras podem ser negativas para elementos que se estendem para fora do canvas — ex.: o ponteiro vermelho de segundos do 275 está em Y = 0xFFFC = −4 (um sprite 30×281, fonte 0x12, rotacionado do centro para fora da borda superior). Ler X/Y como u16 (65532) fazia o guarda descartá-lo. Analise ambos como com sinal e permita uma pequena faixa negativa.
  • Dígitos de relógio digital podem ser 0x60 img_numbers de nível superior (não apenas dentro de um grupo 0x68), e a fonte de dados real é o u8 no offset de registro −5 — a varredura direta de atributos 82 está sistematicamente deslocada em um aqui e captura o atributo do próximo irmão (no 275 o dígito de minuto pegou o 0x18 do dia da semana). O "10:10" do 275 = hora 0x07@X≈306 + min 0x0b@X≈369 com o como um estático adjacente entre eles, cada um um atlas de 11 glifos (). ⚠️ Ao corrigir X/Y de , os , ou uma re-exportação corrompe esses bytes (quebra mesma pegada → ).

Também: as miniaturas oficiais da loja são renderizadas às 10:10 (horário clássico de marketing), não 10:12 — igualar o horário do oráculo a 10:10 reduz a diferença média de pixels visivelmente. O parser do wfweb agora faz round-trip de todos os 103 mostradores byte-exato (a correção de offset de escrita X/Y acima limpou as últimas incompatibilidades).

11.9 Pulo do contêiner AOD + fonte img_number independente (2026-07-03, mostrador "Gradient")

  • Separe o contêiner AOD 0x22 em sua própria visualização. ✅ O caminhador de cena já pula 0x22, mas a varredura plana de texto/número percorria todo o [0x30, firstAsset) — então emitia a variante always-on (AOD) de cada elemento como uma camada normal. No "Gradient" o atlas de data cinza AOD (offset em 0x22) desenhava por cima do vermelho normal. Correção: marque cada registro 0x22 com layer.aod=true (com seu próprio conjunto de deduplicação) e deixe renderAt(…, aod) mostrá-los apenas no modo AOD (o modo normal oculta camadas aod; o modo AOD oculta as normais; o fundo é trocado por setAod e sempre desenha). Ganho líquido do oráculo no modo normal em todo o corpus (284: 31%→21%, +18 outros) — as variantes AOD estavam sobredesenhando muitos mostradores — e o alternador AOD do editor agora mostra o layout always-on real em vez dos normais. O AOD real é uma tela preta (sem cena escurecida): se o mostrador não tem um quadro de fundo AOD dedicado (dial.aod), a cena normal é ocultada no modo AOD para que renderize preto + os elementos em sua própria cor. Os AOD são analisados pelo caminhador de cena também (agora ele recorre no contêiner marcando drawables , em vez de deixá-los para a varredura plana onde seu pivô não correspondia → "sem posição"); ponteiros AOD giram no centro do canvas (o às vezes carrega um x/y de ponteiro descentralizado que o firmware ignora — ex.: a hora do Gradient ). O editor também expõe isso como uma (§UI): cada tela mostra apenas suas próprias camadas e as edições persistem independentemente. A renderização em modo normal é byte-idêntica em todo o processo; o roundtrip permanece byte-exato em todos os 103 mostradores.

11.10 CONTAGEM DE DÍGITOS img_number — o byte 40 01 00 XX (✅ confirmado pelo firmware)

Quantos dígitos um img_number desenha é um único byte no registro de campo — o byte de dados XX do sub-registro de atributo 40 01 00 XX do elemento (o sub-registro 0x40 que fica após a tabela de quadros 61 [count][base][glyph-ids]):

  • nibble baixo XX & 0x0F = número de slots de dígitos (0 ⇒ padrão do firmware 7).
  • bit 7 0x80 = zero à esquerda (mostrar zeros iniciais, ex.: "09" vs "9").

Confirmado por desmontagem do firmware (imagem XIP 0x10000000; rotina de renderização 0x100d8e60): NDIG = ldrb[40sub+3] & 0x0F (→7 se 0); o valor é limitado value % 10^NDIG e exatamente NDIG glifos são desenhados MS-primeiro, zeros iniciais suprimidos a menos que bit7. O u16 após a fonte (60 para data, 1000 para kcal) NÃO é a contagem — ele só alimenta a inserção do glifo separador de milhares/milhões (cmp #1000/#1000000), que é por que editá-lo não fazia nada. O id da fonte também não limita.

O histograma do corpus sobre todos os 620 campos numéricos corresponde: campos de 2 dígitos (hora/min/seg/data/temp/FC) terminam 40 01 00 02/0x82; kcal …04; passos …05; divisões de relógio de dígito único 0x81. Então o campo de data 40 01 00 82 = 2 dígitos, com zero à esquerda — essa é a razão inteira pela qual uma temperatura Fahrenheit reatribuída (≥100) era truncada.

Correção / editor: o wfweb analisa digitCount/digitZeroPad (+digitCountOff) para campos numéricos, expõe "Dígitos" + "Zero à esquerda" no inspetor, escreve o byte no lugar (mesma pegada), e a pré-visualização limita/preenche para digitCount para espelhar o firmware. Então reatribuir a fonte de um campo e definir sua contagem de dígitos funciona para qualquer campo (ex.: data→temperatura °F → Dígitos 3). Oráculo em modo normal inalterado (0 regressões, 3 pequenas melhorias); roundtrip byte-exato em todos os 103 mostradores. (A hipótese anterior de "largura de Dígitos"/rectW estava errada — largura é apenas layout, não a contagem.)

11.11 Inventário de nós, o registro struct, e a cauda de referência de recurso ✅

A cena (§11.7) é um TLV aninhado limpo — [tag u8][len u16 LE][body]. Inventário completo de tags como observado no corpus:

✅ Este inventário está completo para o corpus. Recorrendo apenas nas tags de contêiner acima, o TLV de cena de todos os 15 mostradores verificados caminha exatamente até seu comprimento de raiz declarado com zero tags desconhecidas — então um parser que lida com esta tabela lida com o formato inteiro, e uma tag desconhecida significa uma leitura desalinhada, não um novo tipo de nó. (Cuidado: um caminhador que recorre em todo nó cujo comprimento acontece de ser ≥ 3 descerá em corpos struct/0x5b e alucinará uma longa cauda de "tags" únicas — os corpos folha não são TLV.)

💡 Atalho de autoria: o auto-layout 0x48/0x68 pode ser pulado inteiramente — cada widget pode ser colocado com x,y absoluto diretamente no nível superior da tela, que é o que o builder do zero (§11.7) faz. Só é necessário para ler mostradores existentes. Uma largura meta de 0x8000 marca um struct como um filho de auto-layout de um quadro (a posição vem do pai, não de x,y).

Corpo do struct 0x01 — um prefixo fixo de 18 bytes seguido por uma cauda opcional de referência de recurso:``` +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)

root@kitploit:~
> ✅ Isto unifica os "offsets mágicos" das §11.5/§11.8/§11.9. Essas seções localizam campos *relativos
> ao byte `0x61` da tabela de frames* — que é simplesmente `+0x12` desta struct, portanto `−18`/`−16` = `x`/`y` e
> **`−5` = `meta[9]`, o id da fonte**. Mesmos bytes, um layout limpo. A heurística de varredura direta do atributo `82`
> que estava sistematicamente deslocada por um não é necessária: leia `meta[9]` da struct.
> O `max` de um campo numérico é igualmente apenas `meta[11..13]` (por exemplo, campos de dia do mês carregam `max = 99`).

**Cauda de ref** (`61`) — como um nó aponta para seus 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

⚠️ Esses u16s finais foram anteriormente documentados como "count×id(u16)" / ids de glifos. Eles são tamanhos de bloco: base, mais a soma acumulada deles, percorre o pool de assets entrada por entrada (✅ verificado exatamente para todas as 10 entradas de um atlas de dígitos). Duas consequências:

  • Os assets referenciados por um nó devem ser consecutivos no pool de assets. Não há acesso aleatório — a cadeia só avança para frente. Planeje o pool para que cada conjunto de dígitos (10), cada pick-list (N) e cada folha de frames seja um bloco contíguo. Um escritor que reordena assets sem corrigir a cadeia produz um arquivo que lê lixo (→ tela preta).
  • Apenas os primeiros count−1 tamanhos são essenciais; o valor da última entrada nunca é seguido, então arquivos encontrados na natureza às vezes carregam um valor obsoleto ali. Não trate uma incompatibilidade na entrada final como uma referência quebrada.

0x28 preview — a miniatura da loja/catálogo é embutida no próprio .bin (27 nós de preview em 15 mostradores), como um pvStruct 0x08: um prefixo de 5 bytes mais a mesma cauda de referência, com sem x/y. Útil para construir uma UI de galeria sem enviar PNGs separados.

11.12 Condições de visibilidade — tag 0x02 ✅

O irmão 0x02 de um widget o torna condicional. Sem ele, o widget sempre é desenhado. Gramática:``` count u8 , count × ( id u8 , op u8 , val u24 LE signed ) [5 bytes per entry]

root@kitploit:~
`id` é um id de fonte de dados (§16) — incluindo os **ids de slot sintéticos** do §11.13. Operadores, com
contagens de ocorrência medidas em 15 mostradores:

| op | significado | visto |
|---|---|---|
| `0x01` | desenhar se `value == val` | 99 |
| `0x81` | igual a `0x01` (bit `0x80` definido — aparece em variantes mutuamente exclusivas) | 48 |
| `0x02` | **ocultar** se `value == val` | 7 |
| `0x03` | desenhar se `value == val`, onde `val` é um marcador de **sem dados** (ex.: HR `1000`) | 13 |
| `0x05` | desenhar se `value >= val` | 58 |
| `0x06` | desenhar se `value <= val` | 50 |
| `0x04` | ⚠️ **desconhecido** — 15 ocorrências, sem semântica confirmada | 15 |

Regra de combinação (conforme implementada pelo renderizador de referência, máscara `op & 0x7f`): as entradas
de igualdade são combinadas com **OR**, e depois as entradas de ocultar/`>=`/`<=` devem **todas** ser válidas.

Este único mecanismo cobre a maior parte da variabilidade em tempo de execução do formato e explica estruturas que parecem
widgets duplicados:

- **Layouts 12h / 24h e métrico / imperial** — dois conjuntos de widgets empilhados no mesmo ponto, cada um vinculado a
  `id 0x73` (o sinalizador de unidades) com `val` 0 ou 1. O mostrador 275 tem seis desses pares (12 nós).
- **Espaços reservados de "sem dados"** — op `0x03` contra um sentinela, ex.: `id 0x5f, val 1000` (mostrador 275, duas vezes):
  desenha a arte de travessão em vez de uma temperatura quando a métrica não está disponível.
- **Destaques em intervalos** — um intervalo pareado `0x05`/`0x06`, ex.: a corrente do Metaball onde cada elo acende
  para sua própria janela de 5 minutos.
- **Alternativas de slots de complicação** — vinculadas aos ids de slot sintéticos do §11.13.

### 11.13 Slots de complicação configuráveis — `0x85` + `0x5f` ✅ (substitui §11.8)

O §11.8 concluiu que a métrica ativa de uma complicação configurável é estado de RAM do dispositivo e não poderia ser
recuperada do arquivo. **Isso estava errado** — tanto o menu de métricas do slot quanto sua seleção padrão
estão no `.bin`. Cada nó `0x85` carrega um irmão `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

As alternativas que realmente são desenhadas são grupos comuns de 0x68 em outras partes da árvore, cada um controlado por uma condição 0x02 (§11.12) no id sintético 0x79 + slotIndex — então as variantes do slot 0 dependem de 0x79, as do slot 1 de 0x7a, e assim por diante. Para renderizar um slot: leia activeIdx e desenhe a variante cuja condição corresponda a esse índice.

Medido em mostradores reais:

Os dois slots de 6 métricas do mostrador 275 respondem por 12 dos seus 26 nós 0x02, exatamente como previsto: 01 79 81 0X 00 00 e 01 7a 81 0X 00 00 para X = 0..5 — seis alternativas chaveadas em 0x79 (slot 0) e seis em 0x7a (slot 1). (Os bytes 0x79/0x7a que §11.8 chamou de "byte de instância" são esses ids de vínculo.) Os 14 restantes não estão relacionados: 12 em 0x73 (o sinalizador de 24h/unidades de métrica, val 0 ou 1 — seis pares de widgets alternando entre layouts de 12h e 24h) e 2 em 0x5f com op 0x03 e val = 1000, o placeholder de sem dados de temperatura.

Ainda é genuinamente estado do dispositivo: o que o usuário escolher depois no aplicativo complementar substitui activeIdx em tempo de execução, então uma pré-visualização reproduz o padrão do arquivo, não necessariamente o que um relógio específico mostra. imgs[0] de um nó 0x85 é um placeholder "toque para configurar" que o firmware desenha apenas no seu próprio modo de edição — ignore-o ao pré-visualizar a exibição normal de horas.

11.14 Cor de destaque — o sinalizador de capacidade meta[7] == 4 ✅

Alguns mostradores permitem que o usuário escolha uma cor de destaque no dispositivo, e o firmware a substitui nos bitmaps do widget em tempo de renderização. O interruptor é um único byte: meta[7] da struct (§11.11) — ou seja, o byte +0x0B do corpo 0x01 — igual a 4 marca o(s) recurso(s) desse widget como tingíveis.

  • É um sinalizador de capacidade por widget, não uma cor. Recolora cada pixel não transparente de um recurso sinalizado (deixe o alfa intacto); não há teste de cor por pixel envolvido.
  • Prevalência: 37 de 499 structs e 8 de 15 mostradores medidos aqui; o corpus fmc relata 56 de 100 mostradores com pelo menos um widget sinalizado.
  • 🛑 Nunca grave uma cor de destaque nos bytes exportados. A substituição é ao vivo no relógio; um .bin enviado deve manter seus pixels originais ou você perde permanentemente a escolha do usuário. Aplique o tingimento apenas no caminho de pré-visualização/canvas.

⚠️ Não "melhore" isso em uma heurística de cor — esse caminho é um beco sem saída comprovado (documentado por fmc depois de fazê-lo do jeito difícil). A teoria intuitiva é que pixels sinalizados são gravados em alguma cor de placeholder reconhecível que o firmware troca. Não pode funcionar: o anel tingível do mostrador 348 Tumbler e as faixas de dígitos comuns não tingíveis dos mostradores 282 Radar Sweep / 291 Vertical gravam o exatamente mesmo RGB (255,72,32) (verificado exaustivamente, cada pixel); e os mostradores 305 Dots (ponteiro de horas) e 306 Large Number (dígitos) são tingíveis enquanto gravados em branco puro, então um teste de cor os perderia completamente. Refinamentos sucessivos (1 → 4 cores de referência, mais uma lista de permissões de função de widget) todos falharam. Leia o sinalizador.

Verificado cruzadamente contra o dispositivo real / aplicativo complementar em 7 mostradores, escolhidos para estressar ambas as direções — 349 Theatre, 376 Digits time, 305 Dots, 306 Large Number, 304 Elaborate 2 todos oferecem a configuração de destaque e todos têm widgets meta[7]==4; 316 Trailing (ponteiro avermelhado, sem configuração), 312 Disc e 295 Vortex não oferecem nenhuma e têm zero widgets sinalizados.

meta[4..6] fica logo ao lado do sinalizador e parece que pode codificar uma cor em algumas structs (um RGB de aparência real com cauda f1=1,f2=255, vs. o placeholder (1,0,0)/(4,0,0) das structs sinalizadas). Isso não se correlaciona com a capacidade de destaque. ⚠️ Não resolvido; ignore.

11.15 Anéis de progresso — 0x80/0x5a (procedural) e 0x81/0x5b (recorte de imagem) ✅

Ambas as variantes de anel emparelham uma struct curta (x, y, meta com o id de origem — geralmente sem cauda de ref nenhuma) com um irmão de especificação de arco. §11.5 documentou apenas "0x5b: max u16 @+4", que é a metade baixa de um max i32 e perde a geometria do varrido. Registro completo:``` +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)

root@kitploit:~
O emparelhamento é rígido: `0x80` carrega sempre exatamente `0x01` + um **`0x5a` de 19 bytes**, `0x81` sempre exatamente
`0x01` + um **`0x5b` de 17 bytes** (✅ 26/26 anéis em 15 mostradores). ⚠️ Mas o **`0x81` recortado por imagem
domina** — 25 desses 26. A variante processual `0x80`/`0x5a` apareceu **uma vez** (mostrador 273), então seu
campo `radius` e layout de 19 bytes dependem de uma única amostra; trate com suspeita até ser visto novamente.

`frac = clamp((valor − min) / (max − min), 0, 1)`, e o arco preenchido vai de `start` em direção a `end`.
O ângulo zero está às **3 horas**, positivo no sentido horário. Exemplos medidos:

| mostrador | tag | min..max | start → end | largura | raio |
|---|---|---|---|---|---|
| 273 Activity Mood | `0x5a` | 0..100 | **−102,8° → 102,8°** | 42 | 222 |
| 273 Activity Mood | `0x5b` | 0..100 | 270,0° → 90,0° | 80 | (imagem) |
| 276 Dichotomy | `0x5b` | 0..100 | 60,0° → −120,0° | 23 | (imagem) |
| 304 Elaborate 2 | `0x5b` | 0..100 | −2,0° → 358,0° | 24 / 80 | (imagem) |
| 366 Combo | `0x5b` | 0..100 | 0,0° → 270,0° | 18 | (imagem) |
| 368 Function | `0x5b` | 0..100 | 0,0° → 360,0° | 20 | (imagem) |

> ⚠️ **Isto corrige a suposição "sentido horário a partir das 12 horas"** na §11.5. Esse é apenas o caso
> especial `start = 0, end = 3600` (uma varredura completa, onde a convenção é inobservável). Mostradores reais usam
> **medidores parciais** (o leque de ±102,8° do 273, o anel de três quartos de 270° do 366) e **varreduras negativas**
> (60° → −120° do 276), então um renderizador que sempre varre um círculo completo a partir do topo desenha esses errado.
> 🟡 A convenção exata de ângulo zero e a regra de direção vêm do renderizador do fmc, verificadas de forma cruzada
> contra esses valores em disco — não verificadas de forma independente pixel por pixel no dispositivo por este repositório.
> Observe que `frac = valor/max` e o recorte de setor `0x81` na §11.5 **foram** validados (mostrador 322).

⚠️ **Não resolvido: o trailer de 3 bytes `01 00 kk`.** `kk` assume valores de aparência plausível (104, 152, 216,
232, 248) e a hipótese óbvia é um raio — **testada e refutada**: o mostrador 366 usa `kk = 104`
para um anel de 82×82 e um de 166×166, e o mostrador 273 usa `kk = 232` para um anel de 440×440 e um de 284×284.
Não é um raio, não é um diâmetro. Possivelmente opacidade/estilo. Faça o round-trip verbatim.

🟡 **Pegadinha relatada, não verificada aqui:** no renderizador do fmc, um número `0x60` cujo id de origem seja igual ao
id de **qualquer** anel na mesma tela renderiza `round(frac × 100)` em vez do valor bruto — então um
número de frequência cardíaca ao lado de um anel de frequência cardíaca mostra `36` em vez de `71 bpm`. A
solução documentada por eles é colocar o anel e o número em dois ids **alias** da mesma métrica (passos
`0x19`/`0x26`/`0x49`, calorias `0x1c`/`0x1e`/`0x48`). Se isso é comportamento do firmware ou específico
do renderizador deles **não está estabelecido** — vale uma verificação ao vivo antes de projetar em torno disso.

---

## 12. Detalhes de transferência em massa e OTA

A tabela de transferência está na §6. Pontos adicionais confirmados:

- **AGPS/EPO** ✅: o primeiro bloco gravado começa com o cabeçalho ASCII `000000010000…`. O loop completo
  init → `[A05F ↔ 905F]×N` → finish foi observado no fio (~892 blocos).
- **OTA de firmware** (`9040`–`9042`, finish `9041`) 🔎: estrutura mapeada; payload INIT2 = bytes de versão
  (ex.: `0b 00 00 39` = 11.0.0.57). **Não testado em campo** (o app desativa a atualização de FW aqui). As imagens
  de firmware parecem ser **não assinadas — a integridade é apenas CRC32** (nenhuma assinatura assimétrica observada na RE).
- ⚠️ Como OTA e `FACTORY_RESET (009A 0001)` compartilham a sessão autenticada, uma única autenticação BLE
  válida é suficiente para apagar ou (em princípio) brickar o relógio. Manuseie com cuidado.

---

## 13. Sensores

✅ Hardware exposto via BLE:

- **PPG óptico** — frequência cardíaca (manual/automático/treino/repouso), SpO₂ e estresse derivado de VFC.
- **Acelerômetro de 3 eixos** — passos, distância, calorias, estágios do sono, levantar o pulso, cadência.
- **GNSS/GPS** (assistido por AGPS) — trilha de treino (`WORKOUT_GPS`) e envio de localização (`GPS_PUSH`).

**Não há** barômetro/altímetro, bússola, giroscópio ou sensor de temperatura de pele/corpo. Um termistor
NTC interno (temperatura da placa/bateria) existe, mas é legível **apenas** pelo canal AT
(`AT GETNTCTEMP`, §14) — o fluxo de histórico de temperatura da pele `0155` está vazio neste SKU.

**Sequestros de widget de dados** (não há API real de complicação/vinculação de dados — veja §11.5): os
campos de texto existentes do relógio podem ser reaproveitados para mostrar dados externos de relance. Comprovado ✅: a string
de **cidade do clima** (`WEATHER_SET_1`, ex.: `"BRA 2x1 ARG"` apareceu no widget) e os campos de **faixa/artista**
de música; a lista de **contatos** (20 × nome[32]+número[25]) funciona como um painel de dados rolável. Todos são
envios, não complicações persistentes.

---

## 14. Canal AT de fábrica / shell (`77d4ff01` / `77d4ff02`)

Um canal de comandos AT em texto puro separado, independente do protocolo enquadrado. ✅ testado ao vivo:

- **Leitura:** `AT GETSECRET` (segredo de emparelhamento de 16 bytes), `GETVERSION`, `GETSN`, `GETNAME`, `GETPID`,
  `GETBATLV` (mV bruto, ex.: `3853mv`), `GETGSENSOR` (aceleração bruta em g, `X=… Y=… Z=…`),
  `GETNTCTEMP` (°C, NTC interno).
- **Escrita / atuação:** `AT SETMOTOR=1` (vibrar o motor), `SETHR/SETHRV/SETSPO2=…` (injeção de teste
  de sensor), `SETLCDSWITCH/SETGPSSWITCH/SETKEYSWITCH`.

As respostas terminam em `,OK`. Comandos `SET*` geralmente executam, mas podem não ecoar `,OK` via BLE — confirme
caso a caso.

---

## 15. Recursos bloqueados por firmware / indisponíveis (🔎 RE de firmware)

Alguns recursos estão presentes no firmware, mas desabilitados por SKU/região e **não são acessíveis pelo
telefone/BLE** — eles precisam de uma modificação de firmware, que está fora do escopo aqui:

- **Voz ChatGPT** — gate = id de recurso `ux2sys` `0x9e`, semeado de NVRAM/EFUSE/região na inicialização; neste
  SKU o flag de suporte `908b = 00`. Não influenciável por telefone, conta ou BLE (confirmado por
  experimento + RE). O app é apenas um relé; o áudio vai do telefone → nuvem da Nothing.
- **Pressão arterial** — um subsistema completo existe no firmware, desligado por SKU/região.
- **Alipay / pagamento NFC** — UI completa presente, apenas para SKU da China.
- **Ausente em hardware/firmware:** ECG, SOS/emergência, NFC genérico.

---

## 16. Ids de fonte de dados / getter de complicação

Não é necessário para construir um cliente BLE, mas **essencial para criar ou renderizar um mostrador**: este é o valor de
`meta[9]` (§11.11) que vincula um widget a dados ao vivo, o `id` de uma condição de visibilidade (§11.12) e
as entradas do menu de métricas de um slot (§11.13). O firmware o resolve por meio de uma **tabela de despacho de getter
com 142 entradas** em `0x101f371c`, cada entrada chamando `ux2sys_get(type)` (🔎 RE de firmware).

### Hora / data — ✅ bem estabelecido

| id | significado | id | significado |
|---|---|---|---|
| `0x01` | hora (12/24h conforme configuração do dispositivo) | `0x0f`, `0x12` | segundo (suave) |
| `0x04` | hora (24h) | `0x10`, `0x11` | dezenas / unidades de segundo |
| `0x07` | hora (24h forçado) | `0x71`, `0x72` | segundo (tique-taque / ângulo do ponteiro) |
| `0x02`, `0x03` | dezenas / unidades de hora-12h | `0x13` | flag AM/PM (0 = AM, 1 = PM) |
| `0x05`, `0x06`, `0x08`, `0x09` | dezenas / unidades de hora | `0x15`, `0x16` | mês |
| `0x0a`, `0x70` | ângulo do ponteiro de hora | `0x17` | dia do mês |
| `0x0b` | minuto | `0x18` | dia da semana (0 = segunda-feira ⚠️) |
| `0x0c`, `0x0d` | dezenas / unidades de minuto | `0x0e`, `0x71` | ângulo do ponteiro de minuto |

Ids de dezenas/unidades desenham um **único dígito** — um widget vinculado a um deles renderiza um glifo, não o valor
inteiro (mostrador 284 Square). Ids de ângulo de ponteiro (§11.5) são `0x0a`/`0x70` hora = `h·30° + m·0,5°`,
`0x0e`/`0x71` minuto = `m·6° + s·0,1°`, `0x12`/`0x72` segundo = `s·6°` (✅ confirmado pela desmontagem
dos getters, fallback RTC 10:10:30).

### Saúde / sensores / clima — ⚠️ contestado, leia a coluna de evidências

| id | significado (melhor leitura atual) | evidência |
|---|---|---|
| `0x19` | **passos** | no menu de slots do 368 junto com `0x1a`; corresponde ao `parse.ts` deste repositório |
| `0x1a` | **frequência cardíaca** | no menu de slots do 368 junto com `0x5f` e `0x19` |
| `0x1c` | calorias | menu de slots, ícone de chama no app complementar |
| `0x1e` | calorias (alias) | corpus |
| `0x22` / `0x23` | distância km / mi (parte inteira) | corpus |
| `0x74` / `0x75` | distância km / mi (parte fracionária) | corpus |
| `0x76` | distância (forma de slot) | menu de slots, ícone de estrada |
| `0x24` | **% de bateria** | menu de slots, ícone de raio |
| `0x30` | % de bateria | corpus |
| `0x36` / `0x5f` | temperatura | `0x5f` = menu de slots, ícone de nuvem-sol; 361 TempoG vincula um número simples |
| `0x48` | permanências (horas em pé) | menu de slots, ícone de figura em pé |
| `0x8b` | AQI | menu de slots |
| `0x73` | flag de 24h / unidades métricas | corpus |
| `0x25`–`0x27`, `0x49`, `0x6c`, `0x6f` | % de meta / aliases de slot de passos e calorias | corpus |
| `0x6a` | ⚠️ métrica de slot não identificada | aparece em 4 menus de slots |
| `0x79 + slotIndex` | **sintético** — não é uma métrica; o id de seleção de slot (§11.13) | ✅ §11.13 |

> ⚠️ **Três tabelas neste repositório discordavam; esta é a reconciliação.** Revisões anteriores da §16
> e da §11.5 liam `0x19` como frequência cardíaca, `0x1b` como bateria, `0x24` como temperatura e `0x36` como passos —
> e `wfweb/src/codec/mock.ts` ainda codifica essa leitura, enquanto `wfweb/src/codec/parse.ts` codifica uma
> diferente (`0x19` passos, `0x24` % de meta, `0x48` permanências, `0x1a` clima). A tabela acima segue
> a leitura com melhor evidência (rótulos calibrados pelo fmc contra os ícones do menu de slots de widget do próprio app
> complementar, veja §Fontes). **O código ainda não foi alterado — `mock.ts` e `parse.ts` ainda estão
> inconsistentes entre si e com esta tabela.** Trate os rótulos de ids de saúde como ⚠️ até que alguém vincule
> um campo a cada id e leia o relógio.
>
> A evidência individual mais forte é o **menu de slots do mostrador 368 Function** (§11.13), que oferece
> `0x5f 0x1c 0x19 0x48 0x24 0x76 0x1a 0x8b` como **oito métricas distintas selecionáveis pelo usuário** em um menu.
> Quaisquer que sejam os rótulos, nenhum par desses oito pode ser a mesma métrica — o que descarta
> `0x19` = `0x1a` = frequência cardíaca e `0x24` = `0x5f` = temperatura simultaneamente.

Complicações de anel/arco com uma **folha de quadros** indexam um quadro pré-renderizado (ex.: 50 % = quadro 50 de 100)
embutido no `.bin` que você envia (§11.5), então nenhum pacote RES externo é necessário; anéis sem imagem são desenhados
a partir da especificação de arco (§11.15).

---

### Quick-cards (tiles da tela inicial) — `QUICK_CARD (906D)` ✅

Os tiles da tela inicial do relógio. O telefone apenas escolhe **quais** tiles mostrar e em **qual ordem** — os tiles são
renderizados pelo firmware (sem canal de conteúdo). Primeiro byte do payload = sub-comando: `00` = GET, `01` =
SET; **ambos usam `0x906D`** (`0x906C` está listado, mas não é usado — consultá-lo dá timeout). Resposta = `A06D`.

> 🛑 Enviar um `assemblyId` inventado **apaga as telas do relógio** (ele aceita a lista, não consegue corresponder
> aos ids, não mostra nada). Envie apenas ids que você **leu de volta** via GET; recupere via o app oficial ou um
> reset de fábrica.

**Resposta GET** ✅: `status(1) ‖ 00 ‖ N(1) ‖ N × grupo`, grupo = `tag=01 ‖ K(1) ‖ K×(assemblyId, sportId)`.
Quadro real: `01 00 04  01 02 5d00 6100  01 03 1900 2e00 2300  01 03 5c00 0400 5a02  01 03 4800 5100 5300`
= 4 telas / 11 cartões (`5a02` = cartão Sport, sportId 2).

**Slots:** cada tela tem **4 slots**. O tipo de um cartão define seu tamanho — `circular`/`quadrado` = 1 slot,
`retângulo` = 2 slots. A validação é pura aritmética de slots (Σ ≤ 4 por tela); sem cartões mutuamente exclusivos.
`sportId` é `0` exceto em cartões Sport (87–91). Os ids `64` e `95` não existem.

**Catálogo de `assemblyId`** (cada tipo lógico = um intervalo contíguo de 6 variantes de estilo `_0`..`_5`;
`0` = slot vazio):

| dec | cartão | dec | cartão |
|----|----|----|----|
| 0 | slot vazio | 49–53,97–98 | Clima |
| 1–6 | Passos | 54–58 | Timer |
| 7–12 | Calorias | 59–62 | Respiração |
| 13–18 | Permanência | 63,65–67 | Cronômetro |
| 19–24 | Atividade moderada | 68–71 | Bateria |
| 25–30 | Frequência cardíaca | 72–76 | Recentes |
| 31–36 | SpO₂ | 77–81 | Contatos |
| 37–42 | Estresse | 82–86 | Mostrador / telefone |
| 43–48 | Sono | 87–91 | Sport (`sportId` ≠ 0) |
| 92 | Música | 93/94/96 | Registro de atividade / PAI / Ciclo |

---

## Apêndice A. Catálogo de mostradores de estoque (id → nome)

A §11 refere-se a mostradores por id numérico em todo o texto (275 SlopeTime, 322 Glare 2, 357 Silhouette, …). Os ids
`273`–`376` são as faces da loja/estoque; o id é o que `DIAL_COMMAND (9055/a055)` relata como ativo e
o que `9075` aceita como `old_id` (§11.4). 100 dos 103 ids conhecidos estão nomeados abaixo; os restantes são
stubs de ROM (§11.4). Nomes e agrupamento conforme mostrado pelo app complementar oficial.

- **Padrão** (6) — `273` Activity Mood · `274` Sun Circle · `275` SlopeTime · `276` Dichotomy · `277` Prismatic Time · `280` Multifunction
- **Analógico** (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
- **Digital** (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
- **Multifunção** (10) — `304` Elaborate 2 · `344` InfoMeter · `348` Tumbler · `354` Dual · `363` Vintage · `366` Combo · `367` Complex Figure · `368` Function · `374` Cirquary · `375` InfoHub
- **Criativo** (8) — `284` Square · `312` Disc · `317` Disc 2 · `318` Dominos · `332` Flux · `342` Perfect Match · `343` Progress Day · `358` Asteroid
- **Diwali** (1) — `295` Vortex

---

## Fontes

Os layouts de bytes acima foram reconstruídos a partir do firmware (1.0.0.73), do APK oficial (3.5.7) e de
capturas ao vivo descriptografadas contra um dispositivo real. A implementação de referência deste projeto vive em
`core-rust/src/{commands,frame,crypto,health,session}.rs` (Rust), no editor TypeScript `wfweb/` e nas
ferramentas Python `cmftool/` (`pair.py`, `session.py`, `wf_codec.py`, `upload_custom.py`, …).

**Trabalho independente incorporado aqui.** [freethinkel/fmc](https://github.com/freethinkel/fmc) — um
editor de mostradores SvelteKit + marketplace para o mesmo relógio — fez engenharia reversa de forma independente do
formato `.bin` a partir de um corpus de ~100 faces e alcançou vários resultados que este documento não tinha ou tinha
errado. Os `docs/cmf-protocol.md`, `src/lib/modules/editor/lib/{wf,render}.ts` e
`src/lib/modules/device/lib/ble.ts` deles valem a leitura direta. Descobertas adotadas, cada uma re-verificada
contra bytes de mostradores antes de ser escrita aqui:

| Descoberta | Onde | Status aqui |
|---|---|---|
| Ambas as palavras do cabeçalho são CRC32 (variante não padrão) | §11.4 | ✅ re-verificado 9/9 mostradores; **corrige** "sem checksum de bloqueio" |
| Condições de visibilidade, tag `0x02` | §11.12 | ✅ re-verificado; era não documentado |
| Lista de métricas de slot + `activeIdx` padrão, `0x79 + slotIndex` | §11.13 | ✅ re-verificado em 275/368/273/304; **substitui** §11.8 |
| Flag de capacidade de tint de destaque `meta[7] == 4` | §11.14 | ✅ prevalência re-verificada; era não documentado |
| Especificação completa de arco (ângulos de varredura, largura, raio) | §11.15 | ✅ re-verificado; **corrige** "`max` u16 `@+4`" |
| `u16`s da cauda de ref são tamanhos de bloco, não ids de glifo | §11.11 | ✅ re-verificado exato em um atlas de 10 glifos |
| Rodapé de 36 bytes; variante de byte mágico `0x02`; `0x86` = 64 B; pré-visualização embutida `0x28` | §11.4, §11.11 | ✅ re-verificado |
| Rótulos de ids de saúde/clima calibrados contra o menu de slots do app complementar | §16 | ⚠️ adotado como melhor leitura; conflitos sinalizados |
| UUID do serviço shell é `77d4e67c-…`, e escopo de `optionalServices` do Web Bluetooth | §1 | ⚠️ relato de unidade única, não re-verificado aqui |
| Número compartilhando id com anel renderiza uma porcentagem | §11.15 | 🟡 relatado, **não** verificado aqui |
| Catálogo de id → nome de mostradores de estoque | Apêndice A | ✅ adotado como está |
Baixar ferramenta
FinalidadeServiçoCaracterísticaPropriedades
Escrita de comando0000fff0-0000-1000-8000-00805f9b34fb0000fff2-…Write
Notificação de comando0000fff0-…0000fff1-…Notify
Escrita de shell (AT)—77d4ff01-2fe2-2334-0d35-9ccd078f529cWrite
Notificação de shell (AT)—77d4ff02-…Notify
Escrita de dados em massa—02f00000-0000-0000-0000-00000000ffe1Write
Notificação de dados em massa—02f00000-…ffe2Notify
Nomecmd1,cmd2
TIMEFFFF 8004
FIRMWARE_VERSION_GET / _RETFFFF 8006 / FFFF 0006
SERIAL_NUMBER_GET / _RET00DE 0002 / 00DE 0001
BATTERY005C 0001
TRIGGER_SYNC005C 0002
USER_INFO_SET / _RET 🔎✅0095 0001 / 0095 0003
FACTORY_RESET009A 0001
DEVICE_REBOOT 🔎FFFF 9080
RESOLUTION_GET 🔎 (→ 466×360)FFFF 907F
GPS_PUSH / _RETFFFF 906A / FFFF A06A
UNBIND_SET / _RETFFFF 907A / FFFF A07A
Nomecmd1,cmd2
AUTH_PHONE_NAMEFFFF 8049
AUTH_WATCH_MACFFFF 0049
AUTH_PAIR_REQUEST / _REPLYFFFF 8047 / FFFF 0048
AUTH_NONCE_REQUEST / _REPLYFFFF 804B / FFFF 004C
AUTHENTICATED_CONFIRM_REQUEST / _REPLYFFFF 804D / FFFF 0004
AUTH_FAILEDFFFF A061
Nomecmd1,cmd2
APP_NOTIFICATION0065 0001
INCOMING_CALL ⚠️0064 0001
CALL_REMINDER_REQUEST / _RESPONSEFFFF 9066 / FFFF A066
FIND_PHONE005B 0001
FIND_WATCH005D 0001
FIND_WATCH_TOGGLEFFFF 9069
SMS_MESSAGE_PUSH / _RETFFFF 906E / FFFF A06E
QUICK_REPLY_SET / _RETFFFF 9073 / FFFF A073
Nomecmd1,cmd2
ALARMS_SET / _GET0063 0001 / 0063 0002
CONTACTS_SET / _GET00D5 0001 / 00D5 0002
STANDING_REMINDER_SET / _GET0060 0001 / 0060 0002
WATER_REMINDER_SET / _GET0061 0001 / 0061 0002
TASK_REMINDER_SET / _RET ⚠️FFFF 9072 / FFFF A072
Nomecmd1,cmd2
GOALS_SET / _ACK005E 0001 / 005E 0003
UNIT_LENGTH / _ACKFFFF 9067 / FFFF A067
UNIT_TEMPERATURE / _ACKFFFF 9068 / FFFF A068
TIME_FORMAT / _ACK005F 0001 / 005F 0003
WAKE_ON_WRIST_RAISE / _GET / _ACK0062 0001 / 0062 0002 / 0062 0003
LANGUAGE_SET / _RETFFFF 9058 / FFFF A06B
HEART_MONITORING_ENABLED_SET / _GET009B 0001 / 009B 0002
HEART_MONITORING_ALERTSFFFF 9059
DO_NOT_DISTURB / _GET0099 0001 / 0099 0002
SPORTS_SET / _GET00DC 0001 / 00DC 0002
SPORT_LINKAGE_SET / _RETFFFF 9076 / FFFF A076
SPORT_DATA_SYNC 🔎 (FC/cal/passos ao vivo)FFFF 9078 / FFFF A078
FEMALE_CYCLE_SET / _RETFFFF 9071 / FFFF A071
SLEEP_CONFIG_SET / _RET (min. alvo)FFFF 9074 / FFFF A074
WORLD_CLOCK_GETFFFF 906F
WORLD_CLOCK_DST_SET / _RETFFFF 9083 / FFFF A083
VITALITY_GET / _RETFFFF 9079 / FFFF A079
VITALITY_SW_SET / _RETFFFF 9070 / FFFF A070
Nomecmd1,cmd2
DIAL_COMMAND_SET / _RET (listar/reordenar/selecionar)FFFF 9055 / FFFF A055
DIAL_CONFIG_SET / _RETFFFF 9075 / FFFF A075
CHANGE_DIAL (⚠️ inerte em 1.0.0.73 — não usar)009F 0001
QUICK_CARD_SET/GET / _RET (ambos em 906D)FFFF 906D / FFFF A06D
Nomecmd1,cmd2
ACTIVITY_FETCH_1 / _2FFFF 8005 / FFFF 9057
ACTIVITY_FETCH_ACK_1 / _2FFFF 0005 / FFFF A057
ACTIVITY_DATA0056 0001
SLEEP_DATA / _GET0058 0001 / 0058 0002
SPO20055 0001
STRESS009D 0001
HEART_RATE_MANUAL_AUTO0053 0001
HEART_RATE_RESTING00DA 0001
HEART_RATE_WORKOUT00E0 0001
SKIN_TEMP_HISTORY 🔎 (vazio neste SKU)0155 0001 / 0155 0002
WORKOUT_SUMMARY / _V30057 0001 / 0160 0001
WORKOUT_GPSFFFF A05A
DomínioINIT1 req/replyINIT2 req/replyCHUNK req/writeFINISH ack1/ack2
Mostrador (foto)8052/00529063/A063A064/9064A065/9065
Mostrador (estruturado/alternância)8052/00529075/A075A064/9064A065/9065
Firmware9052/A0529040/A040A042/9042A041/9041
AGPS/EPO905E/A05E—A05F/905FA060/9060
OffsetTamanhoCampo
04timestamp (epoch s)
44passos
84distância (m)
124calorias
1616reservado (observado 0)
OffsetTamanhoCampo
04início_da_sessão (epoch, UTC)
44despertar (epoch, UTC)
82total_profundo_s
102total_leve_s
122total_rem_s
142total_acordado_s
162⚠️ [incerto] (id/pontuação da sessão? valores observados não correspondem às somas dos registros)
ac 49 1f 01
  • CONTACTS_SET (00D5 0001) ✅: N × 57 bytes = nome(32) ‖ telefone(25). A UI do relógio mostra até 20.
  • ALARMS_SET (0063 0001) ✅ — corrige o Gadgetbridge (que colocava o rótulo no final, preenchido com 0xff — errado). 40 bytes por alarme, big-endian: segundosDoDia(i32) ‖ índice(u8) ‖ habilitado(u8) ‖ máscara-de-bits-de-repetição(u8) ‖ flag(u8) ‖ rótulo[32] UTF-8. O rótulo está no offset 8 e aparece no relógio. repetição = máscara de bits dos dias da semana (0 = uma vez); flag é ⚠️ [incerto] (marcador de uma vez?). Exemplo (13:30, idx 2): 0000bdd8 02 01 15 00 "Alarme…".
  • GOALS_SET (005E 0001) ✅ — o app oficial e a implementação de referência usam o DailyTargetBean v1 de 10 bytes, big-endian: passos(u32 BE) ‖ distância_m(u32 BE) ‖ calorias_kcal(u16 BE). (Esta é a forma do Gadgetbridge; relatos anteriores de que o relógio "ignorava" eram um bug de descriptografia de sessão obsoleta, não um problema de payload.) 🔎 A engenharia reversa do firmware também mostra uma variante estendida mais longa de 29 bytes (adiciona sleep_min/exercise_min/stand_h + 6 flags de habilitação, todos u32 BE após um prefixo flag(u16 LE), com faixas impostas: passos 2000–30000, dist 1000–99000, cal 100–5000, sono 360–720, exercício 30–90, em pé 6–16) — não é o caminho padrão do app; prefira a forma de 10 bytes a menos que precise dos alvos extras.
  • STANDING_REMINDER / WATER_REMINDER (0060/0061 0001) ✅: 11 bytes: habilitado(1) ‖ limite_min(u16 LE) ‖ dndInício(u32 LE) ‖ dndFim(u32 LE). Observe que a "janela ativa 08:00–22:00" mostrada na UI é um padrão fixo do firmware e não é transportada no payload.
  • SPORTS_SET (00DC 0001) ✅: contagem(1) = 36 slots ‖ activityTypeCode[36] (códigos ativos depois 00 de preenchimento). Seleciona quais esportes aparecem no menu de treino do relógio.
  • HEART_MONITORING_ENABLED (009B 0001) ✅: byte tipo — 01 = FC 24/7, 02 = SpO₂, 04 = estresse (medido a cada 30 min).
  • HEART_MONITORING_ALERTS (FFFF 9059) ✅: desabilitado = 00; habilitado = 01 ‖ fcBaixa ‖ fcAlta ‖ fcAltaEsporte ‖ spo2Baixa ‖ 00 00 00 00 (um limite 0/255 = "sem limite").
  • FEMALE_CYCLE (FFFF 9071) ✅: 01 ‖ predictionOpen ‖ notifySwitch ‖ cycleStartSwitch ‖ cycleStartNotifyBefore ‖ ovulationStartSwitch ‖ ovulationStartNotifyBefore ‖ fertileStartSwitch ‖ fertileStartNotifyBefore ‖ período(1) ‖ cicloPeríodo(1) ‖ dataInícioCiclo(u32) ‖ marcarInício(u32) ‖ marcarFim(u32) (capturado: período=5, cicloPeríodo=0x1c=28).
  • QUICK_REPLY (FFFF 9073) ✅: TLV — contagem(1) ‖ total(1) ‖ [id(1) ‖ len(u16 LE) ‖ msg-UTF8]… (7 respostas padrão capturadas e descriptografadas).
  • WORLD_CLOCK (FFFF 906F) ✅: envia IDs de cidade numéricos, não nomes (01 ‖ contagem ‖ cityId(2 BE)…); o relógio mapeia ids de uma tabela interna. Config de DST FFFF 9083 = contagem ‖ [id(u16 LE) ‖ dst(u16 LE) ‖ início(u32 LE) ‖ fim(u32 LE)]….
  • MUSIC_INFO_SET (FFFF 905C, 131 B) ✅: estado(1: 0=nenhum/1=pausado/2=tocando) ‖ volume(1) ‖ volumeMáx(1) ‖ faixa(64) ‖ artista(64). O relógio também envia MUSIC_BUTTON (A05D) de volta.
  • WEATHER_SET_1 (FFFF 906B, 199 B) ✅ — use este: 7×9 bytes de dias + 24×2 bytes de horas + cidade(32) + 7×8 bytes de nascer/pôr do sol (LE). Temperaturas codificadas como (temp_c + 100) & 0xFF. ⚠️ O mesmo payload enviado em WEATHER_SET_2 (0066 0001) não atualiza o widget de clima no Pro 2 — sempre use 906B. (A string da cidade também é um vetor comprovado de sequestro de dados — ver §13.)
  • FIND_WATCH (005D 0001) ✅: payload 0x01 → o relógio toca/vibra (+ ACK 005D 0003).
  • GPS_PUSH (FFFF 906A) ✅ — big-endian, longitude primeiro: 16 bytes ts(u32 BE) ‖ lon×1e7(i32 BE) ‖ lat×1e7(i32 BE) ‖ 00 00. Validado para uma localização real.
  • WORKOUT_GPS (FFFF A05A) ✅ — little-endian, longitude primeiro: 12 bytes ts(i32) ‖ lon×1e7(i32) ‖ lat×1e7(i32).
  • TIME (FFFF 8004): ver §7.
  • cfbppraster (após LZ4)uso
    42RGB565-LEfundo opaco (FULL/THUMB)
    53RGB565-LE (2 B) + alpha (1 B) por pxsprites com anti-aliasing (glifos, ponteiros, ícones)
    13 (0x0d)0.5máscara alpha de 4 bits; o firmware tinge em tempo de execuçãoatlas de glifos de dígitos
    24 (0x18)4RGBA8888camadas de cor total (incl. o sempre ativo aodImage)
    1—JPEG/JFIF (ff d8 ff), extraia com qualquer decodificadorquadros de animação raros
    0a
    0x61
    NotEnvelope
    0a
    ChildOverflow
    :
    61 0a 00
    −18/−16
    offsets de escrita devem se mover também
    0a
  • Slots de complicação multi-variante — a métrica ativa NÃO está no .bin ⚠️ este item estava errado e é substituído pela §11.13. Uma complicação configurável é criada como N nós de grupo 0x68 empilhados no mesmo (x,y), cada um desenhado apenas quando uma condição de visibilidade corresponde — isso estava certo. Mas a lista de métricas por slot (o 0x1c/0x6a/0x48/0x24/0x19/0x76 do 275) é exatamente a lista de métricas, não "ids de opção/estilo"; o índice ativo padrão é um byte no arquivo; e o byte 0x79/0x7a não é um "byte de instância" mas o id no qual os alternados são chaveados (0x79 + slotIndex). Uma pré-visualização estática pode reproduzir o padrão do arquivo. Veja §11.13.
  • Complicações inativas ancoradas na borda (ex.: texto de bpm em (446,0), visto em 275/302/325/365/375) são slots que o firmware não desenha na visualização padrão — seu valor nem cabe antes da borda do canvas. Trate como ocultos na pré-visualização.
  • 0x22
    ponteiros
    0x22
    aod
    0x22
    @69,209
    UI de edição isolada Normal|AOD
  • 0x60 img_number independente (cnt=10) — fonte em −5, deslocamento direto em um. ✅ Mesmo deslocamento em um da §11.8 mas para números não-relógio: a data do "Gradient" estava em (203,80) centro-superior com fonte 0x17, mas a varredura direta 82 capturou o getter de ângulo do ponteiro vizinho (0x0a) e a posição do ponteiro → o número renderizava no local do ponteiro com uma fonte falsa. Correção: para um 61 0a 00 img_number em um wrapper 0x60, confie em −5/−18/−16 quando a fonte direta é impossível para um número (fonte-0 ou um getter de ângulo de ponteiro 0x0a/0e/12/70/71/72) e a posição −18/−16 é válida e não-zero (o guarda não-zero pula dígitos filhos de grupo com relX=0).
  • 0x17 = data (dia do mês), 0x24 = temperatura — distintos. O mostrador 340 usa ambos (0x17 "Jun 09" e um 0x24 temp separado), então 0x17 é data, não temperatura. Um mostrador cujo relógio mostra uma temperatura em um slot 0x17 é uma complicação configurada pelo usuário (estado do dispositivo), não o padrão do arquivo.
  • tagfunçãocontêiner?corpo
    0x20raiz da cena (wrapper de corpo, não um drawable)✅filhos
    0x21tela normal✅filhos
    0x22tela AOD (§11.9)✅filhos
    0x28miniatura de pré-visualização de catálogo embutida✅um filho 0x08
    0x68grupo / contêiner de auto-layout✅quadro 0x48 + filhos
    0x30imagem estática, ou escolha-por-valor de N imagens✅0x01 (+0x02)
    0x60leitura numérica ao vivo (tira de dígitos)✅0x01 + 0x40 (+0x02)
    0x70ponteiro rotativo✅0x01 + pivô 0x05
    0x80anel de progresso, procedural✅0x01 + 0x5a (§11.15)
    0x81anel de progresso, recortado por imagem✅0x01 + 0x5b (§11.15)
    0x85slot de complicação atribuível pelo usuário✅0x01 + 0x5f (§11.13)
    0x01struct — geometria + atributos (abaixo)—x,y,meta[14] + cauda de ref
    0x02condição de visibilidade (§11.12)—lista de condições
    0x05pivô — flag u8, pivotX u16, pivotY u16—5 B
    0x08pvStruct — prefix[5] + cauda de ref, sem x/y (apenas pré-visualização)——
    0x40contagem de dígitos / flag de zero à esquerda (§11.10)—1 B
    0x48quadro — x,y,w,h,gap,align linha/coluna de auto-layout——
    0x5a / 0x5bespecificação de arco para 0x80 / 0x81 (§11.15)—19 B / 17 B
    0x5flista de métricas de slot para 0x85 (§11.13)——
    0x86nó de nome de exibição, sempre exatamente 64 bytes, terminado em NUL, não desenhado—64 B
    mostradorslotcontagemactiveIdxids de métrica→ ativo
    275 SlopeTime0601c 6a 48 24 19 760x1c calorias
    275 SlopeTime1641c 6a 48 24 19 760x19 passos
    368 Function0805f 1c 19 48 24 76 1a 8b0x5f temperatura
    368 Function1865f 1c 19 48 24 76 1a 8b0x1a frequência cardíaca
    273 Activity Mood0401c 24 48 6a0x1c calorias
    304 Elaborate 20/1401c 48 6a 24 / 24 1c 6a 480x1c / 0x24