Skip to content

Commit c231dee

Browse files
dspl1236claude
andcommitted
Four features: search, stock comparison, real fonts, and a report
grep searches file *contents* across anything openable -- a partition, a firmware image, a persistence image, a disc, a folder -- because listing and reading both assume you already know which file you want, and most questions are the other way round. Text patterns are tried as UTF-8 and UTF-16LE, since firmware carries plenty of the latter, and hex patterns are there for hunting structures rather than words. It reuses diffimg's Side abstraction: searching and comparing both need paths plus a reader, so they share one. stock answers "what on this unit is not factory" in one step instead of three. The baseline lives in a .efs at ADR3000000 inside whichever release matches the car, which is tribal knowledge standing between someone and the answer. It now finds it -- and, because a disc carries fourteen of them, scores every candidate against the unit and uses the best fit rather than the first. On a test comparison the first-match answer was ARB300 and the right one was RDW400: 31 files identical versus 54, silently wrong in a way nobody would have questioned. Screen labels now draw at their real size. The chain is element -> mFontResID -> CFont -> mFontFormatResID -> CFontFormat.mHeight, and it resolved for zero elements until the cause turned up: a field's CUID is hashed with its class CUID as the seed, so the same field name hashes differently per class, and the two name tables that looked like they contradicted each other were describing different halves of the model. Merged, 2,583 elements resolve, the sizes form a real typographic scale, and the typefaces name themselves -- UCR____.TTF and VERAMONO.TTF, both sitting in IFS2. report writes one self-contained HTML page: what the image is, whether each partition read cleanly, what is not stock, and the bootscreens inlined as data URIs. Something to attach to a forum post instead of twelve screenshots. It carries the privacy warning, because a head-unit drive holds the VIN, saved destinations and call history, and a report is easier to share than a disk image. Two test fixtures were wrong again and are fixed rather than accommodated: a search context padded with NULs is correctly reported as hex, so it was testing the wrong branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 738c6df commit c231dee

7 files changed

Lines changed: 649 additions & 7 deletions

File tree

