Jorijn worked out the technique and it changes what every number in this project means: "press and LIFT (quick tap) is wrong, holding the sensor until it gives the result is a 100% success rate." The logs agree, on a properly controlled comparison. Same template, same session, learning off for all four blocks, only the technique differing: tapped 4/15 and 3/15, held 15/15 and 15/15. The frame data says why. Over every frame this project has a verdict for, split at 2.5x the idle floor: full contact, interrupt settled 78/175 = 45% match full contact, interrupt asserted 28/142 = 20% partial, interrupt settled 1/9 = 11% partial, interrupt asserted 0/53 = 0% A tap is caught while the finger is still arriving or already leaving. Such a frame is not a hard verdict waiting to happen, it is a wasted one: with the rescan budget at 0 every frame is terminal, so its rejection ends the press. 62 partial frames produced exactly one match between them. So the tracker becomes a Schmitt trigger. A press now STARTS on settled contact and ENDS on the finger leaving, which means a frame taken mid-landing produces no event at all rather than a false rejection. A press that never settles simply yields no verdict and the loop waits for the next one, which is an honest try again. Enrolment is untouched: it passes one threshold for both and keeps its own sample-quality gate inside the trustlet. fptrial.sh now says hold, and defaults to fifteen presses. Instructing a tap for its whole life is what quietly made every rate this project has quoted a worst case, and a tap is not a case the product has -- nobody taps a phone sensor and walks away, they rest a finger until it unlocks.
341 lines
16 KiB
C++
341 lines
16 KiB
C++
// SPDX-License-Identifier: GPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// lint-disable-file fixed-width-types
|
|
/*
|
|
Fingerprintd:Engine — the capture loop as a state machine.
|
|
|
|
The trustlet does not poll for a finger. The normal world captures a frame,
|
|
decides whether a finger is on the sensor, and *tells* it what happened; the
|
|
trustlet's enrolment and matching advance only on those reports. So the policy
|
|
that decides "finger down", "finger lifted" and "this press matched" lives out
|
|
here, and it is the part most easily got wrong in a way that looks like bad
|
|
hardware.
|
|
|
|
Pure logic: frames in, events and tallies out. No TEE, no sensor, no clock.
|
|
*/
|
|
|
|
export module Fingerprintd:Engine;
|
|
import std;
|
|
import :Ta;
|
|
|
|
export namespace fingerprintd::engine {
|
|
|
|
using ta::Event;
|
|
using ta::Verdict;
|
|
|
|
// ---- Finger detection -------------------------------------------------
|
|
//
|
|
// CAPTURE_IMAGE returns a metric in the response header (reqOut+0x0c — a
|
|
// header field, not payload+12). It tracks the finger reproducibly: an
|
|
// idle floor around 132 against 345-366 with a finger.
|
|
//
|
|
// The floor is NOT a constant and must never be one. It is per frame, so
|
|
// it scales with how many frames a capture requests, and it drifts: an
|
|
// early session read "18 -> 24 with a finger" as weak detection when the
|
|
// values were drifting upward regardless of what was on the sensor. That
|
|
// was a clean negative misread as a positive because the floor was sampled
|
|
// once and then trusted.
|
|
//
|
|
// So a Baseline is not usable until it has been calibrated, and asking it
|
|
// about a frame before that is a programming error rather than a guess.
|
|
class Baseline {
|
|
public:
|
|
static constexpr std::size_t DefaultSamples = 5;
|
|
|
|
// A finger at full contact reads ~2.7x the floor (133 idle, 355-375
|
|
// pressed), so 2x separated them with margin. But 2x also discards the
|
|
// LANDING frames -- measured at 209 and 247 against a 133 floor, with
|
|
// the interrupt already asserted -- and those are the only frames a
|
|
// quick tap has to spare: a frame that detects a finger takes 400-580 ms
|
|
// to process (the trustlet's REPORT_EVENT is ~300 ms of it) while a tap
|
|
// lasts 400-600 ms, so detection one frame earlier is the difference
|
|
// between one image and two.
|
|
//
|
|
// 3/2 puts the threshold at 200 for a 133 floor: above the highest idle
|
|
// drift observed (147) and below the lowest landing frame seen (209).
|
|
// Expressed as a ratio because the floor is calibrated per session.
|
|
static constexpr std::int32_t MultiplierNum = 3;
|
|
static constexpr std::int32_t MultiplierDen = 2;
|
|
|
|
explicit Baseline(std::size_t samples = DefaultSamples) : want_(samples) {}
|
|
|
|
// Feed an idle capture. The floor is the MAX of the idle samples, not
|
|
// the mean: a floor that under-reads turns drift into false fingers.
|
|
void Observe(std::int32_t metric) {
|
|
if (seen_ < want_) {
|
|
floor_ = std::max(floor_, metric);
|
|
++seen_;
|
|
}
|
|
}
|
|
|
|
// A SECOND, higher bar: contact good enough to spend the press's one
|
|
// terminal verdict on. Detection at 1.5x answers "is a finger there";
|
|
// this answers "is it all the way down and still".
|
|
//
|
|
// Measured over every frame this project has a verdict for (2026-09-05,
|
|
// n=379), split at 2.5x the floor:
|
|
//
|
|
// full contact, interrupt settled 78/175 = 45% match
|
|
// full contact, interrupt asserted 28/142 = 20%
|
|
// partial, interrupt settled 1/9 = 11%
|
|
// partial, interrupt asserted 0/53 = 0%
|
|
//
|
|
// A partial frame is not a hard verdict waiting to happen, it is a
|
|
// WASTED one: with max_authentication_rescan_times at 0 every frame is
|
|
// terminal, so a partial frame's rejection ends the press. 62 partial
|
|
// frames produced exactly one match. Skipping them costs essentially
|
|
// nothing and saves 61 killed presses.
|
|
static constexpr std::int32_t SettledNum = 5;
|
|
static constexpr std::int32_t SettledDen = 2;
|
|
|
|
bool Ready() const { return seen_ >= want_ && floor_ > 0; }
|
|
std::int32_t Floor() const { return floor_; }
|
|
std::int32_t Threshold() const { return floor_ * MultiplierNum / MultiplierDen; }
|
|
std::int32_t SettledThreshold() const { return floor_ * SettledNum / SettledDen; }
|
|
bool IsSettled(std::int32_t metric) const {
|
|
return Ready() && metric >= SettledThreshold();
|
|
}
|
|
|
|
// Nothing is a finger until the floor is known. An uncalibrated
|
|
// Baseline reports false for everything rather than inventing a
|
|
// threshold.
|
|
bool IsFinger(std::int32_t metric) const {
|
|
return Ready() && metric >= Threshold();
|
|
}
|
|
|
|
private:
|
|
std::size_t want_;
|
|
std::size_t seen_ = 0;
|
|
std::int32_t floor_ = 0;
|
|
};
|
|
|
|
// ---- What to report to the trustlet -----------------------------------
|
|
|
|
enum class Mode { Enrol, Authenticate };
|
|
|
|
// Edge detection over the finger-present signal. Which events a frame
|
|
// produces depends on the mode, and the difference is not cosmetic.
|
|
//
|
|
// Enrolment mirrors stock: event 5 on the rising edge, event 6 on the
|
|
// falling one, and nothing in between. Every sample in the Android
|
|
// reference is "got finger touched" -> "enrolling group S" -> "got finger
|
|
// released", and "got image ready" never appears in the whole enrolment
|
|
// trace. Sending event 7 on every held frame instead feeds the algorithm
|
|
// near-duplicate images from a single press.
|
|
//
|
|
// Authentication does want event 7, which reaches the matcher
|
|
// unconditionally; event 5 only reaches it when device+0x10a8 is 1 or 2.
|
|
//
|
|
// Both are sent on the rising edge, and that is deliberate. On this device
|
|
// event 5 ALSO runs the matcher, so the rising frame produces two verdicts
|
|
// from one image (`rej rej`, `MATCH MATCH`) at ~300 ms each -- and an
|
|
// attempt to drop event 5 as redundant produced a run with zero matches
|
|
// across five finger frames, including a held press. Every match ever
|
|
// recorded came after an event 5 on the same press; "event 7 alone
|
|
// matches" had only ever been observed on held frames that followed one.
|
|
// Whether the touch event initialises the press in the trustlet is not
|
|
// known. It is not to be removed without a measurement that isolates it.
|
|
class TouchTracker {
|
|
public:
|
|
// Returns the events to report for this frame, in order.
|
|
//
|
|
// TWO thresholds, deliberately: a press STARTS on settled contact and
|
|
// ENDS on the finger leaving. A Schmitt trigger, and the reason is
|
|
// measured -- a frame taken while the finger is still arriving matches
|
|
// 0 times in 53, and at a rescan budget of 0 that rejection is terminal
|
|
// and ends the press. Starting the press on the settled frame instead
|
|
// spends the verdict on an image that can actually carry it. A press
|
|
// that never settles produces no event at all, which is an honest
|
|
// "try again" rather than a false rejection.
|
|
//
|
|
// `settled` defaults to `present` so enrolment, which has its own
|
|
// sample-quality gate inside the trustlet, is unchanged.
|
|
std::vector<Event> Observe(bool present, Mode mode) {
|
|
return Observe(present, present, mode);
|
|
}
|
|
std::vector<Event> Observe(bool present, bool settled, Mode mode) {
|
|
std::vector<Event> out;
|
|
bool finger = prev_ ? present : settled; // enter high, leave low
|
|
bool rising = finger && !prev_;
|
|
bool falling = !finger && prev_;
|
|
if (rising)
|
|
out.push_back(Event::FingerTouched);
|
|
// CANDIDATE 1, under measurement: on the rising edge of an
|
|
// authentication, the touch event alone. It does run the matcher
|
|
// here (the rising frame answered `MATCH MATCH` / `rej rej` with
|
|
// both events -- two verdicts from one image), and every recorded
|
|
// match followed a touch event, so this keeps what is known to
|
|
// matter and drops ~300 ms of duplicate work on the frame that
|
|
// decides every quick tap. Held frames still send image-ready.
|
|
// Labelled baseline before this change: 2/10 quick taps matched,
|
|
// 0/5 wrong-finger taps. If the rate drops, this comes out.
|
|
if (finger && mode == Mode::Authenticate && !rising)
|
|
out.push_back(Event::ImageReady);
|
|
if (falling)
|
|
out.push_back(Event::FingerReleased);
|
|
prev_ = finger;
|
|
return out;
|
|
}
|
|
bool FingerDown() const { return prev_; }
|
|
void Reset() { prev_ = false; }
|
|
|
|
private:
|
|
bool prev_ = false;
|
|
};
|
|
|
|
// ---- Enrolment progress -----------------------------------------------
|
|
//
|
|
// Read from the response, not the trustlet's log: do_enroll copies samples
|
|
// remaining into the response payload on the common path whether or not
|
|
// the sample was accepted, and the log starves exactly when a frame is
|
|
// accepted. `rem` counting down is the only reliable progress signal.
|
|
//
|
|
// "The common path" means do_enroll's common path -- and a finger-RELEASE
|
|
// event never enters do_enroll at all. Its response leaves the field
|
|
// untouched, so it reads 0, which is indistinguishable from "no samples
|
|
// remaining, you are finished". Taking that at face value ends an
|
|
// enrolment after one press and then calls SAVE_DATA on an algorithm
|
|
// holding no template, which answers -1.
|
|
//
|
|
// So a reading is only meaningful when it came from the event that runs
|
|
// the enrol path. The caller has to say so; there is no way to tell from
|
|
// the value.
|
|
class EnrolSession {
|
|
public:
|
|
// The total is common.max_enrolling_samples and is KNOWN from the
|
|
// config, not inferred. Inferring it from the first reading is off by
|
|
// one: `rem` is reported after the sample has been processed, so the
|
|
// first observation of a healthy enrolment is already 9, not 10, and a
|
|
// session that takes 9 as the total reports one fewer accepted sample
|
|
// than actually happened.
|
|
explicit EnrolSession(std::int32_t total) : total_(total) {}
|
|
|
|
void Observe(std::int32_t remaining, bool fromEnrolPath) {
|
|
if (!fromEnrolPath) return; // a release reports nothing
|
|
if (remaining < 0) return; // not populated at all
|
|
if (remaining > total_) return; // nonsense
|
|
if (!started_) {
|
|
// A first reading of 0 is an unpopulated field, not a finished
|
|
// enrolment: the count starts at the total.
|
|
if (remaining == 0) return;
|
|
remaining_ = remaining;
|
|
started_ = true;
|
|
return;
|
|
}
|
|
if (remaining > remaining_) return; // the count only ever falls
|
|
remaining_ = remaining;
|
|
}
|
|
bool Started() const { return started_; }
|
|
std::int32_t Remaining() const { return remaining_; }
|
|
std::int32_t Total() const { return total_; }
|
|
std::int32_t Accepted() const { return started_ ? total_ - remaining_ : 0; }
|
|
bool Complete() const { return started_ && remaining_ == 0; }
|
|
|
|
// fprintd wants a stage count up front. It is the trustlet's
|
|
// common.max_enrolling_samples, which the first response reveals.
|
|
std::int32_t Stages() const { return total_; }
|
|
|
|
private:
|
|
bool started_ = false;
|
|
std::int32_t total_ = 0;
|
|
std::int32_t remaining_ = -1;
|
|
// Set once the total is known; see the constructor.
|
|
|
|
};
|
|
|
|
// ---- Authentication accounting ----------------------------------------
|
|
//
|
|
// The rule this exists to enforce: only a terminal verdict is an attempt.
|
|
// A frame the matcher never saw, and a frame answering "not identified
|
|
// yet, attempts remain", are neither accepts nor rejects. Counting them as
|
|
// rejects is what turned an 8-for-8 run into an apparent 8-of-39.
|
|
//
|
|
// And the rate that means anything to a user is PER PRESS, not per frame.
|
|
// In the forced-terminal measurement 15 of 20 frames matched, but the five
|
|
// that did not fell inside presses that also matched, so every press
|
|
// succeeded. Quoting the frame rate describes the sensor before the retry
|
|
// mechanism built to absorb exactly those frames.
|
|
//
|
|
// A press can also end without a verdict at all: at the stock rescan
|
|
// budget a press whose frames all answered "not identified yet" simply ran
|
|
// out of frames. It is UNDECIDED, not failed, and counting it as a failure
|
|
// repeats the -11 mistake one level up — so presses are counted separately
|
|
// from presses that reached a verdict.
|
|
class AuthTally {
|
|
public:
|
|
void Observe(Verdict v, bool fingerPresent) {
|
|
switch (v) {
|
|
case Verdict::Match: ++match_; break;
|
|
case Verdict::Rejected: ++rejected_; break;
|
|
case Verdict::NotIdentifiedYet: ++notYet_; break;
|
|
case Verdict::MatcherNeverRan: ++neverRan_; break;
|
|
}
|
|
// A press is a contiguous run of frames with a finger present.
|
|
if (fingerPresent) {
|
|
if (!inPress_) {
|
|
inPress_ = true;
|
|
++presses_;
|
|
pressMatched_ = false;
|
|
pressDecided_ = false;
|
|
}
|
|
if (ta::IsTerminal(v) && !pressDecided_) {
|
|
pressDecided_ = true;
|
|
++pressesDecided_;
|
|
}
|
|
if (v == Verdict::Match && !pressMatched_) {
|
|
pressMatched_ = true;
|
|
++pressesMatched_;
|
|
}
|
|
} else {
|
|
inPress_ = false;
|
|
}
|
|
}
|
|
|
|
int Matches() const { return match_; }
|
|
int Rejections() const { return rejected_; }
|
|
int NotIdentifiedYet() const { return notYet_; }
|
|
int NeverRan() const { return neverRan_; }
|
|
|
|
// The denominator. Anything else over-counts attempts.
|
|
int TerminalFrames() const { return match_ + rejected_; }
|
|
|
|
int Presses() const { return presses_; }
|
|
// Presses that reached a verdict. This is the denominator for a
|
|
// per-press rate; Presses() includes ones that ran out of frames.
|
|
int PressesDecided() const { return pressesDecided_; }
|
|
int PressesMatched() const { return pressesMatched_; }
|
|
int PressesUndecided() const { return presses_ - pressesDecided_; }
|
|
|
|
// A session succeeded if any frame identified the finger.
|
|
bool Identified() const { return match_ > 0; }
|
|
|
|
private:
|
|
int match_ = 0, rejected_ = 0, notYet_ = 0, neverRan_ = 0;
|
|
int presses_ = 0, pressesDecided_ = 0, pressesMatched_ = 0;
|
|
bool inPress_ = false, pressMatched_ = false, pressDecided_ = false;
|
|
};
|
|
|
|
// ---- Session sequencing -----------------------------------------------
|
|
//
|
|
// The reference loop per frame, from the Android trace:
|
|
// QUERY_EVENT_STATUS, CAPTURE_IMAGE, REPORT_EVENT x n, QUERY_EVENT_STATUS
|
|
// Without REPORT_EVENT the trustlet never advances its state machine at
|
|
// all, which is what made SAVE_DATA return "Internal error" for weeks.
|
|
inline constexpr std::array<ta::Cmd, 2> FramePrologue = {
|
|
ta::Cmd::CaptureImage, ta::Cmd::ReportEvent,
|
|
};
|
|
|
|
// One sensor reset buys exactly one trustlet init: a second init in the
|
|
// same power cycle answers -205. So recovering a failed session means
|
|
// power-cycling the rail, not retrying the init.
|
|
enum class SessionState {
|
|
Cold, // sensor unpowered
|
|
Powered, // rail up, reset released, not yet initialised
|
|
Ready, // init chain done, templates loadable
|
|
Failed, // needs a power cycle, not a retry
|
|
};
|
|
|
|
inline bool NeedsPowerCycle(SessionState s) { return s == SessionState::Failed; }
|
|
inline bool CanInit(SessionState s) { return s == SessionState::Powered; }
|
|
}
|