aboutsummaryrefslogtreecommitdiffstats
path: root/f32/AUDIT-SDMA-01.md
blob: 5c8889f17b0a256eae4ca8054540529e22ff81f1 (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
# SDMA (gfx11) — first audit pass over the host-packet surface

Evidence class **[ST]**. Image `psp_out/sdma_6_0_0.bin`, thread TH0. Tool:
`sdma_audit.py` (`table` / `screen` / `gates` / `hand`), on top of the F32 disassembler
made usable in `AUDIT-F32-01.md`.

Why SDMA: it executes packets the driver writes into rings in host-visible memory, so
it has the same surface shape as the DMCUB inbox (FINDING-1/2) and the DCN4 LSDMA
command (FINDING-9) — **and it is loadable on this card** (`SDMA_UCODE_TH0/TH1` are 2
of the 37 accepted `LOAD_IP_FW` types), so anything found here is testable on silicon
we actually have, unlike FINDING-9.

## The packet dispatch table, recovered and validated

The last `0x300` bytes of TH0 are the dispatch table: entries are
`(key << 16) | target` with **`key = (op << 8) | (sub_op << 4)`**. That decoding is not
a guess — it is checked against `navi10_sdma_pkt_open.h`:

```
0x0100 COPY/LINEAR      0x0140 COPY/LINEAR_SUB_WIND   0x0400 INDIRECT
0x0110 COPY/TILED       0x0170 COPY/DIRTY_PAGE        0x0500 FENCE
0x0130 COPY/SOA         0x0180 COPY/LINEAR_PHY        0x0600 TRAP
0x0c00 PTEPDE/GEN       0x0e00 SRBM_WRITE             0x1000 GPUVM_INV
```

Every documented opcode and sub-opcode lands where the enum says it should, which is
what makes the table trustworthy. **52 distinct packets** are implemented; entries
beyond `0x1110` point at the common stub `0x0e0a` and are padding.

**The taint source is exact.** Upstream's README: *"r1 is a magic register that pulls
dwords off of the command queue when read"*. So `mov rX, r1` is literally "consume the
next host-supplied dword", which gives a precise, non-heuristic marker for host data —
better than the pointer-tainting model used for DMCUB.

## SRBM_WRITE is gated, and the gate is legible

`SRBM_WRITE` (`0x0e00`) is the packet that writes a host-named register — the exact
shape of DMCUB's by-design register interface. It is **not** unguarded:

```
0b00  ldw r5, reg[r0, #0x4a14] ; and r5, r5, #0x200 ; lsr r5, r5, #9   ; bit 9
0b04  ldw r5, reg[r0, #0x5968] ; lsr r5, r5, #31                        ; OR bit 31
0b07  ldw r5, reg[r0, #0x5940] ; lsr r5, r5, #23 ; and r5, r5, #0x1     ; AND bit 23
0b0a  and r5, r6, r5
0b0e  mov r11, r1              ; host dword: register address
0b0f  mov r6,  r1              ; host dword: value
0b10  cbz r5, _PKT_0xe00_2     ; gate fails -> reject
0b11  lsr r5, r11, #20         ; aperture bits of the address
0b16  bl _DMA_DATA_39          ; perform the write
```

The reject path raises an error rather than falling through:

```
_PKT_0xe00_2: mov r13, #0x3c ; mov r14, #0x32 ; bl _PKT_0xf00_26
```

`SRBM_WRITE/sub1` (`0x0e10`) carries the same gate. So the obvious "arbitrary register
write from a ring packet" is **closed** on this firmware.

## Gate map — and a wrong measurement I had to throw away

The first cut asked a binary question: "does the handler contain the SRBM_WRITE gate
idiom (reg `0x4a14` bit 9)?" It produced a headline-looking result — *PTEPDE, the
packet that writes page-table entries, is not gated* — which is **false**. PTEPDE gates
on a different register:

```
09c4  ldw r5, reg[r0, #0x5a6c] ; and r5, r5, #0x101 ; seteq r5, r5, #0x101
09c7  cbz r5, _PKT_0xc00_0
```

So different packet classes consult different control registers, and a single-idiom
test cannot answer the question. `sdma_audit.py gates` now reports the **map** instead
of a verdict:

| register | handlers | note |
| --- | --- | --- |
| `0x4a14`, `0x4af8`, `0x4b58` | 49 | shared epilogue — reachable from nearly everything, **not** a per-packet gate |
| `0x49f4` | 15 | |
| `0x5940` | 14 | |
| `0x5968` | 7 | part of the SRBM_WRITE gate |
| `0x5a6c` | 5 | PTEPDE's gate |
| `0x5980`, `0x5970`, `0x49f0` | 2–3 | |

The 49-handler entries are a **confound from the analysis**, not a property of the
firmware: the worklist walk runs into the common exit path. Only registers consulted by
a *subset* of packets carry information.

## Unpublished opcodes — checked, benign

`op3` with sub-ops 1..6 (`0x0310..0x0360`) is absent from the kernel enum, which made it
the most promising lead by analogy with DMCUB's unpublished types 1/3/4 (those became
FINDING-10). It is not interesting: each handler just drains host dwords and rejoins the
main loop.

