Files
MoHadiShibli--Control4Free/tools/package_launcher.py
T
MoHadiShibli a039ce9532 Package: list the app under Applications, not Games
The package declared itself a game (CATEGORY gd), so the PS4 filed it under
Games in the library.

It now uses the values Apollo Save Tool ships with: CATEGORY gde ("Non-Game Mini
App"), ATTRIBUTE 32, APP_TYPE 1. Apollo is built with the same OpenOrbis tools and
lists under Applications. APP_TYPE 4 ("Freemium"), which is reported to move apps
there too, made the PS4 refuse to start ours (CE-39929-2), so it stays 1.

Confirmed on the console (firmware 10.01): the app installs, starts, and lists
under Library > Applications. A test reads the fields back out of the built
package.
2026-10-05 15:30:36 +03:00

173 lines
8.6 KiB
Python

"""Build the native launcher package using OpenOrbis/LibOrbisPkg only."""
import argparse
import binascii
import shutil
import struct
import subprocess
import zlib
import xml.etree.ElementTree as ET
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / 'build/launcher'
STAGE = OUT / 'pkg'
ART = OUT / 'launcher-art'
FONTS = OUT / 'fonts.c'
TITLE_ID = 'CFRE00001' # 4 letters + 5 digits, the usual title ID format
CONTENT_ID = f'IV0000-{TITLE_ID}_00-CONTROL4FREE0000'
assert len(CONTENT_ID) == 36
VERSION = (ROOT / 'VERSION').read_text().strip()
# param.sfo wants MM.mm; the patch level is not part of an app version.
major, minor = VERSION.split('.')[:2]
SFO_VERSION = f'{int(major):02d}.{int(minor):02d}'
def embed_fonts():
sources = [ROOT / 'vendor/roboto/Roboto-Light.ttf', ROOT / 'vendor/roboto/Roboto-Regular.ttf']
if FONTS.exists() and all(FONTS.stat().st_mtime >= s.stat().st_mtime for s in sources):
return
OUT.mkdir(parents=True, exist_ok=True)
subprocess.run(['python3', str(ROOT / 'tools/embed_file.py'), str(FONTS),
f'c4fFontLight={sources[0]}', f'c4fFontRegular={sources[1]}'], check=True)
def build_art_tool():
"""Host build of the launcher's drawing code, for the icon and previews."""
embed_fonts()
flags = ['clang-18', '-std=gnu11', '-O2', '-D_DEFAULT_SOURCE', f'-I{ROOT}/launcher',
f'-I{ROOT}/vendor/stb', f'-I{ROOT}/vendor/qrcodegen',
f'-DC4F_LAUNCHER_VERSION="{VERSION}"']
stb = OUT / 'stb_truetype-host.o'
subprocess.run([*flags, '-w', '-c', str(ROOT / 'launcher/stb_truetype.c'), '-o', str(stb)], check=True)
subprocess.run([*flags, '-Wall', '-Wextra', '-Werror', str(ROOT / 'tools/launcher_art.c'),
str(ROOT / 'launcher/screen.c'), str(ROOT / 'launcher/draw.c'), str(ROOT / 'launcher/logo.c'),
str(ROOT / 'vendor/qrcodegen/qrcodegen.c'), str(FONTS), str(stb), '-lm', '-o', str(ART)],
check=True)
def write_png(path, width, height, rgba, alpha=False):
"""Writes a PNG, picking the filter that compresses each row best. The gradient
backgrounds these icons use come out several times larger without it."""
def chunk(kind, data):
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', binascii.crc32(kind + data) & 0xffffffff)
def paeth(a, b, c):
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
return a if pa <= pb and pa <= pc else b if pb <= pc else c
step = 4 if alpha else 3
pixels = rgba if alpha else bytes(b for i, b in enumerate(rgba) if i % 4 != 3)
stride = width * step
rows = []
previous = bytes(stride)
for y in range(height):
row = pixels[y * stride:(y + 1) * stride]
best = None
for kind in range(5):
out = bytearray(stride)
for i in range(stride):
left = row[i - step] if i >= step else 0
up = previous[i]
upleft = previous[i - step] if i >= step else 0
if kind == 0: out[i] = row[i]
elif kind == 1: out[i] = (row[i] - left) & 0xff
elif kind == 2: out[i] = (row[i] - up) & 0xff
elif kind == 3: out[i] = (row[i] - ((left + up) >> 1)) & 0xff
else: out[i] = (row[i] - paeth(left, up, upleft)) & 0xff
# The usual heuristic: keep the row whose bytes are closest to zero.
score = sum(b if b < 128 else 256 - b for b in out)
if best is None or score < best[0]:
best = (score, kind, bytes(out))
rows.append(bytes([best[1]]) + best[2])
previous = row
header = struct.pack('>IIBBBBB', width, height, 8, 6 if alpha else 2, 0, 0, 0)
Path(path).write_bytes(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', header) +
chunk(b'IDAT', zlib.compress(b''.join(rows), 9)) + chunk(b'IEND', b''))
def render(args, path, width, height):
"""Draw with the art tool (build_art_tool first) and save a PNG."""
raw = OUT / 'art.rgba'
subprocess.run([str(ART), *args, str(raw)], check=True)
write_png(path, width, height, raw.read_bytes())
raw.unlink()
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--sdk', required=True, type=Path)
sdk = parser.parse_args().sdk
binary = sdk / 'bin/linux'
# Start clean, so nothing left over from an older build gets packaged.
shutil.rmtree(STAGE, ignore_errors=True)
for directory in ('sce_sys/about', 'sce_module', 'assets', 'assets/licenses'):
(STAGE / directory).mkdir(parents=True, exist_ok=True)
run = lambda *args: subprocess.run([str(arg) for arg in args], cwd=STAGE, check=True)
run(binary / 'create-fself', '-in=' + str(OUT / 'launcher.elf'), '-out=' + str(OUT / 'launcher.oelf'),
'--eboot', 'eboot.bin', '--paid', '0x3800000000000011')
shutil.copyfile(ROOT / 'build/control4free.elf', STAGE / 'assets/control4free.elf')
shutil.copyfile(sdk / 'samples/graphics/sce_sys/about/right.sprx', STAGE / 'sce_sys/about/right.sprx')
# OpenOrbis's stub modules. Its CHANGELOG (v0.5) says homebrew packages
# should carry them in sce_module/, and every sample does.
for name in ('libc.prx', 'libSceFios2.prx'):
shutil.copyfile(sdk / 'samples/graphics/sce_module' / name, STAGE / 'sce_module' / name)
licenses = STAGE / 'assets/licenses'
shutil.copyfile(ROOT / 'LICENSE', licenses / 'Control4Free-GPL-3.0.txt')
shutil.copyfile(ROOT / 'THIRD_PARTY.md', licenses / 'THIRD_PARTY.txt')
shutil.copyfile(ROOT / 'vendor/jsmn/LICENSE', licenses / 'jsmn-MIT.txt')
shutil.copyfile(ROOT / 'vendor/roboto/LICENSE', licenses / 'Roboto-Apache-2.0.txt')
qrcodegen = (ROOT / 'vendor/qrcodegen/qrcodegen.h').read_text()
(licenses / 'qrcodegen-MIT.txt').write_text(qrcodegen.split('*/', 1)[0] + '*/\n')
stb = (ROOT / 'vendor/stb/stb_truetype.h').read_text()
(licenses / 'stb_truetype.txt').write_text(stb[stb.index('This software is available under 2 licenses'):])
# The home-screen icon comes from the launcher's own drawing code.
build_art_tool()
render(['icon', '512'], STAGE / 'sce_sys/icon0.png', 512, 512)
pkgtool = binary / 'PkgTool.Core'
sfo = 'sce_sys/param.sfo'
run(pkgtool, 'sfo_new', sfo)
# Listed under Applications, not Games: the values Apollo Save Tool ships with
# (bucanero/apollo-ps4, built with the same OpenOrbis tools), which lists there
# and starts fine. CATEGORY gde is "Non-Game Mini App"; gd, which we had, is
# "Game Digital Application". APP_TYPE must stay 1: with 4 ("Freemium") the PS4
# refused to start the app (CE-39929-2, firmware 10.01, 2026-10-05).
entries = {
'APP_TYPE': ('Integer', 4, '1'), 'APP_VER': ('Utf8', 8, SFO_VERSION),
'ATTRIBUTE': ('Integer', 4, '32'), 'CATEGORY': ('Utf8', 4, 'gde'),
'CONTENT_ID': ('Utf8', 48, CONTENT_ID), 'DOWNLOAD_DATA_SIZE': ('Integer', 4, '0'),
'SYSTEM_VER': ('Integer', 4, '0'), 'TITLE': ('Utf8', 128, 'Control4Free'),
'TITLE_ID': ('Utf8', 12, TITLE_ID), 'VERSION': ('Utf8', 8, SFO_VERSION),
}
for key, (kind, size, value) in entries.items():
run(pkgtool, 'sfo_setentry', sfo, key, '--type', kind, '--maxsize', size, '--value', value)
files = ['eboot.bin', 'sce_sys/param.sfo', 'sce_sys/icon0.png', 'sce_sys/about/right.sprx',
'sce_module/libc.prx', 'sce_module/libSceFios2.prx', 'assets/control4free.elf']
files += sorted(str(p.relative_to(STAGE)) for p in (STAGE / 'assets/licenses').iterdir())
project = STAGE / 'launcher.gp4'
run(binary / 'create-gp4', '-out', project, '--content-id=' + CONTENT_ID, '--files', ' '.join(files))
# create-gp4 ships a sample directory tree. Describe the actual package,
# including nested license files, and keep source paths relative to it.
tree = ET.parse(project)
rootdir = tree.getroot().find('rootdir')
rootdir.clear()
for filename in files:
parent = rootdir
for part in Path(filename).parts[:-1]:
child = next((p for p in parent if p.get('targ_name') == part), None)
if child is None:
child = ET.SubElement(parent, 'dir', targ_name=part)
parent = child
ET.indent(tree)
tree.write(project, encoding='utf-8', xml_declaration=True)
shutil.copyfile(project, OUT / 'launcher.gp4')
run(pkgtool, 'pkg_build', project, OUT)
generated = OUT / (CONTENT_ID + '.pkg')
target = ROOT / f'build/Control4Free-{VERSION}.pkg'
shutil.copyfile(generated, target)
print(f'Built {target} ({target.stat().st_size:,} bytes)', flush=True)
if __name__ == '__main__':
main()