Files

711 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;