aboutsummaryrefslogtreecommitdiffstats
path: root/fw/AUDIT-TA-NAMING.md
blob: ee07a4cdb2905213cd3b6fd89610fd594d649806 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
# 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_<addr>` thunk names, and 87
had targets that by then carried a real name: the `j_<target>` 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_<addr>` 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_<target>`. 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_<addr>` 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_<target>`
  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_<addr>` 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 `<ta>_rpc_<hex>`, 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_<target>` | 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: <Name> 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.