```
PKT_0x310:  mov r3, r1 ; mov r3, r1 ; ... (8x) ; b _PKT_0xf00_2
PKT_0x320:  b _PKT_0x310_0
```

These are "swallow N dwords" stubs for reserved opcodes. `op35/sub2`, `op119` and the
`op126` block decode as padding entries pointing at the invalid-packet stub.

## [HW] The gate is RB_PRIV — named by behaviour, confirmed on this card

Script `hw/sdma_srbm_probe.py`, evidence `hw/EVIDENCE-sdma-srbm-gate.txt`.

**Naming it statically failed, and that is recorded as a negative:** fitting the F32
index space against `gc_11_0_0_offset.h` gives a best constant base scoring **25/54**
observed indices against a null 95th percentile of **24** — indistinguishable from
chance over a table of 848 dense SDMA registers. The earlier "0x5968 = SDMA0_F32_CNTL"
style matches were curve-fitting, and are withdrawn.

What identified it was the bit position. `SDMA*_QUEUE*_RB_CNTL__RB_PRIV` is mask
`0x00800000` = **bit 23**, and the gate's AND-term is exactly
`ldw r5, reg[r0, #0x5940] ; lsr r5, #23 ; and r5, #1`. That is a testable prediction,
so it was tested:

```
=== RB_PRIV = 1
  control: NOP packet consumed=True (0.1 ms)
  SRBM_WRITE consumed=True -> regGRBM_SCRATCH_REG0 = 0x5100d00d   WROTE
=== RB_PRIV = 0
  control: NOP packet consumed=True (0.1 ms)
  SRBM_WRITE consumed=True -> regGRBM_SCRATCH_REG0 = 0x00000000   not written
```

Two things make this clean rather than suggestive:

* **The NOP control still retires with RB_PRIV=0.** A queue configured from scratch
  with `RB_PRIV=0` does not fetch at all — the ring fetch is itself privileged — so
  "nothing happened" would have proved nothing. The experiment therefore brings the
  queue up privileged and then clears **only** `RB_PRIV` on the live queue. The ring
  keeps running (NOP retires in 0.1 ms) while SRBM_WRITE stops taking effect, which
  isolates the difference to that one bit.
* **The rejected packet is still consumed** (`rptr` advances, queue keeps running,
  `CONTEXT_STATUS = 0x804`), i.e. the firmware drops it silently rather than wedging —
  consistent with the `_PKT_0xe00_2` error path rather than a hang.

**Conclusions.**

1. **F32 register index `0x5940` is the current queue's `RB_CNTL`.** This is the first
   F32 register named in this tree, and it is named semantically rather than by offset
   arithmetic — the same standard `dmcub_regmap.py` used for DMCUB.
2. **[HW] An SDMA ring packet can write an arbitrary register** — `SRBM_WRITE` with a
   host-chosen absolute index landed `0x5100d00d` in `GRBM_SCRATCH_REG0`.
