109 lines
3.3 KiB
TypeScript
109 lines
3.3 KiB
TypeScript
import { TOMODACHI_LIFE_DECRYPTION_KEY } from "../lib/constants";
|
|
import sjcl from "sjcl-with-all";
|
|
|
|
// @ts-expect-error - This is not in the types, but it's a function needed to enable CTR mode.
|
|
sjcl.beware["CTR mode is dangerous because it doesn't protect message integrity."]();
|
|
|
|
// Converts hair dye to studio color
|
|
// Reference: https://github.com/ariankordi/nwf-mii-cemu-toy/blob/9906440c1dafbe3f40ac8b95e10a22ebd85b441e/assets/data-conversion.js#L282
|
|
// (Credits to kat21)
|
|
const hairDyeConverter = [
|
|
55, 51, 50, 12, 16, 12, 67, 61, 51, 64, 69, 66, 65, 86, 85, 93, 92, 19, 20, 20, 15, 32, 35, 26, 38, 41, 43, 18, 95, 97, 97, 99,
|
|
];
|
|
|
|
// All possible values for 2-bit hair dye mode.
|
|
export enum HairDyeMode {
|
|
None = 0,
|
|
Hair = 1,
|
|
HairEyebrowBeard = 2,
|
|
Invalid = 3,
|
|
}
|
|
|
|
// Reference: https://github.com/ariankordi/nwf-mii-cemu-toy/blob/ffl-renderer-proto-integrate/assets/kaitai-structs/tomodachi_life_qr_code.ksy
|
|
// (Credits to ariankordi for the byte locations)
|
|
export class TomodachiLifeMii {
|
|
firstName: string;
|
|
lastName: string;
|
|
islandName: string;
|
|
|
|
hairDye: number;
|
|
hairDyeMode: HairDyeMode;
|
|
|
|
// There are more properties but I don't plan to add them *yet*
|
|
|
|
// Property below is not located in the bytes
|
|
studioHairColor: number;
|
|
|
|
constructor(buffer: ArrayBuffer) {
|
|
const view = new DataView(buffer);
|
|
let offset = 0;
|
|
|
|
this.firstName = this.readUtf16String(buffer, offset, 32);
|
|
offset += 32;
|
|
this.lastName = this.readUtf16String(buffer, offset, 32);
|
|
offset += 32;
|
|
|
|
// Skip 3 unknown bytes
|
|
offset += 3;
|
|
|
|
// Read little-endian bit fields
|
|
const bitField = view.getUint8(offset++);
|
|
this.hairDyeMode = (bitField >> 6) & 0b00000011; // Bits 7-6
|
|
this.hairDye = (bitField >> 1) & 0b00011111; // Bits 5-1
|
|
|
|
// Skip 12 unknown bytes
|
|
offset += 12;
|
|
|
|
// Skip catchphrase
|
|
offset += 32;
|
|
|
|
// Skip 58 unknown bytes
|
|
offset += 58;
|
|
|
|
// Skip voice properties
|
|
offset += 6;
|
|
|
|
// Skip character properties
|
|
offset += 5;
|
|
|
|
// Skip 35 unknown bytes
|
|
offset += 35;
|
|
|
|
this.islandName = this.readUtf16String(buffer, offset, 20);
|
|
|
|
// Convert hair dye color to studio color
|
|
this.studioHairColor = hairDyeConverter[this.hairDye];
|
|
}
|
|
|
|
static fromBytes(bytes: Uint8Array): TomodachiLifeMii {
|
|
const iv = bytes.subarray(0x70, 128);
|
|
const encryptedExtraData = bytes.subarray(128, -4);
|
|
|
|
const cipher = new sjcl.cipher.aes(TOMODACHI_LIFE_DECRYPTION_KEY);
|
|
|
|
// Convert nonce and encrypted content to sjcl.BitArray.
|
|
const encryptedBits = sjcl.codec.bytes.toBits(Array.from(encryptedExtraData));
|
|
const ivBits = sjcl.codec.bytes.toBits(Array.from(iv));
|
|
// Perform decryption.
|
|
const decryptedBits = sjcl.mode.ctr.decrypt(cipher, encryptedBits, ivBits);
|
|
// Convert from sjcl format.
|
|
const decryptedExtraData = sjcl.codec.bytes.fromBits(decryptedBits);
|
|
const data = new Uint8Array(decryptedExtraData);
|
|
|
|
return new TomodachiLifeMii(data.buffer);
|
|
}
|
|
|
|
private readUtf16String(buffer: ArrayBuffer, offset: number, byteLength: number): string {
|
|
const bytes = new Uint8Array(buffer, offset, byteLength);
|
|
let str = "";
|
|
|
|
// We process every 2 bytes (UTF-16)
|
|
for (let i = 0; i < bytes.length; i += 2) {
|
|
const charCode = bytes[i] | (bytes[i + 1] << 8);
|
|
if (charCode === 0) break; // Stop at the null terminator (0x0000)
|
|
str += String.fromCharCode(charCode);
|
|
}
|
|
|
|
return str;
|
|
}
|
|
}
|