# Naming and typing the TA/ASD decompilation corpus — state, and every lever measured Companion to `AUDIT-TA-STRUCTS.md`, which covers the structures themselves. This file records **what was tried and what it yielded**, including the dead ends, so none of them has to be rediscovered. ## State | metric | value | |---|---| | builds decompiled | 99 (16 components) | | functions | 28,945 | | **named with a meaningful name** | **19,433 (67.1%)** | | ... of which hash placeholders | 112 | | name field set but address-derived | **0** (dropped as uninformative) | | typed field accesses, named functions | 10,863 | | raw dereferences, named functions | 3,506 | | **field share, named artifacts** | **75.6%** | | declared structures | 28 | | self-port gates | 16/16 PASS | Naming started at 35.4% and field share at effectively 0%. **The named figure is stricter than the one reported through most of this work.** The count I had been quoting, 19,424, is every function whose `name` field is set, and 243 of those are `sub_401234` or `j_sub_401234` -- IDA's address-derived defaults, which are not names in any useful sense. The honest count excludes them: at the time of that correction, **19,181 (66.3%)** rather than 67.1%. Both numbers are in the table so the difference is visible rather than quietly resolved. Separating them immediately paid for itself. 220 of the 243 were `j_sub_` thunk names, and 87 had targets that by then carried a real name: the `j_` label is whatever Hex-Rays printed when that thunk was decompiled, so a thunk decompiled before its target was renamed kept a stale address. A late re-resolution pass in `ta_re.py` rewrites those from `assigned` -- a pure lookup, no new inference, and thunks whose target is still unnamed keep `j_sub_*` because that is the honest answer. 87 names recovered, address-derived entries 243 -> 156, meaningful names 19,181 -> 19,268. This is the one case in this work where a metric correction directly produced real improvement: the bad names were invisible while they were being counted as named. **And the correction itself was still wrong.** Both the measurement and the repair anchored on `^(j_)?sub_[0-9A-Fa-f]+$`, immediately after the hex digits, which does not match IDA's collision suffix. `j_sub_D07364_12` is exactly as address-derived as `j_sub_D07364`, and there were **1,852** of those being counted as meaningful names -- so the honest figure was 60.2%, not 66.6%. The repair pass skipped them for the same reason, leaving 713 thunks fixable but untouched. With both regexes accepting the suffix: 713 more names recovered, address-derived entries 2,008 -> 1,295, meaningful names 17,416 -> 18,129 (60.2% -> 62.6%). The remaining 1,272 thunks point at targets that are not functions in their own build, so `j_sub_*` stays. ### Lever 15: decode the thunk branch instead of asking the decompiler (ACCEPTED, 909 names) Dropping the false names left a clean question: of ASD's ~4,500 four-byte anonymous functions, how many are thunks to a target that already has a name? The repair pass could not answer it, because it only rewrites a name that already reads `j_sub_` and these rows had no name at all. Decoding the branch from the ELF answers it directly. The first attempt decoded them as ARM `B` and got nonsense -- 41 words with top byte `0xB8`, 37 with `0xBE`, 34 with `0xBD`. Those are the *second halfword* of a 32-bit Thumb-2 `B.W`, which is how the wrong assumption announced itself. With the correct decode, of 4,530 such functions: **3,402** target a function that is itself unnamed, **909** target a properly named function, 116 target an address IDA never defined, and 103 do not decode as `B.W` at all. The 909 are now named `j_`. This is a resolution rather than an inference -- the branch displacement says where control goes -- which is the same justification the existing THUNK tier rests on. One ordering detail cost a run: the pass must come *after* the address-derived names are dropped. Before that point those rows still carry a stale `j_sub_` label, so they look named and the pass skipped every one of them, reporting zero. Result: meaningful names 18,129 -> 19,038 (62.6% -> 65.8%), with address-derived entries still 0. ### The artifact has to agree with itself Both thunk passes set the name in this script's metadata only, so the emitted banner read `j_asd_clear_link_outputs` while the signature inside the body still read `j_sub_D07E10_16`. Checking for that found **1,018 functions disagreeing with their own banner**, most of them introduced by the earlier repair rather than by this one. A reader who sees the two differ cannot tell which to trust, which is worse than leaving the function anonymous. `ta_re.py` now pushes every derived name into the database and re-decompiles whatever still disagrees. Two details were needed to make it work, both found by re-measuring rather than assuming: * Names must be uniquified first. Several distinct thunks branch to the same target, so `j_` is not unique and a colliding rename is **silently refused** -- on the ASD reference only 55 of 786 reconciled until duplicates were suffixed. Those suffixes are honest: these really are separate functions with a shared destination, unlike the address-derived `_N` names dropped earlier. * The resolution pass must run *after* address-derived names are dropped, because before that point the rows still carry a stale `j_sub_` label and look named. Mismatches are now **7**, down from 1,018, across the whole corpus. ### Lever 18: recover the RPC opcode from inside an SVC stub (ACCEPTED, 4 modules to 100%) Earlier in this work I recorded that the `SVC 0xF2` stubs "carry no semantic information whatsoever" because the operation is selected by caller-set registers. That is true of the *shared* thunks, and I over-generalised it: in the per-module stubs the opcode is loaded **by the stub itself**. DTM's three unnamed functions all decompile to the same four lines: ```c int sub_4010C0() { __asm { SVC 0xF2 }; return 2; } ``` The bytes disagree with that impression. Decoding the Thumb-2 immediates: | address | instruction bytes | opcode | |---|---|---| | `0x4010c0` | `4f f4 e0 70` -> `MOV.W R0, #0x1C0` | **0x1C0** | | `0x4010de` | `40 f2 01 10` -> `MOVW R0, #0x101` | **0x101** | | `0x4010fc` | `4f f4 80 70` -> `MOV.W R0, #0x100` | **0x100** | The value is then stored as the first word of the request frame, `R0` is set to the selector 2, `R1` is pointed at the frame and the supervisor call issues. So the first stored word is the opcode, and `0x100` independently matches a comment already in this tree: "Opcode 0x100 - DTM TOPOLOGY_UPDATE". Named `_rpc_`, matching the existing `asd_rpc_NNN` convention. A stub is only named when exactly one immediate in range survives; otherwise it stays unnamed rather than being guessed at. **The rule is structural, after a weaker one was rejected.** The first version took the sole in-range immediate and scored 296/299, but gave no answer on 102 stubs that load more than one constant. The obvious fix -- take the *first* immediate -- scores 4/4 on exactly those cases, and that number is worthless: all four are the same XGMI function in four builds, which is n=1 dressed as n=4. Applying it to 102 functions on that basis would have been a guess. The structural rule instead tracks MOV/MOVW/MOVS immediates per register and returns whichever register is stored to `[SP, #0]`, the first word of the frame that `R1` is pointed at. Against the **940** stubs the libdump already names `*_rpc_N`: **940 match, 0 disagree**, and it answers on all of them. It also resolves all 102 previously ambiguous stubs, so the weaker rule is simply superseded. The earlier single-immediate measurement is kept below for the record. The libdump already names 299 single-immediate stubs `*_rpc_N`, which is independent ground truth: the decoded immediate matches the established number in **296 of 299**. The three exceptions are one XGMI function across three builds whose libdump name uses a different numbering. Applied corpus-wide on that basis, the pass names 501 stubs: | module | before | after | |---|---|---| | hdcp | 87% | **96%** | | fp | 92% | **95%** | | gcp | 87% | **91%** | | opm | 62% | **75%** | | merit | 76% | 79% | | ras | 35% | 38% | | asd | 64% | 65% | A further corroboration appeared afterwards: HDCP's newly decoded `hdcp_rpc_161` is the same service number as ASD's independently libdump-named `asd_rpc_161`, i.e. two modules calling the same SYS_DRV entry agree without either having been told about the other. Names are uniquified per build and no build contains a duplicate. Also named this round, from reading the bodies: * `oc_memcmp_ct` -- `v3 |= *a1++ ^ *a2++` accumulated over a counted loop with no early exit, which is a constant-time comparison and worth labelling as such; * `csu_rpc_309` and `csu_rpc_305` -- the same stub shape, opcode differing across builds; * `auc_sysdrv_rpc_101C_fwd` -- an 8-byte forwarder adding `a2 + 80` as a fourth argument. **csu, oc, tmz and dtm are now at 100%.** The remaining small-module functions are an AUC body of several hundred lines, a RAP command handler and five FP functions -- real reverse engineering, not pattern work. ### Lever 17: all five GP entry-point slots from the image header (ACCEPTED, TMZ to 100%) `ta_entry_points` read the trampoline's `+0xA0` (Create) and `+0xB0` (Invoke) slots and ignored the three between them. Those three are `TA_DestroyEntryPoint`, `TA_OpenSessionEntryPoint` and `TA_CloseSessionEntryPoint`, which is why TMZ sat at 50% named: it has six functions, and three of the unnamed ones were entry stubs the header identifies exactly. The slot order was **read off GCP, not assumed from the GP spec**. GCP has all five named independently, and its table at `0x405d28` reads Create, nullsub (destroy), OpenSession, CloseSession, Invoke; WSPE and HDCP corroborate. The types corroborate as well -- slots 0, 2 and 4 return a status, slots 1 and 3 are void. Running the new derivation against GCP reproduces GCP's own names exactly, which is the validation that matters. Applied for every module, but only where porting left the address unnamed, so a ported name is never overwritten. Per-module naming after this: | module | before | after | |---|---|---| | tmz | 50% | **100%** | | rap | 76% | **98%** | | csu | 70% | **92%** | | oc | 90% | **97%** | | dtm | 75% | **89%** | **RAS disagrees, and is left alone.** Its libdump names `TA_CreateEntryPoint` and `TA_DestroyEntryPoint` at addresses the header assigns to OpenSession and CloseSession -- a systematic two-slot shift across all 18 builds. Writing the header names there would leave the artifact asserting two different Create entry points, so a guard refuses to introduce a second holder of a role: the conflict is printed on every build and nothing is written. Which source is right for RAS is an open question, recorded rather than resolved, because the obvious tiebreaker (argument count) is not decisive on functions IDA never prototyped. ### Lever 16: positional transfer across ASD thunk tables (REJECTED, yield 3) With 3,402 thunks still pointing at unnamed targets, the remaining idea was ordinal: ASD's four-byte thunks sit in tables, so thunk *N* in one build might be thunk *N* in another, giving a mapping that works where byte identity does not. The already-named thunks make this testable rather than a guess. Positional agreement between neighbouring builds, comparing only positions named in both: | pair | thunk counts | agreement | |---|---|---| | 33.0.16.209 vs 33.0.24.209 | 750 / 750 | **574/574 = 100%** | | 33.0.8.235 vs 33.0.8.239 | 801 / 801 | **613/613 = 100%** | | 33.0.0.239 vs 33.0.8.235 | 801 / 801 | 609/613 = 99% | | 33.0.16.209 vs 33.0.28.209 | 750 / **753** | 505/554 = **91%** | So the tables are positionally stable exactly when the counts match, and misalign as soon as entries are inserted -- 91% is not a transfer mechanism, it is a 1-in-11 chance of a wrong name. Restricting to equal-count groups, which is the only safe case, the measured yield across all 18 ASD builds is **3 names**. Porting already covers everything the safe case would give, because builds with identical thunk tables are also the builds whose bytes match. Rejected on yield, not on principle. ### The structural fix The predicate now lives once, in `fw/ta_metrics.py`, and is imported by the pipeline, by the repair pass and by the audit. Running `python3 fw/ta_metrics.py` prints the corpus numbers with every denominator stated, so a future measurement cannot quietly disagree with the one before it. `ta_re.py` also stops admitting address defaults as names. The sequence matters: a `j_sub_*` candidate is kept at the decompiler-fallback step, because the re-resolution pass needs its target address to recover a real name; whatever is still address-derived after that pass is dropped, so those rows become anonymous and sit in `.anon.c` rather than inflating the named count. Address-derived entries are now **0**, down from 1,295, with the meaningful count unchanged at 18,129 -- the drop removes false coverage without losing a single real name. Two lessons, both about measurement rather than reverse engineering. A regex that defines a quality metric needs the same scrutiny as one that drives a rename, because an over-strict pattern inflates the metric and hides the work. And finding one instance of a class of error is not evidence that the class is now clear -- this was the same mistake twice in two consecutive commits, in code I wrote immediately after describing the problem. The field-share row is also the corrected, named-artifact-only measurement. The earlier 65.9% came from globbing `fw/decomp/*.c`, which matches the `.anon.c` files holding unnamed bodies; see AUDIT-TA-STRUCTS.md. ### Name tiers, and what each is worth | tier | count | basis | trust | |---|---|---|---| | EXACT | 7503 | identical function bytes | identity | | THUNK | 7813 | Hex-Rays thunk resolution, `j_` | identity | | MNEM | 2261 | identical mnemonic sequence, operands differ | identity | | IDA | 1433 | decompiler-stated name, or call-graph position | strong | | DISPATCH | 200 | the command switch routes it | identity by construction | | FUZZY | 182 | >=0.90 positional mnemonic similarity | **a judgement, not an identity** | FUZZY is the only tier that is a guess, and it is 0.9% of names. The threshold was never lowered to inflate coverage. ## Why the signature percentage is not the measure Struct-typed signatures read 4.1%, down from 6.5% earlier, purely because naming added thousands of previously-anonymous functions to the emitted set. Typing one dispatcher or one argument rewrites hundreds of lines while moving the signature count by one. **Field share is the metric that tracks readability**; the signature ratio is kept only for continuity. ## Levers applied, with measured yield | lever | yield | |---|---| | byte/mnemonic porting from a reference build, chained | 7503 EXACT + 2261 MNEM | | Hex-Rays thunk resolution (names the pipeline was discarding) | **7812** | | call-graph position, equal call counts + unique candidate + size agreement | 843 ASD, 150 HDCP, ~60 elsewhere | | dispatcher-anchored command handler naming | 200 | | request structures, one per component | 15 of 16 (TMZ needs none) | | internal structures (link cfg/caps, hdcp2 session, fmt state, auth msg, ...) | 12 | | arg1 / argN prototype rewriting | ~40 functions, thousands of lines | | offset-signature typing for unnamed functions | ASD 1, HDCP 3 | ## Levers measured and REJECTED — do not retry without new information | lever | measured | why rejected | |---|---|---| | address-order disambiguation of ambiguous bodies | ~88% monotonic | ~1 name in 8 would be wrong | | log-string naming (`AMD_TA_X: ENTER`) | 98 functions corpus-wide | ASD logs numeric codes, not names | | cross-module naming by exact body hash | 77 names | libnames clustering already covers the shared code | | cross-module naming by mnemonic sequence | 170 names, 1496 ambiguous | under 2% of what remains | | multi-reference call-graph naming (best corpus build as reference) | 1-4 per build, mismatch 15-35 vs 0-4 | handover names already propagated | | shared-cluster structures beyond `tee_fmt_state` | 3 clusters, single offset each, 56 derefs | a one-field structure buys nothing | | byte-buffer pointer typing | 117 derefs | too small | | typing locals that are plain copies of an argument | 380 derefs | largest case was a mistype (see below) | | typing locals whose offsets fit an existing structure | 282 derefs, **all false positives** | structures with catch-all arrays (`merit_req.body[8224]`, `ta_params.data20[6440]`) match any offset set; HDCP code would have been typed as `merit_req` | | POWERPLAY `fptbl*` local union | 248 derefs | union spanned 0x4..0x2074 with unaligned offsets, i.e. several distinct layouts conflated | ## The recurring failure mode, stated plainly Five consecutive passes each found a defect in earlier work rather than new ground, and **four of the five were silent** — a plausible number concealing a no-op: 1. Thunk names were being discarded and written out as `ANON`; 7812 names recovered by reading the name the decompiler already stated. 2. The rename-error counter read a `"result"` key that does not exist, so it reported zero failures unconditionally for the entire effort. 3. `dispatch_map` merged every switch at the shallowest depth, absorbing `0xFE0A`'s sub-command arms as phantom top-level commands 0x8/0x9/0xA/0xF. Caught by auditing names against the dispatcher, which initially looked like 11 bad names and was in fact one bad map. 4. Call-graph names lived only inside the `.i64` files. Four databases came back corrupted, and rebuilding one silently dropped GCP from 83% to 74%. Now persisted to `fw/cgnames/`. 5. The name-keyed arg1 path kept an inline copy of the prototype rewrite that did not strip comment lines, so on any commented function it produced a declaration IDA rejected — while reporting "12/16 applied", a number that looked like missing family members. It cost the single largest typing target in the corpus, `asd_program_link_by_signal` with 504 dereferences. The lesson that generalises: **verify the effect in the artifact, never the return value of the step**. Every number in the state table above is recomputed from `fw/decomp/` rather than accumulated by the tools that made it. ## Byte identity does NOT imply semantic identity: the SVC-thunk case The cross-module contradiction check -- identical bodies carrying different names -- found 32 cases, of which 21 were a hash placeholder coexisting with a real name and were resolved by upgrading the placeholder. Of the 12 that remain, **six are not contradictions at all**, and the reason invalidates the check's premise for a whole class of function. These bodies are identical: ```c int gp_cipher_update() { __asm { SVC 0xF2 }; return 1; } int gp_cipher_do_final() { __asm { SVC 0xF2 }; return 1; } int auc_sysdrv_rpc_10() { zero_fill(v5, 0x20); __asm { SVC 0xF2 }; return 1; } int fp_sysdrv_rpc_7() { zero_fill(v5, 0x20); __asm { SVC 0xF2 }; return 1; } ``` They are SVC trampolines into SYS_DRV. The operation performed is selected by registers the CALLER sets before the call, so the body carries no semantic information whatsoever. Two such thunks being byte-identical says nothing about whether they do the same thing, and the differing names (`gp_cipher_update` against `sysdrv_rpc_7`, `gp_digest_do_final` against `gp_mac_update`) are each describing what that thunk's own caller does. So these six are left alone deliberately. Renaming them to agree would destroy real information and assert a false equivalence. The lesson generalises: content-hash reasoning is sound for self-contained routines and unsound for argument-passing stubs, and every cross-module naming rule in this tree rests on the former. ### The remaining six | cluster | verdict | |---|---| | `bzero_core` / `lib_zero_fill_words` | same routine, two legitimate hand-made names; cosmetic | | `bzero` / `lib_zero_fill_bytes` | same | | `disp_get_link_status` / `asd_rpc_161` | both ASD's own; semantic name versus transport number | | `disp_srm_read` / `asd_rpc_300` | same | | `memcpy` / `TA_InvokeCommandEntryPoint__h1` | mis-split; `memcpy` is right | | `asd_rpc_200` / `TA_CreateEntryPoint__h2` | mis-split; `asd_rpc_200` is right | | `cmd03__h28` / `aeabi_uldivmod` | mis-split; FIXED -- `fp_cmd03__h28` renamed to `fp_aeabi_uldivmod` | | `entry_trampoline` / `ta_main_loop` | wrong word; FIXED -- 54 instances renamed to `ta_main_loop` | `entry_trampoline` was adjudicated on the body, which is `__noreturn` and reads ```c while (1) { __asm { SVC 0xF0 }; v6 = *a1; switch (v6) { ... } v7(); } ``` It yields to the OS, dispatches on the request code at `*a1`, calls the handler and loops forever. That is a main loop; a trampoline does not loop. DTM, RAS, HDCP and ASD already called the byte-identical function `ta_main_loop`, so the 54 instances under the other name were the outliers, not the majority view. ## Lever 14: the firmware's own log strings (ACCEPTED, and it corrected my work) GCP logs through strings of the form ``` AMD_TA_GCP: AllocateDRMSession ENTER AMD_TA_GCP: GetCryptoSessionForDevice ``` The leading identifier is **the vendor naming the enclosing function**. That is stronger evidence than anything else in this tree: porting infers from byte similarity, the call graph infers from position, the dispatcher infers from a jump table -- a string inside the body is the author's own label. `fw/ta_logname.py` harvests these from the binary, resolves the referencing function via IDA data xrefs, and proposes the name. Measured on GCP's 11 builds: **43 agreements** with names already derived, **70 functions named that were unnamed**, and **193 instances where the log contradicted an existing name**. The agreements matter as much as the corrections. `gcp_cmd01_AllocateDRMSession`, derived from the dispatcher table, is confirmed verbatim by the firmware's own log line -- the dispatcher-anchored naming method is sound, and this is the first independent check of it. ### Two mislabels of mine that the vendor strings caught | address | my name | vendor says | |---|---|---| | `0x403a90` | `cmdFE0F_GetCryptoSessionByDecodeHandle` | `GetCryptoSessionForDevice` | | `0x4038d8` | `cmdFE0A_QueryAccessibilityAttributes__h9` | `GetCryptoSessionByDecodeHandle` | So `GetCryptoSessionByDecodeHandle` was on the wrong function, and the function that really is `GetCryptoSessionByDecodeHandle` had been filed as a *fragment* of a different command. Also corrected: `cmdFE0A` is `QueryAuthenticatedSession`, not `QueryAccessibilityAttributes`, and nine `__hN` "fragments" are separate routines with their own names (`QueryChannelType`, `QueryCryptoSession`, `QueryDeviceHandle`, `QueryOutputID`, `QueryOutputIDCount`, ...). ### One subtlety worth recording A command id is preserved onto the vendor name only when the old name had no `__hN` suffix. That suffix means the id was inherited from a neighbouring function, never established for this address, so carrying it across would manufacture a false dispatcher mapping. ### Ceiling The ceiling claim had to be revised once already. It first rested entirely on the `AMD?TA` regex, which reported POWERPLAY as having no log strings -- in fact POWERPLAY logs `CreateEntry: status = 0x%x` and `InvokeCommand: status = 0x%x`, a second form that names the function BEFORE the colon. Re-checking the claim by dumping every message-like string from all 16 module binaries is what found it. That form yields 10 agreements and 0 contradictions on POWERPLAY, independently confirming its entry points. POWERPLAY also showed why a log name is not automatically the best name. Build 34.0.0.9 had `0x402570` unnamed, and the log calls it `CreateEntry`, but the other five builds name the same address `TA_CreateEntryPoint`. Applying the log spelling verbatim named one function two different things across builds, so short forms of the standard entry points are now aliased to the canonical spelling: the log confirms the identity, the canonical name states it. Only GCP carries substantial names. WSPE has 7 log strings but **all seven are prose**, not function names -- "Received command...", "running", "Request DOPP Active/Deactive". The first version of the harvester took the leading word and proposed `wspe_Received`, which would have clobbered the correct `TA_InvokeCommandEntryPoint`. Two guards now prevent that: an identifier must be multi-word CamelCase (two or more capitals, which every vendor name satisfies and no prose opener does), and the standard `TA_*EntryPoint` names are never overridden. After tightening, WSPE yields zero, which is the right answer. The other 14 modules have no such strings at all -- ASD contains just 41 printable runs, all property keys like `gpd.ta.appID`, so there is nothing to harvest. This lever is therefore exhausted at ~79 names, but they are the highest-confidence names in the tree. ## ASD porting is at its byte-identity ceiling (MEASURED, not assumed) ASD looked like the biggest remaining opportunity: its reference reaches 90.2% named with 1,439 EXACT names, while its 17 sibling builds get only 142-407 EXACT each and sit between 42.6% and 71.5%. A gap that large between a reference and its own siblings normally means porting is underperforming. It is not. The test: hash every named reference body of 16 bytes or more, then hash every *unnamed* body in a sibling and ask how many collide. | build | unnamed >=16B | byte-identical to a NAMED reference body | |---|---|---| | `asd_33.0.8.239_4c19d868` | 187 | **0** | | `asd_33.0.8.235_76e58ee4` | 189 | **0** | | `asd_33.0.16.144_aaa06ff8` | 367 | **1** | Zero, zero and one. Every function that *could* be named by byte identity already is; what remains genuinely differs from the reference at the byte level. The chain order is also correct -- builds are grown outward from the nearest already-processed neighbour, not ported from one distant reference -- so the remaining gap is real version drift across a span from 33.0.16.144 to 33.0.8.239, not a defect. This matters because it converts "ASD is structurally blocked", which was an assertion, into a measurement. The only ways past it are FUZZY below 0.90, which would be guessing, or per-function RE on roughly 250 functions per build. Worth noting the scale was also smaller than claimed: only 187-367 functions per ASD build are unnamed at 16 bytes or more, not the thousands implied by the raw unnamed count, because most of ASD's unnamed entries are 4-byte thunks. ## What remains, and why sweeps will not close it * **9,764 functions without a meaningful name.** RAS sits at 34% and is structurally blocked: no byte similarity to its reference, only 4-12 usable call-graph anchors, and it logs numeric codes. ASD's residue is 43% four-byte thunks -- not the "roughly 3,200 thunks" this file used to claim as if that were all of it -- and its porting is at the byte-identity ceiling, measured: 0, 0 and 1 of the unnamed bodies of 16 bytes or more in three sample builds are byte-identical to any named reference body. Per build only 187-367 functions are unnamed at 16 bytes or more. * **3,506 raw dereferences in named functions.** Measured per *(build, function)* pair the distribution is flat: median 2, mean 2.7, p90 4, max 33, across 1,360 pairs. The max is GCP's `start`, where raw pointer arithmetic is correct. The last concentrated target, `hdcp2_tx_RepeaterAuth_Stream_Manage` at 24 per build, is done. Both remainders need per-function reverse engineering. The honest next step is to pick a subset that matters — the GCP and HDCP command handlers carrying the recorded findings are the obvious candidate — rather than another corpus-wide pass.