Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
CMF-Watch-Pro-2-BLE-Protocol — CMF Watch Pro 2용 역설계된 BLE 프로토콜로, GATT 레이아웃, AES-128-CBC 암호화 명령 프레임, 인증 핸드셰이크 및 대체 컴패니언 앱 개발을 위한 건강 데이터 동기화를 문서화합니다. | Kitploit
도구/GitHubGitHub/joshuapassos/cmf-watch-pro-2-ble-protocol
Embedded Systems SecurityBluetooth SecurityIoT SecurityReverse EngineeringWireless SecurityCryptographyMobile SecurityHardware & IoT SecurityFirmware Analysis

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유
GitHubjoshuapassos/cmf-watch-pro-2-ble-protocol

CMF-Watch-Pro-2-BLE-Protocol

CMF Watch Pro 2용 역설계된 BLE 프로토콜로, GATT 레이아웃, AES-128-CBC 암호화 명령 프레임, 인증 핸드셰이크 및 대체 컴패니언 앱 개발을 위한 건강 데이터 동기화를 문서화합니다.

저장소 보기웹사이트
3281개월 전아직 검토되지 않음

CMF Watch Pro 2 — BLE 프로토콜 (리버스 엔지니어링)

비공식. 이 문서는 CMF Watch Pro 2 (CMF by Nothing)의 Bluetooth Low Energy (BLE) 프로토콜을 대체 컴패니언 앱을 위해 리버스 엔지니어링으로 재구성한 것입니다. Nothing/CMF와 제휴하거나 보증하지 않습니다. 사용에 따른 책임은 본인에게 있습니다.

프레임 헤더와 opcode의 모든 멀티바이트 정수는 빅엔디언입니다. 명령 페이로드 내부의 정수는 별도로 명시되지 않는 한 리틀엔디언입니다(이는 기기 펌웨어를 반영함) — 예외(GOALS_SET, GPS_PUSH, 대량 전송 오프셋/길이)에 주의하세요.

신뢰도 표시

아래의 모든 명확하지 않은 주장에는 확립 방법이 태그로 표시됩니다:

  • ✅ 기기에서 검증됨 — 복호화된 실시간 캡처에서 관찰되었거나 실제 시계에 대해 테스트됨.
  • 🔎 펌웨어 / APK RE에서 확인 — 펌웨어(1.0.0.73) 또는 공식 APK(3.5.7)를 디컴파일하여 추출됨; 코드와 일치하지만 런타임 테스트는 아님.
  • 🟡 부분적으로 입증됨 — 구조적으로 확립되었지만(오프라인, 코퍼스 전체 또는 RE를 통해) 나머지 단계는 시계가 필요하며 아직 실행되지 않음.
  • ⚠️ [불확실] — 추론된 것이며 확인되지 않음; 틀릴 수 있음.

이후 섹션이 이전 섹션을 수정하는 경우, 이전 텍스트는 삭제하지 않고 포인터와 함께 유지됩니다 — 시도되고 반증된 판독값을 아는 것은 다음 사람이 같은 우회로를 피하게 해줍니다.

모든 캡처의 테스트 기기: CMF Watch Pro 2-5485, 펌웨어 1.0.0.73, 일련번호 CI04102520008192, MCU Actions ATS3089C (Cortex-M4), 화면 466×360.


1. GATT 레이아웃

휴대폰이 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/워치페이스 전송 중을 제외하고 데이터/펌웨어 또는 셸 채널에서 트래픽이 없었습니다.


2. 프레임 형식 (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 ____________________________/

root@kitploit:~
- `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가 실시간 프레임을 복호화합니다.


4. 인증 / 페어링 핸드셰이크

두 진입 경로는 동일한 nonce/confirm 꼬리 부분을 공유합니다.

4.1 최초 페어링(기기 비밀값 보유)```

phone → (shell) AT GETSECRET watch → (shell) GETSECRET:<32hex>,OK phone: rnd1 = random16 ; signed1 = SHA256(rnd1 ‖ secret) phone → AUTH_PAIR_REQUEST (plaintext) payload = rnd1(16) ‖ signed1(32) // 48 B watch → AUTH_PAIR_REPLY (plaintext) payload = rnd2(16) ‖ signed2(32) // 48 B phone verifies signed2 == SHA256(rnd2 ‖ secret) phone: authkey = SHA256(rnd1 ‖ rnd2 ‖ secret)[0..16] → set crypto key = authkey phone → AUTH_PHONE_NAME (encrypted) payload = 0xA5 ‖ model(UTF-8) // e.g. "CMF Watch Pro 2" watch → AUTH_WATCH_MAC (encrypted) phone → AUTH_NONCE_REQUEST (encrypted) payload = 0xA5 watch → AUTH_NONCE_REPLY (encrypted) payload = nonce phone: sessionKey = SHA256(nonce ‖ authkey)[0..16] → set crypto key = sessionKey phone → AUTHENTICATED_CONFIRM_REQUEST (encrypted) payload = 0xA5 watch → AUTHENTICATED_CONFIRM_REPLY (encrypted) → state = Initialized

root@kitploit:~
`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

✅ 재연결 순서(셸 트래픽 없음)가 실제 캡처에서 그대로 유지되는 것이 관찰되었습니다.

4.3 인증 후 초기화(2단계)

⚠️→✅ 데이터 조회 전에 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).

4.4 GET → SET 에코 패턴(✅)

대부분의 설정에는 별도의 "읽기" 명령 코드가 없습니다. *_GET(cmd2 = 0x0002, 페이로드 0xA5)을 전송하면 워치가 현재 값을 담은 SET 명령 코드(cmd2 = 0x0001)로 응답합니다. SET 명령은 cmd2 = 0x0003과 빈 본문으로 확인됩니다.


5. 평문 vs 암호화

키가 설정되면 프레임은 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가 필요합니다.


6. 명령 코드 참조 (cmd1, cmd2)

GET/SET/REQUEST = 폰→워치; RET/REPLY/ACK/RESPONSE/DATA = 워치→폰.

세션 / 기기

인증

알림 / 통화 / 찾기

음악

이름cmd1,cmd2
MUSIC_INFO_SET / _ACKFFFF 905C / FFFF A05C
MUSIC_BUTTONFFFF 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/908B ChatGPT 상태/지원)는 Java 레이어가 아닌 앱의 Hermes 바이트코드에서 처리됩니다. 해당 헤더는 캡처에 나타나지만 페이로드 의미는 ⚠️ [불확실] 합니다.

대량 데이터 전송(데이터 채널)

워치페이스 / 펌웨어 / AGPS는 초기화 → 청크 요청/청크 쓰기 루프 → 완료 확인 흐름을 사용합니다:

(모두 cmd1 = FFFF.) 워치가 DATA_CHUNK_REQUEST_*(offset, length)를 내보내며 루프를 주도합니다 (offset/length = u32 빅엔디언); 폰은 데이터 특성에 payload[offset..offset+length]를 담은 DATA_CHUNK_WRITE_*로 응답합니다. 자세한 내용은 §11–§12를 참조하세요.


7. 시간 및 시간대

TIME (FFFF 8004) 페이로드 = epochSeconds(i32, BE) ‖ utcOffsetMillis(i32, BE). 인증 직후 전송되어 워치가 현지 시간을 표시합니다(그리고 데이터 조회를 해제합니다 — §4.3 참조).

⚠️ 워치의 건강 타임스탬프는 UTC입니다. 동반 앱은 현지 달력 날짜/시간을 도출하기 전에 현지 UTC 오프셋을 더해야 합니다. (원시 UTC 날짜로 건강 데이터를 버킷팅하면 잘못된 현지 시간에 날짜가 넘어갑니다.)

TIME_FORMAT (005F 0001) 페이로드 = 1바이트: 00 = 24시간, 01 = 12시간.


8. 건강 동기화

  1. 폰이 ACTIVITY_FETCH_1을 전송; 워치가 ACTIVITY_FETCH_ACK_1로 응답(첫 바이트 01 ⇒ 준비됨).
  2. 폰이 ACTIVITY_FETCH_2를 전송; 워치가 데이터 프레임 버스트를 푸시: ACTIVITY_DATA, HEART_RATE_*, SPO2, STRESS, SLEEP_DATA, WORKOUT_SUMMARY[_V3].
  3. 각각은 분 단위 샘플/세션으로 파싱되고 현지 날짜별로 집계됩니다.

동기화는 순차적입니다(TIME을 따라야 하며, 워치는 ACK_2 후 스트림을 해제합니다), 단일 버스트가 아닙니다. 무거운 세션은 약 160초 동안 ~170–210개의 알림 프레임을 푸시합니다. ✅

8.1 활동 기록 — ACTIVITY_DATA (각 32바이트, LE) ✅

칼로리 단위: 활동 칼로리는 cal(그램-칼로리)로 보고됩니다. 일일 합계를 1000으로 나누어 kcal을 얻으세요. (운동 요약 칼로리는 대조적으로 이미 kcal입니다.)

8.2 HR / SpO₂ / 스트레스 샘플 ✅

  • 수동/자동 HR, 운동 HR, SpO₂, 스트레스 = 각각 8바이트: timestamp(i32 LE) ‖ value(i32 LE) (value = bpm / SpO₂ % / 스트레스 지수).
  • 휴식 HR (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.

8.3 수면 — SLEEP_DATA (18바이트 헤더 + N × 8바이트 레코드) ✅

하나의 SLEEP_DATA = 하나의 수면 세션; 밤에는 여러 개가 포함될 수 있습니다(미세 각성으로 세션 분할).

헤더:

각 8바이트 레코드: timestamp(u32) ‖ duration_s(u16) ‖ stage(u16). 단계 코드: 1 = 깊은 수면, 2 = 코어/가벼운 수면, 3 = REM, 4 = 각성. ✅ 전체 밤(두 세션, D/C/R/A 합계가 일치)에 대해 검증됨.

8.4 운동 요약 — 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 운동의 원시 캡처 하나가 필요합니다.


9. 선택된 명령 페이로드

문자열은 UTF-8이며, 필드 크기로 바이트 단위 잘림(잘림이 멀티바이트 문자를 분할할 수 있음, 펌웨어의 s.encode()[:max] 동작과 일치); 짧은 필드는 오른쪽에 0으로 패딩됩니다.

  • APP_NOTIFICATION (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.
  • BATTERY (005C 0001) ✅: 응답 = level(1) ‖ charging(1) (예: 3b 00 = 59 %, 충전 중 아님).
  • SERIAL_NUMBER_RET (00DE 0001) ✅: len(1) ‖ ASCII (예: 10 + "CI04102520008192").
  • USER_INFO (0095 0001) ✅: height_cm(1) ‖ weight_kg(1) ‖ age(1) ‖ gender(1: 1=M) (예: = 172 cm / 73 kg / 31 / 남성).

10. 구현 참고 사항 및 특이점

  • 코덱에 시스템 시계 없음: 인코더는 now/utc_offset을 명시적 매개변수로 받습니다 (결정적, 테스트 가능). 전송 계층이 실제 시간을 제공합니다.
  • TIME이 모든 것을 제어합니다(§4.3) — 먼저 전송하지 않으면 워치가 데이터 조회에 침묵합니다.
  • 평문 CRC 계산(§2)은 틀리기 쉽습니다 — 평문 프레임은 CRC를 광고하지만 생략합니다.
  • 엔디언: 헤더 + 명령 코드 BE; 페이로드 정수 LE; 예외 — GOALS_SET 및 GPS_PUSH는 빅엔디언이고, 대량 전송 오프셋/길이는 빅엔디언입니다.
  • MTU: 암호화된 청크가 16바이트 AES 블록에 정렬되도록 청크 크기가 계산됩니다.
  • authkey는 영속 가능(첫 페어링 후 저장); sessionKey는 연결별이며 재연결마다 워치 nonce에서 파생됩니다.

11. 워치페이스 / 다이얼 — 제작

워치는 (a) 사진/사용자 지정 다이얼(배경 이미지 + 펌웨어가 그리는 디지털 시계)과 (b) 구조화 다이얼(내장/스토어 페이스: 배경 + 위치 지정 스프라이트 레이어, 시계 바늘, 텍스트 위젯)을 지원합니다. 둘 다 §6의 초기화 → 청크 루프를 통해 데이터 채널로 전송됩니다.

실제로 동작하는 것(✅ 실기기 검증): 모든 이미지에서 사진 다이얼을 만들어 설치; 103개 스토어 다이얼을 오프라인으로 설치; 구조화 다이얼 리스킨(배경 또는 비배경 스프라이트 교체) 및 레이어 이동; 활성 페이스 재정렬/전환; 그리고 처음부터 구조화 다이얼 제작 — 0x20 씬 봉투가 디코딩되고 빌더가 구현됨(§11.7), 오프라인으로 103개 스토어 다이얼을 바이트 단위로 왕복하고 펌웨어 자체 검증기를 통과하는 합성 컨테이너를 생성함이 입증됨. 🟡 유일하게 입증되지 않은 단계는 처음부터 만든 합성 렌더링을 9075를 통해 기기에서 보는 것입니다(구조적 오프라인 증명이 이미 0a 거부를 유발하던 것을 다룹니다). 코덱이나 전송 장벽이 없고 공급업체 툴체인이 필요 없습니다. 이전의 "구조화 렌더링은 RES-pack에 구워져 있어 BLE로 불가능" 및 "cf=0x1f 서버측 코덱" 주장은 틀렸습니다(오프셋+바이트-퍼-픽셀 버그) — 펌웨어는 구조화 다이얼을 보내는 파일에서 데이터 기반으로 렌더링합니다.

11.1 다이얼 관리 — DIAL_COMMAND (9055 / a055) ✅

  • type 0 = 목록 조회. 응답 a055 = result(u8) ‖ selectIndex(u8) ‖ total(u8) ‖ max(u8) ‖ N × dialId(u32 LE) ‖ ffffffff. 예시: 01 05 06 07 … = 활성 #5, 다이얼 6개, 최대 7개.
  • type 1 = 재정렬 / 활성 선택: 대상 다이얼을 인덱스 0에 둔 전체 목록을 다시 전송 (이것이 공식 앱이 페이스를 전환하는 방식입니다; 전용 "활성 설정" 명령 코드는 없습니다).
  • 다이얼 삭제 = 해당 ID 없이 목록을 다시 전송.
  • CHANGE_DIAL (009F 0001)은 fw 1.0.0.73에서 무효(상수를 반환하고 전환하지 않음) — 사용하지 마세요.

11.2 전송 흐름 ✅```

INIT1 8052 (payload A5) → 0052 [0]=01 INIT2 9063 (photo, APPEND) | 9075 (structured, REPLACE) → A063 / A075 [0]=01 [ watch → DATA_CHUNK_REQUEST A064 (offset, length; u32 BE, +progress u8) phone → DATA_CHUNK_WRITE 9064 (bytes[offset..offset+length], plaintext) ] × N FINISH A065 → 9065 (payload A5)

root@kitploit:~
Finish reply byte: `01` = 활성화 및 저장됨; `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

root@kitploit:~
`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]

root@kitploit:~
> ⚠️ **정정(이전의 "차단 체크섬 없음"을 대체함).** 이전 개정판에서는 `@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)

root@kitploit:~
`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 거부를 유발한 원인을 다룹니다.

11.8 렌더링 충실도 개선 (2026-07-02, 다이얼 275 "SlopeTime")

wfweb 렌더링을 공식 스토어 썸네일과 교차 참조하여 (모든 103개 다이얼에 대한 픽셀 오라클) 네 가지 차이를 해결했습니다:

  • 드로어블/포인터 X/Y는 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 쓰기 오프셋 수정이 마지막 불일치를 해결했습니다).

11.9 AOD 컨테이너 건너뛰기 + 독립 img_number 소스 (2026-07-03, 다이얼 "Gradient")

  • 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개 다이얼에서 바이트 단위로 정확합니다.

11.10 img_number 자릿수 — 40 01 00 XX 바이트 (✅ 펌웨어 확인)

img_number가 그리는 자릿수는 필드 레코드의 단일 바이트입니다 — 요소의 40 01 00 XX 속성 하위 레코드의 데이터 바이트 XX (61 [count][base][glyph-ids] 프레임 테이블 뒤에 위치한 0x40 하위 레코드):

  • 낮은 니블 XX & 0x0F = 숫자 슬롯 수 (0 ⇒ 펌웨어 기본값 7).
  • 비트 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 가설은 틀렸습니다 — 너비는 레이아웃 전용이며 카운트가 아닙니다.)

