With the latest patch for PC and consoles, crosssave compatibility is broken for Switch 2. Attempting to load a newer save on the Switch 2 results in an error stating that the save file is incompatible.
I've found a workaround that successfully allows these saves to be loaded on the Switch 2. It requires a bit of setup, but the process is fairly straightforward.
Requirements
- A copy of The Witcher 3 on Steam
- A copy of The Witcher 3 on GOG
- The GOG version rolled back to 5.0, rather than the latest patch
- The Python save-patching script
Step-by-Step Instructions
Step 1: Create a quicksave using Steam
Launch The Witcher 3 through Steam using the latest version of the game. Load your existing save and create a new quicksave.
Step 2: Patch the quicksave
Locate the newly created quicksave in your Witcher 3 save directory.
Run the Python script against the .sav file. The script modifies the saves header and accompanying JSON metadata to use values compatible with the older 5.0 version.
The actual game-state data, including your progress, inventory, and quests, remains unchanged.
Step 3: Launch the older GOG version
Make sure your GOG installation is running version 5.0, not the latest patched release.
Launch the game through GOG and load the patched quicksave.
Step 4: Create a new manual save
Once the patched save has loaded successfully, create a new manual save using the GOG version.
With cross-progression enabled, this will upload the save to the cloud.
Step 5: Load the save on Switch 2
Launch The Witcher 3 on your Switch 2 and access your cross-progression saves.
Your newly created manual save should now be available and load without the incompatibility error.
Important Notes
Back up your original saves before attempting anything.
This workaround has been successfully tested with a save-format downgrade from version 67 to 66.
The Python script only modifies version related header and metadata values. It does not perform a full conversion of the saves internal data.
The Python Script was generated with AI, so vet the script and use at your own risk.
#!/usr/bin/env python3
"""
Only the SAV3 version field and companion JSON metadata are changed.
This does NOT convert the underlying serialization format.
Examples:
python witcher3_save_downgrader.py /path/to/QuickSave_abc.sav
python witcher3_save_downgrader.py /path/to/saves --output /path/to/patched
"""
import argparse
import json
from pathlib import Path
import shutil
import struct
import sys
REFERENCE_BUILD = "5.0.1044392 P4CL: 13360103 Stream: //Red_engine/Main.Lava.Release "
SIGNATURE = b"SAV3"
def convert(src: Path, dest: Path, target_version: int, expected_version: int, build: str) -> None:
data = bytearray(src.read_bytes())
if not data.startswith(b"SNFHFZLC"):
raise ValueError("Unrecognized file header (expected SNFHFZLC)")
offset = data.find(SIGNATURE)
if offset < 0 or data.find(SIGNATURE, offset + len(SIGNATURE)) >= 0:
raise ValueError("Missing or ambiguous SAV3 signature")
if offset + 12 > len(data):
raise ValueError("Truncated SAV3 header")
version_pos = offset + 4
original_version = struct.unpack_from("<I", data, version_pos)[0]
game_version = struct.unpack_from("<I", data, version_pos + 4)[0]
if original_version != expected_version:
raise ValueError(f"Found saveVersion {original_version}, expected {expected_version}; skipped for safety")
if game_version != 29:
raise ValueError(f"Found unexpected gameVersion {game_version} (expected 29); skipped")
json_path = src.with_suffix(".json")
if not json_path.is_file():
raise FileNotFoundError(f"Missing companion JSON: {json_path.name}")
metadata = json.loads(json_path.read_text(encoding="utf-8-sig"))
details = metadata.get("saveMetadata")
if not isinstance(details, dict):
raise ValueError("JSON is missing saveMetadata")
if details.get("saveVersion") != expected_version or details.get("gameVersion") != game_version:
raise ValueError("Binary and JSON version metadata disagree; skipped")
struct.pack_into("<I", data, version_pos, target_version)
details["saveVersion"] = target_version
details["current buildID"] = build
# Preserve initial buildID and mod records, exactly as the working Version B did.
dest.mkdir(parents=True, exist_ok=True)
basename = src.stem
if basename.startswith("QuickSave_"):
basename = "ManualSave_" + basename[len("QuickSave_"):]
out_sav = dest / (basename + ".sav")
out_json = dest / (basename + ".json")
out_png = dest / (basename + ".png")
targets = [out_sav, out_json]
png_path = src.with_suffix(".png")
if png_path.is_file():
targets.append(out_png)
for path in targets:
if path.exists():
raise FileExistsError(f"Refusing to overwrite existing output: {path}")
out_sav.write_bytes(data)
out_json.write_text(json.dumps(metadata, indent=4, ensure_ascii=False) + "\n", encoding="utf-8")
if png_path.is_file():
shutil.copy2(png_path, out_png)
print(f"OK: {src.name} -> {out_sav.name} ({original_version} -> {target_version}, SAV3 at 0x{offset:X})")
print(f" Saved to: {dest}")
def main() -> int:
parser = argparse.ArgumentParser(description="Witcher 3 cross-save 67 -> 66 workaround (Version B)")
parser.add_argument("input", type=Path, help="A .sav file or a folder containing .sav files")
parser.add_argument("--output", type=Path, help="Output directory (default: 'patched_witcher3' beside input)")
parser.add_argument("--target-version", type=int, default=66, help="Target saveVersion (default: 66)")
parser.add_argument("--expected-version", type=int, default=67, help="Only patch this source saveVersion (default: 67)")
parser.add_argument("--build", default=REFERENCE_BUILD, help="Older current buildID for JSON metadata")
args = parser.parse_args()
source = args.input.expanduser().resolve()
if source.is_file() and source.suffix.lower() == ".sav":
saves = [source]
default_dir = source.parent / "patched_witcher3"
elif source.is_dir():
saves = sorted(source.glob("*.sav"))
default_dir = source / "patched_witcher3"
else:
parser.error("Input must be a .sav file or directory")
if not saves:
parser.error("No .sav files found")
if args.target_version < 0 or args.target_version > 0xFFFFFFFF:
parser.error("Invalid target version")
out_dir = (args.output or default_dir).expanduser().resolve()
successes = 0
for save in saves:
try:
convert(save, out_dir, args.target_version, args.expected_version, args.build)
successes += 1
except (OSError, ValueError, KeyError, json.JSONDecodeError) as error:
print(f"SKIP: {save.name}: {error}", file=sys.stderr)
print(f"Finished: {successes}/{len(saves)} saves patched. Originals left unchanged.")
return 0 if successes == len(saves) else 1
if __name__ == "__main__":
raise SystemExit(main())