3. **The confinement holds.** That power is available only to queues the driver marks
   privileged. This is a **confirmed negative**, and a valuable one: had the gate been
   missing, an unprivileged user queue writing arbitrary registers would have been a
   genuine user→kernel escalation, which is a stronger boundary than anything DMCUB
   offers (its inbox is driver-only by construction).

The method generalises: any F32 register whose bits gate observable behaviour can be
named this way, which is the practical route to the register namespace that offset
matching could not deliver.

## [HW] PTEPDE is *not* privilege-gated — and why that is correct, not a bug

Scripts `hw/sdma_ptepde_probe.py`, evidence `hw/EVIDENCE-sdma-ptepde.txt`.

First, a correction to this document's own earlier text. It said "PTEPDE gates on reg
0x5a6c bits 0x101". **That is wrong.** The branch rejects nothing: with the bits set it
applies an extra address/value fixup (two `_DMA_DATA_6` calls), and both paths fall
into the same PTE write at `_PKT_0xc00_0`. It selects an *implementation*, not a
permission. So PTEPDE has **no privilege check at all** — a testable prediction.

Tested with the same live-flip method, on the queue where SRBM_WRITE is refused:

```
RB_PRIV = 1   NOP ok   PTEPDE -> buffer WRITTEN   entry[0]=0xaaaa0000bbbb0000
                                                  entry[1]=0xaaaa0000bbbb1000  (+incr)
RB_PRIV = 0   NOP ok   PTEPDE -> buffer WRITTEN   entry[0]=0xcccc0000dddd0000
                                                  entry[1]=0xcccc0000dddd1000
```

The entries increment by `incr` exactly as the ABI specifies, so the packet genuinely
executed. **PTEPDE writes page-table entries from an unprivileged queue.**

That sounds alarming, so the follow-up decides whether it matters: is the destination
physical, or translated? On this card `mc = paddr + 0x8000000000`, so the same buffer
can be addressed both ways:

```
pe = MC address     0x8001603000  -> buf[0] = 0x1111000022220000   HIT
pe = raw physical   0x0001603000  -> buf[0] = 0x0000000000000000   -
GCVM_L2_PROTECTION_FAULT_STATUS: walker_error=1, mapping_error=1, rw=1, vmid=0, addr 0x1603000
```

The raw physical address raises a **GCVM L2 protection fault** and writes nothing. So
PTEPDE's destination goes through the queue's VMID page tables like any other SDMA
memory access, and the queue survived both (a NOP still retires afterwards).

**The trust model is therefore coherent, and this is a confirmed negative:**

| packet class | confined by | gate needed? |
| --- | --- | --- |
| `SRBM_WRITE` (register access) | nothing — the VM cannot confine an MMIO write | **yes**, and it has one (`RB_PRIV`) [HW] |
| `PTEPDE`, `COPY`, `WRITE` (memory access) | the queue's VMID page tables [HW] | no, and it has none |

A user queue issuing PTEPDE can only write pages already mapped into its own VM, which
is the same reach its ordinary `WRITE`/`COPY` packets have. The asymmetry between the
two packets is exactly what a correct design looks like, not an oversight.

## A lead that looked like a finding, and died on hardware

Worth recording in full, because the static evidence was persuasive.

Screening for host data reaching a *register index* (rather than a DMA address) found
exactly one candidate outside the gated SRBM_WRITE path, in
`POLL_REGMEM/POLL_MEM_VERIFY` (key 0x0830):

```
0785  mov r5, r1            ; 12th host dword
0787  cbz r7, ...           ; r7 = header bit 31 = the ABI's `mode` field
0792  ldw r11, mem[r0, #0]
0794  bl  _PKT_0xf00_44
079c  bl  _DMA_DATA_36
079e  stw r11, reg[r5, #0]  ; register index from a host dword?
```

