Fix the 0x6B motion_period decoder (wrong frame offset)#839
Open
pipiche38 wants to merge 1 commit into
Open
Conversation
…rom byte1, count-truncated final byte
The 0x6B motion_period decoder had the wrong frame offset. It treated the
first two payload bytes as a 12-bit period and read 2-bit MOTION_STATE codes
from byte 2 onward, taking a full four codes from every byte including the
last.
Real-capture evidence disproves that layout: within a session, the low nibble
of payload byte0 increments and wraps mod-16 across consecutive records
(…c, d, e, f, 0, 1…). That is a rolling sequence counter, which makes byte0 a
header, not the low half of a period. The correct layout (native
parse_api_motion_period; the SAME shape as the validated 0x4E sleep-phase
decoder) is:
byte0 = header: bits[7:6] period_type, bits[5:4] = count of valid codes in
the FINAL byte, bits[3:0] = the mod-16 sequence counter
byte1… = 2-bit codes, 4 per byte MSB-first; the LAST byte carries only `count`
The old offset dropped the first real code byte and manufactured phantom codes
from the final byte's padding; short single-code records (e.g. payload `1ea0`,
one TOSSING code) decoded to nothing at all.
Both platforms fixed byte-identically, golden fixtures rewritten to the
corrected layout (a full middle byte + a count-truncated final byte, plus the
real short-record case), and OURA_PROTOCOL.md §6.13 updated. 0x6B stays
decode-only instrumentation — it is not wired into staging/scoring; see the PR
body for why this capture does not yet show it staging sleep.
Swift OuraProtocol 160/0; Kotlin DecoderGoldenTest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
ryanbr
reviewed
Jul 26, 2026
Owner
There was a problem hiding this comment.
Nice find — the mod-16 counter across 3c 0d 1e 2f 00 21 is solid evidence, and reporting the staging correlation as a negative is the right call.
One blocker, then two small things:
count == 0. It is 2 bits but the last byte holds up to 4 codes, so 4 has to encode as 0. In your own header row, 3 of 11 records have count 0 (0x0d,0x00,0x06) — those now take nothing from their final byte, and a 2-byte one returns nil. Can you check whether the final payload byte is0x00in those records? Zero ⇒ current code is right. Non-zero ⇒count == 0means 4, and it needscount == 0 ? 4 : counton both platforms.- Add a
count == 0fixture either way — both current ones use count 1, and a quarter of real records take that path. - Reword "byte-for-byte port of the native
parse_api_motion_period". That describes porting decompiled code; what you did is re-derive and attribute. Something like "cross-checked againstparse_api_motion_period(attribution, not a port)".
Rest looks good — parity is byte-identical and both sides return nil on empty.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
NOOP's
decodeMotionPeriodwas decoding0x6Bwith the wrong layout. It treated the first two payload bytes as a 12-bit "period", read the 2-bitMOTION_STATEcodes starting at byte 2, and took a full four codes from every byte including the last.Why the old layout is wrong (real-capture evidence)
Watching one sync session in ring-time order, the low nibble of payload byte 0 increments and wraps mod-16 across consecutive records:
That is a rolling sequence counter, which makes byte 0 a header, not the low half of a period. The correct layout — a byte-for-byte port of the native
parse_api_motion_period, and the same shape as the already-validated0x4Esleep-phase decoder (one header byte, codes from byte 1) — is:The old offset dropped the first real code byte and manufactured phantom codes from the final byte's padding. Short single-code records decoded to nothing: e.g. payload
1ea0(header0x1e→ count 1; byte0xa0→ oneTOSSINGcode) produced an empty result under the old decoder.What's in this PR
decodeMotionPeriodfixed byte-identically on both platforms (SwiftPackages/OuraProtocol+ Kotlincom.noop.oura).count-truncated final byte, plus the real short-record case (1ea0).docs/OURA_PROTOCOL.md§6.13 updated to the corrected layout.MOTION_STATEenum unchanged:0 NO_MOTION, 1 RESTLESS, 2 TOSSING, 3 ACTIVE.Verification
swift testinPackages/OuraProtocol→ 160/0.DecoderGoldenTestgreen (both new fixtures pass).Scope note: this is a correctness fix, not a staging feature
0x6Bstays decode-only instrumentation — it is not wired intoOuraStreamMapping, staging, or scoring. I evaluated whether the corrected symbols could stage sleep, and on the capture I have they do not (yet) show a usable relationship. Correlating0x6Bsymbols against the ring's own sleep-phase records (same-session), the state mix runs backwards for a stillness ladder — deep sleep was ~49%TOSSINGand the stillest bucket was daytime:The alignment here is weak (only ~55/498 motion records shared a sleep session, and the motion/sleep session-id spaces aren't verified to be the same clock), so this is underpowered/inconclusive, not a disproof. Properly evaluating
0x6Bfor staging needs an epoch-by-epoch wall-clock alignment (anchor session→UTC via the0x42time-sync) against the hypnogram — a follow-up. Until then0x6Bis not a shortcut past consuming the ring's own SleepNet hypnogram for scoring.