LosaDev Tools: Lost Saga modding tools in the browser

This commit is contained in:
LosaDev committed 2026-11-07 23:08:43 +07:00
commit c5e04472fb
105 files changed
+38541

No files matched your search

+302
View File
@@ -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 };
+168
View File
@@ -0,0 +1,168 @@
// Ported from LS Tools desktop: main/costumes.js (mechanical CJS -> ESM)
import fs from "node:fs";
import assets from "./assets.js";
import items from "./items.js";
import codec from "./lsmon/codec.js";
import { IniDoc, TextDoc } from "./inidoc.js";
// Costume tab: registering a costume so the game can give and wear it. The client reads
// config/sp2_costume.ini → config/costume/costume_{armor,helmet,cloak}.ini ([common_info]
// costume_cnt, [costumeN] costum_code / name = STR(1) / icon_name / body_mesh …; the code's
// 100 000s digit is the kind: 1 armor, 2 helmet, 3 cloak — ioCostumeInfoManager), the names come
// from the string tables (|INI_costume_armor::costumeN_1|text|), and the game server only accepts
// codes listed in its own config/sp2_costumemanager.ini ([costumeN] item_code — CostumeManager).
// A player gets one as a present of type 12 (value1 = code, value2 = hours, 0 = for good).
const KINDS = {
armor: { file: "costume/costume_armor.ini", type: 1, meshes: ["body_mesh", "hand_mesh"] },
helmet: { file: "costume/costume_helmet.ini", type: 2, meshes: ["helmet_mesh"] },
cloak: { file: "costume/costume_cloak.ini", type: 3, meshes: ["cloak_mesh"] },
};
const BASE = 100000;
const num = (v) => {
const n = parseInt(String(v ?? "").trim(), 10);
return Number.isFinite(n) ? n : 0;
};
const stemOf = (kind) => KINDS[kind].file.split("/").pop().replace(/\.ini$/i, "").toLowerCase();
function readIni(src, kind) {
const name = KINDS[kind].file;
const hit = assets.readKey(src, `rs.iop|config/${name}`) || assets.readPath(src, `config/${name}`);
if (!hit) throw new Error(`config/${name} isn't in these packs.`);
return { doc: new IniDoc(Buffer.from(hit.bytes)), where: hit.where };
}
/** The string tables with the code page their costume names are written in (HK: text.txt is Big5 too). */
function texts(src, kind) {
const out = [];
for (const t of items.textFiles(src)) {
const hit = assets.readKey(src, t.key);
if (!hit) continue;
const doc = new TextDoc(Buffer.from(hit.bytes));
out.push({ ...t, doc, where: hit.where, encoding: guess(doc, kind, t.encoding) });
}
return out;
}
function guess(doc, kind, fallback) {
const prefix = `ini_${stemOf(kind)}::`;
const parts = [];
for (const [k] of doc.index) {
if (!k.startsWith(prefix)) continue;
const raw = doc.get(k);
if (raw && /[\x80-\xff]/.test(raw)) parts.push(Buffer.from(raw, "latin1"));
if (parts.length >= 80) break;
}
if (!parts.length) return fallback;
const score = (enc, re) => {
const text = parts.map((p) => codec.decodeText(p, enc)).join("");
const wide = [...text].filter((ch) => ch.charCodeAt(0) >= 0x80);
return wide.length ? wide.filter((ch) => re.test(ch)).length / wide.length - (text.match(/�/g) || []).length / wide.length : 0;
};
const kr = score("euc-kr", /[가-힣]/);
const tw = score("big5", /[一-鿿]/);
return kr >= 0.6 || kr >= tw - 0.05 ? "euc-kr" : "big5";
}
/** The costumes of a kind: [{ section, code, name, icon, grade, meshes }] + the next free code. */
function info(src, kind) {
const k = KINDS[kind];
if (!k) throw new Error(`Unknown costume kind ${kind}`);
const { doc } = readIni(src, kind);
const tx = texts(src, kind);
const t = tx[0];
const name = (section, raw) => {
const m = /STR\(\s*(\d+)/i.exec(raw || "");
if (m && t) {
const v = t.doc.get(`ini_${stemOf(kind)}::${section}_${m[1]}`);
if (v !== undefined) return codec.decodeText(Buffer.from(v, "latin1"), t.encoding);
}
return raw && /[\x80-\xff]/.test(raw) ? codec.decode(Buffer.from(raw, "latin1"), "euc-kr") : raw || "";
};
const count = num(doc.get("common_info", "costume_cnt"));
const list = [];
for (const s of doc.order) {
const m = /^costume(\d+)$/i.exec(s.name);
if (!m) continue;
const code = num(doc.get(s.name, "costum_code"));
if (!code) continue;
const meshes = {};
for (const key of k.meshes) meshes[key] = doc.get(s.name, key) || "";
list.push({ section: s.name, n: Number(m[1]), code, name: name(s.name, doc.get(s.name, "name")), icon: doc.get(s.name, "icon_name") || "", grade: num(doc.get(s.name, "grade_type")), meshes, read: Number(m[1]) <= count });
}
const inRange = list.map((c) => c.code).filter((c) => Math.floor(c / BASE) === k.type);
const next = (inRange.length ? Math.max(...inRange) : k.type * BASE + 10000) + 1;
return { kind, file: k.file, count, list, next, texts: tx.map((x) => ({ file: x.file, encoding: x.encoding })) };
}
/**
* Adds a costume: { kind, code, name, grade, icon, meshes: { body_mesh, hand_mesh, … }, template
* (section to copy the other keys from), serverFile (a copy of the server's sp2_costumemanager.ini
* to add the code to; optional) } → { section, code, written: [...], server }
*/
async function register(src, e) {
const k = KINDS[e.kind];
if (!k) throw new Error(`Unknown costume kind ${e.kind}`);
const code = num(e.code);
if (Math.floor(code / BASE) !== k.type) throw new Error(`A ${e.kind} code starts with ${k.type} (${k.type}xxxxx); ${code} is another kind.`);
if (!String(e.name || "").trim()) throw new Error("Give the costume a name.");
const { doc, where } = readIni(src, e.kind);
for (const s of doc.order) if (num(doc.get(s.name, "costum_code")) === code) throw new Error(`${code} is already [${s.name}] of ${k.file}.`);
// the client reads [costume1] … [costume<costume_cnt>]: the new one goes right after
let n = num(doc.get("common_info", "costume_cnt")) + 1;
while (doc.has(`costume${n}`)) n++;
const section = `costume${n}`;
// e.extra: keys of the kind set here (a helmet: hair_change_hm / _hw, invisible_face), not copied
const extra = Object.entries(e.extra || {});
const skip = new Set(["name", "costum_code", "icon_name", "grade_type", "manual", "item_equip_effect", "item_equip_effect_hw", "spring_biped", ...k.meshes, ...extra.map(([key]) => key.toLowerCase())]);
const copied = e.template && doc.has(e.template) ? doc.entries(e.template).filter(([key]) => !skip.has(key.toLowerCase())) : [];
const entries = [
["name", "STR(1)"],
["costum_code", String(code)],
["manual", "1"],
["grade_type", e.grade ? String(e.grade) : null],
["icon_name", e.icon || null],
...copied,
...extra,
...k.meshes.map((key) => [key, e.meshes?.[key] || null]),
];
doc.addSection(section, entries);
doc.update("common_info", { costume_cnt: String(n) });
// the name, in every string table, in its own code page
const warnings = [];
const tx = texts(src, e.kind);
if (!tx.length) warnings.push("No string table (resource/text) in these packs: the name shows as a key.");
for (const t of tx) {
const bytes = codec.encode(String(e.name).replace(/[\r\n|]/g, " "), t.encoding);
if (codec.decode(bytes, t.encoding) !== String(e.name).replace(/[\r\n|]/g, " ")) warnings.push(`${t.file}: some characters don't exist in ${t.encoding} and became "?".`);
t.doc.set(`${t.doc.iniPrefix()}${stemOf(e.kind)}::${section}_1`, bytes.toString("latin1"));
}
const written = [];
await assets.writeBack(where, doc.toBuffer());
written.push(`config/${k.file}`);
for (const t of tx) {
await assets.writeBack(t.where, t.doc.toBuffer());
written.push(`resource/text/${t.file}`);
}
const server = e.serverFile ? addToServer(e.serverFile, code) : null;
return { section, code, written, warnings, server, snippet: serverSnippet(code) };
}
const serverSnippet = (code) => `[costumeN]\r\nitem_code = ${code}`;
/** Adds the code to a copy of the server's sp2_costumemanager.ini (a .bak is kept). */
function addToServer(file, code) {
const buf = fs.readFileSync(file);
const doc = new IniDoc(buf);
if (!doc.has("common")) throw new Error(`${file} has no [common]: is it sp2_costumemanager.ini?`);
for (const s of doc.order) if (num(doc.get(s.name, "item_code")) === code) return { file, section: s.name, added: false };
let n = num(doc.get("common", "costume_cnt")) + 1;
while (doc.has(`costume${n}`)) n++;
doc.addSection(`costume${n}`, [["item_code", String(code)]]);
doc.update("common", { costume_cnt: String(n) });
fs.writeFileSync(`${file}.bak`, buf);
fs.writeFileSync(file, doc.toBuffer());
return { file, section: `costume${n}`, added: true };
}
export { info, register, addToServer, KINDS };
export default { info, register, addToServer, KINDS };
+444
View File
@@ -0,0 +1,444 @@
// Ported from LS Tools desktop: main/dattable.js (mechanical CJS -> ESM)
import fs from "node:fs";
import path from "node:path";
import assets from "./assets.js";
import codec from "./lsmon/codec.js";
import xlsx from "./xlsx.js";
// Tables tab: the game's .dat tables (made by ioDataMaker from Excel; client: resource/tables/*.dat in
// the packs, loaded by BaseDataManager::LoadBin; server: ls_gamesvr/config/tables/*.dat). Layout:
// int toolVersion, int headerVersion, int dataVersion, int columnCount,
// per column: int size, int type, int nameLength, name bytes,
// int rowCount, then rowCount packed rows (#pragma pack 1) of the columns in order.
// headerVersion must match the one compiled into the client / server, so the columns can't be
// changed here — rows and values can. Untouched cells are written back byte for byte.
const TYPES = {
1: { name: "BYTE", read: (b, o) => b.readUInt8(o), write: (b, o, v) => b.writeUInt8(v, o), min: 0, max: 255 },
2: { name: "WORD", read: (b, o) => b.readUInt16LE(o), write: (b, o, v) => b.writeUInt16LE(v, o), min: 0, max: 65535 },
3: { name: "DWORD", read: (b, o) => b.readUInt32LE(o), write: (b, o, v) => b.writeUInt32LE(v, o), min: 0, max: 4294967295 },
4: { name: "STRING" },
5: { name: "SHORT", read: (b, o) => b.readInt16LE(o), write: (b, o, v) => b.writeInt16LE(v, o), min: -32768, max: 32767 },
6: { name: "USHORT", read: (b, o) => b.readUInt16LE(o), write: (b, o, v) => b.writeUInt16LE(v, o), min: 0, max: 65535 },
7: { name: "INT", read: (b, o) => b.readInt32LE(o), write: (b, o, v) => b.writeInt32LE(v, o), min: -2147483648, max: 2147483647 },
8: { name: "UINT", read: (b, o) => b.readUInt32LE(o), write: (b, o, v) => b.writeUInt32LE(v, o), min: 0, max: 4294967295 },
9: { name: "LONG", read: (b, o) => b.readInt32LE(o), write: (b, o, v) => b.writeInt32LE(v, o), min: -2147483648, max: 2147483647 },
10: { name: "ULONG", read: (b, o) => b.readUInt32LE(o), write: (b, o, v) => b.writeUInt32LE(v, o), min: 0, max: 4294967295 },
11: { name: "FLOAT", float: true, read: (b, o) => b.readFloatLE(o), write: (b, o, v) => b.writeFloatLE(v, o) },
13: { name: "ENUM", read: (b, o) => b.readInt32LE(o), write: (b, o, v) => b.writeInt32LE(v, o), min: -2147483648, max: 2147483647 },
14: { name: "BOOL", read: (b, o) => b.readUInt8(o), write: (b, o, v) => b.writeUInt8(v, o), min: 0, max: 255 },
};
// a type number not seen yet: read by its size
const BY_SIZE = {
1: { name: "BYTE?", read: (b, o) => b.readUInt8(o), write: (b, o, v) => b.writeUInt8(v, o), min: 0, max: 255 },
2: { name: "WORD?", read: (b, o) => b.readUInt16LE(o), write: (b, o, v) => b.writeUInt16LE(v, o), min: 0, max: 65535 },
4: { name: "INT?", read: (b, o) => b.readInt32LE(o), write: (b, o, v) => b.writeInt32LE(v, o), min: -2147483648, max: 2147483647 },
8: { name: "DOUBLE?", float: true, read: (b, o) => b.readDoubleLE(o), write: (b, o, v) => b.writeDoubleLE(v, o) },
};
const typeOf = (c) => (c.type === 4 ? TYPES[4] : TYPES[c.type] && (c.type !== 11 || c.size === 4) ? TYPES[c.type] : BY_SIZE[c.size] || null);
const fmtFloat = (v) => String(Number(v.toPrecision(7)));
function parse(buf) {
let o = 0;
const need = (n) => {
if (o + n > buf.length) throw new Error("This .dat file is cut short.");
};
const i32 = () => (need(4), (o += 4), buf.readInt32LE(o - 4));
const tool = i32();
const headerVersion = i32();
const dataVersion = i32();
const count = i32();
if (count <= 0 || count > 1000) throw new Error("Not a table .dat (column count).");
const cols = [];
let offset = 0;
for (let i = 0; i < count; i++) {
const size = i32();
const type = i32();
const nameLength = i32();
need(nameLength);
const name = buf.toString("latin1", o, o + nameLength).replace(/\0[\s\S]*$/, "");
o += nameLength;
if (size <= 0 || size > 65536) throw new Error(`Column ${name}: bad size ${size}.`);
const t = typeOf({ type, size });
cols.push({ name, type, size, offset, typeName: type === 4 ? `STRING[${size}]` : t?.name || `TYPE ${type}`, float: !!t?.float, text: type === 4, known: !!t });
offset += size;
}
const rowSize = offset;
const totalAt = o;
const total = i32();
if (total < 0 || totalAt + 4 + total * rowSize > buf.length) throw new Error(`Row data is shorter than ${total} rows of ${rowSize} bytes.`);
const dataAt = o;
const trailing = buf.length - (dataAt + total * rowSize);
return { tool, headerVersion, dataVersion, cols, rowSize, total, head: buf.subarray(0, totalAt), dataAt, trailing: trailing ? buf.subarray(dataAt + total * rowSize) : null, buf };
}
function cellText(t, row, c, encoding) {
const at = t.dataAt + row * t.rowSize + c.offset;
if (c.text) {
const raw = t.buf.subarray(at, at + c.size);
const end = raw.indexOf(0);
return codec.decode(raw.subarray(0, end < 0 ? raw.length : end), encoding);
}
const ty = typeOf(c);
if (!ty) return t.buf.subarray(at, at + c.size).toString("hex");
const v = ty.read(t.buf, at);
return ty.float ? fmtFloat(v) : String(v);
}
/** Values as strings: [[…], …] */
function rowsOf(t, encoding) {
const out = new Array(t.total);
for (let r = 0; r < t.total; r++) out[r] = t.cols.map((c) => cellText(t, r, c, encoding));
return out;
}
/**
* rows: [{ o: original row index or -1, v: [value strings] }] → bytes, or throws with the list of
* bad cells. A cell equal to its original text keeps its original bytes.
*/
function build(t, rows, encoding, enumLookup = () => null) {
const errors = [];
const out = Buffer.alloc(t.head.length + 4 + rows.length * t.rowSize + (t.trailing?.length || 0));
t.head.copy(out, 0);
out.writeInt32LE(rows.length, t.head.length);
const base = t.head.length + 4;
rows.forEach((row, r) => {
const at = base + r * t.rowSize;
t.cols.forEach((c, ci) => {
const value = String(row.v[ci] ?? "");
const dst = at + c.offset;
if (row.o >= 0 && row.o < t.total && cellText(t, row.o, c, encoding) === value) {
const src = t.dataAt + row.o * t.rowSize + c.offset;
t.buf.copy(out, dst, src, src + c.size);
return;
}
if (c.text) {
const bytes = codec.encode(value, encoding);
if (codec.decode(bytes, encoding) !== value) errors.push({ r, c: ci, msg: `has characters ${encoding} can't hold` });
if (bytes.length > c.size - 1) errors.push({ r, c: ci, msg: `is ${bytes.length} bytes; ${c.name} holds ${c.size - 1}` });
bytes.copy(out, dst, 0, Math.min(bytes.length, c.size - 1));
return;
}
const ty = typeOf(c);
if (!ty) {
const hex = Buffer.from(value, "hex");
if (hex.length !== c.size) errors.push({ r, c: ci, msg: `must be ${c.size} bytes of hex` });
hex.copy(out, dst, 0, c.size);
return;
}
let v;
if (ty.float) v = Number(value || 0);
else if (/^-?\d+$/.test(value.trim() || "0")) v = Number(value.trim() || 0);
else if (c.type === 13 && enumLookup(c.name, value.trim()) !== null) v = enumLookup(c.name, value.trim());
else if (/^(true|false)$/i.test(value.trim())) v = /^true$/i.test(value.trim()) ? 1 : 0;
else v = NaN;
if (!Number.isFinite(v)) {
errors.push({ r, c: ci, msg: `"${value}" isn't a number` });
return;
}
if (!ty.float && (v < ty.min || v > ty.max)) {
errors.push({ r, c: ci, msg: `${v} is outside ${ty.name} (${ty.min} … ${ty.max})` });
return;
}
ty.write(out, dst, v);
});
});
if (t.trailing) t.trailing.copy(out, base + rows.length * t.rowSize);
if (errors.length) {
const e = new Error(`${errors.length} cell(s) can't be saved: ${errors.slice(0, 5).map((x) => `row ${x.r + 1} ${t.cols[x.c].name} ${x.msg}`).join("; ")}${errors.length > 5 ? " …" : ""}`);
e.cells = errors;
throw e;
}
return out;
}
/**
* The code page of a table's texts: Korean (the ioDataMaker sheets are Korean) unless the bytes read
* better as the region's own (e.g. Big5 for HK). Picks by how many decoded characters are Hangul / CJK.
*/
function guessEncoding(t, fallback = "euc-kr") {
const parts = [];
for (let r = 0; r < Math.min(t.total, 400); r++)
for (const c of t.cols) if (c.text) {
const at = t.dataAt + r * t.rowSize + c.offset;
const raw = t.buf.subarray(at, at + c.size);
const end = raw.indexOf(0);
const s = raw.subarray(0, end < 0 ? raw.length : end);
if (s.some((b) => b >= 0x80)) parts.push(s);
}
if (!parts.length) return fallback;
const score = (enc, re) => {
const text = parts.map((p) => codec.decode(p, enc)).join("");
const wide = [...text].filter((ch) => ch.charCodeAt(0) >= 0x80);
return wide.length ? wide.filter((ch) => re.test(ch)).length / wide.length - (text.match(/�/g) || []).length / wide.length : 0;
};
const kr = score("euc-kr", /[가-힣]/);
const tw = score("big5", /[一-鿿]/);
// Korean read as Big5 gives rare hanzi too: Korean wins unless it scores clearly worse
return kr >= 0.6 || kr >= tw - 0.05 ? "euc-kr" : "big5";
}
// ---------- where tables are ----------
/** Client tables in a pack source: [{ key, name, path, pack }] */
function clientTables(src) {
return assets
.list(src)
.filter((it) => /(^|\/)tables\/[^/]+\.dat$/i.test(it.path))
.map((it) => ({ key: it.key, name: path.basename(it.path), path: it.path, pack: it.pack || "" }));
}
function folderTables(folder) {
if (!folder || !fs.existsSync(folder)) return [];
return fs
.readdirSync(folder)
.filter((f) => /\.dat$/i.test(f) && fs.statSync(path.join(folder, f)).isFile())
.map((f) => ({ key: path.join(folder, f), name: f, path: path.join(folder, f) }));
}
// ---------- open tables ----------
const open = new Map(); // id → { where, label, t, encoding, name }
let seq = 0;
function load(ref, encoding) {
let buf;
let where;
let label;
if (ref.kind === "pack") {
const hit = assets.readKey(ref.src, ref.key);
if (!hit) throw new Error(`${ref.key} isn't in the packs any more.`);
buf = Buffer.from(hit.bytes);
where = hit.where;
label = `${ref.key.split("|")[0]} → ${ref.key.split("|")[1]}`;
} else {
buf = fs.readFileSync(ref.file);
where = { kind: "file", file: ref.file };
label = ref.file;
}
const t = parse(buf);
if (!encoding || encoding === "auto") encoding = guessEncoding(t);
const id = ++seq;
const name = path.basename(ref.kind === "pack" ? ref.key.split("|")[1] : ref.file).replace(/\.dat$/i, "");
open.set(id, { where, label, t, encoding, name, ref });
return { id, ...describe(id) };
}
function describe(id) {
const x = open.get(id);
if (!x) throw new Error("That table was closed.");
const { t } = x;
return {
name: x.name,
label: x.label,
encoding: x.encoding,
tool: t.tool,
headerVersion: t.headerVersion.toString(16),
dataVersion: t.dataVersion,
rowSize: t.rowSize,
cols: t.cols.map(({ name, type, size, typeName, float, text, known }) => ({ name, type, size, typeName, float, text, known })),
rows: rowsOf(t, x.encoding),
};
}
function setEncoding(id, encoding) {
open.get(id).encoding = encoding;
return describe(id);
}
async function save(id, rows, { enums = {} } = {}) {
const x = open.get(id);
if (!x) throw new Error("That table was closed.");
const lookup = (col, name) => {
const list = enums[col];
const hit = list?.find((e) => e.name.toLowerCase() === name.toLowerCase());
return hit ? hit.value : null;
};
const bytes = build(x.t, rows, x.encoding, lookup);
if (x.where.kind === "file") {
// one backup of the file as it was before this tool first changed it in this session
const bak = `${x.where.file}.bak`;
if (!x.backedUp) {
fs.copyFileSync(x.where.file, bak);
x.backedUp = bak;
}
fs.writeFileSync(x.where.file, bytes);
} else {
await assets.writeBack(x.where, bytes);
}
// what's saved is the new original
x.t = parse(bytes);
return { bytes: bytes.length, rows: rows.length, backup: x.backedUp || null, ...describe(id) };
}
function close(id) {
open.delete(id);
}
// ---------- Excel / CSV ----------
function exportXlsx(id, rows, file) {
const x = open.get(id);
const cols = x.t.cols;
const sheet = {
name: x.name,
rows: [cols.map((c) => c.name), ...rows.map((r) => r.map((v, i) => (!cols[i].text && v !== "" && Number.isFinite(Number(v)) ? Number(v) : v)))],
};
const types = { name: "Columns", rows: [["Column", "Type", "Bytes"], ...cols.map((c) => [c.name, c.typeName, c.size])] };
fs.writeFileSync(file, xlsx.writeXlsx([sheet, types]));
return file;
}
/** A sheet / CSV whose first row names the columns → rows in this table's column order. */
function importFile(id, file) {
const x = open.get(id);
let grid;
if (/\.xlsx$/i.test(file)) {
const sheets = xlsx.readXlsx(fs.readFileSync(file));
const pick = sheets.find((s) => s.name.toLowerCase() === x.name.toLowerCase()) || sheets[0];
grid = pick.rows;
// an ioDataMaker source sheet: rows EXPORT / OPTION / TYPE / HEAD, then data (";" rows skipped)
const head = grid.findIndex((r) => String(r[0]).toUpperCase() === "HEAD");
if (head >= 0) {
const names = grid[head].slice(1);
const data = grid.slice(head + 1).filter((r) => r.length && String(r[0]).trim() !== ";" && !/^(END|REMARK)$/i.test(String(r[0]).trim()));
const end = data.findIndex((r) => /^END$/i.test(String(r[0] || r[1] || "").trim()));
grid = [names, ...(end >= 0 ? data.slice(0, end) : data).map((r) => r.slice(1))];
}
} else {
const text = fs.readFileSync(file, "utf8").replace(/^/, "");
grid = parseCsv(text, /\.tsv$|\.txt$/i.test(file) ? "\t" : text.split("\n")[0].includes("\t") ? "\t" : ",");
}
if (!grid.length) throw new Error("The file is empty.");
const names = grid[0].map((n) => String(n).trim().toLowerCase());
const map = x.t.cols.map((c) => names.indexOf(c.name.toLowerCase()));
const missing = x.t.cols.filter((_, i) => map[i] < 0).map((c) => c.name);
const rows = grid
.slice(1)
.filter((r) => r.some((v) => String(v).trim() !== ""))
.map((r) => map.map((i) => (i < 0 ? "" : String(r[i] ?? ""))));
return { rows, missing, extra: grid[0].filter((n) => !x.t.cols.some((c) => c.name.toLowerCase() === String(n).trim().toLowerCase())) };
}
function parseCsv(text, sep) {
const rows = [];
let row = [];
let cell = "";
let q = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (q) {
if (ch === '"' && text[i + 1] === '"') (cell += '"'), i++;
else if (ch === '"') q = false;
else cell += ch;
} else if (ch === '"' && cell === "") q = true;
else if (ch === sep) row.push(cell), (cell = "");
else if (ch === "\n") row.push(cell.replace(/\r$/, "")), rows.push(row), (row = []), (cell = "");
else cell += ch;
}
if (cell !== "" || row.length) row.push(cell.replace(/\r$/, "")), rows.push(row);
return rows;
}
function exportCsv(id, rows, file) {
const x = open.get(id);
const esc = (v) => (/[",\n\r]/.test(v) ? `"${String(v).replace(/"/g, '""')}"` : String(v));
const text = [x.t.cols.map((c) => c.name), ...rows].map((r) => r.map(esc).join(",")).join("\r\n");
fs.writeFileSync(file, "" + text, "utf8");
return file;
}
// ---------- web addition: export for the client or the server ----------
/**
* Client and server .dat of the same table have different columns (ioDataMaker exports some only to
* one side) and their own header version. This writes the open table's rows in the layout of
* `target` (that side's .dat of the same table, used as the template): columns are matched by name;
* a column the open table doesn't have takes the template row with the same key (first column), or
* 0 / empty. Unchanged cells stay byte-identical to the template. → the file is written to `file`.
*/
function exportFor(id, rows, target, file) {
const x = open.get(id);
if (!x) throw new Error("That table was closed.");
let buf;
if (target.kind === "pack") {
const hit = assets.readKey(target.src, target.key);
if (!hit) throw new Error(`${target.key} isn't in the packs any more.`);
buf = Buffer.from(hit.bytes);
} else buf = fs.readFileSync(target.file);
const t2 = parse(buf);
const enc = guessEncoding(t2, x.encoding);
const lower = (s) => s.toLowerCase();
const from = t2.cols.map((c) => x.t.cols.findIndex((s) => lower(s.name) === lower(c.name)));
const missing = t2.cols.filter((_, i) => from[i] < 0).map((c) => c.name);
const dropped = x.t.cols.filter((c) => !t2.cols.some((d) => lower(d.name) === lower(c.name))).map((c) => c.name);
// template rows by key (first column), when both tables share it
const keyFrom = from[0];
const tplRows = rowsOf(t2, enc);
const byKey = new Map();
if (keyFrom >= 0) tplRows.forEach((r, i) => !byKey.has(r[0]) && byKey.set(r[0], i));
const out = rows.map((v) => {
const o = keyFrom >= 0 && byKey.has(String(v[keyFrom])) ? byKey.get(String(v[keyFrom])) : -1;
return {
o,
v: t2.cols.map((c, i) => (from[i] >= 0 ? String(v[from[i]] ?? "") : o >= 0 ? tplRows[o][i] : c.text ? "" : "0")),
};
});
const bytes = build(t2, out, enc);
fs.writeFileSync(file, bytes);
return { file, rows: out.length, missing, dropped, headerVersion: t2.headerVersion.toString(16), bytes: bytes.length };
}
// ---------- ioDataMaker sources: column descriptions and enum names ----------
const metaCache = new Map();
/** From ioDataMaker's Config.xlsx and the table's source sheet: { cols: { name: { desc, export, enumName } }, enums: { col: [{ name, value }] } } */
function meta(folder, table) {
if (!folder) return null;
const cfgFile = path.join(folder, "Config.xlsx");
if (!fs.existsSync(cfgFile)) return null;
const key = `${folder}|${table}`.toLowerCase();
const stamp = fs.statSync(cfgFile).mtimeMs;
const hit = metaCache.get(key);
if (hit && hit.stamp === stamp) return hit.value;
const cfg = xlsx.readXlsx(fs.readFileSync(cfgFile));
const enums = {};
let cur = null;
for (const r of cfg.find((s) => s.name.toLowerCase() === "enum")?.rows || []) {
const kind = String(r[1] || "").trim().toUpperCase();
if (kind === "NAME") (cur = String(r[2] || "").trim()), (enums[cur] = []);
else if (kind === "VAL" && cur) enums[cur].push({ name: String(r[2] || "").trim(), value: Number(r[3]) });
else if (kind === "END") cur = null;
}
const sheets = new Map();
for (const r of cfg.find((s) => s.name.toLowerCase() === "config")?.rows || []) {
if (String(r[0] || "").trim() === ";" || !r[1] || !r[2]) continue;
sheets.set(String(r[2]).trim().toLowerCase(), path.join(folder, String(r[1]).replace(/^\.[\\/]/, "")));
}
const value = { cols: {}, enums: {}, source: null };
const src = sheets.get(table.toLowerCase());
if (src && fs.existsSync(src)) {
const book = xlsx.readXlsx(fs.readFileSync(src));
const sh = book.find((s) => s.name.toLowerCase() === table.toLowerCase());
if (sh) {
value.source = `${path.basename(src)} [${sh.name}]`;
const find = (label) => sh.rows.find((r) => String(r[0] || "").trim().toUpperCase() === label) || [];
const exp = find("EXPORT");
const types = find("TYPE");
const head = find("HEAD");
const descRow = sh.rows[sh.rows.indexOf(head) + 1] || [];
const desc = String(descRow[0] || "").trim() === ";" ? descRow : [];
head.forEach((n, i) => {
if (!i || !n) return;
const name = String(n).trim();
const ty = String(types[i] || "");
const m = /^ENUM\[([^\]]+)\]/i.exec(ty);
value.cols[name] = { desc: String(desc[i] || "").trim(), export: String(exp[i] || "").trim(), enumName: m ? m[1] : null };
if (m) {
const list = enums[m[1]] || enums[`DATA_${m[1]}`] || Object.entries(enums).find(([k]) => k.toLowerCase().endsWith(m[1].toLowerCase()))?.[1];
if (list) value.enums[name] = list;
}
});
}
}
metaCache.set(key, { stamp, value });
return value;
}
export { parse, rowsOf, build, clientTables, folderTables, load, describe, setEncoding, save, close, exportXlsx, exportCsv, importFile, meta, exportFor, TYPES };
export default { parse, rowsOf, build, clientTables, folderTables, load, describe, setEncoding, save, close, exportXlsx, exportCsv, importFile, meta, exportFor, TYPES };
+296
View File
@@ -0,0 +1,296 @@
// Generated by tools/gen-handlers.mjs from the desktop main/index.js registerIpc(). Do not edit.
// Dropped channels: 118
import __req___imagesets from "./imagesets.js";
import __req_node_fs from "node:fs";
import __req___assets from "./assets.js";
import __req___items from "./items.js";
import __req___costumes from "./costumes.js";
import __req___quests from "./quests.js";
import __req___dattable from "./dattable.js";
import __req___models from "./models.js";
import path from "node:path";
import { app } from "electron";
import store from "./store.js";
import iop from "./iopman.js";
const tasks = new Map();
const win = null;
export function register(handle, task) {
handle("task:cancel", (taskId) => tasks.get(taskId)?.abort());
const ims = __req___imagesets;
handle("ims:loadPack", (sessionId, entry) => ims.loadFromPack(sessionId, entry));
handle("ims:loadFile", (file) => ims.loadFromFile(file));
handle("ims:texture", (source, file) => ims.texture(source, file));
handle("ims:describeSource", (source) => ims.describe(source));
handle("ims:savePack", (sessionId, entry, text) => ims.saveToPack(sessionId, entry, text));
handle("ims:saveFile", (file, text) => ims.saveToFile(file, text));
handle("ims:reloadTextures", (source) => ims.reloadTextures(source));
handle("file:writeBase64", (file, b64) => __req_node_fs.writeFileSync(file, Buffer.from(b64, "base64")));
const assets = __req___assets;
const fs = __req_node_fs;
handle("mdl:list", (src) => {
// [key, path, pack] of every model config, mesh, skeleton and animation
const out = [];
for (const it of assets.list(src)) {
const l = it.path.toLowerCase();
if (/\.(msh|ani|skl)$/.test(l) || (l.endsWith(".txt") && /(^|\/)model\//.test(l))) out.push([it.key, it.path, it.pack || ""]);
}
return out;
});
handle("fx:list", (src) => assets.list(src)
.filter((it) => /(^|\/)resource\/effect\/.+\.txt$/i.test(it.path))
.map((it) => [it.key, it.path, it.pack || ""]));
handle("hero:list", (src) => {
const rows = assets.list(src).filter((it) => /(^|\/)config\/mercenary\/(\d{3})\/\2_item\.ini$/i.test(it.path));
return rows.map((it) => {
const id = /mercenary\/(\d{3})\//i.exec(it.path)[1];
const hit = assets.readKey(src, it.key);
const raw = hit ? Buffer.from(hit.bytes).toString("latin1") : "";
const name = /;[-\s/]*\d{3}\.\s*([^\r\n;]+)/.exec(raw)?.[1]?.trim()
|| /\[item1\][\s\S]*?^\s*name\s*=\s*([^\r\n;]+)/im.exec(raw)?.[1]?.trim()
|| `Hero ${id}`;
return [id, name, it.pack || ""];
}).sort((a, b) => Number(a[0]) - Number(b[0]));
});
handle("hero:export", (folder, id, files) => {
if (!/^\d{3}$/.test(id) || !Array.isArray(files) || !files.length) throw new Error("No hero changes to export");
const allowed = new Set(["item", "skill", "attack", "buff"]);
for (const f of files) if (!allowed.has(f.kind) || typeof f.text !== "string") throw new Error("Invalid hero export file");
const root = fs.mkdtempSync(path.join(folder, `hero-${id}-`));
const dir = path.join(root, "config", "mercenary", id);
fs.mkdirSync(dir, { recursive: true });
for (const f of files) fs.writeFileSync(path.join(dir, `${id}_${f.kind}.ini`), Buffer.from(f.text, "latin1"));
fs.writeFileSync(path.join(root, "README.txt"),
`Hero Creator experiment for mercenary ${id}\r\n\r\n` +
`Edited configs are under config/mercenary/${id}/.\r\n` +
`Inspect the changes and test on a disposable client copy before packing.\r\n` +
`No game pack was changed by the export.\r\n`);
return { folder: root, files: files.map((f) => `${id}_${f.kind}.ini`) };
});
const fxlabDir = () => path.join(app.getPath("userData"), "fxlab-presets");
const presetFile = (name) => {
const safe = String(name || "").replace(/[^\w\- .]/g, "_").trim().slice(0, 80);
if (!safe) throw new Error("Preset needs a name");
return path.join(fxlabDir(), `${safe}.json`);
};
handle("fxlab:presets", () => {
if (!fs.existsSync(fxlabDir())) return [];
return fs.readdirSync(fxlabDir()).filter((f) => f.toLowerCase().endsWith(".json")).sort().flatMap((f) => {
try { return [JSON.parse(fs.readFileSync(path.join(fxlabDir(), f), "utf8"))]; } catch { return []; }
});
});
handle("fxlab:savePreset", (preset) => {
fs.mkdirSync(fxlabDir(), { recursive: true });
const file = presetFile(preset?.name);
fs.writeFileSync(file, JSON.stringify(preset, null, 2));
return file;
});
handle("fxlab:deletePreset", (name) => { const f = presetFile(name); if (fs.existsSync(f)) fs.unlinkSync(f); });
handle("fxlab:writeFiles", (folder, files) => {
const root = path.resolve(folder);
const out = [];
for (const f of files || []) {
const dest = path.resolve(root, f.path);
if (!dest.startsWith(root + path.sep)) throw new Error(`Invalid export path ${f.path}`);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(f.b64, "base64"));
out.push(dest);
}
return out;
});
handle("mdl:read", (src, name, folder, strict) => assets.read(src, name, folder, strict));
handle("mdl:readKey", (src, key) => assets.readKey(src, key));
handle("mdl:readPath", (src, suffix) => assets.readPath(src, suffix));
handle("wnd:texts", (src) => {
const hits = assets.list(src).filter((it) => /(^|\/)text\/text\.txt$/i.test(it.path));
let best = null;
for (const it of hits) {
const r = assets.readKey(src, it.key);
if (!r) continue;
const b = Buffer.from(r.bytes.buffer, r.bytes.byteOffset, r.bytes.byteLength);
const s = b.toString("latin1");
const n = (s.match(/\|xml_/gi) || []).length;
if (!best || n > best.n) best = { n, b, source: r.source };
}
if (!best) return null;
// XML lines only (the file also holds every EXE_/INI_ string)
const lines = best.b.toString("latin1").split(/\r?\n/).filter((l) => /^\|xml_/i.test(l));
const sample = Buffer.from(lines.slice(0, 4000).join("\n"), "latin1");
let enc = "big5";
let fewest = Infinity;
for (const e of ["big5", "euc-kr", "gbk", "windows-874", "shift_jis"]) {
const bad = (new TextDecoder(e).decode(sample).match(/�/g) || []).length;
if (bad < fewest) {
fewest = bad;
enc = e;
}
}
const dec = new TextDecoder(enc);
const xml = {};
for (const l of lines) {
const m = /^\|([^|]+)\|(.*)\|\s*$/.exec(l);
if (m) xml[m[1].toLowerCase()] = dec.decode(Buffer.from(m[2], "latin1")).replace(/\\n/g, "\n").replace(/\\r/g, "");
}
return { source: best.source, encoding: enc, xml, count: Object.keys(xml).length };
});
handle("wnd:font", (src) => assets.readPath(src, "font/lostsaga.ttf"));
handle("xml:list", (src) => assets.list(src).filter((it) => /(^|\/)xml\/.+\.xml$/i.test(it.path)).map((it) => [it.key, it.path, it.pack || ""]));
handle("mdl:names", (src, folder) => {
const re = new RegExp(`(^|/)resource/${folder.replace(/[^\w-]/g, "")}/(.+)$`, "i");
const out = new Set();
for (const it of assets.list(src)) {
const m = re.exec(it.path);
if (m && m[2].includes(".")) out.add(m[2]);
}
return [...out].sort();
});
handle("map:list", (src) => {
const out = [];
for (const it of assets.list(src)) {
const l = it.path.toLowerCase();
const m = /(^|\/)config\/mode\/(.+)\.ini$/.exec(l);
if (!m) continue;
let text;
try {
text = Buffer.from(assets.readKey(src, it.key).bytes).toString("latin1");
} catch {
continue;
}
const mesh = /^\s*\[map\][^[]*?^\s*mesh_name1?\s*=\s*([^\r\n;]+)/im.exec(text);
if (!mesh) continue;
const parts = m[2].split("/");
out.push([it.key, it.path, parts.length > 1 ? parts[0] : "", mesh[1].trim(), it.pack || ""]);
}
return out;
});
handle("items:load", (src, opts) => __req___items.load(src, opts || {}));
handle("items:detail", (src, textFile, categoryId, index) => __req___items.detail(src, textFile, categoryId, index));
handle("cos:info", (src, kind) => __req___costumes.info(src, kind));
handle("cos:register", (src, entry) => __req___costumes.register(src, entry));
handle("q:spec", () => __req___quests.spec());
handle("q:load", (src, opts) => __req___quests.load(src, opts || {}));
handle("q:quest", (src, t, section) => __req___quests.quest(src, t, section));
handle("q:section", (src, t, file, section) => __req___quests.section(src, t, file, section));
handle("q:presents", (src, t) => __req___quests.presents(src, t));
handle("q:presentLabel", (src, t, type, v1, v2) => __req___quests.presentLabel(src, t, type, v1, v2));
handle("q:missions", (src, t, id) => __req___quests.missions(src, t, id));
handle("q:help", (src, t, index) => __req___quests.help(src, t, index));
handle("q:check", (src, t) => __req___quests.check(src, t));
handle("q:next", (src, t) => __req___quests.next(src, t));
handle("q:pickLists", (src, t) => __req___quests.pickLists(src, t));
handle("q:save", (src, t, changes) => __req___quests.save(src, t, changes));
handle("q:export", (src, t, folder) => __req___quests.exportTo(src, t, folder));
handle("dat:client", (src) => __req___dattable.clientTables(src));
handle("dat:folder", (folder) => __req___dattable.folderTables(folder));
handle("dat:open", (ref, encoding) => __req___dattable.load(ref, encoding));
handle("dat:encoding", (id, encoding) => __req___dattable.setEncoding(id, encoding));
handle("dat:save", (id, rows, opts) => __req___dattable.save(id, rows, opts || {}));
handle("dat:close", (id) => __req___dattable.close(id));
handle("dat:meta", (folder, table) => __req___dattable.meta(folder, table));
handle("dat:exportXlsx", (id, rows, file) => __req___dattable.exportXlsx(id, rows, file));
handle("dat:exportCsv", (id, rows, file) => __req___dattable.exportCsv(id, rows, file));
handle("dat:import", (id, file) => __req___dattable.importFile(id, file));
handle("mdl:texture", (src, name) => assets.texture(src, name));
handle("mdl:describe", (src) => assets.describe(src));
handle("mdl:reload", (src) => (assets.reload(src), models.forget(src)));
const models = __req___models;
handle("mdl:findSkeleton", (src, q) => models.findSkeleton(src, q));
handle("mdl:usedBy", (src, q) => models.usedBy(src, q));
handle("mdl:readFile", (file) => {
const b = fs.readFileSync(file);
return { bytes: new Uint8Array(b.buffer, b.byteOffset, b.byteLength), source: file, where: { kind: "file", file } };
});
handle("mdl:writeBack", (where, bytes) => assets.writeBack(where, bytes));
handle("tex:list", (dir) =>
fs
.readdirSync(dir, { withFileTypes: true })
.filter((d) => d.isFile() && /\.(dds|png|jpe?g|tga|bmp)$/i.test(d.name))
.map((d) => {
const st = fs.statSync(path.join(dir, d.name));
return { name: d.name, size: st.size, mtime: st.mtimeMs };
}),
);
handle("anim:mtime", (file) => {
try {
return fs.statSync(file).mtimeMs;
} catch {
return null;
}
});
handle("mdl:writeFiles", (folder, files) => {
fs.mkdirSync(folder, { recursive: true });
for (const f of files) {
const target = path.join(folder, path.basename(f.name));
fs.writeFileSync(target, typeof f.data === "string" ? Buffer.from(f.data, "latin1") : Buffer.from(f.data.buffer, f.data.byteOffset, f.data.byteLength));
}
return { folder, count: files.length };
});
}
+44
View File
@@ -0,0 +1,44 @@
// Ported from LS Tools desktop: main/imagesets.js (mechanical CJS -> ESM)
import fs from "node:fs";
import path from "node:path";
import tools from "../core/tools.js";
import iop from "./iopman.js";
import assets from "./assets.js";
// UI Image Set editor, main side: loads xml/uiimageset.xml (from an open pack or a file), finds
// the textures its imagesets point at, and saves the XML back into the pack or to a file.
//
// Textures (File="ui_icon_event1.dds", a bare name) come from an asset source (see assets.js):
// the packs next to a file open in IOP Manager, a folder of .iop packs, or a folder of extracted
// files; resource/texture/<name> wins over the same name elsewhere.
//
// The XML is ASCII (euc-kr declared) and travels as a latin1 string, so every byte round-trips.
/** The XML inside an open IOP Manager session. */
function loadFromPack(sessionId, entryName) {
const { pack, password, file } = iop.handle(sessionId);
const e = pack.lookup(entryName);
if (!e) throw new Error(`${entryName} isn't in ${path.basename(file)}.`);
const data = tools.readEntry(pack, e, password, { xorIni: false });
return { text: data.toString("latin1"), source: { kind: "pack", sessionId, entry: e.name, file } };
}
function loadFromFile(file) {
return { text: fs.readFileSync(file).toString("latin1"), source: { kind: "file", file } };
}
/** Writes the XML back into its pack (newest copy of the entry, comment kept). */
function saveToPack(sessionId, entryName, text) {
return iop.replaceData(sessionId, entryName, Buffer.from(text, "latin1"));
}
function saveToFile(file, text) {
fs.writeFileSync(file, Buffer.from(text, "latin1"));
return { file };
}
const __export_texture = assets.texture;
const __export_describe = assets.describe;
const __export_reloadTextures = assets.reload;
export { loadFromPack, loadFromFile, __export_texture as texture, __export_describe as describe, saveToPack, saveToFile, __export_reloadTextures as reloadTextures };
export default { loadFromPack, loadFromFile, texture: __export_texture, describe: __export_describe, saveToPack, saveToFile, reloadTextures: __export_reloadTextures };
+198
View File
@@ -0,0 +1,198 @@
// Ported from LS Tools desktop: main/inidoc.js (mechanical CJS -> ESM)
// Source-preserving editing of the game's INI files and string tables: everything is kept as
// latin1 text (bytes as they are, so the CP949 comments survive), only the lines that change are
// rewritten, and new keys / sections are added next to their neighbours. Parsing follows the
// client (see docs/quests.md): case-insensitive names, the first copy of a section or key wins,
// ";" and "//" lines are comments, an empty value counts as missing.
class IniDoc {
/** @param {Buffer} buf */
constructor(buf) {
this.lines = buf.toString("latin1").split("\n");
// line ends: keep each line's "\r"; new lines use the file's usual ending
const cr = this.lines.filter((l) => l.endsWith("\r")).length;
this.cr = cr * 2 >= this.lines.length - 1 ? "\r" : "";
this.reindex();
}
reindex() {
this.sections = new Map(); // lower name → { name, header, end, keys: Map(lower → line), last }
this.order = [];
let cur = null;
for (let i = 0; i < this.lines.length; i++) {
const line = this.lines[i].trim();
if (!line || line.startsWith(";") || line.startsWith("//")) continue;
const m = /^\[([^\]]+)\]/.exec(line);
if (m) {
if (cur) cur.end = i;
const name = m[1].trim();
const key = name.toLowerCase();
if (this.sections.has(key)) {
cur = { name, header: i, end: i + 1, keys: new Map(), last: i, dup: true }; // a later copy: read by nobody
} else {
cur = { name, header: i, end: i + 1, keys: new Map(), last: i };
this.sections.set(key, cur);
this.order.push(cur);
}
continue;
}
const eq = line.indexOf("=");
if (!cur || eq < 1) continue;
const k = line.slice(0, eq).trim().toLowerCase();
if (!cur.keys.has(k)) cur.keys.set(k, i);
cur.last = i;
}
if (cur) cur.end = this.lines.length;
}
section(name) {
return this.sections.get(String(name).toLowerCase()) || null;
}
has(name) {
return this.sections.has(String(name).toLowerCase());
}
/** Raw value (latin1, trimmed) or undefined. An empty value counts as missing, like the client. */
get(sec, key) {
const s = this.section(sec);
const i = s?.keys.get(String(key).toLowerCase());
if (i === undefined) return undefined;
const line = this.lines[i];
const v = line.slice(line.indexOf("=") + 1).trim();
return v === "" ? undefined : v;
}
/** Every key of a section: [[key as written, raw value]] in file order. */
entries(sec) {
const s = this.section(sec);
if (!s) return [];
return [...s.keys.values()]
.sort((a, b) => a - b)
.map((i) => {
const line = this.lines[i];
const eq = line.indexOf("=");
return [line.slice(0, eq).trim(), line.slice(eq + 1).trim()];
});
}
/**
* Changes one section: values[key] = raw string sets or adds the key, null removes it.
* Keys that are already there keep their place and spacing; new ones go after the last key.
*/
update(sec, values) {
const s = this.section(sec);
if (!s) throw new Error(`[${sec}] isn't in the file.`);
const removals = [];
const inserts = [];
for (const [key, value] of Object.entries(values)) {
const i = s.keys.get(key.toLowerCase());
if (value === null || value === undefined || value === "") {
if (i !== undefined) removals.push(i);
continue;
}
const v = String(value);
if (/[\r\n]/.test(v)) throw new Error(`${key}: a value can't span lines.`);
if (i !== undefined) {
const line = this.lines[i];
const cr = line.endsWith("\r") ? "\r" : "";
const body = cr ? line.slice(0, -1) : line;
const eq = body.indexOf("=");
const lead = /^[ \t]*/.exec(body.slice(eq + 1))[0];
this.lines[i] = `${body.slice(0, eq + 1)}${lead || " "}${v}${cr}`;
} else inserts.push(`${key}\t=\t${v}${this.cr}`);
}
let at = s.last + 1;
for (const i of removals.sort((a, b) => b - a)) {
this.lines.splice(i, 1);
if (i < at) at--;
}
if (inserts.length) this.lines.splice(at, 0, ...inserts);
if (removals.length || inserts.length) this.reindex();
}
/** Adds [name] with its keys at the end of the file (or after the section `after`). */
addSection(name, entries = [], { after = null } = {}) {
if (this.has(name)) throw new Error(`[${name}] is already in the file.`);
const block = [`[${name}]${this.cr}`, ...entries.filter(([, v]) => v !== null && v !== undefined && v !== "").map(([k, v]) => `${k}\t=\t${v}${this.cr}`)];
const prev = after ? this.section(after) : null;
if (prev) {
this.lines.splice(prev.last + 1, 0, ...block);
} else {
// before the trailing empty line(s) of the file
let end = this.lines.length;
while (end > 0 && this.lines[end - 1].trim() === "") end--;
this.lines.splice(end, 0, ...block);
}
this.reindex();
}
/** Removes a section: its header and every line up to its last key (comments inside it too). */
removeSection(name) {
const s = this.section(name);
if (!s) return;
this.lines.splice(s.header, s.last - s.header + 1);
this.reindex();
}
toBuffer() {
return Buffer.from(this.lines.join("\n"), "latin1");
}
}
/** The string table ("|key|text|" lines; text in the region's code page). Keys are lowercase-matched. */
class TextDoc {
constructor(buf) {
this.lines = buf.toString("latin1").split("\n");
const cr = this.lines.filter((l) => l.endsWith("\r")).length;
this.cr = cr * 2 >= this.lines.length - 1 ? "\r" : "";
this.index = new Map();
this.lines.forEach((line, i) => {
if (line[0] !== "|") return;
const k = line.indexOf("|", 1);
if (k < 0) return;
const key = line.slice(1, k).toLowerCase();
if (!this.index.has(key)) this.index.set(key, i);
});
}
/** How the table writes the "ini_" prefix of INI keys ("INI_" in the HK table), for new keys. */
iniPrefix() {
if (this._prefix === undefined) {
const line = this.lines.find((l) => /^\|ini_/i.test(l));
this._prefix = line ? line.slice(1, 5) : "ini_";
}
return this._prefix;
}
/** Raw text bytes (as a latin1 string) of a key, or undefined. */
get(key) {
const i = this.index.get(key.toLowerCase());
if (i === undefined) return undefined;
const line = this.lines[i].replace(/\r$/, "");
const k = line.indexOf("|", 1);
return line.slice(k + 1, line.lastIndexOf("|"));
}
/** Sets a key's text (latin1 string of the encoded bytes); new keys go at the end of the table. */
set(key, raw) {
const i = this.index.get(key.toLowerCase());
if (i !== undefined) {
const cr = this.lines[i].endsWith("\r") ? "\r" : "";
const k = this.lines[i].indexOf("|", 1);
this.lines[i] = `${this.lines[i].slice(0, k + 1)}${raw}|${cr}`;
return;
}
let end = this.lines.length;
while (end > 0 && this.lines[end - 1].replace(/\r$/, "") === "") end--;
this.lines.splice(end, 0, `|${key}|${raw}|${this.cr}`);
this.index.set(key.toLowerCase(), end);
}
toBuffer() {
return Buffer.from(this.lines.join("\n"), "latin1");
}
}
export { IniDoc, TextDoc };
export default { IniDoc, TextDoc };
+47
View File
@@ -0,0 +1,47 @@
// Web stand-in for the desktop iopman.js (IOP Manager sessions). The packs of the opened game folder
// are registered as sessions, so every tool that works with "a pack open in IOP Manager" (UI image
// sets: Load, texture source "packs next to …") works with the game folder. The web IOP Manager
// (PLAN phase 8b) will add its own packs here too.
// Session ids are numbers, like the desktop's: the renderer does Number(select.value).
const registry = new Map(); // id -> { id, name, file, pack, key, game }
const idOfFile = new Map(); // lower file path -> id (stable while the page is open)
let nextId = 1;
/** Replace the game folder's packs: [{ name, file, pack, key }] (key = detected profile or null). */
export function setGamePacks(list) {
for (const [id, s] of [...registry]) if (s.game) registry.delete(id);
for (const p of list) {
const k = p.file.toLowerCase();
if (!idOfFile.has(k)) idOfFile.set(k, nextId++);
const id = idOfFile.get(k);
registry.set(id, { id, game: true, ...p });
}
}
/** Same shape as the desktop: { id, name, file, encrypted, detected, profileId }. */
export function sessions() {
return [...registry.values()].map((s) => ({
id: s.id,
name: s.name,
file: s.file,
encrypted: !!s.encrypted,
detected: !!s.key,
profileId: s.key ? s.key.id : null,
entries: s.pack.liveEntries().length,
}));
}
/** { pack, password (type 0 of the detected key), file } of a session. */
export function handle(id) {
const s = registry.get(Number(id));
if (!s) throw new Error("That pack is not open any more. Open the game folder again.");
return { pack: s.pack, password: s.key ? s.key.passwords[0] : null, file: s.file };
}
export async function addData() {
throw new Error("Saving into packs is not available yet.");
}
export const replaceData = addData;
export default { sessions, handle, addData, replaceData, setGamePacks };
+595
View File
@@ -0,0 +1,595 @@
// Ported from LS Tools desktop: main/items.js (mechanical CJS -> ESM)
import assets from "./assets.js";
import codec from "./lsmon/codec.js"; // web port: Korean strings left in Latin text tables
// Item Viewer: every item the client knows, read the way the client reads it (see README → Items),
// straight from the game's packs (an asset source, normally the profile's game folder), so it always
// shows what the current rs.iop / map.iop hold.
//
// config/*.ini (rs.iop) item definitions; names / descriptions are "STR(n)"
// resource/text/text_*.txt the string table (map.iop): "|key|text|" per line, key =
// lower("ini_" + ini file stem + "::" + section + "_" + n)
// *_manual.ini descriptions: [ManualN] <line>Text<k> / <line>Color<k> (read on demand)
// xml/uiimageset.xml icons ("Set#Image"); drawn by the window (UIRes)
//
// Categories (client managers): etc items (ioEtcItemManager), equipment (ioItemMaker: sp2_item /
// sp2_extraitem / … group files), equipment boxes (ioExtraItemInfoManager), costumes and costume
// boxes (ioCostumeInfoManager / ioCostumeShopInfoManager), medals (ioMedalItemInfoManager),
// decorations (ioDecorationPrice), heroes (ioSetItemInfoManager). Hero prices come from the server.
// Text tables per client region, and their code page. The first one the packs hold is the default.
// Web port fix: the region tables come before the Korean base table — a regional client (e.g.
// Gemscool Indonesia: text.txt + text_us + text_id) keeps text.txt only as a leftover full of
// STR(n) placeholders, so Korean first showed every quest and item as "STR(1)".
const TEXTS = [
{ file: "text_tw.txt", label: "Traditional Chinese (TW / HK)", encoding: "big5" },
{ file: "text_th.txt", label: "Thai", encoding: "windows-874" },
{ file: "text_id.txt", label: "Indonesian", encoding: "windows-1252" },
{ file: "text_us.txt", label: "English (US)", encoding: "windows-1252" },
{ file: "text_kr.txt", label: "Korean", encoding: "euc-kr" },
{ file: "text.txt", label: "Korean (base)", encoding: "euc-kr" },
];
const cache = new Map(); // source key + text → db
// ---------- reading ----------
const srcKey = (src) => JSON.stringify(src);
/** A config file the way the client finds it: rs.iop first, then anywhere. → Buffer | null */
function readConfig(src, name) {
const hit = assets.readKey(src, `rs.iop|config/${name}`) || assets.readPath(src, `config/${name}`);
return hit ? Buffer.from(hit.bytes) : null;
}
/** Which string tables the packs hold. */
function textFiles(src) {
const have = new Map();
for (const it of assets.list(src)) {
const m = /(^|\/)text\/(text[^/]*\.txt)$/i.exec(it.path);
if (m && !have.has(m[2].toLowerCase())) have.set(m[2].toLowerCase(), it.key);
}
return TEXTS.filter((t) => have.has(t.file)).map((t) => ({ ...t, key: have.get(t.file) }));
}
/** "|key|text|" lines → Map(key lower → raw bytes). The first copy of a key wins, like the client. */
function parseTextTable(buf) {
const table = new Map();
let start = 0;
for (let i = 0; i <= buf.length; i++) {
if (i < buf.length && buf[i] !== 0x0a) continue;
let end = i;
if (end > start && buf[end - 1] === 0x0d) end--;
// key: ASCII up to the second "|"; text: up to the last "|" of the line (a Big5 trail byte can be 0x7C, never the last one)
if (buf[start] === 0x7c) {
const k = buf.indexOf(0x7c, start + 1);
const last = buf.lastIndexOf(0x7c, end - 1);
if (k > start && k < end && last > k) {
const key = buf.toString("latin1", start + 1, k).toLowerCase();
if (!table.has(key)) table.set(key, buf.subarray(k + 1, last));
}
}
start = i + 1;
}
return table;
}
/**
* INI → Map(section lower → { name, keys: Map(key lower → value) }). Values stay latin1 strings
* (bytes as they are); comment lines (";", "//") are skipped. Sections and keys case-insensitive,
* the first copy wins, like the client's parser.
*/
function parseIni(buf) {
const out = new Map();
let cur = null;
for (const raw of buf.toString("latin1").split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith(";") || line.startsWith("//")) continue;
const sec = /^\[([^\]]+)\]/.exec(line);
if (sec) {
const name = sec[1].trim();
const key = name.toLowerCase();
if (!out.has(key)) out.set(key, { name, keys: new Map() });
cur = out.get(key);
continue;
}
const eq = line.indexOf("=");
if (!cur || eq < 1) continue;
const k = line.slice(0, eq).trim().toLowerCase();
if (!cur.keys.has(k)) cur.keys.set(k, line.slice(eq + 1).trim());
}
return out;
}
// ---------- one loaded game ----------
class ItemDb {
constructor(src, text) {
this.src = src;
this.text = text; // { file, encoding, … } or null
this.decoder = codec.textDecoder(text?.encoding || "euc-kr");
// the INI files themselves are written in Korean (CP949): a value that isn't STR(n) is Korean text
this.rawDecoder = new TextDecoder("euc-kr");
this.table = new Map();
this.inis = new Map(); // file name → parsed ini (null when missing)
this.missing = new Set();
this.categories = [];
}
ini(name) {
const key = name.toLowerCase();
if (!this.inis.has(key)) {
const buf = readConfig(this.src, key);
if (!buf) this.missing.add(key);
this.inis.set(key, buf ? parseIni(buf) : null);
}
return this.inis.get(key);
}
/** A value of `file`'s [section]: STR(n) from the string table, else the raw text in the region's code page. */
str(file, section, value) {
if (value == null || value === "") return "";
const m = /STR\(\s*(\d+)/i.exec(value);
if (m && Number(m[1]) > 0) {
// the client keys by the file name alone (no folder, no extension): costume/costume_armor.ini → costume_armor
const stem = file.split(/[\\/]/).pop().replace(/\.ini$/i, "").toLowerCase();
const bytes = this.table.get(`ini_${stem}::${section}_${m[1]}`.toLowerCase());
return bytes ? this.decoder.decode(bytes).replace(/\\r/g, "").replace(/\\n/g, "\n") : value;
}
return /[\x80-\xff]/.test(value) ? this.rawDecoder.decode(Buffer.from(value, "latin1")) : value;
}
load() {
if (this.text) {
const hit = assets.readKey(this.src, this.text.key);
if (hit) this.table = parseTextTable(Buffer.from(hit.bytes));
}
const cats = [
["etc", "Etc items", () => this.etcItems()],
["equip", "Equipment", () => this.equipment()],
["equipbox", "Equipment boxes", () => this.machines("sp2_extraitem_info.ini", "random_machine", "machine_code", "sp2_etc_manual.ini")],
["costume", "Costumes", () => this.costumes()],
["costumebox", "Costume boxes", () => this.machines("sp2_costume_shop_info.ini", "costume_machine", "costume_shop_code", "sp2_costume_shop_manual.ini")],
["medal", "Medals", () => this.medals()],
["deco", "Decorations", () => this.decorations()],
["hero", "Heroes", () => this.heroes()],
];
for (const [id, label, fn] of cats) {
let items = [];
let error = null;
try {
items = fn();
} catch (err) {
error = err.message;
}
this.categories.push({ id, label, items, error });
}
// names for box contents (equipment / costume codes)
this.names = new Map();
for (const c of this.categories) for (const it of c.items) if (!this.names.has(`${c.id}:${it.id}`)) this.names.set(`${c.id}:${it.id}`, { name: it.name, icon: it.icon });
for (const c of this.categories.filter((x) => x.id === "equipbox" || x.id === "costumebox")) {
const pool = c.id === "equipbox" ? "equip" : "costume";
for (const it of c.items) for (const x of it.contents || []) Object.assign(x, this.names.get(`${pool}:${x.code}`) || this.extraList().get(x.code) || {});
}
return this;
}
/** Section values as a plain object (raw), for the "all fields" list. */
raw(sec) {
return Object.fromEntries(sec.keys);
}
// ----- etc items: sp2_etcitem_info.ini [etcitem1..max] -----
etcItems() {
const F = "sp2_etcitem_info.ini";
const ini = this.ini(F);
if (!ini) return [];
const max = parseInt(ini.get("common")?.keys.get("max") || "0", 10) || 20000;
const out = [];
for (let i = 1; i <= max; i++) {
const sec = ini.get(`etcitem${i}`);
if (!sec) continue;
const k = (n) => sec.keys.get(n);
const shop = [];
for (let s = 1; s <= 100 && k(`value${s}`) !== undefined; s++) {
shop.push({
label: this.str(F, sec.name, k(`shop_name${s}`)) || "",
value: num(k(`value${s}`)),
peso: num(k(`peso${s}`)),
cash: num(k(`cash${s}`)),
bonusPeso: num(k(`bonus_peso${s}`)),
active: k(`active${s}`) === undefined ? null : num(k(`active${s}`)) !== 0,
});
}
const type = num(k("type"));
out.push({
id: type,
section: sec.name,
name: this.str(F, sec.name, k("name")),
icon: k("icon_name") || "",
grade: num(k("grade_type")),
kind: USE_TYPES[Math.floor(type / 1000000)] || "",
sellPeso: num(k("sell_peso")),
canSell: k("can_sell") === undefined ? null : num(k("can_sell")) !== 0,
shop,
manuals: [
{ file: "sp2_etc_inven_manual.ini", n: num(k("inventory_manual")), label: "Inventory" },
{ file: "sp2_etc_manual.ini", n: num(k("main_manual1")), label: "Shop" },
].filter((m) => m.n > 0),
file: F,
fields: this.raw(sec),
});
}
return out;
}
/**
* sp2_extraitem_list.ini: what the shop shows per equipment box ([machine_<N>] item<i>_code /
* item<i>_name / item<i>_icon). Its names are the translated ones (the hero equipment files keep
* the Korean originals), so equipment and box contents are named from here by code.
*/
extraList() {
if (this._extraList) return this._extraList;
const F = "sp2_extraitem_list.ini";
const map = new Map();
const ini = this.ini(F);
for (const sec of ini ? ini.values() : []) {
const cnt = num(sec.keys.get("item_cnt"));
for (let i = 1; i <= cnt; i++) {
const code = num(sec.keys.get(`item${i}_code`));
if (!code) continue;
const name = this.str(F, sec.name, sec.keys.get(`item${i}_name`));
const icon = sec.keys.get(`item${i}_icon`) || "";
const have = map.get(code);
if (!have || (!have.name && name) || (!have.icon && icon)) map.set(code, { name: have?.name || name, icon: have?.icon || icon });
}
}
return (this._extraList = map);
}
// ----- equipment: sp2_item.ini / sp2_extraitem.ini / … → group files [item1..] -----
equipment() {
const list = this.extraList();
const out = [];
for (const top of ["sp2_item.ini", "sp2_extraitem.ini", "sp2_powerup_item.ini", "sp2_powerup_extraitem.ini", "sp2_accessory.ini", "sp2_item_npc.ini"]) {
const ini = this.ini(top);
if (!ini) continue;
const info = ini.get("common_info");
const cnt = num(info?.keys.get("item_group_cnt"));
for (let g = 0; g < cnt; g++) {
const file = info.keys.get(`item_group${g}`);
if (!file) continue;
const name = /\.ini$/i.test(file) ? file : `${file}.ini`;
const gi = this.ini(name.replace(/^config[\\/]/i, ""));
if (!gi) continue;
const n = num(gi.get("common")?.keys.get("item_cnt")) || 5000;
for (let i = 1; i <= n; i++) {
const sec = gi.get(`item${i}`);
if (!sec) continue;
const k = (x) => sec.keys.get(x);
const gf = name.replace(/^config[\\/]/i, "");
const code = num(k("code"));
const own = this.str(gf, sec.name, k("name"));
const shown = list.get(code);
out.push({
id: code,
section: sec.name,
// the translated name from sp2_extraitem_list when there is one, else the file's own
name: (shown?.name && !/^STR\(/i.test(shown.name) ? shown.name : "") || own,
originalName: shown?.name && shown.name !== own ? own : undefined,
icon: k("item_large_icon0") || k("item_large_icon") || shown?.icon || "",
grade: num(k("grade_type")),
kind: (k("item_type") || "").toUpperCase(),
group: `${top.replace(/\.ini$/i, "")} / ${gf.replace(/\.ini$/i, "")}`,
enableClass: k("enable_class") || "",
manuals: num(k("manual")) > 0 ? [{ file: "sp2_extra_inven_manual.ini", n: num(k("manual")), label: "Inventory" }] : [],
file: gf,
fields: this.raw(sec),
});
}
}
}
return out;
}
// ----- equipment / costume boxes: [default<type>] templates, then [<prefix>N] -----
machines(F, prefix, codeKey, manualFile) {
const ini = this.ini(F);
if (!ini) return [];
const out = [];
for (const [key, sec] of ini) {
const m = new RegExp(`^${prefix}(\\d+)$`, "i").exec(key);
if (!m) continue;
// the client copies [default<type>] first, then this section overrides it
const merged = new Map();
const tpl = ini.get(`default${sec.keys.get("type") || ""}`);
if (tpl) for (const [a, b] of tpl.keys) merged.set(a, { v: b, from: tpl.name });
for (const [a, b] of sec.keys) merged.set(a, { v: b, from: sec.name });
const k = (x) => merged.get(x)?.v;
const s = (x) => (merged.has(x) ? this.str(F, merged.get(x).from, merged.get(x).v) : "");
const shop = [];
if (k("need_cash") !== undefined && num(k("need_cash")) > 0) shop.push({ label: "", value: null, peso: 0, cash: num(k("need_cash")), bonusPeso: num(k("bonus_peso")), active: null });
for (let i = 1; i <= 50 && k(`need_peso${i}`) !== undefined; i++) {
shop.push({ label: "", value: null, period: num(k(`period${i}`)), peso: num(k(`need_peso${i}`)), cash: 0, bonusPeso: 0, active: null });
}
const contents = [];
for (let i = 1; i <= num(k("item_cnt")); i++) {
if (k(`item${i}_code`) === undefined) continue;
contents.push({ code: num(k(`item${i}_code`)), rate: num(k(`item${i}_rate`)) });
}
const total = contents.reduce((n, c) => n + c.rate, 0);
for (const c of contents) c.percent = total ? (c.rate * 100) / total : null;
out.push({
id: num(k(codeKey)) || num(m[1]),
section: sec.name,
name: s("name"),
icon: k("icon") || "",
grade: num(k("grade_type")),
kind: `type ${k("type") || "-"}`,
needLevel: num(k("need_level")),
sellPeso: num(k("sell_peso")),
active: k("active") === undefined ? null : num(k("active")) !== 0,
shop,
contents,
manuals: num(k("manual")) > 0 ? [{ file: manualFile, n: num(k("manual")), label: "Shop" }] : [],
file: F,
fields: Object.fromEntries([...merged].map(([a, b]) => [a, b.v])),
});
}
return out.sort((a, b) => a.id - b.id);
}
// ----- costumes: sp2_costume.ini → group files [costume1..] -----
costumes() {
const ini = this.ini("sp2_costume.ini");
if (!ini) return [];
const info = ini.get("common_info");
const cnt = num(info?.keys.get("costume_group_cnt"));
const out = [];
for (let g = 1; g <= cnt; g++) {
const file = info.keys.get(`costume_group${g}`);
if (!file) continue;
const gf = (/\.ini$/i.test(file) ? file : `${file}.ini`).replace(/^config[\\/]/i, "");
const gi = this.ini(gf);
if (!gi) continue;
const n = num(gi.get("common_info")?.keys.get("costume_cnt")) || 5000;
for (let i = 1; i <= n; i++) {
const sec = gi.get(`costume${i}`);
if (!sec) continue;
const k = (x) => sec.keys.get(x);
out.push({
id: num(k("costum_code")),
section: sec.name,
name: this.str(gf, sec.name, k("name")),
icon: k("icon_name") || "",
grade: num(k("grade_type")),
kind: "",
group: gf.replace(/\.ini$/i, ""),
enableClass: k("enable_class") || "",
manuals: num(k("manual")) > 0 ? [{ file: "sp2_costume_inven_manual.ini", n: num(k("manual")), label: "Inventory" }] : [],
file: gf,
fields: this.raw(sec),
});
}
}
return out;
}
// ----- medals: sp2_medalitem_info.ini [item_info_<N>] -----
medals() {
const F = "sp2_medalitem_info.ini";
const ini = this.ini(F);
if (!ini) return [];
const max = num(ini.get("item_info_common")?.keys.get("max_item_info")) || 2000;
const out = [];
for (let i = 1; i <= max; i++) {
const sec = ini.get(`item_info_${i}`);
if (!sec) continue;
const k = (x) => sec.keys.get(x);
out.push({
id: num(k("item_type")),
section: sec.name,
name: this.str(F, sec.name, k("name")),
icon: k("icon") || "",
grade: 0,
kind: k("sub_medal_type") !== undefined ? `sub type ${k("sub_medal_type")}` : "",
needLevel: num(k("limit_level")),
sellPeso: num(k("sell_peso")),
manuals: num(k("manual")) > 0 ? [{ file: "sp2_medal_inven_manual.ini", n: num(k("manual")), label: "Inventory" }] : [],
file: F,
fields: this.raw(sec),
});
}
return out;
}
// ----- decorations: sp2_deco_sex.ini [Sex<N>] → Deco_ini [Deco<j>] entries _k -----
decorations() {
const ini = this.ini("sp2_deco_sex.ini");
if (!ini) return [];
const maxSex = num(ini.get("info")?.keys.get("max_sex")) || 2;
const out = [];
for (let s = 1; s <= maxSex; s++) {
const sx = ini.get(`sex${s}`);
const file = sx?.keys.get("deco_ini");
if (!file) continue;
const df = (/\.ini$/i.test(file) ? file : `${file}.ini`).replace(/^config[\\/]/i, "");
const di = this.ini(df);
if (!di) continue;
const sexName = this.str("sp2_deco_sex.ini", sx.name, sx.keys.get("name")) || `sex ${sx.keys.get("sex")}`;
const maxDeco = num(di.get("info")?.keys.get("max_deco")) || 200;
for (let j = 1; j <= maxDeco; j++) {
const sec = di.get(`deco${j}`);
if (!sec) continue;
const kk = (x) => sec.keys.get(x);
const title = this.str(df, sec.name, kk("title"));
for (let e = 1; e <= (num(kk("max")) || 0); e++) {
if (kk(`code_${e}`) === undefined) continue;
const shop = [];
if (num(kk(`peso_${e}`)) || num(kk(`cash_${e}`))) shop.push({ label: "", value: null, peso: num(kk(`peso_${e}`)), cash: num(kk(`cash_${e}`)), bonusPeso: num(kk(`bonuspeso_${e}`)), active: kk(`active_${e}`) === undefined ? null : num(kk(`active_${e}`)) !== 0 });
const fields = {};
for (const [a, b] of sec.keys) if (a.endsWith(`_${e}`) || !/_\d+$/.test(a)) fields[a] = b;
out.push({
id: num(kk(`code_${e}`)),
section: `${sec.name} #${e}`,
name: this.str(df, sec.name, kk(`name_${e}`)),
icon: kk(`icon_${e}`) || "",
grade: 0,
kind: `${sexName} · ${title} (type ${kk("type")})`,
needLevel: num(kk(`limitlevel_${e}`)),
sellPeso: num(kk(`sellpeso_${e}`)),
shop,
manuals: [
{ file: "sp2_deco_inven_manual.ini", n: num(kk(`invenmanual_${e}`)), label: "Inventory" },
{ file: "sp2_deco_manual.ini", n: num(kk(`manual_${e}`)), label: "Shop" },
].filter((m) => m.n > 0),
file: df,
fields,
});
}
}
}
return out;
}
// ----- heroes: sp2_setitem_info.ini [set_item<N>] (prices come from the server) -----
heroes() {
const F = "sp2_setitem_info.ini";
const ini = this.ini(F);
if (!ini) return [];
const max = num(ini.get("common")?.keys.get("max_set_item")) || 1000;
const out = [];
for (let i = 1; i <= max; i++) {
const sec = ini.get(`set_item${i}`);
if (!sec) continue;
const k = (x) => sec.keys.get(x);
const code = num(k("set_code"));
const cls = code - 700000;
out.push({
id: code,
section: sec.name,
name: this.str(F, sec.name, k("name")),
// the client builds the icon: SoldierIconPack<pack>#<class>, 18 classes per pack
icon: cls > 0 ? `SoldierIconPack${String(Math.floor((cls - 1) / 18) + 1).padStart(3, "0")}#${String(cls).padStart(3, "0")}` : "",
grade: 0,
kind: `class ${cls}`,
needLevel: num(k("need_level1")),
manuals: num(k("item_manual")) > 0 ? [{ file: "sp2_item_manual.ini", n: num(k("item_manual")), label: "Shop" }] : [],
file: F,
fields: this.raw(sec),
});
}
return out;
}
/** A description: [Manual<n>] of a manual ini → lines of coloured runs (+ title). */
manual(file, n) {
const ini = this.ini(file);
const sec = ini?.get(`manual${n}`);
if (!sec) return null;
const k = (x) => sec.keys.get(x);
const runs = (prefix) => {
const out = [];
for (let j = 1; j <= 20; j++) {
const t = k(`${prefix}text${j}`);
if (t === undefined || t === "") break;
out.push({ text: this.str(file, sec.name, t).replace(/#/g, " "), color: colorOf(k(`${prefix}color${j}`)) });
}
return out;
};
const title = runs("title");
const lines = [];
for (let l = 1; l <= 200; l++) {
const r = runs(String(l));
if (!r.length) break;
lines.push({ runs: r, space: num(k(`${l}spaceline`)) });
}
return { file, n, title, lines };
}
}
const USE_TYPES = { 1: "count", 2: "time", 3: "once", 4: "eternal", 5: "date" };
const num = (v) => {
const n = parseInt(String(v ?? "").trim(), 10);
return Number.isFinite(n) ? n : 0;
};
const colorOf = (v) => {
const m = /^0x([0-9a-f]{6,8})$/i.exec(String(v || "").trim());
return m ? `#${m[1].slice(-6)}` : null;
};
// ---------- API for the window ----------
/** What the source offers: its string tables (to pick the language). */
function sources(src) {
return { texts: textFiles(src).map(({ file, label, encoding }) => ({ file, label, encoding })), describe: assets.describe(src) };
}
/**
* Loads (or returns the loaded) item lists. reload: read the packs again (the viewer always shows
* the packs as they are now). → { texts, text, categories: [{ id, label, count, error, items }], missing }
*/
function load(src, { textFile = null, reload = false } = {}) {
const texts = textFiles(src);
const text = texts.find((t) => t.file === textFile) || texts[0] || null;
const key = `${srcKey(src)}|${text?.file || ""}`;
if (reload) {
assets.reload(src);
for (const k of [...cache.keys()]) if (k.startsWith(srcKey(src))) cache.delete(k);
}
if (!cache.has(key)) cache.set(key, new ItemDb(src, text).load());
const db = cache.get(key);
// the lists without each item's raw fields (those come with detail()): a few MB instead of ~30
const light = ({ fields, ...rest }) => rest;
return {
texts: texts.map(({ file, label }) => ({ file, label })),
text: text?.file || null,
strings: db.table.size,
missing: [...db.missing],
categories: db.categories.map((c) => ({ id: c.id, label: c.label, error: c.error, items: c.items.map(light) })),
};
}
/** The loaded db of a source + text table (loads it when needed). */
function dbOf(src, textFile) {
const texts = textFiles(src);
const text = texts.find((t) => t.file === textFile) || texts[0] || null;
const key = `${srcKey(src)}|${text?.file || ""}`;
if (!cache.has(key)) load(src, { textFile });
return cache.get(key);
}
/** One item in full: its raw INI fields and its descriptions. */
function detail(src, textFile, categoryId, index) {
const db = dbOf(src, textFile);
const it = db.categories.find((c) => c.id === categoryId)?.items[index];
if (!it) return null;
return { fields: it.fields, file: it.file, section: it.section, manuals: it.manuals.map((m) => ({ ...m, manual: db.manual(m.file, m.n) })) };
}
function manual(src, textFile, file, n) {
return dbOf(src, textFile).manual(file, n);
}
/** Name + icon lookups per category (id → { name, icon }), e.g. for quest rewards. */
function lookup(src, textFile) {
const db = dbOf(src, textFile);
if (!db.lookup) {
db.lookup = {};
for (const c of db.categories) {
const m = new Map();
for (const it of c.items) if (!m.has(it.id)) m.set(it.id, { name: it.name, icon: it.icon });
db.lookup[c.id] = m;
}
}
return db.lookup;
}
/** The loaded text table's decoder and the lists (for pickers): [{ id, label, items: [{ id, name, icon }] }]. */
function pickLists(src, textFile) {
return dbOf(src, textFile).categories.map((c) => ({ id: c.id, label: c.label, items: c.items.map((it) => ({ id: it.id, name: it.name, icon: it.icon })) }));
}
const __export_cacheReset = (src) => [...cache.keys()].filter((k) => k.startsWith(srcKey(src))).forEach((k) => cache.delete(k));
export { load, detail, manual, sources, parseIni, parseTextTable, lookup, pickLists, textFiles, __export_cacheReset as cacheReset };
export default { load, detail, manual, sources, parseIni, parseTextTable, lookup, pickLists, textFiles, cacheReset: __export_cacheReset };
+82
View File
@@ -0,0 +1,82 @@
// Ported from LS Tools desktop: main/lsmon/codec.js (mechanical CJS -> ESM)
// Text in LS server packets is the server's ANSI code page, NUL-terminated: CP949 (Korea), Big5 (HK/TW),
// Windows-874 (Thai), GBK, Shift-JIS, Windows-1252. TextDecoder decodes all of them; for encoding the
// reverse table is built once per code page from the decoder (no extra package).
const ENCODINGS = {
big5: "Traditional Chinese (Big5 — HK / TW)",
"euc-kr": "Korean (CP949)",
"windows-874": "Thai (Windows-874)",
gbk: "Simplified Chinese (GBK)",
shift_jis: "Japanese (Shift-JIS)",
"windows-1252": "Western (Windows-1252)",
};
const decoders = new Map();
const encoders = new Map();
function decoder(enc) {
if (!decoders.has(enc)) decoders.set(enc, new TextDecoder(ENCODINGS[enc] ? enc : "windows-1252"));
return decoders.get(enc);
}
/** char → bytes for a code page, from decoding every single- and double-byte sequence. */
function encoder(enc) {
if (encoders.has(enc)) return encoders.get(enc);
const map = new Map();
const d = new TextDecoder(ENCODINGS[enc] ? enc : "windows-1252", { fatal: true });
const tryBytes = (bytes) => {
try {
const s = d.decode(Uint8Array.from(bytes));
if (s.length === 1 || (s.length === 2 && s.codePointAt(0) > 0xffff)) {
if (!map.has(s) && s !== "�") map.set(s, Buffer.from(bytes));
}
} catch {}
};
for (let b = 0; b < 0x80; b++) map.set(String.fromCharCode(b), Buffer.from([b]));
const single = enc === "windows-874" || enc === "windows-1252";
if (single) {
for (let b = 0x80; b < 0x100; b++) tryBytes([b]);
} else {
for (let lead = 0x81; lead <= 0xfe; lead++) {
for (let trail = 0x40; trail <= 0xfe; trail++) tryBytes([lead, trail]);
}
if (enc === "shift_jis") for (let b = 0xa1; b <= 0xdf; b++) tryBytes([b]); // half-width katakana
}
encoders.set(enc, map);
return map;
}
/** String → bytes in the code page (characters it can't hold become "?"). */
function encode(text, enc) {
const map = encoder(enc);
const parts = [];
for (const ch of String(text ?? "")) parts.push(map.get(ch) || map.get("?"));
return Buffer.concat(parts);
}
const decode = (bytes, enc) => decoder(enc).decode(bytes);
/**
* Web addition: a Latin (windows-1252) text table of a regional client still holds untranslated
* Korean strings (e.g. Gemscool text_id.txt: "ÇÁ¸®¹Ì¾ö ÆÐŰÁö"). A string whose high bytes all pair
* up as EUC-KR double bytes (lead and trail A1–FE: Hangul, Hanja, Korean symbols such as Ⅰ Ⅱ) is
* read as Korean instead. Latin accents (é, ñ…) stand alone and keep the Latin reading.
*/
function looksKorean(bytes) {
let pairs = 0;
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i];
if (b < 0x80) continue;
const t = bytes[i + 1];
if (b < 0xa1 || b > 0xfe || t === undefined || t < 0xa1 || t > 0xfe) return false;
pairs++;
i++;
}
return pairs > 0;
}
const decodeText = (bytes, enc) => (enc === "windows-1252" && looksKorean(bytes) ? decoder("euc-kr").decode(bytes) : decoder(enc).decode(bytes));
const textDecoder = (enc) => ({ decode: (bytes) => decodeText(bytes, enc) });
export { encode, decode, decodeText, textDecoder, looksKorean, ENCODINGS };
export default { encode, decode, decodeText, textDecoder, looksKorean, ENCODINGS };
+149
View File
@@ -0,0 +1,149 @@
// Ported from LS Tools desktop: main/models.js (mechanical CJS -> ESM)
import assets from "./assets.js";
import { iniXor } from "../core/tools.js";
// Which skeleton goes with a mesh or an animation. Meshes and animations only name bones, so:
// 1. model configs (resource/model/*.txt) that list the mesh / animation name their skeleton;
// 2. otherwise the skeleton whose bone names cover the most of the mesh's / track's bones; ties go
// to the skeleton the game refers to most (config/*.ini, xml, model configs) — for hero gear
// that is human_man.skl, set in the hero configs rather than in a model config.
// The index is built once per source (≈3 000 model configs + ≈500 skeletons).
const indexes = new Map(); // source key → index
const keyOf = (src) => `${src.kind}:${(src.folder || "").toLowerCase()}:${src.sessionId || ""}`;
function parseCfg(text) {
const cfg = { skeleton: "", animations: [], meshes: [] };
const lines = text.split(/\r\n|\r|\n/).map((l) => l.trim());
let list = null;
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (!l || l.startsWith(";") || l.startsWith("//")) continue;
if (l === "#SKELETON") cfg.skeleton = (lines[++i] || "").toLowerCase();
else if (l === "#ANIMATION_LIST") list = cfg.animations;
else if (l === "#MESH_LIST") list = cfg.meshes;
else if (l.startsWith("#")) list = null;
else if (list) list.push(l.toLowerCase());
}
return cfg;
}
/** Bone names of a .skl and the skeleton it links to (see LSF.parseSkl for the full reader). */
function sklBones(b) {
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
let o = 8;
const n = dv.getInt32(o, true);
o += 4;
const str = () => {
const l = dv.getInt32(o, true);
o += 4;
const s = Buffer.from(b.buffer, b.byteOffset + o, l).toString("latin1").toLowerCase();
o += l;
return s;
};
const names = [];
for (let i = 0; i < n; i++) {
names.push(str());
o += 28 + 28 + 64;
str();
const c = dv.getInt32(o, true);
o += 4;
for (let j = 0; j < c; j++) str();
}
const linked = o + 4 <= b.length ? str() : "";
return { names, linked };
}
// Web port: the index is built per part, only what a call needs ("models": the model configs;
// "skl": skeleton bones; "refs": skeleton names in config/xml — the slow part, thousands of
// files). "Used by" needs only the model configs. Progress goes to the page's loading overlay.
const kindOf = (l) =>
l.endsWith(".txt") && /(^|\/)model\//.test(l) ? "models" : /^(config|xml)\//.test(l) && /\.(ini|xml)$/.test(l) ? "refs" : l.endsWith(".skl") ? "skl" : null;
function build(src, need = ["models", "skl", "refs"]) {
const key = keyOf(src);
if (!indexes.has(key)) indexes.set(key, { byMesh: new Map(), byAni: new Map(), models: new Map(), skl: new Map(), refs: new Map(), done: new Set() });
const idx = indexes.get(key);
const todo = need.filter((p) => !idx.done.has(p));
if (!todo.length) return idx;
const ref = (name, w = 1) => idx.refs.set(name, (idx.refs.get(name) || 0) + w);
const add = (map, k, v) => {
if (!map.has(k)) map.set(k, new Set());
map.get(k).add(v);
};
const items = assets.list(src).filter((it) => todo.includes(kindOf(it.path.toLowerCase())));
let n = 0;
for (const it of items) {
globalThis.__lsProgress?.(n++, items.length);
const l = it.path.toLowerCase();
try {
if (l.endsWith(".txt") && /(^|\/)model\//.test(l)) {
const r = assets.readKey(src, it.key);
const cfg = parseCfg(Buffer.from(r.bytes).toString("latin1"));
const name = l.split("/").pop();
idx.models.set(name, cfg);
if (!cfg.skeleton) continue;
ref(cfg.skeleton);
for (const m of cfg.meshes) add(idx.byMesh, m, cfg.skeleton);
for (const a of cfg.animations) add(idx.byAni, a, cfg.skeleton);
} else if (/^(config|xml)\//.test(l) && /\.(ini|xml)$/.test(l)) {
// skeleton names in the game configs (some .ini are XOR-scrambled)
const raw = Buffer.from(assets.readKey(src, it.key).bytes);
let text = raw.toString("latin1");
if (!/\.skl/i.test(text) && l.endsWith(".ini")) text = iniXor(raw).toString("latin1");
// the main configs (config/sp2_char.ini: the heroes) count more than mode / npc configs
const w = /^config\/[^/]+\.ini$/.test(l) ? 5 : 1;
for (const m of text.matchAll(/([\w.-]+\.skl)\b/gi)) ref(m[1].toLowerCase(), w);
} else if (l.endsWith(".skl")) {
const name = l.split("/").pop();
if (!idx.skl.has(name)) {
const s = sklBones(assets.readKey(src, it.key).bytes);
idx.skl.set(name, Object.assign(new Set(s.names), { linked: s.linked }));
}
}
} catch (err) {
console.warn("[models] skipped", it.path, err.message);
}
}
for (const p of todo) idx.done.add(p);
return idx;
}
/**
* Ranked skeleton candidates for a mesh (stem, e.g. "npc_boarman_head") or an animation
* ("public_delay.ani"), given its bone names. → [{ name, matched, total, fromModel }]
*/
function findSkeleton(src, { mesh, ani, bones = [] }) {
const idx = build(src);
const want = [...new Set(bones.map((b) => b.toLowerCase()))];
const listed = new Set([...(mesh ? idx.byMesh.get(mesh.toLowerCase()) || [] : []), ...(ani ? idx.byAni.get(ani.toLowerCase()) || [] : [])]);
const out = [];
for (const [name, set] of idx.skl) {
const matched = want.filter((b) => set.has(b)).length;
if (!listed.has(name) && (!want.length || matched === 0)) continue;
out.push({ name, matched, total: want.length, bones: set.size, fromModel: listed.has(name), refs: idx.refs.get(name) || 0, linked: set.linked || "" });
}
// listed by a model config first, then the best cover, then the standard hero skeletons (hero
// gear only names hand / spine bones, which every biped has), then the most used, base
// skeletons before linked variants, then the tightest fit
const hero = (n) => (/^human_(man|woman)\.skl$/.test(n) ? 1 : 0);
out.sort((a, b) => b.fromModel - a.fromModel || b.matched - a.matched || hero(b.name) - hero(a.name) || b.refs - a.refs || !!a.linked - !!b.linked || a.bones - b.bones || a.name.localeCompare(b.name));
return out.slice(0, 30);
}
/** Model configs that use an animation or mesh (for "used by"). */
function usedBy(src, { mesh, ani }) {
const idx = build(src, ["models"]);
const out = [];
for (const [name, cfg] of idx.models) {
if ((mesh && cfg.meshes.includes(mesh.toLowerCase())) || (ani && cfg.animations.includes(ani.toLowerCase()))) out.push(name);
}
return out.slice(0, 50);
}
function forget(src) {
indexes.delete(keyOf(src));
}
export { findSkeleton, usedBy, forget };
export default { findSkeleton, usedBy, forget };
+667
View File
@@ -0,0 +1,667 @@
// Ported from LS Tools desktop: main/quests.js (mechanical CJS -> ESM)
import fs from "node:fs";
import path from "node:path";
import assets from "./assets.js";
import items from "./items.js";
import codec from "./lsmon/codec.js";
import spec from "./questspec.js";
import { IniDoc, TextDoc } from "./inidoc.js";
// Quests tab: quests, their rewards, help texts, guides and the daily / weekly / monthly missions,
// read from the game's packs (config/*.ini in rs.iop, texts in the string table of map.iop) and
// written back in place: only changed lines are rewritten (inidoc.js). The game server reads the
// same files, so "Export for server" copies them out. What each key means: docs/quests.md.
const FILES = {
quest: { name: "sp2_quest_info.ini", max: ["common", "max_quest"], prefix: "quest" },
present: { name: "sp2_quest_present.ini", max: ["common", "max_present"], prefix: "Present" },
help: { name: "sp2_quest_help.ini", max: ["common", "max_help"], prefix: "help" },
guide: { name: "sp2_quest_guide.ini", max: ["Common", "MaxGuide"], prefix: "Guide" },
daily: { name: "sp2_daily_mission.ini", max: ["common", "max_count"], prefix: "date", mission: 1 },
weekly: { name: "sp2_weekly_mission.ini", max: ["common", "max_count"], prefix: "date", mission: 2 },
monthly: { name: "sp2_monthly_mission.ini", max: ["common", "max_count"], prefix: "date", mission: 3 },
};
const SERVER_FILES = ["quest", "present", "daily", "weekly", "monthly"];
const HELP_DONE = 100000;
const HELP_GOAL = 200000;
const num = (v) => {
const n = parseInt(String(v ?? "").trim(), 10);
return Number.isFinite(n) ? n : 0;
};
const colorOf = (v) => {
const m = /^0x([0-9a-f]{6,8})$/i.exec(String(v || "").trim());
return m ? `#${m[1].slice(-6)}` : null;
};
const nf = (n) => Number(n || 0).toLocaleString("en-US");
const stemOf = (fileId) => FILES[fileId].name.replace(/\.ini$/i, "").toLowerCase();
const cache = new Map();
const srcKey = (src) => JSON.stringify(src);
class QuestDb {
constructor(src, text) {
this.src = src;
this.text = text; // { file, encoding, key } or null
this.files = {};
this.tdoc = undefined;
this.kr = new TextDecoder("euc-kr");
}
// ---------- files ----------
doc(id) {
if (!(id in this.files)) {
const name = FILES[id].name;
const hit = assets.readKey(this.src, `rs.iop|config/${name}`) || assets.readPath(this.src, `config/${name}`);
this.files[id] = hit ? { doc: new IniDoc(Buffer.from(hit.bytes)), where: hit.where, dirty: false } : null;
}
return this.files[id]?.doc || null;
}
textDoc() {
if (this.tdoc === undefined) {
const hit = this.text ? assets.readKey(this.src, this.text.key) : null;
this.tdoc = hit ? { doc: new TextDoc(Buffer.from(hit.bytes)), where: hit.where, dirty: false } : null;
}
return this.tdoc?.doc || null;
}
/** A value as the player sees it: STR(n) from the string table, other text in the INI's own code page. */
str(fileId, section, raw) {
if (raw === undefined || raw === null || raw === "") return "";
const m = /STR\(\s*(\d+)/i.exec(raw);
if (m) {
const t = this.textDoc()?.get(`ini_${stemOf(fileId)}::${section}_${m[1]}`);
return t === undefined ? raw : codec.decodeText(Buffer.from(t, "latin1"), this.text.encoding).replace(/\\r/g, "").replace(/\\n/g, "\n");
}
return /[\x80-\xff]/.test(raw) ? this.kr.decode(Buffer.from(raw, "latin1")) : raw;
}
isText(raw) {
return /STR\(\s*\d+/i.test(raw || "") || /[\x80-\xff]/.test(raw || "");
}
// ---------- quests ----------
quests() {
if (this._quests) return this._quests;
const d = this.doc("quest");
if (!d) return (this._quests = []);
const max = num(d.get("common", "max_quest"));
const out = [];
for (const s of d.order) {
const m = /^quest(\d+)$/i.exec(s.name);
if (!m) continue;
const g = (k) => d.get(s.name, k);
const maxSub = num(g("max_sub_quest"));
const subs = [];
for (let j = 1; j <= maxSub; j++) {
const p = `sub${j}_`;
subs.push({
j,
title: this.str("quest", s.name, g(`${p}title`)),
icon: g(`${p}icon`) || "",
perform: num(g(`${p}perform_type`)),
complete: num(g(`${p}complete_value`)),
oneday: num(g(`${p}oneday_style`)) !== 0,
repeat: num(g(`${p}repeat_style`)) !== 0,
start: this.dateOf(g, p, "start"),
end: this.dateOf(g, p, "end"),
});
}
out.push({ section: s.name, n: Number(m[1]), main: num(g("main_index")), cls: g("class_name") || "", subs, ignored: Number(m[1]) > max });
}
this._quests = out;
this._byMain = new Map();
for (const q of out) if (!this._byMain.has(q.main)) this._byMain.set(q.main, q);
return out;
}
dateOf(g, p, which) {
const y = num(g(`${p}${which}_year`));
if (!y) return null;
const pad = (v) => String(num(v)).padStart(2, "0");
return `${y}-${pad(g(`${p}${which}_month`))}-${pad(g(`${p}${which}_date`))} ${pad(g(`${p}${which}_hour`))}:00`;
}
byMain(main) {
this.quests();
return this._byMain.get(main) || null;
}
// ---------- rewards ----------
presents() {
if (this._presents) return this._presents;
const d = this.doc("present");
const map = new Map();
const dups = [];
if (d) {
for (const s of d.order) {
if (!/^present\d+$/i.test(s.name)) continue;
const g = (k) => d.get(s.name, k);
const index = num(g("RewardIndex"));
if (!index) continue;
if (map.has(index)) {
dups.push({ index, section: s.name, first: map.get(index).section });
continue;
}
map.set(index, this.presentOf(s.name, g));
}
}
this._presents = map;
this._presentDups = dups;
return map;
}
presentOf(section, g) {
const type = num(g("PresentType"));
const v1 = num(g("PresentValue1"));
const v2 = num(g("PresentValue2"));
const p = {
section,
index: num(g("RewardIndex")),
type,
v1,
v2,
direct: num(g("PresentDirect")) !== 0,
ment: num(g("PresentMent")),
period: num(g("PresentPeriod")),
alarm: num(g("PresentAlarm")),
sender: this.str("present", section, g("PresentSendID")),
typeName: spec.PRESENT_TYPES[type]?.name || `Type ${type}`,
};
Object.assign(p, this.presentLabel(type, v1, v2));
return p;
}
lookup() {
if (!this._lookup) {
try {
this._lookup = items.lookup(this.src, this.text?.file || null);
} catch {
this._lookup = {};
}
}
return this._lookup;
}
/** What a present gives, in words: { label, icon } */
presentLabel(type, v1, v2) {
const L = this.lookup();
const find = (cat, id) => L[cat]?.get(id) || null;
const named = (hit, fallback) => (hit?.name && !/^STR\(/i.test(hit.name) ? hit.name : fallback);
const hours = (h) => (h ? `${nf(h)} h` : "permanent");
switch (type) {
case 1: {
const h = find("hero", 700000 + v1);
return { label: `${named(h, `Hero class ${v1}`)} (${hours(v2)})`, icon: h?.icon || "" };
}
case 3: {
const h = find("etc", v1);
return { label: `${named(h, `Etc item ${v1}`)} × ${nf(v2)}`, icon: h?.icon || "" };
}
case 4:
return { label: `${nf(v1)} peso`, icon: "" };
case 5: {
const h = find("equip", v1);
const reinforce = Math.floor(v2 / 10000) % 10000;
return { label: `${named(h, `Gear ${v1}`)}${reinforce ? ` +${reinforce}` : ""} (${hours(v2 % 10000)})`, icon: h?.icon || "" };
}
case 6: {
const h = find("equipbox", v1);
return { label: named(h, `Gear box ${v1}`), icon: h?.icon || "" };
}
case 8:
return { label: `${nf(v1)} grade exp`, icon: "" };
case 9: {
const h = find("medal", v1);
return { label: `${named(h, `Medal ${v1}`)} (${hours(v2)})`, icon: h?.icon || "" };
}
case 12: {
const h = find("costume", v1);
return { label: `${named(h, `Costume ${v1}`)} (${hours(v2 % 10000)})`, icon: h?.icon || "" };
}
case 13:
return { label: `${nf(v1)} bonus cash`, icon: "" };
case 15: {
const h = find("costumebox", v1);
return { label: named(h, `Costume box ${v1}`), icon: h?.icon || "" };
}
default:
return { label: `${spec.PRESENT_TYPES[type]?.name || `Type ${type}`} ${v1} / ${v2}`, icon: "" };
}
}
/** Which quests / missions give each reward: Map(RewardIndex → count) */
presentUse() {
const use = new Map();
const add = (i) => i && use.set(i, (use.get(i) || 0) + 1);
const d = this.doc("quest");
if (d) {
for (const q of this.quests()) {
for (const s of q.subs) {
const n = Math.min(num(d.get(q.section, `sub${s.j}_max_reward`)), 5);
for (let k = 1; k <= n; k++) add(num(d.get(q.section, `sub${s.j}_reward_present${k}`)));
}
}
}
for (const id of ["daily", "weekly", "monthly"]) for (const date of this.missions(id)) for (const m of date.missions) add(m.present);
return use;
}
// ---------- help / guide ----------
helpIndex() {
if (this._help) return this._help;
const d = this.doc("help");
const map = new Map();
if (d) for (const s of d.order) if (/^help\d+$/i.test(s.name)) {
const i = num(d.get(s.name, "index"));
if (i && !map.has(i)) map.set(i, s.name);
}
return (this._help = map);
}
help(index) {
const section = this.helpIndex().get(index);
if (!section) return { index, section: null, lines: [] };
const d = this.doc("help");
const g = (k) => d.get(section, k);
const lines = [];
const max = num(g("max_line"));
for (let l = 1; l <= max; l++) {
const runs = [];
const count = Math.max(num(g(`line${l}_count`)), 1);
for (let k = 1; k <= count; k++) {
const t = g(`line${l}_text${k}`);
if (t === undefined) continue;
runs.push({ text: this.str("help", section, t), color: colorOf(g(`line${l}_color${k}`)), space: num(g(`line${l}_space${k}`)) });
}
lines.push({ runs, image: g(`line${l}_image`) || "" });
}
return { index, section, lines };
}
guides() {
if (this._guides) return this._guides;
const d = this.doc("guide");
const map = new Map();
if (d) for (const s of d.order) if (/^guide\d+$/i.test(s.name)) {
const g = (k) => d.get(s.name, k);
const key = `${num(g("MainIndex"))}.${num(g("SubIndex"))}`;
const data = [];
for (let i = 1; i <= num(g("MaxGuideData")); i++) data.push(g(`GuideData${i}`) || "");
if (!map.has(key)) map.set(key, []);
map.get(key).push({ section: s.name, data });
}
return (this._guides = map);
}
// ---------- missions ----------
missions(id) {
this._missions = this._missions || {};
if (this._missions[id]) return this._missions[id];
const d = this.doc(id);
const out = [];
if (d) {
const presents = this.presents();
for (const s of d.order) {
if (!/^date\d+$/i.test(s.name)) continue;
const g = (k) => d.get(s.name, k);
const missions = [];
for (let k = 1; k < 1000; k++) {
const code = num(g(`mission${k}_code`));
if (!code) break;
const type = num(g(`mission${k}_type`));
const titles = [1, 2].map((t) => {
const runs = [];
for (let i = 1; i <= 9; i++) {
const raw = g(`mission${k}_title${t}_text${i}`);
if (raw === undefined) break;
runs.push({ text: this.str(id, s.name, raw).replace(/#/g, " "), color: colorOf(g(`mission${k}_title${t}_color${i}`)) });
}
return runs;
});
const present = num(g(`mission${k}_present`));
missions.push({
k,
code,
type,
typeName: spec.MISSION_TYPES[type]?.name || `Type ${type}`,
values: g(`mission${k}_values`) || "",
present,
reward: presents.get(present) || null,
icon: g(`mission${k}_icon`) || "",
titles,
presentText: [1, 2, 3, 4].map((i) => this.str(id, s.name, g(`mission${k}_present_text${i}`))).filter(Boolean),
});
}
out.push({ section: s.name, n: num(/\d+/.exec(s.name)[0]), start: g("start_date") || "", end: g("end_date") || "", missions });
}
}
return (this._missions[id] = out);
}
// ---------- checks ----------
/** Problems of one quest section: [{ sev, sub, msg }] */
questProblems(q) {
const d = this.doc("quest");
const out = [];
const add = (sev, sub, msg) => out.push({ sev, sub, msg });
const cls = spec.CLASSES.find((c) => c.name.toLowerCase() === q.cls.toLowerCase());
if (!cls) add("error", 0, q.cls ? `Unknown class ${q.cls}: the client and server skip this quest.` : "No class_name: the quest is skipped.");
if (!q.main) add("error", 0, "main_index is missing or 0.");
else if (q.main > 65535) add("warn", 0, `main_index ${q.main} is above 65535.`);
if (!q.subs.length) add("error", 0, "max_sub_quest is 0: nothing is loaded.");
if (q.ignored) add("warn", 0, `[${q.section}] is above max_quest: it isn't read.`);
if (d.get(q.section, `sub${q.subs.length + 1}_title`) !== undefined) add("warn", 0, `sub${q.subs.length + 1}_* keys exist but max_sub_quest is ${q.subs.length}.`);
const presents = this.presents();
const help = this.helpIndex();
for (const s of q.subs) {
const g = (k) => d.get(q.section, `sub${s.j}_${k}`);
const maxReward = num(g("max_reward"));
if (maxReward > 5) add("error", s.j, `max_reward ${maxReward}: at most 5 (the server reads past its array).`);
for (let k = 1; k <= Math.min(maxReward, 5); k++) {
const r = num(g(`reward_present${k}`));
if (!r) add("warn", s.j, `reward_present${k} is empty.`);
else if (!presents.has(r)) add("error", s.j, `reward_present${k} = ${r}: no such RewardIndex in sp2_quest_present.ini.`);
}
if (num(g("rewardselect_style")) && num(g("rewardselect_num")) > maxReward) add("warn", s.j, "rewardselect_num is more than max_reward.");
// custom values must be contiguous
let gap = 0;
for (let i = 1; i <= 50; i++) {
const v = g(`custom_value${i}`);
if (v === undefined) gap = gap || i;
else if (gap) {
add("error", s.j, `custom_value${gap} is missing: values after it (custom_value${i}) aren't read.`);
break;
}
}
const pm = num(g("prev_main_index"));
const ps = num(g("prev_sub_index"));
if (pm > 0 && ps > 0) {
const pq = this.byMain(pm);
if (!pq || ps > pq.subs.length) add("error", s.j, `Needs quest ${pm}.${ps}, which doesn't exist: it can never be offered.`);
}
const nm = num(g("next_main_index"));
const ns = num(g("next_sub_index"));
if (nm > 0 && ns > 0) {
const nq = this.byMain(nm);
if (!nq || ns > nq.subs.length) add("warn", s.j, `Next quest ${nm}.${ns} doesn't exist.`);
}
// help H = in progress, H+100000 = completed, H+200000 = objective (HK uses 3xxxxx bases too)
const h = num(g("help"));
if (h > 0 && help.size && !help.has(h)) add("warn", s.j, `help ${h} isn't in sp2_quest_help.ini.`);
const perform = num(g("perform_type"));
if (perform === 2) {
if (!num(g("start_year")) || !num(g("end_year"))) add("error", s.j, "Event quest (perform_type 2) without start / end dates.");
else if (num(g("start_year")) < 2010) add("error", s.j, "Start year before 2010.");
else if (s.end && s.start && s.end <= s.start) add("warn", s.j, "The event ends before it starts.");
} else if (perform !== 1) add("warn", s.j, `perform_type ${perform}: use 1 (normal) or 2 (event).`);
if (g("oneday_cnt") !== undefined) add("warn", s.j, "oneday_cnt is misread by the server: don't use it.");
if (/^QuestEtcItemUse$/i.test(q.cls) && num(g("custom_value3")) === 0) add("warn", s.j, "QuestEtcItemUse with no item codes (custom_value3 = 0) never completes.");
if (/^QuestExtraItemEquipKo$/i.test(q.cls) && num(g("custom_value4")) === 0) add("warn", s.j, "QuestExtraItemEquipKo with gear code 0 never completes.");
if (!num(g("complete_value")) && cls && cls.qc) add("warn", s.j, `complete_value is 0 (this class usually uses ${cls.qc}: ${spec.COMPLETE[cls.qc]}).`);
}
return out;
}
/** Every problem in the quest files. → [{ sev, file, section, sub, msg }] */
check() {
const out = [];
const seen = new Map();
for (const q of this.quests()) {
if (q.main && seen.has(q.main)) out.push({ sev: "error", file: "quest", section: q.section, sub: 0, msg: `main_index ${q.main} is also used by [${seen.get(q.main)}]: client and server disagree on it.` });
else seen.set(q.main, q.section);
for (const p of this.questProblems(q)) out.push({ ...p, file: "quest", section: q.section });
}
this.presents();
for (const x of this._presentDups) out.push({ sev: "warn", file: "present", section: x.section, sub: 0, msg: `RewardIndex ${x.index} is also in [${x.first}]: this copy is ignored.` });
for (const p of this.presents().values()) if (!spec.PRESENT_TYPES[p.type]) out.push({ sev: "error", file: "present", section: p.section, sub: 0, msg: `Unknown PresentType ${p.type}.` });
for (const id of ["daily", "weekly", "monthly"]) {
const codes = new Map();
for (const date of this.missions(id)) {
for (const m of date.missions) {
if (codes.has(m.code) && codes.get(m.code) !== date.section) out.push({ sev: "warn", file: id, section: date.section, sub: m.k, msg: `Mission code ${m.code} is also used in [${codes.get(m.code)}] (progress is stored by code).` });
else codes.set(m.code, date.section);
if (m.present && !m.reward) out.push({ sev: "error", file: id, section: date.section, sub: m.k, msg: `mission${m.k}_present = ${m.present}: no such RewardIndex.` });
if (!spec.MISSION_TYPES[m.type]) out.push({ sev: "error", file: id, section: date.section, sub: m.k, msg: `Unknown mission type ${m.type}.` });
}
}
}
return out;
}
// ---------- one section in full ----------
/** Raw keys of any section + the player-visible text of its text keys. */
sectionData(fileId, section) {
const d = this.doc(fileId);
if (!d?.has(section)) return null;
const entries = d.entries(section);
const texts = {};
for (const [k, v] of entries) if (this.isText(v)) texts[k.toLowerCase()] = this.str(fileId, section, v);
return { file: fileId, section: d.section(section).name, entries, texts };
}
questDetail(section) {
const q = this.quests().find((x) => x.section.toLowerCase() === String(section).toLowerCase());
if (!q) return null;
const d = this.doc("quest");
const presents = this.presents();
const guides = this.guides();
const subs = q.subs.map((s) => {
const g = (k) => d.get(q.section, `sub${s.j}_${k}`);
const rewards = [];
for (let k = 1; k <= Math.min(num(g("max_reward")), 5); k++) {
const i = num(g(`reward_present${k}`));
rewards.push({ k, index: i, present: presents.get(i) || null });
}
const h = num(g("help"));
const chain = (m, sb) => {
if (!(m > 0 && sb > 0)) return null;
const t = this.byMain(m);
return { main: m, sub: sb, section: t?.section || null, title: t?.subs[sb - 1]?.title || null };
};
return {
j: s.j,
rewards,
help: h > 0 ? [this.help(h), this.help(h + HELP_DONE), this.help(h + HELP_GOAL)].map((x, i) => ({ ...x, role: ["In progress", "Completed", "Objective"][i] })) : [],
guides: guides.get(`${q.main}.${s.j}`) || [],
prev: chain(num(g("prev_main_index")), num(g("prev_sub_index"))),
next: chain(num(g("next_main_index")), num(g("next_sub_index"))),
};
});
return { ...this.sectionData("quest", q.section), quest: { section: q.section, n: q.n, main: q.main, cls: q.cls }, subs, problems: this.questProblems(q) };
}
// ---------- numbers for new things ----------
next() {
const qs = this.quests();
const mains = new Set(qs.map((q) => q.main));
let main = Math.max(0, ...qs.map((q) => (q.main <= 65535 ? q.main : 0))) + 1;
while (mains.has(main)) main++;
const presents = [...this.presents().values()];
const pd = this.doc("present");
// a new help base needs H, H+100000 and H+200000 free
const helps = this.helpIndex();
let help = Math.max(0, ...[...helps.keys()].filter((i) => i < HELP_DONE)) + 1;
while (helps.has(help) || helps.has(help + HELP_DONE) || helps.has(help + HELP_GOAL)) help++;
const maxSection = (id) => {
const d = this.doc(id);
if (!d) return 0;
return Math.max(0, ...d.order.map((s) => num(new RegExp(`^${FILES[id].prefix}(\\d+)$`, "i").exec(s.name)?.[1])));
};
return {
questSection: `quest${maxSection("quest") + 1}`,
main,
presentSection: pd ? `Present${maxSection("present") + 1}` : null,
rewardIndex: Math.max(0, ...presents.map((p) => p.index)) + 1,
help,
missionSection: Object.fromEntries(["daily", "weekly", "monthly"].map((id) => [id, `date${maxSection(id) + 1}`])),
missionCode: Object.fromEntries(["daily", "weekly", "monthly"].map((id) => [id, Math.max(0, ...this.missions(id).flatMap((x) => x.missions.map((m) => m.code))) + 1])),
};
}
// ---------- writing ----------
nextStr(d, section, pending) {
let n = 0;
const scan = (v) => {
const m = /STR\(\s*(\d+)/i.exec(v || "");
if (m) n = Math.max(n, Number(m[1]));
};
for (const [, v] of d.has(section) ? d.entries(section) : []) scan(v);
for (const v of Object.values(pending)) scan(v);
return n + 1;
}
setText(fileId, section, n, text, warnings) {
const t = this.textDoc();
if (!t) throw new Error("No string table to write the text into.");
const enc = this.text.encoding;
const clean = String(text).replace(/\r/g, "").replace(/\n/g, "\\n");
const bytes = codec.encode(clean, enc);
if (codec.decode(bytes, enc) !== clean) warnings.push(`Some characters of "${String(text).slice(0, 30)}" don't exist in ${enc} and became "?".`);
t.set(`${t.iniPrefix()}${stemOf(fileId)}::${section}_${n}`, bytes.toString("latin1"));
this.tdoc.dirty = true;
}
/**
* One change to one section of a file:
* { file, section, create?, remove?, values: { key: raw | null }, texts: { key: "text" | "" } }
* texts go to the string table (the key becomes / stays STR(n)); "" removes the key.
* → { warnings }
*/
apply(change) {
const f = FILES[change.file];
if (!f) throw new Error(`Unknown file ${change.file}`);
const d = this.doc(change.file);
if (!d) throw new Error(`${f.name} isn't in the packs.`);
const section = change.section;
const warnings = [];
if (change.remove) {
d.removeSection(section);
} else {
if (change.create) d.addSection(section);
else if (!d.has(section)) throw new Error(`[${section}] isn't in ${f.name}.`);
const vals = { ...(change.values || {}) };
for (const [key, text] of Object.entries(change.texts || {})) {
if (text === "" || text === null) {
vals[key] = null;
continue;
}
const raw = key in vals ? vals[key] : d.get(section, key);
let n = /STR\(\s*(\d+)/i.exec(raw || "")?.[1];
if (!n) {
n = this.nextStr(d, section, vals);
vals[key] = `STR(${n})`;
}
this.setText(change.file, section, n, text, warnings);
}
d.update(section, vals);
// the client reads sections 1..max: keep a new section inside
const m = new RegExp(`^${f.prefix}(\\d+)$`, "i").exec(section);
if (m && f.max && d.has(f.max[0]) && Number(m[1]) > num(d.get(f.max[0], f.max[1]))) d.update(f.max[0], { [f.max[1]]: m[1] });
}
this.files[change.file].dirty = true;
this.forgetParsed();
return { warnings };
}
forgetParsed() {
this._quests = this._byMain = this._presents = this._help = this._guides = this._missions = null;
}
/** Writes every changed file back into its pack. → [written file names] */
async flush() {
const written = [];
for (const [id, f] of Object.entries(this.files)) {
if (!f?.dirty) continue;
await assets.writeBack(f.where, f.doc.toBuffer());
f.dirty = false;
written.push(FILES[id].name);
}
if (this.tdoc?.dirty) {
await assets.writeBack(this.tdoc.where, this.tdoc.doc.toBuffer());
this.tdoc.dirty = false;
written.push(this.text.file);
}
return written;
}
/** The files the server needs, as they are now, into a folder. */
exportTo(folder) {
fs.mkdirSync(folder, { recursive: true });
const out = [];
for (const id of SERVER_FILES) {
const d = this.doc(id);
if (!d) continue;
fs.writeFileSync(path.join(folder, FILES[id].name), d.toBuffer());
out.push(FILES[id].name);
}
return out;
}
}
// ---------- API for the window ----------
function dbOf(src, textFile, { reload = false } = {}) {
const texts = items.textFiles(src);
const text = texts.find((t) => t.file === textFile) || texts[0] || null;
const key = `${srcKey(src)}|${text?.file || ""}`;
if (reload) {
assets.reload(src);
items.cacheReset(src);
for (const k of [...cache.keys()]) if (k.startsWith(srcKey(src))) cache.delete(k);
}
if (!cache.has(key)) cache.set(key, new QuestDb(src, text));
return { db: cache.get(key), texts, text };
}
const light = (q) => ({ section: q.section, n: q.n, main: q.main, cls: q.cls, ignored: q.ignored, subs: q.subs });
function load(src, { textFile = null, reload = false } = {}) {
const { db, texts, text } = dbOf(src, textFile, { reload });
const missing = Object.keys(FILES).filter((id) => !db.doc(id)).map((id) => FILES[id].name);
const quests = db.quests();
const problems = new Map();
for (const p of db.check()) if (p.file === "quest") problems.set(p.section, (problems.get(p.section) || 0) + (p.sev === "error" ? 1000 : 1));
return {
texts: texts.map(({ file, label }) => ({ file, label })),
text: text?.file || null,
missing,
quests: quests.map((q) => ({ ...light(q), errors: Math.floor((problems.get(q.section) || 0) / 1000), warnings: (problems.get(q.section) || 0) % 1000 })),
next: db.next(),
};
}
const call = (src, textFile, fn) => fn(dbOf(src, textFile).db);
const __export_spec = () => ({ classes: spec.CLASSES, enums: spec.ENUMS, presentTypes: spec.PRESENT_TYPES, missionTypes: spec.MISSION_TYPES, subFields: spec.SUB_FIELDS, files: Object.fromEntries(Object.entries(FILES).map(([k, v]) => [k, v.name])) });
const __export_quest = (src, t, section) => call(src, t, (db) => db.questDetail(section));
const __export_section = (src, t, file, section) => call(src, t, (db) => db.sectionData(file, section));
const __export_presents = (src, t) =>
call(src, t, (db) => {
const use = db.presentUse();
return [...db.presents().values()].map((p) => ({ ...p, used: use.get(p.index) || 0 }));
});
const __export_presentLabel = (src, t, type, v1, v2) => call(src, t, (db) => db.presentLabel(num(type), num(v1), num(v2)));
const __export_missions = (src, t, id) => call(src, t, (db) => db.missions(id));
const __export_help = (src, t, index) => call(src, t, (db) => db.help(num(index)));
const __export_check = (src, t) => call(src, t, (db) => db.check());
const __export_next = (src, t) => call(src, t, (db) => db.next());
const __export_pickLists = (src, t) => items.pickLists(src, dbOf(src, t).text?.file || null);
const __export_save = async (src, t, changes) => {
const { db } = dbOf(src, t);
const warnings = [];
try {
for (const c of changes) warnings.push(...db.apply(c).warnings);
const written = await db.flush();
return { warnings, written };
} catch (err) {
// half-applied changes stay out: the next read starts from the packs again
for (const k of [...cache.keys()]) if (k.startsWith(srcKey(src))) cache.delete(k);
throw err;
}
};
const __export_exportTo = (src, t, folder) => call(src, t, (db) => db.exportTo(folder));
export { __export_spec as spec, load, __export_quest as quest, __export_section as section, __export_presents as presents, __export_presentLabel as presentLabel, __export_missions as missions, __export_help as help, __export_check as check, __export_next as next, __export_pickLists as pickLists, __export_save as save, __export_exportTo as exportTo };
export default { spec: __export_spec, load, quest: __export_quest, section: __export_section, presents: __export_presents, presentLabel: __export_presentLabel, missions: __export_missions, help: __export_help, check: __export_check, next: __export_next, pickLists: __export_pickLists, save: __export_save, exportTo: __export_exportTo };
+354
View File
@@ -0,0 +1,354 @@
// Ported from LS Tools desktop: main/questspec.js (mechanical CJS -> ESM)
// What the client and the game server know about quests (see docs/quests.md): enums, the quest
// classes with the meaning of their custom values, present types and mission types. The Quests tab
// uses it for labels, the editor's fields and the checks.
const OCCUR = {
0: "Any event",
1: "Enter lobby",
2: "Enter PvE battle",
3: "Enter PvP battle",
4: "PvP final result",
5: "Battle final result",
6: "Enter plaza",
7: "Fishing success",
8: "Fishing failed",
9: "Fishing level up",
10: "Fishing sell",
11: "Hero acquired",
12: "Grade exp acquired",
13: "Hero exp acquired",
14: "Enter lobby / plaza",
15: "Game login",
16: "PvE round clear",
17: "Enter PvE room",
18: "Enter PvP room",
19: "Excavation level up",
20: "Etc item use",
21: "Camp season reward",
22: "Login after being away (days)",
23: "PC room authority",
24: "Friend recommend",
25: "Login after being away (date)",
26: "Practice success",
};
const COMPLETE = {
0: "None",
1: "PvE round clear",
2: "Enter PvP battle",
3: "PvP KO",
4: "PvP win",
5: "Grade up",
6: "Time growth try",
7: "Time growth success",
8: "Peso growth try",
9: "Buy etc item",
10: "Fishing try",
11: "Fishing success",
12: "Fishing failed",
13: "Fishing level up",
14: "Fishing sell peso",
15: "Fishing success item",
16: "PvP award row",
17: "Hero practice success",
18: "Buy gear",
19: "Gear reinforce success",
20: "Hero level up",
21: "Enter plaza",
22: "Manual close",
23: "Game login",
24: "PvE KO",
25: "Pick item",
26: "Tutorial clear",
27: "Present received",
28: "Camp join",
29: "Enter camp battle",
30: "Camp battle KO",
31: "Camp battle win",
32: "Camp season reward",
33: "Award acquire",
34: "Prisoner drop",
35: "Prisoner save",
36: "Enter headquarters",
37: "Battle exp acquire",
38: "PvP final result shown",
39: "Drop kill",
40: "Multi kill",
41: "Enter lobby",
42: "Enter battle (observer)",
43: "Etc item use",
44: "Friend request",
45: "Friend made",
46: "Stone attack",
47: "Other king attack",
48: "Crown hold time",
49: "Boss mode: become boss",
50: "Boss mode: kill as boss",
51: "Hero acquired",
52: "Attack hit",
53: "Attack defense",
54: "Camp exp acquire",
55: "Camp final result shown",
56: "Quick start option",
57: "Soccer ball hit",
58: "Soccer goal",
59: "Soccer assist",
60: "Soccer save",
61: "Excavation try",
62: "Excavation success",
63: "PvE team KO",
64: "Excavation fail",
65: "Screenshot",
66: "Movie mode",
67: "Make movie",
68: "Gear acquired",
69: "Gear equipped",
70: "KO while wearing gear",
71: "Excavation level up",
72: "Gangsi kill",
73: "Gangsi human kill",
74: "Gangsi alive time",
75: "Gangsi human win",
76: "Hit with attack attribute",
77: "PC room login",
78: "Champion AI clear",
79: "Accessory compound success",
80: "Practice success",
};
const ROOM_STYLE = { "-1": "Any", 1: "Normal battle room", 2: "Ladder / camp team", 3: "Any battle", 4: "Shuffle (today's mode)" };
const MODE = {
"-1": "Any",
0: "None",
1: "Symbol",
2: "Catch",
3: "King",
4: "Training",
5: "Survival",
6: "Team survival",
7: "Boss",
8: "Monster survival",
9: "Football",
10: "Hero match",
11: "Gangsi",
12: "Dungeon A",
13: "Headquarters",
14: "Catch running man",
15: "Fight club",
16: "Tower defense",
17: "Dark Xmas",
18: "Fire temple",
19: "Double crown",
20: "Shuffle bonus",
21: "Factory",
22: "Team survival AI",
23: "House",
24: "My room",
25: "Underwear",
26: "CBT",
27: "Raid",
28: "Succession",
29: "Practice",
30: "Flag capture",
31: "Arena",
32: "Battle",
33: "Farming",
};
const CHANNELING = { "-1": "Any", 0: "Wemade", 300: "MGame", 400: "Daum", 600: "Naver", 700: "Tooniland", 800: "Nexon", 900: "Hangame", 1000: "Valofe", 1100: "HappyTalk" };
// custom value labels; "list" = a count N at that value followed by N codes
const G = "Min grade";
const C = (name, desc, qc, custom = [], opts = {}) => ({ name, desc, qc, custom, ...opts });
const CLASSES = [
C("QuestBasic", "Done on the first matching event", 41),
C("QuestMonsterModeClear", "Clear a PvE floor N times", 1, [G, "Floor (== round)", "Clear count", "Max players at start"], { target: 3 }),
C("QuestEnterBattlePvPMode", "Enter a PvP battle", 2, [G]),
C("QuestEnterBattlePvPModeKO", "PvP kills", 3, ["Kill count", "Best attacker pass (1 = ignore)"], { target: 1 }),
C("QuestEnterBattlePvPModeWin", "PvP wins", 4, ["Win count"], { target: 1 }),
C("QuestPvEMonsterKill", "PvE monster kills", 63, ["Kill count", "Min floor", "Max floor", "Killer class (0 any)", G, "Monster codes N (0 any)"], { target: 1, list: 6 }),
C("QuestGradeUP", "Reach a grade", 5, [G, "Target grade"], { target: 2 }),
C("QuestTimeGrowthTry", "Try time growth", 6, [G]),
C("QuestTimeGrowthSuccess", "Growth level reached", 7, ["Target growth level"], { target: 1 }),
C("QuestPesoGrowthTry", "Try peso growth", 8, [G]),
C("QuestFishingTry", "Fish once", 10),
C("QuestFishingSuccess", "Successful catches", 11, ["Count", "Min fishing level"], { target: 1 }),
C("QuestFishingFailed", "Failed catches", 12, ["Count", "Min fishing level"], { target: 1 }),
C("QuestFishingLevelUP", "Reach a fishing level", 13, ["Min level to offer", "Target level", "Gauge base"], { target: 2 }),
C("QuestFishingSellPeso", "Fish sales of at least X peso", 14, ["Min fishing level", "Min peso per sale", "Count"], { target: 3 }),
C("QuestFishingSuccessItem", "Catch an item type", 15, ["Min fishing level", "Item type", "Count"], { target: 3 }),
C("QuestBattlePvPModeAwardAcquire", "Awards in one result", 16, [G, "Award count"], { target: 2 }),
C("QuestSoldierPractice", "Practice with a hero", 17, ["Hero class"]),
C("QuestExtraItemReinforceSuccess", "Reinforce gear to a level", 19, [G, "Target level", "Gear codes N (0 any)"], { target: 2, list: 3 }),
C("QuestSoldierLevelUP", "Hero level", 20, ["Hero class", "Target level", "Min level to offer", "Gauge base"], { target: 2 }),
C("QuestOpenManual", "Close a manual page", 22, [G, "Manual id (-1 any)"]),
C("QuestLoginCount", "Log in on separate days", 23, [G, "Login count", "Min days between", "Max days (resets above)"], { target: 2 }),
C("QuestEnterPlaza", "Enter the plaza", 21, [G]),
C("QuestGetPotion", "Pick up potions", 25, ["Round", "Count", "Min floor", "Max floor"], { target: 2 }),
C("QuestTutorial", "Tutorial users only", 26),
C("QuestPresentReceive", "Receive gifts", 27, [G, "Count"], { target: 2 }),
C("QuestCampJoin", "Join a camp", 28, [G]),
C("QuestEnterCampBattle", "Enter a camp battle", 29, [G]),
C("QuestCampBattleKO", "Camp battle kills", 30, [G, "Kills", "Best attacker pass"], { target: 2 }),
C("QuestCampBattleWin", "Camp battle wins", 31, [G, "Wins"], { target: 2 }),
C("QuestCampSeasonReward", "Camp season result", 32, [G, "1 win / 2 lose", "Min points (-1)", "Max rank (-1)", "Min bonus peso (-1)"]),
C("QuestAwardAcquire", "Award points", 33, [G, "Award type (-1 any)", "Count"], { target: 3 }),
C("QuestPrisonerDrop", "Drop prisoners", 34, [G, "Count"], { target: 2 }),
C("QuestPrisonerSave", "Rescue prisoners", 35, [G, "Count"], { target: 2 }),
C("QuestBattleLevel", "Party level", 38, ["Min level to offer", "Target level", "Gauge base"], { target: 2 }),
C("QuestAwardLevel", "Award level", 33, ["Min level to offer", "Target level", "Gauge base"], { target: 2 }),
C("QuestContribute", "Contribution in one match", 38, [G, "Min contribution", "Count"], { target: 3 }),
C("QuestDropKill", "Drop kills", 39, [G, "Kills", "Best attacker pass"], { target: 2 }),
C("QuestMultiKill", "Kills in one stage", 40, [G, "Kills", "Best attacker pass"], { target: 2 }),
C("QuestPvPConsecution", "PvP win streak", 4, [G, "Streak"], { target: 2 }),
C("QuestCampConsecution", "Camp win streak", 31, [G, "Streak"], { target: 2 }),
C("QuestEtcItemUse", "Use etc items", 43, [G, "Use count", "Etc item codes N (0 never completes)"], { target: 2, list: 3 }),
C("QuestEtcItemCnt", "Own N of an etc item", 43, [G, "Count", "Etc item code", "Consume on reward (1)"], { target: 2 }),
C("QuestRequestFriend", "Send a friend request", 44, [G]),
C("QuestMakeFriends", "Friend count", 45, [G, "Target", "Min friends to offer"], { target: 2 }),
C("QuestModePlayTime", "Minutes played", 38, [G, "Minutes", "Hero class (-1 any)"], { target: 2 }),
C("QuestStoneAttack", "Damage to the stone", 46, [G, "Total damage", "Team (0 own, 1 enemy)"], { target: 2 }),
C("QuestKingAttack", "Hit the enemy king", 47, [G, "Count"], { target: 2 }),
C("QuestCrownHoldTime", "Hold the crown", 48, [G, "Seconds"], { target: 2 }),
C("QuestBossModeBecomeBoss", "Become the boss", 49, [G, "Count"], { target: 2 }),
C("QuestBossModeBosswithKill", "Kills as the boss", 50, [G, "Kills", "0 = reset when not boss"], { target: 2 }),
C("QuestMortmainSoldierCount", "Permanent heroes owned", 51, [G, "Target", "Min to offer"], { target: 2 }),
C("QuestHitCount", "Hits landed", 52, [G, "Count"], { target: 2 }),
C("QuestDefenceCount", "Blocks", 53, [G, "Count"], { target: 2 }),
C("QuestPickItem", "Pick up items", 25, [G, "Count", "Item codes N (0 any)"], { target: 2, list: 3 }),
C("QuestCampLevel", "Ladder level", 55, ["Min level to offer", "Target level", "Gauge base"], { target: 2 }),
C("QuestBuyItem", "Buy items", 9, [G, "Count", "Codes N (0 any; etc type for 9, machine for 18)"], { target: 2, list: 3 }),
C("QuestMaxFriendSlot", "Friend slots", 9, [G, "Target"], { target: 2 }),
C("QuestRealTimeCount", "Seconds connected", 1, ["Min grade", "Max grade", "Seconds"], { target: 3 }),
C("QuestPlayTimeCount", "Seconds played", 1, ["Min grade", "Max grade", "Seconds"], { target: 3 }),
C("QuestFriendRecommendPlayTime", "Seconds played (friend recommend)", 1, ["Min grade", "Max grade", "Seconds"], { target: 3 }),
C("QuestQuickStartOption", "Pick a quick start type", 56, [G, "Type"]),
C("QuestBuySoldier", "Acquire heroes", 51, [G, "Count", "Type (-1 any, 0 timed, 1 permanent)", "Style (-1 any, 0 bought, 1 trial, 2 PC room, 3 HQ)", "Hero classes N (0 any)"], { target: 2, list: 5 }),
C("QuestPvETimeAttack", "Clear a floor in X seconds", 1, [G, "Floor", "Max seconds"]),
C("QuestSoccerBallHit", "Soccer ball hits", 57, [G, "Count"], { target: 2 }),
C("QuestSoccerGoal", "Soccer goals", 58, [G, "Count"], { target: 2 }),
C("QuestSoccerAssist", "Soccer assists", 59, [G, "Count"], { target: 2 }),
C("QuestSoccerSave", "Soccer saves", 60, [G, "Count"], { target: 2 }),
C("QuestExcavationTry", "Dig once", 61, ["Min dig level"]),
C("QuestExcavationLevelUP", "Dig level", 71, ["Min level to offer", "Target level", "Gauge base"], { target: 2 }),
C("QuestExcavationSuccess", "Artifacts found", 62, ["Min dig level", "Count", "(unused)", "Min peso", "(unused)", "(unused)", "Artifact codes N (0 any)"], { target: 2, list: 7 }),
C("QuestExcavationFail", "Failed digs", 64, ["Min dig level", "Count"], { target: 2 }),
C("QuestExcavationTime", "Seconds digging", 10, ["Min dig level", "Seconds"], { target: 2 }),
C("QuestScreenShot", "Screenshots", 65, [G, "Count"], { target: 2 }),
C("QuestMovieMode", "Movie mode", 66, [G]),
C("QuestMakeMovie", "Make a movie", 67, [G]),
C("QuestExtraItemAcquire", "Get gear", 68, [G, "Period (-1 permanent, >0 at least, 0 any)", "Gear codes N (0 any)"], { list: 3 }),
C("QuestExtraItemEquip", "Equip gear", 69, [G, "Period type (-1 any)", "Gear code (not compared by the client)"]),
C("QuestExtraItemEquipKo", "Kills while wearing gear", 70, [G, "Kills", "Best attacker pass", "Gear code (0 never completes)"], { target: 2 }),
C("QuestGangsiKill", "Gangsi kills", 72, [G, "Count"], { target: 2 }),
C("QuestGangsiHumanKill", "Gangsi: human kills", 73, [G, "Count"], { target: 2 }),
C("QuestGangsiAliveTime", "Gangsi: seconds alive", 74, [G, "Seconds"], { target: 2 }),
C("QuestGangsiHumanWin", "Gangsi: human wins", 75, [G, "Count"], { target: 2 }),
C("QuestHitAttackAttribute", "Hits with an attack attribute", 76, [G, "Count", "Codes N", "Code (only this one is compared)"], { target: 2 }),
C("QuestGuildLevel", "Guild level", 32, ["Target level"], { target: 1 }),
C("QuestGuildLevelMaintenance", "Keep a guild level", 32, ["Min guild level", "Seasons", "Min bonus peso"], { target: 2 }),
C("QuestGuildTeamWin", "Guild team camp wins", 31, [G, "Wins"], { target: 2 }),
C("QuestGuildTeamPlayTime", "Minutes in guild team battles", 38, [G, "Minutes"], { target: 2 }),
C("QuestPlayTimeRepeat", "Rewards every X minutes (never completes)", 1, [G, "Minutes"]),
C("QuestDormantUser", "Returning player (days away)", 0, [G, "Days"]),
C("QuestDormantUserCustom", "Returning player (last logout before a date)", 0, [G, "Year", "Month", "Day"]),
C("QuestDevKMove", "Developer K in the practice room", 0, [G, "X", "Y", "Z", "Range"]),
C("QuestPracticeSuccess", "Practice success", 80, ["Min grade", "Practice number", "Min stars"]),
];
const PRESENT_TYPES = {
1: { name: "Hero", v1: "Hero class", v2: "Hours (0 = permanent)" },
2: { name: "Decoration", v1: "class*100000 + sex*1000 + type", v2: "Code" },
3: { name: "Etc item", v1: "Etc item type", v2: "Amount" },
4: { name: "Peso", v1: "Amount", v2: "" },
5: { name: "Gear", v1: "Gear code", v2: "ext*100000000 + reinforce*10000 + hours" },
6: { name: "Gear box", v1: "Machine code", v2: "(peso array+1)*100 + gold" },
7: { name: "Random decoration", v1: "Value 1", v2: "Value 2" },
8: { name: "Grade exp", v1: "Exp", v2: "" },
9: { name: "Medal", v1: "Medal code", v2: "Hours" },
10: { name: "Alchemic", v1: "Code", v2: "Amount" },
11: { name: "Pet", v1: "Code", v2: "Value" },
12: { name: "Costume", v1: "Costume code", v2: "Period (value % 10000)" },
13: { name: "Bonus cash", v1: "Amount", v2: "" },
14: { name: "Accessory", v1: "Code", v2: "Value" },
15: { name: "Costume box", v1: "Machine code", v2: "Value" },
16: { name: "Spirit", v1: "Code", v2: "Value" },
};
const MISSION_TYPES = {
1: { name: "Login time", values: ["Minutes online"] },
2: { name: "Fishing", values: ["Count", "0 any / 1 success / 2 fail"] },
3: { name: "Peso", values: ["Amount", "1 spent / 2 earned / 0 any"] },
4: { name: "Monster kill", values: ["Count", "Monster code"] },
5: { name: "Item buy", values: ["Present type", "Code", "Count"] },
6: { name: "Clover", values: ["Count", "1 send / 2 receive / 0 any"] },
7: { name: "Excavation", values: ["Count", "0 any / 1 success / 2 fail"] },
8: { name: "Item use", values: ["Present type", "Code", "Count"] },
9: { name: "Reinforce", values: ["Count", "1 success / 2 fail / 0 any"] },
10: { name: "Mode play", values: ["Plays", "Min play time (ms)", "Room style (0 any)", "Mode (0 any)"] },
11: { name: "Daily missions completed", values: ["Count"] },
12: { name: "Daily all clear", values: ["Count"] },
13: { name: "Weekly missions completed", values: ["Count"] },
14: { name: "Weekly all clear", values: ["Count"] },
15: { name: "Monthly all clear", values: ["Count"] },
16: { name: "Additive use", values: ["Count", "Use type"] },
};
// the sub-quest fields the editor shows, in order: [key, label, kind, default]
// kind: text (STR string), int, bool, enum:<name>, date
const SUB_FIELDS = [
["title", "Title", "text", ""],
["progress", "Progress text", "text", ""],
["progress_result", "Counter suffix", "text", ""],
["icon", "Icon", "icon", ""],
["sub_icon", "Small icon", "icon", ""],
["help", "Help index", "int", 0],
["perform_type", "Type", "enum:perform", 1],
["channeling_type", "Channeling", "enum:channeling", -1],
["occur_value", "Offered on", "enum:occur", 1],
["occur_value_ex", "Offered: etc item type", "int", -1],
["occur_mode", "Offered in mode", "enum:mode", -1],
["occur_excepting_mode", "Not offered in mode", "enum:mode", -1],
["occur_room_style", "Offered in room", "enum:room", -1],
["occur_limit_grade_min", "Offered from grade", "int", -1],
["occur_limit_grade_max", "Offered up to grade", "int", -1],
["complete_value", "Completes on", "enum:complete", 0],
["complete_mode", "Completes in mode", "enum:mode", -1],
["complete_excepting_mode", "Not completed in mode", "enum:mode", -1],
["complete_room_style", "Completes in room", "enum:room", -1],
["oneday_style", "Daily (reset every day)", "bool", 0],
["repeat_style", "Repeatable", "bool", 0],
["guild_style", "Needs a guild", "bool", 0],
["guild_terms_level", "Min guild level", "int", 0],
["pcroom_style", "PC room only", "bool", 0],
["prev_main_index", "Needs quest (main)", "int", 0],
["prev_sub_index", "Needs quest (sub)", "int", 0],
["next_main_index", "Next quest (main)", "int", 0],
["next_sub_index", "Next quest (sub)", "int", 0],
["rewardselect_style", "Player picks rewards", "bool", 0],
["rewardselect_num", "Rewards to pick", "int", 1],
["invited_pass", "Auto-pass invites", "bool", 0],
["user_mode_pass", "Not in custom rooms", "bool", 0],
["death_time_pass", "Not while dead", "bool", 0],
["play_occur_show", "Popup: offered (play)", "bool", 1],
["play_complete_show", "Popup: completed (play)", "bool", 1],
["round_end_occur_show", "Popup: offered (round end)", "bool", 1],
["round_end_complete_show", "Popup: completed (round end)", "bool", 1],
["complete_game_alarm", "Announce completion", "bool", 0],
["complete_web_alarm", "Web board on completion", "bool", 0],
["costume", "Costume mark", "bool", 0],
];
const ENUMS = {
perform: { 1: "Normal", 2: "Event (dates)" },
channeling: CHANNELING,
occur: OCCUR,
complete: COMPLETE,
mode: MODE,
room: ROOM_STYLE,
};
export { CLASSES, ENUMS, PRESENT_TYPES, MISSION_TYPES, SUB_FIELDS, OCCUR, COMPLETE, MODE, ROOM_STYLE, CHANNELING };
export default { CLASSES, ENUMS, PRESENT_TYPES, MISSION_TYPES, SUB_FIELDS, OCCUR, COMPLETE, MODE, ROOM_STYLE, CHANNELING };
+19
View File
@@ -0,0 +1,19 @@
// Web replacement of the desktop store.js: only the key-profile part the ported modules use.
// (Workspaces, recent files and UI settings live in the UI thread's localStorage.)
import { allKeys } from "../../keys.js";
/** Key profiles (full objects, like the desktop store) in the order they are tried. */
export function keyOrder() {
return allKeys();
}
/** A key profile by id: { id, name, passwords: [type0, type1] }. */
export function profile(id) {
return allKeys().find((k) => k.id === id) || null;
}
export function profiles() {
return allKeys();
}
export default { keyOrder, profile, profiles };
+191
View File
@@ -0,0 +1,191 @@
// Ported from LS Tools desktop: main/xlsx.js (mechanical CJS -> ESM)
import zlib from "node:zlib";
// Minimal .xlsx reading / writing (no dependency): a zip of XML parts. Reading takes the cell text
// of every sheet (shared and inline strings, numbers, booleans); writing makes one workbook with
// plain text / number cells and a bold first row, which Excel, LibreOffice and Google Sheets open.
// ---------- zip ----------
function readZip(buf) {
let eocd = -1;
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) {
if (buf.readUInt32LE(i) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd < 0) throw new Error("Not a zip / xlsx file.");
const count = buf.readUInt16LE(eocd + 10);
let p = buf.readUInt32LE(eocd + 16);
const files = new Map();
for (let i = 0; i < count; i++) {
if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error("Bad zip directory.");
const method = buf.readUInt16LE(p + 10);
const csize = buf.readUInt32LE(p + 20);
const nlen = buf.readUInt16LE(p + 28);
const xlen = buf.readUInt16LE(p + 30);
const clen = buf.readUInt16LE(p + 32);
const local = buf.readUInt32LE(p + 42);
const name = buf.toString("utf8", p + 46, p + 46 + nlen);
const data = buf.subarray(local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28));
const raw = data.subarray(0, csize);
files.set(name, method === 0 ? Buffer.from(raw) : zlib.inflateRawSync(raw));
p += 46 + nlen + xlen + clen;
}
return files;
}
const crcTable = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function writeZip(files) {
const parts = [];
const dir = [];
let off = 0;
for (const [name, content] of files) {
const data = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
const comp = zlib.deflateRawSync(data);
const nameBuf = Buffer.from(name, "utf8");
const crc = crc32(data);
const head = Buffer.alloc(30);
head.writeUInt32LE(0x04034b50, 0);
head.writeUInt16LE(20, 4);
head.writeUInt16LE(0x0800, 6);
head.writeUInt16LE(8, 8);
head.writeUInt32LE(crc, 14);
head.writeUInt32LE(comp.length, 18);
head.writeUInt32LE(data.length, 22);
head.writeUInt16LE(nameBuf.length, 26);
parts.push(head, nameBuf, comp);
const c = Buffer.alloc(46);
c.writeUInt32LE(0x02014b50, 0);
c.writeUInt16LE(20, 4);
c.writeUInt16LE(20, 6);
c.writeUInt16LE(0x0800, 8);
c.writeUInt16LE(8, 10);
c.writeUInt32LE(crc, 16);
c.writeUInt32LE(comp.length, 20);
c.writeUInt32LE(data.length, 24);
c.writeUInt16LE(nameBuf.length, 28);
c.writeUInt32LE(off, 42);
dir.push(c, nameBuf);
off += 30 + nameBuf.length + comp.length;
}
const dirBuf = Buffer.concat(dir);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(files.size ?? files.length, 8);
end.writeUInt16LE(files.size ?? files.length, 10);
end.writeUInt32LE(dirBuf.length, 12);
end.writeUInt32LE(off, 16);
return Buffer.concat([...parts, dirBuf, end]);
}
// ---------- xml helpers ----------
const unxml = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))).replace(/&amp;/g, "&");
const xml = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
const colIndex = (ref) => {
const m = /^([A-Z]+)/.exec(ref);
let n = 0;
for (const ch of m ? m[1] : "A") n = n * 26 + (ch.charCodeAt(0) - 64);
return n - 1;
};
const colName = (i) => {
let s = "";
for (i++; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + ((i - 1) % 26)) + s;
return s;
};
/** → [{ name, rows: [[text, …], …] }] */
function readXlsx(buf) {
const files = readZip(buf);
const text = (n) => files.get(n)?.toString("utf8") || "";
const shared = [...text("xl/sharedStrings.xml").matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) => unxml([...m[1].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => t[1]).join("")));
const rels = new Map([...text("xl/_rels/workbook.xml.rels").matchAll(/<Relationship\b[^>]*>/g)].map((m) => [/Id="([^"]+)"/.exec(m[0])?.[1], /Target="([^"]+)"/.exec(m[0])?.[1]]));
const sheets = [];
for (const m of text("xl/workbook.xml").matchAll(/<sheet\b[^>]*>/g)) {
const name = unxml(/name="([^"]*)"/.exec(m[0])?.[1] || "");
const rid = /r:id="([^"]+)"/.exec(m[0])?.[1];
let target = rels.get(rid) || "";
target = target.startsWith("/") ? target.slice(1) : `xl/${target}`;
const rows = [];
for (const r of text(target).matchAll(/<row\b([^>]*)>([\s\S]*?)<\/row>|<row\b([^>]*)\/>/g)) {
const rn = Number(/r="(\d+)"/.exec(r[1] || r[3] || "")?.[1] || rows.length + 1) - 1;
const cells = [];
for (const c of (r[2] || "").matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
const attrs = c[1];
const ref = /r="([A-Z]+\d+)"/.exec(attrs)?.[1];
const t = /t="([^"]+)"/.exec(attrs)?.[1];
const body = c[2] || "";
const v = /<v>([\s\S]*?)<\/v>/.exec(body)?.[1];
let val = "";
if (t === "s") val = shared[Number(v)] ?? "";
else if (t === "inlineStr") val = unxml([...body.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((x) => x[1]).join(""));
else if (t === "b") val = v === "1" ? "TRUE" : "FALSE";
else if (v !== undefined) val = unxml(v);
cells[ref ? colIndex(ref) : cells.length] = val;
}
rows[rn] = Array.from(cells, (x) => x ?? "");
}
sheets.push({ name, rows: Array.from(rows, (x) => x || []) });
}
return sheets;
}
/** sheets: [{ name, rows: [[value]] }] (numbers stay numbers) → .xlsx bytes */
function writeXlsx(sheets) {
const files = new Map();
files.set(
"[Content_Types].xml",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${sheets.map((_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join("")}</Types>`,
);
files.set("_rels/.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`);
files.set(
"xl/workbook.xml",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>${sheets.map((s, i) => `<sheet name="${xml(s.name.replace(/[\\/?*[\]:]/g, "_").slice(0, 31) || `Sheet${i + 1}`)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join("")}</sheets></workbook>`,
);
files.set(
"xl/_rels/workbook.xml.rels",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${sheets.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join("")}<Relationship Id="rId${sheets.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`,
);
files.set(
"xl/styles.xml",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border/></borders><cellStyleXfs count="1"><xf/></cellStyleXfs><cellXfs count="2"><xf fontId="0"/><xf fontId="1" applyFont="1"/></cellXfs></styleSheet>`,
);
sheets.forEach((s, i) => {
const rows = s.rows
.map((row, r) => {
const cells = row
.map((v, c) => {
if (v === null || v === undefined || v === "") return "";
const ref = `${colName(c)}${r + 1}`;
const st = r === 0 ? ' s="1"' : "";
if (typeof v === "number" && Number.isFinite(v)) return `<c r="${ref}"${st}><v>${v}</v></c>`;
return `<c r="${ref}"${st} t="inlineStr"><is><t xml:space="preserve">${xml(v)}</t></is></c>`;
})
.join("");
return `<row r="${r + 1}">${cells}</row>`;
})
.join("");
files.set(
`xl/worksheets/sheet${i + 1}.xml`,
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><sheetData>${rows}</sheetData></worksheet>`,
);
});
return writeZip(files);
}
export { readZip, writeZip, readXlsx, writeXlsx };
export default { readZip, writeZip, readXlsx, writeXlsx };