Skip to content

Instantly share code, notes, and snippets.

@dogtopus
Last active April 30, 2023 19:06
Show Gist options
  • Save dogtopus/92430a958c412d12d306241e671ad4a3 to your computer and use it in GitHub Desktop.
Save dogtopus/92430a958c412d12d306241e671ad4a3 to your computer and use it in GitHub Desktop.
Keygen for the Japanese retail release of Frane: Dragons' Odyssey (2003) (aka: Lost Memory Of Angel Story FraneIII -緋蒼の幻想曲-)
# Keygen for the Japanese retail release of Frane: Dragons' Odyssey (2003)
# For educational purpose only. I take no responsibility for what you will do with it.
#
# Also to EXE-Create:
# Quoting from GabeN: Piracy is a service issue. If FDO (2019) on Steam was actually half
# decent and wasn't having an allegedly dodgy localization and a lot of features from FDO (2003)
# removed (voice acting, mouse control and FMV cutscenes to name a few), this thing simply won't
# exist.
import argparse
import random
# Integer nibble to scrambled hex code and vice-versa
HEX_TABLE = {i: d for i, d in enumerate('9D8C53BF710246AE')}
HEX_TABLE_INVERSE = {d: i for i, d in HEX_TABLE.items()}
def parse_args():
p = argparse.ArgumentParser()
sub = p.add_subparsers(dest='action')
action = sub.add_parser('generate', help='Generate a random serial number.')
action = sub.add_parser('generate-from-part1', help='Generate a serial number from a user-specified part 1.')
action.add_argument('part1', type=int, help='Part 1. Must be within range(30000, 50000).')
action.add_argument('-b', '--base', type=int, default=10, help='Override base for part 2 (default is 10). Must be within range(2, 16).')
action = sub.add_parser('validate', help='Validate a serial number.')
action.add_argument('sn', help='Serial number to validate.')
return p, p.parse_args()
# Shamelessly stolen from https://stackoverflow.com/questions/2267362/how-to-convert-an-integer-to-a-string-in-any-base
# because I'm too lazy to write my own
def number_to_base_le(n, b):
if n == 0:
return [0]
digits = []
while n:
digits.append(int(n % b))
n //= b
return digits
def base_to_num_le(digits, b):
n = 0
for i, digit in enumerate(digits):
n += b ** i * digit
return n
def scramble_digit(digit):
return HEX_TABLE[digit]
def unscramble_digit(digit):
return HEX_TABLE_INVERSE.get(digit, 0)
def gen_sn_from_part1(part1, base):
if not (30000 <= part1 < 50000):
raise ValueError('Part1 must be within range(30000, 50000).')
if not (2 <= base < 16):
raise ValueError('Base must be within range(2, 16).')
part1_digits = number_to_base_le(part1, base)
checksum = (sum(part1_digits) // 3) % 100
# checksum has fixed 2 digits of base 10
checksum_digits = (checksum % 10, checksum // 10)
part2_base = scramble_digit(base)
part2_num = ''.join(scramble_digit(digit) for digit in part1_digits)
part2_checksum = ''.join(scramble_digit(digit) for digit in checksum_digits)
return f'{part1}-{part2_base}{part2_num}{part2_checksum}'
def gen_sn_random():
part1 = random.randrange(30000, 50000)
base = random.randrange(2, 16)
return gen_sn_from_part1(part1, base)
# This doesn't emulate some edge cases and therefore isn't super accurate
def validate_sn(sn):
if len(sn) < 10:
raise ValueError('SN must be longer than 10 characters.')
parts = sn.split('-')
if len(parts) < 2:
raise ValueError('Part 2 does not exist.')
part1_str, part2_str = parts[0], '-'.join(parts[1:])
# Original ignores error and parses as much as it can while this will error out and return nothing
part1 = int(part1_str, 10)
if len(part2_str) < 3:
# Original will wrap around and try to parse the other characters from the delimiter and part1
# but we don't emulate that here
raise ValueError('Part 2 is too short.')
part2_base, part2_num, part2_checksum = part2_str[0], part2_str[1:-2], part2_str[-2:]
part2_base_int = unscramble_digit(part2_base)
part2_digits = tuple(unscramble_digit(digit) for digit in part2_num)
part2_checksum_expected = base_to_num_le(tuple(unscramble_digit(digit) for digit in part2_checksum), 10)
part2_checksum_actual = (sum(part2_digits) // 3) % 100
if part2_checksum_actual != part2_checksum_expected:
raise ValueError('Invalid part 2 checksum.')
part2 = base_to_num_le(part2_digits, part2_base_int)
if not (30000 <= part1 < 50000):
raise ValueError('Part 1 must be in range (30000, 50000).')
if part1 != part2:
raise ValueError('Parts do not match.')
if __name__ == '__main__':
p, args = parse_args()
if args.action == 'generate-from-part1':
print(gen_sn_from_part1(args.part1, args.base))
elif args.action == 'validate':
try:
validate_sn(args.sn)
except ValueError as e:
print(f'{args.sn} is NOT a valid serial number: {e}')
else:
print(f'{args.sn} is a valid serial number.')
else:
print(gen_sn_random())
@dogtopus
Copy link
Author

Also as a bonus: a cringy but funny engineered S/N: 37448-5-BFF-R1el-Kunah-19.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment