evocode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
evocode/keys.py ADDED
@@ -0,0 +1,137 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Recover your own coding (NCD) or SecurityAccess (L3) key from your own PSdZData — locally.
3
+
4
+ One chain, keyed by the bootloader (BTLD) id read from the car:
5
+
6
+ "ncd" — coding signing key (Codier-Sign), from security/sec_ncdkeys.xml. (Re)signs an NCD.
7
+ "l3" — Level-3 SecurityAccess key, from security/sec_auth_l3.xml. Unlocks a write session.
8
+
9
+ Both are wrapped the same way: open the E-SYS PKCS#12 keystore in sec_security.lic with the derived
10
+ password (evocode.psdz); RSA-unwrap the AES session key named by the key file's encKeyRef from
11
+ sec_keyreference.xml; AES-128-CBC (IV=0) decrypt the BTLD's entry -> a PKCS#8 RSA private key. The
12
+ modulus is public (baked into the HU); the private exponent is the secret, written only to a local
13
+ file. Needs the `crypto` extra and nothing from BMW is embedded here.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import re
19
+ import xml.etree.ElementTree as ET
20
+ from pathlib import Path
21
+ from typing import Optional, Tuple
22
+
23
+ from . import psdz
24
+
25
+ KINDS = {
26
+ "ncd": {"file": "sec_ncdkeys.xml", "elem": "NCodSigKey", "use": "coding signature — (re)sign an NCD"},
27
+ "l3": {"file": "sec_auth_l3.xml", "elem": "AuthenticationKey", "use": "SecurityAccess — write unlock"},
28
+ }
29
+ _DIGESTS = {"MD5": "md5", "SHA-256": "sha256", "SHA256": "sha256", "SHA-1": "sha1"}
30
+
31
+
32
+ def _local(tag: str) -> str:
33
+ return tag.rsplit("}", 1)[-1]
34
+
35
+
36
+ def _bytes_of(s: str) -> bytes:
37
+ s = re.sub(r"\s+", "", s or "")
38
+ if re.fullmatch(r"[0-9a-fA-F]+", s) and len(s) % 2 == 0:
39
+ return bytes.fromhex(s)
40
+ return base64.b64decode(s)
41
+
42
+
43
+ def _unwrap_session_key(keyref_path: Path, esys_priv, ref_name: Optional[str]) -> bytes:
44
+ """RSA-unwrap the AES session key. Select by the key file's exact encKeyRef: every SessionKey is
45
+ wrapped to the same E-SYS key, so any of them 'decrypts' — picking the wrong one yields garbage."""
46
+ from cryptography.hazmat.primitives.asymmetric import padding
47
+ root = ET.parse(keyref_path).getroot()
48
+ entries: list = []
49
+ for sk in root.iter():
50
+ if _local(sk.tag) != "SessionKey":
51
+ continue
52
+ ref = next(((c.text or "").strip() for c in sk.iter() if _local(c.tag).lower() == "reference"), None)
53
+ kd = next((c.text for c in sk.iter() if _local(c.tag).lower() == "keydata" and (c.text or "").strip()), None)
54
+ if kd:
55
+ entries.append((ref, kd))
56
+
57
+ def unwrap(text: str) -> bytes:
58
+ k = esys_priv.decrypt(_bytes_of(text), padding.PKCS1v15())
59
+ return k if len(k) in (16, 24, 32) else k[-16:]
60
+
61
+ if ref_name:
62
+ for r, t in entries:
63
+ if r == ref_name:
64
+ return unwrap(t)
65
+ for r, t in entries:
66
+ if r and ref_name in r:
67
+ return unwrap(t)
68
+ for _r, t in entries: # last resort: first that unwraps sanely
69
+ try:
70
+ return unwrap(t)
71
+ except Exception: # noqa: BLE001
72
+ continue
73
+ raise KeyError("no session key could be unwrapped from %s" % keyref_path)
74
+
75
+
76
+ def _find_btld_entry(sec_path: Path, btld: str, elem: str) -> Tuple[bytes, dict]:
77
+ root = ET.parse(sec_path).getroot()
78
+ norm = btld.lower().lstrip("0") or "0"
79
+ for el in root.iter():
80
+ if _local(el.tag) != elem:
81
+ continue
82
+ text = ET.tostring(el, encoding="unicode").lower()
83
+ if ("btld-" + btld.lower()) in text or re.search(r"\b0*" + norm + r"\b", text):
84
+ kd = next((c for c in el.iter() if _local(c.tag).lower() in ("keydata", "key", "privatekey")), None)
85
+ if kd is not None and (kd.text or "").strip():
86
+ return _bytes_of(kd.text), kd.attrib
87
+ raise KeyError("no %s entry for BTLD %s in %s" % (elem, btld, sec_path))
88
+
89
+
90
+ def recover(psdzdata: Path, btld: str, kind: str, keystore_password: bytes) -> dict:
91
+ """Recover the key dict (schema matches the shipped ncd_key.json / l3_key.json)."""
92
+ if kind not in KINDS:
93
+ raise ValueError("kind must be one of %s" % sorted(KINDS))
94
+ from cryptography.hazmat.primitives.serialization import load_der_private_key, pkcs12
95
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
96
+
97
+ spec = KINDS[kind]
98
+ sec = Path(psdzdata) / "security"
99
+ lic, keyref, src = sec / "sec_security.lic", sec / "sec_keyreference.xml", sec / spec["file"]
100
+ for p in (lic, keyref, src):
101
+ if not p.exists():
102
+ raise FileNotFoundError("expected %s — is --psdzdata the psdzdata/ root?" % p)
103
+
104
+ esys_priv, _, _ = pkcs12.load_key_and_certificates(psdz.keystore_der(lic), keystore_password)
105
+ if esys_priv is None:
106
+ raise ValueError("no private key in sec_security.lic (derived keystore password rejected)")
107
+ ref_name = ET.parse(src).getroot().get("encKeyRef")
108
+ aes_key = _unwrap_session_key(keyref, esys_priv, ref_name)
109
+
110
+ body, attrib = _find_btld_entry(src, btld, spec["elem"])
111
+ dec = Cipher(algorithms.AES(aes_key), modes.CBC(b"\x00" * 16)).decryptor()
112
+ pt = dec.update(body) + dec.finalize()
113
+ if pt and pt[-1] <= 16: # PKCS#5 unpad
114
+ pt = pt[:-pt[-1]]
115
+ priv = load_der_private_key(pt, password=None)
116
+ nums = priv.private_numbers()
117
+ pub = nums.public_numbers
118
+ dattr = next((v for k, v in attrib.items() if _local(k).lower() == "digestalg"), None)
119
+ return {
120
+ "btld": btld.upper(),
121
+ "kind": kind,
122
+ "use": spec["use"],
123
+ "scheme": next((v for k, v in attrib.items() if _local(k).lower() == "signaturescheme"), "BMWSigVerify"),
124
+ "digest": _DIGESTS.get((dattr or "").upper().replace(" ", "-"), "md5"),
125
+ "test_key": str(attrib.get("testKey", "")).lower() == "true",
126
+ "public_exponent": pub.e,
127
+ "modulus_hex": "%x" % pub.n,
128
+ "private_exponent_hex": "%x" % nums.d,
129
+ }
130
+
131
+
132
+ def recover_from_install(psdzdata: Path, btld: str, kind: str, *, resource_jar: Optional[Path] = None,
133
+ logo: Optional[Path] = None, mini: Optional[Path] = None) -> dict:
134
+ """Derive the keystore password from the PSdZ resources, then recover the key."""
135
+ password = psdz.derive_password(Path(psdzdata) / "security" / "sec_security.lic",
136
+ resource_jar=resource_jar, logo=logo, mini=mini)
137
+ return recover(psdzdata, btld, kind, password)
evocode/l3.py ADDED
@@ -0,0 +1,39 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """SecurityAccess "L3" — turn a requestSeed into a sendKey response with your own recovered key.
3
+
4
+ The coding write (and flashing) unlock with this handshake: hash a block built from the seed, sign
5
+ it raw-RSA with your L3 private key, and wire-encode the result a particular way. The algorithm is
6
+ public; only the key is yours, and it is never shipped — you pass the ``l3_key.json`` you recovered
7
+ from your own PSdZData.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+
13
+
14
+ def _wordswap(b: bytes) -> bytes:
15
+ if len(b) % 4:
16
+ raise ValueError("length must be a multiple of 4")
17
+ return b"".join(b[i:i + 4][::-1] for i in range(0, len(b), 4))
18
+
19
+
20
+ def sign_seed(seed: bytes, key: dict, btld: int = 0x00001FFD, user_id: int = 0x00000000) -> bytes:
21
+ """Bytes to send after ``27 02``: ``MD5([UserID][Controller][seed])`` signed raw-RSA ``e=3``
22
+ (little-endian), as a big-endian 32-bit word count followed by the per-word-swapped signature.
23
+
24
+ ``key`` is a dict with ``modulus_hex`` and ``private_exponent_hex`` (public exponent defaults to
25
+ 3). ``btld`` is the module's bootloader id; ``user_id`` is the testerId sent with requestSeed
26
+ (0 matches the proven flow). Raises if the signature fails self-verification (wrong key).
27
+ """
28
+ if len(seed) != 8:
29
+ raise ValueError("seed must be 8 bytes, got %d" % len(seed))
30
+ n = int(key["modulus_hex"], 16)
31
+ d = int(key["private_exponent_hex"], 16)
32
+ e = int(key.get("public_exponent", 3))
33
+ block = user_id.to_bytes(4, "big") + btld.to_bytes(4, "big") + seed
34
+ m = int.from_bytes(hashlib.md5(block).digest(), "little")
35
+ s = pow(m, d, n)
36
+ if pow(s, e, n) != m:
37
+ raise ValueError("signature failed self-check — wrong L3 key?")
38
+ size = (n.bit_length() + 7) // 8
39
+ return (size // 4).to_bytes(4, "big") + _wordswap(s.to_bytes(size, "little"))
evocode/psdz.py ADDED
@@ -0,0 +1,171 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Derive the ``sec_security.lic`` keystore password from your own PSdZ install — nothing to type.
3
+
4
+ PSdZ never stores this password; it computes it from resources that ship in the BMW runtime, and so
5
+ do we (reproducing BMW's ``LicenseValidatorCrypto.genHashedPIN`` + ``ResourceParser``):
6
+
7
+ password = HEX_UPPER( SHA1( passphrase[pid] ) )
8
+
9
+ ``pid`` is an attribute of your own ``sec_security.lic``. ``passphrase[pid]`` comes from a ``<pplist>``
10
+ hidden in two BMW logo PNGs in your PSdZ runtime's ``resource.jar``: ``bmw_logo.png`` carries the
11
+ AES-CBC ciphertext, ``bmw_mini_logo.png`` carries the AES key — both steganographically, in the low
12
+ two bits of every pixel. This module embeds none of it: point it at your own ``resource.jar``.
13
+
14
+ The AES/PKCS parts need the ``crypto`` extra (``pip install 'evocode[crypto]'``); the PNG/stego
15
+ decode is pure stdlib.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import struct
22
+ import xml.etree.ElementTree as ET
23
+ import zipfile
24
+ import zlib
25
+ from pathlib import Path
26
+ from typing import Dict, Optional, Tuple
27
+
28
+ LOGO = "bmw_logo.png"
29
+ MINI_LOGO = "bmw_mini_logo.png"
30
+
31
+
32
+ # ---- a minimal 8-bit truecolor PNG decoder (avoids a Pillow dependency for two images) ----
33
+
34
+ def _paeth(a: int, b: int, c: int) -> int:
35
+ p = a + b - c
36
+ pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
37
+ return a if pa <= pb and pa <= pc else (b if pb <= pc else c)
38
+
39
+
40
+ def decode_png_rgb8(data: bytes) -> Tuple[int, int, bytes]:
41
+ """Return (width, height, rgb) for an 8-bit, non-interlaced, truecolor-RGB PNG."""
42
+ if data[:8] != b"\x89PNG\r\n\x1a\n":
43
+ raise ValueError("not a PNG")
44
+ pos, width, height, idat = 8, 0, 0, bytearray()
45
+ while pos < len(data):
46
+ (length,) = struct.unpack(">I", data[pos:pos + 4])
47
+ ctype = data[pos + 4:pos + 8]
48
+ chunk = data[pos + 8:pos + 8 + length]
49
+ if ctype == b"IHDR":
50
+ width, height, depth, color, _c, _f, interlace = struct.unpack(">IIBBBBB", chunk)
51
+ if (depth, color, interlace) != (8, 2, 0):
52
+ raise ValueError("expected an 8-bit non-interlaced RGB PNG (BMW logo format)")
53
+ elif ctype == b"IDAT":
54
+ idat += chunk
55
+ elif ctype == b"IEND":
56
+ break
57
+ pos += 12 + length # length(4) + type(4) + data + CRC(4)
58
+ raw = zlib.decompress(bytes(idat))
59
+ stride = width * 3
60
+ out, prev, p = bytearray(), bytes(stride), 0
61
+ for _ in range(height):
62
+ ftype = raw[p]; p += 1
63
+ line = bytearray(raw[p:p + stride]); p += stride
64
+ if ftype == 1: # Sub
65
+ for x in range(3, stride):
66
+ line[x] = (line[x] + line[x - 3]) & 0xFF
67
+ elif ftype == 2: # Up
68
+ for x in range(stride):
69
+ line[x] = (line[x] + prev[x]) & 0xFF
70
+ elif ftype == 3: # Average
71
+ for x in range(stride):
72
+ left = line[x - 3] if x >= 3 else 0
73
+ line[x] = (line[x] + ((left + prev[x]) >> 1)) & 0xFF
74
+ elif ftype == 4: # Paeth
75
+ for x in range(stride):
76
+ left = line[x - 3] if x >= 3 else 0
77
+ upleft = prev[x - 3] if x >= 3 else 0
78
+ line[x] = (line[x] + _paeth(left, prev[x], upleft)) & 0xFF
79
+ elif ftype != 0:
80
+ raise ValueError("unsupported PNG filter %d" % ftype)
81
+ out += line
82
+ prev = bytes(line)
83
+ return width, height, bytes(out)
84
+
85
+
86
+ # ---- the steganographic payload (BMW ResourceParser.processImg) ----
87
+
88
+ def extract_payload(png: bytes) -> bytes:
89
+ """Low 2 bits of R,G,B of every pixel (row 0 and column 0 skipped), packed three 2-bit groups to
90
+ one byte (+32 for printable ASCII), until '_'. The ASCII is a hex string; return its bytes."""
91
+ w, h, px = decode_png_rgb8(png)
92
+ tb: list = []
93
+ for row in range(1, h):
94
+ for col in range(1, w):
95
+ o = (row * w + col) * 3
96
+ tb.append(px[o] & 3)
97
+ tb.append(px[o + 1] & 3)
98
+ tb.append(px[o + 2] & 3)
99
+ chars = []
100
+ for i in range(0, len(tb) - 2, 3):
101
+ ch = chr((tb[i] | (tb[i + 1] << 2) | (tb[i + 2] << 4)) + 32)
102
+ if ch == "_":
103
+ break
104
+ chars.append(ch)
105
+ return bytes.fromhex("".join(chars))
106
+
107
+
108
+ def passphrases(logo_png: bytes, mini_logo_png: bytes) -> Dict[str, str]:
109
+ """{pid: passphrase} from the two logo PNGs (mini = AES key, full = AES-CBC ciphertext of pplist)."""
110
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
111
+ from cryptography.hazmat.primitives.padding import PKCS7
112
+
113
+ ciphertext = extract_payload(logo_png)
114
+ key = extract_payload(mini_logo_png)
115
+ if len(key) not in (16, 24, 32):
116
+ raise ValueError("unexpected AES key length %d from %s" % (len(key), MINI_LOGO))
117
+ dec = Cipher(algorithms.AES(key), modes.CBC(b"\x00" * 16)).decryptor()
118
+ padded = dec.update(ciphertext) + dec.finalize()
119
+ unpad = PKCS7(128).unpadder()
120
+ xml = unpad.update(padded) + unpad.finalize()
121
+ root = ET.fromstring(xml)
122
+ pps = {e.attrib["id"]: (e.text or "") for e in root.iter() if e.tag.endswith("passphrase")}
123
+ if not pps:
124
+ raise ValueError("no <passphrase> entries in the decoded pplist")
125
+ return pps
126
+
127
+
128
+ # ---- the .lic wrapper + password ----
129
+
130
+ def read_logos(resource_jar: Optional[Path], logo: Optional[Path], mini: Optional[Path]) -> Tuple[bytes, bytes]:
131
+ if logo and mini:
132
+ return Path(logo).read_bytes(), Path(mini).read_bytes()
133
+ if resource_jar:
134
+ with zipfile.ZipFile(resource_jar) as z:
135
+ names = z.namelist()
136
+
137
+ def grab(leaf: str) -> bytes:
138
+ hit = next((n for n in names if n.rsplit("/", 1)[-1] == leaf), None)
139
+ if hit is None:
140
+ raise FileNotFoundError("%s not in %s" % (leaf, resource_jar))
141
+ return z.read(hit)
142
+
143
+ return grab(LOGO), grab(MINI_LOGO)
144
+ raise ValueError("need --resource-jar (your PSdZ/bin/psdz/resource.jar) or both logo PNGs")
145
+
146
+
147
+ def parse_lic(lic_path: Path) -> Tuple[Optional[str], bytes]:
148
+ """sec_security.lic is ``<... pid="N">BASE64(PKCS#12)</...>``. Return (pid, pkcs12_der)."""
149
+ data = Path(lic_path).read_bytes()
150
+ try:
151
+ root = ET.fromstring(data)
152
+ except ET.ParseError:
153
+ return None, data # already a raw .p12
154
+ return root.attrib.get("pid"), base64.b64decode((root.text or "").strip())
155
+
156
+
157
+ def keystore_der(lic_path: Path) -> bytes:
158
+ return parse_lic(lic_path)[1]
159
+
160
+
161
+ def derive_password(lic_path: Path, *, resource_jar: Optional[Path] = None,
162
+ logo: Optional[Path] = None, mini: Optional[Path] = None) -> bytes:
163
+ """The PKCS#12 password for sec_security.lic: HEX_UPPER(SHA1(passphrase[pid])), as ASCII bytes."""
164
+ pid, _ = parse_lic(lic_path)
165
+ if pid is None:
166
+ raise ValueError("no pid attribute on %s" % lic_path)
167
+ logo_b, mini_b = read_logos(resource_jar, logo, mini)
168
+ pps = passphrases(logo_b, mini_b)
169
+ if pid not in pps:
170
+ raise KeyError("sec_security.lic pid=%s not among passphrases %s" % (pid, sorted(pps)))
171
+ return hashlib.sha1(pps[pid].encode("ascii")).hexdigest().upper().encode("ascii")
evocode/root.py ADDED
@@ -0,0 +1,76 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """OPT_DEBUG root-access enable/revert — starter.cfg edit and flash orchestration.
3
+
4
+ ``starter.cfg`` is a QNX ``starter``-format XML config (``/fs/sda0/opt/sys/etc/starter.cfg``).
5
+ Enabling root = flipping the ``OPT_DEBUG`` service from ``STOP`` to ``RUN``; reverting = the
6
+ opposite. The patched file is wrapped in a SWDL no-signature container and flashed via the
7
+ diagnostic channel (ENET/HSFZ → bootloader → L3 → transferData). See ``docs/root-access.md``.
8
+
9
+ The ``starter.cfg`` is **build-specific**: a wrong-version config fails the next boot. This module
10
+ takes the operator's own config as input — it never inlines a version-specific blob.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import socket
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ # The OPT_DEBUG line in starter.cfg — QNX starter XML:
20
+ # <Name>OPT_DEBUG</Name>\n <RequestState>STOP</RequestState>
21
+ _RE_STOP = re.compile(
22
+ r"(<Name>OPT_DEBUG</Name>\s*<RequestState>)STOP(</RequestState>)")
23
+ _RE_RUN = re.compile(
24
+ r"(<Name>OPT_DEBUG</Name>\s*<RequestState>)RUN(</RequestState>)")
25
+
26
+
27
+ def is_enabled(cfg: str) -> Optional[bool]:
28
+ """True if OPT_DEBUG is RUN, False if STOP, None if not found."""
29
+ if _RE_RUN.search(cfg):
30
+ return True
31
+ if _RE_STOP.search(cfg):
32
+ return False
33
+ return None
34
+
35
+
36
+ def patch(cfg: str) -> str:
37
+ """STOP → RUN. Raises if already RUN or OPT_DEBUG not found."""
38
+ state = is_enabled(cfg)
39
+ if state is True:
40
+ raise ValueError("OPT_DEBUG is already RUN")
41
+ if state is None:
42
+ raise ValueError("OPT_DEBUG service not found in starter.cfg")
43
+ return _RE_STOP.sub(r"\g<1>RUN\g<2>", cfg, count=1)
44
+
45
+
46
+ def unpatch(cfg: str) -> str:
47
+ """RUN → STOP. Raises if already STOP or not found."""
48
+ state = is_enabled(cfg)
49
+ if state is False:
50
+ raise ValueError("OPT_DEBUG is already STOP")
51
+ if state is None:
52
+ raise ValueError("OPT_DEBUG service not found in starter.cfg")
53
+ return _RE_RUN.sub(r"\g<1>STOP\g<2>", cfg, count=1)
54
+
55
+
56
+ # ------------------------------------------------------------------------------- status probe
57
+
58
+ STARTER_PATH = "/fs/sda0/opt/sys/etc/"
59
+ STARTER_NAME = "starter.cfg"
60
+
61
+
62
+ def check_ports(host: str, ports: tuple[int, ...] = (22, 23, 8000),
63
+ timeout: float = 2.0) -> dict[int, bool]:
64
+ """Probe whether OPT_DEBUG dev ports are open on the HU."""
65
+ result: dict[int, bool] = {}
66
+ for p in ports:
67
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
68
+ s.settimeout(timeout)
69
+ try:
70
+ s.connect((host, p))
71
+ result[p] = True
72
+ except (OSError, socket.timeout):
73
+ result[p] = False
74
+ finally:
75
+ s.close()
76
+ return result
evocode/srec.py ADDED
@@ -0,0 +1,98 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Motorola S-record (SREC) read/write for NBT EVO coding NCD files.
3
+
4
+ An NCD stores each coding datagroup's bytes at address ``(datagroup << 8) + offset``. BMW/E-Sys
5
+ export these as **S2** (24-bit address) data records, an S0 header, and an S5 record-count trailer;
6
+ others use S3 (32-bit) with an S7 end record. We accept S1/S2/S3 data and re-emit each with the same
7
+ address width, and we preserve the header and trailer records verbatim, so an edited file round-trips
8
+ byte-for-byte except where data changed. The signature is over the datagroup *content*, never the
9
+ record framing.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Dict, List, Tuple
14
+
15
+ Mem = Dict[int, int] # address -> byte
16
+ Chunk = Tuple[int, int, int] # (start address, length, address-byte-width)
17
+
18
+ _ADDR_WIDTH = {"1": 2, "2": 3, "3": 4} # data-record type -> address bytes
19
+ _TYPE_FOR_WIDTH = {2: "1", 3: "2", 4: "3"}
20
+
21
+
22
+ def _checksum(payload: bytes) -> int:
23
+ """S-record checksum: ones' complement of the low byte of the sum of count+address+data."""
24
+ return (~sum(payload)) & 0xFF
25
+
26
+
27
+ def parse(text: str) -> Tuple[Mem, dict]:
28
+ """Parse SREC text into a {address: byte} map plus the metadata needed to re-emit it."""
29
+ mem: Mem = {}
30
+ chunks: List[Chunk] = []
31
+ header = None
32
+ trailer: List[str] = []
33
+ for line in text.splitlines():
34
+ line = line.strip()
35
+ if len(line) < 4 or line[0] != "S":
36
+ continue
37
+ stype = line[1]
38
+ count = int(line[2:4], 16)
39
+ body = bytes.fromhex(line[4:4 + count * 2]) # address + data + checksum
40
+ if stype == "0":
41
+ header = line
42
+ elif stype in _ADDR_WIDTH:
43
+ w = _ADDR_WIDTH[stype]
44
+ addr = int.from_bytes(body[:w], "big")
45
+ data = body[w:-1] # strip trailing checksum
46
+ for i, b in enumerate(data):
47
+ mem[addr + i] = b
48
+ chunks.append((addr, len(data), w))
49
+ elif stype in ("5", "6", "7", "8", "9"): # record-count + end records
50
+ trailer.append(line)
51
+ return mem, {"header": header, "chunks": chunks, "trailer": trailer}
52
+
53
+
54
+ def _data_record(addr: int, data: bytes, width: int) -> str:
55
+ count = width + len(data) + 1
56
+ body = addr.to_bytes(width, "big") + data
57
+ return "S%s%02X%s%02X" % (_TYPE_FOR_WIDTH[width], count, body.hex().upper(),
58
+ _checksum(bytes([count]) + body))
59
+
60
+
61
+ def count_record(n: int) -> str:
62
+ """An S5 record stating the number of preceding data records."""
63
+ body = n.to_bytes(2, "big")
64
+ count = len(body) + 1
65
+ return "S5%02X%s%02X" % (count, body.hex().upper(), _checksum(bytes([count]) + body))
66
+
67
+
68
+ def emit(mem: Mem, meta: dict) -> str:
69
+ """Re-emit SREC text from a (possibly edited) mem, preserving address widths and trailer records."""
70
+ lines = [meta.get("header") or "S0030000FC"]
71
+ for chunk in meta["chunks"]:
72
+ addr, length = chunk[0], chunk[1]
73
+ width = chunk[2] if len(chunk) > 2 else 4
74
+ data = bytes(mem.get(addr + i, 0) for i in range(length))
75
+ lines.append(_data_record(addr, data, width))
76
+ lines.extend(meta.get("trailer") or ["S70500000000FA"])
77
+ return "\n".join(lines) + "\n"
78
+
79
+
80
+ def pages(mem: Mem, datagroups) -> Dict[int, bytes]:
81
+ """{datagroup: contiguous bytes} for each requested datagroup present in mem (address = dg<<8)."""
82
+ out: Dict[int, bytes] = {}
83
+ for dg in datagroups:
84
+ base = dg << 8
85
+ run = bytearray()
86
+ i = 0
87
+ while (base + i) in mem:
88
+ run.append(mem[base + i])
89
+ i += 1
90
+ if run:
91
+ out[dg] = bytes(run)
92
+ return out
93
+
94
+
95
+ def set_byte(mem: Mem, dg: int, off: int, val: int, mask: int = 0xFF, shift: int = 0) -> None:
96
+ """Edit one coding byte in place: mem[dg,off] = (old & ~mask) | ((val<<shift) & mask)."""
97
+ addr = (dg << 8) + off
98
+ mem[addr] = (mem.get(addr, 0) & ~mask) | ((val << shift) & mask)