@@ -432,13 +432,13 @@ def set_mnemonic(self, mnemonic):
432432
433433 def call_raw (self , msg ):
434434
435- # Screenshot capture disabled in call_raw (captures idle screens, adds latency).
436- # Real confirmation screenshots are captured in callback_ButtonRequest instead.
437- # Exception: capture on Failure (rejection screens like invalid BIP-39 word).
435+ # Screenshot capture is disabled in call_raw: a wire Failure is often
436+ # emitted after the UI has already returned to the lock/home screen.
437+ # Treating that framebuffer as operation evidence created convincing
438+ # but unrelated blank/lock frames. Tests that claim a visible rejection
439+ # capture it explicitly at the point the firmware renders it.
438440
439441 resp = super (DebugLinkMixin , self ).call_raw (msg )
440- if isinstance (resp , proto .Failure ):
441- self ._capture_oled ()
442442 self ._check_request (resp )
443443 return resp
444444
@@ -460,20 +460,19 @@ def _check_request(self, msg):
460460 raise CallException (types .Failure_Other ,
461461 "Expected %s, got %s" % (pprint (expected ), pprint (msg )))
462462
463- def _capture_oled (self ):
463+ def _capture_oled (self , layout = None ):
464464 """Capture current OLED layout to screenshot directory."""
465465 if not SCREENSHOT :
466466 return
467467 if not self .debug :
468- import sys
469- print ("[SCREENSHOT] SKIP: no debug link" , file = sys .stderr )
470- return
468+ raise RuntimeError ("screenshot capture requested without debug link" )
471469 try :
472- layout = self .debug .read_layout ()
470+ if layout is None :
471+ layout = self .debug .read_layout ()
473472 if not layout or len (layout ) < 1024 :
474- import sys
475- print ( "[SCREENSHOT] SKIP: layout too small (%d bytes)" % ( len ( layout ) if layout else 0 ), file = sys . stderr )
476- return
473+ raise RuntimeError (
474+ " layout too small (%d bytes)" %
475+ ( len ( layout ) if layout else 0 ))
477476 layout_bytes = len (layout )
478477 height = 64 if layout_bytes >= 2048 else 32
479478 rows = []
@@ -500,13 +499,73 @@ def _capture_oled(self):
500499 import sys , traceback
501500 print ("[SCREENSHOT] ERROR: %s" % e , file = sys .stderr )
502501 traceback .print_exc (file = sys .stderr )
502+ raise
503+
504+ def _capture_oled_after_animation (self , seconds , required_region = None ):
505+ """Capture the completed PIN/cipher frame, not its initial blank state."""
506+ if not SCREENSHOT :
507+ return
508+ # The emulator's PIN/recovery loop blocks while waiting for host
509+ # input. Wall-clock sleep alone therefore does not repaint: each
510+ # DebugLink request wakes the loop for one 20 ms animation tick.
511+ # Drive every required tick and retain the layout from the final poll.
512+ layout = None
513+ for _tick in range (int (seconds / 0.020 ) + 2 ):
514+ time .sleep (0.025 )
515+ layout = self .debug .read_layout ()
516+ if required_region :
517+ x0 , x1 , y0 , y1 = required_region
518+ lit = 0
519+ for y in range (y0 , y1 ):
520+ for x in range (x0 , x1 ):
521+ byte_index = x + (y // 8 ) * 256
522+ value = layout [byte_index ]
523+ if not isinstance (value , int ):
524+ value = ord (value )
525+ lit += (value >> (y % 8 )) & 1
526+ area = (x1 - x0 ) * (y1 - y0 )
527+ if lit < 32 or area - lit < 32 :
528+ raise RuntimeError (
529+ "animated OLED evidence lacks grid contrast "
530+ "(%d lit of %d pixels)" % (lit , area ))
531+ self ._capture_oled (layout )
532+
533+ def _read_oled_after_settle (self ):
534+ """Return a stable confirmation frame after the render-loop handoff.
535+
536+ A ButtonRequest can reach the host one or more emulator ticks before
537+ its OLED update. Sampling immediately duplicated the preceding prompt
538+ and omitted the security-relevant next prompt while preserving the
539+ expected file count. DebugLink reads advance the blocked render loop;
540+ require at least five polls and three identical final layouts.
541+ """
542+ candidate = None
543+ stable_reads = 0
544+ for tick in range (20 ):
545+ time .sleep (0.025 )
546+ layout = self .debug .read_layout ()
547+ if layout == candidate :
548+ stable_reads += 1
549+ else :
550+ candidate = layout
551+ stable_reads = 1
552+ if tick >= 4 and stable_reads >= 3 :
553+ return layout
554+ raise RuntimeError (
555+ 'OLED confirmation did not settle before evidence capture' )
556+
557+ def _capture_oled_after_settle (self ):
558+ """Capture the settled frame used for confirmation evidence."""
559+ if not SCREENSHOT :
560+ return
561+ self ._capture_oled (self ._read_oled_after_settle ())
503562
504563 def callback_ButtonRequest (self , msg ):
505564 if self .verbose :
506565 log ("ButtonRequest code: " + get_buttonrequest_value (msg .code ))
507566
508- # Capture OLED screenshot BEFORE pressing button (confirmation screen)
509- self ._capture_oled ()
567+ # Capture the completed OLED frame BEFORE pressing the button.
568+ self ._capture_oled_after_settle ()
510569
511570 if self .auto_button :
512571 if self .verbose :
@@ -520,6 +579,9 @@ def callback_ButtonRequest(self, msg):
520579 return proto .ButtonAck ()
521580
522581 def callback_PinMatrixRequest (self , msg ):
582+ # Firmware animates the randomized grid for PIN_MAX_ANIMATION_MS
583+ # (1000 ms). Sampling immediately captures only the prompt/blank mask.
584+ self ._capture_oled_after_animation (1.05 , (192 , 256 , 0 , 64 ))
523585 if self .pin_correct :
524586 pin = self .debug .read_pin_encoded ()
525587 else :
0 commit comments