Files

1219 lines
65 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// 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: <name>_body_01, <name>_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/<material>.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_<name>_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:<base>:<sub>"). */
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:<base>:<sub>" → { 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 <name>_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 <name>[_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();
})();