And `SDMA_PKT_POLL_MEM_VERIFY_RESERVED_reserved_offset` is **12** — the field the
published ABI calls *reserved* appeared to be consumed as a register number, in a
handler with no privilege check. That would have been an ungated arbitrary register
write from an unprivileged queue, i.e. a real user→kernel primitive.

**Hardware says no.** Aimed at F32 index `0x5940` (the queue's `RB_CNTL` — the one index
we have named [HW], and one the host can read back), with `mode=1` so the path is
taken:

```
RB_PRIV=1 mode=1   consumed=True  -> RB_CNTL 0x00841015 -> 0x00841015  unchanged
RB_PRIV=0 mode=1   consumed=True  -> RB_CNTL 0x00041015 -> 0x00041015  unchanged
```

**Root cause of the bad lead: the dataflow was not call-clobber aware.** There are two
`bl` calls between `mov r5, r1` and the store, and F32 helpers clobber scratch
registers, so `r5` at `0x079e` is not the host dword — it is whatever the callee left
behind. This is precisely the flaw called out in `../mes/AUDIT-MES-02-API.md`
("absence of evidence is weak because the pass is not call-clobber aware"), reproduced
here in the other direction: it manufactured a *positive* that was not real.

Two process notes worth keeping:

* The **first** version of this test aimed at `regGRBM_SCRATCH_REG0`'s *host* index
  (0x2040). `reg[r5]` indexes the F32 namespace, which does not align with the host's,
  so that run's null result was uninformative — a miss would have been indistinguishable
  from a wrong target. Re-aiming at the one F32 index we have actually identified is
  what made the negative meaningful.
* `POLL_MEM_VERIFY` with `mode=0` **wedges the queue** (`consumed=False`, and the queue
  stays dead): the packet polls until its comparison succeeds, by design. That is a
  self-inflicted stall by the queue's own owner, and SDMA's watchdog
  (`SDMA_WATCHDOG_CNTL.queue_hang_count`, which the driver sets to ~10 s) is the
  intended backstop — noted as behaviour, not claimed as a finding.

## Call-clobber-aware taint: one sink in all of TH0, and it is gated [HW]

The screening pass was rebuilt to be call-clobber aware (`sdma_audit.py taint`):
per-callee clobber sets are computed over the call graph to a fixpoint, and a host
taint is killed across any `bl` that may define the register. Re-running it over all 52
packets:

* the `POLL_MEM_VERIFY` false lead **disappears**, which is the control that the new
  pass actually fixes the flaw that produced it;
* **exactly one** sink remains in the whole of TH0 — `0x0657 ldw r10, unk[r5]` in
  `POLL_REGMEM/POLL_REG_WRITE_MEM`, where the host dword is the *address of a register
  to read*.

That handler is the read counterpart of SRBM_WRITE: it reads the register the packet
names and DMAs the value to a host-supplied address. The address convention is known
rather than guessed — amdgpu's `sdma_v6_0_ring_emit_reg_wait` passes `reg << 2` — so
the test can predict the exact expected value:

```
RB_PRIV=1   host wrote 0xfeedface -> memory got 0xfeedface   LEAKED
RB_PRIV=0   host wrote 0xfeed0531 -> memory got 0x00000000   nothing
```

**It is gated.** Register *reads* require `RB_PRIV` just as register *writes* do, so the
trust model is uniform and there is no read/write asymmetry to exploit.

### That result invalidates my own gate map as a security indicator

`cmd_gates` said this packet does **not** contain the `0x4a14`-bit-9 idiom, yet hardware
shows it is privilege-gated anyway — the enforcement lives somewhere the static screen
does not see (a shared helper such as `_PKT_0xf00_50`, or the `unk[]` aperture access
being checked in hardware). So **the gate map under-reports gating and must not be read
as "these packets are unprivileged"**. Only the hardware test is authoritative. The
same caveat now sits on the map itself.

### Standing scoreboard for TH0's host surface

