Skip to content

Tools

Every tool in tools/, generated from its own docstring and --help by tools/tool_reference.py. Run any of them with uv run tools/<name>.py; none needs setting up (see Running the tools).

Generated page

Edit the tool's docstring or arguments, then run python3 tools/tool_reference.py. A test fails when a tool is missing from this page or its summary has changed.

Build and flash

tool what it does
build_package.py Build a patched SMEG+ package from a manifest, in one command.
patch_smeg.py Apply a SMEG+ patch set to an upgrade package and rebuild its checksum cascade.
patch_media.py Patch files inside a SMEG+ media partition (<module>/system.bin).
patch_contract.py Re-encrypt contract.dat so the unit will accept a modified package.
preflight.py Pre-flight a SMEG+ package: say what it will do before it goes on a stick.
verify_package.py Audit a package's checksum cascade before it goes near a car.
prepare_usb.py Put a built package onto a USB stick, and prove it landed.
fix_userdata_case.py Rewrite one FAT directory entry so the SMEG+ updater reads 'sqlite', not 'SQLITE'.
patch_status.py Write each patch set's hardware status into the docs, from patches/*.json alone.

build-package

tools/build_package.py: Build a patched SMEG+ package from a manifest, in one command.

usage: build_package.py [-h] --manifest MANIFEST [--skip-preflight] [--dry-run]

Build a patched SMEG+ package from a manifest, in one command.

Applying a patch by hand means running three or four tools in a specific order with an
`rsync` between each one, and two of those orderings are silent if you get them wrong:

  * the media step must run against the **already application-patched** package, or it
    rebuilds `ctrl.bin` without the application change and quietly drops it;
  * the contract must be re-sealed **last**, or the package is left unsealed and the unit
    refuses it (string 2099).

This tool owns that ordering so a build is a file you can read and re-run, not a sequence
you have to remember. It shells out to the individual tools rather than reimplementing
them, so they stay usable on their own.

Manifest (JSON — no extra dependency):

    {
      "package": "SMEG_PLUS_UPG",
      "out": "SMEG_PLUS_UPG_custom",
      "module": "NAV",
      "app":   { "patches": ["aux-autoswitch"] },
      "media": {
        "tones":  { "ring_tones/ring1RT.wav": "piano-riff.mp3" },
        "splash": { "peugeot": "snoopy.png" },
        "names":  { "ring2": "Piano Riff" },
        "gui_ver": "32.01"
      },
      "seal": true
    }

Every section is optional. `app.patches` names files in `patches/`; `media.tones` maps a
partition-relative destination to a source audio file of any format ffmpeg reads;
`media.splash` maps a marque to an image; `media.names` renames ring1..ring5 in the phone's
ringtone menu. Those names are literals in the application image (#190), so this becomes an
application patch - NAV 5.43.A.R2 only, and each name has a fixed maximum length;
`media.gui_ver` sets `GUI_VER` in the partition's `Data_base/smeg.inf`, which System
Information shows on its GUI item's page (read, #191); the updater does not gate on it.

usage:
    python3 tools/build_package.py --manifest build.json
    python3 tools/build_package.py --manifest build.json --dry-run

options:
  -h, --help           show this help message and exit
  --manifest MANIFEST
  --skip-preflight     do not run the final pre-flight check (not recommended)
  --dry-run            show the steps and what would change, without writing

patch-smeg

tools/patch_smeg.py: Apply a SMEG+ patch set to an upgrade package and rebuild its checksum cascade.

usage: patch_smeg.py [-h] --src SRC --out OUT [--patches PATCHES] [--only [ONLY ...]]
                     [--copy-package] [--level LEVEL] [--stock]

Apply a SMEG+ patch set to an upgrade package and rebuild its checksum cascade.

The application for each variant is AppBin/f_BigQuick.bin: a 0x801-byte header
plus a zlib stream holding the PowerPC image loaded at 0x01000000. This tool:

  1. inflates the image,
  2. checks the original bytes at each patch address ("expect") and applies the
     replacement ("bytes"),
  3. re-deflates the image back into the container,
  4. rebuilds the checksum cascade:

        f_BigQuick.bin --crc32--> f_BigQuick.bin.inf
                       --crc32--> smeg.inf  (BIGQUICK_CRC32)
                       --crc32--> <module>_ctrl.bin
        <module>_ctrl.bin --crc32--> ctrl.bin (root manifest)

Only the changed files are written to --out (mirroring their package-relative
paths). Use tools/apply_files.sh to overlay them onto a copy of your package, or
pass --copy-package to have this tool copy the whole package into --out first.

No vendor firmware is bundled with this tool; point --src at your own legally
obtained package.

usage:
    python3 tools/patch_smeg.py --src SMEG_PLUS_UPG --out SMEG_PLUS_UPG_mod
    python3 tools/patch_smeg.py --src SMEG_PLUS_UPG --out out --only NAV

options:
  -h, --help         show this help message and exit
  --src SRC          original package directory
  --out OUT          output directory for changed files
  --patches PATCHES  patch definition JSON
  --only [ONLY ...]  variant name(s) to patch
  --copy-package     copy the whole package into --out before overlaying changes
  --level LEVEL      zlib level for re-packing (default 6)
  --stock            apply no patches, but rebuild and verify the checksum cascade

patch-media

tools/patch_media.py: Patch files inside a SMEG+ media partition (<module>/system.bin).

usage: patch_media.py [-h] {list,extract,restore,apply} ...

Patch files inside a SMEG+ media partition (`<module>/system.bin`).

`system.bin` is a gzip'd tar that the unit extracts to `/SYSTEM/`. Editing anything in
it means rebuilding the tar, re-gzipping, and repairing the whole checksum chain:

    changed file
      -> system_ctrl.bin    (fixed 264-byte records: [path][pad][type][crc32])
      -> system.bin         (tar + gzip)
      -> system.bin.inf     (CRC32 + the SIZE / SIZE_n fields)
      -> <module>_ctrl.bin  (4-byte big-endian CRC32 per packaged file)
      -> ctrl.bin           (root manifest)

The `SIZE` fields are the uncompressed *contents* size, not the tar or gzip size:

    SIZE   = sum of the file sizes inside the tar (headers and padding excluded)
    SIZE_n = the same, with each file rounded up to an n KiB block

They are read by `UpgPlugin.out` (not `upgrade.out`) for the media space check.

Only **replacing** existing files is supported. Adding one would need a new
`system_ctrl.bin` record; that record format is now fully mapped (see
docs/MEDIA_PARTITION.md) and so is mechanically expressible, but whether the updater
accepts a record count it has never seen is untested — so this tool still refuses.

usage:
    # see what is in the partition
    python3 tools/patch_media.py list --package SMEG_PLUS_UPG --module NAV

    # extract it (and keep a copy of the originals so you can restore them)
    python3 tools/patch_media.py extract --package SMEG_PLUS_UPG --module NAV \
        --tree media --backup backups

    # put an original file back
    python3 tools/patch_media.py restore --backup backups --tree media --only ring_tones/ring1RT.wav

    # rebuild the package with everything that differs in the tree
    python3 tools/patch_media.py apply --package SMEG_PLUS_UPG --module NAV \
        --tree media --out SMEG_PLUS_UPG_mod

positional arguments:
  {list,extract,restore,apply}
    list                list the files in the partition
    extract             extract the partition (and optionally back up the originals)
    restore             put original files back into a tree
    apply               rebuild the package from a tree

options:
  -h, --help            show this help message and exit

patch-contract

tools/patch_contract.py: Re-encrypt contract.dat so the unit will accept a modified package.

usage: patch_contract.py [-h] --package PACKAGE [--module MODULE] [--out OUT] [--show]

Re-encrypt `contract.dat` so the unit will accept a modified package.

## Background

The head unit validates the update media before copying anything. Losing that check
produces *"The update file is protected and cannot be copied."* (string 2099), which is
what happens if you patch `AppBin/f_BigQuick.bin` and flash the result.

`contract.dat` is a table of per-file checks, encrypted in 256-byte RSA blocks.
`C_BCM_UPGRADE::CheckTrustedSource()` decrypts it on the unit and compares:

| CheckType | field @64 | field @68 | field @72 |
|---|---|---|---|
| 1 | `0xfffefffe` | `0xfffefffe` | file **size** (u32 BE) |
| 2 | `0xfffefffe` | `0xfffefffe` | file **crc32** (u32 BE) |
| 3 | **length** | **offset** | **raw bytes** read from the file |

Each record is 212 bytes: a NUL-padded path in `[0..62]`, the CheckType in `[63]`, then
those fields. Block 0 is a 152-byte header (`19/09/2017`, manifest version, magic
`deadbeef`/`badef00d`).

The unit *decrypts* with a private key, so the file was *encrypted with the public key*.
RSA public encryption is something anyone with the public key can do — and the key pair
is embedded in the firmware image itself. So a modified package can be re-sealed by
recomputing the checks from the files on disk and re-encrypting.

## Key handling

This tool does **not** ship any key material. It extracts the key pair from the
`f_BigQuick.bin` of the package you point it at — i.e. from the firmware you already own —
and uses it for that package only. The plaintext contract and the key are never written
anywhere.

## Usage

    # after patch_smeg.py / patch_media.py have written the changed files into --out
    python3 tools/patch_contract.py --package SMEG_PLUS_UPG_mod --out SMEG_PLUS_UPG_mod

If the package root is also the output (changes applied in place on a *copy* of the
package), one path is enough. The regenerated `contract.dat` is written to `--out`.

    python3 tools/patch_contract.py --package SMEG_PLUS_UPG_mod --show

prints what it would change without writing.

options:
  -h, --help         show this help message and exit
  --package PACKAGE  package directory holding contract.dat and the module images
  --module MODULE    module whose app image carries the key material (default NAV)
  --out OUT          where to write the new contract.dat (default: --package)
  --show             report changes without writing

preflight

tools/preflight.py: Pre-flight a SMEG+ package: say what it will do before it goes on a stick.

usage: preflight.py [-h] --package PACKAGE [--module MODULE] [--stock STOCK] [--json]

Pre-flight a SMEG+ package: say what it will do before it goes on a stick.

Every car trip this project has spent was to discover something that was knowable offline.
The three most recent were:

  * `supervisor.Last_Source = 4` — not a valid source, so the unit ignored it and fell back
    to FM. The enum is known; nothing checked the value against it.
  * settings edited in `system.bin` appeared to do nothing, because the unit reads them from
    a separate `/USER_DATA` partition. Nothing said where a change would actually land.
  * a package rejected with string 2099 — the contract. Nothing checked the seal.

This runs those checks, plus the ones that are merely tedious, and reports what it does
**not** know as prominently as what it does. The unknowns are where the car trips went.

usage:
    python3 tools/preflight.py --package SMEG_PLUS_UPG_mod
    python3 tools/preflight.py --package SMEG_PLUS_UPG_mod --stock SMEG_PLUS_UPG
    python3 tools/preflight.py --package SMEG_PLUS_UPG_mod --json

options:
  -h, --help         show this help message and exit
  --package PACKAGE
  --module MODULE
  --stock STOCK      a stock package to compare against (optional)
  --json

verify-package

tools/verify_package.py: Audit a package's checksum cascade before it goes near a car.

usage: verify_package.py [-h] --package PACKAGE [--json]

Audit a package's checksum cascade before it goes near a car.

A package is refused by the unit if any of its recorded CRC32s disagree with the files they
describe, and the failure it produces in the car looks like a firmware fault rather than a
packaging one. This reads every record the package carries and checks it against the bytes.

**It never assumes the record layout.** The `*_ctrl.bin` layout is now known
(docs/FLASH_CHAIN.md), but a check that does not depend on it stays correct if a manifest ever
differs. So rather than parse them, this looks for the CRC *as a value*: each manifest must contain, as a raw big-endian
word, the CRC32 of every file it is responsible for. That is layout-free and still catches
the failure that matters, which is a manifest that does not describe what shipped.

What it checks:

  * every `*.inf` sidecar's `CRC32:` field against the file beside it,
  * `smeg.inf`'s `BIGQUICK_CRC32:` against the module's `AppBin/f_BigQuick.bin`,
  * each `<MODULE>_ctrl.bin` against the three files of that module,
  * `ctrl.bin` against each `<MODULE>_ctrl.bin`.

usage:
    python3 tools/verify_package.py --package out/SMEG_PLUS_UPG
    python3 tools/verify_package.py --package out/SMEG_PLUS_UPG --json

options:
  -h, --help         show this help message and exit
  --package PACKAGE  the package directory to audit
  --json             machine-readable output

prepare-usb

tools/prepare_usb.py: Put a built package onto a USB stick, and prove it landed.

usage: prepare_usb.py [-h] --package PACKAGE --target TARGET [--dry-run] [--force] [--keep-index]
                      [--eject]

Put a built package onto a USB stick, and prove it landed.

Getting the package onto the stick is the last manual step, and it has already gone wrong
twice: a stick was pulled mid-copy, and `ditto` left AppleDouble `._*` files beside the
package. A silently truncated copy produces an update failure in the car that looks like a
firmware fault, which is an expensive way to find out. This makes both failures loud:

  1. probe the target — the updater wants **MBR + FAT32**, and says how to fix it otherwise
  2. check free space against the package
  3. copy, excluding AppleDouble and `.DS_Store`, using a data-only copy, then delete the
     `._*` shadows macOS still writes for each new file on FAT
  4. **re-read every file from the stick and compare checksums** against the source, which
     is what catches the mid-copy removal
  5. confirm the layout the updater looks for, and count any `._*` left behind as a failure
  6. on macOS: stop Spotlight indexing the stick (`.metadata_never_index`, `mdutil -i off`)
     and remove the `.Spotlight-V100` / `.fseventsd` folders it leaves at the root; indexing
     also held the volume busy so `diskutil eject` failed until retried (`--keep-index` skips)
  7. with `--eject`, eject the stick, retrying while the volume is still busy

It only ever writes inside `--target`, and it prints the target before touching anything.

usage:
    python3 tools/prepare_usb.py --package out/SMEG_PLUS_UPG --target /Volumes/USB
    python3 tools/prepare_usb.py --package out/SMEG_PLUS_UPG --target /Volumes/USB --dry-run
    python3 tools/prepare_usb.py --package out/SMEG_PLUS_UPG --target /Volumes/USB --eject

options:
  -h, --help         show this help message and exit
  --package PACKAGE  the built package directory
  --target TARGET    the mounted USB stick
  --dry-run          check everything, copy nothing
  --force            continue past filesystem warnings
  --keep-index       macOS: leave Spotlight indexing and its folders on the stick alone
  --eject            macOS: eject the stick when done

fix-userdata-case

tools/fix_userdata_case.py: Rewrite one FAT directory entry so the SMEG+ updater reads 'sqlite', not 'SQLITE'.

Rewrite one FAT directory entry so the SMEG+ updater reads 'sqlite', not 'SQLITE'.

Why this exists
---------------
A package can carry a USER_DATA payload at
``SMEG_PLUS_UPG/NAV/USER_DATA/user_data/sqlite/up_common.sqlite``. The updater
copies it with ``xcopy_blk("/bd0/SMEG_PLUS_UPG/NAV/USER_DATA", "/USER_DATA")``,
naming each destination directory after what it reads off the stick.

The application reads its live settings from **lowercase** ``/USER_DATA/user_data/sqlite/``.
But ``sqlite`` is a valid 8.3 name, so macOS stores it as the short name ``SQLITE``
with the NT "lowercase base" bit set and **no long-filename entry**. The updater
honours long filenames but not that bit, so it creates an uppercase ``SQLITE``
sibling that the application never opens. Observed on hardware; see
docs/VERIFICATION.md and docs/FLASHING.md.

The fix is to give that one directory a long-filename entry reading ``sqlite``.

Usage
-----
Check a volume or image without writing::

    python3 tools/fix_userdata_case.py --check /dev/diskNsM

To create the required LFN on macOS, first rename ``sqlite`` to a name that needs
one, such as ``sqlite_dat``. Unmount the volume, inspect the planned edit, apply
it through the raw device, then remount and check::

    diskutil unmount /Volumes/<vol>
    sudo python3 tools/fix_userdata_case.py --dry-run /dev/rdiskNsM
    sudo python3 tools/fix_userdata_case.py /dev/rdiskNsM
    diskutil mount /dev/diskNsM
    python3 tools/fix_userdata_case.py --check /dev/diskNsM

It edits one 32-byte entry, read-modify-written inside its 512-byte sector.
Nothing is moved, no cluster is allocated, and the entry checksum is unaffected
because the short name does not change. --dry-run reports and writes nothing.

patch-status

tools/patch_status.py: Write each patch set's hardware status into the docs, from patches/*.json alone.

usage: patch_status.py [-h] [--check]

Write each patch set's hardware status into the docs, from `patches/*.json` alone.

Status used to be restated by hand in several pages, and they drifted apart (fixed twice in
review). Now each `patches/<name>.json` carries it:

    "summary": "what it changes, one line of Markdown",
    "status": {"state": "never-flashed", "label": "Never flashed", "note": "..."}

`state` is one of STATES below and picks the pill colour; `label` and `note` are free text.
This regenerates the marked blocks in the docs:

    <!-- patch-status:table -->   ...   <!-- /patch-status:table -->    docs/PATCHES.md
    <!-- patch-status:panel -->   ...   <!-- /patch-status:panel -->    docs/index.md

usage:
    python3 tools/patch_status.py            # rewrite the blocks
    python3 tools/patch_status.py --check    # exit 1 if any block is stale (the test runs this)

options:
  -h, --help  show this help message and exit
  --check     fail if a block is out of date

Media: tones, logos, settings

tool what it does
ringtones.py Ring tone / wait tone tool for SMEG+ — inspect, convert and stage custom audio.
assets.py The customisable assets in a SMEG+ media partition, with human-readable names.
splash.py Brand logo images for SMEG+ — inspect and replace the Data_base/graphics/logo/*.pkg bundles.
guistrings.py Decode and rebuild the GUI string tables (gui_text_strings_<LANG>.xml.bin).
patch_studio.py Ringtone Studio — a Qt front-end for SMEG+ ring tones and firmware patches.
dbschema.py Dump the schema of the SQLite databases a SMEG+ unit keeps its state in.

ringtones

tools/ringtones.py: Ring tone / wait tone tool for SMEG+ — inspect, convert and stage custom audio.

usage: ringtones.py [-h] {list,probe,export,convert,stage,names,rename} ...

Ring tone / wait tone tool for SMEG+ — inspect, convert and stage custom audio.

The unit plays plain PCM WAV:

    ring_tones/ring1RT.wav .. ring5RT.wav     16-bit mono   44100 Hz
    ring_tones/{busy,error,ok,ko}RT.wav       16-bit mono   44100 Hz
    wait_tones/MM_HoldOn_<LANG>_8kHz.wav      16-bit stereo  8000 Hz

`convert` produces a file in exactly the right format from anything ffmpeg can read
(mp3, ogg, flac, m4a, ...). If ffmpeg is not installed, WAV input that is already in
the target format is accepted and copied; anything else is refused with a clear
message. `stage` writes the converted file into an extracted media-partition tree
under the correct name, ready for repacking.

Note: the WAVs live inside the media partition (`system.bin`). Repacking that tar is
tracked separately and is not done here — `stage` prepares the tree.

usage:
    python3 tools/ringtones.py list
    python3 tools/ringtones.py export --tree media/ -o out/
    python3 tools/ringtones.py convert song.mp3 --slot ring1 -o ring1RT.wav
    python3 tools/ringtones.py stage song.ogg --slot ring5 --tree media/
    python3 tools/ringtones.py probe some.wav

positional arguments:
  {list,probe,export,convert,stage,names,rename}
    list                show the slots and the format each expects
    probe               describe an audio file
    export              copy the stock WAVs out of an extracted media tree
    convert             convert a file to a slot's format
    stage               convert and place a file into an extracted media tree
    names               list the ringtone names the phone UI shows
    rename              change one of those names

options:
  -h, --help            show this help message and exit

assets

tools/assets.py: The customisable assets in a SMEG+ media partition, with human-readable names.

usage: assets.py [-h] [--tree TREE] {list,export,replace} ...

The customisable assets in a SMEG+ media partition, with human-readable names.

The partition holds 845 files across directories that give no clue what is what —
`ring1RT.wav`, `MM_HoldOn_GED_8kHz.wav`, `AT_O3.png`, `GillSansPSA.ttf`. This knows
what each one is for, and turns the filename into a label a person can act on, so
"replace a ring tone" or "replace the radio logos" is a choice rather than a hunt.

It only ever *replaces* files that already exist: the partition's `system_ctrl.bin`
records are per-file, so adding a new one would need a record that does not exist.

usage:
    python3 tools/assets.py --tree media list
    python3 tools/assets.py --tree media list --group sounds
    python3 tools/assets.py --tree media export --group logos -o stock-logos/
    python3 tools/assets.py --tree media replace --asset radio-logos --with my-logos/
    python3 tools/assets.py --tree media replace --asset ring1 --with piano.mp3

positional arguments:
  {list,export,replace}
    list                everything replaceable, with readable names
    export              copy the current assets out, as a backup or a template
    replace             swap assets in for the current ones

options:
  -h, --help            show this help message and exit
  --tree TREE           an extracted media partition (contains ring_tones/)

splash

tools/splash.py: Brand logo images for SMEG+ — inspect and replace the Data_base/graphics/logo/*.pkg bundles.

usage: splash.py [-h] [--tree TREE] {list,extract,replace,selftest} ...

Brand logo images for SMEG+ — inspect and replace the `Data_base/graphics/logo/*.pkg` bundles.

Despite the name these are NOT the boot splash. Flashing a replaced one leaves the factory
animation untouched: nothing in the application image references these files, and the boot
artwork lives in a separate NAND "Logo Area" that the USB update flow has no step for. See
docs/MEDIA_PARTITION.md. These images are real marque artwork used elsewhere; that use is
still unidentified, so treat their effect on the unit as unknown.

`Data_base/graphics/logo/` holds one `.pkg` per marque (`peugeot`, `citroen`, `ds`). Each is a
small container holding four 800x480 24-bit images. The first is the Peugeot lion and
wordmark artwork; it is not what the unit shows while starting (see above).

Container layout (verified against the shipped packages):

    0x0000  u32   crc32 of bytes 0x0004..0x0800        (directory integrity)
    0x0004  u32   size of the first chunk, as the offset of the next chunk from 0x0800
    0x0008  u32   uncompressed size of every chunk (800*480*3 + 54 = 1152054)
    0x000c  u32   0
    0x0010  ...   directory records, zero-padded to 0x0800
    ...

Each directory record is a 32-byte NUL-padded name followed by 6 or 7 big-endian u32s; the
last two carry the offset of the *next* chunk's marker byte and that chunk's length + 3.

The data region runs from 0x0800. Every chunk is:

    u8  0x08 marker
    ... a standard zlib stream (deflate level 6) holding one 800x480 24-bit BMP
    u16 an unidentified two-byte trailer, preserved verbatim

The BMPs are ordinary bottom-up 24-bit BMPs, but they are stored **vertically mirrored**:
read with normal BMP semantics the artwork is upside down. That matches the car, where the
splash displays correctly, so the unit flips it when rendering. To make a replacement
display the right way up it must therefore be stored flipped — `replace` does that for you.

Known unknown: the two-byte trailer after each zlib stream has not been identified (it is
not a crc32 or adler32 fragment of the chunk). It is preserved as-is, and a rebuilt package
has not yet been flashed, so treat a replaced splash as unverified until a unit accepts it.

usage:
    python3 tools/splash.py list --tree media/
    python3 tools/splash.py extract --tree media/ -o splash/ --marque peugeot
    python3 tools/splash.py replace --tree media/ --marque peugeot --image mine.png
    python3 tools/splash.py selftest --tree media/

positional arguments:
  {list,extract,replace,selftest}
    list                show the images in each marque's package
    extract             write the images out as BMP
    replace             replace an image and rebuild the package
    selftest            rebuild from the stock images and compare

options:
  -h, --help            show this help message and exit
  --tree TREE           an extracted media partition (contains Data_base/graphics/logo/)

guistrings

tools/guistrings.py: Decode and rebuild the GUI string tables (gui_text_strings_<LANG>.xml.bin).

usage: guistrings.py [-h] {info,dump,probe,build,patch,diff} ...

Decode and rebuild the GUI string tables (`gui_text_strings_<LANG>.xml.bin`).

The unit shows its user-visible wording from one binary file per language, in the media
partition at `Data_base/boardfs/GUI_STYLE/GUIS_RESSOURCES/gui_texts/`. Despite the name they
are **not XML**:

    0x00  u32  total file size
    0x04  u32  record count
    0x08  u32  offset of the first string (always 0x10 + 12 * count)
    0x0c  u32  zero
    0x10  n * 12-byte records:  u32 id, u32 offset (absolute), u32 length (bytes)
    ...   the string blob: UTF-16BE code units, concatenated, no terminator, no BOM

The directory is sorted by id, the offsets are contiguous, and the blob ends exactly at EOF —
so there is **no internal checksum**; the file's CRC32 lives in `system_ctrl.bin` with the
rest of the partition cascade. A decode followed by a rebuild reproduces the input
byte-for-byte; `probe` checks that on the file you hand it. See docs/GUI_STRINGS.md.

The reader is `C_GUI_StringsManager::LoadStringsFileBinary` and the vendor's own writer is
`C_GUI_StringsManager::buildBinaryStringsFile`, both in the application image; the layout
above is what the disassembly of the two agrees on.

usage:
    # the id -> string table, and what the header says
    python3 tools/guistrings.py dump  media/Data_base/.../gui_texts/gui_text_strings_GB.xml.bin
    python3 tools/guistrings.py info  media/.../gui_text_strings_GB.xml.bin

    # what the format allows, and proof that a rebuild is byte-identical
    python3 tools/guistrings.py probe media/.../gui_text_strings_GB.xml.bin
    python3 tools/guistrings.py probe media/.../gui_texts --lang GB

    # edit a label: dump to JSON, change the value, rebuild (the JSON is the overrides file)
    python3 tools/guistrings.py dump  GB.xml.bin --json > edits.json
    python3 tools/guistrings.py build --base GB.xml.bin --out GB_new.xml.bin --overrides edits.json

    # or in place, keeping every slot the same length so no offset moves
    python3 tools/guistrings.py patch GB.xml.bin --in-place --set 4=CarPlay --pad

    # compare two languages
    python3 tools/guistrings.py diff GB.xml.bin FR.xml.bin

positional arguments:
  {info,dump,probe,build,patch,diff}
    info                the header fields and a structural check
    dump                print the id -> string table
    probe               show the format's own limits and prove the round trip
    build               rebuild a file from a base plus edits (no edits = a byte-for-byte copy)
    patch               apply edits to a file (build with the base filled in)
    diff                compare two string tables

options:
  -h, --help            show this help message and exit

patch-studio

tools/patch_studio.py: Ringtone Studio — a Qt front-end for SMEG+ ring tones and firmware patches.

Ringtone Studio — a Qt front-end for SMEG+ ring tones and firmware patches.

**Ringtones tab.** Shows every replaceable tone in the media partition, with its
current state. Per row you can preview it, overwrite it with any audio file
(mp3/ogg/flac/m4a/wav — anything ffmpeg reads), or restore the original that came in the
package. "Extract from package…" pulls the partition out of a package and stores the
originals as a backup, so restore always has something to go back to.

Previewing uses QtMultimedia (QSoundEffect) when available, and otherwise falls back to a
system player (`afplay` on macOS, `paplay`/`aplay` on Linux).

**Pack & patch tab.** Tick the `patches/*.json` definitions you want, point it at a
package and a media tree, and it writes a patched package: the application patches first,
then the media partition rebuild (tar, gzip, `system_ctrl.bin`, `system.bin.inf`, the
module manifest and the root manifest).

usage:
    .venv/bin/python tools/patch_studio.py          # PySide6 + ffmpeg already present
    uv run tools/patch_studio.py                    # fetches PySide6
    python3 tools/patch_studio.py --help            # this text; needs no PySide6

dbschema

tools/dbschema.py: Dump the schema of the SQLite databases a SMEG+ unit keeps its state in.

usage: dbschema.py [-h] {schema,from-package} ...

Dump the schema of the SQLite databases a SMEG+ unit keeps its state in.

A unit keeps its settings in ~29 SQLite databases. The seeds ship read-only in the media
partition (`Data_base/sqlite/*.sqlite`, inside `<module>/system.bin`); the live copies are on
the `/USER_DATA` partition the car owns, one `.sqlite` with a `.inf` CRC sidecar. Editing a
seed changes nothing on the unit — see docs/DATABASES.md and docs/MEDIA_PARTITION.md.

This reads a database and prints what is *in* it structurally: tables, columns and their types,
NOT NULL / defaults, the primary key, indexes and foreign keys. It reads **your own** databases
or the ones in **your own** package; it ships no vendor data. The live `up_common`/`up_user`
stores are gzip'd on disk, which is handled transparently.

Because it only reads, it never writes to the file it is given: the bytes are copied to a
throwaway location first, so a live database on `USER_DATA` is not touched and no `-wal`/`-shm`
is created beside it.

usage:
    # a database, or several
    python3 tools/dbschema.py schema path/to/up_common.sqlite

    # every Data_base/sqlite/*.sqlite inside your own media partition, straight from the
    # gzipped tar (a system.bin file) or from an extracted package/module directory
    python3 tools/dbschema.py from-package SMEG_PLUS_UPG/NAV/system.bin
    python3 tools/dbschema.py from-package SMEG_PLUS_UPG/NAV
    python3 tools/dbschema.py from-package media/

    # machine-readable, and with the original CREATE statements
    python3 tools/dbschema.py schema up_common.sqlite --json
    python3 tools/dbschema.py schema up_common.sqlite --sql

positional arguments:
  {schema,from-package}
    schema              dump one or more .sqlite files
    from-package        dump every Data_base/sqlite/*.sqlite in your own media partition

options:
  -h, --help            show this help message and exit

Diagnostics

tool what it does
spy_read.py Read a SPY capture in one command: the boot timeline, the AUX events, any buffer.

spy-read

tools/spy_read.py: Read a SPY capture in one command: the boot timeline, the AUX events, any buffer.

usage: spy_read.py [-h] [--list | --boot | --aux | --show ID] [--no-redact] source

Read a SPY capture in one command: the boot timeline, the AUX events, any buffer.

`SPYTAKE` collects every module's trace buffer into `SPY/<stamp>/TAR/<stamp>-USER.tar.gz`,
which `SPYSTORE` copies to the stick. Inside, `RAMDISK_SPY/<id>/<stamp>.bin` holds one module's
buffer, mostly text. Reading one by hand meant untarring and grepping; this reads the tar in
memory and never writes the capture anywhere.

SOURCE may be a `SPY/<stamp>` folder (the newest `TAR/*-USER.tar.gz` is used), a `-USER.tar.gz`
itself, or a folder that already contains `RAMDISK_SPY/`.

  --list        every buffer with its size and first line
  --boot        (default) how the boot source was chosen: C_MGR_SRC's trace, the saved
                Last_Source, each source request with its PrOnly flag, the ScheduledInit
                table, and which source won and when
  --aux         only the AUX-related lines from the source manager, media app and audio module
  --show ID     one buffer, e.g. 25300

**Captures hold personal data.** The settings and Bluetooth buffers carry the VIN, device
addresses and phone numbers. Output is redacted by default (`--no-redact` turns it off, for
your own eyes only). Never commit a capture or paste one into an issue.

usage:
    python3 tools/spy_read.py /Volumes/USB/SPY/01_202609280110
    python3 tools/spy_read.py 01_202609280108-USER.tar.gz --aux
    python3 tools/spy_read.py SPY/01_202609280110 --show 25300

positional arguments:
  source       SPY/<stamp> folder, a -USER.tar.gz, or an extracted tree

options:
  -h, --help   show this help message and exit
  --list       every buffer and its first line
  --boot       how the boot source was chosen
  --aux        AUX-related lines only
  --show ID    print one buffer
  --no-redact  show personal data (VIN, addresses)

Firmware analysis

tool what it does
unpack.py Inflate a SMEG+ AppBin/f_BigQuick.bin to the raw PowerPC application image.
fingerprint.py Identify which SMEG+ build an application image is, and fail loudly when it is unclear.
survey.py Inventory every function in a SMEG+ application image: where it sits, what reaches it.
ppcdis.py PowerPC (32-bit, big-endian) disassembler for SMEG+ application images.
ppcemu.py Execute individual firmware functions on an emulated PowerPC core.
xref.py Find code that references a string, address or pointer inside a SMEG+ image.
callers.py Find direct (bl) callers of one or more addresses in a SMEG+ PPC image.
mkelf.py Wrap a raw SMEG+ application image plus its symbol map into a disassemblable ELF.
elfsyms.py Read the symbol tables the SMEG+ package ships in plain sight.
symdiff.py Diff two firmware releases by symbol, so a patch set can be carried across.
crc_recover.py Recover CRC parameters from (message, checksum) samples.
cartography.py Read the cartography metadata: name pools, the .inf sidecars, SCC records, CCT.DAT.
tool_reference.py Generate docs/TOOLS.md: every tool, its purpose and its --help, from the tools themselves.

unpack

tools/unpack.py: Inflate a SMEG+ AppBin/f_BigQuick.bin to the raw PowerPC application image.

usage: unpack.py [-h] input output

Inflate a SMEG+ AppBin/f_BigQuick.bin to the raw PowerPC application image.

f_BigQuick.bin is a 0x801-byte header followed by a zlib stream. The inflated
image is loaded at 0x01000000 and the release's Application/PKG/abs_symbols_base
map lines up with it, so addresses from the symbol map map 1:1 onto this file.

usage:
    python3 tools/unpack.py SMEG_PLUS_UPG/NAV/AppBin/f_BigQuick.bin app_nav.bin

positional arguments:
  input       f_BigQuick.bin
  output      raw application image to write

options:
  -h, --help  show this help message and exit

fingerprint

tools/fingerprint.py: Identify which SMEG+ build an application image is, and fail loudly when it is unclear.

usage: fingerprint.py [-h] [--src SRC] [--image IMAGE] [--patches [PATCHES ...]] [--json]

Identify which SMEG+ build an application image is, and fail loudly when it is unclear.

Patch addresses are per build. `patches/*.json` declares each variant's addresses, and
`patch_smeg.py` guards the *firmware version* by looking for the vendor's build token
(`5.43.A.R2`) inside the image. That leaves the *build* itself — `AUDIO_BT`,
`AUDIO_BT_256`, `NAV` — to be chosen by hand, which is the part that is easy to get
wrong: `AUDIO_BT` and `AUDIO_BT_256` share their patch addresses exactly.

This reads the image and answers that question from the bytes, rather than from what
the operator remembered:

  * inflate the application container,
  * evaluate every variant's recorded `expect` bytes at their addresses,
  * report a unique match, or refuse.

**It deliberately reports ambiguity instead of picking.** On the firmware in this
repository `AUDIO_BT` and `AUDIO_BT_256` carry identical addresses and identical
`expect` bytes, so the recorded probes genuinely cannot tell them apart. A tool that
quietly chose one would be worse than the manual step it replaced. The same applies
when nothing matches: the message names the build tokens actually present.

Both a package directory and a single image file are accepted. A raw inflated image
(the `0x02604450`-byte layout) is detected, since that is what an analysis pass has
lying around.

usage:
    python3 tools/fingerprint.py --src /path/to/SMEG_PLUS_UPG
    python3 tools/fingerprint.py --image app_nav.bin
    python3 tools/fingerprint.py --src PKG --json

options:
  -h, --help            show this help message and exit
  --src SRC             a SMEG+ upgrade package directory
  --image IMAGE         a single application image (container or inflated)
  --patches [PATCHES ...]
                        patch definition JSON files (default: all in patches/)
  --json                machine-readable output

survey

tools/survey.py: Inventory every function in a SMEG+ application image: where it sits, what reaches it.

usage: survey.py [-h] --out OUT [--base BASE] image symbols

Inventory every function in a SMEG+ application image: where it sits, what reaches it.

One pass over the whole image produces, for each code symbol in the map:

  * its family (subsystem, from the class-name prefix) and size in instructions,
  * direct callers (`bl`),
  * address-materialisation sites (`lis rX,hi` + `addi`/`ori rX,rX,lo`) that produce its
    address - how this firmware makes most of its calls, via `mtctr`/`bctrl`, and what
    `callers.py` cannot see,
  * pointers to it stored in data (vtables, callback tables), and
  * the strings it materialises the address of - usually trace or log text naming what the
    function does,
  * the vtable slots it occupies, and
  * the globals it reads and writes (`lis rX,hi` + a load or store at `lo(rX)`).

The output is local analysis, written under `--out` and never into this repository: it is
derived from the vendor's symbol map, which AGENTS.md keeps out of the tree.

  functions.tsv   one row per function
  families.tsv    one row per family: count, instructions, share of the image
  unreached.tsv   functions with no caller, reference or pointer found - dead code, or
                  reached some way this scan does not model (computed branch tables)
  vtables.tsv     one row per vtable slot: class, slot offset, function
  virtual_calls.tsv  call sites of the form `lwz vptr,0(obj); lwz rZ,off(vptr); mtctr rZ;
                  bctrl`, with the slot offset they call. The receiver's class is not inferred,
                  so a site is not attributed to one function
  globals.tsv     data symbols with the functions that read and write them

The reference counts are lower bounds. An address built with `addis`+`lwz`, or computed at
run time, is not counted. A function with no references found is not proved unreachable.

usage:
    python3 tools/survey.py NAV.img abs_symbols_base.txt.gz --out ~/smeg-survey
    python3 tools/survey.py NAV/AppBin/f_BigQuick.bin abs_symbols_base.txt --out ~/smeg-survey

positional arguments:
  image        f_BigQuick.bin or an already-inflated image
  symbols      abs_symbols_base.txt, or the .gz as SPYSTORE copies it

options:
  -h, --help   show this help message and exit
  --out OUT    directory for the TSV files
  --base BASE

ppcdis

tools/ppcdis.py: PowerPC (32-bit, big-endian) disassembler for SMEG+ application images.

usage: ppcdis.py [-h] [--base BASE] image symbols start end

PowerPC (32-bit, big-endian) disassembler for SMEG+ application images.

Resolves symbols from an absolute symbol map (e.g. Application/PKG/abs_symbols_base.txt)
and annotates indirect calls made through the `lis/addi -> mtctr -> bctrl` idiom that
the compiler emits, so `bctrl` shows the callee name.

usage:
    python3 tools/ppcdis.py IMAGE SYMFILE ADDRESS END
    python3 tools/ppcdis.py app_nav.bin abs_symbols_base.txt 0x0230331c 0x02303460

positional arguments:
  image
  symbols
  start
  end

options:
  -h, --help   show this help message and exit
  --base BASE

ppcemu

tools/ppcemu.py: Execute individual firmware functions on an emulated PowerPC core.

usage: ppcemu.py [-h] [--base BASE] --call CALL [--arg ARG] [--patches PATCHES] [--module MODULE]
                 [--stub-all] [--stub STUB] [--trace]
                 image

Execute individual firmware functions on an emulated PowerPC core.

positional arguments:
  image              raw image from tools/unpack.py

options:
  -h, --help         show this help message and exit
  --base BASE        load address (default 0x01000000)
  --call CALL        function address to call
  --arg ARG          register argument (repeatable)
  --patches PATCHES  a patches/*.json to apply first
  --module MODULE    which variant of --patches to apply
  --stub-all         stub every call instead of executing it
  --stub STUB        ADDR=VALUE — stub one callee's return value (repeatable)
  --trace            print the instructions executed

xref

tools/xref.py: Find code that references a string, address or pointer inside a SMEG+ image.

usage: xref.py [-h] [--base BASE] image symbols target

Find code that references a string, address or pointer inside a SMEG+ image.

Two things are searched for:

  * literal 4-byte big-endian pointers to the target (data/vtable references), and
  * instruction sequences that materialise the target immediate - the
    `lis rX,hi ; addi/ori rX,rX,lo` idiom the compiler uses for addresses.

Give TARGET as a quoted string (its address is located first) or as 0xADDR.

usage:
    python3 tools/xref.py app_nav.bin abs_symbols_base.txt "Auxiliary_Input"
    python3 tools/xref.py app_nav.bin abs_symbols_base.txt 0x023031dc

positional arguments:
  image
  symbols
  target

options:
  -h, --help   show this help message and exit
  --base BASE

callers

tools/callers.py: Find direct (bl) callers of one or more addresses in a SMEG+ PPC image.

usage: callers.py [-h] [--base BASE] image symbols targets [targets ...]

Find direct (bl) callers of one or more addresses in a SMEG+ PPC image.

Only direct branches are found; virtual calls and calls made through function
pointers (`bctrl`) are not, which is normal for C++ code. `survey.py` counts the `lis`/`addi` references
that most calls in this firmware go through.

usage:
    python3 tools/callers.py app_nav.bin abs_symbols_base.txt 0x02324928

positional arguments:
  image
  symbols
  targets      addresses to look for callers of

options:
  -h, --help   show this help message and exit
  --base BASE

mkelf

tools/mkelf.py: Wrap a raw SMEG+ application image plus its symbol map into a disassemblable ELF.

usage: mkelf.py [-h] [--base BASE] image symbols output

Wrap a raw SMEG+ application image plus its symbol map into a disassemblable ELF.

Some disassemblers (and debuggers) want a real ELF rather than a flat image. This
produces an ELF32 big-endian PowerPC executable whose .text holds the image at its
load address, with the symbol map attached.

usage:
    python3 tools/mkelf.py app_nav.bin abs_symbols_base.txt app_nav.elf
    objdump -d app_nav.elf            # any PPC-capable objdump / llvm-objdump

positional arguments:
  image
  symbols
  output

options:
  -h, --help   show this help message and exit
  --base BASE

elfsyms

tools/elfsyms.py: Read the symbol tables the SMEG+ package ships in plain sight.

usage: elfsyms.py [-h] [--grep GREP] [--cls CLS] [--strings [MATCH]] [--raw] elf

Read the symbol tables the SMEG+ package ships in plain sight.

positional arguments:
  elf                 an .out from the package root

options:
  -h, --help          show this help message and exit
  --grep GREP         only symbols whose name contains this (case-insensitive)
  --cls, --class CLS  only methods of this C++ class, in address order
  --strings [MATCH]   list string literals instead, optionally filtered
  --raw               do not demangle

symdiff

tools/symdiff.py: Diff two firmware releases by symbol, so a patch set can be carried across.

usage: symdiff.py [-h] --a IMAGE SYMBOLS --b IMAGE SYMBOLS [--base BASE] [--patch-addr PATCH_ADDR]
                  [--json]

Diff two firmware releases by symbol, so a patch set can be carried across.

Extending the patches to another SMEG release currently means redoing the analysis by hand.
Most of it does not need redoing. The 5.43 and 5.42 NAV images are the same code *displaced by
a constant 152 bytes*, which a naive byte comparison reports as ~80% different — the shift,
not new code. This separates the three cases that matter:

  * **moved, same bytes** — the patch address is mechanical: add the displacement
  * **moved, different bytes** — the address carries over but the patch needs re-deriving
  * **gone or renamed** — nothing to carry

and it reports the displacement rather than making you find it. Point `--patch-addr` at a
site in A and it says whether that site survives into B, and where.

It heeds the rule in `AGENTS.md` either way: a derived address is a *candidate*. Nothing here
patches anything, and a candidate address without the `expect` bytes to back it is not safe to
apply.

usage:
    python3 tools/symdiff.py --a old.bin old_symbols.txt --b new.bin new_symbols.txt
    python3 tools/symdiff.py --a old.bin old_symbols.txt --b new.bin new_symbols.txt \
        --patch-addr 0x02247858

options:
  -h, --help            show this help message and exit
  --a IMAGE SYMBOLS     older release
  --b IMAGE SYMBOLS     newer release
  --base BASE           load address of the images
  --patch-addr PATCH_ADDR
                        a patch site in A to locate in B
  --json

crc-recover

tools/crc_recover.py: Recover CRC parameters from (message, checksum) samples.

usage: crc_recover.py [-h] [--package PACKAGE] [--samples SAMPLES] [--root ROOT] [--width WIDTH]

Recover CRC parameters from (message, checksum) samples.

options:
  -h, --help         show this help message and exit
  --package PACKAGE  a map package root; collects from its DESCRI.DAT files
  --samples SAMPLES  a file of '<path> <hex>' lines
  --root ROOT        base for the paths in --samples
  --width WIDTH

cartography

tools/cartography.py: Read the cartography metadata: name pools, the .inf sidecars, SCC records, CCT.DAT.

usage: cartography.py [-h] {names,names-round-trip,inf,scc,cct} ...

Read the cartography metadata: name pools, the `.inf` sidecars, `SCC` records, `CCT.DAT`.

positional arguments:
  {names,names-round-trip,inf,scc,cct}
    names               inspect or rewrite a NUL-separated name pool
    names-round-trip    read and write a pool, then compare bytes
    inf                 show a .inf sidecar
    scc                 list the records of a %03dSCC.DST
    cct                 decrypt and show CCT.DAT (needs the firmware image)

options:
  -h, --help            show this help message and exit

tool-reference

tools/tool_reference.py: Generate docs/TOOLS.md: every tool, its purpose and its --help, from the tools themselves.

usage: tool_reference.py [-h] [--check]

Generate docs/TOOLS.md: every tool, its purpose and its `--help`, from the tools themselves.

The tool tables in README.md, AGENTS.md, CLAUDE.md and docs/RUNNING.md drifted: one was
missing 14 of 31 tools. This page is written from each tool's docstring and `--help` instead.

`--check` compares what should not drift silently - the set of tools, their groups and their
one-line summaries - and not the `--help` text, whose formatting changes between Python
versions (3.13 locally, 3.14 in CI). Re-run without `--check` to refresh the whole page.

usage:
    python3 tools/tool_reference.py            # rewrite docs/TOOLS.md
    python3 tools/tool_reference.py --check    # exit 1 if a tool is missing or its summary changed

options:
  -h, --help  show this help message and exit
  --check     fail if the page is missing a tool

Repository hooks

tool what it does
check_commit_msg.py Validate a Conventional Commits message, or a PR title.
check_no_pii.py Refuse to commit personal data.

check-commit-msg

tools/check_commit_msg.py: Validate a Conventional Commits message, or a PR title.

usage: check_commit_msg.py [-h] [--title TITLE] [--quiet] [msgfile]

Validate a Conventional Commits message, or a PR title.

Conventional commits are not a style preference here — the repository squash-merges, so the
PR title becomes the commit on `main`, and Release Please reads it to pick the next version
and write `CHANGELOG.md`. A title that does not parse means the release is silently wrong
(or does not happen), which is why this is enforced in two places:

  * as a `commit-msg` hook, so a local commit cannot be made with a bad message;
  * in CI against the PR title, because that is the message that actually lands.

usage:
    check_commit_msg.py .git/COMMIT_EDITMSG     # hook form
    check_commit_msg.py --title "feat: a thing"  # CI form

positional arguments:
  msgfile        a commit message file (what the commit-msg hook passes)

options:
  -h, --help     show this help message and exit
  --title TITLE  validate a string instead, e.g. a PR title
  --quiet

check-no-pii

tools/check_no_pii.py: Refuse to commit personal data.


Shared libraries

Imported by the tools above; not run directly.

module what it holds
appimage.py The application image container, shared by every tool that reads it.
smeglib.py The boring, load-bearing parts of a package's checksum chain, in one place.
symbols.py Reading the symbol maps the vendor's unstripped ELFs come with.