MBMNET-Design fürs HOME-Menü: eigene Töne (CWAV), Aufbau wie Usagi, in release.sh und im SD-Paket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nico Meister
2026-10-26 20:57:36 +01:00
co-authored by Claude Opus 5.5
parent f4ff27b375
commit 17c4c7a074
2 changed files with 220 additions and 3 deletions
+203
View File
@@ -0,0 +1,203 @@
// Baut das MBMNET-Design für das HOME-Menü (Anemone3DS): Standard-Aussehen, eigene Töne.
// node theme/build-theme.js <Ausgabeordner>
// Ergebnis: <Ausgabe>/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}`);