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:
co-authored by
Claude Opus 5.5
parent
f4ff27b375
commit
17c4c7a074
+17
-3
@@ -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)
|
(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
|
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) ---
|
# --- 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"
|
SDZIP="$(cd "$RELEASE" && pwd)/mbmnet-sd.zip"
|
||||||
SDTMP=$(mktemp -d)
|
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.cia" "$RELEASE/mbmnet-shop.cia" "$SDTMP/cias/"
|
||||||
cp "$RELEASE/mbmnet-installieren.gm9" "$SDTMP/gm9/scripts/"
|
cp "$RELEASE/mbmnet-installieren.gm9" "$SDTMP/gm9/scripts/"
|
||||||
cp "$RELEASE/mbmnet.3dsx" "$SDTMP/3ds/"
|
cp "$RELEASE/mbmnet.3dsx" "$SDTMP/3ds/"
|
||||||
|
cp "$DESIGNZIP" "$SDTMP/Themes/MBMNET-Design.zip"
|
||||||
|
rm -rf "$THEME_TMP"
|
||||||
cat > "$SDTMP/LIESMICH.txt" <<'TXT'
|
cat > "$SDTMP/LIESMICH.txt" <<'TXT'
|
||||||
MBMNET - Installation
|
MBMNET - Installation
|
||||||
|
|
||||||
@@ -52,9 +63,12 @@ MBMNET - Installation
|
|||||||
|
|
||||||
Ohne GodMode9: mbmnet.cia mit FBI installieren, oder 3ds/mbmnet.3dsx
|
Ohne GodMode9: mbmnet.cia mit FBI installieren, oder 3ds/mbmnet.3dsx
|
||||||
über den Homebrew Launcher starten.
|
ü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
|
TXT
|
||||||
rm -f "$SDZIP"
|
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"
|
rm -rf "$SDTMP"
|
||||||
echo "SD-Paket: $(du -h "$SDZIP" | cut -f1)"
|
echo "SD-Paket: $(du -h "$SDZIP" | cut -f1)"
|
||||||
|
|
||||||
@@ -82,7 +96,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Vorhandene Dateien gleichen Namens löschen (bei erneutem Lauf), dann hochladen
|
# 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
|
[ -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
|
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
|
api DELETE "/releases/$ID/assets/$old" >/dev/null
|
||||||
|
|||||||
@@ -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}`);
|
||||||
Reference in New Issue
Block a user