# psp_emu — Unicorn-based emulator for AMD PSP trusted applications Emulates the PSP TAs (HDCP, ASD, DTM, SYS_DRV) and the RPC edges between them, so handler behaviour can be measured directly instead of inferred from a hardware crash oracle that costs a PCIe retrain per data point. ## Setup ```bash python3 -m venv psp_emu/.venv psp_emu/.venv/bin/pip install -r psp_emu/requirements.txt psp_emu/.venv/bin/python psp_emu/tests/test_smoke.py # must be all PASS psp_emu/.venv/bin/python psp_emu/harness/hdcp_subcmd12.py ``` Images are read from `../psp_out/` (`elf/*.elf`, falling back to `*_full.bin`). ## Runtimes and the knobs that control them | harness | runtime | what it proves | | --- | --- | --- | | `tests/test_smoke.py` | <1s | images load, ARM/Thumb voting, leaf routines | | `harness/ccp_selftest.py` | ~4s | 11/11 crypto vectors against the CCP model | | `harness/verify_real_image.py` | ~6s | 5/5 containers verified, 5/5 negatives rejected | | `harness/boot_sos_full.py` | ~1s | sOS boots from the reset vector to its service loop | | `harness/boot_sos_and_tas.py` | ~7s | sOS + SYS_DRV + 4 modules loaded by the firmware | | `harness/boot_and_mailbox.py` | ~15s | host drives the real mailbox/ring into a TA invoke | | `harness/load_asd.py` | ~51s | ASD driver loaded, verified and executing (+2 negative controls) | | `harness/gcp_fe08_fullsys.py` | ~21s | GCP and ASD in one emulated boot: LOAD_ASD status `0`, service 2 registered; logs GCP RPC returns | | `harness/full_system.py` | ~40s | **sOS + SYS_DRV + HDCP + DTM + RAS + ASD all running in one boot** — each attributed by address space + live PA, and each shown *started* (trampoline + lifecycle entry), with a SKIP_ASD control | | `harness/asd_rpc.py` | ~10s | HDCP's ASD RPCs executing in the real ASD dispatcher | | `harness/dtm_ras.py` | ~20s | DTM commands 2/3/4 driven; RAS gate documented | | `harness/sysdrv_bridge.py` | ~60s | **experimental** — TA SVC 0xF2 service-1 routed into a real SYS_DRV; does not yet open RAS's gate | | `harness/fuzz_pss_differential.py` | ~90s | firmware PSS parser vs RFC 8017, own keypair | | `harness/fuzz_ta_invoke.py` | ~0.25s/invoke | host-driven TA invoke fuzzing — **exits 2**, see below | `gcp_fe08_fullsys.py` supplies the Navi 31 ASIC ID (`0x31`) at the sOS boot-input byte `0x80D0`, which the earlier boot stage would set on hardware. Without it ASD rejects startup with status `7`. `Ring.submit()` returns a dispatch-probe hit count, **not** a firmware status; loaders must read `Ring.response()` and check service-table registration before interpreting later RPC results. The current FE08 probe stops at ASD's device-key unwrap (`rpc_201` returns `1`, FE08 returns `0x263`), before its zero ciphertext is processed. That is a separate device-key unwrap failure; its cause remains unproven. These used to take minutes each, almost entirely spent running a system that had already finished the work being measured. See **"Harness runtime"** in `FIDELITY-NOTES.md` for the measurements and the one real fidelity tradeoff. `fuzz_ta_invoke.py` **deliberately exits non-zero.** Its controls pass, but its randomised invokes cover no blocks beyond the benign control, so its "0 faults" is not evidence of absence. It now refuses to report that, the same way it already refused when its positive control failed. The reason is now understood: `hdcp_invoke_entry` ignores the GP `commandID` in `R1` entirely and requires `paramTypes & 0xF == 7` plus a memref with `size > 0x2817`, taking its command selector from the **first DWORD of the shared buffer**. The host path supplies `paramTypes = 0x100`, so the first check fails and every invoke returns error 6 after 22 blocks. Satisfying the ABI at the entry point unlocks 73 new blocks across commands 1..0x13. The only code that can produce the required memref parameter (`tee_unmarshal_params`) was then measured to execute **zero instructions** across boot, a mailbox sweep, every ring command id 0x00..0x0F and a TA load — with `tee_cmd_dispatch` firing in the same run as a positive control. So in this configuration the HDCP command surface is **not host-reachable**, and this harness is *correctly blocked* rather than broken: removing the gate would just restore the old vacuous "0 faults". Full decode, jump table, per-command measurements and the scope limits of that claim are in `psp_out/FINDING-HOST-MAILBOX-AND-RING-IN-EMULATOR.md`. Escape hatches, for when you suspect the fast path is hiding something: | variable / flag | effect | | --- | --- | | `boot_sos_full.py --full` | disable quiescence exit, run the old fixed 60M-instruction budget | | `RUN_FULL=1` | `boot_sos_and_tas.py`: ignore the completion predicate, burn the whole wall-clock budget | | `TIMER_PERIOD=2000` | restore the original guest clock rate (see the caveat below) | | `RUN_SECONDS`, `BOOT_BLOCKS` | wall-clock / block budgets, unchanged in meaning | **The one thing to know before trusting a number:** `TIMER_PERIOD` sets how many basic blocks make up one emulated millisecond (now 200, was 2000). Lowering it makes firmware delay loops finish in less host time, but it also makes the emulated CPU *slower relative to guest time*, so firmware timeouts become more likely to fire. Results were identical at 2000/500/200/100 here, but if a finding ever depends on timing, pin `TIMER_PERIOD=2000` and say so in the write-up. ## Layout ``` pspemu/ consts.py firmware addresses, SVC numbers, RPC ABI, session offsets memmap.py per-component memory layouts (HDCP/DTM/ASD/SYS_DRV) loader.py ELF / raw image loading isa.py ARM vs Thumb detection (call-site voting) component.py Component: Unicorn instance + AAPCS run_function() + faults svc.py SVC decode/dispatch services.py emulated crypto service + ASD RPC (configurable, incl. bugs) bus.py inter-component routing (HDCP -> ASD across address spaces) trace.py WriteTracer, ContentDiff, Watchpoint, CallTracer harness/ hdcp_subcmd12.py rx_ids + v58 stack overflow measurement ``` Each TA gets its **own** Unicorn instance — HDCP and DTM are both based at `0x400100` on real hardware and cannot share an address space. ## Three caveats that will bite you **1. IDA's `T` segment register is wrong on these images.** HDCP `ta_memcpy` @`0x401058` is marked `T=1` (Thumb) but the bytes `e3520003 9a000017 e210c003 0a000008` are an ARM memcpy prologue (`CMP R2,#3 / BLS / ANDS R12,R0,#3 / BEQ`); IDA's own listing there degenerates into `DCD`. The TAs are **mixed ARM/Thumb**: handlers are Thumb, several libc leaf routines are ARM, reached by `BLX` (state-toggling) rather than `BL`. `isa.build_mode_map()` votes over every call site instead: `ta_memcpy` gets 62 ARM votes / 0 Thumb, `ta_memclr` 91 Thumb / 0 ARM. **2. `UC_HOOK_MEM_WRITE` corrupts Thumb ITSTATE.** Conditional stores that should be skipped execute while a write hook is installed. Measured on `ta_memclr(0x4ff980, 0x20)`: | | bytes written | |---|---| | no hook | 32 (correct) | | write hook installed | 47 — the `it hs` / `it mi` tail stores fire | The optimised memcpy/memset routines are full of IT blocks, so **hook-derived extents are not trustworthy**. Use `trace.ContentDiff` (sentinel-fill, run hook-free, diff) for any load-bearing measurement. `WriteTracer` is still useful for attribution//exploration, not for numbers. **3. The image blobs end with a 256-byte RSA signature that overlaps BSS.** `*_code.bin` is `[verified body ($PS1.size)][0x100 signature]`, and the TA header field `+0x1C` is the BSS base -- which lands exactly on that tail (ASD BSS 0xD33000 vs image end 0xD33100; HDCP 0x407000 vs 0x407100). Loading verbatim maps high-entropy signature bytes where zeroed BSS belongs, and they masquerade as initialised data -- one of them passed a `CBZ` null guard in ASD `sub_D02794` and became a `BLX` target. `Component._zero_signature_tail()` zeroes it after load. **4. `ta_memcpy` jumps out of its own function bounds** (0x401058 → 0x401702), so filtering writes by "PC within `[func, func+size)`" silently misses most of the copy. ## Validated against hardware * `__stack_chk_guard` lives at `0x404334`. The **image** holds `0x33333333`, but that is only a build-time placeholder: on hardware the runtime guard differs (at N=56 the copy fills the canary with `0x33333333`; the emulator returns cleanly, the device faults). Treat the emulated guard as a stand-in, and do not assume a `0x33` payload preserves the canary on real hardware. * A smashed canary reproduces the exact hardware crash-oracle signature: `pc=0x40433c` reading `0xdeadbeef` (`__stack_chk_fail`'s deliberate trap). * Fault attribution works: `LR=0x40410f` identifies `hdcp_verify_mac`'s canary check rather than subcmd12's. ## Result: subcmd12 stack overflow (harness/hdcp_subcmd12.py) Frame: `PUSH {R4-R11,LR}; SUB SP,#0x214`, `v58` at `SP+0x100` → canary at `v58+272`, saved LR at `v58+308`. **RETRACTED: the "return-address control" result below no longer holds, and the reason is that the emulator got *more* faithful.** The table originally read: ``` N 5N v58 reach canary savedLR PC-ctl out <-- SUPERSEDED 62 310 +319 0x33333333 0x3e003333 True FETCH at 0x3e003332 70 350 +359 0x33333333 0x33333333 True FETCH at 0x33333332 103 515 +524 0x33333333 0x33333333 True FETCH at 0x33333332 ``` and concluded that a `0x33` payload *preserves* the canary (payload == guard), so for N >= 62 the saved LR is overwritten and the CPU fetches from `0x33333332`. That depended entirely on `__stack_chk_guard` holding the image's **build-time placeholder** `0x33333333`. It no longer does. The per-load stack canary is now modelled (SVC 0xA2 TRNG + loader write, see FIDELITY-NOTES), so the guard is a real value — measured `0x31cca5e4`, stable across loads because the TRNG model is deterministic. A `0x33` payload therefore *smashes* the canary instead of preserving it, and `__stack_chk_fail` traps before the corrupted LR is ever used. Current measured output: ``` N 5N v58 reach canary savedLR PC-ctl out fault 40 200 +312 0x31cca5e4 0x00ff0001 False 106 52 260 +312 0x31cca5e4 0x00ff0001 False 106 54 270 +312 0x00000000 0x00ff0001 False None READ_UNMAPPED at 0xdeadbeef 56 280 +312 0x33333333 0x00ff0001 False None READ_UNMAPPED at 0xdeadbeef 62 310 +319 0x33333333 0x3e003333 False None READ_UNMAPPED at 0xdeadbeef 70 350 +359 0x33333333 0x33333333 False None READ_UNMAPPED at 0xdeadbeef 103 515 +524 0x33333333 0x33333333 False None READ_UNMAPPED at 0xdeadbeef ``` What survives, and what does not: * **The overflow is real and the reach numbers are unchanged.** v58 reach, canary at `v58+272` and saved LR at `v58+308` all still measure exactly as before; at N >= 62 the saved LR still takes attacker bytes (`0x3e003333`). The ARM memcpy stores whole words, so the canary is corrupted at N=54 (270 bytes), slightly below the naive 272. * **PC control does not survive.** The canary check now fires first, every time, producing `pc=0x40433c` reading `0xdeadbeef` — the exact hardware crash-oracle signature. `PC-ctl` is `False` at every N. This is the emulator converging on hardware, not regressing: the caveat two sections above already said "do not assume a `0x33` payload preserves the canary on real hardware", and the hardware oracle always faulted where the emulator returned cleanly. Those now agree. The earlier *unconditional* PC-control claim was an artefact of the placeholder guard and should not be cited. **The conditional form of the claim is still exactly true**, and was re-verified by forcing the guard back to the placeholder and changing nothing else: ``` guard = real per-load value (0x31cca5e4) guard forced to 0x33333333 N=62 savedLR=0x3e003333 PC-ctl=False N=62 PC-ctl=True FETCH at 0x3e003332 N=70 savedLR=0x33333333 PC-ctl=False N=70 PC-ctl=True FETCH at 0x33333332 N=103 savedLR=0x33333333 PC-ctl=False N=103 PC-ctl=True FETCH at 0x33333332 ``` So the guard value is the **sole** blocker, which is what `psp_out/SUBCMD12-RESOLVED.md` and `psp_out/FINDINGS-SUMMARY.md` already say ("with a matching guard, N>=63 yields full PC control", "blocked solely by the runtime stack guard value"). Those two documents were written conditionally and remain accurate as-is; only this README overstated it. **The hardware divergence is now explained** (`harness/hdcp_cmd0f.py`). `hdcp_cmd0F_handler` has a latch at `0x402234`: `ldr r1,[r2,#0x18]` (sess->err_state) / `cmp r1,#7` / return with all response sub-status fields left at 100. subcmd12 sets `err_state=7` on every failure path (109/107/108/106), so the **first** failing subcommand bricks the session for cmd 0x0F: ``` A) fresh session, N=40 -> subs=[106,..] err_state=7 rx_count=40 (ran) B) same session, N=90 -> subs=[100,..] err_state=7 rx_count=40 (NOT called) C) err_state=7 preset -> subs=[100,..] rx_count=0 D) FRESH slot, N=90 one-shot-> rx_count=90, PC=0x33333332 (LR hijack) ``` Earlier hardware runs "primed" a slot first, which latched it — so every later measurement observed a handler that never executed. Correct methodology is **one observation per freshly opened slot**. Still: the emulated hijack in (D) uses stubbed crypto; on hardware the real MAC fails and the uninitialised-context overrun smashes `hdcp_verify_mac`'s canary first, so this is not yet a hardware result. ## What is still stubbed (fidelity gaps) | area | current | why it matters | |---|---|---| | crypto service (SVC F2, id 1) | SHA-256 stand-in; optional `overrun_uninitialised`. **Measured not load-bearing for `fuzz_hdcp`** | real MAC always fails for unauthenticated data -> `err_state=7` on the first call, which is *the* reason hardware is one-shot-only. Lives in sOS/secure SRAM, absent from every image we have — can only be modelled behaviourally. Swapping the stand-in for a per-call random secret leaves every `fuzz_hdcp` statistic bit-identical, so no conclusion there depends on crypto fidelity; see "not load-bearing" in FIDELITY-NOTES.md. Unmeasured for `fuzz_asd`/`fuzz_sysdrv`, which do more crypto. | | ASD TA (SVC F2, id 2) | **wired** — `AsdRpcService(bus=...)` runs the real ASD dispatcher (`harness/asd_rpc.py`); stub still the default elsewhere | the stub returned 0 for every verb; the real dispatcher returns 2 / 5 and executes 28-30 ASD blocks per call. Note subcmd12 issues **zero** ASD RPCs, so the old claim that this "changes subcmd12's control flow" is not reproduced — see the divergence note in `harness/asd_rpc.py`. | | TA entry / init | **done** — `TA_CreateEntryPoint` (0x40139C) is executed and `dword_40237C` is left at its image value (0x407000, the TA's own BSS table). Nothing patches it any more. | measured: create returns 0 and writes no globals, so the image default *is* the real table; the old hand-patch pointed the TA at a harness fiction. | | session lifecycle (cmd 0x01/0x0E) | **done** — sessions are opened by a real cmd 0x0E invoke through `hdcp_invoke_entry`; the TA allocates slots itself (1..7, never 0) | measured: one cmd 0x0E takes a slot from (state=0, active=0) to (state=2, active=1) and touches nothing else in the 0x28c-byte session — byte-for-byte what the old hand-written bypass produced. | | sOS gate semantics | **region validation modelled and validated** (`pspemu/gate.py`, `harness/sos_gate.py`); **DACR switch was already modelled** — the old "not modelled" claim is retracted; async dispatch still absent | `sos_check_user_ptr` (FA 0x178C) and `sos_check_user_range` (FA 0x191C) are reimplemented and differentially tested against the firmware: **292/292 cases agree**, and the differential is shown to catch a deliberately wrong model (the old always-succeed behaviour is caught on 18/84 pointer cases). The gate is enforced in the RPC path, not inert: out-of-window request pointers get the firmware's own 0xA and the service never runs, while a real HDCP invoke has 0 of its own requests rejected. `config['gate']=False` restores the old behaviour, `config['proc_type']=4` models a privileged caller. `check_range` is validated to the same standard but deliberately **not** applied generically — knowing which request fields are buffers is service-specific, and guessing would invent semantics; on the one path where the layout is known (SYS_DRV RPC) the firmware's own `sub_E0DEF4` and `sub_E0AB44` run unpatched in `sysdrv_bridge.py`, so range checking there is faithful by execution. **DACR**: the row used to claim no domain switch. That was a stale restatement of a claim FIDELITY-NOTES.md had already retracted — the MMU is enabled and domains are live. Measured both ways: the emulator really enforces domains (domain=no-access faults, client+AP=0 faults, manager reads — positive control passes), and sOS's own `mcr p15,0,rX,c3,c0,0` sites execute **3944 times in a bounded boot** (86501/86500 paired entry/exit in a full run). **Async dispatch**: also measured rather than assumed, and the old "not modelled" claim is retracted for the full-system harnesses — sOS's own scheduler runs there, creating **22 tasks** with **24008 reschedules** and **8 stacks busy enough to carry >1000 blocks each**, which is real context switching, not one thread. It remains genuinely absent *component-level*, where there is no sOS and an SVC completes synchronously inside the handler. All four properties are re-checked on every `harness/sos_gate.py` run, each with a failure mode that reports NOT REPORTABLE rather than a vacuous pass. | | persistence | **modelled** — `fuzz_hdcp` keeps TA state across iterations by default (`HDCP_PERSIST=0` restores the old wipe); still a fresh `Component` per *harness run* | hardware keeps TA globals for a whole boot, which is exactly what the cmd 0x0F latch exploits. Measured over 1500 iterations: persistence gains ~9% HDCP coverage (767 -> 839 distinct blocks) and the run now ends with sessions genuinely latched (`err_state` 7), reproducing the one-shot behaviour. CANARY_SMASH drops 46 -> 7 because the latch stops subcmd12 being reached — that is the latch being modelled, not lost bug-finding. | | DTM / RAS | **DTM driven** (`harness/dtm_ras.py`): commands 2/3/4 through the real GP invoke entry, with negative controls. **RAS blocked** on an init gate | DTM could not have been driven before: `loader.resolve` picked `elf/dtm2.elf`, which carries the full image (0x2100, header+body) but declares the body-only base `p_vaddr=0x400100`, so everything landed 0x100 high and all five GP entry points resolved into the container header's zero padding. Now uses `elf/dtm.elf` (same build, correct base). RAS gates every command on a byte at 0x4060e0. Traced: the only store to it is inside `TA_OpenSessionEntryPoint`, guarded on an inner call chain that bottoms out in **SVC 0xF2 service 1 -- the SYS_DRV RPC** (opcodes 0x0001/0x000d/0x0002/0x1077, measured). So RAS is blocked on SYS_DRV RPC *responses*, not a mystery flag; component-level, service 1 is bound to CryptoService, the wrong service. Routing it into a real SYS_DRV the way `Bus.route_asd` does for ASD is the fix, and is now tractable because `sysdrv_env` opens SYS_DRV's own init gate for real. Command coverage explicitly not reported until then. | | TA image versions vs silicon | **RAS matches the card byte-for-byte; HDCP/DTM/ASD do not** | Checked against an RX 7900 XTX's `psp_13_0_0_ta.bin`. Our `ras2.elf` body is bit-identical to the card's RAS 27.0.2.5 (`db2ed53e...b58385`), so RAS findings apply to that silicon directly. The card runs HDCP **23.0.0.63**, DTM 18.0.0.22, ASD 33.0.0.217 — none of which are the images in this tree (we do not hold .63 at all; `vdiff` has .49/.70/.82). **Caveat now discharged for HDCP and DTM**: the card's own builds were extracted into `elf/hdcp_23.0.0.63_edab4e4f.elf`, `elf/dtm_18.0.0.22_adbf8c8b.elf`, `elf/asd_33.0.0.217_c4c1e5bc.elf`, and re-running `hdcp_cmd0f`, `hdcp_subcmd12` and `dtm_ras` against them gives **identical output** except the PC of one sentinel fault (`0x40433c` → `0x4042b4`, same code at a different link address). `fuzz_hdcp` runs comparably (817 vs 815 blocks). Entry points are resolved from the image by `pspemu/ta_entries.py` (validated 3/3 against known builds) rather than hardcoded. **ASD now covered too**: `resolve_asd()` reads its dispatcher from trampoline `+0x1C` (validated across 12 builds, all 0xD013B8, corrupted stub refused), and `fuzz_asd` resolves `asd_validate_ptr` by unique-match signature instead of the hardcoded 0xD12E90 that made it fetch from 0 on other builds. Against the card's ASD 33.0.0.217: `fuzz_asd` 967 vs 938 blocks with the same 395 ok / 5 noise split, `asd_rpc` same return values, and `load_asd` puts the card's **signed** container through the firmware's own loader — RSA check passes, 76 blocks, both bit-flip controls still rejected. Not in SPI flash, incidentally: `spi_parse.py` finds no TA entries at all, and the PSP images that are there are `enc_option=1` encrypted. See `psp_out/HW-VALIDATION-RX7900XTX.md` and `psp_out/HW-TA-VERSION-REPRODUCTION.md`. | | RAS on silicon | **the 0xE575B8 blocker is confirmed a missing-state gap, not a firmware defect** | The card's own signed RAS 27.0.2.5 (body byte-identical to `ras2.elf`) was loaded over the GFX ring with `psp_out/load_ta.py` (built for this — tinygrad has no LOAD_TA path). **Command 1 returns `ras_status=0`**, so RAS processed it rather than short-circuiting on its init gate at 0x4060E0 — a gate only opened via the SYS_DRV RPC chain containing opcode 0x000d. So 0xE575B8 *is* populated on a real boot. Hardware oracle for when the emulator's gate opens: `1→0x0`, `2→0xa002`, `3/4→0xa010`, `5..0x10` rejected at the ring with `0xFFFF000A`. Caution recorded: `resp.status` is ring delivery only and returns 0 for bogus command ids too — the TA verdict is `ras_status` in the shared buffer. See `psp_out/HW-RAS-ON-CARD.md`. | | ring status codes vs silicon | **7/9 exact** | `GET_FW_ATTESTATION`→0x0, unknown ids (0x00/0x55/0x99/0xfe/0x1000)→0x100, `INVOKE_CMD`→0x117 all match hardware exactly. `LOAD_TA` (hw 0x5 vs emu 0x116) and `UNLOAD_TA` (hw 0x117 vs emu 0x11a) differ, but those are the only state-dependent paths and the ring harness has no TMR or loaded TAs — **not** reportable as firmware divergence. Inputs were equalised first. | | timing / concurrency | **task scheduling is modelled** in the full-system harnesses (retracting "none"); *timing* fidelity is still approximate | "no preemption or task scheduling" was wrong in the same way the DACR row was: sOS's own scheduler executes, and `harness/sos_gate.py` measures it every run — 22 tasks created, 24008 `svc_60_reschedule` calls, 8 busy stacks. A timer FIQ is installed and does interrupt execution, so tasks that sleep genuinely wake. What is *not* claimed: the scheduler is cooperative in sOS's own design, so this is not evidence of preemptive time-slicing, and **timing remains approximate** — `TIMER_PERIOD` defaults to 200 rather than hardware's 2000, which makes the emulated CPU slower relative to guest time (pin `TIMER_PERIOD=2000` if a finding depends on timing). Component-level harnesses remain single-threaded and synchronous. | | SVC permission gate | **modelled and validated** — `gate.check_svc`, enforced in `SvcDispatcher` | `sos_svc_dispatch` (FA 0x587C) is not a bare jump table: it checks the SVC against the calling process's entry in the same table `check_ptr` uses and denies with **0xE** before any handler runs. Rule: `svc <= 0xDF` denied if `(proc_byte & 0xF)` is 5 or 6 and `svc != 0x75`; `0xE0..0xEF` denied unless `proc_byte & 0x20`; `> 0xEF` unchecked. Differentially tested against the real dispatcher with handler bodies stopped at entry so no side effects confuse it: **140/140 agree, 46 of them actual denials**. Live in the dispatcher: an ordinary process has 0 of 279 SVCs denied, a type-5 process 47 of 167. `config['proc_byte']` selects the class; `config['svc_gate']=False` restores the old unconditional behaviour. | | component-level sOS SVC *bodies* | **RESOLVED — measured from a real boot** — ASD issues SVCs nobody models (`0x51` unknown, `0x57` `svc_57_obj_op`, `0x5D` `svc_5D_alloc`, `0xB4` `svc_B4_multiop`), answered with a fabricated 0 | previously silent; now warned per SVC (`PSPEMU_SVC_STRICT=1` to make fatal). Measured over 300 iterations at fixed seed: baseline 924 ASD blocks, but flipping `0x51`/`0x5D`/`0xB4` individually to a failure gives 518 / 482 / 771 — each is individually load-bearing, up to 48%. The fabricated value is the *permissive* one, so `fuzz_asd` explores paths a real failure would cut off: its coverage is an **upper bound** and its clean runs are not evidence of absence. Note the near-miss — returning a plausible handle from `0x5D` instead of 0 is bit-identical, so only driving the value to a *failure* exposed the dependency. **Resolved**: `full_system.py` dispatches all four through the real `sos_svc_dispatch`, so the true values were measured, not modelled — `0x51` returns **1,2,3,...** and `0x5D` returns **0,1,2,...** (sequential handle allocators, strictly increasing, no reuse), while `0x57`/`0xB4` genuinely return 0. `services.register_measured_sos_svcs()` installs these and `fuzz_asd` uses them by default (`ASD_RAW_SVC=1` restores the old behaviour); nothing falls through the silent default now. The upper-bound qualification is **withdrawn**: measured fabricated-0 vs real values gives 959 blocks both ways with a byte-identical block set, so no past result was distorted — the fabricated value was wrong but happened to be path-equivalent. Bound: one boot, small call counts (18/39/3/1), so this is normal-operation behaviour, not allocator policy at exhaustion. See `psp_out/FINDING-ASD-SVC-STUBS-LOAD-BEARING.md`. | Priority to close the gap: ~~(1) wire `Bus` so ASD RPCs execute in the real ASD dispatcher~~ **done** (`harness/asd_rpc.py`, 5/5 thunks routed and executing), (2) model the crypto service's real failure semantics, ~~(3) run TA init instead of patching globals~~ **done** (`TA_CreateEntryPoint` executed, `dword_40237C` no longer patched, sessions opened by real cmd 0x0E). SYS_DRV **does** have init, and `harness/fuzz_sysdrv.py` was not running it -- it called a sub-initialiser that left the gate shut, so every iteration bounced off `sub_E087E4` after 4 basic blocks while reporting a clean 5000/5000. It now runs the real init (`sysdrv_env.run_init`, opcode 0x1000) and raises if the gate is still closed. See "Independently rediscovered" in `psp_out/FINDING-FEATURE-MASK-GET-UNBOUNDED-INDEX.md`. ASD has **no init phase** to run: its entry trampoline at 0xD19868 is `mov r0,r2 / blx *(0xD19884) / svc 0x75`, and `*(0xD19884) == 0xD013B9` is the dispatcher itself. So (3) has no ASD counterpart. Two ASD code patches were examined while checking that: `asd_feature_enabled` is **no longer patched** (measured redundant -- it already returns 1 for ids 0..0x1F unpatched), and ~~`asd_validate_ptr` is still stubbed~~ **also fixed**: it is a genuine range check accepting only 0x400000..0x600000 (the caller's window sOS maps in for the RPC). `fuzz_asd.py` now allocates its argument buffers inside that window so the REAL check gates them, and asserts at startup that the validator both accepts those buffers and still rejects ASD scratch -- a stub that accepts everything would pass the first half alone. Effect: scalar-deref noise 533 -> 82 over 5000 iterations, at a measured cost of 53 distinct blocks (-4.4%) that were only reachable via pointers the real ASD refuses. ## Extending * New service: implement `__call__(self, comp, req_ptr)` and register it in `Component.set_rpc_services({id: svc})`. * Cross-TA calls: build a `Bus`, add components, pass `AsdRpcService(bus=bus)` so HDCP RPCs execute inside the real ASD dispatcher. * New harness: copy `harness/hdcp_subcmd12.py`; use `ContentDiff` for numbers.