mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-27 22:24:14 +00:00
feat: astro test
This commit is contained in:
parent
df6e31ba89
commit
84144c383c
262 changed files with 18993 additions and 2655 deletions
16
shared/src/constants.ts
Normal file
16
shared/src/constants.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { BitArray } from "sjcl";
|
||||
|
||||
/** Length of an encrypted Mii QR code, without extra data. (CFLiWrappedMiiData) */
|
||||
export const MII_QR_ENCRYPTED_LENGTH = 112;
|
||||
|
||||
// Keys are in sjcl.BitArray format.
|
||||
|
||||
/** AES-CCM key for Mii QR codes. Original: 59FC817E6446EA6190347B20E9BDCE52 */
|
||||
export const MII_DECRYPTION_KEY: BitArray = [1509720446, 1682369121, -1875608800, -373436846];
|
||||
|
||||
/** AES-CTR key for Tomodachi Life extra data at the end of Mii QR codes. Original: 30819F300D06092A864886F70D010101 */
|
||||
export const TOMODACHI_LIFE_DECRYPTION_KEY: BitArray = [813801264, 218499370, -2042067209, 218169601];
|
||||
|
||||
// Keys as Uint8Array.
|
||||
// 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]);
|
||||
5
shared/src/index.ts
Normal file
5
shared/src/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./constants";
|
||||
export * from "./qr-codes";
|
||||
export * from "./switch";
|
||||
export * from "./three-ds-tomodachi-life-mii";
|
||||
export type { SwitchMiiInstructions } from "./types";
|
||||
3
shared/src/mii.js/README.md
Normal file
3
shared/src/mii.js/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Every file in here is from https://github.com/PretendoNetwork/mii-js/
|
||||
|
||||
./mii.ts has been edited to remove the asset URL functions and remove some assertions related to hair dye (hairColor, facialHairColor, eyebrowColor)
|
||||
45
shared/src/mii.js/extended-bit-stream.ts
Normal file
45
shared/src/mii.js/extended-bit-stream.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Based on https://github.com/PretendoNetwork/mii-js/
|
||||
// Updated to bit-buffer v0.3.0
|
||||
|
||||
import { BitStream } from "bit-buffer";
|
||||
|
||||
export default class ExtendedBitStream extends BitStream {
|
||||
constructor(buffer: Buffer) {
|
||||
super(buffer, buffer.byteOffset, buffer.byteLength);
|
||||
}
|
||||
|
||||
public swapEndian(): void {
|
||||
this.bigEndian = !this.bigEndian;
|
||||
}
|
||||
|
||||
public alignByte(): void {
|
||||
const nextClosestByteIndex = 8 * Math.ceil(this.index / 8);
|
||||
const bitDistance = nextClosestByteIndex - this.index;
|
||||
this.skipBits(bitDistance);
|
||||
}
|
||||
|
||||
public skipBits(bitCount: number): void {
|
||||
this.index += bitCount;
|
||||
}
|
||||
|
||||
public skipBit(): void {
|
||||
this.skipBits(1);
|
||||
}
|
||||
|
||||
public skipInt16(): void {
|
||||
// Skipping a uint16 is the same as skipping 2 uint8's
|
||||
this.skipBits(16);
|
||||
}
|
||||
|
||||
public readBit(): number {
|
||||
return this.readBits(1, false);
|
||||
}
|
||||
|
||||
public readBuffer(length: number): Buffer {
|
||||
return Buffer.from(super.readBytes(length));
|
||||
}
|
||||
|
||||
public readUTF16String(length: number): string {
|
||||
return this.readBuffer(length).toString("utf16le").replace(/\0.*$/, "");
|
||||
}
|
||||
}
|
||||
3
shared/src/mii.js/index.ts
Normal file
3
shared/src/mii.js/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as Mii } from "./mii";
|
||||
export * from "./util";
|
||||
export * from "./extended-bit-stream";
|
||||
567
shared/src/mii.js/mii.ts
Normal file
567
shared/src/mii.js/mii.ts
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
// Stolen and edited from https://github.com/PretendoNetwork/mii-js/
|
||||
|
||||
import ExtendedBitStream from "./extended-bit-stream";
|
||||
import Util from "./util";
|
||||
|
||||
const STUDIO_RENDER_URL_BASE = "https://studio.mii.nintendo.com/miis/image.png";
|
||||
|
||||
const STUDIO_RENDER_DEFAULTS = {
|
||||
type: "face",
|
||||
expression: "normal",
|
||||
width: 96,
|
||||
bgColor: "FFFFFF00",
|
||||
clothesColor: "default",
|
||||
cameraXRotate: 0,
|
||||
cameraYRotate: 0,
|
||||
cameraZRotate: 0,
|
||||
characterXRotate: 0,
|
||||
characterYRotate: 0,
|
||||
characterZRotate: 0,
|
||||
lightXDirection: 0,
|
||||
lightYDirection: 0,
|
||||
lightZDirection: 0,
|
||||
lightDirectionMode: "none",
|
||||
instanceCount: 1,
|
||||
instanceRotationMode: "model",
|
||||
};
|
||||
|
||||
const STUDIO_RENDER_TYPES = ["face", "face_only", "all_body"];
|
||||
|
||||
const STUDIO_RENDER_EXPRESSIONS = [
|
||||
"normal",
|
||||
"smile",
|
||||
"anger",
|
||||
"sorrow",
|
||||
"surprise",
|
||||
"blink",
|
||||
"normal_open_mouth",
|
||||
"smile_open_mouth",
|
||||
"anger_open_mouth",
|
||||
"surprise_open_mouth",
|
||||
"sorrow_open_mouth",
|
||||
"blink_open_mouth",
|
||||
"wink_left",
|
||||
"wink_right",
|
||||
"wink_left_open_mouth",
|
||||
"wink_right_open_mouth",
|
||||
"like_wink_left",
|
||||
"like_wink_right",
|
||||
"frustrated",
|
||||
];
|
||||
|
||||
const STUDIO_RENDER_CLOTHES_COLORS = [
|
||||
"default",
|
||||
"red",
|
||||
"orange",
|
||||
"yellow",
|
||||
"yellowgreen",
|
||||
"green",
|
||||
"blue",
|
||||
"skyblue",
|
||||
"pink",
|
||||
"purple",
|
||||
"brown",
|
||||
"white",
|
||||
"black",
|
||||
];
|
||||
|
||||
const STUDIO_RENDER_LIGHT_DIRECTION_MODS = ["none", "zerox", "flipx", "camera", "offset", "set"];
|
||||
|
||||
const STUDIO_RENDER_INSTANCE_ROTATION_MODES = ["model", "camera", "both"];
|
||||
|
||||
const STUDIO_BG_COLOR_REGEX = /^[0-9A-F]{8}$/; // Mii Studio does not allow lowercase
|
||||
|
||||
type Assert = {
|
||||
ok: (value: unknown, message?: string) => asserts value;
|
||||
equal: (a: unknown, b: unknown, message?: string) => void;
|
||||
fail: (message?: string) => never;
|
||||
};
|
||||
|
||||
export const assert: Assert = {
|
||||
ok(value: unknown, message = "Assertion failed"): asserts value {
|
||||
if (!value) {
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
equal(a: unknown, b: unknown, message?: string) {
|
||||
if (a !== b) {
|
||||
throw new Error(message ?? `Assertion failed: expected ${a} to equal ${b}`);
|
||||
}
|
||||
},
|
||||
|
||||
fail(message = "Assertion failed"): never {
|
||||
throw new Error(message);
|
||||
},
|
||||
};
|
||||
|
||||
export default class Mii {
|
||||
public bitStream: ExtendedBitStream;
|
||||
public buffer: Buffer;
|
||||
|
||||
// Mii data
|
||||
// can be sure that these are all initialized in decode()
|
||||
|
||||
public version!: number;
|
||||
public allowCopying!: boolean;
|
||||
public profanityFlag!: boolean;
|
||||
public regionLock!: number;
|
||||
public characterSet!: number;
|
||||
public pageIndex!: number;
|
||||
public slotIndex!: number;
|
||||
public unknown1!: number;
|
||||
public deviceOrigin!: number;
|
||||
public systemId!: Buffer;
|
||||
public normalMii!: boolean;
|
||||
public dsMii!: boolean;
|
||||
public nonUserMii!: boolean;
|
||||
public isValid!: boolean;
|
||||
public creationTime!: number;
|
||||
public consoleMAC!: Buffer;
|
||||
public gender!: number;
|
||||
public birthMonth!: number;
|
||||
public birthDay!: number;
|
||||
public favoriteColor!: number;
|
||||
public favorite!: boolean;
|
||||
public miiName!: string;
|
||||
public height!: number;
|
||||
public build!: number;
|
||||
public disableSharing!: boolean;
|
||||
public faceType!: number;
|
||||
public skinColor!: number;
|
||||
public wrinklesType!: number;
|
||||
public makeupType!: number;
|
||||
public hairType!: number;
|
||||
public hairColor!: number;
|
||||
public flipHair!: boolean;
|
||||
public eyeType!: number;
|
||||
public eyeColor!: number;
|
||||
public eyeScale!: number;
|
||||
public eyeVerticalStretch!: number;
|
||||
public eyeRotation!: number;
|
||||
public eyeSpacing!: number;
|
||||
public eyeYPosition!: number;
|
||||
public eyebrowType!: number;
|
||||
public eyebrowColor!: number;
|
||||
public eyebrowScale!: number;
|
||||
public eyebrowVerticalStretch!: number;
|
||||
public eyebrowRotation!: number;
|
||||
public eyebrowSpacing!: number;
|
||||
public eyebrowYPosition!: number;
|
||||
public noseType!: number;
|
||||
public noseScale!: number;
|
||||
public noseYPosition!: number;
|
||||
public mouthType!: number;
|
||||
public mouthColor!: number;
|
||||
public mouthScale!: number;
|
||||
public mouthHorizontalStretch!: number;
|
||||
public mouthYPosition!: number;
|
||||
public mustacheType!: number;
|
||||
public unknown2!: number;
|
||||
public beardType!: number;
|
||||
public facialHairColor!: number;
|
||||
public mustacheScale!: number;
|
||||
public mustacheYPosition!: number;
|
||||
public glassesType!: number;
|
||||
public glassesColor!: number;
|
||||
public glassesScale!: number;
|
||||
public glassesYPosition!: number;
|
||||
public moleEnabled!: boolean;
|
||||
public moleScale!: number;
|
||||
public moleXPosition!: number;
|
||||
public moleYPosition!: number;
|
||||
public creatorName!: string;
|
||||
public checksum!: number;
|
||||
|
||||
constructor(buffer: Buffer) {
|
||||
this.buffer = buffer;
|
||||
this.bitStream = new ExtendedBitStream(buffer);
|
||||
this.decode();
|
||||
}
|
||||
|
||||
public validate(): void {
|
||||
// Size check
|
||||
assert.equal(this.bitStream.length / 8, 0x60, `Invalid Mii data size. Got ${this.bitStream.length / 8}, expected 96`);
|
||||
|
||||
// Value range and type checks
|
||||
assert.ok(this.version === 0 || this.version === 3, `Invalid Mii version. Got ${this.version}, expected 0 or 3`);
|
||||
assert.equal(typeof this.allowCopying, "boolean", `Invalid Mii allow copying. Got ${this.allowCopying}, expected true or false`);
|
||||
assert.equal(typeof this.profanityFlag, "boolean", `Invalid Mii profanity flag. Got ${this.profanityFlag}, expected true or false`);
|
||||
assert.ok(Util.inRange(this.regionLock, Util.range(4)), `Invalid Mii region lock. Got ${this.regionLock}, expected 0-3`);
|
||||
assert.ok(Util.inRange(this.characterSet, Util.range(4)), `Invalid Mii region lock. Got ${this.characterSet}, expected 0-3`);
|
||||
assert.ok(Util.inRange(this.pageIndex, Util.range(10)), `Invalid Mii page index. Got ${this.pageIndex}, expected 0-9`);
|
||||
assert.ok(Util.inRange(this.slotIndex, Util.range(10)), `Invalid Mii slot index. Got ${this.slotIndex}, expected 0-9`);
|
||||
assert.equal(this.unknown1, 0, `Invalid Mii unknown1. Got ${this.unknown1}, expected 0`);
|
||||
assert.ok(Util.inRange(this.deviceOrigin, Util.range(1, 5)), `Invalid Mii device origin. Got ${this.deviceOrigin}, expected 1-4`);
|
||||
assert.equal(this.systemId.length, 8, `Invalid Mii system ID size. Got ${this.systemId.length}, system IDs must be 8 bytes long`);
|
||||
assert.equal(typeof this.normalMii, "boolean", `Invalid normal Mii flag. Got ${this.normalMii}, expected true or false`);
|
||||
assert.equal(typeof this.dsMii, "boolean", `Invalid DS Mii flag. Got ${this.dsMii}, expected true or false`);
|
||||
assert.equal(typeof this.nonUserMii, "boolean", `Invalid non-user Mii flag. Got ${this.nonUserMii}, expected true or false`);
|
||||
assert.equal(typeof this.isValid, "boolean", `Invalid Mii valid flag. Got ${this.isValid}, expected true or false`);
|
||||
assert.ok(this.creationTime < 268435456, `Invalid Mii creation time. Got ${this.creationTime}, max value for 28 bit integer is 268,435,456`);
|
||||
assert.equal(this.consoleMAC.length, 6, `Invalid Mii console MAC address size. Got ${this.consoleMAC.length}, console MAC addresses must be 6 bytes long`);
|
||||
assert.ok(Util.inRange(this.gender, Util.range(2)), `Invalid Mii gender. Got ${this.gender}, expected 0 or 1`);
|
||||
assert.ok(Util.inRange(this.birthMonth, Util.range(13)), `Invalid Mii birth month. Got ${this.birthMonth}, expected 0-12`);
|
||||
assert.ok(Util.inRange(this.birthDay, Util.range(32)), `Invalid Mii birth day. Got ${this.birthDay}, expected 0-31`);
|
||||
assert.ok(Util.inRange(this.favoriteColor, Util.range(12)), `Invalid Mii favorite color. Got ${this.favoriteColor}, expected 0-11`);
|
||||
assert.equal(typeof this.favorite, "boolean", `Invalid favorite Mii flag. Got ${this.favorite}, expected true or false`);
|
||||
assert.ok(Buffer.from(this.miiName, "utf16le").length <= 0x14, `Invalid Mii name. Got ${this.miiName}, name may only be up to 10 characters`);
|
||||
assert.ok(Util.inRange(this.height, Util.range(128)), `Invalid Mii height. Got ${this.height}, expected 0-127`);
|
||||
assert.ok(Util.inRange(this.build, Util.range(128)), `Invalid Mii build. Got ${this.build}, expected 0-127`);
|
||||
assert.equal(typeof this.disableSharing, "boolean", `Invalid disable sharing Mii flag. Got ${this.disableSharing}, expected true or false`);
|
||||
assert.ok(Util.inRange(this.faceType, Util.range(12)), `Invalid Mii face type. Got ${this.faceType}, expected 0-11`);
|
||||
assert.ok(Util.inRange(this.skinColor, Util.range(7)), `Invalid Mii skin color. Got ${this.skinColor}, expected 0-6`);
|
||||
assert.ok(Util.inRange(this.wrinklesType, Util.range(12)), `Invalid Mii wrinkles type. Got ${this.wrinklesType}, expected 0-11`);
|
||||
assert.ok(Util.inRange(this.makeupType, Util.range(12)), `Invalid Mii makeup type. Got ${this.makeupType}, expected 0-11`);
|
||||
assert.ok(Util.inRange(this.hairType, Util.range(132)), `Invalid Mii hair type. Got ${this.hairType}, expected 0-131`);
|
||||
// assert.ok(Util.inRange(this.hairColor, Util.range(8)), `Invalid Mii hair color. Got ${this.hairColor}, expected 0-7`);
|
||||
assert.equal(typeof this.flipHair, "boolean", `Invalid flip hair flag. Got ${this.flipHair}, expected true or false`);
|
||||
assert.ok(Util.inRange(this.eyeType, Util.range(60)), `Invalid Mii eye type. Got ${this.eyeType}, expected 0-59`);
|
||||
assert.ok(Util.inRange(this.eyeColor, Util.range(6)), `Invalid Mii eye color. Got ${this.eyeColor}, expected 0-5`);
|
||||
assert.ok(Util.inRange(this.eyeScale, Util.range(8)), `Invalid Mii eye scale. Got ${this.eyeScale}, expected 0-7`);
|
||||
assert.ok(Util.inRange(this.eyeVerticalStretch, Util.range(7)), `Invalid Mii eye vertical stretch. Got ${this.eyeVerticalStretch}, expected 0-6`);
|
||||
assert.ok(Util.inRange(this.eyeRotation, Util.range(8)), `Invalid Mii eye rotation. Got ${this.eyeRotation}, expected 0-7`);
|
||||
assert.ok(Util.inRange(this.eyeSpacing, Util.range(13)), `Invalid Mii eye spacing. Got ${this.eyeSpacing}, expected 0-12`);
|
||||
assert.ok(Util.inRange(this.eyeYPosition, Util.range(19)), `Invalid Mii eye Y position. Got ${this.eyeYPosition}, expected 0-18`);
|
||||
assert.ok(Util.inRange(this.eyebrowType, Util.range(25)), `Invalid Mii eyebrow type. Got ${this.eyebrowType}, expected 0-24`);
|
||||
// assert.ok(Util.inRange(this.eyebrowColor, Util.range(8)), `Invalid Mii eyebrow color. Got ${this.eyebrowColor}, expected 0-7`);
|
||||
assert.ok(Util.inRange(this.eyebrowScale, Util.range(9)), `Invalid Mii eyebrow scale. Got ${this.eyebrowScale}, expected 0-8`);
|
||||
assert.ok(
|
||||
Util.inRange(this.eyebrowVerticalStretch, Util.range(7)),
|
||||
`Invalid Mii eyebrow vertical stretch. Got ${this.eyebrowVerticalStretch}, expected 0-6`,
|
||||
);
|
||||
assert.ok(Util.inRange(this.eyebrowRotation, Util.range(12)), `Invalid Mii eyebrow rotation. Got ${this.eyebrowRotation}, expected 0-11`);
|
||||
assert.ok(Util.inRange(this.eyebrowSpacing, Util.range(13)), `Invalid Mii eyebrow spacing. Got ${this.eyebrowSpacing}, expected 0-12`);
|
||||
assert.ok(Util.inRange(this.eyebrowYPosition, Util.range(3, 19)), `Invalid Mii eyebrow Y position. Got ${this.eyebrowYPosition}, expected 3-18`);
|
||||
assert.ok(Util.inRange(this.noseType, Util.range(18)), `Invalid Mii nose type. Got ${this.noseType}, expected 0-17`);
|
||||
assert.ok(Util.inRange(this.noseScale, Util.range(9)), `Invalid Mii nose scale. Got ${this.noseScale}, expected 0-8`);
|
||||
assert.ok(Util.inRange(this.noseYPosition, Util.range(19)), `Invalid Mii nose Y position. Got ${this.noseYPosition}, expected 0-18`);
|
||||
assert.ok(Util.inRange(this.mouthType, Util.range(36)), `Invalid Mii mouth type. Got ${this.mouthType}, expected 0-35`);
|
||||
assert.ok(Util.inRange(this.mouthColor, Util.range(5)), `Invalid Mii mouth color. Got ${this.mouthColor}, expected 0-4`);
|
||||
assert.ok(Util.inRange(this.mouthScale, Util.range(9)), `Invalid Mii mouth scale. Got ${this.mouthScale}, expected 0-8`);
|
||||
assert.ok(Util.inRange(this.mouthHorizontalStretch, Util.range(7)), `Invalid Mii mouth stretch. Got ${this.mouthHorizontalStretch}, expected 0-6`);
|
||||
assert.ok(Util.inRange(this.mouthYPosition, Util.range(19)), `Invalid Mii mouth Y position. Got ${this.mouthYPosition}, expected 0-18`);
|
||||
assert.ok(Util.inRange(this.mustacheType, Util.range(6)), `Invalid Mii mustache type. Got ${this.mustacheType}, expected 0-5`);
|
||||
assert.ok(Util.inRange(this.beardType, Util.range(6)), `Invalid Mii beard type. Got ${this.beardType}, expected 0-5`);
|
||||
// assert.ok(Util.inRange(this.facialHairColor, Util.range(8)), `Invalid Mii beard type. Got ${this.facialHairColor}, expected 0-7`);
|
||||
assert.ok(Util.inRange(this.mustacheScale, Util.range(9)), `Invalid Mii mustache scale. Got ${this.mustacheScale}, expected 0-8`);
|
||||
assert.ok(Util.inRange(this.mustacheYPosition, Util.range(17)), `Invalid Mii mustache Y position. Got ${this.mustacheYPosition}, expected 0-16`);
|
||||
assert.ok(Util.inRange(this.glassesType, Util.range(9)), `Invalid Mii glassess type. Got ${this.glassesType}, expected 0-8`);
|
||||
assert.ok(Util.inRange(this.glassesColor, Util.range(6)), `Invalid Mii glassess type. Got ${this.glassesColor}, expected 0-5`);
|
||||
assert.ok(Util.inRange(this.glassesScale, Util.range(8)), `Invalid Mii glassess type. Got ${this.glassesScale}, expected 0-7`);
|
||||
assert.ok(Util.inRange(this.glassesYPosition, Util.range(21)), `Invalid Mii glassess Y position. Got ${this.glassesYPosition}, expected 0-20`);
|
||||
assert.equal(typeof this.moleEnabled, "boolean", `Invalid mole enabled flag. Got ${this.moleEnabled}, expected true or false`);
|
||||
assert.ok(Util.inRange(this.moleScale, Util.range(9)), `Invalid Mii mole scale. Got ${this.moleScale}, expected 0-8`);
|
||||
assert.ok(Util.inRange(this.moleXPosition, Util.range(17)), `Invalid Mii mole X position. Got ${this.moleXPosition}, expected 0-16`);
|
||||
assert.ok(Util.inRange(this.moleYPosition, Util.range(31)), `Invalid Mii mole Y position. Got ${this.moleYPosition}, expected 0-30`);
|
||||
|
||||
// Sanity checks
|
||||
/*
|
||||
|
||||
HEYimHeroic says this check must be true,
|
||||
but in my testing my Mii's have both these flags
|
||||
set and are valid
|
||||
|
||||
Commenting out until we get more info
|
||||
|
||||
if (this.dsMii && this.isValid) {
|
||||
assert.fail('If DS Mii flag is true, the is valid flag must be false');
|
||||
}
|
||||
*/
|
||||
|
||||
if (this.nonUserMii && (this.creationTime !== 0 || this.isValid || this.dsMii || this.normalMii)) {
|
||||
assert.fail("Non-user Mii's must have all other Mii ID bits set to 0");
|
||||
}
|
||||
|
||||
if (!this.normalMii && !this.disableSharing) {
|
||||
assert.fail("Special Miis must have sharing disabled");
|
||||
}
|
||||
}
|
||||
|
||||
public decode(): void {
|
||||
this.version = this.bitStream.readUint8();
|
||||
this.allowCopying = this.bitStream.readBoolean();
|
||||
this.profanityFlag = this.bitStream.readBoolean();
|
||||
this.regionLock = this.bitStream.readBits(2);
|
||||
this.characterSet = this.bitStream.readBits(2);
|
||||
this.bitStream.alignByte();
|
||||
this.pageIndex = this.bitStream.readBits(4);
|
||||
this.slotIndex = this.bitStream.readBits(4);
|
||||
this.unknown1 = this.bitStream.readBits(4);
|
||||
this.deviceOrigin = this.bitStream.readBits(3);
|
||||
this.bitStream.alignByte();
|
||||
this.systemId = this.bitStream.readBuffer(8);
|
||||
this.bitStream.swapEndian(); // * Mii ID data is BE
|
||||
this.normalMii = this.bitStream.readBoolean();
|
||||
this.dsMii = this.bitStream.readBoolean();
|
||||
this.nonUserMii = this.bitStream.readBoolean();
|
||||
this.isValid = this.bitStream.readBoolean();
|
||||
this.creationTime = this.bitStream.readBits(28);
|
||||
this.bitStream.swapEndian(); // * Swap back to LE
|
||||
this.consoleMAC = this.bitStream.readBuffer(6);
|
||||
this.bitStream.skipInt16(); // * 0x0000 padding
|
||||
this.gender = this.bitStream.readBit();
|
||||
this.birthMonth = this.bitStream.readBits(4);
|
||||
this.birthDay = this.bitStream.readBits(5);
|
||||
this.favoriteColor = this.bitStream.readBits(4);
|
||||
this.favorite = this.bitStream.readBoolean();
|
||||
this.bitStream.alignByte();
|
||||
this.miiName = this.bitStream.readUTF16String(0x14);
|
||||
this.height = this.bitStream.readUint8();
|
||||
this.build = this.bitStream.readUint8();
|
||||
this.disableSharing = this.bitStream.readBoolean();
|
||||
this.faceType = this.bitStream.readBits(4);
|
||||
this.skinColor = this.bitStream.readBits(3);
|
||||
this.wrinklesType = this.bitStream.readBits(4);
|
||||
this.makeupType = this.bitStream.readBits(4);
|
||||
this.hairType = this.bitStream.readUint8();
|
||||
this.hairColor = this.bitStream.readBits(3);
|
||||
this.flipHair = this.bitStream.readBoolean();
|
||||
this.bitStream.alignByte();
|
||||
this.eyeType = this.bitStream.readBits(6);
|
||||
this.eyeColor = this.bitStream.readBits(3);
|
||||
this.eyeScale = this.bitStream.readBits(4);
|
||||
this.eyeVerticalStretch = this.bitStream.readBits(3);
|
||||
this.eyeRotation = this.bitStream.readBits(5);
|
||||
this.eyeSpacing = this.bitStream.readBits(4);
|
||||
this.eyeYPosition = this.bitStream.readBits(5);
|
||||
this.bitStream.alignByte();
|
||||
this.eyebrowType = this.bitStream.readBits(5);
|
||||
this.eyebrowColor = this.bitStream.readBits(3);
|
||||
this.eyebrowScale = this.bitStream.readBits(4);
|
||||
this.eyebrowVerticalStretch = this.bitStream.readBits(3);
|
||||
this.bitStream.skipBit();
|
||||
this.eyebrowRotation = this.bitStream.readBits(4);
|
||||
this.bitStream.skipBit();
|
||||
this.eyebrowSpacing = this.bitStream.readBits(4);
|
||||
this.eyebrowYPosition = this.bitStream.readBits(5);
|
||||
this.bitStream.alignByte();
|
||||
this.noseType = this.bitStream.readBits(5);
|
||||
this.noseScale = this.bitStream.readBits(4);
|
||||
this.noseYPosition = this.bitStream.readBits(5);
|
||||
this.bitStream.alignByte();
|
||||
this.mouthType = this.bitStream.readBits(6);
|
||||
this.mouthColor = this.bitStream.readBits(3);
|
||||
this.mouthScale = this.bitStream.readBits(4);
|
||||
this.mouthHorizontalStretch = this.bitStream.readBits(3);
|
||||
this.mouthYPosition = this.bitStream.readBits(5);
|
||||
this.mustacheType = this.bitStream.readBits(3);
|
||||
this.unknown2 = this.bitStream.readUint8();
|
||||
this.beardType = this.bitStream.readBits(3);
|
||||
this.facialHairColor = this.bitStream.readBits(3);
|
||||
this.mustacheScale = this.bitStream.readBits(4);
|
||||
this.mustacheYPosition = this.bitStream.readBits(5);
|
||||
this.bitStream.alignByte();
|
||||
this.glassesType = this.bitStream.readBits(4);
|
||||
this.glassesColor = this.bitStream.readBits(3);
|
||||
this.glassesScale = this.bitStream.readBits(4);
|
||||
this.glassesYPosition = this.bitStream.readBits(5);
|
||||
this.moleEnabled = this.bitStream.readBoolean();
|
||||
this.moleScale = this.bitStream.readBits(4);
|
||||
this.moleXPosition = this.bitStream.readBits(5);
|
||||
this.moleYPosition = this.bitStream.readBits(5);
|
||||
this.bitStream.alignByte();
|
||||
this.creatorName = this.bitStream.readUTF16String(0x14);
|
||||
this.bitStream.skipInt16(); // * 0x0000 padding
|
||||
this.bitStream.swapEndian(); // * Swap to big endian because thats how checksum is calculated here
|
||||
this.checksum = this.bitStream.readUint16();
|
||||
this.bitStream.swapEndian(); // * Swap back to little endian
|
||||
|
||||
this.validate();
|
||||
|
||||
if (this.checksum !== this.calculateCRC()) {
|
||||
throw new Error("Invalid Mii checksum");
|
||||
}
|
||||
}
|
||||
|
||||
public calculateCRC(): number {
|
||||
// #view is inaccessible
|
||||
const data = new Uint8Array(this.buffer.buffer, this.buffer.byteOffset, this.buffer.length).subarray(0, 0x5e);
|
||||
|
||||
let crc = 0x0000;
|
||||
|
||||
for (const byte of data) {
|
||||
for (let bit = 7; bit >= 0; bit--) {
|
||||
const flag = (crc & 0x8000) != 0;
|
||||
crc = ((crc << 1) | ((byte >> bit) & 0x1)) ^ (flag ? 0x1021 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 16; i > 0; i--) {
|
||||
const flag = (crc & 0x8000) != 0;
|
||||
crc = (crc << 1) ^ (flag ? 0x1021 : 0);
|
||||
}
|
||||
|
||||
return crc & 0xffff;
|
||||
}
|
||||
|
||||
public encodeStudio(): Buffer {
|
||||
this.validate();
|
||||
|
||||
/*
|
||||
Can also disable randomization with:
|
||||
|
||||
let miiStudioData = Buffer.alloc(0x2F);
|
||||
let next = 256;
|
||||
|
||||
and removing "randomizer" and the "miiStudioData.writeUInt8(randomizer);" call
|
||||
*/
|
||||
const miiStudioData = Buffer.alloc(0x2f);
|
||||
const randomizer = Math.floor(256 * Math.random());
|
||||
let next = randomizer;
|
||||
let pos = 1;
|
||||
|
||||
function encodeMiiPart(partValue: number): void {
|
||||
const encoded = (7 + (partValue ^ next)) % 256;
|
||||
next = encoded;
|
||||
|
||||
miiStudioData.writeUInt8(encoded, pos);
|
||||
pos++;
|
||||
}
|
||||
|
||||
miiStudioData.writeUInt8(randomizer);
|
||||
|
||||
if (this.facialHairColor === 0) {
|
||||
encodeMiiPart(8);
|
||||
} else {
|
||||
encodeMiiPart(this.facialHairColor);
|
||||
}
|
||||
|
||||
encodeMiiPart(this.beardType);
|
||||
encodeMiiPart(this.build);
|
||||
encodeMiiPart(this.eyeVerticalStretch);
|
||||
encodeMiiPart(this.eyeColor + 8);
|
||||
encodeMiiPart(this.eyeRotation);
|
||||
encodeMiiPart(this.eyeScale);
|
||||
encodeMiiPart(this.eyeType);
|
||||
encodeMiiPart(this.eyeSpacing);
|
||||
encodeMiiPart(this.eyeYPosition);
|
||||
encodeMiiPart(this.eyebrowVerticalStretch);
|
||||
|
||||
if (this.eyebrowColor === 0) {
|
||||
encodeMiiPart(8);
|
||||
} else {
|
||||
encodeMiiPart(this.eyebrowColor);
|
||||
}
|
||||
|
||||
encodeMiiPart(this.eyebrowRotation);
|
||||
encodeMiiPart(this.eyebrowScale);
|
||||
encodeMiiPart(this.eyebrowType);
|
||||
encodeMiiPart(this.eyebrowSpacing);
|
||||
encodeMiiPart(this.eyebrowYPosition);
|
||||
encodeMiiPart(this.skinColor);
|
||||
encodeMiiPart(this.makeupType);
|
||||
encodeMiiPart(this.faceType);
|
||||
encodeMiiPart(this.wrinklesType);
|
||||
encodeMiiPart(this.favoriteColor);
|
||||
encodeMiiPart(this.gender);
|
||||
|
||||
if (this.glassesColor == 0) {
|
||||
encodeMiiPart(8);
|
||||
} else if (this.glassesColor < 6) {
|
||||
encodeMiiPart(this.glassesColor + 13);
|
||||
} else {
|
||||
encodeMiiPart(0);
|
||||
}
|
||||
|
||||
encodeMiiPart(this.glassesScale);
|
||||
encodeMiiPart(this.glassesType);
|
||||
encodeMiiPart(this.glassesYPosition);
|
||||
|
||||
if (this.hairColor == 0) {
|
||||
encodeMiiPart(8);
|
||||
} else {
|
||||
encodeMiiPart(this.hairColor);
|
||||
}
|
||||
|
||||
encodeMiiPart(this.flipHair ? 1 : 0);
|
||||
encodeMiiPart(this.hairType);
|
||||
encodeMiiPart(this.height);
|
||||
encodeMiiPart(this.moleScale);
|
||||
encodeMiiPart(this.moleEnabled ? 1 : 0);
|
||||
encodeMiiPart(this.moleXPosition);
|
||||
encodeMiiPart(this.moleYPosition);
|
||||
encodeMiiPart(this.mouthHorizontalStretch);
|
||||
|
||||
if (this.mouthColor < 4) {
|
||||
encodeMiiPart(this.mouthColor + 19);
|
||||
} else {
|
||||
encodeMiiPart(0);
|
||||
}
|
||||
|
||||
encodeMiiPart(this.mouthScale);
|
||||
encodeMiiPart(this.mouthType);
|
||||
encodeMiiPart(this.mouthYPosition);
|
||||
encodeMiiPart(this.mustacheScale);
|
||||
encodeMiiPart(this.mustacheType);
|
||||
encodeMiiPart(this.mustacheYPosition);
|
||||
encodeMiiPart(this.noseScale);
|
||||
encodeMiiPart(this.noseType);
|
||||
encodeMiiPart(this.noseYPosition);
|
||||
|
||||
return miiStudioData;
|
||||
}
|
||||
|
||||
public studioUrl(
|
||||
queryParams: {
|
||||
type?: string;
|
||||
expression?: string;
|
||||
width?: number;
|
||||
bgColor?: string;
|
||||
clothesColor?: string;
|
||||
cameraXRotate?: number;
|
||||
cameraYRotate?: number;
|
||||
cameraZRotate?: number;
|
||||
characterXRotate?: number;
|
||||
characterYRotate?: number;
|
||||
characterZRotate?: number;
|
||||
lightXDirection?: number;
|
||||
lightYDirection?: number;
|
||||
lightZDirection?: number;
|
||||
lightDirectionMode?: string;
|
||||
instanceCount?: number;
|
||||
instanceRotationMode?: string;
|
||||
data?: string;
|
||||
} = STUDIO_RENDER_DEFAULTS,
|
||||
): string {
|
||||
const params = {
|
||||
...STUDIO_RENDER_DEFAULTS,
|
||||
...queryParams,
|
||||
data: this.encodeStudio().toString("hex"),
|
||||
};
|
||||
|
||||
params.type = STUDIO_RENDER_TYPES.includes(params.type as string) ? params.type : STUDIO_RENDER_DEFAULTS.type;
|
||||
params.expression = STUDIO_RENDER_EXPRESSIONS.includes(params.expression as string) ? params.expression : STUDIO_RENDER_DEFAULTS.expression;
|
||||
params.width = Util.clamp(params.width, 512);
|
||||
params.bgColor = STUDIO_BG_COLOR_REGEX.test(params.bgColor as string) ? params.bgColor : STUDIO_RENDER_DEFAULTS.bgColor;
|
||||
params.clothesColor = STUDIO_RENDER_CLOTHES_COLORS.includes(params.clothesColor) ? params.clothesColor : STUDIO_RENDER_DEFAULTS.clothesColor;
|
||||
params.cameraXRotate = Util.clamp(params.cameraXRotate, 359);
|
||||
params.cameraYRotate = Util.clamp(params.cameraYRotate, 359);
|
||||
params.cameraZRotate = Util.clamp(params.cameraZRotate, 359);
|
||||
params.characterXRotate = Util.clamp(params.characterXRotate, 359);
|
||||
params.characterYRotate = Util.clamp(params.characterYRotate, 359);
|
||||
params.characterZRotate = Util.clamp(params.characterZRotate, 359);
|
||||
params.lightXDirection = Util.clamp(params.lightXDirection, 359);
|
||||
params.lightYDirection = Util.clamp(params.lightYDirection, 359);
|
||||
params.lightZDirection = Util.clamp(params.lightZDirection, 359);
|
||||
params.lightDirectionMode = STUDIO_RENDER_LIGHT_DIRECTION_MODS.includes(params.lightDirectionMode)
|
||||
? params.lightDirectionMode
|
||||
: STUDIO_RENDER_DEFAULTS.lightDirectionMode;
|
||||
params.instanceCount = Util.clamp(params.instanceCount, 1, 16);
|
||||
params.instanceRotationMode = STUDIO_RENDER_INSTANCE_ROTATION_MODES.includes(params.instanceRotationMode)
|
||||
? params.instanceRotationMode
|
||||
: STUDIO_RENDER_DEFAULTS.instanceRotationMode;
|
||||
|
||||
// converts non-string params to strings
|
||||
const query = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([key, value]) => [key, value.toString()])));
|
||||
|
||||
if (params.lightDirectionMode === "none") {
|
||||
query.delete("lightDirectionMode");
|
||||
query.delete("lightXDirection");
|
||||
query.delete("lightYDirection");
|
||||
query.delete("lightZDirection");
|
||||
}
|
||||
|
||||
return `${STUDIO_RENDER_URL_BASE}?${query.toString()}`;
|
||||
}
|
||||
}
|
||||
24
shared/src/mii.js/util.ts
Normal file
24
shared/src/mii.js/util.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Stolen from https://github.com/PretendoNetwork/mii-js/
|
||||
|
||||
export default class Util {
|
||||
public static inRange(val: number, range: number[]): boolean {
|
||||
return range.includes(val);
|
||||
}
|
||||
|
||||
public static clamp(val: number, min: number, max?: number): number {
|
||||
if (max === undefined) {
|
||||
max = min;
|
||||
min = 0;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(val, min), max);
|
||||
}
|
||||
|
||||
public static range(start: number, end?: number): number[] {
|
||||
if (end === undefined) {
|
||||
end = start;
|
||||
start = 0;
|
||||
}
|
||||
return Array.from({ length: end - start }, (_, i) => i + start);
|
||||
}
|
||||
}
|
||||
116
shared/src/qr-codes.ts
Normal file
116
shared/src/qr-codes.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { profanity } from "@2toad/profanity";
|
||||
// import { AES_CCM } from "@trafficlunar/asmcrypto.js";
|
||||
import sjcl from "sjcl-with-all";
|
||||
|
||||
import { MII_DECRYPTION_KEY, MII_QR_ENCRYPTED_LENGTH } from "./constants";
|
||||
import Mii from "./mii.js/mii";
|
||||
import { ThreeDsTomodachiLifeMii, HairDyeMode } from "./three-ds-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
|
||||
// or sjcl, they can't verify the nonce/tag.
|
||||
|
||||
// In order to use sjcl's decrypt function without
|
||||
// verification, a private function needs to be called.
|
||||
// This private function's name is originally "_ctrMode"
|
||||
// but its name is minified across builds.
|
||||
// In "sjcl-with-all" v1.0.8 from npm, the name is "u"
|
||||
|
||||
/** Private _ctrMode function defined here: {@link https://github.com/bitwiseshiftleft/sjcl/blob/85caa53c281eeeb502310013312c775d35fe0867/core/ccm.js#L194} */
|
||||
const sjclCcmCtrMode:
|
||||
| ((prf: sjcl.SjclCipher, data: sjcl.BitArray, iv: sjcl.BitArray, tag: sjcl.BitArray, tlen: number, L: number) => { data: sjcl.BitArray; tag: sjcl.BitArray })
|
||||
| undefined =
|
||||
// @ts-expect-error -- Referencing a private function that is not in the types.
|
||||
sjcl.mode.ccm.u; // NOTE: This may need to be changed with a different sjcl build. Read above
|
||||
|
||||
export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii } | never {
|
||||
// Decrypt 96 byte 3DS/Wii U format Mii data from the QR code.
|
||||
// References (Credits: jaames, kazuki-4ys):
|
||||
// - https://gist.github.com/jaames/96ce8daa11b61b758b6b0227b55f9f78
|
||||
// - https://github.com/kazuki-4ys/kazuki-4ys.github.io/blob/148dc339974f8b7515bfdc1395ec1fc9becb68ab/web_apps/MiiInfoEditorCTR/encode.js#L57
|
||||
|
||||
// Check that the private _ctrMode function is defined.
|
||||
if (!sjclCcmCtrMode) {
|
||||
throw new Error(
|
||||
"Private sjcl.mode.ccm._ctrMode function cannot be found. The build of sjcl expected may have changed. Read src/lib/qr-codes.ts for more details.",
|
||||
);
|
||||
}
|
||||
|
||||
// Verify that the length is not smaller than expected.
|
||||
if (bytes.length < MII_QR_ENCRYPTED_LENGTH) {
|
||||
throw new Error(`Mii QR code has wrong size (got ${bytes.length}, expected ${MII_QR_ENCRYPTED_LENGTH} or longer)`);
|
||||
}
|
||||
|
||||
const nonce = bytes.subarray(0, 8); // Extract the AES-CCM nonce.
|
||||
const encryptedContent = bytes.subarray(8); // Extract the ciphertext.
|
||||
|
||||
const cipher = new sjcl.cipher.aes(MII_DECRYPTION_KEY); // Construct new sjcl cipher.
|
||||
|
||||
// Convert encrypted content and nonce to sjcl.BitArray (toBits expects array)
|
||||
const encryptedBits = sjcl.codec.bytes.toBits(Array.from(encryptedContent));
|
||||
const nonceBits = sjcl.codec.bytes.toBits([...nonce, 0, 0, 0, 0]); // Pad to 12 bytes.
|
||||
|
||||
// Isolate the actual ciphertext from the tag and adjust IV.
|
||||
// Copied from sjcl.mode.ccm.decrypt: https://github.com/bitwiseshiftleft/sjcl/blob/85caa53c281eeeb502310013312c775d35fe0867/core/ccm.js#L83
|
||||
const tlen = 128; // Tag length in bits.
|
||||
const dataWithoutTag = sjcl.bitArray.clamp(
|
||||
encryptedBits,
|
||||
// remove tag from out, tag length = 128
|
||||
sjcl.bitArray.bitLength(encryptedBits) - tlen,
|
||||
);
|
||||
|
||||
let decryptedBits: { data: sjcl.BitArray };
|
||||
try {
|
||||
decryptedBits = sjclCcmCtrMode(cipher, dataWithoutTag, nonceBits, [], tlen, 3); // hardcoding 3 as "L" / length
|
||||
} catch {
|
||||
// Note that the function above is not likely to fail since it decrypts without verification.
|
||||
throw new Error("Failed to decrypt QR code. It may be invalid or corrupted");
|
||||
}
|
||||
// NOTE: The CBC-MAC within the QR code is NOT verified here.
|
||||
|
||||
// Convert the decrypted bytes from sjcl format.
|
||||
const decryptedBytes = sjcl.codec.bytes.fromBits(decryptedBits.data);
|
||||
const decryptedSlice = new Uint8Array(decryptedBytes).subarray(0, 88);
|
||||
|
||||
// Create the final Mii StoreData from the decrypted bytes.
|
||||
const result = new Uint8Array([
|
||||
...decryptedSlice.subarray(0, 12), // First 12 decrypted bytes.
|
||||
...nonce, // Original nonce from the encrypted bytes.
|
||||
...decryptedSlice.subarray(12), // Then the rest of the decrypted bytes.
|
||||
]);
|
||||
|
||||
// Check if Mii data is valid by checking always zero "reserved_2" field.
|
||||
if (result[0x16] !== 0 && result[0x17] !== 0) {
|
||||
throw new Error("QR code is not a valid Mii QR code");
|
||||
}
|
||||
|
||||
// Construct mii-js Mii and TomodachiLifeMii classes.
|
||||
try {
|
||||
const buffer = Buffer.from(result); // Convert to node Buffer.
|
||||
const mii = new Mii(buffer); // Will verify the Mii data further.
|
||||
// Decrypt Tomodachi Life Mii data from encrypted QR code bytes.
|
||||
const tomodachiLifeMii = ThreeDsTomodachiLifeMii.fromBytes(bytes);
|
||||
|
||||
// Apply hair dye fields.
|
||||
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.
|
||||
tomodachiLifeMii.firstName = profanity.censor(tomodachiLifeMii.firstName);
|
||||
tomodachiLifeMii.lastName = profanity.censor(tomodachiLifeMii.lastName);
|
||||
tomodachiLifeMii.islandName = profanity.censor(tomodachiLifeMii.islandName);
|
||||
|
||||
return { mii, tomodachiLifeMii };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Mii data is not valid");
|
||||
}
|
||||
}
|
||||
317
shared/src/schemas.ts
Normal file
317
shared/src/schemas.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import { MiiGender, MiiMakeup, MiiPlatform } from "@tomodachi-share/backend";
|
||||
import { z } from "zod";
|
||||
|
||||
// profanity censoring bypasses the regex in some of these but I think it's funny
|
||||
|
||||
export const querySchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, { error: "Search query must be at least 2 characters long" })
|
||||
.max(64, { error: "Search query cannot be more than 64 characters long" })
|
||||
.regex(/^[a-zA-Z0-9-_. ']+$/, {
|
||||
error: "Search query can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.",
|
||||
});
|
||||
|
||||
// Miis
|
||||
export const nameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, { error: "Name must be at least 2 characters long" })
|
||||
.max(64, { error: "Name cannot be more than 64 characters long" })
|
||||
.regex(/^[a-zA-Z0-9-_. ']+$/, {
|
||||
error: "Name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.",
|
||||
});
|
||||
|
||||
export const tagsSchema = z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(2, { error: "Tags must be at least 2 characters long" })
|
||||
.max(20, { error: "Tags cannot be more than 20 characters long" })
|
||||
.regex(/^[a-z0-9-_]+$/, {
|
||||
error: "Tags can only contain lowercase letters, numbers, dashes, and underscores.",
|
||||
}),
|
||||
)
|
||||
.min(1, { error: "There must be at least 1 tag" })
|
||||
.max(8, { error: "There cannot be more than 8 tags" });
|
||||
|
||||
export const idSchema = z.coerce.number({ error: "ID must be a number" }).int({ error: "ID must be an integer" }).positive({ error: "ID must be valid" });
|
||||
|
||||
export const searchSchema = z.object({
|
||||
q: querySchema.optional(),
|
||||
sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"),
|
||||
tags: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) =>
|
||||
value
|
||||
?.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0),
|
||||
),
|
||||
exclude: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) =>
|
||||
value
|
||||
?.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0),
|
||||
),
|
||||
platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
|
||||
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', 'FEMALE', or 'NONBINARY' if on Switch platform" }).optional(),
|
||||
makeup: z.enum(MiiMakeup, { error: "Makeup must be either 'FULL', 'PARTIAL', or 'NONE'" }).optional(),
|
||||
allowCopying: z.coerce.boolean({ error: "Allow Copying must be either true or false" }).optional(),
|
||||
quarantined: z.coerce.boolean({ error: "Quarantined must be either true or false" }).optional(),
|
||||
// todo: incorporate tagsSchema
|
||||
// Pages
|
||||
limit: z.coerce
|
||||
.number({ error: "Limit must be a number" })
|
||||
.int({ error: "Limit must be an integer" })
|
||||
.min(1, { error: "Limit must be at least 1" })
|
||||
.max(100, { error: "Limit cannot be more than 100" })
|
||||
.optional(),
|
||||
page: z.coerce.number({ error: "Page must be a number" }).int({ error: "Page must be an integer" }).min(1, { error: "Page must be at least 1" }).optional(),
|
||||
// Random sort
|
||||
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
|
||||
// Other
|
||||
parentPage: z.string().optional(),
|
||||
userId: idSchema.optional(),
|
||||
});
|
||||
|
||||
export const userNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, { error: "Name must be at least 2 characters long" })
|
||||
.max(64, { error: "Name cannot be more than 64 characters long" })
|
||||
.regex(/^[a-zA-Z0-9-_. ']+$/, {
|
||||
error: "Name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.",
|
||||
});
|
||||
|
||||
const colorSchema = z.number().int().min(0).max(152).optional();
|
||||
const geometrySchema = z.number().int().min(-100).max(100).optional();
|
||||
|
||||
export const switchMiiInstructionsSchema = z
|
||||
.object({
|
||||
head: z.object({ skinColor: colorSchema }).optional(),
|
||||
hair: z
|
||||
.object({
|
||||
color: colorSchema,
|
||||
subColor: colorSchema,
|
||||
style: z.number().int().min(1).max(3).optional(),
|
||||
isFlipped: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
eyebrows: z
|
||||
.object({
|
||||
color: colorSchema,
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
rotation: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
eyes: z
|
||||
.object({
|
||||
main: z
|
||||
.object({
|
||||
color: colorSchema,
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
rotation: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
eyelashesTop: z
|
||||
.object({
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
rotation: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
eyelashesBottom: z
|
||||
.object({
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
rotation: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
eyelidTop: z
|
||||
.object({
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
rotation: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
eyelidBottom: z
|
||||
.object({
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
rotation: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
eyeliner: z
|
||||
.object({
|
||||
color: colorSchema,
|
||||
})
|
||||
.optional(),
|
||||
pupil: z
|
||||
.object({
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
rotation: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
nose: z
|
||||
.object({
|
||||
height: geometrySchema,
|
||||
size: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
lips: z
|
||||
.object({
|
||||
color: colorSchema,
|
||||
height: geometrySchema,
|
||||
rotation: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
hasLipstick: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
ears: z
|
||||
.object({
|
||||
height: geometrySchema,
|
||||
size: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
glasses: z
|
||||
.object({
|
||||
ringColor: colorSchema,
|
||||
shadesColor: colorSchema,
|
||||
height: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
other: z
|
||||
.object({
|
||||
wrinkles1: z
|
||||
.object({
|
||||
color: colorSchema,
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
wrinkles2: z
|
||||
.object({
|
||||
color: colorSchema,
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
beard: z
|
||||
.object({
|
||||
color: colorSchema,
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
moustache: z
|
||||
.object({
|
||||
color: colorSchema,
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
isFlipped: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
goatee: z
|
||||
.object({
|
||||
color: colorSchema,
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
mole: z
|
||||
.object({
|
||||
color: colorSchema,
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
eyeShadow: z
|
||||
.object({
|
||||
color: colorSchema,
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
blush: z
|
||||
.object({
|
||||
color: colorSchema,
|
||||
height: geometrySchema,
|
||||
distance: geometrySchema,
|
||||
size: geometrySchema,
|
||||
stretch: geometrySchema,
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
height: z.number().int().min(0).max(128).optional(),
|
||||
weight: z.number().int().min(0).max(128).optional(),
|
||||
datingPreferences: z.array(z.enum(MiiGender)).optional(),
|
||||
birthday: z
|
||||
.object({
|
||||
day: z.number().int().min(1).max(31).optional(),
|
||||
month: z.number().int().min(1).max(12).optional(),
|
||||
age: z.number().int().min(1).max(1000).optional(),
|
||||
dontAge: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
voice: z
|
||||
.object({
|
||||
speed: z.number().int().min(0).max(50).optional(),
|
||||
pitch: z.number().int().min(0).max(50).optional(),
|
||||
depth: z.number().int().min(0).max(50).optional(),
|
||||
delivery: z.number().int().min(0).max(50).optional(),
|
||||
tone: z.number().int().min(1).max(6).optional(),
|
||||
})
|
||||
.optional(),
|
||||
personality: z
|
||||
.object({
|
||||
movement: z.number().int().min(0).max(7).optional(),
|
||||
speech: z.number().int().min(0).max(7).optional(),
|
||||
energy: z.number().int().min(0).max(7).optional(),
|
||||
thinking: z.number().int().min(0).max(7).optional(),
|
||||
overall: z.number().int().min(0).max(7).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
240
shared/src/switch.ts
Normal file
240
shared/src/switch.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { SwitchMiiInstructions } from "./types";
|
||||
|
||||
export function minifyInstructions(instructions: Partial<SwitchMiiInstructions>) {
|
||||
const DEFAULT_ZERO_FIELDS = new Set(["height", "distance", "rotation", "size", "stretch"]);
|
||||
|
||||
function minify(object: Partial<SwitchMiiInstructions>): Partial<SwitchMiiInstructions> {
|
||||
for (const key in object) {
|
||||
const value = object[key as keyof SwitchMiiInstructions];
|
||||
|
||||
if (value === null || value === undefined || (typeof value === "boolean" && value === false) || (DEFAULT_ZERO_FIELDS.has(key) && value === 0)) {
|
||||
delete object[key as keyof SwitchMiiInstructions];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
minify(value as Partial<SwitchMiiInstructions>);
|
||||
|
||||
if (Object.keys(value).length === 0) {
|
||||
delete object[key as keyof SwitchMiiInstructions];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
return minify(instructions);
|
||||
}
|
||||
|
||||
export const defaultInstructions: SwitchMiiInstructions = {
|
||||
head: { skinColor: null },
|
||||
hair: {
|
||||
color: null,
|
||||
subColor: null,
|
||||
subColor2: null,
|
||||
style: null,
|
||||
isFlipped: false,
|
||||
},
|
||||
eyebrows: { color: null, height: null, distance: null, rotation: null, size: null, stretch: null },
|
||||
eyes: {
|
||||
main: { color: null, height: null, distance: null, rotation: null, size: null, stretch: null },
|
||||
eyelashesTop: { height: null, distance: null, rotation: null, size: null, stretch: null },
|
||||
eyelashesBottom: { height: null, distance: null, rotation: null, size: null, stretch: null },
|
||||
eyelidTop: { height: null, distance: null, rotation: null, size: null, stretch: null },
|
||||
eyelidBottom: { height: null, distance: null, rotation: null, size: null, stretch: null },
|
||||
eyeliner: { color: null },
|
||||
pupil: { height: null, distance: null, rotation: null, size: null, stretch: null },
|
||||
},
|
||||
nose: { height: null, size: null },
|
||||
lips: { color: null, height: null, rotation: null, size: null, stretch: null, hasLipstick: false },
|
||||
ears: { height: null, size: null },
|
||||
glasses: { ringColor: null, shadesColor: null, height: null, size: null, stretch: null },
|
||||
other: {
|
||||
wrinkles1: { height: null, distance: null, size: null, stretch: null },
|
||||
wrinkles2: { height: null, distance: null, size: null, stretch: null },
|
||||
beard: { color: null },
|
||||
moustache: { color: null, height: null, isFlipped: false, size: null, stretch: null },
|
||||
goatee: { color: null },
|
||||
mole: { color: null, height: null, distance: null, size: null },
|
||||
eyeShadow: { color: null, height: null, distance: null, size: null, stretch: null },
|
||||
blush: { color: null, height: null, distance: null, size: null, stretch: null },
|
||||
},
|
||||
height: null,
|
||||
weight: null,
|
||||
datingPreferences: [],
|
||||
birthday: {
|
||||
day: null,
|
||||
month: null,
|
||||
age: null,
|
||||
dontAge: false,
|
||||
},
|
||||
voice: { speed: null, pitch: null, depth: null, delivery: null, tone: null },
|
||||
personality: { movement: null, speech: null, energy: null, thinking: null, overall: null },
|
||||
};
|
||||
|
||||
export const COLORS: string[] = [
|
||||
// Outside
|
||||
"000000",
|
||||
"8E8E93",
|
||||
"6B4F0F",
|
||||
"5A2A0A",
|
||||
"7A1E0E",
|
||||
"A0522D",
|
||||
"A56B2A",
|
||||
"D4A15A",
|
||||
// Row 1
|
||||
"FFFFFF",
|
||||
"E6CEB2",
|
||||
"FAF79A",
|
||||
"D7FA9C",
|
||||
"BCF1A9",
|
||||
"85E5B5",
|
||||
"9FE3FE",
|
||||
"D1C5ED",
|
||||
"FEC8D6",
|
||||
"FEBFB8",
|
||||
// Row 2
|
||||
"DBD7CE",
|
||||
"E6BA79",
|
||||
"F7EA9B",
|
||||
"D6E683",
|
||||
"97DE7E",
|
||||
"7FD4BD",
|
||||
"78C4DC",
|
||||
"EFBDFA",
|
||||
"FCACC9",
|
||||
"FFA6A6",
|
||||
// Row 3
|
||||
"BDBDBD",
|
||||
"CF9F4A",
|
||||
"FDE249",
|
||||
"D5D86F",
|
||||
"9EE041",
|
||||
"63C787",
|
||||
"85BDFA",
|
||||
"C4ADE4",
|
||||
"FA7495",
|
||||
"FF7366",
|
||||
// Row 4
|
||||
"9B9B9B",
|
||||
"D09B69",
|
||||
"F9DF82",
|
||||
"D8CC82",
|
||||
"93BE0D",
|
||||
"79C49D",
|
||||
"56B4F0",
|
||||
"BF83CB",
|
||||
"C7556E",
|
||||
"F54949",
|
||||
// Row 5
|
||||
"797880",
|
||||
"A96001",
|
||||
"FFC28B",
|
||||
"CBBF37",
|
||||
"4AAD1C",
|
||||
"4FAEB0",
|
||||
"8AA6FA",
|
||||
"A992C8",
|
||||
"B05380",
|
||||
"EF0D0E",
|
||||
// Row 6
|
||||
"786F66",
|
||||
"A54D1B",
|
||||
"FF960E",
|
||||
"CDB987",
|
||||
"34996F",
|
||||
"347E8B",
|
||||
"2982D4",
|
||||
"845BB7",
|
||||
"C81C56",
|
||||
"D8530E",
|
||||
// Row 7
|
||||
"6D6E70",
|
||||
"8D4F40",
|
||||
"FFB166",
|
||||
"A59562",
|
||||
"427901",
|
||||
"216663",
|
||||
"4655A8",
|
||||
"6E42B1",
|
||||
"991C3C",
|
||||
"B63D42",
|
||||
// Row 8
|
||||
"404040",
|
||||
"7E4500",
|
||||
"EF9974",
|
||||
"99922A",
|
||||
"017562",
|
||||
"0C4F58",
|
||||
"154166",
|
||||
"4B164E",
|
||||
"8A163D",
|
||||
"A80C0D",
|
||||
// Row 9
|
||||
"2E2526",
|
||||
"663D2B",
|
||||
"885816",
|
||||
"605F31",
|
||||
"396F58",
|
||||
"013D3B",
|
||||
"223266",
|
||||
"38263C",
|
||||
"842626",
|
||||
"7B3B17",
|
||||
// Row 10
|
||||
"000000",
|
||||
"41220D",
|
||||
"5F380D",
|
||||
"4D3D0C",
|
||||
"0C4A35",
|
||||
"0D2E35",
|
||||
"161C40",
|
||||
"321C40",
|
||||
"722E3B",
|
||||
"5B160E",
|
||||
// Hair tab extra colors
|
||||
"FFD8BA",
|
||||
"FFD5AC",
|
||||
"FEC1A4",
|
||||
"FEC68F",
|
||||
"FEB089",
|
||||
"FEBA6B",
|
||||
"F39866",
|
||||
"E89854",
|
||||
"E37E3F",
|
||||
"B45627",
|
||||
"914220",
|
||||
"59371F",
|
||||
"662D16",
|
||||
"392D1E",
|
||||
// Eye tab extra colors
|
||||
"000100",
|
||||
"6B6F6E",
|
||||
"663F2D",
|
||||
"605F34",
|
||||
"3B6F59",
|
||||
"4856A6",
|
||||
// Lips tab extra colors
|
||||
"D65413",
|
||||
"F21415",
|
||||
"F54A4A",
|
||||
"EE9670",
|
||||
"8A4E40",
|
||||
// Glasses tab extra colors
|
||||
"000000",
|
||||
"776F66",
|
||||
"603915",
|
||||
"A65F00",
|
||||
"A61615",
|
||||
"273465",
|
||||
// Eye shade extra colors
|
||||
"A54E21",
|
||||
"653E2C",
|
||||
"EC946F",
|
||||
"FC9414",
|
||||
"F97595",
|
||||
"F54A4A",
|
||||
"86E1B0",
|
||||
"6E44B0",
|
||||
];
|
||||
107
shared/src/three-ds-tomodachi-life-mii.ts
Normal file
107
shared/src/three-ds-tomodachi-life-mii.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { TOMODACHI_LIFE_DECRYPTION_KEY } from "./constants";
|
||||
import sjcl from "sjcl-with-all";
|
||||
|
||||
// @ts-expect-error - This is not in the types, but it's a function needed to enable CTR mode.
|
||||
sjcl.beware["CTR mode is dangerous because it doesn't protect message integrity."]();
|
||||
|
||||
// Converts hair dye to studio color
|
||||
// Reference: https://github.com/ariankordi/nwf-mii-cemu-toy/blob/9906440c1dafbe3f40ac8b95e10a22ebd85b441e/assets/data-conversion.js#L282
|
||||
// (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];
|
||||
|
||||
// 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 class ThreeDsTomodachiLifeMii {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
islandName: string;
|
||||
|
||||
hairDye: number;
|
||||
hairDyeMode: HairDyeMode;
|
||||
|
||||
// There are 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 little-endian bit fields
|
||||
const bitField = view.getUint8(offset++);
|
||||
this.hairDyeMode = (bitField >> 6) & 0b00000011; // Bits 7-6
|
||||
this.hairDye = (bitField >> 1) & 0b00011111; // Bits 5-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): ThreeDsTomodachiLifeMii {
|
||||
const iv = bytes.subarray(0x70, 128);
|
||||
const encryptedExtraData = bytes.subarray(128, -4);
|
||||
|
||||
const cipher = new sjcl.cipher.aes(TOMODACHI_LIFE_DECRYPTION_KEY);
|
||||
|
||||
// Convert nonce and encrypted content to sjcl.BitArray.
|
||||
const encryptedBits = sjcl.codec.bytes.toBits(Array.from(encryptedExtraData));
|
||||
const ivBits = sjcl.codec.bytes.toBits(Array.from(iv));
|
||||
// Perform decryption.
|
||||
const decryptedBits = sjcl.mode.ctr.decrypt(cipher, encryptedBits, ivBits);
|
||||
// Convert from sjcl format.
|
||||
const decryptedExtraData = sjcl.codec.bytes.fromBits(decryptedBits);
|
||||
const data = new Uint8Array(decryptedExtraData);
|
||||
|
||||
return new ThreeDsTomodachiLifeMii(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;
|
||||
}
|
||||
}
|
||||
165
shared/src/types.d.ts
vendored
Normal file
165
shared/src/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { MiiGender } from "@tomodachi-share/backend";
|
||||
|
||||
export interface SwitchMiiInstructions {
|
||||
head: {
|
||||
skinColor: number | null; // Additional 14 are not in color menu, default is 2
|
||||
};
|
||||
hair: {
|
||||
color: number | null;
|
||||
subColor: number | null; // Default is none
|
||||
subColor2: number | null; // Only used when bangs/back is selected
|
||||
style: number | null; // is this different for each hair?
|
||||
isFlipped: boolean; // Only for sets and fringe
|
||||
};
|
||||
eyebrows: {
|
||||
color: number | null;
|
||||
height: number | null;
|
||||
distance: number | null;
|
||||
rotation: number | null;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
};
|
||||
eyes: {
|
||||
main: {
|
||||
color: number | null;
|
||||
height: number | null;
|
||||
distance: number | null;
|
||||
rotation: number | null;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
};
|
||||
eyelashesTop: {
|
||||
height: number | null;
|
||||
distance: number | null;
|
||||
rotation: number | null;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
};
|
||||
eyelashesBottom: {
|
||||
height: number | null;
|
||||
distance: number | null;
|
||||
rotation: number | null;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
};
|
||||
eyelidTop: {
|
||||
height: number | null;
|
||||
distance: number | null;
|
||||
rotation: number | null;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
};
|
||||
eyelidBottom: {
|
||||
height: number | null;
|
||||
distance: number | null;
|
||||
rotation: number | null;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
};
|
||||
eyeliner: {
|
||||
color: number | null;
|
||||
};
|
||||
pupil: {
|
||||
height: number | null;
|
||||
distance: number | null;
|
||||
rotation: number | null;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
};
|
||||
};
|
||||
nose: {
|
||||
height: number | null;
|
||||
size: number | null;
|
||||
};
|
||||
lips: {
|
||||
color: number | null;
|
||||
height: number | null;
|
||||
rotation: number | null;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
hasLipstick: boolean;
|
||||
};
|
||||
ears: {
|
||||
height: number | null; // Does not work for default
|
||||
size: number | null; // Does not work for default
|
||||
};
|
||||
glasses: {
|
||||
ringColor: number | null;
|
||||
shadesColor: number | null; // Only works after gap
|
||||
height: number | null;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
};
|
||||
other: {
|
||||
// names were assumed
|
||||
wrinkles1: {
|
||||
height: number | null;
|
||||
distance: number | null;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
};
|
||||
wrinkles2: {
|
||||
height: number | null;
|
||||
distance: number | null;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
};
|
||||
beard: {
|
||||
color: number | null;
|
||||
};
|
||||
moustache: {
|
||||
color: number | null; // is this same as hair?
|
||||
height: number | null;
|
||||
isFlipped: boolean;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
};
|
||||
goatee: {
|
||||
color: number | null;
|
||||
};
|
||||
mole: {
|
||||
color: number | null; // is this same as hair?
|
||||
height: number | null;
|
||||
distance: number | null;
|
||||
size: number | null;
|
||||
};
|
||||
eyeShadow: {
|
||||
color: number | null;
|
||||
height: number | null;
|
||||
distance: number | null;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
};
|
||||
blush: {
|
||||
color: number | null;
|
||||
height: number | null;
|
||||
distance: number | null;
|
||||
size: number | null;
|
||||
stretch: number | null;
|
||||
};
|
||||
};
|
||||
// makeup, use video?
|
||||
height: number | null;
|
||||
weight: number | null;
|
||||
datingPreferences: MiiGender[];
|
||||
birthday: {
|
||||
day: number | null;
|
||||
month: number | null;
|
||||
age: number | null; // TODO: update accordingly with mii creation date
|
||||
dontAge: boolean;
|
||||
};
|
||||
voice: {
|
||||
speed: number | null;
|
||||
pitch: number | null;
|
||||
depth: number | null;
|
||||
delivery: number | null;
|
||||
tone: number | null; // 1 to 6
|
||||
};
|
||||
personality: {
|
||||
movement: number | null; // 8 levels, slow to quick
|
||||
speech: number | null; // 8 levels, polite to honest
|
||||
energy: number | null; // 8 levels, flat to varied
|
||||
thinking: number | null; // 8 levels, serious to chill
|
||||
overall: number | null; // 8 levels, normal to quirky
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue