diff --git a/release.sh b/release.sh index 7693396..d3230ef 100755 --- a/release.sh +++ b/release.sh @@ -33,13 +33,24 @@ if [ "$NUR_RELEASE" = 0 ]; then (cd "$RELEASE" && git add mbmnet.3dsx mbmnet.cia mbmnet-shop.3dsx mbmnet-shop.cia mbmnet-installieren.gm9 version.txt && git commit -m "MBMNET-App v$VERSION" && git push) fi +# --- MBMNET-Design fürs HOME-Menü (eigene Töne, Standard-Aussehen; für Anemone3DS) -------------- +THEME_TMP=$(mktemp -d) +node theme/build-theme.js "$THEME_TMP" >/dev/null +docker run --rm -v "$PWD":/app -v "$THEME_TMP":/out -w /app mbmnet-3ds-build \ + bannertool makesmdh -s "MBMNET" -l "MBMNET-Design: eigene Töne im HOME-Menü" -p "MBMNET" -i icon.png -o /out/MBMNET/info.smdh >/dev/null +DESIGNZIP="$(cd "$RELEASE" && pwd)/mbmnet-design.zip" +rm -f "$DESIGNZIP" +(cd "$THEME_TMP/MBMNET" && zip -q "$DESIGNZIP" body_LZ.bin info.smdh preview.png) + # --- SD-Karten-Paket (wie bei MSET9/Hacks-Guide: am PC auf die SD entpacken, dann GM9-Skript starten) --- SDZIP="$(cd "$RELEASE" && pwd)/mbmnet-sd.zip" SDTMP=$(mktemp -d) -mkdir -p "$SDTMP/cias" "$SDTMP/gm9/scripts" "$SDTMP/3ds" +mkdir -p "$SDTMP/cias" "$SDTMP/gm9/scripts" "$SDTMP/3ds" "$SDTMP/Themes" cp "$RELEASE/mbmnet.cia" "$RELEASE/mbmnet-shop.cia" "$SDTMP/cias/" cp "$RELEASE/mbmnet-installieren.gm9" "$SDTMP/gm9/scripts/" cp "$RELEASE/mbmnet.3dsx" "$SDTMP/3ds/" +cp "$DESIGNZIP" "$SDTMP/Themes/MBMNET-Design.zip" +rm -rf "$THEME_TMP" cat > "$SDTMP/LIESMICH.txt" <<'TXT' MBMNET - Installation @@ -52,9 +63,12 @@ MBMNET - Installation Ohne GodMode9: mbmnet.cia mit FBI installieren, oder 3ds/mbmnet.3dsx über den Homebrew Launcher starten. + +Freiwillig: Themes/MBMNET-Design.zip ist ein HOME-Menü-Design mit eigenen Tönen +(Aussehen bleibt Standard). Mit Anemone3DS installieren. TXT rm -f "$SDZIP" -(cd "$SDTMP" && zip -qr "$SDZIP" LIESMICH.txt cias gm9 3ds) +(cd "$SDTMP" && zip -qr "$SDZIP" LIESMICH.txt cias gm9 3ds Themes) rm -rf "$SDTMP" echo "SD-Paket: $(du -h "$SDZIP" | cut -f1)" @@ -82,7 +96,7 @@ else fi # Vorhandene Dateien gleichen Namens löschen (bei erneutem Lauf), dann hochladen -for f in mbmnet-sd.zip mbmnet.cia mbmnet.3dsx mbmnet-shop.cia mbmnet-shop.3dsx mbmnet-installieren.gm9; do +for f in mbmnet-sd.zip mbmnet-design.zip mbmnet.cia mbmnet.3dsx mbmnet-shop.cia mbmnet-shop.3dsx mbmnet-installieren.gm9; do [ -f "$RELEASE/$f" ] || continue for old in $(api GET "/releases/$ID/assets" | json "' '.join(str(a['id']) for a in d if a['name']=='$f')"); do api DELETE "/releases/$ID/assets/$old" >/dev/null diff --git a/theme/build-theme.js b/theme/build-theme.js new file mode 100644 index 0000000..59aa48b --- /dev/null +++ b/theme/build-theme.js @@ -0,0 +1,203 @@ +// 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}`);