pcmexplorer/cli.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Command-line interface -- everything the GUI does, scriptable."""
22
import os
3+
import re
34
import sys
45

56
from . import version_string
@@ -47,6 +48,21 @@
4748
pcm-explorer <x.mmi> verify container self-check
4849
pcm-explorer <x.mmi> screens [root] drawables resolved to x/y/w/h
4950
51+
Search contents across any of the above:
52+
53+
pcm-explorer grep <target> <pattern> [part] which files contain this?
54+
pcm-explorer grep disk.img Burmester P2
55+
pcm-explorer grep PCM3_IFS1.ifs --hex 89504e47
56+
57+
One shareable HTML page about an image:
58+
59+
pcm-explorer report disk.img
60+
pcm-explorer report disk.img unit.html "D:/PCM/ISO Extract"
61+
62+
What is non-stock on a unit -- the baseline is found inside the disc for you:
63+
64+
pcm-explorer stock ./car_hbpersistence "D:/PCM/ISO Extract"
65+
5066
Compare any two of the above:
5167
5268
pcm-explorer diff <a> <b> what changed between them
@@ -453,6 +469,129 @@ def _hbm5_cmd(m, cmd, a):
453469
return 1
454470

455471

472+
def cmd_grep(argv):
473+
"""Search file contents across any openable artifact."""
474+
from .diffimg import open_side
475+
from .search import as_patterns, format_hits, search_side
476+
if len(argv) < 2:
477+
print("usage: grep <image|firmware|efs|disc|folder> <pattern> [part]")
478+
print(" --hex pattern is hex bytes, e.g. 89504e47")
479+
print(" -i case-insensitive")
480+
print(" --in STR only files whose path contains STR")
481+
return 1
482+
target, pattern = argv[0], argv[1]
483+
rest = argv[2:]
484+
mode = "hex" if "--hex" in rest else "both"
485+
ignore_case = "-i" in rest
486+
path_filter = None
487+
if "--in" in rest:
488+
i = rest.index("--in")
489+
if i + 1 < len(rest):
490+
path_filter = rest[i + 1]
491+
part = next((a for a in rest if re.match(r"^[PL]\d+$", a)), None)
492+
493+
try:
494+
side = open_side(target, part)
495+
patterns = as_patterns(pattern, mode, ignore_case)
496+
except Exception as e:
497+
print("could not search: %s" % e)
498+
return 1
499+
500+
print("searching %s (%s, %d files) for %r\n"
501+
% (side.label, side.kind, len(side.index), pattern))
502+
nf = nh = 0
503+
for path, hits in search_side(side, patterns, ignore_case, path_filter):
504+
print(format_hits(path, hits))
505+
nf += 1
506+
nh += len(hits)
507+
print("\n%d hit%s in %d file%s"
508+
% (nh, "" if nh == 1 else "s", nf, "" if nf == 1 else "s"))
509+
return 0 if nf else 1
510+
511+
512+
def cmd_report(argv):
513+
"""One shareable HTML page describing an image."""
514+
from .report import build
515+
if not argv:
516+
print("usage: report <image> [out.html] [update disc or .efs]")
517+
print(" A self-contained page: what the image is, whether it read")
518+
print(" cleanly, and -- with a baseline -- what on it is not stock.")
519+
return 1
520+
src = argv[0]
521+
out = argv[1] if len(argv) > 1 else os.path.splitext(
522+
os.path.basename(src))[0] + "-report.html"
523+
baseline = argv[2] if len(argv) > 2 else None
524+
try:
525+
path = build(src, out, baseline)
526+
except Exception as e:
527+
print("could not build the report: %s" % e)
528+
return 1
529+
print("wrote %s (%s)" % (path, human(os.path.getsize(path))))
530+
return 0
531+
532+
533+
def cmd_stock(argv):
534+
"""What on this unit differs from factory?"""
535+
from .diffimg import compare, find_baseline, open_side
536+
if len(argv) < 2:
537+
print("usage: stock <exported /HBpersistence folder> <update disc or .efs>")
538+
print(" Compares a unit against the factory baseline and reports what")
539+
print(" has been added, removed or changed. The baseline is found for")
540+
print(" you inside the disc.")
541+
return 1
542+
target, ref = argv[0], argv[1]
543+
try:
544+
mine = open_side(target)
545+
except Exception as e:
546+
print("could not open %s: %s" % (target, e))
547+
return 1
548+
549+
label, base = None, None
550+
if os.path.isdir(ref) or ref.lower().endswith(".iso"):
551+
try:
552+
label, base = find_baseline(ref, against=mine)
553+
except Exception as e:
554+
print("could not read the disc: %s" % e)
555+
return 1
556+
if base is None:
557+
print("no factory persistence image found in %s" % ref)
558+
return 1
559+
print("baseline: %s" % label)
560+
else:
561+
try:
562+
base = open_side(ref)
563+
except Exception as e:
564+
print("could not open %s: %s" % (ref, e))
565+
return 1
566+
567+
added, removed, changed, same, by_name = compare(base, mine)
568+
print("\n%s vs factory\n" % mine.label)
569+
print(" %-28s %d" % ("identical to factory", same))
570+
print(" %-28s %d" % ("modified", len(changed)))
571+
print(" %-28s %d" % ("added (not in factory)", len(added)))
572+
print(" %-28s %d" % ("missing (factory has it)", len(removed)))
573+
if by_name:
574+
print("\n matched on file name -- the flash image carries no directory")
575+
print(" structure, so same-named files in different folders merge.")
576+
577+
def block(title, rows, fmt):
578+
if not rows:
579+
return
580+
print("\n%s" % title)
581+
for r in rows[:30]:
582+
print(" " + fmt(r))
583+
if len(rows) > 30:
584+
print(" ... and %d more" % (len(rows) - 30))
585+
586+
block("MODIFIED", changed,
587+
lambda r: "%-44s %8d -> %d" % (r[0][:44], r[1], r[3]))
588+
block("ADDED", added, lambda r: "%-44s %8d" % (r[0][:44], r[1]))
589+
block("MISSING", removed, lambda r: "%-44s %8d" % (r[0][:44], r[1]))
590+
if not (changed or added or removed):
591+
print("\nNothing differs from factory.")
592+
return 0
593+
594+
456595
def cmd_diff(argv):
457596
"""Compare two readable things and report what differs."""
458597
from .diffimg import compare, format_report, open_side
@@ -487,6 +626,12 @@ def main(argv):
487626
# diff takes two operands rather than an image plus a verb
488627
if argv[0] == "diff":
489628
return cmd_diff(argv[1:])
629+
if argv[0] == "grep":
630+
return cmd_grep(argv[1:])
631+
if argv[0] == "stock":
632+
return cmd_stock(argv[1:])
633+
if argv[0] == "report":
634+
return cmd_report(argv[1:])
490635

491636
path = argv[0]
492637
# a directory is only meaningful as an extracted update-disc tree

pcmexplorer/diffimg.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,65 @@ def open_side(path, part=None):
129129
"partition")
130130

131131

132+
def find_baseline(disc_path, want="persistence", against=None):
133+
"""Locate the factory baseline for a comparison inside an update disc.
134+
135+
"What is non-stock on this unit" is the question people actually have, and
136+
answering it by hand means knowing that the factory ``/HBpersistence`` lives
137+
in a ``.efs`` at ``ADR3000000`` inside whichever release matches the car.
138+
That is three steps of tribal knowledge before the comparison even starts,
139+
so this does the finding.
140+
141+
Returns (label, side) or (None, None).
142+
"""
143+
disc = UpdateDisc(disc_path)
144+
files = disc.files()
145+
if want == "persistence":
146+
# A disc carries one baseline per release -- ARB, CHN, RDW, several
147+
# versions. Taking the first is a coin toss dressed as an answer, so
148+
# score each against the unit and use the best fit.
149+
import tempfile
150+
cands = [p for p in files
151+
if p.lower().endswith(".efs") and "hbpersistence" in p.lower()]
152+
best, best_side, best_score = None, None, -1
153+
for i, p in enumerate(cands):
154+
data = disc.read(p)
155+
if not data:
156+
continue
157+
tmp = os.path.join(tempfile.gettempdir(), "pcmx_baseline_%d.efs" % i)
158+
with open(tmp, "wb") as f:
159+
f.write(data)
160+
try:
161+
side = _from_entries(p.rsplit("/", 1)[-1], EfsImage(tmp),
162+
"EFS", flat=True)
163+
except Exception:
164+
continue
165+
if against is None:
166+
return p, side # nothing to score against
167+
_a, _r, _c, same, _bn = compare(side, against)
168+
if same > best_score:
169+
best, best_side, best_score = p, side, same
170+
if best_side is not None:
171+
label = best
172+
if len(cands) > 1:
173+
label = "%s (best of %d, %d files identical)" % (
174+
best, len(cands), best_score)
175+
return label, best_side
176+
# fall back to the update's own overlay, which is a subset
177+
overlay = [p for p in files if "/FIL/HBpersistence/" in p]
178+
if overlay:
179+
index, store = {}, {}
180+
for p in overlay:
181+
data = disc.read(p) or b""
182+
key = "/" + p.split("/FIL/HBpersistence/", 1)[1]
183+
index[key] = (len(data), _sha(data))
184+
store[key] = p
185+
return ("FIL/HBpersistence overlay",
186+
Side("update overlay", index,
187+
lambda k: disc.read(store[k]) or b"", kind="disc overlay"))
188+
return None, None
189+
190+
132191
def compare(a, b):
133192
"""Return (added, removed, changed, same, by_name).
134193

