From 30258212afedceffc87fd9fcd135f12a8ffd6af5 Mon Sep 17 00:00:00 2001 From: yellows111 Date: Thu, 12 Oct 2023 08:49:07 +0200 Subject: [PATCH] Initial commit --- icon.js | 275 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ index.js | 35 +++++++ input.htm | 239 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 549 insertions(+) create mode 100644 icon.js create mode 100644 index.js create mode 100644 input.htm diff --git a/icon.js b/icon.js new file mode 100644 index 0000000..04b20dd --- /dev/null +++ b/icon.js @@ -0,0 +1,275 @@ +//todo: Make this a module/mjs file. C6 compatibility can stay, if needed. +ICONJS_DEBUG = false; +ICONJS_STRICT = true; + +function setDebug(value) { + ICONJS_DEBUG = !!value; +} + +function setStrictness(value) { + ICONJS_STRICT = !!value; +} + +// where U = uncompressed, N = none, C = compressed + +function getTextureFormat(i) { + if (i<8) { + if(i==3) { + return 'N'; + } + return 'U'; + } else if (i>=8) { + return 'C'; + } else { + return void(0); + } +} + +function BGR5A1(i) { + return {b: (i & 0b11111), g: ((i >> 5) & 0b11111), r: ((i >> 10) & 0b11111), a: (i >> 15)} + // map this as *8 for each color and i+1*127 for alpha, GL-wise, float(i+1) +} + +function stringScrubber(dirty) { + return dirty.replaceAll("\x00","").substring(0, (dirty.indexOf("\x00") === -1) ? dirty.length : dirty.indexOf("\x00")); +} + +function readPS2D(input) { + const view = new DataView(input); + const u32le = function(i){return view.getUint32(i, 1)} + const f32le = function(i){return view.getFloat32(i, 1)} + //!pattern ps2d.hexpat + const header = u32le(0); + if (header !== 0x44325350) { + throw `Not a PS2D file (was ${header}, expected ${0x44325350})`; + } + //:skip 2 + const titleOffset = u32le(6); + //:skip 2 + const bgAlpha = u32le(12); // should read as a u8, (k/127?.5) for float value (255 = a(2.0~)) + const bgColors = [ + {r: u32le(16), g: u32le(20), b: u32le(24), a: u32le(28)}, + {r: u32le(32), g: u32le(36), b: u32le(40), a: u32le(44)}, + {r: u32le(48), g: u32le(52), b: u32le(56), a: u32le(60)}, + {r: u32le(64), g: u32le(68), b: u32le(72), a: u32le(76)} + ]; // top-left, top-right, bottom-left, bottom-right; + const lightIndices = [ + {x: f32le(80), y: f32le(84), z: f32le(88)}, //:skip 4 + {x: f32le(96), y: f32le(100), z: f32le(104)}, //:skip 4 + {x: f32le(112), y: f32le(116), z: f32le(120)} //:skip 4 + ] + const lightColors = [ + {r: f32le(128), g: f32le(132), b: f32le(136), a: f32le(140)}, + {r: f32le(144), g: f32le(148), b: f32le(152), a: f32le(156)}, + {r: f32le(160), g: f32le(164), b: f32le(168), a: f32le(172)}, + {r: f32le(176), g: f32le(180), b: f32le(184), a: f32le(188)} + ] + // save builder says color 1 is ambient, 2-4 are for 3-point cameras + const int_title = input.slice(0xc0, 0x100); + const tmp_title16 = new Uint16Array(int_title); + for (let index = 0; index < 32; index++) { + //find "bad" shift-jis two-bytes, and convert to spaces (or NULL if strict) + if(tmp_title16[index] === 129 || tmp_title16[index] === 33343) { + console.warn(`PS2D syntax error: known bad two-byte sequence 0x${tmp_title16[index].toString(16).padEnd(4,"0")} (at ${index}). Replacing with ${(ICONJS_STRICT) ? "NULL" : "0x8140"}.\n${(ICONJS_STRICT) ? "This is console-accurate, so" : "An actual console does not do this."} I recommend patching your icon.sys files!`); + tmp_title16[index] = (ICONJS_STRICT) ? 0 : 0x4081; // is a reference, so this'll edit int_title too + } + } + //:skip 4 -- Unless proven, keep 64 bytes for display name. + const int_filename_n = input.slice(0x104, 0x143); + const int_filename_c = input.slice(0x144, 0x183); + const int_filename_d = input.slice(0x184, 0x1C3); + //;skip 512 -- rest of this is just padding + const rawTitle = (new TextDecoder("shift-jis")).decode(int_title); + const title = [ + stringScrubber(rawTitle.substring(0,(titleOffset/2))), + (rawTitle.indexOf("\x00") < (titleOffset/2)) ? "" : stringScrubber(rawTitle.substring((titleOffset/2))) + ]; + const filenames = { + n: stringScrubber((new TextDecoder("utf-8")).decode(int_filename_n)), + c: stringScrubber((new TextDecoder("utf-8")).decode(int_filename_c)), + d: stringScrubber((new TextDecoder("utf-8")).decode(int_filename_d)) + } + if(ICONJS_DEBUG){ + console.log({header, titleOffset, bgAlpha, bgColors, lightIndices, lightColors, title, filenames}); + } + return {filenames, title, background: {colors: bgColors, alpha: bgAlpha}, lighting: {points: lightIndices, colors: lightColors}}; +} + +function readIconFile(input) { + //!pattern ps2icon-hacked.hexpat + const view = new DataView(input); + const u32le = function(i){return view.getUint32(i, 1)} + const f32le = function(i){return view.getFloat32(i, 1)} + const f16le = function(i){return (view.getInt16(i, 1) / 4096)} + const u32_rgba8 = function(i) {return { + r: (i & 0xff), + g: ((i & 0xff00) >> 8), + b: ((i & 0xff0000) >> 16), + a: (i > 0x7fffffff ? 255 : (((i & 0xff000000) >>> 24) * 2)+1) + }}; + const magic = u32le(0); + if (magic !== 0x010000) { + // USER WARNING: APPARENTLY NOT ALL ICONS ARE 0x010000. THIS THROW WILL BE DROPPED LATER. + throw `Not a PS2 icon file (was ${magic}, expected ${0x010000})`; + } + const numberOfShapes = u32le(4); + const textureType = u32le(8); + const textureFormat = getTextureFormat(textureType); + //:skip 4 + const numberOfVertexes = u32le(16); + // now we're entering a bunch of chunks... oh baby, now it's painful. + // format: [xxyyzzaa * numberOfShapes][xxyyzzaa][uuvvrgba], ((8 * numberOfShapes) + 16) [per chunk] + let offset = 20; + let vertices = new Array(); + const chunkLength = ((numberOfShapes * 8) + 16); + + // for numberOfVertexes, copy chunkLength x times, then append with normal, uv and rgba8 color. Floats are 16-bit. + for (let index = 0; index < numberOfVertexes; index++) { + let shapes = new Array(); + for (let indey = 0; indey < numberOfShapes; indey++) { + shapes.push({ + x: f16le(offset+((chunkLength*index)+(indey*8))), + y: f16le(offset+((chunkLength*index)+(indey*8))+2), + z: f16le(offset+((chunkLength*index)+(indey*8))+4) + }); + //:skip 2 + } + + let normal = { + x: f16le(offset+(chunkLength*index)+((numberOfShapes * 8))), + y: f16le(offset+(chunkLength*index)+((numberOfShapes * 8))+2), + z: f16le(offset+(chunkLength*index)+((numberOfShapes * 8))+4) + }; + //:skip 2 + let uv = { + u: f16le(offset+(chunkLength*index)+((numberOfShapes * 8))+8), + v: f16le(offset+(chunkLength*index)+((numberOfShapes * 8))+10) + }; + let color = u32_rgba8(u32le(offset+(chunkLength*index)+((numberOfShapes * 8))+12)); + // keep original u32le color? + + vertices.push({shapes, normal, uv, color}); + } + offset = (20+(numberOfVertexes * chunkLength)); + animationHeader = {id: u32le(offset), length: u32le(offset+4), speed: f32le(offset+8), "offset": u32le(offset+12), keyframes: u32le(offset+16)}; + let animData = new Array(); + // now we have to do stuff dynamically + // format for a keyframe: sssskkkk[ffffvvvv] where [ffffvvvv] repeat based on the value that kkkk(eys) has. + // sssskkkk[ffffvvvv] is repeated based on animationHeader.keyframes value. + offset += 20; + for (let index = 0; index < animationHeader.keyframes; index++) { + let frameData = new Array(); + let shapeId = u32le(offset); + let keys = u32le(offset+4); + offset += 8; + for (let indey = 0; indey < keys; indey++) { + frameData.push({frame: f32le(offset), value: f32le(offset+4)}); + offset += 8; + } + animData.push({shapeId, keys, frameData}); + } + let texture = null; + switch(textureFormat) { + case 'N': { + break; + } + case 'U': { + //where every 16-bit entry is a BGR5A1 color 0b[bbbbbgggggrrrrra] + texture = new Uint16Array(input.slice(offset, (offset+0x8000))); + //texture.forEach(function(indice){console.log(BGR5A1(indice))}); + break; + } + case 'C': { + // compression format unknown, but all in type use same header format + size = u32le(offset); + texture = {size, data: input.slice(offset+4, offset+(4+size))}; + } + } + if(ICONJS_DEBUG){ + console.log({magic, numberOfShapes, textureType, textureFormat, numberOfVertexes, chunkLength, vertices, animationHeader, animData, texture}); + } + return {numberOfShapes, vertices, textureFormat, texture, animData}; +} + +function readEntryBlock(input) { + const view = new DataView(input); + const u32le = function(i){return view.getUint32(i, 1)}; + const u16le = function(i){return view.getUint16(i, 1)}; + const t64le = function(i){return { + seconds: view.getUint8(i+1), + minutes: view.getUint8(i+2), + hours: view.getUint8(i+3), + day: view.getUint8(i+4), + month: view.getUint8(i+5), + year: view.getUint16(i+6, 1) + }}; //NOTE: times are in JST timezone (GMT+09:00), so clients should implement correctly! + //!pattern psu_file.hexpat + const permissions = u32le(0); + let type; + if (permissions>0xffff) { + throw `Not a EMS Max (PSU) save file (was ${permissions}, expected less than ${0xffff})`; + } + if((permissions & 0b00100000)>=1){ + type = "directory"; + } + if((permissions & 0b00010000)>=1){ + type = "file"; + } + if((permissions & 0b0001100000000000)>=1){ + throw `I don't parse portable applications or legacy save data. (${permissions} has bits 10 or 11 set)`; + } + const size = u32le(4); + const createdTime = t64le(8); + const sectorOffset = u32le(16); + const dirEntry = u32le(20); + const modifiedTime = t64le(24); + const specialSection = input.slice(0x20, 0x40); + const int_filename = input.slice(0x40, 512); + const filename = stringScrubber((new TextDecoder("utf-8")).decode(int_filename)); + if(ICONJS_DEBUG){ + console.log({permissions, type, size, createdTime, sectorOffset, dirEntry, modifiedTime, specialSection, filename}); + } + return {type, size, filename, createdTime, modifiedTime}; +} + +function readEmsPsuFile(input){ + const header = readEntryBlock(input.slice(0,0x1ff)); + let fsOut = {length: header.size, rootDirectory: header.filename, timestamps: {created: header.createdTime, modified: header.modifiedTime}}; + let output = new Object(); + let offset = 512; + for (let index = 0; index < header.size; index++) { + fdesc = readEntryBlock(input.slice(offset, offset + 512)); + switch(fdesc.type) { + case "directory": { + offset += 512; + output[fdesc.filename] = null; + break; + } + case "file": { + if(ICONJS_DEBUG){ + console.log(`PARSING | F: "${fdesc.filename}" O: ${offset} S: ${fdesc.size}`); + } + offset += 512; + const originalOffset = offset; + if((fdesc.size % 1024) > 0) { + offset += ((fdesc.size & 0b11111111110000000000) + 1024); + } else { + offset += fdesc.size; + // if we're already filling sectors fully, no to change anything about it + } + output[fdesc.filename] = { + size: fdesc.size, + data: input.slice(originalOffset, originalOffset+fdesc.size) + }; + break; + } + } + } + fsOut[header.filename] = output; + return fsOut; +} + +if(typeof module !== "undefined") { + // for C6JS + module.exports = {readIconFile, readPS2D, readEmsPsuFile, setDebug, setStrictness}; +} \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..bb9ab51 --- /dev/null +++ b/index.js @@ -0,0 +1,35 @@ +const iconjs = require("./icon.js"); +const filesystem = require("fs"); +const processObj = require("process"); + +// to make it viewable +require("util").inspect.defaultOptions.maxArrayLength = 10; +require("util").inspect.defaultOptions.compact = true; +require("util").inspect.defaultOptions.depth = 2; + +// debugger +iconjs.setDebug(false); + +// node.js client +if(processObj.argv[2] === "psu") { + let output = new Object(); + let inputFile = filesystem.readFileSync(processObj.argv[3] ? processObj.argv[3] : "file.psu"); + const parsed = iconjs.readEmsPsuFile(inputFile.buffer.slice(inputFile.byteOffset, inputFile.byteOffset + inputFile.byteLength)); + const PS2D = iconjs.readPS2D(parsed[parsed.rootDirectory]["icon.sys"].data); + output = {parsed, PS2D} + Object.keys(PS2D.filenames).forEach(function(file) { + output[file] = iconjs.readIconFile(parsed[parsed.rootDirectory][PS2D.filenames[file]].data); + }); + console.log(output); +} else { + let inputFile = filesystem.readFileSync(processObj.argv[2] ? processObj.argv[2] : "icon.sys"); + const metadata = iconjs.readPS2D(inputFile.buffer.slice(inputFile.byteOffset, inputFile.byteOffset + inputFile.byteLength)); + console.log("\noutput:", metadata, "\n") + + 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"); +}); +} \ No newline at end of file diff --git a/input.htm b/input.htm new file mode 100644 index 0000000..b71a626 --- /dev/null +++ b/input.htm @@ -0,0 +1,239 @@ + + + + + + + input validation test + + + + + + + + + (enables console-accurate title parsing) +
+ + +
+

No File

+

Loaded

+ Background preview:
+ +
+

Normal: (no file)

+

Copying: (no file)

+

Deleting: (no file)

+
+ + +
+
+ + +
+

+ Date created: --:--:-- --/--/---- UTC+09:00 +
+ Date modified: --:--:-- --/--/---- UTC+09:00 +

+ + + icondumper2 0.3.2 - © 2023 yellows111 + + \ No newline at end of file