479 lines
20 KiB
JavaScript
479 lines
20 KiB
JavaScript
//
|
|
// 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;
|
|
|
|
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") {
|
|
this.particles = [];
|
|
for (let i=0; i<particleCount; i++) {
|
|
this.particles[i] = readParticle(view, offset);
|
|
offset = this.particles[i].nextOff;
|
|
}
|
|
}
|
|
|
|
offset = firstTexOffset;
|
|
this.particleTextures = [];
|
|
for (let i=0; i<particleTexCount; i++) {
|
|
this.particleTextures[i] = readParticleTexture(view, offset);
|
|
offset = this.particleTextures[i].nextOff;
|
|
}
|
|
|
|
if (window.debugParticle) {
|
|
for (let i=0; i<particleCount; i++) {
|
|
var text = document.createElement("textarea");
|
|
var p = this.particles[i];
|
|
text.value = JSON.stringify(p, true, 4);
|
|
text.style.width = 500;
|
|
text.style.height = 200;
|
|
|
|
|
|
var obj = this.particleTextures[p.textureId];
|
|
if (p.texAnim) obj = this.particleTextures[p.texAnim.textures[0]];
|
|
if (obj == null) {
|
|
continue;
|
|
}
|
|
var test = readTexWithPal(obj.info, obj);
|
|
document.body.appendChild(test);
|
|
document.body.appendChild(text);
|
|
document.body.appendChild(document.createElement("br"));
|
|
}
|
|
}
|
|
}
|
|
|
|
function readParticle(view, off) {
|
|
var obj = {};
|
|
var ParticleFlags =
|
|
{
|
|
Type0: 0,
|
|
//1: random sphere
|
|
//2: random ground
|
|
//3: rotation invariant
|
|
//4+: unpredictable past here
|
|
Type1: 0x10, //spark type
|
|
Type2: 0x20, //3d, makes rotation around billboard axis
|
|
Type3: 0x30, //spark 3d
|
|
Type4: 0x40, //billboard
|
|
Type5: 0x80, //billboard but ignores ground setting? maybe 2d
|
|
ScaleAnim: 0x100,
|
|
ColorAnimation: 0x200,
|
|
OpacityAnimation: 0x400,
|
|
TextureAnimation: 0x800,
|
|
Unknown: 0x1000,
|
|
RandomDirection: 0x2000,
|
|
CrashGame: 0x4000,
|
|
AttachedToEmitter: 0x8000,
|
|
Bit16: 0x10000,
|
|
Bit21: 0x200000,
|
|
Bit22: 0x400000,
|
|
Bit23: 0x800000,
|
|
Gravity: 0x1000000,
|
|
Bit25: 0x2000000, //balloon? perhaps sine rotation (as it flies away)
|
|
Bit26: 0x4000000,
|
|
Bit27: 0x8000000,
|
|
Bit28: 0x10000000,
|
|
Bit29: 0x20000000
|
|
}
|
|
|
|
obj.flag = view.getUint32(off, true);
|
|
obj.position = [view.getInt32(off+0x4, true)/4096, view.getInt32(off+0x8, true)/4096, view.getInt32(off+0xC, true)/4096];
|
|
//this is just hilarious at this point
|
|
//the best approach here is to look at each particle on a case by case basis, seeing how each particle behaves ingame
|
|
obj.particleChance = view.getInt32(off+0x10, true)/4096; //if less than 1, pct chance a particle will appear on that frame. >1 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); //random xz velocity intensity
|
|
obj.velocity = view.getUint32(off+0x28, true); //initial velocity related
|
|
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.unknown13 = view.getUint16(off+0x38, true); //??? (0)
|
|
obj.unknown14 = view.getUint16(off+0x3A, true); //??? (4B)
|
|
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.getUint16(off+0x50, true); //x scale delta for some reason. usually 0
|
|
obj.yScaleDelta = view.getUint16(off+0x52, true); //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,
|
|
param: view.getUint16(off+6, true),
|
|
flagParam: view.getUint16(off+8, true),
|
|
unk4b: view.getUint16(off+10, true),
|
|
};
|
|
off += 12;
|
|
}
|
|
if ((obj.flag & ParticleFlags.ColorAnimation) != 0)
|
|
{
|
|
obj.ColorAnimation = {
|
|
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 (let 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 (let 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 (let i=0; i<8; i++) obj.Bit25[i] = view.getUint8(off+i);
|
|
off += 8;
|
|
}
|
|
if ((obj.flag & ParticleFlags.Bit26) != 0)
|
|
{
|
|
obj.Bit26 = [];
|
|
for (let i=0; i<16; i++) obj.Bit26[i] = view.getUint8(off+i);
|
|
off += 16;
|
|
}
|
|
if ((obj.flag & ParticleFlags.Bit27) != 0)
|
|
{
|
|
obj.Bit27 = [];
|
|
for (let i=0; i<4; i++) obj.Bit27[i] = view.getUint8(off+i);
|
|
off += 4;
|
|
}
|
|
if ((obj.flag & ParticleFlags.Bit28) != 0)
|
|
{
|
|
obj.Bit28 = [];
|
|
for (let i=0; i<8; i++) obj.Bit28[i] = view.getUint8(off+i);
|
|
off += 8;
|
|
}
|
|
if ((obj.flag & ParticleFlags.Bit29) != 0)
|
|
{
|
|
obj.Bit29 = [];
|
|
for (let 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: (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<total; i++) {
|
|
var col;
|
|
if (format == 1) { //A3I5 encoding. 3 bits alpha 5 bits pal index
|
|
var dat = texView.getUint8(off++)
|
|
col = readPalColour(palView, palOff, dat&31, trans);
|
|
col[3] = (dat>>5)*(255/7);
|
|
|
|
} 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);
|
|
|
|
} 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;
|
|
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 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<h; y++) {
|
|
for (var x=0; x<w; x++) {
|
|
//inside block
|
|
var bInfo = compView.getUint16(compOff, true); //block info
|
|
|
|
var addr = (bInfo & 0x3fff); //offset to relevant pallete
|
|
var mode = ((bInfo >> 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);
|
|
}
|
|
} |