diff --git a/.github/workflows/gameboost-build.yml b/.github/workflows/gameboost-build.yml
new file mode 100644
index 0000000..771ee3f
--- /dev/null
+++ b/.github/workflows/gameboost-build.yml
@@ -0,0 +1,102 @@
+name: build
+
+on:
+ push:
+ branches: [main]
+ paths: ['GameBoost/**', '.github/workflows/build.yml']
+ pull_request:
+ paths: ['GameBoost/**']
+ workflow_dispatch:
+
+defaults:
+ run:
+ working-directory: GameBoost
+
+jobs:
+ build:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.x'
+
+ - name: Restaurar
+ run: dotnet restore GameBoost.sln
+
+ - name: Compilar
+ run: dotnet build GameBoost.sln -c Release --no-restore -warnaserror
+
+ - name: Testar
+ run: dotnet test GameBoost.sln -c Release --no-build --logger "trx;LogFileName=testes.trx"
+
+ - name: Publicar testes
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: resultado-testes
+ path: GameBoost/**/TestResults/*.trx
+
+ - name: Publicar executavel
+ run: >
+ dotnet publish src/GameBoost.App/GameBoost.App.csproj
+ -c Release -r win-x64 --self-contained
+ -p:PublishSingleFile=true
+ -p:IncludeNativeLibrariesForSelfExtract=true
+ -p:PublishReadyToRun=true
+ -o publish
+
+ # Trimming quebra WPF e reflexao (secao 10 do spec). Falha a build se alguem ligar.
+ - name: Conferir que o trimming segue desligado
+ shell: pwsh
+ run: |
+ $props = Get-Content Directory.Build.props -Raw
+ if ($props -notmatch 'false') {
+ throw 'PublishTrimmed precisa continuar false: trimming quebra WPF.'
+ }
+
+ - name: Montar o instalador
+ shell: pwsh
+ run: |
+ choco install innosetup --no-progress -y --no-color
+
+ # $env:ProgramFiles(x86) nao e sintaxe valida: o PowerShell le
+ # "$env:ProgramFiles" e deixa "(x86)" solto, virando
+ # "C:\Program Files(x86)". A forma correta e ${env:...}.
+ # O ISCC tambem muda de lugar conforme a instalacao, entao vale
+ # procurar em vez de fixar um caminho.
+ $candidatos = @(
+ "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
+ "$env:ProgramFiles\Inno Setup 6\ISCC.exe",
+ "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe"
+ )
+
+ $iscc = $candidatos | Where-Object { Test-Path $_ } | Select-Object -First 1
+ if (-not $iscc) {
+ throw "ISCC.exe nao encontrado. Procurado em: $($candidatos -join '; ')"
+ }
+
+ Write-Host "Usando $iscc"
+ & $iscc installer\GameBoost.iss
+ if ($LASTEXITCODE -ne 0) { throw "ISCC falhou com codigo $LASTEXITCODE" }
+
+ - name: Gerar SHA256SUMS
+ shell: pwsh
+ run: |
+ $arquivos = @(Get-ChildItem publish\GameBoost.exe, installer\Output\*.exe -ErrorAction SilentlyContinue)
+ $arquivos | ForEach-Object {
+ $h = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
+ "$h $($_.Name)"
+ } | Set-Content SHA256SUMS.txt -Encoding utf8
+ Get-Content SHA256SUMS.txt
+
+ - name: Publicar artefatos
+ uses: actions/upload-artifact@v4
+ with:
+ name: gameboost
+ path: |
+ GameBoost/publish/GameBoost.exe
+ GameBoost/installer/Output/*.exe
+ GameBoost/SHA256SUMS.txt
diff --git a/.github/workflows/gameboost-release.yml b/.github/workflows/gameboost-release.yml
new file mode 100644
index 0000000..9e7d4dd
--- /dev/null
+++ b/.github/workflows/gameboost-release.yml
@@ -0,0 +1,220 @@
+name: release
+
+# Dispara só em tag `gameboost-vX.Y.Z`. O repositório guarda mais de um
+# projeto, então a tag carrega o nome do produto: uma tag `v2.0.0` solta
+# construiria o GameBoost por engano quando outro projeto for versionado.
+on:
+ push:
+ tags: ['gameboost-v*']
+ workflow_dispatch:
+ inputs:
+ versao:
+ description: 'Versão a empacotar (ex.: 2.0.0). Só para teste manual.'
+ required: true
+
+defaults:
+ run:
+ working-directory: GameBoost
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.x'
+
+ - name: Descobrir a versao
+ id: versao
+ shell: pwsh
+ run: |
+ if ($env:GITHUB_REF -like 'refs/tags/gameboost-v*') {
+ $v = $env:GITHUB_REF -replace '.*/gameboost-v', ''
+ } else {
+ $v = '${{ github.event.inputs.versao }}'
+ }
+
+ if ($v -notmatch '^\d+\.\d+\.\d+$') {
+ throw "Versao invalida: '$v'. Esperado X.Y.Z."
+ }
+
+ # A tag e o Directory.Build.props precisam concordar. Publicar uma
+ # tag 2.1.0 com o assembly dizendo 2.0.0 deixaria o auto-update
+ # comparando numeros que nao batem com o que o usuario tem.
+ $props = Get-Content Directory.Build.props -Raw
+ if ($props -notmatch "$([regex]::Escape($v))") {
+ throw "A tag diz $v mas o Directory.Build.props diz outra coisa. Alinhe os dois."
+ }
+
+ "versao=$v" >> $env:GITHUB_OUTPUT
+ Write-Host "Versao: $v"
+
+ - name: Restaurar
+ run: dotnet restore GameBoost.sln
+
+ - name: Testar
+ run: dotnet test GameBoost.sln -c Release --logger "trx;LogFileName=testes.trx"
+
+ - name: Publicar executavel
+ run: >
+ dotnet publish src/GameBoost.App/GameBoost.App.csproj
+ -c Release -r win-x64 --self-contained
+ -p:PublishSingleFile=true
+ -p:IncludeNativeLibrariesForSelfExtract=true
+ -p:PublishReadyToRun=true
+ -p:Version=${{ steps.versao.outputs.versao }}
+ -o publish
+
+ # ------------------------------------------------------------------
+ # Assinatura de codigo
+ # ------------------------------------------------------------------
+ #
+ # Condicional de proposito. O certificado OV vive num token USB ou HSM
+ # (exigencia das CAs desde jun/2023), entao nem sempre esta disponivel
+ # para o CI. Sem o segredo, a release sai **sem assinatura** e o passo
+ # seguinte avisa em alto e bom som, em vez de falhar a build inteira.
+ #
+ # Ver docs/ASSINATURA.md para o que foi apurado sobre as opcoes.
+ - name: Assinar o executavel
+ if: ${{ secrets.GAMEBOOST_CERT_PFX != '' }}
+ env:
+ CERT_PFX: ${{ secrets.GAMEBOOST_CERT_PFX }}
+ CERT_SENHA: ${{ secrets.GAMEBOOST_CERT_SENHA }}
+ shell: pwsh
+ run: |
+ $pfx = "$env:RUNNER_TEMP\cert.pfx"
+ [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:CERT_PFX))
+
+ $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe |
+ Where-Object { $_.FullName -like '*\x64\*' } |
+ Select-Object -First 1 -ExpandProperty FullName
+
+ if (-not $signtool) { throw 'signtool.exe nao encontrado no runner.' }
+
+ & $signtool sign /f $pfx /p $env:CERT_SENHA `
+ /fd SHA256 /tr http://timestamp.sectigo.com /td SHA256 `
+ publish\GameBoost.exe
+
+ if ($LASTEXITCODE -ne 0) { throw "signtool falhou com codigo $LASTEXITCODE" }
+ Remove-Item $pfx -Force
+
+ - name: Avisar que a release sai sem assinatura
+ if: ${{ !secrets.GAMEBOOST_CERT_PFX }}
+ shell: pwsh
+ run: |
+ Write-Host '::warning::Sem certificado configurado: esta release sai SEM assinatura de codigo. O SmartScreen vai avisar no primeiro download. Ver docs/ASSINATURA.md.'
+
+ # ------------------------------------------------------------------
+ # Pacote de atualizacao (Velopack)
+ # ------------------------------------------------------------------
+ - name: Empacotar com o Velopack
+ shell: pwsh
+ run: |
+ dotnet tool install -g vpk
+ $env:PATH = "$env:USERPROFILE\.dotnet\tools;$env:PATH"
+
+ vpk pack `
+ --packId GameBoost `
+ --packVersion ${{ steps.versao.outputs.versao }} `
+ --packDir publish `
+ --mainExe GameBoost.exe `
+ --packTitle GameBoost `
+ --packAuthors "Jonathan Vaz" `
+ --outputDir velopack
+
+ if ($LASTEXITCODE -ne 0) { throw "vpk pack falhou com codigo $LASTEXITCODE" }
+
+ - name: Montar o instalador
+ shell: pwsh
+ run: |
+ choco install innosetup --no-progress -y --no-color
+
+ $candidatos = @(
+ "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
+ "$env:ProgramFiles\Inno Setup 6\ISCC.exe",
+ "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe"
+ )
+
+ $iscc = $candidatos | Where-Object { Test-Path $_ } | Select-Object -First 1
+ if (-not $iscc) { throw "ISCC.exe nao encontrado. Procurado em: $($candidatos -join '; ')" }
+
+ & $iscc /DAppVersion=${{ steps.versao.outputs.versao }} installer\GameBoost.iss
+ if ($LASTEXITCODE -ne 0) { throw "ISCC falhou com codigo $LASTEXITCODE" }
+
+ # ------------------------------------------------------------------
+ # Portatil
+ # ------------------------------------------------------------------
+ #
+ # O mesmo exe, mais um portable.txt vazio ao lado. O AppPaths procura
+ # esse arquivo e, achando, guarda tudo em .\data\ em vez de
+ # %LOCALAPPDATA%. Nada muda no codigo.
+ - name: Montar o portatil
+ shell: pwsh
+ run: |
+ $pasta = "portatil\GameBoost-${{ steps.versao.outputs.versao }}-portatil"
+ New-Item -ItemType Directory -Force $pasta | Out-Null
+
+ Copy-Item publish\GameBoost.exe $pasta
+ Copy-Item README.md "$pasta\LEIA-ME.txt"
+
+ @(
+ 'Este arquivo faz o GameBoost guardar tudo em .\data\, ao lado do executavel,'
+ 'em vez de %LOCALAPPDATA%\GameBoost.'
+ ''
+ 'Apague este arquivo para voltar ao comportamento normal.'
+ ''
+ 'O modo portatil nao tem atualizacao automatica: baixe a versao nova pela'
+ 'pagina de releases quando quiser trocar.'
+ ) | Set-Content "$pasta\portable.txt" -Encoding utf8
+
+ Compress-Archive -Path "$pasta\*" -DestinationPath "portatil\GameBoost-${{ steps.versao.outputs.versao }}-portatil.zip" -Force
+
+ - name: Gerar SHA256SUMS
+ shell: pwsh
+ run: |
+ $arquivos = @(
+ Get-ChildItem publish\GameBoost.exe -ErrorAction SilentlyContinue
+ Get-ChildItem installer\Output\*.exe -ErrorAction SilentlyContinue
+ Get-ChildItem portatil\*.zip -ErrorAction SilentlyContinue
+ Get-ChildItem velopack\*.nupkg, velopack\*.exe -ErrorAction SilentlyContinue
+ )
+
+ if ($arquivos.Count -eq 0) { throw 'Nenhum artefato para somar.' }
+
+ $arquivos | ForEach-Object {
+ $h = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
+ "$h $($_.Name)"
+ } | Set-Content SHA256SUMS.txt -Encoding utf8
+
+ Get-Content SHA256SUMS.txt
+
+ - name: Anexar tudo ao Release
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: softprops/action-gh-release@v2
+ with:
+ name: GameBoost ${{ steps.versao.outputs.versao }}
+ body_path: GameBoost/installer/NOTAS-DA-RELEASE.md
+ files: |
+ GameBoost/publish/GameBoost.exe
+ GameBoost/installer/Output/*.exe
+ GameBoost/portatil/*.zip
+ GameBoost/velopack/*
+ GameBoost/SHA256SUMS.txt
+
+ - name: Guardar artefatos da execucao manual
+ if: ${{ !startsWith(github.ref, 'refs/tags/') }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: gameboost-${{ steps.versao.outputs.versao }}
+ path: |
+ GameBoost/publish/GameBoost.exe
+ GameBoost/installer/Output/*.exe
+ GameBoost/portatil/*.zip
+ GameBoost/velopack/*
+ GameBoost/SHA256SUMS.txt
diff --git a/GameBoost/.gitignore b/GameBoost/.gitignore
new file mode 100644
index 0000000..ac42ad8
--- /dev/null
+++ b/GameBoost/.gitignore
@@ -0,0 +1,16 @@
+# Build
+bin/
+obj/
+publish/
+*.user
+*.suo
+.vs/
+
+# Publish e instalador
+installer/Output/
+*.exe
+*.msi
+
+# Dados locais do app em modo portatil
+data/
+*.log
diff --git a/GameBoost/CHANGELOG.md b/GameBoost/CHANGELOG.md
new file mode 100644
index 0000000..ac7d3ab
--- /dev/null
+++ b/GameBoost/CHANGELOG.md
@@ -0,0 +1,510 @@
+# Changelog
+
+Formato baseado em Keep a Changelog. Versionamento semantico.
+
+## [2.0.0] - 2026-09-14
+
+### QA manual, quinta rodada - a deteccao de jogo funcionava, o resto nao (2026-09-14)
+
+Seis correcoes no bloco de perfis. A deteccao em si sempre funcionou — o log registrava
+os tres criterios do spec, com confianca e tela cheia. Tudo o que vinha depois dela
+estava quebrado.
+
+#### Corrigido
+- **O convite era disparado e ninguem escutava.** O shell dispara `AoPedirAviso` e nenhum
+ assinante existia: nem balao, nem faixa. O jogo era detectado, o log registrava, e o
+ usuario nao via nada em lugar nenhum. Agora vai para a bandeja E para uma faixa na
+ janela — com o jogo em tela cheia o Windows engole o balao, e a faixa espera.
+- **Aceitar o convite nao aplicava o perfil.** So navegava para a tela Inicio. Prioridade,
+ afinidade e tweaks ficavam parados no arquivo, e o ProfileRunner nao era chamado por
+ ninguem.
+- **Fechar o jogo nao desfazia nada**, mas o aviso dizia "tudo o que o GameBoost alterou
+ foi desfeito". Afirmar reversao sem reverter e o oposto da regra 1.
+- **O perfil nao casava com o jogo.** O perfil e chaveado pelo executavel
+ ("warframe.x64") e o casamento usava o nome da pasta ("Warframe"). A linha aparecia
+ duas vezes: uma dizendo que tem perfil e outra que nao tem. Agora casa pela pasta de
+ instalacao, que os dois tem em comum.
+- **Falso positivo na deteccao: o Agent do Battle.net.** Ele mora em
+ `ProgramData\Battle.net\Agent`, que bate com a lista de pastas de launcher, e engorda
+ enquanto baixa atualizacao — passou do limite de memoria e foi anunciado como jogo numa
+ sessao real. Nome que descreve funcao ("agent", "launcher", "updater", "helper") dentro
+ de pasta de launcher e infraestrutura, nunca jogo.
+- **"Nao havia perfil aplicado" quando havia.** Quando o jogo fecha antes do Desfazer, nao
+ sobra prioridade para restaurar — ela morre com o processo. O texto negava que houvesse
+ perfil em vez de explicar isso.
+- O contador do cabecalho da pagina Jogos nao acompanhava criar e remover perfil.
+
+#### Adicionado
+- Teste que prova que maquinaria de launcher nunca e jogo, com 2 GB de RAM para garantir
+ que o filtro de nome vale mesmo com o processo gordo — e que um jogo de verdade na mesma
+ pasta continua sendo detectado.
+
+### QA manual, quarta rodada - o Reverter da inicializacao nao revertia (2026-09-14)
+
+#### Corrigido
+- **Reverter da Inicializacao devolvia sucesso sem fazer nada.** A tela passa GUIDs de
+ ChangeRecord, mas o modulo esperava NOMES de programa e tentava casar com
+ `id.Contains(e.Nome)`. Um GUID nunca contem "WallpaperEngine": a lista de alvos saia
+ vazia, nada era desfeito e o metodo retornava sucesso. A tela dizia "Pronto" e o item
+ continuava desativado.
+
+ Alem de errado, aquilo dependia de alguem ter varrido na mesma sessao: reabrir o app e
+ clicar em Reverter nao funcionaria nem com o casamento certo. Agora usa o
+ RollbackEngine, que le o proprio ChangeRecord — mesmo caminho dos Tweaks.
+
+#### Adicionado
+- Teste do ciclo completo da inicializacao, com uma entrada descartavel criada so para
+ ele: desativa, confere os 12 bytes com o primeiro em 0x03, reativa e confere que voltou.
+
+#### Notas
+- Reativar pode apagar o valor em vez de gravar 0x02, e as duas formas valem: no
+ StartupApproved a ausencia significa habilitado. Apagar e mais limpo, porque nao deixa
+ residuo de uma chave que nao existia antes.
+
+### QA manual, terceira rodada - a Lixeira derrubava o aplicativo (2026-09-14)
+
+#### Corrigido
+- **`Pack = 1` na struct do SHFileOperation derrubava o processo.** Em x64 o `Pack = 1`
+ tira o preenchimento entre os campos, e o ponteiro `pFrom` sai no deslocamento 12 em vez
+ de 16. O shell32 continua lendo do 16, pega metade de um ponteiro colada na metade de
+ outro e usa aquilo como endereco: violacao de acesso (0xc0000005), processo morto sem
+ excecao gerenciada, sem log e sem caixa de erro.
+
+ **A Lixeira nunca funcionou em lugar nenhum.** Limpeza, Espaco e Restos usam a mesma
+ funcao. Era o item que estava marcado como nao testado no docs/QA.md desde a Fase 2, e o
+ que ele escondia era um crash garantido em toda tentativa.
+
+#### Adicionado
+- Teste que manda um arquivo de verdade para a Lixeira e confere que ele saiu da origem.
+ Marshalling errado nao quebra a compilacao: so uma chamada real pega.
+
+### QA manual, segunda rodada - medicao de velocidade e quatro correcoes (2026-09-14)
+
+Achados nos blocos de tweaks e de rede do docs/QA.md.
+
+#### Corrigido
+- **O teste de velocidade media a rajada, nao a linha.** Ele transferia 25 MB e
+ cronometrava. Numa fibra domestica a operadora deixa passar bem acima do contratado por
+ um ou dois segundos, e uma transferencia que termina em 0,6 s mede so isso. Com 8, 25 e
+ 50 MB o upload saiu 131, 354 e 275 Mbps — tres numeros que nao se parecem, na mesma
+ linha e no mesmo minuto. Agora mede por **tempo** (8 s por amostra), descartando os 2
+ primeiros segundos, onde moram a rajada e o slow start do TCP. O erro caiu de +44% para
+ -15% contra um teste dedicado, e 15% abaixo e o resultado certo para um teste de um
+ servidor so e uma conexao so.
+- **A lista nao atualizava depois de agir.** O badge de um servico continuava dizendo
+ "automatico" depois de ele ja ter virado "manual": a tela parecia nao ter feito nada.
+ Onde a varredura e barata (Tweaks), ela refaz sozinha; onde e cara (Apps), a tela avisa
+ que a lista mostra o estado de antes.
+- **Markdown cru aparecia na tela.** Textos escritos com `**negrito**` mostravam os
+ asteriscos, porque o WPF nao renderiza markdown. A enfase passou a vir da estrutura da
+ frase.
+- **Glifos de icone se perdiam.** Os icones do menu sao caracteres da area de uso privado
+ do Unicode, e todo reescrita do arquivo por outra ferramenta os apagava. Agora estao em
+ escape `\uXXXX`, que e ASCII puro.
+
+#### Notas
+- O teste de velocidade ganhou um teto de 120 MB por amostra, para nao comer a franquia de
+ quem tem limite.
+- Um teste de velocidade dedicado tambem erra quando o servidor escolhido esta
+ congestionado: na mesma maquina e no mesmo minuto, um servidor deu 73 Mbps de upload e
+ outro deu 264. A tela agora diz que o numero sai 10% a 15% abaixo e por que.
+
+### QA manual, primeira rodada - seis correcoes (2026-09-14)
+
+Achados rodando o app elevado na maquina real, no bloco de reversibilidade do
+docs/QA.md. Dois deles apareciam em toda sessao.
+
+#### Corrigido
+- **Sessao nunca era encerrada.** O botao "Restaurar agora" revertia tudo e limpava a tela,
+ mas nao marcava o session.json como encerrado. O aviso "foi fechado com o Modo Game ainda
+ ativo" voltava em TODA abertura, mesmo com zero pendencias. Um aviso que aparece sempre
+ deixa de ser aviso.
+- **Excecao em toda saida.** `GameWatcher.Dispose()` era chamado pelo App e de novo pelo
+ container de DI, que descarta todo singleton IDisposable. Na segunda chamada o `Cancel()`
+ caia num CancellationTokenSource ja descartado, e a excecao escapava para a caixa de erro
+ porque o `ServiceProvider.Dispose()` estava fora do try. Dispose agora e idempotente.
+- **Lista de restauracao nao existia.** O rodape anunciava "N aguardando na lista de
+ restauracao" e nao havia lista nenhuma. Agora ha o bloco "Aguardando para reabrir", com
+ botao por app e a explicacao de por que ele nao voltou sozinho.
+- **O contador contradizia a acao.** O resumo dizia "38 apps marcados" com um unico item
+ marcado, porque era o texto da varredura e nao acompanhava a selecao. Agora ha contagem
+ viva ao lado, e o resumo da varredura fica em segundo plano.
+
+#### Adicionado
+- **Busca em toda pagina de modulo.** Entrou na classe base, entao vale para Inicio, Apps,
+ Limpeza, Tweaks e todas as outras. Ignora acento e caixa: quem digita "gravacao" acha
+ "Gravação". Filtrar **nao** desmarca nada — a contagem continua sobre a lista inteira.
+- **Marcar todos e Limpar na tela Inicio**, que era a unica pagina sem eles.
+
+### Fase 7 - Distribuicao: auto-update, CI de release, portatil e assinatura (2026-09-14)
+
+#### Adicionado
+- Auto-update com Velopack apontando para as releases do GitHub. Checagem silenciosa na
+ abertura, download em segundo plano, e a troca de versao so no proximo inicio ou quando
+ o usuario mandar.
+- `VelopackApp.Build().Run()` como primeira linha do Main, antes de ler argumentos: o
+ Velopack roda o proprio exe para instalar e atualizar, e essa chamada precisa
+ interceptar.
+- Secao "Versao do GameBoost" em Configuracoes: versao atual, botao Verificar e, quando
+ ha download pronto, "Instalar e reiniciar".
+- Workflow `gameboost-release.yml`: dispara em tag `gameboost-v*`, confere que a tag bate
+ com o Directory.Build.props, testa, publica, assina (quando ha certificado), empacota
+ com o Velopack, monta o instalador e o portatil, gera SHA256SUMS e anexa tudo a release.
+- Passo de assinatura condicional. Sem o segredo, a release sai sem assinatura e o CI
+ emite um aviso visivel, em vez de falhar a build inteira.
+- Portatil montado no CI: o mesmo exe, mais um `portable.txt` explicando o que ele faz.
+- Instalador com escolha entre instalar para todos os usuarios ou so para o atual. A
+ chave de autostart passou de HKCU para HKA, que resolve para a raiz certa nos dois casos.
+- `docs/ASSINATURA.md`: o que foi apurado sobre certificado (o Brasil esta fora do Azure
+ Artifact Signing, EV nao pula mais o SmartScreen desde 2024, OV e o caminho viavel) e
+ como submeter falso positivo.
+- `installer/NOTAS-DA-RELEASE.md`: o que baixar, e como conferir o SHA256 quando o
+ SmartScreen avisar.
+
+#### Corrigido
+- A here-string do PowerShell (`@'...'@`) exige o terminador na coluna 0, e isso quebrava
+ o bloco YAML do workflow, que precisa de todo conteudo indentado. Trocada por array de
+ strings.
+- O `.iss` tinha `#define AppVersion` fixo, entao o `/DAppVersion` do CI seria ignorado.
+ Agora e `#ifndef`.
+- O `UseWindowsForms` da Fase 6 ligou os analisadores do WinForms, e o WFO0003 manda tirar
+ a configuracao de DPI do manifesto — regra que nao se aplica a um app WPF. Com o
+ `-warnaserror` do CI, isso quebraria a build de Release inteira. Suprimido com o motivo
+ escrito no csproj.
+
+#### Notas
+- Modo portatil nao tem auto-update, e a tela diz isso: o Velopack atualiza uma instalacao
+ que ele mesmo montou, e um exe solto numa pasta nao tem o que atualizar.
+- O teste do modo portatil nao pode exigir que o caminho fique fora de `%LOCALAPPDATA%`:
+ a pasta temporaria do Windows mora dentro dele. A invariante testada e nao cair na pasta
+ padrao do app.
+
+### Fase 7 - Distribuicao: auto-update, CI de release, portatil e assinatura (2026-09-14)
+
+#### Adicionado
+- Auto-update com Velopack apontando para as releases do GitHub. Checagem silenciosa na
+ abertura, download em segundo plano, e a troca de versao so no proximo inicio ou quando
+ o usuario mandar.
+- `VelopackApp.Build().Run()` como primeira linha do Main, antes de ler argumentos: o
+ Velopack roda o proprio exe para instalar e atualizar, e essa chamada precisa
+ interceptar.
+- Secao "Versao do GameBoost" em Configuracoes: versao atual, botao Verificar e, quando
+ ha download pronto, "Instalar e reiniciar".
+- Workflow `gameboost-release.yml`: dispara em tag `gameboost-v*`, confere que a tag bate
+ com o Directory.Build.props, testa, publica, assina (quando ha certificado), empacota
+ com o Velopack, monta o instalador e o portatil, gera SHA256SUMS e anexa tudo a release.
+- Passo de assinatura condicional. Sem o segredo, a release sai sem assinatura e o CI
+ emite um aviso visivel, em vez de falhar a build inteira.
+- Portatil montado no CI: o mesmo exe, mais um `portable.txt` explicando o que ele faz.
+- Instalador com escolha entre instalar para todos os usuarios ou so para o atual. A
+ chave de autostart passou de HKCU para HKA, que resolve para a raiz certa nos dois casos.
+- `docs/ASSINATURA.md`: o que foi apurado sobre certificado (o Brasil esta fora do Azure
+ Artifact Signing, EV nao pula mais o SmartScreen desde 2024, OV e o caminho viavel) e
+ como submeter falso positivo.
+- `installer/NOTAS-DA-RELEASE.md`: o que baixar, e como conferir o SHA256 quando o
+ SmartScreen avisar.
+
+#### Corrigido
+- A here-string do PowerShell (`@'...'@`) exige o terminador na coluna 0, e isso quebrava
+ o bloco YAML do workflow, que precisa de todo conteudo indentado. Trocada por array de
+ strings.
+- O `.iss` tinha `#define AppVersion` fixo, entao o `/DAppVersion` do CI seria ignorado.
+ Agora e `#ifndef`.
+
+#### Notas
+- Modo portatil nao tem auto-update, e a tela diz isso: o Velopack atualiza uma instalacao
+ que ele mesmo montou, e um exe solto numa pasta nao tem o que atualizar.
+- O teste do modo portatil nao pode exigir que o caminho fique fora de `%LOCALAPPDATA%`:
+ a pasta temporaria do Windows mora dentro dele. A invariante testada e nao cair na pasta
+ padrao do app.
+
+### Fase 6 - Perfis por jogo, bandeja e onboarding (2026-09-14)
+
+#### Adicionado
+- Perfis por jogo (secao 5.11): um arquivo por jogo em `profiles/*.json`. Um perfil diz o
+ que fazer quando o jogo abre e o que desfazer quando ele fecha; a reversao nao e
+ opcional nem configuravel.
+- Deteccao automatica de jogo (secao 5.1): vigia os processos a cada 2 s numa thread de
+ prioridade baixa, cruzando nome conhecido, pasta de launcher e janela em tela cheia.
+- Perfil novo **nunca** ativa sozinho. O convite aparece, e so quem marcar "ativa sozinho"
+ passa a ter o perfil aplicado sem perguntar (regra 3).
+- Afinidade de CPU por perfil, desligada por padrao, recusando indice de nucleo que nao
+ existe na maquina atual.
+- Resolucao de timer em 0,5 ms por perfil, desligada por padrao e com o texto dizendo que
+ vale menos do que a internet promete.
+- Pagina Jogos: biblioteca dos launchers cruzada com os perfis. Medido: 14 jogos,
+ 974,2 GB.
+- Icone na bandeja com o menu da secao 6 (Ativar Modo Game, Limpar RAM, Abrir, Sair).
+ Fechar a janela esconde na bandeja, com aviso na primeira vez; "Sair" sai de verdade.
+- "Limpar RAM" como acao propria, com ganho medido antes e depois. Quando nao ha o que
+ liberar, o texto diz isso em vez de inventar um numero.
+- Onboarding de tres telas na primeira abertura: reversivel, sem telemetria, e o que faz
+ diferenca de verdade. Nao vende FPS.
+- Dois interruptores novos em Configuracoes: icone na bandeja e deteccao automatica.
+
+#### Corrigido
+- `UseWindowsForms` (necessario para o NotifyIcon) injeta using global de
+ `System.Windows.Forms` e `System.Drawing` em todo arquivo, tornando `Control`,
+ `Application`, `Brush` e `MouseEventArgs` ambiguos com os tipos do WPF na aplicacao
+ inteira. Os usings implicitos foram removidos no csproj.
+- O texto de estado vazio da pagina Jogos aparecia por cima da lista cheia: a
+ visibilidade dependia do Resumo, que nunca fica vazio.
+
+#### Notas
+- A deteccao e por polling, nao por `Win32_ProcessStartTrace`. WMI em laco foi o que
+ estourou o orcamento de CPU na Fase 1; 2 segundos de atraso nao importam para algo que
+ o usuario vai confirmar num dialogo.
+- Sair do app desfaz o perfil aplicado antes de encerrar. Sem isso, prioridade e tweaks
+ ficariam de pe depois que o processo sumiu.
+
+### Complemento da Fase 4 - aba Atualizacoes (winget) e aba Restos na tela (2026-09-14)
+
+Entrega retroativa: a secao 5.3 ganhou a aba de atualizacoes depois que a Fase 4 ja
+estava commitada. Vem em commit proprio para o historico nao mentir sobre o que cada
+fase entregou.
+
+#### Adicionado
+- Aba "Atualizacoes" em cima do winget: lista o que tem versao nova, classificado e
+ agrupado. Medido: 35 atualizacoes, 4 de seguranca, 7 que se atualizam sozinhos.
+- Classificacao por id: driver (bloqueado, o GameBoost nao instala driver), runtime,
+ "atualiza sozinho" e "o winget nao sabe a versao instalada". Cada grupo tras o texto
+ que explica por que tratar diferente.
+- Apps que abrem arquivo vindo da internet (navegador, compactador, leitor de PDF, Java)
+ sao marcados como atualizacao de seguranca: neles ficar desatualizado nao e conforto.
+- App aberto e detectado e a linha avisa "feche antes".
+- Atualizacao uma a uma, com progresso por item. `winget upgrade --all` seria mais curto
+ e perderia o que importa: com `--all` um app que falha some no meio da saida.
+- Codigos de saida do winget viram frase em portugues; o caminho do log detalhado aparece
+ quando algo falha. Historico em updates-history.json.
+- Quando o winget nao existe, a aba explica o que ele e e abre a pagina do App Installer
+ na Microsoft Store.
+- Aba "Restos de apps antigos" **na interface**. A varredura existia no Core desde a Fase
+ 4, mas nunca tinha sido ligada a uma tela.
+- Pagina de Apps reorganizada em tres abas, cada uma com o seu proprio botao de acao.
+
+#### Corrigido
+- O template de TabControl escrito para o tema escuro quebrava a arvore de automacao:
+ nenhum controle dentro das abas era exposto. Leitor de tela nao alcancaria nada, e a
+ navegacao por teclado perdia o destino. Faltava o `ContentPresenter` chamado
+ `PART_SelectedContentHost`, que e o nome que o `TabControlAutomationPeer` procura.
+- O casamento de id do winget era por prefixo cru, entao "Google.Chrome" pegava
+ "Google.ChromeRemoteDesktopHost": o host de acesso remoto aparecia como navegador e
+ como atualizacao de seguranca. Agora exige fronteira de ponto.
+- As abas do TabControl saiam ilegiveis: o estilo padrao do WPF usa fundo claro e texto
+ escuro, que sobre a superficie escura do GameBoost fica branco no branco.
+
+#### Notas
+- A saida do `winget upgrade` nao tem formato estruturado: nem JSON, nem XML. E uma
+ tabela de largura fixa com cabecalho traduzido. A leitura e por **posicao de coluna**,
+ calculada a partir do cabecalho, o que funciona em qualquer idioma.
+- Nada e pre-marcado, nem a atualizacao de seguranca: instalar versao nova pode quebrar
+ o que funcionava, e a escolha e de quem usa a maquina.
+
+### Fase 5 - Tweaks, servicos, rede com NAT e drivers (2026-09-14)
+
+#### Adicionado
+- Pagina Tweaks (secoes 5.8 e 5.7): 12 ajustes do catalogo e 12 servicos do Windows na
+ mesma lista, agrupados por categoria. Cada ajuste tras o efeito real, a evidencia, o
+ risco e como desfazer. Medido: 24 itens, 1 ja ativo na maquina de teste.
+- Nada e pre-marcado, nem o que o proprio GameBoost recomenda: o selo "recomendado"
+ aparece no texto e a caixa continua vazia (regra 3).
+- Isolamento de nucleo (VBS) aparece so de leitura, com o custo declarado (5% a 15%
+ segundo testes publicos) e o botao que abre a tela da Microsoft. Mitigacoes de
+ Spectre/Meltdown nao entram nem como item bloqueado.
+- SysMain, servicos do Xbox e Windows Update aparecem bloqueados **para explicar por que
+ nao mexer**: a informacao de que nao se deve desligar vale tanto quanto a sugestao de
+ desligar.
+- Pagina Rede (secao 5.9) em tres blocos: "Posso jogar online?" com semaforo, "Velocidade"
+ e as tabelas de latencia, DNS e conexoes.
+- Cliente STUN proprio (RFC 5389) com dois servidores por duas portas locais. Classifica o
+ NAT no vocabulario dos jogos: aberto, moderado, estrito. Medido na maquina real: aberto.
+- Deteccao das causas: CGNAT pela faixa 100.64.0.0/10, NAT duplo por saltos privados no
+ traceroute, UPnP por SSDP, Teredo pelo registro e perfis do firewall.
+- Teste de velocidade com 3 amostras e mediana, opt-in: gasta cerca de 100 MB, entao
+ depende de permissao explicita em Configuracoes, desligada por padrao.
+- Reativacao do Teredo, reversivel, com ChangeRecord por chave. E a correcao do caso mais
+ comum de "Game Pass nao conecta": alguem desativou seguindo tutorial de otimizacao.
+- Troca de DNS por adaptador, reversivel inclusive para "automatico (DHCP)", e reset de
+ Winsock com risco Alto e o aviso de que nao tem desfazer.
+- Comparacao de DNS por consulta UDP montada a mao, porque `Dns.GetHostEntry` perguntaria
+ sempre ao resolvedor do sistema.
+- Leitura do driver de video (secao 5.10) pelo registro, com traducao da versao da NVIDIA
+ para a de marketing (32.0.16.1664 vira 616.64) e a idade em meses.
+- `docs/PORTAS.md`: portas por launcher e por jogo, o que fazer em CGNAT e em NAT duplo, e
+ onde fica UPnP em sete marcas de roteador.
+
+#### Corrigido
+- O badge "Protegido" aparecia em ajuste que so precisava de elevacao. Sao coisas
+ diferentes: protegido e o GameBoost se recusando a mexer; precisar de admin some ao
+ reabrir elevado. `ActionItem` ganhou `RotuloBloqueio`.
+- A leitura da hibernacao procurava `C:\hiberfil.sys`, e `File.Exists` devolve **false**
+ para aquele arquivo mesmo com a hibernacao ligada: a ACL barra a consulta sem elevacao.
+ O app diria "ja desligada" para todo mundo que abrisse sem ser administrador. Agora le o
+ registro.
+- O driver de video listava o adaptador virtual do Hyper-V, carimbado com 21/06/2006, e a
+ tela anunciaria "seu driver tem 242 meses" numa maquina com driver do mes passado.
+- A lista de quem usa a rede repetia o mesmo nome varias vezes, uma por PID. Agora agrupa
+ por nome e diz quantos processos sao.
+- A barra de status da pagina Rede repetia a explicacao do NAT que ja tem bloco proprio, e
+ saia cortada no meio da frase.
+
+#### Notas
+- O spec foi substituido no meio desta fase. Os conflitos entre o que ja estava escrito e o
+ texto novo estao listados em docs/DECISOES.md, com o que foi corrigido e o que ficou
+ pendente.
+- A aba "Atualizacoes" via winget (secao 5.3) e da Fase 4, que ja foi entregue. Entra como
+ complemento retroativo depois desta fase, com commit proprio.
+- Banda por processo nao e possivel como o spec descreve: `GetExtendedTcpTable` devolve
+ conexoes com o PID dono, nao contadores de trafego. A tela mostra conexoes e diz isso.
+
+### Fase 4 - Desinstalador e gerenciador de inicializacao (2026-09-14)
+
+#### Adicionado
+- Inventario de aplicativos (secao 5.3) lendo registro (32 e 64 bits, maquina e usuario),
+ Microsoft Store e UserAssist. Medido: 217 apps, 113 da Store, 78 protegidos.
+- Desinstalacao em lote, sempre pelo desinstalador do proprio app: QuietUninstallString,
+ depois `msiexec /x {GUID} /qn`, depois `Remove-AppxPackage`, e por ultimo o desinstalador
+ com janela. Limite de 10 minutos por app. Saidas 0, 3010 e 1605 contam como sucesso.
+- Varredura de restos depois de cada desinstalacao, com remocao para a Lixeira.
+- Aba "Restos de apps antigos": varre %APPDATA%, %LOCALAPPDATA% e %PROGRAMDATA% e lista
+ pastas sem app correspondente, com tamanho e data. Medido: 30 pastas, 3,9 GB.
+- Gerenciador de inicializacao (secao 5.6): chaves Run, pastas Inicializar e o estado do
+ StartupApproved. Desativar grava o mesmo formato de 12 bytes do Gerenciador de Tarefas,
+ com ChangeRecord antes de cada escrita. Medido: 26 entradas, 4 bloqueadas, 8 sugeridas.
+- Verificacao de assinatura Authenticode com WinVerifyTrust, para mostrar o fabricante.
+
+#### Corrigido
+- O resumo dos apps anunciava 1812,4 GB numa maquina com 953 GB. Eram duas contas erradas:
+ app que declara InstallLocation generica (media a pasta inteira) e varios apps declarando
+ a MESMA pasta, contada uma vez por app. Agora sao 1044 GB para 2145 GB de disco.
+- A lista vinha ordenada pelo tamanho DECLARADO no registro, medido so depois: o ARK, com
+ 309 GB reais, aparecia atras do AutoCAD, que declara 4 GB.
+- App da Store cujo executavel mora em WindowsApps era acusado de "sem assinatura digital".
+ A ACL de la barra ate o administrador; nao dava para verificar, e agora o texto diz isso.
+- O impacto no boot aparecia duas vezes na mesma linha.
+- `WinVerifyTrust` rodava duas vezes por entrada de inicializacao.
+
+#### Notas
+- Desinstalacao nao tem desfazer automatico, e a tela diz isso antes de confirmar.
+- `Get-AppxPackage` so funciona com `-EncodedCommand` e com as duas saidas lidas: com
+ `-Command` o PowerShell reinterpreta pipes, e sem ler o stderr o buffer de progresso
+ CLIXML enche e o processo trava. Ver docs/DECISOES.md.
+- Nada e pre-marcado nesta fase, em nenhuma das tres listas (regra 3).
+
+### Fase 3 - Analisador de espaco e biblioteca de jogos (2026-09-14)
+
+#### Adicionado
+- Varredura de volume com FindFirstFileEx e FIND_FIRST_EX_LARGE_FETCH, paralelizada por
+ subarvore. Medido: 1.548.745 arquivos e 809,5 GB em 20,3 s, acima do volume do criterio
+ da secao 5.4 e abaixo do tempo.
+- Treemap navegavel com algoritmo squarified, colorido por categoria, com clique para
+ descer e trilha para voltar.
+- Top 100 arquivos e top 50 pastas, com abrir no Explorer e enviar para a Lixeira.
+- Categorias inteligentes: jogos, videos, imagens, instaladores, caches, sistema,
+ documentos, codigo e musica. A pasta manda mais que a extensao.
+- Arquivos especiais (hiberfil.sys, pagefile.sys, Windows.old) explicados em vez de so
+ listados, com o risco de cada um.
+- Biblioteca de jogos (secao 5.11) lendo Steam, Epic, GOG e Xbox, com tamanho e quando foi
+ jogado pela ultima vez. Jogo grande e parado ha mais de 6 meses ganha selo.
+
+#### Notas
+- A MFT crua nao foi usada: FSCTL_ENUM_USN_DATA nao devolve o tamanho do arquivo. Ver
+ docs/DECISOES.md.
+- O contador de pastas inacessiveis reportava 40.871; o numero real e 543.
+- A arvore consumia 1,1 GB de RAM por guardar o caminho completo em cada no; agora sao
+ 594 MB.
+
+### Fase 2 - Limpeza e ferramentas rapidas (2026-09-13)
+
+#### Adicionado
+- Modulo Cleaner (secao 5.2): 20 alvos de limpeza, do %TEMP% ao shader cache, com
+ pagina no shell e o comando `--clean --preset seguro|completo`.
+- Whitelist de leitura separada da de remocao: a varredura pode olhar Downloads para
+ dizer quanto ha de instalador velho, sem nunca ganhar permissao de apagar de la.
+- Remocao de conteudo do usuario sempre pela Lixeira (SHFileOperation com FOF_ALLOWUNDO).
+- Historico de limpeza em cleanup-history.json.
+- Ferramentas rapidas (secao 5.13): reiniciar Explorer e driver de video, limpar cache
+ de DNS, esvaziar Lixeira, abrir Limpeza de Disco, criar ponto de restauracao, sfc e
+ DISM com saida ao vivo, teste de velocidade de disco e informacoes do sistema.
+
+#### Notas
+- Cada alvo de limpeza diz o efeito colateral antes: shader cache avisa do engasgo,
+ prefetch avisa que raramente compensa. Ha teste exigindo advertencia em todo alvo de
+ risco medio ou alto.
+- RevertAsync do Cleaner diz que nao ha reversao automatica, em vez de fingir que desfaz.
+- `ipconfig /flushdns` virou DnsFlushResolverCache, e Checkpoint-Computer virou WMI
+ SystemRestore: regra 9, sem chamar executavel quando ha API.
+- A pagina de Limpeza nao tem XAML proprio: herda a tela inteira do ModulePageViewModel.
+
+### Shell de navegacao (2026-09-13)
+
+- Menu lateral com as 11 entradas da secao 6, tema escuro mantido.
+- ModulePageViewModel: padrao de pagina de modulo reutilizavel, com cabecalho e Varrer,
+ lista generica de ActionItem, rodape com "Aplicar N selecionados" e "Reverter".
+- Paginas dos modulos ainda nao implementados explicam o que farao e em qual fase chegam.
+- Configuracoes com abas Preferencias e Historico, lendo o state-backup.
+
+### Fase 1 - Diagnostico de gargalos e relatorio de saude (2026-09-13)
+
+#### Adicionado
+- Modulo Bottleneck (secao 5.5): coleta a cada 1 s com buffer de 5 minutos, medindo CPU,
+ memoria, Standby List, GPU, disco, rede e temperatura.
+- 17 regras de diagnostico como classes IFindingRule testaveis, cada uma escrevendo em
+ linguagem humana: "Chrome esta usando 38% da CPU", nao "cpu_threshold_exceeded".
+- Deteccao de reset por atualizacao do Windows: guarda a build e avisa quando um update
+ desfez os ajustes. Nenhum concorrente faz isso.
+- FindingEngine com debounce de 30 s, entao a lista nao pisca a cada segundo.
+- Pagina Diagnostico: cinco medidores ao vivo com grafico de 60 s, top 10 processos e a
+ lista de achados com botao de acao.
+- Modulo HealthReport (secao 5.12): nota 0 a 100 por area com os 5 principais achados,
+ exportavel em HTML (arquivo unico, sem link externo) e JSON.
+- Pagina Inicio mostra a nota de saude e exporta o relatorio.
+- `GameBoost.exe --report saida.html` na CLI, com versao texto e `--json`.
+
+#### Desempenho
+- Custo da coleta: **0,115% de CPU, ciclo de 36 ms**, contra o limite de 1,5% da secao 5.5.
+ A primeira versao media 5,02% com ciclo de 1039 ms. Ver docs/DECISOES.md.
+
+#### Notas
+- Nada de PerformanceCounter por nome: num Windows pt-BR os contadores do spec nao existem
+ com aquele nome. Onde ha API nativa, usa-se API nativa.
+- Temperaturas ficam "indisponivel" nesta fase: a leitura boa exige driver de sensores.
+ Nunca aparece zero no lugar.
+
+### Fase 0 - Fundacao (2026-09-13)
+
+#### Adicionado
+- Solucao dividida em `GameBoost.Core` (sem WPF, testavel), `GameBoost.App` (WPF),
+ `GameBoost.Cli` (parser e runner) e `GameBoost.Tests` (xUnit + NSubstitute).
+- `SafetyGuard`: blindagem central consultada por todos os modulos. Listas de processos
+ criticos, anti-cheats, antivirus (WMI + estatica), caminhos e servicos protegidos.
+- `RollbackEngine` e `StateBackup`: toda alteracao de sistema grava o valor anterior antes
+ de ser aplicada e tem reversao testada. Reversao em ordem inversa; falha em um item nao
+ interrompe os demais.
+- `IGameBoostLogger` com log estruturado e rotacao (5 MB x 5 arquivos).
+- `AppSettings` com `schemaVersion` e migradores em sequencia; arquivo antigo vira `.bak`.
+- Modo portatil: `portable.txt` ao lado do exe move os dados para `.\data\`.
+- Contrato `IModule` / `ActionItem` e tela generica de lista com checkbox, badge de risco e
+ botao "?" com o que faz, qual o risco e como desfazer.
+- Modo Game portado para o contrato: varredura com blindagem, encerramento gracioso com
+ fallback forcado, limpeza de RAM (`EmptyWorkingSet` + purga da Standby List), plano de
+ energia, prioridade do jogo, silenciamento do sistema e pausa do Windows Update.
+- Deteccao de jogo por nome conhecido, pasta de launcher e heuristica de consumo.
+- CLI com a superficie da secao 8 do spec; `--scan` preserva o comportamento da v1.
+- `--dry-run` funcional em todos os caminhos: mesmo relatorio, zero escrita.
+- Modo somente leitura quando o app roda sem privilegios de administrador.
+- Aviso na abertura quando o app foi fechado com o Modo Game ativo.
+- CI no GitHub Actions: build, testes, publish e instalador.
+- Documentacao: `CLAUDE.md`, `docs/PROTECAO.md`, `docs/TWEAKS.md`, `docs/DECISOES.md`,
+ `docs/QA.md`.
+
+#### Notas
+- A v1.0.0 foi reconstruida a partir do `LEIA-ME.txt` porque o codigo-fonte original nao
+ existia mais. Ver `docs/DECISOES.md`.
+- `--report` e `--clean` estao no parser mas respondem com a fase em que chegam.
+
+## [1.0.0] - 2026-09-03
+
+Versao original. Modo Game com encerramento de processos, limpeza de RAM, plano de energia,
+prioridade do jogo, silenciamento do sistema, snapshot e reversao, e `--scan`.
+Codigo-fonte perdido.
diff --git a/GameBoost/CLAUDE.md b/GameBoost/CLAUDE.md
new file mode 100644
index 0000000..e0c17da
--- /dev/null
+++ b/GameBoost/CLAUDE.md
@@ -0,0 +1,100 @@
+# CLAUDE.md — GameBoost
+
+Regras resumidas para quem escreve código neste repositório. Geradas a partir da seção 2
+do `GAMEBOOST_V2_SPEC.md`. **Valem para todo código novo, sem exceção.**
+
+---
+
+## As 10 regras inegociáveis
+
+1. **Reversível ou não existe.** Toda alteração de sistema (registro, serviço, plano de
+ energia, tweak) grava o valor anterior num `ChangeRecord` em `state-backup.json` **antes**
+ de aplicar, e tem caminho de reversão testado em `RollbackEngineTests`.
+2. **Nunca deletar sem lixeira ou confirmação.** Arquivos de usuário vão para a Lixeira
+ (`SHFileOperation` com `FOF_ALLOWUNDO`). Temporários de sistema podem ser apagados direto,
+ mas só das pastas da whitelist do módulo.
+3. **Nada é pré-marcado se puder causar perda.** Jogos, launchers, IDEs com trabalho aberto e
+ apps de gravação aparecem na lista, nunca marcados.
+4. **Honestidade sobre ganho.** Cada ação mostra o ganho **medido** (MB liberados, apps
+ fechados, segundos de boot). Proibido inventar número. Proibido "limpeza de registro".
+5. **Sem telemetria, sem anúncios, sem upsell.** Nada sai da máquina sem o usuário clicar em
+ "Enviar diagnóstico".
+6. **Blindagem primeiro.** `ISafetyGuard` é consultado por **todos** os módulos, não só pelo
+ Modo Game.
+7. **Tudo em PT-BR.** Strings de UI em recurso para permitir EN depois.
+8. **Log de tudo.** Toda ação grava linha estruturada em `gameboost.log`
+ (timestamp, módulo, ação, alvo, resultado) via `IGameBoostLogger`.
+9. **Sem PowerShell quando houver API.** Win32/WMI/CIM via P/Invoke ou `System.Management`.
+ PowerShell só para o que não tem API razoável (`Get-AppxPackage`), sempre com
+ `-NoProfile -NonInteractive -ExecutionPolicy Bypass`.
+10. **Nunca travar a UI.** Toda varredura roda em `Task` com `IProgress` e
+ `CancellationToken`.
+
+---
+
+## Mapa de pastas
+
+```
+GameBoost/
+├── src/
+│ ├── GameBoost.Core/ Lógica pura, sem WPF. Testável.
+│ │ ├── Abstractions/ Interfaces: IProcessService, IRegistryService, IFileSystem, IClock…
+│ │ ├── Native/ P/Invoke centralizado (NativeMethods interno + Bridge público)
+│ │ ├── Safety/ SafetyGuard, ProtectedProcesses/Paths/Services, AntivirusDetector
+│ │ ├── State/ ChangeRecord, StateBackup, RollbackEngine
+│ │ ├── Logging/ Logger estruturado, rotação 5 MB × 5
+│ │ ├── Settings/ AppPaths, AppSettings, SettingsStore, SettingsMigrator
+│ │ ├── Services/ Implementações Windows das Abstractions
+│ │ └── Modules/
+│ │ ├── IModule.cs, ActionItem.cs, RiskLevel.cs
+│ │ └── GameMode/ GameModeModule, GameDetector, SystemSilencer, GameSession
+│ ├── GameBoost.App/ WPF: Views, ViewModels, Converters, Themes
+│ ├── GameBoost.Cli/ Parser e runner dos comandos de linha
+│ └── GameBoost.Tests/ xUnit + NSubstitute
+├── installer/GameBoost.iss
+└── docs/ DECISOES.md, PROTECAO.md, TWEAKS.md, QA.md
+```
+
+---
+
+## Contratos que todo módulo novo segue
+
+```csharp
+public interface IModule
+{
+ string Id { get; }
+ Task ScanAsync(IProgress? progress, CancellationToken ct);
+ Task ApplyAsync(IReadOnlyList itemIds, bool dryRun, CancellationToken ct);
+ Task RevertAsync(IReadOnlyList changeIds, bool dryRun, CancellationToken ct);
+}
+```
+
+Cada item acionável é um `ActionItem { Id, Categoria, Titulo, Descricao, Risco,
+GanhoEstimado, PreMarcado, ComoDesfazer, Bloqueado, MotivoBloqueio }`. A UI é genérica: uma
+lista de `ActionItem` com checkbox, badge de risco e botão "?".
+
+**Todo módulo precisa de `dryRun` funcional**: mesmo relatório, zero escrita. Existe teste
+provando que os fakes não recebem chamadas de escrita.
+
+---
+
+## Padrões
+
+- **MVVM** com CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`).
+- **DI** com `Microsoft.Extensions.DependencyInjection`, montado em `CoreServices`.
+ Classe de módulo recebe interfaces, nunca `static`.
+- **Elevação**: `requireAdministrator` no manifest. Sem admin o app entra em modo somente
+ leitura: varreduras funcionam, ações ficam desabilitadas com aviso.
+- **Trimming**: proibido. Quebra WPF e reflexão.
+
+---
+
+## Antes de abrir PR
+
+```powershell
+dotnet build GameBoost.sln # 0 erros, 0 avisos
+dotnet test GameBoost.sln # tudo verde
+```
+
+Qualquer decisão que fuja do `GAMEBOOST_V2_SPEC.md` vai para `docs/DECISOES.md` com data e
+motivo. Tweak novo entra em `docs/TWEAKS.md`; lista de proteção nova, em `docs/PROTECAO.md`.
diff --git a/GameBoost/Directory.Build.props b/GameBoost/Directory.Build.props
new file mode 100644
index 0000000..ffe4cd8
--- /dev/null
+++ b/GameBoost/Directory.Build.props
@@ -0,0 +1,20 @@
+
+
+
+ 2.0.0
+ GameBoost
+ Jonathan Vaz
+ Jonathan Vaz
+ Copyright (c) 2026 Jonathan Vaz
+
+ latest
+ enable
+ enable
+ false
+ nullable
+ pt-BR
+ false
+
+ false
+
+
diff --git a/GameBoost/GAMEBOOST_V2_SPEC.md b/GameBoost/GAMEBOOST_V2_SPEC.md
new file mode 100644
index 0000000..e85a774
--- /dev/null
+++ b/GameBoost/GAMEBOOST_V2_SPEC.md
@@ -0,0 +1,626 @@
+# GameBoost v2 · Especificação completa para o Claude Code
+
+> Documento de trabalho para o Claude Code evoluir o GameBoost de "booster de jogos" para "central de otimização e manutenção do PC gamer". Leia inteiro antes de escrever código. As regras da seção 2 valem para todo o projeto.
+
+---
+
+## 0. Como usar este documento
+
+1. Abra o repositório do GameBoost (`src/GameBoost/GameBoost.csproj`, `installer/GameBoost.iss`).
+2. Rode a Fase 0 (seção 12) antes de qualquer feature nova: refatorar para módulos, criar a infraestrutura compartilhada.
+3. Implemente as fases na ordem. Cada fase termina com build limpo, testes passando e um commit.
+4. Toda decisão que fugir deste documento deve ser registrada em `docs/DECISOES.md` com data e motivo.
+
+---
+
+## 1. Estado atual (v1.0.0)
+
+**Stack**: C# / .NET 9 / WPF, publish self-contained single-file, instalador Inno Setup 6, requer admin (manifest `requireAdministrator`).
+
+**O que já existe e deve ser preservado:**
+
+| Recurso | Implementação atual |
+|---|---|
+| Modo Game (ativar/desativar) | Varredura de processos, tela de confirmação pré-marcada, encerramento gracioso (3 s) depois forçado |
+| Reabertura de apps essenciais (★) | `session.json` guarda caminho + argumentos |
+| Limpeza de RAM | `EmptyWorkingSet` em todos os processos + purga da Standby List (`NtSetSystemInformation` / `SystemMemoryListInformation`) |
+| Plano de energia | Alto desempenho / Desempenho Máximo, restaura o anterior |
+| Prioridade do jogo | Detecção do jogo e prioridade Alta (nunca Realtime) |
+| Silenciar sistema | Notificações, Game DVR, Game Bar overlay, pausa Windows Update |
+| Blindagem | Lista de processos críticos, tudo em `C:\Windows`, antivírus/EDR via WMI, drivers, outras sessões |
+| Snapshot e reversão | `state-backup.json`, botão "Reverter tudo", aviso na abertura se ficou ativo, reversão no desinstalador |
+| Diagnóstico | `GameBoost.exe --scan relatorio.txt` |
+| Persistência | `%LOCALAPPDATA%\GameBoost\` (`settings.json`, `session.json`, `state-backup.json`, `gameboost.log`) |
+
+**Limitações conhecidas da v1:**
+- Uma única função (Modo Game). Usuário instala, usa uma vez, esquece.
+- Sem visão de "o que está pesando" na máquina. O usuário não sabe por que o PC está lento.
+- Sem manutenção contínua (limpeza, inicialização, espaço em disco).
+- Sem assinatura digital (SmartScreen e falsos positivos de antivírus).
+- Sem auto-update.
+
+---
+
+## 2. Regras inegociáveis (valem para todo código novo)
+
+1. **Reversível ou não existe.** Toda alteração de sistema (registro, serviço, plano de energia, tweak) grava o valor anterior em `state-backup.json` antes de aplicar e tem um caminho de reversão testado.
+2. **Nunca deletar sem lixeira ou confirmação.** Arquivos de usuário vão para a Lixeira (`SHFileOperation` com `FOF_ALLOWUNDO`). Arquivos de sistema temporários podem ser deletados direto, mas só das pastas da whitelist (seção 5.2).
+3. **Nada é pré-marcado se puder causar perda.** Jogos, launchers, IDEs com trabalho aberto, apps de gravação: aparecem, nunca vêm marcados.
+4. **Honestidade sobre ganho.** Cada ação mostra o ganho real medido (MB liberados, apps fechados, segundos de boot economizados). Proibido inventar número. Proibido "limpeza de registro" (não traz ganho mensurável e é risco puro).
+5. **Sem telemetria, sem anúncios, sem upsell.** Nada sai da máquina sem o usuário clicar em "Enviar diagnóstico".
+6. **Blindagem primeiro.** As listas de proteção (seção 9) são aplicadas em todos os módulos, não só no Modo Game.
+7. **Tudo em PT-BR**, com strings em arquivo de recursos (`Resources/Strings.pt-BR.resx`) para permitir EN depois.
+8. **Log de tudo.** Toda ação grava linha estruturada em `gameboost.log` (timestamp, módulo, ação, alvo, resultado).
+9. **Sem PowerShell quando houver API.** Prefira Win32/WMI/CIM via P/Invoke ou `System.Management`. PowerShell só para o que não tem API razoável (ex.: `Get-AppxPackage`), e sempre com `-NoProfile -NonInteractive -ExecutionPolicy Bypass`.
+10. **Nunca travar a UI.** Toda varredura roda em `Task` com `IProgress` e `CancellationToken`.
+
+---
+
+## 3. Pesquisa de mercado
+
+> Referência de concorrentes e do que os usuários pedem. Validado com fontes de 2026 (guias de otimização, reviews e fóruns) em 13/09/2026. Fontes principais listadas em 3.5.
+
+### 3.1 Concorrentes diretos (boosters de jogos)
+
+| Produto | Preço | Pontos fortes | Reclamações recorrentes dos usuários |
+|---|---|---|---|
+| **Razer Cortex** | Grátis | Ainda o "free all-rounder" mais citado em 2026: suspende serviços, limpa RAM, restaura ao sair, contador de FPS, launcher unificado | Anúncios e promoções embutidos, ganho mínimo em PC bom, relatos de conflito com apps e jogos, consumo próprio de recursos, exige conta |
+| **Wise Game Booster** | Grátis | Leve, simples, gerencia serviços e processos | Interface datada, sem monitoramento, pouco mantido |
+| **IObit Advanced SystemCare / Game Booster** | Freemium (~US$20/ano) | Suite completa (limpeza, startup, drivers, privacidade) | Bloatware, pop-ups agressivos, instala outros produtos IObit, "limpeza de registro" placebo |
+| **Smart Game Booster** | Freemium | Overlay FPS, monitor de temperatura, auto-boost | Falsos positivos de antivírus, upsell constante |
+| **Game Fire / GearUP Booster / Chris-PC Game Booster** | Freemium | Preset por jogo antes de abrir, restauração ao sair, teste antes/depois dentro do app (Game Fire e GearUP) | Pouco conhecidos, parte das funções paga |
+| **Process Lasso** | Freemium | Regras persistentes por processo, ProBalance, afinidade de CPU. Sempre citado como "o que funciona de verdade" | Curva de aprendizado, não é pensado pra leigo |
+| **Windows Game Mode + Xbox Game Bar** | Grátis (nativo) | Integração de sistema, sem instalação | Game Bar consome recursos, Game Mode faz pouco, sem controle fino |
+| **AMD Adrenalin / NVIDIA App** | Grátis | Overlay, gravação, tweaks de driver | Só GPU, não cuida de processos/disco |
+
+### 3.2 Concorrentes indiretos (limpeza e manutenção)
+
+| Categoria | Referências | O que fazem bem |
+|---|---|---|
+| Limpeza | CCleaner (Piriform/Gen), BleachBit (open source), Wise Disk Cleaner, Windows Storage Sense | Presets por app (navegadores, Discord, Steam shader cache), agendamento |
+| Análise de disco | **WizTree 4.30 (mar/2026)**: lê a MFT, varre SSD grande em segundos, trata hard links, treemap, top arquivos, busca rápida, export CSV e localizador de duplicados desde a v4. WinDirStat, TreeSize Free, SpaceSniffer | É a referência unânime em 2026 ("free, blindingly fast"). Não apaga nada sozinho, só mostra e abre no Explorer. Guias sempre listam os mesmos suspeitos: hiberfil.sys, Windows.old (20 GB+ após update), temp, jogos de 80 a 150 GB, backups velhos |
+| Desinstalação | **Bulk Crap Uninstaller** (Apache 2.0, acha desinstaladores quebrados/ocultos, portáteis, Store, Windows Features, Chocolatey, apps de lojas de jogos), **Revo Uninstaller Free 2.7.0 (mai/2026)** com Hunter Mode pra apps da Store e restos melhorados, Geek Uninstaller | Varredura de restos após desinstalar (com aviso de que não é garantida), desinstalação silenciosa em lote, remoção de bloatware da Store |
+| Inicialização | Autoruns (Sysinternals), Gerenciador de Tarefas, Startup Delayer | Mostrar impacto real no boot, tarefas agendadas, serviços |
+| Gerência de processos | Process Lasso, Process Explorer | Regras por processo persistentes, afinidade de CPU, ProBalance |
+| Tweaks | Chris Titus WinUtil, Winaero Tweaker, O&O ShutUp10++, Atlas OS / ReviOS | Tweaks documentados, reversíveis, com explicação de cada um |
+| Monitoramento | HWiNFO64, MSI Afterburner + RTSS, LibreHardwareMonitor, LatencyMon | Temperaturas, clocks, throttling, DPC latency, overlay em jogo |
+| Drivers | Driver Booster (IObit), DDU, Snappy Driver Installer | Detectar driver de GPU desatualizado (mas instalar driver automaticamente é risco) |
+
+### 3.3 O que os usuários realmente pedem (Reddit r/pcgaming, r/Windows11, r/pcmasterrace, Steam forums, reviews da Microsoft Store)
+
+**Querem:**
+1. Saber **o que está pesando** o PC agora (o "por que está lento?").
+2. **Espaço em disco**: onde estão os GB perdidos (shader cache, Windows.old, downloads antigos, jogos que não jogam mais).
+3. **Desinstalar de verdade**, inclusive bloatware da Store e apps que reaparecem.
+4. **Inicialização limpa**: o PC demora pra ligar e não sabem o que desativar com segurança.
+5. **Ganho de FPS honesto**: dizer claramente o que faz diferença (fechar Chrome/Discord overlay, plano de energia, driver de GPU, HAGS, Fullscreen Optimizations) e o que é placebo.
+6. **Leve, sem anúncio, sem conta, sem assinatura**, de preferência portátil.
+7. **Explicação em cada opção**: "o que isso faz, qual o risco, como desfazer".
+8. **Não quebrar nada**: medo de tweak que trava Windows Update, some com o Wi-Fi, quebra a impressora.
+9. **Perfis por jogo**: ao abrir Valorant faz X, ao abrir Cyberpunk faz Y.
+10. **Temperatura e throttling**: saber se o FPS caiu porque a CPU/GPU está esquentando.
+
+**Odeiam:**
+- Limpeza de registro, "otimização de DNS mágica", "acelerador de RAM" sem número.
+- Pop-up de upgrade, instalação de toolbar, envio de dados.
+- App que fecha o anti-cheat ou o próprio jogo.
+- Alterações sem explicação e sem "desfazer".
+
+### 3.4 Consenso técnico de 2026 sobre o que realmente dá ganho
+
+Os guias mais sérios convergem: cerca de seis ajustes explicam quase todo o ganho real, o resto é placebo, já é padrão ou troca segurança por poucos %.
+
+1. **Plano de energia em Alto desempenho / Melhor desempenho** (evita clock baixo).
+2. **Podar inicialização e apps em background** (junto com o item 1, são os dois de maior valor e os mais fáceis de reverter).
+3. **Game Mode do Windows ligado** (versão 22H2+ ajuda e evita Windows Update durante o jogo; só desligar se houver conflito).
+4. **Game Bar / gravação em segundo plano desligados.**
+5. **Jogo em SSD** (lançamentos de 2025/2026 já exigem SSD e engasgam em HDD).
+6. **Driver de GPU atualizado da fonte oficial** e monitor configurado na taxa de atualização certa (Windows volta pra 60 Hz depois de driver ou troca de cabo com frequência).
+
+Pontos que exigem teste, não regra:
+- **HAGS**: obrigatório para DLSS Frame Generation; melhora frame pacing em GPUs recentes (RTX 30+, RX 6000+) com driver atual, mas causa stutter em CPUs antigas ou certas versões de driver. É "o único ajuste que você precisa testar em vez de só ligar". Custa até 1 GB de VRAM (atenção em placas de 8 GB).
+- **VBS / Memory Integrity (Core Isolation)**: vem ligado por padrão no Windows 11 e os guias medem 5 a 15% de FPS em jogos CPU-bound (até 25% em casos extremos). É recurso de segurança contra malware de kernel. Consenso: desligar só em PC exclusivo de jogo, manter em máquina de trabalho/banco. Por isso o GameBoost **informa e mede, mas não desliga**: mostra o estado, o impacto estimado e o caminho nas Configurações do Windows.
+- **Updates grandes do Windows (24H2, 26H2) resetam** Game Mode, HAGS, plano de energia, efeitos visuais e Game Bar. O GameBoost deve detectar isso e avisar "seu perfil foi desfeito por uma atualização".
+
+Placebo ou risco confirmados: limpeza de registro, "defrag" em SSD, updaters de driver automáticos, tweaks agressivos de serviço.
+
+### 3.5 Fontes consultadas (set/2026)
+- Frozen Tweaks, Gear-Rank, SmoothFPS, Switchblade Gaming, TechFixGrid: guias de otimização Windows 11 2026 (VBS, HAGS, Game Mode, 26H2).
+- iTechGuides, TechBloat, TechBre, Techlasi, Gitnux, Worldmetrics: rankings de boosters 2026.
+- Neat Net Tricks e Swastiktechnology: reviews WizTree 2026; RottenWifi: BCU, Revo 2.7.0, Storage Sense.
+- Steam Community e Tom's Guide: percepção de usuário sobre boosters ("snake oil", "bloatware em PC bem cuidado").
+- Microsoft Learn: SmartScreen reputation, opções de assinatura de código, Azure Artifact Signing.
+
+### 3.6 Posicionamento do GameBoost v2
+
+**"O booster que mostra o que faz e desfaz tudo."** Diferenciais:
+- Transparência total (cada botão explica antes, mede depois, reverte sempre).
+- Zero anúncios, zero telemetria, PT-BR nativo.
+- Diagnóstico de gargalo em linguagem humana ("seu Chrome está usando 40% da CPU com 62 abas").
+- Portátil opcional (mesmo exe roda sem instalar).
+- **Prova de ganho**: medição antes/depois em cada ação (Game Fire e GearUP já fazem isso e os reviews valorizam muito).
+- **Detector de reset pós-update**: nenhum concorrente avisa que o Windows desfez os ajustes.
+
+---
+
+## 4. Arquitetura alvo
+
+### 4.1 Estrutura de pastas
+
+```
+GameBoost/
+├── src/
+│ ├── GameBoost.Core/ # Lógica pura, sem WPF. Testável.
+│ │ ├── Abstractions/ # Interfaces (IProcessService, IRegistryService, IFileSystem, IClock...)
+│ │ ├── Native/ # P/Invoke centralizado (Kernel32, Psapi, NtDll, Advapi32, Shell32, PowrProf)
+│ │ ├── Safety/ # Blindagem: ProtectedProcesses, ProtectedPaths, ProtectedServices, AntiCheatList
+│ │ ├── State/ # Snapshot/rollback (StateBackup, ChangeRecord, RollbackEngine)
+│ │ ├── Logging/ # Logger estruturado (JSON lines + texto legível)
+│ │ ├── Settings/ # Settings, perfis, migração de versão
+│ │ └── Modules/
+│ │ ├── GameMode/ # (existente, refatorado)
+│ │ ├── Cleaner/ # Limpeza de temporários e caches
+│ │ ├── Uninstaller/ # Desinstalador Win32 + Store + restos
+│ │ ├── DiskAnalyzer/ # Análise de espaço (MFT + fallback)
+│ │ ├── Bottleneck/ # Monitor e diagnóstico de gargalos
+│ │ ├── Startup/ # Inicialização, tarefas agendadas
+│ │ ├── Services/ # Serviços do Windows
+│ │ ├── Tweaks/ # Tweaks de jogos reversíveis
+│ │ ├── Network/ # Velocidade, NAT (STUN), UPnP, Teredo, firewall
+│ │ ├── Drivers/ # Verificação de drivers de GPU
+│ │ ├── Profiles/ # Perfis por jogo
+│ │ └── HealthReport/ # Relatório e pontuação
+│ ├── GameBoost.App/ # WPF: Views, ViewModels, Converters, Themes
+│ │ ├── Views/
+│ │ ├── ViewModels/
+│ │ ├── Controls/ # Treemap, Gauge, ProcessList, ConfirmDialog
+│ │ ├── Themes/ # Dark.xaml (padrão), Light.xaml
+│ │ └── Resources/Strings.pt-BR.resx
+│ ├── GameBoost.Cli/ # Modo linha de comando (--scan, --clean, --report)
+│ └── GameBoost.Tests/ # xUnit + NSubstitute
+├── installer/GameBoost.iss
+├── docs/
+│ ├── DECISOES.md
+│ ├── TWEAKS.md # Catálogo de cada tweak: chave, valor, efeito, risco, fonte
+│ ├── PORTAS.md # Portas por jogo/launcher para UPnP e firewall
+│ └── PROTECAO.md # Listas de blindagem documentadas
+├── CLAUDE.md # Regras resumidas para o Claude Code (gerar a partir da seção 2)
+└── README.md
+```
+
+### 4.2 Padrões
+
+- **MVVM** com CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`).
+- **DI** com `Microsoft.Extensions.DependencyInjection`. Toda classe de módulo recebe interfaces, nunca `static`.
+- **Cada módulo expõe** `IModule` com: `ScanAsync(progress, ct)` → `ScanResult`, `ApplyAsync(selection, ct)` → `ApplyResult`, `RevertAsync(changeIds)`.
+- **Cada item acionável** é um `ActionItem { Id, Categoria, Titulo, Descricao, Risco (Baixo/Medio/Alto), GanhoEstimado, PreMarcado, ComoDesfazer }`. A UI é genérica: uma lista de `ActionItem` com checkbox, badge de risco e botão "?" com a explicação.
+- **Elevação**: manter `requireAdministrator`. Adicionar modo "somente leitura" quando rodar sem admin (varreduras funcionam, ações ficam desabilitadas com aviso).
+
+---
+
+## 5. Módulos: especificação funcional
+
+Cada módulo abaixo tem: objetivo, fonte de dados/API, itens gerados, regras, UI, critérios de aceite.
+
+### 5.1 Modo Game (existente, melhorias)
+
+**Melhorias:**
+- Detecção automática de jogo: monitorar criação de processos (WMI `Win32_ProcessStartTrace` ou polling a cada 2 s) e cruzar com (a) lista de jogos conhecidos, (b) executáveis dentro de `steamapps/common`, `Epic Games`, `Riot Games`, `Battle.net`, `GOG Galaxy/Games`, `XboxGames`, (c) janela em fullscreen exclusivo/borderless ocupando 100% da tela. Ao detectar, oferecer "Ativar Modo Game para ?" via notificação (ou ativar direto se o perfil do jogo mandar).
+- Timer resolution: `NtSetTimerResolution(0.5 ms)` enquanto Modo Game ativo (Windows 11 já lida bem, mas em 10 ajuda). Reverter ao sair.
+- Afinidade de CPU opcional para o jogo (perfil avançado): permitir reservar núcleos. Padrão desligado.
+- Modo "Só o essencial": encerrar apenas processos com >2% CPU ou >300 MB RAM nos últimos 30 s (evita lista gigante).
+- Exibir ganho: RAM livre antes/depois, nº de processos fechados, % CPU em background antes/depois.
+
+**Critérios de aceite:** ativar e desativar 10 vezes seguidas sem vazar estado; matar o app durante o modo ativo e reabrir mostra o aviso de restauração; jogo nunca aparece pré-marcado.
+
+### 5.2 Limpeza de arquivos temporários e caches
+
+**Objetivo:** liberar espaço com segurança, mostrando o que será apagado e quanto ocupa.
+
+**Categorias e alvos (whitelist, só estes caminhos):**
+
+| Categoria | Caminhos | Risco | Pré-marcado |
+|---|---|---|---|
+| Temp do usuário | `%TEMP%` (arquivos não bloqueados, >24 h) | Baixo | Sim |
+| Temp do sistema | `C:\Windows\Temp` | Baixo | Sim |
+| Prefetch | `C:\Windows\Prefetch\*.pf` mais antigos que 30 dias | Baixo | Não (afeta boot por alguns dias) |
+| Windows Update cache | `C:\Windows\SoftwareDistribution\Download` (parar serviço `wuauserv` antes) | Baixo | Sim |
+| Delivery Optimization | via `IDOManager` ou `Get-DeliveryOptimizationStatus`; simples: `Clear-DeliveryOptimizationCache` não existe, usar Limpeza de Disco API `cleanmgr /sageset` presets ou pasta `%WINDIR%\ServiceProfiles\NetworkService\AppData\Local\Microsoft\Windows\DeliveryOptimization\Cache` | Baixo | Sim |
+| Windows.old | `C:\Windows.old` (só se existir; usar `cleanmgr` handler "Previous Windows installation(s)" ou takeown + delete) | Médio | Não |
+| Lixeira | `SHEmptyRecycleBin` | Médio | Não |
+| Miniaturas / cache de ícones | `%LOCALAPPDATA%\Microsoft\Windows\Explorer\thumbcache_*.db`, `iconcache_*.db` | Baixo | Não |
+| Logs do Windows | `C:\Windows\Logs\CBS\*.log` antigos, `%WINDIR%\Panther` (não), `C:\ProgramData\Microsoft\Windows\WER\ReportQueue` | Baixo | Sim (só WER) |
+| Dumps de memória | `C:\Windows\Minidump`, `C:\Windows\MEMORY.DMP` | Baixo | Sim |
+| Cache de navegadores | Chrome/Edge/Brave/Opera (`User Data\\Cache`, `Code Cache`, `GPUCache`), Firefox (`cache2`). NUNCA cookies, senhas, histórico | Baixo | Sim, só se navegador fechado |
+| Discord | `%APPDATA%\discord\Cache`, `Code Cache`, `GPUCache` | Baixo | Sim, se fechado |
+| Spotify | `%LOCALAPPDATA%\Spotify\Storage`, `Data` | Baixo | Não |
+| Shader cache | NVIDIA: `%LOCALAPPDATA%\NVIDIA\DXCache`, `%LOCALAPPDATA%\NVIDIA\GLCache`, `%PROGRAMDATA%\NVIDIA Corporation\NV_Cache`; AMD: `%LOCALAPPDATA%\AMD\DxCache`, `GLCache`; DirectX: `%LOCALAPPDATA%\D3DSCache`; Steam: `Steam\steamapps\shadercache` | Médio (jogos vão engasgar ao recompilar) | Não, com explicação |
+| Steam | `Steam\appcache\httpcache`, `Steam\logs`, `Steam\dumps` | Baixo | Sim |
+| Epic / Riot / Battle.net | Logs e caches de cada launcher (documentar caminhos em `TWEAKS.md`) | Baixo | Sim |
+| Instaladores esquecidos | `%USERPROFILE%\Downloads\*.exe|*.msi|*.zip` > 30 dias | Alto | Nunca. Apenas listar em "Sugestões" |
+
+**Regras:**
+- Varredura calcula tamanho por categoria antes de mostrar. Exibir total "até X GB".
+- Arquivos em uso (`IOException` sharing violation) são pulados e contados em "não foi possível remover N arquivos (em uso)".
+- Cache de navegador/Discord só limpa se o processo não estiver rodando; senão, oferece "Fechar e limpar".
+- Nunca tocar em: `AppData\Roaming` de jogos (saves), `Documents`, `Pictures`, `OneDrive`, qualquer pasta com `.git`.
+- Guardar histórico: `cleanup-history.json` com data, categorias, MB liberados.
+- Agendamento opcional: tarefa no Agendador (`schtasks`) semanal com `GameBoost.exe --clean --preset seguro`.
+
+**Critérios de aceite:** rodar limpeza duas vezes seguidas, segunda vez libera ~0; nenhum arquivo fora da whitelist é tocado (teste com `IFileSystem` fake registrando todos os caminhos); tamanho reportado bate com o `du` real com margem de 5%.
+
+### 5.3 Desinstalador de aplicativos
+
+**Objetivo:** listar tudo instalado (Win32, Store/UWP, portáteis detectados), permitir desinstalação em lote e limpar restos.
+
+**Fontes:**
+- Registro: `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, `HKLM\SOFTWARE\WOW6432Node\...\Uninstall`, `HKCU\...\Uninstall`. Ler `DisplayName`, `DisplayVersion`, `Publisher`, `InstallDate`, `InstallLocation`, `EstimatedSize`, `UninstallString`, `QuietUninstallString`, `WindowsInstaller` (MSI → `msiexec /x {GUID} /qn`).
+- Store/UWP: `Get-AppxPackage -AllUsers` via PowerShell (ou `PackageManager` da WinRT via `Windows.Management.Deployment`). Marcar `NonRemovable`, `IsFramework` e pacotes de sistema como protegidos.
+- Uso real: cruzar com `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\UserAssist` (decodificar ROT13) e `LastWriteTime` dos executáveis para mostrar "último uso" e "nunca usado".
+- Tamanho real: `EstimatedSize` do registro é pouco confiável; se `InstallLocation` existir, calcular tamanho da pasta em background.
+
+**Lista de bloatware conhecido (sugestão, nunca pré-marcado):** Candy Crush, Spotify (Store), TikTok, Xbox TCUI (não remover se for jogar), Microsoft Solitaire, 3D Viewer, Clipchamp, LinkedIn, McAfee/Norton trial, Booking.com, Disney+, Netflix (Store shortcut), OEM utilities (HP Wolf, Lenovo Vantage é útil, cuidado), Cortana, Feedback Hub, Get Help, Mixed Reality Portal.
+
+**Nunca desinstalar (protegido):** Visual C++ Redistributables, .NET Runtimes, DirectX, drivers de GPU/áudio/chipset, Microsoft Edge (WebView2 é dependência), Xbox Identity Provider e Xbox Game Services (anti-cheat e jogos do Game Pass dependem), antivírus ativo, qualquer app do publisher "Microsoft Corporation" da lista de frameworks.
+
+**Fluxo:**
+1. Lista com busca, filtros (Store / Win32 / Grandes / Nunca usados / Bloatware sugerido), ordenação por tamanho e último uso.
+2. Seleção múltipla → "Desinstalar N apps" → tela de confirmação com badge de risco.
+3. Execução sequencial: preferir `QuietUninstallString`; se não houver, rodar `UninstallString` e mostrar aviso "este app abre o desinstalador próprio". Timeout de 10 min por app.
+4. Após cada desinstalação, **varredura de restos**: pasta `InstallLocation` residual, `%APPDATA%\`, `%LOCALAPPDATA%\`, `%PROGRAMDATA%\`, chaves `HKCU\Software\` e `HKLM\Software\`. Só sugerir se o nome tiver match exato (nunca substring curta). Restos vão para a Lixeira / chave exportada em `.reg` antes de apagar.
+5. Ponto de restauração do sistema opcional antes de lote grande (`Checkpoint-Computer` via PowerShell ou `SystemRestore` WMI), com aviso de que o Windows limita a 1 por 24 h por padrão.
+
+**Aba "Atualizações" (winget):**
+- Verificar se o `winget` está disponível (`winget --version`; vem com o App Installer no Windows 10 1709+ e 11). Se não estiver, oferecer instalar pela Store (`ms-appinstaller:`).
+- Listar desatualizados com `winget upgrade --include-unknown --accept-source-agreements --disable-interactivity` e parsear a saída (ou usar `--output json` quando disponível na versão instalada; detectar pela versão do winget). Colunas: nome, versão atual, versão nova, fonte (winget / msstore).
+- Cruzar com a lista de instalados da aba principal pra mostrar tamanho e último uso ao lado.
+- Ações: "Atualizar selecionados" (`winget upgrade --id --silent --accept-package-agreements --accept-source-agreements`) e "Atualizar tudo" (mesmo comando por item, em sequência, com progresso por app; evitar `--all` pra poder pular e mostrar erro por app).
+- Regras:
+ - Nunca pré-marcar launchers e apps que se atualizam sozinhos (Steam, Epic, Discord, navegadores, Spotify, NVIDIA App): mostrar com tag "atualiza sozinho" e explicar que forçar pelo winget pode conflitar.
+ - Nunca atualizar drivers ou runtimes (Visual C++, .NET) sem o usuário marcar explicitamente; badge de risco Médio.
+ - Se um app estiver aberto, avisar "feche X antes" e oferecer fechar (reutilizando a lógica de encerramento gracioso do Modo Game).
+ - Apps com versão "desconhecida" (`--include-unknown`) ficam em grupo separado com aviso de que o winget não consegue comparar.
+ - Log de cada atualização em `updates-history.json`; se um update falhar, mostrar o código de erro do winget e link pro log em `%LOCALAPPDATA%\Packages\Microsoft.DesktopAppInstaller_*\LocalState\DiagOutputDir`.
+- Finding no Diagnóstico: "N apps desatualizados (M de segurança: navegadores, Java, 7-Zip)".
+- Agendamento opcional: checar semanalmente e notificar na bandeja, nunca atualizar sozinho.
+- Também pode ser usado pra **desinstalar** apps que o winget conhece (`winget uninstall --id`), como alternativa quando o `UninstallString` estiver quebrado.
+
+**Critérios de aceite:** lista bate com "Aplicativos instalados" do Windows (±2 itens de diferença aceitável por apps ocultos); desinstalação silenciosa de um app MSI de teste funciona; restos de app removido são detectados; nenhum item protegido aparece selecionável.
+
+### 5.4 Analisador de espaço em disco
+
+**Objetivo:** responder "onde foram parar meus GB" em segundos, estilo WizTree.
+
+**Implementação:**
+- **Caminho rápido (NTFS):** ler a MFT diretamente com `FSCTL_ENUM_USN_DATA` (`DeviceIoControl` em `\\.\C:`), reconstruir a árvore por `ParentFileReferenceNumber`, obter tamanho via `FSCTL_GET_RETRIEVAL_POINTERS` ou, mais simples, via `NtQueryDirectoryFile`/`FindFirstFileEx` com `FIND_FIRST_EX_LARGE_FETCH` só nos diretórios já enumerados. Alternativa aceitável na primeira versão: `FindFirstFileEx` recursivo paralelizado com `FIND_FIRST_EX_LARGE_FETCH`, que já é bem mais rápido que `DirectoryInfo`.
+- **Fallback (FAT/exFAT/sem admin):** enumeração recursiva normal.
+- Resultado em árvore com tamanho agregado, contagem de arquivos, extensão dominante.
+
+**UI:**
+- **Treemap** (controle próprio em WPF, retângulos por tamanho, cor por categoria: jogos, vídeo, instaladores, cache, sistema, outros). Clique para descer nível, breadcrumb para voltar.
+- **Top 100 maiores arquivos** e **Top 50 maiores pastas**.
+- **Categorias inteligentes:** Jogos (pastas de launchers), Vídeos (>500 MB `.mp4/.mkv`), Instaladores (`.exe/.msi/.iso` em Downloads), Caches, Windows.old, Arquivos de hibernação (`hiberfil.sys`) e paginação (`pagefile.sys`) com explicação e opção "Desativar hibernação (`powercfg /h off`)" com badge de risco Médio.
+- Ações por item: abrir no Explorer, mover para Lixeira, "Ignorar sempre".
+- Duplicados (fase posterior): hash parcial (primeiros 64 KB + tamanho) e só depois hash completo dos candidatos.
+
+**Critérios de aceite:** varredura de 500 GB com 1 milhão de arquivos em < 30 s via MFT em SSD; treemap responde a clique em < 100 ms; nunca oferece apagar nada dentro de `C:\Windows`, `Program Files` (só via Desinstalador) ou pastas de saves.
+
+### 5.5 Diagnóstico de gargalos (o "por que está lento?")
+
+**Objetivo:** mostrar em tempo real e em linguagem simples o que está consumindo CPU, GPU, RAM, disco e rede, e quem é o culpado.
+
+**Coleta (intervalo 1 s, buffer de 5 min em memória):**
+- **CPU:** total e por processo (`PerformanceCounter` ou delta de `Process.TotalProcessorTime`; preferir `NtQuerySystemInformation(SystemProcessInformation)` que traz tudo numa chamada). Frequência atual vs. base (`Win32_Processor.CurrentClockSpeed` é lento; usar contador `\Processor Information(_Total)\% Processor Performance`).
+- **RAM:** `GlobalMemoryStatusEx`, Standby List, Commit, por processo (Working Set privado).
+- **GPU:** contadores `\GPU Engine(*)\Utilization Percentage` e `\GPU Adapter Memory(*)\Dedicated Usage` (disponíveis no Windows 10 1709+). Por processo via instância do contador (`pid_XXXX_luid...`). Temperatura e clocks via **LibreHardwareMonitorLib** (NuGet, MPL 2.0) para NVIDIA/AMD/Intel.
+- **Disco:** `\PhysicalDisk(*)\% Idle Time`, fila, MB/s; por processo via ETW (`Microsoft.Diagnostics.Tracing.TraceEvent`, kernel provider `DiskIO`) ou, mais simples, `Process` IO counters delta (`GetProcessIoCounters`).
+- **Rede:** `\Network Interface(*)\Bytes Total/sec`; por processo via `GetExtendedTcpTable` + ETW `TcpIp` provider (fase posterior).
+- **Temperaturas:** CPU package, GPU core, NVMe (LibreHardwareMonitor). Detectar **throttling**: CPU com `% Processor Performance` < 80% enquanto uso > 70% e temperatura > 90 °C.
+- **Latência DPC/ISR** (opcional, avançado): ETW kernel `DPC`/`Interrupt` events para apontar driver problemático (estilo LatencyMon). Marcar como experimental.
+
+**Diagnóstico automático (regras, cada uma gera um `Finding` com texto humano e ação sugerida):**
+
+| Regra | Texto exemplo | Ação sugerida |
+|---|---|---|
+| Processo > 25% CPU por 30 s fora do jogo | "Chrome está usando 38% da CPU (62 abas abertas)" | Fechar / adicionar ao Modo Game |
+| RAM disponível < 10% | "Só 1,2 GB de RAM livre. Discord + Chrome usam 4,1 GB" | Limpar RAM / fechar apps |
+| Standby List > 40% da RAM | "6 GB de RAM presos em cache do sistema" | Purgar Standby |
+| Disco do sistema > 90% ocupado | "C: com 94% de uso. SSD cheio fica lento" | Abrir Analisador de Disco |
+| Disco com % Idle < 20% por 30 s | "Seu disco está no limite. Windows Search está indexando" | Pausar indexação / ver processo |
+| GPU > 95% e FPS baixo | "GPU no máximo. Reduza resolução/qualidade ou ative DLSS/FSR" | Informativo |
+| CPU > 95% e GPU < 60% em jogo | "Gargalo de CPU: o processador não alimenta a placa de vídeo" | Fechar apps / prioridade |
+| Throttling térmico | "CPU a 97 °C reduzindo clock. Limpe o cooler / ajuste curva" | Informativo |
+| Plano de energia balanceado em jogo | "Plano Balanceado ativo durante o jogo" | Ativar Alto desempenho |
+| Driver de GPU > 6 meses | "Driver NVIDIA de fev/2026. Nova versão disponível" | Abrir página oficial |
+| Muitos apps na inicialização | "23 programas iniciam com o Windows (boot estimado +48 s)" | Abrir Inicialização |
+| Windows Search / Defender / Update usando disco | "Windows Defender fazendo varredura completa" | Informativo (nunca desativar Defender) |
+| Xbox Game Bar / Game DVR ligado | "Gravação em segundo plano ativa" | Desativar (reversível) |
+| Build do Windows mudou desde a última execução e tweaks/perfil não batem mais | "A atualização 26H2 desfez 4 ajustes seus" | Reaplicar perfil |
+| Monitor a 60 Hz sendo capaz de mais (`EnumDisplaySettings`) | "Seu monitor de 165 Hz está em 60 Hz" | Abrir configurações de vídeo |
+| VBS / Memory Integrity ativo | "Isolamento do núcleo ativo (custo típico 5 a 15% em jogos CPU-bound)" | Informativo com link |
+| HDD como disco do jogo | "Jogo instalado em HDD. Mova para SSD" | Informativo |
+| Memória em single channel | Via `Win32_PhysicalMemory` contagem de pentes | Informativo |
+
+**UI:** painel com 5 medidores (CPU, GPU, RAM, Disco, Rede) + temperatura, gráfico de 60 s, tabela "Top 10 processos" por recurso selecionado, e lista de `Findings` ordenada por impacto. Modo compacto "sempre no topo" (janela pequena) para deixar ao lado do jogo em segundo monitor.
+
+**Critérios de aceite:** coleta consome < 1,5% de CPU e < 60 MB de RAM; funciona sem LibreHardwareMonitor (temperaturas ficam "indisponível"); Findings não repetem a cada segundo (debounce de 30 s).
+
+### 5.6 Gerenciador de inicialização
+
+**Fontes:** `HKCU/HKLM\...\Run`, `RunOnce`, pasta Startup do usuário e comum, `HKLM\...\Explorer\StartupApproved` (estado ativado/desativado igual ao Gerenciador de Tarefas), Tarefas Agendadas com gatilho "no logon" (`Microsoft.Win32.TaskScheduler` NuGet ou `schtasks /query /xml`), serviços com início Automático de terceiros.
+
+**Dados por item:** nome, publisher, caminho, assinado digitalmente (sim/não, via `WinVerifyTrust` ou `X509Certificate.CreateFromSignedFile`), impacto no boot (ler `HKCU\...\Explorer\StartupApproved` não traz impacto; estimar via tamanho do exe + nº de DLLs importadas, ou usar dados de `Windows Performance` se disponíveis; mostrar como Baixo/Médio/Alto estimado).
+
+**Ações:** Desativar (gravar em `StartupApproved` com o mesmo formato do Gerenciador de Tarefas, para o Windows mostrar coerente), Atrasar (criar tarefa agendada com delay de N s e desativar a entrada original), Abrir local, Ver no Desinstalador.
+
+**Protegidos:** drivers de áudio/vídeo, antivírus, OneDrive (avisar), Bluetooth/touchpad OEM, SecurityHealth. Sugerir desativar: Spotify, Discord (se o usuário quiser abrir manualmente), Steam/Epic/EA app, Teams, Adobe Updater, iTunes Helper, Cortana.
+
+### 5.7 Serviços do Windows
+
+Lista de serviços com filtro "terceiros" e "otimizáveis para jogos". Tweaks reversíveis (guardar `StartType` anterior):
+
+| Serviço | Ação sugerida | Risco |
+|---|---|---|
+| `SysMain` (Superfetch) | Desativar só em SSD com <16 GB RAM? Não. Manter. Apenas explicar | Médio |
+| `WSearch` | Manual (busca do Explorer fica lenta) | Baixo |
+| `DiagTrack` (telemetria) | Desativar | Baixo |
+| `XblAuthManager`, `XblGameSave`, `XboxNetApiSvc` | Manter se usa Game Pass / anti-cheat da MS. Explicar | Médio |
+| `Fax`, `RemoteRegistry`, `WMPNetworkSvc`, `MapsBroker` | Desativar | Baixo |
+| `Spooler` | Manual se não tem impressora | Baixo |
+| `wuauserv` | Nunca desativar. Só pausar dentro do Modo Game (já existe) | Alto |
+| Serviços de antivírus, `WinDefend`, `SecurityHealthService` | Protegido | Alto |
+
+### 5.8 Tweaks de jogos (catálogo documentado em `docs/TWEAKS.md`)
+
+Cada tweak: chave/valor, efeito, evidência, risco, reversão. Todos desligados por padrão, aplicados individualmente, com snapshot.
+
+| Tweak | Onde | Efeito real | Risco |
+|---|---|---|---|
+| Hardware-Accelerated GPU Scheduling (HAGS) | `HKLM\SYSTEM\CurrentControlSet\Control\GraphicsDrivers\HwSchMode = 2` (reboot) | Obrigatório para DLSS Frame Gen; melhora frame pacing em RTX 30+/RX 6000+ com driver atual, piora em CPU antiga. Usa até 1 GB de VRAM. Oferecer "Testar com/sem" com benchmark rápido | Médio |
+| Game Mode do Windows | `HKCU\Software\Microsoft\GameBar\AutoGameModeEnabled` | Positivo no 22H2+ (bloqueia Update durante o jogo). Recomendar ligado; desligar só se der conflito com streaming | Baixo |
+| Game DVR / Game Bar | `HKCU\System\GameConfigStore\GameDVR_Enabled = 0`, `HKCU\Software\Microsoft\GameBar\UseNexusForGameBarEnabled = 0` | Reduz overhead de gravação | Baixo |
+| Fullscreen Optimizations por exe | `HKCU\System\GameConfigStore` `GameDVR_FSEBehaviorMode`, e `HKCU\...\AppCompatFlags\Layers` `DISABLEDXMAXIMIZEDWINDOWEDMODE` por jogo | Depende do jogo; oferecer por perfil | Baixo |
+| Multi-Plane Overlay (MPO) | `HKLM\SOFTWARE\Microsoft\Windows\Dwm\OverlayTestMode = 5` | Resolve stutter/flicker em alguns setups NVIDIA | Médio |
+| Nagle (TcpAckFrequency, TCPNoDelay) | `HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` | Ganho marginal em jogos com muitos pacotes pequenos; a maioria já usa UDP. Explicar honestamente | Baixo |
+| Network Throttling Index | `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Multimedia\SystemProfile\NetworkThrottlingIndex = 0xFFFFFFFF` | Marginal | Baixo |
+| SystemResponsiveness | mesma chave, `SystemResponsiveness = 0` | Prioriza foreground | Baixo |
+| Games task priority | `...\SystemProfile\Tasks\Games` `GPU Priority = 8`, `Priority = 6`, `Scheduling Category = High` | Marginal | Baixo |
+| Mouse: desativar aceleração | `HKCU\Control Panel\Mouse` `MouseSpeed=0`, `MouseThreshold1/2=0` | Consistência de mira | Baixo |
+| Power throttling off para o jogo | `powercfg /setacvalueindex ... PERFBOOSTMODE` e `HKLM\...\Power\PowerThrottling\PowerThrottlingOff` | Evita clock baixo em notebook | Baixo |
+| Desativar hibernação | `powercfg /h off` | Libera espaço = tamanho da RAM, perde Inicialização Rápida | Médio |
+| Ultimate Performance plan | `powercfg /duplicatescheme e9a42b02-...` | Já existe no Modo Game | Baixo |
+| Visual effects "melhor desempenho" | `HKCU\...\Explorer\VisualEffects` | Ajuda só em máquina muito fraca; afeta UX | Baixo |
+| Timer resolution | Já no Modo Game | Baixo |
+| Core Isolation / VBS (Memory Integrity) | **Não alterar.** Detectar via `msinfo32`/WMI `Win32_DeviceGuard` e mostrar Finding: "VBS ativo, custo medido pela comunidade de 5 a 15% em jogos CPU-bound". Botão abre Configurações → Segurança do Windows → Isolamento do núcleo. Explicar o trade-off (PC só de jogo vs. máquina de trabalho/banco) | Alto |
+| Spectre/Meltdown mitigations | **Nunca.** | Alto |
+
+### 5.9 Rede: velocidade, NAT e conectividade para jogar online
+
+**Objetivo:** responder "minha internet está boa pra jogar?" e "por que não consigo entrar na partida/festa do meu amigo?" (NAT estrito), corrigindo o que for do Windows e apontando com precisão o que é do roteador ou da operadora.
+
+**5.9.1 Teste de velocidade**
+- Download/upload contra endpoints públicos (Cloudflare `speed.cloudflare.com/__down?bytes=N` e `__up`; fallback: arquivos de teste de CDNs conhecidas). Rodar 3 amostras de 10 s, reportar mediana. Nunca usar API da Ookla (termos restritos).
+- Ping/jitter/perda (ICMP `Ping` + TCP connect quando ICMP bloqueado) para: gateway, 1.1.1.1, 8.8.8.8 e servidores de jogo cadastrados pelo usuário (lista inicial com regiões de Riot, Valve, Blizzard, Epic, EA em São Paulo).
+- Resultado com semáforo: ping < 30 ms verde, 30 a 80 amarelo, > 80 vermelho; jitter > 15 ms e perda > 1% viram Finding.
+- Histórico em `network-history.json` pra mostrar "sua conexão piorou à noite".
+
+**5.9.2 Detecção do tipo de NAT (STUN, RFC 5389/5780)**
+- Cliente STUN próprio em UDP (poucas centenas de linhas): enviar Binding Request para 2 servidores públicos (ex.: `stun.l.google.com:19302`, `stun.cloudflare.com:3478`) por 2 portas locais e comparar os endereços mapeados. Classificar:
+ - **Aberto** (full cone / sem NAT): mesmo mapeamento em todos os testes e recebe de qualquer origem.
+ - **Moderado** (restricted / port-restricted cone): mapeamento estável, mas só recebe de quem já falou.
+ - **Estrito** (simétrico): mapeamento muda por destino. É o que impede duas pessoas com NAT estrito de se conectarem.
+- Exibir no mesmo vocabulário que Xbox/Call of Duty/Discord usam (Open/Moderate/Strict) e explicar em uma frase o que cada um bloqueia.
+
+**5.9.3 Detecção das causas**
+
+| Verificação | Como | Finding |
+|---|---|---|
+| **CGNAT** da operadora | IP WAN do roteador (via UPnP `GetExternalIPAddress` ou página do gateway) na faixa `100.64.0.0/10`, ou diferente do IP visto pelo STUN/`api.ipify.org` | "Sua operadora usa CGNAT. Nenhum ajuste no PC ou no roteador resolve NAT estrito. Peça IP público (às vezes chamado de IP fixo) ou use IPv6" |
+| **NAT duplo** | `tracert` para 1.1.1.1: dois ou mais saltos iniciais com IP privado (10/8, 172.16/12, 192.168/16) | "Dois roteadores em cascata. Coloque o modem da operadora em modo bridge ou use só um deles" |
+| **UPnP** | Descoberta SSDP (`M-SEARCH` para `239.255.255.250:1900`, `urn:schemas-upnp-org:device:InternetGatewayDevice:1`), depois `AddPortMapping` de teste e `DeletePortMapping` | "UPnP desligado no roteador: os jogos não conseguem abrir portas sozinhos" |
+| **Teredo / rede Xbox** | `netsh interface teredo show state` (estado `qualified` é o bom), serviço `XblAuthManager`/`XboxNetApiSvc`, e a leitura que o Windows mostra em Configurações → Jogos → Rede Xbox | "Teredo bloqueado. Festas e jogos que usam a rede Xbox no PC não vão conectar" |
+| **IPv6** | Interface tem endereço global e `ping -6` para `2606:4700:4700::1111` responde | Informativo: "IPv6 ativo, isso contorna o CGNAT em jogos que suportam" |
+| **Firewall do Windows** | `INetFwPolicy2` (COM) buscando regras para o exe do jogo detectado; verificar perfil ativo (Público vs Privado) | "Sua rede está marcada como Pública e o jogo não tem regra de entrada" |
+| **Wi-Fi** | `WlanQueryInterface`: banda, sinal, canal, taxa de conexão | "Wi-Fi 2,4 GHz com sinal de 40%. Para competitivo, use cabo ou 5 GHz" |
+| **Adaptador** | Velocidade de link (`Win32_NetworkAdapter.Speed`), duplex, driver com mais de 2 anos | "Placa de rede negociou 100 Mbps num plano de 500" |
+| **DNS** | Tempo de resolução com o DNS atual vs Cloudflare/Google/Quad9 | Tabela comparativa, o usuário escolhe |
+| **Banda em uso** | `GetExtendedTcpTable`/`GetExtendedUdpTable` + bytes por processo | "Steam baixando a 45 MB/s enquanto você joga" |
+
+**5.9.4 Correções que o app aplica (todas com snapshot e "Desfazer")**
+
+| Ação | Comando/API | Risco |
+|---|---|---|
+| Ativar Teredo e serviço de rede Xbox | `netsh interface teredo set state type=enterpriseclient servername=win1910.ipv6.microsoft.com` (padrão `default` como primeira tentativa), `Start-Service XboxNetApiSvc` | Baixo |
+| Criar regra de firewall pro jogo detectado (entrada e saída, TCP/UDP) | `INetFwRule` via COM | Baixo |
+| Marcar rede atual como Privada | `Set-NetConnectionProfile -NetworkCategory Private` | Baixo (explicar que não é pra fazer em Wi-Fi público) |
+| Abrir portas via UPnP para o jogo em execução (tabela de portas por jogo em `docs/PORTAS.md`) | `AddPortMapping` com lease de 24 h, removido ao sair do jogo | Baixo |
+| Limpar DNS, renovar IP | `ipconfig /flushdns`, `/release`, `/renew` | Baixo |
+| Resetar Winsock e pilha TCP/IP | `netsh winsock reset`, `netsh int ip reset` (exige reinício) | Médio |
+| Trocar DNS do adaptador | `SetDNSServerSearchOrder` (WMI) | Baixo |
+| Desativar Nagle na interface do jogo | Ver 5.8 | Baixo |
+
+**5.9.5 O que o app não corrige (e como orienta)**
+- **CGNAT**: mostrar o nome da operadora (via ASN do IP público, tabela estática dos principais ASNs BR) e um texto pronto pra copiar no chat de suporte pedindo IP público, além de sugerir IPv6 e, como paliativo, serviços de roteamento (ExitLag, WTFast, GearUP) com a ressalva de que custam por mês e não resolvem CGNAT em todo jogo.
+- **Roteador**: botão "Abrir configuração do roteador" (gateway em `http://` ou `https://`), lista das portas do jogo detectado e passo a passo por marca (TP-Link, Intelbras, Huawei, ZTE, Mercusys, Asus, D-Link) pra ligar UPnP, abrir portas ou DMZ pro IP do PC. Recomendar reservar IP fixo no DHCP antes de abrir porta.
+- **NAT duplo**: instruções de modo bridge por marca e aviso de que isso é feito no modem da operadora.
+
+**UI:** página Rede com três blocos: "Velocidade" (gráfico e histórico), "Posso jogar online?" (NAT com semáforo grande, lista de causas encontradas, botão "Corrigir o que é do Windows" e cartões de orientação pro resto) e "Ajustes" (DNS, firewall, tabela de portas).
+
+**Critérios de aceite:** classificação de NAT bate com o que o Xbox app / Call of Duty mostram na mesma rede; CGNAT detectado corretamente em conexão com IP 100.64.x.x; todas as correções revertem; nenhuma alteração de roteador é tentada além de UPnP com lease.
+
+### 5.10 Drivers de GPU
+
+- Detectar fabricante e versão (`Win32_VideoController.DriverVersion`, `DriverDate`) e traduzir para versão "de marketing" (NVIDIA: últimos 5 dígitos do DriverVersion → `5xx.xx`).
+- Comparar com versão mais recente conhecida via arquivo JSON estático atualizado a cada release do GameBoost e, se web permitida pelo usuário, endpoint oficial (NVIDIA tem API não documentada; AMD/Intel via página). Sem web: só "seu driver tem N meses".
+- **Nunca instalar driver.** Abrir a página oficial. Opcional: link para DDU com explicação de quando usar.
+
+### 5.11 Perfis por jogo
+
+- Perfil = `{ executavel, nome, acoes: [modo game on, plano energia X, prioridade Y, afinidade Z, tweaks por exe, apps a fechar, apps a reabrir], auto: bool }`.
+- Detecção via 5.1. Ao fechar o jogo, reverter automaticamente.
+- Biblioteca: varrer launchers para listar jogos instalados (Steam `libraryfolders.vdf` + `appmanifest_*.acf`; Epic `C:\ProgramData\Epic\EpicGamesLauncher\Data\Manifests\*.item`; GOG registry; Xbox `.GamingRoot`), mostrar tamanho e último jogado (para sugerir desinstalar jogos parados, que é onde estão os GB de verdade).
+
+### 5.12 Relatório de saúde e pontuação
+
+- Executa todas as varreduras em modo leitura e gera nota 0 a 100 por área (Desempenho, Espaço, Inicialização, Configuração para jogos) com os 5 principais `Findings`.
+- Exportar `relatorio.html` (para o usuário mandar para quem dá suporte) e `relatorio.json`.
+- `GameBoost.exe --report saida.html` na CLI.
+
+### 5.13 Ferramentas rápidas (atalhos de coisas que o usuário procura)
+
+- Reiniciar Explorer, reiniciar driver de vídeo (`Win+Ctrl+Shift+B` programático via `SendInput`), limpar cache DNS, esvaziar Lixeira, abrir Limpeza de Disco do Windows, criar ponto de restauração, verificar integridade (`sfc /scannow` e `DISM /RestoreHealth` com console embutido), teste de velocidade de disco (leitura/escrita sequencial 1 GB em arquivo temporário), informações do sistema (CPU, GPU, RAM canais/velocidade, discos e tipo, versão do Windows, BIOS) com botão copiar.
+
+---
+
+## 6. UI / UX
+
+- **Tema escuro padrão**, acento único (manter identidade atual do GameBoost). Fluent-like, sem gradientes exagerados.
+- **Navegação lateral** com ícones: Início (Modo Game + pontuação + findings), Diagnóstico, Limpeza, Espaço, Apps, Inicialização, Jogos (perfis), Tweaks, Rede, Ferramentas, Configurações.
+- **Padrão de tela de módulo:** cabeçalho com botão "Varrer" e resumo (ex.: "12,4 GB podem ser liberados"), lista de `ActionItem` agrupada por categoria com checkbox, badge de risco (verde/amarelo/vermelho), botão "?" que abre painel lateral com "O que faz / Risco / Como desfazer", rodapé fixo com "Aplicar N selecionados" e "Reverter".
+- **Tela de confirmação única e genérica** (reusar a do Modo Game) para tudo que altera o sistema.
+- **Histórico** (Configurações → Histórico): toda ação aplicada, com botão "Desfazer" individual enquanto a reversão for possível.
+- **Bandeja do sistema**: ícone com menu (Ativar Modo Game, Limpar RAM, Abrir, Sair). Opção "iniciar minimizado com o Windows" (desligada por padrão, e o GameBoost aparece na própria lista de inicialização, com honestidade).
+- **Acessibilidade**: fontes escaláveis, navegação por teclado, contraste AA.
+- **Onboarding** na primeira abertura: 3 telas explicando "reversível / sem telemetria / o que faz diferença de verdade" e oferecendo o Relatório de Saúde inicial.
+
+---
+
+## 7. Dados e persistência
+
+Tudo em `%LOCALAPPDATA%\GameBoost\`:
+
+| Arquivo | Conteúdo | Formato |
+|---|---|---|
+| `settings.json` | Preferências, tema, whitelist, essenciais, agendamentos, `schemaVersion` | JSON |
+| `profiles/*.json` | Um perfil por jogo | JSON |
+| `state-backup.json` | Pilha de `ChangeRecord { Id, Modulo, Tipo (Registry/Service/Power/File/Startup), Alvo, ValorAnterior, ValorNovo, Data, Revertido }` | JSON |
+| `session.json` | Sessão do Modo Game | JSON |
+| `history/*.json` | Histórico de ações por dia | JSON |
+| `cache/disk-scan-.bin` | Último resultado da varredura de disco (para abrir instantâneo) | Binário |
+| `gameboost.log` | Log texto rotacionado (5 MB × 5 arquivos) | Texto |
+| `backups/*.reg` | Export de chaves antes de remover restos | REG |
+
+Migração: ao abrir, se `schemaVersion` < atual, rodar migradores em sequência. Nunca apagar arquivo antigo, renomear para `.bak`.
+
+Modo portátil: se existir `portable.txt` ao lado do exe, usar `.\data\` em vez de `%LOCALAPPDATA%`.
+
+---
+
+## 8. CLI (`GameBoost.Cli` ou argumentos no exe principal)
+
+```
+GameBoost.exe --scan [arquivo] # relatório de varredura do Modo Game (já existe)
+GameBoost.exe --report saida.html # relatório de saúde completo
+GameBoost.exe --clean --preset seguro # limpeza com preset (seguro | completo)
+GameBoost.exe --clean --categorias temp,wu,wer
+GameBoost.exe --revert-all # reverte tudo do state-backup
+GameBoost.exe --gamemode on|off
+GameBoost.exe --dry-run # combina com qualquer comando, não altera nada
+```
+
+Saída em texto e `--json`.
+
+---
+
+## 9. Blindagem (documentar em `docs/PROTECAO.md`)
+
+Aplicada por `SafetyGuard` central, consultado por todos os módulos.
+
+- **Processos:** lista da v1 + anti-cheats (`EasyAntiCheat`, `BEService`, `vgc`, `vgtray` (Vanguard), `FACEIT`, `PnkBstr`, `XignCode`, `GameGuard`, `nProtect`, `Ricochet`, `EAAntiCheat`), launchers nunca pré-marcados, `RTSS`, `MSIAfterburner` (usuário pode querer), overlays de captura (aviso).
+- **Caminhos:** `C:\Windows` (exceto whitelist de limpeza), `Program Files*` (exceto via Desinstalador), pastas de usuário (`Documents`, `Pictures`, `Videos`, `Desktop`, `OneDrive`), qualquer pasta contendo `.git`, `saves`, `Saved Games`, `%APPDATA%` de jogos.
+- **Serviços:** núcleo do Windows, antivírus, `wuauserv` (só pausa), rede (`Dhcp`, `Dnscache`, `nsi`, `NlaSvc`), áudio (`Audiosrv`, `AudioEndpointBuilder`), `Themes`, `DcomLaunch`, `RpcSs`, `Winmgmt`, `EventLog`, `Schedule`, `TrustedInstaller`, `CryptSvc`, `BFE`, `mpssvc`.
+- **Registro:** nunca tocar em `HKLM\SYSTEM\CurrentControlSet\Services\*` além de `Start`, nunca em `Winlogon`, `Image File Execution Options`, `Policies`.
+- **Apps:** ver 5.3.
+- **Tweaks proibidos:** VBS/Core Isolation, mitigações de CPU, Defender, UAC, SmartScreen, assinatura de drivers.
+
+Teste obrigatório: `SafetyGuardTests` com casos para cada lista.
+
+---
+
+## 10. Build, distribuição e atualização
+
+- `dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishReadyToRun=true` (ReadyToRun reduz tempo de abertura).
+- Trimming: **não** habilitar (WPF e reflexão quebram).
+- Versão semântica em `Directory.Build.props`, propagada para Inno Setup.
+- **Assinatura de código (validado set/2026):**
+ - **Azure Artifact Signing** (ex-Trusted Signing, a partir de US$ 9,99/mês) é a opção barata da Microsoft, mas para **desenvolvedor individual só aceita EUA e Canadá**; organizações precisam estar em EUA/Canadá/UE/UK e outros poucos países. **Brasil está fora**, tanto PF quanto PJ. Não contar com isso.
+ - **Certificado OV** de uma CA (Certum, Sectigo, DigiCert, GlobalSign): caminho viável para o Brasil. Desde jun/2023 a chave privada precisa ficar em token USB ou HSM em nuvem (a CA fornece). Faixa de US$ 70 a 300/ano. Certum costuma ser a mais barata para dev individual.
+ - **EV não pula mais o SmartScreen** desde 2024: OV e EV passam pelo mesmo processo de reputação. Não vale pagar EV.
+ - **Microsoft Store (MSIX)**: a Microsoft reassina o pacote e o SmartScreen nunca aparece. Exige `runFullTrust` e o app precisa passar na certificação (o Modo Game e as ações de sistema podem gerar perguntas). Vale como meta de médio prazo.
+ - Independente da opção: reputação só acumula com downloads, então assinar sempre com a mesma identidade e submeter cada release ao Microsoft Security Intelligence para reduzir falsos positivos.
+- **Auto-update:** Velopack (sucessor do Squirrel, .NET moderno) apontando para GitHub Releases. Checar na abertura, baixar em background, aplicar no próximo início. Configurável. Sem web, apenas mostrar "versão X disponível" se o usuário clicar "Verificar".
+- **Portátil:** o mesmo exe publicado, zipado, com `portable.txt`.
+- **CI:** GitHub Actions `windows-latest`: build, testes, publish, Inno Setup, anexar ao Release. Gerar `SHA256SUMS.txt`.
+- **Instalador:** manter Inno Setup. Adicionar página de opções: atalho na bandeja ao iniciar, associar tarefa de limpeza semanal (desligada por padrão), instalar para todos os usuários vs. só atual.
+
+---
+
+## 11. Testes
+
+- **Unit (xUnit):** `SafetyGuard`, `RollbackEngine` (aplicar → reverter → estado igual), parsers (registro Uninstall, `libraryfolders.vdf`, UserAssist ROT13, versão de driver NVIDIA), regras de `Bottleneck` (dado um snapshot de métricas, gera os Findings esperados), whitelist do Cleaner (todo caminho gerado pertence à whitelist).
+- **Integração (rodam só em Windows, tag `[Trait("Category","Windows")]`):** limpeza real em pasta temp sintética; leitura de MFT em volume real (só leitura); desinstalação de um MSI de teste (criar com WiX ou usar `msiexec` em pacote conhecido); startup toggle em chave `HKCU` fake.
+- **Manual (checklist em `docs/QA.md`):** SmartScreen, antivírus, máquina com 8 GB, notebook com iGPU + dGPU, usuário sem admin, Windows 10 22H2 e Windows 11 24H2+, disco FAT32 externo, jogo com Vanguard rodando (nada pode ser fechado).
+- **Dry-run** em todos os módulos: `--dry-run` produz o mesmo relatório sem tocar em nada; teste garante que `IFileSystem`/`IRegistry` fakes não recebem chamadas de escrita.
+
+---
+
+## 12. Fases de implementação (ordem obrigatória)
+
+Cada fase: branch própria, PR, changelog em `CHANGELOG.md`.
+
+### Fase 0 · Fundação (refatorar sem mudar comportamento)
+- Separar `GameBoost.Core` (sem WPF) de `GameBoost.App`.
+- Extrair `SafetyGuard`, `RollbackEngine`, `Logger`, `Settings` com `schemaVersion`.
+- Criar `IModule` / `ActionItem` e portar o Modo Game para esse contrato.
+- Criar a tela genérica de lista de `ActionItem` + confirmação.
+- Configurar DI, CommunityToolkit.Mvvm, xUnit, GitHub Actions.
+- Gerar `CLAUDE.md` com a seção 2 e o mapa de pastas.
+- **Pronto quando:** app faz exatamente o que a v1 fazia, com testes de `SafetyGuard` e `RollbackEngine` passando.
+
+### Fase 1 · Diagnóstico de gargalos (5.5) + Relatório (5.12)
+Motivo: é o que faz o usuário abrir o app todo dia e entender o valor do resto.
+
+### Fase 2 · Limpeza (5.2) + Ferramentas rápidas (5.13)
+
+### Fase 3 · Analisador de disco (5.4) + Biblioteca de jogos (parte de 5.11)
+
+### Fase 4 · Desinstalador (5.3) + Inicialização (5.6)
+
+### Fase 5 · Tweaks (5.8) + Serviços (5.7) + Rede completa com NAT (5.9) + Drivers (5.10)
+
+### Fase 6 · Perfis por jogo com detecção automática (5.1 + 5.11), bandeja, onboarding
+
+### Fase 7 · Distribuição: Velopack, CI completo, portátil, assinatura, submissão de falsos positivos
+
+### Backlog (depois da v2.0)
+- Overlay de FPS in-game (integração com RTSS via shared memory, ou hook próprio em D3D11/12, que é trabalho grande e risco com anti-cheat; preferir RTSS).
+- Duplicados e arquivos grandes esquecidos com sugestão de compressão NTFS (`compact /c /exe:xpress8k` em pastas de jogos, que economiza 20 a 40% sem perda de FPS em SSD).
+- Curva de ventoinha (fora de escopo, depende de hardware).
+- Versão EN e loja Microsoft Store (MSIX com `runFullTrust`).
+- Benchmark rápido antes/depois (rodar 30 s de carga sintética e comparar) para provar ganho.
+
+---
+
+## 13. Prompts sugeridos para o Claude Code (um por fase)
+
+**Fase 0**
+> Leia `GAMEBOOST_V2_SPEC.md` inteiro. Execute a Fase 0: refatore o projeto para a estrutura da seção 4.1 sem alterar nenhum comportamento visível. Crie `SafetyGuard`, `RollbackEngine`, `Logger` e `Settings` conforme seções 2, 7 e 9. Porte o Modo Game para `IModule`/`ActionItem`. Adicione xUnit com testes para `SafetyGuard` e `RollbackEngine`. Gere `CLAUDE.md` com as regras da seção 2. Ao final, `dotnet build` e `dotnet test` limpos, e liste em `docs/DECISOES.md` qualquer desvio.
+
+**Fase 1**
+> Implemente o módulo `Bottleneck` (seção 5.5) e `HealthReport` (5.12). Coleta a cada 1 s com buffer de 5 min, contadores de CPU/GPU/RAM/disco/rede, LibreHardwareMonitorLib opcional para temperaturas. Implemente todas as regras de diagnóstico da tabela como classes `IFindingRule` testáveis. Tela com 5 medidores, gráfico de 60 s, top 10 processos e lista de Findings. Exporte `relatorio.html`. Meça e garanta < 1,5% CPU.
+
+**Fases seguintes:** mesmo padrão: "Implemente o módulo X conforme seção 5.Y, respeitando seções 2 e 9, com testes das regras, dry-run funcional e entrada no `docs/TWEAKS.md` ou `docs/PROTECAO.md` quando aplicável."
+
+---
+
+## 14. Glossário rápido (para textos da UI)
+
+- **Standby List:** memória que o Windows guarda "por precaução" com dados já usados. Liberar ajuda quando falta RAM, não aumenta FPS por si só.
+- **Shader cache:** arquivos que o jogo gera para não recompilar efeitos. Apagar libera espaço, mas o jogo engasga nas primeiras horas até recriar.
+- **HAGS:** deixa a placa de vídeo gerenciar a própria fila de trabalho. Ajuda em alguns casos, atrapalha em outros. Teste com e sem.
+- **Throttling:** o processador reduz a velocidade para não superaquecer. Sinal de cooler sujo ou pasta térmica velha.
+- **Gargalo de CPU:** o processador não consegue preparar quadros rápido o suficiente; a placa de vídeo fica esperando.
+
+---
+
+*Documento gerado em 13/09/2026 para o projeto GameBoost, com pesquisa de mercado validada por fontes web na mesma data. Autor do produto: Jonathan Vaz.*
diff --git a/GameBoost/GameBoost.sln b/GameBoost/GameBoost.sln
new file mode 100644
index 0000000..7731860
--- /dev/null
+++ b/GameBoost/GameBoost.sln
@@ -0,0 +1,84 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameBoost.Core", "src\GameBoost.Core\GameBoost.Core.csproj", "{67F62495-3B0A-4309-BC87-D97EABE2D2A7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameBoost.Cli", "src\GameBoost.Cli\GameBoost.Cli.csproj", "{94138042-896D-4F31-BAED-E287520B18A7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameBoost.Tests", "src\GameBoost.Tests\GameBoost.Tests.csproj", "{984E3716-662D-4E8C-8B6F-B81C33CDF790}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameBoost.App", "src\GameBoost.App\GameBoost.App.csproj", "{CAF682AC-B64C-483C-9C80-533990BC4F24}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {67F62495-3B0A-4309-BC87-D97EABE2D2A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {67F62495-3B0A-4309-BC87-D97EABE2D2A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {67F62495-3B0A-4309-BC87-D97EABE2D2A7}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {67F62495-3B0A-4309-BC87-D97EABE2D2A7}.Debug|x64.Build.0 = Debug|Any CPU
+ {67F62495-3B0A-4309-BC87-D97EABE2D2A7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {67F62495-3B0A-4309-BC87-D97EABE2D2A7}.Debug|x86.Build.0 = Debug|Any CPU
+ {67F62495-3B0A-4309-BC87-D97EABE2D2A7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {67F62495-3B0A-4309-BC87-D97EABE2D2A7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {67F62495-3B0A-4309-BC87-D97EABE2D2A7}.Release|x64.ActiveCfg = Release|Any CPU
+ {67F62495-3B0A-4309-BC87-D97EABE2D2A7}.Release|x64.Build.0 = Release|Any CPU
+ {67F62495-3B0A-4309-BC87-D97EABE2D2A7}.Release|x86.ActiveCfg = Release|Any CPU
+ {67F62495-3B0A-4309-BC87-D97EABE2D2A7}.Release|x86.Build.0 = Release|Any CPU
+ {94138042-896D-4F31-BAED-E287520B18A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {94138042-896D-4F31-BAED-E287520B18A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {94138042-896D-4F31-BAED-E287520B18A7}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {94138042-896D-4F31-BAED-E287520B18A7}.Debug|x64.Build.0 = Debug|Any CPU
+ {94138042-896D-4F31-BAED-E287520B18A7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {94138042-896D-4F31-BAED-E287520B18A7}.Debug|x86.Build.0 = Debug|Any CPU
+ {94138042-896D-4F31-BAED-E287520B18A7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {94138042-896D-4F31-BAED-E287520B18A7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {94138042-896D-4F31-BAED-E287520B18A7}.Release|x64.ActiveCfg = Release|Any CPU
+ {94138042-896D-4F31-BAED-E287520B18A7}.Release|x64.Build.0 = Release|Any CPU
+ {94138042-896D-4F31-BAED-E287520B18A7}.Release|x86.ActiveCfg = Release|Any CPU
+ {94138042-896D-4F31-BAED-E287520B18A7}.Release|x86.Build.0 = Release|Any CPU
+ {984E3716-662D-4E8C-8B6F-B81C33CDF790}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {984E3716-662D-4E8C-8B6F-B81C33CDF790}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {984E3716-662D-4E8C-8B6F-B81C33CDF790}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {984E3716-662D-4E8C-8B6F-B81C33CDF790}.Debug|x64.Build.0 = Debug|Any CPU
+ {984E3716-662D-4E8C-8B6F-B81C33CDF790}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {984E3716-662D-4E8C-8B6F-B81C33CDF790}.Debug|x86.Build.0 = Debug|Any CPU
+ {984E3716-662D-4E8C-8B6F-B81C33CDF790}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {984E3716-662D-4E8C-8B6F-B81C33CDF790}.Release|Any CPU.Build.0 = Release|Any CPU
+ {984E3716-662D-4E8C-8B6F-B81C33CDF790}.Release|x64.ActiveCfg = Release|Any CPU
+ {984E3716-662D-4E8C-8B6F-B81C33CDF790}.Release|x64.Build.0 = Release|Any CPU
+ {984E3716-662D-4E8C-8B6F-B81C33CDF790}.Release|x86.ActiveCfg = Release|Any CPU
+ {984E3716-662D-4E8C-8B6F-B81C33CDF790}.Release|x86.Build.0 = Release|Any CPU
+ {CAF682AC-B64C-483C-9C80-533990BC4F24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CAF682AC-B64C-483C-9C80-533990BC4F24}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CAF682AC-B64C-483C-9C80-533990BC4F24}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {CAF682AC-B64C-483C-9C80-533990BC4F24}.Debug|x64.Build.0 = Debug|Any CPU
+ {CAF682AC-B64C-483C-9C80-533990BC4F24}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {CAF682AC-B64C-483C-9C80-533990BC4F24}.Debug|x86.Build.0 = Debug|Any CPU
+ {CAF682AC-B64C-483C-9C80-533990BC4F24}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CAF682AC-B64C-483C-9C80-533990BC4F24}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CAF682AC-B64C-483C-9C80-533990BC4F24}.Release|x64.ActiveCfg = Release|Any CPU
+ {CAF682AC-B64C-483C-9C80-533990BC4F24}.Release|x64.Build.0 = Release|Any CPU
+ {CAF682AC-B64C-483C-9C80-533990BC4F24}.Release|x86.ActiveCfg = Release|Any CPU
+ {CAF682AC-B64C-483C-9C80-533990BC4F24}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {67F62495-3B0A-4309-BC87-D97EABE2D2A7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {94138042-896D-4F31-BAED-E287520B18A7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {984E3716-662D-4E8C-8B6F-B81C33CDF790} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {CAF682AC-B64C-483C-9C80-533990BC4F24} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ EndGlobalSection
+EndGlobal
diff --git a/GameBoost/README.md b/GameBoost/README.md
new file mode 100644
index 0000000..f96a1a1
--- /dev/null
+++ b/GameBoost/README.md
@@ -0,0 +1,199 @@
+# GameBoost
+
+**O booster que mostra o que faz e desfaz tudo.**
+
+Central de otimização e manutenção para PC gamer no Windows 10 e 11. Sem anúncio, sem conta,
+sem telemetria, sem assinatura. PT-BR nativo.
+
+> **Estado:** v2.0.0 em desenvolvimento — Fases 0 a 3 concluídas.
+> Modo Game, diagnóstico, relatório de saúde, limpeza, ferramentas rápidas e analisador de
+> espaço funcionais; os demais módulos chegam nas fases seguintes.
+
+---
+
+## O que faz hoje
+
+**Modo Game.** Encerra os aplicativos que você confirmar, libera memória, aplica o plano de
+energia de alto desempenho, sobe a prioridade do jogo e silencia notificações, gravação em
+segundo plano e Windows Update. Ao desligar, devolve tudo ao estado anterior e reabre os
+apps marcados com ★.
+
+Três coisas que ele não faz, por princípio:
+
+- **Não fecha o jogo nem o anti-cheat.** Vanguard, EasyAntiCheat, BattlEye e companhia nem
+ aparecem selecionáveis.
+- **Não mata nada sem pedir.** Todo app recebe primeiro o pedido de fechamento normal, com
+ 3 segundos para salvar. A tela de confirmação aparece antes de qualquer coisa ser fechada.
+- **Não inventa número.** O ganho mostrado é o medido: RAM livre antes e depois, apps
+ fechados, alterações aplicadas.
+
+**Diagnóstico de gargalos.** Cinco medidores ao vivo (CPU, GPU, memória, disco, rede) com
+gráfico dos últimos 60 segundos, os 10 processos que mais consomem, e uma lista de achados
+em português claro: *"Chrome está usando 38% da CPU"*, *"seu monitor de 165 Hz está em
+60 Hz"*, *"a atualização 26H2 desfez 4 ajustes seus"*. A coleta custa 0,1% de CPU.
+
+**Relatório de saúde.** Nota de 0 a 100 por área (Desempenho, Espaço, Inicialização,
+Configuração para jogos) com os cinco principais achados, exportável como página HTML única
+para mandar a quem dá suporte.
+
+**Limpeza.** Vinte alvos, do `%TEMP%` ao shader cache, cada um mostrando quanto ocupa e
+qual o efeito colateral antes de você marcar. Arquivos seus vão para a Lixeira; navegador
+aberto bloqueia o próprio cache; `Downloads` só é listado, nunca tocado.
+
+**Ferramentas rápidas.** Reiniciar o Explorer ou o driver de vídeo, limpar o cache de DNS,
+criar ponto de restauração, rodar `sfc` e `DISM` com a saída ao vivo, medir a velocidade do
+disco e copiar as informações do sistema para mandar a quem dá suporte.
+
+**Onde está o seu espaço.** Varre o disco inteiro em cerca de 20 segundos e mostra num
+treemap onde foram parar os GB. Lista os 100 maiores arquivos, as 50 maiores pastas e a sua
+biblioteca de jogos — com o tamanho de cada um e quando você jogou pela última vez. Explica o
+que são `hiberfil.sys` e `pagefile.sys` em vez de só mostrar o tamanho. Não apaga nada: abre
+no Explorer ou manda para a Lixeira, com confirmação.
+
+**Aplicativos instalados.** Lista tudo que está instalado — registro, Microsoft Store e o
+que o Windows registra de uso — com o tamanho medido de verdade, não o declarado no
+registro, e quando você usou pela última vez. Desinstala em lote, sempre chamando o
+desinstalador do próprio app, e depois procura o que ficou para trás. Uma aba à parte varre
+`%APPDATA%`, `%LOCALAPPDATA%` e `%PROGRAMDATA%` atrás de pastas de programas que você já
+desinstalou. Runtimes, drivers e antivírus aparecem bloqueados, com o motivo. **Nada vem
+marcado** — nem o bloatware mais óbvio, nem o resto mais antigo.
+
+**Atualizações.** Uma aba da mesma tela mostra o que tem versão nova, em cima do winget,
+o gerenciador de pacotes da própria Microsoft — quem baixa e confere assinatura é ela, não
+o GameBoost. Navegador, compactador e Java desatualizados aparecem marcados como
+**atualização de segurança**, porque neles ficar para trás não é questão de conforto.
+Driver aparece bloqueado: o GameBoost não instala driver, nem pelo winget. O que se
+atualiza sozinho (Steam, Discord, navegadores) vem com a tag e a explicação de por que
+forçar pode brigar com o atualizador do próprio app.
+
+**O que abre com o Windows.** Chaves `Run`, pastas Inicializar e o estado real de cada item,
+lido de onde o Gerenciador de Tarefas lê. Mostra o fabricante, se o executável é assinado e
+uma estimativa grosseira do peso no boot — declarada como grosseira, porque vem do tamanho
+do arquivo e não de medição. Desativar grava exatamente o mesmo formato do Gerenciador de
+Tarefas, e dá para reverter aqui, lá ou os dois.
+
+**Ajustes do Windows.** Doze tweaks de jogo e doze serviços do Windows na mesma lista, cada
+um com o efeito real escrito sem promessa: quando um ajuste é marginal, está escrito que é
+marginal. O isolamento de núcleo aparece explicado e **não** é alterado — essa escolha é
+sua, na tela da Microsoft. SysMain, os serviços do Xbox e o Windows Update aparecem
+bloqueados para explicar por que toda lista de otimização da internet erra ao mandar
+desligar. Nada vem marcado, nem o que o próprio GameBoost recomenda.
+
+**Rede.** Responde duas perguntas: "minha internet está boa pra jogar?" e "por que eu não
+entro na partida do meu amigo?". Mede latência, jitter e perda, compara o seu DNS com
+Cloudflare, Google e Quad9, e descobre o tipo do seu NAT com um cliente STUN próprio —
+aberto, moderado ou estrito, no mesmo vocabulário que o jogo usa. Quando é estrito, diz
+**por quê**: CGNAT da operadora, dois roteadores em série, UPnP desligado, Teredo
+desativado ou firewall. Corrige o que é do Windows, com desfazer, e para o resto abre
+[docs/PORTAS.md](docs/PORTAS.md) com as portas de cada jogo e onde fica cada coisa em sete
+marcas de roteador. O teste de velocidade é um botão à parte e desligado por padrão, porque
+gasta 100 MB da sua franquia.
+
+**Perfis por jogo.** O GameBoost reconhece quando um jogo abre e pergunta se quer ativar o
+Modo Game para ele. Cada jogo pode ter o seu perfil — prioridade, núcleos, tweaks, apps a
+fechar — aplicado ao entrar e **desfeito ao sair**, sem exceção. Perfil novo nunca ativa
+sozinho: só passa a agir sem perguntar quem você marcar como automático.
+
+**Bandeja e primeira abertura.** Ícone ao lado do relógio com Ativar Modo Game, Limpar RAM,
+Abrir e Sair. Fechar a janela esconde na bandeja, e ele avisa isso na primeira vez —
+programa que some sem dizer para onde foi é o motivo de as pessoas irem no Gerenciador de
+Tarefas. Na primeira abertura, três telas explicando o contrato: tudo é reversível, nada
+sai daqui, e o que faz diferença de verdade.
+
+## Como instalar
+
+| Arquivo | Para quem |
+|---|---|
+| `GameBoost-Setup-*.exe` | A maioria. Instala, cria atalho e habilita a atualização automática |
+| `GameBoost-*-portatil.zip` | Pen drive ou máquina de terceiro. Guarda os dados ao lado do executável e não se atualiza sozinho |
+| `SHA256SUMS.txt` | Conferir que o download veio inteiro |
+
+O instalador pergunta se quer instalar para todos os usuários ou só para você, e
+oferece — desmarcados — o atalho na área de trabalho, iniciar com o Windows
+minimizado na bandeja, e uma limpeza semanal agendada.
+
+Se o SmartScreen avisar, é porque esta versão pode não estar assinada: **Mais
+informações → Executar assim mesmo**. O porquê está em
+[docs/ASSINATURA.md](docs/ASSINATURA.md), junto com o que foi apurado sobre
+certificado de código para quem está no Brasil.
+
+---
+
+## Reversibilidade
+
+Antes de alterar qualquer coisa, o GameBoost grava o valor anterior em
+`state-backup.json`. Isso vale para registro, serviços e plano de energia.
+
+- O botão **Reverter tudo** está sempre visível.
+- Se o app travar ou o PC reiniciar com o Modo Game ativo, a próxima abertura avisa e
+ oferece a restauração.
+- O desinstalador roda a reversão antes de remover o app.
+
+Sem isso, um travamento com o Windows Update pausado deixaria a máquina sem atualizações
+indefinidamente.
+
+---
+
+## Linha de comando
+
+```
+GameBoost.exe abre a interface
+GameBoost.exe --scan [arquivo] relatório de varredura, não encerra nada
+GameBoost.exe --report saida.html relatório de saúde completo (HTML ou JSON)
+GameBoost.exe --clean --preset seguro limpeza (Fase 2)
+GameBoost.exe --revert-all reverte tudo do state-backup
+GameBoost.exe --gamemode on|off liga ou desliga o Modo Game
+```
+
+Modificadores: `--dry-run` (combina com qualquer comando, não altera nada), `--json`,
+`--help`.
+
+## Onde ficam os dados
+
+`%LOCALAPPDATA%\GameBoost\` — `settings.json`, `session.json`, `state-backup.json`,
+`gameboost.log` (rotacionado, 5 MB × 5).
+
+**Modo portátil:** criando um `portable.txt` ao lado do executável, tudo passa a ficar em
+`.\data\`.
+
+---
+
+## Compilar
+
+Requisitos: .NET 9 SDK e, para o instalador, Inno Setup 6.
+
+```powershell
+dotnet build GameBoost.sln
+dotnet test GameBoost.sln
+
+# Executável final (self-contained, arquivo único)
+dotnet publish src\GameBoost.App\GameBoost.App.csproj -c Release -o publish
+
+# Instalador
+& "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe" installer\GameBoost.iss
+```
+
+O app exige privilégios de administrador (encerrar processos, controlar serviços e purgar a
+Standby List). Sem elevação ele abre em modo somente leitura: as varreduras funcionam e as
+ações ficam desabilitadas.
+
+## Avisos conhecidos
+
+- **SmartScreen.** O instalador ainda não é assinado. Ver seção 10 do spec para o caminho de
+ assinatura (certificado OV; Azure Artifact Signing não atende o Brasil).
+- **Antivírus.** Encerrar processos e elevar privilégios é o mesmo comportamento que um
+ malware teria — alguns antivírus reclamam de qualquer booster.
+
+## Documentação
+
+| Arquivo | Conteúdo |
+|---|---|
+| [CLAUDE.md](CLAUDE.md) | Regras obrigatórias para código novo |
+| [docs/PROTECAO.md](docs/PROTECAO.md) | Listas de blindagem, documentadas |
+| [docs/TWEAKS.md](docs/TWEAKS.md) | Catálogo de tweaks com efeito real e risco |
+| [docs/DECISOES.md](docs/DECISOES.md) | Decisões que fugiram do spec, com motivo |
+| [docs/QA.md](docs/QA.md) | Checklist de teste manual |
+
+---
+
+Autor: Jonathan Vaz.
diff --git a/GameBoost/docs/ASSINATURA.md b/GameBoost/docs/ASSINATURA.md
new file mode 100644
index 0000000..c621e89
--- /dev/null
+++ b/GameBoost/docs/ASSINATURA.md
@@ -0,0 +1,115 @@
+# Assinatura de código e falso positivo
+
+O que foi apurado em setembro de 2026 sobre assinar o GameBoost, e o que fazer
+enquanto ele não está assinado.
+
+Isto importa mais para este programa do que para a média. Um app que encerra
+processos, mexe no registro, altera serviços e purga a Standby List tem o perfil
+de comportamento que antivírus heurístico marca. Sem assinatura, o SmartScreen
+avisa no primeiro download e uma parte das pessoas desiste ali.
+
+---
+
+## As opções, e por que a escolha é essa
+
+### Azure Artifact Signing (ex-Trusted Signing) — **fora do Brasil**
+
+É a opção barata da Microsoft, a partir de US$ 9,99/mês, e seria a escolha óbvia
+se desse.
+
+Não dá. Para **desenvolvedor individual** a Microsoft só aceita Estados Unidos e
+Canadá. Para **organização**, a lista é maior — EUA, Canadá, União Europeia,
+Reino Unido e mais alguns — e **o Brasil não está nela**, nem como pessoa física
+nem como jurídica.
+
+Não é questão de preço nem de burocracia: é elegibilidade geográfica. Não vale
+gastar tempo tentando.
+
+### Certificado OV de uma CA — **o caminho viável**
+
+Certum, Sectigo, DigiCert, GlobalSign. Faixa de US$ 70 a 300 por ano; a Certum
+costuma ser a mais barata para desenvolvedor individual.
+
+Desde junho de 2023 a chave privada **não pode mais ficar num arquivo `.pfx` no
+seu disco**: ela tem que morar em token USB ou HSM em nuvem, e a CA fornece um
+dos dois. Isso muda o CI — assinar deixa de ser "coloca o segredo e roda" e passa
+a exigir ou um runner com o token plugado, ou a API do HSM da CA.
+
+O `gameboost-release.yml` tem o passo de assinatura preparado para o caso `.pfx`,
+que serve para HSM em nuvem com credencial exportável e para certificado de teste.
+Para token USB físico, o passo precisa ser trocado pelo cliente da CA.
+
+### EV — **não vale mais a pena**
+
+Até 2024 o certificado EV pulava o SmartScreen na hora. **Não pula mais.** Desde
+então OV e EV passam pelo mesmo processo de reputação, e a diferença de preço
+deixou de comprar alguma coisa.
+
+Se alguém sugerir EV "porque tira o aviso", essa informação está desatualizada.
+
+### Microsoft Store (MSIX) — **meta de médio prazo**
+
+A Microsoft reassina o pacote, e o SmartScreen nunca aparece. É o caminho mais
+limpo do ponto de vista do usuário.
+
+Custa passar na certificação, e este app tem tudo para gerar pergunta: o Modo
+Game encerra processos de terceiros, o desinstalador roda executáveis de
+terceiros, e vários módulos escrevem em `HKLM`. Precisa de `runFullTrust`, e
+ainda assim pode receber pedido de justificativa.
+
+Vale tentar depois que a v2 estiver estável, não antes.
+
+---
+
+## Reputação: o que realmente tira o aviso
+
+Assinar não zera o SmartScreen. O que zera é **reputação acumulada**, e ela
+depende de duas coisas:
+
+1. **Sempre a mesma identidade.** Trocar de certificado ou de nome de publicador
+ reinicia a contagem. Se um dia o certificado for renovado, renovar com a mesma
+ entidade, não com outra.
+2. **Volume de downloads sem incidente.** Não há atalho. É tempo.
+
+---
+
+## Submeter falso positivo
+
+Quando um antivírus marcar o GameBoost, o caminho é submeter a amostra, não pedir
+ao usuário para desativar a proteção.
+
+**Microsoft Defender** — [microsoft.com/wdsi/filesubmission](https://www.microsoft.com/en-us/wdsi/filesubmission)
+
+Escolher "Software developer", anexar o `.exe`, e no campo de justificativa
+descrever o que o programa faz e por quê. Vale citar, especificamente:
+
+- Ele encerra processos porque é um otimizador de jogos e essa é a função
+ principal, sempre com confirmação do usuário.
+- Ele grava em `HKLM\SYSTEM\CurrentControlSet\Control\GraphicsDrivers` e chaves
+ vizinhas porque aplica ajustes documentados pela própria Microsoft.
+- Ele chama `NtSetSystemInformation` para purgar a Standby List.
+- Todo o código está aberto em `github.com/jvaz-DEVENG/showcase`.
+
+A resposta costuma vir em 24 a 72 horas.
+
+**Outros antivírus** — cada um tem o próprio formulário. Os mais comuns em
+máquina brasileira, em ordem de aparição: Kaspersky, Avast/AVG, Bitdefender,
+McAfee, Norton. Todos aceitam submissão de desenvolvedor.
+
+**Depois de cada release**, submeter de novo: a assinatura muda a cada build, e o
+hash novo não herda a liberação do anterior.
+
+---
+
+## O que o usuário vê hoje
+
+Sem certificado, no primeiro download:
+
+> O Windows protegeu o computador.
+> O Microsoft Defender SmartScreen impediu a inicialização de um aplicativo não
+> reconhecido.
+
+Para rodar mesmo assim: **Mais informações → Executar assim mesmo**.
+
+O `SHA256SUMS.txt` sai em toda release justamente para isso: quem quiser conferir
+que baixou o arquivo certo tem como, sem depender de assinatura nenhuma.
diff --git a/GameBoost/docs/DECISOES.md b/GameBoost/docs/DECISOES.md
new file mode 100644
index 0000000..70e766d
--- /dev/null
+++ b/GameBoost/docs/DECISOES.md
@@ -0,0 +1,769 @@
+# Decisões de projeto
+
+Registro de toda decisão que fugiu do `GAMEBOOST_V2_SPEC.md`, com data e motivo.
+
+---
+
+## 2026-09-13 — A Fase 0 virou reconstrução, não refatoração
+
+**Contexto.** A Fase 0 do spec diz "refatore o projeto para a estrutura da seção 4.1 **sem
+alterar nenhum comportamento visível**". Isso pressupõe o código-fonte da v1.0.0.
+
+**O que existia.** Apenas o binário: `GameBoost.rar` com `GameBoost.exe` (68 MB,
+self-contained, compilado em 03/09/2026), `LEIA-ME.txt` e o desinstalador Inno. Nenhum
+`.csproj`, `.sln`, `.iss` ou repositório git da v1 em nenhum disco da máquina.
+
+**Decisão.** Reconstruir a v1 do zero já dentro da arquitetura alvo da seção 4.1, usando o
+`LEIA-ME.txt` da v1 e a seção 1 do spec como especificação do comportamento a reproduzir.
+
+**Consequência.** Não há garantia de paridade byte-a-byte com a v1 — só de paridade
+funcional com o que estava documentado. O checklist de QA (`docs/QA.md`) lista o que precisa
+ser validado à mão contra o binário antigo.
+
+---
+
+## 2026-09-13 — CLI é biblioteca, não executável separado
+
+**Spec.** A seção 4.1 lista `GameBoost.Cli/` como projeto; a seção 8 diz
+"`GameBoost.Cli` ou argumentos no exe principal".
+
+**Decisão.** `GameBoost.Cli` é uma **biblioteca** (`CommandLineOptions` + `CommandRunner`)
+consumida pelo `GameBoost.App`. O `App.OnStartup` faz o parse dos argumentos e, se for um
+comando de linha, anexa ao console do processo pai (`AttachConsole(-1)`), executa e sai.
+
+**Motivo.** A v1 expunha `GameBoost.exe --scan relatorio.txt`. Dois executáveis quebrariam
+esse contrato, dobrariam o tamanho do publish self-contained e obrigariam o instalador a
+distribuir os dois. Uma biblioteca preserva o comando da v1 e ainda deixa a lógica testável
+sem levantar processo.
+
+---
+
+## 2026-09-13 — `AppSettings.SchemaVersion` começa em 0, não na versão atual
+
+**Decisão.** O inicializador do campo foi removido; a propriedade começa em `0` e o
+`SettingsStore.Save` carimba `VersaoAtual` na gravação.
+
+**Motivo.** Com `= VersaoAtual` no campo, um `settings.json` da v1 — que não gravava
+`schemaVersion` — desserializava como se já fosse da versão corrente, e o migrador **nunca
+rodava**. O bug foi pego pelo teste
+`Settings_da_v1_sem_schema_version_e_migrado_e_o_original_vira_bak`.
+
+---
+
+## 2026-09-13 — Prioridade do jogo não gera ChangeRecord
+
+**Decisão.** Subir a prioridade do processo do jogo para Alta é registrado no log, mas não
+gera entrada em `state-backup.json`.
+
+**Motivo.** Prioridade de processo é estado volátil: morre junto com o processo e não
+sobrevive a reboot. Um `ChangeRecord` pendente apontando para um PID que não existe mais só
+poluiria a lista de reversão. A regra 1 continua valendo para tudo que **persiste**
+(registro, serviço, plano de energia).
+
+---
+
+## 2026-09-13 — Processos críticos não aparecem na lista da UI
+
+**Spec.** A seção 4.2 prevê itens protegidos aparecendo bloqueados, não selecionáveis.
+
+**Decisão.** Itens protegidos por `ProcessoCritico`, `DentroDoWindows`, `OutraSessao` e
+`ProprioApp` são **omitidos** da lista. Anti-cheats e antivírus **aparecem**, bloqueados,
+com a explicação.
+
+**Motivo.** Incluir todos encheria a tela com dezenas de `svchost` que o usuário nunca deve
+tocar, escondendo o que importa. Anti-cheat e antivírus continuam visíveis de propósito: é
+informação útil ("o Vanguard está rodando"), e ver que o app se recusa a tocar neles é o que
+constrói confiança.
+
+---
+
+## 2026-09-13 — Escrita de arquivo com retry na troca atômica
+
+**Decisão.** `WindowsFileSystem.WriteAllText` grava `.tmp` e faz `File.Move(overwrite)` com
+até 5 tentativas e espera progressiva.
+
+**Motivo.** O antivírus desta máquina segura o handle do `.tmp` por alguns milissegundos
+depois da escrita, fazendo a troca atômica falhar com `IOException` de forma intermitente.
+Sem o retry, `state-backup.json` poderia não ser gravado exatamente no momento mais crítico:
+logo antes de alterar o sistema.
+
+---
+
+## 2026-09-13 — ReadyToRun dobra o tamanho do executável
+
+**Medido.** O publish self-contained single-file da v2 saiu com **138 MB**, contra 68 MB do
+`GameBoost.exe` da v1.0.0.
+
+**Causa.** `PublishReadyToRun=true`, pedido pela seção 10 do spec para reduzir o tempo de
+abertura. Ele embute código nativo pré-compilado junto do IL.
+
+**Decisão.** Manter ligado, como o spec pede, e medir o tempo de abertura com e sem antes de
+decidir de vez. Trimming continua proibido (quebra WPF), então não há como recuperar o
+tamanho por esse caminho.
+
+**A conferir na Fase 7:** se o ganho de abertura não justificar +70 MB de download, desligar
+ReadyToRun é a alternativa óbvia — o instalador comprimido sofre menos com isso que o
+download do portátil.
+
+---
+
+## 2026-09-13 — Fase 1: nada de PerformanceCounter por nome traduzido
+
+**Problema.** A seção 5.5 sugere contadores como `\Processor Information(_Total)\% Processor Performance`.
+Num Windows **pt-BR esse contador não existe** — chama-se "Informações do Processador".
+`PerformanceCounter` exige o nome no idioma do sistema, então o código do spec falharia
+nesta máquina e em qualquer Windows não-inglês.
+
+**Decisão.** Onde há API nativa, usar API nativa: `NtQuerySystemInformation` para CPU,
+Standby List e processos; `GlobalMemoryStatusEx` para memória; `DriveInfo` para disco;
+`System.Net.NetworkInformation` para rede. Todas devolvem números, não texto.
+
+Para GPU não existe equivalente público, então ali se usa PDH — mas via
+`PdhAddEnglishCounter`, que aceita o nome em inglês em qualquer idioma.
+
+---
+
+## 2026-09-13 — A coleta custava 5% de CPU; três correções a levaram a 0,1%
+
+O critério de aceite da seção 5.5 é **menos de 1,5% de CPU**. A primeira versão media
+**5,02%**, com ciclo de **1039 ms** — mais lenta que o próprio intervalo de coleta.
+
+| O que estava errado | Correção | Resultado |
+|---|---|---|
+| Um `PerformanceCounter` por instância de GPU; cada `NextValue()` é uma consulta separada | Uma consulta PDH com curinga cobre todas as instâncias de uma vez | ciclo 1039 ms → 36 ms |
+| `Process.GetProcesses()` + `TotalProcessorTime` abria um handle por processo, ~400 vezes por segundo | `NtQuerySystemInformation(SystemProcessInformation)`: tudo numa chamada, sem handle | — |
+| Detecção de jogo chamava WMI `Win32_Process` a cada 10 s | Detecta pelo nome nos processos que a coleta já leu | — |
+| Interfaces de rede reenumeradas a cada segundo | Lista revalidada a cada 30 s | — |
+
+**Medição final:** 0,115% de CPU, ciclo de 36 ms, app inteiro em 0,61%.
+
+### A medição do custo também estava errada
+
+O primeiro cálculo usava `Stopwatch`, que mede **tempo de parede**: um ciclo parado
+esperando WMI parecia caro sem ter gasto CPU. Foi trocado por `GetThreadTimes`, que mede o
+tempo de CPU realmente consumido.
+
+Isso exigiu tirar o laço do `Task.Delay`: com `await`, a continuação volta em **outra**
+thread do pool e a medição compara threads diferentes. O monitor passou a usar uma thread
+dedicada, com prioridade abaixo do normal — o que também garante que ele ceda lugar ao jogo.
+
+Medir errado o próprio custo é exatamente o tipo de número inventado que a regra 4 proíbe.
+
+---
+
+## 2026-09-13 — Temperaturas ficam indisponíveis nesta fase
+
+**Spec.** A seção 5.5 pede LibreHardwareMonitorLib para temperaturas de CPU e GPU.
+
+**Decisão.** A Fase 1 usa apenas WMI `MSAcpi_ThermalZoneTemperature`, que nesta máquina
+responde "Sem suporte". A UI mostra **"indisponível"**, nunca zero.
+
+**Motivo.** LibreHardwareMonitorLib carrega um driver em modo kernel para ler os sensores.
+Isso é uma decisão de segurança e de distribuição (assinatura, antivírus, SmartScreen) grande
+demais para entrar junto com o resto da Fase 1. A regra de throttling térmico já está escrita
+e testada: ela simplesmente não dispara enquanto não houver leitura.
+
+---
+
+## 2026-09-13 — `AoEntrar` no binding, não no comando de navegação
+
+O menu lateral troca de página pelo binding de `SelectedItem`, que **nunca passa pelo
+comando** `Navegar`. Com o gancho no comando, o Diagnóstico não começava a coletar e o
+Histórico não recarregava. O gancho foi para `OnPaginaAtualChanged`, que roda em qualquer
+caminho de navegação. Descoberto porque o log não registrava o início da coleta.
+
+---
+
+## 2026-09-13 — Fase 3: sem leitura crua da MFT, e não foi preciso
+
+**Spec.** A seção 5.4 pede leitura da MFT via `FSCTL_ENUM_USN_DATA`, com o critério de
+**500 GB e 1 milhão de arquivos em menos de 30 s**.
+
+**Problema com o caminho sugerido.** `FSCTL_ENUM_USN_DATA` devolve `USN_RECORD`, que traz
+nome, referência do pai e atributos — mas **não traz o tamanho do arquivo**. Para os
+tamanhos seria preciso parsear os registros da MFT crus, lendo os atributos `$DATA` a mão. É
+o que o WizTree faz, e é bem mais trabalho e risco do que a seção sugere.
+
+**Decisão.** A alternativa que o próprio spec autoriza: `FindFirstFileEx` com
+`FIND_FIRST_EX_LARGE_FETCH` e `FindExInfoBasic`, paralelizado por subárvore com uma fila de
+trabalho. O tamanho já vem no resultado da enumeração, sem uma chamada extra por arquivo.
+
+**Medido nesta máquina** (disco do sistema, NVMe de 953 GB):
+
+```
+1.548.745 arquivos, 809,5 GB somados, em 20,3 s
+```
+
+**Um milhão e meio de arquivos e 809 GB em 20 s** — acima do volume do critério e abaixo do
+tempo. A MFT crua fica para quando houver uma máquina onde isto não seja suficiente.
+
+### O contador de pastas inacessíveis estava mentindo
+
+A primeira versão contava **40.871 pastas "sem acesso"**. O número real é **543**: eu estava
+contando toda pasta vazia como inacessível, porque `FindFirstFileEx` devolve lista vazia nos
+dois casos. Agora o código separa pelos códigos de erro do Windows
+(`ERROR_FILE_NOT_FOUND`, `ERROR_PATH_NOT_FOUND`, `ERROR_NO_MORE_FILES` não são falta de
+permissão). Reportar 40 mil problemas onde há 543 é exatamente o número inventado que a
+regra 4 proíbe.
+
+### Outras decisões da varredura
+
+- **Reparse points são ignorados.** Junção e link simbólico apontam para outro lugar: segui-los
+ contaria o mesmo espaço duas vezes e pode virar laço infinito.
+- **Prefixo `\?\`** em todo caminho, senão a varredura para nos 260 caracteres — o que
+ acontece em qualquer `node_modules` ou pasta de build.
+- **`Consolidar` é iterativo**, não recursivo: há um teste com 20 mil níveis de profundidade,
+ que estouraria a pilha numa versão recursiva.
+
+---
+
+## 2026-09-14 — A árvore do disco custava 1,1 GB de RAM
+
+Depois da primeira varredura pela interface, o app estava com **1118 MB**. A causa era o
+`DiskNode` guardar o **caminho completo** em cada nó: numa árvore de 1,5 milhão de arquivos,
+com caminhos de 60 a 100 caracteres, isso sozinho passa de 240 MB, mais o overhead de cada
+string.
+
+O caminho já está na estrutura — é a sequência de nomes até a raiz. Agora só a raiz guarda um
+caminho completo, e os demais montam o seu subindo pelos pais.
+
+**Medido: 594 MB**, contra 1118 MB. Redução de 47%.
+
+Ao fazer isso apareceu uma armadilha: o scanner montava o caminho de cada filho a partir de
+`pasta.Caminho`, o que passou a subir a árvore inteira **por arquivo** e tornaria a varredura
+quadrática na profundidade. O caminho da pasta passou a ser calculado uma vez por pasta, não
+por arquivo. O construtor foi separado em dois — um para a raiz, um para filho — para que
+esse erro não seja possível de escrever de novo.
+
+**Ainda são 594 MB** para 1,5 milhão de nós, e não vale esconder isso: uma versão futura pode
+guardar a árvore achatada em arrays em vez de objetos ligados. Por ora o custo aparece só
+enquanto a página de Espaço está aberta.
+
+---
+
+## 2026-09-14 — Fase 4: `Get-AppxPackage` devolvia zero numa máquina com 142 pacotes
+
+Duas causas somadas, e cada uma sozinha já bastava para zerar a lista.
+
+A primeira: `powershell -Command "..."` passa pela análise de linha de comando do próprio
+PowerShell, que reinterpreta pipe e cifrão antes de o script existir. A correção é
+`-EncodedCommand` com o script em Base64 UTF-16LE — o texto chega intacto.
+
+A segunda é mais traiçoeira. O `stderr` estava redirecionado e ninguém lia. O PowerShell
+escreve o progresso ali em CLIXML; o buffer do pipe encheu, o processo parou de escrever à
+espera de alguém ler, e morreu no timeout de 30 s sem uma linha de erro. Agora as **duas**
+saídas são lidas em paralelo, e o script começa com `$ProgressPreference = 'SilentlyContinue'`.
+
+Vale para qualquer processo filho: redirecionar um fluxo obriga a lê-lo.
+
+## 2026-09-14 — O casamento exato de nomes acusava app instalado como resto
+
+A primeira varredura de restos devolveu 53 pastas e 26 GB, e entre elas estavam
+`BraveSoftware`, `EpicGamesLauncher` e uma pasta `Programs` de 9 GB. Os dois primeiros estão
+instalados — só escrevem a pasta sem os espaços do nome. `Programs` é guarda-chuva: tem
+vários apps ativos dentro, não é resto de nada.
+
+A comparação passou a normalizar (fora espaço e pontuação, tudo minúsculo) e a aceitar
+prefixo a partir de 5 caracteres, e as pastas guarda-chuva entraram na lista de ignoradas.
+Caiu para 30 pastas e 3,9 GB.
+
+O corte de 5 caracteres é arbitrário e foi escolhido por um motivo assimétrico: **deixar de
+listar um resto custa espaço em disco; apontar um app ativo como resto custa os dados do
+usuário.** Quando errar é inevitável, errar para o lado barato.
+
+## 2026-09-14 — O resumo dos apps anunciava 1812 GB num disco de 953 GB
+
+A tela ficou pronta, rodou na máquina real e disse "217 aplicativos, 1812,4 GB no total".
+Nenhum teste pegaria: cada tamanho individual estava certo. O que estava errado era somar.
+
+Dois erros diferentes, os dois só visíveis no total:
+
+1. Instalador que grava `InstallLocation` apontando para `C:\Program Files` ou para a raiz
+ do disco. Medir aquela pasta atribui o disco inteiro a um programa só. Agora pasta
+ genérica é recusada e o app fica sem tamanho medido, que é a resposta honesta.
+2. Vários apps declarando a **mesma** pasta — componentes de uma suíte, por exemplo. A soma
+ contava a pasta uma vez por app. Agora agrupa por pasta antes de somar.
+
+Deu 1044 GB para 2145 GB de disco. O teste que trava isso não confere um número exato: ele
+compara o total com a capacidade real dos discos e recusa qualquer app que sozinho responda
+por mais de um terço. É o tipo de invariante que sobrevive à máquina mudar.
+
+No mesmo teste apareceu um terceiro erro. A lista vinha ordenada pelo tamanho **declarado no
+registro**, porque o inventário ordena antes de a medição acontecer. O ARK, com 309 GB
+reais, aparecia atrás do AutoCAD, que declara 4 GB — exatamente o contrário do que a tela
+serve para mostrar.
+
+## 2026-09-14 — "Sem assinatura digital" era acusação falsa
+
+O Teams aparecia na inicialização com a observação "sem assinatura digital". O executável
+dele mora em `WindowsApps`, cuja ACL barra leitura até para o administrador: o
+`WinVerifyTrust` não reprovou a assinatura, ele nunca conseguiu abrir o arquivo.
+
+`Assinado` virou tri-estado (`bool?`): verdadeiro, falso e **não deu para verificar**. São
+coisas diferentes e a tela agora diz qual é qual. Atalho da pasta Inicializar não fala de
+assinatura nenhuma — o `.lnk` aponta para outro lugar e nunca é assinado, então dizer que
+ele não tem assinatura não diria nada sobre o programa.
+
+---
+
+## 2026-09-14 — O spec foi atualizado no meio da Fase 5: o que conflita
+
+O `GAMEBOOST_V2_SPEC.md` foi substituído por uma versão nova enquanto a Fase 5
+estava sendo escrita. Duas seções mudaram de tamanho: a **5.9 (Rede)** virou um
+módulo inteiro com NAT, e a **5.3 (Desinstalador)** ganhou uma aba de
+atualizações via `winget` — esta última atribuída à Fase 4, que já foi entregue.
+
+Antes de tocar em código, o que já existe e diverge do texto novo:
+
+### Onde o arquivo estava
+
+O spec novo não chegou ao repositório: o `GAMEBOOST_V2_SPEC.md` de lá continuava
+sendo o de 13/09, e as duas versões novas estavam em `C:\Users\User\Downloads`
+como `GAMEBOOST_V2_SPEC (1).md` e `(2).md`. A `(2)`, de 14/09 12:20, é a única
+com as duas mudanças (8 menções a `winget`, 4 a `STUN`); foi ela que entrou no
+repositório.
+
+### 5.9 — o que já estava escrito e diverge
+
+| O que existe | O que o spec novo pede | Situação |
+|---|---|---|
+| NAT em 5 categorias próprias (sem NAT, aberto, simétrico, CGNAT, duplo) | 3 categorias no vocabulário do Xbox: **Aberto / Moderado / Estrito** | Corrigido nesta fase |
+| STUN de **uma** porta local para 2 servidores | 2 servidores **por 2 portas locais** | Corrigido nesta fase |
+| Semáforo do ping em 20/50/100 ms | 30 / 80 ms | Corrigido nesta fase |
+| Teste de velocidade: um download de 25 MB | 3 amostras de 10 s, reportar a **mediana** | Corrigido nesta fase |
+| Aviso de jitter a partir de 5 ms no gateway | Finding com jitter > 15 ms e perda > 1% | Os dois convivem: ver nota abaixo |
+| DNS trocado por `netsh` | `SetDNSServerSearchOrder` via WMI | Fica como está: ver nota abaixo |
+| Banda por processo: recusada, mostra contagem de conexões | bytes por processo via `GetExtendedTcpTable`/`GetExtendedUdpTable` | Não é possível como descrito: ver nota abaixo |
+
+**Jitter.** O spec define o Finding em 15 ms, e está certo para a conexão como um
+todo. O aviso de 5 ms que já existia é outra coisa e continua: ele mede o jitter
+até o **próprio roteador**, onde 5 ms já é anormal e aponta problema dentro de
+casa. São duas medidas diferentes com dois limiares diferentes, não uma
+contradição.
+
+**DNS por `netsh`, não por WMI.** `SetDNSServerSearchOrder` pertence à
+`Win32_NetworkAdapterConfiguration`, congelada desde o Windows 8, e falha em
+adaptador configurado por DHCP em parte das máquinas. O `netsh` é o caminho que o
+próprio Windows usa, e é ele que permite voltar ao estado "automático (DHCP)" —
+que o método WMI não expressa. A reversão para automático é justamente o caso
+mais fácil de perder.
+
+**Bytes por processo.** `GetExtendedTcpTable` devolve **conexões com o PID dono**,
+não contadores de tráfego. Não existe contador por processo de rede no Windows
+sem ETW, e um consumidor de ETW rodando continuamente estoura o teto de 1,5% de
+CPU da regra 9. O que a tela faz é o que dá para afirmar: quantas conexões cada
+processo mantém e para onde. O texto na tela diz isso com todas as letras, em vez
+de apresentar contagem de conexão como se fosse velocidade.
+
+### 5.9 — o que o spec novo pede e ainda não existe
+
+Fica como pendência declarada desta fase, não como coisa feita:
+
+- Histórico em `network-history.json` ("sua conexão piorou à noite").
+- Ping para servidores de jogo por região (Riot, Valve, Blizzard, Epic, EA em
+ São Paulo).
+- UPnP hoje é só descoberta SSDP. O `AddPortMapping` de teste, e a abertura de
+ porta com lease de 24 h removida ao sair do jogo, dependem do detector de jogo
+ em execução, que é da Fase 6.
+- Firewall: hoje lê o estado dos perfis pelo registro. `INetFwPolicy2` via COM
+ para procurar regra do executável do jogo, e `Set-NetConnectionProfile` para
+ marcar a rede como Privada, também dependem do jogo detectado.
+- Checagem de IPv6 e de velocidade de link do adaptador.
+- Tabela de ASN para nomear a operadora, texto pronto para o suporte e passo a
+ passo por marca de roteador.
+
+### 5.3 — a aba "Atualizações" (winget) é da Fase 4, que já foi entregue
+
+O spec novo põe a aba de atualizações via `winget` na seção 5.3, e a seção 12
+mantém a 5.3 na **Fase 4**, que foi fechada e commitada antes desta versão do
+documento existir.
+
+Ela **não** foi feita, e não vai ser embutida na Fase 5 fingindo que sempre
+esteve lá. Entra como a primeira coisa depois que a Fase 5 fechar, com o commit
+dizendo que é complemento retroativo da Fase 4. Misturar as duas deixaria o
+histórico mentindo sobre o que cada fase entregou.
+
+Na mesma seção 5.3 há outros dois pontos ainda não atendidos:
+
+- A varredura de restos cobre pastas, mas não as chaves `HKCU\Software\` e
+ `HKLM\Software\`, nem a exportação para `.reg` antes de apagar.
+- O ponto de restauração antes de um lote grande existe em Ferramentas, mas não é
+ oferecido dentro do fluxo de desinstalação.
+
+---
+
+## 2026-09-14 — A saída do winget não tem formato para máquina ler
+
+`winget upgrade` não tem `--output json`, nem XML, nem nada estruturado. O que
+existe é uma tabela de largura fixa desenhada para humano.
+
+O jeito óbvio de ler seria procurar a coluna "Available". Ele quebra na primeira
+máquina em outro idioma: em português o cabeçalho é "Disponível", e nesta máquina
+o winget imprimiu os cabeçalhos em inglês enquanto traduzia os **nomes** dos
+pacotes ("Subsistema do Windows para Linux"). Depender de qualquer palavra seria
+depender de uma combinação que nem é consistente dentro da mesma saída.
+
+A leitura é por **posição de coluna**: acha-se a régua de tracinhos, o cabeçalho é
+a linha acima dela, e as colunas começam onde há caractere visível logo depois de
+um espaço. Nenhuma palavra é lida. Sobre isso vêm duas defesas: id com espaço no
+meio é descartado (sinal de que a fatia caiu errado), e a fonte precisa ser uma
+das que o winget conhece.
+
+O teste que trava isso passa a mesma tabela com cabeçalho em português. Se o
+parser voltar a depender de idioma, ele devolve zero e o teste falha.
+
+## 2026-09-14 — Um template de TabControl apagou a árvore de acessibilidade
+
+As abas da página de Apps saíram ilegíveis no tema escuro: o `TabItem` padrão do
+WPF ainda usa o visual do Windows Classic, com fundo claro e texto escuro. Reescrevi
+o `ControlTemplate` do `TabItem` e do `TabControl`.
+
+O script de captura de tela parou de achar os botões. A investigação mostrou algo
+pior que um problema de teste: **a automação não enxergava nenhum controle dentro
+das abas**. As três abas apareciam; da aba para dentro, árvore vazia.
+
+A causa é que o `TabControlAutomationPeer` do WPF procura um `ContentPresenter`
+chamado exatamente `PART_SelectedContentHost` para expor o conteúdo da aba
+selecionada. O meu tinha `ContentSource="SelectedContent"` e nenhum nome, então
+renderizava certo e sumia da automação.
+
+Isso vale registrar por dois motivos. O primeiro é que um leitor de tela não
+alcançaria botão nenhum dessa página — regra de acessibilidade da seção 6.
+O segundo é o método: o defeito só apareceu porque **a mesma automação que um
+leitor de tela usa** é a que dirige os testes de tela. Um teste que clicasse por
+coordenada teria passado.
+
+## 2026-09-14 — Prefixo de id não é fronteira
+
+A classificação das atualizações casava o id do winget por prefixo cru. Na tela
+real, "Chrome Remote Desktop Host" apareceu marcado como **atualização de
+segurança** e como navegador que se atualiza sozinho: o id dele é
+`Google.ChromeRemoteDesktopHost`, e `StartsWith("Google.Chrome")` é verdadeiro.
+
+O id do winget é hierárquico e separado por ponto. O casamento passou a exigir
+fronteira: ou igualdade, ou o caractere seguinte é um ponto. Padrão terminado em
+ponto (`AMD.`, `Intel.`, `RiotGames.`) continua valendo para a família inteira, e
+isso é proposital.
+
+O mesmo erro pegaria `Git.GitLFS` como se fosse o `Git.Git`, e
+`Microsoft.EdgeWebView2Runtime` como se fosse o navegador Edge. Os três casos
+estão no teste.
+
+---
+
+## 2026-09-14 — Detecção de jogo por polling, não por WMI
+
+A seção 5.1 sugere `Win32_ProcessStartTrace`, que entrega o evento no instante em
+que o processo nasce. A alternativa é listar processos de tempos em tempos.
+
+Ficou o polling de 2 segundos, e o motivo é medido, não teórico: na Fase 1 a
+coleta de métricas chegou a **5,02% de CPU**, e o WMI era a maior parcela
+daquilo. O `ProcessStartTrace` exige uma consulta WMI viva o tempo todo — é
+exatamente a forma que saiu de lá. Listar processos por
+`NtQuerySystemInformation` custa microssegundos.
+
+Dois segundos de atraso não importam para isto. O que acontece ao detectar é uma
+pergunta num diálogo; ninguém percebe a diferença entre responder no segundo 0 e
+no segundo 2. Se um dia houver um caso que precise do instante exato, o
+argumento muda.
+
+## 2026-09-14 — `UseWindowsForms` contaminou a aplicação inteira
+
+O WPF não tem ícone de bandeja. O caminho normal é o `NotifyIcon` do WinForms,
+que exige `true` no csproj.
+
+O que esse switch faz, além de referenciar a biblioteca, é injetar `global using
+System.Windows.Forms` e `global using System.Drawing` em **todo arquivo do
+projeto**. `Control`, `Application`, `Brush` e `MouseEventArgs` existem nos dois
+mundos, então metade da aplicação parou de compilar por ambiguidade — inclusive
+o `Treemap` e o `MiniGrafico`, que não têm nada a ver com bandeja.
+
+A correção é remover só os usings implícitos, mantendo a referência:
+
+```xml
+
+
+
+
+```
+
+O arquivo da bandeja declara os dele explicitamente. O resto da aplicação
+continua vendo só o WPF.
+
+## 2026-09-14 — O timer de 0,5 ms não faz o que dizem que faz
+
+`NtSetTimerResolution(0.5 ms)` é receita fixa de todo guia de otimização. Desde o
+Windows 10 2004 o pedido é **por processo**: o GameBoost pedir 0,5 ms afeta o
+GameBoost, não o jogo. O que sobra é o sistema honrar a menor resolução pedida
+por alguém para temporizadores globais, e é daí que vem o ganho residual que
+ainda aparece no Windows 10.
+
+Ele entrou no perfil porque é reversível e não custa nada, fica **desligado por
+padrão**, e o texto na tela não promete FPS. É o mesmo tratamento dado ao
+`SystemResponsiveness` e ao `NetworkThrottlingIndex` na Fase 5: marginal escrito
+como marginal.
+
+---
+
+## 2026-09-14 — Assinatura de código: o Brasil está fora da opção barata
+
+O caminho óbvio seria o Azure Artifact Signing, ex-Trusted Signing, a US$ 9,99
+por mês. Ele não está disponível aqui: para desenvolvedor individual a Microsoft
+só aceita Estados Unidos e Canadá, e para organização a lista maior também não
+inclui o Brasil. Não é preço nem papelada, é elegibilidade geográfica.
+
+Sobra o certificado OV de uma CA — Certum, Sectigo, DigiCert —, entre US$ 70 e
+300 por ano. Desde junho de 2023 a chave privada precisa ficar em token USB ou
+HSM em nuvem, o que muda o CI: assinar deixa de ser "põe o segredo e roda".
+
+O que **não** vale é EV. Até 2024 ele pulava o SmartScreen na hora; desde então
+OV e EV passam pelo mesmo processo de reputação, e a diferença de preço parou de
+comprar alguma coisa. É informação que ainda circula desatualizada.
+
+Por isso o passo de assinatura do workflow é **condicional**. Sem o segredo, a
+release sai sem assinatura e o CI emite um aviso visível. Falhar a build inteira
+por falta de um certificado que ainda não existe impediria de publicar qualquer
+coisa. Detalhes em docs/ASSINATURA.md.
+
+## 2026-09-14 — A here-string do PowerShell é incompatível com bloco YAML
+
+O passo que monta o `portable.txt` usava uma here-string (`@'...'@`). O
+PowerShell exige o terminador `'@` no começo da linha, sem espaço antes. O YAML
+exige que todo conteúdo de um bloco `run: |` fique indentado.
+
+As duas regras não coexistem: o `'@` em coluna 0 encerra o bloco YAML no meio do
+script, e o arquivo deixa de ser YAML válido. O erro não aparece ao escrever —
+aparece quando o GitHub recusa o workflow.
+
+Trocado por um array de strings passado ao `Set-Content`, que faz o mesmo e
+respeita a indentação. Vale como regra geral para qualquer script PowerShell
+dentro de workflow.
+
+Vale registrar também **como** isso foi pego: validando o YAML com um parser
+antes do commit, e não confiando na leitura. O mesmo cuidado achou que o
+`build.yml` continuava válido.
+
+---
+
+## 2026-09-14 — Seis bugs em três passos de QA manual
+
+A primeira rodada de QA à mão, com o binário elevado na máquina real, achou seis
+problemas nos três primeiros passos do checklist. Nenhum deles tinha aparecido em
+304 testes automatizados nem nas capturas de tela.
+
+Os dois piores eram de **ciclo de vida**, não de lógica:
+
+**A sessão nunca era encerrada.** `ReverterTudo` desfazia tudo e limpava a tela,
+mas não tocava no `session.json`. O arquivo continuava dizendo `Ativo: true` para
+sempre, e o aviso de "fechado com o Modo Game ainda ativo" voltava em toda
+abertura — com zero pendências. Nenhum teste pegaria: o estado do registro estava
+perfeito, e é isso que os testes conferem.
+
+**`Dispose` chamado duas vezes.** O `App.OnExit` descartava o `GameWatcher`, e o
+`ServiceProvider.Dispose()` logo abaixo descartava de novo, porque o container
+descarta todo singleton `IDisposable`. A segunda chamada caía num
+`CancellationTokenSource` já descartado. A exceção escapava porque o
+`ServiceProvider.Dispose()` estava **fora** do `try`.
+
+A lição que fica: quem escreve `Dispose` não controla quantas vezes ele é chamado.
+Aguentar a segunda chamada em silêncio é obrigação, não gentileza.
+
+Os outros quatro eram de **interface dizendo uma coisa e fazendo outra**:
+
+- O rodapé anunciava "N aguardando na lista de restauração" sem existir lista.
+- O resumo dizia "38 apps marcados" com um único item marcado, porque era o texto
+ da varredura e não acompanhava a seleção.
+- Faltava marcar/desmarcar todos na tela Início.
+- Faltava busca em qualquer lista.
+
+Os dois últimos vieram como sugestão de quem estava testando, não como defeito. A
+busca, em particular, já estava na seção 5.3 do spec e tinha passado despercebida
+por sete fases.
+
+**O que isso diz sobre o método.** As capturas de tela das fases anteriores
+mostravam cada tela funcionando, e todas estavam certas. O que elas não mostram é
+a **sequência**: abrir, agir, fechar, reabrir. Foi aí que os seis apareceram.
+
+---
+
+## 2026-09-14 — Medir banda por tamanho fixo mede a rajada, não a linha
+
+O spec pedia "3 amostras de 10 s". Eu troquei por 25 MB de transferência
+cronometrada, achando que era equivalente e mais previsível para quem tem
+franquia. Deixei isso escrito como se fosse uma melhoria.
+
+Não é equivalente. Numa fibra doméstica a operadora deixa passar bem acima do
+contratado por um ou dois segundos. Uma transferência que termina em 0,6 segundo
+mede **só essa rajada**.
+
+O sinal de que algo estava errado foi o número não bater com um teste dedicado.
+A investigação mostrou o problema de forma direta — mesma linha, mesmo minuto:
+
+| Tamanho | Tempo | Upload medido |
+|---|---|---|
+| 8 MB | 0,51 s | 131,5 Mbps |
+| 25 MB | 0,59 s | 354,7 Mbps |
+| 50 MB | 1,53 s | 274,9 Mbps |
+
+Três respostas diferentes para a mesma pergunta. Um teste cujo resultado depende
+do tamanho escolhido não está medindo a linha.
+
+A correção é medir por **tempo** (8 s) e descartar os 2 primeiros segundos, onde
+moram a rajada e o slow start do TCP. O teto de 120 MB por amostra continua
+protegendo a franquia.
+
+### E um erro meu de leitura, no meio disso
+
+Ao comparar com o Speedtest, eu vi 380 Mbps de upload contra 73 e afirmei que 380
+era impossível e 73 era o número real. Estava errado: aquele servidor específico
+(em Cotia) é que limitava. Trocando de servidor, o mesmo Speedtest deu **264
+Mbps**, e o ping caiu de 361 ms para 4 ms.
+
+A medição corrigida dá 224 Mbps — 15% abaixo dos 264, que é o esperado para um
+teste de um servidor só e uma conexão só.
+
+A lição vale para o texto da tela, e ela foi escrita lá: **um teste dedicado
+também erra** quando o servidor escolhido está congestionado. Dizer "o Speedtest
+é a verdade" seria tão impreciso quanto o defeito original.
+
+## 2026-09-14 — A tela mostrava o estado de antes depois de agir
+
+Quem testava perguntou se estava certo o badge de um serviço dizer "automático"
+depois da reversão. Estava — mas a pergunta revelou o problema: logo **depois de
+aplicar**, com o serviço já em Manual, o badge também dizia "automático".
+
+A lista guarda o resultado da varredura e não é refeita depois de agir. Na
+prática: a pessoa aplica, olha a linha, e parece que nada aconteceu.
+
+Refazer a varredura sozinho resolve onde ela é barata. Em Tweaks são
+milissegundos, e passou a ser automático. Na lista de apps instalados leva quase
+um minuto, e revarrer sozinho travaria a tela logo depois de uma ação — ali a
+tela avisa que a lista mostra o estado de antes.
+
+A regra que fica: **ou a tela mostra o estado atual, ou diz que não está
+mostrando.** O que não pode é apresentar um estado velho como se fosse o de
+agora.
+
+---
+
+## 2026-09-14 — Um `Pack = 1` apagou a Lixeira do aplicativo inteiro
+
+Ao confirmar o envio de uma pasta para a Lixeira, o GameBoost fechou. Sem caixa
+de erro, sem exceção no log — o log simplesmente parava na linha anterior. O
+Visualizador de Eventos do Windows mostrou o motivo:
+
+```
+Código de exceção: 0xc0000005
+Nome do módulo com falha: GameBoost.exe
+```
+
+Violação de acesso. Crash nativo, não exceção .NET — por isso os três handlers
+globais não pegaram nada.
+
+A causa estava numa linha:
+
+```csharp
+[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode, Pack = 1)]
+private struct SHFILEOPSTRUCT
+```
+
+Em x64 os campos dessa struct são alinhados em 8 bytes. Com `Pack = 1` o .NET
+remove o preenchimento, e o `pFrom` passa a ficar no deslocamento 12. O `shell32`
+continua lendo do 16 — pega a metade alta de um ponteiro colada na metade baixa
+do seguinte, e usa aquilo como endereço de memória.
+
+### O que isso diz sobre o que não foi testado
+
+**A Lixeira nunca funcionou.** Limpeza, Espaço e Restos chamam a mesma função.
+Três módulos, três fases, e em todos eles a ação de "mandar para a Lixeira"
+derrubaria o aplicativo.
+
+O `docs/QA.md` tinha o item desde a Fase 2:
+
+```
+- [ ] Enviar um arquivo para a Lixeira pela página e conferir que dá para restaurar
+```
+
+Ficou sem marcar por três fases. E não era um detalhe cosmético esperando na
+fila: era a única forma de descobrir um crash garantido.
+
+Marshalling de P/Invoke não quebra a compilação e não aparece em teste que use
+fake. O teste novo chama a função de verdade, com um arquivo de verdade. É o
+único jeito.
+
+---
+
+## 2026-09-14 — A detecção funcionava; era tudo depois dela que não
+
+O QA do bloco de perfis achou seis problemas, e o padrão entre eles diz mais que
+cada um isolado.
+
+A **detecção** estava certa desde a Fase 6. O log registrava exatamente o que o
+spec pede:
+
+```
+Watcher | Warframe.x64 | executavel dentro de uma pasta de launcher,
+ confiança 80, tela cheia True
+```
+
+O que não existia era o resto do caminho:
+
+1. O convite era disparado num evento **sem nenhum assinante**. Nascia e morria
+ na mesma linha.
+2. Aceitar o convite só navegava para outra tela. O `ProfileRunner` — a classe
+ inteira escrita para aplicar o perfil — nunca era chamada por ninguém.
+3. Fechar o jogo também não chamava o `ProfileRunner`, mas a mensagem afirmava
+ que "tudo o que o GameBoost alterou foi desfeito".
+
+Três elos de uma corrente de quatro. O primeiro funcionava perfeitamente e os
+outros três não existiam — e nada disso aparece em teste unitário, porque cada
+peça isolada faz o que promete. O `ProfileRunner` tem teste e passa. O que não
+tinha teste era **alguém chamando o ProfileRunner**.
+
+### O falso positivo, e por que o filtro de memória não bastou
+
+Numa sessão real o app anunciou "Agent está rodando. Ativar o Modo Game?".
+
+`Agent.exe` é o atualizador do Battle.net, e mora em
+`C:\ProgramData\Battle.net\Agent\Agent.9775\`. A pasta bate com a lista de
+launchers, o que dá confiança 80.
+
+Existia um filtro: dentro de pasta de launcher, só conta como jogo quem usa mais
+de 300 MB. Parecia suficiente — o Agent usa 32 MB parado. Só que ele **engorda
+enquanto baixa atualização**, e naquele momento passou do limite.
+
+Filtro de memória não distingue jogo de instalador; distingue processo grande de
+processo pequeno. O que separa de verdade é o nome: jogo tem nome próprio,
+infraestrutura tem nome de função. "Agent", "Launcher", "Updater" e "Helper"
+descrevem o que o programa faz — e dentro da pasta de um launcher, isso é sempre
+a maquinaria dele.
+
+O teste novo passa 2 GB de RAM de propósito, para provar que o filtro de nome
+vale mesmo quando o processo está gordo.
+
+### Um texto que negava o que tinha acontecido
+
+Quando o jogo fecha antes de o Desfazer rodar, não sobra prioridade para
+restaurar: ela morre junto com o processo. Isso é o esperado.
+
+Mas o aviso dizia **"Não havia perfil aplicado"** — e havia. A frase negava um
+fato para descrever uma consequência. Agora ela explica o que aconteceu de
+verdade: o perfil saiu junto com o jogo.
+
+---
+
+## Pendências conhecidas desta fase
+
+- `--clean` (seção 5.2) responde com "chega na Fase 2" e código de saída 3. Está no parser
+ porque a superfície da CLI da seção 8 precisa existir inteira desde já.
+ `--report` foi implementado na Fase 1.
+- `Resources/Strings.pt-BR.resx` ainda não existe: as strings estão no código. A regra 7 diz
+ que devem sair para recurso; fica para quando houver a segunda tela, para não criar um
+ arquivo de recursos com meia dúzia de entradas.
+- Detecção automática de jogo por `Win32_ProcessStartTrace` (seção 5.1) não foi feita: a
+ detecção atual é sob demanda, durante a varredura. Entra na Fase 6, junto com os perfis.
+- Findings de uso de disco e rede **por processo** (seção 5.5) dependem de ETW e ficaram de
+ fora; as regras que existem usam CPU, memória e os fatos do sistema.
+- Latência DPC/ISR, marcada como experimental no spec, não foi implementada.
+- Timer resolution: o P/Invoke existe (`NativeMethodsBridge.SetTimerResolution`) mas não é
+ chamado pelo Modo Game ainda. Entra na Fase 6.
diff --git a/GameBoost/docs/PORTAS.md b/GameBoost/docs/PORTAS.md
new file mode 100644
index 0000000..55070fc
--- /dev/null
+++ b/GameBoost/docs/PORTAS.md
@@ -0,0 +1,213 @@
+# Portas por jogo e por launcher
+
+Referência usada pela página **Rede** quando o diagnóstico encontra NAT estrito e
+o roteador não tem UPnP. É a lista que o usuário precisa ter na mão para abrir
+porta à mão, e o texto de cada seção diz o que abrir e o que **não** adianta
+abrir.
+
+> **Antes de abrir qualquer porta:** reserve um IP fixo para este PC no DHCP do
+> roteador. Sem isso o endereço muda no próximo boot e a regra passa a apontar
+> para outra máquina da casa — normalmente a TV.
+
+> **Abrir porta não resolve CGNAT nem NAT duplo.** Se o diagnóstico apontou um
+> desses, a porta aberta no seu roteador morre no equipamento de cima. Veja as
+> seções no fim deste arquivo.
+
+---
+
+## O que o GameBoost faz e o que ele não faz
+
+| | |
+|---|---|
+| Detecta o tipo de NAT (STUN) | Sim |
+| Detecta CGNAT, NAT duplo, UPnP, Teredo e firewall | Sim |
+| Reativa o Teredo (reversível) | Sim |
+| Abre porta via UPnP com prazo de 24 h | Previsto para a Fase 6, junto com o detector de jogo |
+| Configura o roteador | **Não.** O roteador não é dele; uma regra escrita lá não tem como ser desfeita se o app sumir |
+| Coloca o modem em bridge | **Não.** Isso derruba a internet da casa até a nova configuração subir |
+
+---
+
+## Launchers e plataformas
+
+Estas são as portas que valem para **todos** os jogos da plataforma. Abrir só
+estas já resolve boa parte dos casos de "não entro na festa".
+
+### Steam
+
+| Porta | Protocolo | Para quê |
+|---|---|---|
+| 27015 – 27030 | UDP | Tráfego de jogo e consulta de servidor |
+| 27015 – 27030 | TCP | Servidor dedicado |
+| 27036 – 27037 | UDP e TCP | Remote Play e transmissão na rede local |
+| 4380 | UDP | Tráfego de jogo antigo (Source) |
+| 3478, 4379, 4380 | UDP | Voz do Steam |
+
+### Epic Games
+
+| Porta | Protocolo | Para quê |
+|---|---|---|
+| 5222 | TCP | Presença e convite de amigo |
+| 5795 – 5847 | UDP | Tráfego de jogo (Fortnite e outros) |
+| 443, 80 | TCP | Download e autenticação |
+
+### Battle.net (Blizzard)
+
+| Porta | Protocolo | Para quê |
+|---|---|---|
+| 1119 | TCP e UDP | Battle.net (chat, grupo, presença) |
+| 3724 | TCP | World of Warcraft |
+| 6112 – 6114 | TCP e UDP | Jogos antigos e Overwatch |
+| 80, 443 | TCP | Cliente e download |
+
+### Xbox / Game Pass no PC
+
+| Porta | Protocolo | Para quê |
+|---|---|---|
+| 3074 | UDP e TCP | Rede Xbox Live |
+| 88 | UDP | Autenticação |
+| 500, 3544, 4500 | UDP | **Teredo** — sem estas, festa e multiplayer não conectam |
+| 53 | UDP e TCP | DNS |
+
+> O caso do Game Pass é o mais comum de todos: o multiplayer não conecta porque
+> alguém desativou o Teredo seguindo um tutorial de "otimização de rede". O
+> GameBoost detecta isso e reativa, com desfazer. Não precisa abrir porta
+> nenhuma para resolver esse caso.
+
+### Riot (League of Legends, Valorant)
+
+| Porta | Protocolo | Para quê |
+|---|---|---|
+| 5000 – 5500 | UDP | Tráfego de partida |
+| 8393 – 8400 | TCP | Cliente |
+| 2099, 5222, 5223 | TCP | Chat e plataforma |
+
+### EA / Origin / EA App
+
+| Porta | Protocolo | Para quê |
+|---|---|---|
+| 3659 | UDP | Tráfego de jogo |
+| 9988, 20000 – 20100 | UDP | Matchmaking |
+| 42127 | TCP | Cliente |
+| 1024 – 1124 | TCP | Download |
+
+### GOG Galaxy
+
+Não exige porta aberta: usa relay por padrão. Se um jogo específico da GOG pedir
+porta, ela é do jogo, não da plataforma.
+
+### Discord
+
+| Porta | Protocolo | Para quê |
+|---|---|---|
+| 50000 – 65535 | UDP | Voz |
+| 443, 80 | TCP | Aplicativo |
+
+> Discord com voz falhando e NAT estrito no diagnóstico são o mesmo problema.
+> Quando não dá para abrir porta, a opção "Qualidade de serviço com prioridade
+> alta" **desligada** nas configurações de voz do Discord costuma ajudar, porque
+> alguns roteadores tratam mal o pacote marcado.
+
+---
+
+## Jogos com porta própria
+
+Abrir a porta do launcher normalmente basta. Estes pedem porta própria quando
+você quer **hospedar**.
+
+| Jogo | Portas | Protocolo |
+|---|---|---|
+| Minecraft (Java, servidor) | 25565 | TCP e UDP |
+| Minecraft (Bedrock) | 19132 – 19133 | UDP |
+| Terraria | 7777 | TCP |
+| ARK: Survival Evolved | 7777 – 7778, 27015 | UDP |
+| Valheim | 2456 – 2458 | UDP |
+| Rust | 28015 – 28016 | UDP |
+| Call of Duty (Warzone, MW) | 3074, 27014 – 27050 | UDP e TCP |
+| Counter-Strike 2 | 27015 – 27020, 27031 – 27036 | UDP |
+| Palworld | 8211 | UDP |
+| Satisfactory | 7777, 15000, 15777 | UDP |
+
+---
+
+## CGNAT: por que abrir porta não adianta
+
+A operadora divide um endereço público entre vários assinantes. O NAT que
+atrapalha está no equipamento dela, e você não tem acesso a ele. O sinal é o IP
+de WAN do seu roteador estar na faixa `100.64.x.x` a `100.127.x.x`, ou ser
+diferente do IP que a internet enxerga.
+
+O que funciona, em ordem de esforço:
+
+1. **Pedir IP público à operadora.** Em várias é gratuito e resolve por telefone;
+ em outras é um adicional mensal. O nome varia: "IP público", "IP fixo", "IP
+ válido", "saída do CGNAT".
+2. **IPv6.** Jogos que suportam IPv6 passam por cima do CGNAT, porque cada
+ máquina tem endereço próprio. A maioria das operadoras brasileiras já entrega
+ IPv6; verifique se está ligado no roteador.
+3. **Serviço de roteamento** (ExitLag, WTFast, GearUP). Custa por mês, e não
+ resolve em todo jogo — só nos que aceitam o túnel. É paliativo, não solução.
+
+O que **não** funciona: abrir porta, ligar UPnP, DMZ, trocar de DNS, resetar o
+Winsock, trocar o cabo. Nenhum deles toca no equipamento da operadora.
+
+---
+
+## NAT duplo: dois roteadores em série
+
+Acontece quando o modem da operadora também roteia e você ligou o seu roteador
+nele. O `tracert` mostra dois ou mais saltos com IP privado antes do primeiro
+público.
+
+Três saídas, da melhor para a pior:
+
+1. **Modo bridge no modem da operadora.** Ele passa a ser só modem e o seu
+ roteador recebe o IP público direto. É a solução correta. Feito na
+ configuração do modem, e às vezes só o suporte da operadora consegue ligar.
+2. **DMZ no modem apontando para o IP do seu roteador.** Todo tráfego de entrada
+ passa a ir para o seu roteador. Funciona, mas expõe o seu roteador inteiro.
+3. **Usar só um dos dois.** Se o modem da operadora tem Wi-Fi decente, desligar o
+ seu roteador resolve o NAT duplo de graça.
+
+---
+
+## Marcas de roteador: onde fica cada coisa
+
+O endereço é quase sempre `192.168.0.1`, `192.168.1.1` ou `192.168.15.1` — a
+página Rede mostra o do seu. O usuário e a senha padrão costumam estar numa
+etiqueta embaixo do aparelho.
+
+| Marca | UPnP | Redirecionamento de porta |
+|---|---|---|
+| TP-Link | Avançado → NAT Forwarding → UPnP | Avançado → NAT Forwarding → Virtual Servers |
+| Intelbras | Avançado → Encaminhamento → UPnP | Avançado → Encaminhamento → Servidores Virtuais |
+| Huawei | Avançado → NAT → UPnP | Avançado → NAT → Mapeamento de Portas |
+| ZTE | Aplicação → UPnP | Aplicação → Encaminhamento de Portas |
+| Mercusys | Avançado → NAT Forwarding → UPnP | Avançado → NAT Forwarding → Servidores Virtuais |
+| Asus | WAN → Internet Connection → Enable UPnP | WAN → Virtual Server / Port Forwarding |
+| D-Link | Advanced → Advanced Network → UPnP | Advanced → Port Forwarding |
+
+---
+
+## Quando o problema é o firewall do Windows, não o roteador
+
+Dois casos, e os dois aparecem no diagnóstico:
+
+- **Rede marcada como Pública.** O Windows bloqueia entrada por padrão em rede
+ pública. Em casa, a rede deve estar como Privada.
+- **O jogo não tem regra de entrada.** Acontece quando o jogo foi instalado
+ copiando a pasta, ou quando alguém clicou em "Cancelar" no aviso do firewall
+ na primeira execução.
+
+O GameBoost hoje lê e informa os dois. Criar a regra do jogo e trocar o perfil da
+rede dependem de saber qual jogo está aberto, o que entra na Fase 6 junto com o
+detector de jogo.
+
+---
+
+## Fontes
+
+As portas vêm da documentação de suporte de cada plataforma (Steam, Epic,
+Blizzard, Riot, EA, Xbox) consultadas em setembro de 2026. Elas mudam pouco, mas
+mudam: quando um jogo parar de bater com esta tabela, a fonte é a página de
+suporte do próprio jogo, não um fórum.
diff --git a/GameBoost/docs/PROTECAO.md b/GameBoost/docs/PROTECAO.md
new file mode 100644
index 0000000..bd5a0db
--- /dev/null
+++ b/GameBoost/docs/PROTECAO.md
@@ -0,0 +1,228 @@
+# Blindagem
+
+Documentação das listas de proteção da seção 9 do spec. Toda lista aqui tem teste
+correspondente em `SafetyGuardTests`. **Se algum desses testes cair, o app não pode ser
+publicado.**
+
+Ponto único de consulta: `ISafetyGuard`, implementado por
+[`SafetyGuard`](../src/GameBoost.Core/Safety/SafetyGuard.cs). Nenhum módulo decide sozinho o
+que pode tocar.
+
+---
+
+## 1. Processos
+
+### 1.1 Núcleo do Windows — omitidos da lista
+
+`ProtectedProcesses.Criticos`. Encerrar qualquer um derruba a sessão ou o sistema.
+
+`system`, `idle`, `registry`, `memory compression`, `secure system`, `smss`, `csrss`,
+`wininit`, `winlogon`, `services`, `lsass`, `lsaiso`, `svchost`, `spoolsv`, `dwm`,
+`explorer`, `fontdrvhost`, `sihost`, `taskhostw`, `ctfmon`, `runtimebroker`,
+`shellexperiencehost`, `startmenuexperiencehost`, `searchhost`, `searchindexer`, `audiodg`,
+`conhost`, `dllhost`, `wudfhost`, `wmiprvse`, `trustedinstaller`, `tiworker`, `logonui`,
+`userinit`, `dashost`, `applicationframehost`, `textinputhost`, `systemsettings`,
+`backgroundtaskhost`
+
+Além da lista: **qualquer PID ≤ 4** e **qualquer executável dentro de `C:\Windows`**.
+
+> A comparação de caminho é por segmento. `C:\WindowsApps` **não** conta como dentro de
+> `C:\Windows` — há teste para isso.
+
+### 1.2 Anti-cheats — aparecem bloqueados
+
+`ProtectedProcesses.AntiCheats`. Fechar derruba o jogo e pode gerar banimento.
+
+`easyanticheat` (+ `_eos`, `_setup`), `beservice`, `bedaisy`, `battleye`, `vgc`, `vgtray`,
+`vgk` (Vanguard), `faceitservice`, `faceit`, `pnkbstra/b/k` (PunkBuster), `xigncode`,
+`xhunter1`, `gameguard`, `gamemon`, `npggnt`, `nprotect`, `ricochet`, `eaanticheat`,
+`anticheatexpert`, `ace-base`, `ace-guard`, `mhyprot`, `sgguard`, `steamservice`
+
+### 1.3 Antivírus e EDR — aparecem bloqueados
+
+Detecção em duas camadas:
+
+1. **WMI** `root\SecurityCenter2` (`AntiVirusProduct`, `AntiSpywareProduct`,
+ `FirewallProduct`) → nome do executável assinado. É a fonte de verdade.
+2. **Lista estática** `ProtectedProcesses.Seguranca`, rede de segurança para quando o WMI
+ falhar ou o produto não se registrar: Defender, Kaspersky, Avast/AVG, Bitdefender,
+ McAfee, Norton, ESET, Sophos, Carbon Black, CrowdStrike, SentinelOne, Cylance, Trend
+ Micro, Webroot, Malwarebytes.
+
+### 1.4 Nunca pré-marcados — aparecem desmarcados (regra 3)
+
+`ProtectedProcesses.NuncaPreMarcados`. O usuário pode marcar à mão; o app nunca marca por
+ele.
+
+| Grupo | Exemplos |
+|---|---|
+| Launchers | Steam, Epic, Battle.net, Riot Client, GOG Galaxy, EA Desktop, Origin, Ubisoft Connect, Xbox |
+| Overlay e monitoramento | RTSS, RivaTuner, MSI Afterburner |
+| Gravação e streaming | OBS, Streamlabs, XSplit, NVIDIA Share, Radeon Software |
+| IDEs e editores | Visual Studio, VS Code, Rider, IntelliJ, PyCharm, WebStorm |
+| Criação | Photoshop, Illustrator, Premiere, After Effects |
+| Office | Excel, Word, PowerPoint, Outlook |
+| Virtualização | VMware, VirtualBox, Docker Desktop |
+| Sincronização de nuvem | OneDrive, OneDrive.Sync.Service, FileCoAuth, Dropbox, Google Drive, MEGAsync, Syncthing, Nextcloud |
+
+**Instaladores e atualizadores em execução** também nunca vêm marcados. A detecção é por
+padrão de nome (`setup`, `install`, `updater`, `update`, `upgrade`, `patch`, `msiexec`,
+`redist`, `unins`, ou terminando em `.tmp`), e não por lista: instalador novo aparece toda
+semana. Encerrar um no meio do trabalho corrompe a instalação.
+
+Além da lista: **qualquer executável dentro de pasta de jogo** —
+`\steamapps\common\`, `\Epic Games\`, `\Riot Games\`, `\Battle.net\`,
+`\GOG Galaxy\Games\`, `\XboxGames\`, `\EA Games\`, `\Origin Games\`, `\Ubisoft\`.
+
+### 1.5 Listas do usuário
+
+`settings.json` aceita `NuncaEncerrar` e `SempreEncerrar`.
+
+**`SempreEncerrar` não vence a blindagem.** Colocar `lsass` nessa lista não libera nada — há
+teste garantindo. Ela só supera a lista de "nunca pré-marcados": quem quer que a Steam venha
+marcada pode pedir isso.
+
+---
+
+## 2. Caminhos
+
+`CheckPath(caminho, whitelist)` só libera se o caminho estiver **dentro** de algum item da
+whitelist do módulo. **Whitelist vazia não libera nada.** O caminho é normalizado com
+`Path.GetFullPath` antes de qualquer comparação, então `..\` não escapa.
+
+Bloqueados mesmo dentro da whitelist — se o segmento aparecer em **qualquer nível**:
+
+`.git`, `onedrive`, `dropbox`, `google drive`, `icloud`, `saves`, `savegames`,
+`saved games`, `savedgames`, `my games`
+
+Pastas pessoais (`Documentos`, `Imagens`, `Vídeos`, `Música`, `Área de Trabalho`,
+`Favoritos`) são bloqueadas a menos que a própria whitelist do módulo aponte para dentro
+delas — o que exige decisão explícita de quem escreveu o módulo.
+
+Pastas de sistema (`C:\Windows`, `Program Files`, `Program Files (x86)`, `System32`,
+`SysWOW64`) só são alcançáveis pela whitelist explícita de limpeza da seção 5.2, e
+`Program Files` só via Desinstalador.
+
+---
+
+## 3. Serviços
+
+`ProtectedServices.Intocaveis` — não podem ser parados nem ter o `StartType` alterado:
+
+| Grupo | Serviços |
+|---|---|
+| Núcleo | `DcomLaunch`, `RpcSs`, `RpcEptMapper`, `Winmgmt`, `EventLog`, `Schedule`, `TrustedInstaller`, `CryptSvc`, `PlugPlay`, `Power`, `ProfSvc`, `SamSs`, `LSM`, `Themes`, `UserManager`, `gpsvc`, `BrokerInfrastructure`, `SystemEventsBroker` |
+| Rede | `Dhcp`, `Dnscache`, `nsi`, `NlaSvc`, `netprofm`, `WlanSvc`, `LanmanWorkstation` |
+| Áudio | `Audiosrv`, `AudioEndpointBuilder` |
+| Segurança | `WinDefend`, `SecurityHealthService`, `wscsvc`, `Sense`, `mpssvc`, `BFE` |
+| Update | `wuauserv` |
+
+`ProtectedServices.PausaveisTemporariamente` — podem ser **parados** durante o Modo Game,
+nunca desabilitados: `wuauserv`, `UsoSvc`, `DoSvc`, `BITS`.
+
+A parada grava `ChangeRecord` com `estavaRodando=true`, então o serviço volta no
+desligamento do Modo Game, no "Reverter tudo", na próxima abertura após um travamento e no
+desinstalador.
+
+---
+
+## 4. Registro
+
+- Nunca tocar em `HKLM\SYSTEM\CurrentControlSet\Services\*` além de `Start`.
+- Nunca tocar em `Winlogon`, `Image File Execution Options`, `Policies`.
+- Toda escrita passa por `ChangeRecord` com `root`, `kind` e `ValorAnteriorExistia`. Quando
+ o valor **não existia**, reverter significa **apagar**, não gravar zero — há teste.
+
+---
+
+## 5. Tweaks proibidos
+
+Nunca alterados pelo GameBoost, em nenhuma fase:
+
+- **VBS / Core Isolation (Memory Integrity)** — só informar e medir, com link para as
+ Configurações do Windows. É segurança contra malware de kernel; a decisão é do usuário.
+- **Mitigações de Spectre/Meltdown**
+- **Windows Defender**, **UAC**, **SmartScreen**, **assinatura de drivers**
+- **Limpeza de registro** — sem ganho mensurável, risco puro (regra 4)
+
+---
+
+## 6. O que **não** é jogo, mesmo morando na pasta da Steam
+
+`GameDetector.NaoSaoJogos`. Sem esta lista, o Wallpaper Engine era eleito "o jogo" só por
+estar em `steamapps\common` — e o Modo Game subia a prioridade de CPU do papel de parede em
+vez da do jogo de verdade. Foi encontrado no primeiro teste com privilégios numa máquina real.
+
+`wallpaper64`, `wallpaper32`, `wallpaperservice64`, `webwallpaper32`, `vrmonitor`,
+`vrserver`, `vrcompositor`, `vrdashboard`, `steamvr`, `aseprite`, `blender`, `krita`,
+`obs64`, `3dsmax`, `unity`, `unityhub`, `rpcs3`, `pcsx2`, `dolphin`, `retroarch`,
+`steamwebhelper`, `gameoverlayui`, `steamerrorreporter`
+
+Além da lista, a heurística de pasta ficou mais exigente: um executável desconhecido dentro
+de pasta de launcher só é considerado jogo se ocupar **mais de 300 MB** de memória.
+Utilitário não tem porte de jogo.
+
+---
+
+## 7. Desinstalador: o que nunca sai
+
+`UninstallerProtection`. Um app que case com qualquer destes trechos de nome aparece na lista
+**bloqueado**, com o motivo visível.
+
+| Grupo | Por quê |
+|---|---|
+| Visual C++ Redistributable, `vc_redist` | Outros programas param de abrir sem o runtime |
+| .NET Runtime, Desktop Runtime, ASP.NET Core, .NET SDK | Programas em .NET param de abrir |
+| DirectX | Jogos param de abrir |
+| Microsoft Edge, WebView2 | Muitos aplicativos usam o WebView2 para desenhar a interface |
+| Xbox Identity Provider, Gaming Services | Game Pass e anti-cheats da Microsoft dependem |
+| NVIDIA, AMD Software, Radeon, Intel, Realtek, chipset, áudio | Componentes de driver |
+| Windows SDK, Visual Studio | Remova pelo instalador oficial |
+| Atualizações e componentes de sistema | Não são aplicativos |
+| **Antivírus** (Kaspersky, Avast, AVG, Bitdefender, McAfee, Norton, ESET, Sophos, Malwarebytes, Trend Micro, F-Secure, Panda, CrowdStrike, SentinelOne, Webroot, Carbon Black) | Remover daqui deixaria a máquina desprotegida |
+| Pacotes da Store marcados `NonRemovable` pelo Windows | O próprio Windows recusa |
+
+**Bloatware conhecido** (Candy Crush, Solitaire, 3D Viewer, Clipchamp, LinkedIn, TikTok,
+Feedback Hub, Get Help, Your Phone, utilitários OEM e afins) aparece **sugerido**, com a caixa
+**vazia**. O que é bloat para um é ferramenta para outro — a decisão é do usuário (regra 3).
+
+---
+
+## 8. Restos de apps antigos: pastas nunca listadas
+
+`LeftoverScanner.Ignoradas`. A varredura de `%APPDATA%`, `%LOCALAPPDATA%` e `%PROGRAMDATA%`
+nunca inclui estas pastas, mesmo sem app correspondente:
+
+**Microsoft e Windows:** `Microsoft`, `MicrosoftEdge`, `Microsoft Corporation`, `Windows`,
+`WindowsApps`, `OneDrive`, `Microsoft OneDrive`, `Packages`, `Temp`, `Tmp`, `Cache`, `Comms`,
+`ConnectedDevicesPlatform`, `D3DSCache`, `ElevatedDiagnostics`, `Publisher Cache`,
+`VirtualStore`, `WebCacheLock`, `IconCache`, `Package Cache`, `Installer`, `Assembly`,
+`Diagnostics`, `History`, `Application Data`, `AppData`, `ProgramData`, `UsoShared`,
+`UsoPrivate`, `WindowsHolographicDevices`, `Desktop`, `Start Menu`, `ssh`
+
+**Guarda-chuva** (contêm vários apps ativos dentro): `Programs`, `Apps`, `Local`, `Roaming`,
+`LowLevel`, `Virtual Machines`
+
+**Hardware e driver:** `NVIDIA`, `NVIDIA Corporation`, `AMD`, `ATI`, `Intel`,
+`IntelGraphicsProfiles`, `Realtek`, `Logishrd`, `Logitech`, `Razer`, `Corsair`, `SteelSeries`,
+`Synaptics`, `ELAN`, `Qualcomm`, `Broadcom`
+
+**Compartilhadas:** `Adobe`, `Common Files`, `Oracle`, `Java`, `Sun`, `Mozilla`, `Google`,
+`Chromium`, `Chrome`, `CrashDumps`, `crashpad`, `CrashReports`, `Sentry`, `SquirrelTemp`,
+`node`, `npm`, `npm-cache`, `yarn`, `pip`, `nuget`, `.nuget`, `dotnet`, `JetBrains`,
+`VisualStudio`, `VSCode`, `Code`, `GitHub`, `Git`, `Docker`, `Kaspersky Lab`, `ESET`,
+`Avast Software`
+
+Além da lista: pastas começando com ponto, e qualquer pasta abaixo de 1 MB.
+
+### Por que o casamento não é só exato
+
+A primeira versão comparava nomes exatos e acusou `BraveSoftware` e `EpicGamesLauncher` como
+restos — os dois **estão instalados**, só escrevem a pasta sem os espaços do nome. A
+comparação passou a normalizar espaços e pontuação e a aceitar prefixo a partir de 5
+caracteres. Isso derrubou o resultado de **53 pastas e 26 GB** para **30 pastas e 3,9 GB**
+nesta máquina.
+
+**Falso positivo aqui é pior que falso negativo:** deixar de listar um resto custa espaço,
+apontar um app ativo como resto custa os dados do usuário. Por isso, e porque a heurística
+nunca será perfeita, **nada é pré-marcado e a remoção vai para a Lixeira**.
diff --git a/GameBoost/docs/QA.md b/GameBoost/docs/QA.md
new file mode 100644
index 0000000..e6fb1f0
--- /dev/null
+++ b/GameBoost/docs/QA.md
@@ -0,0 +1,270 @@
+# Checklist de QA manual
+
+O que os testes automatizados não cobrem. Nenhuma release sai sem isto.
+
+Legenda: `[ ]` não testado · `[x]` passou · `[!]` falhou, ver observação.
+
+---
+
+## Fase 0 — paridade com a v1.0.0
+
+A v1 foi reconstruída a partir do `LEIA-ME.txt` (ver `DECISOES.md`), então a paridade
+funcional precisa ser conferida à mão contra o binário antigo.
+
+- [x] `GameBoost.exe --scan relatorio.txt` gera relatório e **não encerra nada**
+- [x] `GameBoost.exe --scan` sem argumento imprime no terminal que chamou
+- [x] Ativar o Modo Game abre a tela de confirmação **antes** de fechar qualquer coisa,
+ e lista só o que está marcado
+- [x] Um app com trabalho não salvo (Bloco de Notas com texto) não perde nada. O Bloco de
+ Notas do Windows 11 persiste sozinho e por isso não pergunta; o texto volta intacto
+- [x] Apps marcados com ★ reabrem sozinhos ao desligar
+- [x] Apps não marcados aparecem na lista de restauração com botão individual
+- [x] Plano de energia volta ao anterior ao desligar
+- [x] Windows Update volta a rodar ao desligar (`Get-Service wuauserv`)
+- [x] Game DVR e Game Bar voltam aos valores originais (conferir no `regedit`)
+
+## Reversão e recuperação
+
+- [x] Ativar o Modo Game, **matar o GameBoost pelo Gerenciador de Tarefas**, reabrir:
+ aparece o aviso "foi fechado com o Modo Game ainda ativo" com o botão Restaurar agora
+- [ ] Ativar o Modo Game, **reiniciar o PC**, abrir: mesmo aviso
+- [ ] "Reverter tudo" funciona a qualquer momento, mesmo sem Modo Game ativo
+- [ ] Rodar o desinstalador com o Modo Game ativo reverte antes de remover
+- [ ] Reverter duas vezes seguidas não reaplica nada
+- [ ] Ativar e desativar **10 vezes seguidas** sem vazar estado
+ (`state-backup.json` termina sem pendências)
+
+## Blindagem — o teste que não pode falhar
+
+- [ ] **Com Valorant aberto (Vanguard rodando)**: nem `vgc`, nem `vgtray`, nem `vgk`
+ aparecem selecionáveis. Ativar o Modo Game **não derruba o jogo**
+- [ ] Com jogo da Steam aberto: o jogo aparece na categoria "Jogo", bloqueado
+- [ ] Steam, Epic e Battle.net aparecem **desmarcados**
+- [ ] OBS gravando aparece desmarcado
+- [ ] Nenhum processo do antivírus aparece selecionável
+
+## Fase 1 — diagnóstico e relatório
+
+- [x] Página Diagnóstico: os cinco medidores se movem e o gráfico de 60 s preenche
+- [x] GPU aparece com valor numa máquina com placa dedicada (medido: 14%, 1,9 GB de VRAM)
+- [x] Temperatura aparece como "indisponível", nunca como 0 °C
+- [ ] Sair da página Diagnóstico para a coleta (conferir no log a linha de custo)
+- [ ] Achados não piscam: um que apareceu continua na tela por pelo menos 30 s
+- [ ] Botão de ação de cada achado leva ao lugar certo (Segurança do Windows, vídeo, energia)
+- [x] `--report saida.html` gera arquivo que abre em qualquer navegador, sem link externo
+- [x] `--report saida.json` gera JSON válido
+- [x] Custo da coleta abaixo de 1,5% de CPU em máquina fraca (medido: 0,115% com 20 núcleos)
+- [ ] Numa máquina bem cuidada, a nota fica acima de 90 e quase não há achados
+
+## Fase 2 — limpeza e ferramentas
+
+- [x] Varredura de limpeza mede o disco real (medido: 22,3 GB encontrados, 4,8 GB no preset seguro)
+- [x] Shader cache e prefetch aparecem desmarcados, com a advertência visível
+- [x] `Downloads` aparece bloqueado, com "o GameBoost nunca remove arquivo daqui"
+- [x] Cache de navegador aberto aparece bloqueado, pedindo para fechar
+- [x] `--clean --preset seguro --dry-run` não remove nada
+- [x] Teste de velocidade de disco apaga o arquivo de 1 GB, inclusive se falhar
+- [x] Informações do sistema leem dados reais da máquina
+- [ ] Limpeza real: rodar duas vezes seguidas, segunda libera ~0
+- [ ] Reiniciar o Explorer devolve a barra de tarefas
+- [ ] Reiniciar o driver de vídeo não derruba jogo aberto
+- [ ] Ponto de restauração aparece na Restauração do Sistema do Windows
+- [ ] `sfc /scannow` mostra a saída ao vivo e não parece travado
+
+## Fase 3 — analisador de espaço
+
+- [x] Varredura do disco do sistema (medido: 1.548.745 arquivos, 809,5 GB, em 18 a 20 s)
+- [x] Soma bate com o espaço ocupado, e o que falta é declarado no rodapé
+- [x] Treemap desenha proporcional e colorido por categoria
+- [x] Biblioteca lê Steam e Epic com tamanho e último jogo (14 jogos, 974 GB)
+- [x] Contador de pastas inacessíveis reporta o número real (543, não 40.871)
+- [x] Memória com a árvore aberta (medido: 594 MB para 1,5 milhão de nós)
+- [ ] Clicar num bloco do treemap desce um nível, e Subir volta
+- [x] Enviar um arquivo para a Lixeira pela página e conferir que dá para restaurar
+- [ ] Varredura de um segundo volume (D:)
+- [ ] Cancelar a varredura no meio deixa a interface utilizável
+
+## Fase 4 — desinstalador e inicialização
+
+- [x] Inventário lê a máquina real (medido: 217 apps, 113 da Store, 78 protegidos)
+- [x] Nada vem pré-marcado, nem o bloatware mais óbvio (rodapé abre em "Aplicar 0 selecionados")
+- [x] IDEs e apps de desenvolvimento aparecem na lista e **nunca** pré-marcados (regra 3)
+- [x] Runtimes, drivers e antivírus aparecem bloqueados com o motivo visível
+- [x] Total declarado é plausível (1044 GB para 2145 GB de disco; antes dizia 1812 GB)
+- [x] Lista ordenada pelo tamanho **medido**, não pelo declarado no registro
+- [x] Aba "Restos de apps antigos" sem falso positivo óbvio (30 pastas, 3,9 GB)
+- [x] Inicialização lê registro e pastas (medido: 26 entradas, 4 bloqueadas, 8 sugeridas)
+- [x] O número bate com o que o módulo de Diagnóstico informa
+- [x] Dry-run da inicialização não escreve `ChangeRecord` nenhum
+- [x] App da Store em WindowsApps não é acusado de "sem assinatura digital"
+- [x] Desinstalar um app de verdade (medido: Call of Duty, 180,3 GB; 217→216 apps, 1044→863,7 GB)
+- [ ] Desinstalar um app da Store (`Remove-AppxPackage`)
+- [x] Desinstalar um app cujo desinstalador abre janela própria (o GameBoost espera, não trava)
+- [x] Desativar um item e conferir no Gerenciador de Tarefas (medido: WallpaperEngine,
+ byte 0x03 + FILETIME, "Desabilitado" nos dois lados)
+- [x] Reverter a desativação e conferir que o programa volta a abrir no boot
+- [x] Enviar uma pasta de resto para a Lixeira (medido: qwen-updater, 119 MB, restaurável)
+
+## Complemento da Fase 4 — aba Atualizações (winget)
+
+- [x] winget detectado e versão lida (medido: 1.29.290)
+- [x] Tabela lida por posição, não por idioma (teste com cabeçalho em português)
+- [x] Lista bate com `winget upgrade` rodado à mão (medido: 35 atualizações)
+- [x] Nenhum id sai com espaço no meio; toda fonte é winget ou msstore
+- [x] Driver aparece bloqueado com o motivo
+- [x] Runtime e "atualiza sozinho" aparecem com risco médio e explicação
+- [x] Atualização de segurança marcada só em quem abre arquivo da internet
+- [x] "Chrome Remote Desktop Host" **não** é marcado como navegador
+- [x] App aberto é detectado e a linha avisa "feche antes"
+- [x] Nada pré-marcado, nem a atualização de segurança
+- [x] Abas legíveis no tema escuro
+- [x] Controles dentro das abas visíveis para a automação (leitor de tela)
+- [x] Aba "Restos de apps antigos" na interface (medido: 30 pastas, 3,8 GB)
+- [x] Atualizar um app de verdade (medido: 7-Zip 24.09 → 26.03)
+- [ ] Atualizar um app que está aberto e conferir a mensagem de erro
+- [ ] Atualizar um app que falha e conferir o código traduzido e o link do log
+- [x] Conferir o `updates-history.json` depois de uma atualização real
+- [ ] Máquina sem winget: conferir o cartão e o botão da Store
+- [ ] Windows em inglês: conferir que a tabela continua sendo lida
+
+## Fase 5 — tweaks, serviços, rede e drivers
+
+- [x] Catálogo lê o estado real de cada ajuste (medido: 24 itens, 1 já ativo)
+- [x] Nada pré-marcado, nem o que está marcado como recomendado
+- [x] VBS aparece bloqueado e só informativo
+- [x] Windows Update e Defender aparecem bloqueados com o motivo
+- [x] Ajuste que só precisa de elevação diz "Precisa de admin", não "Protegido"
+- [x] Estado da hibernação lido do registro, correto sem elevação
+- [x] Dry-run dos tweaks não escreve ChangeRecord nenhum
+- [x] NAT classificado como aberto/moderado/estrito (medido: aberto, semáforo verde)
+- [x] STUN devolve endereço público de verdade, nunca um privado
+- [x] CGNAT, NAT duplo, UPnP, Teredo e firewall detectados e explicados
+- [x] Teste de velocidade recusa sair para a rede sem a permissão, e diz por quê
+- [x] Driver de vídeo lido com versão de marketing (medido: GTX 1660 Ti, 616.64, 0 meses)
+- [x] Adaptador virtual do Hyper-V não entra na lista de placas
+- [x] Aplicar um tweak de HKCU elevado e conferir a chave (mouse: 1/6/10 viraram 0/0/0)
+- [x] Reverter tweak cujo valor **não existia**: `VisualFXSetting` foi apagado, não zerado
+- [x] Mudar um serviço para Manual e conferir (WSearch: Automatic → Manual)
+- [x] Reverter o serviço e conferir que o estado de execução também volta
+- [ ] Trocar o DNS do adaptador e reverter para automático (DHCP)
+- [ ] Reativar o Teredo numa máquina onde ele está desligado e conferir `netsh interface teredo show state`
+- [x] Teste de velocidade comparado com teste dedicado (medido: -15%, dentro do esperado)
+- [ ] Máquina em CGNAT: conferir que o diagnóstico acusa e não sugere abrir porta
+- [ ] Máquina com NAT duplo: conferir a contagem de saltos privados
+- [ ] Comparar a classificação de NAT com o que o app do Xbox mostra na mesma rede
+- [ ] Máquina no Wi-Fi: conferir SSID, padrão e banda
+
+## Fase 6 — perfis, bandeja e onboarding
+
+- [x] Perfil grava e lê de volta, com afinidade e timer
+- [x] Perfil novo nunca vem com "ativa sozinho" marcado
+- [x] Afinidade recusa índice de núcleo que não existe na máquina (medido: 20 núcleos)
+- [x] Afinidade com lista vazia devolve todos os núcleos
+- [x] Varredura de janelas para tela cheia responde rápido (medido: 3 ms)
+- [x] Biblioteca cruza com perfis (medido: 14 jogos, 974,2 GB, 0 com perfil)
+- [x] Ícone na bandeja aparece e o vigia inicia (confirmado no log)
+- [x] Onboarding aparece na primeira abertura e é alcançável pela automação
+- [x] Abrir um jogo de verdade e conferir o convite (medido: Warframe, confiança 80, tela cheia)
+- [x] Aceitar o convite e conferir que o perfil é aplicado (prioridade Normal → AboveNormal)
+- [x] Fechar o jogo e conferir o comportamento (prioridade morre com o processo; o texto diz isso)
+- [ ] Marcar "ativa sozinho" e conferir que o próximo boot do jogo não pergunta
+- [x] Recusar o convite e conferir que ele não volta na mesma sessão
+- [ ] Matar o GameBoost com perfil aplicado e conferir o aviso na reabertura
+- [x] Fechar a janela e conferir que o app fica na bandeja, com o aviso uma vez só
+- [x] "Sair" da bandeja encerra o processo de verdade
+- [ ] "Limpar RAM" pela bandeja com o app minimizado
+- [ ] Onboarding marcado como concluído não volta na segunda abertura
+- [x] Anti-cheat: o jogo aparece na lista do Modo Game como **Protegido**, com "Este parece
+ ser o jogo", risco Alto e caixa desabilitada
+- [x] Atualizador de launcher (Agent do Battle.net) **não** é detectado como jogo
+- [ ] Perfil com afinidade num jogo real: conferir no Gerenciador de Tarefas
+
+## Fase 7 — distribuição
+
+- [x] Modo portátil grava tudo em `.\data\` ao lado do exe (medido com o binário real)
+- [x] Modo portátil não deixa nada em `%LOCALAPPDATA%\GameBoost`
+- [x] Serviço de atualização sempre explica por que não pode verificar
+- [x] YAML do workflow de release é válido (14 passos)
+- [x] `#ifndef AppVersion` deixa o CI passar a versão
+- [ ] Rodar o workflow de release por `workflow_dispatch` e conferir os artefatos
+- [ ] Instalar pelo `.exe` gerado e conferir o atalho e a entrada em Programas
+- [ ] Instalar "só para o usuário atual" sem senha de administrador
+- [ ] Instalar "para todos" e conferir que o autostart foi para HKLM
+- [ ] Desinstalar e conferir que não sobra chave de autostart
+- [ ] Marcar a limpeza semanal e conferir a tarefa em `taskschd.msc`
+- [ ] Publicar uma release e conferir que o auto-update encontra a versão nova
+- [ ] Aplicar a atualização e conferir que a versão mudou
+- [ ] Anotar exatamente o que o SmartScreen diz no primeiro download
+- [ ] Conferir se o Defender bloqueia o instalador ou a execução
+- [ ] Submeter ao Microsoft Security Intelligence se houver falso positivo
+
+## Ambiente
+
+- [x] **Usuário sem admin**: varreduras funcionam, botões de ação desabilitados com aviso
+ visível de modo somente leitura
+- [ ] Windows 10 22H2
+- [ ] Windows 11 24H2 ou mais novo
+- [ ] Máquina com 8 GB de RAM (é onde a limpeza de RAM rende de verdade)
+- [ ] Notebook com iGPU + dGPU
+- [ ] Dois usuários logados: processos da outra sessão não aparecem
+- [ ] Monitor 4K e escala de 150% (DPI per-monitor v2)
+
+## Distribuição
+
+- [ ] SmartScreen: anotar exatamente o que aparece no primeiro download
+- [ ] Windows Defender não bloqueia o instalador nem a execução
+- [ ] Submeter ao Microsoft Security Intelligence se houver falso positivo
+- [ ] Instalação limpa em máquina sem .NET instalado (publish é self-contained)
+- [ ] Desinstalação não deixa `%LOCALAPPDATA%\GameBoost` com dados do usuário sem avisar
+
+## Desempenho
+
+- [ ] Varredura completa em menos de 5 s numa máquina com ~250 processos
+- [ ] A UI não congela durante a varredura (o botão Cancelar responde)
+- [ ] O app ocioso consome menos de 60 MB de RAM
+
+---
+
+## Observações de execução
+
+| Data | Item | Resultado |
+|---|---|---|
+| 14/09/2026 | QA manual bloco 6 (perfis, deteccao, bandeja) | **6 bugs achados**: a deteccao funcionava, o resto nao |
+| 14/09/2026 | QA manual bloco 5 (inicializacao) | **1 bug achado**: o Reverter nao revertia |
+| 14/09/2026 | QA manual bloco 4 (apps, restos, winget) | **1 crash achado e corrigido**: a Lixeira derrubava o app |
+| 14/09/2026 | QA manual blocos 2 e 3 (tweaks, servicos, rede) | **4 bugs achados e corrigidos**, depois passou |
+| 14/09/2026 | QA manual bloco 1 (reversibilidade) elevado, na maquina real | **6 bugs achados e corrigidos**, depois passou |
+| 14/09/2026 | Modo portatil com o binario real: data\ ao lado do exe | Passou |
+| 14/09/2026 | Perfis, afinidade, tela cheia, bandeja e onboarding | Passou apos 2 correcoes |
+| 14/09/2026 | Telas de Jogos e onboarding renderizadas | Passou |
+| 14/09/2026 | winget: 35 atualizacoes lidas e classificadas | Passou apos 3 correcoes |
+| 14/09/2026 | Abas de Apps (Instalados, Atualizacoes, Restos) renderizadas | Passou |
+| 14/09/2026 | Tweaks, servicos, NAT, velocidade e driver de video (nao elevado) | Passou apos 4 correcoes |
+| 14/09/2026 | Telas de Tweaks e Rede renderizadas e conferidas | Passou |
+| 14/09/2026 | Inventario de apps, restos, inicializacao (nao elevado) | Passou. 217 apps, 30 restos, 26 entradas |
+| 14/09/2026 | Telas de Apps e Inicializacao renderizadas e conferidas | Passou apos 4 correcoes |
+| 13/09/2026 | Ciclo real ativar → desligar, com privilégios | **Passou.** Ver detalhe abaixo |
+| 13/09/2026 | `--scan`, `--report` (HTML, JSON e texto) no binário de produção | Passou |
+| 13/09/2026 | Custo da coleta | 0,115% de CPU, ciclo de 36 ms, em 20 núcleos |
+| 13/09/2026 | Telas Início e Diagnóstico | Passou, depois de corrigir um crash de XAML |
+
+### Ciclo real de reversibilidade — 13/09/2026
+
+Executado com o binário de produção (`requireAdministrator`), com todos os 129 processos da
+máquina em `NuncaEncerrar` e um único alvo descartável compilado para o teste.
+
+| Item | Antes | Com o Modo Game ativo | Depois de desligar |
+|---|---|---|---|
+| `GameDVR_Enabled` | `1` | `0` | `1` |
+| `UseNexusForGameBarEnabled` | não existia | `0` | **apagado** |
+| `ShowStartupPanel` | não existia | `0` | **apagado** |
+| `ToastEnabled` | não existia | `0` | **apagado** |
+| Plano de energia | Equilibrado | Alto desempenho | Equilibrado |
+| `wuauserv` | parado | parado (não foi tocado) | parado |
+| App marcado com ★ | rodando | encerrado | **reaberto** |
+
+Os três valores que **não existiam** foram apagados na reversão, não zerados — que é a
+diferença entre reverter e deixar um rastro. `state-backup.json` terminou com **0 pendências**.
+
+Ganho medido na ativação: RAM livre de 1,3 GB para 3,9 GB, com 13,2 GB retirados do working
+set de 462 processos.
diff --git a/GameBoost/docs/TWEAKS.md b/GameBoost/docs/TWEAKS.md
new file mode 100644
index 0000000..374e412
--- /dev/null
+++ b/GameBoost/docs/TWEAKS.md
@@ -0,0 +1,174 @@
+# Catálogo de tweaks
+
+Cada tweak: chave, valor, efeito real, evidência, risco e reversão. Todos desligados por
+padrão, aplicados individualmente, sempre com snapshot em `state-backup.json`.
+
+**A coluna "efeito real" é honesta por obrigação (regra 4).** Se um tweak é marginal, está
+escrito que é marginal.
+
+---
+
+## Implementados na Fase 5 (página Tweaks)
+
+Catálogo completo da seção 5.8, aplicado item a item, cada um com ChangeRecord
+antes da escrita. **Nada vem pré-marcado**, nem o que está marcado como
+recomendado: recomendação é informação, não consentimento.
+
+### Jogos
+
+| Tweak | Chave | Valor | Efeito real | Risco |
+|---|---|---|---|---|
+| Agendamento de GPU por hardware (HAGS) | `HKLM\SYSTEM\CurrentControlSet\Control\GraphicsDrivers` → `HwSchMode` | `2` | Obrigatório para o Frame Generation do DLSS 3. Em RTX 30+ e RX 6000+ com driver atual costuma melhorar a regularidade dos quadros; em CPU antiga pode piorar. Usa até 1 GB de VRAM. **Exige reinício** | Médio |
+| Modo Jogo do Windows | `HKCU\Software\Microsoft\GameBar` → `AutoGameModeEnabled` | `1` | No 22H2+ segura o Windows Update e a instalação de driver durante o jogo, que é o ganho concreto. Recomendado | Baixo |
+| Gravação em segundo plano (Game DVR) | `HKCU\System\GameConfigStore` → `GameDVR_Enabled` e `HKCU\Software\Microsoft\GameBar` → `UseNexusForGameBarEnabled` | `0` | Desliga a gravação contínua. Ganho real em máquina modesta, pequeno em PC forte. Perde o atalho de gravar os últimos 30 s | Baixo |
+| Prioridade da categoria Jogos | `HKLM\...\SystemProfile\Tasks\Games` → `GPU Priority`=8, `Priority`=6, `Scheduling Category`=High | — | **Marginal.** Nenhuma medição pública consistente mostra ganho em hardware atual. Está no catálogo por ser reversível e sem custo, não por render FPS | Baixo |
+| Desativar Multi-Plane Overlay (MPO) | `HKLM\SOFTWARE\Microsoft\Windows\Dwm` → `OverlayTestMode` | `5` | Só vale com o sintoma: piscada preta, tremulação ou stutter na área de trabalho, comum em NVIDIA com dois monitores. **Não dá FPS.** Exige reinício | Médio |
+
+### Sistema
+
+| Tweak | Chave | Valor | Efeito real | Risco |
+|---|---|---|---|---|
+| Reserva de CPU para tarefas de fundo | `HKLM\...\SystemProfile` → `SystemResponsiveness` | `0` | **Marginal.** Devolve ao primeiro plano os 20% que o Windows reserva para multimídia de fundo. Pode causar engasgo em gravação e transmissão — o padrão 20 existe para proteger áudio | Baixo |
+| Efeitos visuais no melhor desempenho | `HKCU\...\Explorer\VisualEffects` → `VisualFXSetting` | `2` | Ajuda só em máquina fraca ou sem GPU dedicada. Não afeta jogo em tela cheia, que nem passa pelo DWM | Baixo |
+
+### Rede
+
+| Tweak | Chave | Valor | Efeito real | Risco |
+|---|---|---|---|---|
+| Desativar limite de tráfego multimídia | `HKLM\...\SystemProfile` → `NetworkThrottlingIndex` | `0xFFFFFFFF` | **Marginal.** O limite de 10 pacotes/ms foi criado para placas de rede de 2007 e praticamente não morde em hardware atual | Baixo |
+
+### Mouse e teclado
+
+| Tweak | Chave | Valor | Efeito real | Risco |
+|---|---|---|---|---|
+| Desativar aceleração do mouse | `HKCU\Control Panel\Mouse` → `MouseSpeed`, `MouseThreshold1`, `MouseThreshold2` | `0` | Consistência de mira, não desempenho. Jogos que usam Raw Input já ignoram isto. Vai atrapalhar quem está acostumado com a aceleração ligada | Baixo |
+
+### Energia
+
+| Tweak | Onde | Efeito real | Risco |
+|---|---|---|---|
+| Desativar limitação de energia do processador | `HKLM\SYSTEM\CurrentControlSet\Control\Power\PowerThrottling` → `PowerThrottlingOff` = 1 | Faz diferença em notebook, onde a limitação é agressiva. Em desktop na tomada, quase nada. Custa consumo e temperatura | Baixo |
+| Desativar hibernação | `powercfg /h off` | Libera em disco o equivalente à RAM instalada. Perde hibernação e Inicialização Rápida, e o boot fica alguns segundos mais lento. Vale por espaço, não por desempenho | Médio |
+
+### Segurança: aparece, explica e não mexe
+
+| Item | Por quê |
+|---|---|
+| Isolamento de núcleo (VBS) | Aparece **só de leitura**. As medições da comunidade põem o custo entre 5% e 15% em jogos limitados por CPU, e em troca é o que impede um driver malicioso de ler a memória do sistema. Num PC que também é banco e trabalho, desligar é troca ruim; num PC só de jogo é escolha defensável — do dono, na tela da Microsoft. O botão abre Segurança do Windows |
+| Mitigações de Spectre/Meltdown | **Não estão no catálogo, nem bloqueadas.** Item na tela vira ideia na cabeça de alguém. Desligar rende alguns por cento em CPU antiga e abre um buraco de segurança real |
+
+### Como o estado é lido
+
+Um tweak "desligado" quase nunca significa valor zero: na maioria das chaves
+significa **valor ausente**, com o Windows assumindo o padrão. Por isso a
+reversão apaga o valor quando ele não existia antes, em vez de gravar zero —
+confundir os dois é o que faz ferramenta de tweak deixar lixo que nunca sai.
+
+A hibernação é lida do registro (`HibernateEnabled`, com `HibernateEnabledDefault`
+como fallback), não pela existência de `C:\hiberfil.sys`: aquele arquivo é de
+sistema e `File.Exists` devolve falso sem elevação, o que faria o app dizer "já
+desligada" para quem abriu sem ser administrador.
+
+---
+
+## Serviços do Windows (seção 5.7, Fase 5)
+
+Régua diferente da dos tweaks: um serviço desligado por engano não tira alguns
+FPS, tira a impressora, a busca ou o Game Pass.
+
+### Dá para desativar
+
+| Serviço | Sugestão | O que você perde |
+|---|---|---|
+| `DiagTrack` | Desativado | Nada de sistema. O motivo de desligar é privacidade, não FPS |
+| `MapsBroker` | Desativado | Mapas offline do aplicativo Mapas |
+| `RemoteRegistry` | Desativado | Nada. Já vem desativado no Windows doméstico |
+| `WMPNetworkSvc` | Desativado | Compartilhar a biblioteca do Media Player na rede |
+| `Fax` | Desativado | Fax por modem |
+| `WSearch` | Manual | A busca do Iniciar e do Explorer fica lenta. Vale em HDD; em SSD o índice compensa |
+| `Spooler` | Manual | Impressão, **inclusive salvar em PDF** |
+
+### Aparece para explicar por que **não** mexer
+
+| Serviço | Por quê |
+|---|---|
+| `SysMain` | Toda lista de otimização manda desligar, e está errada. Em SSD ele quase não faz leitura antecipada; o que faz hoje é gerenciar memória. Desligar não devolve RAM, devolve cache — e o Windows usa RAM livre para cache de qualquer jeito |
+| `XblAuthManager`, `XboxNetApiSvc` | Sem eles o Game Pass não abre e alguns anti-cheat falham. Parados não custam nada; desligados quebram o Game Pass |
+| `wuauserv` | Nunca desativar. Máquina sem atualização de segurança é problema maior que qualquer FPS. O Modo Game já pausa o Update durante o jogo e religa depois |
+| `WinDefend`, `SecurityHealthService` | O GameBoost não desliga antivírus, nem o do Windows nem o de terceiros |
+
+---
+
+## Implementados na Fase 0 (Modo Game)
+
+Aplicados em bloco pelo Modo Game, revertidos no desligamento. Ver
+[`SystemSilencer`](../src/GameBoost.Core/Modules/GameMode/SystemSilencer.cs).
+
+| Tweak | Chave | Valor | Efeito real | Risco |
+|---|---|---|---|---|
+| Notificações do Windows | `HKCU\Software\Microsoft\Windows\CurrentVersion\PushNotifications` → `ToastEnabled` | `0` | Impede notificação aparecendo sobre o jogo em tela cheia. Efeito de conforto, não de FPS | Baixo |
+| Game DVR | `HKCU\System\GameConfigStore` → `GameDVR_Enabled` | `0` | Desliga gravação contínua em segundo plano. Ganho real em máquina fraca; em PC forte é pequeno | Baixo |
+| Overlay da Game Bar | `HKCU\Software\Microsoft\GameBar` → `UseNexusForGameBarEnabled` | `0` | Evita a injeção do overlay em todo jogo aberto | Baixo |
+| Painel da Game Bar | `HKCU\Software\Microsoft\GameBar` → `ShowStartupPanel` | `0` | Só evita o painel aparecer ao abrir o jogo | Baixo |
+
+**Reversão:** cada um grava o valor anterior e o flag `ValorAnteriorExistia`. Quando a chave
+não existia, a reversão **apaga** o valor em vez de gravar zero.
+
+### Plano de energia
+
+Aplica **Desempenho Máximo** se existir, senão **Alto desempenho**; guarda o GUID do plano
+anterior e restaura no desligamento.
+
+| Plano | GUID |
+|---|---|
+| Balanceado | `381b4222-f694-41f0-9685-ff5bb260df2e` |
+| Alto desempenho | `8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c` |
+| Desempenho Máximo | `e9a42b02-d5df-448d-aa00-03f14749eb61` |
+| Economia de energia | `a1841308-3541-4fab-bc81-f71556f20b4a` |
+
+**Evidência:** é o item nº 1 do consenso técnico de 2026 (seção 3.4 do spec) — evita que a
+CPU baixe o clock no meio do jogo. Junto com a poda da inicialização, é o ajuste de maior
+valor e o mais fácil de reverter.
+
+### Pausa do Windows Update
+
+Para o serviço `wuauserv` (só para, nunca altera `StartType`). Grava `ChangeRecord` com
+`estavaRodando=true`.
+
+**Por que importa reverter:** um travamento com o Update pausado deixaria a máquina sem
+atualizações indefinidamente. Por isso a reversão acontece no desligamento, no "Reverter
+tudo", na próxima abertura após travamento **e** no desinstalador.
+
+---
+
+## Do catálogo da seção 5.8, o que ficou de fora
+
+Três itens do spec não entraram na Fase 5, e cada um tem um motivo diferente.
+
+| Tweak | Por que não entrou |
+|---|---|
+| Fullscreen Optimizations por executável | `GameDVR_FSEBehaviorMode` e `DISABLEDXMAXIMIZEDWINDOWEDMODE` são ajustes **por jogo**, não globais. O spec já diz "oferecer por perfil", e perfil por jogo é a Fase 6 |
+| Nagle (`TcpAckFrequency`, `TCPNoDelay`) | A chave fica em `...\Tcpip\Parameters\Interfaces\`, uma por adaptador, e o ganho é marginal mesmo no melhor caso: a maioria dos jogos usa UDP, onde Nagle não existe. Entra junto com o perfil por jogo, que sabe qual jogo justifica mexer |
+| Timer resolution | O P/Invoke existe em `NativeMethodsBridge`, mas a resolução de timer só vale enquanto o processo que pediu está vivo. Faz sentido dentro da sessão do Modo Game, não como um botão que liga e desliga sozinho. Fase 6 |
+
+### Nunca alterados
+
+| Item | Por quê |
+|---|---|
+| **VBS / Core Isolation (Memory Integrity)** | Custo medido pela comunidade de 5 a 15% em jogos CPU-bound (até 25% em casos extremos), mas é proteção contra malware de kernel. O GameBoost **informa e mede, não desliga**: mostra o estado via WMI `Win32_DeviceGuard`, o impacto estimado e o caminho nas Configurações do Windows. Trade-off de PC exclusivo de jogo vs. máquina de trabalho/banco é decisão do usuário |
+| **Mitigações de Spectre/Meltdown** | Troca segurança real por poucos % |
+| **Defender, UAC, SmartScreen, assinatura de drivers** | Fora de escopo, sempre |
+| **Limpeza de registro** | Sem ganho mensurável. Risco puro (regra 4) |
+| **Desfragmentação de SSD** | Placebo, desgasta a unidade |
+| **Atualização automática de driver** | Instalar driver sozinho é risco. O GameBoost só detecta a idade e abre a página oficial |
+
+---
+
+## Detector de reset pós-update
+
+Atualizações grandes do Windows (24H2, 26H2) **resetam** Game Mode, HAGS, plano de energia,
+efeitos visuais e Game Bar. Nenhum concorrente avisa disso.
+
+Planejado para a Fase 1 como `Finding`: guardar a build do Windows junto do perfil aplicado
+e, quando a build mudar e os valores não baterem mais, mostrar *"a atualização 26H2 desfez 4
+ajustes seus"* com o botão de reaplicar.
diff --git a/GameBoost/installer/GameBoost.iss b/GameBoost/installer/GameBoost.iss
new file mode 100644
index 0000000..f88987c
--- /dev/null
+++ b/GameBoost/installer/GameBoost.iss
@@ -0,0 +1,100 @@
+; Instalador do GameBoost (Inno Setup 6).
+; Compilar com: ISCC.exe installer\GameBoost.iss
+; Antes, publicar: dotnet publish src\GameBoost.App\GameBoost.App.csproj -c Release -o publish
+
+#define AppName "GameBoost"
+; O CI passa a versao com /DAppVersion=X.Y.Z. O valor abaixo so vale quando
+; o ISCC e chamado a mao, sem o parametro.
+#ifndef AppVersion
+ #define AppVersion "2.0.0"
+#endif
+#define AppAutor "Jonathan Vaz"
+#define AppExe "GameBoost.exe"
+
+[Setup]
+AppId={{7C3F9A21-4E8B-4A6D-9F2C-8B1D5E7A3C90}
+AppName={#AppName}
+AppVersion={#AppVersion}
+AppVerName={#AppName} {#AppVersion}
+AppPublisher={#AppAutor}
+DefaultDirName={autopf}\{#AppName}
+DefaultGroupName={#AppName}
+OutputDir=Output
+OutputBaseFilename={#AppName}-Setup-{#AppVersion}
+Compression=lzma2/max
+SolidCompression=yes
+WizardStyle=modern
+ArchitecturesAllowed=x64compatible
+ArchitecturesInstallIn64BitMode=x64compatible
+; O app encerra processos, controla servicos e purga a Standby List, entao
+; precisa de admin para RODAR. Instalar, porem, pode ser so para o usuario
+; atual: quem nao tem a senha de administrador da maquina ainda consegue
+; instalar em %LOCALAPPDATA% e usar o app em modo somente leitura.
+;
+; "dialog" faz o Inno perguntar na primeira tela. A resposta muda {autopf}
+; entre "Program Files" e a pasta local do usuario.
+PrivilegesRequired=lowest
+PrivilegesRequiredOverridesAllowed=dialog commandline
+UninstallDisplayIcon={app}\{#AppExe}
+MinVersion=10.0.17763
+
+[Languages]
+Name: "brazilianportuguese"; MessagesFile: "compiler:Languages\BrazilianPortuguese.isl"
+
+[Tasks]
+Name: "desktopicon"; Description: "Criar atalho na area de trabalho"; GroupDescription: "Atalhos:"
+Name: "startminimized"; Description: "Iniciar o GameBoost junto com o Windows, minimizado na bandeja"; GroupDescription: "Inicializacao:"; Flags: unchecked
+; Desligada por padrao de proposito: agendamento so com escolha explicita do usuario.
+Name: "limpezasemanal"; Description: "Agendar limpeza semanal com o preset seguro"; GroupDescription: "Manutencao:"; Flags: unchecked
+
+[Files]
+Source: "..\publish\{#AppExe}"; DestDir: "{app}"; Flags: ignoreversion
+Source: "..\README.md"; DestDir: "{app}"; DestName: "LEIA-ME.txt"; Flags: ignoreversion isreadme
+
+[Icons]
+Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExe}"
+Name: "{group}\Desinstalar {#AppName}"; Filename: "{uninstallexe}"
+Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExe}"; Tasks: desktopicon
+
+[Registry]
+; HKA resolve para HKCU numa instalacao so do usuario e para HKLM numa
+; instalacao para todos. Fixar HKCU faria a opcao "todos os usuarios" gravar
+; o autostart so para quem rodou o instalador.
+Root: HKA; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \
+ ValueName: "GameBoost"; ValueData: """{app}\{#AppExe}"" --minimizado"; \
+ Flags: uninsdeletevalue; Tasks: startminimized
+
+[Run]
+Filename: "{app}\{#AppExe}"; Description: "Abrir o {#AppName}"; Flags: nowait postinstall skipifsilent
+
+; Tarefa semanal opcional, aos domingos as 10h.
+Filename: "{sys}\schtasks.exe"; \
+ Parameters: "/Create /F /TN ""GameBoost - Limpeza semanal"" /SC WEEKLY /D SUN /ST 10:00 /RL HIGHEST /TR ""\""{app}\{#AppExe}\"" --clean --preset seguro"""; \
+ Flags: runhidden; Tasks: limpezasemanal
+
+[UninstallRun]
+; Regra 1: o desinstalador desfaz as alteracoes de sistema ANTES de remover o app.
+; Sem isto, desinstalar com o Modo Game ativo deixaria o Windows Update pausado
+; e o plano de energia trocado para sempre.
+Filename: "{app}\{#AppExe}"; Parameters: "--revert-all"; Flags: runhidden waituntilterminated; RunOnceId: "ReverterTudo"
+Filename: "{sys}\schtasks.exe"; Parameters: "/Delete /F /TN ""GameBoost - Limpeza semanal"""; Flags: runhidden; RunOnceId: "RemoverTarefa"
+
+[UninstallDelete]
+Type: filesandordirs; Name: "{app}\data"
+
+[Code]
+// Os dados do usuario (preferencias, historico, log) ficam fora de {app} e nao sao
+// apagados sem pergunta.
+procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
+var
+ Dados: String;
+begin
+ if CurUninstallStep = usPostUninstall then
+ begin
+ Dados := ExpandConstant('{localappdata}\GameBoost');
+ if DirExists(Dados) then
+ if MsgBox('Remover tambem suas preferencias, historico e log?' + #13#10 + #13#10 + Dados,
+ mbConfirmation, MB_YESNO) = IDYES then
+ DelTree(Dados, True, True, True);
+ end;
+end;
diff --git a/GameBoost/installer/NOTAS-DA-RELEASE.md b/GameBoost/installer/NOTAS-DA-RELEASE.md
new file mode 100644
index 0000000..48a6123
--- /dev/null
+++ b/GameBoost/installer/NOTAS-DA-RELEASE.md
@@ -0,0 +1,36 @@
+## O que baixar
+
+| Arquivo | Para quem |
+|---|---|
+| `GameBoost-Setup-*.exe` | A maioria. Instala, cria atalho e habilita a atualização automática |
+| `GameBoost-*-portatil.zip` | Pen drive, máquina de terceiro, ou quem não quer instalar nada. Guarda os dados ao lado do executável e **não** se atualiza sozinho |
+| `GameBoost.exe` | O executável solto, para quem sabe o que quer |
+| `SHA256SUMS.txt` | Conferir que o download veio inteiro |
+
+Os arquivos `.nupkg` e `*-full.nupkg` são do mecanismo de atualização. Não
+precisam ser baixados à mão.
+
+## Aviso do SmartScreen
+
+Esta versão pode não estar assinada. Se aparecer *"O Windows protegeu o
+computador"*, o caminho é **Mais informações → Executar assim mesmo**.
+
+Antes disso, se quiser conferir que o arquivo é o mesmo que saiu daqui:
+
+```powershell
+Get-FileHash .\GameBoost-Setup-2.0.0.exe -Algorithm SHA256
+```
+
+e comparar com a linha correspondente do `SHA256SUMS.txt`.
+
+O porquê de não estar assinado, e o que está sendo feito a respeito, está em
+[docs/ASSINATURA.md](../docs/ASSINATURA.md).
+
+## O que ele faz
+
+Todo o resto está no [README](../README.md). O resumo em três linhas:
+
+- Tudo o que ele altera, ele desfaz. O valor anterior vai para o histórico antes
+ da escrita, e cada ação tem botão de desfazer.
+- Não tem telemetria. Nenhum dado sai da máquina.
+- Quando um ajuste é marginal, está escrito que é marginal.
diff --git a/GameBoost/src/GameBoost.App/App.xaml b/GameBoost/src/GameBoost.App/App.xaml
new file mode 100644
index 0000000..2a3fac4
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/App.xaml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/App.xaml.cs b/GameBoost/src/GameBoost.App/App.xaml.cs
new file mode 100644
index 0000000..59fd2bb
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/App.xaml.cs
@@ -0,0 +1,235 @@
+using System.Windows;
+using GameBoost.App.ViewModels;
+using GameBoost.App.Views;
+using GameBoost.Core;
+using GameBoost.Core.Logging;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace GameBoost.App;
+
+///
+/// Caminho grafico. Os comandos de linha nunca chegam aqui: Program os trata
+/// antes de o WPF existir.
+///
+public partial class App : Application
+{
+ private IServiceProvider? _services;
+
+ public static IServiceProvider Services =>
+ ((App)Current)._services ?? throw new InvalidOperationException("Container ainda nao inicializado.");
+
+ protected override void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+
+ // Sem isto o app morre com APPCRASH e nao deixa rastro nenhum: o log
+ // para na ultima acao bem-sucedida e nao ha stack trace em lugar algum.
+ DispatcherUnhandledException += AoFalharNaUi;
+ AppDomain.CurrentDomain.UnhandledException += AoFalharForaDaUi;
+ TaskScheduler.UnobservedTaskException += AoFalharEmTask;
+
+ var colecao = new ServiceCollection();
+ colecao.AddGameBoostCore();
+ colecao.AddSingleton();
+ colecao.AddSingleton();
+ colecao.AddSingleton();
+ colecao.AddSingleton();
+ colecao.AddSingleton();
+ colecao.AddSingleton();
+ colecao.AddSingleton();
+ colecao.AddSingleton();
+ colecao.AddSingleton();
+ colecao.AddSingleton();
+ colecao.AddSingleton();
+ colecao.AddSingleton();
+ colecao.AddSingleton();
+ _services = colecao.BuildServiceProvider();
+
+ _services.GetRequiredService().Info("App", "Startup", null,
+ $"v{typeof(App).Assembly.GetName().Version} admin={CoreServices.RodandoComoAdministrador()}");
+
+ var janela = new ShellWindow { DataContext = _services.GetRequiredService() };
+ MainWindow = janela;
+ janela.Show();
+
+ // A bandeja vem primeiro: o vigia liga o convite nela.
+ LigarBandeja(janela);
+ LigarVigiaDeJogos();
+ MostrarOnboarding(janela);
+ }
+
+ // ==================================================================
+ // Bandeja (secao 6)
+ // ==================================================================
+
+ private Services.BandejaDoSistema? _bandeja;
+
+ private void LigarBandeja(ShellWindow janela)
+ {
+ var settings = _services!.GetRequiredService().Load();
+
+ if (!settings.MostrarNaBandeja)
+ return;
+
+ _bandeja = _services!.GetRequiredService();
+ var shell = _services!.GetRequiredService();
+
+ _bandeja.AoAbrir += () => Dispatcher.Invoke(() =>
+ {
+ janela.Show();
+ janela.WindowState = WindowState.Normal;
+ janela.Activate();
+ });
+
+ _bandeja.AoSair += () => Dispatcher.Invoke(() =>
+ {
+ // Sair pela bandeja sai de verdade. Um programa que so finge
+ // fechar e continua rodando escondido e queixa legitima.
+ _saindoDeVez = true;
+ Shutdown();
+ });
+
+ _bandeja.AoAlternarModoGame += () => Dispatcher.Invoke(shell.AlternarModoGamePelaBandeja);
+ _bandeja.AoLimparRam += () => Dispatcher.Invoke(shell.LimparRamPelaBandeja);
+
+ _bandeja.Mostrar();
+ janela.Bandeja = _bandeja;
+ }
+
+ private bool _saindoDeVez;
+
+ /// True enquanto fechar a janela deve so esconder.
+ public bool EsconderEmVezDeFechar => _bandeja is { Visivel: true } && !_saindoDeVez;
+
+ // ==================================================================
+ // Deteccao automatica de jogo (secao 5.1)
+ // ==================================================================
+
+ private void LigarVigiaDeJogos()
+ {
+ var settings = _services!.GetRequiredService().Load();
+
+ if (!settings.DetectarJogosAutomaticamente)
+ return;
+
+ var vigia = _services!.GetRequiredService();
+ var shell = _services!.GetRequiredService();
+
+ vigia.AoAbrir += evento => Dispatcher.Invoke(() => shell.JogoDetectado(evento));
+ vigia.AoFechar += evento => Dispatcher.Invoke(() => shell.JogoEncerrado(evento));
+
+ // Sem isto o convite morre no lugar onde nasce: o shell dispara o
+ // evento e ninguem escuta. O jogo era detectado, o log registrava a
+ // deteccao, e o usuario nao via nada em lugar nenhum.
+ //
+ // Balao E faixa, nao um ou outro: com o jogo em tela cheia o Windows
+ // engole o balao, e a faixa fica esperando na janela. Sem tela cheia,
+ // o balao chega primeiro.
+ shell.AoPedirAviso += (titulo, texto) => Dispatcher.Invoke(() =>
+ _bandeja?.Avisar(titulo, texto));
+
+ vigia.Iniciar();
+ }
+
+ // ==================================================================
+ // Onboarding (secao 6)
+ // ==================================================================
+
+ private void MostrarOnboarding(ShellWindow janela)
+ {
+ var store = _services!.GetRequiredService();
+ var settings = store.Load();
+
+ if (settings.OnboardingConcluido)
+ return;
+
+ var vm = new OnboardingViewModel();
+ var dialogo = new OnboardingWindow { DataContext = vm, Owner = janela };
+
+ vm.AoTerminar += abrirRelatorio =>
+ {
+ settings.OnboardingConcluido = true;
+ store.Save(settings);
+
+ dialogo.Close();
+
+ if (abrirRelatorio)
+ _services!.GetRequiredService().AbrirRelatorioInicial();
+ };
+
+ dialogo.ShowDialog();
+ }
+
+ protected override void OnExit(ExitEventArgs e)
+ {
+ // Sair sem desfazer o perfil aplicado deixaria prioridade e tweaks de
+ // pe depois que o app sumiu. Isso e o oposto da regra 1.
+ //
+ // O GameWatcher NAO e descartado aqui: ele e singleton IDisposable, e o
+ // proprio container o descarta logo abaixo. Chamar nos dois lugares e o
+ // que fazia a excecao aparecer em toda saida.
+ try
+ {
+ _services?.GetService()?.Desfazer();
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException)
+ {
+ Registrar("Exit", ex);
+ }
+
+ try
+ {
+ _bandeja?.Dispose();
+ (_services as ServiceProvider)?.Dispose();
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException)
+ {
+ // Falhar ao fechar nao pode virar caixa de erro: o app ja esta
+ // saindo e nao ha nada que o usuario possa fazer a respeito.
+ Registrar("Exit", ex);
+ }
+
+ base.OnExit(e);
+ }
+
+ private void AoFalharNaUi(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
+ {
+ Registrar("UI", e.Exception);
+
+ MessageBox.Show(
+ "O GameBoost encontrou um erro inesperado e registrou os detalhes no log."
+ + Environment.NewLine + Environment.NewLine
+ + e.Exception.Message,
+ "GameBoost", MessageBoxButton.OK, MessageBoxImage.Error);
+
+ // A janela continua de pe: um erro numa tela nao precisa derrubar o app.
+ e.Handled = true;
+ }
+
+ private void AoFalharForaDaUi(object sender, UnhandledExceptionEventArgs e)
+ {
+ if (e.ExceptionObject is Exception ex)
+ Registrar("Dominio", ex);
+ }
+
+ private void AoFalharEmTask(object? sender, UnobservedTaskExceptionEventArgs e)
+ {
+ Registrar("Task", e.Exception);
+ e.SetObserved();
+ }
+
+ private void Registrar(string origem, Exception ex)
+ {
+ try
+ {
+ _services?.GetRequiredService()
+ .Error("App", $"Excecao nao tratada ({origem})", ex.GetType().Name,
+ ex.ToString().Replace(Environment.NewLine, " | "), ex);
+ }
+ catch (Exception)
+ {
+ // Falhar ao registrar a falha nao pode virar uma segunda falha.
+ }
+ }
+
+}
diff --git a/GameBoost/src/GameBoost.App/Controls/MiniGrafico.cs b/GameBoost/src/GameBoost.App/Controls/MiniGrafico.cs
new file mode 100644
index 0000000..0d19fe3
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Controls/MiniGrafico.cs
@@ -0,0 +1,126 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+
+namespace GameBoost.App.Controls;
+
+///
+/// Grafico dos ultimos 60 segundos (secao 5.5).
+///
+/// Desenha em OnRender em vez de montar uma Polyline com centenas de pontos:
+/// a serie e trocada inteira a cada segundo, e recriar objetos visuais nesse
+/// ritmo apareceria no orcamento de CPU da propria coleta.
+///
+public sealed class MiniGrafico : Control
+{
+ public static readonly DependencyProperty ValoresProperty = DependencyProperty.Register(
+ nameof(Valores), typeof(IReadOnlyList), typeof(MiniGrafico),
+ new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty MaximoProperty = DependencyProperty.Register(
+ nameof(Maximo), typeof(double), typeof(MiniGrafico),
+ new FrameworkPropertyMetadata(100.0, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty CorDaLinhaProperty = DependencyProperty.Register(
+ nameof(CorDaLinha), typeof(Brush), typeof(MiniGrafico),
+ new FrameworkPropertyMetadata(Brushes.Teal, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty RotuloProperty = DependencyProperty.Register(
+ nameof(Rotulo), typeof(string), typeof(MiniGrafico),
+ new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public IReadOnlyList? Valores
+ {
+ get => (IReadOnlyList?)GetValue(ValoresProperty);
+ set => SetValue(ValoresProperty, value);
+ }
+
+ public double Maximo
+ {
+ get => (double)GetValue(MaximoProperty);
+ set => SetValue(MaximoProperty, value);
+ }
+
+ public Brush CorDaLinha
+ {
+ get => (Brush)GetValue(CorDaLinhaProperty);
+ set => SetValue(CorDaLinhaProperty, value);
+ }
+
+ public string Rotulo
+ {
+ get => (string)GetValue(RotuloProperty);
+ set => SetValue(RotuloProperty, value);
+ }
+
+ protected override void OnRender(DrawingContext dc)
+ {
+ base.OnRender(dc);
+
+ var largura = ActualWidth;
+ var altura = ActualHeight;
+
+ if (largura <= 1 || altura <= 1)
+ return;
+
+ // Linhas de grade em 25, 50 e 75%.
+ var grade = new Pen(new SolidColorBrush(Color.FromArgb(30, 255, 255, 255)), 1);
+ grade.Freeze();
+
+ for (var i = 1; i < 4; i++)
+ {
+ var y = altura * i / 4.0;
+ dc.DrawLine(grade, new Point(0, y), new Point(largura, y));
+ }
+
+ var valores = Valores;
+ if (valores is null || valores.Count < 2)
+ {
+ DesenharRotulo(dc, "aguardando dados", largura, altura);
+ return;
+ }
+
+ var maximo = Maximo <= 0 ? 1 : Maximo;
+ var passo = largura / Math.Max(valores.Count - 1, 1);
+
+ var linha = new Pen(CorDaLinha, 1.6) { LineJoin = PenLineJoin.Round };
+ linha.Freeze();
+
+ var geometria = new StreamGeometry();
+ using (var ctx = geometria.Open())
+ {
+ var primeiro = new Point(0, Y(valores[0], maximo, altura));
+ ctx.BeginFigure(primeiro, isFilled: false, isClosed: false);
+
+ for (var i = 1; i < valores.Count; i++)
+ ctx.LineTo(new Point(i * passo, Y(valores[i], maximo, altura)), isStroked: true, isSmoothJoin: false);
+ }
+
+ geometria.Freeze();
+ dc.DrawGeometry(null, linha, geometria);
+
+ if (!string.IsNullOrEmpty(Rotulo))
+ DesenharRotulo(dc, Rotulo, largura, altura);
+ }
+
+ private static double Y(double valor, double maximo, double altura)
+ {
+ var proporcao = Math.Clamp(valor / maximo, 0, 1);
+ return altura - proporcao * altura;
+ }
+
+ private void DesenharRotulo(DrawingContext dc, string texto, double largura, double altura)
+ {
+ var formatado = new FormattedText(
+ texto,
+ CultureInfo.CurrentUICulture,
+ FlowDirection.LeftToRight,
+ new Typeface("Segoe UI"),
+ 10,
+ new SolidColorBrush(Color.FromArgb(140, 255, 255, 255)),
+ VisualTreeHelper.GetDpi(this).PixelsPerDip);
+
+ dc.DrawText(formatado, new Point(largura - formatado.Width - 2, altura - formatado.Height - 1));
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Controls/Treemap.cs b/GameBoost/src/GameBoost.App/Controls/Treemap.cs
new file mode 100644
index 0000000..b237b7c
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Controls/Treemap.cs
@@ -0,0 +1,293 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using GameBoost.Core.Modules.DiskAnalyzer;
+
+namespace GameBoost.App.Controls;
+
+/// Um retângulo desenhado, guardado para achar o nó sob o mouse.
+internal sealed record Bloco(DiskNode No, Rect Area);
+
+///
+/// Treemap da seção 5.4: retângulos proporcionais ao tamanho, coloridos por
+/// categoria.
+///
+/// Usa o algoritmo "squarified": em vez de fatiar sempre na mesma direção, ele
+/// escolhe a cada passo a direção que deixa os retângulos mais próximos de
+/// quadrados. Fatias compridas e finas são impossíveis de clicar e não deixam
+/// comparar áreas a olho, que é justamente para o que o treemap serve.
+///
+/// Desenha em OnRender: um disco cheio tem milhares de blocos por nível, e
+/// criar objetos visuais para cada um travaria a navegação.
+///
+public sealed class Treemap : Control
+{
+ private readonly List _blocos = new();
+ private DiskNode? _sobMouse;
+
+ public static readonly DependencyProperty RaizProperty = DependencyProperty.Register(
+ nameof(Raiz), typeof(DiskNode), typeof(Treemap),
+ new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty MaximoDeBlocosProperty = DependencyProperty.Register(
+ nameof(MaximoDeBlocos), typeof(int), typeof(Treemap),
+ new FrameworkPropertyMetadata(120, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public DiskNode? Raiz
+ {
+ get => (DiskNode?)GetValue(RaizProperty);
+ set => SetValue(RaizProperty, value);
+ }
+
+ /// Acima disso os retângulos ficam menores que o clique.
+ public int MaximoDeBlocos
+ {
+ get => (int)GetValue(MaximoDeBlocosProperty);
+ set => SetValue(MaximoDeBlocosProperty, value);
+ }
+
+ /// Disparado ao clicar num bloco que é pasta: o shell desce um nível.
+ public event Action? AoEntrarNaPasta;
+
+ public event Action? AoPassarOMouse;
+
+ public Treemap()
+ {
+ ClipToBounds = true;
+ Cursor = Cursors.Hand;
+ }
+
+ protected override void OnMouseMove(MouseEventArgs e)
+ {
+ base.OnMouseMove(e);
+
+ var no = Encontrar(e.GetPosition(this));
+ if (ReferenceEquals(no, _sobMouse))
+ return;
+
+ _sobMouse = no;
+ AoPassarOMouse?.Invoke(no);
+ InvalidateVisual();
+ }
+
+ protected override void OnMouseLeave(MouseEventArgs e)
+ {
+ base.OnMouseLeave(e);
+
+ _sobMouse = null;
+ AoPassarOMouse?.Invoke(null);
+ InvalidateVisual();
+ }
+
+ protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e)
+ {
+ base.OnMouseLeftButtonUp(e);
+
+ var no = Encontrar(e.GetPosition(this));
+
+ // Só pasta desce um nível: clicar num arquivo não leva a lugar nenhum.
+ if (no is { EhPasta: true, Filhos.Count: > 0 })
+ AoEntrarNaPasta?.Invoke(no);
+ }
+
+ private DiskNode? Encontrar(Point ponto)
+ {
+ for (var i = _blocos.Count - 1; i >= 0; i--)
+ {
+ if (_blocos[i].Area.Contains(ponto))
+ return _blocos[i].No;
+ }
+
+ return null;
+ }
+
+ protected override void OnRender(DrawingContext dc)
+ {
+ base.OnRender(dc);
+
+ _blocos.Clear();
+
+ var largura = ActualWidth;
+ var altura = ActualHeight;
+
+ if (largura <= 4 || altura <= 4)
+ return;
+
+ var fundo = new SolidColorBrush(Color.FromRgb(0x1C, 0x1F, 0x26));
+ fundo.Freeze();
+ dc.DrawRectangle(fundo, null, new Rect(0, 0, largura, altura));
+
+ var raiz = Raiz;
+ if (raiz is null || raiz.Tamanho <= 0)
+ {
+ Texto(dc, "Varra um disco para ver onde está o espaço", largura / 2, altura / 2, centralizado: true);
+ return;
+ }
+
+ var filhos = raiz.Filhos
+ .Where(f => f.Tamanho > 0)
+ .OrderByDescending(f => f.Tamanho)
+ .Take(MaximoDeBlocos)
+ .ToList();
+
+ if (filhos.Count == 0)
+ {
+ Texto(dc, "Pasta vazia", largura / 2, altura / 2, centralizado: true);
+ return;
+ }
+
+ Espalhar(dc, filhos, new Rect(1, 1, largura - 2, altura - 2));
+ }
+
+ ///
+ /// Coração do squarified: acumula itens numa faixa enquanto isso melhora a
+ /// proporção média dos retângulos, e fecha a faixa quando começa a piorar.
+ ///
+ private void Espalhar(DrawingContext dc, List itens, Rect area)
+ {
+ var total = itens.Sum(i => (double)i.Tamanho);
+ if (total <= 0)
+ return;
+
+ var restantes = new List(itens);
+
+ while (restantes.Count > 0 && area is { Width: > 1, Height: > 1 })
+ {
+ var somaRestante = restantes.Sum(i => (double)i.Tamanho);
+ var horizontal = area.Width >= area.Height;
+ var ladoFixo = horizontal ? area.Height : area.Width;
+
+ var faixa = new List();
+ double somaFaixa = 0;
+ var melhorProporcao = double.MaxValue;
+
+ foreach (var item in restantes)
+ {
+ var candidata = somaFaixa + item.Tamanho;
+ var proporcao = PiorProporcao(faixa, item, candidata, somaRestante, area, ladoFixo);
+
+ if (faixa.Count > 0 && proporcao > melhorProporcao)
+ break;
+
+ faixa.Add(item);
+ somaFaixa = candidata;
+ melhorProporcao = proporcao;
+ }
+
+ var fracao = somaFaixa / somaRestante;
+ var espessura = (horizontal ? area.Width : area.Height) * fracao;
+
+ var posicao = horizontal ? area.Top : area.Left;
+
+ foreach (var item in faixa)
+ {
+ var parte = somaFaixa <= 0 ? 0 : item.Tamanho / somaFaixa * ladoFixo;
+
+ var retangulo = horizontal
+ ? new Rect(area.Left, posicao, espessura, parte)
+ : new Rect(posicao, area.Top, parte, espessura);
+
+ Desenhar(dc, item, retangulo);
+ posicao += parte;
+ }
+
+ area = horizontal
+ ? new Rect(area.Left + espessura, area.Top, Math.Max(0, area.Width - espessura), area.Height)
+ : new Rect(area.Left, area.Top + espessura, area.Width, Math.Max(0, area.Height - espessura));
+
+ foreach (var item in faixa)
+ restantes.Remove(item);
+ }
+ }
+
+ private static double PiorProporcao(
+ List faixa, DiskNode candidato, double somaComCandidato,
+ double somaRestante, Rect area, double ladoFixo)
+ {
+ if (somaComCandidato <= 0 || somaRestante <= 0)
+ return double.MaxValue;
+
+ var comprimento = (area.Width >= area.Height ? area.Width : area.Height)
+ * (somaComCandidato / somaRestante);
+
+ if (comprimento <= 0)
+ return double.MaxValue;
+
+ var pior = 0.0;
+
+ foreach (var item in faixa.Append(candidato))
+ {
+ var lado = item.Tamanho / somaComCandidato * ladoFixo;
+ if (lado <= 0)
+ continue;
+
+ pior = Math.Max(pior, Math.Max(comprimento / lado, lado / comprimento));
+ }
+
+ return pior == 0 ? double.MaxValue : pior;
+ }
+
+ private void Desenhar(DrawingContext dc, DiskNode no, Rect area)
+ {
+ if (area.Width < 1 || area.Height < 1)
+ return;
+
+ _blocos.Add(new Bloco(no, area));
+
+ var cor = (Color)ColorConverter.ConvertFromString(DiskCategories.Cor(no.Categoria));
+ var destacado = ReferenceEquals(no, _sobMouse);
+
+ var preenche = new SolidColorBrush(cor) { Opacity = destacado ? 1.0 : 0.78 };
+ preenche.Freeze();
+
+ var borda = new Pen(new SolidColorBrush(Color.FromArgb(destacado ? (byte)255 : (byte)60, 20, 22, 26)),
+ destacado ? 2 : 1);
+ borda.Freeze();
+
+ // Deflate evita que bordas vizinhas se sobreponham e engrossem.
+ var desenho = Rect.Inflate(area, -0.5, -0.5);
+ if (desenho.Width <= 0 || desenho.Height <= 0)
+ desenho = area;
+
+ dc.DrawRectangle(preenche, borda, desenho);
+
+ // Rótulo só cabe em bloco grande; abaixo disso vira borrão.
+ if (area.Width > 64 && area.Height > 26)
+ {
+ Texto(dc, no.Nome, area.Left + 5, area.Top + 3, centralizado: false, maximo: area.Width - 10);
+
+ if (area.Height > 42)
+ {
+ Texto(dc, Core.Modules.GameMode.GameModeModule.Formatar(no.Tamanho),
+ area.Left + 5, area.Top + 19, centralizado: false, maximo: area.Width - 10, opaco: true);
+ }
+ }
+ }
+
+ private void Texto(
+ DrawingContext dc, string texto, double x, double y,
+ bool centralizado, double maximo = double.MaxValue, bool opaco = false)
+ {
+ var formatado = new FormattedText(
+ texto,
+ CultureInfo.CurrentUICulture,
+ FlowDirection.LeftToRight,
+ new Typeface("Segoe UI"),
+ 11.5,
+ new SolidColorBrush(Color.FromArgb(opaco ? (byte)190 : (byte)255, 13, 17, 23)),
+ VisualTreeHelper.GetDpi(this).PixelsPerDip)
+ {
+ MaxLineCount = 1,
+ Trimming = TextTrimming.CharacterEllipsis
+ };
+
+ if (maximo < double.MaxValue)
+ formatado.MaxTextWidth = Math.Max(10, maximo);
+
+ dc.DrawText(formatado, centralizado
+ ? new Point(x - formatado.Width / 2, y - formatado.Height / 2)
+ : new Point(x, y));
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Converters/Converters.cs b/GameBoost/src/GameBoost.App/Converters/Converters.cs
new file mode 100644
index 0000000..8b541b4
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Converters/Converters.cs
@@ -0,0 +1,107 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+using System.Windows.Media;
+using GameBoost.Core.Modules;
+
+namespace GameBoost.App.Converters;
+
+/// Badge de risco: verde, amarelo, vermelho (secao 6 do spec).
+public sealed class RiscoParaCorConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var chave = (value as RiskLevel?) switch
+ {
+ RiskLevel.Alto => "RiscoAlto",
+ RiskLevel.Medio => "RiscoMedio",
+ _ => "RiscoBaixo"
+ };
+
+ return Application.Current?.TryFindResource(chave) as Brush ?? Brushes.Gray;
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
+
+public sealed class BoolParaVisibilidadeConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var visivel = value is true;
+ if (parameter as string == "inverter")
+ visivel = !visivel;
+
+ return visivel ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
+
+public sealed class InverterBoolConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is not true;
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is not true;
+}
+
+/// Mostra o elemento apenas quando o texto existe e nao esta vazio.
+public sealed class TextoParaVisibilidadeConverter : IValueConverter
+{
+ ///
+ /// Com ConverterParameter="inverter", aparece quando o texto esta VAZIO.
+ /// E o que faz o texto-fantasma de uma caixa de busca sumir assim que a
+ /// pessoa comeca a digitar.
+ ///
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var vazio = string.IsNullOrWhiteSpace(value as string);
+
+ if (string.Equals(parameter as string, "inverter", StringComparison.OrdinalIgnoreCase))
+ return vazio ? Visibility.Visible : Visibility.Collapsed;
+
+ return vazio ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
+
+///
+/// True vira negrito. Serve para destacar a linha "em uso agora" na tabela de
+/// DNS sem precisar de uma cor a mais na paleta.
+///
+public sealed class BoolParaPesoConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is true ? FontWeights.Bold : FontWeights.Normal;
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
+
+///
+/// Semaforo do NAT e da latencia: verde, amarelo, vermelho, cinza.
+///
+/// Usa as MESMAS cores do badge de risco, de proposito. Um verde aqui e um
+/// verde la precisam ser o mesmo verde, senao o usuario aprende duas escalas
+/// de cor no mesmo aplicativo.
+///
+public sealed class SemaforoParaCorConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => (value as string) switch
+ {
+ "verde" => new SolidColorBrush(Color.FromRgb(0x3F, 0xB9, 0x50)),
+ "amarelo" => new SolidColorBrush(Color.FromRgb(0xE3, 0xB3, 0x41)),
+ "vermelho" => new SolidColorBrush(Color.FromRgb(0xE0, 0x5B, 0x4F)),
+ _ => new SolidColorBrush(Color.FromRgb(0x7A, 0x7A, 0x7A))
+ };
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/GameBoost/src/GameBoost.App/GameBoost.App.csproj b/GameBoost/src/GameBoost.App/GameBoost.App.csproj
new file mode 100644
index 0000000..8e9fd6f
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/GameBoost.App.csproj
@@ -0,0 +1,73 @@
+
+
+ WinExe
+ net9.0-windows
+ true
+
+ true
+
+ $(NoWarn);WFO0003
+ GameBoost.App
+ GameBoost
+
+ true
+ true
+ win-x64
+ true
+ true
+ pt-BR
+
+ GameBoost.App.Program
+
+
+
+
+ false
+ app.manifest
+ app-dev.manifest
+ GameBoost-dev
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Program.cs b/GameBoost/src/GameBoost.App/Program.cs
new file mode 100644
index 0000000..9fa68fb
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Program.cs
@@ -0,0 +1,118 @@
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using GameBoost.Cli;
+using GameBoost.Core;
+using GameBoost.Core.Logging;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace GameBoost.App;
+
+///
+/// Ponto de entrada do GameBoost.exe. Decide antes de qualquer coisa se a
+/// execucao e de linha de comando ou grafica.
+///
+/// O caminho CLI **nao** inicializa o WPF. A primeira tentativa chamava
+/// Application.Shutdown() dentro de OnStartup e o processo ficava pendurado:
+/// um app WPF que nunca abriu janela mantem o loop de mensagens vivo, e
+/// Environment.Exit no meio da inicializacao arrisca travar. Separar o Main
+/// resolve na origem: em `--scan` nem existe Application.
+///
+internal static class Program
+{
+ [STAThread]
+ public static int Main(string[] args)
+ {
+ // Antes de qualquer outra coisa, inclusive de ler argumentos.
+ //
+ // O Velopack usa o proprio executavel para instalar, atualizar e
+ // desinstalar: ele roda o exe com argumentos proprios e espera que esta
+ // chamada os intercepte e encerre o processo. Se ela vier depois de
+ // qualquer inicializacao, a instalacao abre a janela do app no meio do
+ // processo de update — ou trava.
+ //
+ // Em build de desenvolvimento, sem pacote Velopack por perto, isto e
+ // uma chamada que nao faz nada.
+ Velopack.VelopackApp.Build().Run();
+
+ var opcoes = CommandLineOptions.Parse(args);
+
+ if (!opcoes.EhCli)
+ {
+ App.Main();
+ return 0;
+ }
+
+ return ExecutarCli(opcoes);
+ }
+
+ private static int ExecutarCli(CommandLineOptions opcoes)
+ {
+ // Sendo WinExe, o processo nao tem console proprio.
+ //
+ // Quando a saida ja vem redirecionada (`--scan > arquivo.txt`, um pipe,
+ // ou um script), o handle padrao ja e valido e basta usa-lo. So quando
+ // nao ha redirecionamento e preciso anexar ao console de quem chamou,
+ // para que o comando imprima no terminal como na v1.
+ //
+ // A primeira versao so olhava o AttachConsole e descartava a saida
+ // inteira quando ele falhava, entao redirecionar para arquivo produzia
+ // um arquivo vazio.
+ var redirecionado = Console.IsOutputRedirected;
+ var anexou = !redirecionado && AttachConsole(AttachParentProcess);
+ var temSaida = redirecionado || anexou;
+
+ ServiceProvider? provider = null;
+
+ try
+ {
+ var colecao = new ServiceCollection();
+ colecao.AddGameBoostCore();
+ provider = colecao.BuildServiceProvider();
+
+ provider.GetRequiredService()
+ .Info("Cli", opcoes.Comando.ToString(), null,
+ $"admin={CoreServices.RodandoComoAdministrador()} dryRun={opcoes.DryRun}");
+
+ using var saida = temSaida
+ ? new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) { AutoFlush = true }
+ : TextWriter.Null;
+
+ return new CommandRunner(provider, saida).ExecutarAsync(opcoes).GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ try
+ {
+ provider?.GetRequiredService()
+ .Error("Cli", opcoes.Comando.ToString(), null, "falha nao tratada", ex);
+ }
+ catch (InvalidOperationException)
+ {
+ // Container nao chegou a subir: nao ha onde registrar.
+ }
+
+ if (temSaida)
+ Console.Error.WriteLine($"Erro: {ex.Message}");
+
+ return 1;
+ }
+ finally
+ {
+ provider?.Dispose();
+
+ if (anexou)
+ FreeConsole();
+ }
+ }
+
+ private const int AttachParentProcess = -1;
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool AttachConsole(int processId);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool FreeConsole();
+}
diff --git a/GameBoost/src/GameBoost.App/Services/BandejaDoSistema.cs b/GameBoost/src/GameBoost.App/Services/BandejaDoSistema.cs
new file mode 100644
index 0000000..ac6d05e
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Services/BandejaDoSistema.cs
@@ -0,0 +1,167 @@
+using System.Drawing;
+using System.Windows.Forms;
+using GameBoost.Core.Logging;
+
+namespace GameBoost.App.Services;
+
+///
+/// Ícone na bandeja com o menu da seção 6: Ativar Modo Game, Limpar RAM, Abrir,
+/// Sair.
+///
+/// Duas decisões que valem ser ditas:
+///
+/// - **Fechar a janela não fecha o app quando a bandeja está ligada**, mas o
+/// menu tem "Sair" e ele sai de verdade. Programa que só finge fechar e fica
+/// rodando escondido é queixa legítima; o caminho para sair existe e está
+/// visível.
+/// - **O ícone é desenhado em código**, não carregado de arquivo. Num publish
+/// single-file, um `.ico` solto ao lado do exe não existe, e embutir recurso
+/// para dezesseis pixels não vale.
+///
+public sealed class BandejaDoSistema : IDisposable
+{
+ private readonly IGameBoostLogger _log;
+ private NotifyIcon? _icone;
+ private ToolStripMenuItem? _itemModoGame;
+
+ public BandejaDoSistema(IGameBoostLogger log)
+ {
+ _log = log;
+ }
+
+ public event Action? AoAbrir;
+ public event Action? AoAlternarModoGame;
+ public event Action? AoLimparRam;
+ public event Action? AoSair;
+
+ public bool Visivel => _icone is { Visible: true };
+
+ public void Mostrar()
+ {
+ if (_icone is not null)
+ {
+ _icone.Visible = true;
+ return;
+ }
+
+ try
+ {
+ var menu = new ContextMenuStrip();
+
+ _itemModoGame = new ToolStripMenuItem("Ativar Modo Game");
+ _itemModoGame.Click += (_, _) => AoAlternarModoGame?.Invoke();
+ menu.Items.Add(_itemModoGame);
+
+ var limpar = new ToolStripMenuItem("Limpar RAM");
+ limpar.Click += (_, _) => AoLimparRam?.Invoke();
+ menu.Items.Add(limpar);
+
+ menu.Items.Add(new ToolStripSeparator());
+
+ var abrir = new ToolStripMenuItem("Abrir GameBoost");
+ abrir.Click += (_, _) => AoAbrir?.Invoke();
+ menu.Items.Add(abrir);
+
+ var sair = new ToolStripMenuItem("Sair");
+ sair.Click += (_, _) => AoSair?.Invoke();
+ menu.Items.Add(sair);
+
+ _icone = new NotifyIcon
+ {
+ Icon = Desenhar(false),
+ Text = "GameBoost",
+ Visible = true,
+ ContextMenuStrip = menu
+ };
+
+ _icone.DoubleClick += (_, _) => AoAbrir?.Invoke();
+
+ _log.Info("bandeja", "Mostrar", null, "ícone na bandeja");
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or ArgumentException)
+ {
+ _log.Error("bandeja", "Mostrar", null, ex.Message, ex);
+ }
+ }
+
+ public void Esconder()
+ {
+ if (_icone is not null)
+ _icone.Visible = false;
+ }
+
+ /// Muda o texto do menu e a cor do ícone conforme o Modo Game.
+ public void AtualizarModoGame(bool ativo)
+ {
+ if (_itemModoGame is not null)
+ _itemModoGame.Text = ativo ? "Desligar Modo Game" : "Ativar Modo Game";
+
+ if (_icone is null)
+ return;
+
+ var antigo = _icone.Icon;
+ _icone.Icon = Desenhar(ativo);
+ _icone.Text = ativo ? "GameBoost — Modo Game ativo" : "GameBoost";
+
+ // O handle do ícone antigo não sai sozinho: trocar o ícone a cada
+ // sessão vazaria um handle por vez.
+ antigo?.Dispose();
+ }
+
+ /// Balão da bandeja. Usado para o convite de Modo Game.
+ public void Avisar(string titulo, string texto)
+ {
+ if (_icone is null)
+ return;
+
+ try
+ {
+ _icone.BalloonTipTitle = titulo;
+ _icone.BalloonTipText = texto;
+ _icone.BalloonTipIcon = ToolTipIcon.Info;
+ _icone.ShowBalloonTip(8000);
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or ArgumentException)
+ {
+ _log.Warn("bandeja", "Avisar", titulo, ex.Message);
+ }
+ }
+
+ ///
+ /// Desenha um ícone de 16×16: um quadrado arredondado com a inicial. Verde
+ /// quando o Modo Game está ativo, cinza quando não.
+ ///
+ private static Icon Desenhar(bool ativo)
+ {
+ using var bitmap = new Bitmap(16, 16);
+ using var g = Graphics.FromImage(bitmap);
+
+ g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
+ g.Clear(Color.Transparent);
+
+ var cor = ativo
+ ? Color.FromArgb(0x2E, 0xC4, 0x9A)
+ : Color.FromArgb(0x8A, 0x90, 0x99);
+
+ using var pincel = new SolidBrush(cor);
+ g.FillEllipse(pincel, 1, 1, 14, 14);
+
+ using var fonte = new Font("Segoe UI", 8, FontStyle.Bold, GraphicsUnit.Pixel);
+ using var texto = new SolidBrush(Color.FromArgb(0x10, 0x12, 0x16));
+
+ g.DrawString("G", fonte, texto, new PointF(4, 3));
+
+ return Icon.FromHandle(bitmap.GetHicon());
+ }
+
+ public void Dispose()
+ {
+ if (_icone is null)
+ return;
+
+ _icone.Visible = false;
+ _icone.Icon?.Dispose();
+ _icone.Dispose();
+ _icone = null;
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Themes/Controles.xaml b/GameBoost/src/GameBoost.App/Themes/Controles.xaml
new file mode 100644
index 0000000..ad91ee0
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Themes/Controles.xaml
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Themes/Conversores.xaml b/GameBoost/src/GameBoost.App/Themes/Conversores.xaml
new file mode 100644
index 0000000..df21c23
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Themes/Conversores.xaml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Themes/Dark.xaml b/GameBoost/src/GameBoost.App/Themes/Dark.xaml
new file mode 100644
index 0000000..13c9af2
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Themes/Dark.xaml
@@ -0,0 +1,80 @@
+
+
+
+ #FF14161A
+ #FF1C1F26
+ #FF242830
+ #FF00C2A8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/ViewModels/ActionItemViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/ActionItemViewModel.cs
new file mode 100644
index 0000000..6a9ea5d
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/ActionItemViewModel.cs
@@ -0,0 +1,60 @@
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GameBoost.Core.Modules;
+
+namespace GameBoost.App.ViewModels;
+
+///
+/// Envolve um ActionItem com o estado de selecao da UI. A tela de lista e
+/// generica: todo modulo produz ActionItem e reusa a mesma view.
+///
+public sealed partial class ActionItemViewModel : ObservableObject
+{
+ public ActionItemViewModel(ActionItem item)
+ {
+ Item = item;
+ _selecionado = item.PreMarcado && !item.Bloqueado;
+ }
+
+ public ActionItem Item { get; }
+
+ [ObservableProperty]
+ private bool _selecionado;
+
+ public string Id => Item.Id;
+ public string Titulo => Item.Titulo;
+ public string Descricao => Item.Descricao;
+ public string Categoria => Item.Categoria;
+ public RiskLevel Risco => Item.Risco;
+ public string GanhoEstimado => Item.GanhoEstimado;
+ public bool Bloqueado => Item.Bloqueado;
+ public bool PodeSelecionar => !Item.Bloqueado;
+ public string? MotivoBloqueio => Item.MotivoBloqueio;
+ public string RotuloBloqueio => Item.RotuloBloqueio;
+
+ public string TextoDeRisco => Item.Risco switch
+ {
+ RiskLevel.Alto => "Risco alto",
+ RiskLevel.Medio => "Risco medio",
+ _ => "Risco baixo"
+ };
+
+ /// Conteudo do botao "?": o que faz, qual o risco, como desfazer (secao 6).
+ public string Explicacao =>
+ $"O que faz: {Item.Descricao}\n\n" +
+ $"Risco: {TextoDeRisco}\n\n" +
+ $"Como desfazer: {Item.ComoDesfazer}" +
+ (Item.MotivoBloqueio is null ? string.Empty : $"\n\nProtegido: {Item.MotivoBloqueio}");
+
+ [RelayCommand]
+ private void Explicar()
+ => MessageBox.Show(Explicacao, Titulo, MessageBoxButton.OK, MessageBoxImage.Information);
+
+ /// Selecao so muda quando o item nao esta bloqueado. Protege contra bind teimoso.
+ partial void OnSelecionadoChanged(bool value)
+ {
+ if (value && Item.Bloqueado)
+ Selecionado = false;
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/AppsPageViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/AppsPageViewModel.cs
new file mode 100644
index 0000000..8010826
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/AppsPageViewModel.cs
@@ -0,0 +1,364 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GameBoost.Core.Modules;
+using GameBoost.Core.Modules.GameMode;
+using GameBoost.Core.Modules.Uninstaller;
+using GameBoost.Core.State;
+
+namespace GameBoost.App.ViewModels;
+
+/// Uma pasta de resto, na aba própria.
+public sealed partial class RestoViewModel : ObservableObject
+{
+ public RestoViewModel(Resto resto)
+ {
+ Item = resto;
+ }
+
+ public Resto Item { get; }
+
+ /// Nunca pré-marcado: o casamento de nomes é heurística.
+ [ObservableProperty] private bool _selecionado;
+
+ public string Titulo => Item.Nome;
+ public string Descricao => $"{Item.Local} · {GameModeModule.Formatar(Item.Bytes)} · {Item.Idade}";
+ public string Caminho => Item.Caminho;
+}
+
+///
+/// Página de aplicativos (seção 5.3), em três abas.
+///
+/// - **Instalados**: a lista principal, herdada do padrão de módulo.
+/// - **Atualizações**: o que o winget tem de versão nova.
+/// - **Restos de apps antigos**: pastas sem app correspondente.
+///
+/// As três compartilham o mesmo rodapé conceitual, mas cada uma age sobre a sua
+/// lista: aplicar na aba de atualizações não desinstala nada, e vice-versa.
+///
+public sealed partial class AppsPageViewModel : ModulePageViewModel
+{
+ private readonly UninstallerModule _modulo;
+
+ public AppsPageViewModel(UninstallerModule modulo, IStateBackup backup)
+ : base(modulo, backup)
+ {
+ _modulo = modulo;
+ }
+
+ public override string Nome => "Apps";
+ public override string Titulo => "Aplicativos instalados";
+ public override string Subtitulo => "Desinstala em lote, inclusive apps da Store, e mostra o que ficou para trás.";
+ public override string Icone => "\uE71D";
+
+ // ==================================================================
+ // Aba: Atualizações
+ // ==================================================================
+
+ public ObservableCollection Atualizacoes { get; } = new();
+
+ [ObservableProperty] private string _resumoDeAtualizacoes = "Clique em Verificar para consultar o winget.";
+ [ObservableProperty] private string _statusDeAtualizacoes = string.Empty;
+ [ObservableProperty] private bool _wingetAusente;
+ [ObservableProperty] private bool _ocupadoComAtualizacoes;
+
+ public int AtualizacoesSelecionadas => Atualizacoes.Count(a => a.Selecionado);
+
+ public string TextoAtualizar => AtualizacoesSelecionadas == 1
+ ? "Atualizar 1 selecionado"
+ : $"Atualizar {AtualizacoesSelecionadas} selecionados";
+
+ [RelayCommand]
+ public async Task VerificarAtualizacoesAsync()
+ {
+ if (OcupadoComAtualizacoes)
+ return;
+
+ OcupadoComAtualizacoes = true;
+ Atualizacoes.Clear();
+ ResumoDeAtualizacoes = "Consultando o winget...";
+
+ try
+ {
+ var progresso = new Progress(p =>
+ {
+ StatusDeAtualizacoes = p.Etapa;
+ Progresso = p.Percentual;
+ });
+
+ var resultado = await _modulo.VarrerAtualizacoesAsync(progresso, CancellationToken.None);
+
+ WingetAusente = !_modulo.Winget.Disponivel;
+
+ foreach (var item in resultado.Itens)
+ {
+ var vm = new ActionItemViewModel(item);
+ vm.PropertyChanged += AoMudarSelecaoDeAtualizacao;
+ Atualizacoes.Add(vm);
+ }
+
+ ResumoDeAtualizacoes = resultado.Resumo;
+ StatusDeAtualizacoes = resultado.Avisos.Count > 0
+ ? string.Join(" ", resultado.Avisos)
+ : "Nada vem marcado. Marque o que quiser atualizar.";
+ }
+ catch (OperationCanceledException)
+ {
+ StatusDeAtualizacoes = "Consulta cancelada.";
+ }
+ finally
+ {
+ OcupadoComAtualizacoes = false;
+ Progresso = 100;
+ NotificarAtualizacoes();
+ }
+ }
+
+ [RelayCommand]
+ public async Task AtualizarSelecionadosAsync()
+ {
+ if (OcupadoComAtualizacoes || !EhAdministrador)
+ return;
+
+ var selecionados = Atualizacoes.Where(a => a.Selecionado && a.PodeSelecionar).ToList();
+
+ if (selecionados.Count == 0)
+ return;
+
+ if (!DryRun && !ConfirmarAtualizacao(selecionados))
+ return;
+
+ OcupadoComAtualizacoes = true;
+
+ try
+ {
+ var progresso = new Progress(p =>
+ {
+ StatusDeAtualizacoes = p.Etapa;
+ Progresso = p.Percentual;
+ });
+
+ var resultado = await _modulo.AtualizarAsync(
+ selecionados.Select(s => s.Item.Id).ToList(), DryRun, progresso, CancellationToken.None);
+
+ ResumoDeAtualizacoes = resultado.Resumo;
+
+ var falhas = resultado.Acoes.Where(a => !a.Sucesso).ToList();
+
+ StatusDeAtualizacoes = falhas.Count == 0
+ ? "Tudo certo. Verifique de novo para confirmar."
+ : string.Join(" ", falhas.Select(f => f.Detalhe));
+
+ if (!DryRun)
+ await VerificarAtualizacoesAsync();
+ }
+ catch (OperationCanceledException)
+ {
+ StatusDeAtualizacoes = "Atualização cancelada.";
+ }
+ finally
+ {
+ OcupadoComAtualizacoes = false;
+ }
+ }
+
+ /// Abre a página do App Installer na Microsoft Store.
+ [RelayCommand]
+ private void InstalarWinget()
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo(WingetService.LinkDaStore) { UseShellExecute = true });
+ }
+ catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
+ {
+ StatusDeAtualizacoes = $"Não foi possível abrir a Store: {ex.Message}";
+ }
+ }
+
+ private bool ConfirmarAtualizacao(IReadOnlyList selecionados)
+ {
+ var nomes = string.Join("\n ", selecionados.Take(12).Select(s => s.Titulo));
+ var extra = selecionados.Count > 12 ? $"\n ... e mais {selecionados.Count - 12}" : string.Empty;
+
+ var arriscados = selecionados.Count(s => s.Item.Risco != RiskLevel.Baixo);
+
+ var texto = $"Atualizar {selecionados.Count} aplicativo(s) pelo winget?\n\n {nomes}{extra}\n\n";
+
+ if (arriscados > 0)
+ {
+ texto += $"{arriscados} deles são runtime, se atualizam sozinhos ou têm versão "
+ + "instalada desconhecida. O motivo aparece em cada linha.\n\n";
+ }
+
+ texto += "Cada app é atualizado por vez, pelo instalador do próprio fabricante. "
+ + "Não há como voltar a versão pelo winget: se a versão nova der problema, "
+ + "a reinstalação da antiga é pelo site do fabricante.\n\n"
+ + "Feche os aplicativos que estiverem abertos antes de continuar.\n\nContinuar?";
+
+ return System.Windows.MessageBox.Show(
+ texto, "Atualizar aplicativos",
+ System.Windows.MessageBoxButton.OKCancel,
+ System.Windows.MessageBoxImage.Warning) == System.Windows.MessageBoxResult.OK;
+ }
+
+ private void AoMudarSelecaoDeAtualizacao(object? remetente, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(ActionItemViewModel.Selecionado))
+ NotificarAtualizacoes();
+ }
+
+ private void NotificarAtualizacoes()
+ {
+ OnPropertyChanged(nameof(AtualizacoesSelecionadas));
+ OnPropertyChanged(nameof(TextoAtualizar));
+ }
+
+ // ==================================================================
+ // Aba: Restos de apps antigos
+ // ==================================================================
+
+ public ObservableCollection Restos { get; } = new();
+
+ [ObservableProperty] private string _resumoDeRestos =
+ "Clique em Varrer restos para procurar pastas de programas que você já desinstalou.";
+
+ [ObservableProperty] private string _statusDeRestos = string.Empty;
+ [ObservableProperty] private bool _ocupadoComRestos;
+
+ public int RestosSelecionados => Restos.Count(r => r.Selecionado);
+
+ public string TextoRemoverRestos => RestosSelecionados == 1
+ ? "Mandar 1 para a Lixeira"
+ : $"Mandar {RestosSelecionados} para a Lixeira";
+
+ [RelayCommand]
+ public async Task VarrerRestosAsync()
+ {
+ if (OcupadoComRestos)
+ return;
+
+ OcupadoComRestos = true;
+ Restos.Clear();
+ ResumoDeRestos = "Procurando...";
+
+ try
+ {
+ // A varredura de restos precisa da lista de instalados para saber o
+ // que é resto. Sem ela, tudo viraria resto.
+ if (_modulo.UltimaLista.Count == 0)
+ {
+ StatusDeRestos = "Varrendo os apps instalados primeiro...";
+ await _modulo.ScanAsync(null, CancellationToken.None);
+ }
+
+ var progresso = new Progress(texto => StatusDeRestos = texto);
+
+ var achados = await Task.Run(
+ () => _modulo.VarrerRestos(progresso, CancellationToken.None));
+
+ foreach (var resto in achados)
+ {
+ var vm = new RestoViewModel(resto);
+ vm.PropertyChanged += AoMudarSelecaoDeResto;
+ Restos.Add(vm);
+ }
+
+ var total = achados.Sum(r => r.Bytes);
+
+ ResumoDeRestos = achados.Count == 0
+ ? "Nenhuma pasta sem app correspondente."
+ : $"{achados.Count} pastas sem app correspondente, {GameModeModule.Formatar(total)} no total.";
+
+ StatusDeRestos = "O casamento de nomes é uma heurística: uma pasta pode pertencer "
+ + "a um app que o registro não declara. Por isso nada vem marcado e "
+ + "a remoção vai para a Lixeira.";
+ }
+ catch (OperationCanceledException)
+ {
+ StatusDeRestos = "Varredura cancelada.";
+ }
+ finally
+ {
+ OcupadoComRestos = false;
+ NotificarRestos();
+ }
+ }
+
+ [RelayCommand]
+ public void RemoverRestos()
+ {
+ var escolhidos = Restos.Where(r => r.Selecionado).ToList();
+
+ if (escolhidos.Count == 0 || !EhAdministrador)
+ return;
+
+ var nomes = string.Join("\n ", escolhidos.Take(12).Select(r => $"{r.Titulo} — {r.Caminho}"));
+ var extra = escolhidos.Count > 12 ? $"\n ... e mais {escolhidos.Count - 12}" : string.Empty;
+ var total = escolhidos.Sum(r => r.Item.Bytes);
+
+ var texto = $"Mandar {escolhidos.Count} pasta(s) para a Lixeira, "
+ + $"liberando {GameModeModule.Formatar(total)}?\n\n {nomes}{extra}\n\n"
+ + "Elas vão para a Lixeira, não são apagadas de vez: se alguma pertencer a um "
+ + "app que você ainda usa, dá para restaurar.\n\nContinuar?";
+
+ if (System.Windows.MessageBox.Show(
+ texto, "Remover restos",
+ System.Windows.MessageBoxButton.OKCancel,
+ System.Windows.MessageBoxImage.Warning) != System.Windows.MessageBoxResult.OK)
+ {
+ return;
+ }
+
+ var (removidos, bytes) = _modulo.RemoverRestos(escolhidos.Select(r => r.Item).ToList());
+
+ foreach (var removido in escolhidos.Take(removidos))
+ Restos.Remove(removido);
+
+ ResumoDeRestos = $"{removidos} pasta(s) na Lixeira, {GameModeModule.Formatar(bytes)} liberados.";
+ StatusDeRestos = "Para recuperar espaço de verdade, esvazie a Lixeira — está em Ferramentas.";
+ NotificarRestos();
+ }
+
+ private void AoMudarSelecaoDeResto(object? remetente, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(RestoViewModel.Selecionado))
+ NotificarRestos();
+ }
+
+ private void NotificarRestos()
+ {
+ OnPropertyChanged(nameof(RestosSelecionados));
+ OnPropertyChanged(nameof(TextoRemoverRestos));
+ }
+
+ // ==================================================================
+ // Aba: Instalados
+ // ==================================================================
+
+ protected override string MontarConfirmacao(IReadOnlyList selecionados)
+ {
+ var total = selecionados.Sum(i => i.Item.GanhoBytes);
+ var nomes = string.Join("\n ", selecionados.Take(10).Select(i => i.Titulo));
+ var extra = selecionados.Count > 10 ? $"\n ... e mais {selecionados.Count - 10}" : string.Empty;
+
+ var apps = selecionados.Select(i => i.Item.Payload as InstalledApp).Where(a => a is not null).ToList();
+ var semSilencio = apps.Count(a => !a!.TemDesinstalacaoSilenciosa);
+
+ var texto = $"Desinstalar {selecionados.Count} aplicativo(s), liberando cerca de "
+ + $"{GameModeModule.Formatar(total)}?\n\n {nomes}{extra}\n\n";
+
+ if (semSilencio > 0)
+ {
+ texto += $"{semSilencio} deles não têm desinstalação silenciosa: o desinstalador "
+ + "próprio de cada um vai abrir, e você conclui por ele.\n\n";
+ }
+
+ texto += "Isto não tem como ser desfeito pelo GameBoost. Configurações e dados dos "
+ + "aplicativos se perdem.\n\nConsidere criar um ponto de restauração antes "
+ + "(está em Ferramentas). Continuar?";
+
+ return texto;
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/ConfiguracoesPageViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/ConfiguracoesPageViewModel.cs
new file mode 100644
index 0000000..a889972
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/ConfiguracoesPageViewModel.cs
@@ -0,0 +1,293 @@
+using System.Collections.ObjectModel;
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GameBoost.Core;
+using GameBoost.Core.Logging;
+using GameBoost.Core.Settings;
+using GameBoost.Core.State;
+
+namespace GameBoost.App.ViewModels;
+
+/// Uma linha do Historico: uma alteracao de sistema ja aplicada.
+public sealed partial class HistoricoItemViewModel : ObservableObject
+{
+ public HistoricoItemViewModel(ChangeRecord record)
+ {
+ Record = record;
+ _revertido = record.Revertido;
+ }
+
+ public ChangeRecord Record { get; }
+
+ [ObservableProperty] private bool _revertido;
+
+ public string Quando => Record.Data.ToLocalTime().ToString("dd/MM/yyyy HH:mm");
+ public string Modulo => Record.Modulo;
+ public string Tipo => Record.Tipo.ToString();
+
+ public string Descricao => Record.Extras.TryGetValue("nome", out var nome) && !string.IsNullOrWhiteSpace(nome)
+ ? nome
+ : string.IsNullOrEmpty(Record.SubAlvo) ? Record.Alvo : $"{Record.Alvo}\\{Record.SubAlvo}";
+
+ public string Mudanca => Record.ValorAnteriorExistia
+ ? $"{Record.ValorAnterior ?? "-"} -> {Record.ValorNovo ?? "-"}"
+ : $"(nao existia) -> {Record.ValorNovo ?? "-"}";
+
+ public string Situacao => Revertido ? "Revertido" : "Aplicado";
+ public bool PodeDesfazer => !Revertido;
+}
+
+///
+/// Configuracoes e Historico (secao 6). O Historico le direto o state-backup:
+/// e a mesma fonte que o "Reverter tudo" usa, entao nunca fica dessincronizado.
+///
+public sealed partial class ConfiguracoesPageViewModel : PageViewModelBase
+{
+ private readonly AppSettings _settings;
+ private readonly ISettingsStore _store;
+ private readonly IStateBackup _backup;
+ private readonly IRollbackEngine _rollback;
+ private readonly AppPaths _paths;
+ private readonly Core.Updates.UpdateService _atualizacao;
+ private readonly IGameBoostLogger _log;
+ private bool _carregando;
+
+ public ConfiguracoesPageViewModel(
+ AppSettings settings,
+ ISettingsStore store,
+ IStateBackup backup,
+ IRollbackEngine rollback,
+ AppPaths paths,
+ Core.Updates.UpdateService atualizacao,
+ IGameBoostLogger log)
+ {
+ _atualizacao = atualizacao;
+ _versao = atualizacao.VersaoAtual;
+ _estadoDaAtualizacao = atualizacao.Suportado
+ ? "Clique em Verificar para procurar versao nova."
+ : "Esta copia nao foi instalada pelo instalador do GameBoost (ou esta em modo "
+ + "portatil), entao nao ha atualizacao automatica.";
+
+ _settings = settings;
+ _store = store;
+ _backup = backup;
+ _rollback = rollback;
+ _paths = paths;
+ _log = log;
+
+ _carregando = true;
+ _temaEscuro = !string.Equals(settings.Tema, "Light", StringComparison.OrdinalIgnoreCase);
+ _iniciarComWindows = settings.IniciarComWindows;
+ _permitirAcessoARede = settings.PermitirAcessoARede;
+ _mostrarNaBandeja = settings.MostrarNaBandeja;
+ _detectarJogosAutomaticamente = settings.DetectarJogosAutomaticamente;
+ _iniciarMinimizado = settings.IniciarMinimizado;
+ _carregando = false;
+
+ EhAdministrador = CoreServices.RodandoComoAdministrador();
+ RecarregarHistorico();
+ }
+
+ public override string Nome => "Configuracoes";
+ public override string Titulo => "Configuracoes";
+ public override string Subtitulo => "Preferencias do app e historico de tudo que ele ja alterou.";
+ public override string Icone => "\uE713";
+
+ public ObservableCollection Historico { get; } = new();
+
+ [ObservableProperty] private bool _temaEscuro;
+ [ObservableProperty] private bool _iniciarComWindows;
+ [ObservableProperty] private bool _iniciarMinimizado;
+ [ObservableProperty] private bool _permitirAcessoARede;
+ [ObservableProperty] private bool _mostrarNaBandeja;
+ [ObservableProperty] private string _versao = string.Empty;
+ [ObservableProperty] private string _estadoDaAtualizacao = string.Empty;
+ [ObservableProperty] private bool _verificandoAtualizacao;
+ [ObservableProperty] private bool _atualizacaoPronta;
+ [ObservableProperty] private bool _detectarJogosAutomaticamente;
+ [ObservableProperty] private bool _ehAdministrador;
+ [ObservableProperty] private string _status = string.Empty;
+
+ public string PastaDeDados => _paths.RootDirectory;
+
+ public string ModoDeArmazenamento => _paths.Portatil
+ ? "Modo portatil: os dados ficam ao lado do executavel."
+ : "Os dados ficam em %LOCALAPPDATA%\\GameBoost.";
+
+ public bool HistoricoVazio => Historico.Count == 0;
+
+ public override void AoEntrar() => RecarregarHistorico();
+
+ [RelayCommand]
+ private void RecarregarHistorico()
+ {
+ _backup.Recarregar();
+ Historico.Clear();
+
+ // Mais recente primeiro: e o que o usuario quer desfazer.
+ foreach (var record in _backup.Todos.OrderByDescending(r => r.Data))
+ Historico.Add(new HistoricoItemViewModel(record));
+
+ OnPropertyChanged(nameof(HistoricoVazio));
+ }
+
+ [RelayCommand]
+ private void Desfazer(HistoricoItemViewModel? item)
+ {
+ if (item is null || !EhAdministrador)
+ return;
+
+ var resposta = MessageBox.Show(
+ $"Desfazer esta alteracao?\n\n{item.Descricao}\n{item.Mudanca}",
+ "Desfazer", MessageBoxButton.YesNo, MessageBoxImage.Question);
+
+ if (resposta != MessageBoxResult.Yes)
+ return;
+
+ var resultado = _rollback.Reverter(new[] { item.Record.Id }, dryRun: false).FirstOrDefault();
+
+ if (resultado is null)
+ {
+ Status = "Esta alteracao ja havia sido revertida.";
+ RecarregarHistorico();
+ return;
+ }
+
+ item.Revertido = resultado.Sucesso;
+ Status = resultado.Detalhe;
+ _log.Info("Config", "Desfazer", item.Record.Alvo, resultado.Detalhe);
+
+ RecarregarHistorico();
+ }
+
+ [RelayCommand]
+ private void AbrirPastaDeDados()
+ {
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = _paths.RootDirectory,
+ UseShellExecute = true
+ });
+ }
+ catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
+ {
+ Status = "Nao foi possivel abrir a pasta.";
+ }
+ }
+
+ private void Salvar()
+ {
+ if (_carregando)
+ return;
+
+ _settings.Tema = TemaEscuro ? "Dark" : "Light";
+ _settings.IniciarComWindows = IniciarComWindows;
+ _settings.PermitirAcessoARede = PermitirAcessoARede;
+ _settings.MostrarNaBandeja = MostrarNaBandeja;
+ _settings.DetectarJogosAutomaticamente = DetectarJogosAutomaticamente;
+ _settings.IniciarMinimizado = IniciarMinimizado;
+ _store.Save(_settings);
+ }
+
+ partial void OnTemaEscuroChanged(bool value) => Salvar();
+
+ partial void OnIniciarComWindowsChanged(bool value)
+ {
+ Salvar();
+
+ // Honestidade (secao 6): o GameBoost aparece na propria lista de
+ // inicializacao que ele mesmo gerencia.
+ Status = value
+ ? "O GameBoost passa a iniciar com o Windows. Ele aparece na propria lista de Inicializacao."
+ : "O GameBoost nao inicia mais com o Windows.";
+ }
+
+ partial void OnIniciarMinimizadoChanged(bool value) => Salvar();
+
+ // ==================================================================
+ // Atualizacao do proprio GameBoost (secao 10)
+ // ==================================================================
+
+ ///
+ /// Verifica se ha versao nova.
+ ///
+ /// Sem permissao de rede, sem instalacao feita pelo instalador ou em modo
+ /// portatil, ele **diz o que falta** em vez de falhar calado. Um botao que
+ /// nao responde e pior que um botao que explica.
+ ///
+ [RelayCommand]
+ private async Task VerificarAtualizacaoAsync()
+ {
+ if (VerificandoAtualizacao)
+ return;
+
+ VerificandoAtualizacao = true;
+ EstadoDaAtualizacao = "Verificando...";
+
+ try
+ {
+ var estado = await _atualizacao.VerificarAsync(CancellationToken.None);
+
+ if (estado == Core.Updates.EstadoDaAtualizacao.Disponivel)
+ {
+ EstadoDaAtualizacao = _atualizacao.Motivo + " Baixando...";
+ estado = await _atualizacao.BaixarAsync(CancellationToken.None);
+ }
+
+ EstadoDaAtualizacao = _atualizacao.Motivo ?? "Nada a relatar.";
+ AtualizacaoPronta = estado == Core.Updates.EstadoDaAtualizacao.ProntaParaInstalar;
+ }
+ catch (OperationCanceledException)
+ {
+ EstadoDaAtualizacao = "Verificacao cancelada.";
+ }
+ finally
+ {
+ VerificandoAtualizacao = false;
+ }
+ }
+
+ [RelayCommand]
+ private void InstalarAtualizacao()
+ {
+ if (!AtualizacaoPronta)
+ return;
+
+ _atualizacao.InstalarEReiniciar();
+ }
+
+ partial void OnMostrarNaBandejaChanged(bool value)
+ {
+ Salvar();
+
+ Status = value
+ ? "O ícone volta a aparecer na bandeja na próxima abertura."
+ : "Sem o ícone na bandeja, fechar a janela fecha o GameBoost de vez. "
+ + "A mudança vale na próxima abertura.";
+ }
+
+ partial void OnDetectarJogosAutomaticamenteChanged(bool value)
+ {
+ Salvar();
+
+ Status = value
+ ? "Ao abrir um jogo, o GameBoost vai perguntar se você quer ativar o Modo Game. "
+ + "Ele nunca ativa sozinho, a não ser que o perfil daquele jogo mande."
+ : "O GameBoost deixa de vigiar os processos. O Modo Game continua funcionando "
+ + "pelo botão de sempre. Vale na próxima abertura.";
+ }
+
+ partial void OnPermitirAcessoARedeChanged(bool value)
+ {
+ Salvar();
+
+ Status = value
+ ? "O teste de velocidade da página Rede foi liberado. Nenhum dado seu é enviado: "
+ + "o teste só baixa e envia bytes aleatórios para medir a conexão."
+ : "Acesso à internet desligado. O teste de velocidade fica indisponível; "
+ + "ping, DNS e detecção de NAT continuam funcionando.";
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/DiagnosticoPageViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/DiagnosticoPageViewModel.cs
new file mode 100644
index 0000000..31bec51
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/DiagnosticoPageViewModel.cs
@@ -0,0 +1,348 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GameBoost.Core;
+using GameBoost.Core.Abstractions;
+using GameBoost.Core.Logging;
+using GameBoost.Core.Modules.Bottleneck;
+using GameBoost.Core.Modules.GameMode;
+using GameBoost.Core.Modules.HealthReport;
+
+namespace GameBoost.App.ViewModels;
+
+/// Um dos cinco medidores do painel.
+public sealed partial class MedidorViewModel : ObservableObject
+{
+ public MedidorViewModel(string nome, string unidade, double maximo)
+ {
+ Nome = nome;
+ Unidade = unidade;
+ Maximo = maximo;
+ }
+
+ public string Nome { get; }
+ public string Unidade { get; }
+ public double Maximo { get; }
+
+ [ObservableProperty] private double _valor;
+ [ObservableProperty] private string _texto = "--";
+ [ObservableProperty] private bool _disponivel = true;
+ [ObservableProperty] private string? _detalhe;
+ [ObservableProperty] private IReadOnlyList _serie = Array.Empty();
+
+ /// Percentual para a barra, mesmo quando a unidade nao e porcentagem.
+ public double Percentual => Maximo <= 0 ? 0 : Math.Clamp(Valor / Maximo * 100.0, 0, 100);
+
+ partial void OnValorChanged(double value) => OnPropertyChanged(nameof(Percentual));
+}
+
+public sealed partial class FindingViewModel : ObservableObject
+{
+ public FindingViewModel(Finding finding)
+ {
+ Finding = finding;
+ }
+
+ public Finding Finding { get; }
+
+ public string Titulo => Finding.Titulo;
+ public string Detalhe => Finding.Detalhe;
+ public string? TextoDaAcao => Finding.TextoDaAcao;
+ public bool TemAcao => Finding.Acao != FindingAcao.Nenhuma && Finding.TextoDaAcao is not null;
+ public FindingSeverity Severidade => Finding.Severidade;
+
+ public string TextoDeSeveridade => Finding.Severidade switch
+ {
+ FindingSeverity.Critico => "Critico",
+ FindingSeverity.Atencao => "Atencao",
+ _ => "Informativo"
+ };
+}
+
+///
+/// Painel de diagnostico (secao 5.5): cinco medidores ao vivo, grafico de 60 s,
+/// top 10 processos e a lista de findings em linguagem humana.
+///
+/// A coleta roda em Task propria dentro do BottleneckMonitor; aqui so
+/// devolvemos cada tique para a thread da UI (regra 10).
+///
+public sealed partial class DiagnosticoPageViewModel : PageViewModelBase, IDisposable
+{
+ private readonly BottleneckMonitor _monitor;
+ private readonly HealthReportModule _relatorio;
+ private readonly IMemoryService _memoria;
+ private readonly IGameBoostLogger _log;
+ private bool _inscrito;
+
+ public DiagnosticoPageViewModel(
+ BottleneckMonitor monitor,
+ HealthReportModule relatorio,
+ IMemoryService memoria,
+ IGameBoostLogger log)
+ {
+ _monitor = monitor;
+ _relatorio = relatorio;
+ _memoria = memoria;
+ _log = log;
+
+ Cpu = new MedidorViewModel("CPU", "%", 100);
+ Gpu = new MedidorViewModel("GPU", "%", 100);
+ Ram = new MedidorViewModel("Memoria", "%", 100);
+ Disco = new MedidorViewModel("Disco", "%", 100);
+ Rede = new MedidorViewModel("Rede", "MB/s", 10);
+
+ Medidores = new ObservableCollection { Cpu, Gpu, Ram, Disco, Rede };
+
+ EhAdministrador = CoreServices.RodandoComoAdministrador();
+ }
+
+ public override string Nome => "Diagnostico";
+ public override string Titulo => "Diagnostico de gargalos";
+ public override string Subtitulo => "O que esta consumindo a maquina agora, e quem e o culpado.";
+ public override string Icone => "\uE9D9";
+
+ public ObservableCollection Medidores { get; }
+ public MedidorViewModel Cpu { get; }
+ public MedidorViewModel Gpu { get; }
+ public MedidorViewModel Ram { get; }
+ public MedidorViewModel Disco { get; }
+ public MedidorViewModel Rede { get; }
+
+ public ObservableCollection TopProcessos { get; } = new();
+ public ObservableCollection Findings { get; } = new();
+
+ [ObservableProperty] private bool _monitorando;
+ [ObservableProperty] private bool _ehAdministrador;
+ [ObservableProperty] private string _status = "Clique em Iniciar para acompanhar a maquina em tempo real.";
+ [ObservableProperty] private string? _temperaturaCpu;
+ [ObservableProperty] private string? _jogoDetectado;
+ [ObservableProperty] private string _custoDaColeta = string.Empty;
+
+ public bool SemFindings => Findings.Count == 0 && Monitorando;
+
+ /// Ao entrar na pagina o monitoramento comeca sozinho: e o que o usuario quer ver.
+ public override void AoEntrar()
+ {
+ if (!Monitorando)
+ Iniciar();
+ }
+
+ /// Sair da pagina para a coleta: nao faz sentido medir o que ninguem esta vendo.
+ public override void AoSair() => Parar();
+
+ [RelayCommand]
+ private void Iniciar()
+ {
+ if (Monitorando)
+ return;
+
+ if (!_inscrito)
+ {
+ _monitor.AoColetar += AoColetar;
+ _inscrito = true;
+ }
+
+ _monitor.Iniciar();
+ Monitorando = true;
+ Status = "Acompanhando a maquina. A coleta roda uma vez por segundo.";
+ }
+
+ [RelayCommand]
+ private void Parar()
+ {
+ if (!Monitorando)
+ return;
+
+ _monitor.Parar();
+ Monitorando = false;
+ Status = $"Monitoramento parado. Custo medio da coleta: {_monitor.CustoMedioDeCpuPercent:0.00}% de CPU.";
+ }
+
+ private void AoColetar(MonitorTick tique)
+ {
+ // O monitor roda em Task propria; tudo daqui para baixo e UI.
+ var dispatcher = Application.Current?.Dispatcher;
+ if (dispatcher is null)
+ return;
+
+ dispatcher.InvokeAsync(() => Aplicar(tique));
+ }
+
+ private void Aplicar(MonitorTick tique)
+ {
+ var s = tique.Snapshot;
+ var historico = _monitor.Buffer.Ultimos(TimeSpan.FromSeconds(60));
+
+ Cpu.Valor = s.CpuPercent;
+ Cpu.Texto = $"{s.CpuPercent:0}%";
+ Cpu.Detalhe = $"{s.NucleosLogicos} nucleos logicos";
+ Cpu.Serie = historico.Select(h => h.CpuPercent).ToList();
+
+ if (s.GpuPercent.Disponivel)
+ {
+ Gpu.Disponivel = true;
+ Gpu.Valor = s.GpuPercent.Valor;
+ Gpu.Texto = $"{s.GpuPercent.Valor:0}%";
+ Gpu.Serie = historico.Select(h => h.GpuPercent.OuZero).ToList();
+ Gpu.Detalhe = s.GpuMemoriaBytes.Disponivel
+ ? $"{GameModeModule.Formatar((long)s.GpuMemoriaBytes.Valor)} de VRAM em uso"
+ : null;
+ }
+ else
+ {
+ Gpu.Disponivel = false;
+ Gpu.Texto = "indisponivel";
+ Gpu.Detalhe = "Os contadores de GPU nao existem nesta maquina.";
+ }
+
+ Ram.Valor = s.RamUsadaPercent;
+ Ram.Texto = $"{s.RamUsadaPercent:0}%";
+ Ram.Detalhe = $"{GameModeModule.Formatar(s.RamDisponivelBytes)} livres de {GameModeModule.Formatar(s.RamTotalBytes)}"
+ + (s.StandbyBytes > 0 ? $" · {GameModeModule.Formatar(s.StandbyBytes)} em cache" : string.Empty);
+ Ram.Serie = historico.Select(h => h.RamUsadaPercent).ToList();
+
+ Disco.Valor = s.DiscoSistemaUsadoPercent;
+ Disco.Texto = $"{s.DiscoSistemaUsadoPercent:0}%";
+ Disco.Detalhe = "ocupacao do disco do sistema";
+ Disco.Serie = historico.Select(h => h.DiscoSistemaUsadoPercent).ToList();
+
+ if (s.RedeBytesPorSegundo.Disponivel)
+ {
+ var mbps = s.RedeBytesPorSegundo.Valor / (1024.0 * 1024.0);
+ Rede.Disponivel = true;
+ Rede.Valor = mbps;
+ Rede.Texto = $"{mbps:0.0} MB/s";
+ Rede.Detalhe = "soma das interfaces ativas";
+ Rede.Serie = historico
+ .Select(h => h.RedeBytesPorSegundo.OuZero / (1024.0 * 1024.0))
+ .ToList();
+ }
+ else
+ {
+ Rede.Disponivel = false;
+ Rede.Texto = "--";
+ }
+
+ TemperaturaCpu = s.TemperaturaCpu.Disponivel
+ ? $"CPU a {s.TemperaturaCpu.Valor:0} C"
+ : null;
+
+ TopProcessos.Clear();
+ foreach (var p in s.Processos.Take(10))
+ TopProcessos.Add(p);
+
+ AtualizarFindings(tique.Findings);
+
+ CustoDaColeta = $"A propria coleta custa {_monitor.CustoMedioDeCpuPercent:0.00}% de CPU.";
+ }
+
+ private void AtualizarFindings(IReadOnlyList novos)
+ {
+ // Reconstroi so quando o conjunto muda: a lista nao pode piscar a cada segundo.
+ var idsAtuais = Findings.Select(f => f.Finding.Id).ToList();
+ var idsNovos = novos.Select(f => f.Id).ToList();
+
+ if (idsAtuais.SequenceEqual(idsNovos))
+ return;
+
+ Findings.Clear();
+ foreach (var f in novos)
+ Findings.Add(new FindingViewModel(f));
+
+ OnPropertyChanged(nameof(SemFindings));
+ }
+
+ /// Botao de acao de um finding. Cada acao leva para onde resolve.
+ [RelayCommand]
+ private void ExecutarAcao(FindingViewModel? item)
+ {
+ if (item is null)
+ return;
+
+ switch (item.Finding.Acao)
+ {
+ case FindingAcao.LimparRam:
+ case FindingAcao.PurgarStandby:
+ if (!EhAdministrador)
+ {
+ Status = "Limpar memoria exige executar como administrador.";
+ return;
+ }
+
+ var antes = _memoria.GetSnapshot();
+ var ok = _memoria.PurgeStandbyList();
+ var depois = _memoria.GetSnapshot();
+ var ganho = depois.AvailableBytes - antes.AvailableBytes;
+
+ // Regra 4: mostra o ganho medido, inclusive quando e zero.
+ Status = ok
+ ? ganho > 0
+ ? $"Standby List purgada: {GameModeModule.Formatar(ganho)} a mais de RAM livre."
+ : "Standby List purgada, mas sem ganho mensuravel de memoria."
+ : "Nao foi possivel purgar a Standby List.";
+ break;
+
+ case FindingAcao.AbrirSegurancaDoWindows:
+ Abrir("windowsdefender://coreisolation");
+ break;
+
+ case FindingAcao.AbrirConfiguracoesDeVideo:
+ Abrir("ms-settings:display-advanced");
+ break;
+
+ case FindingAcao.AtivarAltoDesempenho:
+ Abrir("ms-settings:powersleep");
+ break;
+
+ case FindingAcao.AbrirSiteDoDriver:
+ Abrir(EnderecoDoDriver(item.Finding));
+ break;
+
+ default:
+ Status = item.Finding.TextoDaAcao is null
+ ? "Este achado e informativo."
+ : $"{item.Finding.TextoDaAcao}: disponivel quando o modulo correspondente chegar.";
+ break;
+ }
+ }
+
+ private static string EnderecoDoDriver(Finding finding)
+ {
+ if (finding.Titulo.Contains("NVIDIA", StringComparison.OrdinalIgnoreCase))
+ return "https://www.nvidia.com/pt-br/drivers/";
+
+ if (finding.Titulo.Contains("AMD", StringComparison.OrdinalIgnoreCase))
+ return "https://www.amd.com/pt/support";
+
+ if (finding.Titulo.Contains("Intel", StringComparison.OrdinalIgnoreCase))
+ return "https://www.intel.com.br/content/www/br/pt/download-center/home.html";
+
+ return "https://www.google.com/search?q=atualizar+driver+de+video";
+ }
+
+ private void Abrir(string destino)
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo { FileName = destino, UseShellExecute = true });
+ }
+ catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
+ {
+ _log.Warn("Diagnostico", "Abrir", destino, ex.Message);
+ Status = "Nao foi possivel abrir esse destino.";
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_inscrito)
+ {
+ _monitor.AoColetar -= AoColetar;
+ _inscrito = false;
+ }
+
+ _monitor.Parar();
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/EspacoPageViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/EspacoPageViewModel.cs
new file mode 100644
index 0000000..7aaf13b
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/EspacoPageViewModel.cs
@@ -0,0 +1,315 @@
+using System.IO;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GameBoost.Core.Logging;
+using GameBoost.Core.Modules.DiskAnalyzer;
+using GameBoost.Core.Modules.GameMode;
+using GameBoost.Core.Native;
+
+namespace GameBoost.App.ViewModels;
+
+/// Um item de lista: arquivo grande, pasta grande ou jogo instalado.
+public sealed class ItemDeEspacoViewModel
+{
+ public ItemDeEspacoViewModel(string nome, string detalhe, long bytes, string caminho, string? marca = null)
+ {
+ Nome = nome;
+ Detalhe = detalhe;
+ Bytes = bytes;
+ Caminho = caminho;
+ Marca = marca;
+ }
+
+ public string Nome { get; }
+ public string Detalhe { get; }
+ public long Bytes { get; }
+ public string Caminho { get; }
+
+ /// Selo curto, como "parado há muito tempo".
+ public string? Marca { get; }
+
+ public string Tamanho => GameModeModule.Formatar(Bytes);
+ public bool TemMarca => Marca is not null;
+}
+
+///
+/// Analisador de espaço em disco (seção 5.4) com a biblioteca de jogos (5.11).
+///
+/// Não implementa IModule: aqui nada é aplicado nem revertido. A página mostra
+/// onde está o espaço e abre no Explorer — quem apaga é o usuário, com as
+/// ferramentas do Windows.
+///
+public sealed partial class EspacoPageViewModel : PageViewModelBase
+{
+ private readonly DiskScanner _scanner;
+ private readonly GameLibrary _biblioteca;
+ private readonly IGameBoostLogger _log;
+
+ private DiskScanResult? _resultado;
+ private CancellationTokenSource? _cts;
+
+ public EspacoPageViewModel(DiskScanner scanner, GameLibrary biblioteca, IGameBoostLogger log)
+ {
+ _scanner = scanner;
+ _biblioteca = biblioteca;
+ _log = log;
+
+ foreach (var unidade in DiskScanner.VolumesDisponiveis())
+ Volumes.Add(unidade.RootDirectory.FullName);
+
+ VolumeSelecionado = Volumes.FirstOrDefault()
+ ?? Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.Windows))
+ ?? @"C:\";
+ }
+
+ public override string Nome => "Espaco";
+ public override string Titulo => "Onde está o seu espaço";
+ public override string Subtitulo => "Varre o disco e mostra o que ocupa, do maior para o menor.";
+ public override string Icone => "\uE8B7";
+
+ public ObservableCollection Volumes { get; } = new();
+ public ObservableCollection MaioresArquivos { get; } = new();
+ public ObservableCollection MaioresPastas { get; } = new();
+ public ObservableCollection Jogos { get; } = new();
+ public ObservableCollection Especiais { get; } = new();
+
+ /// Caminho até a pasta aberta no treemap, para o botão de voltar.
+ public ObservableCollection Trilha { get; } = new();
+
+ [ObservableProperty] private string _volumeSelecionado = @"C:\";
+ [ObservableProperty] private DiskNode? _noAtual;
+ [ObservableProperty] private bool _varrendo;
+ [ObservableProperty] private bool _jaVarreu;
+ [ObservableProperty] private int _progresso;
+ [ObservableProperty] private string _status = "Escolha um disco e clique em Varrer.";
+ [ObservableProperty] private string _resumo = string.Empty;
+ [ObservableProperty] private string? _sobOMouse;
+
+ public bool PodeSubir => Trilha.Count > 1;
+
+ [RelayCommand]
+ private async Task VarrerAsync()
+ {
+ if (Varrendo)
+ return;
+
+ _cts = new CancellationTokenSource();
+ Varrendo = true;
+ Progresso = 0;
+ Status = $"Lendo {VolumeSelecionado}...";
+
+ Limpar();
+
+ var volume = VolumeSelecionado;
+
+ try
+ {
+ var progresso = new Progress(p =>
+ {
+ Status = $"{p.Arquivos:N0} arquivos, {GameModeModule.Formatar(p.Bytes)}...";
+
+ // Não há total conhecido antes do fim: a barra anda até 95% e
+ // completa na conclusão, em vez de fingir uma porcentagem exata.
+ Progresso = Math.Min(95, Progresso + 1);
+ });
+
+ var resultado = await Task.Run(() => _scanner.Varrer(volume, progresso, _cts.Token), _cts.Token);
+ var jogos = await Task.Run(() => _biblioteca.Listar(), _cts.Token);
+
+ _resultado = resultado;
+ Preencher(resultado, jogos);
+
+ Progresso = 100;
+ JaVarreu = true;
+ }
+ catch (OperationCanceledException)
+ {
+ Status = "Varredura cancelada.";
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ Status = $"Não foi possível varrer {volume}: {ex.Message}";
+ _log.Error("Espaco", "Varrer", volume, ex.Message, ex);
+ }
+ finally
+ {
+ Varrendo = false;
+ }
+ }
+
+ private void Limpar()
+ {
+ MaioresArquivos.Clear();
+ MaioresPastas.Clear();
+ Jogos.Clear();
+ Especiais.Clear();
+ Trilha.Clear();
+ NoAtual = null;
+ }
+
+ private void Preencher(DiskScanResult resultado, IReadOnlyList jogos)
+ {
+ NoAtual = resultado.Raiz;
+ Trilha.Add(resultado.Raiz);
+
+ foreach (var arquivo in resultado.Raiz.MaioresArquivos(100))
+ {
+ MaioresArquivos.Add(new ItemDeEspacoViewModel(
+ arquivo.Nome,
+ Path.GetDirectoryName(arquivo.Caminho) ?? arquivo.Caminho,
+ arquivo.Tamanho,
+ arquivo.Caminho,
+ DiskCategories.Nome(arquivo.Categoria)));
+ }
+
+ foreach (var pasta in resultado.Raiz.MaioresPastas(50))
+ {
+ MaioresPastas.Add(new ItemDeEspacoViewModel(
+ pasta.Nome,
+ $"{pasta.TotalDeArquivos:N0} arquivos · {pasta.Caminho}",
+ pasta.Tamanho,
+ pasta.Caminho));
+ }
+
+ foreach (var jogo in jogos)
+ {
+ Jogos.Add(new ItemDeEspacoViewModel(
+ jogo.Nome,
+ $"{jogo.Launcher} · jogado {jogo.QuandoJogou}",
+ jogo.Bytes,
+ jogo.Pasta,
+ jogo.CandidatoADesinstalar ? "parado e grande" : null));
+ }
+
+ foreach (var especial in SpecialFiles.Encontrar(resultado.Raiz.Caminho))
+ Especiais.Add(especial);
+
+ var ocupado = resultado.EspacoTotal - resultado.EspacoLivre;
+ var naoAlcancado = ocupado - resultado.Raiz.Tamanho;
+
+ Resumo = $"{GameModeModule.Formatar(resultado.Raiz.Tamanho)} mapeados em "
+ + $"{resultado.TotalDeArquivos:N0} arquivos, em {resultado.Duracao.TotalSeconds:0.0} segundos. "
+ + $"Livre: {GameModeModule.Formatar(resultado.EspacoLivre)} de "
+ + $"{GameModeModule.Formatar(resultado.EspacoTotal)}.";
+
+ // Honestidade sobre o que a varredura não alcançou (regra 4).
+ Status = resultado.PastasIgnoradas > 0
+ ? $"{resultado.PastasIgnoradas} pastas não puderam ser lidas (permissão do Windows). "
+ + $"Faltam {GameModeModule.Formatar(Math.Max(0, naoAlcancado))} para fechar com o espaço ocupado."
+ : "Varredura concluída.";
+ }
+
+ // ---------------- Navegação no treemap ----------------
+
+ public void Entrar(DiskNode pasta)
+ {
+ if (!pasta.EhPasta || pasta.Filhos.Count == 0)
+ return;
+
+ Trilha.Add(pasta);
+ NoAtual = pasta;
+ OnPropertyChanged(nameof(PodeSubir));
+ }
+
+ [RelayCommand]
+ private void Subir()
+ {
+ if (Trilha.Count <= 1)
+ return;
+
+ Trilha.RemoveAt(Trilha.Count - 1);
+ NoAtual = Trilha[^1];
+ OnPropertyChanged(nameof(PodeSubir));
+ }
+
+ [RelayCommand]
+ private void VoltarParaRaiz()
+ {
+ if (_resultado is null)
+ return;
+
+ Trilha.Clear();
+ Trilha.Add(_resultado.Raiz);
+ NoAtual = _resultado.Raiz;
+ OnPropertyChanged(nameof(PodeSubir));
+ }
+
+ public void MostrarNoRodape(DiskNode? no)
+ => SobOMouse = no is null
+ ? null
+ : $"{no.Caminho} — {GameModeModule.Formatar(no.Tamanho)}"
+ + (no.EhPasta ? $" em {no.TotalDeArquivos:N0} arquivos" : string.Empty);
+
+ // ---------------- Ações ----------------
+
+ ///
+ /// Abre no Explorer com o item já selecionado. O GameBoost não apaga nada
+ /// daqui: a seção 5.4 é para mostrar, e quem decide é o usuário.
+ ///
+ [RelayCommand]
+ private void AbrirNoExplorer(ItemDeEspacoViewModel? item)
+ {
+ if (item is null)
+ return;
+
+ try
+ {
+ var existeArquivo = File.Exists(item.Caminho);
+ var argumento = existeArquivo ? $"/select,\"{item.Caminho}\"" : $"\"{item.Caminho}\"";
+
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = "explorer.exe",
+ Arguments = argumento,
+ UseShellExecute = true
+ });
+ }
+ catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
+ {
+ Status = "Não foi possível abrir o Explorer.";
+ _log.Warn("Espaco", "AbrirNoExplorer", item.Caminho, ex.Message);
+ }
+ }
+
+ /// Manda um item para a Lixeira, com confirmação e sem apagar de vez.
+ [RelayCommand]
+ private void MandarParaLixeira(ItemDeEspacoViewModel? item)
+ {
+ if (item is null)
+ return;
+
+ var resposta = MessageBox.Show(
+ $"Enviar para a Lixeira?\n\n{item.Caminho}\n{item.Tamanho}\n\n"
+ + "Dá para recuperar pela Lixeira do Windows enquanto ela não for esvaziada.",
+ "Enviar para a Lixeira", MessageBoxButton.YesNo, MessageBoxImage.Warning);
+
+ if (resposta != MessageBoxResult.Yes)
+ return;
+
+ if (RecycleBinBridge.ParaLixeira(new[] { item.Caminho }))
+ {
+ Status = $"{item.Nome} foi para a Lixeira. Varra de novo para atualizar os números.";
+ _log.Info("Espaco", "ParaLixeira", item.Caminho, item.Tamanho);
+ }
+ else
+ {
+ Status = "O Windows recusou mover para a Lixeira. O arquivo pode estar em uso.";
+ }
+ }
+
+ [RelayCommand]
+ private void ExplicarEspecial(SpecialFile? arquivo)
+ {
+ if (arquivo is null)
+ return;
+
+ MessageBox.Show(SpecialFiles.Descrever(arquivo), arquivo.Nome,
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+ [RelayCommand]
+ private void Cancelar() => _cts?.Cancel();
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/FerramentasPageViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/FerramentasPageViewModel.cs
new file mode 100644
index 0000000..795bc85
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/FerramentasPageViewModel.cs
@@ -0,0 +1,192 @@
+using System.Collections.ObjectModel;
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GameBoost.Core;
+using GameBoost.Core.Modules;
+using GameBoost.Core.Modules.Tools;
+
+namespace GameBoost.App.ViewModels;
+
+public sealed partial class FerramentaViewModel : ObservableObject
+{
+ public FerramentaViewModel(QuickTool ferramenta)
+ {
+ Ferramenta = ferramenta;
+ }
+
+ public QuickTool Ferramenta { get; }
+
+ public string Id => Ferramenta.Id;
+ public string Titulo => Ferramenta.Titulo;
+ public string Descricao => Ferramenta.Descricao;
+ public string Categoria => Ferramenta.Categoria;
+ public RiskLevel Risco => Ferramenta.Risco;
+ public string? Duracao => Ferramenta.Duracao;
+ public bool PrecisaAdmin => Ferramenta.PrecisaAdmin;
+
+ public string TextoDeRisco => Ferramenta.Risco switch
+ {
+ RiskLevel.Alto => "Risco alto",
+ RiskLevel.Medio => "Risco medio",
+ _ => "Risco baixo"
+ };
+
+ [ObservableProperty] private bool _rodando;
+ [ObservableProperty] private string? _resultado;
+ [ObservableProperty] private bool _falhou;
+
+ public bool TemResultado => !string.IsNullOrWhiteSpace(Resultado);
+
+ partial void OnResultadoChanged(string? value) => OnPropertyChanged(nameof(TemResultado));
+}
+
+///
+/// Página de Ferramentas rápidas (seção 5.13).
+///
+/// Não herda de ModulePageViewModel de propósito: aqui não existe lista de
+/// itens para marcar e aplicar em lote. São ações independentes, cada uma com
+/// a própria confirmação e o próprio resultado.
+///
+public sealed partial class FerramentasPageViewModel : PageViewModelBase
+{
+ private readonly QuickToolsService _servico;
+ private CancellationTokenSource? _cts;
+
+ public FerramentasPageViewModel(QuickToolsService servico)
+ {
+ _servico = servico;
+ EhAdministrador = CoreServices.RodandoComoAdministrador();
+
+ foreach (var ferramenta in QuickToolsCatalog.Todas())
+ Ferramentas.Add(new FerramentaViewModel(ferramenta));
+ }
+
+ public override string Nome => "Ferramentas";
+ public override string Titulo => "Ferramentas rápidas";
+ public override string Subtitulo => "Atalhos para o que normalmente exige prompt de comando.";
+ public override string Icone => "\uE912";
+
+ public ObservableCollection Ferramentas { get; } = new();
+
+ [ObservableProperty] private bool _ehAdministrador;
+ [ObservableProperty] private string _status = "Cada ferramenta explica o que faz e como desfazer.";
+ [ObservableProperty] private string? _saidaLonga;
+ [ObservableProperty] private bool _ocupado;
+
+ public bool TemSaida => !string.IsNullOrWhiteSpace(SaidaLonga);
+
+ partial void OnSaidaLongaChanged(string? value) => OnPropertyChanged(nameof(TemSaida));
+
+ [RelayCommand]
+ private async Task ExecutarAsync(FerramentaViewModel? item)
+ {
+ if (item is null || Ocupado)
+ return;
+
+ var f = item.Ferramenta;
+
+ if (f.PrecisaAdmin && !EhAdministrador)
+ {
+ item.Falhou = true;
+ item.Resultado = "Esta ferramenta exige executar o GameBoost como administrador.";
+ return;
+ }
+
+ if (f.Confirmacao is not null)
+ {
+ var texto = f.Confirmacao;
+
+ if (f.Duracao is not null)
+ texto += $"\n\nIsto {f.Duracao}.";
+
+ var resposta = MessageBox.Show(texto, f.Titulo,
+ MessageBoxButton.YesNo, MessageBoxImage.Warning);
+
+ if (resposta != MessageBoxResult.Yes)
+ return;
+ }
+
+ _cts = new CancellationTokenSource();
+ Ocupado = true;
+ item.Rodando = true;
+ item.Falhou = false;
+ item.Resultado = null;
+ SaidaLonga = null;
+ Status = f.Duracao is null ? $"{f.Titulo}..." : $"{f.Titulo} — {f.Duracao}.";
+
+ // Ferramenta demorada mostra a saída ao vivo: sem isso o usuário acha
+ // que travou e mata o app no meio de um sfc.
+ var progresso = f.Tipo == ToolKind.Demorada
+ ? new Progress(linha => SaidaLonga = (SaidaLonga ?? string.Empty) + linha + Environment.NewLine)
+ : null;
+
+ try
+ {
+ var resultado = await _servico.ExecutarAsync(f.Id, progresso, _cts.Token);
+
+ item.Falhou = !resultado.Sucesso;
+ item.Resultado = resultado.Mensagem;
+
+ if (!string.IsNullOrWhiteSpace(resultado.Detalhe))
+ SaidaLonga = resultado.Detalhe;
+
+ Status = resultado.Mensagem;
+ }
+ catch (OperationCanceledException)
+ {
+ item.Resultado = "Cancelado.";
+ Status = "Cancelado.";
+ }
+ finally
+ {
+ item.Rodando = false;
+ Ocupado = false;
+ }
+ }
+
+ [RelayCommand]
+ private void Cancelar() => _cts?.Cancel();
+
+ [RelayCommand]
+ private void CopiarSaida()
+ {
+ if (string.IsNullOrWhiteSpace(SaidaLonga))
+ return;
+
+ try
+ {
+ Clipboard.SetText(SaidaLonga);
+ Status = "Copiado. É só colar onde precisar.";
+ }
+ catch (System.Runtime.InteropServices.ExternalException)
+ {
+ // A área de transferência pode estar presa por outro app.
+ Status = "Não foi possível copiar: outro programa está usando a área de transferência.";
+ }
+ }
+
+ [RelayCommand]
+ private void LimparSaida() => SaidaLonga = null;
+
+ /// Explicação do botão "?": o que faz, risco e como desfazer.
+ [RelayCommand]
+ private void Explicar(FerramentaViewModel? item)
+ {
+ if (item is null)
+ return;
+
+ var f = item.Ferramenta;
+ var texto = $"O que faz: {f.Descricao}\n\nRisco: {item.TextoDeRisco}";
+
+ if (f.Duracao is not null)
+ texto += $"\n\nDuração: {f.Duracao}";
+
+ if (f.PrecisaAdmin)
+ texto += "\n\nExige executar como administrador.";
+
+ texto += $"\n\nComo desfazer: {f.ComoDesfazer}";
+
+ MessageBox.Show(texto, f.Titulo, MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/InicializacaoPageViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/InicializacaoPageViewModel.cs
new file mode 100644
index 0000000..7f9da7d
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/InicializacaoPageViewModel.cs
@@ -0,0 +1,34 @@
+using GameBoost.Core.Modules.Startup;
+using GameBoost.Core.State;
+
+namespace GameBoost.App.ViewModels;
+
+///
+/// Página de inicialização (seção 5.6). Também herda a tela do padrão de módulo:
+/// "Aplicar N selecionados" aqui significa desativar, e "Reverter" reativa.
+///
+public sealed class InicializacaoPageViewModel : ModulePageViewModel
+{
+ public InicializacaoPageViewModel(StartupModule modulo, IStateBackup backup)
+ : base(modulo, backup)
+ {
+ }
+
+ public override string Nome => "Inicializacao";
+ public override string Titulo => "O que abre com o Windows";
+ public override string Subtitulo => "Podar a inicialização é, junto com o plano de energia, o ajuste de maior valor.";
+ public override string Icone => "\uE7E8";
+
+ protected override string MontarConfirmacao(IReadOnlyList selecionados)
+ {
+ var nomes = string.Join("\n ", selecionados.Take(12).Select(i => i.Titulo));
+ var extra = selecionados.Count > 12 ? $"\n ... e mais {selecionados.Count - 12}" : string.Empty;
+
+ return $"Impedir que {selecionados.Count} programa(s) abram junto com o Windows?\n\n"
+ + $" {nomes}{extra}\n\n"
+ + "Os programas continuam instalados e você pode abrir quando quiser. "
+ + "O efeito aparece no próximo boot.\n\n"
+ + "Para reativar, use o botão Reverter aqui, o Gerenciador de Tarefas ou "
+ + "Configurações, Aplicativos, Inicializar.";
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/InicioPageViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/InicioPageViewModel.cs
new file mode 100644
index 0000000..06e7989
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/InicioPageViewModel.cs
@@ -0,0 +1,390 @@
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GameBoost.Core.Abstractions;
+using GameBoost.Core.Logging;
+using GameBoost.Core.Modules;
+using GameBoost.Core.Modules.Bottleneck;
+using GameBoost.Core.Modules.GameMode;
+using GameBoost.Core.Modules.HealthReport;
+using GameBoost.Core.State;
+
+namespace GameBoost.App.ViewModels;
+
+///
+/// Pagina Inicio: Modo Game, pontuacao e findings (secao 6).
+///
+/// Herda o padrao de pagina de modulo, mas troca o rodape generico
+/// "Aplicar N selecionados" pelos botoes do Modo Game, que e o que o usuario
+/// espera encontrar na abertura.
+///
+/// Uma linha da lista de restauracao.
+public sealed class AppParaReabrir
+{
+ public AppParaReabrir(Core.Modules.GameMode.ClosedApp item)
+ {
+ Item = item;
+ }
+
+ public Core.Modules.GameMode.ClosedApp Item { get; }
+
+ public string Nome => Item.Nome;
+
+ public string Detalhe => Item.Essencial
+ ? "marcado como essencial"
+ : "não reabre sozinho porque não está marcado com estrela";
+}
+
+public sealed partial class InicioPageViewModel : ModulePageViewModel
+{
+ private readonly GameModeModule _gameMode;
+ private readonly IStateBackup _backup;
+ private readonly IRollbackEngine _rollback;
+ private readonly ISessionStore _sessions;
+ private readonly HealthReportModule _saude;
+ private readonly IMemoryService _memoria;
+ private readonly IProcessService _processos;
+ private readonly IGameBoostLogger _log;
+ private CancellationTokenSource? _ctsSaude;
+
+ public InicioPageViewModel(
+ GameModeModule gameMode,
+ IStateBackup backup,
+ IRollbackEngine rollback,
+ ISessionStore sessions,
+ HealthReportModule saude,
+ IMemoryService memoria,
+ IProcessService processos,
+ IGameBoostLogger log)
+ : base(gameMode, backup)
+ {
+ _gameMode = gameMode;
+ _memoria = memoria;
+ _processos = processos;
+ _saude = saude;
+ _backup = backup;
+ _rollback = rollback;
+ _sessions = sessions;
+ _log = log;
+
+ // Aviso na abertura se o app morreu com o Modo Game ativo (secao 1).
+ var sessao = _sessions.Load();
+ if (sessao.Ativo)
+ {
+ ModoGameAtivo = true;
+ AtualizarListaDeRestauracao();
+ AvisoDeRestauracao =
+ "O GameBoost foi fechado com o Modo Game ainda ativo. " +
+ "Clique em Restaurar agora para devolver o sistema ao estado anterior.";
+ }
+ else if (_backup.TemPendencias)
+ {
+ AvisoDeRestauracao =
+ $"Existem {_backup.Pendentes.Count} alteracoes de sistema pendentes de reversao.";
+ }
+ }
+
+ public override string Nome => "Inicio";
+ public override string Titulo => "Modo Game";
+ public override string Subtitulo => "Encerra o que voce confirmar, libera RAM e prepara o sistema para o jogo.";
+ public override string Icone => "\uE80F";
+
+ [ObservableProperty] private bool _modoGameAtivo;
+ [ObservableProperty] private string? _avisoDeRestauracao;
+
+ ///
+ /// Apps encerrados que ainda nao voltaram. Sem estrela eles nao reabrem
+ /// sozinhos, e ate agora o app anunciava "N aguardando na lista de
+ /// restauracao" sem existir lista nenhuma para agir.
+ ///
+ public ObservableCollection ParaReabrir { get; } = new();
+
+ public bool TemAppsParaReabrir => ParaReabrir.Count > 0;
+
+ private void AtualizarListaDeRestauracao()
+ {
+ ParaReabrir.Clear();
+
+ foreach (var app in _sessions.Load().AppsEncerrados.Where(a => !a.Reaberto))
+ {
+ if (!string.IsNullOrWhiteSpace(app.ExecutablePath))
+ ParaReabrir.Add(new AppParaReabrir(app));
+ }
+
+ OnPropertyChanged(nameof(TemAppsParaReabrir));
+ }
+
+ /// Reabre um app da lista, e so ele.
+ [RelayCommand]
+ private void Reabrir(AppParaReabrir? alvo)
+ {
+ if (alvo?.Item.ExecutablePath is null)
+ return;
+
+ var ok = _processos.Start(
+ alvo.Item.ExecutablePath, alvo.Item.Argumentos, alvo.Item.WorkingDirectory);
+
+ if (!ok)
+ {
+ Status = $"Não foi possível reabrir {alvo.Nome}. "
+ + "Alguns apps da Microsoft Store só abrem pelo menu Iniciar.";
+ return;
+ }
+
+ // Marcar no arquivo, senao ele reaparece na lista na proxima abertura.
+ var sessao = _sessions.Load();
+
+ foreach (var app in sessao.AppsEncerrados.Where(a =>
+ string.Equals(a.Nome, alvo.Item.Nome, StringComparison.OrdinalIgnoreCase)))
+ {
+ app.Reaberto = true;
+ }
+
+ _sessions.Save(sessao);
+
+ ParaReabrir.Remove(alvo);
+ OnPropertyChanged(nameof(TemAppsParaReabrir));
+
+ Status = $"{alvo.Nome} reaberto.";
+ _log.Info("gamemode", "Reabrir", alvo.Nome, "pela lista de restauração");
+ }
+
+ /// Encerra a sessao no arquivo e limpa a lista da tela.
+ private void EncerrarSessao()
+ {
+ var sessao = _sessions.Load();
+
+ if (!sessao.Ativo && sessao.AppsEncerrados.Count == 0)
+ return;
+
+ sessao.Ativo = false;
+ _sessions.Save(sessao);
+
+ AtualizarListaDeRestauracao();
+ }
+
+ // ------------------------------------------------------------------
+ // Pontuacao de saude e principais findings (secoes 5.12 e 5.5)
+ // ------------------------------------------------------------------
+
+ public ObservableCollection Areas { get; } = new();
+ public ObservableCollection PrincipaisFindings { get; } = new();
+
+ [ObservableProperty] private bool _pontuacaoDisponivel;
+ [ObservableProperty] private bool _analisando;
+ [ObservableProperty] private int _notaGeral;
+ [ObservableProperty] private string _conceito = string.Empty;
+ [ObservableProperty] private string _progressoDaAnalise = string.Empty;
+
+ public string PontuacaoPlaceholder =>
+ "Clique em Analisar para medir a maquina e ver a nota de saude com os principais achados.";
+
+ /// Roda o relatorio de saude completo: mede por alguns segundos e pontua.
+ [RelayCommand]
+ private async Task AnalisarSaudeAsync()
+ {
+ if (Analisando)
+ return;
+
+ _ctsSaude = new CancellationTokenSource();
+ Analisando = true;
+ ProgressoDaAnalise = "Medindo a maquina...";
+
+ var progresso = new Progress(p => ProgressoDaAnalise = p.Etapa);
+
+ try
+ {
+ var relatorio = await _saude.GerarAsync(progresso, _ctsSaude.Token);
+
+ NotaGeral = relatorio.Pontuacao.NotaGeral;
+ Conceito = relatorio.Pontuacao.Conceito;
+
+ Areas.Clear();
+ foreach (var area in relatorio.Pontuacao.Areas)
+ Areas.Add(area);
+
+ PrincipaisFindings.Clear();
+ foreach (var f in relatorio.Pontuacao.Principais)
+ PrincipaisFindings.Add(new FindingViewModel(f));
+
+ PontuacaoDisponivel = true;
+ ProgressoDaAnalise = PrincipaisFindings.Count == 0
+ ? "Nada fora do lugar."
+ : $"{PrincipaisFindings.Count} achados. Detalhes no Diagnostico.";
+ }
+ catch (OperationCanceledException)
+ {
+ ProgressoDaAnalise = "Analise cancelada.";
+ }
+ finally
+ {
+ Analisando = false;
+ }
+ }
+
+ [RelayCommand]
+ private void CancelarAnalise() => _ctsSaude?.Cancel();
+
+ /// Exportar o relatorio para mandar a quem da suporte (secao 5.12).
+ [RelayCommand]
+ private async Task ExportarRelatorioAsync()
+ {
+ var dialogo = new Microsoft.Win32.SaveFileDialog
+ {
+ FileName = $"gameboost-saude-{DateTime.Now:yyyy-MM-dd-HHmm}.html",
+ Filter = "Pagina HTML (*.html)|*.html|Dados JSON (*.json)|*.json",
+ Title = "Salvar relatorio de saude"
+ };
+
+ if (dialogo.ShowDialog() != true)
+ return;
+
+ Analisando = true;
+ ProgressoDaAnalise = "Medindo a maquina para o relatorio...";
+
+ try
+ {
+ var relatorio = await _saude.GerarAsync(
+ new Progress(p => ProgressoDaAnalise = p.Etapa),
+ CancellationToken.None);
+
+ var json = dialogo.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase);
+ var conteudo = json
+ ? HtmlReportWriter.GerarJson(relatorio)
+ : HtmlReportWriter.GerarHtml(relatorio);
+
+ File.WriteAllText(dialogo.FileName, conteudo, new System.Text.UTF8Encoding(false));
+
+ ProgressoDaAnalise = $"Relatorio salvo em {dialogo.FileName}";
+ _log.Info("Inicio", "ExportarRelatorio", dialogo.FileName, $"nota {relatorio.Pontuacao.NotaGeral}");
+ }
+ catch (IOException ex)
+ {
+ ProgressoDaAnalise = $"Nao foi possivel salvar: {ex.Message}";
+ }
+ finally
+ {
+ Analisando = false;
+ }
+ }
+
+ protected override string MontarConfirmacao(IReadOnlyList selecionados)
+ {
+ if (selecionados.Count == 0)
+ return "Nenhum app foi marcado. O Modo Game vai apenas ajustar o sistema "
+ + "(energia, gravacao, Windows Update). Continuar?";
+
+ var nomes = string.Join("\n ", selecionados.Take(12).Select(i => i.Titulo));
+ var extra = selecionados.Count > 12 ? $"\n ... e mais {selecionados.Count - 12}" : string.Empty;
+
+ return $"O GameBoost vai encerrar {selecionados.Count} app(s):\n\n {nomes}{extra}\n\n"
+ + "Cada um recebe primeiro o pedido de fechamento normal, com 3 segundos para salvar.\n\n"
+ + "Continuar?";
+ }
+
+ protected override void AposAplicar(ApplyResult resultado)
+ {
+ if (resultado.DryRun)
+ return;
+
+ ModoGameAtivo = true;
+ AvisoDeRestauracao = null;
+ }
+
+ protected override void AposReverter(ApplyResult resultado)
+ {
+ if (resultado.DryRun)
+ return;
+
+ ModoGameAtivo = false;
+ AvisoDeRestauracao = null;
+
+ // O que nao tinha estrela nao volta sozinho: aparece aqui com botao.
+ AtualizarListaDeRestauracao();
+ }
+
+ ///
+ /// Libera memoria sem ativar o Modo Game. E o item "Limpar RAM" da bandeja.
+ ///
+ /// O ganho e medido, nao estimado: mede a RAM livre antes, roda, mede
+ /// depois e mostra a diferenca. Boosters costumam anunciar aqui um numero
+ /// inventado; quando o Windows ja estava bem de memoria, o numero honesto
+ /// e "quase nada", e e isso que aparece.
+ ///
+ [RelayCommand]
+ private void LimparRam()
+ {
+ if (!EhAdministrador)
+ {
+ Status = "Liberar memoria precisa de privilegios de administrador.";
+ return;
+ }
+
+ var antes = _memoria.GetSnapshot();
+
+ var processos = 0;
+
+ foreach (var p in _processos.GetProcesses())
+ {
+ if (_processos.TrimWorkingSet(p.Pid) > 0)
+ processos++;
+ }
+
+ var purgou = _memoria.PurgeStandbyList();
+ var depois = _memoria.GetSnapshot();
+ var ganho = depois.AvailableBytes - antes.AvailableBytes;
+
+ Status = ganho > 0
+ ? $"{Core.Modules.GameMode.GameModeModule.Formatar(ganho)} a mais de RAM livre "
+ + $"({processos} processos{(purgou ? ", Standby List purgada" : string.Empty)})."
+ : "A memoria ja estava livre: nao havia o que liberar. "
+ + "Isso e normal em maquina com RAM sobrando.";
+
+ _log.Info("gamemode", "LimparRam", null, Status);
+ }
+
+ /// Botao Reverter tudo: sempre visivel, desfaz qualquer alteracao pendente.
+ [RelayCommand]
+ private void ReverterTudo()
+ {
+ if (!EhAdministrador)
+ return;
+
+ if (!_backup.TemPendencias)
+ {
+ Status = "Nada a reverter: nenhuma alteracao pendente.";
+ return;
+ }
+
+ var pendentes = _backup.Pendentes.Count;
+ var resposta = MessageBox.Show(
+ $"Desfazer {pendentes} alteracao(oes) de sistema feitas pelo GameBoost?",
+ "Reverter tudo", MessageBoxButton.YesNo, MessageBoxImage.Question);
+
+ if (resposta != MessageBoxResult.Yes)
+ return;
+
+ var resultados = _rollback.ReverterTudo(DryRun);
+ var falhas = resultados.Count(r => !r.Sucesso);
+
+ // Encerrar a sessao no arquivo, e nao so na tela.
+ //
+ // Sem isto, o session.json continuava dizendo Ativo=true para sempre, e
+ // o aviso "foi fechado com o Modo Game ainda ativo" voltava em TODA
+ // abertura — mesmo com zero pendencias. Um aviso que aparece sempre
+ // deixa de ser aviso.
+ if (!DryRun)
+ EncerrarSessao();
+
+ ModoGameAtivo = false;
+ AvisoDeRestauracao = null;
+ Status = falhas == 0
+ ? $"{resultados.Count} alteracoes revertidas."
+ : $"{resultados.Count - falhas} revertidas, {falhas} falharam. Veja o log.";
+
+ _log.Info("App", "ReverterTudo", null, Status);
+ OnPropertyChanged(nameof(TemPendencias));
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/JogosPageViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/JogosPageViewModel.cs
new file mode 100644
index 0000000..bfbb67e
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/JogosPageViewModel.cs
@@ -0,0 +1,303 @@
+using System.Collections.ObjectModel;
+using System.IO;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GameBoost.Core;
+using GameBoost.Core.Abstractions;
+using GameBoost.Core.Modules.DiskAnalyzer;
+using GameBoost.Core.Modules.GameMode;
+using GameBoost.Core.Modules.Profiles;
+
+namespace GameBoost.App.ViewModels;
+
+/// Um jogo da biblioteca, com ou sem perfil.
+public sealed partial class JogoViewModel : ObservableObject
+{
+ public JogoViewModel(string nome, string? caminho, string launcher, long bytes, string quandoJogou, GameProfile? perfil)
+ {
+ Nome = nome;
+ Caminho = caminho;
+ Launcher = launcher;
+ Bytes = bytes;
+ QuandoJogou = quandoJogou;
+ _perfil = perfil;
+ }
+
+ public string Nome { get; }
+ public string? Caminho { get; }
+ public string Launcher { get; }
+ public long Bytes { get; }
+ public string QuandoJogou { get; }
+
+ [ObservableProperty] private GameProfile? _perfil;
+
+ public bool TemPerfil => Perfil is not null;
+
+ public string Tamanho => Bytes > 0 ? GameModeModule.Formatar(Bytes) : "tamanho desconhecido";
+
+ public string Detalhe => Perfil is null
+ ? $"{Launcher} · {Tamanho} · {QuandoJogou} · sem perfil"
+ : $"{Launcher} · {Tamanho} · {QuandoJogou} · {Perfil.Resumo}";
+
+ partial void OnPerfilChanged(GameProfile? value)
+ {
+ OnPropertyChanged(nameof(TemPerfil));
+ OnPropertyChanged(nameof(Detalhe));
+ }
+}
+
+///
+/// Página Jogos (seções 5.1 e 5.11).
+///
+/// Junta a biblioteca lida dos launchers com os perfis gravados. Um jogo sem
+/// perfil aparece igual aos outros, com o botão de criar; o perfil não é
+/// obrigatório para nada, e o Modo Game continua funcionando sem ele.
+///
+public sealed partial class JogosPageViewModel : PageViewModelBase
+{
+ private readonly GameLibrary _biblioteca;
+ private readonly ProfileStore _perfis;
+ private readonly GameWatcher _vigia;
+ private readonly ProfileRunner _executor;
+
+ public JogosPageViewModel(
+ GameLibrary biblioteca,
+ ProfileStore perfis,
+ GameWatcher vigia,
+ ProfileRunner executor)
+ {
+ _biblioteca = biblioteca;
+ _perfis = perfis;
+ _vigia = vigia;
+ _executor = executor;
+
+ EhAdministrador = CoreServices.RodandoComoAdministrador();
+ }
+
+ public override string Nome => "Jogos";
+ public override string Titulo => "Jogos e perfis";
+ public override string Subtitulo => "Cada jogo com os seus ajustes, aplicados ao abrir e desfeitos ao fechar.";
+ public override string Icone => "\uE7FC";
+
+ public ObservableCollection Jogos { get; } = new();
+
+ [ObservableProperty] private string _resumo = "Clique em Varrer para ler a sua biblioteca.";
+ [ObservableProperty] private string _status = string.Empty;
+ [ObservableProperty] private bool _ocupado;
+ [ObservableProperty] private bool _ehAdministrador;
+ [ObservableProperty] private bool _vigiaLigado;
+ [ObservableProperty] private string _jogoEmExecucao = string.Empty;
+ [ObservableProperty] private JogoViewModel? _selecionado;
+
+ ///
+ /// Lista vazia. A visibilidade do texto de estado vazio precisa depender
+ /// disto, nao do Resumo: o Resumo nunca fica vazio, entao o texto ficava
+ /// desenhado por cima da lista cheia.
+ ///
+ public bool ListaVazia => Jogos.Count == 0;
+
+ public override void AoEntrar()
+ {
+ base.AoEntrar();
+ VigiaLigado = _vigia.Ativo;
+ AtualizarJogoEmExecucao();
+ }
+
+ private void AtualizarJogoEmExecucao()
+ {
+ var atual = _vigia.JogoAtual;
+
+ JogoEmExecucao = atual is null
+ ? string.Empty
+ : _executor.EmSessao
+ ? $"{atual.Process.Name} rodando, perfil aplicado."
+ : $"{atual.Process.Name} rodando, sem perfil aplicado.";
+ }
+
+ [RelayCommand]
+ public async Task VarrerAsync()
+ {
+ if (Ocupado)
+ return;
+
+ Ocupado = true;
+ Jogos.Clear();
+ Resumo = "Lendo Steam, Epic, GOG e Xbox...";
+
+ try
+ {
+ var achados = await Task.Run(() => _biblioteca.Listar());
+
+ var perfis = _perfis.Todos.ToList();
+
+ foreach (var jogo in achados)
+ {
+ Jogos.Add(new JogoViewModel(
+ jogo.Nome, jogo.Pasta, jogo.Launcher, jogo.Bytes, jogo.QuandoJogou,
+ CasarPerfil(jogo, perfis)));
+ }
+
+ // Perfis de jogos que a biblioteca não achou: instalado fora de
+ // launcher, ou launcher que o GameBoost ainda não lê. Some da tela
+ // seria pior que aparecer sem tamanho.
+ foreach (var perfil in perfis)
+ {
+ if (Jogos.Any(j => j.Perfil?.Executavel == perfil.Executavel))
+ continue;
+
+ Jogos.Add(new JogoViewModel(
+ perfil.Nome, perfil.Caminho, perfil.Launcher ?? "fora de launcher",
+ 0, perfil.UltimaVez is null ? "nunca usado pelo perfil" : $"perfil usado {perfil.VezesUsado}x",
+ perfil));
+ }
+
+ OnPropertyChanged(nameof(ListaVazia));
+
+ var comPerfil = Jogos.Count(j => j.TemPerfil);
+ var total = Jogos.Sum(j => j.Bytes);
+
+ Resumo = $"{Jogos.Count} jogos, {GameModeModule.Formatar(total)} no total. "
+ + $"{comPerfil} com perfil.";
+
+ Status = VigiaLigado
+ ? "A detecção automática está ligada: ao abrir um jogo, o GameBoost pergunta."
+ : "A detecção automática está desligada. Ligue em Configurações para o GameBoost "
+ + "reconhecer o jogo sozinho.";
+ }
+ catch (OperationCanceledException)
+ {
+ Status = "Varredura cancelada.";
+ }
+ finally
+ {
+ Ocupado = false;
+ }
+ }
+
+ /// Cria um perfil padrão para o jogo selecionado.
+ [RelayCommand]
+ private void CriarPerfil()
+ {
+ var jogo = Selecionado;
+
+ if (jogo is null || jogo.TemPerfil)
+ return;
+
+ var executavel = ExecutavelProvavel(jogo);
+
+ if (executavel is null)
+ {
+ Status = $"Não foi possível descobrir o executável de {jogo.Nome}. "
+ + "Abra o jogo uma vez com a detecção automática ligada: o GameBoost aprende sozinho.";
+ return;
+ }
+
+ var perfil = GameProfile.Padrao(executavel, jogo.Nome, jogo.Caminho, jogo.Launcher);
+ _perfis.Salvar(perfil);
+ jogo.Perfil = perfil;
+
+ Status = $"Perfil criado para {jogo.Nome}. Ele não ativa sozinho: "
+ + "marque \"ativar sozinho\" se quiser que o GameBoost não pergunte.";
+ }
+
+ [RelayCommand]
+ private void RemoverPerfil()
+ {
+ var jogo = Selecionado;
+
+ if (jogo?.Perfil is null)
+ return;
+
+ _perfis.Remover(jogo.Perfil.Executavel);
+ Status = $"Perfil de {jogo.Nome} removido. O jogo continua na biblioteca.";
+ jogo.Perfil = null;
+ }
+
+ /// Alterna "ativa sozinho" do perfil selecionado.
+ [RelayCommand]
+ private void AlternarAutomatico()
+ {
+ var perfil = Selecionado?.Perfil;
+
+ if (perfil is null)
+ return;
+
+ perfil.Automatico = !perfil.Automatico;
+ _perfis.Salvar(perfil);
+
+ // Forca a linha a redesenhar com o resumo novo.
+ var jogo = Selecionado!;
+ jogo.Perfil = null;
+ jogo.Perfil = perfil;
+
+ Status = perfil.Automatico
+ ? $"{perfil.Nome} passa a ativar o Modo Game sozinho ao abrir."
+ : $"{perfil.Nome} volta a perguntar antes de ativar.";
+ }
+
+ ///
+ /// Tenta adivinhar o executável a partir da pasta do jogo.
+ ///
+ /// É palpite, e a tela diz isso: escolhe o maior .exe da raiz da pasta, que
+ /// acerta na maioria dos jogos e erra em quem põe o executável num
+ /// subdiretório `Binaries\Win64`. Quando erra, o caminho certo é abrir o
+ /// jogo com a detecção ligada — aí o executável vem do processo de verdade.
+ ///
+ private static string? ExecutavelProvavel(JogoViewModel jogo)
+ {
+ if (jogo.Caminho is null || !Directory.Exists(jogo.Caminho))
+ return null;
+
+ try
+ {
+ var candidatos = Directory.EnumerateFiles(jogo.Caminho, "*.exe", SearchOption.TopDirectoryOnly)
+ .Select(f => new FileInfo(f))
+ .Where(f => !EhAuxiliar(f.Name))
+ .OrderByDescending(f => f.Length)
+ .ToList();
+
+ return candidatos.Count == 0 ? null : Path.GetFileNameWithoutExtension(candidatos[0].Name);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private static bool EhAuxiliar(string nome)
+ {
+ string[] marcadores = { "unins", "setup", "install", "launcher", "crash", "report", "redist", "vcredist", "directx" };
+ return marcadores.Any(m => nome.Contains(m, StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// Acha o perfil do jogo, pela pasta antes do nome.
+ ///
+ /// A primeira versão casava pelo nome da pasta contra a chave do perfil,
+ /// que é o **executável**. "Warframe" nunca casa com "warframe.x64": o jogo
+ /// aparecia como "sem perfil" e o perfil entrava de novo na lista como
+ /// órfão. A mesma linha duas vezes, uma dizendo que tem perfil e a outra
+ /// que não tem.
+ ///
+ /// A pasta de instalação é o que os dois têm em comum e não muda, então é
+ /// por ela que o casamento começa. O nome fica de reserva, para o perfil
+ /// que foi gravado sem caminho.
+ ///
+ private static GameProfile? CasarPerfil(JogoInstalado jogo, IReadOnlyList perfis)
+ {
+ if (!string.IsNullOrWhiteSpace(jogo.Pasta))
+ {
+ var pasta = jogo.Pasta.TrimEnd('\\', '/');
+
+ var porCaminho = perfis.FirstOrDefault(p =>
+ !string.IsNullOrWhiteSpace(p.Caminho)
+ && string.Equals(p.Caminho!.TrimEnd('\\', '/'), pasta, StringComparison.OrdinalIgnoreCase));
+
+ if (porCaminho is not null)
+ return porCaminho;
+ }
+
+ return perfis.FirstOrDefault(p =>
+ string.Equals(p.Nome, jogo.Nome, StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/LimpezaPageViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/LimpezaPageViewModel.cs
new file mode 100644
index 0000000..90684b8
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/LimpezaPageViewModel.cs
@@ -0,0 +1,49 @@
+using GameBoost.Core.Modules.Cleaner;
+using GameBoost.Core.State;
+
+namespace GameBoost.App.ViewModels;
+
+///
+/// Página de Limpeza (seção 5.2).
+///
+/// Toda a tela vem de ModulePageViewModel: cabeçalho com Varrer, lista genérica
+/// de ActionItem, rodapé com "Aplicar N selecionados". Este arquivo só precisa
+/// dizer como a página se chama e o que perguntar antes de apagar — que era
+/// exatamente a promessa do padrão criado junto com o shell.
+///
+public sealed class LimpezaPageViewModel : ModulePageViewModel
+{
+ public LimpezaPageViewModel(CleanerModule modulo, IStateBackup backup)
+ : base(modulo, backup)
+ {
+ }
+
+ public override string Nome => "Limpeza";
+ public override string Titulo => "Limpeza de temporários e caches";
+ public override string Subtitulo => "Libera espaço mostrando exatamente o que sai e quanto ocupa.";
+ public override string Icone => "\uE74D";
+
+ protected override string MontarConfirmacao(IReadOnlyList selecionados)
+ {
+ var total = selecionados.Sum(i => i.Item.GanhoBytes);
+ var nomes = string.Join("\n ", selecionados.Take(10).Select(i => i.Titulo));
+ var extra = selecionados.Count > 10 ? $"\n ... e mais {selecionados.Count - 10}" : string.Empty;
+
+ var texto = $"O GameBoost vai limpar {selecionados.Count} item(ns), "
+ + $"liberando cerca de {Core.Modules.GameMode.GameModeModule.Formatar(total)}:\n\n"
+ + $" {nomes}{extra}\n\n";
+
+ // Avisa de novo, na hora de confirmar, o que tem efeito colateral.
+ var comAviso = selecionados
+ .Select(i => i.Item.Payload as AchadoDeLimpeza)
+ .Where(a => a?.Alvo.Advertencia is not null)
+ .Select(a => $" - {a!.Alvo.Titulo}: {a.Alvo.Advertencia}")
+ .ToList();
+
+ if (comAviso.Count > 0)
+ texto += "Atenção:\n" + string.Join("\n", comAviso) + "\n\n";
+
+ texto += "Arquivos em uso são mantidos. Continuar?";
+ return texto;
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/ModulePageViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/ModulePageViewModel.cs
new file mode 100644
index 0000000..bab806a
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/ModulePageViewModel.cs
@@ -0,0 +1,371 @@
+using System.Collections.ObjectModel;
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GameBoost.Core;
+using GameBoost.Core.Modules;
+using GameBoost.Core.State;
+
+namespace GameBoost.App.ViewModels;
+
+///
+/// Padrao de pagina de modulo da secao 6, pronto para reuso: cabecalho com
+/// botao Varrer e resumo, lista generica de ActionItem agrupada por categoria,
+/// rodape fixo com "Aplicar N selecionados" e "Reverter".
+///
+/// Todo modulo das fases seguintes so precisa implementar IModule e herdar
+/// daqui — nenhuma tela nova.
+///
+public abstract partial class ModulePageViewModel : PageViewModelBase
+{
+ private readonly IModule _modulo;
+ private readonly IStateBackup _backup;
+ private CancellationTokenSource? _cts;
+
+ protected ModulePageViewModel(IModule modulo, IStateBackup backup)
+ {
+ _modulo = modulo;
+ _backup = backup;
+ EhAdministrador = CoreServices.RodandoComoAdministrador();
+
+ Status = EhAdministrador
+ ? "Clique em Varrer para ver o que pode ser feito."
+ : "Modo somente leitura: sem privilegios de administrador as varreduras funcionam, mas nada pode ser alterado.";
+ }
+
+ ///
+ /// Tudo o que a varredura achou. A lista que a tela mostra e a
+ /// , que e esta filtrada pela busca.
+ ///
+ private readonly List _todos = new();
+
+ public ObservableCollection Itens { get; } = new();
+
+ ///
+ /// Texto da busca. Procura no titulo e na descricao, sem acento e sem
+ /// diferenciar maiuscula: quem procura "notepad" tem que achar "Notepad", e
+ /// quem procura "gravacao" tem que achar "Gravação".
+ ///
+ [ObservableProperty] private string _busca = string.Empty;
+
+ partial void OnBuscaChanged(string value) => AplicarBusca();
+
+ private void AplicarBusca()
+ {
+ Itens.Clear();
+
+ var termo = Normalizar(Busca);
+
+ foreach (var item in _todos)
+ {
+ if (termo.Length == 0
+ || Normalizar(item.Titulo).Contains(termo, StringComparison.Ordinal)
+ || Normalizar(item.Item.Descricao).Contains(termo, StringComparison.Ordinal))
+ {
+ Itens.Add(item);
+ }
+ }
+
+ OnPropertyChanged(nameof(TextoDoFiltro));
+ OnPropertyChanged(nameof(ListaVaziaPorBusca));
+ }
+
+ /// Tira acento e caixa para a busca casar do jeito que se digita.
+ private static string Normalizar(string texto)
+ {
+ var decomposto = texto.Normalize(System.Text.NormalizationForm.FormD);
+ var sb = new System.Text.StringBuilder(decomposto.Length);
+
+ foreach (var c in decomposto)
+ {
+ if (System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c)
+ != System.Globalization.UnicodeCategory.NonSpacingMark)
+ {
+ sb.Append(char.ToLowerInvariant(c));
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ public bool ListaVaziaPorBusca => Busca.Length > 0 && Itens.Count == 0;
+
+ public string TextoDoFiltro => Busca.Length == 0
+ ? string.Empty
+ : $"{Itens.Count} de {_todos.Count} itens";
+
+ ///
+ /// Quantos estao marcados agora, independente da busca.
+ ///
+ /// Conta sobre , nao sobre : filtrar
+ /// a lista nao pode desmarcar nada nem esconder da contagem o que vai ser
+ /// aplicado.
+ ///
+ public int SelecionadosNoTotal => _todos.Count(i => i.Selecionado);
+
+ public string TextoDaSelecao => SelecionadosNoTotal == 0
+ ? "Nada marcado."
+ : SelecionadosNoTotal == 1
+ ? "1 item marcado."
+ : $"{SelecionadosNoTotal} itens marcados.";
+
+ [ObservableProperty] private string _status = string.Empty;
+ [ObservableProperty] private string _resumo = string.Empty;
+ [ObservableProperty] private string? _ganhoMedido;
+ [ObservableProperty] private bool _ocupado;
+ [ObservableProperty] private int _progresso;
+ [ObservableProperty] private bool _ehAdministrador;
+ [ObservableProperty] private bool _dryRun;
+ [ObservableProperty] private bool _jaVarreu;
+
+ public bool PodeAgir => !Ocupado && EhAdministrador;
+
+ public int Selecionados => SelecionadosNoTotal;
+
+ public string TextoAplicar => Selecionados == 1
+ ? "Aplicar 1 selecionado"
+ : $"Aplicar {Selecionados} selecionados";
+
+ public bool TemPendencias => _backup.Pendentes.Any(r => r.Modulo == _modulo.Id);
+
+ /// Texto da confirmacao antes de aplicar. Cada modulo explica o seu.
+ protected abstract string MontarConfirmacao(IReadOnlyList selecionados);
+
+ ///
+ /// Varrer de novo sozinho depois de aplicar ou reverter.
+ ///
+ /// Ligado onde a varredura e barata — ler registro e servico custa
+ /// milissegundos. Desligado onde ela demora: a lista de apps instalados
+ /// leva quase um minuto, e revarrer sozinho deixaria a tela travada logo
+ /// depois de uma acao.
+ ///
+ /// Quando esta desligado, a tela avisa que a lista pode estar
+ /// desatualizada em vez de mostrar um estado antigo como se fosse o atual.
+ ///
+ protected virtual bool RevarrerAposAgir => false;
+
+ [RelayCommand]
+ public async Task VarrerAsync()
+ {
+ if (Ocupado)
+ return;
+
+ _cts = new CancellationTokenSource();
+ Ocupado = true;
+ Progresso = 0;
+ GanhoMedido = null;
+
+ foreach (var antigo in _todos)
+ antigo.PropertyChanged -= AoMudarSelecao;
+
+ _todos.Clear();
+ Itens.Clear();
+
+ var progresso = new Progress(p =>
+ {
+ Status = p.Etapa;
+ Progresso = p.Percentual;
+ });
+
+ try
+ {
+ var resultado = await _modulo.ScanAsync(progresso, _cts.Token);
+
+ foreach (var item in resultado.Itens)
+ {
+ var vm = new ActionItemViewModel(item);
+ vm.PropertyChanged += AoMudarSelecao;
+ _todos.Add(vm);
+ }
+
+ AplicarBusca();
+
+ Resumo = resultado.Resumo;
+ Status = resultado.Avisos.Count > 0
+ ? string.Join(" ", resultado.Avisos)
+ : "Varredura concluida. Revise a lista antes de confirmar.";
+ JaVarreu = true;
+
+ AposVarrer(resultado);
+ }
+ catch (OperationCanceledException)
+ {
+ Status = "Varredura cancelada.";
+ }
+ finally
+ {
+ Ocupado = false;
+ Progresso = 100;
+ NotificarSelecao();
+ }
+ }
+
+ [RelayCommand]
+ private async Task AplicarAsync()
+ {
+ if (Ocupado || !EhAdministrador)
+ return;
+
+ var selecionados = _todos.Where(i => i.Selecionado && i.PodeSelecionar).ToList();
+
+ if (!DryRun && !Confirmar(selecionados))
+ return;
+
+ _cts = new CancellationTokenSource();
+ Ocupado = true;
+
+ try
+ {
+ var resultado = await _modulo.ApplyAsync(
+ selecionados.Select(i => i.Id).ToList(), DryRun, _cts.Token);
+
+ Resumo = resultado.Resumo;
+ GanhoMedido = resultado.GanhoMedido;
+ Status = resultado.Falhas > 0
+ ? $"{resultado.Sucessos} acoes concluidas, {resultado.Falhas} falharam. Veja o log."
+ : "Pronto.";
+
+ AposAplicar(resultado);
+ }
+ catch (OperationCanceledException)
+ {
+ Status = "Cancelado.";
+ }
+ finally
+ {
+ Ocupado = false;
+ OnPropertyChanged(nameof(TemPendencias));
+ }
+
+ await AtualizarListaAsync(DryRun);
+ }
+
+ [RelayCommand]
+ private async Task ReverterAsync()
+ {
+ if (Ocupado || !EhAdministrador)
+ return;
+
+ var pendentes = _backup.Pendentes.Where(r => r.Modulo == _modulo.Id).ToList();
+
+ if (pendentes.Count == 0)
+ {
+ Status = "Nada a reverter nesta pagina.";
+ return;
+ }
+
+ var resposta = MessageBox.Show(
+ $"Desfazer {pendentes.Count} alteracao(oes) de sistema feitas por {Titulo}?",
+ "Reverter", MessageBoxButton.YesNo, MessageBoxImage.Question);
+
+ if (resposta != MessageBoxResult.Yes)
+ return;
+
+ Ocupado = true;
+
+ try
+ {
+ var resultado = await _modulo.RevertAsync(
+ pendentes.Select(r => r.Id).ToList(), DryRun, CancellationToken.None);
+
+ Resumo = resultado.Resumo;
+ GanhoMedido = resultado.GanhoMedido;
+ Status = resultado.Falhas > 0
+ ? $"{resultado.Sucessos} revertidas, {resultado.Falhas} falharam."
+ : "Sistema devolvido ao estado anterior.";
+
+ AposReverter(resultado);
+ }
+ finally
+ {
+ Ocupado = false;
+ OnPropertyChanged(nameof(TemPendencias));
+ }
+
+ await AtualizarListaAsync(DryRun);
+ }
+
+ [RelayCommand]
+ private void Cancelar() => _cts?.Cancel();
+
+ [RelayCommand]
+ private void MarcarTodos()
+ {
+ foreach (var item in _todos.Where(i => i.PodeSelecionar))
+ item.Selecionado = true;
+ }
+
+ [RelayCommand]
+ private void DesmarcarTodos()
+ {
+ foreach (var item in _todos)
+ item.Selecionado = false;
+ }
+
+ protected virtual bool Confirmar(IReadOnlyList selecionados)
+ {
+ var resposta = MessageBox.Show(
+ MontarConfirmacao(selecionados),
+ $"Confirmar - {Titulo}", MessageBoxButton.YesNo, MessageBoxImage.Warning);
+
+ return resposta == MessageBoxResult.Yes;
+ }
+
+ ///
+ /// Gancho para a pagina que tem conteudo proprio alem da lista. A pagina de
+ /// Rede usa isto para preencher as tabelas de latencia e DNS, que nao cabem
+ /// no formato de ActionItem.
+ ///
+ protected virtual void AposVarrer(ScanResult resultado)
+ {
+ }
+
+ protected virtual void AposAplicar(ApplyResult resultado)
+ {
+ }
+
+ protected virtual void AposReverter(ApplyResult resultado)
+ {
+ }
+
+ private void AoMudarSelecao(object? remetente, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(ActionItemViewModel.Selecionado))
+ NotificarSelecao();
+ }
+
+ ///
+ /// Depois de agir, a lista mostra o estado de ANTES ate alguem varrer de
+ /// novo. Isso fez o badge de um servico continuar dizendo "automatico"
+ /// depois de ele ja ter virado "manual": a tela parecia nao ter feito nada.
+ ///
+ private async Task AtualizarListaAsync(bool foiDryRun)
+ {
+ if (foiDryRun || !JaVarreu)
+ return;
+
+ if (RevarrerAposAgir)
+ {
+ var buscaAnterior = Busca;
+ await VarrerAsync();
+ Busca = buscaAnterior;
+ return;
+ }
+
+ Status = Status.Length == 0
+ ? "A lista ainda mostra o estado de antes. Varra de novo para ver como ficou."
+ : Status + " A lista ainda mostra o estado de antes; varra de novo para ver como ficou.";
+ }
+
+ protected void NotificarSelecao()
+ {
+ OnPropertyChanged(nameof(Selecionados));
+ OnPropertyChanged(nameof(SelecionadosNoTotal));
+ OnPropertyChanged(nameof(TextoAplicar));
+ OnPropertyChanged(nameof(TextoDaSelecao));
+ }
+
+ partial void OnOcupadoChanged(bool value) => OnPropertyChanged(nameof(PodeAgir));
+
+ partial void OnEhAdministradorChanged(bool value) => OnPropertyChanged(nameof(PodeAgir));
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/OnboardingViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/OnboardingViewModel.cs
new file mode 100644
index 0000000..a21ed54
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/OnboardingViewModel.cs
@@ -0,0 +1,102 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace GameBoost.App.ViewModels;
+
+/// Uma das três telas do onboarding.
+public sealed record TelaDeOnboarding(string Titulo, string Texto, string Rodape);
+
+///
+/// Onboarding da primeira abertura (seção 6).
+///
+/// Três telas, e o conteúdo delas não é propaganda: é o contrato. O usuário
+/// precisa saber, antes de clicar em qualquer coisa, que tudo volta atrás, que
+/// nada é enviado para lugar nenhum, e que a maior parte do que os boosters
+/// prometem não existe. Um onboarding que vendesse FPS seria a primeira mentira
+/// do aplicativo.
+///
+public sealed partial class OnboardingViewModel : ObservableObject
+{
+ public static IReadOnlyList Telas { get; } = new[]
+ {
+ new TelaDeOnboarding(
+ "Tudo o que ele faz, ele desfaz",
+ "Antes de alterar qualquer coisa no Windows, o GameBoost grava o valor anterior. "
+ + "Cada ação tem um botão de desfazer, e Configurações → Histórico mostra tudo o que "
+ + "já foi feito, com a data.\n\n"
+ + "Se o aplicativo travar ou o computador reiniciar no meio de uma sessão, a próxima "
+ + "abertura avisa e oferece a restauração.\n\n"
+ + "Nada é aplicado sem você confirmar, e nada vem marcado — nem o que o próprio "
+ + "GameBoost recomenda.",
+ "Reversível por construção, não por promessa."),
+
+ new TelaDeOnboarding(
+ "Nada sai daqui",
+ "O GameBoost não tem telemetria. Não há conta, não há login, não há servidor "
+ + "recebendo dados sobre você ou sobre esta máquina.\n\n"
+ + "Tudo o que ele lê fica em %LOCALAPPDATA%\\GameBoost, em arquivos de texto que você "
+ + "pode abrir e ler.\n\n"
+ + "As únicas funções que usam a internet são o teste de velocidade e a checagem de "
+ + "atualizações de aplicativos. A primeira vem desligada e só liga se você mandar.",
+ "Sem conta, sem nuvem, sem telemetria."),
+
+ new TelaDeOnboarding(
+ "O que faz diferença de verdade",
+ "A maior parte do que os \"otimizadores\" prometem não existe. Limpar registro não "
+ + "acelera nada. Desfragmentar SSD só desgasta a unidade. Fechar processo do Windows "
+ + "à toa quebra mais do que ajuda.\n\n"
+ + "O que rende de verdade, nesta ordem: podar o que abre com o Windows, liberar espaço "
+ + "em disco quando ele está cheio, manter o driver de vídeo atual, e usar cabo em vez "
+ + "de Wi-Fi para jogo online.\n\n"
+ + "Cada ajuste desta ferramenta diz o efeito real que tem. Quando é marginal, está "
+ + "escrito que é marginal.",
+ "Números medidos, não estimados.")
+ };
+
+ [ObservableProperty] private int _indice;
+
+ public TelaDeOnboarding Atual => Telas[Indice];
+
+ public bool EhPrimeira => Indice == 0;
+ public bool EhUltima => Indice == Telas.Count - 1;
+
+ public string TextoDoAvancar => EhUltima ? "Começar" : "Próxima";
+ public string Passo => $"{Indice + 1} de {Telas.Count}";
+
+ /// Marcado na última tela: abre o relatório de saúde ao terminar.
+ [ObservableProperty] private bool _abrirRelatorio = true;
+
+ /// Fechado, com ou sem "começar".
+ public event Action? AoTerminar;
+
+ [RelayCommand]
+ private void Avancar()
+ {
+ if (EhUltima)
+ {
+ AoTerminar?.Invoke(AbrirRelatorio);
+ return;
+ }
+
+ Indice++;
+ }
+
+ [RelayCommand]
+ private void Voltar()
+ {
+ if (!EhPrimeira)
+ Indice--;
+ }
+
+ [RelayCommand]
+ private void Pular() => AoTerminar?.Invoke(false);
+
+ partial void OnIndiceChanged(int value)
+ {
+ OnPropertyChanged(nameof(Atual));
+ OnPropertyChanged(nameof(EhPrimeira));
+ OnPropertyChanged(nameof(EhUltima));
+ OnPropertyChanged(nameof(TextoDoAvancar));
+ OnPropertyChanged(nameof(Passo));
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/PageViewModelBase.cs b/GameBoost/src/GameBoost.App/ViewModels/PageViewModelBase.cs
new file mode 100644
index 0000000..38b114f
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/PageViewModelBase.cs
@@ -0,0 +1,39 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace GameBoost.App.ViewModels;
+
+///
+/// Base de toda pagina do menu lateral (secao 6 do spec). O shell so conhece
+/// este contrato: titulo, icone e se a pagina ja existe de verdade.
+///
+public abstract partial class PageViewModelBase : ObservableObject
+{
+ /// Nome curto, como aparece no menu lateral.
+ public abstract string Nome { get; }
+
+ /// Titulo da pagina, no cabecalho.
+ public abstract string Titulo { get; }
+
+ /// Uma linha explicando o que a pagina faz.
+ public abstract string Subtitulo { get; }
+
+ /// Glifo da fonte Segoe Fluent Icons / Segoe MDL2 Assets.
+ public abstract string Icone { get; }
+
+ /// Falso enquanto o modulo nao existe: a pagina mostra o aviso de fase.
+ public virtual bool Implementada => true;
+
+ /// Chamado toda vez que a pagina entra em foco no shell.
+ public virtual void AoEntrar()
+ {
+ }
+
+ ///
+ /// Chamado ao sair da pagina. Existe para quem liga trabalho continuo
+ /// poder desligar: o monitoramento do Diagnostico nao deve seguir rodando
+ /// enquanto o usuario le a tela de Limpeza.
+ ///
+ public virtual void AoSair()
+ {
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/PlaceholderPageViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/PlaceholderPageViewModel.cs
new file mode 100644
index 0000000..8b97d90
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/PlaceholderPageViewModel.cs
@@ -0,0 +1,39 @@
+namespace GameBoost.App.ViewModels;
+
+///
+/// Pagina de modulo que ainda nao existe. Mostra o que vai fazer e em qual fase
+/// chega, em vez de sumir do menu: o usuario ve o mapa inteiro do produto.
+///
+public sealed class PlaceholderPageViewModel : PageViewModelBase
+{
+ public PlaceholderPageViewModel(
+ string nome,
+ string titulo,
+ string subtitulo,
+ string icone,
+ int fase,
+ string secaoDoSpec,
+ IReadOnlyList oQueVaiFazer)
+ {
+ Nome = nome;
+ Titulo = titulo;
+ Subtitulo = subtitulo;
+ Icone = icone;
+ Fase = fase;
+ SecaoDoSpec = secaoDoSpec;
+ OQueVaiFazer = oQueVaiFazer;
+ }
+
+ public override string Nome { get; }
+ public override string Titulo { get; }
+ public override string Subtitulo { get; }
+ public override string Icone { get; }
+ public override bool Implementada => false;
+
+ public int Fase { get; }
+ public string SecaoDoSpec { get; }
+ public IReadOnlyList OQueVaiFazer { get; }
+
+ public string AvisoDeFase => $"Disponivel na Fase {Fase}";
+ public string Referencia => $"Especificado na secao {SecaoDoSpec} do GAMEBOOST_V2_SPEC.md";
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/RedePageViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/RedePageViewModel.cs
new file mode 100644
index 0000000..4127be1
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/RedePageViewModel.cs
@@ -0,0 +1,241 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GameBoost.Core.Modules;
+using GameBoost.Core.Modules.Network;
+using GameBoost.Core.State;
+
+namespace GameBoost.App.ViewModels;
+
+/// Uma linha da tabela de latência.
+public sealed record LinhaDePing(string Alvo, string Latencia, string Jitter, string Perda, string Leitura);
+
+/// Uma linha da tabela de DNS.
+public sealed record LinhaDeDns(string Nome, string Endereco, string Tempo, bool EmUso, string Comparacao);
+
+/// Uma linha de quem está conectado agora.
+public sealed record LinhaDeConexao(string Processo, string Conexoes, string Destino);
+
+///
+/// Página de rede (seção 5.9).
+///
+/// Diferente das outras páginas de módulo, esta tem conteúdo acima da lista: as
+/// medições. A lista de ações sozinha não resolveria — "trocar de DNS" só faz
+/// sentido depois de ver a tabela que mostra quanto cada um demora.
+///
+public sealed partial class RedePageViewModel : ModulePageViewModel
+{
+ private readonly NetworkModule _modulo;
+
+ public RedePageViewModel(NetworkModule modulo, IStateBackup backup)
+ : base(modulo, backup)
+ {
+ _modulo = modulo;
+ }
+
+ public override string Nome => "Rede";
+ public override string Titulo => "Rede";
+ public override string Subtitulo => "Latência, jitter, perda e DNS medidos agora, na sua conexão.";
+ public override string Icone => "\uE839";
+
+ public ObservableCollection Pings { get; } = new();
+ public ObservableCollection Dns { get; } = new();
+ public ObservableCollection Conexoes { get; } = new();
+
+ [ObservableProperty] private string? _wifi;
+ [ObservableProperty] private bool _temMedicao;
+
+ // ---- Bloco "Posso jogar online?" ----
+ [ObservableProperty] private string _nat = string.Empty;
+ [ObservableProperty] private string _natExplicacao = string.Empty;
+ [ObservableProperty] private string _natSemaforo = "cinza";
+ [ObservableProperty] private bool _temNat;
+ public ObservableCollection Causas { get; } = new();
+
+ // ---- Bloco "Velocidade" ----
+ [ObservableProperty] private string _velocidade = string.Empty;
+ [ObservableProperty] private bool _medindoVelocidade;
+ [ObservableProperty] private bool _velocidadePermitida;
+
+ public string TextoDoBotaoDeVelocidade => VelocidadePermitida
+ ? "Testar velocidade"
+ : "Testar velocidade (precisa de permissão)";
+
+ ///
+ /// O teste de velocidade é um botão à parte, e não parte de Medir, porque
+ /// ele transfere quase 100 MB. Quem está com franquia contada não pode ter
+ /// isso acontecendo sem pedir.
+ ///
+ [RelayCommand]
+ public async Task TestarVelocidadeAsync()
+ {
+ if (MedindoVelocidade)
+ return;
+
+ MedindoVelocidade = true;
+ Velocidade = "Medindo...";
+
+ try
+ {
+ var progresso = new Progress(texto => Velocidade = texto);
+ var resultado = await _modulo.MedirVelocidadeAsync(progresso, CancellationToken.None);
+
+ Velocidade = resultado.Funcionou
+ ? $"Download {resultado.DownloadMbps} Mbps · upload {resultado.UploadMbps} Mbps "
+ + $"· {resultado.BytesBaixados / 1024 / 1024} MB em {resultado.Duracao.TotalSeconds:0} s"
+ : resultado.Erro ?? "Não foi possível medir.";
+ }
+ catch (OperationCanceledException)
+ {
+ Velocidade = "Teste cancelado.";
+ }
+ finally
+ {
+ MedindoVelocidade = false;
+ VelocidadePermitida = _modulo.VelocidadePermitida;
+ }
+ }
+
+ public override void AoEntrar()
+ {
+ base.AoEntrar();
+ VelocidadePermitida = _modulo.VelocidadePermitida;
+
+ if (Velocidade.Length == 0)
+ {
+ Velocidade = VelocidadePermitida
+ ? "Não medido ainda. O teste baixa e envia cerca de 100 MB."
+ : "O teste de velocidade usa a internet e está desligado por padrão. "
+ + "Ligue em Configurações, se quiser.";
+ }
+ }
+
+ protected override void AposVarrer(ScanResult resultado)
+ {
+ Pings.Clear();
+ Dns.Clear();
+ Conexoes.Clear();
+
+ foreach (var p in _modulo.UltimoPing)
+ {
+ Pings.Add(new LinhaDePing(
+ $"{p.Rotulo} ({p.Alvo})",
+ p.Respondeu ? $"{p.MediaMs} ms" : "sem resposta",
+ p.Respondeu ? $"{p.JitterMs} ms" : "—",
+ p.Respondeu ? $"{p.PerdaPercentual}%" : "—",
+ p.Leitura));
+ }
+
+ var atual = _modulo.UltimoDns.FirstOrDefault(d => d.EmUso && d.Respondeu);
+
+ foreach (var d in _modulo.UltimoDns)
+ {
+ var comparacao = !d.Respondeu
+ ? "não respondeu"
+ : d.EmUso || atual is null
+ ? string.Empty
+ : d.MediaMs < atual.MediaMs
+ ? $"{Math.Round(atual.MediaMs - d.MediaMs, 1)} ms mais rápido"
+ : $"{Math.Round(d.MediaMs - atual.MediaMs, 1)} ms mais lento";
+
+ Dns.Add(new LinhaDeDns(
+ d.Nome, d.Endereco,
+ d.Respondeu ? $"{d.MediaMs} ms" : "—",
+ d.EmUso, comparacao));
+ }
+
+ foreach (var c in _modulo.UltimosConsumidores)
+ Conexoes.Add(new LinhaDeConexao(c.Nome, c.Conexoes.ToString(), c.PrincipalDestino));
+
+ var w = _modulo.UltimoWifi;
+
+ Wifi = w is null
+ ? NetworkDiagnostics.ConectadoPorCabo()
+ ? "Conectado por cabo. Para jogo competitivo é o melhor que existe."
+ : null
+ : $"Wi-Fi: {w.Ssid} · {w.Padrao} · {w.Banda} · sinal {w.SinalPercentual}%";
+
+ TemMedicao = Pings.Count > 0;
+
+ var n = _modulo.UltimoNat;
+ Causas.Clear();
+
+ if (n is null)
+ {
+ TemNat = false;
+ }
+ else
+ {
+ TemNat = true;
+ Nat = n.NomeCurto;
+ NatExplicacao = n.Explicacao;
+ NatSemaforo = n.Semaforo;
+
+ // As causas só aparecem quando **são** causa. Listar "UPnP: ligado"
+ // num NAT aberto seria encher a tela de verde inútil e esconder a
+ // linha que importa quando houver uma.
+ if (n.Tipo == TipoDeNat.Cgnat)
+ Causas.Add("CGNAT da operadora: o NAT que atrapalha não está na sua casa.");
+
+ if (n.SaltosPrivados >= 2)
+ Causas.Add($"{n.SaltosPrivados} saltos privados antes da internet: há dois roteadores em série.");
+
+ if (!n.UpnpDisponivel && n.Tipo is TipoDeNat.Estrito or TipoDeNat.Moderado)
+ Causas.Add("O roteador não respondeu por UPnP: os jogos não conseguem abrir porta sozinhos.");
+
+ if (n.Teredo == EstadoTeredo.Desativado)
+ Causas.Add("Teredo desativado: multiplayer de Xbox e Game Pass no PC não conecta.");
+
+ if (n.PerfisDeFirewallAtivos.Count == 0)
+ Causas.Add("Nenhum perfil do firewall do Windows está ligado. Isso não é otimização, é risco.");
+
+ foreach (var ruim in _modulo.UltimoPing.Where(p => p.EhProblema))
+ {
+ Causas.Add($"{ruim.Rotulo}: jitter de {ruim.JitterMs} ms e {ruim.PerdaPercentual}% de perda. "
+ + "Acima de 15 ms de jitter ou 1% de perda o jogo engasga.");
+ }
+
+ if (Causas.Count == 0 && n.Tipo is TipoDeNat.Aberto or TipoDeNat.SemNat)
+ Causas.Add("Nenhum problema de conectividade encontrado. Dá para hospedar partida e entrar em festa.");
+ }
+
+ // A classe base joga todos os avisos na barra de status. Aqui isso
+ // duplicaria a explicação do NAT, que já tem bloco próprio no topo, e o
+ // texto ainda sairia cortado no meio da frase por falta de espaço.
+ Status = resultado.Itens.Count == 0
+ ? "Medição concluída. Nada a ajustar."
+ : $"Medição concluída. {resultado.Itens.Count} ajuste(s) disponíveis abaixo.";
+ }
+
+ protected override string MontarConfirmacao(IReadOnlyList selecionados)
+ {
+ var nomes = string.Join("\n ", selecionados.Select(i => i.Titulo));
+ var winsock = selecionados.Any(i => i.Item.Id == "rede:winsock");
+ var dns = selecionados.Count(i => i.Item.Id.StartsWith("dns:", StringComparison.Ordinal));
+
+ var texto = $"Aplicar {selecionados.Count} ação(ões) de rede?\n\n {nomes}\n\n";
+
+ if (dns > 1)
+ {
+ texto += "Você marcou mais de um servidor de DNS. Só um pode ser o primário: "
+ + "o último da lista é o que vai valer.\n\n";
+ }
+
+ if (dns > 0)
+ {
+ texto += "Trocar o DNS não muda o ping do jogo. O jogo resolve o nome do servidor "
+ + "uma vez e depois fala direto com ele por IP. O que melhora é o tempo de "
+ + "abrir páginas e de o launcher encontrar os servidores.\n\n";
+ }
+
+ if (winsock)
+ {
+ texto += "ATENÇÃO: resetar o Winsock derruba a rede até você reiniciar o "
+ + "computador, apaga configurações de VPN e proxy, e não tem desfazer "
+ + "automático. Só faz sentido se a máquina já está sem internet.\n\n";
+ }
+
+ texto += "Continuar?";
+ return texto;
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/ShellViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/ShellViewModel.cs
new file mode 100644
index 0000000..fed2e85
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/ShellViewModel.cs
@@ -0,0 +1,284 @@
+using System.Collections.ObjectModel;
+using System.IO;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GameBoost.Core;
+
+namespace GameBoost.App.ViewModels;
+
+///
+/// Shell da secao 6: navegacao lateral com as 11 entradas, uma pagina por vez.
+///
+/// As paginas dos modulos que ainda nao existem sao placeholders explicando o
+/// que farao e em qual fase chegam. Elas ficam no menu de proposito: o usuario
+/// ve o mapa inteiro do produto desde a primeira versao.
+///
+public sealed partial class ShellViewModel : ObservableObject
+{
+ public ShellViewModel(
+ InicioPageViewModel inicio,
+ DiagnosticoPageViewModel diagnostico,
+ LimpezaPageViewModel limpeza,
+ EspacoPageViewModel espaco,
+ AppsPageViewModel apps,
+ InicializacaoPageViewModel inicializacao,
+ JogosPageViewModel jogos,
+ Core.Modules.Profiles.ProfileRunner executorDePerfil,
+ Core.Modules.Profiles.ProfileStore perfis,
+ Core.Modules.Profiles.GameWatcher vigia,
+ TweaksPageViewModel tweaks,
+ RedePageViewModel rede,
+ FerramentasPageViewModel ferramentas,
+ ConfiguracoesPageViewModel configuracoes)
+ {
+ Paginas = new ObservableCollection
+ {
+ inicio,
+
+ diagnostico,
+
+ limpeza,
+
+ espaco,
+
+ apps,
+
+ inicializacao,
+
+ jogos,
+
+ tweaks,
+
+ rede,
+
+ ferramentas,
+
+ configuracoes
+ };
+
+ _inicio = inicio;
+ _jogos = jogos;
+ _executorDePerfil = executorDePerfil;
+ _perfis = perfis;
+ _vigia = vigia;
+ _paginaAtual = Paginas[0];
+ EhAdministrador = CoreServices.RodandoComoAdministrador();
+ }
+
+ private readonly InicioPageViewModel _inicio;
+ private readonly JogosPageViewModel _jogos;
+ private readonly Core.Modules.Profiles.ProfileRunner _executorDePerfil;
+ private readonly Core.Modules.Profiles.ProfileStore _perfis;
+ private readonly Core.Modules.Profiles.GameWatcher _vigia;
+
+ public ObservableCollection Paginas { get; }
+
+ [ObservableProperty] private PageViewModelBase _paginaAtual;
+ [ObservableProperty] private bool _ehAdministrador;
+
+ public string AvisoDePrivilegio => EhAdministrador
+ ? "Executando como administrador."
+ : "Modo somente leitura: sem privilegios de administrador.";
+
+ [RelayCommand]
+ private void Navegar(PageViewModelBase? pagina)
+ {
+ if (pagina is not null)
+ PaginaAtual = pagina;
+ }
+
+ ///
+ /// AoEntrar tem que ficar aqui, e nao no comando Navegar: o menu lateral
+ /// troca de pagina pelo binding de SelectedItem, que nunca passa pelo
+ /// comando. Com o gancho no comando, o Diagnostico nao comecava a coletar
+ /// e o Historico nao recarregava ao abrir a pagina.
+ ///
+ partial void OnPaginaAtualChanged(PageViewModelBase? oldValue, PageViewModelBase newValue)
+ {
+ oldValue?.AoSair();
+ newValue?.AoEntrar();
+ }
+
+ // ==================================================================
+ // Bandeja (secao 6)
+ // ==================================================================
+
+ ///
+ /// Menu da bandeja: liga ou desliga o Modo Game.
+ ///
+ /// Ligar leva para a tela de Inicio antes de agir. A confirmacao do Modo
+ /// Game lista o que vai ser fechado, e essa lista **precisa** ser vista:
+ /// ativar direto da bandeja, sem a tela, seria fechar programas do usuario
+ /// a partir de um menu de dois cliques.
+ ///
+ public void AlternarModoGamePelaBandeja()
+ {
+ PaginaAtual = _inicio;
+
+ if (_inicio.ModoGameAtivo)
+ {
+ _inicio.ReverterCommand.Execute(null);
+ return;
+ }
+
+ // Sem varredura previa nao ha o que confirmar.
+ if (_inicio.Itens.Count == 0)
+ _inicio.VarrerCommand.Execute(null);
+ }
+
+ /// Menu da bandeja: libera memoria, sem tocar em mais nada.
+ public void LimparRamPelaBandeja()
+ {
+ PaginaAtual = _inicio;
+ _inicio.LimparRamCommand.Execute(null);
+ }
+
+ public void AbrirRelatorioInicial()
+ {
+ PaginaAtual = _inicio;
+ _inicio.AnalisarSaudeCommand.Execute(null);
+ }
+
+ // ==================================================================
+ // Deteccao automatica de jogo (secao 5.1)
+ // ==================================================================
+
+ [ObservableProperty] private string? _convite;
+
+ public event Action? AoPedirAviso;
+
+ ///
+ /// O vigia achou um jogo. Perfil com "ativa sozinho" aplica direto; todo o
+ /// resto **pergunta** (regra 3).
+ ///
+ public void JogoDetectado(Core.Modules.Profiles.JogoAbriu evento)
+ {
+ JogoDetectadoAgora = evento;
+
+ var nome = evento.Perfil?.Nome ?? evento.Jogo.Process.Name;
+
+ if (evento.Perfil is { Automatico: true })
+ {
+ Convite = null;
+ AoPedirAviso?.Invoke("GameBoost",
+ $"{nome} detectado. O perfil dele ativa sozinho, e tudo volta ao normal quando o jogo fechar.");
+ return;
+ }
+
+ Convite = evento.Perfil is null
+ ? $"{nome} está rodando. Ativar o Modo Game para ele?"
+ : $"{nome} está rodando. Aplicar o perfil deste jogo?";
+
+ AoPedirAviso?.Invoke("GameBoost", Convite);
+ }
+
+ /// Guarda o evento inteiro; Convite e so o texto da faixa.
+ public Core.Modules.Profiles.JogoAbriu? JogoDetectadoAgora { get; private set; }
+
+ ///
+ /// O jogo saiu: desfazer o que o perfil aplicou.
+ ///
+ /// A primeira versão só limpava o convite e anunciava que "tudo foi
+ /// desfeito" — sem desfazer nada. Prioridade, afinidade e tweaks ficariam
+ /// de pé até alguém reverter à mão, e o aviso na tela estaria mentindo.
+ ///
+ public void JogoEncerrado(Core.Modules.Profiles.JogoFechou evento)
+ {
+ Convite = null;
+ JogoDetectadoAgora = null;
+
+ // Saber ANTES se havia sessao: depois do Desfazer nao da mais para
+ // distinguir "nao havia perfil" de "havia, mas nada sobrou para
+ // restaurar".
+ var haviaPerfil = _executorDePerfil.EmSessao;
+ var feitos = _executorDePerfil.Desfazer();
+
+ var texto = $"{evento.Nome} fechou depois de {evento.Duracao.TotalMinutes:0} minutos.";
+
+ // Só afirmar que desfez quando desfez mesmo, e nunca negar que havia
+ // perfil quando havia.
+ if (feitos.Count > 0)
+ {
+ texto += $" Desfeito: {string.Join(", ", feitos)}.";
+ }
+ else if (haviaPerfil)
+ {
+ // Prioridade e afinidade morrem junto com o processo: quando o jogo
+ // fecha primeiro, nao sobra o que restaurar. Isso e o esperado, nao
+ // uma falha.
+ texto += " O perfil saiu junto com o jogo: prioridade e afinidade "
+ + "valem enquanto o processo existe.";
+ }
+ else
+ {
+ texto += " Não havia perfil aplicado.";
+ }
+
+ AoPedirAviso?.Invoke("GameBoost", texto);
+ }
+
+ ///
+ /// Aceitar o convite aplica o perfil do jogo e leva para o Modo Game.
+ ///
+ /// A primeira versão só navegava para a tela Início. O perfil nunca era
+ /// aplicado: prioridade, afinidade e tweaks ficavam parados no arquivo, e o
+ /// `ProfileRunner` não era chamado por ninguém.
+ ///
+ [RelayCommand]
+ private void AceitarConvite()
+ {
+ var evento = JogoDetectadoAgora;
+ Convite = null;
+
+ if (evento is null)
+ {
+ AlternarModoGamePelaBandeja();
+ return;
+ }
+
+ AplicarPerfil(evento);
+ AlternarModoGamePelaBandeja();
+ }
+
+ ///
+ /// Aplica o perfil do jogo, criando um padrão quando ainda não existe.
+ ///
+ /// Criar aqui é o que faz a segunda vez ser diferente da primeira: o perfil
+ /// nasce com o executável certo, vindo do processo de verdade, e não de um
+ /// palpite sobre qual `.exe` da pasta é o jogo.
+ ///
+ private void AplicarPerfil(Core.Modules.Profiles.JogoAbriu evento)
+ {
+ var perfil = evento.Perfil;
+
+ if (perfil is null)
+ {
+ perfil = Core.Modules.Profiles.GameProfile.Padrao(
+ evento.Jogo.Process.Name,
+ evento.Jogo.Process.Name,
+ Path.GetDirectoryName(evento.Jogo.Process.ExecutablePath),
+ null);
+ }
+
+ try
+ {
+ _executorDePerfil.Aplicar(perfil, evento.Jogo.Process.Pid);
+ _perfis.Salvar(perfil);
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or UnauthorizedAccessException)
+ {
+ AoPedirAviso?.Invoke("GameBoost", $"Não foi possível aplicar o perfil: {ex.Message}");
+ }
+ }
+
+ [RelayCommand]
+ private void RecusarConvite()
+ {
+ // Recusar tem que valer para a sessão: sem isto a pergunta voltaria
+ // dois segundos depois, e de novo, e de novo.
+ if (JogoDetectadoAgora is not null)
+ _vigia.Recusar(JogoDetectadoAgora.Jogo.Process.Name);
+
+ Convite = null;
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/ViewModels/TweaksPageViewModel.cs b/GameBoost/src/GameBoost.App/ViewModels/TweaksPageViewModel.cs
new file mode 100644
index 0000000..ce5f105
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/ViewModels/TweaksPageViewModel.cs
@@ -0,0 +1,78 @@
+using GameBoost.Core.Modules.Tweaks;
+using GameBoost.Core.State;
+
+namespace GameBoost.App.ViewModels;
+
+///
+/// Página de tweaks e serviços (seções 5.8 e 5.7).
+///
+/// A confirmação desta tela é a mais séria do app depois da do desinstalador:
+/// aqui o usuário está alterando o Windows, não apagando arquivo. Por isso ela
+/// separa o que precisa de reinício, o que é de risco médio ou alto, e diz
+/// exatamente onde o estado anterior ficou guardado.
+///
+public sealed class TweaksPageViewModel : ModulePageViewModel
+{
+ public TweaksPageViewModel(TweaksModule modulo, IStateBackup backup)
+ : base(modulo, backup)
+ {
+ }
+
+ public override string Nome => "Tweaks";
+ public override string Titulo => "Ajustes do Windows";
+ public override string Subtitulo => "Cada ajuste com o efeito real, não com a promessa. Nada vem marcado.";
+ public override string Icone => "\uE90F";
+
+ ///
+ /// Ler o estado de 24 ajustes custa milissegundos: registro e consulta de
+ /// servico. Vale revarrer sozinho para o badge de cada linha mostrar como
+ /// as coisas ficaram, e nao como estavam.
+ ///
+ protected override bool RevarrerAposAgir => true;
+
+ protected override string MontarConfirmacao(IReadOnlyList selecionados)
+ {
+ var nomes = string.Join("\n ", selecionados.Select(i => i.Titulo));
+
+ var reboot = selecionados
+ .Select(i => i.Item.Payload)
+ .OfType()
+ .Where(e => e.Definicao.ExigeReboot)
+ .Select(e => e.Definicao.Nome)
+ .ToList();
+
+ var servicos = selecionados.Count(i => i.Item.Id.StartsWith("servico:", StringComparison.Ordinal));
+
+ var texto = $"Aplicar {selecionados.Count} ajuste(s)?\n\n {nomes}\n\n";
+
+ if (servicos > 0)
+ {
+ texto += $"{servicos} deles mudam o tipo de inicialização de um serviço do Windows. "
+ + "O estado atual de cada um fica guardado e volta pelo botão Reverter.\n\n";
+ }
+
+ if (reboot.Count > 0)
+ {
+ texto += $"{reboot.Count} só passa(m) a valer depois de reiniciar o computador:\n "
+ + string.Join("\n ", reboot)
+ + "\n\nAté lá, nada muda na prática.\n\n";
+ }
+
+ var arriscados = selecionados
+ .Where(i => i.Item.Risco != Core.Modules.RiskLevel.Baixo)
+ .ToList();
+
+ if (arriscados.Count > 0)
+ {
+ texto += "Ajustes de risco médio ou alto nesta seleção:\n "
+ + string.Join("\n ", arriscados.Select(i => $"{i.Titulo} — {i.Item.ComoDesfazer}"))
+ + "\n\n";
+ }
+
+ texto += "Tudo isto é reversível: o valor anterior de cada chave vai para o histórico "
+ + "antes de qualquer escrita, e Configurações → Histórico desfaz um por um.\n\n"
+ + "Continuar?";
+
+ return texto;
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Views/AppsPage.xaml b/GameBoost/src/GameBoost.App/Views/AppsPage.xaml
new file mode 100644
index 0000000..dd28318
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/AppsPage.xaml
@@ -0,0 +1,304 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Views/AppsPage.xaml.cs b/GameBoost/src/GameBoost.App/Views/AppsPage.xaml.cs
new file mode 100644
index 0000000..42d9b33
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/AppsPage.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows.Controls;
+
+namespace GameBoost.App.Views;
+
+public partial class AppsPage : UserControl
+{
+ public AppsPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Views/ConfiguracoesPage.xaml b/GameBoost/src/GameBoost.App/Views/ConfiguracoesPage.xaml
new file mode 100644
index 0000000..c3347a5
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/ConfiguracoesPage.xaml
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Views/ConfiguracoesPage.xaml.cs b/GameBoost/src/GameBoost.App/Views/ConfiguracoesPage.xaml.cs
new file mode 100644
index 0000000..aa031c7
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/ConfiguracoesPage.xaml.cs
@@ -0,0 +1,12 @@
+using System.Windows;
+using System.Windows.Controls;
+
+namespace GameBoost.App.Views;
+
+public partial class ConfiguracoesPage : UserControl
+{
+ public ConfiguracoesPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Views/DiagnosticoPage.xaml b/GameBoost/src/GameBoost.App/Views/DiagnosticoPage.xaml
new file mode 100644
index 0000000..7dcbefd
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/DiagnosticoPage.xaml
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Views/DiagnosticoPage.xaml.cs b/GameBoost/src/GameBoost.App/Views/DiagnosticoPage.xaml.cs
new file mode 100644
index 0000000..a7f19af
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/DiagnosticoPage.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows.Controls;
+
+namespace GameBoost.App.Views;
+
+public partial class DiagnosticoPage : UserControl
+{
+ public DiagnosticoPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Views/EspacoPage.xaml b/GameBoost/src/GameBoost.App/Views/EspacoPage.xaml
new file mode 100644
index 0000000..3d92026
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/EspacoPage.xaml
@@ -0,0 +1,257 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Views/EspacoPage.xaml.cs b/GameBoost/src/GameBoost.App/Views/EspacoPage.xaml.cs
new file mode 100644
index 0000000..fdbcdb9
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/EspacoPage.xaml.cs
@@ -0,0 +1,17 @@
+using System.Windows.Controls;
+using GameBoost.App.ViewModels;
+
+namespace GameBoost.App.Views;
+
+public partial class EspacoPage : UserControl
+{
+ public EspacoPage()
+ {
+ InitializeComponent();
+
+ // O treemap avisa por evento, e nao por binding: navegar e passar o
+ // mouse sao interacoes visuais, nao estado a ser observado.
+ Mapa.AoEntrarNaPasta += no => (DataContext as EspacoPageViewModel)?.Entrar(no);
+ Mapa.AoPassarOMouse += no => (DataContext as EspacoPageViewModel)?.MostrarNoRodape(no);
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Views/FerramentasPage.xaml b/GameBoost/src/GameBoost.App/Views/FerramentasPage.xaml
new file mode 100644
index 0000000..f4d5a72
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/FerramentasPage.xaml
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Views/FerramentasPage.xaml.cs b/GameBoost/src/GameBoost.App/Views/FerramentasPage.xaml.cs
new file mode 100644
index 0000000..2e3f735
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/FerramentasPage.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows.Controls;
+
+namespace GameBoost.App.Views;
+
+public partial class FerramentasPage : UserControl
+{
+ public FerramentasPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Views/InicioPage.xaml b/GameBoost/src/GameBoost.App/Views/InicioPage.xaml
new file mode 100644
index 0000000..b3b47e6
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/InicioPage.xaml
@@ -0,0 +1,274 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Views/InicioPage.xaml.cs b/GameBoost/src/GameBoost.App/Views/InicioPage.xaml.cs
new file mode 100644
index 0000000..850f10a
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/InicioPage.xaml.cs
@@ -0,0 +1,12 @@
+using System.Windows;
+using System.Windows.Controls;
+
+namespace GameBoost.App.Views;
+
+public partial class InicioPage : UserControl
+{
+ public InicioPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Views/JogosPage.xaml b/GameBoost/src/GameBoost.App/Views/JogosPage.xaml
new file mode 100644
index 0000000..c98add0
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/JogosPage.xaml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Views/JogosPage.xaml.cs b/GameBoost/src/GameBoost.App/Views/JogosPage.xaml.cs
new file mode 100644
index 0000000..59805b8
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/JogosPage.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows.Controls;
+
+namespace GameBoost.App.Views;
+
+public partial class JogosPage : UserControl
+{
+ public JogosPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Views/ModuloPage.xaml b/GameBoost/src/GameBoost.App/Views/ModuloPage.xaml
new file mode 100644
index 0000000..34744a6
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/ModuloPage.xaml
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Views/ModuloPage.xaml.cs b/GameBoost/src/GameBoost.App/Views/ModuloPage.xaml.cs
new file mode 100644
index 0000000..676d192
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/ModuloPage.xaml.cs
@@ -0,0 +1,12 @@
+using System.Windows;
+using System.Windows.Controls;
+
+namespace GameBoost.App.Views;
+
+public partial class ModuloPage : UserControl
+{
+ public ModuloPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Views/OnboardingWindow.xaml b/GameBoost/src/GameBoost.App/Views/OnboardingWindow.xaml
new file mode 100644
index 0000000..836c0bb
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/OnboardingWindow.xaml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Views/OnboardingWindow.xaml.cs b/GameBoost/src/GameBoost.App/Views/OnboardingWindow.xaml.cs
new file mode 100644
index 0000000..5bee42c
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/OnboardingWindow.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows;
+
+namespace GameBoost.App.Views;
+
+public partial class OnboardingWindow : Window
+{
+ public OnboardingWindow()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Views/PlaceholderPage.xaml b/GameBoost/src/GameBoost.App/Views/PlaceholderPage.xaml
new file mode 100644
index 0000000..5403d03
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/PlaceholderPage.xaml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Views/PlaceholderPage.xaml.cs b/GameBoost/src/GameBoost.App/Views/PlaceholderPage.xaml.cs
new file mode 100644
index 0000000..777e494
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/PlaceholderPage.xaml.cs
@@ -0,0 +1,12 @@
+using System.Windows;
+using System.Windows.Controls;
+
+namespace GameBoost.App.Views;
+
+public partial class PlaceholderPage : UserControl
+{
+ public PlaceholderPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Views/RedePage.xaml b/GameBoost/src/GameBoost.App/Views/RedePage.xaml
new file mode 100644
index 0000000..6ab5164
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/RedePage.xaml
@@ -0,0 +1,261 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Views/RedePage.xaml.cs b/GameBoost/src/GameBoost.App/Views/RedePage.xaml.cs
new file mode 100644
index 0000000..0ec390c
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/RedePage.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows.Controls;
+
+namespace GameBoost.App.Views;
+
+public partial class RedePage : UserControl
+{
+ public RedePage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/GameBoost/src/GameBoost.App/Views/ShellWindow.xaml b/GameBoost/src/GameBoost.App/Views/ShellWindow.xaml
new file mode 100644
index 0000000..9a88b95
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/ShellWindow.xaml
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.App/Views/ShellWindow.xaml.cs b/GameBoost/src/GameBoost.App/Views/ShellWindow.xaml.cs
new file mode 100644
index 0000000..a6829ee
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/Views/ShellWindow.xaml.cs
@@ -0,0 +1,49 @@
+using System.ComponentModel;
+using System.Windows;
+
+namespace GameBoost.App.Views;
+
+public partial class ShellWindow : Window
+{
+ public ShellWindow()
+ {
+ InitializeComponent();
+ }
+
+ ///
+ /// Bandeja ligada, quando existe. Com ela, fechar a janela esconde em vez
+ /// de sair.
+ ///
+ public Services.BandejaDoSistema? Bandeja { get; set; }
+
+ ///
+ /// Fechar pelo X esconde para a bandeja, mas **só quando a bandeja está
+ /// visível**: se ela não estiver, o X fecha de verdade, senão o app viraria
+ /// um processo que não dá para encerrar pela interface.
+ ///
+ /// O aviso aparece uma vez. Programa que some sem dizer para onde foi é o
+ /// motivo de as pessoas irem no Gerenciador de Tarefas.
+ ///
+ protected override void OnClosing(CancelEventArgs e)
+ {
+ if (App.Current is App app && app.EsconderEmVezDeFechar)
+ {
+ e.Cancel = true;
+ Hide();
+
+ if (!_jaAvisou)
+ {
+ _jaAvisou = true;
+ Bandeja?.Avisar("GameBoost continua aberto",
+ "Ele fica na bandeja, ao lado do relógio. Para sair de vez, clique com o "
+ + "botão direito no ícone e escolha Sair.");
+ }
+
+ return;
+ }
+
+ base.OnClosing(e);
+ }
+
+ private bool _jaAvisou;
+}
diff --git a/GameBoost/src/GameBoost.App/app-dev.manifest b/GameBoost/src/GameBoost.App/app-dev.manifest
new file mode 100644
index 0000000..249928c
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/app-dev.manifest
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true/pm
+ permonitorv2
+ true
+
+
+
diff --git a/GameBoost/src/GameBoost.App/app.manifest b/GameBoost/src/GameBoost.App/app.manifest
new file mode 100644
index 0000000..ab9cd45
--- /dev/null
+++ b/GameBoost/src/GameBoost.App/app.manifest
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true/pm
+ permonitorv2
+ true
+
+
+
diff --git a/GameBoost/src/GameBoost.Cli/CommandLineOptions.cs b/GameBoost/src/GameBoost.Cli/CommandLineOptions.cs
new file mode 100644
index 0000000..a34ca29
--- /dev/null
+++ b/GameBoost/src/GameBoost.Cli/CommandLineOptions.cs
@@ -0,0 +1,148 @@
+namespace GameBoost.Cli;
+
+public enum CliCommand
+{
+ None,
+ Scan,
+ Report,
+ Clean,
+ RevertAll,
+ GameModeOn,
+ GameModeOff,
+ Help
+}
+
+///
+/// Parser dos argumentos da secao 8 do spec. Sem dependencia externa: um
+/// parser de 80 linhas custa menos que arrastar System.CommandLine para um
+/// publish single-file.
+///
+public sealed class CommandLineOptions
+{
+ public CliCommand Comando { get; private set; } = CliCommand.None;
+ public string? ArquivoDeSaida { get; private set; }
+ public string? Preset { get; private set; }
+ public IReadOnlyList Categorias { get; private set; } = Array.Empty();
+
+ /// Combina com qualquer comando: produz o mesmo relatorio sem tocar em nada.
+ public bool DryRun { get; private set; }
+
+ public bool Json { get; private set; }
+ public string? Erro { get; private set; }
+
+ public bool EhCli => Comando != CliCommand.None;
+
+ public static CommandLineOptions Parse(string[] args)
+ {
+ var o = new CommandLineOptions();
+ if (args.Length == 0)
+ return o;
+
+ for (var i = 0; i < args.Length; i++)
+ {
+ var arg = args[i];
+ var proximo = i + 1 < args.Length ? args[i + 1] : null;
+ var proximoEhValor = proximo is not null && !proximo.StartsWith("--", StringComparison.Ordinal);
+
+ switch (arg.ToLowerInvariant())
+ {
+ case "--scan":
+ o.Comando = CliCommand.Scan;
+ if (proximoEhValor) { o.ArquivoDeSaida = proximo; i++; }
+ break;
+
+ case "--report":
+ o.Comando = CliCommand.Report;
+ if (proximoEhValor) { o.ArquivoDeSaida = proximo; i++; }
+ break;
+
+ case "--clean":
+ o.Comando = CliCommand.Clean;
+ break;
+
+ case "--revert-all":
+ o.Comando = CliCommand.RevertAll;
+ break;
+
+ case "--gamemode":
+ if (!proximoEhValor)
+ {
+ o.Erro = "--gamemode exige on ou off.";
+ return o;
+ }
+
+ o.Comando = proximo!.ToLowerInvariant() switch
+ {
+ "on" => CliCommand.GameModeOn,
+ "off" => CliCommand.GameModeOff,
+ _ => CliCommand.None
+ };
+
+ if (o.Comando == CliCommand.None)
+ o.Erro = $"Valor invalido para --gamemode: {proximo}. Use on ou off.";
+
+ i++;
+ break;
+
+ case "--preset":
+ if (proximoEhValor) { o.Preset = proximo!.ToLowerInvariant(); i++; }
+ else o.Erro = "--preset exige um valor (seguro ou completo).";
+ break;
+
+ case "--categorias":
+ if (proximoEhValor)
+ {
+ o.Categorias = proximo!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ i++;
+ }
+ else
+ {
+ o.Erro = "--categorias exige a lista separada por virgula.";
+ }
+ break;
+
+ case "--dry-run":
+ o.DryRun = true;
+ break;
+
+ case "--json":
+ o.Json = true;
+ break;
+
+ case "--help":
+ case "-h":
+ case "/?":
+ o.Comando = CliCommand.Help;
+ break;
+
+ default:
+ if (arg.StartsWith("--", StringComparison.Ordinal))
+ o.Erro = $"Argumento desconhecido: {arg}";
+ break;
+ }
+ }
+
+ if (o.Preset is not null && o.Preset is not ("seguro" or "completo"))
+ o.Erro = $"Preset invalido: {o.Preset}. Use seguro ou completo.";
+
+ return o;
+ }
+
+ public static string TextoDeAjuda => """
+ GameBoost - central de otimizacao e manutencao do PC gamer
+
+ Uso:
+ GameBoost.exe abre a interface grafica
+ GameBoost.exe --scan [arquivo] relatorio de varredura do Modo Game
+ GameBoost.exe --report saida.html relatorio de saude completo
+ GameBoost.exe --clean --preset seguro limpeza com preset (seguro | completo)
+ GameBoost.exe --clean --categorias temp,wu,wer
+ GameBoost.exe --revert-all reverte tudo do state-backup
+ GameBoost.exe --gamemode on|off liga ou desliga o Modo Game
+
+ Modificadores:
+ --dry-run combina com qualquer comando, nao altera nada
+ --json saida em JSON em vez de texto
+ --help esta ajuda
+ """;
+}
diff --git a/GameBoost/src/GameBoost.Cli/CommandRunner.cs b/GameBoost/src/GameBoost.Cli/CommandRunner.cs
new file mode 100644
index 0000000..b15f150
--- /dev/null
+++ b/GameBoost/src/GameBoost.Cli/CommandRunner.cs
@@ -0,0 +1,348 @@
+using System.Text;
+using System.Text.Json;
+using GameBoost.Core;
+using GameBoost.Core.Modules;
+using GameBoost.Core.Modules.GameMode;
+using GameBoost.Core.Modules.Bottleneck;
+using GameBoost.Core.Modules.Cleaner;
+using GameBoost.Core.Modules.HealthReport;
+using GameBoost.Core.State;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace GameBoost.Cli;
+
+///
+/// Executa os comandos de linha. Vive numa biblioteca para que o mesmo
+/// GameBoost.exe atenda a UI e a CLI, como na v1 (--scan sem instalar nada a parte).
+///
+public sealed class CommandRunner
+{
+ private readonly IServiceProvider _services;
+ private readonly TextWriter _saida;
+
+ public CommandRunner(IServiceProvider services, TextWriter saida)
+ {
+ _services = services;
+ _saida = saida;
+ }
+
+ public async Task ExecutarAsync(CommandLineOptions opcoes, CancellationToken ct = default)
+ {
+ if (opcoes.Erro is not null)
+ {
+ _saida.WriteLine($"Erro: {opcoes.Erro}");
+ _saida.WriteLine();
+ _saida.WriteLine(CommandLineOptions.TextoDeAjuda);
+ return 2;
+ }
+
+ return opcoes.Comando switch
+ {
+ CliCommand.Help => Ajuda(),
+ CliCommand.Scan => await ScanAsync(opcoes, ct),
+ CliCommand.GameModeOn => await GameModeAsync(opcoes, ligar: true, ct),
+ CliCommand.GameModeOff => await GameModeAsync(opcoes, ligar: false, ct),
+ CliCommand.RevertAll => ReverterTudo(opcoes),
+ CliCommand.Report => await ReportAsync(opcoes, ct),
+ CliCommand.Clean => await CleanAsync(opcoes, ct),
+ _ => Ajuda()
+ };
+ }
+
+ private int Ajuda()
+ {
+ _saida.WriteLine(CommandLineOptions.TextoDeAjuda);
+ return 0;
+ }
+
+ private int NaoImplementado(string comando, string fase)
+ {
+ _saida.WriteLine($"{comando} ainda nao esta disponivel. Chega na {fase}.");
+ return 3;
+ }
+
+ ///
+ /// Presets da secao 8. "seguro" leva so o que o catalogo ja marca por
+ /// padrao; "completo" leva tudo que nao esta bloqueado, e por isso avisa
+ /// antes do que esta levando junto.
+ ///
+ private async Task CleanAsync(CommandLineOptions opcoes, CancellationToken ct)
+ {
+ if (!opcoes.DryRun && !CoreServices.RodandoComoAdministrador())
+ _saida.WriteLine("Aviso: sem privilegios de administrador, partes do sistema ficam de fora.");
+
+ var modulo = _services.GetRequiredService();
+
+ _saida.WriteLine("Medindo o que pode ser liberado...");
+ var scan = await modulo.ScanAsync(
+ new Progress(p => _saida.WriteLine($" {p.Percentual,3}% {p.Etapa}")), ct);
+
+ _saida.WriteLine();
+ _saida.WriteLine(scan.Resumo);
+ _saida.WriteLine();
+
+ var selecionados = Selecionar(scan, opcoes).ToList();
+
+ foreach (var item in scan.Itens)
+ {
+ var marca = item.Bloqueado ? "[x]" : selecionados.Contains(item.Id) ? "[*]" : "[ ]";
+ _saida.WriteLine($" {marca} {item.Titulo,-42} {item.GanhoEstimado,10} risco {item.Risco}");
+
+ if (item.MotivoBloqueio is not null)
+ _saida.WriteLine($" {item.MotivoBloqueio}");
+ }
+
+ if (selecionados.Count == 0)
+ {
+ _saida.WriteLine();
+ _saida.WriteLine("Nada selecionado. Use --preset seguro, --preset completo ou --categorias.");
+ return 0;
+ }
+
+ _saida.WriteLine();
+ var resultado = await modulo.ApplyAsync(selecionados, opcoes.DryRun, ct);
+
+ foreach (var acao in resultado.Acoes)
+ _saida.WriteLine($" {(acao.Sucesso ? "ok " : "FALHA")} {acao.Detalhe}");
+
+ _saida.WriteLine();
+ _saida.WriteLine(resultado.Resumo);
+ _saida.WriteLine(resultado.GanhoMedido);
+
+ return resultado.Falhas > 0 ? 1 : 0;
+ }
+
+ private static IEnumerable Selecionar(ScanResult scan, CommandLineOptions opcoes)
+ {
+ var disponiveis = scan.Itens.Where(i => !i.Bloqueado);
+
+ if (opcoes.Categorias.Count > 0)
+ {
+ // Casa pelo sufixo do id: "temp" pega temp-usuario e temp-sistema.
+ return disponiveis
+ .Where(i => opcoes.Categorias.Any(c =>
+ i.Id.Contains(c, StringComparison.OrdinalIgnoreCase)
+ || i.Categoria.Equals(c, StringComparison.OrdinalIgnoreCase)))
+ .Select(i => i.Id);
+ }
+
+ return opcoes.Preset switch
+ {
+ "completo" => disponiveis.Select(i => i.Id),
+ "seguro" => disponiveis.Where(i => i.PreMarcado).Select(i => i.Id),
+ _ => Enumerable.Empty()
+ };
+ }
+
+ private async Task ReportAsync(CommandLineOptions opcoes, CancellationToken ct)
+ {
+ var modulo = _services.GetRequiredService();
+
+ _saida.WriteLine("Medindo a maquina por alguns segundos...");
+
+ var progresso = new Progress(p => _saida.WriteLine($" {p.Percentual,3}% {p.Etapa}"));
+ var relatorio = await modulo.GerarAsync(progresso, ct);
+
+ var destino = opcoes.ArquivoDeSaida;
+
+ if (destino is null)
+ {
+ _saida.WriteLine();
+ _saida.WriteLine(opcoes.Json ? HtmlReportWriter.GerarJson(relatorio) : FormatarRelatorio(relatorio));
+ return 0;
+ }
+
+ // A extensao decide o formato, e --json continua valendo por cima dela.
+ var json = opcoes.Json || destino.EndsWith(".json", StringComparison.OrdinalIgnoreCase);
+ var conteudo = json ? HtmlReportWriter.GerarJson(relatorio) : HtmlReportWriter.GerarHtml(relatorio);
+
+ File.WriteAllText(destino, conteudo, new UTF8Encoding(false));
+
+ _saida.WriteLine();
+ _saida.WriteLine($"Nota geral: {relatorio.Pontuacao.NotaGeral} ({relatorio.Pontuacao.Conceito})");
+ _saida.WriteLine($"Relatorio gravado em {Path.GetFullPath(destino)}");
+ return 0;
+ }
+
+ private static string FormatarRelatorio(HealthSnapshot relatorio)
+ {
+ var sb = new StringBuilder();
+ var p = relatorio.Pontuacao;
+
+ sb.AppendLine("GameBoost - relatorio de saude");
+ sb.AppendLine($"Gerado em {relatorio.Momento:dd/MM/yyyy HH:mm:ss}");
+ sb.AppendLine();
+ sb.AppendLine($"Nota geral: {p.NotaGeral}/100 ({p.Conceito})");
+ sb.AppendLine();
+
+ foreach (var area in p.Areas)
+ sb.AppendLine($" {area.Nome,-26} {area.Nota,3}/100 {area.Conceito}");
+
+ sb.AppendLine();
+
+ if (p.Principais.Count == 0)
+ {
+ sb.AppendLine("Nada fora do lugar. A maquina esta bem cuidada.");
+ }
+ else
+ {
+ sb.AppendLine("Principais achados:");
+ sb.AppendLine();
+
+ foreach (var f in p.Principais)
+ {
+ var marca = f.Severidade switch
+ {
+ FindingSeverity.Critico => "[!]",
+ FindingSeverity.Atencao => "[*]",
+ _ => "[ ]"
+ };
+
+ sb.AppendLine($" {marca} {f.Titulo}");
+ sb.AppendLine($" {f.Detalhe}");
+ if (f.TextoDaAcao is not null)
+ sb.AppendLine($" Sugestao: {f.TextoDaAcao}");
+ sb.AppendLine();
+ }
+ }
+
+ sb.AppendLine("Nada foi alterado. Este comando so le o sistema.");
+ return sb.ToString();
+ }
+
+ private async Task ScanAsync(CommandLineOptions opcoes, CancellationToken ct)
+ {
+ var modulo = _services.GetRequiredService();
+ var resultado = await modulo.ScanAsync(null, ct);
+
+ var texto = opcoes.Json
+ ? SerializarScan(resultado)
+ : FormatarScan(resultado);
+
+ if (opcoes.ArquivoDeSaida is not null)
+ {
+ File.WriteAllText(opcoes.ArquivoDeSaida, texto, new UTF8Encoding(false));
+ _saida.WriteLine($"Relatorio gravado em {Path.GetFullPath(opcoes.ArquivoDeSaida)}");
+ }
+ else
+ {
+ _saida.WriteLine(texto);
+ }
+
+ return 0;
+ }
+
+ private static string FormatarScan(ScanResult resultado)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("GameBoost - relatorio de varredura");
+ sb.AppendLine($"Gerado em {resultado.Momento:dd/MM/yyyy HH:mm:ss}");
+ sb.AppendLine($"Administrador: {(CoreServices.RodandoComoAdministrador() ? "sim" : "nao (somente leitura)")}");
+ sb.AppendLine();
+ sb.AppendLine(resultado.Resumo);
+ sb.AppendLine();
+
+ foreach (var aviso in resultado.Avisos)
+ sb.AppendLine($" aviso: {aviso}");
+
+ if (resultado.Avisos.Count > 0)
+ sb.AppendLine();
+
+ foreach (var grupo in resultado.Itens.GroupBy(i => i.Categoria))
+ {
+ sb.AppendLine($"== {grupo.Key} ==");
+ foreach (var item in grupo)
+ {
+ var marca = item.Bloqueado ? "[x]" : item.PreMarcado ? "[*]" : "[ ]";
+ sb.AppendLine($" {marca} {item.Titulo,-28} {item.GanhoEstimado,-22} risco {item.Risco}");
+ sb.AppendLine($" {item.Descricao}");
+ if (item.MotivoBloqueio is not null)
+ sb.AppendLine($" protegido: {item.MotivoBloqueio}");
+ }
+
+ sb.AppendLine();
+ }
+
+ sb.AppendLine("Legenda: [*] vem pre-marcado [ ] aparece desmarcado [x] protegido, nao selecionavel");
+ sb.AppendLine("Nenhum processo foi encerrado. Este comando so le o sistema.");
+ return sb.ToString();
+ }
+
+ private static string SerializarScan(ScanResult resultado) =>
+ JsonSerializer.Serialize(new
+ {
+ gerado = resultado.Momento,
+ resumo = resultado.Resumo,
+ avisos = resultado.Avisos,
+ itens = resultado.Itens.Select(i => new
+ {
+ i.Id, i.Categoria, i.Titulo, i.Descricao,
+ risco = i.Risco.ToString(),
+ i.GanhoBytes, i.GanhoEstimado, i.PreMarcado, i.Bloqueado, i.MotivoBloqueio
+ })
+ }, new JsonSerializerOptions { WriteIndented = true });
+
+ private async Task GameModeAsync(CommandLineOptions opcoes, bool ligar, CancellationToken ct)
+ {
+ if (!opcoes.DryRun && !CoreServices.RodandoComoAdministrador())
+ {
+ _saida.WriteLine("Este comando precisa de privilegios de administrador.");
+ return 4;
+ }
+
+ var modulo = _services.GetRequiredService();
+
+ if (ligar)
+ {
+ var scan = await modulo.ScanAsync(null, ct);
+ var selecionados = scan.Itens.Where(i => i.PreMarcado && !i.Bloqueado).Select(i => i.Id).ToList();
+
+ _saida.WriteLine($"Ativando o Modo Game com {selecionados.Count} apps pre-marcados...");
+ var resultado = await modulo.ApplyAsync(selecionados, opcoes.DryRun, ct);
+ Imprimir(resultado);
+ return resultado.Falhas > 0 ? 1 : 0;
+ }
+
+ _saida.WriteLine("Desligando o Modo Game...");
+ var desligar = await modulo.RevertAsync(Array.Empty(), opcoes.DryRun, ct);
+ Imprimir(desligar);
+ return desligar.Falhas > 0 ? 1 : 0;
+ }
+
+ private int ReverterTudo(CommandLineOptions opcoes)
+ {
+ var rollback = _services.GetRequiredService();
+ var backup = _services.GetRequiredService();
+
+ if (!backup.TemPendencias)
+ {
+ _saida.WriteLine("Nada a reverter: nenhuma alteracao pendente no state-backup.");
+ return 0;
+ }
+
+ var resultados = rollback.ReverterTudo(opcoes.DryRun);
+
+ foreach (var r in resultados)
+ _saida.WriteLine($" {(r.Sucesso ? "ok " : "FALHA")} {r.Detalhe}");
+
+ var falhas = resultados.Count(r => !r.Sucesso);
+ _saida.WriteLine();
+ _saida.WriteLine(opcoes.DryRun
+ ? $"Dry-run: {resultados.Count} alteracoes seriam revertidas."
+ : $"{resultados.Count - falhas} de {resultados.Count} alteracoes revertidas.");
+
+ return falhas > 0 ? 1 : 0;
+ }
+
+ private void Imprimir(ApplyResult resultado)
+ {
+ foreach (var acao in resultado.Acoes)
+ _saida.WriteLine($" {(acao.Sucesso ? "ok " : "FALHA")} {acao.Detalhe}");
+
+ _saida.WriteLine();
+ _saida.WriteLine(resultado.Resumo);
+
+ if (!string.IsNullOrWhiteSpace(resultado.GanhoMedido))
+ _saida.WriteLine(resultado.GanhoMedido);
+ }
+}
diff --git a/GameBoost/src/GameBoost.Cli/GameBoost.Cli.csproj b/GameBoost/src/GameBoost.Cli/GameBoost.Cli.csproj
new file mode 100644
index 0000000..e9e9c3a
--- /dev/null
+++ b/GameBoost/src/GameBoost.Cli/GameBoost.Cli.csproj
@@ -0,0 +1,9 @@
+
+
+ net9.0-windows
+ GameBoost.Cli
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.Core/Abstractions/IClock.cs b/GameBoost/src/GameBoost.Core/Abstractions/IClock.cs
new file mode 100644
index 0000000..b3aa00c
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Abstractions/IClock.cs
@@ -0,0 +1,12 @@
+namespace GameBoost.Core.Abstractions;
+
+/// Relogio injetavel. Existe para tornar tempo testavel (regra 10 nao se aplica, mas testes exigem).
+public interface IClock
+{
+ DateTimeOffset Now { get; }
+}
+
+public sealed class SystemClock : IClock
+{
+ public DateTimeOffset Now => DateTimeOffset.Now;
+}
diff --git a/GameBoost/src/GameBoost.Core/Abstractions/IFileSystem.cs b/GameBoost/src/GameBoost.Core/Abstractions/IFileSystem.cs
new file mode 100644
index 0000000..6887d7c
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Abstractions/IFileSystem.cs
@@ -0,0 +1,19 @@
+namespace GameBoost.Core.Abstractions;
+
+///
+/// Toda escrita em disco passa por aqui. Os testes usam um fake que registra
+/// cada caminho tocado, garantindo a regra 2 (nunca deletar fora da whitelist).
+///
+public interface IFileSystem
+{
+ bool FileExists(string path);
+ bool DirectoryExists(string path);
+ string ReadAllText(string path);
+ void WriteAllText(string path, string content);
+ void CreateDirectory(string path);
+ void DeleteFile(string path);
+ void MoveFile(string source, string destination, bool overwrite);
+ long GetFileSize(string path);
+ IEnumerable EnumerateFiles(string path, string pattern, SearchOption option);
+ IEnumerable EnumerateDirectories(string path);
+}
diff --git a/GameBoost/src/GameBoost.Core/Abstractions/IMemoryService.cs b/GameBoost/src/GameBoost.Core/Abstractions/IMemoryService.cs
new file mode 100644
index 0000000..fd6f3ff
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Abstractions/IMemoryService.cs
@@ -0,0 +1,15 @@
+namespace GameBoost.Core.Abstractions;
+
+public sealed record MemorySnapshot(long TotalBytes, long AvailableBytes, long StandbyBytes)
+{
+ public long UsedBytes => TotalBytes - AvailableBytes;
+ public double UsedPercent => TotalBytes == 0 ? 0 : UsedBytes * 100.0 / TotalBytes;
+}
+
+public interface IMemoryService
+{
+ MemorySnapshot GetSnapshot();
+
+ /// Purga a Standby List do kernel. Exige SeProfileSingleProcessPrivilege (admin).
+ bool PurgeStandbyList();
+}
diff --git a/GameBoost/src/GameBoost.Core/Abstractions/IPowerService.cs b/GameBoost/src/GameBoost.Core/Abstractions/IPowerService.cs
new file mode 100644
index 0000000..00728da
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Abstractions/IPowerService.cs
@@ -0,0 +1,10 @@
+namespace GameBoost.Core.Abstractions;
+
+public sealed record PowerPlan(Guid Id, string Name);
+
+public interface IPowerService
+{
+ PowerPlan? GetActivePlan();
+ IReadOnlyList GetPlans();
+ bool SetActivePlan(Guid planId);
+}
diff --git a/GameBoost/src/GameBoost.Core/Abstractions/IProcessService.cs b/GameBoost/src/GameBoost.Core/Abstractions/IProcessService.cs
new file mode 100644
index 0000000..f9d6b02
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Abstractions/IProcessService.cs
@@ -0,0 +1,55 @@
+namespace GameBoost.Core.Abstractions;
+
+/// Instantaneo de um processo. Struct de dados pura, sem handle preso.
+public sealed record ProcessInfo(
+ int Pid,
+ string Name,
+ string? ExecutablePath,
+ string? CommandLine,
+ long WorkingSetBytes,
+ string? WindowTitle,
+ string? Company,
+ int SessionId);
+
+public interface IProcessService
+{
+ IReadOnlyList GetProcesses();
+ ProcessInfo? GetProcess(int pid);
+
+ /// Pede o fechamento normal (WM_CLOSE). Retorna true se o processo saiu dentro do timeout.
+ bool TryCloseGracefully(int pid, TimeSpan timeout);
+
+ /// Finaliza a forca. So deve ser chamado depois de TryCloseGracefully falhar.
+ bool Kill(int pid);
+
+ bool SetPriority(int pid, ProcessPriority priority);
+ ProcessPriority? GetPriority(int pid);
+
+ ///
+ /// Restringe o processo a um conjunto de nucleos, por indice.
+ ///
+ /// Lista vazia devolve o processo a todos os nucleos, que e como reverter.
+ /// Devolve false quando algum indice nao existe nesta maquina: um perfil
+ /// gravado num PC de 16 nucleos nao pode derrubar o jogo num de 4.
+ ///
+ bool SetAffinity(int pid, IReadOnlyList nucleos);
+
+ /// Nucleos em que o processo pode rodar agora.
+ IReadOnlyList GetAffinity(int pid);
+
+ /// Relanca um executavel na sessao do usuario. Usado para reabrir os apps essenciais.
+ bool Start(string executablePath, string? arguments, string? workingDirectory);
+
+ /// EmptyWorkingSet no processo. Retorna quantos bytes sairam do working set.
+ long TrimWorkingSet(int pid);
+}
+
+/// Realtime nao existe de proposito: travaria o Windows (secao 1 do spec).
+public enum ProcessPriority
+{
+ Idle,
+ BelowNormal,
+ Normal,
+ AboveNormal,
+ High
+}
diff --git a/GameBoost/src/GameBoost.Core/Abstractions/IRegistryService.cs b/GameBoost/src/GameBoost.Core/Abstractions/IRegistryService.cs
new file mode 100644
index 0000000..4f3c77c
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Abstractions/IRegistryService.cs
@@ -0,0 +1,33 @@
+namespace GameBoost.Core.Abstractions;
+
+public enum RegistryRoot
+{
+ ClassesRoot,
+ CurrentUser,
+ LocalMachine,
+ Users
+}
+
+///
+/// Acesso ao registro. Toda escrita exige que o chamador ja tenha gravado o
+/// valor anterior num ChangeRecord (regra 1).
+///
+public interface IRegistryService
+{
+ object? GetValue(RegistryRoot root, string subKey, string valueName);
+ void SetValue(RegistryRoot root, string subKey, string valueName, object value, RegistryValueKindLite kind);
+ void DeleteValue(RegistryRoot root, string subKey, string valueName);
+ bool KeyExists(RegistryRoot root, string subKey);
+ IReadOnlyList GetSubKeyNames(RegistryRoot root, string subKey);
+ IReadOnlyList GetValueNames(RegistryRoot root, string subKey);
+}
+
+public enum RegistryValueKindLite
+{
+ String,
+ ExpandString,
+ DWord,
+ QWord,
+ Binary,
+ MultiString
+}
diff --git a/GameBoost/src/GameBoost.Core/Abstractions/IServiceControllerService.cs b/GameBoost/src/GameBoost.Core/Abstractions/IServiceControllerService.cs
new file mode 100644
index 0000000..a672e20
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Abstractions/IServiceControllerService.cs
@@ -0,0 +1,21 @@
+namespace GameBoost.Core.Abstractions;
+
+public enum ServiceStartMode
+{
+ Boot,
+ System,
+ Automatic,
+ Manual,
+ Disabled
+}
+
+public sealed record ServiceInfo(string Name, string DisplayName, bool IsRunning, ServiceStartMode StartMode);
+
+public interface IServiceControllerService
+{
+ ServiceInfo? GetService(string name);
+ IReadOnlyList GetServices();
+ bool Stop(string name, TimeSpan timeout);
+ bool Start(string name, TimeSpan timeout);
+ bool SetStartMode(string name, ServiceStartMode mode);
+}
diff --git a/GameBoost/src/GameBoost.Core/CoreServices.cs b/GameBoost/src/GameBoost.Core/CoreServices.cs
new file mode 100644
index 0000000..96e9d73
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/CoreServices.cs
@@ -0,0 +1,139 @@
+using System.Diagnostics;
+using GameBoost.Core.Abstractions;
+using GameBoost.Core.Logging;
+using GameBoost.Core.Modules;
+using GameBoost.Core.Modules.Bottleneck;
+using GameBoost.Core.Modules.Cleaner;
+using GameBoost.Core.Modules.DiskAnalyzer;
+using GameBoost.Core.Modules.Startup;
+using GameBoost.Core.Modules.Drivers;
+using GameBoost.Core.Modules.Network;
+using GameBoost.Core.Modules.Profiles;
+using GameBoost.Core.Modules.Tweaks;
+using GameBoost.Core.Modules.Uninstaller;
+using GameBoost.Core.Modules.Tools;
+using GameBoost.Core.Modules.GameMode;
+using GameBoost.Core.Modules.HealthReport;
+using GameBoost.Core.Safety;
+using GameBoost.Core.Services;
+using GameBoost.Core.Settings;
+using GameBoost.Core.State;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace GameBoost.Core;
+
+///
+/// Composicao unica do Core. App e CLI montam o mesmo grafo de servicos, entao
+/// os dois se comportam igual (regra 4.2: nada de static).
+///
+public static class CoreServices
+{
+ ///
+ /// Deixe nulo em producao para resolver %LOCALAPPDATA% (ou .\data no modo
+ /// portatil). Os testes de integracao passam uma pasta temporaria para nao
+ /// escrever nos dados reais do usuario.
+ ///
+ public static IServiceCollection AddGameBoostCore(this IServiceCollection services, AppPaths? paths = null)
+ {
+ paths ??= AppPaths.Resolve();
+ paths.EnsureDirectories();
+
+ services.AddSingleton(paths);
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService().Load());
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddSingleton(sp =>
+ {
+ using var atual = Process.GetCurrentProcess();
+ Native.NativeMethodsBridge.TryGetSessionId(atual.Id, out var sessao);
+
+ return new SafetyGuard(
+ sp.GetRequiredService(),
+ sessao,
+ atual.Id,
+ sp.GetRequiredService().Detectar());
+ });
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Fase 1: diagnostico de gargalos e relatorio de saude.
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(sp => new FindingEngine(
+ sp.GetRequiredService(),
+ sp.GetRequiredService()));
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Fase 2: limpeza.
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Fase 3: analisador de espaco.
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Fase 4: desinstalador.
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Fase 5: tweaks e servicos.
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Fase 6: perfis por jogo.
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Fase 7: atualizacao do proprio app.
+ services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+
+ return services;
+ }
+
+ ///
+ /// Varreduras funcionam sem admin; as acoes ficam desabilitadas com aviso
+ /// (modo somente leitura da secao 4.2).
+ ///
+ public static bool RodandoComoAdministrador()
+ {
+ try
+ {
+ using var identidade = System.Security.Principal.WindowsIdentity.GetCurrent();
+ var principal = new System.Security.Principal.WindowsPrincipal(identidade);
+ return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
+ }
+ catch (Exception ex) when (ex is UnauthorizedAccessException or InvalidOperationException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/GameBoost/src/GameBoost.Core/GameBoost.Core.csproj b/GameBoost/src/GameBoost.Core/GameBoost.Core.csproj
new file mode 100644
index 0000000..e7c8cc7
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/GameBoost.Core.csproj
@@ -0,0 +1,20 @@
+
+
+ net9.0-windows
+ false
+ GameBoost.Core
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GameBoost/src/GameBoost.Core/Logging/IGameBoostLogger.cs b/GameBoost/src/GameBoost.Core/Logging/IGameBoostLogger.cs
new file mode 100644
index 0000000..dbbf30d
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Logging/IGameBoostLogger.cs
@@ -0,0 +1,29 @@
+namespace GameBoost.Core.Logging;
+
+public enum LogLevel
+{
+ Debug,
+ Info,
+ Warn,
+ Error
+}
+
+///
+/// Regra 8: toda acao grava linha estruturada (timestamp, modulo, acao, alvo, resultado).
+///
+public interface IGameBoostLogger
+{
+ void Log(LogLevel level, string modulo, string acao, string? alvo, string resultado, Exception? ex = null);
+}
+
+public static class LoggerExtensions
+{
+ public static void Info(this IGameBoostLogger log, string modulo, string acao, string? alvo, string resultado)
+ => log.Log(LogLevel.Info, modulo, acao, alvo, resultado);
+
+ public static void Warn(this IGameBoostLogger log, string modulo, string acao, string? alvo, string resultado)
+ => log.Log(LogLevel.Warn, modulo, acao, alvo, resultado);
+
+ public static void Error(this IGameBoostLogger log, string modulo, string acao, string? alvo, string resultado, Exception? ex = null)
+ => log.Log(LogLevel.Error, modulo, acao, alvo, resultado, ex);
+}
diff --git a/GameBoost/src/GameBoost.Core/Logging/StructuredLogger.cs b/GameBoost/src/GameBoost.Core/Logging/StructuredLogger.cs
new file mode 100644
index 0000000..ef167d3
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Logging/StructuredLogger.cs
@@ -0,0 +1,76 @@
+using System.Text;
+using GameBoost.Core.Abstractions;
+using GameBoost.Core.Settings;
+
+namespace GameBoost.Core.Logging;
+
+///
+/// Log texto legivel, rotacionado em 5 MB x 5 arquivos (secao 7 do spec).
+/// Serializado por lock: as varreduras rodam em varias Tasks ao mesmo tempo.
+///
+public sealed class StructuredLogger : IGameBoostLogger
+{
+ private const long MaxBytes = 5 * 1024 * 1024;
+ private const int MaxArquivos = 5;
+
+ private readonly AppPaths _paths;
+ private readonly IClock _clock;
+ private readonly object _gate = new();
+
+ public StructuredLogger(AppPaths paths, IClock clock)
+ {
+ _paths = paths;
+ _clock = clock;
+ }
+
+ public void Log(LogLevel level, string modulo, string acao, string? alvo, string resultado, Exception? ex = null)
+ {
+ var linha = new StringBuilder()
+ .Append(_clock.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"))
+ .Append(" | ").Append(level.ToString().ToUpperInvariant().PadRight(5))
+ .Append(" | ").Append(modulo)
+ .Append(" | ").Append(acao)
+ .Append(" | ").Append(string.IsNullOrEmpty(alvo) ? "-" : alvo)
+ .Append(" | ").Append(resultado);
+
+ if (ex is not null)
+ linha.Append(" | ").Append(ex.GetType().Name).Append(": ").Append(ex.Message);
+
+ lock (_gate)
+ {
+ try
+ {
+ Directory.CreateDirectory(_paths.RootDirectory);
+ Rotacionar();
+ File.AppendAllText(_paths.LogFile, linha.ToString() + Environment.NewLine, Encoding.UTF8);
+ }
+ catch (IOException)
+ {
+ // Log nunca derruba o app. Falha de disco e engolida de proposito.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+ }
+
+ private void Rotacionar()
+ {
+ var atual = _paths.LogFile;
+ if (!File.Exists(atual) || new FileInfo(atual).Length < MaxBytes)
+ return;
+
+ var maisAntigo = $"{atual}.{MaxArquivos}";
+ if (File.Exists(maisAntigo))
+ File.Delete(maisAntigo);
+
+ for (var i = MaxArquivos - 1; i >= 1; i--)
+ {
+ var origem = $"{atual}.{i}";
+ if (File.Exists(origem))
+ File.Move(origem, $"{atual}.{i + 1}", overwrite: true);
+ }
+
+ File.Move(atual, $"{atual}.1", overwrite: true);
+ }
+}
diff --git a/GameBoost/src/GameBoost.Core/Modules/ActionItem.cs b/GameBoost/src/GameBoost.Core/Modules/ActionItem.cs
new file mode 100644
index 0000000..bbf5d20
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Modules/ActionItem.cs
@@ -0,0 +1,42 @@
+namespace GameBoost.Core.Modules;
+
+///
+/// Unidade acionavel de qualquer modulo. A UI e generica: uma lista de ActionItem
+/// com checkbox, badge de risco e botao "?" com a explicacao (secao 4.2 do spec).
+///
+public sealed class ActionItem
+{
+ public required string Id { get; init; }
+ public required string Categoria { get; init; }
+ public required string Titulo { get; init; }
+ public required string Descricao { get; init; }
+ public RiskLevel Risco { get; init; } = RiskLevel.Baixo;
+
+ /// Texto honesto do ganho. Proibido inventar numero (regra 4).
+ public string GanhoEstimado { get; init; } = string.Empty;
+
+ /// Bytes que este item deve liberar, quando mensuravel. 0 = nao se aplica.
+ public long GanhoBytes { get; init; }
+
+ /// Nada que possa causar perda vem marcado (regra 3).
+ public bool PreMarcado { get; init; }
+
+ public required string ComoDesfazer { get; init; }
+
+ /// Itens protegidos aparecem na lista mas nao podem ser selecionados (regra 6).
+ public bool Bloqueado { get; init; }
+
+ public string? MotivoBloqueio { get; init; }
+
+ ///
+ /// Texto do selo quando o item esta bloqueado. O padrao e "Protegido", mas
+ /// nem todo bloqueio e protecao: um ajuste que so precisa de elevacao
+ /// aparece bloqueado e **nao** e protegido — chamar os dois da mesma coisa
+ /// faz o usuario achar que o GameBoost se recusa a mexer, quando na verdade
+ /// basta reabrir como administrador.
+ ///
+ public string RotuloBloqueio { get; init; } = "Protegido";
+
+ /// Carga util especifica do modulo (ex.: o ProcessInfo por tras do item).
+ public object? Payload { get; init; }
+}
diff --git a/GameBoost/src/GameBoost.Core/Modules/Bottleneck/BottleneckMonitor.cs b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/BottleneckMonitor.cs
new file mode 100644
index 0000000..1760abd
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/BottleneckMonitor.cs
@@ -0,0 +1,169 @@
+using System.Diagnostics;
+using GameBoost.Core.Logging;
+using GameBoost.Core.Modules.GameMode;
+
+namespace GameBoost.Core.Modules.Bottleneck;
+
+public sealed record MonitorTick(MetricsSnapshot Snapshot, IReadOnlyList Findings);
+
+///
+/// Coleta a cada 1 s com buffer de 5 min (secao 5.5).
+///
+/// Roda numa Task propria, nunca na thread da UI (regra 10). Se um tique
+/// atrasa, o proximo nao se acumula: o intervalo e medido do fim de um ciclo
+/// ao inicio do seguinte, entao a coleta nunca vira uma fila de trabalho
+/// atrasado quando a maquina esta sob carga.
+///
+public sealed class BottleneckMonitor : IDisposable
+{
+ public static readonly TimeSpan Intervalo = TimeSpan.FromSeconds(1);
+ public static readonly TimeSpan Janela = TimeSpan.FromMinutes(5);
+
+ private readonly IMetricsCollector _coletor;
+ private readonly FindingEngine _motor;
+ private readonly SystemFactsReader _fatos;
+ private readonly GameDetector _detector;
+ private readonly IGameBoostLogger _log;
+
+ private readonly MetricsBuffer _buffer = new((int)(Janela.TotalSeconds / Intervalo.TotalSeconds));
+ private CancellationTokenSource? _cts;
+ private Thread? _thread;
+
+ /// Disparado a cada coleta. O assinante e responsavel por voltar para a UI.
+ public event Action? AoColetar;
+
+ public BottleneckMonitor(
+ IMetricsCollector coletor,
+ FindingEngine motor,
+ SystemFactsReader fatos,
+ GameDetector detector,
+ IGameBoostLogger log)
+ {
+ _coletor = coletor;
+ _motor = motor;
+ _fatos = fatos;
+ _detector = detector;
+ _log = log;
+ }
+
+ public bool Rodando => _thread is { IsAlive: true };
+
+ public MetricsBuffer Buffer => _buffer;
+
+ public MetricsSnapshot? Ultimo => _buffer.Ultimo;
+
+ ///
+ /// Custo medio de CPU da propria coleta, para provar o orcamento da secao 5.5.
+ /// Medido pelo tempo de CPU da thread de coleta, nao pelo tempo de parede.
+ ///
+ public double CustoMedioDeCpuPercent { get; private set; }
+
+ /// Duracao media de um ciclo em milissegundos, util para diagnosticar lentidao.
+ public double DuracaoMediaDoCicloMs { get; private set; }
+
+ public void Iniciar()
+ {
+ if (Rodando)
+ return;
+
+ _cts = new CancellationTokenSource();
+ var token = _cts.Token;
+
+ // Thread dedicada, e nao Task no pool, por dois motivos: o custo da
+ // coleta e medido por GetThreadTimes, que so faz sentido se for sempre
+ // a mesma thread (um await devolveria a continuacao em outra), e a
+ // prioridade abaixo do normal garante que o monitor cede lugar para o
+ // jogo em vez de disputar com ele.
+ _thread = new Thread(() => Laco(token))
+ {
+ IsBackground = true,
+ Name = "GameBoost.Bottleneck",
+ Priority = ThreadPriority.BelowNormal
+ };
+
+ _thread.Start();
+
+ _log.Info("Bottleneck", "Iniciar", null, $"coleta a cada {Intervalo.TotalSeconds:0}s, janela de {Janela.TotalMinutes:0} min");
+ }
+
+ public void Parar()
+ {
+ _cts?.Cancel();
+
+ _thread?.Join(TimeSpan.FromSeconds(3));
+ _thread = null;
+ _log.Info("Bottleneck", "Parar", null,
+ $"custo medio de CPU da coleta: {CustoMedioDeCpuPercent:0.000}%, ciclo medio de {DuracaoMediaDoCicloMs:0} ms");
+ }
+
+ private void Laco(CancellationToken ct)
+ {
+ var cronometro = new Stopwatch();
+ var nucleos = Environment.ProcessorCount;
+ var amostrasDeCusto = 0;
+ var cpuDaThreadAntes = Native.NativeMethodsBridge.TempoDeCpuDaThreadAtual();
+
+ // Primeira coleta e so linha de base dos deltas: nao gera finding.
+ try
+ {
+ _coletor.Coletar();
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or UnauthorizedAccessException)
+ {
+ _log.Error("Bottleneck", "Coletar", null, "falha na leitura inicial", ex);
+ }
+
+ while (!ct.IsCancellationRequested)
+ {
+ if (ct.WaitHandle.WaitOne(Intervalo))
+ return;
+
+ cronometro.Restart();
+ cpuDaThreadAntes = Native.NativeMethodsBridge.TempoDeCpuDaThreadAtual();
+
+ try
+ {
+ var snapshot = _coletor.Coletar();
+ _buffer.Adicionar(snapshot);
+
+ var jogo = _detector.DetectarPorNome(snapshot.Processos.Select(p => p.Nome));
+
+ var contexto = new FindingContext
+ {
+ Atual = snapshot,
+ Historico = _buffer.Todos(),
+ Fatos = _fatos.Ler(),
+ JogoEmExecucao = jogo
+ };
+
+ var findings = _motor.Avaliar(contexto);
+ AoColetar?.Invoke(new MonitorTick(snapshot, findings));
+ }
+ catch (Exception ex) when (ex is InvalidOperationException
+ or UnauthorizedAccessException
+ or System.ComponentModel.Win32Exception)
+ {
+ _log.Error("Bottleneck", "Coletar", null, "falha numa coleta, o laco continua", ex);
+ }
+
+ cronometro.Stop();
+
+ // Custo real: tempo de CPU consumido pela thread de coleta sobre o
+ // intervalo, dividido pelos nucleos (100% = a maquina inteira).
+ // Usar o cronometro aqui contaria espera de WMI como se fosse CPU.
+ var cpuDaThread = (Native.NativeMethodsBridge.TempoDeCpuDaThreadAtual() - cpuDaThreadAntes).TotalMilliseconds;
+ var custo = cpuDaThread / Intervalo.TotalMilliseconds * 100.0 / nucleos;
+
+ amostrasDeCusto++;
+ CustoMedioDeCpuPercent += (custo - CustoMedioDeCpuPercent) / amostrasDeCusto;
+ DuracaoMediaDoCicloMs += (cronometro.Elapsed.TotalMilliseconds - DuracaoMediaDoCicloMs) / amostrasDeCusto;
+ }
+ }
+
+ public void Dispose()
+ {
+ Parar();
+ _cts?.Dispose();
+ (_coletor as IDisposable)?.Dispose();
+ }
+}
diff --git a/GameBoost/src/GameBoost.Core/Modules/Bottleneck/Finding.cs b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/Finding.cs
new file mode 100644
index 0000000..b70aaeb
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/Finding.cs
@@ -0,0 +1,64 @@
+namespace GameBoost.Core.Modules.Bottleneck;
+
+public enum FindingSeverity
+{
+ Informativo,
+ Atencao,
+ Critico
+}
+
+/// Para onde o botao do finding leva.
+public enum FindingAcao
+{
+ Nenhuma,
+ AbrirModoGame,
+ LimparRam,
+ PurgarStandby,
+ AbrirAnalisadorDeDisco,
+ AbrirInicializacao,
+ AbrirTweaks,
+ AtivarAltoDesempenho,
+ AbrirConfiguracoesDeVideo,
+ AbrirSegurancaDoWindows,
+ AbrirSiteDoDriver,
+ ReaplicarPerfil
+}
+
+///
+/// Um diagnostico em linguagem humana (secao 5.5). O texto e escrito para o
+/// usuario, nao para o desenvolvedor: "seu Chrome esta usando 38% da CPU",
+/// nao "cpu_usage_threshold_exceeded".
+///
+public sealed record Finding
+{
+ /// Estavel por regra e alvo: e o que o debounce usa para nao repetir.
+ public required string Id { get; init; }
+
+ public required string RegraId { get; init; }
+ public required string Titulo { get; init; }
+ public required string Detalhe { get; init; }
+ public FindingSeverity Severidade { get; init; } = FindingSeverity.Informativo;
+
+ /// Ordena a lista. Quanto maior, mais alto aparece.
+ public int Impacto { get; init; }
+
+ public FindingAcao Acao { get; init; } = FindingAcao.Nenhuma;
+ public string? TextoDaAcao { get; init; }
+
+ /// Area do Relatorio de Saude que este finding penaliza.
+ public HealthArea Area { get; init; } = HealthArea.Desempenho;
+
+ /// Quanto tira da nota daquela area, de 0 a 100.
+ public int Penalidade { get; init; }
+
+ public DateTimeOffset Momento { get; init; }
+}
+
+/// Areas pontuadas no Relatorio de Saude (secao 5.12).
+public enum HealthArea
+{
+ Desempenho,
+ Espaco,
+ Inicializacao,
+ ConfiguracaoParaJogos
+}
diff --git a/GameBoost/src/GameBoost.Core/Modules/Bottleneck/FindingEngine.cs b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/FindingEngine.cs
new file mode 100644
index 0000000..af4074c
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/FindingEngine.cs
@@ -0,0 +1,128 @@
+using GameBoost.Core.Abstractions;
+using GameBoost.Core.Logging;
+using GameBoost.Core.Modules.Bottleneck.Rules;
+
+namespace GameBoost.Core.Modules.Bottleneck;
+
+///
+/// Roda todas as regras e devolve os findings ordenados por impacto.
+///
+/// O debounce de 30 s (criterio de aceite da secao 5.5) impede que a lista
+/// pisque a cada segundo: um finding que ja esta na tela nao e reemitido, e um
+/// que sumiu so e esquecido depois da janela, evitando que oscile na fronteira
+/// do limite.
+///
+public sealed class FindingEngine
+{
+ public static readonly TimeSpan JanelaDeDebounce = TimeSpan.FromSeconds(30);
+
+ private readonly IReadOnlyList _regras;
+ private readonly IGameBoostLogger _log;
+ private readonly IClock _relogio;
+ private readonly Dictionary _ativos = new();
+ private readonly object _gate = new();
+
+ public FindingEngine(IGameBoostLogger log, IClock relogio, IReadOnlyList? regras = null)
+ {
+ _log = log;
+ _relogio = relogio;
+ _regras = regras ?? RegrasPadrao();
+ }
+
+ /// Todas as regras da tabela da secao 5.5.
+ public static IReadOnlyList RegrasPadrao() => new IFindingRule[]
+ {
+ new ProcessoDevorandoCpuRule(),
+ new RamNoLimiteRule(),
+ new StandbyListInchadaRule(),
+ new DiscoDoSistemaCheioRule(),
+ new GpuNoLimiteRule(),
+ new GargaloDeCpuEmJogoRule(),
+ new ThrottlingTermicoRule(),
+ new ProcessoDeSistemaOcupandoDiscoRule(),
+ new PlanoDeEnergiaBalanceadoRule(),
+ new DriverDeVideoAntigoRule(),
+ new InicializacaoLotadaRule(),
+ new GravacaoEmSegundoPlanoRule(),
+ new PerfilDesfeitoPorUpdateRule(),
+ new MonitorEmTaxaBaixaRule(),
+ new VbsAtivoRule(),
+ new JogoEmHddRule(),
+ new MemoriaSingleChannelRule()
+ };
+
+ public int QuantidadeDeRegras => _regras.Count;
+
+ ///
+ /// Avalia o contexto e devolve a lista atual de findings, ja com debounce.
+ /// Uma regra que lance excecao e isolada: as demais continuam valendo.
+ ///
+ public IReadOnlyList Avaliar(FindingContext contexto)
+ {
+ var agora = contexto.Atual.Momento;
+ var encontrados = new List();
+
+ foreach (var regra in _regras)
+ {
+ try
+ {
+ var finding = regra.Avaliar(contexto);
+ if (finding is not null)
+ encontrados.Add(finding);
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or ArgumentException or FormatException)
+ {
+ _log.Error("Bottleneck", "Regra", regra.Id, "falhou ao avaliar", ex);
+ }
+ }
+
+ lock (_gate)
+ {
+ foreach (var finding in encontrados)
+ {
+ if (_ativos.TryGetValue(finding.Id, out var existente))
+ {
+ // Ja esta na tela: atualiza o texto, mantem a posicao.
+ _ativos[finding.Id] = (finding, agora);
+ }
+ else
+ {
+ _ativos[finding.Id] = (finding, agora);
+ _log.Info("Bottleneck", "Finding", finding.RegraId, finding.Titulo);
+ }
+ }
+
+ // Some so depois da janela: evita piscar na fronteira do limite.
+ var expirados = _ativos
+ .Where(kv => agora - kv.Value.VistoPorUltimo > JanelaDeDebounce)
+ .Select(kv => kv.Key)
+ .ToList();
+
+ foreach (var id in expirados)
+ _ativos.Remove(id);
+
+ return _ativos.Values
+ .Select(v => v.Finding)
+ .OrderByDescending(f => f.Severidade)
+ .ThenByDescending(f => f.Impacto)
+ .ToList();
+ }
+ }
+
+ public IReadOnlyList Atuais()
+ {
+ lock (_gate)
+ {
+ return _ativos.Values
+ .Select(v => v.Finding)
+ .OrderByDescending(f => f.Severidade)
+ .ThenByDescending(f => f.Impacto)
+ .ToList();
+ }
+ }
+
+ public void Limpar()
+ {
+ lock (_gate) _ativos.Clear();
+ }
+}
diff --git a/GameBoost/src/GameBoost.Core/Modules/Bottleneck/GpuCounterReader.cs b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/GpuCounterReader.cs
new file mode 100644
index 0000000..a1a6dfe
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/GpuCounterReader.cs
@@ -0,0 +1,84 @@
+using GameBoost.Core.Logging;
+using GameBoost.Core.Native;
+
+namespace GameBoost.Core.Modules.Bottleneck;
+
+///
+/// Uso e memoria da GPU pelos contadores "GPU Engine" e "GPU Adapter Memory"
+/// (Windows 10 1709+).
+///
+/// Uma consulta PDH com curinga cobre todas as instancias de uma vez. A
+/// primeira versao criava um PerformanceCounter por instancia e o ciclo de
+/// coleta passava de 1 segundo com jogo e navegadores abertos, estourando o
+/// orcamento da secao 5.5.
+///
+/// Se os contadores nao existirem, a GPU vira "indisponivel" e nada mais
+/// quebra: a UI diz isso em vez de mostrar zero (regra 4).
+///
+internal sealed class GpuCounterReader : IDisposable
+{
+ private const string CaminhoDeUso = @"\GPU Engine(*engtype_3D)\Utilization Percentage";
+ private const string CaminhoDeMemoria = @"\GPU Adapter Memory(*)\Dedicated Usage";
+
+ private readonly IGameBoostLogger _log;
+ private PdhQuery? _uso;
+ private PdhQuery? _memoria;
+ private bool _iniciado;
+ private bool _descartado;
+
+ public GpuCounterReader(IGameBoostLogger log)
+ {
+ _log = log;
+ }
+
+ private void GarantirIniciado()
+ {
+ if (_iniciado || _descartado)
+ return;
+
+ _iniciado = true;
+
+ _uso = PdhQuery.Abrir(CaminhoDeUso);
+ _memoria = PdhQuery.Abrir(CaminhoDeMemoria);
+
+ if (_uso is null)
+ _log.Warn("Bottleneck", "GPU", null,
+ "contadores de GPU indisponiveis nesta maquina: o uso de GPU fica sem medida");
+ }
+
+ public Medida LerUso()
+ {
+ GarantirIniciado();
+
+ if (_uso is null)
+ return Medida.Indisponivel;
+
+ var valores = _uso.Coletar();
+ if (valores.Count == 0)
+ return Medida.De(0);
+
+ // Cada engine reporta a propria ocupacao; a placa esta tao ocupada
+ // quanto a engine mais carregada, nao quanto a soma delas.
+ return Medida.De(Math.Clamp(valores.Max(), 0, 100));
+ }
+
+ public Medida LerMemoria()
+ {
+ GarantirIniciado();
+
+ if (_memoria is null)
+ return Medida.Indisponivel;
+
+ var valores = _memoria.Coletar();
+ return valores.Count == 0 ? Medida.Indisponivel : Medida.De(valores.Sum());
+ }
+
+ public void Dispose()
+ {
+ _descartado = true;
+ _uso?.Dispose();
+ _memoria?.Dispose();
+ _uso = null;
+ _memoria = null;
+ }
+}
diff --git a/GameBoost/src/GameBoost.Core/Modules/Bottleneck/IFindingRule.cs b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/IFindingRule.cs
new file mode 100644
index 0000000..627202b
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/IFindingRule.cs
@@ -0,0 +1,92 @@
+namespace GameBoost.Core.Modules.Bottleneck;
+
+///
+/// Tudo que uma regra precisa saber. Passar um contexto em vez de servicos vivos
+/// e o que torna cada regra testavel com um snapshot sintetico, sem tocar no
+/// Windows (secao 11).
+///
+public sealed class FindingContext
+{
+ public required MetricsSnapshot Atual { get; init; }
+
+ /// Historico recente, do mais antigo para o mais novo.
+ public IReadOnlyList Historico { get; init; } = Array.Empty();
+
+ /// Fatos do sistema que nao mudam a cada segundo.
+ public SystemFacts Fatos { get; init; } = new();
+
+ /// Nome do jogo em execucao, quando ha um.
+ public string? JogoEmExecucao { get; init; }
+
+ public bool EmJogo => !string.IsNullOrEmpty(JogoEmExecucao);
+
+ /// Media de uma metrica na janela pedida. Null se nao houver amostras.
+ public double? MediaNaJanela(TimeSpan janela, Func seletor)
+ {
+ var corte = Atual.Momento - janela;
+ var valores = Historico
+ .Where(s => s.Momento >= corte)
+ .Select(seletor)
+ .Where(v => v.HasValue)
+ .Select(v => v!.Value)
+ .ToList();
+
+ return valores.Count == 0 ? null : valores.Average();
+ }
+
+ /// True se a condicao valeu em toda a janela e houve amostras suficientes.
+ public bool SustentadoPor(TimeSpan janela, Func condicao, int minimoDeAmostras = 5)
+ {
+ var corte = Atual.Momento - janela;
+ var amostras = Historico.Where(s => s.Momento >= corte).ToList();
+
+ if (amostras.Count < minimoDeAmostras)
+ return false;
+
+ return amostras.All(condicao);
+ }
+}
+
+///
+/// Fatos lidos uma vez (ou raramente), nao a cada segundo: plano de energia,
+/// build do Windows, driver de video, taxa de atualizacao do monitor.
+///
+public sealed class SystemFacts
+{
+ public Guid? PlanoDeEnergiaAtivo { get; init; }
+ public string? NomeDoPlanoDeEnergia { get; init; }
+ public bool PlanoEhBalanceado { get; init; }
+
+ public string? BuildDoWindows { get; init; }
+ public string? BuildAnteriormenteVisto { get; init; }
+ public int TweaksDesfeitosPorUpdate { get; init; }
+
+ public string? FabricanteDaGpu { get; init; }
+ public string? VersaoDoDriver { get; init; }
+ public DateTimeOffset? DataDoDriver { get; init; }
+
+ public int TaxaDeAtualizacaoAtual { get; init; }
+ public int TaxaDeAtualizacaoMaxima { get; init; }
+
+ public bool VbsAtivo { get; init; }
+ public bool GameDvrAtivo { get; init; }
+ public bool GameBarOverlayAtivo { get; init; }
+
+ public int ItensNaInicializacao { get; init; }
+ public bool JogoEmHdd { get; init; }
+ public string? JogoEmHddNome { get; init; }
+ public int PentesDeMemoria { get; init; }
+ public bool DefenderVarrendo { get; init; }
+ public bool WindowsSearchIndexando { get; init; }
+}
+
+///
+/// Uma regra de diagnostico. Devolve null quando nao ha nada a dizer.
+/// Cada regra da tabela da secao 5.5 e uma classe destas, testada isoladamente.
+///
+public interface IFindingRule
+{
+ string Id { get; }
+
+ Finding? Avaliar(FindingContext contexto);
+}
diff --git a/GameBoost/src/GameBoost.Core/Modules/Bottleneck/MetricsBuffer.cs b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/MetricsBuffer.cs
new file mode 100644
index 0000000..1d85f6d
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/MetricsBuffer.cs
@@ -0,0 +1,96 @@
+namespace GameBoost.Core.Modules.Bottleneck;
+
+///
+/// Janela deslizante dos ultimos 5 minutos (secao 5.5). Tamanho fixo: o buffer
+/// nunca cresce, entao a coleta continua cabendo no orcamento de 60 MB.
+///
+public sealed class MetricsBuffer
+{
+ private readonly MetricsSnapshot?[] _itens;
+ private readonly object _gate = new();
+ private int _proximo;
+ private int _quantidade;
+
+ public MetricsBuffer(int capacidade = 300)
+ {
+ if (capacidade <= 0)
+ throw new ArgumentOutOfRangeException(nameof(capacidade));
+
+ Capacidade = capacidade;
+ _itens = new MetricsSnapshot?[capacidade];
+ }
+
+ public int Capacidade { get; }
+
+ public int Quantidade
+ {
+ get { lock (_gate) return _quantidade; }
+ }
+
+ public MetricsSnapshot? Ultimo
+ {
+ get
+ {
+ lock (_gate)
+ {
+ if (_quantidade == 0)
+ return null;
+
+ var indice = (_proximo - 1 + Capacidade) % Capacidade;
+ return _itens[indice];
+ }
+ }
+ }
+
+ public void Adicionar(MetricsSnapshot snapshot)
+ {
+ lock (_gate)
+ {
+ _itens[_proximo] = snapshot;
+ _proximo = (_proximo + 1) % Capacidade;
+
+ if (_quantidade < Capacidade)
+ _quantidade++;
+ }
+ }
+
+ /// Do mais antigo para o mais recente.
+ public IReadOnlyList Todos()
+ {
+ lock (_gate)
+ {
+ var resultado = new List(_quantidade);
+ var inicio = (_proximo - _quantidade + Capacidade) % Capacidade;
+
+ for (var i = 0; i < _quantidade; i++)
+ {
+ var item = _itens[(inicio + i) % Capacidade];
+ if (item is not null)
+ resultado.Add(item);
+ }
+
+ return resultado;
+ }
+ }
+
+ /// Os ultimos N segundos, para o grafico e para as regras de duracao.
+ public IReadOnlyList Ultimos(TimeSpan janela)
+ {
+ var todos = Todos();
+ if (todos.Count == 0)
+ return todos;
+
+ var corte = todos[^1].Momento - janela;
+ return todos.Where(s => s.Momento >= corte).ToList();
+ }
+
+ public void Limpar()
+ {
+ lock (_gate)
+ {
+ Array.Clear(_itens);
+ _proximo = 0;
+ _quantidade = 0;
+ }
+ }
+}
diff --git a/GameBoost/src/GameBoost.Core/Modules/Bottleneck/MetricsSnapshot.cs b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/MetricsSnapshot.cs
new file mode 100644
index 0000000..ac82ad4
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/MetricsSnapshot.cs
@@ -0,0 +1,75 @@
+namespace GameBoost.Core.Modules.Bottleneck;
+
+///
+/// Valor que pode nao estar disponivel nesta maquina. Existe para a UI dizer
+/// "indisponivel" em vez de mostrar zero, que seria mentira (regra 4).
+///
+public readonly record struct Medida(double Valor, bool Disponivel)
+{
+ public static Medida Indisponivel { get; } = new(0, false);
+
+ public static Medida De(double valor) => new(valor, true);
+
+ public double OuZero => Disponivel ? Valor : 0;
+
+ public override string ToString() => Disponivel ? Valor.ToString("0.0") : "indisponivel";
+}
+
+/// Consumo de um processo num instante.
+public sealed record ProcessUsage(
+ int Pid,
+ string Nome,
+ double CpuPercent,
+ long WorkingSetBytes,
+ long IoBytesPorSegundo,
+ int Instancias)
+{
+ public string NomeExibido => Instancias > 1 ? $"{Nome} ({Instancias})" : Nome;
+}
+
+/// Um instante completo da maquina. Imutavel: vai direto para o buffer.
+public sealed record MetricsSnapshot
+{
+ public required DateTimeOffset Momento { get; init; }
+
+ // CPU
+ public double CpuPercent { get; init; }
+ public Medida CpuFrequenciaPercent { get; init; } = Medida.Indisponivel;
+ public int NucleosLogicos { get; init; }
+
+ // Memoria
+ public long RamTotalBytes { get; init; }
+ public long RamDisponivelBytes { get; init; }
+ public long StandbyBytes { get; init; }
+ public long CommitBytes { get; init; }
+
+ // GPU
+ public Medida GpuPercent { get; init; } = Medida.Indisponivel;
+ public Medida GpuMemoriaBytes { get; init; } = Medida.Indisponivel;
+
+ // Disco
+ public Medida DiscoOcupadoPercent { get; init; } = Medida.Indisponivel;
+ public Medida DiscoBytesPorSegundo { get; init; } = Medida.Indisponivel;
+ public double DiscoSistemaUsadoPercent { get; init; }
+
+ // Rede
+ public Medida RedeBytesPorSegundo { get; init; } = Medida.Indisponivel;
+
+ // Temperaturas
+ public Medida TemperaturaCpu { get; init; } = Medida.Indisponivel;
+ public Medida TemperaturaGpu { get; init; } = Medida.Indisponivel;
+
+ public IReadOnlyList Processos { get; init; } = Array.Empty();
+
+ public double RamUsadaPercent => RamTotalBytes == 0
+ ? 0
+ : (RamTotalBytes - RamDisponivelBytes) * 100.0 / RamTotalBytes;
+
+ public double RamDisponivelPercent => RamTotalBytes == 0
+ ? 0
+ : RamDisponivelBytes * 100.0 / RamTotalBytes;
+
+ public double StandbyPercent => RamTotalBytes == 0
+ ? 0
+ : StandbyBytes * 100.0 / RamTotalBytes;
+}
diff --git a/GameBoost/src/GameBoost.Core/Modules/Bottleneck/Rules/RegrasDeConfiguracao.cs b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/Rules/RegrasDeConfiguracao.cs
new file mode 100644
index 0000000..e3ab921
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/Rules/RegrasDeConfiguracao.cs
@@ -0,0 +1,291 @@
+namespace GameBoost.Core.Modules.Bottleneck.Rules;
+
+/// "Plano Balanceado ativo durante o jogo" — item nº 1 do consenso de 2026.
+public sealed class PlanoDeEnergiaBalanceadoRule : IFindingRule
+{
+ public string Id => "plano-balanceado";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ if (!ctx.Fatos.PlanoEhBalanceado)
+ return null;
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = ctx.EmJogo
+ ? "Plano de energia Balanceado ativo durante o jogo"
+ : "Plano de energia Balanceado ativo",
+ Detalhe = "No modo Balanceado a CPU baixa o clock quando acha que pode. Alto desempenho evita "
+ + "isso e e o ajuste de maior ganho real, alem de ser o mais facil de desfazer.",
+ Severidade = ctx.EmJogo ? FindingSeverity.Atencao : FindingSeverity.Informativo,
+ Impacto = ctx.EmJogo ? 80 : 45,
+ Acao = FindingAcao.AtivarAltoDesempenho,
+ TextoDaAcao = "Ativar Alto desempenho",
+ Area = HealthArea.ConfiguracaoParaJogos,
+ Penalidade = 20,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+/// "Driver NVIDIA de fev/2026" — driver velho. Nunca instala nada.
+public sealed class DriverDeVideoAntigoRule : IFindingRule
+{
+ public const int MesesLimite = 6;
+
+ public string Id => "driver-antigo";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ if (ctx.Fatos.DataDoDriver is not { } data)
+ return null;
+
+ var meses = (int)((ctx.Atual.Momento - data).TotalDays / 30.0);
+ if (meses < MesesLimite)
+ return null;
+
+ var fabricante = ctx.Fatos.FabricanteDaGpu ?? "de video";
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = $"Driver {fabricante} com {meses} meses",
+ Detalhe = $"Instalado em {data:MM/yyyy}. Driver atualizado costuma render FPS em jogos novos. "
+ + "O GameBoost nunca instala driver sozinho: abre a pagina oficial do fabricante.",
+ Severidade = FindingSeverity.Atencao,
+ Impacto = 50,
+ Acao = FindingAcao.AbrirSiteDoDriver,
+ TextoDaAcao = "Abrir pagina oficial",
+ Area = HealthArea.ConfiguracaoParaJogos,
+ Penalidade = 15,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+/// "23 programas iniciam com o Windows".
+public sealed class InicializacaoLotadaRule : IFindingRule
+{
+ public const int Limite = 12;
+
+ public string Id => "inicializacao-lotada";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ var quantidade = ctx.Fatos.ItensNaInicializacao;
+ if (quantidade < Limite)
+ return null;
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = $"{quantidade} programas iniciam com o Windows",
+ Detalhe = "Podar a inicializacao e, junto com o plano de energia, o ajuste de maior valor e o "
+ + "mais facil de reverter. Cada item desativado tambem libera RAM o dia inteiro.",
+ Severidade = quantidade >= 20 ? FindingSeverity.Atencao : FindingSeverity.Informativo,
+ Impacto = Math.Min(quantidade * 3, 65),
+ Acao = FindingAcao.AbrirInicializacao,
+ TextoDaAcao = "Abrir Inicializacao",
+ Area = HealthArea.Inicializacao,
+ Penalidade = Math.Min((quantidade - Limite) * 4, 40),
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+/// "Gravacao em segundo plano ativa" — Game DVR ligado.
+public sealed class GravacaoEmSegundoPlanoRule : IFindingRule
+{
+ public string Id => "game-dvr-ativo";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ if (!ctx.Fatos.GameDvrAtivo && !ctx.Fatos.GameBarOverlayAtivo)
+ return null;
+
+ var oQue = ctx.Fatos.GameDvrAtivo && ctx.Fatos.GameBarOverlayAtivo
+ ? "A gravacao em segundo plano e o overlay da Game Bar estao ligados"
+ : ctx.Fatos.GameDvrAtivo
+ ? "A gravacao em segundo plano esta ligada"
+ : "O overlay da Game Bar esta ligado";
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = "Gravacao em segundo plano ativa",
+ Detalhe = $"{oQue}. Consome CPU e GPU o tempo todo, mesmo sem voce gravar nada. "
+ + "O Modo Game desliga e devolve ao sair.",
+ Severidade = FindingSeverity.Informativo,
+ Impacto = 35,
+ Acao = FindingAcao.AbrirModoGame,
+ TextoDaAcao = "Desativar no Modo Game",
+ Area = HealthArea.ConfiguracaoParaJogos,
+ Penalidade = 10,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+///
+/// "A atualizacao 26H2 desfez 4 ajustes seus" — o diferencial que nenhum
+/// concorrente tem (secao 3.6).
+///
+public sealed class PerfilDesfeitoPorUpdateRule : IFindingRule
+{
+ public string Id => "perfil-desfeito-por-update";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ var fatos = ctx.Fatos;
+
+ if (string.IsNullOrEmpty(fatos.BuildDoWindows) || string.IsNullOrEmpty(fatos.BuildAnteriormenteVisto))
+ return null;
+
+ if (fatos.BuildDoWindows == fatos.BuildAnteriormenteVisto)
+ return null;
+
+ if (fatos.TweaksDesfeitosPorUpdate <= 0)
+ return null;
+
+ var plural = fatos.TweaksDesfeitosPorUpdate == 1 ? "ajuste seu" : "ajustes seus";
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = $"A atualizacao {fatos.BuildDoWindows} desfez {fatos.TweaksDesfeitosPorUpdate} {plural}",
+ Detalhe = "Atualizacoes grandes do Windows resetam Game Mode, plano de energia, efeitos visuais e "
+ + "Game Bar. O GameBoost guardou como estava e pode reaplicar.",
+ Severidade = FindingSeverity.Atencao,
+ Impacto = 75,
+ Acao = FindingAcao.ReaplicarPerfil,
+ TextoDaAcao = "Reaplicar perfil",
+ Area = HealthArea.ConfiguracaoParaJogos,
+ Penalidade = 15,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+/// "Seu monitor de 165 Hz esta em 60 Hz" — acontece muito apos troca de driver.
+public sealed class MonitorEmTaxaBaixaRule : IFindingRule
+{
+ public string Id => "monitor-taxa-baixa";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ var atual = ctx.Fatos.TaxaDeAtualizacaoAtual;
+ var maxima = ctx.Fatos.TaxaDeAtualizacaoMaxima;
+
+ if (atual <= 0 || maxima <= 0 || atual >= maxima)
+ return null;
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = $"Seu monitor de {maxima} Hz esta em {atual} Hz",
+ Detalhe = "O Windows costuma voltar para 60 Hz depois de atualizar driver ou trocar de cabo. "
+ + "Corrigir isso e o ganho de fluidez mais barato que existe.",
+ Severidade = FindingSeverity.Atencao,
+ Impacto = 88,
+ Acao = FindingAcao.AbrirConfiguracoesDeVideo,
+ TextoDaAcao = "Abrir configuracoes de video",
+ Area = HealthArea.ConfiguracaoParaJogos,
+ Penalidade = 20,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+///
+/// "Isolamento do nucleo ativo" — informa e mede, nunca desliga (secao 3.4).
+///
+public sealed class VbsAtivoRule : IFindingRule
+{
+ public string Id => "vbs-ativo";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ if (!ctx.Fatos.VbsAtivo)
+ return null;
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = "Isolamento do nucleo (VBS) ativo",
+ Detalhe = "Vem ligado por padrao no Windows 11 e a comunidade mede de 5 a 15% de FPS em jogos "
+ + "limitados por CPU. E protecao real contra malware de kernel: vale desligar so em PC "
+ + "exclusivo de jogo, nunca em maquina de trabalho ou banco. O GameBoost nao altera isto; "
+ + "a decisao e sua, nas Configuracoes do Windows.",
+ Severidade = FindingSeverity.Informativo,
+ Impacto = 25,
+ Acao = FindingAcao.AbrirSegurancaDoWindows,
+ TextoDaAcao = "Abrir Seguranca do Windows",
+ Area = HealthArea.ConfiguracaoParaJogos,
+ Penalidade = 0,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+/// "Jogo instalado em HDD. Mova para SSD".
+public sealed class JogoEmHddRule : IFindingRule
+{
+ public string Id => "jogo-em-hdd";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ if (!ctx.Fatos.JogoEmHdd)
+ return null;
+
+ var nome = ctx.Fatos.JogoEmHddNome ?? "Um jogo";
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = $"{nome} esta instalado em HDD",
+ Detalhe = "Lancamentos de 2025 e 2026 assumem SSD e engasgam em disco mecanico, principalmente ao "
+ + "carregar textura. Mover para SSD resolve engasgo que nenhum ajuste de software resolve.",
+ Severidade = FindingSeverity.Atencao,
+ Impacto = 72,
+ Area = HealthArea.ConfiguracaoParaJogos,
+ Penalidade = 15,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+/// Memoria em single channel: metade da banda, sem custo nenhum para descobrir.
+public sealed class MemoriaSingleChannelRule : IFindingRule
+{
+ public string Id => "memoria-single-channel";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ if (ctx.Fatos.PentesDeMemoria != 1)
+ return null;
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = "Memoria em canal unico",
+ Detalhe = "Ha um unico pente de RAM instalado. Com dois pentes iguais em canal duplo, a banda de "
+ + "memoria dobra, e isso costuma render FPS de verdade em jogos limitados por CPU e em "
+ + "notebooks com video integrado.",
+ Severidade = FindingSeverity.Informativo,
+ Impacto = 55,
+ Area = HealthArea.ConfiguracaoParaJogos,
+ Penalidade = 10,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
diff --git a/GameBoost/src/GameBoost.Core/Modules/Bottleneck/Rules/RegrasDeRecurso.cs b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/Rules/RegrasDeRecurso.cs
new file mode 100644
index 0000000..2241d81
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/Rules/RegrasDeRecurso.cs
@@ -0,0 +1,316 @@
+namespace GameBoost.Core.Modules.Bottleneck.Rules;
+
+/// Formatacao compartilhada pelas regras. Texto para gente, nao para log.
+internal static class Fmt
+{
+ public static string Bytes(long bytes) => GameMode.GameModeModule.Formatar(bytes);
+
+ public static string Bytes(double bytes) => GameMode.GameModeModule.Formatar((long)bytes);
+
+ public static string Percent(double valor) => $"{valor:0}%";
+}
+
+/// "Chrome esta usando 38% da CPU" — processo pesado fora do jogo.
+public sealed class ProcessoDevorandoCpuRule : IFindingRule
+{
+ public const double Limite = 25.0;
+ private static readonly TimeSpan Janela = TimeSpan.FromSeconds(30);
+
+ public string Id => "processo-cpu-alta";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ var vilao = ctx.Atual.Processos
+ .Where(p => p.CpuPercent >= Limite)
+ .Where(p => !EhOJogo(p, ctx))
+ .MaxBy(p => p.CpuPercent);
+
+ if (vilao is null)
+ return null;
+
+ // Um pico de 1 segundo nao e problema. So vale se persistir.
+ var sustentado = ctx.SustentadoPor(Janela,
+ s => s.Processos.Any(p => p.Nome.Equals(vilao.Nome, StringComparison.OrdinalIgnoreCase)
+ && p.CpuPercent >= Limite));
+
+ if (!sustentado)
+ return null;
+
+ var instancias = vilao.Instancias > 1 ? $" em {vilao.Instancias} processos" : string.Empty;
+
+ return new Finding
+ {
+ Id = $"{Id}:{vilao.Nome}",
+ RegraId = Id,
+ Titulo = $"{vilao.Nome} esta usando {Fmt.Percent(vilao.CpuPercent)} da CPU",
+ Detalhe = $"Ha pelo menos 30 segundos{instancias}, ocupando {Fmt.Bytes(vilao.WorkingSetBytes)} de RAM. "
+ + "Fechar antes de jogar libera CPU para o jogo.",
+ Severidade = vilao.CpuPercent >= 50 ? FindingSeverity.Critico : FindingSeverity.Atencao,
+ Impacto = (int)vilao.CpuPercent,
+ Acao = FindingAcao.AbrirModoGame,
+ TextoDaAcao = "Ver no Modo Game",
+ Area = HealthArea.Desempenho,
+ Penalidade = vilao.CpuPercent >= 50 ? 20 : 10,
+ Momento = ctx.Atual.Momento
+ };
+ }
+
+ private static bool EhOJogo(ProcessUsage p, FindingContext ctx)
+ => ctx.JogoEmExecucao is not null
+ && p.Nome.Equals(ctx.JogoEmExecucao, StringComparison.OrdinalIgnoreCase);
+}
+
+/// "So 1,2 GB de RAM livre" — memoria no limite.
+public sealed class RamNoLimiteRule : IFindingRule
+{
+ public const double LimitePercent = 10.0;
+
+ public string Id => "ram-no-limite";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ if (ctx.Atual.RamTotalBytes == 0 || ctx.Atual.RamDisponivelPercent >= LimitePercent)
+ return null;
+
+ var maiores = ctx.Atual.Processos
+ .OrderByDescending(p => p.WorkingSetBytes)
+ .Take(2)
+ .ToList();
+
+ var culpados = maiores.Count == 2
+ ? $"{maiores[0].Nome} e {maiores[1].Nome} usam {Fmt.Bytes(maiores[0].WorkingSetBytes + maiores[1].WorkingSetBytes)}."
+ : string.Empty;
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = $"So {Fmt.Bytes(ctx.Atual.RamDisponivelBytes)} de RAM livre",
+ Detalhe = $"{Fmt.Percent(ctx.Atual.RamUsadaPercent)} da memoria em uso. {culpados}".Trim(),
+ Severidade = FindingSeverity.Critico,
+ Impacto = 90,
+ Acao = FindingAcao.LimparRam,
+ TextoDaAcao = "Limpar RAM",
+ Area = HealthArea.Desempenho,
+ Penalidade = 25,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+/// "6 GB de RAM presos em cache do sistema" — Standby List inchada.
+public sealed class StandbyListInchadaRule : IFindingRule
+{
+ public const double LimitePercent = 40.0;
+
+ public string Id => "standby-inchada";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ if (ctx.Atual.StandbyBytes == 0 || ctx.Atual.StandbyPercent < LimitePercent)
+ return null;
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = $"{Fmt.Bytes(ctx.Atual.StandbyBytes)} de RAM presos em cache do sistema",
+ Detalhe = "A Standby List guarda dados ja usados por precaucao. Liberar ajuda quando falta RAM, "
+ + "mas nao aumenta FPS por si so.",
+ Severidade = FindingSeverity.Informativo,
+ Impacto = 40,
+ Acao = FindingAcao.PurgarStandby,
+ TextoDaAcao = "Purgar Standby",
+ Area = HealthArea.Desempenho,
+ Penalidade = 5,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+/// "C: com 94% de uso" — disco do sistema cheio.
+public sealed class DiscoDoSistemaCheioRule : IFindingRule
+{
+ public const double LimitePercent = 90.0;
+
+ public string Id => "disco-cheio";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ if (ctx.Atual.DiscoSistemaUsadoPercent < LimitePercent)
+ return null;
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = $"Disco do sistema com {Fmt.Percent(ctx.Atual.DiscoSistemaUsadoPercent)} de uso",
+ Detalhe = "SSD muito cheio perde desempenho de escrita, e o Windows precisa de espaco livre "
+ + "para arquivo de paginacao e atualizacoes.",
+ Severidade = ctx.Atual.DiscoSistemaUsadoPercent >= 95 ? FindingSeverity.Critico : FindingSeverity.Atencao,
+ Impacto = 70,
+ Acao = FindingAcao.AbrirAnalisadorDeDisco,
+ TextoDaAcao = "Ver o que ocupa espaco",
+ Area = HealthArea.Espaco,
+ Penalidade = ctx.Atual.DiscoSistemaUsadoPercent >= 95 ? 40 : 20,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+/// "GPU no maximo" — o gargalo e a placa de video.
+public sealed class GpuNoLimiteRule : IFindingRule
+{
+ public const double LimitePercent = 95.0;
+
+ public string Id => "gpu-no-limite";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ if (!ctx.Atual.GpuPercent.Disponivel || ctx.Atual.GpuPercent.Valor < LimitePercent)
+ return null;
+
+ if (!ctx.SustentadoPor(TimeSpan.FromSeconds(20),
+ s => s.GpuPercent.Disponivel && s.GpuPercent.Valor >= LimitePercent))
+ {
+ return null;
+ }
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = "Placa de video no limite",
+ Detalhe = "A GPU esta em 100%. Se o FPS estiver baixo, o caminho e reduzir resolucao ou "
+ + "qualidade grafica, ou ligar DLSS/FSR. Fechar programas nao ajuda neste caso.",
+ Severidade = FindingSeverity.Informativo,
+ Impacto = 60,
+ Area = HealthArea.Desempenho,
+ Penalidade = 0,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+/// "Gargalo de CPU: o processador nao alimenta a placa de video".
+public sealed class GargaloDeCpuEmJogoRule : IFindingRule
+{
+ public string Id => "gargalo-cpu-em-jogo";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ if (!ctx.EmJogo || !ctx.Atual.GpuPercent.Disponivel)
+ return null;
+
+ if (ctx.Atual.CpuPercent < 95 || ctx.Atual.GpuPercent.Valor >= 60)
+ return null;
+
+ if (!ctx.SustentadoPor(TimeSpan.FromSeconds(15),
+ s => s.CpuPercent >= 90 && s.GpuPercent.Disponivel && s.GpuPercent.Valor < 70))
+ {
+ return null;
+ }
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = "Gargalo de CPU",
+ Detalhe = $"A CPU esta em {Fmt.Percent(ctx.Atual.CpuPercent)} e a GPU em "
+ + $"{Fmt.Percent(ctx.Atual.GpuPercent.Valor)}: o processador nao consegue preparar quadros "
+ + "rapido o suficiente e a placa de video fica esperando. Fechar apps em segundo plano ajuda.",
+ Severidade = FindingSeverity.Atencao,
+ Impacto = 85,
+ Acao = FindingAcao.AbrirModoGame,
+ TextoDaAcao = "Abrir Modo Game",
+ Area = HealthArea.Desempenho,
+ Penalidade = 15,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+/// "CPU a 97 graus reduzindo clock" — throttling termico.
+public sealed class ThrottlingTermicoRule : IFindingRule
+{
+ public const double LimiteTemperatura = 90.0;
+
+ public string Id => "throttling-termico";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ if (!ctx.Atual.TemperaturaCpu.Disponivel || ctx.Atual.TemperaturaCpu.Valor < LimiteTemperatura)
+ return null;
+
+ if (ctx.Atual.CpuPercent < 70)
+ return null;
+
+ var frequencia = ctx.Atual.CpuFrequenciaPercent;
+ var reduzindo = frequencia.Disponivel && frequencia.Valor < 80;
+
+ return new Finding
+ {
+ Id = Id,
+ RegraId = Id,
+ Titulo = $"CPU a {ctx.Atual.TemperaturaCpu.Valor:0} graus",
+ Detalhe = reduzindo
+ ? "O processador esta reduzindo a velocidade para nao superaquecer. Sinal de cooler sujo "
+ + "ou pasta termica velha."
+ : "Temperatura alta sob carga. Se o FPS cair de repente durante o jogo, o motivo e este.",
+ Severidade = FindingSeverity.Critico,
+ Impacto = 95,
+ Area = HealthArea.Desempenho,
+ Penalidade = 20,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
+
+/// "Windows Defender fazendo varredura completa" — informativo, nunca desativar.
+public sealed class ProcessoDeSistemaOcupandoDiscoRule : IFindingRule
+{
+ private static readonly IReadOnlyDictionary Conhecidos =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["MsMpEng"] = "O Windows Defender esta varrendo arquivos",
+ ["SearchIndexer"] = "O Windows Search esta indexando arquivos",
+ ["SearchHost"] = "A busca do Windows esta trabalhando",
+ ["TiWorker"] = "O Windows esta instalando uma atualizacao",
+ ["TrustedInstaller"] = "O Windows esta instalando uma atualizacao",
+ ["wuauclt"] = "O Windows Update esta trabalhando",
+ ["CompatTelRunner"] = "O Windows esta coletando dados de compatibilidade"
+ };
+
+ public string Id => "sistema-ocupando-disco";
+
+ public Finding? Avaliar(FindingContext ctx)
+ {
+ var culpado = ctx.Atual.Processos
+ .Where(p => Conhecidos.ContainsKey(p.Nome) && p.CpuPercent >= 8)
+ .MaxBy(p => p.CpuPercent);
+
+ if (culpado is null)
+ return null;
+
+ if (!ctx.SustentadoPor(TimeSpan.FromSeconds(20),
+ s => s.Processos.Any(p => p.Nome.Equals(culpado.Nome, StringComparison.OrdinalIgnoreCase)
+ && p.CpuPercent >= 5)))
+ {
+ return null;
+ }
+
+ return new Finding
+ {
+ Id = $"{Id}:{culpado.Nome}",
+ RegraId = Id,
+ Titulo = Conhecidos[culpado.Nome],
+ Detalhe = $"Usando {Fmt.Percent(culpado.CpuPercent)} da CPU. E uma tarefa normal do Windows e "
+ + "termina sozinha. O GameBoost nao desativa protecao nem atualizacao.",
+ Severidade = FindingSeverity.Informativo,
+ Impacto = 30,
+ Area = HealthArea.Desempenho,
+ Penalidade = 0,
+ Momento = ctx.Atual.Momento
+ };
+ }
+}
diff --git a/GameBoost/src/GameBoost.Core/Modules/Bottleneck/SystemFactsReader.cs b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/SystemFactsReader.cs
new file mode 100644
index 0000000..bb14354
--- /dev/null
+++ b/GameBoost/src/GameBoost.Core/Modules/Bottleneck/SystemFactsReader.cs
@@ -0,0 +1,352 @@
+using System.Management;
+using System.Runtime.InteropServices;
+using GameBoost.Core.Abstractions;
+using GameBoost.Core.Logging;
+using GameBoost.Core.Services;
+using GameBoost.Core.Settings;
+
+namespace GameBoost.Core.Modules.Bottleneck;
+
+///
+/// Le os fatos que nao mudam a cada segundo: plano de energia, driver de video,
+/// taxa do monitor, VBS, pentes de memoria, build do Windows.
+///
+/// Roda em cache com validade propria para nao pagar WMI a cada tique da coleta.
+///
+public sealed class SystemFactsReader
+{
+ private static readonly TimeSpan Validade = TimeSpan.FromMinutes(2);
+
+ private readonly IPowerService _power;
+ private readonly IRegistryService _registro;
+ private readonly ISettingsStore _settingsStore;
+ private readonly AppSettings _settings;
+ private readonly IGameBoostLogger _log;
+ private readonly IClock _relogio;
+
+ private SystemFacts? _cache;
+ private DateTimeOffset _lidoEm;
+
+ public SystemFactsReader(
+ IPowerService power,
+ IRegistryService registro,
+ ISettingsStore settingsStore,
+ AppSettings settings,
+ IGameBoostLogger log,
+ IClock relogio)
+ {
+ _power = power;
+ _registro = registro;
+ _settingsStore = settingsStore;
+ _settings = settings;
+ _log = log;
+ _relogio = relogio;
+ }
+
+ public SystemFacts Ler(bool forcar = false)
+ {
+ if (!forcar && _cache is not null && _relogio.Now - _lidoEm < Validade)
+ return _cache;
+
+ var plano = Seguro(() => _power.GetActivePlan(), null);
+ var (fabricante, versao, data) = LerDriverDeVideo();
+ var (atual, maxima) = LerTaxaDeAtualizacao();
+ var build = LerBuildDoWindows();
+
+ var fatos = new SystemFacts
+ {
+ PlanoDeEnergiaAtivo = plano?.Id,
+ NomeDoPlanoDeEnergia = plano?.Name,
+ PlanoEhBalanceado = plano is not null && plano.Id == WindowsPowerService.Balanceado,
+
+ BuildDoWindows = build,
+ BuildAnteriormenteVisto = _settings.UltimaBuildDoWindows,
+ TweaksDesfeitosPorUpdate = ContarTweaksDesfeitos(build),
+
+ FabricanteDaGpu = fabricante,
+ VersaoDoDriver = versao,
+ DataDoDriver = data,
+
+ TaxaDeAtualizacaoAtual = atual,
+ TaxaDeAtualizacaoMaxima = maxima,
+
+ VbsAtivo = LerVbs(),
+ GameDvrAtivo = LerDword(RegistryRoot.CurrentUser, @"System\GameConfigStore", "GameDVR_Enabled") != 0,
+ GameBarOverlayAtivo = LerDword(RegistryRoot.CurrentUser, @"Software\Microsoft\GameBar", "UseNexusForGameBarEnabled") != 0,
+
+ ItensNaInicializacao = ContarItensDeInicializacao(),
+ PentesDeMemoria = ContarPentesDeMemoria()
+ };
+
+ _cache = fatos;
+ _lidoEm = _relogio.Now;
+
+ // Guarda a build para a proxima execucao comparar.
+ if (!string.IsNullOrEmpty(build) && _settings.UltimaBuildDoWindows != build)
+ {
+ _settings.UltimaBuildDoWindows = build;
+ Seguro