├── iterations.bin ├── ReadMe.md └── sentcode.py /iterations.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonowen/sentcode/HEAD/iterations.bin -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 | # sentcode.py 2 | 3 | A Python script to generate the secret codes needed to access landscapes in 4 | Geoff Crammond's classic game: The Sentinel (aka The Sentry). It supports all 5 | known versions of the game (BBC, C64, CPC, Spectrum, ST, PC (DOS), Amiga). 6 | 7 | ## Usage 8 | 9 | Generate secret code for landscape 1234: 10 | ``` 11 | python sentcode.py 1234 12 | ``` 13 | 14 | Generate _all_ landscape codes (slow!): 15 | ``` 16 | python sentcode.py -a 17 | ``` 18 | 19 | For convenience, I've included the [full secret code list](sentinel_codes.txt). 20 | 21 | ## Secret Codes 22 | 23 | Secret codes are created using values taken from the random number generator 24 | (RNG) after generating a landscape. However, since the codes depend only on 25 | the state of the RNG, we only need to know how many values were taken from the 26 | RNG during the generation of each landscape. These counts were captured from 27 | the original game and stored in the bundled `iterations.bin` file. 28 | 29 | The original game supports 10000 landscapes (0000 to 9999). However the game 30 | stores them in BCD format, meaning there are six additional hex landscapes 31 | (A-F) for each ten original landscapes (0-9). These aren't readily accessible 32 | in the original game, but optionally supported by [Augmentinel](https://simonowen.com/spectrum/augmentinel). 33 | 34 | The script supports all 57344 possible landscape numbers (0000 to DFFF). 35 | Landscapes E000 to FFFF are unavailable as they hang the landscape generator. 36 | This is caused by a bias of 2 in the most significant nibble when determining 37 | the number of sentries, leading to a loop exit condition that never passes. 38 | 39 | ## Random Number Generator 40 | 41 | The RNG used is a 40-bit linear feedback shift register. Bits 33 and 20 are 42 | XORed and fed back into bit 0 after each shift. The top 8 bits form the new 43 | random value after 8 shift iterations. 44 | 45 | The RNG is seeded from the 2-byte landscape number (already in BCD format), 46 | which is placed in bits 0-15. Bit 16 is also set to ensure there is at least 47 | one set bit in the state, as the XOR feedback doesn't provide a way to create 48 | set bits from zero inputs. 49 | 50 | ## Generation Method 51 | 52 | The script creates a secret code as follows: 53 | 54 | - seed RNG using landscape number (as detailed above) 55 | - read the required count of RNG calls from iterations.bin 56 | - read 'count' RNG values to simulate landscape generation 57 | - generate and discard 38 pairs of digits (part of code obfuscation!) 58 | - generate 4 pairs of digits to give the final 8-digit secret code 59 | 60 | ## Random Digits 61 | 62 | The secret codes are intentionally different between most versions of the game. 63 | This is achieved by changing the routine that generates a pair of digits using 64 | values from the RNG. 65 | 66 | The BBC and C64 versions use a single 8-bit random value to generate two 67 | digits. The upper nibble is used for the 10s digit and the low nibble for the 68 | 1s digit. If either value is hex (A-F) then 6 is subtracted to bring it back 69 | into decimal digit range. 70 | 71 | The CPC version uses a different RNG value for each digit, generating 1s before 72 | 10s. The Spectrum version does the same but in the opposite order. 73 | 74 | There are only so many ways you can swap the nibbles around, so later versions 75 | read and discard 3 values between each pair of digits. See the script code for 76 | more details. 77 | 78 | --- 79 | 80 | Simon Owen 81 | https://simonowen.com 82 | -------------------------------------------------------------------------------- /sentcode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Secret code generator for The Sentinel (aka The Sentry) 4 | # 5 | # By Simon Owen https://github.com/simonowen/sentcode 6 | 7 | import sys 8 | import struct 9 | import argparse 10 | 11 | num_landscapes = 0xe000 12 | ull = 0 13 | 14 | def seed(landscape_bcd): 15 | global ull 16 | ull = (1 << 16) | landscape_bcd 17 | 18 | def rng(): 19 | global ull 20 | for _ in range(8): 21 | ull <<= 1 22 | ull |= ((ull >> 20) ^ (ull >> 33)) & 1 23 | 24 | return (ull >> 32) & 0xff 25 | 26 | def rng_bcd_digits(): 27 | x = rng() 28 | 29 | # Left digit 30 | a = (x >> 4) & 0xf 31 | if a > 9: 32 | a -= 6 33 | 34 | # Right digit 35 | b = (x >> 0) & 0xf 36 | if b > 9: 37 | b -= 6 38 | 39 | return (a << 4) | b 40 | 41 | def bbc_c64_digits(): 42 | return rng_bcd_digits() 43 | 44 | def cpc_digits(): 45 | b = (rng_bcd_digits() >> 4) & 0x0f 46 | a = (rng_bcd_digits() << 4) & 0xf0 47 | return a | b 48 | 49 | def spectrum_digits(): 50 | b = rng_bcd_digits() & 0x0f 51 | a = rng_bcd_digits() & 0xf0 52 | return a | b 53 | 54 | def pc_st_digits(): 55 | for _ in range(3): 56 | rng() 57 | 58 | b = rng_bcd_digits() & 0x0f 59 | a = rng_bcd_digits() & 0xf0 60 | return a | b 61 | 62 | def amiga_digits(): 63 | for _ in range(3): 64 | rng() 65 | 66 | b = (rng_bcd_digits() >> 4) & 0x0f 67 | a = (rng_bcd_digits() << 4) & 0xf0 68 | return a | b 69 | 70 | def generate_code(fn_pair, state): 71 | global ull 72 | 73 | # Set cached seeded state after landscape generation 74 | ull = state 75 | 76 | # Advance the RNG in digit pairs, for the code check obfuscation. 77 | for _ in range(0xa5 - 0x80 + 1): 78 | fn_pair() 79 | 80 | # The next 4 values are the landscape code. 81 | a, b, c, d = fn_pair(), fn_pair(), fn_pair(), fn_pair() 82 | return "{:02X}{:02X}{:02X}{:02X}".format(a, b, c, d) 83 | 84 | def generate_codes(landscape_bcd): 85 | global ull, iterations 86 | 87 | # Seed RNG using landscape number. 88 | seed(landscape_bcd) 89 | 90 | # Advance the RNG by the amount the actual landscape generation would have. 91 | for _ in range(iterations[landscape_bcd]): 92 | rng() 93 | 94 | # Save RNG state as the starting point to generate codes. 95 | state = ull 96 | 97 | return [ 98 | generate_code(bbc_c64_digits, state), 99 | generate_code(cpc_digits, state), 100 | generate_code(spectrum_digits, state), 101 | generate_code(pc_st_digits, state), 102 | generate_code(amiga_digits, state)] 103 | 104 | def print_one(landscape_bcd): 105 | codes = generate_codes(landscape_bcd) 106 | 107 | entry_fmt = "{:>25s} = {}" 108 | print("Landscape {:04X}:\n".format(landscape_bcd)) 109 | print(entry_fmt.format("BBC Micro / Commodore 64", codes[0])) 110 | print(entry_fmt.format("Amstrad CPC", codes[1])) 111 | print(entry_fmt.format("Sinclair Spectrum", codes[2])) 112 | print(entry_fmt.format("IBM PC / Atari ST", codes[3])) 113 | print(entry_fmt.format("Commodore Amiga", codes[4])) 114 | 115 | def print_all(extended): 116 | sep = "+------+----------+----------+----------+----------+----------+" 117 | 118 | print("Secret codes for all landscapes in The Sentinel (aka The Sentry)\n") 119 | print("Generated by: https://github.com/simonowen/sentcode\n") 120 | print(sep) 121 | print(("| Land |" + " {:^8s} |" * 5).format( 122 | "BBC/C64", "CPC", "Spectrum", "PC/ST", "Amiga")) 123 | print(sep) 124 | 125 | for landscape_bcd in range(num_landscapes): 126 | if not extended and not "{:X}".format(landscape_bcd).isdigit(): 127 | continue 128 | 129 | codes = generate_codes(landscape_bcd) 130 | print(("| {:04X} |" + " {} |" * 5).format(landscape_bcd, *codes)) 131 | 132 | print(sep) 133 | 134 | def main(args): 135 | global ull, iterations 136 | 137 | # Read the RNG iterations count for each landscape. 138 | with open(sys.path[0] + '/iterations.bin', 'rb') as f: 139 | iterations = struct.unpack("<{}h".format(num_landscapes), f.read()) 140 | 141 | if args.all: 142 | print_all(args.extended) 143 | elif args.landscape == None: 144 | parser.print_help() 145 | elif args.landscape >= 0 and args.landscape < num_landscapes: 146 | print_one(args.landscape) 147 | else: 148 | print("Landscape number must be in range {:04X}-{:04X}".format(0, num_landscapes - 1)) 149 | 150 | if __name__ == "__main__": 151 | parser = argparse.ArgumentParser( 152 | description="Landscape code generator for The Sentinel.", 153 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 154 | parser.add_argument('landscape', help='landscape number', type=lambda x: int(x,16), nargs='?') 155 | parser.add_argument('-a', help='all landscape codes (SLOW!)', dest='all', action='store_true') 156 | parser.add_argument('-x', help='include extended landscapes', dest='extended', action='store_true') 157 | main(parser.parse_args()) 158 | --------------------------------------------------------------------------------