This commit is contained in:
Guilherme committed 2026-10-27 11:29:30 -03:00
commit bb3ce7fa04
12 files changed
+1630 -130

No files matched your search

+2
View File
@@ -138,6 +138,8 @@ jobs:
run: |
DLSS_SDK_ROOT=$(cygpath -u "$GITHUB_WORKSPACE")/dlss-sdk \
CMAKE_EXE="/c/Program Files/CMake/bin/cmake.exe" bash packaging/windows/build_dlss.sh
- name: Regression - speculative fault recovery
run: python -m unittest discover -s tests -p test_windows_fault_recovery.py -v
- name: Package
env:
MSYS2_ROOT: ${{ runner.temp }}/msys64
+418
View File
@@ -0,0 +1,418 @@
{
"baseline_commit": "b8d5f412398fae52a444400a9b5131bc1c970cc3",
"unchanged_methods": [
"__init__",
"pt",
"t",
"filters_changed",
"picked_language",
"refresh_language_choices",
"check",
"changed",
"refresh_status",
"browse",
"open",
"clear_cache",
"append",
"play",
"run_game",
"pump",
"quit"
],
"presentation_methods_changed": [
"build",
"load_art",
"build_filters",
"combo",
"build_game_language",
"remap_dialog",
"mods_dialog"
],
"contrast": {
"pairs": {
"main": {
"foreground": "#283d35",
"background": "#f8f5ed",
"ratio": 10.654
},
"secondary": {
"foreground": "#536158",
"background": "#eeeae0",
"ratio": 5.431
},
"links": {
"foreground": "#48644f",
"background": "#eeeae0",
"ratio": 5.444
},
"selection": {
"foreground": "#f4f0e4",
"background": "#283d35",
"ratio": 10.188
},
"popup_selection": {
"foreground": "#f4f0e4",
"background": "#48644f",
"ratio": 5.739
},
"disabled": {
"foreground": "#536158",
"background": "#d5d9cc",
"ratio": 4.544
},
"control_border": {
"foreground": "#7a8477",
"background": "#f8f5ed",
"ratio": 3.573
}
},
"hero": [
{
"language": "pt_BR",
"geometry": "1040x700",
"text": "UM VÍNCULO ALÉM DAS PALAVRAS",
"minimum_ratio": 4.732,
"threshold": 4.5
},
{
"language": "pt_BR",
"geometry": "1040x700",
"text": "THE LAST\nGUARDIAN",
"minimum_ratio": 3.269,
"threshold": 3.0
},
{
"language": "pt_BR",
"geometry": "1040x700",
"text": "Um menino. Um companheiro misterioso.\nUma jornada extraordinária espera por você.",
"minimum_ratio": 4.769,
"threshold": 4.5
},
{
"language": "pt_BR",
"geometry": "1040x700",
"text": "RECOMPILAÇÃO PARA PC / HAYATOG",
"minimum_ratio": 5.597,
"threshold": 4.5
},
{
"language": "pt_BR",
"geometry": "1240x820",
"text": "UM VÍNCULO ALÉM DAS PALAVRAS",
"minimum_ratio": 8.102,
"threshold": 4.5
},
{
"language": "pt_BR",
"geometry": "1240x820",
"text": "THE LAST\nGUARDIAN",
"minimum_ratio": 5.597,
"threshold": 3.0
},
{
"language": "pt_BR",
"geometry": "1240x820",
"text": "Um menino. Um companheiro misterioso.\nUma jornada extraordinária espera por você.",
"minimum_ratio": 5.258,
"threshold": 4.5
},
{
"language": "pt_BR",
"geometry": "1240x820",
"text": "RECOMPILAÇÃO PARA PC / HAYATOG",
"minimum_ratio": 10.529,
"threshold": 4.5
},
{
"language": "pt_BR",
"geometry": "1920x1080",
"text": "UM VÍNCULO ALÉM DAS PALAVRAS",
"minimum_ratio": 9.465,
"threshold": 4.5
},
{
"language": "pt_BR",
"geometry": "1920x1080",
"text": "THE LAST\nGUARDIAN",
"minimum_ratio": 13.835,
"threshold": 3.0
},
{
"language": "pt_BR",
"geometry": "1920x1080",
"text": "Um menino. Um companheiro misterioso.\nUma jornada extraordinária espera por você.",
"minimum_ratio": 11.06,
"threshold": 4.5
},
{
"language": "pt_BR",
"geometry": "1920x1080",
"text": "RECOMPILAÇÃO PARA PC / HAYATOG",
"minimum_ratio": 13.993,
"threshold": 4.5
},
{
"language": "en_US",
"geometry": "1040x700",
"text": "A BOND BEYOND WORDS",
"minimum_ratio": 4.732,
"threshold": 4.5
},
{
"language": "en_US",
"geometry": "1040x700",
"text": "THE LAST\nGUARDIAN",
"minimum_ratio": 3.269,
"threshold": 3.0
},
{
"language": "en_US",
"geometry": "1040x700",
"text": "A boy. A mysterious companion.\nAn extraordinary journey awaits.",
"minimum_ratio": 5.174,
"threshold": 4.5
},
{
"language": "en_US",
"geometry": "1040x700",
"text": "PC RECOMPILATION / HAYATOG",
"minimum_ratio": 5.597,
"threshold": 4.5
},
{
"language": "en_US",
"geometry": "1240x820",
"text": "A BOND BEYOND WORDS",
"minimum_ratio": 9.121,
"threshold": 4.5
},
{
"language": "en_US",
"geometry": "1240x820",
"text": "THE LAST\nGUARDIAN",
"minimum_ratio": 5.597,
"threshold": 3.0
},
{
"language": "en_US",
"geometry": "1240x820",
"text": "A boy. A mysterious companion.\nAn extraordinary journey awaits.",
"minimum_ratio": 7.452,
"threshold": 4.5
},
{
"language": "en_US",
"geometry": "1240x820",
"text": "PC RECOMPILATION / HAYATOG",
"minimum_ratio": 11.938,
"threshold": 4.5
},
{
"language": "en_US",
"geometry": "1920x1080",
"text": "A BOND BEYOND WORDS",
"minimum_ratio": 9.465,
"threshold": 4.5
},
{
"language": "en_US",
"geometry": "1920x1080",
"text": "THE LAST\nGUARDIAN",
"minimum_ratio": 13.835,
"threshold": 3.0
},
{
"language": "en_US",
"geometry": "1920x1080",
"text": "A boy. A mysterious companion.\nAn extraordinary journey awaits.",
"minimum_ratio": 11.06,
"threshold": 4.5
},
{
"language": "en_US",
"geometry": "1920x1080",
"text": "PC RECOMPILATION / HAYATOG",
"minimum_ratio": 13.993,
"threshold": 4.5
}
],
"dialog_text": [
{
"text": "PS4 button",
"ratio": 9.663
},
{
"text": "Keys / mouse",
"ratio": 9.663
},
{
"text": "Default",
"ratio": 9.663
},
{
"text": "PS4 button",
"ratio": 9.663
},
{
"text": "Keys / mouse",
"ratio": 9.663
},
{
"text": "Default",
"ratio": 9.663
},
{
"text": "Cross (X) — jump",
"ratio": 9.663
},
{
"text": "Space",
"ratio": 5.431
},
{
"text": "Circle",
"ratio": 9.663
},
{
"text": "Left Shift",
"ratio": 5.431
},
{
"text": "Square",
"ratio": 9.663
},
{
"text": "E|Mouse Middle",
"ratio": 5.431
},
{
"text": "Triangle — grab, hold on",
"ratio": 9.663
},
{
"text": "Q|Mouse Left",
"ratio": 5.431
},
{
"text": "L1",
"ratio": 9.663
},
{
"text": "1",
"ratio": 5.431
},
{
"text": "R1 — call Trico",
"ratio": 9.663
},
{
"text": "3|Mouse Right",
"ratio": 5.431
},
{
"text": "L2",
"ratio": 9.663
},
{
"text": "R",
"ratio": 5.431
},
{
"text": "R2",
"ratio": 9.663
},
{
"text": "F",
"ratio": 5.431
},
{
"text": "L3",
"ratio": 9.663
},
{
"text": "Z",
"ratio": 5.431
},
{
"text": "R3",
"ratio": 9.663
},
{
"text": "C",
"ratio": 5.431
},
{
"text": "Options — menu",
"ratio": 9.663
},
{
"text": "Return",
"ratio": 5.431
},
{
"text": "Touchpad (left) — photo mode",
"ratio": 9.663
},
{
"text": "Tab",
"ratio": 5.431
},
{
"text": "Touchpad (right) — pause",
"ratio": 9.663
},
{
"text": "Backspace",
"ratio": 5.431
},
{
"text": "D-pad up",
"ratio": 9.663
},
{
"text": "I",
"ratio": 5.431
},
{
"text": "D-pad down",
"ratio": 9.663
},
{
"text": "K",
"ratio": 5.431
},
{
"text": "D-pad left",
"ratio": 9.663
},
{
"text": "J",
"ratio": 5.431
},
{
"text": "D-pad right",
"ratio": 9.663
},
{
"text": "L",
"ratio": 5.431
},
{
"text": "Click Set and press a key or mouse button, or type SDL names (Space, Left Shift, Mouse Left, Mouse X1, Wheel Up…). \"|\" gives several: Q|Mouse Left. \"none\": no key. Empty: the default (grey). Applies at the next start.",
"ratio": 5.431
},
{
"text": "Drop shadPS4/GoldHEN cheat XML files in the mods folder, then turn them on here. Nothing here changes the game until you enable it.",
"ratio": 9.663
},
{
"text": "A cheat can crash the game or behave oddly; if that happens, turn it off here.",
"ratio": 5.411
},
{
"text": "No cheat XML files found in the mods folder yet.",
"ratio": 5.431
}
]
}
}
+112
View File
@@ -0,0 +1,112 @@
# Auditoria da interface do launcher
Data: 17 de outubro de 2026.
## Resultado e alcance
**100% dos controles inventariados na UI antiga estão presentes na nova UI.**
Nenhuma opção, faixa numérica, alternativa de seletor, ação principal ou ação dos
diálogos foi removida. A nova UI acrescenta cinco botões de navegação e uma barra
horizontal no log. A comparação foi feita instanciando ambas as interfaces com
configurações isoladas e comparando seus widgets, além de comparar o código dos callbacks.
Isso representa paridade do inventário funcional da interface, não uma declaração
de 100% de cobertura de linhas ou de validação do jogo completo. Os testes de
preparação/execução usam serviços e subprocessos simulados; nenhum jogo foi iniciado.
A inspeção visual foi realizada no Windows com Tk/ttk `clam`; Linux não foi inspecionado.
Base anterior ao redesign: `b8d5f412398fae52a444400a9b5131bc1c970cc3`,
`port/launcher/tlg_launcher.py`. O inventário original está congelado em
[`launcher_ui_legacy.json`](../port/tests/data/launcher_ui_legacy.json).
## Paridade dos controles
As linhas abaixo se sobrepõem por função; não devem ser somadas.
| Grupo | UI antiga → nova | Localização atual / evidência |
| --- | --- | --- |
| Pastas do jogo e dos módulos | 2 → 2 campos; 2 → 2 ações Procurar | Jogo e idioma; escolha de pasta e persistência verificadas |
| Idioma do jogo e da interface | 2 → 2 seletores | Jogo e idioma; eventos reais de seleção, idiomas US/EU e reconstrução da interface verificados |
| Modo de exibição e FPS | 2 → 2 seletores, com as mesmas alternativas | Imagem e vídeo; todas as escolhas exercitadas |
| Áudio nativo, VSync, overlay FPS, câmera no mouse, inverter Y, SMAA e ajustes de cor | 7 → 7 checkboxes | As sete opções alternam e persistem pelos callbacks existentes |
| VRAM, sensibilidade e quatro ajustes de cor | 6 → 6 campos numéricos | Mesmas faixas e incrementos; persistência e limites dos filtros verificados |
| Nitidez | 1 → 1 seletor | Desligada, RCAS e CAS; valores correspondentes no INI verificados |
| Ações principais | 9 → 9 botões existentes, mais 5 de navegação | Procurar ×2, remapear, Sair, Jogar, logs, mods, saves e limpar cache |
| Links externos | 3 → 3 | YouTube, Discord e apoio; destinos verificados sem abrir sites |
| Remapeamento | 17 → 17 campos; 19 → 19 botões | Os 17 Definir, Restaurar tudo e Fechar continuam disponíveis; gravação e restauração verificadas |
| Mods | 3 → 3 botões | Abrir pasta, atualizar e fechar; lista e ativação verificadas com mod temporário simulado |
| Registro | Log e atualização preservados | Saída, conclusão, erros e preservação após troca de idioma verificados |
| Estado de execução | Regras preservadas | Pronto, arquivos ausentes, jogo em execução, bloqueio de Jogar/cache, confirmação do cache e Sair verificados |
Os conjuntos de variáveis são idênticos: **9 variáveis de configurações + 6 de
filtros**, além dos cinco seletores. Uma comparação estrutural da AST confirmou
que **17 métodos existentes permanecem idênticos**, incluindo `changed`,
`filters_changed`, `refresh_status`, `play`, `run_game`, `pump`, `clear_cache` e `quit`.
Os sete métodos antigos alterados cuidam da construção/apresentação da UI; as
funções aninhadas de remapeamento e mods mantêm sua lógica.
## Problemas de contraste encontrados e corrigidos
1. **Seletor com foco:** o mapa herdado de `clam` em `readonly + focus` aplicava
texto branco (`#ffffff`) ao fundo claro (`#f8f5ed`): **1,09:1**. Agora o texto
normal/com foco é `#283d35`; texto selecionado usa marfim sobre verde escuro.
2. **Textos secundários e links:** mediam **4,24:1** e **4,32:1** sobre o fundo
principal. Os tons foram escurecidos, preservando a paleta verde.
3. **Seleção em campos de texto e numéricos:** removida a dependência das cores
herdadas do sistema, inclusive quando o campo perde o foco. A lista suspensa
usa uma paleta explícita e foi inspecionada separadamente da área do campo.
4. **Diálogos:** os padrões do remapeamento e textos dos mods usavam cinzas que
chegavam a **2,95:1**. Agora usam a cor secundária verificada.
5. **Texto sobre a arte:** o rodapé pequeno da abertura chegava a **2,99:1** no
tamanho mínimo. Sua cor foi clareada; todos os textos avaliados agora passam
o limiar aplicável.
6. **Controles:** bordas dos campos e marca de checkbox ganharam contraste.
A seleção do texto do log também tem cores explícitas.
## Medições após a correção
Referência usada: [WCAG 2.2 — contraste de texto](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html),
**4,5:1 para texto comum** e **3:1 para texto grande**. Para bordas e indicadores,
a referência é [contraste não textual](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html), **3:1**.
Controles desativados são isentos pelo critério, mas também receberam texto legível.
Esta é uma verificação de contraste da UI, não uma certificação integral WCAG.
| Elemento | Cores de texto / fundo | Contraste |
| --- | --- | --- |
| Campos e texto principal nos cartões | `#283d35` / `#f8f5ed` | **10,65:1** |
| Texto secundário no fundo principal | `#536158` / `#eeeae0` | **5,43:1** |
| Links e legendas verdes | `#48644f` / `#eeeae0` | **5,44:1** |
| Seleção de texto nos campos | `#f4f0e4` / `#283d35` | **10,19:1** |
| Item selecionado da lista suspensa | `#f4f0e4` / `#48644f` | **5,74:1** |
| Texto em controle desativado | `#536158` / `#d5d9cc` | **4,54:1** |
| Borda de campo sobre seu fundo | `#7a8477` / `#f8f5ed` | **3,57:1** |
| Menor contraste medido em texto dos diálogos | Cores efetivas dos widgets | **5,41:1** |
| Menor contraste de texto comum sobre a arte | Pixels de fundo sob os limites dos textos | **4,73:1** |
| Menor contraste do título grande sobre a arte | Pixels de fundo sob os limites do título | **3,27:1** |
Os estados normal, foco, readonly, seleção, desativado e suas combinações foram
verificados nos estilos dos campos; botões foram verificados também em hover e
pressionados. A lista suspensa foi consultada em seu próprio widget Tcl.
Na abertura, foi reconstruído o fundo sem texto, usando a arte real e seu
posicionamento no canvas. Foi medida a pior cor de fundo sob cada caixa de texto
em **1040×700, 1240×820 e 1920×1080**, em português e inglês. O título permanece
acima de 18 pontos e foi avaliado com o limiar de 3:1. Os resultados detalhados
estão em [`launcher-ui-audit-results.json`](launcher-ui-audit-results.json).
## Regressão automatizada
**12 testes de UI passaram.** Executar da raiz do repositório:
```powershell
python -m unittest discover -s port/tests -p test_launcher_ui.py -v
```
Os testes usam arquivos temporários em `port/out/`, preservam as configurações do
usuário e não iniciam o jogo, abrem pastas reais nem seguem links externos.
O arquivo de teste registra tanto a paridade histórica quanto o contraste dos
estados que causaram o problema do campo branco.
As alterações de correção limitam-se à apresentação em `tlg_launcher.py`, aos
testes e à documentação desta auditoria. Preparação, execução e persistência
continuam usando a implementação existente.
@@ -0,0 +1,67 @@
# Windows: deadlock entre recuperação especulativa e readback da GPU
## Evidência
O log local `run-20261017-202612.log` parou após 2.427 frames. O relatório
`FREEZE` mostrou `tlg:DrawPrep1` e `workerThread1` dentro do handler de page fault,
aguardando `GPU thread (command)`. O thread principal e os demais workers ficaram
aguardando a renderização. O contador de frames permaneceu parado enquanto os
vblanks continuavam avançando.
## Causa
`PipelineCache::GetProgram` mantém `programs_mutex` compartilhado enquanto um
worker especulativo executa `RefreshFlatBuf`/`StageSpecialization`. Esses acessos
à memória guest têm um ponto de recuperação (`runtime_fault_recover`), pois os
dados podem estar protegidos pela GPU ou ter sido reutilizados.
No Windows, `vectored_fault` chamava `tlggpu_handle_fault` antes de consultar
esse ponto. O readback podia enfileirar um comando e aguardar a thread da GPU;
essa thread, ao precisar publicar/atualizar shaders, precisava adquirir
`programs_mutex` exclusivamente, ainda retido pelo worker. A espera era circular.
O handler POSIX já recuperava a leitura especulativa antes do readback.
## Correção
O handler Windows agora recupera o acesso especulativo primeiro, usando o
trampolim `tlg_longjmp` existente. O worker abandona o preparo daquele draw e
libera o lock. Apenas acessos sem ponto de recuperação entram no rastreamento e
readback da GPU. Os readbacks precisos e os workers permanecem habilitados.
## Regressão
`test_windows_fault_recovery.py` extrai o handler de produção e o compila com
`compat_win.c`. Usa páginas Windows realmente protegidas e verifica:
- 100 falhas especulativas recuperadas sem chamar o callback da GPU;
- limpeza do ponto de recuperação em TLS;
- falhas normais de leitura e escrita ainda encaminhadas ao rastreamento da GPU.
O mesmo teste **falha com o handler anterior**, código 99, e **passa com a
correção**. O callback simulado falha imediatamente no caminho proibido para não
deixar o teste preso no deadlock. A etapa foi incluída no CI Windows.
```powershell
python -m unittest discover -s port/tests -p test_windows_fault_recovery.py -v
```
É necessário um compilador MSYS2 CLANG64 no PATH ou em `TLG_TEST_CLANG`.
Os 12 testes da interface também passaram. O motor completo foi recompilado com
Clang/MSYS2 e suas DLLs foram empacotadas a partir da árvore de imports.
A instalação original e os saves do usuário foram preservados.
## Validação em execução
Na sessão local `run-20261017-203640.log`, a build recompilada ultrapassou três
minutos na tela inicial/menu, com estatísticas recentes de 30 FPS e sem relatório
`FREEZE`. O menu respondeu à entrada e abriu a opção New Game. O teste preservou
os 8 workers DrawPrep e o readback preciso; não desativou a otimização nem ocultou
o detector de congelamento. Isso valida a inicialização e a continuidade da
renderização; a campanha completa não foi testada.
Em 18 de outubro de 2026, o usuário executou novamente o launcher com a build
corrigida e confirmou que o jogo funciona. Essa confirmação complementa o teste
local de inicialização/menu; não representa validação da campanha completa.
Artefatos locais: `port/dist/tlg-ui-test-windows-fixed.zip` e a cópia de teste
existente atualizada em `port/dist/tlg-ui-test-windows/`.
+22
View File
@@ -0,0 +1,22 @@
# Launcher visual references
The presentation uses warm parchment, desaturated stone, moss green, soft light and
serif titles, inspired by the official environments and Trico's plumage. The initial
screen gives the artwork room; settings and the session log have separate pages.
English and Brazilian Portuguese share the same layout.
## Official artwork
`guardian-hero.png` is a presentation crop of Sony's official screenshot, resized
and shaded for text legibility. It is loaded locally, with no network requests or
additional Python dependencies. The original artwork remains © Sony Interactive
Entertainment; it is not covered by the source code's GPL license.
- Official game page: https://www.playstation.com/en-us/games/the-last-guardian/
- Official gallery: https://store.playstation.com/en-us/product/UP9000-CUSA03627_00-LASTGUARDIANUS00
- Hero original: https://image.api.playstation.com/cdn/UP9000/CUSA03627_00/FREE_CONTENTrz6xdLO2VRKfw0Cfax6D/PREVIEW_SCREENSHOT1_118520.jpg
- E3 reference: https://blog.playstation.com/2015/06/15/the-last-guardian-coming-to-ps4-in-2016/
- Creative direction reference: https://blog.playstation.com/2016/06/21/the-last-guardian-5-storytelling-secrets/
Only launcher presentation is changed. Game preparation, settings persistence,
rendering, input mappings, mods and the game launch path use the existing callbacks.
Binary file not shown.

