Skip to content

Instantly share code, notes, and snippets.

@Gemba
Last active April 28, 2022 19:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Gemba/1cb0bc7d90e6c03cc6e85d2714f3de99 to your computer and use it in GitHub Desktop.
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
#! /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