Last active
May 17, 2025 22:50
-
-
Save ariankordi/e6e66b8b03b1424d6e4e489fd9dd83bf to your computer and use it in GitHub Desktop.
simple mii studio data encoding in C and JS, from Wii U/3DS format (Ver3StoreData/FFLStoreData)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @ts-check | |
/* eslint @stylistic/indent: ['error', 4] -- Define indent rules. */ | |
/** | |
* Convert 3DS/Wii U format Mii data (Ver3StoreData/FFLStoreData) to | |
* struct format used on studio.mii.nintendo.com before obfuscation. | |
* Ported from original C function, decompiled to pseudocode: https://gist.github.com/ariankordi/e6e66b8b03b1424d6e4e489fd9dd83bf | |
* @param {Uint8Array} dst - 46-byte destination raw studio data (unobfuscated / charInfoStudio). | |
* @param {Uint8Array} src - 96-byte source Ver3StoreData. | |
*/ | |
function ver3StoreDataToCharInfoStudio(dst, src) { | |
// Color indices need to be converted using Ver3 tables: https://github.com/Genwald/MiiPort/blob/4ee38bbb8aa68a2365e9c48d59d7709f760f9b5d/include/convert_mii.h#L8 | |
// A shortcut that is equivalent to the tables is used in this snippet. | |
dst[0] = src[0x42] >> 3 & 7; | |
dst[1] = src[0x42] & 7; | |
dst[2] = src[0x2f]; | |
dst[3] = src[0x35] >> 5; | |
dst[4] = ((src[0x35] & 1) << 2) | src[0x34] >> 6; | |
dst[5] = src[0x36] & 0x1f; | |
dst[6] = src[0x35] >> 1 & 0xf; | |
dst[7] = src[0x34] & 0x3f; | |
dst[8] = ((src[0x37] & 1) << 3) | src[0x36] >> 5; | |
dst[9] = src[0x37] >> 1 & 0x1f; | |
dst[10] = src[0x39] >> 4 & 7; | |
dst[0xb] = src[0x38] >> 5; | |
dst[0xc] = src[0x3a] & 0x1f; | |
dst[0xd] = src[0x39] & 0xf; | |
dst[0xe] = src[0x38] & 0x1f; | |
dst[0xf] = ((src[0x3b] & 1) << 3) | src[0x3a] >> 5; | |
dst[0x10] = src[0x3b] >> 1 & 0x1f; | |
dst[0x11] = src[0x30] >> 5; | |
dst[0x12] = src[0x31] >> 4; | |
dst[0x13] = src[0x30] >> 1 & 0xf; | |
dst[0x14] = src[0x31] & 0xf; | |
dst[0x15] = src[0x19] >> 2 & 0xf; | |
dst[0x16] = src[0x18] & 1; | |
dst[0x17] = src[0x44] >> 4 & 7; | |
dst[0x18] = (src[0x45] & 7) * 2 | src[0x44] >> 7; | |
dst[0x19] = src[0x44] & 0xf; | |
dst[0x1a] = src[0x45] >> 3; | |
dst[0x1b] = src[0x33] & 7; | |
dst[0x1c] = src[0x33] >> 3 & 1; | |
dst[0x1d] = src[0x32]; | |
dst[0x1e] = src[0x2e]; | |
dst[0x1f] = src[0x46] >> 1 & 0xf; | |
dst[0x20] = src[0x46] & 1; | |
dst[0x21] = ((src[0x47] & 3) << 3) | src[0x46] >> 5; | |
dst[0x22] = src[0x47] >> 2 & 0x1f; | |
dst[0x23] = src[0x3f] >> 5; | |
dst[0x24] = ((src[0x3f] & 1) << 2) | src[0x3e] >> 6; | |
dst[0x25] = src[0x3f] >> 1 & 0xf; | |
dst[0x26] = src[0x3e] & 0x3f; | |
dst[0x27] = src[0x40] & 0x1f; | |
dst[0x28] = ((src[0x43] & 3) << 2) | src[0x42] >> 6; | |
dst[0x29] = src[0x40] >> 5; | |
dst[0x2a] = src[0x43] >> 2 & 0x1f; | |
dst[0x2b] = ((src[0x3d] & 1) << 3) | src[0x3c] >> 5; | |
dst[0x2c] = src[0x3c] & 0x1f; | |
dst[0x2d] = src[0x3d] >> 1 & 0x1f; | |
// All fields have been set, so the struct does not need memset. | |
// Attempt to convert Ver3 colors to common colors. | |
if (dst[0x1b] == 0) { | |
dst[0x1b] = 8; // Map 0 to 8. | |
} | |
// Beard and eyebrow color are treated like hair color. | |
if (dst[0] == 0) { | |
dst[0] = 8; | |
} | |
if (dst[0xb] == 0) { | |
dst[0xb] = 8; | |
} | |
dst[0x24] = dst[0x24] + 19; // Offset mouth color by 19. | |
dst[4] = dst[4] + 8; // Offset eye color by 8. | |
// Handle glass color. | |
if (dst[0x17] == 0) { | |
dst[0x17] = 8; | |
} else if (dst[0x17] < 6) { | |
dst[0x17] = dst[0x17] + 13; | |
} | |
} | |
/** | |
* Obfuscation code from: https://mii-studio.akamaized.net/static/js/editor.pc.46056ea432a4ef3974af.js | |
* Search ".prototype.encode". | |
* @param {Uint8Array} dst - 47-byte destination. | |
* @param {Uint8Array} src - 46-byte source data before obfuscation. | |
* @param {number} [seed] - Random byte value to use for obfuscation. | |
*/ | |
function studioURLObfuscationEncode(dst, src, seed = 0) { | |
// Store the seed at index 0 of destination. | |
dst[0] = seed; | |
// Use seed as initial previous value. | |
let previous = seed; | |
// iterate over the source array length | |
for (let i = 0; i < 46; i++) { // 46 = sizeof(charInfoStudio) | |
const current = src[i]; | |
// XOR the current value with the previous one, add 7, then take modulo 256 | |
dst[i + 1] = (7 + (current ^ previous)) % 256; | |
// update the previous value to the current encoded value | |
previous = dst[i + 1]; | |
} | |
} | |
/** | |
* Convert 3DS/Wii U format Mii data (Ver3StoreData/FFLStoreData) to | |
* URL data format used on studio.mii.nintendo.com before hex encoding. | |
* @param {Uint8Array} src - 96-byte input Ver3StoreData. | |
* @param {number} [seed] - Initial seed for obfuscation. Set to 0. | |
* @returns {Uint8Array} Converted Studio data with obfuscation to be used in a URL. | |
*/ | |
function ver3StoreDataToStudioURLData(src, seed = 0) { | |
const studioDataRaw = new Uint8Array(46); // Studio data without obfuscation. | |
// Convert to raw data. | |
ver3StoreDataToCharInfoStudio(studioDataRaw, src); | |
const dst = new Uint8Array(47); // Obfuscated studio data. | |
// Add obfuscation. | |
studioURLObfuscationEncode(dst, studioDataRaw, seed); | |
return dst; | |
} | |
// export { ver3StoreDataToStudioURLData }; | |
// == Testing == | |
// NOTE: All code below can be safely excluded | |
// if you do not need to run tests. | |
/** | |
* Data for testing conversion from Ver3StoreData | |
* to studio URL data, obfuscated with seed 0. | |
* Every array is an array with two elements | |
* element 1 = Base64 Ver3StoreData, element 2 = hex studio data | |
* @type {Array<{src: string, dst: string}>} | |
*/ | |
const testVer3StoreDataToStudioSeed0 = [ | |
// "Jasmine", from NNID: JasmineChlora | |
{ | |
src: | |
'AwAAQKBBOMSghAAA27iHMb5gKyoqQgAAWS1KAGEAcwBtAGkAbgBlAAAAAAAAABw3EhB7ASFuQxwNZMcYAAgegg0AMEGzW4JtAABvAHMAaQBnAG8AbgBhAGwAAAAAAJA6', | |
dst: '000d142a303f434b717a7b84939ba6b2bbbec5cbc9d0e2ea010d15252b3250535960736f726870757f8289a0a7aeb1' | |
}, | |
// "All" from Exzap's FFL_ODB. | |
{ | |
src: 'AwAAQAAAAAAAAAAA2JXdtJBltjwAAAAAAEBBAGwAbAAAAEEATQBFAAAAAAAAAGN/RmVrASdogyX1NEYUoQAXijAABSk1UklQRQBYAAAAAAAAAAAAAAAAAAAAAAA=', | |
dst: '000f11757d7c8689b5c0d9e1edf2fdeff4050e0f131d242b424d4f4c545b375b666e736e7169736b828d93a0acb4bb' | |
}, | |
// "Aiueome" from Super Mario Maker Wii U binary. | |
{ | |
src: 'AwEAMAAAAAAAAAAA2sZrOqTA4fgk3wAAABBBAGkAdQBlAG8AbQBlAAAAAAAAAH9/JwAuCXPOgxfsCIUfDyUY0GUAO0K2oxFSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGUq', | |
dst: '000e14727b79818dc5d0e2e9f5f7061124323a4149505b6279858aa5abb1a6e0eff5ecff001a19081423273e3d3932' | |
}, | |
// Guest A. Create ID may be inaccurate, but studio result is same. | |
{ | |
src: 'AwEAMAAAAAAAAAAAgAAAAOz/gtIAAAAAABBuAG8AIABuAGEAbQBlAAAAAAAAAEBAgQBEAAJoRBgGNEYUgRIXaA0AACkAUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAALQV', | |
dst: '000f165d6574777a7f848f93a2abb6b7bcbdc0c7ced5d8dfdee1e8e9e8efb2f9040b100b0f232e4054575e5b666e6e' | |
} | |
]; | |
// Utility: Base64 -> U8, Hex -> U8, U8 -> Hex | |
/** | |
* Base64 -> U8 / https://stackoverflow.com/a/41106346 | |
* @param {string} base64 - Input Base64 data to decode. | |
* @returns {Uint8Array} Decoded input data. | |
*/ | |
const base64ToBytes = base64 => Uint8Array.from(atob(base64), c => c.charCodeAt(0)); | |
/** | |
* Hex -> U8 / https://gist.github.com/themikefuller/608202bde24077990c0539f960b79fe4 (hex2string) | |
* @param {string} hex - Input hex data to decode. | |
* @returns {Uint8Array} Decoded input data. | |
*/ | |
const hexToBytes = hex => new Uint8Array((hex.match(/.{1,2}/g) || []) | |
.map((/** @type {string} */ byte) => parseInt(byte, 16))); | |
/** | |
* U8 -> Hex / https://www.xaymar.com/articles/2020/12/08/fastest-uint8array-to-hex-string-conversion-in-javascript/ | |
* @param {Array<number>|Uint8Array} bytes - Input data to encode. | |
* @returns {string} Hexadecimal representation of `buffer`. | |
*/ | |
const bytesToHex = bytes => Array.prototype.map.call(bytes, | |
(/** @type {{ toString: (arg0: number) => string; }} */ x) => x.toString(16).padStart(2, '0')).join(''); | |
/** | |
* Run conversion tests for {@link ver3StoreDataToStudioURLData}. | |
* @param {Array<{src: string, dst: string}>} [testData] - Expected test data ({@link testVer3StoreDataToStudioSeed0}). | |
*/ | |
function testVer3StoreDataToStudioURLData(testData = testVer3StoreDataToStudioSeed0) { | |
for (const [index, { src, dst }] of testData.entries()) { | |
// Gather source and destination Uint8Arrays. | |
const srcBytes = base64ToBytes(src); | |
const expectedDstBytes = hexToBytes(dst); | |
// Perform conversion with seed of 0. | |
const actualDstBytes = ver3StoreDataToStudioURLData(srcBytes, 0); | |
// Convert to hex. | |
const actualHex = bytesToHex(actualDstBytes); | |
const expectedHex = bytesToHex(expectedDstBytes); | |
// Use console.assert to verify. | |
console.assert( | |
actualHex === expectedHex, | |
`Test case #${index + 1} failed.\nExpected: ${expectedHex}\nActual: ${actualHex}` | |
); | |
} | |
console.debug('testVer3StoreDataToStudioURLData: Tests finished.'); | |
} | |
testVer3StoreDataToStudioURLData(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Compilation command into Ghidra: x86_64-linux-gnu-gcc -nostdlib -g3 -gdwarf-4 simple-ver3storedata-studio.c -shared -o /dev/shm/studio.so -std=c23 -fstrict-volatile-bitfields | |
#include <string.h> // memset | |
// Typedefs: u64, u32, u16, u8 | |
typedef unsigned long u64; | |
typedef unsigned int u32; | |
typedef unsigned short u16; | |
typedef unsigned char u8; | |
// From MiiPort: https://github.com/Genwald/MiiPort/blob/4ee38bbb8aa68a2365e9c48d59d7709f760f9b5d/include/mii_ext.h#L170 | |
// nn::mii::detail::Ver3StoreDataRaw, nn::mii::Ver3StoreData | |
// Matches CFLiMiiDataPacket, FFLStoreData: https://github.com/decaf-emu/decaf-emu/blob/e6c528a20a41c34e0f9eb91dd3da40f119db2dee/src/libdecaf/src/nn/ffl/nn_ffl_miidata.h#L159, https://github.com/aboood40091/ffl/blob/73fe9fc70c0f96ebea373122e50f6d3acc443180/include/nn/ffl/FFLiMiiDataCore.h#L886 | |
typedef struct __attribute__((__packed__)) { /* ver3StoreData */ | |
u32 mii_version:8; | |
u32 copyable:1; | |
u32 ng_word:1; | |
u32 region_move:2; | |
u32 font_region:2; | |
u32 reserved0:2; | |
u32 room_index:4; | |
u32 position_in_room:4; | |
u32 author_type:4; | |
u32 birth_platform:3; | |
u32 reserved_1:1; | |
u64 author_id; | |
u8 create_id[10]; | |
u16 reserved_2; | |
u16 gender:1; | |
u16 birth_month:4; | |
u16 birth_day:5; | |
u16 favorite_color:4; | |
u16 favorite:1; | |
u16 padding0:1; | |
u8 name[20]; | |
u8 height; | |
u8 build; | |
u16 localonly:1; | |
u16 face_type:4; | |
u16 face_color:3; | |
u16 face_tex:4; | |
u16 face_make:4; | |
u16 hair_type:8; | |
u16 hair_color:3; | |
u16 hair_flip:1; | |
u16 padding1:4; | |
u16 eye_type:6; | |
u16 eye_color:3; | |
u16 eye_scale:4; | |
u16 eye_aspect:3; | |
u16 eye_rotate:5; | |
u16 eye_x:4; | |
u16 eye_y:5; | |
u16 padding2:2; | |
u16 eyebrow_type:5; | |
u16 eyebrow_color:3; | |
u16 eyebrow_scale:4; | |
u16 eyebrow_aspect:3; | |
u16 padding3:1; | |
u16 eyebrow_rotate:5; | |
u16 eyebrow_x:4; | |
u16 eyebrow_y:5; | |
u16 padding4:2; | |
u16 nose_type:5; | |
u16 nose_scale:4; | |
u16 nose_y:5; | |
u16 padding5:2; | |
u16 mouth_type:6; | |
u16 mouth_color:3; | |
u16 mouth_scale:4; | |
u16 mouth_aspect:3; | |
u16 mouth_y:5; | |
u16 mustache_type:3; | |
u16 padding6:8; | |
u16 beard_type:3; | |
u16 beard_color:3; | |
u16 beard_scale:4; | |
u16 beard_y:5; | |
u16 padding7:1; | |
u16 glass_type:4; | |
u16 glass_color:3; | |
u16 glass_scale:4; | |
u16 glass_y:5; | |
u16 mole_type:1; | |
u16 mole_scale:4; | |
u16 mole_x:5; | |
u16 mole_y:5; | |
u16 padding8:1; | |
u8 creator_name[20]; | |
u16 padding9; | |
u16 crc; | |
} ver3StoreData; | |
static_assert(sizeof(ver3StoreData) == 0x60); // 96 | |
// Matches the format used on studio.mii.nintendo.com before obfuscation. | |
// https://github.com/ariankordi/FFL-Testing/blob/2219f64473ac8312bab539cd05c00f88c14d2ffd/include/mii_ext_MiiPort.h#L180 | |
typedef struct { /* charInfoStudio */ | |
u8 beard_color; | |
u8 beard_type; | |
u8 build; | |
u8 eye_aspect; | |
u8 eye_color; | |
u8 eye_rotate; | |
u8 eye_scale; | |
u8 eye_type; | |
u8 eye_x; | |
u8 eye_y; | |
u8 eyebrow_aspect; | |
u8 eyebrow_color; | |
u8 eyebrow_rotate; | |
u8 eyebrow_scale; | |
u8 eyebrow_type; | |
u8 eyebrow_x; | |
u8 eyebrow_y; | |
u8 faceline_color; | |
u8 faceline_make; | |
u8 faceline_type; | |
u8 faceline_wrinkle; | |
u8 favorite_color; | |
u8 gender; | |
u8 glass_color; | |
u8 glass_scale; | |
u8 glass_type; | |
u8 glass_y; | |
u8 hair_color; | |
u8 hair_flip; | |
u8 hair_type; | |
u8 height; | |
u8 mole_scale; | |
u8 mole_type; | |
u8 mole_x; | |
u8 mole_y; | |
u8 mouth_aspect; | |
u8 mouth_color; | |
u8 mouth_scale; | |
u8 mouth_type; | |
u8 mouth_y; | |
u8 mustache_scale; | |
u8 mustache_type; | |
u8 mustache_y; | |
u8 nose_scale; | |
u8 nose_type; | |
u8 nose_y; | |
} charInfoStudio; | |
static_assert(sizeof(charInfoStudio) == 0x2E); // 46 | |
// Obfuscation code from: https://mii-studio.akamaized.net/static/js/editor.pc.46056ea432a4ef3974af.js | |
// Search ".prototype.encode". | |
void studioURLObfuscationEncode(char* dst, const char* src, int seed) { | |
// Store the seed at index 0 of destination. | |
dst[0] = seed; | |
// Use seed as initial previous value. | |
char previous = seed; | |
// iterate over the source array length | |
for (int i = 0; i < 46; i++) { // 46 = sizeof(charInfoStudio) | |
char current = src[i]; | |
// XOR the current value with the previous one, add 7, then take modulo 256 | |
dst[i + 1] = (7 + (current ^ previous)) % 256; | |
// update the previous value to the current encoded value | |
previous = dst[i + 1]; | |
} | |
} | |
// Convert 3DS/Wii U format Mii data (Ver3StoreData/FFLStoreData) to | |
// struct format used on studio.mii.nintendo.com before obfuscation. | |
void ver3StoreDataToCharInfoStudio(charInfoStudio* dst, const ver3StoreData* src) { | |
// Color indices need to be converted using Ver3 tables: https://github.com/Genwald/MiiPort/blob/4ee38bbb8aa68a2365e9c48d59d7709f760f9b5d/include/convert_mii.h#L8 | |
// A shortcut that is equivalent to the tables is used in this snippet. | |
dst->beard_color = src->beard_color; // Convert to CommonColor | |
dst->beard_type = src->beard_type; | |
dst->build = src->build; | |
dst->eye_aspect = src->eye_aspect; | |
dst->eye_color = src->eye_color; // Convert to CommonColor | |
dst->eye_rotate = src->eye_rotate; | |
dst->eye_scale = src->eye_scale; | |
dst->eye_type = src->eye_type; | |
dst->eye_x = src->eye_x; | |
dst->eye_y = src->eye_y; | |
dst->eyebrow_aspect = src->eyebrow_aspect; | |
dst->eyebrow_color = src->eyebrow_color; // Convert to CommonColor | |
dst->eyebrow_rotate = src->eyebrow_rotate; | |
dst->eyebrow_scale = src->eyebrow_scale; | |
dst->eyebrow_type = src->eyebrow_type; | |
dst->eyebrow_x = src->eyebrow_x; | |
dst->eyebrow_y = src->eyebrow_y; | |
// All faceline fields are named diffetently. | |
dst->faceline_color = src->face_color; // No up conversion needed | |
dst->faceline_make = src->face_make; | |
dst->faceline_type = src->face_type; | |
dst->faceline_wrinkle = src->face_tex; | |
dst->favorite_color = src->favorite_color; // Unchanged | |
dst->gender = src->gender; | |
dst->glass_color = src->glass_color; // Convert to CommonColor | |
dst->glass_scale = src->glass_scale; | |
dst->glass_type = src->glass_type; // No up conversion needed | |
dst->glass_y = src->glass_y; | |
dst->hair_color = src->hair_color; // Convert to CommonColor | |
dst->hair_flip = src->hair_flip; | |
dst->hair_type = src->hair_type; | |
dst->height = src->height; | |
dst->mole_scale = src->mole_scale; | |
dst->mole_type = src->mole_type; | |
dst->mole_x = src->mole_x; | |
dst->mole_y = src->mole_y; | |
dst->mouth_aspect = src->mouth_aspect; | |
dst->mouth_color = src->mouth_color; // Convert to CommonColor | |
dst->mouth_scale = src->mouth_scale; | |
dst->mouth_type = src->mouth_type; | |
dst->mouth_y = src->mouth_y; | |
// Beard fields are named differently. | |
dst->mustache_scale = src->beard_scale; | |
dst->mustache_type = src->mustache_type; | |
dst->mustache_y = src->beard_y; | |
dst->nose_scale = src->nose_scale; | |
dst->nose_type = src->nose_type; | |
dst->nose_y = src->nose_y; | |
// All fields have been set, so the struct does not need memset. | |
// Attempt to convert Ver3 colors to common colors. | |
if (dst->hair_color == 0) dst->hair_color = 8; // Map 0 to 8. | |
// Beard and eyebrow color are treated like hair color. | |
if (dst->beard_color == 0) dst->beard_color = 8; | |
if (dst->eyebrow_color == 0) dst->eyebrow_color = 8; | |
dst->mouth_color += 19; // Offset mouth color by 19. | |
dst->eye_color += 8; // Offset eye color by 8. | |
// Handle glass color. | |
if (dst->glass_color == 0) { | |
dst->glass_color = 8; | |
} else if (dst->glass_color < 6) { | |
dst->glass_color += 13; | |
} | |
} | |
void ver3StoreDataToStudioURLData(char dst[47], const ver3StoreData* src, int seed) { | |
charInfoStudio studioDataRaw; // Studio data without obfuscation. | |
memset(&studioDataRaw, 0, sizeof(charInfoStudio)); | |
// Convert to raw data. | |
ver3StoreDataToCharInfoStudio(&studioDataRaw, src); | |
// Add obfuscation. | |
studioURLObfuscationEncode(dst, (char*)src, seed); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment