Skip to content

Instantly share code, notes, and snippets.

@andrzejsliwa
Last active May 25, 2026 12:17
Show Gist options
  • Select an option

  • Save andrzejsliwa/1a6c9e3db22b44e0df182e17e281a74b to your computer and use it in GitHub Desktop.

Select an option

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
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
#!/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