// Baut das MBMNET-Design für das HOME-Menü (Anemone3DS): Standard-Aussehen, eigene Töne. // node theme/build-theme.js // Ergebnis: /MBMNET/{body_LZ.bin, info.smdh, preview.png} (info.smdh baut bannertool, siehe release.sh) // // Aufbau der Design-Datei (body) und des Ton-Abschnitts wie im Usagi 3DS Theme Editor (MIT, // github.com/usagirei/3DS-Theme-Editor): Kopf 0xD0 Bytes, Ton-Abschnitt = 2, 100, dann 8 Töne // (Cursor, App starten, Ordner, Schließen, Bildschirm 0-2, Deckel auf) mit Größe und Lautstärke. // Die Töne sind CWAV (PCM16, 22 kHz, mono), Aufbau wie 3dbrew/BCWAV bzw. libcwav. const fs = require('node:fs'); const path = require('node:path'); const zlib = require('node:zlib'); const RATE = 22050; // --- Töne erzeugen --------------------------------------------------------------------------- // Ton aus Teiltönen: [{ f: Frequenz, t0: Start (s), d: Dauer (s), a: Lautstärke }], weiche Hüllkurve function synth(parts, total, { noise = 0 } = {}) { const n = Math.round(total * RATE); const out = new Float64Array(n); let seed = 12345; for (const p of parts) { const s0 = Math.round(p.t0 * RATE), len = Math.round(p.d * RATE); for (let i = 0; i < len && s0 + i < n; i++) { const t = i / RATE; const attack = Math.min(1, i / (0.004 * RATE)); // 4 ms Einschwingen (kein Knacken) const decay = Math.exp(-t / (p.d / 4)); // ausklingen const f = p.f1 ? p.f + (p.f1 - p.f) * (i / len) : p.f; // optional Gleiten out[s0 + i] += p.a * attack * decay * Math.sin(2 * Math.PI * f * t) + (p.a * 0.25) * attack * decay * Math.sin(4 * Math.PI * f * t); // etwas Oberton: weicher Klang } } if (noise) { // gefiltertes Rauschen für "Wisch"-Töne let lp = 0; for (let i = 0; i < n; i++) { seed = (seed * 1103515245 + 12345) & 0x7fffffff; const white = seed / 0x3fffffff - 1; lp += 0.08 * (white - lp); const env = Math.sin(Math.PI * i / n) ** 2; out[i] += noise * lp * env; } } // Ende weich ausblenden (letzte 10 ms) const fade = Math.round(0.01 * RATE); for (let i = 0; i < fade; i++) out[n - 1 - i] *= i / fade; const pcm = Buffer.alloc(n * 2); for (let i = 0; i < n; i++) pcm.writeInt16LE(Math.max(-32767, Math.min(32767, Math.round(out[i] * 32767))), i * 2); return pcm; } const SOUNDS = [ // 0 Cursor: weicher, kurzer Klick (statt Piepen) synth([{ f: 1650, t0: 0, d: 0.035, a: 0.22 }], 0.045), // 1 App starten: aufsteigender Zweiklang (MBMNET-Rot-Feeling: hell, freundlich) synth([{ f: 659.3, t0: 0, d: 0.16, a: 0.30 }, { f: 987.8, t0: 0.08, d: 0.22, a: 0.30 }], 0.32), // 2 Ordner: kurzer "Pop" synth([{ f: 520, f1: 760, t0: 0, d: 0.07, a: 0.30 }], 0.08), // 3 Schließen / zurück: absteigender Zweiklang synth([{ f: 880, t0: 0, d: 0.10, a: 0.26 }, { f: 587.3, t0: 0.06, d: 0.14, a: 0.26 }], 0.22), // 4-6 Bildschirm blättern: sanfter Wisch synth([], 0.12, { noise: 0.35 }), synth([], 0.12, { noise: 0.35 }), synth([{ f: 440, f1: 660, t0: 0, d: 0.10, a: 0.10 }], 0.12, { noise: 0.30 }), // 7 Deckel auf: kleiner Gong (drei Töne) synth([{ f: 523.3, t0: 0, d: 0.30, a: 0.22 }, { f: 659.3, t0: 0.09, d: 0.30, a: 0.22 }, { f: 1046.5, t0: 0.18, d: 0.40, a: 0.22 }], 0.6), ]; // --- CWAV (PCM16, mono) ---------------------------------------------------------------------- const align = (n, a) => (n + a - 1) & ~(a - 1); function cwav(pcm) { const samples = pcm.length / 2; const infoSize = 0x40; // 8 Kopf + 0x14 Werte + 4 Anzahl + 8 Verweis + 0x14 Kanal = 0x3C -> 0x40 const dataOff = 0x40 + infoSize; const dataSize = align(0x20 + pcm.length, 0x20); const file = Buffer.alloc(dataOff + dataSize); // Kopf file.write('CWAV', 0, 'latin1'); file.writeUInt16LE(0xFEFF, 4); file.writeUInt16LE(0x40, 6); file.writeUInt32LE(0x02010000, 8); file.writeUInt32LE(file.length, 0x0C); file.writeUInt16LE(2, 0x10); file.writeUInt16LE(0x7000, 0x14); file.writeUInt32LE(0x40, 0x18); file.writeUInt32LE(infoSize, 0x1C); file.writeUInt16LE(0x7001, 0x20); file.writeUInt32LE(dataOff, 0x24); file.writeUInt32LE(dataSize, 0x28); // INFO const I = 0x40; file.write('INFO', I, 'latin1'); file.writeUInt32LE(infoSize, I + 4); file.writeUInt8(1, I + 8); // PCM16 file.writeUInt8(0, I + 9); // keine Schleife file.writeUInt32LE(RATE, I + 0x0C); file.writeUInt32LE(0, I + 0x10); // Schleifenanfang file.writeUInt32LE(samples, I + 0x14); // Ende = Anzahl Samples file.writeUInt32LE(0, I + 0x18); const table = I + 0x1C; // Verweistabelle der Kanäle file.writeUInt32LE(1, table); file.writeUInt16LE(0x7100, table + 4); file.writeUInt32LE(0x0C, table + 8); // Kanal-Info relativ zur Tabelle const ch = table + 0x0C; file.writeUInt16LE(0x1F00, ch); file.writeUInt32LE(0x18, ch + 4); // Samples relativ zu DATA+8 file.writeUInt16LE(0, ch + 8); file.writeUInt32LE(0xFFFFFFFF, ch + 0x0C); // keine ADPCM-Infos file.writeUInt32LE(0, ch + 0x10); // DATA file.write('DATA', dataOff, 'latin1'); file.writeUInt32LE(dataSize, dataOff + 4); pcm.copy(file, dataOff + 0x20); return file; } // --- Ton-Abschnitt (wie Usagi CwavBlock.Generate) ------------------------------------------ function sfxBlock(files) { const parts = []; const u32 = (v) => { const b = Buffer.alloc(4); b.writeUInt32LE(v >>> 0); parts.push(b); }; u32(0x02); u32(0x64); for (let i = 0; i < 8; i++) { if (i === 4) for (const v of [0x00, 0x64, 0x00, 0x64, 0x64, 0x00, 0x64, 0x64, 0x00, 0x00, 0x00]) u32(v); const f = files[i]; u32(f.length); u32(f.length ? 0x50 : 1); // Lautstärke 80 parts.push(f); } return Buffer.concat(parts); } // --- Design-Datei (body) --------------------------------------------------------------------- function body(sfx) { const header = Buffer.alloc(0xD0); header.writeUInt32LE(1, 0); // Version // 0x5 Hintergrundmusik aus, alle Zeichen-Arten 0 (Standard-Aussehen), alle Farb-Flags 0 header.writeUInt32LE(1, 0xB8); // Töne an header.writeUInt32LE(sfx.length, 0xBC); header.writeUInt32LE(0xD0, 0xC0); if (sfx.length > 0x2DC00) throw new Error(`Töne zu groß: ${sfx.length} Bytes`); const raw = Buffer.concat([header, sfx]); return Buffer.concat([raw, Buffer.alloc(align(raw.length, 0x10) - raw.length)]); } // --- LZ11 (nur Literale: immer gültig, die Datei ist ohnehin klein) --------------------------- function lz11(data) { const out = [Buffer.from([0x11, data.length & 0xFF, (data.length >> 8) & 0xFF, (data.length >> 16) & 0xFF])]; for (let i = 0; i < data.length; i += 8) { out.push(Buffer.from([0x00])); out.push(data.subarray(i, i + 8)); } return Buffer.concat(out); } function unlz11(buf) { // zur Prüfung const size = buf[1] | (buf[2] << 8) | (buf[3] << 16); const out = Buffer.alloc(size); let i = 4, o = 0; while (o < size) { const flags = buf[i++]; for (let b = 7; b >= 0 && o < size; b--) { if (!(flags & (1 << b))) { out[o++] = buf[i++]; continue; } throw new Error('komprimierte Blöcke nicht erwartet'); } } return out; } // --- Vorschau (412x480: oben 400x240 + unten 320x240), schlicht in MBMNET-Farben --------------- function preview() { const W = 412, H = 480; const rows = []; for (let y = 0; y < H; y++) { const row = Buffer.alloc(1 + W * 3); for (let x = 0; x < W; x++) { const top = y < 240; const inScreen = top ? x < 400 : x >= 46 && x < 366; let c = inScreen ? (top ? [0x22, 0x26, 0x2E] : [0x16, 0x19, 0x1F]) : [0, 0, 0]; // Logo-Quadrat in der Mitte oben if (top && Math.abs(x - 200) < 40 && Math.abs(y - 110) < 40) c = [0xE4, 0x00, 0x2B]; row.set(c, 1 + x * 3); } rows.push(row); } const chunk = (t, d) => { const len = Buffer.alloc(4); len.writeUInt32BE(d.length); const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32 ? zlib.crc32(Buffer.concat([Buffer.from(t), d])) >>> 0 : crc32(Buffer.concat([Buffer.from(t), d]))); return Buffer.concat([len, Buffer.from(t), d, crc]); }; const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(W, 0); ihdr.writeUInt32BE(H, 4); ihdr[8] = 8; ihdr[9] = 2; return Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(Buffer.concat(rows))), chunk('IEND', Buffer.alloc(0))]); } function crc32(buf) { let c, crc = 0xFFFFFFFF; for (const b of buf) { c = (crc ^ b) & 0xFF; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; crc = (crc >>> 8) ^ c; } return (crc ^ 0xFFFFFFFF) >>> 0; } const outDir = path.join(process.argv[2] || '.', 'MBMNET'); fs.mkdirSync(outDir, { recursive: true }); const files = SOUNDS.map(cwav); const sfx = sfxBlock(files); const b = body(sfx); const packed = lz11(b); if (!unlz11(packed).equals(b)) throw new Error('LZ11-Prüfung fehlgeschlagen'); fs.writeFileSync(path.join(outDir, 'body_LZ.bin'), packed); fs.writeFileSync(path.join(outDir, 'preview.png'), preview()); for (let i = 0; i < files.length; i++) fs.writeFileSync(path.join(outDir, `..`, `ton-${i}.bcwav`), files[i]); console.log(`MBMNET-Design: body ${b.length} Bytes (Töne ${sfx.length}), gepackt ${packed.length} Bytes -> ${outDir}`);