Merge pull request #4 from ariankordi/handle-hair-dye-hair-only

Handle hair dye for hair only (fixes #1)
This commit is contained in:
trafficlunar 2025-05-05 09:02:20 +01:00 committed by GitHub
commit 1006ae83f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 76 additions and 19 deletions

View file

@ -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");

View file

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

View file

@ -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,11 +84,42 @@ describe("convertQrCode", () => {
// Verified by new Mii() constructor from mii-js.
expect(() => convertQrCode(bytes)).toThrow("Mii data is not valid");
});
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);
});
*/

View file

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

View file

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