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
|
# Hand-derived TA structures, and the evidence for each
**Status: every module covered for its host request, plus five internal structures.
Per-function typing remains open and is the bulk of what is left.**
15 of 16 components have a derived request structure; TMZ needs none (its invoke entry is
`return 0`). See the summary table below for which are closed against an enforced size and
which are not. This file records how
each one was obtained so a later reader can check it rather than trust it.
Only seven structs existed in this tree before this work (`fw/ta_ida.py`: five GCP, two
MERIT), so thirteen of sixteen components decompiled with every pointer typed `int`. The
prototypes recovered in `fw/decomp` fixed arity and pointer-ness but not domain types, because
none were ever recorded.
## Method
1. **Harvest** offsets and access widths with `fw/ta_struct_re.py`, which reads Hex-Rays
output rather than re-implementing register dataflow.
2. **Read the code by hand** at the sites the harvest points to. The harvest is a lead, not a
layout: see the caveats below for two ways its raw output misleads.
3. **Author** the struct in `fw/ta_ida.py`, with an expected size and member count.
4. **Read the layout back out of IDA** and compare against that expectation. A struct whose
parsed size does not match the size the firmware itself uses is wrong.
5. **Propagate** by re-running `fw/ta_re.py --type <ta>`, which declares types before applying
prototypes.
### Harvester caveats, both found the hard way
* **Array bases are invisible to a naive scan.** They are not written against the argument.
GCP computes `v6 = a1 + 80 * HIBYTE(v4)` and then reads `*(_DWORD *)(v6 + 768)`, so `0x300`
exists only as an offset on a *derived* pointer. One level of alias tracking is required,
and even then what is observed is `base + field`, never the bare base unless the field
offset is zero.
* **`a1[i]` is scaled by the pointer's declared element width.** Where Hex-Rays types an
argument `_BYTE *`, reported offsets are byte-scaled and will not line up with dword fields.
XGMI arg1 reports offsets `0x1/0x2/0x3` at 4-byte width from exactly this; they are not
fields.
The harvester is validated against GCP, whose layout is independently proven in
`AUDIT-NEW-TAS-RE.md`: it rediscovers `auth[16]` at `0x300` stride 80, `crypto[64]` at `0x800`
stride 88, and `asd_refcount` at `0x1E00`, from code alone, on a build with no types applied.
================================================================================
## `xgmi_req` — the XGMI host request [VERIFIED, build 32.0.0.20]
**Size `0xC20`. Declared in `fw/ta_ida.py` as `XGMI_DECLS`.**
### The mistake this avoided
The obvious move is to type a command handler's second argument, since handlers look like
`sub_403426(_BYTE *a1, int a2)` with `a2` checked for null and read at `+0` and `+8`. That
would have been **wrong**. The dispatcher `sub_402A1C` does not pass the host buffer:
```c
_DWORD v12[3]; // [sp+8h] [bp-C20h] BYREF
int v15[258]; // [sp+18h] [bp-C10h] BYREF
_QWORD v16[257]; // [sp+420h] [bp-808h] BYREF
sub_4011B0(v12, 0xC20u); // zero a 0xC20 stack image
v8 = *a4;
if ( *a4 == 0 || (unsigned int)a4[1] < 0xC20 ) // reject an undersized declared buffer
return -65536;
sub_4010C4(v12, (char *)v8, 0xC20u, v7); // copy the host page in
...
case 9: v11 = sub_4033A8(a1, (int)v16); // handlers get INTERIOR pointers
case 0xA: v11 = sub_403426(a1, (int)v15);
```
So a handler's `a2` is `v15` or `v16`, an interior pointer into a staging copy. Typing it as
the request base would have placed every field at the wrong offset.
### Derivation
Offsets are the decompiler's own stack slots, taken relative to the staging base `v12` at
`sp+8`:
| local | stack | offset in request | size |
|---|---|---|---|
| `v12` | `sp+0x08` | `0x000` | — (base) |
| `v13` | `sp+0x14` | `0x00C` | 1 |
| `v14` | `sp+0x15` | `0x00D` | 1 |
| `v15` | `sp+0x18` | `0x010` | `258 * 4 = 0x408` |
| `v16` | `sp+0x420` | `0x418` | `257 * 8 = 0x808` |
The layout **closes exactly**: `v15` spans `0x010..0x418`, `v16` begins at `0x418` and spans
to `0xC20` — the same size used by the zero-fill, the copy, and the size check. `v16`'s offset
`0x418` is 8-byte aligned, as its element type requires. Nothing is padded to make this work.
The staging image is **1:1 with the host page**, which is what makes these host offsets rather
than merely stack offsets: the writeback returns `v16` to its own offset in the host buffer,
```c
loc_40114C(v8 + 1048, v16, 2056); // 1048 = 0x418, 2056 = 0x808
```
and two scalars are written back individually at `v8+8` and `v8+13`, matching `status` at
`0x08` and `flag0D` at `0x0D`.
### Verification
IDA's parsed layout was read back and matched against the derivation, not assumed:
```
parsed size: 3104 (0xc20) EXPECT 0xC20 -> MATCH
cmd_id @ 0x0 unk04 @ 0x4 status @ 0x8
unk0C @ 0xc flag0D @ 0xd pad0E @ 0xe
argA @ 0x10 argB @ 0x418
```
Applied, the dispatcher decompiles as `if ( v12.cmd_id <= 0xB && ... )` where it previously
read `v12[0] <= 0xBu`.
### Host-reachability note
`cmd_id` is validated `<= 0xB` against a bitmask `0xB6F`, so commands `4`, `7` and `9` are
*not* in the accepted set despite having handlers — consistent with the reach table in
`AUDIT-NEW-TAS.md` rather than contradicting it. The request requires a declared buffer of at
least `0xC20`; a smaller one is refused with `-65536` before any copy.
### Scope: this struct is version-specific
Propagation deliberately **refuses** to apply it to the other three XGMI builds, and the
refusal is evidence rather than a failure:
| build | result |
|---|---|
| 32.0.0.20 | applied |
| 32.0.0.15 | dispatcher not recovered (no switch found within depth 2 of invoke) |
| 32.0.0.13 | no local with frame size `0xC20`; its dispatcher uses `0x414`/`0x400` |
| 32.0.0.2 | no local with frame size `0xC20`; uses `0x400` |
The older builds stage a *different and smaller* request, so forcing `xgmi_req` onto them
would misdescribe them. The staging local is located by frame size rather than by name
precisely so that a size change shows up as a refusal instead of a silent mistyping — Hex-Rays
numbers locals per build, so matching on `v12` would have applied the wrong layout happily.
================================================================================
## `oc_req` — the OC host request [VERIFIED, build 50.0.0.1]
**Size `0x1C`.** `oc_TA_InvokeCommandEntryPoint` is itself the dispatcher — there is no
switch, it chains `if`/`else if` on the command, which is why the switch-based dispatcher
recovery finds nothing for this component and the invoke entry has to be used directly.
```c
if ( (unsigned int)a4[1] < 0x1C ) return ...; // declared size floor
v5 = *a4; // the host buffer, used in place
v6 = *v5; // command at +0x00
if ( *v5 == 2 ) v7 = oc_cmd02(ctx: (char *)v5 + 22, ...); // +0x16
else if ( v6 == 1 ) v7 = oc_cmd01(ctx: v5 + 3, ...); // int* step -> +0x0C
else if ( v6 == 0 ) v7 = ...__h1(a1: (_BYTE *)v5 + 22); // +0x16
v5[1] = v8; // result written back at +0x04
```
`0x16 + 6 = 0x1C`, so the trailing block ends exactly at the validated size. Unlike XGMI there
is **no staging copy**: the handlers operate on the host buffer directly, so every offset here
is a host offset by construction.
Applied, the entry reads `if ( v5->cmd_id == 2 )` and Hex-Rays propagates the type outward to
`struct oc_req **a4`.
## `wspe_req` — the WSPE host request [VERIFIED, build 30.0.4.27]
**Size `0x14`.** Same shape as OC, same `if`/`else` dispatch: size floor `0x14`, `v5 = *a4`,
command at `*v5`, result written back to `v5[1]` (`+0x04`), and both recovered handlers —
`wspe_cmd04_request_dopp_active` and `wspe_cmd05_request_active_protected_session` — receive
`v5 + 4`, an `int *` step giving `+0x10`. `0x10 + 4 = 0x14`, closing the request exactly.
It applies to 2 of the 7 WSPE builds; the rest do not expose a local assigned from `*a4` in
the same shape and are left untyped rather than forced.
================================================================================
## A wrong signature this work introduced, and the lesson
Applying a prototype harvested from `fw/pseudo` **by name** can be wrong. OC's dispatcher
calls `oc_cmd01(ctx: v5 + 3, params: (struct ta_params *)7)` — the second argument is the
literal `7`, not a pointer, so the `struct ta_params *` prototype inherited from the pseudo
file misdescribes it. The cast in the decompiled output is the tell.
The general rule this suggests, not yet enforced: a prototype should be checked against the
arguments its call sites actually pass before being applied, and a scalar passed where the
prototype wants a pointer should block application rather than produce a confident lie.
================================================================================
## Command-id anchoring, and a mislabel it corrected
Matching prototypes to functions by name reached only 8 of GCP's 24, because the tree carries
two naming generations that **disagree about which command a handler serves**:
| source | name for the handler at `0x402f90` |
|---|---|
| `fw/pseudo` | `gcp_cmdFE0C_CreateCryptoSession` |
| `fw/libdumps` | `gcp_cmdFE0C_CreateAuthenticatedSession` |
The hardware findings settle it — `FINDING-GCP-ACQUIRE-GATE-BYPASS.md` records `0xFE0C` as
**CreateCryptoSession**, and `FINDING-GCP-FE0D-REFCOUNT-DESYNC.md` agrees — so the libdump
name is wrong and had propagated into every artifact.
Keying prototypes on the **command id** and resolving the address through the dispatcher
avoids the disagreement entirely: the dispatcher is the only authority on which function
serves which command. This raised GCP from 5-9 applied prototypes per build to 14-17, lifted
its struct-typed signatures from 28 to 172, and relabels the handler at `0x402e94` from
`gcp_cmdFE03_CreateAuthenticatedSession` to `gcp_cmdFE07_CreateAuthenticatedSession` on every
build — the single conflict the dispatcher cross-check had been reporting all along.
================================================================================
## Quality measured from the artifacts
Judged by reading `fw/decomp`, not by effort spent:
| metric | session start | now |
|---|---|---|
| struct-typed signatures | 309 / 10070 (3.1%) | 456 / 10070 (4.5%) |
| modules with a derived request struct | 0 | 3 (xgmi, oc, wspe) |
| modules with zero struct-typed signatures | 6 | 4 (dtm, hdcp, ras, tmz) |
The rate is still low and the reason is structural: ASD alone is 6763 of the 10070 functions
and has no recorded type information at all, so no amount of work on the small components
moves the global percentage much. ASD is where the remaining value is.
================================================================================
## Summary: all 16 components
A structure is only as good as its bound. These four groups are deliberately distinguished,
because a size that the firmware itself checks is evidence, and a size chosen to cover the
furthest offset seen is a convention.
### Closed against a size the firmware enforces (8)
| component | struct | size | the floor, in the firmware's own words |
|---|---|---|---|
| xgmi | `xgmi_req` | `0xC20` | `a4[1] < 0xC20` refused; zero-fill and copy use the same size |
| hdcp | `hdcp_req` | `0x2818` | dispatches only when `params[1] > 0x2817` |
| oc | `oc_req` | `0x1C` | `a4[1] < 0x1C` refused |
| wspe | `wspe_req` | `0x14` | `a4[1] < 0x14` refused |
| csu | `csu_req` | `0x24` | size below `0x24` refused |
| dtm | `dtm_req` | `0x50` | `*(a4 + 4) < 0x50` refused |
| merit | `merit_req` | `0x2024` | `a4[1] >= 0x2024` required |
| opm | `opm_req` | `0x3D54` | `*(a4 + 4) < 0x3D54` refused |
`opm_req`'s floor independently reproduces the `params_size >= 0x3D54` figure recorded in
`AUDIT-NEW-TAS-RE.md`, derived from the entry rather than taken from that note.
### Bounded by internal evidence rather than a size check (1)
| component | struct | size | why it still closes |
|---|---|---|---|
| rap | `rap_req` | `0x34` | the entry zero-fills exactly 24 bytes at `+0x18` and then stores at `+0x30`, so the layout closes without guesswork |
### Size is a maximum observed, NOT a bound (4)
| component | struct | size | what is actually known |
|---|---|---|---|
| asd | `asd_req` | `0x18` | smallest 4-aligned size covering every observed access (max is a byte at `+0x14`) |
| powerplay | `powerplay_req` | `0x84` | header hardware-corroborated; params extent is the furthest observed access |
| auc | `auc_req` | `0x3BC` | ctx bounded exactly at `+0x10..+0x298`; params extent only observed |
| fp | `fp_req` | `0x294` | interior pointers scale off a `_DWORD *`: `v4 + 3` is `+0x0C`, `v4 + 68` is `+0x110` |
### Pre-existing, and needing nothing (3)
`gcp` (five structures, proven in `AUDIT-NEW-TAS-RE.md`), `merit`'s slot/table pair, and `tmz`,
whose invoke entry is `return 0` -- confirming the "TMZ is a stub" note from the entry itself.
### The ack-word convention, and a negative that needed refining
POWERPLAY writes `command | 0x80000000` back at `+0x04`, and the host-page dump in
`AUDIT-NEW-TAS-RE.md` (`dw0-3: 0x4 0x80000004 0x415 0`) corroborates that on hardware. It
looked like a shared TA ABI worth hoisting into one declaration for all sixteen components, and
a scan of twelve entries for an ack at `+4` found none, so it was recorded as POWERPLAY-only.
That was too strong. RAP writes the same ack at `+0x08` and CSU at `+0x04`. The convention is
real and shared; only its OFFSET is component-specific. Hoisting a single header would still
have been wrong, but for a different reason than first stated.
### Inherited prototypes that misdescribe their call sites
Applying a `fw/pseudo` prototype by name can assert a type the call site contradicts. Two
confirmed cases, both left recorded rather than quietly applied:
* OC: `oc_cmd01(ctx: v5 + 3, params: (struct ta_params *)7)` -- the second argument is the
literal `7`.
* CSU: `csu_cmdNN(ctx: *a4, params: v5)` where `v5` is `a4[1]`, the declared **size**, an
integer rather than a pointer.
In both the cast in the decompiled output is the tell. A prototype should be checked against
what its call sites pass before being applied; that check is not yet implemented.
================================================================================
## Internal structures (beyond the host requests)
| struct | size | reached as | basis |
|---|---|---|---|
| `tee_fmt_state` | `0x28` | a1 of the shared runtime helpers | byte-identical cluster across 5 modules; RAS names them `vsnprintf_*` |
| `asd_link_cfg` | `0xB8` | a1 of `asd_program_link_by_signal*`, `asd_encoder_enable_seq*`, `asd_clear_link_outputs` | union across 18 builds; contiguous dwords `0x04..0xB4` |
| `asd_link_caps` | `0x28` | a1 of `asd_get_link_caps_full*`, `asd_resolve_asic_handlers`, `asd_dio_read_block` | contiguous `0x04..0x24` |
| `hdcp2_session` | `0x26C` | a1 of the `hdcp2_rx_*` / `hdcp2_tx_*` handlers | union of rx **and** tx handlers; revised twice |
| `gcp_access_attrs` | `0x60` | a1 of `gcp_cmdFE0A_QueryAccessibilityAttributes__h8/__h10` | byte runs `0x00..0x07`, `0x40..0x43`, `0x5C..0x5F`, 8-byte read at `0x20` |
### Three ways a structure derived from a subset of its users came out wrong
This is the single most repeated failure of this work, and read-back verification caught none of
them -- it confirms that IDA parsed what was declared, never that what was declared was complete.
1. **`asd_link_cfg` was half its size.** Derived from the reference build, where the relevant
functions never touch `0x10`, `0x1C`, `0x3C` or `0x50`, so those were declared as unused gaps
and the structure stopped at `0x58`. Larger builds carry fuller versions of the same functions
-- one is 6102 bytes with 692 dereferences -- and they touch every dword to `0xB4` with no gap.
2. **`hdcp2_session` was first refused entirely.** The three heaviest handlers were compared by
INTERSECTION, which found two common offsets and suggested three unrelated message layouts.
Intersection is the wrong test for a shared context: it shrinks as handlers are added. The
union gives 56 offsets and a coherent structure.
3. **`hdcp2_session` was then wrong in two places.** Built from the rx handlers alone, it had byte
padding at `0x2C` where the tx handlers read a dword, and a byte run at `0x246..0x24C` buried
inside `pad235`.
The guard that works is widening the evidence base and re-deriving; the guard that does not work
is verifying the declaration against itself.
### Checks that found nothing, and are worth knowing are weak
* **Truncation audit over all declared structures**: compares the maximum offset still reached by
a raw argument dereference, across every build, against each declared size. Nothing exceeds its
bound. But this check could not have caught `asd_link_cfg` either, because the functions that
overran it were unnamed and therefore untyped, with no field accesses to compare against. A
clean result here is weaker evidence than it appears.
* Two apparent anomalies in that audit -- `gcp_ctx` at `0xC` and `merit_table` at `0x0` -- are
defects in the audit script, which does not account for nested structure arrays, not in the
structures.
### Typing without naming, and its controls
Where the heaviest holders of raw dereferences are UNNAMED functions, no name-keyed rule reaches
them, and guessing a name would assert an identity the evidence does not support. Typing only
claims the pointer's shape, so it is applied on an offset signature instead, with the specificity
of the signature checked rather than assumed:
* ASD: a function qualifies only if its a1 offsets cover all fifteen core link offsets at once.
On the reference's 1440 functions this selects exactly one, with zero false positives.
* `tee_fmt_state`: the four-offset signature is too weak alone, so SIZE is required as a second
discriminator -- a candidate must match the byte length of a confirmed cluster member. 86 of 88
candidates satisfy both.
* HDCP: selects 3 unnamed functions, 135 dereferences, zero false positives among the named.
### A mistyping this avoided
`hdcp2_tx_RepeaterAuth_Stream_Manage` holds 164 dereferences through a local, `v10`, which is a
direct copy of `a1` -- so typing `a1` would propagate to it, and it was briefly treated as a
`hdcp2_session` user. Checking its offsets first showed `0x1`, `0x2`, `0x3`, `0xD`, `0xF9`, `0x106`,
which land in that structure's PADDING. It is a message byte buffer, not the session context, and
typing it would have misdescribed 164 lines.
================================================================================
## Quality, measured from the artifacts
| metric | at the start | now |
|---|---|---|
| typed field accesses | 0 | **14020** |
| raw pointer-arithmetic dereferences | ~19000 | **6756** |
| field share, named functions only | ~0% | **75.6%** |
| field share, including unnamed bodies | ~0% | **67.5%** |
| struct-typed signatures | 309 / 10070 (3.1%) | **642+ / 10088 (6.4%)** |
| modules with a request structure | 0 | **15 / 16** (TMZ needs none) |
**The two field-share rows are a correction.** Every figure reported during this work came from
globbing `fw/decomp/*.c`, which also matches the `*.anon.c` files holding the bodies of functions
that have no name. Unnamed functions are mostly untyped by construction, so including them dragged
the headline down: the measurement was of the whole corpus when the claim being made was about the
readable, named artifacts. Measured correctly, the named decompilation is at **74.5%** (10,708
typed field accesses against 3,661 raw dereferences), and the unnamed bodies sit at 51.7%, which
is higher than expected and shows argument typing reaches them too.
Both numbers are now stated because both are true of something, but the named-only figure is the
one that answers "are the artifacts readable".
The signature percentage badly understates the change and is kept only for continuity: typing one
dispatcher or one argument rewrites hundreds of lines while moving the signature count by one or
two. The field share is the measure that tracks readability.
### What remains, and why it is not another pass of this method
### The per-function residue, measured per build
An earlier version of this table summed each function's dereferences across all 17 builds, which
made the residue look concentrated. Measured per *(build, function)* pair, which is what a reader
actually opens, the distribution is flat: **median 2, mean 2.7, p90 4, max 33**, spread over 1,360
pairs, and half of all raw dereferences sit across the top 257 of them. The max of 33 is GCP's
`start`, the entry stub, where raw pointer arithmetic is correct and typing would be wrong.
So there is no large struct-typing lever left. The one real target the measurement did surface was
`hdcp2_tx_RepeaterAuth_Stream_Manage` at 24 per build, and it is now done -- see below. After it,
the largest remaining named holder is `asd_topology_update` at 24, then a long flat tail.
### hdcp2_tx_RepeaterAuth_Stream_Manage: a wrong note cost this one for a long time
This handler was excluded from `hdcp2_session` with the recorded reason that its dereferences "go
through a local (v10), not a1, so typing a1 would not reach them". The body says `v10 = a1`. The
note was simply wrong, and because it read as a settled finding it was never rechecked.
The real disqualifier was the documented padding rule: offsets `0x1-0x3`, `0xD`, `0xF9`, `0x106` and
`0x250` all landed inside declared `pad` arrays. Per union-never-intersection those offsets belong in
the layout, so `pad00`, `pad08`, `pad55` and `pad24E` were split to name them. The struct size is
unchanged at `0x26C`, and all ten observed offsets now resolve to named fields.
Result in the artifacts: **24 raw dereferences per build to 1**, with 22 typed field accesses, across
all 7 HDCP builds. Corpus named-only field share 74.5% to 75.6%.
The rest of the residue is dominated by:
* ASD's long tail inside UNNAMED functions -- a naming problem, not a typing one;
* accesses through LOCALS rather than arguments (GCP's `start` has 310 on one local), which
`retype_arg1` cannot reach and which need `set_lvar_type` per function per build;
* byte-buffer accesses, where naming a region adds little readability.
Locals that are plain copies of an argument were measured as a possible rule: 380 dereferences in
total, and the largest single case is the `Stream_Manage` mistype described above. That is not a
multiplier.
================================================================================
## Not yet derived
`xgmi_ctx` (the `_BYTE *` first argument; only the byte flag at `+0` is confirmed, and the
harvested arg1 offsets are contaminated by the `a1[i]` scaling caveat above), and all of ASD,
HDCP, RAS, DTM, OPM, POWERPLAY, WSPE, AUC, CSU, FP, OC, RAP, TMZ.
ASD is the largest prize — 1439 named functions and 681 recorded comments — and the one place
a context structure would pay for itself most, but its 6800 prototypes are almost entirely
primitive-typed, so there is no recorded type information to start from.
|