| capability | confinement | evidence |
| --- | --- | --- |
| register write (`SRBM_WRITE`) | `RB_PRIV` | [HW] `sdma_srbm_probe.py` |
| register read (`POLL_REG_WRITE_MEM`) | `RB_PRIV` | [HW] `sdma_regread_probe.py` |
| memory write (`PTEPDE`, `COPY`, `WRITE`) | queue VMID page tables | [HW] `sdma_ptepde_probe.py` |
| host data → register index / memory base | 1 site, and it is the gated read above | [ST] call-clobber-aware taint |

**No primitive was found in TH0.** Each capability that escapes VM confinement is
privilege-gated, and the one remaining taint sink is one of them.

## TH1 (the "ctl" thread) — examined, no host-controlled register index

TH1 is a different animal from TH0 and the difference shows up immediately:

| | TH0 | TH1 |
| --- | --- | --- |
| reads of `r1` (host command queue) | 370 | **11** |
| packet dispatch table | 52 packets, validated | **none** |
| computed register accesses (`reg[rN]`, N≠0) | 5 | **52** |

The tail of TH1 is **not** a dispatch table. `f32dis` assumes the trailing page is a CP
packet jump table and duly prints 64 entries, but the keys are garbage (`0xd411`,
`0x6967`, `0x86f7`…) rather than `(op<<8)|(sub_op<<4)` values — an artefact of the
CP-oriented tool, not a structure in the firmware. So **TH1 does not process ring
packets**; it is the queue/context control thread.

Its interesting feature is those 52 computed register accesses (40 reads, 12 writes).
Root-cause tracing of every index:

```
const                       48   firmware-chosen register blocks
from caller                   2
stw r7, [r0, #0x6b]           1
stw r3, reg[r0, #0x5978]      1
```

The two "from caller" sites are a register-programming helper (`_PKT_0xc3a5_33`):

```
0521  mov r5, r1              ; value = host dword
0522  seteq r6, r3, #0x4ba8   ; five indices special-cased first ...
052a  seteq r6, r3, #0x4bb0   ; (0x4ba8, 0x4bb0, 0x4bb4, 0x4bb8, 0x4bbc)
052c  stw r5, reg[r3, #0x0]   ; ... default: program the register
```

Host data supplies the **value**, so the question is whether it also supplies the
**index**. It does not. Every caller computes it:

```
mov r3, #0x4b80 ; add r3, r3, r10      ; r10 = loop variable
mov r5, #0x4b80 ; mov r6, #0x4ce0 ; sub r6, r6, r5   ; stride = 0x160
mul r5, r6, #0x8 ; setge r6, r10, r5   ; bounded to 8 iterations
```

i.e. `r3 = 0x4b80 + queue * 0x160`, bounded by an 8-iteration loop. This is queue
context restore: the firmware walks the eight queues' register blocks and reloads their
saved state. **The index is firmware-computed and bounded; only the values are
host-supplied, which is the whole point of restoring a queue's context.** No primitive.

Two useful by-products:

* **Structural fact for the register namespace:** the per-queue register block in F32
  index space is **base `0x4b80`, stride `0x160`, 8 queues** (`0x4b80..0x5460`). That is
  independent of, and consistent with, the `0x5940 = RB_CNTL` identification from
  hardware, which lies outside that range — so `0x5940` is a *current-queue* alias
  window rather than a per-queue slot.
* Five queue registers (`0x4ba8`, `0x4bb0`, `0x4bb4`, `0x4bb8`, `0x4bbc`) are **not**
  restored directly; they are diverted into internal shadow slots. Worth naming later —
  a register the firmware refuses to restore verbatim is exactly where a restore-stream
  attack would aim, if the stream were ever attacker-controlled.

## The F32 register namespace, solved

Naming registers by fitting a constant delta against `gc_11_0_0_offset.h` failed
earlier at chance level (25/54 vs a null 95th percentile of 24). The namespace fell out
of *structure* instead, from two independent anchors:

* TH1 walks the eight per-queue register blocks with F32 base `0x4b80`, **stride 0x160**
  (recovered above from its bounded 8-iteration loop);
* the host per-queue stride is **0x58 dwords** (`regSDMA0_QUEUE{n}_RB_CNTL` =
  0x0080, 0x00d8, 0x0130, …).

