{"slug": "minion-rush-windows-store-edition-save-data-editor-ai-generated", "title": "Minion Rush Windows Store Edition save data editor (AI generated)", "summary": "A developer released version 1.2.0 of a Python-based save data editor for the Windows Store edition of Minion Rush. The tool rebuilds savegame files from JSON, using XTEA encryption and patching wallet shadow values to ensure consistency. It supports custom keys and connectivity trackers, offering a practical utility for modding the game.", "body_md": "|\n#!/usr/bin/env python3 |\n|\n\"\"\" |\n|\nMinion Rush savegame encoder, version 1.2.0. |\n|\n|\n|\nRebuilds a Windows Store Minion Rush savegame from JSON produced by |\n|\nminion_save_decoder_v2_modified.py. |\n|\n|\n|\nA template savegame is required because the decoded JSON intentionally does not |\n|\ncontain every opaque RedundantStream marker byte. The encoder preserves the |\n|\nphysical wrapper and replaces each valid redundant payload copy in-place. |\n|\n|\n|\nTypical use: |\n|\npython minion_save_encoder_v1.py savegame.json --template savegame --out savegame.new |\n|\n|\n|\nThe top-level decoded_values object is applied to the Player and MapMgr blobs. |\n|\nOptionally, the same banana/token values can also be written to a raw |\n|\nconnectivity_trackers file using --connectivity-template/--connectivity-out. |\n|\n|\n|\nImportant: version-0x1C Player data contains an on-disk wallet shadow near the |\n|\nend of the serialized blob. When its valid flag is 1, the deserializer uses the |\n|\nshadow values to overwrite the ordinary banana/token fields. This encoder patches |\n|\nboth representations together. |\n|\n|\n|\nFields: |\n|\nbanana_count -> Player + 0x10 (u32 little-endian) |\n|\ntoken_count -> Player + 0x0C (u32 little-endian) |\n|\ncurrent_jelly_lab_level -> MapMgr + 0x08 (u32 little-endian) |\n|\n\"\"\" |\n|\n|\n|\nfrom __future__ import annotations |\n|\n|\n|\nimport argparse |\n|\nimport base64 |\n|\nimport copy |\n|\nimport json |\n|\nimport math |\n|\nimport struct |\n|\nimport sys |\n|\nimport zlib |\n|\nfrom pathlib import Path |\n|\nfrom typing import Any |\n|\n|\n|\nHEADER_MARKER_SIZE = 0xB0 |\n|\nHEADER_SIZE = HEADER_MARKER_SIZE + 8 |\n|\nPAYLOAD_MARKER_SIZE = 0x90 |\n|\nSAVE_MAGIC = 0xED |\n|\nDEFAULT_KEY_SOURCE = b\"ERROR: invalid stream\" |\n|\nMAX_U32 = 0xFFFFFFFF |\n|\nVERSION = \"1.2.0\" |\n|\n|\n|\n|\n|\nclass EncodeError(Exception): |\n|\npass |\n|\n|\n|\n|\n|\ndef derive_xtea_key(source: bytes) -> bytes: |\n|\nkey = bytearray(16) |\n|\nfor i, byte in enumerate(source): |\n|\nkey[i & 0x0F] ^= byte |\n|\nreturn bytes(key) |\n|\n|\n|\n|\n|\ndef xtea_encrypt_block(block: bytes, key: bytes) -> bytes: |\n|\nif len(block) != 8: |\n|\nraise ValueError(\"XTEA block must be exactly 8 bytes\") |\n|\nif len(key) != 16: |\n|\nraise ValueError(\"XTEA key must be exactly 16 bytes\") |\n|\n|\n|\nv0, v1 = struct.unpack(\"<2I\", block) |\n|\nk = struct.unpack(\"<4I\", key) |\n|\ndelta = 0x9E3779B9 |\n|\ntotal = 0 |\n|\n|\n|\nfor _ in range(32): |\n|\nv0 = ( |\n|\nv0 |\n|\n+ ( |\n|\n(((v1 << 4) ^ (v1 >> 5)) + v1) |\n|\n^ ((total + k[total & 3]) & MAX_U32) |\n|\n) |\n|\n) & MAX_U32 |\n|\ntotal = (total + delta) & MAX_U32 |\n|\nv1 = ( |\n|\nv1 |\n|\n+ ( |\n|\n(((v0 << 4) ^ (v0 >> 5)) + v0) |\n|\n^ ((total + k[(total >> 11) & 3]) & MAX_U32) |\n|\n) |\n|\n) & MAX_U32 |\n|\n|\n|\nreturn struct.pack(\"<2I\", v0, v1) |\n|\n|\n|\n|\n|\ndef xtea_encrypt(data: bytes, key: bytes) -> bytes: |\n|\nif len(data) % 8: |\n|\nraise EncodeError(\"XTEA plaintext must be padded to a multiple of 8 bytes\") |\n|\nreturn b\"\".join( |\n|\nxtea_encrypt_block(data[offset:offset + 8], key) |\n|\nfor offset in range(0, len(data), 8) |\n|\n) |\n|\n|\n|\n|\n|\ndef parse_key(args: argparse.Namespace) -> bytes: |\n|\nsupplied = sum( |\n|\nvalue is not None |\n|\nfor value in (args.key_source, args.key_source_hex, args.xtea_key_hex) |\n|\n) |\n|\nif supplied > 1: |\n|\nraise EncodeError( |\n|\n\"use only one of --key-source, --key-source-hex, or --xtea-key-hex\" |\n|\n) |\n|\n|\n|\nif args.xtea_key_hex is not None: |\n|\ntry: |\n|\nkey = bytes.fromhex(args.xtea_key_hex) |\n|\nexcept ValueError as exc: |\n|\nraise EncodeError(f\"invalid --xtea-key-hex: {exc}\") from exc |\n|\nif len(key) != 16: |\n|\nraise EncodeError(\"--xtea-key-hex must decode to exactly 16 bytes\") |\n|\nreturn key |\n|\n|\n|\nif args.key_source_hex is not None: |\n|\ntry: |\n|\nsource = bytes.fromhex(args.key_source_hex) |\n|\nexcept ValueError as exc: |\n|\nraise EncodeError(f\"invalid --key-source-hex: {exc}\") from exc |\n|\nreturn derive_xtea_key(source) |\n|\n|\n|\nif args.key_source is not None: |\n|\nreturn derive_xtea_key(args.key_source.encode(args.key_encoding)) |\n|\n|\n|\nreturn derive_xtea_key(DEFAULT_KEY_SOURCE) |\n|\n|\n|\n|\n|\ndef checked_u32(value: Any, name: str) -> int: |\n|\nif isinstance(value, bool) or not isinstance(value, int): |\n|\nraise EncodeError(f\"{name} must be an integer\") |\n|\nif not 0 <= value <= MAX_U32: |\n|\nraise EncodeError(f\"{name} must be in the range 0..{MAX_U32}\") |\n|\nreturn value |\n|\n|\n|\n|\n|\n|\n|\ndef rol32(value: int, count: int) -> int: |\n|\ncount &= 31 |\n|\nvalue &= MAX_U32 |\n|\nreturn ((value << count) | (value >> ((32 - count) & 31))) & MAX_U32 |\n|\n|\n|\n|\n|\ndef wallet_shadow_encode(value: int) -> int: |\n|\nreturn rol32(checked_u32(value, \"wallet shadow value\"), 10) ^ 0x48607922 |\n|\n|\n|\n|\n|\ndef patch_wallet_shadow( |\n|\nblob: bytearray, |\n|\nold_tokens: int, |\n|\nold_bananas: int, |\n|\nnew_tokens: int, |\n|\nnew_bananas: int, |\n|\n) -> dict[str, Any]: |\n|\n\"\"\"Patch the serialized wallet shadow used by Player format 0x1C. |\n|\n|\n|\nSerialized layout is contiguous: |\n|\nuint8 valid_flag == 1 |\n|\nuint32 token_shadow |\n|\nuint32 banana_shadow |\n|\n|\n|\nshadow(value) = ROL32(value, 10) XOR 0x48607922. |\n|\n\"\"\" |\n|\nold_seq = ( |\n|\nb\"\\x01\" |\n|\n+ struct.pack(\"<I\", wallet_shadow_encode(old_tokens)) |\n|\n+ struct.pack(\"<I\", wallet_shadow_encode(old_bananas)) |\n|\n) |\n|\nnew_seq = ( |\n|\nb\"\\x01\" |\n|\n+ struct.pack(\"<I\", wallet_shadow_encode(new_tokens)) |\n|\n+ struct.pack(\"<I\", wallet_shadow_encode(new_bananas)) |\n|\n) |\n|\nmatches = [] |\n|\nstart = 0 |\n|\nwhile True: |\n|\npos = blob.find(old_seq, start) |\n|\nif pos < 0: |\n|\nbreak |\n|\nmatches.append(pos) |\n|\nstart = pos + 1 |\n|\n|\n|\nif len(matches) != 1: |\n|\nraise EncodeError( |\n|\n\"could not identify a unique version-0x1C wallet shadow: \" |\n|\nf\"expected one sequence for tokens={old_tokens}, bananas={old_bananas}, \" |\n|\nf\"found {len(matches)}\" |\n|\n) |\n|\n|\n|\noffset = matches[0] |\n|\nblob[offset:offset + len(old_seq)] = new_seq |\n|\nreturn { |\n|\n\"offset\": offset, |\n|\n\"old_token_shadow\": f\"0x{wallet_shadow_encode(old_tokens):08X}\", |\n|\n\"old_banana_shadow\": f\"0x{wallet_shadow_encode(old_bananas):08X}\", |\n|\n\"new_token_shadow\": f\"0x{wallet_shadow_encode(new_tokens):08X}\", |\n|\n\"new_banana_shadow\": f\"0x{wallet_shadow_encode(new_bananas):08X}\", |\n|\n} |\n|\n|\n|\ndef decode_blob(record: dict[str, Any], name: str) -> bytearray: |\n|\nif record.get(\"kind\") != \"blob\" or \"data_base64\" not in record: |\n|\nraise EncodeError(f\"record {name!r} is not a Base64 blob\") |\n|\ntry: |\n|\nreturn bytearray(base64.b64decode(record[\"data_base64\"], validate=True)) |\n|\nexcept (ValueError, TypeError) as exc: |\n|\nraise EncodeError(f\"record {name!r} has invalid data_base64\") from exc |\n|\n|\n|\n|\n|\ndef patch_u32(blob: bytearray, offset: int, value: Any, field: str) -> None: |\n|\nvalue = checked_u32(value, field) |\n|\nif offset + 4 > len(blob): |\n|\nraise EncodeError( |\n|\nf\"cannot write {field}: blob is {len(blob)} bytes, offset is 0x{offset:X}\" |\n|\n) |\n|\nstruct.pack_into(\"<I\", blob, offset, value) |\n|\n|\n|\n|\n|\ndef apply_decoded_values(document: dict[str, Any]) -> dict[str, Any]: |\n|\nrecords = document.get(\"records\") |\n|\nif not isinstance(records, dict): |\n|\nraise EncodeError(\"JSON root must contain a 'records' object\") |\n|\n|\n|\nreport: dict[str, Any] = {} |\n|\ntop = document.get(\"decoded_values\") |\n|\nif top is None: |\n|\ntop = {} |\n|\nif not isinstance(top, dict): |\n|\nraise EncodeError(\"decoded_values must be an object\") |\n|\n|\n|\nplayer = records.get(\"Player\") |\n|\nif isinstance(player, dict): |\n|\nvalues: dict[str, Any] = {} |\n|\nif isinstance(player.get(\"decoded\"), dict): |\n|\nvalues.update(player[\"decoded\"]) |\n|\nvalues.update({k: top[k] for k in (\"token_count\", \"banana_count\") if k in top}) |\n|\n|\n|\nif values: |\n|\nblob = decode_blob(player, \"Player\") |\n|\nold_tokens = struct.unpack_from(\"<I\", blob, 0x0C)[0] |\n|\nold_bananas = struct.unpack_from(\"<I\", blob, 0x10)[0] |\n|\nnew_tokens = checked_u32(values.get(\"token_count\", old_tokens), \"token_count\") |\n|\nnew_bananas = checked_u32(values.get(\"banana_count\", old_bananas), \"banana_count\") |\n|\n|\n|\nshadow_report = patch_wallet_shadow( |\n|\nblob, old_tokens, old_bananas, new_tokens, new_bananas |\n|\n) |\n|\npatch_u32(blob, 0x0C, new_tokens, \"token_count\") |\n|\npatch_u32(blob, 0x10, new_bananas, \"banana_count\") |\n|\n|\n|\nplayer[\"data_base64\"] = base64.b64encode(blob).decode(\"ascii\") |\n|\nplayer[\"length\"] = len(blob) |\n|\nplayer[\"_aux\"] = len(blob) |\n|\nreport[\"Player\"] = { |\n|\n\"old_token_count\": old_tokens, |\n|\n\"old_banana_count\": old_bananas, |\n|\n\"new_token_count\": new_tokens, |\n|\n\"new_banana_count\": new_bananas, |\n|\n\"wallet_shadow\": shadow_report, |\n|\n} |\n|\n|\n|\nmap_mgr = records.get(\"MapMgr\") |\n|\nif isinstance(map_mgr, dict): |\n|\nvalues = {} |\n|\nif isinstance(map_mgr.get(\"decoded\"), dict): |\n|\nvalues.update(map_mgr[\"decoded\"]) |\n|\nif \"current_jelly_lab_level\" in top: |\n|\nvalues[\"current_jelly_lab_level\"] = top[\"current_jelly_lab_level\"] |\n|\n|\n|\nif \"current_jelly_lab_level\" in values: |\n|\nblob = decode_blob(map_mgr, \"MapMgr\") |\n|\npatch_u32( |\n|\nblob, |\n|\n0x08, |\n|\nvalues[\"current_jelly_lab_level\"], |\n|\n\"current_jelly_lab_level\", |\n|\n) |\n|\nmap_mgr[\"data_base64\"] = base64.b64encode(blob).decode(\"ascii\") |\n|\nmap_mgr[\"length\"] = len(blob) |\n|\nmap_mgr[\"_aux\"] = len(blob) |\n|\nreport[\"MapMgr\"] = { |\n|\n\"current_jelly_lab_level\": values[\"current_jelly_lab_level\"] |\n|\n} |\n|\n|\n|\nreturn report |\n|\n|\n|\ndef encode_record_string(value: str) -> bytes: |\n|\nif not isinstance(value, str): |\n|\nraise EncodeError(\"record name/string value must be a string\") |\n|\nraw = value.encode(\"utf-8\") |\n|\nif len(raw) > 0xFFFF: |\n|\nraise EncodeError(\"record name/string exceeds 65535 encoded bytes\") |\n|\nreturn struct.pack(\"<H\", len(raw)) + raw |\n|\n|\n|\n|\n|\ndef parse_nonfinite(value: Any, field: str) -> float: |\n|\nif isinstance(value, (int, float)) and not isinstance(value, bool): |\n|\nreturn float(value) |\n|\nif isinstance(value, str): |\n|\nmapping = { |\n|\n\"nan\": math.nan, |\n|\n\"inf\": math.inf, |\n|\n\"+inf\": math.inf, |\n|\n\"-inf\": -math.inf, |\n|\n} |\n|\nparsed = mapping.get(value.lower()) |\n|\nif parsed is not None: |\n|\nreturn parsed |\n|\nraise EncodeError(f\"{field} must be a number, 'nan', 'inf', or '-inf'\") |\n|\n|\n|\n|\n|\ndef encode_record_value(record: dict[str, Any]) -> bytes: |\n|\nif not isinstance(record, dict): |\n|\nraise EncodeError(\"each record value must be an object\") |\n|\ntry: |\n|\ntype_id = int(record[\"_type\"]) |\n|\nexcept (KeyError, TypeError, ValueError) as exc: |\n|\nraise EncodeError(\"record is missing a valid _type\") from exc |\n|\n|\n|\nbody: bytes |\n|\naux: int |\n|\n|\n|\nif type_id == 0: |\n|\nencoded = record.get(\"data_base64\", \"\") |\n|\ntry: |\n|\nbody = base64.b64decode(encoded, validate=True) if encoded else b\"\" |\n|\nexcept (ValueError, TypeError) as exc: |\n|\nraise EncodeError(\"type-0 record has invalid data_base64\") from exc |\n|\naux = len(body) |\n|\n|\n|\nelif type_id == 1: |\n|\nif \"raw\" in record: |\n|\nraw = checked_u32(record[\"raw\"], \"type-1 raw\") |\n|\nelse: |\n|\nraw = 1 if bool(record.get(\"value\")) else 0 |\n|\nbody = struct.pack(\"<I\", raw) |\n|\naux = checked_u32(record.get(\"_aux\", 0), \"type-1 _aux\") |\n|\n|\n|\nelif type_id == 2: |\n|\nraw_hex = record.get(\"raw_hex\") |\n|\nif raw_hex is not None: |\n|\ntry: |\n|\nbody = bytes.fromhex(raw_hex) |\n|\nexcept ValueError as exc: |\n|\nraise EncodeError(\"type-2 raw_hex is invalid\") from exc |\n|\nif len(body) != 8: |\n|\nraise EncodeError(\"type-2 raw_hex must contain 8 bytes\") |\n|\nelif \"unsigned\" in record: |\n|\nbody = struct.pack(\"<Q\", int(record[\"unsigned\"])) |\n|\nelif \"signed\" in record: |\n|\nbody = struct.pack(\"<q\", int(record[\"signed\"])) |\n|\nelse: |\n|\nraise EncodeError(\"type-2 record needs raw_hex, unsigned, or signed\") |\n|\naux = checked_u32(record.get(\"_aux\", 0), \"type-2 _aux\") |\n|\n|\n|\nelif type_id == 3: |\n|\nraw_hex = record.get(\"raw_hex\") |\n|\nif raw_hex is not None: |\n|\ntry: |\n|\nbody = bytes.fromhex(raw_hex) |\n|\nexcept ValueError as exc: |\n|\nraise EncodeError(\"type-3 raw_hex is invalid\") from exc |\n|\nif len(body) != 4: |\n|\nraise EncodeError(\"type-3 raw_hex must contain 4 bytes\") |\n|\nelif \"unsigned\" in record: |\n|\nbody = struct.pack(\"<I\", checked_u32(record[\"unsigned\"], \"unsigned\")) |\n|\nelif \"signed\" in record: |\n|\nbody = struct.pack(\"<i\", int(record[\"signed\"])) |\n|\nelse: |\n|\nraise EncodeError(\"type-3 record needs raw_hex, unsigned, or signed\") |\n|\naux = checked_u32(record.get(\"_aux\", 0), \"type-3 _aux\") |\n|\n|\n|\nelif type_id == 4: |\n|\nraw_hex = record.get(\"raw_hex\") |\n|\nif raw_hex is not None: |\n|\ntry: |\n|\nbody = bytes.fromhex(raw_hex) |\n|\nexcept ValueError as exc: |\n|\nraise EncodeError(\"type-4 raw_hex is invalid\") from exc |\n|\nif len(body) != 4: |\n|\nraise EncodeError(\"type-4 raw_hex must contain 4 bytes\") |\n|\nelse: |\n|\nbody = struct.pack(\"<f\", parse_nonfinite(record.get(\"value\"), \"float32\")) |\n|\naux = checked_u32(record.get(\"_aux\", 0), \"type-4 _aux\") |\n|\n|\n|\nelif type_id == 5: |\n|\nraw_hex = record.get(\"raw_hex\") |\n|\nif raw_hex is not None: |\n|\ntry: |\n|\nbody = bytes.fromhex(raw_hex) |\n|\nexcept ValueError as exc: |\n|\nraise EncodeError(\"type-5 raw_hex is invalid\") from exc |\n|\nif len(body) != 8: |\n|\nraise EncodeError(\"type-5 raw_hex must contain 8 bytes\") |\n|\nelse: |\n|\nbody = struct.pack(\"<d\", parse_nonfinite(record.get(\"value\"), \"float64\")) |\n|\naux = checked_u32(record.get(\"_aux\", 0), \"type-5 _aux\") |\n|\n|\n|\nelif type_id == 6: |\n|\nbody = encode_record_string(record.get(\"value\", \"\")) |\n|\naux = checked_u32(record.get(\"_aux\", 0), \"type-6 _aux\") |\n|\n|\n|\nelif type_id == 7: |\n|\nbody = bytes(decode_blob(record, \"<blob>\")) |\n|\naux = len(body) |\n|\n|\n|\nelif type_id == 8: |\n|\nnested = record.get(\"value\") |\n|\nif not isinstance(nested, dict): |\n|\nraise EncodeError(\"type-8 record is missing nested 'value' RecordDB\") |\n|\nbody = encode_record_db(nested) |\n|\naux = checked_u32(record.get(\"_aux\", 0), \"type-8 _aux\") |\n|\n|\n|\nelse: |\n|\nraise EncodeError(f\"unsupported RecordDB type {type_id}\") |\n|\n|\n|\nreturn struct.pack(\"<BI\", type_id, aux) + body |\n|\n|\n|\n|\n|\ndef ordered_entries(database: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: |\n|\nrecords = database.get(\"records\") |\n|\nif not isinstance(records, dict): |\n|\nraise EncodeError(\"RecordDB object is missing a 'records' object\") |\n|\n|\n|\nentries: list[tuple[str, dict[str, Any]]] = [] |\n|\nduplicate_names: set[str] = set() |\n|\nduplicates = database.get(\"_duplicates\", {}) |\n|\n|\n|\nif isinstance(duplicates, dict): |\n|\nfor name, group in duplicates.items(): |\n|\nif not isinstance(group, list): |\n|\nraise EncodeError(f\"duplicate group {name!r} must be a list\") |\n|\nduplicate_names.add(name) |\n|\nfor record in group: |\n|\nif not isinstance(record, dict): |\n|\nraise EncodeError(f\"duplicate record {name!r} must be an object\") |\n|\nentries.append((name, record)) |\n|\n|\n|\nfor name, record in records.items(): |\n|\nif not isinstance(record, dict): |\n|\nraise EncodeError(f\"record {name!r} must be an object\") |\n|\n# For duplicate names, _duplicates already contains the first occurrence |\n|\n# as well as every later occurrence. |\n|\nif name not in duplicate_names: |\n|\nentries.append((name, record)) |\n|\n|\n|\ndef sort_key(item: tuple[str, dict[str, Any]]) -> tuple[int, str]: |\n|\nindex = item[1].get(\"_record_index\") |\n|\nreturn (index if isinstance(index, int) else 1 << 60, item[0]) |\n|\n|\n|\nentries.sort(key=sort_key) |\n|\nreturn entries |\n|\n|\n|\n|\n|\ndef encode_record_db(database: dict[str, Any]) -> bytes: |\n|\nentries = ordered_entries(database) |\n|\nout = bytearray(struct.pack(\"<I\", len(entries))) |\n|\nfor name, record in entries: |\n|\nout += encode_record_string(name) |\n|\nout += encode_record_value(record) |\n|\nreturn bytes(out) |\n|\n|\n|\n|\n|\ndef build_logical_payload(recorddb: bytes, key: bytes) -> bytes: |\n|\ninner_crc = zlib.crc32(recorddb) & MAX_U32 |\n|\nplaintext = struct.pack(\"<I\", inner_crc) + recorddb |\n|\npadded_length = (len(plaintext) + 7) & ~7 |\n|\npadded = plaintext + b\"\\x00\" * (padded_length - len(plaintext)) |\n|\nciphertext = xtea_encrypt(padded, key) |\n|\nsection_length = 4 + len(ciphertext) |\n|\nreturn ( |\n|\nstruct.pack(\"<BI\", SAVE_MAGIC, 1) |\n|\n+ struct.pack(\"<II\", section_length, len(plaintext)) |\n|\n+ ciphertext |\n|\n) |\n|\n|\n|\n|\n|\ndef find_all(data: bytes, pattern: bytes) -> list[int]: |\n|\npositions: list[int] = [] |\n|\nstart = 0 |\n|\nwhile True: |\n|\npos = data.find(pattern, start) |\n|\nif pos < 0: |\n|\nreturn positions |\n|\npositions.append(pos) |\n|\nstart = pos + 1 |\n|\n|\n|\n|\n|\ndef locate_template_payloads(data: bytes) -> tuple[int, int, list[int], list[int]]: |\n|\nif len(data) < 2 * HEADER_SIZE + PAYLOAD_MARKER_SIZE: |\n|\nraise EncodeError(\"template is too small to contain a RedundantStream wrapper\") |\n|\n|\n|\nheader_marker = data[:HEADER_MARKER_SIZE] |\n|\nstored_crc, payload_len = struct.unpack_from(\"<II\", data, HEADER_MARKER_SIZE) |\n|\nheader_positions = find_all(data, header_marker) |\n|\nheader_set = set(header_positions) |\n|\n|\n|\ncandidate_starts: list[int] = [] |\n|\nfor pos in header_positions: |\n|\nif pos - HEADER_SIZE in header_set and pos + HEADER_SIZE not in header_set: |\n|\ncandidate_starts.append(pos + HEADER_SIZE + PAYLOAD_MARKER_SIZE) |\n|\nfor pos in header_positions: |\n|\ncandidate_starts.append(pos + HEADER_SIZE + PAYLOAD_MARKER_SIZE) |\n|\n|\n|\nvalid_starts: list[int] = [] |\n|\nfor start in dict.fromkeys(candidate_starts): |\n|\nif start + payload_len <= len(data): |\n|\npayload = data[start:start + payload_len] |\n|\nif zlib.crc32(payload) & MAX_U32 == stored_crc: |\n|\nvalid_starts.append(start) |\n|\n|\n|\nif not valid_starts: |\n|\nraise EncodeError(\"could not locate any CRC-valid payload copies in template\") |\n|\n|\n|\nvalid_headers: list[int] = [] |\n|\nfor pos in header_positions: |\n|\nif pos + HEADER_SIZE <= len(data): |\n|\ncrc, length = struct.unpack_from(\"<II\", data, pos + HEADER_MARKER_SIZE) |\n|\nif crc == stored_crc and length == payload_len: |\n|\nvalid_headers.append(pos) |\n|\n|\n|\nreturn stored_crc, payload_len, sorted(set(valid_starts)), valid_headers |\n|\n|\n|\n|\n|\ndef rewrite_template(template: bytes, logical_payload: bytes) -> tuple[bytes, int, int]: |\n|\n_, old_length, payload_starts, header_positions = locate_template_payloads(template) |\n|\nif len(logical_payload) != old_length: |\n|\nraise EncodeError( |\n|\n\"encoded logical payload length changed from \" |\n|\nf\"{old_length} to {len(logical_payload)} bytes. This encoder currently \" |\n|\n\"supports in-place, same-length edits only. Editing decoded counters works; \" |\n|\n\"adding/removing records or changing string/blob lengths does not.\" |\n|\n) |\n|\n|\n|\nresult = bytearray(template) |\n|\nnew_crc = zlib.crc32(logical_payload) & MAX_U32 |\n|\n|\n|\nfor start in payload_starts: |\n|\nresult[start:start + old_length] = logical_payload |\n|\n|\n|\nfor pos in header_positions: |\n|\nstruct.pack_into(\"<II\", result, pos + HEADER_MARKER_SIZE, new_crc, old_length) |\n|\n|\n|\nreturn bytes(result), len(payload_starts), len(header_positions) |\n|\n|\n|\n|\n|\n|\n|\ndef parse_connectivity_trackers(data: bytes) -> tuple[int, int, list[dict[str, Any]]]: |\n|\n\"\"\"Parse the observed compact connectivity_trackers container.\"\"\" |\n|\nif len(data) < 13: |\n|\nraise EncodeError(\"connectivity_trackers template is too small\") |\n|\ndeclared_size, version, stored_crc = struct.unpack_from(\"<IBI\", data, 0) |\n|\nbody = data[9:] |\n|\nactual_crc = zlib.crc32(body) & MAX_U32 |\n|\nif actual_crc != stored_crc: |\n|\nraise EncodeError( |\n|\n\"connectivity_trackers CRC mismatch: \" |\n|\nf\"stored={stored_crc:08x}, calculated={actual_crc:08x}\" |\n|\n) |\n|\ncount = struct.unpack_from(\"<I\", body, 0)[0] |\n|\noffset = 4 |\n|\nrecords: list[dict[str, Any]] = [] |\n|\nfor index in range(count): |\n|\nif offset + 2 > len(body): |\n|\nraise EncodeError(f\"truncated connectivity record {index}\") |\n|\nname_len = struct.unpack_from(\"<H\", body, offset)[0] |\n|\noffset += 2 |\n|\nif offset + name_len + 5 > len(body): |\n|\nraise EncodeError(f\"truncated connectivity record {index}\") |\n|\ntry: |\n|\nname = body[offset:offset + name_len].decode(\"utf-8\") |\n|\nexcept UnicodeDecodeError as exc: |\n|\nraise EncodeError(f\"invalid connectivity record name at index {index}\") from exc |\n|\noffset += name_len |\n|\ntype_code, size = struct.unpack_from(\"<BI\", body, offset) |\n|\noffset += 5 |\n|\nif offset + size > len(body): |\n|\nraise EncodeError(f\"truncated connectivity value {name!r}\") |\n|\nraw = body[offset:offset + size] |\n|\noffset += size |\n|\nrecords.append({\"name\": name, \"type_code\": type_code, \"raw\": raw}) |\n|\nif offset != len(body): |\n|\nraise EncodeError( |\n|\nf\"connectivity_trackers has {len(body) - offset} unexpected trailing bytes\" |\n|\n) |\n|\nbias = declared_size - len(data) |\n|\nreturn version, bias, records |\n|\n|\n|\n|\n|\ndef encode_connectivity_value(type_code: int, value: int, name: str) -> bytes: |\n|\nvalue = checked_u32(value, name) |\n|\nif type_code != 3: |\n|\nraise EncodeError( |\n|\nf\"connectivity field {name!r} has type {type_code}; expected uint32 type 3\" |\n|\n) |\n|\nreturn struct.pack(\"<I\", value) |\n|\n|\n|\n|\n|\ndef patch_connectivity_trackers( |\n|\ntemplate: bytes, |\n|\nbanana_count: int | None, |\n|\ntoken_count: int | None, |\n|\n) -> tuple[bytes, list[str]]: |\n|\nversion, bias, records = parse_connectivity_trackers(template) |\n|\npatches = {\"bananas\": banana_count, \"tokens\": token_count} |\n|\nfound: set[str] = set() |\n|\nbody = bytearray(struct.pack(\"<I\", len(records))) |\n|\nfor rec in records: |\n|\nname = rec[\"name\"] |\n|\nraw = rec[\"raw\"] |\n|\nif name in patches and patches[name] is not None: |\n|\nraw = encode_connectivity_value(rec[\"type_code\"], patches[name], name) |\n|\nfound.add(name) |\n|\nname_raw = name.encode(\"utf-8\") |\n|\nbody += struct.pack(\"<H\", len(name_raw)) + name_raw |\n|\nbody += struct.pack(\"<BI\", rec[\"type_code\"], len(raw)) + raw |\n|\nrequested = {name for name, value in patches.items() if value is not None} |\n|\nmissing = requested - found |\n|\nif missing: |\n|\nraise EncodeError( |\n|\n\"connectivity_trackers is missing requested fields: \" + \", \".join(sorted(missing)) |\n|\n) |\n|\nfile_size = 9 + len(body) |\n|\ndeclared_size = file_size + bias |\n|\ncrc = zlib.crc32(body) & MAX_U32 |\n|\nreturn struct.pack(\"<IBI\", declared_size, version, crc) + body, sorted(found) |\n|\n|\n|\n|\n|\ndef resolve_cli_values(document: dict[str, Any], args: argparse.Namespace) -> tuple[int | None, int | None]: |\n|\ntop = document.setdefault(\"decoded_values\", {}) |\n|\nif not isinstance(top, dict): |\n|\nraise EncodeError(\"decoded_values must be an object\") |\n|\nif args.bananas is not None: |\n|\ntop[\"banana_count\"] = checked_u32(args.bananas, \"--bananas\") |\n|\nif args.tokens is not None: |\n|\ntop[\"token_count\"] = checked_u32(args.tokens, \"--tokens\") |\n|\nbananas = top.get(\"banana_count\") |\n|\ntokens = top.get(\"token_count\") |\n|\nif bananas is not None: |\n|\nbananas = checked_u32(bananas, \"banana_count\") |\n|\nif tokens is not None: |\n|\ntokens = checked_u32(tokens, \"token_count\") |\n|\nreturn bananas, tokens |\n|\n|\n|\ndef main() -> int: |\n|\nparser = argparse.ArgumentParser( |\n|\ndescription=\"Encode decoder JSON back into a Minion Rush savegame\" |\n|\n) |\n|\nparser.add_argument(\"json_file\", type=Path, help=\"JSON produced by the decoder\") |\n|\nparser.add_argument(\"--template\", required=True, type=Path, help=\"original savegame\") |\n|\nparser.add_argument(\"--out\", required=True, type=Path, help=\"output savegame path\") |\n|\nparser.add_argument(\"--bananas\", type=int, help=\"override Player banana count\") |\n|\nparser.add_argument(\"--tokens\", type=int, help=\"override Player token count\") |\n|\nparser.add_argument( |\n|\n\"--connectivity-template\", |\n|\ntype=Path, |\n|\nhelp=\"optional raw connectivity_trackers file to patch with the same balances\", |\n|\n) |\n|\nparser.add_argument( |\n|\n\"--connectivity-out\", |\n|\ntype=Path, |\n|\nhelp=\"output path for patched connectivity_trackers\", |\n|\n) |\n|\nparser.add_argument(\"--key-source\") |\n|\nparser.add_argument(\"--key-source-hex\") |\n|\nparser.add_argument(\"--xtea-key-hex\") |\n|\nparser.add_argument(\"--key-encoding\", default=\"utf-8\") |\n|\nargs = parser.parse_args() |\n|\n|\n|\ntry: |\n|\ndocument = json.loads(args.json_file.read_text(encoding=\"utf-8\")) |\n|\nif not isinstance(document, dict): |\n|\nraise EncodeError(\"JSON root must be an object\") |\n|\n|\n|\ndocument = copy.deepcopy(document) |\n|\nbananas, tokens = resolve_cli_values(document, args) |\n|\npatch_report = apply_decoded_values(document) |\n|\nrecorddb = encode_record_db(document) |\n|\nkey = parse_key(args) |\n|\nlogical_payload = build_logical_payload(recorddb, key) |\n|\ntemplate = args.template.read_bytes() |\n|\noutput, payload_copies, headers = rewrite_template(template, logical_payload) |\n|\nargs.out.write_bytes(output) |\n|\n|\n|\nconnectivity_result = None |\n|\nif args.connectivity_template is not None or args.connectivity_out is not None: |\n|\nif args.connectivity_template is None or args.connectivity_out is None: |\n|\nraise EncodeError( |\n|\n\"--connectivity-template and --connectivity-out must be used together\" |\n|\n) |\n|\nif bananas is None and tokens is None: |\n|\nraise EncodeError( |\n|\n\"no banana/token values are available for connectivity_trackers\" |\n|\n) |\n|\nconnectivity_template = args.connectivity_template.read_bytes() |\n|\nconnectivity_output, patched_fields = patch_connectivity_trackers( |\n|\nconnectivity_template, bananas, tokens |\n|\n) |\n|\nargs.connectivity_out.write_bytes(connectivity_output) |\n|\nconnectivity_result = { |\n|\n\"template\": str(args.connectivity_template), |\n|\n\"output\": str(args.connectivity_out), |\n|\n\"patched_fields\": patched_fields, |\n|\n\"output_size\": len(connectivity_output), |\n|\n\"output_crc32\": f\"{zlib.crc32(connectivity_output) & MAX_U32:08x}\", |\n|\n} |\n|\n|\n|\nresult = { |\n|\n\"encoder_version\": VERSION, |\n|\n\"json\": str(args.json_file), |\n|\n\"template\": str(args.template), |\n|\n\"output\": str(args.out), |\n|\n\"recorddb_length\": len(recorddb), |\n|\n\"logical_payload_length\": len(logical_payload), |\n|\n\"redundant_payload_copies_replaced\": payload_copies, |\n|\n\"header_checksums_updated\": headers, |\n|\n\"output_size\": len(output), |\n|\n\"output_crc32\": f\"{zlib.crc32(output) & MAX_U32:08x}\", |\n|\n\"requested_balances\": { |\n|\n\"banana_count\": bananas, |\n|\n\"token_count\": tokens, |\n|\n}, |\n|\n\"patch_report\": patch_report, |\n|\n\"connectivity_trackers\": connectivity_result, |\n|\n\"wallet_note\": ( |\n|\n\"Patched the ordinary Player currency fields and the version-0x1C \" |\n|\n\"serialized wallet shadow used to overwrite them during load.\" |\n|\n), |\n|\n} |\n|\nprint(json.dumps(result, indent=2)) |\n|\nreturn 0 |\n|\n|\n|\nexcept (OSError, json.JSONDecodeError, EncodeError, ValueError, struct.error) as exc: |\n|\nprint(f\"error: {exc}\", file=sys.stderr) |\n|\nreturn 1 |\n|\n|\n|\n|\n|\nif __name__ == \"__main__\": |\n|\nraise SystemExit(main()) |", "url": "https://wpnews.pro/news/minion-rush-windows-store-edition-save-data-editor-ai-generated", "canonical_source": "https://gist.github.com/marethyu/779786c9d161d9eca133309ce6f58806", "published_at": "2026-07-28 08:34:52+00:00", "updated_at": "2026-08-03 16:31:33.683852+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Minion Rush", "Windows Store", "Python", "XTEA"], "alternates": {"html": "https://wpnews.pro/news/minion-rush-windows-store-edition-save-data-editor-ai-generated", "markdown": "https://wpnews.pro/news/minion-rush-windows-store-edition-save-data-editor-ai-generated.md", "text": "https://wpnews.pro/news/minion-rush-windows-store-edition-save-data-editor-ai-generated.txt", "jsonld": "https://wpnews.pro/news/minion-rush-windows-store-edition-save-data-editor-ai-generated.jsonld"}}