"use strict"; // Costume Studio (Costume tab): the hero body (sp2_human_man / _woman) in its rest pose, a 3D model // imported onto it (.glb / .gltf / .obj) with weights from its own bones or from the body, played // with any animation, and saved as a costume mesh (.msh + material script + .dds). "Body for // Blender" writes what is shown as a rigged .glb to model on. The maths is in costumekit.js. (() => { const CK = CostumeKit; const COS = { src: null, rows: [], kind: "msh", sex: "man", sk: null, P: null, // rest pose surface: null, // the hero body's surface (weights for the costume) base: [], // the hero's own meshes worn: [], // meshes put on from the list anims: [], ani: null, // { data, map, name } time: 0, playing: false, tpose: true, file: null, // { path, name, data (loadGltf / parseObj), meshNames } mats: [], // per file material: { name, color, image: { name, bytes, mime }, img, tex } out: {}, // part → CK.build result (body, hand, helmet, cloak) views: {}, // part → the mesh in the view opts: { parts: {}, hideFace: true, weights: "auto", bone: 0, scale: 0, offset: [0, 0, 0], rotY: 0, maxBones: CK.MAX_BONES, include: null, name: "", hw: true, outline: true, texMax: 1024, splitHands: true, outfit: false, skinTol: 1 }, dirty: false, }; window.COS = COS; try { COS.texDir = localStorage.getItem("cos:texDir") || null; // the last textures folder picked: looked in again } catch {} let view = null; let kit = null; const stem = (p) => p.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, ""); const dirOf = (p) => p.replace(/[\\/][^\\/]*$/, ""); const visible = () => $("view-cos").classList.contains("on"); const toggled = (id) => $(id).classList.contains("on"); // the costume parts a mesh of the file can be: what it is in the game and which hero mesh it replaces const PARTS = { body: { label: "Body", ini: "armor", key: "body_mesh", hides: /(^|_)body/i }, hand: { label: "Hands", ini: "armor", key: "hand_mesh", hides: /hand/i }, helmet: { label: "Helmet / head", ini: "helmet", key: "helmet_mesh", hides: /hair/i }, cloak: { label: "Cloak / back", ini: "cloak", key: "cloak_mesh", hides: null }, skip: { label: "— leave out —" }, }; const PART_ORDER = ["body", "hand", "helmet", "cloak"]; /** The file name of a part: _body_01, _hand_01 … */ const outName = (part) => `${COS.opts.name}_${part}_01`; /** The first built part (the materials / report card start from it). */ const anyOut = () => PART_ORDER.map((p) => COS.out[p]).find(Boolean) || null; function ensureView() { if (view) return view; try { view = new GL3D.View($("cos-canvas")); } catch (err) { toast(`3D view unavailable: ${err.message}`, true); return null; } kit = MeshKit.create(view, () => COS.src, { prepareTexture: skinTone }); COS.view = view; return view; } /** * The hero textures keep the skin as transparent black (the game blends in #MODEL_COLOR there: * blend_texture_alpha texture tfactor); the preview does the same, else the skin looks black. */ function skinTone({ img }) { const c = COS.skin || [243, 210, 143]; let d; if (img instanceof ImageData) d = img; else { const cv = new OffscreenCanvas(img.width, img.height); const g = cv.getContext("2d"); g.drawImage(img, 0, 0); d = g.getImageData(0, 0, img.width, img.height); } const p = d.data; let clear = 0, dark = 0; for (let i = 0; i < p.length; i += 4) if (p[i + 3] < 128) (clear++, p[i] + p[i + 1] + p[i + 2] < 72 && dark++); if (!clear || dark < clear * 0.9) return img; // real transparency (cut-outs, glass): leave it const out = new ImageData(new Uint8ClampedArray(p), d.width, d.height); const o = out.data; for (let i = 0; i < o.length; i += 4) { const a = o[i + 3] / 255; for (let k = 0; k < 3; k++) o[i + k] = c[k] * (1 - a) + o[i + k] * a; o[i + 3] = 255; } return out; } function loading(on, text = "Loading…") { $("cos-loading").hidden = !on; $("cos-loading").querySelector("span").textContent = text; } // ===================================================================== // source + list // ===================================================================== async function setSource(src) { try { const d = await call("mdl:describe", src); COS.src = { ...src, label: d.label }; } catch (err) { return toast(err.message, true); } $("cos-src-label").textContent = COS.src.kind === "dir" ? "Extracted folder" : COS.src.kind === "packs" ? "Pack folder" : `Packs next to ${COS.src.name}`; $("cos-src").title = `Reading from ${COS.src.label}`; kit?.clearTextures(); await loadList(); await loadHero(); } async function chooseSource() { const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); const options = [ ...profileSourceOption(), ...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })), { value: "packs", title: "A folder with .iop packs…", sub: "the client folder: map.iop, map2.iop, rs.iop…" }, { value: "dir", title: "A folder of extracted files…", sub: "resource/skeleton, resource/mesh, resource/model…" }, ]; let choice = options[0].value; const list = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [el("input", { type: "radio", name: "cossrc", checked: i === 0, onchange: () => (choice = o.value) }), el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })])]))); if (!(await modal("Where is the hero body?", ["The hero skeleton, body meshes and animations are spread over several packs. Pick the folder they are in.", list], [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return false; let src = choice; if (choice === "packs" || choice === "dir") { const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files"); if (!folder) return false; src = { kind: choice, folder }; } await setSource(src); return true; } $("cos-src").onclick = () => chooseSource(); const list = new VTable($("cos-list"), [{ key: 1, label: "Name", width: 200, grow: true, title: true }, { key: 2, label: "Pack", width: 70, cls: "mono dim" }], { multi: false, id: "cos" }); list.keyOf = (r) => r[0]; list.sortBy(1); async function loadList() { if (!COS.src) return; loading(true, "Reading the pack index…"); try { const rows = await call("mdl:list", COS.src); COS.rows = rows .map(([k, path, pack]) => { const l = path.toLowerCase(); const kind = l.endsWith(".msh") ? "msh" : l.endsWith(".ani") ? "ani" : null; return kind && [k, stem(path), pack || path.split("/").slice(0, -1).join("/"), kind, path]; }) .filter(Boolean); } catch (err) { toast(err.message, true); COS.rows = []; } finally { loading(false); } filterList(); } function filterList() { const q = $("cos-search").value.trim().toLowerCase(); const rows = COS.rows.filter((r) => r[3] === COS.kind && (!q || r[1].toLowerCase().includes(q))); list.setRows(rows); $("cos-count").textContent = rows.length.toLocaleString(); } let searchTimer = 0; $("cos-search").addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(filterList, 120); }); for (const b of $("cos-kinds").children) b.onclick = () => { COS.kind = b.dataset.kind; for (const x of $("cos-kinds").children) x.classList.toggle("on", x === b); filterList(); }; list.onOpen = (r) => openRow(r); $("cos-list").addEventListener("contextmenu", (e) => { const rowEl = e.target.closest(".vt-row"); if (!rowEl) return; e.preventDefault(); const r = list.view[Number(rowEl.dataset.i)]; const items = r[3] === "msh" ? [{ label: "Put on the hero", icon: "plus", run: () => openRow(r) }] : [{ label: "Play on the hero", icon: "play", run: () => openRow(r) }]; items.push("-", { label: "Copy name", icon: "copy", run: () => navigator.clipboard.writeText(r[4].split("/").pop()) }); showMenu(items, { x: e.clientX, y: e.clientY }); }); async function openRow(r) { if (!r || !COS.sk) return; try { const hit = await call("mdl:readKey", COS.src, r[0]); if (!hit) throw new Error("Not found in the packs any more."); if (r[3] === "msh") await wear(hit, r[1]); else await playAni(hit, `${r[1]}.ani`); } catch (err) { toast(err.message, true); } } const readNamed = (name, folder, strict = true) => (COS.src ? call("mdl:read", COS.src, name, folder, strict) : null); // ===================================================================== // the hero // ===================================================================== async function loadHero() { if (!COS.src || !ensureView()) return; loading(true, "Loading the hero…"); try { const model = await kit.readModel(`animodel/sp2_human_${COS.sex}.txt`); if (!model) throw new Error(`model/animodel/sp2_human_${COS.sex}.txt isn't in these packs.`); const cfg = model.cfg; if (cfg.color?.length >= 3 && String(cfg.color) !== String(COS.skin)) { COS.skin = cfg.color.slice(0, 3); kit.clearTextures(); } const sh = await readNamed(cfg.skeleton, "skeleton"); if (!sh) throw new Error(`${cfg.skeleton} isn't in these packs.`); const data = LSF.parseSkl(sh.bytes); const sk = SkinKit.prepare(data); if (data.linked) { const lh = await readNamed(data.linked, "skeleton"); if (!lh || !SkinKit.link(sk, LSF.parseSkl(lh.bytes))) toast(`${cfg.skeleton} links to ${data.linked}, which isn't in these packs; the body may look off.`, true); } sk.name = cfg.skeleton; for (const m of [...COS.base, ...COS.worn]) view.removeMesh(m.id); COS.sk = sk; COS.P = CK.restPose(sk); COS.base = []; COS.worn = []; const missing = []; for (const n of cfg.meshes) { const h = await readNamed(`${n}.msh`, "mesh"); if (!h) { missing.push(n); continue; } const m = await kit.loadMesh(h, n); m.palette = SkinKit.palette(sk, m.data); COS.base.push(m); kit.loadTextures(m).then(() => (view.dirty = true)); } const posedBase = COS.base.map((m) => CK.posed(sk, COS.P, m.data)); COS.surface = CK.surface(posedBase); // outfit only: the hero's body and hands go into the costume, its face tells the model's face apart COS.heroParts = {}; COS.base.forEach((m, i) => { const k = /hand/i.test(m.name) ? "hand" : /face/i.test(m.name) ? "face" : /hair/i.test(m.name) ? null : /body/i.test(m.name) ? "body" : null; if (k && !COS.heroParts[k]) COS.heroParts[k] = { i, m, posed: posedBase[i] }; }); COS.faceSurface = COS.heroParts.face ? CK.surface([COS.heroParts.face.posed]) : null; // what Fit to the LS body measures: this hero's body, hands and face (not its hair) COS.hero = CK.bodyPoints(posedBase.filter((_, i) => !/hair/i.test(COS.base[i].name))); COS.anims = cfg.animations; renderAnimSelect(); $("cos-empty").hidden = true; if (missing.length) toast(`Not in these packs: ${missing.join(", ")}`, true); if (COS.ani) COS.ani.map = SkinKit.mapTracks(sk, COS.ani.data); if (COS.file) rebuild(); frame(); applyPose(); renderSide(); } catch (err) { toast(err.message, true); } finally { loading(false); } } for (const b of $("cos-sex").children) b.onclick = () => { if (COS.sex === b.dataset.sex) return; COS.sex = b.dataset.sex; for (const x of $("cos-sex").children) x.classList.toggle("on", x === b); loadHero(); }; async function wear(hit, name) { const m = await kit.loadMesh(hit, name); m.palette = SkinKit.palette(COS.sk, m.data); COS.worn.push(m); kit.loadTextures(m).then(() => (view.dirty = true)); // a costume body / hands / helmet replaces the hero's own one for (const s of Object.values(PARTS)) if (s.hides && s.hides.test(name)) for (const b of COS.base) if (s.hides.test(b.name)) b.hidden = true; applyPose(); renderSide(); } // ===================================================================== // animation + pose // ===================================================================== function renderAnimSelect() { const sel = $("cos-anim"); const names = [...new Set([...(COS.ani ? [COS.ani.name] : []), ...COS.anims])]; sel.replaceChildren(el("option", { value: "", text: "— rest pose (T-pose) —" }), ...names.map((n) => el("option", { value: n, text: n, selected: COS.ani?.name === n && !COS.tpose }))); } $("cos-anim").onchange = async () => { const n = $("cos-anim").value; if (!n) return showTpose(); const h = await readNamed(n, "animation", false); if (!h) return toast(`${n} isn't in these packs.`, true); playAni(h, n); }; async function playAni(hit, name) { if (!COS.sk) return; const data = LSF.parseAni(hit.bytes); COS.ani = { data, map: SkinKit.mapTracks(COS.sk, data), name, total: data.total || Math.max(1, ...data.tracks.map((t) => t.times?.[t.times.length - 1] || 0)) }; COS.time = 0; COS.tpose = false; renderAnimSelect(); play(); } function showTpose() { stop(); COS.tpose = true; renderAnimSelect(); applyPose(); } $("cos-tpose").onclick = showTpose; let raf = 0, last = 0; function play() { if (!COS.ani) return; COS.tpose = false; COS.playing = true; $("cos-play").replaceChildren(icon("pause")); last = performance.now(); cancelAnimationFrame(raf); const tick = (now) => { if (!COS.playing) return; COS.time = (COS.time + (now - last)) % Math.max(1, COS.ani.total); last = now; if (visible()) applyPose(); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); } function stop() { COS.playing = false; cancelAnimationFrame(raf); $("cos-play").replaceChildren(icon("play")); } $("cos-play").onclick = () => (COS.playing ? stop() : COS.ani ? play() : toast("Pick an animation (the list below the view, or Anims on the left).")); $("cos-scrub").oninput = () => { if (!COS.ani) return; stop(); COS.tpose = false; COS.time = (Number($("cos-scrub").value) / 1000) * COS.ani.total; applyPose(); }; const allMeshes = () => [...COS.base, ...COS.worn, ...PART_ORDER.map((p) => COS.views[p]).filter(Boolean)]; function applyPose() { if (!view || !COS.sk) return; const ps = COS.tpose || !COS.ani ? COS.P : SkinKit.pose(COS.sk, COS.ani.data, COS.ani.map, COS.time); for (const m of allMeshes()) { view.setVisible(m.id, !m.hidden); if (m.hidden || !m.palette) continue; SkinKit.skin(m, m.palette, ps); view.updateMesh(m.id, m.skinPos, m.skinNrm); } // bones if (toggled("cos-t-bones")) { const l = []; COS.sk.parent.forEach((pi, i) => { if (pi < 0) return; l.push(...ps.wp[pi], 0.96, 0.79, 0.35, 1, ...ps.wp[i], 0.96, 0.79, 0.35, 1); }); view.setOverlay("bones", new Float32Array(l), { depth: false }); } else view.setOverlay("bones", null); view.opts.wire = toggled("cos-t-wire"); const t = COS.ani && !COS.tpose ? COS.time : 0; $("cos-clock").textContent = COS.ani && !COS.tpose ? `${(t / 1000).toFixed(2)} / ${(COS.ani.total / 1000).toFixed(2)} s` : "rest pose"; if (!$("cos-scrub").matches(":active")) $("cos-scrub").value = COS.ani ? String(Math.round((t / COS.ani.total) * 1000)) : "0"; view.dirty = true; } function frame() { view.frame([-60, 0, -30], [60, 170, 30]); const g = []; const c = [1, 1, 1, 0.16], a = [1, 1, 1, 0.4]; for (let v = -100; v <= 100; v += 20) { const col = v === 0 ? a : c; g.push(v, 0, -100, ...col, v, 0, 100, ...col, -100, 0, v, ...col, 100, 0, v, ...col); } view.setOverlay("grid", new Float32Array(g)); view.showOverlay("grid", toggled("cos-t-grid")); } for (const id of ["cos-t-bones", "cos-t-grid", "cos-t-wire"]) $(id).onclick = () => { $(id).classList.toggle("on"); view?.showOverlay("grid", toggled("cos-t-grid")); applyPose(); }; $("cos-reset").onclick = () => view && frame(); // ===================================================================== // import // ===================================================================== async function readImage(im) { if (!im?.bytes) return null; const n = (im.name || "").toLowerCase(); if (n.endsWith(".dds") || (im.bytes[0] === 0x44 && im.bytes[1] === 0x44 && im.bytes[2] === 0x53)) return TexDecode.dds(im.bytes); if (n.endsWith(".tga")) return TexDecode.tga(im.bytes); return createImageBitmap(new Blob([im.bytes], { type: im.mime || "image/png" }), { premultiplyAlpha: "none" }); } /** * A file a model points to: where it says, next to the model, or in a textures folder near it (Blender * often writes broken absolute paths such as C:/11.png). → bytes (throws when not found) */ async function findFile(uri, dir) { const name = uri.split(/[\\/]/).pop(); const abs = /^([a-z]:|[\\/])/i.test(uri); const up = dirOf(dir); const tries = [abs ? uri : `${dir}/${uri}`, `${dir}/${name}`, `${dir}/textures/${name}`, `${dir}/texture/${name}`, `${up}/textures/${name}`, `${up}/texture/${name}`, ...(COS.texDir ? [`${COS.texDir}/${name}`] : [])]; for (const f of tries) { const hit = await call("mdl:readFile", f).catch(() => null); if (hit) return hit.bytes; } throw new Error(`${name} not found`); } async function importFile(path) { if (!COS.sk && !(COS.src || (await chooseSource()))) return; if (!COS.sk) return toast("The hero body didn't load; pick the game's packs first.", true); loading(true, "Reading the 3D model…"); try { const dir = dirOf(path); const readRel = (uri) => findFile(uri, dir); const bytes = (await call("mdl:readFile", path)).bytes; let data; if (/\.obj$/i.test(path)) { const text = new TextDecoder().decode(bytes); const lib = /^mtllib\s+(.+)$/m.exec(text)?.[1]?.trim(); let mtl = ""; if (lib) mtl = await readRel(lib).then((b) => new TextDecoder().decode(b)).catch(() => ""); data = CK.parseObj(text, mtl); for (const im of data.images) im.bytes = await readRel(im.name).catch(() => null); for (const im of data.images) im.name = im.name.split(/[\\/]/).pop(); } else if (/\.(glb|gltf)$/i.test(path)) data = await CK.loadGltf(bytes, readRel); else if (/\.fbx$/i.test(path)) { data = await CK.parseFbx(bytes); for (const im of data.images) im.bytes = await readRel(im.name).catch(() => null); for (const im of data.images) im.name = im.name.split(/[\\/]/).pop(); } else throw new Error("Use a .glb, .gltf, .obj or .fbx file (Blender: File → Export → glTF 2.0)."); await texturesFromJson(data, readRel); for (const m of COS.mats) m.tex && view.gl.deleteTexture(m.tex); COS.mats = await Promise.all( data.materials.map(async (m) => { const image = m.image >= 0 ? data.images[m.image] : null; const img = await readImage(image).catch(() => null); return { name: m.name, color: m.color, image: image?.bytes ? image : null, wanted: image && !img ? image.name : null, img, tex: img ? view.texture(img) : null }; }), ); COS.file = { path, name: path.split(/[\\/]/).pop(), data, meshNames: data.meshes.map((m) => m.name) }; const auto = CK.autoScale(COS.sk, COS.P, data); COS.file.auto = auto; COS.texPix = null; // a rig whose bones aren't the hero's names: fit it onto the hero's proportions const named = CK.jointMap(COS.sk, data.joints).filter((b) => b >= 0).length; COS.opts.fit = data.joints.length > 0 && named < 8; Object.assign(COS.opts, { scale: 0, offset: [0, 0, 0], rotY: 0, weights: "auto", name: safeName(stem(path)).replace(/_(body|hand|helmet|cloak)(_\d+)?$/, "").replace(/_[ta]_?pose$/, "") }); // each mesh: what it hangs on (head → helmet, a cape → cloak) or what the file is called const whole = guessPart(COS.file.name); let guess = null; try { guess = data.joints.length ? CK.partsGuess(data, COS.sk, COS.P, { ...COS.opts, fitHero: COS.hero }) : null; } catch {} COS.opts.parts = Object.fromEntries(data.meshes.map((m) => [m.name, whole !== "body" ? whole : guess?.[m.name] || "body"])); rebuild(); COS.dirty = true; toast(`${COS.file.name}: ${PART_ORDER.filter((p) => COS.out[p]).map((p) => `${p} ${COS.out[p].report.vertices.toLocaleString()}`).join(", ") || "nothing"} vertices, scale ${auto.scale.toFixed(2)} (${auto.by}).`); } catch (err) { toast(err.message, true); console.error(err); } finally { loading(false); } } /** * Game rips (FModel / UE Viewer) keep the textures out of the model: Materials/.json lists * them (MainTex, BaseColor, Diffuse…), the pictures are in Textures/. A material without a texture * takes its diffuse from there, or a picture named like it (T__D.png). */ async function texturesFromJson(data, readRel) { const read = async (rel) => (await readRel(rel).catch(() => null)) || null; for (const m of data.materials) { if (m.image >= 0 && data.images[m.image]?.bytes) continue; const tries = []; const js = (await read(`Materials/${m.name}.json`)) || (await read(`${m.name}.json`)); if (js) { try { const t = JSON.parse(new TextDecoder().decode(js)).Textures || {}; const keys = Object.keys(t); const key = keys.find((k) => /^(MainTex|BaseColor|BaseColorMap|Diffuse|DiffuseMap|Albedo|Base_Color|Color)$/i.test(k)) || keys.find((k) => /(diffuse|basecolor|albedo|maintex)/i.test(k) && !/^PM_/i.test(k)) || keys.find((k) => /_D$/i.test(String(t[k]).split(".")[0])); if (key) tries.push(String(t[key]).split("/").pop().split(".")[0]); } catch {} } const base = m.name.replace(/^(MI_|M_|Mat_)/i, ""); tries.push(`T_${base}_D`, `${base}_D`, base); for (const n of tries) { let hit = null, name = null; for (const ext of ["png", "tga", "dds", "jpg"]) for (const rel of [`Textures/${n}.${ext}`, `${n}.${ext}`]) if (!hit && (hit = await read(rel))) name = `${n}.${ext}`; if (hit) { data.images.push({ name, bytes: hit, mime: /\.jpe?g$/i.test(name) ? "image/jpeg" : "image/png" }); m.image = data.images.length - 1; break; } } } } const safeName = (s) => s.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "costume"; const guessPart = (n) => (/hand|glove/i.test(n) ? "hand" : /helmet|hat|cap|mask/i.test(n) ? "helmet" : /cloak|cape|wing/i.test(n) ? "cloak" : "body"); $("cos-import").onclick = async () => { if (!COS.sk && !(await chooseSource())) return; const file = await call("dialog:openExt", "Import a 3D model as a costume", "3D models", ["glb", "gltf", "fbx", "obj"]); if (file) importFile(file); }; // drop a file on the view $("cos-canvas").addEventListener("dragover", (e) => e.preventDefault()); $("cos-canvas").addEventListener("drop", (e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; const p = f && bridge.pathOf?.(f); if (p) importFile(p); }); /** The hero meshes the costume's parts replace in the game are hidden (a helmet with a face: the face too). */ function hideForParts() { for (const b of COS.base) b.hidden = PART_ORDER.some((p) => COS.out[p] && PARTS[p].hides?.test(b.name)) || (!!COS.out.helmet && COS.opts.hideFace && /face/i.test(b.name)); } /** Puts a built mesh in the view (reusing old's id). → the view mesh */ function showBuilt(b, old, name) { const d = b.data; const id = old?.id || `cos-${Date.now()}-${name}`; const groups = b.groups.map((g) => { const ls = lsMat(g.material); if (ls) return Object.assign({}, ls.group, { start: g.start, count: g.count, tex: ls.group?.texInfo?.tex || null }); const m = COS.mats[g.material]; const look = kit.groupLook(null, d); return Object.assign(look, { start: g.start, count: g.count, tex: m?.tex || null, color: !m?.tex && m ? [m.color[0], m.color[1], m.color[2], 1] : null }); }); view.addMesh(id, { pos: d.pos, normal: d.normal, uv0: d.uv0, indices: Uint32Array.from(d.indices), groups }); return { id, name, data: d, groups, built: b, palette: SkinKit.palette(COS.sk, d), skinPos: new Float32Array(d.pos.length), skinNrm: new Float32Array(d.normal.length), costume: true, hidden: old?.hidden }; } /** The hero's body / hand mesh at its rest pose for build's base (outfit only): its groups keep the game's material ("ls::"). */ function heroBase(k, hand = false) { const h = COS.heroParts?.[k]; if (!h) return null; const subs = h.posed.subs?.length ? h.posed.subs : [{ indexStart: 0, faceCount: h.posed.idx.length / 3 }]; return { ...h.posed, subs, hand, mats: subs.map((_, s) => `ls:${h.i}:${s}`) }; } /** A built group's game material: "ls::" → { mesh, group, sub } */ const lsMat = (key) => { if (typeof key !== "string") return null; const [, i, sub] = key.split(":").map(Number); const mesh = COS.base[i]; return mesh ? { mesh, group: mesh.groups[sub], sub } : null; }; /** The model's textures' colours (≤ 512 px), for Outfit only's skin tone: (material, u, v) → [r, g, b, a] | null */ function texel() { if (!COS.texPix) COS.texPix = COS.mats.map((m) => (m?.img ? pixels(m.img, 512) : null)); return (mi, u, v) => { const t = COS.texPix[mi]; if (!t) return null; u -= Math.floor(u); v -= Math.floor(v); const i = (Math.min(t.h - 1, Math.floor(v * t.h)) * t.w + Math.min(t.w - 1, Math.floor(u * t.w))) * 4; return [t.rgba[i], t.rgba[i + 1], t.rgba[i + 2], t.rgba[i + 3]]; }; } /** The top-level "material { … }" blocks of a material script, in order. */ function materialBlocks(text) { const out = []; let depth = 0, start = -1; const re = /\bmaterial\b|[{}]/g; let m; while ((m = re.exec(text))) { if (m[0] === "material" && depth === 0 && start < 0) start = m.index; else if (m[0] === "{") depth++; else if (m[0] === "}" && --depth === 0 && start >= 0) out.push(text.slice(start, m.index + 1)), (start = -1); } return out; } /** * Builds every part from the file + options and shows them: one placement / fit for all (measured on * every mesh that isn't left out), then a mesh per part; the body's hands split off unless the file * has a hands part of its own. */ function rebuild() { if (!COS.file || !COS.sk) return; const o = COS.opts; const parts = Object.entries(o.parts).filter(([, p]) => p !== "skip"); const out = {}; let rig = null; try { rig = CK.prepare(COS.file.data, COS.sk, COS.P, { ...o, fitHero: COS.hero, measure: new Set(parts.map(([n]) => n)) }); } catch (err) { toast(`Fit: ${err.message}`, true); rig = CK.prepare(COS.file.data, COS.sk, COS.P, { ...o, fit: false }); } const handsPart = parts.some(([, p]) => p === "hand"); const outfit = !!(o.outfit && o.fit && rig?.T); for (const part of PART_ORDER) { const include = new Set(parts.filter(([, p]) => p === part).map(([n]) => n)); if (!include.size) continue; try { const splitHands = part === "body" && o.splitHands && !handsPart; const base = !outfit ? null : part === "body" ? [heroBase("body"), splitHands ? heroBase("hand", true) : null].filter(Boolean) : part === "hand" ? [heroBase("hand")].filter(Boolean) : null; const r = CK.build(COS.file.data, COS.sk, COS.P, COS.surface, { ...o, rig, include, splitHands, outfit, texel: outfit ? texel() : null, face: COS.faceSurface, base }); if (!r) { toast(`${PARTS[part].label}: only skin / face — nothing of it is left (Outfit only).`); continue; } out[part] = r; if (r.hand) out.hand = r.hand; } catch (err) { toast(`${PARTS[part].label}: ${err.message}`, true); } } COS.out = out; for (const p of PART_ORDER) { if (out[p]) COS.views[p] = showBuilt(out[p], COS.views[p], outName(p)); else if (COS.views[p]) view.removeMesh(COS.views[p].id), delete COS.views[p]; } hideForParts(); $("cos-title").textContent = COS.file.name; $("cos-dirty").hidden = !COS.dirty; $("cos-save").disabled = !anyOut(); applyPose(); renderSide(); } let rebuildTimer = 0; const rebuildSoon = () => { clearTimeout(rebuildTimer); rebuildTimer = setTimeout(rebuild, 150); }; // ===================================================================== // side cards // ===================================================================== const field = (label, input) => [el("label", { text: label }), input]; const num = (value, onchange, step = "any") => el("input", { type: "number", step, value: String(value), onchange: (e) => onchange(Number(e.target.value)) }); function renderSide() { renderChar(); renderOpts(); renderMats(); renderReport(); renderReg(); } function renderChar() { const box = $("cos-char"); $("cos-char-name").textContent = COS.sk ? `sp2_human_${COS.sex} · ${COS.sk.name}` : ""; if (!COS.sk) return box.replaceChildren(el("div", { class: "ans-note", text: "Pick the game's packs (top left)." })); const row = (m, removable) => el("div", { class: "ans-mesh" }, [ el("input", { type: "checkbox", checked: !m.hidden, title: "Show / hide", onchange: (e) => ((m.hidden = !e.target.checked), applyPose()) }), el("span", { class: `mono grow${m.costume ? " ok" : ""}`, text: m.costume ? `${m.name} (import)` : m.name, title: m.name }), removable ? el("button", { class: "iconbtn only danger", title: "Take it off", onclick: () => takeOff(m) }, [icon("trash")]) : null, ]); box.replaceChildren(...COS.base.map((m) => row(m, false)), ...COS.worn.map((m) => row(m, true)), ...PART_ORDER.filter((p) => COS.views[p]).map((p) => row(COS.views[p], false)), el("div", { class: "ans-note", text: "Unticked meshes are hidden (in the game the costume's body replaces human_body, its hands default_hand, a helmet the hair)." })); } function takeOff(m) { view.removeMesh(m.id); COS.worn.splice(COS.worn.indexOf(m), 1); applyPose(); renderSide(); } function renderOpts() { const box = $("cos-opts"); const o = COS.opts; $("cos-file-name").textContent = COS.file?.name || ""; if (!COS.file) return box.replaceChildren(el("div", { class: "ans-note", text: "Import 3D… a .glb / .gltf / .fbx / .obj (or drop it on the view)." }), el("div", { class: "ans-row" }, [el("button", { class: "iconbtn", onclick: () => $("cos-import").onclick() }, [icon("import"), "Import 3D…"])])); const set = (k, v, rebuildNow = true) => { o[k] = v; COS.dirty = true; if (rebuildNow) rebuildSoon(); }; const hasJoints = COS.file.data.joints.length > 0; const weights = el("select", { onchange: () => (set("weights", weights.value), renderOpts()) }, [ ["auto", hasJoints ? "Auto (the file's bones)" : "Auto (from the hero body)"], ["file", "The file's bones (same names as the hero's)"], ["body", "From the hero body (closest point)"], ["bone", "All on one bone"], ].map(([v, t]) => el("option", { value: v, text: t, selected: v === o.weights, disabled: v === "file" && !hasJoints }))); const bone = el("select", { onchange: () => set("bone", Number(bone.value)) }, COS.sk.data.bones.map((b, i) => el("option", { value: i, text: b.name.text, selected: i === o.bone }))); const auto = COS.file.auto; const scale = num(o.scale || +auto.scale.toFixed(4), (v) => set("scale", v > 0 ? v : 0)); const off = o.offset.map((v, i) => num(v, (x) => set("offset", Object.assign(o.offset.slice(), { [i]: x || 0 })))); const rot = el("select", { onchange: () => set("rotY", Number(rot.value)) }, [0, 90, 180, -90].map((v) => el("option", { value: v, text: `${v}°`, selected: v === o.rotY }))); // what each mesh of the file becomes const meshes = el( "div", { class: "cos-parts" }, COS.file.meshNames.map((n) => { const verts = COS.file.data.meshes.find((m) => m.name === n)?.prims.reduce((t, p) => t + p.pos.length / 3, 0) || 0; const sel = el("select", { onchange: () => set("parts", { ...o.parts, [n]: sel.value }) }, Object.entries(PARTS).map(([k, p]) => el("option", { value: k, text: p.label, selected: (o.parts[n] || "body") === k }))); return el("div", { class: "ans-mesh" }, [el("span", { class: "mono grow", text: n, title: `${n}: ${verts.toLocaleString()} vertices` }), sel]); }), ); const name = el("input", { type: "text", value: o.name, spellcheck: "false", onchange: () => { const v = name.value.replace(/\.msh$/i, "").trim().toLowerCase(); if (!/^[a-z0-9_\-]+$/.test(v)) return toast("Use a-z, 0-9, _ and - (the client's file names).", true); o.name = v; for (const p of PART_ORDER) if (COS.views[p]) COS.views[p].name = outName(p); renderSide(); renderChar(); } }); box.replaceChildren( el("div", { class: "ans-grid" }, [ ...field("Weights", weights), ...(o.weights === "bone" ? field("Bone", bone) : []), ...field("Scale", el("div", { class: "ans-vec" }, [scale, el("button", { class: "iconbtn only", title: `Auto: ${auto.scale.toFixed(3)} (${auto.by})`, onclick: () => (set("scale", 0), renderOpts()) }, [icon("refresh")])])), ...field("Move x y z", el("div", { class: "ans-vec" }, off)), ...field("Turn", rot), ...field("Max bones", num(o.maxBones, (v) => set("maxBones", Math.max(1, Math.min(CK.MAX_BONES, Math.round(v) || CK.MAX_BONES))), "1")), ...field("Save as", name), ]), el("div", { class: "ans-note", text: "Each mesh of the file is a part of the costume (saved and registered on its own):" }), meshes, el("label", { class: "ans-check", title: COS.file.data.joints.length ? "Finds the rig's hips, spine, head, arms, hands, legs and feet by their shape (any bone names) and reshapes the model to this hero's proportions (Man / Woman): limbs as long and thick as the hero's, the head as big, a cape and long hair hanging the body's length. The weights come from the rig, a cape goes onto the hero's Mantle bones." : "Needs the model's armature: export a .glb / .gltf with it (an .obj has no bones)" }, [el("input", { type: "checkbox", checked: !!o.fit, disabled: !COS.file.data.joints.length, onchange: (e) => set("fit", e.target.checked) }), "Fit to the LS body (" + (COS.sex === "woman" ? "woman" : "man") + ")"]), o.fit ? el("label", { class: "ans-check", title: "On: the torso and hips take the hero's width but keep their own depth front to back (a bust, a bottom, a coat stay full). Off: as flat as the hero's body." }, [el("input", { type: "checkbox", checked: o.keepVolume !== false, onchange: (e) => set("keepVolume", e.target.checked) }), "Keep the body's volume (chest, hips)"]) : null, o.fit ? el("div", { class: "ans-grid" }, [ ...field("Head size %", el("div", { class: "ans-vec", title: "100 = as tall as the hero's head (chin to crown); the chin stays on the hero's chin" }, [num(Math.round((o.headScale || 1) * 100), (v) => set("headScale", Math.max(0.3, Math.min(3, (v || 100) / 100))), "5")])), ...field("LS build %", el("div", { class: "ans-vec", title: "How thick arms, legs and torso are: 0 = the model's own build (kept near its shape), 100 = as thick as the hero's body everywhere, like the game's own costumes" }, [num(Math.round((o.lsBuild || 0) * 100), (v) => set("lsBuild", Math.max(0, Math.min(1, (v || 0) / 100))), "10")])), ]) : null, o.fit ? el("label", { class: "ans-check", title: "Takes only the clothes (and hair) of the model: its skin and face are cut away (the skin tone is read off its face's texture) and the costume's body mesh carries the hero's own body underneath — the Man / Woman button picks which. The hero's face shows; the hero's hands stay unless the model wears gloves." }, [el("input", { type: "checkbox", checked: !!o.outfit, onchange: (e) => { o.outfit = e.target.checked; if (o.outfit) o.hideFace = false; // the hero's own face shows under the hair set("outfit", o.outfit); renderOpts(); } }), `Outfit only — on the LS ${COS.sex === "woman" ? "woman's" : "man's"} body`]) : null, o.fit && o.outfit ? el("div", { class: "ans-grid" }, [ ...field("Skin match %", el("div", { class: "ans-vec", title: "How close to the face's skin tone a texture colour must be to be cut away: lower if clothes in a skin-like colour (beige, pink) go missing, higher if bits of skin stay" }, [num(Math.round((o.skinTol ?? 1) * 100), (v) => set("skinTol", Math.max(0, Math.min(2, (v ?? 100) / 100))), "10")])), ]) : null, COS.out.helmet ? el("label", { class: "ans-check", title: "The helmet brings its own face: the hero's face is hidden (invisible_face = 1 when registered)" }, [el("input", { type: "checkbox", checked: o.hideFace, onchange: (e) => (set("hideFace", e.target.checked, false), hideForParts(), applyPose(), renderReg()) }), "Helmet hides the hero's face"]) : null, Object.values(o.parts).includes("body") && !Object.values(o.parts).includes("hand") ? el("label", { class: "ans-check", title: "Faces that move with a hand or its fingers become a second mesh (hand_mesh, replaces default_hand): a mesh can use 24 bones, the body ~19 and the fingers 20. Only when the model has fingers." }, [el("input", { type: "checkbox", checked: o.splitHands, onchange: (e) => set("splitHands", e.target.checked) }), `Split the hands → ${outName("hand")}`]) : null, el("label", { class: "ans-check", title: "Women look for _hw first; without it they wear the men's mesh" }, [el("input", { type: "checkbox", checked: o.hw, onchange: (e) => set("hw", e.target.checked, false) }), "Also save as _hw (women)"]), el("div", { class: "ans-note", text: `Units: LS ≈ cm, the hero is ~140 tall; Blender metres × 100. Auto scale: ${auto.by}.` }), ); } function renderMats() { const box = $("cos-mats"); if (!anyOut()) return box.replaceChildren(el("div", { class: "ans-note", text: "—" })); const o = COS.opts; const used = usedMats(); const rows = used.map((mi, k) => { const m = COS.mats[mi]; const texName = texNameOf(k); return el("div", { class: "ans-mesh" }, [ el("span", { class: "mono grow", text: `${m?.name || "no material"} → ${texName}`, title: m?.image ? `texture: ${m.image.name} ${m.img ? `${m.img.width}×${m.img.height}` : "(can't read)"}` : "no texture: a plain colour" }), el("span", { class: m?.img ? "muted" : "warn", text: m?.img ? `${m.img.width}×${m.img.height}` : m?.wanted ? `${m.wanted}?` : "colour", title: m?.wanted ? `${m.wanted} isn't next to the model: Textures folder… (or the image button)` : "" }), el("button", { class: "iconbtn only", title: "Use another texture (.png / .jpg / .dds / .tga)", onclick: () => pickTexture(mi) }, [icon("image")]), ]); }); const max = el("select", { onchange: () => (o.texMax = Number(max.value)) }, [256, 512, 1024, 2048].map((v) => el("option", { value: v, text: `${v}`, selected: v === o.texMax }))); const lost = used.filter((mi) => COS.mats[mi]?.wanted); box.replaceChildren( ...rows, lost.length ? el("div", { class: "ans-note warn", text: `${lost.length} texture(s) not found: ${[...new Set(lost.map((mi) => COS.mats[mi].wanted))].join(", ")}.` }) : null, lost.length ? el("div", { class: "ans-row" }, [el("button", { class: "iconbtn", onclick: () => findTextures().catch((err) => toast(err.message, true)) }, [icon("folder"), "Textures folder…"])]) : null, el("div", { class: "ans-grid" }, [...field("Texture max", max)]), el("label", { class: "ans-check" }, [el("input", { type: "checkbox", checked: o.outline, onchange: (e) => (o.outline = e.target.checked) }), "Toon outline (like the game's costumes)"]), el("div", { class: "ans-note", text: "Textures are saved as 32-bit .dds (power-of-two sizes); a .dds you pick is kept as it is." }), ); } /** Looks for the missing textures (by file name) in a folder the user picks. */ async function findTextures() { const folder = await call("dialog:folder", "Folder with the model's textures"); if (!folder) return; COS.texDir = folder.replace(/\\/g, "/"); try { localStorage.setItem("cos:texDir", COS.texDir); } catch {} let found = 0; for (const m of COS.mats) { if (!m?.wanted) continue; const bytes = await call("mdl:readFile", `${COS.texDir}/${m.wanted}`).then((h) => h.bytes).catch(() => null); if (!bytes) continue; const image = { name: m.wanted, bytes, mime: /\.jpe?g$/i.test(m.wanted) ? "image/jpeg" : "image/png" }; const img = await readImage(image).catch(() => null); if (!img) continue; Object.assign(m, { image, img, tex: view.texture(img), wanted: null }); COS.texPix = null; found++; } toast(found ? `Found ${found} texture(s).` : "None of the textures are in that folder.", !found); if (found) (COS.dirty = true), rebuild(); } const texNameOf = (k) => `${COS.opts.name}${k ? `_${k + 1}` : ""}.dds`; async function pickTexture(mi) { const file = await call("dialog:openExt", "Texture for this material", "Images", ["png", "jpg", "jpeg", "dds", "tga", "bmp"]); if (!file) return; try { const bytes = (await call("mdl:readFile", file)).bytes; const name = file.split(/[\\/]/).pop(); const image = { name, bytes, mime: /\.jpe?g$/i.test(name) ? "image/jpeg" : "image/png" }; const img = await readImage(image); const m = COS.mats[mi] || (COS.mats[mi] = { name: "material", color: [1, 1, 1, 1] }); if (m.tex) view.gl.deleteTexture(m.tex); Object.assign(m, { image, img, tex: view.texture(img) }); COS.texPix = null; COS.dirty = true; rebuild(); } catch (err) { toast(`Can't read that image: ${err.message}`, true); } } function renderReport() { const box = $("cos-report"); const first = anyOut(); if (!first) return box.replaceChildren(el("div", { class: "ans-note", text: "—" })); const rows = []; const warn = []; for (const p of PART_ORDER) { const r = COS.out[p]?.report; if (!r) continue; rows.push(...field(PARTS[p].label, el("span", { class: "mono", text: `${outName(p)} · ${r.vertices.toLocaleString()} v · ${r.faces.toLocaleString()} f · ${r.bones}/${CK.MAX_BONES} bones`, title: r.boneNames.join("\n") }))); if (r.unweighted) warn.push(`${PARTS[p].label}: ${r.unweighted} vertices have no bone (they stay in place).`); if (r.folded.length) warn.push(`${PARTS[p].label}: more than ${COS.opts.maxBones} bones, ${r.folded.length} merged into their parents (${r.folded.slice(0, 3).join(", ")}${r.folded.length > 3 ? "…" : ""}).`); } const r = first.report; const fi = r.fit; if (fi) { const g = (k) => fi.info[k] || {}; warn.push(`Fitted (rig: hips ${fi.joints.pelvis}, chest ${fi.joints.chest}, head ${fi.joints.head}, hands ${fi.joints.leftHand} / ${fi.joints.rightHand}${fi.joints.capes ? `, ${fi.joints.capes} cape chain(s)` : ""}): head ×${g("Bip01 Head").scale}, thighs ×${g("Bip01 L Thigh").length}, arms ×${g("Bip01 L UpperArm").length}, feet ×${g("Bip01 L Foot").length}, torso thickness ×${g("Bip01 Spine").thickness}.`); } const of = PART_ORDER.map((p) => COS.out[p]?.report.outfit).filter(Boolean); if (of.length) { const t = of[0].tone; const hb = COS.out.body?.report.heroBody; warn.push(`Outfit only: skin tone ${t ? `rgb(${t.join(", ")})` : "not found (no face texture) — only the face is cut"}; left out ${of.reduce((n, x) => n + x.skin, 0).toLocaleString()} skin and ${of.reduce((n, x) => n + x.face, 0).toLocaleString()} face faces.${hb ? ` The ${COS.sex}'s body is inside the body mesh: ${hb.kept.toLocaleString()} of its faces, ${hb.hidden.toLocaleString()} hidden under the clothes left out.` : ""}`); } if (COS.out.body?.handNote && COS.opts.splitHands) warn.push(`Split the hands: ${COS.out.body.handNote}.`); if (r.mode === "body" && COS.out.body) warn.push("Long skirts / coats: weights from the body follow the closest leg; rig them in Blender for a better swing."); box.replaceChildren( el("div", { class: "ans-grid" }, [ ...rows, ...field("Weights", el("span", { text: r.mode === "file" ? `file's bones${r.fromBody ? ` (+${r.fromBody} from the body)` : ""}` : r.mode === "body" ? "from the hero body" : "one bone" })), ...field("Scale", el("span", { class: "mono", text: r.scale.toFixed(3) })), ]), ...warn.map((w) => el("div", { class: "ans-note warn", text: w })), ); } // ===================================================================== // register in game: a costume entry (costume_*.ini + its name) and the server's code list // ===================================================================== /** The costume INIs the built parts go into: armor (body / hands), helmet, cloak. */ const kinds = () => [...new Set(PART_ORDER.filter((p) => COS.out[p]).map((p) => PARTS[p].ini))]; const GRADES = [[0, "Normal"], [2, "Premium"], [3, "Rare"], [4, "Unique"]]; const REG = { kind: null, info: {}, loading: null, form: null, last: null, serverFile: "" }; try { REG.serverFile = localStorage.getItem("cos:serverFile") || ""; } catch {} async function regInfo(kind, reload = false) { if (!reload && REG.info[kind]) return REG.info[kind]; REG.loading = kind; renderReg(); try { REG.info[kind] = await call("cos:info", COS.src, kind); } finally { REG.loading = null; } return REG.info[kind]; } /** A new form for the current costume (kind from the slot, the next free code, the meshes it saves). */ function newForm(kind, info) { const o = COS.opts; const last = info.list[info.list.length - 1]; const meshes = {}; for (const p of PART_ORDER) if (PARTS[p].ini === kind) meshes[PARTS[p].key] = COS.out[p] ? outName(p) : ""; return { kind, code: info.next, name: COS.file ? stem(COS.file.name) : o.name, grade: 0, template: last?.section || "", icon: last?.icon || "", meshes, addServer: !!REG.serverFile }; } function renderReg() { const box = $("cos-reg"); if (!COS.src) return box.replaceChildren(el("div", { class: "ans-note", text: "—" })); const ks = kinds(); const kind = REG.kind && (ks.includes(REG.kind) || !ks.length) ? REG.kind : ks[0] || "armor"; $("cos-reg-file").textContent = `costume_${kind}.ini`; const info = REG.info[kind]; if (!info) { return box.replaceChildren( el("div", { class: "ans-note", text: "Adds the costume to the game: an entry in config/costume/costume_*.ini (code, name, meshes), its name in the string tables, and its code in the server's sp2_costumemanager.ini." }), el("div", { class: "ans-row" }, [el("button", { class: "iconbtn", disabled: !!REG.loading, onclick: () => regInfo(kind).then(renderReg).catch((err) => toast(err.message, true)) }, [icon("refresh"), REG.loading ? "Reading the costume list…" : "Read the costume list"])]), ); } if (!REG.form || REG.form.kind !== kind) REG.form = newForm(kind, info); const f = REG.form; // the costume was renamed / moved to another slot: its mesh names follow const at = `${COS.opts.name}|${PART_ORDER.filter((p) => COS.out[p]).join(",")}`; if (f.at && f.at !== at) Object.assign(f.meshes, newForm(kind, info).meshes); f.at = at; const kindSel = el("select", { onchange: () => ((REG.kind = kindSel.value), (REG.form = null), regInfo(REG.kind).then(renderReg).catch((err) => toast(err.message, true))) }, ["armor", "helmet", "cloak"].map((k) => el("option", { value: k, text: k, selected: k === kind }))); const taken = new Set(info.list.map((c) => c.code)); const code = el("input", { type: "number", step: "1", value: String(f.code), onchange: () => { f.code = Number(code.value); code.classList.toggle("bad", taken.has(f.code)); if (taken.has(f.code)) toast(`${f.code} is already a costume (${info.list.find((c) => c.code === f.code).name}).`, true); } }); const name = el("input", { type: "text", value: f.name, placeholder: "shown in the game (any language)", oninput: () => (f.name = name.value) }); const grade = el("select", { onchange: () => (f.grade = Number(grade.value)) }, GRADES.map(([v, t]) => el("option", { value: v, text: t, selected: v === f.grade }))); const recent = info.list.slice(-40).reverse(); const tpl = el("select", { title: "The other keys (glow colours, armour sound…) and the icon are copied from this costume", onchange: () => { f.template = tpl.value; const t = info.list.find((c) => c.section === f.template); if (t?.icon) (f.icon = t.icon), (iconIn.value = t.icon); } }, recent.map((c) => el("option", { value: c.section, text: `${c.code} ${c.name}`, selected: c.section === f.template }))); const iconIn = el("input", { type: "text", value: f.icon, spellcheck: "false", placeholder: "imageset#icon (empty: the code's default)", onchange: () => (f.icon = iconIn.value.trim()) }); const meshIns = Object.keys(f.meshes).map((k) => field(k, el("input", { type: "text", value: f.meshes[k], spellcheck: "false", placeholder: "(none)", onchange: (e) => (f.meshes[k] = e.target.value.trim().replace(/\.msh$/i, "")) }))); const server = el("label", { class: "ans-check", title: REG.serverFile || "No file chosen" }, [ el("input", { type: "checkbox", checked: f.addServer && !!REG.serverFile, disabled: !REG.serverFile, onchange: (e) => (f.addServer = e.target.checked) }), REG.serverFile ? `Add the code to ${REG.serverFile.split(/[\\/]/).slice(-2).join("\\")}` : "Server list: not chosen", ]); const done = REG.last ? el("div", { class: "ans-note" }, [ `Registered [${REG.last.section}] = ${REG.last.code}. Give it to a player: Monitor → Gift items → `, el("b", { class: "mono", text: `12.${REG.last.code}.0` }), " (0 = for good).", REG.last.server ? ` Server list: ${REG.last.server.added ? `added as [${REG.last.server.section}]` : `already [${REG.last.server.section}]`} — restart / reload the game server.` : " Add it to the server's sp2_costumemanager.ini too, else the server refuses it.", ]) : null; box.replaceChildren( el("div", { class: "ans-grid" }, [ ...field("Kind", kindSel), ...field("Code", code), ...field("Name", name), ...field("Grade", grade), ...field("Copy from", tpl), ...field("Icon", iconIn), ...meshIns.flat(), ]), server, el("div", { class: "ans-row" }, [ el("button", { class: "iconbtn", title: "The server's config/sp2_costumemanager.ini (a copy you upload, or the server folder)", onclick: chooseServerFile }, [icon("folder"), "Server list…"]), el("button", { class: "btn primary", onclick: () => register().catch((err) => toast(err.message, true)) }, [icon("plus"), "Register"]), ks.length > 1 ? el("button", { class: "iconbtn", title: `One entry per part: ${ks.join(", ")} — the same name, each kind's next free code`, onclick: () => registerAll().catch((err) => toast(err.message, true)) }, [icon("plus"), `Register all (${ks.length})`]) : null, ]), done, ); } async function chooseServerFile() { const file = await call("dialog:openExt", "The game server's sp2_costumemanager.ini", "INI", ["ini"]); if (!file) return; REG.serverFile = file; try { localStorage.setItem("cos:serverFile", file); } catch {} if (REG.form) REG.form.addServer = true; renderReg(); } /** What cos:register gets; a helmet takes the hero's hair off (and the face when it brings its own). */ const entryOf = (f) => ({ kind: f.kind, code: f.code, name: f.name.trim(), grade: f.grade, icon: f.icon, meshes: f.meshes, template: f.template, serverFile: f.addServer ? REG.serverFile : null, extra: f.kind === "helmet" ? { hair_change_hm: "1", hair_change_hw: "1", invisible_face: COS.opts.hideFace ? "1" : "0" } : {}, }); /** Every part at once: one entry per costume INI, the form's name / grade / icon, each kind's next code. */ async function registerAll() { const f = REG.form; if (!f?.name.trim()) return toast("Give the costume a name.", true); const list = []; for (const k of kinds()) { const info = await regInfo(k, true); const nf = { ...newForm(k, info), name: f.name, grade: f.grade, icon: f.icon, addServer: f.addServer }; list.push(nf); } const missing = []; for (const nf of list) for (const m of Object.values(nf.meshes).filter(Boolean)) if (!(await readNamed(`${m}.msh`, "mesh"))) missing.push(m); const body = el("div", {}, [ el("p", { text: `"${f.name}" as ${list.length} costumes:` }), el("div", { class: "mono ans-note" }, list.map((nf) => el("div", { text: `costume_${nf.kind}.ini ${nf.code} ${Object.entries(nf.meshes).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join(" ")}` }))), missing.length ? el("p", { class: "warn", text: `Not in the packs yet: ${missing.join(", ")} — Save into the packs first.` }) : null, ]); if (!(await modal("Register every part", body, [{ label: "Cancel", value: false }, { label: "Register all", kind: "primary", value: true }]))) return; loading(true, "Registering…"); try { const done = []; for (const nf of list) { const r = await call("cos:register", COS.src, entryOf(nf)); for (const w of r.warnings) toast(w, true); done.push(r); await regInfo(nf.kind, true); } REG.form = null; REG.last = done[done.length - 1]; REG.lastAll = done; renderReg(); toast(`Registered ${done.map((r) => r.code).join(", ")}. Gifts: ${done.map((r) => `12.${r.code}.0`).join(" ")}`); } finally { loading(false); } } async function register() { const f = REG.form; const info = REG.info[f.kind]; if (!f.name.trim()) return toast("Give the costume a name.", true); if (info.list.some((c) => c.code === f.code)) return toast(`${f.code} is already a costume.`, true); const meshes = Object.entries(f.meshes).filter(([, v]) => v); if (!meshes.length) return toast("Fill in the mesh it wears (e.g. body_mesh).", true); // the meshes should be in the packs already (Save first) const missing = []; for (const [, m] of meshes) if (!(await readNamed(`${m}.msh`, "mesh"))) missing.push(m); const body = el("div", {}, [ el("p", { text: `[costume${info.count + 1}] in config/costume/costume_${f.kind}.ini:` }), el("div", { class: "mono ans-note" }, [`costum_code = ${f.code}`, `name = STR(1) → "${f.name}" (${info.texts.map((t) => `${t.file} ${t.encoding}`).join(", ")})`, ...meshes.map(([k, v]) => `${k} = ${v}`), f.icon ? `icon_name = ${f.icon}` : ""].filter(Boolean).map((t) => el("div", { text: t }))), missing.length ? el("p", { class: "warn", text: `Not in the packs yet: ${missing.join(", ")}.msh — Save the costume into the packs first, else the game shows nothing.` }) : null, f.addServer && REG.serverFile ? el("p", { class: "muted", text: `+ item_code = ${f.code} in ${REG.serverFile} (the old one is kept as .bak).` }) : el("p", { class: "warn", text: "The server's sp2_costumemanager.ini isn't changed: add the code there yourself." }), ]); if (!(await modal("Register the costume", body, [{ label: "Cancel", value: false }, { label: "Register", kind: "primary", value: true }]))) return; loading(true, "Writing the costume list and the string tables…"); try { const r = await call("cos:register", COS.src, entryOf(f)); REG.last = r; for (const w of r.warnings) toast(w, true); toast(`Registered ${f.code}: ${r.written.length} files written.`); await regInfo(f.kind, true); REG.form = null; renderReg(); REG.last = r; renderReg(); } finally { loading(false); } } // ===================================================================== // save // ===================================================================== /** Pixels of an image at a power-of-two size (≤ max). → { rgba, w, h } */ function pixels(img, max) { const p2 = (n) => Math.min(max, 2 ** Math.round(Math.log2(Math.max(1, n)))); const w = p2(img.width), h = p2(img.height); const c = new OffscreenCanvas(w, h); const g = c.getContext("2d"); if (img instanceof ImageData) { const t = new OffscreenCanvas(img.width, img.height); t.getContext("2d").putImageData(img, 0, 0); g.drawImage(t, 0, 0, w, h); } else g.drawImage(img, 0, 0, w, h); return { rgba: g.getImageData(0, 0, w, h).data, w, h }; } /** Every file of the costume: [{ folder: mesh|material|texture, name, data }] */ /** The file materials the costume uses (body, then hands): texture k is [_k+1].dds. */ const usedMats = () => [...new Set(PART_ORDER.flatMap((p) => COS.out[p]?.groups || []).map((g) => g.material).filter((m) => typeof m === "number"))]; function files() { const o = COS.opts; if (!anyOut() || !o.name) throw new Error("Nothing to save."); const used = usedMats(); const out = []; used.forEach((mi, k) => { const m = COS.mats[mi]; let data; if (m?.image && /\.dds$/i.test(m.image.name)) data = m.image.bytes; else if (m?.img) { const p = pixels(m.img, o.texMax); data = CK.encodeDds(p.rgba, p.w, p.h); } else { const c = (m?.color || [0.8, 0.8, 0.8, 1]).map((v) => Math.round(Math.max(0, Math.min(1, v)) * 255)); const px = new Uint8Array(8 * 8 * 4); for (let i = 0; i < 64; i++) px.set([c[0], c[1], c[2], 255], i * 4); data = CK.encodeDds(px, 8, 8); } out.push({ folder: "texture", name: texNameOf(k), data }); }); const meshes = []; for (const [part, name] of PART_ORDER.filter((p) => COS.out[p]).map((p) => [COS.out[p], outName(p)])) { const msh = LSF.buildMsh(part.data); const mat = new TextEncoder().encode( part.groups .map((g) => { const ls = lsMat(g.material); if (!ls) return CK.materialText([texNameOf(used.indexOf(g.material))], { outline: o.outline }); // the hero's own body: its material as the game has it (the skin colour blend) const block = ls.mesh.mat ? materialBlocks(ls.mesh.mat.text)[ls.sub] : null; return block ? block.replace(/\r?\n/g, "\r\n") + "\r\n" : CK.materialText([ls.group?.texName || null], { outline: o.outline }); }) .join(""), ); meshes.push({ folder: "mesh", name: `${name}.msh`, data: msh }, { folder: "material", name: `${name}.txt`, data: mat }); if (o.hw) out.push({ folder: "mesh", name: `${name}_hw.msh`, data: msh }, { folder: "material", name: `${name}_hw.txt`, data: mat }); } out.unshift(...meshes); return out; } const REF = { mesh: "human_body.msh", material: "human_body.txt", texture: "human_body.dds" }; async function saveIntoPacks() { const list = files(); // a file that's already in the packs is replaced where it is; a new one goes next to the hero body's const plan = []; for (const f of list) { const hit = await readNamed(f.name, f.folder); const ref = hit || (await readNamed(REF[f.folder], f.folder)); if (ref?.where?.kind !== "pack") throw new Error(`No pack with resource/${f.folder} (or the source is an extracted folder): use Save to a folder.`); const dir = ref.where.entry.replace(/[^/\\]+$/, ""); plan.push({ f, where: { kind: "pack", file: ref.where.file, entry: hit ? ref.where.entry : `${dir}${f.name}`, create: !hit }, replace: !!hit }); } const replaced = plan.filter((p) => p.replace); const body = el("div", {}, [ el("p", { text: `${plan.length} files go into the packs:` }), el("div", { class: "mono ans-note" }, plan.map((p) => el("div", { text: `${p.replace ? "replace" : "new "} ${p.where.file.split(/[\\/]/).pop()} ${p.where.entry}` }))), replaced.length ? el("p", { class: "warn", text: `${replaced.length} existing file(s) are replaced — every costume that uses them changes in the game.` }) : null, el("p", { class: "muted", text: `In the game: register it (Register in game) — ${PART_ORDER.filter((p) => COS.out[p]).map((p) => `${PARTS[p].key} = ${outName(p)}`).join(", ")}.` }), ]); if (!(await modal("Save the costume into the packs", body, [{ label: "Cancel", value: false }, { label: "Save", kind: "primary", value: true }]))) return; loading(true, "Saving…"); try { for (const p of plan) await call("mdl:writeBack", p.where, p.f.data); kit.clearTextures(); COS.dirty = false; $("cos-dirty").hidden = true; toast(`Saved ${plan.length} files into the packs.`); loadList(); } catch (err) { toast(err.message, true); } finally { loading(false); } } async function saveToFolder() { const list = files(); const folder = await call("dialog:folder", "Folder to save the costume in (resource/… is made inside)"); if (!folder) return; try { for (const sub of ["mesh", "material", "texture"]) { const fs = list.filter((f) => f.folder === sub); if (fs.length) await call("mdl:writeFiles", `${folder}/resource/${sub}`, fs.map((f) => ({ name: f.name, data: f.data }))); } COS.dirty = false; $("cos-dirty").hidden = true; toast(`Saved ${list.length} files in ${folder}\\resource.`); } catch (err) { toast(err.message, true); } } $("cos-save").onclick = (e) => showMenu( [ { label: `Save into the packs (${PART_ORDER.filter((p) => COS.out[p]).map(outName).join(", ")})`, icon: "box", disabled: !anyOut(), run: () => saveIntoPacks().catch((err) => toast(err.message, true)) }, { label: "Save to a folder…", icon: "download", disabled: !anyOut(), run: () => saveToFolder().catch((err) => toast(err.message, true)) }, ], { anchor: e.currentTarget }, ); // ===================================================================== // the body for Blender // ===================================================================== async function png(img) { const c = new OffscreenCanvas(img.width, img.height); const g = c.getContext("2d"); if (img instanceof ImageData) g.putImageData(img, 0, 0); else g.drawImage(img, 0, 0); return new Uint8Array(await (await c.convertToBlob({ type: "image/png" })).arrayBuffer()); } /** What is shown, as a rigged .glb. → { bytes, meshes } */ async function bodyGlb() { const shown = allMeshes().filter((m) => !m.hidden); const images = [], meshes = []; for (const m of shown) { const p = CK.posed(COS.sk, COS.P, m.data); const groups = []; for (const [gi, g] of m.groups.entries()) { let image = null; const ls = m.costume ? lsMat(m.built.groups[gi].material) : null; const img = ls ? ls.group?.texInfo?.img : m.costume ? COS.mats[m.built.groups[gi].material]?.img : g.texInfo?.img; const nm = m.costume && !ls ? `${m.name}_${gi + 1}.png` : (ls ? ls.group : g)?.texName ? (ls ? ls.group : g).texName.replace(/\.[^.]+$/, ".png") : null; if (img && nm) { if (!images.some((x) => x.name === nm)) images.push({ name: nm, png: await png(img) }); image = nm; } groups.push({ start: g.start, count: g.count, image }); } meshes.push({ name: m.name, ...p, groups }); } return { bytes: CK.exportGlb(COS.sk, COS.P, meshes, images), meshes }; } async function exportBody() { if (!COS.sk && !(await chooseSource())) return; if (!COS.sk) return; const { bytes, meshes } = await bodyGlb(); const file = await call("dialog:saveAs", `lostsaga_${COS.sex}_body.glb`, "glb"); if (!file) return; await call("mdl:writeBack", { kind: "file", file }, bytes); toast(`Saved ${file}: ${meshes.length} meshes + the ${COS.sk.data.bones.length}-bone skeleton. In Blender: File → Import → glTF 2.0.`); } $("cos-blender").onclick = () => exportBody().catch((err) => toast(err.message, true)); // ===================================================================== // keys + first show // ===================================================================== document.addEventListener("keydown", (e) => { if (!visible() || e.target.closest?.("input, textarea, select") || !$("modal").hidden) return; if (e.key === " ") { e.preventDefault(); $("cos-play").onclick(); } }); // the hero appears when the tab is first opened with the profile's packs new MutationObserver(() => { if (visible() && !COS.src && !view) { const s = profileSource(); if (s) setSource(s); } }).observe($("view-cos"), { attributes: true, attributeFilter: ["class"] }); Object.assign(COS, { REG, regInfo, importFile, exportBody, bodyGlb, files, rebuild, playAni, setSource, loadHero }); renderSide(); })();