11.11 노드 인벤토리, 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)

root@kitploit:~
> ✅ 이는 §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개 항목 모두에 대해 정확히 검증됨). 두 가지 결과가 따릅니다:

  • 노드가 참조하는 자산은 자산 풀에서 연속적이어야 합니다. 임의 접근은 없습니다 — 체인은 앞으로만 이동합니다. 각 숫자 집합(10), 각 선택 목록(N), 각 프레임 시트가 하나의 연속 블록이 되도록 풀을 계획하세요. 체인을 수정하지 않고 자산을 재정렬하는 작성자는 쓰레기(→ 검은 화면)를 읽는 파일을 생성합니다.
  • 처음 count−1개의 크기만 실제로 사용됩니다. 마지막 항목의 값은 절대 따라가지 않으므로, 실제 파일에는 마지막 항목에 오래된 값이 담겨 있는 경우가 있습니다. 마지막 항목의 불일치를 깨진 참조로 취급하지 마세요.

0x28 미리보기 — 상점/카탈로그 썸네일은 .bin 자체에 내장되어 있으며(15개 다이얼에 걸쳐 27개의 미리보기 노드), 0x08 pvStruct로 존재합니다: 5바이트 접두사에 동일한 참조 꼬리, x/y 없음. 별도의 PNG를 배포하지 않고 갤러리 UI를 구축하는 데 유용합니다.