`0x160 / 0x58 = 4` exactly, so F32 addresses registers by **byte** offset:

```
    F32_index = 4 * host_dword_offset + 0x4980
```

**Validation, against a null model:** 31 of the 54 observed indices resolve to a named
`regSDMA0_*` register, where randomly drawn indices over the same range score a median
of 5, a 95th percentile of 9 and a **maximum of 14** over 400 trials. The 4-alignment
requirement alone rejects 3 in 4 candidates, and the surviving names are semantically
right for a DMA engine's inner loop:

| F32 index | register | uses |
| --- | --- | --- |
| `0x4a14` | `regSDMA0_STATUS_REG` | 45 |
| `0x49f4` | `regSDMA0_CHICKEN_BITS` | 16 |
| `0x4a60` | `regSDMA0_STATUS2_REG` | 12 |
| `0x4b58` | `regSDMA0_STATUS4_REG` | common path |
| `0x49f0` | `regSDMA0_CNTL` | 6 |
| `0x4a70` | `regSDMA0_UTCL1_CNTL` | 4 |

`sdma_audit.py gates` now prints names instead of bare indices.

### The gate registers are *unpublished*

The indices that stayed unnamed cluster tightly: `0x5940`, `0x594c`, `0x5950`, `0x5968`,
`0x5970`, `0x5980`, `0x5a6c` map to host dword offsets **0x3f0–0x43b**, which lie inside
the SDMA0 aperture but have **no definition anywhere in AMD's header**. So the
SRBM_WRITE gate (`0x5940` bit 23, `0x5968` bit 31) and PTEPDE's implementation switch
(`0x5a6c` bits 0/8) live in registers AMD does not publish.

That also resolves an apparent inconsistency. Hardware proved `0x5940`'s bit 23 behaves
exactly as `RB_CNTL.RB_PRIV`, yet under the formula `0x5940` is **not** any queue's
`RB_CNTL` (those are `0x4b80 + q*0x160`). Both facts hold if `0x3f0` is a
**current-queue context shadow**: when SDMA switches to a queue it copies that queue's
`RB_CNTL` into an unpublished context register, and the firmware gates on the shadow.
That is a hypothesis consistent with all the evidence, not a measurement — the
behavioural result (flipping `RB_PRIV` flips SRBM_WRITE) stands on its own either way.

## What this pass does *not* establish

* **No finding.** This is reconnaissance: the table, the taint source, and the gating
  structure. No memory-safety bug has been demonstrated in any handler.
* **Most of the register namespace is still unmapped** (`AUDIT-F32-01.md`). `0x5940` is
  named [HW]; `0x4a14 bit 9`, `0x5968 bit 31` (the gate's OR-term) and `0x5a6c bits 0/8`
  (PTEPDE's implementation switch) are not.
* Handler analysis is a bounded worklist walk (250 instructions, follows branch
  targets), an under-approximation, not a closure — **and it is not call-clobber
  aware**, which already produced one false lead (above). Any future "host data reaches
  X" claim from this tooling must be checked across `bl` boundaries before being
  believed.
* TH1 is now examined at the same level as TH0 (above), but its *callers* were traced
  only for the register-programming helper; the rest of its control flow, and what
  drives its 11 `r1` reads, are not mapped.
* 2.6% of TH0 words still decode as unknown (CI-era opcode table), so a handler could
  do something the listing does not show.

## Next steps, in order of value

1. ~~Name the gate registers~~ / ~~test the gate on hardware~~ — **both done above**
   for the AND-term (`0x5940` = `RB_CNTL`, bit 23 = `RB_PRIV`). Still unnamed: the
   OR-term (`0x4a14` bit 9, `0x5968` bit 31) and PTEPDE's `0x5a6c` bits 0/8, each of
   which is now a concrete behavioural experiment of the same shape.
2. Audit the copy/fill handlers (`COPY/*`, `CONST_FILL`, `WRITE/*`) for length and
   address handling, which is where an SDMA memory-safety bug would live.
4. Map TH1's table the same way.