mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 05:07:46 +00:00
286 lines
8.9 KiB
TypeScript
286 lines
8.9 KiB
TypeScript
import { CharInfoEx } from "charinfo-ex";
|
|
import * as fzstd from "fzstd";
|
|
import { BytesDeswizzle } from "./deswizzle";
|
|
|
|
import { minifyInstructions } from "./switch";
|
|
import { type MiiGender, type SwitchMiiInstructions } from "./types";
|
|
|
|
export class SwitchTomodachiLifeMii {
|
|
buffer: ArrayBuffer;
|
|
data: CharInfoEx;
|
|
|
|
datingPreferences: MiiGender[];
|
|
birthday: { month: number; day: number; age: number; dontAge: boolean };
|
|
voice: { speed: number; pitch: number; depth: number; delivery: number; tone: number };
|
|
personality: { movement: number; speech: number; energy: number; thinking: number; overall: number };
|
|
|
|
constructor(buffer: ArrayBuffer, data: CharInfoEx) {
|
|
this.buffer = buffer;
|
|
this.data = data;
|
|
|
|
const view = new DataView(buffer);
|
|
const bytes = new Uint8Array(buffer);
|
|
const parse = (index: number): number => view.getUint8(161 + index * 4);
|
|
|
|
const age = view.getUint32(0x00e1, true);
|
|
const year = view.getUint32(0x00d9, true);
|
|
const dontAge = age !== 0xffffffff;
|
|
|
|
this.datingPreferences = (["MALE", "FEMALE", "NONBINARY"] as const).filter((_, i) => bytes[0x01a9 + i] === 1);
|
|
this.birthday = {
|
|
month: parse(17),
|
|
day: parse(15),
|
|
age: dontAge ? age : new Date().getFullYear() - year,
|
|
dontAge,
|
|
};
|
|
this.voice = {
|
|
speed: parse(6),
|
|
pitch: parse(8),
|
|
depth: parse(5),
|
|
delivery: Math.max(0, view.getInt8(0xc5)), // why is this an integer??
|
|
tone: parse(7) + 1,
|
|
// TODO: add voice preset to instructions type?
|
|
};
|
|
this.personality = {
|
|
movement: parse(4) - 1,
|
|
speech: parse(2) - 1,
|
|
energy: parse(1) - 1,
|
|
thinking: parse(0) - 1,
|
|
overall: parse(3) - 1,
|
|
};
|
|
|
|
// Validate
|
|
// if (bytes[0x01a9] > 1 || bytes[0x01aa] > 1 || bytes[0x01ab] > 1) throw new Error("Invalid dating preference bytes");
|
|
// if (this.birthday.month < 1 || this.birthday.month > 12) throw new Error("Invalid birthday month");
|
|
// if (this.birthday.day < 1 || this.birthday.day > 31) throw new Error("Invalid birthday day");
|
|
// if (
|
|
// this.personality.movement < 0 ||
|
|
// this.personality.movement > 4 ||
|
|
// this.personality.speech < 0 ||
|
|
// this.personality.speech > 4 ||
|
|
// this.personality.energy < 0 ||
|
|
// this.personality.energy > 4 ||
|
|
// this.personality.thinking < 0 ||
|
|
// this.personality.thinking > 4 ||
|
|
// this.personality.overall < 0 ||
|
|
// this.personality.overall > 4
|
|
// )
|
|
// throw new Error("Invalid personality values");
|
|
}
|
|
|
|
// There's a UGC Texture image but we're ignoring it
|
|
public async extractFacePaintImage(): Promise<Buffer | null> {
|
|
try {
|
|
if (typeof window !== "undefined") {
|
|
throw new Error("sharp cannot run in the browser");
|
|
}
|
|
|
|
const { default: sharp } = await import("sharp");
|
|
|
|
const buf = Buffer.from(this.buffer);
|
|
|
|
const canvasMarker = Buffer.from([0xa3, 0xa3, 0xa3, 0xa3]);
|
|
const ugcMarker = Buffer.from([0xa4, 0xa4, 0xa4, 0xa4]);
|
|
|
|
const canvasStart = buf.indexOf(canvasMarker);
|
|
if (canvasStart === -1) return null;
|
|
|
|
const ugcStart = buf.indexOf(ugcMarker);
|
|
const canvasData = buf.subarray(canvasStart + 4, ugcStart === -1 ? undefined : ugcStart);
|
|
|
|
const decompressed = Buffer.from(fzstd.decompress(canvasData));
|
|
const deswizzled = new BytesDeswizzle(decompressed, [256, 256], [1, 1], 4, 4).deswizzle();
|
|
|
|
// Issue (#47)
|
|
const gamma = 0.4545;
|
|
|
|
for (let i = 0; i < deswizzled.length; i += 4) {
|
|
deswizzled[i] = Math.min(255, Math.round(255 * Math.pow(deswizzled[i] / 255, gamma)));
|
|
deswizzled[i + 1] = Math.min(255, Math.round(255 * Math.pow(deswizzled[i + 1] / 255, gamma)));
|
|
deswizzled[i + 2] = Math.min(255, Math.round(255 * Math.pow(deswizzled[i + 2] / 255, gamma)));
|
|
}
|
|
|
|
return await sharp(deswizzled, {
|
|
raw: { width: 256, height: 256, channels: 4 },
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
} catch (err) {
|
|
console.error("extractFacePaintImage failed:", err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public toInstructions() {
|
|
const instructions: Partial<SwitchMiiInstructions> = {
|
|
head: {
|
|
type: this.data.facelineType,
|
|
skinColor: this.data.facelineColor,
|
|
},
|
|
hair: {
|
|
set: this.data.hairType,
|
|
bangs: this.data.hairTypeFront,
|
|
back: this.data.hairTypeBack,
|
|
color: this.data.hairColor0,
|
|
subColor: this.data.hairColor1,
|
|
subColor2: this.data.hairColor0, // TODO: check
|
|
style: this.data.hairStyle,
|
|
isFlipped: (this.data.faceFlags & (1 << 2)) !== 0, // bangsSide
|
|
},
|
|
eyebrows: {
|
|
type: this.data.eyebrowType,
|
|
color: this.data.eyebrowColor,
|
|
height: this.data.eyebrowY - 10,
|
|
distance: this.data.eyebrowX - 4,
|
|
rotation: this.data.eyebrowRotate - 6,
|
|
size: this.data.eyebrowScale - 4,
|
|
stretch: this.data.eyebrowAspect - 3,
|
|
},
|
|
eyes: {
|
|
main: {
|
|
type: this.data.eyeType,
|
|
color: this.data.eyeColor,
|
|
height: this.data.eyeY - 12,
|
|
distance: this.data.eyeX - 2,
|
|
rotation: this.data.eyeRotate - 4,
|
|
size: this.data.eyeScale - 4,
|
|
stretch: this.data.eyeAspect - 3,
|
|
},
|
|
eyelashesTop: {
|
|
type: this.data.eyelashUpperType,
|
|
height: this.data.eyelashUpperY,
|
|
distance: this.data.eyelashUpperX,
|
|
rotation: this.data.eyelashUpperRotate,
|
|
size: this.data.eyelashUpperScale,
|
|
stretch: this.data.eyelashUpperAspect,
|
|
},
|
|
eyelashesBottom: {
|
|
type: this.data.eyelashLowerType,
|
|
height: this.data.eyelashLowerY,
|
|
distance: this.data.eyelashLowerX,
|
|
rotation: this.data.eyelashLowerRotate,
|
|
size: this.data.eyelashLowerScale,
|
|
stretch: this.data.eyelashLowerAspect,
|
|
},
|
|
eyelidTop: {
|
|
type: this.data.eyelidUpperType,
|
|
height: this.data.eyelidUpperY,
|
|
distance: this.data.eyelidUpperX,
|
|
rotation: this.data.eyelidUpperRotate,
|
|
size: this.data.eyelidUpperScale,
|
|
stretch: this.data.eyelidUpperAspect,
|
|
},
|
|
eyelidBottom: {
|
|
type: this.data.eyelidLowerType,
|
|
height: this.data.eyelidLowerY,
|
|
distance: this.data.eyelidLowerX,
|
|
rotation: this.data.eyelidLowerRotate,
|
|
size: this.data.eyelidLowerScale,
|
|
stretch: this.data.eyelidLowerAspect,
|
|
},
|
|
eyeliner: {
|
|
type: (this.data.faceFlags & (1 << 4)) !== 0, // eyeShadowEnabled
|
|
color: this.data.eyeShadowColor,
|
|
},
|
|
pupil: {
|
|
type: this.data.eyeHighlightType,
|
|
height: this.data.eyeHighlightY,
|
|
distance: this.data.eyeHighlightX,
|
|
rotation: this.data.eyeHighlightRotate,
|
|
size: this.data.eyeHighlightScale,
|
|
stretch: this.data.eyeHighlightAspect,
|
|
},
|
|
},
|
|
nose: {
|
|
type: this.data.noseType,
|
|
height: this.data.noseY - 9,
|
|
size: this.data.noseScale - 4,
|
|
},
|
|
lips: {
|
|
type: this.data.mouthType,
|
|
color: this.data.mouthColor,
|
|
height: this.data.mouthY - 13,
|
|
rotation: this.data.mouthRotate,
|
|
size: this.data.mouthScale - 4,
|
|
stretch: this.data.mouthAspect - 3,
|
|
hasLipstick: (this.data.faceFlags & (1 << 5)) !== 0, // mouthInvert
|
|
},
|
|
ears: {
|
|
type: this.data.earType,
|
|
height: this.data.earY - 4,
|
|
size: this.data.earScale - 2,
|
|
},
|
|
glasses: {
|
|
type: this.data.glassType1,
|
|
type2: this.data.glassType2,
|
|
ringColor: this.data.glassColor1,
|
|
shadesColor: this.data.glassColor2,
|
|
height: this.data.glassY - 11,
|
|
size: this.data.glassScale - 4,
|
|
stretch: this.data.glassAspect - 3,
|
|
},
|
|
other: {
|
|
wrinkles1: {
|
|
type: this.data.wrinkleLowerType,
|
|
height: this.data.wrinkleLowerY - 15,
|
|
distance: this.data.wrinkleLowerX - 2,
|
|
size: this.data.wrinkleLowerScale - 6,
|
|
stretch: this.data.wrinkleLowerAspect - 3,
|
|
},
|
|
wrinkles2: {
|
|
type: this.data.wrinkleUpperType,
|
|
height: this.data.wrinkleUpperY - 23,
|
|
distance: this.data.wrinkleUpperX - 7,
|
|
size: this.data.wrinkleUpperScale - 6,
|
|
stretch: this.data.wrinkleUpperAspect - 3,
|
|
},
|
|
beard: {
|
|
type: this.data.beardType,
|
|
color: this.data.beardColor,
|
|
},
|
|
moustache: {
|
|
type: this.data.mustacheType,
|
|
color: this.data.mustacheColor,
|
|
height: this.data.mustacheY - 10,
|
|
isFlipped: (this.data.faceFlags & (1 << 6)) !== 0, // mustacheInverted
|
|
size: this.data.mustacheScale - 4,
|
|
stretch: this.data.mustacheAspect - 3,
|
|
},
|
|
goatee: {
|
|
type: this.data.beardShortType,
|
|
color: this.data.beardShortColor,
|
|
},
|
|
mole: {
|
|
type: this.data.moleX != 0,
|
|
height: this.data.moleY - 20,
|
|
distance: this.data.moleX - 2,
|
|
size: this.data.moleScale - 4,
|
|
},
|
|
eyeShadow: {
|
|
type: this.data.makeup0,
|
|
color: this.data.makeup0Color,
|
|
height: this.data.makeup0Y - 12,
|
|
distance: this.data.makeup0X - 1,
|
|
size: this.data.makeup0Scale - 6,
|
|
stretch: this.data.makeup0Aspect - 3,
|
|
},
|
|
blush: {
|
|
type: this.data.makeup1,
|
|
color: this.data.makeup1Color,
|
|
height: this.data.makeup1Y - 19,
|
|
distance: this.data.makeup1X - 6,
|
|
size: this.data.makeup1Scale - 5,
|
|
stretch: this.data.makeup1Aspect - 3,
|
|
},
|
|
},
|
|
height: this.data.height,
|
|
weight: this.data.build,
|
|
datingPreferences: this.datingPreferences,
|
|
birthday: this.birthday,
|
|
voice: this.voice,
|
|
personality: this.personality,
|
|
};
|
|
|
|
return minifyInstructions(instructions);
|
|
}
|
|
}
|