miijs
Version:
The most complete and easy to use Mii library available.
719 lines (624 loc) • 25.8 kB
JavaScript
import { formats, mappings, defaultMappings, forwardPort, backPort } from "./formats.js";
import { lookupTables } from "./data.js";
import { Buffer } from "./platform.js";
import { scanQR } from "./qrTools.js";
function isWebp(buf) {
return (
Buffer.isBuffer(buf)
&& buf.length >= 12
&& buf.toString("ascii", 0, 4) === "RIFF"
&& buf.toString("ascii", 8, 12) === "WEBP"
);
}
function isPng(buf) {
return (
Buffer.isBuffer(buf)
&& buf.length >= 8
&& buf[0] === 0x89
&& buf[1] === 0x50
&& buf[2] === 0x4E
&& buf[3] === 0x47
&& buf[4] === 0x0D
&& buf[5] === 0x0A
&& buf[6] === 0x1A
&& buf[7] === 0x0A
);
}
function isJpg(buf) {
return (
Buffer.isBuffer(buf)
&& buf.length >= 3
&& buf[0] === 0xFF
&& buf[1] === 0xD8
&& buf[2] === 0xFF
);
}
/** @typedef {Buffer|Uint8Array|ArrayBuffer|SharedArrayBuffer} SupportedBuffers */
/** Returns polyfilled buffer if type is buffer type, else false */
function isBuffer(inp) {
const isValidBuffer =
Buffer.isBuffer(inp)
|| inp instanceof Uint8Array
|| inp instanceof ArrayBuffer
|| inp instanceof SharedArrayBuffer;
// || (Array.isArray(inp) && inp.every(x => Number.isInteger(x) && x >= 0 && x <= 255));
if (!isValidBuffer) return false;
return Buffer.from(inp);
}
function ensureBuffer(buf, debug) {
// If already bytes, just validate buffer
let out = buf;
if (typeof out === "string") {
const s = out.trim();
// ---- data URI ----
if (/^data:/i.test(s)) {
const comma = s.indexOf(",");
if (comma === -1) return false;
const meta = s.slice(5, comma);// after "data:" up to comma
let data = s.slice(comma + 1);
const isB64 = /;\s*base64\s*$/i.test(meta);
if (debug) console.log(`Processing as data URI (${isB64 ? "base64" : "percent"})`);
if (isB64) {
// base64 payload (strip whitespace, allow base64url, allow missing padding)
data = data.replace(/\s+/g, "").replace(/-/g, "+").replace(/_/g, "/");
const pad = data.length % 4;
if (pad) data += "=".repeat(4 - pad);
out = bytesFromBase64(data);
} else {
// percent-encoded (data:,Hello%20world)
try {
const decoded = decodeURIComponent(data);
out = bytesFromText(decoded);
} catch {
return false;
}
}
}
else {
const compact = s.replace(/\s+/g, "");
if (/^[0-9a-f]+$/i.test(compact) && (compact.length % 2 === 0)) {
if (debug) console.log("Processing as hex");
out = bytesFromHex(compact);
}
else if (/^[A-Za-z0-9+/_-]+={0,2}$/.test(compact) && compact.length >= 4) {
if (debug) console.log("Processing as base64");
let b64 = compact.replace(/-/g, "+").replace(/_/g, "/");
const pad = b64.length % 4;
if (pad) b64 += "=".repeat(4 - pad);
out = bytesFromBase64(b64);
}
}
}
out = isBuffer(out);
return out ? out : false;
}
function isPromiseLike(value) {
return value && typeof value.then === "function";
}
function bytesFromHex(hex) {
if (typeof Buffer !== "undefined" && Buffer.from) return Buffer.from(hex, "hex");
const out = new Uint8Array(hex.length / 2);
for (let i = 0, j = 0; i < hex.length; i += 2, j++) out[j] = parseInt(hex.slice(i, i + 2), 16);
return out;
}
function bytesFromBase64(b64) {
if (typeof Buffer !== "undefined" && Buffer.from) return Buffer.from(b64, "base64");
if (typeof atob !== "undefined") {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
return null;
}
function bytesFromText(str) {
if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(str);
if (typeof Buffer !== "undefined" && Buffer.from) return Buffer.from(str, "utf8");
const out = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) out[i] = str.charCodeAt(i) & 0xff;
return out;
}
/** @typedef {import("./mii-jsdoc.js").Mii} MiiData */
const canonical3DSGlassesTypes = new Set(lookupTables.glassesTypes);
function formatUses3DSTranslation(formatName, visited = new Set()) {
if (!formatName || visited.has(formatName)) return false;
visited.add(formatName);
const format = formats[formatName];
if (!format) return false;
if (format.translation === "3ds") return true;
if (format.preEncode) return formatUses3DSTranslation(format.preEncode, visited);
return false;
}
function bufferToBitString(buf) {
let out = '';
for (let i = 0; i < buf.length; i++) {
out += buf[i].toString(2).padStart(8, '0');
}
return out;
}
function setNestedValue(obj, key, value) {
try {
const keys = key.split('.');
const lastKey = keys.pop();
const target = keys.reduce((current, key) => {
if (!current[key] || typeof current[key] !== 'object' || Array.isArray(current[key])) {
current[key] = {};
}
return current[key];
}, obj);
target[lastKey] = value;
return obj;
}
catch (e) {
throw new Error(`${key} was not usable.`);
}
}
function getNestedValue(obj, path) {
if (path === 'SKIP') return null;
try {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current === undefined || current === null) {
return undefined;
}
current = current[part];
}
return current;
}
catch (e) {
return null;
}
}
function binaryToText(binaryString) {
const chars = [];
for (let i = 0; i + 16 <= binaryString.length; i += 16) {
const chunk = binaryString.slice(i, i + 16);
const highByte = parseInt(chunk.slice(0, 8), 2);
const lowByte = parseInt(chunk.slice(8, 16), 2);
const charCode = (highByte << 8) | lowByte;
// stop at first null character (0x0000)
if (charCode === 0) break;
chars.push(String.fromCharCode(charCode));
}
return chars.join('');
}
function textToBinaryBE(text, bitLength) {
const numChars = bitLength / 16;
let binary = '';
for (let i = 0; i < numChars; i++) {
const charCode = i < text.length ? text.charCodeAt(i) : 0;
binary += charCode.toString(2).padStart(16, '0');
}
return binary;
}
function textToBinaryLE(text, bitLength) {
const numChars = bitLength / 16;
let binary = '';
for (let i = 0; i < numChars; i++) {
const charCode = i < text.length ? text.charCodeAt(i) : 0;
// Low byte (bits 0-7), then high byte (bits 8-15)
const lowByte = (charCode & 0xFF).toString(2).padStart(8, '0');
const highByte = ((charCode >> 8) & 0xFF).toString(2).padStart(8, '0');
binary += lowByte + highByte;
}
return binary;
}
function binaryToTextLE(binaryString) {
const chars = [];
for (let i = 0; i + 16 <= binaryString.length; i += 16) {
const chunk = binaryString.slice(i, i + 16);
// First 8 bits = low byte, next 8 bits = high byte (little-endian)
const lowByte = parseInt(chunk.slice(0, 8), 2);
const highByte = parseInt(chunk.slice(8, 16), 2);
const charCode = (highByte << 8) | lowByte;
// stop at first null character (0x0000)
if (charCode === 0) break;
chars.push(String.fromCharCode(charCode));
}
return chars.join('');
}
/**
* Check whether given buffer matches the specified mii format
* @param {SupportedBuffers} buf - The buffer to validate.
* @param {string} requireFormat - The format requested.
* @param {boolean} debug - Debug logging
* @returns {boolean} Returns true if the buffer is in provided format, false if not.
*/
function isMiiInFormat(buf, requireFormat, debug) {
// Validate input
buf = isBuffer(buf);
if (!buf) {
console.error(`Is not a buffer`);
return false;
}
// Validate format
requireFormat = requireFormat?.toLowerCase().replaceAll(".", '');
if (buf.length !== formats[requireFormat].len) {
if (debug) console.log(`The file length does not match ${requireFormat}, expected`, formats[requireFormat].len, "got", buf.length);
return false;
}
let bits = bufferToBitString(buf);
let offset = 0;
let wordBits = null;
let wordPos = 0;
let wordEnd = 0;
if (formats[requireFormat].hasOwnProperty('struct')) {
let wordStartOffset = 0;
for (const field of formats[requireFormat].struct) {
if (field.word) {
wordStartOffset = offset;
const raw = bits.slice(offset, offset + field.len);
const bytes = raw.match(/.{1,8}/g) || [];
wordBits = bytes.map(b => b.split('').reverse().join('')).join('');
wordPos = 0;
wordEnd = field.len;
continue;
}
const inWord = wordBits && wordPos + field.len <= wordEnd;
// choose slice
const slice = inWord
? wordBits.slice(wordPos, wordPos + field.len)
: bits.slice(offset, offset + field.len);
// validate
if (field.max !== undefined || field.min !== undefined) {
const numBits = inWord ? slice.split('').reverse().join('') : slice;
const subset = parseInt(numBits, 2);
const min = field.min ?? 0;
const max = field.max ?? subset; // if no max, don't fail
if (subset < min || subset > max) {
if (debug) console.log(`${requireFormat} fails due to ${field.name} being ${subset}, which is outside the bounds of ${min}-${max}. Offset ${offset}.`);
return false;
}
}
// advance (MATCH decodeMii)
if (inWord) {
wordPos += field.len;
if (wordPos >= wordEnd) {
wordBits = null;
offset = wordStartOffset + wordEnd;
}
} else {
offset += field.len;
}
}
}
// Passed all checks
return true;
}
/**
* Detects the Mii format(s) present in the given buffer.
* @param {Buffer} buf - The potential mii buffer
* @param {boolean} debug - Optional flag to enable debug logging.
* @returns {string[]} An array of matching format names, or null if the input is not a Buffer.
*/
function detectMiiFormat(buf, debug) {
buf = isBuffer(buf);
if (!buf) return [];
let matches = [];
Object.keys(formats).forEach(form => {
if (isMiiInFormat(buf, form, debug)) matches.push(form);
});
if (matches.length == 0) {
//We only do this if no other format matches since Miis have no headers/magic bytes and I imagine some format could have the potential to collide with a PNG/JPG's
if (isPng(buf)) matches.push("png");
if (isJpg(buf)) matches.push("jpg");
if (isWebp(buf)) matches.push("webp");
}
return matches;
}
/**
* Decodes Mii data from various input formats (Buffer, hex string, file path, or URL) into a structured object.
* If the input is already a decoded object (checked by presence of 'general.favoriteColor'), it returns as-is.
* Supports decoding from different Mii formats, including QR codes in images, and applies post-processing or translations if defined.
*
* @param {SupportedBuffers|string} toDecode - The data to decode. Accepts a Buffer, a hex string, a file path string, or a URL string.
* @param {boolean} [debug] - Optional flag to enable debug logging during the decoding process.
* @returns {MiiData} The decoded Mii object with structured data.
* @throws {Error} If the input type is invalid, QR code decoding fails, or no decodable format is found.
*/
function decodeMii(toDecode, debug) {
// If data is already decoded, return - TODO: verifying the format here would be better than a single field check
if (toDecode?.general?.hasOwnProperty("favoriteColor")) return toDecode;
//Ensure Buffer, convert from hex string, file path string, URL path string
toDecode = ensureBuffer(toDecode);
if (!toDecode) throw new Error(`toDecode is an invalid type (Accepts: Buffer, Hex String, File Path String, URL String).`);
//Choose the file type to decode it from
var miiType = detectMiiFormat(toDecode);
if (miiType.includes("png") || miiType.includes("jpg") || miiType.includes("webp")) {
toDecode = scanQR(toDecode);
if (isPromiseLike(toDecode)) {
return toDecode.then(scanned => {
if (scanned === null) throw new Error(`Detected an image QR format (PNG/JPG/WEBP), but couldn't decode the QR code!`);
return decodeMii(scanned, debug);
});
}
if (toDecode === null) throw new Error(`Detected an image QR format (PNG/JPG/WEBP), but couldn't decode the QR code!`);
miiType = detectMiiFormat(toDecode);
}
miiType = miiType.filter(a => formats[a].hasOwnProperty("struct"));
if (miiType.length == 0) {
//Before we throw, check if it's an encrypted format we can decrypt
let workableFormats = detectMiiFormat(toDecode, debug);
if (workableFormats.filter(a => formats[a].hasOwnProperty("decoder")).length > 0) {
toDecode = formats[workableFormats[0]].decoder(toDecode);
if (isPromiseLike(toDecode)) {
return toDecode.then(decoded => {
if (debug) console.log(`Attempted to decode as ${workableFormats[0]}`);
if (typeof decoded == 'object' && !isBuffer(decoded)) {
if (debug) console.log(`Returning a pre-decoded object`);
return decoded;
}
if (debug) console.log(`Now decoding async decoder output`);
return decodeMii(decoded, debug);
});
}
if (debug) console.log(`Attempted to decode as ${workableFormats[0]}`);
//If it decoded correctly to JSON already, our job here is done.
if (typeof toDecode == 'object' && !isBuffer(toDecode)) {
if (debug) console.log(`Returning a pre-decoded object`);
return toDecode;
}
else {
miiType = detectMiiFormat(toDecode).filter(a => formats[a].hasOwnProperty("struct"));
if (debug) console.log(`Now decoding as ${miiType}`);
}
}
if (miiType.length == 0) {
if (debug) console.error(toDecode);
throw new Error(`Could not find any decodeable formats${(typeof debug !== "boolean" && debug !== undefined) ? ` for ${debug}` : ``}: ${detectMiiFormat(toDecode, debug)}`);
}
}
miiType = miiType[0];
//Start building based on the struct definition
var obj = {};
var offset = 0;
let val;
let bits = bufferToBitString(toDecode);
let wordBits = null;
let wordPos = 0;
let wordEnd = 0;
let wordStartOffset = 0;
for (const field of formats[miiType].struct) {
// Start a word window (does NOT consume offset)
if (field.word) {
wordStartOffset = offset;
const raw = bits.slice(offset, offset + field.len);
const bytes = raw.match(/.{1,8}/g) || [];
wordBits = bytes.map(b => b.split('').reverse().join('')).join('');
wordPos = 0;
wordEnd = field.len;
continue;
}
const wasInWord = wordBits && wordPos + field.len <= wordEnd;
if (mappings[field.name] === 'SKIP') {
if (wasInWord) {
wordPos += field.len;
if (wordPos >= wordEnd) {
wordBits = null;
offset = wordStartOffset + wordEnd;
}
}
else {
offset += field.len;
}
continue;
}
let subset;
if (wasInWord) {
subset = wordBits.slice(wordPos, wordPos + field.len);
}
else {
subset = bits.slice(offset, offset + field.len);
}
if (field.text) {
// Support both boolean (defaults to "be") and explicit endianness
const endianness = typeof field.text === 'string' ? field.text.toLowerCase() : 'be';
val = (endianness === 'le') ? binaryToTextLE(subset) : binaryToText(subset);
}
else if (field.hex) {
val = subset.match(/.{1,4}/g)
.map(chunk => parseInt(chunk, 2).toString(16).toUpperCase())
.join('');
}
else {
// Numeric fields from word are LSB->MSB, reverse them
const numBits = wasInWord ? subset.split('').reverse().join('') : subset;
val = parseInt(numBits, 2);
if (field.bool) val = !!val;
}
if (field.hasOwnProperty("decoder")) {
val = field.decoder(val);
}
if (mappings[field.name] === undefined && debug) {
console.log(`Skipping ${field.name} for being undefined.`);
}
else if (mappings[field.name] !== undefined) {
obj = setNestedValue(obj, mappings[field.name], val);
}
// Advance positions
if (wasInWord) {
wordPos += field.len;
if (wordPos >= wordEnd) {
wordBits = null;
offset = wordStartOffset + wordEnd;
}
} else {
offset += field.len;
}
}
if (formats[miiType].hasOwnProperty("postProcess")) {
obj = formats[miiType].postProcess(obj);
}
if (formats[miiType].hasOwnProperty("translation")) {
obj = forwardPort(obj, formats[miiType].translation);
}
return obj;
}
function encodeMii(miiObject, targetFormat, debug) {
if (!formats[targetFormat]) {
throw new Error(`Unknown format: ${targetFormat}`);
}
if (!formats[targetFormat].hasOwnProperty("struct")) {
if (!formats[targetFormat].hasOwnProperty("encoder")) {
throw new Error(`Format ${targetFormat} does not have a struct definition for encoding`);
}
else {
if (formats[targetFormat].hasOwnProperty("preEncode")) {
miiObject = encodeMii(miiObject, formats[targetFormat].preEncode);
if (isPromiseLike(miiObject)) {
return miiObject.then(encoded => formats[targetFormat].encoder(encoded));
}
}
return formats[targetFormat].encoder(miiObject);
}
}
// Apply pre-processing
let obj = structuredClone(miiObject);
if (formats[targetFormat].hasOwnProperty("translation")) {
obj = backPort(obj, formats[targetFormat].translation);
}
if (formats[targetFormat].hasOwnProperty("preProcess")) {
obj = formats[targetFormat].preProcess(obj);
}
// Helper function to get nested value from object
// Build bit string
let bits = '';
let wordBits = '';
let wordLen = 0;
let inWord = false;
for (const field of formats[targetFormat].struct) {
// Start a word window
if (field.word) {
inWord = true;
wordBits = '';
wordLen = field.len;
continue;
}
let val = getNestedValue(obj, mappings[field.name]);
// Handle SKIP fields
if (mappings[field.name] === 'SKIP') {
if (field.hasOwnProperty("encoder")) {
val = field.encoder(val);
}
else {
const skipBits = '0'.repeat(field.len);
if (inWord) {
wordBits += skipBits;
} else {
bits += skipBits;
}
if (inWord && wordBits.length >= wordLen) {
// Reverse bytes and append to main bits
const byteReversed = wordBits.match(/.{1,8}/g)
.map(b => b.split('').reverse().join(''))
.join('');
bits += byteReversed;
inWord = false;
wordBits = '';
}
continue;
}
}
// Handle undefined values
if (val === undefined || val === null) {
val = defaultMappings.hasOwnProperty(field.name) ? defaultMappings[field.name] : (f => {
let v = Math.min(f.max, Math.max(0, f.min));
if (f.text) {
v = "";
}
else if (f.hex) {
v = "0".repeat(f.len / 4);
}
else if (f.bool) {
v = false;
}
return v;
})(field);
//Some fields have different defaults for different genders, if a gender is set, use that one's default. Otherwise use the male default (games like Tomodachi Life set this precedent).
if (typeof val == 'object') val = val[+getNestedValue(obj, 'general.gender') ? +getNestedValue(obj, 'general.gender') : 0];
}
// Apply specific encoder if present
if (field.hasOwnProperty("encoder")) {
val = field.encoder(val);
}
let fieldBits;
if (field.text) {
// Text encoding
if (val.length === 0) val = "";
const endianness = typeof field.text === 'string' ? field.text.toLowerCase() : 'be';
fieldBits = (endianness === 'le')
? textToBinaryLE(val, field.len)
: textToBinaryBE(val, field.len);
}
else if (field.hex) {
// Expect val to be a Uint8Array / Buffer OR hex string
let bytes;
const hexStr = val.replace(/[^0-9A-Fa-f]/g, "");
bytes = Buffer.from(hexStr, "hex");
const totalBits = field.len;
const totalBytes = Math.ceil(totalBits / 8);
// Pad or truncate to required byte length
const fixed = Buffer.alloc(totalBytes);
bytes.copy(fixed, 0, 0, Math.min(bytes.length, totalBytes));
// Convert to bit string, then clamp to exact bit length
fieldBits = "";
for (let i = 0; i < fixed.length; i++) {
fieldBits += fixed[i].toString(2).padStart(8, "0");
}
fieldBits = fieldBits.slice(0, totalBits);
}
else {
// Numeric/boolean encoding
let numVal = field.bool ? (val ? 1 : 0) : Number(val);
if (isNaN(numVal)) {
if (debug) console.warn(`Non-numeric value for ${field.name}: ${val}, using 0`);
numVal = 0;
}
// Convert to binary
fieldBits = numVal.toString(2).padStart(field.len, '0');
// For word fields, reverse the bits (LSB->MSB)
if (inWord) {
fieldBits = fieldBits.split('').reverse().join('');
}
// Ensure correct length
if (fieldBits.length > field.len) {
fieldBits = fieldBits.slice(-field.len); // Truncate from left
if (debug) console.warn(`Value truncated for ${field.name}: ${val}`);
}
}
// Append to appropriate bit string
if (inWord) {
wordBits += fieldBits;
// Check if word is complete
if (wordBits.length >= wordLen) {
// Reverse each byte and append to main bits
const bytes = wordBits.slice(0, wordLen).match(/.{1,8}/g) || [];
const byteReversed = bytes.map(b => b.split('').reverse().join('')).join('');
bits += byteReversed;
inWord = false;
wordBits = '';
}
} else {
bits += fieldBits;
}
}
// Convert bit string to buffer
const bytes = [];
for (let i = 0; i < bits.length; i += 8) {
const byte = bits.slice(i, i + 8);
bytes.push(parseInt(byte.padEnd(8, '0'), 2));
}
const buffer = Buffer.from(bytes);
// Apply encryption if format has encoder
if (formats[targetFormat].hasOwnProperty("encoder")) {
return formats[targetFormat].encoder(buffer);
}
return buffer;
}
export {
isMiiInFormat,
detectMiiFormat,
decodeMii,
encodeMii
};