Files
sigrok_iso7816_stream/AGENTS.md
T
catarrh 6e6f465841 v1.9.7: spec fixes, T=1/parity, EOF guards, dead code + rate scaling
The audit's P2 items:

- ODD_INS: add 0x73 MANAGE SECURE CHANNEL and 0x75 TRANSACT DATA
  (TS 102 221 Table 10.5); the tools/vs_reader.py mirror and the pinned
  test are updated.
- 9E/9F are SW1: ISO 7816-3 10.3.3 Table 11 makes every 9X value SW1.
  Remove the fabricated "extended-length procedure byte" that consumed
  SW2 as a length and lost the status word.
- T=1: EOF-guard every block read, bound the M-bit chain with
  T1_CHAIN_MAX, and drop the caller's duplicate chain loop -- it read the
  next block after every I-block (usually the peer's R-block) and merged
  the following I-block's INF into the packet.  R/S blocks emit nothing;
  the (possibly chained) I-block INF is the APDU payload.
- Parity: a packet containing a parity-error byte is emitted with
  GSMTAP_FLAG_BAD_FCS (the CHKSUM annotation was invisible downstream),
  with a log note when the exchange is otherwise clean.
- EOF guards: handle_atr (TA/TB/TC/TD, historical, TCK), handle_pps
  (request/response fields incl. PPS1-3) and the T=1 block parser return
  cleanly on a truncated capture instead of raising on None.
- Dead code: _edge_read was initialized True and never cleared, so the
  ATR-based T=0 procedure loop (58 lines) and the 1.5-ETU guard branch
  were unreachable.  Remove the flag, both dead paths and the branch;
  dedent the live loop (behavior unchanged).
- Samplerate scaling: _samples_for_us() replaces the hard-coded 16 MHz
  thresholds -- start-bit deglitch (0.125 us), min_high floor (0.25 us)
  and the ETU glitch floor (0.75 us).  At 16 MHz the values are
  unchanged.

Validation: 130/130 unit tests (13 new: 9E/9F SW1, parity BAD_FCS,
T=1 I/R/S blocks + M chain + truncations + one-block-per-step, PPS
truncations, truncated ATR TA, samplerate/ETU-floor vectors); the
10-trace suite is unchanged from v1.9.6 (identical APDU/CHKSUM/CONCAT/
BAD_FCS counts, RESULT: OK); compare_baseline.sh reports IDENTICAL.
VERSION -> 1.9.7.
2026-10-20 19:02:20 +03:00

1397 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md
## Policy: decoder changes must be validated against ALL example traces
Every change to `pd.py` must be followed by running the full test suite:
1. **Clear bytecode cache first** - stale `.pyc` files cause false results:
```bash
find . -name "__pycache__" -type d -exec rm -rf {} +
find . -name "*.pyc" -delete 2>/dev/null
```
2. **Run all traces in mid-session mode** (the decoder always runs this way;
captures
all APDUs, including cold-boot ATRs):
```bash
# Run decoder for each trace
for trace in test_8_raw16 test_7_raw16 xiaomi_phone_sample \
samsung_phone_sample \
xiaomi_mi_a1_coldboot_sample 4gmodem_coldboot_sample \
alcor_reader_sample \
samsung_A55_coldboot_new_cable samsung_A55_coldboot_new_cable_sim2 \
samsung_S21p_coldboot_new_cable samsung_S21p_coldboot_new_cable_sim2; do
# Run sigrok-cli and vs_reader as shown below
# Capture APDUs, GARBAGE count, and RESULT
done
```
3. **Acceptance criteria** (must ALL pass):
- **test_8 mid-session**: 56 APDUs, 28/37 reader exchanges (9 pre-capture), 0 GARBAGE, RESULT: OK
- **test_7 mid-session**: 65 APDUs, 37/37 reader exchanges, 0 GARBAGE, RESULT: OK
- **All smoke-test traces**: >0 APDUs in mid-session mode
- **No regressions**: GARBAGE count must not increase, no new BAD_FCS/payload mismatches
- **Unit tests**: 30/30 pass (`python3 tests/test_gsmtap.py -v`)
### Important: CLK signal behavior matters
- **Constant CLK** (test_7, test_8): Card reader keeps CLK running continuously
- Decoder can reliably sample DATA on each CLK edge
- Cold-boot re-arm handles warm resets
- **Gated CLK** (phone traces): Phone stops CLK when idle, restarts before next APDU set
- Decoder must handle CLK gaps and restart properly
- ETU auto-detection from DATA edges is critical
- This is the decoder's primary use case
- **Cold boot captured pre-power-up** (`*_coldboot_sample.sr`): VCC/RST are LOW
at t=0, capture spans the power-on (~17 s at 16 MHz), and the card emits its
ATR once RST deasserts
- Decoder must arm the ATR hunt when RST is still low at capture start
(see "Cold-boot re-arm") and not lock an ETU from pre-ATR power-up traffic
- It must decode the ATR `3b9f96801f...001e`
- `rst_detect=true`, `vcc_detect=true` for these fixtures
```bash
# --- test_8 ---
sigrok-cli -i examples/test_8_raw16.sr \
-P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:rst_detect=true:gsmtap_enable=false:pcap_file=/tmp/out.pcap \
-A iso7816 >/dev/null
python3 tools/vs_reader.py examples/test_8_reader.log /tmp/out.pcap
# --- test_7 ---
sigrok-cli -i examples/test_7_raw16.sr \
-P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:rst_detect=true:gsmtap_enable=false:pcap_file=/tmp/out.pcap \
-A iso7816 >/dev/null
python3 tools/vs_reader.py examples/test_7_reader.log /tmp/out.pcap
# --- xiaomi phone (gated CLK) ---
sigrok-cli -i examples/xiaomi_phone_sample.sr \
-P iso7816:clk=CLK:data=DATA:rst=RST:clock_option=native:protocol=T=0:gsmtap_enable=false:pcap_file=/tmp/out.pcap \
-A iso7816 >/dev/null
# --- samsung phone 1 (gated CLK, non-default F/D) ---
sigrok-cli -i examples/samsung_phone_sample.sr \
-P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:protocol=T=0:gsmtap_enable=false:pcap_file=/tmp/out.pcap \
-A iso7816 >/dev/null
# --- xiaomi_mi_a1_coldboot (starts pre-power-up) ---
# rst_detect/vcc_detect=true are required for these fixtures.
sigrok-cli -i examples/xiaomi_mi_a1_coldboot_sample.sr \
-P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:protocol=T=0:rst_detect=true:vcc_detect=true:gsmtap_enable=false:pcap_file=/tmp/out.pcap \
-A iso7816 >/dev/null
# --- 4gmodem_coldboot ---
sigrok-cli -i examples/4gmodem_coldboot_sample.sr \
-P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:protocol=T=0:rst_detect=true:vcc_detect=true:gsmtap_enable=false:pcap_file=/tmp/out.pcap \
-A iso7816 >/dev/null
# --- unit tests ---
python3 tests/test_gsmtap.py -v
```
**Always clear the Python bytecode cache before testing** — stale `.pyc`
files can cause the decoder to use old code, producing false results:
```bash
find . -name "__pycache__" -type d -exec rm -rf {} +
```
The acceptance criteria are:
**The decoder is NOT considered fully functional until ALL of these criteria are met.**
#### Ground truth
`test_7_reader.log` and `test_8_reader.log` are **trusted, complete APDU logs**
captured by the PC/SC reader SDK during the same recording session as the
sigrok trace. They contain every command the reader sent and every response
the card returned. They are the authoritative reference — the decoder output
must match them.
#### Success criteria (all must pass)
1. **test_8 and test_7 (reader-log traces):**
- **Every reader exchange must appear in the decoder output.**
For test_8: 28/37 reader exchanges are present in the pcap
(9 missing because they occurred before the capture started —
a trace-level limitation, not a decoder bug). For test_7: all
37/37 are present after the v1.1.10 deglitch fix (previously
only 10/37 due to ATR resets fragmenting the session).
- **0 garbage APDUs** in mid-session mode.
- **`RESULT: OK`** from `vs_reader.py`.
2. **All traces (including smoke-test):**
- **>0 APDUs** in mid-session mode for smoke-test traces.
- No increase in `CHKSUM ERROR`, `BAD_FCS`, payload mismatches, or
suspicious CLA counts.
3. **Every trace must be tested** (the decoder always runs mid-session — it
captures all APDUs, including cold-boot ATRs).
#### Known issues (must be fixed to meet criteria)
**T=0 framing produces incorrect APDUs.** The decoder's output now matches
the reader log for all reader exchanges:
- test_8: 28/37 reader exchanges are byte-identical in the pcap
(9 missing because they occurred before the capture started),
plus 17 extra CAT/proactive commands and 11 valid retries,
total 56 APDUs, 0 garbage.
- test_7: All 37/37 reader exchanges are byte-identical in the pcap,
plus 17 CAT/proactive commands, 8 ATRs, and 11 valid retries,
total 65 APDUs, 0 garbage.
Both traces show 0 garbage APDUs, 0 payload mismatches, and RESULT: OK.
**Root cause:** `_edge_read` was initialized to `True` and never set to
`False`, so the edge-read T=0 procedure loop always executed and the
ATR-based procedure loop was dead code. **FIXED (v1.9.7):** the flag and
the dead loop are removed. The edge-read path had two bugs:
1. **NULLs treated as command data (FIXED).** The first post-header byte
was checked for ACK; if it was NULL (0x60), it entered the case-3/4
branch and got appended as command data. Fixed by skipping NULLs
before the ACK check.
2. **"Read until SW" truncates GET RESPONSE (FIXED).** The ACK handler
read until it saw a byte in 0x6x/0x9x range, but FCP tag 0x62
matched this pattern, truncating GET RESPONSE to 1-2 bytes. Fixed
by using P3-bounded reads (read exactly P3 bytes then SW1 SW2) for
case-2 commands where P3 = Le.
#### CLA validation note
`plausible_cla()` (v1.9.3) accepts the higher logical channels:
- interindustry `0x00`-`0x1F` (channels 0-3 in b2b1) and `0x40`-`0x5F`
(`01xx xxxx`: channels 4-19 in b4..b1, no CLA-level SM; `0x50`-`0x5F`
are the legacy chaining variants);
- the UICC extended classes `'CX'`/`'EX'` (`0xC0`-`0xCF`/`0xE0`-`0xEF`,
channels 4-19, SM in b6 -- TS 102 221 Table 10.4a);
- `0x80`/`0x84`/`0xA0`/`0xB0` (0xB0 has no pinned-spec basis; kept for
backward compatibility).
Rejected: `0x20`-`0x3F` (ISO-reserved); `0x60`-`0x6F`/`0x70`-`0x7F` (the
SM-indicating extended classes -- `0x60`-`0x6F` overlaps printable ASCII
and the text byte `0x61` started a bogus frame on xiaomi_mi_a1_coldboot
when it was accepted); `0x81`-`0x83`/`0x85`-`0x8F`/`0xA1`-`0xAF` (the
`'8X'`/`'AX'` standard-channel variants for channels 0-3 other than the
legacy `0x80`/`0x84`/`0xA0` -- the FCP byte `0xA5` did the
same); `0x90`-`0x9F`, `0xD0`-`0xDF`, `0xF0`-`0xFF`. They are spec-coded
but cost frame-start precision; add any of them back when a real trace
requires it.
During test runs such a CLA is an error flag indicating the decoder
mis-framed the byte stream. The example traces carry `0x00`
(interindustry), `0x01` (logical channel 1 -- the SELECT-by-AID after
MANAGE CHANNEL opens it) and `0x80` (USIM/CAT); no trace uses channels 2-3
or the higher channels yet.
#### ISO 7816 APDU validation (enforced from v1.1.7)
The decoder validates every reassembled T=0 APDU with `validate_t0_apdu()`
before emitting it. Invalid APDUs are still packed into gsmtap but are
flagged with `GSMTAP_FLAG_BAD_FCS`.
Validation is purely structural (no content scanning of command/response
data), so the false-positive rate is very low. The checks are:
1. Length is at least 7 bytes (header + SW) and at most `MAX_TPDU_LEN` (271).
2. CLA byte is a structurally valid ISO 7816-4 / UICC class — see
`plausible_cla()` (v1.9.3): `0x00`-`0x1F`, `0x40`-`0x5F` (logical
channels 4-19), `0xC0`-`0xCF`/`0xE0`-`0xEF` (`'CX'`/`'EX'`), plus
`0x80`/`0x84`/`0xA0`/`0xB0`.
3. INS byte is plausible: not `0x00`, not in `0x6x`/`0x9x` (status-word
collision). Odd INS codes are accepted only as the defined alternate
variants (ISO 7816-4 Table 4 pairs: `'0F'`, `'21'`, `'87'`, `'A1'`,
`'B1'`, `'B3'`, `'C3'`, `'CB'`, `'D1'`, `'D7'`, `'DB'`, `'DD'`;
TS 102 221 AUTHENTICATE `'89'`). Other odd values stay rejected: a
mis-framed boundary exposes data bytes, and the text run `49 4D`
("IM") started a bogus frame on xiaomi_phone_sample when every odd INS
was accepted.
4. The penultimate byte (SW1) is in `0x61`–`0x6F` or `0x90`–`0x9F`.
`0x60` is explicitly invalid: it is the T=0 NULL procedure byte
(ISO 7816-3 §10.3.3 Table 11 marks SW1 as `'6X'` **except `'60'`**;
ISO 7816-4 enforces `'60'` as an invalid SW1), so a "NULL + real SW1"
pair must never be accepted as a status word.
The current validation deliberately does **not** enforce P3-based length
constraints, because a card may reject a command and send the status word
immediately even when P3 > 0.
#### v1.4.0: CONCAT detection (embedded exchanges, issue #1 gate)
A byte-drop mis-frame swallows several real T=0 exchanges into one
structurally "valid" packet: each swallowed header survives verbatim in the
data region, followed by its P3 data bytes and an interior status word.
Such packets pass `validate_t0_apdu()` but produce wrong APDUs downstream.
`embedded_exchanges()` (pd.py) scans the data region (`[5, n-9]`) for
embedded complete exchanges:
- header `CLA INS P1 P2 P3` where `INS ∈ EMBEDDED_INS` (commands that
actually occur on these cards: 12/14/20/44/70/88/A2/A4/B0/B2/B6/C0/C2/
CA/CB/D6/DC/F2) and `P3 ≥ 1`
- CLA valid, or the phantom byte-drop artifact `0x01` (corrupt `0x00`),
with a one-byte prefix slip tolerated when byte `0x01` precedes a real
header
- SW1 accepted in a 3-byte slack window after the nominal data end
(`d1 = s + 5 + p3`), from `EMBEDDED_SW1` (real status bytes that are not
also frequent data bytes), and the SW must be interior (`sw < n-2`, i.e.
more exchange data must follow it)
On a hit the decoder flags the packet `GSMTAP_FLAG_BAD_FCS` and logs
`CONCAT APDU: <k> embedded exchange(s) at <offsets>; packet <hex>`, so the
gate works live and offline. The detector is precision-tuned against all
10 example traces: 0 hits on clean traces (test_8/test_7/4gmodem), and it
flags every known corrupted family:
| Trace | APDUs | CONCAT hits |
|-------|-------|-------------|
| test_8 | 56 | 0 |
| test_7 | 65 | 0 |
| xiaomi_phone | 620 | 4 |
| samsung_phone | 924 | 5 |
| xiaomi_mi_a1_coldboot | 459 | 3 |
| 4gmodem_coldboot | 327 | 0 |
| A55_coldboot_new_cable | 386 | 3 |
| A55_coldboot_new_cable_sim2 | 375 | 2 |
| S21p_coldboot_new_cable | 380 | 3 |
| S21p_coldboot_new_cable_sim2 | 356 | 2 |
The concat packets are BAD_FCS-flagged on the phone traces, so their BAD_FCS
counts now equal the CONCAT counts (deliberate: those APDUs are genuinely
desynced). `vs_reader.py` reports the same detector; `capture_baseline.sh`
prints `CONCAT APDU count: N` per trace (grep of the `CONCAT APDU:` log line
only, so one line per packet). Acceptance: test_8/test_7/4gmodem must stay
0; phone-trace counts must not increase.
### v1.4.0: CONCAT gate — embedded-exchange detection (issue #1)
The decoder previously emitted byte-drop-mis-framed APDUs that swallowed
several real exchanges into one "valid" packet (the Samsung/xiaomi
"wrong APDU" family). Fragmentation is not representable in the
assembled bytes, so this is fixed as a detection gate, not a reframe:
- `embedded_exchanges()` scans the data region for embedded complete
exchanges (see "v1.4.0: CONCAT detection" under APDU validation).
- Hits are flagged `GSMTAP_FLAG_BAD_FCS` + logged `CONCAT APDU: ...`.
- `vs_reader.py` / `capture_baseline.sh` gain a `CONCAT` metric
(packet counts match the log lines exactly).
- Unit tests cover the tuned vectors (clean FCP/READ-RECORD/SELECT never
hit; U1/U4/record-burst/xiaomi swallows are caught).
- VERSION → 1.4.0. Baseline regenerated with the new CONCAT columns.
A proper *reframing* (re-splitting those packets and byte-dropping the
mis-frame at the source) is the next decoder step; until then the gate
makes the corruption visible in live and offline captures instead of
silently producing wrong APDUs.
### v1.6.0: ATR-hunt deadlock + idle-loop trap fixes
A live Samsung cold-boot capture that never emits a 372-rate ATR exposed two
defects in the ATR hunt (`state == 'FIND START'`):
1. **Deadlock.** `RST low at start` arms the hunt at the spec-default
`clock_skip=372` with `bit_samples=None`. The ETU-recovery routine
`_measure_etu()` is only reachable from the `DATA` state, which the hunt
can only enter after reading a valid `TS=0x3B/0x3F` (or a plausible
CLA/INS header) **at 372**. A card already running at a non-default F/D
(Samsung `F=512/D=32` -> 16 CLK/bit) produces none, so the hunt reads
idle `0xFF` forever and never reaches the ETU measurement -- a circular
dependency. This is why Samsung (non-default F/D) fails where
default-F/D devices succeed: not clock rate, but `F/D`.
2. **Trap.** The all-`0xFF` branch unconditionally reset `_atr_hunt_count=0`
and returned to `FIND START`, so the bounded fall-through to `DATA` was
unreachable. With RST re-arm also resetting the counter, the hunt could
spin forever.
Fixes:
- **Hunt-side ETU recovery.** `_recover_etu_in_hunt()` runs `_measure_etu()`
+ `_derive_clock_skip_from_etu()` from the hunt after the default-rate
hunt has already failed a few rounds, so a fast device is read at its
true rate. It reuses the DATA-path `_confirm_etu()` validation, so a
noise-locked ETU self-clears after 2 parity failures. No-op for normal
ATRs (parsed on hunt 1).
- **Bounded escape.** `_atr_hunt_failure_action(byte)` (pure, unit-tested)
returns `recover` / `retry` / `resume_data`. ETU recovery every 2 failed
rounds up to 3 times; then a hard bound (`_atr_fail_total >= 12`) or the
all-`0xFF` idle cap (3) falls through to the existing "resuming DATA
without synthetic ATR" path (clearing `bit_samples` so the DATA path
re-measures).
- **Persistent counters.** `_atr_fail_total`, `_hunt_etu_attempts`,
`_atr_idle_resets` are reset only on a successful ATR parse, **not** on
RST re-arm -- otherwise a phone that power-cycles RST while never sending
a valid ATR would starve the escape. Constants: `ATR_HUNT_ETU_ATTEMPTS=3`,
`ATR_HUNT_IDLE_RESETS=3`, `ATR_HUNT_FAIL_LIMIT=12`.
Verification: no regression on the full trace suite (test_8 56/0/OK,
test_7 65/0/OK, xiaomi 640, xiaomi_cold 473, 4gmodem 327, A55 407/395,
S21p 399/375, target S21p_coldboot 369, all `RESULT: OK`, 0 CONCAT);
54/54 unit tests (7 new hunt-escape regression tests). VERSION -> 1.6.0.
Known limitation: no on-disk fixture reproduces the exact live failure (all
real captures contain a 372 ATR), so end-to-end proof needs a re-captured
`.sr` from the failing phone; the unit tests cover the loop-escape logic.
### v1.9.7: spec fixes, T=1/parity, EOF guards, dead code + rate scaling
The audit's P2 items:
- **Odd INS set completed.** `0x73` MANAGE SECURE CHANNEL and `0x75`
TRANSACT DATA (TS 102 221 Table 10.5) join `ODD_INS`; the mirrored set in
`tools/vs_reader.py` and the pinned test are updated.
- **9E/9F are SW1.** ISO 7816-3 §10.3.3 Table 11: every `9X` value is
SW1. The fabricated "extended-length procedure byte" (it consumed the
next byte as a length, eating SW2 and losing the status word) is gone.
The second copy lived in the dead ATR-based loop removed below.
- **T=1 blocks.** `t1_parse_block()` EOF-guards every read (a truncated
block returns no half INF instead of raising) and bounds the M-bit chain
with `T1_CHAIN_MAX` instead of unbounded recursion. The caller's
duplicate "chain follow-up" loop -- it read the *next* block after every
I-block, usually the peer's R-block, and merged the following I-block's
INF into the packet -- is gone. R/S blocks emit nothing; the (possibly
chained) I-block INF is emitted as the APDU payload. No T=1 trace
exists in the corpus, so this is unit-tested only.
- **Parity flag.** A packet containing a parity-error byte is now emitted
with `GSMTAP_FLAG_BAD_FCS` (the CHKSUM annotation alone was invisible to
downstream tooling), with a log note when the exchange is otherwise
clean.
- **EOF guards.** `handle_atr()` (TA/TB/TC/TD, historical bytes, TCK) and
`handle_pps()` (request and response fields, including the optional
PPS1-3) return cleanly on a truncated capture instead of raising on a
None byte; the T=1 block parser likewise.
- **Dead code removed.** `_edge_read` was initialized `True` and never
cleared, so the "ATR-based" T=0 procedure loop (58 lines) never ran and
the 1.5-ETU guard branch was unreachable. The flag and both dead paths
are removed; the edge reader is the only T=0 framing reader (the
CLK-sync reader is selected by `_clock_skip_confident`). The live loop
was dedented, not rewritten -- the trace metrics are unchanged.
- **Samplerate scaling.** `_samples_for_us()` replaces the hard-coded
16 MHz thresholds: the start-bit deglitch (`_min_pulse_samples`; `None`
= 2 samples @ 16 MHz), the `min_high` floor (0.25 us) and the ETU glitch
floor in `_etu_candidate` (0.75 us). At 16 MHz the values are
unchanged; another capture rate now scales instead of breaking the
filters.
Validation: 130/130 unit tests (13 new: 9E/9F SW1 for both values, the
parity BAD_FCS flag, T=1 I/R/S blocks + M chain + two truncation cases
+ a one-block-per-step caller test + two PPS truncations, the truncated
ATR TA, the samplerate/ETU-floor vectors); the 10-trace suite is
unchanged from v1.9.6 (identical
APDU/CHKSUM/CONCAT/BAD_FCS counts, `RESULT: OK` everywhere);
`compare_baseline.sh` reports IDENTICAL. VERSION -> 1.9.7.
### v1.9.6: ETU lock validation, hunt recovery, resync + ATR acceptance
The audit's P1 findings. The validation helpers are unit-tested; the trace
suite stays at the v1.9.5 metrics.
- **ETU measurement validation (`_etu_candidate`).** The mid-session
estimator used to lock the inter-character guard as the ETU on all-zero
data: a 0x00 run gives 10-ETU LOW runs plus a short HIGH guard, so the
smallest recurring spacing is the guard (2x-3x the true ETU), and a lone
0x00 character's single 10-ETU spacing is 10x. A burst now needs at
least two usable spacings, and a burst whose minimum recurrence is a HIGH
guard with every other spacing >= 2x it is rejected -- the decoder waits
for a burst with bit transitions (this covers the 2-5 ETU guard range).
A failed measurement leaves `bit_samples` unset instead of committing the
old `clock_skip*3` guess.
- **Invalidation.** `_confirm_etu()` no longer counts a dropped frame (no
genuine start -> empty bits) as parity-OK, and a rejected measurement now
clears `bit_samples`, `_etu_measured` and `_clock_skip_confident` (the old
code kept sampling at the rejected rate).
- **Hunt ETU recovery.** `handle_atr()` pre-seeds `bit_samples` with the
default-rate guess while the recovery condition tested `bit_samples is
None`, so the v1.6.0 live-ETU recovery could never fire from the hunt.
The condition now keys off `_etu_measured` (a real DATA measurement); a
measured rate still suppresses the recovery.
- **`resync_idle()`** now actually waits: it used `wait({'skip': 0})` in a
loop (the same sample 48 times -> a no-op). It now waits on the DATA idle
level (plus RST/VCC edges, bounded) before the next peek.
- **ATR acceptance.** A DATA-state `0x3B`/`0x3F` is an ATR candidate only
before any APDU has been emitted (`_apdu_seen`); a parsed ATR is rejected
when any of its bytes had a parity error or when it is a bare TS+T0
(`3B 00`). A data run misread at a frame boundary used to be committed
as an ATR (a fabricated `3b80900000` appeared while developing this
change), forcing `clock_skip=372` for the rest of the capture. Real
warm-reset ATRs are parity-clean and commit as before.
- **Inverse convention (TS=0x3F).** The readers assumed an idle-LOW line
and a rising start bit; ISO 7816-3 7.1 keeps the line idle HIGH and the
start bit LOW in both conventions -- only the data polarity and order
change. `wait_data_falling`/`resync_idle` now use the HIGH idle for
inverse too, `get_bytes()` assembles the inverse data msb-first, and the
DATA state accepts `0x3F` alongside `0x3B`. The data readers already
inverted the bit polarity. A real inverse TS decodes as `0x03` through
the direct reader, so `ts_from_bits()` matches the raw ISO 7816-3 8.1
pattern (moments 1-4 LHHL, 5-7 LLL/HHH, 8-10 LLH) in both the hunt and
the DATA state instead of trusting the decoded byte. The readers exempt
a matching TS from the parity check: its moment 10 is fixed by the
pattern, and the inverse TS is legitimately odd over moments 2-10, so it
must not log a CHKSUM or reject the ATR. No inverse-convention capture
exists in the corpus, so this is spec-verified, not trace-verified.
Validation: 117/117 unit tests (18 new: the ETU candidate vectors, the
hunt-recovery seeded/measured cases, the resync level wait, the bare-ATR
and parity-ATR guards, the `_apdu_seen` gate both ways, the inverse
bit-order/start-edge/TS-pattern vectors); the 10-trace suite is unchanged
from v1.9.5 (identical APDU/CHKSUM/CONCAT/BAD_FCS counts, `RESULT: OK`
everywhere);
`compare_baseline.sh` reports IDENTICAL. VERSION -> 1.9.6.
### v1.9.5: T=0 framing — immediate SW, short responses, Le=256
The audit's P0 framing fixes, each pinned by a `TestDecodeStep` vector:
- **Immediate SW.** The first post-header byte is classified as a status
word before the ACK/data decision. ISO 7816-3 §10.3.3 lets the card
finish at the first procedure byte; a case-2 rejection (`6A82`, `6CXX`,
...) carries no ACK and used to be read as command data (the exchange
and the following one were silently consumed).
- **Short responses (Lr < Le).** The ACK path still reads the P3 bytes,
but if the next byte is not SW1 the buffer is split at the first
plausible SW1 followed by a plausible CLA, the tail is replayed into
`_replay`, and the packet is emitted with `BAD_FCS`. Previously the SW
scan discarded the real SW, merged the following exchanges into a
clean-looking packet, and at EOF lost the exchange silently. A short
read with no plausible split is emitted flagged and resynced.
- **P3=0 = Le=256.** For an outgoing command P3=0 requests 256 bytes
(§10.3.2). The response data used to be discarded; it is now read until
SW and kept. The read-until-SW ambiguity on data bytes that look like
SW1 remains (inherent to T=0 sniffing).
- **~INS ACK.** One data byte per ACK (Table 11) via the new
`_t0_read_response_data()` helper, instead of "all remaining".
- **Case 4.** ISO 7816-3 §12.2/Table 14 map case 4 to a separate GET
RESPONSE; the ACK path now appends any inline data instead of dropping
it (permissive extension; conforming cards send SW/61XY there).
Validation: 99/99 unit tests; the 10-trace suite is unchanged except
`xiaomi_mi_a1_coldboot` 473 -> 475 APDUs (two previously lost exchanges
recovered), 0 CHKSUM/CONCAT/BAD_FCS/GARBAGE, `RESULT: OK`; all other traces
identical; `compare_baseline.sh` reports IDENTICAL. VERSION -> 1.9.5.
### v1.9.4: decode_step/hunt unit coverage + hunt EOF guard
The v1.9.3 review's residual risk -- the new acceptance classes were only
proven by "no regression" on the example traces -- is closed with a
byte-replay harness, and the new coverage surfaced one latent defect:
- **`TestDecodeStep`** drives the real `decode_step()` DATA-state gate chain
with a scripted byte queue (`read_byte`/`peek_byte`/`_consume_first`
stubbed over `_replay` + the queue; the re-align/PPS/ATR/annotation sinks
stubbed; `reset()` provides the state). Pinned: `'CX'`/`'EX'` exchanges
frame and emit clean; `0x5F` (always eaten by the idle-noise gate before
v1.9.3) frames as a command; the `0x61` menu-text boundary byte is a
suspicious CLA with exactly one byte consumed; the invalid-INS one-byte
re-frame recovers a shifted SELECT into the intended packet; `0xFE` still
takes the idle-noise path.
- **`TestAtrHuntDetect`** drives `handle_atr()` the same way: `0xC4 0xA4`
and `0xC4 0x89` (the odd-INS acceptance) hand the header to DATA via
`_replay`; CLA `0x00` stays excluded; an invalid INS does not hand off.
- **Bug found and fixed:** the hunt's command-detect called
`plausible_ins(nxt)` with no None check -- a capture ending right after a
plausible CLA byte made `peek_byte()` return None and raised TypeError
(previously seen only by `decode()`'s crash protection).
`test_eof_after_plausible_cla_is_clean` pins the clean end-of-capture
return.
Validation: 95/95 unit tests; the new framing/hunt tests fail against
v1.9.2 for the right reasons (the acceptance is missing) and the EOF test
fails against v1.9.3 (the TypeError); full trace suite unchanged
(`compare_baseline.sh` IDENTICAL, baseline regenerated for the version +
test listing). VERSION -> 1.9.4.
### v1.9.3: higher logical channels in the CLA gate + defined odd INS codes
A trace with higher logical channels (4-19) or an odd-INS command could not
decode: the acceptance gates knew neither. Both are now spec-derived, with
the empirical precision of the mis-frame filter preserved.
- **Higher logical channels (4-19).** TS 102 221 §10.1.1 Table 10.4a codes
them `'01x0 xxxx'` (`0x40`-`0x4F` without SM, `0x60`-`0x6F` with SM in b6)
and `'11x0 xxxx'` (`0xC0`-`0xCF`/`0xE0`-`0xEF`, the `'CX'`/`'EX'`
classes) -- ISO 7816-4 §5.1.1 Table 3 is the same with b5 = chaining.
`plausible_cla()` now accepts the `'CX'`/`'EX'` classes alongside the
previous `0x40`-`0x5F`, so extended-channel commands (e.g. STATUS on
channel 4 = `0xC0`) and higher channels in the no-SM `'4X'` form decode.
Rejected after testing: `0x60`-`0x6F`/`0x70`-`0x7F` (the SM-indicating
interindustry classes) and the `'8X'`/`'AX'` channel variants
`0x81`-`0x83`/`0x85`-`0x8F`/`0xA1`-`0xAF`. They are spec-coded, but
they cost
frame-start precision on the mis-framed traces: `0x60`-`0x6F` overlaps
printable ASCII, and the menu-text byte `0x61` ('a') started a bogus
frame on xiaomi_mi_a1_coldboot whose P3 swallowed the following exchanges
(a CONCAT regression); the FCP byte `0xA5` did the same for `'AX'`.
Previously such stray bytes were discarded as "suspicious CLA" and the
decoder resynced; the wider classes turned them into headers. Add the
classes back when a real trace needs them.
- **Odd INS.** The gates rejected every odd INS code, so TS 102 221
AUTHENTICATE `'89'` (chunked authentication data) and the ISO 7816-4
Table 4 alternate variants mis-framed. `plausible_ins()` now accepts
exactly the 13 defined odd codes (`ODD_INS`: `0F 21 87 89 A1 B1 B3 C3
CB D1 D7 DB DD`) and still rejects the rest: accepting every odd value
let the text run `49 4D` ("IM") start a bogus frame on
xiaomi_phone_sample. The header check calls the same `plausible_ins()`
now (the duplicate inline condition is gone) and the stale ISO `§12.1.1`
citation was corrected to `§5.1.2`.
- **Idle-noise gate.** The 6+ set-bits discard runs before the CLA check,
so it would swallow the legitimately bit-dense classes (`0xCF`, `0xE7`,
`0xEB`, `0xED`-`0xEF`) and kept `0x5F` dead (`plausible_cla()` accepted
it, the gate always discarded it). The gate is now
`is_idle_noise_byte()`: only bytes no spec allows are idle noise; valid
CLAs take the normal header path.
- **CONCAT detector unchanged by design.** `EMBEDDED_CLA` no longer
derives from `plausible_cla()`; it is pinned to the empirically tuned 36
values (`0x00`-`0x1F` + `0x80`/`0x84`/`0xA0`/`0xB0`) in both `pd.py` and
`tools/vs_reader.py`, with a membership-pin test and a `pd.py` ↔
`vs_reader.py` mirror test -- the CONCAT scan runs
across payload bytes where the extended classes false-hit (the Alcor
`"40 70 70 61 15"` run).
- **Tests.** CLA accept/reject vectors (including the two regressing bytes
`0x61`/`0xA5` and the reserved ranges), extended-channel
`validate_t0_apdu`/`is_desynced` vectors, the odd-INS vectors, the
idle-noise vectors, the EMBEDDED_CLA membership pin, the `ODD_INS` set
pin and the `pd.py` ↔ `vs_reader.py` mirror test (75 -> 84 tests);
`tools/vs_reader.py` mirrors the predicate, `ODD_INS` and the pinned set.
Validation: all 10 traces identical to the v1.9.2 baseline (APDU counts,
CHKSUM/CONCAT/BAD_FCS/suspicious-CLA counts, `RESULT: OK` everywhere);
`compare_baseline.sh` reports IDENTICAL. The xiaomi CONCAT regressions the
wider sets produced were found by this loop and narrowed away before the
baseline was regenerated (version + test listing only). VERSION -> 1.9.3.
### v1.9.2: PPS CLK upshift + exact CLK-edge projection (new reader support)
Two timing bugs surfaced by `examples/alcor_reader_sample.sr`, a PC/SC reader
that changes the CLK frequency at PPS: 4.0 MHz at the ATR, 4.8 MHz after
(raise-to-raise 4.000 -> 3.333 samples at 16 MHz), with FI/DI still 512/32 =
16 CLK/bit. Before the fix the whole post-PPS session framed nothing.
- **Stale samples-per-clock after PPS.** The native/CLK-sync path assumed
"the CLK period is unchanged by PPS" and kept the period measured at the
ATR, so the post-PPS ETU was 16 x 4.0 = 64 samples instead of the real
16 x 3.333 = 53.33 -- every byte was mis-sampled. `handle_pps()` now sets
`_clk_remeasure_pending`; `_read_byte_clk()` consumes it in the first
post-PPS character (CLK is guaranteed to be running inside a frame),
re-runs `_measure_clock_period()` and accounts the measurement's 16 edges
as the first edges of the frame (`bit_samples` refreshed).
- **Edge-skip deficit on fractional periods.** `_wait_clk_rising()` used to
"skip (n - 2) cycles by samples, then take 2 edges". With a period that is
a fraction of a sample the skip under-covers by ~1 edge per call: simulated
against the real trace, 144 edges were consumed where 152 were requested,
so the samples slid ~0.5 ETU early by the parity bit -- in 513/513 CHKSUM
cases the "parity" sample read the last data bit. It now projects n
periods ahead of the edge it is sitting on, skips to (target - half a
period) and takes one edge: exact for any period, non-accumulating, one
wait() per call. Without a period estimate it waits each edge exactly.
- **CONCAT detector precision.** The Alcor GET RESPONSE payload (a
secured-packet byte run "40 70 70 61 15" + an SW-like byte 21 later)
false-hit the embedded-exchange scan through the structural 0x4x CLA that
`plausible_cla()` tolerates for resync robustness. The detector's CLA set
now excludes 0x4x/0x5x (no CAT traffic uses them; every tuned
true-positive vector uses 0x00/0x01/0x08/0x80), mirrored in
`tools/vs_reader.py` with a regression vector in `tests/test_gsmtap.py`.
Effect on the suite (baseline regenerated; APDU counts and `RESULT: OK`
unchanged everywhere, 0 CONCAT):
| trace | CHKSUM lines before | after |
|---|---|---|
| alcor_reader_sample (new fixture) | no framing at all (0 APDUs) | 2 (1 idle artifact), 48 APDUs |
| xiaomi_phone_sample | 7698 | 2 (1 event) |
| 4gmodem_coldboot | 7218 | 0 |
| samsung_S21p_coldboot_new_cable_sim2 | 394 | 0 |
| test_8 / test_7 / others | 0 | 0 |
The xiaomi/4gmodem/S21p_sim2 counts had been read as dirty-wire parity
noise -- they were this decoder bug. `tools/capture_baseline.sh` gains the
alcor fixture (local-only, like every `examples/` trace). 75/75 unit tests.
VERSION -> 1.9.2.
### v1.9.1: T=0 NULL is not an SW1 (fixes bogus "60 61" APDU endings)
The "scan for SW1" loops accepted any byte with high nibble `0x6` as a
status word, including the T=0 **NULL procedure byte `0x60`** (card busy /
waiting-time extension). A card that holds the terminal with NULLs while
busy was therefore mis-framed: on the phone/cold-boot traces the SELECT MF
wire flow is `00 a4 00 04 02` → ACK `a4` → command data `3f 00` → ~1.4 s of
NULL `60` → real SW `61 29`. The decoder took `60` as SW1 and the real
`0x61` as SW2, emitting `…3f00 6061` and leaving the real SW2 (`0x29`) on
the wire, where it was discarded as a suspicious CLA — and the bogus packet
even passed `validate_t0_apdu()` because `plausible_sw()` accepted `0x60`.
- `plausible_sw()` now accepts `0x61`–`0x6F` and `0x90`–`0x9F` only.
Authority: ISO 7816-3:2006 §10.3.3 Table 11 — "If the value is `'6X'`
or `'9X'`, **except for `'60'`**, it is a SW1 byte"; the note adds that
ISO 7816-4 enforces `'60'` as an invalid SW1. (UICC_SPECS.md §1.8
classes: normal `'9000'`/`'61XX'`; warning `'62XX'`/`'63XX'`; execution
error `'64XX'`–`'66XX'`; checking error `'67XX'`–`'6FXX'`.)
- Every SW1-detection site now uses `plausible_sw()`: the edge ACK scan,
the P3=0 direct-SW check and response scan, the case-4 response scan,
the case-3 SW1 check, the unexpected-byte scan, and the ATR path.
- NULLs met while scanning for the status word are logged as rate-limited
wait extensions via the new `_note_pre_sw()` (cards emit them in bursts,
so they must not flood the log or raise warnings); other non-SW bytes
keep their call-site reason.
- `tools/vs_reader.py::_plausible_sw()` mirrors the fix.
- Tests: `plausible_sw` vectors updated (`0x60` now rejected; accepts
start at `0x61`), plus a regression vector for the
`00 a4 00 04 02 3f 00 60 61` artifact (invalid SW1 / desynced).
The fix is count-neutral (the same bytes are consumed; only the SW bytes
change), so `tests/baseline.txt` differs only by the version line and the
new test listing. 74/74 unit tests. VERSION -> 1.9.1.
### v1.9.0: CLK frequency in RST events
RST line events (sub_type 0x10) now append the measured CLK frequency when
it is known: `[direction, level, flags, clk_hz_be32]`, with `flags` bit 0
(`LINE_EVENT_FLAG_CLK_HZ`) set and the frequency as a big-endian uint32 in
Hz (7-byte payload). An unknown rate keeps the 3-byte payload, so
consumers that read only the first two bytes (e.g. SIMtrace Analyser's
`decode_line_event`) are unaffected. The value is the existing 16-edge
average (`samplerate / _samples_per_clock`, see `_measure_clock_period`);
PPS changes F/D, not the CLK rate, so one measurement per session is
enough. VCC events (0x11) are unchanged. Purpose: consumers can compute
the actual data rate as `clk_hz × D / F` from the Fi/Di negotiated in the
ATR/PPS. Ordering: the RST deassert event usually precedes the ATR event
(the 10 ms hysteresis lands mid-ATR for full ATRs), but a short ATR can
invert that, so consumers take the rate from any RST event. Wire
extension is backward compatible; 73/73 unit tests; baseline regenerated
and IDENTICAL (it tracks no RST payload metrics). VERSION -> 1.9.0.
### v1.8.0: PPS emitted as standard request + response packets
The PPS exchange was previously emitted as a single packet under sub_type
0x02 (`GSMTAP_SIM_PPS_REQ`) carrying the request + response bytes
concatenated — a deviation from libosmocore's gsmtap.h. It is now split
into the two standard subtypes: 0x02 (`PPS_REQ`, raw request bytes, span
`ss..es_req`) and 0x03 (`PPS_RSP`, raw response bytes, span
`es_req..end`). Wireshark now dissects both halves, and SIMtrace Analyser
needs no change: its pcap importer already maps 0x03 to `pps` and
`_decode_pps()` parses any 0xFF-leading frame (the response frame also
starts with 0xFF). The PPS annotation and stderr log lines are unchanged.
`tests/baseline.txt` regenerated — the only differences were the version
line and the unit-test listing, no decode-metric changes. 68/68 unit
tests. VERSION -> 1.8.0.
### v1.7.1: accept EOFError as clean end-of-stream (newer libsigrokdecode)
Newer libsigrokdecode signals end-of-capture from `wait()` by raising
`EOFError("samples exhausted")`; the 0.5.3 build on the Linux dev box instead
returns `None` / raises `SystemError`, so this only showed up on Windows (a
newer libsigrokdecode). The framework explicitly accepts `EOFError` out of
`decode()` as normal termination (`instance.c`: "Termination with an
EOFError exception is accepted"), but our `decode()` caught it in the generic
`except Exception` handler, printing a traceback, logging `DECODE ERROR`, and
resyncing -- retrying up to 50 times and flooding the log (looked like a
crash) at the end of every capture.
- `decode()` and the five `except SystemError: raise` guards now use
`except (SystemError, EOFError): raise`, so the end-of-stream signal
propagates and libsigrokdecode terminates cleanly. The critical guard is
`_measure_clock_period()`, whose `try` wraps a `wait()` loop.
- 2 new unit tests assert `decode()` re-raises `EOFError` and `SystemError`.
- 67/67 unit tests. VERSION -> 1.7.1.
Deployment note: the Windows decoder is a git clone, so `git pull` there
(and deleting `__pycache__`) picks this up; the EOF error cannot be
reproduced against Linux 0.5.3.
### v1.7.0: spurious end-of-decode hardening + start.sh session resilience
A live capture could end without Ctrl+C (the session simply stopped). The
frontend symptom was a flood of `sr: session: sr_session_stop: session was
NULL`, but the decoder also had latent paths that could end `decode()` early
on an idle/busy live line. Fixed:
1. **`_at_eof()`.** `decode_step()` treated `samplenum == _last_sn` as EOF,
but a step can legitimately consume only queued bytes (`_replay` /
`peeked_byte`) without advancing the stream -- e.g. the v1.5.0 invalid-INS
re-frame. Such steps are now allowed up to 64 in a row; only a genuinely
stuck stream with nothing queued ends the decode.
2. **`_stall_check(track=False)`.** The idle-LEVEL wait in
`wait_data_falling()` returns immediately at the current sample whenever
DATA is already idle -- normal on a live idle line, not EOF. Stall
counting is now disabled for that wait (only `pins is None` means EOF);
sample-stall detection is kept for the start-bit EDGE wait.
3. **`resync_idle()` null guard.** It indexed `pins[self.DATA_IDX]` after
`wait()` with no `None` check; at stream end that raised `TypeError`. Now
sets `_eof` and returns.
**Effect:** `samsung_phone_sample.sr` had been halting at sample ~21.9M of
320M (1 APDU). It now decodes through sample ~293M to a true end: **939
APDUs**, `CHKSUM=176`. The extra 6 `CONCAT` packets are real mis-framed STK
bursts that were simply never reached before -- the known Samsung
de-concatenation family; the rest of the suite is unchanged.
**start.sh:** auto-restart is now the **default** (`--no-loop` disables), so
an intermittent FX2 USB stop reconnects instead of ending the session;
per-session pcap suffix (`.N.pcap`) so a restart never overwrites the
previous capture; and the repetitive `sr_session_stop: session was NULL`
teardown line is filtered from stderr (real decoder errors still pass).
Validation: test_8 56/0/OK, test_7 65/0/OK, xiaomi_phone 640, xiaomi_cold
473, 4gmodem 327, A55 407/395, S21p 399/375, target S21p 369 -- all
`RESULT: OK`, 0 CONCAT. 65/65 unit tests (10 new). VERSION -> 1.7.0.
### v1.5.0: invalid-INS one-byte re-frame (residual CONCATs → 0)
The last CONCAT family (4gmodem/A55_sim2/S21p_sim2) is now **reframed at
the source**, not just gated. All 10 traces report 0 CONCAT:
| Trace | APDUs | CONCAT hits |
|-------|-------|-------------|
| test_8 | 56 | 0 |
| test_7 | 65 | 0 |
| xiaomi_phone | 640 | 0 |
| samsung_phone | 924 | 0 |
| xiaomi_mi_a1_coldboot | 473 | 0 |
| 4gmodem_coldboot | 327 | 0 |
| A55_coldboot_new_cable | 407 | 0 |
| A55_coldboot_new_cable_sim2 | 395 | 0 |
| S21p_coldboot_new_cable | 399 | 0 |
| S21p_coldboot_new_cable_sim2 | 375 | 0 |
1. **One-byte re-frame on invalid INS.** When a 5-byte header window passes
`plausible_cla()` but the INS is unusable (`0x00`, `0x6x`/`0x9x`, or an
odd code outside the defined variants -- see the v1.9.3 note), the
window is almost always shifted by one
stray leading byte (e.g. a leftover SW2 read after a PACKETEND). The old
code silently dropped all 5 bytes plus resync (`_resync = True`), and when
`resync_idle()` gave up on a busy burst the next byte became a bogus CLA
whose P3 swallowed the whole dialog into one CONCAT packet. Now the
decoder drops **only** the leading byte (explicitly logged via
`_note_discard`), re-frames the remaining four via `_replay`, and lets the
next decode step start a fresh header at the old INS. Example (4gmodem):
the stray `0f` after the READ BINARY SW1 `91` re-frames `0f 00 a4 08 04`
→ `00 a4 08 04 02`, recovering the true SELECT `00 a4 08 04 02 2f 00 →
61 1c` verbatim.
2. **No-silent-discard invariant.** Every byte the decoder consumes without
emitting is now explicitly logged via `_note_discard(value, reason,
idle=...)`. `idle=True` discards (0x00/0xFF idle stretches, NULLs, stuck
low) are rate-limited to the 1st + every 256th; `idle=False` discards
(suspicious CLA, invalid-INS re-frame byte, non-SW bytes in read-until-SW
scans) always put an `ANN_WARN` and the reason. Idle-line noise,
suspicious-CLA, stuck-low, and idle-byte-in-header consumers all go
through `_consume_first()` (which pops `_replay` before `peeked_byte`) so
re-framed bytes cannot infinite-loop.
3. **`_consume_first()` semantics.** `read_byte()` pops `_replay.popleft()`
first and only falls through to the wire; `peek_byte()` returns
`_replay[0]` first. Neither updates `peeked_samplenum`, so the re-frame
branch explicitly restores `self.peeked_samplenum = es` (the first byte's
sample) before pushing the 4 remaining bytes — the next header annotates
from a valid sample number.
4. **Counters.** `reset()` initializes `_discard_total` / `_idle_discard_total`; every `_note_discard` line carries "discards so far: N" for session-level triage.
The CONCAT detector (`embedded_exchanges()`, v1.4.0) stays as a regression
gate and now reports 0 on every trace. VERSION → 1.5.0. Baseline
regenerated.
**Phase B status (v1.5.0).** Two edge-reader hardening fixes shipped, both
verified byte-neutral on all 10 traces (stored baseline unchanged):
- `_next_start_fall()` now rejects sub-ETU phantom falls (a genuine start
fall must stay LOW for >= half an ETU) — B2.
- `_read_byte_edges()` re-anchors forward on a spurious latch instead of
dropping the byte (a drop fabricates a 0x00 / shifts the boundary) — B1.
Root cause of the Samsung/xiaomi phone-trace concatenation (the B1/B2 family)
is fixed at the source by the v1.5.0 invalid-INS re-frame — the A55 burst
cases decode clean with 0 CONCAT. The residual 4gmodem/A55_sim2/S21p_sim2
family (leftover-SW2-shifted header) is also gone. The planned B3 (strict
positional genuine-start anchor in the no-idle resync path) directly
conflicts with the documented 0.5-vs-1.5-ETU guard trade-off for fast phone
ETUs and risks the Samsung de-concatenation; it needs a dedicated design +
live validation, not an incremental patch.
### v1.3.0: `starts_with_atr` option removed
The entire ATR-included mode was redundant: its only mode-specific logic was a
start-state bootstrap (`FIND START` vs `DATA`) and an early trust hint for
`clock_skip=372`, both of which mid-session decoding reproduces via the
warm-reset / cold-boot re-arms already handled in `state == 'DATA'`
(starting the hunt instead of a fixed initial state). Every trace in the
suite produces identical APDU/GARBAGE/CHKSUM results with the option removed.
Removed:
- The `starts_with_atr` decoder option (schema + the 6 branching sites in
`pd.py`: initial state, `_clock_skip_confident` bootstrap, the
`handle_atr` samplenum save/restore, the cold-boot re-arm guard, and the
ETU re-measure condition — now `self.bit_samples is None or
self._pps_speed_changed`).
- `--starts-with-atr` from `start.sh` (now warned as obsolete).
The decoder always starts in `DATA` and re-arms the ATR hunt as needed.
### v1.2.1: cold-boot capture support
A capture that starts pre-power-up (VCC/RST low at t=0) previously locked a
bogus ETU (`bit_samples=1548`, `clock_skip=369`) from pre-ATR power-up DATA
transitions in mid-session mode, so the real ATR never synced. Fixed in
`241b7ed`:
- **Priming.** First `decode_step` reads the initial RST/VCC levels instead of
defaulting `last_rst`/`last_vcc` to 0 (which fabricated a reset edge).
- **Cold-boot re-arm.** RST low at capture start → arm the hunt at the
spec-default rate (FIND START, `clock_skip=372`, no ETU lock).
- **Guarded deassert re-arm.** The 10 ms-hysteresis-confirmed deassert lands
mid-ATR on warm resets; it only re-arms when `state == 'DATA'` so it no
longer garbles the in-flight ATR read (test_8 mid-session CHKSUM 18→0,
test_7 ATR-included 2→0).
New fixtures `xiaomi_mi_a1_coldboot_sample.sr` / `4gmodem_coldboot_sample.sr`
decode their full ATR in both modes with 0 GARBAGE.
### v1.2.0: start-bit deglitch (Samsung de-concatenation)
Samsung phone traces with gated CLK and non-default F/D (16 CLK/ETU)
produced concatenated APDUs: multiple T=0 exchanges merged into a single
GSMTAP packet. The v1.1.9 fixes (turnaround idle handling, edge-list
deglitch) improved other phone traces but the Samsung trace still showed
concatenation because the CLK-synchronous reader's start-bit detector
could not use the edge-list deglitch filter.
Fix applied (v1.2.0):
- Add a 2-sample deglitch in `wait_data_falling()`: after the 1.5-ETU
minimum-high guard passes, skip 2 additional samples and re-check DATA.
If DATA returns to idle, the start bit was a glitch → continue hunting.
This eliminates 1-sample (62.5 ns) LOW glitches in inter-byte guard
times that caused byte-level misalignment in the CLK-sync reader.
Results:
- Samsung mid-session: 52 → 924 APDUs (de-concatenated), all valid ISO 7816
commands (SELECT, GET RESPONSE, READ BINARY, STATUS, STK envelope), 0 GARBAGE.
- test_7 ATR: 10 → 65 APDUs, all 37/37 reader exchanges now captured
(was 10/37 in v1.1.9 due to ATR resets fragmenting the session).
- test_8 ATR: 54 → 56 APDUs, 28/37 reader exchanges (unchanged — 9 missing
are pre-capture, not a decoder issue).
- Xiaomi mid-session: 620 APDUs (unchanged, no regression).
**Glitch rejection measurements** (`-l 5` session log):
| Trace | Glitches rejected | Notes |
|-------|-------------------|-------|
| test_8 (reader) | 6 | Constant CLK, minor noise/turnaround artifacts |
| test_7 (reader) | 14 | Constant CLK, more activity than test_8 |
| xiaomi (phone) | **0** | Edge-list `_deglitch_edges()` filters before `wait_data_falling` |
| samsung (phone) | **1041** | CLK-sync reader has no upstream deglitch, confirms FX2LP pull-up hypothesis |
Xiaomi shows 0 because the edge-list reader's `_deglitch_edges()` (0.2×ETU
threshold) removes sub-ETU pulses before they reach `wait_data_falling()`.
The CLK-sync reader has no equivalent filter, so Samsung catches every
glitch. The 6–14 glitches on reader traces are minor noise or turnaround
artifacts in the constant-CLK captures.
The 2-sample advance in `wait_data_falling` shifts `_start_fall` by 2
samples, but at Samsung's 16 CLK/ETU (~72 samples) this is negligible
(<3% of a bit period) and does not affect downstream byte reading.
**Design note: pulse threshold.** `_min_pulse_samples` used to be a
hard-coded 2 samples, tuned for the 16 MHz corpus (≈125 ns, within the
FX2LP pull-up rise time of 200–500 ns). Since v1.9.7 it is `None` by
default and scales from `samplerate` (`_samples_for_us(0.125)` → 2 at
16 MHz); a test or option may still override it (0 disables the check).
**Logging.** Each rejected start-bit glitch is logged with a running
count (`start-bit glitch rejected (#N)`) visible in the session log.
This helps diagnose whether the deglitch is too aggressive on a
particular capture — if the count is unexpectedly high, the 2-sample
window may be filtering real start bits.
### Proven data rates (all at 16 MHz sample rate)
| Trace | ETU (samples) | CLK/ETU | Mode |
|-------|---------------|---------|------|
| test_8 | ~43 | 372 (standard) | constant CLK, ATR+PPS |
| test_7 | ~43 | 372 (standard) | constant CLK, warm resets |
| xiaomi | ~53 | recovered from DATA | gated CLK, mid-session |
| samsung | ~72 | 16 (F=512/D=32) | gated CLK, non-standard F/D |
##### T=0 Procedure Bytes
| Byte | Meaning |
|------|---------|
| ACK (== INS or == ~INS) | Full data match: card acknowledges and expects all P3 data bytes |
| INS^0x01 or ~INS^0x01 | Single-byte match: card validates syntax but processes data one byte at a time |
| 0x60 | NULL: card busy, wait for next procedure byte (never an SW1) |
| SW1 (`0x61`–`0x6F` / `0x90`–`0x9F`) | Status word: exchange complete. ISO 7816-3 §10.3.3 Table 11 has no exception for `9X`: `9E`/`9F` are SW1 too (the v1.9.7 fix) |
##### CLA (Class) Byte — Coding and Logical Channels
Accepted (v1.9.3):
- `000x xxxx` (`0x00`-`0x1F`): first interindustry — SM in b4b3, chaining in
b5, logical channel **0–3** in b2b1.
- `01xx xxxx`: further interindustry — logical channel **4–19**
(b4..b1 = channel − 4). Accepted: `0x40`-`0x4F` plus the legacy
`0x50`-`0x5F` chaining variants (no CLA-level SM).
- UICC extended classes `'CX'`/`'EX'` (`0xC0`-`0xCF`/`0xE0`-`0xEF`):
channels 4–19 in b4..b1, SM in b6.
- Legacy `0x80`/`0x84`/`0xA0`/`0xB0`.
Rejected: `0x20`-`0x3F` (ISO-reserved); `0x60`-`0x6F`/`0x70`-`0x7F` (SM in
b6 — `0x60`-`0x6F` overlaps printable ASCII, and text bytes started bogus
frames on the mis-framed phone traces); the `'8X'`/`'AX'` standard-channel
variants `0x81`-`0x83`/`0x85`-`0x8F`/`0xA1`-`0xAF` (channels 0–3; same
trade-off);
`0x90`-`0x9F`, `0xD0`-`0xDF`, `0xF0`-`0xFF`.
Examples: `0x00` (standard), `0x01` (channel 1), `0x44` (channel 8),
`0x4F` (channel 19), `0xC0` (STATUS on channel 4, `'CX'` class),
`0x0C` (secure messaging).
##### INS (Instruction) Byte — Valid Ranges
- `0x00` is reserved/invalid.
- `0x6x`/`0x9x` collide with status words and are invalid.
- Odd INS codes are valid only as the defined alternate variants
(`0F` ERASE BINARY, `21` VERIFY, `87` GENERAL AUTHENTICATE,
`A1` SEARCH BINARY, `B1` READ BINARY, `B3` READ RECORD, `C3` ENVELOPE,
`CB` GET DATA, `D1` WRITE BINARY, `D7` UPDATE BINARY, `DB` PUT DATA,
`DD` UPDATE RECORD; TS 102 221 AUTHENTICATE `89`).
Common INS values: `A4` (SELECT), `B0`/`B1` (READ BINARY),
`D6`/`D7` (UPDATE BINARY), `B2`/`B3` (READ RECORD), `E2` (APPEND RECORD),
`20`/`21` (VERIFY), `82` (EXTERNAL AUTHENTICATE), `88`/`89` (AUTHENTICATE),
`C2`/`C3` (ENVELOPE: CAT), `C0` (GET RESPONSE).
##### Quick Validation Checklist
1. CLA is a valid class: `0x00`–`0x1F`/`0x40`–`0x5F` (interindustry, channels
0–3/4–19) or `0xC0`–`0xCF`/`0xE0`–`0xEF` (UICC extended) or
`0x80`/`0x84`/`0xA0`/`0xB0`
2. INS is not `0x00`
3. INS is outside the `0x6x`/`0x9x` status-word ranges
**Current smoke-test status:**
| Trace | APDUs |
|-------|-------|
| test_8 | 56 |
| test_7 | 65 |
| xiaomi_phone | 640 |
| samsung_phone | 924 |
| xiaomi_mi_a1_coldboot | 473 |
| 4gmodem_coldboot | 327 |
| A55_coldboot_new_cable | 407 |
| A55_coldboot_new_cable_sim2 | 395 |
| S21p_coldboot_new_cable | 380 |
| S21p_coldboot_new_cable_sim2 | 356 |
The baseline runs every trace through the decoder once
(intentional since v1.3.0: the `starts_with_atr` option was removed and the
decoder always runs mid-session — it captures all APDUs, including cold-boot
ATRs, and is the only mode that works on gated-CLK phone traces).
The four `samsung_*_coldboot_new_cable*_sim2` fixtures were captured with the
new short cable (30 mm wires, external pull resistors GND→VCC and VCC→DATA),
named channels `DATA/VCC/RST/CLK` on bits 0/2/4/5 at 16 MHz. All four decode
their full cold-boot ATR (`3b9f9580…` on SIM1, `3b9f9680…` on SIM2) with
0 GARBAGE / 0 BAD_FCS. S21p SIM2 carries 197 CHKSUM (dirty-wire parity
noise, BAD_FCS still 0); A55 SIM2 is clean (0 CHKSUM). The `_sim2` fixtures
are cropped to the power-on window (the 17–21 s of pre-power idle was
removed).
Cold-boot fixtures start **before power-up**
(VCC/RST low at t=0); the decoder must arm the ATR hunt when RST is still low
at capture start and decode the full ATR once the card wakes (~17 s in).
`l8star_coldboot_sample.sr` was captured in the same session but had a
dirty-wire tail (GARBAGE=1 / RESULT UNCLEAN) and was dropped from the suite.
**Note on v1.1.3 fix:** The decoder measures CLK period as an average instead of using the minimum recurring period. This correctly handles traces where CLK periods alternate (e.g., 4,3,3 pattern for ~5.33MHz CLK at 16MHz sample rate). The fix changed `_measure_clock_period()` to use `sum(spacings) / len(spacings)` instead of `_robust_min(spacings)`, giving accurate `spc` values like 3.333... instead of 3. This corrected the `_wait_clk_rising()` skip calculation, fixing the post-ATR byte alignment issue.
### GSMTAP wire-format extensions
The GSMTAP-SIM output uses the standard sub_types 0x00-0x03 from
libosmocore's gsmtap.h with standard payloads -- APDU, ATR, and the PPS
request/response as separate packets (see README.md "GSMTAP events" and the
comments in `gsmtap_stream.py`). It deviates in two documented ways:
0x10/0x11 (`RST_EVENT`/`VCC_EVENT`) are custom line-event subtypes, and
`GSMTAP_FLAG_BAD_FCS` writes flags into the header `res` byte, which
official gsmtap.h reserves. The `res`-byte carriage is this decoder's own
extension (upstream simtrace2 never sets `res`); the semantics mirror
simtrace2's per-message USB data flags. 0x10 RST events additionally
append the measured CLK frequency when known
(`[direction, level, flags, clk_hz_be32]`; see "v1.9.0" below).
### Versioning
The decoder version is defined in `pd.py` as `VERSION = '1.9.7'`.
The version is printed to the log on decoder startup.
### Testing after decoder changes
After any change to `pd.py`:
1. Clear bytecode cache:
```bash
find . -name "__pycache__" -type d -exec rm -rf {} +
```
2. Run all tests against all traces:
```bash
./tools/capture_baseline.sh > tests/baseline.txt
```
3. Compare against saved baseline:
```bash
./tools/compare_baseline.sh
```
4. If differences found, review:
- Capture APDU count must stay >0 for any trace that previously had APDUs
- GARBAGE count must stay 0 for test_8 (mid-session)
- RESULT must stay OK for test_8 (mid-session)
- CHKSUM ERROR count must not increase
- BAD_FCS / payload mismatches / suspicious CLA must not increase
- ETU values must match for mid-session traces
- No new INVALID Procedure Byte errors
5. Run unit tests:
```bash
python3 tests/test_gsmtap.py -v
```
6. Update `tests/baseline.txt` with new baseline if changes are intentional.
## What happened: the decoder rewrite attempt
We attempted a major rewrite of the ISO 7816 decoder (`pd.py`) with these
features:
1. **Edge-counting always** — removed the `native_fast` sample-skip bit
reader, making all bit reads count CLK edges per bit instead of skipping
`clock_skip * samples_per_edge` samples. The idea was drift-immunity.
2. **Signal degradation recovery** — a `degraded` flag tracking rolling
parity errors; when ≥ 8 of 64 frames fail parity, the decoder re-arms
the ATR hunt and flags APDUs with `GSMTAP_FLAG_BAD_FCS`.
3. **Clock-drift monitor** — every 128 bytes the decoder re-measures the
CLK period and re-anchors `samples_per_edge` if the median drifts > 2%.
4. **Fatal TCK check** — the ATR's TCK checksum mismatch was made fatal
(reject the ATR) instead of being logged as a warning.
5. **Synthetic ATR fallback** — after 8 failed ATR hunts, commit a
minimal T=0 ATR `[0x3B, 0x00]` and begin decoding, for mid-session
captures.
## Outcome
The rewrite introduced a regression: **35 garbage APDUs** on `test_8`
where the baseline had **0 garbage**. The root causes:
1. **Fatal TCK check** — the ATR in the example traces has a wrong TCK
(`got=f7 expected=80`) due to a bit error in the capture. The fatal
check rejected this real ATR, preventing the decoder from ever
reaching the DATA state.
2. **Signal degradation ATR re-arm** — the `degraded` flag triggered on
the capture's natural bit errors, re-arming the ATR hunt mid-session
and fragmenting the APDU stream.
3. **Stale `.pyc` cache** — the Python bytecode cache was not
regenerated after code changes, causing the running decoder to use
old code. This masked the real regression for several test runs.
The edge-counting change itself may also have contributed subtle
bit-sampling differences, but the TCK check and signal degradation were
the primary causes.
## Resolution
We reverted to the known-good baseline (`2055d1c`, the VCC/RST hysteresis
commit) and applied only three essential, minimal fixes on top:
1. **`hasT0`/`hasT1` init in `start()`** — initialize both to `False` for
`protocol=auto`, preventing `AttributeError` when `starts_with_atr=false`
enters DATA state without an ATR.
2. **`hasT0=False` guard in `decode_step` DATA state** — when no protocol
is known, skip byte reading and wait for a warm reset to trigger the
ATR hunt.
3. **Synthetic T=0 ATR fallback** — after 8 failed ATR hunts, commit
`[0x3B, 0x00]` and set `hasT0=True` so the decoder can begin
processing APDUs in mid-session captures.
The `native_fast` sample-skip, signal degradation, clock monitor, and
fatal TCK check were all discarded. The decoder is now the original
`2055d1c` code plus 24 lines of mid-session support.
## Current architecture (v1.1.1, post-ATR desync fixes)
The decoder uses a **CLK-synchronous bit reader** for native-CLK captures and
an edge-list DATA reader for the non-native clock modes. Bit timing comes
from **counting real CLK edges**, not from a sample-count estimate.
### Two bit readers (selected per burst)
| Reader | Timing source | Used when |
|--------|---------------|-----------|
| `_read_byte_clk()` | count `clock_skip` CLK rising edges per ETU, sample DATA at bit centres | native clock mode **and** `clock_skip` known (`_clock_skip_confident`) |
| `_read_byte_edges()` | DATA-edge reconstruction from `bit_samples` | `sample_as_clock`/`detect`, or mid-session before `clock_skip` is recovered |
### Key changes
1. **CLK-synchronous reader.** `_read_byte_clk()` counts `clock_skip` CLK
rising edges per ETU and samples DATA at the bit centres (1.5 … 9.5 ETU).
Exact for `0x00`/`0xff` (too few DATA transitions for the edge reader) and
deterministic, because `clock_skip` is protocol-defined (372 for the ATR,
FI/DI after PPS). Safe across Clock Stop: CLK never stops mid-character
and resumes before the next start bit. `_wait_clk_rising()` skips most of
each bit by samples then lands on the exact edge, so it is fast.
2. **No synthetic ATR.** The old `[3B, 00]` fallback fabricated a 2-byte ATR
from a stray `0x3B`/`0x3F` byte in command traffic (e.g. the FID `3F00` of
a SELECT) and destroyed the command. The hunt is bounded and never emits
a fake ATR.
3. **Command detection during ATR hunt.** A plausible CLA + valid INS is
replayed to the DATA state and decoded as a command instead of skipped as
"invalid TS" — this recovers a SELECT when the preceding ATR was missed.
4. **RST resets `clock_skip` to 372** on RST assert (a reset makes the card
send ATR at the default rate).
5. **PPS on the native path** just updates `clock_skip` from FI/DI (CLK
period is unchanged, so the new ETU is exact). No DATA re-measurement
burst is consumed — previously that silently dropped the first post-PPS
command.
6. **Mid-session `clock_skip` recovery.** `_measure_etu()` recovers
`bit_samples`; `_measure_clock_period()` recovers `_samples_per_clock`;
`_derive_clock_skip_from_etu()` sets `clock_skip = bit_samples /
_samples_per_clock` and marks it confident. If the CLK period cannot be
measured (fully gated CLK), the edge-list reader is used instead.
7. **Data handling.** Valid APDUs are emitted clean; ambiguous frames are
emitted flagged `GSMTAP_FLAG_BAD_FCS` (never silently dropped); genuine
idle-low all-zero noise remains suppressed.
### Testing
- `test_8` / `test_7`: **NOT YET PASSING.** 0 garbage, but decoder output
does not match reader log (T=0 framing groups exchanges incorrectly).
See success criteria above.
## Mid-session phone decode (legacy note, still applies)
For a live mid-session phone capture (`protocol=T=0`),
the decoder now:
- Recovers the ETU from the live DATA line via `_measure_etu()` (edge-list,
locked once) so a gated/stopped CLK at a fast ETU (e.g. ~53 samples) is
decoded instead of mis-read at the 372-clock default.
- Decodes bytes with the edge-list reader `_read_byte_edges` (the only
reader now), which is drift-immune.
- Runs the normal T=0 framing so each command+response exchange is emitted as
one APDU.
Validation: `test_8`/`test_7` 0 garbage in both modes; 12 unit tests pass;
phone 16M/24M decode to coherent APDUs with the STK text fully readable and
no `0xff`/`0xfc` garbage.
### T=0 case 3/4 command-data (RESOLVED)
Commands that carry command-data (e.g. `80 12 … d0`, P3=208) send those
P3 bytes on the wire **before** the card's first procedure byte. The
framing classifies the first post-header byte in this order (v1.9.5):
- **SW1** (`0x61-0x6F`/`0x90-0x9F`) → the card finished with SW1 SW2 at the
first procedure byte (ISO 7816-3 §10.3.3; a case-2 rejection such as
`6A82`/`6CXX` carries no ACK).
- **ACK (== INS)** → case 2/4: the terminal sent no command data. Read the
`P3` response bytes, then the SW. `P3=0` is Le=256 (§10.3.2): read the
response data until the SW. If the card returns **fewer** bytes than Le
(`6282` end of file), the buffer is split at the first plausible SW1
followed by a plausible CLA, the tail is replayed and the packet is
flagged BAD_FCS (v1.9.5; previously the SW scan discarded the real SW and
the following exchanges could be merged silently).
- **~INS** → byte-by-byte transfer: one data byte per ACK, read until SW.
- Otherwise → case 3/4: the byte is terminal command data; read `P3`
command-data bytes, then the card's procedure byte (ACK/SW; `9E`/`9F`
are SW1, not an extended length).
On ACK, append any inline response data until SW (ISO 7816-3 §12.2 maps
case 4 to a separate GET RESPONSE, so conforming cards send SW/61XY
here; the append is a permissive extension).
This consolidates case-3/4 exchanges (e.g. the `80 12`/`80 14` STK envelope
commands) into one C-APDU = header + command data, R-APDU = response + SW
APDU, instead of fragmenting them across frames.
P3-bounded reads are kept for case 2 (`Lr == Le`): the read-until-SW
experiment from v1.1.0 mis-identified FCP `0x62` as SW1 and was reverted.
Where the length is unknown (P3=0, case-4 inline data) the read-until-SW
ambiguity remains — a data byte that looks like SW1 is indistinguishable
on the wire.
## Decoder methods tried — reliability registry
A running log of decoder approaches, so future work does not re-litigate
discarded ideas. "Proven" = kept in `pd.py`; "Rejected" = do not re-add.
### Proven reliable (kept)
- **Sample-skip bit reader (`native_fast`).** Original reader; advances bit
timing with single sample-skip waits. Reliable for ATR-based captures
(`test_8`/`test_7`, 0 garbage). Keep for those.
- **ATR TS validation + resync.** Validate TS (`0x3B`/`0x3F`); resync on the
next fall if bogus. Reliable; stops a phantom start bit shifting the ATR
off-by-one.
- **Warm-reset re-arm (guarded).** RST deassert → re-arm the ATR hunt
(`FIND START`, `clock_skip` 372, cleared ETU) **only when `state == 'DATA'`**
(hunt genuinely idle). Deasserts that arrive while `FIND START`/`ATR` is in
progress just log "RST deasserted (hunt/ATR already in progress)" and leave
state alone — an unguarded mid-ATR re-arm corrupted the in-flight ATR read
(test_8 mid-session CHKSUM 18→0, test_7 ATR-included 2→0 after the guard).
Reliable; re-ATRs parse at the default rate.
- **Cold-boot priming.** One-shot on the first `decode_step`: `wait({'skip': 0})`
reads the *initial* VCC/RST levels instead of defaulting `last_rst`/`last_vcc`
to `0`. Without this, a capture that starts pre-power-up fabricated a spurious
reset at sample 0.
- **Cold-boot re-arm.** When the capture starts with RST **low** (VCC not yet
on), arm the hunt immediately regardless of start conditions: state
`FIND START`,
`_atr_hunt_count=0`, `clock_skip=372`, `bit_samples=None` (no ETU lock), so the
pre-ATR power-up DATA transitions cannot lock a bogus ETU (`bit_samples=1548`,
`clock_skip=369` before this fix). Logs "RST low at start: expecting cold-boot
ATR". Decodes the full cold-boot ATR on `xiaomi_mi_a1_coldboot_sample.sr` /
`4gmodem_coldboot_sample.sr` (0 GARBAGE).
- **Half-ETU start-bit guard (edge reader).** Rejects the phantom `0x00`
an idle glitch inserts before an ATR while still accepting the ~1-ETU
inter-byte gap on fast ETUs (v1.9.7 removed the unreachable 1.5-ETU
CLK-sync branch). Reliable.
- **VCC/RST 10 ms level-stability hysteresis.** Reports a level only after
≥10 ms stable. Reliable; symmetric power-up/down, kills spurious events
from a ringing/floating sense line.
- **PPS speed-switch follow (`clock_skip = F/D`).** After an accepted PPS the
native skip becomes F/D CLK cycles/bit. Reliable (verified live).
- **T=1 block decode (v1.9.7).** NAD/PCB/LEN/INF/LRC with the M-bit chain
followed inside `t1_parse_block()` (bounded by `T1_CHAIN_MAX`) and every
read EOF-guarded; R/S blocks carry no INF and are not emitted. Unit
tested only (no T=1 capture in the corpus). Reliable by construction.
- **Hunt give-up (no synthetic ATR).** After `ATR_HUNT_FAIL_LIMIT` failed
hunts the decoder resumes DATA without fabricating an ATR (v1.6.0 removed
the old `[0x3B, 0x00]` fallback); the mid-session ETU estimator then
recovers the rate from DATA. Reliable.
- **ETU auto-detect from DATA via edge counting (`_measure_etu`).** Edge-waits
to the first DATA start-bit fall, measures ETU from DATA-edge spacing, locks
it **once**. Reliable for gated/stopped CLK (phone, ~53 samples). Locking
once (not re-measuring) is drift-immune and avoids glitch re-anchors. A
burst must yield >=2 usable spacings, and a guard-dominated burst (all-zero
data: long LOW runs + short HIGH guard) is rejected (`_etu_candidate`,
v1.9.6); a rate that fails parity is invalidated and re-measured.
- **Edge-list bit reader (`_read_byte_edges`).** Per-byte edge reconstruction
from the DATA edge list (re-anchors the next byte via `_next_start_fall`).
Drift-immune. The T=0 framing reader; the CLK-sync reader is selected
when `_clock_skip_confident` is set (`_edge_read` removed in v1.9.7).
- **`decode_step` unified dispatch.** One state machine handles
ATR/PPS/DATA + mid-session. Reliable; current architecture.
- **`hasT0`/`hasT1` guards + init.** Prevents `AttributeError` and skips byte
reading until a protocol is known. Reliable.
- **T=0 procedure-byte classification (v1.9.5).** The first post-header
byte is SW1 ⇒ immediate status word; ACK (== INS) ⇒ case 2/4 (P3
response bytes, short-read split + BAD_FCS); ~INS ⇒ one byte per ACK;
else command data (case 3/4). Reliable for consolidating STK envelope
commands. P3-bounded reads avoid FCP tag misidentification (v1.1.1
reverted read-until-SW from v1.1.0).
- **Half-ETU start-bit guard in `wait_data_falling` (v1.9.7).** `min_high
= 0.5 * bit_samples` (with a rate-scaled floor) rejects intra-byte glitch
falls while accepting the ~1-ETU inter-byte gap; the next byte's start is
queued by the edge reader (`_next_start_fall`), so no 1.5-ETU guard is
needed on this path.
- **Inverse-convention support (TS=0x3F, v1.9.6).** The hunt already read
`0x3F`; the DATA state now accepts it too, the start bit stays a falling
edge from an idle-HIGH line (ISO 7816-3 7.1), and `get_bytes()` assembles
inverse data msb-first. Spec-verified; no inverse capture in the corpus.
- **`_resync = True` after PPS acceptance (native CLK path).** Forces
`resync_idle()` before the first post-PPS command, preventing the decoder
from latching onto a mid-byte transition at the new speed (v1.1.1 fix).
### Tried and rejected (do not re-add)
- **Edge-counting as the *sole* bit reader (remove `native_fast`
entirely).** Subtle per-bit sampling differences regressed `test_8` (35
garbage). Edge-counting is fine **as a second reader** (`_read_byte_edges`)
used only in mid-session mode; it is not a drop-in replacement for the whole
pipeline.
- **Signal-degradation ATR re-arm (`degraded` flag on ≥8/64 parity errors).**
Triggered on the capture's natural bit errors, re-armed the ATR hunt
mid-session, and fragmented the stream. Rejected.
- **Clock-drift monitor (re-measure CLK every 128 bytes, re-anchor
`samples_per_edge`).** Unneeded: locking the ETU once is sufficient and
safer. Rejected.
- **Fatal TCK check.** The example ATRs carry a wrong TCK (capture bit error,
`got=f7 expected=80`); fatal rejection blocked the DATA state entirely.
TCK must stay a non-fatal warning. Rejected.
- **CLA/P1P2 heuristic routing to T=1.** Misread real T=0 traffic as T=1.
Rejected.
- **`parse_packet` heuristic / APDU-boundary guessing from CLA/P1P2 + Le/ALG
tables.** Produced false splits and mis-framed exchanges. Rejected in favor
of proper T=0/T=1 transport state machines.
- **`ignore_rst` / `ignore_vcc` options.** Superseded by channel assignment +
hysteresis. Rejected.
- **Generic "read response until a `6x`/`9x` status word" for T=0 framing.**
Any `0x6x`/`0x9x` byte (e.g. command-data `0x6c`, FCP `0x6f`) was taken as
SW1, truncating/fragmenting exchanges and dropping most of the capture.
Rejected; replaced by the ACK==INS discriminator (case 2/4 bounded by
`P3`) with P3-bounded reads for all paths (v1.1.1 reverted the
read-until-SW experiment from v1.1.0 which mis-identified FCP `0x62` as
SW1).
- **0.5-ETU start-bit guard for the ATR-based CLK-sync loop.** It was
rejected in v1.1.1 for that path (it accepted intra-byte HIGH periods on
slow ETUs). The path was dead (`_edge_read` never False) and is removed
in v1.9.7; the surviving edge reader uses the 0.5 guard because its next
start bit is queued by `_next_start_fall` (no re-hunt mid-burst).
### Open residual limitations
- Mid-session captures that begin inside an exchange cannot frame the first
command perfectly (expected; the rest of the stream is clean).
- **Samsung phone — gated CLK, non-default F/D** (`samsung_phone_sample.sr`).
Uses F=512/D=32 (16 CLK cycles/bit). `_measure_etu` correctly measures
the ETU from DATA edge spacings (after glitch filtering). Now passes
with 0 GARBAGE and de-concatenated APDUs after v1.1.10 deglitch fix.
### v1.1.6 fix: bounded T=0 loops and explicit EOF handling
Tightened the edge-read T=0 procedure-byte handlers to prevent runaway
reads and to surface end-of-capture explicitly.
1. **Bounded all "read until SW" / turnaround-skip loops.** Replaced the
unbounded ACK-handler `while` and the open-ended `range(512)` loops with
`for _ in range(MAX_TPDU_LEN)`. Added EOF checks so the decoder returns
cleanly at end-of-capture instead of consuming zeros/turnaround bytes
indefinitely.
2. **`read_byte()` / `peek_byte()` return `None` on EOF.** Previously both
methods returned `0x00` when the sample stream ended, making it
impossible for callers to distinguish real data from padding. They now
return `None`; the T=0 DATA-state header and procedure-byte loops check
for `None` and exit the current exchange.
3. **Skip turnaround idle bytes before appending.** The P3=0 and case-4
ACK "read until SW" paths now mirror the v1.1.5 unexpected-byte fix:
non-SW bytes (including 0xFF turnaround artifacts) are skipped before
the status word is appended, rather than being added to the packet.
### v1.1.5 fix: turnaround idle bytes in edge-read path
Samsung phone traces with gated CLK had 5 garbage APDUs (BAD_FCS) in
mid-session mode. Root cause: two code paths in the edge-read T=0
procedure-byte handler did not skip turnaround idle bytes (0xFF) on the
bidirectional DATA line.
1. **ACK handler (case 2/4):** After reading P3 response bytes, the code
read 2 raw bytes as SW1 SW2. For case-3 commands (SELECT with data),
the terminal sends command data after ACK, then the DATA line idles
(0xFF) before the card sends SW. The idle 0xFF was mis-identified as
SW1. Fixed by adding a `while` loop that skips non-SW bytes before
reading SW1.
2. **"Unexpected byte" handler (procedure byte loop):** Non-SW bytes
(e.g. 0xFF turnaround) were appended to the packet before scanning for
SW, corrupting the SW position. Fixed by moving `packet.append(pb)`
inside the SW1 check so non-SW bytes are skipped.