tomodachi-share/src/lib/tomodachi-life-mii.ts

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;
}
}