diff --git a/frontend/index.html b/frontend/index.html
index 014579a..f8bf728 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1925,7 +1925,7 @@ function showDetail(msg, entry) {
| INS | 0x${(decoded.ins_hex || '').toUpperCase()} — ${decoded.ins_name} |
${catRow}${respRow}${trRow}
${p1p2}${p1}${p2}
- | P3 | 0x${hx(decoded.p3, 2)} — ${decoded.p3} bytes${lmNote} |
+ | P3 | 0x${hx(decoded.p3, 2)} — ${decoded.p3} bytes${decoded.p3_kind === 'le' ? ' · ' + t('Le (expected response length)') : decoded.p3_kind === 'lc' ? ' · ' + t('Lc (command data length)') : ''}${lmNote} |
${body}
${cmdRows(decoded.cmd)}
${responseRows(decoded.response)}
@@ -3386,7 +3386,7 @@ function onSearchChange() {
}
// ──────────────────── Versioning ────────────────────
-const PWA_VERSION = '2.7.8';
+const PWA_VERSION = '2.7.9';
const MIN_SERVER_VERSION = '2.3.0';
function parseVersion(v) {
@@ -3453,6 +3453,8 @@ const LANG_RU = {
'Close': 'Закрыть',
'Offset': 'Смещение',
'Record': 'Запись №',
+ 'Lc (command data length)': 'Lc (длина данных команды)',
+ 'Le (expected response length)': 'Le (ожидаемая длина ответа)',
'Before': 'Было',
'After': 'Стало',
'Show the change': 'Показать изменение',
diff --git a/frontend/sw.js b/frontend/sw.js
index 519fc62..fb65e99 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simtrace-analyser-v88';
+const CACHE = 'simtrace-analyser-v89';
const PRECACHE = [
'index.html',
diff --git a/frontend/tests/style.test.js b/frontend/tests/style.test.js
index 503e350..033d214 100644
--- a/frontend/tests/style.test.js
+++ b/frontend/tests/style.test.js
@@ -22,6 +22,11 @@ test('no per-element scrollbar overrides or thin default remain', () => {
assert.doesNotMatch(src, /::-webkit-scrollbar \{ width: 6px/);
});
+test('the P3 row labels Lc and Le', () => {
+ assert.match(src, /decoded\.p3_kind === 'le' \? ' · ' \+ t\('Le \(expected response length\)'\)/);
+ assert.match(src, /t\('Lc \(command data length\)'\)/);
+});
+
// ── trace topbar layout ────────────────────────────────────────────────
test('the view selector sits over the side panel at the panel width', () => {
diff --git a/pyproject.toml b/pyproject.toml
index b3b2d36..a2d31a6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "simtrace-analyser"
-version = "2.7.8"
+version = "2.7.9"
description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)"
readme = {file = "README.md", content-type = "text/markdown"}
license = {text = "GPL-2.0-or-later"}
diff --git a/simtrace_analyser/decode.py b/simtrace_analyser/decode.py
index 2fe8a51..9aa5329 100644
--- a/simtrace_analyser/decode.py
+++ b/simtrace_analyser/decode.py
@@ -3942,6 +3942,14 @@ def decode_message(raw_data, prev=None):
elif cmd_body_len < p3 and not (spec and spec.get('le')):
result['length_mismatch'] = {'kind': 'truncated', 'expected': p3, 'actual': cmd_body_len}
+ # P3 is the length of the command data (Lc) when the command carries data
+ # and the expected response length (Le) for a case-2 command — the two are
+ # otherwise easy to confuse in the detailed view.
+ if spec and spec.get('le'):
+ result['p3_kind'] = 'le'
+ elif cmd_body_len > 0:
+ result['p3_kind'] = 'lc'
+
if cmd_body_len > 0:
body = remaining[:cmd_body_len]
result['body'] = {'hex': body.hex(), 'size': cmd_body_len}
@@ -4020,14 +4028,23 @@ def decode_message(raw_data, prev=None):
'raw': body.hex().upper()}
else:
result['file'] = {'sfi': rec_sfi, 'unknown': True, 'ef': f'SFI {rec_sfi}'}
- elif ins == 0xA2 and prev and rec_sfi:
- # SEARCH RECORD with SFI → attach the target file name (the body
- # is a search pattern, not file data).
- fid = (prev.get('sfi_map') or {}).get(rec_sfi)
+ elif ins == 0xA2 and prev and (rec_sfi or prev.get('sel')):
+ # SEARCH RECORD: the body is a search pattern, not file data. An
+ # SFI addresses the file it names; without one the search runs in
+ # the current EF (TS 102 221 §11.1.7.1) — show the target like
+ # READ/UPDATE do.
+ sel = prev.get('sel') or {}
+ fid = (prev.get('sfi_map') or {}).get(rec_sfi) if rec_sfi \
+ else sel.get('fid')
if fid:
- result['file'] = {'fid': fid, 'ef': _file_name(fid, prev.get('df'))}
- else:
- result['file'] = {'sfi': rec_sfi, 'unknown': True, 'ef': f'SFI {rec_sfi}'}
+ if not rec_sfi and not _is_record_file(sel):
+ result['file'] = _stale_file_note(sel)
+ else:
+ result['file'] = {'fid': fid,
+ 'ef': _file_name(fid, prev.get('df'))}
+ elif rec_sfi:
+ result['file'] = {'sfi': rec_sfi, 'unknown': True,
+ 'ef': f'SFI {rec_sfi}'}
elif ins in (0xB0, 0xB2, 0xD6, 0xDC) and prev and prev.get('sel'):
offset = p1p2.get('offset', 0)
if ins in (0xB2, 0xDC) and not _is_record_file(prev['sel']):
diff --git a/simtrace_analyser/version.py b/simtrace_analyser/version.py
index 47467f4..cd3138d 100644
--- a/simtrace_analyser/version.py
+++ b/simtrace_analyser/version.py
@@ -1,3 +1,3 @@
"""Single source of truth for the SIMtrace Analyser version."""
-__version__ = '2.7.8'
+__version__ = '2.7.9'
diff --git a/tests/test_decode.py b/tests/test_decode.py
index 0e4310e..2dce4cd 100644
--- a/tests/test_decode.py
+++ b/tests/test_decode.py
@@ -1967,6 +1967,42 @@ class TestP1P2(unittest.TestCase):
r = decode_message(bytes.fromhex('0020008108' + '12' * 8))
self.assertEqual(r['p2']['name'], 'Second PIN Appl 1')
+ def test_p3_kind_lc_le(self):
+ # P3 is the command-data length (Lc) when the command carries data and
+ # the expected response length (Le) for a case-2 command.
+ from simtrace_analyser.decode import decode_message
+ self.assertEqual(decode_message(bytes.fromhex('00b000000a'))['p3_kind'],
+ 'le') # READ BINARY: Le
+ self.assertEqual(decode_message(
+ bytes.fromhex('00a40000023f00'))['p3_kind'], 'lc') # SELECT: Lc
+ self.assertEqual(decode_message(
+ bytes.fromhex('00a201040200006164'))['p3_kind'], 'lc') # pattern
+ # A zero-length SELECT carries neither (no data, no Le).
+ self.assertNotIn('p3_kind',
+ decode_message(bytes.fromhex('00a4000000')))
+
+ def test_search_record_target_file(self):
+ # A SEARCH RECORD without an SFI searches the current EF
+ # (TS 102 221 §11.1.7.1) — show the target like READ RECORD does.
+ from simtrace_analyser.decode import decode_message
+ prev = {'sel': {'fid': '4fa1', 'structure': 'linear fixed'},
+ 'df': '5f3a'}
+ r = decode_message(bytes.fromhex('00a201040200006164'), prev=prev)
+ self.assertEqual(r['file'], {'fid': '4fa1', 'ef': '4FA1'})
+ # An SFI-addressed search resolves through the SFI map…
+ prev['sfi_map'] = {2: '4f3a'}
+ r = decode_message(bytes.fromhex('00a201142c' + 'ff' * 44), prev=prev)
+ self.assertEqual(r['file'], {'fid': '4f3a', 'ef': '4F3A'})
+ # …and an unknown SFI stays marked as such.
+ prev['sfi_map'] = {}
+ r = decode_message(bytes.fromhex('00a201142c' + 'ff' * 44), prev=prev)
+ self.assertTrue(r['file']['unknown'])
+ self.assertEqual(r['file']['sfi'], 2)
+ # A record search on a transparent selection is a stale-selection note.
+ prev2 = {'sel': {'fid': '2fe2', 'structure': 'transparent'}, 'df': '3f00'}
+ r = decode_message(bytes.fromhex('00a201040200006164'), prev=prev2)
+ self.assertTrue(r['file'].get('stale'))
+
def test_manage_channel_p2(self):
r = decode_message(bytes.fromhex('0070000300'))
self.assertEqual(r['p1']['name'], 'Open channel')
diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py
index 47914a8..bddf362 100644
--- a/tests/test_pcap_import.py
+++ b/tests/test_pcap_import.py
@@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase):
class TestVersion(unittest.TestCase):
def test_package_version(self):
from simtrace2_pysniff import __version__
- self.assertEqual(__version__, '2.7.8')
+ self.assertEqual(__version__, '2.7.9')
def test_status_includes_version(self):
import os