@@ -1476,6 +1476,297 @@ static bool backup_confirm_screen_lines_do_not_wrap(void)
14761476 return true;
14771477}
14781478
1479+ /* Lit rows of the nav chrome, scanned band by band. The chrome packs a title
1480+ * bar, three list rows and a confirm bar into 135 pixels with single-digit
1481+ * gaps between them, and draw_centered_box wraps rather than clips - so a
1482+ * title or a button label one word too long lands in the band below instead
1483+ * of running off the glass, exactly the failure the dice-hint check above
1484+ * exists for. Bands are the geometry in seedtool_render.c (NAV_BACK_HEIGHT,
1485+ * LIST_TOP, NAV_ROW_HEIGHT, NAV_BAR_Y), kept in sync with the numbers here. */
1486+ static bool nav_band_is_clear (const int from , const int to )
1487+ {
1488+ const uint16_t * const pixels = seedtool_render_pixels ();
1489+ for (int y = from ; y < to ; ++ y ) {
1490+ for (int x = 0 ; x < SEEDTOOL_DISPLAY_WIDTH ; ++ x ) {
1491+ if (pixels [y * SEEDTOOL_DISPLAY_WIDTH + x ] != 0x0000 ) {
1492+ return false;
1493+ }
1494+ }
1495+ }
1496+ return true;
1497+ }
1498+
1499+ /* Nothing lit at or right of `x` in the title band. Measured on the right
1500+ * rather than over the arrow on the left, because that is the side a title too
1501+ * wide for its column shows up on: draw_centered_box centres by an offset it
1502+ * clamps at zero, so an overlong line starts hard against its left edge and
1503+ * runs off the right one. A title that clears this has cleared the arrow. */
1504+ static bool nav_title_stays_in_its_column (const int x )
1505+ {
1506+ const uint16_t * const pixels = seedtool_render_pixels ();
1507+ for (int y = 0 ; y < 20 ; ++ y ) {
1508+ for (int column = x ; column < SEEDTOOL_DISPLAY_WIDTH ; ++ column ) {
1509+ if (pixels [y * SEEDTOOL_DISPLAY_WIDTH + column ] != 0x0000 ) {
1510+ return false;
1511+ }
1512+ }
1513+ }
1514+ return true;
1515+ }
1516+
1517+ /* The confirm bar's band (NAV_BAR_Y..the bottom of the glass), with its label
1518+ * required to stay a few pixels clear of both edges. */
1519+ /* NAV_BAR_Y in seedtool_render.c, kept in sync with this. */
1520+ #define NAV_BAR_BAND_Y 118
1521+
1522+ static bool nav_bar_label_fits (void )
1523+ {
1524+ const uint16_t * const pixels = seedtool_render_pixels ();
1525+ int leftmost = SEEDTOOL_DISPLAY_WIDTH , rightmost = -1 ;
1526+ for (int y = NAV_BAR_BAND_Y ; y < SEEDTOOL_DISPLAY_HEIGHT ; ++ y ) {
1527+ for (int x = 0 ; x < SEEDTOOL_DISPLAY_WIDTH ; ++ x ) {
1528+ if (pixels [y * SEEDTOOL_DISPLAY_WIDTH + x ] == 0x0000 ) {
1529+ continue ;
1530+ }
1531+ /* The rule drawn along the top of an unfilled bar spans the whole
1532+ * width and is not the label; skipped by row, not by colour, so a
1533+ * label sharing the rule's colour still counts. */
1534+ if (y == NAV_BAR_BAND_Y ) {
1535+ continue ;
1536+ }
1537+ if (x < leftmost ) {
1538+ leftmost = x ;
1539+ }
1540+ if (x > rightmost ) {
1541+ rightmost = x ;
1542+ }
1543+ }
1544+ }
1545+ if (rightmost < 0 ) {
1546+ return false; /* the label never reached the screen */
1547+ }
1548+ return leftmost >= 4 && rightmost <= SEEDTOOL_DISPLAY_WIDTH - 5 ;
1549+ }
1550+
1551+ static bool nav_chrome_bands_do_not_collide (void )
1552+ {
1553+ /* Twelve rows of the widest label review_and_confirm can build: two
1554+ * digits, a dot, a space and the longest word in the BIP39 list. */
1555+ static const char * const words [12 ] = { "01. wildcat" , "02. abandon" , "03. zoo" , "04. mosquito" , "05. jealous" ,
1556+ "06. abandon" , "07. abandon" , "08. abandon" , "09. abandon" , "10. abandon" , "11. abandon" , "12. abandon" };
1557+ /* Both titles review_and_confirm shows, and the label on its confirm bar,
1558+ * copied from seedtool_app.c the same way the dice hints above are. */
1559+ static const char * const titles [] = { "Review words" , "Review - fix a word" };
1560+ for (size_t i = 0 ; i < sizeof (titles ) / sizeof (titles [0 ]); ++ i ) {
1561+ /* Every state the chrome can be drawn in, since each paints something
1562+ * the others do not: the arrow highlighted, an item highlighted with
1563+ * the confirm bar dimmed, and the confirm bar filled. */
1564+ static const size_t selections [] = { SEEDTOOL_NAV_BACK , 0 , SEEDTOOL_NAV_CONFIRM };
1565+ for (size_t s = 0 ; s < sizeof (selections ) / sizeof (selections [0 ]); ++ s ) {
1566+ /* Item 0 stands for the invalid-checksum screen, the one case
1567+ * where a word is the selection and the bar cannot be taken - so
1568+ * that pass is the one that draws the bar dimmed. */
1569+ const bool confirm_enabled = selections [s ] != 0 ;
1570+ const seedtool_nav_t nav = { .selected = selections [s ],
1571+ .confirm = "Continue" ,
1572+ .confirm_enabled = confirm_enabled ,
1573+ .back = true };
1574+ seedtool_render_nav_list (& nav , titles [i ], words , 12 , 0 );
1575+ /* Title bar ends at 20, rows run 21..116 (LIST_TOP + 3 *
1576+ * NAV_ROW_HEIGHT, less the 2px each row leaves under itself), the
1577+ * confirm bar starts at 118. Two gaps must stay dark, or a band
1578+ * has grown into its neighbour. */
1579+ if (!nav_band_is_clear (20 , 21 ) || !nav_band_is_clear (115 , 118 )) {
1580+ return false;
1581+ }
1582+ /* The title's column is inset by NAV_BACK_X + NAV_BACK_WIDTH at
1583+ * both ends, so its right edge is that far in from the glass. */
1584+ if (!nav_title_stays_in_its_column (SEEDTOOL_DISPLAY_WIDTH - (2 + 20 ))) {
1585+ return false;
1586+ }
1587+ /* The label on the bar, measured only in the states where the
1588+ * bar is not filled - a filled bar lights every pixel of the band
1589+ * by design, including the bottom row, and would swamp this. The
1590+ * label is drawn on one line by draw_centered_in, which does not
1591+ * wrap, so an overlong one runs off the right edge instead of
1592+ * down; measured on both sides so it is held clear of the glass
1593+ * rather than merely on it. */
1594+ if (selections [s ] != SEEDTOOL_NAV_CONFIRM && !nav_bar_label_fits ()) {
1595+ return false;
1596+ }
1597+ }
1598+ }
1599+ /* The text screen wearing the same chrome: enter_word_number's check of
1600+ * the number just typed. Its body is drawn by draw_centered, which wraps
1601+ * rather than clips, so line 2 growing to two lines would march down
1602+ * towards the bar - and the bar's own label is the widest this chrome
1603+ * carries anywhere. Strings copied from seedtool_app.c, the same way the
1604+ * dice hints above are. */
1605+ /* A menu wears the chrome with no confirm bar at all: its rows are its
1606+ * actions, so the arrow is the only control and the bar's band must stay
1607+ * dark rather than holding an empty outline. */
1608+ for (size_t s = 0 ; s < 2 ; ++ s ) {
1609+ const seedtool_nav_t nav = { .selected = s ? SEEDTOOL_NAV_BACK : 0 , .back = true };
1610+ seedtool_render_nav_list (& nav , "Word entry" , words , 2 , 0 );
1611+ if (!nav_band_is_clear (NAV_BAR_BAND_Y , SEEDTOOL_DISPLAY_HEIGHT )) {
1612+ return false;
1613+ }
1614+ }
1615+ static const struct {
1616+ const char * title ;
1617+ const char * line1 ;
1618+ const char * line2 ;
1619+ const char * label ;
1620+ } screens [] = {
1621+ /* Every screen on the chrome, with the widest text each can build -
1622+ * the longest word in the list, and every digit of the largest word
1623+ * number twice over. Copied from seedtool_app.c the same way the dice
1624+ * hints above are. */
1625+ { "Word 12/24" , "mosquito" , "Number 2048 of 2048" , "Use this word" },
1626+ { "Confirm backup" , "Retype 8 of the 24 words" , "Have your backup ready" , "Start quiz" },
1627+ { "Checksum valid" , "BIP39 English" , "Derivation unlocked" , "Open wallet" },
1628+ { "Word doesn't match" , "Check your backup" , NULL , "Try again" },
1629+ { "Compact SeedQR" , "Encodes your ENTIRE seed" , "A photo = total loss of funds" , "Show QR" },
1630+ { "QR export" , "Account key included" , "A photo reveals every address" , "Show QR" },
1631+ { "Descriptor export" , "Account key included" , "A photo reveals every address" , "Show descriptor" },
1632+ { "Confirm passphrase" , "Enter it a second time" , "Exact match required" , "Enter again" },
1633+ { "Poor entropy!" , "128 of 128 bits" , NULL , "Proceed anyway" },
1634+ { "Pattern detected!" , NULL , NULL , "Proceed anyway" },
1635+ };
1636+ for (size_t i = 0 ; i < sizeof (screens ) / sizeof (screens [0 ]); ++ i ) {
1637+ for (int on_back = 0 ; on_back < 2 ; ++ on_back ) {
1638+ const seedtool_nav_t nav = { .selected = on_back ? SEEDTOOL_NAV_BACK : SEEDTOOL_NAV_CONFIRM ,
1639+ .confirm = screens [i ].label ,
1640+ .confirm_enabled = true,
1641+ .back = true };
1642+ seedtool_render_nav_text (& nav , screens [i ].title , screens [i ].line1 , screens [i ].line2 , NULL );
1643+ /* Line 2 sits at 65 and the 16px face is that tall again, so its
1644+ * wraps land at 81, 97, 113 - and 113 is inside the bar's own
1645+ * margin. One wrap is tolerated because the export warnings
1646+ * already take it on the plain screen this chrome inherits its
1647+ * body layout from ("A photo reveals every address" has always
1648+ * been two lines there); a second is what would reach the bar. */
1649+ if (!nav_band_is_clear (20 , 21 ) || !nav_band_is_clear (97 , 118 )) {
1650+ return false;
1651+ }
1652+ if (!nav_title_stays_in_its_column (SEEDTOOL_DISPLAY_WIDTH - (2 + 20 ))) {
1653+ return false;
1654+ }
1655+ /* Measured only with the arrow selected, for the same reason as
1656+ * above: a selected bar is filled and would swamp this. */
1657+ if (on_back && !nav_bar_label_fits ()) {
1658+ return false;
1659+ }
1660+ }
1661+ }
1662+ /* The same chrome with the entropy quality bar drawn in. Its band is
1663+ * different: the bar occupies DICE_BAR_Y=90..103, so what is checked here
1664+ * is the 104..118 gap between it and the confirm bar. A body line wrapping
1665+ * *into* the quality bar is not checked here and does not need to be -
1666+ * dice_screen_hints_clear_the_bar above already holds these same hints to
1667+ * that, at the same body heights this chrome inherits. */
1668+ static const struct {
1669+ const char * title ;
1670+ const char * line1 ;
1671+ const char * line2 ;
1672+ const char * label ;
1673+ } dice [] = {
1674+ { "Coins" , "256 flips needed" , "Red bar = non-random" , "Start" },
1675+ { "D6 dice" , "99 rolls needed" , "Red bar = non-random" , "Start" },
1676+ { "Diamonds" , "99 cards needed" , "Return & reshuffle each card" , "Start" },
1677+ { "Coins" , "256 of 256 bits" , "Looks good" , "Generate seed" },
1678+ };
1679+ const seedtool_progress_t full = { .rolls_pct = 100 , .entropy_pct = 100 , .warn = false, .complete = true };
1680+ for (size_t i = 0 ; i < sizeof (dice ) / sizeof (dice [0 ]); ++ i ) {
1681+ for (int on_back = 0 ; on_back < 2 ; ++ on_back ) {
1682+ const seedtool_nav_t nav = { .selected = on_back ? SEEDTOOL_NAV_BACK : SEEDTOOL_NAV_CONFIRM ,
1683+ .confirm = dice [i ].label ,
1684+ .confirm_enabled = true,
1685+ .back = true };
1686+ seedtool_render_nav_dice (& nav , dice [i ].title , dice [i ].line1 , dice [i ].line2 , & full );
1687+ if (!nav_band_is_clear (20 , 21 ) || !nav_band_is_clear (104 , 118 )) {
1688+ return false;
1689+ }
1690+ if (!nav_title_stays_in_its_column (SEEDTOOL_DISPLAY_WIDTH - (2 + 20 ))) {
1691+ return false;
1692+ }
1693+ if (on_back && !nav_bar_label_fits ()) {
1694+ return false;
1695+ }
1696+ }
1697+ }
1698+ /* The notices: no arrow, so the title centres across the whole glass and
1699+ * has the full width to fit in - but the bar is still there, and the body
1700+ * still wraps rather than clips. Strings from seedtool_app.c. */
1701+ static const struct {
1702+ const char * title ;
1703+ const char * line1 ;
1704+ const char * line2 ;
1705+ const char * label ;
1706+ } notices [] = {
1707+ { "Invalid checksum" , "Check your words" , "Fix one to continue" , "Fix a word" },
1708+ { "Passphrase mismatch" , "Nothing was derived" , "Try again" , "Try again" },
1709+ { "Backup confirmed" , "Words matched" , NULL , "Continue" },
1710+ { "Too long for a QR" , "Compact SeedQR" , "Read it as text instead" , "OK" },
1711+ { "Error" , "Could not derive addresses" , NULL , "OK" },
1712+ { "Error" , "Could not compute" , "word numbers" , "OK" },
1713+ };
1714+ for (size_t i = 0 ; i < sizeof (notices ) / sizeof (notices [0 ]); ++ i ) {
1715+ const seedtool_nav_t nav = { .selected = SEEDTOOL_NAV_CONFIRM ,
1716+ .confirm = notices [i ].label ,
1717+ .confirm_enabled = true,
1718+ .back = false };
1719+ seedtool_render_nav_text (& nav , notices [i ].title , notices [i ].line1 , notices [i ].line2 , NULL );
1720+ if (!nav_band_is_clear (20 , 21 ) || !nav_band_is_clear (97 , NAV_BAR_BAND_Y )) {
1721+ return false;
1722+ }
1723+ }
1724+ /* The paged screens. Their bodies run lower than the two-line ones -
1725+ * screen4's fourth row ends at 103 - and the page counter goes in what is
1726+ * left, so the band that must stay clear is only 116..118. Body lines are
1727+ * already held to their own widths by seedtool_render_fit, which is what
1728+ * page_text splits by; what is new here is the counter, and whether the
1729+ * two together clear the bar. */
1730+ static const size_t cursors [] = { SEEDTOOL_NAV_BACK , 0 , SEEDTOOL_NAV_CONFIRM };
1731+ for (size_t c = 0 ; c < sizeof (cursors ) / sizeof (cursors [0 ]); ++ c ) {
1732+ /* A full-width transcript line, and the widest counter either paged
1733+ * screen can reach: MAX_PAGE_LINES=24 gives page_text eight pages. */
1734+ const seedtool_nav_t paged = { .selected = cursors [c ],
1735+ .confirm = "Continue" ,
1736+ .confirm_enabled = true,
1737+ .back = true,
1738+ .counter = "8/8" };
1739+ seedtool_render_nav_text (& paged , "Canonical transcript" , "20-1-2-1-4-1-1-1-1-1-1-1-1-" ,
1740+ "1-1-1-1-1-1-1-1-1-20-1-1-1-" , "1-1-1-1-1-1-1-1-1-1" );
1741+ if (!nav_band_is_clear (20 , 21 ) || !nav_band_is_clear (116 , 118 )) {
1742+ return false;
1743+ }
1744+ if (!nav_title_stays_in_its_column (SEEDTOOL_DISPLAY_WIDTH - (2 + 20 ))) {
1745+ return false;
1746+ }
1747+ if (cursors [c ] != SEEDTOOL_NAV_CONFIRM && !nav_bar_label_fits ()) {
1748+ return false;
1749+ }
1750+ static const char * const rows [] = { "21. mosquito" , "22. mosquito" , "23. mosquito" , "24. mosquito" };
1751+ const seedtool_nav_t listed = { .selected = cursors [c ],
1752+ .confirm = "Continue" ,
1753+ .confirm_enabled = true,
1754+ .back = true,
1755+ .counter = "6/6" };
1756+ seedtool_render_nav_rows (& listed , "BIP39 word numbers" , rows , 4 );
1757+ if (!nav_band_is_clear (20 , 21 ) || !nav_band_is_clear (116 , 118 )) {
1758+ return false;
1759+ }
1760+ if (!nav_title_stays_in_its_column (SEEDTOOL_DISPLAY_WIDTH - (2 + 20 ))) {
1761+ return false;
1762+ }
1763+ if (cursors [c ] != SEEDTOOL_NAV_CONFIRM && !nav_bar_label_fits ()) {
1764+ return false;
1765+ }
1766+ }
1767+ return true;
1768+ }
1769+
14791770/* Known-good vectors straight from Krux's own base32 test suite
14801771 * (tests/test_bbqr.py, B32_TEST_BYTES/B32_ENCODED_STRINGS, unpadded form):
14811772 * proof this encoder produces byte-for-byte the same output a BBQr-reading
@@ -1787,6 +2078,10 @@ static int self_test(void)
17872078 fputs ("Origo backup confirmation screen self-test failed\n" , stderr );
17882079 return 1 ;
17892080 }
2081+ if (!nav_chrome_bands_do_not_collide ()) {
2082+ fputs ("Origo nav chrome geometry self-test failed\n" , stderr );
2083+ return 1 ;
2084+ }
17902085 /* Every value the QR screen offers must actually encode. The account key
17912086 * payload is the longest thing the device ever puts in a code - and with
17922087 * the account index now user-chosen rather than always "0", the account
0 commit comments