Extractors
An extractor is the only piece of code that knows one ECU family. It answers two questions and nothing else: does this binary belong to my family? and what identity does it carry?
38 extractors cover 6 manufacturers: Bosch 19, Siemens 8, Magneti Marelli 4, Denso 4, Delphi 2, Hitachi 1.
The registry
openremap/core/manufacturers/__init__.py builds one flat list:
BUILTIN_EXTRACTORS: list[BaseManufacturerExtractor] = [
*bosch.EXTRACTORS,
*siemens.EXTRACTORS,
*delphi.EXTRACTORS,
*marelli.EXTRACTORS,
*denso.EXTRACTORS,
*hitachi.EXTRACTORS,
]
def get_extractors() -> list[BaseManufacturerExtractor]:
return BUILTIN_EXTRACTORS
Each OEM package owns an ordered EXTRACTORS list. Order matters: within a
family group the more specific extractor comes first, so a general pattern cannot
claim a binary that a precise one recognises. Adding support for a new family is
adding a module and one line to that list — no central registry to edit, no
if family == anywhere.
The contract — BaseManufacturerExtractor
An extractor implements two abstract methods and inherits everything else:
| Member | Kind | What it does |
|---|---|---|
name |
property (abstract) | the human-readable manufacturer, e.g. "Bosch" |
supported_families |
property | the family strings this extractor can produce |
can_handle(data) -> bool |
abstract | does this binary belong to me? also records evidence |
extract(data, filename) -> dict |
abstract | the identity fields, as a dict |
build_match_key(...) |
base class | builds VARIANT::IDENTIFIER from the extracted fields |
_set_evidence(tags) / last_detection_evidence |
base class | how can_handle() records why it matched |
_run_all_patterns, _search, _first_hit, extract_raw_strings |
base class | the byte-searching helpers every extractor uses |
The two-method split is deliberate: can_handle() must be cheap enough to run
against every binary, and extract() only runs for the extractor that claimed the
file.
Evidence, not a strength rating
Older extractors declared a static DetectionStrength — STRONG, MODERATE or
WEAK. That field is deprecated: a hand-assigned word is an opinion, and it
cannot be checked. Extractors now append evidence tags during detection, and the
confidence scorer turns the tag set into a bonus.
Standard tags, from base.py:
| Category | Tags |
|---|---|
| Structural | SIZE_MATCH, MAGIC_MATCH, HEADER_MATCH, POINTER_TABLE, LAYOUT_FINGERPRINT, BOOT_BLOCK |
| Identity | FAMILY_STRING, DETECTION_SIGNATURE, IDENT_BLOCK, FAMILY_ANCHOR |
| Cross-check | EXCLUSION_CLEAR, FILL_PATTERN, SYNC_MARKER, MANUFACTURER_CONFIRM |
The pattern is uniform:
def can_handle(self, data: bytes) -> bool:
evidence: list[str] = []
if len(data) == 0x400000:
evidence.append(SIZE_MATCH)
if b"MAG" in data[:0x200]:
evidence.append(MANUFACTURER_CONFIRM)
...
self._set_evidence(evidence)
return True
Every return False path calls self._set_evidence() with nothing, so a stale
tag list can never survive a failed detection. Extractors that have not been
upgraded yet return an empty tuple, and the scorer falls back to the old static
value — that is the migration path, not a separate mode.
Match keys
A match key is the identity of a software version, not of a car:
VARIANT::IDENTIFIER EDC17C66::1037541778126241V0
build_match_key() composes it from ecu_variant and the software version, with
one documented escape hatch: match_key_fallback_field. Some architectures never
store a software version (Bosch LH-Jetronic Format A is the example) — such an
extractor names another extracted field to use as the identifier. Software version
always wins when present; the fallback is only consulted when it is absent and
non-empty.
Why it matters: the apply ladder's Γ2 check is a match-key comparison. A match
key built from a truncated string can never match, which is why 0.9.0 fixed the
Bosch software-version pattern that read 1037541778126241V0 as
10375417781262.
The identification pipeline
| Step | Module | What happens |
|---|---|---|
| 1 | services/identify/identify_binary.py |
identify_binary(data, filename, container) — the entry point every consumer calls |
| 2 | manufacturers/scan.py |
runs every extractor's can_handle() and keeps the claims |
| 3 | services/identify/identifier.py |
identify_ecu() — asks the winner for extract(), builds the identity dict (_to_identity, or _unknown_identity when nobody claims the file) |
| 4 | services/identify/confidence.py |
score_identity() — tiers the result from signals |
| 5 | services/coherence.py |
cross-checks identity against checksums and code references: agree / gap / conflict |
When two extractors claim the same file, the batch scan calls it contested
rather than silently picking one — the classification vocabulary is scanned
(identified with a software version), sw_missing (family recognised, version
absent), contested, unknown, and unreadable.
Confidence scoring
score_identity() returns a ConfidenceResult with a tier and the
ConfidenceSignal list that produced it. Signals are additive and each one is
printable, which is why the CLI can show this:
Tier MEDIUM
Signal + detection evidence (2 tags: EXCLUSION_CLEAR, DETECTION_SIGNATURE)
Signal + canonical SW version
Signal + ECU variant identified (EDC17C66)
The scorer awards the evidence bonus from the tag set, a canonical-software-version
bonus per family profile, and ident-block cross-checks; it subtracts for
contradictions. Tiers map from the total: HIGH, MEDIUM, LOW, SUSPICIOUS,
UNKNOWN. Details of the tier semantics are on
confidence scoring.
Writing one
The contributor path is automated — openremap contribute extractor measures your
own dumps for a marker that is stable in your family and absent from every family
already supported, then writes the fixture, an extractor skeleton, a test and a PR
description. What travels is the pattern, never a measured value: a literal value
copied into can_handle() would match exactly one calibration.
See contribute for the workflow, and contributing for the review bar.