pcmexplorer/gui.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,22 @@ def show_screen(self, root=None):
108108
cv.create_rectangle(0, 0, DISPLAY_W, DISPLAY_H, outline=ACCENT)
109109
# biggest first, so small elements land on top of their containers
110110
drawn = 0
111-
for _rid, b, lab in sorted(boxes, key=lambda r: -(r[1][2] * r[1][3])):
111+
for rid, b, lab in sorted(boxes, key=lambda r: -(r[1][2] * r[1][3])):
112112
x, y, w, h, src = b
113113
if w <= 0 or h <= 0 or x > DISPLAY_W or y > DISPLAY_H:
114114
continue
115115
colour = ACCENT if "P" in src else DIM
116116
cv.create_rectangle(x, y, x + w, y + h, outline=colour)
117-
if lab and w > 26 and h > 12:
118-
cv.create_text(x + 3, y + 2, anchor="nw", text=lab[:24],
119-
fill=TEXT, font=("Consolas", 7))
117+
if lab and w > 20 and h > 10:
118+
# draw the label at the element's own font size, centred like the
119+
# unit would -- the metrics are in the file, so use them rather
120+
# than one fixed size that makes every screen look the same
121+
px = sc.font_px(rid) or 11
122+
px = max(6, min(px, h - 2))
123+
cv.create_text(x + w // 2, y + h // 2, anchor="center",
124+
text=lab[:40], fill=TEXT,
125+
font=("Segoe UI", -px),
126+
width=max(10, w - 4))
120127
drawn += 1
121128
ttk.Label(win, style="Dim.TLabel",
122129
text="%d of %d elements drawn %dx%d gold = position "

pcmexplorer/hbm5geom.py

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,27 +67,61 @@
6767
# Kinds seen on a position descriptor: a left/right anchor pair.
6868
ANCHOR_KINDS = (21, 22)
6969

70+
# A field's CUID is hashed with its *class* CUID as the seed, so the same field
71+
# name has a different hash in every class that declares it. These two groups
72+
# therefore coexist rather than conflict: the drawable hierarchy below, and the
73+
# resource classes it points at.
7074
CLASS_NAMES = {
75+
# drawables
7176
0x8E2F8293: "CDrawObject", 0x90AA9E70: "CGUIElement",
7277
0xA806B8EE: "CAligningObject", 0xA3C213B8: "CBitmapObject",
7378
0x71B5DB06: "CTextBase", 0x3EBA6425: "CTextObject",
7479
0xBA066794: "CFormattedTextObject", 0x77F80FB9: "CTextArea",
80+
0x2267FE33: "<CPoint>", 0x0AA3495D: "<CSize>",
81+
# resources
7582
0xDA63396B: "CDisplay", 0x92AE6BAD: "CColor",
7683
0x57E931C3: "CAlignment", 0xC6D7DD90: "CBitmap",
7784
0xE0D04503: "CFontFormat", 0x0B60D4CA: "CFontFile",
78-
0x5C8F9492: "CResourceTable", 0x25B3AC9A: "<string>",
79-
0x2267FE33: "<CPoint>", 0x0AA3495D: "<CSize>",
85+
0x5C8F9492: "CResourceTable", 0x25B3AC9A: "CHBString",
86+
0x60436E39: "CFont", 0x8AC3362F: "CFontReferences",
87+
0x2F71594A: "CFontTextCombination", 0xE161265A: "CPlacementFontCombination",
88+
0x3DC09477: "CPlacementCombination", 0xB9106E94: "CBitmapColorCombination",
89+
0xD7CE702B: "CColors", 0xD8492763: "CColorMasks",
90+
0x1F53E9FF: "CColorReferences", 0x833EFC17: "PositionResource",
91+
0x8425447B: "AlignmentResource",
8092
}
8193

8294
FIELD_NAMES = {
95+
# on the drawable classes
8396
0x0A2B4963: "mParentID", 0x7F9A5557: "mDrawOrder",
8497
0x7A1CC624: "mPositionResID", 0xF790B5F2: "mSizeResID",
8598
0xEBD70139: "mPosition", 0x45CF85C4: "mSize",
8699
0x5FFAEE1E: "m_childrenIDs", 0x1A2BEA44: "m_resourceTableID",
87100
0x5A894D17: "mAlignmentResID", 0x79CC0437: "mAlignment",
88101
0x94EA844C: "mBmpResID", 0xF9F3B843: "mColorsResID",
89102
0xD7C9A631: "mTextResID", 0xB397877F: "mFontResID",
90-
0x9960974E: "point", 0x26693065: "size",
103+
0x621C612D: "mFontResID", 0x9960974E: "point",
104+
0x26693065: "size",
105+
# on CFont / CFontFormat / CFontFile -- the chain a label's size comes from
106+
0xAFB73BC7: "mFontFormatResID", 0x650043A0: "mColorsResID",
107+
0x84477324: "mFontFileResID", 0xA2E58771: "mHeight",
108+
0xD080B74E: "mWidth", 0xEE757E54: "mEngine",
109+
0x92DD65C3: "mHorizontalDPI", 0x2E858ACE: "mVerticalDPI",
110+
0x70160B06: "mFileName", 0xFFF5F83A: "mOutlineWidth",
111+
0x1EB8A8EF: "mFontResID", 0x5F028ED0: "mFontResID",
112+
0x5F90203A: "mFonts", 0x5C8C1726: "mString",
113+
# on CBitmap
114+
0xEC9570D7: "mMode", 0x93DF2ACC: "mBitsPerPixel",
115+
0x78BDA50A: "mWidth", 0xE70B5DA7: "mHeight",
116+
0x0800845E: "mPaletteCount", 0x9A979BD7: "mPixelData",
117+
# on the resource-side placement classes
118+
0x8F1AC150: "mPoint", 0xC0654178: "mSize",
119+
0x10432267: "mPosition", 0x2B5B2DE3: "mSize",
120+
0xE4E168FB: "mPositionResID", 0xD967BDC7: "mSizeResID",
121+
0xE46C3005: "mBmpResID", 0x98165484: "mTextResID",
122+
0xA433A1BF: "mColorsResID", 0xF1BF6A3D: "mParentID",
123+
0xA3EFFD51: "m_childrenIDs", 0xD0B3623F: "m_resourceTableID",
124+
0x516D5D62: "mDrawOrder",
91125
}
92126

93127

@@ -398,6 +432,58 @@ def screens(self, min_elements=4, limit=400):
398432
break
399433
return uniq
400434

435+
def font_px(self, rid):
436+
"""Pixel height of the font an element draws in, or None.
437+
438+
The chain is element -> mFontResID -> CFont -> mFontFormatResID ->
439+
CFontFormat.mHeight, and CFontFormat also names the .ttf via CFontFile,
440+
so the real typeface is knowable too -- the files are in IFS2. Using the
441+
recorded size rather than one fixed size is what stops every screen
442+
looking alike: a heading and a list row differ by more than their text.
443+
"""
444+
r = self.record(rid)
445+
if not r:
446+
return None
447+
fid = r.get("mFontResID") or 0
448+
if not fid:
449+
return None
450+
f = self.record(fid)
451+
if f is None: # a descriptor: follow its variants
452+
for _kind, pid in self.desc.get(fid, ()):
453+
f = self.record(pid)
454+
if f is not None:
455+
break
456+
if not f:
457+
return None
458+
ffid = f.get("mFontFormatResID") or 0
459+
fmt = self.record(ffid) if ffid else None
460+
if fmt is None and ffid:
461+
for _kind, pid in self.desc.get(ffid, ()):
462+
fmt = self.record(pid)
463+
if fmt is not None:
464+
break
465+
h = (fmt or {}).get("mHeight")
466+
if isinstance(h, int) and 4 <= h <= 96:
467+
return h
468+
return None
469+
470+
def font_file(self, rid):
471+
"""The .ttf an element's font resolves to, if the chain is complete."""
472+
r = self.record(rid)
473+
fid = (r or {}).get("mFontResID") or 0
474+
f = self.record(fid) if fid else None
475+
ffid = (f or {}).get("mFontFormatResID") or 0
476+
fmt = self.record(ffid) if ffid else None
477+
fileid = (fmt or {}).get("mFontFileResID") or 0
478+
ff = self.record(fileid) if fileid else None
479+
name = (ff or {}).get("mFileName")
480+
if isinstance(name, bytes):
481+
try:
482+
return name.decode("utf-8", "replace").strip("\x00")
483+
except Exception:
484+
return None
485+
return name if isinstance(name, str) else None
486+
401487
def name_of(self, rid, scan=600):
402488
"""A human name for a screen: the first text found inside it.
403489

0 commit comments

Comments
 (0)