From 1ff38232094507547c41e19904fe7d43e59cbea5 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Mon, 13 Apr 2026 17:39:04 +0100 Subject: [PATCH 1/2] feat: get dating prefs, birthday, voice, personality --- src/app/api/submit/route.ts | 36 +++++++++++++++++++++++++--- src/app/mii/[id]/download/route.ts | 32 +++++++++++++++++++++++++ src/app/mii/[id]/page.tsx | 14 +++++++++-- src/components/mii/instructions.tsx | 1 + src/components/submit-form/index.tsx | 2 +- 5 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 src/app/mii/[id]/download/route.ts diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index c8abe19..3a567f3 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -48,7 +48,7 @@ const submitSchema = z.object({ // Save data way miiDataFile: z .instanceof(File) - .refine((blob) => blob.size < 1024 * 30, "File too large") // TODO: actual size + .refine((blob) => blob.size < 1024 * 1024 * 1.5, "File too large") // TODO: actual size .optional(), // Manual way @@ -207,7 +207,16 @@ export async function POST(request: NextRequest) { const miiData = miiDataFileBuffer ? CharInfoEx.FromShareMiiFileArrayBuffer(miiDataFileBuffer) : undefined; if (way === "savedata") { - if (!miiData) return rateLimit.sendResponse({ error: "No mii data provided" }, 400); + if (!miiData || !miiDataFileBuffer || !miiDataFileArray) return rateLimit.sendResponse({ error: "No valid Mii data provided" }, 400); + + const view = new DataView(miiDataFileBuffer); + + 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; const instructions: Partial = { head: { @@ -375,7 +384,28 @@ export async function POST(request: NextRequest) { }, height: miiData.height, weight: miiData.build, - // uh oh, no dating prefs, birthday, voice, personality + datingPreferences: ([MiiGender.MALE, MiiGender.FEMALE, MiiGender.NONBINARY] as const).filter((_, i) => miiDataFileArray[0x01a9 + i] === 1), + birthday: { + month: parse(17), + day: parse(15), + age: dontAge ? age : new Date().getFullYear() - year, + dontAge, + }, + 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, + // preset type? + }, + personality: { + movement: parse(4) - 1, + speech: parse(2) - 1, + energy: parse(1) - 1, + thinking: parse(0) - 1, + overall: parse(3) - 1, + }, }; minifiedInstructions = minifyInstructions(instructions); diff --git a/src/app/mii/[id]/download/route.ts b/src/app/mii/[id]/download/route.ts new file mode 100644 index 0000000..ff73020 --- /dev/null +++ b/src/app/mii/[id]/download/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { RateLimit } from "@/lib/rate-limit"; +import { idSchema } from "@/lib/schemas"; + +export async function GET(request: NextRequest, { params }: { params: { id: string } }) { + const rateLimit = new RateLimit(request, 200, "/mii/image"); + const check = await rateLimit.handle(); + if (check) return check; + + const { id: slugId } = await params; + const parsed = idSchema.safeParse(slugId); + if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); + const miiId = parsed.data; + + const mii = await prisma.mii.findUnique({ + where: { id: miiId }, + }); + + if (!mii || !mii.miiData) { + return new NextResponse("Not found", { status: 404 }); + } + + const fileName = `${mii.name}.ltd`; + + return new NextResponse(mii.miiData, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename="${fileName}"`, + }, + }); +} diff --git a/src/app/mii/[id]/page.tsx b/src/app/mii/[id]/page.tsx index 73efc43..9a01f07 100644 --- a/src/app/mii/[id]/page.tsx +++ b/src/app/mii/[id]/page.tsx @@ -371,9 +371,15 @@ export default async function MiiPage({ params }: Props) { {/* Buttons */} -
+
+ {mii.miiData && ( + + + Download + + )} @@ -391,7 +397,11 @@ export default async function MiiPage({ params }: Props) { Instructions -

All instructions are based off of the default Male Mii.

+

+ All instructions are based off of the default Male Mii. +
+ {mii.miiData && "If you're on modded/emulator, you can download the .ltd file above."} +

{mii.youtubeId && ( diff --git a/src/components/mii/instructions.tsx b/src/components/mii/instructions.tsx index eb95d3e..a50b115 100644 --- a/src/components/mii/instructions.tsx +++ b/src/components/mii/instructions.tsx @@ -252,6 +252,7 @@ export default function MiiInstructions({ instructions, isUsingSaveFile }: Props {(height || weight || datingPreferences || voice || personality) && (

Misc

+

These contain sliders: 0 is middle, positive is to the right, negative is to the left

diff --git a/src/components/submit-form/index.tsx b/src/components/submit-form/index.tsx index 1beb7e5..7585a1c 100644 --- a/src/components/submit-form/index.tsx +++ b/src/components/submit-form/index.tsx @@ -433,7 +433,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) { type="button" className={`flex flex-col justify-center items-center rounded-xl p-4 shadow-md border-2 cursor-pointer text-center text-sm transition hover:scale-[1.03] ${way === "savedata" ? "bg-cyan-100 border-cyan-600" : "bg-zinc-50 border-zinc-300 hover:bg-cyan-100 hover:border-cyan-600"}`} > - .ltd file + .ltd file (Modded) -

Click on a way to see tutorials for them

+

Select a method above and click 'How to?' to view the tutorial.

{/* (Switch Only) Mii Screenshots */} @@ -503,7 +501,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) { {way === "manual" && ( <> - +

A tutorial on how to screenshot the features is above.

)} @@ -538,12 +536,13 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {

- Save Data + ShareMii File
+ {/* YouTube */}
@@ -599,7 +598,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
- + Mii editor may be inaccurate. Instructions are recommended, but not required - you do not have to add every instruction. diff --git a/src/components/tutorial/index.tsx b/src/components/tutorial/index.tsx index 19fa3d8..59016f4 100644 --- a/src/components/tutorial/index.tsx +++ b/src/components/tutorial/index.tsx @@ -5,11 +5,13 @@ import { useEffect, useState } from "react"; import useEmblaCarousel from "embla-carousel-react"; import { Icon } from "@iconify/react"; import confetti from "canvas-confetti"; +import Link from "next/link"; interface Slide { // step is never used, undefined is assumed as a step type?: "start" | "step" | "finish"; text?: string; + link?: string; imageSrc?: string; } @@ -159,7 +161,13 @@ export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
) : ( <> -

{slide.text}

+ {slide.link ? ( + + {slide.text} + + ) : ( +

{slide.text}

+ )} view.getUint8(161 + index * 4); + + const age = view.getUint32(0x00e1, true); + const year = view.getUint32(0x00d9, true); + const dontAge = age !== 0xffffffff; + + this.datingPreferences = ([MiiGender.MALE, MiiGender.FEMALE, MiiGender.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, + }; + } + + // There's a UGC Texture image but we're ignoring it + public async extractFacePaintImage(): Promise { + try { + 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(); + + 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 = { + 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); + } +}