711 lines
28 KiB
JavaScript
711 lines
28 KiB
JavaScript
"use strict";
|
||
|
||
// Lost Saga 3D formats (io3DEngine): .msh meshes, .skl skeletons, .ani animations, material
|
||
// scripts and model configs. Little-endian, no compression; strings are int32 length + raw bytes
|
||
// (CP949, no terminator). Readers keep the raw bytes of everything they don't understand or the
|
||
// editor doesn't touch, so a save changes only what was edited.
|
||
|
||
const LSF = (() => {
|
||
const cp949 = new TextDecoder("euc-kr");
|
||
|
||
class Reader {
|
||
constructor(bytes) {
|
||
this.b = bytes;
|
||
this.dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||
this.o = 0;
|
||
}
|
||
need(n) {
|
||
if (this.o + n > this.b.length) throw new Error(`File ends early (offset ${this.o}, needs ${n} bytes)`);
|
||
}
|
||
i32() {
|
||
this.need(4);
|
||
const v = this.dv.getInt32(this.o, true);
|
||
this.o += 4;
|
||
return v;
|
||
}
|
||
u32() {
|
||
this.need(4);
|
||
const v = this.dv.getUint32(this.o, true);
|
||
this.o += 4;
|
||
return v;
|
||
}
|
||
f32() {
|
||
this.need(4);
|
||
const v = this.dv.getFloat32(this.o, true);
|
||
this.o += 4;
|
||
return v;
|
||
}
|
||
/** n floats as a Float32Array (copied, so it is aligned). */
|
||
f32s(n) {
|
||
this.need(n * 4);
|
||
const out = new Float32Array(this.b.slice(this.o, this.o + n * 4).buffer);
|
||
this.o += n * 4;
|
||
return out;
|
||
}
|
||
u32s(n) {
|
||
this.need(n * 4);
|
||
const out = new Uint32Array(this.b.slice(this.o, this.o + n * 4).buffer);
|
||
this.o += n * 4;
|
||
return out;
|
||
}
|
||
str() {
|
||
const n = this.i32();
|
||
if (n < 0 || n > 4096) throw new Error(`Bad string length ${n} at offset ${this.o - 4}`);
|
||
this.need(n);
|
||
const raw = this.b.slice(this.o, this.o + n);
|
||
this.o += n;
|
||
return { raw, text: cp949.decode(raw) };
|
||
}
|
||
magic(m) {
|
||
this.need(4);
|
||
const s = String.fromCharCode(this.b[this.o], this.b[this.o + 1], this.b[this.o + 2]);
|
||
if (s !== m || this.b[this.o + 3] !== 0) throw new Error(`Not a .${m.toLowerCase()} file`);
|
||
this.o += 4;
|
||
}
|
||
}
|
||
|
||
class Writer {
|
||
constructor(size = 1 << 16) {
|
||
this.b = new Uint8Array(size);
|
||
this.dv = new DataView(this.b.buffer);
|
||
this.o = 0;
|
||
}
|
||
grow(n) {
|
||
if (this.o + n <= this.b.length) return;
|
||
let size = this.b.length * 2;
|
||
while (size < this.o + n) size *= 2;
|
||
const nb = new Uint8Array(size);
|
||
nb.set(this.b);
|
||
this.b = nb;
|
||
this.dv = new DataView(nb.buffer);
|
||
}
|
||
i32(v) {
|
||
this.grow(4);
|
||
this.dv.setInt32(this.o, v, true);
|
||
this.o += 4;
|
||
}
|
||
u32(v) {
|
||
this.grow(4);
|
||
this.dv.setUint32(this.o, v >>> 0, true);
|
||
this.o += 4;
|
||
}
|
||
f32(v) {
|
||
this.grow(4);
|
||
this.dv.setFloat32(this.o, v, true);
|
||
this.o += 4;
|
||
}
|
||
bytes(u8) {
|
||
this.grow(u8.length);
|
||
this.b.set(u8, this.o);
|
||
this.o += u8.length;
|
||
}
|
||
/** A string: its original bytes when the text wasn't changed, else ASCII. */
|
||
str(s) {
|
||
const raw = s.raw && cp949.decode(s.raw) === s.text ? s.raw : ascii(s.text);
|
||
this.i32(raw.length);
|
||
this.bytes(raw);
|
||
}
|
||
done() {
|
||
return this.b.slice(0, this.o);
|
||
}
|
||
}
|
||
|
||
function ascii(text) {
|
||
const out = new Uint8Array(text.length);
|
||
for (let i = 0; i < text.length; i++) {
|
||
const c = text.charCodeAt(i);
|
||
if (c > 0x7e || c < 0x20) throw new Error(`“${text}”: only plain ASCII text can be written here.`);
|
||
out[i] = c;
|
||
}
|
||
return out;
|
||
}
|
||
const S = (text) => ({ raw: null, text }); // a new string (written as ASCII)
|
||
|
||
// ---------- .msh ----------
|
||
const MESH_TYPES = ["Static", "Animated", "Lightmap", "Billboard", "Normal billboard", "Animate effect", "Static vertex colour"];
|
||
const FVF = { POSITION: 0x1, POSITION2: 0x2, WEIGHTS: 0x8, INDICES: 0x10, NORMAL: 0x20, COLOR0: 0x40, UV0: 0x100, UV1: 0x200, TANGENT: 0x1000, BINORMAL: 0x2000 };
|
||
|
||
function parseMsh(bytes) {
|
||
const r = new Reader(bytes);
|
||
r.magic("MSH");
|
||
const m = { version: r.i32(), type: r.i32(), mask: r.u32() };
|
||
m.aabbMin = [r.f32(), r.f32(), r.f32()];
|
||
m.aabbMax = [r.f32(), r.f32(), r.f32()];
|
||
m.radius = r.f32();
|
||
const subCount = r.i32();
|
||
if (subCount < 0 || subCount > 4096) throw new Error("Bad sub-mesh count");
|
||
m.subs = [];
|
||
for (let i = 0; i < subCount; i++) m.subs.push({ minIndex: r.i32(), vertexCount: r.i32(), indexStart: r.i32(), faceCount: r.i32() });
|
||
const V = (m.vertexCount = r.i32());
|
||
if (V < 0 || V > 4e6) throw new Error("Bad vertex count");
|
||
const k = m.mask;
|
||
if (k & FVF.POSITION) m.pos = r.f32s(V * 3);
|
||
if (k & FVF.NORMAL) m.normal = r.f32s(V * 3);
|
||
if (k & FVF.TANGENT) m.tangent = r.f32s(V * 3);
|
||
if (k & FVF.BINORMAL) m.binormal = r.f32s(V * 3);
|
||
if (k & FVF.COLOR0) m.color = r.u32s(V);
|
||
if (k & FVF.UV0) m.uv0 = r.f32s(V * 2);
|
||
if (k & FVF.UV1) m.uv1 = r.f32s(V * 2);
|
||
if (k & FVF.WEIGHTS) {
|
||
const n = r.i32();
|
||
if (n < 0 || n > 1024) throw new Error("Bad bone count");
|
||
m.boneNames = [];
|
||
for (let i = 0; i < n; i++) m.boneNames.push(r.str());
|
||
// per vertex: weight[4], boneIndex[4] (indices stored as floats)
|
||
const blk = r.f32s(V * 8);
|
||
m.weights = new Float32Array(V * 4);
|
||
m.boneIdx = new Uint8Array(V * 4);
|
||
for (let v = 0; v < V; v++) {
|
||
for (let j = 0; j < 4; j++) {
|
||
m.weights[v * 4 + j] = blk[v * 8 + j];
|
||
m.boneIdx[v * 4 + j] = Math.max(0, Math.min(255, Math.round(blk[v * 8 + 4 + j])));
|
||
}
|
||
}
|
||
}
|
||
if (k & FVF.POSITION2) m.pos2 = r.f32s(V * 3);
|
||
const F = (m.faceCount = r.i32());
|
||
if (F < 0 || F > 8e6) throw new Error("Bad face count");
|
||
const at = r.o;
|
||
// Index width: 16-bit in the client files we checked; some newer engines read 32-bit.
|
||
// Pick the one whose end lines up with the file (after the control points for v2002).
|
||
const tryWidth = (w) => {
|
||
const end = at + F * 3 * w;
|
||
if (end > bytes.length) return null;
|
||
if (m.version !== 2002) return end === bytes.length ? { end, cps: [] } : null;
|
||
try {
|
||
const rr = new Reader(bytes);
|
||
rr.o = end;
|
||
const cps = readControlPoints(rr);
|
||
return rr.o === bytes.length ? { end, cps } : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
let hit = tryWidth(2);
|
||
m.index32 = false;
|
||
if (!hit && (hit = tryWidth(4))) m.index32 = true;
|
||
if (!hit) {
|
||
// unexpected trailing data: read 16-bit and keep the rest as is
|
||
hit = { end: at + F * 6, cps: [], tail: true };
|
||
if (hit.end > bytes.length) throw new Error("File ends inside the index list");
|
||
}
|
||
const ib = bytes.slice(at, hit.end).buffer;
|
||
m.indices = m.index32 ? new Uint32Array(ib) : new Uint16Array(ib);
|
||
m.bodyEnd = hit.end; // everything before the control points
|
||
m.controlPoints = hit.cps;
|
||
m.trailing = hit.tail ? bytes.slice(hit.end) : null;
|
||
m.bytes = bytes;
|
||
return m;
|
||
}
|
||
|
||
function readControlPoints(r) {
|
||
const n = r.i32();
|
||
if (n < 0 || n > 10000) throw new Error("Bad control point count");
|
||
const out = [];
|
||
for (let i = 0; i < n; i++) out.push({ name: r.str(), bone: r.str(), extra: r.str(), point: [r.f32(), r.f32(), r.f32()] });
|
||
return out;
|
||
}
|
||
|
||
/** The mesh with its control points rewritten (geometry bytes kept); v2000/2001 become 2002. */
|
||
function writeMsh(m) {
|
||
const w = new Writer(m.bodyEnd + 256);
|
||
w.bytes(m.bytes.subarray(0, m.bodyEnd));
|
||
if (m.trailing) {
|
||
w.bytes(m.trailing);
|
||
return w.done();
|
||
}
|
||
const cps = m.controlPoints || [];
|
||
if (m.version !== 2002 && !cps.length) return w.done();
|
||
w.dv.setInt32(4, 2002, true);
|
||
w.i32(cps.length);
|
||
for (const c of cps) {
|
||
w.str(c.name);
|
||
w.str(c.bone);
|
||
w.str(c.extra);
|
||
for (const v of c.point) w.f32(v);
|
||
}
|
||
return w.done();
|
||
}
|
||
|
||
/**
|
||
* A whole new mesh from its fields (the Costume tab's imports): { type, subs, pos, normal, uv0,
|
||
* boneNames: [text], weights (4 per vertex, biggest first), boneIdx, indices }. Writes v2000 with
|
||
* 16-bit indices like every client mesh; the box and radius are computed.
|
||
*/
|
||
function buildMsh(m) {
|
||
const V = m.pos.length / 3;
|
||
if (m.indices.length && Math.max(...m.indices) >= 65536) throw new Error("More than 65 536 vertices: split the mesh.");
|
||
let mask = FVF.POSITION | FVF.UV0;
|
||
if (m.normal) mask |= FVF.NORMAL;
|
||
if (m.boneNames?.length) mask |= FVF.WEIGHTS | FVF.INDICES;
|
||
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
||
for (let v = 0; v < V; v++) for (let k = 0; k < 3; k++) {
|
||
min[k] = Math.min(min[k], m.pos[v * 3 + k]);
|
||
max[k] = Math.max(max[k], m.pos[v * 3 + k]);
|
||
}
|
||
if (!V) min.fill(0), max.fill(0);
|
||
const radius = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2; // half the box diagonal, like the exporter
|
||
const w = new Writer(64 + V * 80 + m.indices.length * 2);
|
||
w.bytes(Uint8Array.of(0x4d, 0x53, 0x48, 0)); // "MSH\0"
|
||
w.i32(2000);
|
||
w.i32(m.type ?? 1);
|
||
w.u32(mask);
|
||
for (const v of [...min, ...max, radius]) w.f32(v);
|
||
w.i32(m.subs.length);
|
||
for (const s of m.subs) for (const v of [s.minIndex, s.vertexCount, s.indexStart, s.faceCount]) w.i32(v);
|
||
w.i32(V);
|
||
for (const v of m.pos) w.f32(v);
|
||
if (m.normal) for (const v of m.normal) w.f32(v);
|
||
for (const v of m.uv0) w.f32(v);
|
||
if (mask & FVF.WEIGHTS) {
|
||
w.i32(m.boneNames.length);
|
||
for (const b of m.boneNames) w.str(typeof b === "string" ? S(b) : b); // text, or a read / built { raw, text }
|
||
// per vertex: weight[4], boneIndex[4] as floats; the engine stops at the first zero weight
|
||
for (let v = 0; v < V; v++) {
|
||
for (let j = 0; j < 4; j++) w.f32(m.weights[v * 4 + j]);
|
||
for (let j = 0; j < 4; j++) w.f32(m.boneIdx[v * 4 + j]);
|
||
}
|
||
}
|
||
w.i32(m.indices.length / 3);
|
||
w.grow(m.indices.length * 2);
|
||
for (const i of m.indices) {
|
||
w.dv.setUint16(w.o, i, true);
|
||
w.o += 2;
|
||
}
|
||
return w.done();
|
||
}
|
||
|
||
// ---------- .skl ----------
|
||
function parseSkl(bytes) {
|
||
const r = new Reader(bytes);
|
||
r.magic("SKL");
|
||
const s = { version: r.i32(), bones: [] };
|
||
const n = r.i32();
|
||
if (n < 0 || n > 4096) throw new Error("Bad bone count");
|
||
for (let i = 0; i < n; i++) {
|
||
const b = { name: r.str() };
|
||
b.localPos = [r.f32(), r.f32(), r.f32()];
|
||
b.localRot = [r.f32(), r.f32(), r.f32(), r.f32()];
|
||
b.invPos = [r.f32(), r.f32(), r.f32()];
|
||
b.invRot = [r.f32(), r.f32(), r.f32(), r.f32()];
|
||
b.objectTM = Array.from(r.f32s(16));
|
||
b.parent = r.str();
|
||
const c = r.i32();
|
||
b.children = [];
|
||
for (let j = 0; j < c; j++) b.children.push(r.str().text);
|
||
s.bones.push(b);
|
||
}
|
||
s.linked = r.o < bytes.length ? r.str().text : "";
|
||
return s;
|
||
}
|
||
|
||
// ---------- .ani ----------
|
||
const ANI_VERSIONS = { 4000: "full rotations (16 bytes)", 4001: "small-three rotations (4 bytes)", 4002: "8-byte rotations" };
|
||
|
||
/** 4001: index of the largest component in bits 31-30, the other three as 10-bit fields. */
|
||
function smallThree(dw, out, o) {
|
||
const max = dw >>> 30;
|
||
const a = 2 * (((dw >>> 20) & 1023) / 1023) - 1;
|
||
const b = 2 * (((dw >>> 10) & 1023) / 1023) - 1;
|
||
const c = 2 * ((dw & 1023) / 1023) - 1;
|
||
const extra = Math.sqrt(Math.max(0, 1 - (a * a + b * b + c * c)));
|
||
const vals = [a, b, c];
|
||
for (let j = 0, k = 0; j < 4; j++) out[o + j] = j === max ? extra : vals[k++];
|
||
}
|
||
function eightBytes(hi, lo, out, o) {
|
||
out[o] = ((hi >>> 16) / 65535) * 2 - 1;
|
||
out[o + 1] = ((hi & 0xffff) / 65535) * 2 - 1;
|
||
out[o + 2] = ((lo >>> 16) / 65535) * 2 - 1;
|
||
out[o + 3] = (lo & 0xffff) / 65535;
|
||
}
|
||
|
||
/** QComp::CompSmallThree / Comp8Bytes (QuaternionCompression.cpp), the inverse of the two above. */
|
||
function compSmallThree(q) {
|
||
let mx = 0;
|
||
for (let i = 1; i < 4; i++) if (Math.abs(q[i]) > Math.abs(q[mx])) mx = i;
|
||
const s = q[mx] < 0 ? -1 : 1;
|
||
let dw = mx << 30;
|
||
for (let i = 0, k = 0; i < 4; i++) {
|
||
if (i === mx) continue;
|
||
const v = Math.trunc(((s * q[i] + 1) * 0.5) * 1023);
|
||
dw |= Math.max(0, Math.min(v, 1023)) << (10 * (2 - k++));
|
||
}
|
||
return dw >>> 0;
|
||
}
|
||
function compEight(q) {
|
||
const s = q[3] < 0 ? -1 : 1;
|
||
const c = (v) => Math.max(0, Math.min(Math.trunc(((s * v + 1) * 0.5) * 65535), 65535));
|
||
return [((c(q[0]) << 16) | c(q[1])) >>> 0, ((c(q[2]) << 16) | Math.max(0, Math.min(Math.trunc(s * q[3] * 65535), 65535))) >>> 0];
|
||
}
|
||
/** Stored rotation words of a (normalised) quaternion for an .ani version. */
|
||
function encodeRot(version, q) {
|
||
const len = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
|
||
const n = [q[0] / len, q[1] / len, q[2] / len, q[3] / len];
|
||
if (version === 4001) return [compSmallThree(n)];
|
||
if (version === 4002) return compEight(n);
|
||
const dv = new DataView(new ArrayBuffer(16));
|
||
n.forEach((v, j) => dv.setFloat32(j * 4, v, true));
|
||
return [0, 1, 2, 3].map((j) => dv.getUint32(j * 4, true));
|
||
}
|
||
/** What a stored rotation reads back as (to show edits with the precision they'll have in the game). */
|
||
function decodeRot(version, words) {
|
||
const out = [0, 0, 0, 1];
|
||
if (version === 4001) smallThree(words[0], out, 0);
|
||
else if (version === 4002) eightBytes(words[0], words[1], out, 0);
|
||
else {
|
||
const dv = new DataView(new ArrayBuffer(4));
|
||
for (let j = 0; j < 4; j++) {
|
||
dv.setUint32(0, words[j], true);
|
||
out[j] = dv.getFloat32(0, true);
|
||
}
|
||
}
|
||
const len = Math.hypot(...out) || 1;
|
||
return out.map((v) => v / len);
|
||
}
|
||
|
||
function parseAni(bytes) {
|
||
const r = new Reader(bytes);
|
||
r.magic("ANI");
|
||
const a = { version: r.i32(), events: [], tracks: [] };
|
||
if (!ANI_VERSIONS[a.version]) throw new Error(`Unknown .ani version ${a.version}`);
|
||
const ne = r.i32();
|
||
if (ne < 0 || ne > 100000) throw new Error("Bad event count");
|
||
for (let i = 0; i < ne; i++) a.events.push({ type: r.str(), sub: r.str(), time: r.f32() });
|
||
a.total = r.i32();
|
||
const nt = r.i32();
|
||
if (nt < 0 || nt > 4096) throw new Error("Bad track count");
|
||
const words = a.version === 4001 ? 1 : a.version === 4002 ? 2 : 4; // 4000: four raw floats
|
||
const bits = new DataView(new ArrayBuffer(4));
|
||
for (let i = 0; i < nt; i++) {
|
||
const t = { name: r.str(), weight: r.f32() };
|
||
const n = r.i32();
|
||
if (n < 0 || n > 1e6) throw new Error("Bad key count");
|
||
t.times = new Int32Array(n);
|
||
t.trans = new Float32Array(n * 3);
|
||
t.rot = new Float32Array(n * 4);
|
||
t.rawRot = new Uint32Array(n * words); // stored rotation words, written back untouched
|
||
for (let k = 0; k < n; k++) {
|
||
if (words === 4)
|
||
for (let j = 0; j < 4; j++) {
|
||
bits.setUint32(0, (t.rawRot[k * 4 + j] = r.u32()), true);
|
||
t.rot[k * 4 + j] = bits.getFloat32(0, true);
|
||
}
|
||
else if (words === 1) smallThree((t.rawRot[k] = r.u32()), t.rot, k * 4);
|
||
else eightBytes((t.rawRot[k * 2] = r.u32()), (t.rawRot[k * 2 + 1] = r.u32()), t.rot, k * 4);
|
||
const q = t.rot.subarray(k * 4, k * 4 + 4);
|
||
const len = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
|
||
for (let j = 0; j < 4; j++) q[j] /= len;
|
||
t.trans[k * 3] = r.f32();
|
||
t.trans[k * 3 + 1] = r.f32();
|
||
t.trans[k * 3 + 2] = r.f32();
|
||
t.times[k] = r.i32();
|
||
}
|
||
a.tracks.push(t);
|
||
}
|
||
if (r.o !== bytes.length) a.trailing = bytes.slice(r.o);
|
||
return a;
|
||
}
|
||
|
||
/** Writes an animation; rotations keep their stored bytes (no re-compression). */
|
||
function writeAni(a) {
|
||
const w = new Writer(1 << 16);
|
||
w.bytes(new Uint8Array([0x41, 0x4e, 0x49, 0]));
|
||
w.i32(a.version);
|
||
const events = a.events.slice().sort((x, y) => x.time - y.time);
|
||
w.i32(events.length);
|
||
for (const e of events) {
|
||
w.str(e.type);
|
||
w.str(e.sub);
|
||
w.f32(e.time);
|
||
}
|
||
w.i32(Math.round(a.total));
|
||
w.i32(a.tracks.length);
|
||
const words = a.version === 4001 ? 1 : a.version === 4002 ? 2 : 4;
|
||
for (const t of a.tracks) {
|
||
w.str(t.name);
|
||
w.f32(t.weight);
|
||
const n = t.times.length;
|
||
w.i32(n);
|
||
for (let k = 0; k < n; k++) {
|
||
for (let j = 0; j < words; j++) w.u32(t.rawRot[k * words + j]);
|
||
w.f32(t.trans[k * 3]);
|
||
w.f32(t.trans[k * 3 + 1]);
|
||
w.f32(t.trans[k * 3 + 2]);
|
||
w.i32(t.times[k]);
|
||
}
|
||
}
|
||
if (a.trailing) w.bytes(a.trailing);
|
||
return w.done();
|
||
}
|
||
|
||
/** Sampled local transform of a track at time t (ms): lerp position, slerp rotation. */
|
||
function sampleTrack(t, time, pos, rot) {
|
||
const n = t.times.length;
|
||
if (!n) return false;
|
||
let i = 0;
|
||
while (i < n && t.times[i] <= time) i++;
|
||
if (i === 0 || i === n) {
|
||
const k = i === 0 ? 0 : n - 1;
|
||
pos[0] = t.trans[k * 3];
|
||
pos[1] = t.trans[k * 3 + 1];
|
||
pos[2] = t.trans[k * 3 + 2];
|
||
for (let j = 0; j < 4; j++) rot[j] = t.rot[k * 4 + j];
|
||
return true;
|
||
}
|
||
const a = i - 1;
|
||
const b = i;
|
||
const span = t.times[b] - t.times[a];
|
||
const f = span > 0 ? (time - t.times[a]) / span : 0;
|
||
for (let j = 0; j < 3; j++) pos[j] = t.trans[a * 3 + j] + (t.trans[b * 3 + j] - t.trans[a * 3 + j]) * f;
|
||
slerp(t.rot, a * 4, t.rot, b * 4, f, rot);
|
||
return true;
|
||
}
|
||
|
||
function slerp(qa, oa, qb, ob, f, out) {
|
||
let ax = qa[oa], ay = qa[oa + 1], az = qa[oa + 2], aw = qa[oa + 3];
|
||
let bx = qb[ob], by = qb[ob + 1], bz = qb[ob + 2], bw = qb[ob + 3];
|
||
let cos = ax * bx + ay * by + az * bz + aw * bw;
|
||
if (cos < 0) {
|
||
cos = -cos;
|
||
bx = -bx;
|
||
by = -by;
|
||
bz = -bz;
|
||
bw = -bw;
|
||
}
|
||
let s0, s1;
|
||
if (cos > 0.9995) {
|
||
s0 = 1 - f;
|
||
s1 = f;
|
||
} else {
|
||
const th = Math.acos(cos);
|
||
const sin = Math.sin(th);
|
||
s0 = Math.sin((1 - f) * th) / sin;
|
||
s1 = Math.sin(f * th) / sin;
|
||
}
|
||
out[0] = s0 * ax + s1 * bx;
|
||
out[1] = s0 * ay + s1 * by;
|
||
out[2] = s0 * az + s1 * bz;
|
||
out[3] = s0 * aw + s1 * bw;
|
||
const len = Math.hypot(out[0], out[1], out[2], out[3]) || 1;
|
||
for (let j = 0; j < 4; j++) out[j] /= len;
|
||
}
|
||
|
||
// ---------- material script (OGRE-like text) ----------
|
||
/** → [{ passes: [{ shader, units: [{ texture, uvSet, light }], sceneBlend, alphaReject, cull, lighting }] }] */
|
||
function parseMaterial(text) {
|
||
const mats = [];
|
||
const stack = [];
|
||
let pendingBlock = null;
|
||
let mat = null;
|
||
let pass = null;
|
||
let unit = null;
|
||
for (let line of text.split(/\r\n|\r|\n/)) {
|
||
line = line.replace(/\/\/.*$/, "").trim();
|
||
if (!line) continue;
|
||
if (line === "{") {
|
||
stack.push(pendingBlock);
|
||
if (pendingBlock === "material") mats.push((mat = { passes: [] }));
|
||
else if (pendingBlock === "pass" && mat) mat.passes.push((pass = { units: [] }));
|
||
else if (pendingBlock === "texture_unit" && pass) pass.units.push((unit = { uvSet: 0 }));
|
||
pendingBlock = null;
|
||
continue;
|
||
}
|
||
if (line === "}") {
|
||
const top = stack.pop();
|
||
if (top === "texture_unit") unit = null;
|
||
else if (top === "pass") pass = null;
|
||
else if (top === "material") mat = null;
|
||
continue;
|
||
}
|
||
const [key, ...rest] = line.split(/\s+/);
|
||
if (["material", "technique", "pass", "texture_unit"].includes(key) && !rest.length) {
|
||
pendingBlock = key;
|
||
continue;
|
||
}
|
||
pendingBlock = key; // unknown block names ("technique x") still nest
|
||
if (unit) {
|
||
const wave = () => ({ type: (rest[0] || "").toLowerCase(), wave: (rest[1] || "sine").toLowerCase(), base: Number(rest[2]) || 0, freq: Number(rest[3]) || 0, phase: Number(rest[4]) || 0, amp: Number(rest[5]) || 0, once: rest[6] === "true" });
|
||
if (key === "texture") unit.texture = rest[0];
|
||
else if (key === "color_blend") unit.colorBlend = rest.map((x) => x.toLowerCase());
|
||
else if (key === "tex_coord_set") unit.uvSet = Number(rest[0]) || 0;
|
||
else if (key === "use_light_texture") unit.light = rest[0] === "true";
|
||
// texture animation (ioTextureUnitState): frames, scroll, rotate, wave transforms, colour waves
|
||
else if (key === "anim_texture") unit.frames = { count: Math.max(1, Number(rest[0]) || 1), duration: Number(rest[1]) || 1000, once: rest[2] === "true" };
|
||
else if (key === "scroll_anim") unit.scroll = { u: Number(rest[0]) || 0, v: Number(rest[1]) || 0, once: rest[2] === "true" };
|
||
else if (key === "rotate_anim") unit.rotate = { speed: Number(rest[0]) || 0, once: rest[1] === "true" };
|
||
else if (key === "wave_xform") (unit.waves ||= []).push(wave());
|
||
else if (key === "color_xform") (unit.colorWaves ||= []).push(wave());
|
||
else if (key === "scale_uv_on_center") unit.scaleCenter = rest[0] === "true";
|
||
} else if (pass) {
|
||
if (key === "shader_group") pass.shader = rest[0];
|
||
else if (key === "scene_blend") pass.sceneBlend = rest.join(" ");
|
||
else if (key === "alpha_rejection") pass.alphaReject = rest.join(" ");
|
||
else if (key === "cull") pass.cull = rest[0];
|
||
else if (key === "lighting") pass.lighting = rest[0] !== "false";
|
||
else if (key === "depth_write") pass.depthWrite = rest[0] !== "false";
|
||
} else if (mat && key === "diffuse") {
|
||
mat.diffuse = rest.slice(0, 3).map(Number);
|
||
}
|
||
}
|
||
return mats;
|
||
}
|
||
|
||
/**
|
||
* What the viewer draws for one material: the first real texture of the first non-outline pass.
|
||
* LightMap passes have the lightmap first (UV set 0) and the diffuse after it (usually set 1).
|
||
*/
|
||
function diffuseOf(mat) {
|
||
for (const p of mat?.passes || []) {
|
||
if (/outline/i.test(p.shader || "")) continue;
|
||
const units = p.units.filter((x) => x.texture && !x.light);
|
||
if (!units.length) continue;
|
||
const lightmap = /lightmap/i.test(p.shader || "") && units.length > 1;
|
||
const u = lightmap ? units[units.length - 1] : units[0];
|
||
const animOf = (x) => (x && (x.frames || x.scroll || x.rotate || x.waves || x.colorWaves) ? { frames: x.frames || null, scroll: x.scroll || null, rotate: x.rotate || null, waves: x.waves || [], colorWaves: x.colorWaves || [], scaleCenter: !!x.scaleCenter } : null);
|
||
return {
|
||
texture: u.texture,
|
||
uvSet: u.uvSet,
|
||
anim: animOf(u),
|
||
lightmap: lightmap ? units[0].texture : null,
|
||
lightmapSet: lightmap ? units[0].uvSet : 0,
|
||
lightmapAnim: lightmap ? animOf(units[0]) : null,
|
||
lmAddDiffuse: lightmap && units[0].colorBlend?.join(" ") === "add texture diffuse",
|
||
diffuseColor: mat.diffuse || [1, 1, 1],
|
||
blend: p.sceneBlend || "",
|
||
alphaReject: p.alphaReject || "",
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ---------- model config (#MODEL_CFG) ----------
|
||
function parseModelCfg(text) {
|
||
const cfg = { skeleton: "", animations: [], meshes: [] };
|
||
const lines = text.split(/\r\n|\r|\n/).map((l) => l.trim());
|
||
let list = null;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const l = lines[i];
|
||
if (!l || l.startsWith(";") || l.startsWith("//")) continue; // commented-out entries
|
||
if (l === "#SKELETON") cfg.skeleton = lines[++i] || "";
|
||
else if (l === "#ANIMATION_LIST") list = cfg.animations;
|
||
else if (l === "#MESH_LIST") list = cfg.meshes;
|
||
else if (l === "#ANIMATION_LIST_END" || l === "#MESH_LIST_END") list = null;
|
||
else if (l === "#MODEL_COLOR") cfg.color = (lines[++i] || "").split(/\s+/).map(Number);
|
||
else if (l === "#AABOX") cfg.aabb = [(lines[++i] || "").split(/\s+/).map(Number), (lines[++i] || "").split(/\s+/).map(Number)];
|
||
else if (l === "#COLLISION_MESH") cfg.collision = lines[++i] || "";
|
||
else if (l === "#OUTLINE") {
|
||
// file, on/off, colour (r g b), thickness
|
||
cfg.outline = { file: lines[++i] || "", on: (lines[++i] || "") !== "0", color: (lines[++i] || "").split(/\s+/).map(Number), size: Number(lines[++i]) || 0 };
|
||
}
|
||
else if (l.startsWith("#")) list = null;
|
||
else if (list) list.push(l);
|
||
}
|
||
return cfg;
|
||
}
|
||
|
||
// ---------- .cms collision mesh (ioOpcodeManagerImpl::LoadOpcodeShapeMesh) ----------
|
||
/** "CMS\0", version, vertex count, float3[], face count, uint16[3 * faces]. */
|
||
function parseCms(bytes) {
|
||
const r = new Reader(bytes);
|
||
r.magic("CMS");
|
||
const c = { version: r.i32() };
|
||
// 5000 = world / object collision; 5500 / 5700 are character attack / hit box groups
|
||
if (c.version !== 5000) throw new Error(`Not a collision mesh (version ${c.version}: a character box group)`);
|
||
const V = r.i32();
|
||
if (V < 0 || V > 4e6) throw new Error("Bad collision vertex count");
|
||
c.pos = r.f32s(V * 3);
|
||
const F = r.i32();
|
||
if (F < 0 || F > 8e6) throw new Error("Bad collision face count");
|
||
r.need(F * 6);
|
||
c.indices = new Uint16Array(bytes.slice(r.o, r.o + F * 6).buffer);
|
||
return c;
|
||
}
|
||
|
||
// ---------- .edg outline edges (ioEdgeRender::LoadFileInternal) ----------
|
||
/** vertices + normals, face normals, edges { a, b (vertices), fa, fb (faces, 0xFFFF = open) }. */
|
||
function parseEdg(bytes) {
|
||
const r = new Reader(bytes);
|
||
r.magic("EDG");
|
||
const e = { version: r.i32() };
|
||
const V = r.i32();
|
||
if (V < 0 || V > 4e6) throw new Error("Bad edge vertex count");
|
||
e.pos = r.f32s(V * 3);
|
||
e.normal = r.f32s(V * 3);
|
||
const F = r.i32();
|
||
if (F < 0 || F > 8e6) throw new Error("Bad edge face count");
|
||
e.faceNormals = r.f32s(F * 3);
|
||
const E = r.i32();
|
||
if (E < 0 || E > 8e6) throw new Error("Bad edge count");
|
||
r.need(E * 8);
|
||
e.edges = new Uint16Array(bytes.slice(r.o, r.o + E * 8).buffer); // a, b, fa, fb per edge
|
||
return e;
|
||
}
|
||
|
||
// ---------- texture animation at time t (seconds), like ioTexture*Effect + RecalcTextureMatrix ----------
|
||
const waveValue = (w, t) => {
|
||
let x = t * w.freq;
|
||
x = w.once && x >= 1 ? 1 : x - Math.floor(x);
|
||
let o = 0;
|
||
if (w.wave === "sine") o = Math.sin(x * Math.PI * 2);
|
||
else if (w.wave === "triangle") o = x < 0.25 ? x * 4 : x <= 0.75 ? 1 - (x - 0.25) * 4 : (x - 0.75) * 4 - 1;
|
||
else if (w.wave === "square") o = x <= 0.5 ? 1 : -1;
|
||
else if (w.wave === "sawtooth") o = x * 2 - 1;
|
||
else if (w.wave === "inverse_sawtooth") o = 1 - x * 2;
|
||
return w.base + (o + 1) * 0.5 * w.amp;
|
||
};
|
||
const wrap = (v, once) => (once ? Math.max(-1, Math.min(1, v)) : v - Math.trunc(v));
|
||
|
||
/** → { m: Float32Array(9) for vec3(uv,1) * m (GLSL), frame, colorMul, emissive } */
|
||
function texAnimAt(a, t) {
|
||
let uMod = 0, vMod = 0, rot = 0, uScale = 1, vScale = 1;
|
||
if (a.scroll) {
|
||
uMod = wrap(-a.scroll.u * t, a.scroll.once);
|
||
vMod = wrap(-a.scroll.v * t, a.scroll.once);
|
||
}
|
||
if (a.rotate) rot = (-a.rotate.speed * t) % 360;
|
||
let colorMul = 1, emissive = 0;
|
||
for (const w of a.waves) {
|
||
const r = waveValue(w, t);
|
||
if (w.type === "translate_u") uMod = r;
|
||
else if (w.type === "translate_v") vMod = r;
|
||
else if (w.type === "rotate") rot = r * 360;
|
||
else if (w.type === "scale_u") uScale = r >= 0 ? 1 + r : 1 / -r;
|
||
else if (w.type === "scale_v") vScale = r >= 0 ? 1 + r : 1 / -r;
|
||
}
|
||
for (const w of a.colorWaves) {
|
||
const r = waveValue(w, t);
|
||
if (w.type === "diffuse" || w.type === "ambient") colorMul *= w.type === "diffuse" ? r : 0.5 + 0.5 * r; // approximation of the pass colour rates
|
||
else if (w.type === "emissive") emissive += r;
|
||
}
|
||
// D3D row-vector 3×3 (uv·M): scale (optionally on the centre), then translate, then rotate about (0.5, 0.5)
|
||
const mul = (A, B) => {
|
||
const o = new Array(9);
|
||
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) o[r * 3 + c] = A[r * 3] * B[c] + A[r * 3 + 1] * B[3 + c] + A[r * 3 + 2] * B[6 + c];
|
||
return o;
|
||
};
|
||
let X = [1 / uScale, 0, 0, 0, 1 / vScale, 0, 0, 0, 1];
|
||
if (a.scaleCenter) {
|
||
X[6] = -0.5 * X[0] + 0.5;
|
||
X[7] = -0.5 * X[4] + 0.5;
|
||
}
|
||
if (uMod || vMod) X = mul([1, 0, 0, 0, 1, 0, uMod, vMod, 1], X);
|
||
if (rot) {
|
||
const th = (rot * Math.PI) / 180, c = Math.cos(th), s = Math.sin(th);
|
||
X = mul([c, s, 0, -s, c, 0, 0.5 + (-0.5 * c - -0.5 * s), 0.5 + (-0.5 * s + -0.5 * c), 1], X);
|
||
}
|
||
// GLSL mat3 columns = the D3D matrix's columns, used as vec3(uv, 1) * m
|
||
const m = new Float32Array([X[0], X[3], X[6], X[1], X[4], X[7], X[2], X[5], X[8]]);
|
||
let frame = 0;
|
||
if (a.frames && a.frames.count > 1) {
|
||
let r = (t * 1000) / a.frames.duration;
|
||
r = a.frames.once && r >= 1 ? 0.999 : r - Math.floor(r);
|
||
frame = Math.min(a.frames.count - 1, Math.floor(r * a.frames.count));
|
||
}
|
||
return { m, frame, colorMul, emissive };
|
||
}
|
||
|
||
const decode = (bytes) => cp949.decode(bytes);
|
||
return { texAnimAt, parseMsh, writeMsh, buildMsh, parseCms, parseEdg, parseSkl, parseAni, writeAni, encodeRot, decodeRot, sampleTrack, slerp, parseMaterial, diffuseOf, parseModelCfg, decode, S, MESH_TYPES, FVF, ANI_VERSIONS };
|
||
})();
|
||
window.LSF = LSF;
|