After

Width:  |  Height:  |  Size: 908 KiB

+367 -130
View File
@@ -15,6 +15,7 @@ import sys
import threading
import time
import tkinter as tk
from tkinter import font as tkfont
from tkinter import filedialog, messagebox, ttk
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -22,6 +23,25 @@ import logtail # noqa: E402
import tlg_run # noqa: E402
TEXT = {
'journey': ('Journey', 'Jornada'),
'setup': ('Game & language', 'Jogo e idioma'),
'image': ('Image & display', 'Imagem e vídeo'),
'journal': ('Session log', 'Registro da sessão'),
'eyebrow': ('A BOND BEYOND WORDS', 'UM VÍNCULO ALÉM DAS PALAVRAS'),
'hero_copy': ('A boy. A mysterious companion.\nAn extraordinary journey awaits.',
'Um menino. Um companheiro misterioso.\nUma jornada extraordinária espera por você.'),
'edition': ('PC RECOMPILATION', 'RECOMPILAÇÃO PARA PC'),
'home_note': ('Before the journey', 'Antes da jornada'),
'home_help': ('Choose your game folder in Game & language. Fine-tune your experience in Image & display and Controls.',
'Selecione a pasta do jogo em Jogo e idioma. Ajuste sua experiência em Imagem e vídeo e Controles.'),
'setup_hint': ('Your game, your language. Settings are saved automatically.',
'Seu jogo, seu idioma. As configurações são salvas automaticamente.'),
'image_hint': ('Find the right balance for your journey.', 'Encontre o equilíbrio para sua jornada.'),
'controls_hint': ('A little closer to Trico.', 'Um pouco mais perto de Trico.'),
'journal_hint': ('Follow the game output and find details about your session.',
'Acompanhe a saída do jogo e os detalhes da sua sessão.'),
'files': ('Files & maintenance', 'Arquivos e manutenção'),
'automatic_save': ('Settings saved automatically', 'Configurações salvas automaticamente'),
'title': ('The Last Guardian — PC', 'The Last Guardian — PC'),
'game': ('Game', 'Jogo'),
'game_dir': ('Game folder', 'Pasta do jogo'),
@@ -289,171 +309,383 @@ class Launcher:
return (pt if self.pt() else en).format(**fmt)
# -- window -----------------------------------------------------------------------------
def configure_theme(self):
"""Stone, parchment and moss; all controls remain native Tk/ttk widgets."""
self.colors = {'paper': '#eeeae0', 'card': '#f8f5ed', 'ink': '#283d35',
'muted': '#536158', 'line': '#d4d6c8', 'accent': '#48644f',
'control_border': '#7a8477', 'disabled': '#d5d9cc',
'dark': '#1a2420', 'light': '#f4f0e4'}
c = self.colors
families = set(tkfont.families(self.root))
self.serif = next((f for f in ('Palatino Linotype', 'Book Antiqua', 'DejaVu Serif')
if f in families), 'Times')
self.sans = next((f for f in ('Segoe UI', 'Noto Sans', 'DejaVu Sans')
if f in families), 'TkDefaultFont')
self.root.configure(background=c['paper'])
self.root.option_add('*TCombobox*Listbox.background', c['card'])
self.root.option_add('*TCombobox*Listbox.foreground', c['ink'])
self.root.option_add('*TCombobox*Listbox.selectBackground', c['accent'])
self.root.option_add('*TCombobox*Listbox.selectForeground', c['light'])
style = ttk.Style(self.root)
style.theme_use('clam')
style.configure('.', font=(self.sans, 10), background=c['paper'], foreground=c['ink'],
bordercolor=c['line'], lightcolor=c['line'], darkcolor=c['line'],
troughcolor=c['paper'], focuscolor=c['accent'])
style.map('.', foreground=[('disabled', c['muted'])])
style.configure('TFrame', background=c['paper'])
style.configure('TLabel', background=c['paper'], foreground=c['ink'])
style.configure('Muted.TLabel', foreground=c['muted'])
style.configure('Heading.TLabel', font=(self.serif, 25), foreground=c['ink'])
style.configure('Brand.TLabel', font=(self.serif, 17))
style.configure('Eyebrow.TLabel', font=(self.sans, 8, 'bold'), foreground=c['accent'])
style.configure('TLabelframe', background=c['card'], borderwidth=1, relief='solid')
style.configure('TLabelframe.Label', background=c['paper'], foreground=c['accent'],
font=(self.sans, 10, 'bold'))
style.configure('Card.TFrame', background=c['card'])
style.configure('Card.TLabel', background=c['card'])
style.configure('TButton', background=c['card'], foreground=c['ink'], padding=(14, 9),
borderwidth=1, relief='flat')
style.map('TButton', background=[('active', '#e0e4d7'), ('pressed', '#d1daca')],
foreground=[('disabled', c['muted'])], bordercolor=[('focus', c['accent'])])
style.configure('Nav.TButton', background=c['paper'], padding=(18, 12),
font=(self.sans, 10), borderwidth=0)
style.configure('Selected.Nav.TButton', background=c['ink'], foreground=c['light'])
style.map('Selected.Nav.TButton', background=[('active', c['accent'])],
foreground=[('active', c['light'])])
style.configure('Play.TButton', background=c['ink'], foreground=c['light'],
font=(self.sans, 13, 'bold'), padding=(38, 14))
style.map('Play.TButton', background=[('disabled', c['disabled']), ('active', '#42604d'),
('pressed', '#1a3026')],
foreground=[('disabled', c['muted']), ('active', c['light'])])
style.configure('TEntry', fieldbackground=c['card'], padding=8, insertcolor=c['ink'])
style.configure('TCombobox', fieldbackground=c['card'], background=c['card'],
foreground=c['ink'], arrowcolor=c['accent'], padding=7)
style.configure('TSpinbox', fieldbackground=c['card'], background=c['card'],
arrowcolor=c['accent'], padding=6)
# Clam makes readonly+focus combobox text white. Override the foreground
# independently of text selection, including inactive selections in entries.
for field_style in ('TEntry', 'TCombobox', 'TSpinbox'):
style.configure(field_style, foreground=c['ink'], selectforeground=c['light'],
selectbackground=c['ink'], bordercolor=c['control_border'],
lightcolor=c['control_border'], darkcolor=c['control_border'])
style.map(field_style,
foreground=[('disabled', c['muted']), ('!disabled', c['ink'])],
fieldbackground=[('disabled', c['disabled']), ('!disabled', c['card'])],
selectforeground=[('disabled', c['muted']), ('!disabled', c['light'])],
selectbackground=[('disabled', c['disabled']), ('!disabled', c['ink'])],
bordercolor=[('focus', c['ink']), ('!focus', c['control_border'])],
lightcolor=[('focus', c['ink']), ('!focus', c['control_border'])],
darkcolor=[('focus', c['ink']), ('!focus', c['control_border'])])
style.configure('TCheckbutton', background=c['card'], padding=(0, 7),
indicatorbackground=c['paper'], indicatorforeground=c['ink'],
upperbordercolor=c['control_border'], lowerbordercolor=c['control_border'])
style.map('TCheckbutton', background=[('active', c['card'])],
indicatorbackground=[('selected', c['accent'])],
indicatorforeground=[('selected', c['light'])])
style.configure('Vertical.TScrollbar', background=c['control_border'], borderwidth=0,
arrowsize=12, troughcolor=c['paper'])
style.configure('TSeparator', background=c['line'])
def build(self):
"""Horizontal layout: banner on top; Game + Audio | Language + Video | game log; buttons below."""
"""Presentation only: existing settings, callbacks and launch path are reused."""
old_log = self.log.get('1.0', 'end-1c') if getattr(self, 'log', None) else ''
current_page = getattr(self, 'current_page', 'journey')
for child in self.root.winfo_children():
child.destroy()
root = self.root
root.title(self.t('title'))
root.minsize(1120, 680)
pad = {'padx': 6, 'pady': 4}
main = ttk.Frame(root, padding=12)
main.pack(fill='both', expand=True)
for column, weight in enumerate((3, 3, 4)):
main.columnconfigure(column, weight=weight, uniform='cols' if column < 2 else '')
main.rowconfigure(2, weight=1)
header = ttk.Frame(main)
header.grid(row=0, column=0, columnspan=3, sticky='ew', pady=(0, 8))
self.configure_theme()
if not getattr(self, 'window_sized', False):
root.geometry('1240x820')
self.window_sized = True
root.minsize(1040, 700)
self.load_art()
if self.banner:
ttk.Label(header, image=self.banner).pack(side='left', padx=(0, 14))
titles = ttk.Frame(header)
titles.pack(side='left', fill='y')
ttk.Label(titles, text='The Last Guardian', font=('TkDefaultFont', 22, 'bold')).pack(anchor='w')
ttk.Label(titles, text='PC — CUSA03627 v1.03', foreground='#777').pack(anchor='w')
main = ttk.Frame(root)
main.pack(fill='both', expand=True)
header = ttk.Frame(main, padding=(28, 18))
header.pack(fill='x')
brand = ttk.Frame(header)
brand.pack(side='left')
ttk.Label(brand, text='THE LAST GUARDIAN', style='Brand.TLabel').pack(anchor='w')
ttk.Label(brand, text=self.t('edition') + ' / CUSA03627 · v1.03',
style='Eyebrow.TLabel').pack(anchor='w', pady=(3, 0))
credits = ttk.Frame(header)
credits.pack(side='right', anchor='ne')
ttk.Label(credits, text=self.t('port_by'), foreground='#777').pack(anchor='e')
credits.pack(side='right')
ttk.Label(credits, text=self.t('port_by'), style='Muted.TLabel').pack(anchor='e')
self.coffee_button = tk.Button(credits, text='☕ ' + self.t('coffee'),
command=lambda: webbrowser.open(COFFEE_URL),
command=lambda: tlg_run.open_external(COFFEE_URL),
background='#FFDD00', foreground='#000000',
activebackground='#FFE873', activeforeground='#000000',
font=('TkDefaultFont', 10, 'bold'), cursor='hand2',
relief='flat', padx=12, pady=7)
self.coffee_button.pack(anchor='e', pady=(6, 8))
for key, url in (('channel', CHANNEL_URL), ('discord', DISCORD_URL), ('sponsor', SPONSOR_URL)):
link = ttk.Label(credits, text=self.t(key), foreground='#2a6fdb', cursor='hand2',
font=('TkDefaultFont', 10, 'underline'))
link.pack(anchor='e')
font=(self.sans, 9, 'bold'), cursor='hand2',
relief='flat', padx=12, pady=6)
self.coffee_button.pack(anchor='e', pady=(6, 0))
links = ttk.Frame(credits)
links.pack(anchor='e', pady=(5, 0))
for index, (key, url) in enumerate((('channel', CHANNEL_URL), ('discord', DISCORD_URL),
('sponsor', SPONSOR_URL))):
link = ttk.Label(links, text=self.t(key), foreground=self.colors['accent'],
cursor='hand2', font=(self.sans, 9))
link.pack(side='left', padx=(16 if index else 0, 0))
link.bind('<Button-1>', lambda _, u=url: tlg_run.open_external(u))
self.status = ttk.Label(titles, text='', foreground='#555', wraplength=420, justify='left')
self.status.pack(anchor='w', pady=(10, 0))
ttk.Separator(main).pack(fill='x', padx=28)
navigation = ttk.Frame(main, padding=(28, 10))
navigation.pack(fill='x')
self.nav_buttons = {}
for key in ('journey', 'setup', 'image', 'controls', 'journal'):
button = ttk.Button(navigation, text=self.t(key), style='Nav.TButton',
command=lambda k=key: self.show_page(k))
button.pack(side='left', padx=(0, 6))
self.nav_buttons[key] = button
self.content = ttk.Frame(main)
self.content.columnconfigure(0, weight=1)
self.content.rowconfigure(0, weight=1)
self.pages = {}
self.vars = {}
left = ttk.Frame(main)
left.grid(row=2, column=0, sticky='nsew', **pad)
left.columnconfigure(0, weight=1)
game = ttk.LabelFrame(left, text=self.t('game'), padding=8)
game.grid(sticky='ew', pady=(0, 8))
game.columnconfigure(0, weight=1)
self.build_journey()
setup = self.settings_page('setup', 'setup_hint')
game = self.card(setup, self.t('game'), 0, 0)
for row, key in enumerate(('game_dir', 'system_lib_dir')):
ttk.Label(game, text=self.t(key)).grid(row=row * 2, column=0, columnspan=2, sticky='w')
ttk.Label(game, text=self.t(key), style='Card.TLabel').grid(
row=row * 2, column=0, columnspan=2, sticky='w', pady=(0, 6))
var = tk.StringVar(value=self.settings[key])
var.trace_add('write', lambda *_: self.changed())
self.vars[key] = var
ttk.Entry(game, textvariable=var).grid(row=row * 2 + 1, column=0, sticky='ew', pady=(0, 6))
ttk.Entry(game, textvariable=var, width=30).grid(
row=row * 2 + 1, column=0, sticky='ew', pady=(0, 18))
ttk.Button(game, text=self.t('browse'), command=lambda k=key: self.browse(k)).grid(
row=row * 2 + 1, column=1, padx=(6, 0), pady=(0, 6))
audio = ttk.LabelFrame(left, text=self.t('audio'), padding=8)
audio.grid(sticky='ew', pady=(0, 8))
self.check(audio, 0, 'native_audio')
controls = ttk.LabelFrame(left, text=self.t('controls'), padding=8)
controls.grid(sticky='ew')
controls.columnconfigure(1, weight=1)
self.check(controls, 0, 'mouse_enabled')
self.check(controls, 1, 'mouse_invert_y')
self.combo(controls, 4, 'menu_confirm', ['cross', 'circle'],
lambda k: 'Cross (X / A)' if k == 'cross' else 'Circle (O / B)')
ttk.Label(controls, text=self.t('mouse_sensitivity')).grid(row=2, column=0, sticky='w', pady=(4, 0))
sens = tk.DoubleVar(value=float(self.settings.get('mouse_sensitivity', 1.0)))
self.vars['mouse_sensitivity'] = sens
sens_spin = ttk.Spinbox(controls, from_=0.1, to=5.0, increment=0.1, format='%.2f', textvariable=sens,
width=6, command=self.changed)
sens_spin.grid(row=2, column=1, sticky='w', padx=6, pady=(4, 0))
sens_spin.bind('<FocusOut>', lambda _: self.changed())
ttk.Button(controls, text=self.t('remap'), command=self.remap_dialog).grid(row=2, column=2, sticky='e', pady=(4, 0))
ttk.Label(controls, text=self.t('mouse_hint'), foreground='#777', wraplength=420, justify='left').grid(
row=3, column=0, columnspan=3, sticky='w', pady=(2, 0))
middle = ttk.Frame(main)
middle.grid(row=2, column=1, sticky='nsew', **pad)
middle.columnconfigure(0, weight=1)
lang = ttk.LabelFrame(middle, text=self.t('language'), padding=8)
lang.grid(sticky='ew', pady=(0, 8))
lang.columnconfigure(1, weight=1)
row=row * 2 + 1, column=1, padx=(8, 0), pady=(0, 18))
lang = self.card(setup, self.t('language'), 0, 1)
self.game_lang_box = None
self.build_game_language(lang)
self.combo(lang, 1, 'ui_language', UI_LANGUAGES,
lambda k: self.t('auto') if k == 'auto' else UI_LANGUAGE_NAMES[k], on_change=self.build)
video = ttk.LabelFrame(middle, text=self.t('video'), padding=8)
video.grid(sticky='ew')
video.columnconfigure(1, weight=1)
lambda k: self.t('auto') if k == 'auto' else UI_LANGUAGE_NAMES[k],
on_change=self.build)
audio = self.card(setup, self.t('audio'), 1, 0, span=2)
self.check(audio, 0, 'native_audio')
image = self.settings_page('image', 'image_hint')
video = self.card(image, self.t('video'), 0, 0)
self.combo(video, 0, 'display_mode', DISPLAY_MODES, lambda k: self.t(k))
self.check(video, 1, 'vsync')
self.check(video, 2, 'show_fps')
self.combo(video, 5, 'frame_rate', tlg_run.FRAME_RATES, lambda k: self.t(f'fps_{k}'))
ttk.Label(video, text=self.t('fps_hint'), foreground='#777', wraplength=320).grid(
row=6, column=0, columnspan=2, sticky='w')
ttk.Label(video, text=self.t('vram'), wraplength=220).grid(row=3, column=0, sticky='w', pady=(6, 0))
ttk.Label(video, text=self.t('vram'), wraplength=230, style='Card.TLabel').grid(
row=3, column=0, sticky='w', pady=(12, 0))
vram = tk.IntVar(value=int(self.settings['vram_budget_mb']))
self.vars['vram_budget_mb'] = vram
spin = ttk.Spinbox(video, from_=2000, to=24000, increment=200, textvariable=vram, width=8,
command=self.changed)
spin.grid(row=3, column=1, sticky='w', padx=6, pady=(6, 0))
spin = ttk.Spinbox(video, from_=2000, to=24000, increment=200,
textvariable=vram, width=8, command=self.changed)
spin.grid(row=3, column=1, sticky='e', pady=(12, 0))
spin.bind('<FocusOut>', lambda _: self.changed())
ttk.Label(video, text=self.t('vram_hint'), foreground='#777', wraplength=360).grid(row=4, column=0, columnspan=2, sticky='w')
ttk.Label(video, text=self.t('vram_hint'), style='Card.TLabel',
foreground=self.colors['muted'], wraplength=420, justify='left').grid(
row=4, column=0, columnspan=2, sticky='w', pady=(10, 18))
self.combo(video, 5, 'frame_rate', tlg_run.FRAME_RATES, lambda k: self.t(f'fps_{k}'))
ttk.Label(video, text=self.t('fps_hint'), style='Card.TLabel',
foreground=self.colors['muted'], wraplength=420, justify='left').grid(
row=6, column=0, columnspan=2, sticky='w', pady=(8, 0))
self.combo(video, 7, 'resolution', tlg_run.RESOLUTIONS, tlg_run.resolution_label)
ttk.Label(video, text=self.t('resolution_hint'), foreground='#777', wraplength=320).grid(
row=8, column=0, columnspan=2, sticky='w')
self.build_filters(video, 9)
debug = ttk.LabelFrame(middle, text=self.t('debug'), padding=8)
debug.grid(sticky='ew', pady=(8, 0))
ttk.Label(video, text=self.t('resolution_hint'), style='Card.TLabel',
foreground=self.colors['muted'], wraplength=420, justify='left').grid(
row=8, column=0, columnspan=2, sticky='w', pady=(8, 0))
filters = self.card(image, self.t('image'), 0, 1)
self.build_filters(filters, 0)
debug = self.card(image, self.t('debug'), 1, 0, span=2)
self.check(debug, 0, 'level_select')
self.check(debug, 1, 'bridge_fix')
ttk.Label(debug, text=self.t('debug_hint'), foreground='#777', wraplength=360, justify='left').grid(
row=2, column=0, columnspan=3, sticky='w')
ttk.Label(debug, text=self.t('debug_hint'), style='Card.TLabel',
foreground=self.colors['muted'], wraplength=760, justify='left').grid(
row=2, column=0, columnspan=3, sticky='w', pady=(10, 0))
log_frame = ttk.LabelFrame(main, text='Log', padding=4)
log_frame.grid(row=2, column=2, sticky='nsew', **pad)
self.log = tk.Text(log_frame, wrap='none', font=('TkFixedFont', 9), state='disabled', width=60)
control_page = self.settings_page('controls', 'controls_hint')
controls = self.card(control_page, self.t('controls'), 0, 0, span=2)
self.check(controls, 0, 'mouse_enabled')
self.check(controls, 1, 'mouse_invert_y')
ttk.Label(controls, text=self.t('mouse_sensitivity'), style='Card.TLabel').grid(
row=2, column=0, sticky='w', pady=(12, 0))
sens = tk.DoubleVar(value=float(self.settings.get('mouse_sensitivity', 1.0)))
self.vars['mouse_sensitivity'] = sens
sens_spin = ttk.Spinbox(controls, from_=0.1, to=5.0, increment=0.1, format='%.2f',
textvariable=sens, width=6, command=self.changed)
sens_spin.grid(row=2, column=1, sticky='w', padx=12, pady=(12, 0))
sens_spin.bind('<FocusOut>', lambda _: self.changed())
ttk.Button(controls, text=self.t('remap'), command=self.remap_dialog).grid(
row=2, column=2, sticky='e', pady=(12, 0))
ttk.Label(controls, text=self.t('mouse_hint'), style='Card.TLabel',
foreground=self.colors['muted'], wraplength=760).grid(
row=3, column=0, columnspan=3, sticky='w', pady=(18, 0))
self.combo(controls, 4, 'menu_confirm', ['cross', 'circle'],
lambda k: 'Cross (X / A)' if k == 'cross' else 'Circle (O / B)')
journal = self.settings_page('journal', 'journal_hint')
tools = ttk.Frame(journal)
tools.grid(row=0, column=0, columnspan=2, sticky='ew', pady=(0, 14))
for key, command in (('saves', lambda: self.open(tlg_run.USER_DIR / 'savedata')),
('logs', lambda: self.open(tlg_run.LOG_DIR)),
('mods', self.mods_dialog)):
ttk.Button(tools, text=self.t(key), command=command).pack(side='left', padx=(0, 8))
self.cache_button = ttk.Button(tools, text=self.t('clear_cache'), command=self.clear_cache)
self.cache_button.pack(side='right')
log_frame = ttk.Frame(journal)
log_frame.grid(row=1, column=0, columnspan=2, sticky='nsew')
journal.rowconfigure(1, weight=1)
self.log = tk.Text(log_frame, wrap='none', font=('TkFixedFont', 10),
background=self.colors['dark'], foreground='#d9e3d0',
insertbackground=self.colors['light'], selectbackground='#45634f',
selectforeground=self.colors['light'],
relief='flat', borderwidth=0, highlightthickness=0,
padx=18, pady=16, width=60, height=16)
self.log.insert('1.0', old_log)
self.log.configure(state='disabled')
scroll = ttk.Scrollbar(log_frame, command=self.log.yview)
self.log.configure(yscrollcommand=scroll.set)
self.log.pack(side='left', fill='both', expand=True)
scroll.pack(side='right', fill='y')
horizontal = ttk.Scrollbar(log_frame, orient='horizontal', command=self.log.xview)
self.log.configure(yscrollcommand=scroll.set, xscrollcommand=horizontal.set)
log_frame.rowconfigure(0, weight=1)
log_frame.columnconfigure(0, weight=1)
self.log.grid(row=0, column=0, sticky='nsew')
scroll.grid(row=0, column=1, sticky='ns')
horizontal.grid(row=1, column=0, sticky='ew')
buttons = ttk.Frame(main)
buttons.grid(row=3, column=0, columnspan=3, sticky='ew', pady=(10, 0))
ttk.Button(buttons, text=self.t('quit'), command=self.quit).pack(side='left')
ttk.Label(buttons, text=self.t('menu_note'), foreground='#555', wraplength=300).pack(side='left', padx=12)
style = ttk.Style()
style.configure('Play.TButton', font=('TkDefaultFont', 13, 'bold'))
self.play_button = ttk.Button(buttons, text=self.t('play'), style='Play.TButton', command=self.play)
self.play_button.pack(side='right', ipadx=30, ipady=8)
ttk.Button(buttons, text=self.t('logs'), command=lambda: self.open(tlg_run.LOG_DIR)).pack(side='right', padx=6)
ttk.Button(buttons, text=self.t('mods'), command=self.mods_dialog).pack(side='right', padx=6)
ttk.Button(buttons, text=self.t('graphics'), command=self.graphics_dialog).pack(side='right', padx=(6, 0))
ttk.Button(buttons, text=self.t('saves'), command=lambda: self.open(tlg_run.USER_DIR / 'savedata')).pack(side='right')
self.cache_button = ttk.Button(buttons, text=self.t('clear_cache'), command=self.clear_cache)
self.cache_button.pack(side='right', padx=(0, 6))
footer = ttk.Frame(main, padding=(28, 16))
footer.pack(side='bottom', fill='x')
ttk.Separator(main).pack(side='bottom', fill='x')
self.content.pack(fill='both', expand=True)
self.play_button = ttk.Button(footer, text='▷ ' + self.t('play'),
style='Play.TButton', command=self.play)
self.play_button.pack(side='right', padx=(24, 0))
ttk.Button(footer, text=self.t('quit'), command=self.quit).pack(side='right')
ttk.Button(footer, text=self.t('graphics'), command=self.graphics_dialog).pack(side='right', padx=(0, 8))
status_frame = ttk.Frame(footer)
status_frame.pack(side='left', fill='x', expand=True)
ttk.Label(status_frame, text=self.t('automatic_save'), style='Eyebrow.TLabel').pack(anchor='w')
self.status = ttk.Label(status_frame, text='', style='Muted.TLabel',
wraplength=690, justify='left')
self.status.pack(anchor='w', pady=(5, 0))
status_frame.bind('<Configure>', lambda event: self.status.configure(
wraplength=max(240, event.width)))
self.show_page(current_page)
self.refresh_status()
def show_page(self, key):
self.current_page = key
self.pages[key].tkraise()
for name, button in self.nav_buttons.items():
button.configure(style='Selected.Nav.TButton' if name == key else 'Nav.TButton')
def settings_page(self, key, hint):
"""Scrollable pages keep every setting reachable at smaller window sizes."""
page = ttk.Frame(self.content)
page.grid(row=0, column=0, sticky='nsew')
self.pages[key] = page
heading = ttk.Frame(page, padding=(32, 18, 32, 20))
heading.pack(fill='x')
ttk.Label(heading, text=self.t(key), style='Heading.TLabel').pack(anchor='w')
ttk.Label(heading, text=self.t(hint), style='Muted.TLabel').pack(anchor='w', pady=(5, 0))
holder = ttk.Frame(page)
holder.pack(fill='both', expand=True, padx=(24, 16), pady=(0, 24))
canvas = tk.Canvas(holder, background=self.colors['paper'], highlightthickness=0)
scroll = ttk.Scrollbar(holder, command=canvas.yview)
canvas.configure(yscrollcommand=scroll.set)
scroll.pack(side='right', fill='y')
canvas.pack(side='left', fill='both', expand=True)
body = ttk.Frame(canvas)
window = canvas.create_window((0, 0), window=body, anchor='nw')
body.columnconfigure((0, 1), weight=1, uniform='cards')
body.bind('<Configure>', lambda _: canvas.configure(scrollregion=canvas.bbox('all')))
def resized(event):
canvas.itemconfigure(window, width=event.width)
canvas.itemconfigure(window, height=max(event.height, body.winfo_reqheight()))
canvas.bind('<Configure>', resized)
# Bind only this page's descendants: comboboxes and spinboxes keep their own wheel behavior.
def wheel(event):
if canvas.winfo_height() < body.winfo_reqheight():
delta = (-1 if event.num == 4 else 1) if event.num in (4, 5) else -int(event.delta / 120)
canvas.yview_scroll(delta, 'units')
return 'break'
def bind_wheel():
def visit(widget):
if not isinstance(widget, (ttk.Combobox, ttk.Spinbox, tk.Text)):
for sequence in ('<MouseWheel>', '<Button-4>', '<Button-5>'):
widget.bind(sequence, wheel)
for child in widget.winfo_children():
visit(child)
visit(body)
page.after_idle(bind_wheel)
return body
def card(self, parent, title, row, column, span=1):
card = ttk.LabelFrame(parent, text=' ' + title + ' ', padding=20)
card.grid(row=row, column=column, columnspan=span, sticky='new', padx=8, pady=(6, 18))
card.columnconfigure(0, weight=1)
return card
def build_journey(self):
page = ttk.Frame(self.content)
page.grid(row=0, column=0, sticky='nsew')
self.pages['journey'] = page
canvas = tk.Canvas(page, background=self.colors['dark'], highlightthickness=0, height=430)
canvas.pack(fill='both', expand=True)
art = canvas.create_image(0, 0, image=self.banner, anchor='ne') if self.banner else None
items = []
items.append(canvas.create_text(56, 92, text=self.t('eyebrow'), fill='#c0ccab',
font=(self.sans, 9, 'bold'), anchor='nw'))
items.append(canvas.create_line(56, 123, 108, 123, fill='#a6b18f', width=2))
items.append(canvas.create_text(52, 146, text='THE LAST\nGUARDIAN', fill='#f3efdf',
font=(self.serif, 45), anchor='nw'))
items.append(canvas.create_text(56, 318, text=self.t('hero_copy'), fill='#d4d9c9',
font=(self.sans, 12), anchor='nw', width=420))
items.append(canvas.create_text(56, 397, text=self.t('edition') + ' / HAYATOG',
fill=self.colors['light'], font=(self.sans, 8), anchor='nw'))
def resized(event):
if art:
canvas.coords(art, event.width, 0)
scale = min(1.0, event.height / 490)
offset = max(0, (event.height - 490) / 2)
canvas.itemconfigure(items[2], font=(self.serif, max(23, round(45 * scale))))
canvas.itemconfigure(items[3], font=(self.sans, max(10, round(12 * scale))))
for item, y in zip(items, (92, 123, 146, 318, 397)):
coords = canvas.coords(item)
if len(coords) == 4:
canvas.coords(item, 56, y * scale + offset, 108, y * scale + offset)
else:
canvas.coords(item, 52 if y == 146 else 56, y * scale + offset)
canvas.bind('<Configure>', resized)
note = ttk.Frame(page, padding=(32, 18))
note.pack(fill='x')
ttk.Label(note, text=self.t('home_note'), font=(self.serif, 15)).pack(anchor='w')
help_text = ttk.Label(note, text=self.t('home_help'), style='Muted.TLabel', wraplength=1080)
help_text.pack(anchor='w', pady=(5, 0))
note.bind('<Configure>', lambda event: help_text.configure(wraplength=max(400, event.width - 64)))
def load_art(self):
"""Window icon (the port's tlg.png) and a 480x135 banner (a band of pic1.png from the dump, when present)."""
"""Local presentation asset, with the user's dump as a fallback; no network at startup."""
self.banner = getattr(self, 'banner', None)
if getattr(self, 'art_loaded', False):
return
self.art_loaded = True
icon = tlg_run.PORT_DIR / 'launcher' / 'tlg.png'
try:
icon = tlg_run.PORT_DIR / 'launcher' / 'tlg.png' # the port's own icon
if icon.is_file():
self.icon = tk.PhotoImage(file=str(icon))
self.root.iconphoto(True, self.icon)
picture = art_path(self.settings, 'pic1.png') or art_path(self.settings, 'pic0.png')
if picture:
full = tk.PhotoImage(file=str(picture))
band = full.width() * 9 // 32 # a 32:9 band from the middle of the image
top = (full.height() - band) // 2
self.banner = tk.PhotoImage()
self.banner.tk.call(self.banner, 'copy', full, '-from', 0, top, full.width(), top + band,
'-subsample', 4, 4) # Tk command: same on every Python version
except tk.TclError:
self.banner = None
pass
picture = tlg_run.PORT_DIR / 'launcher' / 'assets' / 'guardian-hero.png'
if not picture.is_file():
picture = art_path(self.settings, 'pic1.png') or art_path(self.settings, 'pic0.png')
if picture:
try:
self.banner = tk.PhotoImage(file=str(picture))
except tk.TclError:
self.banner = None
def build_filters(self, parent, row):
"""SMAA, sharpening and color adjustments, read from and written to tlg.ini right away."""
ttk.Separator(parent).grid(row=row, column=0, columnspan=2, sticky='ew', pady=(8, 4))
ttk.Label(parent, text=self.t('filters'), foreground='#555', wraplength=360).grid(
ttk.Label(parent, text=self.t('filters'), style='Card.TLabel',
foreground=self.colors['muted'], wraplength=360).grid(
row=row + 1, column=0, columnspan=2, sticky='w')
ini = dict(INI_DEFAULTS)
ini.update({k: v for k, v in tlg_run.read_ini(tlg_run.USER_DIR / 'tlg.ini').items() if k in ini})
@@ -463,7 +695,7 @@ class Launcher:
self.ini_vars[key] = var
ttk.Checkbutton(parent, text=self.t(key), variable=var, command=self.filters_changed).grid(
row=row + offset, column=0, columnspan=2, sticky='w', pady=2)
ttk.Label(parent, text=self.t('sharpening')).grid(row=row + 3, column=0, sticky='w')
ttk.Label(parent, text=self.t('sharpening'), style='Card.TLabel').grid(row=row + 3, column=0, sticky='w')
self.sharpener = ttk.Combobox(parent, values=[self.t('sharpen_' + k) for k in SHARPENERS],
state='readonly', width=16)
self.sharpener.current(2 if ini['cas'] not in ('0', '') else 1 if ini['rcas'] not in ('0', '') else 0)
@@ -471,7 +703,8 @@ class Launcher:
self.sharpener.bind('<<ComboboxSelected>>', lambda _: self.filters_changed())
for index, key in enumerate(COLOR_RANGES):
low, high = COLOR_RANGES[key]
ttk.Label(parent, text=self.t(key)).grid(row=row + 5 + index, column=0, sticky='w', padx=(16, 0))
ttk.Label(parent, text=self.t(key), style='Card.TLabel', wraplength=230).grid(
row=row + 5 + index, column=0, sticky='w', padx=(16, 0))
var = tk.StringVar(value=ini[key])
self.ini_vars[key] = var
spin = ttk.Spinbox(parent, from_=low, to=high, increment=0.05, format='%.2f', textvariable=var,
@@ -492,12 +725,13 @@ class Launcher:
tlg_run.update_ini(tlg_run.USER_DIR / 'tlg.ini', values)
def combo(self, parent, row, key, values, label, on_change=None):
ttk.Label(parent, text=self.t(key)).grid(row=row, column=0, sticky='w')
ttk.Label(parent, text=self.t(key), style='Card.TLabel', wraplength=185).grid(
row=row, column=0, sticky='w', pady=8)
names = [label(v) for v in values]
box = ttk.Combobox(parent, values=names, state='readonly', width=28)
box = ttk.Combobox(parent, values=names, state='readonly', width=22)
current = self.settings.get(key, values[0])
box.current(values.index(current) if current in values else 0)
box.grid(row=row, column=1, sticky='w', padx=6, pady=2)
box.grid(row=row, column=1, sticky='ew', padx=(12, 0), pady=8)
def picked(_):
self.settings[key] = values[box.current()]
@@ -512,9 +746,10 @@ class Launcher:
# -- game language (depends on the loaded CUSA) ---------------------------------------------
def build_game_language(self, parent):
"""Game language: the ones the loaded CUSA ships (by its title id), US by default."""
ttk.Label(parent, text=self.t('game_language')).grid(row=0, column=0, sticky='w')
self.game_lang_box = ttk.Combobox(parent, state='readonly', width=28)
self.game_lang_box.grid(row=0, column=1, sticky='w', padx=6, pady=2)
ttk.Label(parent, text=self.t('game_language'), style='Card.TLabel', wraplength=185).grid(
row=0, column=0, sticky='w', pady=8)
self.game_lang_box = ttk.Combobox(parent, state='readonly', width=22)
self.game_lang_box.grid(row=0, column=1, sticky='ew', padx=(12, 0), pady=8)
self.game_lang_box.bind('<<ComboboxSelected>>', self.picked_language)
self.refresh_language_choices()
@@ -547,6 +782,7 @@ class Launcher:
self.remap_window.lift()
return
win = self.remap_window = tk.Toplevel(self.root)
win.configure(background=self.colors['paper'])
win.title(self.t('remap_title'))
win.transient(self.root)
frame = ttk.Frame(win, padding=12)
@@ -601,9 +837,9 @@ class Launcher:
set_button = ttk.Button(frame, text=self.t('set'), width=10)
set_button.configure(command=lambda b=button, e=entry, s=set_button: capture(b, e, s))
set_button.grid(row=row, column=column + 2, padx=4, pady=1)
ttk.Label(frame, text=tlg_run.DEFAULT_KEYS[button], foreground='#888').grid(
ttk.Label(frame, text=tlg_run.DEFAULT_KEYS[button], foreground=self.colors['muted']).grid(
row=row, column=column + 3, sticky='w', padx=(0, 18))
ttk.Label(frame, text=self.t('remap_help'), foreground='#555', wraplength=760, justify='left').grid(
ttk.Label(frame, text=self.t('remap_help'), foreground=self.colors['muted'], wraplength=760, justify='left').grid(
row=half + 1, column=0, columnspan=8, sticky='w', pady=(10, 6))
bottom = ttk.Frame(frame)
bottom.grid(row=half + 2, column=0, columnspan=8, sticky='ew')
@@ -627,6 +863,7 @@ class Launcher:
self.mods_window.lift()
return
win = self.mods_window = tk.Toplevel(self.root)
win.configure(background=self.colors['paper'])
win.title(self.t('mods_title'))
win.transient(self.root)
win.minsize(560, 360)
@@ -638,7 +875,7 @@ class Launcher:
list_frame = ttk.Frame(frame)
list_frame.pack(fill='both', expand=True)
canvas = tk.Canvas(list_frame, highlightthickness=0)
canvas = tk.Canvas(list_frame, background=self.colors['paper'], highlightthickness=0)
scroll = ttk.Scrollbar(list_frame, orient='vertical', command=canvas.yview)
rows = ttk.Frame(canvas)
rows.bind('<Configure>', lambda _: canvas.configure(scrollregion=canvas.bbox('all')))
@@ -659,7 +896,7 @@ class Launcher:
mods = []
ttk.Label(rows, text=str(error), foreground='#a33').pack(anchor='w')
if not mods:
ttk.Label(rows, text=self.t('mods_empty'), foreground='#777').pack(anchor='w', pady=8)
ttk.Label(rows, text=self.t('mods_empty'), foreground=self.colors['muted']).pack(anchor='w', pady=8)
for key, name, note, enabled in mods:
row = ttk.Frame(rows)
row.pack(fill='x', anchor='w', pady=2)
@@ -667,7 +904,7 @@ class Launcher:
ttk.Checkbutton(row, text=name or key, variable=var,
command=lambda k=key, v=var: toggle(k, v)).pack(anchor='w')
if note:
ttk.Label(row, text=note, foreground='#777', wraplength=480, justify='left').pack(
ttk.Label(row, text=note, foreground=self.colors['muted'], wraplength=480, justify='left').pack(
anchor='w', padx=(22, 0))
populate()
+1
View File
@@ -33,6 +33,7 @@ rm -rf -- out/stage
mkdir -p "$dest/out/gpu" "$dest/out/lib" "$dest/launcher" "$dest/system_lib"
cp -r "out/pyi-dist/$PORT_NAME/." "$dest/"
cp launcher/$PORT_ICON.png "$dest/launcher/"
cp -r launcher/assets "$dest/launcher/"
strip --strip-debug -o "$dest/out/tlg-probe" out/tlg-probe
strip --strip-debug -o "$dest/out/gpu/libtlggpu.so" out/gpu/libtlggpu.so
[[ -f out/tlg-gpu-capabilities ]] && strip -o "$dest/out/tlg-gpu-capabilities" out/tlg-gpu-capabilities
+1
View File
@@ -45,6 +45,7 @@ mkdir -p "$dest/out" "$dest/launcher" "$dest/system_lib"
cp -r "out/pyi-dist/$PORT_NAME/." "$dest/"
llvm-strip -o "$dest/Play $PORT_NAME.exe" out/tlg-play.exe
cp launcher/$PORT_ICON.ico launcher/$PORT_ICON.png "$dest/launcher/"
cp -r launcher/assets "$dest/launcher/"
# The executables without debug information, where tlg_run.py looks for them (out/).
for exe in tlg-probe.exe tlg-gpu-capabilities.exe; do
llvm-strip --strip-debug -o "$dest/out/$exe" "out/$exe"
+136
View File
@@ -0,0 +1,136 @@
{
"source_commit": "b8d5f412398fae52a444400a9b5131bc1c970cc3",
"inventory": {
"vars": [
"game_dir",
"mouse_enabled",
"mouse_invert_y",
"mouse_sensitivity",
"native_audio",
"show_fps",
"system_lib_dir",
"vram_budget_mb",
"vsync"
],
"filters": [
"color_adjust",
"color_brightness",
"color_contrast",
"color_gamma",
"color_saturation",
"smaa"
],
"buttons": [
"Browse…",
"Browse…",
"Keyboard and mouse buttons…",
"Quit",
"Play",
"Logs folder",
"Mods / Cheats…",
"Saves folder",
"Clear shader cache"
],
"checks": [
"Native audio (libSceNgs2.sprx from your console)",
"Mouse turns the camera",
"Invert mouse Y axis",
"VSync",
"Show FPS overlay at start (F10 toggles)",
"SMAA anti-aliasing (smooths edges; the HUD too)",
"Color adjustments"
],
"combos": [
[
"English",
"Português (Brasil)",
"Español (Latinoamérica)",
"Français (Canada)"
],
[
"Automatic (system)",
"Português (Brasil)",
"English (US)"
],
[
"Windowed",
"Borderless window",
"Fullscreen"
],
[
"30 FPS (as on PS4)",
"60 FPS",
"Unlocked (up to the display)"
],
[
"Off",
"RCAS (FSR 1)",
"CAS (AMD)"
]
],
"spins": [
[
"0.1",
"5.0",
"0.1"
],
[
"2000",
"24000",
"200"
],
[
"-0.5",
"0.5",
"0.05"
],
[
"0.5",
"1.5",
"0.05"
],
[
"0.0",
"2.0",
"0.05"
],
[
"0.5",
"2.0",
"0.05"
]
],
"links": [
"YouTube channel: @hayatog-dev",
"Discord community",
"♥ Sponsor on GitHub"
],
"remap_entries": 17,
"remap_buttons": [
"Set",
"Set",
"Set",
"Set",
"Set",
"Set",
"Set",
"Set",
"Set",
"Set",
"Set",
"Set",
"Set",
"Set",
"Set",
"Set",
"Set",
"Reset all",
"Close"
],
"mods_buttons": [
"Open mods folder",
"Refresh list",
"Close"
]
}
}
+382
View File
@@ -0,0 +1,382 @@
"""Launcher presentation regression checks; no game process or real settings are used."""
import copy
from collections import Counter
import itertools
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'launcher'))
import tlg_launcher as ui
def widgets(parent):
for child in parent.winfo_children():
yield child
yield from widgets(child)
def contrast(root, foreground, background):
def luminance(color):
channels = [value / 65535 for value in root.winfo_rgb(color)]
linear = [value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4
for value in channels]
return sum(value * weight for value, weight in zip(linear, (0.2126, 0.7152, 0.0722)))
low, high = sorted((luminance(foreground), luminance(background)))
return (high + 0.05) / (low + 0.05)
class LauncherUITest(unittest.TestCase):
def setUp(self):
try:
self.root = ui.tk.Tk()
except ui.tk.TclError as error:
self.skipTest(f'Tk display unavailable: {error}')
self.root.withdraw()
output = Path(__file__).resolve().parents[1] / 'out'
output.mkdir(exist_ok=True)
self.folder = tempfile.TemporaryDirectory(dir=output)
self.addCleanup(self.folder.cleanup)
self.user = Path(self.folder.name)
self.settings = copy.deepcopy(ui.tlg_run.DEFAULT_SETTINGS)
self.settings['ui_language'] = 'pt_BR'
self.errors = []
self.root.report_callback_exception = lambda *error: self.errors.append(error)
for patch in (
mock.patch.object(ui.tlg_run, 'load_settings', return_value=self.settings),
mock.patch.object(ui.tlg_run, 'config_path', return_value=self.user / 'settings.json'),
mock.patch.object(ui.tlg_run, 'USER_DIR', self.user),
mock.patch.object(ui.tlg_run, 'check_game', return_value=[]),
):
patch.start()
self.addCleanup(patch.stop)
self.app = ui.Launcher(self.root)
self.addCleanup(self.close_root)
self.root.deiconify()
self.root.update()
def close_root(self):
owners = [self.root, *widgets(self.root)]
for timer in self.root.tk.call('after', 'info'):
command = str(self.root.tk.call('after', 'info', timer)[0])
owner = next((widget for widget in owners if command in (widget._tclCommands or [])), self.root)
owner.after_cancel(timer)
self.root.destroy()
def test_navigation_language_rebuild_and_session_log(self):
self.app.append('Session preserved — Trico')
for key, button in self.app.nav_buttons.items():
button.invoke()
self.assertEqual(self.app.current_page, key)
self.assertEqual(button.cget('style'), 'Selected.Nav.TButton')
self.app.settings['ui_language'] = 'en_US'
self.app.build()
self.root.update()
self.assertEqual(self.app.current_page, 'journal')
self.assertIn('Session preserved — Trico', self.app.log.get('1.0', 'end'))
self.assertEqual(self.app.nav_buttons['setup'].cget('text'), 'Game & language')
self.app.lines.put('New session output')
self.app.pump()
self.assertIn('New session output', self.app.log.get('1.0', 'end'))
self.assertFalse(self.errors)
def test_all_settings_remain_available_and_existing_callbacks_save(self):
expected = {'game_dir', 'system_lib_dir', 'native_audio', 'vsync', 'show_fps',
'vram_budget_mb', 'mouse_enabled', 'mouse_invert_y', 'mouse_sensitivity'}
self.assertEqual(set(self.app.vars), expected)
self.assertEqual(set(self.app.ini_vars), set(ui.INI_DEFAULTS) - {'rcas', 'cas'})
self.app.vars['vram_budget_mb'].set(7000)
self.app.vars['mouse_sensitivity'].set(1.5)
self.app.changed()
saved_file = json.loads((self.user / 'settings.json').read_text(encoding='utf-8'))
self.assertEqual(saved_file['vram_budget_mb'], 7000)
self.assertEqual(saved_file['mouse_sensitivity'], 1.5)
self.app.ini_vars['smaa'].set(True)
self.app.filters_changed()
self.assertEqual(ui.tlg_run.read_ini(self.user / 'tlg.ini')['smaa'], '1')
self.assertFalse(self.errors)
def test_historical_control_inventory_has_no_missing_controls(self):
# This fixture was collected from the original UI at source_commit, not the new layout.
baseline = json.loads((Path(__file__).parent / 'data' / 'launcher_ui_legacy.json').read_text(
encoding='utf-8'))['inventory']
self.app.settings['ui_language'] = 'en_US'
self.app.build()
actual = {'vars': sorted(self.app.vars), 'filters': sorted(self.app.ini_vars),
'buttons': [], 'checks': [], 'combos': [], 'spins': [], 'links': []}
for widget in widgets(self.root):
if isinstance(widget, ui.ttk.Button):
actual['buttons'].append(str(widget.cget('text')).removeprefix('▷ '))
if isinstance(widget, ui.ttk.Checkbutton):
actual['checks'].append(str(widget.cget('text')))
if isinstance(widget, ui.ttk.Combobox):
actual['combos'].append(list(widget.cget('values')))
if isinstance(widget, ui.ttk.Spinbox):
actual['spins'].append([str(widget.cget(key)) for key in ('from', 'to', 'increment')])
if isinstance(widget, ui.ttk.Label) and widget.bind('<Button-1>'):
actual['links'].append(str(widget.cget('text')))
self.app.remap_dialog()
actual['remap_entries'] = sum(type(widget) == ui.ttk.Entry
for widget in widgets(self.app.remap_window))
actual['remap_buttons'] = [str(widget.cget('text')) for widget in widgets(self.app.remap_window)
if isinstance(widget, ui.ttk.Button)]
self.app.mods_dialog()
actual['mods_buttons'] = [str(widget.cget('text')) for widget in widgets(self.app.mods_window)
if isinstance(widget, ui.ttk.Button)]
for key, expected in baseline.items():
with self.subTest(inventory=key):
if isinstance(expected, int):
self.assertEqual(actual[key], expected)
else:
encode = lambda values: Counter(json.dumps(value, sort_keys=True) for value in values)
self.assertFalse(encode(expected) - encode(actual[key]))
self.assertEqual(len(actual['buttons']) - len(baseline['buttons']), 5) # New navigation only.
def test_text_contrast_in_normal_focus_selection_and_disabled_states(self):
style = ui.ttk.Style(self.root)
states = [tuple(state for state, enabled in zip(('focus', 'readonly', 'disabled'), flags) if enabled)
for flags in itertools.product((False, True), repeat=3)]
for name in ('TEntry', 'TSpinbox', 'TCombobox'):
for state in states:
for foreground, background in (('foreground', 'fieldbackground'),
('selectforeground', 'selectbackground')):
with self.subTest(style=name, state=state, pair=foreground):
self.assertGreaterEqual(contrast(self.root, style.lookup(name, foreground, state),
style.lookup(name, background, state)), 4.5)
for name in ('TLabel', 'Muted.TLabel', 'Eyebrow.TLabel', 'Card.TLabel',
'TButton', 'Nav.TButton', 'Selected.Nav.TButton', 'Play.TButton', 'TCheckbutton'):
for state in ((), ('focus',), ('active',), ('pressed',), ('disabled',)):
with self.subTest(style=name, state=state):
self.assertGreaterEqual(contrast(self.root, style.lookup(name, 'foreground', state),
style.lookup(name, 'background', state)), 4.5)
for state in ((), ('selected',)):
self.assertGreaterEqual(contrast(self.root, style.lookup('TCheckbutton', 'indicatorforeground', state),
style.lookup('TCheckbutton', 'indicatorbackground', state)), 4.5)
for name in ('TEntry', 'TSpinbox', 'TCombobox'):
for state in ((), ('focus',), ('readonly', 'focus')):
self.assertGreaterEqual(contrast(self.root, style.lookup(name, 'bordercolor', state),
style.lookup(name, 'fieldbackground', state)), 3)
self.assertGreaterEqual(contrast(self.root, self.app.log.cget('foreground'),
self.app.log.cget('background')), 4.5)
self.assertGreaterEqual(contrast(self.root, self.app.log.cget('selectforeground'),
self.app.log.cget('selectbackground')), 4.5)
# Check the actual Tcl popdown listbox, which has a separate palette from ttk.
popdown = self.root.tk.call('ttk::combobox::PopdownWindow', str(self.app.game_lang_box))
listbox = str(popdown) + '.f.l'
for foreground, background in (('-foreground', '-background'),
('-selectforeground', '-selectbackground')):
self.assertGreaterEqual(contrast(self.root, self.root.tk.call(listbox, 'cget', foreground),
self.root.tk.call(listbox, 'cget', background)), 4.5)
def test_every_choice_and_checkbox_uses_the_existing_persistence_callbacks(self):
for key, choices in (('display_mode', [self.app.t(value) for value in ui.DISPLAY_MODES]),
('frame_rate', [self.app.t('fps_' + value) for value in ui.tlg_run.FRAME_RATES])):
box = next(widget for widget in widgets(self.root) if isinstance(widget, ui.ttk.Combobox)
and list(widget.cget('values')) == choices)
values = ui.DISPLAY_MODES if key == 'display_mode' else ui.tlg_run.FRAME_RATES
for index, expected in enumerate(values):
box.current(index)
box.event_generate('<<ComboboxSelected>>')
self.assertEqual(json.loads((self.user / 'settings.json').read_text(encoding='utf-8'))[key], expected)
for widget in widgets(self.root):
if isinstance(widget, ui.ttk.Checkbutton):
variable = str(widget.cget('variable'))
before = self.root.getvar(variable)
widget.invoke()
self.assertNotEqual(self.root.getvar(variable), before)
key = next((key for key, var in self.app.vars.items() if str(var) == variable), None)
if key:
saved = json.loads((self.user / 'settings.json').read_text(encoding='utf-8'))
self.assertEqual(saved[key], self.root.tk.getboolean(self.root.getvar(variable)))
else:
key = next(key for key, var in self.app.ini_vars.items() if str(var) == variable)
self.assertEqual(ui.tlg_run.read_ini(self.user / 'tlg.ini')[key],
str(int(self.root.getvar(variable))))
for index in range(3):
self.app.sharpener.current(index)
self.app.sharpener.event_generate('<<ComboboxSelected>>')
ini = ui.tlg_run.read_ini(self.user / 'tlg.ini')
self.assertEqual((ini['rcas'], ini['cas']), (str(int(index == 1)), str(int(index == 2))))
self.app.game_lang_box.current(0)
self.app.game_lang_box.event_generate('<<ComboboxSelected>>')
self.assertEqual(self.app.settings['game_language'], self.app.game_lang_values[0])
for key, (low, high) in ui.COLOR_RANGES.items():
self.app.ini_vars[key].set(str(high + 1))
self.app.filters_changed()
self.assertEqual(float(ui.tlg_run.read_ini(self.user / 'tlg.ini')[key]), high)
self.app.ini_vars[key].set(str(low - 1))
self.app.filters_changed()
self.assertEqual(float(ui.tlg_run.read_ini(self.user / 'tlg.ini')[key]), low)
self.assertFalse(self.errors)
def test_folder_buttons_shortcuts_links_and_dialog_actions(self):
buttons = [widget for widget in widgets(self.root) if isinstance(widget, ui.ttk.Button)]
browse_buttons = [widget for widget in buttons if widget.cget('text') == self.app.t('browse')]
for key, button in zip(('game_dir', 'system_lib_dir'), browse_buttons):
with mock.patch.object(ui.filedialog, 'askdirectory', return_value=str(self.user / key)):
button.invoke()
self.assertEqual(self.app.settings[key], str(self.user / key))
with mock.patch.object(self.app, 'open') as open_folder:
for key, path in (('saves', self.user / 'savedata'), ('logs', ui.tlg_run.LOG_DIR)):
next(widget for widget in buttons if widget.cget('text') == self.app.t(key)).invoke()
open_folder.assert_called_with(path)
with mock.patch.object(ui.webbrowser, 'open') as open_link:
for label, url in zip((widget for widget in widgets(self.root)
if isinstance(widget, ui.ttk.Label) and widget.bind('<Button-1>')),
(ui.CHANNEL_URL, ui.DISCORD_URL, ui.SPONSOR_URL)):
label.event_generate('<Button-1>')
open_link.assert_called_with(url)
next(widget for widget in buttons if widget.cget('text') == self.app.t('remap')).invoke()
entries = [widget for widget in widgets(self.app.remap_window) if type(widget) == ui.ttk.Entry]
entries[0].insert(0, 'Mouse X1')
entries[0].event_generate('<FocusOut>')
self.assertEqual(self.app.settings['key_map']['cross'], 'Mouse X1')
remap_buttons = [widget for widget in widgets(self.app.remap_window) if isinstance(widget, ui.ttk.Button)]
next(widget for widget in remap_buttons if widget.cget('text') == self.app.t('reset')).invoke()
self.assertEqual(self.app.settings['key_map'], {})
next(widget for widget in remap_buttons if widget.cget('text') == self.app.t('close')).invoke()
mods = [('fixture.xml:test', 'Audit fixture', 'Temporary test mod', False)]
with mock.patch.object(ui.tlg_run, 'list_mods', return_value=mods) as list_mods, \
mock.patch.object(ui.tlg_run, 'set_mod_enabled') as toggle:
next(widget for widget in buttons if widget.cget('text') == self.app.t('mods')).invoke()
next(widget for widget in widgets(self.app.mods_window) if isinstance(widget, ui.ttk.Checkbutton)).invoke()
toggle.assert_called_once_with('fixture.xml:test', True)
next(widget for widget in widgets(self.app.mods_window) if isinstance(widget, ui.ttk.Button)
and widget.cget('text') == self.app.t('mods_refresh')).invoke()
self.assertEqual(list_mods.call_count, 2)
self.assertFalse(self.errors)
def test_play_button_preparation_launch_log_completion_and_error(self):
process = mock.Mock()
process.stdout = ['Trico session output\n']
process.wait.return_value = 0
logfile = self.user / 'session.log'
with mock.patch.object(ui.threading, 'Thread') as thread, \
mock.patch.object(ui.tlg_run, 'prepare') as prepare, \
mock.patch.object(ui.tlg_run, 'launch', return_value=(process, logfile)) as launch:
self.app.play_button.invoke()
self.assertTrue(self.app.play_button.instate(['disabled']))
self.assertTrue(self.app.cache_button.instate(['disabled']))
thread.return_value.start.assert_called_once()
thread.call_args.kwargs['target']() # Execute only the mocked game services.
self.app.pump()
prepare.assert_called_once()
launch.assert_called_once_with(self.app.settings)
self.assertIn('Trico session output', logfile.read_text(encoding='utf-8'))
self.assertIn(self.app.t('ended', code=0), self.app.log.get('1.0', 'end'))
self.assertFalse(self.app.play_button.instate(['disabled']))
with mock.patch.object(ui.tlg_run, 'prepare', side_effect=RuntimeError('Audit error')), \
mock.patch.object(ui.messagebox, 'showerror') as error:
self.app.run_game()
self.app.pump()
error.assert_called_once_with(self.app.t('error'), 'Audit error')
self.app.lines.put(('ended', 2))
with mock.patch.object(ui.messagebox, 'showwarning') as warning:
self.app.pump()
warning.assert_called_once()
self.assertFalse(self.errors)
def test_cache_confirmation_and_quit_callbacks(self):
with mock.patch.object(ui.tlg_run, 'shader_cache_size', return_value=1024), \
mock.patch.object(ui.tlg_run, 'clear_shader_cache', return_value=(1024, [])) as clear, \
mock.patch.object(ui.messagebox, 'askyesno', return_value=False) as confirm, \
mock.patch.object(ui.messagebox, 'showinfo') as information:
self.app.cache_button.invoke()
clear.assert_not_called()
confirm.return_value = True
self.app.cache_button.invoke()
clear.assert_called_once_with(self.app.settings)
information.assert_called_once()
self.app.process = mock.Mock()
with mock.patch.object(self.root, 'destroy') as close:
next(widget for widget in widgets(self.root) if isinstance(widget, ui.ttk.Button)
and widget.cget('text') == self.app.t('quit')).invoke()
self.app.process.terminate.assert_called_once()
close.assert_called_once()
self.app.process = None
self.assertFalse(self.errors)
def test_game_region_language_choices_and_interface_language_event(self):
with mock.patch.object(ui.tlg_run, 'game_languages', return_value=ui.tlg_run.LANGUAGES_EU):
self.app.refresh_language_choices()
self.assertEqual(list(self.app.game_lang_box.cget('values')),
[ui.GAME_LANGUAGE_NAMES[key] for key in ui.tlg_run.LANGUAGES_EU])
index = list(ui.tlg_run.LANGUAGES_EU).index('pt_PT')
self.app.game_lang_box.current(index)
self.app.game_lang_box.event_generate('<<ComboboxSelected>>')
self.assertEqual(self.app.settings['game_language'], 'pt_PT')
self.app.append('Language event audit')
self.app.show_page('setup')
box = next(widget for widget in widgets(self.root) if isinstance(widget, ui.ttk.Combobox)
and list(widget.cget('values')) == [self.app.t('auto'), *ui.UI_LANGUAGE_NAMES.values()])
box.current(ui.UI_LANGUAGES.index('en_US'))
box.event_generate('<<ComboboxSelected>>')
self.root.update()
self.assertEqual(self.app.settings['ui_language'], 'en_US')
self.assertEqual(self.app.current_page, 'setup')
self.assertIn('Language event audit', self.app.log.get('1.0', 'end'))
self.assertFalse(self.errors)
def test_missing_art_falls_back_to_themed_initial_screen(self):
self.app.art_loaded = False
self.app.banner = None
with mock.patch.object(Path, 'is_file', return_value=False), \
mock.patch.object(ui, 'art_path', return_value=None):
self.app.build()
self.root.update()
self.assertIsNone(self.app.banner)
self.assertEqual(self.app.current_page, 'journey')
self.assertFalse(self.errors)
def test_ready_running_and_missing_game_states(self):
self.assertFalse(self.app.play_button.instate(['disabled']))
self.app.process = mock.Mock()
self.app.refresh_status()
self.assertTrue(self.app.play_button.instate(['disabled']))
self.assertTrue(self.app.cache_button.instate(['disabled']))
self.app.process = None
with mock.patch.object(ui.tlg_run, 'check_game', return_value=[('missing', 'eboot.bin')]):
self.app.refresh_status()
self.assertTrue(self.app.play_button.instate(['disabled']))
self.assertIn('eboot.bin', self.app.status.cget('text'))
self.assertFalse(self.app.cache_button.instate(['disabled']))
def test_pages_fit_minimum_size_in_both_languages_and_dialogs_open(self):
for language in ('pt_BR', 'en_US'):
self.app.settings['ui_language'] = language
self.app.build()
self.root.geometry('1040x700')
self.root.update()
for key in self.app.pages:
self.app.show_page(key)
self.root.update()
self.assertTrue(self.app.play_button.winfo_ismapped())
self.assertLessEqual(self.app.play_button.winfo_rooty() + self.app.play_button.winfo_height(),
self.root.winfo_rooty() + self.root.winfo_height())
if key == 'journey':
canvas = next(w for w in self.app.pages[key].winfo_children()
if isinstance(w, ui.tk.Canvas))
for item in canvas.find_all():
if canvas.type(item) == 'text':
self.assertLessEqual(canvas.bbox(item)[3], canvas.winfo_height())
continue
page = self.app.pages[key]
canvases = [w for holder in page.winfo_children()
for w in holder.winfo_children() if isinstance(w, ui.tk.Canvas)]
self.assertEqual(len(canvases), 1)
canvas = canvases[0]
body = canvas.nametowidget(canvas.itemcget(canvas.find_all()[0], 'window'))
self.assertLessEqual(body.winfo_reqwidth(), canvas.winfo_width(), (language, key))
self.app.remap_dialog()
self.app.mods_dialog()
self.root.update()
self.assertTrue(self.app.remap_window.winfo_exists())
self.assertTrue(self.app.mods_window.winfo_exists())
self.assertFalse(self.errors)
if __name__ == '__main__':
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
"""Exercise the production Windows exception handler with real protected-memory faults.
No renderer or game files are needed. The GPU callback fails immediately if a speculative
read reaches it, instead of letting the original readback/pipeline-cache deadlock hang CI.
"""
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include "compat_win.h"
typedef tlg_jmp_buf sigjmp_buf;
static __thread sigjmp_buf *runtime_fault_recover;
typedef struct { int unused; } IsaRegs;
static void *isa_table;
static unsigned char *image;
static int gpu_enabled=1;
static volatile int speculative_expected, gpu_calls;
static volatile unsigned char *page;
static void isa_context_regs(CONTEXT *c, IsaRegs *r, int to) { (void)c; (void)r; (void)to; }
static int isa_rewrite_trap(void *table, uintptr_t at, IsaRegs *regs, void *xmm, uintptr_t *rip) {
(void)table; (void)at; (void)regs; (void)xmm; (void)rip; return 0;
}
static LONG fatal_exception(EXCEPTION_POINTERS *info) { (void)info; return EXCEPTION_CONTINUE_SEARCH; }
static int tlggpu_handle_fault(void *context, void *address) {
(void)context; (void)address;
++gpu_calls;
if (speculative_expected) {
fputs("FAIL: speculative worker entered GPU readback before recovery\n",stderr);
ExitProcess(99);
}
DWORD old;
if (!VirtualProtect((void *)page,4096,PAGE_READWRITE,&old)) ExitProcess(98);
return 1;
}
/* The exact production handler is inserted here by the test. */
__HANDLER__
static void require(int condition, const char *message) {
if (!condition) { fprintf(stderr,"FAIL: %s\n",message); ExitProcess(97); }
}
int main(void) {
PVOID handler=AddVectoredExceptionHandler(1,vectored_fault);
require(handler!=NULL,"install handler");
page=VirtualAlloc(NULL,4096,MEM_RESERVE|MEM_COMMIT,PAGE_NOACCESS);
require(page!=NULL,"allocate protected page");
/* Repeated recoveries exercise the context trampoline and TLS reset too. */
for (int iteration=0; iteration<100; ++iteration) {
sigjmp_buf recovery;
speculative_expected=1;
if (!tlg_setjmp(recovery)) {
runtime_fault_recover=&recovery;
volatile unsigned char value=*page;
(void)value;
require(0,"speculative read must recover");
}
require(runtime_fault_recover==NULL,"clear speculative TLS recovery point");
require(gpu_calls==0,"speculative read must not enqueue GPU work");
}
speculative_expected=0;
volatile unsigned char value=*page;
require(value==0 && gpu_calls==1,"normal read still resolves through GPU tracking");
/* A write-protection fault must still use the GPU tracking callback. */
DWORD old;
require(VirtualProtect((void *)page,4096,PAGE_READONLY,&old),"protect write");
*page=42;
require(*page==42 && gpu_calls==2,"normal write still resolves through GPU tracking");
VirtualFree((void *)page,0,MEM_RELEASE);
RemoveVectoredExceptionHandler(handler);
puts("PASS: speculative recovery precedes readback; normal read/write tracking preserved");
return 0;
}
'''
@unittest.skipUnless(os.name == 'nt', 'Windows exception handling only')
class WindowsFaultRecoveryTest(unittest.TestCase):
def test_speculative_fault_recovers_before_gpu_readback(self):
compiler = os.environ.get('TLG_TEST_CLANG') or shutil.which('clang')
local = ROOT / 'out/native-tools/msys64/clang64/bin/clang.exe'
if not compiler and local.is_file():
compiler = str(local)
if not compiler:
self.skipTest('MSYS2 CLANG64 compiler unavailable; set TLG_TEST_CLANG')
source = (ROOT / 'src/probe.c').read_text(encoding='utf-8')
start = source.index('static LONG CALLBACK vectored_fault(')
end = source.index('static LONG WINAPI unhandled_fault(', start)
harness = HARNESS.replace('__HANDLER__', source[start:end])
output = ROOT / 'out'
output.mkdir(exist_ok=True)
env = dict(os.environ)
env['PATH'] = str(Path(compiler).resolve().parent) + os.pathsep + env.get('PATH', '')
with tempfile.TemporaryDirectory(dir=output) as folder:
env['TEMP'] = env['TMP'] = folder
test_source = Path(folder) / 'test.c'
executable = Path(folder) / 'fault-recovery.exe'
test_source.write_text(harness, encoding='utf-8')
built = subprocess.run([compiler, '-std=c11', '-O2', '-Wall', '-Wextra', '-Werror',
'-I' + str(ROOT / 'src'), str(test_source), str(ROOT / 'src/compat_win.c'),
'-lbcrypt', '-o', str(executable)], env=env, capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
result = subprocess.run([str(executable)], env=env, capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('normal read/write tracking preserved', result.stdout)
if __name__ == '__main__':
unittest.main()