LosaDev Tools: Lost Saga modding tools in the browser

This commit is contained in:
LosaDev committed 2026-11-07 23:08:43 +07:00
commit c5e04472fb
105 files changed
+38541

No files matched your search

+710
View File
@@ -0,0 +1,710 @@
"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;