Handle hair dye that is applied to hair only - add HairDyeMode and associated tests.
This commit is contained in:
parent
37df009806
commit
a29726dea0
5 changed files with 76 additions and 19 deletions
|
|
@ -16,7 +16,7 @@ import { RateLimit } from "@/lib/rate-limit";
|
||||||
import { validateImage } from "@/lib/images";
|
import { validateImage } from "@/lib/images";
|
||||||
import { convertQrCode } from "@/lib/qr-codes";
|
import { convertQrCode } from "@/lib/qr-codes";
|
||||||
import Mii from "@/lib/mii.js/mii";
|
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");
|
const uploadsDirectory = path.join(process.cwd(), "uploads");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import qrcode from "qrcode-generator";
|
||||||
import { nameSchema, tagsSchema } from "@/lib/schemas";
|
import { nameSchema, tagsSchema } from "@/lib/schemas";
|
||||||
import { convertQrCode } from "@/lib/qr-codes";
|
import { convertQrCode } from "@/lib/qr-codes";
|
||||||
import Mii from "@/lib/mii.js/mii";
|
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 TagSelector from "../tag-selector";
|
||||||
import ImageList from "./image-list";
|
import ImageList from "./image-list";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { convertQrCode } from "./qr-codes";
|
import { convertQrCode } from "./qr-codes";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import Mii from "./mii.js/mii";
|
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.
|
// List of encrypted QR code data to test against.
|
||||||
const validQrCodes = [
|
const validQrCodes = [
|
||||||
|
|
@ -26,12 +26,25 @@ const validQrCodes = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 372 bytes of zeroes.
|
||||||
const zeroes372 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
const zeroes372 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||||
|
|
||||||
|
// 32 bytes of zeroes (too short to be a Mii QR code).
|
||||||
const length32 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
const length32 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
|
|
||||||
|
// Mii QR code encrypted correctly but has an invalid CRC-16 checksum.
|
||||||
const badCrc = "ULENrXILeXZooDaJvwHMcFaBjGH7k5pj+DDPDV0irq66c01Fh82TIrZerZX2xiB+TbHBzYIaDHdryA9IbR1uH/slSalGCScmjDiL9zpOXlvh8z38NGkmjtL1fVVrshCcJHKeQApdbZU4S6h+wZd0LA==";
|
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 {
|
function base64ToUint8Array(base64: string): Uint8Array {
|
||||||
const binary = Buffer.from(base64, "base64");
|
const binary = Buffer.from(base64, "base64");
|
||||||
return new Uint8Array(binary.buffer, binary.byteOffset, binary.byteLength);
|
return new Uint8Array(binary.buffer, binary.byteOffset, binary.byteLength);
|
||||||
|
|
@ -71,12 +84,43 @@ describe("convertQrCode", () => {
|
||||||
// Verified by new Mii() constructor from mii-js.
|
// Verified by new Mii() constructor from mii-js.
|
||||||
expect(() => convertQrCode(bytes)).toThrow("Mii data is not valid");
|
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);
|
||||||
|
});
|
||||||
*/
|
*/
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import sjcl from "sjcl-with-all";
|
||||||
|
|
||||||
import { MII_DECRYPTION_KEY, MII_QR_ENCRYPTED_LENGTH } from "./constants";
|
import { MII_DECRYPTION_KEY, MII_QR_ENCRYPTED_LENGTH } from "./constants";
|
||||||
import Mii from "./mii.js/mii";
|
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)
|
// 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
|
// 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);
|
const tomodachiLifeMii = TomodachiLifeMii.fromBytes(bytes);
|
||||||
|
|
||||||
// Apply hair dye fields.
|
// Apply hair dye fields.
|
||||||
if (tomodachiLifeMii.hairDyeEnabled) {
|
switch (tomodachiLifeMii.hairDyeMode) {
|
||||||
mii.hairColor = tomodachiLifeMii.studioHairColor;
|
case HairDyeMode.HairEyebrowBeard:
|
||||||
mii.eyebrowColor = tomodachiLifeMii.studioHairColor;
|
mii.eyebrowColor = tomodachiLifeMii.studioHairColor;
|
||||||
mii.facialHairColor = 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.
|
// Censor potential inappropriate words.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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
|
// 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)
|
// (Credits to ariankordi for the byte locations)
|
||||||
export default class TomodachiLifeMii {
|
export class TomodachiLifeMii {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
islandName: string;
|
islandName: string;
|
||||||
|
|
||||||
hairDye: number;
|
hairDye: number;
|
||||||
hairDyeEnabled: boolean;
|
hairDyeMode: HairDyeMode;
|
||||||
|
|
||||||
// There are more properties but I don't plan to add them *yet*
|
// 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
|
// Skip 3 unknown bytes
|
||||||
offset += 3;
|
offset += 3;
|
||||||
|
|
||||||
// Read bit fields
|
// Read little-endian bit fields
|
||||||
const bitField = view.getUint8(offset++);
|
const bitField = view.getUint8(offset++);
|
||||||
this.hairDyeEnabled = (bitField & 0x80) !== 0;
|
this.hairDyeMode = (bitField >> 6) & 0b00000011; // Bits 7-6
|
||||||
this.hairDye = (bitField & 0x3e) >> 1;
|
this.hairDye = (bitField >> 1) & 0b00011111; // Bits 5-1
|
||||||
|
|
||||||
// Skip 12 unknown bytes
|
// Skip 12 unknown bytes
|
||||||
offset += 12;
|
offset += 12;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue