"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;