Files
EmiiBytee--np2kai-ps4/tools/validate-release.py
T
2026-07-28 06:34:41 +02:00

454 lines
25 KiB
Python
Executable File

#!/usr/bin/env python3
"""comprobaciones antes de compilar.
esto no compila nada, solo lee los fuentes y mira que sigan estando las cosas
que ya funcionaban. lo hice porque me cargue el mando y el audio arreglando el
teclado y no me entere hasta tenerlo instalado en la consola.
si una comprobacion se queja, o has roto algo o has movido codigo y hay que
actualizarla aqui.
"""
from pathlib import Path
import re
import sys
root = Path(__file__).resolve().parents[1]
main = (root / 'SDL2/main.cpp').read_text()
backend = (root / 'SDL2/ps4_keyboard.cpp').read_text()
backend_header = (root / 'SDL2/ps4_keyboard.h').read_text()
core = (root / 'third_party/NP2kai/sdl/kbtrans.c').read_text()
makefile = (root / 'Makefile').read_text()
metadata_validator = (root / 'tools/validate-ps4-package-metadata.py').read_text()
errors = []
def require(condition, message):
if not condition:
errors.append(message)
# --- Control selector: 104 assignments over the full 101-key PC-98 table -----
start = main.index('static const ControlChoice CONTROL_CHOICES[]')
end = main.index('static constexpr unsigned CONTROL_CHOICE_COUNT', start)
block = main[start:end]
choices = re.findall(
r'\{\{ControlBindingType::(None|Keyboard|MouseLeft|MouseRight),\s*'
r'(RETROK_[A-Za-z0-9_]+)\},\s*"([^"]+)"',
block,
)
require(len(choices) == 104, f'expected 104 choices, found {len(choices)}')
config_names = [item[2] for item in choices]
require(len(config_names) == len(set(config_names)), 'duplicate config names')
legacy = {
'none', 'key_z', 'key_x', 'space', 'control', 'shift',
'backspace', 'escape', 'return', 'mouse_left', 'mouse_right',
}
require(legacy.issubset(config_names), 'one or more legacy binding names are missing')
core101 = core[
core.index('static const LRKCNV lrcnv101[]'):
core.index('/* 106 keyboard key table */')
]
core_keys = set(re.findall(r'\{(RETROK_[A-Za-z0-9_]+),', core101))
choice_keys = {key for kind, key, _ in choices if kind == 'Keyboard'}
unsupported = sorted(choice_keys - core_keys)
require(not unsupported, 'selector keys missing from PC-98 table: ' + ', '.join(unsupported))
require(len(choice_keys) == len(core_keys) == 101,
f'expected complete 101-key coverage, selector={len(choice_keys)} core={len(core_keys)}')
# --- Existing functionality must remain intact ------------------------------
require('static bool runDefaultControlsMenu()' in main, 'global controls editor missing')
require('runGameControlsMenu(game)' in main, 'per-game controls editor missing')
require('default_binding_%s=%s\\n' in main, 'custom default bindings are not persistent')
require('static bool presentGameDetailsOverlay(' not in main,
'la ficha del juego ahora vive en el panel de la biblioteca')
require('gameHasOwnProfile' in main, 'el panel debe decir si el juego tiene perfil propio')
require('enum class InputLayout' in main and
'setActiveInputLayout(InputLayout::Keyboard)' in main and
'setActiveInputLayout(InputLayout::Controller)' in main,
'automatic input-layout switching missing')
require('SDL_KEYDOWN' in main and 'SDL_KEYUP' in main and 'SDL_TEXTINPUT' in main,
'SDL keyboard event bridge/fallback missing')
# --- 1.0 root-cause fix: libSceKeyboard, not the IME ----------------------
require('#include "ps4_keyboard.h"' in main, 'native backend header is not included')
require('__attribute__((weak))' not in main, 'stale weak liborbisKeyboard declarations remain')
for stale in ('orbisKeyboardInit', 'orbisKeyboardOpen', 'orbisKeyboardFinish',
'sceImeKeyboardOpen', 'PS4_SYSMODULE_LIBIME',
'sceSysmoduleLoadModuleInternal'):
require(stale not in main, f'v0.37 IME leftover still referenced in main.cpp: {stale}')
for symbol in ('sceKeyboardInit', 'sceKeyboardOpen', 'sceKeyboardClose',
'sceKeyboardReadState', 'sceKeyboardGetKey2Char'):
require(symbol in backend, f'native backend does not call {symbol}')
require('PS4_SYSMODULE_KEYBOARD = 0x0106' in backend,
'keyboard sysmodule id is missing')
require('ps4KeyboardPoll' in backend_header and 'ps4KeyboardOpenDevice' in backend_header,
'native backend public API incomplete')
require('static void processNativeKeyboardEvents()' in main,
'native keyboard poll is missing from the frontend')
require('processNativeKeyboardEvents();' in main and
'if (!g_externalKeyboardEventSubsystemReady) return;' in main,
'native polling must run before the SDL-only early return')
require('static unsigned hidUsageToRetroKey(' in main,
'HID usage to libretro keycode table missing')
require('static void applyExternalKeyEvent(' in main,
'shared key handler missing: native and SDL paths must converge')
require('if (!g_nativeKeyboardOpened) tryOpenNativeKeyboardBackend();' in main,
'hot-plug retry path missing')
require('NATIVE_KEYBOARD_RETRY_MS = 2000' in main,
'native keyboard retry interval missing')
require('Keyboard backend: libSceKeyboard opened=' in main,
'persistent latest.log keyboard diagnostics missing')
require('externalKeyboardStatusText()' in main and
'sceKeyboardOpen no encuentra el dispositivo' in main,
'visible native keyboard status missing')
# The frontend must not lose a held key when the device disappears.
require('handle released' in backend,
'unplug handling does not release held keys')
# --- Build wiring: no network, no libSceIme, no external repositories -------
require('-lSceKeyboard' in makefile, 'libSceKeyboard is not linked')
require('-lSceIme' not in makefile, 'libSceIme is still linked')
require('NATIVE_KEYBOARD_LIB' not in makefile.replace('clean-native-keyboard', ''),
'the liborbisKeyboard archive is still wired into the build')
require('--whole-archive $(CORE_LIB) --no-whole-archive' in makefile,
'core force-link line was altered')
require(not (root / 'tools/prepare-native-keyboard.sh').exists(),
'prepare-native-keyboard.sh still present: the build would clone repositories')
for token in ('liborbis', 'ps4sdk', 'firmware505', 'git clone'):
require(token not in makefile, f'build still references removed dependency: {token}')
# --- 1.0 regression guard: one shared user service -----------------------
# v0.38 broke the pad and the audio because the keyboard backend called
# sceUserServiceInitialize first, so the pad's own call returned
# ALREADY_INITIALIZED and it aborted, leaving the user id invalid.
require('static bool ensureUserServiceReady()' in main,
'shared user-service helper missing')
require('ORBIS_USER_SERVICE_ALREADY_INITIALIZED' in main,
'ALREADY_INITIALIZED is not treated as success')
require(main.count('sceUserServiceInitialize(') == 1,
'sceUserServiceInitialize must be called from exactly one place')
require('sceUserServiceInitialize' not in backend,
'the keyboard backend must not own the user service')
require('sceUserServiceGetInitialUser' not in backend,
'the keyboard backend must not resolve the user itself')
require('bool ps4KeyboardOpenDevice(int userId)' in backend_header,
'the keyboard open call must receive the user id from the frontend')
require('ps4KeyboardOpenDevice(g_padUserId)' in main,
'the frontend does not pass the shared user id to the keyboard')
require('if (!ensureUserServiceReady()) {' in main,
'initializePadInput no longer goes through the shared helper')
require('g_userServiceReady && !ps4KeyboardIsOpen()' in main,
'the user service can be terminated while the keyboard is open')
# --- 1.0 feature: guided first-run setup ---------------------------------
require('static bool runFirstRunSetup()' in main, 'first-run setup screen missing')
require('static bool firstRunSetupNeeded()' in main, 'first-run detection missing')
require('static unsigned collectSetupRequirements(' in main,
'first-run requirement scan missing')
require('SETUP_COMPLETED_MARKER' in main, 'first-run marker missing')
require('if (g_running && firstRunSetupNeeded()) {' in main,
'first-run setup is not wired into startup')
require('contentDirectoryHasFiles' in main, 'games directory is not scanned')
# --- 1.0: la interfaz se compone en ram y se anima sola -------------------
# antes cada pantalla pintaba pixel a pixel en la memoria de video y solo
# repintaba al pulsar un boton, asi que no habia forma de animar nada
require('uiFrameBegin()' in main and 'uiFrameSubmit()' in main,
'falta la capa de composicion de la interfaz')
require(main.count('uiFrameBegin()') >= 12,
'alguna pantalla sigue pintando fuera del lienzo de ram')
_screens = main[main.index('static bool presentSettingsScreen'):]
require('g_displayBuffers[g_activeDisplayBuffer])' not in _screens,
'alguna pantalla sigue escribiendo directa en la memoria de video')
require('static void renderBackgroundInto' in main and
'g_uiBackgroundCacheReady' in main,
'el fondo ya no se cachea')
require('static float uiApproach(' in main and 'UI_TAU_FOCUS' in main,
'falta el sistema de animacion')
require('g_uiDeltaSeconds' in main,
'las animaciones no van por tiempo real')
require('static void uiDrawMovingFocus(' in main,
'el recuadro del foco no se desliza')
require('static void drawUiIcon(' in main and 'enum class UiIcon' in main,
'faltan los iconos')
# ningun bucle de menu puede volver a bloquear la entrada con delays
for pattern in ('SDL_Delay(12);', 'if (dirty)'):
require(pattern not in main, f'vuelve a haber animacion bloqueante: {pattern}')
# --- 1.0: la de la pantalla negra ----------------------------------------
# uiFrameSubmit se llamaba a si misma. lo hizo un reemplazo automatico que
# encontro el mismo patron dentro de la funcion que acababa de escribir, y
# compila igual porque una funcion recursiva es legal. muerte por pila llena
# antes de pintar el primer frame.
import re as _re
_begin = main.count('uiFrameBegin();')
_submit = main.count('return uiFrameSubmit();')
require(_begin == _submit,
f'cada pantalla necesita su pareja: {_begin} uiFrameBegin y {_submit} uiFrameSubmit')
_body = main[main.index('static bool uiFrameSubmit()') + len('static bool uiFrameSubmit()'):]
_body = _body[:_body.index('\n}\n')]
require('submitActiveDisplayBuffer()' in _body,
'uiFrameSubmit no lanza el flip')
require('uiFrameSubmit()' not in _body,
'uiFrameSubmit se llama a si misma otra vez')
# ninguna funcion puede acabar llamandose a si misma sin condicion
for _m in _re.finditer(r'^static [^\n(]*?\b(\w+)\(', main, _re.M):
_name = _m.group(1)
_i = main.find('{', _m.end())
if _i < 0 or _i - _m.end() > 400: continue
_d = 0; _e = -1
for _j in range(_i, len(main)):
if main[_j] == '{': _d += 1
elif main[_j] == '}':
_d -= 1
if _d == 0: _e = _j; break
if _e < 0: continue
require(not _re.search(r'\breturn ' + _name + r'\(\);', main[_i:_e]),
f'{_name} se llama a si misma como ultima instruccion')
# si el lienzo no cabe en memoria hay que seguir pintando, no quedarse en negro
require('g_uiDirectMode' in main, 'falta el modo directo de emergencia')
# --- 1.0: medicion por tramos --------------------------------------------
# el tiempo de presentacion era un solo numero que juntaba conversion, volcado
# y espera de vsync, asi que no se podia saber que frenaba
for _bucket in ('g_msWaitBuffer', 'g_msConvert', 'g_msSubmit', 'g_msSlack'):
require(_bucket in main, f'falta el cronometro {_bucket}')
require('g_lateFrames' in main, 'no se cuentan los frames que llegan tarde')
require('writePerfSample' in main, 'no se escribe el historico de rendimiento')
require('logs/perf.log' in main, 'falta el archivo perf.log')
require('static bool runDiagnosticsScreen()' in main, 'falta la pantalla de diagnostico')
require('g_tickSlack += nextDeadline - now;' in main,
'el hueco libre no se mide, y es el dato que dice si hay margen')
# --- 1.0: picos y iconos -------------------------------------------------
require('g_msWorstRun' in main, 'no se guarda el peor frame del segundo')
require('g_audioCutsPerSecond' in main, 'los cortes de audio deben ser por segundo, no el total')
require('const int inset = size * 12 / 100;' in main,
'los iconos deben dibujarse sobre la caja comun')
require('cortes/s' in main and 'cuts/s' in main,
'la cola de audio tiene que estar traducida')
# --- 1.0: paleta unica ---------------------------------------------------
# antes habia 119 colores escritos a pelo por todo el archivo y no se podia
# cambiar el aspecto sin ir uno por uno
require('struct UiPalette' in main and 'static UiPalette g_ui' in main,
'falta la paleta')
require('enum class UiTheme' in main, 'faltan los temas')
require('g_uiTheme = UiTheme::Grey;' in main, 'el tema por defecto debe ser el gris')
require('ui_theme=%s' in main, 'el tema no se guarda en el ini')
_pantallas = main[main.index('static void renderBackgroundInto'):]
import re as _re2
_sueltos = _re2.findall(r'encodeDisplayColor\(\s*\d+,\s*\d+,\s*\d+\s*\)', _pantallas)
require(len(_sueltos) <= 6,
f'quedan {len(_sueltos)} colores a pelo en las pantallas, deberian salir de la paleta')
require('blendDisplayColor(*pixel, sheen' in main,
'la banda de luz debe mezclarse sobre el fondo, con el destino primero')
# el texto personal del autor no se toca
require('cabezoneria' in main, 'el texto de Sobre este proyecto no debe borrarse')
# --- 1.0: maquetacion comun ----------------------------------------------
# antes cada pantalla ponia sus coordenadas a mano y todas salian iguales de
# sosas: titulo arriba, lista centrada y media pantalla vacia a la derecha
require('namespace ui {' in main and 'constexpr int PANEL_X' in main,
'falta la rejilla comun')
for _pieza in ('uiHeader', 'uiRow', 'uiPanel', 'uiFocusRow', 'uiPanelTitle',
'uiPanelField', 'uiScrollBar', 'uiRoundedRect'):
require('static ' in main and _pieza + '(' in main, f'falta la pieza {_pieza}')
# ninguna pantalla puede volver a maquetar a mano, salvo la pausa
_sinPausa = main.replace(main[main.index('static bool presentPauseScreen'):
main.index('static bool presentPauseScreen') + 4000], '')
require(_sinPausa.count('970, FooterButton') == 0,
'alguna pantalla sigue con el pie en la coordenada vieja')
require('settingsRowHelp' in main, 'el panel de ajustes necesita sus descripciones')
# el texto del autor sigue intacto
require('cabezoneria' in main, 'el texto de Sobre este proyecto no debe borrarse')
require('Emii_in_Gensokyo' in main, 'falta el contacto del autor')
# --- 1.0: la del fondo roto ----------------------------------------------
# blendDisplayColor(destino, origen, alpha) lleva el destino PRIMERO. yo pasaba
# el color como destino, asi que en vez de teñir el fondo lo sustituia entero:
# banda blanca gigante y todo lo demas casi negro
import re as _re3
for _m in _re3.finditer(r'blendDisplayColor\(([^,]+),', main):
_primero = _m.group(1).strip()
require(_primero.startswith('*pixel') or _primero.startswith('destination') or
_primero.startswith('row[px]') or _primero.startswith('std::uint32_t'),
f'blendDisplayColor con el destino mal: primer argumento {_primero}')
# dos tipografias, generadas con el script del repo
require('enum class UiFace' in main, 'falta la separacion de tipografias')
require('UI_GLYPHS_DISPLAY' in main and 'UI_FONT_ALPHA_DISPLAY' in main,
'falta la tipografia de titulos')
require((root / 'tools/build-ui-font.py').exists(),
'el generador de la fuente debe ir en el repo, o no se puede rehacer')
require('UiFace::Display' in main, 'los titulos deben usar la cara de display')
# --- 1.0: respuesta al pulsar e iconos nuevos ----------------------------
require('uiTriggerPress' in main, 'falta la respuesta al pulsar')
require(main.count('uiTriggerPress();') >= 5,
'la pulsacion tiene que dispararse en todos los menus, no solo en uno')
require('g_uiPress' in main, 'falta el estado del golpecito')
# nada de borrar pintando con color 0: eso pinta negro
require('0x00000000u)' not in main, 'hay un rectangulo de borrado con color 0')
# los iconos son otros
require('UI_ICON_ALPHA' in main and 'drawIconBitmap' in main,
'los iconos deben salir del atlas de Tabler')
require('playstation-x' in main, 'los botones del pie deben usar los iconos nuevos')
require((root / 'THIRD-PARTY.md').exists(),
'Tabler es MIT: hace falta el aviso de licencia en el repo')
require('Tabler' in (root / 'THIRD-PARTY.md').read_text(),
'falta la atribucion de Tabler')
require((root / 'tools/build-ui-icons.py').exists(),
'el conversor de iconos debe ir en el repo')
# --- 1.0: estados de guardado --------------------------------------------
for _f in ('retro_serialize_size', 'retro_serialize', 'retro_unserialize'):
require(_f in main, f'falta el enlace con {_f}')
require('saveStateToSlot' in main and 'loadStateFromSlot' in main,
'falta el gestor de estados')
require('static bool runSaveStateMenu' in main, 'falta la pantalla de ranuras')
require('STATE_MAGIC' in main, 'los archivos de estado deben llevar cabecera')
# retro_serialize_size hace una grabacion entera por dentro: no puede llamarse
# mas que en el momento de guardar
require(main.count('= retro_serialize_size();') == 1,
'retro_serialize_size solo puede llamarse al guardar: cuesta una grabacion entera')
# escritura atomica: primero al temporal y luego rename
require('std::rename(temporary, path)' in main,
'el estado debe escribirse a un temporal y renombrarse')
require('g_stateJustLoaded' in main,
'al cargar un estado hay que volver al juego, no al menu')
require('Estados de guardado' in main, 'falta la entrada en el menu de pausa')
# --- 1.0: recarga, cambio de disco y la fila perdida ---------------------
_core = (root / 'third_party/NP2kai/sdl/libretro/libretro.c').read_text(errors='replace')
require('NP2KAI_PS4_RELOAD_FIX' in _core,
'falta el arreglo de recarga en el core: cerrar un juego y abrir otro cuelga')
require('firstcall = 1;' in _core,
'retro_unload_game debe dejar el core listo para la siguiente carga')
require((root / 'tools/np2kai-reload-fix.patch').exists(),
'el parche del core debe ir en el repo: NP2kai es un submodulo')
require((root / 'tools/apply-np2kai-patches.sh').exists(),
'falta el aplicador compartido de parches del core')
require('apply-np2kai-patches.sh' in (root / 'build-ps4-core.sh').read_text(),
'el script del core debe aplicar los parches en un clon limpio')
# la fila del menu de pausa se perdia por un 6 escrito a mano
require('PAUSE_ROW_COUNT' in main, 'el numero de filas de la pausa debe salir de una constante')
require('row < PAUSE_ROW_COUNT' in main, 'el bucle de la pausa no puede llevar el numero a mano')
_labels = main[main.index('static const char* pauseRowLabel'):]
_labels = _labels[:_labels.index('return interfaceIsEnglish()')]
require(_labels.count('",') + _labels.count('"}') == 14,
'las etiquetas de la pausa no cuadran con las 7 filas')
require('Cerrar aplicación' not in main, 'la opcion de cerrar la aplicacion debia quitarse')
require('runDiskSwapMenu' in main, 'falta el cambio de disco')
require('GameSessionResult::SwapDisk' in main, 'el cambio de disco no llega al bucle principal')
# --- release: nada residual ------------------------------------------------
import glob as _glob
import re as _re5
_prohibidos = []
# los .log son artefactos normales de compilacion y estan en .gitignore, asi
# que no se comprueban aqui: se comprobaba que existieran las reglas
for _pat in ('*.orig', '*.rej', '*.bak', 'VALIDATION-*.txt',
'assets/system/np2kai/*.rom', 'assets/system/np2kai/*.ROM',
'assets/system/np2kai/font.bmp', 'assets/games/*'):
_prohibidos += _glob.glob(str(root / _pat))
require(not _prohibidos,
'quedan archivos residuales: ' + ', '.join(_prohibidos[:4]))
require(not (root / 'backups').exists(), 'la carpeta backups no debe existir')
_ignore = (root / '.gitignore').read_text()
for _regla in ('*.log', '*.orig', 'build/', 'assets/system/np2kai/*', '*.rom'):
require(_regla in _ignore, f'.gitignore no cubre {_regla}')
require(not (root / 'patches').exists(), 'la carpeta patches no debe existir')
require(len(_glob.glob(str(root / 'CHANGELOG-v*.md'))) == 0,
'los changelogs deben estar consolidados en CHANGELOG.md')
require((root / 'CHANGELOG.md').exists(), 'falta CHANGELOG.md')
require((root / 'LICENSE').exists(), 'falta el archivo LICENSE en la raiz')
require((root / 'build-and-deploy.sh').exists(),
'falta el script de compilacion portable')
# nada de rutas de una maquina concreta
import re as _re4
for _f in ('build-and-deploy.sh', 'build-ps4-core.sh', 'build-libretro-common-ps4.sh',
'upload-pkg-goldhen-ftp.sh', 'Makefile'):
_txt = (root / _f).read_text(errors='replace')
require('/home/' not in _txt and '/Users/' not in _txt,
f'{_f} lleva una ruta personal escrita a mano')
# creditos completos y visibles desde la aplicacion
require('InfoDocument::Credits' in main, 'falta la pantalla de creditos')
for _quien in ('AZO234', 'Neko Project II', 'fmgen', 'SoftFloat', 'OpenOrbis',
'GoldHEN', 'SDL2', 'Tabler', 'Carlito', 'Poppins'):
require(_quien in main, f'los creditos no mencionan {_quien}')
require('Créditos' in main, 'falta la fila de creditos en Ayuda')
# nada de desahogos personales en los comentarios
for _raro in ('me comi', 'menos mal', 'culpa mia', 'ni idea de si'):
require(_raro not in main, f'comentario demasiado personal: {_raro}')
# --- las etiquetas de ajustes tienen que cuadrar en los dos idiomas ---------
# se me colo una fila solo en el array español: en ingles todo se desplazaba y
# la ultima fila leia fuera del array, mostrando memoria de al lado como texto
_paginas = dict(_re5.findall(r'case SettingsPage::(\w+): return (\d+);', main))
for _pagina, _clave in (('Root','root'), ('Display','display'), ('Audio','audio'),
('Controls','controls'), ('Emulation','emulation'),
('Interface','interface'), ('Developer','developer'),
('Support','support')):
_esperadas = int(_paginas.get(_pagina, -1))
for _idioma in ('Es', 'En'):
_m = _re5.search(r'static const char\* ' + _clave + _idioma + r'\[\] = \{(.*?)\};',
main, _re5.S)
require(_m is not None, f'falta el array {_clave}{_idioma}')
_n = len(_re5.findall(r'"[^"]*"', _m.group(1)))
require(_n == _esperadas,
f'{_clave}{_idioma} tiene {_n} etiquetas y la pagina declara {_esperadas}')
# --- Versioning and package safety ------------------------------------------
require('config_version=36\\n' in main, 'el formato de perfiles por juego debe seguir en 36')
require('config_version=37\\n' in main, 'frontend.ini debe declarar la version 37')
require('if (loadedConfigVersion < 37)' in main, 'falta la migracion a la version 37')
require('static bool g_uiAnimationsEnabled = true;' in main,
'las animaciones deben venir encendidas de serie')
require('RELEASE_VERSION := 1.0' in makefile, 'source release version is not 1.0')
require('VERSION := 01.00' in makefile, 'SFO version is not 01.00')
require('validate-ps4-package-metadata.py' in makefile, 'package metadata preflight missing')
require('[0-9]{2}\\.[0-9]{2}' in metadata_validator, 'metadata validator does not enforce XX.YY')
require('ORBIS_PAD_BUTTON_TOUCH_PAD' in main, 'fixed touchpad shortcut missing')
require('ORBIS_PAD_BUTTON_L3' in main and 'ORBIS_PAD_BUTTON_R3' in main,
'fixed controller pause chord missing')
require('keycode == RETROK_F12' in main, 'fixed keyboard pause key missing')
if errors:
print('NP2kai PS4 1.0 validation: FAILED')
for error in errors:
print(f'- {error}')
sys.exit(1)
print('NP2kai PS4 1.0 validation: PASS')
print(f'- Selectable assignments: {len(choices)}')
print(f'- PC-98 keyboard keys covered: {len(choice_keys)}/{len(core_keys)}')
print('- Keyboard backend: libSceKeyboard (sysmodule 0x0106, sceKeyboardOpen/ReadState)')
print('- No libSceIme, no liborbisKeyboard, no build-time downloads')
print('- Hot-plug: device open retries every 2 seconds; unplug releases held keys')
print('- Native and SDL key paths share applyExternalKeyEvent()')
print('- Unified keyboard/controller UI and automatic layout: preserved')
print('- Game details overlay and global/per-game controls: preserved')
print('- Interfaz: composicion en ram, fondo cacheado, repintado continuo')
print('- Animaciones por tiempo real, sin bloquear la entrada')
print('- uiFrameBegin/uiFrameSubmit emparejados, sin recursion')
print('- Modo directo de emergencia si no hay memoria para el lienzo')
print('- Medicion separada: run / espera / conversion / flip / hueco')
print('- Historico por segundo en logs/perf.log, etiquetado por juego')
print('- Rejilla comun: lista a la izquierda, panel de detalle a la derecha')
print('- Dos tipografias: Carlito de cuerpo, Poppins Medium de titulos')
print('- Paleta unica con cuatro temas, gris por defecto')
print('- Estados de guardado: 4 ranuras por juego, escritura atomica')
print('- Recarga del core arreglada; cambio de disco cerrando y abriendo')
print('- Config format: 36; SFO version: 01.00')