Last active
April 28, 2022 19:14
-
-
Save Gemba/1cb0bc7d90e6c03cc6e85d2714f3de99 to your computer and use it in GitHub Desktop.
Modify 'Touché: The Adventures of the Fifth Musketeer' savegames to overcome unsolvable puzzles due to logic errors in the original game script. See also: https://wiki.scummvm.org/index.php?title=Touche/TODO
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 | |
# Modify 'Touché: The Adventures of the Fifth Musketeer' savegames to overcome | |
# unsolvable puzzles due to logic errors in the original game script. | |
# | |
# At some point you may be stuck, because: | |
# You can not take a flask any longer which is needed or you can not | |
# leave the castle to do some puzzle to get a specific item. | |
# | |
# If this does ring a bell you have found the right place. | |
# | |
# Tested with English savegame states. | |
# See also: https://wiki.scummvm.org/index.php?title=Touche/TODO | |
# (C) 2022 Gemba | |
# | |
# This program is free software: you can redistribute it and/or modify it | |
# under the terms of the GNU General Public License as published by the | |
# Free Software Foundation, either version 3 of the License, or (at your | |
# option) any later version. | |
# This program is distributed in the hope that it will be useful, but | |
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY | |
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License | |
# for more details. | |
# You should have received a copy of the GNU General Public License along | |
# with this program. If not, see <https://www.gnu.org/licenses/>. | |
import argparse | |
import gzip | |
import re | |
import struct | |
import sys | |
from pathlib import Path | |
all_items = { | |
0x01: "Money", | |
0x02: "Sword", | |
0x03: "Food", | |
0x04: "Cup", | |
0x05: "Bottle", | |
0x06: "Roast chicken", | |
0x07: "Dagger", | |
0x08: "De Peuple's body", | |
0x09: "Potion", | |
0x0a: "Hat", | |
0x0b: "Boots", | |
0x0c: "Souvenir", | |
0x0d: "Handkerchief", | |
0x0e: "Horseshoe", | |
0x0f: "Tongs", | |
0x10: "Horse linament", | |
0x11: "Flowers", | |
0x12: "Orchids", | |
0x13: "Notice", | |
0x14: "Silver Coin", | |
0x15: "Poem", | |
0x17: "Crucifix", | |
0x18: "Candlestick", | |
0x19: "Altar Cloth", | |
0x1a: "Coffin Plate", | |
0x1b: "Melon", | |
0x1c: "Certificate", | |
0x1d: "Paper", | |
0x1e: "Marked Card", | |
0x1f: "Pass", | |
0x20: "Coals", | |
0x21: "Bucket", | |
0x22: "Ladder", | |
0x23: "Hammer", | |
0x24: "Keys", | |
0x25: "Broken ladder", | |
0x26: "Banana", | |
0x27: "Cooking Roster", | |
0x28: "Formula", | |
0x29: "Chain", | |
0x2a: "Hot water", | |
0x2b: "Stool", | |
0x2c: "Needle and thread", | |
0x2d: "Linament bath", | |
0x2e: "Broken Sandals", | |
0x2f: "Mended sandals", | |
0x30: "Soap", | |
0x31: "The Will", | |
0x32: "Letter", | |
0x33: "Chalk", | |
0x34: "Flag", | |
0x35: "Key", | |
0x36: "Broken pole", | |
0x37: "Bread", | |
0x38: "Cheese", | |
0x39: "Fried Rat", | |
0x3b: "Paddle", | |
0x3c: "Old bottle", | |
0x3d: "Eau de Juliette", | |
0x3e: "Plans", | |
0x3f: "Broken Cathedral", | |
0x40: "Menu", | |
0x41: "Stockings", | |
0x42: "Placard", | |
0x43: "Rope", | |
0x44: "Waxy knife with marks", | |
0x46: "Candle", | |
0x47: "Sticky Altar Cloth", | |
0x48: "Waxy knife", | |
0x49: "Key", | |
0x4a: "Habit", | |
0x4b: "Watch", | |
0x4c: "Coach schedule", | |
0x4d: "Appointment", | |
0x4e: "Coal bucket", | |
0x4f: "File", | |
0x50: "Placard", | |
0x51: "Candlestick", | |
0x52: "Hooked Chain", | |
} | |
def init_cli_parser(): | |
parser = argparse.ArgumentParser( | |
description="Adds an item to Geoffroi's inventory to bypass some" | |
" logic script errors in the original adventure 'Touché: The" | |
" Adventures of the Fifth Musketeer'.") | |
parser.add_argument("savefile", help="the 'touche.<n>' savegame file", | |
nargs='?') | |
parser.add_argument("item_id", help="the item number to add", nargs='?', | |
default=0, type=int) | |
parser.add_argument("-l", "--listitems", | |
help="list the items with their respective item id", | |
action="store_true", default=False) | |
return parser | |
def print_items(): | |
print(f"[*] List of item ids and names:\n") | |
idx = 1 | |
s = "" | |
for k, v in all_items.items(): | |
if k < 3: | |
continue | |
if idx % 3: | |
s = f"{s} {k:2d}: {v:22}" | |
else: | |
print(f"{s} {k:2d}: {v:22}") | |
s = "" | |
idx = idx + 1 | |
print(f"{s}") | |
print(f"\n[*] You will either need '{all_items[61]}' (61) or" | |
f" '{all_items[9]}' (9) to be able to continue the game, depending" | |
" on your saved game progress. See also" | |
" https://wiki.scummvm.org/index.php?title=Touche/TODO") | |
def read_inventory(bin): | |
# start of Geoffroi's inventory is variable in savefile but has at least | |
# NULL_SLIDE zeros (each 2 bytes) before | |
NULL_SLIDE = 13 * 8 | |
ctr = 0 | |
start_inv = 0 | |
inv = [] | |
has_sword = False | |
free_item_slot = 0 | |
# each info is held in a unsigned short | |
for i in range(0, len(bin), 2): | |
uint16_le = struct.unpack("<H", bin[i:i + 2])[0] | |
if start_inv == 0: | |
# try to find NULL_SLIDE zeros | |
if uint16_le == 0: | |
ctr = ctr + 1 | |
if ctr > NULL_SLIDE: | |
start_inv = 1 | |
else: | |
ctr = 0 | |
elif start_inv == 1: | |
# at least NULL_SLIDE zeros found, search for first item entry | |
if uint16_le != 0: | |
start_inv = 2 | |
if start_inv == 2: | |
# remember all items and test for sword (only in Geoffroi's inv.) | |
if uint16_le != 0: | |
inv.append(uint16_le) | |
if uint16_le == 2: | |
has_sword = True | |
else: | |
start_inv = 0 | |
ctr = 0 | |
if not has_sword: | |
# not Geoffroi's inventory | |
inv = [] | |
has_sword = False | |
else: | |
# remember position for new item | |
free_item_slot = i | |
return inv, free_item_slot, has_sword | |
if __name__ == "__main__": | |
parser = init_cli_parser() | |
args = parser.parse_args(args=None if sys.argv[1:] else ['-l']) | |
if args.listitems: | |
print_items() | |
sys.exit(0) | |
src_savefile = args.savefile | |
item_to_add = args.item_id | |
if not Path(src_savefile).exists(): | |
print(f"[!] Aieee! File not found {src_savefile}.") | |
sys.exit(1) | |
if item_to_add < 0x03 or item_to_add not in all_items.keys(): | |
print(f"[!] Aieee! Item id is invalid/missing.") | |
sys.exit(1) | |
with gzip.open(src_savefile, 'rb') as archive: | |
bin = archive.read() | |
SAVEGAME_TITLE_MAXLEN = 30 | |
savename = bin[4:4 + SAVEGAME_TITLE_MAXLEN] | |
savename = savename.decode('UTF-8').strip('\x00') | |
# all savegame data is little endian | |
end_marker = struct.unpack("<I", bin[-4:])[0] | |
if end_marker != 0x55aa55aa: | |
print("[!] Aieee! Not a valid savegame.") | |
sys.exit(1) | |
inv, free_item_slot, has_sword = read_inventory(bin) | |
if not has_sword: | |
print("[!] Aieee! Inventory of Geoffroi not found.") | |
sys.exit(1) | |
print("[*] Detected Geoffroi's inventory:") | |
for v in inv: | |
print(f" {v:2d} (0x{v:02X}00): {all_items[v]}") | |
if item_to_add in inv: | |
print( | |
f"[-] Item '{all_items[item_to_add]}' ({item_to_add}) already in " | |
"inventory. No action.") | |
sys.exit(1) | |
bin_new = bytearray(bin) | |
bin_new[free_item_slot] = item_to_add | |
print(f"[+] Added item {item_to_add:d} (0x{item_to_add:02X}00):" | |
f" {all_items[item_to_add]}") | |
# remove possible title marker from previous patching | |
savename = re.sub(r'\(\+\s.+\)', '', savename).rstrip() | |
max_len = SAVEGAME_TITLE_MAXLEN - len(savename) - 1 | |
savename_new = f"{savename}{'(+ ' + all_items[item_to_add]:>{max_len}})" | |
bin_new[4:4 + SAVEGAME_TITLE_MAXLEN] = map(ord, savename_new) | |
print(f"[+] Savegame title: '{savename_new}'") | |
bn, ext = Path(src_savefile).name.split('.') | |
tgt_savefile = None | |
for ext_new in range(int(ext) + 1, 100): | |
tgt_savefile = f"{bn}.{ext_new}" | |
if not Path(tgt_savefile).exists(): | |
break | |
if not tgt_savefile: | |
print("[!] Aieee! Can not save. ScummVM saveslots exceeded.") | |
sys.exit(1) | |
print(f"[+] Savegame outfile: '{tgt_savefile}'") | |
with gzip.open(Path(src_savefile).parent / tgt_savefile, 'wb') as archive: | |
archive.write(bin_new) | |
print("[*] Done.\n 'Perfect time for supper.' -- Henri") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment