
CMF Watch Pro 2용 역설계된 BLE 프로토콜로, GATT 레이아웃, AES-128-CBC 암호화 명령 프레임, 인증 핸드셰이크 및 대체 컴패니언 앱 개발을 위한 건강 데이터 동기화를 문서화합니다.
비공식. 이 문서는 CMF Watch Pro 2 (CMF by Nothing)의 Bluetooth Low Energy (BLE) 프로토콜을 대체 컴패니언 앱을 위해 리버스 엔지니어링으로 재구성한 것입니다. Nothing/CMF와 제휴하거나 보증하지 않습니다. 사용에 따른 책임은 본인에게 있습니다.
프레임 헤더와 opcode의 모든 멀티바이트 정수는 빅엔디언입니다. 명령 페이로드 내부의 정수는
별도로 명시되지 않는 한 리틀엔디언입니다(이는 기기 펌웨어를 반영함) — 예외(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…)은 명령 채널의 제어 opcode로
조정되는 대용량 바이너리 블롭(워치페이스, 펌웨어, 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의 발견, §출처 참조). 특성 UUID는 변경되지 않습니다.77d4e67c가 기기 간에 안정적인지 확인되지 않았습니다 — 하드코딩보다는 열거하세요.
🌐 Web Bluetooth 참고. Chromium은 필터링되지 않은
getPrimaryServices()호출에서도 페이지가optionalServices에 나열한 서비스만 발견합니다 — 3개의 서비스를 나열한 페이지는 3개만 보는 반면,chrome://bluetooth-internals(Chrome 자체 C++ 레이어, 범위 제한 없음)는 10개 모두를 표시합니다. 브라우저 클라이언트를 작성하는 경우 위의 모든 UUID를 미리 나열해야 합니다. 그렇지 않으면 분명히 존재하는 서비스로 페어링이 실패합니다. Firefox/Safari에는 Web Bluetooth가 없습니다. 사용자 제스처 + HTTPS/localhost가 필요합니다.
✅ 전체 실제 세션이 단일 명령 채널에서 실행되었습니다 — 160초의 과도한 사용 캡처 동안 명시적인 OTA/워치페이스 전송 중을 제외하고 데이터/펌웨어 또는 셸 채널에서 트래픽이 없었습니다.
0xF5)모든 명령 채널 메시지는 하나 이상의 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`는 함께 **opcode**를 구성합니다(§6 참조). 🔎 공식 앱의 프레임 빌더(`C6117b.m30831g`)에서 확인됨.
- `chunkCount` = 이 명령의 총 청크 수; `chunkIndex`는 **1부터 시작**합니다.
- `chunkLen` = 이 프레임에서 `chunk`의 바이트 수.
- 단일 BLE 쓰기는 링크 MTU에 의해 분할될 수 있습니다. 수신기는 원시 바이트를 버퍼링하고 완전한 프레임을 다시 추출합니다. 대용량 페이로드는 여러 청크(동일한 `cmd1/cmd2`, 증가하는 `chunkIndex`)로 분할되어 순서대로 재조립됩니다.
### Opcode 규칙 (✅ 와이어에서 확인됨)
- `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`가 됩니다.
**평문 특이점:** 평문 opcode의 경우 워치가 `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** with **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** over the concatenation of parts.
키 파생:```
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 응답에서 온 바이트.키가 설정된 후, 모든 명령 채널 프레임은 AES로 암호화되며, §5에 나열된 평문 opcode는 예외입니다.
✅ 두 파생 방식 모두 검증됨: 루팅된 휴대폰의 ntwatch.db에서 복구한 authkey가 캡처된 rnd1/rnd2/secret에서 파생된 값과 일치했고, 캡처된 nonce에서 재현한 sessionKey가 실시간 프레임을 복호화합니다.
두 진입 경로는 동일한 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없이 세 개의 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는 초기화 → 청크 요청/청크 쓰기 루프 → 완료 확인 흐름을 사용합니다:
(모두 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바이트 레코드) ✅하나의 SLEEP_DATA = 하나의 수면 세션; 밤에는 여러 개가 포함될 수 있습니다(미세 각성으로 세션 분할).
헤더:
각 8바이트 레코드: timestamp(u32) ‖ duration_s(u16) ‖ stage(u16).
단계 코드: 1 = 깊은 수면, 2 = 코어/가벼운 수면, 3 = REM, 4 = 각성. ✅ 전체 밤(두 세션,
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, 유산소/무산소, recoveryTime, VO₂max, 케이던스, PAI, 최고 러닝
기록…)을 더한 최신 레이아웃입니다. 필드 집합은 알려져 있지만(앱의 Room DB에서) 해당
40바이트 블록 내 정확한 바이트 오프셋은 ⚠️ [불확실] 합니다 — 이를 확정하려면 GPS 운동의 원시
캡처 하나가 필요합니다.
문자열은 UTF-8이며, 필드 크기로 바이트 단위 잘림(잘림이 멀티바이트 문자를 분할할 수 있음,
펌웨어의 s.encode()[:max] 동작과 일치); 짧은 필드는 오른쪽에 0으로 패딩됩니다.
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의 초기화 → 청크 루프를 통해 데이터 채널로 전송됩니다.
실제로 동작하는 것(✅ 실기기 검증): 모든 이미지에서 사진 다이얼을 만들어 설치; 103개 스토어 다이얼을 오프라인으로 설치; 구조화 다이얼 리스킨(배경 또는 비배경 스프라이트 교체) 및 레이어 이동; 활성 페이스 재정렬/전환; 그리고 처음부터 구조화 다이얼 제작 —
0x20씬 봉투가 디코딩되고 빌더가 구현됨(§11.7), 오프라인으로 103개 스토어 다이얼을 바이트 단위로 왕복하고 펌웨어 자체 검증기를 통과하는 합성 컨테이너를 생성함이 입증됨. 🟡 유일하게 입증되지 않은 단계는 처음부터 만든 합성 렌더링을9075를 통해 기기에서 보는 것입니다(구조적 오프라인 증명이 이미0a거부를 유발하던 것을 다룹니다). 코덱이나 전송 장벽이 없고 공급업체 툴체인이 필요 없습니다. 이전의 "구조화 렌더링은 RES-pack에 구워져 있어 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`가 **프레임당 BLE 쓰기 하나**로 전송되어야 합니다. 연결(concatenating) 후 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 블록 over 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 상하단 순서로 변환한 뒤,
선택적으로 원 밖의 픽셀을 0으로 설정하고, 각각 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])
검증됨: 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개 자산 모두가 w·h·bpp에서 표준 lz4.block
압축 해제기로 정확히 디코딩됩니다. 투명도는 알파 바이트(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` 레코드 스키마는 체계적으로
> OFF-BY-ONE이었습니다.** 씬 본문은 깔끔한 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` = 에셋 포인터; `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`
> 오라클(공식 스토어 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°`). ✅ getter 디스어셈블리로 확인됨(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개 항목 getter 테이블을 통해 id를 해석합니다(각각 `ux2sys_get(type)` 호출).
⚠️ **id를 이 전방 `82`-attr 스캔보다 구조체의 `meta[9]`(§11.11)로 읽는 것을 선호** — 이 스캔은
§11.8/§11.9에 설명된 off-by-one 소스입니다. 전체 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: 배경→청록 + 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 재사용; ref-tail 블록 크기 체인을 수정하지 않고 **에셋 재정렬**(§11.11). 오늘은 치명적이지 않지만
어쨌든 올바르게 작성하세요: 씬 또는 에셋 풀에 대한 변경은 두 헤더 **CRC32** 워드를 무효화합니다 —
둘 다 다시 계산하세요(§11.4), `@0x00` 전에 `@0x20`.
### 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
장면은 깔끔한 중첩 TLV — [tag u8][len u16 LE][body], 컨테이너 태그 0x20/0x21/0x22/0x68
재귀, 리프 드로어블 0x30 (정적) / 0x70 (요소/포인터) / 0x80 / 0x81 / 0x86 (이름).
(플랫 61 01 00 / 61 0a 00 레코드는 드로어블 본문 내부에 존재하는 패턴입니다. 기존
파서는 이를 휴리스틱하게 찾았고 — 인접한 본문을 서로 이어붙였습니다. §11.5 개정 참조.
드로어블 본문 레이아웃은 이제 완전히 디코딩되었습니다.) 모든 자식의 offset+len은 부모의
창 안에 맞아야 합니다.
첫 번째 본문 바이트 ≠ 0x20 → 파서 오류 −16; 자식이 창을 초과하면 → −2; 둘 중 하나라도 발생하면
9065 핸들러 (0xeb50c)가 종료 0a를 기록합니다.
빌더는 구현되었고 오프라인 검증 완료 (core-rust/watchface_struct.rs:
SceneNode / serialize / parse_scene / validate_container / build_container /
build_container_raw; CLI cmfwatch-wfgen reframe):
scene_roundtrip_identity — 모든 103개 스토어 다이얼: parse_scene→serialize가 장면을
바이트 단위로 그대로 재현하며 (재계산된 중첩 len 일치) validate_container가 모든 항목에서 통과합니다.build_reframe_identity / CLI reframe — 전체 .bin을 처음부터 재조립하여 파일을
바이트 단위로 재현하되 이름 패딩 바이트 1개 제외 (@0x17; 체크섬 아님).build_container_synthetic — 새로운 다이얼 (배경 + 20→21에 중첩된 드로어블)을 구성하여
펌웨어의 정확한 불변식을 통과합니다 (build_container가 올바른 중첩 창을 생성).validate_rejects_bad_containers — 플랫 본문 (→ , 역사적
버그)과 창을 초과하는 자식 (→ )을 거부합니다.🟡 아직 입증되지 않음 (워치 필요, 비차단): 9075를 통해 처음부터 만든 합성물을 업로드하고
렌더링을 확인 — 오프라인 구조 증명은 이미 0a 거부를 유발한 원인을 다룹니다.
wfweb 렌더링을 공식 스토어 썸네일과 교차 참조하여 (모든 103개 다이얼에 대한 픽셀 오라클) 네 가지 차이를 해결했습니다:
i16 (부호 있음). ✅ 앵커는 캔버스 밖으로 확장되는 요소에 대해
음수일 수 있습니다 — 예: 275의 빨간 초침은 Y = 0xFFFC = −4에 위치 (30×281 스프라이트,
소스 0x12, 상단 가장자리에서 중심 기준 회전). X/Y를 u16 (65532)로 읽으면 가드가 이를
버렸습니다. 둘 다 부호 있는 값으로 파싱하고 작은 음수 범위를 허용합니다.0x60 img_numbers일 수 있습니다 (0x68 그룹 내부뿐만 아니라),
그리고 실제 데이터 소스는 레코드 오프셋 −5의 u8입니다 — 전방 82-속성 스캔은 여기서
체계적으로 1만큼 어긋나며 다음 형제의 속성을 가져옵니다 (275에서 분 숫자가 요일 0x18을
가져옴). 275의 "10:10" = 시 0x07@X≈306 + 분 0x0b@X≈369, 그 사이에 :가 인접 정적으로
있으며, 각각 11글리프 아틀라스 ()입니다. ⚠️ X/Y를 에서 수정할 때 , 그렇지 않으면 재내보내기가 해당 바이트를 손상시킵니다 (동일
풋프린트 위반 → ).또한: 공식 스토어 썸네일은 10:10에 렌더링됩니다 (클래식 마케팅 시간), 10:12가 아님 — 오라클 시간을 10:10으로 맞추면 평균 픽셀 차이가 눈에 띄게 줄어듭니다. wfweb의 파서는 이제 모든 103개 다이얼을 바이트 단위로 정확히 왕복합니다 (위의 X/Y 쓰기 오프셋 수정이 마지막 불일치를 해결했습니다).
0x22 AOD 컨테이너를 별도 보기로 분리. ✅ 장면 워커는 이미 0x22를 건너뛰지만,
플랫 텍스트/숫자 스캔은 전체 [0x30, firstAsset)을 탐색했습니다 — 그래서 각 요소의
항상 켜짐 (AOD) 변형을 일반 레이어로 출력했습니다. "Gradient"에서 AOD 회색 날짜
아틀라스 (오프셋 0x22 내)가 빨간색 일반 것 위에 그려졌습니다. 수정: 모든 0x22 레코드에
layer.aod=true 태그를 지정하고 (자체 중복 제거 세트 포함) renderAt(…, aod)가 AOD 모드에서만
표시하도록 합니다 (일반 모드는 aod 레이어를 숨김; AOD 모드는 일반 레이어를 숨김; 배경은
setAod로 교체되며 항상 그려짐). 코퍼스 전체의 일반 모드 오라클 순이득 (284: 31%→21%, +18개
기타) — AOD 변형이 많은 다이얼을 과도하게 그렸습니다 — 그리고 편집기의 AOD 토글은 이제 실제
항상 켜짐 레이아웃을 일반 레이어 대신 표시합니다. 실제 AOD는 검은색 화면입니다 (어두워진
장면 없음): 다이얼에 전용 AOD 배경 프레임 (dial.aod)이 없으면 일반 장면은 AOD 모드에서
숨겨져 검은색 + 0x22 요소가 자체 색상으로 렌더링됩니다. AOD 바늘도 장면 워커를 통해
파싱됩니다 (이제 0x22 컨테이너를 재귀하여 드로어블에 태그를 지정하고, 피벗이 일치하지
않아 "위치 미지정"이 되는 플랫 스캔에 남기지 않음); AOD 바늘은 캔버스 중심에서 회전합니다
(는 때때로 펌웨어가 무시하는 중심 밖 바늘 x/y를 포함 — 예: Gradient의 시 ).
편집기는 이를 (§UI)로도 노출합니다: 각 화면은 자체 레이어만
표시하고 편집은 독립적으로 유지됩니다. 일반 모드 렌더링은 전체에서 바이트 동일; 왕복은 모든
103개 다이얼에서 바이트 단위로 정확합니다.40 01 00 XX 바이트 (✅ 펌웨어 확인)img_number가 그리는 자릿수는 필드 레코드의 단일 바이트입니다 — 요소의 40 01 00 XX
속성 하위 레코드의 데이터 바이트 XX (61 [count][base][glyph-ids] 프레임 테이블 뒤에 위치한
0x40 하위 레코드):
XX & 0x0F = 숫자 슬롯 수 (0 ⇒ 펌웨어 기본값 7).0x80 = 제로 패딩 (선행 0 표시, 예: "09" vs "9").펌웨어 디스어셈블리로 확인됨 (XIP 이미지 0x10000000; 렌더 루틴 0x100d8e60):
NDIG = ldrb[40sub+3] & 0x0F (0이면 →7); 값은 value % 10^NDIG로 클램프되고 정확히 NDIG
글리프가 MS 우선으로 그려지며, 비트7이 아니면 선행 0이 억제됩니다. 소스 뒤의 u16 (날짜 60,
kcal 1000)은 카운트가 아닙니다 — 천/백만 구분자 글리프 삽입 (cmp #1000/#1000000)에만
사용되며, 편집해도 아무 효과가 없는 이유입니다. 소스 id도 상한을 두지 않습니다.
모든 620개 숫자 필드에 대한 코퍼스 히스토그램이 일치합니다: 2자리 필드 (시/분/초/날짜/온도/HR)는
40 01 00 02/0x82로 끝남; kcal …04; steps …05; 한 자리 시계 분할 0x81. 따라서 날짜
필드 40 01 00 82 = 2자리, 제로 패딩 — 이것이 화씨 온도 (≥100)가 잘린 전체 이유입니다.
수정 / 편집기: wfweb은 숫자 필드에 대해 digitCount/digitZeroPad (+digitCountOff)를
파싱하고, 인스펙터에 "Digits" + **"Zero-pad"**를 노출하며, 바이트를 제자리 (동일
풋프린트) 에 기록하고, 미리보기는 펌웨어를 반영하도록 digitCount로 클램프/패딩합니다. 따라서
필드의 소스를 다시 바인딩하고 자릿수를 설정하는 것은 모든 필드에서 작동합니다 (예:
날짜→온도 °F → Digits 3). 일반 모드 오라클 변경 없음 (0 회귀, 3개 사소한 개선); 왕복은 모든
103개 다이얼에서 바이트 단위로 정확합니다. (이전 "Digits width"/rectW 가설은 틀렸습니다 —
너비는 레이아웃 전용이며 카운트가 아닙니다.)
struct 레코드, 리소스 참조 꼬리 ✅장면 (§11.7)은 깔끔한 중첩 TLV — [tag u8][len u16 LE][body]입니다. 코퍼스에서 관찰된 완전한
태그 인벤토리:
✅ 이 인벤토리는 코퍼스에 대해 완전합니다. 위의 컨테이너 태그로만 재귀하면, 확인된 15개
다이얼의 장면 TLV는 정확히 선언된 루트 길이까지 탐색되며 알 수 없는 태그가 0개입니다 —
따라서 이 테이블을 처리하는 파서는 전체 형식을 처리하며, 알 수 없는 태그는 새 노드 유형이 아닌
정렬 오류를 의미합니다. (주의: 길이가 ≥ 3인 모든 노드로 재귀하는 워커는 struct/0x5b 본문으로
내려가 일회성 "태그"의 긴 꼬리를 환각할 것입니다 — 리프 본문은 TLV가 아닙니다.)
💡 작성 단축키:
0x48/0x68자동 레이아웃은 완전히 건너뛸 수 있습니다 — 모든 위젯은 화면 최상위 수준에서 절대x,y로 배치할 수 있으며, 이것이 처음부터 빌더 (§11.7)가 하는 방식입니다. 기존 다이얼을 읽는 데만 필요합니다.meta너비0x8000은 struct를 프레임의 자동 레이아웃 자식으로 표시합니다 (위치는x,y가 아닌 부모에서 옴).
0x01 struct 본문 — 고정 18바이트 접두사 뒤에 선택적 리소스 참조 꼬리:```
+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만큼 어긋났던
> 순방향 스캔 `82`-속성 휴리스틱은 전혀 필요하지 않습니다. 구조체의 `meta[9]`를 읽으면 됩니다.
> 숫자 필드의 `max` 역시 단지 `meta[11..13]`입니다(예: 일(day-of-month) 필드는 `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개 항목 모두에 대해 정확히 검증됨). 두 가지 결과가 따릅니다:
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시간 및 미터법/야드파운드법 레이아웃** — 같은 위치에 두 개의 위젯 세트가 겹쳐 있으며, 각각 `id 0x73`(단위 플래그)에 `val` 0 또는 1로 바인딩됩니다. 다이얼 275에는 이러한 쌍이 6개 있습니다(12개 노드).
- **"데이터 없음" 자리 표시자** — 센티널 값에 대한 op `0x03`, 예: `id 0x5f, val 1000`(다이얼 275, 두 번): 측정값을 사용할 수 없을 때 온도 대신 em-dash 아트를 그립니다.
- **버킷 하이라이트** — 쌍을 이루는 `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의 두 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 기능 플래그 ✅일부 다이얼은 사용자가 기기에서 강조 색상을 선택할 수 있게 하며, 펌웨어는 렌더링 시점에 이를 위젯의 비트맵에 대체합니다. 스위치는 단일 바이트입니다: 구조체의 meta[7](§11.11) — 즉 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는 설정을 제공하지 않으며 플래그가 지정된 위젯이 0개입니다.
meta[4..6]은 플래그 바로 옆에 있으며 일부 구조체에서 색상을 인코딩할 수 있는 것처럼 보입니다(꼬리 f1=1,f2=255가 있는 실제처럼 보이는 RGB, 플래그가 지정된 구조체의 자리 표시자 (1,0,0)/(4,0,0)와 대조적).
이는 강조 기능과 상관관계가 없습니다. ⚠️ 미해결; 무시하세요.
0x80/0x5a(절차적) 및 0x81/0x5b(이미지 클리핑) ✅두 링 변형 모두 짧은 구조체(x, y, 소스 id가 있는 meta — 일반적으로 ref 꼬리가 전혀 없음)를 호 사양 형제와 짝지습니다. §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` 방향으로 그려진다.
각도 0은 **3시 방향**, 양수는 시계 방향. 측정된 예시:
| 다이얼 | 태그 | min..max | start → end | 폭 | radius |
|---|---|---|---|---|---|
| 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°)을 사용하므로, 항상 위에서부터 전체 원을 스윕하는 렌더러는 그것들을 잘못 그린다.
> 🟡 정확한 각도 0 관례와 방향 규칙은 fmc의 렌더러에서 비롯되었으며, 디스크의 이 값들과 교차 검증되었다 —
> 이 저장소에서 기기에서 픽셀 단위로 독립 검증되지는 않았다.
> `frac = value/max` 및 §11.5의 `0x81` 섹터 클립 결과는 **검증되었다** (다이얼 322).
⚠️ **미해결: 3바이트 트레일러 `01 00 kk`.** `kk`는 그럴듯한 값 (104, 152, 216,
232, 248)을 취하며, 명백한 가설은 radius이다 — **테스트 및 반증됨**: 다이얼 366은 82×82 및 166×166 링
모두에 `kk = 104`를 사용하고, 다이얼 273은 440×440 및 284×284 링 모두에 `kk = 232`를 사용한다.
radius도, 지름도 아니다. 아마도 불투명도/스타일. 그대로 왕복 전송하라.
🟡 **보고된 함정, 여기서는 검증되지 않음:** fmc의 렌더러에서, 소스 id가 같은 화면의 **임의의** 링의 id와
같은 `0x60` 숫자는 원시 값 대신 `round(frac × 100)`을 렌더링한다 — 따라서
심박수 링 옆의 심박수 숫자는 `71 bpm` 대신 `36`을 표시한다. 그들의 문서화된
해결 방법은 링과 숫자를 동일한 메트릭의 두 **별칭** id에 배치하는 것이다 (걸음
`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 × 이름[32]+번호[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. 데이터 소스 / 컴플리케이션 getter id
BLE 클라이언트를 구축하는 데는 필요하지 않지만, 다이얼을 **작성하거나 렌더링하는 데 필수적**이다: 이것은
위젯을 실시간 데이터에 바인딩하는 `meta[9]` (§11.11)의 값, 가시성 조건의 `id` (§11.12), 그리고
슬롯의 메트릭 메뉴 항목 (§11.13)이다. 펌웨어는 `0x101f371c`의 **142개 항목 getter
디스패치 테이블**을 통해 이를 해석하며, 각 항목은 `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는 **단일 숫자**를 그린다 — 그 중 하나에 바인딩된 위젯은 전체 값이 아닌 하나의
글리프를 렌더링한다 (다이얼 284 Square). 바늘 각도 id (§11.5)는 `0x0a`/`0x70` 시 = `h·30° + m·0.5°`,
`0x0e`/`0x71` 분 = `m·6° + s·0.1°`, `0x12`/`0x72` 초 = `s·6°` (✅ getter 디스어셈블로 확인됨,
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 |
> ⚠️ **이 저장소의 세 테이블이 불일치했다; 이것이 조정 결과다.** §16 및 §11.5의 이전 개정판은
> `0x19`를 심박수, `0x1b`를 배터리, `0x24`를 온도, `0x36`을 걸음으로 읽었으며 —
> `wfweb/src/codec/mock.ts`는 여전히 그 해석을 인코딩하는 반면, `wfweb/src/codec/parse.ts`는
> 다른 해석 (`0x19` 걸음, `0x24` 목표 %, `0x48` 스탠드, `0x1a` 날씨)을 인코딩한다. 위 테이블은
> 더 나은 증거를 가진 해석을 따른다 (라벨은 fmc가 동반 앱의 자체 위젯 슬롯 메뉴 아이콘에 대해 보정함,
> §출처 참조). **코드는 아직 변경되지 않았다 — `mock.ts`와 `parse.ts`는 여전히 서로 및 이 테이블과
> 불일치한다.** 누군가 각 id에 필드를 바인딩하고 시계를 읽을 때까지 건강 id 라벨을 ⚠️로 취급하라.
>
> 가장 강력한 단일 증거는 **다이얼 368 Function의 슬롯 메뉴** (§11.13)로, 하나의 메뉴에서
> `0x5f 0x1c 0x19 0x48 0x24 0x76 0x1a 0x8b`를 **8개의 서로 다른 사용자 선택 가능 메트릭**으로 제공한다.
> 라벨이 무엇이든, 그 8개 중 두 개는 동일한 메트릭일 수 없다 — 이는
> `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 × 그룹`, 그룹 = `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` = Sport 카드, sportId 2).
**슬롯:** 각 화면에는 **4개의 슬롯**이 있다. 카드의 유형이 크기를 결정한다 — `circular`/`square` = 1 슬롯,
`rectangle` = 2 슬롯. 검증은 순수 슬롯 산술이다 (화면당 Σ ≤ 4); 상호 배타적 카드는 없다.
`sportId`는 Sport 카드 (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 | Sport (`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 | ✅ 보급률 재검증됨; 문서화되지 않았었음 |
| 전체 아크 사양 (스윕 각도, 폭, radius) | §11.15 | ✅ 재검증됨; **"`max` u16 `@+4`" 수정** |
| Ref-꼬리 `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 | 타임스탬프(epoch 초) |
| 4 | 4 | 걸음 수 |
| 8 | 4 | 거리(m) |
| 12 | 4 | 칼로리 |
| 16 | 16 | 예약됨(0으로 관찰됨) |
| 오프셋 | 크기 | 필드 |
|---|
| 0 | 4 | 세션 시작(epoch, UTC) |
| 4 | 4 | 기상(epoch, 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) 픽셀당 | 앤티앨리어싱 스프라이트(글리프, 바늘, 아이콘) |
| 13 (0x0d) | 0.5 | 4비트 알파 마스크; 펌웨어가 런타임에 틴트 적용 | 숫자-글리프 아틀라스 |
| 24 (0x18) | 4 | RGBA8888 | 풀컬러 레이어(항상 켜져 있는 aodImage 포함) |
| 1 | — | JPEG/JFIF(ff d8 ff), 아무 디코더로나 추출 | 드문 애니메이션 프레임 |
0x61NotEnvelope0aChildOverflow61 0a 00−18/−160a.bin에 없음(x,y)에 쌓인 N개의 0x68 그룹
노드로 작성되며, 각각 가시성 조건이 일치할 때만 그려집니다 — 그 부분은 맞았습니다. 그러나
슬롯별 메트릭 목록 (275의 0x1c/0x6a/0x48/0x24/0x19/0x76)은 정확히 메트릭 목록이며,
"옵션/스타일 id"가 아닙니다; 기본 활성 인덱스는 파일의 바이트입니다; 그리고 0x79/0x7a
바이트는 "인스턴스 바이트"가 아니라 대체 항목이 키로 사용하는 id입니다 (0x79 + slotIndex).
정적 미리보기는 파일의 기본값을 재현할 수 있습니다. §11.13 참조.(446,0)의 bpm 텍스트, 275/302/325/365/375에서
확인)은 펌웨어가 기본 보기에서 그리지 않는 슬롯입니다 — 그 값은 캔버스 가장자리 앞에 맞지
않습니다. 미리보기에서 숨김으로 처리합니다.aod0x22@69,2090x60 img_number (cnt=10) — 소스 −5, 전방 오프바이원. ✅ §11.8과 동일한
오프바이원이지만 시계 숫자가 아닌 경우: "Gradient"의 날짜는 소스 0x17과 함께 (203,80)
상단 중앙에 있었지만, 전방 82-스캔은 이웃 포인터의 각도 게터 (0x0a)와 포인터의 위치를
가져왔습니다 → 숫자가 잘못된 소스로 포인터 위치에 렌더링되었습니다. 수정: 0x60 래퍼의
61 0a 00 img_number에 대해, 전방 소스가 숫자에 불가능한 경우 (소스-0 또는 포인터 각도 게터
0x0a/0e/12/70/71/72) 그리고 −18/−16 위치가 유효하고 0이 아닌 경우 −5/−18/−16을
신뢰합니다 (0이 아닌 가드는 relX=0인 그룹 자식 숫자를 건너뜁니다).0x17 = 날짜 (월의 일), 0x24 = 온도 — 구별됨. 다이얼 340은 둘 다 사용합니다 (0x17
"Jun 09" 및 별도의 0x24 온도), 따라서 0x17은 날짜이며 온도가 아닙니다. 0x17 슬롯에
온도를 표시하는 다이얼은 사용자 구성 컴플리케이션 (장치 상태)이며 파일 기본값이 아닙니다.| 태그 | 역할 | 컨테이너? | 본문 |
|---|
0x20 | 장면 루트 (본문 래퍼, 드로어블 아님) | ✅ | 자식 |
0x21 | 일반 화면 | ✅ | 자식 |
0x22 | AOD 화면 (§11.9) | ✅ | 자식 |
0x28 | 임베디드 카탈로그 미리보기 썸네일 | ✅ | 하나의 0x08 자식 |
0x68 | 그룹 / 자동 레이아웃 컨테이너 | ✅ | 0x48 프레임 + 자식 |
0x30 | 정적 이미지, 또는 N개 이미지에서 값별 선택 | ✅ | 0x01 (+0x02) |
0x60 | 실시간 숫자 판독 (숫자 스트립) | ✅ | 0x01 + 0x40 (+0x02) |
0x70 | 회전 바늘 | ✅ | 0x01 + 0x05 피벗 |
0x80 | 진행 링, 절차적 | ✅ | 0x01 + 0x5a (§11.15) |
0x81 | 진행 링, 이미지 클리핑 | ✅ | 0x01 + 0x5b (§11.15) |
0x85 | 사용자 할당 가능 컴플리케이션 슬롯 | ✅ | 0x01 + 0x5f (§11.13) |
0x01 | struct — 지오메트리 + 속성 (아래) | — | x,y,meta[14] + 참조 꼬리 |
0x02 | 가시성 조건 (§11.12) | — | 조건 목록 |
0x05 | 피벗 — flag u8, pivotX u16, pivotY u16 | — | 5 B |
0x08 | pvStruct — prefix[5] + 참조 꼬리, x/y 없음 (미리보기 전용) | — | — |
0x40 | 자릿수 / 제로 패딩 플래그 (§11.10) | — | 1 B |
0x48 | 프레임 — x,y,w,h,gap,align 자동 레이아웃 행/열 | — | — |
0x5a / 0x5b | 0x80 / 0x81용 호 사양 (§11.15) | — | 19 B / 17 B |
0x5f | 0x85용 슬롯 메트릭 목록 (§11.13) | — | — |
0x86 | 표시 이름 노드, 항상 정확히 64바이트, NUL 종료, 그려지지 않음 | — | 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 |