Skip to content

Instantly share code, notes, and snippets.

@ariankordi
Created June 28, 2025 20:16
Show Gist options
  • Save ariankordi/66efb65ebf58da9f8151f21dcf3299dc to your computer and use it in GitHub Desktop.
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.
<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>
// @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