Last active
May 25, 2026 12:17
-
-
Save andrzejsliwa/1a6c9e3db22b44e0df182e17e281a74b to your computer and use it in GitHub Desktop.
Single-file, stdlib only, Python 3.7+. Tarkov 1.0 to SPT config migration script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| USage: | |
| Take the sptSettings folder from your C:\4.0.13\SPT\user | |
| Take the Settings folder C:\Users\UserName\AppData\Roaming\Battlestate Games\Escape from Tarkov\ | |
| # Defaults: ./Settings -> ./sptSettings -> ./sptSettings_migrated | |
| python migrate_settings.py | |
| # Or explicit paths | |
| python migrate_settings.py Settings sptSettings sptSettings_migrated |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Migrate values from a new-version Settings/ folder into an old-version sptSettings/ | |
| folder for SPT compatibility. | |
| Strategy: | |
| - sptSettings is the schema template ("placeholders"). Only keys/paths present | |
| there are filled. Keys that exist only in the new Settings are dropped. | |
| - Top-level "Version" is NEVER overwritten (it is the schema marker). | |
| - Type mismatches between old and new are skipped and reported (would break | |
| the old parser). int<->float is allowed; bool<->str, int<->str are not. | |
| - Arrays of {axisName:...} / {keyName:...} / {bindingName:...} are matched by | |
| name. Other arrays of dicts (pairs, variants, Stored, ...) are matched | |
| positionally. | |
| - Arrays of SCALARS (e.g. keyCode key-combo lists) are replaced wholesale — | |
| they are not parallel slots but a single composite value. This is what | |
| makes modifier additions/removals migrate correctly. | |
| - Output preserves 2-space indent, CRLF line endings, and no trailing newline, | |
| matching the original sptSettings files. | |
| Usage: | |
| python migrate_settings.py # defaults below | |
| python migrate_settings.py SRC DST OUT | |
| python migrate_settings.py Settings sptSettings sptSettings_migrated | |
| """ | |
| import collections | |
| import json | |
| import os | |
| import sys | |
| DEFAULT_SRC = "Settings" # new version (values to migrate from) | |
| DEFAULT_DST = "sptSettings" # old version (placeholders to fill) | |
| DEFAULT_OUT = "sptSettings_migrated" | |
| PROTECTED_PATHS = {"Version"} # dotted root paths that are never overwritten | |
| NAMED_ARRAY_KEYS = ("axisName", "keyName", "bindingName") | |
| # ---------- merge logic ------------------------------------------------------ | |
| def same_type(old, new): | |
| """True if `new` can safely replace `old` without changing the JSON type.""" | |
| # bool is a subclass of int in Python — treat them as different | |
| if isinstance(old, bool) != isinstance(new, bool): | |
| return False | |
| if isinstance(old, bool) and isinstance(new, bool): | |
| return True | |
| # numerics are interchangeable | |
| if isinstance(old, (int, float)) and isinstance(new, (int, float)): | |
| return True | |
| if type(old) is type(new): | |
| return True | |
| # null placeholder on the destination is fillable by any non-null source | |
| if old is None and new is not None: | |
| return True | |
| return False | |
| def is_named_array(lst, name_key): | |
| return ( | |
| isinstance(lst, list) | |
| and len(lst) > 0 | |
| and all(isinstance(x, dict) and name_key in x for x in lst) | |
| ) | |
| def is_primitive_array(lst): | |
| """Array containing only primitives (no dicts, no nested lists). | |
| These are treated as atomic values — replaced wholesale rather than merged | |
| element-by-element. Empty arrays count as primitive. Used for keyCode and | |
| similar flat arrays where position doesn't carry semantic meaning. | |
| """ | |
| return isinstance(lst, list) and all( | |
| not isinstance(x, (dict, list)) for x in lst | |
| ) | |
| def merge(dst, src, path, report): | |
| if isinstance(dst, dict) and isinstance(src, dict): | |
| for k, dv in list(dst.items()): | |
| here = f"{path}.{k}" if path else k | |
| if here in PROTECTED_PATHS: | |
| report["protected"].append((here, dv)) | |
| continue | |
| if k not in src: | |
| report["no_source"].append((here, dv)) | |
| continue | |
| sv = src[k] | |
| if isinstance(dv, dict) and isinstance(sv, dict): | |
| merge(dv, sv, here, report) | |
| elif isinstance(dv, list) and isinstance(sv, list): | |
| # Primitive arrays (keyCode etc.) are atomic — replace wholesale | |
| if is_primitive_array(dv) and is_primitive_array(sv): | |
| if dv == sv: | |
| report["unchanged"].append((here, dv)) | |
| else: | |
| dst[k] = list(sv) | |
| report["migrated"].append((here, dv, sv)) | |
| else: | |
| merge_list(dv, sv, here, report) | |
| else: | |
| if not same_type(dv, sv): | |
| report["type_skip"].append((here, dv, sv)) | |
| continue | |
| if dv == sv: | |
| report["unchanged"].append((here, dv)) | |
| else: | |
| dst[k] = sv | |
| report["migrated"].append((here, dv, sv)) | |
| def merge_list(dst, src, path, report): | |
| # Scalar-only arrays (e.g. keyCode key-combo lists) are a single composite | |
| # value, not parallel slots. Replace wholesale so combos of different | |
| # length migrate correctly. Treat empty lists as scalar-compatible. | |
| if is_primitive_array(dst) and is_primitive_array(src): | |
| if dst == src: | |
| report["unchanged"].append((path, list(dst))) | |
| else: | |
| old = list(dst) | |
| dst.clear() | |
| dst.extend(src) | |
| report["migrated"].append((path, old, list(src))) | |
| return | |
| # Match by name when both arrays use a known name key | |
| for name_key in NAMED_ARRAY_KEYS: | |
| if is_named_array(dst, name_key) and is_named_array(src, name_key): | |
| src_by_name = {x[name_key]: x for x in src} | |
| dst_names = {x[name_key] for x in dst} | |
| for elem in dst: | |
| name = elem[name_key] | |
| here = f"{path}[{name_key}={name}]" | |
| if name not in src_by_name: | |
| report["no_source"].append((here, "<element>")) | |
| continue | |
| merge(elem, src_by_name[name], here, report) | |
| for name in src_by_name: | |
| if name not in dst_names: | |
| report["src_only"].append( | |
| (f"{path}[{name_key}={name}]", "<not in dst>") | |
| ) | |
| return | |
| # Otherwise match positionally up to min(len_a, len_b) | |
| for i in range(min(len(dst), len(src))): | |
| here = f"{path}[{i}]" | |
| dv, sv = dst[i], src[i] | |
| if isinstance(dv, dict) and isinstance(sv, dict): | |
| merge(dv, sv, here, report) | |
| elif isinstance(dv, list) and isinstance(sv, list): | |
| if is_primitive_array(dv) and is_primitive_array(sv): | |
| if dv == sv: | |
| report["unchanged"].append((here, dv)) | |
| else: | |
| dst[i] = list(sv) | |
| report["migrated"].append((here, dv, sv)) | |
| else: | |
| merge_list(dv, sv, here, report) | |
| else: | |
| if not same_type(dv, sv): | |
| report["type_skip"].append((here, dv, sv)) | |
| continue | |
| if dv == sv: | |
| report["unchanged"].append((here, dv)) | |
| else: | |
| dst[i] = sv | |
| report["migrated"].append((here, dv, sv)) | |
| if len(src) > len(dst): | |
| for i in range(len(dst), len(src)): | |
| report["src_only"].append((f"{path}[{i}]", "<not in dst>")) | |
| elif len(dst) > len(src): | |
| for i in range(len(src), len(dst)): | |
| report["no_source"].append((f"{path}[{i}]", "<kept as-is>")) | |
| # ---------- io --------------------------------------------------------------- | |
| def write_crlf(path, obj): | |
| """Serialize JSON with 2-space indent, CRLF newlines, no trailing newline.""" | |
| text = json.dumps(obj, indent=2, ensure_ascii=False).replace("\n", "\r\n") | |
| with open(path, "wb") as fh: | |
| fh.write(text.encode("utf-8")) | |
| def fmt_val(v): | |
| if isinstance(v, str): | |
| return f'"{v}"' | |
| if v is None: | |
| return "null" | |
| if isinstance(v, bool): | |
| return "true" if v else "false" | |
| if isinstance(v, (list, dict)): | |
| return f"<{type(v).__name__}>" | |
| return str(v) | |
| # ---------- driver ----------------------------------------------------------- | |
| def run(src_dir, dst_dir, out_dir): | |
| if not os.path.isdir(src_dir): | |
| sys.exit(f"ERROR: source folder not found: {src_dir}") | |
| if not os.path.isdir(dst_dir): | |
| sys.exit(f"ERROR: destination folder not found: {dst_dir}") | |
| os.makedirs(out_dir, exist_ok=True) | |
| # Auto-discover: process every .ini that exists in BOTH src and dst | |
| files = sorted( | |
| f for f in os.listdir(dst_dir) | |
| if f.endswith(".ini") and os.path.isfile(os.path.join(src_dir, f)) | |
| ) | |
| if not files: | |
| sys.exit("ERROR: no overlapping .ini files between the two folders") | |
| print(f"Source: {src_dir}/") | |
| print(f"Destination: {dst_dir}/ (schema template)") | |
| print(f"Output: {out_dir}/") | |
| print(f"Files: {', '.join(files)}") | |
| for fname in files: | |
| with open(os.path.join(src_dir, fname), encoding="utf-8") as fh: | |
| src = json.load(fh) | |
| with open(os.path.join(dst_dir, fname), encoding="utf-8") as fh: | |
| dst = json.load(fh) | |
| report = collections.defaultdict(list) | |
| merge(dst, src, "", report) | |
| write_crlf(os.path.join(out_dir, fname), dst) | |
| print(f"\n========== {fname} ==========") | |
| print(f" Migrated: {len(report['migrated'])}") | |
| print(f" Unchanged: {len(report['unchanged'])}") | |
| print(f" Kept (no source): {len(report['no_source'])}") | |
| print(f" Type mismatch: {len(report['type_skip'])}") | |
| print(f" Protected: {len(report['protected'])}") | |
| print(f" Src-only (dropped): {len(report['src_only'])}") | |
| if report["migrated"]: | |
| print(" --- Migrated values ---") | |
| for p, old, new in report["migrated"]: | |
| print(f" {p}: {fmt_val(old)} -> {fmt_val(new)}") | |
| if report["type_skip"]: | |
| print(" --- SKIPPED (type mismatch, would break old parser) ---") | |
| for p, old, new in report["type_skip"]: | |
| print(f" {p}: old={fmt_val(old)} ({type(old).__name__}) " | |
| f"new={fmt_val(new)} ({type(new).__name__})") | |
| if report["protected"]: | |
| print(" --- Protected (schema marker, not overwritten) ---") | |
| for p, v in report["protected"]: | |
| print(f" {p}: {fmt_val(v)}") | |
| if report["no_source"]: | |
| print(" --- Kept (no source in new Settings) ---") | |
| for p, v in report["no_source"]: | |
| print(f" {p}: {fmt_val(v)}") | |
| if report["src_only"]: | |
| print(" --- Dropped (only in new Settings, no placeholder in old) ---") | |
| for p, _ in report["src_only"]: | |
| print(f" {p}") | |
| if __name__ == "__main__": | |
| args = sys.argv[1:] | |
| if args and args[0] in ("-h", "--help"): | |
| print(__doc__) | |
| sys.exit(0) | |
| src = args[0] if len(args) > 0 else DEFAULT_SRC | |
| dst = args[1] if len(args) > 1 else DEFAULT_DST | |
| out = args[2] if len(args) > 2 else DEFAULT_OUT | |
| run(src, dst, out) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment