diff --git a/launcher/issn_lang.py b/launcher/issn_lang.py index 11ed895..7edf190 100644 --- a/launcher/issn_lang.py +++ b/launcher/issn_lang.py @@ -1602,20 +1602,20 @@ TROPHY_TEXTS = { "The trophy's icon and name in a top corner for a few seconds when the game unlocks one. Also in Insert > Overlay.": 'O ícone e o nome do troféu num canto de cima por alguns segundos quando o jogo libera um. Também em Insert > Overlay.', 'Trophy names (optional)': 'Nomes dos troféus (opcional)', - "The names and descriptions in the game's trophy file are encrypted; without a key the popup and this page show \"Trophy N\" with its icon. If you have your own PS4 trophy key (the same one shadPS4 asks for), paste it here: it stays only on this PC, in user\\trophy-key.txt, and the port never ships or sends it.": - 'Os nomes e as descrições no arquivo de troféus do jogo são criptografados; sem uma chave, o aviso e esta página mostram "Troféu N" com o ícone. Se você tem a sua própria chave de troféus do PS4 (a mesma que o shadPS4 pede), cole aqui: ela fica só neste PC, em user\\trophy-key.txt, e o port nunca a distribui nem envia.', + "The names and descriptions in the game's trophy file are encrypted. Without a key the port uses its own list (English, Portuguese and Spanish, written from public trophy lists). If you have your own PS4 trophy key (the same one shadPS4 asks for), paste it here to see the game's own texts: it stays only on this PC, in user\\trophy-key.txt, and the port never ships or sends it. A name shows once its trophy is unlocked.": + 'Os nomes e as descrições no arquivo de troféus do jogo são criptografados. Sem uma chave, o port usa a lista própria dele (inglês, português e espanhol, escrita a partir de listas públicas de troféus). Se você tem a sua própria chave de troféus do PS4 (a mesma que o shadPS4 pede), cole aqui para ver os textos do próprio jogo: ela fica só neste PC, em user\\trophy-key.txt, e o port nunca a distribui nem envia. O nome aparece quando o troféu é liberado.', 'Save key': 'Salvar chave', 'Remove key': 'Remover chave', 'Trophy key': 'Chave de troféus', 'No key: names are not shown.': 'Sem chave: os nomes não aparecem.', - 'Key saved on this PC: names shown.': 'Chave salva neste PC: nomes aparecendo.', + "Key saved on this PC: the names are the game's own.": 'Chave salva neste PC: os nomes são os do próprio jogo.', + "No key: the names come from the port's own list.": 'Sem chave: os nomes vêm da lista própria do port.', 'The key is saved but the names could not be read: {}': 'A chave está salva, mas os nomes não puderam ser lidos: {}', 'No trophy file in the game folder (sce_sys\\trophy\\trophy00.trp, or Sc0\\trophy beside it). Choose the game folder on the Game page.': 'Nenhum arquivo de troféus na pasta do jogo (sce_sys\\trophy\\trophy00.trp, ou Sc0\\trophy ao lado). Escolha a pasta do jogo na página Jogo.', '{} of {} unlocked ({}%)': '{} de {} liberados ({}%)', 'Platinum! Every trophy unlocked': 'Platinado com sucesso!', 'Platinum': 'Platina', 'Gold': 'Ouro', 'Silver': 'Prata', 'Bronze': 'Bronze', - 'Hidden trophy': 'Troféu oculto', 'Trophy {}': 'Troféu {}', 'Unlocked {}': 'Liberado em {}', 'Locked': 'Bloqueado', @@ -1633,20 +1633,20 @@ TROPHY_TEXTS = { "The trophy's icon and name in a top corner for a few seconds when the game unlocks one. Also in Insert > Overlay.": 'El icono y el nombre del trofeo en una esquina superior durante unos segundos cuando el juego desbloquea uno. También en Insert > Overlay.', 'Trophy names (optional)': 'Nombres de los trofeos (opcional)', - "The names and descriptions in the game's trophy file are encrypted; without a key the popup and this page show \"Trophy N\" with its icon. If you have your own PS4 trophy key (the same one shadPS4 asks for), paste it here: it stays only on this PC, in user\\trophy-key.txt, and the port never ships or sends it.": - 'Los nombres y las descripciones del archivo de trofeos del juego están cifrados; sin una clave, el aviso y esta página muestran "Trofeo N" con su icono. Si tienes tu propia clave de trofeos de PS4 (la misma que pide shadPS4), pégala aquí: se queda solo en este PC, en user\\trophy-key.txt, y el port nunca la distribuye ni la envía.', + "The names and descriptions in the game's trophy file are encrypted. Without a key the port uses its own list (English, Portuguese and Spanish, written from public trophy lists). If you have your own PS4 trophy key (the same one shadPS4 asks for), paste it here to see the game's own texts: it stays only on this PC, in user\\trophy-key.txt, and the port never ships or sends it. A name shows once its trophy is unlocked.": + 'Los nombres y las descripciones del archivo de trofeos del juego están cifrados. Sin una clave, el port usa su propia lista (inglés, portugués y español, escrita a partir de listas públicas de trofeos). Si tienes tu propia clave de trofeos de PS4 (la misma que pide shadPS4), pégala aquí para ver los textos del propio juego: se queda solo en este PC, en user\\trophy-key.txt, y el port nunca la distribuye ni la envía. El nombre aparece cuando se desbloquea el trofeo.', 'Save key': 'Guardar clave', 'Remove key': 'Quitar clave', 'Trophy key': 'Clave de trofeos', 'No key: names are not shown.': 'Sin clave: los nombres no se muestran.', - 'Key saved on this PC: names shown.': 'Clave guardada en este PC: nombres visibles.', + "Key saved on this PC: the names are the game's own.": 'Clave guardada en este PC: los nombres son los del propio juego.', + "No key: the names come from the port's own list.": 'Sin clave: los nombres vienen de la lista propia del port.', 'The key is saved but the names could not be read: {}': 'La clave está guardada, pero no se pudieron leer los nombres: {}', 'No trophy file in the game folder (sce_sys\\trophy\\trophy00.trp, or Sc0\\trophy beside it). Choose the game folder on the Game page.': 'No hay archivo de trofeos en la carpeta del juego (sce_sys\\trophy\\trophy00.trp, o Sc0\\trophy al lado). Elige la carpeta del juego en la página Juego.', '{} of {} unlocked ({}%)': '{} de {} desbloqueados ({}%)', 'Platinum! Every trophy unlocked': '¡Platino! Todos los trofeos', 'Platinum': 'Platino', 'Gold': 'Oro', 'Silver': 'Plata', 'Bronze': 'Bronce', - 'Hidden trophy': 'Trofeo oculto', 'Trophy {}': 'Trofeo {}', 'Unlocked {}': 'Desbloqueado el {}', 'Locked': 'Bloqueado', diff --git a/launcher/issn_launcher_win.py b/launcher/issn_launcher_win.py index aa01684..255adf6 100644 --- a/launcher/issn_launcher_win.py +++ b/launcher/issn_launcher_win.py @@ -2511,10 +2511,11 @@ class Launcher: _("The trophy's icon and name in a top corner for a few seconds when the game unlocks one. " 'Also in Insert > Overlay.')) self.section(f, _('Trophy names (optional)')) - self.note(f, _("The names and descriptions in the game's trophy file are encrypted; without a key the " - 'popup and this page show "Trophy N" with its icon. If you have your own PS4 trophy key ' - '(the same one shadPS4 asks for), paste it here: it stays only on this PC, in ' - 'user\\trophy-key.txt, and the port never ships or sends it.'), top=4) + self.note(f, _("The names and descriptions in the game's trophy file are encrypted. Without a key the port " + 'uses its own list (English, Portuguese and Spanish, written from public trophy lists). ' + 'If you have your own PS4 trophy key (the same one shadPS4 asks for), paste it here to see ' + "the game's own texts: it stays only on this PC, in user\\trophy-key.txt, and the port never " + 'ships or sends it. A name shows once its trophy is unlocked.'), top=4) holder = ttk.Frame(f) self.trophy_key_entry = ttk.Entry(holder, width=40, show='•') self.trophy_key_entry.pack(side='left') @@ -2529,16 +2530,18 @@ class Launcher: return [self.var('update_dir', 'app').get(), self.var('game_dir', 'app').get()] def trophy_texts(self, trp): - """({id: text} in the game language with the saved key, a note on the key).""" + """({id: text} in the game language, a note on where the names come from): the game's own + texts with the saved key, else the port's list (scripts/trophy_list.py).""" key = trophies.read_key(self.user_dir()) - if key is None: - return {}, _('No key: names are not shown.') language = game_language({**self.app, 'game_language': self.var('game_language', 'app').get()}) cache = ('texts', str(trp), key, language) if cache not in self.trophy_cache: try: - texts = trophies.read_texts(trp, trophies.npbind_file(*self.trophy_folders()), key, language) - self.trophy_cache[cache] = (texts, _('Key saved on this PC: names shown.')) + texts, source = trophies.game_texts(trp, language, *self.trophy_folders(), key=key) + note = (_('Key saved on this PC: the names are the game\'s own.') if source == 'key' else + _("No key: the names come from the port's own list.") if source == 'list' else + _('No key: names are not shown.')) + self.trophy_cache[cache] = (texts, note) except (OSError, ValueError, KeyError, trophies.ElementTree.ParseError) as error: self.trophy_cache[cache] = ({}, _('The key is saved but the names could not be read: {}').format(error)) return self.trophy_cache[cache] @@ -2603,11 +2606,12 @@ class Launcher: self.trophy_icons.append(photo) tk.Label(card, image=photo, bg=CARD, bd=0).grid(row=0, column=0, rowspan=2, padx=self.px(8), pady=self.px(6)) - hidden = text.get('hidden') and not got - name = _('Hidden trophy') if hidden else text.get('name') or _('Trophy {}').format(trophy_id) + # issn 11-05 (João): a trophy's name and description show once it is unlocked; until + # then only its number, icon and grade. + name = text.get('name') if got and text.get('name') else _('Trophy {}').format(trophy_id) tk.Label(card, text=name, bg=CARD, fg=TEXT if got else MUTED, font=(UI_FONT, 11, 'bold'), anchor='w').grid(row=0, column=1, sticky='w', pady=(self.px(8), 0)) - detail = '' if hidden else text.get('detail', '') + detail = text.get('detail', '') if got else '' when = _('Unlocked {}').format(unlocked[trophy_id]) if got else _('Locked') tk.Label(card, text=f'{detail}\n{when}' if detail else when, bg=CARD, fg=MUTED, font=(UI_FONT, 9), anchor='w', justify='left', wraplength=self.px(520)).grid(row=1, column=1, sticky='w', diff --git a/scripts/trophies.py b/scripts/trophies.py index c5d8d90..3e999a3 100644 --- a/scripts/trophies.py +++ b/scripts/trophies.py @@ -8,7 +8,8 @@ game, src/runtime_trophy.c). - The texts (names, descriptions, grades) are encrypted (TROP*.ESFM). They open only with the player's own trophy key, which the launcher keeps in user/trophy-key.txt on their PC; the port - never ships one. With it, run.py writes user/trophy-names.txt for the in-game popup. + never ships one. Without it, inFAMOUS Second Son's names come from trophy_list.py (written by + hand from public lists). Either way run.py writes user/trophy-names.txt for the in-game popup. ESFM: AES-128-CBC. The file key is the NP communication id (npbind.dat, "NPWRnnnnn_nn" padded to 16 bytes with zeros) encrypted with the trophy key (CBC, zero IV); each ESFM is a 16-byte IV and @@ -317,6 +318,49 @@ def read_texts(trp, npbind, key, language=1): return result +def game_ids(*folders): + """(title ID, NP communication id) of the game in the folders; None for what is not found.""" + title = comm = None + sfo = system_file('param.sfo', *folders) + if sfo is not None: + try: + from prepare import load_sfo + title = load_sfo(sfo).get('TITLE_ID') + except (ImportError, OSError, ValueError): + title = None + npbind = npbind_file(*folders) + if npbind is not None: + try: + comm = np_comm_id(npbind.read_bytes()) + except OSError: + comm = None + return title, comm + + +def builtin_texts(trp, language, *folders): + """The port's own list (trophy_list.py) when the trophy file is inFAMOUS Second Son's: the + same number of trophies, and its title ID or NP communication id; else None.""" + import trophy_list + try: + if len(trophy_ids(read_entries(trp))) != len(trophy_list.TROPHIES): + return None + except (OSError, ValueError): + return None + title, comm = game_ids(*folders) + if title not in trophy_list.TITLE_IDS and comm != trophy_list.COMM_ID: + return None + return trophy_list.texts(language) + + +def game_texts(trp, language, *folders, key=None): + """(texts, source): the game's own texts with the player's key ('key'), else the port's list + ('list'), else ({}, None). ValueError from a key that does not open the file is passed on.""" + if key is not None: + return read_texts(trp, npbind_file(*folders), key, language), 'key' + texts = builtin_texts(trp, language, *folders) + return (texts, 'list') if texts else ({}, None) + + def write_names(user_dir, texts): """user/trophy-names.txt for the game (src/runtime_trophy.c): id, grade, base, name.""" lines = ['# Trophy names from the player\'s own key (not shipped). id\tgrade\tbase\tname'] @@ -346,15 +390,18 @@ def prepare(env, user_dir): except (OSError, ValueError) as error: return f'Trophies: {error}' key = read_key(user_dir) - note = 'no names (no trophy key)' - if key is not None: - try: - texts = read_texts(trp, npbind_file(*folders), key, env.get('ISSN_LANGUAGE', '1')) - write_names(user_dir, texts) - note = f'names: {sum(1 for t in texts.values() if t["name"])}' - except (OSError, ValueError, KeyError, ElementTree.ParseError) as error: - names.unlink(missing_ok=True) - note = f'no names ({error})' + language = env.get('ISSN_LANGUAGE', '1') + try: + texts, source = game_texts(trp, language, *folders, key=key) + except (OSError, ValueError, KeyError, ElementTree.ParseError) as error: + texts, source = {}, None + note = f'no names ({error})' + else: + note = 'no names (no trophy key)' + if texts: + write_names(user_dir, texts) + origin = 'the trophy key' if source == 'key' else "the port's list" + note = f'names: {sum(1 for t in texts.values() if t["name"])} ({origin})' else: names.unlink(missing_ok=True) return f'Trophies: {count} in {trp}, {unlocked} unlocked on this PC, {note}' diff --git a/scripts/trophy_list.py b/scripts/trophy_list.py new file mode 100644 index 0000000..bd836ae --- /dev/null +++ b/scripts/trophy_list.py @@ -0,0 +1,188 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +"""inFAMOUS Second Son's trophy names, for players without a trophy key (scripts/trophies.py). + +Written by hand from public trophy lists, not taken from the game's files: +- English names, descriptions, grades and order: PlayStation's own list as shown by PlatPursuit + (NPWR05740_00); the order matches the icons of the game's trophy file (0 platinum cup, 2/3 good + and evil Delsin, 14 open lock = "Everybody Out", 39 car = "Total CARnage", ...). +- Portuguese: PlayStation Blast (2015 article with the Brazilian texts); its typo "Estillhaçado" + is corrected. The platinum's Portuguese name was not in it: "Aproveite seus poderes" is our own. +- Spanish: Xataka México's trophy guide (38 of them); the ten story trophies it left out + (4-13) are our own translations. +Names that are our own may differ from the game's; the player's own key (Achievements page) gives +the game's texts instead. +""" + +COMM_ID = 'NPWR05740_00' +TITLE_IDS = ('CUSA00004', 'CUSA00223') + +# (grade, English name, English description, Portuguese name, Portuguese description, +# Spanish name, Spanish description), by trophy id. +TROPHIES = [ + ('P', 'Enjoy Your Powers', 'Collect all trophies', + 'Aproveite seus poderes', 'Obtenha todos os troféus', + 'Disfruta tus poderes', 'Obtén todos los trofeos.'), + ('S', 'Unstoppable', 'Finish the game on Expert difficulty', + 'Imbatível', 'Termine o jogo na dificuldade Especialista', + 'Imparable', 'Termina el juego en nivel Experto.'), + ('B', 'Sacrifice', 'Risk your freedom to protect those you care about', + 'Sacrifício', 'Arrisque sua liberdade para proteger aqueles com quem se importa', + 'Sacrificio', 'Arriesga tu libertad por proteger a los que quieres.'), + ('B', 'Ruthless', 'Look out for yourself, no matter the consequences', + 'Implacável', 'Proteja-se, não importam as consequências', + 'Despiadado', 'Mira solo por ti, pase lo que pase.'), + ('B', 'Temperance', 'Practice self-control', + 'Moderação', 'Pratique o autocontrole', + 'Templanza', 'Practica el autocontrol.'), + ('B', 'Malice', 'Get them before they get you', + 'Malícia', 'Pegue eles antes que te peguem', + 'Malicia', 'Atrápalos antes de que te atrapen.'), + ('B', 'Courage', 'Stand up for the helpless', + 'Coragem', 'Levante-se pelos indefesos', + 'Valor', 'Defiende a los indefensos.'), + ('B', 'Contempt', "Don't fear the weak", + 'Desobediência', 'Não tema os fracos', + 'Desprecio', 'No temas a los débiles.'), + ('B', 'Mercy', 'Everyone deserves a second chance', + 'Misericórdia', 'Todos merecem uma segunda chance', + 'Piedad', 'Todos merecen una segunda oportunidad.'), + ('B', 'Revenge', 'An eye for an eye, a tooth for a tooth', + 'Vingança', 'Olho por olho, dente por dente', + 'Venganza', 'Ojo por ojo, diente por diente.'), + ('B', 'Justice', 'Expose the corruption of the powerful', + 'Justiça', 'Exponha a corrupção dos poderosos', + 'Justicia', 'Expón la corrupción de los poderosos.'), + ('B', 'Dominance', 'Show the world that nobody can protect them', + 'Domínio', 'Mostre ao mundo que ninguém pode protegê-los', + 'Dominio', 'Muestra al mundo que nadie puede protegerlos.'), + ('G', 'Reconciliation', 'Finish the story with Good Karma', + 'Reconciliação', 'Termine a história com carma do bem', + 'Reconciliación', 'Termina la historia con karma del bien.'), + ('G', 'Rebellion', 'Finish the story with Evil Karma', + 'Rebelião', 'Termine a história com carma do mal', + 'Rebelión', 'Termina la historia con karma del mal.'), + ('B', 'Everybody Out', 'Rescue 10 suspects from suspicion pens', + 'Todo mundo solto', 'Resgate 10 suspeitos das celas de suspeitos', + 'Todos fuera', 'Rescata a 10 sospechosos de las celdas.'), + ('B', 'Spin-off', 'Beat down 10 sign twirlers', + 'Chega de cartazes', 'Dê porrada em 10 pessoas com placas', + 'Basta de publicidad', 'Derrota a 10 portadores de anuncios.'), + ('B', "Everyone's a Critic", 'Take out 10 street musicians', + 'Todo mundo é crítico', 'Acabe com 10 músicos de rua', + 'Todos somos críticos', 'Elimina a 10 músicos callejeros.'), + ('B', 'Just Say No', 'Bust 10 drug dealers', + 'Drogas, tô fora', 'Pegue 10 traficantes', + 'Simplemente di no', 'Detén a 10 narcotraficantes.'), + ('B', 'Freedom of Speech', 'Disrupt 10 activist rallies', + 'Liberdade de expressão', 'Atrapalhe 10 manifestações ativistas', + 'Libertad de expresión', 'Interrumpe 10 reuniones de activistas.'), + ('B', 'What Did You Call Me?', 'Kill 10 obnoxious Akuran gang members', + 'Do que você me chamou?', 'Mate 10 membros idiotas da gangue Akulan', + '¿Qué me has llamado?', 'Mata a 10 molestos miembros de la banda akulana.'), + ('B', 'Someone Your Own Size', 'Rescue 10 escaped conduits from angry mobs', + 'Alguém do seu tamanho', 'Resgate 10 condutores fugitivos de multidões enfurecidas', + 'Alguien de tu tamaño', 'Rescata a 10 conductores escapados de las turbas violentas.'), + ('B', 'Walk It Off', 'Heal 10 wounded civilians', + 'Caminha que passa', 'Cure 10 civis feridos', + 'Ya estás bien', 'Cura a 10 civiles heridos.'), + ('B', 'Kicking and Screaming', 'Finish off 10 wounded enemies or civilians', + 'Agonizando', 'Finalize 10 inimigos feridos', + 'Remátalos', 'Acaba con 10 enemigos o civiles heridos.'), + ('S', 'True Hero', 'Achieve Level 5 Good Karma', + 'Herói Verdadeiro', 'Atinja carma do bem no nível 5', + 'Héroe de verdad', 'Alcanza el nivel 5 de karma del bien.'), + ('S', 'Infamous', 'Achieve Level 5 Evil Karma', + 'Infame', 'Atinja carma do mal no nível 5', + 'Infame', 'Alcanza el nivel 5 de karma del mal.'), + ('B', 'Bweep! Bweep! Bweep!', 'Collect half the Blast Shards', + 'Bipe! Bipe! Bipe!', 'Colete metade dos estilhaços de explosões', + '¡Bip! ¡Bip! ¡Bip!', 'Obtén la mitad de los fragmentos de la explosión.'), + ('S', 'Shardcore', 'Collect all the Blast Shards', + 'Estilhaçado', 'Colete todos os estilhaços de explosões', + 'Fragmentado', 'Reúne todos los fragmentos de la explosión.'), + ('B', 'Nothing To See Here', 'Destroy half the D.U.P. security cameras', + 'Nada para ver aqui', 'Destrua metade das câmeras de segurança do DUP', + 'Se acabó la función', 'Destruye la mitad de las cámaras de seguridad del D.U.P.'), + ('S', 'Blinded', 'Destroy all of the D.U.P. security cameras', + 'Cego', 'Destrua todas as câmeras de segurança do DUP', + 'Cegados', 'Destruye todas las cámaras de seguridad del D.U.P.'), + ('S', 'Inside Track', 'Collect all the Informant Audio Logs', + 'Nos trilhos', 'Colete todos os registros de áudio de informantes', + 'Grabación interna', 'Reúne todos los diarios de audio del informante.'), + ('S', 'Blown Cover', 'Defeat all the D.U.P. Secret Agents', + 'Disfarce descoberto', 'Derrote todos os agentes secretos do DUP', + 'Sin tapadera', 'Derrota a todos los agentes secretos del D.U.P.'), + ('S', 'Big Brother', 'Destroy all the D.U.P. Hidden Cameras', + 'Big Brother', 'Destrua todas as câmeras secretas do DUP', + 'Gran hermano', 'Destruye todas las cámaras ocultas del D.U.P.'), + ('S', 'Coloring Inside the Lines', 'Complete all the Stencil Art', + 'Pintando dentro da linha', 'Conclua todas as artes de estêncil', + 'No te salgas de los puntos', 'Completa todos los grafitis con plantilla.'), + ('B', 'Prank Call', 'Complete a District Showdown', + 'Trote', 'Conclua um confronto no distrito', + 'Broma telefónica', 'Completa un Enfrentamiento de distrito.'), + ('B', 'Hello Operator', 'Complete Showdowns in half of the districts', + 'Alô, operadora', 'Conclua confrontos em metade dos distritos', + 'Hola, operadora', 'Completa Enfrentamientos en la mitad de los distritos.'), + ('S', 'Final Showdown', 'Complete all of the District Showdowns in the city', + 'Último confronto', 'Conclua os confrontos em todos os distritos da cidade', + 'Enfrentamiento final', 'Completa todos los Enfrentamientos de distrito de la ciudad.'), + ('B', 'One Down', 'Clear the D.U.P. completely out of a district', + 'Menos um', 'Expulse o DUP completamente de um distrito', + 'Uno menos', 'Expulsa por completo al D.U.P. de un distrito.'), + ('B', 'Home Field Advantage', 'Clear the D.U.P. out of half the districts in the city', + 'Jogando em casa', 'Expulse o DUP de metade dos distritos da cidade', + 'Jugar en casa', 'Expulsa al D.U.P. de la mitad de los distritos de la ciudad.'), + ('S', 'Clean Sweep', 'Clear the D.U.P. out of the entire city', + 'Limpeza total', 'Expulse o DUP da cidade inteira', + 'Limpieza', 'Expulsa al D.U.P. de toda la ciudad.'), + ('B', 'Total CARnage', 'Destroy 5 cars in 5 seconds', + 'Carnificina total', 'Destrua 5 carros em 5 segundos', + 'Autocarnicería total', 'Destruye 5 coches en 5 segundos.'), + ('B', 'Drop Everything', 'Comet Drop over 170 meters', + 'Como um cometa', 'Execute o cometa por mais de 170 metros', + 'Realmente certero', 'Realiza un Cometa de más de 170 metros.'), + ('B', 'Keep It Up', 'Stay in the air for more than 45 seconds', + 'Continue assim', 'Fique no ar por mais de 45 segundos', + 'Sigue así', 'Permanece en el aire más de 45 segundos.'), + ('B', 'Put That Anywhere', 'Defeat 10 enemies by detonating ammo crates', + 'Coloque isso em qualquer lugar', 'Derrote 10 inimigos ao detonar caixas de munição', + 'Pon eso donde sea', 'Derrota a 10 enemigos detonando cajas de munición.'), + ('B', 'Hat Trick', 'Defeat an enemy using three different power sources', + 'Truque de mestre', 'Derrote um inimigo usando três fontes de poder diferentes', + 'Truco ofensivo', 'Derrota a un enemigo utilizando tres fuentes de poder diferentes.'), + ('B', 'Double Smoke Stack Attack', + 'Dash through two red air vents without touching the ground and then Comet Drop on an enemy', + 'Ataque duplo de fumaça em sequência', + 'Passe rápido por duas saídas de ar vermelhas sem tocar o chão e caia como um cometa no inimigo', + 'Ataque de chimenea doble', + 'Corre por dos conductos de ventilación rojos sin tocar el suelo y haz un Cometa sobre un enemigo.'), + ('B', 'Cough Drop', 'Comet Drop on three coughing enemies', + 'Pastilha para tosse', 'Execute o cometa em 3 inimigos com tosse', + 'Caída antitós', 'Realiza un Cometa sobre 3 enemigos que tosen.'), + ('B', 'Make Fetch Proud', 'Hit three weak points with Neon Beam in 6 seconds', + 'Dê orgulho a Fetch', 'Atinja 3 pontos fracos com feixe de neon em 6 segundos', + 'Que Fetch esté orgullosa', 'Alcanza 3 puntos débiles con Rayo de neón en 6 segundos.'), + ('B', 'Excessive Force', 'Use a Karmic Streak attack to defeat exactly one enemy', + 'Força excessiva', 'Use um ataque de sequência cármica para derrotar exatamente um inimigo', + 'Fuerza excesiva', 'Usa un ataque de Racha kármica para derrotar exactamente a un enemigo.'), +] + + +def language_column(language): + """0 English, 1 Portuguese, 2 Spanish from a PS4 language id (ISSN_LANGUAGE).""" + try: + value = int(language) + except (TypeError, ValueError): + value = 1 + return 1 if value in (7, 17) else 2 if value in (3, 20) else 0 + + +def texts(language=1): + """{id: {'grade', 'base', 'hidden', 'name', 'detail'}} like trophies.read_texts.""" + column = language_column(language) + result = {} + for trophy_id, (grade, *names) in enumerate(TROPHIES): + result[trophy_id] = {'grade': grade, 'base': grade != 'P', 'hidden': False, + 'name': names[2 * column], 'detail': names[2 * column + 1]} + return result diff --git a/tests/test_launcher_trophies.py b/tests/test_launcher_trophies.py index 0a1231b..d2586a7 100644 --- a/tests/test_launcher_trophies.py +++ b/tests/test_launcher_trophies.py @@ -77,16 +77,15 @@ class LauncherTrophiesTests(unittest.TestCase): self.app.trophy_key_entry.insert(0, KEY.hex()) self.app.save_trophy_key() self.assertEqual(trophies.read_key(self.user), KEY) - # Names in the game language (Brazilian Portuguese: TROP_17); a locked hidden trophy stays hidden. + # The name in the game language (Brazilian Portuguese: TROP_17) only for the unlocked trophy. texts = [self.labels(card)[0] for card in self.cards()] - self.assertEqual(texts, ['Segundo', 'Tudo', 'Primeiro passo', 'Extra um']) + self.assertEqual(texts, ['Segundo', 'Trophy 0', 'Trophy 1', 'Trophy 3']) + self.assertEqual(self.labels(self.cards()[0])[1], 'Oculto\nUnlocked 2026-11-04 23:50:12') + self.assertEqual(self.labels(self.cards()[1])[1], 'Locked') self.assertIn('Key saved', self.app.trophy_key_state.cget('text')) - (self.user / trophies.UNLOCKED_FILE).write_text('', encoding='utf-8') - self.app.refresh_trophies() - self.assertIn('Hidden trophy', [self.labels(card)[0] for card in self.cards()]) self.app.remove_trophy_key() self.assertIsNone(trophies.read_key(self.user)) - self.assertEqual(self.labels(self.cards()[0])[0], 'Trophy 0') + self.assertEqual(self.labels(self.cards()[0])[0], 'Trophy 2') # not inFAMOUS: no list either if __name__ == '__main__': diff --git a/tests/test_trophies.py b/tests/test_trophies.py index e90e9d1..fcfbad6 100644 --- a/tests/test_trophies.py +++ b/tests/test_trophies.py @@ -8,6 +8,7 @@ import unittest import paths # noqa: F401 import trophies +import trophy_list KEY = bytes.fromhex('00112233445566778899AABBCCDDEEFF') # made up for the test COMM = 'NPWR99999_00' @@ -150,5 +151,49 @@ class TrophyFileTest(unittest.TestCase): self.assertNotIn('ISSN_TROPHY_FILE', env) +class BuiltinListTest(unittest.TestCase): + """The port's own names for inFAMOUS Second Son (no key): 48 trophies, the platinum first.""" + + def test_list_shape(self): + self.assertEqual(len(trophy_list.TROPHIES), 48) + grades = [t[0] for t in trophy_list.TROPHIES] + self.assertEqual((grades.count('P'), grades.count('G'), grades.count('S'), grades.count('B')), (1, 2, 11, 34)) + self.assertEqual(grades[0], 'P') + for row in trophy_list.TROPHIES: + self.assertEqual(len(row), 7) + self.assertTrue(all(text.strip() == text and text for text in row[1:]), row) + + def test_languages(self): + self.assertEqual(trophy_list.texts(17)[6]['name'], 'Coragem') + self.assertEqual(trophy_list.texts('1')[14]['name'], 'Everybody Out') + self.assertEqual(trophy_list.texts(20)[39]['name'], 'Autocarnicería total') + self.assertEqual(trophy_list.texts('x')[0]['name'], 'Enjoy Your Powers') + texts = trophy_list.texts(17) + self.assertFalse(texts[0]['base']) + self.assertTrue(all(texts[i]['base'] for i in range(1, 48))) + + def test_used_only_for_this_game(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + game, user = root / 'dump/Image0', root / 'user' + (game / 'sce_sys').mkdir(parents=True) + user.mkdir() + trp = root / 'dump/Sc0/trophy/trophy00.trp' + make_trp(trp, [(f'TROP{i:03d}.PNG', png(i), 0) for i in range(48)]) + env = {'ISSN_GAME_DIR': str(game), 'ISSN_LANGUAGE': '17'} + # Another game (no title ID, no NP id): no names. + self.assertIn('no names', trophies.prepare(env, user)) + self.assertFalse((user / trophies.NAMES_FILE).exists()) + # inFAMOUS Second Son's NP id in npbind.dat beside it: the port's list. + (root / 'dump/Sc0/npbind.dat').write_bytes(bytes(64) + trophy_list.COMM_ID.encode() + bytes(20)) + self.assertIn("names: 48 (the port's list)", trophies.prepare(env, user)) + lines = (user / trophies.NAMES_FILE).read_text(encoding='utf-8').splitlines() + self.assertEqual(lines[1], '0\tP\t0\tAproveite seus poderes') + self.assertEqual(lines[7], '6\tB\t1\tCoragem') + # A different number of trophies is another game, whatever the ids say. + make_trp(trp, [(f'TROP{i:03d}.PNG', png(i), 0) for i in range(10)]) + self.assertIsNone(trophies.builtin_texts(trp, 17, game)) + + if __name__ == '__main__': unittest.main()