'use strict'; /* * spieldesign.js – AeroMC-Look im Spiel (Minecraft 1.14 … 26.x) * -------------------------------------------------------------- * Zwei Teile: * * 1. Ressourcenpaket "AeroMC-Design" im Ordner resourcepacks der Instanz: * Titel-Logo mit Untertitel, Menü-Hintergrund (Panorama), eigene Sprüche, * Knöpfe im AeroMC-Stil und Symbole vor den Menü-Beschriftungen (als eigene * Bitmap-Schrift über die Sprachdateien). Bis 1.20.1 stecken die Knöpfe im * gemeinsamen widgets.png der Version, ab 1.20.2 sind es eigene Sprites. * Das Paket wird in options.txt ganz unten eingereiht – eigene * Ressourcenpakete des Spielers haben also immer Vorrang. * 2. Grafiken für den Agenten (Ladebildschirm-Logo, Fenstersymbol): Java kann * nicht aus app.asar lesen, darum werden sie nach userData kopiert. * * Für den Mod-Schalter (siehe modschalter.js) bringt das Paket außerdem die * Beschriftung „Mods verwalten“ mit – auch wenn der AeroMC-Look aus ist. * Ist beides aus, wird das Paket wieder entfernt. */ const fs = require('fs'); const path = require('path'); const zlib = require('zlib'); const unzipper = require('unzipper'); const PAKET = 'AeroMC-Design'; const PAKET_ID = 'file/' + PAKET; const STAND = 8; // erhöhen, wenn sich die Grafiken ändern -> Paket wird neu gebaut const ASSETS = path.join(__dirname, 'assets', 'spieldesign'); // ---------- Versionen ---------- // "1.21.9-rc1" -> [1, 21, 9]; Snapshots wie "25w31a" -> null function zahlen(version) { const m = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(version || '')); return m ? [Number(m[1]), Number(m[2]), Number(m[3] || 0)] : null; } function ab(version, grenze) { const a = zahlen(version); if (!a) return true; // Snapshots u. Ä. gelten als neu const b = zahlen(grenze); for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i]; return true; } // Ressourcenpaket-Format je Version (minecraft.wiki, "Pack format") const FORMATE = [ ['1.14', 4], ['1.15', 5], ['1.16.2', 6], ['1.17', 7], ['1.18', 8], ['1.19', 9], ['1.19.3', 12], ['1.19.4', 13], ['1.20', 15], ['1.20.2', 18], ['1.20.3', 22], ['1.20.5', 32], ['1.21', 34], ['1.21.2', 42], ['1.21.4', 46], ['1.21.5', 55], ['1.21.6', 63], ['1.21.7', 64], ['1.21.9', 69], ['1.21.11', 75], ['26.1', 84], ['26.2', 88], ['26.3', 97], ]; function paketFormat(version) { let f = FORMATE[FORMATE.length - 1][1]; if (zahlen(version)) for (const [v, n] of FORMATE) if (ab(version, v)) f = n; return f; } function mcmeta(version) { const f = paketFormat(version); const neuste = f === FORMATE[FORMATE.length - 1][1]; const pack = { description: 'AeroMC – Menü, Logo und Knöpfe' }; if (f >= 65) { // ab 1.21.9: min/max statt pack_format; die neuste bekannte Version offen nach oben pack.min_format = f; pack.max_format = neuste ? 999 : f; } else { pack.pack_format = f; } return JSON.stringify({ pack }, null, 2); } /** Spieldesign gibt es ab 1.14 (vorher andere Menüs und LWJGL 2). */ function unterstuetzt(version) { return ab(version, '1.14'); } // ---------- PNG (verlustfrei, ohne Zusatzbibliothek) ---------- function pngLesen(buf) { if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('kein PNG'); let pos = 8, w = 0, h = 0, tiefe = 0, typ = 0, palette = null, transparenz = null; const idat = []; while (pos < buf.length) { const laenge = buf.readUInt32BE(pos); const art = buf.toString('ascii', pos + 4, pos + 8); const daten = buf.subarray(pos + 8, pos + 8 + laenge); if (art === 'IHDR') { w = daten.readUInt32BE(0); h = daten.readUInt32BE(4); tiefe = daten[8]; typ = daten[9]; if (daten[12] !== 0) throw new Error('Interlacing nicht unterstützt'); } else if (art === 'PLTE') palette = daten; else if (art === 'tRNS') transparenz = daten; else if (art === 'IDAT') idat.push(daten); else if (art === 'IEND') break; pos += 12 + laenge; } const kanaele = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[typ]; if (!kanaele || (tiefe !== 8 && !(typ === 3 && tiefe < 8) && !(typ === 0 && tiefe < 8))) throw new Error(`PNG-Format ${typ}/${tiefe} nicht unterstützt`); const roh = zlib.inflateSync(Buffer.concat(idat)); const bpp = Math.max(1, (kanaele * tiefe) >> 3); // Bytes je Pixel für die Filter const zeile = Math.ceil((w * kanaele * tiefe) / 8); const pixel = Buffer.alloc(zeile * h); for (let y = 0; y < h; y++) { const f = roh[y * (zeile + 1)]; const ein = roh.subarray(y * (zeile + 1) + 1, (y + 1) * (zeile + 1)); const aus = pixel.subarray(y * zeile, (y + 1) * zeile); const oben = y ? pixel.subarray((y - 1) * zeile, y * zeile) : null; for (let i = 0; i < zeile; i++) { const a = i >= bpp ? aus[i - bpp] : 0, b = oben ? oben[i] : 0, c = oben && i >= bpp ? oben[i - bpp] : 0; let v = ein[i]; if (f === 1) v += a; else if (f === 2) v += b; else if (f === 3) v += (a + b) >> 1; else if (f === 4) { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; } aus[i] = v & 255; } } const rgba = Buffer.alloc(w * h * 4); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const o = (y * w + x) * 4; let wert; if (tiefe < 8) { const bit = x * tiefe; wert = (pixel[y * zeile + (bit >> 3)] >> (8 - tiefe - (bit & 7))) & ((1 << tiefe) - 1); } else wert = null; const p = y * zeile + x * kanaele; if (typ === 6) { pixel.copy(rgba, o, p, p + 4); continue; } if (typ === 2) { rgba[o] = pixel[p]; rgba[o + 1] = pixel[p + 1]; rgba[o + 2] = pixel[p + 2]; rgba[o + 3] = 255; continue; } if (typ === 4) { rgba[o] = rgba[o + 1] = rgba[o + 2] = pixel[p]; rgba[o + 3] = pixel[p + 1]; continue; } if (typ === 0) { const g = tiefe < 8 ? Math.round((wert * 255) / ((1 << tiefe) - 1)) : pixel[p]; rgba[o] = rgba[o + 1] = rgba[o + 2] = g; rgba[o + 3] = 255; continue; } const index = tiefe < 8 ? wert : pixel[p]; // Palette rgba[o] = palette[index * 3]; rgba[o + 1] = palette[index * 3 + 1]; rgba[o + 2] = palette[index * 3 + 2]; rgba[o + 3] = transparenz && index < transparenz.length ? transparenz[index] : 255; } } return { w, h, rgba }; } const CRC = (() => { const t = new Int32Array(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; } return t; })(); function crc32(buf) { let c = -1; for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 255] ^ (c >>> 8); return (c ^ -1) >>> 0; } function block(art, daten) { const kopf = Buffer.alloc(8); kopf.writeUInt32BE(daten.length, 0); kopf.write(art, 4, 'ascii'); const pruef = Buffer.alloc(4); pruef.writeUInt32BE(crc32(Buffer.concat([kopf.subarray(4), daten])), 0); return Buffer.concat([kopf, daten, pruef]); } function pngSchreiben(w, h, rgba) { const roh = Buffer.alloc((w * 4 + 1) * h); for (let y = 0; y < h; y++) rgba.copy(roh, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4); const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 6; return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), block('IHDR', ihdr), block('IDAT', zlib.deflateSync(roh, { level: 9 })), block('IEND', Buffer.alloc(0)), ]); } // ---------- Paket bauen ---------- async function ausJar(jar, eintraege) { const verz = await unzipper.Open.file(jar); const aus = {}; for (const e of eintraege) { const f = verz.files.find((d) => d.path === e); if (f) aus[e] = await f.buffer(); } return aus; } function schreibe(datei, inhalt) { fs.mkdirSync(path.dirname(datei), { recursive: true }); fs.writeFileSync(datei, inhalt); } async function bauePaket(gameDir, version, clientJar, protokoll, teile) { const { design, mods } = teile; const ziel = path.join(gameDir, 'resourcepacks', PAKET); const marke = path.join(ziel, 'aeromc.json'); const kennung = JSON.stringify({ stand: STAND, version, design: !!design, mods: !!mods }); try { if (fs.readFileSync(marke, 'utf8') === kennung) return; } catch { /* noch nicht gebaut */ } fs.rmSync(ziel, { recursive: true, force: true }); const alt = !ab(version, '1.20'); // Titel-Logo zweigeteilt bis 1.19.4 const gui = path.join(ziel, 'assets', 'minecraft', 'textures', 'gui'); const asset = (n) => fs.readFileSync(path.join(ASSETS, n)); schreibe(path.join(ziel, 'pack.png'), asset('pack.png')); schreibe(path.join(ziel, 'pack.mcmeta'), mcmeta(version)); // Symbole (Schriftzeichen) und Beschriftungen – für Menü-Knöpfe und/oder „Mods verwalten“ schreibe(path.join(ziel, 'assets', 'aeromc', 'textures', 'font', 'symbole.png'), asset('symbole.png')); // Die Schriftdatei ersetzt die von Minecraft (zumindest in neueren Versionen) – // darum die Original-Einträge dieser Version übernehmen und die Symbole davorsetzen try { const eintrag = 'assets/minecraft/font/default.json'; const orig = JSON.parse(String((await ausJar(clientJar, [eintrag]))[eintrag] || '{"providers":[]}')); const eigene = JSON.parse(String(asset('font_default.json'))).providers; schreibe(path.join(ziel, 'assets', 'minecraft', 'font', 'default.json'), JSON.stringify({ providers: [...eigene, ...(orig.providers || [])] }, null, 2)); } catch (err) { if (protokoll) protokoll('[AeroMC] Symbole nicht eingebunden: ' + err.message); } for (const sprache of ['de_de', 'en_us']) { const texte = Object.assign({}, design ? JSON.parse(String(asset(`lang_${sprache}.json`))) : {}, mods ? JSON.parse(String(asset(`lang_mods_${sprache}.json`))) : {}); schreibe(path.join(ziel, 'assets', 'minecraft', 'lang', sprache + '.json'), JSON.stringify(texte, null, 2)); } // 26.x: „Feedback geben“ ist ein Symbol-Knopf -> Puzzle-Symbol für „Mods verwalten“ if (mods) schreibe(path.join(gui, 'sprites', 'pause_menu', 'social_interactions.png'), asset('mods_knopf.png')); if (!design) { schreibe(marke, kennung); return; } const titel = fs.readFileSync(path.join(ASSETS, alt ? 'titel_alt.png' : 'titel_neu.png')); schreibe(path.join(gui, 'title', 'minecraft.png'), titel); schreibe(path.join(gui, 'title', 'minceraft.png'), titel); schreibe(path.join(gui, 'title', 'edition.png'), fs.readFileSync(path.join(ASSETS, alt ? 'edition_alt.png' : 'edition_neu.png'))); // Hintergrund des Hauptmenüs (Minecraft dreht ihn als Würfel um die Kamera) for (let i = 0; i < 6; i++) { schreibe(path.join(gui, 'title', 'background', `panorama_${i}.png`), fs.readFileSync(path.join(ASSETS, `panorama_${i}.png`))); } schreibe(path.join(ziel, 'assets', 'minecraft', 'texts', 'splashes.txt'), asset('splashes.txt')); // Knöpfe wie im Entwurf (dunkles Schiefer, hervorgehoben leuchtend blau) try { if (ab(version, '1.20.2')) { // eigene Sprites (doppelte Auflösung) samt Neun-Teile-Skalierung const w = path.join(gui, 'sprites', 'widget'); schreibe(path.join(w, 'button.png'), asset('knopf.png')); schreibe(path.join(w, 'button_highlighted.png'), asset('knopf_hell.png')); schreibe(path.join(w, 'button.png.mcmeta'), asset('knopf.png.mcmeta')); schreibe(path.join(w, 'button_highlighted.png.mcmeta'), asset('knopf.png.mcmeta')); } else { // widgets.png enthält auch Hotbar & Co. – Original 4-fach vergrößern (verlustfrei) // und nur die Knopf-Zeilen durch die neuen ersetzen (66–86 normal, 86–106 hervorgehoben) const e = 'assets/minecraft/textures/gui/widgets.png'; const daten = (await ausJar(clientJar, [e]))[e]; if (daten) { const orig = pngLesen(daten); const f = Math.max(1, Math.round(1024 / orig.w)); const W = orig.w * f, H = orig.h * f; const gross = Buffer.alloc(W * H * 4); for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) orig.rgba.copy(gross, (y * W + x) * 4, (((y / f) | 0) * orig.w + ((x / f) | 0)) * 4, (((y / f) | 0) * orig.w + ((x / f) | 0)) * 4 + 4); } const s = W / 256; for (const [datei, zeile] of [['knopf_4x.png', 66], ['knopf_hell_4x.png', 86]]) { const k = pngLesen(asset(datei)); const kb = Math.round(200 * s), kh = Math.round(20 * s), y0 = Math.round(zeile * s); for (let y = 0; y < kh; y++) { for (let x = 0; x < kb; x++) { const q = (((y * k.h / kh) | 0) * k.w + ((x * k.w / kb) | 0)) * 4; k.rgba.copy(gross, ((y0 + y) * W + x) * 4, q, q + 4); } } } schreibe(path.join(gui, 'widgets.png'), pngSchreiben(W, H, gross)); } } } catch (err) { if (protokoll) protokoll('[AeroMC] Knöpfe nicht ersetzt: ' + err.message); } schreibe(marke, kennung); } // ---------- options.txt ---------- function liesListe(zeilen, schluessel) { const z = zeilen.find((l) => l.startsWith(schluessel + ':')); if (!z) return null; try { const v = JSON.parse(z.slice(schluessel.length + 1)); return Array.isArray(v) ? v : []; } catch { return []; } } function setzeListe(zeilen, schluessel, liste) { const i = zeilen.findIndex((l) => l.startsWith(schluessel + ':')); const neu = schluessel + ':' + JSON.stringify(liste); if (i >= 0) zeilen[i] = neu; else zeilen.push(neu); } /** Paket in options.txt ein- bzw. austragen. Rückgabe: ob sich etwas geändert hat. */ function trageEin(gameDir, an) { const datei = path.join(gameDir, 'options.txt'); let text = ''; try { text = fs.readFileSync(datei, 'utf8'); } catch { if (!an) return false; } const zeilen = text ? text.split(/\r?\n/) : []; while (zeilen.length && zeilen[zeilen.length - 1] === '') zeilen.pop(); const aktiv = liesListe(zeilen, 'resourcePacks') || ['vanilla']; const unvertraeglich = liesListe(zeilen, 'incompatibleResourcePacks') || []; const vorher = JSON.stringify([aktiv, unvertraeglich]); const ohne = (l) => l.filter((p) => p !== PAKET_ID && p !== PAKET); let a = ohne(aktiv), u = ohne(unvertraeglich); if (an) { // direkt über den eingebauten Paketen, unter allen eigenen Ressourcenpaketen let pos = a.findIndex((p) => String(p).startsWith('file/')); if (pos < 0) pos = a.length; if (!a.includes('vanilla')) { a.unshift('vanilla'); pos++; } a.splice(Math.max(pos, a.indexOf('vanilla') + 1), 0, PAKET_ID); // NICHT in incompatibleResourcePacks eintragen: Steht ein passendes Paket dort, // streicht Minecraft es aus der Liste und lässt es dabei für diesen Start weg } if (JSON.stringify([a, u]) === vorher) return false; if (liesListe(zeilen, 'resourcePacks') || an) setzeListe(zeilen, 'resourcePacks', a); if (liesListe(zeilen, 'incompatibleResourcePacks')) setzeListe(zeilen, 'incompatibleResourcePacks', u); fs.writeFileSync(datei, zeilen.join('\n') + '\n'); return true; } // ---------- Einstieg ---------- /** * Vor jedem Spielstart: Paket anlegen und eintragen bzw. (ausgeschaltet) * wieder entfernen. Fehler brechen den Start nie ab. */ async function vorbereiten({ gameDir, version, clientJar, an, mods, protokoll }) { try { if ((an || mods) && unterstuetzt(version)) { await bauePaket(gameDir, version, clientJar, protokoll, { design: !!an, mods: !!mods }); trageEin(gameDir, true); } else { if (trageEin(gameDir, false) || fs.existsSync(path.join(gameDir, 'resourcepacks', PAKET))) { fs.rmSync(path.join(gameDir, 'resourcepacks', PAKET), { recursive: true, force: true }); } } } catch (err) { if (protokoll) protokoll('[AeroMC] Spieldesign nicht vorbereitet: ' + err.message); } } /** Grafiken für den Agenten nach userData kopieren; Rückgabe: Ordner oder '' */ function agentOrdner(userData) { try { const ordner = path.join(userData, 'agent', 'spieldesign'); fs.mkdirSync(ordner, { recursive: true }); for (const n of fs.readdirSync(ASSETS)) { if (!/^(ladelogo(_alt)?\.png|icon_\d+\.rgba)$/.test(n)) continue; const q = path.join(ASSETS, n), z = path.join(ordner, n); const daten = fs.readFileSync(q); let gleich = false; try { gleich = fs.readFileSync(z).equals(daten); } catch { /* fehlt noch */ } if (!gleich) fs.writeFileSync(z, daten); } return ordner; } catch { return ''; } } module.exports = { vorbereiten, agentOrdner, unterstuetzt, paketFormat, mcmeta, pngLesen, pngSchreiben, trageEin, PAKET_ID };