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 { 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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
*/
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue