LosaDev Tools: Lost Saga modding tools in the browser

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

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
// Copies the desktop renderer into public/legacy/ for the web host, without editing the scripts:
// js/<file>.js renderer scripts, byte for byte except the fixes in tools/legacy-patches.mjs
// (app.js and monitor.js are left out)
// views/<view>.html each <section id="view-<view>" class="view"> of index.html
// chrome.html shared overlays: #menu, #modal, #toast, #task
// legacy.css app.css scoped under .lsx (CSS nesting), @keyframes hoisted out,
// :root tokens moved onto .lsx so the web theme can override them
// usage: node tools/extract-legacy.mjs
import fs from "node:fs";
import path from "node:path";
import { applyPatches, PATCHES } from "./legacy-patches.mjs";
const REF = path.resolve("../reference/lstools-desktop/src/renderer");
const OUT = path.resolve("public/legacy");
const SKIP = new Set(["app.js", "monitor.js"]);
const DROP_VIEWS = new Set(["iop", "pm", "mon", "pw"]); // IOP Manager UI comes back later as its own page
fs.rmSync(OUT, { recursive: true, force: true });
fs.mkdirSync(path.join(OUT, "js"), { recursive: true });
fs.mkdirSync(path.join(OUT, "views"), { recursive: true });
// ---------- scripts ----------
const scripts = fs.readdirSync(REF).filter((f) => f.endsWith(".js") && !SKIP.has(f));
for (const f of scripts) {
if (!PATCHES.some((p) => p.file === f)) {
fs.copyFileSync(path.join(REF, f), path.join(OUT, "js", f));
continue;
}
fs.writeFileSync(path.join(OUT, "js", f), applyPatches(f, fs.readFileSync(path.join(REF, f), "utf8")));
console.log(`patched ${f}: ${PATCHES.filter((p) => p.file === f).length}`);
}
// ---------- markup ----------
const html = fs.readFileSync(path.join(REF, "index.html"), "utf8");
/** The element starting at `start` (an opening tag) up to its matching close tag. */
function element(src, start) {
const tag = src.slice(start + 1).match(/^[\w-]+/)[0];
const re = new RegExp(`<(/?)${tag}\\b[^>]*>`, "g");
re.lastIndex = start;
let depth = 0;
for (let m; (m = re.exec(src)); ) {
if (m[0].endsWith("/>")) continue;
depth += m[1] ? -1 : 1;
if (depth === 0) return src.slice(start, re.lastIndex);
}
throw new Error(`unclosed <${tag}> at ${start}`);
}
const views = [];
for (const m of html.matchAll(/<section id="view-([\w-]+)" class="view[^"]*">/g)) {
const name = m[1];
if (DROP_VIEWS.has(name)) continue;
fs.writeFileSync(path.join(OUT, "views", `${name}.html`), element(html, m.index));
views.push(name);
}
const chrome = [];
for (const id of ["menu", "modal", "toast", "task"]) {
const m = html.match(new RegExp(`<[\\w-]+[^>]*\\bid="${id}"[^>]*>`));
if (m) chrome.push(element(html, m.index));
else console.warn(`no #${id} in index.html`);
}
fs.writeFileSync(path.join(OUT, "chrome.html"), chrome.join("\n"));
// ---------- css ----------
let css = fs.readFileSync(path.join(REF, "app.css"), "utf8");
const keyframes = [];
css = css.replace(/@keyframes\s+[\w-]+\s*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, (k) => (keyframes.push(k), ""));
css = css.replace(/:root\s*\{/, "& {");
fs.writeFileSync(path.join(OUT, "legacy.css"), `/* Generated by tools/extract-legacy.mjs from the desktop app.css. Do not edit. */\n${keyframes.join("\n")}\n.lsx {\n${css}\n}\n`);
// ---------- host.js: the shared helpers of app.js, copied line for line ----------
// Line ranges of the reference app.js (1-based, inclusive). Only helpers the tool scripts use;
// IOP Manager / Patch Manager / Monitor code stays out.
const APP_RANGES = [
[6, 103], // $, bridge, call, el, size, hex, fmtDos, MAX_ENTRIES, toast, modal, confirmBox, prompt, runTask
[162, 162], // baseName
[494, 558], // ICONS, icon()
[564, 593], // showMenu, hideMenu, cmd
[818, 823], // replay
[1051, 1063], // unsavedWork (profileSource / profileSourceOption: web versions in host-web-after.js)
];
const appLines = fs.readFileSync(path.join(REF, "app.js"), "utf8").split(/\r?\n/);
const copied = APP_RANGES.map(([a, b]) => `// ---- app.js ${a}-${b} ----\n` + appLines.slice(a - 1, b).join("\n")).join("\n\n");
const webPart = fs.readFileSync(path.resolve("src/legacy/host-web.js"), "utf8");
const webAfter = fs.readFileSync(path.resolve("src/legacy/host-web-after.js"), "utf8");
fs.writeFileSync(
path.join(OUT, "host.js"),
`"use strict";\n// Generated by tools/extract-legacy.mjs: helpers copied from the desktop app.js + web additions.\n\n${webPart}\n\n${copied}\n\n${webAfter}\n`,
);
// Load order = the desktop index.html order (scripts depend on earlier ones' globals)
const order = [...html.matchAll(/<script src="([\w.-]+\.js)"><\/script>/g)].map((m) => m[1]).filter((f) => !SKIP.has(f));
const missing = scripts.filter((s) => !order.includes(s));
if (missing.length) console.warn("scripts not in index.html (not loaded):", missing.join(", "));
fs.writeFileSync(path.join(OUT, "manifest.json"), JSON.stringify({ order, views }, null, 2));
console.log(`scripts ${scripts.length}, views ${views.join(", ")}, keyframes ${keyframes.length}, chrome ${chrome.length}`);
+78
View File
@@ -0,0 +1,78 @@
// Generates src/worker/lib/main/handlers.js from the desktop main/index.js: the statements inside
// registerIpc() are copied as source text (no logic changes), except
// - handle()/task() registrations of channels the web version doesn't have (dropped prefixes),
// - every require("x") becomes an identifier imported once at the top of the module.
// usage: node tools/gen-handlers.mjs
import fs from "node:fs";
import path from "node:path";
import * as acorn from "acorn";
const SRC = path.resolve("../reference/lstools-desktop/src/main/index.js");
const OUT = path.resolve("src/worker/lib/main/handlers.js");
// Answered by the browser (bridge.js) or not part of the web version.
const DROP = ["dialog:", "shell:", "store:", "ws:", "app:", "pm:", "cz:", "lr:", "mon:", "iop:", "snd:"];
// Handlers that need a module the web version does not ship are left out too.
const MAIN_DIR = path.dirname(OUT);
// Kept channels whose body opens an Electron dialog: answered in the browser (bridge.js) instead.
const DIALOG_CHANNELS = ["fxlab:openJson", "fxlab:saveJson", "fx:openFile", "dat:pickFile"];
// Non-registration statements that wire up removed parts (IOP Manager notifier, Monitor, Patch Manager).
const DROP_STATEMENT = /\b(iop\.setNotifier|monitor\.|pm\.|patchman|launcherpub|clientzip)\b/;
const text = fs.readFileSync(SRC, "utf8");
const ast = acorn.parse(text, { ecmaVersion: "latest", sourceType: "script", locations: true });
const reg = ast.body.find((n) => n.type === "FunctionDeclaration" && n.id.name === "registerIpc");
if (!reg) throw new Error("registerIpc not found");
const kept = [];
const dropped = [];
for (const st of reg.body.body) {
const call = st.type === "ExpressionStatement" && st.expression.type === "CallExpression" ? st.expression : null;
const isReg = call && call.callee.type === "Identifier" && ["handle", "task"].includes(call.callee.name) && call.arguments[0]?.type === "Literal";
if (isReg) {
const ch = call.arguments[0].value;
if (DROP.some((p) => ch.startsWith(p)) || DIALOG_CHANNELS.includes(ch)) {
dropped.push(ch);
continue;
}
} else if (DROP_STATEMENT.test(text.slice(st.start, st.end))) {
dropped.push(`(statement line ${st.loc.start.line})`);
continue;
}
const needs = [...text.slice(st.start, st.end).matchAll(/require\("(\.\/[^"]+)"\)/g)].map((m) => (m[1].endsWith(".js") || m[1].endsWith(".json") ? m[1] : m[1] + ".js"));
if (needs.some((s) => !fs.existsSync(path.join(MAIN_DIR, s)))) {
dropped.push("(unshipped)");
continue;
}
// keep comments right above the statement
kept.push(text.slice(st.start, st.end));
}
// requires -> hoisted imports
const imports = new Map(); // spec -> identifier
const idFor = (spec) => {
if (!imports.has(spec)) imports.set(spec, `__req_${spec.replace(/^node:/, "node_").replace(/[^\w]/g, "_")}`);
return imports.get(spec);
};
let body = kept.join("\n\n").replace(/require\("([^"]+)"\)/g, (_, spec) => idFor(spec));
// names index.js defines outside registerIpc that the handlers use
const fixSpec = (s) => (s.startsWith(".") && !s.endsWith(".js") && !s.endsWith(".json") ? s + ".js" : s);
const head = [...imports].map(([spec, id]) => `import ${id} from "${fixSpec(spec)}";`);
head.push(`import path from "node:path";`, `import { app } from "electron";`, `import store from "./store.js";`, `import iop from "./iopman.js";`);
const out = `// Generated by tools/gen-handlers.mjs from the desktop main/index.js registerIpc(). Do not edit.
// Dropped channels: ${dropped.length}
${head.join("\n")}
const tasks = new Map();
const win = null;
export function register(handle, task) {
${body.replace(/^/gm, "\t")}
}
`;
fs.writeFileSync(OUT, out);
const left = [...out.matchAll(/\brequire\(/g)].length;
const regs = [...out.matchAll(/\b(handle|task)\("([\w:.-]+)"/g)].map((m) => m[2]);
console.log(`kept ${regs.length} channels, dropped ${dropped.length}, imports ${imports.size}, require() left ${left}`);
console.log("imports: " + [...imports.keys()].join(", "));
+173
View File
@@ -0,0 +1,173 @@
// Fixes applied to the copied desktop renderer scripts by tools/extract-legacy.mjs (the copies in
// public/legacy are regenerated, so a fix lives here). Each patch must match exactly once, or the
// extraction stops — a changed desktop source then gets looked at instead of silently skipped.
export const PATCHES = [
// ---------- Neutral wording in a copied comment ----------
{
file: "lsformats.js",
why: "comment names other servers",
find: / \/\/ Index width: 16-bit in every \S+ client file;[^\r\n]*/,
replace: " // Index width: 16-bit in the client files we checked; some newer engines read 32-bit.",
},
// ---------- Heroes: a helmet changes the default hair like the game ----------
// LSClient ioHelmetItem / ioEquipSlot: hair_change_all0_hm (man) / _hw (woman) of the helmet:
// 0 = half hair ("half" + hair mesh, the default), 1 = no hair, 2 = "bind" + hair, 3 = full hair.
// The desktop preview always kept the full default hair, so it stuck out of helmets.
{
file: "model.js",
why: "hero preview: helmet hair rule",
find: "async function previewHero(src, meshes, animation, { embedded = false, host = null } = {}) {",
replace: "async function previewHero(src, meshes, animation, { embedded = false, host = null, hair = null } = {}) {",
},
{
file: "model.js",
why: "hero preview: helmet hair rule",
find: " const missing = [];\n for (const name of [...new Set(meshes)].filter(Boolean)) {",
replace: [
" // web fix: the helmet's hair rule (0 half, 1 none, 2 bind, 3 full)",
" if (hair !== null && hair !== undefined && Number(hair) !== 3) {",
" const baseHair = MDL.scene.meshes.find((m) => /^hair/i.test(m.name));",
" if (baseHair) {",
" removeMesh(baseHair);",
" const alt = Number(hair) === 0 ? `half${baseHair.name}` : Number(hair) === 2 ? `bind${baseHair.name}` : \"\";",
" const h = alt ? await readNamed(`${alt}.msh`, \"mesh\") : null;",
" if (h) await addMesh(h, alt);",
" }",
" }",
" const missing = [];",
" for (const name of [...new Set(meshes)].filter(Boolean)) {",
].join("\n"),
},
{
file: "hero.js",
why: "hero preview: pass the helmet hair rule",
find: " await MDL.previewHero(HERO.src, meshes, animation, { embedded: true });",
replace: " await MDL.previewHero(HERO.src, meshes, animation, { embedded: true, hair: helmetHair() });",
},
{
file: "hero.js",
why: "hero preview: pass the helmet hair rule (animation previews)",
find: " await MDL.previewHero(HERO.src, gearMeshes(), name);",
replace: " await MDL.previewHero(HERO.src, gearMeshes(), name, { hair: helmetHair() });",
},
{
file: "hero.js",
why: "hero preview: helmet hair rule + STR(n) names from the text table",
find: " const gearMeshes = () =>",
replace: [
" // web fix: the helmet section (item_type HELMET) and its hair rule for the man body (default 0 = half hair)",
" function helmetHair() {",
" const s = itemSections().find((x) => /^helmet$/i.test(value(\"item\", x.name, \"item_type\")) && value(\"item\", x.name, \"equip_mesh0\"));",
" if (!s) return null;",
" const v = value(\"item\", s.name, \"hair_change_all0_hm\");",
" return v === \"\" ? 0 : Number(v);",
" }",
" const gearMeshes = () =>",
].join("\n"),
},
// ---------- Heroes: STR(n) names read from the game's text table ----------
{
file: "hero.js",
why: "STR(n) names",
find: " const value = (kind, section, key) => { const d = doc(kind); return d ? HeroIni.get(d, section, key) : \"\"; };",
replace: [
" const value = (kind, section, key) => { const d = doc(kind); return d ? HeroIni.get(d, section, key) : \"\"; };",
" // web fix: STR(n) → the text the game shows (text table key ini_<file stem>::<section>_<n>)",
" const shown = (kind, section, v) => {",
" const m = /^\\s*STR\\(\\s*(\\d+)/i.exec(v || \"\");",
" return m ? HERO.strs?.[`ini_${HERO.id}_${kind}::${section}_${m[1]}`.toLowerCase()] || v : v;",
" };",
" const strText = (kind, section, key) => shown(kind, section, value(kind, section, key));",
].join("\n"),
},
{
file: "hero.js",
why: "STR(n) names: load the hero's strings with its configs",
find: " if (!docs.item) throw new Error(`No ${id}_item.ini in this source`);",
replace: [
" if (!docs.item) throw new Error(`No ${id}_item.ini in this source`);",
" try { HERO.strs = await call(\"text:forStems\", HERO.src, Object.keys(docs).map((k) => `${id}_${k}`)); } catch { HERO.strs = {}; }",
].join("\n"),
},
{
file: "hero.js",
why: "STR(n) names: moveset cards",
find: 'cards.push(card(`${value("item", s.name, "item_type") || s.name} · ${value("item", s.name, "name")}`',
replace: 'cards.push(card(`${value("item", s.name, "item_type") || s.name} · ${strText("item", s.name, "name")}`',
},
{
file: "hero.js",
why: "STR(n) names: skill cards",
find: 'return card(`${s.name} · ${value("skill", s.name, "desc_name") || value("skill", s.name, "name")}`',
replace: 'return card(`${s.name} · ${strText("skill", s.name, "desc_name") || strText("skill", s.name, "name")}`',
},
{
file: "hero.js",
why: "STR(n) names: skill buttons",
find: ' const title = value("skill", section, "desc_name") || value("skill", section, "name");',
replace: ' const title = strText("skill", section, "desc_name") || strText("skill", section, "name");',
},
{
file: "hero.js",
why: "STR(n) names: gear cards",
find: ' const name = value("item", s.name, "name");',
replace: ' const name = strText("item", s.name, "name");',
},
];
PATCHES.push(
// ---------- Textures: the list renders in chunks (a game folder has 10 000+ textures) ----------
// Search still filters the whole list (an array in memory); only the rows drawn are limited:
// the first 300, then 300 more each time the list is scrolled near its end.
{
file: "texture.js",
why: "textures list in chunks",
find: [
" $(\"tex-count\").textContent = rows.length ? String(rows.length) : \"\";",
" $(\"tex-list\").replaceChildren(",
" ...rows.map((f) =>",
].join("\n"),
replace: [
" $(\"tex-count\").textContent = rows.length ? String(rows.length) : \"\";",
" // web fix: draw 300 rows at a time, more while scrolling (10 000+ textures froze the page)",
" const box = $(\"tex-list\");",
" const CHUNK = 300;",
" let shown = Math.min(rows.length, Math.max(CHUNK, TEX._shown || 0));",
" const rowEl = (f) =>",
" el(\"div\", { class: `tex-row${TEX.file?.name === f.name && TEX.file?.dir === TEX.dir ? \" on\" : \"\"}`, title: `${f.name} — ${size(f.size)}`, onclick: () => openFile(join(TEX.dir, f.name)) }, [",
" el(\"span\", { class: `tex-ext ${extOf(f.name)}`, text: extOf(f.name) }),",
" el(\"span\", { class: \"grow\", text: f.name }),",
" el(\"span\", { class: \"dim\", text: size(f.size) }),",
" ]);",
" box.onscroll = () => {",
" if (shown >= rows.length || box.scrollTop + box.clientHeight < box.scrollHeight - 400) return;",
" box.append(...rows.slice(shown, shown + CHUNK).map(rowEl));",
" shown = Math.min(rows.length, shown + CHUNK);",
" TEX._shown = shown;",
" };",
" if (TEX._q !== q) (TEX._q = q), (shown = Math.min(rows.length, CHUNK)), (box.scrollTop = 0);",
" TEX._shown = shown;",
" $(\"tex-list\").replaceChildren(",
" ...rows.slice(0, shown).map((f) =>",
].join("\n"),
},
);
/** text → patched text; throws when a patch doesn't match exactly once. */
export function applyPatches(file, text) {
const crlf = text.includes("\r\n");
const eol = (s) => (crlf ? s.replace(/\r?\n/g, "\r\n") : s);
for (const p of PATCHES.filter((x) => x.file === file)) {
if (p.find instanceof RegExp) {
const n = (text.match(new RegExp(p.find.source, p.find.flags.replace("g", "") + "g")) || []).length;
if (n !== 1) throw new Error(`legacy patch "${p.why}" matches ${n}× in ${file}`);
text = text.replace(p.find, () => eol(p.replace));
continue;
}
const find = eol(p.find);
const n = text.split(find).length - 1;
if (n !== 1) throw new Error(`legacy patch "${p.why}" matches ${n}× in ${file}`);
text = text.replace(find, () => eol(p.replace));
}
return text;
}
+74
View File
@@ -0,0 +1,74 @@
// Mechanical CommonJS -> ESM conversion of desktop LS Tools modules (no logic changes).
// usage: node tools/port-module.mjs <reference file> <output file>
// const x = require("y"); -> import x from "y";
// const { a, b: c } = require("y"); -> import { a, b as c } from "y";
// module.exports = { A, B: C }; -> export { A, C as B };
// module.exports = X; -> export default X;
// Relative requires get ".js" added. Anything else that is not this simple is reported, so it
// gets ported by hand instead of guessed.
import fs from "node:fs";
import * as acorn from "acorn";
const [src, dst] = process.argv.slice(2);
let text = fs.readFileSync(src, "utf8");
const problems = [];
const fixSpec = (s) => (s.startsWith(".") && !s.endsWith(".js") && !s.endsWith(".json") ? s + ".js" : s);
text = text.replace(/^const (\w+) = require\("([^"]+)"\);?$/gm, (_, name, spec) => `import ${name} from "${fixSpec(spec)}";`);
text = text.replace(/^const \{([^}]+)\} = require\("([^"]+)"\);?$/gm, (_, names, spec) => {
const list = names.split(",").map((n) => n.trim()).filter(Boolean).map((n) => n.replace(/^(\w+)\s*:\s*(\w+)$/, "$1 as $2"));
return `import { ${list.join(", ")} } from "${fixSpec(spec)}";`;
});
// module.exports = { ... } is parsed (acorn), not split on commas: values can be whole arrow
// functions or methods with commas inside.
{
const ast = acorn.parse(text, { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true });
const st = ast.body.find(
(n) =>
n.type === "ExpressionStatement" &&
n.expression.type === "AssignmentExpression" &&
n.expression.left.type === "MemberExpression" &&
n.expression.left.object.name === "module" &&
n.expression.left.property.name === "exports" &&
n.expression.right.type === "ObjectExpression",
);
if (st) {
const consts = [];
const list = [];
for (const p of st.expression.right.properties) {
if (p.type !== "Property" || p.computed) {
problems.push(`module.exports: unsupported property ${text.slice(p.start, p.end).slice(0, 60)}`);
continue;
}
const key = p.key.type === "Identifier" ? p.key.name : String(p.key.value);
if (p.shorthand) list.push([key, key]);
else if (!p.method && p.value.type === "Identifier") list.push([p.value.name, key]);
else if (p.method || p.kind !== "init") {
// method shorthand / getter: keep the exact source inside a one-property object
consts.push(`const __export_${key} = ({ ${text.slice(p.start, p.end)} }).${key};`);
list.push([`__export_${key}`, key]);
} else {
// key: expression (e.g. texture: assets.texture, save: async (...) => {...})
consts.push(`const __export_${key} = ${text.slice(p.value.start, p.value.end)};`);
list.push([`__export_${key}`, key]);
}
}
const named = list.map(([local, key]) => (local === key ? key : `${local} as ${key}`));
// default export too: other modules do `const x = require("./y")` -> `import x from "./y.js"`
const defaults = list.map(([local, key]) => (local === key ? key : `${key}: ${local}`));
const out = `${consts.join("\n")}${consts.length ? "\n" : ""}export { ${named.join(", ")} };\nexport default { ${defaults.join(", ")} };\n`;
text = text.slice(0, st.start) + out + text.slice(st.end);
}
}
text = text.replace(/^module\.exports = (\w+);?\s*$/m, "export default $1;\n");
for (const [i, line] of text.split("\n").entries()) {
if (/\brequire\(/.test(line)) problems.push(`${i + 1}: require left: ${line.trim()}`);
if (/\bmodule\.exports\b|\bexports\.\w+\s*=/.test(line)) problems.push(`${i + 1}: exports left: ${line.trim()}`);
if (/\b__dirname\b|\b__filename\b|process\.(env|platform|argv)/.test(line)) problems.push(`${i + 1}: node global: ${line.trim()}`);
}
fs.mkdirSync(dst.replace(/[\\/][^\\/]+$/, ""), { recursive: true });
fs.writeFileSync(dst, `// Ported from LS Tools desktop: ${src.replace(/\\/g, "/").split("/src/").pop()} (mechanical CJS -> ESM)\n` + text);
console.log(`${dst}: ${problems.length ? problems.length + " to check" : "ok"}`);
for (const p of problems) console.log(" " + p);