LosaDev Tools: Lost Saga modding tools in the browser
This commit is contained in:
commit
c5e04472fb
105 files changed
+38541
No files matched your search
@@ -0,0 +1,302 @@
|
||||
// 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/<name>,
|
||||
// Mesh/<name>.msh, …), so lookups go by name with a folder hint: resource/<folder>/<name> wins,
|
||||
// then any path with /<folder>/ 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/<folder>/<base>, then …/<folder>/…, 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 };
|
||||
Reference in new issue
Block a user