11.12 가시성 조건 — 태그 0x02 ✅

위젯의 0x02 형제는 위젯을 조건부로 만듭니다. 이것이 없으면 위젯은 항상 그려집니다. 문법:``` count u8 , count × ( id u8 , op u8 , val u24 LE signed ) [5 bytes per entry]

root@kitploit:~
`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]은 펌웨어가 자체 편집 모드에서만 그리는 "탭하여 구성" 자리 표시자입니다 — 일반 시간 표시를 미리볼 때는 건너뛰세요.

11.14 강조 색상 — meta[7] == 4 기능 플래그 ✅

일부 다이얼은 사용자가 기기에서 강조 색상을 선택할 수 있게 하며, 펌웨어는 렌더링 시점에 이를 위젯의 비트맵에 대체합니다. 스위치는 단일 바이트입니다: 구조체의 meta[7](§11.11) — 즉 0x01 본문의 바이트 +0x0B — 4와 같으면 해당 위젯의 리소스가 색조 적용 가능으로 표시됩니다.

  • 이는 위젯별 기능 플래그이지 색상이 아닙니다. 플래그가 지정된 리소스의 모든 비투명 픽셀을 다시 색칠하세요(알파는 그대로 둠); 픽셀별 색상 테스트는 포함되지 않습니다.
  • 보급률: 여기서 측정한 499개 구조체 중 37개 및 15개 다이얼 중 8개; fmc 코퍼스는 100개 다이얼 중 56개가 플래그가 지정된 위젯을 하나 이상 보유한다고 보고합니다.
  • 🛑 내보낸 바이트에 강조 색상을 절대 굽지 마세요. 대체는 시계에서 실시간으로 이루어집니다; 배포된 .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)와 대조적). 이는 강조 기능과 상관관계가 없습니다. ⚠️ 미해결; 무시하세요.

