Drop stb_image from Switch OTA launcher with a pre-baked logo asset.

This commit is contained in:
Andrew Quenehen
2026-08-06 10:14:11 -03:00
parent 3fae3088ca
commit e94d8e32ef
8 changed files with 220 additions and 8006 deletions
+1
View File
@@ -44,6 +44,7 @@ native/switch-ota-launcher/*.nro
native/switch-ota-launcher/*.nacp native/switch-ota-launcher/*.nacp
native/switch-ota-launcher/*.elf native/switch-ota-launcher/*.elf
native/switch-ota-launcher/*.map native/switch-ota-launcher/*.map
native/switch-ota-launcher/romfs/logo.rgba
native/switch-ota-launcher/romfs/logo.png native/switch-ota-launcher/romfs/logo.png
native/switch-ota-launcher/romfs/cacert.pem native/switch-ota-launcher/romfs/cacert.pem
native/switch-ota-launcher/romfs/ota-bootstrap.nro native/switch-ota-launcher/romfs/ota-bootstrap.nro
+5 -3
View File
@@ -46,7 +46,8 @@ APP_VERSION ?= 0.0.0
# Prefer project Switch icon if present # Prefer project Switch icon if present
ICON := $(TOPDIR)/../../assets/switch/icon.jpg ICON := $(TOPDIR)/../../assets/switch/icon.jpg
LOGO_SRC := $(TOPDIR)/../../assets/logo/logo.png LOGO_RGBA_SRC := $(TOPDIR)/assets/logo.rgba
LOGO_ROMFS := $(CURDIR)/$(ROMFS)/logo.rgba
#--------------------------------------------------------------------------------- #---------------------------------------------------------------------------------
ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE
@@ -148,7 +149,8 @@ bootstrap-romfs:
sync-romfs: bootstrap-romfs sync-romfs: bootstrap-romfs
@mkdir -p $(CURDIR)/$(ROMFS) @mkdir -p $(CURDIR)/$(ROMFS)
@cp -f $(LOGO_SRC) $(CURDIR)/$(ROMFS)/logo.png @[ -f "$(LOGO_RGBA_SRC)" ] || (echo "missing $(LOGO_RGBA_SRC) — run: python3 scripts/switch/bake_ota_logo.py" && exit 1)
@cp -f "$(LOGO_RGBA_SRC)" "$(LOGO_ROMFS)"
@if ! curl -sfL --time-cond $(CACERT_ROMFS) -o $(CACERT_ROMFS) $(CACERT_URL); then \ @if ! curl -sfL --time-cond $(CACERT_ROMFS) -o $(CACERT_ROMFS) $(CACERT_URL); then \
[ -f $(CACERT_ROMFS) ] || (echo "sync-romfs: failed to fetch cacert.pem" && exit 1); \ [ -f $(CACERT_ROMFS) ] || (echo "sync-romfs: failed to fetch cacert.pem" && exit 1); \
fi fi
@@ -161,7 +163,7 @@ $(BUILD): sync-romfs
clean: clean:
@echo clean ... @echo clean ...
@rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf $(TARGET).lst build-host @rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf $(TARGET).lst build-host
@rm -f $(CURDIR)/$(ROMFS)/logo.png @rm -f $(CURDIR)/$(ROMFS)/logo.rgba $(CURDIR)/$(ROMFS)/logo.png
host-test: host-test:
@mkdir -p build-host @mkdir -p build-host
+12
View File
@@ -59,6 +59,18 @@ scripts/switch/build_ota_launcher.sh
Docker fallback uses the same pin as fused builds (`scripts/switch/dkp-docker.image`). Docker fallback uses the same pin as fused builds (`scripts/switch/dkp-docker.image`).
## OTA logo asset
The launcher draws a pre-scaled logo from `romfs:/logo.rgba` (no PNG decoder in
the NRO). The baked blob lives at `assets/logo.rgba` and is copied into romfs at
build time. After changing `assets/logo/logo.png`, regenerate:
```bash
python3 scripts/switch/bake_ota_logo.py
```
Requires Pillow, or on macOS uses `sips` when Pillow is not installed.
## Packaging ## Packaging
`scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP LAUNCHER_NRO` writes both `scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP LAUNCHER_NRO` writes both
Binary file not shown.
+32 -12
View File
@@ -12,12 +12,10 @@
#if defined(__SWITCH__) #if defined(__SWITCH__)
#include <switch.h> #include <switch.h>
#define STB_IMAGE_IMPLEMENTATION
#define STBI_ONLY_PNG
#define STBI_NO_THREAD_LOCALS
#include "../third_party/stb_image.h"
#include "../third_party/font8x8_basic.h" #include "../third_party/font8x8_basic.h"
#define OTA_LOGO_ROMFS "romfs:/logo.rgba"
#define FB_W 1280 #define FB_W 1280
#define FB_H 720 #define FB_H 720
@@ -261,25 +259,47 @@ static void blit_logo(u32 *fb, u32 stride_px, int dst_x, int dst_y, int max_w) {
static void load_logo(void) { static void load_logo(void) {
if (g_logo) return; if (g_logo) return;
/* romfs is mounted in ota_net_init() before the release check runs. */ FILE *fp = fopen(OTA_LOGO_ROMFS, "rb");
int w = 0, h = 0, n = 0; if (!fp) return;
unsigned char *data = stbi_load("romfs:/logo.png", &w, &h, &n, 4);
if (!data || w <= 0 || h <= 0) { unsigned char header[8];
if (data) stbi_image_free(data); if (fread(header, 1, sizeof(header), fp) != sizeof(header)) {
fclose(fp);
return; return;
} }
int w = (int)(header[0] | (header[1] << 8) | (header[2] << 16) | (header[3] << 24));
int h = (int)(header[4] | (header[5] << 8) | (header[6] << 16) | (header[7] << 24));
if (w <= 0 || h <= 0 || w > 2048 || h > 2048) {
fclose(fp);
return;
}
size_t nbytes = (size_t)w * (size_t)h * 4u;
unsigned char *data = (unsigned char *)malloc(nbytes);
if (!data) {
fclose(fp);
return;
}
if (fread(data, 1, nbytes, fp) != nbytes) {
free(data);
fclose(fp);
return;
}
fclose(fp);
g_logo = (u32 *)malloc((size_t)w * (size_t)h * sizeof(u32)); g_logo = (u32 *)malloc((size_t)w * (size_t)h * sizeof(u32));
if (!g_logo) { if (!g_logo) {
stbi_image_free(data); free(data);
return; return;
} }
for (int i = 0; i < w * h; i++) { for (int i = 0; i < w * h; i++) {
unsigned char *p = data + i * 4; unsigned char *p = data + (size_t)i * 4u;
g_logo[i] = RGBA8(p[0], p[1], p[2], p[3]); g_logo[i] = RGBA8(p[0], p[1], p[2], p[3]);
} }
free(data);
g_logo_w = w; g_logo_w = w;
g_logo_h = h; g_logo_h = h;
stbi_image_free(data);
} }
static int ui_ensure(void) { static int ui_ensure(void) {
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Bake assets/logo/logo.png into a pre-scaled RGBA blob for the Switch OTA UI.
Output format (little-endian):
uint32 width, uint32 height, then width*height RGBA8888 pixels.
Regenerate after editing the source PNG:
python3 scripts/switch/bake_ota_logo.py
Uses Pillow when available (see scripts/setup.sh). On macOS without Pillow,
falls back to sips + BMP export.
"""
from __future__ import annotations
import math
import platform
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
DEFAULT_SRC = ROOT / "assets/logo/logo.png"
DEFAULT_OUT = ROOT / "native/switch-ota-launcher/assets/logo.rgba"
DEFAULT_MAX_W = 320
DEFAULT_MAX_H = 90
def fit_size(src_w: int, src_h: int, max_w: int, max_h: int) -> tuple[int, int]:
scale = min(max_w / src_w, max_h / src_h, 1.0)
w = max(1, int(math.floor(src_w * scale + 0.5)))
h = max(1, int(math.floor(src_h * scale + 0.5)))
return w, h
def read_png_size(path: Path) -> tuple[int, int]:
with path.open("rb") as fp:
sig = fp.read(8)
if sig != b"\x89PNG\r\n\x1a\n":
raise SystemExit(f"not a PNG: {path}")
while True:
raw = fp.read(8)
if len(raw) < 8:
raise SystemExit(f"truncated PNG: {path}")
length, ctype = struct.unpack(">I4s", raw)
data = fp.read(length)
fp.read(4)
if ctype == b"IHDR":
return struct.unpack(">II", data[:8])
if ctype == b"IEND":
break
raise SystemExit(f"missing IHDR in PNG: {path}")
def read_bmp_rgba(path: Path) -> tuple[int, int, bytes]:
data = path.read_bytes()
if len(data) < 54 or data[:2] != b"BM":
raise SystemExit(f"not a BMP: {path}")
pixel_offset = struct.unpack_from("<I", data, 10)[0]
width = struct.unpack_from("<i", data, 18)[0]
height_raw = struct.unpack_from("<i", data, 22)[0]
bpp = struct.unpack_from("<H", data, 28)[0]
height = abs(height_raw)
top_down = height_raw < 0
if bpp != 32 or width <= 0 or height <= 0:
raise SystemExit(f"unsupported BMP {width}x{height_raw} @{bpp}bpp: {path}")
row_bytes = ((width * 4 + 3) // 4) * 4
pixels = bytearray(width * height * 4)
for row in range(height):
src_row = row if top_down else height - 1 - row
row_off = pixel_offset + src_row * row_bytes
for col in range(width):
b, g, r, a = data[row_off + col * 4 : row_off + col * 4 + 4]
dst = (row * width + col) * 4
pixels[dst : dst + 4] = bytes((r, g, b, a))
return width, height, bytes(pixels)
def bake_with_sips(src: Path, out_w: int, out_h: int) -> tuple[int, int, bytes]:
if platform.system() != "Darwin":
raise SystemExit(
"Pillow is required to bake the OTA logo on this platform. "
"Run scripts/setup.sh or: pip install pillow"
)
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
scaled_png = tmp_dir / "scaled.png"
scaled_bmp = tmp_dir / "scaled.bmp"
subprocess.run(
["sips", "-z", str(out_h), str(out_w), str(src), "--out", str(scaled_png)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
subprocess.run(
["sips", "-s", "format", "bmp", str(scaled_png), "--out", str(scaled_bmp)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
return read_bmp_rgba(scaled_bmp)
def bake_with_pillow(src: Path, out_w: int, out_h: int) -> tuple[int, int, bytes]:
from PIL import Image
with Image.open(src) as image:
image = image.convert("RGBA")
if image.size != (out_w, out_h):
image = image.resize((out_w, out_h), Image.LANCZOS)
w, h = image.size
return w, h, image.tobytes()
def bake(src: Path, out: Path, max_w: int, max_h: int) -> tuple[int, int]:
if not src.is_file():
raise SystemExit(f"missing source PNG: {src}")
src_w, src_h = read_png_size(src)
out_w, out_h = fit_size(src_w, src_h, max_w, max_h)
try:
from PIL import Image # noqa: F401
w, h, pixels = bake_with_pillow(src, out_w, out_h)
except ImportError:
w, h, pixels = bake_with_sips(src, out_w, out_h)
if len(pixels) != w * h * 4:
raise SystemExit(f"unexpected pixel buffer size for {w}x{h}")
out.parent.mkdir(parents=True, exist_ok=True)
with out.open("wb") as fp:
fp.write(struct.pack("<II", w, h))
fp.write(pixels)
return w, h
def main(argv: list[str]) -> int:
src = Path(argv[1]) if len(argv) > 1 else DEFAULT_SRC
out = Path(argv[2]) if len(argv) > 2 else DEFAULT_OUT
max_w = int(argv[3]) if len(argv) > 3 else DEFAULT_MAX_W
max_h = int(argv[4]) if len(argv) > 4 else DEFAULT_MAX_H
w, h = bake(src, out, max_w, max_h)
print(f"wrote {out} ({w}x{h}, {8 + w * h * 4} bytes)")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+14 -3
View File
@@ -508,7 +508,16 @@ test('AC-010: Fonte do launcher e Makefile DEVKITPRO existem @spec:AC-010', () =
assert.ok(fs.existsSync(path.join(root, 'native/switch-ota-launcher/src/ota_ui.c'))); assert.ok(fs.existsSync(path.join(root, 'native/switch-ota-launcher/src/ota_ui.c')));
const otaUi = read('native/switch-ota-launcher/src/ota_ui.c'); const otaUi = read('native/switch-ota-launcher/src/ota_ui.c');
assert.match(otaUi, /COL_RAIL_R|rail|FFD600|255,\s*214/); assert.match(otaUi, /COL_RAIL_R|rail|FFD600|255,\s*214/);
assert.match(otaUi, /logo\.png|stb_image/); assert.match(otaUi, /logo\.rgba/);
assert.doesNotMatch(otaUi, /stb_image/);
assert.ok(
fs.existsSync(path.join(root, 'native/switch-ota-launcher/assets/logo.rgba')),
'pre-baked OTA logo asset'
);
assert.ok(
fs.existsSync(path.join(root, 'scripts/switch/bake_ota_logo.py')),
'logo bake script for regenerating logo.rgba'
);
assert.match(otaUi, /ota_ui_sanitize_ascii/); assert.match(otaUi, /ota_ui_sanitize_ascii/);
assert.match(otaUi, /draw_text_wrapped_centered/); assert.match(otaUi, /draw_text_wrapped_centered/);
assert.match(otaUi, /ota_ui_alert_error/); assert.match(otaUi, /ota_ui_alert_error/);
@@ -519,6 +528,7 @@ test('AC-010: Fonte do launcher e Makefile DEVKITPRO existem @spec:AC-010', () =
assert.doesNotMatch(read('native/switch-ota-launcher/src/ota_net.c'), /CURLOPT_SSL_VERIFYPEER,\s*0L/); assert.doesNotMatch(read('native/switch-ota-launcher/src/ota_net.c'), /CURLOPT_SSL_VERIFYPEER,\s*0L/);
assert.match(read('native/switch-ota-launcher/Makefile'), /^ROMFS\s*:=/m); assert.match(read('native/switch-ota-launcher/Makefile'), /^ROMFS\s*:=/m);
assert.match(read('native/switch-ota-launcher/Makefile'), /cacert\.pem/); assert.match(read('native/switch-ota-launcher/Makefile'), /cacert\.pem/);
assert.match(read('native/switch-ota-launcher/Makefile'), /logo\.rgba/);
const mk = read('native/switch-ota-launcher/Makefile'); const mk = read('native/switch-ota-launcher/Makefile');
assert.match(mk, /libnx\/switch_rules|DEVKITPRO/); assert.match(mk, /libnx\/switch_rules|DEVKITPRO/);
@@ -530,8 +540,9 @@ test('AC-010: Fonte do launcher e Makefile DEVKITPRO existem @spec:AC-010', () =
assert.match(read('native/switch-ota-launcher/src/ota_unzip.c'), /zzip\/zzip\.h/); assert.match(read('native/switch-ota-launcher/src/ota_unzip.c'), /zzip\/zzip\.h/);
assert.match(read('native/switch-ota-launcher/src/main.c'), /ota_unzip_extract_file/); assert.match(read('native/switch-ota-launcher/src/main.c'), /ota_unzip_extract_file/);
assert.match(read('native/switch-ota-launcher/src/main.c'), /GAME_MEMBER_IN_ZIP|switch\/gen1recomp\//); assert.match(read('native/switch-ota-launcher/src/main.c'), /GAME_MEMBER_IN_ZIP|switch\/gen1recomp\//);
assert.match(read('native/switch-ota-launcher/src/main.c'), /LAUNCHER_MEMBER_IN_ZIP|ota_fs_atomic_replace_nro/); assert.match(read('native/switch-ota-launcher/src/main.c'), /LAUNCHER_MEMBER_IN_ZIP|ota_fs_stage_launcher_bootstrap/);
assert.match(read('native/switch-ota-launcher/src/main.c'), /ota_fs_atomic_replace_nro\([\s\S]*\) != 0/); assert.match(read('native/switch-ota-launcher/src/main.c'), /ota_fs_stage_launcher_bootstrap\([\s\S]*\) != 0/);
assert.match(read('native/switch-ota-launcher/src/main.c'), /return 2.*bootstrap|bootstrap.*return 2/i);
assert.match(read('native/switch-ota-launcher/src/ota_fs.c'), /ota_fs_atomic_replace_nro/); assert.match(read('native/switch-ota-launcher/src/ota_fs.c'), /ota_fs_atomic_replace_nro/);
assert.match(read('native/switch-ota-launcher/src/ota_fs.c'), /remove\(dest\)/); assert.match(read('native/switch-ota-launcher/src/ota_fs.c'), /remove\(dest\)/);
assert.doesNotMatch(read('native/switch-ota-launcher/src/ota_fs.c'), /#ifdef _WIN32[\s\S]*remove\(dest\)/); assert.doesNotMatch(read('native/switch-ota-launcher/src/ota_fs.c'), /#ifdef _WIN32[\s\S]*remove\(dest\)/);