feat: tomodachi life data decrypting and fix hair color rendering

This commit is contained in:
trafficlunar 2025-04-05 17:03:18 +01:00
parent fae79e67af
commit 5de6e924f2
4 changed files with 107 additions and 1 deletions

View file

@ -11,6 +11,7 @@ import { MII_DECRYPTION_KEY } from "@/lib/constants";
import { nameSchema, tagsSchema } from "@/lib/schemas"; import { nameSchema, tagsSchema } from "@/lib/schemas";
import Mii from "@/utils/mii.js/mii"; import Mii from "@/utils/mii.js/mii";
import TomodachiLifeMii from "@/utils/tomodachi-life-mii";
const uploadsDirectory = path.join(process.cwd(), "public", "uploads"); const uploadsDirectory = path.join(process.cwd(), "public", "uploads");
@ -61,6 +62,13 @@ export async function POST(request: Request) {
// Convert to Mii class // Convert to Mii class
const buffer = Buffer.from(result); const buffer = Buffer.from(result);
const mii = new Mii(buffer); 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 // Create Mii in database
const miiRecord = await prisma.mii.create({ const miiRecord = await prisma.mii.create({

View file

@ -13,6 +13,7 @@ import { MII_DECRYPTION_KEY } from "@/lib/constants";
import { nameSchema, tagsSchema } from "@/lib/schemas"; import { nameSchema, tagsSchema } from "@/lib/schemas";
import Mii from "@/utils/mii.js/mii"; import Mii from "@/utils/mii.js/mii";
import TomodachiLifeMii from "@/utils/tomodachi-life-mii";
import TagSelector from "./submit/tag-selector"; import TagSelector from "./submit/tag-selector";
import QrUpload from "./submit/qr-upload"; import QrUpload from "./submit/qr-upload";
@ -107,9 +108,16 @@ export default function SubmitForm() {
return; return;
} }
// Convert to Mii class // Convert to Mii classes
const buffer = Buffer.from(result); const buffer = Buffer.from(result);
const mii = new Mii(buffer); 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 { try {
setStudioUrl(mii.studioUrl({ width: 128 })); setStudioUrl(mii.studioUrl({ width: 128 }));

View file

@ -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 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,
]);

View file

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