mirror of
https://github.com/HayatoG/tlg_pc.git
synced 2026-11-07 10:46:09 -10:00
Merge PR #75
This commit is contained in:
commit
bb3ce7fa04
12 files changed
+1630
-130
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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/`.
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Regular → Executable
+1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user