// // spa.js //-------------------- // Reads spa files. Based off of code from MJDS Course Modifier, which was very incomplete but at least got the textures. // Reverse engineered most of the emitter stuff. // by RHY3756547 // window.spa = function(input) { var t = this; this.load = load; this.getTexture = getTexture; var colourBuffer; function load(input) { colourBuffer = new Uint32Array(4); var view = new DataView(input); var header = null; var offset = 0; var stamp = readChar(view, 0x0)+readChar(view, 0x1)+readChar(view, 0x2)+readChar(view, 0x3); if (stamp != " APS") throw "SPA invalid. Expected ' APS', found "+stamp; offset += 4; var version = readChar(view, offset)+readChar(view, offset+1)+readChar(view, offset+2)+readChar(view, offset+3); offset += 4; var particleCount = view.getUint16(offset, true); var particleTexCount = view.getUint16(offset+2, true); var unknown = view.getUint32(offset+4, true); var unknown2 = view.getUint32(offset+8, true); var unknown3 = view.getUint32(offset+12, true); var firstTexOffset = view.getUint32(offset+16, true); var pad = view.getUint32(offset+20, true); offset += 24; if (version == "12_1") { t.particles = []; for (var i=0; i1 means more than one particle will appear. obj.areaSpread = view.getInt32(off+0x14, true)/4096; //x and z //^ particle count? obj.unknown3 = view.getInt32(off+0x18, true)/4096; //unknown (does not change anything for grass) //not sure what this vector is for. grass it's (0, 1, 0), smoke it's (-0.706787109375, 0, -0.707275390625) billboard alignment vector? (it's a bit crazy for powerslide) obj.vector = [view.getInt16(off+0x1C, true)/4096, view.getInt16(off+0x1E, true)/4096, view.getInt16(off+0x20, true)/4096]; obj.color = view.getUint16(off+0x22, true); //15 bit, usually 32767 for white. obj.randomxz = view.getUint32(off+0x24, true)/4096; //random xz velocity intensity obj.velocity = view.getUint32(off+0x28, true)/4096; //initial velocity related (along predefined vector) obj.size = view.getUint32(off+0x2C, true)/4096; //size obj.aspect = view.getUint16(off+0x30, true) / 4096; //aspect //frame delay before activation (x2) //rotational velocity from (x2) //rotational velocity to (x2) obj.delay = view.getUint16(off+0x32, true); obj.rotVelFrom = view.getInt16(off+0x34, true); obj.rotVelTo = view.getInt16(off+0x36, true); obj.scX = view.getInt16(off+0x38, true)/0x8000; //??? (0) //scale center offset? obj.scY = view.getInt16(off+0x3A, true)/0x8000; //??? (4B) //scale center offset? obj.emitterLifetime = view.getUint16(off+0x3C, true); //stop emitting particles after this many frames obj.duration = view.getUint16(off+0x3E, true); obj.varScale = view.getUint8(off+0x40, true); obj.varDuration = view.getUint8(off+0x42, true); obj.varUnk1 = view.getUint8(off+0x44, true); //usually like 1-8 obj.varUnk2 = view.getUint8(off+0x46, true); //usually like 128 (hahaa) obj.frequency = view.getUint8(off+0x44, true); //create particle every n frames obj.opacity = view.getUint8(off+0x45, true); //opacity (0-1F) obj.yOffIntensity = view.getUint8(off+0x46, true); //y offset intensity (seems to include updraft and gravity. 124 for smoke, 120 for grass. 128 is probably 1x) obj.textureId = view.getUint8(off+0x47, true); obj.unknown21 = view.getUint32(off+0x48, true); //negative number makes grass disappear (1 for grass, smoke) obj.unknown22 = view.getUint32(off+0x4C, true); //some numbers make grass disappear (0x458d00 for grass, 0x74725f60 for smoke) obj.xScaleDelta = view.getInt16(off+0x50, true)/4096; //x scale delta for some reason. usually 0 obj.yScaleDelta = view.getInt16(off+0x52, true)/4096; //y scale delta for some reason. usually 0 obj.unknown25 = view.getUint32(off+0x54, true); //FFFFFFFF makes run at half framerate. idk? usually 0 off += 0x58; if ((obj.flag & ParticleFlags.ScaleAnim) != 0) { obj.scaleAnim = []; //1.000 (doesn't seem to do anything important, but occasionally is between start and end) //start scale //end scale //???? (seems to affect the interpolation. cubic params?) //flags (1: random scale for one frame? everything above it might be cubic params) //???? (0x4B) obj.scaleAnim = { unkBase: view.getUint16(off, true)/4096, scaleFrom: view.getUint16(off+2, true)/4096, scaleTo: view.getUint16(off+4, true)/4096, fromZeroTime: view.getUint8(off+6, true)/0xFF, //time to dedicate to an animation from zero size holdTime: view.getUint8(off+7, true)/0xFF, //time to dedicate to holding state at the end. flagParam: view.getUint16(off+8, true), unk4b: view.getUint16(off+10, true), }; off += 12; } if ((obj.flag & ParticleFlags.ColorAnimation) != 0) { obj.colorAnim = { colorFrom: view.getUint16(off, true), //color from colorTo: view.getUint16(off+2, true), //color to (seems to be same as base color) framePct: view.getUint16(off+4, true), //frame pct to become color to (FFFF means always from, 8000 is about the middle) unknown: view.getUint16(off+6, true), //unknown, 00FF for fire? flags: view.getUint32(off+8, true), //flags (1: binary select color, 4: smooth blend) }; off += 12; } if ((obj.flag & ParticleFlags.OpacityAnimation) != 0) { //opacity //intensity x2 (0FFF to 0000. smoke is 0bff. 1000 breaks it, i'm assuming it pushes opacity from 1f to 20 (overflow to 0)) //random flicker //unknown (negative byte breaks it) //startfade x2 //cubic param? x2 obj.opacityAnim = { intensity: view.getUint16(off, true), random: view.getUint8(off+2, true), unk: view.getUint8(off+3, true), startFade: view.getUint16(off+4, true), //0-FFFF. seems to be the pct of duration where the anim starts. param: view.getUint16(off+6, true), } off += 8; } if ((obj.flag & ParticleFlags.TextureAnimation) != 0) { var textures = []; for (var i=0; i<8; i++) textures[i] = view.getUint8(off+i); obj.texAnim = { textures: textures, frames: view.getUint8(off+8), unknown1: view.getUint8(off+9), //128 - duration of particle. 37 - blue spark? (7 frames for 7 duration effect) unknown2: view.getUint16(off+10, true), //1 - random frame? } off += 12; } if ((obj.flag & ParticleFlags.Bit16) != 0) { obj.Bit16 = []; for (var i=0; i<20; i++) obj.Bit16[i] = view.getUint8(off+i); off += 20; } if ((obj.flag & ParticleFlags.Gravity) != 0) { //gravity //x wind //gravity (signed 16, -1 is down, leaves are FFEA (-22/4096)) //z wind //pad? obj.gravity = [ view.getInt16(off, true)/4096, view.getInt16(off+2, true)/4096, view.getInt16(off+4, true)/4096, view.getInt16(off+6, true)/4096, //pad, should be ignored by vec3 ops ]; off += 8; } if ((obj.flag & ParticleFlags.Bit25) != 0) { //seems to be 4 int 16s typically in some kind of pattern. obj.Bit25 = []; for (var i=0; i<8; i++) obj.Bit25[i] = view.getUint8(off+i); off += 8; } if ((obj.flag & ParticleFlags.Bit26) != 0) { obj.Bit26 = []; for (var i=0; i<16; i++) obj.Bit26[i] = view.getUint8(off+i); off += 16; } if ((obj.flag & ParticleFlags.Bit27) != 0) { obj.Bit27 = []; for (var i=0; i<4; i++) obj.Bit27[i] = view.getUint8(off+i); off += 4; } if ((obj.flag & ParticleFlags.Bit28) != 0) { obj.Bit28 = []; for (var i=0; i<8; i++) obj.Bit28[i] = view.getUint8(off+i); off += 8; } if ((obj.flag & ParticleFlags.Bit29) != 0) { obj.Bit29 = []; for (var i=0; i<16; i++) obj.Bit29[i] = view.getUint8(off+i); off += 16; } obj.nextOff = off; return obj; } function readParticleTexture(view, off) { var obj = {}; obj.stamp = readChar(view, off+0x0)+readChar(view, off+0x1)+readChar(view, off+0x2)+readChar(view, off+0x3); if (obj.stamp != " TPS") throw "SPT invalid (particle texture in SPA). Expected ' TPS', found "+obj.stamp; var flags = view.getUint16(off+4, true); obj.info = { pal0trans: true,//z(flags>>3)&1, //weirdly different format format: ((flags)&7), height: 8 << ((flags>>8)&0xF), width: 8 << ((flags>>4)&0xF), repeatX: (flags>>12)&1, repeatY: (flags>>13)&1, flipX: (flags>>14)&1, flipY: (flags>>15)&1, } obj.flags = flags; obj.unknown = view.getUint16(off+6, true); obj.texDataLength = view.getUint32(off+8, true); obj.palOff = view.getUint32(off+0xC, true); obj.palDataLength = view.getUint32(off+0x10, true); obj.unknown2 = view.getUint32(off+0x14, true); obj.unknown3 = view.getUint32(off+0x18, true); obj.unknown4 = view.getUint32(off+0x1C, true); obj.texData = view.buffer.slice(off+32, off+32+obj.texDataLength); off += 32+obj.texDataLength; obj.palData = view.buffer.slice(off, off+obj.palDataLength); obj.nextOff = off+obj.palDataLength; //var test = readTexWithPal(obj.info, obj); //document.body.appendChild(test); return obj; } //modified from NSBTX.js - should probably refactor to use be generic between both function readTexWithPal(tex, data) { var format = tex.format; var trans = tex.pal0trans; if (format == 5) return readCompressedTex(tex, data); //compressed 4x4 texture, different processing entirely var off = 0;//tex.texOffset; var palView = new DataView(data.palData); var texView = new DataView(data.texData); var palOff = 0;//pal.palOffset; var canvas = document.createElement("canvas"); canvas.width = tex.width; canvas.height = tex.height; var ctx = canvas.getContext("2d"); var img = ctx.getImageData(0, 0, tex.width, tex.height); var total = tex.width*tex.height; var databuf; for (var i=0; i>5)*(255/7); premultiply(col); } else if (format == 2) { //2 bit pal if (i%4 == 0) databuf = texView.getUint8(off++); col = readPalColour(palView, palOff, (databuf>>((i%4)*2))&3, trans) } else if (format == 3) { //4 bit pal if (i%2 == 0) { databuf = texView.getUint8(off++); col = readPalColour(palView, palOff, databuf&15, trans) } else { col = readPalColour(palView, palOff, databuf>>4, trans) } } else if (format == 4) { //8 bit pal col = readPalColour(palView, palOff, texView.getUint8(off++), trans) } else if (format == 6) { //A5I3 encoding. 5 bits alpha 3 bits pal index var dat = texView.getUint8(off++) col = readPalColour(palView, palOff, dat&7, trans); col[3] = (dat>>3)*(255/31); premultiply(col); } else if (format == 7) { //raw color data col = texView.getUint16(off, true); colourBuffer[0] = Math.round(((col&31)/31)*255) colourBuffer[1] = Math.round((((col>>5)&31)/31)*255) colourBuffer[2] = Math.round((((col>>10)&31)/31)*255) colourBuffer[3] = Math.round((col>>15)*255); col = colourBuffer; premultiply(col); off += 2; } else { console.log("texture format is none, ignoring") return canvas; } img.data.set(col, i*4); } ctx.putImageData(img, 0, 0) return canvas; } function premultiply(col) { col[0] *= col[3]/255; col[1] *= col[3]/255; col[2] *= col[3]/255; } function readCompressedTex(tex) { //format 5, 4x4 texels. I'll keep this well documented so it's easy to understand. throw "compressed tex not supported for particles! (unknowns for tex data offsets and lengths?)"; var off = 0;//tex.texOffset; var texView = new DataView(compData); //real texture data - 32 bits per 4x4 block (one byte per 4px horizontal line, each descending 1px) var compView = new DataView(compInfoData); //view into compression info - informs of pallete and parameters. var palView = new DataView(data.palData); //view into the texture pallete var compOff = off/2; //info is 2 bytes per block, so the offset is half that of the tex offset. var palOff = 0;//pal.palOffset; var transColor = new Uint8Array([0, 0, 0, 0]); //transparent black var canvas = document.createElement("canvas"); canvas.width = tex.width; canvas.height = tex.height; var ctx = canvas.getContext("2d"); var img = ctx.getImageData(0, 0, tex.width, tex.height); var w = tex.width>>2; //iterate over blocks, block w and h is /4. var h = tex.height>>2; for (var y=0; y> 14) & 3); var finalPo = palOff+addr*4; var imgoff = x*4+(y*w*16); for (var iy=0; iy<4; iy++) { var dat = texView.getUint8(off++); for (var ix=0; ix<4; ix++) { //iterate over horiz lines var part = (dat>>(ix*2))&3; var col; switch (mode) { case 0: //value 3 is transparent, otherwise pal colour if (part == 3) col = transColor; else col = readPalColour(palView, finalPo, part); break; case 1: //average mode - colour 2 is average of 1st two, 3 is transparent. 0&1 are normal. if (part == 3) col = transColor; else if (part == 2) col = readFractionalPal(palView, finalPo, 0.5); else col = readPalColour(palView, finalPo, part); break; case 2: //pal colour col = readPalColour(palView, finalPo, part); break; case 3: //5/8 3/8 mode - colour 2 is 5/8 of col0 plus 3/8 of col1, 3 is 3/8 of col0 plus 5/8 of col1. 0&1 are normal. if (part == 3) col = readFractionalPal(palView, finalPo, 3/8); else if (part == 2) col = readFractionalPal(palView, finalPo, 5/8); else col = readPalColour(palView, finalPo, part); break; } img.data.set(col, (imgoff++)*4) } imgoff += tex.width-4; } compOff += 2; //align off to next block } } ctx.putImageData(img, 0, 0) return canvas; } function readPalColour(view, palOff, ind, pal0trans) { var col = view.getUint16(palOff+ind*2, true); var f = 255/31; colourBuffer[0] = Math.round((col&31)*f) colourBuffer[1] = Math.round(((col>>5)&31)*f) colourBuffer[2] = Math.round(((col>>10)&31)*f) colourBuffer[3] = (pal0trans && ind == 0)?0:255; return colourBuffer; } function readFractionalPal(view, palOff, i) { var col = view.getUint16(palOff, true); var col2 = view.getUint16(palOff+2, true); var ni = 1-i; var f = 255/31; colourBuffer[0] = Math.round((col&31)*f*i + (col2&31)*f*ni) colourBuffer[1] = Math.round(((col>>5)&31)*f*i + ((col2>>5)&31)*f*ni) colourBuffer[2] = Math.round(((col>>10)&31)*f*i + ((col2>>10)&31)*f*ni) colourBuffer[3] = 255; return colourBuffer; } //end NSBTX function readChar(view, offset) { return String.fromCharCode(view.getUint8(offset)); } if (input != null) { load(input); } }