
CMF Watch Pro 2向けのリバースエンジニアリングされたBLEプロトコル。GATTレイアウト、AES-128-CBC暗号化コマンドフレーム、認証ハンドシェイク、および代替コンパニオンアプリ開発のためのヘルスデータ同期を文書化しています。
非公式。 このドキュメントは、CMF Watch Pro 2(CMF by Nothing)のBluetooth Low Energy(BLE)プロトコルを、代替コンパニオンアプリのためにリバースエンジニアリングによって再構築したものです。Nothing/CMFとは提携しておらず、その承認も受けていません。自己責任で使用してください。
フレームヘッダーとオペコード内のすべてのマルチバイト整数はビッグエンディアンです。コマンドペイロード内の整数は、特に明記されていない限りリトルエンディアンです(これはデバイスファームウェアを反映しています)— 例外(GOALS_SET、GPS_PUSH、バルク転送のオフセット/長さはビッグエンディアン)に注意してください。
以下に示す明白でない主張にはすべて、その確立方法を示すタグが付いています:
後のセクションで以前のセクションを修正する場合、以前のテキストは削除せず、ポインタ付きで保持されます — どの読み取りが試され、反駁されたかを知ることで、次の人が同じ回り道をしなくて済みます。
すべてのキャプチャのテストデバイス:CMF Watch Pro 2-5485、ファームウェア1.0.0.73、シリアルCI04102520008192、MCU Actions ATS3089C(Cortex-M4)、画面466×360。
電話がGATTクライアントで、ウォッチがペリフェラルであり、CMF Watch Pro 2-XXXX(4桁の16進数)としてアドバタイズします。
各CCCD(00002902-…)に01 00を書き込むことで通知を有効にします。コマンドチャネル(fff1/fff2)は以下のフレーム化されたプロトコルを運びます。シェルチャネル(77d4…)はプレーンなATスタイルのテキスト(例:AT GETSECRET、§14参照)を運びます。データチャネル(02f0…)は大きなバイナリブロブ(ウォッチフェイス、ファームウェア、AGPS)を運び、コマンドチャネル上の制御オペコードによって調整されます。
サービスUUID — ウォッチは約10個のプライマリサービスをアドバタイズします。実機で列挙されたもの:
0xfff0(コマンド)、0x180f(バッテリー)、0x180a(デバイス情報)、0xefe7、0xffd0、
02f00000-…ffe0および02f00000-…fe00(データ)、77d4e67c-2fe2-2334-0d35-9ccd078f529c(シェル/ペアリング)、e49a3001-f69a-11e8-8eb2-f2801f1b9fd1、f48a23c0-f69a-11e8-8eb2-f2801f1b9fd1。
⚠️ シェルサービスの UUIDは**
77d4e67c-…**であり、77d4ff00-…ではありません。このドキュメントの以前の改訂版では、サービスがそのキャラクタリスティック(77d4ff01/77d4ff02、§14)のff00プレフィックスを共有していると想定していました — 実際にはそうではありません。少なくともこの確認が行われたユニットでは(freethinkel/fmcからの発見、§Sources参照)。キャラクタリスティックUUIDは変更されていません。77d4e67cがユニット間で安定しているかどうかは未検証です — ハードコードするのではなく列挙してください。
🌐 Web Bluetoothに関する注意。 Chromiumは、ページが
optionalServicesにリストしたサービスしか発見しません。フィルタリングされていないgetPrimaryServices()呼び出しでも同様です — 3つのサービスをリストしたページは3つしか見えませんが、chrome://bluetooth-internals(Chrome自身のC++レイヤー、スコープなし)は10個すべてを表示します。ブラウザクライアントを書く場合は、上記のすべてのUUIDを事前にリストしてください。そうしないと、明らかに存在するサービスでペアリングが失敗します。Firefox/SafariにはWeb Bluetoothはありません。ユーザージェスチャーとHTTPS/localhostが必要です。
✅ 単一のコマンドチャネル上で実際のセッション全体が実行されました — 160秒間の高負荷キャプチャ中、明示的なOTA/ウォッチフェイス転送中を除いて、データ/ファームウェアまたはシェルチャネルにはトラフィックがありませんでした。
0xF5)すべてのコマンドチャネルメッセージは、1つ以上の11バイトヘッダーフレームにラップされます:``` +------+-----------+--------+-------------+-------------+--------+-------------------+ | 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` を合わせて **オペコード** とします(§6 参照)。🔎 公式アプリのフレームビルダー(`C6117b.m30831g`)に対して確認済みです。
- `chunkCount` = このコマンドの総チャンク数。`chunkIndex` は **1 始まり** です。
- `chunkLen` = このフレーム内の `chunk` のバイト数。
- 1 回の BLE 書き込みはリンク MTU によって断片化される場合があります。受信側は生バイトをバッファリングし、完全なフレームを再抽出します。大きなペイロードは複数のチャンク(同じ `cmd1/cmd2`、増加する `chunkIndex`)に分割され、順番に再組み立てされます。
### オペコードの規約(✅ ワイヤ上で確認済み)
- `cmd1 = 0xFFFF`:`cmd2` が `0x80xx`/`0x90xx` = スマホ→ウォッチ(要求/設定)。`0x00xx`/`0xa0xx` = ウォッチ→スマホ(応答)。ペアは下位バイトで対応します(`0x9055`↔`0xa055`、`0x8051`↔`0x0051`)。
- 機能固有の `cmd1`:`cmd2` のサフィックスは `0x0001` = **SET**、`0x0002` = **GET**、`0x0003` = **ACK**。
### チャンク本体
各チャンクについて、本体は `payloadPiece ‖ CRC32_LE(payloadPiece)`(4 バイト CRC、リトルエンディアン、zlib/IEEE)です。コマンドが**暗号化**されている場合(§3 参照)、`payloadPiece ‖ CRC` 全体が AES-128-CBC/PKCS7 で暗号化され、その暗号文がフレームの `chunk` になります。
**平文の癖:** 平文オペコードの場合、ウォッチは `chunkLen` に 4 バイトの CRC を*数えます*が、それを**送信しません**。したがって、平文フレームをデコードするとき、実際のデータ長は `chunkLen − 4` です。(暗号化フレームは通常どおり CRC を暗号文内に含みます。)
チャンクサイズ(暗号化チャンクが AES ブロック境界に揃うように)、`maxWrite = mtu − 3` とします:
- 暗号化:`floor((maxWrite − 11) / 16) * 16 − 4 − 1`
- 平文:`maxWrite − 11 − 4 − 1`
✅ 観測されたすべての暗号化フレームの `chunkLen` 値は 16 の倍数でした(ブロック整合が成立)。
---
## 3. 暗号プリミティブ
- **AES-128-CBC**、**PKCS7** パディング、**固定 IV**(ファームウェア `CmfCharacteristic.AES_IV` より):
`50 51 52 53 54 55 56 57 60 61 62 63 64 65 66 5A`。
- **CRC32**(zlib/IEEE)、4 バイトのリトルエンディアンとして出力。
- パーツの連結に対する **SHA-256**。
鍵導出:```
authkey = SHA256( rnd1 ‖ rnd2 ‖ secret )[0..16] // persisted across sessions
sessionKey = SHA256( nonce ‖ authkey )[0..16] // per connection
secret = 16バイトのデバイスシークレット(シェルコマンド
AT GETSECRET → GETSECRET:<32-hex>,OK でウォッチから取得可能)。rnd1 = 電話側が選択する16ランダムバイト、rnd2 = ウォッチからの16ランダムバイト。nonce = ウォッチのnonce応答からのバイト。キーが設定された後、§5に記載された平文オペコードを除き、すべてのコマンドチャネルフレームはAES暗号化されます。
✅ 両方の導出が検証済み:ルート化された電話のntwatch.dbから復元されたauthkeyが、
キャプチャしたrnd1/rnd2/secretから導出された値と一致しました。キャプチャしたnonceから再現されたsessionKeyは、ライブフレームを復号します。
2つのエントリパスが同じ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
`AUTH_FAILED (0xFFFF,0xA061)` または署名の不一致が発生した場合、認証は失敗します。
### 4.2 再接続(authkey が既知の場合)```
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
✅ 再接続順序(シェルトラフィックなし)は、実際のキャプチャでそのまま観測されました。
⚠️→✅ データクエリの前に
TIMEが必須です。Initialized後、ウォッチはセッション内でTIME (FFFF 8004)が送信されるまでBATTERY、SERIAL_NUMBER_GET、ACTIVITY_FETCH_*ハンドシェイクに 応答しません — それがない場合、未承諾のFIRMWARE_VERSION_RETだけが届き、他はすべてタイムアウトします。✅ ライブで確認済み(Pixel 8a):TIMEなしで3つのGETを送信 → ファームウェアのみ応答;TIMEを先に送信 → バッテリー と シリアル番号が応答し始めます。
推奨されるフェーズ2の順序:TIME → FIRMWARE_VERSION_GET → SERIAL_NUMBER_GET →
BATTERY (0xA5) → 設定プッシュ → ヘルス同期(§8)。
ほとんどの設定に独立した「読み取り」オペコードはありません。*_GET(cmd2 = 0x0002、ペイロード
0xA5)を送信すると、ウォッチは現在値を保持した SET オペコード(cmd2 = 0x0001)で応答します。
SET コマンドは cmd2 = 0x0003 と空のボディで確認応答されます。
フレームはキーが設定されると AES 暗号化されますが、以下を除く これらのオペコードは常に平文です:
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)フレーム ヘッダー(cmd1/cmd2)は常に平文で送信されるため、コマンド シーケンス はキーがなくても
どのキャプチャでも確認できます — 暗号化されるのは ペイロード のみで、sessionKey が必要です。
(cmd1, cmd2)GET/SET/REQUEST = 電話→ウォッチ;RET/REPLY/ACK/RESPONSE/DATA = ウォッチ→電話。
| 名前 | cmd1,cmd2 |
|---|---|
| MUSIC_INFO_SET / _ACK | FFFF 905C / FFFF A05C |
| MUSIC_BUTTON | FFFF A05D |
| 名前 | cmd1,cmd2 |
|---|---|
| WEATHER_SET_1(動作する方) | FFFF 906B |
| WEATHER_SET_2(Pro 2 では無視 — §9 参照) | 0066 0001 |
JS専用オペコード(
FFFF 8051、FFFF 0051、FFFF 90A2、FFFF 90C5、FFFF A056、FFFF 908A/908BChatGPT ステータス/サポート)は Java レイヤーではなく、アプリの Hermes バイトコードで 処理されます。ヘッダーはキャプチャに現れますが、ペイロードの意味は ⚠️ [不確実] です。
文字盤 / ファームウェア / AGPS は、init → チャンク要求/チャンク書き込みループ → 完了ACK を使用します:
(すべて cmd1 = FFFF。)ウォッチが DATA_CHUNK_REQUEST_*(offset, length) を発行してループを駆動します
(offset/length = u32 ビッグエンディアン);電話はデータ特性上で payload[offset..offset+length] を保持する
DATA_CHUNK_WRITE_* で応答します。詳細は §11–§12 を参照してください。
TIME (FFFF 8004) ペイロード = epochSeconds(i32, BE) ‖ utcOffsetMillis(i32, BE)。認証直後に送信され、
ウォッチがローカル時刻を表示するようにします(データクエリのブロック解除も行います — §4.3 参照)。
⚠️ ウォッチからのヘルスデータのタイムスタンプは UTC です。 コンパニオンアプリはローカルのカレンダー日 / 時刻を導出する前に、ローカルの UTC オフセットを加算する必要があります。(生の UTC 日でヘルスをバケット化すると、 日付が誤ったローカル時刻で切り替わります。)
TIME_FORMAT (005F 0001) ペイロード = 1バイト:00 = 24時間、01 = 12時間。
ACTIVITY_FETCH_1 を送信;ウォッチが ACTIVITY_FETCH_ACK_1 で応答(最初のバイト 01 ⇒ 準備完了)。ACTIVITY_FETCH_2 を送信;ウォッチはデータフレームのバーストをプッシュします:
ACTIVITY_DATA、HEART_RATE_*、SPO2、STRESS、SLEEP_DATA、WORKOUT_SUMMARY[_V3]。同期は順次実行されます(TIME に従う必要があります;ウォッチは ACK_2 後にストリームを解放します)、
単一のバーストではありません。重いセッションでは約160秒で約170〜210の通知フレームがプッシュされます。✅
ACTIVITY_DATA(各32バイト、LE)✅カロリー単位: アクティビティカロリーは cal(グラムカロリー)で報告されます。日次合計を 1000 で割って kcal を取得します。(対照的に、ワークアウトサマリーのカロリーはすでに kcal です。)
timestamp(i32 LE) ‖ value(i32 LE)
(value = bpm / SpO₂ % / ストレス指数)。00DA 0001)は異なります — 5バイト:timestamp(i32 LE) ‖ hr(u8)。
✅ ライブ例 5e dc 29 6a 4e → ts、hr = 78 bpm。ストレススコア範囲:1–29 / 30–59 / 60–79 / 80–99。SLEEP_DATA(18バイトヘッダー + N × 8バイトレコード)✅1つの SLEEP_DATA = 1つの 睡眠セッション;夜間に複数含まれる場合があります(マイクロ覚醒がセッションを分割)。
ヘッダー:
各8バイトレコード:timestamp(u32) ‖ duration_s(u16) ‖ stage(u16)。
ステージコード:1 = 深い睡眠、2 = コア/浅い睡眠、3 = REM、4 = 覚醒。✅ 一晩全体で検証済み
(2セッション、D/C/R/A 合計が整合)。
WORKOUT_SUMMARY v1(54バイト)/ _V3(0160 0001)v1:start(u32)、end(u32)、duration_s(u32)、次にタイプ/カロリー/歩数/距離/平均HRと
GPS/拡張ブロック。✅ v1レイアウトはファームウェアに対して確認済み。WORKOUT_SUMMARY_V3 は同じデータに
約40バイトの拡張ブロック(exerciseLoad、有酸素/無酸素、回復時間、VO₂max、ケイデンス、PAI、ベストランニングタイム…)を
加えた新しいレイアウトです。フィールドセットは既知(アプリの Room DB から)ですが、
その40バイトブロック内の正確なバイトオフセットは ⚠️ [不確実] です — 確定には GPS ワークアウトの
生キャプチャが1つ必要です。
文字列は UTF-8 で、フィールドサイズに バイト切り詰め されます(切り詰めによりマルチバイト文字が分割される場合があります。
ファームウェアの s.encode()[:max] 動作と一致);短いフィールドは右側にゼロパディングされます。
0065 0001)✅:iconCode(1) ‖ 0x00 ‖ when(u32 BE) ‖ titleLen(1) ‖ title ‖ body。
iconCode はアプリアイコンを選択します(WhatsApp=8、Telegram=12、Instagram=18、Gmail=27;不明=0xFF)。
タイトル ≤ 20バイト、本文 ≤ 128バイト。クライアントから送信 → ウォッチが表示 + ACK 0065 0003。005C 0001)✅:応答 = level(1) ‖ charging(1)(例:3b 00 = 59 %、充電中ではない)。00DE 0001)✅:len(1) ‖ ASCII(例:10 + "CI04102520008192")。0095 0001)✅:height_cm(1) ‖ weight_kg(1) ‖ age(1) ‖ gender(1: 1=M)
(例: = 172 cm / 73 kg / 31 / 男性)。now/utc_offset を明示的なパラメータとして受け取ります
(決定論的、テスト可能)。トランスポートが実際の時刻を供給します。TIME がすべてをゲートします(§4.3)— 最初に送信しないと、ウォッチはデータクエリに対して沈黙したままです。GOALS_SET と
GPS_PUSH はビッグエンディアンで、バルク転送のオフセット/長さもビッグエンディアンです。ウォッチは(a)写真/カスタムダイヤル(背景画像 + ファームウェア描画のデジタル時計)と (b)構造化ダイヤル(内蔵/ストア文字盤:背景 + 配置されたスプライトレイヤー、針、 テキストウィジェット)をサポートします。両方とも §6 の init → チャンクループを介してデータチャネル上で転送されます。
実際に動作するもの(✅ ライブ検証済み): 任意の画像から 写真ダイヤルを構築 してインストール; 103個のストアダイヤル をオフラインでインストール;構造化ダイヤルの 再スキン(背景または 非背景スプライトの交換)とレイヤーの 移動;アクティブな文字盤の 並べ替え / 切り替え; そして ゼロから構造化ダイヤルを構築 —
0x20シーンエンベロープがデコードされ、ビルダーが実装されています(§11.7)、 103個すべてのストアダイヤルをバイト単位で往復し、ファームウェア自身のバリデーターを通過する合成コンテナを 生成することが オフラインで 証明されています。🟡 唯一未証明のステップ は、ゼロから構築した合成レンダリングを9075経由で デバイス上で 確認することです(構造的なオフライン証明は、以前0a拒否の原因だったものを すでにカバーしています)。コーデックやトランスポートの障壁はなく、ベンダーツールチェーンも不要です。 以前の 「構造化レンダリングはRESパックに焼き付けられておりBLEでは不可能」および「cf=0x1f サーバーサイドコーデック」という 主張は 誤りでした(オフセット+バイト/ピクセルのバグ)— ファームウェアは構造化ダイヤルを 送信したファイルから データ駆動で レンダリングします。
DIAL_COMMAND (9055 / a055) ✅a055 = result(u8) ‖ selectIndex(u8) ‖ total(u8) ‖ max(u8) ‖ N × dialId(u32 LE) ‖ ffffffff。例:01 05 06 07 … = アクティブ #5、6個のダイヤル、最大7。CHANGE_DIAL (009F 0001) は fw 1.0.0.73 では 無効(定数を返し、切り替えません)— 使用しないでください。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` = 有効化&保存済み。`0a` = 保存されたが**有効化されていない**/拒否された。Androidでは各`DATA_CHUNK_WRITE`は**フレームごとに1回のBLE書き込み**で送信する必要がある。連結してMTUで再分割するとヘッダーがずれ、ウォッチがオフセット0を要求し続けるループになる。
- **`9063`(写真)= APPEND。** ダイヤルリストが増える(6→7)。`watchfaceId = 0xFFFFFFFF`(カスタム
センチネル)なので重複として拒否されることはなく、ウォッチが自動的に有効化する。
- **`9075`(構造化)= REPLACE** で`old_id`スロットを置き換える。`old_id`は**すでに**リスト内に存在している**必要がある**
(そうでなければ`0a`)。すでに存在するIDを再インストールするには、**まず削除して**(9055のリストマイナスID)
から「新規」としてアップロードする。既存のIDをそのまま再利用すると`0a`になる。
### 11.3 写真/カスタムダイヤル — ✅ エンドツーエンドで完全検証済み
**コンテナ**(バイト検証済みのラウンドトリップ。全フィールドはリトルエンディアン):```
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 = 標準のLZ4ブロック(RGB565リトルエンディアン、トップダウン)(payloadLenは最初のLZ4バイトから数えます)。公式アプリはLZ4-HCを使用し、21バイトのLZ4ブロックヘッダー/フッターを除去します。プレーンなリテラルのみのLZ4エンコーダでも動作します。ウォッチは有効なLZ4であれば何でも受け付けるため、バイト単位の同一性は必須ではありません。内接円(中心233,233、半径233)の外側のピクセルは0x0000に設定されます。
9063用のINIT_2 — 正確なヘッダー(✅ これが動作するものです):```
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` = 正確な `.bin` 長さ。`FFFFFFFF` = カスタム `watchfaceId`。`styleId` 0〜4 で内蔵の
デジタル時計レイアウトを選択します(常に描画され、「オフ」はありません)。`posX/posY` で配置します(実績のある
56 / 77)。`color565` で色を付けます(例: `FFFF` = 白)。⚠️ 短い `A5 ‖ size ‖ watchfaceId` 形式は
finish `0a` で**拒否**されます — 上記の完全なヘッダーを使用してください。(参考実装:
`core-rust/engine.rs::build_wf_init2`。公式アプリの `C6135t.m31104u` を反映しています。)
**手順:** 画像を466×466(および270×270のサムネイル)にリサイズし、RGB565-LE の上から下への形式に変換し、
必要に応じて円外のピクセルをゼロにし、それぞれを LZ4 圧縮し、上記のコンテナを組み立て、
`watchfaceId = 0xFFFFFFFF` で `9063` パイプライン経由でアップロードします。(参考コーデック: `core-rust/watchface.rs`、
`work/codec_dfa.py`。)
### 11.4 構造化 / ストアダイヤル — コンテナとコーデック ✅
**ファイルレイアウト** — 36バイトのヘッダーは、EOF に**バイト単位で同一の36バイトのフッター**として繰り返されます
(✅ 15個のダイヤルで検証済み。異なる場合はパーサーがファイルを拒否する必要があります):```
[36-byte header][scene TLV (§11.7)][asset pool][36-byte header again]
ヘッダー(全103個のストアダイヤルで同一構造。全フィールドはリトルエンディアン):``` 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]
> ⚠️ **訂正(「ブロッキングチェックサムは存在しない」という記述を置き換える)。** 以前のリビジョンでは、`@0x00` を
> ダイヤルごとのID/ハッシュとして読み取り、`@0x20` を「3× u32 ID/ハッシュワード \[CRCではない]」と解釈し、
> CRC32/Adler32/バイト合計がすべて一致しないと述べていました。両方のワードは**CRC32**です — 以前のテストで見逃されたのは、
> そのバリアントが非標準であることと、「`0x20` の3ワード」という解釈が、単一のCRCワードを
> `0x24` から始まるシーンコンテナの最初のバイトと混同していたためです(同様に「`0x2c` で名前が繰り返される」のは、
> シーンの `0x86` 名前ノード、§11.11 です)。発見元:
> [freethinkel/fmc](https://github.com/freethinkel/fmc); ここで再検証済み。
**CRC32-raw** = 反射型IEEE多項式 `0xEDB88320`、**`init = 0`**、かつ**最終XORなし** — つまり、
標準の `crc32` の `init=0xFFFFFFFF` でも `^0xFFFFFFFF` でもありません。これが、既製のCRC32が
決して一致しなかった理由のすべてです。順序依存性に注意してください: `crc_assets` は `crc_tree` が
カバーする範囲の内側にあるため、**最初に `@0x20` を書き込み、その後で `@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 の純正ストアダイヤルが両方の単語で一致し、このリポジトリ自身のテンプレート6/6もcrc_treeで一致します。
🟡 ファームウェアはどちらのCRCも強制していないようです。 このリポジトリが
9075上にインストールしたすべてのダイヤル — 同じフットプリントのインプレース編集パス(§11.6)で生成されたリキン(アセットペイロードとX/Yバイトをヘッダーを再計算せずに変更する)を含む — はデバイス上で正常にレンダリングされました。つまり、古いCRCが0a拒否の原因ではありません(それはコンテナウィンドウの不変条件、§11.7です)。CRCはとにかく正しく書き込むものとして扱ってください:コストは安く、フォーマット内で唯一知られている整合性フィールドです。シーンまたはアセットプールを書き換えるものは、両方の単語を再計算すべきです。
スタブダイヤル(約173 B、例:ID 273/274/277)はROMに焼き込まれたフェイス用のプレースホルダーです:ヘッダー+ディレクトリのみで、実際のアセットはありません。
アセット — 各アセットはdimsWord(u32 LE) ‖ len(u32 LE) ‖ LZ4(payload)で、ここでcf = dimsWord & 0x1f、w = (dimsWord >> 10) & 0x7FF、h = (dimsWord >> 21) & 0x7FF、lenは最初のLZ4バイトから数えます(そこによく見られる1f 00 01 00は最初のLZ4トークンです — スキップしないでください)。展開後のサイズ = w·h·bpp:
✅ 103個のダイヤルにわたる全4151/4151アセットが、標準のlz4.blockデコンプレッサーでw·h·bppで正確にデコードされます。 透明度はアルファバイト(cf=5/24)または0x0000(円の外側のcf=4)です — RLEも「エスケープ」もありません。エンコード=再ラスター化 → 標準LZ4 → [dimsWord][len][LZ4]。
9075のINIT_2 — AES暗号化ボディ:```
kind(1) ‖ old_id(u32 LE) ‖ new_id(u32 LE) ‖ file_len(u32 LE)
`kind` = `0x02`/`0x03`; `old_id` = 現在のアクティブダイヤル(`9055` から); `file_len` = 実際の `.bin`
サイズ(= `@0x18 + 36`)。ストアの `.bin` をそのままインストールするのが確実な経路です(Ring Data id 359 +
他 102 件確認済み)。(参照: `core-rust/engine.rs::build_dial_replace_init`。)
### 11.5 構造化ディレクトリ文法 ✅(デコード済み・実装済み — 2026-07-02 改訂)
> **⚠️ 改訂(2026-07-02): 以下のフラットな `61 01 00` レコードスキーマは体系的に
> 1 バイトずれていました。** シーン本体はクリーンな TLV(§11.7)です。ドロワブル **リーフ本体**(タグ `0x30`/`0x38`
> 静的、`0x70` ポインタ)は次の通りです:
>
> ```
> 01 xx 00 [X u16][Y u16] …attrs… 61 [count u16][base u32][count×id u16] [05 05 00 01 pivX pivY]
> ```
>
> - attr `0x01` が本体を開きます: **X,Y = 466² キャンバス上の左上**(SDK の
> `sty_picture_t` の `s16 x,y`)。
> - **フレームテーブル `61 …` が本体を閉じます**(`base` = アセット ptr; `count` 1 = 画像、10/11 =
> 数字アトラス — 以前の「レコードタイプ `0a/0b`」は実はこの count でした! — 7/13/2 = コンプリケーション
> フレームシート)。
> - ポインタの追加要素: `0x01` attr 内のソース+スケール `[src] 00 3c 00`; ピボットは
> `05 05 00 01 [pivX][pivY]` **トレーラー**内。**回転中心 = ポインタごとの `(X+pivX, Y+pivY)`** —
> 固定(233,233)ではありません: 中心から外れたサブダイヤルが存在します(例: ダイヤル 366 の針は 150,150 を中心に回転)。
>
> `61 01 00` の線形スキャンは、要素 **N** のフレームテーブル+ピボットを、要素 **N+1** の X/Y
> (およびタグバイト、旧「f3」)に縫い付けていました — 隣接する針がほぼ同一のジオメトリを共有するアナログダイヤルでのみ
> *正しく見えた*のです。「コンパクトバリアントの壁」(仕様 24 §24.4.5)は
> この同じ誤読でした。`core-rust/watchface_struct.rs` の `scan_scene_drawables` と
> `wfweb/src/codec/parse.ts` として実装済み(シーン = 画像/ポインタの主要ソース; フラットスキャンは
> テキスト + 非エンベロープフォールバック用に維持)。`wfweb/compare.html` オラクル(レンダリング vs 公式
> ストア PNG、99 ダイヤル)で検証済み: 64→72 良好、8→5 不良、平均差分 9.3→7.4%。
旧フラットレコード読み取り(置き換え済み、文脈用に保持):
- **静的画像**(`61 01 00`): `asset_ptr(u32) ‖ elemId(u16) ‖ 05 05 00 01 ‖ pivotX(u16) ‖
pivotY(u16) ‖ 3B ‖ 01 ‖ 1b 00 ‖ X(u16) ‖ Y(u16)`。466² キャンバス上の左上 = `(X−pivotX, Y−pivotY)`。
- **ポインタ/針** — 同じ画像レコード、実行時に回転。**回転中心 = `(X+pivotX, Y+pivotY)`**
(アナログダイヤルでは約 233,233)。**データソースはレコードオフセット `+36` の `u8`**、スケール `u16` は
`+38`(=60): `0x0a`/`0x70` = 時(`h·30°+m·0.5°`)、`0x0e`/`0x71` = 分(`m·6°+s·0.1°`)、
`0x12`/`0x72` = 秒(`s·6°`)。✅ ゲッターの逆アセンブルで確認済み(RTC フォールバック 10:10:30)。
- **テキスト / 数字ウィジェット**(`61 0a 00`): `asset_ptr(u32) ‖ [10×u16 フォントメトリクス] ‖ 40 01 00 ‖ flag ‖
3B ‖ 01 ‖ u16 ‖ X(u16) ‖ Y(u16)`。`asset_ptr` はグリフ「0」を指します; 数字 *d* =
`index("0") + d` のアセット(連続する 10 個の cf=5 スプライト、例: `0123456789` と `,°` 句読点)。✅ レンダリング済み。
- **コンプリケーションフィル = フレームインデックス**(✅ 数字/列挙/ゲージコンプリケーションで確認済み、count>1):
値は `.bin` 内の**プリレンダリング済みフレームシート**をインデックスします — `frame = (count−1)·val/100`(パーセント)または
`frame = value`(フリップ数字 / 列挙)。フレームテーブル = サブレコード `61 ‖ count(u16) ‖ base(u32) ‖
count×id(u16)`。例: 327 Digit Max の大きな時針は 13 フレームシート(数字 0–12)、`frame = hour`。
- **プログレスリング / 円弧 = 実行時セクタークリップ**(✅ 2026-07-02、**仕様 25 §2 の「リングはフレームシート」
読み取りを修正**): 要素タグ **`0x81`** は**単一の**完全な円盤(`61` フレームテーブル
`count == 1`)を持ち、部分的な楔形はその円盤を**パイセクターにクリップ**したものです(`frac = value/max`、
12 時方向から時計回り)— 322 Glare 2 でピクセル単位で検証済み、**20 ダイヤル**で `count==1` を確認済み。
ディスク上: `0x81` 本体 = サブ `0x01`(ジオメトリ `x@+0 y@+2 w@+4 h@+6`、インライン `61 1 base`
= 円盤)+ サブ `0x5b`(円弧仕様)。wfweb で実装済み(`blendSector`)。
⚠️ `0x5b` サブレコードは、以前文書化された「`max` u16 `@+4`」**だけではありません** — それは
`max i32` の下位半分を読み取り、その直後にある**開始/終了スイープ角度とストローク幅**を見逃していました。
また、この文書がこれまで扱ったことのない手続き型の兄弟 `0x80`/`0x5a`(明示的な半径を持つ)もあります。
完全なレコードレイアウト、および以前の「12 時方向から時計回り」の仮定が間違っていた点: **§11.15**。
- **データソース id** — 要素の `82` attr ブロックは `delim+3`(最後の `40 01 00` の後)にあり、
**ソース id は `+0x14` の `u8`**(また `relX@+0x07 s16`、`relY@+0x09 s16`、`anchor@+0x0C/0E`、
`mode@+0x15`、`frame-count@+0x1A`)。Anchor < 0 = 親のエッジに整列。🔎 ファームウェアは
`0x101f371c` の 142 エントリのゲッターテーブルを通じて id を解決します(各エントリは `ux2sys_get(type)` を呼び出します)。
⚠️ **id はこの前方 `82`-attr スキャンではなく、構造体の `meta[9]`(§11.11)として読むことを推奨** —
これは §11.8/§11.9 で説明されている 1 バイトずれの原因です。完全な id テーブルは §16;
このセクションが以前インラインで列挙した健康/天気ラベル(`0x19` HR、`0x1b` バッテリー、
`0x24` 温度、`0x36` 歩数)は**論争中でおそらく誤り** — §16 の ⚠️ ボックスを参照してください。
(以下の `0x07:0x0b:0x0f` = HH:MM:SS グループの例は影響を受けません。)
- **グループノード**(`0x68`): 子を自身の TLV 本体(`0x60` = 値/テキスト、
`0x30` = 静的)内にネストします; 各 `0x60` は `data+16` にソース id を持ちます。例: グループ `0x07:0x0b:0x0f` =
HH:MM:SS クロック。TLV 要素パーサー = `0x100db55c`(`tag−0x70` でインデックスされるジャンプテーブル)。
### 11.6 オーサリングマトリックス
| パス | ステータス | メモ |
|---|---|---|
| 任意の画像からのフォトダイヤル | ✅ **完了** | §11.3; デバイス上で検証済み |
| 103 個のストアダイヤルのいずれかをインストール | ✅ **完了** | §11.4; `9075`、`old_id`=アクティブ |
| ストアダイヤルの cf=4 背景のリスキン | ✅ **ライブで動作** | 完全なペイロードをその場で交換、アセット `len` を**新しい**ブロックサイズ(≤ 旧)に設定、同じファイルフットプリントを維持、新規インストール |
| テンプレートによる再オーサリング(任意のレイヤーのピクセルを交換 + ジオメトリを移動) | ✅ **BLE 経由でレンダリング** | ダイヤル 373: bg→シアン + cf=5 スプライト→赤 + X 224→100 に移動、すべてレンダリング、針はライブ |
| ゼロからの 100% 合成構造化ダイヤル | ✅ **ビルダー完了、オフライン検証済み** | `watchface_struct.rs` の `0x20` エンベロープビルダー(`build_container`/`serialize`/`validate_container`); 103 ダイヤルすべてをバイト単位で往復 + 合成はファームウェアバリデーターを通過(§11.7)。🟡 `9075` 上のデバイスレンダリングはまだ撮影されていません |
| システムフォント(`.font`) | ✅ **デコード/レンダリング(すべて)** | LVGL bin(独自形式ではない); 32 個の数字フォント(`num*/nm*`、非圧縮)+ 24 個のテキストフォント(`font*`、LVGL RLE `comp=1`)すべてデコード — 12208 グリフ、0 オーバーラン、完全な ASCII。RLE = LVGL v8.3 `lv_font_fmt_txt.c`(3 状態 SINGLE/REPEATE/COUNTER + 行ごとの XOR プリフィルター)、1:1 で移植、逆アセンブリなし |
⚠️ 黒画面または `0a` を引き起こすリスキン/再オーサリングの落とし穴: **古いアセット `len`** を残す(
ウォッチはブロックを超えて読み取る → オーバーラン → 黒); **ファイルを拡大する**(インストール時に拒否);
新規インストールではなく**その場で** id を再利用する; 参照テールブロックサイズチェーンを修正せずに
**アセットを並べ替える**(§11.11)。今日は致命的ではありませんが、正しく書いてください: シーンまたはアセットプールへの変更は
2 つのヘッダー **CRC32** ワードを無効にします — 両方を再計算してください(§11.4)、`@0x20` を `@0x00` の前に。
### 11.7 `0x20` シーンエンベロープ — デコード済み & ビルダー実装済み ✅
再オーサリングされた**実際の**ダイヤルがレンダリングされるのは、ファイルのシーンエンベロープを保持するためです。純粋に合成された
フラットな `61 …` レコードの本体は**拒否されます** — ファームウェアパーサー(`WFManager_Parser`、`0xdb35c`)
は本体(オフセット `0x24` から)が `0x20` シーンコンテナで始まることを要求します。完全なファイルは次の通りです:```
[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
The scene is a clean nested TLV — [tag u8][len u16 LE][body], container tags 0x20/0x21/0x22/0x68
recursing, leaf drawables 0x30 (static) / 0x70 (element/pointer) / 0x80 / 0x81 / 0x86 (name).
(The flat 61 01 00 / 61 0a 00 records are patterns that live inside the drawable bodies; the
old parser found them heuristically — and stitched adjacent bodies together, see the §11.5 revision.
The drawable body layout is now fully decoded there.) Every child's offset+len must fit inside its
parent's window;
first body byte ≠ 0x20 → parser error −16; a child overrunning its window → −2; either makes the
9065 handler (0xeb50c) write finish .
Builder is implemented and offline-validated (core-rust/watchface_struct.rs:
SceneNode / serialize / parse_scene / validate_container / build_container /
build_container_raw; CLI cmfwatch-wfgen reframe):
scene_roundtrip_identity — all 103 store dials: parse_scene→serialize reproduces the scene
byte-for-byte (recomputed nested lens match) and validate_container passes on every one.build_reframe_identity / CLI reframe — reassembling the whole .bin from scratch reproduces
the file byte-for-byte except 1 name-padding byte (@0x17; not a checksum).build_container_synthetic — composes a new dial (background + drawable nested in 20→21) that
passes the firmware's exact invariant (build_container emits correct nested windows).validate_rejects_bad_containers — rejects a flat body (→ , the historic
bug) and a child that overruns its window (→ ).🟡 Still unproven (needs the watch, non-blocking): uploading a from-scratch synthetic over 9075
and watching it render — the offline structural proof already covers what caused the 0a reject.
Cross-referenced the wfweb render against the official store thumbnails (pixel oracle over all 103 dials) and closed four gaps:
i16 (signed). ✅ Anchors can be negative for elements that
extend off-canvas — e.g. 275's red second hand sits at Y = 0xFFFC = −4 (a 30×281 sprite,
source 0x12, rotated from center off the top edge). Reading X/Y as u16 (65532) made the
guard drop it. Parse both as signed and allow a small negative range.0x60 img_numbers (not only inside a 0x68 group), and
the real data source is the u8 at record offset −5 — the forward 82-attr scan is
systematically off-by-one here and grabs the next sibling's attr (in 275 the minute digit
picked up the weekday 0x18). 275's "10:10" = hour 0x07@X≈306 + min 0x0b@X≈369 with the
as an adjacent static between them, each an 11-glyph atlas (). ⚠️ When correcting X/Y
from , the , or a re-export corrupts those bytes (breaks
same-footprint → ).Also: the official store thumbnails are rendered at 10:10 (classic marketing time), not 10:12 — matching the oracle time to 10:10 drops mean pixel-diff noticeably. wfweb's parser now round-trips all 103 dials byte-exact (the X/Y write-offset fix above cleared the last mismatches).
0x22 AOD container into its own view. ✅ The scene walker already skips 0x22,
but the flat text/number scan walked the whole [0x30, firstAsset) — so it emitted the
always-on (AOD) variant of each element as a normal layer. On "Gradient" the AOD gray date
atlas (offset in 0x22) drew on top of the red normal one. Fix: tag every 0x22 record with
layer.aod=true (with its own dedup set) and let renderAt(…, aod) show them only in AOD mode
(normal mode hides aod layers; AOD mode hides normal ones; the background is swapped by setAod
and always draws). Net oracle win in normal mode across the corpus (284: 31%→21%, +18 others) —
the AOD variants were overdrawing many dials — and the editor's AOD toggle now shows the real
always-on layout instead of the normal ones. The real AOD is a black screen (no dimmed scene):
if the dial has no dedicated AOD background frame (dial.aod), the normal scene is hidden in AOD
mode so it renders black + the 0x22 elements at their own colour. AOD parse via the
scene walker too (it now recurses the container tagging drawables , instead of leaving
them to the flat scan where their pivot didn't match → "unpositioned"); AOD hands rotate at the
canvas centre (the sometimes carries an off-centre hand x/y the firmware ignores — e.g.
Gradient's hour ). The editor also exposes this as an
(§UI): each screen shows only its own layers and edits persist independently. Normal-mode render is
byte-identical throughout; roundtrip stays byte-exact on all 103 dials.40 01 00 XX byte (✅ firmware-confirmed)How many digits an img_number draws is a single byte in the field record — the data byte XX
of the element's 40 01 00 XX attribute sub-record (the 0x40 sub-record that sits after the
61 [count][base][glyph-ids] frame table):
XX & 0x0F = number of digit slots (0 ⇒ firmware default 7).0x80 = zero-pad (show leading zeros, e.g. "09" vs "9").Confirmed by disassembling the firmware (XIP image 0x10000000; render routine 0x100d8e60):
NDIG = ldrb[40sub+3] & 0x0F (→7 if 0); the value is clamped value % 10^NDIG and exactly NDIG
glyphs are drawn MS-first, leading zeros suppressed unless bit7. The u16 after the source (60 for
date, 1000 for kcal) is NOT the count — it only feeds the thousands/millions separator-glyph
insertion (cmp #1000/#1000000), which is why editing it did nothing. Source id doesn't cap either.
Corpus histogram over all 620 number fields matches: 2-digit fields (hour/min/sec/date/temp/HR) end
40 01 00 02/0x82; kcal …04; steps …05; single-digit clock splits 0x81. So the date field
40 01 00 82 = 2 digits, zero-padded — that's the entire reason a rebound Fahrenheit temperature
(≥100) was truncated.
Fix / editor: wfweb parses digitCount/digitZeroPad (+digitCountOff) for number fields,
exposes "Digits" + "Zero-pad" in the inspector, writes the byte in-place (same-footprint),
and the preview clamps/pads to digitCount to mirror the firmware. So rebinding a field's source and
setting its digit count works for any field (e.g. date→temperature °F → Digits 3). Normal-mode
oracle unchanged (0 regressions, 3 tiny improvements); roundtrip byte-exact on all 103 dials.
(The earlier "Digits width"/rectW hypothesis was wrong — width is layout only, not the count.)
struct record, and the resource-reference tail ✅The scene (§11.7) is a clean nested TLV — [tag u8][len u16 LE][body]. Complete tag inventory as
observed across the corpus:
✅ This inventory is complete for the corpus. Recursing only into the container tags above, the
scene TLV of all 15 dials checked walks exactly to its declared root length with zero unknown
tags — so a parser that handles this table handles the whole format, and an unknown tag means a
misaligned read, not a new node type. (Beware: a walker that recurses into every node whose length
happens to be ≥ 3 will descend into struct/0x5b bodies and hallucinate a long tail of one-off
"tags" — the leaf bodies are not TLV.)
💡 Authoring shortcut:
0x48/0x68auto-layout can be skipped entirely — every widget can be placed with absolutex,ydirectly at screen top level, which is what the from-scratch builder (§11.7) does. Only needed to read existing dials. Ametawidth of0x8000marks a struct as an auto-layout child of a frame (position comes from the parent, not fromx,y).
0x01 struct body — a fixed 18-byte prefix followed by an optional resource-reference tail:```
+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)
> ✅ これにより、§11.5/§11.8/§11.9 の「マジックオフセット」が統一されます。これらのセクションは、フィールドを `0x61` フレームテーブルバイト *からの相対位置* で特定していますが、これは単にこの構造体の `+0x12` に相当するため、`−18`/`−16` = `x`/`y` となり、**`−5` = `meta[9]`、つまりソース ID** になります。同じバイトで、1 つのクリーンなレイアウトにまとまります。体系的に 1 バイトずれていた前方スキャンの `82` 属性ヒューリスティックは、まったく不要です。構造体の `meta[9]` を読み取るだけで済みます。数値フィールドの `max` も同様に、単に `meta[11..13]` です(例: 日フィールドは `max = 99` を保持します)。
**Ref テール** (`61`) — ノードがビットマップを指す方法:```
+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
⚠️ これら末尾の u16 群は、以前は「count×id(u16)」/グリフIDとして文書化されていましたが、実際はブロックサイズです。base にそれらの累積和を加えていくと、アセットプールをエントリごとに順に辿ります(✅ 数字アトラスの全10エントリで正確に検証済み)。これには2つの結果があります:
count−1 個のサイズだけが意味を持ちます。最後のエントリの値は決して参照されないため、実際のファイルではそこに古い値が入っていることがあります。最後のエントリの不一致を壊れた参照として扱わないでください。0x28 プレビュー — ストア/カタログのサムネイルは、.bin 自体に埋め込まれており(15個のダイヤルに27個のプレビューノード)、0x08 pvStruct として、5バイトのプレフィックスと同一の参照テールを持ち、x/y はありません。別途PNGを配布せずにギャラリーUIを構築するのに便利です。
0x02 ✅ウィジェットの 0x02 兄弟要素は、そのウィジェットを条件付きにします。これがない場合、ウィジェットは常に描画されます。文法:```
count u8 , count × ( id u8 , op u8 , val u24 LE signed ) [5 bytes per entry]
`id` はデータソースID(§16)であり、**§11.13の合成スロットID**を含みます。演算子と、15ダイヤルで測定した出現回数は以下の通りです:
| op | 意味 | 出現回数 |
|---|---|---|
| `0x01` | `value == val` の場合に描画 | 99 |
| `0x81` | `0x01` と同じ(ビット `0x80` が設定 — 相互排他的なバリアントに出現) | 48 |
| `0x02` | `value == val` の場合に**非表示** | 7 |
| `0x03` | `value == val` の場合に描画。ここで `val` は**データなしマーカー**(例:HR `1000`) | 13 |
| `0x05` | `value >= val` の場合に描画 | 58 |
| `0x06` | `value <= val` の場合に描画 | 50 |
| `0x04` | ⚠️ **不明** — 15回出現、確認された意味はなし | 15 |
組み合わせルール(参照レンダラーが実装、マスク `op & 0x7f`):等価エントリは**OR**で結合され、その後、非表示/`>=`/`<=` エントリは**すべて**成立する必要があります。
この単一のメカニズムがフォーマットの実行時変動性の大部分をカバーし、重複したウィジェットのように見える構造を説明します:
- **12時間/24時間およびメートル法/ヤード・ポンド法のレイアウト** — 同じ場所に積み重ねられた2つのウィジェットセット。それぞれが `id 0x73`(単位フラグ)に `val` 0または1でバインドされています。ダイヤル275にはそのようなペアが6つあります(12ノード)。
- **「データなし」プレースホルダー** — センチネルに対する op `0x03`、例:`id 0x5f, val 1000`(ダイヤル275、2回):メトリックが利用できない場合、温度の代わりにemダッシュのアートを描画します。
- **バケット化されたハイライト** — ペアになった `0x05`/`0x06` の範囲、例:Metaballのチェーンで各リンクが独自の5分間ウィンドウで点灯します。
- **コンプリケーションスロットの代替** — §11.13の合成スロットIDにバインドされています。
### 11.13 設定可能なコンプリケーションスロット — `0x85` + `0x5f` ✅(§11.8を置き換え)
§11.8では、設定可能なコンプリケーションのアクティブメトリックはデバイスのRAM状態であり、ファイルから復元できないと結論付けました。**それは誤りでした** — スロットのメトリックメニューとそのデフォルト選択の両方が `.bin` 内にあります。各 `0x85` ノードには `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
実際に描画される代替候補は、ツリー内の他の場所にある通常の0x68グループであり、それぞれが合成ID 0x79 + slotIndex 上の0x02条件(§11.12)によってゲートされています。つまり、スロット0のバリアントは0x79に、スロット1のバリアントは0x7aにバインドされ、以下同様です。スロットを描画するには:activeIdxを読み取り、そのインデックスに一致する条件を持つバリアントを描画します。
実際のダイヤルで測定した結果:
ダイヤル275の2つの6メトリクススロットは、その26個の0x02ノードのうち12個を占めており、まさに予測どおりです:01 79 81 0X 00 00と01 7a 81 0X 00 00(X = 0..5)— 0x79(スロット0)にキー付けされた6つの代替候補と、0x7a(スロット1)にキー付けされた6つの代替候補です。(§11.8が「インスタンスバイト」と呼んだ0x79/0x7aバイトは、これらのバインドIDです。)残りの14個は無関係です:0x73上の12個(24時間/メトリクス単位フラグ、valは0または1 — 12時間と24時間レイアウトを切り替える6つのウィジェットペア)と、op 0x03とval = 1000を持つ0x5f上の2個(温度のデータなしプレースホルダー)。
依然として真のデバイス状態:ユーザーが後でコンパニオンアプリで選択したものは、実行時にactiveIdxを上書きするため、プレビューはファイルのデフォルトを再現しますが、必ずしも特定の時計が表示するものとは限りません。0x85ノードのimgs[0]は、ファームウェアが独自の編集モードでのみ描画する「タップして設定」プレースホルダーです — 通常の時刻表示をプレビューするときはスキップしてください。
meta[7] == 4 機能フラグ ✅一部のダイヤルでは、ユーザーがデバイス上でアクセントカラーを選択でき、ファームウェアはレンダリング時にそれをウィジェットのビットマップに置き換えます。スイッチは単一バイトです:構造体(§11.11)のmeta[7] — つまり0x01ボディのバイト+0x0B — が**4に等しい**場合、そのウィジェットのリソースが着色可能としてマークされます。
.binは元のピクセルを保持する必要があります。そうしないと、ユーザーの選択が永久に失われます。ティントはプレビュー/キャンバスパスのみに適用してください。⚠️ これを色ヒューリスティックに「改良」しないでください — その道は証明された行き止まりです(fmcが苦労して学んだ後に文書化されています)。直感的な理論は、フラグ付きピクセルがファームウェアが置き換える認識可能なプレースホルダー色で焼き込まれているというものです。それは機能しません:ダイヤル348 Tumblerの着色可能なリングと、ダイヤル282 Radar Sweep / 291 Verticalの通常の非着色可能な数字ストリップは、まったく同じ
(255,72,32)RGBを焼き込んでいます(全ピクセルを徹底的にチェック済み);また、ダイヤル305 Dots(時針)と306 Large Number(数字)は着色可能でありながら純白で焼き込まれているため、色テストでは完全に見逃されます。連続的な改良(1 → 4つの参照色、さらにウィジェットロールの許可リスト)はすべて失敗しました。フラグを読み取ってください。実機/コンパニオンアプリと7つのダイヤルで相互検証済みで、両方向を強調するように選択されています — 349 Theatre、376 Digits time、305 Dots、306 Large Number、304 Elaborate 2はすべてアクセント設定を提供し、すべて
meta[7]==4ウィジェットを持っています;316 Trailing(赤みがかった針、設定なし)、312 Disc、295 Vortexは提供せず、フラグ付きウィジェットはゼロです。
meta[4..6]はフラグのすぐ隣にあり、一部の構造体では色をエンコードしているように見えます(末尾f1=1,f2=255を持つ実際のRGBのように見えるもの vs. フラグ付き構造体のプレースホルダー(1,0,0)/(4,0,0))。これはアクセント機能とは相関しません。⚠️ 未解決;無視してください。
0x80/0x5a(プロシージャル)と0x81/0x5b(画像クリップ)✅両方のリング形式は、短い構造体(x、y、ソースIDを持つmeta — 通常は参照テールなし)をアーク仕様の兄弟とペアにします。§11.5は「0x5b:max u16 @+4」のみを文書化しており、これはmax i32の下半分であり、スイープジオメトリを見逃しています。完全なレコード:```
+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)
ペアリングは固定です: `0x80` は常に正確に `0x01` + **19バイト**の `0x5a` を運び、`0x81` は常に正確に
`0x01` + **17バイト**の `0x5b` を運びます(✅ 15個のダイヤルで26/26リング)。⚠️ ただし**画像クリップされた `0x81`
が優勢**です — その26個のうち25個。手続き的な `0x80`/`0x5a` バリアントは**一度だけ**出現しました(ダイヤル273)。そのため、その
`radius` フィールドと19バイトのレイアウトは単一サンプルに依存しています。再び見られるまで疑ってかかってください。
`frac = clamp((value − min) / (max − min), 0, 1)` で、塗りつぶされた円弧は `start` から `end` に向かって描画されます。
角度ゼロは **3時の位置**、正の方向は時計回りです。測定例:
| ダイヤル | タグ | min..max | start → end | 幅 | 半径 |
|---|---|---|---|---|---|
| 273 Activity Mood | `0x5a` | 0..100 | **−102.8° → 102.8°** | 42 | 222 |
| 273 Activity Mood | `0x5b` | 0..100 | 270.0° → 90.0° | 80 | (画像) |
| 276 Dichotomy | `0x5b` | 0..100 | 60.0° → −120.0° | 23 | (画像) |
| 304 Elaborate 2 | `0x5b` | 0..100 | −2.0° → 358.0° | 24 / 80 | (画像) |
| 366 Combo | `0x5b` | 0..100 | 0.0° → 270.0° | 18 | (画像) |
| 368 Function | `0x5b` | 0..100 | 0.0° → 360.0° | 20 | (画像) |
> ⚠️ **これは§11.5の「12時から時計回り」という前提を修正します。** それは特別なケース
> `start = 0, end = 3600`(フルスイープ。この場合、その規則は観測不能)にのみ当てはまります。実際のダイヤルは
> **部分ゲージ**(273の±102.8°の扇形、366の270°の3/4リング)と**負のスイープ**
> (276の60° → −120°)を使用するため、常に上部から全円をスイープするレンダラーはこれらを誤って描画します。
> 🟡 正確な角度ゼロの規則と方向の規則はfmcのレンダラーに由来し、これらのディスク上の値と照合されています —
> このリポジトリによってデバイス上でピクセル単位で独立検証されたわけではありません。
> `frac = value/max` と§11.5の `0x81` セクタークリップの結果は**検証済み**であることに注意してください(ダイヤル322)。
⚠️ **未解決: 3バイトのトレーラー `01 00 kk`。** `kk` はもっともらしい値(104、152、216、
232、248)を取り、明白な仮説は半径です — **テストして反駁されました**: ダイヤル366は82×82と166×166の両方のリングで `kk = 104` を使用し、ダイヤル273は440×440と284×284の両方の
リングで `kk = 232` を使用します。半径でも直径でもありません。おそらく不透明度/スタイルです。そのまま忠実に往復させてください。
🟡 **報告された落とし穴(ここでは未検証):** fmcのレンダラーでは、ソースIDが同じ画面上の**任意の**リングのIDと等しい `0x60` 数値は、生の値の代わりに `round(frac × 100)` をレンダリングします。つまり、
心拍数リングの隣にある心拍数数値は `71 bpm` の代わりに `36` を表示します。彼らの文書化された
回避策は、リングと数値を同じメトリクスの**エイリアス**ID 2つ(歩数
`0x19`/`0x26`/`0x49`、カロリー `0x1c`/`0x1e`/`0x48`)に配置することです。これがファームウェアの動作なのか、それとも
彼らのレンダラーに固有のものなのかは**確立されていません** — それを前提に設計する前にライブチェックする価値があります。
---
## 12. 一括転送とOTAの詳細
転送テーブルは§6にあります。追加で確認されたポイント:
- **AGPS/EPO** ✅: 最初に書き込まれるチャンクはASCIIヘッダー `000000010000…` で始まります。完全な
init → `[A05F ↔ 905F]×N` → finish ループがワイヤ上で観測されました(~892チャンク)。
- **ファームウェアOTA**(`9040`–`9042`、finish `9041`)🔎: 構造はマッピング済み。INIT2ペイロード = バージョンバイト
(例: `0b 00 00 39` = 11.0.0.57)。**フィールドテスト未実施**(アプリはここでFW更新を無効化)。ファームウェア
イメージは**署名なし — 整合性はCRC32のみ**(REで非対称署名は観測されず)。
- ⚠️ OTAと `FACTORY_RESET (009A 0001)` は認証済みセッションを共有するため、単一の有効なBLE
認証でウォッチをワイプしたり(原則として)ブリックしたりできます。注意して扱ってください。
---
## 13. センサー
✅ BLE経由で公開されているハードウェア:
- **光学式PPG** — 心拍数(手動/自動/ワークアウト/安静時)、SpO₂、HRV由来のストレス。
- **3軸加速度計** — 歩数、距離、カロリー、睡眠ステージ、手首上げ、ケイデンス。
- **GNSS/GPS**(AGPSアシスト)— ワークアウトトラック(`WORKOUT_GPS`)と位置情報プッシュ(`GPS_PUSH`)。
**気圧計/高度計、コンパス、ジャイロスコープ、皮膚/体温センサーはありません。** 内部
NTCサーミスタ(ボード/バッテリー温度)は存在しますが、ATチャネル経由でのみ読み取り可能です
(`AT GETNTCTEMP`、§14)— `0155` スキン温度履歴ストリームはこのSKUでは空です。
**データウィジェットのハイジャック**(実際のコンプリケーション/データバインディングAPIはありません — §11.5参照): ウォッチの
既存のテキストフィールドを再利用して、一目でわかる外部データを表示できます。実証済み ✅: 天気
**都市文字列**(`WEATHER_SET_1`、例: `"BRA 2x1 ARG"` がウィジェットに表示)と音楽
**トラック/アーティスト**フィールド。**連絡先**リスト(20 × name[32]+number[25])はスクロール可能なデータ
パネルとして機能します。すべてプッシュであり、永続的なコンプリケーションではありません。
---
## 14. ATファクトリー/シェルチャネル(`77d4ff01` / `77d4ff02`)
フレーム化されたプロトコルとは独立した、別のプレーンテキストATコマンドチャネル。✅ ライブテスト済み:
- **読み取り:** `AT GETSECRET`(16バイトのペアリングシークレット)、`GETVERSION`、`GETSN`、`GETNAME`、`GETPID`、
`GETBATLV`(生のmV、例: `3853mv`)、`GETGSENSOR`(生の加速度g、`X=… Y=… Z=…`)、
`GETNTCTEMP`(°C、内部NTC)。
- **書き込み/作動:** `AT SETMOTOR=1`(モーターを振動させる)、`SETHR/SETHRV/SETSPO2=…`(センサーテスト
注入)、`SETLCDSWITCH/SETGPSSWITCH/SETKEYSWITCH`。
応答は `,OK` で終わります。`SET*` コマンドは一般的に実行されますが、BLE経由で `,OK` をエコーしない場合があります —
ケースバイケースで確認してください。
---
## 15. ファームウェアゲート/利用不可機能(🔎 ファームウェアRE)
一部の機能はファームウェアに存在しますが、SKU/リージョンによって無効化されており、**電話/BLEからは到達できません** —
ファームウェアの改造が必要ですが、これはここでの範囲外です:
- **ChatGPT音声** — ゲート = `ux2sys` 機能ID `0x9e`。起動時にNVRAM/EFUSE/リージョンからシードされます。この
SKUではサポートフラグ `908b = 00`。電話、アカウント、BLEでは影響を与えられません(実験 + REで確認)。
アプリは単なるリレーです。音声は電話 → Nothingクラウドへ送信されます。
- **血圧** — 完全なサブシステムがファームウェアに存在しますが、SKU/リージョンによってオフになっています。
- **Alipay / NFC決済** — 完全なUIが存在しますが、中国SKUのみ。
- **ハードウェア/ファームウェアに存在しないもの:** ECG、SOS/緊急、汎用NFC。
---
## 16. データソース/コンプリケーションゲッターID
BLEクライアントを構築するために必要ではありませんが、ダイヤルを**作成またはレンダリングするには不可欠**です: これは
ウィジェットをライブデータにバインドする `meta[9]`(§11.11)の値、可視性条件の `id`(§11.12)、
スロットのメトリクスメニューのエントリ(§11.13)です。ファームウェアはこれを`0x101f371c` の**142エントリのゲッター
ディスパッチテーブル**を通じて解決し、各エントリは `ux2sys_get(type)` を呼び出します(🔎 ファームウェアRE)。
### 時刻/日付 — ✅ 十分に確立
| id | 意味 | id | 意味 |
|---|---|---|---|
| `0x01` | 時(デバイス設定に応じて12/24時間) | `0x0f`, `0x12` | 秒(スムーズ) |
| `0x04` | 時(24時間) | `0x10`, `0x11` | 秒の十の位 / 一の位 |
| `0x07` | 時(強制24時間) | `0x71`, `0x72` | 秒(チクタク / 針の角度) |
| `0x02`, `0x03` | 12時間制の十の位 / 一の位 | `0x13` | AM/PMフラグ(0 = AM、1 = PM) |
| `0x05`, `0x06`, `0x08`, `0x09` | 時の十の位 / 一の位 | `0x15`, `0x16` | 月 |
| `0x0a`, `0x70` | 時の針の角度 | `0x17` | 日(月内) |
| `0x0b` | 分 | `0x18` | 曜日(0 = 月曜日 ⚠️) |
| `0x0c`, `0x0d` | 分の十の位 / 一の位 | `0x0e`, `0x71` | 分の針の角度 |
十の位/一の位のIDは**1桁**を描画します — それらのいずれかにバインドされたウィジェットは、値全体ではなく1つのグリフをレンダリングします
(ダイヤル284 Square)。針の角度ID(§11.5)は `0x0a`/`0x70` 時 = `h·30° + m·0.5°`、
`0x0e`/`0x71` 分 = `m·6° + s·0.1°`、`0x12`/`0x72` 秒 = `s·6°`(✅ ゲッターの逆アセンブルで確認済み、RTCフォールバック 10:10:30)。
### 健康/センサー/天気 — ⚠️ 論争あり、証拠の列を読んでください
| id | 意味(現時点での最良の解釈) | 証拠 |
|---|---|---|
| `0x19` | **歩数** | 368のスロットメニューで `0x1a` と並んで表示。このリポジトリの `parse.ts` と一致 |
| `0x1a` | **心拍数** | 368のスロットメニューで `0x5f` と `0x19` と並んで表示 |
| `0x1c` | カロリー | スロットメニュー、コンパニオンアプリの炎アイコン |
| `0x1e` | カロリー(エイリアス) | コーパス |
| `0x22` / `0x23` | 距離 km / mi(整数部分) | コーパス |
| `0x74` / `0x75` | 距離 km / mi(小数部分) | コーパス |
| `0x76` | 距離(スロット形式) | スロットメニュー、道路アイコン |
| `0x24` | **バッテリー%** | スロットメニュー、稲妻アイコン |
| `0x30` | バッテリー% | コーパス |
| `0x36` / `0x5f` | 温度 | `0x5f` = スロットメニュー、雲と太陽のアイコン。361 TempoGはプレーンな数値をバインド |
| `0x48` | スタンド(立った時間) | スロットメニュー、立っている人のアイコン |
| `0x8b` | AQI | スロットメニュー |
| `0x73` | 24時間/メートル単位フラグ | コーパス |
| `0x25`–`0x27`, `0x49`, `0x6c`, `0x6f` | 目標% / 歩数とカロリーのスロットエイリアス | コーパス |
| `0x6a` | ⚠️ 未識別のスロットメトリクス | 4つのスロットメニューに出現 |
| `0x79 + slotIndex` | **合成** — メトリクスではない。スロット選択ID(§11.13) | ✅ §11.13 |
> ⚠️ **このリポジトリの3つのテーブルが矛盾していました。これはその調整版です。** §16の以前のリビジョン
> と§11.5は `0x19` を心拍数、`0x1b` をバッテリー、`0x24` を温度、`0x36` を歩数として読んでいました —
> そして `wfweb/src/codec/mock.ts` は依然としてその読み方をエンコードしていますが、`wfweb/src/codec/parse.ts` は
> 別の読み方をエンコードしています(`0x19` 歩数、`0x24` 目標%、`0x48` スタンド、`0x1a` 天気)。上記のテーブルは
> より証拠の強い読み方に従っています(ラベルはfmcがコンパニオンアプリ自身のウィジェットスロット
> メニューアイコンに対して較正、§Sources参照)。**コードはまだ変更されていません — `mock.ts` と `parse.ts` は依然として
> 互いに、そしてこのテーブルと矛盾しています。** 誰かが各IDにフィールドをバインドしてウォッチを読むまで、健康IDのラベルを⚠️として扱ってください。
>
> 最も強力な単一の証拠は**ダイヤル368 Functionのスロットメニュー**(§11.13)で、1つのメニューで
> `0x5f 0x1c 0x19 0x48 0x24 0x76 0x1a 0x8b` を**8つの異なるユーザー選択可能なメトリクス**として提供します。
> ラベルが何であれ、これら8つのうちの2つが同じメトリクスになることはありません — つまり
> `0x19` = `0x1a` = 心拍数、および `0x24` = `0x5f` = 温度を同時に除外します。
**フレームシート**を持つリング/円弧コンプリケーションは、送信する `.bin` に焼き込まれた事前レンダリングフレーム
(例: 50% = 100のうちのフレーム50)をインデックスします(§11.5)。そのため、外部のRESパックは不要です。イメージレスリングは
代わりに円弧仕様から描画されます(§11.15)。
---
### クイックカード(ホームタイル)— `QUICK_CARD (906D)` ✅
ウォッチのホームタイル。電話は**どの**タイルを表示するか、**どの順序で**表示するかのみを選択します —
タイルはファームウェアによってレンダリングされます(コンテンツチャネルなし)。最初のペイロードバイト = サブコマンド: `00` = GET、`01` =
SET。**両方とも `0x906D` を使用します**(`0x906C` はリストされていますが使用されていません — クエリするとタイムアウトします)。応答 = `A06D`。
> 🛑 でっち上げの `assemblyId` を送信すると**ウォッチの画面が消去されます**(リストを受け入れますが、IDを
> 一致させることができず、何も表示しません)。GETで**読み戻した**IDのみを送信してください。公式アプリまたは
> ファクトリーリセットで復旧できます。
**GET応答** ✅: `status(1) ‖ 00 ‖ N(1) ‖ N × group`、group = `tag=01 ‖ K(1) ‖ K×(assemblyId, sportId)`。
実際のフレーム: `01 00 04 01 02 5d00 6100 01 03 1900 2e00 2300 01 03 5c00 0400 5a02 01 03 4800 5100 5300`
= 4画面 / 11カード(`5a02` = スポーツカード、sportId 2)。
**スロット:** 各画面には**4つのスロット**があります。カードのタイプがサイズを決定します — `circular`/`square` = 1スロット、
`rectangle` = 2スロット。検証は純粋なスロット演算です(画面ごとにΣ ≤ 4)。相互排他的な
カードはありません。`sportId` はスポーツカード(87–91)以外は `0` です。ID `64` と `95` は存在しません。
**`assemblyId` カタログ**(各論理タイプ = 6つのスタイルバリアント `_0`..`_5` の連続範囲。
`0` = 空スロット):
| dec | カード | dec | カード |
|----|----|----|----|
| 0 | 空スロット | 49–53,97–98 | 天気 |
| 1–6 | 歩数 | 54–58 | タイマー |
| 7–12 | カロリー | 59–62 | 呼吸 |
| 13–18 | スタンド | 63,65–67 | ストップウォッチ |
| 19–24 | 中程度のアクティビティ | 68–71 | バッテリー |
| 25–30 | 心拍数 | 72–76 | 最近 |
| 31–36 | SpO₂ | 77–81 | 連絡先 |
| 37–42 | ストレス | 82–86 | ダイヤル / 電話 |
| 43–48 | 睡眠 | 87–91 | スポーツ(`sportId` ≠ 0) |
| 92 | 音楽 | 93/94/96 | アクティビティ記録 / PAI / サイクル |
---
## 付録A. ストックダイヤルカタログ(id → 名前)
§11は全体を通して数値IDでダイヤルを参照しています(275 SlopeTime、322 Glare 2、357 Silhouette、…)。ID
`273`–`376` はストア/ストックのフェイスです。IDは `DIAL_COMMAND (9055/a055)` がアクティブとして報告するものであり、
`9075` が `old_id` として受け取るものです(§11.4)。既知の103個のIDのうち100個が以下に名前付けされています。残りは
ROMスタブです(§11.4)。名前とグループ分けは公式コンパニオンアプリに表示される通りです。
- **デフォルト**(6)— `273` Activity Mood · `274` Sun Circle · `275` SlopeTime · `276` Dichotomy · `277` Prismatic Time · `280` Multifunction
- **アナログ**(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
- **デジタル**(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
- **多機能**(10)— `304` Elaborate 2 · `344` InfoMeter · `348` Tumbler · `354` Dual · `363` Vintage · `366` Combo · `367` Complex Figure · `368` Function · `374` Cirquary · `375` InfoHub
- **クリエイティブ**(8)— `284` Square · `312` Disc · `317` Disc 2 · `318` Dominos · `332` Flux · `342` Perfect Match · `343` Progress Day · `358` Asteroid
- **ディワリ**(1)— `295` Vortex
---
## 出典
上記のバイトレイアウトは、ファームウェア(1.0.0.73)、公式APK(3.5.7)、および
実機に対する復号化されたライブキャプチャから再構築されました。このプロジェクトのリファレンス実装は
`core-rust/src/{commands,frame,crypto,health,session}.rs`(Rust)、`wfweb/` TypeScriptエディター、
および `cmftool/` Pythonツール(`pair.py`、`session.py`、`wf_codec.py`、`upload_custom.py`、…)にあります。
**ここに組み込まれた独立した作業。** [freethinkel/fmc](https://github.com/freethinkel/fmc) — 同じウォッチ用の
SvelteKitウォッチフェイスエディター+マーケットプレイス — は、~100フェイスのコーパスから `.bin` 形式を独立して
リバースエンジニアリングし、このドキュメントが欠落していた、または誤っていたいくつかの結果に到達しました。彼らの
`docs/cmf-protocol.md`、`src/lib/modules/editor/lib/{wf,render}.ts`、および
`src/lib/modules/device/lib/ble.ts` は直接読む価値があります。採用された発見は、ここに書き留める前にそれぞれダイヤルバイトに対して再検証されています:
| 発見 | 場所 | ここでのステータス |
|---|---|---|
| 両方のヘッダーワードがCRC32(非標準バリアント) | §11.4 | ✅ 9/9ダイヤルで再検証済み。**「ブロッキングチェックサムなし」を修正** |
| 可視性条件、タグ `0x02` | §11.12 | ✅ 再検証済み。文書化されていなかった |
| スロットメトリクスリスト + デフォルト `activeIdx`、`0x79 + slotIndex` | §11.13 | ✅ 275/368/273/304で再検証済み。**§11.8を置き換え** |
| アクセントティント機能フラグ `meta[7] == 4` | §11.14 | ✅ 普及率を再検証済み。文書化されていなかった |
| 完全な円弧仕様(スイープ角度、幅、半径) | §11.15 | ✅ 再検証済み。**「`max` u16 `@+4`」を修正** |
| 参照テール `u16` はグリフIDではなくブロックサイズ | §11.11 | ✅ 10グリフアトラスで正確に再検証済み |
| 36バイトのフッター。マジックバイトバリアント `0x02`。`0x86` = 64 B。`0x28` 埋め込みプレビュー | §11.4、§11.11 | ✅ 再検証済み |
| コンパニオンアプリのスロットメニューに対して較正された健康/天気IDラベル | §16 | ⚠️ 最良の解釈として採用。競合をフラグ付け |
| シェルサービスUUIDは `77d4e67c-…`、およびWeb Bluetooth `optionalServices` スコーピング | §1 | ⚠️ 単一ユニットの報告。ここでは再検証されていない |
| リングとIDを共有する数値がパーセンテージをレンダリング | §11.15 | 🟡 報告済み。ここでは**未検証** |
| ストックダイヤルID → 名前カタログ | 付録A | ✅ そのまま採用 |
| 目的 | サービス | キャラクタリスティック | プロパティ |
|---|
| コマンド書き込み | 0000fff0-0000-1000-8000-00805f9b34fb | 0000fff2-… | Write |
| コマンド通知 | 0000fff0-… | 0000fff1-… | Notify |
| シェル書き込み(AT) | — | 77d4ff01-2fe2-2334-0d35-9ccd078f529c | Write |
| シェル通知(AT) | — | 77d4ff02-… | Notify |
| バルクデータ書き込み | — | 02f00000-0000-0000-0000-00000000ffe1 | Write |
| バルクデータ通知 | — | 02f00000-…ffe2 | Notify |
| 名前 | 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 |
| 名前 | 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 |
| 名前 | 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 |
| 名前 | 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 |
| 名前 | 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 🔎(ライブHR/カロリー/歩数) | FFFF 9078 / FFFF A078 |
| FEMALE_CYCLE_SET / _RET | FFFF 9071 / FFFF A071 |
| SLEEP_CONFIG_SET / _RET(目標分) | 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 |
| 名前 | cmd1,cmd2 |
|---|
| DIAL_COMMAND_SET / _RET(一覧/並べ替え/選択) | FFFF 9055 / FFFF A055 |
| DIAL_CONFIG_SET / _RET | FFFF 9075 / FFFF A075 |
| CHANGE_DIAL(⚠️ 1.0.0.73 では無効 — 使用しないでください) | 009F 0001 |
| QUICK_CARD_SET/GET / _RET(両方 906D 上) | FFFF 906D / FFFF A06D |
| 名前 | 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 🔎(このSKUでは空) | 0155 0001 / 0155 0002 |
| WORKOUT_SUMMARY / _V3 | 0057 0001 / 0160 0001 |
| WORKOUT_GPS | FFFF A05A |
| ドメイン | INIT1 要求/応答 | INIT2 要求/応答 | CHUNK 要求/書き込み | FINISH ack1/ack2 |
|---|
| 文字盤(写真) | 8052/0052 | 9063/A063 | A064/9064 | A065/9065 |
| 文字盤(構造化/切替) | 8052/0052 | 9075/A075 | A064/9064 | A065/9065 |
| ファームウェア | 9052/A052 | 9040/A040 | A042/9042 | A041/9041 |
| AGPS/EPO | 905E/A05E | — | A05F/905F | A060/9060 |
| オフセット | サイズ | フィールド |
|---|
| 0 | 4 | タイムスタンプ(エポック秒) |
| 4 | 4 | 歩数 |
| 8 | 4 | 距離(m) |
| 12 | 4 | カロリー |
| 16 | 16 | 予約済み(0を観測) |
| オフセット | サイズ | フィールド |
|---|
| 0 | 4 | セッション開始(エポック、UTC) |
| 4 | 4 | 起床(エポック、UTC) |
| 8 | 2 | 合計深い睡眠秒 |
| 10 | 2 | 合計コア睡眠秒 |
| 12 | 2 | 合計REM秒 |
| 14 | 2 | 合計覚醒秒 |
| 16 | 2 | ⚠️ [不確実](セッションID/スコア?観測値はレコード合計と一致しません) |
ac 49 1f 0100D5 0001)✅:N × 57バイト = name(32) ‖ phone(25)。ウォッチUIは最大20件表示。0063 0001)✅ — Gadgetbridge を修正(ラベルを末尾に置き、0xff でパディング — 誤り)。
アラームごとに40バイト、ビッグエンディアン:
secondsOfDay(i32) ‖ index(u8) ‖ enabled(u8) ‖ repetition-bitmask(u8) ‖ flag(u8) ‖ label[32] UTF-8。
ラベルはオフセット8にあり、ウォッチに表示されます。repetition = 曜日ビットマスク(0 = 一回限り);
flag は ⚠️ [不確実](一回限りマーカー?)。例(13:30、idx 2):0000bdd8 02 01 15 00 "Alarm…"。005E 0001)✅ — 公式アプリとリファレンス実装は 10バイト、ビッグエンディアン の
DailyTargetBean v1 を使用:steps(u32 BE) ‖ distance_m(u32 BE) ‖ calories_kcal(u16 BE)。(これは Gadgetbridge 形式です;ウォッチが「無視した」という以前の報告は
古いセッションの復号バグであり、ペイロードの問題ではありません。)🔎 ファームウェアREはより長い
29バイト拡張 バリアントも示しています(sleep_min/exercise_min/stand_h + 6つの有効フラグを追加、
すべて flag(u16 LE) プレフィックスの後に u32 BE、範囲が強制されます:歩数 2000–30000、距離 1000–99000、カロリー
100–5000、睡眠 360–720、運動 30–90、立位 6–16)— アプリのデフォルトパスではありません;追加ターゲットが
必要ない限り 10バイト形式を優先してください。0060/0061 0001)✅:11バイト:
enabled(1) ‖ threshold_min(u16 LE) ‖ dndStart(u32 LE) ‖ dndEnd(u32 LE)。UIに表示される「アクティブウィンドウ
08:00–22:00」は 固定のファームウェアデフォルト であり、ペイロードには 含まれません。00DC 0001)✅:count(1) = 36スロット ‖ activityTypeCode[36](アクティブコードの後に 00
パディング)。ウォッチのワークアウトメニューに表示するスポーツを選択します。009B 0001)✅:kind バイト — 01 = 24/7 HR、02 = SpO₂、
04 = ストレス(30分ごとに測定)。FFFF 9059)✅:無効 = 00;有効 =
01 ‖ hrLow ‖ hrHigh ‖ sportHrHigh ‖ spo2Low ‖ 00 00 00 00(0/255 の境界 = 「制限なし」)。FFFF 9071)✅:01 ‖ predictionOpen ‖ notifySwitch ‖ cycleStartSwitch ‖ cycleStartNotifyBefore ‖ ovulationStartSwitch ‖ ovulationStartNotifyBefore ‖ fertileStartSwitch ‖ fertileStartNotifyBefore ‖ period(1) ‖ cyclePeriod(1) ‖ cycleStartDate(u32) ‖ markStart(u32) ‖ markEnd(u32)(キャプチャ済み:period=5、cyclePeriod=0x1c=28)。FFFF 9073)✅:TLV — count(1) ‖ total(1) ‖ [id(1) ‖ len(u16 LE) ‖ msg-UTF8]…
(7つのデフォルト返信がキャプチャ・復号済み)。FFFF 906F)✅:名前ではなく数値の 都市ID を送信します(01 ‖ count ‖ cityId(2 BE)…);
ウォッチは内部テーブルからIDをマッピングします。DST設定 FFFF 9083 =
count ‖ [id(u16 LE) ‖ dst(u16 LE) ‖ start(u32 LE) ‖ end(u32 LE)]…。FFFF 905C、131 B)✅:state(1: 0=なし/1=一時停止/2=再生中) ‖ volume(1) ‖ volumeMax(1) ‖ track(64) ‖ artist(64)。ウォッチは MUSIC_BUTTON (A05D) も送り返します。FFFF 906B、199 B)✅ — これを使用してください:7×9バイトの日 + 24×2バイトの時間 +
都市(32) + 7×8バイトの日の出/日の入り(LE)。気温は (temp_c + 100) & 0xFF としてエンコードされます。
⚠️ 同じペイロードを WEATHER_SET_2(0066 0001)で送信しても、Pro 2 の天気ウィジェットは
更新されません — 常に 906B を使用してください。(都市文字列は実証済みのデータハイジャックベクトルでもあります — §13 参照。)005D 0001)✅:ペイロード 0x01 → ウォッチが鳴動/振動(+ ACK 005D 0003)。FFFF 906A)✅ — ビッグエンディアン、経度が先:16バイト
ts(u32 BE) ‖ lon×1e7(i32 BE) ‖ lat×1e7(i32 BE) ‖ 00 00。実際の位置に対して検証済み。FFFF A05A)✅ — リトルエンディアン、経度が先:12バイト
ts(i32) ‖ lon×1e7(i32) ‖ lat×1e7(i32)。FFFF 8004):§7 を参照。| cf | bpp | ラスター(LZ4後) | 用途 |
|---|
| 4 | 2 | RGB565-LE | 不透明な背景(FULL/THUMB) |
| 5 | 3 | RGB565-LE(2 B)+アルファ(1 B)/px | アンチエイリアススプライト(グリフ、針、アイコン) |
| 13 (0x0d) | 0.5 | 4ビットアルファマスク;ファームウェアが実行時に着色 | 数字グリフアトラス |
| 24 (0x18) | 4 | RGBA8888 | フルカラーレイヤー(常時オンのaodImageを含む) |
| 1 | — | JPEG/JFIF(ff d8 ff)、任意のデコーダーで抽出 | まれなアニメーションフレーム |
0a0x61NotEnvelope0aChildOverflow:61 0a 00−18/−160a.bin0x68 group
nodes stacked at the same (x,y), each drawn only when a visibility condition matches — that much
was right. But the per-slot metric list (275's 0x1c/0x6a/0x48/0x24/0x19/0x76) is exactly the
metric list, not "option/style ids"; the default active index is a byte in the file; and the
0x79/0x7a byte is not an "instance byte" but the id the alternates are keyed on
(0x79 + slotIndex). A static preview can reproduce the file's default. See §11.13.(446,0), seen on 275/302/325/365/375)
are slots the firmware doesn't draw in the default view — their value can't even fit before the
canvas edge. Treat as hidden in the preview.0x22aod0x22@69,2090x60 img_number (cnt=10) — source at −5, off-by-one forward. ✅ Same off-by-one
as §11.8 but for non-clock numbers: "Gradient"'s date sat at (203,80) top-center with source
0x17, but the forward 82-scan grabbed the neighbouring pointer's angle getter (0x0a) and
the pointer's position → the number rendered at the pointer's spot with a bogus source. Fix: for a
61 0a 00 img_number in a 0x60 wrapper, trust −5/−18/−16 when the forward source is
impossible for a number (source-0 or a pointer-angle getter 0x0a/0e/12/70/71/72) and the
−18/−16 position is valid & non-zero (the non-zero guard skips group-child digits with relX=0).0x17 = date (day of month), 0x24 = temperature — distinct. Dial 340 uses both (0x17
"Jun 09" and a separate 0x24 temp), so 0x17 is date, not temp. A dial whose watch shows a
temperature in a 0x17 slot is a user-configured complication (device state), not the file default.| tag | role | container? | body |
|---|
0x20 | scene root (body wrapper, not a drawable) | ✅ | children |
0x21 | normal screen | ✅ | children |
0x22 | AOD screen (§11.9) | ✅ | children |
0x28 | embedded catalog preview thumbnail | ✅ | one 0x08 child |
0x68 | group / auto-layout container | ✅ | 0x48 frame + children |
0x30 | static image, or pick-by-value from N images | ✅ | 0x01 (+0x02) |
0x60 | live numeric readout (digit strip) | ✅ | 0x01 + 0x40 (+0x02) |
0x70 | rotating hand | ✅ | 0x01 + 0x05 pivot |
0x80 | progress ring, procedural | ✅ | 0x01 + 0x5a (§11.15) |
0x81 | progress ring, image-clipped | ✅ | 0x01 + 0x5b (§11.15) |
0x85 | user-assignable complication slot | ✅ | 0x01 + 0x5f (§11.13) |
0x01 | struct — geometry + attributes (below) | — | x,y,meta[14] + ref tail |
0x02 | visibility condition (§11.12) | — | condition list |
0x05 | pivot — flag u8, pivotX u16, pivotY u16 | — | 5 B |
0x08 | pvStruct — prefix[5] + ref tail, no x/y (preview only) | — | — |
0x40 | digit count / zero-pad flag (§11.10) | — | 1 B |
0x48 | frame — x,y,w,h,gap,align auto-layout row/column | — | — |
0x5a / 0x5b | arc spec for 0x80 / 0x81 (§11.15) | — | 19 B / 17 B |
0x5f | slot metric list for 0x85 (§11.13) | — | — |
0x86 | display-name node, always exactly 64 bytes, NUL-terminated, not drawn | — | 64 B |
| ダイヤル | スロット | 数 | activeIdx | メトリクスID | → アクティブ |
|---|
| 275 SlopeTime | 0 | 6 | 0 | 1c 6a 48 24 19 76 | 0x1c カロリー |
| 275 SlopeTime | 1 | 6 | 4 | 1c 6a 48 24 19 76 | 0x19 歩数 |
| 368 Function | 0 | 8 | 0 | 5f 1c 19 48 24 76 1a 8b | 0x5f 温度 |
| 368 Function | 1 | 8 | 6 | 5f 1c 19 48 24 76 1a 8b | 0x1a 心拍数 |
| 273 Activity Mood | 0 | 4 | 0 | 1c 24 48 6a | 0x1c カロリー |
| 304 Elaborate 2 | 0/1 | 4 | 0 | 1c 48 6a 24 / 24 1c 6a 48 | 0x1c / 0x24 |