Skip to content

Instantly share code, notes, and snippets.

@ketsuban
Created June 21, 2025 19:44
Show Gist options
  • Save ketsuban/87e9c202c3fb97ce547bf55a3036aede to your computer and use it in GitHub Desktop.
Save ketsuban/87e9c202c3fb97ce547bf55a3036aede to your computer and use it in GitHub Desktop.
Rust code for decrypting the banlist passwords in Yu-Gi-Oh! Ultimate Masters - World Championship Tournament 2006
use std::collections::HashMap;
use std::sync::LazyLock;
// const CHARSET: &str = "ABCDEFGHJKLMNPQRTUVWXYZacdefghijkmnprstuvwxyz23478&#!?+-÷=%@★●▲■";
// static MAPPING: LazyLock<HashMap<char, u8>> = LazyLock::new(|| {
// let mut map = HashMap::new();
// // Each character is converted into a 6-bit value from 0 to 63.
// for (i, ch) in CHARSET.chars().enumerate() {
// map.insert(ch, i);
// }
// // I don't have noted down what these go to. I suspect the fact the map of character to
// // value is not injective means I felt no need to note them. I suspect they all go to 0, so
// // they are treated the same as A.
// for ch in "IOSbloq1569".chars() {
// map.insert(ch, 0);
// }
// map
// });
static MAPPING: LazyLock<HashMap<char, u8>> = LazyLock::new(|| HashMap::from([
('A', 0),
('B', 1),
('C', 2),
('D', 3),
('E', 4),
('F', 5),
('G', 6),
('H', 7),
('I', 0), // assumed
('J', 8),
('K', 9),
('L', 10),
('M', 11),
('N', 12),
('O', 0), // assumed
('P', 13),
('Q', 14),
('R', 15),
('S', 0), // assumed
('T', 16),
('U', 17),
('V', 18),
('W', 19),
('X', 20),
('Y', 21),
('Z', 22),
('a', 23),
('b', 0), // assumed
('c', 24),
('d', 25),
('e', 26),
('f', 27),
('g', 28),
('h', 29),
('i', 30),
('j', 31),
('k', 32),
('l', 0), // assumed
('m', 33),
('n', 34),
('o', 0), // assumed
('p', 35),
('q', 0), // assumed
('r', 36),
('s', 37),
('t', 38),
('u', 39),
('v', 40),
('w', 41),
('x', 42),
('y', 43),
('z', 44),
('1', 0), // assumed
('2', 45),
('3', 46),
('4', 47),
('5', 0), // assumed
('6', 0), // assumed
('7', 48),
('8', 49),
('9', 0), // assumed
('&', 50),
('#', 51),
('!', 52),
('?', 53),
('+', 54),
('-', 55),
('÷', 56),
('=', 57),
('%', 58),
('@', 59),
('★', 60),
('●', 61),
('▲', 62),
('■', 63),
]));
fn decrypt(password: &str) -> Vec<u8> {
let mut output = Vec::new();
let mut accum: u16 = 0;
let mut slack = 0;
for byte in password.chars().map(|c| MAPPING[&c]) {
accum |= u16::from(byte) << slack;
slack += 6;
if slack >= 8 {
output.push(u8::try_from(accum & 0xFF).unwrap() ^ 0x39);
accum >>= 8;
slack -= 8;
}
}
if slack != 0 {
// [05:46] GenericMadScientist: If you have a byte at the end you need to pad, then that
// last byte is not xored
accum <<= 8 - slack;
output.push(u8::try_from(accum & 0xFF).unwrap());
} else {
// [05:59] GenericMadScientist: Ah, looking a bit more closely, if the number of bits do fit
// perfectly into bytes then for the end you get an extra 0 byte, if I understand this
// correctly.
output.push(0);
}
output
}
// March 2006
const PASSWORD: &str = "v2AQz8Gfz●Lup3KEWQ÷rmQ=vWGxrMrpBLeaP=rWQ@zWQ=EWQU7uMvyn?f7YG8-BWgJ?D";
// September 2006
// const PASSWORD: &str = "Vh7Qz8Gfz●Lrp2LAWQ@r2Q=v#Gx!MrpBdiaN=rWQ@zWQ=EWQUzmM8-C%x7uHZ4m?g7HEgsUhe÷▲EV●k4LcvD";
// March 2007
// const PASSWORD: &str = "HXkQz8Gfz8LEp2LNUQMrmQ=v▲Gx!MrpPdiHR=rWQ@vWQ=EWQU7uMf÷V★2k3LZ4m?gJsH▲xUheJ?DgskmQA";
// September 2007
// const PASSWORD: &str = "Q2aRz8Gfz8LHpsLNUQ@v4Q=v★Ge!MrpPK3H!=rWQ@rpB=XWQU7uMf÷V★2k3LZ#8XiJsHFvm4ecLH?wmmic●CV●AaKA";
fn main() {
let data = decrypt(PASSWORD);
// Checksum is byte 0: if this is not equal to the bitwise negation of the sum of the other
// bytes modulo 256, password is rejected.
// [05:46] GenericMadScientist: If you have a byte at the end you need to pad, then that last
// byte is [...] ignored for the checksum check, and is ignored when working out the length for
// the length check, but is not ignored for the end when the game is looking at the remaining
// bytes as 2-byte values?
let (checksum, payload) = (&data[0], &data[1..]);
assert_eq!(
*checksum,
!payload.iter().cloned().fold(0, u8::wrapping_add)
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment