From a29726dea0c7a290546e190f7c1096ad3799d512 Mon Sep 17 00:00:00 2001 From: Arian Kordi Date: Sun, 4 May 2025 20:21:11 -0400 Subject: [PATCH] Handle hair dye that is applied to hair only - add HairDyeMode and associated tests. --- src/app/api/submit/route.ts | 2 +- src/components/submit-form/index.tsx | 2 +- src/lib/qr-codes.test.ts | 58 ++++++++++++++++++++++++---- src/lib/qr-codes.ts | 15 ++++--- src/lib/tomodachi-life-mii.ts | 18 ++++++--- 5 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index 5d25a1c..c0f02e2 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -16,7 +16,7 @@ import { RateLimit } from "@/lib/rate-limit"; import { validateImage } from "@/lib/images"; import { convertQrCode } from "@/lib/qr-codes"; import Mii from "@/lib/mii.js/mii"; -import TomodachiLifeMii from "@/lib/tomodachi-life-mii"; +import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii"; const uploadsDirectory = path.join(process.cwd(), "uploads"); diff --git a/src/components/submit-form/index.tsx b/src/components/submit-form/index.tsx index e5fa7ed..bb6fd77 100644 --- a/src/components/submit-form/index.tsx +++ b/src/components/submit-form/index.tsx @@ -11,7 +11,7 @@ import qrcode from "qrcode-generator"; import { nameSchema, tagsSchema } from "@/lib/schemas"; import { convertQrCode } from "@/lib/qr-codes"; import Mii from "@/lib/mii.js/mii"; -import TomodachiLifeMii from "@/lib/tomodachi-life-mii"; +import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii"; import TagSelector from "../tag-selector"; import ImageList from "./image-list"; diff --git a/src/lib/qr-codes.test.ts b/src/lib/qr-codes.test.ts index 016ee71..38e543d 100644 --- a/src/lib/qr-codes.test.ts +++ b/src/lib/qr-codes.test.ts @@ -1,7 +1,7 @@ import { convertQrCode } from "./qr-codes"; import { describe, it, expect } from "vitest"; import Mii from "./mii.js/mii"; -import TomodachiLifeMii from "./tomodachi-life-mii"; +import { TomodachiLifeMii, HairDyeMode } from "./tomodachi-life-mii"; // List of encrypted QR code data to test against. const validQrCodes = [ @@ -26,12 +26,25 @@ const validQrCodes = [ }, ]; +// 372 bytes of zeroes. const zeroes372 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +// 32 bytes of zeroes (too short to be a Mii QR code). const length32 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +// Mii QR code encrypted correctly but has an invalid CRC-16 checksum. const badCrc = "ULENrXILeXZooDaJvwHMcFaBjGH7k5pj+DDPDV0irq66c01Fh82TIrZerZX2xiB+TbHBzYIaDHdryA9IbR1uH/slSalGCScmjDiL9zpOXlvh8z38NGkmjtL1fVVrshCcJHKeQApdbZU4S6h+wZd0LA=="; +// Hair dye should be applied to hair only. +const hairDyeHairOnly = "lHDnfRgqe2+drU303Slkj7+o4JldrKcIdOl5zgLM0LpwQKQY+i3cpt5IIg7LBNAr7TCzHOvi698oUV0QkcyNj71MgtAAaw4MvOdT4dsv0PLof6E7IcjgnCA1ZAJ2Bs5PQTnM/yuVBUIXdq6WYh+nmG3HtxV7zKbEpSy4bqVep8uuvUlfZcB+BQgucPXQLmDnS8ECKwOlANcKTI+ZjIZggVaEsyY88pjRWyXnwe1z4Favw16bIzecesehGlqXzZh9U5Vm5dZP8wmKc3G6TGylYmbnloRd99UYRNULvTQCUer8WljGuV30ftXlJOwfsnwoAiVOGoG3KvbsBpPtPLywR5DavRgQIPd0/b+XUzHQDhkyftMXeqVEalsuEmU/b/1/j4yVL+2lWgD1i2xyET65uJawAnd8jbKbG8lxPMgzIKGVqJB4QmJOl9/dTf21r9GgRFRaFEz+66bVfiYhzXKmJUQv2qx/t/V3r96QzYd08nrWSHK0"; +const hairDyeHairOnlyExpectedCommonColor = 99; // Must apply to hair but not eyebrow and beard. +// Hair dye should be applied to hair, eyebrow, and beard. +const hairDyeHairEyebrowBeard = validQrCodes[1].base64; // Miku has hair dye. +const hairDyeHairEyebrowBeardExpectedCommonColor = 67; // Must apply to hair, eyebrow, and beard. + +// Should not have hair dye enabled. +const hairDyeMode0 = validQrCodes[0].base64; // Frieren doesn't have hair dye + function base64ToUint8Array(base64: string): Uint8Array { const binary = Buffer.from(base64, "base64"); return new Uint8Array(binary.buffer, binary.byteOffset, binary.byteLength); @@ -71,12 +84,43 @@ describe("convertQrCode", () => { // Verified by new Mii() constructor from mii-js. expect(() => convertQrCode(bytes)).toThrow("Mii data is not valid"); }); - /* - it('should censor bad words in names', () => { - const qrWithSwears = // TODO TODO - const { tomodachiLifeMii } = convertQrCode(qrWithSwears); - expect(tomodachiLifeMii.firstName).not.toMatch(/INSERT_SWEARS_HERE/i); - }); + it("should apply hair dye to hair, eyebrow, and beard", () => { + const bytes = base64ToUint8Array(hairDyeHairEyebrowBeard); + const { mii, tomodachiLifeMii } = convertQrCode(bytes); + + expect(tomodachiLifeMii.hairDyeMode).toBe(HairDyeMode.HairEyebrowBeard); + expect(tomodachiLifeMii.studioHairColor).toBe(hairDyeHairEyebrowBeardExpectedCommonColor); + expect(mii.hairColor).toBe(hairDyeHairEyebrowBeardExpectedCommonColor); + expect(mii.eyebrowColor).toBe(hairDyeHairEyebrowBeardExpectedCommonColor); + expect(mii.facialHairColor).toBe(hairDyeHairEyebrowBeardExpectedCommonColor); + }); + + it("should apply hair dye to hair only", () => { + const bytes = base64ToUint8Array(hairDyeHairOnly); + const { mii, tomodachiLifeMii } = convertQrCode(bytes); + + expect(tomodachiLifeMii.hairDyeMode).toBe(HairDyeMode.Hair); + expect(tomodachiLifeMii.studioHairColor).toBe(hairDyeHairOnlyExpectedCommonColor); + expect(mii.hairColor).toBe(hairDyeHairOnlyExpectedCommonColor); + expect(mii.eyebrowColor === hairDyeHairOnlyExpectedCommonColor).toBe(false); + expect(mii.facialHairColor === hairDyeHairOnlyExpectedCommonColor).toBe(false); + }); + + it("should not apply hair dye if mode is 0", () => { + const bytes = base64ToUint8Array(hairDyeMode0); + const { mii, tomodachiLifeMii } = convertQrCode(bytes); + + expect(tomodachiLifeMii.hairDyeMode).toBe(HairDyeMode.None); + expect(mii.hairColor === tomodachiLifeMii.studioHairColor).toBe(false); + expect(mii.hairColor === mii.facialHairColor).toBe(false); + }); + + /* + it('should censor bad words in names', () => { + const qrWithSwears = // TODO TODO + const { tomodachiLifeMii } = convertQrCode(qrWithSwears); + expect(tomodachiLifeMii.firstName).not.toMatch(/INSERT_SWEARS_HERE/i); + }); */ }); diff --git a/src/lib/qr-codes.ts b/src/lib/qr-codes.ts index 3be79f7..72ea377 100644 --- a/src/lib/qr-codes.ts +++ b/src/lib/qr-codes.ts @@ -4,7 +4,7 @@ import sjcl from "sjcl-with-all"; import { MII_DECRYPTION_KEY, MII_QR_ENCRYPTED_LENGTH } from "./constants"; import Mii from "./mii.js/mii"; -import TomodachiLifeMii from "./tomodachi-life-mii"; +import { TomodachiLifeMii, HairDyeMode } from "./tomodachi-life-mii"; // AES-CCM encrypted Mii QR codes have some errata (https://www.3dbrew.org/wiki/AES_Registers#CCM_mode_pitfall) // causing them to not be easily decryptable by asmcrypto @@ -89,10 +89,15 @@ export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii: const tomodachiLifeMii = TomodachiLifeMii.fromBytes(bytes); // Apply hair dye fields. - if (tomodachiLifeMii.hairDyeEnabled) { - mii.hairColor = tomodachiLifeMii.studioHairColor; - mii.eyebrowColor = tomodachiLifeMii.studioHairColor; - mii.facialHairColor = tomodachiLifeMii.studioHairColor; + switch (tomodachiLifeMii.hairDyeMode) { + case HairDyeMode.HairEyebrowBeard: + mii.eyebrowColor = tomodachiLifeMii.studioHairColor; + mii.facialHairColor = tomodachiLifeMii.studioHairColor; + // Fall-through and also apply to hair. + case HairDyeMode.Hair: + mii.hairColor = tomodachiLifeMii.studioHairColor; + break; + // Default: do not apply hair dye. } // Censor potential inappropriate words. diff --git a/src/lib/tomodachi-life-mii.ts b/src/lib/tomodachi-life-mii.ts index 6a20ff2..c76b0a7 100644 --- a/src/lib/tomodachi-life-mii.ts +++ b/src/lib/tomodachi-life-mii.ts @@ -11,15 +11,23 @@ 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 default class TomodachiLifeMii { +export class TomodachiLifeMii { firstName: string; lastName: string; islandName: string; hairDye: number; - hairDyeEnabled: boolean; + hairDyeMode: HairDyeMode; // There are more properties but I don't plan to add them *yet* @@ -38,10 +46,10 @@ export default class TomodachiLifeMii { // Skip 3 unknown bytes offset += 3; - // Read bit fields + // Read little-endian bit fields const bitField = view.getUint8(offset++); - this.hairDyeEnabled = (bitField & 0x80) !== 0; - this.hairDye = (bitField & 0x3e) >> 1; + this.hairDyeMode = (bitField >> 6) & 0b00000011; // Bits 7-6 + this.hairDye = (bitField >> 1) & 0b00011111; // Bits 5-1 // Skip 12 unknown bytes offset += 12;