11.15 진행 링 — 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)

root@kitploit:~
페어링은 고정적이다: `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-00805f9b34fb0000fff2-…Write
명령 알림0000fff0-…0000fff1-…Notify
셸 쓰기 (AT)—77d4ff01-2fe2-2334-0d35-9ccd078f529cWrite
셸 알림 (AT)—77d4ff02-…Notify
대량 데이터 쓰기—02f00000-0000-0000-0000-00000000ffe1Write
대량 데이터 알림—02f00000-…ffe2Notify
이름cmd1,cmd2
TIMEFFFF 8004
FIRMWARE_VERSION_GET / _RETFFFF 8006 / FFFF 0006
SERIAL_NUMBER_GET / _RET00DE 0002 / 00DE 0001
BATTERY005C 0001
TRIGGER_SYNC005C 0002
USER_INFO_SET / _RET 🔎✅0095 0001 / 0095 0003
FACTORY_RESET009A 0001
DEVICE_REBOOT 🔎FFFF 9080
RESOLUTION_GET 🔎 (→ 466×360)FFFF 907F
GPS_PUSH / _RETFFFF 906A / FFFF A06A
UNBIND_SET / _RETFFFF 907A / FFFF A07A
이름cmd1,cmd2
AUTH_PHONE_NAMEFFFF 8049
AUTH_WATCH_MACFFFF 0049
AUTH_PAIR_REQUEST / _REPLYFFFF 8047 / FFFF 0048
AUTH_NONCE_REQUEST / _REPLYFFFF 804B / FFFF 004C
AUTHENTICATED_CONFIRM_REQUEST / _REPLYFFFF 804D / FFFF 0004
AUTH_FAILEDFFFF A061
이름cmd1,cmd2
APP_NOTIFICATION0065 0001
INCOMING_CALL ⚠️0064 0001
CALL_REMINDER_REQUEST / _RESPONSEFFFF 9066 / FFFF A066
FIND_PHONE005B 0001
FIND_WATCH005D 0001
FIND_WATCH_TOGGLEFFFF 9069
SMS_MESSAGE_PUSH / _RETFFFF 906E / FFFF A06E
QUICK_REPLY_SET / _RETFFFF 9073 / FFFF A073
이름cmd1,cmd2
ALARMS_SET / _GET0063 0001 / 0063 0002
CONTACTS_SET / _GET00D5 0001 / 00D5 0002
STANDING_REMINDER_SET / _GET0060 0001 / 0060 0002
WATER_REMINDER_SET / _GET0061 0001 / 0061 0002
TASK_REMINDER_SET / _RET ⚠️FFFF 9072 / FFFF A072
이름cmd1,cmd2
GOALS_SET / _ACK005E 0001 / 005E 0003
UNIT_LENGTH / _ACKFFFF 9067 / FFFF A067
UNIT_TEMPERATURE / _ACKFFFF 9068 / FFFF A068
TIME_FORMAT / _ACK005F 0001 / 005F 0003
WAKE_ON_WRIST_RAISE / _GET / _ACK0062 0001 / 0062 0002 / 0062 0003
LANGUAGE_SET / _RETFFFF 9058 / FFFF A06B
HEART_MONITORING_ENABLED_SET / _GET009B 0001 / 009B 0002
HEART_MONITORING_ALERTSFFFF 9059
DO_NOT_DISTURB / _GET0099 0001 / 0099 0002
SPORTS_SET / _GET00DC 0001 / 00DC 0002
SPORT_LINKAGE_SET / _RETFFFF 9076 / FFFF A076
SPORT_DATA_SYNC 🔎 (실시간 HR/칼로리/걸음)FFFF 9078 / FFFF A078
FEMALE_CYCLE_SET / _RETFFFF 9071 / FFFF A071
SLEEP_CONFIG_SET / _RET (목표 시간)FFFF 9074 / FFFF A074
WORLD_CLOCK_GETFFFF 906F
WORLD_CLOCK_DST_SET / _RETFFFF 9083 / FFFF A083
VITALITY_GET / _RETFFFF 9079 / FFFF A079
VITALITY_SW_SET / _RETFFFF 9070 / FFFF A070
이름cmd1,cmd2
DIAL_COMMAND_SET / _RET (목록/재정렬/선택)FFFF 9055 / FFFF A055
DIAL_CONFIG_SET / _RETFFFF 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 / _2FFFF 8005 / FFFF 9057
ACTIVITY_FETCH_ACK_1 / _2FFFF 0005 / FFFF A057
ACTIVITY_DATA0056 0001
SLEEP_DATA / _GET0058 0001 / 0058 0002
SPO20055 0001
STRESS009D 0001
HEART_RATE_MANUAL_AUTO0053 0001
HEART_RATE_RESTING00DA 0001
HEART_RATE_WORKOUT00E0 0001
SKIN_TEMP_HISTORY 🔎 (이 SKU에서 비어 있음)0155 0001 / 0155 0002
WORKOUT_SUMMARY / _V30057 0001 / 0160 0001
WORKOUT_GPSFFFF A05A
도메인INIT1 요청/응답INIT2 요청/응답CHUNK 요청/쓰기FINISH ack1/ack2
워치페이스(사진)8052/00529063/A063A064/9064A065/9065
워치페이스(구조화/전환)8052/00529075/A075A064/9064A065/9065
펌웨어9052/A0529040/A040A042/9042A041/9041
AGPS/EPO905E/A05E—A05F/905FA060/9060
오프셋크기필드
04타임스탬프(epoch 초)
44걸음 수
84거리(m)
124칼로리
1616예약됨(0으로 관찰됨)
오프셋크기필드
04세션 시작(epoch, UTC)
44기상(epoch, UTC)
82총 깊은 수면 초
102총 코어 수면 초
122총 REM 초
142총 각성 초
162⚠️ [불확실] (세션 ID/점수? 관찰된 값이 레코드 합계와 일치하지 않음)
ac 49 1f 01
  • CONTACTS_SET (00D5 0001) ✅: N × 57바이트 = name(32) ‖ phone(25). 워치 UI는 최대 20개 표시.
  • ALARMS_SET (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…".
  • GOALS_SET (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바이트 형식을 선호하세요.
  • STANDING_REMINDER / WATER_REMINDER (0060/0061 0001) ✅: 11바이트: enabled(1) ‖ threshold_min(u16 LE) ‖ dndStart(u32 LE) ‖ dndEnd(u32 LE). UI에 표시되는 "활성 창 08:00–22:00"은 고정된 펌웨어 기본값이며 페이로드에 포함되지 않습니다.
  • SPORTS_SET (00DC 0001) ✅: count(1) = 36개 슬롯 ‖ activityTypeCode[36] (활성 코드 다음 00 패딩). 워치의 운동 메뉴에 표시할 스포츠를 선택합니다.
  • HEART_MONITORING_ENABLED (009B 0001) ✅: kind 바이트 — 01 = 24/7 HR, 02 = SpO₂, 04 = 스트레스(30분마다 측정).
  • HEART_MONITORING_ALERTS (FFFF 9059) ✅: 비활성화 = 00; 활성화 = 01 ‖ hrLow ‖ hrHigh ‖ sportHrHigh ‖ spo2Low ‖ 00 00 00 00 (0/255 경계 = "제한 없음").
  • FEMALE_CYCLE (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).
  • QUICK_REPLY (FFFF 9073) ✅: TLV — count(1) ‖ total(1) ‖ [id(1) ‖ len(u16 LE) ‖ msg-UTF8]… (7개의 기본 답변이 캡처 및 복호화됨).
  • WORLD_CLOCK (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)]….
  • MUSIC_INFO_SET (FFFF 905C, 131 B) ✅: state(1: 0=없음/1=일시정지/2=재생) ‖ volume(1) ‖ volumeMax(1) ‖ track(64) ‖ artist(64). 워치는 또한 MUSIC_BUTTON (A05D)을 다시 전송합니다.
  • WEATHER_SET_1 (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 참조.)
  • FIND_WATCH (005D 0001) ✅: 페이로드 0x01 → 워치가 울림/진동(+ ACK 005D 0003).
  • GPS_PUSH (FFFF 906A) ✅ — 빅엔디언, 경도 먼저: 16바이트 ts(u32 BE) ‖ lon×1e7(i32 BE) ‖ lat×1e7(i32 BE) ‖ 00 00. 실제 위치로 검증됨.
  • WORKOUT_GPS (FFFF A05A) ✅ — 리틀엔디언, 경도 먼저: 12바이트 ts(i32) ‖ lon×1e7(i32) ‖ lat×1e7(i32).
  • TIME (FFFF 8004): §7 참조.
  • cfbpp래스터(LZ4 후)용도
    42RGB565-LE불투명 배경(FULL/THUMB)
    53RGB565-LE(2 B) + 알파(1 B) 픽셀당앤티앨리어싱 스프라이트(글리프, 바늘, 아이콘)
    13 (0x0d)0.54비트 알파 마스크; 펌웨어가 런타임에 틴트 적용숫자-글리프 아틀라스
    24 (0x18)4RGBA8888풀컬러 레이어(항상 켜져 있는 aodImage 포함)
    1—JPEG/JFIF(ff d8 ff), 아무 디코더로나 추출드문 애니메이션 프레임
    0x61
    NotEnvelope
    0a
    ChildOverflow
    61 0a 00
    −18/−16
    쓰기 오프셋도 함께 이동해야 합니다
    0a
  • 다중 변형 컴플리케이션 슬롯 — 활성 메트릭은 .bin에 없음 ⚠️ 이 항목은 잘못되었으며 §11.13으로 대체됨. 구성 가능한 컴플리케이션은 동일한 (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에서 확인)은 펌웨어가 기본 보기에서 그리지 않는 슬롯입니다 — 그 값은 캔버스 가장자리 앞에 맞지 않습니다. 미리보기에서 숨김으로 처리합니다.
  • aod
    0x22
    @69,209
    격리된 Normal|AOD 편집 UI
  • 독립 0x60 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일반 화면✅자식
    0x22AOD 화면 (§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)
    0x01struct — 지오메트리 + 속성 (아래)—x,y,meta[14] + 참조 꼬리
    0x02가시성 조건 (§11.12)—조건 목록
    0x05피벗 — flag u8, pivotX u16, pivotY u16—5 B
    0x08pvStruct — prefix[5] + 참조 꼬리, x/y 없음 (미리보기 전용)——
    0x40자릿수 / 제로 패딩 플래그 (§11.10)—1 B
    0x48프레임 — x,y,w,h,gap,align 자동 레이아웃 행/열——
    0x5a / 0x5b0x80 / 0x81용 호 사양 (§11.15)—19 B / 17 B
    0x5f0x85용 슬롯 메트릭 목록 (§11.13)——
    0x86표시 이름 노드, 항상 정확히 64바이트, NUL 종료, 그려지지 않음—64 B
    다이얼슬롯개수activeIdx메트릭 id→ 활성
    275 SlopeTime0601c 6a 48 24 19 760x1c 칼로리
    275 SlopeTime1641c 6a 48 24 19 760x19 걸음 수
    368 Function0805f 1c 19 48 24 76 1a 8b0x5f 온도
    368 Function1865f 1c 19 48 24 76 1a 8b0x1a 심박수
    273 Activity Mood0401c 24 48 6a0x1c 칼로리
    304 Elaborate 20/1401c 48 6a 24 / 24 1c 6a 480x1c / 0x24