diff --git a/.gitattributes b/.gitattributes
index 46526f6..9b6b61b 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,3 +1,21 @@
* text=auto eol=lf
-*.png binary
+*.cs text eol=lf
+*.csproj text eol=lf
+*.json text eol=lf
+*.md text eol=lf
+*.py text eol=lf
+*.xaml text eol=lf
+*.xml text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
+*.ps1 text eol=crlf
+*.cmd text eol=crlf
+*.7z binary
+*.dll binary
+*.exe binary
+*.ico binary
*.jpg binary
+*.png binary
+*.pdb binary
+*.webp binary
+*.zip binary
diff --git a/.github/workflows/public-source.yml b/.github/workflows/public-source.yml
new file mode 100644
index 0000000..bfbc968
--- /dev/null
+++ b/.github/workflows/public-source.yml
@@ -0,0 +1,36 @@
+name: Validação pública
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ name: Código e repositório
+ runs-on: windows-latest
+ timeout-minutes: 15
+
+ steps:
+ - name: Obter o repositório
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Configurar Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: '3.10'
+
+ - name: Configurar .NET
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Validar conteúdo, testes e interface
+ shell: pwsh
+ run: .\tools\Test-PublicSource.ps1
diff --git a/.gitignore b/.gitignore
index b9f8c62..ae35b8e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,14 +1,27 @@
-# Pacotes e binários de distribuição permanecem fora do repositório.
+# Compilação, caches e ambientes locais.
+**/bin/
+**/obj/
+**/__pycache__/
+*.pyc
+.tools/
+.vs/
+.idea/
+
+# Pacotes, binários e artefatos de distribuição.
*.7z
*.dll
*.exe
*.pdb
*.zip
+dist/
+installer/NIKKEPTBR.Installer/Payload/
-# Artefatos locais e privados.
+# Evidências e dados privados do workspace.
private/
work/
-# Arquivos gerados pelo sistema operacional.
+# Temporários e arquivos do sistema operacional.
+*.log
+*.tmp
.DS_Store
Thumbs.db
diff --git a/README.md b/README.md
index 4c412ca..a5118bf 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,10 @@
GODDESS OF VICTORY: NIKKE PT-BR / PC
+
+
+
+
TRADUÇÃO COMUNITÁRIA / BETA / WINDOWS X64
@@ -53,7 +57,8 @@ O projeto é criado, dirigido, mantido e validado por **Kacks**. Não possui afi
| Apontamentos editoriais preservados | **1.689** |
| Pacote transacional | **18/18 verificações** |
| Executável final | **11/11 verificações** |
-| Suíte atual do projeto | **116/116 testes** |
+| Suíte da versão publicada | **116/116 testes** |
+| Testes públicos do instalador | **23/23 aprovados** |
Esses números descrevem cobertura e integridade técnica. Eles não significam que 429.498 frases receberam revisão humana individual. Os apontamentos editoriais permanecem registrados para uma etapa futura, sem invalidar o funcionamento da primeira versão.
@@ -113,22 +118,31 @@ Nenhum mod pode prometer compatibilidade absoluta com versões futuras ainda des
## 07 / TRADUÇÃO EM JOGO
-As imagens abaixo são capturas reais do cliente com a integração PT-BR ativa.
+As imagens abaixo são capturas reais do cliente com a integração PT-BR ativa. Elas demonstram lobby, personagem, narrativa, escolhas, campanha, progressão e inventário.
+
+| Lobby e personagem | Diálogo e cena de história |
+| --- | --- |
+|  |  |
+|  |  |
-| Identificação de personagem | Diálogo narrativo |
+| Escolhas e campanha | Progressão e inventário |
| --- | --- |
-|  |  |
+|  |  |
+|  |  |
-## 08 / CONTEÚDO PÚBLICO E VERIFICAÇÃO
+## 08 / CÓDIGO PÚBLICO E VERIFICAÇÃO
-| Área | Conteúdo |
+| Área | Conteúdo público |
| --- | --- |
+| [`installer`](./installer) | Interface WPF e núcleo transacional usados pelo instalador gráfico. |
+| [`tools`](./tools) | Contrato do pacote, bootstrap determinístico e auditoria do repositório. |
+| [`tests`](./tests) | Testes automatizados do pacote, das transações e da interface com o núcleo. |
| [`docs`](./docs) | Arquitetura, compatibilidade, instalação, status, autoria e roadmap. |
| [`assets`](./assets) | Identidade visual e capturas aprovadas para a página do projeto. |
| [`release`](./release) | Manifesto público sem textos nem arquivos proprietários do jogo. |
| [`CHANGELOG.md`](./CHANGELOG.md) | Histórico das versões distribuídas. |
-Cada Release inclui o instalador, manifesto JSON e arquivo SHA-256. O repositório não publica bancos extraídos, textos integrais, chaves, executáveis ou outros arquivos proprietários do cliente.
+Cada Release inclui o instalador, manifesto JSON e arquivo SHA-256. O repositório publica o código autoral necessário para auditar a interface e a segurança transacional, mas não distribui o catálogo, a carga útil, bancos extraídos, textos integrais, chaves, executáveis ou arquivos proprietários do cliente. Consulte [Compilação e limites do código público](./docs/COMPILACAO.md).
## 09 / AUTORIA E TRANSPARÊNCIA
diff --git a/assets/screenshots/01-dialogo-ptbr.png b/assets/screenshots/01-dialogo-ptbr.png
deleted file mode 100644
index e37b44c..0000000
Binary files a/assets/screenshots/01-dialogo-ptbr.png and /dev/null differ
diff --git a/assets/screenshots/01-lobby-ptbr.webp b/assets/screenshots/01-lobby-ptbr.webp
new file mode 100644
index 0000000..c393adb
Binary files /dev/null and b/assets/screenshots/01-lobby-ptbr.webp differ
diff --git a/assets/screenshots/02-dialogo-ptbr.png b/assets/screenshots/02-dialogo-ptbr.png
deleted file mode 100644
index 7d23f23..0000000
Binary files a/assets/screenshots/02-dialogo-ptbr.png and /dev/null differ
diff --git a/assets/screenshots/02-perfil-personagem-ptbr.webp b/assets/screenshots/02-perfil-personagem-ptbr.webp
new file mode 100644
index 0000000..f27b0e5
Binary files /dev/null and b/assets/screenshots/02-perfil-personagem-ptbr.webp differ
diff --git a/assets/screenshots/03-dialogo-narrativo-ptbr.webp b/assets/screenshots/03-dialogo-narrativo-ptbr.webp
new file mode 100644
index 0000000..951fb76
Binary files /dev/null and b/assets/screenshots/03-dialogo-narrativo-ptbr.webp differ
diff --git a/assets/screenshots/04-cena-historia-ptbr.webp b/assets/screenshots/04-cena-historia-ptbr.webp
new file mode 100644
index 0000000..b61f854
Binary files /dev/null and b/assets/screenshots/04-cena-historia-ptbr.webp differ
diff --git a/assets/screenshots/05-escolhas-dialogo-ptbr.webp b/assets/screenshots/05-escolhas-dialogo-ptbr.webp
new file mode 100644
index 0000000..69b484a
Binary files /dev/null and b/assets/screenshots/05-escolhas-dialogo-ptbr.webp differ
diff --git a/assets/screenshots/06-campanha-formacao-ptbr.webp b/assets/screenshots/06-campanha-formacao-ptbr.webp
new file mode 100644
index 0000000..94be1bd
Binary files /dev/null and b/assets/screenshots/06-campanha-formacao-ptbr.webp differ
diff --git a/assets/screenshots/07-progressao-recompensas-ptbr.webp b/assets/screenshots/07-progressao-recompensas-ptbr.webp
new file mode 100644
index 0000000..3084910
Binary files /dev/null and b/assets/screenshots/07-progressao-recompensas-ptbr.webp differ
diff --git a/assets/screenshots/08-inventario-item-ptbr.webp b/assets/screenshots/08-inventario-item-ptbr.webp
new file mode 100644
index 0000000..364ef5f
Binary files /dev/null and b/assets/screenshots/08-inventario-item-ptbr.webp differ
diff --git a/docs/AUTORIA-E-PROCESSO.md b/docs/AUTORIA-E-PROCESSO.md
index 246c216..c3df552 100644
--- a/docs/AUTORIA-E-PROCESSO.md
+++ b/docs/AUTORIA-E-PROCESSO.md
@@ -39,6 +39,6 @@ A versão `0.1.0 beta` é uma primeira versão funcional de ampla cobertura. A v
## Política de transparência
-O repositório publica métricas agregadas, documentação, manifesto e limitações. Bancos extraídos, textos integrais, chaves, dumps e arquivos proprietários do jogo não são distribuídos.
+O repositório publica métricas agregadas, documentação, manifesto, limitações e o código autoral necessário para auditar a interface e a segurança transacional do instalador. Bancos extraídos, catálogo, carga útil, textos integrais, chaves, dumps, binários e arquivos proprietários do jogo não são distribuídos.
Cada versão declara o cliente validado e não promete compatibilidade com atualizações futuras desconhecidas.
diff --git a/docs/COMPILACAO.md b/docs/COMPILACAO.md
new file mode 100644
index 0000000..7604285
--- /dev/null
+++ b/docs/COMPILACAO.md
@@ -0,0 +1,32 @@
+# Compilação e limites do código público
+
+O repositório disponibiliza o código autoral da interface WPF, do núcleo transacional e das ferramentas que validam o pacote do NIKKE PT-BR. Esse material permite auditar as regras de instalação, verificação, reparo, remoção, recuperação e integridade.
+
+## Conteúdo intencionalmente ausente
+
+O catálogo de tradução, o índice compilado, os binários do runtime, o bootstrap incorporado e qualquer dado obtido do cliente não fazem parte do repositório. Por esse motivo, compilar apenas o código público produz a interface verificável do instalador, mas não uma distribuição funcional do mod.
+
+O instalador pronto e aprovado deve ser obtido exclusivamente na [Release oficial](https://github.com/kacksdev/nikke-ptbr/releases/latest). Não aceite compilações de terceiros como equivalentes à versão publicada.
+
+## Requisitos para validação
+
+- Windows x64;
+- Python 3.10 ou posterior;
+- .NET SDK 8 ou posterior;
+- acesso à internet apenas para restaurar o pacote de referências do .NET na primeira compilação.
+
+## Validação local
+
+Na raiz do repositório, execute:
+
+```powershell
+.\tools\Test-PublicSource.ps1
+```
+
+O script executa a auditoria do conteúdo público, os testes do núcleo transacional e a compilação determinística da interface WPF sem carga útil. A mesma sequência é executada pelo GitHub Actions.
+
+## Construção oficial
+
+A construção de uma distribuição funcional exige uma carga útil privada previamente aprovada, composta somente pelos três componentes pertencentes ao projeto. O bootstrap é criado com ordem e timestamps fixos, tem cada arquivo registrado por SHA-256 e é incorporado ao instalador somente depois da verificação do contrato.
+
+Esse processo deliberadamente não é transformado em um download alternativo pelo repositório. A separação protege os direitos dos titulares, impede a publicação acidental de dados do jogo e mantém uma única origem oficial para o binário testado.
diff --git a/docs/FERRAMENTAS.md b/docs/FERRAMENTAS.md
index 638aaec..f99061e 100644
--- a/docs/FERRAMENTAS.md
+++ b/docs/FERRAMENTAS.md
@@ -42,3 +42,9 @@ O instalador não baixa componentes nem envia telemetria durante a operação.
| OpenAI Codex | Tradução em escala, engenharia, auditoria, testes e documentação sob direção do mantenedor. |
| Git e GitHub | Versionamento, Issues, segurança, manifesto e Releases. |
| GameBanana | Distribuição alternativa e feedback da comunidade. |
+
+## Código público
+
+O repositório público contém a interface WPF do instalador, o núcleo transacional em Python, o empacotador determinístico, testes automatizados e a validação contínua. O catálogo PT-BR, a carga útil compilada, binários e dados obtidos do cliente não são publicados.
+
+Consulte [Compilação e limites do código público](./COMPILACAO.md) para reproduzir a validação disponível sem material privado.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 7a36c48..5ed7f78 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -53,13 +53,13 @@ O roadmap separa cobertura técnica, revisão editorial, integração, instalaç
## Fase 5: primeira publicação
-**Estado: em preparação**
+**Estado: concluída em 11 de setembro de 2026**
-- documentação pública atualizada;
-- manifesto e checksums preparados;
-- Release do GitHub preparada em rascunho;
-- página do GameBanana mantida privada até revisão final;
-- publicação simultânea somente após autorização explícita.
+- documentação pública revisada;
+- Release `v0.1.0` publicada no GitHub com instalador, manifesto e checksum;
+- distribuição alternativa publicada no GameBanana;
+- hashes dos downloads públicos conferidos contra os artefatos aprovados;
+- código autoral do instalador e do núcleo transacional disponibilizado para auditoria.
## Fase 6: manutenção e maturidade editorial
diff --git a/docs/STATUS.md b/docs/STATUS.md
index dcb5d22..4b53db6 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -1,8 +1,8 @@
# Status técnico
-Atualizado em 10 de setembro de 2026.
+Atualizado em 11 de setembro de 2026.
-## Versão preparada
+## Versão publicada
| Item | Estado |
| --- | --- |
@@ -37,7 +37,8 @@ Nenhum arquivo oficial é substituído. O runtime valida o cliente e falha de fo
| --- | --- |
| Pacote transacional | 18/18 |
| Executável final em réplica | 11/11 |
-| Suíte atual | 116/116 |
+| Suíte da versão publicada | 116/116 |
+| Testes públicos do instalador | 23/23 |
| Construções finais consecutivas | Idênticas byte a byte |
| Instalação real | Aprovada |
| Inicialização pelo launcher | Aprovada |
@@ -62,4 +63,6 @@ A carga útil exata da versão final foi aceita no cliente real. Depois dessa pr
## Publicação
-Os artefatos preparados para a versão incluem instalador, SHA-256, manifesto e documentação. A página pública deve sempre indicar o cliente validado e separar cobertura técnica de revisão editorial.
+A versão `0.1.0` está disponível na [Release oficial do GitHub](https://github.com/kacksdev/nikke-ptbr/releases/tag/v0.1.0) e na [página oficial do GameBanana](https://gamebanana.com/mods/715729). Os dois downloads públicos foram conferidos por SHA-256 contra os artefatos aprovados.
+
+O código autoral da interface do instalador, do núcleo transacional e dos respectivos testes está publicado para auditoria. Catálogo, carga útil, binários compilados e materiais proprietários do cliente permanecem fora do repositório.
diff --git a/installer/NIKKEPTBR.Installer/App.xaml b/installer/NIKKEPTBR.Installer/App.xaml
new file mode 100644
index 0000000..568b2c7
--- /dev/null
+++ b/installer/NIKKEPTBR.Installer/App.xaml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/installer/NIKKEPTBR.Installer/App.xaml.cs b/installer/NIKKEPTBR.Installer/App.xaml.cs
new file mode 100644
index 0000000..a3b2bf0
--- /dev/null
+++ b/installer/NIKKEPTBR.Installer/App.xaml.cs
@@ -0,0 +1,110 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Web.Script.Serialization;
+using System.Windows;
+
+namespace NIKKEPTBR.Installer
+{
+ public partial class App : Application
+ {
+ protected override void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+ if (e.Args.Any(arg => string.Equals(arg, "--headless", StringComparison.OrdinalIgnoreCase)))
+ {
+ RunHeadless(e.Args);
+ return;
+ }
+
+ var requested = ReadOption(e.Args, "--game-root");
+ var window = new MainWindow(requested);
+ MainWindow = window;
+ ShutdownMode = ShutdownMode.OnMainWindowClose;
+ window.Show();
+ }
+
+ private void RunHeadless(string[] args)
+ {
+ CoreResponse response;
+ HeadlessOptions options = null;
+ try
+ {
+ options = HeadlessOptions.Parse(args);
+ var bridge = new CoreBridge(options.CacheRoot, options.StateRoot);
+ response = bridge.ExecuteAsync(
+ options.Action,
+ options.GameRoot,
+ new InlineProgress(_ => { })).GetAwaiter().GetResult();
+ }
+ catch (Exception error)
+ {
+ response = new CoreResponse
+ {
+ Success = false,
+ Action = options?.Action.ToString().ToLowerInvariant() ?? "unknown",
+ Message = error.Message,
+ Technical = error.ToString()
+ };
+ }
+
+ var reportPath = options?.ReportPath ?? ReadOption(args, "--report");
+ if (!string.IsNullOrWhiteSpace(reportPath))
+ {
+ WriteJsonAtomic(Path.GetFullPath(reportPath), response);
+ }
+ Environment.ExitCode = response.Success ? 0 : 1;
+ Shutdown(Environment.ExitCode);
+ }
+
+ private static string ReadOption(string[] args, string name)
+ {
+ for (var i = 0; i + 1 < args.Length; i++)
+ {
+ if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
+ {
+ return args[i + 1];
+ }
+ }
+ return null;
+ }
+
+ private static void WriteJsonAtomic(string path, CoreResponse response)
+ {
+ var parent = Path.GetDirectoryName(path);
+ if (string.IsNullOrWhiteSpace(parent))
+ {
+ throw new InvalidOperationException("Caminho de relatório inválido.");
+ }
+ Directory.CreateDirectory(parent);
+ var temporary = path + ".tmp-" + Guid.NewGuid().ToString("N");
+ var serializer = new JavaScriptSerializer { MaxJsonLength = int.MaxValue };
+ File.WriteAllText(temporary, serializer.Serialize(response), new UTF8Encoding(false));
+ if (File.Exists(path))
+ {
+ File.Replace(temporary, path, null);
+ }
+ else
+ {
+ File.Move(temporary, path);
+ }
+ }
+ }
+
+ internal sealed class InlineProgress : IProgress
+ {
+ private readonly Action _callback;
+
+ public InlineProgress(Action callback)
+ {
+ _callback = callback ?? throw new ArgumentNullException(nameof(callback));
+ }
+
+ public void Report(T value)
+ {
+ _callback(value);
+ }
+ }
+}
diff --git a/installer/NIKKEPTBR.Installer/Assets/THIRD-PARTY-NOTICES.txt b/installer/NIKKEPTBR.Installer/Assets/THIRD-PARTY-NOTICES.txt
new file mode 100644
index 0000000..28f0e38
--- /dev/null
+++ b/installer/NIKKEPTBR.Installer/Assets/THIRD-PARTY-NOTICES.txt
@@ -0,0 +1,51 @@
+NIKKE PT-BR installer third-party notices
+
+Python 3.10
+Copyright (c) 2001-2026 Python Software Foundation.
+Distributed under the Python Software Foundation License Version 2.
+https://www.python.org/psf/license/
+
+PyInstaller 6.22.2
+Copyright (c) 2010-2026 PyInstaller Development Team.
+Distributed under GPL-2.0-or-later with the PyInstaller bootloader exception.
+https://pyinstaller.org/
+
+MinHook 1.3.4
+Commit c3fcafdc10146beb5919319d0683e44e3c30d537.
+Copyright (C) 2009-2017 Tsuda Kageyu.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+Portions of MinHook include Hacker Disassembler Engine 32/64 C.
+Copyright (c) 2008-2009, Vyacheslav Patkov.
+All rights reserved. These portions use the same BSD 2-Clause conditions and
+disclaimer reproduced above.
+
+MinHook source and complete license:
+https://github.com/TsudaKageyu/minhook/tree/v1.3.4
+
+The installer interface uses Microsoft .NET Framework components supplied by Windows.
+
+GODDESS OF VICTORY: NIKKE and its artwork belong to their respective rights holders.
+This free community translation is independent and is not affiliated with or endorsed by
+SHIFT UP, Level Infinite, Proxima Beta, or the official game publishers.
diff --git a/installer/NIKKEPTBR.Installer/Assets/installer-background.png b/installer/NIKKEPTBR.Installer/Assets/installer-background.png
new file mode 100644
index 0000000..110009d
Binary files /dev/null and b/installer/NIKKEPTBR.Installer/Assets/installer-background.png differ
diff --git a/installer/NIKKEPTBR.Installer/Assets/kacks-installer.ico b/installer/NIKKEPTBR.Installer/Assets/kacks-installer.ico
new file mode 100644
index 0000000..cf9ceaf
Binary files /dev/null and b/installer/NIKKEPTBR.Installer/Assets/kacks-installer.ico differ
diff --git a/installer/NIKKEPTBR.Installer/BootstrapMetadata.cs b/installer/NIKKEPTBR.Installer/BootstrapMetadata.cs
new file mode 100644
index 0000000..02fba4e
--- /dev/null
+++ b/installer/NIKKEPTBR.Installer/BootstrapMetadata.cs
@@ -0,0 +1,12 @@
+namespace NIKKEPTBR.Installer
+{
+ internal static class BootstrapMetadata
+ {
+ public const string ResourceName = "NIKKEPTBR.Bootstrap.zip";
+ public const string ExpectedSha256 = "ED14ECD3139F8D4FDC78A906E1AB88C0E3382F5F949A44A1B7BF863556DEEB89";
+ public const long ExpectedSize = 41878741;
+ public const string BuildId = "CB9793DE936A2DF96CDC84A2";
+ public const string CoreRelativePath = "core/NIKKEPTBR-Core.exe";
+ public const string PackageRelativePath = "package";
+ }
+}
diff --git a/installer/NIKKEPTBR.Installer/CoreBridge.cs b/installer/NIKKEPTBR.Installer/CoreBridge.cs
new file mode 100644
index 0000000..bfbe5c6
--- /dev/null
+++ b/installer/NIKKEPTBR.Installer/CoreBridge.cs
@@ -0,0 +1,264 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+using System.Web.Script.Serialization;
+
+namespace NIKKEPTBR.Installer
+{
+ public sealed class CoreBridge
+ {
+ private readonly string _cacheOverride;
+ private readonly string _stateOverride;
+ private readonly object _bootstrapLock = new object();
+ private BootstrapContext _context;
+
+ public CoreBridge(string cacheOverride = null, string stateOverride = null)
+ {
+ _cacheOverride = cacheOverride;
+ _stateOverride = stateOverride;
+ }
+
+ public Task ExecuteAsync(
+ InstallerAction action,
+ string gameRoot,
+ IProgress progress)
+ {
+ if (string.IsNullOrWhiteSpace(gameRoot))
+ {
+ throw new ArgumentException("A pasta do jogo não foi informada.", nameof(gameRoot));
+ }
+ return Task.Run(() => Execute(action, Path.GetFullPath(gameRoot), progress));
+ }
+
+ private CoreResponse Execute(
+ InstallerAction action,
+ string gameRoot,
+ IProgress progress)
+ {
+ var context = EnsureBootstrap(progress);
+ var stateRoot = string.IsNullOrWhiteSpace(_stateOverride)
+ ? DefaultStateRoot(gameRoot)
+ : Path.GetFullPath(_stateOverride);
+ var allowedParent = Directory.GetParent(gameRoot)?.FullName;
+ if (string.IsNullOrWhiteSpace(allowedParent))
+ {
+ throw new InvalidOperationException("A pasta do cliente não possui um diretório pai seguro.");
+ }
+
+ var arguments = new List
+ {
+ "--action", ActionName(action),
+ "--package-root", context.PackageRoot,
+ "--target-root", gameRoot,
+ "--state-root", stateRoot,
+ "--allowed-target-parent", allowedParent
+ };
+ var start = new ProcessStartInfo
+ {
+ FileName = context.CoreExecutable,
+ Arguments = JoinArguments(arguments),
+ WorkingDirectory = Path.GetDirectoryName(context.CoreExecutable),
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ StandardOutputEncoding = new UTF8Encoding(false),
+ StandardErrorEncoding = new UTF8Encoding(false)
+ };
+ start.EnvironmentVariables["PYTHONUTF8"] = "1";
+ start.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8";
+
+ CoreResponse final = null;
+ var serializer = new JavaScriptSerializer { MaxJsonLength = int.MaxValue };
+ using (var process = new Process { StartInfo = start })
+ {
+ if (!process.Start())
+ {
+ throw new InvalidOperationException("Não foi possível iniciar o núcleo seguro do instalador.");
+ }
+ var errorRead = process.StandardError.ReadToEndAsync();
+ string line;
+ while ((line = process.StandardOutput.ReadLine()) != null)
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+ Dictionary payload;
+ try
+ {
+ payload = serializer.Deserialize>(line);
+ }
+ catch (Exception parseError)
+ {
+ throw new InvalidDataException("Resposta interna inválida do instalador.", parseError);
+ }
+
+ object typeValue;
+ var type = payload.TryGetValue("type", out typeValue)
+ ? Convert.ToString(typeValue)
+ : string.Empty;
+ if (string.Equals(type, "progress", StringComparison.OrdinalIgnoreCase))
+ {
+ var corePercent = GetInt(payload, "percent");
+ progress?.Report(new InstallerProgress
+ {
+ Percent = 20 + (int)Math.Round(corePercent * 0.80),
+ Phase = GetString(payload, "phase"),
+ Message = GetString(payload, "message")
+ });
+ }
+ else if (string.Equals(type, "result", StringComparison.OrdinalIgnoreCase))
+ {
+ final = ParseResponse(payload);
+ }
+ }
+ process.WaitForExit();
+ var standardError = errorRead.GetAwaiter().GetResult();
+ if (final == null)
+ {
+ throw new InvalidOperationException(
+ "O núcleo do instalador terminou sem um relatório válido. " + standardError.Trim());
+ }
+ if (!final.Success || process.ExitCode != 0)
+ {
+ if (string.IsNullOrWhiteSpace(final.Technical))
+ {
+ final.Technical = standardError.Trim();
+ }
+ return final;
+ }
+ }
+ return final;
+ }
+
+ private BootstrapContext EnsureBootstrap(IProgress progress)
+ {
+ lock (_bootstrapLock)
+ {
+ if (_context == null)
+ {
+ _context = new PackageBootstrap(_cacheOverride).EnsureReady(progress);
+ }
+ return _context;
+ }
+ }
+
+ private static CoreResponse ParseResponse(Dictionary payload)
+ {
+ object dataValue;
+ var data = payload.TryGetValue("data", out dataValue)
+ ? dataValue as Dictionary
+ : null;
+ return new CoreResponse
+ {
+ Success = GetBool(payload, "success"),
+ Action = GetString(payload, "action"),
+ Message = GetString(payload, "message"),
+ Technical = GetString(payload, "technical"),
+ PackageId = GetString(payload, "package_id"),
+ PackageIdentity = GetString(payload, "package_identity_sha256"),
+ ModVersion = GetString(payload, "mod_version"),
+ ClientVersion = GetString(payload, "client_version"),
+ Data = data ?? new Dictionary()
+ };
+ }
+
+ private static string DefaultStateRoot(string gameRoot)
+ {
+ var baseRoot = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Kacksdev",
+ "NIKKEPTBR",
+ "state");
+ var normalized = Path.GetFullPath(gameRoot).TrimEnd(Path.DirectorySeparatorChar).ToUpperInvariant();
+ string key;
+ using (var sha = SHA256.Create())
+ {
+ key = BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(normalized)))
+ .Replace("-", string.Empty)
+ .Substring(0, 20);
+ }
+ return Path.Combine(baseRoot, key);
+ }
+
+ private static string ActionName(InstallerAction action)
+ {
+ return action.ToString().ToLowerInvariant();
+ }
+
+ private static string GetString(IDictionary payload, string key)
+ {
+ object value;
+ return payload.TryGetValue(key, out value) && value != null
+ ? Convert.ToString(value, CultureInfo.InvariantCulture)
+ : string.Empty;
+ }
+
+ private static int GetInt(IDictionary payload, string key)
+ {
+ object value;
+ return payload.TryGetValue(key, out value) && value != null
+ ? Convert.ToInt32(value, CultureInfo.InvariantCulture)
+ : 0;
+ }
+
+ private static bool GetBool(IDictionary payload, string key)
+ {
+ object value;
+ return payload.TryGetValue(key, out value) && value != null &&
+ Convert.ToBoolean(value, CultureInfo.InvariantCulture);
+ }
+
+ private static string JoinArguments(IEnumerable values)
+ {
+ var result = new StringBuilder();
+ foreach (var value in values)
+ {
+ if (result.Length > 0)
+ {
+ result.Append(' ');
+ }
+ result.Append(QuoteArgument(value ?? string.Empty));
+ }
+ return result.ToString();
+ }
+
+ private static string QuoteArgument(string value)
+ {
+ if (value.Length > 0 && value.IndexOfAny(new[] { ' ', '\t', '\n', '\v', '"' }) < 0)
+ {
+ return value;
+ }
+ var result = new StringBuilder();
+ result.Append('"');
+ var backslashes = 0;
+ foreach (var character in value)
+ {
+ if (character == '\\')
+ {
+ backslashes++;
+ continue;
+ }
+ if (character == '"')
+ {
+ result.Append('\\', backslashes * 2 + 1);
+ result.Append('"');
+ backslashes = 0;
+ continue;
+ }
+ result.Append('\\', backslashes);
+ backslashes = 0;
+ result.Append(character);
+ }
+ result.Append('\\', backslashes * 2);
+ result.Append('"');
+ return result.ToString();
+ }
+ }
+}
diff --git a/installer/NIKKEPTBR.Installer/GameDetector.cs b/installer/NIKKEPTBR.Installer/GameDetector.cs
new file mode 100644
index 0000000..9e0bbc8
--- /dev/null
+++ b/installer/NIKKEPTBR.Installer/GameDetector.cs
@@ -0,0 +1,183 @@
+using Microsoft.Win32;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+
+namespace NIKKEPTBR.Installer
+{
+ public static class GameDetector
+ {
+ private static readonly string[] RequiredFiles =
+ {
+ "nikke.exe",
+ "UnityPlayer.dll",
+ "GameAssembly.dll"
+ };
+
+ public static string Detect()
+ {
+ foreach (var candidate in CandidateRoots())
+ {
+ var normalized = Normalize(candidate);
+ if (!string.IsNullOrWhiteSpace(normalized))
+ {
+ return normalized;
+ }
+ }
+ return null;
+ }
+
+ public static string Normalize(string requested)
+ {
+ if (string.IsNullOrWhiteSpace(requested))
+ {
+ return null;
+ }
+ try
+ {
+ var basePath = File.Exists(requested)
+ ? Path.GetDirectoryName(Path.GetFullPath(requested))
+ : Path.GetFullPath(requested);
+ var variants = new[]
+ {
+ basePath,
+ Path.Combine(basePath, "game"),
+ Path.Combine(basePath, "NIKKE", "game"),
+ Path.Combine(basePath, "NIKKE", "NIKKE", "game")
+ };
+ return variants.FirstOrDefault(IsClientRoot);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public static bool IsClientRoot(string path)
+ {
+ return !string.IsNullOrWhiteSpace(path) &&
+ Directory.Exists(path) &&
+ RequiredFiles.All(name => File.Exists(Path.Combine(path, name)));
+ }
+
+ private static IEnumerable CandidateRoots()
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var candidate in RawCandidates())
+ {
+ if (string.IsNullOrWhiteSpace(candidate))
+ {
+ continue;
+ }
+ string full;
+ try
+ {
+ full = Path.GetFullPath(candidate);
+ }
+ catch
+ {
+ continue;
+ }
+ if (seen.Add(full))
+ {
+ yield return full;
+ }
+ }
+ }
+
+ private static IEnumerable RawCandidates()
+ {
+ var environment = Environment.GetEnvironmentVariable("NIKKE_GAME_ROOT");
+ if (!string.IsNullOrWhiteSpace(environment))
+ {
+ yield return environment;
+ }
+ yield return AppDomain.CurrentDomain.BaseDirectory;
+ yield return Environment.CurrentDirectory;
+
+ foreach (var processName in new[] { "nikke", "nikke_launcher" })
+ {
+ foreach (var process in Process.GetProcessesByName(processName))
+ {
+ using (process)
+ {
+ string executable = null;
+ try
+ {
+ executable = process.MainModule?.FileName;
+ }
+ catch
+ {
+ }
+ if (!string.IsNullOrWhiteSpace(executable))
+ {
+ yield return Path.GetDirectoryName(executable);
+ }
+ }
+ }
+ }
+
+ foreach (var location in RegistryLocations())
+ {
+ yield return location;
+ }
+
+ foreach (var drive in DriveInfo.GetDrives().Where(item => item.IsReady && item.DriveType == DriveType.Fixed))
+ {
+ var root = drive.RootDirectory.FullName;
+ yield return Path.Combine(root, "Games", "NIKKE", "NIKKE", "game");
+ yield return Path.Combine(root, "Games", "NIKKE");
+ yield return Path.Combine(root, "NIKKE", "NIKKE", "game");
+ yield return Path.Combine(root, "NIKKE");
+ yield return Path.Combine(root, "Level Infinite", "NIKKE");
+ yield return Path.Combine(root, "Program Files", "NIKKE");
+ yield return Path.Combine(root, "Program Files (x86)", "NIKKE");
+ }
+ }
+
+ private static IEnumerable RegistryLocations()
+ {
+ var locations = new List();
+ foreach (var hive in new[] { RegistryHive.CurrentUser, RegistryHive.LocalMachine })
+ {
+ foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
+ {
+ try
+ {
+ using (var root = RegistryKey.OpenBaseKey(hive, view))
+ using (var uninstall = root.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"))
+ {
+ if (uninstall == null)
+ {
+ continue;
+ }
+ foreach (var name in uninstall.GetSubKeyNames())
+ {
+ using (var key = uninstall.OpenSubKey(name))
+ {
+ var display = Convert.ToString(key?.GetValue("DisplayName"));
+ if (display?.IndexOf("NIKKE", StringComparison.OrdinalIgnoreCase) < 0 &&
+ display?.IndexOf("GODDESS OF VICTORY", StringComparison.OrdinalIgnoreCase) < 0)
+ {
+ continue;
+ }
+ var install = Convert.ToString(key?.GetValue("InstallLocation"));
+ if (!string.IsNullOrWhiteSpace(install))
+ {
+ locations.Add(install.Trim('"'));
+ }
+ }
+ }
+ }
+ }
+ catch
+ {
+ }
+ }
+ }
+ return locations;
+ }
+ }
+}
diff --git a/installer/NIKKEPTBR.Installer/MainWindow.xaml b/installer/NIKKEPTBR.Installer/MainWindow.xaml
new file mode 100644
index 0000000..ee59021
--- /dev/null
+++ b/installer/NIKKEPTBR.Installer/MainWindow.xaml
@@ -0,0 +1,187 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/installer/NIKKEPTBR.Installer/MainWindow.xaml.cs b/installer/NIKKEPTBR.Installer/MainWindow.xaml.cs
new file mode 100644
index 0000000..772c834
--- /dev/null
+++ b/installer/NIKKEPTBR.Installer/MainWindow.xaml.cs
@@ -0,0 +1,463 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Reflection;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Input;
+using System.Windows.Media;
+using Forms = System.Windows.Forms;
+
+namespace NIKKEPTBR.Installer
+{
+ public partial class MainWindow : Window
+ {
+ private const string GitHubUrl = "https://github.com/kacksdev/nikke-ptbr";
+ private const string GameBananaUrl = "https://gamebanana.com/members/4201671";
+ private readonly CoreBridge _bridge = new CoreBridge();
+ private readonly string _requestedPath;
+ private bool _busy;
+ private CoreResponse _inspection;
+ private InstallerAction _recommendedAction = InstallerAction.Install;
+ private int _lastLoggedPercent = -100;
+ private string _lastLoggedPhase = string.Empty;
+ private string _lastLoggedMessage = string.Empty;
+
+ public MainWindow(string requestedPath = null)
+ {
+ _requestedPath = requestedPath;
+ InitializeComponent();
+ ConfigureResponsiveWindow();
+ Loaded += async (_, __) => await DetectAndInspectAsync(_requestedPath);
+ }
+
+ private void ConfigureResponsiveWindow()
+ {
+ var workArea = SystemParameters.WorkArea;
+ var highResolution = workArea.Width >= 2400 && workArea.Height >= 1300;
+ var targetWidth = highResolution ? 1920.0 : 1280.0;
+ var targetHeight = highResolution ? 1080.0 : 720.0;
+ Width = Math.Min(targetWidth, Math.Max(960, workArea.Width - 16));
+ Height = Math.Min(targetHeight, Math.Max(600, workArea.Height - 16));
+ MaxWidth = workArea.Width;
+ MaxHeight = workArea.Height;
+ }
+
+ private async Task DetectAndInspectAsync(string requestedPath)
+ {
+ SetBusy(true);
+ SetStatus("LOCALIZANDO O JOGO", "Validando a instalação oficial do NIKKE.", Brushes.White);
+ AddLog("Iniciando detecção segura do cliente.");
+ try
+ {
+ var path = string.IsNullOrWhiteSpace(requestedPath)
+ ? await Task.Run(() => GameDetector.Detect())
+ : await Task.Run(() => GameDetector.Normalize(requestedPath));
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new InvalidOperationException(
+ "A pasta oficial do NIKKE não foi localizada automaticamente. Escolha a pasta que contém nikke.exe.");
+ }
+
+ GamePathBox.Text = path;
+ var progress = new Progress(UpdateProgress);
+ var response = await _bridge.ExecuteAsync(InstallerAction.Inspect, path, progress);
+ if (!response.Success)
+ {
+ throw new InstallerUiException(response);
+ }
+ _inspection = response;
+ if (!string.IsNullOrWhiteSpace(response.ModVersion))
+ {
+ ModVersionText.Text = response.ModVersion;
+ }
+ if (!string.IsNullOrWhiteSpace(response.ClientVersion))
+ {
+ ClientVersionText.Text = response.ClientVersion;
+ }
+ ApplyInspection(response);
+ SetInspectionProgress(response.StringData("health"));
+ AddLog("Cliente validado: " + path);
+ AddLog(response.StringData("summary"));
+ }
+ catch (InstallerUiException error)
+ {
+ _inspection = null;
+ PresentFailure(error.Response);
+ DisableActionsForUnconfirmedClient();
+ }
+ catch (Exception error)
+ {
+ _inspection = null;
+ SetStatus("PASTA NÃO CONFIRMADA", error.Message, WarningBrush());
+ AddLog("Detecção não concluída: " + error.Message);
+ DisableActionsForUnconfirmedClient();
+ }
+ finally
+ {
+ SetBusy(false);
+ }
+ }
+
+ private void ApplyInspection(CoreResponse response)
+ {
+ var health = response.StringData("health");
+ var summary = response.StringData("summary");
+ var recommended = response.StringData("recommended_action");
+ switch (health)
+ {
+ case "installed_verified":
+ SetStatus("INSTALAÇÃO VERIFICADA", summary, SuccessBrush());
+ PrimaryButton.Content = "TRADUÇÃO INSTALADA";
+ PrimaryButton.IsEnabled = false;
+ VerifyButton.IsEnabled = true;
+ RemoveButton.IsEnabled = true;
+ _recommendedAction = InstallerAction.Verify;
+ break;
+ case "repair_required":
+ SetStatus("REPARO NECESSÁRIO", summary, WarningBrush());
+ PrimaryButton.Content = "REPARAR AGORA";
+ PrimaryButton.IsEnabled = true;
+ VerifyButton.IsEnabled = true;
+ RemoveButton.IsEnabled = true;
+ _recommendedAction = InstallerAction.Repair;
+ break;
+ case "recovery_required":
+ _recommendedAction = ParseAction(recommended);
+ SetStatus("RETOMADA DISPONÍVEL", summary, WarningBrush());
+ PrimaryButton.Content = "RETOMAR " + ActionLabel(_recommendedAction);
+ PrimaryButton.IsEnabled = true;
+ VerifyButton.IsEnabled = false;
+ RemoveButton.IsEnabled = false;
+ break;
+ case "not_installed":
+ SetStatus("PRONTO PARA INSTALAR", summary, Brushes.White);
+ PrimaryButton.Content = "INSTALAR TRADUÇÃO";
+ PrimaryButton.IsEnabled = true;
+ VerifyButton.IsEnabled = false;
+ RemoveButton.IsEnabled = false;
+ _recommendedAction = InstallerAction.Install;
+ break;
+ default:
+ SetStatus("AÇÃO MANUAL NECESSÁRIA", summary, WarningBrush());
+ PrimaryButton.Content = "INSTALAÇÃO BLOQUEADA";
+ PrimaryButton.IsEnabled = false;
+ VerifyButton.IsEnabled = false;
+ RemoveButton.IsEnabled = false;
+ _recommendedAction = InstallerAction.Inspect;
+ DetailsExpander.IsExpanded = true;
+ break;
+ }
+ }
+
+ private async void PrimaryAction_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (_inspection == null)
+ {
+ Browse_OnClick(sender, e);
+ return;
+ }
+ if (_recommendedAction == InstallerAction.Install ||
+ _recommendedAction == InstallerAction.Repair ||
+ _recommendedAction == InstallerAction.Remove)
+ {
+ await RunOperationAsync(_recommendedAction);
+ }
+ }
+
+ private async void Verify_OnClick(object sender, RoutedEventArgs e)
+ {
+ await RunOperationAsync(InstallerAction.Verify);
+ }
+
+ private async void Remove_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (_inspection == null)
+ {
+ return;
+ }
+ var answer = MessageBox.Show(
+ "Remover a tradução PT-BR deste cliente? O instalador removerá somente os três componentes que pertencem exatamente a este pacote e preservará o relatório de execução.",
+ "Confirmar remoção",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+ if (answer == MessageBoxResult.Yes)
+ {
+ await RunOperationAsync(InstallerAction.Remove);
+ }
+ }
+
+ private async Task RunOperationAsync(InstallerAction action)
+ {
+ if (_busy || _inspection == null || string.IsNullOrWhiteSpace(GamePathBox.Text))
+ {
+ return;
+ }
+
+ SetBusy(true);
+ DetailsExpander.IsExpanded = true;
+ ResetProgress();
+ AddLog("Operação iniciada: " + ActionLabel(action) + ".");
+ try
+ {
+ var progress = new Progress(UpdateProgress);
+ var response = await _bridge.ExecuteAsync(action, GamePathBox.Text, progress);
+ if (!response.Success)
+ {
+ PresentFailure(response);
+ }
+ else
+ {
+ SetStatus("OPERAÇÃO CONCLUÍDA", SuccessMessage(action), SuccessBrush());
+ CompletionHint.Text = "Depois de testar, avalie o projeto no GitHub ou no GameBanana. Se encontrar um problema, envie um relato por uma dessas páginas.";
+ AddLog(SuccessMessage(action));
+ }
+ }
+ catch (Exception error)
+ {
+ PresentFailure(new CoreResponse
+ {
+ Success = false,
+ Action = action.ToString().ToLowerInvariant(),
+ Message = error.Message,
+ Technical = error.ToString()
+ });
+ }
+ finally
+ {
+ SetBusy(false);
+ }
+
+ await DetectAndInspectAsync(GamePathBox.Text);
+ }
+
+ private void PresentFailure(CoreResponse response)
+ {
+ var message = string.IsNullOrWhiteSpace(response?.Message)
+ ? "A operação não foi concluída. Nenhuma continuação insegura foi executada."
+ : response.Message;
+ SetStatus("OPERAÇÃO NÃO CONCLUÍDA", message, WarningBrush());
+ AddLog("Falha: " + message);
+ if (!string.IsNullOrWhiteSpace(response?.Technical) &&
+ !string.Equals(response.Technical, message, StringComparison.Ordinal))
+ {
+ AddLog("Detalhe técnico: " + response.Technical);
+ }
+ DetailsExpander.IsExpanded = true;
+ }
+
+ private void UpdateProgress(InstallerProgress item)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ var percent = Math.Max(0, Math.Min(100, item.Percent));
+ InstallProgress.Value = percent;
+ ProgressPercent.Text = percent + "%";
+ ProgressPhase.Text = string.IsNullOrWhiteSpace(item.Phase) ? "PROCESSANDO" : item.Phase;
+ var phaseChanged = !string.Equals(_lastLoggedPhase, item.Phase, StringComparison.Ordinal);
+ var messageChanged = !string.Equals(_lastLoggedMessage, item.Message, StringComparison.Ordinal);
+ if (phaseChanged || messageChanged || percent == 100 || percent - _lastLoggedPercent >= 10)
+ {
+ AddLog(item.Message);
+ _lastLoggedPhase = item.Phase ?? string.Empty;
+ _lastLoggedMessage = item.Message ?? string.Empty;
+ _lastLoggedPercent = percent;
+ }
+ });
+ }
+
+ private async void Browse_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (_busy)
+ {
+ return;
+ }
+ using (var picker = new Forms.FolderBrowserDialog())
+ {
+ picker.Description = "Selecione a pasta que contém nikke.exe";
+ picker.ShowNewFolderButton = false;
+ if (picker.ShowDialog() == Forms.DialogResult.OK)
+ {
+ await DetectAndInspectAsync(picker.SelectedPath);
+ }
+ }
+ }
+
+ private void SetBusy(bool busy)
+ {
+ _busy = busy;
+ BrowseButton.IsEnabled = !busy;
+ if (busy)
+ {
+ PrimaryButton.IsEnabled = false;
+ VerifyButton.IsEnabled = false;
+ RemoveButton.IsEnabled = false;
+ }
+ else if (_inspection != null)
+ {
+ ApplyInspection(_inspection);
+ }
+ }
+
+ private void DisableActionsForUnconfirmedClient()
+ {
+ PrimaryButton.Content = "ESCOLHER PASTA";
+ PrimaryButton.IsEnabled = true;
+ VerifyButton.IsEnabled = false;
+ RemoveButton.IsEnabled = false;
+ }
+
+ private void ResetProgress()
+ {
+ InstallProgress.Value = 0;
+ ProgressPercent.Text = "0%";
+ ProgressPhase.Text = "PREPARANDO";
+ _lastLoggedPercent = -100;
+ _lastLoggedPhase = string.Empty;
+ _lastLoggedMessage = string.Empty;
+ }
+
+ private void SetInspectionProgress(string health)
+ {
+ if (string.Equals(health, "installed_verified", StringComparison.Ordinal))
+ {
+ InstallProgress.Value = 100;
+ ProgressPercent.Text = "100%";
+ ProgressPhase.Text = "VERIFICADO";
+ }
+ else
+ {
+ InstallProgress.Value = 0;
+ ProgressPercent.Text = "0%";
+ ProgressPhase.Text = string.Equals(health, "repair_required", StringComparison.Ordinal)
+ ? "REPARO NECESSÁRIO"
+ : "PRONTO";
+ }
+ }
+
+ private void SetStatus(string title, string message, Brush color)
+ {
+ StatusTitle.Text = title;
+ StatusMessage.Text = message;
+ StatusDot.Fill = color;
+ }
+
+ private void AddLog(string message)
+ {
+ if (string.IsNullOrWhiteSpace(message))
+ {
+ return;
+ }
+ LogText.Text += "[" + DateTime.Now.ToString("HH:mm:ss") + "] " + message + Environment.NewLine;
+ LogScroller.ScrollToEnd();
+ }
+
+ private static InstallerAction ParseAction(string action)
+ {
+ InstallerAction parsed;
+ return Enum.TryParse(action, true, out parsed) ? parsed : InstallerAction.Inspect;
+ }
+
+ private static string ActionLabel(InstallerAction action)
+ {
+ switch (action)
+ {
+ case InstallerAction.Install: return "INSTALAÇÃO";
+ case InstallerAction.Verify: return "VERIFICAÇÃO";
+ case InstallerAction.Repair: return "REPARO";
+ case InstallerAction.Remove: return "REMOÇÃO";
+ case InstallerAction.Rollback: return "RECUPERAÇÃO";
+ default: return "INSPEÇÃO";
+ }
+ }
+
+ private static string SuccessMessage(InstallerAction action)
+ {
+ switch (action)
+ {
+ case InstallerAction.Install: return "A tradução PT-BR foi instalada e verificada com sucesso.";
+ case InstallerAction.Verify: return "Todos os componentes da tradução foram verificados com sucesso.";
+ case InstallerAction.Repair: return "A tradução foi reparada e verificada com sucesso.";
+ case InstallerAction.Remove: return "A tradução foi removida com segurança.";
+ case InstallerAction.Rollback: return "O estado anterior foi restaurado e verificado.";
+ default: return "Operação concluída com sucesso.";
+ }
+ }
+
+ private static Brush SuccessBrush() => new SolidColorBrush(Color.FromRgb(215, 230, 219));
+ private static Brush WarningBrush() => new SolidColorBrush(Color.FromRgb(229, 181, 84));
+
+ private static void OpenUrl(string url)
+ {
+ Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ }
+
+ private void GitHub_OnClick(object sender, MouseButtonEventArgs e) => OpenUrl(GitHubUrl);
+ private void GameBanana_OnClick(object sender, MouseButtonEventArgs e) => OpenUrl(GameBananaUrl);
+
+ private void Licenses_OnClick(object sender, MouseButtonEventArgs e)
+ {
+ try
+ {
+ using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("NIKKEPTBR.ThirdPartyNotices.txt"))
+ using (var reader = new StreamReader(stream ?? throw new InvalidOperationException("Licenças não encontradas."), Encoding.UTF8))
+ {
+ MessageBox.Show(reader.ReadToEnd(), "Licenças e avisos", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ }
+ catch (Exception error)
+ {
+ MessageBox.Show(error.Message, "Licenças e avisos", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ }
+
+ private void Minimize_OnClick(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
+ private void Close_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (!_busy)
+ {
+ Close();
+ }
+ }
+
+ private void Header_OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ if (e.ClickCount == 2)
+ {
+ WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
+ }
+ else
+ {
+ DragMove();
+ }
+ }
+
+ private void Window_OnClosing(object sender, CancelEventArgs e)
+ {
+ if (!_busy)
+ {
+ return;
+ }
+ e.Cancel = true;
+ MessageBox.Show(
+ "Aguarde a operação atual terminar. Se o computador for desligado, o instalador retomará pelo journal persistente na próxima execução.",
+ "Operação em andamento",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ }
+ }
+
+ internal sealed class InstallerUiException : Exception
+ {
+ public CoreResponse Response { get; }
+
+ public InstallerUiException(CoreResponse response)
+ : base(response?.Message)
+ {
+ Response = response;
+ }
+ }
+}
diff --git a/installer/NIKKEPTBR.Installer/Models.cs b/installer/NIKKEPTBR.Installer/Models.cs
new file mode 100644
index 0000000..f8b8698
--- /dev/null
+++ b/installer/NIKKEPTBR.Installer/Models.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Collections.Generic;
+
+namespace NIKKEPTBR.Installer
+{
+ public enum InstallerAction
+ {
+ Inspect,
+ Install,
+ Verify,
+ Repair,
+ Remove,
+ Rollback
+ }
+
+ public sealed class InstallerProgress
+ {
+ public int Percent { get; set; }
+ public string Phase { get; set; }
+ public string Message { get; set; }
+ }
+
+ public sealed class CoreResponse
+ {
+ public bool Success { get; set; }
+ public string Action { get; set; }
+ public string Message { get; set; }
+ public string Technical { get; set; }
+ public string PackageId { get; set; }
+ public string PackageIdentity { get; set; }
+ public string ModVersion { get; set; }
+ public string ClientVersion { get; set; }
+ public Dictionary Data { get; set; } = new Dictionary();
+
+ public string StringData(string key)
+ {
+ object value;
+ return Data != null && Data.TryGetValue(key, out value) && value != null
+ ? Convert.ToString(value)
+ : string.Empty;
+ }
+ }
+
+ internal sealed class BootstrapManifest
+ {
+ public int schema_version { get; set; }
+ public string build_id { get; set; }
+ public string package_identity_sha256 { get; set; }
+ public List files { get; set; } = new List();
+ }
+
+ internal sealed class BootstrapFile
+ {
+ public string path { get; set; }
+ public long size { get; set; }
+ public string sha256 { get; set; }
+ }
+
+ internal sealed class BootstrapContext
+ {
+ public string Root { get; set; }
+ public string CoreExecutable { get; set; }
+ public string PackageRoot { get; set; }
+ }
+
+ internal sealed class HeadlessOptions
+ {
+ public InstallerAction Action { get; private set; } = InstallerAction.Inspect;
+ public string GameRoot { get; private set; }
+ public string ReportPath { get; private set; }
+ public string StateRoot { get; private set; }
+ public string CacheRoot { get; private set; }
+
+ public static HeadlessOptions Parse(string[] args)
+ {
+ var result = new HeadlessOptions();
+ for (var i = 0; i < args.Length; i++)
+ {
+ var value = args[i];
+ if (EqualsOption(value, "--action") && i + 1 < args.Length)
+ {
+ InstallerAction action;
+ if (!Enum.TryParse(args[++i], true, out action))
+ {
+ throw new ArgumentException("Ação headless inválida.");
+ }
+ result.Action = action;
+ }
+ else if (EqualsOption(value, "--game-root") && i + 1 < args.Length)
+ {
+ result.GameRoot = System.IO.Path.GetFullPath(args[++i]);
+ }
+ else if (EqualsOption(value, "--report") && i + 1 < args.Length)
+ {
+ result.ReportPath = System.IO.Path.GetFullPath(args[++i]);
+ }
+ else if (EqualsOption(value, "--state-root") && i + 1 < args.Length)
+ {
+ result.StateRoot = System.IO.Path.GetFullPath(args[++i]);
+ }
+ else if (EqualsOption(value, "--cache-root") && i + 1 < args.Length)
+ {
+ result.CacheRoot = System.IO.Path.GetFullPath(args[++i]);
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(result.GameRoot))
+ {
+ result.GameRoot = GameDetector.Detect();
+ }
+ if (string.IsNullOrWhiteSpace(result.GameRoot))
+ {
+ throw new InvalidOperationException("Pasta oficial do NIKKE não localizada.");
+ }
+ if (string.IsNullOrWhiteSpace(result.ReportPath))
+ {
+ throw new ArgumentException("O modo headless exige --report.");
+ }
+ return result;
+ }
+
+ private static bool EqualsOption(string value, string expected)
+ {
+ return string.Equals(value, expected, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/installer/NIKKEPTBR.Installer/NIKKEPTBR.Installer.csproj b/installer/NIKKEPTBR.Installer/NIKKEPTBR.Installer.csproj
new file mode 100644
index 0000000..cfc6fd2
--- /dev/null
+++ b/installer/NIKKEPTBR.Installer/NIKKEPTBR.Installer.csproj
@@ -0,0 +1,43 @@
+
+
+ WinExe
+ net472
+ true
+ NIKKEPTBR
+ Assets\kacks-installer.ico
+ NIKKEPTBR.Installer
+ app.manifest
+ 0.1.0
+ 0.1.0.0
+ 0.1.0.0
+ true
+ latest
+ disable
+ x64
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/installer/NIKKEPTBR.Installer/PackageBootstrap.cs b/installer/NIKKEPTBR.Installer/PackageBootstrap.cs
new file mode 100644
index 0000000..8328908
--- /dev/null
+++ b/installer/NIKKEPTBR.Installer/PackageBootstrap.cs
@@ -0,0 +1,300 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+using System.Web.Script.Serialization;
+
+namespace NIKKEPTBR.Installer
+{
+ internal sealed class PackageBootstrap
+ {
+ private const string ManifestName = "bootstrap-manifest.json";
+ private const string ReadyMarker = ".ready";
+ private readonly string _cacheBase;
+
+ public PackageBootstrap(string cacheOverride = null)
+ {
+ _cacheBase = Path.GetFullPath(string.IsNullOrWhiteSpace(cacheOverride)
+ ? Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Kacksdev",
+ "NIKKEPTBR",
+ "cache")
+ : cacheOverride);
+ }
+
+ public BootstrapContext EnsureReady(IProgress progress)
+ {
+ if (BootstrapMetadata.ExpectedSize <= 0 ||
+ string.Equals(BootstrapMetadata.ExpectedSha256, "PENDING_BUILD", StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException("O pacote interno do instalador ainda não foi materializado.");
+ }
+
+ Directory.CreateDirectory(_cacheBase);
+ var cacheRoot = Path.Combine(_cacheBase, BootstrapMetadata.BuildId);
+ if (Directory.Exists(cacheRoot))
+ {
+ Report(progress, 4, "VALIDANDO CACHE", "Conferindo o pacote interno já preparado.");
+ try
+ {
+ ValidateExtracted(cacheRoot);
+ return CreateContext(cacheRoot);
+ }
+ catch
+ {
+ SafeDeleteDirectory(cacheRoot, _cacheBase);
+ }
+ }
+
+ var staging = Path.Combine(_cacheBase, ".staging-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(staging);
+ try
+ {
+ Report(progress, 5, "PREPARANDO", "Abrindo os componentes internos verificados.");
+ ExtractEmbedded(staging, progress);
+ Report(progress, 17, "VALIDANDO", "Conferindo cada componente por SHA-256.");
+ ValidateExtracted(staging);
+ File.WriteAllText(
+ Path.Combine(staging, ReadyMarker),
+ BootstrapMetadata.ExpectedSha256 + Environment.NewLine,
+ new UTF8Encoding(false));
+
+ if (Directory.Exists(cacheRoot))
+ {
+ SafeDeleteDirectory(cacheRoot, _cacheBase);
+ }
+ Directory.Move(staging, cacheRoot);
+ Report(progress, 20, "PACOTE PRONTO", "Componentes internos preparados com integridade confirmada.");
+ return CreateContext(cacheRoot);
+ }
+ catch
+ {
+ if (Directory.Exists(staging))
+ {
+ SafeDeleteDirectory(staging, _cacheBase);
+ }
+ throw;
+ }
+ }
+
+ private static BootstrapContext CreateContext(string root)
+ {
+ var context = new BootstrapContext
+ {
+ Root = root,
+ CoreExecutable = ResolveInside(root, BootstrapMetadata.CoreRelativePath),
+ PackageRoot = ResolveInside(root, BootstrapMetadata.PackageRelativePath)
+ };
+ if (!File.Exists(context.CoreExecutable))
+ {
+ throw new InvalidDataException("Executável interno do instalador ausente.");
+ }
+ if (!Directory.Exists(context.PackageRoot))
+ {
+ throw new InvalidDataException("Pacote da tradução ausente.");
+ }
+ return context;
+ }
+
+ private static void ExtractEmbedded(string destinationRoot, IProgress progress)
+ {
+ var assembly = Assembly.GetExecutingAssembly();
+ using (var resource = assembly.GetManifestResourceStream(BootstrapMetadata.ResourceName))
+ {
+ if (resource == null)
+ {
+ throw new InvalidDataException("Recurso interno do instalador não encontrado.");
+ }
+ if (resource.Length != BootstrapMetadata.ExpectedSize)
+ {
+ throw new InvalidDataException("Tamanho do pacote interno divergente.");
+ }
+ var actualHash = ComputeHash(resource);
+ if (!string.Equals(actualHash, BootstrapMetadata.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException("Integridade do pacote interno inválida.");
+ }
+ resource.Position = 0;
+
+ using (var archive = new ZipArchive(resource, ZipArchiveMode.Read, leaveOpen: false))
+ {
+ var total = archive.Entries.Where(entry => !IsDirectory(entry)).Sum(entry => entry.Length);
+ long completed = 0;
+ foreach (var entry in archive.Entries.OrderBy(entry => entry.FullName, StringComparer.Ordinal))
+ {
+ if (IsDirectory(entry))
+ {
+ continue;
+ }
+ var destination = ResolveInside(destinationRoot, entry.FullName);
+ Directory.CreateDirectory(Path.GetDirectoryName(destination));
+ using (var input = entry.Open())
+ using (var output = new FileStream(
+ destination,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ 1024 * 1024,
+ FileOptions.SequentialScan))
+ {
+ var buffer = new byte[1024 * 1024];
+ int read;
+ while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ output.Write(buffer, 0, read);
+ completed += read;
+ var percent = 5 + (total == 0 ? 0 : (int)(10L * completed / total));
+ Report(progress, percent, "PREPARANDO", "Extraindo os componentes da tradução.");
+ }
+ output.Flush(true);
+ }
+ }
+ }
+ }
+ }
+
+ private static void ValidateExtracted(string root)
+ {
+ var manifestPath = ResolveInside(root, ManifestName);
+ if (!File.Exists(manifestPath))
+ {
+ throw new InvalidDataException("Manifesto interno ausente.");
+ }
+ var serializer = new JavaScriptSerializer { MaxJsonLength = int.MaxValue };
+ var manifest = serializer.Deserialize(File.ReadAllText(manifestPath, Encoding.UTF8));
+ if (manifest == null || manifest.schema_version != 1 || manifest.files == null)
+ {
+ throw new InvalidDataException("Manifesto interno inválido.");
+ }
+ if (!string.Equals(manifest.build_id, BootstrapMetadata.BuildId, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException("Identidade do pacote interno divergente.");
+ }
+
+ var expected = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var record in manifest.files)
+ {
+ var normalized = NormalizeRelative(record.path);
+ if (!expected.Add(normalized))
+ {
+ throw new InvalidDataException("Entrada duplicada no pacote interno: " + normalized);
+ }
+ var path = ResolveInside(root, normalized);
+ if (!File.Exists(path))
+ {
+ throw new InvalidDataException("Componente interno ausente: " + normalized);
+ }
+ var info = new FileInfo(path);
+ if (info.Length != record.size ||
+ !string.Equals(ComputeHash(path), record.sha256, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException("Componente interno adulterado: " + normalized);
+ }
+ }
+
+ var observed = new HashSet(
+ Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)
+ .Select(path => NormalizeRelative(RelativePath(root, path)))
+ .Where(path => !string.Equals(path, ManifestName, StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(path, ReadyMarker, StringComparison.OrdinalIgnoreCase)),
+ StringComparer.OrdinalIgnoreCase);
+ if (!expected.SetEquals(observed))
+ {
+ throw new InvalidDataException("Conjunto de arquivos do pacote interno divergente.");
+ }
+ }
+
+ private static string RelativePath(string root, string path)
+ {
+ var rootUri = new Uri(AppendSeparator(Path.GetFullPath(root)));
+ var pathUri = new Uri(Path.GetFullPath(path));
+ return Uri.UnescapeDataString(rootUri.MakeRelativeUri(pathUri).ToString())
+ .Replace('/', Path.DirectorySeparatorChar);
+ }
+
+ private static string AppendSeparator(string path)
+ {
+ return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
+ ? path
+ : path + Path.DirectorySeparatorChar;
+ }
+
+ private static string NormalizeRelative(string path)
+ {
+ return (path ?? string.Empty).Replace('\\', '/').TrimStart('/');
+ }
+
+ private static string ResolveInside(string root, string relative)
+ {
+ if (string.IsNullOrWhiteSpace(relative) || Path.IsPathRooted(relative) || relative.Contains(":"))
+ {
+ throw new InvalidDataException("Caminho interno inválido.");
+ }
+ var normalized = relative.Replace('/', Path.DirectorySeparatorChar);
+ var fullRoot = Path.GetFullPath(root);
+ var candidate = Path.GetFullPath(Path.Combine(fullRoot, normalized));
+ var prefix = AppendSeparator(fullRoot);
+ if (!candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException("Caminho interno escapou do pacote.");
+ }
+ return candidate;
+ }
+
+ private static bool IsDirectory(ZipArchiveEntry entry)
+ {
+ return string.IsNullOrEmpty(entry.Name) &&
+ (entry.FullName.EndsWith("/", StringComparison.Ordinal) ||
+ entry.FullName.EndsWith("\\", StringComparison.Ordinal));
+ }
+
+ private static string ComputeHash(string path)
+ {
+ using (var stream = new FileStream(
+ path,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read,
+ 1024 * 1024,
+ FileOptions.SequentialScan))
+ {
+ return ComputeHash(stream);
+ }
+ }
+
+ private static string ComputeHash(Stream stream)
+ {
+ using (var sha = SHA256.Create())
+ {
+ return BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", string.Empty);
+ }
+ }
+
+ private static void SafeDeleteDirectory(string target, string allowedParent)
+ {
+ var parent = Path.GetFullPath(allowedParent);
+ var candidate = Path.GetFullPath(target);
+ if (!candidate.StartsWith(AppendSeparator(parent), StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(candidate, parent, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException("Recusa de remoção fora do cache autorizado.");
+ }
+ Directory.Delete(candidate, recursive: true);
+ }
+
+ private static void Report(IProgress progress, int percent, string phase, string message)
+ {
+ progress?.Report(new InstallerProgress
+ {
+ Percent = percent,
+ Phase = phase,
+ Message = message
+ });
+ }
+ }
+}
diff --git a/installer/NIKKEPTBR.Installer/app.manifest b/installer/NIKKEPTBR.Installer/app.manifest
new file mode 100644
index 0000000..981fac5
--- /dev/null
+++ b/installer/NIKKEPTBR.Installer/app.manifest
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true/pm
+ PerMonitorV2
+ true
+
+
+
diff --git a/installer/core/runtime_installer_cli.py b/installer/core/runtime_installer_cli.py
new file mode 100644
index 0000000..2d63c7e
--- /dev/null
+++ b/installer/core/runtime_installer_cli.py
@@ -0,0 +1,304 @@
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from typing import Any, Callable, Iterable
+
+
+ROOT = Path(__file__).resolve().parents[2]
+TOOLS = ROOT / "tools"
+if TOOLS.is_dir():
+ sys.path.insert(0, str(TOOLS))
+
+from runtime_installer_core import InstallerError, RuntimeInstallerTransaction # noqa: E402
+from runtime_release_package import verify_runtime_package # noqa: E402
+
+
+EventSink = Callable[[dict[str, Any]], None]
+ACTIVE_STAGES = {"installing", "repairing", "removing"}
+
+
+def emit_json(event: dict[str, Any]) -> None:
+ # Keep the pipe strictly ASCII. JSON escapes preserve every Portuguese character
+ # while avoiding locale-dependent decoding in the .NET Framework host.
+ print(json.dumps(event, ensure_ascii=True, separators=(",", ":")), flush=True)
+
+
+def progress(
+ sink: EventSink,
+ percent: int,
+ phase: str,
+ message: str,
+ **extra: Any,
+) -> None:
+ event: dict[str, Any] = {
+ "type": "progress",
+ "percent": max(0, min(100, int(percent))),
+ "phase": phase,
+ "message": message,
+ }
+ event.update(extra)
+ sink(event)
+
+
+def friendly_error(error: BaseException) -> str:
+ raw = str(error)
+ lowered = raw.lower()
+ if "processes must be closed" in lowered:
+ return "Feche o jogo e o launcher do NIKKE antes de continuar."
+ if "unsupported or updated nikke client fingerprint" in lowered:
+ return (
+ "A versão instalada do NIKKE ainda não corresponde ao cliente validado "
+ "por este pacote. Nenhum arquivo foi alterado."
+ )
+ if "runtime target fingerprint mismatch" in lowered:
+ return (
+ "O cliente instalado não corresponde à versão validada pelo mod. "
+ "Nenhum arquivo foi alterado."
+ )
+ if "unknown existing mod targets" in lowered:
+ return (
+ "Foi encontrado outro componente no mesmo ponto de integração. "
+ "O instalador recusou sobrescrevê-lo para proteger o cliente."
+ )
+ if "runtime output collision" in lowered:
+ return (
+ "Existe uma pasta de execução que não pertence a esta instalação. "
+ "O instalador interrompeu a operação sem substituir nada."
+ )
+ if "requires repair" in lowered:
+ return "A instalação existe, mas precisa ser reparada antes de continuar."
+ if "matching installation state is required" in lowered:
+ return "Não foi encontrada uma instalação do mod pertencente a este instalador."
+ if "refusing to remove changed" in lowered or "changed owned targets" in lowered:
+ return (
+ "Um componente instalado foi alterado depois da instalação. "
+ "A remoção foi interrompida para preservar esses dados. Use Reparar primeiro."
+ )
+ if "another transaction is pending" in lowered:
+ return (
+ "Existe uma operação anterior incompleta. Retome a ação indicada antes de "
+ "iniciar outra operação."
+ )
+ if isinstance(error, PermissionError) or "access is denied" in lowered:
+ return (
+ "O Windows bloqueou a gravação nessa pasta. Reinicie o instalador como "
+ "administrador e tente novamente."
+ )
+ return raw or error.__class__.__name__
+
+
+def classify_inspection(transaction: RuntimeInstallerTransaction) -> dict[str, Any]:
+ official = transaction._verify_client_compatibility()
+ artifacts = transaction._artifact_states()
+ runtime_output = transaction._scan_runtime_output()
+ state = transaction._read_installation_state()
+ journal = transaction._load_journal()
+ exact = all(record["state"] == "exact" for record in artifacts.values())
+ missing = all(record["state"] == "missing" for record in artifacts.values())
+ matching = transaction._matching_state(state)
+ stage = str(journal.get("stage", "")) if journal else ""
+ operation = str(journal.get("operation", "")) if journal else ""
+
+ if stage in ACTIVE_STAGES and operation in {"install", "repair", "remove"}:
+ health = "recovery_required"
+ recommended_action = operation
+ summary = "Uma operação anterior foi interrompida e pode ser retomada com segurança."
+ elif matching and state and state.get("status") == "installed" and exact:
+ health = "installed_verified"
+ recommended_action = "verify"
+ summary = "A tradução está instalada e todos os componentes conferem."
+ elif matching and state and state.get("status") == "installed":
+ health = "repair_required"
+ recommended_action = "repair"
+ summary = "A instalação foi reconhecida, mas um ou mais componentes precisam de reparo."
+ elif matching and state and state.get("status") == "removed" and missing:
+ health = "not_installed"
+ recommended_action = "install"
+ summary = "A tradução não está instalada neste cliente."
+ elif state is None and missing and not runtime_output:
+ health = "not_installed"
+ recommended_action = "install"
+ summary = "Cliente compatível encontrado e pronto para receber a tradução."
+ else:
+ health = "blocked_unknown_state"
+ recommended_action = "none"
+ summary = (
+ "Foram encontrados componentes ou um estado que não pertencem com segurança "
+ "a este pacote. Nada será sobrescrito automaticamente."
+ )
+
+ return {
+ "health": health,
+ "recommended_action": recommended_action,
+ "summary": summary,
+ "artifact_states": artifacts,
+ "runtime_output": runtime_output,
+ "installation_state_status": state.get("status") if state else None,
+ "matching_installation_state": matching,
+ "pending_transaction": (
+ {
+ "operation": operation,
+ "stage": stage,
+ "next_action": journal.get("next_action"),
+ }
+ if journal
+ else None
+ ),
+ "official_snapshot": official,
+ }
+
+
+def attach_journal_progress(
+ transaction: RuntimeInstallerTransaction,
+ sink: EventSink,
+) -> None:
+ original = transaction._save_journal
+ payload_count = max(1, len(transaction.package.payloads))
+
+ def observed(journal: dict[str, Any]) -> None:
+ original(journal)
+ stage = str(journal.get("stage", ""))
+ operation = str(journal.get("operation", ""))
+ completed = 0
+ if operation == "install":
+ completed = len(journal.get("added", []))
+ elif operation == "repair":
+ completed = len(journal.get("repaired", []))
+ elif operation == "remove":
+ completed = len(journal.get("removed", []))
+
+ phase_messages = {
+ "installing": ("INSTALANDO", "Aplicando componentes verificados."),
+ "installed_verified": ("INSTALAÇÃO VERIFICADA", "Instalação concluída e conferida."),
+ "install_rolled_back_verified": ("ROLLBACK CONCLUÍDO", "O estado anterior foi restaurado."),
+ "repairing": ("REPARANDO", "Restaurando componentes da tradução."),
+ "repair_verified": ("REPARO VERIFICADO", "Reparo concluído e conferido."),
+ "repair_rolled_back_verified": ("ROLLBACK CONCLUÍDO", "O estado anterior foi restaurado."),
+ "removing": ("REMOVENDO", "Removendo somente componentes pertencentes ao mod."),
+ "removed_verified": ("REMOÇÃO VERIFICADA", "A tradução foi removida com segurança."),
+ "remove_rolled_back_to_installed": ("ROLLBACK CONCLUÍDO", "A instalação anterior foi restaurada."),
+ }
+ phase, message = phase_messages.get(stage, ("PROCESSANDO", "Atualizando o estado transacional."))
+ if stage.endswith("_verified"):
+ percent = 96
+ elif "rolled_back" in stage:
+ percent = 88
+ else:
+ percent = 24 + int(68 * completed / payload_count)
+ progress(
+ sink,
+ percent,
+ phase,
+ message,
+ stage=stage,
+ operation=operation,
+ completed_files=completed,
+ total_files=payload_count,
+ )
+
+ transaction._save_journal = observed # type: ignore[method-assign]
+
+
+def execute(
+ *,
+ action: str,
+ package_root: Path,
+ target_root: Path,
+ state_root: Path,
+ allowed_target_parent: Path | None,
+ forbidden_roots: Iterable[Path] = (),
+ sink: EventSink = emit_json,
+) -> dict[str, Any]:
+ progress(sink, 2, "PREPARANDO", "Validando o pacote interno do instalador.")
+ package = verify_runtime_package(package_root)
+ progress(sink, 14, "PACOTE VALIDADO", "Integridade e proveniência do pacote confirmadas.")
+
+ transaction = RuntimeInstallerTransaction(
+ package=package,
+ target_root=target_root,
+ state_root=state_root,
+ allowed_target_parent=allowed_target_parent,
+ forbidden_roots=forbidden_roots,
+ )
+ attach_journal_progress(transaction, sink)
+ progress(sink, 20, "CLIENTE VALIDADO", "Identidade do cliente e estado local confirmados.")
+
+ if action == "inspect":
+ data = classify_inspection(transaction)
+ elif action == "install":
+ data = transaction.install()
+ elif action == "verify":
+ data = transaction.verify_installed()
+ if not data.get("installed"):
+ raise InstallerError("The installed translation did not pass verification.")
+ elif action == "repair":
+ data = transaction.repair()
+ elif action == "remove":
+ data = transaction.remove()
+ elif action == "rollback":
+ data = transaction.rollback_pending()
+ else:
+ raise InstallerError(f"Unsupported installer action: {action}")
+
+ progress(sink, 100, "CONCLUÍDO", "Operação concluída com verificação integral.")
+ return {
+ "success": True,
+ "action": action,
+ "package_id": package.package_id,
+ "package_identity_sha256": package.package_identity,
+ "mod_version": package.manifest["mod_version"],
+ "client_version": package.manifest["client_compatibility"]["version"],
+ "data": data,
+ }
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="NIKKE PT-BR transactional installer core")
+ parser.add_argument(
+ "--action",
+ required=True,
+ choices=("inspect", "install", "verify", "repair", "remove", "rollback"),
+ )
+ parser.add_argument("--package-root", required=True, type=Path)
+ parser.add_argument("--target-root", required=True, type=Path)
+ parser.add_argument("--state-root", required=True, type=Path)
+ parser.add_argument("--allowed-target-parent", type=Path)
+ parser.add_argument("--forbidden-root", action="append", default=[], type=Path)
+ return parser.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = parse_args(argv)
+ try:
+ result = execute(
+ action=args.action,
+ package_root=args.package_root,
+ target_root=args.target_root,
+ state_root=args.state_root,
+ allowed_target_parent=args.allowed_target_parent,
+ forbidden_roots=args.forbidden_root,
+ )
+ except BaseException as error:
+ emit_json(
+ {
+ "type": "result",
+ "success": False,
+ "action": args.action,
+ "message": friendly_error(error),
+ "technical": str(error),
+ "error_type": error.__class__.__name__,
+ }
+ )
+ return 1
+
+ event = {"type": "result"}
+ event.update(result)
+ emit_json(event)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..4b4386f
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""Public tests for NIKKE PT-BR."""
diff --git a/tests/test_runtime_installer_cli.py b/tests/test_runtime_installer_cli.py
new file mode 100644
index 0000000..51a459a
--- /dev/null
+++ b/tests/test_runtime_installer_cli.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+ROOT = Path(__file__).parents[1]
+sys.path.insert(0, str(ROOT / "tools"))
+sys.path.insert(0, str(ROOT / "installer" / "core"))
+
+from runtime_installer_cli import ( # noqa: E402
+ attach_journal_progress,
+ classify_inspection,
+ friendly_error,
+)
+from tests.test_runtime_installer_core import InstallerFixture # noqa: E402
+
+
+class RuntimeInstallerCliTests(unittest.TestCase):
+ def test_clean_client_is_reported_as_not_installed(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ transaction = fixture.transaction()
+ inspection = classify_inspection(transaction)
+ self.assertEqual(inspection["health"], "not_installed")
+ self.assertEqual(inspection["recommended_action"], "install")
+
+ def test_installed_and_damaged_states_are_distinguished(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ transaction = fixture.transaction()
+ transaction.install()
+ installed = classify_inspection(transaction)
+ self.assertEqual(installed["health"], "installed_verified")
+
+ (fixture.target / "NIKKEPTBR-Runtime.dll").unlink()
+ damaged = classify_inspection(transaction)
+ self.assertEqual(damaged["health"], "repair_required")
+ self.assertEqual(damaged["recommended_action"], "repair")
+
+ def test_interrupted_install_is_reported_as_resumable(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ transaction = fixture.transaction()
+ events = []
+ attach_journal_progress(transaction, events.append)
+ with self.assertRaises(BaseException):
+ transaction.install(interrupt_after=1)
+ inspection = classify_inspection(transaction)
+ self.assertEqual(inspection["health"], "recovery_required")
+ self.assertEqual(inspection["recommended_action"], "install")
+ self.assertTrue(any(event.get("stage") == "installing" for event in events))
+
+ def test_friendly_error_preserves_safe_user_guidance(self) -> None:
+ message = friendly_error(RuntimeError("NIKKE processes must be closed: nikke.exe"))
+ self.assertIn("Feche o jogo", message)
+ collision = friendly_error(RuntimeError("Unknown existing mod targets; refusing overwrite"))
+ self.assertIn("recusou sobrescrevê-lo", collision)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_runtime_installer_core.py b/tests/test_runtime_installer_core.py
new file mode 100644
index 0000000..586d5d2
--- /dev/null
+++ b/tests/test_runtime_installer_core.py
@@ -0,0 +1,324 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import struct
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+ROOT = Path(__file__).parents[1]
+TOOLS = ROOT / "tools"
+sys.path.insert(0, str(TOOLS))
+
+from runtime_installer_core import ( # noqa: E402
+ InjectedFailure,
+ InstallerError,
+ RuntimeInstallerTransaction,
+ SimulatedInterruption,
+)
+from runtime_release_package import ( # noqa: E402
+ compile_runtime_package,
+ verify_runtime_package,
+)
+
+
+def digest(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest().upper()
+
+
+def synthetic_gameassembly(primary: bytes, legacy: bytes) -> bytes:
+ data = bytearray(0x9000)
+ pe_offset = 0x80
+ coff = pe_offset + 4
+ optional_size = 0xF0
+ section = coff + 20 + optional_size
+ struct.pack_into(" None:
+ self.root = root
+ self.packages = root / "packages"
+ self.packages.mkdir()
+ self.build = root / "build"
+ self.build.mkdir()
+ self.evidence_root = root / "evidence"
+ self.evidence_root.mkdir()
+ self.targets = root / "targets"
+ self.targets.mkdir()
+ self.target = self.targets / "game"
+ self.target.mkdir()
+ self.state = root / "state"
+ self.artifacts = {
+ "winhttp.dll": self.build / "winhttp.dll",
+ "NIKKEPTBR-Runtime.dll": self.build / "NIKKEPTBR-Runtime.dll",
+ "NIKKEPTBR-Runtime.idx": self.build / "NIKKEPTBR-Runtime.idx",
+ }
+ for index, (name, path) in enumerate(self.artifacts.items(), start=1):
+ path.write_bytes((f"payload-{name}".encode("ascii") + b"\0") * index)
+ self.evidence = {"runtime_acceptance": self.evidence_root / "acceptance.json"}
+ self.evidence["runtime_acceptance"].write_text(
+ json.dumps({"status": "approved"}), encoding="utf-8"
+ )
+ primary_target = b"P" * 32
+ legacy_target = b"L" * 32
+ official_files = {
+ "nikke.exe": b"synthetic official nikke executable",
+ "UnityPlayer.dll": b"synthetic official unity player",
+ "GameAssembly.dll": synthetic_gameassembly(primary_target, legacy_target),
+ }
+ for name, content in official_files.items():
+ (self.target / name).write_bytes(content)
+ self.critical = {
+ name: {"size": len(content), "sha256": digest(content)}
+ for name, content in official_files.items()
+ }
+ self.fingerprints = {
+ "primary_table_key_lookup": {
+ "module": "GameAssembly.dll",
+ "rva": "0x1234",
+ "length": 32,
+ "bytes_sha256": digest(primary_target),
+ },
+ "legacy_unity_ui_text_setter": {
+ "module": "GameAssembly.dll",
+ "rva": "0x5678",
+ "length": 32,
+ "bytes_sha256": digest(legacy_target),
+ },
+ }
+ if mismatched_target is not None:
+ self.fingerprints[mismatched_target]["bytes_sha256"] = digest(
+ f"wrong-{mismatched_target}".encode("ascii")
+ )
+ self.snapshot = {
+ name: {"size": record["size"], "sha256": record["sha256"]}
+ for name, record in self.critical.items()
+ }
+ self.package_root = self.packages / "candidate"
+ compile_runtime_package(
+ package_id="nikke-ptbr-runtime-private-v1",
+ mod_version="0.1.0",
+ output_root=self.package_root,
+ allowed_output_parent=self.packages,
+ artifact_sources=self.artifacts,
+ evidence_files=self.evidence,
+ client_version="150.6.9",
+ critical_files=self.critical,
+ target_fingerprints=self.fingerprints,
+ runtime_catalog={
+ "sha256": digest(b"catalog"),
+ "translated_occurrences": 533785,
+ "translated_units": 427399,
+ "source_tables": 34,
+ "identity": ["source_table", "source_key", "original_source_text"],
+ "fail_open": True,
+ },
+ )
+ self.package = verify_runtime_package(self.package_root)
+
+ def transaction(self, *, state: Path | None = None) -> RuntimeInstallerTransaction:
+ transaction = RuntimeInstallerTransaction(
+ package=self.package,
+ target_root=self.target,
+ state_root=state or self.state,
+ allowed_target_parent=self.targets,
+ process_checker=lambda: [],
+ )
+ return transaction
+
+
+class RuntimeInstallerCoreTests(unittest.TestCase):
+ def test_both_runtime_target_fingerprints_are_required_before_write(self) -> None:
+ for target_name in (
+ "primary_table_key_lookup",
+ "legacy_unity_ui_text_setter",
+ ):
+ with self.subTest(target_name=target_name), tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(
+ Path(temporary), mismatched_target=target_name
+ )
+ with self.assertRaisesRegex(
+ InstallerError,
+ f"Runtime target fingerprint mismatch: {target_name}",
+ ):
+ fixture.transaction().install()
+ self.assertTrue(
+ all(
+ not (fixture.target / name).exists()
+ for name in fixture.artifacts
+ )
+ )
+
+ def test_updated_official_client_is_rejected_before_write(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ (fixture.target / "nikke.exe").write_bytes(b"updated official client")
+ with self.assertRaisesRegex(
+ InstallerError, "Unsupported or updated NIKKE client fingerprint"
+ ):
+ fixture.transaction().install()
+ self.assertTrue(
+ all(not (fixture.target / name).exists() for name in fixture.artifacts)
+ )
+
+ def test_full_install_verify_idempotent_remove_cycle(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ transaction = fixture.transaction()
+ installed = transaction.install()
+ self.assertEqual(installed["changed_files"], 3)
+ self.assertTrue(transaction.verify_installed()["installed"])
+ self.assertEqual(transaction.install()["result"], "verified_noop")
+ removed = transaction.remove()
+ self.assertEqual(removed["changed_files"], 3)
+ self.assertTrue(transaction.verify_removed()["removed"])
+ self.assertEqual(transaction.remove()["result"], "verified_noop")
+
+ def test_unknown_existing_target_is_never_overwritten(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ collision = fixture.target / "winhttp.dll"
+ collision.write_bytes(b"another mod")
+ with self.assertRaisesRegex(InstallerError, "refusing overwrite"):
+ fixture.transaction().install()
+ self.assertEqual(collision.read_bytes(), b"another mod")
+
+ def test_install_interruption_resumes_without_recopying_valid_file(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ transaction = fixture.transaction()
+ with self.assertRaises(SimulatedInterruption):
+ transaction.install(interrupt_after=1)
+ first = fixture.target / "winhttp.dll"
+ first_hash = hashlib.sha256(first.read_bytes()).hexdigest().upper()
+ resumed = transaction.install()
+ self.assertEqual(resumed["result"], "installed_verified")
+ self.assertEqual(hashlib.sha256(first.read_bytes()).hexdigest().upper(), first_hash)
+ self.assertTrue(transaction.verify_installed()["installed"])
+
+ def test_install_failure_rolls_back_all_added_files(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ transaction = fixture.transaction()
+ with self.assertRaises(InjectedFailure):
+ transaction.install(fail_after=1)
+ for name in fixture.artifacts:
+ self.assertFalse((fixture.target / name).exists())
+ journal = json.loads(transaction.journal_path.read_text(encoding="utf-8"))
+ self.assertEqual(journal["stage"], "install_rolled_back_verified")
+
+ def test_repair_missing_and_changed_files_with_quarantine(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ transaction = fixture.transaction()
+ transaction.install()
+ (fixture.target / "winhttp.dll").write_bytes(b"changed after install")
+ (fixture.target / "NIKKEPTBR-Runtime.dll").unlink()
+ repaired = transaction.repair()
+ self.assertEqual(repaired["changed_files"], 2)
+ self.assertTrue(transaction.verify_installed()["installed"])
+ quarantine = list(transaction.quarantine_root.rglob("winhttp.dll"))
+ self.assertEqual(len(quarantine), 1)
+ self.assertEqual(quarantine[0].read_bytes(), b"changed after install")
+
+ def test_repair_failure_restores_exact_damaged_pre_state(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ transaction = fixture.transaction()
+ transaction.install()
+ changed = fixture.target / "winhttp.dll"
+ missing = fixture.target / "NIKKEPTBR-Runtime.dll"
+ changed.write_bytes(b"user changed bytes")
+ missing.unlink()
+ with self.assertRaises(InjectedFailure):
+ transaction.repair(fail_after=1)
+ self.assertEqual(changed.read_bytes(), b"user changed bytes")
+ self.assertFalse(missing.exists())
+ self.assertEqual(
+ json.loads(transaction.journal_path.read_text(encoding="utf-8"))["stage"],
+ "repair_rolled_back_verified",
+ )
+ transaction.repair()
+ self.assertTrue(transaction.verify_installed()["installed"])
+
+ def test_remove_interruption_resumes_and_failure_rolls_back(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ transaction = fixture.transaction()
+ transaction.install()
+ with self.assertRaises(SimulatedInterruption):
+ transaction.remove(interrupt_after=1)
+ self.assertTrue(transaction.remove()["removed"])
+ self.assertTrue(transaction.verify_removed()["removed"])
+
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ transaction = fixture.transaction()
+ transaction.install()
+ with self.assertRaises(InjectedFailure):
+ transaction.remove(fail_after=1)
+ self.assertTrue(transaction.verify_installed()["installed"])
+
+ def test_remove_refuses_changed_owned_target_before_mutation(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ transaction = fixture.transaction()
+ transaction.install()
+ changed = fixture.target / "NIKKEPTBR-Runtime.idx"
+ changed.write_bytes(b"changed")
+ with self.assertRaisesRegex(InstallerError, "Refusing to remove changed"):
+ transaction.remove()
+ self.assertTrue((fixture.target / "winhttp.dll").exists())
+ self.assertEqual(changed.read_bytes(), b"changed")
+
+ def test_runtime_log_is_preserved_and_unknown_output_blocks_remove(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ transaction = fixture.transaction()
+ transaction.install()
+ output = fixture.target / "NIKKEPTBR-Runtime"
+ output.mkdir()
+ (output / "runtime.log").write_text("runtime evidence\n", encoding="utf-8")
+ removed = transaction.remove()
+ self.assertEqual(len(removed["runtime_evidence"]), 1)
+ evidence_path = Path(removed["runtime_evidence"][0]["path"])
+ self.assertEqual(evidence_path.read_text(encoding="utf-8"), "runtime evidence\n")
+
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ transaction = fixture.transaction()
+ transaction.install()
+ output = fixture.target / "NIKKEPTBR-Runtime"
+ output.mkdir()
+ (output / "unknown.bin").write_bytes(b"unknown")
+ with self.assertRaisesRegex(InstallerError, "unknown entries"):
+ transaction.remove()
+ for payload in fixture.package.payloads:
+ target = fixture.target / payload.name
+ self.assertTrue(target.is_file())
+ self.assertEqual(
+ hashlib.sha256(target.read_bytes()).hexdigest().upper(),
+ payload.sha256,
+ )
+
+ def test_state_root_must_remain_outside_target(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = InstallerFixture(Path(temporary))
+ with self.assertRaisesRegex(InstallerError, "disjoint"):
+ fixture.transaction(state=fixture.target / "state")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_runtime_release_package.py b/tests/test_runtime_release_package.py
new file mode 100644
index 0000000..acbf44f
--- /dev/null
+++ b/tests/test_runtime_release_package.py
@@ -0,0 +1,196 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+TOOLS = Path(__file__).parents[1] / "tools"
+sys.path.insert(0, str(TOOLS))
+
+from runtime_release_package import ( # noqa: E402
+ PACKAGE_MANIFEST_NAME,
+ PackageError,
+ compile_runtime_package,
+ verify_runtime_package,
+)
+
+
+def digest(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest().upper()
+
+
+class RuntimePackageFixture:
+ def __init__(self, root: Path) -> None:
+ self.root = root
+ self.output_parent = root / "packages"
+ self.output_parent.mkdir()
+ self.build = root / "build"
+ self.build.mkdir()
+ self.evidence_root = root / "evidence"
+ self.evidence_root.mkdir()
+ self.artifacts = {
+ "winhttp.dll": self.build / "winhttp.dll",
+ "NIKKEPTBR-Runtime.dll": self.build / "NIKKEPTBR-Runtime.dll",
+ "NIKKEPTBR-Runtime.idx": self.build / "NIKKEPTBR-Runtime.idx",
+ }
+ for index, (name, path) in enumerate(self.artifacts.items(), start=1):
+ path.write_bytes((name.encode("ascii") + b"\0") * index)
+ self.evidence = {
+ "runtime_acceptance": self.evidence_root / "acceptance.json",
+ "offline_validation": self.evidence_root / "offline.json",
+ }
+ for name, path in self.evidence.items():
+ path.write_text(json.dumps({"status": "approved", "name": name}), encoding="utf-8")
+ self.critical = {
+ "nikke.exe": {"size": 101, "sha256": digest(b"nikke")},
+ "UnityPlayer.dll": {"size": 102, "sha256": digest(b"unity")},
+ "GameAssembly.dll": {"size": 103, "sha256": digest(b"assembly")},
+ }
+ self.fingerprints = {
+ "primary_table_key_lookup": {
+ "module": "GameAssembly.dll",
+ "rva": "0x1234",
+ "length": 32,
+ "bytes_sha256": digest(b"primary-target"),
+ },
+ "legacy_unity_ui_text_setter": {
+ "module": "GameAssembly.dll",
+ "rva": "0x5678",
+ "length": 32,
+ "bytes_sha256": digest(b"legacy-target"),
+ },
+ }
+ self.catalog = {
+ "sha256": digest(b"catalog"),
+ "translated_occurrences": 533785,
+ "translated_units": 427399,
+ "source_tables": 34,
+ "identity": ["source_table", "source_key", "original_source_text"],
+ "fail_open": True,
+ }
+
+ @property
+ def output(self) -> Path:
+ return self.output_parent / "nikke-ptbr-runtime-private-v1"
+
+ def compile(self) -> dict[str, object]:
+ return compile_runtime_package(
+ package_id="nikke-ptbr-runtime-private-v1",
+ mod_version="0.1.0-dev",
+ output_root=self.output,
+ allowed_output_parent=self.output_parent,
+ artifact_sources=self.artifacts,
+ evidence_files=self.evidence,
+ client_version="150.6.9",
+ critical_files=self.critical,
+ target_fingerprints=self.fingerprints,
+ runtime_catalog=self.catalog,
+ )
+
+
+class RuntimeReleasePackageTests(unittest.TestCase):
+ def test_compile_and_verify_closed_three_file_contract(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = RuntimePackageFixture(Path(temporary))
+ manifest = fixture.compile()
+ package = verify_runtime_package(
+ fixture.output,
+ expected_evidence_files=fixture.evidence,
+ )
+ self.assertEqual(len(package.payloads), 3)
+ self.assertEqual(manifest["schema_version"], 2)
+ self.assertEqual(
+ [
+ record["name"]
+ for record in manifest["client_compatibility"]["target_fingerprints"]
+ ],
+ ["primary_table_key_lookup", "legacy_unity_ui_text_setter"],
+ )
+ self.assertEqual(manifest["payload"]["official_files_included"], 0)
+ self.assertTrue(manifest["release_gate"]["private_installable_candidate"])
+ self.assertFalse(manifest["release_gate"]["public_distribution_authorized"])
+
+ def test_payload_tampering_is_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = RuntimePackageFixture(Path(temporary))
+ fixture.compile()
+ (fixture.output / "payload" / "NIKKEPTBR-Runtime.idx").write_bytes(b"tampered")
+ with self.assertRaisesRegex(PackageError, "Payload size mismatch|Payload hash mismatch"):
+ verify_runtime_package(fixture.output)
+
+ def test_extra_and_missing_payload_files_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = RuntimePackageFixture(Path(temporary))
+ fixture.compile()
+ (fixture.output / "payload" / "extra.bin").write_bytes(b"extra")
+ with self.assertRaisesRegex(PackageError, "Package file set mismatch"):
+ verify_runtime_package(fixture.output)
+
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = RuntimePackageFixture(Path(temporary))
+ fixture.compile()
+ (fixture.output / "payload" / "winhttp.dll").unlink()
+ with self.assertRaisesRegex(PackageError, "Missing package payload"):
+ verify_runtime_package(fixture.output)
+
+ def test_external_provenance_change_is_rejected_when_requested(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = RuntimePackageFixture(Path(temporary))
+ fixture.compile()
+ fixture.evidence["runtime_acceptance"].write_text("changed", encoding="utf-8")
+ with self.assertRaisesRegex(PackageError, "Provenance size mismatch|Provenance hash mismatch"):
+ verify_runtime_package(
+ fixture.output,
+ expected_evidence_files=fixture.evidence,
+ )
+
+ def test_manifest_identity_tampering_is_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = RuntimePackageFixture(Path(temporary))
+ fixture.compile()
+ manifest_path = fixture.output / PACKAGE_MANIFEST_NAME
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ manifest["mod_version"] = "9.9.9"
+ manifest_path.write_text(
+ json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ with self.assertRaisesRegex(PackageError, "Package identity mismatch"):
+ verify_runtime_package(fixture.output)
+
+ def test_incomplete_target_fingerprint_contract_is_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = RuntimePackageFixture(Path(temporary))
+ del fixture.fingerprints["legacy_unity_ui_text_setter"]
+ with self.assertRaisesRegex(PackageError, "fingerprint set"):
+ fixture.compile()
+
+ def test_output_must_be_new_and_inside_allowed_parent(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = RuntimePackageFixture(Path(temporary))
+ fixture.compile()
+ with self.assertRaisesRegex(PackageError, "already exists"):
+ fixture.compile()
+
+ outside = fixture.root / "outside"
+ with self.assertRaisesRegex(PackageError, "escaped"):
+ compile_runtime_package(
+ package_id="outside",
+ mod_version="0.1.0-dev",
+ output_root=outside,
+ allowed_output_parent=fixture.output_parent,
+ artifact_sources=fixture.artifacts,
+ evidence_files=fixture.evidence,
+ client_version="150.6.9",
+ critical_files=fixture.critical,
+ target_fingerprints=fixture.fingerprints,
+ runtime_catalog=fixture.catalog,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tools/Test-PublicSource.ps1 b/tools/Test-PublicSource.ps1
new file mode 100644
index 0000000..8ecd994
--- /dev/null
+++ b/tools/Test-PublicSource.ps1
@@ -0,0 +1,42 @@
+[CmdletBinding()]
+param(
+ [string]$Configuration = 'Release',
+ [string]$PythonCommand = 'python'
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+$repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
+$project = Join-Path $repositoryRoot 'installer\NIKKEPTBR.Installer\NIKKEPTBR.Installer.csproj'
+$previousPythonUtf8 = [Environment]::GetEnvironmentVariable('PYTHONUTF8', 'Process')
+
+Push-Location $repositoryRoot
+try {
+ $env:PYTHONUTF8 = '1'
+ & $PythonCommand 'tools\audit_public_repository.py'
+ if ($LASTEXITCODE -ne 0) {
+ throw 'A auditoria do conteúdo público falhou.'
+ }
+
+ & $PythonCommand -m unittest discover -s tests -v
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Os testes do núcleo transacional falharam.'
+ }
+
+ & dotnet build $project -c $Configuration --nologo
+ if ($LASTEXITCODE -ne 0) {
+ throw 'A compilação da interface do instalador falhou.'
+ }
+
+ $builtExecutable = Join-Path $repositoryRoot "installer\NIKKEPTBR.Installer\bin\$Configuration\net472\NIKKEPTBR.exe"
+ if (-not (Test-Path -LiteralPath $builtExecutable -PathType Leaf)) {
+ throw 'A compilação terminou sem produzir o executável esperado.'
+ }
+
+ Write-Host 'Conteúdo público, testes e interface aprovados.'
+}
+finally {
+ [Environment]::SetEnvironmentVariable('PYTHONUTF8', $previousPythonUtf8, 'Process')
+ Pop-Location
+}
diff --git a/tools/audit_public_repository.py b/tools/audit_public_repository.py
new file mode 100644
index 0000000..0e2926a
--- /dev/null
+++ b/tools/audit_public_repository.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""Fail-closed audit for the public NIKKE PT-BR repository."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+import re
+import subprocess
+
+
+ROOT = Path(__file__).resolve().parents[1]
+FORBIDDEN_SUFFIXES = {".7z", ".dll", ".exe", ".pdb", ".pyc", ".zip"}
+FORBIDDEN_PARTS = {"__pycache__", "bin", "obj", "private", "work"}
+TEXT_SUFFIXES = {
+ ".cs",
+ ".csproj",
+ ".json",
+ ".md",
+ ".ps1",
+ ".py",
+ ".xaml",
+ ".xml",
+ ".yml",
+ ".yaml",
+}
+SCREENSHOTS = (
+ "01-lobby-ptbr.webp",
+ "02-perfil-personagem-ptbr.webp",
+ "03-dialogo-narrativo-ptbr.webp",
+ "04-cena-historia-ptbr.webp",
+ "05-escolhas-dialogo-ptbr.webp",
+ "06-campanha-formacao-ptbr.webp",
+ "07-progressao-recompensas-ptbr.webp",
+ "08-inventario-item-ptbr.webp",
+)
+MARKDOWN_LINK = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
+HTML_PATH = re.compile(r'(?:src|srcset)="([^"]+)"')
+
+
+def tracked_files() -> tuple[Path, ...]:
+ result = subprocess.run(
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
+ cwd=ROOT,
+ check=True,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ )
+ return tuple(
+ ROOT / line
+ for line in result.stdout.splitlines()
+ if line and not line.startswith(".git/")
+ )
+
+
+def relative_target(source: Path, raw: str) -> Path | None:
+ value = raw.strip().split(maxsplit=1)[0].strip("<>")
+ if not value or value.startswith(("#", "https://", "http://", "mailto:")):
+ return None
+ path_text = value.split("#", 1)[0]
+ if not path_text:
+ return None
+ return (source.parent / path_text).resolve(strict=False)
+
+
+def main() -> int:
+ files = tracked_files()
+ errors: list[str] = []
+
+ for path in files:
+ relative = path.relative_to(ROOT)
+ lowered_parts = {part.lower() for part in relative.parts}
+ if path.suffix.lower() in FORBIDDEN_SUFFIXES:
+ errors.append(f"artefato proibido: {relative.as_posix()}")
+ if lowered_parts & FORBIDDEN_PARTS:
+ errors.append(f"diretório privado ou gerado: {relative.as_posix()}")
+ if not path.is_file():
+ errors.append(f"arquivo listado ausente: {relative.as_posix()}")
+ continue
+
+ if path.suffix.lower() == ".json":
+ try:
+ json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ errors.append(f"JSON inválido em {relative.as_posix()}: {error}")
+
+ if path.suffix.lower() in TEXT_SUFFIXES:
+ text = path.read_text(encoding="utf-8")
+ if re.search(r"(?i)[A-Z]:\\Users\\", text):
+ errors.append(f"caminho pessoal exposto: {relative.as_posix()}")
+ if path.suffix.lower() == ".md":
+ for raw in (*MARKDOWN_LINK.findall(text), *HTML_PATH.findall(text)):
+ target = relative_target(path, raw)
+ if target is not None and not target.exists():
+ errors.append(
+ f"link local quebrado em {relative.as_posix()}: {raw}"
+ )
+
+ screenshot_root = ROOT / "assets" / "screenshots"
+ observed = tuple(
+ path.name for path in sorted(screenshot_root.glob("*")) if path.is_file()
+ )
+ if observed != SCREENSHOTS:
+ errors.append(
+ "galeria divergente: esperado "
+ + ", ".join(SCREENSHOTS)
+ + "; encontrado "
+ + ", ".join(observed)
+ )
+
+ if errors:
+ for error in errors:
+ print(f"ERRO: {error}")
+ return 1
+
+ print(f"Auditoria pública aprovada: {len(files)} arquivos, 8 capturas e 0 bloqueio.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/build_installer_bootstrap.py b/tools/build_installer_bootstrap.py
new file mode 100644
index 0000000..a4e601f
--- /dev/null
+++ b/tools/build_installer_bootstrap.py
@@ -0,0 +1,200 @@
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import tempfile
+import zipfile
+from pathlib import Path
+from typing import Any, Iterable
+
+
+FIXED_ZIP_TIME = (2026, 1, 1, 0, 0, 0)
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest().upper()
+
+
+def canonical_bytes(value: Any) -> bytes:
+ return json.dumps(
+ value,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+
+
+def atomic_bytes(path: Path, content: bytes) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ handle, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
+ temporary = Path(temporary_name)
+ try:
+ with os.fdopen(handle, "wb") as stream:
+ stream.write(content)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, path)
+ finally:
+ if temporary.exists():
+ temporary.unlink()
+
+
+def source_files(root: Path, prefix: str) -> Iterable[tuple[str, Path]]:
+ for path in sorted(root.rglob("*"), key=lambda item: item.as_posix().lower()):
+ if path.is_file():
+ relative = path.relative_to(root).as_posix()
+ yield f"{prefix}/{relative}", path
+
+
+def zip_info(name: str) -> zipfile.ZipInfo:
+ info = zipfile.ZipInfo(name, FIXED_ZIP_TIME)
+ info.compress_type = zipfile.ZIP_DEFLATED
+ info.create_system = 0
+ info.external_attr = 0o644 << 16
+ return info
+
+
+def metadata_source(*, zip_hash: str, zip_size: int, build_id: str) -> bytes:
+ source = f'''namespace NIKKEPTBR.Installer
+{{
+ internal static class BootstrapMetadata
+ {{
+ public const string ResourceName = "NIKKEPTBR.Bootstrap.zip";
+ public const string ExpectedSha256 = "{zip_hash}";
+ public const long ExpectedSize = {zip_size};
+ public const string BuildId = "{build_id}";
+ public const string CoreRelativePath = "core/NIKKEPTBR-Core.exe";
+ public const string PackageRelativePath = "package";
+ }}
+}}
+'''
+ return source.encode("utf-8")
+
+
+def build(
+ *,
+ core_root: Path,
+ package_root: Path,
+ output: Path,
+ metadata_output: Path,
+ report_output: Path | None,
+) -> dict[str, Any]:
+ core_root = core_root.resolve(strict=True)
+ package_root = package_root.resolve(strict=True)
+ if not (core_root / "NIKKEPTBR-Core.exe").is_file():
+ raise RuntimeError("NIKKEPTBR-Core.exe is missing from the frozen core.")
+ package_manifest_path = package_root / "NIKKE-PTBR-PACKAGE.json"
+ package_manifest = json.loads(package_manifest_path.read_text(encoding="utf-8"))
+ package_identity = str(package_manifest["package_identity_sha256"]).upper()
+
+ inputs = [*source_files(core_root, "core"), *source_files(package_root, "package")]
+ records = [
+ {
+ "path": name,
+ "size": path.stat().st_size,
+ "sha256": sha256_file(path),
+ }
+ for name, path in inputs
+ ]
+ build_id = hashlib.sha256(
+ canonical_bytes(
+ {
+ "schema_version": 1,
+ "package_identity_sha256": package_identity,
+ "files": records,
+ }
+ )
+ ).hexdigest().upper()[:24]
+ manifest = {
+ "schema_version": 1,
+ "build_id": build_id,
+ "package_identity_sha256": package_identity,
+ "core_relative_path": "core/NIKKEPTBR-Core.exe",
+ "package_relative_path": "package",
+ "files": records,
+ }
+ manifest_bytes = canonical_bytes(manifest) + b"\n"
+
+ output.parent.mkdir(parents=True, exist_ok=True)
+ temporary = output.with_name(f".{output.name}.{os.getpid()}.tmp")
+ if temporary.exists():
+ temporary.unlink()
+ try:
+ with zipfile.ZipFile(
+ temporary,
+ mode="w",
+ compression=zipfile.ZIP_DEFLATED,
+ compresslevel=9,
+ allowZip64=True,
+ ) as archive:
+ archive.writestr(zip_info("bootstrap-manifest.json"), manifest_bytes)
+ for (name, path), record in zip(inputs, records):
+ if name != record["path"]:
+ raise RuntimeError("Bootstrap input ordering changed unexpectedly.")
+ archive.writestr(zip_info(name), path.read_bytes())
+ os.replace(temporary, output)
+ finally:
+ if temporary.exists():
+ temporary.unlink()
+
+ zip_hash = sha256_file(output)
+ zip_size = output.stat().st_size
+ atomic_bytes(
+ metadata_output,
+ metadata_source(zip_hash=zip_hash, zip_size=zip_size, build_id=build_id),
+ )
+ report = {
+ "schema_version": 1,
+ "status": "approved_for_installer_build",
+ "build_id": build_id,
+ "package_identity_sha256": package_identity,
+ "file_count": len(records),
+ "uncompressed_bytes": sum(record["size"] for record in records),
+ "bootstrap_zip": {
+ "path": str(output),
+ "size": zip_size,
+ "sha256": zip_hash,
+ },
+ "metadata_source": {
+ "path": str(metadata_output),
+ "size": metadata_output.stat().st_size,
+ "sha256": sha256_file(metadata_output),
+ },
+ }
+ if report_output is not None:
+ atomic_bytes(report_output, json.dumps(report, indent=2).encode("utf-8") + b"\n")
+ return report
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Build deterministic embedded installer bootstrap")
+ parser.add_argument("--core-root", required=True, type=Path)
+ parser.add_argument("--package-root", required=True, type=Path)
+ parser.add_argument("--output", required=True, type=Path)
+ parser.add_argument("--metadata-output", required=True, type=Path)
+ parser.add_argument("--report-output", type=Path)
+ args = parser.parse_args()
+ print(
+ json.dumps(
+ build(
+ core_root=args.core_root,
+ package_root=args.package_root,
+ output=args.output,
+ metadata_output=args.metadata_output,
+ report_output=args.report_output,
+ ),
+ ensure_ascii=False,
+ indent=2,
+ )
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/runtime_installer_core.py b/tools/runtime_installer_core.py
new file mode 100644
index 0000000..6328680
--- /dev/null
+++ b/tools/runtime_installer_core.py
@@ -0,0 +1,835 @@
+#!/usr/bin/env python3
+"""Transactional installer core for the approved NIKKE PT-BR runtime loader.
+
+The core owns only three files that are absent from an official installation.
+It never patches, replaces or backs up official client files. Compatibility is
+checked before install and repair; removal is limited to exact package-owned
+hashes so an official update cannot turn an uninstall into an unsafe write.
+"""
+
+from __future__ import annotations
+
+import ctypes
+from ctypes import wintypes
+from datetime import datetime, timezone
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import struct
+import tempfile
+from typing import Any, Callable, Iterable
+import uuid
+
+from runtime_release_package import (
+ VerifiedRuntimePackage,
+ RuntimePayload,
+ sha256_file,
+ verify_runtime_package,
+)
+
+
+RELEVANT_PROCESSES = {
+ "nikke.exe",
+ "nikke_launcher.exe",
+ "assistant.exe",
+ "startup_runner.exe",
+ "ace-service64.exe",
+ "ace-setup64.exe",
+}
+RUNTIME_OUTPUT_DIRECTORY = "NIKKEPTBR-Runtime"
+ALLOWED_RUNTIME_OUTPUT_FILES = {"runtime.log"}
+
+
+class InstallerError(RuntimeError):
+ """The requested operation cannot continue safely."""
+
+
+class InjectedFailure(RuntimeError):
+ """Deterministic harness failure that must trigger rollback."""
+
+
+class SimulatedInterruption(BaseException):
+ """Deterministic process interruption that leaves a resumable journal."""
+
+
+class PROCESSENTRY32W(ctypes.Structure):
+ _fields_ = [
+ ("dwSize", wintypes.DWORD),
+ ("cntUsage", wintypes.DWORD),
+ ("th32ProcessID", wintypes.DWORD),
+ ("th32DefaultHeapID", ctypes.c_size_t),
+ ("th32ModuleID", wintypes.DWORD),
+ ("cntThreads", wintypes.DWORD),
+ ("th32ParentProcessID", wintypes.DWORD),
+ ("pcPriClassBase", wintypes.LONG),
+ ("dwFlags", wintypes.DWORD),
+ ("szExeFile", wintypes.WCHAR * 260),
+ ]
+
+
+def utc_now() -> str:
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+
+def atomic_json(path: Path, payload: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f".{path.name}.", dir=path.parent
+ )
+ temporary = Path(temporary_name)
+ try:
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
+ json.dump(payload, stream, ensure_ascii=False, indent=2)
+ stream.write("\n")
+ stream.flush()
+ os.fsync(stream.fileno())
+ temporary.replace(path)
+ except BaseException:
+ temporary.unlink(missing_ok=True)
+ raise
+
+
+def atomic_copy(source: Path, destination: Path, expected_hash: str) -> None:
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f".{destination.name}.", dir=destination.parent
+ )
+ temporary = Path(temporary_name)
+ try:
+ with source.open("rb") as input_stream, os.fdopen(
+ descriptor, "wb"
+ ) as output_stream:
+ shutil.copyfileobj(input_stream, output_stream, length=4 * 1024 * 1024)
+ output_stream.flush()
+ os.fsync(output_stream.fileno())
+ if sha256_file(temporary) != expected_hash:
+ raise InstallerError(f"Staged payload hash mismatch: {destination.name}")
+ temporary.replace(destination)
+ if sha256_file(destination) != expected_hash:
+ raise InstallerError(f"Installed payload hash mismatch: {destination.name}")
+ except BaseException:
+ temporary.unlink(missing_ok=True)
+ raise
+
+
+def is_inside(path: Path, parent: Path) -> bool:
+ try:
+ path.resolve(strict=False).relative_to(parent.resolve(strict=False))
+ return True
+ except ValueError:
+ return False
+
+
+def read_json(path: Path, label: str) -> dict[str, Any]:
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ raise InstallerError(f"Cannot read {label}: {error}") from error
+ if not isinstance(value, dict):
+ raise InstallerError(f"{label} root must be an object.")
+ return value
+
+
+def running_relevant_processes() -> list[dict[str, Any]]:
+ if os.name != "nt":
+ return []
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
+ kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
+ snapshot = kernel32.CreateToolhelp32Snapshot(0x00000002, 0)
+ invalid = ctypes.c_void_p(-1).value
+ if snapshot == invalid:
+ raise OSError(ctypes.get_last_error(), "CreateToolhelp32Snapshot failed")
+ kernel32.Process32FirstW.argtypes = [
+ wintypes.HANDLE,
+ ctypes.POINTER(PROCESSENTRY32W),
+ ]
+ kernel32.Process32FirstW.restype = wintypes.BOOL
+ kernel32.Process32NextW.argtypes = [
+ wintypes.HANDLE,
+ ctypes.POINTER(PROCESSENTRY32W),
+ ]
+ kernel32.Process32NextW.restype = wintypes.BOOL
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
+ entry = PROCESSENTRY32W()
+ entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
+ result: list[dict[str, Any]] = []
+ try:
+ available = kernel32.Process32FirstW(snapshot, ctypes.byref(entry))
+ while available:
+ name = entry.szExeFile
+ if name.lower() in RELEVANT_PROCESSES:
+ result.append({"name": name, "pid": int(entry.th32ProcessID)})
+ available = kernel32.Process32NextW(snapshot, ctypes.byref(entry))
+ finally:
+ kernel32.CloseHandle(snapshot)
+ return result
+
+
+def rva_to_offset(header: bytes, rva: int) -> int:
+ if len(header) < 0x40:
+ raise InstallerError("GameAssembly PE header is truncated.")
+ pe = struct.unpack_from(" str:
+ return (
+ datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ + "-"
+ + uuid.uuid4().hex[:12]
+ )
+
+
+class RuntimeInstallerTransaction:
+ """Install, verify, repair and remove a verified runtime package."""
+
+ def __init__(
+ self,
+ *,
+ package: VerifiedRuntimePackage,
+ target_root: Path,
+ state_root: Path,
+ allowed_target_parent: Path | None = None,
+ forbidden_roots: Iterable[Path] = (),
+ process_checker: Callable[[], list[dict[str, Any]]] = running_relevant_processes,
+ ) -> None:
+ self.package = verify_runtime_package(package.root)
+ self.target_root = target_root.resolve(strict=True)
+ self.state_root = state_root.resolve(strict=False)
+ self.allowed_target_parent = (
+ allowed_target_parent.resolve(strict=True)
+ if allowed_target_parent is not None
+ else None
+ )
+ self.forbidden_roots = tuple(
+ path.resolve(strict=False) for path in forbidden_roots
+ )
+ self.process_checker = process_checker
+ self._validate_roots()
+ self.state_root.mkdir(parents=True, exist_ok=True)
+ self.state_root = self.state_root.resolve(strict=True)
+ self.journal_path = self.state_root / "transaction-journal.json"
+ self.installation_state_path = self.state_root / "installation-state.json"
+ self.receipts_root = self.state_root / "receipts"
+ self.quarantine_root = self.state_root / "quarantine"
+
+ def _validate_roots(self) -> None:
+ if not self.target_root.is_dir():
+ raise InstallerError("Target root is not a directory.")
+ if self.allowed_target_parent is not None and not is_inside(
+ self.target_root, self.allowed_target_parent
+ ):
+ raise InstallerError("Target escaped its explicitly allowed parent.")
+ if self.target_root == self.state_root or is_inside(
+ self.target_root, self.state_root
+ ) or is_inside(self.state_root, self.target_root):
+ raise InstallerError("Target and installer state roots must be disjoint.")
+ if is_inside(self.package.root, self.target_root):
+ raise InstallerError("Package source must remain outside the client root.")
+ for forbidden in self.forbidden_roots:
+ if self.target_root == forbidden or is_inside(self.target_root, forbidden):
+ raise InstallerError("Target is inside a forbidden root.")
+ if self.state_root == forbidden or is_inside(self.state_root, forbidden):
+ raise InstallerError("Installer state is inside a forbidden root.")
+
+ def _require_processes_closed(self) -> None:
+ running = self.process_checker()
+ if running:
+ raise InstallerError(f"NIKKE processes must be closed: {running}")
+
+ def _payload(self, name: str) -> RuntimePayload:
+ for payload in self.package.payloads:
+ if payload.name == name:
+ return payload
+ raise InstallerError(f"Unknown package payload: {name}")
+
+ def _target(self, name: str) -> Path:
+ candidate = (self.target_root / name).resolve(strict=False)
+ if candidate.parent != self.target_root:
+ raise InstallerError("Owned target escaped the client root.")
+ return candidate
+
+ def _read_installation_state(self, *, required: bool = False) -> dict[str, Any] | None:
+ if not self.installation_state_path.exists():
+ if required:
+ raise InstallerError("Matching installation state is required.")
+ return None
+ state = read_json(self.installation_state_path, "installation state")
+ if state.get("schema_version") != 1:
+ raise InstallerError("Installation state schema mismatch.")
+ return state
+
+ def _matching_state(self, state: dict[str, Any] | None) -> bool:
+ return bool(
+ state
+ and state.get("package_id") == self.package.package_id
+ and state.get("package_identity_sha256") == self.package.package_identity
+ and Path(str(state.get("target_root", ""))).resolve(strict=False)
+ == self.target_root
+ )
+
+ def _load_journal(self) -> dict[str, Any] | None:
+ if not self.journal_path.exists():
+ return None
+ journal = read_json(self.journal_path, "transaction journal")
+ if journal.get("schema_version") != 1:
+ raise InstallerError("Transaction journal schema mismatch.")
+ if journal.get("package_identity_sha256") != self.package.package_identity:
+ raise InstallerError("Pending journal belongs to another package.")
+ if Path(str(journal.get("target_root", ""))).resolve(strict=False) != self.target_root:
+ raise InstallerError("Pending journal belongs to another target.")
+ return journal
+
+ def _save_journal(self, journal: dict[str, Any]) -> None:
+ journal["updated_at"] = utc_now()
+ atomic_json(self.journal_path, journal)
+
+ def _new_journal(self, operation: str) -> dict[str, Any]:
+ return {
+ "schema_version": 1,
+ "transaction_id": new_transaction_id(),
+ "created_at": utc_now(),
+ "updated_at": utc_now(),
+ "operation": operation,
+ "stage": f"{operation}_prepared",
+ "package_id": self.package.package_id,
+ "package_identity_sha256": self.package.package_identity,
+ "target_root": str(self.target_root),
+ "official_snapshot": self._verify_client_compatibility(),
+ "added": [],
+ "repaired": [],
+ "removed": [],
+ "pre_states": {},
+ "quarantine": {},
+ "next_action": operation,
+ }
+
+ def _critical_contract(self) -> dict[str, dict[str, Any]]:
+ records = self.package.manifest["client_compatibility"][
+ "critical_official_files"
+ ]
+ return {str(record["name"]): record for record in records}
+
+ def _verify_client_compatibility(self) -> dict[str, dict[str, Any]]:
+ result: dict[str, dict[str, Any]] = {}
+ for name, expected in self._critical_contract().items():
+ path = self.target_root / name
+ if not path.is_file():
+ raise InstallerError(f"Required official client file is missing: {name}")
+ actual = {
+ "size": path.stat().st_size,
+ "sha256": sha256_file(path),
+ }
+ if actual["size"] != int(expected["size"]) or actual["sha256"] != str(
+ expected["sha256"]
+ ).upper():
+ raise InstallerError(
+ f"Unsupported or updated NIKKE client fingerprint: {name}"
+ )
+ result[name] = actual
+
+ fingerprints = self.package.manifest["client_compatibility"][
+ "target_fingerprints"
+ ]
+ headers: dict[str, bytes] = {}
+ for fingerprint in fingerprints:
+ name = str(fingerprint["name"])
+ module = str(fingerprint["module"])
+ path = self.target_root / module
+ if module not in result or not path.is_file():
+ raise InstallerError(f"Runtime target module is not authorized: {name}")
+ if module not in headers:
+ with path.open("rb") as stream:
+ headers[module] = stream.read(1024 * 1024)
+ rva = int(str(fingerprint["rva"]), 16)
+ length = int(fingerprint["length"])
+ offset = rva_to_offset(headers[module], rva)
+ with path.open("rb") as stream:
+ stream.seek(offset)
+ target_bytes = stream.read(length)
+ if len(target_bytes) != length:
+ raise InstallerError(f"Runtime target fingerprint is truncated: {name}")
+ actual_target = hashlib.sha256(target_bytes).hexdigest().upper()
+ if actual_target != str(fingerprint["bytes_sha256"]).upper():
+ raise InstallerError(f"Runtime target fingerprint mismatch: {name}")
+ return result
+
+ def _require_official_snapshot(self, expected: dict[str, Any]) -> None:
+ if self._verify_client_compatibility() != expected:
+ raise InstallerError("Official client snapshot changed during transaction.")
+
+ def _scan_runtime_output(self) -> list[str]:
+ output = self.target_root / RUNTIME_OUTPUT_DIRECTORY
+ if not output.exists():
+ return []
+ if not output.is_dir():
+ raise InstallerError("Runtime output path is not a directory.")
+ unknown = sorted(
+ entry.name
+ for entry in output.iterdir()
+ if not entry.is_file() or entry.name not in ALLOWED_RUNTIME_OUTPUT_FILES
+ )
+ if unknown:
+ raise InstallerError(
+ "Runtime output contains unknown entries: " + ", ".join(unknown)
+ )
+ return sorted(entry.name for entry in output.iterdir())
+
+ def _artifact_states(self) -> dict[str, dict[str, Any]]:
+ states: dict[str, dict[str, Any]] = {}
+ for payload in self.package.payloads:
+ target = self._target(payload.name)
+ if not target.exists():
+ states[payload.name] = {"state": "missing", "sha256": None}
+ elif not target.is_file():
+ states[payload.name] = {"state": "unknown", "sha256": None}
+ else:
+ actual = sha256_file(target)
+ states[payload.name] = {
+ "state": "exact" if actual == payload.sha256 else "changed",
+ "sha256": actual,
+ }
+ return states
+
+ def _write_installation_state(self, status: str) -> dict[str, Any]:
+ state = {
+ "schema_version": 1,
+ "updated_at": utc_now(),
+ "status": status,
+ "package_id": self.package.package_id,
+ "package_identity_sha256": self.package.package_identity,
+ "mod_version": self.package.manifest["mod_version"],
+ "client_version": self.package.manifest["client_compatibility"]["version"],
+ "target_root": str(self.target_root),
+ "owned_files": {
+ payload.name: {
+ "size": payload.size,
+ "sha256": payload.sha256,
+ "original_state": "absent",
+ }
+ for payload in self.package.payloads
+ },
+ }
+ atomic_json(self.installation_state_path, state)
+ return state
+
+ def _copy_payload(self, payload: RuntimePayload) -> None:
+ if sha256_file(payload.source) != payload.sha256:
+ raise InstallerError(f"Package payload changed after verification: {payload.name}")
+ atomic_copy(payload.source, self._target(payload.name), payload.sha256)
+
+ def _maybe_fault(
+ self,
+ completed: int,
+ *,
+ interrupt_after: int | None,
+ fail_after: int | None,
+ ) -> None:
+ if interrupt_after is not None and completed == interrupt_after:
+ raise SimulatedInterruption("simulated resumable interruption")
+ if fail_after is not None and completed == fail_after:
+ raise InjectedFailure("injected transactional failure")
+
+ def _install_rollback(self, journal: dict[str, Any]) -> None:
+ for name in reversed(list(journal.get("added", []))):
+ payload = self._payload(name)
+ target = self._target(name)
+ if not target.exists():
+ continue
+ if not target.is_file() or sha256_file(target) != payload.sha256:
+ journal["stage"] = "install_rollback_blocked_unknown_state"
+ journal["next_action"] = "manual_inspection_required"
+ self._save_journal(journal)
+ raise InstallerError(f"Refusing to remove changed owned target: {name}")
+ target.unlink()
+ self._require_official_snapshot(journal["official_snapshot"])
+ journal["stage"] = "install_rolled_back_verified"
+ journal["next_action"] = "install"
+ self._save_journal(journal)
+
+ def install(
+ self,
+ *,
+ interrupt_after: int | None = None,
+ fail_after: int | None = None,
+ ) -> dict[str, Any]:
+ self._require_processes_closed()
+ verify_runtime_package(self.package.root)
+ state = self._read_installation_state()
+ states = self._artifact_states()
+ self._scan_runtime_output()
+ if self._matching_state(state) and state.get("status") == "installed":
+ if all(record["state"] == "exact" for record in states.values()):
+ self._verify_client_compatibility()
+ return {"result": "verified_noop", "installed": True, "changed_files": 0}
+ raise InstallerError("Installed package requires repair, not install.")
+
+ journal = self._load_journal()
+ if journal and journal.get("operation") == "install" and journal.get("stage") == "installing":
+ pass
+ else:
+ if journal and str(journal.get("stage", "")).endswith("_prepared") is False and journal.get("stage") not in {
+ "install_rolled_back_verified",
+ "removed_verified",
+ }:
+ raise InstallerError("Another transaction is pending.")
+ collisions = [name for name, record in states.items() if record["state"] != "missing"]
+ if collisions:
+ raise InstallerError(
+ "Unknown existing mod targets; refusing overwrite: " + ", ".join(collisions)
+ )
+ output = self.target_root / RUNTIME_OUTPUT_DIRECTORY
+ if output.exists():
+ raise InstallerError("Runtime output collision; refusing install.")
+ journal = self._new_journal("install")
+ journal["stage"] = "installing"
+ journal["next_action"] = "resume_install_or_rollback"
+ self._save_journal(journal)
+
+ completed = len(journal.get("added", []))
+ try:
+ for payload in self.package.payloads:
+ target = self._target(payload.name)
+ if target.exists():
+ if not target.is_file() or sha256_file(target) != payload.sha256:
+ raise InstallerError(
+ f"Unexpected target state during install: {payload.name}"
+ )
+ if payload.name not in journal["added"]:
+ journal["added"].append(payload.name)
+ self._save_journal(journal)
+ continue
+ self._copy_payload(payload)
+ journal["added"].append(payload.name)
+ completed += 1
+ self._save_journal(journal)
+ self._maybe_fault(
+ completed,
+ interrupt_after=interrupt_after,
+ fail_after=fail_after,
+ )
+ self._require_official_snapshot(journal["official_snapshot"])
+ self._write_installation_state("installed")
+ journal["stage"] = "installed_verified"
+ journal["next_action"] = "verify_or_launch"
+ self._save_journal(journal)
+ return {
+ "result": "installed_verified",
+ "installed": True,
+ "changed_files": len(journal["added"]),
+ "transaction_id": journal["transaction_id"],
+ }
+ except SimulatedInterruption:
+ raise
+ except Exception as error:
+ journal["error"] = str(error)
+ self._save_journal(journal)
+ self._install_rollback(journal)
+ raise
+
+ def verify_installed(self) -> dict[str, Any]:
+ official = self._verify_client_compatibility()
+ states = self._artifact_states()
+ runtime_output = self._scan_runtime_output()
+ state = self._read_installation_state(required=True)
+ installed = bool(
+ self._matching_state(state)
+ and state.get("status") == "installed"
+ and all(record["state"] == "exact" for record in states.values())
+ )
+ return {
+ "installed": installed,
+ "artifact_states": states,
+ "runtime_output": runtime_output,
+ "official_snapshot": official,
+ }
+
+ def _repair_rollback(self, journal: dict[str, Any]) -> None:
+ for name, pre in journal.get("pre_states", {}).items():
+ payload = self._payload(name)
+ target = self._target(name)
+ pre_state = pre["state"]
+ if pre_state == "exact":
+ continue
+ if pre_state == "missing":
+ if not target.exists():
+ continue
+ if not target.is_file() or sha256_file(target) != payload.sha256:
+ journal["stage"] = "repair_rollback_blocked_unknown_state"
+ journal["next_action"] = "manual_inspection_required"
+ self._save_journal(journal)
+ raise InstallerError(f"Cannot roll back changed repair target: {name}")
+ target.unlink()
+ elif pre_state == "changed":
+ quarantine_record = journal["quarantine"].get(name)
+ if not quarantine_record:
+ raise InstallerError(f"Missing repair quarantine record: {name}")
+ source = Path(quarantine_record["path"])
+ if not source.is_file() or sha256_file(source) != quarantine_record["sha256"]:
+ raise InstallerError(f"Repair quarantine is invalid: {name}")
+ if target.is_file() and sha256_file(target) == quarantine_record["sha256"]:
+ continue
+ if target.exists() and (
+ not target.is_file() or sha256_file(target) != payload.sha256
+ ):
+ journal["stage"] = "repair_rollback_blocked_unknown_state"
+ journal["next_action"] = "manual_inspection_required"
+ self._save_journal(journal)
+ raise InstallerError(f"Cannot roll back changed repair target: {name}")
+ atomic_copy(source, target, quarantine_record["sha256"])
+ self._require_official_snapshot(journal["official_snapshot"])
+ self._write_installation_state("installed")
+ journal["stage"] = "repair_rolled_back_verified"
+ journal["next_action"] = "repair"
+ self._save_journal(journal)
+
+ def repair(
+ self,
+ *,
+ interrupt_after: int | None = None,
+ fail_after: int | None = None,
+ ) -> dict[str, Any]:
+ self._require_processes_closed()
+ verify_runtime_package(self.package.root)
+ state = self._read_installation_state(required=True)
+ if not self._matching_state(state) or state.get("status") != "installed":
+ raise InstallerError("Repair requires a matching installed package state.")
+ self._scan_runtime_output()
+ journal = self._load_journal()
+ if journal and journal.get("operation") == "repair" and journal.get("stage") == "repairing":
+ pass
+ else:
+ if journal and journal.get("stage") not in {
+ "installed_verified",
+ "repair_rolled_back_verified",
+ "repair_verified",
+ }:
+ raise InstallerError("Another transaction is pending.")
+ journal = self._new_journal("repair")
+ journal["stage"] = "repairing"
+ states = self._artifact_states()
+ journal["pre_states"] = states
+ tx_quarantine = self.quarantine_root / journal["transaction_id"]
+ for name, record in states.items():
+ if record["state"] != "changed":
+ continue
+ source = self._target(name)
+ destination = tx_quarantine / name
+ atomic_copy(source, destination, str(record["sha256"]))
+ journal["quarantine"][name] = {
+ "path": str(destination),
+ "sha256": record["sha256"],
+ "size": source.stat().st_size,
+ }
+ journal["next_action"] = "resume_repair_or_rollback"
+ self._save_journal(journal)
+
+ completed = len(journal.get("repaired", []))
+ try:
+ for payload in self.package.payloads:
+ target = self._target(payload.name)
+ exact = target.is_file() and sha256_file(target) == payload.sha256
+ if exact:
+ continue
+ if target.exists() and not target.is_file():
+ raise InstallerError(f"Repair target is not a file: {payload.name}")
+ self._copy_payload(payload)
+ if payload.name not in journal["repaired"]:
+ journal["repaired"].append(payload.name)
+ completed += 1
+ self._save_journal(journal)
+ self._maybe_fault(
+ completed,
+ interrupt_after=interrupt_after,
+ fail_after=fail_after,
+ )
+ self._require_official_snapshot(journal["official_snapshot"])
+ self._write_installation_state("installed")
+ journal["stage"] = "repair_verified"
+ journal["next_action"] = "verify_or_launch"
+ self._save_journal(journal)
+ return {
+ "result": "repair_verified",
+ "installed": True,
+ "changed_files": len(journal["repaired"]),
+ "transaction_id": journal["transaction_id"],
+ }
+ except SimulatedInterruption:
+ raise
+ except Exception as error:
+ journal["error"] = str(error)
+ self._save_journal(journal)
+ self._repair_rollback(journal)
+ raise
+
+ def _preserve_runtime_log(self, transaction_id: str) -> list[dict[str, Any]]:
+ output = self.target_root / RUNTIME_OUTPUT_DIRECTORY
+ entries = self._scan_runtime_output()
+ if not entries:
+ return []
+ evidence_root = self.receipts_root / transaction_id / "runtime-evidence"
+ evidence_root.mkdir(parents=True, exist_ok=True)
+ preserved: list[dict[str, Any]] = []
+ for name in entries:
+ source = output / name
+ destination = evidence_root / name
+ shutil.copy2(source, destination)
+ preserved.append(
+ {
+ "name": name,
+ "path": str(destination),
+ "size": source.stat().st_size,
+ "sha256": sha256_file(source),
+ }
+ )
+ return preserved
+
+ def _remove_rollback(self, journal: dict[str, Any]) -> None:
+ for name in journal.get("removed", []):
+ payload = self._payload(name)
+ target = self._target(name)
+ if target.exists():
+ if not target.is_file() or sha256_file(target) != payload.sha256:
+ raise InstallerError(f"Cannot restore removed target safely: {name}")
+ continue
+ self._copy_payload(payload)
+ self._write_installation_state("installed")
+ journal["stage"] = "remove_rolled_back_to_installed"
+ journal["next_action"] = "remove"
+ self._save_journal(journal)
+
+ def remove(
+ self,
+ *,
+ interrupt_after: int | None = None,
+ fail_after: int | None = None,
+ ) -> dict[str, Any]:
+ self._require_processes_closed()
+ state = self._read_installation_state()
+ states = self._artifact_states()
+ if self._matching_state(state) and state.get("status") == "removed":
+ if all(record["state"] == "missing" for record in states.values()):
+ return {"result": "verified_noop", "removed": True, "changed_files": 0}
+ if not self._matching_state(state) or state.get("status") != "installed":
+ raise InstallerError("Removal requires a matching installed package state.")
+ unknown = [
+ name for name, record in states.items() if record["state"] not in {"exact", "missing"}
+ ]
+ if unknown:
+ raise InstallerError(
+ "Refusing to remove changed owned targets: " + ", ".join(unknown)
+ )
+ self._scan_runtime_output()
+
+ journal = self._load_journal()
+ if journal and journal.get("operation") == "remove" and journal.get("stage") == "removing":
+ pass
+ else:
+ if journal and journal.get("stage") not in {
+ "installed_verified",
+ "repair_verified",
+ "remove_rolled_back_to_installed",
+ }:
+ raise InstallerError("Another transaction is pending.")
+ journal = self._new_journal("remove")
+ journal["stage"] = "removing"
+ journal["runtime_evidence"] = self._preserve_runtime_log(
+ journal["transaction_id"]
+ )
+ journal["next_action"] = "resume_remove_or_rollback"
+ self._save_journal(journal)
+
+ completed = len(journal.get("removed", []))
+ try:
+ for payload in self.package.payloads:
+ target = self._target(payload.name)
+ if not target.exists():
+ continue
+ if not target.is_file() or sha256_file(target) != payload.sha256:
+ raise InstallerError(
+ f"Owned target changed during removal: {payload.name}"
+ )
+ target.unlink()
+ if payload.name not in journal["removed"]:
+ journal["removed"].append(payload.name)
+ completed += 1
+ self._save_journal(journal)
+ self._maybe_fault(
+ completed,
+ interrupt_after=interrupt_after,
+ fail_after=fail_after,
+ )
+ output = self.target_root / RUNTIME_OUTPUT_DIRECTORY
+ if output.exists():
+ for name in self._scan_runtime_output():
+ (output / name).unlink()
+ if not any(output.iterdir()):
+ output.rmdir()
+ self._write_installation_state("removed")
+ journal["stage"] = "removed_verified"
+ journal["next_action"] = "install"
+ self._save_journal(journal)
+ return {
+ "result": "removed_verified",
+ "removed": True,
+ "changed_files": len(journal["removed"]),
+ "runtime_evidence": journal.get("runtime_evidence", []),
+ "transaction_id": journal["transaction_id"],
+ }
+ except SimulatedInterruption:
+ raise
+ except Exception as error:
+ journal["error"] = str(error)
+ self._save_journal(journal)
+ self._remove_rollback(journal)
+ raise
+
+ def rollback_pending(self) -> dict[str, Any]:
+ self._require_processes_closed()
+ journal = self._load_journal()
+ if not journal:
+ return {"result": "no_pending_transaction"}
+ stage = str(journal.get("stage"))
+ if journal.get("operation") == "install" and stage == "installing":
+ self._install_rollback(journal)
+ elif journal.get("operation") == "repair" and stage == "repairing":
+ self._repair_rollback(journal)
+ elif journal.get("operation") == "remove" and stage == "removing":
+ self._remove_rollback(journal)
+ else:
+ return {"result": "no_pending_transaction", "stage": stage}
+ return {"result": "rollback_verified", "stage": journal["stage"]}
+
+ def verify_removed(self) -> dict[str, Any]:
+ states = self._artifact_states()
+ state = self._read_installation_state(required=True)
+ output = self.target_root / RUNTIME_OUTPUT_DIRECTORY
+ removed = bool(
+ self._matching_state(state)
+ and state.get("status") == "removed"
+ and all(record["state"] == "missing" for record in states.values())
+ and not output.exists()
+ )
+ return {
+ "removed": removed,
+ "artifact_states": states,
+ "runtime_output_exists": output.exists(),
+ }
diff --git a/tools/runtime_release_package.py b/tools/runtime_release_package.py
new file mode 100644
index 0000000..6d17823
--- /dev/null
+++ b/tools/runtime_release_package.py
@@ -0,0 +1,506 @@
+#!/usr/bin/env python3
+"""Compile and verify the private NIKKE PT-BR runtime release package.
+
+The package is intentionally self-contained and contains only the three files
+owned by the mod. Official client files are fingerprinted as compatibility
+dependencies, never copied into the package.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime, timezone
+import hashlib
+import json
+import os
+from pathlib import Path, PurePosixPath
+import shutil
+import tempfile
+from typing import Any, Mapping
+
+
+PACKAGE_MANIFEST_NAME = "NIKKE-PTBR-PACKAGE.json"
+PACKAGE_STATUS = "private_runtime_release_candidate"
+PAYLOAD_DIRECTORY = "payload"
+EXPECTED_PAYLOAD_NAMES = (
+ "winhttp.dll",
+ "NIKKEPTBR-Runtime.dll",
+ "NIKKEPTBR-Runtime.idx",
+)
+EXPECTED_CRITICAL_FILES = (
+ "nikke.exe",
+ "UnityPlayer.dll",
+ "GameAssembly.dll",
+)
+EXPECTED_TARGET_FINGERPRINT_NAMES = (
+ "primary_table_key_lookup",
+ "legacy_unity_ui_text_setter",
+)
+TARGET_FINGERPRINT_LENGTH = 32
+
+
+class PackageError(RuntimeError):
+ """The package failed a closed contract check."""
+
+
+def utc_now() -> str:
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(4 * 1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest().upper()
+
+
+def canonical_json_bytes(value: Any) -> bytes:
+ return json.dumps(
+ value,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+
+
+def is_inside(path: Path, parent: Path) -> bool:
+ try:
+ path.resolve(strict=False).relative_to(parent.resolve(strict=False))
+ return True
+ except ValueError:
+ return False
+
+
+def safe_relative_path(raw: str) -> Path:
+ normalized = raw.replace("\\", "/")
+ pure = PurePosixPath(normalized)
+ if not normalized.strip() or pure.is_absolute():
+ raise PackageError(f"Unsafe package path: {raw!r}")
+ if any(part in {"", ".", ".."} for part in pure.parts):
+ raise PackageError(f"Unsafe package path: {raw!r}")
+ return Path(*pure.parts)
+
+
+def read_json(path: Path, label: str) -> dict[str, Any]:
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ raise PackageError(f"Cannot read {label}: {error}") from error
+ if not isinstance(value, dict):
+ raise PackageError(f"{label} root must be an object.")
+ return value
+
+
+def atomic_json(path: Path, payload: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f".{path.name}.", dir=path.parent
+ )
+ temporary = Path(temporary_name)
+ try:
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
+ json.dump(payload, stream, ensure_ascii=False, indent=2)
+ stream.write("\n")
+ stream.flush()
+ os.fsync(stream.fileno())
+ temporary.replace(path)
+ except BaseException:
+ temporary.unlink(missing_ok=True)
+ raise
+
+
+def require_file(path: Path, label: str) -> Path:
+ try:
+ resolved = path.resolve(strict=True)
+ except OSError as error:
+ raise PackageError(f"Missing {label}: {path}") from error
+ if not resolved.is_file():
+ raise PackageError(f"{label} is not a regular file: {resolved}")
+ return resolved
+
+
+def package_identity(identity_input: dict[str, Any]) -> str:
+ return hashlib.sha256(canonical_json_bytes(identity_input)).hexdigest().upper()
+
+
+@dataclass(frozen=True)
+class RuntimePayload:
+ name: str
+ source: Path
+ size: int
+ sha256: str
+
+
+@dataclass(frozen=True)
+class VerifiedRuntimePackage:
+ root: Path
+ manifest: dict[str, Any]
+ payloads: tuple[RuntimePayload, ...]
+
+ @property
+ def package_id(self) -> str:
+ return str(self.manifest["package_id"])
+
+ @property
+ def package_identity(self) -> str:
+ return str(self.manifest["package_identity_sha256"])
+
+
+def _normalize_critical_files(
+ critical_files: Mapping[str, Mapping[str, Any]],
+) -> list[dict[str, Any]]:
+ if set(critical_files) != set(EXPECTED_CRITICAL_FILES):
+ raise PackageError("Critical official file set does not match the contract.")
+ normalized: list[dict[str, Any]] = []
+ for name in EXPECTED_CRITICAL_FILES:
+ record = critical_files[name]
+ size = int(record["size"])
+ sha256 = str(record["sha256"]).upper()
+ if size <= 0 or len(sha256) != 64:
+ raise PackageError(f"Invalid critical fingerprint: {name}")
+ normalized.append({"name": name, "size": size, "sha256": sha256})
+ return normalized
+
+
+def _normalize_target_fingerprints(
+ target_fingerprints: Mapping[str, Mapping[str, Any]],
+) -> list[dict[str, Any]]:
+ if set(target_fingerprints) != set(EXPECTED_TARGET_FINGERPRINT_NAMES):
+ raise PackageError("Runtime target fingerprint set does not match the contract.")
+ normalized: list[dict[str, Any]] = []
+ for name in EXPECTED_TARGET_FINGERPRINT_NAMES:
+ record = target_fingerprints[name]
+ if set(record) != {"module", "rva", "length", "bytes_sha256"}:
+ raise PackageError(f"Malformed runtime target fingerprint: {name}")
+ module = str(record["module"])
+ if module != "GameAssembly.dll":
+ raise PackageError(f"Unsupported runtime target module: {name}")
+ try:
+ rva_value = int(str(record["rva"]), 16)
+ except ValueError as error:
+ raise PackageError(f"Invalid runtime target RVA: {name}") from error
+ if rva_value <= 0:
+ raise PackageError(f"Invalid runtime target RVA: {name}")
+ length = int(record["length"])
+ if length != TARGET_FINGERPRINT_LENGTH:
+ raise PackageError(f"Invalid runtime target length: {name}")
+ sha256 = str(record["bytes_sha256"]).upper()
+ try:
+ digest_bytes = bytes.fromhex(sha256)
+ except ValueError as error:
+ raise PackageError(f"Invalid runtime target fingerprint: {name}") from error
+ if len(digest_bytes) != 32:
+ raise PackageError(f"Invalid runtime target fingerprint: {name}")
+ normalized.append(
+ {
+ "name": name,
+ "module": module,
+ "rva": f"0x{rva_value:X}",
+ "length": length,
+ "bytes_sha256": sha256,
+ }
+ )
+ return normalized
+
+
+def compile_runtime_package(
+ *,
+ package_id: str,
+ mod_version: str,
+ output_root: Path,
+ allowed_output_parent: Path,
+ artifact_sources: Mapping[str, Path],
+ evidence_files: Mapping[str, Path],
+ client_version: str,
+ critical_files: Mapping[str, Mapping[str, Any]],
+ target_fingerprints: Mapping[str, Mapping[str, Any]],
+ runtime_catalog: Mapping[str, Any],
+) -> dict[str, Any]:
+ """Materialize a closed private candidate without touching the client."""
+
+ allowed_output_parent = allowed_output_parent.resolve(strict=True)
+ output_root = output_root.resolve(strict=False)
+ if not is_inside(output_root, allowed_output_parent) or output_root == allowed_output_parent:
+ raise PackageError("Package output escaped its explicitly allowed parent.")
+ if output_root.exists():
+ raise PackageError(f"Package output already exists: {output_root}")
+ if not package_id.strip() or not mod_version.strip():
+ raise PackageError("Package id and mod version are required.")
+ if set(artifact_sources) != set(EXPECTED_PAYLOAD_NAMES):
+ raise PackageError("Runtime artifact set does not match the closed contract.")
+
+ payload_records: list[dict[str, Any]] = []
+ resolved_sources: dict[str, Path] = {}
+ for name in EXPECTED_PAYLOAD_NAMES:
+ source = require_file(Path(artifact_sources[name]), f"runtime artifact {name}")
+ resolved_sources[name] = source
+ payload_records.append(
+ {
+ "name": name,
+ "path": f"{PAYLOAD_DIRECTORY}/{name}",
+ "target": name,
+ "size": source.stat().st_size,
+ "sha256": sha256_file(source),
+ "original_state": "absent",
+ }
+ )
+
+ provenance: dict[str, dict[str, Any]] = {}
+ for logical_name, raw_path in sorted(evidence_files.items()):
+ evidence_path = require_file(Path(raw_path), f"provenance {logical_name}")
+ provenance[logical_name] = {
+ "size": evidence_path.stat().st_size,
+ "sha256": sha256_file(evidence_path),
+ }
+ if not provenance:
+ raise PackageError("At least one provenance record is required.")
+
+ critical = _normalize_critical_files(critical_files)
+ fingerprints = _normalize_target_fingerprints(target_fingerprints)
+
+ catalog = {
+ "sha256": str(runtime_catalog["sha256"]).upper(),
+ "translated_occurrences": int(runtime_catalog["translated_occurrences"]),
+ "translated_units": int(runtime_catalog["translated_units"]),
+ "source_tables": int(runtime_catalog["source_tables"]),
+ "identity": list(runtime_catalog["identity"]),
+ "fail_open": bool(runtime_catalog["fail_open"]),
+ }
+ if catalog["translated_occurrences"] <= 0 or catalog["translated_units"] <= 0:
+ raise PackageError("Runtime catalog cardinality is invalid.")
+
+ identity_input = {
+ "schema_version": 2,
+ "package_id": package_id,
+ "mod_version": mod_version,
+ "client_version": client_version,
+ "critical_official_files": critical,
+ "target_fingerprints": fingerprints,
+ "payload": payload_records,
+ "runtime_catalog": catalog,
+ "provenance": provenance,
+ }
+ manifest: dict[str, Any] = {
+ "schema_version": 2,
+ "generated_at": utc_now(),
+ "project": "nikke-ptbr",
+ "package_id": package_id,
+ "package_identity_sha256": package_identity(identity_input),
+ "mod_version": mod_version,
+ "status": PACKAGE_STATUS,
+ "client_compatibility": {
+ "version": client_version,
+ "critical_official_files": critical,
+ "target_fingerprints": fingerprints,
+ "unknown_client_policy": "refuse_install_and_preserve_official_behavior",
+ },
+ "payload": {
+ "files": payload_records,
+ "file_count": len(payload_records),
+ "bytes": sum(record["size"] for record in payload_records),
+ "official_files_included": 0,
+ },
+ "runtime_catalog": catalog,
+ "provenance": provenance,
+ "transaction_contract": {
+ "owned_targets": list(EXPECTED_PAYLOAD_NAMES),
+ "original_state": "all_owned_targets_absent",
+ "collision_policy": "refuse_unknown_existing_target",
+ "repair_policy": "repair_only_with_matching_installation_state_and_quarantine",
+ "remove_policy": "remove_only_exact_owned_payload_hashes",
+ "runtime_output_directory": "NIKKEPTBR-Runtime",
+ "allowed_runtime_output_files": ["runtime.log"],
+ },
+ "release_gate": {
+ "private_installable_candidate": True,
+ "public_distribution_authorized": False,
+ "pending": [
+ "private package transaction harness",
+ "professional installer interface",
+ "end-user-equivalent acceptance",
+ "documentation and private distribution preparation",
+ ],
+ },
+ }
+
+ staging = Path(
+ tempfile.mkdtemp(prefix=f".{output_root.name}.", dir=allowed_output_parent)
+ ).resolve(strict=True)
+ if not is_inside(staging, allowed_output_parent):
+ raise PackageError("Package staging escaped its allowed parent.")
+ try:
+ payload_root = staging / PAYLOAD_DIRECTORY
+ payload_root.mkdir()
+ for record in payload_records:
+ destination = payload_root / record["name"]
+ shutil.copy2(resolved_sources[record["name"]], destination)
+ if destination.stat().st_size != record["size"]:
+ raise PackageError(f"Payload size mismatch while staging {record['name']}.")
+ if sha256_file(destination) != record["sha256"]:
+ raise PackageError(f"Payload hash mismatch while staging {record['name']}.")
+ atomic_json(staging / PACKAGE_MANIFEST_NAME, manifest)
+ staging.replace(output_root)
+ except BaseException:
+ if staging.exists() and is_inside(staging, allowed_output_parent):
+ shutil.rmtree(staging)
+ raise
+ return manifest
+
+
+def verify_runtime_package(
+ package_root: Path,
+ *,
+ expected_evidence_files: Mapping[str, Path] | None = None,
+) -> VerifiedRuntimePackage:
+ package_root = package_root.resolve(strict=True)
+ if not package_root.is_dir():
+ raise PackageError("Package root is not a directory.")
+ manifest_path = require_file(package_root / PACKAGE_MANIFEST_NAME, "package manifest")
+ manifest = read_json(manifest_path, "package manifest")
+ if manifest.get("schema_version") != 2 or manifest.get("status") != PACKAGE_STATUS:
+ raise PackageError("Package status or schema is not approved.")
+ if manifest.get("project") != "nikke-ptbr":
+ raise PackageError("Package project identity mismatch.")
+
+ compatibility = manifest.get("client_compatibility")
+ if not isinstance(compatibility, dict):
+ raise PackageError("Missing client compatibility contract.")
+ critical_records = compatibility.get("critical_official_files")
+ if not isinstance(critical_records, list):
+ raise PackageError("Missing critical official fingerprints.")
+ critical_map = {
+ str(record.get("name")): record
+ for record in critical_records
+ if isinstance(record, dict)
+ }
+ _normalize_critical_files(critical_map)
+ target_records = compatibility.get("target_fingerprints")
+ if not isinstance(target_records, list):
+ raise PackageError("Missing runtime target fingerprints.")
+ target_map: dict[str, dict[str, Any]] = {}
+ for record in target_records:
+ if not isinstance(record, dict):
+ raise PackageError("Malformed runtime target fingerprint record.")
+ name = str(record.get("name"))
+ if name in target_map:
+ raise PackageError("Duplicate runtime target fingerprint record.")
+ target_map[name] = {
+ key: value for key, value in record.items() if key != "name"
+ }
+ normalized_targets = _normalize_target_fingerprints(target_map)
+ if target_records != normalized_targets:
+ raise PackageError("Runtime target fingerprints are not canonical.")
+
+ payload = manifest.get("payload")
+ if not isinstance(payload, dict) or not isinstance(payload.get("files"), list):
+ raise PackageError("Missing payload contract.")
+ records = payload["files"]
+ if len(records) != len(EXPECTED_PAYLOAD_NAMES):
+ raise PackageError("Payload cardinality mismatch.")
+ payloads: list[RuntimePayload] = []
+ declared_files = {PACKAGE_MANIFEST_NAME}
+ seen_names: set[str] = set()
+ for record in records:
+ if not isinstance(record, dict):
+ raise PackageError("Malformed payload record.")
+ name = str(record.get("name"))
+ target = str(record.get("target"))
+ relative = safe_relative_path(str(record.get("path")))
+ if name not in EXPECTED_PAYLOAD_NAMES or target != name or name in seen_names:
+ raise PackageError("Payload target set does not match the closed contract.")
+ if relative.as_posix() != f"{PAYLOAD_DIRECTORY}/{name}":
+ raise PackageError(f"Unexpected payload path for {name}.")
+ if record.get("original_state") != "absent":
+ raise PackageError(f"Invalid original-state contract for {name}.")
+ source = require_file(package_root / relative, f"package payload {name}")
+ expected_size = int(record["size"])
+ expected_hash = str(record["sha256"]).upper()
+ if source.stat().st_size != expected_size:
+ raise PackageError(f"Payload size mismatch: {name}")
+ if sha256_file(source) != expected_hash:
+ raise PackageError(f"Payload hash mismatch: {name}")
+ payloads.append(
+ RuntimePayload(name=name, source=source, size=expected_size, sha256=expected_hash)
+ )
+ declared_files.add(relative.as_posix())
+ seen_names.add(name)
+ if seen_names != set(EXPECTED_PAYLOAD_NAMES):
+ raise PackageError("Payload target set is incomplete.")
+
+ actual_files = {
+ path.relative_to(package_root).as_posix()
+ for path in package_root.rglob("*")
+ if path.is_file()
+ }
+ if actual_files != declared_files:
+ raise PackageError(
+ "Package file set mismatch; extra or missing files are not allowed."
+ )
+ if payload.get("file_count") != len(payloads):
+ raise PackageError("Manifest payload count mismatch.")
+ if payload.get("bytes") != sum(item.size for item in payloads):
+ raise PackageError("Manifest payload byte count mismatch.")
+ if payload.get("official_files_included") != 0:
+ raise PackageError("Official client files must not be included.")
+
+ catalog = manifest.get("runtime_catalog")
+ if not isinstance(catalog, dict):
+ raise PackageError("Missing runtime catalog contract.")
+ required_catalog = {
+ "source_tables": 34,
+ "identity": ["source_table", "source_key", "original_source_text"],
+ "fail_open": True,
+ }
+ if any(catalog.get(key) != value for key, value in required_catalog.items()):
+ raise PackageError("Runtime catalog contract mismatch.")
+ translated_units = int(catalog.get("translated_units", 0))
+ translated_occurrences = int(catalog.get("translated_occurrences", 0))
+ if translated_units <= 0 or translated_occurrences < translated_units:
+ raise PackageError("Runtime catalog cardinality is invalid.")
+ catalog_sha256 = str(catalog.get("sha256", "")).upper()
+ try:
+ catalog_digest = bytes.fromhex(catalog_sha256)
+ except ValueError as error:
+ raise PackageError("Runtime catalog fingerprint is invalid.") from error
+ if len(catalog_digest) != 32:
+ raise PackageError("Runtime catalog fingerprint is invalid.")
+
+ provenance = manifest.get("provenance")
+ if not isinstance(provenance, dict) or not provenance:
+ raise PackageError("Missing package provenance.")
+ if expected_evidence_files is not None:
+ if set(expected_evidence_files) != set(provenance):
+ raise PackageError("Expected provenance set mismatch.")
+ for logical_name, raw_path in expected_evidence_files.items():
+ evidence = require_file(Path(raw_path), f"provenance {logical_name}")
+ record = provenance[logical_name]
+ if evidence.stat().st_size != int(record["size"]):
+ raise PackageError(f"Provenance size mismatch: {logical_name}")
+ if sha256_file(evidence) != str(record["sha256"]).upper():
+ raise PackageError(f"Provenance hash mismatch: {logical_name}")
+
+ identity_input = {
+ "schema_version": 2,
+ "package_id": manifest["package_id"],
+ "mod_version": manifest["mod_version"],
+ "client_version": compatibility["version"],
+ "critical_official_files": critical_records,
+ "target_fingerprints": target_records,
+ "payload": records,
+ "runtime_catalog": catalog,
+ "provenance": provenance,
+ }
+ if package_identity(identity_input) != manifest.get("package_identity_sha256"):
+ raise PackageError("Package identity mismatch.")
+ release_gate = manifest.get("release_gate", {})
+ if release_gate.get("private_installable_candidate") is not True:
+ raise PackageError("Private installable candidate gate is closed.")
+ if release_gate.get("public_distribution_authorized") is not False:
+ raise PackageError("Public distribution state is invalid for this candidate.")
+
+ return VerifiedRuntimePackage(
+ root=package_root,
+ manifest=manifest,
+ payloads=tuple(payloads),
+ )