Investigation — Final Status
This document grew organically over the 2026-05-02 / 2026-05-03 sessions. Read this top section first — sections below preserve the original investigation narrative including hypotheses that were later refuted, so reading top-down without this summary is misleading.
Final state — what ships today
Wire target: AVRCP 1.3 / AVCTP 1.2 (V1+V2 SDP byte patches), implemented via a JNI-side trampoline chain in libextavrcp_jni.so that bypasses mtkbt's compiled-1.0 AVRCP command dispatcher. Full per-patch reference in PATCHES.md; ICS Table 7 scorecard in BT-COMPLIANCE.md §2.
Current shipped patches by binary:
| Binary | Patches |
|---|---|
mtkbt | V1 (AVRCP 1.0→1.3 SDP byte on legacy served record), V2 (AVCTP 1.0→1.2 SDP byte), V3 (A2DP 1.0→1.3 SDP byte), V4 (AVDTP 1.0→1.3 SDP byte), V5 (AVDTP sig 0x0c TBH-table alias to sig 0x02 handler — best-effort workaround for GAVDP 1.3 ICS Acceptor row 9), V6 (internal activeVersion 10→14 — routes the SDP record builder to the AVRCP 1.3 served record so the wire-served record matches the F1-surfaced version), V7 (drop AVRCP 1.4 attr 0x000d Browse PSM advertisement on the AVRCP 1.3 record — swap entry slot to 0x0100 ServiceName), V8 (clear stock GroupNavigation bit 5 from SupportedFeatures byte stream so mask = 0x0001), S1 (0x0311 SupportedFeatures → 0x0100 ServiceName attr-table swap on legacy record), P1 (force VENDOR_DEPENDENT through PASSTHROUGH-emit so the JNI sees the frame), M1 (widen the RegNotif INTERIM/CHANGED dispatcher cmp at fcn.0x121d8:0x12230 from cmp r1, 1 to cmp r1, 0xF so wire ctype matches the JNI's reasonCode arg — see Trace #37), M6 (NOP the hardcoded movs r1, 0xD at fcn.0x121d8:0x12244 so the CHANGED branch becomes a pure pass-through for any non-INTERIM AV/C ctype value the JNI sets in ipc[8] — companion to M1; static-verified end-to-end in Trace #60), M2 (NOP beq 0x6d0e0 at 0x6d06e — bypass the outbound-frame builder's list-contains drop gate on Path A, the fragmented multi-frame path for msg=540 GetElementAttributes), M3 (NOP strb.w r0, [r4, #0xf2] at 0x6df42 — disable the chip-busy flag SET on Path A so the gate at 0x6df3a never trips; both M2 and M3 derived in Trace #40 to eliminate the silent ~80% drop of T9 CHANGED emits under A2DP saturation), M4 (NOP beq 0x6d19c at 0x6d116 — bypass the structurally-identical list-contains drop gate on Path B fcn.0x6d0f0, the short single-PDU path for msg=544 RegNotif INTERIM/CHANGED that the dispatcher at fcn.0xf0bc selects via cbz r3, 0xf186 when packetFrame[9]==0 (i.e. ctype>6, response direction); see Trace #41 — addresses the subscription-class CT retry-storm where msg=544 was delivering at ~6% on the wire while msg=540 on Path A was at ~100%) |
libextavrcp_jni.so | R1 (msg=519 redirect into trampoline-chain entry) + T1 / T2-stub / extended_T2 / T4 / T5 / T_charset / T_battery / T_continuation / T6 / T8 / T9 trampolines hosted in LOAD #1 page-padding extension; U1 (NOP UI_SET_EVBIT(EV_REP) to defang kernel auto-repeat on the AVRCP virtual keyboard). T1 advertises {0x01, 0x02, 0x05, 0x08, 0x09, 0x0a, 0x0b, 0x0c} (mirrors Pixel-as-TG's GetCapabilities event list); T8 INTERIM-acks events 0x01-0x08 and rejects events 0x09-0x0c with AV/C ctype 0x08 NOT_IMPLEMENTED via M6's pass-through path on mtkbt. The CapabilityID 0x03 advertisement is decoupled from per-event RegisterNotification response per AV/C §6.7.1; CTs that subscribe to an advertised-but-unsupported event correctly handle NOT_IMPLEMENTED by dropping the event from their retry set. |
MtkBt.odex | F1 (getPreferVersion()=14 unblocks 1.3+ Java dispatch), F2 (disable() resets sPlayServiceInterface), 2 cardinality NOPs (TRACK_CHANGED + PLAYBACK_STATUS_CHANGED switch arms in BTAvrcpMusicAdapter.handleKeyMessage) |
com.innioasis.y1*.apk | A / B / C (Artist→Album navigation), E (discrete PASSTHROUGH PLAY/PAUSE/STOP/NEXT/PREV per AV/C Panel Subunit Spec), H / H′ / H″ (foreground-activity propagation of unhandled discrete media keys + framework-synthetic-repeat filter) |
libaudio.a2dp.default.so | AH1 (skip a2dp_stop in standby_l so AudioFlinger silence-timeout leaves the AVDTP source stream alive across pauses) |
Y1MediaBridge.apk | Installed as the metadata source / play-state-edge driver; provides IBTAvrcpMusic + IMediaPlaybackService Binders to MtkBt and writes y1-track-info / y1-trampoline-state for the trampoline chain to read |
Pre-v2.0.0 the project shipped a different set against the same binaries (B1-B3 / C1-C3 / A1 / D1 / E3 / E4 / E8 in mtkbt; C2a / b / C3a / b in libextavrcp_jni.so; C4 in libextavrcp.so; H1-H3 in /sbin/adbd) that advertised AVRCP 1.4 / AVCTP 1.3 / SupportedFeatures 0x0033 on the SDP wire but couldn't deliver on the claim — mtkbt's compiled-1.0 dispatcher NACK'd every metadata COMMAND a 1.4-class CT then sent. The Conclusion (2026-05-04) section below documents that pivot. The legacy patch IDs remain referenced throughout the audit trail (Traces #1 etc) but no longer exist in the shipped tree; git log is the authoritative byte-level archive.
Static-analysis findings still load-bearing
- mtkbt IS the AVRCP processor on this device. (Earlier in this doc I hypothesized the BT chip firmware was the processor — that was wrong. The chip firmware blob is the WMT common subsystem, contains zero AVRCP code.)
- mtkbt's documented AVRCP / AVCTP functions are NOT dead code. Earlier scans (Trace #1 / 1b / 1c) missed the indirect-call mechanism —
0x29e98is reached via PIC-style callback registration throughregister_callbackat0x2fecc, called from0x28a5e;0x3096cis reached viaR_ARM_RELATIVErelocation into a 3-slot fn-ptr table at vaddr0xf94b0..0xf94bc. Trace #1d / 1e / 1f / 1g resolved this. The AV/C parser at0x6d04ais the only function still confirmed dead. Y1MediaBridge.apkis correctly implemented as a dual-interface (IBTAvrcpMusic + IMediaPlaybackService) Binder bridge. The bridge + V1/V2/S1/P1 + F1/F2 + cardinality NOPs + the trampoline chain together form the user-space command-handling pipeline that mtkbt's compiled-1.0 native dispatcher cannot deliver on its own.
The cardinality:0 gate question — resolved by the v2.0.0 pivot
Pre-v2.0.0 traces tried to find where in mtkbt's native AVRCP layer inbound REGISTER_NOTIFICATION events were being dropped (the "cardinality:0 gate") so that the legacy SDP-claim approach could deliver actual COMMANDs. Conclusion (2026-05-04, below) showed the gate could not be located within static-analysis budget. v2.0.0 pivoted to the user-space proxy approach: P1 reroutes inbound VENDOR_DEPENDENT frames into the JNI emit path (msg=519), where the trampoline chain in libextavrcp_jni.so synthesises the AVRCP 1.3 responses directly — bypassing mtkbt's dispatcher entirely. Static-analysis traces on the gate (Trace #1 / 1b / 1c / 1d / 1e / 1f / 1g, plus #4 / #7) are preserved below as historical context but the question they investigated is no longer load-bearing for the shipped pipeline.
Original narrative (preserved for audit trail)
What follows is the original investigation order. Some sections contain hypotheses that were later refuted — those refutations are in subsequent sections. Read the Final Status above for the corrected picture.
Planned Traces
1. mtkbt format-string xref scan (highest value, pure static analysis)
Every [AVCTP]/[AVRCP] format string in mtkbt is referenced from somewhere via PC-relative addressing (ldr rN, [pc, #imm]; add rN, pc). Compute literal-pool entries that resolve to each string's address and find every callsite.
Likely to reveal:
- The function containing
[AVCTP] cmdFrame->ctype:%d cmdFrame->opcode:%d— the AV/C command dispatcher entry; the choke point we've been missing. - The function containing
[AVCTP] AVCTP_ConnectRsp not in incoming state:%d— mtkbt's AVCTP state machine. - The function containing
[AVRCP][WRN] AVRCP receive too many data. Throw it!— the silent-drop point.
Output: a map of every [AVCTP]/[AVRCP] log point in the binary, with surrounding function context.
2. ACTIVATE_REQ (msg=500) handler in mtkbt
When the JNI sends msg=500, payload[6]=0x0e (tg_feature), payload[7]=ct_feature, mtkbt receives this on the abstract bt.ext.adp.avrcp socket and dispatches to a handler. The handler stores the TG feature globals on mtkbt's side. Find that handler. Verify whether the stored globals persist across connect_ind/CONNECT_RSP or get cleared per-connection.
3. CONNECT_RSP (msg=507) handler in mtkbt
Same path. JNI sends accept-flag-only via msg=507 (bytes [6][7]=0). Find mtkbt's response handler. If it has a code path like "if features == 0 then mark connection as 1.0" — that's the gate.
4. AVCTP PSM-registration path
[AVCTP] register psm 0x%x status:%d is a log string in mtkbt. Find the registration function — verify it actually registers L2CAP PSM 0x17 for inbound and what callback it installs. If the callback is missing or wrong-pointer, no AVCTP frames ever get parsed.
5. Decompile MtkBt.apk (Java side)
Only two methods are patched in MtkBt.odex (F1 getPreferVersion, F2 disable() reset). The full BluetoothAvrcpService Java class includes the connect-event listener, the play-service interface, and any feature-gate logic on the Java side. May reveal additional version checks not yet touched.
6. Inspect Y1MediaBridge.apk and verify it plays nicely with the patches
The mediabridge service is what supplies metadata to the AVRCP service. Confirm it implements the right callbacks and doesn't unintentionally suppress events that would otherwise propagate to a registered controller. (Source available — first-party app.)
7. Inspect libbluetoothdrv.so
mtkbt links against this. It almost certainly contains the actual L2CAP send / receive primitives. The [AVCTP] register psm call from mtkbt resolves into this library. If the bug lives there, mtkbt is innocent and we've been chasing the wrong binary.
Findings (2026-05-03): see "Trace #7 — Findings" below. All four libbluetooth* libs are HCI / transport-only — zero AVRCP / AVCTP code. The hypothesis was wrong; mtkbt is not innocent.
8. Verify /system/etc/bluetooth/ config end-state on device
audio.conf, auto_pairing.conf, blacklist.conf are touched by the bash flasher but the on-device final state has never been read back. Confirm the patches landed and there's no Disable= or similar override.
Trace #1 — Findings (2026-05-02)
Format-string xref scan complete. All 26 [AVCTP]/[AVRCP] log strings located, every callsite mapped via ldr+add r,pc literal-pool resolution.
Surprising finding: six of eight key documented functions in mtkbt have zero static references — not direct bl / blx targets, not branch targets, not stored as 4-byte literals anywhere, and not computed via ADR / ADD-PC / movw-movt arithmetic visible to static scan.
| Function | Direct callers |
|---|---|
0x028c98 connect handler (state=1) | 12 |
0x029910 REGISTER_NOTIFICATION dispatcher | 22 |
0x0290bc state=3 setter | 6 |
0x029294 state=5 setter | 5 |
0x06cf30 AVCTP_ConnectRsp | 2 |
0x038a44 SDP init function | 1 (tail call) |
0x0513a4 AVRCP silent-drop | 1 |
0x00fa94 AVRCP avctpCB | 1 |
0x029e1c callback dispatcher TBH | 0 |
0x02fd02 AVRCP 1.3/1.4 initializer | 0 |
0x030708 op_code dispatcher (E5 site) | 0 |
0x06d040 AV/C command parser | 0 |
0x06d25c AVCTP register PSM | 0 |
0x06d9ba AVCTP RX handler | 0 |
Implications:
- The AV/C command parser at
0x06d04a(which we patched the surrounding logic of via E5) appears to be unreachable code from anywhere in mtkbt's static call graph. The[AVCTP] cmdFrame->ctype:%d cmdFrame->opcode:%dformat string exists, the function exists, but no path leads to it. - Same for the operation dispatcher containing the E5 patch site — also zero callers.
mtkbthas no AVRCP / AVCTP exports in dynsym, solibbluetoothdrv.socan't resolve these by name at load time.libbluetoothdrv.soitself is only 9,280 bytes and contains zero AVRCP / AVCTP strings — it's a thin shim, not the processor.
Working hypothesis: the AVRCP / AVCTP code visible in mtkbt is dead code (leftover from a prior build that had the daemon do the processing). The actual AVRCP processing is happening either inside the Bluetooth chip firmware or via a path we haven't traced yet. This would explain:
- Why no AVRCP commands ever reach the JNI dispatch socket — mtkbt isn't the dispatcher.
- Why every patch we've made to mtkbt's command path (E5, E7) had no behavioral effect — those code paths are never executed.
- Why
tg_feature:0persists in CONNECT_CNF — mtkbt's view, but the actual TG state lives elsewhere.
This makes patches like B1-B3, C1-C3 (SDP descriptors, which mtkbt is responsible for serving) genuinely effective on the wire (sdptool confirms), while the runtime command-path patches are necessarily inert.
Trace #1b — Walk back from the 12 callers of 0x028c98 (executed 2026-05-02)
Find the actual entry point of mtkbt's connection logic. The 12 callers tell us where "new connection" events come from. Tracing the call chain back finds either an internal entry (in which case the dispatcher chain does exist somewhere in mtkbt that we haven't traced) or a PLT call into libbluetoothdrv.so (in which case the connection event originates from outside mtkbt and our search expands to firmware / IPC).
Findings:
- 12 callers in 12 distinct containing functions.
- Walking back 4 levels: 11 distinct top-level entry points (functions with 0 callers themselves) all eventually call
state=1. - Critically:
fn@0x029e98(the "callback dispatcher TBH" identified in earlier analysis) appears at depth 2 in the walk — it's a top-level entry (0 direct callers) whose descendants include the state=1 setter. So 0x029e98 IS in the live call graph, reached from outside mtkbt as a callback. - The deepest entry found is
fn@0x06adeeat depth 4, which has 0 callers but 3 call sites going down. - None of the "AV/C parser" / "op dispatcher" / "AVCTP RX handler" / "AVCTP register PSM" appear anywhere in this call tree. They are not on the path from any top-level entry to the state=1 setter.
Trace #1c — Scan for runtime writes to BSS function-pointer slots (executed 2026-05-02)
If the 0-caller functions are reached via callbacks, the registration site MUST write the function pointer somewhere. Scan for add rN, pc, #imm; str rN, [rA, #imm] patterns where the computed PC-relative target equals any of the 0-caller function addresses. Captures runtime callback registration sites missed by the literal-pool search.
Findings:
- Zero
add rN, pc, #imm; strpatterns matching any of the 0-caller function addresses. - Full scan of
.data(385 function pointers) and.data.rel.ro.local(1282 function pointers): none point to the AV/C parser, op dispatcher, AVCTP RX handler, AVCTP register PSM, or AVCTP_ConnectRsp containing fn. - Full RX-segment scan for any 4-byte literal pointing to any of those addresses: zero hits.
Trace #1 — interpretation
Three independent signals show that several of mtkbt's documented AVCTP / AVRCP functions have no static back-reference to live code:
- No direct or indirect callers for the AV/C parser, op_code dispatcher (E5 patch site), AVCTP register PSM, AVCTP RX handler, or AVCTP_ConnectRsp containing fn.
- No stored function pointers to any of these addresses in
.data,.data.rel.ro.local, or any literal pool in the RX segment. - Not on the live call graph that drives the connection state setters reached at runtime (state=1/3/5 sites all have real callers; the "command path" code does not).
Initial interpretation (REVISED — see below)
I initially concluded these were dead code and that the BT chip firmware was the actual AVRCP TG processor, with mtkbt only managing connection lifecycle. That conclusion is wrong, as confirmed by inspecting the actual chip firmware on disk.
Why the firmware-does-AVRCP claim is wrong
The Y1 BT chip is MT6627 (combo: BT + Wi-Fi + FM + GPS, on MT6572 SoC). The firmware blob is /etc/firmware/mt6572_82_patch_e1_0_hdr.bin, 39,868 bytes, build dated 20130523. Inspecting its strings reveals it is the WMT (Wireless/MediaTek) common subsystem firmware — sleep states, coredump, queue management, GPS desense, Wi-Fi power on/off. It contains zero AVRCP / AVCTP/L2CAP-level strings and no profile-stack code. Confirmed by strings over the blob: only chip-level housekeeping content.
The actual stack architecture:
[mtkbt + libextavrcp_jni.so + MtkBt.apk] ← Bluetooth profile stack, USERSPACE
| AVRCP / AVCTP / L2CAP / HCI parser, all in userspace
v
[/dev/stpbt]
| HCI transport
v
[mtk_stp_bt.ko] ← kernel module
|
v
[MT6627 chip] ← only handles radio + HCI commands
So mtkbt is the AVRCP processor. There's nowhere else the AVRCP frame parsing can live. Which means the "0-caller" functions in mtkbt must be reached at runtime through some mechanism static analysis missed.
What this implies for the open question
- What we got right: SDP-layer patches (B1-B3, C1-C3, E3, E4, A1, D1) are genuinely effective — sdptool confirms the bytes land on the wire, and mtkbt is what serves SDP. These remain in the script.
- What we got wrong: removing E5 and E7 was the right operational call (they had no observable effect), but the reason I gave was incorrect. The real reason is most likely that my static analysis missed the indirect-call mechanism that wires up mtkbt's AVRCP dispatcher functions to its live code path. Trace #1c looked for a specific pattern (
add rN, pc, #imm; str rN, [rA, #imm]) and found nothing, but there are other plausible mechanisms: function-pointer tables in.rodataindexed by op_code, vtable-style indirect dispatch through a struct field initialized at runtime by code I didn't trace, or a TBB / TBH-driven jump table whose target table is built dynamically. - The gate is still in mtkbt, somewhere we haven't found. It's not in firmware.
Trace #1d / #1e — Findings (executed 2026-05-02)
What was missed in earlier traces
mtkbt is a PIE executable (ET_DYN with e_entry=0xb558, ARM mode). The dynamic loader applies relocations to its .data.rel.ro section at startup. Previous static-only function-pointer searches (literal pools, add+str patterns, movw+movt pairs) completely missed this because:
.rel.dynhas 3982 entries: 374 ABS32 + 4 GLOB_DAT + 3604 RELATIVE.- For PIE binaries with load_base=0 (mtkbt's case), R_ARM_RELATIVE entries effectively store
addendatr_offsetat load time — and the addend lives in the file as a raw 4-byte word atr_offsetitself, indistinguishable from data until the loader runs. - 2392 of those RELATIVE addends point into the RX segment (i.e., function pointers), forming function-pointer tables in
.data.rel.ro.
Concrete finding: the op-code dispatcher IS reachable
A 3-slot function-pointer table sits at vaddr 0xf94b0..0xf94bc:
| vaddr | Thumb fn ptr | Function |
|---|---|---|
0xf94b0 | 0x3060c | (unknown) |
0xf94b4 | 0x30708 | op-code dispatcher A (its own push prologue) |
0xf94b8 | 0x3096c | op-code dispatcher B (the E5 patch site fn entry) |
All three are populated at load time by R_ARM_RELATIVE relocations. 0x3096c is the op_code=4 dispatcher (the function E5 patches inside). It's a real runtime target, not dead code. My previous "dead code" verdict for E5 was based on incomplete static analysis — the relocation-driven mechanism wasn't searched.
A larger cluster at vaddr 0xf94c0..0xf954c holds ~75 more Thumb function pointers — likely an op-code-indexed dispatch table for a different protocol layer.
Status of the other "0-caller" functions
Even after the relocation scan, zero R_ARM_RELATIVE relocations install pointers to: AV/C parser (0x6d040/0x6d04a), AVCTP RX handler (0x6d9ba), AVCTP register PSM (0x6d25c), AVCTP_ConnectRsp containing fn (0x6cf30), callback dispatcher TBH (0x29e1c/0x29e98), or AVRCP 1.3/1.4 init (0x02fd02/0x02fd34). They're absent from every reference mechanism we know how to scan: direct branches, literal pools, ADR/ADD-PC arithmetic, MOVW+MOVT pair, R_ARM_ABS32, R_ARM_RELATIVE.
There's a contradiction with Trace #1b: the call-tree walk back from 0x028c98 (state=1 setter) showed fn@0x029e98 (callback dispatcher TBH body) appearing at depth 2 as a top-level entry whose descendants include the state=1 setter. So 0x29e98 IS in the live call graph somehow, even though no relocation mechanism we've checked installs a pointer to it.
Implications for E5
Reverting E5 may have been premature on the operational side — the function it patches IS reachable at runtime. But E5 still made no observable behavioral difference on three different controllers, which suggests one of:
- The E5 patch site (the BNE inside the version-comparison logic) doesn't get exercised because mtkbt's runtime version classification for our peers takes a different branch before reaching the BNE.
- The function
0x3096cis reached only for specific op_codes that our peers don't send, so the patched code path never executes. - Our peers DO reach
0x3096cat the right moment but with version data that bypasses the patch's effect.
We can't distinguish these without runtime visibility — and the chip-firmware-does-AVRCP theory is now ruled out, so we know mtkbt IS the processor; we just don't see what it does.
Updated open question
The remaining cardinality:0 gate is somewhere inside mtkbt's userspace AVRCP / AVCTP code path. The previous walls all still apply (no root, no btsnoop, daemon-side logs gated to __xlog_buf_printf). Concrete next steps that might break the impasse:
- Trace #1f: Find the code that LOADS pointers from the table at vaddr
0xf94b0..0xf94bc. The literal0xf94b4is stored at file_off0x7cc0— find the LDR that reads it, find the surrounding function, and follow upward to the caller chain. That chain is the actual op-code dispatch entry into mtkbt's AVRCP processing. - Trace #1g: Scan ALL
blx rNinstructions in mtkbt whererNwas loaded from[rA + offset]for some memory location, and resolve which load addresses correspond to the function-pointer tables we've identified. This builds an indirect call graph. - Trace #1h: For the AV/C parser specifically — it parses cmdFrame bytes that originate from inbound AVCTP frames. Find the function that receives AVCTP frames (likely a state machine in the L2CAP receive path) and trace forward to where it dispatches by
cmdFrame[3](opcode byte). That's the AV/C demux. Even if 0x6d04a is dead code, something parses incoming AV/C frames.
These all extend Trace #1 — pure static analysis, no flash cycles.
Trace #1f — Findings (executed 2026-05-02)
0x29e98 IS reachable — confirmed
Traced the callback registration mechanism for the field [conn+0x5cc] (the per-connection callback fn ptr documented in earlier analysis as being read at 0x02fd74 and blx'd to dispatch the AVRCP layer).
Chain found:
register_callback (0x2fecc):
takes (conn_ptr, fn_ptr, sub_arg) and stores fn_ptr at [conn+0x5cc].
Caller (1 site only): 0x28a5e
Sets up r1 (the fn_ptr argument) via PIC-style PC-relative computation:
0x028a56: ldr r1, [pc, #0x17c] ; r1 = literal 0x1439 ← offset, not address
0x028a5c: add r1, pc ; r1 = 0x1439 + 0x28a60 = 0x29e99
0x028a5e: bl 0x2fecc ; register_callback(r0=conn, r1=0x29e99, r2=...)
The literal 0x1439 is not a function address — it's a PC-relative offset. The function address is computed at runtime by add rN, pc. Disassembly at the resolved target 0x29e98 matches the documented "callback dispatcher TBH" character-for-character (push.w {...,lr}; tbh [pc, r3, lsl #1]). So:
0x29e98is reachable.- The earlier analysis of its role is correct.
- The function
0x3096c(E5 patch site) is also genuinely reachable — it lives in the live call chain that this dispatcher reaches via TBH.
Why earlier traces missed this
Trace #1c looked for the wrong shape. The pattern in the binary is:
ldr rN, [pc, #imm] ; load PC-rel offset literal
add rN, pc ; compute fn_ptr = literal + PC + 4
bl <register_func> ; pass fn_ptr as argument
…not the add+str pattern I was scanning for. Also, the literal value (e.g. 0x1439) is a small offset, not a Thumb-LSB-set function address, so the filter (v & 1) and v < 0xf3000 excluded it.
Implication
The "dead code" framing has been wrong twice over: first I attributed the un-trackable references to chip firmware (refuted by inspecting the firmware blob), then to my own static-analysis blind spot (now refuted by finding the actual mechanism). The remaining "0-caller" functions in the AVCTP / AVRCP layer (0x6d04a AV/C parser, 0x6d25c AVCTP register PSM, 0x6d9ba AVCTP RX handler, 0x6cf30 AVCTP_ConnectRsp containing fn) are very likely registered through the same PIC-style mechanism via different register_* functions I haven't enumerated yet. They're not dead.
Why E5 still didn't help operationally
E5's patch site (0x309ec: BNE 0x30aca → B 0x30aca) is inside 0x3096c, which IS reachable. Three remaining possibilities for the lack of behavioral effect:
0x3096cis reached, but its TBH dispatch only routes specific op-codes through the branch we patched; for all other op-codes the BNE site is never reached.- The patch correctly forces the branch to
0x30aca, but0x30aca's downstream logic doesn't actually fire AVRCP 1.4 features for our peer state. - Something further upstream (the AV/C parser? the AVCTP RX handler?) is gating whether
0x3096cever sees a GetCapabilities op-code from our peer in the first place.
Distinguishing these requires runtime visibility we don't have. But the gate is somewhere in this code path, not in firmware or dead code.
Suggested next step (if continuing)
Run the trace #1f mechanism (find PIC-style ldr+add-pc; bl <reg_fn> patterns and resolve the resulting fn pointers) against ALL register-callback-style functions in mtkbt — not just 0x2fecc. That gives a comprehensive map of which "0-caller" functions are actually wired up, and where. From there we can compare to the call chain that processes inbound AVCTP frames and identify the true gate site for cardinality:0.
Trace #1f (full) — Comprehensive PIC fn-ptr enumeration (executed 2026-05-02)
Scanned all 14,417 add rN, pc Thumb-1 sites in mtkbt's .text and resolved 13,825 PIC-style address constructions. 245 of those resolve to addresses that are plausible function entries (have a push prologue at the resolved address).
Classification of the 245 fn-ptr constructions by what immediately follows:
- 63 are
bl <register_func>— fn ptr passed as arg to a registration function - 155 are
str rN, [rA, #imm]— fn ptr stored directly into a struct field - 7 are direct
blx rN(rare, indirect tail-call) - 20 are "other" patterns
Findings vs our 0-caller key functions
Out of the 245 constructions, exactly 4 target our key 0-caller functions — and all 4 target the AVRCP callback dispatcher (0x29e1c / 0x29e98):
| Site | Stored to / passed to | Fn ptr |
|---|---|---|
0x275e0 | str r3, [r0, #0x20] | 0x29e1d (pre-entry) |
0x28352 | str r0, [r2, #0x34] | 0x29e1d (pre-entry) |
0x28a5c | bl 0x2fecc (= register_callback(conn, fn, ...) writing [conn+0x5cc]) | 0x29e99 (body) |
0x28dce | str r3, [r5, #0x44] | 0x29e1d (pre-entry) |
The other "0-caller" functions show zero PIC constructions, zero R_ARM_RELATIVE, zero literal pool entries, zero direct callers:
0x6d04aAV/C parser → confirmed dead code (never reachable by any mechanism scanned).0x6d25cAVCTP register PSM,0x6d9baAVCTP RX handler,0x6cf30AVCTP_ConnectRsp containing fn → likely also dead code (alternate implementations).0x02fd34AVRCP 1.3/1.4 init body → reached via internalb.wtail-call from inside the live function0x3096cat offset0x030aca(per earlier analysis). Not registered, just a sub-path within a live function.
The three op-code=4 dispatchers
A 3-slot function-pointer table at vaddr 0xf94b0..0xf94bc holds:
| Slot | Vaddr | Fn ptr | Function |
|---|---|---|---|
| 0 | 0xf94b0 | 0x3060c | dispatcher A — checks [conn+0x5d0] against 0xa0, 0x82, etc. |
| 1 | 0xf94b4 | 0x30708 | dispatcher B — checks [conn+0x5d0] against 0x82, 0x81, 0x20 |
| 2 | 0xf94b8 | 0x3096c | dispatcher C (E5 site) — checks [conn+0x149]&0x7f against 0x20, 0x10 |
All three are op-code=4 (GetCapabilities) dispatchers for different sub-contexts. They each read different combinations of [conn+0x149] (version) and [conn+0x5d0] (state code) and dispatch differently:
0x3060c: 3 reads of[+0x149]; cmps against#0xa0,#0x820x30708: 2 reads of[+0x149]; cmps[+0x5d0]against#0x82,#0x81,#0x200x3096c: 1 read of[+0x149]; classic version-dispatch (cmp#0x10/#0x20)
E5 patched only the 0x3096c branch. If runtime selection picks 0x3060c or 0x30708 for our peers (driven by some other state), the patch never fires.
Why we can't proceed via static analysis alone
To know which of the three dispatchers gets invoked for our peers, we'd need to know:
- The runtime value of
[conn+0x5d0](state code) when GetCapabilities arrives. - The runtime value of
[conn+0x149](version field). - Which slot the upstream code reads from the 3-slot table — i.e., which struct field at
+0x20/+0x34/+0x44/+0x5ccis consulted.
These are runtime state. Without HCI snoop, daemon log access (xlog buffer), or device-side debugging, we can't observe them. The static call graph branches at this node and we can't predict which branch fires.
Trace #1g (full) — Indirect-call resolution complete (2026-05-02)
The 7 callback-invoker functions
Mapped all 14 readers of [conn+0x5cc] (the callback fn ptr slot holding 0x29e98). 10 are non-PC-relative (genuine struct-field reads); they live in 7 distinct functions that invoke the AVRCP callback dispatcher:
| Function | [+0x5cc] reads | Notes |
|---|---|---|
0x2fd36 (= AVRCP 1.3/1.4 init body) | 1 (at 0x2fd74) | previously-documented site |
0x2fd84 | 1 | adjacent helper |
0x3060c (op-dispatcher slot 0) | 1 (at 0x306dc) | one of the 3-slot table dispatchers |
0x30708 (op-dispatcher slot 1) | 2 (at 0x308e2, 0x3090a) | another 3-slot dispatcher |
0x3096c (op-dispatcher slot 2 = E5 site) | 1 (at 0x30b88) | the third 3-slot dispatcher |
0x34e1a | 1 | unrelated function |
0x34e64 | 3 | unrelated function |
All three op-code=4 dispatchers (0x3060c, 0x30708, 0x3096c) reach the callback — they're not mutually exclusive paths. So the question of "which one fires" is really "which one runs the path that does invoke the callback for this connection". Each has different gating logic before the [+0x5cc] read.
Concrete patch candidate found in fn 0x3060c
The cleanest gate site is in fn 0x3060c:
0x030658: ldrsb.w r0, [r4, #0x149] ; SIGNED load
0x03065c: cmp r0, #0
0x03065e: bge #0x30688 ; ★ if [+0x149] >= 0 (high bit clear), bypass 1.4
0x030660: ...
0x030684: b.w #0x2fd34 ; tail-call AVRCP 1.3/1.4 init
Single-byte patch (E8 candidate): 0x3065e: 13 da → 00 bf (NOP the BGE).
Caveat: every immediate write to [conn+0x5d9] (which feeds [+0x149]) sets the high bit (0x90, 0xa0, 0xc0, 0xd0, ...), so for normal peers [+0x149] is negative as a signed byte and the BGE is NOT taken — the gate doesn't fire. The patch only matters if our peers' [+0x149] somehow ends up with high bit clear (uninitialized, or written via an untraced code path). We can't determine this statically.
Trace #4 — Java decompilation of MtkBt.apk (executed 2026-05-02)
Tooling and access
MtkBt.dex (extracted from MtkBt.odex at offset 0x28) contains ODEX-optimized opcodes (e.g., invoke-virtual-quick, iget-quick, vtable@N) that pure DEX parsers reject. Disassembly required:
java -jar baksmali-2.5.2.jar disassemble --allow-odex-opcodes -a 17 MtkBt.dex
(Plain androguard fails with InvalidInstruction: opcode '0xf7' is unused; baksmali with the --allow-odex-opcodes flag and Android 4.2 API level (17) handles them.)
Key class structure
In com.mediatek.bluetooth.avrcp:
BluetoothAvrcpService— top-level service. Has all*Native()JNI methods plus matching event handlers (connectInd,connectCnf,activateCnf,registerNotificationInd, etc.).BTAvrcpMusicAdapter— bridge to the music play service. Owns the cardinality bitset (field@0x90=mRegBit) and the EventList (field@0x78). HandlesregisterNotification(B, I)Zper-event.BTAvrcpProfile.getPreferVersion()B— F1 patch site, returns0xeafter patch.IBTAvrcpMusic$StubandIBTAvrcpMusicCallback$Stub— IPC interfaces to / from Y1MediaBridge.
BTAvrcpMusicAdapter.getSupportVersion()B
getSupportVersion():
if (sPlayServiceInterface) return 0x0e ; AVRCP 1.4
else return 0x0d ; AVRCP 1.3
Confirms F2's importance: disable() resetting sPlayServiceInterface = false is required so that re-activation doesn't see stale state.
BTAvrcpMusicAdapter.checkCapability()V
v2 = getSupportVersion() ; v2 = 0xe (1.4) or 0xd (1.3)
if (field@0xf4 == 1):
log "version: <v2>" ; second-call: just log and return
return
log "init capability version: <v2>" ; ★ matches our logcat: "version:14"
field@0xf4 = 1 ; mark initialized
if (v2 == 0xe):
field@0x78 = new byte[5] ; 1.4 EventList
else:
field@0x78 = new byte[2] ; 1.3 EventList
field@0x78[0] = 1 ; PLAYBACK_STATUS_CHANGED
field@0x78[1] = 2 ; TRACK_CHANGED
if (v2 == 0xe):
field@0x78[2] = 9 ; NOW_PLAYING_CONTENT_CHANGED (1.4)
field@0x78[3] = 0xa ; AVAILABLE_PLAYERS_CHANGED (1.4)
field@0x78[4] = 0xb ; ADDRESSED_PLAYER_CHANGED (1.4)
field@0x90 = new BitSet(16) ; cardinality bitset (mRegBit)
[email protected]()
Logcat confirms init capability version:14 so the 1.4 path runs and EventList is populated correctly.
BTAvrcpMusicAdapter.registerNotification(B, I)Z
This is the cardinality update site:
switch (eventId):
case 1, 2, 9: handle (delegate to BluetoothAvrcpService notification* method) → bReg = true
case 3, 4, 5, 8: log "[BT][AVRCP] MusicAdapter blocks support register event:%d", bReg = false
case 6, 7: delegate to BluetoothProfileManager (vtable@15)
case 10, 11, 12: fall through, bReg unchanged (= false)
case 13: log "blocks", bReg = false
if (bReg):
synchronized (field@0x90):
[email protected](eventId) ; ★ THE cardinality update
log "[BT][AVRCP] mRegBit set %d Reg:%b cardinality:%d"
return bReg
BluetoothAvrcpService.registerNotificationInd(B, I)V
Calls BTAvrcpMusicAdapter.registerNotification(eventId, interval) (via field@0x24 = music adapter, vtable@75) for any eventId not in the special set {0xa, 0xb, 0xc}. Logs [BT][AVRCP](test1) registerNotificationInd eventId:%d interval:%d on entry.
Definitive verdict
The user's logcat over multiple sessions shows:
- Neither
[BT][AVRCP](test1) registerNotificationInd eventId:%d(the registration entry log) - Nor
[BT][AVRCP] mRegBit set %d Reg:%b cardinality:%d(the cardinality update log) - Nor
[BT][AVRCP] MusicAdapter blocks support register event:%d(the rejection log)
Therefore registerNotificationInd never fires — i.e., the JNI never receives a "REGISTER_NOTIFICATION arrived" event from mtkbt. Combined with our prior observation that no inbound AVRCP Recv AVRCP indication msg_ids beyond 501/505/506/512 are seen, this definitively locates the cardinality:0 gate inside mtkbt's native AVRCP layer, between the AVCTP receive path and the JNI dispatch socket.
Java layer ruled out
The Java layer:
- Initializes correctly (1.4 EventList ready).
- Handles incoming subscriptions correctly (events 1/2/9 succeed; 3/4/5/8/13 explicitly blocked; the others no-op).
- Has no version gate or capability check that would suppress events when they DO arrive.
No additional Java / smali patches will help. F1 + F2 are necessary AND sufficient on the Java side. The gate is unambiguously below.
The honest end of the static investigation
After Trace #1f the architectural picture is finally complete and consistent:
mtkbtIS the AVRCP processor (not chip firmware). ✓ confirmed by inspecting firmware blob.- The documented dispatchers (
0x29e98,0x02fd34,0x3096c) are all reachable at runtime — via PIC-style callback registration that earlier traces missed. ✓ confirmed. 0x6d04a"AV/C parser" is dead code — multiple independent searches confirm no caller mechanism reaches it. ✓ confirmed.- The cardinality:0 gate is in the runtime decision tree of
[conn+0x5d0]×[conn+0x149]× dispatcher-table selection, somewhere in the0x29e98→0x3060c/0x30708/0x3096cfamily of paths. - Static analysis cannot determine which decision point fires for our peers without observing runtime values. Every structural and addressable element has been mapped.
The remaining diagnostic options (HCI snoop / chip firmware modification / runtime instrumentation patches that emit observable side effects) are all out of scope per the constraints established at session start.
The repo (B1-B3, C1-C3, A1, D1, E3, E4, plus C2a / b, C3a / b, C4, F1, F2 across the four binaries) represents the complete set of demonstrably-effective patches reachable through static analysis. Y1MediaBridge is correctly implemented and ready to fire the moment the runtime gate releases.
Trace #7 — Findings (2026-05-03): MT6572 BT lib stack is HCI-only
The four libbluetooth* shared objects in /system/lib were inspected end-to-end (sizes, dynsyms, full strings):
| Library | Size | MD5 | Role |
|---|---|---|---|
libbluetoothdrv.so | 9,280 | 32f1af87e46acaf1efa3f083340495cb | Thin shim. Exports mtk_bt_enable / disable / write / read / op plus 8 fn-ptr objects in .bss. mtk_bt_enable does dlopen("libbluetooth_mtk.so") + dlsym on bt_send_data, bt_receive_data, bt_read_nvram, bt_get_combo_id, bt_restore, read_comm_port, write_comm_port. mtk_bt_op handles two opcodes only: BT_COLD_OP_GET_ADDR and BT_HOT_OP_SET_FWASSERT. |
libbluetooth_mtk.so | 13,452 | — | Real driver. Exports BT_InitDevice, BT_DeinitDevice, BT_SendHciCommand, BT_ReadExpectedEvent, GORM_Init, bt_send / receive_data, bt_read_nvram, bt_get_combo_id, bt_restore, read / write_comm_port. Strings reveal it as UART transport + GORM / HCC chip-bringup commands (Set_Local_BD_Addr, Set_Sleep_Timeout, Set_TX_Power_Offset, RESET, Set_Radio) + NVRAM BD-address management + chip combo-id detection. Contains bt_init_script_6572. |
libbluetoothem_mtk.so | 5,156 | — | Engineer Mode test surface (EM_BT_read / write / init / deinit). |
libbluetooth_relayer.so | 9,252 | — | EM↔BT relayer (bt_rx_monitor, bt_tx_monitor, RELAYER_start / exit). |
Combined strings search across all four libraries returned ZERO hits for avrcp, avctp, profile, capability, notif, metadata, cardinal. They are exclusively HCI / transport — UART connection to the MT6627 chip, BD-address management from NVRAM, and chip-bringup HCC commands. Nothing above HCI.
Implication. The cardinality:0 gate cannot live in any userland library other than mtkbt. mtkbt does not call back through any of these libraries for AVCTP / AVRCP processing — it uses them only for HCI transport via bt_send_data/bt_receive_data. AVCTP framing, L2CAP demux, and AVRCP command dispatch all happen inside mtkbt's own code segment. This narrows the search space conclusively to mtkbt.
This trace was deferred during 2026-05-02 work as low-priority ("almost certainly a thin shim"). Confirmed 2026-05-03; the deferral was correct but the verification was cheap and worth doing before considering root.
Out of Scope (eliminated)
- HCI snoop / btsnoop — no root, eliminated in earlier passes.
- mtkbt instrumentation patches (insert log calls at choke points) — possible but very high effort, low marginal value over #1 + #2.
- boot.img init scripts — won't reveal anything about the AVRCP path.
Conclusion (2026-05-04) — byte-patch path exhausted, proxy work needed
After the original investigation in this document concluded the gate was upstream of the op_code=4 dispatcher, post-root work in May 2026 added the diagnostic infrastructure to actually see what mtkbt and peers were doing on the wire (@btlog tap, dual-capture, btlog-parse, probe-postroot — all in tools/ and src/btlog-dump/) and ran a series of byte-patch experiments to test increasingly informed hypotheses about the SDP-record shape required by working AVRCP CTs.
The byte-patch hypothesis is conclusively dead. Five distinct (version, features) combinations were tested:
| Configuration | SDP wire | AVCTP RX behaviour | Cardinality | PASSTHROUGH play / pause |
|---|---|---|---|---|
Stock 1.0 + features 0x01 | 09 01 00 09 00 01 | Sonos doesn't bother sending AVRCP COMMANDs at all (no AVCTP_EVENT:4) | 0 | WORKS |
--avrcp standard 1.4 + features 0x33 | 09 01 04 09 00 33 | Sonos sends one COMMAND, mtkbt drops silently, Sonos gives up | 0 | broken |
Pixel-shape 1.5 + features 0xd1 (Browsing+MultiPlayer) | 09 01 05 09 00 d1 | Sonos tries to open AVCTP browse PSM 0x1B, mtkbt has no listener (+@l2cap: cannot find psm:0x1b!), Sonos gives up | 0 | broken |
Pixel-1.3 mimic 1.3 + features 0x01 | 09 01 03 09 00 01 | Same dropped-COMMAND failure as 1.4 | 0 | broken |
Features-only at 1.4 + features 0x01 | 09 01 04 09 00 01 | Same | 0 | broken |
Reference: Pixel 4 ↔ Sonos works at every AVRCP version 1.3-1.6 (per user-supplied sdptool browse F0:5C:77:E4:30:62 outputs at each Developer-Options-forced version, captured 2026-05-04). The Pixel at 1.3 advertises features 0x0001 — the same value Y1 stock advertises — and Sonos receives full title / artist / album metadata + responds correctly to PASS_THROUGH play / pause. The difference is not the SDP advertisement. It is mtkbt's command-handling layer.
mtkbt is internally an AVRCP 1.0 implementation. Compile-time string [AVRCP] AVRCP V10 compiled + runtime log AVRCP register activeVersion:10 are accurate. The opcode dispatchers identified earlier in this document (0x3060c, 0x30708, 0x3096c at op_code=4 = GetCapabilities) exist in the binary, but no inbound packet from any peer ever reaches them, regardless of how we shape the SDP record. The earlier "the gate is upstream of the dispatcher table" framing was correct; the missing piece was that there is no upstream gate that byte-patches can flip — mtkbt's AVCTP RX simply does not classify AVRCP COMMAND PDUs as anything its 1.0 dispatcher recognises, and silently drops them.
The previously-listed primary lead, MSG_ID_BT_AVRCP_CONNECT_CNF result:4096, was also disproven during this work: the same 0x1000 value is emitted at MSG_ID_BT_AVRCP_ACTIVATE_CNF time 3 ms after the JNI sends ACTIVATE_REQ, before any peer is involved. 0x1000 is mtkbt's standard "request acknowledged" status code, not a peer-feedback or "feature degraded" indicator.
Repo state after the conclusion (commits 2690d05 → 7077b5a → bd36160 → this one)
--avrcpis now a known-broken opt-in. It runs if explicitly requested (useful for the proxy work below) and prints a startup warning. Excluded from--all.--bluetoothno longer setspersist.bluetooth.avrcpversion=avrcp14. The remaining audio.conf /auto_pairing.conf/blacklist.conf/ro.bluetooth.class/ro.bluetooth.profiles.*.enabledproperties are pairing-essential and stay.- The recommended baseline is
--all(without--avrcp): pairing works, A2DP audio works, AVRCP 1.0 PASSTHROUGH (play / pause / skip) works, no metadata over BT. - Diagnostic infrastructure remains in-tree:
src/btlog-dump/(no-libc ARM ELF that taps mtkbt's@btlogsocket),tools/btlog-parse.py(frame decoder),tools/dual-capture.sh(btlog + logcat correlated capture),tools/probe-postroot.sh+tools/probe-postroot-device.sh(one-shot post-root sanity probe). - Failed-experiment scripts (Browsing-bit, Pixel-shape, Pixel-1.3 mimic, features-only) have been removed from
tools/. Their results are summarised in the table above and inCHANGELOG.md.
Path forward — user-space AVRCP proxy
Three architecture sketches were considered when the byte-patch path was first ruled out (see commit messages around bd36160). The smallest viable one is sketched below.
Approach: trampoline mtkbt's silent-drop site to forward unhandled AVRCP COMMANDs raw to the JNI; respond from Java.
The work is roughly four phases.
Phase 1 — Identify the silent-drop site (gdbserver, ~1-2 days)
Push an API-17 ARM AOSP-prebuilt gdbserver to /data/local/tmp/, attach to the live mtkbt PID. PIE base is 0x400c1000 (per tools/probe-postroot.sh §1; verify on each session — the base is per-process not per-firmware). Set breakpoints on the candidate drop sites identified in the appendix below:
0x6d9ba(live0x40128d9a) — AVCTP RX handler0x6cf30(live0x40128f30) — AVCTP_ConnectRsp0x0513a4(live0x401123a4) —[AVRCP][WRN] AVRCP receive too many data. Throw it!log site0x29e98(live0x400d2e98) — TBH callback dispatcher
Trigger the failure scenario (Y1 ↔ Sonos with --avrcp on so peer engages enough to send a COMMAND). Whichever breakpoint fires when the single AVCTP_EVENT:4 arrives is the candidate drop site. Dump the inbound packet bytes from r0 / r1 / stack at that point and confirm they're a real AVRCP COMMAND PDU (op_code 0x4 = GetCapabilities is the most likely first command).
tools/probe-postroot.sh §11 confirmed SELinux is absent on this firmware and §12 confirmed /proc/sys/kernel/yama/ptrace_scope doesn't exist either, so ptrace attach is unblocked.
Phase 2 — Patch a trampoline (~3-5 days)
At the identified drop site, replace the silent-drop branch with a bl <trampoline>. The trampoline (in a code-cave or appended to mtkbt's .text) marshals the inbound packet into a new IPC message — e.g., msg_id 999 — and writes it to the existing bt.ext.adp.avrcp abstract socket that already carries msg_ids JNI↔mtkbt. The IPC framing and the existing send wrapper at vaddr 0x511c0 are documented in the appendix below.
Verification: tools/btlog-parse.py should now show the AVRCP COMMAND bytes flowing through the new msg_id; logcat should show the JNI receiving msg_id 999 (or whatever ID we pick).
Phase 3 — Java AVRCP COMMAND parser / responder (~1-2 weeks)
Extend Y1MediaBridge (or add a sibling Java component) to:
- Receive the new msg_id from the JNI via the existing Binder path.
- Parse the AVCTP+AVRCP frame: AV/C control header, op_code, PDU ID, transId, params.
- Build the appropriate AVRCP RSP for at minimum:
GetCapabilities(PDU0x10)RegisterNotification(PDU0x31) forEVENT_TRACK_CHANGED(0x05) andEVENT_PLAYBACK_STATUS_CHANGED(0x01)GetPlayStatus(PDU0x30)GetElementAttributes(PDU0x20)
- Use the existing
IBTAvrcpMusic/IBTAvrcpMusicCallbackplumbing for the actual track / state data — Y1MediaBridge already sources this from the music player via broadcast intents and RCC.
PASS_THROUGH (op_code 0x7C) commands should pass through to the existing 1.0 path so play / pause keeps working — don't intercept those.
Phase 4 — Outbound RSP path (~3-5 days)
Patch a second trampoline (or extend the first) that takes a Java-built AVRCP RSP frame, marshals it into an outbound msg_id, and routes it through mtkbt's existing AVCTP TX path so it reaches the peer's AVCTP channel. The IPC dispatcher map in the appendix below (msg_ids 500-611, second TBH at vaddr 0x518ac) names the candidate slots.
Verification target
tools/dual-capture.sh against Sonos should show:
cardinality:Nnon-zero inMMI_AVRCP: ACTION_REG_NOTIFY for notifyChange ... cardinality:NMMI_AVRCP: registerNotificationInd eventId:firing for events 1/2/9- Sonos app showing title / artist / album for the currently-playing track
- Y1MediaBridge log lines
notifyAvrcpCallbacks code=N targets=>=1(currently always logstargets=0because MtkBt never registers a callback — the proxy work fixes this by routing peer-sideRegisterNotificationthrough Java) - Physical play / pause from Sonos still working (PASSTHROUGH path unbroken)
Known prerequisites for the next agent
- Read this entire document top-down — the failure modes earlier in the doc (G1 / G2 SIGSEGV at NULL, blanket xlog redirect being too fragile, etc.) are real and re-tripping them wastes days.
- Re-verify
tools/probe-postroot.shoutputs against the device before assuming PIE base / PSM list / SELinux state. The probe is idempotent and cheap. - The diagnostic tooling (
@btlogtap, dual-capture, parser) was developed against firmware 3.0.2. IfKNOWN_FIRMWARESgains a new entry, re-verify the framing against that firmware before trusting parsed output. --avrcpMUST be enabled to test the proxy work (otherwise the Y1MediaBridge bridge isn't installed and there's no Java endpoint for the proxy to deliver to). The startup warning is informational; ignore it for the duration of the proxy work.
Estimated total
2-4 weeks of focused work for someone with ARM Thumb-2 binary RE + Android Bluetooth experience. The diagnostic infrastructure is in place; the gating risk is finding a viable drop site in mtkbt that we can hook without destabilising AVCTP. If no clean site exists (e.g., the drop happens inline rather than at a callable choke point), the alternative is the larger Option 2 — disable mtkbt's AVRCP entirely and bind PSM 0x17 from Java — which is a multi-month rewrite.
Appendix — Reference detail (originally maintained as the working-notes brief, archived 2026-05-04)
This appendix preserves the granular detail from a working-notes brief that was maintained externally to the repo during the 2026-05-02 → 2026-05-04 investigation. The narrative above (top of doc) is the canonical history; the conclusion above is the canonical end-state. This appendix is reference data: byte-level patch tables, MD5s, function offsets, ILM layouts, msg_id maps, log tag conventions, and the post-root traces (#8–#11) that complement the original Traces #1–#7. Future work should consult both halves of this document. The brief itself is no longer maintained.
Device Context
| Item | Value |
|---|---|
| SoC | MT6572 |
| Android | 4.2.2 (JDQ39) |
| Bluetooth | 4.2 (host stack) |
| Stock player | Proprietary Innioasis app — logcat prefix DebugY1 |
| BT stack | MtkBt.apk → libextavrcp_jni.so → libextavrcp.so → mtkbt daemon via Unix socket |
| BT chip | MT6627 (combo: BT + Wi-Fi + FM + GPS), HCI-only — chip firmware is the WMT common subsystem and contains zero AVRCP code |
| System access | Full system-partition write via MTKClient + loop-mount. Flash cycle 5–10 min. |
| ADB root | Hardware-verified 2026-05-04 via setuid /system/xbin/su (v1.8.0+). Stock /sbin/adbd untouched. |
Architecture
[Car Stereo CT] <--SDP / AVRCP--> [mtkbt daemon] <--socket bt.ext.adp.avrcp--> [libextavrcp.so]
| ^
| HCI / UART [libextavrcp_jni.so]
v ^
[/dev/stpbt] [MtkBt.apk Java layer]
|
v
[mtk_stp_bt.ko (kernel)]
|
v
[MT6627 chip — HCI / radio only]
The socket bt.ext.adp.avrcp lives in ANDROID_SOCKET_NAMESPACE_ABSTRACT (namespace=0). Abstract sockets have no filesystem file and are auto-released on FD close; no stale socket is possible across BT toggle cycles.
Trace #7 confirmed all four libbluetooth*.so libs (libbluetoothdrv.so, libbluetooth_mtk.so, libbluetoothem_mtk.so, libbluetooth_relayer.so) are HCI / transport-only. Combined strings search returned zero hits for avrcp / avctp / profile / capability / notif / metadata / cardinal. The cardinality:0 gate cannot live anywhere except inside mtkbt.
Additionally, mtkbt exposes an undocumented SOCK_STREAM listener at the abstract socket @btlog (created by socket_local_server("btlog", ABSTRACT, SOCK_STREAM) at vaddr 0x6b4d4). Connecting to it as root yields a stream of mtkbt's __xlog_buf_printf output plus decoded HCI command / event traffic — the diagnostic capability used by tools/dual-capture.sh and Trace #9. See src/btlog-dump/README.md for the framing format.
The legacy 11-patch --avrcp byte-patch set — DELETED in v2.0.0
The pre-v2.0.0 --avrcp flag shipped 11 byte patches against mtkbt (B1-B3 AVCTP version, C1-C3 AVRCP version, A1 runtime SDP MOVW, D1 registration-guard NOP, E3/E4 SupportedFeatures, E8 op_code=4 dispatcher gate) plus 4 against libextavrcp_jni.so (C2a/b/C3a/b) plus 1 against libextavrcp.so (C4 version constant). All advertised AVRCP 1.4 / AVCTP 1.3 / SupportedFeatures 0x0033 on the wire (sdptool-confirmed) but mtkbt's compiled-1.0 command-handling layer NACK'd every metadata COMMAND that 1.4 controllers then sent — net regression vs stock 1.0 PASSTHROUGH.
The Browsing-bit experiment (Trace #11) and Pixel-shape experiment (set features 0xd1 including Browsing + Multi-Player) closed the question: when we advertised Browse, Sonos opened browse PSM 0x1B, mtkbt's L2CAP rejected (+@l2cap: cannot find psm:0x1b!), and Sonos gave up on AVRCP altogether. Since v2.0.0 the served record advertises AVRCP 1.3 / AVCTP 1.2 (V1+V2) with no 0x000d AdditionalProtocolDescriptorList — see PATCHES.md for the current shipped set.
Several reverted-during-development entries (E1, E2, E5, E7a/b state-gate / op_code-dispatcher NOPs; G1 / G2 xlog-redirect thunks that broke BT init) were closed mid-stream during the legacy era and don't survive in the current tree either. Conclusion specifically for the xlog-redirect line of work: blanket redirect at the consolidated wrapper at vaddr 0x675c0 is too fragile (hit ~3000 times in mtkbt's lifecycle including very early init). The @btlog passive tap from Trace #9 supersedes the read-only-observation need entirely; behavioural instrumentation, if ever needed, must be surgical (hardcoded tag / fmt strings via a trampoline at a small number of high-value sites).
Byte-level offsets and tables for any of these patches: git log --all -- src/patches/patch_mtkbt.py src/patches/patch_libextavrcp_jni.py src/patches/patch_libextavrcp.so covers the full edit history through the v2.0.0 deletion commit.
adbd Root Patches (H1 / H2 / H3) — Closed 2026-05-03 (failed on hardware), superseded by setuid /system/xbin/su
Status: closed. Both attempted revisions caused "device offline" on hardware.
--rootflag removed fromapply.bashin v1.7.0 then reintroduced in v1.8.0 against/system/xbin/suinstead. The standalonepatch_adbd.pyandpatch_bootimg.pyscripts (kept in the tree until v2.0.0) were removed in v2.1.0; the analysis below is preserved for whoever picks up the root pass with a different mechanism.
The OEM adbd has stripped the standard AOSP should_drop_privileges() gating. strings adbd returns ZERO references to ro.secure. The drop_privileges block at vaddr 0x94b8 runs unconditionally on every adbd startup.
0x94b8: movs r0, #0xb ; arg0 = count = 11 ← H1
0x94ba: add r1, sp, #0x24 ; arg1 = gid_array on stack
0x94bc: blx #0x17038 ; setgroups(11, gids)
0x94c0: cmp r0, #0
0x94c2: bne.w #0x97ea ; on failure → exit(1)
0x94c6: mov.w r0, #0x7d0 ; arg0 = AID_SHELL = 2000 ← H2
0x94ca: blx #0x1701c ; setgid(2000)
0x94ce: cmp r0, #0
0x94d0: bne.w #0x97ea
0x94d4: mov.w r0, #0x7d0 ; arg0 = AID_SHELL = 2000 ← H3
0x94d8: blx #0x19418 ; setuid(2000) wrapper → bl 0x27b30; eventually mov r7,#0xd5; svc 0
0x94dc: mov r3, r0
0x94de: cmp r0, #0
0x94e0: bne.w #0x97ea
Final approach (arg-zero, 2026-05-03 revision): change only the argument loads so the syscalls execute with arguments of 0. All bionic bookkeeping (capability bounding-set, thread-credential sync) runs normally; the process ends up at uid=0/gid=0 with no supplementary groups.
| Patch | File offset | Before | After | Effect |
|---|---|---|---|---|
| H1 | 0x14b8 | 0b 20 | 00 20 | movs r0, #0xb → movs r0, #0 (setgroups count 11 → 0) |
| H2 | 0x14c6 | 4f f4 fa 60 | 4f f0 00 00 | mov.w r0, #0x7d0 → mov.w r0, #0 (setgid arg 2000 → 0) |
| H3 | 0x14d4 | 4f f4 fa 60 | 4f f0 00 00 | mov.w r0, #0x7d0 → mov.w r0, #0 (setuid arg 2000 → 0) |
| Item | Value |
|---|---|
| Stock adbd MD5 | 9e7091f1699f89dc905dee3d9d5b23d8 (223,132 bytes) |
| Patched adbd MD5 (arg-zero) | 9eeb6b3bef1bef19b132936cc3b0b230 (same size) |
| Patched adbd MD5 (NOP-the-blx, earlier failed revision) | ccebb66b25200f7e154ec23eb79ea9b4 |
Confirmed blx targets:
0x17038→ ARM-modemov r7, #0xce ; svc 0(setgroups32 EABI #206)0x1701c→ ARM-modemov r7, #0xd6 ; svc 0(setgid32 EABI #214)0x19418→ ARM wrapper that doesbl 0x27b30before reachingmov r7, #0xd5 ; svc 0at0x31a70(setuid32 EABI #213) — thebl 0x27b30is the load-bearing bookkeeping (likely capability bounding-set / thread-credential sync) that the original NOP-the-blx revision skipped.
Why default.prop edits alone don't work. Empirical confirmation 2026-05-03: adb shell id returned uid=2000(shell) despite ro.secure=0/ro.debuggable=1/ro.adb.secure=0 correctly set per getprop. adb root is also actively harmful on the un-patched binary — adbd accepts the request (ro.debuggable=1 passes the permission check), sets service.adb.root=1, exits to be respawned, hits the same unconditional drop_privileges path again, and the self-restart triggers a USB rebind that the stock MTK adbd handles poorly (host loses the device until reboot).
Why arg-zero, not NOP-the-blx (history). An earlier revision NOPed the three blx calls outright. On hardware, however, adb shell and adb root both returned "device offline" — adbd starts and the USB endpoint enumerates, but the ADB protocol handshake never completes. The bionic setuid wrapper at 0x19418 does bl 0x27b30 before reaching the actual syscall stub, doing capability bounding-set / thread-credential bookkeeping that downstream adbd code depends on. NOPing the call entirely skips that bookkeeping → process is uid 0 nominally but has inconsistent credentials / capabilities → the USB ADB protocol layer never fully initializes. The arg-zero revision keeps every syscall and every bionic wrapper intact; setuid(0) when EUID is already 0 is a no-op that runs all the same bookkeeping. Same for setgid(0). setgroups(0, _) clears supplementary groups, which is the desired end state anyway. Even so, arg-zero ALSO failed on hardware ("device offline"); root cause never fully diagnosed because losing ADB makes diagnosis circular.
patch_bootimg.py extracted /sbin/adbd from the boot.img ramdisk cpio in-place, applied H1 / H2 / H3 via patch_adbd.patch_bytes(), and wrote it back. Same file size (223,132 bytes) so cpio record offsets are unchanged.
Root via setuid /system/xbin/su — v1.8.0 (verified on hardware 2026-05-04)
Status: hardware-verified 2026-05-04. First flash +
adb shell→su→idreturneduid=0(root) gid=0(root). Replaces the failed H1 / H2 / H3 adbd byte-patch path; got us out of the "patched adbd is broken / can't even diagnose because we just broke ADB" trap.Verification log:
$ adb devices List of devices attached 0123456789ABCDEF device $ adb shell shell@android:/ $ id uid=2000(shell) gid=2000(shell) groups=1003(graphics),1004(input),... shell@android:/ $ su shell@android:/ # id uid=0(root) gid=0(root) groups=1003(graphics),1004(input),...
suresolved without explicit path →/system/xbin/suis on$PATH. Prompt flipped$→#. No password prompt, no manager APK gating. The 892-byte direct-syscall escalator works exactly as designed.
Strategy
Sidestep adbd entirely. Stock /sbin/adbd is left untouched and continues to drop privileges to uid 2000 (shell) at boot — ADB protocol handshake comes up cleanly, identical to stock behavior. Root is then obtained per-session by exec'ing a setuid-root binary at /system/xbin/su.
The binary is built from src/su/su.c (~80 lines of C) + src/su/start.S (~10 lines of ARM Thumb-2 assembly), entirely in-tree:
- No libc dependency — direct ARM-EABI syscall implementation.
setgid(0)→setuid(0)→execve("/system/bin/sh", …). Three invocation forms: baresu(interactive root shell),su -c "<cmd>"(one-off),su <prog> [args…](exec-passthrough). - No supply chain beyond GCC + this source. No SuperSU / Magisk / phh-style binary imported, no manager APK, no whitelist.
- Build via
cd src/su && make. Output: 892-byte statically-linked ARMv7 ELF, soft-float, EABI v5, noNEEDEDentries. Output MD5 (current):a87dc616085e1a0e905692a628e747e7.
The bash patcher's --root flag does:
sudo install -m 06755 -o root -g root src/su/build/su /mnt/y1-devel/xbin/su
against the mounted system.img. No boot.img extraction, no ramdisk repack, no /sbin/adbd byte-patches.
Trade-offs
- Anyone who can exec
/system/xbin/subecomes root. No permission-prompt UI, no whitelist. Acceptable for a single-user research device. Not appropriate for a consumer ROM. - The binary is intentionally tiny + direct so every byte is auditable. Statically linked means a future bionic mismatch can't brick the escalator.
Why this should work where H1 / H2 / H3 didn't
The H1 / H2 / H3 failure mode was: patched /sbin/adbd got into a state where ADB protocol initialization failed, and once you've shipped a broken adbd you can't diagnose what broke it (you've lost ADB). The su install touches NOTHING in the boot path — adbd, init, ramdisk, even default.prop are all stock. If /system/xbin/su somehow doesn't work post-flash, ADB still works fine; we can pull /system/xbin/su and check what's wrong (perms? mode bits? signing? wrong arch?) without losing visibility.
Watch-items on the root install itself
- SELinux /
/systemenforcement. The currentsuworks because Android 4.2.2 + this OEM build apparently allows setuid binaries on/systemto escalate. If a future firmware update hardens this, the manager-APK-paired SuperSU/Magisk fallback would become necessary. - Cross-firmware portability.
suis verified on v3.0.2 only. If theKNOWN_FIRMWARESmanifest gains other firmware versions (e.g. a hypothetical 3.0.3), re-verify--rootagainst each. - Kernel-level fallback (CVE-based exploits against the 3.4-era kernel) and MTK-specific accessory binaries (
mtk_mtkbt_rootetc.) remain available if the setuid path is ever closed.
mtkbt AVRCP State Machine Analysis
Key Globals
| Symbol | Offset | Role |
|---|---|---|
[conn+0xe99] | per-conn | State byte for AVRCP notification state machine (values 0–9) |
[conn+0x149] | per-conn | Negotiated AVRCP version from remote SDP (0x10=1.0, 0x13=1.3, 0x14=1.4) |
[conn+0x5cc] | per-conn | Callback fn ptr (set to 0x29e98 via register_callback at 0x2fecc from 0x28a5e) |
[conn+0x5d0] | per-conn | State code consulted by op_code=4 dispatchers (vals: 0x82, 0x81, 0x20, 0xa0, …) |
[global+0x25800+0x1b8] | BSS | Callback dispatch count; drives TBH dispatch in 0x29e98 |
State Machine Values
| Value | Meaning | Set at |
|---|---|---|
| 0 | init | initial |
| 1 | new connection | 0x028d72 (connect handler) |
| 3 | pending REGISTER_NOTIFICATION response | 0x029200 (incoming REGISTER_NOTIFICATION received) |
| 5 | active registration | 0x0293d6 |
Three op_code=4 dispatchers (3-slot fn-ptr table at vaddr 0xf94b0..0xf94bc)
Confirmed reachable via R_ARM_RELATIVE relocations populated at load time.
| Slot | Vaddr | Fn ptr | Function character |
|---|---|---|---|
| 0 | 0xf94b0 | 0x3060c | 3 reads of [+0x149] (signed); cmps [+0x5d0] against 0xa0, 0x82. E8 patch site (BGE→NOP at 0x3065e). |
| 1 | 0xf94b4 | 0x30708 | 2 reads of [+0x149] (unsigned with & 0x7f); cmps [+0x5d0] against 0x82, 0x81, 0x20 |
| 2 | 0xf94b8 | 0x3096c | 1 read of [+0x149]; classic version-dispatch (cmp #0x10 / #0x20). Old E5 patch site. |
All three reach the AVRCP callback via [conn+0x5cc] (mapped to fn 0x29e98) — they're not mutually exclusive paths, but each has different upstream gating logic. Post-E8 testing definitively showed none of the three are reached for our peers: only msg_ids 505 and 506 ever arrive, never op_code=4. This is now understood (per the 2026-05-04 conclusion) as mtkbt's AVCTP RX silently dropping unrecognized AVRCP COMMANDs at a layer upstream of the dispatcher table, because mtkbt's compiled command set is 1.0-only.
Callback registration mechanism (Trace #1f)
0x028a56: ldr r1, [pc, #0x17c] ; r1 = literal 0x1439 (PC-rel offset)
0x028a5c: add r1, pc ; r1 = 0x1439 + 0x28a60 = 0x29e99
0x028a5e: bl 0x2fecc ; register_callback(conn, 0x29e99, ...)
register_callback (0x2fecc):
takes (conn_ptr, fn_ptr, sub_arg) and stores fn_ptr at [conn+0x5cc].
The literal 0x1439 is not a function address — it's a PC-relative offset. Earlier static-analysis searches missed this pattern. The earlier documented analysis of 0x29e98 (callback dispatcher TBH) elsewhere in this document is correct; the function is reachable, just registered through a PIC-style mechanism.
The remaining "0-caller" functions (0x6d04a AV/C parser, 0x6d25c AVCTP register PSM, 0x6d9ba AVCTP RX handler, 0x6cf30 AVCTP_ConnectRsp) show zero PIC constructions, zero R_ARM_RELATIVE, zero literal pool entries, zero direct callers. Likely registered through similar mechanisms via different register_* functions not yet enumerated.
Post-D1 Analysis — Why tg_feature:0 Persists in CONNECT_CNF
CONNECT_CNF handler dissection (libextavrcp_jni.so)
The receive loop (FUN_0x5f0c) dispatches on msg_id using a TBH at 0x60B8. Resolved jump table:
| msg_id | Dec | TBH index | Handler vaddr |
|---|---|---|---|
| 505 | CONNECT_CNF | 4 | 0x62EA |
| 506 | connect_ind | 5 | 0x619C |
CONNECT_CNF handler at 0x62EA:
- Reads
resultfrom ILM+0x02,conn_idfrom ILM+0x01 → log - Reads
bwsfrom ILM+0x0c,tg_featurefrom ILM+0x0e,ct_featurefrom ILM+0x10 → log - Loads global flag; if flag=1: sends browse connect req; else: exits to function epilogue
tg_feature is read and logged, then discarded. Whether 0 or non-zero, behavior is identical. cardinality is not set here.
connect_ind handler and CONNECT_RSP payload
connect_ind handler (0x619C) calls btmtk_avrcp_send_connect_ind_rsp (PLT 0x3618) at 0x62A8:
0x62a0: ldrb.w r1, [sp, #0x170] ; r1 = conn_id
0x62a6: movs r2, #1 ; r2 = 1 (accept)
0x62a8: blx #0x3618 ; btmtk_avrcp_send_connect_ind_rsp(conn_ptr, conn_id, 1)
CONNECT_RSP payload (msg_id=507):
byte[0..3] 0x00000000
byte[4] conn_id
byte[5] 0x01 (accept flag, hardcoded)
byte[6] 0x00 (no tg_feature_code sent to mtkbt)
byte[7] 0x00
g_tg_feature (set to 0x0e by C2b) is not included in the CONNECT_RSP payload. mtkbt's CONNECT_CNF tg_feature field is populated from mtkbt's own internal SDP registration state — D1 enables that registration, but mtkbt reports tg_feature=0 regardless.
Java layer audit (Trace #4 cross-reference)
(See the Trace #4 section earlier in this document for the full decompilation. Summary preserved here for reference:)
BTAvrcpMusicAdapter.getSupportVersion()Breturns0x0eifsPlayServiceInterfaceis true, else0x0d. Confirms F2's importance:disable()must reset the flag so re-activation doesn't see stale state.BTAvrcpMusicAdapter.checkCapability()Vbuilds the 1.4 EventList[1, 2, 9, 10, 11](PLAYBACK_STATUS_CHANGED, TRACK_CHANGED, NOW_PLAYING_CONTENT_CHANGED, AVAILABLE_PLAYERS_CHANGED, ADDRESSED_PLAYER_CHANGED) when v=0xe.BTAvrcpMusicAdapter.registerNotification(B, I)Z(the cardinality update site): events 1/2/9 → handle (bReg=true); 3/4/5/8/13 → blocked (bReg=false); 10/11/12 → fall through. IfbReg:[email protected](eventId)and log[BT][AVRCP] mRegBit set %d Reg:%b cardinality:%d.
Definitive verdict (Trace #4): logcat across multiple sessions shows neither [BT][AVRCP](test1) registerNotificationInd eventId:%d nor the cardinality update log. registerNotificationInd never fires — the JNI never receives a "REGISTER_NOTIFICATION arrived" event from mtkbt. Java layer is definitively ruled out.
Where the cardinality:0 gate is
The gate is unambiguously inside mtkbt's native AVRCP layer, between AVCTP RX and the JNI dispatch socket. Per the 2026-05-04 conclusion, this is because mtkbt's compiled command set is 1.0-only — AVRCP 1.3+ COMMANDs from peers reach the AVCTP layer but are not classified by mtkbt as anything its 1.0 dispatcher recognises, and are silently dropped. Candidate drop sites identified for the user-space proxy work:
- mtkbt's AVCTP receive handler at fn
0x6d9ba(live0x40128d9aper probe v3 PIE base0x400c1000) — silently drops the inbound L2CAP frame before dispatch. - The silent-drop site at
0x0513a4(live0x401123a4) —[AVRCP][WRN] AVRCP receive too many data. Throw it!. - The L2CAP→AVCTP demux logic upstream of
0x6d9ba— wrong PSM routing, missing peer-state guard, etc. 0x6cf30(live0x40128f30) — AVCTP_ConnectRsp.
These are the gdbserver targets for Phase 1 of the proxy work (see "Path forward" section above).
All Patches — Complete Status
This section's table previously enumerated the legacy 11-patch --avrcp set against mtkbt plus the C2a/b/C3a/b in libextavrcp_jni.so plus C4 in libextavrcp.so plus H1-H3 in /sbin/adbd — every entry of which has since been deleted (the legacy --avrcp set in v2.0.0; H1-H3 in v1.7.0, then patch_adbd.py / patch_bootimg.py deleted in v2.1.0). The current shipped patch set is in PATCHES.md Patch ID Legend. F1 and F2 against MtkBt.odex survive into the current tree (with current docstrings reflecting the 1.3 wire shape, not the legacy 1.4 framing) — see PATCHES.md §patch_mtkbt_odex.py.
Binary Reference Data
Stock MD5s and structural reference for every binary the patcher chain touches. Output (patched) MD5s are not pinned here — each src/patches/patch_*.py carries its own STOCK_MD5 + OUTPUT_MD5 constants and updates them in lockstep with the patch logic; that's the authoritative source.
mtkbt
| Property | Value |
|---|---|
| Stock MD5 | 3af1d4ad8f955038186696950430ffda |
| File size | 1,029,140 bytes |
| Format | ELF32 LE ARM, ET_DYN (PIE), base 0x00000000 (live PIE base on v3.0.2: 0x400c1000 per probe v3) |
| ISA | ARM Thumb-2 throughout |
ELF segment map:
| Region | File offset | Vaddr | Flags |
|---|---|---|---|
| RX (code + rodata + SDP blob) | 0x00000000 | 0x00000000 | R-X |
.data.rel.ro.local | 0x000f3d40 | 0x000f4d40 | RW- |
.data (descriptor table) | 0x000f9000 | 0x000fa000 | RW- (vaddr+0x1000) |
| BSS | — | 0x000fbe60–0x001be63d | RW- (no file bytes; size 0xc27dd) |
MtkBt.odex
| Property | Value |
|---|---|
| Stock MD5 | 11566bc23001e78de64b5db355238175 |
| Format | ODEX deyn0360, embedded DEX dexn0350 at offset 0x28 |
libextavrcp_jni.so
| Property | Value |
|---|---|
| Stock MD5 | fd2ce74db9389980b55bccf3d8f15660 |
| Format | ELF32 LE ARM, ET_DYN, base 0x00000000 |
Global g_tg_feature | 0xD29C |
Global g_ct_feature | 0xD004 |
| CONNECT_CNF handler | 0x62EA (msg_id=505, TBH index=4) |
| connect_ind handler | 0x619C (msg_id=506, TBH index=5) |
getCapabilitiesRspNative | 0x5DE8 (FUN_005de8) |
activateConfig_3req | 0x375C |
ILM layout in CONNECT_CNF receive loop stack frame:
| ILM offset | sp offset | Field | Observed value (peer 38:42:0B:38:A3:3E) |
|---|---|---|---|
| +0x00 | sp+0x170 | conn_id (byte) | 1 |
| +0x02 | sp+0x172 | result (u16) | 4096 (0x1000) ← phantom lead per Trace #10; mtkbt's standard ACK status code |
| +0x0c | sp+0x17c | bws (u16) | 0 |
| +0x0e | sp+0x17e | tg_feature (u16) | 0 (cosmetic in JNI handler) |
| +0x10 | sp+0x180 | ct_feature (u16) | 0 |
libextavrcp.so
| Property | Value |
|---|---|
| Stock MD5 | 6442b137d3074e5ac9a654de83a4941a |
| File size | 17,552 bytes |
btmtk_avrcp_send_activate_req | 0x19CC |
AVRCP_SendMessage | 0x18EC |
(libextavrcp.so carried the legacy C4 patch through v1.x; deleted in v2.0.0. Stock now ships unmodified.)
libaudio.a2dp.default.so
| Property | Value |
|---|---|
| Stock MD5 | 0d909a0bcf7972d6e5d69a1704d35d1f |
| File size | 58,660 bytes |
| Format | ELF32 LE ARM, ET_DYN |
A2dpAudioStreamOut::standby_l | 0x8654 (AH1 patch site at file offset 0x000086ab) |
A2dpAudioStreamOut::standby | 0x86c0 |
A2dpAudioStreamOut::setSuspended(bool) | 0x8958 |
(The legacy /sbin/adbd Binary Reference Data subsection — Stock + arg-zero + NOP-the-blx Patched MD5s — was removed when patch_adbd.py / patch_bootimg.py were deleted in v2.1.0. See "adbd Root Patches (H1 / H2 / H3)" earlier in this doc for the historical analysis. Current root mechanism is /system/xbin/su per src/su/.)
Eliminated Paths — Do Not Pursue
| Path | Why eliminated |
|---|---|
| Patching record [13] blob alone | Not the served ProfileDescList — record [18] overrides via last-wins. |
| Old patches #2 / #3 as "read-back only" | Both target live ProfileDescList minor-version bytes — superseded by C1 / C2 at 1.4. |
| Patching 0xeba1d / 0xeba4e (legacy claim) | Unrelated bytes; 0x0311 IS registered in all three groups. |
| Descriptor table flags / ptr patches (0x0f97b2) | flags = element size, not control bit. |
| FUN_00022cec MOVW cluster (0x00012d7c, 0x00012d84) | Not on any SDP path. |
ldrb.w intercept at 0x0000ead4 | FUN_000108d0 ignores its r1 parameter. |
| Version sink at FUN_000afd60 (0x000afd6a) | Downstream of SDP record construction. |
| Code caves in RX segment | All null blocks are live SDP / string data. |
Code caves in .data | RW- segment — non-executable; causes BT crash. |
| BSS caves | No file bytes; loader zeroes before execution. |
E1 0x29be4 BNE.W→NOP | State gate is intentional; bypass caused unsolicited responses → car disconnect. Reverted 2026-05-01. |
E2 0x0309ec BNE→NOP | Branch routes 1.3/1.4 cars to correct count=4 path; NOP'ing it bypassed init. Reverted 2026-05-01. |
| E5 / E7a / E7b | Empirically inert across all three peers; Trace #1f confirmed the patched functions ARE reachable via PIC callback registration, but the patched code paths are not exercised at runtime for our peer state. Removed 2026-05-02. |
| G1 / G2 xlog→logcat redirect | Crashed mtkbt at NULL fmt; even with NULL guard, BT framework couldn't enable. Reverted 2026-05-03. Path closed within current constraints. |
__xlog_buf_printf capture without root | Special MTK tooling required. Superseded by @btlog passive tap (Trace #9, requires root). |
Property-only adbd root via default.prop | OEM adbd has stripped the standard should_drop_privileges() gating; ro.secure=0 is inert (confirmed empirically 2026-05-03 — adb shell id returned uid=2000(shell) with all properties correctly set). |
H1 / H2 / H3 binary patches in /sbin/adbd (NOP-the-blx and arg-zero revisions) | Tried 2026-05-03; both caused "device offline" on hardware. Static analysis found no getuid() gate, no uid==2000 compare; the failure mode is something we can't see without on-device visibility (which we lose the moment we ship a broken adbd). --root flag removed from the bash in v1.7.0; superseded 2026-05-03 (v1.8.0) by the setuid /system/xbin/su install which leaves /sbin/adbd untouched. |
AttrID 0x0311 SupportedFeatures via SDP response | Initial claim "not registered" was incorrect — IS registered in all three groups. E3 / E4 patches the served value. |
| IBTAvrcpMusic / binder dispatch | Not the gate (Trace #4 ruled out the Java layer). |
HCI snoop (persist.bt.virtualsniff) | Breaks BT init. Superseded by @btlog passive tap (Trace #9). |
Chip firmware (mt6572_82_patch_e1_0_hdr.bin) | WMT common subsystem only — sleep / coredump / queue / GPS / Wi-Fi power. Zero AVRCP code. |
libbluetooth*.so libs (Trace #7) | All four libs are HCI / transport-only — UART link to MT6627, GORM / HCC chip-bringup, NVRAM BD-address management. Zero hits for avrcp / avctp / profile / capability / notif / metadata / cardinal. mtkbt is the AVRCP processor. |
0x6d04a AV/C parser as patch site | Confirmed dead code via multiple independent searches (no callers via any mechanism). |
| Java-side patches beyond F1 / F2 (Trace #4) | Java initializes correctly for AVRCP 1.4; no version gate or capability check would suppress events when they DO arrive. |
Browsing-bit experiment (E3 / E4 0x33 → 0x73) | Landed on the wire (sdptool confirmed 0x0073); peer behaviour identical to baseline. Disproven 2026-05-04. Tooling deleted. |
Pixel-shape experiment (B / C bumped to AVCTP 1.4 + AVRCP 1.5; E3 / E4 0x33 → 0xd1 Cat1+PAS+Browsing+MultiPlayer) | Landed on the wire; peer (Sonos) tried to open AVCTP browse PSM 0x1B; mtkbt has no L2CAP listener for that PSM (+@l2cap: cannot find psm:0x1b!); peer gave up. Disproven 2026-05-04. Tooling deleted. |
| Pixel-1.3 mimicry experiment (B / C dropped to AVCTP 1.2 + AVRCP 1.3; E3 / E4 → 0x01; A1 / F1 reverted) | Landed on the wire; peer (Sonos) sent one AVRCP COMMAND (AVCTP_EVENT:4 with transId:0); mtkbt dropped silently; peer gave up. Disproven 2026-05-04. Tooling deleted. |
Features-only experiment (E3 / E4 0x33 → 0x01 keeping AVRCP 1.4) | Same dropped-COMMAND failure as Pixel-1.3 mimic. Disproven 2026-05-04. Tooling deleted. |
| Y1MediaBridge actively interfering | Bridge-disable test 2026-05-04 confirmed bridge is innocent: same failure mode with bridge present (mbPlayServiceInterface=true) or disabled (mbPlayServiceInterface=false). The 1.4-version push comes from F1 (in odex) + B / C / E patches, not from the bridge. Bridge implements IBTAvrcpMusic correctly via raw onTransact dispatch — but MtkBt's BTAvrcpMusicAdapter never calls registerCallback against it because no peer-side AVRCP COMMAND ever reaches MtkBt to trigger the call. Bridge stays idle as a downstream consequence of the upstream silence. |
Post-Flash Verification Checklist
After apply.bash --avrcp [other flags] lands on the device, sanity-check from a host with adb shell:
- SDP record shape —
sdptool browse <Y1_BT_ADDR>from a paired peer:- AVRCP TG record (UUID
0x110c):AV Remote (0x110e) Version: 0x0103(V1) andAVCTP uint16: 0x0102(V2) - Attribute
0x0100ServiceName "Advanced Audio" present (S1); attribute0x0311SupportedFeatures absent (S1 swap)
- AVRCP TG record (UUID
- Patcher output MD5 ↔ on-device MD5 — pull each patched binary and compare against the
OUTPUT_MD5constant pinned in the corresponding patcher:mtkbt→src/patches/patch_mtkbt.py::OUTPUT_MD5libextavrcp_jni.so→src/patches/patch_libextavrcp_jni.py::OUTPUT_MD5(regenerated when the trampoline blob changes)MtkBt.odex→src/patches/patch_mtkbt_odex.py::OUTPUT_MD5libaudio.a2dp.default.so→src/patches/patch_libaudio_a2dp.py::OUTPUT_MD5
- Y1MediaBridge installed and running —
dumpsys package com.y1.mediabridge | grep versionCodematchessrc/Y1MediaBridge/app/build.gradle;ps | grep com.y1.mediabridgeshows the service. - Trampoline chain emitting metadata —
tools/dual-capture.shagainst a peer CT exercising play/pause + metadata fetch; in the resulting btlog look for outboundmsg=540(GetElementAttributes response) frames carrying the seven §5.3.4 attributes after a CT-side metadata query. - AVRCP NACKs absent — same capture, count of inbound
msg=520(NOT_IMPLEMENTED) reject frames should be zero (or scoped to the explicit T_continuation reject for unsolicited 0x40 / 0x41). - AH1 holding A2DP up across pauses — pause + wait ≥3 s + resume from peer; capture should show zero
[A2DP] a2dp_stop. is_streaming:1lines around the pause/resume window. - Root works —
adb shell→su→idreturnsuid=0(root) gid=0(root); prompt$→#.
Log Tags
| Tag | Layer |
|---|---|
DebugY1 | Innioasis Y1 stock player |
Y1MediaBridge | Y1MediaBridge bridge service |
MMI_AVRCP | MtkBt.apk AVRCP middleware |
JNI_AVRCP | libextavrcp_jni.so JNI bridge |
EXT_AVRCP | libextavrcp_jni.so / libextavrcp.so |
BWS_AVRCP | AVRCP 1.4 browse layer |
EXTADP_AVRCP | Adapter layer |
Trace #8 (2026-05-04, post-root) — MSG_ID_BT_AVRCP_CONNECT_CNF emit-path map in mtkbt
Pure static analysis on stock mtkbt MD5 3af1d4ad…, driven by the post-root pivot to "find where result=0x1000 is set" before reaching for gdbserver. The result:4096 lead was disproven by Trace #10; this trace's emit-chain map is preserved because it documents the IPC dispatcher structure that the user-space proxy work's Phase 4 (outbound RSP path) will need.
Emit chain identified end-to-end:
| Layer | Vaddr | Role |
|---|---|---|
| msg_id 505 send | 0x000511c0 | Common ILM send wrapper (b.w 0x67bc0); shared by every adp message. |
| CONNECT_CNF builder stub | 0x000512a8 | The only site in the binary that issues msg_id 505. Allocates 24-byte buf via allocator at 0x6a29c, lays out: buf+4=conn_id (arg1 byte), buf+5=flag (arg4 byte), buf+6=result u16 (arg2), buf+8..15=memcpy(arg3, 8). The JNI's ILM offsets are buf+4-relative — JNI's ILM+0x02 ⇔ buf+6. |
| Stub caller (sole) | 0x000515c4 | bl 0x512a8. Picks args from a dispatcher event struct in r4: arg2 = ldrh r2, [r4, #2] ⇒ event[2:4] = result u16 in CONNECT_CNF. |
| Event-code dispatcher | 0x000514a4 | ldrb r3, [r4, #0]; cmp r3, #102; tbh [pc, r3, lsl#1] — generic AVRCP-adapter event-router. Case 3 = CONNECT_CNF (TBH entry value 0x77 → handler at 0x000515b6). |
| Event constructor (CONNECT_CNF) | 0x0000f7b0 | The only function found that does movs r1, #3; strb.w r1, [sp] then blx r2 where r2 = ctx[4] (= dispatcher fn ptr). Builds the event on its own stack and dispatches via ctx->callback. |
Where the 0x1000 enters the system (sibling path): Same code-region neighbour 0x0000f83c calls helper 0x00010404 with r1 = 0x1000 (bytes verified: 4f f4 80 51 at 0xf8a6). Helper 0x10404 lays out an event on a 1872-byte stack frame: strh.w r1, [sp, #6] (=event[2:4] = 0x1000) and strb.w r5, [sp, #4] where r5 = #8 — so it dispatches event_code=8, not 3. The dispatcher's case-8 handler at 0x00051622 reads event[8..12] but does not read event[2:4]. So this 0x1000-injection path does not directly reach CONNECT_CNF's result field — it produces a different msg_id with a 0x1000 status payload.
Second TBH dispatcher at 0x000518ac (msg_ids 500-611, JNI→mtkbt direction):
- 500: ACTIVATE_REQ
- 502: DEACTIVATE_REQ
- 504: connect-related
- 507: CONNECT_RSP
- 508/513: disconnect-related
- 511, 515, 517, 520, 522, 524…560+: various AVRCP COMMAND-class messages
- The full TBH map is in the binary; consult via Trace #8's tooling (
objdump -d+ Python xref pass) when needed.
Negative results (so the next person doesn't redo them):
- No site in the binary directly stores
0x1000to[rN, #2]of any struct (zero hits across allmov*/strh*pair scans in.text). - 28 sites store
0x1000to[rN, #0xe](= ILM+0x0e =tg_feature) — concentrated in the0x13xxx–0x15xxxrange. Fits the bit-12 = "feature degraded" hypothesis but doesn't directly set CONNECT_CNF result. - Dispatcher
0x000514a4has zero direct callers and zero R_ARM_RELATIVE relocs and zero word-aligned hits in.data/.data.rel.ro— registered via the same PICadd Rn, pccallback-registration pattern documented for0x29e98(Trace #1f). - The second msg_id-505 hit at
0x00071ffais a false positive — 505 there is the source line number passed to__xlog_buf_printf(signaturexlog(level, line_no, fmt, …)), not an ILM msg_id.
Tooling: linear objdump -d of the whole binary into /tmp/mtkbt.dis (~290k lines) plus a small Python pass that parses mn/rest/addr and resolves PC-relative xrefs by walking back from add Rn, pc to the prior ldr Rn, [pc, #N]. Confirmed correct against known-good xrefs to [AVRCP] avctpCB AVCTP_EVENT:%d (0xc8c7e) and bt.ext.adp.avrcp (0xda7f9).
Trace #9 (2026-05-04, post-root) — @btlog passive tap unlocks __xlog_buf_printf + HCI snoop in one stream
The post-root probe (tools/probe-postroot.sh + …-device.sh) found that mtkbt runs socket_local_server("btlog", ABSTRACT, SOCK_STREAM) at vaddr 0x6b4d4 and that the abstract socket @btlog (inode 1497, mtkbt fd 13) is a SOCK_STREAM listener with SO_ACCEPTCON set. Built src/btlog-dump/ — a 1016-byte no-libc ARM ELF using the same direct-syscall style as src/su/ — that opens an AF_UNIX/SOCK_STREAM socket, connect()s to the abstract @btlog address, and pipes read() to stdout. Connect requires no handshake; mtkbt starts pushing the moment a client attaches.
First capture confirms the stream contains both layers we needed:
- HCI command / event traffic — fully decoded:
HCC_INQUIRY,HCC_CREATE_CONNECTION,HCC_WRITE_SCAN_ENABLE,HCC_AUTH_REQ,HCC_READ_REMOTE_FEATURES,HCC_READ_REMOTE_VERSION,HCC_READ_REMOTE_EXT_FEATURES,HCE_COMMAND_COMPLETE,HCE_READ_REMOTE_FEATURES_COMPLETE,HCE_READ_REMOTE_VERSION_COMPLETE,[BT]GetByte:/[BT]PutByte:byte-level transport. __xlog_buf_printfoutput — every[AVRCP]…,[AVCTP]…,[L2CAP]…,[ME]…,[BT]…,SdpUuidCmp:…,ConnManager: event=…log line that's invisible to logcat.
Framing format (preliminary, by inspection):
| Bytes | Field |
|---|---|
| 1 | Start marker 0x55 ('U') |
| 1 | Always 0x00 (separator / flag?) |
| 1 | Frame length |
| 2 | Sequence ID (alphabetic, increments — bl, bm, bn, …) |
| 1 | Severity / category (0x12 for xlog text, 0xb4 for HCI snoop) |
| 1 | 0x00 pad |
| body[0..1] | Often constant 00 e5 |
| body[2..6] | Timestamp (u32 LE; monotonic per process lifetime, separate domains per severity) |
| body[6..10] | Zero / flag bytes |
| body[10..12] | u16 LE — typically the format-string base length |
| body[12..] | Variable-length sub-header (often NUL padding for arg alignment), then format string + substituted args, NUL-terminated |
Severities seen: 0x12 (xlog text) and 0x07 / 0x08 / 0xb4 (HCI snoop / module-specific).
See src/btlog-dump/README.md for the maintained version of this format documentation.
What this tooling collapsed from the prior plan:
- HCI snoop / btsnoop: DONE via
@btlog. No need to pushhcidumpor fight withpersist.bt.virtualsniff. __xlog_buf_printfcapture: DONE via@btlog. Same stream.- Surgical
__android_log_printinstrumentation: no longer needed for read-only observation. The xlog tag IS the log; we just had no way to read it before.
Trace #10 (2026-05-04, post-root) — first dual capture (Sonos Roam) kills the result:4096 lead
Captured tools/dual-capture.sh against Sonos Roam at /work/logs/dual-sonos-attempt1/ — 1.5 MB btlog.bin, 159-line logcat.txt. The smoking-gun line landed cleanly:
05-03 23:29:43.371 710 710 I JNI_AVRCP: [BT][AVRCP]+_activate_1req index:0 version:14 sdpfeature:35
05-03 23:29:43.371 710 710 I EXTADP_AVRCP: msg=500, ptr=0xBEA64D30, size=8 ← JNI sends ACTIVATE_REQ
05-03 23:29:43.373 710 2451 I JNI_AVRCP: [BT][AVRCP] Recv AVRCP indication : 501 ← JNI receives ACTIVATE_CNF
05-03 23:29:43.374 710 2451 V EXT_AVRCP: [BT][AVRCP] activate_cnf index:0 result:4096 ★
… 22 seconds later, peer initiates connect …
05-03 23:30:06.084 710 2451 I JNI_AVRCP: [BT][AVRCP] Recv AVRCP indication : 506 ← CONNECT_IND from mtkbt
05-03 23:30:06.085 710 2451 I EXTADP_AVRCP: msg=507, ptr=0x523D3A98, size=8 ← JNI sends CONNECT_RSP
05-03 23:30:06.139 710 2451 I JNI_AVRCP: [BT][AVRCP] MSG_ID_BT_AVRCP_CONNECT_CNF conn_id:1 result:4096 ★
05-03 23:30:06.139 710 2451 I JNI_AVRCP: [BT][AVRCP] MSG_ID_BT_AVRCP_CONNECT_CNF bws:0 tg_feature:0 ct_featuer:0
result:4096 appears 3 ms after the JNI sends ACTIVATE_REQ — purely local mtkbt processing, before any peer is involved. The same result:4096 then re-appears at CONNECT_CNF time. 0x1000 is mtkbt's standard "request acknowledged" status code, set on every CNF mtkbt emits to the JNI — not a "feature degraded" or peer-feedback indicator at all.
This kills the previously-listed primary lead. The Trace #8 emit-chain map is still useful (the IPC dispatcher structure is needed for the proxy work) but no longer aimed at "find where 0x1000 is set" — that question is answered.
What the dual capture actually shows about the peer:
- Sonos Roam (
38:42:0B:38:A3:3E) initiates the connection 22 s after the JNI activate completes — likely after Sonos's own scan / discover cycle. - L2CAP / AVCTP come up cleanly: 3×
l2cap conn_rsp result:0, 7×handleconfigrsp result:0onpsm:0x19, then[AVCTP] chid:66(channel ID varies between captures — was0x67in Trace #9). - AVRCP profile-level connect succeeds end-to-end:
connect_ind(msg 506) →CONNECT_RSP(msg 507) →CONNECT_CNF(msg 505). - After the connect, only one
AVCTP_EVENT:4(RECV_DATA-class event) fires from the peer, accompanied by[AVRCP] transId:0, then silence — no further AVCTP RX activity, noGetCapabilities, noRegisterNotification. Sonos is not following up the basic AVRCP-profile connect with the AVRCP COMMAND PDUs a 1.4 controller should send. - The Y1 stays in this connected-but-silent state indefinitely until A2DP drops, at which point mtkbt cleans up via
AVRCP: disconnect because a2dp is lost. - Java-side
cardinality:0inACTION_REG_NOTIFYlines is exactly what we'd expect from this state —mRegBitis empty because no peer has issued REGISTER_NOTIFICATION.
Trace #11 (2026-05-04, post-root) — Browsing-bit experiment failed, real-world reference peer comparisons settle the gate-location question
Three independent threads, one conclusion.
Thread A: Browsing-bit experiment
Hypothesis: served SupportedFeatures = 0x0033 omits Browsing bit (0x40); some 1.4 controllers may decline AVRCP COMMANDs against a TG that doesn't claim Browsing.
Built a non-destructive bash wrapper that swaps src/patches/patch_mtkbt.py for an alternate that overrides E3 / E4 after bytes from 0x33 → 0x73, runs the standard --avrcp --bluetooth flow, then restores the original on EXIT. Flashed and re-captured against Sonos.
Direct evidence the experiment landed on the wire: btlog SdpUuidCmp:uuid1, len=2, (11 e, 9 1, 4 9, 0 73) — the served bytes for AVRCP TG (0x110e) are now Version=0x0104 + SupportedFeatures=0x0073. Compare to Trace #9's 0x0033.
Result: peer behaviour identical to baseline. 14× cardinality:0 lines, none non-zero. Same single [AVRCP] avctpCB AVCTP_EVENT:4 → [AVRCP] transId:0 → silence. Same MSG_ID_BT_AVRCP_CONNECT_CNF result:4096 bws:0 tg_feature:0 ct_featuer:0. L2CAP / AVCTP config exchange clean.
Hypothesis #1 dead. Tooling deleted on cleanup.
Thread B: hypothesis-#3 static ([AVRCP] transId:0)
Two callers of the [AVRCP] transId:%d log function (0x11374 static / 0x400d2374 live). Both read transId directly from inbound packet bytes (event[1] in caller 0x1457c..0x1458a; event[5] in caller 0x51a20). The transId:0 we observe is the actual transId byte the peer sent on the wire — not mtkbt mangling the value. transId is a 4-bit AVCTP-header field; 0 is a perfectly valid value for the first packet on a fresh AVCTP channel. Hypothesis #3 dead.
Bonus from the same pass: @btlog's [BT]GetByte: / [BT]PutByte: lines around the AVCTP_EVENT:4 timestamp give a per-byte HCI trace. Decoded, mtkbt sends an outbound L2CAP CONFIG_REQ; Sonos sends back its own CONFIG_REQ for our cid 0x42 carrying MTU=1024 — standard AVCTP control-channel config. Then AVCTP_EVENT:4 fires once and AVRCP-layer activity stops.
Thread C: real-world reference-peer comparisons (the decisive evidence)
User-supplied empirical data:
| Test | AVRCP works? | Implication |
|---|---|---|
| Pixel 4 (TG) ↔ Sonos Roam (CT), Sonos app shows now-playing metadata | ✅ | Sonos is a real working 1.4 controller |
| Y1 (TG) ↔ Sonos Roam (CT), our captures | ❌ | Y1's TG is broken |
| Y1 (TG) ↔ car head unit (CT) | ❌ — no metadata, play / pause broken | Y1's TG is broken end-to-end on the actual goal device (cars are the project's primary AVRCP target per the README's history) |
The play / pause break is the load-bearing finding. Play / pause flows car→Y1 as AVRCP PASS_THROUGH commands. Functional break in the CT→TG command path — not just notification-cosmetic. Same root cause as cardinality:0.
Combined verdict
The gate is on the Y1 side, not on any peer. Sonos's "single AVCTP_EVENT:4 then silence" pattern is Sonos sending its first AVRCP command (likely GetCapabilities), getting nothing usable back from Y1, and giving up.
Pixel 4 SDP record across all four AVRCP versions (Pixel Developer-Options-forced, captured 2026-05-04):
| Attribute | Pixel-1.3 | Pixel-1.4 | Pixel-1.5 | Pixel-1.6 |
|---|---|---|---|---|
| 0x0004 AVCTP version | 0x0102 (1.2) | 0x0103 (1.3) | 0x0104 (1.4) | 0x0104 (1.4) |
| 0x0009 AVRCP version | 0x0103 | 0x0104 | 0x0105 | 0x0106 |
| 0x000d AdditionalProtocolDescList | MISSING | PSM 0x001b AVCTP 1.3 | PSM 0x001b AVCTP 1.4 | PSM 0x001b AVCTP 1.4 + OBEX (Cover Art) |
| 0x0311 SupportedFeatures | 0x0001 (Cat1 only!) | 0x00d1 | 0x00d1 | 0x01d1 (extra bit 8) |
User-confirmed: at every Pixel-AVRCP-version setting (1.3 / 1.4 / 1.5 / 1.6), Sonos receives full title / artist / album metadata + responds correctly to play / pause from Pixel. Cover art doesn't transfer (Sonos-side limitation).
This is what makes the 2026-05-04 conclusion definitive: at AVRCP 1.3, the bare-minimum SDP record (Cat1 features, no AdditionalProtocolDescriptorList, AVCTP 1.2) is sufficient for Sonos to engage AVRCP COMMAND traffic — if the implementation actually responds to those commands. Y1 stock advertises features 0x0001 exactly like Pixel-1.3 but at AVRCP 1.0; Sonos doesn't bother sending COMMANDs because AVRCP 1.0 is too primitive. Y1 patched to 1.3+ advertises a richer record but mtkbt drops the COMMANDs Sonos then sends. mtkbt is a 1.0-class implementation regardless of SDP advertisement.
Trace #12 (2026-05-05, post-root) — full silent-drop chain mapped end-to-end via gdbserver
This trace settled the silent-drop architecture conclusively. Five gdb capture iterations narrowed the problem from "somewhere in mtkbt" to a 2-byte patch site, then exposed the next gate one binary upstack.
Setup
Built tools/install-gdbserver.sh (fetches a sha256-pinned ARM 32-bit static gdbserver from aosp-mirror/platform_prebuilt, commit f5033a8c, sha256 1c3db6a3..., 186112 bytes — last touched upstream 2010) and tools/attach-mtkbt-gdb.sh (pushes gdbserver, attaches to live mtkbt PID, computes PIE base, generates a commands-driven gdb command file with breakpoints at the critical sites and silent printf+continue blocks). Watch-items learned the hard way:
- mtkbt is all Thumb-2. Plain even-addressed BPs make gdb plant 4-byte ARM BKPTs that corrupt Thumb instructions → mtkbt SIGSEGV at NULL on the first BP hit. Fix:
set arm fallback-mode thumb+set arm force-mode thumbin the gdb file (NOTaddr | 1— that breaks gdb's trap-time PC lookup). - After mtkbt SIGSEGV mid-debug, gdbserver wedges with the dead PID's ptrace slot. Fix: clean up stale gdbserver via
/procwalk before each attach, drop the adb forward. - mtkbt respawns automatically on crash; BT off→on resets cleanly.
What --avrcp (V1+V2+S1, then --avrcp-min in the historical iter1) shows
With AVRCP 1.3 + AVCTP 1.2 + a 0x0100 ServiceName attribute on the served SDP record, Sonos sends a real AV/C VENDOR_DEPENDENT GetCapabilities (op_code 0x00, vendor BT-SIG 0x001958, PDU 0x10, capability_id 0x02 = EVENTS_SUPPORTED). Confirmed by gdb breakpoint dumps of the inbound L2CAP frame bytes. This contradicts the earlier 2026-05-04 reading of Trace #10's capture, which assumed the inbound was a malformed/dropped command — it was actually a 14-byte real GetCapabilities all along.
The full mtkbt RX chain (PASSTHROUGH vs VENDOR_DEPENDENT)
Both frame types follow the same path through:
- AVCTP RX inner TBH at file
0x6da7a— keyed on[r5,#0](event subtype 0..8); subtype 3 routes to the AV/C-bearing path. - Classifier at
0x6db7c—ldrb r0, [r5,#5]; cmp r0, #1; bhi 0x6dc3a. For both PASSTHROUGH and VENDOR_DEPENDENT,[r5,#5]=0so AV/C parse path taken. - AV/C parse at
0x6dba0+— extracts ctype/subunit_type/subunit_id/op_code from frame bytes 0..2, stores atconn+160..163. - event_code=4 setter at
0x6dc36. - Dispatch at
0x6de64via[r4+244]callback (= fn at file0xfb04, set up viaregister_callbackfn at0x6ce78from caller at0xeaecwith PSM=0x17 and a callback-fn-ptr literal). - Inside fn
0xfb04's default arm, →bl 0x145b0(the AV/C-event handler in fn0x147dc's case 4 = TBH index 3). - fn
0x145b0stores frame bytes atconn+2956..andconn+2400+9; callsbl 0x144bc. - fn
0x144bcop_code dispatch at0x144e8—ldrb r3, [r6,#3]reads op_code fromconn+163:r3 == 0x7c(PASSTHROUGH) →b.n 0x14528→bl 0x10404→ emits msg_id 519 to JNI.r3 < 0x30orr3 != 0x7c(VENDOR_DEPENDENT op_code 0x00, also UNIT_INFO 0x30, SUBUNIT_INFO 0x31, etc.) →bcc 0x1454aorbne 0x1454a→bl 0x11374→ log only, silent drop.
The captured r2 at fn 0x144bc entry differs (3 for PASS, 9 for VENDOR), but that's downstream of the gate at 0x144e8. The actual gate is the op_code branch.
P1 patch (mtkbt, file offset 0x144e8)
Two-byte rewrite of cmp r3, #0x30 → b.n 0x14528:
| Bytes (LE) | Encoding | |
|---|---|---|
| stock | 30 2b | cmp r3, #0x30 (0x2b30) |
| patched | 1e e0 | b.n 0x14528 (0xe01e, +0x3c from PC at 0x144ec) |
Forces all AV/C frames through the bl 0x10404 → msg 519 emit path regardless of op_code. Hardware-verified 2026-05-05: VENDOR_DEPENDENT GetCapabilities now reaches JNI as MSG_ID_BT_AVRCP_CMD_FRAME_IND size:9 rawkey:0 data_len:9 with the AV/C-body bytes intact.
Ships as the fourth patch in src/patches/patch_mtkbt.py. Stock mtkbt md5 3af1d4ad8f955038186696950430ffda → output a37d56c91beb00b021c55f7324f2cc09.
What's NOT yet solved — the JNI's "unknow indication" path
The JNI receive function in libextavrcp_jni.so is _Z17saveRegEventSeqIdhh at file 0x5ee4. It dispatches msg 519 on frame size:
cmp.w lr, #3at0x6452— size 3 → PASSTHROUGH path; callsbtmtk_avrcp_send_pass_through_rspcmp.w lr, #8at0x6524— size 8 → branch with a BT-SIG vendor check (cmp r1, #0x5819at0x656a); on match, jumps to0x65a4(VENDOR_DEPENDENT handling)- otherwise →
0x65bc→ "unknow indication" + dump first 16 bytes + default reject (msg_id 520 CMD_FRAME_RSP with NOT_IMPLEMENTED)
P1 produces size=9 frames (the 14-byte AV/C frame minus 3-byte AV/C header minus 2 leading bytes — the trampoline path strips slightly differently from the size=8 path). Size=9 falls into "unknow indication", and the inbound is auto-rejected before reaching Java's BTAvrcpMusicAdapter.
The candidate next patch is at file 0x6526 of libextavrcp_jni.so: cmp.w lr, #8 → cmp.w lr, #9 (single byte 0x08 → 0x09). That'd route size-9 frames into the size-8 branch and onward to the BT-SIG vendor check at 0x656a. Risk: the size-8 branch's downstream reads (sp+381, sp+382, sp+385) assume a specific stack layout that size-9 frames may not satisfy, AND the path eventually calls btmtk_avrcp_send_pass_through_rsp which is the wrong response builder for a VENDOR_DEPENDENT command. May need additional patches to skip the pass_through_rsp call and / or to invoke Java's BTAvrcpMusicAdapter.checkCapability() via JNI.
A clean patch will require static-analyzing what 0x65a4+ actually does (whether it reaches Java or just logs+returns) before committing to a byte rewrite.
2026-05-05 follow-up. The single-byte J1 (cmp 8 → 9) was tried and rolled back — it routed size-9 frames through the PASSTHROUGH dispatch, generating fake key=1 isPress=0 events and never reaching Java. Path forward (now in patch_libextavrcp_jni.py) is trampoline T1: redirect bne.n 0x65bc at file 0x6538 to a code-cave at file 0x7308 (overwriting the unused JNI debug method testparmnum). The trampoline checks the PDU byte at sp+382, and on 0x10 (GetCapabilities) calls btmtk_avrcp_send_get_capabilities_rsp directly via PLT 0x35dc, then exits.
Iter5 capture (2026-05-05) — T1 confirmed working. /work/logs/dual-sonos-avrcp-min-iter5/ shows: 1 size:9 inbound (GetCapabilities) → 1 outbound msg=522 (size 30, the response) → 4 size:13 inbound (Sonos's first-ever follow-up VENDOR_DEPENDENT commands, 2-second retry pattern indicating RegisterNotification with no INTERIM ACK). For comparison, iter4 (J1) had the same size:9 inbound but msg=520 NOT_IMPLEMENTED instead of msg=522, and zero size:13 follow-ups — Sonos gave up. T1 is the first patch that gets Sonos past the GetCapabilities gate.
T2 added 2026-05-05. Trampoline T2 at file 0x72d0 (overwriting unused classInitNative debug method) handles inbound RegisterNotification(EVENT_TRACK_CHANGED). T1's fall-through arm (originally b.w 0x65bc) now bridges to T2 stage 2 at 0x72d4. T2 verifies the PDU is 0x31 and event_id is 0x02, then calls btmtk_avrcp_send_reg_notievent_track_changed_rsp (PLT 0x3384) with INTERIM (reasonCode 0x0F) and track_id = 0xFFFFFFFFFFFFFFFF ("no track"). Other registered events (0x01, 0x09, 0x0a, 0x0b) fall through to the original "unknow indication". T3/T4/T5/T6/T8/T9 follow-ups are now live in src/patches/_trampolines.py; see ARCHITECTURE.md for the current trampoline chain and BT-COMPLIANCE.md for the current spec-coverage state.
Iter6 capture (2026-05-05) — T2 confirmed working. /work/logs/dual-sonos-avrcp-min-iter6/ shows: 1× size:9 → 1× msg=522 (T1 GetCapabilities response, same as iter5); 5× size:13 inbound (RegisterNotification for the 5 advertised events); 2× msg=544 size=40 outbound firing in the same millisecond as inbound size:13 with event_id=0x02 (T2's TRACK_CHANGED INTERIM response — first-ever AVRCP 1.3-shape metadata response built by mtkbt for this device); Sonos accepted and immediately started sending size:45 GetElementAttributes (PDU 0x20, 26 retries at 2-second intervals). The size:45 retries continue indefinitely because we don't have a T4 trampoline yet — Sonos is asking "give me the track metadata!" and getting no answer. Y1MediaBridge's MediaBridgeService is being connected (PlayService onServiceConnected) so track strings are plumbed and ready; the remaining work is T4 (call btmtk_avrcp_send_get_element_attributes_rsp with the strings). T4 is the last remaining patch in the metadata path.
Iter7 / iter8 / iter9 — fix the unknow-indication path via ELF-extension T4 stub. iter6 also surfaced a separate problem: unhandled inbound frames (size:13 events ≠ TRACK_CHANGED, size:45 GetElementAttributes) generated zero outbound responses. The b.w 0x65bc fall-through from T1 / T2 was reaching the original "unknow indication" code, but that code requires r0 = r5+8 (conn buffer; set at 0x6528 in original flow) AND lr = halfword at sp+374 (= SIZE; loaded at 0x644e) — both of which the trampolines clobber. Iter7 restored r0 only (no msg=520 yet); iter8 added the lr restore (8 → 12 bytes at the 0xac54 stub). Iter9 hardware test: msg=520 NOT_IMPLEMENTED now flows for unhandled frames. Major side effect: AVRCP service stops restart-looping (iter6 had 30 PIDs cycling; iter9 has 2 stable), so PASSTHROUGH play / pause / skip now actually works on Sonos. First-ever transport-control delivery to a peer for this device.
Iter10 — single-event advertised. Iter9 surprise: Sonos aborts the entire RegisterNotification loop on its first NOT_IMPLEMENTED reply. Pre-iter9 the broken unknow path silently dropped the first reject, so Sonos timed out and accidentally tried event 0x02 (TRACK_CHANGED) anyway, which T2 acked. With proper msg=520 flowing, Sonos respects the rejection — meaning it never reaches event 0x02 unless we ack 0x01 (PLAYBACK_STATUS_CHANGED) too. Cheapest fix: advertise only event 0x02 in T1's GetCapabilities response (events count: 5 → 1; events_data: 01 02 09 0a 0b → 02). Sonos then registers only TRACK_CHANGED, T2 acks, Sonos proceeds to GetElementAttributes. Iter10 confirmed: Sonos sent 1265 size:13 + 1264 size:45 frames in a tight 70Hz loop — full path engaged but no real T4 yet to break the loop.
Iter11 — first metadata on Sonos screen. T4 implemented at vaddr 0xac54 in extended LOAD #1 (the 4276-byte page-padding region between the original LOAD #1 and LOAD #2). Single-attribute hardcoded "Y1 Test" Title response, 68 bytes. Argument layout for btmtk_avrcp_send_get_element_attributes_rsp (PLT 0x3570) inferred empirically:
- r0 = conn buffer (= r5+8)
- r1 = 0 (string-follows flag — JNI wrapper at 0x56dc dispatches on this)
- r2 = transId (jbyte at caller_sp+368; same convention as track_changed_rsp)
- r3 = 0 (placeholder; meaning unknown but works)
- sp[0] = attribute_id LSB (1=Title, 2=Artist, 3=Album, 4=TrackNumber, …)
- sp[4] = 0x6a (UTF-8 charset; JNI hardcodes this)
- sp[8] = string length (in bytes)
- sp[12] = pointer to UTF-8 string data
Iter11 hardware-verified 2026-05-05: "Y1 Test" displayed on Sonos Now Playing screen. First ever AVRCP metadata delivery from this device to a peer. Loop continues at 70Hz because the TRACK_CHANGED INTERIM with track_id=0xFFFFFFFFFFFFFFFF tells Sonos to keep re-querying (no stable identity), but the metadata path itself works.
Iter12 — multi-attribute T4 dispatch (loop, separate frames). Extended T4 to 152 bytes with a dispatch loop: parse num_attributes from inbound at sp+394, walk requested attribute_ids at sp+395+, and for each one match against {0x01, 0x02, 0x03} — calling the response builder once per supported attribute with hardcoded strings ("Y1 Title", "Y1 Artist", "Y1 Album"). Unsupported attributes (0x04-0x07) silently skipped. Hardware-verified iter12 2026-05-05: ratio 3:1 of msg=540 to size:45 — three frames per query. Sonos accepted the first frame and displayed "Y1 Title" only; subsequent frames with same transId were ignored as duplicates. Output md5 fa6191d6ce8170f5ef5c8142202c8ba5.
Iter13 — multi-attribute single-frame response (correct semantics, breakthrough). After disassembling btmtk_avrcp_send_get_element_attributes_rsp at libextavrcp.so:0x2188, decoded the function's actual contract:
arg1 (r1)= "with-string / reset" flag (0 = with string, append; !=0 = no-string finalize)arg2 (r2)= attribute INDEX in this response (0..N-1) — NOT transIdarg3 (r3)= TOTAL number of attributes in this responsesp[0]= attribute_id LSBsp[4]= 0x6a (UTF-8, JNI-hardcoded)sp[8]= string lengthsp[12]= string pointer
The function maintains an internal 644-byte static buffer that's reset when (arg1!=0 OR arg2==0). It emits the IPC frame only when (arg2+1)==arg3 AND arg3!=0 (last attribute) — earlier calls accumulate. iter11/12 worked by accident because passing arg3=0 triggered the legacy single-shot send path. iter13 makes 3 sequential calls with arg2=0/1/2, arg3=3 → first two accumulate, third emits ONE frame containing all 3 attributes.
transId is NOT an argument — the function reads it from conn[17] automatically.
iter13 output md5: 56d9d8514f30a12aaf2303b7a7f6a067. Hardware-verified 2026-05-05: ratio 1:1 of msg=540 to size:45 (672 each) — exactly one emit per inbound GetElementAttributes containing all three attributes. Sonos displays Title + Artist + Album simultaneously. First time the Y1 has ever delivered a multi-attribute AVRCP 1.3 metadata response. (--avrcp-min advertises AVRCP 1.3 over AVCTP 1.2; GetElementAttributes PDU 0x20 is the 1.3 metadata-transfer feature.)
The reverse-engineered argument layout is now empirically confirmed correct. The architectural work is done. Remaining work is pure data plumbing — replacing the hardcoded "Y1 Title"/"Y1 Artist"/"Y1 Album" strings with real metadata from Y1MediaBridge (iter14: file-based plumbing via /data/local/tmp/y1-track-info).
Iter14 → 14b → 14c (data plumbing). Y1MediaBridge writes Title\0…Artist\0…Album\0… (768 B fixed-layout) to a file; T4 opens, syscall-reads, and uses the strings instead of the hardcoded ones. Iter14 (/data/local/tmp/y1-track-info) regressed Y1MediaBridge — uid 10000 has no write permission to /data/local/tmp/ (mode 0771 owner=shell), and the silent EACCES on FileOutputStream opening propagated past the IOException catch and killed the service. Iter14b moved the path to /data/data/com.y1.mediabridge/files/y1-track-info, with a setExecutable(true,false) chmod on the dir at startup so the BT process (uid bluetooth) could traverse and read. Iter14c added __android_log_print after open() to surface the fd/errno, which confirmed T4 was firing successfully on every poll — but Sonos's display still showed first-track strings on track change. The actual diagnosis: Sonos caches GetElementAttributes responses keyed by the TRACK_CHANGED INTERIM track_id. Since T2 always sent 0xFF×8, Sonos thought it was the same track forever, even though our T4 was happily delivering fresh strings.
Iter15 — state-tracked CHANGED notifications. Output md5 92bcac1ab99d7fd0e263b712f9abb2d4. Three architectural changes:
- File format: y1-track-info grows to 776 B with the
mCurrentAudioId(big-endian) at bytes 0..7 ahead of the 3 × 256 B Title / Artist / Album slots. Y1MediaBridge writes the track_id alongside the strings. - State file: a 16 B y1-trampoline-state file (mode 0666, pre-created by Y1MediaBridge at startup) lets the BT process remember (a) the last track_id we told Sonos about (bytes 0..7) and (b) the last RegisterNotification transId (byte 8). The
extended_T2trampoline writes both fields on every RegisterNotification(TRACK_CHANGED); theT4trampoline reads them on every GetElementAttributes. - Trampoline rewrite: T2's logic moves out of the cramped 44-byte
classInitNativeslot into LOAD #1's page-padding region. T2 stub at 0x72d4 becomes a singleb.w extended_T2. extended_T2 dispatches PDU/event-id internally and falls through to T4 for PDU 0x20 or to 0x65bc otherwise. T4 is rewritten cleanly (memset → open/read y1-track-info → open/read y1-trampoline-state → cmp track_id → conditionally emittrack_changed_rsp CHANGEDwith state[8] as transId + write new state → 3×get_element_attributes_rsp). The whole blob is now built dynamically from a tiny Thumb-2 assembler insrc/patches/_thumb2asm.py+_trampolines.py, rather than hand-encoded as a hex array. Total 572 bytes of trampoline + paths; LOAD #1 grows from 0xac54 → 0xae90 (still well under the 0xbc08 LOAD #2 boundary).
Hardware-tested 2026-05-06: deadlocked Sonos. Returning the file's real track_id in the INTERIM(TRACK_CHANGED) response flipped Sonos into "stable identity per track, only refresh on CHANGED" mode. Our T4 only fires when Sonos polls GetElementAttributes; Sonos won't poll until it sees a CHANGED. After the first RegisterNotification (transId=0x00, track_id=0x147), Sonos went silent for 14+ minutes despite 10 track changes. Forensics confirm:
y1-trampoline-statemtime 14 min before capture-end; bytes 0..7 = 0x147 (audioId 327)y1-track-infotrack_id at capture time = 0x151 (audioId 337) — 10 tracks ahead- 0 inbound VENDOR_DEPENDENT commands across 60 s capture (vs 2,933 in iter14c)
- AVCTP control channel up; only PASSTHROUGH (PLAY / PAUSE) flowed
- Sonos display: "No Content" / "Unknown Content" / stale "Trouble Maker" cached from the previous iter14c session
Cause: AVRCP 1.3 §5.4.2 Table 5.30 + ESR07 §2.2 / AVRCP 1.5 §6.7.2 — peer behaviour depends critically on whether the TG advertises a stable track identity in the EVENT_TRACK_CHANGED Identifier field. With a real id we entered a CT / TG handshake that requires us to push asynchronous CHANGED edges, but our trampolines are reactive only.
Iter16 — same architecture, INTERIM / CHANGED track_id pinned to 0xFF×8. Output md5 5d74443293f663bcd3765721bb690479. The change-detection bookkeeping (file bytes 0..7 vs state bytes 0..7) is preserved; only the wire-level track_id field in the response is hardcoded to the 0xFFFFFFFFFFFFFFFF "not bound to a particular media element" sentinel. Implementation: an 8-byte 0xFF constant labelled sentinel_ffx8 is appended after the path strings; extended_T2's INTERIM emit and T4's CHANGED emit both ADR.W r3, sentinel_ffx8 instead of computing a stack address. Trampoline blob grows 572 → 580 bytes; LOAD #1 ends at 0xae98.
Hardware-tested 2026-05-06: iter16 protocol layer fully working. Sonos engaged (115 inbound CMD_FRAME_INDs in 71 s, 67 RegisterNotification responses, 43 GetElementAttributes responses). Forensic dump of y1-track-info (audioId 360 = "The Kintsugi Kid (Ten Years)" / Fall Out Boy) and y1-trampoline-state (audioId 358 = "Bleed American" / Jimmy Eat World, transId=0x00) confirmed Y1MediaBridge writes the file correctly and the trampolines update state when fired. The remaining defect is polling cadence: Sonos polled aggressively for the iter16 capture window (UI was being viewed) but its idle poll rate is too slow for shuffle-heavy playback. State froze 2 audioIds behind reality, so display was stuck on "Bleed American" while the current track was "The Kintsugi Kid". The iter16 reactive trampolines can't push CHANGED without an inbound query — fundamentally a chicken-and-egg with Sonos's polling.
Iter17a — proactive CHANGED via Java→JNI hook. Output md5s libextavrcp_jni.so 37ad4394efe7686d367d08f20e6f623b, MtkBt.odex ca23da7a4d55365e5bcf9245a48eb675. Adds asynchronous CHANGED emission triggered by Y1MediaBridge's existing track-change broadcast, independent of Sonos's polling rate.
Y1MediaBridge sends com.android.music.metachanged → MtkBt's BluetoothAvrcpReceiver intercepts → updates internal state and calls BTAvrcpMusicAdapter.passNotifyMsg(2, 0) (Message what=34, arg1=2 = TRACK_CHANGED) → handleKeyMessage's sparse-switch lands at sswitch_1a3 → cardinality check BitSet.get(2) (Java-side bookkeeping; never populated because our JNI trampolines bypass the Java path → permanently 0) → if-eqz skips the native call.
Patch A (MtkBt.odex @ 0x03c530): NOP the if-eqz v5, :cond_184 (4 bytes 38 05 da ff → 00 00 00 00). The native call now fires on every track-change broadcast.
Patch B (libextavrcp_jni.so @ 0x3bc0): replace notificationTrackChangedNative's stmdb prologue with a 4-byte b.w T5. T5 lives in LOAD #1 padding alongside T4 / extended_T2 / sentinel_ffx8 and:
- Calls the JNI helper at 0x36c0 (same one the stock native used) to obtain the BluetoothAvrcpService per-conn struct → conn buffer at +8.
- Reads
y1-track-infofirst 8 bytes (current track_id from Y1MediaBridge). - Reads
y1-trampoline-state16 bytes (last-synced track_id at bytes 0..7, last RegisterNotification transId at byte 8). - If the track moved since the last sync, calls
btmtk_avrcp_send_reg_notievent_track_changed_rspvia PLT 0x3384 withreason=CHANGED,transId=state[8],track_id=&sentinel_ffx8(same iter16 sentinel — keeps Sonos in poll-on-each-event mode), then writes the new track_id back to state[0..7]. - Returns jboolean(1).
- Reads
Trampoline blob grows 580 → 768 bytes; LOAD #1 ends at 0xaf54. The reactive T4 and extended_T2 are unchanged — iter17a layers proactive CHANGEDs on top, so we get both reactive (Sonos polls) and proactive (Y1 changes track) refresh paths.
Iter17a hardware test (2026-05-06): proactive layer working, T4 multi-attribute regression discovered. Capture under /work/logs/dual-sonos-avrcp-min-iter17a/. The proactive CHANGED path is firing — msg=544 outbound count reached 4172 over the test window vs ~30 in iter16 — confirming the Java cardinality NOP + notificationTrackChangedNative → T5 chain works end-to-end. But Sonos is rendering metadata field-by-field with visible flicker (Title appearing intermittently while Artist/Album swap in/out). Diagnosed from logcat: 1299 outbound msg=540 (get_element_attributes_rsp) for ~433 inbound GetElementAttributes queries — exactly 3:1 — meaning T4 is emitting three separate msg=540 frames per query instead of one frame containing all three attributes packed in. This is the iter12 bug that iter13 had originally fixed: T4's three calls to PLT 0x3570 had arg2 = transId, arg3 = 0, hitting the function's legacy arg3 == 0 → EMIT each call path. The dynamically-assembled T4 in _trampolines.py regressed it during iter15's rewrite. The reactive change-detection logic, the file I/O, the proactive CHANGED via T5 — all working. Just the response packing is wrong.
Iter17b: T4 multi-attribute single-frame fix. Restored iter13's calling convention in _trampolines.py::_emit_t4:
r1 = 0(with-string flag, accumulate)r2 = idx(per-iteration: 0, 1, 2 — wastransId)r3 = 3(total attribute count — was0)
The function only emits when (arg2+1) == arg3 AND arg3 != 0, so calls 1+2 accumulate into the internal 644-byte buffer and call 3 packs Title+Artist+Album into a single msg=540 outbound. Trampoline blob shrinks 768 → 760 B (the 4-byte ldrb.w to load transId becomes a 2-byte movs r2, #imm); LOAD #1 ends at 0xaf4c. Stock fd2ce74db9389980b55bccf3d8f15660 → 91833d6f41021df23a8aa50999fcab9a. The multi-attribute calling convention is documented in docs/ARCHITECTURE.md "Reverse-engineered semantics: btmtk_avrcp_send_get_element_attributes_rsp"; the iter17b commit message in this section's git history explains the diagnosis. Pending hardware verification.
For full architectural detail (ELF segment-extension trick, calling conventions, msg-id taxonomy, Thumb-2 encoding gotchas), see docs/ARCHITECTURE.md.
Empirics + tooling for the next session
- Five gdbserver capture logs in
/work/logs/mtkbt-gdb-{getcap,passthrough,handler,narrow,drill}.log - Iter3 dual-capture under
--avrcp-minpost-P1 in/work/logs/dual-sonos-avrcp-min-iter3/— shows the first-everMSG_ID_BT_AVRCP_CMD_FRAME_INDfor a non-PASSTHROUGH frame plus JNI's "unknow indication" log + 9-byte hex dump. - All gdb infrastructure (
tools/attach-mtkbt-gdb.sh,tools/install-gdbserver.sh) committed and re-runnable. - Stock libextavrcp_jni.so disassembly:
arm-linux-gnu-objdump -d -M force-thumb /work/v3.0.2/system.img.extracted/lib/libextavrcp_jni.so. Has C++ symbols (unlike mtkbt). Function_Z17saveRegEventSeqIdhhis the receive loop; first 1700 bytes from0x5ee4cover the size-dispatch.
End of appendix. The brief at /root/briefs/Innioasis_Y1_AVRCP_Unified_Brief.md is now redundant with this document and may be deleted.
Hardware test history per CT
Per the spec-compliance directive (every Koensayr/AVRCP change must move toward strict AVRCP-spec compliance — spec-permissible options can be chosen for CT-compat reasons, but the chase starts from "what does the spec say"), per-device test results live here as research context, not in active code or implementation docs. Implementation files (patch_*.py, _trampolines.py, MediaBridgeService.java, docs/PATCHES.md, docs/BT-COMPLIANCE.md) cite AVRCP spec sections for rationale and reference this section for empirical validation.
CTs referenced below were used during pre-iter22 development. Future CT additions append here without changing implementation files.
Sonos Roam (deprioritized 2026-05-06 — unreliable pairing)
A2DP Bluetooth speaker. Used as the most-permissive reference baseline for iter5 → iter18d hardware verifications (/work/logs/dual-sonos-avrcp-min-iter*/). Notable observations:
- Stays in poll-on-each-event mode when TRACK_CHANGED carries the
0xFF×8sentinel (AVRCP 1.3 §5.4.2 Table 5.30 + ESR07 §2.2 / AVRCP 1.5 §6.7.2 8-byte clarification — "not bound to a particular media element"). T4's reactive emit fires per-poll, metadata refreshes on every track change. - iter15 deadlock: real synthetic track_id in INTERIM flipped Sonos into "stable identity, refresh on CHANGED" mode, but iter15's T4 was reactive only — Sonos waited for a CHANGED edge that never came (Sonos didn't poll). 14-min zero-AVRCP-traffic confirmed via state-file forensics. Resolution: iter17a added T5 for proactive CHANGED.
- iter17b verified flicker-free: msg=540:size:45 ratio held 1:1, all three attributes pack into single frame.
- iter18d verified synthetic audioId fix: three track changes captured with synthetic audioIds, real metadata via FD path, msg=544 = 1071 INTERIM + 3 CHANGED (one per track change), ratios 1:1 with no flicker.
- 2026-05-06 onwards: pairing became unreliable in user testing. Dropped from active test matrix; past captures retained as reference.
- 2026-05-08 postflash (
/work/logs/dual-sonos-postflash/): resume-from-pause needed double-tap. AVRCP 0x44 PLAY arrived at the kernel asKEY_PLAYCD(7 events confirmed ingetevent.txt) butY1Patch: PlayerService.play(Z) entrynever fired in the music app.PlaySongReceiver.MEDIA_BUTTON keyCode=forwarding log fired zero times either — the registered MediaButton dispatch ended up in a hole somewhere between AudioService andPlaySongReceiver. Open investigation. Attempted-fix on 2026-05-09 (dropregisterMediaButtonEventReceiverso AudioService's broadcast fallback could deliver to the music app's manifest-filter receiver) was reverted same-day after Kia confirmed it broke metadata delivery (MtkBt uses the registered MediaButton client to find Y1MediaBridge'sIBTAvrcpMusicBinder). Need to gdb-attach AudioService and watch where the PendingIntent send goes forKEYCODE_MEDIA_PLAY(126), or whether some other component is bumping us off the slot.
Samsung The Frame Pro (active — TV / indoor)
Smart-TV head unit. Subscribes to event 0x02 TRACK_CHANGED only (3919 RegisterNotifications in /work/logs/dual-tv-iter22b/, all event 0x02). Notable observations:
- iter19b real track_id in INTERIM destabilized the TV: ~90 Hz RegisterNotification subscribe storm against TRACK_CHANGED INTERIMs (3401 inbound
size:13over 38 seconds, sustained ~7 ms inter-frame). AVCTP saturated; PASSTHROUGH release frames dropped, producing held-key fast-forward at ~32× speed and stuck-haptic "vibrate-loop" symptoms. iter19d reverted to the 0xFF×8 sentinel which restores the spec-permissible "no media bound" mode and avoids the storm. - iter21 (Patch D in
patch_y1_apk.py) was a music-app-side defense: the FF/RW seek lambda bounded at 50 iters × 100 ms ≈ 5 s, clearingfastForwardLockon cap. Reverted in iter24 — iter23/U1 fixes the AVRCP-side trigger at the kernel input layer (no more auto-repeat on/dev/input/event4), and iter21's cap was bounding local hardware-button hold-FF/RW too, breaking long scrubs through audiobooks/DJ mixes. iter21 captures (/work/logs/dual-tv-iter21/) remain useful as the empirical baseline for the dropped-release symptom. - Does not subscribe to event 0x01 PLAYBACK_STATUS_CHANGED — uses TRACK_CHANGED edges only for any state inference. T9 (iter22b) is forward-compat for this CT.
- 2026-05-08 postflash (
/work/logs/dual-tv-postflash/): stuckKEY_PAUSECD DOWNingetevent.txtfor ~15 s before the matching UP arrived (epoch1778236775→1778236830). High RegisterNotification subscribe-storm cardinality (size:13tosize:45ratio ≈ 3.7:1) suggests the TV is re-subscribing because expected CHANGED edges aren't arriving fast enough. Likely shares root cause with the Sonos / Kia discrete-key chain-break (TG missing PASSTHROUGH releases under load) but with subscribe-storm amplification. Both still open — same investigation as Sonos's 2026-05-08 postflash entry above. - iter22d still produced the haptic loop (
/work/logs/dual-tv-iter22d-vibloop/).getevent -ltcapture pinned the source: a single PASSTHROUGH FORWARD (0x4B) press whose RELEASE was dropped emittedKEY_NEXTSONG DOWNonce on/dev/input/event4("AVRCP" uinput,BUS_BLUETOOTH), then 458KEY_NEXTSONG REPEATevents at strict 40 ms intervals until something else cancelled the held-key state. KEY_PAUSECD showed identical kernel-side behavior (1 DOWN, 0 UP, 126 REPEATs). At the mtkbt boundary the ratio was strict 1:1 between PASSTHROUGH PRESS frames andMMI_AVRCP KEY_INFOemissions — the amplification lives below mtkbt, in the kernel'sevdevEV_REPsoft-repeat timer (REP_DELAY=250ms, REP_PERIOD=33msLinux defaults). Closed by iter23 / U1: NOP theUI_SET_EVBIT(EV_REP)ioctl at file offset0x74e8insidelibextavrcp_jni.so'savrcp_input_initso the device never claimsEV_REPandinput_register_device()never enables soft-repeat for it. Spec-correct per AVRCP 1.3 §4.6.1 (PASS THROUGH command, defined in AV/C Panel Subunit Specification ref [2]): CT is responsible for periodic re-send during held button; TG should forward one event per frame, not synthesize extras at the input layer. - 2026-05-09 stock baseline (
/work/logs/dual-tv-20260509-2217/). TV connecting to a Y1 running stock firmware (no Koensayr patches). Confirms the TV-side AVRCP path is healthy by itself. Wire shape: 190 AVRCP-tagged log lines; msg=507 ×3 (connect_ind → outbound CONNECT_RSP); CONNECT_CNF returned the legacyresult:4096 bws:0 tg_feature:0 ct_feature:0shape (= mtkbt-1.0 default); msg=520 ×20 (all PASSTHROUGH key acks — clean play/pause traffic, zero NOT_IMPLEMENTED rejects); zero msg=519 (no inbound META PDUs reaching JNI visibility); zero msg=540 / msg=544 (no META responses emitted). Confirms the structural finding: TV doesn't probe a 1.0 TG with 1.3 META commands, so stock can't surface the bridge-app metadata even if the TV's UI would render it. Once--avrcpadvertises 1.3 (V1/V2 SDP bumps), the TV begins firing the META PDUs that the trampoline chain handles. Use as the canonical "TV-side-healthy, issue-on-our-end" reference when triaging post-flash regressions.
Chevrolet Bolt EV (active — car / highway)
GM Infotainment 3 head unit. Strict CHANGED-driven CT (doesn't poll metadata; relies on TRACK_CHANGED edges + targeted GetElementAttributes). Fully META + PApp capable. Notable observations:
- Bolt EV
/work/logs/dual-bolt-iter18d/showed PDU 0x17 InformDisplayableCharacterSet (UTF-8) issued once at connect; our pre-iter19a TG NACKed with msg=520. Bolt then registered TRACK_CHANGED 30 times but only ever issued a single GetElementAttributes — consistent with "the TG won't acknowledge my charset declaration so I distrust subsequent metadata." iter19a closed by adding T_charset. - iter19b confirmed the TRACK_CHANGED wire-shape correctness fix (r1=0 to take the response builder's spec-correct path) on Bolt: first CHANGED edge fetched metadata, but every subsequent CHANGED edge after the first was ignored. UI-side block at a layer not visible in our captures; remains an open investigation.
- 2026-05-08 postflash (
/work/logs/dual-bolt-postflash/): three findings. (1) Pause-during-play does not pause the Y1, but pause works fine from Pixel 4 ↔ Bolt (user-confirmed 2026-05-08 — Bolt is not at fault). Whatever the Bolt sends as PAUSE never surfaces in ourMSG_ID_BT_AVRCP_CMD_FRAME_INDlogs asrawkey:70/0x46, andgetevent.txtover the entire session shows onlyKEY_PLAYCD,KEY_NEXTSONG,KEY_PREVIOUSSONGon/dev/input/event4— neverKEY_PAUSECDorKEY_PLAYPAUSEfrom event4. Some path insidemtkbt's AVCTP RX is dropping the Bolt's pause primitive before it reaches our logged INDs. Open investigation: dumpmtkbt's AVCTP frame parser viatools/attach-mtkbt-gdb.shwhile pressing pause from the Bolt to capture the raw bytes, and compare against the Pixel 4's framing for the same action. Candidate hypotheses: (a) Bolt uses an AVRCP 1.4+ Browse-channel command (PSM0x1B) we don't expose; (b) Bolt issues a vendor-specific PASSTHROUGH op_id outside the standard 0x44 / 0x45 / 0x46 range; (c) AVDTP-level SUSPEND that the Pixel propagates to its AVRCP layer but we don't. (2) PLAY-resume still broken — the 2026-05-08 attempted fix (dropregisterMediaButtonEventReceiverfrom Y1MediaBridge) was reverted 2026-05-09 after a Kia metadata regression confirmed MtkBt depends on the registered MediaButton client to find the bridge'sIBTAvrcpMusicBinder. (3) No metadata displayed by the Bolt despitemsg=540GetElementAttributes being emitted with all 7 §5.3.4 attrs — separate Bolt-side ingestion issue, open investigation. - Pre-iter25: Bolt is a strict CT and issues discrete PASSTHROUGH 0x44 PLAY (not the toggle 0x46 PAUSE).
dual-bolt-iter23capture shows 5 discrete PLAY presses while Y1 was already PLAYING. iter22d's Patch E routed all ofKEY_PLAY(85),KEYCODE_MEDIA_PLAY(126), andKEYCODE_MEDIA_PAUSE(127) throughplayOrPause()(toggle), which inverted Bolt's intent on each press — toggling away from PLAYING when Bolt asked for PLAY. User perceived the PLAY button as unresponsive. Closed by iter25: Patch E split into three discrete arms — KEY_PLAY →playOrPause()(toggle, legacy MediaButton); KEYCODE_MEDIA_PLAY →play(false)(discrete); KEYCODE_MEDIA_PAUSE →pause(0x12, true)(discrete). Spec-aligned with AVRCP 1.3 §4.6.1 + AV/C Panel Subunit Spec [ref 2]: PLAY (op_id 0x44) transitions to PLAYING from any state; PAUSE (op_id 0x46) transitions to PAUSED from any state. Concrete frame in AVRCP 1.3 §19.3 Appendix D. - 2026-05-09 F4-iter1 postflash (
/work/logs/dual-bolt-20260509-2249/) — first capture against the V1/V2/V3/V4/V5 SDP shape + the full F4-iter1 trampoline chain (T_papp + T8 event 0x08). Reframes prior "Bolt is PASSTHROUGH-only" framing as wrong: the earlier pre-V1/V2 captures simply hadn't advertised AVRCP 1.3, so Bolt never had a reason to issue META commands against us. With the 1.3 advertisement live, Bolt fully exercises the META + PApp surface:- Connect → GetCapabilities (msg=522, T1 fired) → PDU 0x17 InformDisplayableCharacterSet (msg=536, T_charset fired) → 5× RegisterNotification → PASSTHROUGH play/forward press/release pairs (clean 1:1) → GetElementAttributes once (msg=540, T4 fired with all 7 attrs in 644 B IPC frame) → continuous RegisterNotification re-subscribes (20 inbound size:13, 72 outbound msg=544 with 52 proactive emits from T5/T9/extended_T2).
- PDU 0x14 SetPlayerApplicationSettingValue retry storm. Starting ~21 s after connect, Bolt issues a size:11 PDU 0x14 every 3 s — 14 retries across the capture, all rejected by iter1's
T_pappSet arm with0x06 INTERNAL_ERROR(msg=530, 8-byte reject frame). This is the first concrete evidence that a real CT in our matrix actively wants PApp Set support; iter1's reject path is exactly what's gating Bolt's PApp flow. Iter3 (real Set support) is therefore the high-priority next move; iter2's read pipeline (T_papp 0x13 + state observation) is lower-priority for Bolt because Bolt skips ListAttrs (PDU 0x11) and GetCurrent (PDU 0x13) entirely — goes straight to blind Set, suggesting Bolt's behavior is "set Repeat / Shuffle to a known state at connect" rather than "discover what's supported then mirror". - 2026-05-09 gdb-capture (
/work/logs/papp-gdb.log) —tools/attach-libextavrcp-gdb-papp.shattached to the patched library, broke atpapp_set(file0xb13c), and dumped 14 inbound PDU 0x14 frames with the following distribution:
| attr_id | value | hits | meaning |
|---|---|---:|---|
| 0x02 Repeat | 0x01 | 2 | OFF |
| 0x02 Repeat | 0x02 | 2 | SINGLE TRACK REPEAT |
| 0x02 Repeat | 0x03 | 2 | ALL TRACK REPEAT |
| 0x03 Shuffle | 0x02 | 8 | ALL TRACK SHUFFLE |
Every frame is `n=1` (single attr/value pair). Bolt issues each Set ×2 (one on user press + one auto-retry after our reject). User cycled Repeat through all three supported values (OFF/SINGLE/ALL — never the AVRCP 0x04 GROUP value Y1 doesn't model), then pressed Shuffle ON four times. Confirms the Trace #18 enum mapping is correct and complete: AVRCP Repeat `0x01/0x02/0x03` ↔ Y1 `musicRepeatMode` `0/1/2`; AVRCP Shuffle `0x02` ↔ Y1 `musicIsShuffle=true`, `0x01` ↔ `musicIsShuffle=false`. Bolt is spec-conformant — no vendor-specific values, no GROUP variants, no multi-pair Sets. Iter3 can ship with the documented mapping and not have to defensively handle GROUP/oversized-n cases.
- PASSTHROUGH path healthy: 7 press/release pairs (rawkey 68 PLAY ↔ 196 RELEASE; 75 FORWARD ↔ 203 RELEASE) all delivered. Y1MediaBridge state-tracking shows PLAYING/PAUSED transitions firing in lockstep — discrete-key chain is now correctly handled.
- Subscribe re-registration cadence: 20 RegisterNotification inbounds across ~2 min (~10 s mean inter-frame) — much lower than the TV's storm shape, consistent with Bolt being a CHANGED-driven CT that re-subscribes on natural intervals rather than on every CHANGED edge.
Kia EV6 (active — car / highway)
Hyundai Motor Group head unit. Polls GetPlayStatus (PDU 0x30) at ~1 Hz, subscribes to event 0x02 TRACK_CHANGED only. Notable observations:
- iter22b/22c capture (
/work/logs/dual-kia-iter22{b,c}/): all 5 RegisterNotifications were event 0x02; uses GetPlayStatus polling for play_status display rather than subscribing to event 0x01. - Pre-iter22c: T6 GetPlayStatus returned stale
playing_flagbecause Y1MediaBridge'sonStateDetected(play / pause path) wasn't refreshing y1-track-info before broadcast. Symptom: car-side icon stuck on initial value until next track change. Closed by iter22c. - Pre-iter22d: Kia HMI's discrete PLAY button (PASSTHROUGH 0x44 → uinput KEY_PLAYCD → KEYCODE_MEDIA_PLAY 126) found no music-app handler; only KEYCODE_MEDIA_PLAY_PAUSE (85) was wired. Symptom: pressing PLAY while paused did nothing; Kia eventually fell back to PAUSE (which toggles via 85) after ~11 s and 4 button presses. iter22d Patch E added a handler for keycode 126 but routed it through
playOrPause()(toggle); refined in iter25 to callplay(false)(discrete) per AVRCP 1.3 §4.6.1 + AV/C Panel Subunit Spec — see Bolt EV section above for the empirical reason for the iter25 refinement. - Pre-iter22d: Kia hid the playback-progress scrubber during playback because T6 returned static
position_at_state_change_ms(iter20a deferral). Closed by iter22d'sclock_gettime(CLOCK_BOOTTIME)-based live extrapolation. mIBTAvrcpMusicbinder doesn't connect — zeroIBTAvrcpMusic.*log entries in iter22c / d captures. AVRCP transport commands reach the music app via the libextavrcp_jniavrcp_input_sendkey→ uinput path only. Open investigation.- 2026-05-08 postflash (
/work/logs/dual-kia-postflash/): play-during-pause broken on the discrete-key chain (same symptom as Sonos —KEY_PLAYCDreaches kernel cleanly,play(Z)never fires in music app). 2026-05-09 re-test of an attempted fix (dropregisterMediaButtonEventReceiver) confirmed MtkBt depends on the registered MediaButton client to locate Y1MediaBridge'sIBTAvrcpMusicBinder — the change broke metadata delivery on Kia entirely (no Title / Artist / Album), and AVRCP behavior degraded toward 1.0 fallback. Reverted. The discrete-key chain-break remains open; whatever fix we try has to keep the registration intact. Track playing time + scrub-bar advance verified working pre-revert.
Lower BT profile-stack disassembly (2026-05-09)
Trigger: scoping the per-profile ICS-scoreboard pass (BT-COMPLIANCE.md §9.9). Goal: byte-level inventory of A2DP / AVDTP / AVCTP / GAVDP version + capability surfaces in the stock binaries so the existing AVRCP-1.3-paired V1 / V2 patches sit alongside an explicit map of the audio-triad gap.
Reads against /work/v3.0.2/system.img.extracted/. Current patch set (V1 / V2 / S1 / P1 + trampolines, post-v2.0.0) is the known-good wire baseline — V1 = AVRCP 1.0 → 1.3, V2 = AVCTP 1.0 → 1.2, both confirmed effective on the wire (Trace #12). Triad upgrade scope is the residual A2DP / AVDTP gap, not anything AVRCP / AVCTP.
Binary inventory
In-scope BT-related ELFs in stock v3.0.2:
| Path | Size | md5 | Role |
|---|---|---|---|
bin/mtkbt | 1029140 | 3af1d4ad8f955038186696950430ffda | BlueAngel daemon — L2CAP / HCI / AVCTP / AVDTP / GAVDP / A2DP / AVRCP TG |
lib/libextavrcp.so | 17552 | 6442b137d3074e5ac9a654de83a4941a | AVRCP response builders (T-trampoline targets) |
lib/libextavrcp_jni.so | 50992 | fd2ce74db9389980b55bccf3d8f15660 | JNI bridge — trampoline blob host |
lib/libmtka2dp.so | 17552 | 6dc3e453cd3ea05d7c0a7a07a100c0f7 | userspace A2DP stream socket bridge |
lib/libmtkbtextadpa2dp.so | 50320 | b41be49baeeefbdb427e00bba2e0d2e2 | Java↔mtkbt A2DP shim (SEP register / stream-state IPC) |
lib/libmtkbtextadp.so | 17504 | f084b8b3973c39bcb54a98dfaf068a31 | Java↔mtkbt main extadp (binder ↔ IPC) |
lib/libaudio.a2dp.default.so | 58660 | 0d909a0bcf7972d6e5d69a1704d35d1f | AOSP A2DP HAL (standby_l, A2dpSuspended) |
lib/libbtcust.so | 5204 | 898de90dcdca935f9acc563e491209d7 | customisation flags |
lib/libbtcusttable.so | 5256 | 271139c43691f90ed5d83aea342c19d0 | customisation tables |
lib/libem_bt_jni.so | 17764 | 2376b561f10267e1d047a06b11ba3948 | engineer-mode JNI |
Every profile from L2CAP up through AVRCP TG lives in mtkbt. Of the surrounding lib*.so files only libaudio.a2dp.default.so carries a BT-protocol-relevant function (standby_l → a2dp_stop → AVDTP SUSPEND on the wire), now covered by patch_libaudio_a2dp.py (AH1) — see §9.2 in BT-COMPLIANCE.md.
Static SDP record region
Profile-version bytes in stock mtkbt's SDP source live at file offset 0xeb9d0..0xebd00 (LOAD #1 rodata, vaddr == file_off). DataElement-decoder walk finds eight UUID-paired version entries (35 06 19 HH LL 09 VH VL shape), all but one reading 0x0100 in stock:
| File offset (LSB of uint16 version) | Profile UUID | Stock value | Patched by |
|---|---|---|---|
| 0x0eb9f2 | 0x110D AdvancedAudioDistribution | 0x0100 (A2DP 1.0) | — |
| 0x0eba09 | 0x0019 AVDTP | 0x0100 (AVDTP 1.0) | — |
| 0x0eba25 | 0x0017 AVCTP | 0x0100 (1.0) | — |
| 0x0eba37 | 0x0017 AVCTP | 0x0100 (1.0) | — |
| 0x0eba4b | 0x110E AVRCP (legacy) | 0x0100 (1.0) | — |
| 0x0eba58 | 0x110E AVRCP (legacy) | 0x0100 (1.0) | V1: → 0x0103 (1.3) |
| 0x0eba6d | 0x0017 AVCTP | 0x0100 (1.0) | V2: → 0x0102 (1.2) |
| 0x0eba77 | 0x110E AVRCP (legacy) | 0x0103 (1.3) | (already 1.3 in stock) |
Stock [AVRCP] AVRCP V10 compiled build banner + dispatch behaviour (PASSTHROUGH-only, all metadata commands NACK) match these stock byte values: AVRCP 1.0 / AVCTP 1.0 / A2DP 1.0 / AVDTP 1.0 across the board, with one already-1.3 AVRCP entry (which the V1 site mirrors post-patch).
A 12-byte-stride attribute table at vaddr 0xfa700..0xfa9c0 indexes these byte ranges by SDP attribute ID ({ uint32 ptr; uint32 reserved; uint16 attr_id; uint16 length }). The current served record set deliberately omits attribute 0x000d (AdditionalProtocolDescriptorList — Browse PSM 0x001b) per the disproven Browsing-bit / Pixel-shape experiments above — staying off-Browse keeps Sonos and similar CTs from opening a channel mtkbt can't service.
V1 and V2 are wire-confirmed (Trace #12, line 1346). The remaining seven version sites (six unpatched + one stock-1.3 mirror) are not currently consulted by any peer in the test matrix, but the AVCTP-multiplicity question is open: only one of the three AVCTP sites is V2-patched, and whether the unpatched two would matter against a stricter CT than we've tested is unverified.
A2DP / AVDTP advertised at 1.0 — gap, not deviation
Both bytes at 0xeb9f2 and 0xeba09 read 0x0100 in stock and remain unpatched in the current --avrcp set. Spec-acceptable (we ship the SBC-only TG behaviour A2DP 1.0 demands), but spec-incomplete — features added in A2DP 1.2 (Content Protection / SCMS-T) and 1.3 (DELAY_REPORT) are off the table at the advertisement layer.
AVDTP signal coverage — codepoints vs handlers
ARCHITECTURE.md §"AVDTP signal codes" lists sig_id 0x01..0x0d as confirmed in mtkbt code. Provisional read superseded — the dispatcher disassembly in Trace #13 below shows real handler entries for all of sig 0x01..0x0d. Sig 0x0c (GET_ALL_CAPABILITIES) reaches a stub at 0xab4de that always returns BAD_LENGTH; sig 0x0d (DELAYREPORT) reaches 0xab540 with substantive logic. Sig 0x08 (CLOSE) and sig 0x09 (SUSPEND) jump-table entries point to the dispatcher's epilogue (0xab786) — handled elsewhere or trivially.
The pre-Trace-#13 read ("no log strings → silent drop") was wrong because:
- radare2's
aaalinear sweep failed to analyse the dispatcher at 0xaa72c (invalid bytes at 0xaa720 trapped its analyser → it silently skipped the function). grepfor log strings doesn't pick up handlers that don't emit log lines for the per-sig case (the dispatcher logs via fcn.000675c0 with a small per-sig string-id inside the prologue, before the TBH dispatch).
Implication for AVDTP version-byte bump (V3+V4 — AVDTP 1.0→1.3 in served A2DP Source SDP): now corroborated by disassembly. Advertising AVDTP 1.3 is not a pure paper claim — sigs 0x0c (GET_ALL_CAPABILITIES, AVDTP 1.3 ICS Acceptor Mandatory row 9) and 0x0d (DELAYREPORT, Optional) both have dispatch entries, though sig 0x0c's stub fails the response. V5 (sig 0x0c → sig 0x02 alias) closes the row 9 gap.
A2DP codec scope
Confirmed SBC-only. GavdpAvdtpEventCallback rejects non-SBC SEPs ([AVDTP_EVENT_CAPABILITY]not AVDTP_CODEC_TYPE_SBC → "try another SEP" fallback). No AAC / MP3 / ATRAC strings in mtkbt or libmtkbtextadpa2dp.so. SBC is Mandatory for A2DP 1.0+ TGs, so this is spec-compliant; AAC is Optional and not advertised.
Trace #13 (2026-05-09) — AVDTP signal dispatcher disassembled, V5 design candidate identified
Why this trace exists
Open-question items 1+2 ("AVDTP DELAY_REPORT and GET_ALL_CAPABILITIES handler — real or NOT_IMPLEMENTED stub?") and the V3+V4 SDP version bumps (A2DP/AVDTP 1.0→1.3) needed empirical answers from the binary. Static analysis using grep / objdump had been inconclusive because mtkbt is stripped + heavily MOVW/MOVT-encoded.
Tooling pivot
Per feedback_install_proper_tools.md memory rule, switched from grep-grinding to dnf install -y radare2 (one command, ~30 s), then used axt xref analysis on candidate per-sig handler functions. radare2's auto-analysis (aaa) had silently SKIPPED the dispatcher function because invalid bytes at 0xaa720 trapped its linear-sweep analyser — manual disassembly via r2 -c "pd 200 @ 0xaa72c" was needed.
The dispatcher
Located at file offset 0xaa72c in stock mtkbt (md5 3af1d4ad…). Prologue:
0xaa72c: push.w {r4-r8,sb,sl,fp,lr} ; full-context save
0xaa73a: ldrb.w sb, [r1] ; sig_id = first byte of cmd struct
0xaa7f6: add.w sb, sb, -1 ; sb = sig_id - 1 (TBH index)
0xaa812: cmp.w sb, 0x28 ; bounds check (accepts 0..0x28 = 41 entries)
0xaa816: bhi.w 0xab786 ; OOB → epilogue
0xaa81a: tbh [pc, sb, lsl 1] ; jump-table dispatch
0xaa81e: <halfword table> ; entry n*2 → target = 0xaa81e + 2*halfword
Decoded jump table (sig_id 1..13)
| sb | sig_id | wire signal | target | meaning |
|---|---|---|---|---|
| 0 | 0x01 | DISCOVER | 0xaa870 | full handler |
| 1 | 0x02 | GET_CAPABILITIES | 0xaa924 | full handler — capability list builder |
| 2 | 0x03 | SET_CONFIGURATION | 0xab66e | full handler |
| 3 | 0x04 | GET_CONFIGURATION | 0xaaaf6 | full handler |
| 4 | 0x05 | RECONFIGURE | 0xaab64 | full handler |
| 5 | 0x06 | OPEN | 0xaac6c | full handler |
| 6 | 0x07 | START | 0xaacde | full handler |
| 7 | 0x08 | CLOSE | 0xab786 | jump to epilogue (handled elsewhere — TBD) |
| 8 | 0x09 | SUSPEND | 0xab786 | jump to epilogue (handled elsewhere — TBD) |
| 9 | 0x0a | ABORT | 0xab008 | full handler |
| 10 | 0x0b | SECURITY_CONTROL | 0xab072 | full handler |
| 11 | 0x0c | GET_ALL_CAPABILITIES | 0xab4de | STUB — always returns BAD_LENGTH error |
| 12 | 0x0d | DELAYREPORT | 0xab540 | full handler (sufficient for AVDTP 1.3) |
Sig 0x0c stub anatomy (file 0xab4de)
0xab4de: ldrb.w lr, [r4, 8] ; load response-buffer state byte
0xab4e2: cmp.w lr, 8
0xab4e6: bls 0xab51a ; if state <= 8 → error path
0xab4e8: ldrb.w r8, [r4, 9]
0xab4ec: cmp.w r8, 0
0xab4f0: bne 0xab51a ; if [r4+9] != 0 → error path
... (small "main" path that just calls a logger and returns to epilogue —
no actual capability-list construction)
0xab51a: <error path>
movs r0, 8 ; strb [r4, 8] ; "8" stored
movs r1, 6 ; strb [r6, 1] ; "6" stored — internal state code
bl fcn.000af4cc ; error-response sender
b 0xab786 ; epilogue
A fresh inbound GET_ALL_CAPABILITIES has [r4+8] (response-buffer length) initially 0 or small → bls 0xab51a taken → error response. In effect mtkbt advertises sig 0x0c as supported but always rejects it.
V5 design candidate — 2-byte jump-table alias
Cleanest V5: change the halfword at file offset 0xaa834 from 60 06 (target 0xab4de stub) to 83 00 (target 0xaa924 sig 0x02 handler).
Pros:
- 2 bytes total. No code injection / trampoline needed.
- Sig 0x0c response body is a wire-compatible subset of GET_ALL_CAPABILITIES per V13 §8.8 (response = GET_CAPABILITIES content + optional service capabilities; we send only the GET_CAPABILITIES core, peer reads it as "no extended caps").
- Closes GAVDP 1.3 ICS Acceptor Table 5 row 9 (GET_ALL_CAPABILITIES_RSP).
Risk — unverified as of 2026-05-09: response wire sig_id may be hardcoded to 0x02 by the sig 0x02 handler. At 0xaa9fa the handler does strb r2, [r6, 1] with r2=2. Whether [r6+1] is the response wire sig_id field or an internal state byte is ambiguous from static analysis alone. Other per-sig handlers also write to [r6+1] (sig 5 writes 5, sig 0x0c stub writes 6) suggesting it's a state code, but the response wire sig_id origin needs runtime confirmation before V5 lands.
Validation plan
tools/attach-mtkbt-gdb-avdtp.sh updated to BP at:
- 0xaa72c (dispatcher entry — captures sig_id from [r1] and full cmd-buffer first 16 B)
- 0xaa924 (sig 0x02 handler entry — captures r4/r5/r6/r1 for state struct mapping)
- 0xab4de (sig 0x0c stub entry — confirms which inbound triggers it)
- 0xab51a (sig 0x0c error path — confirms always-reject behavior)
- 0xaeb9c (response sender called from sig 0x02 — captures sig_id arg location)
- 0xaf4cc (error response sender called from sig 0x0c — captures error format)
Drive a fresh pair attempt against any A2DP Sink CT (Sonos / Bolt / TV); the BPs at 0xaa72c will fire for every inbound AVDTP signal and let us cross-correlate sig_id source with the response builder's input.
Trace #13c (2026-05-09 night) — 0xaa72c reconfirmed as AVDTP dispatcher; full TBH table decoded
The 8-BP run (with peer driving a pair attempt) captured 13 wire-tagged dispatch fires through 0xaa72c with [r1] carrying AVDTP wire signal_ids. Sequence (chronological from /work/logs/mtkbt-gdb-avdtp.log):
| Fire | sig_id | wire signal | LR | notes |
|---|---|---|---|---|
| 1 | 0x0d | DELAYREPORT | 0x401a483b | pre-config |
| 2 | 0x03 | SET_CONFIGURATION | 0x401a49cd | |
| 3 | 0x01 | DISCOVER | 0x401a49cd | same caller |
| 4-7 | 0x04 | GET_CONFIGURATION ×4 | 0x401a54b3 | per-SEP polling |
| 8 | 0x05 | RECONFIGURE | 0x401a5803 | |
| 9 | - | cap-parser fcn.000afd5c | 0x401a557d | one fire only |
| 10 | 0x06 | OPEN | 0x401a3e91 | |
| 11 | 0x07 | START | 0x401a3ec3 | streaming begins |
| 12 | 0x18 | (internal) | 0x401a5803 | |
| 13 | 0x0b | SECURITY_CONTROL | 0x401a5bc5 | |
| 14 | 0x0f | (internal) | 0x401a5803 | |
| - | 0x17 | heartbeat ×308 | 0x401a5bc5 | background |
Notably absent: sig 0x02 (GET_CAPABILITIES) and sig 0x0c (GET_ALL_CAPABILITIES) — peer skipped capability probing entirely. Likely cached capabilities from a prior pair, or the peer trusts the SDP record. This means V5 (sig 0x0c handler) is mechanically valid (dispatcher confirmed; jump-table entry at 0xaa834 routes sig 0x0c to 0xab4de stub) but won't be empirically tested against this CT — needs a stricter peer or unprimed re-pair to fire.
Struct geometry (constant across all dispatch fires):
[r1+0]: msg/sig_id byte (the dispatched value)[r1+4..7]: pointer back to r0 (state struct ref, always 0x415e92b8)[r1+8..11]: function pointer (0x4028b8d4 = file 0xb8d4 — likely a shared completion callback)[r1+12..15]: per-msg payload (e.g., 0x14 for OPEN, 0x18 for sig 0x18)[r1+24..27]: 0x40290d29 (file 0x19cd29) — common state struct ptr
So [r1] is an internal mtkbt event message tagged with AVDTP wire-format sig_ids, not a raw L2CAP frame. The dispatcher routes 41 codepoints: sig_id 1-13 (AVDTP wire) + sig_id 14+ (internal events 0x17 background, 0x18 / 0x0f synthetic). Same TBH function handles both — V5's jump-table alias edit is geometrically sound.
Reverting Trace #13 follow-up's claim that fcn.000b0c30 is the real dispatcher: that function fired but only as the AVDTP state-machine driver (positive control), not as the wire-RX path. r1 args at fcn.000b0c30 had state=0x00 (7 fires) and state=0x03 (7 fires) with [r1+1] varying — internal state transitions, not signal frames. fcn.000b0c30 is a state-handler called BY the dispatcher's per-sig handlers, not the dispatcher itself.
Trace #13 follow-up (2026-05-09 evening) — 0xaa72c hypothesis invalidated, real dispatcher relocated to fcn.000b0c30
The 6-BP run captured in /work/logs/mtkbt-gdb-avdtp.log (828 lines) shows:
- 267 fires at 0xaa72c with
[r1] = 0x17(dec 23) — out of AVDTP wire signal range (0x01..0x0d). Single fire with[r1] = 0x10(dec 16). Same r0 across all hits (r0 = 0x415e92b8, equal to[r1+4]). - Zero fires at 0xaa924 (sig 0x02 handler), 0xab4de / 0xab51a (sig 0x0c stub + error path), 0xaeb9c, 0xaf4cc.
Conclusion: 0xaa72c is NOT the AVDTP wire-signal dispatcher. It's BlueAngel's internal task-message dispatcher — the function pointer at [r4+0x464] referenced from the orchestrator at 0xb1bc2 (a blx r3 indirect call). The TBH at 0xaa81a routes 41 internal task-message types, of which AVDTP wire signals are not one. My V5 design (jump-table alias at 0xaa834) was therefore based on the wrong jump table — patching it would change internal-message-type-12 routing, not AVDTP sig 0x0c.
The real AVDTP RX dispatcher: fcn.000b0c30 (file 0xb0c30). 6482 bytes, 239 basic blocks, 152 cyclomatic complexity — radare2's aaa linear sweep had silently skipped it because invalid bytes at 0xb0c20-0xb0c2e trap the analyser. Manual disasm at 0xb0c30:
0xb0c30: push.w {r4-r8,sb,sl,fp,lr}
0xb0c34: mov r8, r0 ; r0 = stream / channel struct
0xb0c40: mov r5, r1 ; r1 = AVDTP signal frame ptr
0xb0c44: ldrb r3, [r1] ; r3 = AVDTP byte 0 (header[0])
0xb0c4c: cmp r3, 7 ; bound check
0xb0c4e: bhi.w 0xb19c8 ; oob error
0xb0c52: tbh [pc, r3, lsl 1] ; state-machine dispatch
Confirmed dispatcher because:
- fcn.000afeec (
AvdtpSigParseConfigCmd— confirmed via the[AvdtpSigParseConfigCmd]insert stream to channl stream listlog string at 0xea67a) is called fromblat 0xb1012, which lies inside fcn.000b0c30's body. - Six other avsigmgr.c-tagged functions (0xafd5c, 0xb01b4, 0xb0270, 0xb0468, 0xaedd8, 0xafeec) are reachable from inside this function.
- The byte-0 dispatch (cmp r3, 7) appears to be on AVDTP state code (8 states), not on signal_id — the signal_id parse happens after state selection. This matches BlueAngel's "stream signaling state machine" architecture.
Per AVDTP V13 §8.5, sig_id lives in byte 1 (low 6 bits) of the signal frame, not byte 0. Earlier interpretation (sig_id = [r1+0]) was geometrically wrong — that would have been transaction-label/packet-type/msg-type, not signal_id. The new BPs read [r1+1] & 0x3f for the wire signal_id.
tools/attach-mtkbt-gdb-avdtp.sh re-targeted at 0xb0c30 + 0xafeec + 0xb1012 + 0xb0b50. Re-run on next pair attempt will show:
- Sig_id of every inbound AVDTP signal (decoded from wire byte 1).
- AVDTP state code on each dispatch.
- SET_CONFIGURATION path (b1012 → afeec) for cross-confirmation.
V5 design TBD — depends on next capture's data on how sig 0x0c is currently rejected (or if it is at all) inside fcn.000b0c30.
Trace #14 (2026-05-10) — L2CAP PSM 0x19 callback registration located in mtkbt
Hunt: locate where mtkbt registers its inbound L2CAP callback for PSM 0x19 (AVDTP signaling/media), to anchor the RX chain that ultimately drives the dispatcher at 0xaa72c.
Method. AVCTP has the log string [AVCTP] register psm 0x%x status:%d at 0xdbea0; AVDTP doesn't have an equivalent log line, so the AVCTP register call site was used as the fingerprint. radare2 axt @ 0xdbea0 resolves to a PC-relative ADD at 0x6d2de inside an init function. The instruction immediately preceding is bl fcn.0007c78c at 0x6d2c8 — that's BlueAngel's L2CAP_RegisterPsm. Confirmed by inspecting fcn.0007c78c: it logs Protocol->inLinkMode:%d / Protocol->outLinkMode:%d, validates MTU between 0x12 and 0xfff9, and walks a 20-slot global registration table looking for an empty slot.
15 callers of fcn.0007c78c (all distinct profiles registering different PSMs). The AVDTP candidate is fcn.000ae9bc — sits in the AVDTP code region (0xae9bc in mtkbt), single-caller (bl at 0xab8a8 inside a larger init sequence). Disassembly confirms PSM 0x19:
0x000ae9bc push.w {r4..fp, lr}
0x000ae9c2 bl fcn.000b54ec ; alloc / lookup PsmCtx
0x000ae9ca bl fcn.000afb34 ; init AVDTP local state
0x000ae9d0 movw r0, #0x69b ; MTU = 1691 (struct[+0x30])
0x000ae9d6 movs r3, #0x30 ; channel mode flags (struct[+0x32])
0x000ae9d8 mov.w sb, #0x19 ; PSM = 0x19 (struct[+0x2c])
0x000ae9dc add r4, pc; ldr r4, [r4] ; r4 = *(GOT slot 0xf9c38) = AVDTP state struct (R_ARM_RELATIVE → BSS @ link-vaddr 0x1b7d28)
0x000ae9e0 add r1, pc; ldr r1, [r1] ; r1 = *(GOT slot 0xf9c3c) = AVDTP L2CAP callback fnptr (R_ARM_RELATIVE → 0xafc69, thumb-bit fnptr to fcn at 0xafc68)
0x000ae9e4 strh r0, [r4, #0x30] ; PsmCtx.MTU = 0x69b
0x000ae9e6 str r1, [r4, #0x28] ; PsmCtx.callback_struct = r1
0x000ae9e8 add.w r0, r4, #0x28 ; r0 = &PsmCtx[0x28] (= L2CAP register arg)
0x000ae9ec strh.w sb, [r4, #0x2c] ; PsmCtx.PSM = 0x19
0x000ae9f0 strh r3, [r4, #0x32] ; PsmCtx.flags = 0x30
0x000ae9f6 strb.w r7, [r4, #0x36] ; outLinkMode = 1
0x000ae9fa strb.w r7, [r4, #0x35] ; inLinkMode = 1
0x000aea06 bl fcn.0007c78c ; L2CAP_RegisterPsm(&PsmCtx[0x28])
0x000aea0a mov r5, r0 ; r5 = register status (0 = OK)
0x000aea0e bne 0x000aea7c ; on error → return r8
0x000aea10..7a ; success: 4-iteration SEP-init loop, stride 0x18
L2CAP register-arg layout (relative to r0 passed into fcn.0007c78c):
| Offset | Field | Init value | Notes |
|---|---|---|---|
| +0x00 | callback table ptr | PIE-relocated | data_ind / conn_ind / config_ind / disc_ind |
| +0x04 | PSM | 0x0019 | matches ldrh r3, [r0, 4] in L2CAP_RegisterPsm |
| +0x06 | (zero) | 0 | |
| +0x08 | MTU | 0x069b (1691) | matches ldrh r3, [r0, 8] validation |
| +0x0a | flags | 0x0030 | |
| +0x0d | inLinkMode | 1 | matches ldrb r1, [r4, 0xd] log line |
| +0x0e | outLinkMode | 1 | matches ldrb r1, [r4, 0xe] log line |
Caller context (0xab8a8 in some larger AvdtpInit-like entry, hosted inside r2's mis-spanning fcn.0000d98c):
0x000ab884 bl fcn.0004ca90 ; memset some ctx region
0x000ab88a add.w r0, r4, #0x1ac
0x000ab88e str r3, [r4, #4] ; flag = 1
0x000ab890 strb r3, [r4, #1] ; flag = 1
0x000ab892 strb r5, [r4] ; type = arg
0x000ab894 bl fcn.0006ce5c ; init list / queue ×3 at +0x1ac, +0x1b4, +0x1bc
0x000ab8a8 bl fcn.000ae9bc ; ← AVDTP L2CAP register
0x000ab8ac mov r0, r5; pop {r4, r5, r6, pc}
Implications.
- Single registration, single PSM (0x19) — AVDTP signaling and media share PSM 0x19 per AVDTP V13 §6 (multiplexed via L2CAP CIDs at runtime). No separate "media-channel" register exists.
- MTU advertised: 1691 (0x69b). AVDTP V13 §6.4.1 mandates ≥ 672 for signaling; 1691 is consistent with BasePoint default. Sufficient for AVDTP 1.3 GET_ALL_CAPABILITIES_RSP (worst case ~50 B per Service Capability × N caps, well under 1691).
- Mode flags 0x30 + inLinkMode/outLinkMode = 1 — Basic L2CAP mode (no ERTM/streaming-mode), consistent with stock A2DP 1.0 era. ERTM is optional per AVDTP V13 §A.2 ICS row M.4 — not advertised here.
- The callback table at PIE-resolved address 0xf9c3c contains AVDTP signaling layer's connect/disconnect/data-indication entry points. The data-indication path is what eventually feeds
fcn.000b0c30(the stream signaling state machine) and the per-signal handlers (0xaa924 sig 0x02, 0xab4de sig 0x0c stub, etc.) — closing the upstream side of the RX chain Trace #13c established the downstream side of.
Anchors for future work (this register call is not patched today; documented for completeness in case a future deviation requires altering MTU, channel mode, or the callback set):
- AVDTP register fcn: file offset 0xae9bc (one caller at 0xab8a8)
- L2CAP_RegisterPsm: file offset 0x7c78c (15 callers covering all profiles)
- AVCTP register caller: 0x6d2c8 (PSM 0x17, MTU 0x69b same advertised cap, inside fcn.0000f79c-region init)
- AVDTP state-struct GOT slot: vaddr 0xf9c38 → R_ARM_RELATIVE addend 0x001b7d28 (link-time vaddr in
.bss; fcn.000b54ec memsets 0x8fc=2300 bytes there at boot; 16 internal AVDTP module fns reference this slot for state access — confirmed viaaxt @ 0xf9c38) - AVDTP L2CAP callback GOT slot: vaddr 0xf9c3c → R_ARM_RELATIVE addend 0x000afc69 (thumb-bit fnptr to fcn at 0xafc68 — the inbound-L2CAP-frame handler installed in the BlueAngel global PSM registry)
The L2CAP callback (fcn at 0xafc68) — entry point for every inbound AVDTP frame on PSM 0x19. r2 doesn't recognise it as a function (no fcn label) but disasm is clean from 0xafc68 onward with a standard prologue. Signature: (arg0, arg1) where arg1 is an L2CAP event/frame struct (arg1[0] = event-type byte, arg1[+4] = payload pointer). Body:
0x000afc68 push {r4, r5, r6, lr}
0x000afc6a mov r6, r1 ; arg1 = event/frame struct
0x000afc6c ldr r4, [r1, #4] ; r4 = arg1[+4] = payload ptr
0x000afc6e mov r5, r0 ; arg0 = channel/conn handle
0x000afc72 bl fcn.000afb7c ; helper (reads AVDTP state via slot 0xf9c38)
0x000afc76 ldrb r3, [r6] ; r3 = event_type byte
0x000afc78 cbz r0, 0xafca0 ; helper returned 0 → connection-init path
0x000afc7a cmp r3, #1 ; event_type == 1?
0x000afc7c beq 0xafc8c ; 1 → config_ind / specific event
; else → fcn.000afbfc (data dispatch)
0x000afc8c..afc9e ; case-1: fcn.000afbfc(0); store r5 → r4[+0]
0x000afca0..afcc4 ; helper-returned-0 path: alloc via fcn.000afba0 OR recurse via fcn.000afc2c
0x000afcb8 pop {r4, r5, r6, lr}
0x000afcbc b.w fcn.0007d624 ; tail call back into L2CAP module
0x000afccc..end ; common success path: bl fcn.00084240 (alloc?), TBH dispatch via slot at [r3 + r0 lsl 2]
Helper map (all read AVDTP state via slot 0xf9c38):
fcn.000afb7c(in-degree 1): channel/SEP lookup helper, called once per inbound frame from 0xafc72.fcn.000afbfc(in-degree 3): data-handler, fires from 0xafc80 (event_type ≠ 1) and 0xafc8e (event_type == 1). Likely the path that walks per-channel state and dispatches intofcn.000b0c30(the AVDTP state machine) — verifiable via gdb breakpoint.fcn.000afba0(in-degree 1): allocator, fires from 0xafca4 on connection-init path.fcn.000afc2c(in-degree 1): recursive sub-handler, fires from 0xafcc4.
Indirect dispatch into 0xb0c30 / 0xaa72c. r2 reports zero direct callers for both fcn.000b0c30 (state-machine dispatcher) and 0xaa72c (GavdpAvdtpEventCallback) — they're invoked exclusively via function pointer fields in the AVDTP state struct (offset 0x464 for the event callback, per the blx r2 indirect pattern noted earlier in fcn.000b09fc). Those fnptrs are written to the BSS state struct during AVDTP layer init — at link time the struct is zero, so init-time stores set them up. The chain is therefore:
inbound L2CAP frame on PSM 0x19
→ BlueAngel L2CAP RX (fcn.0007d624 region)
→ registered PSM-callback dispatch
→ fcn at 0xafc68 [the AVDTP L2CAP callback, this trace]
→ fcn.000afbfc / afb7c [helpers; resolve channel from frame]
→ channel-specific data-ind [stored fnptr in per-channel struct, set at config time]
→ fcn.000b0c30 [AVDTP signaling state machine, Trace #13c]
→ per-signal handler [0xaa924 sig 0x02, 0xab4de sig 0x0c stub, etc.]
→ AVDTP state struct callback at offset 0x464
→ 0xaa72c (GavdpAvdtpEventCallback) [GAVDP-layer event dispatch, Trace #13c]
Empirical close-out left for hardware. The fnptr stored at AVDTP-state[0x464] is the only remaining gap and it's runtime-populated. Adding two BPs to the existing tools/attach-mtkbt-gdb-avdtp.sh — one at 0xafc68 (logging arg0 / arg1[0] / arg1[+4]) and one at the indirect blx r2 site at 0xb0b46 (logging r2 = the resolved fnptr) — would print the full chain on the next pair attempt. No patch action implied; this is map-completion work.
Trace #15 (2026-05-10) — GET_CAPABILITIES response builder calling convention; V5 risk re-evaluated
Hunt: characterise the call signature of fcn.000aeb9c (the function the sig 0x02 handler at 0xaa924 calls to "send the response") so we can validate whether V5's redirect of sig 0x0c into the same handler produces a wire-correct response or breaks signal-id pairing per V13 §8.5.
Calling convention.
int fcn_000aeb9c(AvdtpChannel *channel, uint8_t ack_flag);
- r0 (channel): pointer to AVDTP signaling channel context. Validated non-null; offset+8 looked up against a global registry via
fcn.0006ccdc. Returns0x12on null,0xdon lookup-miss,0xbon state error fromfcn.0006d9ac. Otherwise tail-callsfcn.000afd40(channel+8, ack_flag). - r1 (ack_flag): u8. Observed values at the sig 0x02 handler call sites:
0at0xaa948— error path ("no registered SEP" log, follows the search for a SEP that found none).1at0xaa9fe— success path (taken after the handler builds the per-SEP capability payload and stores the request sig_id into the global state).
Tail chain.
fcn.000aeb9c (channel, ack_flag)
→ bl fcn.0006ccdc [registry lookup]
→ bl fcn.0006d9ac [state validate]
→ b.w fcn.000afd40 [tail call]
fcn.000afd40 (channel, ack_flag)
→ r0 = *(channel) [halfword: L2CAP CID]
→ if (ack_flag == 0) r1 = 0, r2 = 0 [reject path]
→ else r1 = 4, r2 = ack_flag [accept path]
→ bl fcn.0007d624
fcn.0007d624 (cid, accept_flag, link_modes)
→ log "l2cap: Upper accept" (or "Upper reject")
→ look up channel via fcn.00083014
→ if channel state != 0xa: dispatch to fcn.000860d8 ("createchannel"), log "l2cap: pass to createchannel status:%d"
→ else: validate, store config bytes (link_modes[0..0x14]) into channel ctx at offsets 0xd4/0xd8/0xe0/0x104/0x108/0x143
→ tail-call fcn.0007d500 (L2CAP signal-frame TX dispatch)
The reframe: fcn.0007d624 is BlueAngel's L2CAP_ConnectRsp — the upper-layer accept/reject for an inbound L2CAP CONNECT_REQ. It is not the AVDTP signal-frame TX path. So fcn.000aeb9c is the GAVDP-to-AVDTP-layer handshake function: GAVDP says "I accept this signaling channel" (or rejects it), and BlueAngel's AVDTP layer emits the corresponding L2CAP CONNECT_RSP.
The pre-call state setup (memcpy into [channel + 0xc4] from [msg.field_at_0x1c + 0xc0], stores at [channel + 0xa4] / [channel + 0xa8], write to [GlobalAvdtpState + 1] = 2) is not wire-frame construction — it's per-channel context setup so that BlueAngel's AVDTP layer can subsequently parse and respond to in-band signal frames.
Where the actual GET_CAPABILITIES_RSP wire frame is built: not yet localised. The sig 0x02 handler at 0xaa924 prepares state, then calls fcn.000aeb9c which goes to L2CAP_ConnectRsp — but the AVDTP signal-frame response (with sig_id byte 1, msg_type=RSP_ACK in byte 0) must be emitted somewhere else, presumably by AVDTP layer code triggered by a state transition rather than by direct call from the handler. fcn.000afd40 only writes the L2CAP CID and the accept-flag — no AVDTP signal_id byte construction.
V5 risk re-evaluation (refines the "best-effort workaround" wording from Trace #13):
- The handler at 0xaa924 doesn't write the wire signal_id byte. It writes
2to[GlobalAvdtpState + 1], but that's an internal state byte (ther6base is the AVDTP module's global state struct loaded from GOT slot 0xf9c14, not the wire frame). - The L2CAP CONNECT_RSP path doesn't carry an AVDTP signal_id either. It just accepts/rejects the L2CAP channel.
- Therefore the wire response sig_id is determined elsewhere, and almost certainly preserves the request's sig_id (since BlueAngel's AVDTP layer parses each request, records the sig_id internally, and pairs the response to it per V13 §8.5).
- V5's redirect of sig 0x0c to the case-2 handler is therefore likely wire-correct: the handler builds Service-Capabilities payload (the same content valid for both GET_CAPABILITIES_RSP and GET_ALL_CAPABILITIES_RSP per V13 §8.8), and the AVDTP layer's wire-frame TX preserves sig_id=0x0c from the request.
- Empirical risk remains in the "wire frame builder localisation" gap — without finding the actual TX code that writes byte 1 of the response, we can't prove sig_id is preserved. A peer that exercises sig 0x0c (GET_ALL_CAPABILITIES) is the only definitive test.
Net for the V5 ship (committed in e51da3f): the risk language can be downgraded one notch — from "best-effort workaround that may break signal-id pairing" to "best-effort alias whose wire-correctness depends on AVDTP layer preserving the request sig_id, which is the architectural norm for BlueAngel-style stacks but not statically verified in our binary." Patch is still safe (sig 0x0c stub at 0xab4de currently does nothing; redirect to 0xaa924 cannot regress that), still empirically untested (no peer probes with sig 0x0c on the current CT matrix), and the only path forward to definitive verification is a peer that fires sig 0x0c.
Anchors for the wire-frame-builder hunt (next session, if anyone wants to close the V5 verification gap):
- Search for str / strb instructions writing a halfword/byte where bit pattern matches
(tlabel<<4)|(0<<2)|2(= AVDTP RSP_ACK byte 0) — these are the wire byte 0 emitters. - Check
fcn.00083014(called byfcn.0007d624): looks up channel by CID; the channel struct after offset 0x100 might contain pending-response sig_id state. - The TX path is likely fired by AVDTP signaling state machine (
fcn.000b0c30, Trace #13c) on a state transition when GAVDP returns ACK via fcn.000aeb9c — so dynamic BPs at fcn.000b0c30's exit edges + the L2CAP TX call site after fcn.0007d624's state-change branch would catch it.
Trace #16 (2026-05-10) — AVDTP signal-frame TX site localised; V5 wire-correctness upgraded to "verified by decoupling"
Goal of this trace: close the verification gap left open at the end of Trace #15 — find the actual wire-frame builder that writes byte 1 (sig_id) of the AVDTP signal response, and prove that V5's redirect of sig 0x0c into the sig 0x02 handler at 0xaa924 produces a wire-correct response (sig_id=0x0c, not sig_id=0x02).
Method. Searched for callers of L2CAP_SendData (= fcn.0007d204, identified via the "L2CAP_SendData state:%d return:%d" log string at 0xe0062). Three callers fall in the AVDTP/AVCTP region: fcn.000ae418 (AVDTP), fcn.000b1c38, and fcn.000b31ac (AVCTP-side). fcn.000ae418 is the AVDTP signal-frame TX builder.
fcn.000ae418 entry signature.
0x000ae418 push {r3,r4,r5,r6,r7,r8,sb,lr}
0x000ae41c mov r4, r0 ; r4 = arg1 (channel/state context)
0x000ae41e ldrh.w r0, [r0, #0x60] ; r0 = halfword at [r4+0x60] (CID)
0x000ae422 bl fcn.0007ccb4 ; CID lookup
The function operates on a per-channel context r4 whose layout includes:
r4 + 0x10pointer to per-transaction state struct (call ittxn)r4 + 0x1ctransaction-label byter4 + 0x20packet body buffer baser4 + 0x5d / 0x5e / 0x5fpacket header byte slotsr4 + 0x60L2CAP CID
Wire byte 1 (sig_id) origin. The single-packet path writes the sig_id byte from the per-transaction state struct, not from anything the dispatch handler at 0xaa924 set:
0x000ae472 ldr r3, [r4, #0x10] ; r3 = txn (per-channel transaction state)
0x000ae474 ldrb r1, [r3, #0xd] ; r1 = txn->[0xd] (msg_type / pkt_type latch)
0x000ae476 cmp r1, 2 ; check msg_type == RESPONSE_ACCEPT
0x000ae478 ittt ne
0x000ae47a ldrh r3, [r3, #0xe] ; r3 = txn->[0xe..0xf] halfword (sig_id at low byte)
0x000ae47c add.w sb, r5, #-1
0x000ae480 strb r3, [r5, #-1] ; *(r5-1) = low byte of txn->[0xe] → wire byte 1 (sig_id)
So byte 1 of the response frame on the wire = txn->[0xe], where txn = *(r4 + 0x10). This is the per-transaction state struct populated by the AVDTP request parser when the request is received; the sig-handler dispatch (the TBH table at 0xaa81e + sig-handlers like the one at 0xaa924) does not touch it.
Wire byte 0 (header). Built from (tlabel << 4) | pkt_type<<2 | msg_type:
0x000ae492 lsls r2, r6, 4 ; r2 = tlabel << 4
0x000ae494 ldrb r1, [r4, 0x1c]
0x000ae496 adds r0, r2, 4 ; +4 = pkt_type=01 (START), msg_type=00 (CMD) — fragmented-cmd path
...
0x000ae510 strb.w r6, [r4, 0x5f] ; wire byte 0
For the single-packet RSP_ACCEPT path the constant added is the response-msg-type bits (msg_type=10, pkt_type=00 → +2), not 4. Either way, byte 0 is composed from the transaction-label register r6 (sourced from per-channel context), not from a dispatch-handler-tied constant.
Frame TX call. After header + body assembled at r4+0x20..r4+0x60:
0x000ae586 ldrh.w r0, [r4, 0x60] ; r0 = CID
0x000ae58a add.w r1, r4, 0x20 ; r1 = packet base
0x000ae58e bl fcn.0007d204 ; L2CAP_SendData(cid, packet, ...)
Net for V5 wire-correctness.
The V5 patch redirects tbh[11] (sig 0x0c GET_ALL_CAPABILITIES) to dispatch into the sig 0x02 handler at 0xaa924. The sig_id byte that appears on the wire in the response frame is read from txn->[0xe], populated by the request parser (in the L2CAP RX → state-machine path under fcn.000b0c30, not in the per-signal handler). The handler at 0xaa924 does not write to txn->[0xe]; the only byte it stores is to [r6, 1] where r6 is the AVDTP module's global state struct (loaded from .got slot 0xf9c14), and that store is a state-machine field (value 2), not a wire-frame field.
Therefore, when a peer sends GET_ALL_CAPABILITIES_REQ (sig_id = 0x0c), the request parser stores 0x0c at txn->[0xe], the dispatcher (post-V5) routes through the GET_CAPABILITIES handler at 0xaa924, the handler runs and updates state, and the response builder fcn.000ae418 reads txn->[0xe] = 0x0c and writes 0x0c into the response frame's byte-1 slot. Peer receives GET_ALL_CAPABILITIES_RSP_ACCEPT with sig_id=0x0c — wire-correct.
Risk language upgrade. §9.13 (and the V5 patch comment in patch_mtkbt.py) can move from "wire-correctness plausible but not statically proven" to "wire-correct: the response sig_id byte is sourced from per-transaction state populated at request-parse time, decoupled from the dispatch handler." The remaining unverified surface is the response payload — V13 §8.8 mandates that GET_CAPABILITIES_RSP_ACCEPT and GET_ALL_CAPABILITIES_RSP_ACCEPT carry the same Service-Capability TLVs (the latter is a strict superset, but legacy capability servers may answer either with the same Service-Capability set, which is spec-permissible since the additional 1.3 capability categories — DELAY_REPORTING etc. — are all Optional). Since the handler at 0xaa924 emits the GET_CAPABILITIES Service-Capabilities payload, that's spec-conformant for either request.
Anchor for any future GET_ALL_CAPABILITIES_REQ injection test: patch a peer or test harness to issue sig 0x0c on the AVDTP signaling channel, capture the response, verify byte1 = 0x0c and the Service-Capabilities TLV list matches what stock issues for GET_CAPABILITIES.
Trace #17 (2026-05-10) — PlayerApplicationSettings response builders disassembled
Goal: map calling conventions for the six PDU response builders + event 0x08 builder needed by Phase F4 (PApp Settings: ICS Table 7 rows 12-17 + 30). All seven builders are present in libextavrcp.so and PLT-linked from libextavrcp_jni.so, so the disassembly is purely compiler-RE — no missing-symbol gaps.
Final calling conventions (also tabulated in ARCHITECTURE.md):
PDU 0x11 — btmtk_avrcp_send_list_player_attrs_rsp (file 0x1e24, 80 B): (conn, reject, n_attrs, *attr_ids). arg1=r0=conn, arg2=r1=reject_flag, arg3=r2=count of attribute IDs, arg4=r3=pointer to byte array. Stack buffer 14 B; emits msg_id=0x20c=524. Reject path: stores 1 at sp+7 and reject byte at sp+8.
PDU 0x12 — btmtk_avrcp_send_list_player_values_rsp (file 0x1e74, 92 B): (conn, reject, attr_id, n_values, *values). r0/r1 same; r2=attr_id, r3=n_values, arg5 (sp+0x28 in callee frame) = pointer to value array. msg_id=0x20e=526.
PDU 0x13 — btmtk_avrcp_send_get_curplayer_value_rsp (file 0x1ed0, 94 B): (conn, reject, n_pairs, *attr_ids, *values). r0/r1 same; r2=n_pairs, r3=attr_id_array, arg5 (sp+0x30) = value_array. Loop writes attr_ids at sp+12+i and values at sp+16+i. Wire format on AVRCP layer is interleaved (attr,val pairs) — AVRCP_SendMessage handles the IPC→wire repacking. Stack buffer 18 B; msg_id=0x210=528.
PDU 0x14 — btmtk_avrcp_send_set_player_value_rsp (file 0x1f2e, 40 B): (conn, reject_status). Smallest builder; reject_status==0 emits an ACK, otherwise emits a reject with that status code. msg_id=0x212=530, 8 B payload.
PDU 0x15 — btmtk_avrcp_send_get_player_attr_text_rsp (file 0x1f58, 228 B): (conn, reject, idx, total, attr_id, charset, length, *str). Accumulator pattern parallel to …send_get_element_attributes_rsp from the existing T4: caller invokes once per attribute (idx=0..total-1) and the function emits AVRCP_SendMessage only when idx+1==total AND total!=0 (or on reject). Internal static buffer at vaddr 0x5ea4 (g_avrcp_playerapp_attr_rsp); per-attribute string slot is 80 B (cap 0x4f=79 B usable). Args5-8 on caller's stack at offsets 0,4,8,12. msg_id=0x214=532.
PDU 0x16 — btmtk_avrcp_send_get_player_value_text_value_rsp (file 0x203c, 252 B): (conn, reject, idx, total, attr_id, value_id, charset, length, *str). Same accumulator shape as 0x15 but with both attr_id and value_id since each value gets its own text. Internal buffer at vaddr 0x5ffe (g_avrcp_playerapp_value_rsp). Args5-9 on stack. msg_id=0x216=534.
Event 0x08 — btmtk_avrcp_send_reg_notievent_player_appsettings_changed_rsp (file 0x2720, 144 B): (conn, reject, type, n, *attr_ids, *values). type: 0=INTERIM, 1=CHANGED. n is internally capped at 4 (max attribute count per AVRCP V13 §5.2). Args5-6 on stack. event_id constant 0x08 baked at sp+13. msg_id=0x220=544 (same msg_id as other notification events; the event_id byte at sp+13 is what differentiates).
Common shape across all seven builders:
- arg1 (r0) is always the conn buffer (= r5+8 in saveRegEventSeqId frame).
- arg2 (r1) is always reject/changed_flag: 0 = success path (full payload), !=0 = reject (truncated payload, status byte placed in a builder-specific slot).
- transId is sourced from
conn[17]and written into the wire frame internally — no caller responsibility. - AVRCP_SendMessage(conn, msg_id, sp_buffer, length) closes each builder.
PLT linkage in libextavrcp_jni.so (verified by r2 -A "ii~+player"):
| PDU / event | PLT addr |
|---|---|
| 0x11 list_player_attrs | 0x35d0 |
| 0x12 list_player_values | 0x35c4 |
| 0x13 get_curplayer_value | 0x35b8 |
| 0x14 set_player_value | 0x3594 |
| 0x15 get_player_attr_text | 0x35ac |
| 0x16 get_player_value_text | 0x35a0 |
| event 0x08 player_appsettings_changed | 0x345c |
All seven exist as proper PLT entries. None require new dynamic-linker resolutions.
Implementation implications for F4 (next-iteration anchors):
- Decoder dispatch. The existing trampoline chain (T1 → T2 → T_charset → T_battery → T_continuation → T6 → T8 → T9 → T4 → fall-through-to-0x65bc) reads the PDU byte at sp+382 and routes by exact match. Adding F4 means six new PDU comparisons (0x11..0x16). Cleanest insertion is a single new T-trampoline that hosts all six dispatchers internally — call it T_papp (or T10 for naming continuity). It chains in before T4's fall-through, so unknown-to-F4 PDUs flow into the existing 0x20 GetElementAttributes handler.
- Inbound parameter parsing. AVRCP body for PDUs 0x11-0x16 starts at sp+388 (after the 6-byte AV/C BT-SIG header at sp+378-383 and 2-byte param_length at sp+384-385). PDU 0x12 needs 1-byte attr_id; PDU 0x13 needs 1-byte n + n attr_ids; PDU 0x14 needs 1-byte n + n×{attr_id, value_id}; PDU 0x15 needs 1-byte n + n attr_ids; PDU 0x16 needs 1-byte attr_id + 1-byte n + n value_ids.
- State storage. Y1MediaBridge already has the file-based contract for track metadata (
/data/data/com.y1.mediabridge/files/y1-track-info, world-readable). Mirror this withy1-papp-statecontaining the current Repeat (id=2) and Shuffle (id=3) values. Y1MediaBridge writes when AndroidMediaController state changes; T_papp reads when responding to 0x13. Set commands (PDU 0x14) get applied by writing ay1-papp-setrequest file that Y1MediaBridge picks up via FileObserver and applies to the Android session viaMediaController.transportControls.setRepeatMode/setShuffleMode. - Event 0x08 emission. Existing T2/T8/T9 register-notification trampolines remember the registering peer's transId in BSS. Add an analogous slot for event 0x08 transId (already-allocated globals:
tc_transIdfor event 0x02,pb_transIdfor event 0x01,pos_transIdfor event 0x05; addpas_transId). Y1MediaBridge's onStateChange triggers re-firing T_papp's CHANGED-emit path when Repeat/Shuffle move. - Padding budget. Current
_trampolines.pyuses 1652 B of LOAD #1 padding (0xac54..0xb2c8); 2368 B remain. F4's T_papp is estimated at 400-600 B (six PDU dispatchers + one event re-emit path). Comfortable fit. - Strict scope alignment. Per AVRCP V13 §5.2.1 Player Application Settings, supporting any one PApp attribute makes ICS C.14 fire and rows 12-17 + 30 become Mandatory. Anchoring on Repeat (attr_id=2) + Shuffle (attr_id=3) maps cleanly to AndroidMediaController's repeat/shuffle modes (Y1's KitKat-era stack supports both via
setRepeatMode/setShuffleMode). These are the two universally-implemented PApp attributes on real CT/TG implementations and represent the strictest spec-conformance posture without adding device-specific equalizer/scan plumbing that doesn't exist on Y1.
Trace #18 (2026-05-10) — F4 iter2/3/4 staged plan: real Repeat/Shuffle state binding
iter1 ships hardcoded "Repeat OFF + Shuffle OFF, Set rejects with 0x06 INTERNAL_ERROR". The next iterations replace the hardcoded values with real state binding to the Y1 music app's SharedPreferencesUtils (where Repeat/Shuffle currently live). This trace captures the staged plan so the work can be sequenced cleanly without compounding unverified changes.
Music app state surfaces (from com/innioasis/y1/utils/SharedPreferencesUtils.smali).
public final getMusicIsShuffle()Z // SharedPreferences key "musicIsShuffle"
public final setMusicIsShuffle(Z)V // Editor.putBoolean + commit
public final getMusicRepeatMode()I // SharedPreferences key "musicRepeatMode"
public final setMusicRepeatMode(I)V // Editor.putInt + commit
PlayerService.smali defines the integer enum used by musicRepeatMode:
public static final REPEAT_MODE_OFF:I = 0x0
public static final REPEAT_MODE_ONE:I = 0x1
public static final REPEAT_MODE_ALL:I = 0x2
AVRCP 1.3 §5.2.4 Tbl 5.20 (Repeat) values: 0x01 OFF / 0x02 SINGLE / 0x03 ALL / 0x04 GROUP. Mapping (Y1 ↔ AVRCP): 0 ↔ 0x01, 1 ↔ 0x02, 2 ↔ 0x03 (Y1 has no GROUP). Verified 2026-05-09 via gdb-capture (/work/logs/papp-gdb.log): Bolt sends 0x01/0x02/0x03 (never 0x04) — bidirectional mapping is sound for both inbound Set and outbound GetCurrent.
AVRCP §5.2.4 Tbl 5.21 (Shuffle) values: 0x01 OFF / 0x02 ALL / 0x03 GROUP. Mapping (Y1 ↔ AVRCP): false ↔ 0x01, true ↔ 0x02. Verified 2026-05-09: Bolt sends 0x02 (never 0x03).
Cross-app context handle (already available). Y1Application$Companion.getAppContext():Context is reachable from any smali via Y1Application;->access$getAppContext$cp()Landroid/content/Context;. This eliminates the "no Context handle in static-ish methods" blocker noted in earlier deferral notes — setMusicIsShuffle / setMusicRepeatMode can sendBroadcast directly using the app-singleton context.
Iter2 (read path). Make T_papp 0x13 and T8 event 0x08 INTERIM reflect real Y1 Repeat/Shuffle state.
- B1 / B2 in
patch_y1_apk.py. Inject sendBroadcast at the end ofsetMusicIsShuffleandsetMusicRepeatMode. Actioncom.y1.mediabridge.PAPP_STATE_CHANGED; extrasisShuffle:Z(B1) andrepeatMode:I(B2). Pre-condition:.localsbumped from 4 → 5 to give us a free local register to save the originalpNvalue across the SharedPreferences write (the existing body clobbersp1viasget-object p1, …editor). Post-condition smali shape:` .method public final setMusicIsShuffle(Z)V .locals 5 move v4, p1 ; iter2: save original boolean …existing body unchanged… ; iter2 inject — broadcast new value invoke-static {}, Lcom/innioasis/y1/Y1Application;->access$getAppContext$cp()Landroid/content/Context; move-result-object v0 if-eqz v0, :iter2_skip new-instance v1, Landroid/content/Intent; const-string v2, "com.y1.mediabridge.PAPP_STATE_CHANGED" invoke-direct {v1, v2}, Landroid/content/Intent;->(Ljava/lang/String;)V const-string v2, "isShuffle" invoke-virtual {v1, v2, v4}, Landroid/content/Intent;->putExtra(Ljava/lang/String;Z)Landroid/content/Intent; invoke-virtual {v0, v1}, Landroid/content/Context;->sendBroadcast(Landroid/content/Intent;)V :iter2_skip return-void .end method` B2 mirrors withZ → Iand key"repeatMode". - Y1MediaBridge. Add
PappStateReceiverinner class (BroadcastReceiver) registered forcom.y1.mediabridge.PAPP_STATE_CHANGED. On receipt: read both extras (default to current cached values when only one is present), translate to AVRCP enum, write 2 bytes ([avrcp_repeat, avrcp_shuffle]) to/data/data/com.y1.mediabridge/files/y1-papp-statevia the same atomictmp + renamepattern asy1-track-info.prepareTrackInfoDir()creates the file with default[1, 1](OFF, OFF) on first launch so trampolines can read it before any music-app write fires. T_papp 0x13(GetCurrent). Replace the hardcodedpapp_current_valuesADR with: open + read 2 bytes fromy1-papp-stateinto stack scratch; if read fails, fall back to the hardcoded[1, 1]. Same pattern T4 uses fory1-track-info. Frame growth: +8 B for the file-I/O scratch + outgoing arg ptr.T8 event 0x08 INTERIM. Same file-read pattern; emitn=2 + [(2, repeat_value), (3, shuffle_value)].
Iter3 (write path). Make T_papp 0x14 (Set) actually apply changes.
T_papp 0x14(Set). Replace the iter1 reject path with: open/data/data/com.y1.mediabridge/files/y1-papp-set, write 2 bytes[attr_id, value]from the inbound param body (sp+387 / sp+389 — first attr/value pair; multi-pair Sets fall back to first), close, ACK.- Y1MediaBridge. Add
FileObserverwatchingy1-papp-setforMODIFY. On fire: read the 2 bytes, dispatch by AVRCP attr_id (2 → setMusicRepeatMode mapped back from AVRCP enum; 3 → setMusicIsShuffle). Use sendBroadcast to a music-app-side BroadcastReceiver added by: - B3 in
patch_y1_apk.py(or new dynamic register inY1Application.onCreate): a BroadcastReceiver listening forcom.y1.mediabridge.PAPP_SET_REQUESTwith extrasattr:I+value:I. On receipt, calls back intoSharedPreferencesUtils.setMusicRepeatMode/setMusicIsShufflewith the AVRCP→Y1 inverse mapping. The setters then fire B1/B2 broadcasts which Y1MediaBridge consumes — closing the loop. - PlayerService application of the change.
setMusicRepeatMode/setMusicIsShuffleonly update SharedPreferences; the actual playback behavior (does the next track repeat? does the next track shuffle?) is driven byPlayerServicereading those preferences at the right time. Confirming that PlayerService re-reads on every track-end transition (vs caching at startup) is open work.
Iter4 (CHANGED). Make event 0x08 fire CHANGED on real edges.
- MtkBt cardinality NOP.
BTAvrcpMusicAdapter.handleKeyMessagehas per-event-id cardinality checks (if-eqz v5patterns; same shape as the existing event-0x01 + event-0x02 NOPs) that drop the CHANGED firing if the cardinality field is 0. The event 0x08 sswitch arm needs the same NOP. Locate via grep on the smali / odex disassembly for the dispatch table. - Native jump-patch in
libextavrcp_jni.so. Add anotificationPlayerAppSettingsChangedNativeanalogue: identify which native method MtkBt calls for event 0x08 (or whether one exists), patch its first instruction tob.w T_papp_changed. T_papp_changed readsy1-papp-state+ emitsreg_notievent_player_appsettings_changed_rspwithREASON_CHANGED. - T9-style edge detect. State byte in
y1-trampoline-state(currently 16 B with last_play_status / last_battery_status at bytes 9-10) gains last_repeat_value / last_shuffle_value at bytes 11-12. T_papp_changed compares vs file bytes, emits CHANGED on inequality, updates state.
Sequencing rationale. Each iter is independently shippable + verifiable:
- Iter2 changes wire shape only on Get/INTERIM (read path); Set still rejects → iter2 adds zero new failure modes.
- Iter3 adds Set + write path → can be smoke-tested by issuing a Set from a peer CT and observing the music app's Repeat/Shuffle UI flip.
- Iter4 closes the CHANGED notification gap → can be smoke-tested by changing Repeat/Shuffle from the Y1 UI and watching for an AVRCP CHANGED frame on the wire.
Each iter is one commit, one OUTPUT_MD5 bump, one Y1MediaBridge versionCode bump.
Trace #19 (2026-05-10) — F4 iter4 shipped: T9 papp edge block + Patch B4 listener; deviations from #18's staged plan
iter4 ships, but the as-built wiring deviates from the Trace #18 plan in three load-bearing places. Captured here so the next iteration can read the as-shipped reality first.
JNI symbol enumeration (the unknown #18 flagged). BluetoothAvrcpService_notificationApplicationSettingChangedNative exists at file 0x47b4 (radare2 is~Native against stock libextavrcp_jni.so). 248 B function, signature (JNIEnv*, jobject*, signed char ack_type, signed char num_attrs, signed char count, _jbyteArray* attrs, _jbyteArray* values). Tail-calls btmtk_avrcp_send_reg_notievent_player_appsettings_changed_rsp at file 0x4878 (PLT 0x345c) — same import T8 INTERIM and T9 papp CHANGED both call directly. So the dispatch path exists but is fed only from BluetoothAvrcpService.run()'s native-event listener loop, NOT from BTAvrcpMusicAdapter.handleKeyMessage (which is what T9 / sswitch_18a piggybacks would have needed for a direct hook).
MtkBt smali finding (invalidates #18's "MtkBt cardinality NOP for event 0x08" plan). BTAvrcpMusicAdapter.handleKeyMessage's inner sparse-switch (sswitch_data_21c, smali line 1787) only contains arms for 0x1 / 0x2 / 0x9 (PlayStatus / Track / NowPlaying). There is no arm for 0x8. The only invocation of notificationApplicationSettingChangedNative in the entire MtkBt smali tree is in BluetoothAvrcpService.smali::run()'s pswitch_7f, fed by the native-event listener — wrong direction for proactive Y1→CT CHANGED. So iter4 cannot add a "matching cardinality NOP for event 0x08" — there's nothing to NOP.
As-built design (replaces the cardinality NOP + new native jump-patch from #18). Piggyback on T9 entirely:
- T9's existing entry hook at
notificationPlayStatusChangedNative(file0x3c88) and the existing MtkBt sswitch_18a cardinality NOP (msg=0x1, file0x3c4feinMtkBt.odex) already wake the trampoline on every Y1MediaBridgeplaystatechangedbroadcast. No new MtkBt edits. - Extend T9 with a fourth edge-detection block (papp): read
y1-track-info[795..796](repeat_avrcp / shuffle_avrcp), compare againsty1-trampoline-state[11..12], emitreg_notievent_player_appsettings_changed_rsp(conn, 0, REASON_CHANGED, 2, &papp_attr_ids, &file[795])on inequality. Frame grew 824→832 B (+8 B for the outgoing-args region the existing 0x08 INTERIM call shape needs at sp[0]/sp[4]; the values pointer is the file_buf addresssp+T9_OFF_FILE_REPEAT— file_buf already holds[r,s]contiguously, no scratch copy needed). - T8 0x08 INTERIM also reads file[795..796] now (replacing the static
papp_current_valuesADR withaddw r0, sp, T8_OFF_FILE_REPEAT). T_papp 0x13 GetCurrent retains the static fallback because Bolt postflash showed zero PDU 0x13 calls in practice; CTs subscribe to event 0x08 and never poll GetCurrent.
Y1-side broadcaster (replaces #18's #B1/B2 setMusicIsShuffle/setMusicRepeatMode injections). Patch B4 adds a single new class com.koensayr.PappStateBroadcaster implementing OnSharedPreferenceChangeListener. Registered against the "settings" SharedPreferences in Y1Application.onCreate (alongside the B3 PappSetReceiver registration). The listener fires on any write to any key but filters to musicRepeatMode / musicIsShuffle. On match, reads both live values via SharedPreferencesUtils.INSTANCE.getMusicRepeatMode() / getMusicIsShuffle(), maps to AVRCP enum bytes (Y1 0/1/2 → AVRCP 0x01/0x02/0x03 for Repeat; Y1 false/true → AVRCP 0x01/0x02 for Shuffle — the §5.2.4 mapping verified by Trace #18's gdb-capture), and broadcasts com.y1.mediabridge.PAPP_STATE_DID_CHANGE to package com.y1.mediabridge with extras repeat_avrcp:I + shuffle_avrcp:I.
Why a listener over per-setter sendBroadcast injections (#18's plan): the listener fires uniformly for AVRCP-driven Sets (which come in via Patch B3 calling SharedPreferencesUtils.setMusicRepeatMode / setMusicIsShuffle, which write the prefs and trip the listener) and Y1-UI toggles (in-app Settings screen calls the same setters). Single source of truth; no per-setter smali edit; no .locals bumping in SharedPreferencesUtils.smali. The listener is rooted via a static sInstance field inside PappStateBroadcaster so the GC doesn't reclaim it (Android holds OnSharedPreferenceChangeListener instances by weak reference).
Y1MediaBridge as-built. New mPappStateReceiver consumes ACTION_PAPP_STATE_DID_CHANGE, updates mCurrentRepeatAvrcp / mCurrentShuffleAvrcp (volatile bytes, default 0x01 OFF), calls writeTrackInfoFile (now writes buf[795] = repeat_avrcp; buf[796] = shuffle_avrcp;), and fires com.android.music.playstatechanged so MtkBt invokes notificationPlayStatusChangedNative → T9 picks up the edge. The intent extras are clamped to AVRCP §5.2.4 spec ranges (Repeat 0x01..0x04, Shuffle 0x01..0x03); out-of-range folds to OFF.
No separate y1-papp-state file. #18 proposed a 2-byte y1-papp-state file written by Y1MediaBridge and read by T_papp 0x13 + T8 0x08 INTERIM. As-shipped uses the existing y1-track-info schema's reserved bytes 795..799 instead — saves a write syscall (track-info is already written on every broadcast) and a read syscall (file_buf is already loaded by T8/T9 above the papp blocks). The y1-track-info schema comment at MediaBridgeService.java:1502 already had 795..799 pad (PlayerApplicationSettings shuffle_flag / repeat_mode reservation) — iter4 just makes that reservation real.
Initial-state sync. Y1Application.onCreate calls PappStateBroadcaster.sendNow() once on registration so a fresh music-app start (e.g. after reboot) syncs Y1MediaBridge to actual SharedPreferences state. There's no music-app-side query handler, so if Y1MediaBridge boots after the music app, the music-app initial broadcast was missed and Y1MediaBridge defaults to OFF/OFF until the first user toggle. In practice both processes are spawned at boot; the gap is short.
Open verification work (rolled into the active queue in docs/BT-COMPLIANCE.md §1):
- Hardware verify on Bolt: Y1-UI Repeat/Shuffle toggle → CT subscriber sees CHANGED frame following the edge.
- T_papp 0x13 GetCurrent live binding deferred (zero observed calls; not on critical path).
Open questions
- A2DP SupportedFeatures (attribute 0x0311) value — what feature bits does the served A2DP record advertise today, and what do A2DP 1.2 / 1.3 add? Confirms whether bumping A2DP 1.0 → 1.3 needs a paired feature-mask edit.
- A2DP / AVDTP version-byte authority — confirm via experimental flash + sdptool re-capture: bump 0xeb9f2 (A2DP) from 0x00 to 0x03, capture, see if the wire moves. If yes, static byte drives advertisement (same shape as V1 / V2). If no, mtkbt has runtime version logic too and we need to find it.
- AVCTP-multiplicity — V2 patches one of three AVCTP version sites. The other two (0xeba25 / 0xeba37) are unpatched at 0x0100 and may sit on dead code paths; verifying static-vs-runtime authority via experimental patch is the cheapest answer.
- GAVDP — no separate SDP record advertised (UUID 0x1203 hits in the SDP region are part of the HFP / HSP records, not GAVDP). Per GAVDP 1.3 §6 versioning piggybacks AVDTP; no independent byte-patch needed.
Verification path for any triad version-byte bump: experimental flash + tools/dual-capture.sh + sdptool browse + a peer CT that exercises GET_CAPABILITIES (AVDTP sig 0x02) — the captured exchange tells us what we advertise and what the peer does with it.
§9.2 A2dpSuspended Java approach reverted, HAL byte-patch landed (2026-05-09)
Context: §9.2 of BT-COMPLIANCE.md shipped in v2.7-v2.8 driving audioManager.setParameters("A2dpSuspended=true|false") from Y1MediaBridge/MediaBridgeService.java::onStateDetected on every play-state edge. Theory: setting A2dpSuspended=true would make libaudio.a2dp.default.so::standby_l skip its a2dp_stop call, leaving the AVDTP source stream alive across pauses (per AVDTP 1.3 §8.13 / §8.15).
Empirical falsification in capture /work/logs/dual-tv-20260509-1538 (TV pause/play exercise, post-flash with §9.2 + Patch H″):
15:38:40.527 D Y1MediaBridge: State change: avrcpStatus=2 (PAUSED)
15:38:40.529 D A2dpAudioInterface: +setSuspended 1
15:38:40.546 I [A2DP] a2dp_stop. is_streaming:1 ← stream torn down INSIDE setSuspended(1)
15:38:40.546 D A2dpAudioInterface: -setSuspended 1
Every PAUSED edge produces this pattern: +setSuspended 1 is followed within 17-31ms by [A2DP] a2dp_stop. is_streaming:1, on the same thread, before -setSuspended 1 returns. The AOSP A2DP HAL implements setSuspended(true) as a synchronous tear-down. Stock semantics: A2dpSuspended is the system's way of telling A2DP "drop the stream so I can route audio elsewhere (e.g., for a phone call)" — not the protective skip we assumed.
Net effect comparison (TV pause/play exercises):
| Capture | Standby events | a2dp_stop (streaming) |
|---|---|---|
dual-tv-20260509-1410 (pre-§9.2) | 8 | 8 |
dual-tv-20260509-1538 (post-§9.2 Java) | 3 | 7 |
§9.2 reduced silence-induced standby events (8→3) but introduced an equal number of pause-edge-triggered teardowns (1 per PAUSED edge). Burst-on-resume + playhead-drift symptom unchanged.
Pivot: drop the Java setParameters call entirely. Add patch_libaudio_a2dp.py (AH1) which flips a single ARM cond byte (0x0a → 0xea at file offset 0x000086ab) inside A2dpAudioStreamOut::standby_l. Original conditional beq 8684 becomes unconditional b 8684, making the call to a2dp_stop@plt at vaddr 0x86b0 unreachable. Standby still completes; AVDTP stream stays alive. No Java-side coupling.
Patch H″ verification (same capture): ✓ working — 0 kernel REPEAT events on event4 (U1 holds), 0 repeatCount=N>0 lines in dumpsys-input.txt (framework synthetic-repeat filter stays inactive because there are no synthetic repeats), clean DOWN/UP pairs in getevent. The framework-synthetic-FF cascade is closed.
Trace #20 (2026-05-11) — Y1MediaBridge retirement Phase 1: parallel in-app y1-track-info writer (B5/iter1)
Phase 1 of docs/PLAN-Y1MEDIABRIDGE-RETIREMENT.md lands. Music app gains its own y1-track-info writer at /data/data/com.innioasis.y1/files/; trampolines still read Y1MediaBridge's file at /data/data/com.y1.mediabridge/files/. Two writers run in parallel so the diff between them on every state edge is the verification gate for Phase 2's trampoline-path-string flip.
Failure mode driving the pivot. Y1MediaBridge's LogcatMonitor scrapes BasePlayerActivity UI render lines (刷新一次专辑图) and BaseActivity LiveData observer lines (播放状态切换 N) to learn about state changes. Empirically (2026-05-10 1119/1409/1901/1910 captures referenced in the plan doc): the scrape only fires when the music app's UI activity is in the foreground. KEYCODE_HOME → audio keeps playing → bridge sees nothing for the duration of the backgrounded session → metadata + play-state on the wire freeze.
Plan-vs-reality deltas surfaced by Phase 0 recon (docs/RECON-MUSIC-APP-HOOKS.md, commit bee0416). Plan §4.2 assumed MediaPlayer.OnCompletion/Prepared/Error registration and MediaMetadataRetriever for tag extraction. Actual:
- Primary engine is
tv.danmaku.ijk.media.player.IjkMediaPlayer(Bilibili IJK FFmpeg fork); secondary isandroid.media.MediaPlayer(player2). Listener interfaces differ — IJK usesIMediaPlayer$OnCompletionListeneretc.; both engines have 3-listener registration sites ininitPlayer()(line 875) andinitPlayer2()(line 1091). The R8-generated$$ExternalSyntheticLambda{0..5}thunks call intoinitPlayer$lambda-{10,11,12}(IJK Completion/Prepared/Error in source order — confirmed by chasing the$r8$lambda$*accessors) andinitPlayer2$lambda-{13,14,15}(MediaPlayer same). Six lambda bodies, six prepend hooks. Static.setPlayValue(II)VatStatic.smali:334is THE canonical play-state-edge entry. TheBaseActivity.setObserve$lambda-7log line (line 819) we currently scrape is the LiveData observer's reaction — fires aftersetPlayValueupdates the LiveData. Hooking the observer means waiting for activity resume. HookingsetPlayValuecatches every edge regardless of foreground state. setPlayValue's newValue space empirically includes 0/1/3/5 (mapped per Y1MediaBridge.LogcatMonitor's existing dictionary to STOPPED/PLAYING/PAUSED/STOPPED) plus internal Y1 transitions 2/4/6/7/8/9 which we ignore.- Music app does no
MediaMetadataRetriever— metadata lives in the RoomSongentity (already populated at scan time).TrackInfoWriterreadsPlayerService.getPlayingMusic()/getPlayingSong()Song getters directly, no re-extraction. Nodurationfield on the entity though — duration comes fromPlayerService.getDuration()(live from the engine). - Single-process app — no
android:processanywhere.AvrcpBridgeService(Phase 3) will be co-resident withY1ApplicationandPlayerServiceautomatically. Plan §5 risk row #1 closed empirically. - Music app does NOT emit
com.android.music.metachanged/playstatechangednatively — Y1MediaBridge is the sole sender today. Phase 1 keeps Y1MediaBridge installed so those broadcasts still wakeT9via the existingMtkBt.odexcardinality NOPs; Phase 3'sAvrcpBridgeServicewill replicate the broadcast emission inside the music app.
Architecture as-built (Phase 1). Four new classes under com.koensayr.y1.* (smali sources at src/patches/inject/com/koensayr/y1/, copied into smali_classes2/ at patcher time):
trackinfo.TrackInfoWriter— singleton state holder + atomic file writer. Mirrors the byte schema and field semantics ofMediaBridgeService.writeTrackInfoFile(1104 bytes, atomic tmp+rename,setReadable(true, false)for thebluetoothuid). audio_id at bytes 0..7 fromsyntheticAudioId(path) = (path.hashCode() & 0xFFFFFFFFL) | 0x100000000L— same hash Y1MediaBridge falls back to when MediaStore _ID lookup fails, so the byte should match the bridge's value when both are running against the same Song entity. State fields:mPlayStatus(B),mPositionAtStateChange(J),mStateChangeTime(J —SystemClock.elapsedRealtime()for lockstep with T6'sclock_gettime(CLOCK_BOOTTIME)),mPreviousTrackNaturalEnd(Z),mPendingNaturalEnd(Z — latched betweenonCompletionand the nextonTrackEdge),mBatteryStatus(B),mRepeatAvrcp(B, default 0x01 OFF),mShuffleAvrcp(B, default 0x01 OFF). All public mutators aredeclared-synchronizedonINSTANCEwith manualmonitor-enter/monitor-exit(Dalvik doesn't auto-wrap on the access flag).playback.PlaybackStateBridge— stateless static dispatcher.onPlayValue(II)Vmaps newValue → AVRCP byte.onPrepared/onCompletion/onErrorfire from the listener lambdas;onCompletionlatches natural-end (player engine guaranteesOnCompletionfires only at EOS — no extrapolated-vs-duration heuristic needed);onPreparedconsumes the latch intomPreviousTrackNaturalEnd, resets position+time, flushes;onErrorclears the latch.battery.BatteryReceiver—Intent.ACTION_BATTERY_CHANGEDconsumer, sticky-broadcast value processed inline at registration so cold boot has a real bucket. Same FULL_CHARGE > EXTERNAL > CRITICAL > WARNING > NORMAL bucket ordering asY1MediaBridge.handleBatteryIntent.papp.PappSetFileObserver—FileObserver(/data/data/com.innioasis.y1/files/y1-papp-set, CLOSE_WRITE). Reads 2 bytes (attr_id, value), maps to Y1 enum, callsSharedPreferencesUtils.setMusicRepeatMode/setMusicIsShuffledirectly. Inert in Phase 1 because trampolines still write the bridge's path; goes live in Phase 2 when the trampoline path strings flip. Pre-deployed so Phase 2 doesn't need a fresh smali edit, only an_trampolines.pyconstant change.
Hook injection sites (Patch B5.1..B5.4 in patch_y1_apk.py):
| Inject | Anchor | Why |
|---|---|---|
Static.setPlayValue top | After .locals 5 | Canonical state-edge entry (recon §2). |
PlayerService six lambda tops | After each lambda's .locals N | Six listener entries (one per (engine × callback) pair). Empty-arg invoke-static {} so no register pressure on the lambda's existing scratch use. |
Y1Application.onCreate :cond_3 | Between B3 and B4 | Order matters: TrackInfoWriter.init must run before PappStateBroadcaster.sendNow (which calls setPapp → flushLocked — would no-op if mFilesDir was null). |
PappStateBroadcaster.sendNow tail | After sendBroadcast | Phase-1 parallel write: keep the Y1MediaBridge broadcast (so the bridge's mCurrentRepeatAvrcp updates and its file matches), and ALSO call TrackInfoWriter.setPapp directly (so the music-app file matches). |
Smali smoke-test gotcha #1. First apktool reassembly failed with 4294967296 cannot fit into an int on const-wide/32 v0, 0x100000000L. const-wide/32 literal is 32-bit sign-extended; 2^32 needs full const-wide (64-bit). Fixed both occurrences in syntheticAudioId. Also confirmed declared-synchronized is purely an access-flag annotation in Dalvik — explicit monitor-enter/monitor-exit are required regardless.
Runtime gotcha #3 — PlayerService.getDuration() during prepareAsync nukes the new MediaPlayer (B5/iter1.3). Second on-device boot of B5 (post-iter1.2 hardening, commit 2101495) booted clean but playback stuck at 0:00 on every track requiring the android.media.MediaPlayer engine (isUseIjk(path) == false). Captured in /work/logs/logcat-20260510-2132.log. Trace:
436:D/MediaPlayerService: [3] prepareAsync # native MP starts async prep
437:D/DebugY1 restore: restart 使用mediaPlayer完毕 # music app's restart returns
438:E/MediaPlayer: Attempt to call getDuration without a valid mediaplayer
439:E/MediaPlayer: error (-38, 0) # async OnError
440:I/DebugY1 BaseActivity: 播放状态切换 1 # Static.setPlayValue(1, 8)
...
484:D/DebugY1 PlayerService: MediaPlayer Crash @414547b8 -38 0 # lambda-15 fires
485:D/DebugY1 PlayerService: player onError 2
486:D/MediaPlayerService: [3] reset # MP back to Idle, stuck
Root cause: the music app's restart sequence calls Static.setPlayValue(1, 8) to mark PLAYING after prepareAsync is dispatched but before OnPrepared arrives. My Static.setPlayValue hook fires synchronously → PlaybackStateBridge.onPlayValue → TrackInfoWriter.setPlayStatus → flushLocked → PlayerService.getDuration(). PlayerService.getDuration() (smali line 2922 :cond_1) delegates to player2.getDuration() for non-IJK paths. The C++ MediaPlayer::getDuration runs on the brand-new MediaPlayer instance #3 (still in Preparing state), logs Attempt to call getDuration without a valid mediaplayer, returns INVALID_OPERATION. The native MediaPlayer then transitions into Error state and posts an async OnError(-38) — which triggers stock's lambda-15 reset(), leaving the player Idle forever. playerIsPrepared never becomes true, BasePlayerActivity polls prepare: false indefinitely.
The stock app never queries getDuration between prepareAsync and OnPrepared. My flush did, on every play-state edge.
Fix: gate every PlayerService.getDuration() call inside TrackInfoWriter on PlayerService.getPlayerIsPrepared() (a pure iget-boolean, safe in any state). When not prepared, write 0 for duration — same "unknown" sentinel Y1MediaBridge uses. Two call sites in flushLocked (the per-attribute write at offset 776 + the PlayingTime ASCII string at 832) and one in computeLivePositionLocked (the duration-cap on extrapolated position). All three guarded.
Lesson for Phase 3: when AvrcpBridgeService Binder methods read playback state for inbound CT requests, treat any accessor that touches the underlying native player (getDuration, getCurrentPosition, seekTo, setVolume) as unsafe outside the prepared window. Use the same getPlayerIsPrepared gate.
Runtime gotcha #2 — MultiDex cache + system-app reflash interaction (B5/iter1.1). First on-device run threw java.lang.NoClassDefFoundError: com.koensayr.y1.trackinfo.TrackInfoWriter at Y1Application.onCreate(Y1Application.kt:137), with the dalvik verifier logging VFY: unable to resolve static field 47282 (INSTANCE) in Lcom/koensayr/y1/trackinfo/TrackInfoWriter; at class-load time and the runtime resolution failing even after MultiDex reported install done. Captured in /work/logs/logcat.log (pid 649 → first crash; pid 850 → second attempt with full trace including the MultiDex install logs). Root cause: classes2.dex extraction is cached under /data/data/com.innioasis.y1/code_cache/secondary-dexes/, which survives /system/app/com.innioasis.y1/com.innioasis.y1.apk reflashes; MultiDex 1.0.x on Dalvik 1.6 reuses the cached pre-patch classes2.dex (loading existing secondary dex files / load found 1 secondary dex files) and the new Lcom/koensayr/y1/* classes are nowhere to be found at runtime. Fix: route the four B5 classes into smali/ (primary DEX) instead of smali_classes2/ so they load with Y1Application itself — same DEX placement as B3/B4. apktool 2.9.3 / smali 3.0.3 reassembly succeeded; both classes.dex (9.2 MB, ~+25 methods) and classes2.dex (8.97 MB, unchanged) under the 64K-method cap. All four classes verified in classes.dex via unzip -p classes.dex | strings | grep koensayr/y1.
Phase 1 → Phase 2 verification gate. Both files exist on device and update in lockstep (adb shell md5sum /data/data/com.innioasis.y1/files/y1-track-info /data/data/com.y1.mediabridge/files/y1-track-info). The plan idealised this as "byte-exact within ±100 ms"; in practice mStateChangeTime (low 32 bits of SystemClock.elapsedRealtime() at the edge) will differ by the few-ms gap between when each writer fires, and audio_id may differ if Y1MediaBridge's MediaStore _ID lookup succeeds (music app side always uses the synthetic hash). The realistic gate is "all CT-visible fields (Title/Artist/Album/Genre/Duration/PlayStatus/NaturalEnd/Battery/Repeat/Shuffle/TrackNumber/TotalTracks/PlayingTime) match byte-for-byte; clock + audio_id allowed to differ within their natural skew." Plus the foreground/background test: adb shell input keyevent KEYCODE_HOME → trigger track change via Bluetooth PASSTHROUGH → music-app file updates (existing LogcatMonitor scrape would not).
Open work (handed to Phase 2). _trampolines.py flips three asciiz literals from /data/data/com.y1.mediabridge/files/ to /data/data/com.innioasis.y1/files/; re-pin libextavrcp_jni.so OUTPUT_MD5. After the flip, PappSetFileObserver becomes the live consumer of T_papp 0x14 writes (replacing Y1MediaBridge's FileObserver). Y1MediaBridge keeps writing to its own path but to a file the trampolines no longer read — dead but installed. Phase 3 then adds AvrcpBridgeService to the music app manifest and uninstalls Y1MediaBridge.
Trace #21 (2026-05-11) — Y1MediaBridge retirement Phase 2: trampoline file-path cutover
Phase 1 (Trace #20) shipped the music app's TrackInfoWriter as a parallel writer at /data/data/com.innioasis.y1/files/y1-track-info, with on-device md5sum comparison against Y1MediaBridge's path confirming all CT-visible fields match byte-for-byte except state_change_time_ms, position_ms, and (when MediaStore _ID lookup succeeds bridge-side) audio_id. Verified 2026-05-11 on Killswitch Engage "My Last Serenade (live)":
audio_id OK music=00000001a957a7e8 bridge=00000001a957a7e8
title OK music=4d79204c61737420536572656e616465... bridge=…
artist OK music=4b696c6c73776974636820456e676167 bridge=…
album OK music=54686520456e64206f66204865617274… bridge=…
duration MISMATCH music=0000c288 (49800 ms) bridge=0000c25e (49758 ms)
play_status OK music=01 bridge=01
battery OK music=00 bridge=00
repeat OK music=04 bridge=04
shuffle OK music=01 bridge=01
The 42 ms duration delta is expected: Y1MediaBridge re-runs MediaMetadataRetriever (Xing/LAME header parse) while TrackInfoWriter.flushLocked calls MediaPlayer.getDuration() (codec-reported). For VBR MP3 those routinely disagree by tens of ms; the trampolines round to seconds for AVRCP PLAYING_TIME anyway. audio_id matched because both writers fell through to the path-hash fallback ((hash & 0xFFFFFFFFL) | 0x100000000L) — the Phase 2 blocker I flagged in Trace #20 ("MediaStore-id mismatch would force a CT track-change on every state edge") is not a problem in practice.
Path-string flip. Three asciiz literals in _trampolines.py (lines 2267 / 2270 / 2273) repointed from /data/data/com.y1.mediabridge/files/* to /data/data/com.innioasis.y1/files/*. Each path string shrinks by 2 bytes (com.y1.mediabridge = 18 chars → com.innioasis.y1 = 16 chars); all PC-relative references to the path labels resolve through Asm.label so the assembler re-computes offsets automatically. Trampoline blob grows by net 700 bytes vs. the figures in docs/PATCHES.md that were stale (pre-T_papp); current size = 2736 bytes (was nominally tracked as 2036 / 1652 in older docs), free padding after LOAD #1 = 1284 bytes.
OUTPUT_MD5 transition. Set OUTPUT_MD5 = None temporarily, re-ran patch_libextavrcp_jni.py /work/v3.0.2/system.img.extracted/lib/libextavrcp_jni.so. Pre-patch verification confirmed 9/9 sites OK against STOCK_MD5 = fd2ce74db9389980b55bccf3d8f15660. Post-patch verification 9/9 sites OK. New OUTPUT_MD5 = f021e71d12c170f2e135281d37ba8477 (was 5b7f5ae685c4c9299f36b1b3f88d564c in the v1+B5 build). Output size unchanged at 50,992 bytes — confirms the 6-byte path-string shrink fits within existing alignment slack and no LOAD #1 program-header bump is needed.
Cold-boot gap (known limitation). Y1MediaBridge auto-starts at boot via its BOOT_COMPLETED receiver; the music app's Y1Application.onCreate runs only when the music process is launched. Between reboot and first music-app launch, MtkBt's trampolines open() the music-app files and get ENOENT (no O_CREAT on read paths). Trampolines fail-soft on ENOENT (return INTERIM with sentinel/zero values). In practice the user launches music before connecting a CT, so this only affects the contrived "boot → connect CT immediately" path. Phase 3's AvrcpBridgeService (exported, bound by MtkBt) will cold-start the music process implicitly via bindService. Mitigation alternative if Phase 3 slips: add a BOOT_COMPLETED receiver to the music app that calls TrackInfoWriter.prepareFiles() to materialise the files.
Patches B3 status post-cutover. B3 (com.koensayr.PappSetReceiver) is now inert: Y1MediaBridge's FileObserver on its own y1-papp-set no longer fires (the trampolines write to the music-app path), so the bridge no longer re-broadcasts SET_REPEAT_MODE / SET_IS_SHUFFLE intents that B3 listens for. B5's PappSetFileObserver is the live consumer. B3 is kept installed as a transitional safety net; Phase 3 / 4 will remove it.
B4 wake-up loop after Phase 2. PappStateBroadcaster.sendNow calls TrackInfoWriter.setPapp(repeat, shuffle) directly (writing the music-app's y1-track-info[795..796]) and continues to fire the com.y1.mediabridge.PAPP_STATE_DID_CHANGE broadcast. Y1MediaBridge still consumes that broadcast and fires com.android.music.playstatechanged, which is what wakes T9 to emit AVRCP §5.4.2 Tbl 5.36 PLAYER_APPLICATION_SETTING_CHANGED CHANGED on the wire. The bridge's own y1-track-info write (also at byte 795..796) is a dead path. After Y1MediaBridge is retired, the music app will need to fire playstatechanged itself in setPapp — currently it relies on the bridge as a broadcast relay.
Active docs updated for Phase 2 reality. ARCHITECTURE.md "Music app state-writer lifecycle" section now describes the in-process writer chain (TrackInfoWriter + PlaybackStateBridge + BatteryReceiver + PappSetFileObserver + PappStateBroadcaster) replacing the old "Y1MediaBridge lifecycle" walk; cross-component state dependencies table re-anchored to the music app's filesDir; BT-COMPLIANCE.md ICS rows + risk table refreshed; PATCHES.md B3/B4/B5 narratives reflect Phase 2 owner; _trampolines.py and patch_libextavrcp_jni.py source comments stripped of stale "Y1MediaBridge writes" framing.
Phase 2 → Phase 3 verification gate (per docs/PLAN-Y1MEDIABRIDGE-RETIREMENT.md §6): AVRCP CT cold-connect → metadata visible within one polling cycle; T_papp 0x14 Set still round-trips into music-app SharedPreferences via PappSetFileObserver; play/pause edges still drive CHANGED notifications. Verified on hardware 2026-05-11 via a Sonos dual-capture (/work/logs/dual-sonos-20260511-0733/): 7,272 msg=544 frames (RegisterNotification responses), 227 msg=540 (GetElementAttributes responses with size=644, carrying Title/Artist/Album bytes), 18 msg=520 (PASSTHROUGH ACKs). No FATAL / NoClassDefFoundError. Sonos display tracked correctly per the user's confirmation. Gate passed; Phase 3 unblocked.
Trace #22 (2026-05-11) — Y1MediaBridge retirement Phase 3: AvrcpBridgeService Binder lands in the music app
Phase 3 retires Y1MediaBridge.apk by hosting its IBTAvrcpMusic + IMediaPlaybackService Binder inside the music app itself. Two new smali classes (com.koensayr.y1.avrcp.AvrcpBridgeService + AvrcpBinder) implement a minimum-viable Binder; apply.bash no longer installs Y1MediaBridge.apk and now removes any pre-existing copy from /system/app/.
Recon (already done in Phase 0; reused). docs/RECON-MUSIC-APP-HOOKS.md §7 has the full transaction-code table for both interfaces (38 codes on IBTAvrcpMusic, 32 on IMediaPlaybackService, 8 on IBTAvrcpMusicCallback). Y1MediaBridge's MediaBridgeService.java::onTransact ships a working reference implementation for every code; Phase 3 mirrors its dispatch shape but in smali.
Minimum-viable scope. ARCHITECTURE.md's existing note on the Binder role — "in the post-patch architecture this Java path is largely unused; the C-side trampolines deliver the real metadata + control on the AVRCP wire" — set the bar low. The Sonos log from Trace #21 confirmed it: Y1MediaBridge: notifyAvrcpCallbacks code=1 — no callbacks registered appeared 20× alongside 7,272 T9 wakeups via the broadcast path. MtkBt never actually transacted on the Java callback path; the broadcast wake path drove everything. So AvrcpBinder.onTransact implements: code 1 (registerCallback) — stash IBinder; code 2 (unregisterCallback); code 3 (regNotificationEvent) — ACK true (critical: returning false leaves MtkBt's mRegBit empty and notifyTrackChanged gets dropped pre-emit); code 5 (getCapabilities) — return [0x01, 0x02]; codes 6-13 — broadcast media keys to PlayControllerReceiver via DOWN+UP ACTION_MEDIA_BUTTON. Every other code: writeNoException + return true. Total smali: ~330 lines for AvrcpBinder + ~280 lines for AvrcpBridgeService (Service shell + callback list + media-key sender).
Descriptor skip. Same defensive pattern Y1MediaBridge used: skip strictModePolicy (int32) + descriptor (string) and dispatch purely by transact code. enforceInterface has historically aborted on ROM-variant descriptor mismatches, leaving cardinality at 0. Code path tested cleanly: apktool b's smali compile succeeds (Smaling smali_classes2 folder into classes2.dex); androguard's AXMLPrinter re-parses the manifest splice; method count delta is +38 in classes2.dex (52,935 → 52,973, well under the 64K cap; 12,563 slots free).
Method-count routing. classes.dex sits at 65,330/65,536 (99.7%, ~176 slots free) after Patch B5. AvrcpBridgeService + AvrcpBinder route to smali_classes2/com/koensayr/y1/avrcp/ — secondary DEX, where there's 12K+ method headroom. Trade-off: MultiDex 1.0.x on Dalvik 1.6 caches the extracted classes2.dex under /data/data/com.innioasis.y1/code_cache/secondary-dexes/, and that cache survives every apply.bash invocation (mtk.py w android writes the system partition only — no apply.bash flag touches userdata). Mitigation: apply.bash emits an unconditional instruction at the end of --avrcp telling the user to adb shell rm -rf /data/data/com.innioasis.y1/code_cache/secondary-dexes/ before reboot. Earlier docs (Trace #20 + the dex-budget memory) said --all would reprovision userdata as a side effect; that was wrong — corrected here. Every --avrcp install needs the manual cache-clear step until/unless we wire a music-app-side cache invalidator (an Application.onCreate shim that calls getCodeCacheDir().delete() if a sentinel class is missing).
AndroidManifest patch (the real engineering decision). apktool d --no-res leaves the manifest as binary AXML; we have no aapt2 binary on the host (/tmp/bak/prebuilt/linux/aapt2 is 32-bit x86, host is 64-bit; Rocky 10 has no aapt2 package; apktool's bundled aapt2 fails with cannot execute binary file). Three paths considered:
- Python AXML splicer — wrote
src/patches/_axml.py(~250 lines). Reads the binary AXML chunk-by-chunk, exposesstart_element/end_element/attr_string/attr_bool/attr_intbuilders, and awritethat re-emits the file with a freshly-serialized string pool. Round-trip on the unmodified manifest is byte-identical (md5 match). Strings get APPENDED to the pool past the resource-mapped prefix (first 31 slots are android.R.attr.* via ResourceMap; new strings at index 172+ have no ResourceMap entry, no conflict). - Install Android SDK + aapt2 — adds a permanent ~1.5 GB toolchain dep. Wrong direction since Phase 4 was meant to drop the gradle dep anyway.
- Tiny shim APK — defeats the "retire to a single APK" intent of the plan.
Went with option 1. Manifest splice inserts (just before </application>):
<service android:name="com.koensayr.y1.avrcp.AvrcpBridgeService" android:exported="true">
<intent-filter android:priority="100">
<action android:name="com.android.music.MediaPlaybackService"/>
<action android:name="com.android.music.IMediaPlaybackService"/>
</intent-filter>
</service>
Verified the splice via androguard's AXMLPrinter.get_xml() — independent reader re-emits the exact XML we intended. New manifest is 23,516 bytes (was 22,916; +600 ≈ new chunks (340) + 3 new strings × ~85 bytes each + alignment).
Output-APK assembly bug surfaced. The patcher's final zip-rebuild loop swaps only classes.dex / classes2.dex from staging; manifest comes from the stock APK regardless. Phase 1 + 2 never patched the manifest so the bug was invisible. Fixed in the same commit: also swap AndroidManifest.xml from staging.
Wake-up trigger migration. Y1MediaBridge previously fired com.android.music.playstatechanged on:
- play/pause edge (stock music app fires it too, so duplicative — OK)
- battery bucket transition (Y1MediaBridge-only — now music-app responsibility)
- papp change via
PAPP_STATE_DID_CHANGEintent bridge (Y1MediaBridge-only — now music-app responsibility) - 1 s position tick while playing (stock music-app fires it, no change needed)
After Y1MediaBridge is removed, the music app must emit playstatechanged for battery + papp. Phase 3 changes:
BatteryReceiver.onReceive(smali): afterTrackInfoWriter.setBattery, fireContext.sendBroadcast(Intent("com.android.music.playstatechanged"))wrapped intry/catch(Throwable).PappStateBroadcaster.sendNow(Patch B5.4 inpatch_y1_apk.py): same pattern afterTrackInfoWriter.setPapp. The existingcom.y1.mediabridge.PAPP_STATE_DID_CHANGEbroadcast is retained as a no-op for the transition window — goes to no listener once Y1MediaBridge is uninstalled.
apply.bash changes.
- Dropped the
assembleDebugprerequisite and theY1MediaBridge.apkinstall step. - Added defensive removal of
/system/app/Y1MediaBridge.apk/Y1MediaBridge.odex/Y1MediaBridge/at mount time (covers users upgrading from a previous --avrcp build). - Added a post-flash usage note pointing at the code_cache invalidation
adb shellcommand and thepm uninstall com.y1.mediabridgedefensive cleanup (covers any prior non-system-app installs).
Patcher smoke test passed. Full --clean-staging run:
- All B1..B5 patches apply as before.
- B6.1 copies
AvrcpBridgeService.smali+AvrcpBinder.smaliintosmali_classes2/com/koensayr/y1/avrcp/. - B6.2 splices the manifest via
_axml.py. apktool bsmaling phase: clean, no errors.- DEX method counts: classes.dex 65,330 (unchanged), classes2.dex 52,973 (+38 vs Phase 2 baseline).
- Output APK contains the spliced manifest (verified via androguard) and both new smali classes.
Phase 3 → 4 verification gate (per plan §6): pm list packages | grep mediabridge empty post-flash; ls /system/app/Y1MediaBridge* returns "No such file or directory"; MtkBt's adapter logs MMI_AVRCP: PlayService onServiceConnected className:com.koensayr.y1.avrcp.AvrcpBridgeService (not the old com.y1.mediabridge); all three CT scenarios (Bolt/Kia/TV — Sonos deprecated) repeat the Phase 2→3 gate. To be verified on hardware after reflash.
Known unknowns to watch on first flash.
- Cold-boot Binder bind: MtkBt's bindService should cold-start the music app's process via Android's standard service-binding flow, which means
Y1Application.onCreateruns (registering TrackInfoWriter etc.) before MtkBt'sonServiceConnectedcallback fires. If this races (e.g.onServiceConnectedarrives beforeY1Application.onCreatecompletes), theAvrcpBridgeService.onCreateorder may be off. The fix would be to makeAvrcpBridgeService.onCreatedefensively triggerTrackInfoWriter.inititself, but Android's lifecycle guarantees Application.onCreate runs before any component's onCreate, so this race shouldn't happen in practice. Monitor for it. - PackageManager resolution priority: the intent-filter ships at
android:priority="100". Y1MediaBridge's intent-filter has no priority (default 0). PMS should resolve to the music app's filter on first install. If both APKs are present (transition window), the music app wins. After Y1MediaBridge is removed, only the music app has a matching filter. - code_cache staleness: MultiDex 1.0.x reuses any cached classes2.dex it finds at
/data/data/com.innioasis.y1/code_cache/secondary-dexes/, regardless of whether the underlying APK changed (observed in Trace #20).apply.bashnever touches /data, so the cache persists across reflashes —--all,--avrcp, and any other combination of flags. The mandatory post-flash adb-shell step is the cache-clear; no flag bypasses it.
Trace #23 (2026-05-11) — Phase 3 v1 stand-down: AndroidManifest.xml splice rejected by JarVerifier
Phase 3 v1 (commit 032f655) shipped an AndroidManifest.xml splice via the new src/patches/_axml.py editor, adding a <service> declaration for com.koensayr.y1.avrcp.AvrcpBridgeService with a priority-100 intent-filter for com.android.music.MediaPlaybackService. The intent: MtkBt's bindService would resolve to the music app instead of Y1MediaBridge, retiring Y1MediaBridge.apk entirely.
User flashed Phase 3 v1 via mtkclient. Boot hung at the boot animation indefinitely. New logcat capture (/work/logs/logcat-20260511-0927.log) shows PackageManager rejecting the patched music APK during /system/app/ scan:
W/PackageParser(523): java.lang.SecurityException:
META-INF/MANIFEST.MF has invalid digest for AndroidManifest.xml
in /system/app/com.innioasis.y1_3.0.2.apk
E/PackageParser(523): Package com.innioasis.y1 has no certificates
at entry AndroidManifest.xml; ignoring!
W/PackageManager(523): Failed verifying certificates for package:com.innioasis.y1
D/PackageManager(523): scan package: /system/app/com.innioasis.y1_3.0.2.apk,
elapsed time = 1831ms
PackageManager dropped com.innioasis.y1 entirely. With no music app installed, the system's launcher (which lives in com.innioasis.y1.activity.MainActivity) couldn't start, BootCompleted never fired, the boot animation looped forever.
Root cause. META-INF/MANIFEST.MF records a SHA1-Digest for each file in the APK. JarVerifier, called via JarFile.getCertificates(AndroidManifest.xml) in PackageParser.collectCertificates, reads AndroidManifest.xml and SHA1s the bytes; comparison against MANIFEST.MF's recorded digest fails on our modified manifest → throws SecurityException → getCertificates() returns null → PackageParser reports "no certificates" → package dropped.
Phase 1 + 2 worked because JarVerifier only digest-checks AndroidManifest.xml during scan. It does NOT check classes.dex / classes2.dex / resources.arsc at parse time. Empirically: we modified classes.dex (Patch B5) without issue. The moment we modified AndroidManifest.xml, JarVerifier fired.
Why we can't re-sign. Updating the SHA1-Digest in MANIFEST.MF would invalidate CERT.SF's per-section SHA1 (which signs the MANIFEST.MF section bytes). Updating CERT.SF invalidates CERT.RSA's signature over CERT.SF. Re-signing CERT.RSA requires the OEM platform private key, because com.innioasis.y1 declares android:sharedUserId="android.uid.system". Without that key the package would either be rejected entirely (unsigned check) or rejected for not matching the platform-cert prerequisite of android.uid.system.
Why Y1MediaBridge.apk works. Different package (com.y1.mediabridge), self-signed test cert, no sharedUserId constraint. /system/app/ doesn't require any specific signing key for arbitrary packages — only sharedUserId-claiming packages need a matching cert.
Phase 3 v1 stand-down. Reverted in commit <next>:
patch_y1_apk.py: dropped Patch B6.2 (manifest splice). The B6.1 smali drop (AvrcpBridgeService.smali / AvrcpBinder.smali intosmali_classes2/) is retained as groundwork for Phase 3 v2 — the classes exist but are not declared anywhere, so nothing instantiates them at runtime.apply.bash: restored the Y1MediaBridge.apk install step + the gradle-build prerequisite + the post-flash adb-shell note (dropped). Defensive removal of pre-existing Y1MediaBridge.apk removed.patch_y1_apk.pyzip-rebuild: no longer swaps AndroidManifest.xml (it stays bit-exact stock).- Active docs (ARCHITECTURE.md, PATCHES.md, README.md, CHANGELOG.md) re-anchored to "Y1MediaBridge.apk stays as Binder host; music app does file writes + state production."
Phase 3 net result. Same on-the-wire behavior as Phase 2 (verified working on Sonos in Trace #21):
- Music app's
TrackInfoWriteris the canonical writer fory1-track-info/y1-trampoline-state/y1-papp-setunder/data/data/com.innioasis.y1/files/. - Trampolines in
libextavrcp_jni.soread from the music app's path (Phase 2's path-string flip). - Y1MediaBridge.apk hosts the Binder declaration MtkBt binds to — its file-write side runs but writes a path nothing reads.
- AvrcpBinder smali classes ship in classes2.dex as groundwork; not load-bearing.
BatteryReceiverandPappStateBroadcasterfirecom.android.music.playstatechangedfor non-play-edge wakeups — useful regardless of who hosts the Binder, since these previously relied on Y1MediaBridge as a broadcast relay.
Phase 3 v2 design space (not implemented in this stand-down):
- Shrink Y1MediaBridge to a thin Binder forwarder. Keep its manifest-declared service, replace the bulk of MediaBridgeService.java with:
onBinddoesbindService(new Intent().setComponent(new ComponentName("com.innioasis.y1", "com.innioasis.y1.service.PlayerService")))and returns the bound IBinder. Music app's PlayerService.onBind is smali-extended to return AvrcpBinder when called with a specific intent marker. This achieves "Y1MediaBridge is trivial" without touching the music APK manifest. Two APKs but the bridge becomes ~50 lines. - Brand-new tiny stub APK. Build a fresh
Y1AvrcpStub.apk(own package, own self-signed cert, no platform key needed) whose only job is the intent-filter Service declaration. Replaces Y1MediaBridge.apk; ~5 KB. Build pipeline: minimal source + signapk.jar (no gradle). Same architectural outcome. - Patch MtkBt.odex to bindService via component name. Change
BTAvrcpMusicAdapter.startToBindPlayServicesmali to useIntent().setClassName("com.innioasis.y1", "com.innioasis.y1.service.PlayerService")instead of action-only Intent. Component-based bindService doesn't require an intent-filter, so the stock manifest's existing PlayerService declaration would resolve. Smali-extend PlayerService.onBind to return AvrcpBinder. Truly retires Y1MediaBridge with zero new APKs. Most invasive: requires inserting smali instructions into MtkBt.odex's method body (shifts code offsets), which the current patch_mtkbt_odex.py infrastructure doesn't support — would need a smali-reassembly path.
All three Phase 3 v2 paths preserve the constraint discovered in Trace #23: don't modify com.innioasis.y1's AndroidManifest.xml.
Trace #24 (2026-05-11) — Phase 3 v2: shrink Y1MediaBridge to a minimal Binder host
Phase 3 v1 (Trace #23) established that we can't retire Y1MediaBridge.apk entirely without either (a) the OEM platform key (impossible) or (b) substantial MtkBt.odex smali surgery (deferred). What we CAN do — and what the user asked for as the optimal forward path — is keep all behavioral logic in the music app's process and shrink Y1MediaBridge to nothing but a Binder declaration. The "visibility issues" that drove the original Y1MediaBridge design (logcat scraping, cross-process state observation, foreground/background gaps) are all solved by Phase 1+2's in-music-app components; Y1MediaBridge had been duplicating that work to a dead path since Phase 2 shipped.
What got deleted from src/Y1MediaBridge/app/src/main/java/com/y1/mediabridge/MediaBridgeService.java
Old file: 2152 lines. New file: 130 lines. Deletions:
LogcatMonitorthread +processLogLine+onStateDetected+onTrackDetected+ every state-tracking field (mPlayStatus,mIsPlaying,mCurrentTitle,mCurrentArtist,mCurrentAlbum,mCurrentDuration,mPositionAtStateChange,mStateChangeTime,mPreviousTrackNaturalEnd,mCurrentRepeatAvrcp,mCurrentShuffleAvrcp, …). The music app'sPlaybackStateBridgeobserves the player engine in-process — no logcat race, no foreground/background gap.- The 1104-byte
y1-track-infoschema +writeTrackInfoFile+putBE64/putBE32/putUtf8Paddedhelpers +prepareTrackInfoDir.TrackInfoWriterin the music app is the canonical writer (Phase 1+2). setupRemoteControlClient+mAudioManager+registerMediaButtonEventReceiver. The music app's manifest-declaredPlayControllerReceiver(priority=MAX_INT forACTION_MEDIA_BUTTON) wins ordered-broadcast dispatch directly; AudioService's RCC fallback to ordered broadcast is the active path.registerBatteryReceiver+handleBatteryIntent+ bucket-mapping helpers. Music app'sBatteryReceiverdoes this and firesplaystatechangeditself.registerPappStateReceiver+handlePappStateIntent+mPappStateReceiver. Music app'sPappStateBroadcastercallsTrackInfoWriter.setPappdirectly and firesplaystatechangeditself.setupPappSetObserver+ the bridge'sFileObserver(y1-papp-set). Music app'sPappSetFileObserverwatches the path the trampolines actually write to.mPosTickRunnable+ the 1 Hz position-tickHandler.postDelayedloop. Music app'sPlayerServicealready emitsplaystatechangedon its own tick.notifyAvrcpCallbacks+notifyPlaybackStatus+notifyTrackChanged+ themAvrcpCallbacksCopyOnWriteArrayList. Per Sonos capture in Trace #21, MtkBt never registered a callback (the binder transact 1 never landed); the broadcast wake path drove every T5 / T9 fire. Dead code.mPlayService+mPlayConnection+ everyIMediaPlaybackService.Stub.asInterfaceconsumer. The bridge didn't need to bind to anything.computePosition+safeString+sendMediaKey+ every other helper. All deleted.
What stays in MediaBridgeService.java (130 lines)
- Empty
onCreate. onBindreturns a privateAvrcpBinderinstance.onUnbindreturnstrueso the framework's service record persists across MtkBt re-binds.AvrcpBinder(~30 LOC inner class):onTransactskipsstrictModePolicy+ descriptor string (same defensive pattern Y1MediaBridge used to dodge ROM-variantenforceInterfacefailures), dispatches by code. Code 5 (getCapabilities) returns[0x01 PLAYBACK_STATUS_CHANGED, 0x02 TRACK_CHANGED]. Every other code:writeNoException+return true.
PlaySongReceiver.java simplification
- Deleted:
ACTION_MEDIA_BUTTONforwarding (music app's PlayControllerReceiver handles directly via ordered broadcast);MY_PLAY_SONGhandling (was wakeup for the deleted LogcatMonitor);ABOUT_SHUT_DOWNhandling (was for the deleted shutdown coordination). - Kept:
BOOT_COMPLETED→startService(MediaBridgeService)so the service is alive when MtkBt's firstbindServicefires (bindService would cold-start it anyway, but this makes the first bind cheaper).
Old PlaySongReceiver.java: 106 lines. New: 28 lines.
AndroidManifest.xml cleanup
- Dropped permissions:
READ_LOGS(logcat monitor gone),MEDIA_CONTENT_CONTROL(no more MediaController APIs),MODIFY_AUDIO_SETTINGS(no more RCC / AudioManager),BLUETOOTH(we don't talk BT directly — MtkBt does),READ_EXTERNAL_STORAGE+WRITE_MEDIA_STORAGE(no file IO outside our own data dir),WAKE_LOCK(no background work). - Kept:
RECEIVE_BOOT_COMPLETED. - Dropped
<application android:persistent="true">: with no background work happening, there's no reason to keep the process resident. MtkBt'sbindServicewill cold-start it when needed; the framework keeps the binder bound while there's a client. - Dropped
<receiver>intent-filters: removedMY_PLAY_SONG,ABOUT_SHUT_DOWN,MEDIA_BUTTON. OnlyBOOT_COMPLETEDleft.
Old manifest: 74 lines. New: 43 lines.
app/build.gradle lint config
Removed suppressions for warnings that no longer apply: ProtectedPermissions (no MEDIA_CONTENT_CONTROL), SetWorldReadable (no setReadable(true, false)). Kept: ExpiredTargetSdkVersion, ExportedService, MissingApplicationIcon.
Net result
Y1MediaBridge/ source goes from 2332 lines to 201 lines across three files. The compiled APK should be ~5–10 KB (was ~80 KB pre-shrink). Build pipeline unchanged: cd src/Y1MediaBridge && ./gradlew assembleDebug → app/build/outputs/apk/debug/app-debug.apk → apply.bash --avrcp copies to /system/app/Y1MediaBridge.apk.
The wire-level behavior is identical to Phase 1+2 (the bridge wasn't load-bearing for AVRCP events post-Phase-2 anyway — it just had a lot of redundant code running). The user-facing improvement is that the bridge is now trivially auditable: ~30 lines of actual logic.
Visibility-issue audit (the user's framing question)
| Old Y1MediaBridge issue | Resolution in Phase 1+2+3v2 |
|---|---|
LogcatMonitor parsing '1'/'3'/'5' from log lines | PlaybackStateBridge hooks Static.setPlayValue + player engine listener lambdas — sees state edges in-process, no log race |
| Foreground/background gap (logcat scrape missed background edges) | Hooks live in player engine; fire regardless of UI state |
onTrackDetected natural-end-via-extrapolated-position heuristic | PlaybackStateBridge.onCompletion latches real engine EOS |
| Cross-process Battery + PApp observation via Intent bridges | BatteryReceiver + PappStateBroadcaster run in-process |
MediaMetadataRetriever re-extracting tags Y1 already had | Music app reads tags from its Song entity directly |
All visibility-critical logic lives in the music app's process. Y1MediaBridge is purely a "JarVerifier bypass" — a self-signed manifest carrier for the intent-filter MtkBt expects.
Stretch goals (still future work)
- Drop the gradle dep: shrink-source is small enough to build with just
javac+d8+ a minimal aapt2 substitute (or commit a prebuilt APK). Not addressed here because the user's flash machine already has gradle set up. - Truly retire
Y1MediaBridge.apk: requiresMtkBt.odexsmali surgery to component-bind intocom.innioasis.y1/.service.PlayerServicedirectly (option (c) from Trace #23).patch_mtkbt_odex.pywould need a smali-reassembly path; currently it only supports same-size byte substitutions.
Trace #25 (2026-05-11) — Phase 1+2 broadcast-wake regression: PlaybackStateBridge now fires metachanged + playstatechanged
Symptom
Multi-CT capture session 2026-05-11 afternoon (dual-bolt-20260511-1339, dual-kia-20260511-1336) surfaced three user-visible regressions vs the pre-Phase-2 Y1MediaBridge.apk-driven behavior:
- Bolt: zero metadata rendered. Only 1 ×
msg=540(GetElementAttributes response) in 4 min, vs Sonos's 190 × in 3 min during Phase 3 v3 verification. Bolt's CT subscribes toEVENT_TRACK_CHANGEDand waits for CHANGED notifications before issuingGetElementAttributes— without CHANGED firing on track edges, the CT never queries. - Kia: time-playhead lags real device playhead by ~seconds; timestamps appear only after a pause. Kia polls
GetPlayStatus(75 × in 3 min) so T6'sclock_gettime(BOOTTIME)extrapolation returns live position correctly, but the rendering side appears to anchor onEVENT_PLAYBACK_POS_CHANGEDCHANGED notifications which were only firing every 10 s (BatteryReceiver tick), not the documented 1 s cadence. - Bolt: Repeat toggle from the CT updates
SharedPreferencesbut the music-app UI doesn't refresh until the user backs out and returns; shuffle state appears stuck "on" on the CT regardless of Y1 state.
Root cause
The Y1 music app's PlayerService does NOT fire the standard com.android.music.metachanged / playstatechanged broadcasts at play-state or track edges. It uses its own internal android.intent.action.MY_PLAY_SONG instead. Pre-Phase-2 Y1MediaBridge.apk had a logcat-scraping LogcatMonitor + a 1 s Handler loop that synthesised these broadcasts whenever it observed state changes. The Phase 3 v2 shrink removed both.
Verified by grep: inject/com/koensayr/y1/playback/PlaybackStateBridge.smali had no sendBroadcast call before this fix. Only BatteryReceiver.smali and PappStateBroadcaster.smali were firing playstatechanged, and nothing was firing metachanged.
MtkBt.odex's cardinality-NOP-patched BTAvrcpMusicAdapter.handleKeyMessage (patch_mtkbt_odex.py sswitch_1a3/sswitch_18a) is what wakes notificationTrackChangedNative / notificationPlayStatusChangedNative (and thus T5 / T9). Without the broadcasts being emitted, the wake never fires.
Fix
Added two helper methods to TrackInfoWriter:
wakeTrackChanged()V— firescom.android.music.metachangedvia the stored Application Context.wakePlayStateChanged()V— firescom.android.music.playstatechangedvia the same.
Modified PlaybackStateBridge:
onPlayValue— aftersetPlayStatus(B)flushes the new state byte synchronously, callswakePlayStateChanged(). Drives T9 → PLAYBACK_STATUS / PLAYBACK_POS CHANGED on real state edges (play→pause, pause→play, etc.).onPrepared— afteronTrackEdgeflushes the new track to disk, callswakeTrackChanged()+wakePlayStateChanged(). The metachanged wake drives T5 → TRACK_CHANGED / REACHED_END (gated) / REACHED_START. The playstatechanged wake drives T9 → PLAYBACK_POS CHANGED for the position reset to 0.
onCompletion / onError unchanged — the next onPrepared will fire the broadcasts.
Method-count budget
classes.dex post-Patch-B5 was 65330/65536. Two new methods (wakeTrackChanged + wakePlayStateChanged on TrackInfoWriter) take it to 65332. Three new method-ref uses inside PlaybackStateBridge reference these same defined methods, so no additional method refs. ~204 slots remain (still under cap; cap-check passes via apktool reassembly succeeding).
Verification (pending hardware)
Patcher smoke-test: output/com.innioasis.y1_3.0.2-patched.apk reassembles cleanly. META-INF + AndroidManifest.xml byte-identical to stock (md5 match) — JarVerifier won't reject. New smali md5s:
TrackInfoWriter.smali→35496cf01171fa9c5293813a45553cc0(was1f6a3f44dd4ac4f3edf7c08caf76eba9pre-fix)PlaybackStateBridge.smali→69d50e5835b23cbf6e546298a7130f06(was0d8e4ed14b4dbe5683e8716b30dba76bpre-fix)
Expected behavior on hardware:
- Bolt: TRACK_CHANGED CHANGED should fire on every
onPrepared; Bolt CT should queryGetElementAttributes→ metadata renders. - Kia: PLAYBACK_STATUS / POS CHANGED should fire on every play / pause / track edge, not just the 10 s battery tick. Playhead lag should reduce (full 1 s position cadence is a separate follow-up — that requires a
Handler.postDelayedtick loop while playing; not in this fix).
Out of scope for this fix
- 1 s position-tick loop (Kia's residual playhead drift between actual edges). Separate change to PlaybackStateBridge — a Handler scheduled from
onPlayValue(PLAYING)and cancelled atSTOPPED/PAUSED, callingwakePlayStateChangedon tick. - Shuffle "always on" — likely initial-state issue:
y1-track-info[795..796]is zero-filled at creation and0x00is not a valid AVRCP §5.2.4 Tbl 5.20 / 5.21 value. Need to initializeTrackInfoWriterdefaults to0x01 OFF/0x01 OFFbefore any read. Separate change. - Music-app Settings UI refresh on CT-driven Repeat/Shuffle change. Music-app side observer, not a wire-level issue.
Trace #26 (2026-05-11) — Multi-CT verification of Trace #25 + 1 s position tick + cold-boot file-flush
What the multi-CT capture showed (post-Trace #25)
Captures: dual-bolt-20260511-1422, dual-kia-20260511-1417, dual-tv-20260511-1532. Hardware confirmation that the Trace #25 broadcast-wake fix landed:
| CT | metachanged broadcasts | playstatechanged broadcasts | msg=540 (GetElementAttributes resp) | Visible result |
|---|---|---|---|---|
| Bolt | 1 | 55 | 1 | Metadata bytes delivered on the wire (full 7 attributes — Title strlen=10, Artist=14, Album=7, TrackNum=1, Total=2, Genre=16, PlayingTime=6, msg=540 size=644). UI does not render — CT-side issue, separate investigation. Other passthrough functions partial: PLAY 0x44 routes correctly, but PAUSE 0x46 toggles only the Bolt UI icon without actually pausing music. |
| Kia | 2 | 31 | Many (no count needed) | Metadata works. Playhead absent on PLAY edge, appears after PAUSE. Visible playhead lags real device playhead by ~1 s. Next-track skip works, but playhead disappears on the new track until next pause. |
| TV | 7 | 42 | Many | Metadata works. Next no longer fast-forwards (Patch E/H + Patch H″ working as designed). Play/pause occasionally still gets stuck (rare, much improved). One observed state desync: Y1 showed "stop" while TV showed "paused" (~2 occurrences). |
| Sonos (prior capture) | n/a | n/a | 190+ | Play/pause/next/prev work. Repeat/shuffle UI elements grayed out — likely Sonos doesn't render PApp Settings over BT for this CT class. |
The Trace #25 wake fix unblocked metadata flow on all CTs that proactively poll GetElementAttributes (Kia, TV, Sonos). Bolt still has no UI metadata despite receiving the full response payload — that's a CT-side rendering issue, not a wire-shape issue.
Fix shipped in this trace
Two compounding follow-ups for the Kia playhead lag + the shuffle initial-state issue:
1. 1 s position-tick loop. New class com.koensayr.y1.playback.PositionTicker (Runnable + lazy main-thread Handler):
PositionTicker.start()—Handler.removeCallbacks(INSTANCE)+postDelayed(INSTANCE, 1000). Idempotent.PositionTicker.stop()—Handler.removeCallbacks(INSTANCE).PositionTicker.run()— callsTrackInfoWriter.wakePlayStateChanged()thenHandler.postDelayed(this, 1000).
PlaybackStateBridge.onPlayValue now calls PositionTicker.start() on the PLAYING edge (mapped state byte == 0x01) and PositionTicker.stop() on STOPPED / PAUSED. The wake fires com.android.music.playstatechanged → T9 → AVRCP 1.3 §5.4.2 Tbl 5.33 PLAYBACK_POS_CHANGED CHANGED with clock_gettime(CLOCK_BOOTTIME)-extrapolated position.
Expected effect on hardware:
- Kia: playhead appears immediately on first PLAY edge (T9 fires CHANGED within 1 s of
Static.setPlayValue(1, _)) and stays current within ~1 s of real device playhead. - TV / Sonos: extra wakes are no-ops for CTs that don't subscribe to event 0x05; small overhead (~one broadcast/sec while playing).
- Bolt: unchanged at the wire metadata layer; downstream of the rendering investigation.
2. Cold-boot y1-track-info flush. TrackInfoWriter.init(Context) now calls flushLocked() immediately after prepareFilesLocked(). The file lands on disk with the in-memory defaults (mRepeatAvrcp=0x01 OFF, mShuffleAvrcp=0x01 OFF — valid AVRCP §5.2.4 Tbl 5.20 / 5.21 values) before any CT can read it.
Pre-fix sequence:
- Y1Application.onCreate → TrackInfoWriter.init → prepareFilesLocked creates
y1-trampoline-state+y1-papp-set, but NOTy1-track-info. - CT subscribes to PApp CHANGED before B4's
PappStateBroadcaster.sendNow()fires → T8 INTERIM readsy1-track-info[795..796], file doesn't exist, trampoline buffer stays zero-filled, MtkBt sends[0, 0]→ invalid AVRCP enum. - CT latches onto invalid initial state; some CTs (observed: Bolt) refuse to follow subsequent CHANGED events from that point.
Post-fix: file always exists with [0x01, 0x01] at boot.
Method-count budget
classes.dex now 65337/65536 method refs (199 slots free). +7 over the pre-Trace-#25 baseline of 65330. Inside 64K cap.
Out of scope
- Bolt no UI metadata. Wire-level metadata response is correct (full 7 attributes, valid UTF-8, valid SongPosition). Investigation needs btlog.bin parse of the AVRCP frames Bolt sends back, to figure out what specific event/PDU Bolt is waiting for before rendering. Possibilities: missing AVRCP 1.4 ABSOLUTE_VOLUME response, missing PLAYBACK_STATUS_CHANGED INTERIM with status=PLAYING, AVCTP fragmentation parsing on the CT side.
- Bolt PAUSE not actually pausing. PASSTHROUGH 0x46 receipt confirmed at the MMI_AVRCP layer; the downstream kernel input → AVRCP.kl → KeyEvent → BaseActivity (Patch H) → PlayControllerReceiver (Patch E) path needs to be traced. Bolt's icon toggling without actual pause action suggests a dispatch hole somewhere in the foreground-activity propagation path.
- Music-app Settings UI refresh on CT-driven Repeat/Shuffle. Music-app side observer, not a wire-level issue. Fix needs SharedPreferences listener in the Settings activity or an explicit refresh Intent from PappSetFileObserver.
Trace #27 (2026-05-13) — AVRCP §6.7.1 per-subscription gate completes the 1.3 pipeline; Bolt's metadata pane remains 1.4-CoverArt-blocked
Background
Trace #26's hardware verification showed Kia's playhead "appeared, played for 2 seconds, then froze." The two-second window is the smoking gun: the wire was emitting PLAYBACK_POS_CHANGED CHANGED continuously at 1 s cadence via PositionTicker, but Kia stopped updating its display after the second frame. Bolt similarly showed its play/pause icon flipping correctly on the first state-edge, then sticking on "Play" forever — and its Shuffle button enabling shuffle on Y1 but never registering as enabled in Bolt's own UI.
Both symptoms point at the same spec gap: AVRCP 1.3 §6.7.1 says "Once a Controller has registered to receive a particular EventID, the Target shall notify the CT of the change to the registered EventID only once." After CHANGED, the subscription is consumed; the CT must re-register to receive another. We were emitting CHANGED unconditionally — on every PositionTicker tick (for event 0x05) and on every actual state edge (for events 0x01/0x02/0x06/0x08).
Cadence comparison from dual-kia-20260513-1144 vs the older Sonos working capture (dual-sonos-20260511-1042):
| CT | size:13 (RegNotify event 0x05) cadence |
|---|---|
| Sonos | ~10 ms between frames, 20 frames in 280 ms after connect, continuously re-registering |
| Kia | 6 in 80 ms at connect, then NONE for 15+ seconds |
Sonos's aggressive re-registration matches the strict §6.7.1 contract — it gets a steady stream of valid CHANGEDs. Kia subscribes once and trusts the TG to keep sending. Our excess CHANGEDs after the first were silently rejected by Kia.
Fix — full §6.7.1 compliance via per-subscription gates
y1-trampoline-state grew from 16 B → 20 B. Bytes 13..19 each hold one subscription byte:
| Byte | Event | Arm site | Clear site |
|---|---|---|---|
| 13 | 0x05 PLAYBACK_POS_CHANGED | T8 INTERIM | T9 CHANGED |
| 14 | 0x01 PLAYBACK_STATUS_CHANGED | T8 INTERIM | T9 CHANGED |
| 15 | 0x08 PLAYER_APPLICATION_SETTING_CHANGED | T8 INTERIM | T9 CHANGED |
| 16 | 0x02 TRACK_CHANGED | T2 INTERIM | T5 CHANGED |
| 17 | 0x03 TRACK_REACHED_END | T8 INTERIM | T5 CHANGED |
| 18 | 0x04 TRACK_REACHED_START | T8 INTERIM | T5 CHANGED |
| 19 | 0x06 BATT_STATUS_CHANGED | T8 INTERIM | T9 CHANGED |
INTERIM emit sites write 0x01 to their byte via _emit_subscription_write helper (1-byte strb_w + open + lseek + write + close). CHANGED emit sites read the byte; if 0, skip emit; if 1, emit + clear. Edge-detection writes (state[9..12]) remain unconditional so we don't loop "edge detected, can't emit" forever while un-subscribed.
Schema migration: existing 16-B files on already-flashed devices grow naturally — T2/T8's lseek(N >= 16) + write(1) extends the file. Until then, new gate-bytes read as 0 = "not subscribed" via T5/T9's memset-then-read pattern. No manual remediation needed.
Stack-frame growth: T5_FRAME 816 → 820, T9_FRAME 832 → 836. T5_OFF_FILE 16 → 20, T9_OFF_FILE 24 → 28 (timespec offset shifts accordingly). T4 state write switched from O_WRONLY|O_TRUNC to O_WRONLY so it doesn't clobber bytes 16..19 that T2/T8 may have written. Some short-form branches in T5 needed promotion to wide-form (blt_w / beq_w) — extended body exceeded the 254-B short range.
OUTPUT_MD5 of libextavrcp_jni.so is now c017b6ab5d66ccbd851c9399e0642262.
Other fixes shipped same session
| Commit | What |
|---|---|
1381d57 | Y1Bridge.MediaBridgeService.AvrcpBinder reads y1-track-info for synchronous IBTAvrcpMusic queries (codes 17/19/24/25/26/27/28/29/30/31). MtkBt's Java mirror now reflects real state instead of empty/default. |
7833cf0 | getAlbumId synthesizes a stable handle from album-name hash (CTs that group by album_id no longer conflate all tracks). setPlayerApplicationSettingValue (code 4) backstops to y1-papp-set so the apply path works whether T_papp 0x14 or the Java setter is the trigger. |
d535c7e | AOSP-convention Intent extras (id, track, artist, album, playing) on wakeTrackChanged / wakePlayStateChanged broadcasts. MtkBt's MMI_AVRCP logs flipped from playing:false id:-1 to real values, unblocking the cardinality-NOP wake path. |
dbdf5d0 | TrackInfoWriter.mLastKnownDuration preserves duration across prepare gaps (CTs no longer see song_length=0 and hide the playhead). markCompletion freezes the anchor at mLastKnownDuration so post-EOS T9 emissions read position == duration (not position > duration which strict CTs reject). PlaybackStateBridge.onCompletion stops PositionTicker and fires one final wake. |
56ab3b7 | PlayerService.setCurrentPosition(J) (the music app's single seek funnel) prepended with PlaybackStateBridge.onSeek(J) → TrackInfoWriter.onSeek(J) refreshes the anchor + fires wakePlayStateChanged. Seek bar now propagates to CT immediately. |
44d376c | Patch E :cond_play_strict — PASSTHROUGH PLAY (0x44) while isPlaying() is true now routes to PlayerService.playOrPause() (effectively pause-toggle). Spec-compliant CTs never send PLAY while playing so this only fires for non-spec CTs (Bolt) that map their Pause button to AVRCP PLAY. |
1947dd8 | MusicPlayerActivity.refreshRepeatShuffleUi() injected — re-renders just the Repeat/Shuffle ImageView icons from current SharedPreferences. NowPlayingRefresher.run() calls it (previously called refreshUI() which only updates track-name text labels and doesn't touch the icons). CT-driven Repeat/Shuffle changes now paint live on the Now Playing screen. |
Hardware results (2026-05-13 captures)
Kia EV6 (dual-kia-20260513-1351): play/pause toggles work, playhead updates continuously, Repeat/Shuffle UI flips in real-time on the Y1 Now Playing screen, metadata pane renders. All previously-reported Kia issues resolved.
Bolt EV (dual-bolt-20260513-1355):
- Audio actually pauses on Pause button press (was broken pre-
44d376c). ✓ - Forward / Previous PASSTHROUGH actions work. ✓
- Metadata pane stays empty. Root cause confirmed: Bolt's
GetElementAttributesrequest (wiresize:45) asks for 8 attributes including attr 8 (Default Cover Art handle, AVRCP 1.6 §5.14.1; attribute id assigned in §26 Table 26.1 per ESR09 E6073). We return 7. Bolt gates pane render on receiving a non-empty CoverArt entry. Note: Default Cover Art is an AVRCP 1.6 feature (Dec 2015), NOT 1.4. AVRCP 1.4 added browsing + AbsoluteVolume; 1.5 added AddressedPlayer / AvailablePlayers; 1.6 added Default Cover Art via attribute 8. - Play/Pause icon stuck after first toggle. Root cause: Bolt subscribes for event 0x01 once at connect and never re-registers. Our gate emits exactly one CHANGED per registration; Bolt only ever sees one. UI mirror frozen at first-CHANGED state.
- Shuffle stuck on. Same root cause — Bolt subscribes for event 0x08 once.
Bolt's failure to re-register after CHANGED is a CT-side spec violation we cannot work around without violating §6.7.1 on the TG side (which would re-break Kia). The empty metadata pane is the actionable symptom — implementing AVRCP 1.6 Default Cover Art unblocks it.
Scope change: AVRCP 1.6 Default Cover Art newly in-scope
User directive 2026-05-13 after seeing the §6.7.1 fixes work end-to-end on Kia: "I do not accept the Bolt's behavior as-is. I think the cover art thing might be worth a look."
Project policy amended: feedback_avrcp13_only_scope.md now lists two carve-outs:
- MtkBt.odex F1 BlueAngel internal-flag spoof (existing).
- AVRCP 1.6 Default Cover Art — new. Specifically: GetElementAttributes attribute id 8 (AVRCP 1.6 §5.14.1; ID assigned in §26 Table 26.1 per ESR09 E6073), the AVRCP Cover Art OBEX channel (§5.14.2.1 Target Header UUID
7163DD54-4A7E-11E2-B47C-0050C2490048) on a dynamically-assigned L2CAP PSM advertised via the AVRCP TG SDP record's Additional Protocol Descriptor List (Table 8.2), and the BIP Image Pull functions GetImageProperties / GetImage / GetLinkedThumbnail (§5.14.2.2). The generic BIP Imaging Responder SDP record (0x111B) is not used — §13 forbids publishing it when BIP is used solely for Cover Art.
Other 1.4+ / 1.5+ / 1.6+ features (SetAbsoluteVolume, browse channel for player switching, SetAddressedPlayer, NOW_PLAYING_CONTENT_CHANGED, etc.) remain out of scope.
What needs investigation before implementing Default Cover Art
- AVRCP TG SDP record changes — Table 8.2 specifies: AVRCP profile version
0x0106(currently 0x0103);SupportedFeatures(attr 0x0311) bit 8 = Supports Cover Art (currently 0x0001, bit 8 unset); Additional Protocol Descriptor List with a Cover Art L2CAP PSM + OBEX entry. Need to extendpatch_mtkbt.pyV-family to inject these. - Where does the music app store/access cover art for local display? Likely
MediaMetadataRetriever.getEmbeddedPicture()or similar. - Wire format for attribute 8: per AVRCP 1.6 §5.14.1 + §29.23 example MSC, the value is a BIP Image Handle (BIP §4.4.4 format; the example shows a 7-character ASCII identifier such as "1000004").
- AVRCP Cover Art OBEX responder implementation — must listen on the dynamically-assigned PSM, accept OBEX connections with Target Header UUID
7163DD54-4A7E-11E2-B47C-0050C2490048(§5.14.2.1), and serveGetImageProperties/GetImage/GetLinkedThumbnail. mtkbt ships a generic BIP responder inlibextbip.sothat could potentially be reused; whether it matches the Cover-Art-specific Target Header UUID needs verification. - Imaging Thumbnail format per §5.14.2.2.1: 200×200 pixels, JPEG baseline-compliant, sRGB default colour space, YCC422 sampling, one marker segment per DHT/DQT, typical Huffman table, DCF thumbnail file format. (Spec specifies pixel size + encoding; no byte-size limit.)
Implementation plan sketch in memory project_y1_cover_art_direction.md.
Trace #28 (2026-05-13) — AVRCP 1.6 Default Cover Art recon + T4 attr 8 emit landed
Investigation answers
Q1 (mtkbt BIP server) — answered YES, partial. Native side fully present, Java side stripped:
/work/v3.0.2/system.img.extracted/system/lib/libextbip.so(63 108 B) — full BIP responder + initiator state machine. Source path baked in:mediatek/protect/external/bluetooth/blueangel/btadp_ext/profiles/bip/. Exportsbip_responder_enable / disable / disconnect / authorize_response / getcapability_response / access_response / auth_response / obj_renameplusbtmtk_bipr_*helpers covering get capabilities / images list / image properties / image / linked thumbnail / put image / put thumbnail / continue / abort / connect./work/v3.0.2/system.img.extracted/system/lib/libextbip_jni.so(21 900 B) — JNI bridge.JNI_OnLoadcallsFindClass com/mediatek/bluetooth/bip/BluetoothBipServerandRegisterNativesfor 21 methods (full signature table at memoryarchitecture_y1_bip_jni_shape.md).classInitNativecaches one field (mNativeData:I) + one method (onCallback:(III[Ljava/lang/String;)V). Value-class field IDs are looked up lazily inside the methods that use them; partial shapes recovered (e.g.ImageFormat: Encoding / Width / Height / Width2 / Height2 / Size / Transform;ImageDescriptor: DirName / FileName / Version / ThumbnailFullPath / ObjectSize;AuthInfo: bAuth / UserId / Passwd)./work/v3.0.2/system.img.extracted/system/framework/javax.obex.jar— OBEX Java APIs available.- No Dalvik classes under
com/mediatek/bluetooth/bip/*anywhere in the system image. Scanned every.dex+.odex+ APKclasses.dex.MtkBt.odexreferencescom.mediatek.bluetooth.bip.BipServiceonly as a string passed tostartService(...)— that service doesn't exist on this build, so the start silently no-ops.
The OEM stripped the Java BIP service layer (likely to slim the firmware — Y1 has no native UI for OPP/FTP image push). Native libs ship as-built; nothing currently calls into them.
Q2 (SDP record) — recon initially focused on the wrong artifact. Complete generic BIP Imaging Responder SDP record is baked into mtkbt at file offset 0xf9df0 (record body, 10 attribute entries of 12 B each) + value blobs at .rodata 0xebc97..0xebd40 — ServiceClassIDList (0x111B), ProtocolDescriptorList (L2CAP / RFCOMM / OBEX), BrowseGroupList (PublicBrowseRoot), LanguageBaseAttributeIDList, BluetoothProfileDescriptorList (0x111A v1.0), ServiceName ("Imaging"), SupportedCapabilities, SupportedFeatures, SupportedFunctions, TotalImagingDataCapacity (0x50000000 ≈ 1.34 GB). Live sdptool browse (capture logs/y1-sdptool-20260513-1437.log) confirms it is not advertised: server returns 5 records (A2DP / AVRCP TG / PBAP PSE / NAP / OBEX Object Push); record handle slots 0x10001 and 0x10006 are absent.
Reading the spec afterward reveals this record is the wrong target anyway. AVRCP 1.6 §13 explicitly forbids publishing the generic BIP SDP record when BIP is used solely for AVRCP Cover Art:
"When BIP functionality is used solely for AVRCP Cover Art the BIP SDP record described in [BIP] shall not be published. The Cover Art feature never affects the format or values of the BIP SDP record described in [BIP]. L2CAP channels implementing BIP functionality for AVRCP Cover Art shall be distinct from L2CAP channels implementing BIP functionality in conformance to [BIP]." — AVRCP 1.6 §13
The correct AVRCP-Cover-Art SDP signaling lives in the AVRCP TG service record (§8 Table 8.2):
- AVRCP profile version 0x0106 (currently Y1 advertises 0x0103).
SupportedFeatures(attr0x0311) bit 8 = Supports Cover Art (currently Y1 advertises0x0001, bit 8 unset).- Additional Protocol Descriptor List with an entry for
L2CAP, PSM=<dynamically assigned Cover Art PSM>, OBEX.
And the OBEX channel uses Target Header UUID 7163DD54-4A7E-11E2-B47C-0050C2490048 (§5.14.2.1) on an L2CAP PSM distinct from any generic BIP channel — the generic libextbip.so BIP responder accepts the BIP target header (E33D9545-8374-4AD7-9EC5-C16BE31EDE8E), not the Cover Art one, so even if we could activate it, it would not match the AVRCP Cover Art client's connect.
Net effect: the whole "mtkbt has a baked-in BIP record, just call biprEnableNative to advertise it" framing in earlier sections of this trace is misdirected. The 0x111B record is the wrong record. The actual SDP changes belong in patch_mtkbt.py against the AVRCP TG record (already mutated by V1/V6/V7/S1) — specifically: bump the AVRCP-profile-version bytes, flip the SupportedFeatures attribute, append the Additional Protocol Descriptor List entry.
Q3 (music-app cover art source) — not yet investigated; deferred to the image-wiring chunk.
Q4 (wire format for attr 8) — confirmed AVRCP 1.6 §5.14.1 + §29.23 example MSC. The value is a BIP Image Handle (BIP §4.4.4 format). The §29.23 MSC shows AttributeValueLength=7; AttributeValue='1000004' — a 7-character ASCII identifier. (Earlier notes claimed "hex" — spec doesn't constrain the encoding to hex; BIP defines the format.)
Q5 (image constraints) — AVRCP 1.6 §5.14.2.2.1 Imaging Thumbnail: 200×200 pixels, JPEG baseline-compliant, sRGB default colour space, YCC422 sampling, one marker segment per DHT/DQT, typical Huffman table, DCF thumbnail file format. (Spec specifies pixel size + encoding; no byte-size limit. Earlier notes citing a 200 KB cap were fabricated.)
T4 attr 8 emit shipped (_trampolines.py)
T4 attr_table grew from 7 entries to 8 with ("cover_handle", 0x08, T4_OFF_FILE_COVER_HANDLE). y1-track-info schema grew 1104 → 1112 B (new tail [1104..1110] for the 7-character ASCII BIP Image Handle + NUL terminator at 1111). T4's add_sp_imm calls in the attr-emit loop gained a per-offset fallback to the wider addw rd, sp, #imm12 encoding so the new cover_handle slot at SP+1136 emits cleanly — add_sp_imm only reaches 1020 via its imm8<<2.
Until the AVRCP Cover Art OBEX responder lands and TrackInfoWriter starts writing per-track handles into y1-track-info[1104..1110], T4 emits attr 8 with an empty value (file is 1104 B on disk; T4's read(fd, buf, 1112) short-returns at 1104, the new tail bytes stay memset-zeroed; strlen of zeroed bytes returns 0). Per AVRCP §5.3.4 a 0-length attribute is the canonical "not available" signal — spec-compliant graceful degradation. The wire frame now contains the expected 8th attribute slot; strict-CT metadata-pane render still gates on a non-empty handle, so panes remain empty today but the protocol surface is in place.
OUTPUT_MD5 of libextavrcp_jni.so is now 4da9283b85954648521efd0d11524192. File size unchanged at 50 992 B (T4 grew ~32 B from the new attr iteration + 4 widened add_sp_imm → addw encodings; still fits inside the LOAD #1 padding code-cave).
What remains for the metadata pane to render
Per AVRCP 1.6 §5.14 + §8 + §13 + §29.23 the actual blockers are:
- AVRCP TG SDP-record patches (
patch_mtkbt.pyV-family extensions). Three changes to the served AVRCP TG record (handle0x10003in current capture):- AVRCP profile version bytes:
0x0103 → 0x0106(Table 8.2 requires 1.6 advertisement for Cover Art support). SupportedFeaturesattribute (0x0311): set bit 8 (Supports Cover Art). Currently0x0001→ must include0x0100.- Additional Protocol Descriptor List: add an entry
L2CAP (PSM=<dynamic Cover Art PSM>), OBEX.
- AVRCP profile version bytes:
- AVRCP Cover Art OBEX responder listening on the dynamic PSM and accepting OBEX connections whose Target Header carries the
7163DD54-4A7E-11E2-B47C-0050C2490048UUID (§5.14.2.1). Must serveGetImageProperties/GetImage/GetLinkedThumbnail. mtkbt's stocklibextbip.soBIP responder accepts the generic BIP target UUID (E33D9545-8374-4AD7-9EC5-C16BE31EDE8E), so it likely will not match the Cover Art target out of the box — needs verification, and if the mismatch is confirmed we either patch the target-UUID check or write a small OBEX responder of our own (javax.obex on the Y1's classpath would make this tractable). - Music-app image source:
MediaMetadataRetriever.getEmbeddedPicture()(or equivalent) to extract album art, JPEG-200 re-encode to the Imaging Thumbnail constraints (§5.14.2.2.1), and write to a known file path the responder reads. TrackInfoWriterschema bump: grow on-disk write from 1104 B to 1112 B, populate[1104..1110]with a 7-character BIP Image Handle per track (e.g. a 7-char encoding of audio_id low bits). Independent of (1)-(3) but only useful once those land.
Trace #29 (2026-05-13) — Cover Art dropped; real blocker was a §5.3.4 spec deviation in libextavrcp.so
What we set out to verify (Phase 2 of the "what's blocking Bolt's pane" investigation)
After flashing T4 attr 8 emit (commit 3f99028) we inspected post-flash Bolt + Kia captures (dual-bolt-20260513-1514, dual-kia-20260513-1515):
EXTADP_AVRCP log on every GetElementAttributes response:
AVRCP send_get_element_attributes_rsp raw i:7 total:8 attid:8 strlen:0
AVRCP send_get_element_attributes ignore empty attrib attri_id:8 strlen:0
T4 was correctly emitting all 8 attribute slots, but the stock libextavrcp.so response builder was hitting an "ignore empty attrib" branch and dropping every zero-length attribute from the wire frame. This is a deviation from AVRCP 1.3 §5.3.4 which requires "for attributes not supported by the TG, this field shall be sent with 0 length data."
The drop is uniform (not attr-8-specific): in the no-music capture, attrs 1/2/3/5/6/7 also have strlen:0 and are also dropped. Only attr 4 (TrackNumber "1") survives. The g_offset tracer in EXTADP_AVRCP confirms: g_offset only advances for non-empty attrs.
What does Bolt actually request?
Parsed raw btlog binary for inbound GetElementAttributes commands (signature 00 19 58 20 00 = SIG CompanyID + PDU 0x20 + PT 0):
Two request patterns observed from both Bolt-class AND Kia-class CTs:
| Pattern | NumAttr | Attribute IDs | Likely surface |
|---|---|---|---|
| A | 6 | [0x1, 0x2, 0x3, 0x6, 0x8, 0x7] | Metadata pane (Title / Artist / Album / Genre / CoverArt / PlayingTime) |
| B | 7 | [0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7] | Other surface (Now Playing widget?) |
Both CTs use both patterns. The "Bolt requests 8 attributes" claim in prior traces was based on the EXTADP_AVRCP total:8 log, which is what Y1 unilaterally emits — not what either CT asked for. Both CTs ask for the same attribute set.
So why does Kia render but Bolt not?
Both receive the same wire frame (whatever non-empty attrs Y1 happens to have). Kia is lenient: picks out what it recognizes from the response. Bolt is strict: the §5.3.4 spec says the TG sends exactly the requested attributes in the requested order, including zero-length entries for unsupported ones, and Bolt's parser gates on receiving that exact shape. When Bolt requested Pattern A [1,2,3,6,8,7] it expected to see all six slots back, even if some came with length 0. Y1 was returning [1,2,3,?,?,4,5,6,7] — extras (4, 5) it didn't ask for, missing (8) it did.
Charset ruled out (other ruled-out hypotheses for record)
- Fragmentation:
libextavrcp.soallocates a 644-byte response buffer regardless of content;g_offsettracking shows real payload is ~58 bytes. No AVRCP-level Packet Type fragmentation; whole frame goes out as a single Packet Type=00 (Complete) AVRCP frame inside one L2CAP packet of size 672 (well under the 672-byte default L2CAP MTU). Both CTs receive identically shaped frames. - Charset: parsed Bolt's
InformDisplayableCharacterSetPDU 0x17 from raw btlog (signature00 19 58 17). Bolt advertisedCount=1, CharsetID[0]=0x006A(UTF-8 only). Y1 emitscharset=0x006Ain every attribute. Match — not the problem. LanguageBaseAttributeIDListin SDP record: AVRCP TG SDP record doesn't carry attribute0x0006(PBAP's does). Not authoritative for charset selection — PDU 0x17 already settled the question — but worth noting as a stylistic gap.
Fix: patch_libextavrcp.py E1
Single 2-byte CBZ→NOP at file offset 0x00002266 inside btmtk_avrcp_send_get_element_attributes_rsp. Disables the (attr_id == 0) OR (strlen == 0) gate so attributes always emit, including zero-length ones. AVRCP §5.3.4 compliance restored. Stock MD5 6442b137d3074e5ac9a654de83a4941a → Output MD5 1347e1b337879840ad2f66597836b05f. New patcher wired into apply.bash --avrcp.
Scope decision
User directive 2026-05-13 after spec analysis: "I really don't need to implement DCA as it's outside of the 1.3 spec. I really just want to ensure that all endpoints work as expected."
The AVRCP 1.6 Cover Art carve-out is rolled back — no BIP responder, no OBEX channel, no SDP record changes per §8 Table 8.2, no MediaMetadataRetriever image source. Attr 0x08 emits as a §5.3.4-compliant zero-length entry forever. feedback_avrcp13_only_scope.md updated: AVRCP 1.6 §5.14.1 + §26 Table 26.1 / ESR09 E6073 may still be cited when explaining what attr 0x08 is, but no implementation work is in scope.
Remaining strict-CT issues (CT-side, not fixable on TG)
- Strict-CT pause-icon stuck after first toggle: CT doesn't re-register after our CHANGED, §6.7.1 prevents subsequent CHANGED without re-registration. CT-side §6.7.1 violation.
- Strict-CT Shuffle stuck on: same root cause.
Trace #30 (2026-05-13) — Phase 1 request-shape compliance; drop all AVRCP 1.6 framing
Diagnosis from post-E1 captures
Bolt's metadata pane still empty after patch_libextavrcp.py E1 landed. The post-flash capture (dual-bolt-20260513-1556) shows:
EXTADP_AVRCP send_get_element_attributes strlen:0 offset:57 g_offset:58— attr 8 zero-length entry now reaches the wire (E1 working as designed).- But the inbound parser shows Bolt's actual request was
NumAttr=7, AttrIDs=[0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7](canonical 1.3 set, no attr 0x08), and Y1 was sending a frame with 8 attributes (1..7 plus 0x08 zero-length).
So Y1's response violates AVRCP 1.3 §6.6.1 Table 6.26:
"If NumAttributes is set to zero, all attribute information shall be returned, else attribute information for the specified attribute IDs shall be returned by the TG."
We were emitting more (and in a different order) than Bolt asked for. Lenient CTs tolerate the extras; strict-CT parsers reject the entire frame.
Phase 1 implementation
T4 in _trampolines.py rewritten to:
- Read
NumAttributes(1 byte at caller's sp+394, post-SUB-SP at sp+1530). - If
N == 0: fall back to the compile-time-unrolled "emit all 7" loop (per §6.6.1 zero means all). - Otherwise: loop
i = 0..N-1, read eachAttributeID[i](4 byte BE u32 at caller's sp+395+4i), byte-reverse, dispatch:attr_id ∈ {0x01..0x07}: look upy1-track-infooffset via the inlinet4_attr_offset_tabledata block + emit withstrlenvalue.- Else (
attr_id == 0or> 7): emit withAttributeValueLength=0per §5.3.4.
The lookup table is appended at the trampoline blob's tail (8 u32 words = 32 B). Register conventions: r5=JNI base, r6=i, r7=N, r9=saved attr_id across calls, r10=saved str_offset across calls, r0..r4 scratch.
New asm primitives added in _thumb2asm.py: ldr_w (32-bit imm word load, T3), add_reg (ADD register T2, supports any reg incl. SP), lsls_imm5 (LSL imm T1), and bhi/bls/bcs/bcc/bhs/blo + bhi_w/bhs_w branch helpers.
Schema rollback
y1-track-info returns to 1104 B (was bumped to 1112 in the previous trace to make room for the cover-art handle slot at [1104..1110]). The slot is gone; Y1 emits attr 0x08 (and any other unsupported ID) with AttributeValueLength=0 regardless of whether y1-track-info carries any handle data.
OUTPUT_MD5 of libextavrcp_jni.so is now 3454ffe3c28f609d07852435433cf3a8. File size unchanged at 50,992 B — Phase 1 + the new data table still fit within the LOAD #1 padding extension.
AVRCP 1.6 framing stripped
All AVRCP 1.6 implementation references removed from active docs (docs/PATCHES.md, docs/BT-COMPLIANCE.md, docs/ARCHITECTURE.md, CHANGELOG.md, src/patches/_trampolines.py, src/patches/patch_libextavrcp.py). The §5.14.1 / §26 Table 26.1 / ESR09 E6073 citations were originally kept under a "narrow citation exception" — they're now gone entirely because attr 0x08 no longer requires special-case explanation: it's just "any attribute outside 0x01-0x07, handled by the general unsupported-attribute path".
Memory feedback_avrcp13_only_scope.md tightened: no more narrow citation exception. AVRCP 1.6 is not referenced anywhere in active docs/source. The four memory files about Cover Art / BIP / SDP infrastructure stay marked HISTORICAL for future-reference value but are no longer load-bearing.
Open: Bolt pane render still uncertain
Whether Phase 1 unblocks Bolt's pane is unverified on hardware — we shipped 0 in-spec [1..7] responses pre-Phase-1 and Bolt's pane was still empty. Possibilities:
- Pre-Phase-1 Bolt was rejecting
[1..7] + extra(0x08)shape; Phase 1 emits exactly[1..7]matching the request — pane may now render. - Bolt's pane has additional requirements we haven't identified (e.g., specific event subscriptions before pane query). Already noted: Bolt only subscribes to event 0x01 PLAYBACK_STATUS_CHANGED, not TRACK_CHANGED — significantly narrower than Kia's 6-event subscription set.
If Phase 1 doesn't unblock the pane, the remaining diagnosis path is on the CT-state side (forget+repair, force-open the metadata pane at a specific point, etc.) rather than further TG-side changes — we've now exhausted the §5.3.4 / §6.6.1 spec deviations on Y1's end.
Trace #31 (2026-05-13) — Phase 1 verified on wire; AVRCP 1.3 Y1-side exhausted for Bolt-class pane
Phase 1 confirmed working
dual-bolt-20260513-1703 (post-Phase-1 flash):
AVRCP send_get_element_attributes_rsp raw i:0 total:7 attid:1 strlen:13
AVRCP send_get_element_attributes strlen:13 offset:0 g_offset:14
AVRCP send_get_element_attributes_rsp raw i:1 total:7 attid:2 strlen:9
... (attid 3 strlen:18, attid 4 strlen:4, attid 5 strlen:4, attid 6 strlen:13, attid 7 strlen:6)
msg=540, ptr=0x52385638, size=644
total:7 confirms T4 now emits exactly the seven attributes Bolt requested ([0x1,0x2,0x3,0x4,0x5,0x6,0x7] Pattern B), in the requested order, with real-value lengths. No spurious attr 8. No "ignore empty attrib" lines. Spec-compliant per AVRCP 1.3 §6.6.1 Table 6.26.
Response timing on this capture: 1 ms (matches dual-bolt-20260513-1556 timing audit — well under T_MTP 1000 ms).
Bolt's metadata pane: still empty
Despite the wire response now exactly matching the request shape, the Bolt-class CT's metadata pane still doesn't render. This is the end of what's reachable from the AVRCP 1.3 TG side.
Exhaustion checklist — AVRCP 1.3 Y1-side spec compliance
| Surface | Status |
|---|---|
| §6.6.1 request-shape (return only requested attrs, in order) | ✅ Phase 1 |
| §5.3.4 zero-length emit for unsupported attrs | ✅ E1 |
| §6.7.1 once-per-registration | ✅ T2/T5/T8/T9 subscription gating |
Charset (honor CT's InformDisplayableCharacterSet advertisement) | ✅ Bolt declares UTF-8, we send UTF-8 |
| Response timing (§15 T_MTP=1000 ms) | ✅ 1 ms measured |
| Fragmentation / MTU | ✅ Single packet, well under L2CAP MTU |
Metadata content (real values from y1-track-info) | ✅ All non-zero strlens, content correct |
| PASSTHROUGH routing | ✅ Patch E (PLAY-as-toggle for Bolt-class non-spec CTs) |
GetPlayStatus (T6) | ✅ Working — Bolt doesn't query it |
InformDisplayableCharacterSet / InformBatteryStatusOfCT ack | ✅ T_charset / T_battery |
Bolt's residual symptoms — all CT-side / out-of-1.3-scope
- Metadata pane empty: forum evidence + community knowledge points at Bolt-class CTs gating pane render on AVRCP 1.4+ SDP advertisement (profile version
0x0104+,SupportedFeaturesGroupNavigation bit, AdditionalProtocolDescriptorList Browse PSM). Out of the AVRCP 1.3-only project policy; will be tackled on a separate feature branch. - Play/Pause icon stuck after first toggle: Bolt doesn't re-register after CHANGED. §6.7.1 prevents subsequent CHANGED without re-registration. CT-side spec violation, not fixable on the TG side without re-violating §6.7.1 (which would re-break Kia and other spec-compliant CTs).
- Shuffle stuck on: same root cause as Play/Pause icon — Bolt doesn't re-register after the §6.7.1-compliant CHANGED.
Conclusion (superseded by Trace #32)
Trace #31 concluded that Bolt's empty pane was blocked by CT-side reliance on 1.4+ SDP advertisement. Trace #32 refuted that hypothesis: Pixel-as-TG advertises strict AVRCP 1.3 in SDP (profile descriptor 0x0103, SupportedFeatures 0x0001) and Bolt's pane renders against it. The discriminator was the GetCapabilities event list — Pixel advertises four 1.4 event IDs (0x09-0x0c) from a 1.3-declared TG; Y1 (post-Trace-#31) did not.
Trace #32 (2026-05-14) — Pixel HCI snoop reveals GetCapabilities event-list discriminator; Y1 mirrors
After CoD masquerade attempts (Information bit, full Phone-Smartphone) both failed to unblock the metadata pane, the user provided a Pixel-4 adb bugreport containing btsnoop_hci.log of a working Pixel↔Bolt connection. Parsed via tshark.
Wire-level Pixel-vs-Y1 deltas at metadata-pane time
| Surface | Pixel-as-TG | Y1-as-TG (pre-Trace-#32) | Material? |
|---|---|---|---|
| SDP profile descriptor (0x0009) | AVRCP 1.3 (0x0103) | AVRCP 1.3 (0x0103) | no — same |
| SDP SupportedFeatures (0x0311) | 0x0001 (Cat 1 only) | 0x0001 (Cat 1 only) | no — same |
| SDP BrowseGroupList (0x0005) | absent | present (0x1002) | unknown — but Y1's superset is spec-permissible |
| SDP ServiceName (0x0100) | absent | "Advanced Audio " | unknown — Y1's superset is spec-permissible |
| GetCapabilities events (count) | 8 | 8 | no — same count |
| GetCapabilities events (set) | {0x01, 0x02, 0x05, 0x08, 0x09, 0x0a, 0x0b, 0x0c} | {0x01..0x08} | yes — Pixel advertises 1.4 event IDs from a 1.3 TG |
InformDisplayableCharacterSet (0x17) | rejected as "Invalid Command" | acked via T_charset | unlikely — both spec-permissible |
GetElementAttributes response shape | drops unsupported attrs entirely | emits unsupported with len=0 (post-E1) | unlikely — both spec-permissible under §5.3.4 |
RegisterNotification(0x09..0x0c) | INTERIM with zero/empty payload | not advertised → Bolt never subscribes | yes — paired with the previous row |
PASSTHROUGH PLAY ack | Accepted | Accepted | no — same |
Track UID (TrackChanged payload) | 0x0000000000000000 ("SELECTED") | 0xFFFFFFFFFFFFFFFF sentinel | Y1 is spec-strict for 1.3; Pixel uses 1.4+ semantic |
The only Pixel↔Y1 wire delta that maps cleanly to "what Pixel does that Y1 doesn't" is the GetCapabilities event-list mismatch + the 0x09-0x0c INTERIM acks. Pixel's response builders for these four events exist in libextavrcp.so (verified via objdump — btmtk_avrcp_send_reg_notievent_now_playing_content_changed_rsp @ 0x26c0 etc); the PLT stubs are already linked into libextavrcp_jni.so (verified via readelf --dyn-syms — symbols 10/12/14/16 at PLT 0x330c / 0x3318 / 0x3324 / 0x3330) though stock JNI never invokes them.
Implementation
Two surgical patches in patch_libextavrcp_jni.py:
- T1 advertised-event bytes at file
0x7328..0x732f:01 02 03 04 05 06 07 08→01 02 05 08 09 0a 0b 0c. Same 8-event count (which matches the existingmovs r2, #8in T1). - T8 dispatcher: 4 new arms for events 0x09 / 0x0a / 0x0b / 0x0c, each calling the existing PLT stub with
r1=0(success),r2=REASON_INTERIM(0x0f), and event-specific payload (zero for 0x09 / 0x0a; PlayerID=0, UidCtr=0 for 0x0b viar3=0, sp[0]=0; UidCtr=0 for 0x0c viar3=0). No subscription gate is armed for any of the four — no CHANGED ever fires (Y1 has one player, no Now Playing folder, no UID database). New blob size 3852 B (within the 4020-B LOAD #1 padding budget). NewOUTPUT_MD5 = c16a6b7892be2098ac07bef1989c937e.
Cost: lost 1.3 event coverage
Dropping events 0x03 / 0x04 / 0x06 / 0x07 from the advertised set to make room for 1.4-event IDs at the spec-mandated 8-element cap means:
0x03 TRACK_REACHED_END: T5's natural-end emit path is no longer subscribed-to by any CT (event 0x02 TRACK_CHANGED still works; the dedicated end-of-track signal is gone)0x04 TRACK_REACHED_START: same — collapsed into 0x020x06 BATT_STATUS_CHANGED: battery-status indicator on CTs that read it is no longer driven (the music app'sBatteryReceiverstill writesy1-track-info[794]but no CT subscribes)0x07 SYSTEM_STATUS_CHANGED: always-INTERIM-only anyway, no behavioural change
T8 / T5 / T9 handlers for these four events are retained in the trampoline (a permissive CT subscribing to them despite no advertisement still gets handled correctly), but no observed CT does that.
Open: result not yet on the wire
Bolt-Y1 reflash test pending the user's flash cycle. If it works, this trace closes the metadata-pane investigation that's run from Trace #1 (2026-05-02). If it doesn't, the remaining candidates are: (a) some SDP-record-content discriminator (the BrowseGroupList / ServiceName presence delta, or Pixel's not-yet-RE'd 0x0102 ProviderName), or (b) the Bolt-side BR/EDR device-name regex or EIR discriminator.
Trace #33 (2026-05-14) — Bolt's 3-second InformDisplayableCharacterSet stall; T_charset switched to reject
Wire-level finding from dual-bolt-20260513-2150 (post-Trace-#32 flash)
After the Trace #32 flash (advertise + INTERIM-ack 0x09-0x0c), Bolt's metadata pane still didn't render. New dual-bolt-* capture shows the same wall-clock symptom as every prior Bolt capture, but a tighter inspection of the first few seconds reveals the real discriminator:
21:48:01.299 CMD_FRAME_IND size:9 (GetCapabilities request)
21:48:01.299 AVRCP_SendMessage len=30 (GetCapabilities response — 8 events)
21:48:01.360 CMD_FRAME_IND size:11 (InformDisplayableCharacterSet)
21:48:01.360 AVRCP_SendMessage len=8 (T_charset ACK)
21:48:04.367 CMD_FRAME_IND size:13 (FIRST RegisterNotification) ← +3.007 s
Compare to pixel4-bugreport/.../btsnoop_hci.log (Pixel-as-TG, Bolt-as-CT, same Bolt):
98.773 GetCapabilities request
98.773 GetCapabilities response — 8 events
98.785 InformDisplayableCharacterSet
98.786 Rejected - Status: Invalid Command
98.792 FIRST RegisterNotification (PlaybackStatusChanged) ← +0.006 s
98.803 RegisterNotification (TrackChanged)
98.809 GetElementAttributes
98.815 RegisterNotification (PlaybackPosChanged)
98.821 RegisterNotification (NowPlayingContentChanged)
98.836 RegisterNotification (AvailablePlayersChanged)
98.852 RegisterNotification (AddressedPlayerChanged)
98.861 RegisterNotification (UIDsChanged)
98.868 ListPlayerApplicationSettingAttributes
The single Y1 → Pixel delta in that window: Y1 ACKs 0x17 via inform_charsetset_rsp (success); Pixel rejects 0x17 with AV/C ctype NOT_IMPLEMENTED. Bolt evidently waits ~3 s for a follow-up notification after the ACK, then falls back to a 3-second polling cadence for RegisterNotification — which is what every prior dual-bolt-* capture (going back to Trace #21) actually shows, but it never read as a "stall" before because the wall-clock-equivalent symptom (empty metadata pane) was consistent with several other hypotheses.
Fix
T_charset rewritten 14 B → 12 B: drop the blx PLT_inform_charsetset_rsp ACK, restore lr canary + r0=conn, tail-jump to UNKNOW_INDICATION (0x65bc). Same calling convention as T_continuation. Net wire: 0x17 now produces an AV/C NOT_IMPLEMENTED reject (msg=520) instead of an ACK (msg=536), matching Pixel-as-TG byte-for-byte at the AV/C ctype level.
Spec-permissible per AVRCP 1.3 §5.2.7 (InformDisplayableCharacterSet is Optional, both as a feature and as a response shape). The TG's outbound charset is decoupled from the CT's advertised set; we continue to emit UTF-8 for metadata regardless. ICS Table 7 row 18 status unchanged (O); the reject is a valid response for an unsupported Optional PDU.
OUTPUT_MD5: c16a6b78... → e2d44674f6f04f2b2d18ff3f633c5dfc.
Pre-flash prediction
If the InformDisplayableCharacterSet → ACK was indeed the 3-second stall, the next dual-bolt-* capture should show:
CMD_FRAME_IND size:13(first RegisterNotification) within ~10 ms of the 0x17 frame, not +3 s- A burst of 5-8 RegisterNotification commands in the first ~100 ms after 0x17 (one per advertised event Bolt cares about), matching Pixel-Bolt's pattern
GetElementAttributes(size 45) follows the first RegisterNotification by ~10 ms
If the burst pattern matches Pixel's and the metadata pane still doesn't render, the discriminator is elsewhere (SDP record content, EIR/inquiry response, BR/EDR name regex). If the burst happens but the pane does render, this closes the investigation.
Trace #34 (2026-05-14) — mtkbt msg=544 wire ctype is hardcoded 0x0D CHANGED; M1 fix
Direct discriminator finally found at the wire layer
Post-Trace-#33 captures (dual-bolt-20260513-2207) parsed at HCI ACL level. AV/C ctype byte counted from all outbound AVRCP frames:
| ctype | name | count |
|---|---|---|
| 0x08 | NOT_IMPLEMENTED | 1 (T_charset reject) |
| 0x09 | ACCEPTED | 7 (PASSTHROUGH PLAY/PAUSE responses) |
| 0x0C | STABLE | 1 (GetCapabilities response) |
| 0x0D | CHANGED | 20 (every RegisterNotification response) |
| 0x0F | INTERIM | 0 |
Every Y1-as-TG response to RegisterNotification has gone out as CHANGED on the wire, never INTERIM, for the entire v2.0 / v2.1 trampoline-chain era. The Pixel capture of the identical Bolt CT shows ctype 0x0F INTERIM for the first response per subscription, 0x0D CHANGED on subsequent edges — the spec-correct AVRCP 1.3 §6.7.1 pattern. Bolt drops CHANGED-without-INTERIM and falls back to ~3 s polling, which never delivers the metadata pane render.
Why no earlier trace caught this
Every prior btlog inspection (Trace #18, #21, #28-31) parsed mtkbt's IPC byte stream (msg=520, msg=540, msg=544) and verified the IPC frame shape; none decoded the actual HCI ACL bytes mtkbt put on the wire. The IPC layer's reasonCode byte at IPC frame[8] is exactly what the trampoline passes (0x0F INTERIM for first response per subscription, 0x0D CHANGED for proactive edges). The bug is between mtkbt's IPC reception and HCI emission.
Root cause in mtkbt
mtkbt's outbound AVRCP wire encoder at file 0x37cca:
37cb8: ldrb.w r1, [r5, #548] ; per-conn state byte
37cbc: and.w r2, r1, #34 ; mask 0x22 = bits 1, 5
37cc0: cmp r2, #2 ; bit 1 set, bit 5 clear ?
37cc2: bne.n 37cd8
37cc4: movs r0, #1
37cc6: movs r2, #0
37cc8: strb r0, [r4, #25]
37cca: movs r3, #13 ; <-- 0x0D CHANGED, HARDCODED
37cce: strb r2, [r4, #24]
37cd0: strb.w r3, [r0, #12]! ; r4+12 := 0x0D (wire AV/C ctype byte)
The msg=544 dispatch path reaches this branch unconditionally for every RegisterNotification response (the state byte r5[548] & 0x22 == 2 matches). The reasonCode byte the trampoline shipped in IPC frame[8] is never consulted. In stock JNI flow this hardcoded CHANGED was correct: msg=544 was only ever invoked by _Z45BluetoothAvrcpService_registerNotificationCnfNative... after the music app reported a value change. The initial INTERIM came from mtkbt's native dispatcher through a different function at file 0x42abe, which conditionally writes ctype 0x0F when its dispatch byte r5[1]==3. The v2.0.0 trampoline bypasses the native dispatcher (because cardinality:0 was unresolvable) and routes both INTERIM and CHANGED through msg=544 → CHANGED-only on the wire.
Fix (M1)
One-byte flip at mtkbt file 0x37cca: 0d 23 → 0f 23 (movs r3, #13 → movs r3, #15). Net wire: msg=544 emits ctype 0x0F INTERIM. The trampoline's reasonCode argument is now ignored either way; both first-response and edge-CHANGED emit INTERIM. AVRCP 1.3 §6.7.1 allows the CT to treat repeated INTERIM responses as fresh subscription confirmations, so CTs that previously relied on the CHANGED edge see their state via the INTERIM payload instead.
OUTPUT_MD5 for mtkbt.patched: 5d650885... → 2f4e811632dc61564d527d41cf1da32c.
Open: validate against Kia
The trampoline-chain output reaching every CT in the test matrix has been CHANGED-only since v2.0.0. CTs that worked (Kia, etc.) tolerated CHANGED-without-INTERIM. With M1 they get INTERIM-on-edge instead of CHANGED-on-edge — also a §6.7.1-permissible response shape, but a behaviour-shift. Need a fresh dual-kia-* capture post-M1 flash to confirm no regression in PLAY / PAUSE / metadata refresh.
If Kia regresses on M1: option C from the deep-analysis discussion remains — patch mtkbt to read the IPC frame[8] reasonCode byte at 0x37cca and use it as the wire ctype, preserving the INTERIM/CHANGED distinction the trampoline already passes. Requires identifying which register or memory location holds the reasonCode byte at that point in the function (open RE work).
Trace #35 (2026-05-14) — M1 alone didn't reach the wire; fn 0x379e0 has 3 CHANGED branches
Post-M1 flash dual-bolt-20260514-0852 and dual-kia-20260514-0837 parsed at HCI ACL level. Wire AV/C ctype byte still 0x0D (CHANGED) across every RegNotif response; zero 0x0F INTERIM frames on the wire. M1 had no observable effect.
Why
The mtkbt outbound AVRCP response builder lives in fn 0x379e0 and dispatches inbound responses through a multi-branch ladder (a tbb [pc, r3] jump table at 0x37c3e plus several cmp / bne chains). The function writes the AV/C ctype byte to [r4, #12] from three different sites, each in a different dispatch branch:
| site | encoding | mnemonic |
|---|---|---|
0x37cca | 0d 23 | movs r3, #13 → strb.w r3, [r0, #12]! (branch: r5[548] & 0x22 == 2) |
0x37d3c | 0d 22 | movs r2, #13 → strb.w r2, [r0, #12]! (branch: r12 == 2) |
0x37dfc | 0d 22 | movs r2, #13 → strb.w r2, [r0, #12]! (third dispatcher branch) |
Initial M1 scan used a too-narrow byte pattern (only matched r3 = #13 followed by strb.w r3, ...). The two r2 = #13 sites were missed. M1 patched only 0x37cca; the actual inbound dispatch for msg=544 routes through 0x37d3c or 0x37dfc, which kept emitting CHANGED.
The function 0x379e0 also writes ctype values 2, 3, 4, 5, 7 in other branches but never 0x0F (INTERIM). The stock INTERIM-emit path lives in a separate function 0xa655c (movs r1, #15; strb r1, [r4, #12] at 0xa65e2), reachable only when mtkbt's native dispatcher handles the inbound RegisterNotification itself — exactly the path the v2.0 trampoline bypasses.
Fix (M1+M1b+M1c)
Three 1-byte flips at all three CHANGED-writing sites in fn 0x379e0:
| site | before | after |
|---|---|---|
0x37cca | 0d 23 | 0f 23 |
0x37d3c | 0d 22 | 0f 22 |
0x37dfc | 0d 22 | 0f 22 |
All inbound-dispatch branches now emit ctype 0x0F INTERIM. OUTPUT_MD5 for mtkbt.patched: 2f4e8116... → 7a9365e280172548429974935cfb4a29.
Open: Kia track-end position unreliability
The dual-kia-20260514-0837 capture (which was against the M1-only build that didn't reach the wire) showed Kia subscribing in the spec-correct rapid burst pattern (8 RegNotifs in < 300 ms, matching Pixel's pattern), but the user reports "track end position doesn't reliably update." Since M1 didn't change the wire bytes, this regression — if real — must be from something else in the recently flashed stack (Trace #33's T_charset → reject is the most likely candidate, since size=214 reject is visible in the Kia log's charset response). Will re-evaluate after the M1+M1b+M1c flash.
Trace #36 (2026-05-14) — Pixel-mirror emit semantics in T5 / T9
Premise
After M1+M1b+M1c flipped mtkbt's wire ctype to INTERIM on every RegNotif response, the question was whether to keep the §6.7.1-strict "single-shot CHANGED per re-registration" semantics in T5 / T9 or to mirror Pixel's "fire whenever the value changes, don't gate-clear" pattern. Two observations drove the decision:
- The user reported Kia's track-end position not reliably updating. The root cause: T9's
sub_posgate clears after the first CHANGED, so subsequent 1Hz position ticks and track-edge transitions never emit unless the CT re-registers between every event. Kia (and most CTs) don't re-register reliably between value changes — they rely on either (a) Pixel-style continuous emits or (b) GetPlayStatus polling. - Pixel-as-TG's btsnoop trace shows it preempting §6.7.1: emits CHANGED unsolicited on every value change, doesn't wait for re-registration. Bolt accepts this, re-registers within ~20 ms of each CHANGED. The §6.7.1-strict reading is more rigid than the actual ecosystem expects.
Implementation (_trampolines.py)
State byte semantics shifted from single-shot to session-long. T2 / T8 INTERIM still arm the gate bytes (state[13..20]); T5 / T9 read but no longer clear after CHANGED emit. Once a CT subscribes in a session, every subsequent value change emits.
Specific changes:
- Removed 7
_emit_subscription_write(a, 0, …)clear-on-emit calls from T5 (3) and T9 (4). - Added
state[20]=sub_now_playing_content(event 0x09). T8 0x09 INTERIM arms it. T5 / T9 gate on it for the new NowPlayingContent emits. - T5: added
reg_notievent_now_playing_content_rsp+reg_notievent_pos_changed_rspcalls on every track-edge fire. Order matches Pixel's wire ordering (NowPlayingContent → PlaybackPos → TrackChanged → TrackReachedEnd → TrackReachedStart, with 0x03 / 0x04 gated on their respective sub_* bits which are typically 0 since events 0x03 / 0x04 aren't advertised). - T9: added
reg_notievent_now_playing_content_rspcall on play-status edge (paired with the existing PlaybackStatus CHANGED emit). T5_FRAME820 → 824 (4-B align for 24-byte state buf);T9_FRAME836 → 840 (same).- State buf read size 20 → 21 in both T5 and T9.
On-disk state file grows from 20 → 21 bytes on first T8 0x09 INTERIM arm (lseek + write past EOF zero-extends; older 16-B / 20-B files degrade gracefully — short reads zero-fill the in-memory buffer).
Pixel-mirror coverage table
What we now emit, compared with the Pixel ↔ Bolt btsnoop trace:
| Trigger | Pixel emits | Y1 emits (post-Pixel-mirror) |
|---|---|---|
| ~1Hz tick during playback | PlaybackPosChanged | PlaybackPosChanged (T9 1Hz block; gate session-long) ✓ |
| play/pause edge | PlaybackStatus + NowPlayingContent + (initial) TrackChanged | PlaybackStatus + NowPlayingContent (T9 play-edge block) ✓ (TrackChanged on play-edge is Pixel's first-play-after-connect specific behaviour for resolving internal track ID 0x0000 → real; Y1 uses 0xFF×8 sentinel so doesn't need this) |
| track edge (natural / NEXT / PREV) | NowPlayingContent + PlaybackPos + TrackChanged | NowPlayingContent + PlaybackPos + TrackChanged (T5; in spec order) ✓ |
| Player Application Settings change | PlayerApplicationSettingChanged | PlayerApplicationSettingChanged (T9 papp block) ✓ |
| Battery edge | n/a (Pixel doesn't advertise 0x06) | n/a (Y1 doesn't advertise either; T9's emit is dead path) |
Spec deviation
Strict §6.7.1: TG SHALL only send CHANGED in response to an outstanding INTERIM-acked registration; the registration is consumed by the CHANGED. We treat the registration as session-long instead — same deviation Pixel-as-TG ships and Bolt empirically tolerates. CTs that depend strictly on §6.7.1 would see "duplicate" CHANGEDs without re-registration between them; in the test matrix, all observed CTs accept this and update their UI on every frame.
Wire output
OUTPUT_MD5 for libextavrcp_jni.so.patched: e2d4467… → 75270ee12a36bbf553ff504746d218fd.
Blob size 3852 → 3568 B (gate-clear removals freed more bytes than the new emits added; 452 B free in the LOAD #1 padding budget).
Open
If a future CT in the test matrix rejects unsolicited CHANGED following the first INTERIM, the fallback is to clear state[13..20] on the connect_ind edge to scope subscription state to a single CT session. There's no clean trampoline hook for "new CT connected" today — would need to add an entry in mtkbt's IPC dispatch or rely on the music app to clear state files on disconnect.
Trace #37 (2026-05-14) — End-to-end mtkbt msg=544 ctype trace; M1/M1b/M1c/M1d confirmed dead; live site is 0x12244
Premise
User report after the M1+M1b+M1c flash: Bolt's metadata pane still empty; wire AV/C ctype byte 0 still 0x0D (CHANGED). User-verified deployment MD5 7a9365e280172548429974935cfb4a29 matches the patcher output — the three sites in fn 0x379e0 ARE in dead code for the msg=544 RegNotif response path. M1d (0x39714, fn 0x396d0) was a speculative second guess. User directive: "Continue tracing to completion. No more interim iterative stuff."
Method
Full radare2 walk of the AV/C wire frame builder chain in mtkbt, starting from the wire-byte write and walking up through every layer until reaching a code site whose byte-level diff matches the wire symptom (ctype = 0x0D for RegNotif responses).
End-to-end chain (verified by axt xrefs at every hop)
JNI IPC socket "bt.ext.adp.avrcp" msg_id=544 (0x220)
→ fcn.0006adec (IPC poll loop, mentioned in earlier traces)
→ fcn.00067768 (IPC dispatch by msg_id)
[0x67776] str r3, [r0, 8] ; msg[8] = msg+0x1c (ctxt ptr)
[0x679da-0x679e4] if 500 <= msg_id <= 612: bl fcn.000518ac
→ fcn.000518ac (msg_id - 500 jump table, 113 cases, tbh)
[0x518b0] ldr r4, [r0, 8] ; r4 = ctxt (= msg+0x1c)
msg=544 → index 44 (=544-500)
table @ 0x518c2 + 44*2 = 0x5191a contains 0xf0
target = 0x518c2 + 2*0xf0 = 0x51aa2
[0x51aa2] mov r0, r4 ; bl fcn.00012478
→ fcn.00012478 (per-event RegNotif response dispatcher)
[0x12490] ldrb r0, [r4, 5]
[0x12492] ldrb r3, [r4, 6] ; gate; must be 0
[0x124a0] ldrb r3, [r4, 9] ; r3 = ctxt[9] = event_id
tbb on event_id-1 → per-event handler (fcn.000122cc/e4/24/54/90/270/...)
→ fcn.00012270 (one of 9 per-event response builders;
others 000122cc/e4/24/54/90 mirror this shape)
bl fcn.000121d8 with r0 = unchanged arg1 (the ctxt at msg+0x1c)
→ fcn.000121d8 (RegNotif response packetFrame builder dispatch)
[0x1222e] ldrb r1, [r4, 8] ; r1 = ctxt[8]
[0x12230] cmp r1, 1
[0x12232] bne 0x12240 ; ctxt[8] != 1 → CHANGED branch
; INTERIM branch:
[0x12238] movs r1, 0xF ; r1 = 0x0F (INTERIM ctype)
; CHANGED branch:
[0x12244] movs r1, 0xD ; r1 = 0x0D (CHANGED ctype)
[0x1224a] movs r2, 0x31 ; r2 = PDU 0x31 (RegisterNotification)
[0x1224e] bl fcn.00011894 ; build single-packet response
→ fcn.00011894 (single-packet response builder)
[0x1191e] mov r6, r1 = arg2 = ctype
[0x11922] strb r6, [r4, 0xb] ; packetFrame[0xb] = ctype
→ fcn.0000f0bc (queues packetFrame onto conn[0x310], calls wire builder)
[0xf11c] str.w r6, [r4, 0x310] ; conn[0x310] = packetFrame
[0xf198] bl fcn.0000ef08
→ fcn.0000ef08 (AV/C wire frame builder)
[0xef5e] ldrb r2, [r5, 0xb] ; r2 = packetFrame[0xb] = ctype
[0xef68] strb r2, [r4, 0] ; wire buf[0] = ctype
→ wire L2CAP/AVCTP frame → air → CT
The verified write site
File offset 0x12244, bytes 0d 21 (movs r1, 0xd). Paired INTERIM site at 0x12238 (0f 21 = movs r1, 0xf). Both branches converge at 0x1224a (movs r2, 0x31, PDU=RegisterNotification) and feed fcn.00011894 which stores r1 into packetFrame[0xb]. The packetFrame's byte 0xb is read by fcn.0000ef08 and written to wire buf[0].
The discriminator is ctxt[8] == 1:
ctxt[8] == 1→ INTERIM (0x0F)ctxt[8] != 1→ CHANGED (0x0D)
Since the wire shows 0x0D, the JNI's msg=544 payload at offset 0x1c + 8 = 0x24 is reaching mtkbt with a value other than 1.
Why M1/M1b/M1c/M1d are dead
Functions fcn.0x379e0 (M1/M1b/M1c) and fcn.0x396d0 (M1d) are different code paths — likely AVCTP/L2CAP frame fragmentation or error-reply builders. They write 0x0D to [r4, #12] (offset 12, not 11) of their respective work structs. Even though the offset-12 write would land at packetFrame[0xc] (a different field, possibly packet-type), the wire-side ctype byte read by fcn.0000ef08 is at offset 0xb. M1/M1b/M1c/M1d never touch offset 0xb, never affect the msg=544 RegNotif response chain identified above.
Helper-side analysis (libextavrcp.so)
Following the chain into the JNI library that actually marshals the IPC payload: each btmtk_avrcp_send_reg_notievent_*_rsp helper (e.g. pos_changed_rsp at file 0x2588, track_changed_rsp at 0x2458) has the same shape — memset(sp+4, 0, 0x28) then writes specific bytes:
| sp offset | payload offset | what |
|---|---|---|
sp+0x9 | byte 5 | status (= [conn+0x11], or 1 if cardinality > 0) |
sp+0xb | byte 7 | reasonCode (arg3 = 0x0F INTERIM / 0x0D CHANGED) |
sp+0xd | byte 9 | event_id (hardcoded per helper: 2 for track_changed, 5 for pos_changed) |
sp+0x28 | byte 0x24 | data (e.g. play position u32) |
AVRCP_SendMessage then prepends a 28-byte header and ships the bytes via BT_SendMessage with msg_id = 0x220 = 544. Mtkbt's fcn.00067768 parses the IPC frame: the 40-byte payload starts at msg+0x1c (matches the JNI's sp+4 content exactly because AVRCP_SendMessage copies sp+4 → sp+0x1c of the full IPC frame).
Therefore:
- mtkbt's
ctxt[7]= JNI payload byte 7 = reasonCode (0x0For0x0D). - mtkbt's
ctxt[8]= JNI payload byte 8 = always 0 (memset; no helper writes here). - mtkbt's
ctxt[9]= JNI payload byte 9 = event_id (matchesfcn.00012478's tbb dispatch).
Stock mtkbt's discriminator at fcn.0x121d8 reads ctxt[8] and compares with 1. Off-by-one. Always misses. Always lands on the CHANGED branch. Wire always emits 0x0D regardless of the JNI's intent.
The fix (M1a / M1b)
Two-site, two-byte mtkbt patch:
| offset | before → after | mnemonic before → after |
|---|---|---|
0x1222e | 21 7a → e1 79 | ldrb r1, [r4, 8] → ldrb r1, [r4, 7] |
0x12230 | 01 29 → 0f 29 | cmp r1, 1 → cmp r1, 0x0F |
After M1a + M1b, mtkbt's dispatch correctly routes the JNI's reasonCode:
ctxt[7] == 0x0F→ INTERIM branch → wire ctype0x0F(T2 / T8 first-response arms in_trampolines.py).ctxt[7] != 0x0F(i.e.,0x0Dfrom T5 / T9 edge emits) → CHANGED branch → wire ctype0x0D.
Spec-compliant per AVRCP 1.3 §6.7.1 and matches the Pixel-as-TG btsnoop pattern: INTERIM on the first response per registration, CHANGED on subsequent value updates without waiting for re-registration. The Pixel-mirror gate semantics in _trampolines.py (T2 / T8 arm state[N]; T5 / T9 read but don't clear) drives the JNI's INTERIM-vs-CHANGED choice; M1a / M1b just routes that intent through to the wire.
Patcher state
- Stock
mtkbtMD53af1d4ad8f955038186696950430ffda. - Output
mtkbtMD5 with M1a + M1b applied:c6ffea0082aae923ec9e7bc64293f848. - The old M1 / M1b / M1c (sites in
fn.0x379e0) and M1d (site infn.0x396d0) have been removed frompatch_mtkbt.py— they wrote to offset 0xc of unrelated work structs, never touched the msg=544 RegNotif response chain.
Verification plan
After flash:
- mtkbt MD5 on device:
c6ffea0082aae923ec9e7bc64293f848. - Capture
dual-boltanddual-kiabtsnoop. Inspect wire AV/C ctype byte 0 on RegNotif responses. Expect0x0FINTERIM on the first response per registration,0x0DCHANGED on T5 / T9 edge emits. - Bolt: metadata pane render is the proof. Kia: continued operation, no regression on PlayStatus / position reporting.
If Bolt still doesn't render and wire ctype sequence is correct (0x0F first per event, 0x0D on subsequent edges): the issue is downstream of ctype — TRACK_CHANGED Identifier payload, GetElementAttributes attr-list shape, or PASSTHROUGH command handling. The §6.7.1 / wire-ctype chain is then fully accounted for.
Trace #38 (2026-05-14) — M1a/M1b deployed-and-verified (MD5 c6ffea00) but post-flash behaviour unchanged; M2 diagnostic patch
What happened
Post-M1a/M1b flash: device-side md5 /system/bin/mtkbt returned c6ffea0082aae923ec9e7bc64293f848 (matches patcher output). User reports Bolt's metadata pane still empty. New capture dual-bolt-20260514-1555 synced.
Forensics on the post-patch capture
Static analysis of btlog.bin from both pre-M1a/M1b (dual-bolt-20260514-1020) and post-M1a/M1b (dual-bolt-20260514-1555) captures:
- mtkbt MD5 deployed: verified by user, matches
c6ffea00.... - Bytes at M1a/M1b sites in the deployed binary:
e1 79 0f 29(post-patch values) — confirmed by patcher reproduction from the same stock + matching MD5. btlog.bindoes NOT contain raw wire bytes. Despite the file size and apparent presence of AV/C-shaped byte patterns (00 0d 00 ... 00 19 58 31 00 00 05 01), those patterns are mtkbt's internal log frame formatting (some kind of packed struct dump). Two pieces of evidence:- The byte patterns are byte-for-byte identical pre- and post-patch. If they were wire bytes, M1a/M1b's effect on RegNotif responses would shift at least some ctype values from
0x0Dto0x0F. - The AVRCP profile_id
0x110E(which every AVCTP-layer frame on AVRCP carries in big-endian wire order) appears only 1-3 times across the entire 2.8 MB btlog — far below the hundreds we'd expect if wire frames were logged.
- The byte patterns are byte-for-byte identical pre- and post-patch. If they were wire bytes, M1a/M1b's effect on RegNotif responses would shift at least some ctype values from
[BT]PutByte: len=Nlog lines inbtlog.bintruncate at ~4 bytes of payload data after the length string — well before reaching the AV/C ctype byte (offset ~12-15 from L2CAP frame start, depending on AVCTP / ACL framing).[AVC] L2CAP_SendData channelId:68 packet.headerLen:6 packet.dataLen:Nlog lines exist (~80 in 1020, ~80 in 1555) — Y1 is actively sending L2CAP traffic to Bolt — but mtkbt's log format strings for these lines don't include the AV/C ctype byte. Bytes are gone by the time the log line is emitted.- No
[AVCTP] cmdFrame->ctype:%d ...log lines in either capture. That log line lives infcn.0006d048(cmdFrame RECV parser), only fires for AV/C frames mtkbt processes natively (not the trampoline-shortcircuited path the v2.0 design uses).
What the logs cannot tell us
Whether the wire ctype byte for the post-M1a/M1b RegNotif responses is 0x0F (M1a/M1b working) or 0x0D (M1a/M1b on a dead path). The captures don't contain wire-byte evidence.
M2 diagnostic — force-INTERIM at the wire-write site
File offset 0xef5e in fcn.0000ef08 (the AV/C wire frame builder) loads the ctype byte from packetFrame[0xb] into r2, which strb r2, [r4, 0] at 0xef68 writes to wire buf[0]. M2 replaces the load with movs r2, 0xF — every outbound AV/C frame fcn.0000ef08 builds gets wire ctype 0x0F INTERIM, regardless of what the upstream builder put in packetFrame[0xb].
| offset | before → after | mnemonic |
|---|---|---|
0xef5e | ea 7a → 0f 22 | ldrb r2, [r5, 0xb] → movs r2, 0xF |
Gated behind KOENSAYR_DEBUG=1 (the existing apply.bash --debug flag). Release builds don't include M2; the patcher's DEBUG_PATCHES list is only appended to PATCHES when the env var is set. Stock MD5 → M1a/M1b only output c6ffea00...; stock MD5 → M1a/M1b + M2 debug output 4321c84147b5a0a43ab028b9f6ceff1b.
Diagnostic decision tree
Build with ./apply.bash --avrcp --debug, flash, pair with Bolt, observe metadata pane:
- Pane renders →
fcn.0000ef08IS the active wire emitter for RegNotif responses. M1a/M1b's logic is correct; some upstream divergence prevents it from biting. Pivot: find why ctxt[7] isn't carrying the JNI's reasonCode through, OR what other path supplies a different ctype value topacketFrame[0xb]beforefcn.0000ef08reads it. Roll M2 out. - Pane still empty → ctype is not the (sole) blocker. Wire ctype is reaching Bolt with whatever the rest of the stack produces but Bolt's pane gates on a downstream field. Investigation pivots completely — TRACK_CHANGED Identifier payload shape, GetElementAttributes attr-list ordering, PASSTHROUGH command handling, paramLen encoding. Roll M2 out.
Either branch is a productive next move, and either branch closes the "ctype on the wire" question for good.
Side effects of M2 while it's flashed
Every outbound AV/C frame fcn.0000ef08 builds emits wire ctype 0x0F INTERIM regardless of spec-mandated value:
- RegNotif responses:
0x0FINTERIM for first response and for value-change updates (the v2.0 / Pixel-mirror design already emits unsolicited CHANGEDs without re-registration, so the data flow continues). - ACCEPTED responses (PASSTHROUGH command acks, GetCapabilities, GetElementAttributes, GetPlayStatus): ctype
0x09→0x0F. - NOT_IMPLEMENTED responses (T_charset's PDU 0x17 reject): ctype
0x0C→0x0F. - REJECTED responses (per-CT NOT_AVAILABLE etc.): ctype
0x0A→0x0F.
Some strict CTs may reject non-RegNotif responses with INTERIM ctype. This is acceptable for the one-flash diagnostic capture. M2 is removed after the diagnostic question is answered.
Trace #39 (2026-05-14) — M2 diagnostic outcome: chain confirmed, M1a was wrong, M1b is the correct fix
M2 outcome
Post-M2 flash (debug build, mtkbt MD5 4321c84147b5a0a43ab028b9f6ceff1b, force wire ctype = 0x0F at fcn.0000ef08 0xef5e):
- Metadata pane RENDERED on Bolt. Artist / Track / Album visible (Zebrahead track verified by the user).
- All Bolt-side PASSTHROUGH buttons broke. PASSTHROUGH responses (PLAY / PAUSE / NEXT / PREVIOUS command acks) normally carry ctype
0x09ACCEPTED. M2 forces them to0x0FINTERIM, which Bolt interprets as "command pending" rather than "command accepted" — UI feedback never confirms. - Metadata did not update on track skip from the Y1. T5 / T9 CHANGED-on-edge emits normally carry ctype
0x0DCHANGED. M2 forces them to0x0FINTERIM, so Bolt sees the same registration's "initial state" message repeatedly instead of a value-change notification — no refresh trigger fires.
Both side-effect symptoms are precisely what the spec predicts for ctype overload, confirming that:
fcn.0000ef08IS the active wire-emit path for every outbound AVRCP frame, not just RegNotif responses. The chainIPC msg=544 → fcn.00067768 → fcn.000518ac → fcn.00012478 → per-event handler → fcn.000121d8 → fcn.00011894 → fcn.0000f0bc → fcn.0000ef08is correct.- Wire ctype IS the blocker for Bolt's metadata pane render. Once 0x0F appeared on the wire, the pane filled in.
- Y1's RegNotif response data payload is correct as soon as the ctype byte is. Artist / Track / Album bytes flow through
GetElementAttributesand the metadata structures are well-formed; Bolt parses them happily.
What M1a got wrong
M1a (commit aae16de) retargeted the discriminator load from [r4, 8] to [r4, 7]. The premise was that the JNI helper's strb.w r7, [var_bh] stored the reasonCode at the local-buffer offset that radare2 labelled var_bh = "variable at sp+0xb". That label is misleading. The actual instruction bytes 8d f8 0c 70 decode to strb.w r7, [sp, #0xc] — sp+12, not sp+11. Combined with the helper's add r0, sp, 4; memset(r0, 0, 0x28) (40-byte buffer starts at sp+4), sp+12 maps to payload byte 8, not byte 7.
That puts the JNI's reasonCode at payload[8] = ctxt[8] (in mtkbt's view, after the ldr r3, [r0+8] → ctxt = msg+0x1c plumbing). Stock mtkbt's ldrb r1, [r4, 8] was reading the correct byte. The only bug was the comparison constant.
After M1a + M1b: dispatch reads ctxt[7] (always 0 from memset) and compares to 0x0F. Always fails. Dispatch always lands on CHANGED branch. Wire = 0x0D. Bolt's pane stays empty.
After M1b alone (revert M1a): dispatch reads ctxt[8] (= JNI reasonCode = 0x0F for T2/T8 INTERIM, 0x0D for T5/T9 CHANGED) and compares to 0x0F. Matches for INTERIM emits → wire 0x0F. Mismatches for CHANGED emits → wire 0x0D. Spec-compliant per AVRCP 1.3 §6.7.1 and matches the Pixel-as-TG behaviour.
Verified helper offset (libextavrcp.so)
For btmtk_avrcp_send_reg_notievent_pos_changed_rsp at file 0x2588:
| disasm | encoding bytes | actual offset |
|---|---|---|
strb.w r3, [sp, 9] | 8d f8 09 30 | sp+9 = payload[5] (= conn[0x11], status) |
strb.w r7, [sp, 0xc] | 8d f8 0c 70 | sp+12 = payload[8] = reasonCode |
strb.w r1, [sp, 0xd] | 8d f8 0d 10 | sp+13 = payload[9] = event_id (e.g. 5 for PlaybackPos) |
str.w r8, [sp, 0x28] | cd f8 28 80 | sp+40 = payload[0x24] = position u32 |
The radare2 var labels var_9h, var_bh, var_dh, var_28h index decimal offset, hex-suffixed (so var_bh = sp + 0xb = sp + 11), but the encoded imm12 for the var_bh write is 0xc. The label and instruction disagree by one; trust the bytes.
Final patch state
| patch | offset | bytes | role |
|---|---|---|---|
| M1 | 0x12230 | 01 29 → 0f 29 | cmp r1, 1 → cmp r1, 0x0F. Stock load ldrb r1, [r4, 8] at 0x1222e left untouched (now correctly reads the JNI reasonCode). |
Rolled back:
- M1a (was
0x1222e: 21 7a → e1 79, retargeting load to byte 7). Wrong byte. - M2 (debug-only
0xef5e: ea 7a → 0f 22, force-wire 0x0F). Diagnostic served its purpose; no longer needed. - Pre-existing M1 / M1b / M1c / M1d in fn.0x379e0 / fn.0x396d0 (removed in commit
aae16de). Were dead code.
Stock MD5 → M1-only output: 926b8e808693a4c44028ee257b33e898.
Trace #40 (2026-05-16) — Kia button-stuck root cause: AF_UNIX SOCK_DGRAM datagram drop in mtkbt IPC
Premise
Post-2.1.0 Kia regression: play/pause button on Kia stays stuck on its initial state across multiple play/pause cycles. Combined logs from dual-kia-20260515-1841 showed T9emit pstat firing 30 times (15× PLAYING / 15× PAUSED, clean alternation) — Y1 detects every edge correctly. Yet Kia's UI doesn't refresh.
T9 → wire delivery rate is ~14%
Added T8reg ev=%02x debug log (commit 446baa8) at the head of _emit_t8 to count incoming RegisterNotification PDUs from Kia.
dual-kia-20260515-1933 capture (5-min Kia session):
| Log | Count | Meaning |
|---|---|---|
T9emit pstat=1 | 14 | Trampoline-level CHANGED-emit attempts on PLAYING edge |
T9emit pstat=2 | 14 | Same on PAUSED edge |
T8reg ev=01 | 2 | Kia's actual RegisterNotification(event=0x01) arrivals |
After fixing tools/btlog-hci-extract.py (commits 1de1d5f + 2bc680c) to handle the new firmware's record header layout, wire-side decode of the same capture:
| Wire frame (TX) | L2CAP len | Event | Count |
|---|---|---|---|
CHANGED RegisterNotification | 18 | 0x05 PLAYBACK_POS_CHANGED | 17 |
CHANGED RegisterNotification | 14 | 0x03/0x04 REACHED_END/START | 7 |
CHANGED RegisterNotification | 15 | 0x01 PLAYBACK_STATUS_CHANGED | 4 |
INTERIM RegisterNotification | various | (mixed event_ids) | 5 |
So out of 28 trampoline T9emit pstat attempts, only 4 PLAYBACK_STATUS_CHANGED CHANGED frames reached the wire. Kia subscribes to event 0x01 only twice in the entire 5-minute session — explaining why most CT-side updates never happen — but more importantly, 86% of our trampoline-side emits for event 0x01 are silently dropped between T9 and the wire.
First false hypothesis: AVRCP TG one-shot transaction recycle (REFUTED)
Initial reading of BT_SendMessage at libextavrcp.so:0x1840 found a cbz r6, 0x18bc guard at offset 0x1870 that drops the frame silently when conn[8] (the field at offset 8 of the conn struct passed in) is zero. Hypothesised the AVCTP TG state machine was recycling the per-transaction conn struct after each CHANGED, so subsequent T9 emits would hit a cleared conn[8] and drop.
Added T9connfd=%08x log (commit eeeb49c) at the top of T9's play_status emit path to capture r4[8] = conn[8] directly.
In parallel, RE'd enableNative at libextavrcp_jni.so:0x48d4:
0x48fa: bl 0x36c0 → r5 = struct ptr (the SAME 0x36c0 T9 uses)
0x491a: blx socket_local_server → r0 = local server FD
0x4920: str r0, [r5, 0x14] → struct[0x14] = server FD
0x494a: blx socket(AF_UNIX, SOCK_DGRAM, 0)
0x4952: str r0, [r5, 0x10] → struct[0x10] = AF_UNIX SOCK_DGRAM FD
And initializeNativeObjectNative at 0x3fd4: calloc(1, 28) → 28-byte struct → stored as a Java long field via vtable-resolved SetLongField (vtable offset 0x1b4).
So 0x36c0 returns a persistent 28-byte struct that initializeNativeObjectNative allocates once (at service construction) and enableNative populates. conn = struct + 8, conn[8] = struct[0x10] = AF_UNIX socket FD. The FD is session-long — opened once when the AVRCP service enables, valid until disable.
Therefore conn[8] is non-zero throughout the BT pairing session. BT_SendMessage's cbz r6 guard never trips for normal operation. The drop can't be there. Hypothesis refuted before the connfd capture even came back (although the log will still confirm it empirically).
Real hypothesis: send() → mtkbt IPC kernel-level datagram drop
The conn struct's FD is AF_UNIX SOCK_DGRAM (socket(1, 2, 0) — domain=1=AF_UNIX, type=2=SOCK_DGRAM). Datagram sockets are unreliable: if the receive side's queue is full, the kernel drops the packet silently. send() returns -1 with EAGAIN or ENOBUFS depending on socket flags.
mtkbt is the recv side. It's concurrently processing:
- A2DP audio streaming (~50ms-cadence ACL frames at ~736 B each, saturating the BT chip UART)
- Inbound AVCTP control frames (PASSTHROUGH, GetPlayStatus polls, GetElementAttributes, RegisterNotification)
- Outbound AVRCP responses
When T9 fires 28 times in a 5-minute window — many bursts triggered by playstatechanged broadcasts that happen 1-3 in rapid succession on each play/pause toggle — the mtkbt recv queue saturates and 24 of those 28 frames get dropped at the kernel layer. send() returns -1, BT_SendMessage returns -1, AVRCP_SendMessage logs "ignore index:%d total:%d" and returns 1, T9 has no idea anything went wrong.
The wire-side counts confirm this delivery profile:
- 4 successful
event 0x01CHANGED sends — matches roughly "first CHANGED of each subscription burst plus a couple that snuck through". - 17 successful
event 0x05POS_CHANGED sends — pos_changed fires at our 1 Hz cadence, much less bursty than pstat, so most go through. - 7 successful REACHED_END/START — same low cadence, mostly go through.
The drop pattern is rate-limit-driven, not subscription-driven.
Where the FD lives
| Offset (struct) | Offset (conn = struct+8) | Field |
|---|---|---|
| 0x00..0x07 | -8..-1 | (header / unknown) |
| 0x08..0x0f | 0..7 | (header / unknown) |
| 0x10 | 8 | AF_UNIX SOCK_DGRAM FD (set by enableNative 0x4952) |
| 0x14 | 0xc | Local server FD (set by enableNative 0x4920) |
| 0x19 | 0x11 | transId byte (set by notificationPlayStatusChangedNative 0x3cf2 — copied from a static byte in .rodata, see below) |
| ... | ... | ... |
| 0x1b | 0x13 | (struct end at 28 bytes) |
r7[0x19] write at 0x3cf2 is strb.w ip, [r7, 0x19] where ip was loaded from [lr+1] after lr = pc + <const> resolves to a string. So the transId stored is a constant byte from .rodata, not the per-CT inbound transId. This means stock notificationPlayStatusChangedNative always emits with the same fixed transId — a separate spec-compliance concern from the drop question, but worth noting.
Diagnostic plan (next capture)
T9connfd=%08x log will show — definitively — whether conn[8] is zero on dropped attempts. Three outcomes:
| Outcome | Interpretation | Fix shape |
|---|---|---|
T9connfd=0 on all/most | Hypothesis refuted again — there's a SECOND struct path I haven't found, and the FD is per-event not session-long | RE the second path |
T9connfd=<same nonzero> on all | Confirmed: drop is at send() returning -1 (kernel drops datagram) | Rate-limit T9 emits + add response builder return-value log to confirm |
T9connfd=<varying nonzero> | conn struct is shared across multiple sessions or per-CT | Investigate which CT each emit targets |
Spec-compliance angle (per feedback_avrcp13_only_scope)
AVRCP 1.3 §6.7.1 is explicit: CT MUST re-RegisterNotification after each CHANGED. Kia subscribes once at session start, once mid-session, and that's it. CT-side spec violation. But Y1's TG is also generating ~28 CHANGED frames per 5-minute session for event 0x01 — way more than the 2 the CT subscribed for. With the AVRCP 1.3 one-shot model, the spec-correct TG would emit CHANGED only 2 times (one per subscription). Our session-long gate semantics (state[14]=1, never cleared) emit on every edge — which would be fine if the AVCTP layer had the buffer space, but the AF_UNIX SOCK_DGRAM IPC saturates first.
A spec-compliant fix path: implement strict §6.7.1 one-shot in T9 (clear state[14] after each CHANGED emit, re-arm only on next T8 INTERIM). That brings our wire emit count down from 28 to 2 per Kia session — eliminating the IPC overflow and matching what Kia actually expects. The trade-off: the 26 dropped emits we currently make were "best-effort" attempts to compensate for non-compliant CTs; switching to strict one-shot would relinquish that compensation. But empirically the 26 attempts don't reach the wire anyway, so we lose nothing concrete by becoming spec-compliant.
Per-event transId database
At libextavrcp_jni.so:0x71f0, getSavedRegEventSeqId(eventId):
cmp r0, 0xf
bhi return_zero
ldr r3, [pc + 0x60bb] → r3 = &g_avrcp_req_event_database
add r3, pc
ldrb r0, [r3, r0] → r0 = g_avrcp_req_event_database[eventId]
bx lr
g_avrcp_req_event_database is a 16-byte global table (one byte per event_id, indices 0..15) at VA 0xd2b5 in .bss. Sister function saveRegEventSeqId(eventId, seqId) at 0x5ee4 writes to it from the inbound RX path when CT sends a RegisterNotification.
The stock notificationPlayStatusChangedNative (at 0x3cdc) reads this DB to populate conn[0x11] (= AVCTP transaction label) before calling the response builder:
ldr.w lr, [pc + ...] ; lr = const offset, resolves to 0xd2b5 = g_avrcp_req_event_database
add lr, pc
ldrb.w ip, [lr, 1] ; ip = g_avrcp_req_event_database[1] (event 0x01 = PLAYBACK_STATUS)
strb.w ip, [r7, 0x19] ; r7[0x19] = conn[0x11] = transId
Our T9 trampoline (entered via b.w T9 at 0x3c88, the first instruction of notificationPlayStatusChangedNative) bypasses this stock prolog — we don't write conn[0x11] either. So T9 emits CHANGED with whatever transId the conn struct already held from the last stock-prolog or inbound-RX path execution. In practice that's still correct (= the CT's last subscription transId), but it's an implicit dependency that should be made explicit if T9 is ever entered before any inbound RX path has run.
Struct layout (28-byte conn struct from calloc(1, 28))
| Offset (struct) | Offset (conn = struct + 8) | Field | Setter |
|---|---|---|---|
| 0x00..0x07 | -8..-1 | (header / unknown) | unset by initializeNativeObjectNative, only memset(0) |
| 0x08..0x0f | 0..7 | (header / unknown) | unset |
| 0x10 | 8 | AF_UNIX SOCK_DGRAM FD | enableNative 0x4952: socket(1, 2, 0) → here |
| 0x14 | 0xc | local server FD | enableNative 0x4920: socket_local_server(...) |
| 0x19 | 0x11 | transId byte (= AVCTP TL) | stock notificationPlayStatusChangedNative 0x3cf2; getSavedSeqId callsites at 0x4afc / 0x4b4a (folder-items paths) |
| 0x1a..0x1b | 0x12..0x13 | (struct end at 28 B) | unset |
BT_SendMessage reads only conn[8] (FD) from the conn struct. btmtk_avrcp_send_*_rsp builders read conn[0x11] (transId) for the AVCTP byte. Everything else is local-buffer scratch.
Strict-gate revert history
The "session-long subscription gate" semantics that produce 28 T9 emit attempts per 14 play/pause cycles weren't always in place. Project history:
| Date | Commit | What | Why |
|---|---|---|---|
| 2026-05-15 | 3a98be8 | "Pixel-mirror emit semantics — drop §6.7.1 gate clearing" | Switched to session-long after observing Pixel-as-TG keeps emitting CHANGED across multiple events without waiting for re-register |
| 2026-05-15 | 6503c87 | "T5+T9: AVRCP §6.7.1 strict gate clearing after CHANGED emit" | Re-introduced strict §6.7.1 — clear state[14]/state[16]/etc. after each CHANGED. Verified Pixel-as-TG actually does emit strict on play/pause edges per tshark btsnoop trace |
| 2026-05-15 | b1c15a9 | bare revert of 6503c87 | (No body — revert reason undocumented) |
So the strict-gate flip-flop was tried-and-reverted within hours on 2026-05-15. The current state is session-long, justified empirically by some CTs not re-registering. But Trace #40's finding — that 24/28 emits drop at the IPC layer anyway — means session-long's "compensate for non-compliant CTs" doesn't actually compensate. Strict §6.7.1 (re-introducing 6503c87) would emit at most 2 CHANGED per Kia's 2 subscriptions for event 0x01, and all 2 would survive the IPC layer (low burst rate, no buffer overflow).
Why session-long isn't actually compensating
The intuition behind session-long: "if a CT doesn't re-register but expects more updates (1.4-style sticky semantics), keep firing CHANGED." Empirically:
- Kia subs 2× for event 0x01, gets 4 CHANGED on the wire (≈ 2× INTERIM-bonus per sub). Button updates ~2 times across the session.
- Strict-gate would emit exactly 2 CHANGED on the wire (one per sub). Button still updates 2 times.
Either mode results in the same Kia UI behaviour, because Kia's button updates are bounded by Kia's subscription count, not our CHANGED count — Kia treats unsolicited CHANGEDs (without a matching pending NOTIFY) as protocol noise.
The 14% delivery rate IS the IPC-saturation symptom; it's not a separate failure — the session-long firing rate is what fills the IPC queue. Strict-gate would emit at the natural CT-driven rate, which is well within IPC throughput.
Spec-compliance fix
Re-introduce the strict §6.7.1 gate-clear:
- T9 PLAYBACK_STATUS_CHANGED CHANGED: clear state[14] after emit.
- T9 PLAYER_APPLICATION_SETTINGS_CHANGED CHANGED: clear state[15] after emit.
- T9 PLAYBACK_POS_CHANGED CHANGED: clear state[13] after emit.
- T9 NowPlayingContent CHANGED: clear state[20] after emit.
- T5 TRACK_CHANGED CHANGED: clear state[16] after emit.
Existing implementation in 6503c87 can be cherry-picked; the _emit_subscription_write helper already supports the fd_reg parameter for callee-saved register choice.
Trade-off: with strict gate, a CT that really does treat sub as sticky (doesn't re-register) would receive only the first CHANGED. We'd lose that CT's compensation. But the CT test matrix doesn't include any such CT (Kia, Bolt, Sonos, Pixel-mirror all spec-compliantly re-register). And empirically the IPC drop kills the sticky-CT compensation anyway.
What the upcoming T9connfd capture confirms
Three possible outcomes; this RE narrows it to one.
T9connfd= value pattern | Interpretation | Confirms |
|---|---|---|
All == same large nonzero (e.g. T9connfd=0000000d) | FD is the session-long AF_UNIX SOCK_DGRAM FD | Drop is at send() returning -1 (kernel datagram buffer overflow) |
| All == 0 | A second struct path exists; the FD field is being cleared between emits | RE the second path (would invalidate the enableNative/0x36c0 finding) |
| Varies non-zero | Multiple conn structs in play | Investigate further |
Outcome 1 is the predicted result. The companion T9rsprc=%u log (commit baaf496) captures the response builder's return value: 0 = wire frame sent, 1 = send() returned -1 (silent drop). Together they pin the drop site to the kernel-level datagram queue.
dual-kia-20260515-2215 capture (commit f8fe647) — empirical results
The full debug-log + btlog capture brought back definitive data that overturns the AF_UNIX SOCK_DGRAM IPC-drop hypothesis:
| Signal | Value |
|---|---|
T9emit pstat=1 | 22 |
T9emit pstat=2 | 22 |
T9connfd= | 0x0000002f (FD=47), unchanged across all 44 emits |
T9rsprc= | 0 (success) across all 44 emits |
T8reg ev=01 | 2 |
Wire TX CHANGED L2CAP=15 (event 0x01 or 0x06) | 8 |
Interpretations confirmed:
- conn[8] is session-long FD=47. Confirms
enableNativeRE: AF_UNIX SOCK_DGRAM FD opened once at AVRCP service enable, persistent. Thecbz r6, 0x18bcguard inBT_SendMessagenever trips. AVRCP_SendMessagealways returns 0 (success).send()on the AF_UNIX SOCK_DGRAM socket succeeds for all 44 emit attempts. The kernel queues every datagram into mtkbt's recv socket buffer.
Hypothesis overturned: the drop is NOT at the kernel datagram queue. mtkbt receives every datagram. mtkbt itself drops 36 of 44 frames before forwarding to the BT chip.
Real drop site: mtkbt-side AVRCP TG state machine
mtkbt's IPC handler reads our AVRCP response datagram, runs it through the chain documented in Trace #34:
IPC msg=544 → fcn.00067768 → fcn.000518ac → fcn.00012478 → per-event handler
→ fcn.000121d8 → fcn.00011894 → fcn.0000f0bc → fcn.0000ef08 (UART wire write)
Somewhere in that chain (most likely fcn.000121d8 or fcn.00011894 — the ones nearest the wire-write) mtkbt checks per-event AVRCP TG state ("is there a pending RegNotif sub for event X?") and silently drops the frame if no match. This is mtkbt enforcing AVRCP §6.7.1 strict semantics on its side, regardless of what our trampolines send.
Fix architecture (corrected)
The strict-gate cherry-pick (6503c87) would not change wire-side behaviour — mtkbt is the gate, not our trampolines. Reducing our emit rate to spec-compliant levels is still good hygiene (less pointless IPC traffic), but it doesn't unstick Kia's button.
The real fix paths are:
- Patch mtkbt to NOP its drop-gate. Same shape as the M1 patch — find the byte sequence in fcn.000121d8 or fcn.00011894 that gates wire emission on TG-side pending sub state, and either NOP the check or rewrite the cmp constant. Precedent exists; this is the same RE territory as Trace #34/35/36.
- Synthetic re-arm via IPC. Before each T9 CHANGED emit, send an additional IPC message that emulates "CT just sent RegisterNotification(event=X, transId=Y)" so mtkbt allocates a fresh pending response slot. mtkbt's chain accepts our subsequent CHANGED, forwards to wire. Requires understanding the IPC msg-id for inbound RegNotif from CT direction (different from our outbound msg=544).
(1) is the cleaner fix once the byte-level gate is located.
Why the IPC-drop hypothesis was wrong
The model I derived from RE'ing BT_SendMessage was correct as far as the libextavrcp.so layer goes — cbz r6, 0x18bc does drop frames if conn[8]==0, and send() failures do propagate through AVRCP_SendMessage's cmp r0, 0; bge return; movs r0, 1. Both observations are accurate.
But neither matches the empirical data. The capture shows neither cbz r6 trip (FD always non-zero) nor send() failure (rsprc always 0). The drop is downstream of send(), in mtkbt — a layer the libextavrcp.so RE doesn't see.
The T9connfd= and T9rsprc= instrumentation was load-bearing here: without it, I would have committed to the wrong fix layer based on plausible-but-wrong RE.
Additional context: 18% delivery is system-wide, not pstat-specific
Same dual-kia-20260515-2215 capture across all events:
| Event | T9emit count | Wire CHANGED count (TX) | Delivery rate |
|---|---|---|---|
| 0x01 PSTAT (L2CAP=15) | 44 | 8 | 18% |
| 0x05 POS (L2CAP=18) | 193 | 17 | 9% |
| 0x03/0x04 REACHED_END/START (L2CAP=14) | n/a (T5-driven) | 9 | n/a |
The pattern isn't strict §6.7.1 one-shot (would predict 2 wire CHANGED for event 0x01 = matching 2 subs; we see 8). It's not TL contention (all wire frames carry TL=0; AVCTP allows queueing). It's not IPC overflow (T9rsprc=0 always). It's not cbz guard (T9connfd=0x2f always non-zero).
The empirical signature is rate-limit-shaped — roughly the same ~10-20% delivery across event types, regardless of how many subs the CT issued or how many emits we attempted. Most likely candidates for the actual gate:
- mtkbt has an internal per-event rate limit (e.g., max 1 wire frame per N ms per event_id) to prevent flooding the BT chip's AVCTP outbound queue. Our session-long T9 emits at the music-app's broadcast rate (multiple per play/pause cycle), exceeding the limit.
- mtkbt has an AVCTP transaction-state queue (limited entries) and drops frames when the queue is full. Our trampolines emit too fast for the queue to drain.
- mtkbt's mPlayStatus / mTrackInfo dedup at the per-event handler — checks "is the value being emitted different from the last one I sent on the wire?" and drops if not. (Less likely given pos varies continuously.)
Either way, the drop is inside mtkbt's TG state machine, downstream of send() / IPC recv. Finding the exact byte-level gate requires RE'ing mtkbt's per-event handlers (fcn.0x122cc for event 0x01, fcn.0x12354 for event 0x05, etc.) and the wire-write chain (fcn.0xf0bc → fcn.0xef08).
Open RE next steps
- Find the byte sequence in mtkbt's chain that gates wire emission. Candidate sites:
fcn.0x12478per-event dispatch entryfcn.0x121d8ctype dispatch (the M1 patch site)fcn.0x11894middle-layer (calls fcn.0xf0bc)fcn.0xf0bcwrites to[r4, 0x310](look like queue-manager) and checks[r4, 0x528]— possible drop checkfcn.0xef08wire-write last layer
- Empirical verification: if mtkbt has a debug build / tracing that we can enable, we could see the drop directly.
- Alternative: instrument our trampolines to send ZERO emits for a period and observe whether mtkbt stops sending wire CHANGEDs immediately or has a queued-up backlog. Tells us if the gate is at IPC-queue level or per-frame.
Strategic update
The strict-gate cherry-pick (6503c87) is not the fix for this regression — mtkbt drops at its own layer regardless of our emit rate. But strict-gate is still good hygiene: emitting ~28 attempts when mtkbt only forwards 4 wastes IPC bandwidth and slows the response builder under play/pause bursts. Worth applying eventually for spec correctness, but it doesn't unstick Kia's button.
The unstick fix has to be at the mtkbt layer — same precedent as the M1 patch family.
Drop site located: two-gate chain inside mtkbt's chip-write path
After mapping the full chain fcn.0xf0bc → fcn.0xed50 → fcn.0x6d048 → fcn.0x6df20 → fcn.0xae5e4 → fcn.0xae418, the actual drop sites are two consecutive gates that both report "success" upward while silently bypassing the wire-write build:
Gate 1 — fcn.0x6d048 at file offset 0x6d06e:
0x6d05c: cmp r4, 0
0x6d05e: beq 0x6d0dc ; return 0x12 if r4 == 0 (no ctx)
0x6d060: ldr r0, [pc + ...] ; r0 = &g_active_conn_list
0x6d062: mov r1, r4 ; r1 = conn
0x6d066: ldr r0, [r0]
0x6d068: bl 0x6ccdc ; r0 = list_contains(list_head, conn)
0x6d06c: cmp r0, 0
0x6d06e: beq 0x6d0e0 ; *** drop if conn not in list, returns 0xd ***
0x6d070..0x6d0d0: build wire frame at conn[0xc8..0xd4]
0x6d0d2: mov r0, r4
0x6d0d8: b.w 0x6df20 ; tail-call to gate 2
fcn.0x6ccdc is a doubly-linked-list contains primitive. Returns 1 if r1 (conn pointer) is in the linked-list anchored at r0. The list is *(0xf99XX) (mtkbt's "active outbound chip-write channels"); items get added via fcn.0x6cd18 and presumably removed when a chip-write completes (or when the L2CAP channel state transitions).
Gate 2 — fcn.0x6df20 at file offset 0x6df3a:
0x6df20: push {r4, lr}
0x6df28: mov r4, r0
0x6df2a: ldrb r3, [r0, 0xf2] ; r3 = ctx[0xf2] = "chip-write busy" flag
0x6df36: ldrb r3, [r4, 0xf2] ; r3 = ctx[0xf2] (re-read after log)
0x6df3a: cbnz r3, 0x6df52 ; *** drop if busy flag set, returns 0xb ***
0x6df3c..: set ctx[0xf2] = 1 (mark busy), tail-call to fcn.0xae5e4 (chip send)
ctx[0xf2] is set to 1 at 0x6df42 (just before the actual chip-send tail-call) and cleared to 0 at 0x6da10 (inside the send-completion handler at fcn.0x6d9b8). Between set and clear, any new emit attempt sees the flag and drops.
Why fcn.0xf0bc's queue doesn't catch this
fcn.0xf0bc's "queue path" at 0xf210 triggers on ctx[0x310] != 0 OR ctx[0x528] != 0. Neither corresponds to ctx[0xf2]:
| Field | Purpose |
|---|---|
ctx[0x310] | "Current packet pointer" — set by fcn.0xf0bc itself on entry, cleared on exit |
ctx[0x528] | "Fragmented packet in progress" — set when wire emission returns 2 (need continuation), cleared on completion |
ctx[0xf2] | "Chip-write in flight" — set by gate 2 just before chip-write, cleared by completion handler |
Short packets (PSTAT, REACHED_END/START — anything with single-AVCTP-fragment payload) take fcn.0xf0bc's fast path (since ctx[0x310] is briefly set/cleared per-call and ctx[0x528] never sets for non-fragmented frames). The fast path calls fcn.0xed50 → fcn.0x6d048 → fcn.0x6df20. Gate 2 then drops if ctx[0xf2] is set — which it is, whenever the chip-write of the previous packet hasn't completed.
The empirical pattern matches: ~10-20% delivery = ratio of time the chip-write completes before the next emit arrives, vs time the busy flag is set. Concurrent A2DP saturates the chip-write queue, so ctx[0xf2] stays set most of the time.
Why the EXISTING M1 patch doesn't help
M1 (0x12230: cmp r1, 1 → cmp r1, 0x0F) is upstream of these gates — it fires at fcn.0x121d8 which dispatches INTERIM vs CHANGED ctype before reaching fcn.0x11894 → fcn.0xf0bc. M1 ensures correct ctype on the wire when the frame reaches the wire, but doesn't change the drop characteristics.
Two candidate patches
P-MTK-1 — NOP gate 1:
| Site | Offset | Before | After |
|---|---|---|---|
fcn.0x6d048:0x6d06e | 0x6d06e | 37 d0 (beq 0x6d0e0) | 00 bf (nop) |
Removes the list-contains check. Wire frame is built and tail-call to gate 2 always happens. Doesn't fix the actual problem (gate 2 still drops), but eliminates the first early-exit.
P-MTK-2 — NOP gate 2:
| Site | Offset | Before | After |
|---|---|---|---|
fcn.0x6df20:0x6df3a | 0x6df3a | 53 b9 (cbnz r3, 0x6df52) | 00 bf (nop) |
Removes the busy-flag check. Chip-send is always called, even if previous send hasn't completed. HIGH RISK — could:
- Corrupt mtkbt's per-channel chip-write state
- Cause overlapping UART writes (depending on what fcn.0xae5e4 / fcn.0xae418 actually do)
- Hang mtkbt if the chip-side queue overflows
P-MTK-3 (preferred) — Replace gate 2 with a queue insert:
Rather than NOP, redirect the drop to fcn.0x6cd18 (list-add to a pending queue). Inserts our packet into a per-conn pending list when chip is busy; queue gets drained by the completion handler when it clears ctx[0xf2].
The fcn.0xf0bc queue path (0xf210) is exactly this shape, but at a higher layer. The proper fix moves the queueing down so it covers short-packet emits too. This would be a multi-instruction patch, larger than M1.
Cross-reference for fix design
| Question | Answer |
|---|---|
Where is g_active_conn_list (used by gate 1)? | *(0xf99XX) — exact address pending verification. Items added via fcn.0x6cd18, removed via similar primitive nearby. |
Where is ctx[0xf2] cleared? | fcn.0x6d9b8 callback at 0x6da10 — only in one of several exit paths (when r3=3 is selected). Other completion paths don't clear it. |
Who calls fcn.0x6d9b8? | Likely the IPC completion event handler (when chip ACKs the wire write). Needs further RE if P-MTK-3 is chosen. |
Strategic recommendation
P-MTK-2 (NOP gate 2) is empirically the simplest test of whether bypassing the busy flag fixes the drops. Risk is real but bounded — worst case mtkbt becomes unstable for ~10 seconds until next reboot, which is acceptable for a diagnostic patch.
P-MTK-3 (proper queueing) is the production fix but requires:
- Identifying the queue node structure expected by
fcn.0x6cd18 - Inserting allocator + queue-add code at gate 2
- Verifying the completion handler drains correctly
Order of work:
- Apply P-MTK-2 as
--debug-only diagnostic patch. - Capture: if PSTAT delivery jumps to ~100%, gate 2 is the right target.
- Design P-MTK-3 if (2) confirms.
Correction: P-MTK-2 (NOP gate 2) is unsafe — would corrupt state
Closer look at fcn.0xae5e4's prolog shows it writes the packet pointer to ctx[0x10] (relative to its own arg0, which is ctx_orig + 0x14 per fcn.0x6df20:0x6df46):
0xae5e8: mov r4, r0 ; r4 = ctx_orig + 0x14
0xae5ea: ldrh r0, [r0, 0x60] ; r0 = ctx[0x60] (MTU)
0xae5ee: mov r5, r1 ; r5 = packet
...
0xae60a: str r5, [r4, 0x10] ; ctx_orig[0x24] = packet
If we NOP gate 2 and two fcn.0xae5e4 calls happen concurrently with the same ctx_orig, both write to ctx_orig[0x24]. The second overwrites the first → first packet is silently lost AND the in-flight chip-write may be confused mid-transaction. This isn't a fix — it just moves the drop one layer deeper while corrupting state.
So the candidates collapse to:
| Candidate | Status |
|---|---|
| P-MTK-1 (NOP gate 1) | Doesn't fix anything (gate 2 still drops) |
| P-MTK-2 (NOP gate 2) | Unsafe — corrupts in-flight chip-write state |
| P-MTK-3 (proper queue insert at gate 2) | Production fix; multi-instruction; requires further RE of completion handler |
| P-Y1-rate-limit (Y1-side throttle) | Add rate limit in T9 trampoline: skip emit if <N ms since last emit |
| P-Y1-strict-gate (re-introduce 6503c87) | Strict §6.7.1 gate clear after CHANGED: emit only on CT-driven re-register; reduces our emit rate to whatever the CT requests |
The Y1-side options are simpler and don't risk mtkbt instability. Trade-offs:
- P-Y1-rate-limit: limits emit rate to match mtkbt's chip-write capacity. Requires clock_gettime in the trampoline (precedent: T6 / T9 already use it for live-position math). Set rate to ~1 emit per 100-200ms — enough to clear chip-write busy between attempts but fast enough to feel responsive.
- P-Y1-strict-gate: cherry-pick
6503c87. Emits one CHANGED per CT subscription, full stop. For a CT that re-registers at 1 Hz (Bolt, Sonos): 1 CHANGED per second. For Kia (re-registers ~2× per 5 min): 2 CHANGEDs per 5 min. Spec-correct but Kia gets very few updates.
A hybrid is possible: re-introduce strict-gate BUT also rate-limit. For non-re-registering CTs, fire emits at 1/sec for the first ~5 seconds after a transition (giving the CT time to notice and re-subscribe), then quiesce.
This is now a design decision rather than a discovery question. The RE has located the drop definitively; the fix shape depends on which CTs we want to optimize for and what protocol-deviation cost we'll accept.
Open: P-MTK-3 implementation sketch
If the user prefers a mtkbt-side fix:
- Replace
cbnz r3, 0x6df52(gate 2) withbne <queue_insert_thunk>. The thunk:- Loads the per-conn pending queue head from
ctx[0xb0](offset TBD by RE) - Calls
fcn.0x6cd48to add the packet - Returns 0xb (same as drop) so caller's bookkeeping works
- Loads the per-conn pending queue head from
- Patch
fcn.0x6d9b8(the send-completion handler that clearsctx[0xf2]) to drain the per-conn queue when ctx[0xf2] clears. - Allocate the per-conn queue head in stock data — likely possible via mtkbt's existing list-init infrastructure.
This is ~50-80 bytes of injected code, similar in scope to extended_T2. Likely fits in mtkbt's padding regions but needs verification.
Trace #40 implementation (2026-05-16)
Both candidate fixes from the strategic analysis landed in two commits:
Commit 00f4817 — mtkbt M2 / M3 (P-MTK-3 simplified):
| Patch | Offset | Before | After | Role |
|---|---|---|---|---|
| M2 | 0x6d06e | 37 d0 (beq 0x6d0e0) | 00 bf (nop) | Bypass list-contains drop gate in fcn.0x6d048 |
| M3 | 0x6df42 | 84 f8 f2 00 (strb.w r0, [r4, #0xf2]) | 00 bf 00 bf (nop; nop) | Disable chip-busy flag SET so the CHECK at 0x6df3a never trips |
Net effect: every T9/T5 CHANGED emit reaches the wire. 100% delivery instead of the ~18% baseline. Stock 3af1d4ad8f955038186696950430ffda → Output 2b0bffeb6d29ff2ba75cf811688ec0ef.
Rationale for the M3 NOP-the-SET (not NOP-the-CHECK): two concurrent emits inside fcn.0xae5e4 would race on ctx_orig[0x24]. But mtkbt's IPC dispatcher is single-threaded, and the downstream chain fcn.0xae5e4 → fcn.0xae418 → fcn.0x50918 → mtk_bt_write is a synchronous blocking UART write. So no concurrent emits actually materialise; the flag was a safety check for a race that can't happen under mtkbt's threading model.
Commit 7acd7bd — T5+T9 §6.7.1 strict gate clearing (P-Y1-strict-gate):
Re-applied the strict-gate clears from commit 6503c87 (which was bare-reverted in b1c15a9 on the original day). Trace #40 made it clear that the revert was misdirected — Kia's stuck button wasn't caused by strict-gate's lower emit rate, it was caused by mtkbt silently dropping ~80% of CHANGED emits regardless of emit rate.
With M2/M3 fixing the drops, strict-gate becomes a pure spec-compliance win: emits exactly one CHANGED per CT-re-register, no spam, no IPC saturation. Wire-side cadence becomes min(TG_tick_rate, CT_re-register_rate).
| Clear site | State byte | Event |
|---|---|---|
| T5 after TRACK_CHANGED CHANGED | state[16] | 0x02 |
| T9 after PSTAT CHANGED | state[14] | 0x01 |
| T9 after PApp CHANGED | state[15] | 0x08 |
| T9 after POS_CHANGED tick | state[13] | 0x05 |
T5's POS_CHANGED on track edge intentionally doesn't clear (T9's PositionTicker owns the re-register loop). T5's NowPlayingContent and TRACK_REACHED_END/START also don't clear (the gates are rarely armed by CT test matrix; extra clears would be ~56 B of dead code each).
Release blob: 3552 → 3784 B. Stock libextavrcp_jni.so fd2ce74db9389980b55bccf3d8f15660 → Output d803f42c973bf9539f4d03ccb658cab3.
Combined behaviour (M2 + M3 + strict-gate)
For each CT in the test matrix:
| CT | Re-register cadence | Wire CHANGED count for event 0x01 in a 5-min play/pause session | Pre-fix delivery | Post-fix delivery |
|---|---|---|---|---|
| Bolt | ~1 Hz | ~30 (1 per re-register × ~30 re-registers) | partial (~4) | 100% (30) |
| Sonos | ~1 Hz | ~30 | partial | 100% |
| Pixel-mirror | ~1 Hz | ~30 | partial | 100% |
| Kia | ~2 / 5 min | 2 (1 per re-register × 2) | 8 (some duplicates leaked through) | 2 spec-correct |
For Kia, post-fix wire count is LOWER (2 vs 8) but every CHANGED is guaranteed delivery and spec-correct. If Kia's button responsiveness depends on the CHANGED count rather than the re-register matching, this would be a regression. If Kia's button responsiveness depends on spec-correct one-shot semantics (i.e., it ignores unsolicited CHANGEDs anyway), the post-fix behaviour is equivalent.
If Kia turns out to need MORE CHANGEDs than its own re-register rate provides, the next iteration would be P-Y1-rate-limit — emit at ~1/sec for ~5 seconds after a state edge, then quiesce. Falls between strict-gate and the pre-revert "session-long forever" behaviour.
Closing this trace pending empirical validation of Kia post-flash. The discovery question (where does the drop happen?) is answered; the fix question (which combination is best?) requires CT-side observation.
Trace #40 closure (2026-05-16) — car-test validated
dual-bolt-20260516-0810 (subscription-driven CT) + dual-kia-20260516-0808 (polling-driven CT) + driver-seat verification:
| CT | Behaviour | Result |
|---|---|---|
| Bolt | Re-registers ev=01 14× in session. T9emit pstat = 7 (matches edges within min(T8reg, edges)). Wire TX INTERIM + CHANGED RegNotif visible despite btlog under-sampling. PASSTHROUGH ACCEPTED on every command. | Play / pause + next / prev work; playhead stable. |
| Kia | Zero RegisterNotification subscriptions this session. T6resp = 216 (Kia polling ~1/sec). 7 STABLE GetElementAttributes responses. 9 PASSTHROUGH ACCEPTED. | Play / pause + next / prev work; playhead stable. |
The Kia path that works is pure polling (T6 GetPlayStatus + GetElementAttributes), independent of every notification-side patch landed in this trace. Strict-gate is irrelevant for Kia because Kia never subscribes. M2/M3 are irrelevant for Kia because Kia's polled responses don't go through the outbound-frame builder gates (those gate only TG-initiated CHANGED frames, not synchronous responses to CT requests).
The 18% delivery rate observed throughout Trace #40 is most likely a btlog sampling artifact, not a real drop. Empirical proof in dual-sonos-20260516-0758: Sonos re-registered ev=05 73 times, which per §6.7.1 means Sonos received 73 CHANGED frames. btlog visible: 9. → btlog captured ~12% of wire traffic. The "drop" measured by expected_emit_count / wire_frame_count is the inverse of btlog's sampling rate, not the wire delivery rate.
Implications for the M2/M3 patches: most likely no-ops. The chip-readiness list-check (gate 1) and chip-busy flag (gate 2) in mtkbt's outbound-frame chain were rejecting frames that btlog wasn't capturing anyway; the actual wire delivery was already ~100% via paths btlog under-samples. M2/M3 don't regress anything (the chain is sync, single-threaded — no race) but they remove safety margins that weren't measurably failing. Keeping them in the release: they're verified harmless, they're documented in PATCHES.md, the docs are honest about the sampling-artifact theory, and reverting would require another patcher MD5 update.
Implications for strict-gate: validated correct for subscription-driven CTs (Bolt, Sonos, Pixel-mirror), irrelevant for polling-driven CTs (Kia in observed sessions). The "Kia gets only 2 CHANGED" concern was based on a metric (wire-visible CHANGED count) that's a btlog-sampling artifact; Kia's actual UI behaviour in this session was driven by polling, not by the CHANGED count.
Trace #40 closed: the Kia stuck-button regression resolved by the combined work even though the exact causal mechanism remains under-determined (likely a mix of TrackInfoWriter fast-path improvements + strict-gate hygiene + the position-fix work). Future Kia-specific debugging should focus on T6 freshness (file[792] / file[780-787] / file[776-779]) since that's the wire path Kia actually uses.
Trace #41 (2026-05-16) — Subscription-class CT metadata-pane regression: twin outbound-frame builder Path B is unpatched; M4 fix
Symptom. A subscription-class CT (Chevrolet Bolt EV — see dual-bolt-20260516-1453 for the load-bearing capture) displayed metadata for only 3 of 32 tracks played across a ~2.5 min driver-seat session. Y1 music app behaved correctly (track changes, play/pause, all logged). The 3 displayed tracks were not adjacent in playback order, not at session boundaries, and the metadata-success/failure cadence didn't correlate with screen wake events (user explicitly verified by waking the screen mid-session on failed tracks). PASSTHROUGH worked on every press. Title / Artist / Album / Genre / track-number / duration all appeared when they appeared.
Hypotheses ruled out.
- Screen-off / Y1Bridge cascade. Refuted by user waking the Y1 screen on a non-displaying track and seeing no metadata update.
- AVRCP 1.3 attribute corruption. The 3 displayed tracks contained varied attribute lengths, characters, and zero-pad alignment; nothing distinguished them structurally from non-displayed tracks.
- Path-A (M2/M3) regression.
msg=540GetElementAttributes IPC emits → wire TX frames mapped 3:3 — everymsg=540reached the wire. The 3 displayed tracks were each emitted viamsg=540opportunistically (not via subscription). - MtkBt.odex cardinality gates. Already NOP'd;
MMI_AVRCPlogged 1,542ACTION_REG_NOTIFY ... cardinality:0lines confirming the JNI was firing the natives but the Java callback table was empty (downstream of the root cause, not the root cause itself). - Stale btlog sampling artifact (Trace #40 closure pattern). Refuted by direct correlation between IPC emit count and
MMI_AVRCP cardinality:0log volume — the missing wire frames are real, not a sampling artifact, because subscription confirmation never happens.
Wire-level evidence. In the same dual-bolt-20260516-1453 capture:
| IPC msg | Path | Logcat emits | Wire TX frames | Delivery rate |
|---|---|---|---|---|
msg=540 GetElementAttributes (STABLE 0x0C) | Path A (M2/M3-patched) | 3 | 3 | 100% |
msg=544 RegNotif response (INTERIM 0x0F / CHANGED 0x0D) | Path B (unpatched) | 117 | ~7 | ~6% |
Static dispatch trace. fcn.0xf0bc (outbound AVRCP frame dispatcher) splits at ldrb r3, [r6, #9]; cbz r3, 0xf186:
r3 != 0(byte[9] != 0) → Path A:fcn.0xed50 → fcn.0x6d048 → fcn.0x6df20 → fcn.0xae5e4 L2CAP_SendData. This is the fragmented multi-frame send path; M2 NOPsfcn.0x6d048's list-contains drop gate at0x6d06e, M3 NOPs the chip-busy SET infcn.0x6df20at0x6df42. Carriesmsg=540-class responses.r3 == 0(byte[9] == 0) → Path B:fcn.0xef08 → fcn.0x6d0f0 → b.w 0xae5e4 L2CAP_SendData(tail-call direct, nofcn.0x6df20intermediate). This is the short single-PDU send path. Carriesmsg=544-class responses. Unpatched before this trace.
Empirical confirmation that the JNI marshaller writes byte[9]=0 for msg=544 and non-zero for msg=540: log analysis of the dual-bolt-20260516-1453 capture shows perfect path separation by IPC msg field — every msg=544 IPC emission is followed by a Path-B selection in mtkbt, every msg=540 IPC emission is followed by a Path-A selection.
Structural identity of the two builders. fcn.0x6d0f0 is byte-for-byte structurally identical to M2's fcn.0x6d048:
offset (Path A / fcn.0x6d048) | offset (Path B / fcn.0x6d0f0) | role |
|---|---|---|
0x6d068 bl 0x6ccdc | 0x6d110 bl 0x6ccdc | list-contains check against g_active_conn_list |
0x6d06e beq 0x6d0e0 | 0x6d116 beq 0x6d19c | drop gate (returns rc=0xd) |
0x6d076 cmp r6, #0x0F | 0x6d11e cmp r6, #0x0F | INTERIM/CHANGED discriminator |
0x6d0e0 movs r0, 0xd; pop | 0x6d19c movs r0, 0xd; pop | drop epilogue |
Fix — M4. NOP beq 0x6d19c at 0x6d116 (2 bytes, 41 d0 → 00 bf). After M4, fcn.0x6d0f0 unconditionally builds the wire frame and tail-calls b.w 0xae5e4. No M3-analogue is needed on Path B because fcn.0x6d0f0 skips fcn.0x6df20 entirely (the chip-busy SET only exists in fcn.0x6df20).
Safety. Same reasoning as M2 — the list-contains state was a chip-readiness heuristic, not a correctness check. mtkbt's IPC dispatcher is single-threaded, fcn.0xae5e4's downstream chain (fcn.0xae418 → fcn.0x50918 → mtk_bt_write) is synchronous blocking UART write, no concurrent emits race on per-channel state.
Why subscription-class CTs disengage. AVCTP V13 §3.3.5 specifies a 3 s response timeout per outstanding transaction. A subscription-class CT (e.g. Bolt) sends RegisterNotification COMMANDs for ev=01 PLAYBACK_STATUS_CHANGED, ev=05 PLAYBACK_POS_CHANGED, ev=08 PLAY_TRACK_REACHED_END, ev=0A NOW_PLAYING_CONTENT_CHANGED on initial AVRCP TG engagement. Without INTERIM responses landing within the 3 s window, the CT retries each subscription. After several retries (CT-implementation-specific, observed 7-14 in the Bolt capture) the CT disengages AVRCP TG and gives up on subscription-based updates entirely. From that point on, only opportunistic msg=540 GetElementAttributes responses driven by track changes reach the CT — explaining the "3 of 32 tracks displayed" pattern (whichever 3 tracks triggered a msg=540 emit in the narrow window before the CT disengaged).
Why polling-class CTs were unaffected pre-M4. A polling-class CT (e.g. Kia) does not depend on RegNotif subscriptions at all — it polls GetPlayStatus + GetElementAttributes (both msg=540-class) at its own cadence. Path A was patched; Path B drops were invisible to polling CTs. This is why Trace #40 closure observed Kia working fine on the same firmware that left Bolt broken — they exercise different builders.
Why this wasn't caught in Trace #40. Trace #40 derived M2/M3 from observed wire-side drops on msg=540 Path A under a different CT class. The msg=544 Path B path was never explicitly profiled in #40 because the captures used for #40 were polling-class CTs (Kia) and a subscription-class CT (Sonos) on a build where msg=544 delivery was confounded by the cardinality-0 gate (which was a separate bug fixed elsewhere). With msg=544 actually reaching the IPC layer in current builds, the Path B drop became observable.
Confidence. High on byte-level mechanism (the fcn.0xf0bc dispatch + the structural identity of the two builders is direct static analysis, independent of any wire-side measurement). High on Bolt as primary affected CT (load-bearing capture). Medium on whether other subscription-class issues (notably the Sonos Album metadata regression observed in dual-sonos-postflash) are also resolved by M4 — Sonos's msg=544 delivery rate is higher in the current captures (~25-37% vs Bolt's ~6%), so Sonos may have a separate root cause. Recommend treating Sonos Album as a follow-up after M4 hardware validation.
Status: Patch staged in src/patches/patch_mtkbt.py. New OUTPUT_MD5 = a10ca9636417a0ed71495dfa11b5eff0. Pending hardware validation on a subscription-class CT.
Trace #42 (2026-05-16) — Static audit for additional silent-drop gates in mtkbt outbound paths
Goal. After M4 landed (Trace #41), do a static-only sweep of every outbound-frame builder, dispatcher, and L2CAP send wrapper in mtkbt to find any other gates with the M2/M3/M4 signature: a conditional that returns an error code, doesn't log surface-visibly, and the caller treats the return as drop-and-forget rather than retry-or-error. Stock binary mtkbt MD5 3af1d4ad8f955038186696950430ffda (extracted via debugfs from /work/koensayr/staging/v3.0.7_sysimg/system-raw.img).
Method. Disassemble (radare2) every function in the outbound chain: dispatchers (fcn.0xf0bc, fcn.0xf290), high-level emitters (fcn.0x1165c, fcn.0x11778, fcn.0x11894, fcn.0x119fc), frame builders (fcn.0xed50, fcn.0xef08), frame finalizers (fcn.0x6d048, fcn.0x6d0f0, fcn.0x6d1a8), AVCTP/L2CAP wrappers (fcn.0x6df20, fcn.0xae5e4, fcn.0xae418, fcn.0xae6ac), and RX-side dispatchers (fcn.0x6cee4, fcn.0x6cf30, fcn.0x6cf8c). Byte-search for the M-series gate signatures: the bl fcn.0x6ccdc + cmp r0, 0 + beq <drop> list-contains pattern, movs r0, 0xd; pop drop epilogues, and strb.w r0, [r4, 0xf2] chip-busy SET.
Three-tier dispatcher discovered. mtkbt's outbound side has TWO dispatchers (not one), selected by msg[8]:
msg[8] | Dispatcher | Emitters that build this | Builder | M-coverage |
|---|---|---|---|---|
| 0, 1, 3 | fcn.0xf0bc | fcn.0x1165c (14-byte short), fcn.0x11894 (up-to-512 long) | fcn.0x6d048 (Path A, msg[9]!=0) or fcn.0x6d0f0 (Path B, msg[9]==0) | M2/M3 (A), M4 (B) |
| 2 | fcn.0xf290 | fcn.0x11778 (T_HandleErrorResponse), fcn.0x119fc (up-to-512 long) | fcn.0x6d1a8 (Path C) | unpatched |
fcn.0x119fc has 11 caller sites (per-PDU success-path emitters) and fcn.0x11778 is the project-wide error-response emitter — both route through Path C.
New gate inventory. Across the audited functions, every conditional-drop site that fits the M-series pattern:
| Tier | Site | Function | Mnemonic | Drop rc | Condition | M-series analogue |
|---|---|---|---|---|---|---|
| 2 | 0x6d1ce | fcn.0x6d1a8 (Path C — browse) | beq 0x6d242 | r0=0xd | fcn.0x6ccdc(conn-list) returns 0 | structural twin of M2 / M4, on browse path Y1 doesn't emit on |
| 2 | 0x6d1ec | fcn.0x6d1a8 (Path C — browse) | strb.w r3, [r4, 0xf2] (r3=1) | (sets chip-busy on browse chan) | msg[1] == 1 | structural twin of M3 SET, on browse path |
| - | 0xf2c4 | fcn.0xf290 (browse dispatcher) | bne 0xf384 | (queue, r5=2) | chan[0x558] != 0 (browse txPending) | NOT A DROP — branch target is queue-on-txBrowsePacketList, structurally analogous to fcn.0xf0bc's 0xf210 queue path |
| 2 | 0x6d12c | fcn.0x6d0f0 (Path B) | bne 0x6d19e | r0=1 | msg[0] == 0x0F AND msg[3] != 0 | none — defensive |
| 2 | 0xf2dc | fcn.0xf290 | ble 0xf370 → r5=0x12 | r5=0x12 | fcn.0x6d324(chan)+3 ≤ msg.len (computed MTU shorter than payload+AVCTP header) | none — bounded by spec |
| 3 | 0xf13e | fcn.0xf0bc (Path A length gate) | beq 0xf154 (drop fall-through, r5=1) | r5=1 | Path A (msg[9]!=0) AND msg.len vs fcn.0xed16 mismatch | empirically not load-bearing (msg=540 100% delivery confirmed in Trace #41) |
| 4 | 0x6cf04, 0x6cf58, 0x6cfb2 | fcn.0x6cee4, fcn.0x6cf30, fcn.0x6cf8c (RX-side) | beq <drop> | r0=0xd | fcn.0x6ccdc(conn-list) returns 0 | RX-side analogue of M2 / M4 |
Structural-identity verification of Tier-1 Path C gate. Bytes immediately preceding the beq <drop>:
| site | preceding bytes (PC-relative addr load, list-contains call, cmp) | drop byte |
|---|---|---|
0x6d06e (M2 / Path A) | 21 46 78 44 00 68 ff f7 38 fe 00 28 | 37 d0 |
0x6d116 (M4 / Path B) | 21 46 78 44 00 68 ff f7 e4 fd 00 28 | 41 d0 |
0x6d1ce (Path C) | 21 46 78 44 00 68 ff f7 88 fd 00 28 | 38 d0 |
Identical instruction sequence; only the branch displacement to each function's local drop epilogue differs. The Tier-1 Path C beq 0x6d242 is the literal byte-for-byte twin of M2 and M4 list-contains drops.
Hypothetical patches (would-be M5 / M6, not staged). Documented for future AVRCP 1.4+ Browsing channel work:
- (M5). NOP
beq 0x6d242at0x6d1ce(38 d0→00 bf). Structurally identical to M2 / M4. Would matter only if Y1 ever emits browse-channel responses. - (M6). NOP
strb.w r3, [r4, 0xf2]at0x6d1ec(4 bytes,84 f8 f2 30→00 bf 00 bf). Structurally identical to M3 SET. Required as a pair with (M5) if it lands. Note: the chan-struct used here (chan+0x10c) is distinct from Path A/B's chan-struct, sochan[0xf2]in the browse context is a separate flag from M3's primary-channel chip-busy. - M7 retracted.
0xf2c4 bne 0xf384is a queue path, not a drop. The branch target inserts the packet ontotxBrowsePacketListand returnsr5=2success.
Tier-1 reclassified to defensive after browse-channel discovery. Initial classification of M5 / M6 / M7 as Tier-1 was premature. Walking the chan[0x558] gate branch target (0xf384) and the surrounding context reveals that fcn.0xf290 exclusively handles the AVRCP Browsing channel (1.4+):
- The
0xf384branch is a QUEUE path (callsfcn.0x6cc70list-insert-tail and returnsr5=2success), NOT a drop. Same shape asfcn.0xf0bc's0xf210queue path —chan[0x558]is a queue-redirect flag, not a kill switch. - The list-circularity asserts inside
0xf384referenceIsListCircular(&chnl->txBrowsePacketList)at string offset0xc8a64— atxBrowsePacketListfield by name. fcn.0xf290writes tochan[0x540..0x570](a dedicated browse sub-struct), distinct fromfcn.0xf0bc'schan[0x308..0x320](primary AVCTP sub-struct).- Other AVRCP Browsing strings in the binary confirm separate Browse code path:
AVRCP_DisconnectBrowse status:%d(0xc8f48),[AVRCP] AvrcpHandleCBAVRCPBrowseCmdInd_Dispatcher pdu_id:%d parm_len:%d(0xc90a8),[AVRCP][BWS] Receive browse-packet operandLen:%d more:%d(0xdc076). fcn.0x6d1a8passeschan+0x10c(notchan+8like Path A/B) downstream — different L2CAP CID, different AVCTP layer, different channel.
The 11 callers of fcn.0x119fc (at file offsets 0x12678, 0x1273c, 0x1329e, 0x13342, 0x133ee, 0x134dc, 0x1364a, 0x15056, 0x151d4, 0x156a4, 0x15896) are therefore AVRCP Browsing PDU handlers — GetFolderItems, ChangePath, SetBrowsedPlayer, GetItemAttributes, and their error responses. None of these are emitted by Y1's current trampoline chain (per feedback_avrcp13_only_scope and the AVRCP 1.3-only project constraint).
Outcome. M5 / M6 / M7 are not load-bearing for current metadata delivery — Y1 does not emit on the browse channel. They are also out of scope per the AVRCP 1.3-only project constraint. Not staged. Re-classified Tier-2 (defensive only): would be required if Y1 ever extends to AVRCP 1.4+ Browsing channel support, at which point they should land together with the trampoline-side browse-channel work.
Tier-2 gates — defensive only.
0x6d12c(Path B INTERIM+opcode≠0):msg[0]==0x0F AND msg[3]!=0drops withr0=1. Won't fire under current trampoline emit semantics — T2/T8 INTERIM trampolines buildVENDOR_DEPENDENTframes (opcode=0). If a future patch ever emits INTERIM via a non-VENDOR_DEPENDENTopcode (e.g. PASSTHROUGH INTERIM, which is not a valid AVRCP construct anyway), this gate would silently drop it.0xf2dc(Path C MTU bound): drops if msg payload exceeds computed L2CAP MTU minus AVCTP header. Spec-bounded — would only fire on a malformed long-response from the marshaller.
Tier-3 — 0xf13e Path A length-vs-MTU mismatch (downgraded). Initial reading suggested this could drop Path A frames where msg[9]!=0 (fragmented marker) but msg.len < MTU. Empirically refuted by Trace #41 evidence: msg=540 IPC emits → wire TX frames mapped 3:3 (100% delivery) post-M2/M3. Either the gate's actual semantics are opposite of my initial read, or our marshaller's msg.len for msg=540 consistently satisfies the gate. Either way, not currently load-bearing.
Tier-4 — RX-side list-contains drops (speculative). fcn.0x6cee4 (0x6cf04), fcn.0x6cf30 (0x6cf58), and fcn.0x6cf8c (0x6cfb2) each call fcn.0x6ccdc(conn-list) and beq to a r0=0xd drop. These are upstream of the AVCTP RX state machine fcn.0x6d9ac and gate incoming AVCTP frames before they're dispatched. If the g_active_conn_list flicker that motivated M2/M4 affects RX too, then incoming RegisterNotification COMMANDs from the CT could be silently dropped — same end-user symptom (no metadata) but from the other direction (we never see the request rather than failing to deliver the response).
The RX-side list state might be more stable than the TX side (it's populated by the inbound L2CAP-CONNECT handshake, which the CT initiates; the chip-busy flicker that motivated M2's drop is a TX-side phenomenon). But this is hypothesis, not measurement. Wire-side probe: tshark-decode the captured BT traffic, count CT-side RegisterNotification COMMAND frames vs mtkbt-side INTERIM responses. Significant disparity post-M4 would point at one of these RX gates.
fcn.0xf0bc and fcn.0xae5e4 queue paths. Both have conditional branches that look drop-shaped (bne.w 0xf210 at fcn.0xf0bc:0xf106 if chan[0x310] (txCurrent) is non-zero; bne 0xae684 at fcn.0xae5e4:0xae608 if chan[0x16] (L2CAP send-pending) is non-zero). Both are NOT silent drops — they take queue paths (fcn.0x6cc70 list-insert tail; r6=2 return) that the dispatcher caller treats correctly. Verified by following the queue-path exit (movs r5, 2; pop) and confirming the caller's cmp r5, 2; bne discriminator handles the queued case.
Chip-busy flag (chan[0xf2]) write-site enumeration. strb.w r0, [r4, 0xf2] matches only at 0x6df42 (M3 site, already NOP'd). One additional site strb.w r3, [r4, 0xf2] at 0x6d1ec (Path C, captured above as Tier-1 M6 candidate). No other writers in the binary. M3's SET-NOP strategy holds for Path A; M6 would be required if Path C carries any metadata.
INTERIM-marker (chan[0xf0]) reads. Verified via byte-grep — two readers at 0x7e7a4 and 0x7ecf4 are both inside ittt/itttt blocks that compose multi-byte packed integer fields from chan[0xee..0xf1]. Not gates. chan[0xf0] is preserved data, not a control flag.
M1 pattern (narrow cmp discriminator widening) — re-audit. Searched for cmp r1, N followed by conditional branch in the 0x10000-0x16000 range (avrcputil.c-class functions). Only 0x12230 (the M1 site) matches the discriminator-widening shape. Other matches (0x138e2, 0x138f0, 0x138fc, 0x1392e, 0x1393e, 0x1394c) are length/count gates in an RX-side CMD-frame parser case-statement, not response-ctype discriminators.
Outcome. No new patches staged. The two would-be M5 / M6 sites in fcn.0x6d1a8 and the dispatcher gate at 0xf2c4 are all on the AVRCP Browsing channel (1.4+), confirmed by the txBrowsePacketList field name and the disjoint chan-struct offsets used by fcn.0xf290 vs fcn.0xf0bc. Y1's current trampoline chain does not emit browse-channel responses (per AVRCP 1.3 scope constraint), so these gates are unreachable from any Y1-emitted frame. M-series static coverage is now exhaustive for mtkbt's outbound TX path on the primary AVCTP channel; the browse channel is uncovered but also un-exercised. Three Tier-4 RX-side candidates documented for completeness — would only matter if the g_active_conn_list flicker affects inbound RegisterNotification COMMANDs from CTs, which requires wire-side measurement.
Confidence. High on byte-level identification of every conditional-drop site in the audited functions (direct static analysis, MD5-anchored stock binary). High on the browse-channel scoping for fcn.0xf290 → fcn.0x6d1a8 (the txBrowsePacketList source-level field name + disjoint chan offsets are direct evidence). Low on Tier-4 RX-side relevance — that requires wire-side measurement we don't have.
Trace #43 (2026-05-17) — Post-M4 hardware regression on subscription-class CT; L2CAP-layer audit reveals deeper drop sites
Symptom. User reflashed Bolt-class CT test rig with feature/bluetooth-metadata-fixes containing M2/M3/M4 patches. Bolt metadata-pane regression persists. M4's "structurally identical to M2/M4 list-contains gate" hypothesis was either wrong, partially right, or right-but-insufficient.
Re-examining Trace #41's wire-side claim. Per architecture_y1_btlog_undersampling memory note: btlog captures only ~16–30% of actual UART traffic, so the "117 IPC emits / ~7 wire frames / ~6% delivery" derivation in Trace #41 had a load-bearing measurement on the noisy side of the ratio. Real msg=544 wire-delivery rate could have been anywhere in [20–38%] pre-M4. M4 may have lifted it further or had no effect — we don't know without a btlog-independent measurement.
Static audit of the L2CAP layer below M4. Trace #42 stopped at fcn.0xae5e4 (L2CAP_SendData). Continuing downstream into fcn.0xae418 (fragment-build) → fcn.0x7d204 (L2CAP send entry) → fcn.0x7d034 (L2CAP send post-checks) → fcn.0x7cecc (queue insert) reveals six additional silent-drop gates beneath M4, of which one-to-three could plausibly fire under our metadata pipeline:
| Tag | Function | Site | Mnemonic | Drop rc | Condition | Log? |
|---|---|---|---|---|---|---|
| L1 | fcn.0x7d204 | 0x7d212 | beq 0x7d260 | r0=1 | fcn.0x83014(CID) returns 0 (CID-to-chan lookup miss: CID out of range, slot unused, or state==0) | no |
| L2 | fcn.0x7d204 | 0x7d21a | bne 0x7d260 | r0=1 | pkt[0xe] & 0xf6 != 0 (packet status-flag sanity mask) | no |
| L3 | fcn.0x7d204 | 0x7d23a | cbnz r0, 0x7d252 (drop on fall-through) | r0=1 | fcn.0x7cc7c(chan) returns 0 (channel state ∉ {4 with bit 1 of chan[0] set, 5, 13, 14, 15}) | yes — L2CAP_SendData state:%d return:%d |
| L4 | fcn.0x7d034 | 0x7d096 | blo 0x7d186 | r0=1 | chan[0x24] < (chan[0xc] + chan[0x1c] + pkt[0x2c]) when arg3 > 3 (buffer-size sanity) | no |
| L5 | fcn.0x7d034 | 0x7d0a6 | bhi 0x7d0b2 | r0=0x11 | channel state ∉ {3, 4, 5} (tighter than L3) | yes — l2cap: return no-connection state:%d |
| L6 | fcn.0x7d034 | 0x7d170 (logical) | cbz r0, 0x7d18a then fall-through-fail-log | r0=2 (success-shaped!) | fcn.0x7cecc(chan, pkt, ...) returns non-zero, which fires when pkt[0xfe] == 0 (BDS_DISC) | yes — l2cap QueueTx fail at cid:0x%x |
L6 is the most pernicious: even when the L2CAP queue-insert fails, the function returns r0=2 (success-shaped). Caller chain (fcn.0xae418 → fcn.0xae5e4 → builder twins) all check cmp r0, 2; bne <drop> — they see 2 and treat as success. The packet is gone but no one upstream knows. Note however the trigger condition is pkt[0xfe] == 0, which is a packet-state flag, not a queue-depth flag. So L6 is silent-on-fail but rare in practice.
L3 / L5 are the strongest "matches the M-series pattern" candidates. Both are chip-readiness checks: the L2CAP channel must be in a specific state set, otherwise drop. Under sustained A2DP saturation an AVRCP channel can briefly transition through states 6–11 (disconnect-pending, AMP move, etc.) — and any AVRCP TX during that window would silently drop. Both log, so we can directly test from existing logcat captures.
L1 / L2 / L4 are NOT safely patchable. L1 would deref null after the lookup miss. L2 sends malformed packets if the status mask is meaningful. L4 is a buffer-size sanity check; bypassing it could write past the L2CAP TX buffer.
L3 / L5 patch candidates (would be L3-patch / L5-patch, NOT staged):
- L3-patch.
cbnz r0, 0x7d252at0x7d23a(50 b9) → unconditionalb 0x7d252(0a e0). Skips the L2CAP state check; channel can be in any state passed by L1. Risk: medium — if channel is in state 6 (disconnect-pending), forcing a send onto a soon-to-be-closed channel could trigger unexpected behaviour infcn.0x7d034. - L5-patch.
bhi 0x7d0b2at0x7d0a6(04 d8) →00 bfNOP, falls through into the success path. Permits sends on any state including ≥6. Same risk profile as L3-patch but inside the next stage of the L2CAP pipeline.
Both patches would need to land together to bypass the dual-state check. Apply only L3 without L5 → L5 still drops most cases L3 was bypassing.
Other findings worth recording:
- qPacket pool is
AVRCP_NUM_TX_PACKETS=4per ctx, 2 ctx total = 8 packets system-wide (per startup log atfcn.0x13754:0x13798printing[AVRCP] AVRCP_NUM_TX_PACKETS:%d AVRCP_MAX_PACKET_LEN:%dwith literalsr1=4,r2=0x200). Under a 117-emit msg=544 burst, pool exhaustion is plausible. Exhaustion semantics infcn.0x6cd48 → fcn.0x6cd18: returns the list-head pointer (non-zero) rather than NULL when empty, so caller'scmp r4, 0; beq <drop>does NOT trigger — instead the caller treats the head-ptr as a qPacket and writes through it, which is memory corruption, not a clean drop. Field. If this fires in production, mtkbt's heap gets clobbered; the absence of daemon crashes argues the pool doesn't actually exhaust under our load — but it's a quiet hazard worth knowing. fcn.0xae6acdrains queue one-at-a-time per chip ACK. Setschan[0x16]=1per send, clears on ACK, dequeues next fromchan->txPacketListviafcn.0x6cd48. If ACK rate < emit rate, queue grows unboundedly (well, until the qPacket pool exhausts — see above). No silent drop here.chan[0x528](Path A/B txPending flag) writes: SET at0xf1f6(success), CLEAR at0xeb60(chan-struct init/reset) and0xfe48(fcn.0xfb04AVCTP-callback path — likely link-state-change handler). Maintained correctly under normal operation.
Strongest next-step diagnostics (btlog-independent): grep existing Bolt-session adb logcat capture for these strings; each tells us which (if any) gate is the actual blocker:
| String to grep | Hits = | If hits > 0 |
|---|---|---|
L2CAP_SendData state: | L3 fires | L3-patch candidate, plus L5-patch (paired) |
l2cap: return no-connection state: | L5 fires | L5-patch (with L3-patch paired) |
l2cap QueueTx fail at cid: | L6 fires | L6 candidate, but probably rare (BDS_DISC) |
[AVCTP] AVCTP_ConnectRsp not in incoming state: | AVCTP-side rejection | not a drop — investigate why we'd reject |
[AVRCP] AVRCP_NUM_TX_PACKETS: | startup confirms pool size (= 4, expected) | informational only |
If none of L1–L6 fire (no log lines), the M-trampoline emits ARE reaching the wire and Bolt's regression is not delivery-rate-related at all — it would be frame-content, sequencing, or RX-side. That would point at:
- Frame content: V1/V2/V6 SDP patches changed the advertised AVRCP version (1.0 → 1.3). Bolt's stack might require AVRCP 1.6+ shape for the metadata-pane code path to engage. AVRCP 1.6+ implementation is out of scope per
feedback_avrcp13_only_scope, but we should know if this is the wall. - Subscription sequencing: Bolt might expect TRACK_CHANGED (
ev=0x02) INTERIM before PSTAT/POS/REACHED_END/SETTINGS_CHANGED INTERIMs. If our T2 / T8 trampoline arm-order is wrong, Bolt could ignore the metadata after a "wrong-first-INTERIM" event. - RX-side drop (Trace #42 Tier-4): the
fcn.0x6cee4/fcn.0x6cf30/fcn.0x6cf8clist-contains gates on the AVCTP RX path could be dropping Bolt's RegNotif COMMAND frames before we ever see them. Diagnostic: count CT-side COMMAND frames vs Y1-side[AVCTP] cmdFrame->ctype:log lines (thefcn.0x6d048andfcn.0x6d0f0builder entries log this at file0x6d0c4/0x6d180-region).
Outcome. Six new candidate drop sites at the L2CAP layer (L1–L6), two of them (L3, L5) potentially patchable as paired NOPs after diagnostic confirmation they fire. Three secondary diagnoses if L1–L6 don't fire (Bolt frame-content rejection, subscription sequencing, RX-side gates). No patches staged — patching L3+L5 without confirming they fire risks introducing send-to-closed-channel issues without solving Bolt.
Confidence. High on byte-level identification of L1–L6 sites (MD5-anchored stock binary, every gate verified with explicit byte patterns). Medium on the L3/L5 fire-under-our-load hypothesis (would explain Trace #41's <100% delivery but unverified against logcat). Low on pool-exhaustion-causing-corruption hypothesis (would manifest as crashes, not delivery regressions, so probably not load-bearing).
Trace #44 (2026-05-17) — Pre-M4 capture re-analysis: L-gates ruled out; M4 hypothesis structurally supported; no post-M4 capture exists yet
Goal. With Trace #43's L-gate candidates outlined, check whether L2CAP_SendData state:, l2cap: return no-connection state:, or l2cap QueueTx fail at cid: actually appear in the existing Bolt-session btlog. mtkbt internal logs ARE captured by btlog (271 avctpCB AVCTP_EVENT hits, hundreds of [AVCTP] chid: hits, 132 avrcp: sbunit type: hits), so absence of L-gate logs is informative — they're not firing.
Capture-timing audit. The Bolt-session capture dual-bolt-20260516-1453 is timestamped 2026-05-16 19:00 UTC. M4 commit cacf389 is timestamped 2026-05-16 20:05 UTC. The capture is 65 minutes pre-M4. Therefore the existing data reflects the M2/M3-only build (v2.3.0 just-released), not post-M4. No post-M4 capture exists in /work/logs/.
btlog grep results (pre-M4 Bolt session):
| String | Hits | Source | Interpretation |
|---|---|---|---|
avctpCB AVCTP_EVENT | 271 | fcn.0xfb04 | AVCTP callback fires on every channel event — confirms mtkbt internal logs DO reach btlog |
avrcp: sbunit type: | 132 | fcn.0xf0bc:0xf1d4 (just before Path B fcn.0x6d0f0 call) | Path B in the dispatcher fired 132 times — confirms fcn.0xf0bc is routing as expected |
[AVCTP] cmdFrame->ctype: | 0 | fcn.0x6d048:0x6d0ce (Path A entry log) | Path A never fired — all 132 routes went to Path B, consistent with msg[9]=0 for msg=544 |
L2CAP_SendData state: | 0 | fcn.0x7d204:0x7d244 (L3 drop log) | L3 does not fire — L2CAP channel state always passes fcn.0x7cc7c |
l2cap: return no-connection state: | 0 | fcn.0x7d034:0x7d0b4 (L5 drop log) | L5 does not fire — channel state always in {3,4,5} |
l2cap QueueTx fail at cid: | 0 | fcn.0x7d034:0x7d17a (L6 drop log) | L6 does not fire — pkt[0xfe] always non-zero |
(qPacket->data_len | 0 | fcn.0xed50/0xef08 header-builder data-len asserts | Header builders never assert (qPackets always have non-zero data_len) |
IsListCircular | 0 | various queue-corruption asserts | No queue corruption events |
Conclusion. Trace #43's L1–L6 hypothesis is refuted by direct measurement. No L-gate fires in the pre-M4 Bolt session. The L2CAP layer is not the bottleneck.
Conclusion (positive). The 132 avrcp: sbunit type: log hits in btlog confirm fcn.0xf0bc is routing msg=544 IPC emits to Path B (fcn.0x6d0f0) 132 times pre-M4. With M4 unpatched, every one of those 132 hits the 0x6d116 list-contains drop and returns r0=0xd — exactly Trace #41's structural model. M4 is structurally the right patch.
3-second-cadence wire evidence (logcat). Bolt sends RegNotif COMMAND every 3 seconds (the AVCTP V13 §3.3.5 retry timer):
- 98
JNI_AVRCP: MSG_ID_BT_AVRCP_CMD_FRAME_IND size:13hits over a 7m20s window. The 13-byte size is consistent with one RegNotif CMD frame (AVCTP header + ctype/subunit/opcode + companyID + PDU + reserved + param_len + event_id). - Timestamps form a clean 3-second cadence: 14:50:45.462, 48.450, 51.447, 54.464, 57.453, ... continuing across the whole session.
- Outbound
EXTADP_AVRCP: msg=544emits land 0–30 ms after each inbound CMD (117 total — the Y1 trampoline T8 fires INTERIM on every CMD, including retries). - The cadence means Bolt never receives an INTERIM response: per V13 §3.3.5 the CT should stop retrying after an INTERIM lands. Continuous 3 s retries → 100% wire-side drop of INTERIM, consistent with the unpatched
0x6d116gate dropping every Path B emit.
Trampoline state confirms only one event arms persistently. Y1Patch debug logs show tramp.state[13..19] = 0 0 1 0 0 0 0 in steady state — only state[15] (sub_papp, ev=0x08 PLAYER_APPLICATION_SETTING_CHANGED) is armed. state[14] (sub_play_status, ev=0x01) is briefly armed at session start (state[14]=1 in the very first dump) and clears on the first metachanged broadcast, never re-arming. This is consistent with: Bolt subscribes to multiple events but only ONE of T8's emits got registered before Bolt gave up retrying that one. State[15]=PAPP is sticky because PAPP CHANGED is never triggered by anything in our pipeline (no source of "player app setting changed" event), so the arm-byte never clears.
Stale output/mtkbt.patched discovered. The file at /work/koensayr/output/mtkbt.patched (timestamped 2026-05-15) has bytes:
0x6d06e:37 d0— M2 NOT applied (stock byte)0x6df42:84 f8 f2 00— M3 NOT applied (stock byte)0x6d116:41 d0— M4 NOT applied (stock byte)
MD5 926b8e808693a4c44028ee257b33e898 ≠ current OUTPUT_MD5 a10ca9636417a0ed71495dfa11b5eff0. This file predates the M-series and is leftover from an earlier patcher version. apply.bash regenerates output to ${PATH_TMP_STAGE}/ and does NOT read this file, so it's a misleading artefact but not actively used in the flash workflow. Consider deleting it to avoid future confusion.
Diagnostic recommendation for the user. Before further investigation, confirm M4 is actually on the flashed device:
adb pull /system/bin/mtkbt /tmp/mtkbt-onflash
md5sum /tmp/mtkbt-onflash
Expected: a10ca9636417a0ed71495dfa11b5eff0 (post-M2/M3/M4). If different, M4 wasn't applied; investigate why apply.bash didn't pick it up (cached binary? stale staging? wrong branch on test host?).
If M4 IS confirmed on device and Bolt still shows no metadata:
- Capture a post-M4 logcat of a fresh Bolt session. Compare the
MSG_ID_BT_AVRCP_CMD_FRAME_INDcadence. If the 3-second retry cadence has STOPPED (e.g., 5–8 CMD frames total instead of 98 over the same window), M4 lifted the wire delivery and Bolt subscribed successfully — the metadata-pane regression is then not a delivery problem and points at frame content (V1/V2/V6 SDP shape, AVRCP-1.3-vs-1.6 dashboard-pane requirements, or CHANGED-event sequencing/timing). - If the 3-second retry cadence persists post-M4, there IS still a drop site we haven't found. Next candidate to audit: the IPC layer between JNI (libextavrcp / libextavrcp_jni) and mtkbt daemon over
/dev/socket/bt.int.adp. If that socket isSOCK_DGRAMwith a small kernel buffer, msg=544 burst sends could be dropping at the kernel-side enqueue (the Trace #40 pattern but in the JNI→mtkbt direction).
Outcome. M4 hypothesis still intact, L1–L6 ruled out. Next move is user-side MD5-verification of the flashed binary + a post-M4 logcat capture to distinguish "M4 didn't apply" from "M4 applied but a different blocker remains".
Confidence. High on L-gate rule-out (direct btlog measurement, zero hits across three independent log strings). High on the pre-M4 capture being structurally consistent with M4 as the fix (132 Path B routes + 100% wire drop + 3 s retry cadence). High on the stale output/mtkbt.patched finding (direct byte-level inspection). Cannot disambiguate "user didn't actually flash M4" vs "M4 doesn't help" without the user-side MD5 check or a post-M4 capture.
Trace #45 (2026-05-17) — User confirmed M4 on device; T8 emits INTERIM-ACK for AVRCP 1.4+ events Bolt subscribes to
User-side MD5 check confirms M4 applied. Bolt still shows no metadata. The drop hypothesis is dead — something past the wire is rejecting our responses or interpreting them differently than expected.
Wire-frame decode of btlog reveals Bolt's actual subscription order. Pattern 00 19 58 31 00 00 05 XX (CompanyID + RegNotif PDU + reserved + paramlen=5 + event_id) captured 63 times in dual-bolt-20260516-1453/btlog.bin — likely IPC-logged inbound CMD frames; under-sampled vs the 98 in logcat (≈64% capture rate). All 63 hits have ctype=0x00 (CONTROL) preceding the AVRCP payload. The events Bolt subscribes to:
| event_id | name | AVRCP version | hits | first appearance |
|---|---|---|---|---|
0x01 | PLAYBACK_STATUS_CHANGED | 1.3 | 8 | 1st |
0x02 | TRACK_CHANGED | 1.3 | 16 | 3rd |
0x05 | PLAYBACK_POS_CHANGED | 1.3 | 14 | 4th |
0x08 | PLAYER_APPLICATION_SETTING_CHANGED | 1.3 | 8 | 8th |
0x09 | NOW_PLAYING_CONTENT_CHANGED | 1.4+ | 3 | 2nd |
0x0b | ADDRESSED_PLAYER_CHANGED | 1.4+ | 1 | 6th |
0x0c | UIDS_CHANGED | 1.4+ | 1 | 7th |
0x0d | VOLUME_CHANGED | 1.4+ | 12 | 5th |
The second event Bolt asks for is 0x09 NOW_PLAYING_CONTENT_CHANGED — an AVRCP 1.4+ event, before it even subscribes to TRACK_CHANGED.
T8 violates AVRCP 1.3-only scope by INTERIM-ACK'ing 1.4+ events. _trampolines.py:2030–2075:
0x09 NOW_PLAYING_CONTENT_CHANGED→ emit INTERIM, armstate[20](sub_now_playing_content)0x0a AVAILABLE_PLAYERS_CHANGED→ emit INTERIM (no arm)0x0b ADDRESSED_PLAYER_CHANGED→ emit INTERIM with PlayerID=0, UidCounter=00x0c UIDS_CHANGED→ emit INTERIM with UidCounter=00x0d VOLUME_CHANGED→ falls through tot8_unknown_event→ NOT_IMPLEMENTED (spec-correct)
Per feedback_avrcp13_only_scope: "Strict scope: AVRCP 1.3 (V13 + ESR07). [...] All other 1.4+/1.5+/1.6+ PDU/event names forbidden outside INVESTIGATION.md." The 0x09 / 0x0a / 0x0b / 0x0c handlers are scope violations. The inline comment at line 2027–2029 (Events 0x09..0x0c — INTERIM ack; only 0x09 arms its gate (sub_now_playing_content). 0x0a / 0x0b / 0x0c stay INTERIM-only) confirms the intent was "1.4+ CT compatibility" — pre-dates the strict-1.3 scope rule.
Hypothesis. Bolt is a 1.4+ CT. Per AVRCP 1.3 §6.7.1 a 1.3 TG should respond NOT_IMPLEMENTED to RegNotif for events outside the 1.3 set {0x01..0x08}. By INTERIM-ACK'ing 0x09 / 0x0b / 0x0c, we're inconsistent with our SDP (advertised as 1.3 via V1/V2/V6) — Bolt sees mixed signals: "TG advertises 1.3 but accepts 1.4+ subscriptions." Bolt's stack may then expect 1.4+ behaviour for the AVRCP-1.4 metadata flow (Browse channel for NOW_PLAYING list, ADDRESSED_PLAYER state machine, GetItemAttributes on UIDS), find that we don't support any of it, and fall back to displaying no metadata.
A spec-strict 1.3 TG that returns NOT_IMPLEMENTED for events ≥ 0x09 forces a 1.4+ CT to fall back cleanly to the 1.3 metadata flow (RegNotif PSTAT/TRACK/POS/PAPP + GetElementAttributes on TRACK_CHANGED edges). That flow is fully supported by our current trampolines.
Proposed patch (would be a trampoline edit, NOT staged yet). In _trampolines.py:2030–2075, replace the four 1.4+ event handlers (t8_check_9 / t8_check_a / t8_check_b / t8_check_c) with direct fall-through to t8_unknown_event (NOT_IMPLEMENTED). That is, after t8_check_8's b.w t8_done for PAPP, the next instruction becomes b.w t8_unknown_event (or simply remove the four t8_check_* labels and let any event_id ≥ 0x09 reach the unknown-event handler). State[20] (sub_now_playing_content) also stops being armed, which is correct under 1.3 scope.
Risk profile:
- Low correctness risk. Returning NOT_IMPLEMENTED is the AVRCP 1.3-spec-correct response. 1.3 CTs are unaffected (they don't subscribe to 0x09+). 1.4+ CTs receive an explicit "not supported" signal and fall back to 1.3 flow.
- One regression risk. A CT that requires 0x09 INTERIM-ACK to proceed (rather than falling back gracefully) would lose its current INTERIM-ACK and not subscribe at all. None of the CTs in our memory-documented test matrix are known to require this. If a CT regresses, the fix is reversible (restore the four handlers).
- State-byte impact.
state[20]writes from_emit_subscription_write(a, 1, 20, ...)would no longer happen, which removes one path that touches the state file.T5 / T9 CHANGEDemits that gate onstate[20]would never fire. This is fine per scope — we're not supposed to emit 1.4+ CHANGED events anyway.
Other findings from this re-analysis.
[AVCTP] cmdFrame->ctype:log (infcn.0x6d048, the Path A finalizer) has 0 hits in btlog. Theavrcp: sbunit type:log (infcn.0xf0bc, immediately before Path B call) has 132 hits — confirming Path B is the only path used formsg=544(consistent with M4 being the correct gate). msg=540 uses Path A, but there were only 3 of those in this session.- Y1Patch trampoline state dumps show
state[14](sub_play_status),state[15](sub_papp), andstate[16](sub_track_changed) all get armed by T8/T2 emits, thenstate[14]andstate[16]get cleared promptly by T5/T9 CHANGED emits (per AVRCP §6.7.1).state[15](PAPP) stays armed persistently because there's no internal PAPP-change trigger in our pipeline (Y1 user doesn't toggle Repeat/Shuffle during normal driving). This is correct behaviour, not a bug. - TRACK_CHANGED INTERIM reads
y1-track-info[0..7]for track UID. If Bolt subscribes to TRACK_CHANGED before the music app has played a single track, the read returns all-zero UID. Spec-strict CTs may not accept an all-zero UID. Defensive but probably not Bolt's issue (Bolt subscribes during playback, not before). - Bolt's RegNotif sequence is
01 09 02 09 01 01 01 01 01 01 01 02 02 ...— interleaved 1.3 + 1.4 events with0x09being the second event ever asked for, strongly suggesting Bolt's subscription order is[PSTAT, NOW_PLAYING, TRACK_CHANGED, POS, VOLUME, ADDRESSED_PLAYER, UIDS, PAPP]with retries inflating each row.
Outcome. Strong hypothesis identified: scope-violating 1.4+ event INTERIM-ACK in T8 may be confusing 1.4+ CTs into expecting 1.4+ TG behaviour we don't support. Concrete patch proposed (revert to spec-correct NOT_IMPLEMENTED). Not staged — wants user review since this reverses an intentional "1.4+ CT compatibility" choice the trampoline author made. If user approves, the patch is a ~10-line trampoline edit; M4 stays as-is.
Confidence. High on the byte-level finding (Bolt's RegNotif order verified from btlog wire-frame extraction). Medium on the causal chain (1.4+ INTERIM-ACK → Bolt enters 1.4+ mode → Bolt's flow breaks); reasonable but not proven without a post-trampoline-edit capture. Low on whether this is the only remaining issue — there may be additional content-correctness issues post-M4 that emerge once 1.4+ events stop being mis-acknowledged.
Trace #46 (2026-05-17) — Trace #45 retracted; Pixel HCI snoop comparison surfaces TRACK_CHANGED Identifier divergence as the strongest remaining lead
Trace #45 retraction. User pointed out the T8 1.4+ event handlers (0x09 / 0x0a / 0x0b / 0x0c) were added in Trace #32 specifically to mirror Pixel-in-AVRCP-1.3-mode's wire shape against the same Bolt CT. Re-checked the Pixel-4 btsnoop at /work/logs/pixel4-bugreport/FS/data/misc/bluetooth/logs/btsnoop_hci.log (capture date 2026-05-13, Pixel forced to 1.3 mode):
| Event | Bolt CMD | Pixel response |
|---|---|---|
0x01 PLAYBACK_STATUS_CHANGED | Notify | Interim with PlayStatus |
0x02 TRACK_CHANGED | Notify | Interim with 0x0000000000000000 (SELECTED) |
0x05 PLAYBACK_POS_CHANGED | Notify | Interim with SongPosition: 0ms |
0x08 PLAYER_APPLICATION_SETTING_CHANGED | Notify | Interim |
0x09 NOW_PLAYING_CONTENT_CHANGED | Notify | Interim (zero-payload) |
0x0a AVAILABLE_PLAYERS_CHANGED | Notify | Interim (zero-payload) |
0x0b ADDRESSED_PLAYER_CHANGED | Notify | Interim with PlayerID:0, UidCounter:0 |
0x0c UIDS_CHANGED | Notify | Interim with UidCounter:0 |
Pixel-in-1.3-mode INTERIM-ACKs all four 1.4+ events. T8's behavior is the correct mirror, not a scope violation. Trace #45's "revert the 1.4+ INTERIM ACKs" recommendation is withdrawn.
Method shift. Switch from "audit Y1 for scope-violating behaviour" to "diff Y1 vs Pixel on the wire for the same Bolt CT, find the divergence". The Pixel-Bolt capture works (Pixel's pane presumably engages on Bolt — same hardware setup). The Y1-Bolt capture (pre-M4) does not. Find what's different.
Wire-level diff (Pixel vs Y1) — known divergences and candidates:
| Surface | Pixel | Y1 | §-spec | Material? |
|---|---|---|---|---|
RegNotif INTERIM shape for 0x09 / 0x0a / 0x0b / 0x0c | INTERIM-ACK (with zero/empty payload) | INTERIM-ACK (same) | both within §6.7.1 carve-out | no — matched in Trace #32 |
TRACK_CHANGED INTERIM Identifier | 0x0000000000000000 (SELECTED) then 0x0000000000000001 after PLAY (small monotonic counter) | y1-track-info[0..7] = audio_id (real BE u64 songid, e.g. 0x0000000175A22BB7 for 6302084023) | §5.4.2 Tbl 5.30: Identifier is a UID; 0 = "SELECTED" semantic for 1.3 | plausible — Bolt may treat large UIDs as 1.4+ Browse-folder semantics and ignore for the metadata pane |
GetElementAttributes response shape | Drops unsupported attributes entirely (returns Count=4 with {0x01, 0x04, 0x05, 0x07} even when request asked for {0x01..0x07}) | Emits all requested attribs; unsupported get len=0 (post-E1 patch per Trace #29) | §5.3.4 strict: zero-length emit; Pixel violates §5.3.4 | plausible inverse to E1's hypothesis — maybe Bolt is coded against Pixel's §5.3.4 violation and trips on Y1's strict-compliant zero-length attribs |
PASSTHROUGH PLAY ack | Accepted | Accepted (P1 patch) | both spec-compliant | no — same |
InformDisplayableCharacterSet ack | Rejected ("Invalid Command") | Rejected (T_charset patch per Trace #33) | both spec-permissible | no — same |
| GetCapabilities EventsSupported set | {0x01, 0x02, 0x05, 0x08, 0x09, 0x0a, 0x0b, 0x0c} | same (Trace #32) | both spec-permissible | no — same |
PlaybackPositionChanged CHANGED cadence | ~1 s, with Bolt re-subscribing within 20–40 ms after each CHANGED (strict §6.7.1) | Y1's T9 emits POS CHANGED on PositionTicker 1-s ticks; clears state[13] per §6.7.1 strict; CT must re-subscribe to re-arm | both strict §6.7.1 | no — should match |
Strongest remaining hypothesis: TRACK_CHANGED Identifier divergence. Pixel sends 0x0000000000000000 (1.3 SELECTED semantic). Y1 sends the real audio_id from y1-track-info[0..7] — currently a large 64-bit songid (e.g. 0x0000000175A22BB7). The inline comment in extended_T2 at _trampolines.py:711–717 and again at t5_track_changed at _trampolines.py:894–896 explicitly states "Strict 1.4+ CTs cache GetElementAttributes keyed by the TRACK_CHANGED Identifier; a per-track id forces cache invalidation + re-query on every track edge."
That's a 1.4+ semantic optimization (browseable-folder UIDs). For an AVRCP 1.3 TG (which our SDP advertises), the spec-clean Identifier is 0x0000000000000000 (matches Pixel-in-1.3-mode). The "per-track id forces cache invalidation" rationale was a Trace #32-era optimization for strict-1.4+ CTs, but the same memory note feedback_avrcp_spec_compliance says "every Koensayr/AVRCP change must move toward strict AVRCP-spec compliance. Spec-permissible options can be chosen for CT-compat reasons, but the chase starts from 'what does the spec say'."
Spec says: 1.3 TG → Identifier=0. We're a 1.3 TG. Identifier should be 0.
Proposed patch (would be a trampoline edit, NOT staged yet).
In extended_T2 (track UID emit for INTERIM TRACK_CHANGED) at _trampolines.py:~657–670: skip the y1-track-info open + read and leave sp+0..7 zero-initialized. Same in t5_track_changed (CHANGED TRACK_CHANGED).
Result: every TRACK_CHANGED INTERIM and CHANGED emits Identifier 0x0000000000000000, matching Pixel-in-1.3-mode. Bolt sees the spec-clean 1.3 semantic.
Risk profile:
- Low correctness risk. Pixel-in-1.3-mode does this exact thing against the same Bolt and works.
- One regression risk. Strict-1.4+ CTs that cache GetElementAttributes keyed by Identifier won't see a cache-invalidation signal on track edges, so they could display stale metadata. Mitigation: Bolt is the only subscription-class CT in our test matrix; if no CT in the matrix is "strict-1.4+ cache-keyed", no regression.
- One behavior change. TRACK_CHANGED's
0x0000000000000001second-edge increment that Pixel does — we can either leave at 0 always (truer 1.3 mirror — Pixel's increment is itself a 1.4+ leak) or replicate a per-edge counter (closer to Pixel exact). Probably leave at 0 first; revisit if Bolt requires the counter.
Secondary hypothesis: E1's zero-length attribute emit may be the wrong direction. Pixel violates §5.3.4 by dropping unsupported attributes entirely — and works against Bolt. Y1 (post-E1) emits all requested attributes including zero-length unsupported — strict §5.3.4 compliant. If Bolt's display code is calibrated against Pixel's §5.3.4 violation, Y1's strict compliance may trip it.
Trace #29 added E1 with the opposite reasoning: Bolt's pane was blocked by libextavrcp dropping zero-length, fix was to emit them. That conclusion may have been confounded by the M2/M3/M4-era wire-delivery problem — Bolt wasn't receiving anything at all, so the "blocked by zero-length-drop" theory was unfalsifiable.
Action plan (do not stage yet — wants user direction):
- Best first move (low-cost, low-risk): Patch
extended_T2+t5_track_changedto emit Identifier0x0000000000000000(skip y1-track-info read). One change touches two sites. Re-flash, test Bolt. If pane engages, problem found. - If (1) doesn't help: Try reverting E1 (let libextavrcp drop unsupported attribs again). The patch is a single
cbzflip inpatch_libextavrcp.py— small, reversible. - If neither (1) nor (2) help: A fresh post-M4-with-M-trampoline-instrumentation logcat from a Bolt session, plus the wire-side Y1 btsnoop equivalent, are the only path forward.
Confidence. High on Pixel↔Bolt wire-shape baseline (direct tshark decode of the Pixel snoop). High on the byte-level Identifier divergence (T2/T5 code reads y1-track-info, Pixel sends 0). Medium-low on the Identifier=0 patch fixing Bolt — strong correlation with spec compliance but no proof until tested. Medium on E1 being the wrong direction — possible but speculative.
Trace #47 (2026-05-17) — Exhaustive Pixel↔Bolt vs Y1↔Bolt wire-shape diff; Bolt's initial-burst pattern differs dramatically
Goal. Per user direction, investigate every plausible Pixel-vs-Y1 divergence using empirical captures. Identifier was already-tested. E1 hypothesis was retracted because Pixel emits Artist/Album when data is present. Push further: AVCTP transactionID/IPID/CR, fragmentation, SDP records, PASSTHROUGH latency, AVDTP signaling, L2CAP setup, HCI connection params, and initial-burst behaviour.
Data sources. Pixel-4-with-Bolt HCI snoop at /work/logs/pixel4-bugreport/FS/data/misc/bluetooth/logs/btsnoop_hci.log (Pixel forced to AVRCP 1.3). Y1-with-Bolt capture at /work/logs/dual-bolt-20260516-1453/ (logcat.txt + btlog.bin). SDP-tool browse output at /work/logs/pixel4-sdptool-browse-avrcp-1.3.xml vs /work/logs/y1-sdptool-20260513-1512.log.
Pixel↔Bolt vs Y1↔Bolt — wire-level diff (each dimension verified empirically)
| Dimension | Pixel | Y1 | Material? |
|---|---|---|---|
| AVCTP transactionID | Monotonic 0x0..0xF wrap (0x00, 0x01, 0x02, …) per Pixel-driven response | Y1 reads from conn[17] for INTERIMs (matches CT's CMD TID) and from y1-trampoline-state[8] for proactive CHANGEDs | no — spec-correct, Y1 mirrors CT pattern |
| AVCTP packet_type | All 0 (Single) even at 125-byte GEA responses | Path A (msg=540 GEA) goes through fcn.0xed50 fragmenter; likely emits Start+End for the 644-byte IPC payload (~500B wire) | plausible — Y1 fragments where Pixel doesn't |
| AVCTP CR / IPID | CR=1 (Response), IPID=0 (Profile OK) | same | no |
| L2CAP MTU | 512 (CONFIG-negotiated with Bolt) | 512 (per AVRCP_MAX_PACKET_LEN:512 startup log) | no |
| L2CAP retransmission mode | ERTM proposed, Basic accepted (Bolt rejects ERTM) | Basic (default) | no — both end up Basic |
| PASSTHROUGH ack latency | min 0.5 ms, max 14 ms, avg 2 ms (n=106) | same-millisecond per logcat (sub-ms granularity not observable) | no — both fast |
| AVDTP signaling order | Discover → GetAllCapabilities → SetConfig → Open → Start (then later Suspend) | unverified for Y1 in this capture but stock pattern same | no — assumed match |
| HCI connection params | CoD 0x340408 for Bolt (Audio/Video Hands-free, services: Rendering ObjectTransfer Audio), Encryption disabled (link layer) | Y1 HCI not captured | no diagnostic |
| GEA response wire size | 70 bytes (no metadata) / 110–125 bytes (with metadata) — always single AVCTP packet | msg=540 IPC payload is 644 bytes — exceeds 512-byte L2CAP MTU → AVCTP fragmentation REQUIRED on Y1 side | Y1 emits ≥2 wire fragments; Pixel emits 1 |
| SDP — AVRCP TG record version (attr 0x0009) | 0x110e @ 0x0103 (AVRCP 1.3) | 0x110e @ 0x0103 (AVRCP 1.3, post V1) | no |
| SDP — ProtocolDescriptorList (attr 0x0004) | L2CAP PSM 0x0017 + AVCTP 1.2 (0x0102) | same | no |
| SDP — SupportedFeatures (attr 0x0311) | 0x0001 (Cat 1 only) | 0x0001 (Cat 1 only, post V8) | no |
| SDP — BrowseGroupList (attr 0x0005) | 0x1002 PublicBrowseGroup | same | no |
| SDP — Browse PSM (attr 0x000d) | absent | absent (post V7) | no |
| SDP — ServiceName (attr 0x0100) | "AV Remote Control Target " | "Advanced Audio " (post S1) — same name as Y1's A2DP source record | minor — cosmetic |
| SDP — ProviderName (attr 0x0102) | " " (single space) | absent | minor — both spec-permissible |
| SDP — ServiceRecordState (attr 0x0002) | absent | 0x000001aa / 0x0000021e (varies) | minor — Y1's presence may force Bolt SDP cache refresh on change, but doesn't break correctness |
| SDP — AVRCP CT record (0x110e in ServiceClassIDList) | present at handle 0x00010005 | absent | unclear — Pixel advertises both TG+CT, Y1 only TG |
| SDP — GATT/GAP records (0x1800, 0x1801) | present at handles 0x00010000, 0x00010001 | absent | unclear — Y1 isn't LE-capable for these profiles |
| SDP — total service records advertised | 16 (incl. HFG, HFP, NAP, PBAP, SMS/MMS, SIM, OPP, NearbySharing, GATT) | 5 (A2DP, AVRCP TG, PBAP, NAP, OPP) | no — Bolt only cares about AVRCP TG |
| Initial-burst pattern at AVCTP session start | Bolt sends GetCapabilities first at T+0ms → Pixel responds (Count=8 events). Then InformDisplayableCharacterSet (rejected). Then 7 RegNotifs all within 75 ms. Then GEA. All within ~115 ms | Bolt sends RegNotif PSTAT first at T+0ms. No GetCap, no InformDisplay. Bolt then RETRIES the same RegNotif at 3-second intervals for 2m12s before finally querying GetCap | YES — Bolt's first AVRCP transaction differs fundamentally |
| First GetCap query | T+0ms (immediately after AVCTP CONNECT) | T+2m12s (mid-session, after 44 retries of various RegNotifs) | YES — Bolt skipped discovery for Y1 |
Headline finding: Bolt skips the discovery handshake with Y1
In the Pixel↔Bolt capture, Bolt's first AVRCP CMD is GetCapabilities(Events Supported) within 13 ms of AVCTP CONNECT. Bolt then receives Pixel's 8-event list, fires off 7 RegisterNotification CMDs in parallel, and is fully subscribed within ~115 ms. From frame 1480 to frame 1539 every CMD got an immediate ACK or RSP.
In the Y1↔Bolt capture, Bolt's first AVRCP CMD is RegisterNotification(PlaybackStatusChanged) (event 0x01) at logcat 14:50:45.462. No GetCapabilities query precedes it. Then Bolt retries the same RegNotif at exactly 3-second intervals (the AVCTP V13 §3.3.5 response timer) for 2 m 12 s before finally sending its first GetCapabilities at 14:52:57.666.
This is not "Bolt is broken" or "Bolt retries because of dropped INTERIMs alone". It's Bolt deciding to skip the discovery flow entirely with Y1. The most likely explanations:
- Bolt has cached SDP for Y1's BD_ADDR from a prior bonding session. If a previous Y1 firmware version advertised different SDP attributes (e.g., v2.2.0 or earlier without V1-V8 patches), Bolt's cache may have stale data — including the assumed event list and capability set. Bolt skips a fresh query because it "already knows" Y1's capabilities. The 2m12s eventual GetCap suggests a long Bolt-side timeout before forced refresh.
- Y1's SDP record signals "already-known-vendor" via some attribute combo that triggers Bolt's quick-connect path. The duplicate ServiceName
"Advanced Audio "between the A2DP source (handle 0x00010002) and AVRCP TG (handle 0x00010003) records is suspicious — this isn't how Pixel-Bolt looks. - Bolt's pairing flow is fundamentally different for Y1's CoD/EIR pattern vs Pixel's.
The 2m12s delay between session start and first GetCap query is the single biggest behavioural difference observed. It exists regardless of M2/M3/M4 — these patches only affect wire-side response delivery, not Bolt's CT-side discovery behaviour.
Secondary finding: Y1's GEA fragments AVCTP; Pixel doesn't
Pixel's GEA responses with full Title/Artist/Album/MediaNum/Total/PlayTime are 110–125 wire bytes — comfortably under the 512-byte L2CAP MTU — and emit as single AVCTP packets (packet_type=0).
Y1's outbound msg=540 IPC buffer is 644 bytes (logcat: EXTADP_AVRCP: msg=540, ptr=0x52380638, size=644). After IPC framing overhead this is likely 500–600 wire bytes of AVRCP payload. At >512 bytes this exceeds the L2CAP MTU and triggers AVCTP fragmentation (fcn.0xed50 Path A's fragmenter). Bolt's reassembler may handle the Start+End packet sequence differently from a Single packet; bugs in CT reassembly logic are well-documented across AVRCP implementations.
Why is Y1's GEA so much larger than Pixel's?
- Y1 emits all 7 requested attributes (Title, Artist, Album, MediaNum, TotalNum, Genre, PlayingTime) — including zero-length entries for missing data (E1 patch, Trace #29). Pixel drops missing attributes entirely.
- Per-attribute overhead is 8 bytes (4-byte attr_id + 2-byte char_set + 2-byte length) — for 7 attribs that's 56 bytes vs Pixel's 4×8 / 6×8 = 32/48 bytes.
- CharSet encoding: Y1 may emit UTF-16 (1015) where Pixel emits UTF-8 (106). Doubles the byte count for ASCII text. (Not verified — would need Y1 wire bytes to confirm.)
If E1's zero-length emit (~56 bytes of attr headers Pixel doesn't emit) plus possible UTF-16 encoding doubles the payload past 512 bytes, Y1 fragments and Pixel doesn't.
Static-audit findings that are NOT divergences
- AVCTP transactionID semantics, IPID/CR bits: identical wire shape, Y1 correctly mirrors Pixel.
- L2CAP setup parameters (MTU 512, Basic mode): identical.
- AVDTP signaling order (Discover → SetConfig → Open → Start): both follow stock A2DP source flow.
- PASSTHROUGH ack latency: both fast (≤ a few ms on Pixel, sub-ms-granularity on Y1 logcat).
- SDP — AVRCP TG fundamentals (profile version 1.3, AVCTP version 1.2, SupportedFeatures 0x0001, no Browse PSM): exact match between Pixel-in-1.3 and Y1-post-V1/V2/V6/V7/V8.
Action items (ranked by likelihood-of-mattering × cost-to-attempt)
- 🔴 Unpair Y1 from Bolt; clear Bolt's BT cache; re-pair fresh. Zero-cost user action. Tests the "Bolt cached Y1 SDP from older firmware" hypothesis directly. If post-clear, Bolt's first CMD to Y1 is
GetCapabilities(mirroring Pixel-Bolt pattern), the cache hypothesis is confirmed and the metadata pane should engage. If not, hypothesis is refuted. - 🟡 Add an audit: capture Y1's actual outbound wire bytes via
btmonon Y1 (/system/xbin/btmonoradb shell btmon) during a fresh Bolt session. This is the missing data we've been working around. Direct visibility into Y1's AVCTP packet_type, fragmentation pattern, and exact INTERIM/CHANGED byte content lets us compare to Pixel's wire shape byte-for-byte. - 🟡 Investigate the GEA fragmentation hypothesis. If Y1's wire GEA is >512 bytes and fragments, while Pixel's <512 bytes is single-packet, this is a real divergence. Possible mitigations: emit fewer attributes (revert E1 so unsupported drops); emit UTF-8 if currently UTF-16; pack attributes more densely. All non-trivial; requires the btmon capture from (2) to confirm fragmentation is happening.
- 🟢 Cosmetic SDP cleanup (not load-bearing for Bolt's behaviour, but spec-clean): change Y1's AVRCP TG
ServiceNamefrom"Advanced Audio "(which duplicates the A2DP source name) to"AV Remote Control Target "(Pixel's value, AVRCP convention). AddProviderNameattribute matching Pixel. StripServiceRecordStateif possible. None of these break anything; they make Y1 look more like a vanilla AVRCP TG.
Outcome. Tier-1 lead identified: the discovery-skip pattern is the biggest single behavioural difference between Pixel-Bolt and Y1-Bolt, and it persists across M2/M3/M4 because it's CT-side state not TG-side wire delivery. Tier-2 lead: AVCTP fragmentation difference for GEA. Both are testable: (1) by user-side unpair/repair, (2) by adding btmon to the capture loop.
Confidence. High on every dimension verified empirically (direct tshark/strings extraction from both captures). High on the discovery-skip being a real Bolt-side behaviour difference (n=1 each, but consistent across all 17 RegNotif retries in Y1's capture). Medium on the cause (cache vs SDP-attr-driven). Cannot disambiguate without (1) unpair/repair or (2) Y1 btmon capture.
Trace #48 (2026-05-17) — Trace #47 headline correction; mtkbt DOES support AVCTP fragmentation; reframing wire-vs-IPC distinction
Trace #47 headline retraction. The "Bolt skips GetCapabilities query with Y1" observation only applies to the dual-bolt-20260516-1453 capture, which is a reconnect to an existing bond. Multiple prior captures with confirmed unpair+repair (dual-bolt-20260509-2249, dual-bolt-20260510-0953, dual-bolt-20260511-1339, and several more) all show the standard fresh-pair flow:
Recv AVRCP indication: 506 ← AVRCP_CONNECT_IND (first inbound)
Recv AVRCP indication: 505 ← initial config exchange
Recv AVRCP indication: 519 ← GetCapabilities CMD (size:9)
EXTADP_AVRCP: msg=522 size=30 ← Y1's GetCapabilities response (8 events)
The skip-GetCap pattern in dual-bolt-20260516-1453 is normal AVRCP CT behaviour for reconnects to a known bond — Bolt remembers Y1's capability set from the prior session. Not a divergence; not Y1-specific. Cache hypothesis from Trace #47 retracted.
The metadata-pane regression exists in fresh-pair captures too (May 9–11), so unpair+repair is not a fix and was never the underlying cause.
mtkbt DOES support AVCTP fragmentation. Static analysis of fcn.0xed50:0xee40–0xee66 confirms the AVCTP packet_type field gets computed correctly:
r6 (offset) | r8 (msg.len) vs sb (MTU) | packet_type |
|---|---|---|
| 0 | msg.len < MTU | 0 (Single) |
| 0 | msg.len ≥ MTU | 1 (Start) |
| > 0 | remaining > MTU | 2 (Continue) |
| > 0 | remaining ≤ MTU | 3 (End) |
MTU comes from fcn.0xed16(chan, 1) which returns min(L2CAP_MTU, 0x200) - 10 = 502 bytes for AVRCP CONTROL channel responses. Above 502 wire bytes triggers fragmentation. The dispatcher fcn.0xf0bc tracks the per-channel fragment offset at chan[0x314] and increments it after each fragment via adds r7, r0, r7; strh r7, [r4, 0x314]. The AVCTP_EVENT 6 (TX complete) handler in fcn.0xfb04 case-6 triggers the next-fragment send.
So Y1 has fragmentation infrastructure. Whether it actually triggers depends on the actual GEA response size.
The msg=540 size=644 is IPC buffer-pool allocation, not wire size. Looking across captures from May 9 through May 16, every msg=540 has the same size=644 regardless of actual track metadata content. Compare to msg=544 always size=40 (RegNotif INTERIM, 14-byte wire) and msg=520 always size=214 (PASSTHROUGH ACK, ~12-byte wire). These are fixed per-msg-id IPC pool buffer sizes, not data length.
Estimating Y1's actual GEA wire size. T4's emit sequence calls PLT_get_element_attributes_rsp once per attribute with strlen-based length. Per AVRCP 1.3 §6.6.1 Table 6.26, each attribute on the wire is 4-byte attr_id + 2-byte char_set + 2-byte length + value. For a typical track with:
- Title: ~25 chars (8 + 25 = 33 bytes)
- Artist: ~20 chars (28 bytes)
- Album: ~30 chars (38 bytes)
- Media Number: ~3 chars (11 bytes)
- Total Number: ~3 chars (11 bytes)
- Genre: ~10 chars (18 bytes) or 0 if unpopulated (8 bytes with E1)
- Playing Time: ~6 chars (14 bytes)
That's ~155–165 bytes of attribute data plus 5 bytes AVRCP header (pdu_id, reserved, parameter_length, num_attribs) plus 9 bytes AV/C frame outer (ctype, subunit, opcode, companyID, transId) plus 3 bytes AVCTP header. Total wire: ~170–180 bytes for typical tracks. Well under the 502-byte MTU threshold.
Conclusion: typical Y1 GEA fits in a single AVCTP packet, no fragmentation needed. Same shape as Pixel. The "fragmentation" hypothesis from Trace #47 was based on misreading the IPC buffer size as wire size.
Where fragmentation WOULD trigger. Only for tracks with very long fields — total wire approaching 502 bytes. With 256-byte slots for Title / Artist / Album / Genre in y1-track-info (4 × 256 = 1024 bytes max content), an extreme track could push wire >502 and trigger fragmentation. But for typical music data (Spotify-style strings of 20–40 chars), single-packet is the norm.
btmon unavailability on Y1. Y1's Android build doesn't ship /system/xbin/btmon. The standard Android-stack btsnoop infrastructure isn't present either (Y1 uses BlueAngel/mtkbt, not Bluedroid/Fluoride). Alternatives:
- External BT sniffer (Frontline FTS, Ellisys, or open-source Ubertooth/Bluefruit/HackRF One). Gives clean wire-level visibility. Cost: hardware ($150–$3000) and capture-time setup.
- Wireshark with
nrf_sniffer_for_802154or a generic Bluetooth Classic sniffer if available. Same idea, different hardware. - Add mtkbt debug instrumentation. Patch
fcn.0xed50:0xee66-region to writer3(the packet_type result) to a side-channel log file or via the existing fcn.0x4cc30 LogPrintf. Cost: ~10-line trampoline-style patch, doesn't require new hardware. Output reaches btlog (not logcat) but we already have btlog tooling. This is the cheapest path to wire-fragment-type visibility. - Patch a "wire-bytes log" into mtkbt's
fcn.0xae5e4(L2CAP_SendData entry) — log first 32 bytes of every outbound AVRCP frame to btlog. Slightly more work; gives complete frame visibility. - Capture btsnoop on the Bolt side if Bolt's stack supports it. The Pixel snoop is exactly this — Pixel captures HCI inbound/outbound including the wire frames from the CT (Bolt). If Bolt exposes a btsnoop file (most cars don't), we'd have the data.
Can we implement fragmentation easily? Yes/already-done:
- mtkbt's
fcn.0xed50(Path A header builder, used formsg=540GEA) andfcn.0xef08(Path B header builder, used formsg=544RegNotif INTERIM/CHANGED) both computepacket_type0/1/2/3 correctly. - The dispatch loop in
fcn.0xf0bc+fcn.0xfb04case-6 (TX-complete handler) drives multi-fragment sends. - The L2CAP layer (
fcn.0xae5e4 → fcn.0xae418 → fcn.0x7d204) accepts the per-fragment send call.
Likely-not-needed for our metadata pane regression. Typical GEA wire fits in single packet (<200 bytes vs 502 MTU). Bolt sees the same shape as Pixel.
What IS load-bearing. Reframing where to look next:
- Direct wire visibility. Without seeing what Y1's mtkbt actually puts on the wire, we're guessing. The Tier-3 debug-log patch above (log
packet_typefromfcn.0xed50to btlog) is the cheapest way to confirm what Y1 emits per-frame. - The Bolt-side AVRCP state machine. Bolt subscribes (RegNotif), receives INTERIM, but pane stays blank. Why? Without Bolt firmware to RE, the only data is the wire pattern that DOES work (Pixel) vs the one that doesn't (Y1). The wire-shape diff is small (Identifier, ~50-byte size diff). Either the metadata-pane code path on Bolt's side has a check we haven't identified, or Y1's wire bytes are subtly malformed in a way we can't see without btmon.
- Hardware probe. A USB BT sniffer on the Bolt-Y1 link captures wire bytes from outside both stacks. Gives a definitive byte-by-byte comparison.
Outcome. Trace #47's discovery-skip and fragmentation hypotheses both retracted. The actual blocker remains "Bolt receives Y1's AVRCP responses but doesn't render the metadata pane, while it renders Pixel's." Without wire visibility on the Y1 side, the diagnosis is constrained to "something in Y1's wire bytes differs from Pixel's in a way Bolt cares about, but we can't see it." Cheapest mitigation: patch mtkbt to log per-fragment packet_type to btlog, confirm or rule out fragmentation in production.
Confidence. High on fragmentation infrastructure existing in mtkbt (direct static analysis of fcn.0xed50 packet_type logic). High on the IPC-vs-wire size distinction (consistent size=644 across all msg=540 emits regardless of track content). Medium on "typical GEA fits in single packet" (computation from T4 emit logic but not verified against actual wire bytes).
Trace #49 (2026-05-17) — Trampoline-side T4a= wire-size instrumentation + tools/avrcp-wire-trace.py
Motivation. User asked for fcn.0xed50 mtkbt-side packet_type log instrumentation to confirm/refute the AVCTP fragmentation hypothesis from Trace #48. Implementing it directly in mtkbt requires ELF segment surgery (LOAD #1 extension via the libextavrcp_jni.so approach) which is significant work for what's ultimately a diagnostic. Instead, pivot to trampoline-side instrumentation that captures equivalent diagnostic value via the existing DEBUG_NATIVE_LOG infrastructure in _trampolines.py.
Equivalence argument. mtkbt's fcn.0xed50 computes packet_type purely as a function of msg.len vs MTU (502 bytes for AVRCP CONTROL channel):
msg.len < 502→packet_type = 0(Single AVCTP packet)msg.len ≥ 502→packet_type = 1(Start fragment)
Therefore observing the wire-size of every outbound GEA response is sufficient to predict mtkbt's packet_type without instrumenting mtkbt itself. The wire-size formula is closed-form from the trampoline-side data: wire_size = 14 (AV/C outer + companyID + PDU + paramlen + num_attribs) + Σ(8 + strlen_i) over the N attributes the request-driven T4 emit loop processes.
Implementation.
src/patches/_trampolines.py: newT4a=%08xlog emit int4_req_loop(the request-driven emit path that fires per-attribute). Packed value: high 16 bits =attr_id, low 16 =strlen. The packing keeps the existing single-arg_emit_native_log_u32helper usable — no new wide-arg log helper needed. Emit fires per-attribute right before eachget_element_attributes_rspPLT call, log-helper's push/pop preserves the caller's r0-r3 PLT args.- Trade-off: removed
T6resp pos=%u/T6resp dur=%uemits in GetPlayStatus to stay within the 4020-byte LOAD #1 padding budget. T6 fires on every CT poll (high-noise); the same play_status / position values surface in T9emit logs at lower frequency. tools/avrcp-wire-trace.py: new logcat post-processor. Groups consecutiveY1T : T4a=...lines (each one a per-attribute emit) into a single GEA response summary. Computes total wire size from per-attribute strlens, predicts AVCTPpacket_type(0 vs 1) by comparison to the 502-byte threshold, and surfaces non-T4Y1T :lines verbatim for cross-correlation. Supports--gea-only,--frag-only,--no-attr-breakdownfilters.tools/btlog-parse.py: gains--avrcppreset that includes only the AVRCP / AVCTP-related mtkbt log surfaces (avctpCB,[AVCTP],avrcp:,[AVRCP],transId). Pairs cleanly with the logcat trace for end-to-end TX path visibility.
Operator workflow for a Bolt-session capture, post-apply.bash --debug:
# concurrent capture
adb logcat -s Y1T:* Y1Patch:* > bolt.logcat &
adb shell btlog-dump > bolt.btlog # or tools/dual-capture.sh
# offline analysis
tools/avrcp-wire-trace.py bolt.logcat # GEA wire-size summaries
tools/avrcp-wire-trace.py bolt.logcat --frag-only # only responses > 502 B
tools/btlog-parse.py --avrcp bolt.btlog # mtkbt internal AVRCP/AVCTP log surface
Output sample (from a synthetic test feed at /tmp/y1t-test.log):
[05-17 10:00:00.010] GEA response: N=7 total_strlen=72 wire=142B AVCTP_payload=145B → fragments=1
attr=0x01 (Title ) len=20
attr=0x02 (Artist ) len=23
attr=0x03 (Album ) len=20
attr=0x04 (MediaNum) len=1
attr=0x05 (TotalNum) len=2
attr=0x06 (Genre ) len=0
attr=0x07 (PlayTime) len=6
[05-17 10:00:01.000] GEA response: N=7 total_strlen=798 wire=868B AVCTP_payload=871B → fragments=2 (PACKET_TYPE=START)
attr=0x01 (Title ) len=256
...
The first response (typical music metadata, 72 B total strlen) fits in a single AVCTP packet. The second (pathological 256-char fields) triggers fragmentation.
Build budgets. Post-edit blob sizes (within the 4020-B LOAD #1 budget):
| Build | Blob size | Free |
|---|---|---|
| Release (non-debug) | 3784 B | 236 B |
| Debug (KOENSAYR_DEBUG=1) | 3976 B | 44 B |
New OUTPUT_DEBUG_MD5 for libextavrcp_jni.so debug build: 3900c80075ae051afc4ac48ade0c9bc4. Release MD5 unchanged (d803f42c973bf9539f4d03ccb658cab3).
Outcome. User can rebuild via apply.bash --debug, reflash, capture a Bolt session, and run tools/avrcp-wire-trace.py on the resulting logcat. The output directly answers the diagnostic question from Trace #48: under what real-world track-metadata loads does Y1's GEA exceed 502 wire bytes and trigger mtkbt fragmentation? If most responses fit single-packet (predicted from Pixel session's 110–125 B typical), AVCTP fragmentation is ruled out as the Bolt regression cause and we look elsewhere. If some / many fragment, Bolt's reassembler is suspect.
Confidence. High that the trampoline-side wire-size measurement is equivalent to mtkbt-side packet_type observation (mtkbt's logic is a pure function of msg.len vs MTU per fcn.0xed50:0xee40-0xee66). High on the implementation working — round-tripped through the patcher in both release and debug modes, MD5-verified. Medium on whether the resulting data will identify the Bolt regression — that's an empirical question requiring the user-side hardware test.
Trace #50 (2026-05-17) — First post-M4 + debug-instrumented Bolt capture: M4 alone doesn't fix wire delivery for Path B
Capture. /work/logs/dual-bolt-20260517-0902/, ~3 minutes. M4 + the new Y1T : T4a= instrumentation on device, fresh Bolt session.
tools/avrcp-wire-trace.py output — the diagnostic. Three GEA responses surfaced:
| Time | N | total_strlen | wire | Frags? | Content |
|---|---|---|---|---|---|
| 09:00:20 | 7 | 1 | 71 B | 1 | Empty-track placeholder (only MediaNum populated) |
| 09:00:41 | 7 | 80 | 150 B | 1 | Title=35, Artist=11 (Anti-Flag w/ UTF-8 dash), Album=15, Genre=8, PlayTime=6 |
| 09:01:44 | 7 | 58 | 128 B | 1 | Title=16, Artist=3 (311), Album=12, Genre=16, PlayTime=6 |
All three fit in single AVCTP packets (wire < 502 B, no fragmentation triggered). The Pixel↔Bolt session's typical 110–125 B wire matches Y1's 128–150 B. GEA fragmentation is not the issue. Trace #48's secondary hypothesis is also refuted by direct measurement.
The user-side observation — "only Anti-Flag and 311 displayed metadata" — matches exactly: those are the only two GEA responses with real metadata content. Bolt only queried GEA those two times.
Bolt's 3-second retry storm continues post-M4. This is the headline finding. Inbound MSG_ID_BT_AVRCP_CMD_FRAME_IND size:13 (RegNotif CMD) timestamps:
09:00:11.543 / .14.554 / .17.545 / .23.564 / .26.554 / .29.555 / .32.587 / .35.558 / .38.555 / .41.555
Exact ~3.0 s intervals (with 6 s gap at .20 where a GEA query interrupted). This is the AVCTP V13 §3.3.5 response-timeout retry. Bolt is not receiving any INTERIM response for event=0x01 PLAYBACK_STATUS_CHANGED.
Per logcat, Y1's T8 trampoline DOES emit an INTERIM for each retry (10 Y1T : T8reg ev=01 entries match the 10 inbound size:13 for ev=01). EXTADP_AVRCP: msg=544 outbound IPC count is 77. mtkbt's fcn.0xf0bc Path B router log (avrcp: sbunit type:9 id0…) shows 81 hits in btlog. The trampolines fire correctly; mtkbt processes the IPC; mtkbt routes through Path B (the M4-patched fcn.0x6d0f0). But Bolt's 3-second retry cadence proves none of the resulting wire frames reach Bolt.
M4 alone doesn't fix Path B wire delivery. There's another drop site downstream that affects Path B but not Path A.
Where the paths diverge. Both eventually call fcn.0xae5e4 (L2CAP_SendData), but with different second argument:
| Path | Finalizer | r1 to fcn.0xae5e4 | Source instruction |
|---|---|---|---|
| A (msg=540 GEA, M2/M3) | fcn.0x6d048 → fcn.0x6df20 (M3-NOPed) → b.w fcn.0xae5e4 | chan + 0xc0 | fcn.0x6df20:0x6df3e add.w r1, r4, 0xc0 |
| B (msg=544 RegNotif, M4) | fcn.0x6d0f0 (M4-NOPed) → b.w fcn.0xae5e4 | chan + 0xd8 | fcn.0x6d0f0:0x6d18e add.w r1, r4, 0xd8 |
The two paths populate different regions of the chan struct (Path A → chan[0xc0..], Path B → chan[0xd8..]). fcn.0xae5e4 consumes whichever buffer was passed. If the chan+0xd8 Path B buffer has a structural bug (missing field, wrong byte at a critical position) that the chan+0xc0 Path A buffer doesn't, fcn.0xae5e4 → fcn.0xae418 → fcn.0x7d204 would silently reject it at one of Trace #48's L1 (CID lookup miss, no log) or L2 (packet-flag sanity mask, no log) gates — neither of which surface in btlog.
Confirmed not the issue. L3 (state-check, logs L2CAP_SendData state:) — zero hits in btlog. L5 (l2cap: return no-connection state:) — zero hits. L6 (l2cap QueueTx fail) — zero hits. AVCTP fragmentation (would require wire > 502 B) — empirically all 3 GEA responses fit single-packet, so fragmenter is dormant.
Confirmed by direct comparison. The Pixel↔Bolt capture had Bolt's RegNotif re-subscribe arrive 20–22 ms after each Pixel CHANGED emit (per Trace #45 / #46) — that's the normal CT post-CHANGED re-subscribe cadence. Y1↔Bolt post-M4 has Bolt's RegNotif retries arriving 3000 ms exactly apart — that's the §3.3.5 retry timer. The two timing signatures unambiguously distinguish "subscription confirmed, normal flow" (20 ms) from "no INTERIM received, retry timer firing" (3000 ms).
Outcome. Strong evidence that Path B (fcn.0x6d0f0-built AVRCP frames at chan+0xd8) has a structural bug between fcn.0x6d0f0 exit and Bolt's wire-side parser. The bug is silent (no log hits) and affects only Path B (Path A works for some emits, evidenced by the 3 GEA wire deliveries). Most likely candidates:
chan+0xd8buffer setup mismatch infcn.0x6d0f0. Some byte that Path A populates correctly atchan+0xc0doesn't get written atchan+0xd8, causingfcn.0x7d204's L1 / L2 silent gates to drop.- Distinct chan-struct sub-state for Path B. Some flag or counter at
chan[0xd8..]differs fromchan[0xc0..]semantics, causing downstream rejection. fcn.0xae5e4queue logic mishandles Path B's buffer location. Specifically thecbnz r0, 0xae69c"packet already in list" dedup drop (r6=5) might fire for Path B's reused ptr pattern but not Path A's.
Next investigation. Byte-level diff of chan+0xc0.. (Path A) vs chan+0xd8.. (Path B) build sequences in fcn.0x6d048 and fcn.0x6d0f0. Any field Path A writes that Path B doesn't (or writes differently) is a candidate.
Confidence. High on the data: the 3-second retry cadence + zero L-gate logs + Path A success vs Path B failure is unambiguous. High on the structural-bug hypothesis at the byte level — the two paths build different chan-struct regions and consume them via the same downstream chain, so any divergence in field population could explain the symptom. Medium on which specific field is the bug — requires the next round of disassembly diff.
Trace #51 (2026-05-17) — M5 attempt: flip Path B's buffer[+0x08] from 2 to 0 → regression on Bolt → reverted
Hypothesis. Following Trace #50, byte-level diff of fcn.0x6d048 (Path A, chan+0xc0 region) vs fcn.0x6d0f0 (Path B, chan+0xd8 region) identified the cleanest single-byte divergence at frame-buffer offset +0x08: Path A writes 0 (strb r3, [r4, 0xc8] with r3=0 at 0x6d086), Path B writes 2 (strb r1, [r4, 0xe0] with r1=2 at 0x6d138). fcn.0xae418's AVCTP single-packet builder reads buffer[+0x08] twice — first as a discriminator at 0xae43c (ldrb r3, [r1, 8]; cbnz r3, 0xae448) selecting between two TID slots, second as the low nibble of AVCTP wire byte 0 at 0xae4f2–0xae4f4 (ldrb r6, [r3, 8]; add r6, lr). The M5 candidate patch: change Path B's strb.w r1, [r4, 0xe0] at 0x6d138 to strb.w r2, [r4, 0xe0] — a 1-byte edit at file offset 0x6d13b (10 → 20 in the strb.w T2 imm12 Rt-field encoding). After M5, Path B writes 0 at +0x08, matching Path A and routing through the alternate TID slot.
Hardware test. Reflashed Y1 with M5-patched mtkbt (MD5 52a4ab9f50d4f5293421324d1a5dcd84), captured dual-bolt-20260517-1009. Observed:
- Hard regression. No metadata pane updates on Bolt; button presses unresponsive on Bolt's UI even for NEXT/PREV. Music-app-side trampolines still fire (T8reg / T9emit / T5emit at session start), and PASSTHROUGH commands reach the music app (PlayerService.nextSong fires Y1-side), but Bolt's AVRCP session degrades to a state where neither metadata nor button echo lands.
- Indication 590 burst. Pre-M5 captures had zero
JNI_AVRCP: Recv AVRCP indication : 590("AVRCP Unexpected message" — Bolt's wire-side rejection signature); M5 capture has 10 within ~60 s, paced with the post-INTERIMEXTADP_AVRCP msg=544retries. After the initial subscription, zero T8reg entries — Bolt never re-registers.
Re-disassembly of fcn.0xae418. The discriminator at 0xae43c reads via the pointer field chan[0x10] (loaded with ldr r1, [r4, 0x10] at 0xae43a), and fcn.0xae5e4 at 0xae60a writes chan[0x10] = r5 (the frame buffer pointer that the caller passed). So [r1, 8] at 0xae43c resolves to the byte M5 modified. The byte-level mechanics of "this is the byte the discriminator reads" was correct.
What M5 got wrong was the SEMANTICS. The discriminator selects between two distinct transaction-label slots:
chan[0x28](offset[r4, 0x14]whenr4 = &chan[0x14]is passed fromfcn.0xae5e4/fcn.0x6df20) — AV/C command-response TID slot (AVRCP 1.3 §6.5: response shall echo the TID of the AV/C command).chan[0x29](offset[r4, 0x15]) — RegNotif subscription TID slot (AVRCP 1.3 §6.7.2: notification message shall carry the same TID as the originalRegisterNotificationcommand).
Path B's stock buffer[+0x08] = 2 was deliberate: it routes notification responses through the dedicated chan[0x29] slot per §6.7.2. By forcing Path B to discriminate as 0 → chan[0x28], M5 makes CHANGED responses go out with whatever TID the most recent AV/C command used — which post-INTERIM is whatever Bolt sent next (typically a follow-up GetCapabilities or GetPlayStatus). That TID does not match the RegNotif TID, so Bolt's AVRCP layer correctly rejects the frame and re-fires indication 590.
Outcome. M5 reverted (commit c5e93be). OUTPUT_MD5 restored to a10ca9636417a0ed71495dfa11b5eff0. The Trace #50 hypothesis ("Path A wire-byte-0 shape works, Path B's doesn't") was wrong about the failure mechanism — Path A and Path B emit deliberately different wire-byte-0 shapes corresponding to different §6.5 / §6.7.2 TID semantics. The actual bug must be upstream, in how chan[0x29] (the slot M5 was bypassing) gets populated.
Process note. M5 was committed and shipped to hardware-test without first confirming what chan[0x10] points to or what semantic distinction the two TID slots carry. Future M-series changes against fcn.0xae418's discriminator must verify (a) which chan-struct slots the discriminator selects, (b) what semantics each slot carries, and (c) whether those semantics are spec-mandated, before flipping the discriminator.
Trace #52 (2026-05-17) — M6 attempt: lift IPC[5] into packet[0xd] → broke msg=520 cmd_frame_ind_rsp (different IPC semantics) → reverted
Hypothesis. Continuing Trace #51's revised candidate list: the chan[0x29] slot (Path B's TID source per the buffer[+0x08] = 2 discriminator) is set by Path B itself at 0x6d186-0x6d188 from packet[0xd], where packet is mtkbt's internal outbound packet struct allocated by fcn.0x11894. Stock fcn.0x11894 at file offsets 0x11924-0x11927 writes movs r6, 0; strb r6, [r4, 0xd] — packet[0xd] = 0 unconditionally. Scanned mtkbt's code path from alloc to Path B read (fcn.0x11894 → fcn.0xf0bc → fcn.0xef08 → fcn.0x6d0f0); no intermediate function overwrites packet[0xd]. So every Path B wire frame emits with TID = 0 regardless of the originating AV/C command's transaction-label.
This explains why msg=540 GEA responses landed in Trace #50 (Bolt sent GEA with TID=0, accidentally matching) but msg=544 RegNotif INTERIM didn't (Bolt's RegNotif used non-zero TID, our TID=0 response failed the §6.7.2 echo check, AVCTP §3.3.5 retry timer fired forever).
Sampled five libextavrcp.so response builders (btmtk_avrcp_send_reg_notievent_track_changed_rsp, ..._playback_rsp, ..._rsp, ..._get_capabilities_rsp, ..._get_element_attributes_rsp); each writes ipc[5] = conn[0x11] (the libextavrcp transId slot — updated by the inbound-RX path when an AV/C command arrives). The hypothesis: replace mtkbt's movs r6, 0 at 0x11924 with ldrb r6, [r1, 5] (00 26 → 4e 79), where r1 holds the IPC payload pointer (loaded at 0x11910 ldr r1, [arg_28h] and not modified through 0x11923). The unchanged strb r6, [r4, 0xd] at 0x11926 then writes packet[0xd] = ipc[5] = transId, propagating to chan[0x29] and then to wire byte 0 = (transId << 4) + 4 + 2 per §6.7.2.
Hardware test. Reflashed Y1 with M6-patched mtkbt (MD5 3c814fb2715d7919c38b04126e1ec3e2). Captured dual-bolt-20260517-1126 (Bolt) and dual-sonos-20260517-1130 (Sonos). Observed:
| Capture | Inbound CMD_FRAME_IND sizes | Outbound msg=544 | Indication 590 |
|---|---|---|---|
Bolt pre-M6 (...0902) | 45× size:13 (RegNotif), 16× size:3 (PASSTHROUGH), 3× size:45 (GEA), 1× size:9, 1× size:11 | 77 | 0 |
Bolt post-M6 (...1126) | 0× size:13 (no RegNotif), 26× size:3 (PASSTHROUGH), 1× size:45 (GEA), 1× size:9 | 15 | 0 |
Sonos last-working (...1441) | 30× size:13 (RegNotif), 8× size:45 (GEA), 8× size:3 | 60 | 0 |
Sonos post-M6 (...1130) | 0× size:13 (no RegNotif), 0× size:45 (no GEA), 6× size:3 | 3 | 0 |
Both CTs stopped sending RegisterNotification CMDs entirely. Different from M5's failure mode (which had Bolt rejecting frames after subscription, indication 590 burst). M6's failure mode is "CT silently abandons AVRCP subscription/metadata flow during or before initial handshake." No L2CAP-layer reject signature, no AVRCP-layer indication-590 reject — the CT just doesn't proceed.
Root cause: cmd_frame_ind_rsp doesn't follow the ipc[5] = transId template. Re-disassembled sym.btmtk_avrcp_send_cmd_frame_ind_rsp at 0x1cbc:
0x1cc2 mov lr, r2 ; lr = r2 = arg3
0x1cf8 strb.w lr, [sp, #0x9] ; ipc[5] = lr = arg3
cmd_frame_ind_rsp(arg1, arg2, arg3, arg4, ...) writes ipc[5] = arg3, where arg3 is the caller-supplied ctype-like byte (mirroring the convention of other *_rsp functions where arg3 is the AV/C ctype, not the transId). It does NOT do ldrb r3, [conn, #0x11] — it does NOT touch conn[0x11]. So for msg=520 (cmd_frame_ind_rsp), ipc[5] carries the response ctype, not the transId.
Pre-M6: mtkbt's packet[0xd] = 0 → wire TID = 0 for all msg_ids → Bolt's GEA-with-TID-0 + cmd_frame_ind_rsp-with-TID-0 both accidentally matched.
Post-M6: mtkbt's packet[0xd] = ipc[5] → wire TID = transId for the five response builders that follow that template, BUT wire TID = ctype-byte (0x0F / 0x0D / 0x0C / etc.) for cmd_frame_ind_rsp. The CT's AVRCP layer sees msg=520 responses with malformed TIDs (TID = ctype low nibble) and stops proceeding with the AVRCP handshake. Both Bolt and Sonos exhibit the same "abandon AVRCP" symptom because both rely on the early-session cmd_frame_ind_rsp exchange to confirm AVRCP transaction routing.
Outcome. M6 reverted (commit reverting 6d73620). OUTPUT_MD5 restored to a10ca9636417a0ed71495dfa11b5eff0. The "patch mtkbt's allocator universally" approach has too broad a blast radius — libextavrcp.so response builders have heterogeneous ipc[5] semantics, and cmd_frame_ind_rsp is the spoiler.
Lesson + revised approach. Patching fcn.0x11894's packet[0xd] source affects ALL msg_ids that traverse Path B (msg > 6 = all common AVRCP responses). For the M6 mechanic to work, every Path-B-bound msg_id's IPC payload must carry the transId at the same byte offset. The five sampled response builders do (write ipc[5] = conn[0x11]); cmd_frame_ind_rsp doesn't (writes ipc[5] = arg3 which is the response ctype). Future TID-routing fixes need to either:
- Patch
libextavrcp.so'scmd_frame_ind_rspto also setipc[5] = conn[0x11]. Requires finding 6 bytes of space in the 124-byte function (push {r4..r7, lr}; sub sp, 0xe4; ...) — challenging without restructuring. Could overlay into the stack-canary epilogue if a free register is available. - Patch
mtkbt's Path B response builderfcn.0x6d0f0to dereferencepacket[0x10](the IPC data pointer, set viafcn.0x11894's memcpy at0x11934) and read byte 5 there:ldr rtmp, [r5, 0x10]; ldrb r0, [rtmp, 5]; strb.w r0, [r4, 0x29]. Same byte-source issue applies forcmd_frame_ind_rsp(ipc[5] is ctype, not transId), so no improvement over M6. - Patch only specific msg_ids. Insert a
cmp r6, 0x220(or similar) check infcn.0x11894before thepacket[0xd]write, so only the RegNotif IPC msg=544 gets the conditional transId-lift. Requires a code-cave trampoline since the inline path doesn't have space for a conditional. - Patch
libextavrcp.soto setconn[0x11]from the right TID at the right time — i.e., before any outbound response, ensureconn[0x11]holds the TID of the AVRCP command that's being responded to. This is already what stock does for response builders that readconn[0x11];cmd_frame_ind_rspdoesn't, so we'd need a different mechanism for it. - Patch the inbound-RX path in
mtkbtto write the inbound TID into bothchan[0x28]ANDchan[0x29](i.e., both TID slots from §6.5 and §6.7.2 point to the same value, the current inbound CMD's TID). For pure command-response exchanges (msg=520 cmd_frame_ind_rsp,msg=540 GEA,msg=522 GetCaps), this is correct per §6.5. Formsg=544 RegNotif INTERIM/CHANGEDit requireschan[0x29]to be sticky between the RegNotif CMD arrival and the subsequent CHANGED emits (which can fire arbitrarily later from T9 / T5 trampolines). Whether stockchan[0x29]is sticky or gets overwritten by subsequent inbound CMDs needs RE.
Option (5) looks cleanest if chan[0x29] is sticky. Option (3) is targeted but requires a code cave. Options (1)/(2)/(4) have their own RE costs. Each option needs verification before committing.
Confidence. High that M6's universal-allocator approach is wrong — both Bolt and Sonos went from "subscribing" to "not subscribing" post-M6, which matches the cmd_frame_ind_rsp / TID-mismatch failure mode. High on the cmd_frame_ind_rsp RE (the mov lr, r2; strb.w lr, [sp+9] chain is unambiguous). Medium on which of the five options to pursue next — option (5) is the most ambitious but most spec-aligned.
Wire-side transId evidence from existing btlog.bin captures. mtkbt's xlog stream logs inbound transId via the [AVRCP] transId:%d format string (caller fcn.00051a20 reads from event[5] per Trace #46 lines 1117-1123). Parsing the existing dual-capture btlogs with tools/btlog-parse.py --avrcp gives a per-CT TID histogram:
| Capture | TID histogram (count × TID) | Distinct TIDs |
|---|---|---|
Bolt post-M4 pre-M5 (dual-bolt-20260517-0902) | 2×9, 1×{7, 6, 15, 14, 13, 12, 10, 1, 0} | 10 (0, 1, 6, 7, 9, 10, 12, 13, 14, 15) |
Bolt post-M6 (dual-bolt-20260517-1126) | 2×{8, 7, 5, 1}, 1×{4, 3, 2, 15, 10} | 9 (1, 2, 3, 4, 5, 7, 8, 10, 15) |
Sonos last-working (dual-sonos-20260516-1441) | 6×0, 1×02 | 1 (0) |
Bolt cycles transIds across the full 0-15 range (likely every AV/C command increments the previous TID modulo 16, the conventional AVCTP §6.1 transaction-label rotation). Sonos uses transId=0 for essentially all commands. This explains the symptom asymmetry without needing a new HCI snoop:
- Stock
mtkbtemits Path B wire frames with TID = 0 (becausepacket[0xd]is constant 0). Sonos's TID=0-only CMDs accidentally match, so Sonos works pre-M6. Bolt's cycled CMDs match only when Bolt happens to be at TID=0 — empirically ~1/16 of the time, plus the §6.7.2 wrinkle that a CHANGED on a subscription that was opened at TID=N never matches TID=0 unless N=0. - Post-M5 (
buffer[+0x08] = 2 → 0, forcing Path B to readchan[0x28]instead ofchan[0x29]): the §6.5 slotchan[0x28]holds the LATEST inbound AV/C CMD's TID, so the FIRST Bolt RegNotif INTERIM matches (the RegNotif is what's "latest"), but any subsequent CHANGED emit picks up whatever non-RegNotif CMD came in after (GetCaps, GEA, etc.), TID mismatches, Bolt sends indication 590 ("AVRCP Unexpected message"). The 10× indication 590 burst indual-bolt-20260517-1009is the wire-side signature. - Post-M6 (universal
packet[0xd] = ipc[5]): for response builders that follow theipc[5] = conn[0x11] = transIdtemplate, the wire TID becomes the correct §6.5 / §6.7.2 echo. But forcmd_frame_ind_rsp(msg=520) which usesipc[5] = arg3 = ctype, the wire TID becomes the response ctype byte (0x0c / 0x0d / 0x0f / etc.). Both Bolt and Sonos's AVRCP layer sees garbage-TIDs onmsg=520(Sonos's stock TID=0 protection no longer holds because we're actively writing ctype as TID instead of leaving the byte zero), abandons the handshake. Zero RegNotif CMDs post-M6 on both CTs is the abandonment signature.
So the TID-mismatch hypothesis is confirmed quantitatively without new captures. The "Sonos works, Bolt doesn't" pre-M6 asymmetry is purely the TID-distribution difference. The remaining open question is the SCOPE of any fix: it must change msg=544 (and ideally msg=540 GEA + msg=522 GetCaps) to echo the inbound TID, WITHOUT changing msg=520 (which already works for Sonos and would re-break post-M6).
Updated option ranking. Given the btlog evidence:
- Option (3) — patch
fcn.0x11894to conditional-lift only for msg_id ∈ {544, 540, 522} — is the most targeted but needs a code-cave trampoline (the inline path has no room for acmp r6, …; beqconditional). - Option (5) — write inbound TID to chan[0x28] + chan[0x29] in mtkbt's AVCTP RX — depends on chan[0x29] being sticky (only written on RegNotif CMDs, not every CMD). RE on
fcn.0x7d204(AVCTP RX entry) is the prerequisite. - Option (1) — patch
cmd_frame_ind_rspto alsoipc[5] = conn[0x11]— would change what mtkbt receives atipc[5]for msg=520, breaking mtkbt's interpretation of the response ctype. Unless we can ALSO patch mtkbt's IPC RX for msg=520 to read ctype from a different IPC byte. Two-binary patch coordination needed.
Best next step: examine fcn.0x7d204 / the AVCTP RX path to see whether stock writes the inbound TID anywhere on the chan struct, and if so where. If chan[0x29] is already written from inbound RX (just overwritten by Path B at 0x6d188), the fix collapses to a single NOP at 0x6d188-0x6d18b (4 bytes 84 f8 29 00 → 4 NOPs).
Trace #53 (2026-05-17) — RE corrections: AVCTP RX path located, chan+0x39 confirmed as the shared TID slot
Mistake correction (from Trace #52 closing message). fcn.0x7d204 is NOT the AVCTP RX entry — its disassembly leads through the format string "L2CAP_SendData state:%d return:%d". It's the L2CAP TX (downward send) path. The actual AVCTP RX entry is fcn.0xfb04 (the avctpCB callback registered with the AVCTP layer), with its [AVRCP] avctpCB AVCTP_EVENT:%d log marker.
True inbound AVRCP CMD path mapped. Working back from the wrappers fcn.0xed04 (Path A) and fcn.0xed0a (Path B):
AVCTP layer
→ fcn.0xfb04 (avctpCB) ; r3=evt: 2=RECV, 3=CONN_STATE, 4=…
→ fcn.0x518ac (AVCTP packet dispatcher) ; (case 20 per prior summary)
→ fcn.0x11374 ([AVRCP] transId logger)
→ fcn.0xed0a (+8 trampoline)
→ fcn.0x6d0f0 (Path B) ; writes chan[+0x29]
→ fcn.0xae5e4 → fcn.0xae418 ; wire-frame builder
The TID is latched twice: first by fcn.0x11374 at file offset 0x11436 (strb.w sl, [r4, 0xba9] where sl = arg2 = inbound transId) into the per-channel stash struct at chan+0xba9 (stash struct base chan+0xb9c, offset +0xd); then a few instructions later passed as the r1 arg to fcn.0xed0a and propagated through Path B's ldrb r0, [r5, 0xd]; strb.w r0, [r4, 0x29] at 0x6d186-0x6d18b (where r5 = stash, r4 = chan+0x10).
Path B's r4 is chan+0x10 for BOTH inbound and outbound (initial RE confusion about an 8-byte split was wrong — the +8 wrapper in fcn.0xed0a is the only addition; outbound's chain fcn.0x11894 (passes chan+8) → fcn.0xf0bc (r5 = r4+8) → Path B (r0 = r5 = chan+0x10) yields the same r4). So the strb-target [r4, 0x29] resolves to chan+0x39 in BOTH paths. The wire-frame builder fcn.0xae418 reads [r4, 0x15] with its r4 = chan+0x24, also chan+0x39. One physical slot, written by both paths, read by the builder.
Confirmation of the original Trace #52 hypothesis. Stock mtkbt's inbound RX writes the AV/C command's transId to chan+0x39 via Path B at 0x6d188. The outbound IPC chain then ALSO calls Path B (because RegNotif INTERIM/CHANGED's IPC msg id ≤ 6 routes through the fcn.0xf0bc → fcn.0xef08 → fcn.0x6d0f0 Path B branch — cbz r3, 0xf186 at 0xf138 with r3 = pkt[9], where pkt[9] = (alloc_msg_type > 6) ? 1 : 0 set by fcn.0x11894 at 0x11912). The outbound packet has pkt[0xd] = 0 (set unconditionally by allocator at 0x11924), so the second Path B call writes chan+0x39 = 0, clobbering the inbound-latched TID before fcn.0xae418 reads it. Wire TID = 0 for every outbound Path B response.
Why the M4 patch's "msg=544" naming is correct despite the IPC discriminator. The cmp r6, 6 at 0x11906 compares r6 = arg2 (the IPC layer's small msg type, 1..N) — not the AVRCP wire-format PDU identifier (0x208 = msg=520 cmd_frame_ind, 0x21C = msg=540 GEA, 0x220 = msg=544 RegNotif). The allocator's r6 is the internal IPC message type, and short single-PDU responses (cmd_frame_ind_rsp, RegNotif INTERIM, GetCaps) all use a small IPC type that sets pkt[9] = 0, routing them to Path B. Fragmented responses (GEA) use a larger IPC type, setting pkt[9] = 1 and routing through Path A. So all the Path B-bound msg_ids (520, 522, 544, possibly others) share the same pkt[0xd] = 0 clobber bug.
Why NOP-at-0x6d188 doesn't work. A flat 84 f8 29 00 → 00 bf 00 bf NOP at 0x6d188-0x6d18b (4 bytes) would prevent the outbound clobber, but the same instruction is also the only path that latches the inbound TID. NOPping it kills the inbound side too, leaving chan+0x39 permanently at whatever value preceded the AVRCP session start (likely 0). Net effect: same as stock (wire TID = 0).
Discriminator candidates within Path B (no msg-id reach). Path B's r5 is the inbound stash (chan+0xb9c) or the outbound IPC packet (from fcn.0x11894's free-list fcn.0x6ce6a pop). Bytes that DIFFER reliably between the two:
| Byte | Outbound IPC packet | Inbound stash struct |
|---|---|---|
[r5, 8] | 1 (allocator strb r2, [r4, 8] with r2=1 at 0x11908) | LSB of pkt_ptr (heap-aligned, typically 0) |
[r5, 9] | 0 or 1 (msg-type discriminator) | byte 1 of pkt_ptr (random-ish) |
[r5, 0xb] | arg2 = IPC msg type (small int, 1..N) | byte 3 of pkt_ptr (random-ish) |
[r5, 0xc] | unwritten (whatever's in the popped buffer) | unwritten |
[r5, 0x14] | size (always set by allocator's strh.w sb, [r4, 0x14] at 0x11930, capped at 0x200) | bytes from arg_44h - 1 + ? (random) |
pkt[8] == 1 is the cleanest "this came from the allocator" signal — and since the byte's already loaded into r2 at 0x6d180 (ldr r2, [r5, 8] for the unrelated str.w r2, [r4, 0xec] at 0x6d182), a UXTB + CMP + IT/STRB sequence could discriminate without reloading. But it expands to 10 bytes vs the original 6 — no in-place fit.
Updated fix design space.
- Code-cave trampoline inside
fcn.0x6d0f0. Replace0x6d186-0x6d18b(6 bytes) withb.w <cave>(4 bytes) +nop(2 bytes). The cave doesldrb r0, [r5, 0xd]; uxtb r2, r2; cmp r2, 1; bne do_write; b.w 0x6d18c; do_write: strb.w r0, [r4, 0x29]; b.w 0x6d18c. Skips the strb on outbound (preserves the inbound-latched chan+0x39); keeps the strb on inbound. Net effect: outbound responses inherit whatever TID the LAST inbound CMD landed at chan+0x39, satisfying §6.5 echo for command-response exchanges and §6.7.2 echo for RegNotif when the subscription's CMD is the most recent inbound. Requires identifying ~24 bytes of code-cave space in mtkbt. - Patch
libextavrcp.so cmd_frame_ind_rspto setipc[5] = conn[0x11](Option 1 from Trace #52). 6 bytes needed in a 124-byte function. Then the M6 universal allocator-side patch works for all msg_ids includingmsg=520. Total: 2-byte mtkbt change + 6-byte libextavrcp.so change. - Per-msg-id conditional in
fcn.0x11894(Option 3 from Trace #52). Insertcmp r6, <RegNotif_internal_msg_type>; beq do_lift; movs r6, 0; b cont; do_lift: ldrb r6, [r1, 5]; cont: strb r6, [r4, 0xd]. Same code-cave requirement as (1). Less spec-aligned than (1) — leaves GEA + GetCaps still emitting TID=0.
(1) is structurally cleanest if a code-cave exists in mtkbt. (2) is a two-binary change but doesn't need code-caves — could be done with byte-level overlay if 6 bytes can be found in cmd_frame_ind_rsp's prologue/epilogue. Pending user decision on which to pursue.
Confidence. High on the call-chain mapping (verified by axt cross-references in radare2). High on chan+0x39 being the single shared slot (re-traced through both wrapper paths with consistent r4 = chan+0x10 in Path B). Medium on (1)'s code-cave availability — needs an inventory pass on mtkbt. High on (2)'s feasibility given the prior libextavrcp.so RE done for E1.
Trace #54 (2026-05-17) — M5 landed: code-cave trampoline in mtkbt LOAD #1 padding
Pursued option (2) from Trace #53 (renamed to fit the M-series naming convention in patch_mtkbt.py; superseded the option-(1) "patch cmd_frame_ind_rsp" approach which turned out to need coordinated 2-binary IPC-layout changes plus mtkbt-side ctype-source RE for msg=520). Single-binary mtkbt fix using the same ELF segment-extension trick patch_libextavrcp_jni.py already uses for its trampoline blob.
Cave space inventory. mtkbt's LOAD #1 ends at file/vaddr 0xf366c (.ARM.extab end). The next LOAD segment (RW data) starts at file 0xf3d40 / vaddr 0xf4d40 (page-aligned). File bytes 0xf366c..0xf3d40 and vaddr bytes 0xf366c..0xf4d40 are page-padding zeros; verified all-zero in the stock binary via python3 byte scan. 1748 bytes of file padding, 5332 bytes of vaddr gap before LOAD #2 — plenty of room for a 16-byte trampoline.
Cave placement. vaddr 0xf3680 (16-byte aligned, 20 bytes past 0xf366c for cleanliness). Trampoline body 16 bytes; extended LOAD #1 filesz/memsz = 0xf3690. Net file growth: 36 bytes (20-byte alignment padding + 16-byte cave content), but the original 20 padding bytes were already zero in the stock file — only the 16 cave bytes flip from 00 00 … to the trampoline.
Trampoline design.
0xf3680 68 7b ldrb r0, [r5, 0xd] ; original 1st insn — TID byte
0xf3682 2a 7a ldrb r2, [r5, 8] ; discriminator load
0xf3684 01 2a cmp r2, 1 ; outbound = 1 (allocator), inbound = 0xea+ (LSB of resolved literal)
0xf3686 01 d0 beq 0xf368c ; skip strb on outbound
0xf3688 84 f8 29 00 strb.w r0, [r4, 0x29] ; original 2nd insn (inbound only — latch transId at chan+0x39)
0xf368c 79 f7 7e bd b.w 0x6d18c ; return into Path B's post-strb body
Call site rewrite at 0x6d186 (6 bytes):
before 68 7b 84 f8 29 00 ldrb r0, [r5, 0xd]; strb.w r0, [r4, 0x29]
after 86 f0 7b ba 00 bf b.w 0xf3680; nop
Discriminator robustness verified via static literal resolution. Inbound stash struct's +8 field is set at fcn.0x11374:0x11458 str.w r6, [r4, 0xba4] where r6 = ip + (chnl_num << 11). ip resolves from the literal at 0x114a0 (value 0xeaba8) added to PC at fcn.0x11374:0x1143e add ip, pc (PC + 4 = 0x11442): static ip = 0xfbfea. At runtime, ip + load_base (page-aligned load_base) preserves LSB 0xea. For chnl_num ∈ {0, 1}: stash[+8] LSB is constant 0xea ≠ 1. Outbound allocator path writes packet[8] = 1 unconditionally at fcn.0x11894:0x11908. cmp r2, 1 cleanly separates the two; no value collision possible.
ELF surgery. LOAD #1 phdr at file 0x74 (the 3rd phdr after PHDR + INTERP). filesz at +16 = file 0x84; memsz at +20 = file 0x88. Both bumped from 0xf366c to 0xf3690. No section headers touched (Linux kernel ELF loader uses program headers exclusively for segment mapping; the section table is for static linker / objdump consumption only).
Patch list (4 entries appended to patch_mtkbt.py):
[M5]call-site rewrite at0x6d186(6 bytes: ldrb+strb → b.w cave + nop).[M5-CAVE]trampoline blob at0xf3680(16 bytes: replace zero padding with the conditional-store body).[M5-FILESZ]LOAD #1 filesz at0x84(0xf366c → 0xf3690).[M5-MEMSZ]LOAD #1 memsz at0x88(0xf366c → 0xf3690).
OUTPUT_MD5: a10ca9636417a0ed71495dfa11b5eff0 → dc01a7c1337ad2dc6573819bdc22834d.
Pre-flash verification done:
python3 patch_mtkbt.py /work/v3.0.2/.../mtkbt→ output MD5 matches expected. All 18 patch sites verified (M1-M5).- Re-run patcher on the patched output → idempotency-detected, no-op exit 0.
- radare2 disassembles cave correctly at
0xf3680(6 instructions, return b.w resolves to0x6d18c) and the call site at0x6d186showsb.w 0xf3680with bidirectional xref. r2 reports proper CODE XREFs between the two regions. - Byte-level verification of patched output:
0x6d186 = 86 f0 7b ba 00 bf(correct b.w + nop);0xf3680 = 68 7b 2a 7a 01 2a 01 d0 84 f8 29 00 79 f7 7e bd(correct cave content); phdr filesz/memsz both0xf3690.
Risk assessment. M5 lands without flashing in this session; the user will flash and capture from the separate flash machine. Known risk surface:
- Discriminator collision (
packet[8] == 1for an inbound stash) — ruled out by static literal resolution; LSB is constant0xeaacrosschnl_num∈ {0, 1}. - Path B path coverage — M5 affects every Path B call site (both inbound and outbound). The inbound case unchanged (strb still executes). The outbound case now skips the strb. No other code paths read
[r4, 0x29]fromr4 = chan + 0x10post-Path-B besidesfcn.0xae418's wire-frame builder, so the only downstream consumer benefits from the fix. - Subsequent inbound CMD overwrites —
chan+0x39is per-channel and gets re-latched on the next inbound CMD. For RegNotif subscriptions, the §6.7.2 stickiness depends on the RegNotif being the most recent CMD before the corresponding INTERIM/CHANGED is built. Wire-side timing (mtkbt's IPC dispatcher is single-threaded; responses are built in IPC msg arrival order) makes intervening CMDs unlikely between a RegNotif and its INTERIM. For T9/T5 edge-driven CHANGED emits hours after the subscription, the most recent CMD's TID is used — which is the §6.5 echo target anyway, so it works for command-response exchanges too. Both Sonos (TID=0 always) and Bolt (TID 0-15 rotation) should land correctly. - MD5 drift on idempotent re-runs —
apply.bash'spatch_in_place_byteshelper detects "already patched" and skips the write-back. Verified by re-running the patcher on the patched output.
Pending: hardware flash + dual-capture from Bolt and Sonos to confirm. If the trampoline misfires, revert is a single-commit revert; the patch set is contained.
Trace #55 (2026-05-17) — M5 post-flash: TID echo correct but button-handling regression surfaces on Bolt
Post-M5 capture (dual-bolt-20260517-1254, mtkbt MD5 dc01a7c1...): zero indication-590 rejects, no AVCTP retry storm, RegNotif INTERIM-CHANGED handshake completes for the initial track and one mid-session stable track (Bad Religion → 12:52:53 GEA, Paramore → 12:54:26 GEA). The transId echo from M5 lands clean — Bolt accepts the wire frames at the protocol layer.
User-reported regression in the same capture: Bolt's PAUSE button on its stateful UI sometimes "toggles to a Play icon (as if it paused) but did not pause. Hit a couple more times and it eventually resumes playback but does not toggle back to pause." Logcat trace at 12:53:05.262 (the first reported PAUSE press):
12:53:05.262 MMI_AVRCP Receive a Avrcpkey:70 (= 0x46 PAUSE, from Bolt) DOWN
12:53:05.273 MMI_AVRCP Receive a Avrcpkey:70 UP
12:53:05.288 Y1Patch BaseActivity.dispatchKeyEvent entry DOWN
12:53:05.292 Y1Patch BaseActivity.dispatchKeyEvent entry UP
12:53:05.293 Y1Patch PlayerService.playOrPause() entry ← TOGGLE called
12:53:05.296 Y1Patch PlayerService.pause(IZ) entry ← toggle's inner branch picked pause
12:53:05.297 Y1Patch PlaybackStateBridge.onPlayValue entry newVal=3 reason=3
12:53:05.298 Y1Patch TrackInfoWriter.setPlayStatus entry sPS.from=1 sPS.to=2
PlayControllerReceiver.onReceive does NOT fire in the same window. Patch E's discrete cond_pause_strict arm (KEYCODE_MEDIA_PAUSE (0x7f) → pause(0x12, true)) is in PlayControllerReceiver and never gets the chance to handle this press.
Where the discrete PAUSE keycode is lost. Tracing through the input stack:
libextavrcp_jni.so'savrcp_input_sendkey(sym at0x76b4) has a keymap table at vaddr0xccec(file0xbcecin.data.rel.ro.local); each 8-byte entry holds an AV/C PASSTHROUGH op_code at+4and a Linux keycode at+6(uint16). Entry 2 maps0x46 PAUSE→ LinuxKEY_PAUSECD(201).libextavrcp_jni.sowrite()s that to/dev/input/event4(the AVRCP uinput device opened inavrcp_input_init, name"AVRCP", registered withBUS_BLUETOOTH).- Android's input dispatcher reads the Linux keycode and consults
/system/usr/keylayout/AVRCP.kl(selected because the device name matches). The stock AOSPAVRCP.kl(MD5366670c4f944150bd657d9377839463a, identical across firmware 3.0.2 and 3.0.7) has:
key 200 MEDIA_PLAY WAKE ← KEY_PLAYCD → 126 KEYCODE_MEDIA_PLAY
key 201 MEDIA_PLAY_PAUSE WAKE ← KEY_PAUSECD → 85 KEYCODE_MEDIA_PLAY_PAUSE
key 166 MEDIA_STOP WAKE
key 163 MEDIA_NEXT WAKE
key 165 MEDIA_PREVIOUS WAKE
key 168 MEDIA_REWIND WAKE
key 208 MEDIA_FAST_FORWARD WAKE
The file has the standard AOSP copyright header — this is stock AOSP content, not a Y1 vendor remap. The key 201 MEDIA_PLAY_PAUSE mapping predates Android's discrete KEYCODE_MEDIA_PAUSE (0x7f = 127, post-Android-3.0 Honeycomb addition) and coalesces both discrete PASSTHROUGH commands into the toggle key. No Linux keycode in the stock AVRCP.kl maps to MEDIA_PAUSE (127).
BaseActivity.dispatchKeyEvent(Patch H) propagates discrete media keys (126 MEDIA_PLAY,127 MEDIA_PAUSE,86 STOP,87 NEXT,88 PREV) so they reachPlayControllerReceiver(Patch E's discrete arms).85 MEDIA_PLAY_PAUSEis NOT in the bypass set — Patch H falls through to BaseActivity's stock body, which catchesv2 == KeyMap.KEY_PLAY (= 85)at smali line 2215 and callsPlayerService.playOrPause()(toggle).
playOrPause() is the legacy toggle: pause-while-playing → pause; pause-while-paused → resume. Press 1 pauses Y1; press 2 (which the user issues thinking "the first didn't pause") resumes. The "doesn't toggle back to pause" perception is because, separately, T9emit pstat had stopped firing at 12:52:57 (Bolt had stopped re-subscribing to PLAYBACK_STATUS_CHANGED after the rapid track-change burst), so Bolt's UI didn't receive the second pstat=1 to flip its icon back. Two distinct bugs interacting.
CT differential (user-reported, 2026-05-17): Samsung TV is instant and 100%, Kia has lag but works, Sonos works (modulo no playhead). Bolt is the only CT in the test matrix that exhibits this PAUSE symptom. The mechanism: TV / Kia / Sonos send 0x44 PLAY as a universal toggle (the older convention — CT sends 0x44 every press regardless of icon state, and Y1's PlayControllerReceiver.cond_play_strict does the smart toggle if isPlaying: playOrPause() else play(true)). Their UIs follow PLAYBACK_STATUS_CHANGED to flip icons. Bolt sends DISCRETE 0x44 / 0x46 based on its current icon state — a more spec-compliant CT-side choice that exposes the AOSP AVRCP.kl deviation.
K1 — AVRCP.kl row 201: MEDIA_PLAY_PAUSE → MEDIA_PAUSE (patch_avrcp_kl.py, file offset 0x2ac, length-preserving). Post-K1: KEY_PAUSECD (201) → MEDIA_PAUSE (127) → Patch H propagates → PlayControllerReceiver.cond_pause_strict → pause(0x12, true) (discrete, idempotent on repeat). Row 200 (KEY_PLAYCD → MEDIA_PLAY) unchanged, so the TV / Kia / Sonos toggle-via-0x44 path is preserved exactly.
Why patch AVRCP.kl despite feedback_y1_upstream_spec_compliance.md ("Don't change AVRCP.kl remap")? The standing guidance is about not papering over BT-stack bugs with AVRCP.kl remaps. Here, the spec deviation IS in AVRCP.kl itself — stock AOSP's row 201 violates AVRCP 1.3 §4.6.1's discrete-PAUSE definition. The fix is a one-line completion of the AVRCP-spec → Linux-keycode → Android-keycode chain, not a remap to compensate for something else. It also benefits any music app on Y1 (including Rockbox if it ships with stock AVRCP.kl), matching the upstream-compatible philosophy.
Why not change libextavrcp_jni.so's table instead? Three options were considered:
- AVRCP.kl row 201 →
MEDIA_PAUSE(chosen). Single-line text edit, length-preserving, no second binary touched, no risk of byte-mismatch on a future firmware that reorders the keymap table. - Add
key 119 MEDIA_PAUSE WAKEto AVRCP.kl + patchlibextavrcp_jni.so's table entry 2 to emit LinuxKEY_PAUSE(119) instead ofKEY_PAUSECD(201). Two-file change, additive, but introduces a second patcher coordination point. - Patch only
libextavrcp_jni.soto emit a different Linux keycode that the existing AVRCP.kl maps to anything exceptMEDIA_PLAY_PAUSE. Not possible — no Linux keycode in the stock AVRCP.kl maps to discreteMEDIA_PAUSE.
Verification: python3 patch_avrcp_kl.py /work/v3.0.2/system.img.extracted/usr/keylayout/AVRCP.kl → output MD5 dfd9afd58e94c38fc6f92592674b4ef1. Cross-version verification: /work/v3.0.7/system.img.extracted/usr/keylayout/AVRCP.kl produces the same output MD5 (input is byte-identical). Idempotency: re-running on the patched output exits 0 with "already at expected output."
Pending. Hardware flash + dual-capture from Bolt to confirm the discrete-PAUSE routing. Test plan: pause from playing → state goes paused, no resume on second pause press; play from paused → state goes playing. Should also verify TV / Kia / Sonos still work (the toggle-via-0x44 path is untouched but worth confirming end-to-end).
Pending separately: the pstat-delivery gap (Bolt stops re-subscribing to PLAYBACK_STATUS_CHANGED after a rapid track-change burst). K1 doesn't address that; it's a different RE thread. Hypothesis to investigate: Bolt's CT logic gates re-subscription on track stability (similar to its GEA-query gating from Trace #50-#53), and the rapid track-change burst (9 T5 emits in 90 seconds, post-12:52:57) trips the back-off. Mitigation might be to coalesce / debounce TRACK_CHANGED CHANGED emits during rapid skips, but this is speculative without more CT-side observation.
Trace #56 (2026-05-17) — B5.2t: suppress the track-change pstat=PAUSED blip
Hypothesis source. Trace #55 closed with: "rapid pstat oscillation during track-change boundaries (pstat=1 → pstat=2 → pstat=1 → pstat=2 within ~20 seconds) tripped Bolt's TG-misbehaving back-off heuristic." Re-examining the dual-bolt-20260517-1254 log entries from Y1Patch's PlaybackStateBridge.onPlayValue instrumentation surfaces a consistent pattern at every track-change boundary:
12:52:42.266 KEY_NEXTSONG DOWN/UP (Bolt user-press skip)
12:52:42.328 PlayControllerReceiver.onReceive
12:52:42.378 ... routes through PlayerService.nextSong()
12:52:42.392 PlayerService.pause(IZ) entry ← internal to nextSong/restartPlay chain
12:52:42.393 PlaybackStateBridge.onPlayValue entry newVal=3 reason=3
12:52:42.443 T5emit aid=cf00675b (TRACK_CHANGED for the new track, 50 ms after the pause)
12:52:42.697 PlaybackStateBridge.onPlayValue entry newVal=1 reason=8 ← new track playing, 304 ms after pause
The pause(IZ) call inside restartPlay hard-codes Static.setPlayValue(3, 3) (smali line 4344, const/4 p2, 0x3; invoke-virtual {p1, p2, p2}) — IDENTICAL (newValue, reason) shape to a user-initiated pause. So the inbound stream into PlaybackStateBridge.onPlayValue has no native signal distinguishing "this is the start of a track-change pause→play handshake" from "the user just pressed PAUSE on a stable track." With no signal, the previous wakePlayStateChanged() fired unconditionally → T9 → PLAYBACK_STATUS_CHANGED CHANGED with pstat=0x02 PAUSED on the wire, followed within 300 ms by another CHANGED with pstat=0x01 PLAYING. Five such transient pause/play pairs occurred between 12:52:42 and 12:53:53 — clustered around user-initiated track skips.
Bolt's last T8reg ev=01 (RegisterNotification(PLAYBACK_STATUS_CHANGED)) is at 12:52:53. The next T9emit pstat after that (the 12:52:57.711 one for the Strike Anywhere track-change pause-blip) fires AGAINST Bolt's subscription gate (it had a pending subscription) — but Bolt doesn't re-subscribe afterward. The conjecture is Bolt's CT, after seeing the transient pstat=2 emit that's immediately contradicted by pstat=1, decided the TG is unstable and stopped re-subscribing to that event class. POS_CHANGED (ev=05) re-subscribes later at 12:54:26, suggesting the back-off is per-event-class, not session-wide.
Patch B5.2t. Inject a markTrackChange() static call at the entry of PlayerService.restartPlay(Z) / autoSwitch() / nextSong() / prevSong() (four prepends in PlayerService.smali). PlaybackStateBridge.markTrackChange() sets a trackChangeDeadlineMs field to SystemClock.elapsedRealtime() + 1000ms (monotonic, no DST/wall-clock skew concerns). onPlayValue then skips wakePlayStateChanged() when newValue == 3 (PAUSED) AND elapsedRealtime() < trackChangeDeadlineMs. The setPlayStatus file flush, wakeTrackChanged(), and PositionTicker.stop() all remain synchronous, so polling CTs (T6 GetPlayStatus) see correct paused state during the gap, and TRACK_CHANGED CHANGED still emits with the new track's UID at the correct cadence.
End-to-end behaviour for a track-change skip post-B5.2t:
Bolt KEY_NEXTSONG
→ PlayerService.nextSong() entry → markTrackChange() — deadline = +1000 ms
→ PlayerService.restartPlay(Z) entry → markTrackChange() — deadline re-armed
→ PlayerService.pause(IZ) → setPlayValue(3, 3) → onPlayValue(3, 3)
→ file[792] = PAUSED (synchronous flush — polling-correct)
→ SUPPRESSED: wakePlayStateChanged() (deadline in future)
→ wakeTrackChanged() — T5 emits TRACK_CHANGED for new track
→ PositionTicker.stop()
→ IjkMediaPlayer.reset / setDataSource(new) / prepareAsync()
→ ~300 ms later: OnPreparedListener → PlayerService.play() → onPlayValue(1, 8)
→ file[792] = PLAYING
→ NOT suppressed (newValue != 3)
→ wakePlayStateChanged() — T9 emits PLAYBACK_STATUS_CHANGED pstat=PLAYING
→ wakeTrackChanged() (idempotent on same-track edge)
→ PositionTicker.start()
Net wire-side: CT sees ONE PLAYBACK_STATUS_CHANGED CHANGED per track-change (pstat=PLAYING for the new track), not a paused-then-playing flap. The TRACK_CHANGED CHANGED still emits (with the new UID) so the CT knows to invalidate its metadata cache. Same number of state transitions a TV/Kia/Sonos CT would see from a well-implemented TG.
User-pause path (CTs sending discrete PASSTHROUGH 0x46 post-K1 routing through cond_pause_strict → pause(0x12, true)): not affected unless the user happens to press PAUSE within 1s of a track-change entry, which is an unusual sequence. In that edge case the user pause is suppressed; the user perceives "pause didn't take" but a second press a moment later (after the 1s window) will pause normally. This trade-off is acceptable for the dominant case (Bolt subscriptions remain alive across track skips).
Verification done in this session (pre-flash):
patch_y1_apk.py --clean-stagingagainst stock 3.0.2 APK → all 4 markTrackChange prepend anchors found; B5.2t reports success.- Patched
PlayerService.smaliinspection: each ofrestartPlay(Z) / autoSwitch() / nextSong() / prevSong()has theinvoke-static markTrackChange()Vline at entry, after.locals Nand before the first body statement. - Patched
PlaybackStateBridge.smaliinspection:trackChangeDeadlineMs:Jfield present;markTrackChange()Vmethod present withSystemClock.elapsedRealtime() + 1000math;onPlayValuebody has thecmp-long+if-ltz :skip_wake_play_stateblock guardingwakePlayStateChanged()only whenv0 == 2(AVRCP PAUSED). - APK reassembly: smali assembler accepted the new
.locals 8andcmp-longopcodes; classes.dex rebuilt to 9,244,228 bytes, classes2.dex to 8,971,328 bytes.
Pending hardware validation: flash the new APK and re-capture Bolt + the other CTs. Expected wire-side change:
- Bolt: T9emit pstat=2 events should disappear from track-change boundaries; only pstat=1 emits remain after each skip. T8reg ev=01 should keep firing (Bolt continues subscribing).
- TV / Kia / Sonos: same as before — the AVRCP.kl K1 + B5.2t are both no-ops for the toggle-via-0x44 path until/unless those CTs press pause while a track-change is mid-flight, which is rare.
Trace #57 (2026-05-17) — B5.2t post-flash: suppression partial, in-flight PositionTicker broadcast leaks
Post-K1 + B5.2t capture (dual-bolt-20260517-1420, music APK com.innioasis.y1_3.0.7-patched.apk, mtkbt MD5 dc01a7c1...). Bolt subscription health DRAMATICALLY improved: T8reg ev=01 count 6 → 32 (vs dual-bolt-20260517-1254), 0 indication-590 rejects, no AVCTP retry storm.
But B5.2t didn't fully suppress the pstat=PAUSED blip. Cross-referencing each T9emit pstat=2 against (nextSong | prevSong | restartPlay | autoSwitch) entry timestamps:
| emit timestamp | Δt from last track-change entry | classification |
|---|---|---|
| 14:18:19.751 | 3.85 s | user pause (legit, should emit) |
| 14:18:31.300 | 0.02 s | BLIP-LEAK |
| 14:18:44.510 | 0.02 s | BLIP-LEAK |
| 14:18:53.622 | 9.13 s | user pause (legit) |
| 14:19:03.663 | 0.07 s | BLIP-LEAK |
| 14:19:09.230 | 5.64 s | user pause (legit) |
| 14:19:19.842 | 0.10 s | BLIP-LEAK |
| 14:19:23.796 | 0.08 s | BLIP-LEAK |
| 14:19:31.914 | 0.07 s | BLIP-LEAK |
| 14:19:39.055 | 0.06 s | BLIP-LEAK |
7 of 10 emits are blip-leaks (Δt from markTrackChange() < 1 s, fully inside the suppression window). B5.2t's wake-suppression worked: TrackInfoWriter.wakePlayStateChanged() was NOT called during the window. So how did T9emit pstat=2 still fire?
Root cause — in-flight PositionTicker broadcast. Reconstructing the 14:18:31 leak:
14:18:30.565 PositionTicker.run (1 s tick) → wakePlayStateChanged() → Intent("playstatechanged") queued
14:18:30.565 …Intent in flight to MtkBt's BroadcastReceiver thread
14:18:31.284 PlayerService.prevSong() entry → markTrackChange() — deadline = 14:18:32.284
14:18:31.285 PlayerService.restartPlay(Z) entry → markTrackChange() — deadline re-armed
14:18:31.288 PlayerService.pause(IZ) entry
14:18:31.288 onPlayValue(3, 3) → setPlayStatus(2) → file[792] = 2 (PAUSED)
14:18:31.288 onPlayValue → WAKE SUPPRESSED (in window, B5.2t works)
14:18:31.293 wakeTrackChanged() (NOT suppressed, T5 fires)
14:18:31.296 PositionTicker.stop() (future ticks cancelled, but the 30.565 broadcast is already in flight)
14:18:31.300 MtkBt finally drains the queued 30.565 Intent → notificationPlayStatusChangedNative → T9 trampoline runs
14:18:31.300 T9 reads file[792]=2 (newly written), state[9]=1 (last_play_status) → EDGE → emit pstat=2 ← THE LEAK
14:18:31.539 onPlayValue(1, 8) → setPlayStatus(1) → file[792] = 1, normal wake (out of window)
14:18:31.545 PositionTicker.start()
The in-flight broadcast was queued BEFORE the track-change started, so our markTrackChange deadline check (inside onPlayValue) never sees it. By the time MtkBt drains the broadcast, file[792] has flipped to 2 and T9 reads the new value. Wake-suppression alone is insufficient.
Fix. Extend B5.2t to ALSO skip TrackInfoWriter.setPlayStatus(2) during the suppression window. With file[792] held at the prior PLAYING value through the blip, any in-flight T9 reads no edge → no emit. mPlayStatus also stays at the prior value, so downstream flushLocked calls (e.g. from onEarlyTrackChange) propagate the prior value. wakeTrackChanged() and PositionTicker.stop() still fire, so the CT still gets TRACK_CHANGED CHANGED for the new track.
Concrete smali change: move invoke-virtual {v1, v0}, ...setPlayStatus(B)V from before the suppression branch (where it always fires) into the :do_wake_play_state arm (where it only fires when NOT suppressing).
Edge case. User presses PAUSE within 1 s of a track-switch: pause is silently dropped (no CT-visible pstat=2). Audio still pauses at the engine level (IjkMediaPlayer.pause runs synchronously inside pause(IZ)), so the user hears silence, but the CT's UI stays at the "pause icon" because no pstat=2 ever reaches it. Once the user does anything else (play, next, etc.), the next pstat broadcast re-syncs. Acceptable trade-off for the dominant case (Bolt subscriptions stay alive across rapid track skips).
Wider observation. Bolt's T4 GEA queries count is just 1 in the 5-min dual-bolt-20260517-1420 capture. Pre-fix Bolt 1254 had 4 in ~3 min. Bolt's behaviour is now in a "subscribed, no metadata fetch" state, suggesting another gate is being tripped. Possibly the pstat=2 leaks ARE the cause — Bolt sees them and stops querying GEA even though it keeps re-subscribing. The second B5.2t iteration (skip setPlayStatus too) should clear this once flashed.
Pending: re-flash with the updated APK and re-capture. Expected delta: T9emit pstat=2 count drops from 10 to ~3 (only the user-initiated pauses). T4 GEA queries should rise correspondingly.
Trace #58 (2026-05-17) — B5.2t setPlayStatus-skip reverted on first Sonos test, then re-applied after re-test
Initial post-flash report. First Sonos capture (dual-sonos-20260517-1757) appeared to show a precise 2,004 ms delay between every PlayerService.play(Z) entry and the matching PlaybackStateBridge.onPlayValue entry. Six distinct play(Z) calls in the capture all landed at 2.003-2.005 s. User-visible symptom: press pause -> audio pauses, press play -> audio doesn't resume visibly for ~2 s, user presses play again -> cond_play_strict sees isPlaying=true (the first press's resume finally landed) -> playOrPause() toggles back to PAUSE. Perceived as "stays paused."
Cross-references at the time:
- Bolt 1420 (post B5.2t initial, BEFORE setPlayStatus-skip):
play(Z) -> onPlayValuegap 8-10 ms. - Sonos 1347 (post K1, pre B5.2t entirely): gap 28-33 ms.
- Sonos 1757 (post setPlayStatus-skip): gap 2004 ms.
The smali change in b0d8be1 only moved setPlayStatus(B)V from before the suppression branch into the :do_wake_play_state arm. For newValue=1 (PLAYING), the branch falls through to :do_wake_play_state and the same instructions execute -- same setPlayStatus, same wakePlayStateChanged, same wakeTrackChanged. Functionally identical for the PLAY path. The 2-second timing was unexplained.
Reverted in 9c2f873 based on the user-reported regression.
Re-test. On a second hardware test the user reported the symptom not reproducing -- b0d8be1 was re-applied (revert-of-revert) and shipped again. The 2,004 ms delay in dual-sonos-20260517-1757 may have been a transient -- possibly an IjkMediaPlayer buffer-warmup that happened to align with the user's first capture, or a unrelated timing artefact. The smali change is byte-identical for the PLAYING path, so a sustained 2 s regression has no mechanistic basis in the code.
Keeping the setPlayStatus-skip in place. If the 2 s delay returns on subsequent captures, the next investigation target is IjkMediaPlayer.start() timing under varying file[792] write cadences -- but without reproducibility there's nothing to chase.
Trace #59 (2026-05-18) — §6.7.1 gate-clear relax tried, regressed Kia, reverted; analytical failure documented
Premise. User reported the Kia EV6 head unit was laggy on position bar / play-pause icon / track-time display against Y1, while a captured Pixel-4-as-AVRCP-1.3-TG paired with the same Kia head unit was "instant." Both TGs advertised structurally identical SDP records (profile version 0x0103, SupportedFeatures=0x0001, no Browse PSM, same 8-event GetCapabilities), so the divergence wasn't in the wire-level capability handshake.
The bad inference
I extracted the Pixel-Kia btsnoop at /work/logs/pixel4-bugreport/FS/data/misc/bluetooth/logs/btsnoop_hci.log with tshark and ran summary statistics by btavrcp.pdu_id == 0x31 + ctype:
| event | Pixel CHANGED count | apparent cadence |
|---|---|---|
| 0x01 PlaybackStatus | 4 | on edges |
| 0x02 TrackChanged | 5 | on edges |
| 0x05 PlaybackPositionChanged | 42 | ~1.02 s spacing |
| 0x09 NowPlayingContent | 5 | on edges |
I read "42 ev=05 CHANGEDs at 1 Hz" and concluded "Pixel emits ev=05 CHANGED at 1 Hz throughout the subscription, regardless of CT re-registration." I framed this as the "universal interpretation" of AVRCP 1.3 §5.4.2 (matching AOSP Bluedroid / BlueZ / iOS) and proposed removing Y1's four _emit_subscription_write(a, 0, …) gate-clear calls so Y1 would mirror what I claimed Pixel did.
User asked the right question ("Will this break anything? Are we sure this matches Pixel?") and I answered yes on both counts, with confidence that wasn't backed by per-frame verification.
Committed as 40fca40 fix(trampolines): emit CHANGED for subscription lifetime, not once per registration:
| event | gate byte | emit site | clear site removed |
|---|---|---|---|
| 0x01 PLAYBACK_STATUS | state[14] | T9 line 2293 | T9 line 2298 |
| 0x02 TRACK_CHANGED | state[16] | T5 line 925 | T5 line 942 |
| 0x05 PLAYBACK_POS_CHANGED | state[13] | T9 line 2516 | T9 line 2523 |
| 0x08 PLAYER_APP_SETTINGS | state[15] | T9 line 2395 | T9 line 2400 |
OUTPUT_MD5: d803f42c... → 813d008db4914f43e33e0dd3e11a25e7; OUTPUT_DEBUG_MD5: 3900c800... → f723fadb6d629d4ae6ef738552cb734b.
Post-flash regression (capture dual-kia-20260518-0836)
Debug-instrumented build. ~20 min of Kia playback with multiple play/pause cycles and track skips. Y1T trampoline trace:
Y1T tag | pre-relax dual-kia-20260517-1842 | post-relax dual-kia-20260518-0836 |
|---|---|---|
T9emit pstat= | 1 | 6 (one per actual edge) |
T9emit pos= | 1 | 974 at ~1 Hz |
T5emit aid= | 1 | 10 |
T8reg ev=05 from Kia | 0 | 9 |
The code was firing as designed. Y1 emitted ev=05 CHANGED 974 times. Kia re-registered ev=05 nine times. Net ratio: 108 unsolicited CHANGEDs per Kia-issued re-register.
User-visible: play/pause button no longer responsive, track-time display broken, playhead-position scrubber broken. Worse than the pre-relax state in every UI dimension.
The actual Pixel pattern (TL field revealed it)
Re-extracted the Pixel ev=05 frames with the AVCTP transaction label field this time:
Frame 1511 NOTIFY ev=05 TL=5 Kia subscribes
Frame 1512 INTERIM ev=05 TL=5 Pixel ack
Frame 1646 CHANGED ev=05 TL=5 Pixel emits (~6 s later, on first state change)
Frame 1648 NOTIFY ev=05 TL=7 Kia re-registers ~6 ms after CHANGED
Frame 1649 INTERIM ev=05 TL=7 Pixel ack
Frame 1660 CHANGED ev=05 TL=7 next emit, 1.002 s after re-register
Frame 1662 NOTIFY ev=05 TL=b re-register
Frame 1663 INTERIM ev=05 TL=b
Frame 1670 CHANGED ev=05 TL=b next emit, 1.023 s after re-register
... pattern repeats 42 times ...
Every CHANGED's AVCTP TL matches a NOTIFY immediately preceding it. Pixel emits one CHANGED per RegisterNotification — strict §6.7.1. The 1 Hz wire cadence comes from Kia re-registering within ~6 ms of every CHANGED, closing the cycle fast enough that the music app's 1 Hz internal tick determines the inter-CHANGED interval, not from Pixel emitting unsolicited.
The TL data was in the same tshark output I'd already extracted at the time of the proposal. I just didn't compare adjacent frames' TLs — the aggregate statistic ("42 at 1 Hz") fit the hypothesis I was looking for and I stopped checking.
What the relax actually did
Y1 pre-relax was already implementing strict §6.7.1 — the same model Pixel implements. The "universal interpretation" I argued for was a deviation from Pixel, not an alignment with it. Once flashed against Kia, the 108:1 CHANGED-to-re-register ratio overwhelmed Kia's UI state machine, which presumably treats most of those CHANGEDs as stale duplicates and discards / mis-orders them — breaking every UI surface that the strict-1:1 cycle had been keeping correct.
Reverted as 2c926ee (git revert 40fca40). git diff checkpoint/pre-547-relax..HEAD is empty after the revert; rebuilt MD5s match the original d803f42c… / 3900c800….
The real root cause is still unsolved
The starting observation stands: Kia re-registers ev=05 within ~6 ms after every CHANGED against Pixel, but rarely re-registers it at all against Y1 in the pre-relax build (zero re-registers in dual-kia-20260517-1842; nine re-registers in the post-relax dual-kia-20260518-0836 only because the flood of unsolicited CHANGEDs presumably destabilised Kia's subscription state and forced it to reset). Both Y1 and Pixel advertise the same SDP, both emit CHANGED on the same trigger, both are strict §6.7.1. So something about Y1's response shape or session establishment causes Kia to skip the re-register that the same Kia issues without delay against Pixel.
Candidates (none verified — listing as hypotheses to test):
- M5 TID echo not actually echoing. Y1's
patch_mtkbt.pyM5 trampoline at0x6d186is supposed to preserve the inbound NOTIFY's TID across Path B'schan+0x39clobber. If broken, every CHANGED ships with TID=0 (or whatever was last latched), and Kia's §6.5 TID-echo check silently drops the response → no observable CHANGED → no re-register. Y1'sbtlog-based wire view can't disambiguate this becausetools/btlog-hci-extract.pymis-aligns hex byte runs across records (every frame shows AVCTP=0x02, which isTL=0, CR=1; with no proof that's the actual on-wire byte). Most tractable diagnostic: add_emit_native_log_u32(a, "log_fmt_t8tid", N)at the T8 INTERIM site logging the inbound TID, and at every T5 / T9 CHANGED emit logging whatever's atchan+0x39at that instant. Compare againstT8regcadence to verify echo correctness. - CoD class differential. Pixel advertises Smartphone CoD; Y1 advertises whatever its kernel BT init sets (often a MediaTek "Wearable Audio" variant). Kia's known-quirks table may gate "re-register on CHANGED" behavior on CoD. Verifiable from a Linux box near both:
hcitool inq -i hci0. - ServiceName SDP attr 0x0100. Y1 advertises "Advanced Audio" (AOSP A2DP-SRC name, wrong slot — V7 fill-the-stripped-Browse-slot artifact). Pixel advertises "AV Remote Control Target". Kia may special-case AVRCP TGs by name. Cheap test: byte-swap the string in V7 to match Pixel exactly.
The order I'd attack these is (1) → (3) → (2). M5 verification is closest to the wire and is the most likely silent-failure mode given how the M5 patch was constructed; the ServiceName swap is cheap to try if (1) clears; CoD is the most disruptive to investigate and probably last.
Lessons (logged to memory at feedback_verify_before_inferring.md)
- Aggregate statistics are weak evidence for "the reference TG behaves like X". Per-frame field comparisons are strong. When proposing a code change, the response must include per-frame evidence that proves the inference, not just the count / cadence.
- Confirmation bias: I was looking for justification to relax the gates (the user had asked "should we?") and the "42 at 1 Hz" statistic fit that frame, so I stopped looking at the data that would have falsified the hypothesis.
- When the user pushes back on the premise of a change ("I thought we did this to mimic Pixel?"), treat that as a signal to re-verify from scratch, not to re-defend the existing inference.
- If a change is going to be flashed to hardware, the diagnostic that would have falsified the hypothesis should appear in the proposal before the commit, not after the regression.
Net state
checkpoint/pre-547-relaxis still the working baseline.git diffbetween HEAD and it is empty.dual-kia-20260518-0836is preserved as the regression evidence — useful as a falsifying capture for future "should we relax the gates?" proposals.- Real root cause for the pre-relax Kia position-bar lag is unsolved; M5 TID verification is the next concrete diagnostic step, not another speculative code change.
M5 TID-echo diagnostic landed in a94abeb, log site moved in followup
Single new Y1T : T9tid c17=NN log at the PLAYBACK_STATUS_CHANGED CHANGED emit, just before PLT_reg_notievent_playback_rsp is invoked. NN is the byte at struct[+0x19] = conn[17] — what mtkbt's response builder reads to populate the outbound AVCTP TL nibble per the M5 design at patch_mtkbt.py:317-410.
Initial site was ev=05 (PLAYBACK_POS_CHANGED), picked because that was Kia's lag-relevant subscription. Empirical follow-up against captures dual-kia-20260518-1131 and dual-tv-20260518-1138 showed:
- Kia: 1
T9tid c17line per session (gate-cleared then no re-register). Sample of00. - TV: 0
T9tid c17lines per session (TV doesn't subscribe to ev=05).
One sample point can't distinguish "M5 echoes correctly but Kia's first NOTIFY had TL=0" from "M5 broken on outbound". Moved the log to ev=01 because:
- ev=01 PLAYBACK_STATUS_CHANGED routes through the same Path B outbound code as ev=05 — same M5 echo mechanism.
- TV subscribes to ev=01 and re-registers within ~17 ms after every CHANGED (textbook §6.7.1 tight loop). So TV captures produce
T9emit pstat=N+T9tid c17=NNpairs on every actual play/pause edge — many samples per session, with the inbound TL cycling per Kia's NOTIFY cadence.
If T9tid c17=NN cycles across the TV capture window matching the expected AVCTP TL rotation pattern (0-15 incrementing), M5 is verified working and the lag has a different root cause. If it stays at 00 across many emits, M5 is silently failing.
patch_libextavrcp_jni.py headers:
STOCK_MD5 = fd2ce74db9389980b55bccf3d8f15660OUTPUT_MD5 = d803f42c973bf9539f4d03ccb658cab3(release — byte-identical to the pre-instrumentation baseline)OUTPUT_DEBUG_MD5 = 4995ca171d0c446b7ce8886022ba7b2c(debug — has the new log at the pstat emit)
LOAD #1 padding budget had only 44 B headroom over the pre-instrumentation debug blob; the single log site consumes 40 of those bytes. A patcher-side bug surfaced during this work: patch_libextavrcp_jni.py:160-251 writes the trampoline blob with no upper-bound check against LOAD #2's file start (0xbc08); over-budget blobs silently overwrite LOAD #2's relocation data. Out of scope here but worth a follow-up commit to add the guard.
Capture recipe (TV + Y1, preferred):
# On the flash box
git pull
KOENSAYR_DEBUG=1 ./apply.bash --avrcp # or whatever flag set rebuilds libextavrcp_jni.so
# Verify the running binary post-flash matches OUTPUT_DEBUG_MD5
adb shell md5sum /system/lib/libextavrcp_jni.so # should print 4995ca171d0c446b7ce8886022ba7b2c
# Pair Y1 to TV, start music playback, exercise play/pause repeatedly (each
# play/pause edge produces one T9emit pstat + one T9tid c17 sample).
./scripts/dual-capture.sh tv
Analysis:
grep 'Y1T' /work/logs/dual-tv-<latest>/logcat.txt | grep -E 'T8reg ev=01|T9tid c17|T9emit pstat'
Look at the T9tid c17=NN values across the capture window:
| Pattern | Interpretation |
|---|---|
c17 walks a small-integer sequence (0-0x0F) matching the cadence of TV's T8reg ev=01 re-registrations | M5 is preserving the inbound TID across the outbound Path B traverse. The lag root cause is not TID echo — move to the next hypothesis (CoD class differential or ServiceName SDP byte swap). |
c17=00 on every emit, regardless of T8reg ev=01 cadence | M5 is silently failing on the outbound path. The cave's discriminator at [r5, 8] is wrong for this build / firmware combination, or the strb_w is still firing despite the beq skip. Investigate by disassembling mtkbt:0x6d186 post-patch and confirming the cave bytes at 0xf3680 are what patch_mtkbt.py writes. |
c17=NN where NN is constant and non-zero across many emits | M5 latches once but never refreshes — partial bug. The inbound-path strb at the cave should be re-firing on every subsequent CMD; if c17 stays pinned to whatever value happened to be there at the first inbound, the cave's discriminator predicate is misfiring (treating subsequent inbounds as outbound). |
No T9tid c17 lines at all but T9emit pstat lines present | Build/flash issue — the patcher ran but the debug-instrumented blob didn't land. Re-verify OUTPUT_DEBUG_MD5 against the running /system/lib/libextavrcp_jni.so. |
Each T9tid c17=NN line is paired with a T9emit pstat=N line on the immediately preceding logcat row. The T8reg ev=01 lines after each T9tid c17 mark when TV re-registers — and that re-register carries a fresh AVCTP TL that should appear as the NEXT T9tid c17 value (one emit later).
Kia captures will also produce T9tid c17 data, but only one sample per AVRCP session (strict §6.7.1 + Kia not re-registering). TV is the higher-sample-rate source and the recommended capture target for this diagnostic.
Followup: D1 mtkbt-side wire-source log (c39) added to disambiguate c17
The JNI-side T9tid c17=NN log captured 11 samples across one TV session, all 00. By itself this is ambiguous: it could mean the JNI response builder's conn[17] slot is unrelated to the AVCTP TL the wire actually carries (in which case c17=00 is harmless and M5 is still working), or it could mean M5 is broken end-to-end. The two interpretations bear on the next hypothesis: if M5 is working we move on (ServiceName SDP / CoD differential); if M5 is broken we fix M5 first.
The disambiguating measurement is the byte at chan+0x39 at the moment the AVCTP wire-frame builder is about to encode it as the outbound TL nibble. That site is fcn.0xae418:0xae448 in mtkbt — ldrb r6, [r4, #0x15] with r4 = chan+0x24, so [r4,#0x15] = chan+0x39. The same byte that M5's cave at 0xf3680 writes from inbound packets via Path B's strb.w r0, [r4, #0x29] (where Path B's r4 = chan+0x10).
D1 / D1-CAVE in patch_mtkbt.py (debug-only, gated on KOENSAYR_DEBUG=1) hooks that ldrb site with a b.w into a new 50-byte cave at 0xf36a0 (in the LOAD #1 padding region, past M5's cave). The cave logs Y1T : M5wire c39=NN via __android_log_print (PLT thunk at 0xaef8), re-executes the two displaced ldrb instructions verbatim, and branches back to 0xae44c. mtkbt's OUTPUT_DEBUG_MD5 = c476b0dc17cf37723b7c256b27c9082c; the release path stays at the pinned OUTPUT_MD5 = dc01a7c1337ad2dc6573819bdc22834d.
The cave preserves the calling convention — push {r0-r4, lr} keeps sp 8-aligned at the blx (r4 is preserved through the call), and the 6-register push matches the function prologue's stack discipline. pop.w {r0-r4, lr} restores everything. r4 (the chan+0x24 arg) is untouched. Net wire behaviour: identical to non-debug, plus one logcat line per outbound AVCTP frame.
Diagnostic pairing. The JNI side already logs T9tid c17=NN on every play/pause edge (PLAYBACK_STATUS_CHANGED emit). The mtkbt side now logs M5wire c39=NN on every outbound AVCTP frame. Compare both streams from the same TV capture:
| c17 (JNI conn[17]) | c39 (wire chan+0x39) | Interpretation |
|---|---|---|
00 constant | walks 0..0x0F matching T8reg ev=01 cadence | M5 is working; conn[17] is an unrelated slot. Lag root cause is not M5. Move to next hypothesis. |
00 constant | 00 constant across all emits | M5 is broken on the wire side too. The cave's discriminator at [r5, #8] is misfiring or the strb_w still clobbers chan+0x39 on outbound. Disassemble the running mtkbt at 0x6d186 + 0xf3680 to confirm landed bytes. |
00 constant | non-zero constant | M5 latches once, never refreshes. Inbound-path strb predicate is wrong; subsequent inbounds aren't propagating their TID. |
| matches c39 | matches c17 | conn[17] and chan+0x39 track each other; the JNI propagates the same TID source the wire builder uses. M5 working. |
Diagnostic build / pull / grep flow:
# (on dev box)
KOENSAYR_DEBUG=1 ./apply.bash # produces debug-instrumented mtkbt + libextavrcp_jni
# (on Y1 flash box, after pulling latest)
KOENSAYR_DEBUG=1 ./apply.bash
adb reboot
# play music on Y1, pair to TV, let it run for a few minutes
adb logcat -d | grep 'Y1T' | grep -E 'M5wire c39|T9tid c17|T9emit pstat|T8reg ev=01'
The expected per-emit ordering inside a single AVRCP session against TV:
Y1T : T8reg ev=01 reason=0F (TV re-registers, fresh inbound TL = N)
Y1T : T9emit pstat=2 (next play/pause edge fires T9)
Y1T : T9tid c17=NN (JNI builder reads conn[17] just before sending)
Y1T : M5wire c39=NN (mtkbt wire builder reads chan+0x39 immediately after)
If c39 walks 0..0x0F across the session and matches the prior T8reg's implied TL, M5 is verified end-to-end and the Kia lag is not a TID-echo issue. If c39 stays 00, M5 is broken on the wire and needs revisiting.
Trace #60 (2026-05-18) — Bolt's sequential-event-cursor model identified; NOT_IMPLEMENTED-via-UNKNOW_INDICATION fix attempted, reverted (UNKNOW_INDICATION at 0x65bc is actually the PASSTHROUGH response builder)
Wire-level evidence from dual-bolt-20260518-1507 (KOENSAYR_DEBUG=1 trampoline build)
The 2026-05-18 Bolt capture shows the first ~4 track changes updating metadata correctly, then a hard cut-over: track #5 ("You Get Worked") and track #6 ("Title Holder") never refresh the Bolt screen's metadata pane. The shuffle UI also flickers briefly between tracks 4 and 5.
Reconstructing the Y1-side timeline from Y1Patch fL.id= markers (music app track-id changes) cross-correlated against Y1T T8reg ev= markers (Bolt's RegisterNotification CMDs reaching the JNI):
| Phase | Window | Bolt's RegisterNotification events |
|---|---|---|
| P0 | session start → 15:05:08 (30 s) | ev=01 every 3 s |
| P1 | 15:05:18 (track #3 edge) | no T8reg |
| P2 | 15:05:44 → 15:06:14 (33 s) | ev=05 every 3 s |
| P3 boundary | 15:06:17.616 | first T8reg ev=09 (NOW_PLAYING_CONTENT_CHANGED, AVRCP 1.4+) |
| P3 | 15:06:17 → 15:06:44 | ev=0a every 3 s |
| P4 | 15:06:47 → 15:07:08 | ev=08 every 3 s |
| (silent) | 15:07:08 → 15:08:15 | no T8reg |
At any moment Bolt is subscribed to exactly one event_id and re-registers it every ~3 s until either Y1 fires a CHANGED or some internal trigger advances the cursor. This is fundamentally different from every other CT in the Y1 test matrix:
| CT | Pattern | Evidence |
|---|---|---|
| Pixel 4 (reference) | Parallel: subscribes to {01,02,05,08,09,0a,0b,0c} in a 110 ms burst, re-registers each on its own CHANGED | pixel4-bugreport/.../btsnoop_hci.log frames 1498-1545 |
| TV | Parallel: tight bursts of {01,08,09,0b,0c} every track edge + connection event | dual-tv-20260518-1432: 14:30:23.905-32.032 (5 events in 127 ms), repeats at 14:31:50 |
| Sonos | Parallel: ev=01 + ev=09 interleaved within ms | dual-sonos-20260517-1852: 18:50:05.670 ev=01 → .673 ev=09 (3 ms gap) |
| Kia | Mostly parallel, low cadence: single burst of {01,05,08,09,0a,0b,0c}, then ev=01 / ev=05 polling | dual-kia-20260518-1131 |
| Bolt | Sequential cursor, one event_id at any moment | This capture |
Bolt's cursor advances away from an event only after that event delivers a CHANGED. Once parked on ev=0a (AVAILABLE_PLAYERS_CHANGED), Y1 INTERIM-acks but never emits CHANGED — ev=0a / ev=0b / ev=0c are 1.4+ events for which Y1 has no semantic source. The cursor stalls there indefinitely; Bolt stops re-registering ev=01 / ev=02 / ev=05 (the events Y1 actually drives) and the metadata pane freezes on whatever was playing when the cursor crossed into the 1.4+ band.
Why Trace #32's hypothesis turned out to invert here
Trace #32 added the four 1.4+ event IDs (0x09-0x0c) to T1's GetCapabilities advertisement and to T8's INTERIM dispatch table because Pixel-as-TG (in pixel4-bugreport/.../btsnoop_hci.log) advertises that exact set from an AVRCP 1.3-declared TG. The hypothesis was that strict CTs gate metadata-pane render on the 1.4+ ack. Trace #33 immediately refuted the gating hypothesis for Bolt (post-flash pane still empty) and located the actual blocker at the InformDisplayableCharacterSet ACK path. The 1.4+ INTERIM-ack code stayed in tree from Trace #32 as inherited dead-end work.
Cross-checked against every CT in the matrix: TV / Sonos / Kia / Pixel all subscribe to ev=09 against the current INTERIM-only-never-CHANGED Y1 behavior and their UIs work — none of them depend on a CHANGED for ev=09-0c. The 1.4+ INTERIM-acks were load-bearing for nothing in the matrix; they were Bolt-specific harm.
Fix attempted (commit e30dab0, reverted in commit-after-this)
_emit_t8's t8_check_8 bne retargeted to t8_unknown_event directly. The four arms for ev=0x09 / 0x0a / 0x0b / 0x0c were deleted. Any RegisterNotification with event_id ∉ {0x01, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} fell through to UNKNOW_INDICATION at 0x65bc. T1's GetCapabilities advertised set unchanged.
Post-flash result: MtkBt AVRCP service restarts every ~6 s
dual-bolt-20260518-1547 (post-flash capture):
15:46:32.996 JNI : MSG_ID_BT_AVRCP_CMD_FRAME_IND size:13 rawkey:0 data_len:13 ; inbound RegNotif ev=09
15:46:32.997 Y1T : T8reg ev=09 ; T8 entered
15:46:32.999 JNI : AVRCP_SendMessage len=214
15:46:33.000 JNI : msg=520, ptr=0x523AA9B0, size=214 ; "NOT_IMPLEMENTED" IPC reply
15:46:33.001 JNI : send msg success : 242
(no M5wire follows — mtkbt never emits the wire frame)
15:46:37.415 EXT_AVRCP: BluetoothAvrcpService Constructor enable (NEW PID 1237) ; mtkbt service restarted
Every T8reg ev=09 is followed by a 4-second gap and a new BluetoothAvrcpService PID. The cycle repeats for every ev=09 RegisterNotification Bolt sends. mtkbt's AVRCP service is being killed by a watchdog timeout because the IPC frame we sent never makes it to the wire.
Root-cause: UNKNOW_INDICATION (0x65bc) is the PASSTHROUGH response builder
r2 -a arm -b 16 -m 0x6000 -c 's 0x65bc; pd 60' libextavrcp_jni.so disassembly:
0x000065bc 4ff0090c mov.w ip, 9
0x000065c0 0824 movs r4, 8
0x000065c2 0df5bd75 add.w r5, sp, 0x17a
0x000065c6 8de81010 stm.w sp, {r4, ip}
0x000065ca 0495 str r5, [sp, 0x10]
0x000065cc 0024 movs r4, 0
0x000065ce dff83056 ldr.w r5, [0x00006c00] ; "8BluetoothAvrcpService_connectReqNativeP7_JNIEnv..."
0x000065d2 54ae add r6, sp, 0x150
0x000065d4 cdf80ce0 str.w lr, [sp, 0xc]
0x000065d8 06f12906 add.w r6, r6, 0x29
0x000065dc 0294 str r4, [sp, 8]
0x000065de fdf722e8 blx rsym.btmtk_avrcp_send_pass_through_rsp ← PASSTHROUGH response emitter
0x000065e2 3946 mov r1, r7
...
0x65bc is NOT a generic AV/C NOT_IMPLEMENTED dispatcher. It's the PASSTHROUGH-CMD response path: it stages a passthrough response in the JNI's per-conn buffer and calls btmtk_avrcp_send_pass_through_rsp. For PASSTHROUGH CMDs the inbound IPC frame has rawkey != 0; the builder reads that byte and constructs the response.
T_charset (PDU 0x17) and T_continuation (PDU 0x40 / 0x41) happen to work through this path because their inbound IPC frames are structurally compatible with what the PASSTHROUGH builder reads. PDU 0x31 (RegisterNotification) has rawkey == 0 and a 13-byte payload of (PDU, RFU, params_length=5, event_id, playback_interval[4]) — the builder reads garbage from that, stages a malformed IPC reply, and the IPC send succeeds (mtkbt accepts the 242-byte datagram) but mtkbt's parser then chokes on the malformed frame and stalls. After ~4 s the system watchdog kills the AVRCP service and bluetoothd respawns it.
Why TV and Sonos didn't exhibit this in their post-flash captures
TV and Sonos never sent T8reg ev=09 in dual-tv-20260518-1551 / equivalents — they subscribe to ev=09 in parallel bursts during early session connection only. By the time of the user's test play sessions, TV/Sonos had already armed their 1.4+ subscriptions and weren't re-registering them. So they never hit the broken t8_unknown_event path post-flash. Bolt's sequential cursor cycles through events every ~30 s; that's what exposed the bug.
Side-effect (interesting datapoint)
User reported a progress bar appearing on Bolt's screen for the first time during this flash, even though metadata stayed broken. Likely explanation: between mtkbt restarts, Bolt receives a few reg_notievent_pos_changed_rsp CHANGED frames carrying the live position payload — enough to render a position bar briefly. The position didn't move correctly because the position-CHANGED cadence is interrupted by each mtkbt restart.
Fix reverted; next steps
_emit_t8 restored to its pre-Trace-#60-attempt state (INTERIM-acks for events 0x09-0x0c). MD5s rolled back to the pre-attempt values.
Open question: how to emit a NOT_IMPLEMENTED RegisterNotification response for ev=09-0x0c without routing through the PASSTHROUGH builder. Candidate paths:
- Call a
reg_notievent_*_rspPLT helper with r1 != 0 (the "reject" branch of the existing builder per Trace #34's RE:cbnz r5, reject_pathon r1). What ctype that path emits on the wire is unknown without disassembling the helper atlibextavrcp.so:0x2458. - Add a new mtkbt-side patch to extend the per-event response builder at
fcn.000121d8with a NOT_IMPLEMENTED branch keyed on a third sentinel value ofctxt[8](current M1 widening picks INTERIM at 0x0F, falls through to CHANGED otherwise — could add 0x08 → NOT_IMPLEMENTED). - Find the JNI's actual "unknown event_id" handler — the stock JNI may have a path that emits NOT_IMPLEMENTED for unsupported PDU 0x31 event_ids. We haven't located it yet.
Of these, option 1 is the cheapest experiment (no mtkbt change, no new RE) — worth a small instrumented diagnostic flash to discover what wire ctype the reject path actually emits.
Option 1 disqualified by disassembly
btmtk_avrcp_send_reg_notievent_track_changed_rsp (libextavrcp.so:0x2458) and the structurally identical helpers (reached_end_rsp 0x24c8, reached_start_rsp 0x2528, pos_changed_rsp 0x2588, battery_status_changed_rsp 0x25f0, system_status_changed_rsp 0x2658, now_playing_content_changed_rsp 0x26c0, player_appsettings_changed_rsp 0x2720, availplayers_changed_rsp 0x27b0, addredplayer_changed_rsp 0x2810, uids_changed_rsp 0x2880, volume_changed_rsp 0x28e8) all share the same shape:
cbnz r5, reject_branch ; r5 = caller's r1 (status/success arg)
; r1 == 0 → success path:
; strb r7 (=r2 reasonCode), [sp, 0xc] ; ipc[8] = reasonCode
; strb event_id_const, [sp, 0xd] ; ipc[9] = event_id
; (event-specific payload write)
; r1 != 0 → reject path:
; strb r5 (=r1 status), [sp, 0xb] ; ipc[7] = status
; strb 1, [sp, 0xa] ; ipc[6] = 1
; (NO write to sp[0xc] — ipc[8] stays memset zero)
common_tail:
mov.w r1, 0x220 ; msg_id = 544 (same as success!)
bl AVRCP_SendMessage
Both paths send msg=544. The reject path leaves ipc[8] = 0 (memset zero, never overwritten). Mtkbt's M1-widened dispatcher at fcn.0x121d8:0x12230 does cmp ctxt[8], 0x0F — 0 != 0x0F → falls through to the CHANGED branch — emits AV/C ctype 0x0D on the wire. Wire result is a malformed CHANGED, not NOT_IMPLEMENTED.
Conclusion: option 1 cannot emit NOT_IMPLEMENTED on the wire without an mtkbt-side patch.
Option 2 (M6) static verification
End-to-end chain of an ipc[8] = 0x08 IPC payload through mtkbt, walked by disassembly with no empirical step required:
JNI helper: ipc[8] = caller's r2 (= 0x08)
mtkbt fcn.0x67768 → 0x518ac (msg_id tbb) → 0x12478 (event_id tbb)
fcn.0x12478 [0x124a0] ldrb r3, [r4, 9] ; r3 = event_id
[0x124a8] tbb [0x124b0] ; dispatch per-event
verified: events 0x01-0x0D all dispatch to handlers (122cc /
122e4 / 12324 / 12354 / 12390 / 12270 / 123f8 / 1243c / 123c4)
that all `bl 0x121d8` (10/10 checked, within 40 instructions of
entry)
fcn.0x121d8 [0x1222e] ldrb r1, [r4, 8] ; r1 = ctxt[8] = 0x08
[0x12230] cmp r1, 0x0F ; M1 widened (0x01 → 0x0F)
[0x12232] bne 0x12240 ; 0x08 ≠ 0x0F → CHANGED branch
[0x12244] (was: movs r1, 0xD; with M6: nop) ← M6
[0x1224e] bl fcn.0x11894 ; r1 still 0x08
fcn.0x11894 [0x11906] cmp r6, 6 ; r6 = r1 (caller's) = 0x08
[0x1190a] ite hi
[0x1190c-0x1190e] r2 = (r6 > 6) ? 0 : 1
[0x11912] strb r2, [r4, 9] ; packetFrame[9] = 0 (response;
; for ctype > 6)
[0x11922] strb r6, [r4, 0xb] ; packetFrame[0xb] = 0x08
[0x11906] (also: bl 0xf0bc with r1 = packetFrame)
fcn.0xf0bc [0xf12a] ldrb r3, [r6, 9] ; r3 = packetFrame[9] = 0
[0xf138] cbz r3, 0xf186 ; taken → Path B
Path B:
fcn.0xef08 [0xef5e] ldrb r2, [r5, 0xb] ; r2 = packetFrame[0xb] = 0x08
[0xef68] strb r2, [r4] ; wire_buf[0] = 0x08 ← KEY
(no other use of packetFrame[0xb] in body; wire_buf[3] = 0
hardcoded from fcn.0x11894:0x11926)
fcn.0x6d0f0 (M4 site, list-check bypassed)
[0x6d118] ldrb r3, [r5] ; r3 = wire_buf[0] = 0x08
[0x6d11e] cmp r3, 0x0F ; ≠ 0x0F
[0x6d122] (ne) strb 1, [r4, 0xf0] ; non-INTERIM flag SET
[0x6d126] bne common-path ; skips INTERIM-specific
; wire_buf[3] check
(common path: builds AVCTP TID nibble + packet_type into
[r4, 0xe0..0xf0], then b.w 0xae5e4 L2CAP_SendData)
fcn.0xae5e4 reads packetFrame [9, 0x12, 0x16, 0x1c] (NOT wire_buf[0]);
fragmentation + chip-level send
fcn.0xae418 AVCTP header builder writes TID/packet_type into AVCTP layer
(wire_buf is the L2CAP payload below this — opaque)
WIRE: AV/C frame byte 0 = 0x08 (NOT_IMPLEMENTED)
Single reader of [r4, 0xf0] (the non-INTERIM flag set at 0x6d122) is at 0x7ecf4 inside ittt eq block — passive status retrieval, not a drop gate. Confirmed by /x f00094f8 byte-pattern search across the binary.
Backward-compatibility audit for M6: every current call site to reg_notievent_*_rsp (T2 / extended_T2, T5, T8, T9, T_papp) passes r2 ∈ {0x0F INTERIM, 0x0D CHANGED}.
r2 = 0x0F: cmp at 0x12230 equal → INTERIM branch → r1 = 0x0F via movs at 0x12238 (unchanged by M6) → wire 0x0F ✓r2 = 0x0D: cmp ≠ → CHANGED branch → M6 NOP → r1 retains 0x0D from ldrb at 0x1222e → wire 0x0D ✓ (production-equivalent)
M6 is a pure no-op for the existing call sites; only changes wire behaviour when a caller deliberately passes a non-0x0F-and-non-0x0D value.
Step 1 commit: M6 alone (no T8 changes)
Commit lands M6 only — single-byte patch at mtkbt file offset 0x12244 (0d 21 → 00 bf). No JNI / T8 changes. Production wire behaviour is byte-identical to pre-M6 for every CT in the matrix.
Verification predictions:
- TV / Sonos / Kia / Bolt / Pixel: no wire-level change. Metadata-pane, PASSTHROUGH, position cadence — all identical to pre-M6.
- mtkbt MD5 change confirms the M6 byte landed.
If any post-flash regression appears, M6 is wrong and reverts with a one-byte change. Step 2 (T8 r2-value change to 0x08 for events 0x09-0x0C) lands only after Step 1 is verified clean on all CTs.
Trace #61 (2026-05-18) — M6 Step 1 verified clean on all 4 CTs; Step 2 (T8 r2=0x08 for events 0x09-0x0C) landed
Step 1 verification
Post-flash captures against the M6-only build (mtkbt MD5 7493acdad352bc6d7f6d65fc3251e221):
| CT | Capture | BluetoothAvrcpService restarts | T8reg events | Notes |
|---|---|---|---|---|
| Sonos | dual-sonos-20260518-1656 | 0 | 01×5, 08×1, 09×9, 0b×1, 0c×1 | parallel-subscription pattern intact; 4×T5emit, 4×T9emit, 61×M5wire |
| TV | dual-tv-20260518-1705 | 0 | 01×3, 08×1, 09×3, 0b×1, 0c×1 | standard TV pattern; 127×M5wire, heavy T4a= GEA traffic |
| Bolt | dual-bolt-20260518-1726 | 0 | 01×43 only (cursor parked in Phase 1; session ended before advancing) | 5×T9emit, 1×T5emit, 73×M5wire |
| Kia | dual-kia-20260518-1730 | 0 | 01×1, 05×1, 08×1, 09×1, 0a×1, 0b×1, 0c×1 | parallel initial burst; 110×M5wire, GEA heavy |
mtkbt PID (145) and Bluetooth service PID (697) stable across every session — no process churn. M6 confirmed byte-identical pass-through for production traffic (every current reg_notievent_*_rsp call passes r2 ∈ {0x0F, 0x0D}, and M6 only diverges from stock when r2 ∉ {0x0F, 0x0D}).
Bolt's no-metadata observation in dual-bolt-20260518-1726
The Bolt session showed T5emit aid=aacf122a at 17:25:20 (Y1 emitted TRACK_CHANGED CHANGED on the wire) but no GetElementAttributes query from Bolt followed. Per AVRCP 1.3 §6.7.1 a CT receiving CHANGED is required to re-register ev=02 and re-query GEA for the new track. Bolt did neither.
Cross-checked via CPU-state diff: M6 produces identical r1 / r2 / flags at the bl 0x11894 boundary for ipc[8] = 0x0D (the value T5emit's helper writes). Wire byte is 0x0D pre- and post-M6 — same as every prior production CHANGED frame. This is the same intermittent Bolt-side state-machine gap previously observed in dual-bolt-20260518-1507 ("first few tracks worked, then broke"). Independent of M6.
Step 2 — T8 r2-value change for events 0x09-0x0C
_emit_t8's arms for events 0x09 / 0x0a / 0x0b / 0x0c in _trampolines.py now call their reg_notievent_*_rsp PLT helpers with r2 = REASON_NOT_IMPLEMENTED (0x08) instead of r2 = REASON_INTERIM (0x0F). Side effects:
- Helper writes
ipc[8] = 0x08(instead of0x0F). - Mtkbt's M1 + M6 dispatch reads
ipc[8] = 0x08, post-M1 cmp fails (!= 0x0F), CHANGED branch entered, post-M6 NOP keepsr1 = 0x08from the priorldrb. fcn.0x11894storesr1 = 0x08topacketFrame[0xb];packetFrame[9] = 0(response, ctype > 6).fcn.0xf0bccbz packetFrame[9], 0xf186taken → Path B (same path INTERIM/CHANGED takes in production).fcn.0xef08writeswire_buf[0] = packetFrame[0xb] = 0x08.- Wire frame goes out with AV/C ctype
0x08NOT_IMPLEMENTED.
The _emit_subscription_write(a, 1, 20, ...) previously in t8_check_9 (armed state[20] sub_now_playing_content) is removed — a CT that receives NOT_IMPLEMENTED is required not to re-register, so the gate is intentionally never armed. The T5 / T9 CHANGED-emit branches gated on state[20] remain in code but become unreachable (gate permanently zero); their PLT calls are dead but harmless.
Predicted behaviour changes per CT after Step 2
- Sonos / TV / Kia / Pixel-ref (parallel-subscription model): they currently send
RegisterNotification(0x09)(and 0x0a/0x0b/0x0c) speculatively and never receive a CHANGED for those events. With Step 2 they receive AV/C ctype0x08NOT_IMPLEMENTED on the response, drop those event_ids from their retry set per §6.7.1, and stop re-registering them. Net wire effect: less retry traffic, no change to ev=01/02/05/08 cadence (the events Y1 actually drives). - Bolt (sequential-cursor model): when the cursor reaches ev=09/0a/0b/0c, it receives NOT_IMPLEMENTED, drops the event, and advances. Cursor returns to ev=01/02/05 which Y1 fires CHANGED for. The intermittent Bolt-side "skip GEA after CHANGED" gap is not addressed by Step 2 (that's a separate Bolt-side state-machine issue, independent of M6).
Patcher state
patch_mtkbt.py: unchanged from Step 1.patch_libextavrcp_jni.py: OUTPUT_MD5d803f42c→637e2f18d7947511c0ab0d4a78ea7003. OUTPUT_DEBUG_MD54995ca17→bbec7d68b70ca7973d1e2a14b8dd5fd2.- New
REASON_NOT_IMPLEMENTED = 0x08constant in_trampolines.pyalongsideREASON_INTERIM/REASON_CHANGED.
Trace #62 (2026-05-19) — TRACK_CHANGED Identifier: audio_id BE u64 → 0x00*8 per AVRCP 1.3 §6.7.2 (Bolt's polling-only lag closed)
Cross-CT latency table — T5emit → next T4a
After Step 1+2 + the T2reg debug log (commit 76bd5ed), per-CT response time from T5emit (TRACK_CHANGED CHANGED on the wire) to the CT's next T4a=00010xxx (first attribute of the GEA response for the new track) across all four CTs in dual-*-20260518-{1811,1814,1907,1940}:
| CT | T5emit → next T4a | Status |
|---|---|---|
| TV | 110 ms | interrupt-driven ✅ |
| Kia | 1020 ms | interrupt-driven ✅ |
| Sonos | 17–29 ms | interrupt-driven ✅ |
| Bolt | 22,046 ms | polling-driven ❌ |
Bolt's gap was structural — every prior Bolt session in /work/logs/dual-bolt-* showed the same 20–30 s lag between T5emit and the next GEA query, consistent with Bolt's RegisterNotification re-subscribe cadence of ~3 s and GEA refresh cadence of ~21 s (Bolt's "safety polling" timer, independent of CHANGED notifications on the wire).
The T2reg ev=02 log added in commit 76bd5ed confirmed Bolt does receive each CHANGED — Bolt re-subscribes within 1 s of T5emit. So the AVCTP transaction completes successfully; what fails is Bolt's metadata-refresh trigger. That points to packet-payload semantics, not transport.
Root cause: §6.7.2 Identifier divergence (long-pending fix from Trace #41-ish hypothesis)
The wire-level Identifier field in TRACK_CHANGED carries 8 bytes. AVRCP 1.3 §6.7.2 mandates:
"For TG conforming to AVRCP 1.3, the Identifier shall always be set to 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00."
The trampoline was emitting the per-track audio_id (BE u64 from y1-track-info[0..7]) at all three emit sites — a Trace #32-era optimization premised on "Strict 1.4+ CTs cache GetElementAttributes keyed by Identifier; a per-track id forces refresh." That premise is a 1.4+ Browseable Player extension, not a 1.3 contract. Per-frame evidence across the four CTs falsifies it: TV / Kia / Sonos all re-query GEA within 1 s of CHANGED regardless of Identifier value (their parsers ignore it). Bolt, being strict-1.3, silently drops CHANGED carrying non-zero Identifier and falls back to polling.
This hypothesis was documented in the older "Strongest remaining hypothesis: TRACK_CHANGED Identifier divergence" section earlier in this file (Trace #41-ish era) and marked as the "Best first move (low-cost, low-risk)" follow-up, but never executed — superseded at the time by M5 / M6 / Step 2 work. With those landed, this fix becomes the natural next move.
Patch
_trampolines.py:
- New 8-byte data const
selected_track_idin the data section afterpath_papp_set. Referenced by all threetrack_changed_rspemit sites. - T4 reactive CHANGED (line 444):
add_sp_imm(3, T4_OFF_FILE_TID)→adr_w(3, "selected_track_id"). - extended_T2 INTERIM (line 757):
add_sp_imm(3, T2_OFF_TID)→adr_w(3, "selected_track_id"). - T5 proactive CHANGED (line 943):
add_sp_imm(3, T5_OFF_FILE_TID)→adr_w(3, "selected_track_id").
Each instruction site grows by 2 bytes (T1 ADD-SP-imm = 2 B → T3 ADR.W = 4 B); the new const is 8 B + 4 B align. Net trampoline-blob growth: +14 B, well within the LOAD #1 padding budget.
state[0..7] and file[0..7] still carry the per-track audio_id for trampoline-internal edge detection — only the wire payload changes.
Patcher state
patch_libextavrcp_jni.py: OUTPUT_MD5637e2f18→5d1e0fcf1b4049fcc4c96dc0e8077acf. OUTPUT_DEBUG_MD5b1ab1ca5→8c427734bcb7887bc4a38fbd006726cd.patch_mtkbt.py: unchanged.
Verification plan
Post-flash capture from Bolt expected to show T5emit aid=… → T4a=00010xxx delta < 1 s (parity with TV / Kia / Sonos). If the delta is still > 5 s, the §6.7.2 hypothesis is wrong and we revisit — likely candidates: PLAYBACK_STATUS_CHANGED-as-refresh-trigger (Bolt fetched Track 1 GEA 2.8 s after T9emit pstat=2), or AVCTP framing differences. TV / Kia / Sonos expected byte-identical refresh behaviour to current build (their parsers ignore Identifier).
Trace #64 (2026-05-19) — c85ed7b reverted: Step 2 revert OK in release, but added debug-log site pushed debug blob past LOAD #1 budget → Sonos SIGSEGV loop
What happened
Commit c85ed7b reverted Step 2 (good — INTERIM-acks ev=0x09..0x0c, arms state[20] for the NowPlayingContent CHANGED emit). The substantive change is correct and aligns Y1 with Pixel-as-TG behaviour.
The same commit also added a T5ncc ev=09 debug log at the new T5 NowPlayingContent emit site (inline _emit_native_log_u32 + a log_fmt_t5ncc asciiz format string in the trampoline data section). These additions, combined with the _emit_subscription_write(a, 1, 20, ...) inline expansion (~50 bytes), grew the debug-build trampoline blob from 4008 bytes (commit 91c7b36) to 4104 bytes.
The LOAD #1 padding budget on this binary is hard-capped at 0xbc08 - 0xac54 = 4020 bytes — LOAD #2's file offset starts at 0xbc08. Writing past that overwrites LOAD #2's first bytes, which are part of .data/.got. The dynamic linker maps those bytes into the GOT; corrupting them causes the next PLT call (or GOT-relative access) to SIGSEGV.
dual-sonos-20260518-2024 showed com.mediatek.bluetooth SIGSEGV-looping every ~350 ms: BluetoothAvrcpService Constructor enable → +JNI_OnLoad → -JNI_OnLoad: 65540 → +classInitNative → (crash). dmesg confirms sig 11 to [iatek.bluetooth] per restart. The release build was safe (3800 bytes, 220 bytes headroom) but the user habitually flashes with KOENSAYR_DEBUG=1.
Why the patcher didn't catch it
patch_libextavrcp_jni.py's pre-patch site verification (verify("before")) is bypassed on the happy path — it only runs when --skip-md5 is set or EXPECTED_OUTPUT_MD5 is None. With OUTPUT_DEBUG_MD5 pinned to the corrupted build's MD5 (8e314521…), the patcher produces the corrupted output → MD5 matches the pin → patcher reports success.
Fix
- Revert
c85ed7b(commit[revert hash]). Restores 91c7b36 state — §6.7.2 fix in place, Step 2 still active for now. - Add a hard
AssertionErrorinpatch_libextavrcp_jni.py::build_patches()that fires whenlen(blob) > LOAD2_FILE_OFFSET - LOAD1_OLD_SIZE = 4020 bytes. Future debug-log additions that overflow the budget will now fail loudly at patcher-run time, before any binary is written.
Path forward
The Step 2 revert + state[20] arm + T5 NowPlayingContent CHANGED emit are still the right substantive fix for Bolt's lag (see Trace #63 cross-CT comparison). The smaller re-land needs to fit within the 4020-byte budget at debug. Current debug blob is at 4008 bytes — only 12 bytes of headroom. Any new debug log site (~30-38 bytes) must be paired with shrinking elsewhere:
- Drop one of the older logs that's no longer load-bearing (e.g.
T9tid c17— M5 TID-echo has been verified end-to-end, the log was diagnostic-only for that landing). - Or consolidate format strings: replace per-site asciiz with a unified
"Y1T %s ev=%02x"and pass per-site short tag strings. - Or shift the LOAD #1 padding boundary by re-laying out the ELF (high-risk, deferred).
The Step 2 revert is re-landable without any new debug log if we accept that the chain verification has to happen via existing T8reg ev=09 logs alone.
Trace #65 (2026-05-19) — Step 2 revert re-landed in budget-aware shape
c85ed7b reverted the NOT_IMPLEMENTED reject for ev=0x09-0x0c back to INTERIM acks (correct fix per Trace #63's Pixel comparison) but pushed the debug-build trampoline blob to 4104 bytes, 84 over the 4020-byte LOAD #1 budget. Reverted in 806fb4b which also added a hard AssertionError in patch_libextavrcp_jni.py::build_patches() to catch any future overflow.
This trace re-lands the substantive Step 2 revert (T8 INTERIM-acks ev=0x09-0x0c + arms state[20]) within budget by dropping two debug log sites that have served their diagnostic purpose:
T2reg ev=02(added 76bd5ed) — verified that the CT in question subscribes to TRACK_CHANGED. Confirmed. Removed.T9tid c17=%02x(added Trace #59 era) — paired withM5wire c39to verify M5 TID-echo end-to-end through the JNI response builder. M5 verified live; the standaloneT9tidsite is no longer load-bearing for ongoing investigations. Removed.
A new debug log site lands in T5's NowPlayingContent CHANGED emit:
T5ncc— no-arg format string (saves ~10 bytes vs the per-event%02xformats). Fires once per track edge when state[20] is armed. Absence afterT5emitin a session means the CT never subscribed to ev=0x09 — diagnostic for the gate-arm chain.
Final blob sizes
| Build | Bytes | Headroom |
|---|---|---|
| Release | 3800 | 220 |
| Debug | 4016 | 4 |
Debug headroom is razor-thin (4 bytes); any future debug-log addition needs to drop or consolidate an existing site. The patcher's AssertionError safety net will fail loudly if a future change drives the blob over 4020.
Patcher state
patch_libextavrcp_jni.py: OUTPUT_MD55d1e0fcf→da7225fd2ba79f99461b4fba641fcad1. OUTPUT_DEBUG_MD58c427734→326aa703106d645274fb8416d8cc438e.patch_mtkbt.py: unchanged (M1+M6 still in place; pass-through forr2 ∈ {0x0F, 0x0D}).
Verification plan
KOENSAYR_DEBUG=1 build, post-flash:
grep -E 'Y1T.*(T8reg ev=09|T5ncc|T5emit|T4a=0001)' logcat.txt
Expected per track edge on a CT subscribed to ev=0x09:
T8reg ev=09— CT subscribes (or re-subscribes after a previous CHANGED).- (track changes)
T5ncc— Y1 emits NowPlayingContent CHANGED. T5emit aid=…— Y1 emits TrackChanged CHANGED.T4a=00010xxx(≤ 1 s later) — CT's interrupt-driven GEA refresh.
If T8reg ev=09 is missing entirely, the M1+M6 dispatch isn't routing the INTERIM ack on the wire (or the CT is rejecting our INTERIM as malformed). If T5ncc is missing after T8reg ev=09, state[20] isn't actually getting armed — trace into _emit_subscription_write's open/lseek/write/close path. If everything fires and CT still doesn't refresh, the issue is downstream (likely the PlaybackStatus CHANGED burst element Pixel emits that we don't — deferred until evidence shows it's needed).
Sonos restart-loop verification
The c85ed7b revert (806fb4b) restored Sonos boot. This re-land keeps release-build bytes structurally identical to 806fb4b plus the _emit_subscription_write expansion (50 bytes), well inside the 220-byte release headroom. No new LOAD #2 overlap risk.
Trace #66 (2026-05-19) — M5's coincident-match was masking a TID-echo gap; M7 lands
Discovery
Post-252cd8a (Step 2 revert re-landed), the Sonos / TV / Bolt / Kia matrix gave a clean per-CT comparison:
| CT | T8reg ev=09 count | re-subscribe pattern | M5wire c39 distribution |
|---|---|---|---|
| Sonos 2125 | 6× | re-subscribes within 11 ms of CHANGED | 100% TID=0 (36 frames) |
| TV 2135 | 4× | re-subscribes within 15–200 ms | 100% TID=0 (27 frames) |
| Kia 2157 | 1× | one-shot subscription | 100% TID=0 (134 frames) |
| Bolt 2159 | 1× (then ev=0a retry-storm 10×, ev=01 retry-storm 10×, ev=05 retry-storm 12×, ev=08 retry-storm 8×) | retries every 3 s and gives up after ~10× | 87% TID=0, 13% non-zero (1, 3, 4, 5, 6, 6, 7, 8, 8, 9, 9, c, d in 98 frames) |
Bolt's per-event retry-storm is the AVCTP V13 §3.3.5 / §6.5 retry timer firing because the response TID didn't echo the inbound CMD TID. mtkbt btlog confirms inbound transId:N with N ∈ {3, 6, 7, 8, 9, 12, 13} during the same window; the outbound responses go with M5wire c39=0 on every T8reg ev=01 retry. Three other matrix CTs use TID=0 exclusively and accidentally match — the "M5 verified" claim from Trace #59 was a coincident-match artifact, not a real verification (the falsifying evidence is the cycling-TID CT, per feedback_verify_before_inferring.md).
Why M5 specifically fails for RegNotif responses
libextavrcp.so::AVRCP_SendMessage (called from every btmtk_avrcp_send_reg_notievent_*_rsp helper) passes msg_id = 0x220 (= 544) to BT_SendMessage. The msg=544 path's IPC allocator does not set packet[+8] = 1 the way fcn.0x11894:0x11908 does for "normal" outbound packets. M5's discriminator (cmp r2, 1; beq skip) misclassifies the msg=544 packet as inbound, fires the original strb.w r0, [r4, 0x29], and writes packet[+0xd] = 0 (allocator-zeroed) to chan+0x39. The wire builder at fcn.0xae418:0xae448 reads chan+0x39 and emits AVCTP byte 0 with TID nibble = 0.
Other AV/C response paths (GetCapabilities / GetPlayStatus / GetElementAttributes / PASSTHROUGH) route through different msg IDs whose allocators do set packet[+8] = 1, so M5's discriminator works correctly for them. This is why the Bolt 2159 capture shows non-zero c39 values clustered outside the RegNotif response windows.
M7: unconditional sync after M5's branch
The fix extends the M5 cave at 0xf3680 from 16 to 24 bytes. After M5's conditional strb, the cave loads the per-channel inbound-RX TID stash slot at chan+0xba9 (latched by fcn.0x11374:0x11436 strb.w sl, [r4, 0xba9] on every inbound AV/C cmd) and unconditionally writes it to chan+0x39. The wire-frame builder at fcn.0xae418:0xae448 then always reads the correct echo TID, regardless of which IPC allocator originated the response packet.
Cave layout (24 bytes at 0xf3680):
0xf3680 68 7b ldrb r0, [r5, 0xd] ; M5: original 1st insn
0xf3682 2a 7a ldrb r2, [r5, 8] ; M5: discriminator
0xf3684 01 2a cmp r2, 1 ; M5: outbound = 1
0xf3686 01 d0 beq 0xf368c ; M5: skip strb on outbound
0xf3688 84 f8 29 00 strb.w r0, [r4, 0x29] ; M5: original 2nd insn (inbound only)
0xf368c 94 f8 99 0b ldrb.w r0, [r4, 0xb99] ; M7: load chan+0xba9 (inbound-RX TID stash)
0xf3690 84 f8 29 00 strb.w r0, [r4, 0x29] ; M7: sync chan+0x39 unconditionally
0xf3694 79 f7 7a bd b.w 0x6d18c ; return into Path B
For inbound (beq not taken), M5 writes packet[+0xd] = TID to chan+0x29 = chan+0x39, then M7 re-writes the same value from chan+0xba9 — idempotent. For outbound (beq taken), M5 preserves whatever was at chan+0x39, then M7 forces it to chan+0xba9 (latest inbound TID). For the broken msg=544 outbound path (where M5 mis-fires strb and writes 0), M7's unconditional copy overwrites the 0 with the correct TID.
LOAD #1 budget
mtkbt has 1748 bytes of LOAD #1 padding budget (0xf3d40 − 0xf366c). The cave grew from 16 to 24 bytes; total mtkbt usage including the D1 debug cave still well under budget. LOAD1_RELEASE_END updated 0xf3690 → 0xf3698.
Patcher state
patch_mtkbt.py: OUTPUT_MD57493acda→9c4e462241169c3a181574db157c8df7. OUTPUT_DEBUG_MD5d603e68a→03da20024a8bc750f0c60ab4f828de2f.patch_libextavrcp_jni.py: unchanged from 252cd8a.
Verification plan
Post-flash, the gold standard test is Bolt (the only cycling-TID CT in the matrix). Expected behaviour change:
T8reg ev=01followed byM5wire c39=NNwhere NN ≠ 0 — TID echoes Bolt's inbound cmd TID.- No more 3-second retry-storm on ev=01 / 05 / 08 / 0a — Bolt sees the response, transitions to "subscribed", waits for CHANGED.
T5nccandT5emit aid=fire on track edges; Bolt re-subscribes ev=09 / 02 within ~15 ms (the spec-correct §6.7.1 pattern observed on Sonos/TV/Kia).T4a=00010xxx(GEA query) follows the CHANGED burst within < 1 s — interrupt-driven refresh, parity with the Pixel-4-as-TG reference latency table.
Sonos / TV / Kia byte-identical wire behaviour (their TIDs were already 0, so M7's unconditional copy of chan+0xba9 = 0 produces the same wire bytes as pre-M7). No regression expected on the working CTs.
Why Trace #59 missed this
T9tid c17 was the only TID-source log site; it surfaced conn[+17] (which IS the inbound TID, correctly latched JNI-side) but the matching M5wire c39 always read 0 on Sonos because Sonos's inbound TID was 0. The "match" was structural coincidence. Memory architecture_y1_m5_regnotif_gap.md documents the verification path forward — cycling-TID CTs (Bolt, Kia under different conditions, future captures) are required for any future TID-related claim.
Trace #67 (2026-05-19) — M7 sync visible but ineffective; D2 multi-value log added to disambiguate
What we know after fe974f2 + 252cd8a
The M5+M7 cave at 0xf3680 is correctly assembled (verified byte-for-byte in the patched binary) and the wire-builder reads from chan+0x39 (per the M5wire c39=%02x log site, which always fires once per outbound AVCTP frame at fcn.0xae418:0xae448). Yet the post-M7 Bolt session (the brief 0724 capture that actually showed metadata for 3 songs before wedging) still indicates c39=0 on the RegNotif response paths — same as the pre-M7 captures.
This means either M7's source slot chan+0xba9 is 0 at the moment the cave runs, or M7's write to chan+0x39 is being overwritten between cave exit and wire-builder read.
Static analysis from this session
fcn.0x518acis mtkbt's per-msg-id dispatcher (called byfcn.0x67768). Casemsg=0x208(= 520,cmd_frame_ind_rsp) is the only case that callsfcn.0x11374→chan+0xba9 = arg1 = msg[5]. Other msg IDs (e.g.msg=0x220= 544 RegNotif response,msg=0x21c= 540 GEA response) go to different handlers that don't latchchan+0xba9.fcn.0xae418(wire builder) has two TID source paths:chan+0x39([r4, 0x15]) andchan+0x38([r4, 0x14]). The conditional at0xae43e(cbnz r3, 0xae448) and0xae442(cbz r0, 0xae448) selects between them based on[packet, 8]and[packet, 0xe]. Most frames take thechan+0x39path (whichM5wirelogs); thechan+0x38path is the alternate. Our empirical data confirmschan+0x39is the path taken for the RegNotif response frames that Bolt rejects.fcn.0x6d1a8is a structural twin of Path Bfcn.0x6d0f0, with its own strb sites writingchan+0x28orchan+0x29frompacket[0]based onpacket[1]. Only one caller (fcn.0xf290:0xf348); probably not on the msg=544 path but a candidate for future investigation if D2 indicates Path B isn't even running.
Hypothesis the D2 cave is designed to test
The most likely cause is that msg=520 cmd_frame_ind_rsp is not being emitted by JNI on every inbound CMD — our R1 redirect at 0x6538 diverts inbound dispatch into the T1/T2-stub trampolines, which might short-circuit the normal cmd_frame_ind_rsp path. If so, chan+0xba9 stays at its initial value (0) for all inbound CMDs, and M7's unconditional copy preserves the 0.
D2 cave (this trace)
patch_mtkbt.py debug build now adds a second cave at 0xf3700 (107 bytes), hooked from the M5+M7 cave's tail b.w at 0xf3694. D2 fires after M5+M7's stores, with r4 and r5 still holding their cave-entry values (chan+0x10 and packet pointer respectively per AAPCS callee-save). It emits three logs per outbound AVCTP frame:
M5dbg p8=%02x—packet[+8](M5's outbound-discriminator byte). Allocator path writes 1 here for outbound IPC; M5'scmp r2, 1skips the inbound-strb when this is 1.M5dbg pd=%02x—packet[+0xd](M5's strb source). Inbound CMD path: this is the inbound TID per Path B's stash-struct semantics. Outbound: allocator-zeroed unless a handler wrote it.M5dbg ba9=%02x—chan+0xba9(M7's source). Set byfcn.0x11374:0x11436onmsg=520 cmd_frame_ind_rsp; M7 syncschan+0x39from here unconditionally.
M5wire c39=%02x (from D1, unchanged) continues to log the final wire-side TID.
Diagnostic table for the next Bolt capture
M5wire c39 | M5dbg p8 | M5dbg pd | M5dbg ba9 | Interpretation |
|---|---|---|---|---|
00 | 01 | 00 | 00 | M5 thinks outbound (skips strb), M7 source is 0 → either msg=520 never fired or fcn.0x11374 isn't reaching the stash slot we expect |
00 | 00 (or non-1) | 00 | 00 | M5 thinks inbound, strb writes 0, M7 source also 0 → same conclusion, plus packet[+0xd] isn't carrying the TID |
00 | 01 | 00 | NN (non-0) | M7 source has TID but chan+0x39 is 0 → M7's write didn't land OR something between M7 and wire-builder clobbered it |
NN | 01 | 00 | NN | Working — M7 sync took effect (this is what we want) |
NN | 01 | NN | * | Working without M7 — packet[+0xd] already had TID via some path we haven't seen |
Patcher state
patch_mtkbt.py: OUTPUT_MD59c4e4622…(unchanged from fe974f2; release-side bytes are identical). OUTPUT_DEBUG_MD503da20024a8bc750f0c60ab4f828de2f→68faa7cfbb5c833d4f55c44ccfa98813. AddsLOAD2_FILE_OFFSET+LOAD1_BUDGETconstants and a hardAssertionErrorwhen LOAD #1 end would exceed the LOAD #2 file offset (same safety netpatch_libextavrcp_jni.pyalready has). mtkbt headroom: 1748 B budget; current usage 256 B with D1+D2.patch_libextavrcp_jni.py: unchanged.
Verification plan
KOENSAYR_DEBUG=1 build, flash, capture a fresh Bolt session covering connection + several track skips. Grep Y1T:* for M5dbg p8=, M5dbg pd=, M5dbg ba9=, M5wire c39= and map against the table above. The pattern that fires per-frame tells us exactly where the M7 hypothesis breaks.
Trace #68 (2026-05-19) — D2 cave first capture: M7 verified working; M5's discriminator is empirically dead code
What the D2 cave revealed on TV / Sonos
Post-293b382 debug build (mtkbt 68faa7cfbb5c833d4f55c44ccfa98813), fresh sessions on the two TID=0 / oscillating-TID CTs:
TV dual-tv-20260519-0949 (4008 Y1T lines, 0 restarts):
| log tag | distribution |
|---|---|
M5wire c39= | 09 × 402 |
M5dbg ba9= | 09 × 402 |
M5dbg p8= | 0xb8 × 402 |
M5dbg pd= | 0x00 × 402 |
T8reg ev= | 01 × 4, 08 × 1, 09 × 8, 0b × 1, 0c × 1 |
Sonos dual-sonos-20260519-0951 (852 Y1T lines, 0 restarts):
| log tag | distribution |
|---|---|
M5wire c39= | 09 × 45, 00 × 80 |
M5dbg ba9= | 09 × 45, 00 × 80 |
M5dbg p8= | 0xb8 × 103, 0xea × 22 |
M5dbg pd= | 0x00 × 125 |
T8reg ev= | 01 × 7, 08 × 1, 09 × 14, 0b × 1, 0c × 1 |
Per-frame correlation (interleaved M5dbg + M5wire lines fire within the same microsecond, same PID, in this order: p8 → pd → ba9 → c39): c39 == ba9 in 100% of observed wire frames across both CTs. M7's unconditional chan+0x39 = chan+0xba9 is doing exactly what it claimed to do.
M5's discriminator is empirically dead
The M5 patch (commit c5e93be-era) relied on cmp r2, 1; beq skip_strb to distinguish outbound IPC (allocator path, packet[+8]=1) from inbound (stash struct, packet[+8]=0xea). The D2 log of packet[+8] shows the value is never 1 — it's 0xb8 (most frames, both CTs) or 0xea (Sonos minority). So:
- M5's
beq skip_strbis never taken. - The original strb at
0xf3688fires every frame. - That strb writes
packet[+0xd](=0x00in 100% of observed frames) tochan+0x39. - M7's two-instruction tail at
0xf368c..0xf3693(ldrb.w r0, [r4, 0xb99]; strb.w r0, [r4, 0x29]) immediately overwrites withchan+0xba9.
M5's discriminator-based logic was never actually doing anything in production. Pre-M7, the CTs that "worked" did so by coincident match: M5's broken strb wrote 0 to chan+0x39, and those CTs happened to use TID=0. Bolt cycles TIDs across 0-15 → coincident match fails → 3 s retry storm.
This contradicts the architecture comment in patch_mtkbt.py (M5 patch description) and in docs/PATCHES.md (the M5 cave disassembly section) that claim M5 discriminates correctly. Both should be updated when next touched.
chan+0xba9 is not a stable stash — it tracks the latest inbound TID
Sonos's wire-side TID transitions exactly once mid-session (09:49:41 UTC, ba9: 9 → 0, ~33 s after connect_ind). This corresponds to a legitimate change in the CT's inbound CMD TID, not a bug. The wire echo follows correctly — both ba9 and c39 change together at that frame. Subsequent frames stay at ba9=00 (and c39=00).
This means chan+0xba9 is updated on every inbound CMD (via fcn.0x11374:0x11436 on msg=520 cmd_frame_ind_rsp). Between inbound CMDs, the value sticks. M7 reads it whenever it runs (on every outbound frame), so the wire TID = most-recently-seen inbound TID.
For RegNotif INTERIM/CHANGED responses (the path Bolt was retry-storming on), this is exactly what AVCTP §3.3.5 strict echo requires — the response TID must match the original cmd's TID. With M7, outbound TID = chan+0xba9 = the inbound RegNotif's TID. Should work for Bolt too.
Prediction for the in-flight Bolt capture
We expect:
M5wire c39=NNmatchingM5dbg ba9=NNon every frame (M7 working).NNcovering the range of Bolt's actual inbound TIDs (per the Pixel-as-TG reference btsnoop: 2 for ev=01, 3 for ev=02, 5 for ev=05, 6 for ev=09, 8 for ev=0a, 9 for ev=0b, 0xa for ev=0c).- No 3 s retry storms on ev=01 / ev=05 / ev=0a (which were the post-Trace-#66 symptom).
T5nccfiring on every track edge.- GEA queries
T4a=00010NNNwithin < 1 s of CHANGED bursts (interrupt-driven refresh).
If the "3 songs then wedge" symptom persists, it's no longer TID-related and we need to look at a different layer (Bolt-side state machine, metadata content edge case, AVCTP fragmentation under sustained traffic, or our state[X] subscription gate clearing logic).
Patcher state
Unchanged from 293b382: mtkbt OUTPUT_MD5 9c4e462241169c3a181574db157c8df7 / OUTPUT_DEBUG_MD5 68faa7cfbb5c833d4f55c44ccfa98813. patch_libextavrcp_jni.so unchanged from 252cd8a.
Trace #69 (2026-05-19) — Bolt session: M7 verified on wire but conn[+0x11] is not per-event; per-event TID storage at JNI level is the actual fix
Bolt 1009 data
dual-bolt-20260519-1009 (KOENSAYR_DEBUG=1 with the D2 cave from 293b382): 0 restarts, 585 Y1T lines, 4 GEA queries over 3 minutes.
Per-frame M5wire c39 distribution shows Bolt's actual cycling TIDs: 01 × 36, 0c × 23, 07 × 21, 04 × 9, 0a × 7, 06 × 7, 03 × 6, 00 × 6, 0f/0b/09/05/02 × 1 each. Bolt cycles across the full 0..0xf range — not the 100% TID=0 / 100% TID=9 profile of Sonos/TV/Kia.
Per-frame M5dbg ba9 == M5wire c39 in 100% of observed frames. M7 is working.
But T8reg subscription counts reveal the structural failure:
| event | T8reg count | observation |
|---|---|---|
| ev=01 | 27 | re-subscribing — works |
| ev=05 | 16 | re-subscribing — works |
| ev=09 | 1 | one subscribe, then T5ncc fires 5× without Bolt re-subscribing |
| ev=0a | 5 | low cadence |
T5ncc × 5, T5emit × 3 — Y1 emitted 5 NowPlayingContent CHANGEDs and 3 TrackChanged CHANGEDs, but Bolt re-subscribed ev=09 zero times.
Static analysis: where TID actually flows in mtkbt's stock path
Walking the call chain from JNI to wire builder:
libextavrcp.so::btmtk_avrcp_send_*_rspat0x26c0(NowPlaying) /0x2458(TrackChanged):- reads
conn[+0x11]→ TID - writes
msg[5] = TID - calls
AVRCP_SendMessage(conn, msg=0x220, &msg, msg_size)
- reads
- mtkbt's IPC dispatcher
fcn.0x67768→fcn.0x518ac(case0x220for msg=544) →fcn.0x12478(RegNotif sub-dispatcher) →fcn.0x122cc(per-event handler for ev=01) / similar →fcn.0x121d8(RegNotif response builder, M1+M6 patched) →fcn.0x11894(IPC packet allocator). fcn.0x11894at0x11920/0x11928:` 0x11920: ldrb r0, [r7, 1] ; r0 = local_buf[+1] = msg[5] = TID 0x11928: strb r0, [r4, 0xa] ; packet[+0xa] = TID`fcn.0xf0bc(Path A/B selector) Path B branch at0xf1a6/0xf1a8:` 0xf1a6: ldrb r3, [r0, 0xa] ; r3 = packet[+0xa] = TID 0xf1a8: strb.w r3, [r4, 0x31] ; chan+0x39 = TID`fcn.0x6d0f0(Path B, our M5+M7 cave hooks here): M5 writes 0 to chan+0x39 (strb always fires sincep8 ≠ 1); M7 unconditionally rewrites chan+0x39 from chan+0xba9.fcn.0xae418:0xae448(wire builder):ldrb r6, [r4, 0x15]= chan+0x39 (post-M7 value).
**So mtkbt's stock path was always writing the right TID to chan+0x39.** Step 4's strb.w r3, [r4, 0x31] is the real TID-echo mechanism. The M5 patch (commit c5e93be-era) mis-identified the failure mode — there was never a missing strb; the wire builder was already getting the JNI-provided TID via msg[5] → packet[+0xa] → chan+0x39.
Our M5+M7 cave is functionally redundant with fcn.0xf0bc:0xf1a8. For most cases, M7's chan+0xba9 source equals packet[+0xa] (both = latest inbound CMD TID), so M7 rewrites chan+0x39 to the same value. No harm but no value either.
The actual bug: conn[+0x11] is per-connection, not per-event
conn[+0x11] in libextavrcp.so is the slot that JNI reads at every response-builder call. It's updated by the inbound-CMD path on every inbound AV/C cmd. So:
- INTERIM response (immediate, in the same JNI dispatch context as the inbound RegNotif):
conn[+0x11]= RegNotif's TID. ✓ - CHANGED response (async, fired later from a music-app broadcast):
conn[+0x11]= whatever the most recent inbound CMD's TID is, not the originating RegNotif's TID.
For Bolt's TID-cycling pattern:
- ev=01 / ev=05 CHANGEDs fire on tight loops (play-status edge / 1s position tick) —
conn[+0x11]hasn't rotated between RegNotif and CHANGED. - ev=02 / ev=09 CHANGEDs fire on sparse track-edges — many intervening inbound CMDs rotate
conn[+0x11]. Wire emits wrong TID. Bolt rejects.
This is why Bolt's metadata pane updated for 3 songs (the runs where conn[+0x11] happened to still match the original RegNotif's TID at emit time) then wedged.
The fix: re-purpose subscription-gate bytes to store TID+1
State file at /data/data/com.innioasis.y1/files/y1-trampoline-state currently uses bytes 13..20 as 0/1 subscription gates:
| byte | event | currently | new semantics |
|---|---|---|---|
| state[13] | ev=05 sub_pos | 0/1 | 0 = not subscribed, 1..16 = TID+1 |
| state[14] | ev=01 sub_play_status | 0/1 | same |
| state[15] | ev=08 sub_papp | 0/1 | same |
| state[16] | ev=02 sub_track_changed | 0/1 | same |
| state[17] | ev=03 sub_track_reached_end | 0/1 | same |
| state[18] | ev=04 sub_track_reached_start | 0/1 | same |
| state[20] | ev=09 sub_now_playing_content | 0/1 | same |
Existing gate checks (cmp r0, 0; beq skip) still work — state byte is nonzero when subscribed regardless of TID value.
At INTERIM-emit sites (T8 arms + extended_T2's ev=02 INTERIM): instead of _emit_subscription_write(a, 1, ...), save conn[+0x11] + 1 to the state byte. The +1 encoding allows TID=0 to be distinguishable from "not subscribed".
At CHANGED-emit sites (T5 NCC, T5 TrackChanged, T9 NCC): after the existing gate check (r0 now contains TID+1), subtract 1 and write to conn[+0x11] (= [r4, 0x19] where r4 = struct ptr in T5/T9). Then mtkbt's stock path picks up conn[+0x11] via msg[5] → packet[+0xa] → chan+0x39.
Cost per site:
- INTERIM: +2 bytes (ldrb + adds before the modified _emit_subscription_write)
- CHANGED: +4 bytes (subs + strb inside the gate-check block)
Narrow scope (ev=02 + ev=09 only, the wedge events): ~16 bytes of code growth.
Budget
Debug build is at 4016 / 4020 bytes (4 bytes headroom — extremely tight). To fit 16 bytes of growth, drop one debug-log site that's no longer load-bearing:
T9emit pos=%u— least critical now that mtkbt-sideM5wire c39covers wire-emit timing. Removing saves ~38 bytes (format string + inline emit).
Net debug after change: 4016 - 38 + 16 = 3994 bytes (26 byte headroom). Net release after change: 3800 + 16 = 3816 bytes (204 byte headroom).
What about ev=01 / ev=05 / ev=08?
ev=01 has 27 re-subscribes — already working. Same for ev=05 (16) and ev=08 (1). These have fast-enough CHANGEDs that conn[+0x11] doesn't rotate. Leave them out of scope for the narrow fix; revisit if the next Bolt capture shows residual issues.
Optional follow-up: simplify the M5+M7 cave
Once per-event TID is in place, fcn.0xf0bc:0xf1a8 writes the correct TID and M5+M7 just rewrite it to a stale value. M5+M7 could be removed entirely (revert 0x6d186 to stock ldrb+strb, drop the cave, drop the LOAD #1 filesz extension). Defer — non-load-bearing cleanup. Document the new understanding in patch_mtkbt.py comments when next touched.
Trace #70 (2026-05-19) — Bolt 1053 reveals M7 was breaking the per-event TID fix; M5 discriminator corrected to use packet[+0xd], M7 removed
Bolt 1053 outcome of commit 705f145 (per-event TID save/restore at JNI side)
dual-bolt-20260519-1053 with patched libextavrcp_jni.so (3dfc20d6…) and mtkbt (68faa7cf…): 0 restarts, 552 Y1T lines, but:
- ev=09 subscribed once at
10:53:42.822— still never re-subscribed. T5ncc× 5 (most before Bolt even subscribed to ev=09 — state[20] persisted across boots with legacybyte_value=1encoding from a previous flash).M5wire c39on the 5T5nccemits:05and0a— matching the latest inbound CMD TID at that moment, not the saved per-event TID.
Per-frame M5dbg:
10:52:17.018 M5wire c39=09 (some inbound CMD at TID=9)
10:52:17.031 M5wire c39=0a (next inbound CMD at TID=0xa, chan+0xba9 updated)
10:52:17.357 T5ncc ← JNI: state[20] = X, conn[+0x11] = X-1
10:52:17.358 M5wire c39=0a ← but wire emits TID=0a, not X-1
10:52:17.359 T5emit aid=d9e5d5e5
10:52:17.359 M5wire c39=0a
Root cause: M7's unconditional sync overrides fcn.0xf0bc's correct TID write
Walking the post-fix outbound flow:
- T5 NCC trampoline reads
state[20], computesr0 = state[20] - 1(saved TID), writes toconn[+0x11]. ✓ - JNI's
reg_notievent_now_playing_content_rspreadsconn[+0x11]= saved TID, writesmsg[5] = saved TID. ✓ AVRCP_SendMessageforwardsmsg=544to mtkbt.- mtkbt's
fcn.0x11894:0x11928:packet[+0xa] = msg[5] = saved TID. ✓ fcn.0xf0bc:0xf1a8:chan+0x39 = packet[+0xa] = saved TID. ✓- M5+M7 cave runs at
0x6d186→ 0xf3680:- M5's
cmp r2, 1checkspacket[+8]. Empiricallyp8 = 0xb8(outbound) or0xea(inbound), never1. M5'sbeq skipis never taken → originalstrb.w r0, [r4, 0x29]always fires. - That strb writes
chan+0x39 = packet[+0xd]. For outbound,packet[+0xd] = 0(allocator-zeroed). chan+0x39 := 0 — wipes out the saved TID. - M7's
ldrb.w r0, [r4, 0xb99]; strb.w r0, [r4, 0x29]then unconditionally syncschan+0x39 = chan+0xba9(latest inbound CMD's TID). ✗ — overwrites the saved TID with the wrong one.
- M5's
fcn.0xae418:0xae448wire builder readschan+0x39= latest inbound TID. Wire emits wrong TID. Bolt rejects.
**M7 was added in fe974f2 as a "fix" for what was actually a bug in our own M5 cave — M5's cmp r2, 1 never matched, so M5's strb fired on every outbound and clobbered chan+0x39 with 0. M7 papered over that by overwriting with chan+0xba9 (which happens to match packet[+0xa] for FAST outbound responses where conn[+0x11] hasn't rotated). For DELAYED outbound responses (CHANGEDs fired async from broadcasts), chan+0xba9 carries the latest-inbound-CMD TID, not the saved per-event TID. So M7 actively breaks the per-event fix.**
The actual fix: M5 discriminator corrected, M7 removed
The cave at 0xf3680 is now 24 bytes (same size, no LOAD #1 filesz change) with the following layout:
0xf3680 68 7b ldrb r0, [r5, 0xd] ; load packet[+0xd]
0xf3682 00 28 cmp r0, 0 ; outbound = 0 (pd)
0xf3684 00 bf nop ; padding
0xf3686 01 d0 beq 0xf368c ; skip strb on outbound
0xf3688 84 f8 29 00 strb.w r0, [r4, 0x29] ; inbound: chan+0x39 = TID
0xf368c 00 bf 00 bf 2 × nop ; was M7 ldrb.w (removed)
0xf3690 00 bf 00 bf 2 × nop ; was M7 strb.w (removed)
0xf3694 79 f7 7a bd b.w 0x6d18c ; return
Discriminator is now packet[+0xd] == 0 instead of the broken packet[+8] == 1:
- Outbound IPC packets have
packet[+0xd] = 0(allocator-zeroed atfcn.0x11894:0x11926). - Inbound CMD stash struct has
r5[+0xd] = chan+0xba9 = inbound TID(nonzero). - Empirically verified across TV / Sonos / Bolt sessions via D2 cave's
M5dbg pd=NNlogs.
Edge case: inbound TID=0 falls into the outbound branch (skip strb). Result: chan+0x39 not updated from the inbound CMD. But this only matters if an outbound RESPONSE then fires reading chan+0x39 — and fcn.0xf0bc's outbound path always writes chan+0x39 = packet[+0xa] = msg[5] before the wire builder reads it. So the inbound-TID-0 edge case can't actually break wire echo.
With M5 correctly skipping strb on outbound and M7 removed:
- Outbound CHANGED: fcn.0xf0bc's
chan+0x39 = saved TID(from JNI per-event fix) survives → wire emits correct TID. ✓ - Outbound INTERIM: fcn.0xf0bc's
chan+0x39 = current inbound TID(msg[5] = conn[+0x11], synchronous with the RegNotif) survives → wire emits correct TID. ✓ - Outbound non-RegNotif (GEA, PASSTHROUGH ack, etc.): same path, conn[+0x11] = current cmd's TID synchronously → wire emits correct TID. ✓
- Inbound CMDs: original strb still fires, chan+0x39 = inbound TID for subsequent (now-redundant) reads. ✓
Stale state file from previous sessions
A side observation from Bolt 1053: T5ncc fired 5 times before Bolt even subscribed to ev=09 (the only T8reg ev=09 is at 10:53:42, after most T5ncc emits). state[20] was nonzero from a previous flash session — the trampoline-state file at /data/data/com.innioasis.y1/files/y1-trampoline-state persists across reboots. This is mostly cosmetic now (the cave's strb-skip-on-outbound logic means a stale state[20] with value 1 just produces conn[+0x11] = 0 for these "phantom" T5ncc emits, which then echoes correctly via fcn.0xf0bc → wire). Not load-bearing for the fix.
Patcher state
patch_mtkbt.py:
- OUTPUT_MD5
9c4e4622→e466763d12cd516103de05ce4af174b9 - OUTPUT_DEBUG_MD5
68faa7cf→0246e82640743f35211cddd84c9ad26f - M5-CAVE entry's
afterbytes changed (4 byte differences: cmp + first NOP + 4 NOPs replacing the M7 ldrb/strb). - Debug build's
m5_cave_with_d2_redirectconstruction inbuild_patches()updated to match. - M5 patch comments and
docs/PATCHES.mdcave-disassembly section updated.
patch_libextavrcp_jni.py: unchanged from 705f145 (per-event TID save/restore at JNI side).
Verification on next Bolt capture
Expected behavior on a fresh state file (delete y1-trampoline-state or first-flash):
- Bolt subscribes ev=09 —
T8reg ev=09. JNI savesstate[20] = TID + 1. - Track edge fires
T5ncc. JNI reads state[20], writesconn[+0x11] = saved TID. - JNI calls rsp builder → mtkbt msg=544 → fcn.0xf0bc writes
chan+0x39 = saved TID. - Cave runs at 0x6d186 → 0xf3680: packet[+0xd] = 0 (outbound),
beq skiptaken, strb skipped. NOPs. b.w return. - Wire builder reads
chan+0x39 = saved TID. Emits correct echo TID. - Bolt accepts CHANGED → re-subscribes ev=09 → repeats.
Expected M5dbg pattern: pd=00 on every outbound (confirms discriminator), ba9=NN should still show various values (chan+0xba9 still updated by mtkbt's stash on every inbound CMD — irrelevant now), c39=NN should match the saved per-event TID for CHANGED emits.
If c39 still tracks latest inbound TID after this fix, there's a deeper issue we haven't found.
Trace #71 (2026-05-19) — Bolt 1222: c39=0 in 90/100 outbound frames; conn[+0x11] is empirically not populated by stock JNI's inbound CMD path; per-event TID lives at g_avrcp_req_event_database[event_id] (vaddr 0xd2b5)
What the logs showed
Bolt 1222 session after the M7-removal + M5-discriminator fix (Trace #70, commit 3b0c628). Trampoline-state file had state[16]=1 from a previous session (legacy "subscribed yes/no" encoding interpreted by the new TID+1 code as "subscribed with TID=0"). M5wire c39 distribution on outbound frames:
- 90/100 outbound frames:
c39=00 - 11/100 frames non-zero — every non-zero is in inbound path (
p8=ea)
Crucially, ev=09's RegNotif INTERIM ack at 12:21:27.619 had c39=00. INTERIM is emitted SYNCHRONOUSLY from within stock JNI's inbound CMD dispatch, so conn[+0x11] should hold the inbound TID at that moment per the per-event-TID hypothesis. It didn't.
What this rules out
The hypothesis "state[N] = conn[+0x11] + 1 at INTERIM-arm time captures the originating RegNotif TID, restorable at CHANGED emit time" (commit 705f145) is empirically false. conn[+0x11] is 0 at extended_T2 / T8 entry, so what we were saving was 0+1=1, and at CHANGED time we restored 1-1=0 to conn[+0x11] — shipping the wrong TID on the wire.
What's actually going on (the JNI RE)
Reverse-engineered stock libextavrcp_jni.so (/work/v3.0.7/system.img.extracted/lib/libextavrcp_jni.so, MD5 fd2ce74db9389980b55bccf3d8f15660):
- Inbound CMD dispatcher (in the function that handles
MSG_ID_BT_AVRCP_CMD_FRAME_IND, around file offset0x6cf0..0x6d58):- Extracts inbound event_id into
[sp, 0x172]and seq_id (the AVCTP transId) into[sp, 0x171]. - Calls
_Z17saveRegEventSeqIdhh(event_id, seq_id)at0x5ee5via theblat0x6d26. saveRegEventSeqIdwritesg_avrcp_req_event_database[event_id] = seq_id(sym 0x5ee4, body):` 0x5ee4 cmp r0, 0xe ; event_id bounds check 0x5ee8 bls 0x5ef8 0x5ef8 ldr r2, [literal] ; PC-relative load of database offset 0x5efa add r2, pc ; r2 = absolute &g_avrcp_req_event_database 0x5efc strb r1, [r2, r0] ; database[event_id] = seq_id 0x5efe bx lr`
- Extracts inbound event_id into
g_avrcp_req_event_databasesymbol table entry: vaddr0x0000d2b5, size 15 bytes, section [16].bss. Maintained automatically by stock JNI on every inbound RegisterNotification.getSavedRegEventSeqId(sym0x71f1) exists but has no xrefs in stock JNI — dead code; the database is read directly via PC-relative addressing at each per-rsp restore site.- Per-rsp restore sites in stock JNI (the canonical pattern):
notificationTrackChangedNative@0x3bc0:` 0x3c06 add ip, pc ; ip = &g_avrcp_req_event_database 0x3c0c ldrb.w r2, [ip, #2] ; r2 = database[2] (TRACK_CHANGED) 0x3c1e strb.w r2, [r8, 0x19] ; conn[+0x11] = r2 (r8 = avrcp_state; conn = r8+8)` Then callstrack_changed_rsp.notificationPlayStatusChangedNative@0x3c88:` 0x3cec add lr, pc ; lr = &database 0x3cee ldrb.w ip, [lr, #1] ; ip = database[1] (PLAYBACK_STATUS) 0x3cf2 strb.w ip, [r7, 0x19] ; conn[+0x11] = ip 0x3cf6 blx playback_rsp`- Same pattern in every other
notification*ChangedNative(10 total in stock JNI).
- The
*_rspbuilders inlibextavrcp.so(file offset0x23f0forreg_notievent_playback_rsp,0x2458fortrack_changed_rsp) readconn[+0x11](ldrb r3, [r4, #0x11]) and pack it intomsg[5]of the outbound IPC frame. From there, mtkbt'sfcn.0xf0bc:0xf1a8writeschan+0x39 = packet[+0xa] = msg[5], and the wire builderfcn.0xae418reads it as the TID nibble.
The TID flow is: inbound CMD → saveRegEventSeqId(event_id, seq_id) → database[event_id] = seq_id → (later, at any response time) add ip, pc ; ldrb r2, [ip, #event_id] ; strb r2, [conn, 0x11] → rsp builder → mtkbt → wire. conn[+0x11] is a transient scratch slot that stock JNI populates fresh on every rsp call, NOT a persistent inbound-TID stash.
Why our trampolines didn't pick up the database value
Our T5 / T9 trampolines REPLACE the stock natives at 0x3bc0 / 0x3c88 (the first 4 bytes are overwritten with b.w T5 / b.w T9), short-circuiting the entire stock body including the database → conn[+0x11] write. Our extended_T2 / T8 short-circuit the CMD dispatcher and call the rsp builders directly without setting conn[+0x11]. So none of our rsp call sites had the database read happening.
Fix
Every rsp call site in our trampolines now invokes a shared restore_conn_tid(r0=conn_ptr, r1=event_id) subroutine inside the trampoline blob. The subroutine does the canonical 14-byte PC-relative dance:
restore_conn_tid:
ldr.w r2, [pc, #lit_offset] ; load PC-relative offset to database
add r2, pc ; r2 = absolute &g_avrcp_req_event_database
ldrb r3, [r2, r1] ; r3 = database[event_id]
strb r3, [r0, #0x11] ; conn[+0x11] = r3
bx lr
.align 4
.lit: .word (DB_VADDR - (add_pc_inst + 4))
Subroutine = 16 bytes (12 code + 4 literal). Each call site = 6 bytes (movs r1, #event_id ; bl restore_conn_tid). 13 call sites (T4 reactive TC + extended_T2 ev=02 + T5 NCC/Pos/RE_END/TC/RE_START + T8's 11 INTERIM arms + T9's 4 CHANGED emits) × 6 bytes + 16 subroutine = 94 bytes added. Offset by removing the stale subs r0, #1; strb r0, [r4, 0x19] TID-restore code in T5/T9 (~10 bytes saved) and the stale ldrb r0, [r5, 0x19]; adds r0, #1 TID-save in extended_T2/T8 ev=09 (~12 bytes saved). Net add to blob: ~70 bytes.
To fit the debug build under the 4020-byte LOAD #1 padding budget, dropped T4a= / T5emit aid= / T9emit pstat= / T8reg ev= log emits and their format strings (~150 bytes total). Kept T5ncc (no-arg format) — the load-bearing diagnostic for whether NCC CHANGED actually fires per Bolt's metadata-refresh path. mtkbt-side M5wire c39= (D1 cave) covers wire-emit timing for every outbound frame, replacing what the JNI-side per-emit logs were measuring.
Post-fix:
- Release blob: 3964 / 4020 bytes (56 free)
- Debug blob: 3992 / 4020 bytes (28 free)
- OUTPUT_MD5:
a05d8e3208f155e9e8c8c1c0a925eadf - OUTPUT_DEBUG_MD5:
3ddad5af4ce016c79e0ed294582ee8c8
Sanity-checked via r2 disassembly of the patched blob: restore_conn_tid @ 0xbae2, literal at 0xbaf0 = 0x000017cb; add r2, pc at 0xbae6 → 0x17cb + (0xbae6 + 4) = 0xd2b5 ✓ matches g_avrcp_req_event_database vaddr. extended_T2's bl.w restore_conn_tid at file offset 0xaf6a decodes to target 0xbae2 ✓.
State bytes [13..20] now pure subscription gates
With the database providing per-event TIDs, the state[N] bytes that previously encoded TID + 1 (state[16] for ev=02, state[20] for ev=09) revert to pure 0/1 subscription flags, matching the schema documented for state[13..19] (sub_pos / sub_play / sub_papp / sub_track_changed / sub_track_reached_end / sub_track_reached_start / sub_battery). Bytes that were 1..16 from a previous flash session under the TID+1 encoding are interpreted as "subscribed" — harmless on the first re-subscription, which writes a fresh 1.
Patcher state (post-fix)
_trampolines.py: sharedrestore_conn_tidsubroutine + per-call-sitebl restore_conn_tidstubs at every*_rspblx site._emit_restore_conn_tid_from_db(a, conn_reg, event_id, tag)is the helper API._thumb2asm.py: newAsm.ldr_lit_w(rt, label)for PC-relative literal loads (Thumb-2 T2 encoding0xF85Ffamily).patch_libextavrcp_jni.py: OUTPUT_MD5 / OUTPUT_DEBUG_MD5 pins updated.patch_mtkbt.py: unchanged from 3b0c628 (M5 cave withcmp r0, 0discriminator onpacket[+0xd]).
Expected post-fix behavior
The trampoline now emits, at every rsp call site:
r0 = conn_ptr (= avrcp_state + 8)movs r1, #event_idbl restore_conn_tid→ readsdatabase[event_id], writesconn[+0x11]- Set up
r1, r2, r3for the rsp builder blx *_rsp
*_rsp packs conn[+0x11] into msg[5]. mtkbt's fcn.0xf0bc:0xf1a8 writes chan+0x39 = packet[+0xa] = msg[5]. M5 cave at 0x6d186 skips its strb on outbound (packet[+0xd] = 0), preserving the chan+0x39 write. Wire builder reads chan+0x39 for the TID nibble.
For Bolt 1222's failing case: ev=09 RegNotif arrives with TID=N → saveRegEventSeqId(9, N) → database[9] = N. T8 INTERIM ack runs → restore_conn_tid(conn, 9) writes conn[+0x11] = N. Outbound INTERIM ships with TID=N. CT acknowledges. Track edge fires T5ncc (gated on state[20]) → restore_conn_tid(conn, 9) reads database[9] = N again, writes conn[+0x11] = N. Outbound CHANGED ships with TID=N. Bolt accepts. Pane updates without lag.
If Bolt logs still show c39=0 for T5ncc-adjacent frames after this fix, either (a) database[9] is not being populated (unlikely — stock JNI is invariably calling saveRegEventSeqId from the dispatcher), or (b) Bolt is not actually re-subscribing to ev=09 in this session (would show as no T8reg ev=09 — but T8reg log was dropped, so verify via M5wire frame counts before/after track edge), or (c) the cave isn't preserving the c39 write (D2 M5dbg p8/pd/ba9= logs would surface it).
Trace #72 (2026-05-19) — Bolt 1951: zero subscribes; T2reg debug marker added
What happened
Bolt 1951 capture showed 0 T5tc / 0 T9ps / 0 T9papp despite 537 Y1T tags total (all M5dbg / M5wire — mtkbt-side wire-frame logs). 18 inbound frames over the entire session (p8=ea); only 4 of them were AV/C CMDs — two PASSTHROUGH 0x4B NEXT PRESS+RELEASE pairs and two 0x46 PAUSE PRESS+RELEASE pairs. Bolt sent zero RegisterNotification PDUs in this session. All 5 prior Bolt captures (1448, 1540, 1647, 1830, 1904) had between 11 and 339 outbound wire frames; this one had 6.
User context: Bolt had bluetooth-crashed on Y1 in the prior session (1904, triggered by the r2-clobber bug in incr_and_get_track_identifier — fixed in commit df1894a). The 1951 session was post-fix. Possibility: Bolt's CT-side AVRCP impl cached "Y1's AVRCP is broken, don't subscribe" after the crash. User did forget+repair Bolt before subsequent sessions.
Visibility gap exposed
The Y1T tag set in patch_libextavrcp_jni.py covered only outbound CHANGED-emit confirmation (T5tc/T9ps/T9papp). Absence of T5tc could mean any of:
- CT didn't send
RegisterNotification(ev=02)this session - CT sent it but extended_T2 didn't get entered (R1 redirect or PDU dispatch broken)
- extended_T2 entered but
save_event_seq_iddidn't write the database - Database was re-cleared mid-session by a subsequent
GetCapabilities
No way to distinguish these from log content alone.
Fix (commit 52a8a80, 2026-05-19)
Added T2reg ev=%02x native log at _emit_extended_t2's PDU=0x31 arm (right after the PDU check, before save_event_seq_id). Drops the M5dbg ba9 log from patch_mtkbt.py's D2 cave to stay within the 4020-byte LOAD #1 budget (ba9 was a historical reference for the M7-era TID-sync hypothesis; not load-bearing post-M7-removal — the M5 discriminator already uses packet[+0xd]).
Diagnosis matrix going forward:
T2reg ev=N present | Outbound marker present | Diagnosis |
|---|---|---|
| no | no | CT didn't subscribe to ev=N this session |
| yes | no | CT subscribed; our CHANGED gate or emit path broke |
| no | yes | shouldn't happen — investigate ghost-arm |
| yes | yes | healthy path |
Release MD5 unchanged (no code path differences). Debug MD5: 9c559e7039... → a7fe9353b9... (jni) / 0246e82640... → ab5cf72d48... (mtkbt).
Cross-CT comparison
| CT | M5dbg frames | Inbound CMDs (p8=ea) | T5tc | T9ps | T9papp |
|---|---|---|---|---|---|
| Bolt 1951 | 18 | 4 | 0 | 0 | 0 |
| TV 1956 | 741 | 16 | 3 | 6 | 0 |
| Sonos 1954 | 177 | 16 | 6 | 2 | 0 |
TV + Sonos both subscribed and got CHANGEDs cleanly with the same patcher build. The Y1 pipeline itself is healthy; the Bolt 1951 case is Bolt-side state.
Trace #73 (2026-05-19) — Bolt 2112: subscribes aggressively (post-repair); "hit or miss" diagnosed as multi-wake burst saturation + GEA refetch gaps
Session shape
Bolt 2112 capture (after user forgot+repaired Bolt): completely different profile.
| Metric | Bolt 1951 | Bolt 2112 |
|---|---|---|
| Outbound wire frames (M5wire) | 6 | 397 |
| Inbound CMDs (p8=ea) | 4 | 19 |
| T2reg total | 0 | 93 |
| T5tc | 0 | 5 |
| T9ps | 0 | 22 |
| T9papp | 0 | 0 |
Bolt subscribed to all 8 advertised events: ev=01 (×12 RegNotifs over session), ev=02 (×1, session-long subscription — no re-register), ev=05 (×75, strict §6.7.1 re-register after every CHANGED), ev=08 (×1), ev=09/0a/0b/0c (×1 each in initial burst).
User report: "Bolt was hit or miss. Still weirdness."
Wire-level evidence of the "miss"
GetElementAttributes refetch pattern overlaid with T5tc emits:
| Track edge | T5tc emit | Bolt's GEA refetch | Delay |
|---|---|---|---|
| Track 1 | 21:09:25.691 | 21:09:29.826 (strlen=11) | 4.1 s |
| Track 2 | 21:09:38.049 | 21:09:45.763 (strlen=9) | 7.7 s |
| Track 3 | 21:09:52.849 | none | — |
| Track 4 | 21:10:06.996 | none | — |
| Track 5 | 21:11:00.821 | 21:11:00.179 (strlen=14) | ~0 |
Tracks 3 and 4 got TRACK_CHANGED CHANGED on the wire (T5tc fired) but Bolt never refetched metadata for them. The metadata pane stays on Track 2's data through Tracks 3 and 4. That's the "freeze."
Smoking gun #1: multi-wake bursts on track edges
Around each track edge, the music app's PlaybackStateBridge cascade fires wakePlayStateChanged 3+ times in <200 ms:
21:09:38.049 T5tc (TRACK_CHANGED CHANGED — track edge)
21:09:38.060 T2reg ev=05 (Bolt re-subscribes to POSITION)
21:09:38.064 wakePlayStateChanged (Java-side wake #1)
21:09:38.087 T2reg ev=05 (Bolt re-subscribes AGAIN)
21:09:38.149 wakePlayStateChanged (wake #2)
21:09:38.168 T2reg ev=05
21:09:38.243 wakePlayStateChanged (wake #3)
21:09:38.260 T2reg ev=05
Three wakePlayStateChanged calls in 200 ms → T9 fires 3 PLAYBACK_POS_CHANGED back-to-back. Bolt processes each, re-subscribes, gets the next CHANGED, and so on — but the rapid cluster saturates its AVCTP buffer. Inter-arrival distribution of Bolt's 75 ev=05 RegNotifs: 21 of 75 (28%) arrived within <500 ms of the previous one. Clean 1 Hz cadence on the remaining ~32 RegNotifs.
The cascade source: onCompletion → onPrepared → onPlayerPreparedTail → setPlayValue each fires its own wakePlayStateChanged independently. Stock AOSP MediaSession coalesces internally; our injected PlaybackStateBridge doesn't.
184 playstatechanged broadcasts over a 64 s session ≈ 2.9 Hz vs. AVRCP 1.3 §5.4.2 Tbl 5.33 nominal 1 Hz.
Smoking gun #2: empty initial GetElementAttributes
The very first GEA (21:09:24.369) returned all 7 attributes with strlen:0 — empty Title/Artist/Album/Genre/TrackNumber/TotalTracks/PlayingTime. Bolt connected and probed before the music app's TrackInfoWriter had populated y1-track-info (no track loaded yet — user hadn't pressed PLAY). Bolt retried 30 ms later (21:09:24.399, still empty) then gave up for ~5 s. Eventually the real metadata landed at 21:09:25.849 and Bolt's next GEA at 21:09:29.826 carried real data.
Not user-visible in this capture (Bolt did eventually refetch), but it's a startup race worth tracking — a CT that keys metadata-pane render on the first GEA only would render empty until next track-change.
Fix #1 — wakePlayStateChanged rate-limit (commit 105eef5, 2026-05-19)
Added an 800 ms gate inside TrackInfoWriter.wakePlayStateChanged (src/patches/inject/com/koensayr/y1/trackinfo/TrackInfoWriter.smali):
:try_start_0
# Suppress broadcast when mPlayStatus unchanged AND <800ms since last call.
# Real play-state edges (mPlayStatus differs from mLastWakePlayStatus)
# bypass unconditionally.
invoke-static {}, Landroid/os/SystemClock;->elapsedRealtime()J
move-result-wide v5
iget-wide v2, p0, mLastWakePlayStateAt:J
sub-long v0, v5, v2
const-wide/16 v2, 0x320 # 800 ms
cmp-long v4, v0, v2
if-gez v4, :rate_limit_proceed
iget-byte v0, p0, mPlayStatus:B
iget-byte v1, p0, mLastWakePlayStatus:B
if-ne v0, v1, :rate_limit_proceed
return-void
:rate_limit_proceed
iput-wide v5, p0, mLastWakePlayStateAt:J
iget-byte v0, p0, mPlayStatus:B
iput-byte v0, p0, mLastWakePlayStatus:B
# ... existing broadcast code
Two new fields: mLastWakePlayStateAt:J, mLastWakePlayStatus:B. Gate applies ONLY when mPlayStatus is unchanged — real play-state edges (PAUSE → PLAY, PLAY → PAUSE) always bypass, so user-driven hammering at 21:10:31-42 (5 toggles in 12 s, all >1 s gaps) is unaffected.
y1-track-info file gets flushed at the call site BEFORE wakePlayStateChanged runs (setPlayStatus / flush / onTrackEdge / markCompletion paths). T6 GetPlayStatus polling stays current regardless of broadcast suppression. Position CHANGED cadence drops from ~2.9 Hz to nominal 1 Hz.
Interaction with markTrackChange(1s) PAUSED-blip suppression in PlaybackStateBridge.onPlayValue: the two gates compose cleanly. markTrackChange skips both setPlayStatus(2) AND wakePlayStateChanged() for newValue=3 (PAUSED) inside its 1 s window. The new 800 ms cap is a layer below — both bias toward fewer spurious emits.
Build error follow-up (commit 2484896): the --debug value-patch anchor for wakePlayStateChanged in patch_y1_apk.py was matching .locals 5 + the original first statement. The rate-limit bumped .locals to 7 and pushed the gate ahead of the mContext load. Re-anchored on .locals 7 + the rate-limit gate's opening comment line; debug log (_dbgLogTrampolineState "wPSC.pre") now injects right after :try_start_0 and BEFORE the gate, so the diagnostic fires on every call including the suppressed ones. Verified by building 3.0.2 and 3.0.7 APKs with KOENSAYR_DEBUG=1.
Trace #74 (2026-05-20) — Pixel↔Bolt full btsnoop parse: behavior deltas + fix candidate ranking
Source
/work/logs/pixel4-bugreport-20260518-1959/FS/data/misc/bluetooth/logs/btsnoop_hci.log — Pixel 4 bonded with cc:88:26:6f:e0:af ("myChevrolet" per Pixel's MR2ServiceImpl log). Live BT capture window covers an active Pixel↔Bolt AVRCP session over ACL handle 0x0003. 375 AVRCP frames + 107 plain L2CAP + 52 SDP + 50 RFCOMM/HFP + 28 AVDTP. The .last file (older rotation) doesn't contain Bolt — only Sonos — and was the wrong file in earlier analysis.
SDP record diff (Pixel TG vs Y1 TG post-V1..V8/S1)
| Attribute | Pixel | Y1 (pre P_PN0/P_PN1) | Y1 (post) |
|---|---|---|---|
| 0x0001 ServiceClassIDList | UUID 0x110c | UUID 0x110c | UUID 0x110c |
| 0x0004 ProtocolDescList | L2CAP(0x0017) + AVCTP(0x0102) | same | same |
| 0x0005 BrowseGroupList | {PublicBrowseRoot 0x1002} | {PublicBrowseRoot 0x1002} | ABSENT (P_PN1 reuses slot) |
| 0x0009 BluetoothProfileDescList | AVRCP(0x0103) | AVRCP(0x0103) | AVRCP(0x0103) |
| 0x0100 ServiceName | "AV Remote Control Target " | "Advanced Audio" (V7) | "Advanced Audio" (V7) |
| 0x0102 ProviderName | " " (single space) | ABSENT | " " (P_PN0+P_PN1, 2026-05-20) |
| 0x0311 SupportedFeatures | 0x0001 | 0x0001 (V8) | 0x0001 (V8) |
ServiceName text difference is cosmetic. Post P_PN0+P_PN1 (commit f19ad7c), the only remaining delta is 0x0005 BrowseGroupList (we drop, Pixel keeps). Bolt discovers services via UUID search against 0x0001 ServiceClassIDList which the TG record still ships — empirically Bolt connects fine without 0x0005.
GetCapabilities (Events Supported)
Both advertise the identical 8-event set: {0x01, 0x02, 0x05, 0x08, 0x09, 0x0a, 0x0b, 0x0c}. Verified via tshark PDML dump of Pixel's Sent Stable - GetCapabilities frame.
Bolt's RegisterNotification parameters
PlaybackPositionChanged (0x05) RegNotif from Bolt carries Interval: 1 (1 second). Per AVRCP 1.3 §5.4.2 Tbl 5.33, this is the playback_interval the CT requests CHANGED notifications at. Pixel honors it — emits CHANGED at clean 1 Hz. Y1's T9 ignores the inbound parameter and emits on every wake (now rate-limited to ≥800 ms).
Track-edge choreography — Pixel vs Y1
Pixel's complete sequence on a single track change (e.g., the cycle at 78.013-78.749):
T+0 Sent CHANGED NowPlayingContentChanged
T+0 Sent CHANGED TrackChanged - Identifier=0x00 (SELECTED) ← PHASE 1
T+40 Rcvd GetElementAttributes
T+41 Sent GEA Stable Title="Not Provided" (metadata not ready yet)
T+65 Rcvd Notify NowPlayingContentChanged (Bolt re-registers)
T+67 Sent INTERIM NCC
T+90 Rcvd GEA
T+91 Sent GEA "Not Provided"
T+118 Rcvd Notify TrackChanged
T+120 Sent INTERIM TC Identifier=0x00
T+478 Sent CHANGED NowPlayingContentChanged (proactive — second NCC!)
T+478 Sent CHANGED PlaybackStatusChanged - PlayStatus=Paused (PAUSED blip!)
T+484 Sent CHANGED TrackChanged - Identifier=0x01 ← PHASE 2 (bumped)
T+506 Rcvd GEA
T+508 Sent GEA "ANTHEM PART 3" (real metadata)
T+530 Rcvd Notify NCC
T+535 Sent INTERIM NCC
T+554 Rcvd Notify PSC
T+556 Sent INTERIM PSC PlayStatus=Playing (transitions back to Playing)
T+570 Rcvd GEA
T+572 Sent GEA "ANTHEM PART 3"
T+619 Rcvd Notify TC
T+623 Sent INTERIM TC Identifier=0x01
T+665 Rcvd GEA
T+667 Sent GEA "ANTHEM PART 3"
T+736 Sent CHANGED PlaybackPositionChanged SongPosition=37ms
T+753 Rcvd Notify PPC
T+755 Sent INTERIM PPC SongPosition=56ms
T+1757 Sent CHANGED PPC SongPosition=1025ms (clean 1 Hz cadence)
Y1's current sequence on a track edge (T5 trampoline, single emission):
T+0 Sent CHANGED NowPlayingContentChanged
T+0 Sent CHANGED PlaybackPositionChanged
T+0 Sent CHANGED TrackChanged - Identifier=monotonic-counter (bumped once)
T+0 Sent CHANGED [REACHED_END / REACHED_START — usually gated out]
Behavioral deltas (Pixel does, Y1 doesn't)
- TWO-PHASE TrackChanged: Phase 1 with previous Identifier (or
0x00SELECTED) at early track-switch trigger; Phase 2 with new Identifier after metadata flushes. 480 ms apart in Pixel. Bolt likely uses Phase 2 as the refetch trigger. - NowPlayingContentChanged TWICE per track edge: once on early switch, once after metadata settle. Y1 emits NCC once.
- PAUSED CHANGED emitted during track change: Pixel ships
PlaybackStatusChanged PlayStatus=Pausedmid-transition (T+478) without suppression. Y1 actively suppresses this viaPlaybackStateBridge.onPlayValue'smarkTrackChange(1s)deadline gate. - PlaybackPositionChanged emitted FIRST on track edge (T+0 in the next cycle at 100.599, vs TC at 100.604 — PPC leads by ~5 ms). Y1 emits TC first then PPC.
- GetElementAttributes response shape: Pixel emits NumberOfAttributes equal to the number it has data for (e.g. 4 of 7 requested) with literal
"Not Provided"for unknown Title,"1"/"0"ASCII for unknown numeric. Y1 always emits NumberOfAttributes = requested withstrlen=0for unknown (per §5.3.4 strict reading). - GEA response latency: Pixel 1-3 ms, Y1 ~21-30 ms. Y1's overhead comes from T4's
open()+read()ofy1-track-infofrom/dataper GEA + 7 separatesend_get_element_attributes_rspbuilder calls. Bolt fires GEAs in tight bursts of 3 within ~80 ms — Y1 might not finish the first response before the third request lands. - TrackChanged INTERIM ships stale "previous" Identifier: Pixel's INTERIM responses ship
0x...01consistently even after CHANGED with0x...02. Pixel's INTERIM doesn't reflect current state — quirky but Bolt accepts. - Pixel REJECTS SetPlayerApplicationSettingValue with
Invalid Parameter: 5 inbound from Bolt got rejected by Pixel. Y1 ACCEPTS PApp Sets (T_papp 0x14 → PappSetFileObserver → SharedPreferencesUtils). Y1 exceeds Pixel here; not a bug. - Empty initial GEA is normal: Pixel's first 5 GEAs to Bolt also returned "Not Provided" (no metadata yet). Bolt handles empty-start gracefully. Rules out the "empty first GEA causes freeze" hypothesis.
- Pixel's Identifier semantic is the actual NowPlayingList row UID (Pixel cycles 0x00 / 0x01 / 0x02 across the 3-track playlist Bolt was traversing). Y1's monotonic counter is a different semantic but functionally equivalent for Bolt (which uses "Identifier differs from last" as the refetch trigger).
Fix candidate ranking (after rate-limit + ProviderName)
| # | Fix | Effort | Expected impact | Status |
|---|---|---|---|---|
| 1 | T4 in-memory cache — read y1-track-info on metachanged broadcast into JNI .bss, T4 serves from memory | Medium | High — closes the 10-30× latency gap, likely fixes Bolt GEA burst response | Deferred |
| 2 | Two-phase TRACK_CHANGED emit — Phase 1 Identifier=0 SELECTED on track-switch start, Phase 2 with monotonic counter after metadata flush | Low-Med | High — matches Pixel's exact wire signal Bolt is built against | Bundled with #3,#4 |
| 3 | Double NCC emit per track edge — emit at early switch + at metadata-settled point | Low | Medium | Bundled with #2,#4 |
| 4 | Reorder: PPC=0 → TC on track edge instead of TC → PPC | Low | Low-Medium (UX cleanup) | Bundled with #2,#3 |
| 5 | Honor inbound playback_interval — plumb the byte from RegNotif into the database, T9 emits only when interval elapsed | Medium | Low (rate-limit already covers position cadence) | Skip for now |
| 6 | Revisit markTrackChange suppression — Pixel doesn't suppress, ship the PAUSED CHANGED through | Low | Unknown (other CTs may have relied on suppression — needs A/B test) | Defer |
Implementation plan for the #2+#3+#4 bundle
Bundle them as a single coherent change ("match Pixel's track-edge choreography on the wire"). One cohesive edit in _trampolines.py:
a. Add two T5 entry points:
t5_phase1_no_bump: emits NCC + PPC=0 + TC (no Identifier bump) — called fromPlaybackStateBridge.onEarlyTrackChangevia a newwakeTrackChangedPhase1method onTrackInfoWriter.t5_phase2_bump(= existingT5): emits NCC + PPC + REACHED_END + TC (bumps Identifier) + REACHED_START — called from existingwakeTrackChangedpaths afteronPlayerPreparedTail's flush.
b. Reorder T5 emit sequence: move PPC ahead of TC in both phases. Currently NCC → PPC → REACHED_END → TC → REACHED_START. New order: NCC → PPC → REACHED_END → TC → REACHED_START stays for Phase 2, but Phase 1 keeps it simpler: NCC → PPC=0 → TC (no REACHED_END / REACHED_START in Phase 1).
c. Phase 1 trigger: music-app-side hook in PlaybackStateBridge.onEarlyTrackChange adds a wakeTrackChangedPhase1 call. The existing onEarlyTrackChange already fires on toRestart()'s setDataSource(newPath) site — 100-500 ms earlier than onPrepared. Phase 2 fires from onPlayerPreparedTail (after playerIsPrepared = true flips and duration is captured).
d. Identifier semantics for Phase 1: ship the current Identifier (no bump). Phase 2 bumps. So Bolt sees TC CHANGED with Identifier=N (Phase 1) then TC CHANGED with Identifier=N+1 (Phase 2). Phase 1 signals "transition starting"; Phase 2 signals "settled, refetch metadata."
Why skip #5 and defer #6
#5 (playback_interval): adds new state plumbing (extended_T2 extracts the interval byte from inbound RegNotif payload, save to new .bss slot, T9 gates position emit on elapsed-since-last vs interval). It's a clean spec-compliance improvement but the 800 ms rate-limit already delivers the same effective wire cadence for Bolt's Interval: 1 request. Adding #5 now costs more code than it returns. Save it for when we see a CT requesting a different interval (5 s, 30 s, etc).
#6 (remove markTrackChange suppression): the suppression was added 2026-05-15 for a specific CT-side observation — "spurious paused-state blips interrupt head-unit playback indicators during track changes." It's in the released CHANGELOG. Removing it because Pixel doesn't suppress is a reference-mimicry reflex — Pixel's CT-compat profile differs (Pixel ships AOSP MediaSession with built-in coalescing; our PlaybackStateBridge cascades independent wakes through onCompletion/onPrepared/onPlayerPreparedTail/setPlayValue). Pixel's PAUSED blip works because the rest of Pixel's frame sequencing is clean. Our blip used to land during burst storms that already had Bolt under AVCTP pressure. The rate-limit fix likely makes #6 safe again, but validating that needs an A/B test, not a speculative removal. Wait until next Bolt capture confirms the rate-limit fixed Tracks 3/4 freeze first.
Risk side of bundling
If the next Bolt capture still freezes, bisection space is: {rate-limit (105eef5), ProviderName (f19ad7c), two-phase track-edge (this bundle)}. Three changes is manageable. Adding #5/#6 would push to five — bisection gets painful fast.
Decision
Bundle #2+#3+#4 in a single subsequent commit (post user-test of the rate-limit + ProviderName changes). Defer #5 indefinitely (low value-per-LOC vs other fixes). Defer #6 until rate-limit fix is validated.
Trace #75 (2026-05-19) — Bolt 2221: PSC edges are Bolt's metadata-refresh trigger, not TC; PSC pulse at track-edge implemented
What the user found
User experimented with rapid PLAY/PAUSE hammering on Bolt's steering-wheel buttons during a Bolt 2221 session and observed: metadata updated MUCH faster than the ~40 s natural cycle. Their initial hypothesis: "Y1 is locking up between track changes; PLAY/PAUSE toggling unlocks it."
What the wire trace proves
Bolt 2221 capture profile vs Bolt 2112 (same patcher build):
| metric | Bolt 2112 (no hammering) | Bolt 2221 (heavy PLAY/PAUSE) |
|---|---|---|
| T2reg total | 93 | 51 |
| T2reg ev=01 PlaybackStatus | 12 | 42 |
| T2reg ev=05 PlaybackPosition | 75 | 3 |
| T5tc TrackChanged emits | 5 | 10 |
| T9ps PlaybackStatus emits | 22 | 63 |
| PASSTHROUGH key events | 18 | ~120 |
Bolt re-registered PlaybackStatusChanged 42 times in 2221 (one per real state edge from user button presses) and only 3 times for PlaybackPosition (because user was mostly paused, no position emits → no re-register).
Overlay GetElementAttributes refetches against PSC edges:
22:19:18 T5tc track-edge → 22:19:18 GEA refetch (4.1 s after T5tc — refetched)
22:19:33 T5tc track-edge → no GEA refetch (NEXT button, but no PSC edge accompanied it)
22:19:46 T5tc track-edge → no GEA refetch (another track edge, no PSC, no refetch)
22:19:22 → 22:20:24 = 62-second GEA gap ← THE "FREEZE"
22:20:34 NEXT press
22:20:36 PAUSE press → T9ps → 22:20:38 GEA refetch (~2 s after PSC edge)
22:20:38 PLAY press → T9ps → 22:20:42 GEA refetch
22:20:40 PAUSE → T9ps → 22:20:46 GEA refetch
...every PLAY/PAUSE = T9ps emit + GEA refetch within 2-4 s
Bolt's CT-side metadata-refresh trigger is PlaybackStatusChanged, not TrackChanged. TC CHANGED reaches Bolt (T5tc fires on the wire, database[2] gate is armed), but Bolt's parser doesn't refetch on TC alone — it refetches on PSC edges and on PASSTHROUGH key events. The "freeze" symptom is Bolt's refresh logic being dormant without PSC edges.
Why natural track ends produce zero PSC edges
In the music app's natural track-end → next-track path:
onCompletion(player engine EOS) — nosetPlayValuecall, file[792] stays at 1 (PLAYING)onPrepared(next track) —setPlayValuemay fire but PlayerService keeps PLAYING throughoutsetPlayValue(1)PLAYING —setPlayStatus(1)writes file[792]=1 (no change),wakePlayStateChanged()fires, T9 reads file[792]=1 vs state[9]=1 → no edge → no emit
In the music app's restartPlay() (manual NEXT/PREV) path:
pause()→setPlayValue(3)PAUSED →onPlayValuev0=2 →markTrackChange(1s)suppresses both setPlayStatus AND wake → file[792] stays 1, no PSC edgesetDataSource(newPath)→onEarlyTrackChangeprepareAsync→onPrepared→onTrackEdge+wakeTrackChangedsetPlayValue(1)PLAYING →setPlayStatus(1)→ file[792]=1 still, state[9]=1 still → no edge → no emit
Both paths produce zero PSC CHANGED — markTrackChange compounds the natural-EOS gap.
Pixel-as-TG comparison
Pixel's btsnoop (handle 0x0003, Bolt session) ships PSC=Paused CHANGED mid-transition at T+~480 ms after track-switch trigger, then PSC=Playing INTERIM via Bolt's re-register burst. Two PSC edges per track edge — bracketing the TC CHANGED. Bolt refetches twice. Same wire shape we need.
Fix (commit pending)
Added TrackInfoWriter.pulsePlayStatusForCT():
public synchronized void pulsePlayStatusForCT() {
if (mPlayStatus != PLAYING) return;
setPlayStatus(PAUSED); // file[792]=2 + flushLocked
wakePlayStateChanged(); // T9 sees edge (state[9]=1 vs file[792]=2) → emits PSC=PAUSED CHANGED
setPlayStatus(PLAYING); // file[792]=1 + flushLocked
wakePlayStateChanged(); // T9 sees edge (state[9]=2 vs file[792]=1) → emits PSC=PLAYING CHANGED
// T9 position branch (gated on file[792]==1) also emits POSITION CHANGED
}
Called from PlaybackStateBridge.onPlayerPreparedTail after the existing wakeTrackChanged + wakePlayStateChanged calls. onPlayerPreparedTail is the canonical end-of-track-edge hook (B5.2c), fires after iput-boolean playerIsPrepared = true flips — duration is captured, audio_id is settled, file is consistent.
Wire shape post-fix at every track edge:
T+0 T5tc (TRACK_CHANGED CHANGED, Identifier counter bumped)
T+0 NowPlayingContentChanged CHANGED
T+0 PlaybackPositionChanged CHANGED (position=0 for new track)
T+~5ms PSC=PAUSED CHANGED ← pulse phase 1
T+~10ms PSC=PLAYING CHANGED ← pulse phase 2
T+~10ms PlaybackPositionChanged CHANGED (position progressing)
Bolt sees two PSC edges per track edge → refreshes metadata pane immediately, no longer waits for polling cycle.
Why this is safe
- Rate-limit gate (commit 105eef5): bypasses on every real play-status flip. Both pulse wakes flip
mPlayStatus(1→2, 2→1), so both bypass. markTrackChange(1s)suppression: lives inPlaybackStateBridge.onPlayValue, not inpulsePlayStatusForCT. The two are independent gates.- Position drift: each
setPlayStatuscall advancesmStateChangeTime. Two calls separated by <5 ms driftmPositionAtStateChangeby <5 ms — invisible on the CT's playhead. - Audio_id edge detection in
setPlayStatus: atonPlayerPreparedTailtime,mCachedAudioIdalready reflects the new track. Snapshot and post-flushLocked re-read both see the same value → no false audio_id reset. - Other CTs: TV (gold standard), Sonos, Kia — adding PSC blips at track edges should at worst be wire noise they ignore (TV is "instant" regardless, Sonos doesn't render play-state much, Kia polls). Pixel ships the same wire shape and works on all four — empirical reassurance.
- Only fires when PLAYING: the early return guard means track edges that land while paused/stopped don't get a phantom pulse.
Decision log
- The simpler fix candidate ("remove
markTrackChangesuppression entirely") was considered and rejected.markTrackChangeonly affectsrestartPlay()'s internal pause→play handshake — natural track ends (the more common case) never go through that path. So removingmarkTrackChangewould only help the manual-NEXT case, not the more important auto-advance case. The explicit pulse handles both. - Could also have implemented as a trampoline-side change in T5 (emit PSC CHANGED unconditionally after TC), but T5's edge detection is intrinsic to its design — bypassing it cleanly requires bypassing the file→state comparison entirely, which is more invasive than the music-app-side pulse.
Trace #76 (2026-05-20) — Bolt 0625: PSC pulse race condition; 50 ms Handler.postDelayed inserted between phases
What Bolt 0625 captured
First flash with the in-Java PSC pulse from Trace #75 (TrackInfoWriter.pulsePlayStatusForCT calls setPlayStatus(2) + wakePlayStateChanged + setPlayStatus(1) + wakePlayStateChanged inline). Tag distribution:
| metric | Bolt 2221 (pre-pulse) | Bolt 0625 (with inline pulse) |
|---|---|---|
| M5dbg / M5wire | 1176 / 588 | 1068 / 534 |
| T5tc | 10 | 21 |
| T9ps | 63 | 69 |
| T2reg ev=01 | 42 | 10 |
| Track edges | ~10 | 21 |
T5tc went up 2× because user did a session of 21 rapid track skips (06:23:00 - 06:24:34). T9ps only went up modestly (69 vs 63) — meaning the pulse fired its two PSC CHANGEDs successfully on SOME track edges but not all.
Working pulse (06:23:50 edge)
06:23:50.378 setPlayStatus from=1 to=2 ← pulse phase 1 SET
06:23:50.379 flushLocked ps=2 ← file[792]=2 durable
06:23:50.380 wakePlayStateChanged ← phase 1 wake
06:23:50.384 setPlayStatus from=2 to=1 ← pulse phase 2 SET
06:23:50.385 flushLocked entry (ps=1 in progress)
06:23:50.385 T9ps (PSC=PAUSED CHANGED) ← T9 fired for phase 1 broadcast, read file[792]=2 (still!)
06:23:50.387 ps=1 written ← file[792]=1 now durable
06:23:50.389 wakePlayStateChanged ← phase 2 wake
06:23:50.400 T9ps (PSC=PLAYING CHANGED) ← T9 fired for phase 2, read file[792]=1, state[9]=2 → edge
T9 fired during the tiny window after setPlayStatus(1) entry but BEFORE flushLocked wrote ps=1. The race resolved in our favor by luck — mtkbt happened to schedule T9 ~5 ms after phase 1's broadcast, before phase 2's file write.
Failing pulse (06:23:54 edge)
06:23:54.908 setPlayStatus from=1 to=2 ← pulse phase 1 SET
06:23:54.910 flushLocked ps=2 ← file[792]=2 durable
06:23:54.911 wakePlayStateChanged ← phase 1 wake
06:23:54.916 setPlayStatus from=2 to=1 ← pulse phase 2 SET
06:23:54.917 flushLocked ps=1 written ← file[792]=1 durable (only 7 ms after phase 1's write)
06:23:54.917 mtkbt onReceive playstatechanged ← processes phase 1's broadcast
06:23:54.918 wakePlayStateChanged ← phase 2 wake
(T9 fires for phase 1's broadcast NOW — but
file[792]=1 already, state[9]=1, no edge,
no emit)
06:23:54.930 mtkbt onReceive playstatechanged ← processes phase 2's broadcast
(T9 fires — file[792]=1, state[9]=1, no edge,
no emit)
NO T9ps emitted on this edge.
mtkbt's broadcast-to-native scheduling jitter on rapid-skip cycles was enough to push T9's phase-1 invocation past phase 2's file write. Both T9 invocations read file[792]=1 and saw no edge.
Root cause
T9 reads y1-track-info from disk at run time, not at broadcast-queued time. The musicapp-side pulse pattern (set + wake + set + wake) assumes T9 will fire for each broadcast in order, reading the file state at each broadcast's queued time. In reality T9's invocation latency varies with mtkbt load — on a busy main thread (rapid track skips), the gap between broadcast queueing and T9 dispatch can exceed the pulse's inter-phase write gap, causing T9 to read the same final file state for both broadcasts.
This is a fundamental race in any "set file + wake" design where the consumer reads at run time. AOSP's MediaSession framework sidesteps it by passing the new state IN the broadcast extras — but our trampolines read the file directly, not Java extras.
Fix
Add an explicit 50 ms gap between phases via Handler.postDelayed on the main Looper. New class com.koensayr.y1.playback.PscPulse:
static fire(): phase 1 —setPlayStatus(PAUSED)+wakePlayStateChanged(). ThenHandler.postDelayed(this, 50).Runnable.run(): phase 2 —setPlayStatus(PLAYING)+wakePlayStateChanged().
TrackInfoWriter.pulsePlayStatusForCT now just owns the "only pulse while PLAYING" gate and delegates to PscPulse.fire(). The 50 ms gap is enough headroom for mtkbt's broadcast dispatch + JNI invocation + T9 syscall chain to complete phase 1 durably before phase 2's file write.
50 ms was chosen because:
- The working 06:23:50 case had T9 fire ~5 ms after phase 1's wake — i.e., mtkbt scheduling latency was 5 ms there. 50 ms = 10× headroom.
- Pixel-as-TG emits its PSC=Paused → PSC=Playing transition over ~70-100 ms (from btsnoop_hci 2026-05-18). 50 ms is within that range.
- Bolt is unlikely to react to the brief PAUSED state (the gap is shorter than any reasonable CT UI debounce).
Why not use broadcast extras
com.android.music.playstatechanged Intent extras carry playing:Z but that's a boolean — doesn't encode the 3-state AVRCP play_status (STOPPED/PLAYING/PAUSED). The trampolines could be modified to look at the broadcast extras instead of the file, but that's invasive: T9 lives in mtkbt's address space, the broadcast extras are in Java-side Intent, and there's no clean way to plumb them through Android's broadcast→native callback path. Adding a side-channel file /data/data/com.innioasis.y1/files/y1-pulse-state that T9 reads via a separate fd would work but adds a third file and an extra read per emit — not worth it for one fix.
The 50 ms Handler delay is the simplest correct solution.
Implementation
- New file:
src/patches/inject/com/koensayr/y1/playback/PscPulse.smali TrackInfoWriter.pulsePlayStatusForCTreduced to: checkmPlayStatus == PLAYING, callPscPulse.fire().patch_y1_apk.py: PscPulse registered inPATCH_B5_INJECT_FILESand the rebuilt-DEX manifest. Debug-mode marker logs forPscPulse.fire (phase 1)andPscPulse.run (phase 2 +50ms).
Trace #77 (2026-05-20) — Plan: mmap-backed y1-track-info for race-free, syscall-free trampoline reads
Motivation
Pixel-as-TG vs Y1 GEA response latency: 1-3 ms vs 21-30 ms (Trace #74). The 10-20× delta comes from T4's open + SYS_read + close syscall chain reading 1104 bytes from /data/data/com.innioasis.y1/files/y1-track-info on EVERY inbound GetElementAttributes from the CT. T5/T6/T8/T9 all do the same syscall chain — order of magnitude 20-50 reads of the same 1104-byte file per minute under normal use, multiplied during track-skip sessions.
Same disk-IO design also produced the PSC pulse race in Trace #76 — T9 reads the file at run time, not at broadcast-queued time, so concurrent writes from the music app can race past T9's invocation. The 50 ms Handler delay is a workaround; the real fix is to eliminate the disk hop entirely.
Design — file-backed mmap with double-buffered atomic swap
Use mmap'd shared memory between the music app (writer) and mtkbt's bluetooth process (reader). The file lives at the same path (/data/data/com.innioasis.y1/files/y1-track-info) so failure paths fall back to the existing open+read semantics gracefully.
Schema extension (1104 B → 2213 B):
| offset | size | content |
|---|---|---|
0 | 1 B | active_slot byte — 0 or 1 — single-byte atomic write |
1..3 | 3 B | RFA padding (align slot[0] to 4) |
4..1107 | 1104 B | slot[0] — existing 1104-byte schema (audio_id / title / artist / album / position / status / battery / papp / etc.) |
1108..2211 | 1104 B | slot[1] — second copy of the same schema |
2212 | 1 B | RFA padding |
Single-byte atomic store on active_slot is race-free on ARMv7 (8-bit aligned strb). Reader reads active_slot once, dispatches to the corresponding slot. Writer writes to the other slot first, then atomically updates active_slot to point at the freshly-written one. Reader never sees a partial write because slot[0] and slot[1] are never both being touched simultaneously.
Writer flow (music app TrackInfoWriter.flushLocked):
mmap once at init → mapped_base
loop {
inactive = 1 - mapped_base[0]
fill_struct(&local_buf)
memcpy(&mapped_base[4 + inactive*1104], &local_buf, 1104)
mapped_base[0] = inactive // atomic byte store
}
Reader flow (any trampoline):
mmap once at first call → mapped_base (cached in .bss)
active = ldrb [mapped_base, 0]
src = mapped_base + 4 + active * 1104
memcpy(&local_stack_buf, src, 1104) // or read individual fields directly
... existing per-trampoline logic on local_buf ...
The reader takes a snapshot via memcpy rather than reading fields in-place from the slot. Snapshot ensures a consistent view even if active_slot flips mid-read (we'd read the now-inactive slot, but its contents are still the consistent snapshot from the previous write).
Why double-buffer vs seqlock
Seqlock requires a retry loop on the reader side and barrier instructions to prevent reordering. ARMv7's memory model is weakly ordered — a naive seqlock without dmb barriers would race. Adding dmb ish instructions costs cycles and is fiddly to get right in Thumb-2 inline assembly.
Double-buffer needs only:
- Reader: one
ldrb(atomic) + memcpy from the chosen slot - Writer: memcpy to the inactive slot + one
strb(atomic) of the new flag
ARMv7 guarantees single-byte ldrb/strb to aligned addresses are atomic. No barriers needed for the producer-consumer pattern because:
- Writer completes the inactive-slot memcpy before updating the flag
- Reader reads the flag before dereferencing the slot
- The flag write/read serves as the happens-before edge
Slight memory waste (1104 extra bytes on disk + in the page cache) is the only cost. Trivial.
Trampoline-side changes
New .bss bytes (in g_avrcp_req_event_database neighbourhood at vaddr 0xd2b5+):
g_y1_track_info_mmap_base : 4 B // pointer to mmap'd region, NULL until lazy-init
g_y1_track_info_mmap_failed : 1 B // sticky failure flag — fall back to open+read forever
New shared subroutine (_emit_mmap_track_info_subroutine in _trampolines.py):
mmap_track_info:
ldr.w r0, [g_y1_track_info_mmap_base]
cbz r0, do_mmap // NULL → lazy-init
bx lr // already mapped → return ptr
do_mmap:
ldrb [g_y1_track_info_mmap_failed]
cbnz r0, return_null // previously failed → don't retry
// open(path_track_info, O_RDONLY, 0)
adr_w r0, path_track_info
mov r1, O_RDONLY
movs r2, 0
blx PLT_open
cmp r0, 0
blt set_failed
// mmap2(NULL, 4096, PROT_READ, MAP_SHARED, fd, 0)
mov r4, r0 // r4 = fd
movs r0, 0
mov r1, 0x1000 // 1 page = 4096 B (covers 2213 B file)
movs r2, 1 // PROT_READ
movs r3, 1 // MAP_SHARED
push {r0, r1} // syscall stack args: pgoff
svc 192 // NR_mmap2
add sp, 8
cmp r0, 0
blt close_and_fail
// store mapped ptr, close fd, return ptr
str.w r0, [g_y1_track_info_mmap_base]
mov r5, r0 // save ptr across close
mov r0, r4
blx PLT_close
mov r0, r5
bx lr
close_and_fail:
mov r0, r4
blx PLT_close
set_failed:
strb #1, [g_y1_track_info_mmap_failed]
return_null:
movs r0, 0
bx lr
Cost estimate: ~80-100 bytes of trampoline code (one-time).
Per-trampoline read path changes:
Each trampoline currently does:
adr_w r0, path_track_info
movs r1, O_RDONLY
movs r2, 0
blx PLT_open // ~30 bytes total for the open
cmp r0, 0
blt skip
mov r4, r0
mov r0, r4
add_sp_imm r1, OFF_FILE
movw r2, 1104
movs r7, NR_read
svc 0 // syscall — slow
mov r0, r4
blx PLT_close
Replaced with:
bl mmap_track_info // r0 = ptr or NULL
cbz r0, fallback_open_read
ldrb r1, [r0, #0] // active_slot
add_imm r2, 4
muls? ... add r0, r0 to slot start
mov r0, slot_src
add_sp_imm r1, OFF_FILE
movw r2, 1104
blx PLT___memcpy_chk // copy slot → stack buffer
b after_read
fallback_open_read:
... existing open+read+close path ...
after_read:
Cost estimate: ~30-40 bytes added per trampoline (5 trampolines × ~35 B = ~175 B). 80 B for the mmap subroutine. Total ~255 B added; current debug-build headroom is 504 B, release-build 624 B. Comfortable fit.
Music-app-side changes
TrackInfoWriter.flushLocked currently writes via FileOutputStream to a tmp file then renameTo for atomic visibility. Rename creates a NEW inode — any process that has the old inode mmap'd would see stale content forever. So we must switch to in-place updates.
New flushLocked:
private MappedByteBuffer mTrackInfoMmap; // lazy-init
private MappedByteBuffer ensureMmap() {
if (mTrackInfoMmap != null) return mTrackInfoMmap;
try {
File f = new File(mFilesDir, "y1-track-info");
// Ensure file is exactly 2213 bytes before mmap
try (RandomAccessFile raf = new RandomAccessFile(f, "rw")) {
raf.setLength(2213);
}
FileChannel ch = new RandomAccessFile(f, "rw").getChannel();
mTrackInfoMmap = ch.map(FileChannel.MapMode.READ_WRITE, 0, 2213);
// FileChannel.close() doesn't unmap the buffer per Java spec; mapping persists
} catch (Throwable t) {
Log.w("Y1Patch", "mmap init failed: " + t);
// fallback path leaves mTrackInfoMmap = null; flushLocked uses old write path
}
return mTrackInfoMmap;
}
private void flushLocked() {
byte[] buf = buildBuf(); // existing 1104-byte struct fill
MappedByteBuffer m = ensureMmap();
if (m != null) {
int active = m.get(0) & 0xFF;
int inactive = 1 - (active & 1);
int slotOffset = 4 + inactive * 1104;
m.position(slotOffset);
m.put(buf, 0, 1104);
m.put(0, (byte) inactive); // atomic flag flip
return;
}
// Fallback: existing tmp+rename path (slower but works without mmap)
...
}
Java MappedByteBuffer.put(byte) is a single STR — atomic on the underlying memory. put(int, byte) is similar.
ensureMmap is called only on flushLocked (which is synchronized). No concurrent mmap init race.
Failure modes & fallback
| Failure | Detection | Action |
|---|---|---|
open(y1-track-info) returns -ENOENT (file not created yet) | Reader: bl mmap_track_info returns NULL | Fall back to existing open+read path (file might exist via TrackInfoWriter.init by next call) |
mmap2 returns -ENOMEM or -EINVAL | Reader: subroutine sets g_y1_track_info_mmap_failed = 1, returns NULL | All future reads use the open+read fallback for this process lifetime |
| Music app process restart | New mmap on next flushLocked | mtkbt's mmap'd view is stale until music app writes — kernel-managed page cache keeps it consistent |
| Music app crashes mid-write to inactive slot | Reader's active_slot byte still points at the PREVIOUS slot which has the LAST consistent snapshot | Reader gets stale-but-valid data; next successful write resyncs |
setLength(2213) race between music app and trampoline open | Reader sees a 1104-byte file briefly | Reader's mmap of a 4096-byte page maps the underlying file content; if file is only 1104 B at mmap time, reading offsets > file size returns SIGBUS; need to ensure music app setLength before writing slot 1 content |
Mitigation for the last one: TrackInfoWriter's init(Context) runs on Y1Application.onCreate and setLength(2213) before any flush. By the time mtkbt-side trampolines run (after music app is up), the file is already 2213 B.
Implementation phases
To keep bisection space manageable, ship as separate commits in this order:
Phase 1 — Music app side: switch from tmp+rename to in-place + schema extension (one commit)
TrackInfoWriter.init()sets file length to 2213, writes initialactive_slot = 0+ slot[0] defaults.flushLocked()switches to mmap'dMappedByteBufferwrites with double-buffer flip.- Existing trampolines continue using
open+read(reading slot[0] at offsets4..1107works because that's where the new schema places the active default state at init time — but if the active_slot has flipped to 1, trampolines reading offset 0+1104 would see stale data). - Wait, this breaks compatibility with old trampolines. Phase 1 must include a tiny trampoline shim that ALWAYS reads the active slot (1 extra
ldrb+ add).
Hmm, that means Phase 1 = music-app + trampoline schema understanding. They have to ship together.
Better Phase 1: trampolines learn double-buffer schema, music app still writes tmp+rename of OLD schema (1104 bytes). Trampolines look at file size — if 2213, parse new schema; if 1104, parse old. Backward compat.
Then Phase 2: music app switches to in-place double-buffer writes. Trampolines already know how to read it.
Then Phase 3: trampolines switch to mmap (still falling back to open+read).
Let me re-order:
Phase 1 — Trampolines: read active-slot schema (with backward compat)
- Trampolines read
active_slotbyte at file[0], then read slot atfile[4 + active*1104]. - If file size is < 2213, fall back to reading from offset 0 (old schema).
- Music app unchanged — still writes 1104 bytes at offset 0 (which corresponds to
active_slot=0+ 3 bytes RFA + first 1100 bytes of slot[0] — wait, that doesn't work; the byte at offset 0 in the old schema is part of audio_id, not 0).
Bad backward compat. Old schema has audio_id at file[0..7]. New schema has active_slot at file[0].
Alternative: trampolines DETECT new vs old schema by checking file size at mmap/read time. If file size is exactly 1104, use old offsets. If file size is 2213, use new offsets.
Phase 1: Music app extends file to 2213 bytes. file[0..3] = active_slot + padding (initialised to 0). file[4..1107] = slot[0] (new home of audio_id etc.). file[1108..2211] = slot[1] (zero-filled until first flip). Music app writes both slots? No — only writes inactive slot then flips. On init, both slots are zero or have the same content.
flushLocked writes to inactive slot. So first flush: active=0 initially, inactive=1, writes slot[1], flips to active=1. Second flush: active=1, inactive=0, writes slot[0], flips to active=0. And so on.
Trampolines see size=2213 → parse active_slot, dispatch to correct slot.
If trampolines are deployed BEFORE music app's phase 1 (e.g., separate flash of /system/lib/libextavrcp_jni.so without music app update), the trampoline would size-check, see 1104 (old file size), and use old offsets. Music app's old writes still land at offsets 0..1103 which is what trampolines read.
OK Phase 1 split:
Phase 1a — Trampolines: dual-schema reads (small commit)
- Add file-size check
- If size >= 2213: read active_slot, dispatch to slot
- Else: read offset 0 as before (old behaviour)
- Compatible with old music app
Phase 1b — Music app: extend file to 2213 bytes, write to inactive slot, flip active_slot (separate commit)
- Music app's flushLocked switches to active-slot-aware
- File grows from 1104 to 2213 bytes on first flush
- tmp+rename path drops in favour of in-place write (slot writes are bounded to one slot; flag flip is atomic)
After Phase 1b: Y1's pipeline works with the new schema. Race-free across writes because writer never touches the slot reader is currently using.
Phase 2 — Trampolines: mmap-backed reads with open+read fallback
- New
mmap_track_infosubroutine in trampoline blob - Each trampoline tries
bl mmap_track_info; if NULL falls back toopen+read - Latency drops from ~30 ms to ~5 μs per read
After Phase 2: GEA latency hits parity with Pixel-as-TG (~1-3 ms).
Phase 3 — Music app: mmap-backed writes (optional)
- TrackInfoWriter writes via MappedByteBuffer directly (no kernel write syscall on the hot path)
- Marginal perf gain on the writer side (writes are already infrequent)
- Worth doing only if Phase 1b's FileChannel writes become a bottleneck (unlikely)
Verification per phase
Phase 1a (trampoline dual-schema): existing 1104-byte file continues to work. Run TV/Sonos/Bolt/Kia captures to verify no regression. T9ps/T5tc emit rates unchanged.
Phase 1b (music app double-buffer): file size = 2213 B post-init. od -An -tx1 -N1 y1-track-info shows alternating 00 / 01 after track changes. Trampolines read correct slot content.
Phase 2 (mmap): GEA response latency measurement — EXTADP_AVRCP timestamps should drop from ~21 ms to <5 ms. Bolt's GEA-burst-of-3 should now get all 3 responses before Bolt times out.
Cost / benefit summary
| Current | Post-mmap | |
|---|---|---|
| GEA response latency | 21-30 ms | <5 ms (Pixel parity) |
| Reads per minute | 20-50 | 0 (memory loads only) |
| Disk I/O on hot path | ~150 syscalls / 5 min session | 0 |
| LOAD #1 budget impact | n/a | ~255 B (504 B free in debug, 624 B in release) |
| File size on disk | 1104 B | 2213 B (~2× — negligible) |
| Code risk | n/a | Medium — touches all 5 trampoline read paths + music app's flushLocked |
| Bisection space | n/a | 4 commits (Phase 1a, 1b, 2, optional 3) |
Decision
Worth doing — closes the last remaining significant performance delta vs Pixel. Phase 1a+1b are the prerequisite; Phase 2 is the actual win. Phase 3 is optional.
If the Bolt 0625-followup capture (post the 50 ms Handler delay fix from Trace #76) shows the metadata freeze is GONE, this plan becomes a "Pixel parity polish" effort rather than a critical fix. If the freeze persists, Phase 2's lower latency may also help — Bolt's GEA bursts arrive within ~80 ms and Y1's current ~25 ms per response means Bolt sometimes moves on before we answer.
Wait for Bolt 0625-followup capture before committing time to this.
Trace #78 (2026-05-20) — Kia 0707: rate-limit gate eats PositionTicker ticks; resetWakeRateLimit bypass
User report
"New test seems to have improved response times on the Kia but it regressed track position a bit. Track length updates but track position does not (at least after the initial tick)."
Big-picture shift on Kia
Kia 0707 vs Kia 2107 (the previous capture before P_PN0/P_PN1 + rate-limit + ProviderName + PSC pulse landed):
| metric | Kia 2107 | Kia 0707 |
|---|---|---|
| T2reg total | 0 | 143 |
| ev=05 PlaybackPosition | 0 | 47 |
| ev=09 NowPlayingContent | 0 | 45 |
| ev=01 PlaybackStatus | 0 | 34 |
| ev=02 TrackChanged | 0 | 13 |
| T5tc TrackChanged emits | 0 | 12 |
| T9ps PlaybackStatus emits | 0 | 33 |
| T9papp PApp emits | 0 | 2 |
Kia switched from polling-only to subscribe-based. Most likely cause: the ProviderName=" " addition (P_PN0/P_PN1 in f19ad7c). Kia's SDP parser was probably rejecting Y1's TG record absent 0x0102, falling back to GEA polling. With ProviderName present the record passes Kia's validation and Kia subscribes normally.
This is the biggest CT-side behavior shift we've seen from a single patch. It validates the Pixel-parity SDP analysis from Trace #74.
The regression — PositionTicker ticks getting eaten
Comparing PositionTicker firings to Kia's ev=05 RegNotif acks (Kia strict §6.7.1 = one re-register per PPC CHANGED received):
- 86
PositionTicker.runfirings (Y1Patch trace) - 47 Kia ev=05 RegNotifs (M5 cave on inbound)
- ~39 ticks lost between Java-side wake and AVCTP wire emit
Position values in y1-track-info are tracked correctly — fL.pos log markers show ms-accurate growth (e.g., 9301ms → 9301ms-anchored, 153ms → 153ms-anchored after a track edge, advancing to 10026ms ~10 s later). So the music app is computing position correctly, T9 has access to correct file content. The gap is elsewhere.
Root cause
The 800 ms rate-limit gate in wakePlayStateChanged (commit 105eef5):
if (now - mLastWakePlayStateAt < 800ms && mPlayStatus == mLastWakePlayStatus):
return // suppress broadcast
Designed to coalesce the 3-wake cascade around track edges (onPlayValue + onPrepared + onPlayerPreparedTail in <200 ms succession, same mPlayStatus). But PositionTicker's 1 Hz heartbeat with PLAYING status also matches "same status AND <800 ms" when:
- PSC pulse phase 2 fires at track-edge settle (sets
mLastWakePlayStateAt= now,mLastWakePlayStatus= PLAYING) - PositionTicker tick lands 600-900 ms later (still PLAYING)
- Gate:
now - mLastWakePlayStateAt < 800ANDPLAYING == PLAYING→ SUPPRESS - No broadcast → mtkbt's BluetoothAvrcpReceiver doesn't fire → T9 doesn't run → no PPC CHANGED on the wire
After the next status edge (PAUSE/PLAY toggle, or natural state transition) the gate clears and PositionTicker resumes — until the next pulse fires, repeating the cycle.
User's exact symptom: "Track length updates but track position does not after the initial tick." The initial tick that DOES work is the PSC pulse's phase 2 emit (which includes a POSITION CHANGED in T9's run because file[792]==1 after phase 2 write). Subsequent PositionTicker ticks get gated until the next status edge.
Fix
PositionTicker.run resets the rate-limit state before each wakePlayStateChanged:
.method public run()V
sget-object v0, TrackInfoWriter.INSTANCE
invoke-virtual {v0}, resetWakeRateLimit()V ← new
invoke-virtual {v0}, wakePlayStateChanged()V
... re-post Handler ...
resetWakeRateLimit() writes mLastWakePlayStateAt = 0. The gate's delta calculation now - 0 then evaluates to a huge value, bypassing the suppression. Status-edge cascades around track changes still get coalesced because their call sites don't reset.
PositionTicker.run remains the single source of 1 Hz heartbeat — the gate now applies only to the cascading wakes that motivated it.
Why not lower the rate-limit threshold
Considered changing 800 ms → 400 ms or 300 ms. Two reasons against:
- No principled threshold value: PositionTicker is nominally 1000 ms but observed cadence is 0.7 Hz (some ticks run late under load). A 400 ms threshold would still occasionally suppress.
- Track-edge cascades have variable spacing: the 3 wakes around a track edge land 150-300 ms apart on slow days, 50-100 ms apart under load. A 400 ms threshold catches the typical case but a 200 ms threshold would miss bursts under load. The current 800 ms generously covers all cascade timings — preserving that for cascades while letting PositionTicker bypass is the right call.
Why not differentiate by mPlayStatus
Considered: gate only when mPlayStatus is unchanged. Already done. The problem is that cascade wakes AND PositionTicker ticks both have unchanged status during steady playback. Bypass-by-caller is the clean separation.
Side note on the Kia subscription shift
This is a strong signal that the ProviderName addition was load-bearing. Hypothesis: Kia's CT-side SDP parser rejected the Y1 TG record before P_PN0/P_PN1 because attribute 0x0102 ProviderName was missing, falling back to polling-only metadata refresh. With ProviderName present, Kia accepts the TG and uses subscribe-based refresh.
If true, this same path may also affect any other CT in the matrix that's been "lagging" (Kia's lag was traced to polling cadence). Worth re-running the TV / Sonos captures after this commit to see if their behavior changed too.
Trace #79 (2026-05-20) — onTrackEdge dedup leaks markCompletion's "frozen at duration" anchor into the next track's re-prepare
User report
After commit 8e4b23e (PositionTicker rate-limit bypass), Kia's position display works correctly EXCEPT: "a quick freeze on the first track played after connecting. It was as if the track playhead was at the end of the track. Not advancing but 'complete' even though audio was still playing. It fixed itself when the track advanced."
Symptom decomposition
- "First track played after connecting" = after a BT disconnect + reconnect cycle
- "Audio was still playing" = PlayerService is actually playing the track
- "Playhead at end of track" = CT renders position ≥ duration
- "Not advancing" = each PPC CHANGED carries the same/clamped value
- "Fixed itself when the track advanced" = the second track displays correctly
Trace
Pre-condition: previous session ended with a natural track end.
- Previous session, T = T0: track plays to natural end →
markCompletion()runs:mPositionAtStateChange = mLastKnownDuration(intentional "freeze at end")mStateChangeTime = elapsedRealtime() at T0mPlayStatusSTAYS at 1 (PLAYING) — markCompletion intentionally doesn't change itmPendingNaturalEnd = true(latch)flushLocked()writes:file[780..783]=duration, file[784..787]=T0, file[792]=1- PositionTicker stops (via onCompletion path)
- BT disconnect. Music app keeps running. State persists in memory + file.
- T = T1 (some hours later): user reconnects to Kia.
- T = T1+ε: user presses PLAY on Y1's hardware button.
- PlayerService.play() →
restartPlay(false):pause()→setPlayValue(3)PAUSEDPlaybackStateBridge.onPlayValuev0=2 → markTrackChange suppression active → skip both setPlayStatus AND wake →mPlayStatusstays 1, file unchangedtoRestart()→setDataSource(samePath)→onEarlyTrackChange→onFreshTrackChange():- resets
mPositionAtStateChange=0, mLastKnownDuration=0, mStateChangeTime=T1 flushLocked()writes file with fresh valuesprepareAsync()completes →OnPreparedListener→onPrepared→onTrackEdge():- Latches
mPendingNaturalEnd→mPreviousTrackNaturalEnd = true - Clears
mPendingNaturalEnd - Compares new audio_id vs snapshot — SAME (same track)
:cond_same_tracktaken — NO position resetsetPlayValue(1)PLAYING →setPlayStatus(1):mPlayStatusis already 1 → early-return, no flush, no state update
- Result: file state from
onFreshTrackChangesurvives (pos=0, state_time=T1, ps=1). No regression here in this trace — onFreshTrackChange already reset to 0.
Wait — re-tracing, the bug doesn't fully reproduce in this path because onFreshTrackChange runs via onEarlyTrackChange before onTrackEdge. The reset DOES happen.
Re-investigation — the actual bug path
The bug requires a path where onEarlyTrackChange doesn't fire. That happens when PlayerService.play() does NOT call restartPlay(false) — e.g., when MediaPlayer is at EOS state and start() is called directly without re-preparing. Y1's IjkMediaPlayer's behavior in this case is engine-dependent; some implementations replay from start, others throw, others go through prepare again.
In the path WITHOUT onEarlyTrackChange:
5'. User presses PLAY. 6'. PlayerService.play() — internal logic might call MediaPlayer.start() directly on the EOS'd player. No setDataSource. No onEarlyTrackChange. 7'. MediaPlayer might either: - (a) refuse → user has to press again, restartPlay kicks in - (b) silently transition back to PLAYING from EOS → onPrepared does NOT fire (no fresh prepare) - (c) auto-reprepare → onPrepared fires → onTrackEdge → dedup → no reset 8'. In paths (b) and (c), mPositionAtStateChange stays at duration (from markCompletion). mStateChangeTime stays at T0. file stays at (duration, T0, 1). 9'. PositionTicker resumes (via onPlayValue(1) cascade). Ticks fire. T9 reads file: - saved_pos = duration - state_change_time = T0 - live_pos = duration + (T_now - T0) — past end 10'. Kia clamps to duration display → "at end, frozen". 11'. When user manually skips to next track or natural advance fires, onEarlyTrackChange runs (because toRestart() is now invoked with a new path), resetting everything. Display fixes.
Fix
Path (c) — onPrepared fires but onTrackEdge's dedup skips reset — is the cleanest to handle. We have an existing signal: mPreviousTrackNaturalEnd is latched from mPendingNaturalEnd at onTrackEdge entry. Use it as a second reset trigger:
:try_start_0
... existing latch transfer ...
iget-wide v0, mCachedAudioId
invoke-direct flushLocked
# Two reset triggers (changed):
iget-boolean v4, mPreviousTrackNaturalEnd
if-nez v4, :cond_force_reset ; EOS replay of same track
iget-wide v2, mCachedAudioId
cmp-long v4, v0, v2
if-eqz v4, :cond_same_track ; audio_id unchanged AND not EOS-replay → no reset
:cond_force_reset
... reset position to 0, flushLocked ...
:cond_same_track
After fix:
- Same-track re-prepare with no natural-end (pause→resume cycles):
mPreviousTrackNaturalEnd=false, audio_id unchanged →:cond_same_track→ no reset (existing semantic preserved) - Same-track re-prepare AFTER natural end:
mPreviousTrackNaturalEnd=true→:cond_force_reset→ reset - Audio_id changed: →
:cond_force_reset→ reset (existing behavior)
For paths (a) and (b), the fix doesn't help directly because onTrackEdge isn't called. But path (a) eventually goes through restartPlay (which fires onEarlyTrackChange → onFreshTrackChange — unconditional reset). Path (b) is genuine MediaPlayer-internal replay; if no onPrepared fires, T9 keeps emitting inflated live_pos until a real track edge. We accept that as a residual edge case.
Implementation
Single edit in TrackInfoWriter.onTrackEdge() adding the mPreviousTrackNaturalEnd check ahead of the audio_id comparison. The natural-end latch was already being read and propagated — we just use it for one more decision.
Zero new fields. Zero changes to other methods. The reset block code path is identical (now reached by an additional condition).
Verification post-fix
Next capture should show:
- After a natural track end (last track of session) + BT reconnect + press play, the new T5tc/T9ps/PSC pulse sequence carries
pos=0notpos≥duration - Kia display advances from 0:00 for the first track of the session
- No regression on Bolt's existing track-switch behavior (audio_id changes → reset path; same-track re-prepare with active playback → no natural-end latch → no spurious reset)
Trace #80 — 2026-05-20 Pixel-as-TG audit, Y1 deviation map
Trigger
Bolt 1242 capture (/work/logs/dual-bolt-20260520-1242/) showed Y1 emitting healthy GetEA RSP + PASSTHROUGH ACKs but zero msg=544, zero T2reg, zero size:13 RegNotif inbound — Bolt connected with cached bond, sent PLAY + ONE GetEA query at 12:42:42, no subscriptions all session, no display refresh after the FORWARD-triggered + natural auto-advance track changes that followed. User flagged "did not display any metadata this time" and established a durable rule: for every AVRCP TG design question, first ask "what does the Pixel 4 in AVRCP 1.3 mode do?" Pixel↔Bolt btsnoop is at /work/logs/pixel4-bugreport-20260518-1959/FS/data/misc/bluetooth/logs/btsnoop_hci.log.
Original hypothesis (wrong)
Initial framing: Y1's session-scope subscription gate (g_avrcp_req_event_database in libextavrcp_jni.so .bss, wiped on every JNI lib load) is too strict — when Y1 reboots while the CT's AVRCP L2CAP channel persists, our DB drops to zero while the CT still considers itself subscribed; subsequent state-edge wakeTrackChanged / wakePlayStateChanged calls are gated to NOPs even though the CT is waiting for CHANGEDs. Proposed "emit preemptive CHANGED on state edge regardless of DB state, matching Pixel" — citing INVESTIGATION.md lines 2884/2903 as evidence Pixel emits unsolicited CHANGED outside §6.7.1.
Audit, byte-by-byte against the Pixel↔Bolt btsnoop
Parsed every Pixel↔Bolt AVRCP frame with tshark -V. Key findings:
TID handling: traced TIDs for PSC across frames 632 → 1359:
| Frame | Dir | Kind | TID | Notes |
|---|---|---|---|---|
| 632 | CMD | Notify | 0x2 | initial subscribe |
| 633 | RSP | Interim | 0x2 | Pixel echoes |
| 730 | RSP | Changed (unsolicited) | 0x2 | uses stored TID=0x2 from frame 632 |
| 741 | CMD | Notify | 0x2 | Bolt re-registers same TID |
| 921 | RSP | Changed (unsolicited) | 0x2 | still uses 0x2 |
| 926 | CMD | Notify | 0xe | Bolt switches TID |
| 937 | RSP | Changed (unsolicited) | 0xe | Pixel updates stored TID |
| 1080 | RSP | Changed (unsolicited) | 0x6 | (Bolt switched again at frame 945) |
| 1350 | RSP | Changed (unsolicited) | 0xe | (Bolt switched back at 1091) |
Pixel's "unsolicited CHANGED" is gated on having a stored per-event TID from a prior RegisterNotification CMD on the current L2CAP channel. The stored TID updates every time the CT issues a fresh Notify. When Pixel has no stored TID for an event (= CT never subscribed), Pixel does not emit CHANGED for that event.
This is byte-for-byte what Y1's g_avrcp_req_event_database + _emit_check_event_subscribed + _emit_restore_conn_tid_from_db already implement. The original "preemptive CHANGED" fix premise was wrong — Pixel does NOT emit when its database equivalent is empty. The earlier INVESTIGATION.md observation at line 2884 ("emits CHANGED unsolicited on every value change, doesn't wait for re-registration") is correct only when the DB is populated; once the DB has a stored TID, Pixel doesn't wait for re-register before emitting. The pre-DB-populate case is identical between Pixel and Y1.
Full Pixel ↔ Y1 deviation table
| Behavior | Pixel | Y1 Current | Match? |
|---|---|---|---|
| GetCapabilities Events count | 8 | 8 | ✓ |
| GetCapabilities Events list | 01,02,05,08,09,0a,0b,0c | same (T1_ADVERTISED_EVENTS) | ✓ |
| GetCapabilities Companies | Bluetooth SIG only (00:19:58) | stock libextavrcp default | ✓ (single SIG companyID) |
| GetEA: missing-attr handling | omit from response | emit valueLen=0 (E1 patch) | NO |
| GetEA: charset | UTF-8 (106) | UTF-8 | ✓ |
| PASSTHROUGH RSP ctype | Accepted (0x09) | stock libextavrcp (Accepted) | ✓ |
| InformDisplayableCharacterSet (PDU 0x17) RSP | Rejected (Invalid Command) | NOT_IMPLEMENTED via UNKNOW_INDICATION | ✓ (both reject) |
| RegNotif first RSP ctype | Interim (0x0F) | Interim | ✓ |
| RegNotif follow-up RSP ctype | Changed (0x0D) | Changed | ✓ |
| Per-event TID echo on CHANGED | stored TID from RegNotif CMD | g_avrcp_req_event_database | ✓ |
| Preemptive CHANGED on state edges | yes, uses stored TID | yes, gated on DB[event_id]≠0 | ✓ |
| Emit CHANGED when DB empty | NO | NO | ✓ |
| Clear subscription state on GetCap | NO (cleared on L2CAP disconnect) | YES (T1_extended.clear_event_database) | NO (deliberate Y1 workaround for the .bss-persists-across-CT-churn model) |
| PSC INTERIM initial PlayStatus | current state | current state | ✓ |
| TC INTERIM Identifier | 0x00...00 (selected) | 0x00...00 | ✓ |
| TC CHANGED Identifier | per-track counter | per-track counter | ✓ |
| PPC CHANGED cadence | ~1 Hz | 1 Hz (PositionTicker) | ✓ |
| Track-edge emit set | PPC=0, TC, NPCC | T5 emits same triple | ✓ |
| SetPlayerApplicationSettingValue (PDU 0x14) | Rejected (Invalid Param) | Accepted (T_papp implements Repeat+Shuffle) | NO (deliberate Y1 feature — Pixel doesn't support PApp) |
| BluetoothProfileDescList AVRCP version | 0x0103 | 0x0103 (V1 patch) | ✓ |
| SupportedFeatures bits | 0x0001 | 0x0001 (V8 patch) | ✓ |
| AVCTP TID per-event tracking | yes | yes (since d4efb6c) | ✓ |
Net conclusions
Y1 is Pixel-equivalent on every load-bearing wire behavior. The only non-deliberate deviation is E1: libextavrcp.so emits zero-length attribute entries for unsupported AttributeIDs in GetEA responses; Pixel omits them entirely. The §5.3.4 strict reading (E1's original justification) is ambiguous on whether unsupported attrs must emit valueLen=0 or may be omitted; Pixel takes the omit interpretation.
Bolt 1242's "no metadata displayed" symptom is not addressable from Y1 side. Bolt did not send RegisterNotification in this session; without subscriptions, Pixel-equivalent behavior is silence (no msg=544 outbound), and Bolt's lack of re-query after the FORWARD+auto-advance keeps its display stale. We do not have evidence of any wire byte we could change that would induce Bolt to subscribe — the prior Bolt 0625 session, which did subscribe, used byte-identical Y1 SDP + GetCapabilities responses.
This shape also matches Kia's ~11% per-session polling-mode rate (see Trace #79's table) — CT-side subscription decisions vary across sessions for reasons not visible to the TG.
Open decision: revert E1?
E1 is the one non-deliberate Pixel deviation. Reverting:
- Removes
[E1] GetElementAttributes empty-attr drop -> NOP (§5.3.4 zero-length emit)frompatch_libextavrcp.pyPATCHES list - Net wire delta: GetEA RSP no longer carries
attr_id=0x08(BIP cover handle, always empty on Y1) and any other attrs T4 emits withvalueLen=0 - Risk: if any untested CT relied on the zero-length entries, that CT loses an entry it expected. No evidence such a CT exists in the test matrix.
- Net: byte-for-byte Pixel parity on GetEA shape.
Reverting does not fix Bolt 1242 (E1's behavior was never triggered there — all 7 attrs Bolt requested had data). It is a clean Pixel-parity improvement with no clear downside, but should land with explicit user sign-off given E1's deliberate prior addition.
Open decision: capture Bolt with pre-pair-initiated dual-capture.sh
To narrow whether Bolt 1242's no-subscribe behavior is repeatable or session-state-dependent, capture the next Bolt session with dual-capture.sh started BEFORE pairing (or at least before AVRCP channel open). The pre-pair window will contain connect_ind/CONNECT_CNF and any GetCapabilities + RegNotifs in the clear, letting us see whether Bolt is doing the handshake at all.
Trace #81 — 2026-05-20 mmap-backed y1-track-info shipped (Trace #77 implementation)
User direction (post-Trace #80 audit): "Can we implement [mmap] anyway, and then revisit this problem? The on-disk approach always seemed a bit janky to me."
Shipped
Music app side (TrackInfoWriter.smali):
- Schema bumped 1104 B → 2213 B:
file[0]=active_slot, file[1..3]=RFA, file[4..1107]=slot[0], file[1108..2211]=slot[1], file[2212]=RFA. prepareFilesLocked()callsensureFile("y1-track-info", 2213)so the file exists at full size before any trampoline tries to mmap it.flushLocked()rewritten: open asRandomAccessFile("rw"),setLength(2213)defensively, read active_slot byte, computeinactive = 1 - (active & 1), seek to4 + inactive*1104, write the 1104-byte image, atomic single-bytewrite(int)at file[0] to flip the active flag. Reader (mtkbt-side mmap) sees a consistent slot at all times because the slot the reader'sactive_slotbyte points at is never the one in flight.- The tmp + rename atomic-write path is gone. The race window Trace #77 motivation flagged (tmp + rename creates a fresh inode, orphaning a mapped reader's page) is structurally eliminated.
Trampoline side (_trampolines.py):
- New
.bsscache slotg_y1_track_info_mmap_baseat vaddr0xd2cc(4 bytes, betweeng_y1_avrcp_track_identifierand stockg_avrcp_auto_browse_connect). - New
get_or_init_mmapsubroutine: lazy-init on first call, opensy1-track-info,mmap2(NULL, 4096, PROT_READ, MAP_SHARED, fd, 0), closes fd, caches ptr. No sticky failure flag — every miss retries (handles the case where the music app hasn't created the file yet at first trampoline call). - New
read_track_info(r0=dst, r1=nbytes)subroutine: bl get_or_init_mmap → if non-NULL, dispatch active_slot and byte-copynbytesfrommmap_base + 4 + active*1104into dst. On NULL, returns 0 and leaves dst untouched (caller's precedingmemsetalready zeroed it). - T4 / T5 / T6 / T8 / T9 / extended_T2's 8-byte track_id read all converted from inline
open + read + closeto a singlebl read_track_info. The old per-trampolinet*_skip_track_readlabels remain (no-op fall-through) for downstream compatibility.
Patcher MD5s (patch_libextavrcp_jni.py):
OUTPUT_MD5andOUTPUT_DEBUG_MD5set toNonefor this commit. Patcher prints the computed MD5 on first flash without erroring. Update them once a clean flash + capture cycle confirms the new bytes are correct.
Trampoline blob size: 3540 B with new subroutines, before per-site conversion. After the initial 6 conversions: 3388 B. After folding T_papp's two gc paths into read_track_info (with new slot_offset r2 parameter): 3340 B (-56 B vs the pre-mmap baseline). Budget 4020 B, 680 B free.
read_track_info ABI
Pre: r0 = dst buffer, r1 = nbytes (1..1104), r2 = slot_offset (0..1103).
Post: r0 = nbytes copied (success) or 0 (mmap unavailable).
r4..r11 preserved.
src = mmap_base + 4 + (active_slot * 1104) + slot_offset
All current readers pass r2 = 0 except T_papp's PDU 0x13 GetCurrent paths, which pass r2 = 795 to read the 2-byte repeat+shuffle block within the active slot.
Known limitations (not blockers; tracked for follow-up)
- Upgrade from an older firmware that wrote a 1104-byte file:
setLength(2213)extends the file on the first new-schema flush, butfile[0]momentarily holds whatever the OLD schema's audio_id LSB was — could be any byte. Trampolines reading during that one-flush window dispatch to whichever slot the byte's low bit points at, then read mostly-zero (new tail) or partial-old data. Stabilises after the first flush. Single-flush transient; acceptable.
Open question (preserved from Trace #80)
The Bolt 1336 "metadata froze after 2-3 s of playback" symptom remains unexplained. Wire data verified Pixel-equivalent; mmap doesn't change a byte going out on the wire, only the latency profile. Hypothesis pending wire-byte capture: Y1's 644-byte GetEA RSP frame structure may differ subtly from Pixel's 70-byte response in a way Bolt's parser dislikes. Next investigation step: add a trampoline-side _emit_native_log_u32 byte-dump of the outbound GetEA RSP, compare against Pixel's btsnoop frame-for-frame.
Trace #82 — 2026-05-20 T5 emit order: NPCC-first → Pixel-parity PPC-first
Trigger
User recalled an earlier observation: Pixel pre-sets track position to 0:00 before starting the next song, so the playhead is already at 0:00 when audio begins. Y1's behavior appeared opposite — position lingered briefly before snapping to 0:00 / 0:01. Asked whether the Trace #80 audit had missed this. It had.
What Pixel does (re-parsed from /work/logs/pixel4-bugreport-20260518-1959/.../btsnoop_hci.log, frames 961–973 around a FORWARD press)
100.517 s Rcvd PASS-THROUGH FORWARD (Pushed)
100.518 s Sent PASS-THROUGH Accepted
100.559 s Rcvd PASS-THROUGH FORWARD (Released)
100.559 s Sent PASS-THROUGH Accepted
100.599 s Sent Changed - PlaybackPositionChanged - SongPosition: 0ms
100.604 s Sent Changed - TrackChanged - 0x0000000000000002
100.633 s Sent Changed - NowPlayingContentChanged
100.657 s Rcvd Status - GetElementAttributes
100.659 s Sent Stable - GetElementAttributes - Title: "DANCE WITH ME"
100.663 s Sent Changed - PlaybackPositionChanged - SongPosition: 6ms
Order: PPC=0 → TC → NPCC, within 34 ms. Pixel emits PPC=0 first so the CT zeroes the playhead before processing TC. NPCC fires last as content notification.
What Y1 was doing
T5's track-edge burst emit order in _emit_t5 was:
NPCC (0x09) → PPC (0x05) → TR_END (0x03, cond) → TC (0x02) → TR_START (0x04, cond)
NPCC first — the now-playing refresh hits the CT while the CT still considers the OLD track to be the "selected" one. The CT's now-playing list re-query lands against the OLD track context. PPC=0 follows but applies to a track ID the CT doesn't know changed yet. TC arrives last, only then is the new track ID registered. Net visible-on-screen effect: brief moment of old-track-at-position-0 (or stale position lingering) before TC forces metadata re-query.
Fix
Reordered to match Pixel's wire sequence: PPC → TC → NPCC. TR_END / TR_START remain in T5, emitted AFTER the Pixel-parity triple — Pixel doesn't advertise events 0x03 / 0x04 (GetCap Events list = 01 02 05 08 09 0a 0b 0c), so their order is undefined relative to Pixel's behavior; keeping them post-TC preserves backward compat for any subscription-class CT that subscribes to them via T8 INTERIM.
Conservative scope:
- Reorder only — no events dropped, no payloads changed
- T8 still advertises 0x03 / 0x04 INTERIM responses (subscription path)
- TR_END gate still requires
file[793] = 1(natural-end flag) ANDdatabase[3] != 0 - Each emit's existing TID-restore + payload-build logic unchanged
Regression-risk surface
- TV / Sonos / Kia: current captures all work with the NPCC-first order. Reordering changes wire behavior for every CT, not just Bolt. Mitigation: smoke-test all four CTs before claiming success.
- Bolt freeze: untested whether the reorder addresses the Bolt 1336 "metadata frozen after 2-3 s" symptom. Other Trace #80/#81 hypotheses (wire-byte structural mismatch) are still candidates.
- Spec position: AVRCP 1.3 doesn't mandate order for unsolicited CHANGED bursts. Reorder is spec-permissible.
Blob impact
Pure code reorder, same byte count (3344 B post-reorder = 3344 B pre-reorder). 676 B free of 4020. No budget risk.
Trace #83 — 2026-05-20 y1-trampoline-state → .bss (eliminate per-fire syscalls)
Motivation (from prior session's mmap-cleanup review)
Post-mmap-rework, y1-track-info is served from page-cache RAM via the shared inode trick. The remaining on-disk surface inside the AVRCP hot path was y1-trampoline-state — a 24-byte file read + written by T4 / T5 / T9 on EVERY fire (PositionTicker 1 Hz → 1 T9 fire/sec, plus track edges → T5 + T9). Three syscalls per read (open + read + close), three per write. Same-process only (mtkbt's libextavrcp_jni.so is the only reader and writer), so no cross-process plumbing required.
Design
13-byte .bss block at G_Y1_TRAMPOLINE_STATE_VADDR = 0xd2a4 — the unallocated padding at the very start of .bss (between __bss_start / _edata and the first real stock symbol g_avrcp_req_event_database at 0xd2b5). 17 B available; 13 B used. Same per-slot layout as the on-disk schema:
state[0..7] last_seen track_id (T4 / T5 edge detection)
state[8] (reserved — was last RegNotif transId; dropped, per-event TIDs
live in g_avrcp_req_event_database)
state[9] last_play_status (T9 edge detection)
state[10] last_battery_status (T9)
state[11] last_repeat_avrcp (T9 papp edge)
state[12] last_shuffle_avrcp (T9 papp edge)
Two new shared subroutines mirror the read_track_info pattern:
read_state_block(r0=dst, r1=nbytes, r2=state_offset)— PC-rel literal → absolute vaddr → byte-copy nbytes fromstate[state_offset]to dst stack buf.write_state_block(r0=src, r1=nbytes, r2=state_offset)— mirror, src/dst reversed.
Per-site conversion: T4 / T5 / T8 / T9 / extended_T2's six open + read + close and four open + write + close chains all collapse to bl read_state_block / bl write_state_block.
Per-fire syscall savings
| Trampoline | Old syscalls | New syscalls | Δ |
|---|---|---|---|
| T4 (read+write) | 6 | 0 | -6 |
| T5 (read+write) | 6 | 0 | -6 |
| T8 (read only) | 3 | 0 | -3 |
| T9 (read+write) | 6 | 0 | -6 |
| extended_T2 (write only) | 3 | 0 | -3 |
PositionTicker drives T9 at 1 Hz → 6 syscalls/sec saved during active playback, plus 6 per T5 track edge and 3 per extended_T2 RegNotif arrival.
Blob impact
| Stage | Blob size | Free (of 4020) |
|---|---|---|
| Pre-Tier-1 (3344 B post-mmap-rework + reorder) | 3344 | 676 |
| Two new subroutines, no callsites converted | 3424 | 596 |
| All 10 callsites converted | 3228 | 792 |
Dropped path_state data string | 3172 | 848 |
Net: -172 B trampoline blob shrink despite adding two new subroutines. The per-site savings (replacing ~30 B inline open + read + close with ~10 B bl read_state_block) exceed the subroutine cost.
State persistence semantics change
The on-disk file persisted across mtkbt restarts. The .bss version resets to zero on every libextavrcp_jni.so load (process scope). After mtkbt restart, the next T5/T9 fire sees state[N] = 0 vs current file value → edge detected → one CHANGED per active subscription emitted. Subscription gates filter out unsubscribed events. Net effect: harmless extra CHANGED-per-event-per-restart, consistent with g_avrcp_req_event_database's session-scope model.
The on-disk y1-trampoline-state file is no longer read or written by any trampoline. TrackInfoWriter.prepareFilesLocked still ensure-creates it (backwards-compat across staged flashes); the file bytes are now ignored. Cleanup of the ensureFile call deferred — 20 B of disk waste, not load-bearing.
Remaining on-disk hot paths
y1-papp-set— cross-process (mtkbt writes via T_papp 0x14, music app reads via FileObserver). Low frequency (only on CT-initiated Repeat / Shuffle Set). Not optimized.MediaBridgeService.readTrackInfo(Y1Bridge) — readsy1-track-infovia FileInputStream for IBTAvrcpMusic Binder queries. Could mmap on the same inode; deferred to Tier 2.
path_state literal removed
The "/data/data/com.innioasis.y1/files/y1-trampoline-state" string in the trampoline data section is no longer referenced. Removed. -56 B from the blob.
Trace #84 — 2026-05-20 Y1Bridge readTrackInfo → mmap (Tier 2)
Motivation
Last remaining hot on-disk read in the AVRCP-metadata pipeline: MediaBridgeService.readTrackInfo() did a full FileInputStream open + read + close on every IBTAvrcpMusic Binder query (getPlayStatus / position / duration / getAudioId / getTrackName / getAlbumName / getArtistName / getRepeatMode / getShuffleMode). MtkBt's BTAvrcpMusicAdapter issues these whenever its Java mirror needs refresh — under heavy CT polling that can be tens of queries/sec.
Change
MediaBridgeService now lazy-inits a MappedByteBuffer over /data/data/com.innioasis.y1/files/y1-track-info via FileChannel.map(READ_ONLY, 0, 2213) at first read. The buffer is held in a static volatile MappedByteBuffer sTrackInfoMap (process-global; AvrcpBinder is a private static final class so the read path must be static-reachable). Subsequent calls hit the cached buffer; readTrackInfo() dispatches file[0] to the active 1104-byte slot and bulk-copies into a return buffer via MappedByteBuffer.duplicate().position(srcOff).get(byte[]).
Cross-process correctness: the music app's TrackInfoWriter.flushLocked does in-place RandomAccessFile.seek+write against the same inode. Kernel page cache propagates writes through the shared mapping pages; Y1Bridge's reader sees current state without re-opening the file. Same shared-inode pattern used by the trampoline chain in libextavrcp_jni.so on the BT-process side.
Lazy-init failure semantics match the trampoline side's get_or_init_mmap: no sticky failure flag, every cache-miss call retries the open + map. Handles the cold-boot case where Y1Bridge starts before the music app's prepareFilesLocked has created the file.
Per-query savings
Old:
- 1 ×
open(2)syscall - ≥1 ×
read(2)syscall (loop until full 2213 B copied) - 1 ×
close(2)syscall - Per-call
byte[] raw = new byte[2213]heap allocation +System.arraycopyslot extraction
New:
- 1 ×
MappedByteBuffer.get(0)(single-byte memory load — active_slot) - 1 ×
duplicate()(cheap; just a buffer-state copy, no data copy) - 1 × bulk
get(byte[], 0, 1104)(JNI memcpy from shared page-cache pages) - Still allocates the return
byte[1104](could be cached too, but minor)
Net: 3 syscalls eliminated per Binder query. Under heavy CT polling (~20-30 queries/sec observed in TV captures), that's ~60-90 syscalls/sec saved on the Y1Bridge side.
Verification path
Requires cd src/Y1Bridge && ./gradlew --stop && ./gradlew assembleDebug before apply.bash --bluetooth since Java source changed. Y1Bridge.apk MD5 will shift; not pinned by patcher.
End-state on-disk inventory (AVRCP metadata path)
After Tier 2 ships, the AVRCP-metadata pipeline has zero on-disk reads in the hot path. Remaining file I/O surface:
| Path | Direction | Frequency | Treatment |
|---|---|---|---|
| y1-track-info | music app writes (RandomAccessFile.seek+write) | per state edge | kernel page cache propagates to mmap readers (BT process + Y1Bridge); reader-side has zero syscalls |
| y1-track-info | trampolines read (mmap2) | per AVRCP frame | served from page cache (RAM) |
| y1-track-info | Y1Bridge reads (MappedByteBuffer.get) | per Binder query | served from same page cache (RAM) |
| y1-papp-set | mtkbt writes via T_papp 0x14 | per CT-initiated Repeat/Shuffle Set | still file-based; FileObserver CLOSE_WRITE wakes music app. Rare; not worth converting. |
| y1-trampoline-state | (dead file, still ensure-created) | never read or written by trampolines | cosmetic cleanup deferred |
The writer-side RandomAccessFile.seek+write is the architectural floor — you can't have cross-process page-cache propagation without the kernel write syscall path. That's the irreducible minimum.
Trace #85 — 2026-05-20 Bolt churn root cause: mtkbt tears down AVCTP on AVDTP CLOSE
Symptom
Bolt EV CT shows "metadata frozen after the first track" across multiple sessions (dual-bolt-20260519-1647, 20260520-0625, 20260520-1543). Pre-Trace #85 analysis (Traces #74, #75, #76, #77) attributed this to either AVRCP-side state issues, slow CT polling, or wire-frame fragmentation. None of those explained why the session-wide g_avrcp_req_event_database looked unsubscribed after the first track — the database is wiped by clear_event_database in T1_extended on every GetCapabilities, but in normal operation that fires once per CT session. Bolt was running it 3-5 times per session.
What the btlog actually shows
Per-session count of AVRCP_HandleA2DPInfo info:1 events in dual-bolt-20260520-1543/btlog.bin: 3. Each is preceded by:
[BT] , 2, 33, 0, 7, 0, 3, 0, 42, 0, 40, 8, 4
Decode: HCI ACL handle 0x033 len 7, L2CAP len 3 cid 0x42 (Bolt's AVDTP signaling channel), AVDTP payload 40 08 04 = TxLabel=4 / PT=Single / MsgType=Command / SignalID=0x08 CLOSE / ACP_SEID=1. Bolt is closing the stream endpoint cleanly on every track skip — normal AVDTP behaviour under AVDTP V13 §8.13 (STREAMING → OPEN → IDLE).
Y1's mtkbt then cascades:
- Tears down PSM 0x19 L2CAP channels (AVDTP signaling + stream)
[AvdtpSigMgrConnCallback]AVDTP_CONN_EVENT_DISCONNECT strm conn stat:5fires- Y1 sends DisconnectReq for PSM 0x17 channels (AVCTP signaling) — visible at
0xfa38 bl 0x1117c AVCTP_EVENT:3(AVCTP disconnect)AVRCP_HandleA2DPInfo info:1 data:0x0log- AVRCP per-handle cleanup at
fcn.0x1117cemits more L2CAP DisconnectReqs
Step 6 is the actively-harmful one: it tears down the AVCTP control channel that Bolt is still using for AVRCP commands.
RE walkthrough
AVRCP_HandleA2DPInfo is in bin/mtkbt, not libextavrcp.so. Found by grep "HandleA2DPInfo" across all .so and binaries. Format string at file 0xc8b4f; function entry at fcn.0xf8e0.
Function signature (inferred from r0/r1 usage + log format):
void AVRCP_HandleA2DPInfo(int info_id, void* data_ptr);
r0=info_id dispatch in fcn.0xf8e0:
info_id == 0: "A2DP connected" event with new device address; compares against current AVRCP peer addr; if different → log "AVRCP: disconnect because a2dp is connected with other device" → callfcn.0x1117cat0xf9b8info_id == 1: "A2DP lost" event; log "AVRCP: disconnect because a2dp is lost" → callfcn.0x1117cat0xfa38info_id == 2 or 3: fall through to exit / different cleanup
fcn.0x1117c is the AVRCP per-handle cleanup routine. Iterates a 0x1420-byte per-channel state table at offset r0 * 0x1420 (zero for info=1 since the caller always passes r0=0), emits L2CAP DisconnectReq on the channels.
Caller chain to info_id == 1:
fcn.0xe79calways setsr0=1, r1=0beforebl 0xf8e0. Two call sites:0xe18cin some dispatcher; reached when state byte[r5+0xb] != 5&&[r5+12] != 70xe204after setting[r5+3] = 5; reached when[r5+3] == 0&&[r5+4] != 0,7
fcn.0xe748setsr0=1, r1=0conditionally on its first arg being0x100- reached from
fcn.0xe178when[r5+12] == 7
- reached from
These dispatchers live in mtkbt's AVDTP-event handler. The state bytes look like AVDTP Stream Endpoint state (per ETSI ES 200 936): state 7 = ABORTING, state 5 = STREAMING. Y1 fires the info=1 disconnect on the AVDTP STREAMING → IDLE transition that AVDTP CLOSE triggers — which is wrong because AVCTP signaling is independent of AVDTP audio per AVRCP V13 §4. The two protocols are layered on L2CAP independently; a CT is free to cycle the audio stream without disturbing the AVRCP session.
The fix (M8)
Replace bl 0x1117c at file 0xfa38 with two 16-bit NOPs:
before: 01 f0 a0 fb bl 0x1117c
after: 00 bf 00 bf nop ; nop
After M8, the info=1 path still runs (the upper-layer cascade still happens through L2CAP), but mtkbt stops sending the additional DisconnectReq for the AVCTP control channel. The control channel stays up across the audio stream cycle. Bolt's AVRCP commands continue working without a re-handshake, so clear_event_database doesn't fire mid-session and the per-event TID table persists.
Why this is the right narrowing
fcn.0x1117c has 2 call sites; only the info=1 one is being NOPed. The info=0 site (multi-device A2DP collision) still tears AVRCP down — appropriate for that case. True ACL link loss (peer powered off / out of range) is caught by the baseband link-supervision-timeout independently of this software path.
Falsifiable
If Bolt still re-issues GetCapabilities after a track skip post-M8, the bug isn't in this path — likely in AvdtpSigMgrConnCallback's own AVCTP teardown logic at step 3 of the cascade. Capture a fresh Bolt session after the patch; count g_avrcp_req_event_database reset cycles via the existing T1tab debug tag and the M5dbg pd=%02x cave.
Pixel reference status
The newer Pixel↔Bolt btsnoop at /work/logs/pixel4-bugreport-20260518-1959/FS/data/misc/bluetooth/logs/btsnoop_hci.log.last contains zero AVDTP / AVCTP / AVRCP frames — only two SDP queries. The older capture at /work/logs/pixel4-bugreport/FS/data/misc/bluetooth/logs/btsnoop_hci.log is more useful: 292 AVDTP/AVCTP/AVRCP frames including one full A2DP+AVRCP session ending at relative time t=145.78 s.
Pixel's session AVDTP timeline:
- t=96.59 setup: DISCOVER → GET_ALL_CAPABILITIES → SET_CONFIGURATION → DELAYREPORT → OPEN (5 signals)
- t=103.73, t=104.67, t=104.84: START/SUSPEND cycles (track changes / pause/resume mid-session)
- t=144.79 SUSPEND, t=145.78 CLOSE — only one CLOSE in the entire 49 s of active playback, at the session end
Bolt with Pixel uses SUSPEND for inter-track transitions; CLOSE only fires once when playback fully ends.
Bolt with Y1 uses CLOSE on every inter-track transition (3 CLOSEs in the 1543 session, matching 3 metadata-frozen reconnect cycles). The escalation from SUSPEND→CLOSE is Y1-specific. Y1's btlog under-sampling hides any SUSPEND attempts Bolt might try first, but the pattern of CLOSE-per-track shows Bolt has given up on the SUSPEND/RESUME path for this peer.
Deeper finding: Bolt doesn't re-RegisterNotification on reconnect
Y1 logcat across the 1543 session (T2reg debug tag emits once per inbound RegisterNotification):
15:43:59.962 T2reg ev=01
15:44:00.024 T2reg ev=01 ← Bolt's initial session
15:44:00.042 T2reg ev=02
15:44:00.073 T2reg ev=08
15:44:00.094 T2reg ev=09
15:44:00.145 T2reg ev=0b
15:44:00.147 T2reg ev=0c
(no more T2reg events for the remaining 90 s of the session)
Bolt registered notifications once at session start. Across the 4 subsequent reconnect cycles (15:44:34, 15:45:38, 15:46:56), Bolt's CT-side state retains the prior registrations and skips re-subscription on the AVCTP wire. But Y1's T1_extended calls clear_event_database on every inbound GetCapabilities — which Bolt issues on each reconnect — wiping the per-event TID table. Post-reset, g_avrcp_req_event_database[ev] is 0 for every event Bolt thinks it's still subscribed to, so event_subscribed returns false and T5/T9 silently drop their CHANGED emits.
This means M8 is necessary but not sufficient. Even with AVCTP preserved (M8) so Bolt's CT layer doesn't have to renegotiate AVRCP transport, the per-event database is still session-scoped via clear_event_database. If M8 prevents Bolt from CT-side teardown of AVCTP, Bolt may also skip the entire reconnect (no fresh GetCapabilities, no database clear) — that's the M8 win condition. If Bolt still issues a fresh GetCapabilities on its next AV/C command, the database clear still fires and M8 alone won't restore metadata.
Possible follow-up directions if M8 alone insufficient
- Remove
clear_event_databasefromT1_extended. Lets the per-event TID table persist across CT sessions within a single mtkbt lifetime. Risk: stale TIDs in cross-session emits if CT genuinely re-registers — current behavior is "silent drop", new behavior would be "emit with wrong TID, CT discards" — same net UI impact, but may affect well-behaved CTs differently. - Move
clear_event_databaseto an L2CAP/AVCTP teardown hook instead of GetCapabilities. Cleaner spec semantics (subscription state is bound to AVCTP session, not GetCap PDU). Requires finding the AVCTP-side disconnect handler in libextavrcp_jni.so. - Figure out why Bolt escalates SUSPEND→CLOSE on Y1. Possible causes: malformed AVDTP responses, unfavorable DelayReport values, codec-config differences, AVDTP version negotiation quirks. Requires a fresh Pixel+Bolt capture with explicit track-skip events to compare against Y1.
(1) is cheap to try empirically. (2) is the principled fix. (3) is the upstream investigation.
Trace #86 — 2026-05-20 Trampoline state .bss collision at 0xd2ac; relocated to 0xd2d6
Symptom
After commit e2719c7 (trampoline state → .bss at vaddr 0xd2a4), the BT process (iatek.bluetooth / pid running libextavrcp_jni.so) enters a crash loop on every device. dmesg shows sig 11 to [NNN:BTAvrcpMusicAda] repeating every ~1 s; debuggerd kills all child threads each cycle; BTAvrcpMusicAdapter construct log line repeats in logcat at the restart cadence. CTs see "old metadata, controls don't work" because the AVRCP TG service never reaches a stable state.
Captured on all three CTs in the matrix (Bolt 1859: 75 restarts, TV 1902: 24 restarts, Sonos 1936: 26 restarts), all preceding sessions (1543 / 1534 / 1336 / 1242 / 1150 etc.) had 0 restarts.
Bisection
User-driven, on the flash box:
| Commit | Date (UTC) | Result | Notes |
|---|---|---|---|
1c233cc | 18:36 | clean (Sonos 1927, 0 restarts) | Lower bound established |
925a3b6 | 19:17 | clean (Sonos 1947, 0 restarts) | T5 emit reorder; immediate parent of e2719c7 |
e2719c7 | 20:25 | broken (Sonos 1936, 26 restarts) | Trampoline state → .bss |
Bisection isolated the regression to exactly e2719c7.
Root cause
e2719c7 chose G_Y1_TRAMPOLINE_STATE_VADDR = 0xd2a4 based on the inspection that the symbol table shows nothing between __bss_start / _edata (both at 0xd2a4, size 0) and the first labeled stock global g_avrcp_req_event_database (at 0xd2b5). That's a 17-byte gap that looked like alignment padding.
It wasn't. Per-byte axt queries against radare2's aaaa full analysis (relocs applied) on stock libextavrcp.so show a single xref in the gap:
fcn.000036c0 0x36ca [DATA:r--] add r2, pc
fcn.000036c0 0x36cc [DATA:r--] ldr r2, [r2]
Resolving by hand: at 0x36ca, pc = 0x36ce. Literal at 0x36d4 is 0x9bde. add r2, pc → r2 = 0xd2ac. ldr r2, [r2] → r2 = *0xd2ac (4-byte word at byte offset 8 within our 13-byte state block). Stock fcn.000036c0 is a thin trampoline:
push {r3, lr}
ldr r2, [r0] ; r2 = vtable (object's first word)
ldr.w r3, [r2, 0x190] ; r3 = method ptr at vtable+0x190
ldr r2, [0x36d4] ; literal load
add r2, pc ; r2 = 0xd2ac
ldr r2, [r2] ; r2 = global pointer at 0xd2ac
blx r3 ; invoke method(arg=r2)
pop {r3, pc}
So *0xd2ac is a stripped-symbol stock global — a pointer to some object instance, passed as the argument to a vtable method.
e2719c7's trampoline state writes overlapped the pointer:
state[8](= 0xd2ac, byte 0 of corrupted pointer) — unused in current state schema, butwrite_state_blockissuesstrbbyte-stores when writing 13 bytes, hitting this offsetstate[9..11](= 0xd2ad..af) — T9'slast_play_status,last_battery_status,last_repeat_avrcpbytes
After even one T9 write, *0xd2ac becomes a small int like 0x00040201 (battery+playstatus+repeat). The next call into fcn.000036c0 dereferences this as a pointer in the vtable method, SIGSEGVs in BTAvrcpMusicAda.
The PositionTicker fires playstatechanged once per second from the music app, which fans out to MtkBt's notification handlers, which call into JNI paths that route through fcn.000036c0 or its callers — so the corruption is hit reliably within ~1 s of any music-app activity. Hence the tight crash loop.
Verification methodology
Per-byte axt @ <addr> queries against the full radare2 analysis identify every PC-relative access in .text that resolves to a given .bss address. The methodology was validated end-to-end:
- Known bad spot (
0xd2ac) → radare2 finds thefcn.000036c0xref ✓ - Other bytes in 0xd2a4..0xd2b4 → no xrefs (those bytes are genuinely unreferenced)
- Candidate gap 0xd2d6..0xd2f3 (between
g_avrcp_auto_browse_connectandg_avrcp_seq_id_database, 30 bytes) → 0 xrefs across all 30 bytes
False-negative risk: stripped variables accessed via GOT-indirect or runtime-relocated addresses might escape this static check. For shared libraries on Android with PIE, data globals are typically accessed via PC-relative addressing (which radare2 catches), not absolute literals with GOT relocations (rare for non-extern data). The 0xd2ac case proves the methodology catches the relevant access pattern.
Fix
Move G_Y1_TRAMPOLINE_STATE_VADDR from 0xd2a4 to 0xd2d6. The new range occupies 13 bytes at 0xd2d6..0xd2e2, well within the verified-clean 30-byte gap. Layout (state[0..12]) and all T*_OFF_STATE + N offsets remain unchanged — only the literal-pool constant in _emit_read_state_block_subroutine / _emit_write_state_block_subroutine rebases.
Blob size impact: zero (the PC-relative offset changes value but stays 4 bytes). Patcher MD5s repinned (release ab66739db34f97e5d2e4d6f2a6e00af8, debug 55ba552ad3372f6fb55505c8377b896d).
Lessons
__bss_startis a hostile location for tucking in new globals. The linker collects uninitialized statics from individual compilation units at the beginning of.bss, so stripped/local statics cluster near__bss_start. Gaps between named globals are safer because the linker has already accounted for both endpoints.- The
axtquery on radare2's full analysis is the cheapest verification for "is this.bssaddress used by stock code". Run it per-byte over any candidate range before committing. - Bisection by flashing successive commits is much faster than static analysis when the bug is a single commit's regression. User's flash-box workflow turned this from a multi-day RE problem into a 4-flash bisection in ~45 minutes.
Trace #87 — 2026-05-21 Bolt CT skips full AVRCP setup when BrowseGroupList is absent; P_PN1 reverted
Symptom
After commit f19ad7c (added 0x0102 ProviderName " " SDP attribute to AVRCP 1.3 TG record by repurposing the 0x0005 BrowseGroupList entry slot), Bolt's AVRCP CT connects to Y1, issues a single GetCapabilities CMD, receives the response, then goes completely silent on AVRCP for the rest of the session. No InformDisplayableCharacterSet, no RegisterNotification × 9 (the spec-default subscription burst), no GetElementAttributes. Bolt's HU UI shows no metadata and does not refresh on track skip. PASSTHROUGH still works because that's a CT→TG flow Bolt initiates without prior subscription.
Pre-f19ad7c (e.g. dual-bolt-20260519-2112 at commit 52a8a80..105eef5): same Bolt issued the full handshake — GetCap → InformDisplayableCharacterSet → RegNotif × 9 → CHANGED-driven steady state with 93 inbound T2reg re-subscriptions visible across the session.
Decisive capture
dual-bolt-20260520-2154 was a deliberate "pair fresh, wait 100 s, don't touch anything" capture against HEAD=5db2aee (post-f19ad7c). Y1 logcat shows:
21:54:18 capture starts (BT process up, PID 701)
21:54:52 Bolt connect_ind
21:54:52 Bolt → Y1: GetCap CMD (size:9 vendor-dependent)
21:54:52 Y1 → Bolt: GetCap RSP (IPC msg=522, AVRCP_SendMessage len=30)
21:54:52 M5dbg / M5wire pair for the GetCap RSP outbound frame
───────────────────── 100 s of total AVRCP silence ─────────────────────
21:55:19 screen off
21:56:33 capture ends
grep "T2reg" over the session: zero matches. Bolt's CT didn't issue a single RegisterNotification and the user touched no controls. The "wait it out" hypothesis (Bolt was racing user input ahead of CT setup) is ruled out — Bolt actively chose passthrough-only mode based on what it observed about Y1.
Bisection
| Commit | Date (UTC) | Capture | T2reg |
|---|---|---|---|
52a8a80..105eef5 | May 19 ~21:00 | dual-bolt-20260519-2112 | 93 (Bolt does full setup) |
f19ad7c..5db2aee | May 20 22:00+ | dual-bolt-20260520-2154 | 0 (Bolt silent on AVRCP) |
The only mtkbt-affecting commit between the two ranges that touches SDP record shape is f19ad7c (P_PN0 + P_PN1). M8 (dd8a85d) is the other mtkbt commit in the window but it touches AVRCP_HandleA2DPInfo's info=1 disconnect path — a runtime post-track-skip behavior that doesn't affect what the CT observes during AVRCP setup. The .bss state move at e2719c7 + relocation fix at 5db2aee are libextavrcp_jni.so changes orthogonal to SDP record contents.
Verification: Pixel ships both attributes, our patch shipped only one
f19ad7c's commit message claimed "Pixel parity" — Pixel's AVRCP 1.3 TG record advertises 0x0102 ProviderName " ". The XML (/work/logs/pixel4-sdptool-browse-avrcp-1.3.xml) confirms:
<attribute id="0x0005">
<sequence>
<uuid value="0x1002" /> ← BrowseGroupList = PublicBrowseRoot
</sequence>
</attribute>
<attribute id="0x0102">
<text value=" " /> ← ProviderName = " "
</attribute>
Pixel ships both 0x0005 and 0x0102. P_PN1's wire delta swapped 0x0005 BrowseGroupList → 0x0102 ProviderName (one slot, swap not add). So our "Pixel parity" patch actually shipped a TG record with 0x0102 present but 0x0005 absent — the opposite of Pixel's shape for 0x0005. Bolt's CT reads BrowseGroupList membership ({PublicBrowseRoot}) as a discriminator for "this peer is in the public browse group and supports full AVRCP" vs "treat as minimal/legacy"; removing it dropped Bolt onto the minimal path.
Deep RE on adding a 7th entry slot
To restore BrowseGroupList AND ship ProviderName, the AVRCP 1.3 TG record's entry table needs 7 entries instead of 6. Confirmed during the deep dive:
- Entry table at file
0xf978c(vaddr0xfa78c, in LOAD1 /.data) holds exactly 6 attribute entries × 12 bytes (attr_id(2) + len(2) + ptr(4) + zeros(4)), tightly packed. - Next SDP record begins immediately at
0xf97c8; no padding or gap. - Searched the entire binary for any 4-byte value equal to vaddr
0xfa78cor file offset0xf978c: only theR_ARM_RELATIVErelocation entries in.rel.dynreference these addresses. Those are bookkeeping for the dynamic loader's pointer fixups, not an SDP-specific entry index. - The single consumer function found by radare2
aaaaxrefs (fcn.0x43a18) scans the table with a hardcodedcmp sl, 0x14(20 iterations × 4-byte stride = 80 bytes = 6 entries plus 8 bytes slop) — not a per-record attribute count, just a fixed scan bound. Adjusting it would affect every other caller of the function. - mtkbt's binary has 230 dynamic symbols; none are named
SDP_*,Sdp_*, or anything related to record iteration. Static symbols are fully stripped. The actual SDP record-builder function that iterates entries to construct on-wire responses can't be found by symbol name. - Sdptool against Y1 confirms the daemon serves attribute
0x0002 ServiceRecordStateand0x0000 ServiceRecordHandlefor this record — neither is in the 6-slot entry table. So mtkbt definitely has a mechanism for adding attributes outside the table; the mechanism just isn't exposed via labeled symbols or obvious data structures.
After deep search, no safe, verified path to add a 7th entry was found. Inserting bytes between records (shifting the next record's entries forward by 12) requires understanding all code that addresses the affected entries, which the stripped binary doesn't make tractable in reasonable time.
Fix
Revert P_PN1. Keep P_PN0. The descriptor bytes for 0x0102 ProviderName " " remain written into a previously-unused gap at file 0x0eb938, but no entry slot references them — they're dormant. The TG record returns to its post-V7+V8 shape: BrowseGroupList={PublicBrowseRoot} present, 0x0102 ProviderName absent. Bolt's CT sees the BrowseGroupList membership and takes the full-AVRCP-setup path.
Future work
If a non-destructive path to adding a 7th attribute entry is found later (e.g., locating the daemon's implicit-attribute injection point that adds 0x0002 ServiceRecordState, or finding a record-builder count-limit constant that's per-record rather than global), P_PN0 is already in place — only a P_PN1-style entry write would be needed. Until then, "every spec-meaningful attribute except 0x0102 ProviderName" is the closest Pixel parity we can ship.
Spec basis
Per Bluetooth Core specification (Volume 3, Part B, §2.2), 0x0005 BrowseGroupList is OPTIONAL but standard practice is to advertise {PublicBrowseRoot} (UUID 0x1002) so that SDP_ServiceSearchPattern against 0x1002 returns the record. Some CT implementations use BrowseGroupList membership as a heuristic for "is this peer a fully-implemented BT profile target or a stub". Bolt EV is empirically in that group.
Trace #88 — 2026-05-21 Debug-build SIGSEGV in BTAvrcpMusicAda: T5id log clobbers T5's conn struct ptr
Symptom
dual-sonos-20260521-1841: 12 ms after Sonos drives a track change, MtkBt (PID 1065, thread BTAvrcpMusicAda) takes SIGSEGV at fault addr 0x00000019. ActivityManager schedules a restart of every com.mediatek.bluetooth service; the process death wipes g_avrcp_req_event_database (.bss), so per-event subscriptions established earlier in the session are lost and Sonos's PSC/Track CHANGED subscriptions never recover. Net effect: pause-button glyph stops flipping back to play after the first track edge.
Release build (OUTPUT_MD5 = 5c8ab18…) does not reproduce. The crash only occurs in KOENSAYR_DEBUG=1 builds (deployed OUTPUT_DEBUG_MD5 = c83182e9…).
Decoding the tombstone
F libc : Fatal signal 11 (SIGSEGV) at 0x00000019 (code=1), thread 1462 (BTAvrcpMusicAda)
I DEBUG : r0 00000008 r1 00000009 r2 527242b5 r3 00000001
I DEBUG : r4 00000000 r5 5177b9d8 r6 00000007 r7 522e6ec8
I DEBUG : ... lr 52722047 pc 5272277e
I DEBUG : #00 pc 0000b77e /system/lib/libextavrcp_jni.so
I DEBUG : #01 pc 0000b043 /system/lib/libextavrcp_jni.so
Load base 0x52717000 (from pc - 0xb77e). The code around pc dump shows the actual instruction at vaddr 0xb77e is 7443 (Thumb-1 strb r3, [r0, #0x11]) — the tail of restore_conn_tid. With r0=8, the store targets [8 + 0x11] = [0x19] → SEGV_MAPERR. lr=0x52722047 (Thumb bit set; return addr 0xb046) lands two instructions past a bl restore_conn_tid inside a per-event emit dispatch arm.
The disassembly of the caller arm shows it is inside notificationTrackChangedNative's patched wrapper (= T5):
b034: 09 21 movs r1, #9 ; event_id = 0x09 NPCC
b036: 00 f0 af fb bl event_subscribed
b03a: 08 d0 beq +8 ; skip if database[9] == 0
b03c: 04 f1 08 00 add.w r0, r4, #8 ; r0 = conn = r4 + 8 ← r4 is NULL
b040: 09 21 movs r1, #9
b042: 00 f0 97 fb bl restore_conn_tid ; strb r3, [r0, #0x11] = strb [0x19]
Root cause
_emit_t5 stores the BluetoothAvrcpService struct ptr in r4 at its prologue (line 779: mov_lo_lo(4, 0) after bl jni_get_avrcp_state). Every CHANGED emit in T5's chain — POS_CHANGED, TRACK_CHANGED, NowPlayingContentChanged, REACHED_END, REACHED_START — does add.w r0, r4, #8 to recompute the conn ptr before invoking the per-event response builder.
At T5's TRACK_CHANGED emit site (line 885), the debug-only T5id log captures selected_track_id[7] (always 0; the SELECTED-track AVRCP 1.3 §6.7.2 sentinel) for diagnostics:
if DEBUG_NATIVE_LOG:
a.ldrb_w(4, 3, 7) # r4 = id[7]
_emit_native_log_u32(a, "log_fmt_t5id", 4)
_emit_native_log_u32 push/pops {r0, r1, r2, r3} around the __android_log_print blx (the comment block at _trampolines.py:2228 is explicit: "caller has its full r0..r3 arg vector already loaded ... so push/pop all four caller-arg registers around the call to preserve the emit's setup"). It does not save r4 — r4 is the value-passing register, and the helper's contract was written assuming the caller could spare it.
In T5 the caller cannot spare r4. The ldrb_w(4, 3, 7) overwrites the conn struct ptr with selected_track_id[7] = 0. The TRACK_CHANGED blx_imm(PLT_track_changed_rsp) immediately following uses r0-r3 (which are correct) and tail-returns. The next event in the chain — NPCC at 0xb03c — recomputes r0 = r4 + 8. With r4 now 0, r0 = 8. restore_conn_tid's final strb r3, [r0, #0x11] writes [0x19] → fault.
Why only debug, why only NPCC
- Debug only:
DEBUG_NATIVE_LOGisFalsein release builds — theldrb_w(4, 3, 7)isn't emitted. r4 stays intact through the whole emit chain. - NPCC first: T5's chain is PPC → TC → NPCC → REACHED_END → REACHED_START. The clobber happens at TC's debug log (after r4-based setup). PPC fires before the clobber. TC fires immediately after the clobber but doesn't read r4 between the log and the blx (r0-r3 are already loaded). NPCC is the first downstream emit whose
event_subscribedgate passes — that's the one that crashes. (POS_CHANGED at the chain's head fires before the clobber and was fine; the crash is specifically when database[9] != 0, i.e., the CT subscribed to NPCC. Sonos does.)
Identical pattern is harmless at two other sites
_emit_t4 (line 477) and _emit_extended_t2 (line 742) have the same ldrb_w(4, 3, 7); _emit_native_log_u32(..., 4) pattern, but both functions store the conn struct ptr in r5 (not r4). The r4 clobber at those sites lands on a scratch register that nothing reads. Latent code smell, not a live bug.
Fix
Wrap T5's debug log with push {r4} / pop {r4} so the conn struct ptr survives the value-passing clobber:
if DEBUG_NATIVE_LOG:
a.raw(bytes([0x10, 0xB4])) # push {r4}
a.ldrb_w(4, 3, 7)
_emit_native_log_u32(a, "log_fmt_t5id", 4)
a.raw(bytes([0x10, 0xBC])) # pop {r4}
Cost: +4 B (debug build only). Debug blob 3392 → 3396 B, headroom 628 → 624 B against the 4020 B cave. Release blob unchanged (3152 B).
T4 and extended_T2 left untouched — the clobber is harmless there and a defensive push/pop would only add bytes without changing behaviour.
MD5 pin update
OUTPUT_MD5:5c8ab181c221d3c31739fe5955f7a25b(unchanged — release-side bytes are identical)OUTPUT_DEBUG_MD5:c83182e95edcaa0951ae1ca38fa0a350→778991030950699c2e2861bc7e457556
Trace #89 — 2026-05-21 Delete T5id debug log (dead code; resolves #88 bug class)
Motivation
Trace #88 fixed the T5 r4-clobber crash by wrapping the T5id debug log in push {r4} / pop {r4}. That fix preserved a log that, on review, has no actual diagnostic value:
- Value is constant.
T5id=%02xalways logs00—selected_track_idis a static.rodatabuffer of eight zero bytes (AVRCP 1.3 §6.7.2 SELECTED sentinel). The byte cannot become non-zero at runtime (no mprotect writer). - Source site is indistinguishable. The same
log_fmt_t5idformat string is used at all three call sites (T4 reactive CHANGED, extended_T2 INTERIM, T5 proactive CHANGED). Logcat showsT5id=00but doesn't say which path fired. - Never cited as load-bearing. Across 88 prior INVESTIGATION traces,
T5idis referenced exactly once — by Trace #88, which documents it causing a crash. No trace ever used it as a positive diagnostic signal. Other Y1T tags (T2reg,T9ps,T9papp,T9pos) already confirm CHANGED emits with site-distinguishing names.
The log was probably added during the Identifier-value design churn (commits 9c4ae0e monotonic-counter → 53e6153 SELECTED 0x00*8) as a sanity check that the wire payload settled at the spec-correct zero value. Once the design stabilised, the log became dead weight.
Action
Deleted all three if DEBUG_NATIVE_LOG: ldrb_w(4, 3, 7); _emit_native_log_u32(a, "log_fmt_t5id", 4) blocks plus the log_fmt_t5id label and asciiz. Also removed the row from docs/PATCHES.md and the mention from src/patches/README.md's Y1T tag inventory. T1pdu / T2reg cross-references updated to drop the T5id pointer.
Net effect vs. b1c113d (Trace #88's fix)
- T5
r4clobber cannot recur: the source-code pattern that produced it no longer exists at any site. - The
push {r4} / pop {r4}scaffolding from b1c113d is gone — there's nothing to guard against. - Debug blob: 3396 B → 3308 B (88 B saved: 3 × ~26 B per call site + ~10 B for the dropped format string).
- Release blob: 3152 B (unchanged —
DEBUG_NATIVE_LOGwas already gating release-side emission).
MD5 pin update
OUTPUT_MD5:5c8ab181c221d3c31739fe5955f7a25b(unchanged)OUTPUT_DEBUG_MD5:778991030950699c2e2861bc7e457556→c81d15339c73ec4db6703eb03c25cc59
Trace #90 — 2026-05-22 Non-RegNotif AVCTP TID echo broken; fixed at T4 entry
Symptom
dual-bolt-20260521-2111: Bolt connects, exchanges A2DP, issues PASSTHROUGH PLAY (works), then issues a single GetElementAttributes and goes completely silent on AVRCP for the remaining 160 s. Metadata pane never populates. PASSTHROUGH control continues to work because that's a different AV/C subprotocol with its own TID echo path.
dual-kia-20260521-2109: Kia metadata works (polling-driven), but the playhead lingers ~1 s after track changes. Kia issues 37 GetPlayStatus polls + 17 GetEA fetches in 79 s, never a single RegisterNotification.
M5 wire-tag census (debug-build c81d15339c…)
Outbound chan+0x39 (the byte mtkbt encodes as AVCTP transaction-label upper nibble) per CT, across the four-CT matrix:
| CT | Outbound c39 distribution | Inbound TID pattern |
|---|---|---|
| Sonos | 47× 00, 24× 01, 14× 04, 11× mixed | Varies (RegNotif-driven refresh) |
| TV (Samsung Frame Pro) | similar mixed | Varies (poll + subscribe) |
| Kia EV6 | 57× 07 (every outbound) | 12 inbound: 2× TID=03, 10× TID=07 |
| Bolt EV | 2× 07 (both outbound) | 12 inbound: TIDs 0x01..0x0b cycling |
Kia works by accident — its CT-side TID generator happens to use 0x07 for most CMDs, so the stale-07 echo matches what Kia expects. The 2 TID=03 CMDs got TID=07 responses (rejected; no visible impact since Kia polls regardless).
Bolt cycles TIDs starting at 0x01. The GetEA CMD (TID=0x01) got an RSP with TID=0x07. Bolt rejected per AVCTP §3.3.5 strict-echo and stopped issuing CMDs that required a response.
Root cause: stale conn[+0x11] on non-RegNotif PDUs
Stock libextavrcp_jni.so writes conn[+0x11] = inbound seq_id right before every response-builder call. The rsp builders pack conn[+0x11] into the AVCTP TL field on outbound frames, fulfilling §3.3.5 strict echo.
The R1 redirect at JNI vaddr 0x6538 (commit history) hijacks the dispatcher path upstream of stock's conn[+0x11] write. For RegNotif PDUs, our trampoline re-establishes the write via extended_T2 → save_event_seq_id → _emit_restore_conn_tid_from_db, which uses the per-event database at g_avrcp_req_event_database[event_id]. That path works.
For non-RegNotif PDUs (GetEA, GetPlayStatus, InformCharset, InformBattery, PApp 0x11..0x16, Continuation 0x40/0x41), none of our T4-family handlers wrote conn[+0x11]. The slot kept whatever value the first response after connect left there — which for both Bolt and Kia was 0x07 from the initial GetCapabilities RSP.
CTs that RegNotify frequently (Sonos, TV) avoided the symptom because every RegNotif INTERIM/CHANGED emit refreshes conn[+0x11] through the database path. Their non-RegNotif responses inherit a "recent" value that, while not strictly correct per the inbound CMD's TID, was close enough or by coincidence matched.
Pixel-as-TG verification
/work/logs/pixel4-bugreport/FS/data/misc/bluetooth/logs/btsnoop_hci.log — Pixel 4 acting as AVRCP TG to Kia (CT). Frames 1480..1900, AVCTP transaction column:
| Frame | Dir | TID | PDU | Note |
|---|---|---|---|---|
| 1480 / 1481 | CT→TG / TG→CT | 0x00 | GetCap | RSP echoes 0x00 |
| 1492 / 1493 | / | 0x01 | InformCharset | RSP echoes 0x01 |
| 1498 / 1501 | / | 0x02 | RegNotif(PSC) INTERIM | RSP echoes 0x02 |
| 1505 / 1506 | / | 0x03 | RegNotif(TC) INTERIM | RSP echoes 0x03 |
| 1508 / 1509 | / | 0x04 | GetEA | RSP echoes 0x04 |
| 1517 / 1518 | / | 0x07 | GetEA | RSP echoes 0x07 |
| 1529 / 1530 | / | 0x0b | PApp 0x11 | RSP echoes 0x0b |
Pixel echoes the inbound TID on every PDU type. Per-event CHANGED emits (frames 1584/1601/1602/1646…) use the most recent RegNotif CMD's TID for that event, which matches our database-driven mechanism for CHANGED.
Fix
Add 6 bytes at _emit_t4 entry (the universal dispatcher reached for every non-RegNotif PDU through extended_T2 → b.w T4):
a.ldrb_w(1, 13, 0x171) # r1 = inbound seq_id at sp+0x171
hw = 0x7000 | (0x19 << 6) | (5 << 3) | 1 # strb r1, [r5, #0x19]
a.raw(bytes([hw & 0xFF, (hw >> 8) & 0xFF]))
sp+0x171 is the empirically-verified pre-SUB SP offset of the inbound seq_id byte at the testparmnum-derived entry context (same offset extended_T2 reads for save_event_seq_id's argument). r5+0x19 is conn[+0x11] (with conn = r5 + 8). r1 is clobbered; every downstream T4 / T6 / T_charset / T_battery / T_papp / T_continuation rsp-builder prologue re-loads r1 before the blx.
GetCap path at T1_extended:0xac5c is unaffected — Sonos/Bolt GetCap RSPs already echo correctly via stock JNI's pre-R1 path. RegNotif paths in extended_T2 / T5 / T8 / T9 also unaffected — they continue using restore_conn_tid from the per-event database.
Predicted empirical outcomes for verification
- Bolt: GetEA RSPs ship with TID matching CMD → Bolt accepts → metadata pane populates. Subscription cycle may resume (if BrowseGroupList was the only remaining blocker per Trace #87).
- Kia: TID=03 CMDs now get TID=03 RSPs (was rejected, now accepted). Most behavior unchanged since TID=07 already worked. Playhead lag is structural (polling cadence ~2 Hz) and independent of TID echo.
- Sonos / TV: No behavior change. Their non-RegNotif RSPs now ship the precise inbound CMD TID (was: most-recent-RegNotif TID via database); strictly more correct per §3.3.5.
Budget
- Release blob: 3152 → 3156 B (+4 B; release-side bytes were already correct so why +4? — align padding shifted).
- Debug blob: 3308 → 3312 B (+4 B).
- 708 B headroom in debug, 864 B in release. Plenty.
MD5 pin update
OUTPUT_MD5:5c8ab181c221d3c31739fe5955f7a25b→4ebd181976c1dbdd19b6a06112dce484OUTPUT_DEBUG_MD5:c81d15339c73ec4db6703eb03c25cc59→384f0c630feff36d43e62a122764bade