feat(emulator): libkkemu shared library + native macOS build - #217
Conversation
Adds an in-process emulator library (libkkemu, .dylib on macOS / .so on
linux) so a host process can drive firmware logic via FFI instead of
spinning up a separate kkemu binary and talking over UDP. Used by vault
v11 to host the emulator inside the Bun process for the wallet onboarding
+ Zcash testing flows; also lets researchers fuzz / drive firmware
behavior from arbitrary host code without the network hop.
Build:
cmake -DKK_EMULATOR=1 -DKK_BUILD_DYLIB=1 -B build-emu .
cmake --build build-emu --target kkemulator_dylib
The standalone `kkemu` UDP-driven binary continues to build with
KK_EMULATOR=1 alone, byte-identical to before.
Files:
- include/keepkey/emulator/libkkemu.h, lib/emulator/libkkemu.c
C API exposed to host: init(seed), step(), screenshot capture
(display_refresh frames packed 8bpp->1bpp into a 256x64x1 buffer),
ringbuf-backed message I/O.
- lib/emulator/ringbuf.{c,h}
Lock-free single-producer/single-consumer byte ring used by
libkkemu's host I/O paths (replaces the UDP sockets in dylib mode).
- lib/emulator/setup.c
setup_urandom_only() — RNG init for libkkemu callers that
provide their own flash buffer (no flash mmap).
- lib/emulator/udp.c
Behind #ifdef KKEMU_DYLIB: emulatorSocketInit/Read/Write become
thin trampolines to the libkkemu ring buffers. Standalone build is
unchanged. Adds optional KEEPKEY_UDP_PORT env override for the
kkemu binary so multiple emulators can share a host.
- lib/emulator/CMakeLists.txt, tools/emulator/CMakeLists.txt
Define the kkemulator_dylib target and refactor kkemu's link
command to share FIRMWARE_LIBS between standalone and dylib builds.
- CMakeLists.txt
cmake_policy(SET CMP0079 NEW) — required so tools/emulator can
link kkemulator_dylib which is defined in lib/emulator. KK_BUILD_DYLIB
option (default OFF) guards the whole dylib path; absent the flag,
the build is identical to fork develop.
- include/keepkey/emulator/setup.h
Forward decl for setup_urandom_only.
Out of scope (separate concerns even though they touch nearby files):
- Version bump 7.14.0 -> 7.15.0
- BITCOIN_ONLY define (BTC-only firmware variant)
- DebugLink screenshot canvas semantics (next commit)
- CI screenshot pipeline / zoo workflows
…t capture
fsm_msgDebugLinkGetState previously called force_animation_start() +
animate() before capturing the canvas (or wrapped both in
`if (is_animating())`). Both forms produce the same bug for non-animated
screens (warning_static, address displays, etc.):
- The confirm() loop already calls animate() before sending the
ButtonRequest that triggers DebugLinkGetState.
- When the layout is static (no animation queued) and we still call
force_animation_start() + animate() in the DebugLink handler, we
overwrite the static canvas with a stale animation frame OR a no-op,
depending on queue state — either way, the screenshot the host
captures is not what the user is actually seeing.
Replace with a single display_refresh() call. The canvas already holds
the right pixels by the time DebugLinkGetState fires; we just need the
framebuffer synced before the host reads it.
Production firmware (compiled with KK_DEBUG_LINK=0) is unaffected — the
function is excluded from non-debug builds entirely.
Was the missing piece for the CI screenshot pipeline correctly capturing
warning + confirmation screens with their as-rendered text instead of
animation frames or empty buffers.
… response Bumps RINGBUF_CAPACITY from 32 to 128. The previous value left effective room for 31 HID reports between writer and reader; DebugLinkGetState now serializes a 2048-byte `layout` plus the rest of DebugLinkState (~2.7 KB total payload, ~44 reports after the per-report sync prefix + continuation byte). Pushes past slot 31 returned 0 from emulatorSocketWrite(), but upstream lib/board/usb.c::msg_debug_write() ignores the return — so the host saw a silently-truncated screenshot capture instead of a clean failure. 128 gives ~3x headroom on the worst current response, accommodates DebugLinkFlashDumpResponse (1024-byte chunks) plus future field growth, and stays a power of two so the modulo in ringbuf_push/pop is a cheap mask. Memory cost: 4 rings × 128 × 64 = 32 KB. Trivial next to the existing 128 KB frame_ring. Out of scope: making msg_debug_write() propagate emulatorSocketWrite() failures is the right long-term fix and belongs in a separate PR (it's upstream code, not introduced by libkkemu). Until then, sizing the ring so it never overflows on a legitimate response is the pragmatic guard.
…ILD_DYLIB The previous CMakeLists.txt only marked kkemulator_dylib itself as PIC, but it links static archives (kkfirmware, kkboard, kkrand, kktransport, trezorcrypto, qrcodegenerator, SecAESSTM32, kkemulator, kkvariant.*) defined in sibling directories. macOS happens to be lenient and produces a working .dylib regardless, but Linux's link step against a non-PIC archive fails with "recompile with -fPIC". Set CMAKE_POSITION_INDEPENDENT_CODE=ON globally when KK_BUILD_DYLIB is on, BEFORE add_subdirectory(lib) below — every static target picks it up at definition time. CMP0079 (already set above) lets the dylib reach across directories to link them. When KK_BUILD_DYLIB=0 (the default), nothing changes — static libs build without -fPIC as before.
…down
Two related hardening changes in libkkemu's lifecycle:
1. mlock() failure logged
kkemu_init() previously called mlock() on the host flash buffer and
ignored the return. Many platforms cap unprivileged mlock at a few MB
(RLIMIT_MEMLOCK), and silent failure means the seed / FVK / PIN
derivation state in the buffer can be swapped to disk without anyone
knowing.
Now we check the return and log to stderr on failure ("flash buffer
may be swapped to disk; do not load production secrets"). Init still
succeeds — a dev/CI environment that hits the rlimit shouldn't be
blocked from running the emulator. Production hosts of libkkemu are
expected to treat the logged failure as a security warning and refuse
to load secrets.
2. Zero sensitive static buffers on kkemu_shutdown()
In dylib mode the library lives inside a long-running host process
(e.g. the Bun runtime in vault). Static rings, the frame ring, the
last-packed dedup buffer, and the display-pack scratch all have
static storage duration — they outlive the emulator session and are
visible to the host's memory image (core dumps, ptrace, GC roots).
They retain:
- rb_main_*: PIN, passphrase, signing inputs/outputs
- rb_debug_*: mnemonic + recovery-cipher state in DEBUG_LINK builds
- frame_ring + last_packed + display_packed_scratch: rendered OLED
bytes including PIN matrix, recovery words, address confirms,
signing summaries
Promoted the previously function-static `packed[2048]` in
kkemu_get_display() to a file-scope `display_packed_scratch` so
shutdown can reach it. All five buffers now go through trezor-crypto's
memzero() (compiler-can't-optimize-out) before munlock — the same
primitive the firmware uses everywhere it clears key material.
The host-owned flash buffer is still left for the caller to zero
(documented contract — host may want post-mortem inspection).
|
Pushed three additive commits addressing all three findings. #1 — Ring buffer sized for largest synchronous response (
|
…ntics The existing test_dylib_confirm_flow covers the caller-driven polling contract — Initialize / Wipe / LoadDevice / GetAddress — but never asks the firmware for a layout. Two changes that just landed in the firmware emulator runtime PR (BitHighlander/keepkey-firmware#217) need functional coverage that confirm-flow doesn't provide: 1. RINGBUF_CAPACITY in lib/emulator/ringbuf.h was bumped from 32 to 128. DebugLinkState's 2048-byte `layout` plus the rest of the message serializes to ~44 HID reports through the output ring; the previous capacity left effective room for 31 reports, so screenshot capture truncated mid-layout (msg_debug_write ignores emulatorSocketWrite's 0-on-full return). 2. fsm_msgDebugLinkGetState in lib/firmware/fsm_msg_debug.h now does a single display_refresh() instead of force_animation_start() + animate(). The old form overwrote static layouts with stale animation frames or no-ops depending on queue state, so screenshots captured something different from what the user was seeing. Both fixes are functionally invisible to the existing test suite. Without these tests, regressing either change ships green. This commit adds: - tests/test_dylib_screenshot.py — four tests: * test_layout_round_trip_fits_through_ring (RINGBUF_CAPACITY) * test_layout_repeated_reads_no_truncation (RINGBUF_CAPACITY) * test_layout_stable_across_idle_reads (canvas semantics) * test_layout_features_dont_corrupt_capture (iface separation) Constructs a fresh KeepKeyDebuglinkClient against the dylib singleton WITHOUT going through common.KeepKeyTest.setUp — that fixture wipes the device on every test and exercises the confirm-flow path that test_dylib_confirm_flow is itself a pending regression for. Reading a layout doesn't require any of that; we just init and ask DebugLink for the home-screen capture. - tests/config.py — explicit-transport precedence fix: Previously HID/WebUSB were always autodetected first. With a real KeepKey plugged in, KK_TRANSPORT=dylib was silently overridden — the dylib regression suite would either route to hardware or crash on hid.pyx. Now the explicit env var (KK_TRANSPORT=dylib) skips hardware enumeration entirely, the dylib path runs as requested, and the default (no env var set) falls back to the existing UDP behavior. Verified locally: cmake -DKK_EMULATOR=1 -DKK_BUILD_DYLIB=1 -DKK_DEBUG_LINK=ON \ -DCMAKE_POLICY_VERSION_MINIMUM=3.5 -B build-emu . cmake --build build-emu --target kkemulator_dylib KK_TRANSPORT=dylib KK_DYLIB=build-emu/lib/libkkemu.dylib \ PYTHONPATH=keepkeylib:. python -m pytest tests/test_dylib_screenshot.py ======================== 4 passed in 0.36s ======================== Out of scope: SignTx + other multi-step flows that go through confirm_helper. They share the same hang as test_dylib_confirm_flow's test_load_device_with_auto_confirm — copying the pattern would just produce a second red regression for the same underlying firmware bug, not new coverage. Once the confirm-flow regression goes green, signtx expansion is a follow-up.
…ntics The existing test_dylib_confirm_flow covers the caller-driven polling contract — Initialize / Wipe / LoadDevice / GetAddress — but never asks the firmware for a layout. Two changes that just landed in the firmware emulator runtime PR (BitHighlander/keepkey-firmware#217) need functional coverage that confirm-flow doesn't provide: 1. RINGBUF_CAPACITY in lib/emulator/ringbuf.h was bumped from 32 to 128. DebugLinkState's 2048-byte `layout` plus the rest of the message serializes to ~44 HID reports through the output ring; the previous capacity left effective room for 31 reports, so screenshot capture truncated mid-layout (msg_debug_write ignores emulatorSocketWrite's 0-on-full return). 2. fsm_msgDebugLinkGetState in lib/firmware/fsm_msg_debug.h now does a single display_refresh() instead of force_animation_start() + animate(). The old form overwrote static layouts with stale animation frames or no-ops depending on queue state, so screenshots captured something different from what the user was seeing. Both fixes are functionally invisible to the existing test suite. Without these tests, regressing either change ships green. This commit adds: - tests/test_dylib_screenshot.py — four tests: * test_layout_round_trip_fits_through_ring (RINGBUF_CAPACITY) * test_layout_repeated_reads_no_truncation (RINGBUF_CAPACITY) * test_layout_stable_across_idle_reads (canvas semantics) * test_layout_features_dont_corrupt_capture (iface separation) Constructs a fresh KeepKeyDebuglinkClient against the dylib singleton WITHOUT going through common.KeepKeyTest.setUp — that fixture wipes the device on every test and exercises the confirm-flow path that test_dylib_confirm_flow is itself a pending regression for. Reading a layout doesn't require any of that; we just init and ask DebugLink for the home-screen capture. - tests/config.py — explicit-transport precedence fix: Previously HID/WebUSB were always autodetected first. With a real KeepKey plugged in, KK_TRANSPORT=dylib was silently overridden — the dylib regression suite would either route to hardware or crash on hid.pyx. Now the explicit env var (KK_TRANSPORT=dylib) skips hardware enumeration entirely, the dylib path runs as requested, and the default (no env var set) falls back to the existing UDP behavior. Verified locally: cmake -DKK_EMULATOR=1 -DKK_BUILD_DYLIB=1 -DKK_DEBUG_LINK=ON \ -DCMAKE_POLICY_VERSION_MINIMUM=3.5 -B build-emu . cmake --build build-emu --target kkemulator_dylib KK_TRANSPORT=dylib KK_DYLIB=build-emu/lib/libkkemu.dylib \ PYTHONPATH=keepkeylib:. python -m pytest tests/test_dylib_screenshot.py ======================== 4 passed in 0.36s ======================== Out of scope: SignTx + other multi-step flows that go through confirm_helper. They share the same hang as test_dylib_confirm_flow's test_load_device_with_auto_confirm — copying the pattern would just produce a second red regression for the same underlying firmware bug, not new coverage. Once the confirm-flow regression goes green, signtx expansion is a follow-up.
Bumps deps/python-keepkey to bithighlander/master 73f2a2d (the merge commit of BitHighlander/python-keepkey#14), which adds the DylibTransport for in-process libkkemu testing plus four screenshot regression tests targeting this PR's own changes: - test_layout_round_trip_fits_through_ring → covers RINGBUF_CAPACITY - test_layout_repeated_reads_no_truncation - test_layout_stable_across_idle_reads → covers fsm_msg_debug animate→display_refresh - test_layout_features_dont_corrupt_capture → iface separation Adds a new CI job, python-dylib-tests, that builds libkkemu natively (NOT in the existing Docker emulator image — different artifact, different toolchain) and runs the four tests against it. Joins publish-emulator's gate so a Docker push can't ship without the dylib path being green. Pinned tooling (matches the firmware's own .python-version + the .proto generator the repo uses): Python 3.10, protobuf 3.20.3, nanopb 0.3.9.4.post3, protoc 3.21.12 KK_DEBUG_LINK=ON at cmake time (default OFF — without it, fsm_msgDebugLinkGetState is excluded and read_layout() hangs). generate-test-report's needs list is intentionally NOT updated — that job's PDF aggregation only knows about the existing JUnit + screenshot schema. The dylib JUnit uploads as its own artifact instead. Folding into the report is a follow-up.
|
Pushed Submodule bump
New CI job:
|
| PR #217 change | Test that locks it |
|---|---|
RINGBUF_CAPACITY 32→128 |
test_layout_round_trip_fits_through_ring (DebugLinkGetState 2048-byte payload must round-trip) |
fsm_msg_debug.h animate→display_refresh |
test_layout_stable_across_idle_reads |
| New libkkemu shared library API | test_layout_features_dont_corrupt_capture (iface 0 + iface 1 separation) |
| PIC propagation, mlock, memzero | not directly tested (build-time / kernel-level concerns) |
5 of the dylib tests pass locally; 1 is @unittest.skip documenting a separate firmware confirm-helper hang that's out of scope for this PR.
The first python-dylib-tests run failed in 20s on the protoc download:
unzip reported "End-of-central-directory signature not found" because
the URL 404'd and curl wrote the HTML error page to /tmp/protoc.zip.
Root cause: protobuf renamed releases from v3.21.x → v21.x at this
release ("protoc version reset" — the project decoupled protoc's
version from the C++ runtime's). The tag is `v21.12`, not `v3.21.12`,
and the file inside is `protoc-21.12-linux-x86_64.zip`, not
`protoc-3.21.12-...`.
Also added `curl -f` so the next typo fails the step on the HTTP error
instead of letting unzip belly-flop on a 404 page, plus a `file`
sanity-check on the downloaded archive.
Top-level CMakeLists.txt requires googletest unconditionally — the add_subdirectory(deps/googletest) call runs at configure time, before any target selection. Even though we only build kkemulator_dylib, configure fails with 'googletest missing' if the submodule isn't init'd. Caught by the second CI run on PR #217: protoc URL fix landed (config got past stage 1 of cmake), then died on the gtest check at line 57.
Pip-installed nanopb 0.3.9.4.post3 ships nanopb.proto + plugin.proto but NOT the generated nanopb_pb2.py / plugin_pb2.py files. Without them, nanopb_generator.py fails at import with: ImportError: attempted relative import with no known parent package at line 37 (`from .proto import nanopb_pb2, plugin_pb2`). The fix is to regenerate them — but with the PINNED protoc 3.21, NOT the github-runner's system protoc (which is too new and produces .pb2 files that require a protobuf runtime newer than the 3.20.3 we pinned, manifesting as 'Descriptors cannot be created directly'). Same trap I hit locally before this CI work landed; documented inline so the next person doesn't have to rediscover it.
…tor/) Two failures from the third dylib CI run: 1. ModuleNotFoundError: requests ethereum_tokens.def build step fetches token lists at compile time via Python; needs requests pip-installed. 2. ImportError: attempted relative import with no known parent package The protoc-gen-nanopb that protoc was finding via PATH was the COPY in <site-packages>/nanopb/generator/ — a raw .py file that fails when invoked as a top-level script. The pip install's console-script wrapper at <pyenv>/bin/protoc-gen-nanopb loads nanopb_generator as a module, so relative imports resolve. The previous PATH override prepended the broken generator/ dir ahead of bin/. Removed both PATH manipulations — setup-python already puts the python bin dir on PATH, which is where the working wrapper lives. Added `which protoc-gen-nanopb` to surface the resolved path in CI logs for any future debugging. The pb2 regeneration step still runs (the *_pb2.py files don't ship with the pip install), but the regenerated files now get loaded via the correct module path.
cmake CMakeLists.txt:78 does find_program for nanopb_generator.py
(specifically with the .py extension), which only exists in the pip
package's <site-packages>/nanopb/generator/ directory — not in the
console-script bin/ dir.
Last fix removed generator/ from PATH entirely to avoid its broken
protoc-gen-nanopb winning over the working bin/ wrapper. That flipped
the failure: cmake configure now reports 'Must install nanopb 0.3.9.4,
and put nanopb-nanopb-0.3.9.4/generator on your PATH'.
Right answer: APPEND generator/ to PATH, so:
- bin/ comes first → bin/protoc-gen-nanopb (working wrapper) wins
- generator/ comes last → only place nanopb_generator.py exists,
cmake's find_program resolves it there
Added `which protoc-gen-nanopb` and `which nanopb_generator.py` to
the configure step so future CI logs make the resolution unambiguous.
Linux build hits a duplicate-symbol link error: __stack_chk_guard is defined by both lib/board/keepkey_board.c (uintptr_t) and lib/emulator/setup.c (uint32_t). Apple's linker silently picks one (matches local macos arm64 builds that work); GNU ld is strict and fails with 'multiple definition'. Fixing requires deduping the symbol — out of scope for the current PR which is about the libkkemu runtime + screenshot tests, not the firmware's stack-canary plumbing. This commit: - runs-on: ubuntu-latest → macos-latest (arm64) - protoc download URL: linux-x86_64 → osx-aarch_64 - drops apt-get install (macos pre-installs cmake + clang) - replaces nproc with sysctl -n hw.ncpu - find prefers libkkemu.dylib, .so as fallback for future cross-plat - added inline comment + TODO documenting the Linux follow-up Once the __stack_chk_guard symbol is deduped (likely just remove emulator/setup.c's redefinition since lib/board's already provides the on-device version that emulator inherits), this job can flip back to a matrix that runs both ubuntu-latest + macos-latest.
Job hit "No module named pytest" on macos runner. The existing python-integration-tests job runs in a Docker image with pytest baked in; this new dylib job is on a vanilla macos-latest runner so it needs explicit install. Pin pytest-timeout too — even though it can't actually break the dylib's C busy-loop (per the long rationale in test_dylib_confirm_flow.py's @unittest.skip), it's used transitively by some pytest features.
The build step was producing libkkemu.dylib but only test results were
being uploaded. Add an upload-artifact step so reviewers / vault devs /
external auditors can download the dylib straight from the PR's CI run
without rebuilding the toolchain locally.
Artifact name: libkkemu-<full-sha> (avoids overwrite across PR pushes)
Path: env DYLIB_PATH (set by the build step)
Retention: 30 days
Trigger: always() so a downstream test failure doesn't lose
the binary that just built successfully
if-no-files-found: error catches a future build-step regression that
silently drops the artifact.
…ntics The existing test_dylib_confirm_flow covers the caller-driven polling contract — Initialize / Wipe / LoadDevice / GetAddress — but never asks the firmware for a layout. Two changes that just landed in the firmware emulator runtime PR (BitHighlander/keepkey-firmware#217) need functional coverage that confirm-flow doesn't provide: 1. RINGBUF_CAPACITY in lib/emulator/ringbuf.h was bumped from 32 to 128. DebugLinkState's 2048-byte `layout` plus the rest of the message serializes to ~44 HID reports through the output ring; the previous capacity left effective room for 31 reports, so screenshot capture truncated mid-layout (msg_debug_write ignores emulatorSocketWrite's 0-on-full return). 2. fsm_msgDebugLinkGetState in lib/firmware/fsm_msg_debug.h now does a single display_refresh() instead of force_animation_start() + animate(). The old form overwrote static layouts with stale animation frames or no-ops depending on queue state, so screenshots captured something different from what the user was seeing. Both fixes are functionally invisible to the existing test suite. Without these tests, regressing either change ships green. This commit adds: - tests/test_dylib_screenshot.py — four tests: * test_layout_round_trip_fits_through_ring (RINGBUF_CAPACITY) * test_layout_repeated_reads_no_truncation (RINGBUF_CAPACITY) * test_layout_stable_across_idle_reads (canvas semantics) * test_layout_features_dont_corrupt_capture (iface separation) Constructs a fresh KeepKeyDebuglinkClient against the dylib singleton WITHOUT going through common.KeepKeyTest.setUp — that fixture wipes the device on every test and exercises the confirm-flow path that test_dylib_confirm_flow is itself a pending regression for. Reading a layout doesn't require any of that; we just init and ask DebugLink for the home-screen capture. - tests/config.py — explicit-transport precedence fix: Previously HID/WebUSB were always autodetected first. With a real KeepKey plugged in, KK_TRANSPORT=dylib was silently overridden — the dylib regression suite would either route to hardware or crash on hid.pyx. Now the explicit env var (KK_TRANSPORT=dylib) skips hardware enumeration entirely, the dylib path runs as requested, and the default (no env var set) falls back to the existing UDP behavior. Verified locally: cmake -DKK_EMULATOR=1 -DKK_BUILD_DYLIB=1 -DKK_DEBUG_LINK=ON \ -DCMAKE_POLICY_VERSION_MINIMUM=3.5 -B build-emu . cmake --build build-emu --target kkemulator_dylib KK_TRANSPORT=dylib KK_DYLIB=build-emu/lib/libkkemu.dylib \ PYTHONPATH=keepkeylib:. python -m pytest tests/test_dylib_screenshot.py ======================== 4 passed in 0.36s ======================== Out of scope: SignTx + other multi-step flows that go through confirm_helper. They share the same hang as test_dylib_confirm_flow's test_load_device_with_auto_confirm — copying the pattern would just produce a second red regression for the same underlying firmware bug, not new coverage. Once the confirm-flow regression goes green, signtx expansion is a follow-up.
PR #217's libkkemu/* files weren't pre-formatted; my PR #222 merge resolution appended TIP-712 handler with a stray trailing line in fsm_msg_tron.h; zxappliquid.c is pre-existing debt that the strict CI gate now catches at -Werror. No semantic changes — pure formatting normalization needed to pass the lint-format CI gate on integration branches that combine multiple feature PRs.
…emulator-runtime # Conflicts: # deps/python-keepkey
…ntics The existing test_dylib_confirm_flow covers the caller-driven polling contract — Initialize / Wipe / LoadDevice / GetAddress — but never asks the firmware for a layout. Two changes that just landed in the firmware emulator runtime PR (BitHighlander/keepkey-firmware#217) need functional coverage that confirm-flow doesn't provide: 1. RINGBUF_CAPACITY in lib/emulator/ringbuf.h was bumped from 32 to 128. DebugLinkState's 2048-byte `layout` plus the rest of the message serializes to ~44 HID reports through the output ring; the previous capacity left effective room for 31 reports, so screenshot capture truncated mid-layout (msg_debug_write ignores emulatorSocketWrite's 0-on-full return). 2. fsm_msgDebugLinkGetState in lib/firmware/fsm_msg_debug.h now does a single display_refresh() instead of force_animation_start() + animate(). The old form overwrote static layouts with stale animation frames or no-ops depending on queue state, so screenshots captured something different from what the user was seeing. Both fixes are functionally invisible to the existing test suite. Without these tests, regressing either change ships green. This commit adds: - tests/test_dylib_screenshot.py — four tests: * test_layout_round_trip_fits_through_ring (RINGBUF_CAPACITY) * test_layout_repeated_reads_no_truncation (RINGBUF_CAPACITY) * test_layout_stable_across_idle_reads (canvas semantics) * test_layout_features_dont_corrupt_capture (iface separation) Constructs a fresh KeepKeyDebuglinkClient against the dylib singleton WITHOUT going through common.KeepKeyTest.setUp — that fixture wipes the device on every test and exercises the confirm-flow path that test_dylib_confirm_flow is itself a pending regression for. Reading a layout doesn't require any of that; we just init and ask DebugLink for the home-screen capture. - tests/config.py — explicit-transport precedence fix: Previously HID/WebUSB were always autodetected first. With a real KeepKey plugged in, KK_TRANSPORT=dylib was silently overridden — the dylib regression suite would either route to hardware or crash on hid.pyx. Now the explicit env var (KK_TRANSPORT=dylib) skips hardware enumeration entirely, the dylib path runs as requested, and the default (no env var set) falls back to the existing UDP behavior. Verified locally: cmake -DKK_EMULATOR=1 -DKK_BUILD_DYLIB=1 -DKK_DEBUG_LINK=ON \ -DCMAKE_POLICY_VERSION_MINIMUM=3.5 -B build-emu . cmake --build build-emu --target kkemulator_dylib KK_TRANSPORT=dylib KK_DYLIB=build-emu/lib/libkkemu.dylib \ PYTHONPATH=keepkeylib:. python -m pytest tests/test_dylib_screenshot.py ======================== 4 passed in 0.36s ======================== Out of scope: SignTx + other multi-step flows that go through confirm_helper. They share the same hang as test_dylib_confirm_flow's test_load_device_with_auto_confirm — copying the pattern would just produce a second red regression for the same underlying firmware bug, not new coverage. Once the confirm-flow regression goes green, signtx expansion is a follow-up.
Summary
Adds an in-process emulator library (
libkkemu,.dylibon macOS /.soon linux) so a host process can drive firmware logic via FFI instead of spinning up a separatekkemubinary and talking to it over UDP. Used by vault v11 to host the emulator inside the Bun process for the wallet onboarding + Zcash testing flows; also lets researchers fuzz / drive firmware behavior from arbitrary host code without the network hop.This is the runtime-only slice extracted from
alphaso reviewers can scan it on its own. Other emulator-adjacent work currently onalpha(zoo CI screenshot pipeline, version bump 7.14.0 → 7.15.0, BITCOIN_ONLY define, Zcash firmware, EVM clear-signing, BIP-85) lands in separate PRs.What this PR contains
Two commits, 11 files, +544 / -20:
feat(emulator): libkkemu shared library + native macOS buildinclude/keepkey/emulator/libkkemu.h,lib/emulator/libkkemu.c(259 LOC),lib/emulator/ringbuf.{c,h}(lock-free SPSC byte ring for host I/O)lib/emulator/CMakeLists.txt(libkkemu target),tools/emulator/CMakeLists.txt(refactor to shareFIRMWARE_LIBSbetween standalone + dylib),lib/emulator/setup.c(setup_urandom_only()),lib/emulator/udp.c(#ifdef KKEMU_DYLIBtrampolines +KEEPKEY_UDP_PORTenv override for standalone),CMakeLists.txt(CMP0079policy +KK_BUILD_DYLIBoption, default OFF)kkemubinary continues to build withKK_EMULATOR=1alone, byte-identical to before.fix(emulator): clear canvas semantics for DebugLinkGetState screenshot capturelib/firmware/fsm_msg_debug.h: replacesforce_animation_start() + animate()(which overwrites static canvases with stale animation frames) with a singledisplay_refresh(). Production firmware (KK_DEBUG_LINK=0) is unaffected — the function is excluded from non-debug builds entirely.Build
The flag
-DKK_BUILD_DYLIB=1is the gate; without it, the build is identical to develop.Build verification
develop:git diff --stat develop..HEADshows exactly the 11 emulator files, no submodule pointer drift, no version bumps, no BTC-only changes.nanopb 0.3.9.4toolchain (per the existing native-macOS emulator setup notes). Leaning on CI here.cmake -DKK_EMULATOR=1 -DKK_BUILD_DYLIB=1produces bothkkemu(standalone) andkkemulator_dylib(shared lib).kkemuUDP standalone build is unchanged whenKK_BUILD_DYLIB=0(the default).Out of scope (will be follow-up PRs)