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