Created
June 28, 2025 20:16
-
-
Save ariankordi/66efb65ebf58da9f8151f21dcf3299dc to your computer and use it in GitHub Desktop.
Sample to parse FFL (Mii) resource files in JS using struct-fu. Parses entire header, including shape and texture headers. Originally made on Feb. 12.
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
<body> | |
<h1>upload an FFL resource, then open your console</h1> | |
<p> | |
if you need it, get it from archive.org: <a target="_blank" href="https://web.archive.org/web/20180502054513/http://download-cdn.miitomo.com/native/20180125111639/android/v2/asset_model_character_mii_AFLResHigh_2_3_dat.zip">https://web.archive.org/web/20180502054513/http://download-cdn.miitomo.com/native/20180125111639/android/v2/asset_model_character_mii_AFLResHigh_2_3_dat.zip</a> | |
<div style="font-size: 11px;"> | |
before you try, if you paste that below it won't work since archive.org has no CORS policy :( this proxy from kaeru would work if it were https: <a target="_blank" href="http://ia-proxy.fs3d.net:8989/20180502054513id_/http://download-cdn.miitomo.com/native/20180125111639/android/v2/asset_model_character_mii_AFLResHigh_2_3_dat.zip">http://ia-proxy.fs3d.net:8989/20180502054513id_/http://download-cdn.miitomo.com/native/20180125111639/android/v2/asset_model_character_mii_AFLResHigh_2_3_dat.zip</a> | |
</div> | |
</p> | |
<!-- Inline HTML user interface elements. --> | |
<input type="file" id="fileInput" accept=".zip,.dat"> | |
<form id="urlForm"> | |
<input type="text" id="urlInput" placeholder="Or import from URL."> | |
<br> <button id="urlButton">Fetch File/Zip</button> | |
</form> | |
<!-- -- | |
<script type="importmap"> | |
{ | |
"imports": { | |
"fflate": "https://cdn.jsdelivr.net/npm/[email protected]/+esm" | |
} | |
} | |
</script> | |
--> | |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/umd/index.min.js"></script> | |
<!-- struct-fu library ported to browser --> | |
<!-- original source: https://github.com/natevw/struct-fu/blob/master/lib.js --> | |
<!-- fork: https://github.com/ariankordi/struct-fu/blob/14fc71a75f06a97c0e462baef8f7605b0071323a/lib.js --> | |
<script> | |
/*! | |
* The following is a version of the struct-fu library | |
* fork by ariankordi: https://github.com/ariankordi/struct-fu | |
* with the following changes made for code size: | |
* Added: _.uintptr, mapping to _.uint32le (with WASM in mind) | |
* Removed: Polyfills Function.prototype.bind, TextEncoder/TextDecoder | |
* Removed: bitfield, bitfieldLE... | |
* Removed: char16be, swapBytesPairs | |
* Removed: 64-bit types, 16-bit float | |
* Modified: addField (no bit handling), _.struct (rm "aligned bitfield" message) | |
*/ | |
/** | |
* A library for defining structs to convert between JSON and binary. | |
* Supports numbers, bytes, and strings. | |
* Required: Support for TypedArray, Function.prototype.bind, TextEncoder/TextDecoder | |
* | |
* @namespace | |
*/ | |
var _ = {}; | |
/** | |
* Creates a new buffer backed by an ArrayBuffer. | |
* | |
* @param {number} size - The size of the buffer in bytes. | |
* @returns {Uint8Array} A new Uint8Array of the specified size. | |
*/ | |
function newBuffer(size) { | |
return new Uint8Array(new ArrayBuffer(size)); | |
} | |
/** | |
* Extends an object with properties from subsequent objects. | |
* | |
* @param {Object} obj - The target object to extend. | |
* @returns {Object} The extended object. | |
*/ | |
function extend(obj) { | |
var args = Array.prototype.slice.call(arguments, 1); | |
args.forEach(function (ext) { | |
Object.keys(ext).forEach(function (key) { | |
obj[key] = ext[key]; | |
}); | |
}); | |
return obj; | |
} | |
/** | |
* Adds a bitfield's size to the current cursor. | |
* | |
* @param {Object} ctr - The current cursor with bytes. | |
* @param {Object} f - The field to add. | |
* @returns {Object} The updated cursor. | |
*/ | |
function addField(ctr, f) { | |
ctr.bytes += f.size; | |
return ctr; | |
} | |
/** | |
* Converts a field into an array field if a count is provided. | |
* | |
* @param {Object} f - The field to arrayize. | |
* @param {number} count - The number of elements in the array. | |
* @returns {Object} The arrayized field. | |
*/ | |
function arrayizeField(f, count) { | |
var f2 = (typeof count === 'number') ? extend({ | |
name: f.name, | |
field: f, | |
/** | |
* Unpacks an array of values from bytes. | |
* | |
* @param {ArrayBuffer|Uint8Array} buf - The buffer to read from. | |
* @param {Object} [off] - The offset object with bytes and bits. | |
* @returns {Array} The unpacked array of values. | |
*/ | |
valueFromBytes: function (buf, off) { | |
off || (off = {bytes:0, bits:0}); | |
var arr = new Array(count); | |
for (var idx = 0, len = arr.length; idx < len; idx += 1) { | |
arr[idx] = f.valueFromBytes(buf, off); | |
} | |
return arr; | |
}, | |
/** | |
* Packs an array of values into bytes. | |
* | |
* @param {Array} arr - The array of values to pack. | |
* @param {ArrayBuffer|Uint8Array} [buf] - The buffer to write to. | |
* @param {Object} [off] - The offset object with bytes and bits. | |
* @returns {ArrayBuffer|Uint8Array} The buffer with packed data. | |
*/ | |
bytesFromValue: function (arr, buf, off) { | |
arr || (arr = new Array(count)); | |
buf || (buf = newBuffer(this.size)); | |
off || (off = {bytes:0, bits:0}); | |
for (var idx = 0, len = Math.min(arr.length, count); idx < len; idx += 1) { | |
f.bytesFromValue(arr[idx], buf, off); | |
} | |
while (idx++ < count) addField(off, f); | |
return buf; | |
} | |
}, ('width' in f) ? {width: f.width * count} : {size: f.size * count}) : f; | |
f2.pack = f2.bytesFromValue; | |
f2.unpack = f2.valueFromBytes; | |
return f2; | |
} | |
/** | |
* Defines a new structure with the given fields. | |
* | |
* @param {string} [name] - The name of the structure. | |
* @param {Array} fields - The array of field definitions. | |
* @param {number} [count] - The number of structures in an array. | |
* @returns {Object} The defined structure with pack and unpack methods. | |
*/ | |
_.struct = function (name, fields, count) { | |
if (typeof name !== 'string') { | |
count = fields; | |
fields = name; | |
name = null; | |
} | |
var _size = {bytes:0, bits:0}, | |
_padsById = Object.create(null), | |
fieldsObj = fields.reduce(function (obj, f) { | |
if ('_padTo' in f) { | |
// HACK: we really should just make local copy of *all* fields | |
f._id || (f._id = 'id' + Math.random().toFixed(20).slice(2)); // WORKAROUND: https://github.com/tessel/runtime/issues/716 | |
var _f = _padsById[f._id] = (_size.bits) ? { | |
width: 8*(f._padTo - _size.bytes) - _size.bits | |
} : { | |
size: f._padTo - _size.bytes | |
}; | |
if ((_f.width !== undefined && _f.width < 0) || (_f.size !== undefined && _f.size < 0)) { | |
var xtraMsg = (_size.bits) ? (" and " + _size.bits + " bits") : ''; | |
throw Error("Invalid .padTo(" + f._padTo + ") field, struct is already " + _size.bytes + " byte(s)" + xtraMsg + "!"); | |
} | |
f = _f; | |
} | |
else if (f._hoistFields) { | |
Object.keys(f._hoistFields).forEach(function (name) { | |
var _f = Object.create(f._hoistFields[name]); | |
if ('width' in _f) { | |
_f.offset = { bytes: _f.offset.bytes + _size.bytes, bits: _f.offset.bits }; | |
} else { | |
_f.offset += _size.bytes; | |
} | |
obj[name] = _f; | |
}); | |
} | |
else if (f.name) { | |
f = Object.create(f); // local overrides | |
f.offset = ('width' in f) ? {bytes:_size.bytes,bits:_size.bits} : _size.bytes, | |
obj[f.name] = f; | |
} | |
addField(_size, f); | |
return obj; | |
}, {}); | |
if (_size.bits) throw Error("Improperly aligned bitfield at end of struct: "+name); | |
return arrayizeField({ | |
/** | |
* Unpacks a structure from bytes. | |
* | |
* @param {ArrayBuffer|Uint8Array} buf - The buffer to read from. | |
* @param {Object} [off] - The offset object with bytes and bits. | |
* @returns {Object} The unpacked structure. | |
*/ | |
valueFromBytes: function (buf, off) { | |
off || (off = {bytes:0, bits:0}); | |
var obj = {}; | |
fields.forEach(function (f) { | |
if ('_padTo' in f) return addField(off, _padsById[f._id]); | |
var value = f.valueFromBytes(buf, off); | |
if (f.name) obj[f.name] = value; | |
else if (typeof value === 'object') extend(obj, value); | |
}); | |
return obj; | |
}, | |
/** | |
* Packs a structure into bytes. | |
* | |
* @param {Object} obj - The object containing values to pack. | |
* @param {ArrayBuffer|Uint8Array} [buf] - The buffer to write to. | |
* @param {Object} [off] - The offset object with bytes and bits. | |
* @returns {ArrayBuffer|Uint8Array} The buffer with packed data. | |
*/ | |
bytesFromValue: function (obj, buf, off) { | |
obj || (obj = {}); | |
buf || (buf = newBuffer(this.size)); | |
off || (off = {bytes:0, bits:0}); | |
fields.forEach(function (f) { | |
if ('_padTo' in f) return addField(off, _padsById[f._id]); | |
var value = (f.name) ? obj[f.name] : obj; | |
f.bytesFromValue(value, buf, off); | |
}); | |
return buf; | |
}, | |
_hoistFields: (!name) ? fieldsObj : null, | |
fields: fieldsObj, | |
size: _size.bytes, | |
name: name | |
}, count); | |
}; | |
/** | |
* Defines a padding field up to the specified offset. | |
* | |
* @param {number} off - The byte offset to pad to. | |
* @returns {Object} The padding field definition. | |
*/ | |
_.padTo = function (off) { | |
return {_padTo:off}; | |
}; | |
/** | |
* Defines a byte-based field within a structure. | |
* | |
* @param {string|number} name - The name of the bytefield or its size if name is omitted. | |
* @param {number} [size=1] - The size of the bytefield in bytes. | |
* @param {number} [count] - The number of bytefields in an array. | |
* @returns {Object} The defined bytefield. | |
*/ | |
function bytefield(name, size, count) { | |
if (typeof name !== 'string') { | |
count = size; | |
size = name; | |
name = null; | |
} | |
size = (typeof size === 'number') ? size : 1; | |
var impl = this; | |
return arrayizeField({ | |
/** | |
* Unpacks a bytefield from bytes. | |
* | |
* @param {ArrayBuffer|Uint8Array} buf - The buffer to read from. | |
* @param {Object} [off] - The offset object with bytes and bits. | |
* @returns {Uint8Array} The unpacked bytefield. | |
*/ | |
valueFromBytes: function (buf, off) { | |
off || (off = {bytes:0, bits:0}); | |
var bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf; | |
var val = bytes.subarray(off.bytes, off.bytes + this.size); | |
addField(off, this); | |
return impl.b2v.call(this, val); | |
//return impl.b2v.call(this, val.buffer.slice(val.byteOffset, val.byteOffset + val.byteLength)); // Returns ArrayBuffer usually | |
}, | |
/** | |
* Packs a bytefield into bytes. | |
* | |
* @param {ArrayBuffer|Uint8Array} val - The value to pack. | |
* @param {ArrayBuffer|Uint8Array} [buf] - The buffer to write to. | |
* @param {Object} [off] - The offset object with bytes and bits. | |
* @returns {ArrayBuffer|Uint8Array} The buffer with packed data. | |
*/ | |
bytesFromValue: function (val, buf, off) { | |
buf || (buf = newBuffer(this.size)); | |
off || (off = { bytes: 0, bits: 0 }); | |
var bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf; | |
var blk = bytes.subarray(off.bytes, off.bytes + this.size); | |
impl.vTb.call(this, val, blk); | |
addField(off, this); | |
return buf; | |
}, | |
size: size, | |
name: name | |
}, count); | |
} | |
_.byte = bytefield.bind({ | |
/** | |
* Converts bytes to a value. | |
* | |
* @param {ArrayBuffer|Uint8Array} b - The bytes to convert. | |
* @returns {ArrayBuffer|Uint8Array} The byte value. | |
*/ | |
b2v: function (b) { return b; }, | |
/** | |
* Converts a value to bytes. | |
* | |
* @param {ArrayBuffer|Uint8Array} v - The value to convert. | |
* @param {Uint8Array} b - The buffer to write to. | |
* @returns {number} The number of bytes written. | |
*/ | |
vTb: function (v, b) { if (!v) return 0; b.set(new Uint8Array(v)); return v.byteLength; } | |
}); | |
_.char = bytefield.bind({ | |
/** | |
* Converts bytes to a UTF-8 string. | |
* | |
* @param {ArrayBuffer|Uint8Array} b - The bytes to convert. | |
* @returns {string} The resulting string. | |
*/ | |
b2v: function (b) { | |
var decoder = new TextDecoder('utf-8'); | |
var v = decoder.decode(b); | |
var z = v.indexOf('\0'); | |
return (~z) ? v.slice(0, z) : v; | |
}, | |
/** | |
* Converts a string to UTF-8 bytes. | |
* | |
* @param {string} v - The string to convert. | |
* @param {Uint8Array} b - The buffer to write to. | |
* @returns {number} The number of bytes written. | |
*/ | |
vTb: function (v,b) { | |
v || (v = ''); | |
var encoder = new TextEncoder('utf-8'); | |
var encoded = encoder.encode(v); | |
for (var i = 0; i < encoded.length && i < b.length; i++) { | |
b[i] = encoded[i]; | |
} | |
return encoded.length; | |
} | |
}); | |
_.char16le = bytefield.bind({ | |
/** | |
* Converts bytes to a UTF-16LE string. | |
* | |
* @param {ArrayBuffer|Uint8Array} b - The bytes to convert. | |
* @returns {string} The resulting string. | |
*/ | |
b2v: function (b) { | |
var decoder = new TextDecoder('utf-16le'); | |
var v = decoder.decode(b); | |
var z = v.indexOf('\0'); | |
return (~z) ? v.slice(0, z) : v; | |
}, | |
/** | |
* Converts a string to UTF-16LE bytes. | |
* | |
* @param {string} v - The string to convert. | |
* @param {Uint8Array} b - The buffer to write to. | |
* @returns {number} The number of bytes written. | |
*/ | |
vTb: function (v,b) { | |
v || (v = ''); | |
var bytesWritten = 0; | |
for (var i = 0; i < v.length && bytesWritten + 1 < b.length; i++) { | |
var charCode = v.charCodeAt(i); | |
b[bytesWritten++] = charCode & 0xFF; | |
b[bytesWritten++] = (charCode >> 8) & 0xFF; | |
} | |
return bytesWritten; | |
} | |
}); | |
/** | |
* Defines a standard field with specific read and write methods. | |
* | |
* @param {string} sig - The signature indicating the type. This is assumed to be available in the DataView API as DataView.(get/set)(sig), e.g. "Uint32" is valid because DataView.getUint32() is a valid method | |
* @param {number} size - The size of the field in bytes. | |
* @param {boolean} littleEndian - Indicates whether or not the field is little endian. | |
* @returns {Function} A function to create the standard field. | |
*/ | |
function standardField(sig, size, littleEndian) { | |
var read = 'get' + sig, | |
dump = 'set' + sig; | |
size || (size = +sig.match(/\d+/)[0] / 8); | |
return function (name, count) { | |
if (typeof name !== 'string') { | |
count = name; | |
name = null; | |
} | |
return arrayizeField({ | |
/** | |
* Unpacks a standard field from bytes. | |
* | |
* @param {ArrayBuffer|Uint8Array} buf - The buffer to read from. | |
* @param {Object} [off] - The offset object with bytes and bits. | |
* @returns {*} The unpacked value. | |
*/ | |
valueFromBytes: function (buf, off) { | |
off || (off = {bytes:0}); | |
var bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf; | |
var view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); | |
var val = view[read](off.bytes, littleEndian); | |
addField(off, this); | |
return val; | |
}, | |
/** | |
* Packs a standard field into bytes. | |
* | |
* @param {*} val - The value to pack. | |
* @param {ArrayBuffer|Uint8Array} [buf] - The buffer to write to. | |
* @param {Object} [off] - The offset object with bytes and bits. | |
* @returns {ArrayBuffer|Uint8Array} The buffer with packed data. | |
*/ | |
bytesFromValue: function (val, buf, off) { | |
val || (val = 0); | |
buf || (buf = newBuffer(this.size)); | |
off || (off = {bytes:0}); | |
var bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf; | |
var view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); | |
view[dump](off.bytes, val, littleEndian); | |
addField(off, this); | |
return buf; | |
}, | |
size: size, | |
name: name | |
}, count); | |
}; | |
} | |
_.uint8 = standardField('Uint8', 1, false); | |
_.uint16 = standardField('Uint16', 2, false); | |
_.uint32 = standardField('Uint32', 4, false); | |
_.uint16le = standardField('Uint16', 2, true); | |
_.uint32le = standardField('Uint32', 4, true); | |
_.uintptr = _.uint32le; // assuming WASM/32 bit pointers only | |
_.int8 = standardField('Int8', 1, false); | |
_.int16 = standardField('Int16', 2, false); | |
_.int32 = standardField('Int32', 4, false); | |
_.int16le = standardField('Int16', 2, true); | |
_.int32le = standardField('Int32', 4, true); | |
_.float32 = standardField('Float32', 4, false); | |
_.float32le = standardField('Float32', 4, true); | |
/** | |
* Derives a new field based on an existing one with custom pack and unpack functions. | |
* | |
* @param {Object} orig - The original field to derive from. | |
* @param {Function} pack - The function to pack the derived value. | |
* @param {Function} unpack - The function to unpack the derived value. | |
* @returns {Function} A function to create the derived field. | |
*/ | |
_.derive = function (orig, pack, unpack) { | |
return function (name, count) { | |
if (typeof name !== 'string') { | |
count = name; | |
name = null; | |
} | |
return arrayizeField(extend({ | |
/** | |
* Unpacks a derived field from bytes. | |
* | |
* @param {ArrayBuffer|Uint8Array} buf - The buffer to read from. | |
* @param {Object} [off] - The offset object with bytes and bits. | |
* @returns {*} The unpacked derived value. | |
*/ | |
valueFromBytes: function (buf, off) { | |
return unpack(orig.valueFromBytes(buf, off)); | |
}, | |
/** | |
* Packs a derived field into bytes. | |
* | |
* @param {*} val - The value to pack. | |
* @param {ArrayBuffer|Uint8Array} [buf] - The buffer to write to. | |
* @param {Object} [off] - The offset object with bytes and bits. | |
* @returns {ArrayBuffer|Uint8Array} The buffer with packed data. | |
*/ | |
bytesFromValue: function (val, buf, off) { | |
return orig.bytesFromValue(pack(val), buf, off); | |
}, | |
name: name | |
}, ('width' in orig) ? {width:orig.width} : {size:orig.size}), count); | |
}; | |
}; | |
// Export for Node.js environments | |
if (typeof module !== 'undefined' && module.exports) { | |
module.exports = _; | |
} else { | |
// Export to global scope for browsers | |
window._ = _; | |
} | |
</script> | |
<!-- Import primary script. --> | |
<script src="ffl-res-parse-toy.js"></script> | |
</body> |
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 | |
// import * as _ from './struct-fu.js'; | |
/* eslint @stylistic/indent: ['error', 2] -- Define indent rules. */ | |
/* eslint @stylistic/spaced-comment: ['error', 'always', { | |
line: { markers: ['/<', '!<'] }, | |
block: { markers: ['*'], balanced: true } | |
}], | |
@stylistic/no-multi-spaces: 'off' | |
-- Allow Doxygen-style inline brief comments. | |
*/ | |
// import { unzipSync } from 'fflate'; | |
// const fflate = { unzipSync }; | |
// import * as _Import from './struct-fu.js'; | |
/* globals _ fflate -- Global dependencies. */ | |
/** | |
* @typedef {import('./struct-fu')} _ | |
* @typedef {import('fflate')} fflate | |
*/ | |
/* eslint-disable no-self-assign -- Get TypeScript to identify global imports. */ | |
globalThis._ = /** @type {_} */ (/** @type {*} */ (globalThis)._); | |
globalThis.fflate = /** @type {fflate} */ (/** @type {*} */ (globalThis).fflate); | |
// NOTeslint-disable-next-line @stylistic/max-statements-per-line -- Hack to use either UMD or browser ESM import. | |
// let _ = globalThis._; _ = (!_) ? _Import : _; | |
// let fflate = globalThis.fflate; fflate = (!fflate) ? fflateImport : fflate; | |
/* eslint-enable no-self-assign -- Get TypeScript to identify global imports. */ | |
// Constants for resource header magic. | |
const FFLI_RESOURCE_HEADER_MAGIC_BE = 'FFRA'; | |
const FFLI_RESOURCE_HEADER_MAGIC_LE = 'ARFF'; | |
const FFLI_RESOURCE_HEADER_MAGIC_U32 = 0x46465241; | |
// This sample tries? to detect endianness from the magic. | |
const FFLI_RESOURCE_HEADER_EXPAND_BUFFER_SIZE_OFFSET = 0x0c; | |
const FFLI_RESOURCE_HEADER_EXPAND_BUFFER_SIZE_AFL_2_3 = 0x2502de0; | |
const FFLI_RESOURCE_HEADER_RESOURCE_TYPE_HINT_AFL_2_3 = 3; | |
// Resource strategy values. | |
const FFLI_RESOURCE_STRATEGY_UNCOMPRESSED = 5; | |
/** NOTE: Original FFL max, does not support Brotli. */ | |
const FFLI_RESOURCE_STRATEGY_MAX = 6; | |
// // --------------------------------------------------------------------- | |
// // JSDoc Struct Definitions | |
// // --------------------------------------------------------------------- | |
/** | |
* Structure making up each texture and shape element. | |
* @typedef {Object} FFLiResourcePartsInfo | |
* @property {number} dataPos - Offset of the data in the resource file. | |
* @property {number} dataSize | |
* @property {number} compressedSize | |
* @property {number} compressLevel - enum FFLiResourceCompressLevel | |
* @property {number} windowBits - enum FFLiResourceWindowBits | |
* @property {number} memoryLevel - enum FFLiResourceMemoryLevel | |
* @property {number} strategy - enum FFLiResourceStrategy | |
* @todo ACCURACY?: see DWARF for nn::mii::detail::ResourceCommonAttribute | |
*/ | |
/** | |
* Texture header accounting for either AFLResHigh_2_3.dat or FFLResHigh.dat. | |
* @typedef {Object} FFLiResourceTextureHeader | |
* @property {Array<number>} partsMaxSize | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoBeard | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoCap | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoEye | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoEyebrow | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoFaceline | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoFaceMakeup | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoGlass | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoMole | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoMouth | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoMustache | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoNoseline | |
* @todo ACCURACY?: see DWARF for nn::mii::detail::ResourceTextureHeader | |
*/ | |
/** @enum {number} */ | |
const FFLTextureFormat = { | |
R8_UNORM: 0, | |
R8_G8_UNORM: 1, | |
R8_G8_B8_A8_UNORM: 2, | |
MAX: 3 | |
}; | |
/** | |
* @typedef {Object} FFLiResourceTextureFooter | |
* @property {number} m_MipOffset | |
* @property {number} m_Width | |
* @property {number} m_Height | |
* @property {number} m_NumMips | |
* @property {FFLTextureFormat} m_TextureFormat | |
*/ | |
/** | |
* Shape header, same across all FFL and AFL resources. | |
* Using Normal/Cap naming from my fork: https://github.com/ariankordi/ffl/commit/3c38d160c12e8384c4a752296c8422a9d98377c1 | |
* @typedef {Object} FFLiResourceShapeHeader | |
* @property {Array<number>} partsMaxSize | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoBeard | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoHatNormal | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoHatCap | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoFaceline | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoGlass | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoMask | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoNoseline | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoNose | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoHairNormal | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoHairCap | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoForeheadNormal | |
* @property {Array<FFLiResourcePartsInfo>} partsInfoForeheadCap | |
*/ | |
/** @enum {number} */ | |
const FFLiResourceShapeElementType = { | |
/** | |
* Vertex position, stored as three 32-bit floating point values. | |
* Padded with four zero bytes (stride = 16). | |
*/ | |
POSITION: 0, | |
/** | |
* Normal vector, encoded as 10-bit signed normalized integers | |
* with a 2-bit alpha component (10_10_10_2_SNORM, stride = 4). | |
*/ | |
NORMAL: 1, | |
/** Texture coordinates (UV mapping), stored as two 32-bit floating point values (stride = 8). */ | |
TEXCOORD: 2, | |
/** Tangent vector, stored as four signed 8-bit normalized values (stride = 4). */ | |
TANGENT: 3, | |
/** Vertex color, stored as four unsigned 8-bit normalized values (RGBA, stride = 4). */ | |
COLOR: 4, | |
/** Vertex index, stored as an unsigned 16-bit integer. */ | |
INDEX: 5, | |
/** | |
* Transform data for hair, parsed as {@link FFLiResourceShapeHairTransform}. | |
* Only used for setting FFLPartsTransform/for headwear. | |
*/ | |
TRANSFORM_HAIR_1: 6, | |
/** Transform data for faceline, parsed as {@link FFLiResourceShapeFacelineTransform}. */ | |
TRANSFORM_FACELINE: 7, | |
/** | |
* Bounding box, represented using the `FFLBoundingBox` structure, | |
* which consists of minimum and maximum 3D coordinates. | |
*/ | |
BOUNDING_BOX: 8, | |
BUFFER_MAX: 6 | |
}; | |
/** | |
* @typedef {Object} FFLVec3 | |
* @property {number} x | |
* @property {number} y | |
* @property {number} z | |
*/ | |
/** | |
* @typedef {{min: FFLVec3, max: FFLVec3}} FFLBoundingBox | |
* @property {FFLVec3} min | |
* @property {FFLVec3} max | |
*/ | |
/** | |
* @typedef {Object} FFLiResourceShapeFacelineTransform | |
* @property {FFLVec3} m_HairTranslate | |
* @property {FFLVec3} m_NoseTranslate | |
* @property {FFLVec3} m_BeardTranslate | |
*/ | |
/** | |
* @typedef {Object} FFLiResourceShapeHairTransform | |
* @property {FFLVec3} m_FrontTranslate | |
* @property {FFLVec3} m_FrontRotate | |
* @property {FFLVec3} m_SideTranslate | |
* @property {FFLVec3} m_SideRotate | |
* @property {FFLVec3} m_TopTranslate | |
* @property {FFLVec3} m_TopRotate | |
* @todo TODO: Include FFLPartsTransform as well? | |
* NOTE!!!: that the only purpose of this is to fill in | |
* FFLPartsTransform, making it not completely needed for rendering. | |
*/ | |
/** | |
* @typedef {Object} FFLiResourceShapeDataHeader | |
* @property {Array<number>} m_ElementPos - Size: {@link FFLiResourceShapeElementType.BUFFER_MAX} | |
* @property {Array<number>} m_ElementSize - Size: {@link FFLiResourceShapeElementType.BUFFER_MAX} | |
* @property {FFLBoundingBox} m_BoundingBox - {@link FFLiResourceShapeElementType.BOUNDING_BOX} | |
* @property {Array<number>} m_Transform - NOTE: Interpret this as | |
* either none, {@link FFLiResourceShapeFacelineTransform}, or {@link FFLiResourceShapeHairTransform}. | |
*/ | |
/** | |
* Top-level resource header. | |
* @typedef {Object} FFLiResourceHeader | |
* @property {number} m_Magic - "FFRA" fourcc. | |
* @property {number} m_Version | |
* @property {number} m_UncompressBufferSize | |
* @property {number} m_ExpandBufferSize | |
* @property {number} m_IsExpand | |
* @property {FFLiResourceTextureHeader} m_TextureHeader | |
* @property {FFLiResourceShapeHeader} m_ShapeHeader | |
* @property {Array<number>} _49d0 - Completely unused field. | |
* @todo ACCURACY?: no analog in nn::mii but there's DWARF for ResourceShapeHeader/ResourceTextureHeader | |
*/ | |
/* eslint-disable jsdoc/require-returns-type -- Guess the return type. */ | |
/** | |
* Initializes FFLiResource* structures with struct-fu, returning the structs as an object. | |
* @param {boolean} littleEndian - If true, parse the structures in little-endian, otherwise use big-endian. | |
* @param {boolean} isAFL_2_3TextureHeader - If true, parse the structs using the | |
* texture header specification for AFLResHigh_2_3.dat. | |
* @returns An object containing FFLiResource* structures configured with the endianness provided. | |
*/ | |
const createFFLiResourceStructs = (littleEndian, isAFL_2_3TextureHeader) => { | |
/* eslint-enable jsdoc/require-returns-type -- Guess the return type. */ | |
/** | |
* uint32 but endian-specific. Meant for this context only. | |
* @todo TODO: better name (uint32 -> uint32be then make uint32 general?) | |
* or TODO: into struct-fu in general? per-struct or global? | |
*/ | |
const uint32bi = littleEndian ? _.uint32le : _.uint32; | |
/** uint16 but endian-specific. */ | |
const uint16bi = littleEndian ? _.uint16le : _.uint16; | |
/** float32 but endian-specific. */ | |
const float32bi = littleEndian ? _.float32le : _.float32; | |
// The structs below are based off of the FFL decomp by AboodXD: https://github.com/aboood40091/ffl/blob/0fe8e687dac5963000e3214a2c54d9219c99d63f/include/nn/ffl/FFLiResourceHeader.h | |
/** @type {import('./struct-fu').StructInstance<FFLiResourcePartsInfo>} */ | |
const FFLiResourcePartsInfo = _.struct([ | |
uint32bi('dataPos'), | |
uint32bi('dataSize'), | |
uint32bi('compressedSize'), | |
_.uint8('compressLevel'), | |
_.uint8('windowBits'), | |
_.uint8('memoryLevel'), | |
_.uint8('strategy') | |
]); | |
/** @type {import('./struct-fu').StructInstance<FFLiResourceTextureHeader>} */ | |
const FFLiResourceTextureHeader = (() => { | |
/** Anonymous alias for isAFL_2_3TextureHeader. */ | |
const afl = isAFL_2_3TextureHeader; | |
return _.struct([ | |
uint32bi('partsMaxSize', 11), | |
_.struct('partsInfoBeard', [FFLiResourcePartsInfo], 3), | |
_.struct('partsInfoCap', [FFLiResourcePartsInfo], 132), | |
// Modify counts for eye, eyebrow, glass, mouth | |
// based on whether the resource is from AFLResHigh_2_3.dat. | |
_.struct('partsInfoEye', [FFLiResourcePartsInfo], afl ? 80 : 62), | |
_.struct('partsInfoEyebrow', [FFLiResourcePartsInfo], afl ? 28 : 24), | |
_.struct('partsInfoFaceline', [FFLiResourcePartsInfo], 12), | |
_.struct('partsInfoFaceMakeup', [FFLiResourcePartsInfo], 12), | |
_.struct('partsInfoGlass', [FFLiResourcePartsInfo], afl ? 20 : 9), | |
_.struct('partsInfoMole', [FFLiResourcePartsInfo], 2), | |
_.struct('partsInfoMouth', [FFLiResourcePartsInfo], afl ? 52 : 37), | |
_.struct('partsInfoMustache', [FFLiResourcePartsInfo], 6), | |
_.struct('partsInfoNoseline', [FFLiResourcePartsInfo], 18) | |
]); | |
})(); | |
// For each texture PartsInfo, the data is the texture data with the last | |
// 12 bytes (= FFLiResourceTextureFooter.size) corresponding to FFLiResourceTextureFooter: | |
// Gets read here: https://github.com/aboood40091/ffl/blob/73fe9fc70c0f96ebea373122e50f6d3acc443180/src/detail/FFLiResourceTexture.cpp#L24 | |
/** @type {import('./struct-fu').StructInstance<FFLiResourceTextureFooter>} */ | |
const FFLiResourceTextureFooter = _.struct([ | |
uint32bi('m_MipOffset'), | |
uint16bi('m_Width'), | |
uint16bi('m_Height'), | |
_.uint8('m_NumMips'), | |
_.uint8('m_TextureFormat'), | |
_.byte('_padding', 2) // Includes two bytes of padding. | |
]); | |
/** @type {import('./struct-fu').StructInstance<FFLiResourceShapeHeader>} */ | |
const FFLiResourceShapeHeader = _.struct([ | |
uint32bi('partsMaxSize', 12), | |
_.struct('partsInfoBeard', [FFLiResourcePartsInfo], 4), | |
_.struct('partsInfoHatNormal', [FFLiResourcePartsInfo], 132), | |
_.struct('partsInfoHatCap', [FFLiResourcePartsInfo], 132), | |
_.struct('partsInfoFaceline', [FFLiResourcePartsInfo], 12), | |
_.struct('partsInfoGlass', [FFLiResourcePartsInfo], 1), | |
_.struct('partsInfoMask', [FFLiResourcePartsInfo], 12), | |
_.struct('partsInfoNoseline', [FFLiResourcePartsInfo], 18), | |
_.struct('partsInfoNose', [FFLiResourcePartsInfo], 18), | |
_.struct('partsInfoHairNormal', [FFLiResourcePartsInfo], 132), | |
_.struct('partsInfoHairCap', [FFLiResourcePartsInfo], 132), | |
_.struct('partsInfoForeheadNormal', [FFLiResourcePartsInfo], 132), | |
_.struct('partsInfoForeheadCap', [FFLiResourcePartsInfo], 132) | |
]); | |
// For each shape PartsInfo, the data is FFLiResourceShapeDataHeader: | |
// Gets read here: https://github.com/aboood40091/ffl/blob/73fe9fc70c0f96ebea373122e50f6d3acc443180/src/detail/FFLiResourceShape.cpp#L19 | |
/** @type {import('./struct-fu').StructInstance<FFLVec3>} */ | |
const FFLVec3 = _.struct([ | |
float32bi('x'), | |
float32bi('y'), | |
float32bi('z') | |
]); | |
/** @type {import('./struct-fu').StructInstance<FFLiResourceShapeFacelineTransform>} */ | |
const FFLiResourceShapeFacelineTransform = _.struct([ | |
_.struct('m_HairTranslate', [FFLVec3]), | |
_.struct('m_NoseTranslate', [FFLVec3]), | |
_.struct('m_BeardTranslate', [FFLVec3]) | |
]); | |
/** @type {import('./struct-fu').StructInstance<FFLiResourceShapeHairTransform>} */ | |
const FFLiResourceShapeHairTransform = _.struct([ | |
_.struct('m_FrontTranslate', [FFLVec3]), | |
_.struct('m_FrontRotate', [FFLVec3]), | |
_.struct('m_SideTranslate', [FFLVec3]), | |
_.struct('m_SideRotate', [FFLVec3]), | |
_.struct('m_TopTranslate', [FFLVec3]), | |
_.struct('m_TopRotate', [FFLVec3]) | |
]); | |
/** @type {import('./struct-fu').StructInstance<FFLBoundingBox>} */ | |
const FFLBoundingBox = _.struct([ | |
_.struct('min', [FFLVec3]), | |
_.struct('max', [FFLVec3]) | |
]); | |
const FFLiResourceShapeDataHeader = _.struct([ | |
uint32bi('m_ElementPos', FFLiResourceShapeElementType.BUFFER_MAX), | |
uint32bi('m_ElementSize', FFLiResourceShapeElementType.BUFFER_MAX), | |
_.struct('m_BoundingBox', [FFLBoundingBox]), | |
float32bi('m_Transform', FFLiResourceShapeHairTransform.size / 4) | |
]); | |
// Define top-level resource header structure. | |
/** @type {import('./struct-fu').StructInstance<FFLiResourceHeader>} */ | |
const FFLiResourceHeader = _.struct([ | |
uint32bi('m_Magic'), | |
uint32bi('m_Version'), | |
uint32bi('m_UncompressBufferSize'), | |
uint32bi('m_ExpandBufferSize'), | |
uint32bi('m_IsExpand'), | |
_.struct('m_TextureHeader', [FFLiResourceTextureHeader]), | |
_.struct('m_ShapeHeader', [FFLiResourceShapeHeader]), | |
uint32bi('_49d0', 12) // Unknown. Unused in FFL for NSMBU. | |
]); | |
// Export struct definitions. | |
return { | |
FFLiResourceHeader, | |
// Footer/header for each part type. | |
FFLiResourceTextureFooter, | |
FFLiResourceShapeDataHeader, | |
FFLiResourceShapeFacelineTransform, | |
FFLiResourceShapeHairTransform | |
}; | |
}; | |
/** | |
* @param {ArrayBuffer} buffer - The input ArrayBuffer to read the first 4 bytes of. | |
* @returns {string} The first 4 characters of the input, or the magic/fourcc. | |
*/ | |
const getMagicFromArrayBuffer = buffer => String.fromCharCode( | |
// Read buffer as Uint8Array, get first 4 bytes as string. | |
...new Uint8Array(buffer).subarray(0, 4)); | |
/** | |
* Checks the magic of the resource to verify it | |
* and return whether it is little-endian. | |
* @param {ArrayBuffer} buffer - The ArrayBuffer for the resource. This will read the first 4 bytes. | |
* @returns {boolean} Returns the value for if the resource is little-endian. | |
* @throws {Error} Throws if magic does not match FFL resource. | |
*/ | |
function getValidAndIsLittleEndianFromMagic(buffer) { | |
// Peek at the magic to verify it and determine endianness. | |
const magic = getMagicFromArrayBuffer(buffer); | |
console.log(`🫣 peeked at magic: "${magic}"`); | |
// Determine endianness based on the value of magic. | |
if (magic === FFLI_RESOURCE_HEADER_MAGIC_BE) { | |
console.log('🔧 parsing in big-endian...'); | |
return false; | |
} else if (magic === FFLI_RESOURCE_HEADER_MAGIC_LE) { | |
console.log('🦴 parsing in little-endian...'); | |
return true; | |
} else { | |
throw new Error(`unknown magic ("${magic}"), not an FFL resource`); | |
} | |
} | |
/** | |
* Determines whether a resource is using the | |
* AFLResHigh_2_3.dat header from its buffer data. | |
* @param {ArrayBuffer} resBuffer - The resource header. | |
* @param {boolean} littleEndian - Endianness of the header. | |
* @returns {boolean} Whether the resource is AFLResHigh_2_3.dat. | |
*/ | |
function getIsAFL_2_3Header(resBuffer, littleEndian) { | |
const view = new DataView(resBuffer); | |
// Get m_ExpandBufferSize property, this is the only field that varies. | |
const expandBufferSize = view.getUint32( | |
FFLI_RESOURCE_HEADER_EXPAND_BUFFER_SIZE_OFFSET, littleEndian); | |
// Return if the size matches that of the AFLResHigh_2_3.dat file. | |
// NOTE: Not accounting for AFLResHigh.dat. | |
if (expandBufferSize === FFLI_RESOURCE_HEADER_EXPAND_BUFFER_SIZE_AFL_2_3) { | |
return true; | |
} | |
/** Get the first 3 bits. */ | |
const hint = expandBufferSize >> 29; | |
// Determine if it is AFLResHigh_2_3.dat from the hint here. | |
return (hint === FFLI_RESOURCE_HEADER_RESOURCE_TYPE_HINT_AFL_2_3); | |
} | |
// // --------------------------------------------------------------------- | |
// // Primary Entrypoint | |
// // --------------------------------------------------------------------- | |
/** | |
* Sample for parsing and reading an FFL resource from an ArrayBuffer. | |
* @param {ArrayBuffer} resArrayBuffer - The contents of the resource file. | |
*/ | |
async function readResourceSample(resArrayBuffer) { | |
// console.log('📁 resArrayBuffer:', resArrayBuffer); // NOTE: Memory leak, remove in prod. | |
if (!(resArrayBuffer instanceof ArrayBuffer)) { | |
throw new Error('readResourceSample: Expected resArrayBuffer to be ArrayBuffer.'); | |
} | |
// Verify the magic of the resource and determine its endianness. | |
const littleEndian = getValidAndIsLittleEndianFromMagic(resArrayBuffer); | |
const isAFL_2_3 = getIsAFL_2_3Header(resArrayBuffer, littleEndian); | |
// Construct structs with specified endianness. | |
const s = createFFLiResourceStructs(littleEndian, isAFL_2_3); | |
// Parse FFLiResourceHeader to object. | |
const header = s.FFLiResourceHeader.unpack(resArrayBuffer); | |
console.log('✅ FFLiResourceHeader.unpack result:', header); | |
if (header.m_Magic !== FFLI_RESOURCE_HEADER_MAGIC_U32) { | |
// Print real and expected magic both in hex (base 16). | |
throw new Error(`❌ failed to parse magic, got: 0x${header.m_Magic.toString(16)}, expected: 0x${FFLI_RESOURCE_HEADER_MAGIC_U32.toString(16)}`); | |
} | |
// Extract m_ExpandBufferSize/total uncompressed resource size. | |
/** only last 29 bits, see FFLiResourceUtil.cpp */ | |
const expandBufferSizeWithoutResHint = header.m_ExpandBufferSize & 0x1FFFFFFF; | |
const totalUncompressedResSizeMB = Math.trunc(expandBufferSizeWithoutResHint / (1024 * 1024)); | |
if (totalUncompressedResSizeMB > 75) { | |
// Assert if the resource is suspiciously large, assuming the number was read wrong. | |
console.error(`🤨 you sure this resource is ${totalUncompressedResSizeMB} MB large?`); | |
} | |
console.log(`ℹ️ resource total uncompressed size: ${totalUncompressedResSizeMB} MB`); | |
console.log('🖼️ m_TextureHeader:', header.m_TextureHeader); | |
console.log('📐 m_ShapeHeader:', header.m_ShapeHeader); | |
// Potential TODO: I feel that these methods could be made more generic, | |
// so that completely different resource files could share the same interface. | |
// Example: if it could just get part type/index; getting | |
// its corresponding data, all structures just unpacked... Need to expand that. | |
/** | |
* Takes an {@link FFLiResourcePartsInfo} object within {@link FFLiResourceHeader}, | |
* reading and potentially decompressing the data | |
* @param {ArrayBuffer} resArrayBuffer - The resource to read the part data from. | |
* @param {FFLiResourcePartsInfo} partsInfo - The information about the part. | |
* @returns {Promise<ArrayBuffer>} The decompressed part data. | |
* @throws {Error} Throws if CompressionStream is not supported. | |
*/ | |
async function getPartsInfoData(resArrayBuffer, partsInfo) { | |
// Access the compressed data for the part. | |
const resU8 = new Uint8Array(resArrayBuffer); | |
const rawData = resU8.subarray(partsInfo.dataPos, | |
partsInfo.dataPos + partsInfo.compressedSize); | |
// Check the compression strategy of the part. | |
if (partsInfo.strategy === FFLI_RESOURCE_STRATEGY_UNCOMPRESSED) { | |
return rawData.buffer; ///< Return raw data directly. if uncompressed. | |
} else if (partsInfo.strategy >= FFLI_RESOURCE_STRATEGY_MAX) { | |
// Throw if the strategy is unsupported (such as Brotli). | |
throw new Error(`getPartsInfoData: Unsupported partsInfo.strategy: ${partsInfo.strategy}, max = ${FFLI_RESOURCE_STRATEGY_MAX}`); | |
} | |
// Throw if CompressionStream is not supported. | |
if (!window.CompressionStream) { | |
throw new Error('getPartsInfoData: CompressionStream is not supported in this browser, so this function will have to be refactored to use pako library and call deflate().'); | |
} | |
const cs = new DecompressionStream('deflate'); | |
const stream = /** @type {ReadableStream<Uint8Array>} */ (new Response(rawData).body) | |
.pipeThrough(cs); | |
// Construct new fetch Response to stream data through. | |
return new Response(stream) | |
.arrayBuffer() | |
.then((buffer) => { | |
return buffer; | |
}) | |
// Catch any potential error. | |
.catch((error) => { | |
throw new Error(`getPartsInfoData: Streaming or decompressing resource failed: ${error instanceof Error ? error.message : error}`); | |
}); | |
} | |
/** | |
* @param {Uint8Array} data - The decompressed texture data. | |
* @param {ReturnType<createFFLiResourceStructs>} s - Structures to use. | |
* @returns {FFLiResourceTextureFooter} The texture footer. | |
*/ | |
const getTextureFooter = (data, s) => | |
s.FFLiResourceTextureFooter.unpack(data.subarray(-s.FFLiResourceTextureFooter.size)); | |
/** | |
* @param {Uint8Array} data - The decompressed shape data. | |
* @param {ReturnType<createFFLiResourceStructs>} s - Structures to use. | |
* @returns {FFLiResourceShapeDataHeader} The shape data header. | |
*/ | |
const getShapeHeader = (data, s) => | |
s.FFLiResourceShapeDataHeader.unpack(data.subarray(0, s.FFLiResourceShapeDataHeader.size)); | |
/** | |
* @param {Uint8Array} data - The decompressed shape data. | |
* @param {ReturnType<createFFLiResourceStructs>} s - Structures to use. | |
* @returns {FFLiResourceShapeFacelineTransform} The faceline transform object. | |
*/ | |
function getFacelineTransform(data, s) { | |
const off = /** @type {number} */ (s.FFLiResourceShapeDataHeader.fields.m_Transform.offset); | |
const buf = data.subarray(off, off + s.FFLiResourceShapeFacelineTransform.size); | |
return s.FFLiResourceShapeFacelineTransform.unpack(buf); | |
} | |
// Get data and footer for eye 0. | |
const eye0Data = new Uint8Array(await getPartsInfoData(resArrayBuffer, | |
header.m_TextureHeader.partsInfoEye[0])); | |
const eye0Footer = getTextureFooter(eye0Data, s); | |
console.log('👁️📩 eye 0 footer: ', eye0Footer); | |
if (eye0Footer.m_TextureFormat >= FFLTextureFormat.MAX) { | |
console.warn(`eye 0 texture footer is using format: ${eye0Footer.m_TextureFormat}, expected max: ${FFLTextureFormat.MAX}`); | |
} | |
// Get data and header for faceline 0. | |
const faceline0Data = new Uint8Array(await getPartsInfoData(resArrayBuffer, | |
header.m_ShapeHeader.partsInfoFaceline[0])); | |
const faceline0Header = getShapeHeader(faceline0Data, s); | |
console.log('🗿✉️ faceline 0 header: ', faceline0Header); | |
// Parse indices for faceline 0. | |
const elType = FFLiResourceShapeElementType.INDEX; | |
const pos = faceline0Header.m_ElementPos[elType]; | |
const size = faceline0Header.m_ElementSize[elType]; | |
/** @type {Uint16Array} */ let indices; | |
if (littleEndian) { | |
indices = new Uint16Array(faceline0Data.buffer, faceline0Data.byteOffset + pos, size); | |
} else { | |
// endian swap | |
const view = new DataView(faceline0Data.buffer, faceline0Data.byteOffset + pos, size); | |
// use getUint16 with littleEndian = false | |
indices = Uint16Array.from({ length: size / 2 }, (_, i) => view.getUint16(i * 2, false)); | |
// const indices = getShapeElement(faceline0Header, | |
// FFLiResourceShapeElementType.INDEX, faceline0Data, littleEndian); | |
} | |
console.log('🗿⛓️ faceline 0 indices: ', indices); | |
// const positions = getShapeElement(faceline0Header, | |
// FFLiResourceShapeElementType.POSITION, faceline0Data, littleEndian); | |
// console.log('🗿🧊 faceline 0 positions: ', positions); | |
// Parse faceline transform for faceline 0. | |
const faceline0FacelineTransform = getFacelineTransform(faceline0Data, s); | |
console.log('🗿⬆️ faceline 0 transform: ', faceline0FacelineTransform); | |
} | |
// // --------------------------------------------------------------------- | |
// // Zip File Reading | |
// // --------------------------------------------------------------------- | |
/** | |
* Extract a single file from a zip that matches a specific suffix and fourcc. | |
* @param {ArrayBuffer} buffer - The zip file data. | |
* @param {string} fileSuffix - The file suffix to filter (e.g. '.dat'). | |
* @param {string} requiredFourCC - The required fourcc string (e.g. 'FFRA'). | |
* @returns {Uint8Array} The matching file data. | |
* @throws {Error} If unzipping fails, zip structure is too complex, file is too small, or no file matches the fourcc. | |
*/ | |
function loadSingleFileFromZipWithFourcc(buffer, fileSuffix, requiredFourCC) { | |
/** Maximum allowed number of files in the zip. */ | |
const MAX_FILES = 5; | |
/** Maximum allowed directory recursion level. */ | |
const MAX_RECURSION_LEVEL = 10; | |
/** Minimum allowed file size in bytes (90 kb). */ | |
const MIN_FILE_SIZE = 90 * 1024; | |
/** Unzip the data with fflate. */ | |
const files = fflate.unzipSync(new Uint8Array(buffer)); | |
/** Get zip entries as [name, data] pairs. */ | |
const entries = Object.entries(files); | |
if (entries.length > MAX_FILES) { | |
// Fail if too many files. | |
throw new Error(`loadSingleFileFromZipWithFourcc: Amount of files exceeded ${MAX_FILES}.`); | |
} | |
let found = null; | |
for (const [name, data] of entries) { | |
/** Calculate directory recursion level. */ | |
const level = name.split('/').length - 1; | |
if (level > MAX_RECURSION_LEVEL) { | |
// Fail if recursion level too deep. | |
throw new Error(`loadSingleFileFromZipWithFourcc: Recursion level exceeded ${MAX_RECURSION_LEVEL}.`); | |
} | |
if (!name.endsWith(fileSuffix)) { | |
continue; ///< Skip non-matching file suffix. | |
} | |
if (data.length < MIN_FILE_SIZE) { | |
// Fail if file is too small. | |
throw new Error(`loadSingleFileFromZipWithFourcc: File ${name} is smaller than minimum: ${MIN_FILE_SIZE}.`); | |
} | |
/** Peek first 4 bytes for fourcc. */ | |
const header = String.fromCharCode(...data.slice(0, 4)); | |
if (header === requiredFourCC) { | |
found = data; ///< Found a matching file. | |
break; | |
} | |
} | |
if (!found) { | |
// Fail if no file matches. | |
throw new Error(`No file with required fourcc (${requiredFourCC}) or suffix (${fileSuffix}) found.`); | |
} | |
return found; | |
} | |
// // --------------------------------------------------------------------- | |
// // Utility: Load Buffer from File or URL | |
// // --------------------------------------------------------------------- | |
/** | |
* Load data from a File object. | |
* @param {File} file - The file from an upload. | |
* @returns {Promise<ArrayBuffer>} A promise that resolves to the file's data. | |
*/ | |
function bufferFromFile(file) { | |
return new Promise((resolve, reject) => { | |
/** Create a new FileReader. */ | |
const reader = new FileReader(); | |
reader.onload = () => { | |
if (!(reader.result instanceof ArrayBuffer)) { | |
reject(new Error('bufferFromFile: reader.result is not an ArrayBuffer.')); | |
return; | |
} | |
resolve(reader.result); ///< Resolve with ArrayBuffer. | |
}; | |
reader.onerror = (event) => { | |
// Reject with error details. | |
reject(new Error(`bufferFromFile: File reading failed: ${event.target?.error?.message}.`)); | |
}; | |
reader.readAsArrayBuffer(file); ///< Read file as ArrayBuffer. | |
}); | |
} | |
/** | |
* Load data from a URL using fetch. | |
* @param {string} url - The URL to fetch the file. | |
* @returns {Promise<ArrayBuffer>} A promise that resolves to the file data. | |
*/ | |
async function bufferFromUrl(url) { | |
/** Fetch the zip file. */ | |
const response = await fetch(url); | |
if (!response.ok) { | |
// Fail if response is bad. | |
throw new Error(`bufferFromFile: Fetch failed at URL = ${response.url}, response code = ${response.status}`); | |
} | |
return await response.arrayBuffer(); ///< Get and return ArrayBuffer from response. | |
} | |
// // --------------------------------------------------------------------- | |
// // Event Handlers for File or URL9 | |
// // --------------------------------------------------------------------- | |
/** | |
* Handles the file after it's uploaded or fetched. | |
* Extracts the desired file if it is a zip, and then calls {@link readResourceSample}. | |
* @param {ArrayBuffer} buffer - The ArrayBuffer to use. | |
*/ | |
async function handleZipLoading(buffer) { | |
/** Expected file suffix for valid files. */ | |
const EXPECTED_SUFFIX_IN_ZIP = '.dat'; | |
/** Expected fourcc header. */ | |
const EXPECTED_FOURCC_IN_ZIP = 'FFRA'; | |
/** Prefix for zip file magic. */ | |
const ZIP_MAGIC_PREFIX = 'PK'; | |
try { | |
// If the buffer contains zip data, get the file from it and set it as buffer. | |
if (getMagicFromArrayBuffer(buffer).startsWith(ZIP_MAGIC_PREFIX)) { | |
buffer = loadSingleFileFromZipWithFourcc(buffer, | |
EXPECTED_SUFFIX_IN_ZIP, EXPECTED_FOURCC_IN_ZIP).buffer; ///< Access ArrayBuffer. | |
} | |
await readResourceSample(buffer); ///< Call main entrypoint. | |
} catch (error) { | |
const e = error instanceof Error ? error.message : error; | |
alert(e); ///< Alert error message. | |
throw e; ///< Rethrow error. | |
} | |
} | |
// Get input elements. | |
const fileInput = /** @type {HTMLInputElement} */ (document.getElementById('fileInput')); | |
const urlForm = /** @type {HTMLFormElement} */ (document.getElementById('urlForm')); | |
const urlButton = /** @type {HTMLButtonElement} */ (document.getElementById('urlButton')); | |
const urlInput = /** @type {HTMLInputElement} */ (document.getElementById('urlInput')); | |
// Above JSDoc guarantees them to be non-null, so alert if any don't exist. | |
/** NOTE: Matches either HTML ID or variable name. */ | |
const missing = ['fileInput', 'urlForm', 'urlButton', 'urlInput'] | |
// @ts-ignore - it is indexable by string and we just set above | |
.filter(id => !(globalThis[id] instanceof HTMLElement)); | |
if (missing.length) { | |
alert(`HTML elements not found: ${missing.join(', ')}`); | |
} | |
// // --------------------------------------------------------------------- | |
// // Attach Event Listeners | |
// // --------------------------------------------------------------------- | |
fileInput.addEventListener('change', async (event) => { | |
const files = /** @type {HTMLInputElement} */ (event.target).files; | |
files && files[0] && handleZipLoading(await bufferFromFile(files[0])); ///< Process uploaded file. | |
}); | |
urlForm.addEventListener('submit', async (event) => { | |
event.preventDefault(); ///< Prevent default form submission. | |
const url = urlInput.value.trim(); | |
if (!url) { | |
return; | |
} | |
urlButton.disabled = true; ///< Disable button during fetch. | |
console.log(`Fetching from: ${url}`); ///< Log start of fetch. | |
bufferFromUrl(url) | |
.then((buffer) => { | |
urlButton.disabled = false; ///< Re-enable button after fetch. | |
handleZipLoading(buffer); ///< Process fetched file. | |
}) | |
.catch ((error) => { | |
const e = error instanceof Error ? error.message : error; | |
alert(e); ///< Alert error message. | |
urlButton.disabled = false; ///< Re-enable button after failure. | |
throw e; ///< Rethrow error. | |
}); | |
}); | |
/* | |
(async () => { | |
// Download resource. | |
const response = await fetch(resourceFetchPath); | |
if (!response.ok) { | |
throw new Error('fetch response not ok'); | |
} | |
const resArrayBuffer = await response.arrayBuffer(); | |
readResourceSample(resArrayBuffer); | |
})(); | |
*/ | |
/** | |
* Extracts a correctly formatted array from FFLiResourceShapeDataHeader based on element type. | |
* @param {FFLiResourceShapeDataHeader} header - The FFLiResourceShapeDataHeader. | |
* @param {FFLiResourceShapeElementType} elementType - The FFLiResourceShapeElementType to extract. | |
* @param {Uint8Array} data - The raw data buffer. | |
* @param {boolean} [littleEndian] - Whether to interpret data as little-endian (default: big-endian). | |
* @returns {Uint16Array|Float32Array} The formatted array (Uint16Array, Float32Array). | |
* @throws {Error} If the format is not yet implemented. | |
*/ | |
// eslint-disable-next-line no-unused-vars -- TODO remove this when this is being used | |
const getShapeElement = (header, elementType, data, littleEndian = false) => { | |
/** Byte offset. */ | |
const pos = header.m_ElementPos[elementType]; | |
/** Byte size. */ | |
const size = header.m_ElementSize[elementType]; | |
switch (elementType) { | |
case FFLiResourceShapeElementType.INDEX: { | |
const count = size / 2; | |
return littleEndian | |
? new Uint16Array(data.buffer, data.byteOffset + pos, count) | |
: Uint16Array.from({ length: count }, (_, i) => | |
new DataView(data.buffer, data.byteOffset + pos, size).getUint16(i * 2, false) | |
); | |
} | |
case FFLiResourceShapeElementType.POSITION: { | |
// POSITION is three Float32s, but stride is 16 (4 extra padding bytes) | |
/** Number of vertices */ | |
const count = size / 16; | |
if (littleEndian) { | |
return new Float32Array(data.buffer, data.byteOffset + pos / 4, count); | |
} else { | |
const view = new DataView(data.buffer, data.byteOffset + pos, size); | |
const positions = new Float32Array(size / 4); | |
for (let i = 0; i < count; i++) { | |
positions.set([ | |
view.getFloat32(i * 16, false), | |
view.getFloat32(i * 16 + 4, false), | |
view.getFloat32(i * 16 + 8, false), | |
view.getFloat32(i * 16 + 12, false) | |
], i * 4); | |
} | |
return positions; | |
} | |
} | |
// TODO: Implement remaining cases. | |
case FFLiResourceShapeElementType.NORMAL: | |
case FFLiResourceShapeElementType.TEXCOORD: | |
case FFLiResourceShapeElementType.TANGENT: | |
case FFLiResourceShapeElementType.COLOR: | |
throw new Error(`TODO: Implement extraction for element type ${elementType}`); | |
default: | |
throw new Error(`Unexpected element type: ${elementType}`); | |
} | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment