diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index fa5f7bf..98f8649 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -11,6 +11,7 @@ import { MII_DECRYPTION_KEY } from "@/lib/constants"; import { nameSchema, tagsSchema } from "@/lib/schemas"; import Mii from "@/utils/mii.js/mii"; +import TomodachiLifeMii from "@/utils/tomodachi-life-mii"; const uploadsDirectory = path.join(process.cwd(), "public", "uploads"); @@ -61,6 +62,13 @@ export async function POST(request: Request) { // Convert to Mii class const buffer = Buffer.from(result); const mii = new Mii(buffer); + const tomodachiLifeMii = TomodachiLifeMii.fromBytes(qrBytes); + + if (tomodachiLifeMii.hairDyeEnabled) { + mii.hairColor = tomodachiLifeMii.studioHairColor; + mii.eyebrowColor = tomodachiLifeMii.studioHairColor; + mii.facialHairColor = tomodachiLifeMii.studioHairColor; + } // Create Mii in database const miiRecord = await prisma.mii.create({ diff --git a/src/app/components/submit-form.tsx b/src/app/components/submit-form.tsx index a642757..e593ab4 100644 --- a/src/app/components/submit-form.tsx +++ b/src/app/components/submit-form.tsx @@ -13,6 +13,7 @@ import { MII_DECRYPTION_KEY } from "@/lib/constants"; import { nameSchema, tagsSchema } from "@/lib/schemas"; import Mii from "@/utils/mii.js/mii"; +import TomodachiLifeMii from "@/utils/tomodachi-life-mii"; import TagSelector from "./submit/tag-selector"; import QrUpload from "./submit/qr-upload"; @@ -107,9 +108,16 @@ export default function SubmitForm() { return; } - // Convert to Mii class + // Convert to Mii classes const buffer = Buffer.from(result); const mii = new Mii(buffer); + const tomodachiLifeMii = TomodachiLifeMii.fromBytes(qrBytes); + + if (tomodachiLifeMii.hairDyeEnabled) { + mii.hairColor = tomodachiLifeMii.studioHairColor; + mii.eyebrowColor = tomodachiLifeMii.studioHairColor; + mii.facialHairColor = tomodachiLifeMii.studioHairColor; + } try { setStudioUrl(mii.studioUrl({ width: 128 })); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 4bf150e..7eadb9e 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1 +1,4 @@ export const MII_DECRYPTION_KEY = new Uint8Array([0x59, 0xfc, 0x81, 0x7e, 0x64, 0x46, 0xea, 0x61, 0x90, 0x34, 0x7b, 0x20, 0xe9, 0xbd, 0xce, 0x52]); +export const TOMODACHI_LIFE_DECRYPTION_KEY = new Uint8Array([ + 0x30, 0x81, 0x9f, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, +]); diff --git a/src/utils/tomodachi-life-mii.ts b/src/utils/tomodachi-life-mii.ts new file mode 100644 index 0000000..159434a --- /dev/null +++ b/src/utils/tomodachi-life-mii.ts @@ -0,0 +1,87 @@ +import { TOMODACHI_LIFE_DECRYPTION_KEY } from "@/lib/constants"; +import { AES_CTR } from "@trafficlunar/asmcrypto.js"; + +// Converts hair dye to studio color +// (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, +]; + +// (Credits to ariankordi for the byte locations) +export default class TomodachiLifeMii { + firstName: string; + lastName: string; + islandName: string; + + hairDye: number; + hairDyeEnabled: boolean; + + // There is 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 bit fields + const bitField = view.getUint8(offset++); + this.hairDyeEnabled = (bitField & 0x80) !== 0; + this.hairDye = (bitField & 0x3e) >> 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.slice(0x70, 128); + const encryptedExtraData = bytes.slice(128, -4); + const decryptedExtraData = AES_CTR.decrypt(encryptedExtraData, TOMODACHI_LIFE_DECRYPTION_KEY, iv); + 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; + } +}