diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 66ce543..5dc9da1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ jobs: - name: Install node.js uses: actions/setup-node@main - name: Compile documentation with JSDoc - run: npx jsdoc ./icon.js -d ./documentation -R ./README.md + run: npx jsdoc ./icon.js ./lzari.js -d ./documentation -R ./README.md - name: Upload Artifacts uses: actions/upload-artifact@main with: diff --git a/.gitignore b/.gitignore index aff2196..d27dc03 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ icon.sys *.p2m *.md *.cbs +*.max +*.pws # Unused data diff --git a/README.md b/README.md index a06ab63..23bd080 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ As of writing, there was no exporter that exists for the format that exhibited o * SharkPort export files (.sps) * X-Port export files (.xps) * CodeBreaker Save export files (.cbs) +* Max Drive "PowerSave"/export files (.max) * PS2 icons (.ico, .icn) * PS2D format (icon.sys) @@ -30,6 +31,7 @@ As of writing, there was no exporter that exists for the format that exhibited o * CommonJS (that includes node!) module exporting while still being compatible with other JavaScript implementations. * Convert a 128x128x16 BGR5A1 bitmap to a standard RGB5A1 format. * Convert an icon or a set of icons to glTF 2, with textures saved as PNG. +* Decompress any LZARI-formatted data. ## What it doesn't do: * Create, manipulate or otherwise taint save files. @@ -56,6 +58,7 @@ Because it replaced what *was* left of icondumper (1). | index.js | Node.js example client. | | gltf-exporter.js | Node.js client to export icons to glTF 2. | | index.htm | HTML reference client. | +| lzari.js | A LZARI decompression-only library. | ## Included example files: | Directory | Description | Formats | diff --git a/gltf-exporter.js b/gltf-exporter.js index 0d033e1..daa5cd1 100644 --- a/gltf-exporter.js +++ b/gltf-exporter.js @@ -322,6 +322,23 @@ switch(processObj.argv[2]) { } break; } + case "max": + case "pws": { + let inputFile = filesystem.readFileSync(processObj.argv[3] ? processObj.argv[3] : "file.max"); + function myUnlzari(inputBuffer) { + return (require("./lzari.js").decodeLzari(inputBuffer)).buffer; + } + const parsed = iconjs.readMaxPwsFile(inputFile.buffer.slice(inputFile.byteOffset, inputFile.byteOffset + inputFile.byteLength), myUnlzari); + const PS2D = iconjs.readPS2D(parsed[parsed.rootDirectory]["icon.sys"].data); + loadAndConvertIcon(iconjs.readIconFile(parsed[parsed.rootDirectory][PS2D.filenames.n].data), PS2D.filenames.n); + if(PS2D.filenames.n !== PS2D.filenames.c) { + loadAndConvertIcon(iconjs.readIconFile(parsed[parsed.rootDirectory][PS2D.filenames.c].data), PS2D.filenames.c); + } + if(PS2D.filenames.n !== PS2D.filenames.d) { + loadAndConvertIcon(iconjs.readIconFile(parsed[parsed.rootDirectory][PS2D.filenames.d].data), PS2D.filenames.d); + } + break; + } case "sys": { let inputFile = filesystem.readFileSync(processObj.argv[3] ? processObj.argv[3] : "icon.sys"); const PS2D = iconjs.readPS2D(inputFile.buffer.slice(inputFile.byteOffset, inputFile.byteOffset + inputFile.byteLength)); @@ -352,6 +369,8 @@ psv: Read a PS3 export file. sps: Read a SharkPort export file. xps: Read a X-Port export file. cbs: Read a CodeBreaker Save export file. +max: Read a Max Drive export file. +pws: Read a PowerSave export file. sys: Read a icon.sys (964 bytes) file, and attempt to read icon files from the current directory. diff --git a/icon.js b/icon.js index db79c1b..7b2ea0e 100644 --- a/icon.js +++ b/icon.js @@ -1,5 +1,4 @@ //To swap between mjs/esm and c6js, go to the end of this file, and (un)comment your wanted module mode. -//LOOKING FOR: LZARI implementation (for MAX and PWS files. THIS WILL COMPLETE ICONDUMPER2.) var ICONJS_DEBUG = false; var ICONJS_STRICT = true; @@ -8,7 +7,7 @@ var ICONJS_STRICT = true; * @constant {string} * @default */ -const ICONJS_VERSION = "0.7.0"; +const ICONJS_VERSION = "0.8.0"; /** * The RC4 key used for ciphering CodeBreaker Saves. @@ -488,7 +487,7 @@ function readEntryBlock(input) { function readEmsPsuFile(input){ const header = readEntryBlock(input.slice(0,0x1ff)); if(header.size > 0x7f) { - throw `Directory is too large! (maximum size: ${0x7f}, was ${header.size})` + throw `Directory is too large! (maximum size: ${0x7f}, was ${header.size})`; } let fsOut = {length: header.size, rootDirectory: header.filename, timestamps: header.timestamps}; let output = new Object(); @@ -637,7 +636,7 @@ function readSharkXPortSxpsFile(input) { let offset = 4; const ident = input.slice(offset, offset+identLength); if((new TextDecoder("utf-8")).decode(ident) !== "SharkPortSave") { - throw `Unrecognized file identification string. Expected "SharkPortSave".` + throw `Unrecognized file identification string. Expected "SharkPortSave".`; } offset += (identLength + 4); const titleLength = u32le(offset); @@ -756,19 +755,95 @@ function readCodeBreakerCbsFile(input, inflator = null) { const inflatedData = inflator(decipheredData); const fsOut = {rootDirectory: dirName, timestamps}; fsOut[dirName] = readCodeBreakerCbsDirectory(inflatedData); + if(ICONJS_DEBUG) { + console.debug({magic, dataOffset, compressedSize, dirName, permissions, displayName}); + } return fsOut; } +/** + * Read a Max Drive (MAX) or PowerSave (PWS) file's directory structure. + * @param {ArrayBuffer} input - Uncompressed input + * @returns {Object} (user didn't write a description) + * @protected + */ +function readMaxPwsDirectory(input, directorySize) { + const {u32le} = new yellowDataReader(input); + virtualFilesystem = new Object(); + let offset = 0; + for (let index = 0; index < directorySize; index++) { + const dataSize = u32le(offset); + const _filename = input.slice(offset+4, offset+36); + const filename = stringScrubber((new TextDecoder("utf-8")).decode(_filename)); + if(filename === "") { throw `Unexpected null filename at byte ${offset+4}.`; }; + offset += 36; + const data = input.slice(offset, offset+dataSize); + offset += dataSize; + if(index !== directorySize - 1) { + while((offset & 15) !== 8) { + offset++; + } + } + virtualFilesystem[filename] = ({dataSize, data}); + } + return virtualFilesystem; +} + +/** + * Read a Max Drive (MAX) or PowerSave (PWS) file. + * @param {ArrayBuffer} input - MAX/PWS formatted file + * @param {function(Uint8Array): ArrayBuffer} unlzari - a function which provides a LZARI-compatible decompression function. + * @returns {Object} (user didn't write a description) + * @public + */ +function readMaxPwsFile(input, unlzari) { + if(typeof unlzari !== "function") { + throw `No decompresser function passed. Skipping.`; + } + const {u32le} = new yellowDataReader(input); + const ident = input.slice(0, 12); + if((new TextDecoder("utf-8")).decode(ident) !== "Ps2PowerSave") { + throw `Unrecognized file identification string. Expected "Ps2PowerSave".`; + } + //:skip 4 (u32 checksum) + const _dirName = input.slice(0x10, 0x30); + const dirName = stringScrubber((new TextDecoder("utf-8")).decode(_dirName)); + const _displayName = input.slice(0x30, 0x50); + const displayName = stringScrubber((new TextDecoder("utf-8")).decode(_displayName)); + const compressedSize = u32le(0x50); + if(compressedSize !== (input.byteLength - 88)) { + console.warn(`This file says it's larger then it actually is! (Given size: ${compressedSize}, actual size: ${input.byteLength - 88})`); + } + const size = u32le(0x54); + const compressedData = input.slice(88, input.byteLength); + const uncompressedData = unlzari(new Uint8Array(compressedData)); // read above why we can't trust given size + const fsOut = {rootDirectory: dirName}; + fsOut[dirName] = readMaxPwsDirectory(uncompressedData, size); + // there's no... timestamps or permissions... this doesn't bode well. + if(ICONJS_DEBUG) { + console.debug({ident, compressedSize, dirName, displayName}); + } + return fsOut; +} + + /** * Define (module.)exports with all public functions. * @exports icondumper2/icon */ // start c6js -exports = { - readers: {readIconFile, readPS2D, readEmsPsuFile, readPsvFile, readSharkXPortSxpsFile, readCodeBreakerCbsFile}, - helpers: {uncompressTexture, convertBGR5A1toRGB5A1}, - options: {setDebug, setStrictness}, - version: ICONJS_VERSION -}; +if(typeof exports !== "object") { + exports = { + readers: {readIconFile, readPS2D, readEmsPsuFile, readPsvFile, readSharkXPortSxpsFile, readCodeBreakerCbsFile, readMaxPwsFile}, + helpers: {uncompressTexture, convertBGR5A1toRGB5A1}, + options: {setDebug, setStrictness}, + version: ICONJS_VERSION + }; +} else { + exports.readers = {readIconFile, readPS2D, readEmsPsuFile, readPsvFile, readSharkXPortSxpsFile, readCodeBreakerCbsFile, readMaxPwsFile}; + exports.helpers = {uncompressTexture, convertBGR5A1toRGB5A1}; + exports.options = {setDebug, setStrictness}; + exports.version = ICONJS_VERSION; +} if(typeof module !== "undefined") { module.exports = exports; @@ -776,6 +851,6 @@ if(typeof module !== "undefined") { //end c6js //start esm /*export { - readIconFile, readPS2D, readEmsPsuFile, readPsvFile, readSharkXPortSxpsFile, readCodeBreakerCbsFile, uncompressTexture, convertBGR5A1toRGB5A1, setDebug, ICONJS_VERSION + readIconFile, readPS2D, readEmsPsuFile, readPsvFile, readSharkXPortSxpsFile, readCodeBreakerCbsFile, readMaxPwsFile, uncompressTexture, convertBGR5A1toRGB5A1, setDebug, ICONJS_VERSION };*/ //end esm \ No newline at end of file diff --git a/index.htm b/index.htm index dda680b..10c6a6e 100644 --- a/index.htm +++ b/index.htm @@ -6,6 +6,7 @@ icondumper2 - HTML reference client + diff --git a/index.js b/index.js index 0cb9095..4665328 100644 --- a/index.js +++ b/index.js @@ -62,6 +62,22 @@ switch(processObj.argv[2]) { console.log(output); break; } + case "max": + case "pws": { + let inputFile = filesystem.readFileSync(processObj.argv[3] ? processObj.argv[3] : "file.max"); + function myUnlzari(inputBuffer) { + return (require("./lzari.js").decodeLzari(inputBuffer)).buffer; + } + const parsed = iconjs.readMaxPwsFile(inputFile.buffer.slice(inputFile.byteOffset, inputFile.byteOffset + inputFile.byteLength), myUnlzari); + console.log(parsed); + const PS2D = iconjs.readPS2D(parsed[parsed.rootDirectory]["icon.sys"].data); + let output = {parsed, PS2D} + Object.keys(PS2D.filenames).forEach(function(file) { + output[file] = iconjs.readIconFile(parsed[parsed.rootDirectory][PS2D.filenames[file]].data); + }); + console.log(output); + break; + } case "sys": { let inputFile = filesystem.readFileSync(processObj.argv[3] ? processObj.argv[3] : "icon.sys"); const metadata = iconjs.readPS2D(inputFile.buffer.slice(inputFile.byteOffset, inputFile.byteOffset + inputFile.byteLength)); @@ -70,7 +86,6 @@ switch(processObj.argv[2]) { Object.keys(metadata.filenames).forEach(function(file) { let getFile = filesystem.readFileSync(metadata.filenames[file]); const output = iconjs.readIconFile(getFile.buffer.slice(getFile.byteOffset, getFile.byteOffset + getFile.byteLength)); - //console.log(individialIcon); console.log(`contents of ${metadata.filenames[file]} (${file}):`, output, "\n"); }); } @@ -91,6 +106,8 @@ psv: Read a PS3 export file. sps: Read a SharkPort export file. xps: Read a X-Port export file. cbs: Read a CodeBreaker Save export file. +max: Read a Max Drive export file. +pws: Read a PowerSave export file. sys: Read a icon.sys (964 bytes) file, and attempt to read icon files from the current directory.