// Ported from LS Tools desktop: main/assets.js (mechanical CJS -> ESM) import fs from "node:fs"; import path from "node:path"; import { Pack } from "../core/iop.js"; import tools from "../core/tools.js"; import iop from "./iopman.js"; import store from "./store.js"; // Game resources for the viewers (textures, meshes, skeletons, animations, materials, models), // read from an "asset source": // session every .iop next to a pack open in IOP Manager (password of that session) // packs every .iop in a chosen folder (password found per pack from the profiles) // dir a folder of extracted files (e.g. map.iop extracted) // The client finds resources by bare file name inside a known folder (Texture/, // Mesh/.msh, …), so lookups go by name with a folder hint: resource// wins, // then any path with // in it, then (unless strict) the name anywhere. // Packs are searched in the client's order: rs.iop first, then map.iop (ioPac::GetPacHasFile asks the // packs in the order Login.cpp adds them and takes the first pack that has the name). Other packs // (any other .iop in the folder) come after, by name; their place in the client is not confirmed yet. // Inside one pack the last directory entry for a name wins (Pack.liveEntries). const sources = new Map(); // key → { label, find(base, folder, strict), list(), close() } function packSource(folder, passwordsFor) { const rank = (n) => (n === "rs.iop" ? 0 : n === "map.iop" ? 1 : 2); const names = fs .readdirSync(folder) .filter((n) => /\.iop$/i.test(n) && fs.statSync(path.join(folder, n)).isFile()) .sort((a, b) => rank(a.toLowerCase()) - rank(b.toLowerCase()) || a.localeCompare(b)); const packs = []; for (const n of names) { try { const pack = Pack.open(path.join(folder, n), { readOnly: true }); packs.push({ name: n, file: path.join(folder, n), pack, passwords: passwordsFor(pack) }); } catch (err) { console.warn("[assets] can't open", n, err.message); } } const index = new Map(); // base name → [{ p, entry, lower }] for (const p of packs) { for (const e of p.pack.liveEntries()) { const lower = e.name.toLowerCase(); const base = lower.split("/").pop(); if (!base) continue; if (!index.has(base)) index.set(base, []); index.get(base).push({ p, entry: e, lower }); } } // entries with comment "1" use the type-1 password (and .ini files are XOR-scrambled) const hitOf = (h) => ({ read: () => { const preferred = h.entry.comment.toString("latin1") === "1" ? 1 : 0; const passwords = h.p.passwords; try { return tools.readEntry(h.p.pack, h.entry, passwords?.[preferred] ?? null, { xorIni: true }); } catch (err) { // Some client packs mark entries "1" but encrypt them with the type-0 // key. Retry only a password mismatch with the other known key. // Web port fix: a wrong key passes the 1-byte ZipCrypto check 1 time in 256 and then fails // later (inflate "invalid distance" / CRC), so retry on any read error, not only // "wrong password", and report the first error if the other key fails too. if (!passwords || passwords[1 - preferred] == null) throw err; try { return tools.readEntry(h.p.pack, h.entry, passwords[1 - preferred], { xorIni: true }); } catch { throw err; } } }, source: `${h.p.name}: ${h.entry.name}`, where: { kind: "pack", file: h.p.file, entry: h.entry.name }, }); return { label: `${packs.length} pack(s) in ${folder}`, find(base, folder, strict) { const h = pick(index.get(base) || [], (x) => x.lower, folder, strict); return h ? hitOf(h) : null; }, bySuffix(suffix) { const s = "/" + suffix.toLowerCase(); const h = (index.get(s.split("/").pop()) || []).find((x) => ("/" + x.lower).endsWith(s)); return h ? hitOf(h) : null; }, get(key) { const [pn, ...rest] = key.split("|"); const entryName = rest.join("|"); const p = packs.find((x) => x.name === pn); const e = p?.pack.lookup(entryName); return e ? hitOf({ p, entry: e }) : null; }, list: () => packs.flatMap((p) => p.pack.liveEntries().map((e) => ({ key: `${p.name}|${e.name}`, path: e.name, pack: p.name }))), close: () => packs.forEach((p) => p.pack.close()), }; } function dirSource(folder) { const index = new Map(); // base name → [{ full, lower (relative, /) }] const all = []; const walk = (dir, depth) => { if (depth > 12) return; let list; try { list = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const d of list) { const full = path.join(dir, d.name); if (d.isDirectory()) walk(full, depth + 1); else if (d.isFile()) { const rel = path.relative(folder, full).replace(/\\/g, "/"); const base = d.name.toLowerCase(); if (!index.has(base)) index.set(base, []); index.get(base).push({ full, rel, lower: rel.toLowerCase() }); all.push(rel); } } }; walk(folder, 0); const hitOf = (h) => ({ read: () => fs.readFileSync(h.full), source: h.rel, where: { kind: "file", file: h.full } }); return { label: `extracted files in ${folder}`, find(base, sub, strict) { const h = pick(index.get(base) || [], (x) => x.lower, sub, strict); return h ? hitOf(h) : null; }, bySuffix(suffix) { const s = "/" + suffix.toLowerCase(); const h = (index.get(s.split("/").pop()) || []).find((x) => ("/" + x.lower).endsWith(s)); return h ? hitOf(h) : null; }, get(key) { const full = path.join(folder, key); return fs.existsSync(full) ? hitOf({ full, rel: key }) : null; }, list: () => all.map((rel) => ({ key: rel, path: rel })), close() {}, }; } /** Best hit for a folder hint: resource//, then …//…, then anything. */ function pick(hits, lowerOf, folder, strict) { if (!hits.length) return null; if (!folder) return hits[0]; const f = folder.toLowerCase(); const dirOf = (h) => { const l = "/" + lowerOf(h); return l.slice(0, l.lastIndexOf("/") + 1); }; const exact = hits.find((h) => dirOf(h).endsWith(`/resource/${f}/`)); if (exact) return exact; const inFolder = hits.find((h) => dirOf(h).includes(`/${f}/`)); if (inFolder) return inFolder; return strict ? null : hits[0]; } /** Passwords [type 0, type 1] of the first profile that opens a pack, or null for unencrypted packs. */ function detectedPasswords(pack) { const d = tools.detectPassword(pack, store.keyOrder()); if (!d || !d.encrypted) return null; return store.profile(d.profileId).passwords; } const keyOf = (src) => `${src.kind}:${(src.folder || "").toLowerCase()}:${src.sessionId || ""}`; function sourceFor(src) { if (!src) throw new Error("No source chosen."); const key = keyOf(src); let s = sources.get(key); if (s) return s; if (src.kind === "session") { const { password, file } = iop.handle(src.sessionId); const profileId = iop.sessions().find((x) => x.id === src.sessionId)?.profileId; const pws = store.profile(profileId)?.passwords; s = packSource(path.dirname(file), () => (password ? pws || [password, password] : null)); } else if (src.kind === "packs") s = packSource(src.folder, detectedPasswords); else if (src.kind === "dir") s = dirSource(src.folder); else throw new Error("Unknown source"); sources.set(key, s); return s; } const baseOf = (name) => name.replace(/\\/g, "/").split("/").pop().toLowerCase(); const toBytes = (data) => new Uint8Array(data.buffer, data.byteOffset, data.byteLength); /** Reads a resource by name. → { bytes, source, where } | null */ function read(src, name, folder, strict = false) { const hit = sourceFor(src).find(baseOf(name), folder, strict); if (!hit) return null; return { bytes: toBytes(hit.read()), source: hit.source, where: hit.where }; } /** Reads a resource by the end of its path, e.g. "model/staticmodel/prisoner.txt". */ function readPath(src, suffix) { const hit = sourceFor(src).bySuffix(suffix.replace(/\\/g, "/").replace(/^\/+/, "")); if (!hit) return null; return { bytes: toBytes(hit.read()), source: hit.source, where: hit.where }; } /** Reads a listed item by its key. */ function readKey(src, key) { const hit = sourceFor(src).get(key); if (!hit) return null; return { bytes: toBytes(hit.read()), source: hit.source, where: hit.where }; } // Custom textures (.lsc) are stored scrambled; the client unscrambles them before D3DX loads them: // rotate each byte right by 3, XOR 0xFF, and (newer files) XOR an 8-byte salt. Salted is tried // first and kept when the result looks like an image (ioMemFile::DecompressLSCFile). const SALT = [0x1a, 0x3b, 0x5c, 0x7d, 0x9e, 0x2f, 0x41, 0x68]; const sniff = (b) => b[0] === 0x44 && b[1] === 0x44 && b[2] === 0x53 && b[3] === 0x20 ? "dds" : b[0] === 0x89 && b[1] === 0x50 ? "png" : b[0] === 0xff && b[1] === 0xd8 ? "jpg" : b[0] === 0x42 && b[1] === 0x4d ? "bmp" : null; function unscramble(src, salted) { const out = Buffer.allocUnsafe(src.length); for (let i = 0; i < src.length; i++) { let c = src[i]; c = (((c >> 3) | ((c & 7) << 5)) ^ 0xff) & 0xff; if (salted) c ^= SALT[i % 8]; out[i] = c; } return out; } /** A texture by its bare name. → { bytes, source, ext } | null */ function texture(src, fileName) { const hit = sourceFor(src).find(baseOf(fileName), "texture", false); if (!hit) return null; let data = hit.read(); const named = baseOf(fileName).split(".").pop(); let ext = sniff(data); if (!ext && (named === "lsc" || named === "dds")) { const salted = unscramble(data, true); data = sniff(salted) ? salted : unscramble(data, false); ext = sniff(data) || named; } return { bytes: toBytes(data), source: hit.source, ext: ext || named }; } /** Names of every resource in the source, e.g. for the model browser. */ function list(src) { return sourceFor(src).list(); } function describe(src) { return { label: sourceFor(src).label }; } /** Forgets a source's open packs / file index (after they changed). */ function reload(src) { const key = keyOf(src); sources.get(key)?.close(); sources.delete(key); } /** Forgets every source that reads this pack file (after we wrote into it). */ function forgetPack(file) { const dir = path.dirname(file).toLowerCase(); for (const [key, s] of sources) { if (key.startsWith("dir:")) continue; if (s.label.toLowerCase().endsWith(dir) || key.startsWith("session:")) { s.close(); sources.delete(key); } } } /** * Writes a resource back where it came from: into its pack (as the newest copy of the entry, * comment and encryption kept; through IOP Manager when the pack is open there) or to its file. */ async function writeBack(where, data) { const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength); if (where.kind === "file") { fs.writeFileSync(where.file, buf); return { file: where.file }; } if (where.kind !== "pack") throw new Error("Unknown target"); forgetPack(where.file); const open = iop.sessions().find((s) => s.file.toLowerCase() === where.file.toLowerCase()); // where.create: a new entry is fine (Anim Studio's new animations) if (open) { await (where.create ? iop.addData : iop.replaceData)(open.id, where.entry, buf); return { file: where.file, entry: where.entry }; } if (Pack.recover(where.file)) console.warn("[assets] rolled back an unfinished change of", where.file); const pack = Pack.open(where.file); try { const e = pack.lookup(where.entry); if (!e && !where.create) throw new Error(`${where.entry} isn't in ${path.basename(where.file)}.`); const d = tools.detectPassword(pack, store.keyOrder()); const comment = e ? e.comment.toString("latin1") : ""; const password = d?.encrypted ? store.profile(d.profileId).passwords[comment === "1" ? 1 : d.type ?? 0] : null; tools.addFile(pack, e ? e.name : where.entry, buf, { password, comment, mtime: new Date() }); pack.commit(); } finally { pack.close(); } return { file: where.file, entry: where.entry }; } export { read, readPath, readKey, texture, list, describe, reload, writeBack }; export default { read, readPath, readKey, texture, list, describe, reload, writeBack };