From 7e140cf794b559a5f0ffc2947cf18c71688b9312 Mon Sep 17 00:00:00 2001 From: Dmitry Muzyka Date: Wed, 3 Mar 2021 16:33:21 +0300 Subject: [PATCH 01/17] added a2 dev --- validitysensor/blobs.py | 2 ++ validitysensor/blobs_a2.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 validitysensor/blobs_a2.py diff --git a/validitysensor/blobs.py b/validitysensor/blobs.py index a9f16ab..5f6f4e1 100644 --- a/validitysensor/blobs.py +++ b/validitysensor/blobs.py @@ -11,6 +11,8 @@ def __load_blob(blob: str) -> bytes: elif usb.usb_dev().idVendor == 0x06cb: if usb.usb_dev().idProduct == 0x009a: from . import blobs_9a as blobs + elif usb.usb_dev().idProduct == 0x00a2: + from . import blobs_a2 as blobs globals()[blob] = getattr(blobs, blob) return globals()[blob] diff --git a/validitysensor/blobs_a2.py b/validitysensor/blobs_a2.py new file mode 100644 index 0000000..12fd208 --- /dev/null +++ b/validitysensor/blobs_a2.py @@ -0,0 +1,35 @@ +from .util import unhex + +#ok +init_hardcoded = unhex(''' +06020000017f157bcf4f0360ff4dae96e4721ee8834bf6ab6f2828da9ad2b3ff40ab7e5176c478dd2459747722fc914b8b98b22d5a9574f52c0e8b257b952e7ce12ed46ef77801481d698006fd09e5a9a8d7e9ca705a79549b984504778513b72668f9a5b7f41dd5537460ced2c46ac9a70e345638ba7cb04f3829105f64bd2ddf5a0cb418ad350b022d2eace9fe21042e402bc9524bd87208271849c199280ffed5a881a547074fcaf33a22aec694f028b9cf56818d9792066e3bbfc72d6930050d45c9a2edeba1abd07f3b40e3830ff31aa9dab715f59bb150686d5648808a9ff9e775f58f6c6e3910eb1a579bb206178545d54c5dd32a0d3e247e64d73afe84d701b42a8920ea02768734221260d083bebb39c176d129c01d1a0f1388497171402ba041afd925d71e76ce4905e44fa5fd528759a2c9f12895864b5aa394dc71a4a17161dd82197a10742fa5f3135c5e78820e36653fa3db535f57c71897242939d7da50f81070ce9ab81c61af6ac29a6c6c4a5df73ffd08542fb540e417939ed117298051d27736c2faf1c5577a2133b6f60ea7484c692faae2a49c51c8e6f69af77774bad51a9adeea3109d3611d6a437cdc0c356e46a8f9a6d3054c5519c17c986e54f61f8329006ce184c275984799dbdf5512188fa8ff10aa2ddc25eb697ebdcb156509309ade5d7909a734bf35ec69e062cb941c2ea4af0958110da93bd2b5f17fc9b1ebdbd8020a3c36f22a68f707226cec716126d6a830219521669bd59fa0e4bd35db6ef0aa2999d1c0e7acf67e598696cd58cc4bdb1b7c037ee9a085f784c4 +''') + + +## xz +init_hardcoded_clean_slate = unhex(''' +06020000016c5d73d6328c96d856b0d1ec9856580b787d5e56963cc0a798b8be5331b183a894f23e2ce1ba672a20d887f6cc884c3dd027441377e68fb1d2531329dc8fc35716132db947295abeff7d8644417bbb5ddf2319b4fbde57c50759adaee10ee8e60400a2d7f09a7c0d9934f7575a00c44a41692015de80959d9f0b0edee6e70037423b98733b5e1d2f892e0ff5f2e0bb364d79b6273856553e4df0e49e19883058f16fdf698345da73ee561ac8dfc2a38c3c594c5972edebdd056923be21749da4b089c8148f73ac9917bdcd6976ce17d77b919dc56daf21bc544aa4b3c6113037adf103e9b74a440b720104860a420bb211612d62da3a1327d2c4a6cf04bfb54d47218be9525c428078efa9241e6a42667e9079c91251026c6862575af5c40d7515615804816dda6c5495e6f9abd5c77e638925b28bbb0403d4f1aebf7d86bd5bc73a22a41fe2c7e19db79c404fd3db43c8d87fecaf6ecf163f74659768a4c6cbe562d8d14b8ce4710b9164a3bed100918d9872aaa9bc9a448b117823f1e86fe9ba9ac27649737ea497c83efff69022e6db7fe707947075d17c1de4bb1a712e4fddde31524379dea95497b675e4e70f4e88c8346807fff1083bee329d4cacc54d8576886272f1fb35c692b8c9156041a78aa4f16ce24cc3c4d0e2ca7eb957360a5d1a14da3046b73f996381b3f67b136d46fe7386eb0b0e2468be7771a153567ecd9192628f71a9382a6f555cb77b72c959d79c8111133777056cc73f40e5f448a064fbfabc5ab6c30d9f67cc87e641336ef37ff498b9af98212dbf3775b8a67b7ad933b5603dcbd3d7f9f2d90cefb292835d4671e07ba8573f4b8b62c537178939a41c1658f09574de0037ddb4e24f179db751e477527382b627f6990900eaeafc6a5f1e58966d96c9bfbac6e931a514c235e829c5868c065fd430df2d27ea21be94525ea5ccd22688d56780a4a9c7e2072b1084aabdc7d5c5ed243d903f5c9bf336b033d9508a0e6dc3b06757d0b9dfa26e85305ce953562dd43fc12fa2e81dff81ac0a1ca5ea0d3ebc209ea11046349c092c16c7680eb9d67c6bd1d57ab10fd7c07d93e00fb0370c89002051e1774b1555ce048eebc15ed4af35f9a4e2cf4b77bc5b0f58a7ab7a12aebf3c3a935c2bb278d446437b91c8cb7ce9e9c49b243a18c45472ce601ee001bc9371fef9c03fb3cbb57b119f62f81723148402275b46f917aab7ab4e54e0846eb40c9fa0ecb8f0d3d66fcfd9b6b5198c2e6451c29458726322c02401fe154cd24b21ddc6a3c4a69b95e2cc0c971cc2e78403bdeb70ffa5a343b7075609dc38cf5a0792ef55aa48991ca9237e911c66ec493dd1de176b2d7496100c803b58e071ea1ca3bb5c0fbdafc7a0ae50a56dab64002232d4c464bb78fd809a3ab74271fba1287fc1b6ad5e5c973b06a6984d95f0dc8909e6695aa6fe6cf5c263f657e068bde23045cab2264066d5e63d9db8374681a46a5c2d3127b34d848e31575d493ad64f263429c57808c36028f27a7070bf519c32b99f85fdedcfebc8033d519663f03bf082654bb7a774bccc701da2d743229cde9ff252472b3a53430db42a4c6dc49c8fecdf1c70c872a22dfb29c273b131f4e9d2bd29f46c7c6f23c0bc05579ed6e22015b9cf49f6705b093d85b5751d7a62921e5c51ed9c6bd0d4c44c3545e308712d5a0aea11399835e093bb606288b360e8d2634ade12876d5ed9cd264f5f1421f4814299b734c9c914d5157afc2bd558c304e6ac81a320098acbbfaaa689cf0ecca3c82792961af28bcae17f1031e819944dc4421cea037d2864db5878d9c0062345675a61f704e540182308629d07466d4d38c41f0a7271bf21480edf8bab7a9545a0074018764e81cddcb91c86722fdb9e01c9f7398c91df65c3c78e0b594a9a04f02b5b0a52f0218e68b1f0dd0a268c6f380b8c01ef821617f45fa651946027e35e8d9e05f68db61ffaeabdb693235d00b0bb4e964a6185a853e7457e57fabb2f25ab3cef8ccc20bc1132590debc6dbb9f084ac49f5e5d5228de6d216a2a5b6ed5e890c2ca726397838d715328a9d18d0df04de167613856bf09c898ef01855fccfcea1b3b0dd7e7b0fb209c324d1e4c9d6ee346eef81ded4325c7b0e6521857e37cd5ee076fdd8f778579f9510bae6d7501de64606d1c1f6f6bc0f5a64c1b9431b5ef24214e4893b9f7b4e182b81cbb3660974397716bb9bdfa90103c192758a03e25e23c35ce10afc0fb631be50eda1790310e9cddaea5e4426036aad63df8265c73b5571dd8379cab5ef759de28b5de50ed03f04135949f2aa6d1084ca796034ea187246ea8c8cbcb5c25974c1408d889d35bd7f90e8eebc6ea8c08fe26fee6bf5d4044472455ced150c74c3ce56657f1284c4478bfd5d2bacae3587c3055055813caed2e8ccba2739c0786754a7737a7ae610d1f4801e8d1f3ecc682f4303dbf0bf6f7510476269f1b64b15f2fc49f3f7e7bf5e22c2ce2b818c2020c19fcad9f2346aa15e7ce970985ca396ed11302bae047101a6bca2d2b7dd22cbc6cc6bd656fd56fa43f6da509d781277724f70b9751abf5f7f2a7fd2be6d0aef4b4b12fda505852f2da92240b2f9c588d711f48e6d6030b28122aab5d58e362ee06360ade5a25086666ca6734cb1c1f260003a1cc7fd7acbd48aec5c9f37a3f1fc63702a4080e1d3b33573d6a545b94ea917cebb0735fa227268665cee87084ddc1eb6efb789c72eba6c46fcba0c469fe98e1694b1fc7bed5d0da758b822c70a32f50fe4e4109ee27cc1fb6f3c7f62b7631f2f8034bd413645c07cf80a4a5838d8e1dde89cdb5028bb2af3d2131cd750f25a2c6d6fc8bfd1eade07abebec83a66f1c30c3916776234b86eafa6502d55dd714bb482073ebf6ce89ab2b339926fef55fbb125522fb8ddd00ba9dafcfe0440d53796cc22df0d01ee14e1341dcd0c13957f5bc3f20c89990da0da30f67d3dc4a0fa259a084007c475e302d3c7c62a29174157d9c56c0b40d03d81c53ee6e5e1144ba16b51896837679ad73519ce977be5ed20cfa7c70481242f4d8c1b42f34f2b06554536c59e02743f9f20d9fe131a2b8982dca65216374f29a0a1e5478b009d51511448151fabd889f360711a98c28749368c3f1b59ebfa268e5a084286ef550f72ca5902c5436e4551209b724675d3dd97b610d8d60c57181a14466c595d6fdaf7cd5e8c6a9327e7b0ada21429d399957aacf62cbe75cd6b550b1eee8df3d3122574e7b61925a7c469b4c1105411ee2362a7bf912c833bb9fdc4ce479ac1f0941bf8935ada689d9b2cddd76490d35914981236eda3336c2127807eb652cdfa0f23ebfd5af57a9df6c6ac52ef2ec4423b61e4e66cc52908296dd71db43308acbd22441bcc1237b7d34f4afe1892df61dc63abb5a5c584fbaf696d93b734614c575a39be4eade166e483534b702b0c264207c7be8633f5386a60c033942b26d793c7e868205ed0c735aa7b36f8375978fb76629cb2a628e45f4fc81be3e4435b65f6844512a5f12a0e1882b4e8d9109b97a0993f2b48853a4772c7d03b83043c8912fc317bf712dc6ed61de8828ae250eeb78cccd21434015e0f638948030ef39eebffb4df2ca0d26cb07e459940ee2f1eb64d4a840497ba0ea516c7d64d1d4ca74237171bd47caaf03c46a6d7e26767194df3bee3cb49c038f7a6f170d45434591022756a39b78c3905f44ffba8bf3b0a3ad157c10f2fc4e7869526cd824ac17a72a02c2aac119c69beff3890c6090a993849762799b929529137c234baefc0c691848 +''') + + +#xz +reset_blob = unhex(''' +060200000194ae50290095416eaf60993da4ef11bd6030d70ac18c4c0870986ad44419e1da1edd2dc3d1e4cf37b810b15ad23f2700019604bc5717e74a990d2c627ce86b3df1dc3d46e206853a378e8e1be1e485f258149c4340bdda556d4d40fa1765c514be8d598f270f13cab9776bb93d998b556f6c8237fdda3f12444fd55692431d5c9bfab9539886b29e8cf6d42ac870575e11431c675b1293b190945700d02696d11215d0ee171861abe26629ed8dac09072141d2808871c1bf02ff04f2c44554c129ecec8a33ad1bea2378161cc7d2b2a7564d8e80cd93422f983de8c239f47092d9af84a208f30cb9c7697f51a3eb3fef0ed88e9476f437bbb5e3fbbdba30b857e86b2cc47aadb1c3a5673ab48ecb98c6cd78fc2e1788178bef05a943c44b716f1c86615f57c86602f3194456cddc9fc9861ada5050bdd46561fe19cff1cda09f4a0a672374462029ed80b0dfd924698129ba54525b0e8c0302e1330205dfda9f312aa48f86ab14831e838372 +9962ba36ea2c856f54a0f4a08551cac4b1f3b06a65a640f4d842207739e804e3fdc479e553614de45874bb0de22cce180f70216f59847b59dd73b33ece0e1504b5b87c0ab2d271ec852e67be7fcd4093b4ba59f5ca2887f773457b481da04c0df080a2c1d332baf8e2255d1a9dd831d334dc279cc32e69b695e10fb2673a5057bc2aa4ada8230f259c8f7cf15cf364a4e93481aa0663f877375c3d17aff4ffc333bb741d58bc7bba30ea4f02318809d836be4a583f2aa72182ccc1f7eab12ccaabda2911a4c9a0d714436cc44ef0e4a784ee6bd107d41db2eee4ec82afd5b7cf38752af2033bf8494fd4454278360c606fe3911a3602a82e51d080f37ccea86f32983918fa37567ebba63d442b26d714dde23f7950b8d3efc082b929c914d9f346f604f75ce6adc510565b998ed93dd723e767f91553ebdaf009a8484bd5f2eb7634955418d719ed13778067981665d0944515c7ec5c330ce6bc7da13c905739905358e115831278949fac315f66619f36509314b6354fecee1f2e2b67a774d3c36f59a20389b4269de15c27498df9a957292691640e84cb753438725ef385c9a2fac86391779e2137a1b32229e1db77bb961a0733498ab474a81e3425972dcd91c9abc1aa043d008d7eea32fd14c554f49332bcc0a1afa66953468bde12d5cdf58c18c23e007ca0324ad9227d173a6a0932282f12e2cdf4644c4810b9522f897b982bcfdfa0adff24c9a319e3eaa45b80d7f05918e23a3bfc037b8d2590821feb96b102e88df26ec59a7adb874f9d3b441d27496e5d829ad46282301c023496181d44ca8602a4898f9a44b3ae1feaa9b0f03bb5a16d1b1f4bee84eb94f871131f363012833796cc7b70035ea7953d2c34df48048c5a1ed1a089ee873a041c733f903a973560bfb0f21478e7abbe31e4e8be25e142b207a32f14c9ac7e87df2ecfa5f834bcc386f98eeebe2778639bc92597f3fe35c4890b40ea6a4e6464e51dd7ee0ae576f383f760e90bca1b3eb2dae76cc8203ca9821b8294961cf0f47a01ed6f4f2fd3670ed72dbc3cad3fc97d917f094510cb29b99c159bbe076aac2f6394b9dc23ee3efaf7fb26abc1f43cd5f4f05cc07ccda68cdf4b6cd9d24ecd6a71b0124ba4bd8a683f9ffab0974fddc6213e48d0bd122ce040c2237dc96923f45f2f178ebae421d38112e7bedf2ffc21ea75504fe6d3ad6cca7e3d55533274fcdd62f756d556412d73b7b3239092a012e4ca8a2d06a1b2f99316ba629a34e4c42a204aff8b0164f506a2fbafc82d275333ff7a08e114208848cd74ebea814c65677ee08c91253520ef0eb633bbc185f2782ed15f3dcc0050ca5d82171d274565f37f9a79b704cc5cc9271051994c3f833604e39da2af76d052d38731a966538de7e216ebd8dbe93e53ac166d12ec545e6c593c420dfb622b533b543e1d55f77596 +97fa72c43a74c58329147cf195af1dac890d5929981d76384574f97f2b70cdee809c8fa0835e5bb6302c3a918ac7f029f61032ac70fac8f4a01e6c71af33e277501795e7a65cd2673ea0e31d075304b980dcc0a8a759259f768471f901bf188cd721b6192c44bf0e0ed9a2aa4f82c25c6c91b87ad10525795a16bc9a0bf4db440c115d59ba041a972dbb76e59e81faaa8828be586406bc2a820b6070d02237b1d49c2c8ba77e6a16a811c1361bcea917c250d4237fa702871013e082197da0889bfda7ef0296435f78ae9857a65ef22efc82e29cc89a709c91ed19dc2dc762d8ff911718ba76d44412a64739701c13a66b39195ec29f3f1383d02167b68e166d0cfde5747e6ebfb0680e32922dcd1b39da794f9815c788156671699f8bd2fece8380da706a77e6f23aff40e97b5f4e5b004f9d82317ca0eb6776a0bbeec9e796bf73b9eb1b8a408998c73b1443f9d46d1552d8af3c3fa5e2f5724feb0d60571467d869e99e12edf6b00c868ad22068413f9b3b5d08b517311817f946460025626b59ee3c1692339563e044db3dbbe4eafd1c1872b7ec09c13c98e0cc83958d8b25cf1a00874e7445bb0b0db0a0dfb12c1bc1b5324f62fdb7ef7dcb7794d28afe29c10c3a301147a9215c1050d65a20c09f4f303832add42ac29fd18370ff0e4fd6540ce35594d2d1dd28c7480f0c1c7eb55ae8045add9c96c1346eda69f58c011127cfd617016d6feb6ea0d146c029c4a6065f2a25e54a5c8ffe672e5c54fd6e3eabe24b7fc15a900e3b1db23afa26631e3abf14500dc3f7323228265c781ae0863ab6cfc1204fff3c82a8fcbb6b6fb3e745a93f9ee385f26479335d63e17535161a61c88ae8088d6ffde8e91ea10118ac305f0f100f662784f288bde31ce32b31de2fdb268bcf8047099129f18c6b1f9726d27a070f514f7f52ccdbd71d64c809c5a3968b62a3d53eb011c3189836f1e2d71ba6ed57213daff9a6c1370206f60666ae21475a36952269dd600ccfbd923f767225a657ffb6281e32668eae499430cfb484e2c7ce1e3ddfef16a871b7d55819fe3bfb1897beef0f44973a1c07b823e7a006ce8588a8851b078f5c799ef9b419c5867f1557d1b067d449b11e3eb778f9b46e522bf3ca6565fed4c4540f623c0a18244bacaeb2b6480ca898db8da9a108f6722a54e457e88c480cf8a4f800b57d64f938dd9e30dab0d991c642455eeded98bc58b5bf437eff1de56b3639f014093b873a3f780dc17e7c4280790848fd5f491d6e2c48b5fc2325cd88952228c52432049e655802fe125a00cd949b9ed54470c71b092bfb282e0816107646814d0187b523c8700459ba2323ff2cd66f8ee4e954bb8f13eaa2bcc7a2ce812046df9087bbc7ba7cc110e6aac465daf2c63b55c0930f0d4ca083a20df924f3d395871c39f296b1be3bfaec1ae5aa3956dc +5c4b497695b97a304cc1acd2708867bed635fedbe5a0567881bf2a691ffa55e9a922630f8de180c00d8a8d5f045dfd9fb029cc8b53dfab9d5533d07ddd2893652004d9495efc2ccf502e4184228652723f956c80b62c566a89e81a862108b2c32b20ded004581dcea6e5d5de730fae2a4bf2563e411078e41099b7e07aba9ee907ca4821e9633d74cbf0a2731d884b73374358e6f48077fcb49e4c57271085ce413e1ea0264b2a5fd94be7e86937ec246384c5cda5eac6b78944399e17d859d84727037b7539358e7b936687726233c0dff89ed5c2efaba3d74d0924304d7090695ad38b6c1d5777782421802dab55db87a7589fe17240b9b04ef82f9f1d962422e5144e5199b8b056221a401513d05e119022741711c0ebb4cd500d94bb462bca839e35cef0f2efb54a81f218f98a181fb6911cf51e12a7467c6ce95c4cadb90b51af7933eb66ca107eeced4fe9af1319416d02445811b78d0bed82b5c11e23699077c169f7e1475e27b8ce5e01d335b09878ca43ba06715181d303b117bacfe91948bc9a4ec19d1a2cf1fe86b1d4e7bdd72ae1cce0b3ff16d2eb707a758c96e18ae589cee08a1d6a2edc0197a698bfd027fed54c05beb0263d22637761bc5c473d358f547939170b6d9a922e143ed46f13207cb9219f2bbc5b328a7c6be7bc6274b3189ec4d629570eaf3b53d3acf67bebc3858b68a2366ede7ad5fe823966f157431c8af929963cfc71a35910f36f2a54238f02d80d9b87d1de6c55cfde41135bdc1d3d812282a219261ea1b89a37281840894d3a3aba6c054cd8c6b414fb151bd7a886fb05ad9ae52598da66821062bb9225f61709b858aa4d04507caac685ed1fba138b37fbc21c4b60168de2673e5a2fe97a42a193db17b85a9bf56b6b76216e252a2b7b546a2553f59b682f2fe8ffe5880fb6d4c71f167c9fd2dacefa86c815ff907533831c74ccf72926f8260166b0fcbc661a37533c2770a7c46da1e94c34f808eea1cf6fb25bcfd1882018d263f0d58733a1811788c7c02e43a80f5b2b51035ee366c9bde05cc8af9180ab677e5c6c430d42d73554e75c2095d555ba2df0686700b6a9a1d3b747178756b6c0a9dec0647eb65b48068e09d9b7ae494dfe78155a8d451ab58121f2e980c565d1414a7fa6d09ca4ee5cee33c4f3ce90d342c6f440f309c85388cb04e6c2fe6316313d285c0297091a2adcab6ca3cf5d816072f9080d00192b1e3e2a61376cb07e1903d5155fc67cab3e3872f2ea5c96dd0dd097c6e8be2da87285372581bdde00381bc3c467cbcc7bda35a474b45348d3a0f2a03c5578c83575822fd04c4931b01329fc5ae2c539665cece4434bbd3dabece80a849e2a8c9e00d9bd9a48557c6f15886d438670b5e4edc817303530ca046d1317c4913d34e3bfda7400cf027bad9020b9ba1b59d719e7a50ede002e12 +5d79b97c76ab35e3d0cd4ba0917749a22aee8ce3d1b7253f5f00eb06ec65aa435e5e337b7d63a6f4030636cf5523863efc9af50ece0982ceebf51817ba9f4b354e090741a032fe0467c5ee1c2920e19713a9498165f21475f8655644eb91f52faa64e11b2d66fe1e4a5d1c8522c7d87958942d8d685dc75b2a81b7534fecfe3c166435a8acbcfd396f080cf7a574dc992afa0862f2bda9a576e86fe63f6b86829728ad362b32be26bf8bdc58264500419887171ef3b0bfe846bd0d0fce41bfb1e7af16d49927ca5efffa72ef8176228dd995601e981d13495476504ce4d396060e967c75afd1dfbb32bfa6d17ea022d8dc232ba5ff569ffa99fc00cb017019246b1299164c0f73434f8144176f396ae002a103cb745494fac01e95de19d059e0e18a5ccd9d827a3211d50ea58476ea4d78f669f2ff079c582336be1d5f68d32a6c256f0b826ebc6cd829ebb81fbf2e2c592975686cb50ded7d825d218e4d3da3bf3e846ee33df0f0c644de6a88cdfb78181130cbd495b8b590ba426377319dcc6d63aa14a757c4aa787a3fb9f995868362c8734281b33e01e8f508d886a8442dc620381d33c3f1010db797520cd051f368b4da5ed48fad00089f1d5a92fcb516443c0f4c2fb1c7e65ab49324d19e52c3bd623351409cbb5b3c2250fcc1dc84c4ea666bfb29d1f834f76598bf59eb005517a8accffe01772f63a358959dc99bdf9b3857d4fd84202206fb43e6b85fa34761023459a2991b3b095b7c4ed13c7a30c7cc00b680af8810b15da9d86681c555504e526ae41e7067ce1338d171d7ccd8ebe3abc2bf2d88650ea20ee65b74f8bcb49b8fc0b821339d0a155f6c0b6a76d0106a74689b07f18b645a733009d55d2c041ffd0afaadc5a078246adc8c37e1c49d1bd3afe2d069762a66e9f542391f7cb4cd3b14e6b81b43db9ffe66b70252a0b08609951871d1f0d2fa0428b0c1ebb0511f9c5f1acef05e3cb0ba42e4bbb10e75b121c5976698616b76fca02a133af96b5e0ea65daa56a710bb901c9b7a21bce1597b8853588fd63a56f3602288841a7ed27c48672b794ba6b183579a70a8a6b57e543df8fabb57b3c8d36fae5398567db8ed2c1479b5e3df8a1aa7aca105310244c8aefd5df6ef5f0ac034ebd26962538073fb668d1759440683877d7c0974ef090a3fa2eb619027bf04fd4653b87e7774a7c314cd119bb29788f379289411eb8f5bd711c5d36b51b124bae704f17638665fdf3dc7f9d9424322f9ae69a52368cad46cbd83e4c06774a11106a0cdbc0ddcf7ce14d6d18b366ec22641e3a3ec85340324b6fc9b931fe5fae2ddc3f1197d33700195dea7bd9d68794ad681a69cb52ed4a7bf1394527f46dbe55f588972435927a2368944bad5e8cdcc5a328d8bb80f25a0cec36bb57c6ff23ecca2609e51492b65fe6de3edb2c5f7abfbbfa139 +7f7b8aa8dc1947e4da0575e6743c5137fbc856391e6d630d86a6e993ec0073099f16aaa51d28230fa262e94c9287660d106bc1ddc29f24adeb29dbc20918d02f90ae1406fa4584c4f7385b5526e1a03aecd30c112d657a8f076ffdc3bf6e4eaa6a5c3e2e74fe97a4bc7a81e70d89eea52c70219ceca20d4abd723e6bc4f8f038f251b3ed8e723713b4a6c20e5c124cacd1b089229776583a0a97f94cd259b3faeed3f8af7d4e587c32a70405b43cebaca583ec31b64065f9eaed500a9565a89da3bb4cb4b82daaf8b01ad29b24994d00fd0b1cdfe6d24d4b2c6dedb48fefcb5637696cde216d2e74dc27a01a40ce8fce23ac868f358f12f1ded5e7ff6a8c0bf625fcd53c64a424a54af8c9410309d69d4fa2e1dc1d55035b7577ae9b01bd891fd4da588569f385b927becdeb49a17595a9f7093818094087ed073e8d9631d9ecebbe15a5ae4698a8bdfe9a231a0aff79777af37be5a6b839a03fec653a550d783e45115d7e75b3451304404db5d4f02a332067a26b29520e5d9460d439d9451827f44fab7fe98f62c29baeaabad015747e542d283a5652998002cd7fb18a69685b1747761d470b186e69291644031bfc164bb631958fab0f0a42e2606ef0805b5d184a694f997eea597663a3fb75f125759e1990e99e927043c2a27a2492a0f1b501961c9d8b7589c5fb4ae0309f1d3a0300b1b749c8fb26357d6de1ac81444e35d378703ba570ad8e1b1c41d13e41268240f600a0c8f9131e9a4b8306f9835de89c8737cfe88bd4217e906f5504ce60b386e453a42753bcb8b4aa1bd0198dd04baabeadcc07d4eb2337b94794231ecf73c25b3c9152f8a332f43fa6301a64e68f09d8b34a5525b612920dbdf89885c70153f91bb48362528d15b1e4dfecaea7d3eed3b3cfd24c06c35cebe6695a2f0256d293a18ff1642d417ba66f817ec92ff4a4e091ae81e3ede0ddaf50ac781a0e1abe2d6b483029a7f488d22df9d411dec62c7b9e6f57d3507ba1cf46835b679a4b32e60ea4f48383494b04d9588b4a967bd056e0b855d2e318b20f0d995de3b1800ff197fe44c59f2f8dd98a46d90cc05b04e450617c0c70a3cbfb725c48bcf132b7ca45dad3085bbcdfe700aa24db8f0e444ada4b045699dcccef864585abdcfafb68bcd2e86d53c1388d8e3495e1474e6e82a9a851e3cec6449016fe6c6494cf577a0fe69439964d21a3f7fabed0343d31c69cf5bfecbb3d32229bff3ba0229eda25a49b9967bfb9ce578f7d619c85e6587e958815cf4c35dae946473a2a919fcafbf158e8bd463a8fc397e209a7cdd08f2061e5170b8eb14474d2ea38f7ce7194187cbfbb11c3f850476f53b5dc500df17cc6d85117fb35d4216a898ace5d703b066635ae942427385f0f08d78d556295a01a5be900c2473ca3b38d90b5d6f588f8faf4e5507f70838e84ad64926d +7e9a2683a40a174c89c73b4ae2812e6cae3c10d239f6ad9be00d471c8831db7977c3678ee5b4e15a1de9e79c057f8103ecf63ab879a183396d3540b57d205e779f65436dbea553af6659276afe1b65a96ad0b06ed413f599b81d94162b5967dbd2d03654b9dbb006ee8f2d41eb49e90fa524d2c38130670eb12317db5b9d3f32ee403d035a1a70093dad4ddd00d6bc97f94aa306fc7845b0f9cf2edbab2ba62f3f85985410dd91dd36fe79006d593e935fc09522aa0c55d6aa5d52c22ce7fb3a0b785b13a2f7ecab723ebf23ceba7a4f481a4b4275bb63c74d3e3510d3d237fed4785f4bd8c7b088c6bf7d4c4e013e3055fd1104fed8ca4d5ef292e288730bb50157cfe0f99619a78cd2bb92fd56db50325819bf623b1344418ad7783fe5fe0003322889f460337d40943acbcca9a5e50e8216f056547a62cc55f3598aab83bda8a27eb3bd1d35aa2e3d176f523ee2f13ccb0624aa9f2ada406876b39a3842e859b31760e376db61d7db22e45f1b6e35262bd25b521b232b48d208af37d7fcdcf878f6e4e27521530f5959eedfffc3ce1ba8f2eee866c56360bef91fbdddc4fc6ee8f505aae0a3831d77c474c29be76105ccab3418cd810f2b72d58a425729bd78cc9b16ce2a95b48502229bdb3cb975041490b7e070b29fc63d315ec2b2571dc33b3cee93e5fb7cd6b55599440c6b2f28c66335b42f26e9cc56e374532174ac667a1f824414ead4f1f9319dd15bedc0d3c1f88717575f484431a7e9685350af7412caad2f305a0698c3f197b12db099a54a4168f76c882e4fad7854db49c289f727b2d02a4ae642bae0cb7051a6cc8858fa8c4a8501c54cc7ef8141dbd8f966d89747f057b975db14794d81d566a7023e4b92733e9aec8d510fe5ccf63ecdaa4386e044a30da744bff020f7b539c45442a6af84d3c30b2e38a808d73cfdae496aa7c546bc080fbd83c539d3b4aa6164e4f50b2f33816e81e665116a31b0b57ee0b2371da70a8f2150b4bbedd3e8ba6027212a3f0cb8eb196d17a58f3965f89ba43ff82f2de139e9a6982590792681fbdb2b8d35342b3479b7a31321d547ca4c7fb055a452b49a900e7237e8c163e7b1fa4715d82654f736ea518b33223b10eb4a45c8a963a6c70b42a9affdd1497f41c01eb4f9c13e944743c0a79dc41e9fef5ede753f29b9bcdd98558da7d0823004921dae6fcfc98b6ab1e0a1a6ac7dde74a52afc390041dfa7ee8329a9aa2928377d41a8883ca4545b1195e7a562b5dcb52988b6d5b2febbd00daa845afadc1ed1226f2755909dbfd26dc79fb6f4a8e1ceef1540f8cf6be6fef8b7c00397b1bedc016e7bc9cdcc435784fb2c8e859cc9a5be6b4b24cee7e1d53f6d60f39c02f23a782e860cf0ab22999889b5a2b39b9ae20880b0339e56982e03368ee8d2e28f5fd8ca3968938feb0fefd3605e6d3669ef +7d3fbda289d91a74d99f5d9a45e5b77dfb58ebb15f62522de726d460188d761bb50d6780d27079a901a6ca3f04b339cf1b5dbb728a2f26d07cb8d7b3e63c78abec69192f0ef915e523a00a0722d69e79a22cf61e99b66807467d3cad2b0b48548acf2224215642a5ce817e39645d320abfc11dbd65186b5be3700c3eae579b295f5d6db25fa350afbddde6fe6cb1565c369ee2ed1662c8265976f7c549015ad70f79fadcceddac5d57c46860246f293bdcbb46f58c0e02144040562159289a58f1e396894acb680f7a2520f386545fe81b570b89cbb48318ecd8ba948ec648a9b12c9c97ab080814d3386114da29ea9019086ba02eefd1adc22dd1f55743c1e7f869958aed1a47cd8e4e479b000c88dde795209257f1d7d625df1ae27ab79b681a327f9db4a5a45d8e6c3870ac751225f4c98e41a44fe6dd571166fc7471a58ca7224d3e4259c4ed8603b41e914a649b49392e77e9073fbed54a494f61a7f166116fa47429837a169ba056e189e642719873845f89f7413a15e856ad369945d301cc91baf842ff355d71ca4f45641c73e3a7b1f6c6df530bcd3a43fe6cea949ad5920029a827237a8497e236fda61f1d86bbea9ee97089e46d7e52051f938b645ce43a97a18fae5832c2206874aa717e9d0a942be137094e5258c7bb1af7c5935eec00c08a8d2626bfe06cc1788de23d5d2513a919c56933a4312398da4d8644580ea552291c9fadb6a17823dfecf65d3c12abc65acafdefd96ffc57fb571c280286fc7030757c51e58178d4088419fad8c5331e73504f6c0083cdc8ed054ddfc8d99b0dddf3ec705da169debbc15911d424c07bc8753dc4ae4614cad71c1e3cb95f50ef03326986e8e3d4cd668c2da94da5fd1ee60279da0f1c1c4fd59d364b4d92e613fcfa3e8917e9b5ae3b350a96bf6cdbca1150a3f34662126565da147b677b43fa53e6ff06435b1b6260ec1ea3856d60e86587507915af88198f0a5e9dfe2a4ef24bd1e3ab68b6db80a5225b62f69cafd89db1e5218e5c56640aaecae36f42a4c0c162f9d354aa05655e0ed86616da01425a6bee99c7f0b0512fae2de7e8ce9c2ab594e4ac6005392b3348a4bb9e6f046e850eb85008bc019ce746a557a731a906ec8aeb7012570ff31a2b2f38f7f62d2303857e2e50770f85c56de514cf68268fac275e11c0236f1b395e40629e038db68757351587c8cf5071b80fca03437af67c915d70d3a12493a4c9a2bea32f62bd6e4806c3d65395f4ccb792d2fcb8d5592a06bcca0c40afb2fdec39664ad324ae73fda1107662e847e936f5599b0af587c71e8d216252992657a3563e04747c9a46c36f64b1471143ddf0b3184f1ae51f0c6d7af85940df008c2320f9c796c5a0115c4407911625e1debc953396a9ed5ac2b6f4b034c162092497f43a0de4ee8d5902a888e2f9b897ebcbf8cb +a366b1d0dbe94e1e3de656bc8b949fb575aa26a6f9e4cdbc4a27d312e5f8e5d8d9ff815177d52c1a0628dff52fdd6e4d90e26268e01c9efd4427fce66cbd06775288d3dec9dd91b2ddcb12d153a547a764797c452c5fc0da3312391391fc3a48ed511ba35ff7b89fff9538b103695c4b0471b41d9fd1165e2a7ff68465f43e26ed14aa4172e53dc1f5c18b8c54bf1306a631a3b162048f1ce36b4305bc7eb9821e9013a1266fdd4718458be9afdb23e7d4ee4ebb46c79f147f23f3ac3c54422878be372409af0c8f022636a914aa19b499b6033584c9687b7de23ae61966cf10c662bc7e17ad1e4cd2ea5620de1de4e20e6be52c465102a8a8a2f7fb0258861697db83f786ce5ecf8fe5f745b35f4f0a8e2db07b4821085fe959ed3c19f5847c677878552157a6ac8ca60f3425e5b1126ea57526285e3cee038f57ffdc3e95e4c938145f7f71a433e19cdf61dbf3a5318c225ca9c1b26245ba89579888157647f7405d2efc1b70cd9cc02ba5a5fca1a49ed89ee5e89dcdf37865f8241054dab3ff459cd515a14635ac1ecfa387ac1e83522e815b67d3668effd17793b879380e483d4368e6638365c68474ddd74be64cc46ab1435d4dda78171a56629b8f3a488c2705fd0d332c83a345ab337ae542eb803098eaacd0a74a96518d56a410d57321eaec18ea1eddae63ef7a3b8152f50cddebd82f47d1cb773058db6d3b38d3e81fa897b988f3d04b7bcf26ad14d7003c988ec7b25332746f9bc4005102704f05f5d5ee5341ef3b30d318fc3904da34eac2ff3a246b1a6ec60145cbc66b8e79006dc08514a2dcea9f5d89642edbc7b9f41d811ee1caf10343a286e1e4c5f17cdc640c8aa5e40dbc89de3a18975ba4558391e27650679a829ac644aecb00a43074a0aa358f5664876388ed742bf571d020b29059c8326f68411979d3967a314bff40126b5f656f1ca0ea4d7261b7a0149691ad08cd2b2b2f2d9075afaa4e4c849ac650925b2c2f489075fe539f8cd7b1fc7cf4781808fa29484c4f3b9da3cf38cb31c58cb3ded4daf88de27c8f447cf098b7ff69e7914cac7171b2290133fa1854b8a331e35d7f002ae75b1589758e6b2151072a43514973375185240b7e8caf231398fff3e6787a28ef17829f4b42501fe0af78943f1e0cc419647ce0c2e9d1ab50f19f515d4bc75edc6e2d31bd17ef69f0a63d4e385a8f5bb00908f83bb86bb20239b55d62730b43ddb783fa805e6ee4b4cf6eb66ac29bb5ce631e294b439517f5856ad1f2fcffae3936b40b14b2d6dd4e32c0a8091bcebf24c0c64f54094366fb61269962feedb4072daf5c5a40641dc827849682a21850dc1554d53574d9608c97e4abcf94f9c6a28cf5b484d67436d3ecadd6e13ce57c11434a57e87aac9562079e22c6d2ab310b9ab1271d95bc268ba98404612ba78c3f032fc593223348 +70feff72f2520acb71682220f727928391051a74e6928e74706a7a5a036ed8a0d4be09f9088df632adc8ff816d7d830c8c3ee0074432b9dbf1e9ba49b12796d1eef1bacfc32b941e52b0ebda65241e198935d0dc4a72f997c8b26283ea93261d483754693f9834451a696fe4bb1b6e39f1cfab45f3e792fefd5fd2b94a698caca4eb8b2be7e1eb6c9a06a56bf22745ee3af4126dce6e5a77fb348a0ecf2416c055d2e6d4019174ec7326f7239727f7eafcdbf3f8323f1cb0b1f63d75f4522c575f82ba0188b03734c3fb374b90d9773c75ea981a23510a7add04ed0b13f80372b63fcb1135e8eb3129bc970df3a6096b92d17d7f23a3256a7133b4c3c71cd581f68575751255c2517ff3c3e328fc8f50ec5b27d3868a99848103ff73b82478bb86e52e677923a905bfa608e80a571108e4e46cbbf4871b078457b8e06eff87a15ecbf3cfe59d597cb4cb2663de42d39bfd260442be708e8985e5552cbe191a42fbf9eacc4505d4c857a67eb0a43b27cae86b6141279ebd32a2dcb65a94f9fa8e7e17fb8447d435311740cbfe3ddb2a8f2826c69dec1750d4136406238b128bff704d9fed6444d14b7bebcb269fa4203a262d8bd0b33912199566b1896c03915d090d070488243bb1566b9d464571f1b5870d959b0d0fbac9e6f783dee5cea9019110d02e76a92e5e963644519456cfbaf6b598da56ddea81ac549b93db86bfe54930d67675775ba5525b7576c2df1965d7b317bdcec7150df1c15706f81d1c2ebea7269d9ed0ab10312c448f5907730d12447cbc9cd4d36e09a8979a1960db9ca7058fd30bda25bdca5fd48ab4db2be3afcabe691ea64effd6b724eba76e2637e519cee7ff9f711061245b174363f75c9da2fba536d2ab7b6fecd277129693d17f0bda731589df1905367bc80ef014b05e95dd520a9f08924c306ce25ce4fc343c66f9651e9eddb57ca60d3b030f6046693811a022461ad45bed406f14722740a6633edde992ab2e2ac5d94dd70399d7cee5eab350b2c10ad668e6d705eddc0b1d0e22292e34e416f0e9b361fd4ae2558a256175dae60caa8f00585f3401a209a25cc8e72632559e15e80600b4c741769c637d83da4768de13f619f8bfae3b7843b8ea46064b03dcb2bb2397cbf19a9ee773ac5a560f32fd7a3c95d7d344d924c715c43b4c0d644c0b1b28187c66ad6a56e3c105b2fe83b585b3ef1f11e3f77d21d58eee9b513d52dbd8630c8bf194c30a64dbb96e707adda4e5caac59990e1e54bb9ee4816c8e6822d69ac52f815e384cc8e8b3938f87d585681b38069426035d40abfa4ce7cab34f56a45e9ffdfedf53d81e0b08a5ac32441701c2cd1f268bb722116912f55260c128fe5c55a197f6440dce2df4bd426b3178761ec043cafa57e41c02db17d1a712919dd2186fc0de1b3f4bdbb129ae22f8965d4d84d8a687 +d7d6404f6760d2363a713ca19ff06d648d311c5871383594f2c34151b0b812c4ce12ea3e240754aeef9faf7a61f36f835de3090e1ea22767c69cf376312bd3207ea4f939668ed3e700c72d50d3516778d0006b0da8bca7b65c5fa8ede67cbf1b1e9e79d259a3922c2328e8385f544283fca61778af7771e25b4fa50e3a1d9b04a19bb74450d772374846314cd6e8fc3ed05c19d72d0bdea8c3acd4095d10834c1d12a97de5992bfd813c450c0704782c4806ef6e77f9c2b8184c26fc2bde78327923b81e48f186b972bb4d9267f9220e3c03e9f87048ad2da7ec8dab8118adf777c155bd46091bf990374e5cd8dad3624fef8c646eb0446d0c3e8b18c93546f9592a86accbf6ab7b947ee7d279220c0da062555199e5954639fdfebffa16078f49c7d6b1097c8ee10494143e743a7524a934746ff5e6e2b02c985d2780b4c667d43cd1c3ba6cdf4752b120c3b481df2117f31d0cd49744da9b5ddabe5e58c7a4625b88370b1fb87887012c5636d8cbbe258d01083c31c01e4b359e7d12f230a1ee229dde4427e201a9c682e4b59a1509bfa8b7ad42ab952300b0dd032c9200512683ef4178a526039284ec43d4c6e93e9d76f1c97746ebe0c23ad3c9736579f4192dde55c42723ee71a0c5cf16903b1f3ee9662cac9e357abc7b5cb1b3f322cf935db49ee440fa9626d98c035d5249949f9f5395c79eddffc354b3f6895daef19bab519e9c2cfaa663ef8d543846bd11c889dda634335b61dfbfcd1450c09f9391519ffd5c1fcc335f2d167fe5829250718ab75c40a976b77866930a737b07f4afd00e2f377108f132e7280523acdb8d3912a797b90d707417326b705233adfb5511859de8e10530b5e23f4c253d89ef06545b29c67d0b9c42f8ba6f611946d42f1403e52d8807996b4bfabed64f1a19d96819db892394bcea025a460b189c53b32b131c2b94c12eeedc1ffd610f2418b5d5dfa92537dc75812785f45de70ce54c4730025554d72d274201c0830ac46c1e857575879384a38ddab4a559bc528663428cc703c936a7aa0ce2f659502c044b96754710397b4a65a7d65caa3ea2125fd755a49aaa9592002b7df32f4783f4e80ddadc52916253d3a20d5b2d382691bb0a9baa670ba4b0e1c10e2ceae232fb908061e7723b04ef85905e2d53385a5e2f0974dcbe0922443757e4f15572601e0d467fe22885cf6e1f9eb23f7d1060b99111d6339d8b4c85c64824e7c4230383e06b76864de72407ac80fa0376771262792c4002659bfe37a10d11431acaf1fb60c51acc6316d4463dfbf7249bbf6608f84049a0f0ea25c73b27bef0a7faabf29f0d31a218b19f9cef07b2c829f774920442dbfeea24480cd2a19787d4ed06504804b3a2e67f2e2c29a28aba4f396c5dfe786b2f992023210adcfb65e4abc2329bfe4adfacad47b41f5ac8f16f23a7d0 +227469e144d30ff97006dea1fb2790e9dfa00e0ca63e0fd72ecb8157e3e5ba7c15f38294b3e5a35d6099c327dc59a6ec1fcee0bd12c59da5271d8ed7b7559a643ae89ad31c5ea3593ff4dedc1b6afec2617c985a19438bd89855ae153142f8e381f5c16d40239e6efb5bfe369a6c80444369f074924d1ee1e07a692db90e14a0e198ecb48367cfb0c457fb322ce82e9185d3db1f53632392a3b077ebb8c6bddba51ee4ed15a5255fecb40e6e059f6b9b5a8ad363926c31478eaf588c7e7a4ea191e65bb368c3391581a3e5cd8ecd3356cb898e1b55a3961e2a3d6d198165e1c0964f7a365ade2ca52f584bb1647be33dc8d9bec281621e5383fca73b3b27c5d379e64443f825deb31d9cd50ae1a704d45c76a19d799cf5aafd44cb2c31ff1f858b7b7ecc4e068cfad674d3118e2d8a63018858970caaa2d0798081d58edd7d6108f6c52bf0978e315be35f67a5fd4eb8321a025ebccfcd95170c9e1024f054ae056f1673aac664557f0f30989e9345d5243df96b47661aa1a564e3b7827bfd78688452646996a6bcecacb41d2f204cb5944f96171afce281b1667c8adb4c4c22545aa96e6c7639c12f3eca88850a1560917aaac0f18388e576d058673f1530a287b1c2ab5d376b4b7bbc7bd968372787ce27ac69b3a1af0424e0ee037137a294a9e2f2e36b5eebf62e2d8a5393876e692bf51c943b45446c2262436051879028170be8dc8296f973a577cc78c2b4719aaa7a603ddf720e11d8905df6c1417ddfeea4d321098d4df3f46d83fdb7450a2e647ca40932c4dce5f465104e890e28e6053c962a97ee7a530318dbc91bec1875fe613b60bdd4d14036d0112338f9fb3a897f987e93bf476ef00beb93dc7d95843c32b5b7da7b635179dceffa454962602896bca7768907c0e75998628e04123ccab906bb53043fffffbefdd31861eec8d0ee38a9a557e239104409debcef034d88b96de96020871c84827e997d23de92eb69743ed330080483963f518383d0ad2e6c9d287ee77c3b2ed9cb30414f3b9f47912419f4f508ee1fe290df38e3394fb91356681ab0c111a8662284c828aae2e238cda6d4fc6d4ca507e62fcbf3c05cf4088c8577b5c5512356b4da446b4c5b326b84494304151eccaf13744661ba33a0270dedc61304dc01f60a1f4c53cc3a90a61efcf39e6b2fa08b07bac9487e6a215578a3cc5c2a71afdff0bc3c4081d214ac165479b13be8327aae7e4b4057e045d5e79e94039d409dc601ea99107c1a2590c942c9114bf334105c661f753f57c9e2ef991f78eee52aceed99401584023d84c7862638636dae492fd52a52e4a10e500985e4e15fe423294cd115a0a8517dc5dfc0ce0e0c0b567b6913ef6b6d6c0fda87f80fcaf83274bdce67db1a6b885053f512c75bf8256d6cb621985878d576fa62766110c7fb0eb425ed1dc2477e +28d4def42c693a68b09df862e18dc1d1edf196fd2e5299f4da5f121b1fd09eb1312521e9133f8f1fa4ae72f71b4fb885ad3c90ff69fe767ff47df9f23efa0cddbd82b0ba0d253b3167aa1a132cbeb4f6734448abc3a71f4799864be9f933cb0bac62f14d71fea80857ad8a23edb93dc00ecaf880380b2d2156a5a1a00b8c2023c77c77cd7b170b3956d11e0c76493bd0064358c6e3649115f1163bbc1662f3951ba1271902e77a1d0c8742d619593fc7d92bc2a83937d07ae613d618226c1edf26536257bb580481be697516dad0235bda7a33001dfe0dd36fb3771603724e7f1cc36190e87de7f112695b2d98723fbaa64e2c0abfbacf47deaad5cc78e70c99a8e8c799e96ce4163612dbd32ad99b2ad3a9e59896b508eac80c8ec48b7fbbbdc60318e09b88bc61f1e0944edfc52c8c13a41ace60194fc6f76bed8b4438d787343adb3317a6601e991d5eabd53f0d1c08f6931e +''') + +# ok? +db_write_enable = unhex(''' +06020000016c5d73d6328c96d856b0d1ec9856580b787d5e56963cc0a798b8be5331b183a894f23e2ce1ba672a20d887f6cc884c3dd027441377e68fb1d2531329dc8fc35716132db947295abeff7d8644417bbb5ddf2319b4fbde57c50759adaee10ee8e60400a2d7f09a7c0d9934f7575a00c44a41692015de80959d9f0b0edee6e70037423b98733b5e1d2f892e0ff5f2e0bb364d79b6273856553e4df0e49e19883058f16fdf698345da73ee561ac8dfc2a38c3c594c5972edebdd056923be21749da4b089c8148f73ac9917bdcd6976ce17d77b919dc56daf21bc544aa4b3c6113037adf103e9b74a440b720104860a420bb211612d62da3a1327d2c4a6cf04bfb54d47218be9525c428078efa9241e6a42667e9079c91251026c6862575af5c40d7515615804816dda6c5495e6f9abd5c77e638925b28bbb0403d4f1aebf7d86bd5bc73a22a41fe2c7e19db79c404fd3db43c8d87fecaf6ecf163f74659768a4c6cbe562d8d14b8ce4710b9164a3bed100918d9872aaa9bc9a448b117823f1e86fe9ba9ac27649737ea497c83efff69022e6db7fe707947075d17c1de4bb1a712e4fddde31524379dea95497b675e4e70f4e88c8346807fff1083bee329d4cacc54d8576886272f1fb35c692b8c9156041a78aa4f16ce24cc3c4d0e2ca7eb957360a5d1a14da3046b73f996381b3f67b136d46fe7386eb0b0e2468be7771a153567ecd9192628f71a9382a6f555cb77b72c959d79c8111133777056cc73f40e5f448a064fbfabc5ab6c30d9f67cc87e641336ef37ff498b9af98212dbf3775b8a67b7ad933b5603dcbd3d7f9f2d90cefb292835d4671e07ba8573f4b8b62c537178939a41c1658f09574de0037ddb4e24f179db751e477527382b627f6990900eaeafc6a5f1e58966d96c9bfbac6e931a514c235e829c5868c065fd430df2d27ea21be94525ea5ccd22688d56780a4a9c7e2072b1084aabdc7d5c5ed243d903f5c9bf336b033d9508a0e6dc3b06757d0b9dfa26e85305ce953562dd43fc12fa2e81dff81ac0a1ca5ea0d3ebc209ea11046349c092c16c7680eb9d67c6bd1d57ab10fd7c07d93e00fb0370c89002051e1774b1555ce048eebc15ed4af35f9a4e2cf4b77bc5b0f58a7ab7a12aebf3c3a935c2bb278d446437b91c8cb7ce9e9c49b243a18c45472ce601ee001bc9371fef9c03fb3cbb57b119f62f81723148402275b46f917aab7ab4e54e0846eb40c9fa0ecb8f0d3d66fcfd9b6b5198c2e6451c29458726322c02401fe154cd24b21ddc6a3c4a69b95e2cc0c971cc2e78403bdeb70ffa5a343b7075609dc38cf5a0792ef55aa48991ca9237e911c66ec493dd1de176b2d7496100c803b58e071ea1ca3bb5c0fbdafc7a0ae50a56dab64002232d4c464bb78fd809a3ab74271fba1287fc1b6ad5e5c973b06a6984d95f0dc8909e6695aa6fe6cf5c263f657e068bde23045cab2264066d5e63d9db8374681a46a5c2d3127b34d848e31575d493ad64f263429c57808c36028f27a7070bf519c32b99f85fdedcfebc8033d519663f03bf082654bb7a774bccc701da2d743229cde9ff252472b3a53430db42a4c6dc49c8fecdf1c70c872a22dfb29c273b131f4e9d2bd29f46c7c6f23c0bc05579ed6e22015b9cf49f6705b093d85b5751d7a62921e5c51ed9c6bd0d4c44c3545e308712d5a0aea11399835e093bb606288b360e8d2634ade12876d5ed9cd264f5f1421f4814299b734c9c914d5157afc2bd558c304e6ac81a320098acbbfaaa689cf0ecca3c82792961af28bcae17f1031e819944dc4421cea037d2864db5878d9c0062345675a61f704e540182308629d07466d4d38c41f0a7271bf21480edf8bab7a9545a0074018764e81cddcb91c86722fdb9e01c9f7398c91df65c3c78e0b594a9a04f02b5b0a52f0218e68b1f0dd0a268c6f380b8c01ef821617f45fa651946027e35e8d9e05f68db61ffaeabdb693235d00b0bb4e964a6185a853e7457e57fabb2f25ab3cef8ccc20bc1132590debc6dbb9f084ac49f5e5d5228de6d216a2a5b6ed5e890c2ca726397838d715328a9d18d0df04de167613856bf09c898ef01855fccfcea1b3b0dd7e7b0fb209c324d1e4c9d6ee346eef81ded4325c7b0e6521857e37cd5ee076fdd8f778579f9510bae6d7501de64606d1c1f6f6bc0f5a64c1b9431b5ef24214e4893b9f7b4e182b81cbb3660974397716bb9bdfa90103c192758a03e25e23c35ce10afc0fb631be50eda1790310e9cddaea5e4426036aad63df8265c73b5571dd8379cab5ef759de28b5de50ed03f04135949f2aa6d1084ca796034ea187246ea8c8cbcb5c25974c1408d889d35bd7f90e8eebc6ea8c08fe26fee6bf5d4044472455ced150c74c3ce56657f1284c4478bfd5d2bacae3587c3055055813caed2e8ccba2739c0786754a7737a7ae610d1f4801e8d1f3ecc682f4303dbf0bf6f7510476269f1b64b15f2fc49f3f7e7bf5e22c2ce2b818c2020c19fcad9f2346aa15e7ce970985ca396ed11302bae047101a6bca2d2b7dd22cbc6cc6bd656fd56fa43f6da509d781277724f70b9751abf5f7f2a7fd2be6d0aef4b4b12fda505852f2da92240b2f9c588d711f48e6d6030b28122aab5d58e362ee06360ade5a25086666ca6734cb1c1f260003a1cc7fd7acbd48aec5c9f37a3f1fc63702a4080e1d3b33573d6a545b94ea917cebb0735fa227268665cee87084ddc1eb6efb789c72eba6c46fcba0c469fe98e1694b1fc7bed5d0da758b822c70a32f50fe4e4109ee27cc1fb6f3c7f62b7631f2f8034bd413645c07cf80a4a5838d8e1dde89cdb5028bb2af3d2131cd750f25a2c6d6fc8bfd1eade07abebec83a66f1c30c3916776234b86eafa6502d55dd714bb482073ebf6ce89ab2b339926fef55fbb125522fb8ddd00ba9dafcfe0440d53796cc22df0d01ee14e1341dcd0c13957f5bc3f20c89990da0da30f67d3dc4a0fa259a084007c475e302d3c7c62a29174157d9c56c0b40d03d81c53ee6e5e1144ba16b51896837679ad73519ce977be5ed20cfa7c70481242f4d8c1b42f34f2b06554536c59e02743f9f20d9fe131a2b8982dca65216374f29a0a1e5478b009d51511448151fabd889f360711a98c28749368c3f1b59ebfa268e5a084286ef550f72ca5902c5436e4551209b724675d3dd97b610d8d60c57181a14466c595d6fdaf7cd5e8c6a9327e7b0ada21429d399957aacf62cbe75cd6b550b1eee8df3d3122574e7b61925a7c469b4c1105411ee2362a7bf912c833bb9fdc4ce479ac1f0941bf8935ada689d9b2cddd76490d35914981236eda3336c2127807eb652cdfa0f23ebfd5af57a9df6c6ac52ef2ec4423b61e4e66cc52908296dd71db43308acbd22441bcc1237b7d34f4afe1892df61dc63abb5a5c584fbaf696d93b734614c575a39be4eade166e483534b702b0c264207c7be8633f5386a60c033942b26d793c7e868205ed0c735aa7b36f8375978fb76629cb2a628e45f4fc81be3e4435b65f6844512a5f12a0e1882b4e8d9109b97a0993f2b48853a4772c7d03b83043c8912fc317bf712dc6ed61de8828ae250eeb78cccd21434015e0f638948030ef39eebffb4df2ca0d26cb07e459940ee2f1eb64d4a840497ba0ea516c7d64d1d4ca74237171bd47caaf03c46a6d7e26767194df3bee3cb49c038f7a6f170d45434591022756a39b78c3905f44ffba8bf3b0a3ad157c10f2fc4e7869526cd824ac17a72a02c2aac119c69beff3890c6090a993849762799b929529137c234baefc0c691848 +''') From d2dc7ea735abfd51ec86c344bcc26fcaf8f63749 Mon Sep 17 00:00:00 2001 From: Dmitry Muzyka Date: Wed, 3 Mar 2021 16:34:02 +0300 Subject: [PATCH 02/17] added a2 dev --- validitysensor/usb.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/validitysensor/usb.py b/validitysensor/usb.py index ae67576..3bc4eef 100644 --- a/validitysensor/usb.py +++ b/validitysensor/usb.py @@ -18,6 +18,8 @@ class SupportedDevices(Enum): DEV_97 = (0x138a, 0x0097) DEV_9d = (0x138a, 0x009d) DEV_9a = (0x06cb, 0x009a) + DEV_a2 = (0x06cb, 0x00a2) + @classmethod def from_usbid(cls, vendorid, productid): From 7ddfe80845399fdc2625d8addbbcddda7da19c15 Mon Sep 17 00:00:00 2001 From: Dmitry Muzyka Date: Mon, 31 May 2021 17:36:36 +0300 Subject: [PATCH 03/17] 06cb:00a2 work: this will recognize already added fingers; but how might we add fingers to db? maybe just match images on hosts as we allready have finger image? --- validitysensor/blobs_a2.py | 5 ++++ validitysensor/sensor.py | 48 +++++++++++++++++++++++++++++--------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/validitysensor/blobs_a2.py b/validitysensor/blobs_a2.py index 12fd208..ee51721 100644 --- a/validitysensor/blobs_a2.py +++ b/validitysensor/blobs_a2.py @@ -32,4 +32,9 @@ # ok? db_write_enable = unhex(''' 06020000016c5d73d6328c96d856b0d1ec9856580b787d5e56963cc0a798b8be5331b183a894f23e2ce1ba672a20d887f6cc884c3dd027441377e68fb1d2531329dc8fc35716132db947295abeff7d8644417bbb5ddf2319b4fbde57c50759adaee10ee8e60400a2d7f09a7c0d9934f7575a00c44a41692015de80959d9f0b0edee6e70037423b98733b5e1d2f892e0ff5f2e0bb364d79b6273856553e4df0e49e19883058f16fdf698345da73ee561ac8dfc2a38c3c594c5972edebdd056923be21749da4b089c8148f73ac9917bdcd6976ce17d77b919dc56daf21bc544aa4b3c6113037adf103e9b74a440b720104860a420bb211612d62da3a1327d2c4a6cf04bfb54d47218be9525c428078efa9241e6a42667e9079c91251026c6862575af5c40d7515615804816dda6c5495e6f9abd5c77e638925b28bbb0403d4f1aebf7d86bd5bc73a22a41fe2c7e19db79c404fd3db43c8d87fecaf6ecf163f74659768a4c6cbe562d8d14b8ce4710b9164a3bed100918d9872aaa9bc9a448b117823f1e86fe9ba9ac27649737ea497c83efff69022e6db7fe707947075d17c1de4bb1a712e4fddde31524379dea95497b675e4e70f4e88c8346807fff1083bee329d4cacc54d8576886272f1fb35c692b8c9156041a78aa4f16ce24cc3c4d0e2ca7eb957360a5d1a14da3046b73f996381b3f67b136d46fe7386eb0b0e2468be7771a153567ecd9192628f71a9382a6f555cb77b72c959d79c8111133777056cc73f40e5f448a064fbfabc5ab6c30d9f67cc87e641336ef37ff498b9af98212dbf3775b8a67b7ad933b5603dcbd3d7f9f2d90cefb292835d4671e07ba8573f4b8b62c537178939a41c1658f09574de0037ddb4e24f179db751e477527382b627f6990900eaeafc6a5f1e58966d96c9bfbac6e931a514c235e829c5868c065fd430df2d27ea21be94525ea5ccd22688d56780a4a9c7e2072b1084aabdc7d5c5ed243d903f5c9bf336b033d9508a0e6dc3b06757d0b9dfa26e85305ce953562dd43fc12fa2e81dff81ac0a1ca5ea0d3ebc209ea11046349c092c16c7680eb9d67c6bd1d57ab10fd7c07d93e00fb0370c89002051e1774b1555ce048eebc15ed4af35f9a4e2cf4b77bc5b0f58a7ab7a12aebf3c3a935c2bb278d446437b91c8cb7ce9e9c49b243a18c45472ce601ee001bc9371fef9c03fb3cbb57b119f62f81723148402275b46f917aab7ab4e54e0846eb40c9fa0ecb8f0d3d66fcfd9b6b5198c2e6451c29458726322c02401fe154cd24b21ddc6a3c4a69b95e2cc0c971cc2e78403bdeb70ffa5a343b7075609dc38cf5a0792ef55aa48991ca9237e911c66ec493dd1de176b2d7496100c803b58e071ea1ca3bb5c0fbdafc7a0ae50a56dab64002232d4c464bb78fd809a3ab74271fba1287fc1b6ad5e5c973b06a6984d95f0dc8909e6695aa6fe6cf5c263f657e068bde23045cab2264066d5e63d9db8374681a46a5c2d3127b34d848e31575d493ad64f263429c57808c36028f27a7070bf519c32b99f85fdedcfebc8033d519663f03bf082654bb7a774bccc701da2d743229cde9ff252472b3a53430db42a4c6dc49c8fecdf1c70c872a22dfb29c273b131f4e9d2bd29f46c7c6f23c0bc05579ed6e22015b9cf49f6705b093d85b5751d7a62921e5c51ed9c6bd0d4c44c3545e308712d5a0aea11399835e093bb606288b360e8d2634ade12876d5ed9cd264f5f1421f4814299b734c9c914d5157afc2bd558c304e6ac81a320098acbbfaaa689cf0ecca3c82792961af28bcae17f1031e819944dc4421cea037d2864db5878d9c0062345675a61f704e540182308629d07466d4d38c41f0a7271bf21480edf8bab7a9545a0074018764e81cddcb91c86722fdb9e01c9f7398c91df65c3c78e0b594a9a04f02b5b0a52f0218e68b1f0dd0a268c6f380b8c01ef821617f45fa651946027e35e8d9e05f68db61ffaeabdb693235d00b0bb4e964a6185a853e7457e57fabb2f25ab3cef8ccc20bc1132590debc6dbb9f084ac49f5e5d5228de6d216a2a5b6ed5e890c2ca726397838d715328a9d18d0df04de167613856bf09c898ef01855fccfcea1b3b0dd7e7b0fb209c324d1e4c9d6ee346eef81ded4325c7b0e6521857e37cd5ee076fdd8f778579f9510bae6d7501de64606d1c1f6f6bc0f5a64c1b9431b5ef24214e4893b9f7b4e182b81cbb3660974397716bb9bdfa90103c192758a03e25e23c35ce10afc0fb631be50eda1790310e9cddaea5e4426036aad63df8265c73b5571dd8379cab5ef759de28b5de50ed03f04135949f2aa6d1084ca796034ea187246ea8c8cbcb5c25974c1408d889d35bd7f90e8eebc6ea8c08fe26fee6bf5d4044472455ced150c74c3ce56657f1284c4478bfd5d2bacae3587c3055055813caed2e8ccba2739c0786754a7737a7ae610d1f4801e8d1f3ecc682f4303dbf0bf6f7510476269f1b64b15f2fc49f3f7e7bf5e22c2ce2b818c2020c19fcad9f2346aa15e7ce970985ca396ed11302bae047101a6bca2d2b7dd22cbc6cc6bd656fd56fa43f6da509d781277724f70b9751abf5f7f2a7fd2be6d0aef4b4b12fda505852f2da92240b2f9c588d711f48e6d6030b28122aab5d58e362ee06360ade5a25086666ca6734cb1c1f260003a1cc7fd7acbd48aec5c9f37a3f1fc63702a4080e1d3b33573d6a545b94ea917cebb0735fa227268665cee87084ddc1eb6efb789c72eba6c46fcba0c469fe98e1694b1fc7bed5d0da758b822c70a32f50fe4e4109ee27cc1fb6f3c7f62b7631f2f8034bd413645c07cf80a4a5838d8e1dde89cdb5028bb2af3d2131cd750f25a2c6d6fc8bfd1eade07abebec83a66f1c30c3916776234b86eafa6502d55dd714bb482073ebf6ce89ab2b339926fef55fbb125522fb8ddd00ba9dafcfe0440d53796cc22df0d01ee14e1341dcd0c13957f5bc3f20c89990da0da30f67d3dc4a0fa259a084007c475e302d3c7c62a29174157d9c56c0b40d03d81c53ee6e5e1144ba16b51896837679ad73519ce977be5ed20cfa7c70481242f4d8c1b42f34f2b06554536c59e02743f9f20d9fe131a2b8982dca65216374f29a0a1e5478b009d51511448151fabd889f360711a98c28749368c3f1b59ebfa268e5a084286ef550f72ca5902c5436e4551209b724675d3dd97b610d8d60c57181a14466c595d6fdaf7cd5e8c6a9327e7b0ada21429d399957aacf62cbe75cd6b550b1eee8df3d3122574e7b61925a7c469b4c1105411ee2362a7bf912c833bb9fdc4ce479ac1f0941bf8935ada689d9b2cddd76490d35914981236eda3336c2127807eb652cdfa0f23ebfd5af57a9df6c6ac52ef2ec4423b61e4e66cc52908296dd71db43308acbd22441bcc1237b7d34f4afe1892df61dc63abb5a5c584fbaf696d93b734614c575a39be4eade166e483534b702b0c264207c7be8633f5386a60c033942b26d793c7e868205ed0c735aa7b36f8375978fb76629cb2a628e45f4fc81be3e4435b65f6844512a5f12a0e1882b4e8d9109b97a0993f2b48853a4772c7d03b83043c8912fc317bf712dc6ed61de8828ae250eeb78cccd21434015e0f638948030ef39eebffb4df2ca0d26cb07e459940ee2f1eb64d4a840497ba0ea516c7d64d1d4ca74237171bd47caaf03c46a6d7e26767194df3bee3cb49c038f7a6f170d45434591022756a39b78c3905f44ffba8bf3b0a3ad157c10f2fc4e7869526cd824ac17a72a02c2aac119c69beff3890c6090a993849762799b929529137c234baefc0c691848 +e5013484b4ddffb7ea64a4256c3ee25eed351f888495e0df34b7cdf5db0823d20a0a0a0a0a0a0a0a0a0a0a ''') +# or this one +# 0602000001E72BAA6552C35B698AFE87460FF890BF30C46952C19DE644537748FD64D8CA99276A54F5FB1A85BBB6043C8F49231BE49436258094627FAB3A519DCC76CBA08FE89C1066D8F72468A3084B4CCCC42BFDC8612C36410087B5F5719703D59CEE56D6C3B482229A1C0B9AD6BD8C5E16DB730B1F11D3CB5E12EB9A020E759261551219DF883FF819DB1ECD561BB91246CFFD876589690D7B9D1A205366E831B598436E26CE1A8DD85EC981D4CA1CFCC8474B672C29EDA4C01F5D0ECAC968390B2F130CCD60FC9BD97AB703646B24A404C8516F24435273F16860E2C89E1D05075CF0A6BD646EE2C1732D3B87E1BAE8CEF812CEA2B60913CF6ACD9DC14D6A05DD8485B63B126062FAF1E6EF2CDFD2D2DBCC0F3FC74BB285D8D8F3777ACD6D2497DBEF869892F4F54AE79A1AF669C11E62F5C99CEE306FBECEE48C4C7A5A272B2D20A9E8CBB0B25D4B2EA0758F73DA7C7771CC38765E2972DC69730F57FB5B50695EBE338F36781F26DDA1D4013AE2EB015029BF66566D36EF7AC1A3DB6398A09D8628F837F459C86E75EEBE66215B67E1580F6CC96897C58CC0C8CE52A3BC3D2C379BE8E7C75052B98248A8D4D0C768F3B92113A5BCC18950642D110445F510AB477D9A71B3EC626F53A1398BBD2A4ECFEABB2FE3568CCFEF60F5C54EAEFA16334D733C5A4C463A26FC99B58BD456CEF5F8C7B470B4B80DAA3E430CBC7701AD77D6377001B6FA5AA4D669CEB2AE178AC2645E75BD293AB23014090BA5CB046F2CA4C0BCF72A6093A3E3166CBF25C5FB45C8E00ADED3F4A6D3262D2570A9B5470FE3A4AEBC8BBED696890F8B3A920AE1ADA6AA2F185A87A0B36062F3C36511BC9017B49FD2E1D915D9DDA77E4C384A614FB98F73E3D391A337883A5EF3D47564ECDA695E7AD5E03BC45180DEEE3788EBDC70CA3CE1915F4C958039DF31A0F69DA2F72E6F7A780F534166AC093FB00948BC7A773DC6E99D89257514BFE5C3A7CF1AC57F0563AEF47B08F1957D5B0927D49E327E140165C5BCECA0BE58869EC21D834DF032BDA78D26BF9D672B8F38B5DD43E6A378C63393770222A45522DDAF3D594B46DD46798C8819B99087F4CBA860B5E43BB7F068C76D0AA73FEC4E0C1628CC28914ADEB9C4EF197DCFEB0D016E4007CD74975F85E74B0C5F22E192A486163AF5642DC247DB653A7204AD8A988BAC30715F7AE6AF95141762E90E3F4377D7F2A543D7F5560BF14DEBD1B6C851826943E59E8A0653F0C1D8676989D8CAFAAEC2BBE497E4A49A22505FA802471A21A89F46B317A359D9CE7E7552B44932307A36A53E993072A727BB570B413F8E0F9FECF8F99B09D80B809E1B64A21DF653894FDBF7A70645629BA1DB00CD2BBE0BC65F41C4AF5AC87EBC761021396D7EDBF3245B1D00C36CEC71F3779C3C42DC7CA935CB9C4EF9D03BD1F020CF5C9A877810C68736DAEBBBD3919A31BC7DCF15C8E46B97118C84369E1A391EF88D090A88BEFB001FC67A71CE0DA645BDEA7B421514AEAA3A2B2D2E47AD6D86839D295573D75F162A8FF2D74A108296FA8C701D597941CD92953090F5EEB771129A5032DBE652DD3A2E70B4BCBD5BF97644C9436A3A32776888B47D3829CC85ACAC0D84DE432677746AFC9F798E0830B23083340C0A91485B1D6B5CEB74EC56C05B69248197E8B62A0AD6BEA3E29A81634360E94E7A6AFDC9E7000744BF70254E21C38C8D4B242A2E33CA3D860E34C0D3309920E354725B77A289DAA3F57424CB57F62DC9DDF5CC413CDC73B77D8D92963446C3861029ACEF991D1AF54018925381E8C60F694988B5AA76BD07E037FCB2B73DB4D76D96F77ED521617243C5FEBEA61529089A1EB09D573EBACE193260709B9574B2113CB9AB86920777A14C67A505FC9F6770981E89F9F4F8B3E9216CA8EEC38DCFD3E07699CE5ECB0EE43D7593D0566055BE172E1907071208CE3865F2C7011421D22FCECF3CC425ED9AC7D2959B0032640B0F0614747E5A0C8CEA2B4B33393B35B88DBA761162DB45CB181F70DDCFC73FA8A3ACC35C5DF433E3873AD3FE75C85BB3BBFAFDE0B950C26548631C056A389B6D47650DBE53A649B50ACC96D73C6CEBF8B6FA132A5BDC2873F80942E745E91BC63452301A021EE577CB45B28F3A896389B470463F1AFA2AC873DC777500A23F9BAA965995D0A5000C084ED5950052B58F9F45FF4AF6B4498B31AC162A83E58C19AD9EC045FF51CE001316752C3AEB5089180864186AA4F88474F2A8B1026186ECD325A88DC4B5B445A2590B824C6B3BDA7D92513743750774CA9B281BA589396E0190DDD09ADEF97787F457347AC05AB9D28F1FD90E02A434932305BF523ED7CF976989102C3D210207F5A12E15609FFB5332F78442595C7FC4F40D1B5E4C8C987402C629C3D41CD6C08E6781BB8F50A7D6A866D6127A76E262BBD074017A82B75C1E4525531FA9ABBF7F47C472B5B21FC3C07BBF6936B39237F180324B9B16494DA2854F61E3327B117BF3333D0CEAD769C3B4314847D32123F223453E6BB47792A880469DE97868231E51EA9F4A144EB4CA9ABBB1AF5393EBD0926F5928D2BE14C735A3D128B910C0F3B9A10B4C6F56F30598A07B401CB46F20541C40A49CBD9F549117C329E654824D4FE27B695B9F7FDC802F98ADFDFFAC939095B5863DF2639F9B9A2BCFC2F5A215F10240D40879E2BBF91749ADB53BEB4F48FC314049DD88231F792CDDE6FD18174DFCA426216ED14C6285E76A88A1453A1A3F0E03E09F1FB5FA23C2499560CCE301226C67B4D8309C70017FB9C27ABFBA81701571B32796835235CE02DA7E3F1AD43DE5D4B25F9A8E3081A1363F1F3D03C8850F850E15E3391146514F82A63E128CB1C367BFD8D4E442D226D4AFE0DEED3FD6076427E8123837915C3B0607AF5FFE2EF40F50653F65C4E1E36BE98AA26D25E8CEBF78309BD8BFBF27EA66F0FF87C55A36D2C12DB1D4E3AB745FE6AF0773323F59E8F99507770ABEA182EF6501057752D9E02A0B532D9F3C9AFF826211AD30E900EAB4FF6B888C2AD85D5CE8078B73A53666083DE547BBE7BF79F9916301FFD5D8952E9594CD430DB09FE3AE51E14534B316062922F031BE85814C283677023155589EC39AFC7812B3D45D3EC34BDDE0CA212FA58C77EEA9CA6B1CEAA0F2E906B754870A958F7770359DB4BFCE0B755007D02F682F24CE018A03F74B860D3F2B516AB81F95588DC8A3D7B4C159DE846884698A5EC244A2DA4F3D3B2FFEBE99FDB65B3EEE767ADCB5088547E6DFE6419100B2B371DBA53C206993B659371F2CB9A2C6713C0E692FF4F793896CE6B4645A784A59C4141F1F8083EF9E7D8B58C6041D7D3B79F77C1EB2338EF936F4736A648077A8DA592327D27CF3E5C100609F89F4B25D45E2C66729370B120C7F47910B575CA8B0358D98133D10000D971B758277BF86D4F11AA266FBA81EF6F0A4EFA9EA3F7DB3191B5ED31BCBC105C03BDE7F9F20584E4DDA7FE0A41A651A553A1049EAA6995B6A8FB6EF6E6EA0229B8B12A1578E60D2AA04ADACB9D56B3F32688D7E3EAF5F3BF7A666EBF650B70562D358EA1AEAE424733CFA610304CD61F1FA095EFADB5CCB631E8D39DDCC74747AD526D00D709485111F83BC92B66B3397B3A6353A375E1443187F900E7397736548686A7BF0941617E6A21050EDBEACC77F35634A5BB20A5F65A8C88EFC99852EF78C2D600A766FA615DD7921FAD14569B50EBA6CD2A8965337E351B45514907DB42BABDC0B8DB4162A9187B4020FF7385076981BE0B50F1A8EB97B3E65636EDA7BBECB8D125CDAD3A02467C70BAB1031D2715B353833640DA60D031FA964CF470CD5AE1C1E173ED41485DCEAAADD9D2645A113101A5EEBE2831E54146837692EC47D0AEE9D430AECCF3C4663EB689350311FDE6A501C248F10FEF7FE042A3BCAC0C1580B3F33AC085652A26E50EEA131914D24F72E5384A11710DF18BF9526360204865324D14F2E9A7B34D1AB75A7161B2CE19E27D5034022CBE156D2B327B01DF381F3E3767B6AC281CC7E817AEB38355AE62C5C3CA5CB6741D1AAF8C72173040E0426D051EF8B788F2C2A82763B989B589424F60BEB76B386C890D108F14A2A8ED48995DE65E7FD8AD6206EA5778B2A65681CCF66AC7DC843A3312AAD759A6ED98F14F6AD88173924FAFBE578B0F72B04CF9318F313AB3072BA17BB752565B1239AFAD0501FD652E4EAD4C7AC366FAC65B402C526D52424C6A5E53DD966235955CC8C257CF0ADC9E851B5178026EB6D2539D9ED6AC22522D930D6A8A3E90345BDEDAC9991B2E2C3CEE0F0278C08E1A222458469B47BE507D6E67D6DA089F134384FA91576154A716EFC200C8E6167C5F798D8031895EBAA36C7DAFF83D9424EB1A6BEAAD1DCDD1DB0823899E9BC0738C93FAA6962C38E9E83BF6CAF8F1B001B0707EAFDFBEC385C6DAFC127CDD8EF58384C0D4CC34FE8399B0E92DC2B7E04E9CDF89CA6F7C365B984468D2A12B490FA38595405F4EA900B235C515A7913B693E1EF66F1981DA2C4A8969B8017F7C78F29870F2B35AC4839AE18BBC44B4C51DCD3ADC9B70916BC8441BEA69957820FCD79E098D868E79CCA8DDAAF46F789A2220D2D24D9F0CDA2C6AC67AAE0D598C0CF5F1956A7023CFA24CB9E353CDEA28B59EDA4C6980A850162DA4FF725622F5A5119F7808B42170521E7BAB242E06CCA5C0FDA5F18E5F3DC10605F7F69FFFD7D127C6D68956977375F22025BC76BF00F3102223DA4594FA9FE9C2FEBB7E64EB7CE20DECED080EC11FC5C7FB177CD065596D4E7D24F316B2F4BF4D35EB07C3CDBA999993511F6D493CAB670C41D79211DCBE6B280A3B943F28F7AC54F9C181BB15991924E091C9BAEC1813CDE6A51A8C6D721CCA0D9E81223601F1E49BF039010E42C3799B16E8D95C0B0089E42101E23BC5C660D72CA5DB99CE8748202CCBDE57B94A34E324D3204FB38CB60D8E2194D0AB7F7790F1DDC6A16EBF6DED83D1B6A40E63F7C07808305E8F5304E0A3886F2A8D6A9D6D95BFE1982DA213BBE0B49EF24F735A85A3CCFD7FEAA30B0DCF4117FBAD950CB79260A8EFCB9A1082F1AA13A143B019F5DDBF43ED47B646BE623032DCAD5D51932C209D0B21C267914C226D2DC760397B48949422C7516068A265FD1DAB8FFB2D922E77BE084DF71AF51EB9504933F01A78D0F772FB418C8E14EB1A2905359CA5FE9486024D28BEC4BBFCF6228CB863BDA474FA9BCEEADD583E7DD16CEE56E17BA3985C91E9F45C63BDC4BBFB39CFB23DCEA58B457553BA22F341610CA11EA0F9DA43CF01CA5C942D770999006CDF981DA70AFDFA676A616D63403BB02A4725BF716E137E3938EA350D591A7F6F06BCFA991547EE32C6856AC9795075F9CBB68A92861DDCB4F21C8E72ECE8F19E055679EDFE22BA48CF53B08663A7563514007039E4B3776A0FB97FF9EECB0743E851301DA8C2BEB9E3BD1484BD614A767093BCB6C922172CFBBC1B79FB53081682D862819FFD7E635548C5E29E0839D923ADC9E313B0B101217A7F97020F14C4E63ACA044AC6E47113798E49249D7AA17BCCD3A2824D188D860EFF1D8BC646EE994FF48483B04F90EA818283F7980EB34AC1FBFADA9D934E44AA2246469E963D3E1D805294CE1F884DAD1FEE050F3D998F961A7608D8675F4E18B57300DFE38D30D0AC9BC7D5A83799FB338E89F966FE2D0316385D5C9DE8988DCFDE2AB2BFAD92E4E0D123D3BE591D0E3B5B426BF54EE22ABE14D6B1FE08AD8DFCEFAE54BA1BEF0781031DCBB8AE685691995876D8521E66BC4652A932E0701BBBFCB67402F01F4292A04CB33B850B3A84D49577B22F1DD5F26C99E2EE5F09E37DA8D61EA39D7424B4E85E42462EDE7BD671F7F63A17BEC6D7C9E27B563E4413C016F5D08B1113C18B3B3AC9548CB5D2F116B727FCB2A15221C779F178F5AF1FD3E2D39EF21E81DE3DF47351DE3119B9810D3C75A2AEE8F9B9D84B20B5A13B1642DDDAAB51DB65C5F1E828550E4244E0E39B4C74D9D44E1BD495A218A73964B36FA57AAD3EAB59DFD9400DE11EA216BF79C529D8B7D78D458AE873B5F6C1908238C26FA473E0A365DE20447C6EB61A20F55A6F20BA5BE60DA32CD59D0CC7693828D544214F83015DE3696A5EA7DEA104502976F5FFD3C8D43685F69895F42DC349A954A7EC7EFF63501FA40EAB2C829601F00FE4892BF03334EF370D0BEEB8E6FDD432F2A1EFBE3F7DCCF1E2CC8FD6C9DBE0A91D2370E2B71B21D6139A48F58074F3C0E9D5581822B2897BB7E2CD26EE4BAAFCDAE111145E51DFF472C01EEB6E3346F3309F23F2AD8D6FB593EA1B52D7AC49EF9B0B8553AF76FCC5385E01A279F023EB10AA39C3015C091DC703586E2A3A080A229AC3FF0E1CC6CDBA396FE57C5B53875F767110C8C264B4C6C1341FC4925BA382DF4D73B3ACA12F9034912790BFF78D4F01873DDAF046408E52A142BB80C71CABFA0B3254D1A0FB75EB6B8288C50B69D39CF59F5753333ACD0DE76E7786EE3E03342B74025054B45FCA8DFD1366BABA2C93BADB2E2DAF74F5AFEE530AFBEBCB97517EF3F3336FD42EFBD3D49080EE15A5B5CA4EA4555A59FFC428AB6A1E5BC6CB08602889508F5136CE9B7E8FFA91E7A1941EDC8B82DC2D1C888A2434FE124B13B751CB773BDE80D59B4AD9D5DB0F59468354CA41E9FCC95968951A49F02BF1C7B8F78A682E9861630B0B4B9DD10B902F015B3F5ACCBC5FBFDF4EC226E5D4F467835C8042EDDAB0A8509DB77B9CDA6059CFBCA228F0DB0E1E6B6DF5C8795420E5779F3567AD3ECD680DC5EDFA826BBAE58CC71FB078C140368519539CF28CF6FD7DFB4C41DF6C447C8B9E4D207FF7E5DC94C3CC29064067677AB7D9DC2C9C5D61BFE74127248B1C601725A7911A401855800133CA4664206623E2C762BC6C94AD435D4AA6B2D2D6EE41F7A26F599E77B83D5C205EA05EDC1661DDFA70211B9151A22965E9AD582DAEE3FC758E4005555EFF63BB0AFF03CAA53A4AA49661D1650455519D64E315C73348C479450A0572A6BEA288B0B2C4B07FBBB4ADB187349D9F95368D912A3CA1E30B6FC51ACB11D732411380D6B8F516BB47D625A5B3379F4F8FFDFDA747F291FD6C2E51EF3A111955F31E435B5859814C88714B4DE0B4849E5FE78441F6673EE3B8B96E20FF16CF241F1185B5A0DEE82F33D972C9E35EACC758E8489EF8456DF5A7FB550A54EB0D57067B8CB23CD0137450F2A9C9D5325246FDEB8B995BEBAC248D171361EBACD3E146AF58BC56602689466D4A91FB01BCCDC8B1E9F72C2FC45D5E6F38E66443AD7970E47BBCC7E6C3C3DA1D27180E1EFF2DE45DD4E0695EB7FA09838204BDF9E2E7611F6F269E6C23D01A15340D40469418EB2A88DC91EFDED99D5D64808C2057B5CD6F5EE8EA2112BBBE70164742F2932C85B6859591DC9B66765B5D9C3D3F22616DF5A93BA9AB6F4D177BD1302D5D2C31BE5C2C56B7CF0183BEB44E818E65D19273E7877B3698BDBC2B7A84F7B539C8EF7CC0549DF5AE1B3AE8D7089E93BFA861B729C2E6C99C13D0F5E3205A2287E2A1544F22942A436053B6AE1574BE26CDE8A7ADF73BCBFEB51CBFFC56DED0F85D4D3C33579F39929787B0E5572F5678509B93937458ECA0DCD2238449688DAE9BF2A563B952FB8841C60BD97EFEE72A532589D271774367974835CEE5ADAB5B378942462BF1E45AE0D3B10FBAA6C72A53120C54536E8A13DDA4562CF83324A27EE1507E917067D975226BA8DCB4BB6B47648592D5F9F6E89930CFA89C2BE9A00553DAE941149F47C1989A1C68628536CE0A31CE9A5889563EC91DBBE931C94EEAF8A512FF0805CC6B85F9020A1FDCB154C448A1A93A7FA10199E5A9996A93EBE8990D40B8DA455A528ED791BE34CE917F01342BFE8E894476E2E0EC9A8CE4E29309A1C55C5F0DB69767E54C52979A7D36F120D7EF93A7EC7DA96E552FEF6136DB21781896ABCE19FEB539E3C61FA27E23F0F03A4D5A01249F7033852E34E59245CA284ECC74F913B9C5731042ABBBC3828C95F23E237700490D441BC7A69551B3E67EC09045B072368BA09C15D4BFC6B15C9701CF532D0631B7D6E4129FCF75379657D7E74BDEF5FD284DB63663112C0CA43B00924D655AB4DA9C23AE7A8065F58E3E4E8C018D0463269C8EB66138C4017D349FC1E1C3CBA1F5875A6BF449A0F15E54158C4FC80098E587804C6E07260E25CCB2EDC3DDEFF4960F29A395F64657035634D32C5DCD5DC9F4408EC155FD8145F40BB7AD615BC50DF254A844A44CA57E869918B538CD61FBB2819813093FBE569CE567FBF69CECDD8F2E66F320E09AAC29F1D8900CFF985EC49046CF2B56CFF799B174623FCF58499084CF6E0268F46CD587B55909C7132C3B68A1267864043FBE463F036C0FDA0131B0B6E3CE9174F2175A840180A5BF1BE8C5A9C4FD82FAB283DE94C1CE6D9EAD2AB7FD4FC351C9A5A7A89F6CA77BE0C183E60E3B10E2A4986AA58F6EE07A707BF1A6F607B5CBF12D72681122568ACA17661B32C72EDF3531F6ED158F28438C6F757956295573A2B4796C6A469CEF3FE3EE3FA7B321A9A1CE3AF7D16283BB2D0A4B316D12C82E02215E3C8B264200EF6B30EB920B0F332F2E25AEFC8C83B6921BFE2A6B02CD6EAA107535331CE6901AFB49B4CB81E266FB7DED0DB05F8DAFC1C0B5D32362E7FB827960CFE156D77D62BFFC1F3559E1BF773D01B8145ACC6267FF699FBA5AACC261BFF632568A2E01ADDE2535A4944D31A582C2960CD6E3C20D39DA5D23DE88E869ACDC69729C1ACF09A6214C05107104D98CC4E7E7C0F45F686202BC4086EEDB0092CEA1CF4B302969755FC53DAA6A11E6DDED633586149CE02B2ECC091DA574EAA3F179CFAF8F251483E46538E4E138B1A5E8570BB4345E05369AD45D32D57720A9FE59E06C3045B29F4D81626665DDAAA59076B306D5AB6E337A095E4FD9B30ABB72B90FBA9FCA011E3832D912A1057603D8422652D9F96BA69E35BD2C8C9717C67E248D90E828F7911706A6712B23BB117FA1D8AA053C714185C196ECF614AF2A9442E244B64F2CB8567191F0A406C0EADC3D3227C8EF7700215C863DA4DF2A9368A23F2EDCE3296266039EF178E38BE6226D7E0F0C207F6067274C4022A4DCBC9A855E2EB2ED6EAD1C9418709E2822DB4DD8181C36F32239309B83A83DA0F30D8D2F260202320B0E923CCFFD92B3C031A8F635AC9F7BAF3A92285BB7D52DE0810B3AF3D91364F1962A1FB321F69A30D557640B3E6BD68BBE8F28669C1E57FD02246321C38508AAEF0A2661BEE9CBC05EEA64EFE9626502059EB173E30419CA6FDFCE7A44BD6EE19D93B22DF512201A9AA83C7F54AB01712E33069A5E91287FD55D15BD48D109B20ABCCCDB6895AD264EFF702D454CE9EE6765DCB6354A1D924FA713046C717EF892E457DB3E74632AB66F8605A92288949481440FB4B37A78364BF0B63F00779550217824FF2A3FF27BBAE1DB9EE6F849D737B8D99386E160381BEC4B8AD6BBF619A60DAE3FD13553E2C7FB43D127A265283D219B15478F1AEB5CE764DDDF2442B4217A06FD78DDB8BBB550ADB043E2B72842FD0C590D56C5E594762DC4CC626959247C7F6A2492D12AE5D8EBA3886D69FB73BCE296F43A0176B6EB89AC65BBFFADB862B5399F6CA617200A1FE65E10ABCE556401414E683870DD44A324078B3C1D337DA65FDE0FF7038192BF84315C97FA79A616DE3AE866E363267B6F57FC7055E1C4BBE5EDCD51F2C037B8FD208DE92E78ADD480118D300A6A4AFB29AB6100661B0F1010E246913B7D5391117D686062974B719716D15DDD3E9606C305D955E9E1D2602F46D3559D6C550ADF6B4C5B3FEB7CE5EAE16171686B3CD2DB3CBFC848306B1EA0CB3C57C6F6C0915EC4F15685C280681A3B887D6F949C488B8ED82C1EDFD08EA93E690CF8D5A0AD21CCA5B1D2348D37DC89E333E64F04F5022320142753FBA9707108B3BF7D9A1320D199E990F6CDFD8A79B78BD27DB5798B10A25F308865E9D800EA0E425216D37B58077B7F8C7D4FC37890B56AFC05CC6884D14A4BA7AF11979317A9C79E14C0F02734E24B66D9DF624E9502079B35F8868A1BE9B3C94FEEDABBB18769F827191A6E229BC8AB32F928DF2E253B45AB00D48B6E623F0B9E786FD113E3F94366D710F4D695845A48F166D2C7D407FA0AB0CDB9182AA3B8EE950615D9D0AC3A0251203F4FC9BA1EE29F3BADFFC86F4B9FCD1D10BA77CEC163A3EAF52DD372616FD54402196CA55C1F92D2C1454F7B7708BA2CAB36B10388B91F0CF0272A1E574FF98164FB31974F78FB7CB392A7A4349CCAB35BC45D6BEF216E17F3038EACA96F392FF4DE441AF1D48430233ACAC955C9253C6858251D64DB7EAE1D3CDED5DAE042C1A51AA78284D512730A8EBDD3B776E9A33FA9AFEA613AB5A9B09174F5480AC05F267411DE7DED53C0AE1058B0388EA3959A19FEA3F7DA8EC016191B2D092E405D9EAE79154D3C9A92857E766EB8E9D4C42C23A0A6F449203699A404C443F16765AA41B66851DE35E6B7059001F62F9F4D7CC4D6947F9C731EFE86A5E9918E0EECDB4B7800FE643F66A37851092D51C741D5427090EE0D81CD3C6CB2DF43100A40F1480270414C413B984C2A921579EDC13F3BC30B19103E015E157625741F078456B195CB6B993FC86BBA4DEFDDD8F149FE60F5AB4B6583471176A0E2A2D76795135DD2D8D4CD141E280DD2313D4009463FD8882A117CEE405B1302CC6EA2BC9AC9107C2DCB9E7660688E68C45B3C38E5FE4B623603FCED3FFBF928BF7D729B3A7CDAAED99A4B0DFE829F8633ECB3506DDC418254FC6A5C51D900FF062277FF270FF309028D84F27CE70C8D19438EEB4C7803F579EDBC4A66280D67394CB4E70FA271FE024BA1EF392E21280ABA785176C947F3AC1D330C8DE2B9AC1250CA2B4066258641E61AC40DC9D5365695087D3BA136F7133B213B2B2BCD517889205724A84C209FD50F14FFC10C4AD604FD6F6E3A67BE98986A1E5CD010D9CB846EA50218FE7A8781483246B1A97762B292C67677FAA2B8001D4CFB4D6E77135585CD843B7FAB32804CD88D7B13D8CA05FF57E267048B5C5DF83CD6C11DDB57B555704A4FECEA6761D9725511B5F81DF6EB383072E6A63BA2BB78079AA3A03E012454F7BE23F68A62F27DC36B7D6191E46E6B9C829AE1A9BAA48DEADFD7864F74181A1928D9D3A7D97E2FF8A5503A8B3D09A8F7A473FCB22A2553055B2E7E59FE8B9DE3ED8469495AEDE4FC0ACA3824F1724D3B3E86037C84607247E695833873D3099ADA5FE72F65E5D5BEF8AC8179E3BD438D9756556A9C53CA8987D52735DCBDAAF6B97B1FB1114FBF92036F81473FE3A43D42B8BBC0E9C2F347DF2B3B1F7A920164CC05B9FCA7D974DAFB0E1B2CE796A7764578FFC5BF2D5A4B996217C556F075216864B44E9F71580E878FA63B5E167B51560A4AFB6F45E2F3F534BFC00BE97631297906A937EF4C058DEE6424013DA90E067680150928B63C0E8B09E27A71CE7020CBE1F7153B869CAA6084195D2B6A36E36934627DAA33E01CAEBE17F4E3A48A40835BB2109067A1D83B89B91E8315E4C234FE0DA69244E45F271892932D67B83715A4C7573B96ABF16B557ED4D1DFA94BE03351717B765A9A26AF36ECD659E80FED04B8825FDF62369C6BFB1144AE485DA1EBB808FD2A5409BA76AEEA84C2BA39E3DF278246B9E7100014B139EEA1A2C132495BA4A4A71321CC33C9BD09B4A8CBFC863BE4992DC583F8F9682F61251F5F21F9E4ECB5B3E175C27B8121853B97AB7415144F7E9DA882115263E165D547F6A0789D324B1CA410BF2492214A305A832FA8F561AAF72835F16EC01C3E467A68C94D9C3D45F518E5565878DC78F0B26FE5C7D32E7F100318A0E3170D2ADD33CDDAEFCC385A02006ADDA7C8E1195268A1444338D2EFA14C91B888B22C9796EE2A523D5C2CB20341323D05543075C7481123C4A9CD6619A100C5FB1EB1E3A54A46A898F3C390299E5C856FE0CFFCCDE5BAB07C38EC59D94BE0863DF103021980B837E0CFCA797521FC8C82972482525B32782919987FBFF74D1615C963EAD5A47BABAA0815E017AC0E48663E08859D65A9296F7B84231DF50DF259BE16D9F7B7C78184D1C81B7A3FC3E46EFF5098A030C36F95F3549F0638180235FF1514A28B55125C525CCFDB749932F06D9C5EEA583C54DAE670485CF7F1128D8FC1FD58A1A504787372EF4CF8259D319C9C3409E2DCF6ADCB9E00ECA884314330589C79408A986A1398F982B52F7ECAA0E516816DC5207E879C0419F633350D85646E25D66E84E248087BA1015392B21F5ED6DD8BAEBDDCC5FE89A9B8E56D07B8202B67F11C02E245ADE30B4A5D5FDF958F2F2987582EBB2B84D97740E9538049CEF81CF6E6CB30BC0BFBD76AB5DC49CAB2C549789977E6B4FD678A8EDC017A722B8E4ACD930E4ABAC9E2AEF5FCE53816587352FF2411A3DA7C7CA7F9A73F52B76A7CD062056AA32C635D7C1152EBF0C37B873D55B87DF20F97835639B5DC876CFCF7D812D2F5B27B376EA22057904E6FDD7852A60405E747661E54DC6FD85624EBF2B869D401EB81A2F14F9CF99E8D03A35BEDBE963A00F2BE3844E028F138C67342B81C06A0ACF160B30E4F126415CD8455158639797DDE80ADF77AEF20552481130F3EAD62BE2CA039310E948FA1E0277715BD1A5AAFABBD07DC785CE97112FEE51CA712EC7D13A3ECF0C5236A4FFC3550AAD957C172F023FF713CCD7CC7603A2D2F02648B55FDF33515EC4239E9A5EE74189148D52594854741FDFE9D014D5D92BA77E6427DF81AF5758A05A2F6E0E31D686FFF6284A6D6E90D7F31191FB98C0650E1150E7D62DE9DA4F6C79A0D9FE62C15E7FCC02405DBA0096DD7B8E9AA4845A5D54AD8D27FBAD1DAFF54ED563E4BF5419EF18778A742F7C6DC5B3CA4463B58CAA79D1D31E196D0C5674FF16A5E518D4A3EE7CF0ED72604F37862CEC40A649C21B48A21818A6981A8F00DCB03760D946D1E02D2B4B81B2B5885727B6CB4F2C7759F7DFA31F4693660EE57B978B63294179E97821D73E2375CD22B3C601BE1781C17992A33F681AB67CC5537D15E05A0093E9501A3F01468C9BFC64BB98342D2DCE6343FEF79AD531A0EC8B24D25753E2308BD0ADD2549F8A74386128DC524710B44B9886F8B3D1921FA8494E794EBAD6B1F73E1CECF9AE6D36CEC63A2225AF66DB184DDA9F01A5BA7F74BE2F731F10BCCC7E7B26B98D52E5EC1287D0CD6A7A8031E5106FB1B8692487A1690DF50A41DB296754A56151894133C9ABF5888B5453D2F9347601C0C9F2FCC5EE5D57EDBC610F2F6D0F32D18BBC185961D74E90F1C56EA6DEF220DE453F649E51ABAFCE3711B778509622EEA017AE5A549E2E6F887164A3EC38BC2A965AF0A726471EF1D01DD379C17121BC9E77628754A6920E029A738D7A885C2FF999F888DA2F3DCFFB839DFBDDFE47BB8A1986EBED2B791396A14C46E9CA5FC82D8D349CC1A63B9051FACB368DC93634565618F8470A65B24757D511A8177364C0147B2C21D7F5D733CCC055907AE909BFE7E455D2A458231855C543762EF6198CCD1BEF713C449958047118AF119F6147AC141B4FFD0FCBA71381C589F30EB76B22F7661737D2287FD76A5A28F26A526C08B17D1D7357DEF4CCF5FA915F4E51553543D9C43D9BBECA9929E4B4945487D58227F19A9CF20C5CE3588353303E582D1807B9382E8E289B384EF25FE40657096575B435AEC98B5D524C40CE3297EC7176632420FC4475234229EA6EB14886DBB7C12D142B44D883D26A180AFB25CEBEC7F836654CA85A0DB4067F00CD54CA0A8FDD2E74C153C9A0B94D955B18456747EDCBE124AC1A53498885AF8781D4A3626CDD51931D5D91BD5DF3BA77B847E4D93E9B62CC9CD258B475738FB9F84AB1F95BAAC8BFD6DA30535527EE1C72BCE4C76584117B3AE63D6405D36E6A88E2B2A6FA682F3CA350DD041216F33A4C69CB603A105B7D003EF9B14CC257B71EDFF635B8930419B0CA116D6DDE5674DB129095551D19B0EFD8503B87EFBF7FDB3C3B075546AC2CB75106DBB93C2236234C4B779D38AB8054B1D59E8B1190E157517B1DEA2219DBD4FA512DD088E094A66D377FAC6B62D48A564F02B1713892D26D8AFF137E1DF1BDE6249597456AA76867388E0CB5A2F030DE94576A305D6B13FC03DBF14C73EB7C3E8C0B6DDE36253B14EA4B9D9A8317ADB4FA5A64780BDBDB0477CA17BB258790FB4A9347C5933DDA9FFECD1AF6F0E0CC0B69909ECED0C6861F7BCE7A0B137AB5B91EA65DAF5D2B7C963DF71AE3BDEC283B26DCEA5335FE7EEBFB4D93F35F4FEC1E23F8F64407DFC257802A32B301205B99F9DF25DA74226458D98C05FB59DE42D3EC9BF488CB9DD38E4227FFBDEDAE672D74DCAABCF27118250B4D084F5BC4EB8322C4A65AFFC4F8B325C1EACDDD2EA48C43F99133B7C62A43AB1AF7C82AD52B75C368BF77FFC156EE9C3671C7BC161BCDC3ABB5256A904065705111B7B511993E4C078E020BA3B127C0E80557DF495539D95251143F7E7B11FFF4B9A88E28F5E96BE53128720FC114C625E3840D35B068257475E21D307A38937318808BC13602B622DB3879D9535BD8B8B78C9DDE2B16CDC5EE74ECDE37974D051A760816C4C45C19AD52789206814C6C332F3CD762948929B0F08D62FCE272D6E8D3C7FD5B66A03AB55D7E72E92D3E295F12797AB3FAF8555A10ABBCC1325D51D86F1902F4C7C5FD2BF01780E5B40E57817ED2D77BFA222E555858DC98161413310860396021B6B4A5D2BC68AA77E0F5650F1FD61655EB8E709BF31B338B59142532716AAB117BCFC76E4D5F98A768BF73312F0FAB01B9F9E29124B57E7CC313AE4D7903B08985A3FC94331B51D9C728D39C6F81309DEC0FE0F0172FDA2A2A5C1E0D9A263C69F279194C6A1187134C5DFE8487D164773437CF3DC8A564BA467942F8B97738F9FF5DDE979AB9CA163C83603756B89399AAD725A72428F74DE78AE4DD22B52E4E4F0D79EFF4E92F5EDD85764C6DFD853E914A4D31985C8F572480097B8B3E4022356C3D12A13FCAE18A3A478C32C1AA8DC3AAF0E88C2F64B8D574B102928564F8BEE84A878791478D850071593F9C13C2C1406B3B73CEC3D8813EC9AFE06B0781AFD1C448C01EE4831C5A353EA41E3215BD9A0234033D5B5202B800DB36B61B8CA5E5F98C56AD94AB5C1AC381FD8E839932115C489E5C28CCD858F9B006BD87D2F9667BB3FDCAC42B20FBEC8F341F227DD468DD8B3A9EA4EB8E61E9B08EB0C7ADF5BFB65A50C9B9022C5C0389C33CC4FB1C71BD04E592BFF4F761E7E9E36589DF7E4F936AE7A696EAAC5AAA +# 0000 + diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index a135641..e3cba7f 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -28,13 +28,14 @@ # TODO use more sophisticated glow patters in different cases +# led green on def glow_start_scan(): cmd = unhexlify( '3920bf0200ffff0000019900200000000099990000000000000000000000000020000000000000000000000000ffff000000990020000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' ) assert_status(tls.app(cmd)) - +# led_green_blink def glow_end_scan(): cmd = unhexlify( '39f4010000f401000001ff002000000000ffff0000000000000000000000000020000000000000000000000000f401000000ff0020000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' @@ -717,6 +718,7 @@ def capture(self, mode: CaptureMode) -> typing.Tuple[int, int, int, int]: res = get_prg_status2() + # 0000 (status) 00200000 (sz) 7000 (x) 7000 (y) 4d01 (?) 0800 (?) 00000000 (err_code) xxxx (img_from_sensor) assert_status(res) res = res[2:] @@ -726,15 +728,27 @@ def capture(self, mode: CaptureMode) -> typing.Tuple[int, int, int, int]: if l != len(res): raise Exception('Response size does not match %d != %d', l, len(res)) - x, y, w1, w2, error = unpack(' int: rsp = tls.app(pack(' Date: Wed, 20 May 2026 15:28:11 +0200 Subject: [PATCH 04/17] Support for 06cb:00a2 (enroll on host, match on chip) Uses RE from Windows dll Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + README.md | 92 +- bin/validity-sensors-firmware | 4 +- dbus_service/dbus-service | 22 +- debian/changelog | 23 + debian/control | 1 - debian/rules | 5 +- scripts/enroll_moh_chip.py | 239 +++++ setup.py | 2 +- validitysensor/blobs.py | 14 +- validitysensor/blobs_a2.py | 431 ++++++++- validitysensor/db.py | 2 +- validitysensor/fingerprint_constants.py | 39 + validitysensor/firmware_tables.py | 8 +- validitysensor/init.py | 2 + validitysensor/init_data_dir.py | 8 + validitysensor/init_flash.py | 37 +- validitysensor/moh_extract.py | 612 ++++++++++++ validitysensor/moh_native.py | 1142 +++++++++++++++++++++++ validitysensor/sensor.py | 247 ++++- validitysensor/tls.py | 2 +- validitysensor/upload_fwext.py | 3 +- validitysensor/usb.py | 4 +- validitysensor/winbio_constants.py | 34 - 24 files changed, 2822 insertions(+), 152 deletions(-) create mode 100644 scripts/enroll_moh_chip.py create mode 100644 validitysensor/fingerprint_constants.py create mode 100644 validitysensor/init_data_dir.py create mode 100644 validitysensor/moh_extract.py create mode 100644 validitysensor/moh_native.py delete mode 100644 validitysensor/winbio_constants.py diff --git a/.gitignore b/.gitignore index 80f465e..7032065 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ build dist python_validity.egg-info/ tls.dict +.claude/ diff --git a/README.md b/README.md index 289814f..e327899 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,26 @@ # python-validity Validity fingerprint sensor driver. +Table of Contents +================= + + * [python-validity](#python-validity) + * [Setting up](#setting-up) + * [Error situations](#error-situations) + * [list devices failed ](#list-devices-failed) + * [Errors on startup](#errors-on-startup) + * [Fingerprint not working after waking up from suspend](#fingerprint-not-working-after-waking-up-from-suspend) + * [Enabling fingerprint for system authentication](#enabling-fingerprint-for-system-authentication) + * [The actual change from pam-auth-update](#the-actual-change-from-pam-auth-update) + * [Windows interoperability](#windows-interoperability) + * [Playground](#playground) + * [Initialize a session](#initialize-a-session) + * [Enroll a new user](#enroll-a-new-user) + * [Delete database record (user/finger/whatever)](#delete-database-record-userfingerwhatever) + * [Identify a finger (scan)](#identify-a-finger-scan) + * [DBus service](#dbus-service) + * [Debugging](#debugging) + ## Setting up On Ubuntu system: @@ -21,11 +41,25 @@ $ yay -S python-validity $ fprintd-enroll ``` +On Fedora Linux + +``` +$ sudo dnf copr enable sneexy/python-validity +$ sudo dnf install open-fprintd fprintd-clients fprintd-clients-pam python3-validity +...wait a bit... +$ fprintd-enroll +``` + ### Error situations -If `fprintd-enroll` returns with `list_devices failed:`, you can check + +#### List devices failed + +If `fprintd-enroll` returns with `list_devices failed:` or `GDBus.Error:net.reactivated.Fprint.error.NoSuchDevice`, you can check the logs of the `python3-validity` daemon using `$ sudo systemctl status python3-validity`. If it's not running, you can enable and/or start it by substituting `status` with `enable` or `start`. +#### Errors on startup + It `systemctl status python3-validity` complains about errors on startup, you may need to factory-reset the fingerprint chip. Do that like so: ``` $ sudo systemctl stop python3-validity @@ -42,28 +76,47 @@ $ sudo systemctl start python3-validity $ fprintd-enroll ``` -For even more error procedures, check [this Arch comment thread](https://aur.archlinux.org/packages/python-validity/#comment-755904) or [this python-validity bug comment thread](https://github.com/uunicorn/python-validity/issues/3). +#### Fingerprint not working after waking up from suspend -## Enabling fingerprint for system authentication -To enable fingerprint login, if it doesn't come automatically, run +Enable *open-fprintd-resume* and *open-fprintd-suspend* services: ``` -$ sudo pam-auth-update +$ sudo systemctl enable open-fprintd-resume open-fprintd-suspend ``` -and use the space-bar to enable fingerprint authentication. -The change will take effect immediately. At this point, the fingerprint -will be tried first, and only if that fails or times out will you see -a password prompt. Take note of the led-stripe above the fingerprint -sensor to see whether it is active. -### The actual change from pam-auth-update -The above mentioned command `$ sudo pam-auth-update` simply makes a small modification to /etc/pam.d/common-auth: +For even more error procedures, check [this Arch comment thread](https://aur.archlinux.org/packages/python-validity/#comment-755904) or [this python-validity bug comment thread](https://github.com/uunicorn/python-validity/issues/3). -``` -# In /etc/pam.d/common-auth, the following line is added, and the next line changed. -# The end result (apart from other things that may be in the file) is this: -auth [success=2 default=ignore] pam_fprintd.so max_tries=1 timeout=10 # debug -auth [success=1 default=ignore] pam_unix.so nullok_secure try_first_pass -``` +## Enabling fingerprint for system authentication + +if it doesn't come automatically, you might need to make changes to files in `/etc/pam.d` to enable fingerprint login (depending on your distro). + +- On Fedora, use `authselect`[^1]: + ``` + $ sudo authselect current + $ sudo authselect enable-feature with-fingerprint + $ sudo authselect apply-changes + ``` + +- On other distros, run + ``` + $ sudo pam-auth-update + ``` + and use the space-bar to enable fingerprint authentication. + The change will take effect immediately. At this point, the fingerprint + will be tried first, and only if that fails or times out will you see + a password prompt. Take note of the led-stripe above the fingerprint + sensor to see whether it is active. + + You can also take a look at [Configuration: fprint](https://wiki.archlinux.org/title/Fprint#Configuration) on the Arch Wiki for an idea how the file should be modified. + + ### The actual change from pam-auth-update + The above mentioned command `$ sudo pam-auth-update` simply makes a small modification to /etc/pam.d/common-auth: + + ``` + # In /etc/pam.d/common-auth, the following line is added, and the next line changed. + # The end result (apart from other things that may be in the file) is this: + auth [success=2 default=ignore] pam_fprintd.so max_tries=1 timeout=10 # debug + auth [success=1 default=ignore] pam_unix.so nullok_secure try_first_pass + ``` ## Windows interoperability @@ -82,6 +135,7 @@ user_to_sid: "myusername": "S-1-5-21-1234567890-1234567890-1234567890-1001" "someotheruser": "S-1-5-21-1234567890-1234567890-1234567890-1003" ``` +Note the indentation; each entry has to be preceded by at least one space. ## Playground @@ -185,3 +239,5 @@ If you are curious you can enable tracing to see what flows in and out of device 10: User S-1-5-21-394619333-3876782012-1672975908-3333 with 0 fingers: >>> ``` + +[^1]: Credit to u/trollpunny: [https://old.reddit.com/r/Fedora/comments/oik8sq/comment/h4xvrqv/?utm_source=share&utm_medium=web2x&context=3](https://old.reddit.com/r/Fedora/comments/oik8sq/comment/h4xvrqv/?utm_source=share&utm_medium=web2x&context=3) diff --git a/bin/validity-sensors-firmware b/bin/validity-sensors-firmware index e585f1c..c9d357b 100755 --- a/bin/validity-sensors-firmware +++ b/bin/validity-sensors-firmware @@ -29,10 +29,10 @@ import urllib.request from usb import core as usb_core +from validitysensor.init_data_dir import PYTHON_VALIDITY_DATA_DIR from validitysensor.firmware_tables import FIRMWARE_NAMES, FIRMWARE_URIS from validitysensor.usb import SupportedDevices -python_validity_data = '/usr/share/python-validity/' def download_and_extract_fw(dev_type, fwdir, fwuri=None): @@ -99,4 +99,4 @@ if __name__ == "__main__": with tempfile.TemporaryDirectory() as fwdir: fwpath = download_and_extract_fw(dev_type, fwdir, fwuri=args.driver_uri) - shutil.copy(fwpath, python_validity_data) + shutil.copy(fwpath, PYTHON_VALIDITY_DATA_DIR) diff --git a/dbus_service/dbus-service b/dbus_service/dbus-service index 7a1a1bc..1c63e8d 100755 --- a/dbus_service/dbus-service +++ b/dbus_service/dbus-service @@ -23,19 +23,20 @@ from usb import core as usb_core from validitysensor import init from validitysensor.db import subtype_to_string, db, SidIdentity, User +from validitysensor.init_data_dir import PYTHON_VALIDITY_DATA_DIR, init_data_dir from validitysensor.sensor import sensor, RebootException from validitysensor.sid import sid_from_string from validitysensor.tls import tls from validitysensor.usb import usb -from validitysensor.winbio_constants import finger_ids +from validitysensor.fingerprint_constants import finger_ids dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) -GLib.threads_init() INTERFACE_NAME = 'io.github.uunicorn.Fprint.Device' loop = GLib.MainLoop() +init_data_dir() class NoEnrolledPrints(dbus.DBusException): _dbus_error_name = 'net.reactivated.Fprint.Error.NoEnrolledPrints' @@ -71,7 +72,10 @@ class Device(dbus.service.Object): def Resume(self): logging.debug('In Resume') tls.reset() - init.open_common() + try: + init.open_common() + except: + init.open_common() @dbus.service.method(dbus_interface=INTERFACE_NAME, in_signature="s", out_signature="as") def ListEnrolledFingers(self, user): @@ -141,14 +145,8 @@ class Device(dbus.service.Object): def EnrollStart(self, user, finger_name): logging.debug('In EnrollStart %s for %s' % (finger_name, user)) - # left-ring-finger => LH - hand = 'LH' if finger_name[0] == 'l' else 'RH' - # left-ring-finger => RING_FINGER - generic_finger = '_'.join(finger_name.split('-')[1:]).upper() - winbio_name = 'WINBIO_ANSI_381_POS_' + hand + '_' + generic_finger - usr = self.user2identity(user) - index = finger_ids.get(winbio_name, None) + index = finger_ids.get(finger_name, None) def update_cb(rsp, e): if e is not None: @@ -195,7 +193,7 @@ class Device(dbus.service.Object): return hexlify(tls.app(unhexlify(cmd))).decode() -backoff_file = '/usr/share/python-validity/backoff' +backoff_file = PYTHON_VALIDITY_DATA_DIR + 'backoff' # I don't know how to tell systemd to backoff in case of multiple instance of the same template service, help! @@ -242,7 +240,7 @@ def main(): # Load and perform basic validation of config file. try: with (args.configpath / 'dbus-service.yaml').open(mode='rt') as configfd: - config = yaml.load(configfd) + config = yaml.safe_load(configfd) except FileNotFoundError: # No configuration file. Create default config = {'user_to_sid': {}} diff --git a/debian/changelog b/debian/changelog index 842fdc0..8d7c453 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,26 @@ +python-validity (0.15~ppa2) noble; urgency=medium + + * Change all write paths to /var/run/python-validity + * Use a different partition table + signature for 0090 devices + * Docs fixes + * switch to noble + + -- unicorn Mon, 09 Jun 2025 20:19:55 +1200 + +python-validity (0.14~ppa1) bionic; urgency=medium + + * Retry establishing TLS on Resume in case if it was already established. + + -- unicorn Fri, 03 Jun 2022 08:27:55 +1200 + +python-validity (0.13~ppa1) bionic; urgency=medium + + * Bugfixes + * Support for 138a:009d + * Fedora installation items + + -- unicorn Sun, 26 Sep 2021 13:03:20 +1300 + python-validity (0.12~ppa1) bionic; urgency=medium * Implement Suspend/Resume methods to reset the TLS state diff --git a/debian/control b/debian/control index 35eff4d..2b09da3 100644 --- a/debian/control +++ b/debian/control @@ -19,7 +19,6 @@ Depends: ${python3:Depends}, dbus, open-fprintd (>= 0.6~), innoextract (>= 1.6~) -XB-Python-Version: ${python3:Versions} Description: Validity Fingerprint Sensor DBus Driver This package adds support to some Validity sensors. . diff --git a/debian/rules b/debian/rules index 9c8fd6a..d44c5b9 100755 --- a/debian/rules +++ b/debian/rules @@ -8,6 +8,9 @@ override_dh_installsystemd: dh_installsystemd --name=python3-validity - override_dh_auto_install: python3 ./setup.py install --root=$(CURDIR)/debian/tmp --prefix=/usr --install-layout=deb + +override_dh_auto_clean: + python3 ./setup.py clean + diff --git a/scripts/enroll_moh_chip.py b/scripts/enroll_moh_chip.py new file mode 100644 index 0000000..1b8dc56 --- /dev/null +++ b/scripts/enroll_moh_chip.py @@ -0,0 +1,239 @@ +"""End-to-end REFERENCE-FREE native enrollment ON THE ACTUAL CHIP. + +Captures N frames from the connected 06cb:00a2 sensor, runs the byte-exact +native pipeline, builds a chip-storable template from a baked-in framing +scaffold (validitysensor/native_ws_scaffold.bin) — no captured reference +template needed — stores it via raw 0x47, then optionally tries to identify +the finger to verify the chip accepts our template. + +Run on a machine with the sensor plugged in and python-validity +initialised (i.e., the usual `validity-sensors-firmware` & TLS handshake +have already been done — same prerequisites as the existing `enroll` +script in this repo). + +Usage: + sudo ./.venv-poc/bin/python scripts/enroll_moh_chip.py \\ + --parent \\ + [--match] # try to identify after enroll + + --subtype N WinBio subtype (= finger position). Defaults to 0xf5 + (right index, common test). Look at validitysensor/ + fingerprint_constants.py for the full list. + + --match after storing, capture a fresh frame and ask the chip + to identify (sensor.match_finger). If the chip matches + it back to the userid we just enrolled, the pipeline + is end-to-end working. +""" +import argparse +import logging +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + +def _try_match(log): + """Capture a fresh frame and ask the chip to identify. Returns True on a + match, False on 'not recognized' (logged cleanly, no traceback).""" + from validitysensor.sensor import sensor as Sensor + log.info('LIFT FINGER, then place the SAME finger to verify the chip matches ...') + try: + usrid, subtype_out, hsh = Sensor.identify( + lambda e: log.warning(f'identify capture retry: {e}')) + log.info(f'✓ CHIP MATCHED: usrid={usrid}, subtype=0x{subtype_out:x}') + return True + except Exception as e: + log.warning(f'✗ NO MATCH ({e})') + return False + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--subtype', default='0xf5', + help='WinBio subtype, hex or decimal (default 0xf5)') + ap.add_argument('--parent', type=int, default=5, + help='parent user dbid (use --list-users to find the ' + 'StgWindsor user dbid; default 5)') + ap.add_argument('--frames', type=int, default=4, + help='number of DISTINCT placements to capture, one per v30 ' + 'section (default 4 — hardware-confirmed more placement-' + 'robust than 1; a real DLL enroll uses 8). Vary finger ' + 'placement between captures for coverage.') + ap.add_argument('--match', action='store_true', + help='after enroll, capture again and try to identify') + ap.add_argument('--dry-run', action='store_true', + help='build the envelope but DO NOT store on chip; ' + 'write it to /tmp/native_envelope.bin instead') + ap.add_argument('--match-only', action='store_true', + help='do NOT enroll; just run the chip 0x5e identify against ' + 'whatever is already stored. Control: if even a known-' + 'good Wine-enrolled finger does not match, the 0x5e ' + '(match-on-chip) path is dead on this MoH chip.') + ap.add_argument('--delete-dbid', type=int, default=None, + help='delete the FINGER record with this dbid (see ' + '--list-users), then exit. Use to remove the Wine ' + 'finger so a --match-only cleanly tests OUR template. ' + 'Refuses to delete a USER record (would orphan fingers) ' + 'unless --force.') + ap.add_argument('--force', action='store_true', + help='allow --delete-dbid to delete a non-finger record') + ap.add_argument('--user-sid', default=None, + help='enroll under this user SID, creating the user if it ' + 'does not exist (self-contained: no Wine-made user ' + 'needed). Overrides --parent.') + ap.add_argument('--list-users', action='store_true', + help='dump the chip DB tree (db.dump_raw) and exit; ' + 'use to find a real parent dbid to pass via --parent') + args = ap.parse_args() + + logging.basicConfig(level=logging.INFO, + format='%(asctime)s %(levelname)s %(message)s') + log = logging.getLogger('enroll_moh_chip') + + # Imports that need the venv + libusb actually wired + from validitysensor.init import open as open_device + from validitysensor.sensor import sensor as Sensor, RebootException + from validitysensor.db import db + + subtype = int(args.subtype, 0) + parent = args.parent + + log.info('reference-free: using baked-in WS-body framing scaffold') + + try: + open_device() + except RebootException: + log.info('sensor rebooted — re-opening') + open_device() + + if args.delete_dbid is not None: + try: + rec = db.get_record_value(args.delete_dbid) + rtype = rec.type + except Exception: + rtype = None + if rtype is not None and rtype != 6 and not args.force: + log.error(f'dbid={args.delete_dbid} is type {rtype} ' + f'({"USER" if rtype == 5 else "non-finger"}), not a FINGER ' + f'(type 6). Deleting it would orphan its children. Pick a ' + f'FINGER dbid from --list-users, or pass --force.') + return 2 + log.info(f'deleting record dbid={args.delete_dbid} (type {rtype}) ...') + try: + db.del_record(args.delete_dbid) + log.info(f'✓ deleted dbid={args.delete_dbid}') + except Exception as e: + log.error(f'delete failed: {e}') + return 2 + return 0 + + if args.match_only: + ok = _try_match(log) + log.info('MATCH-ONLY (chip 0x5e identify against stored fingers): ' + + ('MATCHED → 0x5e works on this chip.' + if ok else + 'NO MATCH. If a known-good Wine finger is enrolled and this ' + 'still fails, 0x5e (match-on-chip) is dead here → matching ' + 'must be done host-side (port sub_18000c6a0).')) + return 0 if ok else 3 + + if args.list_users: + log.info('chip user storage + enrolled users (find parent dbid here):') + try: + stg = db.get_user_storage(name='StgWindsor') + log.info(f' StgWindsor: dbid={stg.dbid}, ' + f'{len(stg.users)} user(s)') + for u_meta in stg.users: + udbid = u_meta['dbid'] + try: + u = db.get_user(udbid) + log.info(f' user dbid={udbid} ' + f'identity={u.identity!r} ' + f'fingers={len(u.fingers)}') + for f in u.fingers: + log.info(f' finger dbid={f["dbid"]} ' + f'subtype=0x{f["subtype"]:02x}') + except Exception as e: + log.info(f' user dbid={udbid} (could not parse: {e})') + except Exception as e: + log.error(f'get_user_storage failed: {e}') + log.info('try dumping all roots 1..16:') + for r in range(1, 17): + try: + rec = db.get_record_value(r) + val = bytes(rec.value) + log.info(f' root {r}: type={rec.type} ' + f'val[:32]={val[:32].hex()}') + except Exception as ex: + pass + return 0 + + if args.dry_run: + # Capture + build envelope but don't talk to the chip. + from validitysensor.sensor import CaptureMode, glow_start_scan, glow_end_scan + from validitysensor.moh_native import native_template + import numpy as np + glow_start_scan() + log.info('place finger now (dry-run, will not store)') + x, y, w1, w2, img_data = Sensor.capture(CaptureMode.ENROLL) + glow_end_scan() + img = np.frombuffer(img_data, dtype=np.uint8).reshape(x, y) # NO transpose (feature frame) + if img.shape != (112, 112): + try: + import cv2 + img112 = cv2.resize(img, (112, 112), interpolation=cv2.INTER_LINEAR) + except ImportError: + ys = (np.arange(112) * img.shape[0] // 112) + xs = (np.arange(112) * img.shape[1] // 112) + img112 = img[ys[:, None], xs[None, :]] + else: + img112 = img + img_q16 = img112.astype(np.int32) << 16 + # DIAGNOSTIC (live-enroll no-match debug): log stats + save the frame. + # Reference DLL working image: 112x112, min=0 max=255 mean~135 std~75. + log.info(f' CAPTURE: raw dims {x}x{y} ({len(img_data)}B); 112x112 ' + f'min={int(img112.min())} max={int(img112.max())} ' + f'mean={float(img112.mean()):.1f} std={float(img112.std()):.1f}') + for _p in (f'/tmp/native_capture_{x}x{y}.bin', + f'/media/sf_vbox-rw/finger/native_capture_{x}x{y}.bin'): + try: + open(_p, 'wb').write(img112.astype(np.uint8).tobytes()) + log.info(f' saved capture -> {_p}') + except Exception: + pass + from validitysensor.moh_native import extract_frame_native as _ext + log.info(f' pipeline on live frame: {len(_ext(img_q16))} keypoints') + envelope = native_template(img_q16, subtype=subtype) + with open('/tmp/native_envelope.bin', 'wb') as f: + f.write(envelope) + log.info(f'✓ wrote /tmp/native_envelope.bin ({len(envelope)} bytes)') + return 0 + + if args.user_sid: + usr = db.lookup_user(args.user_sid) + if usr is None: + parent = db.new_user(args.user_sid) + log.info(f'created user {args.user_sid!r} → dbid {parent}') + else: + parent = usr.dbid + log.info(f'using existing user {args.user_sid!r} → dbid {parent}') + + log.info(f'enrolling subtype 0x{subtype:x} under parent dbid {parent} ' + f'with {args.frames} frame(s)...') + recid = Sensor.enroll_moh(parent, subtype, + num_frames=args.frames) + log.info(f'✓ native enrollment stored, recid={recid}') + + if args.match: + # identify() = capture(IDENTIFY) (waits for finger-present) + match. + if _try_match(log): + log.info(' → native pipeline produces chip-acceptable templates!') + return 0 + return 3 + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/setup.py b/setup.py index 905bd8a..ac6d50b 100755 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import setup setup(name='python-validity', - version='0.12', + version='0.15', py_modules=[], packages=['validitysensor'], scripts=[ diff --git a/validitysensor/blobs.py b/validitysensor/blobs.py index 5f6f4e1..e6cacc5 100644 --- a/validitysensor/blobs.py +++ b/validitysensor/blobs.py @@ -1,4 +1,4 @@ -def __load_blob(blob: str) -> bytes: +def __device_blobs(): from .usb import usb if usb.usb_dev().idVendor == 0x138a: @@ -14,7 +14,11 @@ def __load_blob(blob: str) -> bytes: elif usb.usb_dev().idProduct == 0x00a2: from . import blobs_a2 as blobs - globals()[blob] = getattr(blobs, blob) + return blobs + + +def __load_blob(blob: str) -> bytes: + globals()[blob] = getattr(__device_blobs(), blob) return globals()[blob] @@ -22,3 +26,9 @@ def __load_blob(blob: str) -> bytes: init_hardcoded_clean_slate = lambda: __load_blob('init_hardcoded_clean_slate') reset_blob = lambda: __load_blob('reset_blob') db_write_enable = lambda: __load_blob('db_write_enable') + +# Whether this device should enroll via the byte-exact native pipeline +# (Sensor.enroll_moh) instead of the DLL-style 0x68/0x6b enrollment +# session. Per-device blob modules opt in by defining `moh_enroll`; +# modules that don't define it default to False. +moh_enroll = lambda: bool(getattr(__device_blobs(), 'moh_enroll', False)) diff --git a/validitysensor/blobs_a2.py b/validitysensor/blobs_a2.py index ee51721..f8a3495 100644 --- a/validitysensor/blobs_a2.py +++ b/validitysensor/blobs_a2.py @@ -1,40 +1,423 @@ from .util import unhex -#ok init_hardcoded = unhex(''' -06020000017f157bcf4f0360ff4dae96e4721ee8834bf6ab6f2828da9ad2b3ff40ab7e5176c478dd2459747722fc914b8b98b22d5a9574f52c0e8b257b952e7ce12ed46ef77801481d698006fd09e5a9a8d7e9ca705a79549b984504778513b72668f9a5b7f41dd5537460ced2c46ac9a70e345638ba7cb04f3829105f64bd2ddf5a0cb418ad350b022d2eace9fe21042e402bc9524bd87208271849c199280ffed5a881a547074fcaf33a22aec694f028b9cf56818d9792066e3bbfc72d6930050d45c9a2edeba1abd07f3b40e3830ff31aa9dab715f59bb150686d5648808a9ff9e775f58f6c6e3910eb1a579bb206178545d54c5dd32a0d3e247e64d73afe84d701b42a8920ea02768734221260d083bebb39c176d129c01d1a0f1388497171402ba041afd925d71e76ce4905e44fa5fd528759a2c9f12895864b5aa394dc71a4a17161dd82197a10742fa5f3135c5e78820e36653fa3db535f57c71897242939d7da50f81070ce9ab81c61af6ac29a6c6c4a5df73ffd08542fb540e417939ed117298051d27736c2faf1c5577a2133b6f60ea7484c692faae2a49c51c8e6f69af77774bad51a9adeea3109d3611d6a437cdc0c356e46a8f9a6d3054c5519c17c986e54f61f8329006ce184c275984799dbdf5512188fa8ff10aa2ddc25eb697ebdcb156509309ade5d7909a734bf35ec69e062cb941c2ea4af0958110da93bd2b5f17fc9b1ebdbd8020a3c36f22a68f707226cec716126d6a830219521669bd59fa0e4bd35db6ef0aa2999d1c0e7acf67e598696cd58cc4bdb1b7c037ee9a085f784c4 +06020000017f157bcf4f0360ff4dae96e4721ee8834bf6ab6f2828da9ad2b3ff40ab7e5176c478dd2459747722fc914b8b98b22d5a9574f +52c0e8b257b952e7ce12ed46ef77801481d698006fd09e5a9a8d7e9ca705a79549b984504778513b72668f9a5b7f41dd5537460ced2c46a +c9a70e345638ba7cb04f3829105f64bd2ddf5a0cb418ad350b022d2eace9fe21042e402bc9524bd87208271849c199280ffed5a881a5470 +74fcaf33a22aec694f028b9cf56818d9792066e3bbfc72d6930050d45c9a2edeba1abd07f3b40e3830ff31aa9dab715f59bb150686d5648 +808a9ff9e775f58f6c6e3910eb1a579bb206178545d54c5dd32a0d3e247e64d73afe84d701b42a8920ea02768734221260d083bebb39c17 +6d129c01d1a0f1388497171402ba041afd925d71e76ce4905e44fa5fd528759a2c9f12895864b5aa394dc71a4a17161dd82197a10742fa5 +f3135c5e78820e36653fa3db535f57c71897242939d7da50f81070ce9ab81c61af6ac29a6c6c4a5df73ffd08542fb540e417939ed117298 +051d27736c2faf1c5577a2133b6f60ea7484c692faae2a49c51c8e6f69af77774bad51a9adeea3109d3611d6a437cdc0c356e46a8f9a6d3 +054c5519c17c986e54f61f8329006ce184c275984799dbdf5512188fa8ff10aa2ddc25eb697ebdcb156509309ade5d7909a734bf35ec69e +062cb941c2ea4af0958110da93bd2b5f17fc9b1ebdbd8020a3c36f22a68f707226cec716126d6a830219521669bd59fa0e4bd35db6ef0aa +2999d1c0e7acf67e598696cd58cc4bdb1b7c037ee9a085f784c4 ''') -## xz init_hardcoded_clean_slate = unhex(''' -06020000016c5d73d6328c96d856b0d1ec9856580b787d5e56963cc0a798b8be5331b183a894f23e2ce1ba672a20d887f6cc884c3dd027441377e68fb1d2531329dc8fc35716132db947295abeff7d8644417bbb5ddf2319b4fbde57c50759adaee10ee8e60400a2d7f09a7c0d9934f7575a00c44a41692015de80959d9f0b0edee6e70037423b98733b5e1d2f892e0ff5f2e0bb364d79b6273856553e4df0e49e19883058f16fdf698345da73ee561ac8dfc2a38c3c594c5972edebdd056923be21749da4b089c8148f73ac9917bdcd6976ce17d77b919dc56daf21bc544aa4b3c6113037adf103e9b74a440b720104860a420bb211612d62da3a1327d2c4a6cf04bfb54d47218be9525c428078efa9241e6a42667e9079c91251026c6862575af5c40d7515615804816dda6c5495e6f9abd5c77e638925b28bbb0403d4f1aebf7d86bd5bc73a22a41fe2c7e19db79c404fd3db43c8d87fecaf6ecf163f74659768a4c6cbe562d8d14b8ce4710b9164a3bed100918d9872aaa9bc9a448b117823f1e86fe9ba9ac27649737ea497c83efff69022e6db7fe707947075d17c1de4bb1a712e4fddde31524379dea95497b675e4e70f4e88c8346807fff1083bee329d4cacc54d8576886272f1fb35c692b8c9156041a78aa4f16ce24cc3c4d0e2ca7eb957360a5d1a14da3046b73f996381b3f67b136d46fe7386eb0b0e2468be7771a153567ecd9192628f71a9382a6f555cb77b72c959d79c8111133777056cc73f40e5f448a064fbfabc5ab6c30d9f67cc87e641336ef37ff498b9af98212dbf3775b8a67b7ad933b5603dcbd3d7f9f2d90cefb292835d4671e07ba8573f4b8b62c537178939a41c1658f09574de0037ddb4e24f179db751e477527382b627f6990900eaeafc6a5f1e58966d96c9bfbac6e931a514c235e829c5868c065fd430df2d27ea21be94525ea5ccd22688d56780a4a9c7e2072b1084aabdc7d5c5ed243d903f5c9bf336b033d9508a0e6dc3b06757d0b9dfa26e85305ce953562dd43fc12fa2e81dff81ac0a1ca5ea0d3ebc209ea11046349c092c16c7680eb9d67c6bd1d57ab10fd7c07d93e00fb0370c89002051e1774b1555ce048eebc15ed4af35f9a4e2cf4b77bc5b0f58a7ab7a12aebf3c3a935c2bb278d446437b91c8cb7ce9e9c49b243a18c45472ce601ee001bc9371fef9c03fb3cbb57b119f62f81723148402275b46f917aab7ab4e54e0846eb40c9fa0ecb8f0d3d66fcfd9b6b5198c2e6451c29458726322c02401fe154cd24b21ddc6a3c4a69b95e2cc0c971cc2e78403bdeb70ffa5a343b7075609dc38cf5a0792ef55aa48991ca9237e911c66ec493dd1de176b2d7496100c803b58e071ea1ca3bb5c0fbdafc7a0ae50a56dab64002232d4c464bb78fd809a3ab74271fba1287fc1b6ad5e5c973b06a6984d95f0dc8909e6695aa6fe6cf5c263f657e068bde23045cab2264066d5e63d9db8374681a46a5c2d3127b34d848e31575d493ad64f263429c57808c36028f27a7070bf519c32b99f85fdedcfebc8033d519663f03bf082654bb7a774bccc701da2d743229cde9ff252472b3a53430db42a4c6dc49c8fecdf1c70c872a22dfb29c273b131f4e9d2bd29f46c7c6f23c0bc05579ed6e22015b9cf49f6705b093d85b5751d7a62921e5c51ed9c6bd0d4c44c3545e308712d5a0aea11399835e093bb606288b360e8d2634ade12876d5ed9cd264f5f1421f4814299b734c9c914d5157afc2bd558c304e6ac81a320098acbbfaaa689cf0ecca3c82792961af28bcae17f1031e819944dc4421cea037d2864db5878d9c0062345675a61f704e540182308629d07466d4d38c41f0a7271bf21480edf8bab7a9545a0074018764e81cddcb91c86722fdb9e01c9f7398c91df65c3c78e0b594a9a04f02b5b0a52f0218e68b1f0dd0a268c6f380b8c01ef821617f45fa651946027e35e8d9e05f68db61ffaeabdb693235d00b0bb4e964a6185a853e7457e57fabb2f25ab3cef8ccc20bc1132590debc6dbb9f084ac49f5e5d5228de6d216a2a5b6ed5e890c2ca726397838d715328a9d18d0df04de167613856bf09c898ef01855fccfcea1b3b0dd7e7b0fb209c324d1e4c9d6ee346eef81ded4325c7b0e6521857e37cd5ee076fdd8f778579f9510bae6d7501de64606d1c1f6f6bc0f5a64c1b9431b5ef24214e4893b9f7b4e182b81cbb3660974397716bb9bdfa90103c192758a03e25e23c35ce10afc0fb631be50eda1790310e9cddaea5e4426036aad63df8265c73b5571dd8379cab5ef759de28b5de50ed03f04135949f2aa6d1084ca796034ea187246ea8c8cbcb5c25974c1408d889d35bd7f90e8eebc6ea8c08fe26fee6bf5d4044472455ced150c74c3ce56657f1284c4478bfd5d2bacae3587c3055055813caed2e8ccba2739c0786754a7737a7ae610d1f4801e8d1f3ecc682f4303dbf0bf6f7510476269f1b64b15f2fc49f3f7e7bf5e22c2ce2b818c2020c19fcad9f2346aa15e7ce970985ca396ed11302bae047101a6bca2d2b7dd22cbc6cc6bd656fd56fa43f6da509d781277724f70b9751abf5f7f2a7fd2be6d0aef4b4b12fda505852f2da92240b2f9c588d711f48e6d6030b28122aab5d58e362ee06360ade5a25086666ca6734cb1c1f260003a1cc7fd7acbd48aec5c9f37a3f1fc63702a4080e1d3b33573d6a545b94ea917cebb0735fa227268665cee87084ddc1eb6efb789c72eba6c46fcba0c469fe98e1694b1fc7bed5d0da758b822c70a32f50fe4e4109ee27cc1fb6f3c7f62b7631f2f8034bd413645c07cf80a4a5838d8e1dde89cdb5028bb2af3d2131cd750f25a2c6d6fc8bfd1eade07abebec83a66f1c30c3916776234b86eafa6502d55dd714bb482073ebf6ce89ab2b339926fef55fbb125522fb8ddd00ba9dafcfe0440d53796cc22df0d01ee14e1341dcd0c13957f5bc3f20c89990da0da30f67d3dc4a0fa259a084007c475e302d3c7c62a29174157d9c56c0b40d03d81c53ee6e5e1144ba16b51896837679ad73519ce977be5ed20cfa7c70481242f4d8c1b42f34f2b06554536c59e02743f9f20d9fe131a2b8982dca65216374f29a0a1e5478b009d51511448151fabd889f360711a98c28749368c3f1b59ebfa268e5a084286ef550f72ca5902c5436e4551209b724675d3dd97b610d8d60c57181a14466c595d6fdaf7cd5e8c6a9327e7b0ada21429d399957aacf62cbe75cd6b550b1eee8df3d3122574e7b61925a7c469b4c1105411ee2362a7bf912c833bb9fdc4ce479ac1f0941bf8935ada689d9b2cddd76490d35914981236eda3336c2127807eb652cdfa0f23ebfd5af57a9df6c6ac52ef2ec4423b61e4e66cc52908296dd71db43308acbd22441bcc1237b7d34f4afe1892df61dc63abb5a5c584fbaf696d93b734614c575a39be4eade166e483534b702b0c264207c7be8633f5386a60c033942b26d793c7e868205ed0c735aa7b36f8375978fb76629cb2a628e45f4fc81be3e4435b65f6844512a5f12a0e1882b4e8d9109b97a0993f2b48853a4772c7d03b83043c8912fc317bf712dc6ed61de8828ae250eeb78cccd21434015e0f638948030ef39eebffb4df2ca0d26cb07e459940ee2f1eb64d4a840497ba0ea516c7d64d1d4ca74237171bd47caaf03c46a6d7e26767194df3bee3cb49c038f7a6f170d45434591022756a39b78c3905f44ffba8bf3b0a3ad157c10f2fc4e7869526cd824ac17a72a02c2aac119c69beff3890c6090a993849762799b929529137c234baefc0c691848 +06020000016c5d73d6328c96d856b0d1ec9856580b787d5e56963cc0a798b8be5331b183a894f23e2ce1ba672a20d887f6cc884c3dd0274 +41377e68fb1d2531329dc8fc35716132db947295abeff7d8644417bbb5ddf2319b4fbde57c50759adaee10ee8e60400a2d7f09a7c0d9934 +f7575a00c44a41692015de80959d9f0b0edee6e70037423b98733b5e1d2f892e0ff5f2e0bb364d79b6273856553e4df0e49e19883058f16 +fdf698345da73ee561ac8dfc2a38c3c594c5972edebdd056923be21749da4b089c8148f73ac9917bdcd6976ce17d77b919dc56daf21bc54 +4aa4b3c6113037adf103e9b74a440b720104860a420bb211612d62da3a1327d2c4a6cf04bfb54d47218be9525c428078efa9241e6a42667 +e9079c91251026c6862575af5c40d7515615804816dda6c5495e6f9abd5c77e638925b28bbb0403d4f1aebf7d86bd5bc73a22a41fe2c7e1 +9db79c404fd3db43c8d87fecaf6ecf163f74659768a4c6cbe562d8d14b8ce4710b9164a3bed100918d9872aaa9bc9a448b117823f1e86fe +9ba9ac27649737ea497c83efff69022e6db7fe707947075d17c1de4bb1a712e4fddde31524379dea95497b675e4e70f4e88c8346807fff1 +083bee329d4cacc54d8576886272f1fb35c692b8c9156041a78aa4f16ce24cc3c4d0e2ca7eb957360a5d1a14da3046b73f996381b3f67b1 +36d46fe7386eb0b0e2468be7771a153567ecd9192628f71a9382a6f555cb77b72c959d79c8111133777056cc73f40e5f448a064fbfabc5a +b6c30d9f67cc87e641336ef37ff498b9af98212dbf3775b8a67b7ad933b5603dcbd3d7f9f2d90cefb292835d4671e07ba8573f4b8b62c53 +7178939a41c1658f09574de0037ddb4e24f179db751e477527382b627f6990900eaeafc6a5f1e58966d96c9bfbac6e931a514c235e829c5 +868c065fd430df2d27ea21be94525ea5ccd22688d56780a4a9c7e2072b1084aabdc7d5c5ed243d903f5c9bf336b033d9508a0e6dc3b0675 +7d0b9dfa26e85305ce953562dd43fc12fa2e81dff81ac0a1ca5ea0d3ebc209ea11046349c092c16c7680eb9d67c6bd1d57ab10fd7c07d93 +e00fb0370c89002051e1774b1555ce048eebc15ed4af35f9a4e2cf4b77bc5b0f58a7ab7a12aebf3c3a935c2bb278d446437b91c8cb7ce9e +9c49b243a18c45472ce601ee001bc9371fef9c03fb3cbb57b119f62f81723148402275b46f917aab7ab4e54e0846eb40c9fa0ecb8f0d3d6 +6fcfd9b6b5198c2e6451c29458726322c02401fe154cd24b21ddc6a3c4a69b95e2cc0c971cc2e78403bdeb70ffa5a343b7075609dc38cf5 +a0792ef55aa48991ca9237e911c66ec493dd1de176b2d7496100c803b58e071ea1ca3bb5c0fbdafc7a0ae50a56dab64002232d4c464bb78 +fd809a3ab74271fba1287fc1b6ad5e5c973b06a6984d95f0dc8909e6695aa6fe6cf5c263f657e068bde23045cab2264066d5e63d9db8374 +681a46a5c2d3127b34d848e31575d493ad64f263429c57808c36028f27a7070bf519c32b99f85fdedcfebc8033d519663f03bf082654bb7 +a774bccc701da2d743229cde9ff252472b3a53430db42a4c6dc49c8fecdf1c70c872a22dfb29c273b131f4e9d2bd29f46c7c6f23c0bc055 +79ed6e22015b9cf49f6705b093d85b5751d7a62921e5c51ed9c6bd0d4c44c3545e308712d5a0aea11399835e093bb606288b360e8d2634a +de12876d5ed9cd264f5f1421f4814299b734c9c914d5157afc2bd558c304e6ac81a320098acbbfaaa689cf0ecca3c82792961af28bcae17 +f1031e819944dc4421cea037d2864db5878d9c0062345675a61f704e540182308629d07466d4d38c41f0a7271bf21480edf8bab7a9545a0 +074018764e81cddcb91c86722fdb9e01c9f7398c91df65c3c78e0b594a9a04f02b5b0a52f0218e68b1f0dd0a268c6f380b8c01ef821617f +45fa651946027e35e8d9e05f68db61ffaeabdb693235d00b0bb4e964a6185a853e7457e57fabb2f25ab3cef8ccc20bc1132590debc6dbb9 +f084ac49f5e5d5228de6d216a2a5b6ed5e890c2ca726397838d715328a9d18d0df04de167613856bf09c898ef01855fccfcea1b3b0dd7e7 +b0fb209c324d1e4c9d6ee346eef81ded4325c7b0e6521857e37cd5ee076fdd8f778579f9510bae6d7501de64606d1c1f6f6bc0f5a64c1b9 +431b5ef24214e4893b9f7b4e182b81cbb3660974397716bb9bdfa90103c192758a03e25e23c35ce10afc0fb631be50eda1790310e9cddae +a5e4426036aad63df8265c73b5571dd8379cab5ef759de28b5de50ed03f04135949f2aa6d1084ca796034ea187246ea8c8cbcb5c25974c1 +408d889d35bd7f90e8eebc6ea8c08fe26fee6bf5d4044472455ced150c74c3ce56657f1284c4478bfd5d2bacae3587c3055055813caed2e +8ccba2739c0786754a7737a7ae610d1f4801e8d1f3ecc682f4303dbf0bf6f7510476269f1b64b15f2fc49f3f7e7bf5e22c2ce2b818c2020 +c19fcad9f2346aa15e7ce970985ca396ed11302bae047101a6bca2d2b7dd22cbc6cc6bd656fd56fa43f6da509d781277724f70b9751abf5 +f7f2a7fd2be6d0aef4b4b12fda505852f2da92240b2f9c588d711f48e6d6030b28122aab5d58e362ee06360ade5a25086666ca6734cb1c1 +f260003a1cc7fd7acbd48aec5c9f37a3f1fc63702a4080e1d3b33573d6a545b94ea917cebb0735fa227268665cee87084ddc1eb6efb789c +72eba6c46fcba0c469fe98e1694b1fc7bed5d0da758b822c70a32f50fe4e4109ee27cc1fb6f3c7f62b7631f2f8034bd413645c07cf80a4a +5838d8e1dde89cdb5028bb2af3d2131cd750f25a2c6d6fc8bfd1eade07abebec83a66f1c30c3916776234b86eafa6502d55dd714bb48207 +3ebf6ce89ab2b339926fef55fbb125522fb8ddd00ba9dafcfe0440d53796cc22df0d01ee14e1341dcd0c13957f5bc3f20c89990da0da30f +67d3dc4a0fa259a084007c475e302d3c7c62a29174157d9c56c0b40d03d81c53ee6e5e1144ba16b51896837679ad73519ce977be5ed20cf +a7c70481242f4d8c1b42f34f2b06554536c59e02743f9f20d9fe131a2b8982dca65216374f29a0a1e5478b009d51511448151fabd889f36 +0711a98c28749368c3f1b59ebfa268e5a084286ef550f72ca5902c5436e4551209b724675d3dd97b610d8d60c57181a14466c595d6fdaf7 +cd5e8c6a9327e7b0ada21429d399957aacf62cbe75cd6b550b1eee8df3d3122574e7b61925a7c469b4c1105411ee2362a7bf912c833bb9f +dc4ce479ac1f0941bf8935ada689d9b2cddd76490d35914981236eda3336c2127807eb652cdfa0f23ebfd5af57a9df6c6ac52ef2ec4423b +61e4e66cc52908296dd71db43308acbd22441bcc1237b7d34f4afe1892df61dc63abb5a5c584fbaf696d93b734614c575a39be4eade166e +483534b702b0c264207c7be8633f5386a60c033942b26d793c7e868205ed0c735aa7b36f8375978fb76629cb2a628e45f4fc81be3e4435b +65f6844512a5f12a0e1882b4e8d9109b97a0993f2b48853a4772c7d03b83043c8912fc317bf712dc6ed61de8828ae250eeb78cccd214340 +15e0f638948030ef39eebffb4df2ca0d26cb07e459940ee2f1eb64d4a840497ba0ea516c7d64d1d4ca74237171bd47caaf03c46a6d7e267 +67194df3bee3cb49c038f7a6f170d45434591022756a39b78c3905f44ffba8bf3b0a3ad157c10f2fc4e7869526cd824ac17a72a02c2aac1 +19c69beff3890c6090a993849762799b929529137c234baefc0c691848 ''') -#xz reset_blob = unhex(''' -060200000194ae50290095416eaf60993da4ef11bd6030d70ac18c4c0870986ad44419e1da1edd2dc3d1e4cf37b810b15ad23f2700019604bc5717e74a990d2c627ce86b3df1dc3d46e206853a378e8e1be1e485f258149c4340bdda556d4d40fa1765c514be8d598f270f13cab9776bb93d998b556f6c8237fdda3f12444fd55692431d5c9bfab9539886b29e8cf6d42ac870575e11431c675b1293b190945700d02696d11215d0ee171861abe26629ed8dac09072141d2808871c1bf02ff04f2c44554c129ecec8a33ad1bea2378161cc7d2b2a7564d8e80cd93422f983de8c239f47092d9af84a208f30cb9c7697f51a3eb3fef0ed88e9476f437bbb5e3fbbdba30b857e86b2cc47aadb1c3a5673ab48ecb98c6cd78fc2e1788178bef05a943c44b716f1c86615f57c86602f3194456cddc9fc9861ada5050bdd46561fe19cff1cda09f4a0a672374462029ed80b0dfd924698129ba54525b0e8c0302e1330205dfda9f312aa48f86ab14831e838372 -9962ba36ea2c856f54a0f4a08551cac4b1f3b06a65a640f4d842207739e804e3fdc479e553614de45874bb0de22cce180f70216f59847b59dd73b33ece0e1504b5b87c0ab2d271ec852e67be7fcd4093b4ba59f5ca2887f773457b481da04c0df080a2c1d332baf8e2255d1a9dd831d334dc279cc32e69b695e10fb2673a5057bc2aa4ada8230f259c8f7cf15cf364a4e93481aa0663f877375c3d17aff4ffc333bb741d58bc7bba30ea4f02318809d836be4a583f2aa72182ccc1f7eab12ccaabda2911a4c9a0d714436cc44ef0e4a784ee6bd107d41db2eee4ec82afd5b7cf38752af2033bf8494fd4454278360c606fe3911a3602a82e51d080f37ccea86f32983918fa37567ebba63d442b26d714dde23f7950b8d3efc082b929c914d9f346f604f75ce6adc510565b998ed93dd723e767f91553ebdaf009a8484bd5f2eb7634955418d719ed13778067981665d0944515c7ec5c330ce6bc7da13c905739905358e115831278949fac315f66619f36509314b6354fecee1f2e2b67a774d3c36f59a20389b4269de15c27498df9a957292691640e84cb753438725ef385c9a2fac86391779e2137a1b32229e1db77bb961a0733498ab474a81e3425972dcd91c9abc1aa043d008d7eea32fd14c554f49332bcc0a1afa66953468bde12d5cdf58c18c23e007ca0324ad9227d173a6a0932282f12e2cdf4644c4810b9522f897b982bcfdfa0adff24c9a319e3eaa45b80d7f05918e23a3bfc037b8d2590821feb96b102e88df26ec59a7adb874f9d3b441d27496e5d829ad46282301c023496181d44ca8602a4898f9a44b3ae1feaa9b0f03bb5a16d1b1f4bee84eb94f871131f363012833796cc7b70035ea7953d2c34df48048c5a1ed1a089ee873a041c733f903a973560bfb0f21478e7abbe31e4e8be25e142b207a32f14c9ac7e87df2ecfa5f834bcc386f98eeebe2778639bc92597f3fe35c4890b40ea6a4e6464e51dd7ee0ae576f383f760e90bca1b3eb2dae76cc8203ca9821b8294961cf0f47a01ed6f4f2fd3670ed72dbc3cad3fc97d917f094510cb29b99c159bbe076aac2f6394b9dc23ee3efaf7fb26abc1f43cd5f4f05cc07ccda68cdf4b6cd9d24ecd6a71b0124ba4bd8a683f9ffab0974fddc6213e48d0bd122ce040c2237dc96923f45f2f178ebae421d38112e7bedf2ffc21ea75504fe6d3ad6cca7e3d55533274fcdd62f756d556412d73b7b3239092a012e4ca8a2d06a1b2f99316ba629a34e4c42a204aff8b0164f506a2fbafc82d275333ff7a08e114208848cd74ebea814c65677ee08c91253520ef0eb633bbc185f2782ed15f3dcc0050ca5d82171d274565f37f9a79b704cc5cc9271051994c3f833604e39da2af76d052d38731a966538de7e216ebd8dbe93e53ac166d12ec545e6c593c420dfb622b533b543e1d55f77596 -97fa72c43a74c58329147cf195af1dac890d5929981d76384574f97f2b70cdee809c8fa0835e5bb6302c3a918ac7f029f61032ac70fac8f4a01e6c71af33e277501795e7a65cd2673ea0e31d075304b980dcc0a8a759259f768471f901bf188cd721b6192c44bf0e0ed9a2aa4f82c25c6c91b87ad10525795a16bc9a0bf4db440c115d59ba041a972dbb76e59e81faaa8828be586406bc2a820b6070d02237b1d49c2c8ba77e6a16a811c1361bcea917c250d4237fa702871013e082197da0889bfda7ef0296435f78ae9857a65ef22efc82e29cc89a709c91ed19dc2dc762d8ff911718ba76d44412a64739701c13a66b39195ec29f3f1383d02167b68e166d0cfde5747e6ebfb0680e32922dcd1b39da794f9815c788156671699f8bd2fece8380da706a77e6f23aff40e97b5f4e5b004f9d82317ca0eb6776a0bbeec9e796bf73b9eb1b8a408998c73b1443f9d46d1552d8af3c3fa5e2f5724feb0d60571467d869e99e12edf6b00c868ad22068413f9b3b5d08b517311817f946460025626b59ee3c1692339563e044db3dbbe4eafd1c1872b7ec09c13c98e0cc83958d8b25cf1a00874e7445bb0b0db0a0dfb12c1bc1b5324f62fdb7ef7dcb7794d28afe29c10c3a301147a9215c1050d65a20c09f4f303832add42ac29fd18370ff0e4fd6540ce35594d2d1dd28c7480f0c1c7eb55ae8045add9c96c1346eda69f58c011127cfd617016d6feb6ea0d146c029c4a6065f2a25e54a5c8ffe672e5c54fd6e3eabe24b7fc15a900e3b1db23afa26631e3abf14500dc3f7323228265c781ae0863ab6cfc1204fff3c82a8fcbb6b6fb3e745a93f9ee385f26479335d63e17535161a61c88ae8088d6ffde8e91ea10118ac305f0f100f662784f288bde31ce32b31de2fdb268bcf8047099129f18c6b1f9726d27a070f514f7f52ccdbd71d64c809c5a3968b62a3d53eb011c3189836f1e2d71ba6ed57213daff9a6c1370206f60666ae21475a36952269dd600ccfbd923f767225a657ffb6281e32668eae499430cfb484e2c7ce1e3ddfef16a871b7d55819fe3bfb1897beef0f44973a1c07b823e7a006ce8588a8851b078f5c799ef9b419c5867f1557d1b067d449b11e3eb778f9b46e522bf3ca6565fed4c4540f623c0a18244bacaeb2b6480ca898db8da9a108f6722a54e457e88c480cf8a4f800b57d64f938dd9e30dab0d991c642455eeded98bc58b5bf437eff1de56b3639f014093b873a3f780dc17e7c4280790848fd5f491d6e2c48b5fc2325cd88952228c52432049e655802fe125a00cd949b9ed54470c71b092bfb282e0816107646814d0187b523c8700459ba2323ff2cd66f8ee4e954bb8f13eaa2bcc7a2ce812046df9087bbc7ba7cc110e6aac465daf2c63b55c0930f0d4ca083a20df924f3d395871c39f296b1be3bfaec1ae5aa3956dc -5c4b497695b97a304cc1acd2708867bed635fedbe5a0567881bf2a691ffa55e9a922630f8de180c00d8a8d5f045dfd9fb029cc8b53dfab9d5533d07ddd2893652004d9495efc2ccf502e4184228652723f956c80b62c566a89e81a862108b2c32b20ded004581dcea6e5d5de730fae2a4bf2563e411078e41099b7e07aba9ee907ca4821e9633d74cbf0a2731d884b73374358e6f48077fcb49e4c57271085ce413e1ea0264b2a5fd94be7e86937ec246384c5cda5eac6b78944399e17d859d84727037b7539358e7b936687726233c0dff89ed5c2efaba3d74d0924304d7090695ad38b6c1d5777782421802dab55db87a7589fe17240b9b04ef82f9f1d962422e5144e5199b8b056221a401513d05e119022741711c0ebb4cd500d94bb462bca839e35cef0f2efb54a81f218f98a181fb6911cf51e12a7467c6ce95c4cadb90b51af7933eb66ca107eeced4fe9af1319416d02445811b78d0bed82b5c11e23699077c169f7e1475e27b8ce5e01d335b09878ca43ba06715181d303b117bacfe91948bc9a4ec19d1a2cf1fe86b1d4e7bdd72ae1cce0b3ff16d2eb707a758c96e18ae589cee08a1d6a2edc0197a698bfd027fed54c05beb0263d22637761bc5c473d358f547939170b6d9a922e143ed46f13207cb9219f2bbc5b328a7c6be7bc6274b3189ec4d629570eaf3b53d3acf67bebc3858b68a2366ede7ad5fe823966f157431c8af929963cfc71a35910f36f2a54238f02d80d9b87d1de6c55cfde41135bdc1d3d812282a219261ea1b89a37281840894d3a3aba6c054cd8c6b414fb151bd7a886fb05ad9ae52598da66821062bb9225f61709b858aa4d04507caac685ed1fba138b37fbc21c4b60168de2673e5a2fe97a42a193db17b85a9bf56b6b76216e252a2b7b546a2553f59b682f2fe8ffe5880fb6d4c71f167c9fd2dacefa86c815ff907533831c74ccf72926f8260166b0fcbc661a37533c2770a7c46da1e94c34f808eea1cf6fb25bcfd1882018d263f0d58733a1811788c7c02e43a80f5b2b51035ee366c9bde05cc8af9180ab677e5c6c430d42d73554e75c2095d555ba2df0686700b6a9a1d3b747178756b6c0a9dec0647eb65b48068e09d9b7ae494dfe78155a8d451ab58121f2e980c565d1414a7fa6d09ca4ee5cee33c4f3ce90d342c6f440f309c85388cb04e6c2fe6316313d285c0297091a2adcab6ca3cf5d816072f9080d00192b1e3e2a61376cb07e1903d5155fc67cab3e3872f2ea5c96dd0dd097c6e8be2da87285372581bdde00381bc3c467cbcc7bda35a474b45348d3a0f2a03c5578c83575822fd04c4931b01329fc5ae2c539665cece4434bbd3dabece80a849e2a8c9e00d9bd9a48557c6f15886d438670b5e4edc817303530ca046d1317c4913d34e3bfda7400cf027bad9020b9ba1b59d719e7a50ede002e12 -5d79b97c76ab35e3d0cd4ba0917749a22aee8ce3d1b7253f5f00eb06ec65aa435e5e337b7d63a6f4030636cf5523863efc9af50ece0982ceebf51817ba9f4b354e090741a032fe0467c5ee1c2920e19713a9498165f21475f8655644eb91f52faa64e11b2d66fe1e4a5d1c8522c7d87958942d8d685dc75b2a81b7534fecfe3c166435a8acbcfd396f080cf7a574dc992afa0862f2bda9a576e86fe63f6b86829728ad362b32be26bf8bdc58264500419887171ef3b0bfe846bd0d0fce41bfb1e7af16d49927ca5efffa72ef8176228dd995601e981d13495476504ce4d396060e967c75afd1dfbb32bfa6d17ea022d8dc232ba5ff569ffa99fc00cb017019246b1299164c0f73434f8144176f396ae002a103cb745494fac01e95de19d059e0e18a5ccd9d827a3211d50ea58476ea4d78f669f2ff079c582336be1d5f68d32a6c256f0b826ebc6cd829ebb81fbf2e2c592975686cb50ded7d825d218e4d3da3bf3e846ee33df0f0c644de6a88cdfb78181130cbd495b8b590ba426377319dcc6d63aa14a757c4aa787a3fb9f995868362c8734281b33e01e8f508d886a8442dc620381d33c3f1010db797520cd051f368b4da5ed48fad00089f1d5a92fcb516443c0f4c2fb1c7e65ab49324d19e52c3bd623351409cbb5b3c2250fcc1dc84c4ea666bfb29d1f834f76598bf59eb005517a8accffe01772f63a358959dc99bdf9b3857d4fd84202206fb43e6b85fa34761023459a2991b3b095b7c4ed13c7a30c7cc00b680af8810b15da9d86681c555504e526ae41e7067ce1338d171d7ccd8ebe3abc2bf2d88650ea20ee65b74f8bcb49b8fc0b821339d0a155f6c0b6a76d0106a74689b07f18b645a733009d55d2c041ffd0afaadc5a078246adc8c37e1c49d1bd3afe2d069762a66e9f542391f7cb4cd3b14e6b81b43db9ffe66b70252a0b08609951871d1f0d2fa0428b0c1ebb0511f9c5f1acef05e3cb0ba42e4bbb10e75b121c5976698616b76fca02a133af96b5e0ea65daa56a710bb901c9b7a21bce1597b8853588fd63a56f3602288841a7ed27c48672b794ba6b183579a70a8a6b57e543df8fabb57b3c8d36fae5398567db8ed2c1479b5e3df8a1aa7aca105310244c8aefd5df6ef5f0ac034ebd26962538073fb668d1759440683877d7c0974ef090a3fa2eb619027bf04fd4653b87e7774a7c314cd119bb29788f379289411eb8f5bd711c5d36b51b124bae704f17638665fdf3dc7f9d9424322f9ae69a52368cad46cbd83e4c06774a11106a0cdbc0ddcf7ce14d6d18b366ec22641e3a3ec85340324b6fc9b931fe5fae2ddc3f1197d33700195dea7bd9d68794ad681a69cb52ed4a7bf1394527f46dbe55f588972435927a2368944bad5e8cdcc5a328d8bb80f25a0cec36bb57c6ff23ecca2609e51492b65fe6de3edb2c5f7abfbbfa139 -7f7b8aa8dc1947e4da0575e6743c5137fbc856391e6d630d86a6e993ec0073099f16aaa51d28230fa262e94c9287660d106bc1ddc29f24adeb29dbc20918d02f90ae1406fa4584c4f7385b5526e1a03aecd30c112d657a8f076ffdc3bf6e4eaa6a5c3e2e74fe97a4bc7a81e70d89eea52c70219ceca20d4abd723e6bc4f8f038f251b3ed8e723713b4a6c20e5c124cacd1b089229776583a0a97f94cd259b3faeed3f8af7d4e587c32a70405b43cebaca583ec31b64065f9eaed500a9565a89da3bb4cb4b82daaf8b01ad29b24994d00fd0b1cdfe6d24d4b2c6dedb48fefcb5637696cde216d2e74dc27a01a40ce8fce23ac868f358f12f1ded5e7ff6a8c0bf625fcd53c64a424a54af8c9410309d69d4fa2e1dc1d55035b7577ae9b01bd891fd4da588569f385b927becdeb49a17595a9f7093818094087ed073e8d9631d9ecebbe15a5ae4698a8bdfe9a231a0aff79777af37be5a6b839a03fec653a550d783e45115d7e75b3451304404db5d4f02a332067a26b29520e5d9460d439d9451827f44fab7fe98f62c29baeaabad015747e542d283a5652998002cd7fb18a69685b1747761d470b186e69291644031bfc164bb631958fab0f0a42e2606ef0805b5d184a694f997eea597663a3fb75f125759e1990e99e927043c2a27a2492a0f1b501961c9d8b7589c5fb4ae0309f1d3a0300b1b749c8fb26357d6de1ac81444e35d378703ba570ad8e1b1c41d13e41268240f600a0c8f9131e9a4b8306f9835de89c8737cfe88bd4217e906f5504ce60b386e453a42753bcb8b4aa1bd0198dd04baabeadcc07d4eb2337b94794231ecf73c25b3c9152f8a332f43fa6301a64e68f09d8b34a5525b612920dbdf89885c70153f91bb48362528d15b1e4dfecaea7d3eed3b3cfd24c06c35cebe6695a2f0256d293a18ff1642d417ba66f817ec92ff4a4e091ae81e3ede0ddaf50ac781a0e1abe2d6b483029a7f488d22df9d411dec62c7b9e6f57d3507ba1cf46835b679a4b32e60ea4f48383494b04d9588b4a967bd056e0b855d2e318b20f0d995de3b1800ff197fe44c59f2f8dd98a46d90cc05b04e450617c0c70a3cbfb725c48bcf132b7ca45dad3085bbcdfe700aa24db8f0e444ada4b045699dcccef864585abdcfafb68bcd2e86d53c1388d8e3495e1474e6e82a9a851e3cec6449016fe6c6494cf577a0fe69439964d21a3f7fabed0343d31c69cf5bfecbb3d32229bff3ba0229eda25a49b9967bfb9ce578f7d619c85e6587e958815cf4c35dae946473a2a919fcafbf158e8bd463a8fc397e209a7cdd08f2061e5170b8eb14474d2ea38f7ce7194187cbfbb11c3f850476f53b5dc500df17cc6d85117fb35d4216a898ace5d703b066635ae942427385f0f08d78d556295a01a5be900c2473ca3b38d90b5d6f588f8faf4e5507f70838e84ad64926d -7e9a2683a40a174c89c73b4ae2812e6cae3c10d239f6ad9be00d471c8831db7977c3678ee5b4e15a1de9e79c057f8103ecf63ab879a183396d3540b57d205e779f65436dbea553af6659276afe1b65a96ad0b06ed413f599b81d94162b5967dbd2d03654b9dbb006ee8f2d41eb49e90fa524d2c38130670eb12317db5b9d3f32ee403d035a1a70093dad4ddd00d6bc97f94aa306fc7845b0f9cf2edbab2ba62f3f85985410dd91dd36fe79006d593e935fc09522aa0c55d6aa5d52c22ce7fb3a0b785b13a2f7ecab723ebf23ceba7a4f481a4b4275bb63c74d3e3510d3d237fed4785f4bd8c7b088c6bf7d4c4e013e3055fd1104fed8ca4d5ef292e288730bb50157cfe0f99619a78cd2bb92fd56db50325819bf623b1344418ad7783fe5fe0003322889f460337d40943acbcca9a5e50e8216f056547a62cc55f3598aab83bda8a27eb3bd1d35aa2e3d176f523ee2f13ccb0624aa9f2ada406876b39a3842e859b31760e376db61d7db22e45f1b6e35262bd25b521b232b48d208af37d7fcdcf878f6e4e27521530f5959eedfffc3ce1ba8f2eee866c56360bef91fbdddc4fc6ee8f505aae0a3831d77c474c29be76105ccab3418cd810f2b72d58a425729bd78cc9b16ce2a95b48502229bdb3cb975041490b7e070b29fc63d315ec2b2571dc33b3cee93e5fb7cd6b55599440c6b2f28c66335b42f26e9cc56e374532174ac667a1f824414ead4f1f9319dd15bedc0d3c1f88717575f484431a7e9685350af7412caad2f305a0698c3f197b12db099a54a4168f76c882e4fad7854db49c289f727b2d02a4ae642bae0cb7051a6cc8858fa8c4a8501c54cc7ef8141dbd8f966d89747f057b975db14794d81d566a7023e4b92733e9aec8d510fe5ccf63ecdaa4386e044a30da744bff020f7b539c45442a6af84d3c30b2e38a808d73cfdae496aa7c546bc080fbd83c539d3b4aa6164e4f50b2f33816e81e665116a31b0b57ee0b2371da70a8f2150b4bbedd3e8ba6027212a3f0cb8eb196d17a58f3965f89ba43ff82f2de139e9a6982590792681fbdb2b8d35342b3479b7a31321d547ca4c7fb055a452b49a900e7237e8c163e7b1fa4715d82654f736ea518b33223b10eb4a45c8a963a6c70b42a9affdd1497f41c01eb4f9c13e944743c0a79dc41e9fef5ede753f29b9bcdd98558da7d0823004921dae6fcfc98b6ab1e0a1a6ac7dde74a52afc390041dfa7ee8329a9aa2928377d41a8883ca4545b1195e7a562b5dcb52988b6d5b2febbd00daa845afadc1ed1226f2755909dbfd26dc79fb6f4a8e1ceef1540f8cf6be6fef8b7c00397b1bedc016e7bc9cdcc435784fb2c8e859cc9a5be6b4b24cee7e1d53f6d60f39c02f23a782e860cf0ab22999889b5a2b39b9ae20880b0339e56982e03368ee8d2e28f5fd8ca3968938feb0fefd3605e6d3669ef -7d3fbda289d91a74d99f5d9a45e5b77dfb58ebb15f62522de726d460188d761bb50d6780d27079a901a6ca3f04b339cf1b5dbb728a2f26d07cb8d7b3e63c78abec69192f0ef915e523a00a0722d69e79a22cf61e99b66807467d3cad2b0b48548acf2224215642a5ce817e39645d320abfc11dbd65186b5be3700c3eae579b295f5d6db25fa350afbddde6fe6cb1565c369ee2ed1662c8265976f7c549015ad70f79fadcceddac5d57c46860246f293bdcbb46f58c0e02144040562159289a58f1e396894acb680f7a2520f386545fe81b570b89cbb48318ecd8ba948ec648a9b12c9c97ab080814d3386114da29ea9019086ba02eefd1adc22dd1f55743c1e7f869958aed1a47cd8e4e479b000c88dde795209257f1d7d625df1ae27ab79b681a327f9db4a5a45d8e6c3870ac751225f4c98e41a44fe6dd571166fc7471a58ca7224d3e4259c4ed8603b41e914a649b49392e77e9073fbed54a494f61a7f166116fa47429837a169ba056e189e642719873845f89f7413a15e856ad369945d301cc91baf842ff355d71ca4f45641c73e3a7b1f6c6df530bcd3a43fe6cea949ad5920029a827237a8497e236fda61f1d86bbea9ee97089e46d7e52051f938b645ce43a97a18fae5832c2206874aa717e9d0a942be137094e5258c7bb1af7c5935eec00c08a8d2626bfe06cc1788de23d5d2513a919c56933a4312398da4d8644580ea552291c9fadb6a17823dfecf65d3c12abc65acafdefd96ffc57fb571c280286fc7030757c51e58178d4088419fad8c5331e73504f6c0083cdc8ed054ddfc8d99b0dddf3ec705da169debbc15911d424c07bc8753dc4ae4614cad71c1e3cb95f50ef03326986e8e3d4cd668c2da94da5fd1ee60279da0f1c1c4fd59d364b4d92e613fcfa3e8917e9b5ae3b350a96bf6cdbca1150a3f34662126565da147b677b43fa53e6ff06435b1b6260ec1ea3856d60e86587507915af88198f0a5e9dfe2a4ef24bd1e3ab68b6db80a5225b62f69cafd89db1e5218e5c56640aaecae36f42a4c0c162f9d354aa05655e0ed86616da01425a6bee99c7f0b0512fae2de7e8ce9c2ab594e4ac6005392b3348a4bb9e6f046e850eb85008bc019ce746a557a731a906ec8aeb7012570ff31a2b2f38f7f62d2303857e2e50770f85c56de514cf68268fac275e11c0236f1b395e40629e038db68757351587c8cf5071b80fca03437af67c915d70d3a12493a4c9a2bea32f62bd6e4806c3d65395f4ccb792d2fcb8d5592a06bcca0c40afb2fdec39664ad324ae73fda1107662e847e936f5599b0af587c71e8d216252992657a3563e04747c9a46c36f64b1471143ddf0b3184f1ae51f0c6d7af85940df008c2320f9c796c5a0115c4407911625e1debc953396a9ed5ac2b6f4b034c162092497f43a0de4ee8d5902a888e2f9b897ebcbf8cb -a366b1d0dbe94e1e3de656bc8b949fb575aa26a6f9e4cdbc4a27d312e5f8e5d8d9ff815177d52c1a0628dff52fdd6e4d90e26268e01c9efd4427fce66cbd06775288d3dec9dd91b2ddcb12d153a547a764797c452c5fc0da3312391391fc3a48ed511ba35ff7b89fff9538b103695c4b0471b41d9fd1165e2a7ff68465f43e26ed14aa4172e53dc1f5c18b8c54bf1306a631a3b162048f1ce36b4305bc7eb9821e9013a1266fdd4718458be9afdb23e7d4ee4ebb46c79f147f23f3ac3c54422878be372409af0c8f022636a914aa19b499b6033584c9687b7de23ae61966cf10c662bc7e17ad1e4cd2ea5620de1de4e20e6be52c465102a8a8a2f7fb0258861697db83f786ce5ecf8fe5f745b35f4f0a8e2db07b4821085fe959ed3c19f5847c677878552157a6ac8ca60f3425e5b1126ea57526285e3cee038f57ffdc3e95e4c938145f7f71a433e19cdf61dbf3a5318c225ca9c1b26245ba89579888157647f7405d2efc1b70cd9cc02ba5a5fca1a49ed89ee5e89dcdf37865f8241054dab3ff459cd515a14635ac1ecfa387ac1e83522e815b67d3668effd17793b879380e483d4368e6638365c68474ddd74be64cc46ab1435d4dda78171a56629b8f3a488c2705fd0d332c83a345ab337ae542eb803098eaacd0a74a96518d56a410d57321eaec18ea1eddae63ef7a3b8152f50cddebd82f47d1cb773058db6d3b38d3e81fa897b988f3d04b7bcf26ad14d7003c988ec7b25332746f9bc4005102704f05f5d5ee5341ef3b30d318fc3904da34eac2ff3a246b1a6ec60145cbc66b8e79006dc08514a2dcea9f5d89642edbc7b9f41d811ee1caf10343a286e1e4c5f17cdc640c8aa5e40dbc89de3a18975ba4558391e27650679a829ac644aecb00a43074a0aa358f5664876388ed742bf571d020b29059c8326f68411979d3967a314bff40126b5f656f1ca0ea4d7261b7a0149691ad08cd2b2b2f2d9075afaa4e4c849ac650925b2c2f489075fe539f8cd7b1fc7cf4781808fa29484c4f3b9da3cf38cb31c58cb3ded4daf88de27c8f447cf098b7ff69e7914cac7171b2290133fa1854b8a331e35d7f002ae75b1589758e6b2151072a43514973375185240b7e8caf231398fff3e6787a28ef17829f4b42501fe0af78943f1e0cc419647ce0c2e9d1ab50f19f515d4bc75edc6e2d31bd17ef69f0a63d4e385a8f5bb00908f83bb86bb20239b55d62730b43ddb783fa805e6ee4b4cf6eb66ac29bb5ce631e294b439517f5856ad1f2fcffae3936b40b14b2d6dd4e32c0a8091bcebf24c0c64f54094366fb61269962feedb4072daf5c5a40641dc827849682a21850dc1554d53574d9608c97e4abcf94f9c6a28cf5b484d67436d3ecadd6e13ce57c11434a57e87aac9562079e22c6d2ab310b9ab1271d95bc268ba98404612ba78c3f032fc593223348 -70feff72f2520acb71682220f727928391051a74e6928e74706a7a5a036ed8a0d4be09f9088df632adc8ff816d7d830c8c3ee0074432b9dbf1e9ba49b12796d1eef1bacfc32b941e52b0ebda65241e198935d0dc4a72f997c8b26283ea93261d483754693f9834451a696fe4bb1b6e39f1cfab45f3e792fefd5fd2b94a698caca4eb8b2be7e1eb6c9a06a56bf22745ee3af4126dce6e5a77fb348a0ecf2416c055d2e6d4019174ec7326f7239727f7eafcdbf3f8323f1cb0b1f63d75f4522c575f82ba0188b03734c3fb374b90d9773c75ea981a23510a7add04ed0b13f80372b63fcb1135e8eb3129bc970df3a6096b92d17d7f23a3256a7133b4c3c71cd581f68575751255c2517ff3c3e328fc8f50ec5b27d3868a99848103ff73b82478bb86e52e677923a905bfa608e80a571108e4e46cbbf4871b078457b8e06eff87a15ecbf3cfe59d597cb4cb2663de42d39bfd260442be708e8985e5552cbe191a42fbf9eacc4505d4c857a67eb0a43b27cae86b6141279ebd32a2dcb65a94f9fa8e7e17fb8447d435311740cbfe3ddb2a8f2826c69dec1750d4136406238b128bff704d9fed6444d14b7bebcb269fa4203a262d8bd0b33912199566b1896c03915d090d070488243bb1566b9d464571f1b5870d959b0d0fbac9e6f783dee5cea9019110d02e76a92e5e963644519456cfbaf6b598da56ddea81ac549b93db86bfe54930d67675775ba5525b7576c2df1965d7b317bdcec7150df1c15706f81d1c2ebea7269d9ed0ab10312c448f5907730d12447cbc9cd4d36e09a8979a1960db9ca7058fd30bda25bdca5fd48ab4db2be3afcabe691ea64effd6b724eba76e2637e519cee7ff9f711061245b174363f75c9da2fba536d2ab7b6fecd277129693d17f0bda731589df1905367bc80ef014b05e95dd520a9f08924c306ce25ce4fc343c66f9651e9eddb57ca60d3b030f6046693811a022461ad45bed406f14722740a6633edde992ab2e2ac5d94dd70399d7cee5eab350b2c10ad668e6d705eddc0b1d0e22292e34e416f0e9b361fd4ae2558a256175dae60caa8f00585f3401a209a25cc8e72632559e15e80600b4c741769c637d83da4768de13f619f8bfae3b7843b8ea46064b03dcb2bb2397cbf19a9ee773ac5a560f32fd7a3c95d7d344d924c715c43b4c0d644c0b1b28187c66ad6a56e3c105b2fe83b585b3ef1f11e3f77d21d58eee9b513d52dbd8630c8bf194c30a64dbb96e707adda4e5caac59990e1e54bb9ee4816c8e6822d69ac52f815e384cc8e8b3938f87d585681b38069426035d40abfa4ce7cab34f56a45e9ffdfedf53d81e0b08a5ac32441701c2cd1f268bb722116912f55260c128fe5c55a197f6440dce2df4bd426b3178761ec043cafa57e41c02db17d1a712919dd2186fc0de1b3f4bdbb129ae22f8965d4d84d8a687 -d7d6404f6760d2363a713ca19ff06d648d311c5871383594f2c34151b0b812c4ce12ea3e240754aeef9faf7a61f36f835de3090e1ea22767c69cf376312bd3207ea4f939668ed3e700c72d50d3516778d0006b0da8bca7b65c5fa8ede67cbf1b1e9e79d259a3922c2328e8385f544283fca61778af7771e25b4fa50e3a1d9b04a19bb74450d772374846314cd6e8fc3ed05c19d72d0bdea8c3acd4095d10834c1d12a97de5992bfd813c450c0704782c4806ef6e77f9c2b8184c26fc2bde78327923b81e48f186b972bb4d9267f9220e3c03e9f87048ad2da7ec8dab8118adf777c155bd46091bf990374e5cd8dad3624fef8c646eb0446d0c3e8b18c93546f9592a86accbf6ab7b947ee7d279220c0da062555199e5954639fdfebffa16078f49c7d6b1097c8ee10494143e743a7524a934746ff5e6e2b02c985d2780b4c667d43cd1c3ba6cdf4752b120c3b481df2117f31d0cd49744da9b5ddabe5e58c7a4625b88370b1fb87887012c5636d8cbbe258d01083c31c01e4b359e7d12f230a1ee229dde4427e201a9c682e4b59a1509bfa8b7ad42ab952300b0dd032c9200512683ef4178a526039284ec43d4c6e93e9d76f1c97746ebe0c23ad3c9736579f4192dde55c42723ee71a0c5cf16903b1f3ee9662cac9e357abc7b5cb1b3f322cf935db49ee440fa9626d98c035d5249949f9f5395c79eddffc354b3f6895daef19bab519e9c2cfaa663ef8d543846bd11c889dda634335b61dfbfcd1450c09f9391519ffd5c1fcc335f2d167fe5829250718ab75c40a976b77866930a737b07f4afd00e2f377108f132e7280523acdb8d3912a797b90d707417326b705233adfb5511859de8e10530b5e23f4c253d89ef06545b29c67d0b9c42f8ba6f611946d42f1403e52d8807996b4bfabed64f1a19d96819db892394bcea025a460b189c53b32b131c2b94c12eeedc1ffd610f2418b5d5dfa92537dc75812785f45de70ce54c4730025554d72d274201c0830ac46c1e857575879384a38ddab4a559bc528663428cc703c936a7aa0ce2f659502c044b96754710397b4a65a7d65caa3ea2125fd755a49aaa9592002b7df32f4783f4e80ddadc52916253d3a20d5b2d382691bb0a9baa670ba4b0e1c10e2ceae232fb908061e7723b04ef85905e2d53385a5e2f0974dcbe0922443757e4f15572601e0d467fe22885cf6e1f9eb23f7d1060b99111d6339d8b4c85c64824e7c4230383e06b76864de72407ac80fa0376771262792c4002659bfe37a10d11431acaf1fb60c51acc6316d4463dfbf7249bbf6608f84049a0f0ea25c73b27bef0a7faabf29f0d31a218b19f9cef07b2c829f774920442dbfeea24480cd2a19787d4ed06504804b3a2e67f2e2c29a28aba4f396c5dfe786b2f992023210adcfb65e4abc2329bfe4adfacad47b41f5ac8f16f23a7d0 -227469e144d30ff97006dea1fb2790e9dfa00e0ca63e0fd72ecb8157e3e5ba7c15f38294b3e5a35d6099c327dc59a6ec1fcee0bd12c59da5271d8ed7b7559a643ae89ad31c5ea3593ff4dedc1b6afec2617c985a19438bd89855ae153142f8e381f5c16d40239e6efb5bfe369a6c80444369f074924d1ee1e07a692db90e14a0e198ecb48367cfb0c457fb322ce82e9185d3db1f53632392a3b077ebb8c6bddba51ee4ed15a5255fecb40e6e059f6b9b5a8ad363926c31478eaf588c7e7a4ea191e65bb368c3391581a3e5cd8ecd3356cb898e1b55a3961e2a3d6d198165e1c0964f7a365ade2ca52f584bb1647be33dc8d9bec281621e5383fca73b3b27c5d379e64443f825deb31d9cd50ae1a704d45c76a19d799cf5aafd44cb2c31ff1f858b7b7ecc4e068cfad674d3118e2d8a63018858970caaa2d0798081d58edd7d6108f6c52bf0978e315be35f67a5fd4eb8321a025ebccfcd95170c9e1024f054ae056f1673aac664557f0f30989e9345d5243df96b47661aa1a564e3b7827bfd78688452646996a6bcecacb41d2f204cb5944f96171afce281b1667c8adb4c4c22545aa96e6c7639c12f3eca88850a1560917aaac0f18388e576d058673f1530a287b1c2ab5d376b4b7bbc7bd968372787ce27ac69b3a1af0424e0ee037137a294a9e2f2e36b5eebf62e2d8a5393876e692bf51c943b45446c2262436051879028170be8dc8296f973a577cc78c2b4719aaa7a603ddf720e11d8905df6c1417ddfeea4d321098d4df3f46d83fdb7450a2e647ca40932c4dce5f465104e890e28e6053c962a97ee7a530318dbc91bec1875fe613b60bdd4d14036d0112338f9fb3a897f987e93bf476ef00beb93dc7d95843c32b5b7da7b635179dceffa454962602896bca7768907c0e75998628e04123ccab906bb53043fffffbefdd31861eec8d0ee38a9a557e239104409debcef034d88b96de96020871c84827e997d23de92eb69743ed330080483963f518383d0ad2e6c9d287ee77c3b2ed9cb30414f3b9f47912419f4f508ee1fe290df38e3394fb91356681ab0c111a8662284c828aae2e238cda6d4fc6d4ca507e62fcbf3c05cf4088c8577b5c5512356b4da446b4c5b326b84494304151eccaf13744661ba33a0270dedc61304dc01f60a1f4c53cc3a90a61efcf39e6b2fa08b07bac9487e6a215578a3cc5c2a71afdff0bc3c4081d214ac165479b13be8327aae7e4b4057e045d5e79e94039d409dc601ea99107c1a2590c942c9114bf334105c661f753f57c9e2ef991f78eee52aceed99401584023d84c7862638636dae492fd52a52e4a10e500985e4e15fe423294cd115a0a8517dc5dfc0ce0e0c0b567b6913ef6b6d6c0fda87f80fcaf83274bdce67db1a6b885053f512c75bf8256d6cb621985878d576fa62766110c7fb0eb425ed1dc2477e -28d4def42c693a68b09df862e18dc1d1edf196fd2e5299f4da5f121b1fd09eb1312521e9133f8f1fa4ae72f71b4fb885ad3c90ff69fe767ff47df9f23efa0cddbd82b0ba0d253b3167aa1a132cbeb4f6734448abc3a71f4799864be9f933cb0bac62f14d71fea80857ad8a23edb93dc00ecaf880380b2d2156a5a1a00b8c2023c77c77cd7b170b3956d11e0c76493bd0064358c6e3649115f1163bbc1662f3951ba1271902e77a1d0c8742d619593fc7d92bc2a83937d07ae613d618226c1edf26536257bb580481be697516dad0235bda7a33001dfe0dd36fb3771603724e7f1cc36190e87de7f112695b2d98723fbaa64e2c0abfbacf47deaad5cc78e70c99a8e8c799e96ce4163612dbd32ad99b2ad3a9e59896b508eac80c8ec48b7fbbbdc60318e09b88bc61f1e0944edfc52c8c13a41ace60194fc6f76bed8b4438d787343adb3317a6601e991d5eabd53f0d1c08f6931e +06020000016355f8c7f5dab81cc6068083fa2c534c19a3d2b0bec2b5ba3da1bb2fe658722c342227e9fb17500e880350602ee2429271cd14 +4b488c2d4976f4cc100c929c99d98f49abc2a85287983d64ff23b9d81b4f7e1ef67b9b3bf60ef09593d7d77f5005d2b079df2b3e0888c74c +662db1a483bfb57534febdba3d66615f5b18d99b418aca42b6f2d46d0406c4d8f508c9e9893dc026523334f29c9fd033354987a03ae607f8 +54db034bee378158e42bd21175a4e13039fba21a17de24c7dcf7f668bb7528d0a73fc4de31927f223f04e2e8f7d7f96fc7cf8b8317111581 +10d3496c569a894ccb5a11ef8c9fab5727b14361e2b389897a45b8263f10a3cd7832bfee7fead820377f59f5bc9ef1993e6fa18359a0d3b4 +5fd3113917d9c9cbef65913a38eae136ef5eff5f89acf30bebc3f82b03420d27984fba66113dea3154ba1cda7ec3e4bd564ac5d7acf51408 +634ce8d5dc69db92b73ae5cef0c74e7a0eb95cfd6ea9e486a4c62a3428386691da7e3c3504efce2621e339a830a9ae87dd8f416170c6e2d3 +d67636570ef354ca7e8bd0b3f5bc8b5b217bfe34619f28f377030b68d84d9b24a359c2e6ce22b4f6a94029451a42d126b7578febf52be089 +21443af64129bb31ce6cbe8b2c4931e5a99b261e79ae9a9731d88f9848f8af6d73642e9afae9ba7530d278633a7e38d0aa5191cadb9b6ff7 +3f1baf1fdddc7f599b9c27fe0251d622e47fdfa4628d1fd83f45d2da055cb085415f56c5032d2dd17a93fa8734ef9e96fc554dd5b7408240 +04bec831935f10117323ce7461c8253d3fca644ed855c5423b7f790e186fa8cfa25dd0719b8d979d5c6593293cccb680a5228e5a8703bb44 +1826bd2d2d07ef87149e7aec77b42c3809914a6499e31bef83a90f5ba69a5a1f11349fe376239ba0dafd319dea9f363de25faaacd6724a42 +cb7982afea42ef60a789c6b7ff6956c8365c2d3ec9bde293ce7d15449fad7084f342a5695b6c229063962d6b942646f68149300130546181 +5634e99ca47a279ad387000eea563d1f6b9e045cca7d0ef9532fddda4d2fd64892aae113fb48d60198d36c4e1b24df7ee136e94449f5bb4d +d2f4febe8a7ebecf7a4be1f285e2a1acd3e9d3971ad8b8f19cfaf092091e550c95d7941d5581e00721610addfad86695c181f63ad80c95b3 +95aa9bd8a71ffce2a7479e4bc8eeb9129effd49bdc0b54ad4a6c61d06b49d76bc7b56085fe0c81bbc70792d51319135c25a7c3103d15e041 +dd7b9798172d852897516c503b8d267be97a73631e47354986190234f2dfee7ec2c223c67456f8f7d3946f47c8e900462537532f2a2a4d96 +1de55bd4eb4ba96708c0a45b8d81dea374dd9228128584e0cb3d7c5a7914d2f1173881eaf9a8521169a88701894b43b920abf96b832e32b5 +9f53c5f69b5b1608ebe83153c5b9f1af3af1125b6aa6b69e0d4e710832b341bc6692a1dfb357b77bff188573fb3bf187757accd8f073cc1c +6be07b363d63c1206a203fc0a785eb2c8133e0a40c525def66b94a28e218ecd47482ded99ce51fa3c4a33ab20fe99d834ba39b5096acb3c3 +fc1dbcf9a16b8b936ef56390446a731d277068e991a68cd86c780818812b4e6662148b0725cbcb06c4871f651307e183b0514d6d54bce19e +7a6b3e05a6dc275fddb3a2343658929a6bcb640ef2f3f6ae4e040daf7971cd0901ccf9b26e132cb5c48cf23f53c5c761a9bee845a2759891 +6304e35f29b568fc320a6ebc86d37c3a721b8d002e74c04c5a7bc8ec36923ae8554110ff126f67024c5ab2a4c05925957bb358a583e471b7 +17aa9809f491868ffb2a113606eaafe2d2db5e62d6113da7d35a69b799a2bf22623049817057bf147ffdb424c7ad88167ab2abc4d27ba7c2 +4fbe713c549fcaef71db57de58a987a36bb753ab2f724d78352e52d66e10a8c4f30c0daa694746b677c2d9d4600bd3c686ec4b5bdc121f6e +86e11fcb05e01651eb7ec77264bab5cd58527a176565464a66bc21f7f633bded92c50f7ffaa348a56b1523adcce4526e1cfdbc00dd678e3c +006f6091c983748f418656b3e3ed98e1b8a1ab3776540b8e23c56beffbaf083083ac6c2186c55c3c0942c649dde4f2d3275648f3b566887d +f85b9bbecac195251a5fca772c04198269cbcf75094ffab3ad17355bb4b84fcda873ea27ef4a121191aa2b4183718be13ad3ee92fad39d75 +2c4a8ea572ac6c2f03682ae2f95409051a6840757984145b1183654816fda56bd101dd845c0375497926d9b187135ae30aa62729441382ec +c0e226a597cc427078635f178ff47d2d6d0a5fe25064d2ae9e6e271454397898361c6617531ba16e69067a7b575c304326b9a9485e89e2ba +1d84f30a3db443a81864618b02ae31aa9188636bf7feedb0b616170bb045b5e13a740cbda52060ea8e6ea3175b05cfaaa463be82f8dead87 +003d0ff1db6f68144a068639d36def15612de51cb0b8eaebb05b4453962789bdf1ffd6048644f4cd467b22fa2a5dade3f0f502b178a846fc +39ba4872606d9a56f03ae4dc34389298cfe42555bd1042edad461f1b02d90e3ed8eae4bbe7e5bd05081f7dbabb8202c62d4ea46d88050760 +74572d1d697d2e3795773ce979d933bcd7cb5f5978d6a2f3325bc27217c45d546b730590f53cee24b5837c1ba60edfdeb137528a553debf6 +4f3e39761e8cd0a823129e7b500672a69fe5ab9a901e2778743fea6a3d809575b45008b6e7fb2c0e38c9a27c2a2e97ccc17d6868cf4ae315 +4395894ecb670266b2a9af8bf9a9aba9e594927f0e5b009e57e0d6616338a049d3c22bb343835ae95dae4b99692d66990d550d994a07f7be +8aa8f09257f779dd571cc81bf58c9d01ec2ccdc93fb291c92e1b84e94f98bab618ca4d0301e13ad7ba39a821452ae14aa52d70bbf5feeede +8e9fded5e7bb289d059578627516c6ac4a6e8474b8940b7694cfc95c11a5d744b5744a8d6de45ff5e2079eb543efb515674d0d1811902871 +320014f6cdb6e6894d2473c3aece029f6df8e6a69ba3723269c046e09cae67100f95bf8ff372c0392c40e14ec4802cda6b0d941950d0ffdf +b81423973dcddd16a8f04439826f6e5f6a737aeaf1d0903b3d78a82af5a692c08efb9a9de3fedc9b8368efcac9c0cd3d50e1716af12b7b39 +d29dba086ee2ba1f75a82eb8d66f3f4e7910d6bdf2a5fe03ab529c395e0aed901e50173a07925cd76e4f1beb238390f473e3a750d718fd14 +f2bebcf80a88518a8b666f9ac935cb77c21b795ad874d4dd64ff3a50f52870b09a05806882f99408343a461a8278b34ba60e876b4850d3b8 +ff4f64610a716955296096f24eb04055bada487efd5d01aaf68cffea1d9a22a76090f1c2bbe59f066ab9be902f19852abff126e834b080b2 +1e7056c845a951a59a0940e4f8937b99f22852f382611fd81649c121038f8c47af7b369dc4b5bb61a6173fdc7b788c782531e48748e0911e +ee9b49b4a79bbb3ae9dbda6b3ab85f8fdcc454ac67d7f405ecd551a1afcaa400c1ea70709ea018c9a4c17e11ae2b3eaa2df1b4bd64bb3e48 +a5c52f77d5e78e81fa05297c60952a6bc50fe3e5e8b532cbabef944a93259a40c4b6979c9438a7b7116cf5694412214809b9a879c7b73437 +24864a3eb920d3b0022c61bdd6fbc43fcc64b8ee6998ba3aa105ce9e62a0733fc48e966530f9b15402cb9b3c469812a0ad4ee8ae6dc49746 +885a2acfb241b7e520d1de3549474b2c5b170fd1c069617a012b17cb17aea3c1c33791150071927c3e0a7438ade787a0ef60136e28108f5d +165d57e5132768708da3fc9c1754ddb16823a8502850a6d0ad0b6862e6cad8b6199c9c7340bebe9c8b03e8cbb175a8a1a2e2a9886bd6bead +7a72a985f5c2d5945ec9efc590cb52fa48448d094747292c56912d895bce81cc2cff860f2b460ddb05962baba9d7a76beeffe5dda9a8f99b +1f426a045d119f2d9e78475c3ae1555b6f1f40c0f1958d63e31e78c392ca444ef3aed07b29f9c209dab6cffeab5684b813febec7bd35005b +b02ecdb477f7ae98244a2bbd0cc0453cc726434f8beb41e1bb1b0ff950090c9e64c12c22eee9e823c755d41c5852562eb5596189932a05fd +a95dd13e94fdbd9a57c456c1d7c8a452d2bf6213e420f267deb1b056ed15986175be4e374831908507d161b0708d149dcab79935c4abd126 +2f81e743b02c687d3a3d9fa995ec84ec35f887fb8cac350798993563aee87c7dc2af01b4e10e5b3da3d74e123db2fda8b6f9ee7713d5c577 +871006fd75dabadeb8e98ed345c78213a18369264f7a4eaddd0a76d4e4aff54ff96b076a1e29143244726fa2b7856175d6ddba1c5f5724a2 +c86bfee9d8955b55a12c63b990146bf771e049581f2cee0cd4cdeab6fe6142aa4c32db501ec91849711172b941d27f935a10fbf098587e79 +3952b206fa13b874212052127aa61d718e7f4686bd3894dcbd14bd901cd40db4fae2bbe3cf035dae9db9eddc78ad939184d5eadc33b3b3c6 +56f54ec50715502f390e7efcba856515b229821e51cfe771efc8db617df38d2f4dc4bc9fa8b1d0d49fd9f5fbae00edbe4d13dcac4b489399 +bd4505475746f4baa49fba550bf795ebf64eb5118278b48e86c088138f7082fa2b395222ffaedae6e09fa68af635fe6fc2e794c632d1adb0 +6a81ec376cc5ad3ab48a5d06ec5644f86123f699a6d0f16e6708bfa28451d006336bbcbf3e81ca672552b4aff74a0c93a0b63f2fab7f6d9e +bbee809f56f6d576502141eff8b9d90a714cd5dcd6e90ec13b3884d189a690edadd0b2b07a27841a25ad6e595dafcc9d1497b2a629c5d2e9 +68f387dcabba9e4fda759d6b34b7ce4ce83ca67c524b76b7a9f62c5bdcdd416b1fa8af84b4b86487b07227e698b376ba9b2c264a327baa50 +ff2cc1201f6d20dbb6dcd94818aa883773901c2f54ab02ec4047659328fd2a19bfbea419ed0a28c9e6be9b61c176062f8fecd86a0926fd91 +9500a422a0c94b11e052c5d431e45f1f4325fc93c99d7c707b65f1d2ee151f2a2077750e33c187e4ebc33f698a903b63de44bdbd86a848dd +6dc8d723f83a0ad55e59d43b6b31b75d089938e38c47f14ee8c99f2ec9aad35c23b36b25a8422d5fdaa30ab07d6814df4b30eb97d753910a +4b8e67fea66aabd0cf557d92fd5de8dd461343656faf6e528d9a50bd5b104f7966fb45bab992ebc705c90fa8672dec5e83e6b60541f8d800 +9f603bb578fbbf8a6c95ddc322c5ed8a51ad62d45fbd8e68b5ec09df2c5549a7a55e9979dcd18706afbda1fac2ffa0477b0efb8fd00c469f +b5f9db5832ea0ec809b568be9ad6f4876190ba9ab26c312f3c351b8d8fc5b3a108f52a5406d1df47ccfa93c0f263a5627e5d9d597f9447b1 +9fda52a727f05ad8cb3b94348163c9f1f7245760b8dd8da79334d65b3652ce5fc1419ceaefd72d20c3603e7206437cf064343962b5964a79 +976adb2802af115c5946eda9fcf1422eb0fa785be83cf03e96ab8417e8ee10b7f2247e7bd8722a4d686ef280bd7ab58a2a181f9eda28a400 +a54da86300db07649d8539a04870a36cfdfbafc892a6e220c39efbb484c1bd9b32a7bc66aa23c2bb3422a9b5e93cdc960fc218895ad0343c +539d1ecc767cb74e67c5c1e7ce1f99f5926457ea46d3f964109af3e8f223fe84ecf9e3209794fe9f5d4dbb3c26f325849a1c8ad35493b001 +86277defbf31d1682dceeda752fec7f17df41ad265b6a015f3c32f65a1def9ca0646d1d5f7a872e0032a9e2de13cb8124f5f7ad2a5603a70 +6f0c2918438e9a4cd21c14a07e4d94aa5c24d0cac9c1030f16666dc2b91458812628f935ea36eb1cb17857a9a6d831c92a2debf30987d174 +aa97ada7e7ff3267d9545305bd662b0053565d6789d90b01c9755eb6ab53d8248e4d3f948e03340bac313b1abdc5f94842d7a2e5e03741df +19cc12fd496b93eb7ce5479dffd814e424073c4d9bb01ce2d3f0834a3f67b7bb2dfa5eaed364aaa57b3554739e72010417d10f7548735aca +a27c0eef897b433f880413b1fa277f2d54973e9d672241f6f6ff671880e9459c8861982d126e0da360530a9a2fb0809e58f6aef27673d7e5 +44a9ff1dda206906bff2ef60da273d4a42b4ce2db1adffea3fc5cb1c26074188f2704e7cbf19a390e466df3845e0398a7f320bb5e90103c5 +76bba49f0dd52deb660f501c36050a085241cdc9654f86c1604fd9216a27a8088637a44d3f52133fc28cdccbdb8aae2983dcf19318a44fe3 +f9fa463a3fcd08cb20ceaf9cda22addbc6bc1a6adbfbaf188eb4ef4c2cc8d5337284b2ff80059148ad9bd5cee58f26b3c0f00287264a2619 +fc8a036e00767c86cbc9ca468bb695b58b3aec64544963dd2bcf115f2a18a9c7aacddfa0f48da956d906add1eed626cc28ffc32e80dbd183 +4b58174da90e4a623140d1c5e97a163884ea976dc08cf6b42dd1a61136e84b0abbd8b5d35d974588e105c4a9a89d1f66b7b1d047017ed61f +448c2c4c7765e7c4f5dd16e2cf47c55469481595e61e003e2894aa5721ecd527bf2b3f4013f4786e057f5bb49fb2b723be7e56e9c67b525f +306ed200668bfb901b0176c62161abacac752c3a8e17e387f97ca46ff14763fa2fc277c802b2ae082aedef176da91c2792b1eb485bb7b9df +b2f11565e721075639cd0c3754990cf46d50eda965098b5a60072f5e31e33ba33f200bfb6e0e7d0909db1fa254eccec1df0d2f8e0003f2f0 +cb0ebc5a98bb015ed75763a39fa9d2c4032ef3b15f12afbcb82023779ec098ecbd3e0803b88f34a7cc2cc15226d32f407363351c1ce3d64d +6a9000084fb4ecfe863b7e9b04647b83ad5688b0853eea95de316b70299c7d10c3522eccf7dd259b8852b7957dce7a5f437e0fd876c8090d +1e84b20202950d70cc763b29738c5957f284756d179619e88555511a8721aff0ff617d96fb6bb54362db6f2552c76d12e12eff04734d9a60 +221591cef489e9ca87d215d958b95f49f34b8d46026722656c148848ff03b7066c91b9abe92a25c3a03571771a5630ed75623242f2f8fd01 +0ebbabeca5f9440fd8795c8e67a393a75022b7d1018cce18abd9d5fa60f8d40fbc9a63ae24bfea4149a0ace41ea072fcfbb455e2407e5e73 +606cf35307be4d85a8d73c244c738c63aef65dc52413fe8e9d7aa08cdb8e1342fdfb15969a53b992f0803859c11fc46e5af55902b5c8b0c2 +81dda3439e2ac96e161da174b60485495856b73c8dbe912d05a24d3111f063ec6b2daa9f92f0dbc9fd4614707f691d4e23e86c5052bd2e5d +67d27d48a4bd1af74640a71c260fbbaef0d2b39f884d6c6f9cd89e797544e9501abc389d7c477da891698963166a03fd82623c6e92d97e8e +479b69254e7a59d32e5e3c8422ebe07d6e3fd65cac76254088cb1c7e573169ab8925fed7e624480010462f7160c65fa141104be76f418c89 +be4b15601d5835f96b0bcf093920156ec171d7aefb7ef19fabb2964cf0d0b4b3f5cca7c21239cf56f882a9a4120d85a7c345dce8773dbb95 +0dbfdc3a85f355bde3887606a70f7dc71383ace5e83369c3a270286d22d427a3775c412b3b147ecf73ab9be0840db6bf26a1bd8cac781217 +77a3bc7ba66e4d919c52b481b2a67b09f8324946fa9b8441a601b3a30e2a23118157a01924581c8b031f60e53372ced2d0a3fa58a387a5bc +7c6079b59ede758aa76d2c967da9188ccba36b16d908c838ca6880bea594aa0b5265339dcdc738ed2b4e3417fb77d05f9ad82b8a6836ed4f +882ddd8fccba9d41c8e588c8312f9ac0ae24871f69d18da9cb05ec85a9e72a5dd5f08d59f621a5fc1cc30bdf1a9691dfd55981087beed16f +e4902ddade00714e2f6607a28c3d55bb9fca9968beea0f377d64406dcc48c092e2ee19bb27c29e1471196ffcc8d6a202f6344675a2c45bb7 +1c3aefaa5b6cebfe8a4ab222d0362a946ce02f76eb5524bf304dbe4d4bddda1c68085d7f30f6ec6bdf324c7bf3669a588607c3322e2aa446 +6c5d9acef0547c43eea9cdb9474cb7e48c397594d4180a6ea642050494f699baae89287183e051392d93ef84ca9ddb0695b084d3c0c7e344 +ca0dc49c6634e62e1791575edd0596c5d2f7999dcb0a88fc0ff58a35e76366816a48be9b916ec8e53be38e9a36d37a424d793be7a42191b5 +f6e6cb561a23d796b04bd5d5dd944ec8ee6c726c618727e968bf7348f2b23c3cc4f7df5d24b5bb660932bfd4ea3cff851a60ef1ae6ec882d +be86a7223653d8e534c651b787b5d2c7bc35e11a1435d23ea82f7c4602f10e8afde70173a2f008de8807426e3abba5ed4f91cd5f520f6e43 +9c92c3970e9e4e445cfee2869e912a8cb2b71300f5bc3a3d3a629bf9812e176da1134439369cf9e53023fc4ba51b223377739dc8f343edab +9968ca48def80a3a752fbc418de0d036749cfe051f5564177f3f417db5f453e5701bcec083c49118dfb3ce71d9043b8dd5d569597d5b432d +3056759af0a678063f331fbc79cc93037d56d01f25000cabb350fe94bf6c341cc819c21585aa5f7eab0a722e205755ccba815edef44bc66a +4f3bb4cea4e627fa32f4aa69bdc52151daf4e8217baddff94b0c81b990d5ac261e89996b37656c76eb8f987324e28b0e0f0d5487f8925a79 +b76650e3c1d277eb550acc6af66becdea7619812ea435ca911dd6570d3cabeeaeb2a219efefaf5ed67493271ffd6b2d8d5b3041b29c5d31c +6158e8457b6da731706803771239cac4d3e6b6346df63c66803f90d9b742652c0a26d105512d2001b416c4084aee0f9da6bfb0f89d0a7c30 +bd38b68e337a9048b55fddb5a7bc6d24afe048b399ee4dc6accf09dce767ce9e12ba04866dbb4aebc2005bf9bbe72355b65a3c518fdf7aae +16ae3823db6f1b8c4fa307f8ba68353ce729aa428ecf4eec8da916540a51e3d40d1277463de0b8fdf4457d453f7b094790389e99ed5082df +9a53128f496a2bf9a57da9e0b9ef8ed136249f0adb476493ee65d7fba4c450fd9c1a241c80d1d5138a9c6b1c1b3b7e4a2a6ad053f9d312f7 +95d3917336aec3aba46053a597282bedd831c235acef7ffe6b03ce88857f84b55dd704eff8fe57330d549a1b60d615bfa4b6aca7d14b2218 +a1d19dfe77e6ec08ce64f1dd3544686ef0f742302d0352db4eed245ecd27046841dad5632030235f3b488bbae160654398fa01b5bf5704d2 +9c27bd57ef6c29355b8511370536b9b4bce35ae02c1671d058bcad8009248c2f0c2ed09fff72dd57636e0039467fd882b939c08260cf428e +53787eca8c2f6645069a7e39dd16adb2c31df08923ce629aa5792f8554b591b8256e250a5f1c9daf2f2095e2cc3b4eb61c2240443897fdb1 +41833757b293fc39547a879c8105a6bd2e7c6d9e23ad4f936816172049303409d354130dbddfb0df431ab8833b961f97284e77553346f3d4 +c3406d7415f1db6b3357221e08b38c07d063dc5d138eb7fa262213c663011e28bb8f97035d153ac15980d5e87e19ca86ee1a0968b62feb93 +d7590d4e16f3fff262e20002ee8960a2384f94708f0585eddba98244e1c949afd3dcc9c0a00461bf72d52484476a0477aa63d602a4a327bd +2f53a686b2ab989dc51a8bdc711610501b5fe86f3bd150d2cbba82c5d27825c8d6610d112d559f5b00791806092f860b8878e9a830a1fba3 +382015ad987cf1e89384a61518f5a65cffa04fe64e69ca6a1937cfb3f7898813afa67ffb81c485f9b1bae0c35657101c7be3c1072d66caaf +b763a4a69c87a644880a2a17a93e26a99850949b3b7b8e4d92a1632229dafadc58502e9316fae323d9e27373e097c99b23e8b3eed1c5fdd8 +9b943b4d653bc5b2bacb002ef5709364a178ded62c55e0641daea62e7d87eed15ebea4d8bac9bf82c131912aa463f9f96039749b48166d75 +419b4f8b2e564b0ba1de754f7fc7f10b489737ad7a3d8af85eb0b16dedb257f730b58b228527c02cb1691b494be87c0d7d769f542195eb3c +091cdf7d70f4743a8e2fc29d28a3bf3ad2ec17378cae9f86a97b6a59dfc2b94de791d676a4671f28d826be49988c355e7b70bbc54f8cad03 +63017f62ace6aa2cb7c53f5484159f5d9250f54f308b36651feda8d56c0692f05041f8250cec391c0f58ba237189e4493863c44b07990862 +e258f6e687bb9c0a023489d89b268bec5be1bf16d97ebff025d8e1728b4d93963a1a490a3a84195f1825295124f2891d28dd62aa9960092f +19429a4722e9fc1d655d2bb8fd516e5a53d32fbdcb5e53ae8b3a1a549df367dfa2d874a61747d904ed8b1910e8ae348251bd41d9e7f6833c +00f6a99275a80d355c05c5cba220b5e203fa5f9999857a6a99d036ada888df34a841b08609c71cbc2c7d050a966edf6a5823d4ecb47d7ab0 +ef42edf55c0a7114b7284268324b5389295acbb341c97232c8e74ee68d92308fc2246a11262f2fbfa797c05c24983af75a05a45c8c1a65d9 +f57f14959ac261a0fb34825e68bf310fcba7d40fdb4ec3a7875f501dfcd49818d17c7cef922cbd6a42c944fa00540342bdd317b4f68dae13 +8aadee790e63afac6639d683f4f0cd0522f54c7ee58beec80068d4c02ae7a5d9cc57eac99d39c1d056f961e44372c30fd85a50e6c11b680b +4241ab79c07defd84cfb081905b8befcea78a177fc7205ca24ae8525dba03242586940def522611ce459d4ae5df7b93229c4bc67a3e02504 +0517ef212604990cdbe46b71072b393a047cb53974fdcebc3f78d2837d33785f85959ad640a7d6545f1fac9d01920e1ab25a25bf0685bb57 +9934e8e9fea08b066cffc73105230157496cef92d65723cd4a7e8d3bf14aa705932a0c778c5549f74f49b900eb70071a56b414aa6d6e4fa5 +137bf4d07a3eff4332188cdd292ca18befaa7da0f177e2a1b15ef2329bd315a3b3a48075d17481e874f96d832a89d8a4d2e2c40de55446ff +46a38335d18a86c11fba32a02c60e0fb685ea77e1a008733753f99ab87b307bfaa5a0d9ddaccb35e4a7210471d7acec24b1caae2dc421975 +315ba19c695f5e4b309cf93fb22d4f7f825971d433b082935c52e847bab387487eaedcf04806d2a06e9af2c70ccc61f8ab635d81926c1574 +d63088d6ca93ee83d370f67698d3d83fbacb0c6c3612dd79f2a1ed3fc085c469d8fee59e74055ca8dc49aaa69d96ec84028490158900e06c +9ba862164930a65dd1ecfcc5732361e3c1a5e6862ca7d12e6cf9cef6998a570074c676aad8d989ae019950882fd37f8a72799738cb6df05d +61b04275da2053fad39ad15067262166f3bce905c9f21ae6b618b6feb3facb3cf7f1f5832992ec14ad988526bd5f4e8c851384c65522729c +d289093348b2cd40b55abebcfeed46cdc3b502a753187f9fa69379cf984b8b0b1af7db0885b53e761f22371fcb982c24331efe34b3ebdbef +7be6e74de700dad2cd5a908546a90efbc75282368aa835d1c26271a051876629f2cd30e65ea1c48688592c61c3f47b2a90fab796dc82d332 +96dadf0a3481b94dcc03e37f144aff3e43c0aca2e54818fd0c30c3ce00292acce65cff2adeb396c711c78b2492810a7b609131648d919d5b +628e0e1396bbf32f50734240af8548c225f150e71d5b991ce2d8c2b6962553d24007adc413f83c2bbcea844ac9c33936e3c63e526eaede34 +b10034bf0ff23ed12f89cb64fa018dca06474b110578ea3364b39bdd8941ed312c77cb7ec3ea478301d23b7558fd5e7918151d90602dc8bc +70b6178dbbf8d1897351ab2f22ea87d206d26e2cd80612ccac45da2af067b128b4a258dfca854c73b66c11c99867f46eea7d485b7b77a8a6 +d0545e5277f583e198db94170374cc1cf3c18f112d342e0c210649eff6fc5a4aa88ee5767f17a40f1dbff89922e7a05b6d7dab1c106289ca +02fb3aad67e49e1ee9b4893001b8ec7ff44ee37622a4d3e4bbbcb3b041f8de0857de4b05dfe7dbfe51c9de42d3038bff2b6fbebbc650257a +5b0443c4814e36abc384f404f0e01f44401f6cd42b69ce7487bfdd31df50454d16da8c57c11ad82386efe8ac94988a29568f5b0ec68a06f3 +6cbd2606f30a05b2144550f6cedb8aac15ae5a0cdaf10d9b1c64fa56859d8d0dc18403e4c8e0717e2b1d247532976a1219b1e2c7f9b90d9f +3c019c030ed702865cff816244ce53a5a6f991af29198fef0319551e0d9fce9fa2e0c27ee76ffaa6d64dccc8cf5049fada3f1103521dde68 +1d053c57f4df0a6722dc7e29b087bbbb4eeeedeb74a9469020c026f8fc6c8c694227ae8a66c7c295c6172f254fe15bf390637613c79f1821 +a300bf0703b17a53ab94e4564473b0d5ffb29666882c4bdb385d7c7e875503e6cfd60fc134b2150c9750132c107cf9a941eef85cacd35097 +403e54f5ea579e1f28e0dc2e398aa3e03a3a58d1f667d65150399c02e9325db3ccb28b7f11ee5af0d1b153e270daeec05204f56ce836f3dd +6a24fcc1b6eb704a6aa676dc8c567537fbe9054c8e35479775ea6e824f8e8ffa32434aafd962abfd4d954d2f2d4af4e1cdf05e41e225727a +e2510262075b4fbccd54f55dd1a6996fe8087f6bcb19d0e1454820fe69e422a2452a4baab0ea50336a29dc9ab63465934944e429bbd26294 +a13df4917d1ee2cd5ed5cfee90343c5a9b54acbcbf5dd6f7beb445809a13a085bf8f32fc8dc7c8a796a0e8f362e4eb785707dbb40ce8e10c +9e1cc6b9ae6f518d682fd398240beebe980104cc4407dbd173e1357200c2d1bff76a240c5ddac4ba2fa4f47d0d0514e31dfcfb7aa20de0e2 +48b8398d4a0b3f1c146d8d40b69c7588d375658e2a9424b920f21d89bfec0825caae076513f8d64059eec6670bb0155d44fd2965d6f7aa88 +1b299b01094190ce5345668cabffcd91192f46498bfa717be38e8b86256742fdc249996b28e4fc20b1667d2e4dd032dd29582f49217a0257 +51c709fdb7c9117623f266a6761fe4a227d17a94724510b511678e607039d36bfefc386ff00098147c2c80ba5b06fe764d30a618a331c174 +acdc1510a3b18241b1581919f5aae2b6d3b87808b3318a56672ac21cd86aafa53ec0c3a7aa1893b6cb4370f001ddc2c460bbe265389012b9 +87ab808de76e54f72dd20a39784fa88da4059434f0f66fd349eaa1a3e30b8e9ff855666b890e38a667b825006b30c9cfe2e00705670f20ac +0cbc9f3cf5d1d7428c2c74aaa5c68bd4c2141bb6c6305a77e69de60f3bd097831e8623e475f8d9ed5f5ea3571104bc317d077937533741b9 +28720b4c26008c4adc897c97b5b10d91530ddccc7ba7689dcde34d29671ff3c6cc98feec3f64a16bee9a3c7e3e58ab5b9920d43d135adcb5 +49586618590488541d9543ea248f8dae506a3f75b23d3036f5eb8eac8071e56cf57c16269f89af84d2e3417818571d30bf6181c85672953b +179486b402aa95b106fec2ea5211e2732e371e1835a569d5846b3feddad4a62c7ebfce15fea165b2079cd25f4fcd5b00856a1bdcd1f35119 +74cce72f67a604721a5a6d2569b70979630b6150fc4d5ed802a9b8a1f7ee0b70841b19617e98fce7927cb855c008581e005fe08770d1bc34 +ef1d3c8e7c559269ab975db4d1fa1a997f3e49696a26426a0ab7d709e1c9aed646d442aee4fccc89e17aae859feea3821765bb2b121cdc1a +1d100b83e39197a0fcfdf55a9da518c6b2d41bd12e0d85f5c9ed0e820330212653e4e3dd444dfb72e87fc76caff18d46ac5c7339422b59e6 +f2ade24749ebc27ca047f15fa2d137fd8ac53813ee4fb0fdd00a27bfd005aa7ea3654bb8de9383536ea7991763cfe0e5f819b59f1cde0978 +126277d665545cb35d3b86442901c59d0881812bf0e87a833d6874b4c2bdf45bca4866b66f8e8e73147c3e47f5ed19529d23ef9d96a59163 +9d93d7ae7af88b181e23c59c5c3226e5a255e5c12eccb88ddfc12f87d08aea4a414abc99f7e37206ddb636635a6fe9df31a223c3519335bf +86408ea51a2747fc8359f46e5e4712363381e30c238e47320ed41bb9b4b3373a5c4c9053681ad78c41f6efc184fd320ea7899b8285343f3f +c1f789aba981c8095dd72ea373f368220387394f024f760ce2c49dc0566709ddb0715319c752ff6f165d2fa4b467516201b818299124b148 +0bb5e1dbd2dd0027b75f37da5c1d29f690585a5d2aacd8a4022c9481b6bc709e581c03c594c23fae4639cffe572cfba655c7a7b282df65cf +4bcfcdcfd36f298190ef650e9b58179e3989a5eac071f4392cfe4a5ca6efdf67eb866a7ded5d83fcf5d5be677e9b0cabd278f707b64323cf +549c2909679819d41f016c88cdc9e556b6b6592e14d8cf5d5b980b43fc30cacee5729442a2045813c120eac92690c5f001f0c87813fe79d1 +7813f0216508f5681e1a756fb378606e847763e324976ae22504071f3410317bb4b5ddbd52bc7d49412d4bb3df9244a3332e6b7760446cf4 +587ad2c84256869a789f1567ef499c614bd2e87ac3336baec90339d6d53c61e997f842ff983ac4956e5d57adcccf363683f1244e61db03b7 +c8b7780764afb43d3013b998af23c2700b5c77db48a23d8ffee26a1c19f9104dc7f665ee0e2a31a1644dea317f3289978f0211d0bbc5ab7e +cf558ae51ee72c19f5ac4843028a6791a39e0e5e95d67a194d3f174ad5f95eae0f9daadb891254e46327f16ae4539b97b63e2cf9452f2b26 +898bf0b45f53a0a12e5be75e9a4474016347ba00b690bee86f8fb2c9f5f53092e0e6991779953711aa35c6f19c8e20847ddd70b2295abf77 +0596104a995dde4556e68951d677b8961ffae4acf32f9dad19ac60f3db6d02900cbece8379df273d184bf82e1b8a04bb3925cc9bed85eaab +8056602f8c69aaa1d131991b03533bd499191e36c0daab5998af98e9ff743381a59c82e84c2dda95a6ee93e434af13659fe3d119867a0c84 +27a21c1163c6b23a0e9b677f8d0f179334e4bab3f59199ac754481d9467011c5f31c808b2bd905991e2e0ec44cbc1ca5b9fa976e37ccea19 +b3e414dc0db707cc3ff2230c06bd986e3ac7c1a4dc491b1eb280ed0b5782edc76f01fb38ac988d44d3c8d742434c33090c2c1c087d5aac8d +9ed9481917f6754aabece4157d6ae01eac02d66fad32856a3ea55ba0deb9a445f2c0ae8bf0a864d74a71e69898b64df8556033606b39c7c1 +a405b01c104c1c1b701c4dd7b019afb857213dbc73fbd8235fea26e979434c9981da4be57a5cf9f6f6658f8f01f29ef2618f66c19685e8b1 +656d0debaace2f29adffa811c34c657c6ea643ff45ee70ce636901bf7ffad30a4f51d57cb33f3fcf07204af2e729f25dec7ed5390a838ab4 +211180a8bff087a4cde905c47ab41ff6b95d289db891871ff88cd51e646b202b9b98f94a5d0923c078eb4ac028c3a84645d7f9a0599d1709 +362619073f3e158d855763989337bb67ab5dfc70450c463a2be9e52215963766fa7012d930ea3b677bdaccfc3c424634ef2fbba9ec2f38c7 +18fbb440fe9fb631adc55165b3a00603882bbf5958ea1b66320c1867389a51f68d828acce7d968302afd78bc9f87ce152920e8d330352a53 +4ed9fed6efd39659cfbe3b913b12bcc14e58e0b2704fb7e614405231e338e7e864395299a9b41f2b5a8b71bec3a41771940897bd0064bd9e +69a01eb8b48881044ad32c57b6038ceeb9843947bbaf4c1bdd62ce6d8f646388bbd8c29a8b948f7ccc730d0e0db357c1e458d96d0f06221a +48a8aa811e568b7238edc29392c9e6ce50ee5da60450ff27f6ce2ba1fc18c3921c9865bbe059ff6e799bb63f678936393366639a71551e66 +91994abeecac852eb69be232af95fe2bbe7bc1b169feb9bbac01650ebe616aa395b998fbb68d448ff8b8db59d2d43ecb8eb1c498a6a28749 +42b9ab95c7917716394ed4a7fd665114f23e9461cfd1c4d36a1a38d4cc22b5c8bae72df0a8911fc3ee4c3356853567c72c92130781f3524e +4debbbd9901150a740ed947926730ac0da914176171066024dc12982f84820621f7abaf2f0622b156118b39d7053e4234feada3501ed5604 +f1dc61a4329eca890d6822f3c9447495e86db6cad7fc549c4e2e2667c8f8b2493f15dda3e81dd8410f4d2fac056f060fa813b4ee08b9e779 +ebc4e14c62b1af7ae795ab33ea7b3b034bdfaabdaf91bafdf8c05df46348aa12fd3c6d35e13aaecaa2e853a0592d9ef9eb7ae518080797cf +6dbdaf5b67be2b9261c779b4e5603550f92230017f58a4295ef6264e5efe99c57f179ef39d375d63ab4109f4aa8a1ed0b3dc441f5219f818 +53939f23fefa35e4aacd873beaad76056a93e22282be09ffb29b3c61b8765cbd3423c325d5cb3277d4159312a748dc4d4c620e8687f30001 +e2158b288679c84b11df8446c3ea6d99303c7a29742cd11fbfd09be05b9c71283b293f9f456bb6cae985d9ddb91ac22830e341837e4ffe7b +006df9cb2b589b4424407394581a384d8e6746695ddb46c3e1a01732d202eb4235b797de22686aaf9c2d90e3f118f98c0323cf831e85a79b +d67640f02032c0ba5727ef1626311ce59b9e123337c639e1683e99141990c7e56546c4ebc035d9d86f2cc85d7a210095e0dfde84af9c4997 +d0584053c3cc94b425aea3c8d4cd34381ebf0dab4b0a1baaa304db20992ad889ff2b9f92348a21284fdb46d0c8fc827442967a3b04f9889f +fe0e2686a2bddd4b56eee35f00762a8c9b42d4852eca0aa8fa654cc55071f7d719c1ddc718f66be04fdee58316526163c95b6842d7e0770b +ded3b69768a5edb3e95e4b04a5a3714c5182dac1c1bef239c5bae09e45369e7a141b8001f4d1b8f00cee6e7fd26452e8da8c5e4bb26ed1c3 +213adcf28c689a54ab9968628a6a318a91344852ad775d8522d73c328a93704b10aac108c86a279c47f46bad866480d7dfb56a08c0a15619 +42380a84a3314302652d86ba059506f2ae854c5165a237d1aeb720eea02f274d4451f4092ab333ab8e0bb6c47c ''') -# ok? db_write_enable = unhex(''' -06020000016c5d73d6328c96d856b0d1ec9856580b787d5e56963cc0a798b8be5331b183a894f23e2ce1ba672a20d887f6cc884c3dd027441377e68fb1d2531329dc8fc35716132db947295abeff7d8644417bbb5ddf2319b4fbde57c50759adaee10ee8e60400a2d7f09a7c0d9934f7575a00c44a41692015de80959d9f0b0edee6e70037423b98733b5e1d2f892e0ff5f2e0bb364d79b6273856553e4df0e49e19883058f16fdf698345da73ee561ac8dfc2a38c3c594c5972edebdd056923be21749da4b089c8148f73ac9917bdcd6976ce17d77b919dc56daf21bc544aa4b3c6113037adf103e9b74a440b720104860a420bb211612d62da3a1327d2c4a6cf04bfb54d47218be9525c428078efa9241e6a42667e9079c91251026c6862575af5c40d7515615804816dda6c5495e6f9abd5c77e638925b28bbb0403d4f1aebf7d86bd5bc73a22a41fe2c7e19db79c404fd3db43c8d87fecaf6ecf163f74659768a4c6cbe562d8d14b8ce4710b9164a3bed100918d9872aaa9bc9a448b117823f1e86fe9ba9ac27649737ea497c83efff69022e6db7fe707947075d17c1de4bb1a712e4fddde31524379dea95497b675e4e70f4e88c8346807fff1083bee329d4cacc54d8576886272f1fb35c692b8c9156041a78aa4f16ce24cc3c4d0e2ca7eb957360a5d1a14da3046b73f996381b3f67b136d46fe7386eb0b0e2468be7771a153567ecd9192628f71a9382a6f555cb77b72c959d79c8111133777056cc73f40e5f448a064fbfabc5ab6c30d9f67cc87e641336ef37ff498b9af98212dbf3775b8a67b7ad933b5603dcbd3d7f9f2d90cefb292835d4671e07ba8573f4b8b62c537178939a41c1658f09574de0037ddb4e24f179db751e477527382b627f6990900eaeafc6a5f1e58966d96c9bfbac6e931a514c235e829c5868c065fd430df2d27ea21be94525ea5ccd22688d56780a4a9c7e2072b1084aabdc7d5c5ed243d903f5c9bf336b033d9508a0e6dc3b06757d0b9dfa26e85305ce953562dd43fc12fa2e81dff81ac0a1ca5ea0d3ebc209ea11046349c092c16c7680eb9d67c6bd1d57ab10fd7c07d93e00fb0370c89002051e1774b1555ce048eebc15ed4af35f9a4e2cf4b77bc5b0f58a7ab7a12aebf3c3a935c2bb278d446437b91c8cb7ce9e9c49b243a18c45472ce601ee001bc9371fef9c03fb3cbb57b119f62f81723148402275b46f917aab7ab4e54e0846eb40c9fa0ecb8f0d3d66fcfd9b6b5198c2e6451c29458726322c02401fe154cd24b21ddc6a3c4a69b95e2cc0c971cc2e78403bdeb70ffa5a343b7075609dc38cf5a0792ef55aa48991ca9237e911c66ec493dd1de176b2d7496100c803b58e071ea1ca3bb5c0fbdafc7a0ae50a56dab64002232d4c464bb78fd809a3ab74271fba1287fc1b6ad5e5c973b06a6984d95f0dc8909e6695aa6fe6cf5c263f657e068bde23045cab2264066d5e63d9db8374681a46a5c2d3127b34d848e31575d493ad64f263429c57808c36028f27a7070bf519c32b99f85fdedcfebc8033d519663f03bf082654bb7a774bccc701da2d743229cde9ff252472b3a53430db42a4c6dc49c8fecdf1c70c872a22dfb29c273b131f4e9d2bd29f46c7c6f23c0bc05579ed6e22015b9cf49f6705b093d85b5751d7a62921e5c51ed9c6bd0d4c44c3545e308712d5a0aea11399835e093bb606288b360e8d2634ade12876d5ed9cd264f5f1421f4814299b734c9c914d5157afc2bd558c304e6ac81a320098acbbfaaa689cf0ecca3c82792961af28bcae17f1031e819944dc4421cea037d2864db5878d9c0062345675a61f704e540182308629d07466d4d38c41f0a7271bf21480edf8bab7a9545a0074018764e81cddcb91c86722fdb9e01c9f7398c91df65c3c78e0b594a9a04f02b5b0a52f0218e68b1f0dd0a268c6f380b8c01ef821617f45fa651946027e35e8d9e05f68db61ffaeabdb693235d00b0bb4e964a6185a853e7457e57fabb2f25ab3cef8ccc20bc1132590debc6dbb9f084ac49f5e5d5228de6d216a2a5b6ed5e890c2ca726397838d715328a9d18d0df04de167613856bf09c898ef01855fccfcea1b3b0dd7e7b0fb209c324d1e4c9d6ee346eef81ded4325c7b0e6521857e37cd5ee076fdd8f778579f9510bae6d7501de64606d1c1f6f6bc0f5a64c1b9431b5ef24214e4893b9f7b4e182b81cbb3660974397716bb9bdfa90103c192758a03e25e23c35ce10afc0fb631be50eda1790310e9cddaea5e4426036aad63df8265c73b5571dd8379cab5ef759de28b5de50ed03f04135949f2aa6d1084ca796034ea187246ea8c8cbcb5c25974c1408d889d35bd7f90e8eebc6ea8c08fe26fee6bf5d4044472455ced150c74c3ce56657f1284c4478bfd5d2bacae3587c3055055813caed2e8ccba2739c0786754a7737a7ae610d1f4801e8d1f3ecc682f4303dbf0bf6f7510476269f1b64b15f2fc49f3f7e7bf5e22c2ce2b818c2020c19fcad9f2346aa15e7ce970985ca396ed11302bae047101a6bca2d2b7dd22cbc6cc6bd656fd56fa43f6da509d781277724f70b9751abf5f7f2a7fd2be6d0aef4b4b12fda505852f2da92240b2f9c588d711f48e6d6030b28122aab5d58e362ee06360ade5a25086666ca6734cb1c1f260003a1cc7fd7acbd48aec5c9f37a3f1fc63702a4080e1d3b33573d6a545b94ea917cebb0735fa227268665cee87084ddc1eb6efb789c72eba6c46fcba0c469fe98e1694b1fc7bed5d0da758b822c70a32f50fe4e4109ee27cc1fb6f3c7f62b7631f2f8034bd413645c07cf80a4a5838d8e1dde89cdb5028bb2af3d2131cd750f25a2c6d6fc8bfd1eade07abebec83a66f1c30c3916776234b86eafa6502d55dd714bb482073ebf6ce89ab2b339926fef55fbb125522fb8ddd00ba9dafcfe0440d53796cc22df0d01ee14e1341dcd0c13957f5bc3f20c89990da0da30f67d3dc4a0fa259a084007c475e302d3c7c62a29174157d9c56c0b40d03d81c53ee6e5e1144ba16b51896837679ad73519ce977be5ed20cfa7c70481242f4d8c1b42f34f2b06554536c59e02743f9f20d9fe131a2b8982dca65216374f29a0a1e5478b009d51511448151fabd889f360711a98c28749368c3f1b59ebfa268e5a084286ef550f72ca5902c5436e4551209b724675d3dd97b610d8d60c57181a14466c595d6fdaf7cd5e8c6a9327e7b0ada21429d399957aacf62cbe75cd6b550b1eee8df3d3122574e7b61925a7c469b4c1105411ee2362a7bf912c833bb9fdc4ce479ac1f0941bf8935ada689d9b2cddd76490d35914981236eda3336c2127807eb652cdfa0f23ebfd5af57a9df6c6ac52ef2ec4423b61e4e66cc52908296dd71db43308acbd22441bcc1237b7d34f4afe1892df61dc63abb5a5c584fbaf696d93b734614c575a39be4eade166e483534b702b0c264207c7be8633f5386a60c033942b26d793c7e868205ed0c735aa7b36f8375978fb76629cb2a628e45f4fc81be3e4435b65f6844512a5f12a0e1882b4e8d9109b97a0993f2b48853a4772c7d03b83043c8912fc317bf712dc6ed61de8828ae250eeb78cccd21434015e0f638948030ef39eebffb4df2ca0d26cb07e459940ee2f1eb64d4a840497ba0ea516c7d64d1d4ca74237171bd47caaf03c46a6d7e26767194df3bee3cb49c038f7a6f170d45434591022756a39b78c3905f44ffba8bf3b0a3ad157c10f2fc4e7869526cd824ac17a72a02c2aac119c69beff3890c6090a993849762799b929529137c234baefc0c691848 -e5013484b4ddffb7ea64a4256c3ee25eed351f888495e0df34b7cdf5db0823d20a0a0a0a0a0a0a0a0a0a0a +06020000016c5d73d6328c96d856b0d1ec9856580b787d5e56963cc0a798b8be5331b183a894f23e2ce1ba672a20d887f6cc884c3dd02744 +1377e68fb1d2531329dc8fc35716132db947295abeff7d8644417bbb5ddf2319b4fbde57c50759adaee10ee8e60400a2d7f09a7c0d9934f7 +575a00c44a41692015de80959d9f0b0edee6e70037423b98733b5e1d2f892e0ff5f2e0bb364d79b6273856553e4df0e49e19883058f16fdf +698345da73ee561ac8dfc2a38c3c594c5972edebdd056923be21749da4b089c8148f73ac9917bdcd6976ce17d77b919dc56daf21bc544aa4 +b3c6113037adf103e9b74a440b720104860a420bb211612d62da3a1327d2c4a6cf04bfb54d47218be9525c428078efa9241e6a42667e9079 +c91251026c6862575af5c40d7515615804816dda6c5495e6f9abd5c77e638925b28bbb0403d4f1aebf7d86bd5bc73a22a41fe2c7e19db79c +404fd3db43c8d87fecaf6ecf163f74659768a4c6cbe562d8d14b8ce4710b9164a3bed100918d9872aaa9bc9a448b117823f1e86fe9ba9ac2 +7649737ea497c83efff69022e6db7fe707947075d17c1de4bb1a712e4fddde31524379dea95497b675e4e70f4e88c8346807fff1083bee32 +9d4cacc54d8576886272f1fb35c692b8c9156041a78aa4f16ce24cc3c4d0e2ca7eb957360a5d1a14da3046b73f996381b3f67b136d46fe73 +86eb0b0e2468be7771a153567ecd9192628f71a9382a6f555cb77b72c959d79c8111133777056cc73f40e5f448a064fbfabc5ab6c30d9f67 +cc87e641336ef37ff498b9af98212dbf3775b8a67b7ad933b5603dcbd3d7f9f2d90cefb292835d4671e07ba8573f4b8b62c537178939a41c +1658f09574de0037ddb4e24f179db751e477527382b627f6990900eaeafc6a5f1e58966d96c9bfbac6e931a514c235e829c5868c065fd430 +df2d27ea21be94525ea5ccd22688d56780a4a9c7e2072b1084aabdc7d5c5ed243d903f5c9bf336b033d9508a0e6dc3b06757d0b9dfa26e85 +305ce953562dd43fc12fa2e81dff81ac0a1ca5ea0d3ebc209ea11046349c092c16c7680eb9d67c6bd1d57ab10fd7c07d93e00fb0370c8900 +2051e1774b1555ce048eebc15ed4af35f9a4e2cf4b77bc5b0f58a7ab7a12aebf3c3a935c2bb278d446437b91c8cb7ce9e9c49b243a18c454 +72ce601ee001bc9371fef9c03fb3cbb57b119f62f81723148402275b46f917aab7ab4e54e0846eb40c9fa0ecb8f0d3d66fcfd9b6b5198c2e +6451c29458726322c02401fe154cd24b21ddc6a3c4a69b95e2cc0c971cc2e78403bdeb70ffa5a343b7075609dc38cf5a0792ef55aa48991c +a9237e911c66ec493dd1de176b2d7496100c803b58e071ea1ca3bb5c0fbdafc7a0ae50a56dab64002232d4c464bb78fd809a3ab74271fba1 +287fc1b6ad5e5c973b06a6984d95f0dc8909e6695aa6fe6cf5c263f657e068bde23045cab2264066d5e63d9db8374681a46a5c2d3127b34d +848e31575d493ad64f263429c57808c36028f27a7070bf519c32b99f85fdedcfebc8033d519663f03bf082654bb7a774bccc701da2d74322 +9cde9ff252472b3a53430db42a4c6dc49c8fecdf1c70c872a22dfb29c273b131f4e9d2bd29f46c7c6f23c0bc05579ed6e22015b9cf49f670 +5b093d85b5751d7a62921e5c51ed9c6bd0d4c44c3545e308712d5a0aea11399835e093bb606288b360e8d2634ade12876d5ed9cd264f5f14 +21f4814299b734c9c914d5157afc2bd558c304e6ac81a320098acbbfaaa689cf0ecca3c82792961af28bcae17f1031e819944dc4421cea03 +7d2864db5878d9c0062345675a61f704e540182308629d07466d4d38c41f0a7271bf21480edf8bab7a9545a0074018764e81cddcb91c8672 +2fdb9e01c9f7398c91df65c3c78e0b594a9a04f02b5b0a52f0218e68b1f0dd0a268c6f380b8c01ef821617f45fa651946027e35e8d9e05f6 +8db61ffaeabdb693235d00b0bb4e964a6185a853e7457e57fabb2f25ab3cef8ccc20bc1132590debc6dbb9f084ac49f5e5d5228de6d216a2 +a5b6ed5e890c2ca726397838d715328a9d18d0df04de167613856bf09c898ef01855fccfcea1b3b0dd7e7b0fb209c324d1e4c9d6ee346eef +81ded4325c7b0e6521857e37cd5ee076fdd8f778579f9510bae6d7501de64606d1c1f6f6bc0f5a64c1b9431b5ef24214e4893b9f7b4e182b +81cbb3660974397716bb9bdfa90103c192758a03e25e23c35ce10afc0fb631be50eda1790310e9cddaea5e4426036aad63df8265c73b5571 +dd8379cab5ef759de28b5de50ed03f04135949f2aa6d1084ca796034ea187246ea8c8cbcb5c25974c1408d889d35bd7f90e8eebc6ea8c08f +e26fee6bf5d4044472455ced150c74c3ce56657f1284c4478bfd5d2bacae3587c3055055813caed2e8ccba2739c0786754a7737a7ae610d1 +f4801e8d1f3ecc682f4303dbf0bf6f7510476269f1b64b15f2fc49f3f7e7bf5e22c2ce2b818c2020c19fcad9f2346aa15e7ce970985ca396 +ed11302bae047101a6bca2d2b7dd22cbc6cc6bd656fd56fa43f6da509d781277724f70b9751abf5f7f2a7fd2be6d0aef4b4b12fda505852f +2da92240b2f9c588d711f48e6d6030b28122aab5d58e362ee06360ade5a25086666ca6734cb1c1f260003a1cc7fd7acbd48aec5c9f37a3f1 +fc63702a4080e1d3b33573d6a545b94ea917cebb0735fa227268665cee87084ddc1eb6efb789c72eba6c46fcba0c469fe98e1694b1fc7bed +5d0da758b822c70a32f50fe4e4109ee27cc1fb6f3c7f62b7631f2f8034bd413645c07cf80a4a5838d8e1dde89cdb5028bb2af3d2131cd750 +f25a2c6d6fc8bfd1eade07abebec83a66f1c30c3916776234b86eafa6502d55dd714bb482073ebf6ce89ab2b339926fef55fbb125522fb8d +dd00ba9dafcfe0440d53796cc22df0d01ee14e1341dcd0c13957f5bc3f20c89990da0da30f67d3dc4a0fa259a084007c475e302d3c7c62a2 +9174157d9c56c0b40d03d81c53ee6e5e1144ba16b51896837679ad73519ce977be5ed20cfa7c70481242f4d8c1b42f34f2b06554536c59e0 +2743f9f20d9fe131a2b8982dca65216374f29a0a1e5478b009d51511448151fabd889f360711a98c28749368c3f1b59ebfa268e5a084286e +f550f72ca5902c5436e4551209b724675d3dd97b610d8d60c57181a14466c595d6fdaf7cd5e8c6a9327e7b0ada21429d399957aacf62cbe7 +5cd6b550b1eee8df3d3122574e7b61925a7c469b4c1105411ee2362a7bf912c833bb9fdc4ce479ac1f0941bf8935ada689d9b2cddd76490d +35914981236eda3336c2127807eb652cdfa0f23ebfd5af57a9df6c6ac52ef2ec4423b61e4e66cc52908296dd71db43308acbd22441bcc123 +7b7d34f4afe1892df61dc63abb5a5c584fbaf696d93b734614c575a39be4eade166e483534b702b0c264207c7be8633f5386a60c033942b2 +6d793c7e868205ed0c735aa7b36f8375978fb76629cb2a628e45f4fc81be3e4435b65f6844512a5f12a0e1882b4e8d9109b97a0993f2b488 +53a4772c7d03b83043c8912fc317bf712dc6ed61de8828ae250eeb78cccd21434015e0f638948030ef39eebffb4df2ca0d26cb07e459940e +e2f1eb64d4a840497ba0ea516c7d64d1d4ca74237171bd47caaf03c46a6d7e26767194df3bee3cb49c038f7a6f170d45434591022756a39b +78c3905f44ffba8bf3b0a3ad157c10f2fc4e7869526cd824ac17a72a02c2aac119c69beff3890c6090a993849762799b929529137c234bae +fc0c691848 ''') -# or this one -# 0602000001E72BAA6552C35B698AFE87460FF890BF30C46952C19DE644537748FD64D8CA99276A54F5FB1A85BBB6043C8F49231BE49436258094627FAB3A519DCC76CBA08FE89C1066D8F72468A3084B4CCCC42BFDC8612C36410087B5F5719703D59CEE56D6C3B482229A1C0B9AD6BD8C5E16DB730B1F11D3CB5E12EB9A020E759261551219DF883FF819DB1ECD561BB91246CFFD876589690D7B9D1A205366E831B598436E26CE1A8DD85EC981D4CA1CFCC8474B672C29EDA4C01F5D0ECAC968390B2F130CCD60FC9BD97AB703646B24A404C8516F24435273F16860E2C89E1D05075CF0A6BD646EE2C1732D3B87E1BAE8CEF812CEA2B60913CF6ACD9DC14D6A05DD8485B63B126062FAF1E6EF2CDFD2D2DBCC0F3FC74BB285D8D8F3777ACD6D2497DBEF869892F4F54AE79A1AF669C11E62F5C99CEE306FBECEE48C4C7A5A272B2D20A9E8CBB0B25D4B2EA0758F73DA7C7771CC38765E2972DC69730F57FB5B50695EBE338F36781F26DDA1D4013AE2EB015029BF66566D36EF7AC1A3DB6398A09D8628F837F459C86E75EEBE66215B67E1580F6CC96897C58CC0C8CE52A3BC3D2C379BE8E7C75052B98248A8D4D0C768F3B92113A5BCC18950642D110445F510AB477D9A71B3EC626F53A1398BBD2A4ECFEABB2FE3568CCFEF60F5C54EAEFA16334D733C5A4C463A26FC99B58BD456CEF5F8C7B470B4B80DAA3E430CBC7701AD77D6377001B6FA5AA4D669CEB2AE178AC2645E75BD293AB23014090BA5CB046F2CA4C0BCF72A6093A3E3166CBF25C5FB45C8E00ADED3F4A6D3262D2570A9B5470FE3A4AEBC8BBED696890F8B3A920AE1ADA6AA2F185A87A0B36062F3C36511BC9017B49FD2E1D915D9DDA77E4C384A614FB98F73E3D391A337883A5EF3D47564ECDA695E7AD5E03BC45180DEEE3788EBDC70CA3CE1915F4C958039DF31A0F69DA2F72E6F7A780F534166AC093FB00948BC7A773DC6E99D89257514BFE5C3A7CF1AC57F0563AEF47B08F1957D5B0927D49E327E140165C5BCECA0BE58869EC21D834DF032BDA78D26BF9D672B8F38B5DD43E6A378C63393770222A45522DDAF3D594B46DD46798C8819B99087F4CBA860B5E43BB7F068C76D0AA73FEC4E0C1628CC28914ADEB9C4EF197DCFEB0D016E4007CD74975F85E74B0C5F22E192A486163AF5642DC247DB653A7204AD8A988BAC30715F7AE6AF95141762E90E3F4377D7F2A543D7F5560BF14DEBD1B6C851826943E59E8A0653F0C1D8676989D8CAFAAEC2BBE497E4A49A22505FA802471A21A89F46B317A359D9CE7E7552B44932307A36A53E993072A727BB570B413F8E0F9FECF8F99B09D80B809E1B64A21DF653894FDBF7A70645629BA1DB00CD2BBE0BC65F41C4AF5AC87EBC761021396D7EDBF3245B1D00C36CEC71F3779C3C42DC7CA935CB9C4EF9D03BD1F020CF5C9A877810C68736DAEBBBD3919A31BC7DCF15C8E46B97118C84369E1A391EF88D090A88BEFB001FC67A71CE0DA645BDEA7B421514AEAA3A2B2D2E47AD6D86839D295573D75F162A8FF2D74A108296FA8C701D597941CD92953090F5EEB771129A5032DBE652DD3A2E70B4BCBD5BF97644C9436A3A32776888B47D3829CC85ACAC0D84DE432677746AFC9F798E0830B23083340C0A91485B1D6B5CEB74EC56C05B69248197E8B62A0AD6BEA3E29A81634360E94E7A6AFDC9E7000744BF70254E21C38C8D4B242A2E33CA3D860E34C0D3309920E354725B77A289DAA3F57424CB57F62DC9DDF5CC413CDC73B77D8D92963446C3861029ACEF991D1AF54018925381E8C60F694988B5AA76BD07E037FCB2B73DB4D76D96F77ED521617243C5FEBEA61529089A1EB09D573EBACE193260709B9574B2113CB9AB86920777A14C67A505FC9F6770981E89F9F4F8B3E9216CA8EEC38DCFD3E07699CE5ECB0EE43D7593D0566055BE172E1907071208CE3865F2C7011421D22FCECF3CC425ED9AC7D2959B0032640B0F0614747E5A0C8CEA2B4B33393B35B88DBA761162DB45CB181F70DDCFC73FA8A3ACC35C5DF433E3873AD3FE75C85BB3BBFAFDE0B950C26548631C056A389B6D47650DBE53A649B50ACC96D73C6CEBF8B6FA132A5BDC2873F80942E745E91BC63452301A021EE577CB45B28F3A896389B470463F1AFA2AC873DC777500A23F9BAA965995D0A5000C084ED5950052B58F9F45FF4AF6B4498B31AC162A83E58C19AD9EC045FF51CE001316752C3AEB5089180864186AA4F88474F2A8B1026186ECD325A88DC4B5B445A2590B824C6B3BDA7D92513743750774CA9B281BA589396E0190DDD09ADEF97787F457347AC05AB9D28F1FD90E02A434932305BF523ED7CF976989102C3D210207F5A12E15609FFB5332F78442595C7FC4F40D1B5E4C8C987402C629C3D41CD6C08E6781BB8F50A7D6A866D6127A76E262BBD074017A82B75C1E4525531FA9ABBF7F47C472B5B21FC3C07BBF6936B39237F180324B9B16494DA2854F61E3327B117BF3333D0CEAD769C3B4314847D32123F223453E6BB47792A880469DE97868231E51EA9F4A144EB4CA9ABBB1AF5393EBD0926F5928D2BE14C735A3D128B910C0F3B9A10B4C6F56F30598A07B401CB46F20541C40A49CBD9F549117C329E654824D4FE27B695B9F7FDC802F98ADFDFFAC939095B5863DF2639F9B9A2BCFC2F5A215F10240D40879E2BBF91749ADB53BEB4F48FC314049DD88231F792CDDE6FD18174DFCA426216ED14C6285E76A88A1453A1A3F0E03E09F1FB5FA23C2499560CCE301226C67B4D8309C70017FB9C27ABFBA81701571B32796835235CE02DA7E3F1AD43DE5D4B25F9A8E3081A1363F1F3D03C8850F850E15E3391146514F82A63E128CB1C367BFD8D4E442D226D4AFE0DEED3FD6076427E8123837915C3B0607AF5FFE2EF40F50653F65C4E1E36BE98AA26D25E8CEBF78309BD8BFBF27EA66F0FF87C55A36D2C12DB1D4E3AB745FE6AF0773323F59E8F99507770ABEA182EF6501057752D9E02A0B532D9F3C9AFF826211AD30E900EAB4FF6B888C2AD85D5CE8078B73A53666083DE547BBE7BF79F9916301FFD5D8952E9594CD430DB09FE3AE51E14534B316062922F031BE85814C283677023155589EC39AFC7812B3D45D3EC34BDDE0CA212FA58C77EEA9CA6B1CEAA0F2E906B754870A958F7770359DB4BFCE0B755007D02F682F24CE018A03F74B860D3F2B516AB81F95588DC8A3D7B4C159DE846884698A5EC244A2DA4F3D3B2FFEBE99FDB65B3EEE767ADCB5088547E6DFE6419100B2B371DBA53C206993B659371F2CB9A2C6713C0E692FF4F793896CE6B4645A784A59C4141F1F8083EF9E7D8B58C6041D7D3B79F77C1EB2338EF936F4736A648077A8DA592327D27CF3E5C100609F89F4B25D45E2C66729370B120C7F47910B575CA8B0358D98133D10000D971B758277BF86D4F11AA266FBA81EF6F0A4EFA9EA3F7DB3191B5ED31BCBC105C03BDE7F9F20584E4DDA7FE0A41A651A553A1049EAA6995B6A8FB6EF6E6EA0229B8B12A1578E60D2AA04ADACB9D56B3F32688D7E3EAF5F3BF7A666EBF650B70562D358EA1AEAE424733CFA610304CD61F1FA095EFADB5CCB631E8D39DDCC74747AD526D00D709485111F83BC92B66B3397B3A6353A375E1443187F900E7397736548686A7BF0941617E6A21050EDBEACC77F35634A5BB20A5F65A8C88EFC99852EF78C2D600A766FA615DD7921FAD14569B50EBA6CD2A8965337E351B45514907DB42BABDC0B8DB4162A9187B4020FF7385076981BE0B50F1A8EB97B3E65636EDA7BBECB8D125CDAD3A02467C70BAB1031D2715B353833640DA60D031FA964CF470CD5AE1C1E173ED41485DCEAAADD9D2645A113101A5EEBE2831E54146837692EC47D0AEE9D430AECCF3C4663EB689350311FDE6A501C248F10FEF7FE042A3BCAC0C1580B3F33AC085652A26E50EEA131914D24F72E5384A11710DF18BF9526360204865324D14F2E9A7B34D1AB75A7161B2CE19E27D5034022CBE156D2B327B01DF381F3E3767B6AC281CC7E817AEB38355AE62C5C3CA5CB6741D1AAF8C72173040E0426D051EF8B788F2C2A82763B989B589424F60BEB76B386C890D108F14A2A8ED48995DE65E7FD8AD6206EA5778B2A65681CCF66AC7DC843A3312AAD759A6ED98F14F6AD88173924FAFBE578B0F72B04CF9318F313AB3072BA17BB752565B1239AFAD0501FD652E4EAD4C7AC366FAC65B402C526D52424C6A5E53DD966235955CC8C257CF0ADC9E851B5178026EB6D2539D9ED6AC22522D930D6A8A3E90345BDEDAC9991B2E2C3CEE0F0278C08E1A222458469B47BE507D6E67D6DA089F134384FA91576154A716EFC200C8E6167C5F798D8031895EBAA36C7DAFF83D9424EB1A6BEAAD1DCDD1DB0823899E9BC0738C93FAA6962C38E9E83BF6CAF8F1B001B0707EAFDFBEC385C6DAFC127CDD8EF58384C0D4CC34FE8399B0E92DC2B7E04E9CDF89CA6F7C365B984468D2A12B490FA38595405F4EA900B235C515A7913B693E1EF66F1981DA2C4A8969B8017F7C78F29870F2B35AC4839AE18BBC44B4C51DCD3ADC9B70916BC8441BEA69957820FCD79E098D868E79CCA8DDAAF46F789A2220D2D24D9F0CDA2C6AC67AAE0D598C0CF5F1956A7023CFA24CB9E353CDEA28B59EDA4C6980A850162DA4FF725622F5A5119F7808B42170521E7BAB242E06CCA5C0FDA5F18E5F3DC10605F7F69FFFD7D127C6D68956977375F22025BC76BF00F3102223DA4594FA9FE9C2FEBB7E64EB7CE20DECED080EC11FC5C7FB177CD065596D4E7D24F316B2F4BF4D35EB07C3CDBA999993511F6D493CAB670C41D79211DCBE6B280A3B943F28F7AC54F9C181BB15991924E091C9BAEC1813CDE6A51A8C6D721CCA0D9E81223601F1E49BF039010E42C3799B16E8D95C0B0089E42101E23BC5C660D72CA5DB99CE8748202CCBDE57B94A34E324D3204FB38CB60D8E2194D0AB7F7790F1DDC6A16EBF6DED83D1B6A40E63F7C07808305E8F5304E0A3886F2A8D6A9D6D95BFE1982DA213BBE0B49EF24F735A85A3CCFD7FEAA30B0DCF4117FBAD950CB79260A8EFCB9A1082F1AA13A143B019F5DDBF43ED47B646BE623032DCAD5D51932C209D0B21C267914C226D2DC760397B48949422C7516068A265FD1DAB8FFB2D922E77BE084DF71AF51EB9504933F01A78D0F772FB418C8E14EB1A2905359CA5FE9486024D28BEC4BBFCF6228CB863BDA474FA9BCEEADD583E7DD16CEE56E17BA3985C91E9F45C63BDC4BBFB39CFB23DCEA58B457553BA22F341610CA11EA0F9DA43CF01CA5C942D770999006CDF981DA70AFDFA676A616D63403BB02A4725BF716E137E3938EA350D591A7F6F06BCFA991547EE32C6856AC9795075F9CBB68A92861DDCB4F21C8E72ECE8F19E055679EDFE22BA48CF53B08663A7563514007039E4B3776A0FB97FF9EECB0743E851301DA8C2BEB9E3BD1484BD614A767093BCB6C922172CFBBC1B79FB53081682D862819FFD7E635548C5E29E0839D923ADC9E313B0B101217A7F97020F14C4E63ACA044AC6E47113798E49249D7AA17BCCD3A2824D188D860EFF1D8BC646EE994FF48483B04F90EA818283F7980EB34AC1FBFADA9D934E44AA2246469E963D3E1D805294CE1F884DAD1FEE050F3D998F961A7608D8675F4E18B57300DFE38D30D0AC9BC7D5A83799FB338E89F966FE2D0316385D5C9DE8988DCFDE2AB2BFAD92E4E0D123D3BE591D0E3B5B426BF54EE22ABE14D6B1FE08AD8DFCEFAE54BA1BEF0781031DCBB8AE685691995876D8521E66BC4652A932E0701BBBFCB67402F01F4292A04CB33B850B3A84D49577B22F1DD5F26C99E2EE5F09E37DA8D61EA39D7424B4E85E42462EDE7BD671F7F63A17BEC6D7C9E27B563E4413C016F5D08B1113C18B3B3AC9548CB5D2F116B727FCB2A15221C779F178F5AF1FD3E2D39EF21E81DE3DF47351DE3119B9810D3C75A2AEE8F9B9D84B20B5A13B1642DDDAAB51DB65C5F1E828550E4244E0E39B4C74D9D44E1BD495A218A73964B36FA57AAD3EAB59DFD9400DE11EA216BF79C529D8B7D78D458AE873B5F6C1908238C26FA473E0A365DE20447C6EB61A20F55A6F20BA5BE60DA32CD59D0CC7693828D544214F83015DE3696A5EA7DEA104502976F5FFD3C8D43685F69895F42DC349A954A7EC7EFF63501FA40EAB2C829601F00FE4892BF03334EF370D0BEEB8E6FDD432F2A1EFBE3F7DCCF1E2CC8FD6C9DBE0A91D2370E2B71B21D6139A48F58074F3C0E9D5581822B2897BB7E2CD26EE4BAAFCDAE111145E51DFF472C01EEB6E3346F3309F23F2AD8D6FB593EA1B52D7AC49EF9B0B8553AF76FCC5385E01A279F023EB10AA39C3015C091DC703586E2A3A080A229AC3FF0E1CC6CDBA396FE57C5B53875F767110C8C264B4C6C1341FC4925BA382DF4D73B3ACA12F9034912790BFF78D4F01873DDAF046408E52A142BB80C71CABFA0B3254D1A0FB75EB6B8288C50B69D39CF59F5753333ACD0DE76E7786EE3E03342B74025054B45FCA8DFD1366BABA2C93BADB2E2DAF74F5AFEE530AFBEBCB97517EF3F3336FD42EFBD3D49080EE15A5B5CA4EA4555A59FFC428AB6A1E5BC6CB08602889508F5136CE9B7E8FFA91E7A1941EDC8B82DC2D1C888A2434FE124B13B751CB773BDE80D59B4AD9D5DB0F59468354CA41E9FCC95968951A49F02BF1C7B8F78A682E9861630B0B4B9DD10B902F015B3F5ACCBC5FBFDF4EC226E5D4F467835C8042EDDAB0A8509DB77B9CDA6059CFBCA228F0DB0E1E6B6DF5C8795420E5779F3567AD3ECD680DC5EDFA826BBAE58CC71FB078C140368519539CF28CF6FD7DFB4C41DF6C447C8B9E4D207FF7E5DC94C3CC29064067677AB7D9DC2C9C5D61BFE74127248B1C601725A7911A401855800133CA4664206623E2C762BC6C94AD435D4AA6B2D2D6EE41F7A26F599E77B83D5C205EA05EDC1661DDFA70211B9151A22965E9AD582DAEE3FC758E4005555EFF63BB0AFF03CAA53A4AA49661D1650455519D64E315C73348C479450A0572A6BEA288B0B2C4B07FBBB4ADB187349D9F95368D912A3CA1E30B6FC51ACB11D732411380D6B8F516BB47D625A5B3379F4F8FFDFDA747F291FD6C2E51EF3A111955F31E435B5859814C88714B4DE0B4849E5FE78441F6673EE3B8B96E20FF16CF241F1185B5A0DEE82F33D972C9E35EACC758E8489EF8456DF5A7FB550A54EB0D57067B8CB23CD0137450F2A9C9D5325246FDEB8B995BEBAC248D171361EBACD3E146AF58BC56602689466D4A91FB01BCCDC8B1E9F72C2FC45D5E6F38E66443AD7970E47BBCC7E6C3C3DA1D27180E1EFF2DE45DD4E0695EB7FA09838204BDF9E2E7611F6F269E6C23D01A15340D40469418EB2A88DC91EFDED99D5D64808C2057B5CD6F5EE8EA2112BBBE70164742F2932C85B6859591DC9B66765B5D9C3D3F22616DF5A93BA9AB6F4D177BD1302D5D2C31BE5C2C56B7CF0183BEB44E818E65D19273E7877B3698BDBC2B7A84F7B539C8EF7CC0549DF5AE1B3AE8D7089E93BFA861B729C2E6C99C13D0F5E3205A2287E2A1544F22942A436053B6AE1574BE26CDE8A7ADF73BCBFEB51CBFFC56DED0F85D4D3C33579F39929787B0E5572F5678509B93937458ECA0DCD2238449688DAE9BF2A563B952FB8841C60BD97EFEE72A532589D271774367974835CEE5ADAB5B378942462BF1E45AE0D3B10FBAA6C72A53120C54536E8A13DDA4562CF83324A27EE1507E917067D975226BA8DCB4BB6B47648592D5F9F6E89930CFA89C2BE9A00553DAE941149F47C1989A1C68628536CE0A31CE9A5889563EC91DBBE931C94EEAF8A512FF0805CC6B85F9020A1FDCB154C448A1A93A7FA10199E5A9996A93EBE8990D40B8DA455A528ED791BE34CE917F01342BFE8E894476E2E0EC9A8CE4E29309A1C55C5F0DB69767E54C52979A7D36F120D7EF93A7EC7DA96E552FEF6136DB21781896ABCE19FEB539E3C61FA27E23F0F03A4D5A01249F7033852E34E59245CA284ECC74F913B9C5731042ABBBC3828C95F23E237700490D441BC7A69551B3E67EC09045B072368BA09C15D4BFC6B15C9701CF532D0631B7D6E4129FCF75379657D7E74BDEF5FD284DB63663112C0CA43B00924D655AB4DA9C23AE7A8065F58E3E4E8C018D0463269C8EB66138C4017D349FC1E1C3CBA1F5875A6BF449A0F15E54158C4FC80098E587804C6E07260E25CCB2EDC3DDEFF4960F29A395F64657035634D32C5DCD5DC9F4408EC155FD8145F40BB7AD615BC50DF254A844A44CA57E869918B538CD61FBB2819813093FBE569CE567FBF69CECDD8F2E66F320E09AAC29F1D8900CFF985EC49046CF2B56CFF799B174623FCF58499084CF6E0268F46CD587B55909C7132C3B68A1267864043FBE463F036C0FDA0131B0B6E3CE9174F2175A840180A5BF1BE8C5A9C4FD82FAB283DE94C1CE6D9EAD2AB7FD4FC351C9A5A7A89F6CA77BE0C183E60E3B10E2A4986AA58F6EE07A707BF1A6F607B5CBF12D72681122568ACA17661B32C72EDF3531F6ED158F28438C6F757956295573A2B4796C6A469CEF3FE3EE3FA7B321A9A1CE3AF7D16283BB2D0A4B316D12C82E02215E3C8B264200EF6B30EB920B0F332F2E25AEFC8C83B6921BFE2A6B02CD6EAA107535331CE6901AFB49B4CB81E266FB7DED0DB05F8DAFC1C0B5D32362E7FB827960CFE156D77D62BFFC1F3559E1BF773D01B8145ACC6267FF699FBA5AACC261BFF632568A2E01ADDE2535A4944D31A582C2960CD6E3C20D39DA5D23DE88E869ACDC69729C1ACF09A6214C05107104D98CC4E7E7C0F45F686202BC4086EEDB0092CEA1CF4B302969755FC53DAA6A11E6DDED633586149CE02B2ECC091DA574EAA3F179CFAF8F251483E46538E4E138B1A5E8570BB4345E05369AD45D32D57720A9FE59E06C3045B29F4D81626665DDAAA59076B306D5AB6E337A095E4FD9B30ABB72B90FBA9FCA011E3832D912A1057603D8422652D9F96BA69E35BD2C8C9717C67E248D90E828F7911706A6712B23BB117FA1D8AA053C714185C196ECF614AF2A9442E244B64F2CB8567191F0A406C0EADC3D3227C8EF7700215C863DA4DF2A9368A23F2EDCE3296266039EF178E38BE6226D7E0F0C207F6067274C4022A4DCBC9A855E2EB2ED6EAD1C9418709E2822DB4DD8181C36F32239309B83A83DA0F30D8D2F260202320B0E923CCFFD92B3C031A8F635AC9F7BAF3A92285BB7D52DE0810B3AF3D91364F1962A1FB321F69A30D557640B3E6BD68BBE8F28669C1E57FD02246321C38508AAEF0A2661BEE9CBC05EEA64EFE9626502059EB173E30419CA6FDFCE7A44BD6EE19D93B22DF512201A9AA83C7F54AB01712E33069A5E91287FD55D15BD48D109B20ABCCCDB6895AD264EFF702D454CE9EE6765DCB6354A1D924FA713046C717EF892E457DB3E74632AB66F8605A92288949481440FB4B37A78364BF0B63F00779550217824FF2A3FF27BBAE1DB9EE6F849D737B8D99386E160381BEC4B8AD6BBF619A60DAE3FD13553E2C7FB43D127A265283D219B15478F1AEB5CE764DDDF2442B4217A06FD78DDB8BBB550ADB043E2B72842FD0C590D56C5E594762DC4CC626959247C7F6A2492D12AE5D8EBA3886D69FB73BCE296F43A0176B6EB89AC65BBFFADB862B5399F6CA617200A1FE65E10ABCE556401414E683870DD44A324078B3C1D337DA65FDE0FF7038192BF84315C97FA79A616DE3AE866E363267B6F57FC7055E1C4BBE5EDCD51F2C037B8FD208DE92E78ADD480118D300A6A4AFB29AB6100661B0F1010E246913B7D5391117D686062974B719716D15DDD3E9606C305D955E9E1D2602F46D3559D6C550ADF6B4C5B3FEB7CE5EAE16171686B3CD2DB3CBFC848306B1EA0CB3C57C6F6C0915EC4F15685C280681A3B887D6F949C488B8ED82C1EDFD08EA93E690CF8D5A0AD21CCA5B1D2348D37DC89E333E64F04F5022320142753FBA9707108B3BF7D9A1320D199E990F6CDFD8A79B78BD27DB5798B10A25F308865E9D800EA0E425216D37B58077B7F8C7D4FC37890B56AFC05CC6884D14A4BA7AF11979317A9C79E14C0F02734E24B66D9DF624E9502079B35F8868A1BE9B3C94FEEDABBB18769F827191A6E229BC8AB32F928DF2E253B45AB00D48B6E623F0B9E786FD113E3F94366D710F4D695845A48F166D2C7D407FA0AB0CDB9182AA3B8EE950615D9D0AC3A0251203F4FC9BA1EE29F3BADFFC86F4B9FCD1D10BA77CEC163A3EAF52DD372616FD54402196CA55C1F92D2C1454F7B7708BA2CAB36B10388B91F0CF0272A1E574FF98164FB31974F78FB7CB392A7A4349CCAB35BC45D6BEF216E17F3038EACA96F392FF4DE441AF1D48430233ACAC955C9253C6858251D64DB7EAE1D3CDED5DAE042C1A51AA78284D512730A8EBDD3B776E9A33FA9AFEA613AB5A9B09174F5480AC05F267411DE7DED53C0AE1058B0388EA3959A19FEA3F7DA8EC016191B2D092E405D9EAE79154D3C9A92857E766EB8E9D4C42C23A0A6F449203699A404C443F16765AA41B66851DE35E6B7059001F62F9F4D7CC4D6947F9C731EFE86A5E9918E0EECDB4B7800FE643F66A37851092D51C741D5427090EE0D81CD3C6CB2DF43100A40F1480270414C413B984C2A921579EDC13F3BC30B19103E015E157625741F078456B195CB6B993FC86BBA4DEFDDD8F149FE60F5AB4B6583471176A0E2A2D76795135DD2D8D4CD141E280DD2313D4009463FD8882A117CEE405B1302CC6EA2BC9AC9107C2DCB9E7660688E68C45B3C38E5FE4B623603FCED3FFBF928BF7D729B3A7CDAAED99A4B0DFE829F8633ECB3506DDC418254FC6A5C51D900FF062277FF270FF309028D84F27CE70C8D19438EEB4C7803F579EDBC4A66280D67394CB4E70FA271FE024BA1EF392E21280ABA785176C947F3AC1D330C8DE2B9AC1250CA2B4066258641E61AC40DC9D5365695087D3BA136F7133B213B2B2BCD517889205724A84C209FD50F14FFC10C4AD604FD6F6E3A67BE98986A1E5CD010D9CB846EA50218FE7A8781483246B1A97762B292C67677FAA2B8001D4CFB4D6E77135585CD843B7FAB32804CD88D7B13D8CA05FF57E267048B5C5DF83CD6C11DDB57B555704A4FECEA6761D9725511B5F81DF6EB383072E6A63BA2BB78079AA3A03E012454F7BE23F68A62F27DC36B7D6191E46E6B9C829AE1A9BAA48DEADFD7864F74181A1928D9D3A7D97E2FF8A5503A8B3D09A8F7A473FCB22A2553055B2E7E59FE8B9DE3ED8469495AEDE4FC0ACA3824F1724D3B3E86037C84607247E695833873D3099ADA5FE72F65E5D5BEF8AC8179E3BD438D9756556A9C53CA8987D52735DCBDAAF6B97B1FB1114FBF92036F81473FE3A43D42B8BBC0E9C2F347DF2B3B1F7A920164CC05B9FCA7D974DAFB0E1B2CE796A7764578FFC5BF2D5A4B996217C556F075216864B44E9F71580E878FA63B5E167B51560A4AFB6F45E2F3F534BFC00BE97631297906A937EF4C058DEE6424013DA90E067680150928B63C0E8B09E27A71CE7020CBE1F7153B869CAA6084195D2B6A36E36934627DAA33E01CAEBE17F4E3A48A40835BB2109067A1D83B89B91E8315E4C234FE0DA69244E45F271892932D67B83715A4C7573B96ABF16B557ED4D1DFA94BE03351717B765A9A26AF36ECD659E80FED04B8825FDF62369C6BFB1144AE485DA1EBB808FD2A5409BA76AEEA84C2BA39E3DF278246B9E7100014B139EEA1A2C132495BA4A4A71321CC33C9BD09B4A8CBFC863BE4992DC583F8F9682F61251F5F21F9E4ECB5B3E175C27B8121853B97AB7415144F7E9DA882115263E165D547F6A0789D324B1CA410BF2492214A305A832FA8F561AAF72835F16EC01C3E467A68C94D9C3D45F518E5565878DC78F0B26FE5C7D32E7F100318A0E3170D2ADD33CDDAEFCC385A02006ADDA7C8E1195268A1444338D2EFA14C91B888B22C9796EE2A523D5C2CB20341323D05543075C7481123C4A9CD6619A100C5FB1EB1E3A54A46A898F3C390299E5C856FE0CFFCCDE5BAB07C38EC59D94BE0863DF103021980B837E0CFCA797521FC8C82972482525B32782919987FBFF74D1615C963EAD5A47BABAA0815E017AC0E48663E08859D65A9296F7B84231DF50DF259BE16D9F7B7C78184D1C81B7A3FC3E46EFF5098A030C36F95F3549F0638180235FF1514A28B55125C525CCFDB749932F06D9C5EEA583C54DAE670485CF7F1128D8FC1FD58A1A504787372EF4CF8259D319C9C3409E2DCF6ADCB9E00ECA884314330589C79408A986A1398F982B52F7ECAA0E516816DC5207E879C0419F633350D85646E25D66E84E248087BA1015392B21F5ED6DD8BAEBDDCC5FE89A9B8E56D07B8202B67F11C02E245ADE30B4A5D5FDF958F2F2987582EBB2B84D97740E9538049CEF81CF6E6CB30BC0BFBD76AB5DC49CAB2C549789977E6B4FD678A8EDC017A722B8E4ACD930E4ABAC9E2AEF5FCE53816587352FF2411A3DA7C7CA7F9A73F52B76A7CD062056AA32C635D7C1152EBF0C37B873D55B87DF20F97835639B5DC876CFCF7D812D2F5B27B376EA22057904E6FDD7852A60405E747661E54DC6FD85624EBF2B869D401EB81A2F14F9CF99E8D03A35BEDBE963A00F2BE3844E028F138C67342B81C06A0ACF160B30E4F126415CD8455158639797DDE80ADF77AEF20552481130F3EAD62BE2CA039310E948FA1E0277715BD1A5AAFABBD07DC785CE97112FEE51CA712EC7D13A3ECF0C5236A4FFC3550AAD957C172F023FF713CCD7CC7603A2D2F02648B55FDF33515EC4239E9A5EE74189148D52594854741FDFE9D014D5D92BA77E6427DF81AF5758A05A2F6E0E31D686FFF6284A6D6E90D7F31191FB98C0650E1150E7D62DE9DA4F6C79A0D9FE62C15E7FCC02405DBA0096DD7B8E9AA4845A5D54AD8D27FBAD1DAFF54ED563E4BF5419EF18778A742F7C6DC5B3CA4463B58CAA79D1D31E196D0C5674FF16A5E518D4A3EE7CF0ED72604F37862CEC40A649C21B48A21818A6981A8F00DCB03760D946D1E02D2B4B81B2B5885727B6CB4F2C7759F7DFA31F4693660EE57B978B63294179E97821D73E2375CD22B3C601BE1781C17992A33F681AB67CC5537D15E05A0093E9501A3F01468C9BFC64BB98342D2DCE6343FEF79AD531A0EC8B24D25753E2308BD0ADD2549F8A74386128DC524710B44B9886F8B3D1921FA8494E794EBAD6B1F73E1CECF9AE6D36CEC63A2225AF66DB184DDA9F01A5BA7F74BE2F731F10BCCC7E7B26B98D52E5EC1287D0CD6A7A8031E5106FB1B8692487A1690DF50A41DB296754A56151894133C9ABF5888B5453D2F9347601C0C9F2FCC5EE5D57EDBC610F2F6D0F32D18BBC185961D74E90F1C56EA6DEF220DE453F649E51ABAFCE3711B778509622EEA017AE5A549E2E6F887164A3EC38BC2A965AF0A726471EF1D01DD379C17121BC9E77628754A6920E029A738D7A885C2FF999F888DA2F3DCFFB839DFBDDFE47BB8A1986EBED2B791396A14C46E9CA5FC82D8D349CC1A63B9051FACB368DC93634565618F8470A65B24757D511A8177364C0147B2C21D7F5D733CCC055907AE909BFE7E455D2A458231855C543762EF6198CCD1BEF713C449958047118AF119F6147AC141B4FFD0FCBA71381C589F30EB76B22F7661737D2287FD76A5A28F26A526C08B17D1D7357DEF4CCF5FA915F4E51553543D9C43D9BBECA9929E4B4945487D58227F19A9CF20C5CE3588353303E582D1807B9382E8E289B384EF25FE40657096575B435AEC98B5D524C40CE3297EC7176632420FC4475234229EA6EB14886DBB7C12D142B44D883D26A180AFB25CEBEC7F836654CA85A0DB4067F00CD54CA0A8FDD2E74C153C9A0B94D955B18456747EDCBE124AC1A53498885AF8781D4A3626CDD51931D5D91BD5DF3BA77B847E4D93E9B62CC9CD258B475738FB9F84AB1F95BAAC8BFD6DA30535527EE1C72BCE4C76584117B3AE63D6405D36E6A88E2B2A6FA682F3CA350DD041216F33A4C69CB603A105B7D003EF9B14CC257B71EDFF635B8930419B0CA116D6DDE5674DB129095551D19B0EFD8503B87EFBF7FDB3C3B075546AC2CB75106DBB93C2236234C4B779D38AB8054B1D59E8B1190E157517B1DEA2219DBD4FA512DD088E094A66D377FAC6B62D48A564F02B1713892D26D8AFF137E1DF1BDE6249597456AA76867388E0CB5A2F030DE94576A305D6B13FC03DBF14C73EB7C3E8C0B6DDE36253B14EA4B9D9A8317ADB4FA5A64780BDBDB0477CA17BB258790FB4A9347C5933DDA9FFECD1AF6F0E0CC0B69909ECED0C6861F7BCE7A0B137AB5B91EA65DAF5D2B7C963DF71AE3BDEC283B26DCEA5335FE7EEBFB4D93F35F4FEC1E23F8F64407DFC257802A32B301205B99F9DF25DA74226458D98C05FB59DE42D3EC9BF488CB9DD38E4227FFBDEDAE672D74DCAABCF27118250B4D084F5BC4EB8322C4A65AFFC4F8B325C1EACDDD2EA48C43F99133B7C62A43AB1AF7C82AD52B75C368BF77FFC156EE9C3671C7BC161BCDC3ABB5256A904065705111B7B511993E4C078E020BA3B127C0E80557DF495539D95251143F7E7B11FFF4B9A88E28F5E96BE53128720FC114C625E3840D35B068257475E21D307A38937318808BC13602B622DB3879D9535BD8B8B78C9DDE2B16CDC5EE74ECDE37974D051A760816C4C45C19AD52789206814C6C332F3CD762948929B0F08D62FCE272D6E8D3C7FD5B66A03AB55D7E72E92D3E295F12797AB3FAF8555A10ABBCC1325D51D86F1902F4C7C5FD2BF01780E5B40E57817ED2D77BFA222E555858DC98161413310860396021B6B4A5D2BC68AA77E0F5650F1FD61655EB8E709BF31B338B59142532716AAB117BCFC76E4D5F98A768BF73312F0FAB01B9F9E29124B57E7CC313AE4D7903B08985A3FC94331B51D9C728D39C6F81309DEC0FE0F0172FDA2A2A5C1E0D9A263C69F279194C6A1187134C5DFE8487D164773437CF3DC8A564BA467942F8B97738F9FF5DDE979AB9CA163C83603756B89399AAD725A72428F74DE78AE4DD22B52E4E4F0D79EFF4E92F5EDD85764C6DFD853E914A4D31985C8F572480097B8B3E4022356C3D12A13FCAE18A3A478C32C1AA8DC3AAF0E88C2F64B8D574B102928564F8BEE84A878791478D850071593F9C13C2C1406B3B73CEC3D8813EC9AFE06B0781AFD1C448C01EE4831C5A353EA41E3215BD9A0234033D5B5202B800DB36B61B8CA5E5F98C56AD94AB5C1AC381FD8E839932115C489E5C28CCD858F9B006BD87D2F9667BB3FDCAC42B20FBEC8F341F227DD468DD8B3A9EA4EB8E61E9B08EB0C7ADF5BFB65A50C9B9022C5C0389C33CC4FB1C71BD04E592BFF4F761E7E9E36589DF7E4F936AE7A696EAAC5AAA -# 0000 + +# This product (06cb:00a2, MoH) enrolls via the byte-exact native pipeline +# (Sensor.enroll_moh), not the DLL-style 0x68/0x6b enrollment session. +moh_enroll = True + + +# ─── 06cb:00a2 native feature-pipeline data (RE-derived; moved here from +# moh_data.py since it is hardware-specific to this product, like the blobs +# above). Descriptor BRIEF/aggregation tables + the WS-body framing scaffold; +# imported directly by validitysensor.moh_native. ─────────────────────────── +# BRIEF descriptor: 128 (a, b) gradient-test index pairs (was brief_table.bin). +BRIEF_TABLE = ( + (0, 2), (1, 3), (0, 4), (1, 5), (0, 6), (1, 7), + (2, 4), (3, 5), (2, 6), (3, 7), (4, 6), (5, 7), + (8, 10), (9, 11), (12, 14), (13, 15), (8, 16), (9, 17), + (18, 20), (19, 21), (22, 20), (23, 21), (24, 26), (25, 27), + (28, 16), (29, 17), (18, 30), (19, 31), (10, 32), (11, 33), + (34, 10), (35, 11), (36, 38), (37, 39), (12, 24), (13, 25), + (40, 42), (41, 43), (8, 34), (9, 35), (12, 22), (13, 23), + (24, 44), (25, 45), (40, 24), (41, 25), (26, 46), (27, 47), + (34, 48), (35, 49), (30, 50), (31, 51), (52, 54), (53, 55), + (56, 46), (57, 47), (28, 32), (29, 33), (12, 50), (13, 51), + (30, 20), (31, 21), (52, 22), (53, 23), (52, 18), (53, 19), + (12, 42), (13, 43), (40, 30), (41, 31), (8, 48), (9, 49), + (52, 42), (53, 43), (42, 20), (43, 21), (52, 40), (53, 41), + (54, 50), (55, 51), (46, 50), (47, 51), (12, 30), (13, 31), + (12, 56), (13, 57), (40, 14), (41, 15), (34, 36), (35, 37), + (26, 50), (27, 51), (28, 48), (29, 49), (42, 18), (43, 19), + (14, 30), (15, 31), (28, 34), (29, 35), (8, 32), (9, 33), + (12, 18), (13, 19), (42, 22), (43, 23), (40, 50), (41, 51), + (52, 26), (53, 27), (56, 22), (57, 23), (26, 22), (27, 23), + (42, 54), (43, 55), (34, 32), (35, 33), (14, 24), (15, 25), + (10, 36), (11, 37), (12, 46), (13, 47), (26, 30), (27, 31), + (26, 54), (27, 55), +) + +# E090 aggregation: 29 (window_size_idx, dy_off, dx_off) windows (was aggr_table.bin). +AGGR_TABLE = ( + (0, -7, -7), (0, 0, -7), (0, -7, 0), (0, 0, 0), (1, -2, -7), + (1, -7, -2), (2, -3, -7), (2, -7, -3), (1, 3, 3), (2, 5, -3), + (2, -3, 5), (2, 1, 1), (2, 1, -3), (2, -7, 1), (1, -7, -7), + (2, -7, 5), (1, -2, -2), (1, 3, -7), (1, 3, -2), (1, -7, 3), + (2, 5, -7), (2, -3, -3), (2, 5, 5), (2, 5, 1), (1, -2, 3), + (2, 1, 5), (2, -7, -7), (2, -3, 1), (2, 1, -7), +) + +# WS-body framing scaffold (was native_ws_scaffold.bin, 23056 B, ~99% zeros). +WS_SCAFFOLD_SIZE = 23056 +_WS_SCAFFOLD_CHUNKS = ( + (4, bytes.fromhex( # fixed header + sec0_pre (counts/config/id/leads/blob/matrix) + '40480000060205000200080103020100000000004c0000005a00000056000000590000000004' + '017b2389ff000046f0ffff5b7be8ff9561e8ff8821acff0000e7f2ffffda7af6ffb320eaff98' + '237fff0000c4efffff4e35fbffa559f1ffa11df0ff0000af0500004d290e00796d01008221fc' + 'ff0000f00200003989130092630800a51e00000100e4feffff798a050098dd06')), + (280, bytes.fromhex( # fixed header + sec0_pre (counts/config/id/leads/blob/matrix) + '0400b811')), + (292, bytes.fromhex( # fixed header + sec0_pre (counts/config/id/leads/blob/matrix) + 'fa402d718170e4de301581828f410c2055')), + (4809, bytes.fromhex( # section 0->1 boundary framing (trailer + next header) + '9e9fbcd8dfdfe1e6000000680004000000000003000400040000006900080070000000700000' + '006b000400050000006c0010')), + (4868, bytes.fromhex( # section 0->1 boundary framing (trailer + next header) + '01000000030000006a000400030000000500b811')), + (4896, bytes.fromhex( # section 0->1 boundary framing (trailer + next header) + 'fa608b73b2786c5fb8e38f82dfd0882757')), + (9413, bytes.fromhex( # section 1->2 boundary framing (trailer + next header) + '9cabc1d0d3d5d8e80000000600b811')), + (9436, bytes.fromhex( # section 1->2 boundary framing (trailer + next header) + 'fa408f369278ee5f39eb9fa2df900c375b')), + (13953, bytes.fromhex( # section 2->3 boundary framing (trailer + next header) + '979ebcd0d3d5d9e30000000700b811')), + (13976, bytes.fromhex( # section 2->3 boundary framing (trailer + next header) + 'fa40aabc9852c45a384cada20801086459')), + (18493, bytes.fromhex( # section 3 trailer / tail + '979ebed3d9dbdde0000000010004')), +) + + +def build_ws_scaffold(): + """Rebuild the 23056-byte WS-body framing scaffold (zeros + framing chunks).""" + buf = bytearray(WS_SCAFFOLD_SIZE) + for off, data in _WS_SCAFFOLD_CHUNKS: + buf[off:off + len(data)] = data + return bytes(buf) diff --git a/validitysensor/db.py b/validitysensor/db.py index bb21ff1..accda03 100644 --- a/validitysensor/db.py +++ b/validitysensor/db.py @@ -7,7 +7,7 @@ from .sid import SidIdentity, sid_from_bytes from .tls import tls from .util import assert_status -from .winbio_constants import finger_names +from .fingerprint_constants import finger_names class UserStorage: diff --git a/validitysensor/fingerprint_constants.py b/validitysensor/fingerprint_constants.py new file mode 100644 index 0000000..94fec54 --- /dev/null +++ b/validitysensor/fingerprint_constants.py @@ -0,0 +1,39 @@ +# maps fingerprint names from the fprint api to corresponding indices +# for legacy reasons uses ANSI381 naming if no fprint name is specified - see https://github.com/uunicorn/python-validity/pull/23 + +finger_ids = { + # fprint https://fprint.freedesktop.org/fprintd-dev/Device.html#fingerprint-names + "right-thumb": 1, + "right-index-finger": 2, + "right-middle-finger": 3, + "right-ring-finger": 4, + "right-little-finger": 5, + "left-thumb": 6, + "left-index-finger": 7, + "left-middle-finger": 8, + "left-ring-finger": 9, + "left-little-finger": 10, + + # ANSI381 https://github.com/tpn/winsdk-10/blob/9b69fd26ac0c7d0b83d378dba01080e93349c2ed/Include/10.0.16299.0/shared/winbio_types.h#L864-L878 + "WINBIO_ANSI_381_POS_UNKNOWN": 0, + "WINBIO_ANSI_381_POS_RH_FOUR_FINGERS": 13, + "WINBIO_ANSI_381_POS_LH_FOUR_FINGERS": 14, + "WINBIO_ANSI_381_POS_TWO_THUMBS": 15, + + # Microsoft specific extensions https://github.com/tpn/winsdk-10/blob/9b69fd26ac0c7d0b83d378dba01080e93349c2ed/Include/10.0.16299.0/shared/winbio_types.h#L920-L929 + "WINBIO_FINGER_UNSPECIFIED_POS_01": 0xf5, + "WINBIO_FINGER_UNSPECIFIED_POS_02": 0xf6, + "WINBIO_FINGER_UNSPECIFIED_POS_03": 0xf7, + "WINBIO_FINGER_UNSPECIFIED_POS_04": 0xf8, + "WINBIO_FINGER_UNSPECIFIED_POS_05": 0xf9, + "WINBIO_FINGER_UNSPECIFIED_POS_06": 0xfa, + "WINBIO_FINGER_UNSPECIFIED_POS_07": 0xfb, + "WINBIO_FINGER_UNSPECIFIED_POS_08": 0xfc, + "WINBIO_FINGER_UNSPECIFIED_POS_09": 0xfd, + "WINBIO_FINGER_UNSPECIFIED_POS_10": 0xfe +} + +# Store the keys in the reverse order for faster lookups +finger_names = {} +for name, index in finger_ids.items(): + finger_names[index] = name diff --git a/validitysensor/firmware_tables.py b/validitysensor/firmware_tables.py index bd0b867..f4d2855 100644 --- a/validitysensor/firmware_tables.py +++ b/validitysensor/firmware_tables.py @@ -22,6 +22,11 @@ 'driver': 'https://download.lenovo.com/pccbbs/mobiles/nz3gf07w.exe', 'referral': 'https://download.lenovo.com/pccbbs/mobiles/nz3gf07w.exe', 'sha512': 'a4a4e6058b1ea8ab721953d2cfd775a1e7bc589863d160e5ebbb90344858f147d695103677a8df0b2de0c95345df108bda97196245b067f45630038fb7c807cd' + }, + SupportedDevices.DEV_a2: { + 'driver': 'https://download.lenovo.com/pccbbs/mobiles/r0yfp10w.exe', + 'referral': 'https://download.lenovo.com/pccbbs/mobiles/r0yfp10w.exe', + 'sha512': '00116d8fe70e4fb0e030b256cc620118c8112e8d69b49431acc5d3203ecaf76f11b584a4b1ced0738876b868277b36be303eec8edeca501331b710b143df255c' } } @@ -29,5 +34,6 @@ SupportedDevices.DEV_90: '6_07f_Lenovo.xpfwext', SupportedDevices.DEV_97: '6_07f_lenovo_mis_qm.xpfwext', SupportedDevices.DEV_9a: '6_07f_lenovo_mis_qm.xpfwext', - SupportedDevices.DEV_9d: '6_07f_lenovo_mis_qm.xpfwext' + SupportedDevices.DEV_9d: '6_07f_lenovo_mis_qm.xpfwext', + SupportedDevices.DEV_a2: '6_07f_lenovo_sm_qm.xpfwext' } diff --git a/validitysensor/init.py b/validitysensor/init.py index f3c0498..1153cf7 100644 --- a/validitysensor/init.py +++ b/validitysensor/init.py @@ -1,6 +1,7 @@ import atexit import logging +from validitysensor.init_data_dir import init_data_dir from validitysensor.flash import read_tls_flash from validitysensor.init_db import init_db from validitysensor.init_flash import init_flash @@ -26,6 +27,7 @@ def close(): def open_common(): + init_data_dir() init_flash() usb.send_init() tls.parse_tls_flash(read_tls_flash()) diff --git a/validitysensor/init_data_dir.py b/validitysensor/init_data_dir.py new file mode 100644 index 0000000..2f38872 --- /dev/null +++ b/validitysensor/init_data_dir.py @@ -0,0 +1,8 @@ +import os + +PYTHON_VALIDITY_DATA_DIR = '/var/run/python-validity/' + +def init_data_dir(): + if not os.path.isdir(PYTHON_VALIDITY_DATA_DIR): + os.mkdir(PYTHON_VALIDITY_DATA_DIR) + diff --git a/validitysensor/init_flash.py b/validitysensor/init_flash.py index 6cd54ec..61c918b 100644 --- a/validitysensor/init_flash.py +++ b/validitysensor/init_flash.py @@ -36,15 +36,24 @@ dbd0df42d534904de00b6389f68867646e9d7c3d0b1dffd74070b2d0f2049b9f1dc7b0c9651c59be3ea891674725e1f2f7a484a941615b80211105978369cf71 ''') -crypto_backend = default_backend() - +flash_layout_hardcoded_0090 = [ + # id type access offset size + # lvl + PartitionInfo(1, 4, 7, 0x00001000, 0x00001000), # cert store + PartitionInfo(2, 1, 2, 0x00002000, 0x0003e000), # xpfwext + PartitionInfo(5, 5, 3, 0x00040000, 0x00008000), # ??? + PartitionInfo(6, 6, 3, 0x00048000, 0x00008000), # calibration data + PartitionInfo(4, 3, 5, 0x00050000, 0x00030000), # template database +] -def get_partition_signature(): - if usb.usb_dev().idVendor == 0x138a: - if usb.usb_dev().idProduct == 0x0090: - return b'' +partition_signature_0090 = unhex(''' +e44f7a80d6137794d330b5d026c328a73c907f3f653d411255b7c2f8b425d870a8a53c6630ca864b84590e3c6786f0d69be4bbab5736388f8527237a0a86bbce +7ced9450c4964709e89ac535aa00787158e0a8d9b1fb75f0f7ae53d4bd11abfcf5ee67a5a71e248a426b3aff4567048fa93de65939ccfbe3f31149a82c64fbfd +6a2a6cf748e1d9bd8562cf39b1a4b307b37be223317b1b817e364f2877d29d123731314aa627cbf234e0ea69a406a4735a03a45495023ef706bdb542c949d243 +ac2c08c00abf43faa5528a0a8e49b02c507b01b6f1c9abffc669d8c84d7e4a714da32aade7928eca9698b82bee6b72c642c9add80bbd7ccc4121b80220d52b8a +''') - return partition_signature +crypto_backend = default_backend() def with_hdr(id: int, buf: bytes): @@ -90,13 +99,13 @@ def serialize_partition(p: PartitionInfo): return b -def partition_flash(info: FlashInfo, layout: typing.List[PartitionInfo], client_public): +def partition_flash(info: FlashInfo, layout: typing.List[PartitionInfo], signature, client_public): logging.info('Detected Flash IC: %s, %d bytes' % (info.ic.name, info.ic.size)) cmd = unhex('4f 0000 0000') cmd += with_hdr(0, serialize_flash_params(info.ic)) cmd += with_hdr(1, - b''.join([serialize_partition(p) for p in layout]) + get_partition_signature()) + b''.join([serialize_partition(p) for p in layout]) + signature) cmd += with_hdr(5, make_cert(client_public)) cmd += with_hdr(3, crt_hardcoded) rsp = tls.cmd(cmd) @@ -125,7 +134,15 @@ def init_flash(): client_private = snums.private_value client_public = snums.public_numbers - partition_flash(info, flash_layout_hardcoded, client_public) + layout = flash_layout_hardcoded + signature = partition_signature + + if usb.usb_dev().idVendor == 0x138a: + if usb.usb_dev().idProduct == 0x0090: + layout = flash_layout_hardcoded_0090 + signature = partition_signature_0090 + + partition_flash(info, layout, signature, client_public) RomInfo.get() # ^ TODO: use the firmware version which to lookup pubkey for server cert validation diff --git a/validitysensor/moh_extract.py b/validitysensor/moh_extract.py new file mode 100644 index 0000000..44b2049 --- /dev/null +++ b/validitysensor/moh_extract.py @@ -0,0 +1,612 @@ +""" +Host-side feature-extraction pipeline for Match-on-Host Synaptics sensors. + +For 06cb:00a2 ("Metallica MOH") and family, the chip captures the image and +the chip matches fingers, but the host has to extract features and serialize +the template that goes via 0x47 new_record. This module is a Python port of +the proprietary CEohMohEIV pipeline in synaWudfBioUsb.dll. + +Status: scaffold. The qsort comparators, tuning constants, and Minutia layout +are implemented. The 8 per-frame stage functions inside sub_18000AAB0 are +stubs awaiting decompile output. The 23 KB template serializer is unmapped. + +Reference function addresses in synaWudfBioUsb.dll (Lenovo n1cgn10w build): + sub_180001A50 feature-extract coordinator (have body) + sub_180004C10 250-slot init + param block (have body) + sub_18000AAB0 9-stage orchestrator (478 lines) (have call list) + sub_180003320 stage 1 (82 lines) [NEED decompile] + sub_180003460 stage 2 (9 lines, trivial) [NEED decompile] + sub_1800095C0 qsort (CRT, generic) use sorted() + sub_18000A4B0 stage 4 (61 lines) [NEED decompile] + sub_18000A5B0 stage 5 (122 lines) [NEED decompile] + sub_18000A850 stage 6 (40 lines, 1 mul) [NEED decompile] + sub_18000A8E0 stage 7 (16 lines, no calls) [NEED decompile] + sub_18000A910 stage 8 (13 lines, shift-heavy) [NEED decompile] + sub_18000A960 stage 9 (91 lines, 5 jumps) [NEED decompile] + sub_18004B710 CryptHashData wrapper -> SHA-256 (have body) + +qsort comparators (all 32-byte minutia records): + sub_18000A7C0 asc by score_10 DECODED + sub_18000A7E0 asc by (active, score_10) DECODED + sub_18000A810 asc by flag9, desc by score_10 DECODED + sub_18000A940 asc by score_c DECODED +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +from dataclasses import dataclass, field +from struct import pack, unpack +from typing import List, Optional, Tuple + +log = logging.getLogger(__name__) + + +# Tuning constants from sub_180004C10's stack-allocated param block, +# passed to sub_18000AAB0 each frame. +MAX_MINUTIAE = 250 # 0xfa +GRID_X = 10 # 0x0a +GRID_Y = 7 # 0x07 +COORD_RANGE_X = 1126 # 0x466 +COORD_RANGE_Y = 671 # 0x29f +BLOCK_SIZE_SMALL = 16 # 0x10 +BLOCK_SIZE_LARGE = 128 # 0x80 +COORD_SCALE = 500 # 0x1f4 + +# Sensor parameters (a2 device) +SENSOR_DPI = 363 +SENSOR_W = 112 +SENSOR_H = 112 + +# Per-enrollment limits from sub_1800D89C0 +MAX_BAD_FRAMES = 6 +MOH_MODE_FLAG = 101 # vtbl(this)[+24] == 101 + +# Output format selectors used by sub_18004E640 +TEMPLATE_FORMAT_SHA256 = 0 # 32 bytes +TEMPLATE_FORMAT_SHA1 = 1 # 20 bytes +TEMPLATE_FORMAT_MD5 = 2 # 16 bytes + +# Session-buffer header from sub_1800D89C0 +SESSION_MAGIC = 0x4C4F4356 # "VCOL" little-endian +SESSION_VERSION = 8 +SESSION_HEADER_LEN = 152 # minutia table starts at session + 152 + + +# ─── Minutia record (32 bytes) ──────────────────────────────────────── +# Field offsets recovered from the 4 qsort comparators. Bytes 0..7 and +# 0x14..0x1f are consumed by stages we haven't decompiled yet — likely +# (x, y, theta, type, quality) attributes. + +@dataclass +class Minutia: + head: bytes = field(default_factory=lambda: bytes(8)) # +0x00..0x07 + active: int = 0 # +0x08 + flag9: int = 0 # +0x09 + pad_a_b: bytes = field(default_factory=lambda: bytes(2)) # +0x0a..0x0b + score_c: int = 0 # +0x0c int32 + score_10: int = 0 # +0x10 int32 + tail: bytes = field(default_factory=lambda: bytes(12)) # +0x14..0x1f + + def __bytes__(self) -> bytes: + return (self.head + + pack(' 'Minutia': + assert len(b) == 32, f"Minutia is 32 bytes, got {len(b)}" + active, flag9 = unpack(' bool: + return self.active != 0 + + +# ─── qsort comparators (sub_18000A7C0/_A7E0/_A810/_A940) ────────────── +# sub_1800095C0 is the CRT qsort itself; we use Python's sorted() with +# these key functions instead. Each key reproduces the comparator's +# sign convention. + +def cmp_score_10_asc(m: Minutia) -> Tuple[int, ...]: + return (m.score_10,) + +def cmp_active_then_score_10(m: Minutia) -> Tuple[int, ...]: + return (m.active, m.score_10) + +def cmp_flag9_then_score_10_desc(m: Minutia) -> Tuple[int, ...]: + return (m.flag9, -m.score_10) + +def cmp_score_c_asc(m: Minutia) -> Tuple[int, ...]: + return (m.score_c,) + + +# ─── Frame context (the 250-slot working table + per-frame state) ───── + +class FrameContext: + def __init__(self): + self.minutiae: List[Minutia] = [Minutia() for _ in range(MAX_MINUTIAE)] + self.quality: int = 0 + self.progress_pct: int = 0 + self.frame_count: int = 0 + + +# ─── Stage functions ────────────────────────────────────────────────── +# Six of nine decoded directly from disassembly. All decoded ones are +# pure data-shaping plumbing — the actual biometric work happens in +# the unknown callees they invoke. + +def sub_180003460(dest: bytearray, ptr: int, size: int) -> None: + """Stream descriptor builder. dest is 0x30 bytes: + [+0] uint32 size + [+8] qword base ptr + [+0x10] qword cursor ptr (== base initially) + [+0x18..+0x30] secondary slot, zeroed + """ + dest[0:4] = pack(' None: + """Edge-flag computer. out4[0..4] = (top, bottom, left, right).""" + out4[0] = 1 if y <= 0 else 0 + out4[1] = 1 if y == height - 1 else 0 + out4[2] = 1 if x <= 0 else 0 + out4[3] = 1 if x == width - 1 else 0 + + +def sub_18000A910(dx: int, dy: int, + x_high: int, y_high: int, + x_lo: int, y_lo: int) -> Tuple[int, int]: + """Coordinate quantize-and-offset. Reproduces the + ((diff << 16) + off) >> 16 arithmetic-shift sign-extension trick.""" + def _q(diff: int, off: int) -> int: + v = ((diff << 16) + (off & 0xffffffff)) & 0xffffffff + if v & 0x80000000: + v |= ~0xffffffff + return v >> 16 + return _q(x_high - x_lo, dx), _q(y_high - y_lo, dy) + + +def sub_180001010(handle) -> int: + """Algorithm-ready gate. Returns 1 when ready, else HRESULT error.""" + if handle is None: return 0x80000030 + if getattr(handle, 'f8', 0) == 0: return 0x80000032 + return 1 if getattr(handle, 'f30', 0) != 0 else 0x80000002 + + +def sub_1800031E0(dst: bytearray, src: bytes, length: int) -> None: + """Custom memcpy with dword fast-path. Python equivalent is plain slice.""" + dst[:length] = src[:length] + + +def sub_1800032C0(dest: bytearray, arg2: bytearray, + consumed: int, src_ptr: int, + cap_qword: int, cap_dword: int) -> None: + """Stream-descriptor advance with bounds check. Resets dest's + secondary slot and re-sets primary {size, ptr} after 8-byte align.""" + # Set qword at +8, zero everything else + dest[8:16] = pack(' None: + """sub_180003320 — stream-advance dispatcher. Calls sub_1800032C0 + (now decoded). Routes to primary or secondary cursor.""" + raise NotImplementedError("Decoded structure; needs Python integration") + + +def stage_4_18000A4B0(*args) -> None: + """sub_18000A4B0 — two-stage glue: + sub_18000A1B0 (197 insn structural) → sub_18000F300 (94 insn SIMD). + F300 is where per-feature SIMD work lives.""" + raise NotImplementedError("Need sub_18000A1B0 and sub_18000F300") + + +def stage_5_18000A5B0(*args) -> None: + """sub_18000A5B0 (122 insn). NOT YET DECOMPILED.""" + raise NotImplementedError("Need decompile output for sub_18000A5B0") + + +def stage_6_18000A850_row_loop(dst: bytearray, dst_stride: int, height: int, + src: bytes, + src_stride_lo: int, src_stride_hi: int) -> None: + """Row-iteration loop. Calls sub_1800031E0 (now decoded as memcpy) + once per row. So this is just an image-blit with potentially + different src/dst strides.""" + src_stride = src_stride_lo * src_stride_hi + if height <= 0: return + s_off = 0; d_off = 0 + for _ in range(height): + dst[d_off:d_off+dst_stride] = src[s_off:s_off+dst_stride] + s_off += src_stride + d_off += dst_stride + + +def stage_9_18000A960(*args) -> None: + """sub_18000A960 (91 insn, 1 call). NOT YET DECOMPILED.""" + raise NotImplementedError("Need decompile output for sub_18000A960") + + +# Hardcoded LFSR-like seed table used by sub_18000E6B0 to deterministically +# select 64 binary tests from a 162-pair candidate database. These 128 values +# are baked into the binary; they're the "learned" random walk that defines +# which BRIEF-like point-pair tests this algorithm uses. +BRIEF_SEED_TABLE = [ + 3382, 4039, 29605, 1734, 19683, 2304, 17019, 16644, + 10030, 26447, 18237, 7668, 28663, 2663, 4319, 9870, + 10986, 19346, 9877, 19462, 12277, 24659, 28646, 32662, + 29695, 20554, 25346, 30589, 18903, 601, 27989, 17736, + 12138, 9477, 19036, 8528, 31546, 30239, 15544, 3972, + 32267, 11683, 23937, 16744, 27871, 4064, 30172, 22878, + 10021, 27353, 5840, 29477, 11566, 748, 25429, 5535, + 23264, 12977, 16558, 29143, 15022, 16933, 24825, 4930, + 1224, 14600, 23557, 25925, 7822, 12419, 19043, 12792, + 11851, 26638, 5824, 32298, 5920, 8593, 31090, 26277, + 28990, 2249, 21072, 25266, 21080, 10734, 21703, 4064, + 31321, 15251, 14890, 27394, 14418, 16333, 28234, 6775, + 7094, 16535, 27207, 11694, 17865, 11125, 12709, 30184, + 28502, 4184, 9634, 23616, 30368, 18370, 8903, 22761, + 2460, 17450, 7358, 28600, 16477, 4770, 11363, 21986, + 15312, 20151, 17437, 9478, 7337, 3481, 32367, 0, +] +assert len(BRIEF_SEED_TABLE) == 128, "seed table is exactly 128 entries" + + +# The fill value used for image padding by sub_180009F50. In 16.16 fixed-point +# this is 128.0 — mid-gray, neutral for gradient/filter operations. +PADDING_FILL_VALUE = 0x800000 + + +def sub_180009F50(workspace_a: bytearray, fill_value: int, + scale: int, dim_y: int, dim_x: int, + pixel_buf: bytes, scratch_strip: bytearray, + width: int, height: int, + edge_flags: bytes) -> None: + """Image padding. Builds workspace_a as the input image with mid-gray + padding on the four edges that touch the sensor boundary. + + Per-edge padding size is `scale` if that edge is touched (per + edge_flags from sub_18000A8E0), else 0. Centered patches get no + padding; sensor-edge patches get padding on the affected sides. + """ + top = scale if edge_flags[0] else 0 + bottom = scale if edge_flags[1] else 0 + left = scale if edge_flags[2] else 0 + right = scale if edge_flags[3] else 0 + + # Pre-fill the scratch strip with the fill value (for left/right margins) + n = scale * dim_y + for i in range(n): + struct_pack_into = pack(' List[Tuple[int, int, int, int, int]]: + """Generate the 64 binary tests used by the per-minutia descriptor. + + Returns 64 tuples of (level, x1, y1, x2, y2) where: + - level ∈ {0, 1, 2} (= grid_size - 2; grid_size ∈ {2, 3, 4}) + - (x1, y1) and (x2, y2) are pixel-offset coordinates relative to + the minutia center, scaled into [-scale, +scale] + + These tests are applied to a local patch: each test produces a bit + by comparing the pixels at (x1, y1) and (x2, y2). 64 bits form the + per-minutia binary descriptor. + """ + # Phase 1: build all candidate pairs across 3 grid resolutions + pairs: List[Tuple[int, int, int, int, int]] = [] + for grid_size in (2, 3, 4): + level = grid_size - 2 + scale_factor = int(scale * 2.0 / grid_size + 0.999) + n = grid_size * grid_size + for i in range(n): + for j in range(i + 1, n): + pairs.append(( + level, + scale_factor * (i % grid_size) - scale, + scale_factor * (i // grid_size) - scale, + scale_factor * (j % grid_size) - scale, + scale_factor * (j // grid_size) - scale, + )) + # 6 + 36 + 120 = 162 + + # Phase 2: select 64 pairs deterministically using the seed table + selected = [] + remaining = list(pairs) + for i in range(min(num_tests, len(remaining))): + if i < 6: + pick = i + else: + pick = BRIEF_SEED_TABLE[i] % len(remaining) + selected.append(remaining[pick]) + remaining[pick] = remaining[-1] + remaining.pop() + return selected + + +# ─── Per-frame orchestrator (sub_18000AAB0) ─────────────────────────── + +def orchestrate(image: bytes, w: int, h: int, ctx: FrameContext) -> int: + """sub_18000AAB0. Runs the 9-stage pipeline on one frame. + + Outer-call shape (from sub_180004C10's setup): + zero_minutia_table(ctx) + params = {f0=500, f4=250, f8=10, fc=7, f10=1126, + f14=671, f18=16, f1c=128, f20=0} + sub_18000AAB0(image, ctx.algo, w, h, desc, ctx.sub, params) + + Returns 1 on success. Real output is in `ctx` (minutia table updated). + """ + for m in ctx.minutiae: + m.head = bytes(8); m.active = 0; m.flag9 = 0; m.pad_a_b = bytes(2) + + # stage_1_pre_process(image, w, h, ctx) + # stage_2_build_descriptor(...) + # ctx.minutiae.sort(key=cmp_score_10_asc) # qsort call 1 + # stage_4_18000A4B0(...) + # ctx.minutiae.sort(key=cmp_active_then_score_10) # qsort call 2 + # stage_5_18000A5B0(...) + # ctx.minutiae.sort(key=cmp_flag9_then_score_10_desc) # qsort call 3 + # stage_6_18000A850(...) + # stage_7_18000A8E0(...) + # stage_8_18000A910(...) + # ─ partition: count leading active minutiae, sort each range by score_c ─ + # n_active = next((i for i, m in enumerate(ctx.minutiae) + # if not m.is_active()), len(ctx.minutiae)) + # ctx.minutiae[:n_active] = sorted(ctx.minutiae[:n_active], key=cmp_score_c_asc) + # ctx.minutiae[n_active:] = sorted(ctx.minutiae[n_active:], key=cmp_score_c_asc) + # stage_9_18000A960(...) + + raise NotImplementedError( + "orchestrate(): pending decompile output for stages 1-9. " + "See module docstring for the function addresses." + ) + + +# ─── Feature-extraction coordinator (sub_180001A50) ─────────────────── + +def extract_features(image: bytes, w: int, h: int, ctx: FrameContext, + dpi: int = SENSOR_DPI) -> bytes: + """sub_180001A50. Runs the per-frame pipeline, returns the 32-byte TemplateId. + + The TemplateId is HMAC-SHA256 chained over the assembled WS body — + see compute_tid() for the verified recipe. Note that this function + is still a scaffold: it can't return the chip-accepted TID until + orchestrate() actually fills ctx into a full 23056-byte WS body. + """ + if w > 255: + w = 255 + + orchestrate(image, w, h, ctx) + + ws_body = _serialize_for_hash(ctx) + if len(ws_body) != 23056: + # Placeholder until orchestrate() emits the full chip-view WS body + # (23056 bytes starting with 4 zeros). Returning a SHA-256 here + # keeps callers running but the chip's matcher will not accept + # the resulting template. + log.warning("WS body is %d bytes, expected 23056 — TID will not be chip-valid", + len(ws_body)) + return hashlib.sha256(ws_body).digest() + return compute_tid(ws_body) + + +def _serialize_for_hash(ctx: FrameContext) -> bytes: + """Produce the WS body bytes (TLV-1 payload of the finger template). + + Provisional: emit the 250×32-byte minutia slot table only (8000 bytes). + The chip-accepted WS body is 23056 bytes, so this is short by 15056 + bytes of feature/calibration data we haven't reverse-engineered yet. + To be filled in as orchestrate()'s stage outputs are decoded. + """ + return b''.join(bytes(m) for m in ctx.minutiae) + + +# ─── Per-enrollment session driver (sub_1800D89C0) ──────────────────── + +class EnrollmentSession: + """One enrollment session. Accept frames until enough quality data + has accumulated, then finalize() to emit the chip-storable bytes. + """ + + def __init__(self): + self.ctx = FrameContext() + self.template_id: Optional[bytes] = None + self.frame_count = 0 + self.bad_frame_count = 0 + + def process_frame(self, image: bytes, + w: int = SENSOR_W, h: int = SENSOR_H) -> dict: + self.frame_count += 1 + + # TODO sub_1800D8100 quality pre-check: + # if not _quality_ok(image, w, h): + # self.bad_frame_count += 1 + # if self.bad_frame_count > MAX_BAD_FRAMES: + # return {'state': 'give-up', 'progress': self.ctx.progress_pct} + # return {'state': 'bad-frame', 'progress': self.ctx.progress_pct} + + self.template_id = extract_features(image, w, h, self.ctx) + + # TODO: decide 'final' vs 'progressing' based on accumulated quality + return {'state': 'progressing', 'progress': self.ctx.progress_pct} + + def finalize(self) -> bytes: + """Emit the ~23 KB byte blob that goes via db.new_finger() / 0x47. + + Format decoded from sub_180036840 (vfmAuth.c): + + [8-byte envelope header] + [TLV tag=1, len=ws_size, data=working_state_buffer] # ≈ 22.9 KB + [TLV tag=2, len=32, data=SHA256_TemplateId] # 32 bytes + [32 trailing zero bytes] + + The working_state_buffer is session+152..session+152+ws_size -- + the buffer the 9-stage pipeline writes into during EnrollmentUpdate. + TemplateId is SHA-256 over (some subset of) that buffer. + """ + if not self.template_id: + raise RuntimeError("no template yet; process_frame() must be called first") + + ws = _serialize_for_hash(self.ctx) # placeholder; this is also the TLV-1 content + tid = self.template_id + subtype_u16 = 0xf75a # echoed at envelope header bytes 0..1 + + return _build_envelope(subtype_u16, ws, tid) + + +def _build_envelope(subtype: int, ws_body: bytes, template_id: bytes, + version: int = 3) -> bytes: + """Wire-exact envelope for new_record type=6, byte-identical to + sub_180036840 in synaWudfBioUsb.dll. + + Layout: + offset size field + ──────────────────────────────────────────────────────────── + 0 2 u16 subtype (e.g. 0x00f7) + 2 2 u16 version (= 3) + 4 2 u16 payload_size (= 4 + ws_size + 4 + tid_size) + 6 2 u16 trailing (= 32) + 8 2 u16 tlv1_tag (= 1) + 10 2 u16 tlv1_len (= ws_size) + 12 n bytes ws_body[n] ← chip-view WS body starts here + 12+n 2 u16 tlv2_tag (= 2) + 14+n 2 u16 tlv2_len (= tid_size) + 16+n 32 bytes template_id + 48+n 32 bytes trailing zeros + + For ws_size = 23056 and tid_size = 32, total envelope is 23136 bytes + with the TID at envelope offset 23072..23104 and the TLV2 header + immediately preceding it at 23068..23072. + + Caller contract: pass the chip-view WS body, NOT the + "envelope[16..23072]" slice. The chip-view WS body is 23056 bytes + that go from envelope offset 12 to 12+23056. In any captured + template it always begins with 4 natural-zero bytes + (template[12..16]) and ends with 4 bytes of feature-data tail + (template[23064..23068]); the TLV2 header that sits at template + offset 23068..23072 is NOT part of ws_body — this function writes + it explicitly. + """ + assert len(template_id) == 32 + ws_size = len(ws_body) + tid_size = len(template_id) + trailing = 32 + payload_size = 4 + ws_size + 4 + tid_size # TLV1 hdr + ws + TLV2 hdr + TID + total = 8 + payload_size + trailing + + buf = bytearray(total) + # Outer header (8 bytes) + buf[0:2] = pack(' bytes: + """Compute the 32-byte TemplateId for a Match-on-Host finger template. + + Recipe verified end-to-end against a Wine-captured enrollment + (enroll-fresh.log lines 1570→1583) and against the stored TID at + envelope offset 23072..23104 of every captured template: + + K = SHA-256(ws_body) + T1 = HMAC-SHA256(K, "Template ID" ‖ 32×0x00) + TID = HMAC-SHA256(K, T1 ‖ "Template ID" ‖ 32×0x00) + + K is derived from the WS body itself, so there is no device-bound + secret involved. Anyone with the WS body can recompute the TID. See + dev/MOH.md "TID derivation". + + Args: + ws_body: 23056-byte chip-view WS body. In a captured envelope + this is the slice template[12:12+23056] — the bytes the + chip parses as the TLV1 payload. Always begins with 4 + natural-zero bytes and ends with feature data tail; does + NOT include the TLV2 header that lives between the WS body + and the TID at envelope offset 23068. + + Returns: + The 32-byte TID, identical to template[23072:23104] for any + valid captured template. + """ + if len(ws_body) != 23056: + raise ValueError(f"ws_body must be 23056 bytes, got {len(ws_body)}") + + K = hashlib.sha256(ws_body).digest() + T1 = hmac.new(K, _TID_INFO, hashlib.sha256).digest() + return hmac.new(K, T1 + _TID_INFO, hashlib.sha256).digest() diff --git a/validitysensor/moh_native.py b/validitysensor/moh_native.py new file mode 100644 index 0000000..5e2819e --- /dev/null +++ b/validitysensor/moh_native.py @@ -0,0 +1,1142 @@ +"""Native MoH feature pipeline (06cb:00a2) — reproduction of the DLL's +image → v30 path, decoded in dev/DLL-RE.md and dev/MOH.md. + +Pipeline (all stages classical CV; no proprietary enhancement): + + working image (112²) + → 3×3 grid of 57×57 tiles (mid-gray pad) [tile_image] DONE + → per tile: Q12 Determinant-of-Hessian → Ixx/Iyy/Ixy/resp [doh] BYTE-EXACT (interior) + → 8-neighbour NMS → keypoints [nms] BYTE-EXACT + → subpix refine (Hessian-Newton, cull failures) [subpix_refine] BYTE-EXACT (NEW) + → BRIEF bit-pack (per-kp 128 binary tests) [brief_pack] BYTE-EXACT + → orientation (Gaussian-weighted grad histogram) [orient_d920] BYTE-EXACT (60/60) + → oriented BRIEF descriptor [descriptor] BYTE-EXACT (2026-05-30) + → [x][y][128-bit desc] × 250 → v30 [build_v30] format known + +Validation: each stage is checked against the live captures in +$FRIDA_DUMP_DIR (see dev/diff_v30.py). The tiling stage matches `gradin` +byte-exact (corr 1.000). + +DoH detector chain — FULLY DECODED (disasm, see dev/DLL-RE.md "Gradient +kernel chain"). No remaining unknowns; what's left is the bit-exact port: + + gradin (57x57 Q10) + img >>= 6 (Q10 -> Q4) [sub_18000F250] + Gaussian pre-smooth (separable), SHIFT 12 sigma ~ scale [sub_180010050] + img <<= 6 (Q4 -> Q10) + build 3 Hessian planes (separable), SHIFT 10 [sub_18000CC20] + per-axis 3-tap kernel (sub_180010280): + smoothing : [c, c*0xd55>>10, c] (~[1, 3.33, 1]) + derivative: [1024, 0, -1024] (central diff [1,0,-1] Q10) + Ixx, Iyy = deriv^2 . smooth ; Ixy = deriv_x . deriv_y + normalize by scale / scale^2 + resp = (Ixx>>12)*(Iyy>>12) - (Ixy>>12)**2 [sub_18000CE80] + 8-neighbour NMS + threshold + distance-dedup -> keypoints [sub_18000CF90] + +Every separable pass accumulates (pixel*tap)>>SHIFT per-term (the +truncation is why Ixy never recovered as one linear kernel). Validate the +port bit-exact against captured harris_Ixx/Iyy/Ixy/resp planes (57x57) via +dev/diff_v30.py compare_harris BEFORE chaining downstream. +""" +import numpy as np + +# ─── geometry (decoded from orchestrator sub_18000AAB0) ───────────────── +GRID = 3 # 3×3 tiles +GRID_X = 10 # overlap half-width (v13 = 2*GRID_X = 20 total) +FILL = 0x800000 # Q16 mid-gray pad (= 128 << 16 = 8388608). The + # DLL pads with this value (verified empirically: + # padding-region values in captured F250 tiles are + # 0x800000, not 128). Using 128 propagates through + # the doh convolution chain to non-padding pixels + # and changes NMS results in edge tiles. + + +def tile_origin(i, j, h, w): + """Top-left (row, col) of tile (i,j). step = h/GRID; origin = i*step - GRID_X. + Verified against captures: tile(0,0)@(-10,-10), tile(0,1)@(-10,27).""" + return i * (h // GRID) - GRID_X, j * (w // GRID) - GRID_X + + +def tile_size(i, j, h, w): + """Per-tile (height, width). The last row/col absorbs the dim-vs-GRID + remainder, so for the 112-px frame the col-2 and row-2 tiles are 58 + (= 38 step + 2*GRID_X) while inner tiles are 57 (= 37 step + 2*GRID_X). + Matches captured F250 raw_tile sizes (57x57 / 58x57 / 57x58 / 58x58).""" + step_y = h // GRID + step_x = w // GRID + th = (h - (GRID - 1) * step_y) + 2 * GRID_X if i == GRID - 1 else step_y + 2 * GRID_X + tw = (w - (GRID - 1) * step_x) + 2 * GRID_X if j == GRID - 1 else step_x + 2 * GRID_X + return th, tw + + +def tile_image(img): + """Yield (i, j, tile) for the 3×3 grid. Tile size is (57|58)x(57|58) + depending on position (last row/col absorbs the 112-3*37=1 remainder), + mid-gray padded where it falls outside the image. Matches the DLL's + sub_18000A850 blit + sub_180009F50 pad (F250 raw_tile captures sized + 57x57 / 58x57 / 57x58 / 58x58 for the 9 tiles).""" + h, w = img.shape + for i in range(GRID): + for j in range(GRID): + oy, ox = tile_origin(i, j, h, w) + th, tw = tile_size(i, j, h, w) + tile = np.full((th, tw), FILL, dtype=img.dtype) + sy0, sx0 = max(0, oy), max(0, ox) + sy1, sx1 = min(h, oy + th), min(w, ox + tw) + if sy1 > sy0 and sx1 > sx0: + tile[sy0 - oy:sy1 - oy, sx0 - ox:sx1 - ox] = img[sy0:sy1, sx0:sx1] + yield i, j, tile + + +# ─── orientation weighting (dword_180120C00, dumped from the DLL) ──────── +# 7×7 quarter of a 13×13 window; weight[dy][dx] = GAUSS_Q[|dy|][|dx|]. +GAUSS_Q = np.array([ + [1669, 1541, 1212, 812, 464, 226, 94], + [1541, 1422, 1119, 750, 428, 208, 86], + [1212, 1119, 880, 590, 337, 164, 68], + [ 812, 750, 590, 395, 226, 110, 46], + [ 464, 428, 337, 226, 129, 63, 26], + [ 226, 208, 164, 110, 63, 31, 13], + [ 94, 86, 68, 46, 26, 13, 0], +], dtype=np.int64) + +# cos/sin tables are round(cos/sin(deg) * 65536), idx 0..359 (FULL circle — +# E090 uses directed orient [0, 2π), NOT ridge-mod-π). Verified byte-exact +# against the DLL .rdata tables at 0x180131050 (cos) and 0x1801315F0 (sin). +COS_Q16 = np.round(np.cos(np.deg2rad(np.arange(360))) * 65536).astype(np.int64) +SIN_Q16 = np.round(np.sin(np.deg2rad(np.arange(360))) * 65536).astype(np.int64) + +ATAN2_FULLSCALE = np.pi * 65536 # 205887.4 — sub_180003150 Q16-radian full scale + + +def orient_to_index(orient_q16): + """Convert kp[+0xc] orient_q16 (Q16 radians, [0, π·65536)) to cos/sin table + index in [0, 180]. Exact formula from E090 at e0c4/e0ec/e0fe: + index = (int) (orient_q16 · 180 / (π · 65536)) + (cvttsd2si = trunc toward zero, but orient_q16 >= 0).""" + return int(orient_q16 * 180.0 / (np.pi * 65536.0)) + + +# ─── exp lookup table unk_180130F80 (dumped from the DLL .rdata) ───────── +# 52 entries, table[i] = round(65536 * exp(-0.19531 * i)), table[51] = 0. +# Used by sub_18000FEC0 to evaluate Gaussian taps (idx = quantized -x²/2σ²). +EXP_TABLE = np.array([ + 65536, 53908, 44344, 36476, 30005, 24681, 20302, 16700, 13737, 11300, + 9295, 7646, 6289, 5173, 4256, 3501, 2879, 2369, 1948, 1603, + 1318, 1084, 892, 734, 604, 496, 408, 336, 276, 227, + 187, 154, 127, 104, 86, 70, 58, 48, 39, 32, + 27, 22, 18, 15, 12, 10, 8, 7, 6, 5, + 4, 0, +], dtype=np.int64) + + +# ─── 32-bit fixed-point helpers (match x86 imul/sar/idiv semantics) ────── +_M32 = (1 << 32) +def _s32(x): + x &= _M32 - 1 + return x - _M32 if x & 0x80000000 else x +def _sar32(x, n): + return _s32(_s32(x) >> n) +def _idiv32(a, b): + a, b = _s32(a), _s32(b) + q = abs(a) // abs(b) + return -q if (a < 0) ^ (b < 0) else q +def _imul32(a, b): + """x86 IMUL r32, r32: 32-bit signed multiply, result truncated to i32.""" + p = (_s32(a) * _s32(b)) & (_M32 - 1) + return p - _M32 if p & 0x80000000 else p + + +# ─── kernel builders (byte-exact vs the DLL; see dev/port_gradient.py) ──── +def gauss_tap(coef, x): + """sub_18000FEC0: one Gaussian tap = EXP_TABLE[|quantized -coef·x²|].""" + t = _sar32(_s32(coef * x), 2) + t = _sar32(_s32(t * x), 8) + q = (_s32(t) * 0x51eb851f) >> 35 + if q < 0: + q += 1 + i = -(q >> 13) + return int(EXP_TABLE[min(max(i, 0), len(EXP_TABLE) - 1)]) + + +def build_gaussian(n): + """sub_18000FF00: normalized 1D Gaussian (size n) → [(offset, tap)], Q12.""" + sigma = _sar32(_s32(0x26600 * n + 0x59acd), 10) + coef = _idiv32(0xe0000000, _s32(sigma * sigma)) + taps, s = [], 0 + for i in range(n): + t = _sar32(gauss_tap(coef, 512 * (2 * i - n + 1)), 4) + taps.append(t); s += t + norm = _sar32(_idiv32(0x40000000, s), 3) + half = n // 2 + return [(i - half, _sar32(_s32(t * norm), 15)) for i, t in enumerate(taps)] + + +def build_3tap(scale, deriv): + """sub_180010280: sparse 3-point kernel at offsets ±scale. + deriv → [1024,0,-1024]; smooth → [c, round(c·3.33), c], c=2^20/(scale·0x2aaa). + For scale 1: smooth = [96,320,96] (sum 512).""" + if deriv: + return [(-scale, 1024), (0, 0), (scale, -1024)] + c = _idiv32(0x100000, _s32(scale * 0x2aaa)) + mid = _sar32(_s32(c * 0xd55) + (1 << 9), 10) + return [(-scale, c), (0, mid), (scale, c)] + + +# ─── separable apply: per-tap (pixel·tap)>>shift, convolution, with optional +# constant-fill border (matches the DLL's out-of-bounds = mid-gray reads in +# the pre-smooth + descriptor-gradient pipeline). The default `fill=None` +# preserves the historical replicate-clamp behaviour used by DoH/NMS. +def _conv_axis(img, kernel, shift, axis, fill=None): + n = img.shape[axis] + idx = np.arange(n) + acc = np.zeros(img.shape, dtype=np.int64) + for off, tap in kernel: + if tap == 0: + continue + src_idx = idx - off + if fill is None: + src = np.clip(src_idx, 0, n - 1) + vals = np.take(img, src, axis=axis).astype(np.int64) + else: + in_bounds = (src_idx >= 0) & (src_idx < n) + clipped = np.clip(src_idx, 0, n - 1) + vals = np.take(img, clipped, axis=axis).astype(np.int64) + shape = [1] * img.ndim + shape[axis] = -1 + mask = in_bounds.reshape(shape) + vals = np.where(mask, vals, np.int64(fill)) + acc += (vals * tap) >> shift + return acc + + +def apply_sep(img, kx, ky, shift, fill=None): + return _conv_axis(_conv_axis(img, kx, shift, 1, fill=fill), + ky, shift, 0, fill=fill) + + +# ─── DoH front-end — BYTE-EXACT vs gradin/g380 captures (interior) ──────── +def presmooth(tile, size=5): + """sub_18000F250 → sub_1800101C0: separable Gaussian (shift 12) of the Q10 + tile, then <<6. == CC20 input (g380 call1_before), 0 mismatch border 2.""" + gk = build_gaussian(size) + return (apply_sep(tile.astype(np.int64), gk, gk, 12)) << 6 + + +def cc20_planes(smoothed, v9=1): + """sub_18000CC20: Ixx/Iyy/Ixy from the pre-smoothed tile. Each + sub_180010380 pass = (>>6, separable kx·ky shift10, <<6).""" + dk = build_3tap(v9, True); sk = build_3tap(v9, False) + P = lambda im, kx, ky: (apply_sep(im >> 6, kx, ky, 10)) << 6 + buf20 = smoothed.copy() + buf28 = P(buf20, sk, dk) # prep1 → Dy + buf20 = P(buf20, dk, sk) # prep2 → Dx + buf20 = buf20 * v9; buf28 = buf28 * v9 # norm1 ·v9 + ixy = P(buf20, sk, dk); ixx = P(buf20, dk, sk); iyy = P(buf28, sk, dk) + v10 = v9 * v9 + return ixx * v10, iyy * v10, ixy * v10 # norm2 ·v9² + + +def doh(tile, size=5, v9=1): + """gradin tile (Q10) → (Ixx, Iyy, Ixy, response). Byte-exact (interior). + response = (Ixx>>12)·(Iyy>>12) − (Ixy>>12)² (sub_18000CE80).""" + ixx, iyy, ixy = cc20_planes(presmooth(tile, size), v9) + resp = (ixx >> 12) * (iyy >> 12) - (ixy >> 12) ** 2 + return ixx, iyy, ixy, resp + + +# ─── keypoints — NMS (sub_18000CF90) — BYTE-EXACT ✅ ───────────────────── +# Validated set-, count-, AND order-exact vs nms_* captures across all 12 tiles +# (dev/port_gradient.py-style harness; t_lo=671, t_hi=168, dedup_q=72064, +# margin=10 for this hardware). Captured 32-byte CF90 record layout (i32): +# [0, 0, 0, 0, abs(resp), x, y, 0] — fields 0-3 + 7 are zero out of CF90; +# upstream code fills active/tile/global-coords/quality fields later. +def nms(resp, t_lo=671, t_hi=168, dedup_q=72064, margin=10): + """8-neighbour NMS on the response map (sub_18000CF90). A pixel is a + keypoint iff `resp > t_lo`, `resp >= t_hi`, and strictly greater than all + 8 neighbours; score = |resp|. Dedup radius² = ((dedup_q>>6)²)>>20 — with + the captured dedup_q=72064 this is 1 (i.e. effectively a no-op given the + strict 8-nbr max already excludes adjacent equals). + + Args from the ctx struct: t_lo=ctx[+0x20], t_hi=ctx[+0x24], + dedup_q=ctx[+0x48]; margin from CF90's r9d arg (=10 for this hardware). + Returns a list of `(score, x, y)` in raster-scan order (y outer).""" + h, w = resp.shape + r2 = ((dedup_q >> 6) ** 2) >> 20 + kps = [] # (score, x, y) + for y in range(margin, h - margin): + for x in range(margin, w - margin): + v = int(resp[y, x]) + if v <= t_lo or v < t_hi: + continue + nb = resp[y-1:y+2, x-1:x+2] + if v <= nb.max() and not (v == nb.max() and (nb == v).sum() == 1): + continue + s = abs(v) + dup = next((i for i, (_, kx, ky) in enumerate(kps) + if (kx - x) ** 2 + (ky - y) ** 2 <= r2), None) + if dup is None: + kps.append((s, x, y)) + elif s > kps[dup][0]: + kps[dup] = (s, x, y) + return kps + + +# ─── subpix refinement — sub_18000D5D0 + sub_18000D4C0 (byte-exact port) ─ +# After NMS, each (integer) keypoint goes through a Hessian-Newton subpixel +# refinement on the response map. 9 resp values around (x,y) build a +# symmetric Hessian + gradient (with specific SAR shifts), a 2×2 Cramer +# solver finds the apex offset (Δx, Δy) in 1/128-pixel units, and the result +# is written back as Q16 — OR the keypoint is REMOVED entirely if the system +# is singular or |Δ| > 1 pixel (D5D0 calls sub_18000D570 memmove-down). +# +# Math (each shift mirrors a specific instruction in D5D0): +# dxx = (L + R - 2C) >> 2 [d6ee] +# dyy = (T + B - 2C) >> 2 [d6dd] +# dxy = (((BR+TL)>>2) - ((BL+TR)>>2)) >> 2 [d6d7..d6f6, two-stage] +# dx_neg = -((R - L) >> 1) >> 2 [d6b6,d6f9,d710] +# dy_neg = -((B - T) >> 1) >> 2 [d6da,d6e4,d6f2] +# D4C0 (Cramer): a,b,c,d,e,f are coeffs >>4'd, det = (a*d-b*c)>>7, then +# Δx = (d*e - f*c) // det ; Δy = (b*e - f*a) // (-det) +# All arithmetic is signed 32-bit truncating (idiv = trunc-toward-zero). +def _solve_2x2_d4c0(coeffs): + """sub_18000D4C0 — Cramer 2×2 solver. Returns (Δx, Δy) or None on + singular Hessian. `coeffs` = [a, b, c, d, e, f] (i32 each).""" + a, b, c, d, e, f = [_sar32(v, 4) for v in coeffs] + det_pos = _sar32(_imul32(a, d) - _imul32(b, c), 7) + det_neg = _sar32(_imul32(b, c) - _imul32(a, d), 7) + if det_pos == 0 or det_neg == 0: + return None + num_x = _imul32(d, e) - _imul32(f, c) + num_y = _imul32(b, e) - _imul32(f, a) + return _idiv32(num_x, det_pos), _idiv32(num_y, det_neg) + + +def subpix_refine_kp(resp, x_int, y_int, scale_shift=0): + """sub_18000D5D0 — subpixel refine a single integer keypoint. + Returns (x_q16, y_q16) or None if the kp should be removed. + `scale_shift` = ctx[+0x50]+0x60 (typically 0 on 06cb:00a2).""" + h, w = resp.shape + if not (1 <= x_int <= w - 2 and 1 <= y_int <= h - 2): + return None + cV = _s32(int(resp[y_int, x_int])) + L = _s32(int(resp[y_int, x_int - 1])) + R = _s32(int(resp[y_int, x_int + 1])) + T = _s32(int(resp[y_int - 1, x_int])) + B = _s32(int(resp[y_int + 1, x_int])) + TL = _s32(int(resp[y_int - 1, x_int - 1])) + TR = _s32(int(resp[y_int - 1, x_int + 1])) + BL = _s32(int(resp[y_int + 1, x_int - 1])) + BR = _s32(int(resp[y_int + 1, x_int + 1])) + dxx = _sar32(_s32(L + R - 2 * cV), 2) + dyy = _sar32(_s32(T + B - 2 * cV), 2) + dxy = _sar32( + _s32(_sar32(_s32(BR + TL), 2) - _sar32(_s32(BL + TR), 2)), + 2, + ) + dx_neg = _sar32(_s32(-_sar32(_s32(R - L), 1)), 2) + dy_neg = _sar32(_s32(-_sar32(_s32(B - T), 1)), 2) + res = _solve_2x2_d4c0([dxx, dxy, dxy, dyy, dx_neg, dy_neg]) + if res is None: + return None + dx, dy = res + if not (-0x80 <= dx <= 0x80 and -0x80 <= dy <= 0x80): + return None + scale_mul = 1 << scale_shift + x_q16 = (((x_int << 7) + dx) << 9) * scale_mul + y_q16 = (((y_int << 7) + dy) << 9) * scale_mul + return x_q16, y_q16 + + +def subpix_refine_kps(resp, kps, scale_shift=0): + """sub_18000D5D0 driver — refine all NMS keypoints, CULL failures. + `kps`: list of (score, x_int, y_int) from `nms()`. + Returns: list of (score, x_q16, y_q16). Length ≤ input (failed kps + are removed, exactly like the DLL's sub_18000D570 memmove-down).""" + out = [] + for s, x, y in kps: + r = subpix_refine_kp(resp, x, y, scale_shift) + if r is not None: + out.append((s, r[0], r[1])) + return out + + +# NEXT after NMS: orientation (sub_18000D920) + oriented BRIEF (sub_18000E090). + + +# ─── D920 orientation — BYTE-EXACT (per-keypoint dominant gradient angle) ─ +# sub_18000D920(rcx=kp_ptr, rdx=ctx, r8=scratch) writes kp[+0xa]=quality byte +# and kp[+0xc]=orient_q16 (i32, [0, 2π·~) at 0x6487E ≈ 2π·65536 scale). +# +# Algorithm (per disasm 0x18000D920..DF13): +# 1. Sample 13×13 patch centred on the ROUNDED subpix coord — `cx = (sx + +# 0x8000) >> 16`. (Truncating cy=sy>>16 gives off-by-one for fractional +# subpix.) Apply a circular mask: keep pixels with dx²+dy² < 36. +# For each in-circle pixel: +# ggx = ((gradX[y,x] >> 10) * GAUSS_Q[|dy|,|dx|]) >> 4 +# ggy = ((gradY[y,x] >> 10) * GAUSS_Q[|dy|,|dx|]) >> 4 +# angle = fast_atan2(ggy, ggx) (sub_1800030A0) +# bin = trunc-toward-zero(angle / 9830) (signed-div magic constant +# 0x6AAAAABD, shift 56-12) +# Smear: each pixel votes its (ggx, ggy) into bins (bin-6, bin-5, ..., +# bin) — 7 bins to the LEFT of (and including) the primary bin. +# 2. Accumulate two 42-bin histograms H_gx[bin] and H_gy[bin]. +# 3. Find max bin by (H_gx>>13)² + (H_gy>>13)². +# 4. Refine: orient_q16 = precise_atan2(H_gx[max]>>10, H_gy[max]>>10) +# (sub_180003150, a thin 4-quadrant wrapper around fast_atan2). +# +# Validated 1000/1024 byte-exact against orient_after kp[+0xc] (the 24 +# unmatched are tiles past F250_MAX with no captured raw tile — algorithm +# matches every keypoint that has a derived gradient). + + +def _fast_atan2(gy_in, gx_in): + """sub_1800030A0: fixed-point atan2 returning angle in [0, ~408960] + (full-circle scale ~2π·65086). Args ordered as (y, x). Strict <0 + sign branches — `jns` jumps if non-negative, NOT if ≤0.""" + r10d = _s32(gx_in); r11d = _s32(gy_in) + r8d = r10d if r10d > 0 else _s32(-r10d) + r9d = r11d if r11d > 0 else _s32(-r11d) + if r8d < r9d: eax = r8d; ecx = r9d + else: eax = r9d; ecx = r8d + ecx = _s32(ecx + 1) + eax = _s32(eax << 8) + if ecx == 0: + return 0 + q = abs(eax) // abs(ecx); sgn = (eax < 0) ^ (ecx < 0) + eax = _s32(-q if sgn else q) + eax = _s32(eax << 8); ecx_tan = eax + eax = _sar32(eax, 4); ecx = _sar32(ecx_tan, 6) + ecx = _imul32(ecx, ecx); ecx = _sar32(ecx, 4); ecx = _sar32(ecx, 4) + edx = _imul32(ecx, 0xFFFFF5D7); edx = _sar32(edx, 12); edx = _s32(edx + 0x23A7) + edx = _imul32(edx, ecx); edx = _sar32(edx, 12); edx = _s32(edx - 0x4AAC) + edx = _imul32(edx, ecx); edx = _sar32(edx, 12); edx = _s32(edx + 0xE522) + edx = _imul32(edx, eax); edx = _sar32(edx, 6) + if r8d < r9d: edx = _s32(0x5A0000 - edx) + if r10d < 0: edx = _s32(0xB40000 - edx) + if r11d < 0: edx = _s32(0x1680000 - edx) + edx = _sar32(edx, 8); edx = _imul32(edx, 0x47); edx = _sar32(edx, 4) + return _s32(edx) + + +def _precise_atan2(gx, gy): + """sub_180003150(rcx=gx, rdx=gy): 4-quadrant atan2 in [0, 2π·65536). + Wraps _fast_atan2 with sign-aware combinators (0x3243F = π·65536, + 0x6487E ≈ 2π·65536). Returns the orient_q16 stored at kp[+0xc].""" + gx = _s32(gx); gy = _s32(gy) + if gx >= 0: + if gy >= 0: return _fast_atan2(gy, gx) + else: return _s32(0x6487E - _fast_atan2(-gy, gx)) + else: + if gy >= 0: return _s32(0x3243F - _fast_atan2(gy, -gx)) + else: return _s32(0x3243F + _fast_atan2(-gy, -gx)) + + +def _angle_to_bin(ang): + """Signed-div magic-constant emulation: bin ≈ ang / 9830, trunc-toward- + zero (matches the DLL's `imul 0x6AAAAABD; sar edx,24; shr eax,31; add`).""" + ecx_shifted = _s32((_s32(ang) << 12) & 0xFFFFFFFF) + prod = _s32(ecx_shifted) * 0x6AAAAABD + edx_hi = (prod >> 32) & 0xFFFFFFFF + if edx_hi & 0x80000000: edx_hi -= 0x100000000 + edx = _sar32(edx_hi, 24) + return _s32(edx + (1 if edx < 0 else 0)) + + +def orient_d920(gradX, gradY, subpix_x_q16, subpix_y_q16): + """Reproduce sub_18000D920's kp[+0xc] orient_q16 byte-exact. + + `gradX, gradY` are the i32 first-derivative buffers at ctx[+0x50]+0x20/ + +0x28 (same buffers E090 reads — see descriptor_gradient()). `subpix_*` + are kp[+0x14, +0x18] in Q16. Returns the i32 orient_q16.""" + W = gradX.shape[1]; H = gradX.shape[0] + cx = (subpix_x_q16 + 0x8000) >> 16 # ROUNDED, not truncated + cy = (subpix_y_q16 + 0x8000) >> 16 + H_gx = [0] * 42 + H_gy = [0] * 42 + for dy in range(-6, 7): + for dx in range(-6, 7): + if dy * dy + dx * dx >= 36: # circular mask, radius 6 + continue + y = cy + dy; x = cx + dx + if not (0 <= y < H and 0 <= x < W): + continue + w = int(GAUSS_Q[abs(dy), abs(dx)]) + ggx = _imul32(_s32(int(gradX[y, x])) >> 10, w); ggx = _sar32(ggx, 4) + ggy = _imul32(_s32(int(gradY[y, x])) >> 10, w); ggy = _sar32(ggy, 4) + b = _angle_to_bin(_fast_atan2(ggy, ggx)) + for k in range(7): # 7-bin smear: bin-6 .. bin + sb = (b + 36 + k) % 42 + H_gx[sb] = _s32(H_gx[sb] + ggx) + H_gy[sb] = _s32(H_gy[sb] + ggy) + maxbin = 0; maxmag = 0 + for i in range(42): + a = _sar32(H_gx[i], 13); b = _sar32(H_gy[i], 13) + m = _s32(_imul32(a, a) + _imul32(b, b)) + if m > maxmag: + maxmag = m; maxbin = i + return _precise_atan2(_sar32(H_gx[maxbin], 10), _sar32(H_gy[maxbin], 10)) + + +# ─── Descriptor gradient pair — BYTE-EXACT (interior, dist≥3 from edge) ─── +# E090 reads two i32 buffers gradX/gradY at *(ctx[+0x50])+0x20/+0x28. These +# are the OUTPUT of CC20's first two separable passes applied to F250's +# pre-smoothed tile: +# gradX = P(presmooth(tile_q16), dk, sk) # deriv_x · smooth_y → Dx +# gradY = P(presmooth(tile_q16), sk, dk) # smooth_x · deriv_y → Dy +# where P = (im >> 6) → sep[shift10] → (<< 6). +# +# The DLL's tile input is Q16 (mid-gray=0x800000); F250 internally does +# >>6 → Gaussian smooth (shift 12) → <<6 to keep Q16 magnitude. CC20's +# outer loop is skipped in this code path (ctx_struct[+0x58] == 0), so +# the buffer pointers stay at the first-pass intermediates and never get +# overwritten with Ixx/Iyy/Ixy. (CC20 is still entered — only its inner +# loop is gated.) +# +# Verified byte-exact for the interior dist >= 3 of every captured tile; +# the outer 3-pixel rim differs because our apply_sep uses replicate-clamp +# while the DLL fills off-tile reads with mid-gray. NMS margin=10 + E090's +# N=7 sampling means keypoint patches never reach the mismatch ring, so +# this is non-blocking for descriptor extraction. + +def descriptor_gradient(tile_q16): + """Compute (gradX, gradY) i32 arrays for E090's BRIEF sampling. + + `tile_q16` is the Q16-format input tile (mid-gray = 0x800000), exactly + what F250 receives at rcx on entry. Returns two int32 (height, stride) + arrays matching ctx[+0x50]+0x20/+0x28 byte-exact (border + interior). + + Border handling: out-of-bounds reads → 0 (not replicate, not mid-gray). + Empirically byte-exact across the full 57×57 buffer; replicate-clamp left + ~480 border mismatches, mid-gray fill ~440. Verified against the captured + descbrief_gradX/Y for all 38 per-tile gradients in a 1024-keypoint run.""" + tile = np.asarray(tile_q16, dtype=np.int64) + gk = build_gaussian(5) + sm = apply_sep(tile >> 6, gk, gk, 12, fill=0) << 6 + dk = build_3tap(1, True) + sk = build_3tap(1, False) + def P(im, kx, ky): + return apply_sep(im >> 6, kx, ky, 10, fill=0) << 6 + gradX = P(sm, dk, sk).astype(np.int32) + gradY = P(sm, sk, dk).astype(np.int32) + return gradX, gradY + + +# ─── tile→global merge + v30 assembly ────────────────────────────────── +# sub_18000A910 — coordinate transform (called from sub_18000A960 per kp): +# gx_int = ((tile_offset_x_diff << 16) + subpix_x_q16) >> 16 +# gy_int = ((tile_offset_y_diff << 16) + subpix_y_q16) >> 16 +# Equivalent to `gx_int = tile_offset_x + (subpix_x_q16 sar 16)`. The +# (sar 16) matches Python's signed >> on int. +# +# sub_18000A960 — the per-tile→global merge: +# 1. For each kp in the tile's list: run A910 to get (gx_int, gy_int). +# 2. Bound check: 3 ≤ gx < W-3 AND 3 ≤ gy < H-3 (W=H=112 for 06cb:00a2). +# 3. If in bounds: copy the FULL 32-byte kp record to the destination list +# verbatim (subpix stays in tile-local frame; only the bound check +# uses the global coord). r14d/r15d are tile-edge adjustment offsets +# computed from byte flags at A960's arg5 — likely for handling +# keypoints near a tile boundary; not yet ported (no observed effect +# in the captures we have). +# +# v30 record format (per memory + the captured 18-byte structure): +# [u8 x][u8 y][16 B descriptor] 18 bytes +# x, y are the GLOBAL INTEGER coordinates (= the A910 output). +# Body = 250 records × 18 = 4500 bytes + ~17 B lead-in + ~16 B trailer. +# Header = [u16 tag=4][u16 len=4533][8 zeros]. + + +def tile_origin_yx(i, j, h, w): + """Top-left (row, col) of tile (i, j) in the 3×3 grid. Re-export of + tile_image's internal `tile_origin` formula so callers can use it + independently of the iteration.""" + return tile_origin(i, j, h, w) + + +def merge_tile_kps_to_global(per_tile_kps, h, w, margin=3): + """Merge per-tile keypoint lists into a single global list, applying + the DLL's bound check (margin ≤ global_xy < dim-margin). + + `per_tile_kps`: iterable of (i, j, kp_list) where (i, j) is the tile + grid position (matches tile_image — ROW-MAJOR: tile 0 = (0,0), + tile 1 = (0,1), …, tile 8 = (2,2)) and kp_list is iterable of + records whose first two fields are (subpix_x_q16, subpix_y_q16). + Any remaining fields are preserved unchanged. + + Returns: list of (gx_int, gy_int, *rest) tuples, in tile-by-kp order + (matches sub_18000A960's iteration). Keypoints failing the bound + check are dropped. + + The DLL stamps each kp record with its tile index at byte +0x9 + (range 0..8). Frame transitions are signalled by +0x9 wrapping + (going from 8 back down to a lower value on the next D920 call). + `dev/validate_merge.py` uses this to attribute captured kps to + tiles byte-exact; 1024/1024 captured kps fit in [3, 109) when + attributed via +0x9 + row-major (i = +0x9 // 3, j = +0x9 % 3). + """ + out = [] + for i, j, kp_list in per_tile_kps: + oy, ox = tile_origin(i, j, h, w) + for kp in kp_list: + sx_q16, sy_q16 = kp[0], kp[1] + # signed >> 16 matches the DLL's A910 arithmetic. + gx = ox + (sx_q16 >> 16) + gy = oy + (sy_q16 >> 16) + if margin <= gx < w - margin and margin <= gy < h - margin: + out.append((gx, gy, *kp[2:])) + return out + + +def build_v30(global_kps, max_records=250, tag=4, body_len=4533): + """Build the 18-byte-per-record v30 buffer that goes into a frame + section. Layout matches the captured v30: 12 B header + body_len + payload (~17 B lead-in zeros + N × 18 B records + trailer zeros). + + `global_kps`: list of (gx_int, gy_int, ..., desc_16B) from + merge_tile_kps_to_global. Only x, y, and descriptor are used — + orient/quality stay in the per-kp records elsewhere. + + Returns: bytes object of (12 + body_len) bytes.""" + header = bytes([tag & 0xFF, (tag >> 8) & 0xFF, + body_len & 0xFF, (body_len >> 8) & 0xFF]) + bytes(8) + body = bytearray(body_len) + # 17-byte zero lead-in (matches the captured records — empirical; + # disasm of the v30 packer is pending). + rec_offset = 17 + for kp in global_kps[:max_records]: + gx, gy = kp[0], kp[1] + desc = kp[-1] # last field is the 16-byte descriptor + if not isinstance(desc, (bytes, bytearray)): + desc = bytes(desc) + body[rec_offset] = gx & 0xFF + body[rec_offset + 1] = gy & 0xFF + body[rec_offset + 2:rec_offset + 18] = desc[:16] + rec_offset += 18 + return header + bytes(body) + + +# ─── WS-body v30 SECTION serializer ────────────────────────────────────── +# The v30 record area of ONE ws-body section. Distinct from build_v30() +# above, which models the *standalone* per-frame v30 buffer (12-B header + +# 17-B lead-in + body). A ws-body section is a PURE record area: exactly +# n_slots × 18-byte records, no header/lead-in/trailer. +# +# GROUND-TRUTH CONFIRMED (gdb capture ws_body_1780084103036_23056.bin, +# 2026-05-29): record = [x:u8][y:u8][16B descriptor], x/y FIRST (verified +# 250/250 against minutia_tables; the [16B][x][y] order is the match-QUERY +# serializer sub_1800057e0, NOT the stored template). Each section stores a +# full 250 real records (no padding in a genuine enrollment); we zero-pad +# only when our detector yields < 250. +V30_SECTION_RECORDS = 250 +V30_SECTION_BYTES = V30_SECTION_RECORDS * 18 # 4500 +V30_DESC_LEN = 16 # record = [desc:16][x:u8][y:u8]; x,y anchor is +16 into the record +V30_RECORD_LEN = 18 # full v30 record stride +WS_SIZE = 23056 # chip-view WS body size +DEFAULT_SUBTYPE = 0x00f7 # default WinBio finger subtype + + +def find_v30_regions(ws, min_run=30): + """Locate each section's v30 record array by detecting long runs of 18-byte + records. Returns the (x,y) ANCHOR offset of each region; the record layout is + [16B desc][x:u8][y:u8], so the record area (first descriptor) starts at + `anchor - V30_DESC_LEN` and the (x,y) bytes are at +16/+17. (Moved from the + retired moh_opencv module.)""" + regions, p, n = [], 0, len(ws) + while p < n - V30_RECORD_LEN * min_run: + good, q = 0, p + while q + 1 < n and 0 < ws[q] <= 112 and ws[q + 1] <= 112: + good += 1 + q += V30_RECORD_LEN + if good >= min_run: + regions.append(p) + p += V30_SECTION_RECORDS * V30_RECORD_LEN + else: + p += 1 + return regions + + +def serialize_v30_section(records, n_slots=V30_SECTION_RECORDS): + """Serialize one ws-body section's v30 record area. + + `records`: iterable of (x:int, y:int, desc:16-bytes). Truncated to + n_slots; short tail zero-padded. Returns exactly n_slots*18 bytes + (4500 for the default 250-slot section). + + TRUE on-wire record layout = [16B descriptor][x:u8][y:u8] (descriptor + FIRST), decoded from the v30 emitter sub_1800057e0 (2026-06-01) and + verified: parsing a stored section this way pairs (x,y,desc) 238-248/250 + against our single-frame extraction (vs 0/250 for the old [x][y][desc]). + The section's record area starts at (find_v30_regions anchor − 16), so + callers must write this buffer at `anchor - V30_DESC_LEN`.""" + out = bytearray(n_slots * 18) + for i, rec in enumerate(records): + if i >= n_slots: + break + x, y, desc = rec[0], rec[1], rec[2] + o = i * 18 + d = bytes(desc[:16]) + out[o:o + len(d)] = d + out[o + 16] = x & 0xFF + out[o + 17] = (y & 0xFF) if y is not None else 0 + return bytes(out) + +# NOTE: the per-section 24-byte v30 trailer ([u8 split][u8 secobj][22B +# orientation-CDF], sub_180005720) is enroll-only bookkeeping the verify matcher +# does NOT read (A/B-confirmed), so we leave the scaffold's trailer bytes as-is +# rather than regenerating them. + + +# ─── E090 oriented-BRIEF descriptor — BYTE-EXACT (validated 2026-05-30) ───── +# Validated against the GDB_DUMP_F250+DESC_BRIEF capture (session 1780170xxx) +# via dev/validate_descriptor_gradient.py: descriptor_gradient reproduces the +# chip's gradX/gradY byte-exact (34/71 tiles, limited only by F250 capture +# coverage), orient_d920 60/60, the chain (_descriptor_at) 40/40, and +# tile_image == F250 raw tiles (diff=0). The remaining match blocker is the +# keypoint CULL (sub_18000A1B0), NOT the descriptor. +# E090 reads gradient buffers from *(ctx[+0x50]): a struct with i32 stride@+0, +# i32 height@+4, qword gradX_ptr@+0x20, qword gradY_ptr@+0x28. (E090's r8 +# turned out to be a scratch-pool descriptor, NOT the gradient.) Pipeline: +# 1. Rotation+sampling (e380-e427): 16×16 grid xL,yL ∈ [-7..8] +# px = subpix_x + xL·cos − yL·sin + 0x8000 (Q16 → pixel via >>16) +# py = subpix_y + xL·sin + yL·cos + 0x8000 +# if 0<=px>8) + sin·(gy>>8))>>8 +# rotated_gy[i] = (cos·(gy>>8) − sin·(gx>>8))>>8 +# 2. Aggregation (e4a0-e5c0): 29 windows from *(ctx[+0x60]) (3 i32 each: +# window_size_idx, dy_off, dx_off). For each window, sum rotated_gx and +# rotated_gy over a r12d×r12d patch at index ((dy_off+7)·16 + dx_off+7), +# where r12d = small_local_table[window_size_idx] holding {7, ?, 3, ...}. +# Output: 58 i32 = the BRIEF compare input buffer. +# 3. BRIEF compare (brief_pack below — BYTE-EXACT ✅). +# +# COS_Q16/SIN_Q16 are already defined above (orientation tables, 181 entries). +# Orient index from kp[+0xc] orient_q16: idx = trunc(orient_q16 · 180 / +# (pi·65536)) — read 8-byte FP constants from 0x180130ee0 (=180.0) and +# 0x180130ee8 (=pi·65536=205887.416...). Range [0,180); ridge orient mod π. +# +# To enable this port we still need a capture of *(ctx[+0x50]) struct, +# gradX/gradY arrays, and the 29-entry *(ctx[+0x60]) table. Hooks updated in +# dev/gdb_dump.py (descbrief_gradstruct/_gradX/_gradY/_aggrtbl); re-run +# enrollment with GDB_DUMP_DESC_BRIEF=1 to grab them. + + +def _rotate_sample_pair(gx, gy, cos_q16, sin_q16): + """E090 inner rotation: takes raw (gx, gy) i32 samples, returns rotated pair. + Bit-exact emulation of the x86 imul/sar sequence at e3cd-e418.""" + gx8 = np.int32(gx) >> 8 + gy8 = np.int32(gy) >> 8 + rgx = (np.int32(cos_q16 * gx8) >> 8) + (np.int32(sin_q16 * gy8) >> 8) + rgy = (np.int32(cos_q16 * gy8) >> 8) - (np.int32(sin_q16 * gx8) >> 8) + return np.int32(rgx), np.int32(rgy) + + +def desc_sample_rotate(grad_x, grad_y, subpix_x_q16, subpix_y_q16, orient_idx, + N=7): + """E090 rotation+sampling (stage 1). Returns two int32 arrays of length + (2N+2)² = 256 (for N=7) — the rotated_gx/rotated_gy buffers that the + aggregation stage sums over. + + Storage order matches the DLL: COLUMN-MAJOR — xL is the OUTER loop variable + in the disasm (e306 cmp r11d, ..+1), yL is inner (e424 cmp r10d, ..+1), and + rdi/rbp advance by 4 once per inner iter. So index = (xL+N)*(2N+2) + (yL+N). + The aggregation step `field1*span + field2` then walks rows of xL (NOT yL): + `field1` = dx (xL offset), `field2` = dy (yL offset). + """ + stride = grad_x.shape[1] + height = grad_x.shape[0] + cos_q = int(COS_Q16[orient_idx]) + sin_q = int(SIN_Q16[orient_idx]) + span = 2 * N + 2 # = 16 for N=7 + rgx = np.zeros(span * span, dtype=np.int64) + rgy = np.zeros(span * span, dtype=np.int64) + + def _s32(v): + v &= 0xFFFFFFFF + return v - (1 << 32) if v & 0x80000000 else v + + for xi in range(span): # outer = xL + xL = xi - N + for yi in range(span): # inner = yL + yL = yi - N + px_q = subpix_x_q16 + xL * cos_q - yL * sin_q + 0x8000 + py_q = subpix_y_q16 + xL * sin_q + yL * cos_q + 0x8000 + px = px_q >> 16 + py = py_q >> 16 + if 0 <= px < stride and 0 <= py < height: + gx = int(grad_x[py, px]) + gy = int(grad_y[py, px]) + else: + gx = gy = 0x800000 + gx8 = gx >> 8 + gy8 = gy >> 8 + # rotated_gy uses `NEG ecx; SAR ecx,8` (e413/e415) — NEG first, then + # arithmetic shift. For non-multiples of 256, `(-v) >> 8 ≠ -(v >> 8)` + # by one (the SAR rounds toward −∞). Reproduce exactly: + rx = _s32(_s32(cos_q * gx8) >> 8) + _s32(_s32(sin_q * gy8) >> 8) + ry = _s32(_s32(cos_q * gy8) >> 8) + _s32(_s32(-(sin_q * gx8)) >> 8) + idx = xi * span + yi + rgx[idx] = _s32(rx) + rgy[idx] = _s32(ry) + return rgx.astype(np.int32), rgy.astype(np.int32) + + +def desc_aggregate(rgx, rgy, aggr_table, win_sizes, N=7): + """E090 aggregation (stage 2). For each of len(aggr_table) entries + (= ctx[+0x68] = 29), sum rotated_gx and rotated_gy over a w×w window where + w = win_sizes[entry.size_idx]. + + Buffer is column-major (xL outer / yL inner — see desc_sample_rotate); so + `field1` = dx (multiplies span), `field2` = dy (added). Window origin: + base = (dx+N)·(2N+2) + (dy+N). + The disasm at e521-e52a sums rgx/rgy in pairs from rdx, rdx+4 (and r8 buf + likewise) — equivalent to a contiguous w-long span starting at `base`, + repeated w times with stride 2N+2 (= the byte step at e552 `add rdx, r14`). + + Returns the 58-i32 BRIEF input buffer: [gx0, gy0, gx1, gy1, ...]. + """ + span = 2 * N + 2 + out = np.zeros(2 * len(aggr_table), dtype=np.int32) + for i, (size_idx, dx_off, dy_off) in enumerate(aggr_table): + w = int(win_sizes[size_idx]) + if w <= 0: + continue + x0 = dx_off + N + y0 = dy_off + N + sx = sy = 0 + for xx in range(w): + base = (x0 + xx) * span + y0 + sx += int(rgx[base:base + w].sum()) + sy += int(rgy[base:base + w].sum()) + out[2 * i + 0] = np.int32(sx) + out[2 * i + 1] = np.int32(sy) + return out + + +# ─── BRIEF bit-pack — BYTE-EXACT ✅ ────────────────────────────────────── +# sub_18000E090 BRIEF compare loop @ e5e0-e60e: +# bit[j] = 1 if samples[tbl[j].idx1] > samples[tbl[j].idx2] else 0 +# packed little-endian into 16 bytes (sub_18000DF20 @ e660: byte_idx=j>>3, +# bit_pos=j&7, dst[byte_idx] |= bit << bit_pos). 128 tests → 16-byte descriptor. +# Validated 500/500 byte-exact vs descbrief_samples + descbrief_desc captures. +# +# The index-pair table is runtime-generated (NOT in the DLL .rdata; we tried +# the 4 candidate tables). The 128-entry snapshot is inlined as +# blobs_a2.BRIEF_TABLE (loaded lazily below). Reproducing the generator +# algorithm is the remaining piece. +_BRIEF_TABLE = None + + +def _load_brief_table(): + global _BRIEF_TABLE + if _BRIEF_TABLE is None: + from .blobs_a2 import BRIEF_TABLE + _BRIEF_TABLE = np.array(BRIEF_TABLE, dtype=np.int32).reshape(-1, 2) + return _BRIEF_TABLE + + +def brief_pack(samples, table=None, count=128): + """sub_18000DF20 BRIEF bit-pack — byte-exact (500/500 vs DLL captures). + samples: 1D int32 array of pre-sampled gradient values around the keypoint + (E090 pre-fills these by orientation-rotated sampling — NOT yet + byte-validated; use captured `descbrief_samples_*` as oracle). + table: index-pair array (N, 2) of test indices; defaults to blobs_a2.BRIEF_TABLE. + count: number of binary tests (= descriptor bits; 128 here).""" + if table is None: + table = _load_brief_table() + a = samples[table[:count, 0]] + b = samples[table[:count, 1]] + bits = (a > b).astype(np.uint8) + return np.packbits(bits, bitorder='little') # → 16-byte descriptor + + +# ─── End-to-end frame extraction (single image → kp list with descriptors) ─ +# Wires the byte-exact stages into one call. Each per-tile pipeline runs: +# raw_tile_q16 → descriptor_gradient → DoH → NMS → per-kp (D920 + E090) +# Then per-tile lists are merged into a global list with bound filtering. + +_AGGR_TABLE = None +_WIN_SIZES = [7, 5, 3] # the small local table at [rsp+0x78..] in E090 + + +def _load_aggr_table(): + global _AGGR_TABLE + if _AGGR_TABLE is None: + from .blobs_a2 import AGGR_TABLE + _AGGR_TABLE = np.array(AGGR_TABLE, dtype=np.int32).reshape(29, 3) + return _AGGR_TABLE + + +def _descriptor_at(gradX, gradY, subpix_x_q16, subpix_y_q16, orient_q16): + """Compute the 16-byte BRIEF descriptor at a keypoint via the byte-exact + E090 pipeline. Internal helper for extract_frame_native.""" + idx = orient_to_index(orient_q16) % 360 + rgx, rgy = desc_sample_rotate(gradX, gradY, subpix_x_q16, subpix_y_q16, + idx, N=7) + samples = desc_aggregate(rgx, rgy, _load_aggr_table(), _WIN_SIZES, N=7) + return brief_pack(samples) + + +FRAME_KP_CAP = 250 +"""sub_18000AAB0 caps the per-frame kp_array to 250 (constant 0xfa at +[rdi+0x04] in the inline ctx struct staged by the AAB0 caller at line +180004cb4..180004cd4). Applied after the global resp-desc sort that +follows the per-tile A960 edge cull.""" + + +def _a960_passes_global_edge(sx_q16, sy_q16, oy, ox, h, w, ti=None, tj=None): + """sub_18000A960 + sub_18000A910 byte-exact: project (subpix_x_q16, + subpix_y_q16) into the global frame via the tile origin and return + True iff `3 ≤ gx < w - 3` AND `3 ≤ gy < h - 3`, with one corner-tile + tightening empirically observed in Wine enrollment captures. + + A910 computes gx = ((ox << 16) + sx_q16) >> 16 (signed shift) + so a kp at local subpix x=18.5 in a tile with origin x=-10 lands at + global x=8. Negative origins (the outer tiles in the 3×3 pad) are + handled by Python's arithmetic right shift on ints. + + Corner-tile tightening: for tile (i=GRID-1, j=GRID-1) — the bottom- + right corner — the gy upper bound is `oy + step` (= 102 for the + 112-px frame) instead of the standard `h - 3` (= 109). Without this, + my port keeps 7 kps DLL drops (all in tile 8 with gy in [102, 108]). + Empirically derived from 9-AAB0 Wine enrollment, where tile 8's + observed gy_max was 101 vs other row-2 tiles at 108. + + (ti, tj) are optional tile-grid indices. If omitted, no corner + tightening is applied (backward compatibility).""" + gx = ((ox << 16) + sx_q16) >> 16 + gy = ((oy << 16) + sy_q16) >> 16 + gx_hi = w - 3 + gy_hi = h - 3 + if ti is not None and tj is not None: + if ti == GRID - 1 and tj == GRID - 1: + # Bottom-right corner: oy = (GRID-1)*step - GRID_X; for 112-px + # frame oy = 64. Last-row step = h - (GRID-1)*step = 38, so + # oy + step = 102. Cap gy at 102 (exclusive), so gy_max = 101. + step_y = h - (GRID - 1) * (h // GRID) + gy_hi = oy + step_y # exclusive + return 3 <= gx < gx_hi and 3 <= gy < gy_hi + + +def extract_frame_native(image_q16, h=112, w=112, + t_lo=671, t_hi=168, dedup_q=72064, nms_margin=10, + subpix_refine=True, frame_kp_cap=FRAME_KP_CAP): + """Single-frame native feature extractor. + + Faithful to sub_18000AAB0's 3×3 tile orchestrator: per-tile NMS + subpix + + global-edge cull (A960), then a global qsort by abs(resp) descending, + cap to 250 (constant 0xfa), then a re-sort by (tile_id ASC, resp DESC) + via comparator A810 before D920+E090 run. + + Args: + image_q16: int32 array of shape (h, w), mid-gray = 0x800000. This is + the exact buffer F250 receives at rcx (Q16 image). + subpix_refine: if True (default), refine each NMS keypoint via the + byte-exact sub_18000D5D0 Hessian-Newton port (subpix_refine_kp). + + Returns: + list of (gx_int, gy_int, orient_q16, desc_16B) tuples — the global + keypoint list after merge.""" + image_q16 = np.asarray(image_q16, dtype=np.int64) + assert image_q16.shape == (h, w), \ + f"expected ({h}, {w}), got {image_q16.shape}" + + # Phase 1: per-tile detect + subpix + A960 edge filter. Collect + # surviving kps into a global pool tagged by tile_id. Keep gradX/gradY + # per tile for the later orient+descriptor pass. + per_tile_grads = {} + pool = [] # list of (score, tile_id, ti, tj, sx_q16, sy_q16) + for ti, tj, tile in tile_image(image_q16): + gradX, gradY = descriptor_gradient(tile) + per_tile_grads[(ti, tj)] = (gradX, gradY) + _, _, _, resp = doh(tile >> 6) + oy, ox = tile_origin(ti, tj, h, w) + tile_id = ti * GRID + tj + for score, lx, ly in nms(resp, t_lo=t_lo, t_hi=t_hi, + dedup_q=dedup_q, margin=nms_margin): + if subpix_refine: + r = subpix_refine_kp(resp, lx, ly) + if r is None: + continue + sx_q16, sy_q16 = r + else: + sx_q16 = (lx * 65536) & 0xFFFFFFFF + sy_q16 = (ly * 65536) & 0xFFFFFFFF + if not _a960_passes_global_edge(sx_q16, sy_q16, oy, ox, h, w, ti, tj): + continue + pool.append((score, tile_id, ti, tj, sx_q16, sy_q16)) + + # Phase 2: global qsort by abs(resp) descending (comparator A7C0), then + # cap to frame_kp_cap (= 250). + pool.sort(key=lambda r: -r[0]) + pool = pool[:frame_kp_cap] + + # Phase 3: re-sort by (tile_id ASC, abs(resp) DESC) (comparator A810). + # Python's sort is stable, so primary key alone preserves resp order + # within each tile group — but the explicit secondary key is safer. + pool.sort(key=lambda r: (r[1], -r[0])) + + # Phase 4: per-kp orient + descriptor, grouped back into per-tile lists + # for merge_tile_kps_to_global. + per_tile_kps = [] + current_key = None + current_records = None + current_tij = None + for score, tile_id, ti, tj, sx_q16, sy_q16 in pool: + if tile_id != current_key: + if current_records is not None: + per_tile_kps.append((current_tij[0], current_tij[1], current_records)) + current_key = tile_id + current_tij = (ti, tj) + current_records = [] + gradX, gradY = per_tile_grads[(ti, tj)] + orient = orient_d920(gradX, gradY, sx_q16, sy_q16) + desc = _descriptor_at(gradX, gradY, sx_q16, sy_q16, orient) + current_records.append((sx_q16, sy_q16, orient, bytes(desc))) + if current_records is not None: + per_tile_kps.append((current_tij[0], current_tij[1], current_records)) + + return merge_tile_kps_to_global(per_tile_kps, h, w) + + +# ─── Baked WS-body scaffold — makes native enrollment REFERENCE-FREE ─────── +# A genuine chip-accepted Wine template (wine_finger_fresh.bin, mode-A, 4 +# v30 sections) with its v30 RECORD areas zeroed — i.e. the TLV framing only: +# the 24-byte header, per-section counts, the sec0_pre inter-section pose +# table, the section-content markers, and the tail. This is finger-INDEPENDENT +# RE-derived constant data (same role as brief_table.bin / aggr_table.bin): +# we overlay OUR v30 records onto it at runtime and recompute the TID, so no +# captured reference template needs to be supplied. The real descriptors of +# the source template are NOT shipped (zeroed for privacy + clarity). +# +# The v30 record areas live at these fixed offsets in the scaffold (from +# find_v30_regions on fresh.bin); each is 250×18 = 4500 bytes. They're pinned +# rather than re-detected because the scaffold's record areas are zeroed (so +# the coordinate-run heuristic can't find them). +NATIVE_WS_V30_REGIONS = (309, 4913, 9453, 13993) +_NATIVE_WS_SCAFFOLD = None + + +def _load_ws_scaffold(): + """Return the 23056-byte baked WS-body framing scaffold (v30 zeroed), + rebuilt from the sparse framing chunks inlined in blobs_a2.""" + global _NATIVE_WS_SCAFFOLD + if _NATIVE_WS_SCAFFOLD is None: + from .blobs_a2 import build_ws_scaffold + _NATIVE_WS_SCAFFOLD = build_ws_scaffold() + return _NATIVE_WS_SCAFFOLD + + +def patch_pre_v30_near_identity(ws_body, regions): + """Set the WS body's inter-section rigid transforms to NEAR-identity so a + single-frame template (same records in every section) is self-consistent. + + Each pre-v30 zone (between v30 record regions) carries a run of 18-byte + rigid-transform records `[x:u8][y:u8][a:i32][b:i32][tx:i32][ty:i32]` + (sec0_pre's pairwise section-alignment table). For a template whose + sections are identical, the correct inter-section transform is the + identity — but the matcher's candidate-validity filter `sub_18000bfb0` + keeps a record only if `tx != 0 || ty != 0` (pure identity `{0x10000,0,0,0}` + is the DLL's 'unmatched' sentinel, `sub_18000b420`, and is skipped). So we + write NEAR-identity `a=0x10000, b=0, tx=1, ty=1`: geometrically identity + (`1/65536 px ≈ 0`) yet `tx != 0`, so the matcher still treats it as a valid + candidate alignment. + + `regions`: the v30 record-region start offsets (each V30_SECTION_BYTES long). + Returns (patched_ws_body_bytes, n_records_patched).""" + import struct as _struct + ONE = 0x10000 + ws = bytearray(ws_body) + span = V30_SECTION_BYTES + zones, prev = [], 0 + for b in sorted(regions): + zones.append((prev, b)) + prev = b + span + zones.append((prev, len(ws))) + + def rigid(a, b): + return abs(a * a + b * b - ONE * ONE) < ONE * ONE * 0.05 + + ident = _struct.pack('<4i', ONE, 0, 1, 1) # a, b, tx, ty (near-identity) + patched = 0 + for z0, z1 in zones: + best = (0, 0) + for start in range(z0, z1): + o, n = start, 0 + while o + 18 <= z1: + a, b, tx, ty = _struct.unpack_from('<4i', ws, o + 2) + if not rigid(a, b): + break + n += 1 + o += 18 + if n > best[0]: + best = (n, start) + n, start = best + if n >= 2: + o = start + for _ in range(n): + ws[o + 2:o + 18] = ident # keep [x][y] at o, o+1 + o += 18 + patched += 1 + return bytes(ws), patched + + +def native_template(image_q16, subtype=None, fill_all_sections=True): + """End-to-end REFERENCE-FREE native enrollment template. + + Detects keypoints in `image_q16` with the byte-exact native pipeline + (DoH → NMS → orient → descriptor — same code paths the DLL runs), + formats them into 18-byte v30 records `[16B desc][x][y]`, overwrites every + v30 region of the WS body with those records, recomputes the TID, and + returns the new envelope. + + The WS-body framing comes from a baked-in scaffold (inlined in blobs_a2) + — NO captured reference template is required. The scaffold provides only + finger-independent framing bytes (header, section counts, sec0_pre pose + table, section markers, tail); the (x, y, descriptor) content is ours. + + Args: + image_q16: (h, w) int32 Q16 image (mid-gray = 0x800000). The sensor + returns uint8; convert via `img.astype(np.int32) << 16`. + subtype: override the WinBio finger subtype (default DEFAULT_SUBTYPE). + fill_all_sections: if True (default), write our records into every v30 + region; if False, only the first. + + Returns: + 23136-byte envelope ready for db.new_finger() (= chip cmd 0x47).""" + from .moh_extract import compute_tid, _build_envelope + + ws_body = bytearray(_load_ws_scaffold()) + regions = list(NATIVE_WS_V30_REGIONS) + if subtype is None: + subtype = DEFAULT_SUBTYPE + assert len(ws_body) == WS_SIZE, f"WS body must be {WS_SIZE}B, got {len(ws_body)}" + + # The inter-section sec0_pre transforms must be NEAR-identity (load-bearing): + # a single replicated frame is self-aligned, and the matcher's candidate + # filter (sub_18000bfb0) skips pure-identity {0x10000,0,0,0} as the + # 'unmatched' sentinel — so near-identity (tx=ty=1) is what makes each + # section a valid candidate alignment at verify. (Confirmed on hardware.) + ws_body = bytearray(patch_pre_v30_near_identity(bytes(ws_body), regions)[0]) + + # 1. Detect OUR keypoints + descriptors from OUR image. + kps = extract_frame_native(image_q16, h=image_q16.shape[0], + w=image_q16.shape[1]) + # kps: (gx, gy, orient_q16, desc_16B), bound-filtered to [3,109) by A960. + + # 2-3. Serialize into the v30 section format ([16B desc][x][y] × 250, + # zero-padded) and overwrite every v30 region. (The per-section 24-byte + # orientation-CDF trailer is enroll-only bookkeeping the verify matcher does + # NOT read — A/B-confirmed — so we leave the scaffold's bytes untouched.) + section = serialize_v30_section( + [(gx, gy, desc) for (gx, gy, _orient, desc) in kps]) + target_regions = regions if fill_all_sections else regions[:1] + # records start V30_DESC_LEN before the (x,y) anchor — layout [desc][x][y]. + for base in target_regions: + start = base - V30_DESC_LEN + ws_body[start:start + len(section)] = section + + # 4. Recompute TID over the new WS body, wrap in the envelope. + ws_body_bytes = bytes(ws_body) + tid = compute_tid(ws_body_bytes) + return _build_envelope(subtype, ws_body_bytes, tid) diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index e3cba7f..bb4ea5c 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -10,17 +10,18 @@ from usb import core as usb_core from . import timeslot as prg -from .blobs import reset_blob +from .blobs import reset_blob, moh_enroll from .db import db, SidIdentity from .flash import write_enable, call_cleanups, read_flash, erase_flash, write_flash_all, read_flash_all from .hw_tables import dev_info_lookup +from .init_data_dir import PYTHON_VALIDITY_DATA_DIR from .table_types import SensorTypeInfo, SensorCaptureProg from .tls import tls from .usb import usb, CancelledException from .util import assert_status, unhex # TODO: this should be specific to an individual device (system may have more than one sensor) -calib_data_path = '/usr/share/python-validity/calib-data.bin' +calib_data_path = PYTHON_VALIDITY_DATA_DIR + 'calib-data.bin' line_update_type1_devices = [ 0xB5, 0x885, 0xB3, 0x143B, 0x1055, 0xE1, 0x8B1, 0xEA, 0xE4, 0xED, 0x1825, 0x1FF5, 0x199 @@ -692,7 +693,7 @@ def calibrate(self): def cancel(self): usb.cancel = True - def capture(self, mode: CaptureMode) -> typing.Tuple[int, int, int, int]: + def capture(self, mode: CaptureMode) -> typing.Tuple[int, int, int, int, bytes]: try: assert_status(tls.app(self.build_cmd_02(mode))) @@ -728,27 +729,43 @@ def capture(self, mode: CaptureMode) -> typing.Tuple[int, int, int, int]: if l != len(res): raise Exception('Response size does not match %d != %d', l, len(res)) - res, img_data = res[:12], res[12:] - - x, y, w1, w2, error = unpack(' 12. + img_data = b'' + if l > 12: + img_data = res[12:] + # The feature frame is x*y bytes. Each get_prg_status2 returns up + # to 8192 bytes, so pull chunks until we have the whole frame. + # Cap the number of follow-up reads so a sensor that reports + # l > 12 but never streams x*y bytes errors out instead of + # looping forever (8 reads ≈ 64 KB, well over any frame here). + expected = x * y + max_reads = 8 + while len(img_data) < expected: + if max_reads <= 0: + raise Exception('capture: image underrun, got %d of %d bytes' + % (len(img_data), expected)) + max_reads -= 1 + res = get_prg_status2() + assert_status(res) + res = res[2:] + l, res = res[:4], res[4:] + l, = unpack(' int: rsp = tls.app(pack(' Date: Tue, 2 Jun 2026 15:49:59 +0300 Subject: [PATCH 05/17] fix for multiple finger enrollment --- validitysensor/sensor.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index bb4ea5c..0834516 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -862,7 +862,6 @@ def enroll_moh(self, parent_dbid: int, subtype: int, Returns: the recid created in the chip's storage.""" import numpy as np - from . import blobs from .moh_native import (extract_frame_native, _load_ws_scaffold, NATIVE_WS_V30_REGIONS, @@ -950,7 +949,7 @@ def enroll_moh(self, parent_dbid: int, subtype: int, # send_finger() doesn't wait either, and it works. logging.info('enroll_moh: storing on chip...') db.db_info() - assert_status(tls.cmd(blobs.db_write_enable())) + write_enable() try: msg = (pack(' Date: Tue, 2 Jun 2026 16:43:23 +0300 Subject: [PATCH 06/17] reset blob reverted --- validitysensor/blobs_a2.py | 428 ++++++++++++++++++------------------- 1 file changed, 214 insertions(+), 214 deletions(-) diff --git a/validitysensor/blobs_a2.py b/validitysensor/blobs_a2.py index f8a3495..35d5ac3 100644 --- a/validitysensor/blobs_a2.py +++ b/validitysensor/blobs_a2.py @@ -69,220 +69,220 @@ reset_blob = unhex(''' -06020000016355f8c7f5dab81cc6068083fa2c534c19a3d2b0bec2b5ba3da1bb2fe658722c342227e9fb17500e880350602ee2429271cd14 -4b488c2d4976f4cc100c929c99d98f49abc2a85287983d64ff23b9d81b4f7e1ef67b9b3bf60ef09593d7d77f5005d2b079df2b3e0888c74c -662db1a483bfb57534febdba3d66615f5b18d99b418aca42b6f2d46d0406c4d8f508c9e9893dc026523334f29c9fd033354987a03ae607f8 -54db034bee378158e42bd21175a4e13039fba21a17de24c7dcf7f668bb7528d0a73fc4de31927f223f04e2e8f7d7f96fc7cf8b8317111581 -10d3496c569a894ccb5a11ef8c9fab5727b14361e2b389897a45b8263f10a3cd7832bfee7fead820377f59f5bc9ef1993e6fa18359a0d3b4 -5fd3113917d9c9cbef65913a38eae136ef5eff5f89acf30bebc3f82b03420d27984fba66113dea3154ba1cda7ec3e4bd564ac5d7acf51408 -634ce8d5dc69db92b73ae5cef0c74e7a0eb95cfd6ea9e486a4c62a3428386691da7e3c3504efce2621e339a830a9ae87dd8f416170c6e2d3 -d67636570ef354ca7e8bd0b3f5bc8b5b217bfe34619f28f377030b68d84d9b24a359c2e6ce22b4f6a94029451a42d126b7578febf52be089 -21443af64129bb31ce6cbe8b2c4931e5a99b261e79ae9a9731d88f9848f8af6d73642e9afae9ba7530d278633a7e38d0aa5191cadb9b6ff7 -3f1baf1fdddc7f599b9c27fe0251d622e47fdfa4628d1fd83f45d2da055cb085415f56c5032d2dd17a93fa8734ef9e96fc554dd5b7408240 -04bec831935f10117323ce7461c8253d3fca644ed855c5423b7f790e186fa8cfa25dd0719b8d979d5c6593293cccb680a5228e5a8703bb44 -1826bd2d2d07ef87149e7aec77b42c3809914a6499e31bef83a90f5ba69a5a1f11349fe376239ba0dafd319dea9f363de25faaacd6724a42 -cb7982afea42ef60a789c6b7ff6956c8365c2d3ec9bde293ce7d15449fad7084f342a5695b6c229063962d6b942646f68149300130546181 -5634e99ca47a279ad387000eea563d1f6b9e045cca7d0ef9532fddda4d2fd64892aae113fb48d60198d36c4e1b24df7ee136e94449f5bb4d -d2f4febe8a7ebecf7a4be1f285e2a1acd3e9d3971ad8b8f19cfaf092091e550c95d7941d5581e00721610addfad86695c181f63ad80c95b3 -95aa9bd8a71ffce2a7479e4bc8eeb9129effd49bdc0b54ad4a6c61d06b49d76bc7b56085fe0c81bbc70792d51319135c25a7c3103d15e041 -dd7b9798172d852897516c503b8d267be97a73631e47354986190234f2dfee7ec2c223c67456f8f7d3946f47c8e900462537532f2a2a4d96 -1de55bd4eb4ba96708c0a45b8d81dea374dd9228128584e0cb3d7c5a7914d2f1173881eaf9a8521169a88701894b43b920abf96b832e32b5 -9f53c5f69b5b1608ebe83153c5b9f1af3af1125b6aa6b69e0d4e710832b341bc6692a1dfb357b77bff188573fb3bf187757accd8f073cc1c -6be07b363d63c1206a203fc0a785eb2c8133e0a40c525def66b94a28e218ecd47482ded99ce51fa3c4a33ab20fe99d834ba39b5096acb3c3 -fc1dbcf9a16b8b936ef56390446a731d277068e991a68cd86c780818812b4e6662148b0725cbcb06c4871f651307e183b0514d6d54bce19e -7a6b3e05a6dc275fddb3a2343658929a6bcb640ef2f3f6ae4e040daf7971cd0901ccf9b26e132cb5c48cf23f53c5c761a9bee845a2759891 -6304e35f29b568fc320a6ebc86d37c3a721b8d002e74c04c5a7bc8ec36923ae8554110ff126f67024c5ab2a4c05925957bb358a583e471b7 -17aa9809f491868ffb2a113606eaafe2d2db5e62d6113da7d35a69b799a2bf22623049817057bf147ffdb424c7ad88167ab2abc4d27ba7c2 -4fbe713c549fcaef71db57de58a987a36bb753ab2f724d78352e52d66e10a8c4f30c0daa694746b677c2d9d4600bd3c686ec4b5bdc121f6e -86e11fcb05e01651eb7ec77264bab5cd58527a176565464a66bc21f7f633bded92c50f7ffaa348a56b1523adcce4526e1cfdbc00dd678e3c -006f6091c983748f418656b3e3ed98e1b8a1ab3776540b8e23c56beffbaf083083ac6c2186c55c3c0942c649dde4f2d3275648f3b566887d -f85b9bbecac195251a5fca772c04198269cbcf75094ffab3ad17355bb4b84fcda873ea27ef4a121191aa2b4183718be13ad3ee92fad39d75 -2c4a8ea572ac6c2f03682ae2f95409051a6840757984145b1183654816fda56bd101dd845c0375497926d9b187135ae30aa62729441382ec -c0e226a597cc427078635f178ff47d2d6d0a5fe25064d2ae9e6e271454397898361c6617531ba16e69067a7b575c304326b9a9485e89e2ba -1d84f30a3db443a81864618b02ae31aa9188636bf7feedb0b616170bb045b5e13a740cbda52060ea8e6ea3175b05cfaaa463be82f8dead87 -003d0ff1db6f68144a068639d36def15612de51cb0b8eaebb05b4453962789bdf1ffd6048644f4cd467b22fa2a5dade3f0f502b178a846fc -39ba4872606d9a56f03ae4dc34389298cfe42555bd1042edad461f1b02d90e3ed8eae4bbe7e5bd05081f7dbabb8202c62d4ea46d88050760 -74572d1d697d2e3795773ce979d933bcd7cb5f5978d6a2f3325bc27217c45d546b730590f53cee24b5837c1ba60edfdeb137528a553debf6 -4f3e39761e8cd0a823129e7b500672a69fe5ab9a901e2778743fea6a3d809575b45008b6e7fb2c0e38c9a27c2a2e97ccc17d6868cf4ae315 -4395894ecb670266b2a9af8bf9a9aba9e594927f0e5b009e57e0d6616338a049d3c22bb343835ae95dae4b99692d66990d550d994a07f7be -8aa8f09257f779dd571cc81bf58c9d01ec2ccdc93fb291c92e1b84e94f98bab618ca4d0301e13ad7ba39a821452ae14aa52d70bbf5feeede -8e9fded5e7bb289d059578627516c6ac4a6e8474b8940b7694cfc95c11a5d744b5744a8d6de45ff5e2079eb543efb515674d0d1811902871 -320014f6cdb6e6894d2473c3aece029f6df8e6a69ba3723269c046e09cae67100f95bf8ff372c0392c40e14ec4802cda6b0d941950d0ffdf -b81423973dcddd16a8f04439826f6e5f6a737aeaf1d0903b3d78a82af5a692c08efb9a9de3fedc9b8368efcac9c0cd3d50e1716af12b7b39 -d29dba086ee2ba1f75a82eb8d66f3f4e7910d6bdf2a5fe03ab529c395e0aed901e50173a07925cd76e4f1beb238390f473e3a750d718fd14 -f2bebcf80a88518a8b666f9ac935cb77c21b795ad874d4dd64ff3a50f52870b09a05806882f99408343a461a8278b34ba60e876b4850d3b8 -ff4f64610a716955296096f24eb04055bada487efd5d01aaf68cffea1d9a22a76090f1c2bbe59f066ab9be902f19852abff126e834b080b2 -1e7056c845a951a59a0940e4f8937b99f22852f382611fd81649c121038f8c47af7b369dc4b5bb61a6173fdc7b788c782531e48748e0911e -ee9b49b4a79bbb3ae9dbda6b3ab85f8fdcc454ac67d7f405ecd551a1afcaa400c1ea70709ea018c9a4c17e11ae2b3eaa2df1b4bd64bb3e48 -a5c52f77d5e78e81fa05297c60952a6bc50fe3e5e8b532cbabef944a93259a40c4b6979c9438a7b7116cf5694412214809b9a879c7b73437 -24864a3eb920d3b0022c61bdd6fbc43fcc64b8ee6998ba3aa105ce9e62a0733fc48e966530f9b15402cb9b3c469812a0ad4ee8ae6dc49746 -885a2acfb241b7e520d1de3549474b2c5b170fd1c069617a012b17cb17aea3c1c33791150071927c3e0a7438ade787a0ef60136e28108f5d -165d57e5132768708da3fc9c1754ddb16823a8502850a6d0ad0b6862e6cad8b6199c9c7340bebe9c8b03e8cbb175a8a1a2e2a9886bd6bead -7a72a985f5c2d5945ec9efc590cb52fa48448d094747292c56912d895bce81cc2cff860f2b460ddb05962baba9d7a76beeffe5dda9a8f99b -1f426a045d119f2d9e78475c3ae1555b6f1f40c0f1958d63e31e78c392ca444ef3aed07b29f9c209dab6cffeab5684b813febec7bd35005b -b02ecdb477f7ae98244a2bbd0cc0453cc726434f8beb41e1bb1b0ff950090c9e64c12c22eee9e823c755d41c5852562eb5596189932a05fd -a95dd13e94fdbd9a57c456c1d7c8a452d2bf6213e420f267deb1b056ed15986175be4e374831908507d161b0708d149dcab79935c4abd126 -2f81e743b02c687d3a3d9fa995ec84ec35f887fb8cac350798993563aee87c7dc2af01b4e10e5b3da3d74e123db2fda8b6f9ee7713d5c577 -871006fd75dabadeb8e98ed345c78213a18369264f7a4eaddd0a76d4e4aff54ff96b076a1e29143244726fa2b7856175d6ddba1c5f5724a2 -c86bfee9d8955b55a12c63b990146bf771e049581f2cee0cd4cdeab6fe6142aa4c32db501ec91849711172b941d27f935a10fbf098587e79 -3952b206fa13b874212052127aa61d718e7f4686bd3894dcbd14bd901cd40db4fae2bbe3cf035dae9db9eddc78ad939184d5eadc33b3b3c6 -56f54ec50715502f390e7efcba856515b229821e51cfe771efc8db617df38d2f4dc4bc9fa8b1d0d49fd9f5fbae00edbe4d13dcac4b489399 -bd4505475746f4baa49fba550bf795ebf64eb5118278b48e86c088138f7082fa2b395222ffaedae6e09fa68af635fe6fc2e794c632d1adb0 -6a81ec376cc5ad3ab48a5d06ec5644f86123f699a6d0f16e6708bfa28451d006336bbcbf3e81ca672552b4aff74a0c93a0b63f2fab7f6d9e -bbee809f56f6d576502141eff8b9d90a714cd5dcd6e90ec13b3884d189a690edadd0b2b07a27841a25ad6e595dafcc9d1497b2a629c5d2e9 -68f387dcabba9e4fda759d6b34b7ce4ce83ca67c524b76b7a9f62c5bdcdd416b1fa8af84b4b86487b07227e698b376ba9b2c264a327baa50 -ff2cc1201f6d20dbb6dcd94818aa883773901c2f54ab02ec4047659328fd2a19bfbea419ed0a28c9e6be9b61c176062f8fecd86a0926fd91 -9500a422a0c94b11e052c5d431e45f1f4325fc93c99d7c707b65f1d2ee151f2a2077750e33c187e4ebc33f698a903b63de44bdbd86a848dd -6dc8d723f83a0ad55e59d43b6b31b75d089938e38c47f14ee8c99f2ec9aad35c23b36b25a8422d5fdaa30ab07d6814df4b30eb97d753910a -4b8e67fea66aabd0cf557d92fd5de8dd461343656faf6e528d9a50bd5b104f7966fb45bab992ebc705c90fa8672dec5e83e6b60541f8d800 -9f603bb578fbbf8a6c95ddc322c5ed8a51ad62d45fbd8e68b5ec09df2c5549a7a55e9979dcd18706afbda1fac2ffa0477b0efb8fd00c469f -b5f9db5832ea0ec809b568be9ad6f4876190ba9ab26c312f3c351b8d8fc5b3a108f52a5406d1df47ccfa93c0f263a5627e5d9d597f9447b1 -9fda52a727f05ad8cb3b94348163c9f1f7245760b8dd8da79334d65b3652ce5fc1419ceaefd72d20c3603e7206437cf064343962b5964a79 -976adb2802af115c5946eda9fcf1422eb0fa785be83cf03e96ab8417e8ee10b7f2247e7bd8722a4d686ef280bd7ab58a2a181f9eda28a400 -a54da86300db07649d8539a04870a36cfdfbafc892a6e220c39efbb484c1bd9b32a7bc66aa23c2bb3422a9b5e93cdc960fc218895ad0343c -539d1ecc767cb74e67c5c1e7ce1f99f5926457ea46d3f964109af3e8f223fe84ecf9e3209794fe9f5d4dbb3c26f325849a1c8ad35493b001 -86277defbf31d1682dceeda752fec7f17df41ad265b6a015f3c32f65a1def9ca0646d1d5f7a872e0032a9e2de13cb8124f5f7ad2a5603a70 -6f0c2918438e9a4cd21c14a07e4d94aa5c24d0cac9c1030f16666dc2b91458812628f935ea36eb1cb17857a9a6d831c92a2debf30987d174 -aa97ada7e7ff3267d9545305bd662b0053565d6789d90b01c9755eb6ab53d8248e4d3f948e03340bac313b1abdc5f94842d7a2e5e03741df -19cc12fd496b93eb7ce5479dffd814e424073c4d9bb01ce2d3f0834a3f67b7bb2dfa5eaed364aaa57b3554739e72010417d10f7548735aca -a27c0eef897b433f880413b1fa277f2d54973e9d672241f6f6ff671880e9459c8861982d126e0da360530a9a2fb0809e58f6aef27673d7e5 -44a9ff1dda206906bff2ef60da273d4a42b4ce2db1adffea3fc5cb1c26074188f2704e7cbf19a390e466df3845e0398a7f320bb5e90103c5 -76bba49f0dd52deb660f501c36050a085241cdc9654f86c1604fd9216a27a8088637a44d3f52133fc28cdccbdb8aae2983dcf19318a44fe3 -f9fa463a3fcd08cb20ceaf9cda22addbc6bc1a6adbfbaf188eb4ef4c2cc8d5337284b2ff80059148ad9bd5cee58f26b3c0f00287264a2619 -fc8a036e00767c86cbc9ca468bb695b58b3aec64544963dd2bcf115f2a18a9c7aacddfa0f48da956d906add1eed626cc28ffc32e80dbd183 -4b58174da90e4a623140d1c5e97a163884ea976dc08cf6b42dd1a61136e84b0abbd8b5d35d974588e105c4a9a89d1f66b7b1d047017ed61f -448c2c4c7765e7c4f5dd16e2cf47c55469481595e61e003e2894aa5721ecd527bf2b3f4013f4786e057f5bb49fb2b723be7e56e9c67b525f -306ed200668bfb901b0176c62161abacac752c3a8e17e387f97ca46ff14763fa2fc277c802b2ae082aedef176da91c2792b1eb485bb7b9df -b2f11565e721075639cd0c3754990cf46d50eda965098b5a60072f5e31e33ba33f200bfb6e0e7d0909db1fa254eccec1df0d2f8e0003f2f0 -cb0ebc5a98bb015ed75763a39fa9d2c4032ef3b15f12afbcb82023779ec098ecbd3e0803b88f34a7cc2cc15226d32f407363351c1ce3d64d -6a9000084fb4ecfe863b7e9b04647b83ad5688b0853eea95de316b70299c7d10c3522eccf7dd259b8852b7957dce7a5f437e0fd876c8090d -1e84b20202950d70cc763b29738c5957f284756d179619e88555511a8721aff0ff617d96fb6bb54362db6f2552c76d12e12eff04734d9a60 -221591cef489e9ca87d215d958b95f49f34b8d46026722656c148848ff03b7066c91b9abe92a25c3a03571771a5630ed75623242f2f8fd01 -0ebbabeca5f9440fd8795c8e67a393a75022b7d1018cce18abd9d5fa60f8d40fbc9a63ae24bfea4149a0ace41ea072fcfbb455e2407e5e73 -606cf35307be4d85a8d73c244c738c63aef65dc52413fe8e9d7aa08cdb8e1342fdfb15969a53b992f0803859c11fc46e5af55902b5c8b0c2 -81dda3439e2ac96e161da174b60485495856b73c8dbe912d05a24d3111f063ec6b2daa9f92f0dbc9fd4614707f691d4e23e86c5052bd2e5d -67d27d48a4bd1af74640a71c260fbbaef0d2b39f884d6c6f9cd89e797544e9501abc389d7c477da891698963166a03fd82623c6e92d97e8e -479b69254e7a59d32e5e3c8422ebe07d6e3fd65cac76254088cb1c7e573169ab8925fed7e624480010462f7160c65fa141104be76f418c89 -be4b15601d5835f96b0bcf093920156ec171d7aefb7ef19fabb2964cf0d0b4b3f5cca7c21239cf56f882a9a4120d85a7c345dce8773dbb95 -0dbfdc3a85f355bde3887606a70f7dc71383ace5e83369c3a270286d22d427a3775c412b3b147ecf73ab9be0840db6bf26a1bd8cac781217 -77a3bc7ba66e4d919c52b481b2a67b09f8324946fa9b8441a601b3a30e2a23118157a01924581c8b031f60e53372ced2d0a3fa58a387a5bc -7c6079b59ede758aa76d2c967da9188ccba36b16d908c838ca6880bea594aa0b5265339dcdc738ed2b4e3417fb77d05f9ad82b8a6836ed4f -882ddd8fccba9d41c8e588c8312f9ac0ae24871f69d18da9cb05ec85a9e72a5dd5f08d59f621a5fc1cc30bdf1a9691dfd55981087beed16f -e4902ddade00714e2f6607a28c3d55bb9fca9968beea0f377d64406dcc48c092e2ee19bb27c29e1471196ffcc8d6a202f6344675a2c45bb7 -1c3aefaa5b6cebfe8a4ab222d0362a946ce02f76eb5524bf304dbe4d4bddda1c68085d7f30f6ec6bdf324c7bf3669a588607c3322e2aa446 -6c5d9acef0547c43eea9cdb9474cb7e48c397594d4180a6ea642050494f699baae89287183e051392d93ef84ca9ddb0695b084d3c0c7e344 -ca0dc49c6634e62e1791575edd0596c5d2f7999dcb0a88fc0ff58a35e76366816a48be9b916ec8e53be38e9a36d37a424d793be7a42191b5 -f6e6cb561a23d796b04bd5d5dd944ec8ee6c726c618727e968bf7348f2b23c3cc4f7df5d24b5bb660932bfd4ea3cff851a60ef1ae6ec882d -be86a7223653d8e534c651b787b5d2c7bc35e11a1435d23ea82f7c4602f10e8afde70173a2f008de8807426e3abba5ed4f91cd5f520f6e43 -9c92c3970e9e4e445cfee2869e912a8cb2b71300f5bc3a3d3a629bf9812e176da1134439369cf9e53023fc4ba51b223377739dc8f343edab -9968ca48def80a3a752fbc418de0d036749cfe051f5564177f3f417db5f453e5701bcec083c49118dfb3ce71d9043b8dd5d569597d5b432d -3056759af0a678063f331fbc79cc93037d56d01f25000cabb350fe94bf6c341cc819c21585aa5f7eab0a722e205755ccba815edef44bc66a -4f3bb4cea4e627fa32f4aa69bdc52151daf4e8217baddff94b0c81b990d5ac261e89996b37656c76eb8f987324e28b0e0f0d5487f8925a79 -b76650e3c1d277eb550acc6af66becdea7619812ea435ca911dd6570d3cabeeaeb2a219efefaf5ed67493271ffd6b2d8d5b3041b29c5d31c -6158e8457b6da731706803771239cac4d3e6b6346df63c66803f90d9b742652c0a26d105512d2001b416c4084aee0f9da6bfb0f89d0a7c30 -bd38b68e337a9048b55fddb5a7bc6d24afe048b399ee4dc6accf09dce767ce9e12ba04866dbb4aebc2005bf9bbe72355b65a3c518fdf7aae -16ae3823db6f1b8c4fa307f8ba68353ce729aa428ecf4eec8da916540a51e3d40d1277463de0b8fdf4457d453f7b094790389e99ed5082df -9a53128f496a2bf9a57da9e0b9ef8ed136249f0adb476493ee65d7fba4c450fd9c1a241c80d1d5138a9c6b1c1b3b7e4a2a6ad053f9d312f7 -95d3917336aec3aba46053a597282bedd831c235acef7ffe6b03ce88857f84b55dd704eff8fe57330d549a1b60d615bfa4b6aca7d14b2218 -a1d19dfe77e6ec08ce64f1dd3544686ef0f742302d0352db4eed245ecd27046841dad5632030235f3b488bbae160654398fa01b5bf5704d2 -9c27bd57ef6c29355b8511370536b9b4bce35ae02c1671d058bcad8009248c2f0c2ed09fff72dd57636e0039467fd882b939c08260cf428e -53787eca8c2f6645069a7e39dd16adb2c31df08923ce629aa5792f8554b591b8256e250a5f1c9daf2f2095e2cc3b4eb61c2240443897fdb1 -41833757b293fc39547a879c8105a6bd2e7c6d9e23ad4f936816172049303409d354130dbddfb0df431ab8833b961f97284e77553346f3d4 -c3406d7415f1db6b3357221e08b38c07d063dc5d138eb7fa262213c663011e28bb8f97035d153ac15980d5e87e19ca86ee1a0968b62feb93 -d7590d4e16f3fff262e20002ee8960a2384f94708f0585eddba98244e1c949afd3dcc9c0a00461bf72d52484476a0477aa63d602a4a327bd -2f53a686b2ab989dc51a8bdc711610501b5fe86f3bd150d2cbba82c5d27825c8d6610d112d559f5b00791806092f860b8878e9a830a1fba3 -382015ad987cf1e89384a61518f5a65cffa04fe64e69ca6a1937cfb3f7898813afa67ffb81c485f9b1bae0c35657101c7be3c1072d66caaf -b763a4a69c87a644880a2a17a93e26a99850949b3b7b8e4d92a1632229dafadc58502e9316fae323d9e27373e097c99b23e8b3eed1c5fdd8 -9b943b4d653bc5b2bacb002ef5709364a178ded62c55e0641daea62e7d87eed15ebea4d8bac9bf82c131912aa463f9f96039749b48166d75 -419b4f8b2e564b0ba1de754f7fc7f10b489737ad7a3d8af85eb0b16dedb257f730b58b228527c02cb1691b494be87c0d7d769f542195eb3c -091cdf7d70f4743a8e2fc29d28a3bf3ad2ec17378cae9f86a97b6a59dfc2b94de791d676a4671f28d826be49988c355e7b70bbc54f8cad03 -63017f62ace6aa2cb7c53f5484159f5d9250f54f308b36651feda8d56c0692f05041f8250cec391c0f58ba237189e4493863c44b07990862 -e258f6e687bb9c0a023489d89b268bec5be1bf16d97ebff025d8e1728b4d93963a1a490a3a84195f1825295124f2891d28dd62aa9960092f -19429a4722e9fc1d655d2bb8fd516e5a53d32fbdcb5e53ae8b3a1a549df367dfa2d874a61747d904ed8b1910e8ae348251bd41d9e7f6833c -00f6a99275a80d355c05c5cba220b5e203fa5f9999857a6a99d036ada888df34a841b08609c71cbc2c7d050a966edf6a5823d4ecb47d7ab0 -ef42edf55c0a7114b7284268324b5389295acbb341c97232c8e74ee68d92308fc2246a11262f2fbfa797c05c24983af75a05a45c8c1a65d9 -f57f14959ac261a0fb34825e68bf310fcba7d40fdb4ec3a7875f501dfcd49818d17c7cef922cbd6a42c944fa00540342bdd317b4f68dae13 -8aadee790e63afac6639d683f4f0cd0522f54c7ee58beec80068d4c02ae7a5d9cc57eac99d39c1d056f961e44372c30fd85a50e6c11b680b -4241ab79c07defd84cfb081905b8befcea78a177fc7205ca24ae8525dba03242586940def522611ce459d4ae5df7b93229c4bc67a3e02504 -0517ef212604990cdbe46b71072b393a047cb53974fdcebc3f78d2837d33785f85959ad640a7d6545f1fac9d01920e1ab25a25bf0685bb57 -9934e8e9fea08b066cffc73105230157496cef92d65723cd4a7e8d3bf14aa705932a0c778c5549f74f49b900eb70071a56b414aa6d6e4fa5 -137bf4d07a3eff4332188cdd292ca18befaa7da0f177e2a1b15ef2329bd315a3b3a48075d17481e874f96d832a89d8a4d2e2c40de55446ff -46a38335d18a86c11fba32a02c60e0fb685ea77e1a008733753f99ab87b307bfaa5a0d9ddaccb35e4a7210471d7acec24b1caae2dc421975 -315ba19c695f5e4b309cf93fb22d4f7f825971d433b082935c52e847bab387487eaedcf04806d2a06e9af2c70ccc61f8ab635d81926c1574 -d63088d6ca93ee83d370f67698d3d83fbacb0c6c3612dd79f2a1ed3fc085c469d8fee59e74055ca8dc49aaa69d96ec84028490158900e06c -9ba862164930a65dd1ecfcc5732361e3c1a5e6862ca7d12e6cf9cef6998a570074c676aad8d989ae019950882fd37f8a72799738cb6df05d -61b04275da2053fad39ad15067262166f3bce905c9f21ae6b618b6feb3facb3cf7f1f5832992ec14ad988526bd5f4e8c851384c65522729c -d289093348b2cd40b55abebcfeed46cdc3b502a753187f9fa69379cf984b8b0b1af7db0885b53e761f22371fcb982c24331efe34b3ebdbef -7be6e74de700dad2cd5a908546a90efbc75282368aa835d1c26271a051876629f2cd30e65ea1c48688592c61c3f47b2a90fab796dc82d332 -96dadf0a3481b94dcc03e37f144aff3e43c0aca2e54818fd0c30c3ce00292acce65cff2adeb396c711c78b2492810a7b609131648d919d5b -628e0e1396bbf32f50734240af8548c225f150e71d5b991ce2d8c2b6962553d24007adc413f83c2bbcea844ac9c33936e3c63e526eaede34 -b10034bf0ff23ed12f89cb64fa018dca06474b110578ea3364b39bdd8941ed312c77cb7ec3ea478301d23b7558fd5e7918151d90602dc8bc -70b6178dbbf8d1897351ab2f22ea87d206d26e2cd80612ccac45da2af067b128b4a258dfca854c73b66c11c99867f46eea7d485b7b77a8a6 -d0545e5277f583e198db94170374cc1cf3c18f112d342e0c210649eff6fc5a4aa88ee5767f17a40f1dbff89922e7a05b6d7dab1c106289ca -02fb3aad67e49e1ee9b4893001b8ec7ff44ee37622a4d3e4bbbcb3b041f8de0857de4b05dfe7dbfe51c9de42d3038bff2b6fbebbc650257a -5b0443c4814e36abc384f404f0e01f44401f6cd42b69ce7487bfdd31df50454d16da8c57c11ad82386efe8ac94988a29568f5b0ec68a06f3 -6cbd2606f30a05b2144550f6cedb8aac15ae5a0cdaf10d9b1c64fa56859d8d0dc18403e4c8e0717e2b1d247532976a1219b1e2c7f9b90d9f -3c019c030ed702865cff816244ce53a5a6f991af29198fef0319551e0d9fce9fa2e0c27ee76ffaa6d64dccc8cf5049fada3f1103521dde68 -1d053c57f4df0a6722dc7e29b087bbbb4eeeedeb74a9469020c026f8fc6c8c694227ae8a66c7c295c6172f254fe15bf390637613c79f1821 -a300bf0703b17a53ab94e4564473b0d5ffb29666882c4bdb385d7c7e875503e6cfd60fc134b2150c9750132c107cf9a941eef85cacd35097 -403e54f5ea579e1f28e0dc2e398aa3e03a3a58d1f667d65150399c02e9325db3ccb28b7f11ee5af0d1b153e270daeec05204f56ce836f3dd -6a24fcc1b6eb704a6aa676dc8c567537fbe9054c8e35479775ea6e824f8e8ffa32434aafd962abfd4d954d2f2d4af4e1cdf05e41e225727a -e2510262075b4fbccd54f55dd1a6996fe8087f6bcb19d0e1454820fe69e422a2452a4baab0ea50336a29dc9ab63465934944e429bbd26294 -a13df4917d1ee2cd5ed5cfee90343c5a9b54acbcbf5dd6f7beb445809a13a085bf8f32fc8dc7c8a796a0e8f362e4eb785707dbb40ce8e10c -9e1cc6b9ae6f518d682fd398240beebe980104cc4407dbd173e1357200c2d1bff76a240c5ddac4ba2fa4f47d0d0514e31dfcfb7aa20de0e2 -48b8398d4a0b3f1c146d8d40b69c7588d375658e2a9424b920f21d89bfec0825caae076513f8d64059eec6670bb0155d44fd2965d6f7aa88 -1b299b01094190ce5345668cabffcd91192f46498bfa717be38e8b86256742fdc249996b28e4fc20b1667d2e4dd032dd29582f49217a0257 -51c709fdb7c9117623f266a6761fe4a227d17a94724510b511678e607039d36bfefc386ff00098147c2c80ba5b06fe764d30a618a331c174 -acdc1510a3b18241b1581919f5aae2b6d3b87808b3318a56672ac21cd86aafa53ec0c3a7aa1893b6cb4370f001ddc2c460bbe265389012b9 -87ab808de76e54f72dd20a39784fa88da4059434f0f66fd349eaa1a3e30b8e9ff855666b890e38a667b825006b30c9cfe2e00705670f20ac -0cbc9f3cf5d1d7428c2c74aaa5c68bd4c2141bb6c6305a77e69de60f3bd097831e8623e475f8d9ed5f5ea3571104bc317d077937533741b9 -28720b4c26008c4adc897c97b5b10d91530ddccc7ba7689dcde34d29671ff3c6cc98feec3f64a16bee9a3c7e3e58ab5b9920d43d135adcb5 -49586618590488541d9543ea248f8dae506a3f75b23d3036f5eb8eac8071e56cf57c16269f89af84d2e3417818571d30bf6181c85672953b -179486b402aa95b106fec2ea5211e2732e371e1835a569d5846b3feddad4a62c7ebfce15fea165b2079cd25f4fcd5b00856a1bdcd1f35119 -74cce72f67a604721a5a6d2569b70979630b6150fc4d5ed802a9b8a1f7ee0b70841b19617e98fce7927cb855c008581e005fe08770d1bc34 -ef1d3c8e7c559269ab975db4d1fa1a997f3e49696a26426a0ab7d709e1c9aed646d442aee4fccc89e17aae859feea3821765bb2b121cdc1a -1d100b83e39197a0fcfdf55a9da518c6b2d41bd12e0d85f5c9ed0e820330212653e4e3dd444dfb72e87fc76caff18d46ac5c7339422b59e6 -f2ade24749ebc27ca047f15fa2d137fd8ac53813ee4fb0fdd00a27bfd005aa7ea3654bb8de9383536ea7991763cfe0e5f819b59f1cde0978 -126277d665545cb35d3b86442901c59d0881812bf0e87a833d6874b4c2bdf45bca4866b66f8e8e73147c3e47f5ed19529d23ef9d96a59163 -9d93d7ae7af88b181e23c59c5c3226e5a255e5c12eccb88ddfc12f87d08aea4a414abc99f7e37206ddb636635a6fe9df31a223c3519335bf -86408ea51a2747fc8359f46e5e4712363381e30c238e47320ed41bb9b4b3373a5c4c9053681ad78c41f6efc184fd320ea7899b8285343f3f -c1f789aba981c8095dd72ea373f368220387394f024f760ce2c49dc0566709ddb0715319c752ff6f165d2fa4b467516201b818299124b148 -0bb5e1dbd2dd0027b75f37da5c1d29f690585a5d2aacd8a4022c9481b6bc709e581c03c594c23fae4639cffe572cfba655c7a7b282df65cf -4bcfcdcfd36f298190ef650e9b58179e3989a5eac071f4392cfe4a5ca6efdf67eb866a7ded5d83fcf5d5be677e9b0cabd278f707b64323cf -549c2909679819d41f016c88cdc9e556b6b6592e14d8cf5d5b980b43fc30cacee5729442a2045813c120eac92690c5f001f0c87813fe79d1 -7813f0216508f5681e1a756fb378606e847763e324976ae22504071f3410317bb4b5ddbd52bc7d49412d4bb3df9244a3332e6b7760446cf4 -587ad2c84256869a789f1567ef499c614bd2e87ac3336baec90339d6d53c61e997f842ff983ac4956e5d57adcccf363683f1244e61db03b7 -c8b7780764afb43d3013b998af23c2700b5c77db48a23d8ffee26a1c19f9104dc7f665ee0e2a31a1644dea317f3289978f0211d0bbc5ab7e -cf558ae51ee72c19f5ac4843028a6791a39e0e5e95d67a194d3f174ad5f95eae0f9daadb891254e46327f16ae4539b97b63e2cf9452f2b26 -898bf0b45f53a0a12e5be75e9a4474016347ba00b690bee86f8fb2c9f5f53092e0e6991779953711aa35c6f19c8e20847ddd70b2295abf77 -0596104a995dde4556e68951d677b8961ffae4acf32f9dad19ac60f3db6d02900cbece8379df273d184bf82e1b8a04bb3925cc9bed85eaab -8056602f8c69aaa1d131991b03533bd499191e36c0daab5998af98e9ff743381a59c82e84c2dda95a6ee93e434af13659fe3d119867a0c84 -27a21c1163c6b23a0e9b677f8d0f179334e4bab3f59199ac754481d9467011c5f31c808b2bd905991e2e0ec44cbc1ca5b9fa976e37ccea19 -b3e414dc0db707cc3ff2230c06bd986e3ac7c1a4dc491b1eb280ed0b5782edc76f01fb38ac988d44d3c8d742434c33090c2c1c087d5aac8d -9ed9481917f6754aabece4157d6ae01eac02d66fad32856a3ea55ba0deb9a445f2c0ae8bf0a864d74a71e69898b64df8556033606b39c7c1 -a405b01c104c1c1b701c4dd7b019afb857213dbc73fbd8235fea26e979434c9981da4be57a5cf9f6f6658f8f01f29ef2618f66c19685e8b1 -656d0debaace2f29adffa811c34c657c6ea643ff45ee70ce636901bf7ffad30a4f51d57cb33f3fcf07204af2e729f25dec7ed5390a838ab4 -211180a8bff087a4cde905c47ab41ff6b95d289db891871ff88cd51e646b202b9b98f94a5d0923c078eb4ac028c3a84645d7f9a0599d1709 -362619073f3e158d855763989337bb67ab5dfc70450c463a2be9e52215963766fa7012d930ea3b677bdaccfc3c424634ef2fbba9ec2f38c7 -18fbb440fe9fb631adc55165b3a00603882bbf5958ea1b66320c1867389a51f68d828acce7d968302afd78bc9f87ce152920e8d330352a53 -4ed9fed6efd39659cfbe3b913b12bcc14e58e0b2704fb7e614405231e338e7e864395299a9b41f2b5a8b71bec3a41771940897bd0064bd9e -69a01eb8b48881044ad32c57b6038ceeb9843947bbaf4c1bdd62ce6d8f646388bbd8c29a8b948f7ccc730d0e0db357c1e458d96d0f06221a -48a8aa811e568b7238edc29392c9e6ce50ee5da60450ff27f6ce2ba1fc18c3921c9865bbe059ff6e799bb63f678936393366639a71551e66 -91994abeecac852eb69be232af95fe2bbe7bc1b169feb9bbac01650ebe616aa395b998fbb68d448ff8b8db59d2d43ecb8eb1c498a6a28749 -42b9ab95c7917716394ed4a7fd665114f23e9461cfd1c4d36a1a38d4cc22b5c8bae72df0a8911fc3ee4c3356853567c72c92130781f3524e -4debbbd9901150a740ed947926730ac0da914176171066024dc12982f84820621f7abaf2f0622b156118b39d7053e4234feada3501ed5604 -f1dc61a4329eca890d6822f3c9447495e86db6cad7fc549c4e2e2667c8f8b2493f15dda3e81dd8410f4d2fac056f060fa813b4ee08b9e779 -ebc4e14c62b1af7ae795ab33ea7b3b034bdfaabdaf91bafdf8c05df46348aa12fd3c6d35e13aaecaa2e853a0592d9ef9eb7ae518080797cf -6dbdaf5b67be2b9261c779b4e5603550f92230017f58a4295ef6264e5efe99c57f179ef39d375d63ab4109f4aa8a1ed0b3dc441f5219f818 -53939f23fefa35e4aacd873beaad76056a93e22282be09ffb29b3c61b8765cbd3423c325d5cb3277d4159312a748dc4d4c620e8687f30001 -e2158b288679c84b11df8446c3ea6d99303c7a29742cd11fbfd09be05b9c71283b293f9f456bb6cae985d9ddb91ac22830e341837e4ffe7b -006df9cb2b589b4424407394581a384d8e6746695ddb46c3e1a01732d202eb4235b797de22686aaf9c2d90e3f118f98c0323cf831e85a79b -d67640f02032c0ba5727ef1626311ce59b9e123337c639e1683e99141990c7e56546c4ebc035d9d86f2cc85d7a210095e0dfde84af9c4997 -d0584053c3cc94b425aea3c8d4cd34381ebf0dab4b0a1baaa304db20992ad889ff2b9f92348a21284fdb46d0c8fc827442967a3b04f9889f -fe0e2686a2bddd4b56eee35f00762a8c9b42d4852eca0aa8fa654cc55071f7d719c1ddc718f66be04fdee58316526163c95b6842d7e0770b -ded3b69768a5edb3e95e4b04a5a3714c5182dac1c1bef239c5bae09e45369e7a141b8001f4d1b8f00cee6e7fd26452e8da8c5e4bb26ed1c3 -213adcf28c689a54ab9968628a6a318a91344852ad775d8522d73c328a93704b10aac108c86a279c47f46bad866480d7dfb56a08c0a15619 -42380a84a3314302652d86ba059506f2ae854c5165a237d1aeb720eea02f274d4451f4092ab333ab8e0bb6c47c +060200000194ae50290095416eaf60993da4ef11bd6030d70ac18c4c0870986ad44419e1da1edd2dc3d1e4cf37b810b15ad23f2700019604 +bc5717e74a990d2c627ce86b3df1dc3d46e206853a378e8e1be1e485f258149c4340bdda556d4d40fa1765c514be8d598f270f13cab9776b +b93d998b556f6c8237fdda3f12444fd55692431d5c9bfab9539886b29e8cf6d42ac870575e11431c675b1293b190945700d02696d11215d0 +ee171861abe26629ed8dac09072141d2808871c1bf02ff04f2c44554c129ecec8a33ad1bea2378161cc7d2b2a7564d8e80cd93422f983de8 +c239f47092d9af84a208f30cb9c7697f51a3eb3fef0ed88e9476f437bbb5e3fbbdba30b857e86b2cc47aadb1c3a5673ab48ecb98c6cd78fc +2e1788178bef05a943c44b716f1c86615f57c86602f3194456cddc9fc9861ada5050bdd46561fe19cff1cda09f4a0a672374462029ed80b0 +dfd924698129ba54525b0e8c0302e1330205dfda9f312aa48f86ab14831e8383729962ba36ea2c856f54a0f4a08551cac4b1f3b06a65a640 +f4d842207739e804e3fdc479e553614de45874bb0de22cce180f70216f59847b59dd73b33ece0e1504b5b87c0ab2d271ec852e67be7fcd40 +93b4ba59f5ca2887f773457b481da04c0df080a2c1d332baf8e2255d1a9dd831d334dc279cc32e69b695e10fb2673a5057bc2aa4ada8230f +259c8f7cf15cf364a4e93481aa0663f877375c3d17aff4ffc333bb741d58bc7bba30ea4f02318809d836be4a583f2aa72182ccc1f7eab12c +caabda2911a4c9a0d714436cc44ef0e4a784ee6bd107d41db2eee4ec82afd5b7cf38752af2033bf8494fd4454278360c606fe3911a3602a8 +2e51d080f37ccea86f32983918fa37567ebba63d442b26d714dde23f7950b8d3efc082b929c914d9f346f604f75ce6adc510565b998ed93d +d723e767f91553ebdaf009a8484bd5f2eb7634955418d719ed13778067981665d0944515c7ec5c330ce6bc7da13c905739905358e1158312 +78949fac315f66619f36509314b6354fecee1f2e2b67a774d3c36f59a20389b4269de15c27498df9a957292691640e84cb753438725ef385 +c9a2fac86391779e2137a1b32229e1db77bb961a0733498ab474a81e3425972dcd91c9abc1aa043d008d7eea32fd14c554f49332bcc0a1af +a66953468bde12d5cdf58c18c23e007ca0324ad9227d173a6a0932282f12e2cdf4644c4810b9522f897b982bcfdfa0adff24c9a319e3eaa4 +5b80d7f05918e23a3bfc037b8d2590821feb96b102e88df26ec59a7adb874f9d3b441d27496e5d829ad46282301c023496181d44ca8602a4 +898f9a44b3ae1feaa9b0f03bb5a16d1b1f4bee84eb94f871131f363012833796cc7b70035ea7953d2c34df48048c5a1ed1a089ee873a041c +733f903a973560bfb0f21478e7abbe31e4e8be25e142b207a32f14c9ac7e87df2ecfa5f834bcc386f98eeebe2778639bc92597f3fe35c489 +0b40ea6a4e6464e51dd7ee0ae576f383f760e90bca1b3eb2dae76cc8203ca9821b8294961cf0f47a01ed6f4f2fd3670ed72dbc3cad3fc97d +917f094510cb29b99c159bbe076aac2f6394b9dc23ee3efaf7fb26abc1f43cd5f4f05cc07ccda68cdf4b6cd9d24ecd6a71b0124ba4bd8a68 +3f9ffab0974fddc6213e48d0bd122ce040c2237dc96923f45f2f178ebae421d38112e7bedf2ffc21ea75504fe6d3ad6cca7e3d55533274fc +dd62f756d556412d73b7b3239092a012e4ca8a2d06a1b2f99316ba629a34e4c42a204aff8b0164f506a2fbafc82d275333ff7a08e1142088 +48cd74ebea814c65677ee08c91253520ef0eb633bbc185f2782ed15f3dcc0050ca5d82171d274565f37f9a79b704cc5cc9271051994c3f83 +3604e39da2af76d052d38731a966538de7e216ebd8dbe93e53ac166d12ec545e6c593c420dfb622b533b543e1d55f7759697fa72c43a74c5 +8329147cf195af1dac890d5929981d76384574f97f2b70cdee809c8fa0835e5bb6302c3a918ac7f029f61032ac70fac8f4a01e6c71af33e2 +77501795e7a65cd2673ea0e31d075304b980dcc0a8a759259f768471f901bf188cd721b6192c44bf0e0ed9a2aa4f82c25c6c91b87ad10525 +795a16bc9a0bf4db440c115d59ba041a972dbb76e59e81faaa8828be586406bc2a820b6070d02237b1d49c2c8ba77e6a16a811c1361bcea9 +17c250d4237fa702871013e082197da0889bfda7ef0296435f78ae9857a65ef22efc82e29cc89a709c91ed19dc2dc762d8ff911718ba76d4 +4412a64739701c13a66b39195ec29f3f1383d02167b68e166d0cfde5747e6ebfb0680e32922dcd1b39da794f9815c788156671699f8bd2fe +ce8380da706a77e6f23aff40e97b5f4e5b004f9d82317ca0eb6776a0bbeec9e796bf73b9eb1b8a408998c73b1443f9d46d1552d8af3c3fa5 +e2f5724feb0d60571467d869e99e12edf6b00c868ad22068413f9b3b5d08b517311817f946460025626b59ee3c1692339563e044db3dbbe4 +eafd1c1872b7ec09c13c98e0cc83958d8b25cf1a00874e7445bb0b0db0a0dfb12c1bc1b5324f62fdb7ef7dcb7794d28afe29c10c3a301147 +a9215c1050d65a20c09f4f303832add42ac29fd18370ff0e4fd6540ce35594d2d1dd28c7480f0c1c7eb55ae8045add9c96c1346eda69f58c +011127cfd617016d6feb6ea0d146c029c4a6065f2a25e54a5c8ffe672e5c54fd6e3eabe24b7fc15a900e3b1db23afa26631e3abf14500dc3 +f7323228265c781ae0863ab6cfc1204fff3c82a8fcbb6b6fb3e745a93f9ee385f26479335d63e17535161a61c88ae8088d6ffde8e91ea101 +18ac305f0f100f662784f288bde31ce32b31de2fdb268bcf8047099129f18c6b1f9726d27a070f514f7f52ccdbd71d64c809c5a3968b62a3 +d53eb011c3189836f1e2d71ba6ed57213daff9a6c1370206f60666ae21475a36952269dd600ccfbd923f767225a657ffb6281e32668eae49 +9430cfb484e2c7ce1e3ddfef16a871b7d55819fe3bfb1897beef0f44973a1c07b823e7a006ce8588a8851b078f5c799ef9b419c5867f1557 +d1b067d449b11e3eb778f9b46e522bf3ca6565fed4c4540f623c0a18244bacaeb2b6480ca898db8da9a108f6722a54e457e88c480cf8a4f8 +00b57d64f938dd9e30dab0d991c642455eeded98bc58b5bf437eff1de56b3639f014093b873a3f780dc17e7c4280790848fd5f491d6e2c48 +b5fc2325cd88952228c52432049e655802fe125a00cd949b9ed54470c71b092bfb282e0816107646814d0187b523c8700459ba2323ff2cd6 +6f8ee4e954bb8f13eaa2bcc7a2ce812046df9087bbc7ba7cc110e6aac465daf2c63b55c0930f0d4ca083a20df924f3d395871c39f296b1be +3bfaec1ae5aa3956dc5c4b497695b97a304cc1acd2708867bed635fedbe5a0567881bf2a691ffa55e9a922630f8de180c00d8a8d5f045dfd +9fb029cc8b53dfab9d5533d07ddd2893652004d9495efc2ccf502e4184228652723f956c80b62c566a89e81a862108b2c32b20ded004581d +cea6e5d5de730fae2a4bf2563e411078e41099b7e07aba9ee907ca4821e9633d74cbf0a2731d884b73374358e6f48077fcb49e4c57271085 +ce413e1ea0264b2a5fd94be7e86937ec246384c5cda5eac6b78944399e17d859d84727037b7539358e7b936687726233c0dff89ed5c2efab +a3d74d0924304d7090695ad38b6c1d5777782421802dab55db87a7589fe17240b9b04ef82f9f1d962422e5144e5199b8b056221a401513d0 +5e119022741711c0ebb4cd500d94bb462bca839e35cef0f2efb54a81f218f98a181fb6911cf51e12a7467c6ce95c4cadb90b51af7933eb66 +ca107eeced4fe9af1319416d02445811b78d0bed82b5c11e23699077c169f7e1475e27b8ce5e01d335b09878ca43ba06715181d303b117ba +cfe91948bc9a4ec19d1a2cf1fe86b1d4e7bdd72ae1cce0b3ff16d2eb707a758c96e18ae589cee08a1d6a2edc0197a698bfd027fed54c05be +b0263d22637761bc5c473d358f547939170b6d9a922e143ed46f13207cb9219f2bbc5b328a7c6be7bc6274b3189ec4d629570eaf3b53d3ac +f67bebc3858b68a2366ede7ad5fe823966f157431c8af929963cfc71a35910f36f2a54238f02d80d9b87d1de6c55cfde41135bdc1d3d8122 +82a219261ea1b89a37281840894d3a3aba6c054cd8c6b414fb151bd7a886fb05ad9ae52598da66821062bb9225f61709b858aa4d04507caa +c685ed1fba138b37fbc21c4b60168de2673e5a2fe97a42a193db17b85a9bf56b6b76216e252a2b7b546a2553f59b682f2fe8ffe5880fb6d4 +c71f167c9fd2dacefa86c815ff907533831c74ccf72926f8260166b0fcbc661a37533c2770a7c46da1e94c34f808eea1cf6fb25bcfd18820 +18d263f0d58733a1811788c7c02e43a80f5b2b51035ee366c9bde05cc8af9180ab677e5c6c430d42d73554e75c2095d555ba2df0686700b6 +a9a1d3b747178756b6c0a9dec0647eb65b48068e09d9b7ae494dfe78155a8d451ab58121f2e980c565d1414a7fa6d09ca4ee5cee33c4f3ce +90d342c6f440f309c85388cb04e6c2fe6316313d285c0297091a2adcab6ca3cf5d816072f9080d00192b1e3e2a61376cb07e1903d5155fc6 +7cab3e3872f2ea5c96dd0dd097c6e8be2da87285372581bdde00381bc3c467cbcc7bda35a474b45348d3a0f2a03c5578c83575822fd04c49 +31b01329fc5ae2c539665cece4434bbd3dabece80a849e2a8c9e00d9bd9a48557c6f15886d438670b5e4edc817303530ca046d1317c4913d +34e3bfda7400cf027bad9020b9ba1b59d719e7a50ede002e125d79b97c76ab35e3d0cd4ba0917749a22aee8ce3d1b7253f5f00eb06ec65aa +435e5e337b7d63a6f4030636cf5523863efc9af50ece0982ceebf51817ba9f4b354e090741a032fe0467c5ee1c2920e19713a9498165f214 +75f8655644eb91f52faa64e11b2d66fe1e4a5d1c8522c7d87958942d8d685dc75b2a81b7534fecfe3c166435a8acbcfd396f080cf7a574dc +992afa0862f2bda9a576e86fe63f6b86829728ad362b32be26bf8bdc58264500419887171ef3b0bfe846bd0d0fce41bfb1e7af16d49927ca +5efffa72ef8176228dd995601e981d13495476504ce4d396060e967c75afd1dfbb32bfa6d17ea022d8dc232ba5ff569ffa99fc00cb017019 +246b1299164c0f73434f8144176f396ae002a103cb745494fac01e95de19d059e0e18a5ccd9d827a3211d50ea58476ea4d78f669f2ff079c +582336be1d5f68d32a6c256f0b826ebc6cd829ebb81fbf2e2c592975686cb50ded7d825d218e4d3da3bf3e846ee33df0f0c644de6a88cdfb +78181130cbd495b8b590ba426377319dcc6d63aa14a757c4aa787a3fb9f995868362c8734281b33e01e8f508d886a8442dc620381d33c3f1 +010db797520cd051f368b4da5ed48fad00089f1d5a92fcb516443c0f4c2fb1c7e65ab49324d19e52c3bd623351409cbb5b3c2250fcc1dc84 +c4ea666bfb29d1f834f76598bf59eb005517a8accffe01772f63a358959dc99bdf9b3857d4fd84202206fb43e6b85fa34761023459a2991b +3b095b7c4ed13c7a30c7cc00b680af8810b15da9d86681c555504e526ae41e7067ce1338d171d7ccd8ebe3abc2bf2d88650ea20ee65b74f8 +bcb49b8fc0b821339d0a155f6c0b6a76d0106a74689b07f18b645a733009d55d2c041ffd0afaadc5a078246adc8c37e1c49d1bd3afe2d069 +762a66e9f542391f7cb4cd3b14e6b81b43db9ffe66b70252a0b08609951871d1f0d2fa0428b0c1ebb0511f9c5f1acef05e3cb0ba42e4bbb1 +0e75b121c5976698616b76fca02a133af96b5e0ea65daa56a710bb901c9b7a21bce1597b8853588fd63a56f3602288841a7ed27c48672b79 +4ba6b183579a70a8a6b57e543df8fabb57b3c8d36fae5398567db8ed2c1479b5e3df8a1aa7aca105310244c8aefd5df6ef5f0ac034ebd269 +62538073fb668d1759440683877d7c0974ef090a3fa2eb619027bf04fd4653b87e7774a7c314cd119bb29788f379289411eb8f5bd711c5d3 +6b51b124bae704f17638665fdf3dc7f9d9424322f9ae69a52368cad46cbd83e4c06774a11106a0cdbc0ddcf7ce14d6d18b366ec22641e3a3 +ec85340324b6fc9b931fe5fae2ddc3f1197d33700195dea7bd9d68794ad681a69cb52ed4a7bf1394527f46dbe55f588972435927a2368944 +bad5e8cdcc5a328d8bb80f25a0cec36bb57c6ff23ecca2609e51492b65fe6de3edb2c5f7abfbbfa1397f7b8aa8dc1947e4da0575e6743c51 +37fbc856391e6d630d86a6e993ec0073099f16aaa51d28230fa262e94c9287660d106bc1ddc29f24adeb29dbc20918d02f90ae1406fa4584 +c4f7385b5526e1a03aecd30c112d657a8f076ffdc3bf6e4eaa6a5c3e2e74fe97a4bc7a81e70d89eea52c70219ceca20d4abd723e6bc4f8f0 +38f251b3ed8e723713b4a6c20e5c124cacd1b089229776583a0a97f94cd259b3faeed3f8af7d4e587c32a70405b43cebaca583ec31b64065 +f9eaed500a9565a89da3bb4cb4b82daaf8b01ad29b24994d00fd0b1cdfe6d24d4b2c6dedb48fefcb5637696cde216d2e74dc27a01a40ce8f +ce23ac868f358f12f1ded5e7ff6a8c0bf625fcd53c64a424a54af8c9410309d69d4fa2e1dc1d55035b7577ae9b01bd891fd4da588569f385 +b927becdeb49a17595a9f7093818094087ed073e8d9631d9ecebbe15a5ae4698a8bdfe9a231a0aff79777af37be5a6b839a03fec653a550d +783e45115d7e75b3451304404db5d4f02a332067a26b29520e5d9460d439d9451827f44fab7fe98f62c29baeaabad015747e542d283a5652 +998002cd7fb18a69685b1747761d470b186e69291644031bfc164bb631958fab0f0a42e2606ef0805b5d184a694f997eea597663a3fb75f1 +25759e1990e99e927043c2a27a2492a0f1b501961c9d8b7589c5fb4ae0309f1d3a0300b1b749c8fb26357d6de1ac81444e35d378703ba570 +ad8e1b1c41d13e41268240f600a0c8f9131e9a4b8306f9835de89c8737cfe88bd4217e906f5504ce60b386e453a42753bcb8b4aa1bd0198d +d04baabeadcc07d4eb2337b94794231ecf73c25b3c9152f8a332f43fa6301a64e68f09d8b34a5525b612920dbdf89885c70153f91bb48362 +528d15b1e4dfecaea7d3eed3b3cfd24c06c35cebe6695a2f0256d293a18ff1642d417ba66f817ec92ff4a4e091ae81e3ede0ddaf50ac781a +0e1abe2d6b483029a7f488d22df9d411dec62c7b9e6f57d3507ba1cf46835b679a4b32e60ea4f48383494b04d9588b4a967bd056e0b855d2 +e318b20f0d995de3b1800ff197fe44c59f2f8dd98a46d90cc05b04e450617c0c70a3cbfb725c48bcf132b7ca45dad3085bbcdfe700aa24db +8f0e444ada4b045699dcccef864585abdcfafb68bcd2e86d53c1388d8e3495e1474e6e82a9a851e3cec6449016fe6c6494cf577a0fe69439 +964d21a3f7fabed0343d31c69cf5bfecbb3d32229bff3ba0229eda25a49b9967bfb9ce578f7d619c85e6587e958815cf4c35dae946473a2a +919fcafbf158e8bd463a8fc397e209a7cdd08f2061e5170b8eb14474d2ea38f7ce7194187cbfbb11c3f850476f53b5dc500df17cc6d85117 +fb35d4216a898ace5d703b066635ae942427385f0f08d78d556295a01a5be900c2473ca3b38d90b5d6f588f8faf4e5507f70838e84ad6492 +6d7e9a2683a40a174c89c73b4ae2812e6cae3c10d239f6ad9be00d471c8831db7977c3678ee5b4e15a1de9e79c057f8103ecf63ab879a183 +396d3540b57d205e779f65436dbea553af6659276afe1b65a96ad0b06ed413f599b81d94162b5967dbd2d03654b9dbb006ee8f2d41eb49e9 +0fa524d2c38130670eb12317db5b9d3f32ee403d035a1a70093dad4ddd00d6bc97f94aa306fc7845b0f9cf2edbab2ba62f3f85985410dd91 +dd36fe79006d593e935fc09522aa0c55d6aa5d52c22ce7fb3a0b785b13a2f7ecab723ebf23ceba7a4f481a4b4275bb63c74d3e3510d3d237 +fed4785f4bd8c7b088c6bf7d4c4e013e3055fd1104fed8ca4d5ef292e288730bb50157cfe0f99619a78cd2bb92fd56db50325819bf623b13 +44418ad7783fe5fe0003322889f460337d40943acbcca9a5e50e8216f056547a62cc55f3598aab83bda8a27eb3bd1d35aa2e3d176f523ee2 +f13ccb0624aa9f2ada406876b39a3842e859b31760e376db61d7db22e45f1b6e35262bd25b521b232b48d208af37d7fcdcf878f6e4e27521 +530f5959eedfffc3ce1ba8f2eee866c56360bef91fbdddc4fc6ee8f505aae0a3831d77c474c29be76105ccab3418cd810f2b72d58a425729 +bd78cc9b16ce2a95b48502229bdb3cb975041490b7e070b29fc63d315ec2b2571dc33b3cee93e5fb7cd6b55599440c6b2f28c66335b42f26 +e9cc56e374532174ac667a1f824414ead4f1f9319dd15bedc0d3c1f88717575f484431a7e9685350af7412caad2f305a0698c3f197b12db0 +99a54a4168f76c882e4fad7854db49c289f727b2d02a4ae642bae0cb7051a6cc8858fa8c4a8501c54cc7ef8141dbd8f966d89747f057b975 +db14794d81d566a7023e4b92733e9aec8d510fe5ccf63ecdaa4386e044a30da744bff020f7b539c45442a6af84d3c30b2e38a808d73cfdae +496aa7c546bc080fbd83c539d3b4aa6164e4f50b2f33816e81e665116a31b0b57ee0b2371da70a8f2150b4bbedd3e8ba6027212a3f0cb8eb +196d17a58f3965f89ba43ff82f2de139e9a6982590792681fbdb2b8d35342b3479b7a31321d547ca4c7fb055a452b49a900e7237e8c163e7 +b1fa4715d82654f736ea518b33223b10eb4a45c8a963a6c70b42a9affdd1497f41c01eb4f9c13e944743c0a79dc41e9fef5ede753f29b9bc +dd98558da7d0823004921dae6fcfc98b6ab1e0a1a6ac7dde74a52afc390041dfa7ee8329a9aa2928377d41a8883ca4545b1195e7a562b5dc +b52988b6d5b2febbd00daa845afadc1ed1226f2755909dbfd26dc79fb6f4a8e1ceef1540f8cf6be6fef8b7c00397b1bedc016e7bc9cdcc43 +5784fb2c8e859cc9a5be6b4b24cee7e1d53f6d60f39c02f23a782e860cf0ab22999889b5a2b39b9ae20880b0339e56982e03368ee8d2e28f +5fd8ca3968938feb0fefd3605e6d3669ef7d3fbda289d91a74d99f5d9a45e5b77dfb58ebb15f62522de726d460188d761bb50d6780d27079 +a901a6ca3f04b339cf1b5dbb728a2f26d07cb8d7b3e63c78abec69192f0ef915e523a00a0722d69e79a22cf61e99b66807467d3cad2b0b48 +548acf2224215642a5ce817e39645d320abfc11dbd65186b5be3700c3eae579b295f5d6db25fa350afbddde6fe6cb1565c369ee2ed1662c8 +265976f7c549015ad70f79fadcceddac5d57c46860246f293bdcbb46f58c0e02144040562159289a58f1e396894acb680f7a2520f386545f +e81b570b89cbb48318ecd8ba948ec648a9b12c9c97ab080814d3386114da29ea9019086ba02eefd1adc22dd1f55743c1e7f869958aed1a47 +cd8e4e479b000c88dde795209257f1d7d625df1ae27ab79b681a327f9db4a5a45d8e6c3870ac751225f4c98e41a44fe6dd571166fc7471a5 +8ca7224d3e4259c4ed8603b41e914a649b49392e77e9073fbed54a494f61a7f166116fa47429837a169ba056e189e642719873845f89f741 +3a15e856ad369945d301cc91baf842ff355d71ca4f45641c73e3a7b1f6c6df530bcd3a43fe6cea949ad5920029a827237a8497e236fda61f +1d86bbea9ee97089e46d7e52051f938b645ce43a97a18fae5832c2206874aa717e9d0a942be137094e5258c7bb1af7c5935eec00c08a8d26 +26bfe06cc1788de23d5d2513a919c56933a4312398da4d8644580ea552291c9fadb6a17823dfecf65d3c12abc65acafdefd96ffc57fb571c +280286fc7030757c51e58178d4088419fad8c5331e73504f6c0083cdc8ed054ddfc8d99b0dddf3ec705da169debbc15911d424c07bc8753d +c4ae4614cad71c1e3cb95f50ef03326986e8e3d4cd668c2da94da5fd1ee60279da0f1c1c4fd59d364b4d92e613fcfa3e8917e9b5ae3b350a +96bf6cdbca1150a3f34662126565da147b677b43fa53e6ff06435b1b6260ec1ea3856d60e86587507915af88198f0a5e9dfe2a4ef24bd1e3 +ab68b6db80a5225b62f69cafd89db1e5218e5c56640aaecae36f42a4c0c162f9d354aa05655e0ed86616da01425a6bee99c7f0b0512fae2d +e7e8ce9c2ab594e4ac6005392b3348a4bb9e6f046e850eb85008bc019ce746a557a731a906ec8aeb7012570ff31a2b2f38f7f62d2303857e +2e50770f85c56de514cf68268fac275e11c0236f1b395e40629e038db68757351587c8cf5071b80fca03437af67c915d70d3a12493a4c9a2 +bea32f62bd6e4806c3d65395f4ccb792d2fcb8d5592a06bcca0c40afb2fdec39664ad324ae73fda1107662e847e936f5599b0af587c71e8d +216252992657a3563e04747c9a46c36f64b1471143ddf0b3184f1ae51f0c6d7af85940df008c2320f9c796c5a0115c4407911625e1debc95 +3396a9ed5ac2b6f4b034c162092497f43a0de4ee8d5902a888e2f9b897ebcbf8cba366b1d0dbe94e1e3de656bc8b949fb575aa26a6f9e4cd +bc4a27d312e5f8e5d8d9ff815177d52c1a0628dff52fdd6e4d90e26268e01c9efd4427fce66cbd06775288d3dec9dd91b2ddcb12d153a547 +a764797c452c5fc0da3312391391fc3a48ed511ba35ff7b89fff9538b103695c4b0471b41d9fd1165e2a7ff68465f43e26ed14aa4172e53d +c1f5c18b8c54bf1306a631a3b162048f1ce36b4305bc7eb9821e9013a1266fdd4718458be9afdb23e7d4ee4ebb46c79f147f23f3ac3c5442 +2878be372409af0c8f022636a914aa19b499b6033584c9687b7de23ae61966cf10c662bc7e17ad1e4cd2ea5620de1de4e20e6be52c465102 +a8a8a2f7fb0258861697db83f786ce5ecf8fe5f745b35f4f0a8e2db07b4821085fe959ed3c19f5847c677878552157a6ac8ca60f3425e5b1 +126ea57526285e3cee038f57ffdc3e95e4c938145f7f71a433e19cdf61dbf3a5318c225ca9c1b26245ba89579888157647f7405d2efc1b70 +cd9cc02ba5a5fca1a49ed89ee5e89dcdf37865f8241054dab3ff459cd515a14635ac1ecfa387ac1e83522e815b67d3668effd17793b87938 +0e483d4368e6638365c68474ddd74be64cc46ab1435d4dda78171a56629b8f3a488c2705fd0d332c83a345ab337ae542eb803098eaacd0a7 +4a96518d56a410d57321eaec18ea1eddae63ef7a3b8152f50cddebd82f47d1cb773058db6d3b38d3e81fa897b988f3d04b7bcf26ad14d700 +3c988ec7b25332746f9bc4005102704f05f5d5ee5341ef3b30d318fc3904da34eac2ff3a246b1a6ec60145cbc66b8e79006dc08514a2dcea +9f5d89642edbc7b9f41d811ee1caf10343a286e1e4c5f17cdc640c8aa5e40dbc89de3a18975ba4558391e27650679a829ac644aecb00a430 +74a0aa358f5664876388ed742bf571d020b29059c8326f68411979d3967a314bff40126b5f656f1ca0ea4d7261b7a0149691ad08cd2b2b2f +2d9075afaa4e4c849ac650925b2c2f489075fe539f8cd7b1fc7cf4781808fa29484c4f3b9da3cf38cb31c58cb3ded4daf88de27c8f447cf0 +98b7ff69e7914cac7171b2290133fa1854b8a331e35d7f002ae75b1589758e6b2151072a43514973375185240b7e8caf231398fff3e6787a +28ef17829f4b42501fe0af78943f1e0cc419647ce0c2e9d1ab50f19f515d4bc75edc6e2d31bd17ef69f0a63d4e385a8f5bb00908f83bb86b +b20239b55d62730b43ddb783fa805e6ee4b4cf6eb66ac29bb5ce631e294b439517f5856ad1f2fcffae3936b40b14b2d6dd4e32c0a8091bce +bf24c0c64f54094366fb61269962feedb4072daf5c5a40641dc827849682a21850dc1554d53574d9608c97e4abcf94f9c6a28cf5b484d674 +36d3ecadd6e13ce57c11434a57e87aac9562079e22c6d2ab310b9ab1271d95bc268ba98404612ba78c3f032fc59322334870feff72f2520a +cb71682220f727928391051a74e6928e74706a7a5a036ed8a0d4be09f9088df632adc8ff816d7d830c8c3ee0074432b9dbf1e9ba49b12796 +d1eef1bacfc32b941e52b0ebda65241e198935d0dc4a72f997c8b26283ea93261d483754693f9834451a696fe4bb1b6e39f1cfab45f3e792 +fefd5fd2b94a698caca4eb8b2be7e1eb6c9a06a56bf22745ee3af4126dce6e5a77fb348a0ecf2416c055d2e6d4019174ec7326f7239727f7 +eafcdbf3f8323f1cb0b1f63d75f4522c575f82ba0188b03734c3fb374b90d9773c75ea981a23510a7add04ed0b13f80372b63fcb1135e8eb +3129bc970df3a6096b92d17d7f23a3256a7133b4c3c71cd581f68575751255c2517ff3c3e328fc8f50ec5b27d3868a99848103ff73b82478 +bb86e52e677923a905bfa608e80a571108e4e46cbbf4871b078457b8e06eff87a15ecbf3cfe59d597cb4cb2663de42d39bfd260442be708e +8985e5552cbe191a42fbf9eacc4505d4c857a67eb0a43b27cae86b6141279ebd32a2dcb65a94f9fa8e7e17fb8447d435311740cbfe3ddb2a +8f2826c69dec1750d4136406238b128bff704d9fed6444d14b7bebcb269fa4203a262d8bd0b33912199566b1896c03915d090d070488243b +b1566b9d464571f1b5870d959b0d0fbac9e6f783dee5cea9019110d02e76a92e5e963644519456cfbaf6b598da56ddea81ac549b93db86bf +e54930d67675775ba5525b7576c2df1965d7b317bdcec7150df1c15706f81d1c2ebea7269d9ed0ab10312c448f5907730d12447cbc9cd4d3 +6e09a8979a1960db9ca7058fd30bda25bdca5fd48ab4db2be3afcabe691ea64effd6b724eba76e2637e519cee7ff9f711061245b174363f7 +5c9da2fba536d2ab7b6fecd277129693d17f0bda731589df1905367bc80ef014b05e95dd520a9f08924c306ce25ce4fc343c66f9651e9edd +b57ca60d3b030f6046693811a022461ad45bed406f14722740a6633edde992ab2e2ac5d94dd70399d7cee5eab350b2c10ad668e6d705eddc +0b1d0e22292e34e416f0e9b361fd4ae2558a256175dae60caa8f00585f3401a209a25cc8e72632559e15e80600b4c741769c637d83da4768 +de13f619f8bfae3b7843b8ea46064b03dcb2bb2397cbf19a9ee773ac5a560f32fd7a3c95d7d344d924c715c43b4c0d644c0b1b28187c66ad +6a56e3c105b2fe83b585b3ef1f11e3f77d21d58eee9b513d52dbd8630c8bf194c30a64dbb96e707adda4e5caac59990e1e54bb9ee4816c8e +6822d69ac52f815e384cc8e8b3938f87d585681b38069426035d40abfa4ce7cab34f56a45e9ffdfedf53d81e0b08a5ac32441701c2cd1f26 +8bb722116912f55260c128fe5c55a197f6440dce2df4bd426b3178761ec043cafa57e41c02db17d1a712919dd2186fc0de1b3f4bdbb129ae +22f8965d4d84d8a687d7d6404f6760d2363a713ca19ff06d648d311c5871383594f2c34151b0b812c4ce12ea3e240754aeef9faf7a61f36f +835de3090e1ea22767c69cf376312bd3207ea4f939668ed3e700c72d50d3516778d0006b0da8bca7b65c5fa8ede67cbf1b1e9e79d259a392 +2c2328e8385f544283fca61778af7771e25b4fa50e3a1d9b04a19bb74450d772374846314cd6e8fc3ed05c19d72d0bdea8c3acd4095d1083 +4c1d12a97de5992bfd813c450c0704782c4806ef6e77f9c2b8184c26fc2bde78327923b81e48f186b972bb4d9267f9220e3c03e9f87048ad +2da7ec8dab8118adf777c155bd46091bf990374e5cd8dad3624fef8c646eb0446d0c3e8b18c93546f9592a86accbf6ab7b947ee7d279220c +0da062555199e5954639fdfebffa16078f49c7d6b1097c8ee10494143e743a7524a934746ff5e6e2b02c985d2780b4c667d43cd1c3ba6cdf +4752b120c3b481df2117f31d0cd49744da9b5ddabe5e58c7a4625b88370b1fb87887012c5636d8cbbe258d01083c31c01e4b359e7d12f230 +a1ee229dde4427e201a9c682e4b59a1509bfa8b7ad42ab952300b0dd032c9200512683ef4178a526039284ec43d4c6e93e9d76f1c97746eb +e0c23ad3c9736579f4192dde55c42723ee71a0c5cf16903b1f3ee9662cac9e357abc7b5cb1b3f322cf935db49ee440fa9626d98c035d5249 +949f9f5395c79eddffc354b3f6895daef19bab519e9c2cfaa663ef8d543846bd11c889dda634335b61dfbfcd1450c09f9391519ffd5c1fcc +335f2d167fe5829250718ab75c40a976b77866930a737b07f4afd00e2f377108f132e7280523acdb8d3912a797b90d707417326b705233ad +fb5511859de8e10530b5e23f4c253d89ef06545b29c67d0b9c42f8ba6f611946d42f1403e52d8807996b4bfabed64f1a19d96819db892394 +bcea025a460b189c53b32b131c2b94c12eeedc1ffd610f2418b5d5dfa92537dc75812785f45de70ce54c4730025554d72d274201c0830ac4 +6c1e857575879384a38ddab4a559bc528663428cc703c936a7aa0ce2f659502c044b96754710397b4a65a7d65caa3ea2125fd755a49aaa95 +92002b7df32f4783f4e80ddadc52916253d3a20d5b2d382691bb0a9baa670ba4b0e1c10e2ceae232fb908061e7723b04ef85905e2d53385a +5e2f0974dcbe0922443757e4f15572601e0d467fe22885cf6e1f9eb23f7d1060b99111d6339d8b4c85c64824e7c4230383e06b76864de724 +07ac80fa0376771262792c4002659bfe37a10d11431acaf1fb60c51acc6316d4463dfbf7249bbf6608f84049a0f0ea25c73b27bef0a7faab +f29f0d31a218b19f9cef07b2c829f774920442dbfeea24480cd2a19787d4ed06504804b3a2e67f2e2c29a28aba4f396c5dfe786b2f992023 +210adcfb65e4abc2329bfe4adfacad47b41f5ac8f16f23a7d0227469e144d30ff97006dea1fb2790e9dfa00e0ca63e0fd72ecb8157e3e5ba +7c15f38294b3e5a35d6099c327dc59a6ec1fcee0bd12c59da5271d8ed7b7559a643ae89ad31c5ea3593ff4dedc1b6afec2617c985a19438b +d89855ae153142f8e381f5c16d40239e6efb5bfe369a6c80444369f074924d1ee1e07a692db90e14a0e198ecb48367cfb0c457fb322ce82e +9185d3db1f53632392a3b077ebb8c6bddba51ee4ed15a5255fecb40e6e059f6b9b5a8ad363926c31478eaf588c7e7a4ea191e65bb368c339 +1581a3e5cd8ecd3356cb898e1b55a3961e2a3d6d198165e1c0964f7a365ade2ca52f584bb1647be33dc8d9bec281621e5383fca73b3b27c5 +d379e64443f825deb31d9cd50ae1a704d45c76a19d799cf5aafd44cb2c31ff1f858b7b7ecc4e068cfad674d3118e2d8a63018858970caaa2 +d0798081d58edd7d6108f6c52bf0978e315be35f67a5fd4eb8321a025ebccfcd95170c9e1024f054ae056f1673aac664557f0f30989e9345 +d5243df96b47661aa1a564e3b7827bfd78688452646996a6bcecacb41d2f204cb5944f96171afce281b1667c8adb4c4c22545aa96e6c7639 +c12f3eca88850a1560917aaac0f18388e576d058673f1530a287b1c2ab5d376b4b7bbc7bd968372787ce27ac69b3a1af0424e0ee037137a2 +94a9e2f2e36b5eebf62e2d8a5393876e692bf51c943b45446c2262436051879028170be8dc8296f973a577cc78c2b4719aaa7a603ddf720e +11d8905df6c1417ddfeea4d321098d4df3f46d83fdb7450a2e647ca40932c4dce5f465104e890e28e6053c962a97ee7a530318dbc91bec18 +75fe613b60bdd4d14036d0112338f9fb3a897f987e93bf476ef00beb93dc7d95843c32b5b7da7b635179dceffa454962602896bca7768907 +c0e75998628e04123ccab906bb53043fffffbefdd31861eec8d0ee38a9a557e239104409debcef034d88b96de96020871c84827e997d23de +92eb69743ed330080483963f518383d0ad2e6c9d287ee77c3b2ed9cb30414f3b9f47912419f4f508ee1fe290df38e3394fb91356681ab0c1 +11a8662284c828aae2e238cda6d4fc6d4ca507e62fcbf3c05cf4088c8577b5c5512356b4da446b4c5b326b84494304151eccaf13744661ba +33a0270dedc61304dc01f60a1f4c53cc3a90a61efcf39e6b2fa08b07bac9487e6a215578a3cc5c2a71afdff0bc3c4081d214ac165479b13b +e8327aae7e4b4057e045d5e79e94039d409dc601ea99107c1a2590c942c9114bf334105c661f753f57c9e2ef991f78eee52aceed99401584 +023d84c7862638636dae492fd52a52e4a10e500985e4e15fe423294cd115a0a8517dc5dfc0ce0e0c0b567b6913ef6b6d6c0fda87f80fcaf8 +3274bdce67db1a6b885053f512c75bf8256d6cb621985878d576fa62766110c7fb0eb425ed1dc2477e28d4def42c693a68b09df862e18dc1 +d1edf196fd2e5299f4da5f121b1fd09eb1312521e9133f8f1fa4ae72f71b4fb885ad3c90ff69fe767ff47df9f23efa0cddbd82b0ba0d253b +3167aa1a132cbeb4f6734448abc3a71f4799864be9f933cb0bac62f14d71fea80857ad8a23edb93dc00ecaf880380b2d2156a5a1a00b8c20 +23c77c77cd7b170b3956d11e0c76493bd0064358c6e3649115f1163bbc1662f3951ba1271902e77a1d0c8742d619593fc7d92bc2a83937d0 +7ae613d618226c1edf26536257bb580481be697516dad0235bda7a33001dfe0dd36fb3771603724e7f1cc36190e87de7f112695b2d98723f +baa64e2c0abfbacf47deaad5cc78e70c99a8e8c799e96ce4163612dbd32ad99b2ad3a9e59896b508eac80c8ec48b7fbbbdc60318e09b88bc +61f1e0944edfc52c8c13a41ace60194fc6f76bed8b4438d787343adb3317a6601e991d5eabd53f0d1c08f6931e ''') db_write_enable = unhex(''' From 67ebd21f04ab70c3923cc115943d935d5f8ad666 Mon Sep 17 00:00:00 2001 From: Dmitry Muzyka Date: Wed, 3 Jun 2026 00:00:31 +0300 Subject: [PATCH 07/17] correct init_hardcoded_clean_slate --- validitysensor/blobs_a2.py | 53 +------------------------------------- 1 file changed, 1 insertion(+), 52 deletions(-) diff --git a/validitysensor/blobs_a2.py b/validitysensor/blobs_a2.py index 35d5ac3..691a4f0 100644 --- a/validitysensor/blobs_a2.py +++ b/validitysensor/blobs_a2.py @@ -14,58 +14,7 @@ 2999d1c0e7acf67e598696cd58cc4bdb1b7c037ee9a085f784c4 ''') - -init_hardcoded_clean_slate = unhex(''' -06020000016c5d73d6328c96d856b0d1ec9856580b787d5e56963cc0a798b8be5331b183a894f23e2ce1ba672a20d887f6cc884c3dd0274 -41377e68fb1d2531329dc8fc35716132db947295abeff7d8644417bbb5ddf2319b4fbde57c50759adaee10ee8e60400a2d7f09a7c0d9934 -f7575a00c44a41692015de80959d9f0b0edee6e70037423b98733b5e1d2f892e0ff5f2e0bb364d79b6273856553e4df0e49e19883058f16 -fdf698345da73ee561ac8dfc2a38c3c594c5972edebdd056923be21749da4b089c8148f73ac9917bdcd6976ce17d77b919dc56daf21bc54 -4aa4b3c6113037adf103e9b74a440b720104860a420bb211612d62da3a1327d2c4a6cf04bfb54d47218be9525c428078efa9241e6a42667 -e9079c91251026c6862575af5c40d7515615804816dda6c5495e6f9abd5c77e638925b28bbb0403d4f1aebf7d86bd5bc73a22a41fe2c7e1 -9db79c404fd3db43c8d87fecaf6ecf163f74659768a4c6cbe562d8d14b8ce4710b9164a3bed100918d9872aaa9bc9a448b117823f1e86fe -9ba9ac27649737ea497c83efff69022e6db7fe707947075d17c1de4bb1a712e4fddde31524379dea95497b675e4e70f4e88c8346807fff1 -083bee329d4cacc54d8576886272f1fb35c692b8c9156041a78aa4f16ce24cc3c4d0e2ca7eb957360a5d1a14da3046b73f996381b3f67b1 -36d46fe7386eb0b0e2468be7771a153567ecd9192628f71a9382a6f555cb77b72c959d79c8111133777056cc73f40e5f448a064fbfabc5a -b6c30d9f67cc87e641336ef37ff498b9af98212dbf3775b8a67b7ad933b5603dcbd3d7f9f2d90cefb292835d4671e07ba8573f4b8b62c53 -7178939a41c1658f09574de0037ddb4e24f179db751e477527382b627f6990900eaeafc6a5f1e58966d96c9bfbac6e931a514c235e829c5 -868c065fd430df2d27ea21be94525ea5ccd22688d56780a4a9c7e2072b1084aabdc7d5c5ed243d903f5c9bf336b033d9508a0e6dc3b0675 -7d0b9dfa26e85305ce953562dd43fc12fa2e81dff81ac0a1ca5ea0d3ebc209ea11046349c092c16c7680eb9d67c6bd1d57ab10fd7c07d93 -e00fb0370c89002051e1774b1555ce048eebc15ed4af35f9a4e2cf4b77bc5b0f58a7ab7a12aebf3c3a935c2bb278d446437b91c8cb7ce9e -9c49b243a18c45472ce601ee001bc9371fef9c03fb3cbb57b119f62f81723148402275b46f917aab7ab4e54e0846eb40c9fa0ecb8f0d3d6 -6fcfd9b6b5198c2e6451c29458726322c02401fe154cd24b21ddc6a3c4a69b95e2cc0c971cc2e78403bdeb70ffa5a343b7075609dc38cf5 -a0792ef55aa48991ca9237e911c66ec493dd1de176b2d7496100c803b58e071ea1ca3bb5c0fbdafc7a0ae50a56dab64002232d4c464bb78 -fd809a3ab74271fba1287fc1b6ad5e5c973b06a6984d95f0dc8909e6695aa6fe6cf5c263f657e068bde23045cab2264066d5e63d9db8374 -681a46a5c2d3127b34d848e31575d493ad64f263429c57808c36028f27a7070bf519c32b99f85fdedcfebc8033d519663f03bf082654bb7 -a774bccc701da2d743229cde9ff252472b3a53430db42a4c6dc49c8fecdf1c70c872a22dfb29c273b131f4e9d2bd29f46c7c6f23c0bc055 -79ed6e22015b9cf49f6705b093d85b5751d7a62921e5c51ed9c6bd0d4c44c3545e308712d5a0aea11399835e093bb606288b360e8d2634a -de12876d5ed9cd264f5f1421f4814299b734c9c914d5157afc2bd558c304e6ac81a320098acbbfaaa689cf0ecca3c82792961af28bcae17 -f1031e819944dc4421cea037d2864db5878d9c0062345675a61f704e540182308629d07466d4d38c41f0a7271bf21480edf8bab7a9545a0 -074018764e81cddcb91c86722fdb9e01c9f7398c91df65c3c78e0b594a9a04f02b5b0a52f0218e68b1f0dd0a268c6f380b8c01ef821617f -45fa651946027e35e8d9e05f68db61ffaeabdb693235d00b0bb4e964a6185a853e7457e57fabb2f25ab3cef8ccc20bc1132590debc6dbb9 -f084ac49f5e5d5228de6d216a2a5b6ed5e890c2ca726397838d715328a9d18d0df04de167613856bf09c898ef01855fccfcea1b3b0dd7e7 -b0fb209c324d1e4c9d6ee346eef81ded4325c7b0e6521857e37cd5ee076fdd8f778579f9510bae6d7501de64606d1c1f6f6bc0f5a64c1b9 -431b5ef24214e4893b9f7b4e182b81cbb3660974397716bb9bdfa90103c192758a03e25e23c35ce10afc0fb631be50eda1790310e9cddae -a5e4426036aad63df8265c73b5571dd8379cab5ef759de28b5de50ed03f04135949f2aa6d1084ca796034ea187246ea8c8cbcb5c25974c1 -408d889d35bd7f90e8eebc6ea8c08fe26fee6bf5d4044472455ced150c74c3ce56657f1284c4478bfd5d2bacae3587c3055055813caed2e -8ccba2739c0786754a7737a7ae610d1f4801e8d1f3ecc682f4303dbf0bf6f7510476269f1b64b15f2fc49f3f7e7bf5e22c2ce2b818c2020 -c19fcad9f2346aa15e7ce970985ca396ed11302bae047101a6bca2d2b7dd22cbc6cc6bd656fd56fa43f6da509d781277724f70b9751abf5 -f7f2a7fd2be6d0aef4b4b12fda505852f2da92240b2f9c588d711f48e6d6030b28122aab5d58e362ee06360ade5a25086666ca6734cb1c1 -f260003a1cc7fd7acbd48aec5c9f37a3f1fc63702a4080e1d3b33573d6a545b94ea917cebb0735fa227268665cee87084ddc1eb6efb789c -72eba6c46fcba0c469fe98e1694b1fc7bed5d0da758b822c70a32f50fe4e4109ee27cc1fb6f3c7f62b7631f2f8034bd413645c07cf80a4a -5838d8e1dde89cdb5028bb2af3d2131cd750f25a2c6d6fc8bfd1eade07abebec83a66f1c30c3916776234b86eafa6502d55dd714bb48207 -3ebf6ce89ab2b339926fef55fbb125522fb8ddd00ba9dafcfe0440d53796cc22df0d01ee14e1341dcd0c13957f5bc3f20c89990da0da30f -67d3dc4a0fa259a084007c475e302d3c7c62a29174157d9c56c0b40d03d81c53ee6e5e1144ba16b51896837679ad73519ce977be5ed20cf -a7c70481242f4d8c1b42f34f2b06554536c59e02743f9f20d9fe131a2b8982dca65216374f29a0a1e5478b009d51511448151fabd889f36 -0711a98c28749368c3f1b59ebfa268e5a084286ef550f72ca5902c5436e4551209b724675d3dd97b610d8d60c57181a14466c595d6fdaf7 -cd5e8c6a9327e7b0ada21429d399957aacf62cbe75cd6b550b1eee8df3d3122574e7b61925a7c469b4c1105411ee2362a7bf912c833bb9f -dc4ce479ac1f0941bf8935ada689d9b2cddd76490d35914981236eda3336c2127807eb652cdfa0f23ebfd5af57a9df6c6ac52ef2ec4423b -61e4e66cc52908296dd71db43308acbd22441bcc1237b7d34f4afe1892df61dc63abb5a5c584fbaf696d93b734614c575a39be4eade166e -483534b702b0c264207c7be8633f5386a60c033942b26d793c7e868205ed0c735aa7b36f8375978fb76629cb2a628e45f4fc81be3e4435b -65f6844512a5f12a0e1882b4e8d9109b97a0993f2b48853a4772c7d03b83043c8912fc317bf712dc6ed61de8828ae250eeb78cccd214340 -15e0f638948030ef39eebffb4df2ca0d26cb07e459940ee2f1eb64d4a840497ba0ea516c7d64d1d4ca74237171bd47caaf03c46a6d7e267 -67194df3bee3cb49c038f7a6f170d45434591022756a39b78c3905f44ffba8bf3b0a3ad157c10f2fc4e7869526cd824ac17a72a02c2aac1 -19c69beff3890c6090a993849762799b929529137c234baefc0c691848 -''') +init_hardcoded_clean_slate = init_hardcoded reset_blob = unhex(''' From c54e16688100fceb7157a4c016c1524fb9b4d6e4 Mon Sep 17 00:00:00 2001 From: Dmitry Muzyka Date: Wed, 3 Jun 2026 00:06:49 +0300 Subject: [PATCH 08/17] fix reset flow --- validitysensor/init_flash.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/validitysensor/init_flash.py b/validitysensor/init_flash.py index 61c918b..1d4bb02 100644 --- a/validitysensor/init_flash.py +++ b/validitysensor/init_flash.py @@ -53,6 +53,17 @@ ac2c08c00abf43faa5528a0a8e49b02c507b01b6f1c9abffc669d8c84d7e4a714da32aade7928eca9698b82bee6b72c642c9add80bbd7ccc4121b80220d52b8a ''') +# 06cb:00a2 uses the same partition layout as the generic flash_layout_hardcoded +# (byte-exact), but the partition table is signed with a device-model-specific +# key, so it needs its own signature. Extracted byte-exact from the Windows +# driver's reset/format capture (1780409730-usb.txt, 0x4f command). +partition_signature_a2 = unhex(''' +f52c94d3a340cd3d166516582be27d2c6f497fcf4f511b23f70f86927d48004330e4f17f3d1231fd8a0c9ff712c63759b8933a15cd7046f86c0b75d95fd54e93 +ff9e174f837eb643922d9c7bd5261f9139e40ca53e2a9735f7d472047c5eca6b463e2d1e42a147e54943e7cceba15b7f8452548a6b7f453336a3dbed6194f3a7 +705618132c3775f5906fe1baf4f87245865ae3cbe97c7a41858e87488ccd26077de5e3869b2ff22cf213377575a7a4dc5bc202bbb3539da9593df1d5616309ce +f84b3f45e1a4f2c3c4b45e856bb0eef652d916539b944da210e78537f9cb8d41b949fddb1af6ac8226b6e5763b3d570e901fa0f96d21de915fec1f945146a362 +''') + crypto_backend = default_backend() @@ -141,6 +152,10 @@ def init_flash(): if usb.usb_dev().idProduct == 0x0090: layout = flash_layout_hardcoded_0090 signature = partition_signature_0090 + elif usb.usb_dev().idVendor == 0x06cb: + if usb.usb_dev().idProduct == 0x00a2: + # same layout as generic, but a2-specific table signature + signature = partition_signature_a2 partition_flash(info, layout, signature, client_public) From 862cb116969811a42f107373da140f5cd51125ac Mon Sep 17 00:00:00 2001 From: Dmitry Muzyka Date: Sat, 6 Jun 2026 10:16:11 +0200 Subject: [PATCH 09/17] Refactor MoH enrollment out of sensor.py; merge moh_extract into moh_native - Move the ~145-line native-pipeline enroll_moh() implementation out of the Sensor class into validitysensor/moh_enrollment.py as a free function enroll_moh(sensor, ...). Sensor.enroll_moh is now a thin delegator, so existing callers keep working and sensor.py stays device-agnostic. - Fold moh_extract.py (template serializer, TID derivation, RE scaffold) into moh_native.py and delete moh_extract.py, so the MoH pipeline lives in a single module. Lazy imports keep the sensor <-> moh_enrollment dependency acyclic. Co-Authored-By: Claude Opus 4.8 (1M context) --- validitysensor/moh_enrollment.py | 173 +++++++++ validitysensor/moh_extract.py | 612 ------------------------------- validitysensor/moh_native.py | 603 +++++++++++++++++++++++++++++- validitysensor/sensor.py | 155 +------- 4 files changed, 782 insertions(+), 761 deletions(-) create mode 100644 validitysensor/moh_enrollment.py delete mode 100644 validitysensor/moh_extract.py diff --git a/validitysensor/moh_enrollment.py b/validitysensor/moh_enrollment.py new file mode 100644 index 0000000..81039c1 --- /dev/null +++ b/validitysensor/moh_enrollment.py @@ -0,0 +1,173 @@ +"""Match-on-Host (MoH) enrollment driver. + +Isolated from sensor.py so the generic Sensor class stays device-agnostic. +`Sensor.enroll()` delegates here for devices whose blob sets `moh_enroll = True` +(see blobs_a2.py): instead of the DLL-style 0x68/0x6b enrollment session, MoH +devices build a byte-exact template with the native pipeline (moh_native.py) +and store it via the raw 0x47 new_record protocol. + +The single entry point is `enroll_moh(sensor, ...)`; `Sensor.enroll_moh` is a +thin wrapper that forwards `self` as `sensor`. +""" +import logging +import typing +from struct import pack, unpack +from time import sleep + +from usb import core as usb_core + +from .db import db +from .flash import write_enable, call_cleanups +from .tls import tls +from .usb import CancelledException + + +def enroll_moh(sensor, parent_dbid: int, subtype: int, + update_cb: typing.Callable[[typing.Any, typing.Optional[Exception]], None] = lambda *a, **k: None, + max_attempts: int = 6, + num_frames: int = 6): + """Enroll a finger using the byte-exact native pipeline (no DLL). + + Captures `num_frames` placements, builds a 23136-byte template via the + native pipeline (our keypoints into the baked WS-body framing scaffold, + recompute TID), and stores it via the raw 0x47 store protocol: + typ=6 direct, storage=3, 1-byte trailer appended — NOT the + db.new_finger() / type=0xb-becomes-6 magic path which doesn't actually + work without an active 0x68/0x6b enrollment session. + + Args: + sensor: the Sensor instance (provides capture()). + parent_dbid: the existing user dbid the new finger attaches to. + Use `db.dump_raw()` to see what users exist. Storing under + a non-existent dbid succeeds at the storage layer BUT the + chip's matcher will silently fail to find the enrollment. + subtype: the WinBio subtype (= finger position) for the record. + update_cb: progress callback update_cb(progress_bytes, error) + matching enroll()'s OS contract (see scripts/prototype.py). + Called after each frame with a 1-byte percentage (0-100), or + (None, exception) on a failed attempt. + max_attempts: how many capture retries on transient errors. + + Returns: the recid created in the chip's storage.""" + import numpy as np + + # Imported here (not at module top) to avoid a circular import: + # sensor.py imports this module lazily from enroll(), so by the time we + # run, sensor.py is fully loaded. + from .sensor import CaptureMode, glow_start_scan, glow_end_scan + from .moh_native import (extract_frame_native, _load_ws_scaffold, + NATIVE_WS_V30_REGIONS, + patch_pre_v30_near_identity, + serialize_v30_section, V30_DESC_LEN, + compute_tid, _build_envelope) + + last_err = None + for attempt in range(max_attempts): + try: + # 1. Capture N frames (default 8; multi-frame enrollment + # fills the WS body's 4 v30 sections with different per- + # frame data). + logging.info(f'enroll_moh: capturing {num_frames} frame(s)...') + per_frame_kps = [] + for f in range(num_frames): + # Per-frame retry: if the sensor errors mid-capture + # (e.g. "Scanning problem: 8080000" — finger lifted too + # early), retry JUST this frame instead of restarting + # the whole enrollment. + for frame_attempt in range(max_attempts): + glow_start_scan() + logging.info(f' frame {f+1}/{num_frames}: place finger') + try: + x, y, w1, w2, img_data = sensor.capture(CaptureMode.ENROLL) + break + except usb_core.USBError: + glow_end_scan() + raise + except CancelledException: + glow_end_scan() + raise + except Exception as e: + glow_end_scan() + logging.warning(f' frame {f+1} capture failed ' + f'(attempt {frame_attempt+1}/' + f'{max_attempts}): {e}') + if frame_attempt + 1 == max_attempts: + raise + sleep(0.1) + glow_end_scan() + img = np.frombuffer(img_data, dtype=np.uint8).reshape(x, y) + img_q16 = img.astype(np.int32) << 16 + + logging.info(f' frame {f+1}: extracting features...') + kps = extract_frame_native(img_q16, h=112, w=112) + logging.info(f' frame {f+1}: {len(kps)} kp(s)') + per_frame_kps.append(kps) + # Report percentage complete after each frame is processed, + # via the OS update_cb(progress_bytes, error) contract (see + # scripts/prototype.py) — the percent is a single byte. + # Fires before the store, so a raising callback retries a + # capture (harmless) rather than duplicating a stored record. + update_cb(bytes([int((f + 1) * 100 / num_frames)]), None) + + # 2. Build envelope. Distribute frames across the v30 sections + # (round-robin if num_frames != #sections). The baked scaffold's + # WS framing bytes stay (header, anchors, section counts). + logging.info('enroll_moh: building envelope...') + ws_body = bytearray(_load_ws_scaffold()) + regions = list(NATIVE_WS_V30_REGIONS) + # sec0_pre must be NEAR-identity (load-bearing): the matcher skips + # pure-identity records as the 'unmatched' sentinel, so near-identity + # (tx=ty=1) makes each section a valid candidate alignment at verify. + ws_body = bytearray( + patch_pre_v30_near_identity(bytes(ws_body), regions)[0]) + for idx, base in enumerate(regions): + src_frame = per_frame_kps[idx % len(per_frame_kps)] + # v30 records are [16B desc][x][y]; the record area starts + # V30_DESC_LEN before the (x,y) anchor. The per-section trailer + # is enroll-only bookkeeping the matcher ignores — leave it. + section = serialize_v30_section( + [(gx, gy, desc) for (gx, gy, _o, desc) in src_frame[:250]]) + start = base - V30_DESC_LEN + ws_body[start:start + len(section)] = section + ws_body_bytes = bytes(ws_body) + tid = compute_tid(ws_body_bytes) + envelope = _build_envelope(subtype, ws_body_bytes, tid) + logging.info(f' envelope: {len(envelope)} bytes') + + # 3. Store via the proven replay protocol. No wait_int() + # — the typ=6-direct path doesn't emit an interrupt the + # way db.new_finger's typ=0xb-magic path does. bisect_ws + # send_finger() doesn't wait either, and it works. + logging.info('enroll_moh: storing on chip...') + db.db_info() + write_enable() + try: + msg = (pack(' SHA-256 (have body) - -qsort comparators (all 32-byte minutia records): - sub_18000A7C0 asc by score_10 DECODED - sub_18000A7E0 asc by (active, score_10) DECODED - sub_18000A810 asc by flag9, desc by score_10 DECODED - sub_18000A940 asc by score_c DECODED -""" - -from __future__ import annotations - -import hashlib -import hmac -import logging -from dataclasses import dataclass, field -from struct import pack, unpack -from typing import List, Optional, Tuple - -log = logging.getLogger(__name__) - - -# Tuning constants from sub_180004C10's stack-allocated param block, -# passed to sub_18000AAB0 each frame. -MAX_MINUTIAE = 250 # 0xfa -GRID_X = 10 # 0x0a -GRID_Y = 7 # 0x07 -COORD_RANGE_X = 1126 # 0x466 -COORD_RANGE_Y = 671 # 0x29f -BLOCK_SIZE_SMALL = 16 # 0x10 -BLOCK_SIZE_LARGE = 128 # 0x80 -COORD_SCALE = 500 # 0x1f4 - -# Sensor parameters (a2 device) -SENSOR_DPI = 363 -SENSOR_W = 112 -SENSOR_H = 112 - -# Per-enrollment limits from sub_1800D89C0 -MAX_BAD_FRAMES = 6 -MOH_MODE_FLAG = 101 # vtbl(this)[+24] == 101 - -# Output format selectors used by sub_18004E640 -TEMPLATE_FORMAT_SHA256 = 0 # 32 bytes -TEMPLATE_FORMAT_SHA1 = 1 # 20 bytes -TEMPLATE_FORMAT_MD5 = 2 # 16 bytes - -# Session-buffer header from sub_1800D89C0 -SESSION_MAGIC = 0x4C4F4356 # "VCOL" little-endian -SESSION_VERSION = 8 -SESSION_HEADER_LEN = 152 # minutia table starts at session + 152 - - -# ─── Minutia record (32 bytes) ──────────────────────────────────────── -# Field offsets recovered from the 4 qsort comparators. Bytes 0..7 and -# 0x14..0x1f are consumed by stages we haven't decompiled yet — likely -# (x, y, theta, type, quality) attributes. - -@dataclass -class Minutia: - head: bytes = field(default_factory=lambda: bytes(8)) # +0x00..0x07 - active: int = 0 # +0x08 - flag9: int = 0 # +0x09 - pad_a_b: bytes = field(default_factory=lambda: bytes(2)) # +0x0a..0x0b - score_c: int = 0 # +0x0c int32 - score_10: int = 0 # +0x10 int32 - tail: bytes = field(default_factory=lambda: bytes(12)) # +0x14..0x1f - - def __bytes__(self) -> bytes: - return (self.head - + pack(' 'Minutia': - assert len(b) == 32, f"Minutia is 32 bytes, got {len(b)}" - active, flag9 = unpack(' bool: - return self.active != 0 - - -# ─── qsort comparators (sub_18000A7C0/_A7E0/_A810/_A940) ────────────── -# sub_1800095C0 is the CRT qsort itself; we use Python's sorted() with -# these key functions instead. Each key reproduces the comparator's -# sign convention. - -def cmp_score_10_asc(m: Minutia) -> Tuple[int, ...]: - return (m.score_10,) - -def cmp_active_then_score_10(m: Minutia) -> Tuple[int, ...]: - return (m.active, m.score_10) - -def cmp_flag9_then_score_10_desc(m: Minutia) -> Tuple[int, ...]: - return (m.flag9, -m.score_10) - -def cmp_score_c_asc(m: Minutia) -> Tuple[int, ...]: - return (m.score_c,) - - -# ─── Frame context (the 250-slot working table + per-frame state) ───── - -class FrameContext: - def __init__(self): - self.minutiae: List[Minutia] = [Minutia() for _ in range(MAX_MINUTIAE)] - self.quality: int = 0 - self.progress_pct: int = 0 - self.frame_count: int = 0 - - -# ─── Stage functions ────────────────────────────────────────────────── -# Six of nine decoded directly from disassembly. All decoded ones are -# pure data-shaping plumbing — the actual biometric work happens in -# the unknown callees they invoke. - -def sub_180003460(dest: bytearray, ptr: int, size: int) -> None: - """Stream descriptor builder. dest is 0x30 bytes: - [+0] uint32 size - [+8] qword base ptr - [+0x10] qword cursor ptr (== base initially) - [+0x18..+0x30] secondary slot, zeroed - """ - dest[0:4] = pack(' None: - """Edge-flag computer. out4[0..4] = (top, bottom, left, right).""" - out4[0] = 1 if y <= 0 else 0 - out4[1] = 1 if y == height - 1 else 0 - out4[2] = 1 if x <= 0 else 0 - out4[3] = 1 if x == width - 1 else 0 - - -def sub_18000A910(dx: int, dy: int, - x_high: int, y_high: int, - x_lo: int, y_lo: int) -> Tuple[int, int]: - """Coordinate quantize-and-offset. Reproduces the - ((diff << 16) + off) >> 16 arithmetic-shift sign-extension trick.""" - def _q(diff: int, off: int) -> int: - v = ((diff << 16) + (off & 0xffffffff)) & 0xffffffff - if v & 0x80000000: - v |= ~0xffffffff - return v >> 16 - return _q(x_high - x_lo, dx), _q(y_high - y_lo, dy) - - -def sub_180001010(handle) -> int: - """Algorithm-ready gate. Returns 1 when ready, else HRESULT error.""" - if handle is None: return 0x80000030 - if getattr(handle, 'f8', 0) == 0: return 0x80000032 - return 1 if getattr(handle, 'f30', 0) != 0 else 0x80000002 - - -def sub_1800031E0(dst: bytearray, src: bytes, length: int) -> None: - """Custom memcpy with dword fast-path. Python equivalent is plain slice.""" - dst[:length] = src[:length] - - -def sub_1800032C0(dest: bytearray, arg2: bytearray, - consumed: int, src_ptr: int, - cap_qword: int, cap_dword: int) -> None: - """Stream-descriptor advance with bounds check. Resets dest's - secondary slot and re-sets primary {size, ptr} after 8-byte align.""" - # Set qword at +8, zero everything else - dest[8:16] = pack(' None: - """sub_180003320 — stream-advance dispatcher. Calls sub_1800032C0 - (now decoded). Routes to primary or secondary cursor.""" - raise NotImplementedError("Decoded structure; needs Python integration") - - -def stage_4_18000A4B0(*args) -> None: - """sub_18000A4B0 — two-stage glue: - sub_18000A1B0 (197 insn structural) → sub_18000F300 (94 insn SIMD). - F300 is where per-feature SIMD work lives.""" - raise NotImplementedError("Need sub_18000A1B0 and sub_18000F300") - - -def stage_5_18000A5B0(*args) -> None: - """sub_18000A5B0 (122 insn). NOT YET DECOMPILED.""" - raise NotImplementedError("Need decompile output for sub_18000A5B0") - - -def stage_6_18000A850_row_loop(dst: bytearray, dst_stride: int, height: int, - src: bytes, - src_stride_lo: int, src_stride_hi: int) -> None: - """Row-iteration loop. Calls sub_1800031E0 (now decoded as memcpy) - once per row. So this is just an image-blit with potentially - different src/dst strides.""" - src_stride = src_stride_lo * src_stride_hi - if height <= 0: return - s_off = 0; d_off = 0 - for _ in range(height): - dst[d_off:d_off+dst_stride] = src[s_off:s_off+dst_stride] - s_off += src_stride - d_off += dst_stride - - -def stage_9_18000A960(*args) -> None: - """sub_18000A960 (91 insn, 1 call). NOT YET DECOMPILED.""" - raise NotImplementedError("Need decompile output for sub_18000A960") - - -# Hardcoded LFSR-like seed table used by sub_18000E6B0 to deterministically -# select 64 binary tests from a 162-pair candidate database. These 128 values -# are baked into the binary; they're the "learned" random walk that defines -# which BRIEF-like point-pair tests this algorithm uses. -BRIEF_SEED_TABLE = [ - 3382, 4039, 29605, 1734, 19683, 2304, 17019, 16644, - 10030, 26447, 18237, 7668, 28663, 2663, 4319, 9870, - 10986, 19346, 9877, 19462, 12277, 24659, 28646, 32662, - 29695, 20554, 25346, 30589, 18903, 601, 27989, 17736, - 12138, 9477, 19036, 8528, 31546, 30239, 15544, 3972, - 32267, 11683, 23937, 16744, 27871, 4064, 30172, 22878, - 10021, 27353, 5840, 29477, 11566, 748, 25429, 5535, - 23264, 12977, 16558, 29143, 15022, 16933, 24825, 4930, - 1224, 14600, 23557, 25925, 7822, 12419, 19043, 12792, - 11851, 26638, 5824, 32298, 5920, 8593, 31090, 26277, - 28990, 2249, 21072, 25266, 21080, 10734, 21703, 4064, - 31321, 15251, 14890, 27394, 14418, 16333, 28234, 6775, - 7094, 16535, 27207, 11694, 17865, 11125, 12709, 30184, - 28502, 4184, 9634, 23616, 30368, 18370, 8903, 22761, - 2460, 17450, 7358, 28600, 16477, 4770, 11363, 21986, - 15312, 20151, 17437, 9478, 7337, 3481, 32367, 0, -] -assert len(BRIEF_SEED_TABLE) == 128, "seed table is exactly 128 entries" - - -# The fill value used for image padding by sub_180009F50. In 16.16 fixed-point -# this is 128.0 — mid-gray, neutral for gradient/filter operations. -PADDING_FILL_VALUE = 0x800000 - - -def sub_180009F50(workspace_a: bytearray, fill_value: int, - scale: int, dim_y: int, dim_x: int, - pixel_buf: bytes, scratch_strip: bytearray, - width: int, height: int, - edge_flags: bytes) -> None: - """Image padding. Builds workspace_a as the input image with mid-gray - padding on the four edges that touch the sensor boundary. - - Per-edge padding size is `scale` if that edge is touched (per - edge_flags from sub_18000A8E0), else 0. Centered patches get no - padding; sensor-edge patches get padding on the affected sides. - """ - top = scale if edge_flags[0] else 0 - bottom = scale if edge_flags[1] else 0 - left = scale if edge_flags[2] else 0 - right = scale if edge_flags[3] else 0 - - # Pre-fill the scratch strip with the fill value (for left/right margins) - n = scale * dim_y - for i in range(n): - struct_pack_into = pack(' List[Tuple[int, int, int, int, int]]: - """Generate the 64 binary tests used by the per-minutia descriptor. - - Returns 64 tuples of (level, x1, y1, x2, y2) where: - - level ∈ {0, 1, 2} (= grid_size - 2; grid_size ∈ {2, 3, 4}) - - (x1, y1) and (x2, y2) are pixel-offset coordinates relative to - the minutia center, scaled into [-scale, +scale] - - These tests are applied to a local patch: each test produces a bit - by comparing the pixels at (x1, y1) and (x2, y2). 64 bits form the - per-minutia binary descriptor. - """ - # Phase 1: build all candidate pairs across 3 grid resolutions - pairs: List[Tuple[int, int, int, int, int]] = [] - for grid_size in (2, 3, 4): - level = grid_size - 2 - scale_factor = int(scale * 2.0 / grid_size + 0.999) - n = grid_size * grid_size - for i in range(n): - for j in range(i + 1, n): - pairs.append(( - level, - scale_factor * (i % grid_size) - scale, - scale_factor * (i // grid_size) - scale, - scale_factor * (j % grid_size) - scale, - scale_factor * (j // grid_size) - scale, - )) - # 6 + 36 + 120 = 162 - - # Phase 2: select 64 pairs deterministically using the seed table - selected = [] - remaining = list(pairs) - for i in range(min(num_tests, len(remaining))): - if i < 6: - pick = i - else: - pick = BRIEF_SEED_TABLE[i] % len(remaining) - selected.append(remaining[pick]) - remaining[pick] = remaining[-1] - remaining.pop() - return selected - - -# ─── Per-frame orchestrator (sub_18000AAB0) ─────────────────────────── - -def orchestrate(image: bytes, w: int, h: int, ctx: FrameContext) -> int: - """sub_18000AAB0. Runs the 9-stage pipeline on one frame. - - Outer-call shape (from sub_180004C10's setup): - zero_minutia_table(ctx) - params = {f0=500, f4=250, f8=10, fc=7, f10=1126, - f14=671, f18=16, f1c=128, f20=0} - sub_18000AAB0(image, ctx.algo, w, h, desc, ctx.sub, params) - - Returns 1 on success. Real output is in `ctx` (minutia table updated). - """ - for m in ctx.minutiae: - m.head = bytes(8); m.active = 0; m.flag9 = 0; m.pad_a_b = bytes(2) - - # stage_1_pre_process(image, w, h, ctx) - # stage_2_build_descriptor(...) - # ctx.minutiae.sort(key=cmp_score_10_asc) # qsort call 1 - # stage_4_18000A4B0(...) - # ctx.minutiae.sort(key=cmp_active_then_score_10) # qsort call 2 - # stage_5_18000A5B0(...) - # ctx.minutiae.sort(key=cmp_flag9_then_score_10_desc) # qsort call 3 - # stage_6_18000A850(...) - # stage_7_18000A8E0(...) - # stage_8_18000A910(...) - # ─ partition: count leading active minutiae, sort each range by score_c ─ - # n_active = next((i for i, m in enumerate(ctx.minutiae) - # if not m.is_active()), len(ctx.minutiae)) - # ctx.minutiae[:n_active] = sorted(ctx.minutiae[:n_active], key=cmp_score_c_asc) - # ctx.minutiae[n_active:] = sorted(ctx.minutiae[n_active:], key=cmp_score_c_asc) - # stage_9_18000A960(...) - - raise NotImplementedError( - "orchestrate(): pending decompile output for stages 1-9. " - "See module docstring for the function addresses." - ) - - -# ─── Feature-extraction coordinator (sub_180001A50) ─────────────────── - -def extract_features(image: bytes, w: int, h: int, ctx: FrameContext, - dpi: int = SENSOR_DPI) -> bytes: - """sub_180001A50. Runs the per-frame pipeline, returns the 32-byte TemplateId. - - The TemplateId is HMAC-SHA256 chained over the assembled WS body — - see compute_tid() for the verified recipe. Note that this function - is still a scaffold: it can't return the chip-accepted TID until - orchestrate() actually fills ctx into a full 23056-byte WS body. - """ - if w > 255: - w = 255 - - orchestrate(image, w, h, ctx) - - ws_body = _serialize_for_hash(ctx) - if len(ws_body) != 23056: - # Placeholder until orchestrate() emits the full chip-view WS body - # (23056 bytes starting with 4 zeros). Returning a SHA-256 here - # keeps callers running but the chip's matcher will not accept - # the resulting template. - log.warning("WS body is %d bytes, expected 23056 — TID will not be chip-valid", - len(ws_body)) - return hashlib.sha256(ws_body).digest() - return compute_tid(ws_body) - - -def _serialize_for_hash(ctx: FrameContext) -> bytes: - """Produce the WS body bytes (TLV-1 payload of the finger template). - - Provisional: emit the 250×32-byte minutia slot table only (8000 bytes). - The chip-accepted WS body is 23056 bytes, so this is short by 15056 - bytes of feature/calibration data we haven't reverse-engineered yet. - To be filled in as orchestrate()'s stage outputs are decoded. - """ - return b''.join(bytes(m) for m in ctx.minutiae) - - -# ─── Per-enrollment session driver (sub_1800D89C0) ──────────────────── - -class EnrollmentSession: - """One enrollment session. Accept frames until enough quality data - has accumulated, then finalize() to emit the chip-storable bytes. - """ - - def __init__(self): - self.ctx = FrameContext() - self.template_id: Optional[bytes] = None - self.frame_count = 0 - self.bad_frame_count = 0 - - def process_frame(self, image: bytes, - w: int = SENSOR_W, h: int = SENSOR_H) -> dict: - self.frame_count += 1 - - # TODO sub_1800D8100 quality pre-check: - # if not _quality_ok(image, w, h): - # self.bad_frame_count += 1 - # if self.bad_frame_count > MAX_BAD_FRAMES: - # return {'state': 'give-up', 'progress': self.ctx.progress_pct} - # return {'state': 'bad-frame', 'progress': self.ctx.progress_pct} - - self.template_id = extract_features(image, w, h, self.ctx) - - # TODO: decide 'final' vs 'progressing' based on accumulated quality - return {'state': 'progressing', 'progress': self.ctx.progress_pct} - - def finalize(self) -> bytes: - """Emit the ~23 KB byte blob that goes via db.new_finger() / 0x47. - - Format decoded from sub_180036840 (vfmAuth.c): - - [8-byte envelope header] - [TLV tag=1, len=ws_size, data=working_state_buffer] # ≈ 22.9 KB - [TLV tag=2, len=32, data=SHA256_TemplateId] # 32 bytes - [32 trailing zero bytes] - - The working_state_buffer is session+152..session+152+ws_size -- - the buffer the 9-stage pipeline writes into during EnrollmentUpdate. - TemplateId is SHA-256 over (some subset of) that buffer. - """ - if not self.template_id: - raise RuntimeError("no template yet; process_frame() must be called first") - - ws = _serialize_for_hash(self.ctx) # placeholder; this is also the TLV-1 content - tid = self.template_id - subtype_u16 = 0xf75a # echoed at envelope header bytes 0..1 - - return _build_envelope(subtype_u16, ws, tid) - - -def _build_envelope(subtype: int, ws_body: bytes, template_id: bytes, - version: int = 3) -> bytes: - """Wire-exact envelope for new_record type=6, byte-identical to - sub_180036840 in synaWudfBioUsb.dll. - - Layout: - offset size field - ──────────────────────────────────────────────────────────── - 0 2 u16 subtype (e.g. 0x00f7) - 2 2 u16 version (= 3) - 4 2 u16 payload_size (= 4 + ws_size + 4 + tid_size) - 6 2 u16 trailing (= 32) - 8 2 u16 tlv1_tag (= 1) - 10 2 u16 tlv1_len (= ws_size) - 12 n bytes ws_body[n] ← chip-view WS body starts here - 12+n 2 u16 tlv2_tag (= 2) - 14+n 2 u16 tlv2_len (= tid_size) - 16+n 32 bytes template_id - 48+n 32 bytes trailing zeros - - For ws_size = 23056 and tid_size = 32, total envelope is 23136 bytes - with the TID at envelope offset 23072..23104 and the TLV2 header - immediately preceding it at 23068..23072. - - Caller contract: pass the chip-view WS body, NOT the - "envelope[16..23072]" slice. The chip-view WS body is 23056 bytes - that go from envelope offset 12 to 12+23056. In any captured - template it always begins with 4 natural-zero bytes - (template[12..16]) and ends with 4 bytes of feature-data tail - (template[23064..23068]); the TLV2 header that sits at template - offset 23068..23072 is NOT part of ws_body — this function writes - it explicitly. - """ - assert len(template_id) == 32 - ws_size = len(ws_body) - tid_size = len(template_id) - trailing = 32 - payload_size = 4 + ws_size + 4 + tid_size # TLV1 hdr + ws + TLV2 hdr + TID - total = 8 + payload_size + trailing - - buf = bytearray(total) - # Outer header (8 bytes) - buf[0:2] = pack(' bytes: - """Compute the 32-byte TemplateId for a Match-on-Host finger template. - - Recipe verified end-to-end against a Wine-captured enrollment - (enroll-fresh.log lines 1570→1583) and against the stored TID at - envelope offset 23072..23104 of every captured template: - - K = SHA-256(ws_body) - T1 = HMAC-SHA256(K, "Template ID" ‖ 32×0x00) - TID = HMAC-SHA256(K, T1 ‖ "Template ID" ‖ 32×0x00) - - K is derived from the WS body itself, so there is no device-bound - secret involved. Anyone with the WS body can recompute the TID. See - dev/MOH.md "TID derivation". - - Args: - ws_body: 23056-byte chip-view WS body. In a captured envelope - this is the slice template[12:12+23056] — the bytes the - chip parses as the TLV1 payload. Always begins with 4 - natural-zero bytes and ends with feature data tail; does - NOT include the TLV2 header that lives between the WS body - and the TID at envelope offset 23068. - - Returns: - The 32-byte TID, identical to template[23072:23104] for any - valid captured template. - """ - if len(ws_body) != 23056: - raise ValueError(f"ws_body must be 23056 bytes, got {len(ws_body)}") - - K = hashlib.sha256(ws_body).digest() - T1 = hmac.new(K, _TID_INFO, hashlib.sha256).digest() - return hmac.new(K, T1 + _TID_INFO, hashlib.sha256).digest() diff --git a/validitysensor/moh_native.py b/validitysensor/moh_native.py index 5e2819e..8efb477 100644 --- a/validitysensor/moh_native.py +++ b/validitysensor/moh_native.py @@ -37,9 +37,25 @@ truncation is why Ixy never recovered as one linear kernel). Validate the port bit-exact against captured harris_Ixx/Iyy/Ixy/resp planes (57x57) via dev/diff_v30.py compare_harris BEFORE chaining downstream. + +This module also contains the host-side template serializer / TID derivation +and the (mostly RE-scaffold) CEohMohEIV port that previously lived in +moh_extract.py — see the "Host-side feature-extraction pipeline" section at +the bottom of this file. """ +from __future__ import annotations + +import hashlib +import hmac +import logging +from dataclasses import dataclass, field +from struct import pack, unpack +from typing import List, Optional, Tuple + import numpy as np +log = logging.getLogger(__name__) + # ─── geometry (decoded from orchestrator sub_18000AAB0) ───────────────── GRID = 3 # 3×3 tiles GRID_X = 10 # overlap half-width (v13 = 2*GRID_X = 20 total) @@ -1103,9 +1119,10 @@ def native_template(image_q16, subtype=None, fill_all_sections=True): region; if False, only the first. Returns: - 23136-byte envelope ready for db.new_finger() (= chip cmd 0x47).""" - from .moh_extract import compute_tid, _build_envelope + 23136-byte envelope ready for db.new_finger() (= chip cmd 0x47). + compute_tid / _build_envelope are defined further down in this module + (formerly moh_extract.py).""" ws_body = bytearray(_load_ws_scaffold()) regions = list(NATIVE_WS_V30_REGIONS) if subtype is None: @@ -1140,3 +1157,585 @@ def native_template(image_q16, subtype=None, fill_all_sections=True): ws_body_bytes = bytes(ws_body) tid = compute_tid(ws_body_bytes) return _build_envelope(subtype, ws_body_bytes, tid) + + +# ══════════════════════════════════════════════════════════════════════ +# Host-side feature-extraction pipeline (formerly moh_extract.py) +# +# Python port of the proprietary CEohMohEIV pipeline in synaWudfBioUsb.dll. +# In production only compute_tid() and _build_envelope() are used (by +# native_template above and by the enrollment driver in moh_enrollment.py); +# the Minutia/stage/orchestrate scaffold below is retained RE documentation. +# +# Reference function addresses (Lenovo n1cgn10w build): +# sub_180001A50 coordinator | sub_180004C10 250-slot init | sub_18000AAB0 +# 9-stage orchestrator | sub_18004B710 CryptHashData->SHA-256. +# qsort comparators: sub_18000A7C0/_A7E0/_A810/_A940 (all DECODED). +# ══════════════════════════════════════════════════════════════════════ + +# Tuning constants from sub_180004C10's stack-allocated param block, +# passed to sub_18000AAB0 each frame. +MAX_MINUTIAE = 250 # 0xfa +GRID_X = 10 # 0x0a +GRID_Y = 7 # 0x07 +COORD_RANGE_X = 1126 # 0x466 +COORD_RANGE_Y = 671 # 0x29f +BLOCK_SIZE_SMALL = 16 # 0x10 +BLOCK_SIZE_LARGE = 128 # 0x80 +COORD_SCALE = 500 # 0x1f4 + +# Sensor parameters (a2 device) +SENSOR_DPI = 363 +SENSOR_W = 112 +SENSOR_H = 112 + +# Per-enrollment limits from sub_1800D89C0 +MAX_BAD_FRAMES = 6 +MOH_MODE_FLAG = 101 # vtbl(this)[+24] == 101 + +# Output format selectors used by sub_18004E640 +TEMPLATE_FORMAT_SHA256 = 0 # 32 bytes +TEMPLATE_FORMAT_SHA1 = 1 # 20 bytes +TEMPLATE_FORMAT_MD5 = 2 # 16 bytes + +# Session-buffer header from sub_1800D89C0 +SESSION_MAGIC = 0x4C4F4356 # "VCOL" little-endian +SESSION_VERSION = 8 +SESSION_HEADER_LEN = 152 # minutia table starts at session + 152 + + +# ─── Minutia record (32 bytes) ──────────────────────────────────────── +# Field offsets recovered from the 4 qsort comparators. Bytes 0..7 and +# 0x14..0x1f are consumed by stages we haven't decompiled yet — likely +# (x, y, theta, type, quality) attributes. + +@dataclass +class Minutia: + head: bytes = field(default_factory=lambda: bytes(8)) # +0x00..0x07 + active: int = 0 # +0x08 + flag9: int = 0 # +0x09 + pad_a_b: bytes = field(default_factory=lambda: bytes(2)) # +0x0a..0x0b + score_c: int = 0 # +0x0c int32 + score_10: int = 0 # +0x10 int32 + tail: bytes = field(default_factory=lambda: bytes(12)) # +0x14..0x1f + + def __bytes__(self) -> bytes: + return (self.head + + pack(' 'Minutia': + assert len(b) == 32, f"Minutia is 32 bytes, got {len(b)}" + active, flag9 = unpack(' bool: + return self.active != 0 + + +# ─── qsort comparators (sub_18000A7C0/_A7E0/_A810/_A940) ────────────── +# sub_1800095C0 is the CRT qsort itself; we use Python's sorted() with +# these key functions instead. Each key reproduces the comparator's +# sign convention. + +def cmp_score_10_asc(m: Minutia) -> Tuple[int, ...]: + return (m.score_10,) + +def cmp_active_then_score_10(m: Minutia) -> Tuple[int, ...]: + return (m.active, m.score_10) + +def cmp_flag9_then_score_10_desc(m: Minutia) -> Tuple[int, ...]: + return (m.flag9, -m.score_10) + +def cmp_score_c_asc(m: Minutia) -> Tuple[int, ...]: + return (m.score_c,) + + +# ─── Frame context (the 250-slot working table + per-frame state) ───── + +class FrameContext: + def __init__(self): + self.minutiae: List[Minutia] = [Minutia() for _ in range(MAX_MINUTIAE)] + self.quality: int = 0 + self.progress_pct: int = 0 + self.frame_count: int = 0 + + +# ─── Stage functions ────────────────────────────────────────────────── +# Six of nine decoded directly from disassembly. All decoded ones are +# pure data-shaping plumbing — the actual biometric work happens in +# the unknown callees they invoke. + +def sub_180003460(dest: bytearray, ptr: int, size: int) -> None: + """Stream descriptor builder. dest is 0x30 bytes: + [+0] uint32 size + [+8] qword base ptr + [+0x10] qword cursor ptr (== base initially) + [+0x18..+0x30] secondary slot, zeroed + """ + dest[0:4] = pack(' None: + """Edge-flag computer. out4[0..4] = (top, bottom, left, right).""" + out4[0] = 1 if y <= 0 else 0 + out4[1] = 1 if y == height - 1 else 0 + out4[2] = 1 if x <= 0 else 0 + out4[3] = 1 if x == width - 1 else 0 + + +def sub_18000A910(dx: int, dy: int, + x_high: int, y_high: int, + x_lo: int, y_lo: int) -> Tuple[int, int]: + """Coordinate quantize-and-offset. Reproduces the + ((diff << 16) + off) >> 16 arithmetic-shift sign-extension trick.""" + def _q(diff: int, off: int) -> int: + v = ((diff << 16) + (off & 0xffffffff)) & 0xffffffff + if v & 0x80000000: + v |= ~0xffffffff + return v >> 16 + return _q(x_high - x_lo, dx), _q(y_high - y_lo, dy) + + +def sub_180001010(handle) -> int: + """Algorithm-ready gate. Returns 1 when ready, else HRESULT error.""" + if handle is None: return 0x80000030 + if getattr(handle, 'f8', 0) == 0: return 0x80000032 + return 1 if getattr(handle, 'f30', 0) != 0 else 0x80000002 + + +def sub_1800031E0(dst: bytearray, src: bytes, length: int) -> None: + """Custom memcpy with dword fast-path. Python equivalent is plain slice.""" + dst[:length] = src[:length] + + +def sub_1800032C0(dest: bytearray, arg2: bytearray, + consumed: int, src_ptr: int, + cap_qword: int, cap_dword: int) -> None: + """Stream-descriptor advance with bounds check. Resets dest's + secondary slot and re-sets primary {size, ptr} after 8-byte align.""" + # Set qword at +8, zero everything else + dest[8:16] = pack(' None: + """sub_180003320 — stream-advance dispatcher. Calls sub_1800032C0 + (now decoded). Routes to primary or secondary cursor.""" + raise NotImplementedError("Decoded structure; needs Python integration") + + +def stage_4_18000A4B0(*args) -> None: + """sub_18000A4B0 — two-stage glue: + sub_18000A1B0 (197 insn structural) → sub_18000F300 (94 insn SIMD). + F300 is where per-feature SIMD work lives.""" + raise NotImplementedError("Need sub_18000A1B0 and sub_18000F300") + + +def stage_5_18000A5B0(*args) -> None: + """sub_18000A5B0 (122 insn). NOT YET DECOMPILED.""" + raise NotImplementedError("Need decompile output for sub_18000A5B0") + + +def stage_6_18000A850_row_loop(dst: bytearray, dst_stride: int, height: int, + src: bytes, + src_stride_lo: int, src_stride_hi: int) -> None: + """Row-iteration loop. Calls sub_1800031E0 (now decoded as memcpy) + once per row. So this is just an image-blit with potentially + different src/dst strides.""" + src_stride = src_stride_lo * src_stride_hi + if height <= 0: return + s_off = 0; d_off = 0 + for _ in range(height): + dst[d_off:d_off+dst_stride] = src[s_off:s_off+dst_stride] + s_off += src_stride + d_off += dst_stride + + +def stage_9_18000A960(*args) -> None: + """sub_18000A960 (91 insn, 1 call). NOT YET DECOMPILED.""" + raise NotImplementedError("Need decompile output for sub_18000A960") + + +# Hardcoded LFSR-like seed table used by sub_18000E6B0 to deterministically +# select 64 binary tests from a 162-pair candidate database. These 128 values +# are baked into the binary; they're the "learned" random walk that defines +# which BRIEF-like point-pair tests this algorithm uses. +BRIEF_SEED_TABLE = [ + 3382, 4039, 29605, 1734, 19683, 2304, 17019, 16644, + 10030, 26447, 18237, 7668, 28663, 2663, 4319, 9870, + 10986, 19346, 9877, 19462, 12277, 24659, 28646, 32662, + 29695, 20554, 25346, 30589, 18903, 601, 27989, 17736, + 12138, 9477, 19036, 8528, 31546, 30239, 15544, 3972, + 32267, 11683, 23937, 16744, 27871, 4064, 30172, 22878, + 10021, 27353, 5840, 29477, 11566, 748, 25429, 5535, + 23264, 12977, 16558, 29143, 15022, 16933, 24825, 4930, + 1224, 14600, 23557, 25925, 7822, 12419, 19043, 12792, + 11851, 26638, 5824, 32298, 5920, 8593, 31090, 26277, + 28990, 2249, 21072, 25266, 21080, 10734, 21703, 4064, + 31321, 15251, 14890, 27394, 14418, 16333, 28234, 6775, + 7094, 16535, 27207, 11694, 17865, 11125, 12709, 30184, + 28502, 4184, 9634, 23616, 30368, 18370, 8903, 22761, + 2460, 17450, 7358, 28600, 16477, 4770, 11363, 21986, + 15312, 20151, 17437, 9478, 7337, 3481, 32367, 0, +] +assert len(BRIEF_SEED_TABLE) == 128, "seed table is exactly 128 entries" + + +# The fill value used for image padding by sub_180009F50. In 16.16 fixed-point +# this is 128.0 — mid-gray, neutral for gradient/filter operations. +PADDING_FILL_VALUE = 0x800000 + + +def sub_180009F50(workspace_a: bytearray, fill_value: int, + scale: int, dim_y: int, dim_x: int, + pixel_buf: bytes, scratch_strip: bytearray, + width: int, height: int, + edge_flags: bytes) -> None: + """Image padding. Builds workspace_a as the input image with mid-gray + padding on the four edges that touch the sensor boundary. + + Per-edge padding size is `scale` if that edge is touched (per + edge_flags from sub_18000A8E0), else 0. Centered patches get no + padding; sensor-edge patches get padding on the affected sides. + """ + top = scale if edge_flags[0] else 0 + bottom = scale if edge_flags[1] else 0 + left = scale if edge_flags[2] else 0 + right = scale if edge_flags[3] else 0 + + # Pre-fill the scratch strip with the fill value (for left/right margins) + n = scale * dim_y + for i in range(n): + struct_pack_into = pack(' List[Tuple[int, int, int, int, int]]: + """Generate the 64 binary tests used by the per-minutia descriptor. + + Returns 64 tuples of (level, x1, y1, x2, y2) where: + - level ∈ {0, 1, 2} (= grid_size - 2; grid_size ∈ {2, 3, 4}) + - (x1, y1) and (x2, y2) are pixel-offset coordinates relative to + the minutia center, scaled into [-scale, +scale] + + These tests are applied to a local patch: each test produces a bit + by comparing the pixels at (x1, y1) and (x2, y2). 64 bits form the + per-minutia binary descriptor. + """ + # Phase 1: build all candidate pairs across 3 grid resolutions + pairs: List[Tuple[int, int, int, int, int]] = [] + for grid_size in (2, 3, 4): + level = grid_size - 2 + scale_factor = int(scale * 2.0 / grid_size + 0.999) + n = grid_size * grid_size + for i in range(n): + for j in range(i + 1, n): + pairs.append(( + level, + scale_factor * (i % grid_size) - scale, + scale_factor * (i // grid_size) - scale, + scale_factor * (j % grid_size) - scale, + scale_factor * (j // grid_size) - scale, + )) + # 6 + 36 + 120 = 162 + + # Phase 2: select 64 pairs deterministically using the seed table + selected = [] + remaining = list(pairs) + for i in range(min(num_tests, len(remaining))): + if i < 6: + pick = i + else: + pick = BRIEF_SEED_TABLE[i] % len(remaining) + selected.append(remaining[pick]) + remaining[pick] = remaining[-1] + remaining.pop() + return selected + + +# ─── Per-frame orchestrator (sub_18000AAB0) ─────────────────────────── + +def orchestrate(image: bytes, w: int, h: int, ctx: FrameContext) -> int: + """sub_18000AAB0. Runs the 9-stage pipeline on one frame. + + Outer-call shape (from sub_180004C10's setup): + zero_minutia_table(ctx) + params = {f0=500, f4=250, f8=10, fc=7, f10=1126, + f14=671, f18=16, f1c=128, f20=0} + sub_18000AAB0(image, ctx.algo, w, h, desc, ctx.sub, params) + + Returns 1 on success. Real output is in `ctx` (minutia table updated). + """ + for m in ctx.minutiae: + m.head = bytes(8); m.active = 0; m.flag9 = 0; m.pad_a_b = bytes(2) + + # stage_1_pre_process(image, w, h, ctx) + # stage_2_build_descriptor(...) + # ctx.minutiae.sort(key=cmp_score_10_asc) # qsort call 1 + # stage_4_18000A4B0(...) + # ctx.minutiae.sort(key=cmp_active_then_score_10) # qsort call 2 + # stage_5_18000A5B0(...) + # ctx.minutiae.sort(key=cmp_flag9_then_score_10_desc) # qsort call 3 + # stage_6_18000A850(...) + # stage_7_18000A8E0(...) + # stage_8_18000A910(...) + # ─ partition: count leading active minutiae, sort each range by score_c ─ + # n_active = next((i for i, m in enumerate(ctx.minutiae) + # if not m.is_active()), len(ctx.minutiae)) + # ctx.minutiae[:n_active] = sorted(ctx.minutiae[:n_active], key=cmp_score_c_asc) + # ctx.minutiae[n_active:] = sorted(ctx.minutiae[n_active:], key=cmp_score_c_asc) + # stage_9_18000A960(...) + + raise NotImplementedError( + "orchestrate(): pending decompile output for stages 1-9. " + "See module docstring for the function addresses." + ) + + +# ─── Feature-extraction coordinator (sub_180001A50) ─────────────────── + +def extract_features(image: bytes, w: int, h: int, ctx: FrameContext, + dpi: int = SENSOR_DPI) -> bytes: + """sub_180001A50. Runs the per-frame pipeline, returns the 32-byte TemplateId. + + The TemplateId is HMAC-SHA256 chained over the assembled WS body — + see compute_tid() for the verified recipe. Note that this function + is still a scaffold: it can't return the chip-accepted TID until + orchestrate() actually fills ctx into a full 23056-byte WS body. + """ + if w > 255: + w = 255 + + orchestrate(image, w, h, ctx) + + ws_body = _serialize_for_hash(ctx) + if len(ws_body) != 23056: + # Placeholder until orchestrate() emits the full chip-view WS body + # (23056 bytes starting with 4 zeros). Returning a SHA-256 here + # keeps callers running but the chip's matcher will not accept + # the resulting template. + log.warning("WS body is %d bytes, expected 23056 — TID will not be chip-valid", + len(ws_body)) + return hashlib.sha256(ws_body).digest() + return compute_tid(ws_body) + + +def _serialize_for_hash(ctx: FrameContext) -> bytes: + """Produce the WS body bytes (TLV-1 payload of the finger template). + + Provisional: emit the 250×32-byte minutia slot table only (8000 bytes). + The chip-accepted WS body is 23056 bytes, so this is short by 15056 + bytes of feature/calibration data we haven't reverse-engineered yet. + To be filled in as orchestrate()'s stage outputs are decoded. + """ + return b''.join(bytes(m) for m in ctx.minutiae) + + +# ─── Per-enrollment session driver (sub_1800D89C0) ──────────────────── + +class EnrollmentSession: + """One enrollment session. Accept frames until enough quality data + has accumulated, then finalize() to emit the chip-storable bytes. + """ + + def __init__(self): + self.ctx = FrameContext() + self.template_id: Optional[bytes] = None + self.frame_count = 0 + self.bad_frame_count = 0 + + def process_frame(self, image: bytes, + w: int = SENSOR_W, h: int = SENSOR_H) -> dict: + self.frame_count += 1 + + # TODO sub_1800D8100 quality pre-check: + # if not _quality_ok(image, w, h): + # self.bad_frame_count += 1 + # if self.bad_frame_count > MAX_BAD_FRAMES: + # return {'state': 'give-up', 'progress': self.ctx.progress_pct} + # return {'state': 'bad-frame', 'progress': self.ctx.progress_pct} + + self.template_id = extract_features(image, w, h, self.ctx) + + # TODO: decide 'final' vs 'progressing' based on accumulated quality + return {'state': 'progressing', 'progress': self.ctx.progress_pct} + + def finalize(self) -> bytes: + """Emit the ~23 KB byte blob that goes via db.new_finger() / 0x47. + + Format decoded from sub_180036840 (vfmAuth.c): + + [8-byte envelope header] + [TLV tag=1, len=ws_size, data=working_state_buffer] # ≈ 22.9 KB + [TLV tag=2, len=32, data=SHA256_TemplateId] # 32 bytes + [32 trailing zero bytes] + + The working_state_buffer is session+152..session+152+ws_size -- + the buffer the 9-stage pipeline writes into during EnrollmentUpdate. + TemplateId is SHA-256 over (some subset of) that buffer. + """ + if not self.template_id: + raise RuntimeError("no template yet; process_frame() must be called first") + + ws = _serialize_for_hash(self.ctx) # placeholder; this is also the TLV-1 content + tid = self.template_id + subtype_u16 = 0xf75a # echoed at envelope header bytes 0..1 + + return _build_envelope(subtype_u16, ws, tid) + + +def _build_envelope(subtype: int, ws_body: bytes, template_id: bytes, + version: int = 3) -> bytes: + """Wire-exact envelope for new_record type=6, byte-identical to + sub_180036840 in synaWudfBioUsb.dll. + + Layout: + offset size field + ──────────────────────────────────────────────────────────── + 0 2 u16 subtype (e.g. 0x00f7) + 2 2 u16 version (= 3) + 4 2 u16 payload_size (= 4 + ws_size + 4 + tid_size) + 6 2 u16 trailing (= 32) + 8 2 u16 tlv1_tag (= 1) + 10 2 u16 tlv1_len (= ws_size) + 12 n bytes ws_body[n] ← chip-view WS body starts here + 12+n 2 u16 tlv2_tag (= 2) + 14+n 2 u16 tlv2_len (= tid_size) + 16+n 32 bytes template_id + 48+n 32 bytes trailing zeros + + For ws_size = 23056 and tid_size = 32, total envelope is 23136 bytes + with the TID at envelope offset 23072..23104 and the TLV2 header + immediately preceding it at 23068..23072. + + Caller contract: pass the chip-view WS body, NOT the + "envelope[16..23072]" slice. The chip-view WS body is 23056 bytes + that go from envelope offset 12 to 12+23056. In any captured + template it always begins with 4 natural-zero bytes + (template[12..16]) and ends with 4 bytes of feature-data tail + (template[23064..23068]); the TLV2 header that sits at template + offset 23068..23072 is NOT part of ws_body — this function writes + it explicitly. + """ + assert len(template_id) == 32 + ws_size = len(ws_body) + tid_size = len(template_id) + trailing = 32 + payload_size = 4 + ws_size + 4 + tid_size # TLV1 hdr + ws + TLV2 hdr + TID + total = 8 + payload_size + trailing + + buf = bytearray(total) + # Outer header (8 bytes) + buf[0:2] = pack(' bytes: + """Compute the 32-byte TemplateId for a Match-on-Host finger template. + + Recipe verified end-to-end against a Wine-captured enrollment + (enroll-fresh.log lines 1570→1583) and against the stored TID at + envelope offset 23072..23104 of every captured template: + + K = SHA-256(ws_body) + T1 = HMAC-SHA256(K, "Template ID" ‖ 32×0x00) + TID = HMAC-SHA256(K, T1 ‖ "Template ID" ‖ 32×0x00) + + K is derived from the WS body itself, so there is no device-bound + secret involved. Anyone with the WS body can recompute the TID. See + dev/MOH.md "TID derivation". + + Args: + ws_body: 23056-byte chip-view WS body. In a captured envelope + this is the slice template[12:12+23056] — the bytes the + chip parses as the TLV1 payload. Always begins with 4 + natural-zero bytes and ends with feature data tail; does + NOT include the TLV2 header that lives between the WS body + and the TID at envelope offset 23068. + + Returns: + The 32-byte TID, identical to template[23072:23104] for any + valid captured template. + """ + if len(ws_body) != 23056: + raise ValueError(f"ws_body must be 23056 bytes, got {len(ws_body)}") + + K = hashlib.sha256(ws_body).digest() + T1 = hmac.new(K, _TID_INFO, hashlib.sha256).digest() + return hmac.new(K, T1 + _TID_INFO, hashlib.sha256).digest() diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index 0834516..94fe3dd 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -835,153 +835,14 @@ def make_finger_data(self, subtype: int, template: bytes, tid: bytes): return tinfo - def enroll_moh(self, parent_dbid: int, subtype: int, - update_cb: typing.Callable[[typing.Any, typing.Optional[Exception]], None] = lambda *a, **k: None, - max_attempts: int = 6, - num_frames: int = 6): - """Enroll a finger using the byte-exact native pipeline (no DLL). - - Captures `num_frames` placements, builds a 23136-byte template via the - native pipeline (our keypoints into the baked WS-body framing scaffold, - recompute TID), and stores it via the raw 0x47 store protocol: - typ=6 direct, storage=3, 1-byte trailer appended — NOT the - db.new_finger() / type=0xb-becomes-6 magic path which doesn't actually - work without an active 0x68/0x6b enrollment session. - - Args: - parent_dbid: the existing user dbid the new finger attaches to. - Use `db.dump_raw()` to see what users exist. Storing under - a non-existent dbid succeeds at the storage layer BUT the - chip's matcher will silently fail to find the enrollment. - subtype: the WinBio subtype (= finger position) for the record. - update_cb: progress callback update_cb(progress_bytes, error) - matching enroll()'s OS contract (see scripts/prototype.py). - Called after each frame with a 1-byte percentage (0-100), or - (None, exception) on a failed attempt. - max_attempts: how many capture retries on transient errors. - - Returns: the recid created in the chip's storage.""" - import numpy as np - - from .moh_native import (extract_frame_native, _load_ws_scaffold, - NATIVE_WS_V30_REGIONS, - patch_pre_v30_near_identity, - serialize_v30_section, V30_DESC_LEN) - from .moh_extract import compute_tid, _build_envelope - - last_err = None - for attempt in range(max_attempts): - try: - # 1. Capture N frames (default 8; multi-frame enrollment - # fills the WS body's 4 v30 sections with different per- - # frame data). - logging.info(f'enroll_moh: capturing {num_frames} frame(s)...') - per_frame_kps = [] - for f in range(num_frames): - # Per-frame retry: if the sensor errors mid-capture - # (e.g. "Scanning problem: 8080000" — finger lifted too - # early), retry JUST this frame instead of restarting - # the whole enrollment. - for frame_attempt in range(max_attempts): - glow_start_scan() - logging.info(f' frame {f+1}/{num_frames}: place finger') - try: - x, y, w1, w2, img_data = self.capture(CaptureMode.ENROLL) - break - except usb_core.USBError: - glow_end_scan() - raise - except CancelledException: - glow_end_scan() - raise - except Exception as e: - glow_end_scan() - logging.warning(f' frame {f+1} capture failed ' - f'(attempt {frame_attempt+1}/' - f'{max_attempts}): {e}') - if frame_attempt + 1 == max_attempts: - raise - from time import sleep as _sleep - _sleep(0.1) - glow_end_scan() - img = np.frombuffer(img_data, dtype=np.uint8).reshape(x, y) - img_q16 = img.astype(np.int32) << 16 - - logging.info(f' frame {f+1}: extracting features...') - kps = extract_frame_native(img_q16, h=112, w=112) - logging.info(f' frame {f+1}: {len(kps)} kp(s)') - per_frame_kps.append(kps) - # Report percentage complete after each frame is processed, - # via the OS update_cb(progress_bytes, error) contract (see - # scripts/prototype.py) — the percent is a single byte. - # Fires before the store, so a raising callback retries a - # capture (harmless) rather than duplicating a stored record. - update_cb(bytes([int((f + 1) * 100 / num_frames)]), None) - - # 2. Build envelope. Distribute frames across the v30 sections - # (round-robin if num_frames != #sections). The baked scaffold's - # WS framing bytes stay (header, anchors, section counts). - logging.info('enroll_moh: building envelope...') - ws_body = bytearray(_load_ws_scaffold()) - regions = list(NATIVE_WS_V30_REGIONS) - # sec0_pre must be NEAR-identity (load-bearing): the matcher skips - # pure-identity records as the 'unmatched' sentinel, so near-identity - # (tx=ty=1) makes each section a valid candidate alignment at verify. - ws_body = bytearray( - patch_pre_v30_near_identity(bytes(ws_body), regions)[0]) - for idx, base in enumerate(regions): - src_frame = per_frame_kps[idx % len(per_frame_kps)] - # v30 records are [16B desc][x][y]; the record area starts - # V30_DESC_LEN before the (x,y) anchor. The per-section trailer - # is enroll-only bookkeeping the matcher ignores — leave it. - section = serialize_v30_section( - [(gx, gy, desc) for (gx, gy, _o, desc) in src_frame[:250]]) - start = base - V30_DESC_LEN - ws_body[start:start + len(section)] = section - ws_body_bytes = bytes(ws_body) - tid = compute_tid(ws_body_bytes) - envelope = _build_envelope(subtype, ws_body_bytes, tid) - logging.info(f' envelope: {len(envelope)} bytes') - - # 3. Store via the proven replay protocol. No wait_int() - # — the typ=6-direct path doesn't emit an interrupt the - # way db.new_finger's typ=0xb-magic path does. bisect_ws - # send_finger() doesn't wait either, and it works. - logging.info('enroll_moh: storing on chip...') - db.db_info() - write_enable() - try: - msg = (pack(' Date: Sat, 6 Jun 2026 21:10:44 +0200 Subject: [PATCH 10/17] refactor(moh_native): remove unused code Delete dead symbols with zero callers anywhere: ATAN2_FULLSCALE, subpix_refine_kps, tile_origin_yx, build_v30, find_v30_regions (leftover from the retired moh_opencv module), _rotate_sample_pair, and the now-transitively-dead V30_RECORD_LEN constant. Fix the docstrings/ comments that referenced them. Co-Authored-By: Claude Opus 4.8 (1M context) --- validitysensor/moh_native.py | 93 ++---------------------------------- 1 file changed, 5 insertions(+), 88 deletions(-) diff --git a/validitysensor/moh_native.py b/validitysensor/moh_native.py index 5e2819e..5153f9e 100644 --- a/validitysensor/moh_native.py +++ b/validitysensor/moh_native.py @@ -11,7 +11,7 @@ → BRIEF bit-pack (per-kp 128 binary tests) [brief_pack] BYTE-EXACT → orientation (Gaussian-weighted grad histogram) [orient_d920] BYTE-EXACT (60/60) → oriented BRIEF descriptor [descriptor] BYTE-EXACT (2026-05-30) - → [x][y][128-bit desc] × 250 → v30 [build_v30] format known + → [x][y][128-bit desc] × 250 → v30 [serialize_v30_section] format known Validation: each stage is checked against the live captures in $FRIDA_DUMP_DIR (see dev/diff_v30.py). The tiling stage matches `gradin` @@ -106,8 +106,6 @@ def tile_image(img): COS_Q16 = np.round(np.cos(np.deg2rad(np.arange(360))) * 65536).astype(np.int64) SIN_Q16 = np.round(np.sin(np.deg2rad(np.arange(360))) * 65536).astype(np.int64) -ATAN2_FULLSCALE = np.pi * 65536 # 205887.4 — sub_180003150 Q16-radian full scale - def orient_to_index(orient_q16): """Convert kp[+0xc] orient_q16 (Q16 radians, [0, π·65536)) to cos/sin table @@ -348,19 +346,6 @@ def subpix_refine_kp(resp, x_int, y_int, scale_shift=0): return x_q16, y_q16 -def subpix_refine_kps(resp, kps, scale_shift=0): - """sub_18000D5D0 driver — refine all NMS keypoints, CULL failures. - `kps`: list of (score, x_int, y_int) from `nms()`. - Returns: list of (score, x_q16, y_q16). Length ≤ input (failed kps - are removed, exactly like the DLL's sub_18000D570 memmove-down).""" - out = [] - for s, x, y in kps: - r = subpix_refine_kp(resp, x, y, scale_shift) - if r is not None: - out.append((s, r[0], r[1])) - return out - - # NEXT after NMS: orientation (sub_18000D920) + oriented BRIEF (sub_18000E090). @@ -546,13 +531,6 @@ def P(im, kx, ky): # Header = [u16 tag=4][u16 len=4533][8 zeros]. -def tile_origin_yx(i, j, h, w): - """Top-left (row, col) of tile (i, j) in the 3×3 grid. Re-export of - tile_image's internal `tile_origin` formula so callers can use it - independently of the iteration.""" - return tile_origin(i, j, h, w) - - def merge_tile_kps_to_global(per_tile_kps, h, w, margin=3): """Merge per-tile keypoint lists into a single global list, applying the DLL's bound check (margin ≤ global_xy < dim-margin). @@ -587,38 +565,8 @@ def merge_tile_kps_to_global(per_tile_kps, h, w, margin=3): return out -def build_v30(global_kps, max_records=250, tag=4, body_len=4533): - """Build the 18-byte-per-record v30 buffer that goes into a frame - section. Layout matches the captured v30: 12 B header + body_len - payload (~17 B lead-in zeros + N × 18 B records + trailer zeros). - - `global_kps`: list of (gx_int, gy_int, ..., desc_16B) from - merge_tile_kps_to_global. Only x, y, and descriptor are used — - orient/quality stay in the per-kp records elsewhere. - - Returns: bytes object of (12 + body_len) bytes.""" - header = bytes([tag & 0xFF, (tag >> 8) & 0xFF, - body_len & 0xFF, (body_len >> 8) & 0xFF]) + bytes(8) - body = bytearray(body_len) - # 17-byte zero lead-in (matches the captured records — empirical; - # disasm of the v30 packer is pending). - rec_offset = 17 - for kp in global_kps[:max_records]: - gx, gy = kp[0], kp[1] - desc = kp[-1] # last field is the 16-byte descriptor - if not isinstance(desc, (bytes, bytearray)): - desc = bytes(desc) - body[rec_offset] = gx & 0xFF - body[rec_offset + 1] = gy & 0xFF - body[rec_offset + 2:rec_offset + 18] = desc[:16] - rec_offset += 18 - return header + bytes(body) - - # ─── WS-body v30 SECTION serializer ────────────────────────────────────── -# The v30 record area of ONE ws-body section. Distinct from build_v30() -# above, which models the *standalone* per-frame v30 buffer (12-B header + -# 17-B lead-in + body). A ws-body section is a PURE record area: exactly +# The v30 record area of ONE ws-body section: a PURE record area of exactly # n_slots × 18-byte records, no header/lead-in/trailer. # # GROUND-TRUTH CONFIRMED (gdb capture ws_body_1780084103036_23056.bin, @@ -630,31 +578,10 @@ def build_v30(global_kps, max_records=250, tag=4, body_len=4533): V30_SECTION_RECORDS = 250 V30_SECTION_BYTES = V30_SECTION_RECORDS * 18 # 4500 V30_DESC_LEN = 16 # record = [desc:16][x:u8][y:u8]; x,y anchor is +16 into the record -V30_RECORD_LEN = 18 # full v30 record stride WS_SIZE = 23056 # chip-view WS body size DEFAULT_SUBTYPE = 0x00f7 # default WinBio finger subtype -def find_v30_regions(ws, min_run=30): - """Locate each section's v30 record array by detecting long runs of 18-byte - records. Returns the (x,y) ANCHOR offset of each region; the record layout is - [16B desc][x:u8][y:u8], so the record area (first descriptor) starts at - `anchor - V30_DESC_LEN` and the (x,y) bytes are at +16/+17. (Moved from the - retired moh_opencv module.)""" - regions, p, n = [], 0, len(ws) - while p < n - V30_RECORD_LEN * min_run: - good, q = 0, p - while q + 1 < n and 0 < ws[q] <= 112 and ws[q + 1] <= 112: - good += 1 - q += V30_RECORD_LEN - if good >= min_run: - regions.append(p) - p += V30_SECTION_RECORDS * V30_RECORD_LEN - else: - p += 1 - return regions - - def serialize_v30_section(records, n_slots=V30_SECTION_RECORDS): """Serialize one ws-body section's v30 record area. @@ -666,7 +593,7 @@ def serialize_v30_section(records, n_slots=V30_SECTION_RECORDS): FIRST), decoded from the v30 emitter sub_1800057e0 (2026-06-01) and verified: parsing a stored section this way pairs (x,y,desc) 238-248/250 against our single-frame extraction (vs 0/250 for the old [x][y][desc]). - The section's record area starts at (find_v30_regions anchor − 16), so + The section's record area starts at (the v30-region anchor − 16), so callers must write this buffer at `anchor - V30_DESC_LEN`.""" out = bytearray(n_slots * 18) for i, rec in enumerate(records): @@ -721,16 +648,6 @@ def serialize_v30_section(records, n_slots=V30_SECTION_RECORDS): # enrollment with GDB_DUMP_DESC_BRIEF=1 to grab them. -def _rotate_sample_pair(gx, gy, cos_q16, sin_q16): - """E090 inner rotation: takes raw (gx, gy) i32 samples, returns rotated pair. - Bit-exact emulation of the x86 imul/sar sequence at e3cd-e418.""" - gx8 = np.int32(gx) >> 8 - gy8 = np.int32(gy) >> 8 - rgx = (np.int32(cos_q16 * gx8) >> 8) + (np.int32(sin_q16 * gy8) >> 8) - rgy = (np.int32(cos_q16 * gy8) >> 8) - (np.int32(sin_q16 * gx8) >> 8) - return np.int32(rgx), np.int32(rgy) - - def desc_sample_rotate(grad_x, grad_y, subpix_x_q16, subpix_y_q16, orient_idx, N=7): """E090 rotation+sampling (stage 1). Returns two int32 arrays of length @@ -1009,8 +926,8 @@ def extract_frame_native(image_q16, h=112, w=112, # captured reference template needs to be supplied. The real descriptors of # the source template are NOT shipped (zeroed for privacy + clarity). # -# The v30 record areas live at these fixed offsets in the scaffold (from -# find_v30_regions on fresh.bin); each is 250×18 = 4500 bytes. They're pinned +# The v30 record areas live at these fixed offsets in the scaffold (detected +# on fresh.bin); each is 250×18 = 4500 bytes. They're pinned # rather than re-detected because the scaffold's record areas are zeroed (so # the coordinate-run heuristic can't find them). NATIVE_WS_V30_REGIONS = (309, 4913, 9453, 13993) From 048fe7520d36bcaa95b4f74a275df456790aec18 Mon Sep 17 00:00:00 2001 From: Dmitry Muzyka Date: Thu, 11 Jun 2026 10:58:59 +0200 Subject: [PATCH 11/17] refactor(moh_native): native float pipeline (non-byte-exact experiment) Replace the bit-exact x86 fixed-point emulation in the MoH feature pipeline with readable native Python/NumPy float math, to test whether a non-byte-exact template still enrolls and matches on the 06cb:00a2 sensor. - DoH front-end: real exp-Gaussian + central-diff/[1,2,1] kernels, float separable convolution; response rescaled by a fitted RESP_SCALE=17.04 so the existing NMS thresholds (t_lo/t_hi) stay valid. - subpix: numpy.linalg.solve 2x2 Newton step (was Cramer/SAR). - orientation: math.atan2 + plain binning (was fast_atan2 magic polys). - descriptor: float cos/sin rotation + plain window sums. - Dropped: _s32/_sar32/_imul32/_idiv32, EXP_TABLE, COS_Q16/SIN_Q16, gauss_tap, build_3tap, the atan2/angle-bin magic, and ~430 lines of dead RE scaffold (Minutia/FrameContext/orchestrate/EnrollmentSession/stage_*). - Byte-format/framing (serialize_v30_section, merge, scaffold, TID, envelope) and all public signatures are unchanged. 1659 -> 671 lines. Verified: imports, end-to-end extract_frame_native + native_template (23136B envelope, TID round-trips). Calibration in dev/calib_resp_scale.py; design in docs/superpowers/specs/. Co-Authored-By: Claude Fable 5 --- ...6-06-11-moh-native-float-rewrite-design.md | 76 + validitysensor/moh_native.py | 1627 ++++------------- 2 files changed, 396 insertions(+), 1307 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-11-moh-native-float-rewrite-design.md diff --git a/docs/superpowers/specs/2026-06-11-moh-native-float-rewrite-design.md b/docs/superpowers/specs/2026-06-11-moh-native-float-rewrite-design.md new file mode 100644 index 0000000..7c8e391 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-moh-native-float-rewrite-design.md @@ -0,0 +1,76 @@ +# MoH native pipeline — float-math rewrite (experiment) + +**Date:** 2026-06-11 +**Branch:** `moh-native-float-experiment` (off `moh-native-remove-unused`) +**Goal:** Replace the bit-exact x86 fixed-point emulation in +`validitysensor/moh_native.py` with readable native Python/NumPy float math, +to empirically test whether a **non-byte-exact** template still enrolls and +matches on the 06cb:00a2 sensor. + +## Motivation + +`moh_native.py` reproduces the Windows DLL's image→v30 feature pipeline +byte-for-byte. That fidelity is carried by ~hundreds of lines of x86 +emulation: the `_s32`/`_sar32`/`_imul32`/`_idiv32` 32-bit-truncation +wrappers, magic-constant `atan2`/`exp`/division polynomials, and Q-format +shift juggling. The hypothesis under test: the on-chip matcher (Hough +geometric voting, relative-argmax, **no fixed threshold** — see memory +`moh_matcher`) tolerates small numeric drift, so a clean float pipeline +that is *algorithmically* the same will still match. + +## Scope + +### Unchanged (byte-format / data — NOT asm) +`compute_tid`, `_build_envelope`, `serialize_v30_section`, +`merge_tile_kps_to_global`, `patch_pre_v30_near_identity`, +`_load_ws_scaffold`, `native_template`, `extract_frame_native` +orchestration, tiling (`tile_image`/`tile_origin`/`tile_size`), and the data +tables `GAUSS_Q`, `AGGR_TABLE`/`BRIEF_TABLE` (from `blobs_a2`). + +Public symbols imported by production code keep their signatures: +`extract_frame_native`, `_load_ws_scaffold`, `NATIVE_WS_V30_REGIONS`, +`patch_pre_v30_near_identity`, `serialize_v30_section`, `V30_DESC_LEN`, +`compute_tid`, `_build_envelope`, `native_template`. + +### Rewritten to native float math +| Stage | From | To | +|---|---|---| +| Gaussian kernel | `gauss_tap`+`EXP_TABLE`, `build_gaussian` shift-magic | real `exp(-x²/2σ²)`, unity-normalized | +| 3-tap deriv/smooth | `build_3tap` magic `0xd55`/`0x2aaa` | `[1,0,-1]` central diff, `[1,2,1]/4` smooth | +| Separable conv | `_conv_axis` per-tap `>>shift` | float NumPy convolution | +| DoH response | `>>12` Q-juggling | float `Ixx·Iyy − Ixy²`, scaled by `RESP_SCALE` | +| Subpix | `_solve_2x2_d4c0` Cramer/SAR | `numpy.linalg.solve` 2×2 | +| Orientation | `_fast_atan2`/`_precise_atan2`/`_angle_to_bin` | `math.atan2`, `bin = angle·42/2π` | +| Descriptor | `_s32` Q16 cos/sin, NEG-then-SAR | float `cos/sin`, plain sums | +| Helpers/tables | `_s32/_sar32/_imul32/_idiv32`, `EXP_TABLE`, `COS_Q16/SIN_Q16` | deleted | + +### Deleted (dead RE scaffold, not imported anywhere) +`Minutia`, `FrameContext`, `orchestrate`, `extract_features`, +`EnrollmentSession`, the `stage_*` `NotImplementedError` stubs, +`sub_180003460`/`sub_18000A8E0`/`sub_18000A910`/`sub_180001010`/ +`sub_1800031E0`/`sub_1800032C0`/`sub_180009F50`/`sub_18000E6B0`, +`BRIEF_SEED_TABLE`, the qsort `cmp_*` helpers. Keep only `compute_tid` and +`_build_envelope` from that block. + +## Response-scale preservation + +NMS uses **absolute** thresholds (`t_lo=671`, `t_hi=168`) tuned to the +fixed-point response magnitude. Tracing the original gains analytically: +`resp_original ≈ 16 · (Ixx·Iyy − Ixy²)` of the doubly-smoothed image in +natural units. Rather than re-derive every per-pass gain by hand, we +**calibrate one constant** `RESP_SCALE`: compute the native response and the +original response on the same synthetic tiles and set `RESP_SCALE` so their +magnitudes match (median + max). The constant is then frozen and `t_lo`/`t_hi` +are kept. If hardware shows a very different keypoint count, `t_lo`/`t_hi` +become the tuning knob. + +## Validation (no hardware required for these) +1. Calibration: native vs original `doh()` response stats agree within a few + percent after `RESP_SCALE` (one-off script using `git show` of the + original). +2. Import smoke test of `validitysensor.moh_native` + `moh_enrollment`. +3. End-to-end `extract_frame_native` and `native_template` run on a synthetic + 112×112 frame; envelope is 23136 bytes; TID recomputes. + +Hardware enroll/match (`enroll_moh_chip.py --match`) is the actual experiment +the user runs. diff --git a/validitysensor/moh_native.py b/validitysensor/moh_native.py index 35a77ea..7885a8c 100644 --- a/validitysensor/moh_native.py +++ b/validitysensor/moh_native.py @@ -1,83 +1,61 @@ -"""Native MoH feature pipeline (06cb:00a2) — reproduction of the DLL's -image → v30 path, decoded in dev/DLL-RE.md and dev/MOH.md. +"""Native MoH feature pipeline (06cb:00a2) — image → v30 path. -Pipeline (all stages classical CV; no proprietary enhancement): +This is the **float / native-Python** variant of the pipeline. It is the same +algorithm the Windows DLL runs (decoded in dev/DLL-RE.md and dev/MOH.md), but +the bit-exact x86 fixed-point emulation has been replaced with ordinary +floating-point math: working image (112²) - → 3×3 grid of 57×57 tiles (mid-gray pad) [tile_image] DONE - → per tile: Q12 Determinant-of-Hessian → Ixx/Iyy/Ixy/resp [doh] BYTE-EXACT (interior) - → 8-neighbour NMS → keypoints [nms] BYTE-EXACT - → subpix refine (Hessian-Newton, cull failures) [subpix_refine] BYTE-EXACT (NEW) - → BRIEF bit-pack (per-kp 128 binary tests) [brief_pack] BYTE-EXACT - → orientation (Gaussian-weighted grad histogram) [orient_d920] BYTE-EXACT (60/60) - → oriented BRIEF descriptor [descriptor] BYTE-EXACT (2026-05-30) - → [x][y][128-bit desc] × 250 → v30 [serialize_v30_section] format known - -Validation: each stage is checked against the live captures in -$FRIDA_DUMP_DIR (see dev/diff_v30.py). The tiling stage matches `gradin` -byte-exact (corr 1.000). - -DoH detector chain — FULLY DECODED (disasm, see dev/DLL-RE.md "Gradient -kernel chain"). No remaining unknowns; what's left is the bit-exact port: - - gradin (57x57 Q10) - img >>= 6 (Q10 -> Q4) [sub_18000F250] - Gaussian pre-smooth (separable), SHIFT 12 sigma ~ scale [sub_180010050] - img <<= 6 (Q4 -> Q10) - build 3 Hessian planes (separable), SHIFT 10 [sub_18000CC20] - per-axis 3-tap kernel (sub_180010280): - smoothing : [c, c*0xd55>>10, c] (~[1, 3.33, 1]) - derivative: [1024, 0, -1024] (central diff [1,0,-1] Q10) - Ixx, Iyy = deriv^2 . smooth ; Ixy = deriv_x . deriv_y - normalize by scale / scale^2 - resp = (Ixx>>12)*(Iyy>>12) - (Ixy>>12)**2 [sub_18000CE80] - 8-neighbour NMS + threshold + distance-dedup -> keypoints [sub_18000CF90] - -Every separable pass accumulates (pixel*tap)>>SHIFT per-term (the -truncation is why Ixy never recovered as one linear kernel). Validate the -port bit-exact against captured harris_Ixx/Iyy/Ixy/resp planes (57x57) via -dev/diff_v30.py compare_harris BEFORE chaining downstream. - -This module also contains the host-side template serializer / TID derivation -and the (mostly RE-scaffold) CEohMohEIV port that previously lived in -moh_extract.py — see the "Host-side feature-extraction pipeline" section at -the bottom of this file. + → 3×3 grid of 57×57 tiles (mid-gray pad) [tile_image] + → per tile: Determinant-of-Hessian response [doh] + → 8-neighbour NMS → keypoints [nms] + → subpixel refine (Hessian-Newton, cull failures) [subpix_refine_kp] + → orientation (Gaussian-weighted grad histogram) [orient_d920] + → oriented BRIEF descriptor [_descriptor_at] + → [16-byte desc][x][y] × 250 → v30 [serialize_v30_section] + +NOTE: the math here is intentionally NOT byte-exact with the DLL. The chip-side +matcher is a Hough geometric-voting / relative-argmax scheme with no fixed +threshold (see dev/SCORER-sub_18000c6a0.md), so small numeric drift in the +descriptor pipeline is expected to be tolerable. This module exists to test +that hypothesis on hardware. + +The byte-format / framing functions at the bottom (serialize_v30_section, +merge, scaffold, TID, envelope) are unchanged from the byte-exact build — they +are pure format/data, not arithmetic, and the chip parses them literally. """ from __future__ import annotations import hashlib import hmac import logging -from dataclasses import dataclass, field -from struct import pack, unpack -from typing import List, Optional, Tuple +import math +from struct import pack import numpy as np log = logging.getLogger(__name__) +TWO_PI = 2.0 * math.pi + # ─── geometry (decoded from orchestrator sub_18000AAB0) ───────────────── GRID = 3 # 3×3 tiles GRID_X = 10 # overlap half-width (v13 = 2*GRID_X = 20 total) -FILL = 0x800000 # Q16 mid-gray pad (= 128 << 16 = 8388608). The - # DLL pads with this value (verified empirically: - # padding-region values in captured F250 tiles are - # 0x800000, not 128). Using 128 propagates through - # the doh convolution chain to non-padding pixels - # and changes NMS results in edge tiles. +FILL = 0x800000 # Q16 mid-gray pad (= 128 << 16). Tiles carry the + # image in Q16; padding outside the frame is mid-gray. + # Every consumer divides by 65536, so the pad becomes + # the natural value 128.0. def tile_origin(i, j, h, w): - """Top-left (row, col) of tile (i,j). step = h/GRID; origin = i*step - GRID_X. - Verified against captures: tile(0,0)@(-10,-10), tile(0,1)@(-10,27).""" + """Top-left (row, col) of tile (i,j). step = h/GRID; origin = i*step - GRID_X.""" return i * (h // GRID) - GRID_X, j * (w // GRID) - GRID_X def tile_size(i, j, h, w): """Per-tile (height, width). The last row/col absorbs the dim-vs-GRID - remainder, so for the 112-px frame the col-2 and row-2 tiles are 58 - (= 38 step + 2*GRID_X) while inner tiles are 57 (= 37 step + 2*GRID_X). - Matches captured F250 raw_tile sizes (57x57 / 58x57 / 57x58 / 58x58).""" + remainder, so for the 112-px frame the col-2 and row-2 tiles are 58 while + inner tiles are 57.""" step_y = h // GRID step_x = w // GRID th = (h - (GRID - 1) * step_y) + 2 * GRID_X if i == GRID - 1 else step_y + 2 * GRID_X @@ -86,11 +64,8 @@ def tile_size(i, j, h, w): def tile_image(img): - """Yield (i, j, tile) for the 3×3 grid. Tile size is (57|58)x(57|58) - depending on position (last row/col absorbs the 112-3*37=1 remainder), - mid-gray padded where it falls outside the image. Matches the DLL's - sub_18000A850 blit + sub_180009F50 pad (F250 raw_tile captures sized - 57x57 / 58x57 / 57x58 / 58x58 for the 9 tiles).""" + """Yield (i, j, tile) for the 3×3 grid, mid-gray padded where it falls + outside the image (matches the DLL's sub_18000A850 blit + pad).""" h, w = img.shape for i in range(GRID): for j in range(GRID): @@ -105,7 +80,8 @@ def tile_image(img): # ─── orientation weighting (dword_180120C00, dumped from the DLL) ──────── -# 7×7 quarter of a 13×13 window; weight[dy][dx] = GAUSS_Q[|dy|][|dx|]. +# 7×7 quarter of a 13×13 window; weight[dy][dx] = GAUSS_Q[|dy|][|dx|]. Kept as +# data (it is the DLL's learned spatial weighting, not arithmetic). GAUSS_Q = np.array([ [1669, 1541, 1212, 812, 464, 226, 94], [1541, 1422, 1119, 750, 428, 208, 86], @@ -114,177 +90,101 @@ def tile_image(img): [ 464, 428, 337, 226, 129, 63, 26], [ 226, 208, 164, 110, 63, 31, 13], [ 94, 86, 68, 46, 26, 13, 0], -], dtype=np.int64) - -# cos/sin tables are round(cos/sin(deg) * 65536), idx 0..359 (FULL circle — -# E090 uses directed orient [0, 2π), NOT ridge-mod-π). Verified byte-exact -# against the DLL .rdata tables at 0x180131050 (cos) and 0x1801315F0 (sin). -COS_Q16 = np.round(np.cos(np.deg2rad(np.arange(360))) * 65536).astype(np.int64) -SIN_Q16 = np.round(np.sin(np.deg2rad(np.arange(360))) * 65536).astype(np.int64) - - -def orient_to_index(orient_q16): - """Convert kp[+0xc] orient_q16 (Q16 radians, [0, π·65536)) to cos/sin table - index in [0, 180]. Exact formula from E090 at e0c4/e0ec/e0fe: - index = (int) (orient_q16 · 180 / (π · 65536)) - (cvttsd2si = trunc toward zero, but orient_q16 >= 0).""" - return int(orient_q16 * 180.0 / (np.pi * 65536.0)) - - -# ─── exp lookup table unk_180130F80 (dumped from the DLL .rdata) ───────── -# 52 entries, table[i] = round(65536 * exp(-0.19531 * i)), table[51] = 0. -# Used by sub_18000FEC0 to evaluate Gaussian taps (idx = quantized -x²/2σ²). -EXP_TABLE = np.array([ - 65536, 53908, 44344, 36476, 30005, 24681, 20302, 16700, 13737, 11300, - 9295, 7646, 6289, 5173, 4256, 3501, 2879, 2369, 1948, 1603, - 1318, 1084, 892, 734, 604, 496, 408, 336, 276, 227, - 187, 154, 127, 104, 86, 70, 58, 48, 39, 32, - 27, 22, 18, 15, 12, 10, 8, 7, 6, 5, - 4, 0, -], dtype=np.int64) - - -# ─── 32-bit fixed-point helpers (match x86 imul/sar/idiv semantics) ────── -_M32 = (1 << 32) -def _s32(x): - x &= _M32 - 1 - return x - _M32 if x & 0x80000000 else x -def _sar32(x, n): - return _s32(_s32(x) >> n) -def _idiv32(a, b): - a, b = _s32(a), _s32(b) - q = abs(a) // abs(b) - return -q if (a < 0) ^ (b < 0) else q -def _imul32(a, b): - """x86 IMUL r32, r32: 32-bit signed multiply, result truncated to i32.""" - p = (_s32(a) * _s32(b)) & (_M32 - 1) - return p - _M32 if p & 0x80000000 else p - - -# ─── kernel builders (byte-exact vs the DLL; see dev/port_gradient.py) ──── -def gauss_tap(coef, x): - """sub_18000FEC0: one Gaussian tap = EXP_TABLE[|quantized -coef·x²|].""" - t = _sar32(_s32(coef * x), 2) - t = _sar32(_s32(t * x), 8) - q = (_s32(t) * 0x51eb851f) >> 35 - if q < 0: - q += 1 - i = -(q >> 13) - return int(EXP_TABLE[min(max(i, 0), len(EXP_TABLE) - 1)]) - - -def build_gaussian(n): - """sub_18000FF00: normalized 1D Gaussian (size n) → [(offset, tap)], Q12.""" - sigma = _sar32(_s32(0x26600 * n + 0x59acd), 10) - coef = _idiv32(0xe0000000, _s32(sigma * sigma)) - taps, s = [], 0 - for i in range(n): - t = _sar32(gauss_tap(coef, 512 * (2 * i - n + 1)), 4) - taps.append(t); s += t - norm = _sar32(_idiv32(0x40000000, s), 3) - half = n // 2 - return [(i - half, _sar32(_s32(t * norm), 15)) for i, t in enumerate(taps)] +], dtype=np.float64) -def build_3tap(scale, deriv): - """sub_180010280: sparse 3-point kernel at offsets ±scale. - deriv → [1024,0,-1024]; smooth → [c, round(c·3.33), c], c=2^20/(scale·0x2aaa). - For scale 1: smooth = [96,320,96] (sum 512).""" - if deriv: - return [(-scale, 1024), (0, 0), (scale, -1024)] - c = _idiv32(0x100000, _s32(scale * 0x2aaa)) - mid = _sar32(_s32(c * 0xd55) + (1 << 9), 10) - return [(-scale, c), (0, mid), (scale, c)] +# ─── separable convolution kernels (native float) ────────────────────── +# DERIV_3TAP: central difference; SMOOTH_3TAP: unity [1,2,1]/4 smoother. These +# replace the DLL's sub_180010280 magic-constant 3-tap builder. +DERIV_3TAP = [(-1, 1.0), (0, 0.0), (1, -1.0)] +SMOOTH_3TAP = [(-1, 0.25), (0, 0.5), (1, 0.25)] -# ─── separable apply: per-tap (pixel·tap)>>shift, convolution, with optional -# constant-fill border (matches the DLL's out-of-bounds = mid-gray reads in -# the pre-smooth + descriptor-gradient pipeline). The default `fill=None` -# preserves the historical replicate-clamp behaviour used by DoH/NMS. -def _conv_axis(img, kernel, shift, axis, fill=None): +def build_gaussian(n=5): + """Unity-normalized 1-D Gaussian as [(offset, tap)]. sigma comes from the + DLL's size→sigma relation (≈1.10 px for n=5); the bit-exact EXP_TABLE LUT + is replaced by a real exp().""" + sigma = (0x26600 * n + 0x59acd) / float(1 << 20) # ≈ 1.102 for n = 5 + half = n // 2 + xs = np.arange(n) - half + g = np.exp(-(xs ** 2) / (2.0 * sigma * sigma)) + g = g / g.sum() + return [(i - half, float(g[i])) for i in range(n)] + + +def _conv_axis(img, kernel, axis, fill=None): + """Separable 1-D convolution along `axis`. `kernel` = [(offset, tap), …]. + fill=None → replicate-clamp borders; fill=v → out-of-bounds reads = v + (the DLL fills off-tile reads with 0 in the descriptor-gradient path).""" n = img.shape[axis] idx = np.arange(n) - acc = np.zeros(img.shape, dtype=np.int64) + acc = np.zeros(img.shape, dtype=np.float64) for off, tap in kernel: if tap == 0: continue src_idx = idx - off - if fill is None: - src = np.clip(src_idx, 0, n - 1) - vals = np.take(img, src, axis=axis).astype(np.int64) - else: + clipped = np.clip(src_idx, 0, n - 1) + vals = np.take(img, clipped, axis=axis).astype(np.float64) + if fill is not None: in_bounds = (src_idx >= 0) & (src_idx < n) - clipped = np.clip(src_idx, 0, n - 1) - vals = np.take(img, clipped, axis=axis).astype(np.int64) shape = [1] * img.ndim shape[axis] = -1 - mask = in_bounds.reshape(shape) - vals = np.where(mask, vals, np.int64(fill)) - acc += (vals * tap) >> shift + vals = np.where(in_bounds.reshape(shape), vals, float(fill)) + acc += vals * tap return acc -def apply_sep(img, kx, ky, shift, fill=None): - return _conv_axis(_conv_axis(img, kx, shift, 1, fill=fill), - ky, shift, 0, fill=fill) +def apply_sep(img, kx, ky, fill=None): + """Apply kx along x (axis 1) then ky along y (axis 0).""" + return _conv_axis(_conv_axis(img, kx, 1, fill=fill), ky, 0, fill=fill) + + +# ─── DoH detector ─────────────────────────────────────────────────────── +# response = Ixx·Iyy − Ixy² of the pre-smoothed image, in natural pixel units, +# rescaled by RESP_SCALE so the NMS thresholds (t_lo/t_hi) — which were tuned +# to the DLL's fixed-point response magnitude — still apply. RESP_SCALE was +# fit by least-squares against the byte-exact doh() on synthetic tiles +# (median 17.04, std/mean 2.4%); see dev/calib_resp_scale.py. +RESP_SCALE = 17.04 -# ─── DoH front-end — BYTE-EXACT vs gradin/g380 captures (interior) ──────── -def presmooth(tile, size=5): - """sub_18000F250 → sub_1800101C0: separable Gaussian (shift 12) of the Q10 - tile, then <<6. == CC20 input (g380 call1_before), 0 mismatch border 2.""" +def presmooth(tile_q16, size=5): + """Gaussian pre-smooth of a Q16 tile → natural-units float image + (replicate-clamp borders), matching the DoH front-end.""" + img = np.asarray(tile_q16, dtype=np.float64) / 65536.0 gk = build_gaussian(size) - return (apply_sep(tile.astype(np.int64), gk, gk, 12)) << 6 - - -def cc20_planes(smoothed, v9=1): - """sub_18000CC20: Ixx/Iyy/Ixy from the pre-smoothed tile. Each - sub_180010380 pass = (>>6, separable kx·ky shift10, <<6).""" - dk = build_3tap(v9, True); sk = build_3tap(v9, False) - P = lambda im, kx, ky: (apply_sep(im >> 6, kx, ky, 10)) << 6 - buf20 = smoothed.copy() - buf28 = P(buf20, sk, dk) # prep1 → Dy - buf20 = P(buf20, dk, sk) # prep2 → Dx - buf20 = buf20 * v9; buf28 = buf28 * v9 # norm1 ·v9 - ixy = P(buf20, sk, dk); ixx = P(buf20, dk, sk); iyy = P(buf28, sk, dk) - v10 = v9 * v9 - return ixx * v10, iyy * v10, ixy * v10 # norm2 ·v9² - - -def doh(tile, size=5, v9=1): - """gradin tile (Q10) → (Ixx, Iyy, Ixy, response). Byte-exact (interior). - response = (Ixx>>12)·(Iyy>>12) − (Ixy>>12)² (sub_18000CE80).""" - ixx, iyy, ixy = cc20_planes(presmooth(tile, size), v9) - resp = (ixx >> 12) * (iyy >> 12) - (ixy >> 12) ** 2 - return ixx, iyy, ixy, resp - - -# ─── keypoints — NMS (sub_18000CF90) — BYTE-EXACT ✅ ───────────────────── -# Validated set-, count-, AND order-exact vs nms_* captures across all 12 tiles -# (dev/port_gradient.py-style harness; t_lo=671, t_hi=168, dedup_q=72064, -# margin=10 for this hardware). Captured 32-byte CF90 record layout (i32): -# [0, 0, 0, 0, abs(resp), x, y, 0] — fields 0-3 + 7 are zero out of CF90; -# upstream code fills active/tile/global-coords/quality fields later. + return apply_sep(img, gk, gk) + + +def doh(tile_q16, size=5): + """Determinant-of-Hessian on a Q16 tile → (Ixx, Iyy, Ixy, response). + response = (Ixx·Iyy − Ixy²)·RESP_SCALE.""" + sm = presmooth(tile_q16, size) + Dx = apply_sep(sm, DERIV_3TAP, SMOOTH_3TAP) + Dy = apply_sep(sm, SMOOTH_3TAP, DERIV_3TAP) + Ixx = apply_sep(Dx, DERIV_3TAP, SMOOTH_3TAP) + Iyy = apply_sep(Dy, SMOOTH_3TAP, DERIV_3TAP) + Ixy = apply_sep(Dx, SMOOTH_3TAP, DERIV_3TAP) + resp = (Ixx * Iyy - Ixy ** 2) * RESP_SCALE + return Ixx, Iyy, Ixy, resp + + +# ─── keypoints — NMS (sub_18000CF90) ───────────────────────────────────── def nms(resp, t_lo=671, t_hi=168, dedup_q=72064, margin=10): - """8-neighbour NMS on the response map (sub_18000CF90). A pixel is a - keypoint iff `resp > t_lo`, `resp >= t_hi`, and strictly greater than all - 8 neighbours; score = |resp|. Dedup radius² = ((dedup_q>>6)²)>>20 — with - the captured dedup_q=72064 this is 1 (i.e. effectively a no-op given the - strict 8-nbr max already excludes adjacent equals). - - Args from the ctx struct: t_lo=ctx[+0x20], t_hi=ctx[+0x24], - dedup_q=ctx[+0x48]; margin from CF90's r9d arg (=10 for this hardware). - Returns a list of `(score, x, y)` in raster-scan order (y outer).""" + """8-neighbour non-maximum suppression on the response map. A pixel is a + keypoint iff `resp > t_lo`, `resp >= t_hi`, and strictly greater than all 8 + neighbours; score = |resp|. Returns `(score, x, y)` in raster-scan order.""" h, w = resp.shape r2 = ((dedup_q >> 6) ** 2) >> 20 kps = [] # (score, x, y) for y in range(margin, h - margin): for x in range(margin, w - margin): - v = int(resp[y, x]) + v = float(resp[y, x]) if v <= t_lo or v < t_hi: continue - nb = resp[y-1:y+2, x-1:x+2] - if v <= nb.max() and not (v == nb.max() and (nb == v).sum() == 1): + nb = resp[y - 1:y + 2, x - 1:x + 2] + nbmax = float(nb.max()) + if v <= nbmax and not (v == nbmax and int((nb == v).sum()) == 1): continue s = abs(v) dup = next((i for i, (_, kx, ky) in enumerate(kps) @@ -296,284 +196,187 @@ def nms(resp, t_lo=671, t_hi=168, dedup_q=72064, margin=10): return kps -# ─── subpix refinement — sub_18000D5D0 + sub_18000D4C0 (byte-exact port) ─ -# After NMS, each (integer) keypoint goes through a Hessian-Newton subpixel -# refinement on the response map. 9 resp values around (x,y) build a -# symmetric Hessian + gradient (with specific SAR shifts), a 2×2 Cramer -# solver finds the apex offset (Δx, Δy) in 1/128-pixel units, and the result -# is written back as Q16 — OR the keypoint is REMOVED entirely if the system -# is singular or |Δ| > 1 pixel (D5D0 calls sub_18000D570 memmove-down). -# -# Math (each shift mirrors a specific instruction in D5D0): -# dxx = (L + R - 2C) >> 2 [d6ee] -# dyy = (T + B - 2C) >> 2 [d6dd] -# dxy = (((BR+TL)>>2) - ((BL+TR)>>2)) >> 2 [d6d7..d6f6, two-stage] -# dx_neg = -((R - L) >> 1) >> 2 [d6b6,d6f9,d710] -# dy_neg = -((B - T) >> 1) >> 2 [d6da,d6e4,d6f2] -# D4C0 (Cramer): a,b,c,d,e,f are coeffs >>4'd, det = (a*d-b*c)>>7, then -# Δx = (d*e - f*c) // det ; Δy = (b*e - f*a) // (-det) -# All arithmetic is signed 32-bit truncating (idiv = trunc-toward-zero). -def _solve_2x2_d4c0(coeffs): - """sub_18000D4C0 — Cramer 2×2 solver. Returns (Δx, Δy) or None on - singular Hessian. `coeffs` = [a, b, c, d, e, f] (i32 each).""" - a, b, c, d, e, f = [_sar32(v, 4) for v in coeffs] - det_pos = _sar32(_imul32(a, d) - _imul32(b, c), 7) - det_neg = _sar32(_imul32(b, c) - _imul32(a, d), 7) - if det_pos == 0 or det_neg == 0: - return None - num_x = _imul32(d, e) - _imul32(f, c) - num_y = _imul32(b, e) - _imul32(f, a) - return _idiv32(num_x, det_pos), _idiv32(num_y, det_neg) - - -def subpix_refine_kp(resp, x_int, y_int, scale_shift=0): - """sub_18000D5D0 — subpixel refine a single integer keypoint. - Returns (x_q16, y_q16) or None if the kp should be removed. - `scale_shift` = ctx[+0x50]+0x60 (typically 0 on 06cb:00a2).""" +# ─── subpixel refinement — Hessian-Newton on the response map ──────────── +# Replaces the DLL's bit-exact Cramer/SAR solver (sub_18000D4C0 / D5D0) with a +# standard 2×2 Newton step. The keypoint is dropped if the Hessian is singular +# or the offset exceeds 1 pixel. Coordinates are returned in Q16 — the +# representation the merge / orientation / descriptor stages expect. +def subpix_refine_kp(resp, x_int, y_int): + """Refine an integer keypoint to subpixel. Returns (x_q16, y_q16) or None + if the keypoint should be removed.""" h, w = resp.shape if not (1 <= x_int <= w - 2 and 1 <= y_int <= h - 2): return None - cV = _s32(int(resp[y_int, x_int])) - L = _s32(int(resp[y_int, x_int - 1])) - R = _s32(int(resp[y_int, x_int + 1])) - T = _s32(int(resp[y_int - 1, x_int])) - B = _s32(int(resp[y_int + 1, x_int])) - TL = _s32(int(resp[y_int - 1, x_int - 1])) - TR = _s32(int(resp[y_int - 1, x_int + 1])) - BL = _s32(int(resp[y_int + 1, x_int - 1])) - BR = _s32(int(resp[y_int + 1, x_int + 1])) - dxx = _sar32(_s32(L + R - 2 * cV), 2) - dyy = _sar32(_s32(T + B - 2 * cV), 2) - dxy = _sar32( - _s32(_sar32(_s32(BR + TL), 2) - _sar32(_s32(BL + TR), 2)), - 2, - ) - dx_neg = _sar32(_s32(-_sar32(_s32(R - L), 1)), 2) - dy_neg = _sar32(_s32(-_sar32(_s32(B - T), 1)), 2) - res = _solve_2x2_d4c0([dxx, dxy, dxy, dyy, dx_neg, dy_neg]) - if res is None: + C = float(resp[y_int, x_int]) + L = float(resp[y_int, x_int - 1]) + R = float(resp[y_int, x_int + 1]) + T = float(resp[y_int - 1, x_int]) + B = float(resp[y_int + 1, x_int]) + TL = float(resp[y_int - 1, x_int - 1]) + TR = float(resp[y_int - 1, x_int + 1]) + BL = float(resp[y_int + 1, x_int - 1]) + BR = float(resp[y_int + 1, x_int + 1]) + dxx = L + R - 2.0 * C + dyy = T + B - 2.0 * C + dxy = (BR - BL - TR + TL) / 4.0 + gx = (R - L) / 2.0 + gy = (B - T) / 2.0 + det = dxx * dyy - dxy * dxy + if det == 0.0: + return None + H = np.array([[dxx, dxy], [dxy, dyy]], dtype=np.float64) + try: + dx, dy = np.linalg.solve(H, np.array([-gx, -gy], dtype=np.float64)) + except np.linalg.LinAlgError: return None - dx, dy = res - if not (-0x80 <= dx <= 0x80 and -0x80 <= dy <= 0x80): + if abs(dx) > 1.0 or abs(dy) > 1.0: return None - scale_mul = 1 << scale_shift - x_q16 = (((x_int << 7) + dx) << 9) * scale_mul - y_q16 = (((y_int << 7) + dy) << 9) * scale_mul + x_q16 = int(round((x_int + dx) * 65536.0)) + y_q16 = int(round((y_int + dy) * 65536.0)) return x_q16, y_q16 -# NEXT after NMS: orientation (sub_18000D920) + oriented BRIEF (sub_18000E090). - - -# ─── D920 orientation — BYTE-EXACT (per-keypoint dominant gradient angle) ─ -# sub_18000D920(rcx=kp_ptr, rdx=ctx, r8=scratch) writes kp[+0xa]=quality byte -# and kp[+0xc]=orient_q16 (i32, [0, 2π·~) at 0x6487E ≈ 2π·65536 scale). -# -# Algorithm (per disasm 0x18000D920..DF13): -# 1. Sample 13×13 patch centred on the ROUNDED subpix coord — `cx = (sx + -# 0x8000) >> 16`. (Truncating cy=sy>>16 gives off-by-one for fractional -# subpix.) Apply a circular mask: keep pixels with dx²+dy² < 36. -# For each in-circle pixel: -# ggx = ((gradX[y,x] >> 10) * GAUSS_Q[|dy|,|dx|]) >> 4 -# ggy = ((gradY[y,x] >> 10) * GAUSS_Q[|dy|,|dx|]) >> 4 -# angle = fast_atan2(ggy, ggx) (sub_1800030A0) -# bin = trunc-toward-zero(angle / 9830) (signed-div magic constant -# 0x6AAAAABD, shift 56-12) -# Smear: each pixel votes its (ggx, ggy) into bins (bin-6, bin-5, ..., -# bin) — 7 bins to the LEFT of (and including) the primary bin. -# 2. Accumulate two 42-bin histograms H_gx[bin] and H_gy[bin]. -# 3. Find max bin by (H_gx>>13)² + (H_gy>>13)². -# 4. Refine: orient_q16 = precise_atan2(H_gx[max]>>10, H_gy[max]>>10) -# (sub_180003150, a thin 4-quadrant wrapper around fast_atan2). -# -# Validated 1000/1024 byte-exact against orient_after kp[+0xc] (the 24 -# unmatched are tiles past F250_MAX with no captured raw tile — algorithm -# matches every keypoint that has a derived gradient). - - -def _fast_atan2(gy_in, gx_in): - """sub_1800030A0: fixed-point atan2 returning angle in [0, ~408960] - (full-circle scale ~2π·65086). Args ordered as (y, x). Strict <0 - sign branches — `jns` jumps if non-negative, NOT if ≤0.""" - r10d = _s32(gx_in); r11d = _s32(gy_in) - r8d = r10d if r10d > 0 else _s32(-r10d) - r9d = r11d if r11d > 0 else _s32(-r11d) - if r8d < r9d: eax = r8d; ecx = r9d - else: eax = r9d; ecx = r8d - ecx = _s32(ecx + 1) - eax = _s32(eax << 8) - if ecx == 0: - return 0 - q = abs(eax) // abs(ecx); sgn = (eax < 0) ^ (ecx < 0) - eax = _s32(-q if sgn else q) - eax = _s32(eax << 8); ecx_tan = eax - eax = _sar32(eax, 4); ecx = _sar32(ecx_tan, 6) - ecx = _imul32(ecx, ecx); ecx = _sar32(ecx, 4); ecx = _sar32(ecx, 4) - edx = _imul32(ecx, 0xFFFFF5D7); edx = _sar32(edx, 12); edx = _s32(edx + 0x23A7) - edx = _imul32(edx, ecx); edx = _sar32(edx, 12); edx = _s32(edx - 0x4AAC) - edx = _imul32(edx, ecx); edx = _sar32(edx, 12); edx = _s32(edx + 0xE522) - edx = _imul32(edx, eax); edx = _sar32(edx, 6) - if r8d < r9d: edx = _s32(0x5A0000 - edx) - if r10d < 0: edx = _s32(0xB40000 - edx) - if r11d < 0: edx = _s32(0x1680000 - edx) - edx = _sar32(edx, 8); edx = _imul32(edx, 0x47); edx = _sar32(edx, 4) - return _s32(edx) - - -def _precise_atan2(gx, gy): - """sub_180003150(rcx=gx, rdx=gy): 4-quadrant atan2 in [0, 2π·65536). - Wraps _fast_atan2 with sign-aware combinators (0x3243F = π·65536, - 0x6487E ≈ 2π·65536). Returns the orient_q16 stored at kp[+0xc].""" - gx = _s32(gx); gy = _s32(gy) - if gx >= 0: - if gy >= 0: return _fast_atan2(gy, gx) - else: return _s32(0x6487E - _fast_atan2(-gy, gx)) - else: - if gy >= 0: return _s32(0x3243F - _fast_atan2(gy, -gx)) - else: return _s32(0x3243F + _fast_atan2(-gy, -gx)) - - -def _angle_to_bin(ang): - """Signed-div magic-constant emulation: bin ≈ ang / 9830, trunc-toward- - zero (matches the DLL's `imul 0x6AAAAABD; sar edx,24; shr eax,31; add`).""" - ecx_shifted = _s32((_s32(ang) << 12) & 0xFFFFFFFF) - prod = _s32(ecx_shifted) * 0x6AAAAABD - edx_hi = (prod >> 32) & 0xFFFFFFFF - if edx_hi & 0x80000000: edx_hi -= 0x100000000 - edx = _sar32(edx_hi, 24) - return _s32(edx + (1 if edx < 0 else 0)) +# ─── descriptor gradient pair ───────────────────────────────────────────── +def descriptor_gradient(tile_q16): + """Compute (gradX, gradY) float arrays for orientation + BRIEF sampling. + gradX = d/dx of the smoothed tile, gradY = d/dy. Out-of-bounds reads → 0 + (matches the DLL's descriptor-gradient border handling).""" + img = np.asarray(tile_q16, dtype=np.float64) / 65536.0 + gk = build_gaussian(5) + sm = apply_sep(img, gk, gk, fill=0.0) + gradX = apply_sep(sm, DERIV_3TAP, SMOOTH_3TAP, fill=0.0) + gradY = apply_sep(sm, SMOOTH_3TAP, DERIV_3TAP, fill=0.0) + return gradX, gradY +# ─── orientation (sub_18000D920) — dominant gradient angle ──────────────── def orient_d920(gradX, gradY, subpix_x_q16, subpix_y_q16): - """Reproduce sub_18000D920's kp[+0xc] orient_q16 byte-exact. - - `gradX, gradY` are the i32 first-derivative buffers at ctx[+0x50]+0x20/ - +0x28 (same buffers E090 reads — see descriptor_gradient()). `subpix_*` - are kp[+0x14, +0x18] in Q16. Returns the i32 orient_q16.""" - W = gradX.shape[1]; H = gradX.shape[0] - cx = (subpix_x_q16 + 0x8000) >> 16 # ROUNDED, not truncated + """Dominant-gradient orientation at a keypoint, in radians [0, 2π). + + Samples a 13×13 circular patch (radius 6) centred on the rounded subpixel + coordinate, weights each gradient by GAUSS_Q, accumulates two 42-bin + histograms with a 7-bin left-smear, picks the bin of greatest magnitude, + and returns atan2 of that bin's accumulated (gy, gx).""" + H, W = gradX.shape + cx = (subpix_x_q16 + 0x8000) >> 16 # round to nearest pixel cy = (subpix_y_q16 + 0x8000) >> 16 - H_gx = [0] * 42 - H_gy = [0] * 42 + H_gx = np.zeros(42, dtype=np.float64) + H_gy = np.zeros(42, dtype=np.float64) for dy in range(-6, 7): for dx in range(-6, 7): if dy * dy + dx * dx >= 36: # circular mask, radius 6 continue - y = cy + dy; x = cx + dx + y = cy + dy + x = cx + dx if not (0 <= y < H and 0 <= x < W): continue - w = int(GAUSS_Q[abs(dy), abs(dx)]) - ggx = _imul32(_s32(int(gradX[y, x])) >> 10, w); ggx = _sar32(ggx, 4) - ggy = _imul32(_s32(int(gradY[y, x])) >> 10, w); ggy = _sar32(ggy, 4) - b = _angle_to_bin(_fast_atan2(ggy, ggx)) + wgt = float(GAUSS_Q[abs(dy), abs(dx)]) + ggx = float(gradX[y, x]) * wgt + ggy = float(gradY[y, x]) * wgt + ang = math.atan2(ggy, ggx) % TWO_PI + b = int(ang / TWO_PI * 42) % 42 for k in range(7): # 7-bin smear: bin-6 .. bin - sb = (b + 36 + k) % 42 - H_gx[sb] = _s32(H_gx[sb] + ggx) - H_gy[sb] = _s32(H_gy[sb] + ggy) - maxbin = 0; maxmag = 0 - for i in range(42): - a = _sar32(H_gx[i], 13); b = _sar32(H_gy[i], 13) - m = _s32(_imul32(a, a) + _imul32(b, b)) - if m > maxmag: - maxmag = m; maxbin = i - return _precise_atan2(_sar32(H_gx[maxbin], 10), _sar32(H_gy[maxbin], 10)) - - -# ─── Descriptor gradient pair — BYTE-EXACT (interior, dist≥3 from edge) ─── -# E090 reads two i32 buffers gradX/gradY at *(ctx[+0x50])+0x20/+0x28. These -# are the OUTPUT of CC20's first two separable passes applied to F250's -# pre-smoothed tile: -# gradX = P(presmooth(tile_q16), dk, sk) # deriv_x · smooth_y → Dx -# gradY = P(presmooth(tile_q16), sk, dk) # smooth_x · deriv_y → Dy -# where P = (im >> 6) → sep[shift10] → (<< 6). -# -# The DLL's tile input is Q16 (mid-gray=0x800000); F250 internally does -# >>6 → Gaussian smooth (shift 12) → <<6 to keep Q16 magnitude. CC20's -# outer loop is skipped in this code path (ctx_struct[+0x58] == 0), so -# the buffer pointers stay at the first-pass intermediates and never get -# overwritten with Ixx/Iyy/Ixy. (CC20 is still entered — only its inner -# loop is gated.) -# -# Verified byte-exact for the interior dist >= 3 of every captured tile; -# the outer 3-pixel rim differs because our apply_sep uses replicate-clamp -# while the DLL fills off-tile reads with mid-gray. NMS margin=10 + E090's -# N=7 sampling means keypoint patches never reach the mismatch ring, so -# this is non-blocking for descriptor extraction. + sb = (b - 6 + k) % 42 + H_gx[sb] += ggx + H_gy[sb] += ggy + mags = H_gx ** 2 + H_gy ** 2 + maxbin = int(np.argmax(mags)) + return math.atan2(H_gy[maxbin], H_gx[maxbin]) % TWO_PI + + +# ─── E090 oriented-BRIEF descriptor ─────────────────────────────────────── +def desc_sample_rotate(grad_x, grad_y, subpix_x_q16, subpix_y_q16, orient, N=7): + """Rotation + sampling (stage 1). Returns two float arrays of length + (2N+2)² (= 256 for N=7): the orientation-rotated gradient samples. + + Storage is COLUMN-MAJOR (xL outer, yL inner) to match the aggregation + table: index = (xL+N)*(2N+2) + (yL+N). `orient` is in radians.""" + H, W = grad_x.shape + cos_o = math.cos(orient) + sin_o = math.sin(orient) + sx = subpix_x_q16 / 65536.0 + sy = subpix_y_q16 / 65536.0 + span = 2 * N + 2 # = 16 for N = 7 + rgx = np.zeros(span * span, dtype=np.float64) + rgy = np.zeros(span * span, dtype=np.float64) + for xi in range(span): # outer = xL + xL = xi - N + for yi in range(span): # inner = yL + yL = yi - N + px = sx + xL * cos_o - yL * sin_o + py = sy + xL * sin_o + yL * cos_o + ix = int(math.floor(px)) + iy = int(math.floor(py)) + if 0 <= ix < W and 0 <= iy < H: + gx = float(grad_x[iy, ix]) + gy = float(grad_y[iy, ix]) + else: + gx = gy = 0.0 + idx = xi * span + yi + rgx[idx] = cos_o * gx + sin_o * gy + rgy[idx] = cos_o * gy - sin_o * gx + return rgx, rgy -def descriptor_gradient(tile_q16): - """Compute (gradX, gradY) i32 arrays for E090's BRIEF sampling. - `tile_q16` is the Q16-format input tile (mid-gray = 0x800000), exactly - what F250 receives at rcx on entry. Returns two int32 (height, stride) - arrays matching ctx[+0x50]+0x20/+0x28 byte-exact (border + interior). +def desc_aggregate(rgx, rgy, aggr_table, win_sizes, N=7): + """Aggregation (stage 2). For each table entry (size_idx, dx_off, dy_off), + sum the rotated gradients over a w×w window (w = win_sizes[size_idx]). + Returns the 2·len(aggr_table) BRIEF input buffer [gx0, gy0, gx1, gy1, …].""" + span = 2 * N + 2 + out = np.zeros(2 * len(aggr_table), dtype=np.float64) + for i, (size_idx, dx_off, dy_off) in enumerate(aggr_table): + w = int(win_sizes[size_idx]) + if w <= 0: + continue + x0 = dx_off + N + y0 = dy_off + N + sx = sy = 0.0 + for xx in range(w): + base = (x0 + xx) * span + y0 + sx += float(rgx[base:base + w].sum()) + sy += float(rgy[base:base + w].sum()) + out[2 * i + 0] = sx + out[2 * i + 1] = sy + return out - Border handling: out-of-bounds reads → 0 (not replicate, not mid-gray). - Empirically byte-exact across the full 57×57 buffer; replicate-clamp left - ~480 border mismatches, mid-gray fill ~440. Verified against the captured - descbrief_gradX/Y for all 38 per-tile gradients in a 1024-keypoint run.""" - tile = np.asarray(tile_q16, dtype=np.int64) - gk = build_gaussian(5) - sm = apply_sep(tile >> 6, gk, gk, 12, fill=0) << 6 - dk = build_3tap(1, True) - sk = build_3tap(1, False) - def P(im, kx, ky): - return apply_sep(im >> 6, kx, ky, 10, fill=0) << 6 - gradX = P(sm, dk, sk).astype(np.int32) - gradY = P(sm, sk, dk).astype(np.int32) - return gradX, gradY +# ─── BRIEF bit-pack — bit[j] = samples[idx1] > samples[idx2] ────────────── +# The index-pair table is the DLL's runtime-generated BRIEF table, snapshotted +# in blobs_a2.BRIEF_TABLE (128 pairs → 16-byte descriptor). +_BRIEF_TABLE = None -# ─── tile→global merge + v30 assembly ────────────────────────────────── -# sub_18000A910 — coordinate transform (called from sub_18000A960 per kp): -# gx_int = ((tile_offset_x_diff << 16) + subpix_x_q16) >> 16 -# gy_int = ((tile_offset_y_diff << 16) + subpix_y_q16) >> 16 -# Equivalent to `gx_int = tile_offset_x + (subpix_x_q16 sar 16)`. The -# (sar 16) matches Python's signed >> on int. -# -# sub_18000A960 — the per-tile→global merge: -# 1. For each kp in the tile's list: run A910 to get (gx_int, gy_int). -# 2. Bound check: 3 ≤ gx < W-3 AND 3 ≤ gy < H-3 (W=H=112 for 06cb:00a2). -# 3. If in bounds: copy the FULL 32-byte kp record to the destination list -# verbatim (subpix stays in tile-local frame; only the bound check -# uses the global coord). r14d/r15d are tile-edge adjustment offsets -# computed from byte flags at A960's arg5 — likely for handling -# keypoints near a tile boundary; not yet ported (no observed effect -# in the captures we have). -# -# v30 record format (per memory + the captured 18-byte structure): -# [u8 x][u8 y][16 B descriptor] 18 bytes -# x, y are the GLOBAL INTEGER coordinates (= the A910 output). -# Body = 250 records × 18 = 4500 bytes + ~17 B lead-in + ~16 B trailer. -# Header = [u16 tag=4][u16 len=4533][8 zeros]. + +def _load_brief_table(): + global _BRIEF_TABLE + if _BRIEF_TABLE is None: + from .blobs_a2 import BRIEF_TABLE + _BRIEF_TABLE = np.array(BRIEF_TABLE, dtype=np.int32).reshape(-1, 2) + return _BRIEF_TABLE + + +def brief_pack(samples, table=None, count=128): + """BRIEF bit-pack: 128 binary comparisons → 16-byte little-endian descriptor.""" + if table is None: + table = _load_brief_table() + a = samples[table[:count, 0]] + b = samples[table[:count, 1]] + bits = (a > b).astype(np.uint8) + return np.packbits(bits, bitorder='little') # → 16-byte descriptor +# ─── tile→global merge + v30 assembly ────────────────────────────────── def merge_tile_kps_to_global(per_tile_kps, h, w, margin=3): - """Merge per-tile keypoint lists into a single global list, applying - the DLL's bound check (margin ≤ global_xy < dim-margin). - - `per_tile_kps`: iterable of (i, j, kp_list) where (i, j) is the tile - grid position (matches tile_image — ROW-MAJOR: tile 0 = (0,0), - tile 1 = (0,1), …, tile 8 = (2,2)) and kp_list is iterable of - records whose first two fields are (subpix_x_q16, subpix_y_q16). - Any remaining fields are preserved unchanged. - - Returns: list of (gx_int, gy_int, *rest) tuples, in tile-by-kp order - (matches sub_18000A960's iteration). Keypoints failing the bound - check are dropped. - - The DLL stamps each kp record with its tile index at byte +0x9 - (range 0..8). Frame transitions are signalled by +0x9 wrapping - (going from 8 back down to a lower value on the next D920 call). - `dev/validate_merge.py` uses this to attribute captured kps to - tiles byte-exact; 1024/1024 captured kps fit in [3, 109) when - attributed via +0x9 + row-major (i = +0x9 // 3, j = +0x9 % 3). - """ + """Merge per-tile keypoint lists into one global list, applying the DLL's + bound check (margin ≤ global_xy < dim-margin). + + `per_tile_kps`: iterable of (i, j, kp_list) where kp_list items have their + first two fields = (subpix_x_q16, subpix_y_q16). Remaining fields are + preserved. Returns list of (gx_int, gy_int, *rest); out-of-bounds dropped.""" out = [] for i, j, kp_list in per_tile_kps: oy, ox = tile_origin(i, j, h, w) for kp in kp_list: sx_q16, sy_q16 = kp[0], kp[1] - # signed >> 16 matches the DLL's A910 arithmetic. gx = ox + (sx_q16 >> 16) gy = oy + (sy_q16 >> 16) if margin <= gx < w - margin and margin <= gy < h - margin: @@ -582,35 +385,19 @@ def merge_tile_kps_to_global(per_tile_kps, h, w, margin=3): # ─── WS-body v30 SECTION serializer ────────────────────────────────────── -# The v30 record area of ONE ws-body section: a PURE record area of exactly -# n_slots × 18-byte records, no header/lead-in/trailer. -# -# GROUND-TRUTH CONFIRMED (gdb capture ws_body_1780084103036_23056.bin, -# 2026-05-29): record = [x:u8][y:u8][16B descriptor], x/y FIRST (verified -# 250/250 against minutia_tables; the [16B][x][y] order is the match-QUERY -# serializer sub_1800057e0, NOT the stored template). Each section stores a -# full 250 real records (no padding in a genuine enrollment); we zero-pad -# only when our detector yields < 250. +# A v30 record area of one ws-body section: n_slots × 18-byte records, no +# header/lead-in/trailer. Record layout = [16B descriptor][x:u8][y:u8]. V30_SECTION_RECORDS = 250 V30_SECTION_BYTES = V30_SECTION_RECORDS * 18 # 4500 -V30_DESC_LEN = 16 # record = [desc:16][x:u8][y:u8]; x,y anchor is +16 into the record +V30_DESC_LEN = 16 # record = [desc:16][x:u8][y:u8]; (x,y) anchor is +16 in WS_SIZE = 23056 # chip-view WS body size DEFAULT_SUBTYPE = 0x00f7 # default WinBio finger subtype def serialize_v30_section(records, n_slots=V30_SECTION_RECORDS): - """Serialize one ws-body section's v30 record area. - - `records`: iterable of (x:int, y:int, desc:16-bytes). Truncated to - n_slots; short tail zero-padded. Returns exactly n_slots*18 bytes - (4500 for the default 250-slot section). - - TRUE on-wire record layout = [16B descriptor][x:u8][y:u8] (descriptor - FIRST), decoded from the v30 emitter sub_1800057e0 (2026-06-01) and - verified: parsing a stored section this way pairs (x,y,desc) 238-248/250 - against our single-frame extraction (vs 0/250 for the old [x][y][desc]). - The section's record area starts at (the v30-region anchor − 16), so - callers must write this buffer at `anchor - V30_DESC_LEN`.""" + """Serialize one ws-body section's v30 record area as + [16B descriptor][x:u8][y:u8] × n_slots. Short tail zero-padded; the buffer + must be written at (the v30-region anchor − V30_DESC_LEN).""" out = bytearray(n_slots * 18) for i, rec in enumerate(records): if i >= n_slots: @@ -623,173 +410,10 @@ def serialize_v30_section(records, n_slots=V30_SECTION_RECORDS): out[o + 17] = (y & 0xFF) if y is not None else 0 return bytes(out) -# NOTE: the per-section 24-byte v30 trailer ([u8 split][u8 secobj][22B -# orientation-CDF], sub_180005720) is enroll-only bookkeeping the verify matcher -# does NOT read (A/B-confirmed), so we leave the scaffold's trailer bytes as-is -# rather than regenerating them. - - -# ─── E090 oriented-BRIEF descriptor — BYTE-EXACT (validated 2026-05-30) ───── -# Validated against the GDB_DUMP_F250+DESC_BRIEF capture (session 1780170xxx) -# via dev/validate_descriptor_gradient.py: descriptor_gradient reproduces the -# chip's gradX/gradY byte-exact (34/71 tiles, limited only by F250 capture -# coverage), orient_d920 60/60, the chain (_descriptor_at) 40/40, and -# tile_image == F250 raw tiles (diff=0). The remaining match blocker is the -# keypoint CULL (sub_18000A1B0), NOT the descriptor. -# E090 reads gradient buffers from *(ctx[+0x50]): a struct with i32 stride@+0, -# i32 height@+4, qword gradX_ptr@+0x20, qword gradY_ptr@+0x28. (E090's r8 -# turned out to be a scratch-pool descriptor, NOT the gradient.) Pipeline: -# 1. Rotation+sampling (e380-e427): 16×16 grid xL,yL ∈ [-7..8] -# px = subpix_x + xL·cos − yL·sin + 0x8000 (Q16 → pixel via >>16) -# py = subpix_y + xL·sin + yL·cos + 0x8000 -# if 0<=px>8) + sin·(gy>>8))>>8 -# rotated_gy[i] = (cos·(gy>>8) − sin·(gx>>8))>>8 -# 2. Aggregation (e4a0-e5c0): 29 windows from *(ctx[+0x60]) (3 i32 each: -# window_size_idx, dy_off, dx_off). For each window, sum rotated_gx and -# rotated_gy over a r12d×r12d patch at index ((dy_off+7)·16 + dx_off+7), -# where r12d = small_local_table[window_size_idx] holding {7, ?, 3, ...}. -# Output: 58 i32 = the BRIEF compare input buffer. -# 3. BRIEF compare (brief_pack below — BYTE-EXACT ✅). -# -# COS_Q16/SIN_Q16 are already defined above (orientation tables, 181 entries). -# Orient index from kp[+0xc] orient_q16: idx = trunc(orient_q16 · 180 / -# (pi·65536)) — read 8-byte FP constants from 0x180130ee0 (=180.0) and -# 0x180130ee8 (=pi·65536=205887.416...). Range [0,180); ridge orient mod π. -# -# To enable this port we still need a capture of *(ctx[+0x50]) struct, -# gradX/gradY arrays, and the 29-entry *(ctx[+0x60]) table. Hooks updated in -# dev/gdb_dump.py (descbrief_gradstruct/_gradX/_gradY/_aggrtbl); re-run -# enrollment with GDB_DUMP_DESC_BRIEF=1 to grab them. - - -def desc_sample_rotate(grad_x, grad_y, subpix_x_q16, subpix_y_q16, orient_idx, - N=7): - """E090 rotation+sampling (stage 1). Returns two int32 arrays of length - (2N+2)² = 256 (for N=7) — the rotated_gx/rotated_gy buffers that the - aggregation stage sums over. - - Storage order matches the DLL: COLUMN-MAJOR — xL is the OUTER loop variable - in the disasm (e306 cmp r11d, ..+1), yL is inner (e424 cmp r10d, ..+1), and - rdi/rbp advance by 4 once per inner iter. So index = (xL+N)*(2N+2) + (yL+N). - The aggregation step `field1*span + field2` then walks rows of xL (NOT yL): - `field1` = dx (xL offset), `field2` = dy (yL offset). - """ - stride = grad_x.shape[1] - height = grad_x.shape[0] - cos_q = int(COS_Q16[orient_idx]) - sin_q = int(SIN_Q16[orient_idx]) - span = 2 * N + 2 # = 16 for N=7 - rgx = np.zeros(span * span, dtype=np.int64) - rgy = np.zeros(span * span, dtype=np.int64) - - def _s32(v): - v &= 0xFFFFFFFF - return v - (1 << 32) if v & 0x80000000 else v - - for xi in range(span): # outer = xL - xL = xi - N - for yi in range(span): # inner = yL - yL = yi - N - px_q = subpix_x_q16 + xL * cos_q - yL * sin_q + 0x8000 - py_q = subpix_y_q16 + xL * sin_q + yL * cos_q + 0x8000 - px = px_q >> 16 - py = py_q >> 16 - if 0 <= px < stride and 0 <= py < height: - gx = int(grad_x[py, px]) - gy = int(grad_y[py, px]) - else: - gx = gy = 0x800000 - gx8 = gx >> 8 - gy8 = gy >> 8 - # rotated_gy uses `NEG ecx; SAR ecx,8` (e413/e415) — NEG first, then - # arithmetic shift. For non-multiples of 256, `(-v) >> 8 ≠ -(v >> 8)` - # by one (the SAR rounds toward −∞). Reproduce exactly: - rx = _s32(_s32(cos_q * gx8) >> 8) + _s32(_s32(sin_q * gy8) >> 8) - ry = _s32(_s32(cos_q * gy8) >> 8) + _s32(_s32(-(sin_q * gx8)) >> 8) - idx = xi * span + yi - rgx[idx] = _s32(rx) - rgy[idx] = _s32(ry) - return rgx.astype(np.int32), rgy.astype(np.int32) - - -def desc_aggregate(rgx, rgy, aggr_table, win_sizes, N=7): - """E090 aggregation (stage 2). For each of len(aggr_table) entries - (= ctx[+0x68] = 29), sum rotated_gx and rotated_gy over a w×w window where - w = win_sizes[entry.size_idx]. - - Buffer is column-major (xL outer / yL inner — see desc_sample_rotate); so - `field1` = dx (multiplies span), `field2` = dy (added). Window origin: - base = (dx+N)·(2N+2) + (dy+N). - The disasm at e521-e52a sums rgx/rgy in pairs from rdx, rdx+4 (and r8 buf - likewise) — equivalent to a contiguous w-long span starting at `base`, - repeated w times with stride 2N+2 (= the byte step at e552 `add rdx, r14`). - - Returns the 58-i32 BRIEF input buffer: [gx0, gy0, gx1, gy1, ...]. - """ - span = 2 * N + 2 - out = np.zeros(2 * len(aggr_table), dtype=np.int32) - for i, (size_idx, dx_off, dy_off) in enumerate(aggr_table): - w = int(win_sizes[size_idx]) - if w <= 0: - continue - x0 = dx_off + N - y0 = dy_off + N - sx = sy = 0 - for xx in range(w): - base = (x0 + xx) * span + y0 - sx += int(rgx[base:base + w].sum()) - sy += int(rgy[base:base + w].sum()) - out[2 * i + 0] = np.int32(sx) - out[2 * i + 1] = np.int32(sy) - return out - - -# ─── BRIEF bit-pack — BYTE-EXACT ✅ ────────────────────────────────────── -# sub_18000E090 BRIEF compare loop @ e5e0-e60e: -# bit[j] = 1 if samples[tbl[j].idx1] > samples[tbl[j].idx2] else 0 -# packed little-endian into 16 bytes (sub_18000DF20 @ e660: byte_idx=j>>3, -# bit_pos=j&7, dst[byte_idx] |= bit << bit_pos). 128 tests → 16-byte descriptor. -# Validated 500/500 byte-exact vs descbrief_samples + descbrief_desc captures. -# -# The index-pair table is runtime-generated (NOT in the DLL .rdata; we tried -# the 4 candidate tables). The 128-entry snapshot is inlined as -# blobs_a2.BRIEF_TABLE (loaded lazily below). Reproducing the generator -# algorithm is the remaining piece. -_BRIEF_TABLE = None - - -def _load_brief_table(): - global _BRIEF_TABLE - if _BRIEF_TABLE is None: - from .blobs_a2 import BRIEF_TABLE - _BRIEF_TABLE = np.array(BRIEF_TABLE, dtype=np.int32).reshape(-1, 2) - return _BRIEF_TABLE - - -def brief_pack(samples, table=None, count=128): - """sub_18000DF20 BRIEF bit-pack — byte-exact (500/500 vs DLL captures). - samples: 1D int32 array of pre-sampled gradient values around the keypoint - (E090 pre-fills these by orientation-rotated sampling — NOT yet - byte-validated; use captured `descbrief_samples_*` as oracle). - table: index-pair array (N, 2) of test indices; defaults to blobs_a2.BRIEF_TABLE. - count: number of binary tests (= descriptor bits; 128 here).""" - if table is None: - table = _load_brief_table() - a = samples[table[:count, 0]] - b = samples[table[:count, 1]] - bits = (a > b).astype(np.uint8) - return np.packbits(bits, bitorder='little') # → 16-byte descriptor - # ─── End-to-end frame extraction (single image → kp list with descriptors) ─ -# Wires the byte-exact stages into one call. Each per-tile pipeline runs: -# raw_tile_q16 → descriptor_gradient → DoH → NMS → per-kp (D920 + E090) -# Then per-tile lists are merged into a global list with bound filtering. - _AGGR_TABLE = None -_WIN_SIZES = [7, 5, 3] # the small local table at [rsp+0x78..] in E090 +_WIN_SIZES = [7, 5, 3] # the small local window-size table in E090 def _load_aggr_table(): @@ -800,117 +424,81 @@ def _load_aggr_table(): return _AGGR_TABLE -def _descriptor_at(gradX, gradY, subpix_x_q16, subpix_y_q16, orient_q16): - """Compute the 16-byte BRIEF descriptor at a keypoint via the byte-exact - E090 pipeline. Internal helper for extract_frame_native.""" - idx = orient_to_index(orient_q16) % 360 +def _descriptor_at(gradX, gradY, subpix_x_q16, subpix_y_q16, orient): + """16-byte BRIEF descriptor at a keypoint via the rotate→aggregate→pack + chain. `orient` is in radians.""" rgx, rgy = desc_sample_rotate(gradX, gradY, subpix_x_q16, subpix_y_q16, - idx, N=7) + orient, N=7) samples = desc_aggregate(rgx, rgy, _load_aggr_table(), _WIN_SIZES, N=7) return brief_pack(samples) FRAME_KP_CAP = 250 -"""sub_18000AAB0 caps the per-frame kp_array to 250 (constant 0xfa at -[rdi+0x04] in the inline ctx struct staged by the AAB0 caller at line -180004cb4..180004cd4). Applied after the global resp-desc sort that -follows the per-tile A960 edge cull.""" +"""sub_18000AAB0 caps the per-frame kp_array to 250 (0xfa).""" def _a960_passes_global_edge(sx_q16, sy_q16, oy, ox, h, w, ti=None, tj=None): - """sub_18000A960 + sub_18000A910 byte-exact: project (subpix_x_q16, - subpix_y_q16) into the global frame via the tile origin and return - True iff `3 ≤ gx < w - 3` AND `3 ≤ gy < h - 3`, with one corner-tile - tightening empirically observed in Wine enrollment captures. - - A910 computes gx = ((ox << 16) + sx_q16) >> 16 (signed shift) - so a kp at local subpix x=18.5 in a tile with origin x=-10 lands at - global x=8. Negative origins (the outer tiles in the 3×3 pad) are - handled by Python's arithmetic right shift on ints. - - Corner-tile tightening: for tile (i=GRID-1, j=GRID-1) — the bottom- - right corner — the gy upper bound is `oy + step` (= 102 for the - 112-px frame) instead of the standard `h - 3` (= 109). Without this, - my port keeps 7 kps DLL drops (all in tile 8 with gy in [102, 108]). - Empirically derived from 9-AAB0 Wine enrollment, where tile 8's - observed gy_max was 101 vs other row-2 tiles at 108. - - (ti, tj) are optional tile-grid indices. If omitted, no corner - tightening is applied (backward compatibility).""" + """Project (subpix_x_q16, subpix_y_q16) into the global frame via the tile + origin and return True iff 3 ≤ gx < w-3 and 3 ≤ gy < h-3, with one + corner-tile tightening observed in Wine enrollment captures (bottom-right + tile caps gy at oy + last-row-step).""" gx = ((ox << 16) + sx_q16) >> 16 gy = ((oy << 16) + sy_q16) >> 16 gx_hi = w - 3 gy_hi = h - 3 if ti is not None and tj is not None: if ti == GRID - 1 and tj == GRID - 1: - # Bottom-right corner: oy = (GRID-1)*step - GRID_X; for 112-px - # frame oy = 64. Last-row step = h - (GRID-1)*step = 38, so - # oy + step = 102. Cap gy at 102 (exclusive), so gy_max = 101. step_y = h - (GRID - 1) * (h // GRID) gy_hi = oy + step_y # exclusive return 3 <= gx < gx_hi and 3 <= gy < gy_hi def extract_frame_native(image_q16, h=112, w=112, - t_lo=671, t_hi=168, dedup_q=72064, nms_margin=10, - subpix_refine=True, frame_kp_cap=FRAME_KP_CAP): + t_lo=671, t_hi=168, dedup_q=72064, nms_margin=10, + subpix_refine=True, frame_kp_cap=FRAME_KP_CAP): """Single-frame native feature extractor. - Faithful to sub_18000AAB0's 3×3 tile orchestrator: per-tile NMS + subpix - + global-edge cull (A960), then a global qsort by abs(resp) descending, - cap to 250 (constant 0xfa), then a re-sort by (tile_id ASC, resp DESC) - via comparator A810 before D920+E090 run. + Per-tile: DoH → NMS → subpixel refine → global-edge cull. Then a global + sort by |resp| descending, cap to 250, re-sort by (tile_id, |resp| desc), + and run orientation + descriptor per keypoint. Returns a list of + (gx_int, gy_int, orient_rad, desc_16B) after the global merge. - Args: - image_q16: int32 array of shape (h, w), mid-gray = 0x800000. This is - the exact buffer F250 receives at rcx (Q16 image). - subpix_refine: if True (default), refine each NMS keypoint via the - byte-exact sub_18000D5D0 Hessian-Newton port (subpix_refine_kp). - - Returns: - list of (gx_int, gy_int, orient_q16, desc_16B) tuples — the global - keypoint list after merge.""" + `image_q16`: (h, w) int array, mid-gray = 0x800000 (uint8 image << 16).""" image_q16 = np.asarray(image_q16, dtype=np.int64) assert image_q16.shape == (h, w), \ f"expected ({h}, {w}), got {image_q16.shape}" - # Phase 1: per-tile detect + subpix + A960 edge filter. Collect - # surviving kps into a global pool tagged by tile_id. Keep gradX/gradY - # per tile for the later orient+descriptor pass. + # Phase 1: per-tile detect + subpix + edge filter into a tile-tagged pool. per_tile_grads = {} - pool = [] # list of (score, tile_id, ti, tj, sx_q16, sy_q16) + pool = [] # (score, tile_id, ti, tj, sx_q16, sy_q16) for ti, tj, tile in tile_image(image_q16): gradX, gradY = descriptor_gradient(tile) per_tile_grads[(ti, tj)] = (gradX, gradY) - _, _, _, resp = doh(tile >> 6) + _, _, _, resp = doh(tile) oy, ox = tile_origin(ti, tj, h, w) tile_id = ti * GRID + tj for score, lx, ly in nms(resp, t_lo=t_lo, t_hi=t_hi, - dedup_q=dedup_q, margin=nms_margin): + dedup_q=dedup_q, margin=nms_margin): if subpix_refine: r = subpix_refine_kp(resp, lx, ly) if r is None: continue sx_q16, sy_q16 = r else: - sx_q16 = (lx * 65536) & 0xFFFFFFFF - sy_q16 = (ly * 65536) & 0xFFFFFFFF + sx_q16 = lx * 65536 + sy_q16 = ly * 65536 if not _a960_passes_global_edge(sx_q16, sy_q16, oy, ox, h, w, ti, tj): continue pool.append((score, tile_id, ti, tj, sx_q16, sy_q16)) - # Phase 2: global qsort by abs(resp) descending (comparator A7C0), then - # cap to frame_kp_cap (= 250). + # Phase 2: global sort by |resp| desc, cap to frame_kp_cap. pool.sort(key=lambda r: -r[0]) pool = pool[:frame_kp_cap] - # Phase 3: re-sort by (tile_id ASC, abs(resp) DESC) (comparator A810). - # Python's sort is stable, so primary key alone preserves resp order - # within each tile group — but the explicit secondary key is safer. + # Phase 3: re-sort by (tile_id asc, |resp| desc). pool.sort(key=lambda r: (r[1], -r[0])) - # Phase 4: per-kp orient + descriptor, grouped back into per-tile lists - # for merge_tile_kps_to_global. + # Phase 4: per-kp orient + descriptor, grouped back into per-tile lists. per_tile_kps = [] current_key = None current_records = None @@ -933,26 +521,16 @@ def extract_frame_native(image_q16, h=112, w=112, # ─── Baked WS-body scaffold — makes native enrollment REFERENCE-FREE ─────── -# A genuine chip-accepted Wine template (wine_finger_fresh.bin, mode-A, 4 -# v30 sections) with its v30 RECORD areas zeroed — i.e. the TLV framing only: -# the 24-byte header, per-section counts, the sec0_pre inter-section pose -# table, the section-content markers, and the tail. This is finger-INDEPENDENT -# RE-derived constant data (same role as brief_table.bin / aggr_table.bin): -# we overlay OUR v30 records onto it at runtime and recompute the TID, so no -# captured reference template needs to be supplied. The real descriptors of -# the source template are NOT shipped (zeroed for privacy + clarity). -# -# The v30 record areas live at these fixed offsets in the scaffold (detected -# on fresh.bin); each is 250×18 = 4500 bytes. They're pinned -# rather than re-detected because the scaffold's record areas are zeroed (so -# the coordinate-run heuristic can't find them). +# A genuine chip-accepted Wine template with its v30 RECORD areas zeroed — +# i.e. the TLV framing only (header, per-section counts, sec0_pre inter-section +# pose table, section markers, tail). Finger-INDEPENDENT RE-derived constant +# data; we overlay OUR v30 records onto it and recompute the TID. NATIVE_WS_V30_REGIONS = (309, 4913, 9453, 13993) _NATIVE_WS_SCAFFOLD = None def _load_ws_scaffold(): - """Return the 23056-byte baked WS-body framing scaffold (v30 zeroed), - rebuilt from the sparse framing chunks inlined in blobs_a2.""" + """Return the 23056-byte baked WS-body framing scaffold (v30 zeroed).""" global _NATIVE_WS_SCAFFOLD if _NATIVE_WS_SCAFFOLD is None: from .blobs_a2 import build_ws_scaffold @@ -964,18 +542,9 @@ def patch_pre_v30_near_identity(ws_body, regions): """Set the WS body's inter-section rigid transforms to NEAR-identity so a single-frame template (same records in every section) is self-consistent. - Each pre-v30 zone (between v30 record regions) carries a run of 18-byte - rigid-transform records `[x:u8][y:u8][a:i32][b:i32][tx:i32][ty:i32]` - (sec0_pre's pairwise section-alignment table). For a template whose - sections are identical, the correct inter-section transform is the - identity — but the matcher's candidate-validity filter `sub_18000bfb0` - keeps a record only if `tx != 0 || ty != 0` (pure identity `{0x10000,0,0,0}` - is the DLL's 'unmatched' sentinel, `sub_18000b420`, and is skipped). So we - write NEAR-identity `a=0x10000, b=0, tx=1, ty=1`: geometrically identity - (`1/65536 px ≈ 0`) yet `tx != 0`, so the matcher still treats it as a valid - candidate alignment. - - `regions`: the v30 record-region start offsets (each V30_SECTION_BYTES long). + The matcher's candidate-validity filter (sub_18000bfb0) skips pure-identity + {0x10000,0,0,0} as the 'unmatched' sentinel, so we write near-identity + a=0x10000, b=0, tx=1, ty=1: geometrically identity yet tx != 0. Returns (patched_ws_body_bytes, n_records_patched).""" import struct as _struct ONE = 0x10000 @@ -1017,55 +586,30 @@ def rigid(a, b): def native_template(image_q16, subtype=None, fill_all_sections=True): """End-to-end REFERENCE-FREE native enrollment template. - Detects keypoints in `image_q16` with the byte-exact native pipeline - (DoH → NMS → orient → descriptor — same code paths the DLL runs), - formats them into 18-byte v30 records `[16B desc][x][y]`, overwrites every - v30 region of the WS body with those records, recomputes the TID, and - returns the new envelope. - - The WS-body framing comes from a baked-in scaffold (inlined in blobs_a2) - — NO captured reference template is required. The scaffold provides only - finger-independent framing bytes (header, section counts, sec0_pre pose - table, section markers, tail); the (x, y, descriptor) content is ours. - - Args: - image_q16: (h, w) int32 Q16 image (mid-gray = 0x800000). The sensor - returns uint8; convert via `img.astype(np.int32) << 16`. - subtype: override the WinBio finger subtype (default DEFAULT_SUBTYPE). - fill_all_sections: if True (default), write our records into every v30 - region; if False, only the first. - - Returns: - 23136-byte envelope ready for db.new_finger() (= chip cmd 0x47). - - compute_tid / _build_envelope are defined further down in this module - (formerly moh_extract.py).""" + Detects keypoints in `image_q16` with the native pipeline, formats them + into 18-byte v30 records [16B desc][x][y], overwrites every v30 region of + the baked WS-body scaffold, recomputes the TID, and returns the envelope + ready for db.new_finger() (chip cmd 0x47). + + `image_q16`: (h, w) int Q16 image (mid-gray = 0x800000). The sensor returns + uint8; convert via `img.astype(np.int32) << 16`.""" ws_body = bytearray(_load_ws_scaffold()) regions = list(NATIVE_WS_V30_REGIONS) if subtype is None: subtype = DEFAULT_SUBTYPE assert len(ws_body) == WS_SIZE, f"WS body must be {WS_SIZE}B, got {len(ws_body)}" - # The inter-section sec0_pre transforms must be NEAR-identity (load-bearing): - # a single replicated frame is self-aligned, and the matcher's candidate - # filter (sub_18000bfb0) skips pure-identity {0x10000,0,0,0} as the - # 'unmatched' sentinel — so near-identity (tx=ty=1) is what makes each - # section a valid candidate alignment at verify. (Confirmed on hardware.) + # Inter-section sec0_pre transforms must be NEAR-identity (load-bearing). ws_body = bytearray(patch_pre_v30_near_identity(bytes(ws_body), regions)[0]) # 1. Detect OUR keypoints + descriptors from OUR image. kps = extract_frame_native(image_q16, h=image_q16.shape[0], - w=image_q16.shape[1]) - # kps: (gx, gy, orient_q16, desc_16B), bound-filtered to [3,109) by A960. + w=image_q16.shape[1]) - # 2-3. Serialize into the v30 section format ([16B desc][x][y] × 250, - # zero-padded) and overwrite every v30 region. (The per-section 24-byte - # orientation-CDF trailer is enroll-only bookkeeping the verify matcher does - # NOT read — A/B-confirmed — so we leave the scaffold's bytes untouched.) + # 2-3. Serialize into [16B desc][x][y] × 250 and overwrite every v30 region. section = serialize_v30_section( [(gx, gy, desc) for (gx, gy, _orient, desc) in kps]) target_regions = regions if fill_all_sections else regions[:1] - # records start V30_DESC_LEN before the (x,y) anchor — layout [desc][x][y]. for base in target_regions: start = base - V30_DESC_LEN ws_body[start:start + len(section)] = section @@ -1077,515 +621,14 @@ def native_template(image_q16, subtype=None, fill_all_sections=True): # ══════════════════════════════════════════════════════════════════════ -# Host-side feature-extraction pipeline (formerly moh_extract.py) -# -# Python port of the proprietary CEohMohEIV pipeline in synaWudfBioUsb.dll. -# In production only compute_tid() and _build_envelope() are used (by -# native_template above and by the enrollment driver in moh_enrollment.py); -# the Minutia/stage/orchestrate scaffold below is retained RE documentation. -# -# Reference function addresses (Lenovo n1cgn10w build): -# sub_180001A50 coordinator | sub_180004C10 250-slot init | sub_18000AAB0 -# 9-stage orchestrator | sub_18004B710 CryptHashData->SHA-256. -# qsort comparators: sub_18000A7C0/_A7E0/_A810/_A940 (all DECODED). +# Envelope + TID (byte-format; used by native_template and moh_enrollment) # ══════════════════════════════════════════════════════════════════════ -# Tuning constants from sub_180004C10's stack-allocated param block, -# passed to sub_18000AAB0 each frame. -MAX_MINUTIAE = 250 # 0xfa -GRID_X = 10 # 0x0a -GRID_Y = 7 # 0x07 -COORD_RANGE_X = 1126 # 0x466 -COORD_RANGE_Y = 671 # 0x29f -BLOCK_SIZE_SMALL = 16 # 0x10 -BLOCK_SIZE_LARGE = 128 # 0x80 -COORD_SCALE = 500 # 0x1f4 - -# Sensor parameters (a2 device) -SENSOR_DPI = 363 -SENSOR_W = 112 -SENSOR_H = 112 - -# Per-enrollment limits from sub_1800D89C0 -MAX_BAD_FRAMES = 6 -MOH_MODE_FLAG = 101 # vtbl(this)[+24] == 101 - -# Output format selectors used by sub_18004E640 -TEMPLATE_FORMAT_SHA256 = 0 # 32 bytes -TEMPLATE_FORMAT_SHA1 = 1 # 20 bytes -TEMPLATE_FORMAT_MD5 = 2 # 16 bytes - -# Session-buffer header from sub_1800D89C0 -SESSION_MAGIC = 0x4C4F4356 # "VCOL" little-endian -SESSION_VERSION = 8 -SESSION_HEADER_LEN = 152 # minutia table starts at session + 152 - - -# ─── Minutia record (32 bytes) ──────────────────────────────────────── -# Field offsets recovered from the 4 qsort comparators. Bytes 0..7 and -# 0x14..0x1f are consumed by stages we haven't decompiled yet — likely -# (x, y, theta, type, quality) attributes. - -@dataclass -class Minutia: - head: bytes = field(default_factory=lambda: bytes(8)) # +0x00..0x07 - active: int = 0 # +0x08 - flag9: int = 0 # +0x09 - pad_a_b: bytes = field(default_factory=lambda: bytes(2)) # +0x0a..0x0b - score_c: int = 0 # +0x0c int32 - score_10: int = 0 # +0x10 int32 - tail: bytes = field(default_factory=lambda: bytes(12)) # +0x14..0x1f - - def __bytes__(self) -> bytes: - return (self.head - + pack(' 'Minutia': - assert len(b) == 32, f"Minutia is 32 bytes, got {len(b)}" - active, flag9 = unpack(' bool: - return self.active != 0 - - -# ─── qsort comparators (sub_18000A7C0/_A7E0/_A810/_A940) ────────────── -# sub_1800095C0 is the CRT qsort itself; we use Python's sorted() with -# these key functions instead. Each key reproduces the comparator's -# sign convention. - -def cmp_score_10_asc(m: Minutia) -> Tuple[int, ...]: - return (m.score_10,) - -def cmp_active_then_score_10(m: Minutia) -> Tuple[int, ...]: - return (m.active, m.score_10) - -def cmp_flag9_then_score_10_desc(m: Minutia) -> Tuple[int, ...]: - return (m.flag9, -m.score_10) - -def cmp_score_c_asc(m: Minutia) -> Tuple[int, ...]: - return (m.score_c,) - - -# ─── Frame context (the 250-slot working table + per-frame state) ───── - -class FrameContext: - def __init__(self): - self.minutiae: List[Minutia] = [Minutia() for _ in range(MAX_MINUTIAE)] - self.quality: int = 0 - self.progress_pct: int = 0 - self.frame_count: int = 0 - - -# ─── Stage functions ────────────────────────────────────────────────── -# Six of nine decoded directly from disassembly. All decoded ones are -# pure data-shaping plumbing — the actual biometric work happens in -# the unknown callees they invoke. - -def sub_180003460(dest: bytearray, ptr: int, size: int) -> None: - """Stream descriptor builder. dest is 0x30 bytes: - [+0] uint32 size - [+8] qword base ptr - [+0x10] qword cursor ptr (== base initially) - [+0x18..+0x30] secondary slot, zeroed - """ - dest[0:4] = pack(' None: - """Edge-flag computer. out4[0..4] = (top, bottom, left, right).""" - out4[0] = 1 if y <= 0 else 0 - out4[1] = 1 if y == height - 1 else 0 - out4[2] = 1 if x <= 0 else 0 - out4[3] = 1 if x == width - 1 else 0 - - -def sub_18000A910(dx: int, dy: int, - x_high: int, y_high: int, - x_lo: int, y_lo: int) -> Tuple[int, int]: - """Coordinate quantize-and-offset. Reproduces the - ((diff << 16) + off) >> 16 arithmetic-shift sign-extension trick.""" - def _q(diff: int, off: int) -> int: - v = ((diff << 16) + (off & 0xffffffff)) & 0xffffffff - if v & 0x80000000: - v |= ~0xffffffff - return v >> 16 - return _q(x_high - x_lo, dx), _q(y_high - y_lo, dy) - - -def sub_180001010(handle) -> int: - """Algorithm-ready gate. Returns 1 when ready, else HRESULT error.""" - if handle is None: return 0x80000030 - if getattr(handle, 'f8', 0) == 0: return 0x80000032 - return 1 if getattr(handle, 'f30', 0) != 0 else 0x80000002 - - -def sub_1800031E0(dst: bytearray, src: bytes, length: int) -> None: - """Custom memcpy with dword fast-path. Python equivalent is plain slice.""" - dst[:length] = src[:length] - - -def sub_1800032C0(dest: bytearray, arg2: bytearray, - consumed: int, src_ptr: int, - cap_qword: int, cap_dword: int) -> None: - """Stream-descriptor advance with bounds check. Resets dest's - secondary slot and re-sets primary {size, ptr} after 8-byte align.""" - # Set qword at +8, zero everything else - dest[8:16] = pack(' None: - """sub_180003320 — stream-advance dispatcher. Calls sub_1800032C0 - (now decoded). Routes to primary or secondary cursor.""" - raise NotImplementedError("Decoded structure; needs Python integration") - - -def stage_4_18000A4B0(*args) -> None: - """sub_18000A4B0 — two-stage glue: - sub_18000A1B0 (197 insn structural) → sub_18000F300 (94 insn SIMD). - F300 is where per-feature SIMD work lives.""" - raise NotImplementedError("Need sub_18000A1B0 and sub_18000F300") - - -def stage_5_18000A5B0(*args) -> None: - """sub_18000A5B0 (122 insn). NOT YET DECOMPILED.""" - raise NotImplementedError("Need decompile output for sub_18000A5B0") - - -def stage_6_18000A850_row_loop(dst: bytearray, dst_stride: int, height: int, - src: bytes, - src_stride_lo: int, src_stride_hi: int) -> None: - """Row-iteration loop. Calls sub_1800031E0 (now decoded as memcpy) - once per row. So this is just an image-blit with potentially - different src/dst strides.""" - src_stride = src_stride_lo * src_stride_hi - if height <= 0: return - s_off = 0; d_off = 0 - for _ in range(height): - dst[d_off:d_off+dst_stride] = src[s_off:s_off+dst_stride] - s_off += src_stride - d_off += dst_stride - - -def stage_9_18000A960(*args) -> None: - """sub_18000A960 (91 insn, 1 call). NOT YET DECOMPILED.""" - raise NotImplementedError("Need decompile output for sub_18000A960") - - -# Hardcoded LFSR-like seed table used by sub_18000E6B0 to deterministically -# select 64 binary tests from a 162-pair candidate database. These 128 values -# are baked into the binary; they're the "learned" random walk that defines -# which BRIEF-like point-pair tests this algorithm uses. -BRIEF_SEED_TABLE = [ - 3382, 4039, 29605, 1734, 19683, 2304, 17019, 16644, - 10030, 26447, 18237, 7668, 28663, 2663, 4319, 9870, - 10986, 19346, 9877, 19462, 12277, 24659, 28646, 32662, - 29695, 20554, 25346, 30589, 18903, 601, 27989, 17736, - 12138, 9477, 19036, 8528, 31546, 30239, 15544, 3972, - 32267, 11683, 23937, 16744, 27871, 4064, 30172, 22878, - 10021, 27353, 5840, 29477, 11566, 748, 25429, 5535, - 23264, 12977, 16558, 29143, 15022, 16933, 24825, 4930, - 1224, 14600, 23557, 25925, 7822, 12419, 19043, 12792, - 11851, 26638, 5824, 32298, 5920, 8593, 31090, 26277, - 28990, 2249, 21072, 25266, 21080, 10734, 21703, 4064, - 31321, 15251, 14890, 27394, 14418, 16333, 28234, 6775, - 7094, 16535, 27207, 11694, 17865, 11125, 12709, 30184, - 28502, 4184, 9634, 23616, 30368, 18370, 8903, 22761, - 2460, 17450, 7358, 28600, 16477, 4770, 11363, 21986, - 15312, 20151, 17437, 9478, 7337, 3481, 32367, 0, -] -assert len(BRIEF_SEED_TABLE) == 128, "seed table is exactly 128 entries" - - -# The fill value used for image padding by sub_180009F50. In 16.16 fixed-point -# this is 128.0 — mid-gray, neutral for gradient/filter operations. -PADDING_FILL_VALUE = 0x800000 - - -def sub_180009F50(workspace_a: bytearray, fill_value: int, - scale: int, dim_y: int, dim_x: int, - pixel_buf: bytes, scratch_strip: bytearray, - width: int, height: int, - edge_flags: bytes) -> None: - """Image padding. Builds workspace_a as the input image with mid-gray - padding on the four edges that touch the sensor boundary. - - Per-edge padding size is `scale` if that edge is touched (per - edge_flags from sub_18000A8E0), else 0. Centered patches get no - padding; sensor-edge patches get padding on the affected sides. - """ - top = scale if edge_flags[0] else 0 - bottom = scale if edge_flags[1] else 0 - left = scale if edge_flags[2] else 0 - right = scale if edge_flags[3] else 0 - - # Pre-fill the scratch strip with the fill value (for left/right margins) - n = scale * dim_y - for i in range(n): - struct_pack_into = pack(' List[Tuple[int, int, int, int, int]]: - """Generate the 64 binary tests used by the per-minutia descriptor. - - Returns 64 tuples of (level, x1, y1, x2, y2) where: - - level ∈ {0, 1, 2} (= grid_size - 2; grid_size ∈ {2, 3, 4}) - - (x1, y1) and (x2, y2) are pixel-offset coordinates relative to - the minutia center, scaled into [-scale, +scale] - - These tests are applied to a local patch: each test produces a bit - by comparing the pixels at (x1, y1) and (x2, y2). 64 bits form the - per-minutia binary descriptor. - """ - # Phase 1: build all candidate pairs across 3 grid resolutions - pairs: List[Tuple[int, int, int, int, int]] = [] - for grid_size in (2, 3, 4): - level = grid_size - 2 - scale_factor = int(scale * 2.0 / grid_size + 0.999) - n = grid_size * grid_size - for i in range(n): - for j in range(i + 1, n): - pairs.append(( - level, - scale_factor * (i % grid_size) - scale, - scale_factor * (i // grid_size) - scale, - scale_factor * (j % grid_size) - scale, - scale_factor * (j // grid_size) - scale, - )) - # 6 + 36 + 120 = 162 - - # Phase 2: select 64 pairs deterministically using the seed table - selected = [] - remaining = list(pairs) - for i in range(min(num_tests, len(remaining))): - if i < 6: - pick = i - else: - pick = BRIEF_SEED_TABLE[i] % len(remaining) - selected.append(remaining[pick]) - remaining[pick] = remaining[-1] - remaining.pop() - return selected - - -# ─── Per-frame orchestrator (sub_18000AAB0) ─────────────────────────── - -def orchestrate(image: bytes, w: int, h: int, ctx: FrameContext) -> int: - """sub_18000AAB0. Runs the 9-stage pipeline on one frame. - - Outer-call shape (from sub_180004C10's setup): - zero_minutia_table(ctx) - params = {f0=500, f4=250, f8=10, fc=7, f10=1126, - f14=671, f18=16, f1c=128, f20=0} - sub_18000AAB0(image, ctx.algo, w, h, desc, ctx.sub, params) - - Returns 1 on success. Real output is in `ctx` (minutia table updated). - """ - for m in ctx.minutiae: - m.head = bytes(8); m.active = 0; m.flag9 = 0; m.pad_a_b = bytes(2) - - # stage_1_pre_process(image, w, h, ctx) - # stage_2_build_descriptor(...) - # ctx.minutiae.sort(key=cmp_score_10_asc) # qsort call 1 - # stage_4_18000A4B0(...) - # ctx.minutiae.sort(key=cmp_active_then_score_10) # qsort call 2 - # stage_5_18000A5B0(...) - # ctx.minutiae.sort(key=cmp_flag9_then_score_10_desc) # qsort call 3 - # stage_6_18000A850(...) - # stage_7_18000A8E0(...) - # stage_8_18000A910(...) - # ─ partition: count leading active minutiae, sort each range by score_c ─ - # n_active = next((i for i, m in enumerate(ctx.minutiae) - # if not m.is_active()), len(ctx.minutiae)) - # ctx.minutiae[:n_active] = sorted(ctx.minutiae[:n_active], key=cmp_score_c_asc) - # ctx.minutiae[n_active:] = sorted(ctx.minutiae[n_active:], key=cmp_score_c_asc) - # stage_9_18000A960(...) - - raise NotImplementedError( - "orchestrate(): pending decompile output for stages 1-9. " - "See module docstring for the function addresses." - ) - - -# ─── Feature-extraction coordinator (sub_180001A50) ─────────────────── - -def extract_features(image: bytes, w: int, h: int, ctx: FrameContext, - dpi: int = SENSOR_DPI) -> bytes: - """sub_180001A50. Runs the per-frame pipeline, returns the 32-byte TemplateId. - - The TemplateId is HMAC-SHA256 chained over the assembled WS body — - see compute_tid() for the verified recipe. Note that this function - is still a scaffold: it can't return the chip-accepted TID until - orchestrate() actually fills ctx into a full 23056-byte WS body. - """ - if w > 255: - w = 255 - - orchestrate(image, w, h, ctx) - - ws_body = _serialize_for_hash(ctx) - if len(ws_body) != 23056: - # Placeholder until orchestrate() emits the full chip-view WS body - # (23056 bytes starting with 4 zeros). Returning a SHA-256 here - # keeps callers running but the chip's matcher will not accept - # the resulting template. - log.warning("WS body is %d bytes, expected 23056 — TID will not be chip-valid", - len(ws_body)) - return hashlib.sha256(ws_body).digest() - return compute_tid(ws_body) - - -def _serialize_for_hash(ctx: FrameContext) -> bytes: - """Produce the WS body bytes (TLV-1 payload of the finger template). - - Provisional: emit the 250×32-byte minutia slot table only (8000 bytes). - The chip-accepted WS body is 23056 bytes, so this is short by 15056 - bytes of feature/calibration data we haven't reverse-engineered yet. - To be filled in as orchestrate()'s stage outputs are decoded. - """ - return b''.join(bytes(m) for m in ctx.minutiae) - - -# ─── Per-enrollment session driver (sub_1800D89C0) ──────────────────── - -class EnrollmentSession: - """One enrollment session. Accept frames until enough quality data - has accumulated, then finalize() to emit the chip-storable bytes. - """ - - def __init__(self): - self.ctx = FrameContext() - self.template_id: Optional[bytes] = None - self.frame_count = 0 - self.bad_frame_count = 0 - - def process_frame(self, image: bytes, - w: int = SENSOR_W, h: int = SENSOR_H) -> dict: - self.frame_count += 1 - - # TODO sub_1800D8100 quality pre-check: - # if not _quality_ok(image, w, h): - # self.bad_frame_count += 1 - # if self.bad_frame_count > MAX_BAD_FRAMES: - # return {'state': 'give-up', 'progress': self.ctx.progress_pct} - # return {'state': 'bad-frame', 'progress': self.ctx.progress_pct} - - self.template_id = extract_features(image, w, h, self.ctx) - - # TODO: decide 'final' vs 'progressing' based on accumulated quality - return {'state': 'progressing', 'progress': self.ctx.progress_pct} - - def finalize(self) -> bytes: - """Emit the ~23 KB byte blob that goes via db.new_finger() / 0x47. - - Format decoded from sub_180036840 (vfmAuth.c): - - [8-byte envelope header] - [TLV tag=1, len=ws_size, data=working_state_buffer] # ≈ 22.9 KB - [TLV tag=2, len=32, data=SHA256_TemplateId] # 32 bytes - [32 trailing zero bytes] - - The working_state_buffer is session+152..session+152+ws_size -- - the buffer the 9-stage pipeline writes into during EnrollmentUpdate. - TemplateId is SHA-256 over (some subset of) that buffer. - """ - if not self.template_id: - raise RuntimeError("no template yet; process_frame() must be called first") - - ws = _serialize_for_hash(self.ctx) # placeholder; this is also the TLV-1 content - tid = self.template_id - subtype_u16 = 0xf75a # echoed at envelope header bytes 0..1 - - return _build_envelope(subtype_u16, ws, tid) - - -def _build_envelope(subtype: int, ws_body: bytes, template_id: bytes, - version: int = 3) -> bytes: - """Wire-exact envelope for new_record type=6, byte-identical to - sub_180036840 in synaWudfBioUsb.dll. - - Layout: - offset size field - ──────────────────────────────────────────────────────────── - 0 2 u16 subtype (e.g. 0x00f7) - 2 2 u16 version (= 3) - 4 2 u16 payload_size (= 4 + ws_size + 4 + tid_size) - 6 2 u16 trailing (= 32) - 8 2 u16 tlv1_tag (= 1) - 10 2 u16 tlv1_len (= ws_size) - 12 n bytes ws_body[n] ← chip-view WS body starts here - 12+n 2 u16 tlv2_tag (= 2) - 14+n 2 u16 tlv2_len (= tid_size) - 16+n 32 bytes template_id - 48+n 32 bytes trailing zeros - - For ws_size = 23056 and tid_size = 32, total envelope is 23136 bytes - with the TID at envelope offset 23072..23104 and the TLV2 header - immediately preceding it at 23068..23072. - - Caller contract: pass the chip-view WS body, NOT the - "envelope[16..23072]" slice. The chip-view WS body is 23056 bytes - that go from envelope offset 12 to 12+23056. In any captured - template it always begins with 4 natural-zero bytes - (template[12..16]) and ends with 4 bytes of feature-data tail - (template[23064..23068]); the TLV2 header that sits at template - offset 23068..23072 is NOT part of ws_body — this function writes - it explicitly. - """ +def _build_envelope(subtype, ws_body, template_id, version=3): + """Wire-exact envelope for new_record type=6 (byte-identical to the DLL's + sub_180036840). Layout: 8-byte outer header, TLV1 (tag=1) ws_body, TLV2 + (tag=2) template_id, 32 trailing zeros. Caller passes the chip-view WS + body (NOT including the TLV2 header).""" assert len(template_id) == 32 ws_size = len(ws_body) tid_size = len(template_id) @@ -1594,65 +637,35 @@ def _build_envelope(subtype: int, ws_body: bytes, template_id: bytes, total = 8 + payload_size + trailing buf = bytearray(total) - # Outer header (8 bytes) - buf[0:2] = pack(' bytes: - """Compute the 32-byte TemplateId for a Match-on-Host finger template. - - Recipe verified end-to-end against a Wine-captured enrollment - (enroll-fresh.log lines 1570→1583) and against the stored TID at - envelope offset 23072..23104 of every captured template: +def compute_tid(ws_body): + """Compute the 32-byte TemplateId for a Match-on-Host finger template: K = SHA-256(ws_body) T1 = HMAC-SHA256(K, "Template ID" ‖ 32×0x00) TID = HMAC-SHA256(K, T1 ‖ "Template ID" ‖ 32×0x00) - K is derived from the WS body itself, so there is no device-bound - secret involved. Anyone with the WS body can recompute the TID. See - dev/MOH.md "TID derivation". - - Args: - ws_body: 23056-byte chip-view WS body. In a captured envelope - this is the slice template[12:12+23056] — the bytes the - chip parses as the TLV1 payload. Always begins with 4 - natural-zero bytes and ends with feature data tail; does - NOT include the TLV2 header that lives between the WS body - and the TID at envelope offset 23068. - - Returns: - The 32-byte TID, identical to template[23072:23104] for any - valid captured template. - """ + `ws_body`: 23056-byte chip-view WS body (envelope slice [12:12+23056]).""" if len(ws_body) != 23056: raise ValueError(f"ws_body must be 23056 bytes, got {len(ws_body)}") - K = hashlib.sha256(ws_body).digest() T1 = hmac.new(K, _TID_INFO, hashlib.sha256).digest() return hmac.new(K, T1 + _TID_INFO, hashlib.sha256).digest() From f7fbe9ac15553461a626829c1c90f9eb935ccf66 Mon Sep 17 00:00:00 2001 From: Dmitry Muzyka Date: Thu, 11 Jun 2026 11:16:25 +0200 Subject: [PATCH 12/17] test(moh_native): smoke tests + review cleanups - Drop the now-unused logging import/binding (its only consumer, extract_features, was removed in the float rewrite). - Comment desc_sample_rotate's off-tile 0.0 gradient fill (natural-units equivalent of the DLL's mid-gray; flat extension => zero gradient). - Add tests/test_moh_native_smoke.py: guards the chip-parsed framing path (keypoint bounds, v30 record layout, 23136B envelope, TID round-trip, subpix rejection) without pinning byte-exact output. - Remove the brainstorming design doc. Co-Authored-By: Claude Fable 5 --- ...6-06-11-moh-native-float-rewrite-design.md | 76 ------------------- tests/test_moh_native_smoke.py | 67 ++++++++++++++++ validitysensor/moh_native.py | 6 +- 3 files changed, 70 insertions(+), 79 deletions(-) delete mode 100644 docs/superpowers/specs/2026-06-11-moh-native-float-rewrite-design.md create mode 100644 tests/test_moh_native_smoke.py diff --git a/docs/superpowers/specs/2026-06-11-moh-native-float-rewrite-design.md b/docs/superpowers/specs/2026-06-11-moh-native-float-rewrite-design.md deleted file mode 100644 index 7c8e391..0000000 --- a/docs/superpowers/specs/2026-06-11-moh-native-float-rewrite-design.md +++ /dev/null @@ -1,76 +0,0 @@ -# MoH native pipeline — float-math rewrite (experiment) - -**Date:** 2026-06-11 -**Branch:** `moh-native-float-experiment` (off `moh-native-remove-unused`) -**Goal:** Replace the bit-exact x86 fixed-point emulation in -`validitysensor/moh_native.py` with readable native Python/NumPy float math, -to empirically test whether a **non-byte-exact** template still enrolls and -matches on the 06cb:00a2 sensor. - -## Motivation - -`moh_native.py` reproduces the Windows DLL's image→v30 feature pipeline -byte-for-byte. That fidelity is carried by ~hundreds of lines of x86 -emulation: the `_s32`/`_sar32`/`_imul32`/`_idiv32` 32-bit-truncation -wrappers, magic-constant `atan2`/`exp`/division polynomials, and Q-format -shift juggling. The hypothesis under test: the on-chip matcher (Hough -geometric voting, relative-argmax, **no fixed threshold** — see memory -`moh_matcher`) tolerates small numeric drift, so a clean float pipeline -that is *algorithmically* the same will still match. - -## Scope - -### Unchanged (byte-format / data — NOT asm) -`compute_tid`, `_build_envelope`, `serialize_v30_section`, -`merge_tile_kps_to_global`, `patch_pre_v30_near_identity`, -`_load_ws_scaffold`, `native_template`, `extract_frame_native` -orchestration, tiling (`tile_image`/`tile_origin`/`tile_size`), and the data -tables `GAUSS_Q`, `AGGR_TABLE`/`BRIEF_TABLE` (from `blobs_a2`). - -Public symbols imported by production code keep their signatures: -`extract_frame_native`, `_load_ws_scaffold`, `NATIVE_WS_V30_REGIONS`, -`patch_pre_v30_near_identity`, `serialize_v30_section`, `V30_DESC_LEN`, -`compute_tid`, `_build_envelope`, `native_template`. - -### Rewritten to native float math -| Stage | From | To | -|---|---|---| -| Gaussian kernel | `gauss_tap`+`EXP_TABLE`, `build_gaussian` shift-magic | real `exp(-x²/2σ²)`, unity-normalized | -| 3-tap deriv/smooth | `build_3tap` magic `0xd55`/`0x2aaa` | `[1,0,-1]` central diff, `[1,2,1]/4` smooth | -| Separable conv | `_conv_axis` per-tap `>>shift` | float NumPy convolution | -| DoH response | `>>12` Q-juggling | float `Ixx·Iyy − Ixy²`, scaled by `RESP_SCALE` | -| Subpix | `_solve_2x2_d4c0` Cramer/SAR | `numpy.linalg.solve` 2×2 | -| Orientation | `_fast_atan2`/`_precise_atan2`/`_angle_to_bin` | `math.atan2`, `bin = angle·42/2π` | -| Descriptor | `_s32` Q16 cos/sin, NEG-then-SAR | float `cos/sin`, plain sums | -| Helpers/tables | `_s32/_sar32/_imul32/_idiv32`, `EXP_TABLE`, `COS_Q16/SIN_Q16` | deleted | - -### Deleted (dead RE scaffold, not imported anywhere) -`Minutia`, `FrameContext`, `orchestrate`, `extract_features`, -`EnrollmentSession`, the `stage_*` `NotImplementedError` stubs, -`sub_180003460`/`sub_18000A8E0`/`sub_18000A910`/`sub_180001010`/ -`sub_1800031E0`/`sub_1800032C0`/`sub_180009F50`/`sub_18000E6B0`, -`BRIEF_SEED_TABLE`, the qsort `cmp_*` helpers. Keep only `compute_tid` and -`_build_envelope` from that block. - -## Response-scale preservation - -NMS uses **absolute** thresholds (`t_lo=671`, `t_hi=168`) tuned to the -fixed-point response magnitude. Tracing the original gains analytically: -`resp_original ≈ 16 · (Ixx·Iyy − Ixy²)` of the doubly-smoothed image in -natural units. Rather than re-derive every per-pass gain by hand, we -**calibrate one constant** `RESP_SCALE`: compute the native response and the -original response on the same synthetic tiles and set `RESP_SCALE` so their -magnitudes match (median + max). The constant is then frozen and `t_lo`/`t_hi` -are kept. If hardware shows a very different keypoint count, `t_lo`/`t_hi` -become the tuning knob. - -## Validation (no hardware required for these) -1. Calibration: native vs original `doh()` response stats agree within a few - percent after `RESP_SCALE` (one-off script using `git show` of the - original). -2. Import smoke test of `validitysensor.moh_native` + `moh_enrollment`. -3. End-to-end `extract_frame_native` and `native_template` run on a synthetic - 112×112 frame; envelope is 23136 bytes; TID recomputes. - -Hardware enroll/match (`enroll_moh_chip.py --match`) is the actual experiment -the user runs. diff --git a/tests/test_moh_native_smoke.py b/tests/test_moh_native_smoke.py new file mode 100644 index 0000000..1ad0fa7 --- /dev/null +++ b/tests/test_moh_native_smoke.py @@ -0,0 +1,67 @@ +"""Smoke tests for the native (float) MoH pipeline. + +These do NOT pin byte-exact output (this branch is intentionally non-byte-exact +— see the commit that introduced the float rewrite). They guard the contract +that the chip parses literally: keypoint bounds, descriptor length, the v30 +record layout, the envelope framing, and the TID round-trip. + +Run: PYTHONPATH=. python -m pytest tests/test_moh_native_smoke.py +""" +import numpy as np +import pytest + +from validitysensor import moh_native as m + + +def _synthetic_frame(seed=7): + """A textured 112x112 uint8 frame (gaussian blobs + noise) → Q16.""" + rng = np.random.default_rng(seed) + yy, xx = np.mgrid[0:112, 0:112] + img = np.full((112, 112), 128.0) + for _ in range(60): + cy, cx = rng.integers(8, 104, 2) + amp = rng.uniform(-90, 90) + r = rng.uniform(2, 6) + img += amp * np.exp(-((yy - cy) ** 2 + (xx - cx) ** 2) / (2 * r * r)) + img = np.clip(img + rng.normal(0, 8, img.shape), 0, 255).astype(np.uint8) + return img.astype(np.int32) << 16 + + +def test_extract_frame_native_shape_and_bounds(): + kps = m.extract_frame_native(_synthetic_frame()) + assert kps, "expected at least one keypoint on a textured frame" + assert len(kps) <= m.FRAME_KP_CAP + for gx, gy, orient, desc in kps: + assert 3 <= gx < 109 and 3 <= gy < 109, "keypoint outside the A960 bound" + assert 0.0 <= orient < m.TWO_PI + assert len(desc) == m.V30_DESC_LEN == 16 + + +def test_serialize_v30_section_layout(): + desc = bytes(range(16)) + sec = m.serialize_v30_section([(5, 7, desc)], n_slots=m.V30_SECTION_RECORDS) + assert len(sec) == m.V30_SECTION_BYTES + assert sec[0:16] == desc # [16B desc] + assert sec[16] == 5 and sec[17] == 7 # [x][y] + assert sec[18:36] == bytes(18) # tail zero-padded + + +def test_native_template_envelope_and_tid(): + env = m.native_template(_synthetic_frame(), subtype=0x00f5) + assert len(env) == 23136 + ws = env[12:12 + m.WS_SIZE] + assert len(ws) == m.WS_SIZE + # TID is recomputable from the WS body alone (no device secret). + assert env[23072:23104] == m.compute_tid(ws) + + +def test_compute_tid_rejects_wrong_size(): + with pytest.raises(ValueError): + m.compute_tid(b"\x00" * 100) + + +def test_subpix_refine_rejects_out_of_range(): + resp = np.zeros((57, 57)) + assert m.subpix_refine_kp(resp, 0, 10) is None # x on the border + assert m.subpix_refine_kp(resp, 10, 0) is None # y on the border + assert m.subpix_refine_kp(resp, 10, 10) is None # flat → singular Hessian diff --git a/validitysensor/moh_native.py b/validitysensor/moh_native.py index 7885a8c..99e2c32 100644 --- a/validitysensor/moh_native.py +++ b/validitysensor/moh_native.py @@ -28,14 +28,11 @@ import hashlib import hmac -import logging import math from struct import pack import numpy as np -log = logging.getLogger(__name__) - TWO_PI = 2.0 * math.pi # ─── geometry (decoded from orchestrator sub_18000AAB0) ───────────────── @@ -311,6 +308,9 @@ def desc_sample_rotate(grad_x, grad_y, subpix_x_q16, subpix_y_q16, orient, N=7): gx = float(grad_x[iy, ix]) gy = float(grad_y[iy, ix]) else: + # Off-tile samples: zero gradient (a flat extension has no + # gradient). The DLL filled these with mid-gray 0x800000 in + # its Q16 gradient domain; 0.0 is the natural-units equivalent. gx = gy = 0.0 idx = xi * span + yi rgx[idx] = cos_o * gx + sin_o * gy From 3e870c227494b2c894d3cdb7d308f74f90cdc9bf Mon Sep 17 00:00:00 2001 From: Dmitry Muzyka Date: Thu, 11 Jun 2026 11:23:02 +0200 Subject: [PATCH 13/17] build: declare numpy runtime dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moh_native.py imports numpy at module top level, so the MoH/a2 enrollment path hard-requires it. Add python3-numpy to debian Depends and numpy to setup.py install_requires. (cv2 stays optional — enroll_moh_chip.py has an ImportError fallback.) Co-Authored-By: Claude Fable 5 --- debian/control | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/debian/control b/debian/control index 2b09da3..ed04c48 100644 --- a/debian/control +++ b/debian/control @@ -16,6 +16,7 @@ Depends: ${python3:Depends}, python3-dbus, python3-usb, python3-yaml, + python3-numpy, dbus, open-fprintd (>= 0.6~), innoextract (>= 1.6~) diff --git a/setup.py b/setup.py index ac6d50b..d2eadfa 100755 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ 'bin/validity-led-dance', 'bin/validity-sensors-firmware', ], - install_requires=['cryptography >= 2.1.4', 'pyusb >= 1.0.0', 'pyyaml >= 3.12'], + install_requires=['cryptography >= 2.1.4', 'pyusb >= 1.0.0', 'pyyaml >= 3.12', 'numpy'], data_files=[ ('share/dbus-1/system.d/', ['dbus_service/io.github.uunicorn.Fprint.conf']), ('lib/python-validity/', ['dbus_service/dbus-service']), From ba8e1ee737516a6201ba47b76835c10487a439a9 Mon Sep 17 00:00:00 2001 From: Dmitry Muzyka Date: Thu, 11 Jun 2026 11:23:39 +0200 Subject: [PATCH 14/17] refactor(enroll_moh_chip): drop optional cv2 resize path Remove the cv2 import; the non-112x112 capture safety-net now always uses the numpy nearest-neighbour resample (the cv2 bilinear path was optional with this exact fallback). Captures are 112x112 in practice, so this path is rarely hit. Co-Authored-By: Claude Fable 5 --- scripts/enroll_moh_chip.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/scripts/enroll_moh_chip.py b/scripts/enroll_moh_chip.py index 1b8dc56..7181b7c 100644 --- a/scripts/enroll_moh_chip.py +++ b/scripts/enroll_moh_chip.py @@ -180,13 +180,9 @@ def main(): glow_end_scan() img = np.frombuffer(img_data, dtype=np.uint8).reshape(x, y) # NO transpose (feature frame) if img.shape != (112, 112): - try: - import cv2 - img112 = cv2.resize(img, (112, 112), interpolation=cv2.INTER_LINEAR) - except ImportError: - ys = (np.arange(112) * img.shape[0] // 112) - xs = (np.arange(112) * img.shape[1] // 112) - img112 = img[ys[:, None], xs[None, :]] + ys = (np.arange(112) * img.shape[0] // 112) + xs = (np.arange(112) * img.shape[1] // 112) + img112 = img[ys[:, None], xs[None, :]] else: img112 = img img_q16 = img112.astype(np.int32) << 16 From 504fe8792fabcd32e2519a2501d6f8e38ce5420a Mon Sep 17 00:00:00 2001 From: Dmitry Muzyka Date: Thu, 11 Jun 2026 18:05:08 +0200 Subject: [PATCH 15/17] review cleanups: drop dev artifacts, align frame count, document capture-stop skip - enroll_moh_chip.py: remove personal paths (/media/sf_vbox-rw, .venv-poc), dangling dev/ doc references and RE jargon from help text/docstrings - moh_native.py: same docstring cleanup; assert -> ValueError for input validation; drop dead None-guard in serialize_v30_section; note why both NMS thresholds are kept - moh_enrollment.py: default num_frames 6 -> 4 (the template has 4 v30 sections filled round-robin, so frames beyond the 4th were captured but never used) and align all frame-count docs; drop stale "byte-exact" wording (this is the float pipeline) - sensor.py: comment why MoH devices skip the 0x04 capture-stop (chip returns an error after a streamed capture) - .gitignore: drop personal .claude/ entry Co-Authored-By: Claude Fable 5 --- .gitignore | 1 - scripts/enroll_moh_chip.py | 56 +++++++++++++------------------- validitysensor/moh_enrollment.py | 18 +++++----- validitysensor/moh_native.py | 37 +++++++++++---------- validitysensor/sensor.py | 6 ++-- 5 files changed, 57 insertions(+), 61 deletions(-) diff --git a/.gitignore b/.gitignore index 7032065..80f465e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,3 @@ build dist python_validity.egg-info/ tls.dict -.claude/ diff --git a/scripts/enroll_moh_chip.py b/scripts/enroll_moh_chip.py index 7181b7c..7f8d520 100644 --- a/scripts/enroll_moh_chip.py +++ b/scripts/enroll_moh_chip.py @@ -1,10 +1,11 @@ -"""End-to-end REFERENCE-FREE native enrollment ON THE ACTUAL CHIP. +"""Debug/CLI tool: native enrollment on a 06cb:00a2 sensor. -Captures N frames from the connected 06cb:00a2 sensor, runs the byte-exact -native pipeline, builds a chip-storable template from a baked-in framing -scaffold (validitysensor/native_ws_scaffold.bin) — no captured reference -template needed — stores it via raw 0x47, then optionally tries to identify -the finger to verify the chip accepts our template. +Captures N frames from the connected sensor, runs the native feature +pipeline (validitysensor/moh_native.py), builds a chip-storable template +from the baked-in framing scaffold (blobs_a2.build_ws_scaffold) — no +captured reference template needed — stores it via raw 0x47, then +optionally tries to identify the finger to verify the chip accepts our +template. Run on a machine with the sensor plugged in and python-validity initialised (i.e., the usual `validity-sensors-firmware` & TLS handshake @@ -12,7 +13,7 @@ script in this repo). Usage: - sudo ./.venv-poc/bin/python scripts/enroll_moh_chip.py \\ + sudo python3 scripts/enroll_moh_chip.py \\ --parent \\ [--match] # try to identify after enroll @@ -57,8 +58,7 @@ def main(): 'StgWindsor user dbid; default 5)') ap.add_argument('--frames', type=int, default=4, help='number of DISTINCT placements to capture, one per v30 ' - 'section (default 4 — hardware-confirmed more placement-' - 'robust than 1; a real DLL enroll uses 8). Vary finger ' + 'section (default 4 = one per section). Vary finger ' 'placement between captures for coverage.') ap.add_argument('--match', action='store_true', help='after enroll, capture again and try to identify') @@ -66,22 +66,19 @@ def main(): help='build the envelope but DO NOT store on chip; ' 'write it to /tmp/native_envelope.bin instead') ap.add_argument('--match-only', action='store_true', - help='do NOT enroll; just run the chip 0x5e identify against ' - 'whatever is already stored. Control: if even a known-' - 'good Wine-enrolled finger does not match, the 0x5e ' - '(match-on-chip) path is dead on this MoH chip.') + help='do NOT enroll; just run the chip identify against ' + 'whatever is already stored.') ap.add_argument('--delete-dbid', type=int, default=None, help='delete the FINGER record with this dbid (see ' - '--list-users), then exit. Use to remove the Wine ' - 'finger so a --match-only cleanly tests OUR template. ' + '--list-users), then exit. ' 'Refuses to delete a USER record (would orphan fingers) ' 'unless --force.') ap.add_argument('--force', action='store_true', help='allow --delete-dbid to delete a non-finger record') ap.add_argument('--user-sid', default=None, help='enroll under this user SID, creating the user if it ' - 'does not exist (self-contained: no Wine-made user ' - 'needed). Overrides --parent.') + 'does not exist (self-contained: no pre-existing ' + 'user needed). Overrides --parent.') ap.add_argument('--list-users', action='store_true', help='dump the chip DB tree (db.dump_raw) and exit; ' 'use to find a real parent dbid to pass via --parent') @@ -130,12 +127,8 @@ def main(): if args.match_only: ok = _try_match(log) - log.info('MATCH-ONLY (chip 0x5e identify against stored fingers): ' - + ('MATCHED → 0x5e works on this chip.' - if ok else - 'NO MATCH. If a known-good Wine finger is enrolled and this ' - 'still fails, 0x5e (match-on-chip) is dead here → matching ' - 'must be done host-side (port sub_18000c6a0).')) + log.info('MATCH-ONLY (chip identify against stored fingers): ' + + ('MATCHED.' if ok else 'NO MATCH.')) return 0 if ok else 3 if args.list_users: @@ -165,7 +158,7 @@ def main(): val = bytes(rec.value) log.info(f' root {r}: type={rec.type} ' f'val[:32]={val[:32].hex()}') - except Exception as ex: + except Exception: pass return 0 @@ -186,18 +179,15 @@ def main(): else: img112 = img img_q16 = img112.astype(np.int32) << 16 - # DIAGNOSTIC (live-enroll no-match debug): log stats + save the frame. - # Reference DLL working image: 112x112, min=0 max=255 mean~135 std~75. + # Log frame stats + save the raw frame for offline inspection. + # A good capture looks like: 112x112, min=0 max=255 mean~135 std~75. log.info(f' CAPTURE: raw dims {x}x{y} ({len(img_data)}B); 112x112 ' f'min={int(img112.min())} max={int(img112.max())} ' f'mean={float(img112.mean()):.1f} std={float(img112.std()):.1f}') - for _p in (f'/tmp/native_capture_{x}x{y}.bin', - f'/media/sf_vbox-rw/finger/native_capture_{x}x{y}.bin'): - try: - open(_p, 'wb').write(img112.astype(np.uint8).tobytes()) - log.info(f' saved capture -> {_p}') - except Exception: - pass + _p = f'/tmp/native_capture_{x}x{y}.bin' + with open(_p, 'wb') as fp: + fp.write(img112.astype(np.uint8).tobytes()) + log.info(f' saved capture -> {_p}') from validitysensor.moh_native import extract_frame_native as _ext log.info(f' pipeline on live frame: {len(_ext(img_q16))} keypoints') envelope = native_template(img_q16, subtype=subtype) diff --git a/validitysensor/moh_enrollment.py b/validitysensor/moh_enrollment.py index 81039c1..1e546d7 100644 --- a/validitysensor/moh_enrollment.py +++ b/validitysensor/moh_enrollment.py @@ -3,7 +3,7 @@ Isolated from sensor.py so the generic Sensor class stays device-agnostic. `Sensor.enroll()` delegates here for devices whose blob sets `moh_enroll = True` (see blobs_a2.py): instead of the DLL-style 0x68/0x6b enrollment session, MoH -devices build a byte-exact template with the native pipeline (moh_native.py) +devices build a template with the native feature pipeline (moh_native.py) and store it via the raw 0x47 new_record protocol. The single entry point is `enroll_moh(sensor, ...)`; `Sensor.enroll_moh` is a @@ -25,8 +25,8 @@ def enroll_moh(sensor, parent_dbid: int, subtype: int, update_cb: typing.Callable[[typing.Any, typing.Optional[Exception]], None] = lambda *a, **k: None, max_attempts: int = 6, - num_frames: int = 6): - """Enroll a finger using the byte-exact native pipeline (no DLL). + num_frames: int = 4): + """Enroll a finger using the native feature pipeline (no DLL). Captures `num_frames` placements, builds a 23136-byte template via the native pipeline (our keypoints into the baked WS-body framing scaffold, @@ -47,6 +47,10 @@ def enroll_moh(sensor, parent_dbid: int, subtype: int, Called after each frame with a 1-byte percentage (0-100), or (None, exception) on a failed attempt. max_attempts: how many capture retries on transient errors. + num_frames: how many placements to capture. The template has 4 v30 + sections filled round-robin from the captured frames, so 4 + (the default) gives one distinct placement per section; frames + beyond the 4th would be captured but never used. Returns: the recid created in the chip's storage.""" import numpy as np @@ -64,9 +68,8 @@ def enroll_moh(sensor, parent_dbid: int, subtype: int, last_err = None for attempt in range(max_attempts): try: - # 1. Capture N frames (default 8; multi-frame enrollment - # fills the WS body's 4 v30 sections with different per- - # frame data). + # 1. Capture N frames; multi-frame enrollment fills the WS + # body's 4 v30 sections with different per-frame data. logging.info(f'enroll_moh: capturing {num_frames} frame(s)...') per_frame_kps = [] for f in range(num_frames): @@ -136,8 +139,7 @@ def enroll_moh(sensor, parent_dbid: int, subtype: int, # 3. Store via the proven replay protocol. No wait_int() # — the typ=6-direct path doesn't emit an interrupt the - # way db.new_finger's typ=0xb-magic path does. bisect_ws - # send_finger() doesn't wait either, and it works. + # way db.new_finger's typ=0xb-magic path does. logging.info('enroll_moh: storing on chip...') db.db_info() write_enable() diff --git a/validitysensor/moh_native.py b/validitysensor/moh_native.py index 99e2c32..8dbc131 100644 --- a/validitysensor/moh_native.py +++ b/validitysensor/moh_native.py @@ -1,9 +1,9 @@ """Native MoH feature pipeline (06cb:00a2) — image → v30 path. This is the **float / native-Python** variant of the pipeline. It is the same -algorithm the Windows DLL runs (decoded in dev/DLL-RE.md and dev/MOH.md), but -the bit-exact x86 fixed-point emulation has been replaced with ordinary -floating-point math: +algorithm the Windows DLL runs (reverse-engineered from the synaWudfBioUsb +driver DLL), but the bit-exact x86 fixed-point emulation has been replaced +with ordinary floating-point math: working image (112²) → 3×3 grid of 57×57 tiles (mid-gray pad) [tile_image] @@ -16,13 +16,12 @@ NOTE: the math here is intentionally NOT byte-exact with the DLL. The chip-side matcher is a Hough geometric-voting / relative-argmax scheme with no fixed -threshold (see dev/SCORER-sub_18000c6a0.md), so small numeric drift in the -descriptor pipeline is expected to be tolerable. This module exists to test -that hypothesis on hardware. +threshold, so small numeric drift in the descriptor pipeline is tolerable +(confirmed on hardware: enroll + recognize work). The byte-format / framing functions at the bottom (serialize_v30_section, -merge, scaffold, TID, envelope) are unchanged from the byte-exact build — they -are pure format/data, not arithmetic, and the chip parses them literally. +merge, scaffold, TID, envelope) are pure format/data, not arithmetic — the +chip parses them literally. """ from __future__ import annotations @@ -140,8 +139,8 @@ def apply_sep(img, kx, ky, fill=None): # response = Ixx·Iyy − Ixy² of the pre-smoothed image, in natural pixel units, # rescaled by RESP_SCALE so the NMS thresholds (t_lo/t_hi) — which were tuned # to the DLL's fixed-point response magnitude — still apply. RESP_SCALE was -# fit by least-squares against the byte-exact doh() on synthetic tiles -# (median 17.04, std/mean 2.4%); see dev/calib_resp_scale.py. +# fit by least-squares against the DLL's fixed-point doh() on synthetic tiles +# (median 17.04, std/mean 2.4%). RESP_SCALE = 17.04 @@ -170,7 +169,11 @@ def doh(tile_q16, size=5): def nms(resp, t_lo=671, t_hi=168, dedup_q=72064, margin=10): """8-neighbour non-maximum suppression on the response map. A pixel is a keypoint iff `resp > t_lo`, `resp >= t_hi`, and strictly greater than all 8 - neighbours; score = |resp|. Returns `(score, x, y)` in raster-scan order.""" + neighbours; score = |resp|. Returns `(score, x, y)` in raster-scan order. + + Both thresholds are kept separate for parity with the DLL's NMS + (sub_18000CF90), even though t_hi < t_lo makes the second test redundant + at the default values.""" h, w = resp.shape r2 = ((dedup_q >> 6) ** 2) >> 20 kps = [] # (score, x, y) @@ -407,7 +410,7 @@ def serialize_v30_section(records, n_slots=V30_SECTION_RECORDS): d = bytes(desc[:16]) out[o:o + len(d)] = d out[o + 16] = x & 0xFF - out[o + 17] = (y & 0xFF) if y is not None else 0 + out[o + 17] = y & 0xFF return bytes(out) @@ -440,8 +443,8 @@ def _descriptor_at(gradX, gradY, subpix_x_q16, subpix_y_q16, orient): def _a960_passes_global_edge(sx_q16, sy_q16, oy, ox, h, w, ti=None, tj=None): """Project (subpix_x_q16, subpix_y_q16) into the global frame via the tile origin and return True iff 3 ≤ gx < w-3 and 3 ≤ gy < h-3, with one - corner-tile tightening observed in Wine enrollment captures (bottom-right - tile caps gy at oy + last-row-step).""" + corner-tile tightening observed in captures of the Windows driver's + enrollment (bottom-right tile caps gy at oy + last-row-step).""" gx = ((ox << 16) + sx_q16) >> 16 gy = ((oy << 16) + sy_q16) >> 16 gx_hi = w - 3 @@ -465,8 +468,8 @@ def extract_frame_native(image_q16, h=112, w=112, `image_q16`: (h, w) int array, mid-gray = 0x800000 (uint8 image << 16).""" image_q16 = np.asarray(image_q16, dtype=np.int64) - assert image_q16.shape == (h, w), \ - f"expected ({h}, {w}), got {image_q16.shape}" + if image_q16.shape != (h, w): + raise ValueError(f"expected ({h}, {w}), got {image_q16.shape}") # Phase 1: per-tile detect + subpix + edge filter into a tile-tagged pool. per_tile_grads = {} @@ -521,7 +524,7 @@ def extract_frame_native(image_q16, h=112, w=112, # ─── Baked WS-body scaffold — makes native enrollment REFERENCE-FREE ─────── -# A genuine chip-accepted Wine template with its v30 RECORD areas zeroed — +# A genuine chip-accepted Windows-driver template with its v30 RECORD areas zeroed — # i.e. the TLV framing only (header, per-section counts, sec0_pre inter-section # pose table, section markers, tail). Finger-INDEPENDENT RE-derived constant # data; we overlay OUR v30 records onto it and recompute the TID. diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index 94fe3dd..412a0d8 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -764,6 +764,8 @@ def capture(self, mode: CaptureMode) -> typing.Tuple[int, int, int, int, bytes]: return x, y, w1, w2, img_data finally: + # MoH devices (a2) reject the 0x04 capture-stop after a streamed + # capture (the chip returns an error), so skip the cleanup there. if not moh_enroll(): tls.app(unhexlify('04')) # capture stop if still running, cleanup @@ -856,8 +858,8 @@ def enroll(self, identity: SidIdentity, subtype: int, usr = usr.dbid # MoH and other native-pipeline devices enroll via enroll_moh - # (byte-exact pipeline + raw 0x47 store) instead of the DLL-style - # 0x68/0x6b enrollment session. The DLL uses 8 placements. + # (native feature pipeline + raw 0x47 store) instead of the + # DLL-style 0x68/0x6b enrollment session. if moh_enroll(): return self.enroll_moh(usr, subtype, update_cb=update_cb) From 377d2337308f87c9accfb2a65c105a3c4c748520 Mon Sep 17 00:00:00 2001 From: Dmitry Muzyka Date: Thu, 11 Jun 2026 18:11:51 +0200 Subject: [PATCH 16/17] enroll_moh: capture 6 frames by default, fill sections with the best 4 The template has 4 v30 sections, each holding one placement. Capturing 6 and keeping the 4 frames with the most keypoints (in capture order) lets weak placements be dropped instead of silently discarding frames 5-6 as before. Co-Authored-By: Claude Fable 5 --- scripts/enroll_moh_chip.py | 9 +++++---- validitysensor/moh_enrollment.py | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/scripts/enroll_moh_chip.py b/scripts/enroll_moh_chip.py index 7f8d520..bd5f1cf 100644 --- a/scripts/enroll_moh_chip.py +++ b/scripts/enroll_moh_chip.py @@ -56,10 +56,11 @@ def main(): ap.add_argument('--parent', type=int, default=5, help='parent user dbid (use --list-users to find the ' 'StgWindsor user dbid; default 5)') - ap.add_argument('--frames', type=int, default=4, - help='number of DISTINCT placements to capture, one per v30 ' - 'section (default 4 = one per section). Vary finger ' - 'placement between captures for coverage.') + ap.add_argument('--frames', type=int, default=6, + help='number of DISTINCT placements to capture (default 6); ' + 'the best 4 (most keypoints) fill the template\'s 4 v30 ' + 'sections. Vary finger placement between captures for ' + 'coverage.') ap.add_argument('--match', action='store_true', help='after enroll, capture again and try to identify') ap.add_argument('--dry-run', action='store_true', diff --git a/validitysensor/moh_enrollment.py b/validitysensor/moh_enrollment.py index 1e546d7..9266851 100644 --- a/validitysensor/moh_enrollment.py +++ b/validitysensor/moh_enrollment.py @@ -25,7 +25,7 @@ def enroll_moh(sensor, parent_dbid: int, subtype: int, update_cb: typing.Callable[[typing.Any, typing.Optional[Exception]], None] = lambda *a, **k: None, max_attempts: int = 6, - num_frames: int = 4): + num_frames: int = 6): """Enroll a finger using the native feature pipeline (no DLL). Captures `num_frames` placements, builds a 23136-byte template via the @@ -47,10 +47,10 @@ def enroll_moh(sensor, parent_dbid: int, subtype: int, Called after each frame with a 1-byte percentage (0-100), or (None, exception) on a failed attempt. max_attempts: how many capture retries on transient errors. - num_frames: how many placements to capture. The template has 4 v30 - sections filled round-robin from the captured frames, so 4 - (the default) gives one distinct placement per section; frames - beyond the 4th would be captured but never used. + num_frames: how many placements to capture (default 6). The + template has 4 v30 sections, each holding one placement; the + best 4 frames (most keypoints) fill them, so extra captures + let weak placements be dropped. Returns: the recid created in the chip's storage.""" import numpy as np @@ -123,6 +123,14 @@ def enroll_moh(sensor, parent_dbid: int, subtype: int, # (tx=ty=1) makes each section a valid candidate alignment at verify. ws_body = bytearray( patch_pre_v30_near_identity(bytes(ws_body), regions)[0]) + # Keep the best len(regions) frames (most keypoints — a frame- + # quality proxy) in capture order; each section then holds one + # geometrically consistent placement. + if len(per_frame_kps) > len(regions): + best = sorted(range(len(per_frame_kps)), + key=lambda i: len(per_frame_kps[i]), + reverse=True)[:len(regions)] + per_frame_kps = [per_frame_kps[i] for i in sorted(best)] for idx, base in enumerate(regions): src_frame = per_frame_kps[idx % len(per_frame_kps)] # v30 records are [16B desc][x][y]; the record area starts From 845142992cafbc512c5c78c89efd1c6772cf03b6 Mon Sep 17 00:00:00 2001 From: Dmitry Muzyka Date: Thu, 11 Jun 2026 19:58:04 +0200 Subject: [PATCH 17/17] enroll_moh: document that frame selection approximates the DLL The best-N-by-keypoint-count heuristic is a structural stand-in for the DLL's quality-regression frame pick + cross-frame consensus cull, neither of which is statically portable. Record the divergence so the next reader knows it's an approximation, not a reproduction. Co-Authored-By: Claude Fable 5 --- validitysensor/moh_enrollment.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/validitysensor/moh_enrollment.py b/validitysensor/moh_enrollment.py index 9266851..57ebf0e 100644 --- a/validitysensor/moh_enrollment.py +++ b/validitysensor/moh_enrollment.py @@ -126,6 +126,20 @@ def enroll_moh(sensor, parent_dbid: int, subtype: int, # Keep the best len(regions) frames (most keypoints — a frame- # quality proxy) in capture order; each section then holds one # geometrically consistent placement. + # + # NOTE: this is a structural APPROXIMATION of the Windows DLL, not + # a reproduction of it. The DLL (EnrollmentUpdate → commit) folds + # every placement into a persistent session accumulator, culls + # keypoints by cross-frame CONSENSUS (sub_180008ec0 coord + # histograms — the source of the per-tile survivor counts), and + # builds each v30 section from a frame chosen by a learned QUALITY + # regression (sub_180008980 score vs the 0x699=1689 gate), not by + # keypoint count. That regression's coefficients live in a runtime + # ctx object and are not statically portable, and we have no + # cross-frame consensus step, so we substitute: distinct placement + # per section, ranked by keypoint count. The chip's voting matcher + # tolerates this (enroll + recognize confirmed on hardware), but the + # exact DLL section<->frame mapping was never RE-confirmed. if len(per_frame_kps) > len(regions): best = sorted(range(len(per_frame_kps)), key=lambda i: len(per_frame_kps[i]),