// 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_::
_)", " 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; }