# Emulator fidelity — what the hardware work taught us Notes on making `pspemu` behave more like the real PSP, grounded in findings from the hardware/RE effort (see `../psp_out/` docs). Implemented items are done; opportunities are ranked by value. ## Implemented (this pass) ### 1. Per-load stack canary (SVC 0xA2 TRNG + loader write) — DONE The biggest emulator/hardware gap, flagged in `../psp_out/VULN-ASD-202-CANARY-LEAK.md`: *"the emulator uses the build-time guard placeholder; on hardware the same code discloses the runtime (loader-randomised) guard."* Mechanism recovered from `bl_load_ta_image` (sos2 `0x1665A`): the TA header field at `image_base+0x20` holds the VA of `__stack_chk_guard`; the loader reads one CCP TRNG word (`SVC 0xA2`) and stores it there. The shipped image only carries the `0x33333333` placeholder. Now modelled: * `Component.trng_word()` — seedable TRNG source (`config['trng_seed']`). * `svc_read_trng` (SVC 0xA2) — returns a fresh TRNG word. * `Component._randomize_canary()` — at load, reads `image_base+0x20` and writes a TRNG word to `__stack_chk_guard` (HDCP `0x404334`, ASD `0xD19878`, verified). * `config['randomize_canary']=False` keeps the placeholder for deterministic runs. Effect: emulated stack overflows now hit a random canary (can't pass without a leak), and canary-leak PoCs disclose the runtime-random value — matching hardware. Reproducible per seed. ### 2. Optional stale-memory fill — DONE Real hardware does not zero stack/scratch between calls, so uninitialised reads return stale data — that is precisely how the ASD `0x202` leak (uninit `src[4]`) and the `bl_derive_key` 4-byte kernel-stack leak disclose secrets. Unicorn zero-fills, hiding them. `config['stale_fill']=` pre-fills stack+scratch with a recognisable pattern so uninitialised-read disclosures surface instead of reading `0`. Default `None` (zero) preserves existing behaviour and tests. Combined with the canary model, the 0x202 leak reproduces naturally: prime via `0x308` (writes the random canary onto the stack frame), then `0x202` reads the stale word back. ## Opportunities — INVESTIGATED, confirmed not worth implementing An SVC-usage survey across the whole TA command surface (HDCP `0x00-0x15`, ASD `0x100/0x103/0x160/0x180/0x1a0/0x1a7/0x200/0x202/0x300/0x308`) settled these empirically: **the only SVCs any TA execution path invokes are `0xF2` (RPC gate) and `0x8D` (ASIC id) — both handled, zero unhandled.** The rest never arise. ### A. Correct SVC 0x5A/0x5B semantics — confirmed LOW value `svc_5A_5B_wait_events` (sos2 `0x6E40`) is a **wait-on-events** primitive, not the map/reserve an older name implied. But no TA path invokes `0x5A/0x5B` at all (the survey shows only `0xF2`/`0x8D`), so the `ZERO_SVCS` treatment is never exercised by a TA harness. Only relevant to a full mailbox-loop/scheduler model — not built. ### B. TMR fence poison — confirmed LOW value Hardware fences the TMR (host reads `0xFF`, writes dropped; `../psp_out/VULN-CONFIRMED-TMR-RANGE-ROLLBACK.md`). TAs never address the TMR; confirmed — no TA path touches it. Only a full-boot/SYS_DRV model would need it. ### C. Crypto DACR confinement — already modelled *architecturally* `SERVICE1-CRYPTO-ANALYSIS.md`: the crypto service (SVC 0xF2 id 1) writes only into the caller's MMU domain (DACR switch), which is why its overflow stays TA-side. The emulator gives each TA its **own** Unicorn instance mapping only that TA's regions, so a crypto write outside the caller's space **faults on unmapped memory** — exactly the boundary the DACR enforces. The confinement is therefore modelled by construction; no explicit check needed. ### D. Reachability facts — harness-shaping, already followed * ASD refuses host `INVOKE_CMD` (`0x4`); reachable only as SVC 0xF2 service 2 from a TA. Harnesses drive ASD via the RPC gate / its dispatcher, not a host-invoke. * The GFX-ring allow-list is bypassed at runtime (`ring==0xFFFF`); reachable SYS_DRV RPC set is ~30, not 8. `sysdrv_env` already exercises the broad set. ## Bonus: the canary model reproduces the exact hardware crash With the per-load canary in place, driving HDCP `subcmd12` into the crypto MAC overrun now faults at **`pc=0x0040433c` -> read of `0xdeadbeef`** — byte-for-byte the `__stack_chk_fail` crash signature recorded in `../psp_out/HANG-ROOT-CAUSE.md` (fault_lr `0x40173e`, PC `0x40433c`, FAR `0xdeadbeef`). Before the model the canary was the placeholder; the crash location is now hardware-accurate, which is what makes the emulator usable for judging canary-gated exploitability. ## Caveats carried over * `sysdrv_env`'s synthesised config/flash overlays only *unblock init* — any fuzzing fault touching cfg-derived state is SUSPECT until checked on real config. * The `.53` builds were extracted from a linux-firmware container; prefer the `../psp_out/handover/elf/versions/` copies for version-accurate work. ## Boot-chain emulation (self-bootstrapping) — foundation built, roadmap Goal: instead of running one module with stubbed boundaries, have the components run *each other* — sOS stands up, SYS_DRV/ASD register as services, a TA's SVC 0xF2 reaches the REAL SYS_DRV/ASD code. This finds bugs the stubbed model cannot (real crypto, real `sysdrv_tee_map_shared_mem`, real cross-component flow). ### Done: `pspemu/system.py` — the unified address space The resident components occupy non-overlapping ranges (verified), so `System` maps sOS (0x0), ASD (0xD00000), SYS_DRV (0xE00000) and a TA window (0x400000) into ONE Unicorn instance plus the shared MMIO/mailbox/host-shm apertures. Demonstrated: real sOS `memmove_aligned` executes in the shared space with all three other images resident. This is the prerequisite brick for everything below. ### The four remaining pieces, ranked by value/effort 1. **Nested SVC 0xF2 routing** (MEDIUM effort, HIGHEST value). `System.route_svc_f2` is a stub hook point. Real routing = when a TA traps on SVC 0xF2, save its frame, enter sOS's `svc_f2_rpc_gate` (0x22B4) with the caller's R0/R1, run to its return (a magic-return trampoline), then resume the TA with R0. This alone turns the stubs into real inter-component calls and is the single biggest step. 2. **"Warm-boot" service state** (MEDIUM). Rather than run the hardware-coupled cold boot, synthesise the post-init state sOS/SYS_DRV need: the `service_desc_table` (so 0xF2 routes id 1->crypto, 2->ASD, SYS_DRV), the resource/PCB tables, and the per-process caps (so the SVC type-gate behaves). `sysdrv_env` already does this piecemeal for SYS_DRV standalone; generalise it. 3. **Cooperative scheduler** (MEDIUM-LARGE). sOS spawns tasks (`bl_legacy_mailbox_loop`, `bl_ring_service_loop`) and yields via SVC 0x60 / 0x56 / 0x5B (wait-events). A real boot needs a run queue and context switch. Only needed to model the *event-driven* command delivery, not single RPCs. 4. **ARM MMU / DACR** — ~~LARGE, or SKIP~~ **SUPERSEDED; do not follow this item.** It recommended running flat-physical and no-opping the MMU SVCs. That advice is obsolete and contradicts the measured section "Unicorn *does* have an ARM MMU" below in this same file: the MMU is enabled by sOS's own init, QEMU honours the short-descriptor tables, and `DACR = 0x00004505` is live. Domain enforcement is not merely present but *effective*, and this is now checked by `harness/sos_gate.py` rather than asserted: a section placed in a no-access domain faults, a client domain with `AP=0` faults, and the manager case reads cleanly as a positive control. sOS's own DACR writes (`mcr p15,0,rX,c3,c0,0` at 0x1B4 / 0x264 / 0x294) execute 3944 times in a bounded boot. Nothing here needs building; it needed measuring. ### Cold boot vs warm boot — the honest call A *fully faithful cold boot* (sOS reset vector -> init -> load SYS_DRV -> ...) needs the whole SoC hardware model (SPI, CCP, IRQ controller, SMU, MMU) and the on-chip boot ROM (not in any image we have). That is a multi-week effort with steep hardware-modelling risk. The **warm-boot path** (items 1+2, flat MMU) reaches the valuable end state — real inter-component RPC execution — for a fraction of the cost, and is the recommended next milestone. `System` is the foundation it builds on. ## 3. SVC 0x72 heap-pool provider — DONE (the SYS_DRV heap was DEAD) The single biggest SYS_DRV fidelity gap found so far: **every `heap_alloc` in the emulator failed**, so `gp_object_allocate`, `gp_operation_allocate`, `object_buffer_realloc` and the whole chunk allocator were unreachable. No emulator fuzzing had ever executed SYS_DRV heap code. Mechanism, recovered from `heap_alloc` (0xE08884): ```asm r0 = client ; r1 = sp+4 ; r2 = sp svc #0x72 cbnz r0, bail ; NONZERO RETURN = ERROR r0 = [sp+4] ; pool BASE written by the syscall r1 = [sp] ; pool SIZE written by the syscall bl heap_pool_init ``` `0x72` is an **out-parameter** syscall: write pool base to `*R1`, pool size to `*R2`, return 0. It was unregistered, so it defaulted to 0, `[sp+4]` stayed 0, and `heap_alloc` dereferenced `0+4` — `READ_UNMAPPED at 0x4, pc=0xE088A6` — or silently returned NULL. A first attempt that returned the pool pointer *as the return value* also failed, because `cbnz r0` reads any nonzero as an error. Also note `heap_pool_get` (0xE0ECB0) is **not** a getter: it is `MOV.W R1,#0x2A000 ; SVC 0x7D`, i.e. it asks the kernel for the pool. The generic `svc_reg_block` stub ignored R1 and returned a fresh 0x40 block per call, so the "pool pointer" changed on every call. 0x2A000 is the pool size the firmware itself requests, and is now the `make(pool_size=...)` default. Implemented as `_install_heap_pool()` in `pspemu/sysdrv_env.py`; `make()` calls it and exposes `c.heap_pool = {"base","size"}` for harnesses. Verified: after `make()` + `run_init()`, six `heap_alloc` calls succeed and the chunk list walks cleanly — 7 live chunks, 32-byte headers, payload at node+32, magic 0x56, free tail sized 0x29D60. ## 4. Instrumentation gotcha: mem-write hooks perturb or corrupt Measured in this Unicorn/Python build, and the reason `harness/fuzz_sysdrv_hostreach.py` uses snapshot/diff rather than a PC-filtered `UC_HOOK_MEM_WRITE`: * Adding a mem-write hook **after** the first `emu_start` corrupts execution: `heap_alloc` then returns into its own literal pool (`READ_UNMAPPED at 0x01c4ae40, pc=0xE088DC`, which is data, not code). * Adding it **before** `run_init` perturbs init itself: the pool lands at a different base, the 2nd allocation fails (returns 0) and the 3rd and 4th return the same pointer. Raising `count` from 2e6 to 5e7 changes nothing, so it is not instruction-budget exhaustion. Rule: install instrumentation before any emulation, or prefer snapshot/diff. Snapshot/diff cannot perturb the guest and was validated with an explicit positive control (a 0x48-byte memcpy into a 0x40 payload is detected reaching the adjacent chunk header) and negative control (0 diffs when idle). ## 5. Faithful param-slot population (do not patch param_slot_lookup) `gp_crypto_dispatch`/`sysdrv_rpc_dispatch` reject a whole request with `-65521` (0xFFFF000F) when a `{ptr,len}` slot is non-zero but `param_slot_lookup(opcode, slot)` returns 0 for it. A harness that fills all four slots therefore gets 0xFFFF000F for every GP opcode and fuzzes nothing — observed directly. `harness/fuzz_sysdrv.py` patches `param_slot_lookup` to `return 1`, which makes *every* slot a pointer slot — more permissive than hardware, and the source of the "scalars in pointer slots clobber stack" artifacts its own docstring warns about. The better approach, used in `fuzz_sysdrv_hostreach.py`, is to **query** `param_slot_lookup(op, slot)` in the guest and populate only the slots the firmware itself expects. Faithful and it unblocks the path. ## Full sOS boot from the reset vector (`harness/boot_sos_full.py`) sOS boots from image offset 0 (ARM mode, reset = `b #0x98`) through to `bl_main`'s service loop: 42.8 M instructions, 255 distinct blocks, 17 M SVCs. See `psp_out/FINDING-FULL-SOS-BOOT-IN-EMULATOR.md`. **Three divergences this harness deliberately introduces — do not draw security conclusions through them:** 1. **Two CP15 writes are NOP'd**: `0x5274 MCR p15,0,r0,c1,c1,0` (**SCR**) and `0x527a MCR p15,0,r0,c12,c0,1` (**MVBAR**). Unicorn's Cortex-A5 model has no TrustZone banked registers and raises `UC_ERR_INSN_INVALID`. Both are write-only config on this path, but if anything later reads SCR back this harness is silently wrong. (They are Thumb T32 encodings; an ARM-mode-only disassembly scan finds neither.) 2. **All SVCs return 0.** The boot path only branches on failure (`CBNZ R0 -> bl_fatal_hang`), so 0 = success everywhere. This makes the boot *permissive*: some of those 17 M SVCs would fail on hardware and take other branches. The boot proves the path is executable, not faithful. 3. ~~**Page tables are not honoured.** `sos_mmu_init` runs and `mmu_map_page` executes, but Unicorn has no ARM MMU, so everything stays flat/identity. In particular the real TA page attributes (RW+X, `DACR=0x55555555`) are not modelled.~~ **RETRACTED — this is wrong.** Unicorn is QEMU and implements the ARMv7 short-descriptor MMU fully; measured after a normal boot, `SCTLR = 0x00C5107D` (M=1, MMU **enabled**). From the moment `sos_mmu_init` sets SCTLR.M, every guest access is translated by sOS's own page tables and `uc.mem_map()` only controls what physical memory exists behind them. See "RETRACTION: Unicorn DOES implement the ARM MMU" later in this file for the measurement and for the `count=` trap that follows from it. The retraction is repeated here because this list is what a reader hits first, and the project rule is to propagate retractions to the text they contradict. **AXI apertures must be mapped explicitly** and *around* `sos_map()`'s existing `mmio_lo`/`mmio_hi` (`0x3000000-0x3240000`): SMN `0x1000000-0x3000000`, fabric reg `0x3240000`, Syshub `0x4000000+`. An **overlapping `mem_map()` fails silently** (`UcError` swallowed), which produced a phantom "address still unmapped" fault during bring-up. Also note a second `UC_HOOK_MEM_INVALID` handler is **shadowed by `Component`'s own**, so on-demand mapping added from outside the class never fires. ## CCP device model (`pspemu/ccp.py`) CCP5-generation block: MMIO register file, 3 command queues with in-memory rings, 32-byte descriptors, 128x32-byte LSB. Engines implemented: **PASSTHRU, SHA-256/384/512, RSA, ZLIB**. AES/XTS/DES3/ECC deliberately **raise** rather than silently succeeding — a CCP that quietly "works" for an unimplemented engine would manufacture false negatives in every harness above it. Validated by running the **firmware's own drivers**, not reimplementations: * `harness/ccp_selftest.py` — sOS `ccp_sha2` / `ccp_sha256` / `ccp_hmac_sha2`, **11/11** vs hashlib/hmac, including multi-part midstate. * `harness/ccp_sysdrv_rsa.py` — SYS_DRV `rsa_modexp`, **6/6** vs `pow()` at 1024/2048/4096 bits with both short and long exponents. ### Facts that were derived, not assumed * **LSB byte order is proven.** The context is the big-endian state reversed across the whole region — the firmware's IV table `dword_7028` is byte-for-byte `sha256_iv_be[::-1]`, with SHA-384 at `+0x20` and SHA-512 at `+0x60`. * **SHA-384 truncation is load-bearing.** On EOM the hardware must place the *48-byte digest* reversed in the low 48 bytes, not the whole reversed 64-byte state — otherwise SHA-384 comes out rotated by exactly 16 bytes while SHA-256/512 (where digest == context length) are unaffected. * **Two different completion protocols.** sOS polls `HEAD_LO` until it equals the doorbell; SYS_DRV polls `INT_STATUS` (queue+0x10) **bit 1** and reads `Q_STATUS` (queue+**0x100**) & 0x3F as an error code. Completion must raise INT_STATUS bit 1 or `crypto_wait_completion` spins forever. * **The tail wraps.** `ccp_ring_doorbell` writes `ring_pa + 32*((slot+1) & (size-1))`, so once the ring fills the tail is numerically *below* the head; a linear head..tail walk runs off the end. Ring base is recovered by masking, which is sound because `ccp_queue_init` rejects rings not aligned to size*32. * **RSA operand layout came from RAW DISASSEMBLY.** Hex-Rays renders `rsa_modexp`'s three memcpys as `memcpy_thunk(15007744)` with arguments hidden — the same failure that hid the copy_state overflow. Layout: base at `0xE50000` (verbatim), exponent at `+keysize` (**byte-reversed**), modulus at `0xE51000` (verbatim), `keysize = round_up(size,32)`, `ctrl = flags|0x18|(keysize<<8)|0x400000`. * **RSA input is little-endian but output is BIG-endian.** `rsa_modexp` reverses the result on the way out while copying base/modulus verbatim. Confirmed independently: `rsa_pad_and_verify` checks `v22[modlen-1] == 0xBC`, the PSS trailer, which is only last in big-endian. Getting this backwards produces a perfect byte-reversal of the right answer — a failure mode that looks like a broken engine. ### Known divergence / shortcut `harness/ccp_sysdrv_rsa.py` publishes the scratch globals (`dword_E257D0/D4`, ring VA/PA) directly instead of running `desc_query_and_map`, whose SVC 0x78/0x59 contract is not yet modelled. It does run the real `ccp_base_set`, `ccp_queue_reset` and `ccp_queue_config`. The shortcut touches no value the RSA operation itself consumes. ### FIRMWARE BUG found via the model `ccp_hmac_sha2` computes a **wrong HMAC-SHA512**. `ccp_scratch` is 372 bytes; the inner digest lands at `+128+blocksize` and `ccp_sha2`'s init pass memcpy's the IV to `+304`. For SHA-512 those are `[256:320]` and `[304:368]`, so the opad pass clobbers the last 16 bytes of the inner digest with the head of the reversed SHA-512 IV before it is hashed. The emulator's output matches the *clobbered* value byte-exactly, which is what shows the emulator is right and the firmware is wrong. SHA-256 overlaps by 0; **SHA-384 ends exactly at 304 — correct with zero margin**. Both in-tree callers (`sos_s3_build_auth_blob`, `sos_s3_verify_resume_cmd`) pass alg=4, so the broken path is dead code. ## RETRACTION: Unicorn DOES implement the ARM MMU, and sOS turns it on Earlier notes in this file and in `psp_out/FINDING-FULL-SOS-BOOT-IN-EMULATOR.md` state that "Unicorn has no ARM MMU, so everything stays flat/identity" and that `sos_mmu_init` runs but its page tables "are not honoured". **Both are wrong.** Measured after a normal boot (CP15 read via `UC_ARM_REG_CP_REG`): SCTLR (c1,c0,0,0) = 0x00C5107D -> M=1 (MMU ENABLED), C=1, I=1 TTBR0 (c2,c0,0,0) = 0x0000FC48 DACR (c3,c0,0,0) = 0x00004505 Unicorn is QEMU, and QEMU implements the ARMv7 short-descriptor MMU fully. So from the moment `sos_mmu_init` sets SCTLR.M, **every guest access is translated by sOS's own page tables**, and `uc.mem_map()` only controls what physical memory exists behind them. ### Why this mattered in practice Running the stage-2 task (`bl_stage2_task`, VA 0x204894) faulted immediately at `strb.w r0,[sp,#0x1c]` with a repeating `intno=4` (EXCP_DATA_ABORT) and an empty `c.faults` list. That combination is diagnostic: * `c.faults` empty => not a host-unmapped access (UC_HOOK_MEM_INVALID never fired), so `mem_map` was not the problem; * `intno=4` looping => a GUEST MMU fault, re-raised forever because the INTR handler only understands SVC and leaves PC on the faulting insn. `bl_main` requests the task stack with `SVC 0x62 (va=0x280000, pa, 0x51 pages, flags=0xC0000000)`. Stubbing that SVC to return 0 reports success while creating **no translation**, so the VA is genuinely absent from the guest tables. Host-mapping 0x280000 changes nothing. ### Consequence Any SVC that establishes or removes a mapping (0x62 map, 0x63 unmap, 0x7D window reserve, 0x68 cache maintenance) cannot be stubbed once the MMU is on -- it must actually edit the page tables. An "SVC returns 0" stub is not neutral here; it is a silent lie that surfaces much later as an unexplained abort. This also means the emulator can model the real page permissions (AP bits, XN, domains) rather than treating memory as flat -- so the RW+X TA pages and the DACR domain model are observable, not just inferred. ### TRAP: once the MMU is on, `count=` does NOT bound emu_start An access to a VA with no guest translation takes an abort that **retires zero instructions**. Unicorn's `count=` limit only decrements on retired instructions, so the abort loop is unbounded: `emu_start(..., count=1000)` and `emu_start(..., count=200000)` both hang *forever*, identically. It presents as "the emulator is slow", which is misleading -- it is not slow, it is stuck. Two symptoms that identify it: * `c.faults` is EMPTY (UC_HOOK_MEM_INVALID never fires -- the host memory is mapped; it is the *guest* translation that is missing); * an instruction trace shows a block restarting with registers rolled back, e.g. `sub sp,#0x28` appearing to have no effect on SP. Use `armmmu.PageTableEditor.require_mapped(pc=..., sp=..., ret=..., arg=...)` before any post-boot `emu_start`. It refuses in microseconds instead of hanging. **All existing harnesses are unaffected**: `ccp_selftest`, `ccp_sysdrv_rsa`, `verify_real_image`, `fuzz_pss_differential` and the SYS_DRV/TA fuzzers never boot sOS, so the MMU is off and VA == PA for them. The trap only applies to work that runs *after* `sos_mmu_init`. **UNRESOLVED**: even with pc/sp/ret/arg all verified mapped, calling an arbitrary sOS function post-boot still hangs. So the missing-translation case above is *a* cause, not the only one. Do not treat `require_mapped()` passing as proof that a post-boot call is safe. --- ## Harness runtime: where the wall-clock actually went The boot harnesses used to take minutes. Almost none of it was emulation of anything: it was the harnesses continuing to run a system that had already finished the work being measured. Three distinct causes, measured separately. ### 1. Running long after quiescence (the big one) `harness/boot_sos_full.py` ran a fixed `count=60_000_000` instruction budget. sOS reaches `bl_main`'s service loop at **block ~8,700** and then spins there on SVC 0x60 forever, because nothing drives the mailbox. The remaining budget was spent executing the same five idle blocks 8.5M times each: | | blocks | distinct | SVCs | wall | | --- | --- | --- | --- | --- | | fixed 60M budget | 42,834,576 | 255 | 17,130,369 | 171.8s | | stop at quiescence | 208,698 | 255 | 80,018 | 0.83s | **99.96% of the run was idle spin.** `boot()` now stops when no *new* basic block has been discovered for `IDLE_QUIESCE_BLOCKS` (200k, ~24x the blocks needed to reach the loop) **and** PC is inside the service loop — i.e. when the system has demonstrably settled, not after a guessed budget. Equivalence was checked by set-differencing the executed-block sets of the two runs **in both directions**: empty both ways, 255 distinct blocks either way. `--full` restores the exhaustive run and still reproduces the old numbers exactly (42,834,576 / 255 / 17,130,369), so the fast path is a shortcut, not a different experiment. The 42M-entry `blocks` list was also replaced by a `Counter`. It cost ~1.5 GB of int objects to answer `len()`, `set()` and `most_common(5)`, all of which a histogram answers directly. ### 2. Fixed drains that outlive the thing they wait for `boot_and_mailbox.py` drained `for _ in range(10): run(5_000_000)` after the ring TA-load and `range(8)` after the invoke — **90M blocks, unconditionally**, whether or not the firmware had already finished. Replaced with `drain_until(run, cond, slices, blocks)`, which polls the actual success condition (session slot present / `TA_InvokeCommandEntryPoint` reached). `slices*blocks` equals the old `N*BIG`, so a slow path keeps exactly the same patience — the only change is that the common case stops when the firmware is done. Same fix applied to `fuzz_ta_invoke.py`'s setup (~90s per fuzzer start; per-invoke cost is unchanged at ~1.9s, measured against a clean worktree). Measured decomposition for `boot_and_mailbox.py`, each step cumulative: | | wall | note | | --- | --- | --- | | original | 188s | fixed 90M-block drains, `period_blocks=2000` | | drains -> `drain_until` | 32.4s | -156s; the drains were 83% of the harness | | + `TIMER_PERIOD=200` | 14.8s | -17.6s; guest clock rate (see below) | All three produce the identical result: 8/9 mailbox commands answered, 4 ring commands into `tee_cmd_dispatch`, session slot [0], 22 TA blocks, `TA_InvokeCommandEntryPoint` reached, 0 faults. ### 3. Guest clock rate — a real fidelity knob, not free speed `boot_sos_and_tas.py` showed module loads at a **regular ~9s cadence** (0.10s, 9.13s, 18.15s, 26.97s). That is not load work; it is the firmware sitting in delay loops polling `HW_COUNTER`. The emulated clock advances `COUNTER_PER_MS` once every `period_blocks` blocks, so with the old `period_blocks=2000` one emulated millisecond costs 2000 blocks. A ~2-second firmware delay therefore costs ~4M blocks ≈ 9s of host time. | `TIMER_PERIOD` | wall | blocks | distinct | modules | TA blocks | | --- | --- | --- | --- | --- | --- | | 2000 (old) | 40.1s | 19,397,108 | 2920 | 4 | 28 | | 500 | 14.3s | 7,370,237 | 2960 | 4 | 28 | | **200 (new default)** | **6.5s** | 3,114,159 | 2960 | 4 | 28 | | 100 | 4.8s | 2,259,641 | 2962 | 4 | 28 | Identical verdict and identical CCP totals (`PASSTHRU 93 / SHA 30 / RSA 3`, 0 errors) at every setting, and coverage goes **up** slightly (2920 -> 2960) because more timer paths get exercised. **State the risk plainly.** Shortening the tick makes the emulated CPU *slower* relative to guest time (200 blocks/ms instead of 2000), and it was already far slower than the real part. Anything the firmware guards with a timeout is therefore *more* likely to fire, not less. The evidence that it is benign here is empirical — same loads, same CCP descriptor counts, no new faults — and it is a per-harness knob (`TIMER_PERIOD`), not a change to the device model. If a result ever depends on it, pin `TIMER_PERIOD=2000` and say so. `boot_sos_and_tas.py` additionally takes a `done` predicate (its own success criterion: 4 modules loaded + TA code executing), checked at WFI resume boundaries — an already-paid poll point, ~30/s — followed by a `settle` window so in-flight work finishes before the numbers are read. `RUN_FULL=1` restores the unconditional wall-clock run. ### Cheap hooks Measured cost of per-block Python callbacks over an 8M-instruction boot: | hooks installed | blocks/s | | --- | --- | | block list + INTR (old) | 314,094 | | block hook only | 409,835 | | INTR hook only | 365,911 | | **none** | **495,382** | A per-block Python callback costs ~1.6x. Unicorn's `begin`/`end` arguments filter in C, so range-scoped hooks (e.g. "TA blocks only", `0x400000..0x4FFFFF`) cost nothing on the hot path — `boot_and_mailbox.py` and `boot_sos_and_tas.py` now use that instead of testing the address inside the callback. Note this is a *floor*, not a target: `Interrupts._on_block` is mandatory (it delivers IRQs and enforces the deterministic block budget), so a booting harness always pays one per-block callback. --- ## The crypto-service gap is real but measurably not load-bearing (for `fuzz_hdcp`) The remaining fidelity row says the crypto service (SVC 0xF2, id 1) is a SHA-256 stand-in and that on hardware the "real MAC always fails for unauthenticated data". That row was checked rather than assumed, and the check produced a scare that turned out to be wrong. Both halves are recorded, because the scare is easy to re-derive. ### The scare: `hdcp_verify_mac` appears to succeed 37.5% of the time Instrumenting `hdcp_verify_mac` (`0x403ff4`) across 3000 `fuzz_hdcp` iterations: ``` entries : 761 return values : {0xffff0000: 120, 0x00000000: 72} (192 captured returns) ``` and in **every** one of those 192 the crypto MAC op really was invoked, so they are not early-outs. Read naively that says the stand-in authenticates 72/192 calls that hardware would reject — which would make every fuzz result downstream of one unfaithful, exactly the way the neutered `asd_validate_ptr` did. Two refinements narrowed it without settling it: all 72 successes share one signature (`mac_len=8`, `dst-buf=-112`), but 20 *failures* have `mac_len=8` too, and `dst` never overlaps `buf`, so it is not the stand-in's output aliasing its own input. ### The refutation: swap the secret and see if anything moves Decisive test — keep the stand-in, but give each MAC call a **fresh random secret**, so any comparison that depends on the digest being reproducible must break: | crypto model | `verify_mac` success | harness stats | | --- | --- | --- | | deterministic stand-in | 72 / 192 | `ok 391, CANARY_SMASH 9` | | fresh secret per call | **72 / 192** | `ok 391, CANARY_SMASH 9` | **Identical.** The `R0 == 0` returns do not depend on the MAC value at all, so they are not authentication verdicts — that call site returns 0 on a path that does not report the comparison result. The fidelity row stands: the digest never matches, and nothing here contradicts it. ### The technique is the reusable part Swapping the crypto model and diffing the *whole* result is a cheap differential test for "does any conclusion in this harness depend on crypto fidelity?" Here the answer is **no** — every statistic is bit-identical under a completely different secret. So the crypto stand-in, while genuinely unfaithful, is not load-bearing for `fuzz_hdcp`'s results, and closing that gap would not change them. That bounds the gap instead of merely restating it. It also guards against the inverse error: `R0` from a function whose name contains "verify" is not a verdict oracle until something shows it varies with what is being verified.