diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 00000000..1bd833fc --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,43 @@ +name: ModVerify Update Integration Test + +on: + workflow_call: + workflow_dispatch: + +jobs: + update-cycle: + name: Update cycle (${{ matrix.scenario.name }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + scenario: + - { name: single, script: ./test-local-update.ps1 } + - { name: dual, script: ./test-local-update-dual.ps1 } + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Run ${{ matrix.scenario.name }}-channel update cycle + shell: pwsh + run: ${{ matrix.scenario.script }} + + - name: Upload diagnostics on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: update-cycle-${{ matrix.scenario.name }}-logs + # Updater log: %TEMP%\extUpdateLog-*.txt; ModVerify app log: %APPDATA%\ModVerify\*.txt + path: | + ${{ runner.temp }}\extUpdateLog-*.txt + ${{ env.APPDATA }}\ModVerify\*.txt + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a45a5161..64641496 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,22 +13,38 @@ on: env: TOOL_PROJ_PATH: ./src/ModVerify.CliApp/ModVerify.CliApp.csproj - CREATOR_PROJ_PATH: ./modules/ModdingToolBase/src/AnakinApps/ApplicationManifestCreator/ApplicationManifestCreator.csproj - UPLOADER_PROJ_PATH: ./modules/ModdingToolBase/src/AnakinApps/FtpUploader/FtpUploader.csproj + PUBLISH_SCRIPT: ./modules/ModdingToolBase/scripts/Publish-Release.ps1 TOOL_EXE: ModVerify.exe UPDATER_EXE: AnakinRaW.ExternalUpdater.exe - MANIFEST_CREATOR: AnakinRaW.ApplicationManifestCreator.dll - SFTP_UPLOADER: AnakinRaW.FtpUploader.dll + EMBEDDED_TRUST_CERT: src/ModVerify.CliApp/Resources/Certs/AET-root.cer ORIGIN_BASE: https://republicatwar.com/downloads/ModVerify - ORIGIN_BASE_PART: downloads/ModVerify/ + SFTP_BASE_PATH: downloads/ModVerify/ BRANCH_NAME: ${{ github.event.inputs.branch || 'stable' }} + # Migration-release values. Leave empty for a normal release; populate to enable. + # + # Origin URL of the next-generation channel, written into the manifest's componentOriginInfo. + NEXT_ORIGIN_BASE: https://republicatwar.com/downloads/ModVerify/v2 + + # SFTP path the next-generation channel uploads to. Set together with NEXT_ORIGIN_BASE. + NEXT_SFTP_BASE_PATH: downloads/ModVerify/v2/ + + # Previously-deployed updater used in place of the build-output one for the primary deploy. + # Only the old-gen manifest lists this binary; the next-gen manifest still uses the build-output updater. + # Requires NEXT_ORIGIN_BASE + NEXT_SFTP_BASE_PATH to be set. + COMPAT_UPDATER: tools/v1/AnakinRaW.ExternalUpdater.exe + jobs: # Builds and tests the solution. test: uses: ./.github/workflows/test.yml + # End-to-end self-update test (single-channel + dual-channel via /v2/). + integration-test: + needs: [test] + uses: ./.github/workflows/integration-test.yml + pack: name: Pack needs: [test] @@ -47,8 +63,6 @@ jobs: - name: Create Net Core Release # use publish for .NET Core run: dotnet publish ${{ env.TOOL_PROJ_PATH }} --configuration Release -f net10.0 --output ./releases/net10.0 /p:DebugType=None /p:DebugSymbols=false - - name: Create Linux Release - run: dotnet publish ${{ env.TOOL_PROJ_PATH }} --configuration Release -f net10.0 --runtime linux-x64 --self-contained true --output ./releases/linux-x64 /p:DebugType=None /p:DebugSymbols=false - name: Upload a Build Artifact uses: actions/upload-artifact@v7 with: @@ -59,10 +73,13 @@ jobs: deploy: name: Deploy - # Deploy on push to main or manual trigger + # Stable deploys are gated to 'main'. Non-stable channels (beta, canary, etc.) can be + # workflow_dispatched from any branch. if: | - (github.ref == 'refs/heads/main' && github.event_name == 'push') || github.event_name == 'workflow_dispatch' - needs: [pack] + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_dispatch' && + (github.event.inputs.branch != 'stable' || github.ref == 'refs/heads/main')) + needs: [pack, integration-test] runs-on: ubuntu-latest steps: - name: Checkout sources @@ -75,37 +92,37 @@ jobs: name: Binary Releases path: ./releases - # Deploy .NET Framework self-update release - name: Setup .NET uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x - - name: Build Creator - run: dotnet build ${{env.CREATOR_PROJ_PATH}} --configuration Release --output ./dev - - name: Build Uploader - run: dotnet build ${{env.UPLOADER_PROJ_PATH}} --configuration Release --output ./dev - - name: Create binaries directory - run: mkdir -p ./deploy - - name: Copy self-update files + + - name: Publish self-update release + shell: pwsh run: | - cp ./releases/net481/${{env.TOOL_EXE}} ./deploy/ - cp ./releases/net481/${{env.UPDATER_EXE}} ./deploy/ - - name: Create Manifest - run: dotnet ./dev/${{env.MANIFEST_CREATOR}} -a deploy/${{env.TOOL_EXE}} --appDataFiles deploy/${{env.UPDATER_EXE}} --origin ${{env.ORIGIN_BASE}} -o ./deploy -b ${{env.BRANCH_NAME}} - - name: Upload Build - run: dotnet ./dev/${{env.SFTP_UPLOADER}} ftp --host $host --port $port -u ${{secrets.SFTP_USER}} -p ${{secrets.SFTP_PASSWORD}} --base $base_path -s $source - env: - host: republicatwar.com - port: 1579 - base_path: ${{env.ORIGIN_BASE_PART}} - source: ./deploy + & $env:PUBLISH_SCRIPT ` + -AppExePath "./releases/net481/$env:TOOL_EXE" ` + -UpdaterExePath "./releases/net481/$env:UPDATER_EXE" ` + -EmbeddedTrustCertPath "$env:EMBEDDED_TRUST_CERT" ` + -Origin "$env:ORIGIN_BASE" ` + -SftpBasePath "$env:SFTP_BASE_PATH" ` + -Branch "$env:BRANCH_NAME" ` + -SigningPfxBase64 "${{ secrets.UPDATER_SIGNING_PFX_B64 }}" ` + -SigningPfxPassword "${{ secrets.UPDATER_SIGNING_PFX_PASSWORD }}" ` + -SftpHost "republicatwar.com" ` + -SftpPort 1579 ` + -SftpUser "${{ secrets.SFTP_USER }}" ` + -SftpPassword "${{ secrets.SFTP_PASSWORD }}" ` + -NextOrigin "$env:NEXT_ORIGIN_BASE" ` + -NextSftpBasePath "$env:NEXT_SFTP_BASE_PATH" ` + -CompatibilityUpdaterExePath "$env:COMPAT_UPDATER" # Deploy .NET Core and .NET Framework apps to Github - name: Create NET Core .zip # Change into the artifacts directory to avoid including the directory itself in the zip archive working-directory: ./releases/net10.0 run: zip -r ../ModVerify-Net10.zip . - - uses: dotnet/nbgv@v0.5.1 + - uses: dotnet/nbgv@v0.5.2 id: nbgv - name: Create GitHub release # Create a GitHub release on push to main only @@ -117,7 +134,6 @@ jobs: tag_name: v${{ steps.nbgv.outputs.SemVer2 }} token: ${{ secrets.GITHUB_TOKEN }} generate_release_notes: true - files: | + files: | ./releases/net481/ModVerify.exe - ./releases/ModVerify-Net10.zip - ./releases/linux-x64/ModVerify \ No newline at end of file + ./releases/ModVerify-Net10.zip \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aca43b49..964c3f6a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,5 +26,20 @@ jobs: - uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x - - name: Build & Test in Release Mode - run: dotnet test --configuration Release --report-github \ No newline at end of file + - name: Build & Test in Release Mode (Windows) + if: runner.os == 'Windows' + run: dotnet test --configuration Release --report-github + + - name: Build & Test in Release Mode (Linux) + if: runner.os == 'Linux' + shell: bash + # ExternalUpdater.App.Test is net481-only — it spawns the real ExternalUpdater.exe + # and uses Windows-only helpers (cmd .bat stubs, ping.exe). It cannot run on Linux + # (no mono in the runner image, and the helpers don't exist anyway). Build the + # whole solution, then test every project except that one. + run: | + set -euo pipefail + dotnet build --configuration Release + for proj in $(find . -name '*.Test.csproj' -not -path '*/ExternalUpdater.App.Test/*'); do + dotnet test "$proj" --no-build --configuration Release --report-github + done \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..567c34ee --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +ModVerify is a .NET CLI tool that statically analyzes mods for *Star Wars: Empire at War* and *Forces of Corruption* by reimplementing parts of the Petroglyph Alamo engine in managed code and running verifiers over the loaded game data. It produces JSON + text reports of XML errors, missing assets, broken model references, malformed SFX samples, etc. + +Vanilla EaW is currently not supported; FoC is the primary target. + +## Build / test / run + +The repo uses the Microsoft Testing Platform runner (configured via `global.json`), not VSTest. `dotnet test` works, but the test project is also an `Exe` and can be invoked directly. + +```bash +# Restore submodules (required — ModdingToolBase is a git submodule) +git submodule update --init --recursive + +# Build everything +dotnet build ModVerify.slnx + +# Run all tests (Release matches CI) +dotnet test --configuration Release + +# Run the CLI from source +dotnet run --project src/ModVerify.CliApp -- verify --path "" --useDefaultBaseline + +# Build the Windows self-updating release (.NET Framework 4.8.1, Costura-packed single exe) +dotnet build src/ModVerify.CliApp/ModVerify.CliApp.csproj -c Release -f net481 + +# Build the cross-platform release (.NET 10) +dotnet publish src/ModVerify.CliApp/ModVerify.CliApp.csproj -c Release -f net10.0 +``` + +The CLI has two verbs: `verify` and `createBaseline`. Running with no arguments enters interactive mode and prompts for a target. See `README.md` for full option examples. + +`deploy-local.ps1` builds the net481 exe, generates an update manifest, and stages it under `.local_deploy/` so the self-update flow can be exercised end-to-end without hitting the real CDN. + +## Target frameworks and why + +- `src/ModVerify` (the library): `netstandard2.0;netstandard2.1;net10.0` — keeps the core consumable from older tooling. +- `src/ModVerify.CliApp`: `net10.0;net481`. The **net481** build is the user-facing Windows executable (Costura-Fody packs all dependencies into a single `ModVerify.exe`, and the AnakinRaW self-updater is wired in only on this TFM). The **net10.0** build is the cross-platform / CI variant, has no self-updater, and is shipped as a framework-dependent zip (Linux/macOS users run it via `dotnet`). +- Test project: net10.0 always; net481 only on Windows. + +When editing the CLI app, be aware that `IsUpdatable()` is conditional on the net481 build — code paths guarded by `IsUpdateableApplication` or `_applicationEnvironment.IsUpdatable()` only run there. PolySharp + `Microsoft.Bcl.Memory` + `IndexRange` are pulled in to make modern C# language features compile against net481. + +## Architecture + +### Two-layer split: `ModVerify` (library) vs `ModVerify.CliApp` (executable) + +The library knows nothing about command-line parsing, console output, file I/O for results, or the updater. The CLI app composes the library with logging (Serilog), DI (`Microsoft.Extensions.DependencyInjection`), the AnakinRaW `ApplicationBase` lifecycle, and reporters. When adding functionality, decide which layer it belongs in: anything reusable by another host (CI integration, GUI, etc.) goes in `ModVerify`; anything tied to console UX, command-line verbs, or the Windows updater goes in `ModVerify.CliApp`. + +### Verification pipeline + +`GameVerifierService.VerifyAsync` is the entry point from `IGameVerifierService`. It builds a `GameVerifyPipeline` (a `StepRunnerPipelineBase` from `AnakinRaW.CommonUtilities.SimplePipeline`) which: + +1. Initializes a re-implementation of the game engine (`IPetroglyphStarWarsGameEngine` from the `PG.StarWarsGame.Engine` project under `src/PetroglyphTools/`) against the target's location/engine type. Engine init errors are collected by `GameEngineErrorCollector`, which becomes the first pipeline step. +2. Asks `IGameVerifiersProvider` (default: `DefaultGameVerifiersProvider`) for the set of `GameVerifier` instances to run. +3. Wraps each verifier in a `GameVerifierPipelineStep` and runs them via `AsyncStepRunner` (parallelism controlled by `VerifierServiceSettings.ParallelVerifiers`; 1 falls back to a `SequentialStepRunner`). +4. Aggregates all `VerificationError`s, then applies `VerificationBaseline` and `SuppressionList` filters before returning a `VerificationResult`. + +`FailFast` mode is non-trivial: the pipeline checks each thrown `GameVerificationException` against the baseline + suppressions and *swallows* it if every error is already accounted for, so fail-fast does not abort on known issues. Don't simplify this away. + +When adding a new verifier: subclass `GameVerifier` (or `GameVerifier` / `NamedGameEntityVerifier`), register it in `DefaultGameVerifiersProvider`, and pick a unique error code prefix (see `Verifiers/VerifierErrorCodes.cs`). `IAlreadyVerifiedCache` (registered via `RegisterVerifierCache()`) is shared across verifiers — use it to skip work that's already been done in the same run (e.g. the same texture being referenced by multiple GameObjects). + +### Baselines + +A baseline is a frozen JSON snapshot of "errors we already knew about, ignore them." `VerificationBaseline.LatestVersion` is **2.2** and the schema lives at `src/ModVerify/Resources/Schemas/2.2/baseline.json` (embedded). When bumping the baseline format, add a new versioned schema folder rather than mutating the existing one — old baselines on user disks must keep parsing. + +`src/ModVerify.CliApp/Resources/Baselines/baseline-foc.json` is the embedded default baseline shipped with the CLI (selected via `--useDefaultBaseline`). The legacy top-level `focBaseline.json` is *not* the embedded one and exists separately; don't conflate them. + +### Settings flow (CLI) + +``` +argv → ModVerifyOptionsParser → ModVerifyOptionsContainer + → SettingsBuilder.BuildSettings → AppVerifySettings | AppBaselineSettings + → ModVerifyApplication → VerifyAction | CreateBaselineAction +``` + +`SelfUpdateableAppLifecycle` (from ModdingToolBase) drives `InitializeAppAsync` → `CreateAppServices` → `RunAppAsync`. The app argument parser strips trailing arguments injected by the external updater (see `ModVerifyOptionsParser.StripExternalUpdateResults`) before handing off to CommandLineParser — needed so unknown-argument errors stay strict. + +`VerifyVerbOption.WithoutArguments` is the sentinel returned when the user just double-clicks the exe; this triggers interactive target selection in `ConsoleSelector` and enables the interactive update prompt. + +### Logging + +Two Serilog sinks, configured in `Program.ConfigureLogging`: + +- **Console sink** filters by an `EventId.Id == ModVerifyConstants.ConsoleEventIdValue` expression in normal mode (so verifier output stays clean), shows everything from the `AET.ModVerify` namespace at Debug level, and shows everything at Verbose. Fatals are excluded — they're handled by the global exception handler instead. +- **File sink** writes to `ApplicationLocalPath/ModVerify_log.txt` and excludes XML-parser noise (the engine and `XmlFileParser<>` namespaces are reported by the verification pipeline). + +When emitting log lines that the user *should* see in normal operation, attach `ModVerifyConstants.ConsoleEventId`. Plain `Logger.LogInformation(...)` without that EventId will be silently dropped at the console. + +### Petroglyph engine reimplementation (`src/PetroglyphTools/`) + +These projects (`PG.StarWarsGame.Engine`, `PG.StarWarsGame.Engine.FileSystem`, `PG.StarWarsGame.Files.ALO`, `PG.StarWarsGame.Files.ChunkFiles`, `PG.StarWarsGame.Files.XML`, etc.) are vendored copies that will eventually move to the standalone `AlamoEngine-Tools/PetroglyphTools` repo (per `src/PetroglyphTools/README.md`). Treat them as a separate library boundary — changes here may need to land upstream. + +### Submodule + +`modules/ModdingToolBase` is a git submodule (https://github.com/AnakinRaW/ModdingToolBase). It supplies the shared `ApplicationBase`, the AppUpdaterFramework, the manifest creator, and the FTP uploader used by the deploy pipeline. If you're touching the app lifecycle, updater, or release packaging, the relevant code likely lives there, not in this repo. + +## CI + +`.github/workflows/test.yml` builds + tests on Windows and Linux against .NET 10. `release.yml` (triggered on push to `main`) builds the two release artifacts (net481 self-updating exe, net10.0 portable zip), generates an update manifest via `ApplicationManifestCreator`, SFTPs the self-update bits to republicatwar.com, and creates a GitHub release tagged with the Nerdbank.GitVersioning SemVer. + +Versioning is fully driven by `version.json` + `Nerdbank.GitVersioning` — do not hand-edit assembly versions. diff --git a/Directory.Build.props b/Directory.Build.props index 96378218..cc2276e4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -33,7 +33,7 @@ - + all 3.9.50 diff --git a/ModVerify.slnx b/ModVerify.slnx index 917a44fd..9cecc54d 100644 --- a/ModVerify.slnx +++ b/ModVerify.slnx @@ -1,4 +1,9 @@ + + + + + @@ -7,7 +12,14 @@ + + + + + + + @@ -16,9 +28,18 @@ + + + + + + + + + @@ -27,4 +48,5 @@ + diff --git a/README.md b/README.md index 2725d727..8f09fc66 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,22 @@ Uses manual mod setup, including sub-mods and the EaW fallback game, and uses th --useDefaultBaseline ``` +#### Example 3: Layering a mod-specific baseline on top of the default baseline +A typical mod-dev workflow: filter the base game's known findings with the embedded default baseline, *and* filter your mod's own accepted findings with your own baseline. The two baselines stay independent — you can regenerate your mod baseline without touching the default. + +**Windows:** +```bat +.\ModVerify.exe verify --path "C:\My Games\FoC\Mods\MyMod" --useDefaultBaseline --baseline ./myModBaseline.json +``` + +**Linux:** +```bash +./ModVerify verify \ + --path "/home/user/games/FoC/Mods/MyMod" \ + --useDefaultBaseline \ + --baseline ./myModBaseline.json +``` + --- ## Available Checks @@ -156,3 +172,22 @@ ModVerify.exe createBaseline --outFile myBaseline.json --path "C:\My Games\FoC\M --outFile myBaseline.json \ --path "C:\My Games\FoC\Mods\MyMod" ``` + +### Creating a mod baseline on top of a base baseline + +If you maintain a mod and only want your baseline to contain findings your mod is responsible for, supply the base baselines you want to subtract out. Findings already covered by the base baselines are excluded from the new file. The base baselines themselves are not modified, so they can keep being maintained independently. + +**Windows** +```bat +.\ModVerify.exe createBaseline --outFile myModBaseline.json --path "C:\My Games\FoC\Mods\MyMod" --useDefaultBaseline +``` + +**Linux** +```bash +./ModVerify createBaseline \ + --outFile myModBaseline.json \ + --path "/home/user/games/FoC/Mods/MyMod" \ + --useDefaultBaseline +``` + +You can also chain a custom base baseline via `--baseline `, and combine it with `--useDefaultBaseline`. diff --git a/deploy-local.ps1 b/deploy-local.ps1 index 740c5791..08f32030 100644 --- a/deploy-local.ps1 +++ b/deploy-local.ps1 @@ -1,75 +1,67 @@ -# Local deployment script for ModVerify to test the update feature. -# This script builds the application, creates an update manifest, and "deploys" it to a local directory. +# ========================================================================================= +# Local deployment to test the ModVerify update flow end-to-end. +# +# USAGE +# .\deploy-local.ps1 # single-channel test +# .\deploy-local.ps1 -DualPublish # also publish to a next-gen channel +# .\deploy-local.ps1 -DualPublish -CompatibilityUpdater +# # primary uses compat updater; +# # next-gen uses build-output updater +# +# -CompatibilityUpdater requires -DualPublish. +# -InstalledVersion / -ServerVersion override the version pair used to set up the test. +# +# Builds ModVerify twice (older "installed" + newer "server"), then hands off to the shared +# Publish-LocalRelease.ps1 in ModdingToolBase for cert generation, manifest signing, and +# install-dir staging. +# ========================================================================================= + +param( + [string]$InstalledVersion = "0.0.1-local", + [string]$ServerVersion = "99.99.99-local", + [switch]$DualPublish, + [string]$CompatibilityUpdater +) $ErrorActionPreference = "Stop" $root = $PSScriptRoot if ([string]::IsNullOrEmpty($root)) { $root = Get-Location } -$deployRoot = Join-Path $root ".local_deploy" -$stagingDir = Join-Path $deployRoot "staging" -$serverDir = Join-Path $deployRoot "server" -$installDir = Join-Path $deployRoot "install" +. (Join-Path $root "modules\ModdingToolBase\scripts\NbgvVersion.ps1") -$toolProj = Join-Path $root "src\ModVerify.CliApp\ModVerify.CliApp.csproj" -$creatorProj = Join-Path $root "modules\ModdingToolBase\src\AnakinApps\ApplicationManifestCreator\ApplicationManifestCreator.csproj" -$uploaderProj = Join-Path $root "modules\ModdingToolBase\src\AnakinApps\FtpUploader\FtpUploader.csproj" +$deployRoot = Join-Path $root ".local_deploy" +$installBuildDir = Join-Path $deployRoot "bin\install" +$serverBuildDir = Join-Path $deployRoot "bin\tool" -$toolExe = "ModVerify.exe" -$updaterExe = "AnakinRaW.ExternalUpdater.exe" -$manifestCreatorDll = "AnakinRaW.ApplicationManifestCreator.dll" -$uploaderDll = "AnakinRaW.FtpUploader.dll" +$toolProj = Join-Path $root "src\ModVerify.CliApp\ModVerify.CliApp.csproj" +$baseScript = Join-Path $root "modules\ModdingToolBase\scripts\Publish-LocalRelease.ps1" -# 1. Clean and Create directories if (Test-Path $deployRoot) { Remove-Item -Recurse -Force $deployRoot } -New-Item -ItemType Directory -Path $stagingDir | Out-Null -New-Item -ItemType Directory -Path $serverDir | Out-Null -New-Item -ItemType Directory -Path $installDir | Out-Null - -Write-Host "--- Building ModVerify (net481) ---" -ForegroundColor Cyan -dotnet build $toolProj --configuration Release -f net481 --output "$deployRoot\bin\tool" /p:DebugType=None /p:DebugSymbols=false - -Write-Host "--- Building Manifest Creator ---" -ForegroundColor Cyan -dotnet build $creatorProj --configuration Release --output "$deployRoot\bin\creator" - -Write-Host "--- Building Local Uploader ---" -ForegroundColor Cyan -dotnet build $uploaderProj --configuration Release --output "$deployRoot\bin\uploader" - -# 2. Prepare staging -Write-Host "--- Preparing Staging ---" -ForegroundColor Cyan -Copy-Item "$deployRoot\bin\tool\$toolExe" $stagingDir -Copy-Item "$deployRoot\bin\tool\$updaterExe" $stagingDir +New-Item -ItemType Directory -Path $deployRoot | Out-Null -# 3. Create Manifest -# Origin must be an absolute URI for the manifest creator. -# Using 127.0.0.1 and file:// is tricky with Flurl/DownloadManager sometimes. -# We'll use the local path and ensure it's formatted correctly. -$serverPath = (Resolve-Path $serverDir).Path -$serverUri = "file:///$($serverPath.Replace('\', '/'))" -# If we have 3 slashes, Flurl/DownloadManager might still fail on Windows if it expects a certain format. -# However, the ManifestCreator just needs a valid URI for the 'Origin' field in the manifest. -Write-Host "--- Creating Manifest (Origin: $serverUri) ---" -ForegroundColor Cyan -dotnet "$deployRoot\bin\creator\$manifestCreatorDll" ` - -a "$stagingDir\$toolExe" ` - --appDataFiles "$stagingDir\$updaterExe" ` - --origin "$serverUri" ` - -o "$stagingDir" ` - -b "beta" +$nbgv = Backup-NbgvVersion -RepoRoot $root +try { + Write-Host "--- Building ModVerify (net481) @ installed v$InstalledVersion ---" -ForegroundColor Cyan + Set-NbgvVersion -Snapshot $nbgv -Version $InstalledVersion + dotnet build $toolProj --configuration Release -f net481 --output $installBuildDir /p:DebugType=None /p:DebugSymbols=false /p:LocalDeploy=true -# 4. "Deploy" to server using the local uploader -Write-Host "--- Deploying to Local Server ---" -ForegroundColor Cyan -dotnet "$deployRoot\bin\uploader\$uploaderDll" local --base "$serverDir" --source "$stagingDir" + Write-Host "--- Building ModVerify (net481) @ server v$ServerVersion ---" -ForegroundColor Cyan + Set-NbgvVersion -Snapshot $nbgv -Version $ServerVersion + dotnet build $toolProj --configuration Release -f net481 --output $serverBuildDir /p:DebugType=None /p:DebugSymbols=false /p:LocalDeploy=true -# 5. Setup a "test" installation -Write-Host "--- Setting up Test Installation ---" -ForegroundColor Cyan -Copy-Item "$deployRoot\bin\tool\*" $installDir -Recurse + $publishParams = @{ + AppExePath = Join-Path $serverBuildDir "ModVerify.exe" + UpdaterExePath = Join-Path $serverBuildDir "AnakinRaW.ExternalUpdater.exe" + DeployRoot = $deployRoot + InstallBuildDir = $installBuildDir + Branch = "beta" + } + if ($DualPublish) { $publishParams.DualPublish = $true } + if ($CompatibilityUpdater) { $publishParams.CompatibilityUpdater = $CompatibilityUpdater } -Write-Host "`nLocal deployment complete!" -ForegroundColor Green -Write-Host "Server directory: $serverDir" -Write-Host "Install directory: $installDir" -Write-Host "`nTo test the update:" -Write-Host "1. (Optional) Modify the version in version.json and run this script again to 'push' a new version to the local server." -Write-Host "2. Run ModVerify from the install directory with the following command:" -Write-Host " cd '$installDir'" -Write-Host " .\ModVerify.exe updateApplication --updateManifestUrl '$serverUri'" -Write-Host "`n Note: You can also specify a different branch using --updateBranch if needed." + & $baseScript @publishParams +} +finally { + Restore-NbgvVersion -Snapshot $nbgv +} diff --git a/modules/ModdingToolBase b/modules/ModdingToolBase index 3901d1a8..d575a112 160000 --- a/modules/ModdingToolBase +++ b/modules/ModdingToolBase @@ -1 +1 @@ -Subproject commit 3901d1a899b8830ef691c06684b023a85f290b84 +Subproject commit d575a112196400d2710ed7d8b1d0759316c8b712 diff --git a/src/ModVerify.CliApp/App/CreateBaselineAction.cs b/src/ModVerify.CliApp/App/CreateBaselineAction.cs index b776e09f..ff33339a 100644 --- a/src/ModVerify.CliApp/App/CreateBaselineAction.cs +++ b/src/ModVerify.CliApp/App/CreateBaselineAction.cs @@ -1,8 +1,7 @@ -using AET.ModVerify.App.Reporting; +using AET.ModVerify.App.Reporting; using AET.ModVerify.App.Settings; using AET.ModVerify.App.Utilities; using AET.ModVerify.Reporting; -using AET.ModVerify.Reporting.Baseline; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System; @@ -15,7 +14,7 @@ internal sealed class CreateBaselineAction(AppBaselineSettings settings, IServic : ModVerifyApplicationAction(settings, serviceProvider) { private readonly IFileSystem _fileSystem = serviceProvider.GetRequiredService(); - + protected override void PrintAction(VerificationTarget target) { Console.WriteLine(); @@ -29,11 +28,11 @@ protected override void PrintAction(VerificationTarget target) protected override async Task ProcessResult(VerificationResult result) { var baselineFactory = ServiceProvider.GetRequiredService(); - var baseline = baselineFactory.CreateBaseline(result.Target, Settings, result.Errors); + var baseline = baselineFactory.CreateBaseline(result.Target, Settings, result.Errors.NewErrors); var fullPath = _fileSystem.Path.GetFullPath(Settings.NewBaselinePath); - Logger?.LogInformation(ModVerifyConstants.ConsoleEventId, - "Writing Baseline to '{FullPath}' with {Number} findings", fullPath, result.Errors.Count); + Logger?.LogInformation(ModVerifyConstants.ConsoleEventId, + "Writing Baseline to '{FullPath}' with {Number} findings", fullPath, result.Errors.NewErrors.Count); await baselineFactory.WriteBaselineAsync(baseline, Settings.NewBaselinePath); @@ -46,9 +45,4 @@ protected override async Task ProcessResult(VerificationResult result) return ModVerifyConstants.Success; } - - protected override VerificationBaseline GetBaseline(VerificationTarget verificationTarget) - { - return VerificationBaseline.Empty; - } } \ No newline at end of file diff --git a/src/ModVerify.CliApp/App/ModVerifyApplicationAction.cs b/src/ModVerify.CliApp/App/ModVerifyApplicationAction.cs index 4fd10dc9..4ea4cc6c 100644 --- a/src/ModVerify.CliApp/App/ModVerifyApplicationAction.cs +++ b/src/ModVerify.CliApp/App/ModVerifyApplicationAction.cs @@ -6,6 +6,7 @@ using AET.ModVerify.App.Reporting; using AET.ModVerify.App.Settings; using AET.ModVerify.App.TargetSelectors; +using AET.ModVerify.App.Utilities; using AET.ModVerify.Reporting; using AET.ModVerify.Reporting.Baseline; using AET.ModVerify.Reporting.Suppressions; @@ -105,14 +106,30 @@ private int ReportVerificationFailure(Exception verificationException) } protected abstract Task ProcessResult(VerificationResult result); - - protected abstract VerificationBaseline GetBaseline(VerificationTarget verificationTarget); + + protected virtual BaselineCollection GetBaselines(VerificationTarget verificationTarget) + { + var baselineSelector = new BaselineSelector(Settings, ServiceProvider); + var baselines = baselineSelector.SelectBaselines(verificationTarget); + if (!baselines.IsEmpty) + { + Console.WriteLine(); + ModVerifyConsoleUtilities.WriteBaselineInfo(baselines); + foreach (var entry in baselines) + { + Logger?.LogDebug("Using baseline {Baseline} from source '{Identifier}'", + entry.Baseline.ToString(), entry.Identifier); + } + Console.WriteLine(); + } + return baselines; + } private async Task VerifyTargetAsync(VerificationTarget verificationTarget) { var progressReporter = new VerifyConsoleProgressReporter(verificationTarget.Name, Settings.ReportSettings); - var baseline = GetBaseline(verificationTarget); + var baselines = GetBaselines(verificationTarget); var suppressions = GetSuppressions(); try @@ -122,11 +139,11 @@ private async Task VerifyTargetAsync(VerificationTarget veri Logger?.LogInformation(ModVerifyConstants.ConsoleEventId, "Verifying '{Target}'...", verificationTarget.Name); var verificationResult = await verifierService.VerifyAsync( - verificationTarget, + verificationTarget, Settings.VerifierServiceSettings, - baseline, + baselines, suppressions, - progressReporter, + progressReporter, new EngineInitializeProgressReporter(verificationTarget.Engine)); progressReporter.Report(string.Empty, 1.0); diff --git a/src/ModVerify.CliApp/App/VerifyAction.cs b/src/ModVerify.CliApp/App/VerifyAction.cs index deeb053e..46ddf7bb 100644 --- a/src/ModVerify.CliApp/App/VerifyAction.cs +++ b/src/ModVerify.CliApp/App/VerifyAction.cs @@ -1,5 +1,4 @@ -using AET.ModVerify.App.Reporting; -using AET.ModVerify.App.Settings; +using AET.ModVerify.App.Settings; using AET.ModVerify.App.Utilities; using AET.ModVerify.Reporting; using Microsoft.Extensions.Logging; @@ -8,7 +7,6 @@ using System.Linq; using System.Threading.Tasks; using AET.ModVerify.Reporting.Reporters; -using AET.ModVerify.Reporting.Baseline; namespace AET.ModVerify.App; @@ -41,7 +39,7 @@ protected override async Task ProcessResult(VerificationResult result) await reportBroker.ReportAsync(result); if (Settings.AppFailsOnMinimumSeverity.HasValue && - result.Errors.Any(x => x.Severity >= Settings.AppFailsOnMinimumSeverity)) + result.Errors.NewErrors.Any(x => x.Severity >= Settings.AppFailsOnMinimumSeverity)) { Logger?.LogInformation(ModVerifyConstants.ConsoleEventId, "The verification of {Target} completed with findings of the specified failure severity {Severity}", @@ -53,32 +51,18 @@ protected override async Task ProcessResult(VerificationResult result) return ModVerifyConstants.Success; } - protected override VerificationBaseline GetBaseline(VerificationTarget verificationTarget) - { - var baselineSelector = new BaselineSelector(Settings, ServiceProvider); - var baseline = baselineSelector.SelectBaseline(verificationTarget, out var baselinePath); - if (!baseline.IsEmpty) - { - Console.WriteLine(); - ModVerifyConsoleUtilities.WriteBaselineInfo(baseline, baselinePath); - Logger?.LogDebug("Using baseline {Baseline} from location '{Path}'", - baseline.ToString(), baselinePath ?? "Embedded"); - Console.WriteLine(); - } - return baseline; - } - private IReadOnlyCollection CreateReporters() { - var reporters = new List(); - - reporters.Add(IVerificationReporter.CreateConsole(new ConsoleReporterSettings + var reporters = new List { - Verbose = Settings.ReportSettings.Verbose, - MinimumReportSeverity = Settings.VerifierServiceSettings.FailFastSettings.IsFailFast - ? VerificationSeverity.Information - : VerificationSeverity.Error - }, ServiceProvider)); + IVerificationReporter.CreateConsole(new ConsoleReporterSettings + { + Verbose = Settings.ReportSettings.Verbose, + MinimumReportSeverity = Settings.VerifierServiceSettings.FailFastSettings.IsFailFast + ? VerificationSeverity.Information + : VerificationSeverity.Error + }, ServiceProvider) + }; var outputDirectory = Settings.ReportDirectory; reporters.Add(IVerificationReporter.CreateJson(new JsonReporterSettings diff --git a/src/ModVerify.CliApp/ModVerify.CliApp.csproj b/src/ModVerify.CliApp/ModVerify.CliApp.csproj index b1c97477..f814d057 100644 --- a/src/ModVerify.CliApp/ModVerify.CliApp.csproj +++ b/src/ModVerify.CliApp/ModVerify.CliApp.csproj @@ -25,23 +25,32 @@ true + + + $(DefineConstants);LOCAL_DEPLOY + + + - - + + - + - - - - - + + + + + @@ -51,10 +60,10 @@ - + all - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -62,7 +71,7 @@ - + compile runtime; build; native; contentfiles; analyzers; buildtransitive @@ -70,7 +79,7 @@ compile runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/ModVerify.CliApp/ModVerifyAppEnvironment.cs b/src/ModVerify.CliApp/ModVerifyAppEnvironment.cs index 192d97b1..e70cf6a3 100644 --- a/src/ModVerify.CliApp/ModVerifyAppEnvironment.cs +++ b/src/ModVerify.CliApp/ModVerifyAppEnvironment.cs @@ -1,6 +1,7 @@ using System.IO.Abstractions; using System.Reflection; using AnakinRaW.ApplicationBase.Environment; +using AnakinRaW.AppUpdaterFramework.Security; #if !NET using System; using System.IO; @@ -25,12 +26,15 @@ internal sealed class ModVerifyAppEnvironment(Assembly assembly, IFileSystem fil #if NETFRAMEWORK + // The /v2/ path segment is the post-migration update channel: deployed legacy clients + // still fetch from the historical /downloads/ModVerify path (frozen with the migration + // release), while this and every subsequent build pulls from /v2/. public override ICollection UpdateMirrors { get; } = new List { #if DEBUG - new(CreateDebugPath()), + new(CreateDebugPath()), #endif - new($"https://republicatwar.com/downloads/{ModVerifyConstants.ModVerifyToolPath}") + new($"https://republicatwar.com/downloads/{ModVerifyConstants.ModVerifyToolPath}/v2") }; private static string CreateDebugPath() @@ -64,11 +68,9 @@ protected override UpdateConfiguration CreateUpdateConfiguration() DownloadRetryDelay = 500, ValidationPolicy = ValidationPolicy.Required }, - ManifestDownloadConfiguration = new DownloadManagerConfiguration + ManifestDownloadConfiguration = new ManifestDownloadConfiguration { - AllowEmptyFileDownload = false, - DownloadRetryDelay = 500, - ValidationPolicy = ValidationPolicy.Optional + DownloadRetryDelay = 500 }, BranchDownloadConfiguration = new DownloadManagerConfiguration { @@ -82,7 +84,12 @@ protected override UpdateConfiguration CreateUpdateConfiguration() SupportsRestart = true, PassCurrentArgumentsForRestart = true }, - ValidateInstallation = true + ValidateInstallation = true, + ManifestSigningConfiguration = new SigningConfiguration + { + Policy = SignaturePolicy.Required, + SignatureAlgorithm = SignatureAlgorithm.ES256 + } }; } #endif diff --git a/src/ModVerify.CliApp/Program.cs b/src/ModVerify.CliApp/Program.cs index e5316f4f..7a8574c7 100644 --- a/src/ModVerify.CliApp/Program.cs +++ b/src/ModVerify.CliApp/Program.cs @@ -51,6 +51,8 @@ private static Task Main(string[] args) internal class Program : SelfUpdateableAppLifecycle { + private const string EmbeddedTrustCertResource = "AET.ModVerify.App.Resources.Certs.AET-root.cer"; + private static readonly string EngineParserNamespace = typeof(PetroglyphStarWarsGameXmlParser).Namespace!; private static readonly string ParserNamespace = typeof(XmlFileParser<>).Namespace!; private static readonly string ModVerifyRootNameSpace = typeof(Program).Namespace!; @@ -185,6 +187,20 @@ protected override IRegistry CreateRegistry() : new WindowsRegistry(); } + protected override void RegisterTrustedCertificates(IServiceProvider appServices) + { + if (!IsUpdateableApplication) + return; + + string? devCertPath = null; +#if DEBUG || LOCAL_DEPLOY + devCertPath = System.IO.Path.GetFullPath( + System.IO.Path.Combine(AppContext.BaseDirectory, "..", "dev-trust.cer")); +#endif + appServices.GetRequiredService() + .RegisterTrustedCertificates(typeof(Program).Assembly, [EmbeddedTrustCertResource], devCertPath); + } + protected override async Task RunAppAsync(string[] args, IServiceProvider appServiceProvider) { var result = await HandleUpdate(appServiceProvider); diff --git a/src/ModVerify.CliApp/Properties/launchSettings.json b/src/ModVerify.CliApp/Properties/launchSettings.json index 46fa0fc9..986e2a42 100644 --- a/src/ModVerify.CliApp/Properties/launchSettings.json +++ b/src/ModVerify.CliApp/Properties/launchSettings.json @@ -1,12 +1,15 @@ { "profiles": { + "Verify (no args)": { + "commandName": "Project" + }, "Verify": { "commandName": "Project", - "commandLineArgs": "verify --offline" + "commandLineArgs": "verify" }, "Verify (Interactive)": { "commandName": "Project", - "commandLineArgs": "verify -o verifyResults --offline --minFailSeverity Information --searchBaseline" + "commandLineArgs": "verify -o verifyResults --minFailSeverity Information --useDefaultBaseline" }, "Verify (Automatic Target Selection)": { "commandName": "Project", diff --git a/src/ModVerify.CliApp/Reporting/BaselineSelector.cs b/src/ModVerify.CliApp/Reporting/BaselineSelector.cs index 4fff8129..50101588 100644 --- a/src/ModVerify.CliApp/Reporting/BaselineSelector.cs +++ b/src/ModVerify.CliApp/Reporting/BaselineSelector.cs @@ -1,4 +1,4 @@ -using AET.ModVerify.App.Resources.Baselines; +using AET.ModVerify.App.Resources.Baselines; using AET.ModVerify.App.Settings; using AET.ModVerify.Reporting.Baseline; using AnakinRaW.ApplicationBase; @@ -6,65 +6,86 @@ using Microsoft.Extensions.Logging; using PG.StarWarsGame.Engine; using System; -using System.Diagnostics; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text; namespace AET.ModVerify.App.Reporting; -internal sealed class BaselineSelector(AppVerifySettings settings, IServiceProvider services) +internal sealed class BaselineSelector(AppSettingsBase settings, IServiceProvider services) { private readonly ILogger? _logger = services.GetService()?.CreateLogger(typeof(ModVerifyApplication)); private readonly IBaselineFactory _baselineFactory = services.GetRequiredService(); - public VerificationBaseline SelectBaseline(VerificationTarget verificationTarget, out string? usedBaselinePath) + private bool IsCreatingBaseline => settings is AppBaselineSettings; + + public BaselineCollection SelectBaselines(VerificationTarget verificationTarget) { - var baselinePath = settings.ReportSettings.BaselinePath; - if (!string.IsNullOrEmpty(baselinePath)) + var report = settings.ReportSettings; + var collected = new List(); + var seenIdentifiers = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var path in report.BaselinePaths) + { + var entry = LoadExplicitBaseline(path); + if (seenIdentifiers.Add(entry.Identifier)) + collected.Add(entry); + } + + // In interactive mode, offer to discover a baseline near the target when none was supplied. + if (settings.IsInteractive && collected.Count == 0 && TryFindBaselineInteractive(verificationTarget, out var found)) + collected.Add(found); + + // Loading the engine's default baseline is meaningless when creating a baseline for the game itself + // (you'd be subtracting it from itself). Skip it in that case. + var defaultBaselineApplicable = !(IsCreatingBaseline && verificationTarget.IsGame); + + if (report.UseDefaultBaseline) { - try + if (!defaultBaselineApplicable) { - usedBaselinePath = baselinePath; - return _baselineFactory.ParseBaseline(baselinePath); + _logger?.LogWarning(ModVerifyConstants.ConsoleEventId, + "Ignoring --useDefaultBaseline: it does not apply when creating a baseline for the game itself."); } - catch (InvalidBaselineException e) + else if (TryLoadEmbeddedBaseline(verificationTarget.Engine, out var defaultBaseline, out var defaultId)) { - using (ConsoleUtilities.HorizontalLineSeparatedBlock('*')) - { - Console.WriteLine($"The baseline '{baselinePath}' is not a valid baseline file: {e.Message}" + - $"{Environment.NewLine}Please generate a new baseline file or download the latest version." + - $"{Environment.NewLine}"); - } - - // For now, we bubble up this exception because we except users - // to correctly specify their baselines through command line arguments. - throw; + collected.Add(new IdentifiedBaseline(defaultId, defaultBaseline, BaselineSource.EmbeddedDefault)); } } - - if (settings.ReportSettings is { SearchBaselineLocally: false, UseDefaultBaseline: false }) + else if (settings.IsInteractive && defaultBaselineApplicable) { - _logger?.LogDebug(ModVerifyConstants.ConsoleEventId, - "No baseline path specified and local search is not enabled. Using empty baseline."); - usedBaselinePath = null; - return VerificationBaseline.Empty; + // In interactive mode, offer the embedded default independently of any locally + // discovered or explicitly supplied baselines — they're typically stacked. + if (TryPromptForEmbeddedBaseline(verificationTarget.Engine, out var defaultBaseline, out var defaultId)) + collected.Add(new IdentifiedBaseline(defaultId, defaultBaseline, BaselineSource.EmbeddedDefault)); } - if (settings.IsInteractive) - return FindBaselineInteractive(verificationTarget, out usedBaselinePath); - - // If the application is not interactive, we only use a baseline file present in the directory of the verification target. - return FindBaselineNonInteractive(verificationTarget, out usedBaselinePath); + return new BaselineCollection(collected); + } + private IdentifiedBaseline LoadExplicitBaseline(string baselinePath) + { + try + { + return new IdentifiedBaseline(baselinePath, _baselineFactory.ParseBaseline(baselinePath), BaselineSource.File); + } + catch (InvalidBaselineException e) + { + using (ConsoleUtilities.HorizontalLineSeparatedBlock('*')) + { + Console.WriteLine($"The baseline '{baselinePath}' is not a valid baseline file: {e.Message}" + + $"{Environment.NewLine}Please generate a new baseline file or download the latest version." + + $"{Environment.NewLine}"); + } + throw; + } } - private VerificationBaseline FindBaselineInteractive(VerificationTarget verificationTarget, out string? baselinePath) + private bool TryFindBaselineInteractive(VerificationTarget verificationTarget, [NotNullWhen(true)] out IdentifiedBaseline? found) { - // The application is in interactive mode. We apply the following lookup: // 1. Use a baseline found in the directory of the verification target. - // 2. Use a baseline found in the directory ModVerify executable. - // 3. If the verification target is a mod, ask the user to apply the default game's baseline. - // In any case ask the use if they want to use the located baseline file, or they wish to continue using none/empty. + // 2. Use a baseline found in the directory of the ModVerify executable. + // Ask the user if they want to use the located baseline file. _logger?.LogInformation(ModVerifyConstants.ConsoleEventId, "Searching for local baseline files..."); @@ -72,47 +93,48 @@ private VerificationBaseline FindBaselineInteractive(VerificationTarget verifica verificationTarget.Location.TargetPath, b => IsBaselineCompatible(b, verificationTarget), out var baseline, - out baselinePath)) + out var baselinePath)) { if (!_baselineFactory.TryFindBaselineInDirectory( Environment.CurrentDirectory, - b => IsBaselineCompatible(b, verificationTarget), - out baseline, + b => IsBaselineCompatible(b, verificationTarget), + out baseline, out baselinePath)) { Console.ForegroundColor = ConsoleColor.Cyan; Console.WriteLine("No baseline found locally."); Console.ResetColor(); - baselinePath = null; - TryGetDefaultBaseline(verificationTarget.Engine, out baseline); - return baseline ?? VerificationBaseline.Empty; + found = null; + return false; } } - Debug.Assert(baselinePath is not null && baseline is not null); - - return ShouldUseBaseline(baseline, baselinePath) - ? baseline - : VerificationBaseline.Empty; + if (ShouldUseBaseline(baseline, baselinePath)) + { + found = new IdentifiedBaseline(baselinePath, baseline, BaselineSource.File); + return true; + } + found = null; + return false; } - private static bool TryGetDefaultBaseline( - GameEngineType engineType, - [NotNullWhen(true)] out VerificationBaseline? baseline) + private bool TryLoadEmbeddedBaseline(GameEngineType engineType, + [NotNullWhen(true)] out VerificationBaseline? baseline, + [NotNullWhen(true)] out string? identifier) { baseline = null; - if (engineType == GameEngineType.Eaw) - { - // TODO: EAW currently not implemented - return false; - } + identifier = null; - if (!ConsoleUtilities.UserYesNoQuestion($"Do you want to load the default baseline for game engine '{engineType}'?")) + // TODO: EAW currently not implemented + if (engineType == GameEngineType.Eaw) return false; try { baseline = LoadEmbeddedBaseline(engineType); + identifier = MakeDefaultIdentifier(engineType); + _logger?.LogInformation(ModVerifyConstants.ConsoleEventId, + "Applying default embedded baseline for engine '{Engine}'.", engineType); return true; } catch (InvalidBaselineException) @@ -122,6 +144,27 @@ private static bool TryGetDefaultBaseline( } } + private bool TryPromptForEmbeddedBaseline(GameEngineType engineType, + [NotNullWhen(true)] out VerificationBaseline? baseline, + [NotNullWhen(true)] out string? identifier) + { + baseline = null; + identifier = null; + + // TODO: EAW currently not implemented + if (engineType == GameEngineType.Eaw) + return false; + + var question = IsCreatingBaseline + ? $"Apply the default baseline for engine '{engineType}' as a base? Findings already covered by it will be excluded from your new baseline." + : $"Do you want to load the default baseline for game engine '{engineType}'?"; + + if (!ConsoleUtilities.UserYesNoQuestion(question, defaultAnswer: true)) + return false; + + return TryLoadEmbeddedBaseline(engineType, out baseline, out identifier); + } + internal static VerificationBaseline LoadEmbeddedBaseline(GameEngineType engineType) { var baselineFileName = $"baseline-{engineType.ToString().ToLower()}.json"; @@ -131,46 +174,18 @@ internal static VerificationBaseline LoadEmbeddedBaseline(GameEngineType engineT return VerificationBaseline.FromJson(baselineStream); } - private VerificationBaseline FindBaselineNonInteractive(VerificationTarget target, out string? usedPath) - { - if (_baselineFactory.TryFindBaselineInDirectory( - target.Location.TargetPath, - b => IsBaselineCompatible(b, target), - out var baseline, - out usedPath)) - { - _logger?.LogInformation(ModVerifyConstants.ConsoleEventId, "Automatically applying local baseline file '{Path}'.", usedPath); - return baseline; - } - _logger?.LogTrace("No baseline file found in taget path '{TargetPath}'.", target.Location.TargetPath); - usedPath = null; - if (settings.ReportSettings.UseDefaultBaseline) - { - try - { - var defaultBaseline = LoadEmbeddedBaseline(target.Engine); - _logger?.LogInformation(ModVerifyConstants.ConsoleEventId, "Automatically applying default embedded baseline for engine '{Engine}'.", target.Engine); - return defaultBaseline; - } - catch (InvalidBaselineException) - { - throw new InvalidOperationException( - "Invalid baseline packed along ModVerify App. Please reach out to the creators. Thanks!"); - } - } - return VerificationBaseline.Empty; - } - + internal static string MakeDefaultIdentifier(GameEngineType engineType) + => $""; private static bool IsBaselineCompatible(VerificationBaseline baseline, VerificationTarget target) { return baseline.Target?.Engine == target.Engine; } - private static bool ShouldUseBaseline(VerificationBaseline baseline, string baselinePath) + private bool ShouldUseBaseline(VerificationBaseline baseline, string baselinePath) { var sb = new StringBuilder("Found baseline "); - if (baseline.Target is not null) + if (baseline.Target is not null) sb.Append($"for '{baseline.Target.Name}' "); sb.Append($"at '{baselinePath}'."); @@ -178,6 +193,10 @@ private static bool ShouldUseBaseline(VerificationBaseline baseline, string base Console.ForegroundColor = ConsoleColor.Cyan; Console.WriteLine(sb.ToString()); - return ConsoleUtilities.UserYesNoQuestion("Do you want to use it?"); + var question = IsCreatingBaseline + ? "Use it as a base? Findings already covered by it will be excluded from your new baseline." + : "Do you want to use it?"; + Console.ResetColor(); + return ConsoleUtilities.UserYesNoQuestion(question, defaultAnswer: true); } -} \ No newline at end of file +} diff --git a/src/ModVerify.CliApp/Reporting/VerifyConsoleProgressReporter.cs b/src/ModVerify.CliApp/Reporting/VerifyConsoleProgressReporter.cs index 66587ae4..4cff521a 100644 --- a/src/ModVerify.CliApp/Reporting/VerifyConsoleProgressReporter.cs +++ b/src/ModVerify.CliApp/Reporting/VerifyConsoleProgressReporter.cs @@ -61,10 +61,12 @@ protected override void DisposeResources() Console.WriteLine(); } + private const int MaxTicks = 10000; + private ProgressBar EnsureProgressBar() { return LazyInitializer.EnsureInitialized(ref _progressBar, - () => new ProgressBar(100, $"Verifying '{toVerifyName}'", ProgressBarOptions))!; + () => new ProgressBar(MaxTicks, $"Verifying '{toVerifyName}'", ProgressBarOptions))!; } private static int WriteQueuedMessage(ConsoleOutLine arg) diff --git a/src/ModVerify.CliApp/Resources/Baselines/baseline-foc.json b/src/ModVerify.CliApp/Resources/Baselines/baseline-foc.json index fb8cb646..99d339ba 100644 --- a/src/ModVerify.CliApp/Resources/Baselines/baseline-foc.json +++ b/src/ModVerify.CliApp/Resources/Baselines/baseline-foc.json @@ -8,26 +8,15 @@ }, "minSeverity": "Information", "errors": [ - { - "id": "XML04", - "severity": "Warning", - "asset": "Probability", - "message": "Expected integer but got \u002780, 20\u0027. File=\u0027DATA\\XML\\SFXEVENTSWEAPONS.XML #90\u0027", - "context": [ - "Parser: PG.StarWarsGame.Files.XML.Parsers.PetroglyphXmlIntegerParser", - "File: DATA\\XML\\SFXEVENTSWEAPONS.XML", - "Unit_TIE_Fighter_Fire" - ] - }, { "id": "XML10", "severity": "Information", "asset": "Disabled_Darken", - "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8569\u0027", + "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8608\u0027", "context": [ "Parser: PG.StarWarsGame.Engine.Xml.Parsers.CommandBarComponentParser", "File: DATA\\XML\\COMMANDBARCOMPONENTS.XML", - "b_fast_forward" + "b_fast_forward_t" ] }, { @@ -41,45 +30,55 @@ ] }, { - "id": "XML04", - "severity": "Warning", - "asset": "Size", - "message": "Expected double but got value \u002737\u0060\u0027. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #11571\u0027", + "id": "XML10", + "severity": "Information", + "asset": "Disabled_Darken", + "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8569\u0027", "context": [ - "Parser: PG.StarWarsGame.Files.XML.Parsers.PetroglyphXmlFloatParser", + "Parser: PG.StarWarsGame.Engine.Xml.Parsers.CommandBarComponentParser", "File: DATA\\XML\\COMMANDBARCOMPONENTS.XML", - "bm_text_steal" + "b_fast_forward" ] }, { "id": "XML08", "severity": "Information", - "asset": "DATA\\XML\\UNITS_LAND_REBEL_GALLOFREE_HTT.XML", - "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\UNITS_LAND_REBEL_GALLOFREE_HTT.XML #0\u0027", + "asset": "DATA\\XML\\UNITS_HERO_REBEL_ROGUE_SQUADRON.XML", + "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\UNITS_HERO_REBEL_ROGUE_SQUADRON.XML #0\u0027", "context": [ "Parser: PG.StarWarsGame.Engine.Xml.Parsers.GameObjectFileParser", - "File: DATA\\XML\\UNITS_LAND_REBEL_GALLOFREE_HTT.XML" + "File: DATA\\XML\\UNITS_HERO_REBEL_ROGUE_SQUADRON.XML" ] }, { - "id": "XML10", + "id": "XML08", "severity": "Information", - "asset": "Disabled_Darken", - "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8550\u0027", + "asset": "DATA\\XML\\UNITS_SPACE_EMPIRE_TIE_DEFENDER.XML", + "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\UNITS_SPACE_EMPIRE_TIE_DEFENDER.XML #0\u0027", "context": [ - "Parser: PG.StarWarsGame.Engine.Xml.Parsers.CommandBarComponentParser", - "File: DATA\\XML\\COMMANDBARCOMPONENTS.XML", - "b_play_pause" + "Parser: PG.StarWarsGame.Engine.Xml.Parsers.GameObjectFileParser", + "File: DATA\\XML\\UNITS_SPACE_EMPIRE_TIE_DEFENDER.XML" ] }, { "id": "XML08", "severity": "Information", - "asset": "DATA\\XML\\UNITS_HERO_REBEL_ROGUE_SQUADRON.XML", - "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\UNITS_HERO_REBEL_ROGUE_SQUADRON.XML #0\u0027", + "asset": "DATA\\XML\\UNITS_LAND_REBEL_GALLOFREE_HTT.XML", + "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\UNITS_LAND_REBEL_GALLOFREE_HTT.XML #0\u0027", "context": [ "Parser: PG.StarWarsGame.Engine.Xml.Parsers.GameObjectFileParser", - "File: DATA\\XML\\UNITS_HERO_REBEL_ROGUE_SQUADRON.XML" + "File: DATA\\XML\\UNITS_LAND_REBEL_GALLOFREE_HTT.XML" + ] + }, + { + "id": "XML04", + "severity": "Warning", + "asset": "Probability", + "message": "Expected integer but got \u002780, 20\u0027. File=\u0027DATA\\XML\\SFXEVENTSWEAPONS.XML #90\u0027", + "context": [ + "Parser: PG.StarWarsGame.Files.XML.Parsers.PetroglyphXmlIntegerParser", + "File: DATA\\XML\\SFXEVENTSWEAPONS.XML", + "Unit_TIE_Fighter_Fire" ] }, { @@ -105,116 +104,126 @@ { "id": "XML10", "severity": "Information", - "asset": "Disabled_Darken", - "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8608\u0027", + "asset": "Mega_Texture_Name", + "message": "The node \u0027Mega_Texture_Name\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8\u0027", "context": [ "Parser: PG.StarWarsGame.Engine.Xml.Parsers.CommandBarComponentParser", "File: DATA\\XML\\COMMANDBARCOMPONENTS.XML", - "b_fast_forward_t" + "i_main_commandbar" ] }, { "id": "XML10", "severity": "Information", - "asset": "Mega_Texture_Name", - "message": "The node \u0027Mega_Texture_Name\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8\u0027", + "asset": "Disabled_Darken", + "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8589\u0027", "context": [ "Parser: PG.StarWarsGame.Engine.Xml.Parsers.CommandBarComponentParser", "File: DATA\\XML\\COMMANDBARCOMPONENTS.XML", - "i_main_commandbar" + "b_play_pause_t" ] }, { "id": "XML10", "severity": "Information", "asset": "Disabled_Darken", - "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8589\u0027", + "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8550\u0027", "context": [ "Parser: PG.StarWarsGame.Engine.Xml.Parsers.CommandBarComponentParser", "File: DATA\\XML\\COMMANDBARCOMPONENTS.XML", - "b_play_pause_t" + "b_play_pause" ] }, { - "id": "XML08", - "severity": "Information", - "asset": "DATA\\XML\\UNITS_SPACE_EMPIRE_TIE_DEFENDER.XML", - "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\UNITS_SPACE_EMPIRE_TIE_DEFENDER.XML #0\u0027", + "id": "XML04", + "severity": "Warning", + "asset": "Size", + "message": "Expected double but got value \u002737\u0060\u0027. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #11571\u0027", "context": [ - "Parser: PG.StarWarsGame.Engine.Xml.Parsers.GameObjectFileParser", - "File: DATA\\XML\\UNITS_SPACE_EMPIRE_TIE_DEFENDER.XML" + "Parser: PG.StarWarsGame.Files.XML.Parsers.PetroglyphXmlFloatParser", + "File: DATA\\XML\\COMMANDBARCOMPONENTS.XML", + "bm_text_steal" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0315_ENG.WAV", - "message": "Audio file \u0027U000_LEI0315_ENG.WAV\u0027 could not be found.", + "asset": "U000_ARC3106_ENG.WAV", + "message": "Audio file \u0027U000_ARC3106_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Complete_Troops_Arc_Hammer" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0202_ENG.WAV", - "message": "Audio file \u0027U000_LEI0202_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0404_ENG.WAV", + "message": "Audio file \u0027U000_LEI0404_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Guard_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0110_ENG.WAV", - "message": "Audio file \u0027U000_LEI0110_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0309_ENG.WAV", + "message": "Audio file \u0027U000_LEI0309_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0203_ENG.WAV", - "message": "Audio file \u0027U000_LEI0203_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0602_ENG.WAV", + "message": "Audio file \u0027U000_LEI0602_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Fleet_Move_Leia" + "Unit_Increase_Production_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0301_ENG.WAV", - "message": "Audio file \u0027U000_LEI0301_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0304_ENG.WAV", + "message": "Audio file \u0027U000_LEI0304_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0306_ENG.WAV", - "message": "Audio file \u0027U000_LEI0306_ENG.WAV\u0027 could not be found.", + "asset": "U000_ARC3105_ENG.WAV", + "message": "Audio file \u0027U000_ARC3105_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Complete_Troops_Arc_Hammer" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0602_ENG.WAV", - "message": "Audio file \u0027U000_LEI0602_ENG.WAV\u0027 could not be found.", + "asset": "AMB_DES_CLEAR_LOOP_1.WAV", + "message": "Audio file \u0027AMB_DES_CLEAR_LOOP_1.WAV\u0027 could not be found.", "context": [ - "Unit_Increase_Production_Leia" + "Weather_Ambient_Clear_Sandstorm_Loop" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0104_ENG.WAV", - "message": "Audio file \u0027U000_LEI0104_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0502_ENG.WAV", + "message": "Audio file \u0027U000_LEI0502_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Remove_Corruption_Leia" + ] + }, + { + "id": "FILE00", + "severity": "Error", + "asset": "U000_LEI0207_ENG.WAV", + "message": "Audio file \u0027U000_LEI0207_ENG.WAV\u0027 could not be found.", + "context": [ + "Unit_Group_Move_Leia" ] }, { @@ -229,71 +238,80 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_ARC3106_ENG.WAV", - "message": "Audio file \u0027U000_ARC3106_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0314_ENG.WAV", + "message": "Audio file \u0027U000_LEI0314_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Complete_Troops_Arc_Hammer" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0208_ENG.WAV", - "message": "Audio file \u0027U000_LEI0208_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0115_ENG.WAV", + "message": "Audio file \u0027U000_LEI0115_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_DEF3006_ENG.WAV", - "message": "Audio file \u0027U000_DEF3006_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0312_ENG.WAV", + "message": "Audio file \u0027U000_LEI0312_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Corrupt_Sabateur" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0211_ENG.WAV", - "message": "Audio file \u0027U000_LEI0211_ENG.WAV\u0027 could not be found.", + "asset": "U000_MAL0503_ENG.WAV", + "message": "Audio file \u0027U000_MAL0503_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Assist_Move_Missile_Launcher" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0501_ENG.WAV", - "message": "Audio file \u0027U000_LEI0501_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0203_ENG.WAV", + "message": "Audio file \u0027U000_LEI0203_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Remove_Corruption_Leia" + "Unit_Fleet_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0207_ENG.WAV", - "message": "Audio file \u0027U000_LEI0207_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0208_ENG.WAV", + "message": "Audio file \u0027U000_LEI0208_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Group_Move_Leia" ] }, + { + "id": "FILE00", + "severity": "Error", + "asset": "U000_LEI0103_ENG.WAV", + "message": "Audio file \u0027U000_LEI0103_ENG.WAV\u0027 could not be found.", + "context": [ + "Unit_Select_Leia" + ] + }, { "id": "FILE00", "severity": "Error", "asset": "U000_LEI0215_ENG.WAV", "message": "Audio file \u0027U000_LEI0215_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0203_ENG.WAV", - "message": "Audio file \u0027U000_LEI0203_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0212_ENG.WAV", + "message": "Audio file \u0027U000_LEI0212_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Group_Move_Leia" ] @@ -301,82 +319,82 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0402_ENG.WAV", - "message": "Audio file \u0027U000_LEI0402_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0311_ENG.WAV", + "message": "Audio file \u0027U000_LEI0311_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Guard_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0401_ENG.WAV", - "message": "Audio file \u0027U000_LEI0401_ENG.WAV\u0027 could not be found.", + "asset": "U000_DEF3006_ENG.WAV", + "message": "Audio file \u0027U000_DEF3006_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Guard_Leia" + "Unit_Corrupt_Sabateur" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0215_ENG.WAV", - "message": "Audio file \u0027U000_LEI0215_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0206_ENG.WAV", + "message": "Audio file \u0027U000_LEI0206_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Fleet_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "AMB_DES_CLEAR_LOOP_1.WAV", - "message": "Audio file \u0027AMB_DES_CLEAR_LOOP_1.WAV\u0027 could not be found.", + "asset": "U000_LEI0201_ENG.WAV", + "message": "Audio file \u0027U000_LEI0201_ENG.WAV\u0027 could not be found.", "context": [ - "Weather_Ambient_Clear_Sandstorm_Loop" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0202_ENG.WAV", - "message": "Audio file \u0027U000_LEI0202_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0501_ENG.WAV", + "message": "Audio file \u0027U000_LEI0501_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Remove_Corruption_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0115_ENG.WAV", - "message": "Audio file \u0027U000_LEI0115_ENG.WAV\u0027 could not be found.", + "asset": "EGL_STAR_VIPER_SPINNING_1.WAV", + "message": "Audio file \u0027EGL_STAR_VIPER_SPINNING_1.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Star_Viper_Spinning_By" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0303_ENG.WAV", - "message": "Audio file \u0027U000_LEI0303_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0203_ENG.WAV", + "message": "Audio file \u0027U000_LEI0203_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0604_ENG.WAV", - "message": "Audio file \u0027U000_LEI0604_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0203_ENG.WAV", + "message": "Audio file \u0027U000_LEI0203_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Increase_Production_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0312_ENG.WAV", - "message": "Audio file \u0027U000_LEI0312_ENG.WAV\u0027 could not be found.", + "asset": "C000_DST0102_ENG.WAV", + "message": "Audio file \u0027C000_DST0102_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "EHD_Death_Star_Activate" ] }, { @@ -391,8 +409,8 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0308_ENG.WAV", - "message": "Audio file \u0027U000_LEI0308_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0305_ENG.WAV", + "message": "Audio file \u0027U000_LEI0305_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Group_Attack_Leia" ] @@ -400,125 +418,125 @@ { "id": "FILE00", "severity": "Error", - "asset": "FS_BEETLE_3.WAV", - "message": "Audio file \u0027FS_BEETLE_3.WAV\u0027 could not be found.", + "asset": "U000_LEI0311_ENG.WAV", + "message": "Audio file \u0027U000_LEI0311_ENG.WAV\u0027 could not be found.", "context": [ - "SFX_Anim_Beetle_Footsteps" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "FS_BEETLE_4.WAV", - "message": "Audio file \u0027FS_BEETLE_4.WAV\u0027 could not be found.", + "asset": "U000_LEI0108_ENG.WAV", + "message": "Audio file \u0027U000_LEI0108_ENG.WAV\u0027 could not be found.", "context": [ - "SFX_Anim_Beetle_Footsteps" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0208_ENG.WAV", - "message": "Audio file \u0027U000_LEI0208_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0312_ENG.WAV", + "message": "Audio file \u0027U000_LEI0312_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Fleet_Move_Leia" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0101_ENG.WAV", - "message": "Audio file \u0027U000_LEI0101_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0207_ENG.WAV", + "message": "Audio file \u0027U000_LEI0207_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "FS_BEETLE_2.WAV", - "message": "Audio file \u0027FS_BEETLE_2.WAV\u0027 could not be found.", + "asset": "U000_LEI0306_ENG.WAV", + "message": "Audio file \u0027U000_LEI0306_ENG.WAV\u0027 could not be found.", "context": [ - "SFX_Anim_Beetle_Footsteps" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0109_ENG.WAV", - "message": "Audio file \u0027U000_LEI0109_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0206_ENG.WAV", + "message": "Audio file \u0027U000_LEI0206_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0113_ENG.WAV", - "message": "Audio file \u0027U000_LEI0113_ENG.WAV\u0027 could not be found.", + "asset": "TESTUNITMOVE_ENG.WAV", + "message": "Audio file \u0027TESTUNITMOVE_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Move_Gneneric_Test" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0403_ENG.WAV", - "message": "Audio file \u0027U000_LEI0403_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0313_ENG.WAV", + "message": "Audio file \u0027U000_LEI0313_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Guard_Leia" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0206_ENG.WAV", - "message": "Audio file \u0027U000_LEI0206_ENG.WAV\u0027 could not be found.", + "asset": "FS_BEETLE_4.WAV", + "message": "Audio file \u0027FS_BEETLE_4.WAV\u0027 could not be found.", "context": [ - "Unit_Fleet_Move_Leia" + "SFX_Anim_Beetle_Footsteps" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0209_ENG.WAV", - "message": "Audio file \u0027U000_LEI0209_ENG.WAV\u0027 could not be found.", + "asset": "FS_BEETLE_1.WAV", + "message": "Audio file \u0027FS_BEETLE_1.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "SFX_Anim_Beetle_Footsteps" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0312_ENG.WAV", - "message": "Audio file \u0027U000_LEI0312_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0205_ENG.WAV", + "message": "Audio file \u0027U000_LEI0205_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0304_ENG.WAV", - "message": "Audio file \u0027U000_LEI0304_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0102_ENG.WAV", + "message": "Audio file \u0027U000_LEI0102_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_TMC0212_ENG.WAV", - "message": "Audio file \u0027U000_TMC0212_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0202_ENG.WAV", + "message": "Audio file \u0027U000_LEI0202_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Assist_Move_Tie_Mauler" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0315_ENG.WAV", - "message": "Audio file \u0027U000_LEI0315_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0314_ENG.WAV", + "message": "Audio file \u0027U000_LEI0314_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Group_Attack_Leia" ] @@ -526,134 +544,134 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0308_ENG.WAV", - "message": "Audio file \u0027U000_LEI0308_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0206_ENG.WAV", + "message": "Audio file \u0027U000_LEI0206_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0303_ENG.WAV", - "message": "Audio file \u0027U000_LEI0303_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0111_ENG.WAV", + "message": "Audio file \u0027U000_LEI0111_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0111_ENG.WAV", - "message": "Audio file \u0027U000_LEI0111_ENG.WAV\u0027 could not be found.", + "asset": "AMB_URB_CLEAR_LOOP_1.WAV", + "message": "Audio file \u0027AMB_URB_CLEAR_LOOP_1.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Weather_Ambient_Clear_Urban_Loop" ] }, { "id": "FILE00", "severity": "Error", - "asset": "EGL_STAR_VIPER_SPINNING_1.WAV", - "message": "Audio file \u0027EGL_STAR_VIPER_SPINNING_1.WAV\u0027 could not be found.", + "asset": "U000_LEI0315_ENG.WAV", + "message": "Audio file \u0027U000_LEI0315_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Star_Viper_Spinning_By" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0201_ENG.WAV", - "message": "Audio file \u0027U000_LEI0201_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0402_ENG.WAV", + "message": "Audio file \u0027U000_LEI0402_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Guard_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "FS_BEETLE_1.WAV", - "message": "Audio file \u0027FS_BEETLE_1.WAV\u0027 could not be found.", + "asset": "U000_LEI0604_ENG.WAV", + "message": "Audio file \u0027U000_LEI0604_ENG.WAV\u0027 could not be found.", "context": [ - "SFX_Anim_Beetle_Footsteps" + "Unit_Increase_Production_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0313_ENG.WAV", - "message": "Audio file \u0027U000_LEI0313_ENG.WAV\u0027 could not be found.", + "asset": "U000_TMC0212_ENG.WAV", + "message": "Audio file \u0027U000_TMC0212_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Assist_Move_Tie_Mauler" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0305_ENG.WAV", - "message": "Audio file \u0027U000_LEI0305_ENG.WAV\u0027 could not be found.", + "asset": "FS_BEETLE_2.WAV", + "message": "Audio file \u0027FS_BEETLE_2.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "SFX_Anim_Beetle_Footsteps" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0307_ENG.WAV", - "message": "Audio file \u0027U000_LEI0307_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0208_ENG.WAV", + "message": "Audio file \u0027U000_LEI0208_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0313_ENG.WAV", - "message": "Audio file \u0027U000_LEI0313_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0401_ENG.WAV", + "message": "Audio file \u0027U000_LEI0401_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Guard_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0108_ENG.WAV", - "message": "Audio file \u0027U000_LEI0108_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0305_ENG.WAV", + "message": "Audio file \u0027U000_LEI0305_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0503_ENG.WAV", - "message": "Audio file \u0027U000_LEI0503_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0114_ENG.WAV", + "message": "Audio file \u0027U000_LEI0114_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Remove_Corruption_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0205_ENG.WAV", - "message": "Audio file \u0027U000_LEI0205_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0113_ENG.WAV", + "message": "Audio file \u0027U000_LEI0113_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Fleet_Move_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0204_ENG.WAV", - "message": "Audio file \u0027U000_LEI0204_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0104_ENG.WAV", + "message": "Audio file \u0027U000_LEI0104_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0205_ENG.WAV", - "message": "Audio file \u0027U000_LEI0205_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0215_ENG.WAV", + "message": "Audio file \u0027U000_LEI0215_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Move_Leia" ] @@ -661,98 +679,98 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_DEF3106_ENG.WAV", - "message": "Audio file \u0027U000_DEF3106_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0504_ENG.WAV", + "message": "Audio file \u0027U000_LEI0504_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Weaken_Sabateur" + "Unit_Remove_Corruption_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_MAL0503_ENG.WAV", - "message": "Audio file \u0027U000_MAL0503_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0303_ENG.WAV", + "message": "Audio file \u0027U000_LEI0303_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Assist_Move_Missile_Launcher" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0114_ENG.WAV", - "message": "Audio file \u0027U000_LEI0114_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0301_ENG.WAV", + "message": "Audio file \u0027U000_LEI0301_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0504_ENG.WAV", - "message": "Audio file \u0027U000_LEI0504_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0209_ENG.WAV", + "message": "Audio file \u0027U000_LEI0209_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Remove_Corruption_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0211_ENG.WAV", - "message": "Audio file \u0027U000_LEI0211_ENG.WAV\u0027 could not be found.", + "asset": "U000_ARC3104_ENG.WAV", + "message": "Audio file \u0027U000_ARC3104_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Produce_Troops_Arc_Hammer" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0502_ENG.WAV", - "message": "Audio file \u0027U000_LEI0502_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0304_ENG.WAV", + "message": "Audio file \u0027U000_LEI0304_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Remove_Corruption_Leia" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_ARC3104_ENG.WAV", - "message": "Audio file \u0027U000_ARC3104_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0204_ENG.WAV", + "message": "Audio file \u0027U000_LEI0204_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Produce_Troops_Arc_Hammer" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "TESTUNITMOVE_ENG.WAV", - "message": "Audio file \u0027TESTUNITMOVE_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0308_ENG.WAV", + "message": "Audio file \u0027U000_LEI0308_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Gneneric_Test" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0202_ENG.WAV", - "message": "Audio file \u0027U000_LEI0202_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0503_ENG.WAV", + "message": "Audio file \u0027U000_LEI0503_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Fleet_Move_Leia" + "Unit_Remove_Corruption_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0309_ENG.WAV", - "message": "Audio file \u0027U000_LEI0309_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0210_ENG.WAV", + "message": "Audio file \u0027U000_LEI0210_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0210_ENG.WAV", - "message": "Audio file \u0027U000_LEI0210_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0211_ENG.WAV", + "message": "Audio file \u0027U000_LEI0211_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Move_Leia" ] @@ -760,53 +778,53 @@ { "id": "FILE00", "severity": "Error", - "asset": "AMB_URB_CLEAR_LOOP_1.WAV", - "message": "Audio file \u0027AMB_URB_CLEAR_LOOP_1.WAV\u0027 could not be found.", + "asset": "U000_LEI0307_ENG.WAV", + "message": "Audio file \u0027U000_LEI0307_ENG.WAV\u0027 could not be found.", "context": [ - "Weather_Ambient_Clear_Urban_Loop" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0304_ENG.WAV", - "message": "Audio file \u0027U000_LEI0304_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0601_ENG.WAV", + "message": "Audio file \u0027U000_LEI0601_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Increase_Production_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0311_ENG.WAV", - "message": "Audio file \u0027U000_LEI0311_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0109_ENG.WAV", + "message": "Audio file \u0027U000_LEI0109_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "C000_DST0102_ENG.WAV", - "message": "Audio file \u0027C000_DST0102_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0603_ENG.WAV", + "message": "Audio file \u0027U000_LEI0603_ENG.WAV\u0027 could not be found.", "context": [ - "EHD_Death_Star_Activate" + "Unit_Increase_Production_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_MCF1601_ENG.WAV", - "message": "Audio file \u0027U000_MCF1601_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0212_ENG.WAV", + "message": "Audio file \u0027U000_LEI0212_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_StarDest_MC30_Frigate" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0212_ENG.WAV", - "message": "Audio file \u0027U000_LEI0212_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0205_ENG.WAV", + "message": "Audio file \u0027U000_LEI0205_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Move_Leia" ] @@ -814,44 +832,44 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0205_ENG.WAV", - "message": "Audio file \u0027U000_LEI0205_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0107_ENG.WAV", + "message": "Audio file \u0027U000_LEI0107_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0601_ENG.WAV", - "message": "Audio file \u0027U000_LEI0601_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0403_ENG.WAV", + "message": "Audio file \u0027U000_LEI0403_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Increase_Production_Leia" + "Unit_Guard_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0404_ENG.WAV", - "message": "Audio file \u0027U000_LEI0404_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0201_ENG.WAV", + "message": "Audio file \u0027U000_LEI0201_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Guard_Leia" + "Unit_Fleet_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0314_ENG.WAV", - "message": "Audio file \u0027U000_LEI0314_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0201_ENG.WAV", + "message": "Audio file \u0027U000_LEI0201_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0208_ENG.WAV", - "message": "Audio file \u0027U000_LEI0208_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0211_ENG.WAV", + "message": "Audio file \u0027U000_LEI0211_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Group_Move_Leia" ] @@ -859,17 +877,17 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0103_ENG.WAV", - "message": "Audio file \u0027U000_LEI0103_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0309_ENG.WAV", + "message": "Audio file \u0027U000_LEI0309_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0107_ENG.WAV", - "message": "Audio file \u0027U000_LEI0107_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0110_ENG.WAV", + "message": "Audio file \u0027U000_LEI0110_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Select_Leia" ] @@ -877,35 +895,35 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0203_ENG.WAV", - "message": "Audio file \u0027U000_LEI0203_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0306_ENG.WAV", + "message": "Audio file \u0027U000_LEI0306_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0603_ENG.WAV", - "message": "Audio file \u0027U000_LEI0603_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0202_ENG.WAV", + "message": "Audio file \u0027U000_LEI0202_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Increase_Production_Leia" + "Unit_Fleet_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0206_ENG.WAV", - "message": "Audio file \u0027U000_LEI0206_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0106_ENG.WAV", + "message": "Audio file \u0027U000_LEI0106_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0112_ENG.WAV", - "message": "Audio file \u0027U000_LEI0112_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0105_ENG.WAV", + "message": "Audio file \u0027U000_LEI0105_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Select_Leia" ] @@ -913,8 +931,8 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0209_ENG.WAV", - "message": "Audio file \u0027U000_LEI0209_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0202_ENG.WAV", + "message": "Audio file \u0027U000_LEI0202_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Move_Leia" ] @@ -924,6 +942,15 @@ "severity": "Error", "asset": "U000_LEI0307_ENG.WAV", "message": "Audio file \u0027U000_LEI0307_ENG.WAV\u0027 could not be found.", + "context": [ + "Unit_Group_Attack_Leia" + ] + }, + { + "id": "FILE00", + "severity": "Error", + "asset": "U000_LEI0313_ENG.WAV", + "message": "Audio file \u0027U000_LEI0313_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Attack_Leia" ] @@ -931,17 +958,17 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0201_ENG.WAV", - "message": "Audio file \u0027U000_LEI0201_ENG.WAV\u0027 could not be found.", + "asset": "U000_MCF1601_ENG.WAV", + "message": "Audio file \u0027U000_MCF1601_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Fleet_Move_Leia" + "Unit_StarDest_MC30_Frigate" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0311_ENG.WAV", - "message": "Audio file \u0027U000_LEI0311_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0303_ENG.WAV", + "message": "Audio file \u0027U000_LEI0303_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Attack_Leia" ] @@ -949,19 +976,19 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0301_ENG.WAV", - "message": "Audio file \u0027U000_LEI0301_ENG.WAV\u0027 could not be found.", + "asset": "U000_DEF3106_ENG.WAV", + "message": "Audio file \u0027U000_DEF3106_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Weaken_Sabateur" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0213_ENG.WAV", - "message": "Audio file \u0027U000_LEI0213_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0112_ENG.WAV", + "message": "Audio file \u0027U000_LEI0112_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Select_Leia" ] }, { @@ -976,64 +1003,64 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0309_ENG.WAV", - "message": "Audio file \u0027U000_LEI0309_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0213_ENG.WAV", + "message": "Audio file \u0027U000_LEI0213_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0207_ENG.WAV", - "message": "Audio file \u0027U000_LEI0207_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0315_ENG.WAV", + "message": "Audio file \u0027U000_LEI0315_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_ARC3105_ENG.WAV", - "message": "Audio file \u0027U000_ARC3105_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0213_ENG.WAV", + "message": "Audio file \u0027U000_LEI0213_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Complete_Troops_Arc_Hammer" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0212_ENG.WAV", - "message": "Audio file \u0027U000_LEI0212_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0101_ENG.WAV", + "message": "Audio file \u0027U000_LEI0101_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0102_ENG.WAV", - "message": "Audio file \u0027U000_LEI0102_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0208_ENG.WAV", + "message": "Audio file \u0027U000_LEI0208_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Fleet_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0306_ENG.WAV", - "message": "Audio file \u0027U000_LEI0306_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0209_ENG.WAV", + "message": "Audio file \u0027U000_LEI0209_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0206_ENG.WAV", - "message": "Audio file \u0027U000_LEI0206_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0205_ENG.WAV", + "message": "Audio file \u0027U000_LEI0205_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Fleet_Move_Leia" ] }, { @@ -1048,55 +1075,55 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0314_ENG.WAV", - "message": "Audio file \u0027U000_LEI0314_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0308_ENG.WAV", + "message": "Audio file \u0027U000_LEI0308_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0105_ENG.WAV", - "message": "Audio file \u0027U000_LEI0105_ENG.WAV\u0027 could not be found.", + "asset": "FS_BEETLE_3.WAV", + "message": "Audio file \u0027FS_BEETLE_3.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "SFX_Anim_Beetle_Footsteps" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0106_ENG.WAV", - "message": "Audio file \u0027U000_LEI0106_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0301_ENG.WAV", + "message": "Audio file \u0027U000_LEI0301_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0213_ENG.WAV", - "message": "Audio file \u0027U000_LEI0213_ENG.WAV\u0027 could not be found.", + "asset": "underworld_logo_off.tga", + "message": "Could not find GUI texture \u0027underworld_logo_off.tga\u0027 of type \u0027ButtonMiddle\u0027 at origin \u0027MegaTexture\u0027 for component \u0027IDC_PLAY_FACTION_A_BUTTON_BIG\u0027.", "context": [ - "Unit_Group_Move_Leia" + "IDC_PLAY_FACTION_A_BUTTON_BIG" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0201_ENG.WAV", - "message": "Audio file \u0027U000_LEI0201_ENG.WAV\u0027 could not be found.", + "asset": "i_dialogue_button_large_middle_off.tga", + "message": "Could not find GUI texture \u0027i_dialogue_button_large_middle_off.tga\u0027 of type \u0027ButtonMiddleDisabled\u0027 at origin \u0027Repository\u0027 for component \u0027IDC_PLAY_FACTION_B_BUTTON_BIG\u0027.", "context": [ - "Unit_Move_Leia" + "IDC_PLAY_FACTION_B_BUTTON_BIG" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0305_ENG.WAV", - "message": "Audio file \u0027U000_LEI0305_ENG.WAV\u0027 could not be found.", + "asset": "underworld_logo_selected.tga", + "message": "Could not find GUI texture \u0027underworld_logo_selected.tga\u0027 of type \u0027ButtonMiddlePressed\u0027 at origin \u0027MegaTexture\u0027 for component \u0027IDC_PLAY_FACTION_A_BUTTON_BIG\u0027.", "context": [ - "Unit_Attack_Leia" + "IDC_PLAY_FACTION_A_BUTTON_BIG" ] }, { @@ -1111,226 +1138,260 @@ { "id": "FILE00", "severity": "Error", - "asset": "i_dialogue_button_large_middle_off.tga", - "message": "Could not find GUI texture \u0027i_dialogue_button_large_middle_off.tga\u0027 of type \u0027ButtonMiddleDisabled\u0027 at origin \u0027Repository\u0027 for component \u0027IDC_PLAY_FACTION_B_BUTTON_BIG\u0027.", + "asset": "CIN_NAVYTROOPER_ROW.ALO", + "message": "Unable to find Alamo file \u0027CIN_NavyTrooper_Row.alo\u0027", "context": [ - "IDC_PLAY_FACTION_B_BUTTON_BIG" + "Cin_NavyTrooper_Row", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "underworld_logo_off.tga", - "message": "Could not find GUI texture \u0027underworld_logo_off.tga\u0027 of type \u0027ButtonMiddle\u0027 at origin \u0027MegaTexture\u0027 for component \u0027IDC_PLAY_FACTION_A_BUTTON_BIG\u0027.", + "asset": "CIN_DEATHSTAR_HIGH.ALO", + "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", "context": [ - "IDC_PLAY_FACTION_A_BUTTON_BIG" + "UM05_PROP_DSTAR", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "underworld_logo_selected.tga", - "message": "Could not find GUI texture \u0027underworld_logo_selected.tga\u0027 of type \u0027ButtonMiddlePressed\u0027 at origin \u0027MegaTexture\u0027 for component \u0027IDC_PLAY_FACTION_A_BUTTON_BIG\u0027.", + "asset": "p_prison_light", + "message": "Proxy particle \u0027p_prison_light\u0027 not found for model \u0027NB_PRISON.ALO\u0027", "context": [ - "IDC_PLAY_FACTION_A_BUTTON_BIG" + "Imperial_Prison_Facility", + "Tag: Land_Model_Name", + "NB_PRISON.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_SHUTTLE_TYDERIUM.ALO", - "message": "Unable to find Alamo file \u0027Cin_Shuttle_Tyderium.alo\u0027", + "asset": "P_heat_small01", + "message": "Proxy particle \u0027P_heat_small01\u0027 not found for model \u0027NB_VCH.ALO\u0027", "context": [ - "Intro2_Shuttle_Tyderium", - "Tag: Space_Model_Name" + "Volcanic_Civilian_Spawn_House_Independent_AI", + "Tag: Land_Model_Name", + "NB_VCH.ALO" ] }, { "id": "FILE00", "severity": "Error", "asset": "Default.fx", - "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027UV_SKIPRAY.ALO\u0027.", + "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027EV_TIE_LANCET.ALO\u0027.", "context": [ - "Skipray_Bombing_Run", + "Lancet_Air_Artillery", "Tag: Land_Model_Name", - "UV_SKIPRAY.ALO" + "EV_TIE_LANCET.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_desert_ground_dust", - "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027UI_IG88.ALO\u0027", + "asset": "Lensflare0", + "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_LOW.ALO\u0027", "context": [ - "IG-88", - "Tag: Land_Model_Name", - "UI_IG88.ALO" + "Stars_Low", + "Tag: Space_Model_Name", + "W_STARS_LOW.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_EV_STARDESTROYER_WARP.ALO", - "message": "Unable to find Alamo file \u0027Cin_EV_Stardestroyer_Warp.alo\u0027", + "asset": "W_SITH_ARCH.ALO", + "message": "Unable to find Alamo file \u0027w_sith_arch.alo\u0027", "context": [ - "Star_Destroyer_Warp", - "Tag: Space_Model_Name" + "Cin_sith_arch", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_RBEL_GREYGROUP.ALO", - "message": "Unable to find Alamo file \u0027CIN_Rbel_GreyGroup.alo\u0027", + "asset": "CIN_DSTAR_PROTONS.ALO", + "message": "Unable to find Alamo file \u0027Cin_DStar_protons.alo\u0027", "context": [ - "Cin_Rebel_GreyGroup", - "Tag: Land_Model_Name" + "Protons_DStar_Xplode", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_desert_ground_dust", - "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027RI_KYLEKATARN.ALO\u0027", + "asset": "CIN_RBEL_SOLDIER.ALO", + "message": "Unable to find Alamo file \u0027CIN_Rbel_Soldier.alo\u0027", "context": [ - "Kyle_Katarn", - "Tag: Land_Model_Name", - "RI_KYLEKATARN.ALO" + "Cin_Rebel_soldier", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DSTAR_TURRETLASERS.ALO", - "message": "Unable to find Alamo file \u0027Cin_DStar_TurretLasers.alo\u0027", + "asset": "P_mptl-2a_Die", + "message": "Proxy particle \u0027P_mptl-2a_Die\u0027 not found for model \u0027RV_MPTL-2A.ALO\u0027", "context": [ - "TurretLasers_DStar_Xplode", - "Tag: Space_Model_Name" + "MPTL", + "Tag: Land_Model_Name", + "RV_MPTL-2A.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_EI_PALPATINE.ALO", - "message": "Unable to find Alamo file \u0027Cin_EI_Palpatine.alo\u0027", + "asset": "CIN_EI_VADER.ALO", + "message": "Unable to find Alamo file \u0027Cin_EI_Vader.alo\u0027", "context": [ - "Cin_Emperor_Shot_5", + "Cin_Vader", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_BRIDGE.ALO", - "message": "Unable to find Alamo file \u0027Cin_bridge.alo\u0027", + "asset": "W_KAMINO_REFLECT.ALO", + "message": "Unable to find Alamo file \u0027W_Kamino_Reflect.ALO\u0027", "context": [ - "Imperial_Bridge", - "Tag: Space_Model_Name" + "Prop_Kamino_Reflection_01", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_P_PROTON_TORPEDO.ALO", - "message": "Unable to find Alamo file \u0027CIN_p_proton_torpedo.alo\u0027", + "asset": "p_uwstation_death", + "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_03_STATION_D.ALO\u0027", "context": [ - "Cin_Proj_Ground_Proton_Torpedo", + "Underworld_Star_Base_3_Death_Clone", + "Tag: Space_Model_Name", + "UB_03_STATION_D.ALO" + ] + }, + { + "id": "FILE00", + "severity": "Error", + "asset": "Cin_Reb_CelebHall_Wall_B.tga", + "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall_B.tga\u0027 for context: [Cin_w_tile--\u003ETag: Land_Model_Name--\u003EW_TILE.ALO].", + "context": [ + "Cin_w_tile", + "Tag: Land_Model_Name", + "W_TILE.ALO" + ] + }, + { + "id": "FILE00", + "severity": "Error", + "asset": "W_VOL_STEAM01.ALO", + "message": "Unable to find Alamo file \u0027W_Vol_Steam01.ALO\u0027", + "context": [ + "Prop_Vol_Steam01", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_hp_archammer-damage", - "message": "Proxy particle \u0027p_hp_archammer-damage\u0027 not found for model \u0027EV_ARCHAMMER.ALO\u0027", + "asset": "CIN_REB_CELEBCHARACTERS.ALO", + "message": "Unable to find Alamo file \u0027CIN_REb_CelebCharacters.alo\u0027", "context": [ - "Arc_Hammer", - "Tag: Space_Model_Name", - "EV_ARCHAMMER.ALO" + "REb_CelebCharacters", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_cold_tiny01", - "message": "Proxy particle \u0027p_cold_tiny01\u0027 not found for model \u0027NB_SCH.ALO\u0027", + "asset": "CIN_DEATHSTAR_HIGH.ALO", + "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", "context": [ - "Arctic_Civilian_Spawn_House", - "Tag: Land_Model_Name", - "NB_SCH.ALO" + "Death_Star_Whole", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Cin_Reb_CelebHall_Wall_B.tga", - "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall_B.tga\u0027 for context: [Cin_sith_console--\u003ETag: Land_Model_Name--\u003EW_SITH_CONSOLE.ALO].", + "asset": "p_desert_ground_dust", + "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027EI_MARAJADE.ALO\u0027", "context": [ - "Cin_sith_console", + "Mara_Jade", "Tag: Land_Model_Name", - "W_SITH_CONSOLE.ALO" + "EI_MARAJADE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "P_heat_small01", - "message": "Proxy particle \u0027P_heat_small01\u0027 not found for model \u0027NB_VCH.ALO\u0027", + "asset": "CIN_EI_PALPATINE.ALO", + "message": "Unable to find Alamo file \u0027Cin_EI_Palpatine.alo\u0027", "context": [ - "Volcanic_Civilian_Spawn_House_Independent_AI", - "Tag: Land_Model_Name", - "NB_VCH.ALO" + "Cin_Emperor_Shot_6-9", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_IMPERIALCRAFT.ALO", - "message": "Unable to find Alamo file \u0027Cin_ImperialCraft.alo\u0027", + "asset": "CIN_DEATHSTAR_WALL.ALO", + "message": "Unable to find Alamo file \u0027Cin_DeathStar_Wall.alo\u0027", "context": [ - "Intro2_ImperialCraft", + "Death_Star_Hangar_Outside", "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Cin_Reb_CelebHall_Wall.tga", - "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall.tga\u0027 for context: [Cin_sith_console--\u003ETag: Land_Model_Name--\u003EW_SITH_CONSOLE.ALO].", + "asset": "CIN_PLANET_ALDERAAN_HIGH.ALO", + "message": "Unable to find Alamo file \u0027Cin_Planet_Alderaan_High.alo\u0027", "context": [ - "Cin_sith_console", - "Tag: Land_Model_Name", - "W_SITH_CONSOLE.ALO" + "Alderaan_Backdrop_Large 6x", + "Tag: Space_Model_Name" + ] + }, + { + "id": "FILE00", + "severity": "Warning", + "asset": "i_button_ni_nightsister_ranger.tga", + "message": "Could not find icon \u0027i_button_ni_nightsister_ranger.tga\u0027 for game object type \u0027Dathomir_Night_Sister\u0027.", + "context": [ + "Dathomir_Night_Sister" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DSTAR_DISH_CLOSE.ALO", - "message": "Unable to find Alamo file \u0027Cin_DStar_Dish_close.alo\u0027", + "asset": "p_desert_ground_dust", + "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027RI_KYLEKATARN.ALO\u0027", "context": [ - "Death_Star_Dish_Close", - "Tag: Space_Model_Name" + "Kyle_Katarn", + "Tag: Land_Model_Name", + "RI_KYLEKATARN.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_BRIDGE.ALO", - "message": "Unable to find Alamo file \u0027Cin_bridge.alo\u0027", + "asset": "W_PLANET_VOLCANIC.ALO", + "message": "Unable to find Alamo file \u0027w_planet_volcanic.alo\u0027", "context": [ - "UM05_PROP_BRIDGE", - "Tag: Land_Model_Name" + "Volcanic_Backdrop_Large", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_REB_CELEBCHARACTERS.ALO", - "message": "Unable to find Alamo file \u0027CIN_REb_CelebCharacters.alo\u0027", + "asset": "p_uwstation_death", + "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_01_STATION_D.ALO\u0027", "context": [ - "REb_CelebCharacters", - "Tag: Land_Model_Name" + "Underworld_Star_Base_1_Death_Clone", + "Tag: Space_Model_Name", + "UB_01_STATION_D.ALO" ] }, { @@ -1347,34 +1408,32 @@ { "id": "FILE00", "severity": "Error", - "asset": "p_ewok_drag_dirt", - "message": "Proxy particle \u0027p_ewok_drag_dirt\u0027 not found for model \u0027UI_EWOK_HANDLER.ALO\u0027", + "asset": "CIN_REB_CELEBHALL.ALO", + "message": "Unable to find Alamo file \u0027CIN_Reb_CelebHall.alo\u0027", "context": [ - "Ewok_Handler", - "Tag: Land_Model_Name", - "UI_EWOK_HANDLER.ALO" + "REb_CelebHall", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_explosion_small_delay00", - "message": "Proxy particle \u0027p_explosion_small_delay00\u0027 not found for model \u0027EB_COMMANDCENTER.ALO\u0027", + "asset": "Cin_DeathStar.tga", + "message": "Could not find texture \u0027Cin_DeathStar.tga\u0027 for context: [Test_Base_Hector--\u003ETag: Land_Model_Name--\u003EALTTEST.ALO].", "context": [ - "Imperial_Command_Center", + "Test_Base_Hector", "Tag: Land_Model_Name", - "EB_COMMANDCENTER.ALO" + "ALTTEST.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "UB_girder_B.tga", - "message": "Could not find texture \u0027UB_girder_B.tga\u0027 for context: [Underworld_Ysalamiri_Cage--\u003ETag: Land_Model_Name--\u003EUV_MDU_CAGE.ALO].", + "asset": "P_SPLASH_WAKE_LAVA.ALO", + "message": "Unable to find Alamo file \u0027p_splash_wake_lava.alo\u0027", "context": [ - "Underworld_Ysalamiri_Cage", - "Tag: Land_Model_Name", - "UV_MDU_CAGE.ALO" + "Splash_Wake_Lava", + "Tag: Land_Model_Name" ] }, { @@ -1391,149 +1450,125 @@ { "id": "FILE00", "severity": "Error", - "asset": "p_bomb_spin", - "message": "Proxy particle \u0027p_bomb_spin\u0027 not found for model \u0027W_THERMAL_DETONATOR_EMPIRE.ALO\u0027", + "asset": "CIN_PROBE_DROID.ALO", + "message": "Unable to find Alamo file \u0027CIN_Probe_Droid.alo\u0027", "context": [ - "TIE_Bomber_Bombing_Run_Bomb", - "Tag: Land_Model_Name", - "W_THERMAL_DETONATOR_EMPIRE.ALO" + "Empire_Droid", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_OFFICER_ROW.ALO", - "message": "Unable to find Alamo file \u0027CIN_Officer_Row.alo\u0027", + "asset": "CIN_TROOPER_ROW.ALO", + "message": "Unable to find Alamo file \u0027CIN_Trooper_Row.alo\u0027", "context": [ - "Cin_Officer_Row", + "Cin_Trooper_Row", "Tag: Land_Model_Name" ] }, { - "id": "FILE00", - "severity": "Error", - "asset": "W_TE_Rock_f_02_b.tga", - "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [Vengeance_Frigate--\u003ETag: Space_Model_Name--\u003EUV_VENGEANCE.ALO].", + "id": "FILE03", + "severity": "Information", + "asset": "MOV_EMPIRE_INTRO_SHUTTLE_FIRE_DIE_00.ALA", + "message": "Possible file CRC32 collision: \u0027MOV_EMPIRE_INTRO_SHUTTLE_FIRE_DIE_00.ALA\u0027 was requested but \u0027U000_EMP0212_ENG.WAV\u0027 was found by the engine.", "context": [ - "Vengeance_Frigate", - "Tag: Space_Model_Name", - "UV_VENGEANCE.ALO" + "Shuttle_Tyderium_Lua_Cinematic", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_RBEL_GREY.ALO", - "message": "Unable to find Alamo file \u0027CIN_Rbel_grey.alo\u0027", + "asset": "p_desert_ground_dust", + "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027UI_SABOTEUR.ALO\u0027", "context": [ - "Cin_Rebel_Grey", - "Tag: Land_Model_Name" + "Underworld_Saboteur", + "Tag: Land_Model_Name", + "UI_SABOTEUR.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Cin_Reb_CelebHall_Wall.tga", - "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall.tga\u0027 for context: [Cin_w_tile--\u003ETag: Land_Model_Name--\u003EW_TILE.ALO].", + "asset": "Default.fx", + "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027EV_MDU_SENSORNODE.ALO\u0027.", "context": [ - "Cin_w_tile", + "Empire_Offensive_Sensor_Node", "Tag: Land_Model_Name", - "W_TILE.ALO" + "EV_MDU_SENSORNODE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_uwstation_death", - "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_01_STATION_D.ALO\u0027", + "asset": "W_ALLSHADERS.ALO", + "message": "Unable to find Alamo file \u0027W_AllShaders.ALO\u0027", "context": [ - "Underworld_Star_Base_1_Death_Clone", - "Tag: Space_Model_Name", - "UB_01_STATION_D.ALO" + "Prop_AllShaders", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_LAMBDA_HEAD.ALO", - "message": "Unable to find Alamo file \u0027CIN_Lambda_Head.alo\u0027", + "asset": "CIN_FIRE_HUGE.ALO", + "message": "Unable to find Alamo file \u0027CIN_Fire_Huge.alo\u0027", "context": [ - "Cin_Lambda_Head", + "Fin_Fire_Huge", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_smoke_small_thin2", - "message": "Proxy particle \u0027p_smoke_small_thin2\u0027 not found for model \u0027NB_MONCAL_BUILDING.ALO\u0027", + "asset": "p_explosion_smoke_small_thin5", + "message": "Proxy particle \u0027p_explosion_smoke_small_thin5\u0027 not found for model \u0027NB_NOGHRI_HUT.ALO\u0027", "context": [ - "MonCalamari_Spawn_House", + "Noghri_Spawn_House", "Tag: Land_Model_Name", - "NB_MONCAL_BUILDING.ALO" + "NB_NOGHRI_HUT.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_ssd_debris", - "message": "Proxy particle \u0027p_ssd_debris\u0027 not found for model \u0027UV_ECLIPSE_UC_DC.ALO\u0027", + "asset": "CIN_IMPERIALCRAFT.ALO", + "message": "Unable to find Alamo file \u0027Cin_ImperialCraft.alo\u0027", "context": [ - "Eclipse_Super_Star_Destroyer_Death_Clone", - "Tag: Space_Model_Name", - "UV_ECLIPSE_UC_DC.ALO" + "Intro2_ImperialCraft", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_smoke_small_thin2", - "message": "Proxy particle \u0027p_smoke_small_thin2\u0027 not found for model \u0027RB_HYPERVELOCITYGUN.ALO\u0027", + "asset": "CIN_DSTAR_TURRETLASERS.ALO", + "message": "Unable to find Alamo file \u0027Cin_DStar_TurretLasers.alo\u0027", "context": [ - "Ground_Empire_Hypervelocity_Gun", - "Tag: Land_Model_Name", - "RB_HYPERVELOCITYGUN.ALO" + "TurretLasers_DStar_Xplode", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_EV_TIEADVANCED.ALO", - "message": "Unable to find Alamo file \u0027Cin_EV_TieAdvanced.alo\u0027", + "asset": "CIN_FIRE_MEDIUM.ALO", + "message": "Unable to find Alamo file \u0027CIN_Fire_Medium.alo\u0027", "context": [ - "Fin_Vader_TIE", - "Tag: Space_Model_Name" + "Fin_Fire_Medium", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "pe_bwing_yellow", - "message": "Proxy particle \u0027pe_bwing_yellow\u0027 not found for model \u0027RV_BWING.ALO\u0027", - "context": [ - "B-Wing", - "Tag: Space_Model_Name", - "RV_BWING.ALO" - ] - }, - { - "id": "FILE03", - "severity": "Information", - "asset": "MOV_EMPIRE_INTRO_SHUTTLE.ALO", - "message": "Possible file CRC32 collision: \u0027MOV_Empire_Intro_Shuttle.ALO\u0027 was requested but \u0027U000_EMP0212_ENG.WAV\u0027 was found by the engine.", - "context": [ - "Shuttle_Tyderium_Lua_Cinematic", - "Tag: Land_Model_Name" - ] - }, - { - "id": "FILE00", - "severity": "Error", - "asset": "CIN_NAVYTROOPER_ROW.ALO", - "message": "Unable to find Alamo file \u0027CIN_NavyTrooper_Row.alo\u0027", + "asset": "p_ewok_drag_dirt", + "message": "Proxy particle \u0027p_ewok_drag_dirt\u0027 not found for model \u0027UI_EWOK_HANDLER.ALO\u0027", "context": [ - "Cin_NavyTrooper_Row", - "Tag: Land_Model_Name" + "Ewok_Handler", + "Tag: Land_Model_Name", + "UI_EWOK_HANDLER.ALO" ] }, { @@ -1550,203 +1585,210 @@ { "id": "FILE00", "severity": "Error", - "asset": "p_desert_ground_dust", - "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027EI_MARAJADE.ALO\u0027", + "asset": "Cin_Reb_CelebHall_Wall.tga", + "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall.tga\u0027 for context: [Cin_sith_console--\u003ETag: Land_Model_Name--\u003EW_SITH_CONSOLE.ALO].", "context": [ - "Mara_Jade", + "Cin_sith_console", "Tag: Land_Model_Name", - "EI_MARAJADE.ALO" + "W_SITH_CONSOLE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_LAMBDA_MOUTH.ALO", - "message": "Unable to find Alamo file \u0027CIN_Lambda_Mouth.alo\u0027", + "asset": "CIN_OFFICER_ROW.ALO", + "message": "Unable to find Alamo file \u0027CIN_Officer_Row.alo\u0027", "context": [ - "Cin_Lambda_Mouth", + "Cin_Officer_Row", "Tag: Land_Model_Name" ] }, { "id": "FILE00", - "severity": "Warning", - "asset": "i_button_general_dodonna.tga", - "message": "Could not find icon \u0027i_button_general_dodonna.tga\u0027 for game object type \u0027General_Dodonna\u0027.", + "severity": "Error", + "asset": "Lensflare0", + "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_CINE.ALO\u0027", "context": [ - "General_Dodonna" + "Stars_Cinematic", + "Tag: Space_Model_Name", + "W_STARS_CINE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Lensflare0", - "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_MEDIUM.ALO\u0027", + "asset": "W_TE_Rock_f_02_b.tga", + "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [Vengeance_Frigate--\u003ETag: Space_Model_Name--\u003EUV_VENGEANCE.ALO].", "context": [ - "Stars_Medium", + "Vengeance_Frigate", "Tag: Space_Model_Name", - "W_STARS_MEDIUM.ALO" + "UV_VENGEANCE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Cin_Reb_CelebHall_Wall.tga", - "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall.tga\u0027 for context: [Cin_sith_lefthall--\u003ETag: Land_Model_Name--\u003EW_SITH_LEFTHALL.ALO].", + "asset": "p_uwstation_death", + "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_05_STATION_D.ALO\u0027", "context": [ - "Cin_sith_lefthall", - "Tag: Land_Model_Name", - "W_SITH_LEFTHALL.ALO" + "Underworld_Star_Base_5_Death_Clone", + "Tag: Space_Model_Name", + "UB_05_STATION_D.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_DROID_STEAM.ALO", - "message": "Unable to find Alamo file \u0027W_droid_steam.alo\u0027", + "asset": "p_explosion_small_delay00", + "message": "Proxy particle \u0027p_explosion_small_delay00\u0027 not found for model \u0027EB_COMMANDCENTER.ALO\u0027", "context": [ - "Prop_Droid_Steam", - "Tag: Land_Model_Name" + "Imperial_Command_Center", + "Tag: Land_Model_Name", + "EB_COMMANDCENTER.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_REB_CELEBHALL.ALO", - "message": "Unable to find Alamo file \u0027CIN_Reb_CelebHall.alo\u0027", + "asset": "p_steam_small", + "message": "Proxy particle \u0027p_steam_small\u0027 not found for model \u0027RB_HEAVYVEHICLEFACTORY.ALO\u0027", "context": [ - "REb_CelebHall", - "Tag: Land_Model_Name" + "R_Ground_Heavy_Vehicle_Factory", + "Tag: Land_Model_Name", + "RB_HEAVYVEHICLEFACTORY.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_EI_PALPATINE.ALO", - "message": "Unable to find Alamo file \u0027Cin_EI_Palpatine.alo\u0027", + "asset": "CIN_RBEL_NAVYROW.ALO", + "message": "Unable to find Alamo file \u0027CIN_Rbel_NavyRow.alo\u0027", "context": [ - "Cin_Emperor_Shot_6-9", + "Cin_Rebel_NavyRow", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_uwstation_death", - "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_05_STATION_D.ALO\u0027", + "asset": "CIN_LAMBDA_MOUTH.ALO", + "message": "Unable to find Alamo file \u0027CIN_Lambda_Mouth.alo\u0027", "context": [ - "Underworld_Star_Base_5_Death_Clone", - "Tag: Space_Model_Name", - "UB_05_STATION_D.ALO" + "Cin_Lambda_Mouth", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_TROOPER_ROW.ALO", - "message": "Unable to find Alamo file \u0027CIN_Trooper_Row.alo\u0027", + "asset": "CIN_RBEL_SOLDIER_GROUP.ALO", + "message": "Unable to find Alamo file \u0027CIN_Rbel_Soldier_Group.alo\u0027", "context": [ - "Cin_Trooper_Row", + "Cin_Rebel_SoldierRow", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DEATHSTAR_WALL.ALO", - "message": "Unable to find Alamo file \u0027Cin_DeathStar_Wall.alo\u0027", + "asset": "Cin_Reb_CelebHall_Wall_B.tga", + "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall_B.tga\u0027 for context: [Cin_sith_lefthall--\u003ETag: Land_Model_Name--\u003EW_SITH_LEFTHALL.ALO].", "context": [ - "Death_Star_Hangar_Outside", - "Tag: Space_Model_Name" + "Cin_sith_lefthall", + "Tag: Land_Model_Name", + "W_SITH_LEFTHALL.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_prison_light", - "message": "Proxy particle \u0027p_prison_light\u0027 not found for model \u0027NB_PRISON.ALO\u0027", + "asset": "CIN_EI_VADER.ALO", + "message": "Unable to find Alamo file \u0027Cin_EI_Vader.alo\u0027", "context": [ - "Imperial_Prison_Facility", - "Tag: Land_Model_Name", - "NB_PRISON.ALO" + "Cin_Vader_Shot_6-9", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", - "severity": "Warning", - "asset": "i_button_ni_nightsister_ranger.tga", - "message": "Could not find icon \u0027i_button_ni_nightsister_ranger.tga\u0027 for game object type \u0027Dathomir_Night_Sister\u0027.", + "severity": "Error", + "asset": "CIN_DSTAR_LEVERPANEL.ALO", + "message": "Unable to find Alamo file \u0027Cin_DStar_LeverPanel.alo\u0027", "context": [ - "Dathomir_Night_Sister" + "Death_Star_LeverPanel", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DEATHSTAR_HIGH.ALO", - "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", + "asset": "Default.fx", + "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027UV_SKIPRAY.ALO\u0027.", "context": [ - "Death_Star_Whole_Vsmall", - "Tag: Space_Model_Name" + "Skipray_Bombing_Run", + "Tag: Land_Model_Name", + "UV_SKIPRAY.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_EV_LAMBDASHUTTLE_150.ALO", - "message": "Unable to find Alamo file \u0027Cin_EV_lambdaShuttle_150.alo\u0027", + "asset": "CINE_EV_STARDESTROYER.ALO", + "message": "Unable to find Alamo file \u0027CINE_EV_StarDestroyer.ALO\u0027", "context": [ - "Lambda_Shuttle_150X6-9", - "Tag: Land_Model_Name" + "CIN_Star_Destroyer3X", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_FIRE_MEDIUM.ALO", - "message": "Unable to find Alamo file \u0027CIN_Fire_Medium.alo\u0027", + "asset": "pe_bwing_yellow", + "message": "Proxy particle \u0027pe_bwing_yellow\u0027 not found for model \u0027RV_BWING.ALO\u0027", "context": [ - "Fin_Fire_Medium", - "Tag: Land_Model_Name" + "B-Wing", + "Tag: Space_Model_Name", + "RV_BWING.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_VOLCANO_ROCK02.ALO", - "message": "Unable to find Alamo file \u0027W_Volcano_Rock02.ALO\u0027", + "asset": "NB_YsalamiriTree_B.tga", + "message": "Could not find texture \u0027NB_YsalamiriTree_B.tga\u0027 for context: [Ysalamiri_Tree--\u003ETag: Land_Model_Name--\u003ENB_YSALAMIRI_TREE.ALO].", "context": [ - "Prop_Volcano_RockForm03", - "Tag: Land_Model_Name" + "Ysalamiri_Tree", + "Tag: Land_Model_Name", + "NB_YSALAMIRI_TREE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_EI_VADER.ALO", - "message": "Unable to find Alamo file \u0027Cin_EI_Vader.alo\u0027", + "asset": "CIN_EV_LAMBDASHUTTLE_150.ALO", + "message": "Unable to find Alamo file \u0027Cin_EV_lambdaShuttle_150.alo\u0027", "context": [ - "Cin_Vader", + "Lambda_Shuttle_150X6-9", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_VOL_STEAM01.ALO", - "message": "Unable to find Alamo file \u0027W_Vol_Steam01.ALO\u0027", + "asset": "CIN_LAMBDA_HEAD.ALO", + "message": "Unable to find Alamo file \u0027CIN_Lambda_Head.alo\u0027", "context": [ - "Prop_Vol_Steam01", + "Cin_Lambda_Head", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DEATHSTAR_HIGH.ALO", - "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", + "asset": "W_DROID_STEAM.ALO", + "message": "Unable to find Alamo file \u0027W_droid_steam.alo\u0027", "context": [ - "UM05_PROP_DSTAR", + "Prop_Droid_Steam", "Tag: Land_Model_Name" ] }, @@ -1756,81 +1798,82 @@ "asset": "CIN_RV_XWINGPROP.ALO", "message": "Unable to find Alamo file \u0027Cin_rv_XWingProp.alo\u0027", "context": [ - "Grounded_Xwing", + "Cin_X-WingProp", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Default.fx", - "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027EV_MDU_SENSORNODE.ALO\u0027.", + "asset": "p_hp_archammer-damage", + "message": "Proxy particle \u0027p_hp_archammer-damage\u0027 not found for model \u0027EV_ARCHAMMER.ALO\u0027", "context": [ - "Empire_Offensive_Sensor_Node", - "Tag: Land_Model_Name", - "EV_MDU_SENSORNODE.ALO" + "Arc_Hammer", + "Tag: Space_Model_Name", + "EV_ARCHAMMER.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_particle_master", - "message": "Could not find texture \u0027p_particle_master\u0027 for context: [Test_Particle--\u003ETag: Land_Model_Name--\u003EP_DIRT_EMITTER_TEST1.ALO].", + "asset": "W_TE_Rock_f_02_b.tga", + "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [The_Peacebringer--\u003ETag: Space_Model_Name--\u003EUV_KRAYTCLASSDESTROYER_TYBER.ALO].", "context": [ - "Test_Particle", - "Tag: Land_Model_Name", - "P_DIRT_EMITTER_TEST1.ALO" + "The_Peacebringer", + "Tag: Space_Model_Name", + "UV_KRAYTCLASSDESTROYER_TYBER.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_BIKER_ROW.ALO", - "message": "Unable to find Alamo file \u0027CIN_Biker_Row.alo\u0027", + "asset": "W_VOLCANO_ROCK02.ALO", + "message": "Unable to find Alamo file \u0027W_Volcano_Rock02.ALO\u0027", "context": [ - "Cin_Biker_Row", + "Prop_Volcano_RockForm03", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DEATHSTAR_HIGH.ALO", - "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", + "asset": "p_ssd_debris", + "message": "Proxy particle \u0027p_ssd_debris\u0027 not found for model \u0027UV_ECLIPSE_UC_DC.ALO\u0027", "context": [ - "Death_Star_Whole_small", - "Tag: Space_Model_Name" + "Eclipse_Super_Star_Destroyer_Death_Clone", + "Tag: Space_Model_Name", + "UV_ECLIPSE_UC_DC.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_FIRE_HUGE.ALO", - "message": "Unable to find Alamo file \u0027CIN_Fire_Huge.alo\u0027", + "asset": "CIN_RV_XWINGPROP.ALO", + "message": "Unable to find Alamo file \u0027Cin_rv_XWingProp.alo\u0027", "context": [ - "Fin_Fire_Huge", + "Grounded_Xwing", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_RBEL_NAVYROW.ALO", - "message": "Unable to find Alamo file \u0027CIN_Rbel_NavyRow.alo\u0027", + "asset": "CIN_DEATHSTAR_HIGH.ALO", + "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", "context": [ - "Cin_Rebel_NavyRow", - "Tag: Land_Model_Name" + "Death_Star_Whole_Vsmall", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "lookat", - "message": "Proxy particle \u0027lookat\u0027 not found for model \u0027UV_ECLIPSE.ALO\u0027", + "asset": "W_TE_Rock_f_02_b.tga", + "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [TIE_Phantom--\u003ETag: Space_Model_Name--\u003EEV_TIE_PHANTOM.ALO].", "context": [ - "Eclipse_Prop", + "TIE_Phantom", "Tag: Space_Model_Name", - "UV_ECLIPSE.ALO" + "EV_TIE_PHANTOM.ALO" ] }, { @@ -1846,743 +1889,703 @@ { "id": "FILE00", "severity": "Error", - "asset": "W_TE_Rock_f_02_b.tga", - "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [TIE_Phantom--\u003ETag: Space_Model_Name--\u003EEV_TIE_PHANTOM.ALO].", + "asset": "Lensflare0", + "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_MEDIUM.ALO\u0027", "context": [ - "TIE_Phantom", + "Stars_Medium", "Tag: Space_Model_Name", - "EV_TIE_PHANTOM.ALO" + "W_STARS_MEDIUM.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "P_SPLASH_WAKE_LAVA.ALO", - "message": "Unable to find Alamo file \u0027p_splash_wake_lava.alo\u0027", + "asset": "CIN_RBEL_GREYGROUP.ALO", + "message": "Unable to find Alamo file \u0027CIN_Rbel_GreyGroup.alo\u0027", "context": [ - "Splash_Wake_Lava", + "Cin_Rebel_GreyGroup", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_PLANET_ALDERAAN_HIGH.ALO", - "message": "Unable to find Alamo file \u0027Cin_Planet_Alderaan_High.alo\u0027", - "context": [ - "Alderaan_Backdrop_Large 6x", - "Tag: Space_Model_Name" - ] - }, - { - "id": "FILE00", - "severity": "Error", - "asset": "CIN_EV_LAMBDASHUTTLE_150.ALO", - "message": "Unable to find Alamo file \u0027Cin_EV_lambdaShuttle_150.alo\u0027", + "asset": "CIN_DEATHSTAR_HANGAR.ALO", + "message": "Unable to find Alamo file \u0027CIN_DeathStar_Hangar.alo\u0027", "context": [ - "Lambda_Shuttle_150", + "Cin_DeathStar_Hangar", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_PLANET_VOLCANIC.ALO", - "message": "Unable to find Alamo file \u0027w_planet_volcanic.alo\u0027", + "asset": "p_smoke_small_thin2", + "message": "Proxy particle \u0027p_smoke_small_thin2\u0027 not found for model \u0027RB_HYPERVELOCITYGUN.ALO\u0027", "context": [ - "Volcanic_Backdrop_Large", - "Tag: Space_Model_Name" + "Ground_Empire_Hypervelocity_Gun", + "Tag: Land_Model_Name", + "RB_HYPERVELOCITYGUN.ALO" ] }, { "id": "FILE00", - "severity": "Error", - "asset": "CIN_EI_VADER.ALO", - "message": "Unable to find Alamo file \u0027Cin_EI_Vader.alo\u0027", + "severity": "Warning", + "asset": "i_button_general_dodonna.tga", + "message": "Could not find icon \u0027i_button_general_dodonna.tga\u0027 for game object type \u0027General_Dodonna\u0027.", "context": [ - "Cin_Vader_Shot_6-9", - "Tag: Land_Model_Name" + "General_Dodonna" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_uwstation_death", - "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_02_STATION_D.ALO\u0027", + "asset": "CIN_EV_LAMBDASHUTTLE_150.ALO", + "message": "Unable to find Alamo file \u0027Cin_EV_lambdaShuttle_150.alo\u0027", "context": [ - "Underworld_Star_Base_2_Death_Clone", - "Tag: Space_Model_Name", - "UB_02_STATION_D.ALO" + "Lambda_Shuttle_150", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CINE_EV_STARDESTROYER.ALO", - "message": "Unable to find Alamo file \u0027CINE_EV_StarDestroyer.ALO\u0027", + "asset": "CIN_BRIDGE.ALO", + "message": "Unable to find Alamo file \u0027Cin_bridge.alo\u0027", "context": [ - "CIN_Star_Destroyer3X", + "Imperial_Bridge", "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_smoke_small_thin2", - "message": "Proxy particle \u0027p_smoke_small_thin2\u0027 not found for model \u0027NB_PRISON.ALO\u0027", + "asset": "CIN_BIKER_ROW.ALO", + "message": "Unable to find Alamo file \u0027CIN_Biker_Row.alo\u0027", "context": [ - "Imperial_Prison_Facility", - "Tag: Land_Model_Name", - "NB_PRISON.ALO" + "Cin_Biker_Row", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_CORUSCANT.ALO", - "message": "Unable to find Alamo file \u0027Cin_Coruscant.alo\u0027", + "asset": "CIN_EI_PALPATINE.ALO", + "message": "Unable to find Alamo file \u0027Cin_EI_Palpatine.alo\u0027", "context": [ - "Corusant_Backdrop_Large 6x", - "Tag: Space_Model_Name" + "Cin_Emperor_Shot_5", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Cin_Reb_CelebHall_Wall_B.tga", - "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall_B.tga\u0027 for context: [Cin_sith_lefthall--\u003ETag: Land_Model_Name--\u003EW_SITH_LEFTHALL.ALO].", + "asset": "CIN_DEATHSTAR_HIGH.ALO", + "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", "context": [ - "Cin_sith_lefthall", - "Tag: Land_Model_Name", - "W_SITH_LEFTHALL.ALO" + "Death_Star_Whole_small", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_uwstation_death", - "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_03_STATION_D.ALO\u0027", + "asset": "CIN_SHUTTLE_TYDERIUM.ALO", + "message": "Unable to find Alamo file \u0027Cin_Shuttle_Tyderium.alo\u0027", "context": [ - "Underworld_Star_Base_3_Death_Clone", - "Tag: Space_Model_Name", - "UB_03_STATION_D.ALO" + "Intro2_Shuttle_Tyderium", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Default.fx", - "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027EV_TIE_LANCET.ALO\u0027.", + "asset": "CIN_OFFICER.ALO", + "message": "Unable to find Alamo file \u0027Cin_Officer.alo\u0027", "context": [ - "Lancet_Air_Artillery", - "Tag: Land_Model_Name", - "EV_TIE_LANCET.ALO" + "FIN_Officer", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Lensflare0", - "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_CINE_LUA.ALO\u0027", + "asset": "p_smoke_small_thin2", + "message": "Proxy particle \u0027p_smoke_small_thin2\u0027 not found for model \u0027NB_PRISON.ALO\u0027", "context": [ - "Stars_Lua_Cinematic", + "Imperial_Prison_Facility", "Tag: Land_Model_Name", - "W_STARS_CINE_LUA.ALO" + "NB_PRISON.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_SWAMPGASEMIT.ALO", - "message": "Unable to find Alamo file \u0027W_SwampGasEmit.ALO\u0027", + "asset": "CIN_RBEL_GREY.ALO", + "message": "Unable to find Alamo file \u0027CIN_Rbel_grey.alo\u0027", "context": [ - "Prop_SwampGasEmitter", + "Cin_Rebel_Grey", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_smoke_small_thin4", - "message": "Proxy particle \u0027p_smoke_small_thin4\u0027 not found for model \u0027NB_PRISON.ALO\u0027", + "asset": "p_cold_tiny01", + "message": "Proxy particle \u0027p_cold_tiny01\u0027 not found for model \u0027NB_SCH.ALO\u0027", "context": [ - "Imperial_Prison_Facility", + "Arctic_Civilian_Spawn_House", "Tag: Land_Model_Name", - "NB_PRISON.ALO" - ] - }, - { - "id": "FILE00", - "severity": "Error", - "asset": "CIN_DEATHSTAR_HIGH.ALO", - "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", - "context": [ - "Death_Star_Whole", - "Tag: Space_Model_Name" + "NB_SCH.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_OFFICER.ALO", - "message": "Unable to find Alamo file \u0027Cin_Officer.alo\u0027", + "asset": "lookat", + "message": "Proxy particle \u0027lookat\u0027 not found for model \u0027UV_ECLIPSE.ALO\u0027", "context": [ - "FIN_Officer", - "Tag: Space_Model_Name" + "Eclipse_Prop", + "Tag: Space_Model_Name", + "UV_ECLIPSE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_RV_XWINGPROP.ALO", - "message": "Unable to find Alamo file \u0027Cin_rv_XWingProp.alo\u0027", + "asset": "W_KAMINO_REFLECT.ALO", + "message": "Unable to find Alamo file \u0027W_Kamino_Reflect.ALO\u0027", "context": [ - "Cin_X-WingProp", + "Prop_Kamino_Reflection_00", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Lensflare0", - "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_LOW.ALO\u0027", + "asset": "CIN_DSTAR_DISH_CLOSE.ALO", + "message": "Unable to find Alamo file \u0027Cin_DStar_Dish_close.alo\u0027", "context": [ - "Stars_Low", - "Tag: Space_Model_Name", - "W_STARS_LOW.ALO" + "Death_Star_Dish_Close", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DEATHSTAR_HANGAR.ALO", - "message": "Unable to find Alamo file \u0027CIN_DeathStar_Hangar.alo\u0027", + "asset": "W_BUSH_SWMP00.ALO", + "message": "Unable to find Alamo file \u0027W_Bush_Swmp00.ALO\u0027", "context": [ - "Cin_DeathStar_Hangar", + "Prop_Swamp_Bush00", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "P_mptl-2a_Die", - "message": "Proxy particle \u0027P_mptl-2a_Die\u0027 not found for model \u0027RV_MPTL-2A.ALO\u0027", - "context": [ - "MPTL", - "Tag: Land_Model_Name", - "RV_MPTL-2A.ALO" - ] - }, - { - "id": "FILE00", - "severity": "Error", - "asset": "Cin_Reb_CelebHall_Wall_B.tga", - "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall_B.tga\u0027 for context: [Cin_w_tile--\u003ETag: Land_Model_Name--\u003EW_TILE.ALO].", + "asset": "Lensflare0", + "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_HIGH.ALO\u0027", "context": [ - "Cin_w_tile", - "Tag: Land_Model_Name", - "W_TILE.ALO" + "Stars_High", + "Tag: Space_Model_Name", + "W_STARS_HIGH.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_SITH_ARCH.ALO", - "message": "Unable to find Alamo file \u0027w_sith_arch.alo\u0027", + "asset": "lookat", + "message": "Proxy particle \u0027lookat\u0027 not found for model \u0027UV_ECLIPSE_UC.ALO\u0027", "context": [ - "Cin_sith_arch", - "Tag: Land_Model_Name" + "Eclipse_Super_Star_Destroyer", + "Tag: Space_Model_Name", + "UV_ECLIPSE_UC.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_ALLSHADERS.ALO", - "message": "Unable to find Alamo file \u0027W_AllShaders.ALO\u0027", + "asset": "CIN_BRIDGE.ALO", + "message": "Unable to find Alamo file \u0027Cin_bridge.alo\u0027", "context": [ - "Prop_AllShaders", + "UM05_PROP_BRIDGE", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_steam_small", - "message": "Proxy particle \u0027p_steam_small\u0027 not found for model \u0027RB_HEAVYVEHICLEFACTORY.ALO\u0027", - "context": [ - "R_Ground_Heavy_Vehicle_Factory", - "Tag: Land_Model_Name", - "RB_HEAVYVEHICLEFACTORY.ALO" - ] - }, - { - "id": "FILE00", - "severity": "Error", - "asset": "NB_YsalamiriTree_B.tga", - "message": "Could not find texture \u0027NB_YsalamiriTree_B.tga\u0027 for context: [Ysalamiri_Tree--\u003ETag: Land_Model_Name--\u003ENB_YSALAMIRI_TREE.ALO].", + "asset": "p_bomb_spin", + "message": "Proxy particle \u0027p_bomb_spin\u0027 not found for model \u0027W_THERMAL_DETONATOR_EMPIRE.ALO\u0027", "context": [ - "Ysalamiri_Tree", + "TIE_Bomber_Bombing_Run_Bomb", "Tag: Land_Model_Name", - "NB_YSALAMIRI_TREE.ALO" + "W_THERMAL_DETONATOR_EMPIRE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "w_grenade.tga", - "message": "Could not find texture \u0027w_grenade.tga\u0027 for context: [Proj_Merc_Concussion_Grenade--\u003ETag: Land_Model_Name--\u003EW_GRENADE.ALO].", + "asset": "Lensflare0", + "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_CINE_LUA.ALO\u0027", "context": [ - "Proj_Merc_Concussion_Grenade", + "Stars_Lua_Cinematic", "Tag: Land_Model_Name", - "W_GRENADE.ALO" + "W_STARS_CINE_LUA.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_KAMINO_REFLECT.ALO", - "message": "Unable to find Alamo file \u0027W_Kamino_Reflect.ALO\u0027", + "asset": "p_uwstation_death", + "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_02_STATION_D.ALO\u0027", "context": [ - "Prop_Kamino_Reflection_01", - "Tag: Land_Model_Name" + "Underworld_Star_Base_2_Death_Clone", + "Tag: Space_Model_Name", + "UB_02_STATION_D.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DSTAR_PROTONS.ALO", - "message": "Unable to find Alamo file \u0027Cin_DStar_protons.alo\u0027", + "asset": "CIN_EV_STARDESTROYER_WARP.ALO", + "message": "Unable to find Alamo file \u0027Cin_EV_Stardestroyer_Warp.alo\u0027", "context": [ - "Protons_DStar_Xplode", + "Star_Destroyer_Warp", "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Cin_DeathStar.tga", - "message": "Could not find texture \u0027Cin_DeathStar.tga\u0027 for context: [Test_Base_Hector--\u003ETag: Land_Model_Name--\u003EALTTEST.ALO].", + "asset": "p_smoke_small_thin4", + "message": "Proxy particle \u0027p_smoke_small_thin4\u0027 not found for model \u0027NB_PRISON.ALO\u0027", "context": [ - "Test_Base_Hector", + "Imperial_Prison_Facility", "Tag: Land_Model_Name", - "ALTTEST.ALO" - ] - }, - { - "id": "FILE00", - "severity": "Error", - "asset": "lookat", - "message": "Proxy particle \u0027lookat\u0027 not found for model \u0027UV_ECLIPSE_UC.ALO\u0027", - "context": [ - "Eclipse_Super_Star_Destroyer", - "Tag: Space_Model_Name", - "UV_ECLIPSE_UC.ALO" + "NB_PRISON.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_explosion_smoke_small_thin5", - "message": "Proxy particle \u0027p_explosion_smoke_small_thin5\u0027 not found for model \u0027NB_NOGHRI_HUT.ALO\u0027", + "asset": "w_grenade.tga", + "message": "Could not find texture \u0027w_grenade.tga\u0027 for context: [Proj_Merc_Concussion_Grenade--\u003ETag: Land_Model_Name--\u003EW_GRENADE.ALO].", "context": [ - "Noghri_Spawn_House", + "Proj_Merc_Concussion_Grenade", "Tag: Land_Model_Name", - "NB_NOGHRI_HUT.ALO" + "W_GRENADE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_KAMINO_REFLECT.ALO", - "message": "Unable to find Alamo file \u0027W_Kamino_Reflect.ALO\u0027", + "asset": "CIN_CORUSCANT.ALO", + "message": "Unable to find Alamo file \u0027Cin_Coruscant.alo\u0027", "context": [ - "Prop_Kamino_Reflection_00", - "Tag: Land_Model_Name" + "Corusant_Backdrop_Large 6x", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_PROBE_DROID.ALO", - "message": "Unable to find Alamo file \u0027CIN_Probe_Droid.alo\u0027", + "asset": "UB_girder_B.tga", + "message": "Could not find texture \u0027UB_girder_B.tga\u0027 for context: [Underworld_Ysalamiri_Cage--\u003ETag: Land_Model_Name--\u003EUV_MDU_CAGE.ALO].", "context": [ - "Empire_Droid", - "Tag: Land_Model_Name" + "Underworld_Ysalamiri_Cage", + "Tag: Land_Model_Name", + "UV_MDU_CAGE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Lensflare0", - "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_HIGH.ALO\u0027", + "asset": "Cin_Reb_CelebHall_Wall_B.tga", + "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall_B.tga\u0027 for context: [Cin_sith_console--\u003ETag: Land_Model_Name--\u003EW_SITH_CONSOLE.ALO].", "context": [ - "Stars_High", - "Tag: Space_Model_Name", - "W_STARS_HIGH.ALO" + "Cin_sith_console", + "Tag: Land_Model_Name", + "W_SITH_CONSOLE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Lensflare0", - "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_CINE.ALO\u0027", + "asset": "p_uwstation_death", + "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_04_STATION_D.ALO\u0027", "context": [ - "Stars_Cinematic", + "Underworld_Star_Base_4_Death_Clone", "Tag: Space_Model_Name", - "W_STARS_CINE.ALO" + "UB_04_STATION_D.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_desert_ground_dust", - "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027UI_SABOTEUR.ALO\u0027", + "asset": "Cin_Reb_CelebHall_Wall.tga", + "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall.tga\u0027 for context: [Cin_sith_lefthall--\u003ETag: Land_Model_Name--\u003EW_SITH_LEFTHALL.ALO].", "context": [ - "Underworld_Saboteur", + "Cin_sith_lefthall", "Tag: Land_Model_Name", - "UI_SABOTEUR.ALO" + "W_SITH_LEFTHALL.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_uwstation_death", - "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_04_STATION_D.ALO\u0027", + "asset": "p_smoke_small_thin2", + "message": "Proxy particle \u0027p_smoke_small_thin2\u0027 not found for model \u0027NB_MONCAL_BUILDING.ALO\u0027", "context": [ - "Underworld_Star_Base_4_Death_Clone", - "Tag: Space_Model_Name", - "UB_04_STATION_D.ALO" + "MonCalamari_Spawn_House", + "Tag: Land_Model_Name", + "NB_MONCAL_BUILDING.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DSTAR_LEVERPANEL.ALO", - "message": "Unable to find Alamo file \u0027Cin_DStar_LeverPanel.alo\u0027", + "asset": "W_SWAMPGASEMIT.ALO", + "message": "Unable to find Alamo file \u0027W_SwampGasEmit.ALO\u0027", "context": [ - "Death_Star_LeverPanel", - "Tag: Space_Model_Name" + "Prop_SwampGasEmitter", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_TE_Rock_f_02_b.tga", - "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [The_Peacebringer--\u003ETag: Space_Model_Name--\u003EUV_KRAYTCLASSDESTROYER_TYBER.ALO].", + "asset": "Cin_Reb_CelebHall_Wall.tga", + "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall.tga\u0027 for context: [Cin_w_tile--\u003ETag: Land_Model_Name--\u003EW_TILE.ALO].", "context": [ - "The_Peacebringer", - "Tag: Space_Model_Name", - "UV_KRAYTCLASSDESTROYER_TYBER.ALO" + "Cin_w_tile", + "Tag: Land_Model_Name", + "W_TILE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_BUSH_SWMP00.ALO", - "message": "Unable to find Alamo file \u0027W_Bush_Swmp00.ALO\u0027", + "asset": "CIN_P_PROTON_TORPEDO.ALO", + "message": "Unable to find Alamo file \u0027CIN_p_proton_torpedo.alo\u0027", "context": [ - "Prop_Swamp_Bush00", + "Cin_Proj_Ground_Proton_Torpedo", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_RBEL_SOLDIER.ALO", - "message": "Unable to find Alamo file \u0027CIN_Rbel_Soldier.alo\u0027", + "asset": "p_desert_ground_dust", + "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027UI_IG88.ALO\u0027", "context": [ - "Cin_Rebel_soldier", - "Tag: Land_Model_Name" + "IG-88", + "Tag: Land_Model_Name", + "UI_IG88.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_RBEL_SOLDIER_GROUP.ALO", - "message": "Unable to find Alamo file \u0027CIN_Rbel_Soldier_Group.alo\u0027", + "asset": "CIN_EV_TIEADVANCED.ALO", + "message": "Unable to find Alamo file \u0027Cin_EV_TieAdvanced.alo\u0027", "context": [ - "Cin_Rebel_SoldierRow", - "Tag: Land_Model_Name" + "Fin_Vader_TIE", + "Tag: Space_Model_Name" ] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "zoomed_back", - "message": "The CommandBar component \u0027zoomed_back\u0027 is not connected to a shell component.", + "asset": "g_planet_value", + "message": "The CommandBar component \u0027g_planet_value\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "zoomed_header_text", - "message": "The CommandBar component \u0027zoomed_header_text\u0027 is not connected to a shell component.", + "asset": "st_power", + "message": "The CommandBar component \u0027st_power\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_planet_value", - "message": "The CommandBar component \u0027g_planet_value\u0027 is not connected to a shell component.", + "asset": "g_planet_land_forces", + "message": "The CommandBar component \u0027g_planet_land_forces\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "generic_collision", - "message": "The CommandBar component \u0027generic_collision\u0027 is not connected to a shell component.", + "asset": "st_bracket_large", + "message": "The CommandBar component \u0027st_bracket_large\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_corruption_icon", - "message": "The CommandBar component \u0027g_corruption_icon\u0027 is not connected to a shell component.", + "asset": "objective_text", + "message": "The CommandBar component \u0027objective_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_right_text", - "message": "The CommandBar component \u0027encyclopedia_right_text\u0027 is not connected to a shell component.", + "asset": "zoomed_center_text", + "message": "The CommandBar component \u0027zoomed_center_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_ground_level_pips", - "message": "The CommandBar component \u0027g_ground_level_pips\u0027 is not connected to a shell component.", + "asset": "objective_header_text", + "message": "The CommandBar component \u0027objective_header_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "zoomed_center_text", - "message": "The CommandBar component \u0027zoomed_center_text\u0027 is not connected to a shell component.", + "asset": "st_hero_health", + "message": "The CommandBar component \u0027st_hero_health\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_planet_name", - "message": "The CommandBar component \u0027g_planet_name\u0027 is not connected to a shell component.", + "asset": "zoomed_back", + "message": "The CommandBar component \u0027zoomed_back\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "generic_flytext", - "message": "The CommandBar component \u0027generic_flytext\u0027 is not connected to a shell component.", + "asset": "st_control_group", + "message": "The CommandBar component \u0027st_control_group\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_health_medium", - "message": "The CommandBar component \u0027st_health_medium\u0027 is not connected to a shell component.", + "asset": "tooltip_back", + "message": "The CommandBar component \u0027tooltip_back\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_bracket_medium", - "message": "The CommandBar component \u0027st_bracket_medium\u0027 is not connected to a shell component.", + "asset": "bm_title_4011", + "message": "The CommandBar component \u0027bm_title_4011\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "objective_header_text", - "message": "The CommandBar component \u0027objective_header_text\u0027 is not connected to a shell component.", + "asset": "g_planet_name", + "message": "The CommandBar component \u0027g_planet_name\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "help_back", - "message": "The CommandBar component \u0027help_back\u0027 is not connected to a shell component.", + "asset": "skirmish_upgrade", + "message": "The CommandBar component \u0027skirmish_upgrade\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tooltip_price", - "message": "The CommandBar component \u0027tooltip_price\u0027 is not connected to a shell component.", + "asset": "g_conflict", + "message": "The CommandBar component \u0027g_conflict\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_political_control", - "message": "The CommandBar component \u0027g_political_control\u0027 is not connected to a shell component.", + "asset": "objective_back", + "message": "The CommandBar component \u0027objective_back\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_health_bar", - "message": "The CommandBar component \u0027st_health_bar\u0027 is not connected to a shell component.", + "asset": "g_radar_blip", + "message": "The CommandBar component \u0027g_radar_blip\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "radar_blip", - "message": "The CommandBar component \u0027radar_blip\u0027 is not connected to a shell component.", + "asset": "st_bracket_medium", + "message": "The CommandBar component \u0027st_bracket_medium\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_space_level_pips", - "message": "The CommandBar component \u0027g_space_level_pips\u0027 is not connected to a shell component.", + "asset": "st_health", + "message": "The CommandBar component \u0027st_health\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "cs_ability_button", - "message": "The CommandBar component \u0027cs_ability_button\u0027 is not connected to a shell component.", + "asset": "g_space_icon", + "message": "The CommandBar component \u0027g_space_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "zoomed_cost_text", - "message": "The CommandBar component \u0027zoomed_cost_text\u0027 is not connected to a shell component.", + "asset": "st_shields_large", + "message": "The CommandBar component \u0027st_shields_large\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_planet_fleet", - "message": "The CommandBar component \u0027g_planet_fleet\u0027 is not connected to a shell component.", + "asset": "generic_flytext", + "message": "The CommandBar component \u0027generic_flytext\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_space_level", - "message": "The CommandBar component \u0027g_space_level\u0027 is not connected to a shell component.", + "asset": "encyclopedia_text", + "message": "The CommandBar component \u0027encyclopedia_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "remote_bomb_icon", - "message": "The CommandBar component \u0027remote_bomb_icon\u0027 is not connected to a shell component.", + "asset": "st_hero_icon", + "message": "The CommandBar component \u0027st_hero_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_ground_sell", - "message": "The CommandBar component \u0027g_ground_sell\u0027 is not connected to a shell component.", + "asset": "bribe_display", + "message": "The CommandBar component \u0027bribe_display\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_cost_text", - "message": "The CommandBar component \u0027encyclopedia_cost_text\u0027 is not connected to a shell component.", + "asset": "st_health_medium", + "message": "The CommandBar component \u0027st_health_medium\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_special_ability", - "message": "The CommandBar component \u0027g_special_ability\u0027 is not connected to a shell component.", + "asset": "tactical_sell", + "message": "The CommandBar component \u0027tactical_sell\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_planet_ability", - "message": "The CommandBar component \u0027g_planet_ability\u0027 is not connected to a shell component.", + "asset": "g_planet_ring", + "message": "The CommandBar component \u0027g_planet_ring\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_garrison_icon", - "message": "The CommandBar component \u0027st_garrison_icon\u0027 is not connected to a shell component.", + "asset": "balance_pip", + "message": "The CommandBar component \u0027balance_pip\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_space_icon", - "message": "The CommandBar component \u0027g_space_icon\u0027 is not connected to a shell component.", + "asset": "g_ground_sell", + "message": "The CommandBar component \u0027g_ground_sell\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "garrison_slot_icon", - "message": "The CommandBar component \u0027garrison_slot_icon\u0027 is not connected to a shell component.", + "asset": "bm_title_4010", + "message": "The CommandBar component \u0027bm_title_4010\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_icon", - "message": "The CommandBar component \u0027encyclopedia_icon\u0027 is not connected to a shell component.", + "asset": "gui_dialog_tooltip", + "message": "The CommandBar component \u0027gui_dialog_tooltip\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_planet_ring", - "message": "The CommandBar component \u0027g_planet_ring\u0027 is not connected to a shell component.", + "asset": "b_planet_left", + "message": "The CommandBar component \u0027b_planet_left\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_radar_blip", - "message": "The CommandBar component \u0027g_radar_blip\u0027 is not connected to a shell component.", + "asset": "encyclopedia_right_text", + "message": "The CommandBar component \u0027encyclopedia_right_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tooltip_left_text", - "message": "The CommandBar component \u0027tooltip_left_text\u0027 is not connected to a shell component.", + "asset": "help_back", + "message": "The CommandBar component \u0027help_back\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "objective_text", - "message": "The CommandBar component \u0027objective_text\u0027 is not connected to a shell component.", + "asset": "g_ground_level_pips", + "message": "The CommandBar component \u0027g_ground_level_pips\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "zoomed_right_text", - "message": "The CommandBar component \u0027zoomed_right_text\u0027 is not connected to a shell component.", + "asset": "objective_icon", + "message": "The CommandBar component \u0027objective_icon\u0027 is not connected to a shell component.", + "context": [] + }, + { + "id": "CMDBAR04", + "severity": "Warning", + "asset": "g_space_level_pips", + "message": "The CommandBar component \u0027g_space_level_pips\u0027 is not connected to a shell component.", + "context": [] + }, + { + "id": "CMDBAR04", + "severity": "Warning", + "asset": "g_planet_fleet", + "message": "The CommandBar component \u0027g_planet_fleet\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tutorial_text", - "message": "The CommandBar component \u0027tutorial_text\u0027 is not connected to a shell component.", + "asset": "g_planet_ability", + "message": "The CommandBar component \u0027g_planet_ability\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "b_planet_right", - "message": "The CommandBar component \u0027b_planet_right\u0027 is not connected to a shell component.", + "asset": "garrison_respawn_counter", + "message": "The CommandBar component \u0027garrison_respawn_counter\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_health", - "message": "The CommandBar component \u0027st_health\u0027 is not connected to a shell component.", + "asset": "encyclopedia_back", + "message": "The CommandBar component \u0027encyclopedia_back\u0027 is not connected to a shell component.", "context": [] }, { @@ -2592,252 +2595,235 @@ "message": "The CommandBar component \u0027g_ground_level\u0027 is not connected to a shell component.", "context": [] }, - { - "id": "CMDBAR03", - "severity": "Information", - "asset": "g_credit_bar", - "message": "The CommandBar component \u0027g_credit_bar\u0027 is not supported by the game.", - "context": [] - }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_conflict", - "message": "The CommandBar component \u0027g_conflict\u0027 is not connected to a shell component.", + "asset": "lt_weather_icon", + "message": "The CommandBar component \u0027lt_weather_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_header_text", - "message": "The CommandBar component \u0027encyclopedia_header_text\u0027 is not connected to a shell component.", + "asset": "g_radar_view", + "message": "The CommandBar component \u0027g_radar_view\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_hero_icon", - "message": "The CommandBar component \u0027g_hero_icon\u0027 is not connected to a shell component.", + "asset": "g_smuggler", + "message": "The CommandBar component \u0027g_smuggler\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "bribe_display", - "message": "The CommandBar component \u0027bribe_display\u0027 is not connected to a shell component.", + "asset": "zoomed_text", + "message": "The CommandBar component \u0027zoomed_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_bracket_large", - "message": "The CommandBar component \u0027st_bracket_large\u0027 is not connected to a shell component.", + "asset": "tooltip_name", + "message": "The CommandBar component \u0027tooltip_name\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_build", - "message": "The CommandBar component \u0027g_build\u0027 is not connected to a shell component.", + "asset": "g_credit_bar", + "message": "The CommandBar component \u0027g_credit_bar\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "lt_weather_icon", - "message": "The CommandBar component \u0027lt_weather_icon\u0027 is not connected to a shell component.", + "asset": "tooltip_left_text", + "message": "The CommandBar component \u0027tooltip_left_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_bracket_small", - "message": "The CommandBar component \u0027st_bracket_small\u0027 is not connected to a shell component.", + "asset": "st_ability_icon", + "message": "The CommandBar component \u0027st_ability_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "objective_icon", - "message": "The CommandBar component \u0027objective_icon\u0027 is not connected to a shell component.", + "asset": "tutorial_text", + "message": "The CommandBar component \u0027tutorial_text\u0027 is not connected to a shell component.", "context": [] }, - { - "id": "FILE00", - "severity": "Error", - "asset": "i_button_blank.tga", - "message": "Could not find texture \u0027i_button_blank.tga\u0027 for context: [map_shell--\u003EPLANETARY_MODE.ALO].", - "context": [ - "map_shell", - "PLANETARY_MODE.ALO" - ] - }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_radar_view", - "message": "The CommandBar component \u0027g_radar_view\u0027 is not connected to a shell component.", + "asset": "g_corruption_text", + "message": "The CommandBar component \u0027g_corruption_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_hero", - "message": "The CommandBar component \u0027g_hero\u0027 is not connected to a shell component.", + "asset": "tooltip_icon_land", + "message": "The CommandBar component \u0027tooltip_icon_land\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "gui_dialog_tooltip", - "message": "The CommandBar component \u0027gui_dialog_tooltip\u0027 is not connected to a shell component.", + "asset": "st_bracket_small", + "message": "The CommandBar component \u0027st_bracket_small\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_shields_large", - "message": "The CommandBar component \u0027st_shields_large\u0027 is not connected to a shell component.", + "asset": "b_beacon_t", + "message": "The CommandBar component \u0027b_beacon_t\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_control_group", - "message": "The CommandBar component \u0027st_control_group\u0027 is not connected to a shell component.", + "asset": "tutorial_text_back", + "message": "The CommandBar component \u0027tutorial_text_back\u0027 is not connected to a shell component.", "context": [] }, { - "id": "CMDBAR04", - "severity": "Warning", - "asset": "b_planet_left", - "message": "The CommandBar component \u0027b_planet_left\u0027 is not connected to a shell component.", + "id": "CMDBAR03", + "severity": "Information", + "asset": "g_credit_bar", + "message": "The CommandBar component \u0027g_credit_bar\u0027 is not supported by the game.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "objective_back", - "message": "The CommandBar component \u0027objective_back\u0027 is not connected to a shell component.", + "asset": "g_enemy_hero", + "message": "The CommandBar component \u0027g_enemy_hero\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tooltip_icon_land", - "message": "The CommandBar component \u0027tooltip_icon_land\u0027 is not connected to a shell component.", + "asset": "g_political_control", + "message": "The CommandBar component \u0027g_political_control\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "balance_pip", - "message": "The CommandBar component \u0027balance_pip\u0027 is not connected to a shell component.", + "asset": "bribed_icon", + "message": "The CommandBar component \u0027bribed_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tooltip_icon", - "message": "The CommandBar component \u0027tooltip_icon\u0027 is not connected to a shell component.", + "asset": "st_health_bar", + "message": "The CommandBar component \u0027st_health_bar\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_planet_land_forces", - "message": "The CommandBar component \u0027g_planet_land_forces\u0027 is not connected to a shell component.", + "asset": "g_hero", + "message": "The CommandBar component \u0027g_hero\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_hero_health", - "message": "The CommandBar component \u0027st_hero_health\u0027 is not connected to a shell component.", + "asset": "st_health_large", + "message": "The CommandBar component \u0027st_health_large\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "skirmish_upgrade", - "message": "The CommandBar component \u0027skirmish_upgrade\u0027 is not connected to a shell component.", + "asset": "tooltip_icon", + "message": "The CommandBar component \u0027tooltip_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_health_large", - "message": "The CommandBar component \u0027st_health_large\u0027 is not connected to a shell component.", + "asset": "b_planet_right", + "message": "The CommandBar component \u0027b_planet_right\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tactical_sell", - "message": "The CommandBar component \u0027tactical_sell\u0027 is not connected to a shell component.", + "asset": "cs_ability_text", + "message": "The CommandBar component \u0027cs_ability_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_text", - "message": "The CommandBar component \u0027encyclopedia_text\u0027 is not connected to a shell component.", + "asset": "encyclopedia_center_text", + "message": "The CommandBar component \u0027encyclopedia_center_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_weather", - "message": "The CommandBar component \u0027g_weather\u0027 is not connected to a shell component.", + "asset": "zoomed_right_text", + "message": "The CommandBar component \u0027zoomed_right_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_power", - "message": "The CommandBar component \u0027st_power\u0027 is not connected to a shell component.", + "asset": "g_bounty_hunter", + "message": "The CommandBar component \u0027g_bounty_hunter\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "b_quick_ref", - "message": "The CommandBar component \u0027b_quick_ref\u0027 is not connected to a shell component.", + "asset": "g_hero_icon", + "message": "The CommandBar component \u0027g_hero_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_ground_icon", - "message": "The CommandBar component \u0027g_ground_icon\u0027 is not connected to a shell component.", + "asset": "b_quick_ref", + "message": "The CommandBar component \u0027b_quick_ref\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "reinforcement_counter", - "message": "The CommandBar component \u0027reinforcement_counter\u0027 is not connected to a shell component.", + "asset": "generic_collision", + "message": "The CommandBar component \u0027generic_collision\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "bm_title_4011", - "message": "The CommandBar component \u0027bm_title_4011\u0027 is not connected to a shell component.", + "asset": "g_build", + "message": "The CommandBar component \u0027g_build\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_ability_icon", - "message": "The CommandBar component \u0027st_ability_icon\u0027 is not connected to a shell component.", + "asset": "g_corruption_icon", + "message": "The CommandBar component \u0027g_corruption_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_back", - "message": "The CommandBar component \u0027encyclopedia_back\u0027 is not connected to a shell component.", + "asset": "g_smuggled", + "message": "The CommandBar component \u0027g_smuggled\u0027 is not connected to a shell component.", "context": [] }, { @@ -2850,141 +2836,144 @@ { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_credit_bar", - "message": "The CommandBar component \u0027g_credit_bar\u0027 is not connected to a shell component.", + "asset": "reinforcement_counter", + "message": "The CommandBar component \u0027reinforcement_counter\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_hero_icon", - "message": "The CommandBar component \u0027st_hero_icon\u0027 is not connected to a shell component.", + "asset": "g_space_level", + "message": "The CommandBar component \u0027g_space_level\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_corruption_text", - "message": "The CommandBar component \u0027g_corruption_text\u0027 is not connected to a shell component.", + "asset": "remote_bomb_icon", + "message": "The CommandBar component \u0027remote_bomb_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "cs_ability_text", - "message": "The CommandBar component \u0027cs_ability_text\u0027 is not connected to a shell component.", + "asset": "st_garrison_icon", + "message": "The CommandBar component \u0027st_garrison_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tutorial_text_back", - "message": "The CommandBar component \u0027tutorial_text_back\u0027 is not connected to a shell component.", + "asset": "cs_ability_button", + "message": "The CommandBar component \u0027cs_ability_button\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_shields_medium", - "message": "The CommandBar component \u0027st_shields_medium\u0027 is not connected to a shell component.", + "asset": "zoomed_cost_text", + "message": "The CommandBar component \u0027zoomed_cost_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "bribed_icon", - "message": "The CommandBar component \u0027bribed_icon\u0027 is not connected to a shell component.", + "asset": "encyclopedia_icon", + "message": "The CommandBar component \u0027encyclopedia_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_enemy_hero", - "message": "The CommandBar component \u0027g_enemy_hero\u0027 is not connected to a shell component.", + "asset": "surface_mod_icon", + "message": "The CommandBar component \u0027surface_mod_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_smuggler", - "message": "The CommandBar component \u0027g_smuggler\u0027 is not connected to a shell component.", + "asset": "g_special_ability", + "message": "The CommandBar component \u0027g_special_ability\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_shields", - "message": "The CommandBar component \u0027st_shields\u0027 is not connected to a shell component.", + "asset": "tooltip_price", + "message": "The CommandBar component \u0027tooltip_price\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_smuggled", - "message": "The CommandBar component \u0027g_smuggled\u0027 is not connected to a shell component.", + "asset": "encyclopedia_header_text", + "message": "The CommandBar component \u0027encyclopedia_header_text\u0027 is not connected to a shell component.", "context": [] }, { - "id": "CMDBAR04", - "severity": "Warning", - "asset": "garrison_respawn_counter", - "message": "The CommandBar component \u0027garrison_respawn_counter\u0027 is not connected to a shell component.", - "context": [] + "id": "FILE00", + "severity": "Error", + "asset": "i_button_blank.tga", + "message": "Could not find texture \u0027i_button_blank.tga\u0027 for context: [map_shell--\u003EPLANETARY_MODE.ALO].", + "context": [ + "map_shell", + "PLANETARY_MODE.ALO" + ] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "b_beacon_t", - "message": "The CommandBar component \u0027b_beacon_t\u0027 is not connected to a shell component.", + "asset": "garrison_slot_icon", + "message": "The CommandBar component \u0027garrison_slot_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "bm_title_4010", - "message": "The CommandBar component \u0027bm_title_4010\u0027 is not connected to a shell component.", + "asset": "st_shields_medium", + "message": "The CommandBar component \u0027st_shields_medium\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "zoomed_text", - "message": "The CommandBar component \u0027zoomed_text\u0027 is not connected to a shell component.", + "asset": "g_ground_icon", + "message": "The CommandBar component \u0027g_ground_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_center_text", - "message": "The CommandBar component \u0027encyclopedia_center_text\u0027 is not connected to a shell component.", + "asset": "encyclopedia_cost_text", + "message": "The CommandBar component \u0027encyclopedia_cost_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "surface_mod_icon", - "message": "The CommandBar component \u0027surface_mod_icon\u0027 is not connected to a shell component.", + "asset": "g_weather", + "message": "The CommandBar component \u0027g_weather\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tooltip_back", - "message": "The CommandBar component \u0027tooltip_back\u0027 is not connected to a shell component.", + "asset": "zoomed_header_text", + "message": "The CommandBar component \u0027zoomed_header_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tooltip_name", - "message": "The CommandBar component \u0027tooltip_name\u0027 is not connected to a shell component.", + "asset": "radar_blip", + "message": "The CommandBar component \u0027radar_blip\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_bounty_hunter", - "message": "The CommandBar component \u0027g_bounty_hunter\u0027 is not connected to a shell component.", + "asset": "st_shields", + "message": "The CommandBar component \u0027st_shields\u0027 is not connected to a shell component.", "context": [] } ] diff --git a/src/ModVerify.CliApp/Resources/Certs/AET-root.cer b/src/ModVerify.CliApp/Resources/Certs/AET-root.cer new file mode 100644 index 00000000..0e2d38f6 Binary files /dev/null and b/src/ModVerify.CliApp/Resources/Certs/AET-root.cer differ diff --git a/src/ModVerify.CliApp/Settings/CommandLine/BaseModVerifyOptions.cs b/src/ModVerify.CliApp/Settings/CommandLine/BaseModVerifyOptions.cs index 521fb8c3..5212b1c2 100644 --- a/src/ModVerify.CliApp/Settings/CommandLine/BaseModVerifyOptions.cs +++ b/src/ModVerify.CliApp/Settings/CommandLine/BaseModVerifyOptions.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using AET.ModVerify.Reporting; +using AET.ModVerify.Reporting; using CommandLine; using PG.StarWarsGame.Engine; @@ -52,8 +51,12 @@ internal abstract class BaseModVerifyOptions "Multiple paths can be separated using the platform-specific path separator (';' on Windows, ':' on Linux).")] public string? AdditionalFallbackPath { get; init; } - [Option("parallel", Default = false, - HelpText = "When set, game verifiers will run in parallel. " + - "While this may reduce analysis time, console output might be harder to read.")] - public bool Parallel { get; init; } + [Option("baseline", Required = false, + HelpText = "Path(s) to one or more JSON baseline files. Multiple paths can be separated using the platform-specific path separator (';' on Windows, ':' on Linux). " + + "For 'verify' this is mutually exclusive with --searchBaseline. May be combined with --useDefaultBaseline.")] + public string? BaselinePaths { get; init; } + + [Option("useDefaultBaseline", Required = false, + HelpText = "When set, additionally applies the default embedded baseline for the detected game engine. May be combined with --baseline (or --searchBaseline for 'verify').")] + public bool UseDefaultBaseline { get; init; } } \ No newline at end of file diff --git a/src/ModVerify.CliApp/Settings/CommandLine/CreateBaselineVerbOption.cs b/src/ModVerify.CliApp/Settings/CommandLine/CreateBaselineVerbOption.cs index 6593245a..ca2f97d2 100644 --- a/src/ModVerify.CliApp/Settings/CommandLine/CreateBaselineVerbOption.cs +++ b/src/ModVerify.CliApp/Settings/CommandLine/CreateBaselineVerbOption.cs @@ -1,4 +1,4 @@ -using CommandLine; +using CommandLine; namespace AET.ModVerify.App.Settings.CommandLine; @@ -7,7 +7,7 @@ internal sealed class CreateBaselineVerbOption : BaseModVerifyOptions { [Option('o', "outFile", Required = true, HelpText = "The file path of the new baseline file.")] public required string OutputFile { get; init; } - + [Option("skipLocation", Required = false, HelpText = "Skips writing the target location to the baseline.")] public bool SkipLocation { get; init; } -} \ No newline at end of file +} diff --git a/src/ModVerify.CliApp/Settings/CommandLine/VerifyVerbOption.cs b/src/ModVerify.CliApp/Settings/CommandLine/VerifyVerbOption.cs index 5e01c5cb..47ae6207 100644 --- a/src/ModVerify.CliApp/Settings/CommandLine/VerifyVerbOption.cs +++ b/src/ModVerify.CliApp/Settings/CommandLine/VerifyVerbOption.cs @@ -1,4 +1,4 @@ -using AET.ModVerify.Reporting; +using AET.ModVerify.Reporting; using CommandLine; namespace AET.ModVerify.App.Settings.CommandLine; @@ -9,7 +9,6 @@ internal sealed class VerifyVerbOption : BaseModVerifyOptions internal static readonly VerifyVerbOption WithoutArguments = new() { IsRunningWithoutArguments = true, - SearchBaselineLocally = true, }; [Option('o', "outDir", Required = false, HelpText = "Directory where result files shall be stored to.")] @@ -28,18 +27,10 @@ internal sealed class VerifyVerbOption : BaseModVerifyOptions HelpText = "When this flag is present, the application will not report engine assertions.")] public bool IgnoreAsserts { get; init; } - - [Option("baseline", Required = false, - HelpText = "Path to a JSON baseline file. Cannot be used together with --searchBaseline or --useDefaultBaseline.")] - public string? Baseline { get; init; } - - [Option("searchBaseline", Required = false, - HelpText = "When set, the application will search for baseline files and use them for verification. Cannot be used together with --baseline or --useDefaultBaseline")] - public bool SearchBaselineLocally { get; init; } - - [Option("useDefaultBaseline", Required = false, - HelpText = "When set, the application will use the default embedded baseline for the detected game engine. Cannot be used together with --baseline or --searchBaseline.")] - public bool UseDefaultBaseline { get; init; } + [Option("parallel", Default = false, + HelpText = "When set, game verifiers will run in parallel. " + + "While this may reduce analysis time, console output might be harder to read.")] + public bool Parallel { get; init; } public bool IsRunningWithoutArguments { get; init; } -} \ No newline at end of file +} diff --git a/src/ModVerify.CliApp/Settings/ModVerifyAppSettings.cs b/src/ModVerify.CliApp/Settings/ModVerifyAppSettings.cs index 06107288..ba5272b8 100644 --- a/src/ModVerify.CliApp/Settings/ModVerifyAppSettings.cs +++ b/src/ModVerify.CliApp/Settings/ModVerifyAppSettings.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using AET.ModVerify.Reporting; using AET.ModVerify.Settings; @@ -7,16 +8,13 @@ namespace AET.ModVerify.App.Settings; public class AppReportSettings { public VerificationSeverity MinimumReportSeverity { get; init; } - + public string? SuppressionsPath { get; init; } public bool Verbose { get; init; } -} -public sealed class VerifyReportSettings : AppReportSettings -{ - public string? BaselinePath { get; init; } - public bool SearchBaselineLocally { get; init; } + public IReadOnlyList BaselinePaths { get; init; } = []; + public bool UseDefaultBaseline { get; init; } } @@ -39,13 +37,7 @@ public required VerifierServiceSettings VerifierServiceSettings public AppReportSettings ReportSettings { get; } = reportSettings ?? throw new ArgumentNullException(nameof(reportSettings)); } -internal abstract class AppSettingsBase(T reportSettings) : AppSettingsBase(reportSettings) - where T : AppReportSettings -{ - public new T ReportSettings { get; } = reportSettings ?? throw new ArgumentNullException(nameof(reportSettings)); -} - -internal sealed class AppVerifySettings(VerifyReportSettings reportSettings) : AppSettingsBase(reportSettings) +internal sealed class AppVerifySettings(AppReportSettings reportSettings) : AppSettingsBase(reportSettings) { public VerificationSeverity? AppFailsOnMinimumSeverity { get; init; } diff --git a/src/ModVerify.CliApp/Settings/SettingsBuilder.cs b/src/ModVerify.CliApp/Settings/SettingsBuilder.cs index fe315952..afa188ef 100644 --- a/src/ModVerify.CliApp/Settings/SettingsBuilder.cs +++ b/src/ModVerify.CliApp/Settings/SettingsBuilder.cs @@ -36,13 +36,14 @@ private AppVerifySettings BuildFromVerifyVerb(VerifyVerbOption verifyOptions) ParallelVerifiers = verifyOptions.Parallel ? 4 : 1, VerifiersProvider = new DefaultGameVerifiersProvider(), FailFastSettings = failFastSetting, + UseLiveVirtualFileSystem = true, GameVerifySettings = new GameVerifySettings { IgnoreAsserts = verifyOptions.IgnoreAsserts, - ThrowsOnMinimumSeverity = failFastSetting.IsFailFast + ThrowsOnMinimumSeverity = failFastSetting.IsFailFast ? failFastSetting.MinumumSeverity // The app shall not make a specific verifier throw, but it should always run to completion. - : null + : null } }, AppFailsOnMinimumSeverity = verifyOptions.MinimumFailureSeverity, @@ -51,27 +52,6 @@ private AppVerifySettings BuildFromVerifyVerb(VerifyVerbOption verifyOptions) void ValidateVerb() { - if (verifyOptions.SearchBaselineLocally && !string.IsNullOrEmpty(verifyOptions.Baseline)) - { - var searchOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.SearchBaselineLocally)); - var baselineOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.Baseline)); - throw new AppArgumentException($"Options {searchOption} and {baselineOption} cannot be used together."); - } - - if (verifyOptions.UseDefaultBaseline && !string.IsNullOrEmpty(verifyOptions.Baseline)) - { - var useDefaultOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.UseDefaultBaseline)); - var baselineOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.Baseline)); - throw new AppArgumentException($"Options {useDefaultOption} and {baselineOption} cannot be used together."); - } - - if (verifyOptions is { UseDefaultBaseline: true, SearchBaselineLocally: true }) - { - var useDefaultOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.UseDefaultBaseline)); - var searchOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.SearchBaselineLocally)); - throw new AppArgumentException($"Options {useDefaultOption} and {searchOption} cannot be used together."); - } - if (verifyOptions is { FailFast: true, MinimumFailureSeverity: null }) { var failFast = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.FailFast)); @@ -94,13 +74,12 @@ string GetReportDirectory() verifyOptions.OutputDirectory ?? "ModVerifyResults")); } - VerifyReportSettings BuildReportSettings() + AppReportSettings BuildReportSettings() { - return new VerifyReportSettings + return new AppReportSettings { - BaselinePath = verifyOptions.Baseline, + BaselinePaths = SplitBaselinePaths(verifyOptions.BaselinePaths), MinimumReportSeverity = verifyOptions.MinimumSeverity, - SearchBaselineLocally = verifyOptions.SearchBaselineLocally, UseDefaultBaseline = verifyOptions.UseDefaultBaseline, SuppressionsPath = verifyOptions.Suppressions, Verbose = verifyOptions.Verbose @@ -114,7 +93,9 @@ private AppBaselineSettings BuildFromCreateBaselineVerb(CreateBaselineVerbOption { VerifierServiceSettings = new VerifierServiceSettings { - ParallelVerifiers = baselineVerb.Parallel ? 4 : 1, + // Always sequential: baseline creation must be deterministic — error ordering + // and any other parallelism-sensitive behavior would otherwise vary between runs. + ParallelVerifiers = 1, VerifiersProvider = new DefaultGameVerifiersProvider(), GameVerifySettings = GameVerifySettings.Default, FailFastSettings = FailFastSetting.NoFailFast, @@ -130,11 +111,24 @@ AppReportSettings BuildReportSettings() { MinimumReportSeverity = baselineVerb.MinimumSeverity, SuppressionsPath = baselineVerb.Suppressions, - Verbose = baselineVerb.Verbose + Verbose = baselineVerb.Verbose, + BaselinePaths = SplitBaselinePaths(baselineVerb.BaselinePaths), + UseDefaultBaseline = baselineVerb.UseDefaultBaseline }; } } + private IReadOnlyList SplitBaselinePaths(string? rawPaths) + { + if (string.IsNullOrEmpty(rawPaths)) + return []; + var separator = _fileSystem.Path.PathSeparator; + return [.. + rawPaths!.Split([separator], StringSplitOptions.RemoveEmptyEntries) + .Select(p => _fileSystem.Path.GetFullPath(p)) + ]; + } + private VerificationTargetSettings BuildTargetSettings(BaseModVerifyOptions options) { var separator = _fileSystem.Path.PathSeparator; diff --git a/src/ModVerify.CliApp/Updates/ModVerifyUpdater.cs b/src/ModVerify.CliApp/Updates/ModVerifyUpdater.cs index af7d3697..03166fee 100644 --- a/src/ModVerify.CliApp/Updates/ModVerifyUpdater.cs +++ b/src/ModVerify.CliApp/Updates/ModVerifyUpdater.cs @@ -48,10 +48,12 @@ private async Task UpdateApplication(ApplicationUpdateOptions updateOptions, Mod return; } - var updater = new ModVerifyApplicationUpdater(updatableEnvironment, _serviceProvider); + var updater = new ModVerifyApplicationUpdater(updatableEnvironment, _serviceProvider, restartHostAfterUpdate: !updateOptions.NoRestart); var actualBranchName = updater.GetBranchNameFromRegistry(updateOptions.BranchName, false); - var branch = updater.CreateBranch(actualBranchName, updateOptions.ManifestUrl); + var branch = !string.IsNullOrEmpty(updateOptions.ServerUrl) + ? updater.CreateBranchFromServerUrl(updateOptions.ServerUrl!, actualBranchName) + : updater.CreateBranch(actualBranchName, updateOptions.ManifestUrl); using (ConsoleUtilities.CreateHorizontalFrame(length: 40, startWithNewLine: true, newLineAtEnd: true)) { @@ -83,7 +85,7 @@ await updater.CheckForUpdateAsync(branch, CancellationToken.None), if (mode == ModVerifyUpdateMode.InteractiveUpdate) { - var shallUpdate = ConsoleUtilities.UserYesNoQuestion("Do you want to update now?"); + var shallUpdate = ConsoleUtilities.UserYesNoQuestion("Do you want to update now?", defaultAnswer: true); if (!shallUpdate) return; } @@ -122,7 +124,20 @@ private async Task CheckForUpdateAndReport() Console.ForegroundColor = ConsoleColor.DarkGreen; Console.WriteLine("New Update Available!"); Console.ResetColor(); - Console.WriteLine($"Version: {updateInfo.NewVersion}, Download here: {updateInfo.DownloadLink}"); + if (_appEnvironment.IsUpdatable()) + { + Console.WriteLine($"Version: {updateInfo.NewVersion}, More info: {updateInfo.DownloadLink}"); + Console.WriteLine(); + Console.WriteLine( + $"To install the update, start {ModVerifyConstants.AppNameString} without any arguments, " + + "or run it with the 'updateApplication' verb."); + } + else + { + Console.WriteLine($"Version: {updateInfo.NewVersion}, Download here: {updateInfo.DownloadLink}"); + Console.WriteLine(); + Console.WriteLine("Download the new version from the link above and replace your current installation."); + } } } else diff --git a/src/ModVerify.CliApp/Updates/SelfUpdate/ModVerifyApplicationUpdater.cs b/src/ModVerify.CliApp/Updates/SelfUpdate/ModVerifyApplicationUpdater.cs index 9b97043b..406ce44a 100644 --- a/src/ModVerify.CliApp/Updates/SelfUpdate/ModVerifyApplicationUpdater.cs +++ b/src/ModVerify.CliApp/Updates/SelfUpdate/ModVerifyApplicationUpdater.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using AnakinRaW.ApplicationBase.Environment; @@ -8,10 +8,10 @@ namespace AET.ModVerify.App.Updates.SelfUpdate; - internal class ModVerifyApplicationUpdater( UpdatableApplicationEnvironment environment, - IServiceProvider serviceProvider) + IServiceProvider serviceProvider, + bool restartHostAfterUpdate = true) : ApplicationUpdater(environment, serviceProvider) { public override async Task CheckForUpdateAsync(ProductBranch branch, CancellationToken token = default) @@ -31,10 +31,7 @@ public override async Task CheckForUpdateAsync(ProductBranch bran public override async Task UpdateAsync(UpdateCatalog updateCatalog, CancellationToken token = default) { var updateResult = await UpdateService.UpdateAsync(updateCatalog, token).ConfigureAwait(false); - if (updateResult is null) - throw new InvalidOperationException("There is already an update running."); - - var resultHandler = new ModVerifyUpdateResultHandler(Environment, ServiceProvider); + var resultHandler = new ModVerifyUpdateResultHandler(Environment, ServiceProvider, restartHostAfterUpdate); await resultHandler.Handle(updateResult).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/src/ModVerify.CliApp/Updates/SelfUpdate/ModVerifyUpdateResultHandler.cs b/src/ModVerify.CliApp/Updates/SelfUpdate/ModVerifyUpdateResultHandler.cs index 42ab413a..1f6c3792 100644 --- a/src/ModVerify.CliApp/Updates/SelfUpdate/ModVerifyUpdateResultHandler.cs +++ b/src/ModVerify.CliApp/Updates/SelfUpdate/ModVerifyUpdateResultHandler.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; using AnakinRaW.ApplicationBase.Environment; using AnakinRaW.ApplicationBase.Update; @@ -9,9 +9,12 @@ namespace AET.ModVerify.App.Updates.SelfUpdate; internal sealed class ModVerifyUpdateResultHandler( UpdatableApplicationEnvironment applicationEnvironment, - IServiceProvider serviceProvider) + IServiceProvider serviceProvider, + bool restartHostAfterUpdate = true) : ApplicationUpdateResultHandler(applicationEnvironment, serviceProvider) { + protected override bool RestartHostAfterUpdate => restartHostAfterUpdate; + protected override Task ShowError(UpdateResult updateResult) { Console.WriteLine(); @@ -22,7 +25,10 @@ protected override Task ShowError(UpdateResult updateResult) protected override void RestartApplication(RestartReason reason) { Console.WriteLine(); - Console.WriteLine("Restarting application to complete update..."); + if (reason == RestartReason.Update && !restartHostAfterUpdate) + Console.WriteLine("Applying update and exiting; the application will not be relaunched."); + else + Console.WriteLine("Restarting application to complete update..."); base.RestartApplication(reason); } -} \ No newline at end of file +} diff --git a/src/ModVerify.CliApp/Utilities/ModVerifyConsoleUtilities.cs b/src/ModVerify.CliApp/Utilities/ModVerifyConsoleUtilities.cs index 9f9962b4..f6997647 100644 --- a/src/ModVerify.CliApp/Utilities/ModVerifyConsoleUtilities.cs +++ b/src/ModVerify.CliApp/Utilities/ModVerifyConsoleUtilities.cs @@ -2,6 +2,7 @@ using Figgle; using System; using System.Collections.Generic; +using System.Linq; using AET.ModVerify.Reporting.Baseline; namespace AET.ModVerify.App.Utilities; @@ -46,42 +47,58 @@ public static void WriteSelectedTarget(VerificationTarget target) Console.ResetColor(); } - public static void WriteBaselineInfo(VerificationBaseline baseline, string? filePath) + public static void WriteBaselineInfo(BaselineCollection baselines) { - if (baseline.IsEmpty) + var displayable = baselines.Where(b => !b.Baseline.IsEmpty).ToList(); + if (displayable.Count == 0) return; Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("Using Baseline:"); + Console.WriteLine(displayable.Count == 1 ? "Using Baseline:" : "Using Baselines:"); + Console.ResetColor(); + + for (var i = 0; i < displayable.Count; i++) + { + Console.WriteLine(); + WriteSingleBaseline(displayable[i], displayable.Count > 1 ? i + 1 : null); + } + } + + private static void WriteSingleBaseline(IdentifiedBaseline entry, int? index) + { + var baseline = entry.Baseline; + var isDefault = entry.Source == BaselineSource.EmbeddedDefault; + + if (index is not null) + { + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine($"[{index}]"); + } + Console.ForegroundColor = ConsoleColor.DarkGray; - - IList<(string, object)> baselineData = - [ + ConsoleUtilities.PrintAsTable([ + ("Source", isDefault ? "Default (embedded)" : entry.Identifier), ("Version", baseline.Version?.ToString(2) ?? "n/a"), - ("Is Default", filePath is null), ("Minimum Severity", baseline.MinimumSeverity.ToString()), - ("Entries", baseline.Count.ToString()) - ]; - if (!string.IsNullOrEmpty(filePath)) - baselineData.Add(("File Path", filePath)); - - ConsoleUtilities.PrintAsTable(baselineData, 120); + ("Entries", baseline.Count.ToString()), + ], 120); if (baseline.Target is not null) { Console.ForegroundColor = ConsoleColor.DarkMagenta; - Console.WriteLine("Baseline Target:"); + Console.WriteLine("Target:"); Console.ForegroundColor = ConsoleColor.DarkGray; + // Two-space prefix on each key indents the whole sub-table under "Target:". IList<(string, object)> targetData = [ - ("Name", baseline.Target.Name), - ("Type", baseline.Target.IsGame ? "Game" : "Mod"), - ("Engine", baseline.Target.Engine), - ("Version", baseline.Target.Version ?? "n/a"), + (" Name", baseline.Target.Name), + (" Type", baseline.Target.IsGame ? "Game" : "Mod"), + (" Engine", baseline.Target.Engine), + (" Version", baseline.Target.Version ?? "n/a"), ]; if (baseline.Target.Location is not null) - targetData.Add(("Location", baseline.Target.Location.TargetPath)); + targetData.Add((" Location", baseline.Target.Location.TargetPath)); ConsoleUtilities.PrintAsTable(targetData, 120); } diff --git a/src/ModVerify.CliApp/Utilities/Spinner.cs b/src/ModVerify.CliApp/Utilities/Spinner.cs index f25eddab..71feac7b 100644 --- a/src/ModVerify.CliApp/Utilities/Spinner.cs +++ b/src/ModVerify.CliApp/Utilities/Spinner.cs @@ -30,6 +30,7 @@ internal sealed class ConsoleSpinner : IAsyncDisposable private readonly CancellationTokenSource _cts = new(); private readonly Task _observedTask; private readonly bool _origCursorVisibility; + private readonly bool _canToggleCursor; private readonly string[] _animation; private int _frame; private int _lastTextLength; @@ -39,9 +40,20 @@ private ConsoleSpinner(Task observedTask, ConsoleSpinnerOptions options) _observedTask = observedTask; _options = options; _animation = options.Animation; - _origCursorVisibility = Console.CursorVisible; - if (_options.HideCursor) + // Console.CursorVisible throws "the handle is invalid" when stdout is redirected + // (CI, piped invocation, Start-Process -RedirectStandardOutput). Degrade gracefully. + try + { + _origCursorVisibility = Console.CursorVisible; + _canToggleCursor = true; + } + catch (IOException) + { + _canToggleCursor = false; + } + + if (_options.HideCursor && _canToggleCursor) Console.CursorVisible = false; SpinnerLoop().Forget(); @@ -134,7 +146,8 @@ public async Task CleanupAndFinishAsync() } await _options.Writer.FlushAsync(); - Console.CursorVisible = _origCursorVisibility; + if (_canToggleCursor) + Console.CursorVisible = _origCursorVisibility; } private async Task ClearTextAsync(int length) diff --git a/src/ModVerify/AssemblyInfo.cs b/src/ModVerify/AssemblyInfo.cs new file mode 100644 index 00000000..b2805cc0 --- /dev/null +++ b/src/ModVerify/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ModVerify.Test")] diff --git a/src/ModVerify/GameVerifierService.cs b/src/ModVerify/GameVerifierService.cs index efdf3264..5592a7be 100644 --- a/src/ModVerify/GameVerifierService.cs +++ b/src/ModVerify/GameVerifierService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using AET.ModVerify.Progress; @@ -13,11 +13,11 @@ namespace AET.ModVerify; internal sealed class GameVerifierService(IServiceProvider serviceProvider) : IGameVerifierService { public async Task VerifyAsync( - VerificationTarget verificationTarget, + VerificationTarget verificationTarget, VerifierServiceSettings settings, - VerificationBaseline baseline, + BaselineCollection baselines, SuppressionList suppressions, - IVerifyProgressReporter? progressReporter, + IVerifyProgressReporter? progressReporter, IGameEngineInitializationReporter? engineInitializationReporter, CancellationToken token = default) { @@ -25,12 +25,14 @@ public async Task VerifyAsync( throw new ArgumentNullException(nameof(verificationTarget)); if (settings == null) throw new ArgumentNullException(nameof(settings)); + if (baselines == null) + throw new ArgumentNullException(nameof(baselines)); using var pipeline = new GameVerifyPipeline( - verificationTarget, - settings, + verificationTarget, + settings, serviceProvider, - baseline, + baselines, suppressions, progressReporter, engineInitializationReporter); @@ -64,7 +66,7 @@ public async Task VerifyAsync( Errors = pipeline.Errors, Status = completionStatus, Target = verificationTarget, - UsedBaseline = baseline, + UsedBaselines = baselines, UsedSuppressions = suppressions, Verifiers = pipeline.Verifiers, Exception = exception diff --git a/src/ModVerify/GameVerifyPipeline.cs b/src/ModVerify/GameVerifyPipeline.cs index 4a7e0fb0..4c8bbcbe 100644 --- a/src/ModVerify/GameVerifyPipeline.cs +++ b/src/ModVerify/GameVerifyPipeline.cs @@ -18,24 +18,27 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using PG.StarWarsGame.Engine; +using PG.StarWarsGame.Engine.IO; namespace AET.ModVerify; internal sealed class GameVerifyPipeline : StepRunnerPipelineBase { private readonly List _verifiers = []; - private readonly List _errors = []; private readonly List _verificationSteps = []; - + private readonly GameEngineErrorCollection _engineErrorReporter = new(); private readonly VerificationTarget _verificationTarget; private readonly VerifierServiceSettings _serviceSettings; private readonly IVerifyProgressReporter? _progressReporter; private readonly IGameEngineInitializationReporter? _engineInitializationReporter; - private readonly VerificationBaseline _baseline; + private readonly BaselineCollection _baselines; private readonly SuppressionList _suppressions; + private VerificationErrors _errors = VerificationErrors.Empty; - internal IReadOnlyCollection Errors => [.._errors]; + private IStarWarsGameEngineHandle? _gameEngine; + + internal VerificationErrors Errors => _errors; internal IReadOnlyCollection Verifiers => [.. _verifiers]; @@ -45,7 +48,7 @@ public GameVerifyPipeline( VerificationTarget verificationTarget, VerifierServiceSettings serviceSettings, IServiceProvider serviceProvider, - VerificationBaseline baseline, + BaselineCollection baselines, SuppressionList suppressions, IVerifyProgressReporter? progressReporter = null, IGameEngineInitializationReporter? engineInitializationReporter = null) @@ -53,7 +56,7 @@ public GameVerifyPipeline( { _verificationTarget = verificationTarget ?? throw new ArgumentNullException(nameof(verificationTarget)); _serviceSettings = serviceSettings ?? throw new ArgumentNullException(nameof(serviceSettings)); - _baseline = baseline ?? throw new ArgumentNullException(nameof(baseline)); + _baselines = baselines ?? throw new ArgumentNullException(nameof(baselines)); _suppressions = suppressions ?? throw new ArgumentNullException(nameof(suppressions)); _progressReporter = progressReporter; _engineInitializationReporter = engineInitializationReporter; @@ -76,19 +79,22 @@ protected override AsyncStepRunner CreateRunner() protected override async Task PrepareCoreAsync(CancellationToken token) { _verifiers.Clear(); - _errors.Clear(); - - IStarWarsGameEngine gameEngine; + _errors = VerificationErrors.Empty; try { var engineService = ServiceProvider.GetRequiredService(); - gameEngine = await engineService.InitializeAsync( + Action? configureFs = _serviceSettings.UseLiveVirtualFileSystem + ? static fs => fs.UseLiveVirtualStrategy() + : null; + + _gameEngine = await engineService.InitializeAsync( _verificationTarget.Engine, _verificationTarget.Location, _engineErrorReporter, _engineInitializationReporter, false, + configureFs, CancellationToken.None).ConfigureAwait(false); } catch (Exception e) @@ -97,9 +103,9 @@ protected override async Task PrepareCoreAsync(CancellationToken token) throw; } - AddStep(new GameEngineErrorCollector(_engineErrorReporter, gameEngine, _serviceSettings.GameVerifySettings, ServiceProvider)); + AddStep(new GameEngineErrorCollector(_engineErrorReporter, _gameEngine, _serviceSettings.GameVerifySettings, ServiceProvider)); - foreach (var gameVerificationStep in CreateVerifiers(gameEngine)) + foreach (var gameVerificationStep in CreateVerifiers(_gameEngine)) AddStep(gameVerificationStep); } @@ -117,8 +123,14 @@ protected override void OnExecuteStarted() protected override void OnExecuteCompleted() { Logger?.LogInformation("Game verifiers finished."); - _errors.AddRange(GetReportableErrors(_verifiers.SelectMany(s => s.VerifyErrors))); - _progressReporter?.Report(1.0, $"Finished Verifying {_verificationTarget.Name}", + // Suppressions are policy filters and run first so they never contaminate + // baseline attribution; the baselines then categorize what remains as + // new (unseen), existing (still present), or resolved (gone since baseline). + var afterSuppressions = _verifiers + .SelectMany(s => s.VerifyErrors) + .ApplySuppressions(_suppressions); + _errors = _baselines.Categorize(afterSuppressions); + _progressReporter?.Report(1.0, $"Finished Verifying {_verificationTarget.Name}", VerifyProgress.ProgressType, default); } @@ -129,7 +141,7 @@ protected override void OnRunnerExecutionError(object sender, StepRunnerErrorEve var minSeverity = _serviceSettings.FailFastSettings.MinumumSeverity; var ignoreError = verificationException.Errors .Where(error => error.Severity >= minSeverity) - .All(error => _baseline.Contains(error) || _suppressions.Suppresses(error)); + .All(error => _baselines.Contains(error) || _suppressions.Suppresses(error)); if (ignoreError) return; } @@ -147,6 +159,8 @@ protected override void DisposeResources() _engineErrorReporter.Clear(); _aggregatedVerifyProgressReporter?.Dispose(); _aggregatedVerifyProgressReporter = null; + _gameEngine?.Dispose(); + _gameEngine = null; } private void AddStep(GameVerifier verifier) @@ -157,14 +171,6 @@ private void AddStep(GameVerifier verifier) _verifiers.Add(verifier); } - private IEnumerable GetReportableErrors(IEnumerable errors) - { - Logger?.LogDebug("Applying baseline and suppressions."); - // NB: We don't filter for severity here, as the individual reporters handle that. - // This allows better control over what gets reported. - return errors.ApplyBaseline(_baseline).ApplySuppressions(_suppressions); - } - private IEnumerable CreateVerifiers(IStarWarsGameEngine engine) { return _serviceSettings.VerifiersProvider diff --git a/src/ModVerify/IGameVerifierService.cs b/src/ModVerify/IGameVerifierService.cs index c814779b..7095c44f 100644 --- a/src/ModVerify/IGameVerifierService.cs +++ b/src/ModVerify/IGameVerifierService.cs @@ -1,4 +1,4 @@ -using System.Threading; +using System.Threading; using System.Threading.Tasks; using AET.ModVerify.Progress; using AET.ModVerify.Reporting; @@ -14,9 +14,9 @@ public interface IGameVerifierService Task VerifyAsync( VerificationTarget verificationTarget, VerifierServiceSettings settings, - VerificationBaseline baseline, + BaselineCollection baselines, SuppressionList suppressions, IVerifyProgressReporter? progressReporter, IGameEngineInitializationReporter? engineInitializationReporter, CancellationToken token = default); -} \ No newline at end of file +} diff --git a/src/ModVerify/ModVerify.csproj b/src/ModVerify/ModVerify.csproj index 385250ab..17b01529 100644 --- a/src/ModVerify/ModVerify.csproj +++ b/src/ModVerify/ModVerify.csproj @@ -26,13 +26,13 @@ - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/ModVerify/Reporting/Baseline/BaselineCollection.cs b/src/ModVerify/Reporting/Baseline/BaselineCollection.cs new file mode 100644 index 00000000..99099a5f --- /dev/null +++ b/src/ModVerify/Reporting/Baseline/BaselineCollection.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace AET.ModVerify.Reporting.Baseline; + +public sealed class BaselineCollection : IReadOnlyCollection +{ + public static readonly BaselineCollection Empty = new([]); + + private readonly IReadOnlyList _baselines; + + public int Count => _baselines.Count; + + public bool IsEmpty => _baselines.Count == 0; + + public BaselineCollection(IEnumerable baselines) + { + if (baselines is null) + throw new ArgumentNullException(nameof(baselines)); + + var list = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var b in baselines) + { + if (b is null) + throw new ArgumentException("Baseline entries must not be null.", nameof(baselines)); + if (!seen.Add(b.Identifier)) + throw new ArgumentException($"Baseline identifier '{b.Identifier}' is not unique within the collection.", nameof(baselines)); + list.Add(b); + } + _baselines = list; + } + + public bool Contains(VerificationError error) + { + foreach (var entry in _baselines) + { + if (entry.Baseline.Contains(error)) + return true; + } + return false; + } + + public bool TryGetMatchingBaseline(VerificationError error, [NotNullWhen(true)] out string? identifier) + { + foreach (var entry in _baselines) + { + if (entry.Baseline.Contains(error)) + { + identifier = entry.Identifier; + return true; + } + } + identifier = null; + return false; + } + + public IEnumerable Apply(IEnumerable errors) + { + if (errors is null) + throw new ArgumentNullException(nameof(errors)); + if (_baselines.Count == 0) + return errors; + return errors.Where(e => !Contains(e)); + } + + public VerificationErrors Categorize(IEnumerable errors) + { + if (errors is null) + throw new ArgumentNullException(nameof(errors)); + + var newErrors = new List(); + var existingErrors = new Dictionary>(StringComparer.Ordinal); + foreach (var entry in _baselines) + existingErrors[entry.Identifier] = []; + + foreach (var error in errors) + { + if (TryGetMatchingBaseline(error, out var identifier)) + existingErrors[identifier].Add(error); + else + newErrors.Add(error); + } + + var resolvedErrors = new Dictionary>(StringComparer.Ordinal); + foreach (var entry in _baselines) + { + var seen = new HashSet(existingErrors[entry.Identifier]); + var solved = new List(); + foreach (var baselineError in entry.Baseline) + { + if (!seen.Contains(baselineError)) + solved.Add(baselineError); + } + resolvedErrors[entry.Identifier] = solved; + } + + var readOnlyExisting = new Dictionary>(StringComparer.Ordinal); + foreach (var kvp in existingErrors) + readOnlyExisting[kvp.Key] = kvp.Value; + + return new VerificationErrors(newErrors, readOnlyExisting, resolvedErrors); + } + + public IEnumerator GetEnumerator() => _baselines.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Baseline/BaselineSource.cs b/src/ModVerify/Reporting/Baseline/BaselineSource.cs new file mode 100644 index 00000000..e018e418 --- /dev/null +++ b/src/ModVerify/Reporting/Baseline/BaselineSource.cs @@ -0,0 +1,7 @@ +namespace AET.ModVerify.Reporting.Baseline; + +public enum BaselineSource +{ + File, + EmbeddedDefault, +} \ No newline at end of file diff --git a/src/ModVerify/Reporting/Baseline/IdentifiedBaseline.cs b/src/ModVerify/Reporting/Baseline/IdentifiedBaseline.cs new file mode 100644 index 00000000..7726d84c --- /dev/null +++ b/src/ModVerify/Reporting/Baseline/IdentifiedBaseline.cs @@ -0,0 +1,21 @@ +using System; + +namespace AET.ModVerify.Reporting.Baseline; + +public sealed record IdentifiedBaseline +{ + public string Identifier { get; } + + public VerificationBaseline Baseline { get; } + + public BaselineSource Source { get; } + + public IdentifiedBaseline(string identifier, VerificationBaseline baseline, BaselineSource source) + { + if (string.IsNullOrEmpty(identifier)) + throw new ArgumentException("Identifier must be non-empty.", nameof(identifier)); + Identifier = identifier; + Baseline = baseline ?? throw new ArgumentNullException(nameof(baseline)); + Source = source; + } +} diff --git a/src/ModVerify/Reporting/Engine/XmlParseErrorReporter.cs b/src/ModVerify/Reporting/Engine/XmlParseErrorReporter.cs index c8cae051..6410e8d4 100644 --- a/src/ModVerify/Reporting/Engine/XmlParseErrorReporter.cs +++ b/src/ModVerify/Reporting/Engine/XmlParseErrorReporter.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO.Abstractions; -using System.Xml; using AET.ModVerify.Utilities; using AET.ModVerify.Verifiers; using Microsoft.Extensions.DependencyInjection; diff --git a/src/ModVerify/Reporting/Reporters/Console/ConsoleReporter.cs b/src/ModVerify/Reporting/Reporters/Console/ConsoleReporter.cs index 52665505..79892f70 100644 --- a/src/ModVerify/Reporting/Reporters/Console/ConsoleReporter.cs +++ b/src/ModVerify/Reporting/Reporters/Console/ConsoleReporter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -10,7 +10,7 @@ internal class ConsoleReporter(ConsoleReporterSettings settings, IServiceProvide { public override Task ReportAsync(VerificationResult verificationResult) { - var filteredErrors = FilteredErrors(verificationResult.Errors).OrderByDescending(x => x.Severity).ToList(); + var filteredErrors = FilteredErrors(verificationResult.Errors.NewErrors).OrderByDescending(x => x.Severity).ToList(); PrintErrorStats(verificationResult, filteredErrors); Console.WriteLine(); return Task.CompletedTask; @@ -24,7 +24,10 @@ private void PrintErrorStats(VerificationResult verificationResult, List x.Severity); + var groupedBySeverity = verificationResult.Errors.NewErrors.GroupBy(x => x.Severity); foreach (var group in groupedBySeverity) Console.WriteLine($" Severity {group.Key}: {group.Count()}"); Console.WriteLine(); if (filteredErrors.Count == 0) { - if (verificationResult.Errors.Count != 0) + if (verificationResult.Errors.NewErrors.Count != 0) Console.WriteLine("Some errors are not displayed to the console. Please check the created output files."); return; } @@ -62,4 +65,32 @@ private void PrintErrorStats(VerificationResult verificationResult, List x.Value.Count); + if (resolvedCount == 0) + return; + + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine( + $"Reduced issues: {resolvedCount} error(s) present in the baseline are no longer reported."); + Console.ResetColor(); + + if (Settings.Verbose +#if DEBUG + || true +#endif + ) + { + foreach (var baseline in resolvedErrors) + { + foreach (var error in baseline.Value) + Console.WriteLine($" [Resolved] [{baseline.Key}] [{error.Id}] Message={error.Message}"); + } + } + + Console.WriteLine(); + } } \ No newline at end of file diff --git a/src/ModVerify/Reporting/Reporters/JSON/JsonReporter.cs b/src/ModVerify/Reporting/Reporters/JSON/JsonReporter.cs index ac11bb6f..194cddbf 100644 --- a/src/ModVerify/Reporting/Reporters/JSON/JsonReporter.cs +++ b/src/ModVerify/Reporting/Reporters/JSON/JsonReporter.cs @@ -29,7 +29,7 @@ private JsonVerificationReport CreateJsonReport(VerificationResult result) IEnumerable errors; if (Settings.AggregateResults) { - errors = result.Errors + errors = result.Errors.NewErrors .OrderByDescending(x => x.Severity) .ThenBy(x => x.Id) .GroupBy(x => new GroupKey(x.Asset, x.Id, x.VerifierChain)) @@ -44,7 +44,7 @@ private JsonVerificationReport CreateJsonReport(VerificationResult result) } else { - errors = result.Errors + errors = result.Errors.NewErrors .OrderByDescending(x => x.Severity) .ThenBy(x => x.Id) .Select(x => new JsonVerificationError(x, Settings.Verbose)); diff --git a/src/ModVerify/Reporting/Reporters/Text/TextFileReporter.cs b/src/ModVerify/Reporting/Reporters/Text/TextFileReporter.cs index f3e81266..10a2f7f6 100644 --- a/src/ModVerify/Reporting/Reporters/Text/TextFileReporter.cs +++ b/src/ModVerify/Reporting/Reporters/Text/TextFileReporter.cs @@ -28,7 +28,7 @@ private async Task ReportWhole(VerificationResult result) await WriteHeader(result.Target, DateTime.Now, null, streamWriter); - foreach (var error in result.Errors.OrderBy(x => x.Id)) + foreach (var error in result.Errors.NewErrors.OrderBy(x => x.Id)) await WriteError(error, streamWriter); } @@ -36,7 +36,7 @@ private async Task ReportWhole(VerificationResult result) private async Task ReportByVerifier(VerificationResult result) { var time = DateTime.Now; - var grouped = result.Errors.GroupBy(x => x.VerifierChain.Last().Name); + var grouped = result.Errors.NewErrors.GroupBy(x => x.VerifierChain.Last().Name); foreach (var group in grouped) await ReportToSingleFile(group, result.Target, time); } diff --git a/src/ModVerify/Reporting/VerificationErrors.cs b/src/ModVerify/Reporting/VerificationErrors.cs new file mode 100644 index 00000000..7570b27d --- /dev/null +++ b/src/ModVerify/Reporting/VerificationErrors.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; + +namespace AET.ModVerify.Reporting; + +public sealed record VerificationErrors +{ + public static readonly VerificationErrors Empty = new( + [], + new Dictionary>(), + new Dictionary>()); + + public IReadOnlyList NewErrors { get; } + + public IReadOnlyDictionary> ExistingErrors { get; } + + public IReadOnlyDictionary> ResolvedErrors { get; } + + public VerificationErrors( + IReadOnlyList newErrors, + IReadOnlyDictionary> existingErrors, + IReadOnlyDictionary> resolvedErrors) + { + NewErrors = newErrors ?? throw new ArgumentNullException(nameof(newErrors)); + ExistingErrors = existingErrors ?? throw new ArgumentNullException(nameof(existingErrors)); + ResolvedErrors = resolvedErrors ?? throw new ArgumentNullException(nameof(resolvedErrors)); + } +} diff --git a/src/ModVerify/Reporting/VerificationResult.cs b/src/ModVerify/Reporting/VerificationResult.cs index 97c08e59..7333623d 100644 --- a/src/ModVerify/Reporting/VerificationResult.cs +++ b/src/ModVerify/Reporting/VerificationResult.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using AET.ModVerify.Reporting.Baseline; using AET.ModVerify.Reporting.Suppressions; @@ -10,13 +10,13 @@ public sealed record VerificationResult { public required VerificationCompletionStatus Status { get; init; } - public required IReadOnlyCollection Errors + public required VerificationErrors Errors { get; init => field = value ?? throw new ArgumentNullException(nameof(value)); } - public required VerificationBaseline UsedBaseline + public required BaselineCollection UsedBaselines { get; init => field = value ?? throw new ArgumentNullException(nameof(value)); @@ -28,8 +28,8 @@ public required SuppressionList UsedSuppressions init => field = value ?? throw new ArgumentNullException(nameof(value)); } - public required IReadOnlyCollection Verifiers - { + public required IReadOnlyCollection Verifiers + { get; init => field = value ?? throw new ArgumentNullException(nameof(value)); } @@ -43,4 +43,4 @@ public required VerificationTarget Target public required TimeSpan Duration { get; init; } public Exception? Exception { get; init; } -} \ No newline at end of file +} diff --git a/src/ModVerify/Settings/VerifierServiceSettings.cs b/src/ModVerify/Settings/VerifierServiceSettings.cs index 762806de..0feb6acc 100644 --- a/src/ModVerify/Settings/VerifierServiceSettings.cs +++ b/src/ModVerify/Settings/VerifierServiceSettings.cs @@ -9,4 +9,6 @@ public sealed class VerifierServiceSettings public FailFastSetting FailFastSettings { get; init; } = FailFastSetting.NoFailFast; public int ParallelVerifiers { get; init; } = 4; + + public bool UseLiveVirtualFileSystem { get; init; } = false; } \ No newline at end of file diff --git a/src/ModVerify/Utilities/VerificationErrorExtensions.cs b/src/ModVerify/Utilities/VerificationErrorExtensions.cs index f7eefbcc..53a93b5c 100644 --- a/src/ModVerify/Utilities/VerificationErrorExtensions.cs +++ b/src/ModVerify/Utilities/VerificationErrorExtensions.cs @@ -12,13 +12,22 @@ public static class VerificationErrorExtensions { public IEnumerable ApplyBaseline(VerificationBaseline baseline) { - if (errors == null) + if (errors == null) throw new ArgumentNullException(nameof(errors)); if (baseline == null) throw new ArgumentNullException(nameof(baseline)); return baseline.Apply(errors); } + public IEnumerable ApplyBaselines(BaselineCollection baselines) + { + if (errors == null) + throw new ArgumentNullException(nameof(errors)); + if (baselines == null) + throw new ArgumentNullException(nameof(baselines)); + return baselines.Apply(errors); + } + public IEnumerable ApplySuppressions(SuppressionList suppressions) { if (errors == null) diff --git a/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs b/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs index 0df4dd69..86a7676c 100644 --- a/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs +++ b/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs @@ -236,7 +236,7 @@ private void VerifyModelClass(ModelClass modelClass, IReadOnlyCollection if (!CheckBinaryCorruptedFileIsActuallyRenderable(alaFileName, out var actualFilePath)) { var message = - $"Possible file CRC32 collision: '{fileName}' was requested but '{actualFilePath}' was found by the engine."; + $"Possible file CRC32 collision: '{alaFileName}' was requested but '{actualFilePath}' was found by the engine."; AddError(VerificationError.Create( this, VerifierErrorCodes.UnexpectedFileLoad, @@ -246,7 +246,7 @@ private void VerifyModelClass(ModelClass modelClass, IReadOnlyCollection // there are simply more chances for a CRC32 collision. VerificationSeverity.Information, contextInfo, - NormalizeFileName(fileName))); + NormalizeFileName(alaFileName))); } else { @@ -439,7 +439,7 @@ private void VerifyShaderExists(IPetroglyphFileHolder model, string shader, IRea private bool CheckBinaryCorruptedFileIsActuallyRenderable(string fileName, out string actualFilePath) { var filePath = FileSystem.Path.Join(@"DATA\ART\MODELS", fileName); - var exists = GameEngine.GameRepository.FileExists(filePath, false, out _, out actualFilePath!); + var exists = GameEngine.GameRepository.ModelRepository.FileExists(filePath, false, out _, out actualFilePath!); Debug.Assert(exists); var extension = FileSystem.Path.GetExtension(actualFilePath); diff --git a/src/ModVerify/Verifiers/Engine/HardcodedAssetsVerifier.cs b/src/ModVerify/Verifiers/Engine/HardcodedAssetsVerifier.cs index e254a782..58c8753c 100644 --- a/src/ModVerify/Verifiers/Engine/HardcodedAssetsVerifier.cs +++ b/src/ModVerify/Verifiers/Engine/HardcodedAssetsVerifier.cs @@ -1,8 +1,9 @@ -using System; -using System.Threading; -using AET.ModVerify.Settings; +using AET.ModVerify.Settings; using AET.ModVerify.Verifiers.Commons; using PG.StarWarsGame.Engine; +using System; +using System.Threading; +using AET.ModVerify.Reporting; namespace AET.ModVerify.Verifiers.Engine; @@ -18,7 +19,9 @@ public HardcodedAssetsVerifier(IStarWarsGameEngine gameEngine, GameVerifySetting public override void Verify(CancellationToken token) { - OnProgress(0.0d, "Verifying Hardcoded Models"); + OnProgress(0.0d, "Verifying Hardcoded Shaders"); + VerifyShaders(token); + OnProgress(0.5d, "Verifying Hardcoded Models"); VerifyModels(token); OnProgress(1.0, null); } @@ -33,4 +36,31 @@ private void VerifyModels(CancellationToken token) foreach (var error in _modelVerifier.VerifyErrors) AddError(error); } + + // TODO: Create a shader verifier that reports a warning if a shader is located at the game's root + // as this can cause compatibility issues with mods and in general is not recommended. + + private void VerifyShaders(CancellationToken token) + { + var repo = GameEngine.GameRepository.EffectsRepository; + // The engine loads the following shaders at startup + foreach (var shadersName in HardcodedEngineAssets.HardcodedEngineShadersNames) + { + token.ThrowIfCancellationRequested(); + + if (!repo.FileExists(shadersName)) + AddError(VerificationError.Create(this, VerifierErrorCodes.FileNotFound, + $"Unable to find shader '{shadersName}'", VerificationSeverity.Error, [], shadersName)); + } + + // The engine loads the following shaders on terrain load. For simplicity, we try to find them once here + foreach (var shadersName in HardcodedEngineAssets.HardcodedTerrainShadersNames) + { + token.ThrowIfCancellationRequested(); + + if (!repo.FileExists(shadersName)) + AddError(VerificationError.Create(this, VerifierErrorCodes.FileNotFound, + $"Unable to find terrain shader '{shadersName}'", VerificationSeverity.Error, [], shadersName)); + } + } } \ No newline at end of file diff --git a/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs b/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs index 5c567f07..1bbc5b03 100644 --- a/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs +++ b/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs @@ -14,7 +14,7 @@ namespace AET.ModVerify.Verifiers.GuiDialogs; -sealed class GuiDialogsVerifier : GameVerifier +public sealed class GuiDialogsVerifier : GameVerifier { internal const string DefaultComponentIdentifier = "<>"; diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs index feb8e623..474cdb3f 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs @@ -3,6 +3,7 @@ using System.IO.Abstractions; using System.Runtime.InteropServices; using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; using PG.StarWarsGame.Engine.Utilities; using Testably.Abstractions; using Xunit; @@ -20,6 +21,39 @@ protected override IFileSystem CreateFileSystem() protected abstract override void ConfigureStrategy(PetroglyphFileSystem fs); + /// + /// Constructs a fresh instance of the strategy under test, so generic suite + /// tests () can exercise it directly without + /// fighting the 's ownership of the active strategy. + /// + private protected abstract FileExistsStrategy CreateStrategyForCleanupTest(); + + [Fact] + public void Cleanup_CalledTwice_DoesNotThrow() + { + var strategy = CreateStrategyForCleanupTest(); + strategy.Cleanup(); + strategy.Cleanup(); + } + + [Fact] + public void FileExists_AfterCleanup_RemainsUsable() + { + var dir = NewTempDir(); + var file = FileSystem.Path.Combine(dir, "test.txt"); + FileSystem.File.WriteAllText(file, "x"); + + // Warm up the strategy. + Assert.True(FileExists("test.txt".AsSpan(), dir.AsSpan())); + + // Cleanup must not permanently break the strategy. + PgFileSystem.Strategy.Cleanup(); + + // Must still serve correct lookups after Cleanup. + Assert.True(FileExists("test.txt".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("missing.txt".AsSpan(), dir.AsSpan())); + } + protected virtual void AssertResolvedPath(string expectedOnDiskPath, string actualResult) { var expected = expectedOnDiskPath.Replace('\\', FileSystem.Path.DirectorySeparatorChar).Replace('/', FileSystem.Path.DirectorySeparatorChar); @@ -53,10 +87,6 @@ protected string NewTempDir() return dir; } - // --------------------------------------------------------------------------------------------- - // Shared tests — every strategy must satisfy. - // --------------------------------------------------------------------------------------------- - [Theory] [InlineData("/gameDir")] [InlineData(null)] diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs new file mode 100644 index 00000000..d83bafe5 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs @@ -0,0 +1,383 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.IO.Abstractions; +using System.Reflection; +using System.Threading.Tasks; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +#if Windows +public sealed class LiveVirtualFileExistsStrategy_Windows : LiveVirtualFileExistsStrategyTests +{ + protected override void ConfigureStrategy(PetroglyphFileSystem fs) + { + fs.UseLiveVirtualStrategy(new WineFileExistsStrategy(fs.UnderlyingFileSystem)); + } +} +#endif + +public sealed class LiveVirtualFileExistsStrategy_Wine : LiveVirtualFileExistsStrategyTests +{ + protected override void ConfigureStrategy(PetroglyphFileSystem fs) + { + fs.UseLiveVirtualStrategy(new WineFileExistsStrategy(fs.UnderlyingFileSystem)); + } +} + +public abstract class LiveVirtualFileExistsStrategyTests : VirtualFileExistsStrategyBaseTests +{ + /// + /// Hard cap on how long we'll wait for the OS to deliver a watcher event. The OS delivers + /// events asynchronously; we poll the cache state at until the + /// expected condition holds, only failing if the deadline passes. + /// + private static readonly TimeSpan WatcherEventTimeout = TimeSpan.FromSeconds(30); + + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50); + + private protected override void ConfigureStrategy(PetroglyphFileSystem fs, FileExistsStrategy underlying) + => fs.UseLiveVirtualStrategy(underlying); + + private protected override FileExistsStrategy CreateStrategyForCleanupTest() + => new LiveVirtualFileExistsStrategy(FileSystem, new WineFileExistsStrategy(FileSystem)); + + [Fact] + public async Task FileExists_AfterFileDeletedOnDisk_ReportsMissing() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var file = FileSystem.Path.Combine(dataDir, "foo.xml"); + FileSystem.File.WriteAllText(file, "x"); + + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(file), + () => !FileExists("Data/foo.xml".AsSpan(), dir.AsSpan()), + "snapshot to refresh after Data/foo.xml was deleted on disk"); + } + + [Fact] + public async Task FileExists_AfterFileCreatedOnDisk_ReportsPresent() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "seed.xml"), "x"); + + // Prime the snapshot. + Assert.True(FileExists("Data/seed.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/new.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "new.xml"), "y"), + () => FileExists("Data/new.xml".AsSpan(), dir.AsSpan()), + "snapshot to refresh after Data/new.xml was created on disk"); + } + + [Fact] + public async Task FileExists_AfterFileRenamed_OldNameMissingNewNamePresent() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var oldPath = FileSystem.Path.Combine(dataDir, "old.xml"); + var newPath = FileSystem.Path.Combine(dataDir, "new.xml"); + FileSystem.File.WriteAllText(oldPath, "x"); + + Assert.True(FileExists("Data/old.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/new.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Move(oldPath, newPath), + () => !FileExists("Data/old.xml".AsSpan(), dir.AsSpan()) + && FileExists("Data/new.xml".AsSpan(), dir.AsSpan()), + "snapshot to reflect the rename of Data/old.xml to Data/new.xml"); + } + + [Fact] + public async Task FileExists_AfterDirectoryRenamed_OldPathMissingNewPathPresent() + { + var dir = NewTempDir(); + var oldDir = FileSystem.Path.Combine(dir, "OldData"); + var newDir = FileSystem.Path.Combine(dir, "NewData"); + FileSystem.Directory.CreateDirectory(oldDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(oldDir, "foo.xml"), "x"); + + Assert.True(FileExists("OldData/foo.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.Directory.Move(oldDir, newDir), + () => !FileExists("OldData/foo.xml".AsSpan(), dir.AsSpan()) + && FileExists("NewData/foo.xml".AsSpan(), dir.AsSpan()), + "cached descendants of OldData to invalidate after directory rename"); + } + + [Fact] + public async Task FileExists_AfterDirectoryDeleted_AllDescendantsInvalidated() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + var subDir = FileSystem.Path.Combine(dataDir, "Sub"); + FileSystem.Directory.CreateDirectory(subDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "a.xml"), "1"); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(subDir, "b.xml"), "2"); + + Assert.True(FileExists("Data/a.xml".AsSpan(), dir.AsSpan())); + Assert.True(FileExists("Data/Sub/b.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.Directory.Delete(dataDir, recursive: true), + () => !FileExists("Data/a.xml".AsSpan(), dir.AsSpan()) + && !FileExists("Data/Sub/b.xml".AsSpan(), dir.AsSpan()), + "cached descendants of Data/ to invalidate after recursive directory delete"); + } + + [Fact] + public void SwapStrategy_LiveThenWineThenLive_FreshUnderlyingHandlesOutOfBaseLookups() + { + var root = NewTempDir(); + var gameDir = FileSystem.Path.Combine(root, "gameDir"); + var outsideDir = FileSystem.Path.Combine(root, "outside"); + FileSystem.Directory.CreateDirectory(gameDir); + FileSystem.Directory.CreateDirectory(outsideDir); + var insideFile = FileSystem.Path.Combine(gameDir, "in.xml"); + var outsideFile = FileSystem.Path.Combine(outsideDir, "out.xml"); + FileSystem.File.WriteAllText(insideFile, "i"); + FileSystem.File.WriteAllText(outsideFile, "o"); + + // First Live, with trackingA as the out-of-base fallback. + var trackingA = new TrackingFileExistsStrategy(FileSystem) { ReturnValue = true, ResolvedPath = outsideFile }; + PgFileSystem.UseLiveVirtualStrategy(trackingA); + + Assert.True(FileExists("in.xml".AsSpan(), gameDir.AsSpan())); // snapshot path, no delegation + Assert.True(FileExists(outsideFile.AsSpan(), gameDir.AsSpan())); // out-of-base → trackingA + Assert.Equal(1, trackingA.CallCount); + + // Swap to Wine. SwapStrategy disposes the previous Live, which also disposes trackingA. + PgFileSystem.UseWineStrategy(); + + // Swap back to Live with a brand-new tracking underlying. + var trackingB = new TrackingFileExistsStrategy(FileSystem) { ReturnValue = true, ResolvedPath = outsideFile }; + PgFileSystem.UseLiveVirtualStrategy(trackingB); + + // Out-of-base lookup must be routed through the NEW underlying. The old trackingA must + // not be touched anymore — this is the assertion that catches stale references. + Assert.True(FileExists(outsideFile.AsSpan(), gameDir.AsSpan())); + Assert.Equal(1, trackingA.CallCount); + Assert.Equal(1, trackingB.CallCount); + + // And the second Live owns its own snapshot store, so in-base lookups still bypass the + // underlying. + Assert.True(FileExists("in.xml".AsSpan(), gameDir.AsSpan())); + Assert.Equal(1, trackingB.CallCount); + } + + [Fact] + public async Task FileExists_TracksMultipleBaseDirectoriesIndependently() + { + var root = NewTempDir(); + var gameDir = FileSystem.Path.Combine(root, "gameDir"); + var workshopDir = FileSystem.Path.Combine(root, "workshops", "myMod"); + FileSystem.Directory.CreateDirectory(gameDir); + FileSystem.Directory.CreateDirectory(workshopDir); + var gameFile = FileSystem.Path.Combine(gameDir, "game.xml"); + var workshopFile = FileSystem.Path.Combine(workshopDir, "mod.xml"); + FileSystem.File.WriteAllText(gameFile, "x"); + FileSystem.File.WriteAllText(workshopFile, "y"); + + // Prime watchers for both base directories. + Assert.True(FileExists("game.xml".AsSpan(), gameDir.AsSpan())); + Assert.True(FileExists("mod.xml".AsSpan(), workshopDir.AsSpan())); + + // A change under the gameDir base must invalidate the gameDir snapshot… + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(gameFile), + () => !FileExists("game.xml".AsSpan(), gameDir.AsSpan()), + "gameDir snapshot to refresh after game.xml deleted"); + + // …but the workshop snapshot must still be live and serve mod.xml unchanged. + Assert.True(FileExists("mod.xml".AsSpan(), workshopDir.AsSpan())); + + // And the converse — deleting under workshopDir must update only that base's snapshot. + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(workshopFile), + () => !FileExists("mod.xml".AsSpan(), workshopDir.AsSpan()), + "workshopDir snapshot to refresh after mod.xml deleted"); + } + + [Fact] + public async Task FileExists_NewDirectoryUnderTrackedBase_FirstLookupSnapshotsThenCacheServes() + { + var dir = NewTempDir(); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dir, "seed.xml"), "x"); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + ConfigureStrategy(PgFileSystem, tracking); + + // Prime the watcher on the base directory. + Assert.True(FileExists("seed.xml".AsSpan(), dir.AsSpan())); + + // Create a new directory + file under the watched base after the watcher is up. + var newDir = FileSystem.Path.Combine(dir, "NewDir"); + FileSystem.Directory.CreateDirectory(newDir); + var newFile = FileSystem.Path.Combine(newDir, "foo.xml"); + FileSystem.File.WriteAllText(newFile, "y"); + + // Wait for the watcher to invalidate the base directory's cache after the create. + await AwaitCacheInvalidationAsync( + () => { /* disk action already done */ }, + () => FileExists("NewDir/foo.xml".AsSpan(), dir.AsSpan()), + "first lookup of NewDir/foo.xml to succeed against the freshly-snapshotted directory"); + + var afterFirstLookup = tracking.CallCount; + + // Second lookup — the snapshot for NewDir is now in the store, so this is a cache hit. + // Neither the underlying tracking strategy nor the disk should be re-consulted. + Assert.True(FileExists("NewDir/foo.xml".AsSpan(), dir.AsSpan())); + Assert.Equal(afterFirstLookup, tracking.CallCount); + + // Underlying must never be called for in-base-dir paths regardless of lookup count. + Assert.Equal(0, tracking.CallCount); + } + + [Fact] + public async Task WatcherError_BrokenWatcher_StrategyRecoversAndKeepsTrackingOnNextLookup() + { + // There's no portable way to make a real FileSystemWatcher fire Error (buffer overflow + // is flaky/slow; root deletion is OS-dependent), so we synthesize the Error path by + // invoking the strategy's private handler with the live watcher as sender. We do not + // assert any internal state — only that the strategy keeps doing its job: lookups still + // resolve, and subsequent disk changes are still picked up via a re-armed watcher. + var baseDir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(baseDir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var file = FileSystem.Path.Combine(dataDir, "foo.xml"); + FileSystem.File.WriteAllText(file, "x"); + + // Prime: the live strategy installs a watcher and snapshots the directory. + Assert.True(FileExists("Data/foo.xml".AsSpan(), baseDir.AsSpan())); + + var strategy = GetActiveLiveStrategy(); + InvokeOnWatcherError(strategy, GetWatchers(strategy)[baseDir], new ErrorEventArgs(new IOException("simulated"))); + + // 1) Lookups still resolve correctly after the Error. + Assert.True(FileExists("Data/foo.xml".AsSpan(), baseDir.AsSpan())); + + // 2) The strategy keeps tracking: a subsequent disk change is still reflected. + // (Implicitly verifies the next lookup re-armed a working watcher.) + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(file), + () => !FileExists("Data/foo.xml".AsSpan(), baseDir.AsSpan()), + "snapshot to invalidate after Data/foo.xml deleted (post-Error rebuild)"); + } + + [Fact] + public async Task WatcherError_OneOfManyRoots_OtherRootStillTracksChanges() + { + // An Error on one root must not impair the strategy's ability to track changes + // under unrelated roots, nor prevent the broken root from recovering on next use. + var root = NewTempDir(); + var gameDir = FileSystem.Path.Combine(root, "gameDir"); + var workshopDir = FileSystem.Path.Combine(root, "workshops", "myMod"); + FileSystem.Directory.CreateDirectory(gameDir); + FileSystem.Directory.CreateDirectory(workshopDir); + var gameFile = FileSystem.Path.Combine(gameDir, "g.xml"); + var workshopFile = FileSystem.Path.Combine(workshopDir, "m.xml"); + FileSystem.File.WriteAllText(gameFile, "g"); + FileSystem.File.WriteAllText(workshopFile, "m"); + + // Prime both bases. + Assert.True(FileExists("g.xml".AsSpan(), gameDir.AsSpan())); + Assert.True(FileExists("m.xml".AsSpan(), workshopDir.AsSpan())); + + // Synthesize an Error on the gameDir watcher only. + var strategy = GetActiveLiveStrategy(); + InvokeOnWatcherError(strategy, GetWatchers(strategy)[gameDir], new ErrorEventArgs(new IOException("simulated"))); + + // 1) The workshop watcher is still live — deleting a file there invalidates its cache. + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(workshopFile), + () => !FileExists("m.xml".AsSpan(), workshopDir.AsSpan()), + "workshop snapshot to invalidate after m.xml deleted; gameDir Error must not affect it"); + + // 2) The broken root still serves lookups (next call rebuilds snapshot + re-arms watcher). + Assert.True(FileExists("g.xml".AsSpan(), gameDir.AsSpan())); + + // 3) After re-arm, the gameDir watcher tracks changes again. + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(gameFile), + () => !FileExists("g.xml".AsSpan(), gameDir.AsSpan()), + "gameDir snapshot to invalidate after g.xml deleted (post-Error rebuild)"); + } + + [Fact] + public async Task Cleanup_RemovesWatchersAndClearsCache_WatchersReinstalledOnNextLookup() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var file = FileSystem.Path.Combine(dataDir, "foo.xml"); + FileSystem.File.WriteAllText(file, "x"); + + // Prime: install a watcher and warm the snapshot. + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + + var strategyBefore = GetActiveLiveStrategy(); + Assert.True(GetWatchers(strategyBefore).ContainsKey(dir)); + + // Cleanup: teardown watchers and clear the snapshot cache. + strategyBefore.Cleanup(); + + // Lookups must still work after Cleanup — the strategy re-snapshots lazily. + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + + // After the first post-cleanup lookup the watcher must be re-armed: disk changes are tracked again. + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(file), + () => !FileExists("Data/foo.xml".AsSpan(), dir.AsSpan()), + "snapshot to invalidate after Data/foo.xml deleted (post-Cleanup re-arm)"); + } + + private LiveVirtualFileExistsStrategy GetActiveLiveStrategy() + { + return (LiveVirtualFileExistsStrategy)PgFileSystem.Strategy; + } + + // GetWatchers / InvokeOnWatcherError exist only to *synthesize* an Error event (no portable + // way to make a real FSW fire one). The Error tests themselves assert observable behavior, + // not the watcher dictionary's contents. + private static ConcurrentDictionary GetWatchers(LiveVirtualFileExistsStrategy strategy) + { + var field = typeof(LiveVirtualFileExistsStrategy).GetField("_watchers", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (ConcurrentDictionary)field.GetValue(strategy)!; + } + + private static void InvokeOnWatcherError(LiveVirtualFileExistsStrategy strategy, IFileSystemWatcher sender, ErrorEventArgs args) + { + var method = typeof(LiveVirtualFileExistsStrategy).GetMethod("OnWatcherError", BindingFlags.NonPublic | BindingFlags.Instance)!; + method.Invoke(strategy, [sender, args]); + } + + protected static async Task AwaitCacheInvalidationAsync(Action diskAction, Func predicate, string description) + { + diskAction(); + + var ct = TestContext.Current.CancellationToken; + var sw = Stopwatch.StartNew(); + while (true) + { + if (predicate()) + return; + if (sw.Elapsed >= WatcherEventTimeout) + Assert.Fail($"Timed out after {WatcherEventTimeout} waiting for: {description}"); + await Task.Delay(PollInterval, ct); + } + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs new file mode 100644 index 00000000..19f9cc85 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Concurrent; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; +using PG.StarWarsGame.Engine.Utilities; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +/// +/// Tests every -derived strategy must satisfy: +/// per-directory snapshotting, no delegation for in-tree paths, delegation for out-of-tree +/// paths, and missing-directory handling. +/// +public abstract class VirtualFileExistsStrategyBaseTests : FileExistsStrategyTestBase +{ + /// + /// Switch the active strategy on to the strategy under test, with + /// as the fallback for outside-game-directory lookups. + /// + private protected abstract void ConfigureStrategy(PetroglyphFileSystem fs, FileExistsStrategy underlying); + + [Fact] + public void FileExists_RepeatedCallsSameDirectory_BothResolveFromSnapshot() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Mods", "Test", "Data", "Xml"); + FileSystem.Directory.CreateDirectory(dataDir); + var foo = FileSystem.Path.Combine(dataDir, "foo.xml"); + var bar = FileSystem.Path.Combine(dataDir, "bar.xml"); + FileSystem.File.WriteAllText(foo, "1"); + FileSystem.File.WriteAllText(bar, "2"); + + var sb1 = new ValueStringBuilder(); + Assert.True(PgFileSystem.FileExists("MODS/TEST/DATA/XML/FOO.XML".AsSpan(), ref sb1, dir.AsSpan())); + AssertResolvedPath(foo, sb1.ToString()); + + var sb2 = new ValueStringBuilder(); + Assert.True(PgFileSystem.FileExists("mods/test/data/xml/BAR.XML".AsSpan(), ref sb2, dir.AsSpan())); + AssertResolvedPath(bar, sb2.ToString()); + } + + [Fact] + public void FileExists_MissingDirectoryUnderGameRoot_RemainsMissing() + { + var dir = NewTempDir(); + FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "Mods", "Test", "Data", "Xml")); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dir, "Mods", "Test", "Data", "Xml", "foo.xml"), "1"); + + Assert.False(FileExists("MODS/TEST/DATA/OTHER/foo.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("mods/test/data/other/bar.xml".AsSpan(), dir.AsSpan())); + } + + [Fact] + public void FileExists_PathOutsideGameDirectory_DelegatesToUnderlying() + { + var root = NewTempDir(); + var gameDir = FileSystem.Path.Combine(root, "game"); + var outsideDir = FileSystem.Path.Combine(root, "outside"); + FileSystem.Directory.CreateDirectory(gameDir); + FileSystem.Directory.CreateDirectory(outsideDir); + var file = FileSystem.Path.Combine(outsideDir, "FILE.TXT"); + FileSystem.File.WriteAllText(file, "x"); + + var tracking = new TrackingFileExistsStrategy(FileSystem) { ReturnValue = true, ResolvedPath = file }; + ConfigureStrategy(PgFileSystem, tracking); + + var sb = new ValueStringBuilder(); + Assert.True(PgFileSystem.FileExists(file.AsSpan(), ref sb, gameDir.AsSpan())); + AssertResolvedPath(file, sb.ToString()); + + Assert.Equal(1, tracking.CallCount); + } + + [Fact] + public void FileExists_PathUnderGameDirectory_DoesNotDelegate() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "foo.xml"), "x"); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + ConfigureStrategy(PgFileSystem, tracking); + + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + Assert.Equal(0, tracking.CallCount); + } + + [Fact] + public void FileExists_RepeatedLookupInSnapshottedDirectory_DoesNotDelegate() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "foo.xml"), "x"); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "bar.xml"), "y"); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + ConfigureStrategy(PgFileSystem, tracking); + + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + Assert.True(FileExists("Data/bar.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/missing.xml".AsSpan(), dir.AsSpan())); + + Assert.Equal(0, tracking.CallCount); + } + + [Fact] + public void FileExists_MissingSubdirectoryUnderGameRoot_DoesNotDelegate() + { + var dir = NewTempDir(); + FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "Data")); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + ConfigureStrategy(PgFileSystem, tracking); + + Assert.False(FileExists("Data/Other/foo.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/Other/bar.xml".AsSpan(), dir.AsSpan())); + + Assert.Equal(0, tracking.CallCount); + } + + [Fact] + public void Cleanup_ClearsSnapshotCache_FreshSnapshotOnNextLookup() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "foo.xml"), "x"); + + // Prime the snapshot — foo.xml is in cache. + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + Assert.NotEmpty(GetSnapshotStore()); + + // Cleanup evicts the snapshot cache. + GetActiveVirtualStrategy().Cleanup(); + Assert.Empty(GetSnapshotStore()); + + // Add a file to disk after cleanup; the post-cleanup re-snapshot must pick it up. + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "bar.xml"), "y"); + + // Post-cleanup: fresh snapshot taken on next lookup — both files visible. + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + Assert.True(FileExists("Data/bar.xml".AsSpan(), dir.AsSpan())); + } + + private VirtualFileExistsStrategyBase GetActiveVirtualStrategy() + => (VirtualFileExistsStrategyBase)PgFileSystem.Strategy; + + private ConcurrentDictionary GetSnapshotStore() + { + var strategy = GetActiveVirtualStrategy(); + var field = typeof(VirtualFileExistsStrategyBase).GetField("Store", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + return (ConcurrentDictionary)field.GetValue(strategy)!; + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs index eb546dd9..12c4c3f4 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs @@ -1,7 +1,6 @@ using System; -using System.IO; using PG.StarWarsGame.Engine.IO; -using PG.StarWarsGame.Engine.Utilities; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; using Xunit; namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; @@ -22,122 +21,29 @@ protected override void ConfigureStrategy(PetroglyphFileSystem fs) => fs.UseVirtualStrategy(); } -public abstract class VirtualFileExistsStrategyTests : FileExistsStrategyTestBase +public abstract class VirtualFileExistsStrategyTests : VirtualFileExistsStrategyBaseTests { - [Fact] - public void FileExists_RepeatedCallsSameDirectory_BothResolveFromSnapshot() - { - var dir = NewTempDir(); - var dataDir = Path.Combine(dir, "Mods", "Test", "Data", "Xml"); - Directory.CreateDirectory(dataDir); - var foo = Path.Combine(dataDir, "foo.xml"); - var bar = Path.Combine(dataDir, "bar.xml"); - File.WriteAllText(foo, "1"); - File.WriteAllText(bar, "2"); - - var sb1 = new ValueStringBuilder(); - Assert.True(PgFileSystem.FileExists("MODS/TEST/DATA/XML/FOO.XML".AsSpan(), ref sb1, dir.AsSpan())); - AssertResolvedPath(foo, sb1.ToString()); - - var sb2 = new ValueStringBuilder(); - Assert.True(PgFileSystem.FileExists("mods/test/data/xml/BAR.XML".AsSpan(), ref sb2, dir.AsSpan())); - AssertResolvedPath(bar, sb2.ToString()); - } + private protected override void ConfigureStrategy(PetroglyphFileSystem fs, FileExistsStrategy underlying) + => fs.UseVirtualStrategy(underlying); - [Fact] - public void FileExists_MissingDirectoryUnderGameRoot_RemainsMissing() - { - var dir = NewTempDir(); - Directory.CreateDirectory(Path.Combine(dir, "Mods", "Test", "Data", "Xml")); - File.WriteAllText(Path.Combine(dir, "Mods", "Test", "Data", "Xml", "foo.xml"), "1"); - - Assert.False(FileExists("MODS/TEST/DATA/OTHER/foo.xml".AsSpan(), dir.AsSpan())); - Assert.False(FileExists("mods/test/data/other/bar.xml".AsSpan(), dir.AsSpan())); - } + private protected override FileExistsStrategy CreateStrategyForCleanupTest() + => new VirtualFileExistsStrategy(FileSystem, new WineFileExistsStrategy(FileSystem)); [Fact] public void FileExists_AfterFirstResolve_SnapshotServesSubsequentLookups() { var dir = NewTempDir(); - var dataDir = Path.Combine(dir, "Data"); - Directory.CreateDirectory(dataDir); - var file = Path.Combine(dataDir, "foo.xml"); - File.WriteAllText(file, "x"); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var file = FileSystem.Path.Combine(dataDir, "foo.xml"); + FileSystem.File.WriteAllText(file, "x"); Assert.True(FileExists("DATA/foo.xml".AsSpan(), dir.AsSpan())); - File.Delete(file); + FileSystem.File.Delete(file); + // Non-live strategy: snapshot is taken once and serves all subsequent lookups even if + // the file is deleted on disk. The live variant overrides this behavior. Assert.True(FileExists("DATA/foo.xml".AsSpan(), dir.AsSpan())); } - - [Fact] - public void FileExists_PathOutsideGameDirectory_DelegatesToUnderlying() - { - var root = NewTempDir(); - var gameDir = Path.Combine(root, "game"); - var outsideDir = Path.Combine(root, "outside"); - Directory.CreateDirectory(gameDir); - Directory.CreateDirectory(outsideDir); - var file = Path.Combine(outsideDir, "FILE.TXT"); - File.WriteAllText(file, "x"); - - var tracking = new TrackingFileExistsStrategy(FileSystem) { ReturnValue = true, ResolvedPath = file }; - PgFileSystem.UseVirtualStrategy(tracking); - - var sb = new ValueStringBuilder(); - Assert.True(PgFileSystem.FileExists(file.AsSpan(), ref sb, gameDir.AsSpan())); - AssertResolvedPath(file, sb.ToString()); - - Assert.Equal(1, tracking.CallCount); - } - - [Fact] - public void FileExists_PathUnderGameDirectory_DoesNotDelegate() - { - var dir = NewTempDir(); - var dataDir = Path.Combine(dir, "Data"); - Directory.CreateDirectory(dataDir); - File.WriteAllText(Path.Combine(dataDir, "foo.xml"), "x"); - - var tracking = new TrackingFileExistsStrategy(FileSystem); - PgFileSystem.UseVirtualStrategy(tracking); - - Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); - Assert.Equal(0, tracking.CallCount); - } - - [Fact] - public void FileExists_RepeatedLookupInSnapshottedDirectory_DoesNotDelegate() - { - var dir = NewTempDir(); - var dataDir = Path.Combine(dir, "Data"); - Directory.CreateDirectory(dataDir); - File.WriteAllText(Path.Combine(dataDir, "foo.xml"), "x"); - File.WriteAllText(Path.Combine(dataDir, "bar.xml"), "y"); - - var tracking = new TrackingFileExistsStrategy(FileSystem); - PgFileSystem.UseVirtualStrategy(tracking); - - Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); - Assert.True(FileExists("Data/bar.xml".AsSpan(), dir.AsSpan())); - Assert.False(FileExists("Data/missing.xml".AsSpan(), dir.AsSpan())); - - Assert.Equal(0, tracking.CallCount); - } - - [Fact] - public void FileExists_MissingSubdirectoryUnderGameRoot_DoesNotDelegate() - { - var dir = NewTempDir(); - Directory.CreateDirectory(Path.Combine(dir, "Data")); - - var tracking = new TrackingFileExistsStrategy(FileSystem); - PgFileSystem.UseVirtualStrategy(tracking); - - Assert.False(FileExists("Data/Other/foo.xml".AsSpan(), dir.AsSpan())); - Assert.False(FileExists("Data/Other/bar.xml".AsSpan(), dir.AsSpan())); - - Assert.Equal(0, tracking.CallCount); - } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategy_RootGameDirectoryTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategy_RootGameDirectoryTests.cs new file mode 100644 index 00000000..10e64e95 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategy_RootGameDirectoryTests.cs @@ -0,0 +1,59 @@ +using System; +using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.Testing.Attributes; +using Microsoft.Extensions.DependencyInjection; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.Utilities; +using Testably.Abstractions.Testing; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +/// +/// Exercises the snapshot path with the filesystem root (/) as the game directory. +/// Real-disk fixtures cannot create files at / without root privileges, so this uses +/// a Linux-simulated . Only meaningful for the non-live variant — +/// the live variant's binds to the real OS, not the mock. +/// +public sealed class VirtualFileExistsStrategy_RootGameDirectoryTests +{ + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void FileExists_GameDirectoryIsFilesystemRoot_ResolvesFromSnapshot() + { + var mockFs = new MockFileSystem(); + mockFs.File.WriteAllText("/foo.xml", "x"); + + var pgFs = NewPgFs(mockFs); + var tracking = new TrackingFileExistsStrategy(mockFs); + pgFs.UseVirtualStrategy(tracking); + + var sb = new ValueStringBuilder(); + Assert.True(pgFs.FileExists("/foo.xml".AsSpan(), ref sb, "/".AsSpan())); + Assert.Equal("/foo.xml", sb.ToString()); + + // Lookup is under the game directory, so it must resolve from the snapshot, not delegate. + Assert.Equal(0, tracking.CallCount); + } + + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void FileExists_GameDirectoryIsFilesystemRoot_MissingFile_ReportsFalseWithoutDelegating() + { + var mockFs = new MockFileSystem(); + mockFs.File.WriteAllText("/foo.xml", "x"); + + var pgFs = NewPgFs(mockFs); + var tracking = new TrackingFileExistsStrategy(mockFs); + pgFs.UseVirtualStrategy(tracking); + + var sb = new ValueStringBuilder(); + Assert.False(pgFs.FileExists("/missing.xml".AsSpan(), ref sb, "/".AsSpan())); + Assert.Equal(0, tracking.CallCount); + } + + private static PetroglyphFileSystem NewPgFs(IFileSystem fileSystem) + { + var sc = new ServiceCollection(); + sc.AddSingleton(fileSystem); + return new PetroglyphFileSystem(sc.BuildServiceProvider()); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs index 0ee13a7b..fbe9ea35 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; using Xunit; namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; @@ -17,4 +18,11 @@ protected override void ConfigureStrategy(PetroglyphFileSystem fs) Assert.Skip("Windows strategy requires a Windows host."); fs.UseWindowsStrategy(); } + + private protected override FileExistsStrategy CreateStrategyForCleanupTest() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + Assert.Skip("Windows strategy requires a Windows host."); + return new WindowsFileExistsStrategy(FileSystem); + } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs index 50e2aff7..c55a8ea2 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs @@ -1,4 +1,5 @@ using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; @@ -6,4 +7,7 @@ public sealed class WineFileExistsStrategyTests : FileExistsStrategyTestBase { protected override void ConfigureStrategy(PetroglyphFileSystem fs) => fs.UseWineStrategy(); + + private protected override FileExistsStrategy CreateStrategyForCleanupTest() + => new WineFileExistsStrategy(FileSystem); } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.CombineJoin.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.CombineJoin.cs index 5da9a79c..a2e62d5b 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.CombineJoin.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.CombineJoin.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using PG.StarWarsGame.Engine.IO; using PG.StarWarsGame.Engine.Utilities; using Xunit; #if NETFRAMEWORK @@ -10,34 +11,37 @@ namespace PG.StarWarsGame.Engine.FileSystem.Test.IO; public partial class PetroglyphFileSystemTests { + private static string Sep = PetroglyphFileSystem.DirectorySeparatorChar.ToString(); + private static string AltSep = PetroglyphFileSystem.AltDirectorySeparatorChar.ToString(); + private static string OsSep = Path.DirectorySeparatorChar.ToString(); + + // Mirrors TestData_JoinTwoPaths, but with CombinePath semantics: a rooted second path replaces the + // first entirely. Null cases are omitted because CombinePath throws on null (see the dedicated facts). + public static TheoryData TestData_CombineTwoPaths = new() + { + { "", "", "" }, + { Sep, "", Sep }, + { AltSep, "", AltSep }, + { "", Sep, Sep }, + { "", AltSep, AltSep }, + { Sep, Sep, Sep }, + { AltSep, AltSep, AltSep }, + { "a", "", "a" }, + { "", "a", "a" }, + { "a", "a", $"a{OsSep}a" }, + { $"a{Sep}", "a", $"a{Sep}a" }, + { "a", $"{Sep}a", $"{Sep}a" }, + { $"a{Sep}", $"{Sep}a", $"{Sep}a" }, + { "a", $"a{Sep}", $"a{OsSep}a{Sep}" }, + { $"a{AltSep}", "a", $"a{AltSep}a" }, + { "a", $"{AltSep}a", $"{AltSep}a" }, + { $"a{Sep}", $"{AltSep}a", $"{AltSep}a" }, + { $"a{AltSep}", $"{AltSep}a", $"{AltSep}a" }, + { "a", $"a{AltSep}", $"a{OsSep}a{AltSep}" } + }; + [Theory] -#if Windows - [InlineData("a", "b", "a\\b")] - [InlineData("a/", "b", "a/b")] - [InlineData("a\\", "b", "a\\b")] - [InlineData("", "b", "b")] - [InlineData("a", "", "a")] - [InlineData("/", "b", "/b")] - [InlineData("a", "/b", "/b")] - [InlineData("a", "\\b", "\\b")] - [InlineData("a/b", "c/d", "a/b\\c/d")] - [InlineData("a\\b", "c\\d", "a\\b\\c\\d")] - [InlineData("a/b/", "c/d", "a/b/c/d")] - [InlineData("a\\b\\", "c\\d", "a\\b\\c\\d")] -#else - [InlineData("a", "b", "a/b")] - [InlineData("a/", "b", "a/b")] - [InlineData("a\\", "b", "a\\b")] - [InlineData("", "b", "b")] - [InlineData("a", "", "a")] - [InlineData("/", "b", "/b")] - [InlineData("a", "/b", "/b")] - [InlineData("a", "\\b", "\\b")] - [InlineData("a/b", "c/d", "a/b/c/d")] - [InlineData("a\\b", "c\\d", "a\\b/c\\d")] - [InlineData("a/b/", "c/d", "a/b/c/d")] - [InlineData("a\\b\\", "c\\d", "a\\b\\c\\d")] -#endif + [MemberData(nameof(TestData_CombineTwoPaths))] public void CombinePath(string pathA, string pathB, string expected) { var result = _pgFileSystem.CombinePath(pathA, pathB); @@ -47,31 +51,35 @@ public void CombinePath(string pathA, string pathB, string expected) #endif } + public static TheoryData TestData_JoinTwoPaths = new() + { + { "", "", "" }, + { Sep, "", Sep }, + { AltSep, "", AltSep }, + { "", Sep, Sep }, + { "", AltSep, AltSep }, + { Sep, Sep, $"{Sep}{Sep}" }, + { AltSep, AltSep, $"{AltSep}{AltSep}" }, + { "a", "", "a" }, + { "", "a", "a" }, + { "a", "a", $"a{OsSep}a" }, + { $"a{Sep}", "a", $"a{Sep}a" }, + { "a", $"{Sep}a", $"a{Sep}a" }, + { $"a{Sep}", $"{Sep}a", $"a{Sep}{Sep}a" }, + { "a", $"a{Sep}", $"a{OsSep}a{Sep}" }, + { $"a{AltSep}", "a", $"a{AltSep}a" }, + { "a", $"{AltSep}a", $"a{AltSep}a" }, + { $"a{Sep}", $"{AltSep}a", $"a{Sep}{AltSep}a" }, + { $"a{AltSep}", $"{AltSep}a", $"a{AltSep}{AltSep}a" }, + { "a", $"a{AltSep}", $"a{OsSep}a{AltSep}" }, + { null, null, ""}, + { null, "a", "a"}, + { "a", null, "a"} + }; + [Theory] -#if Windows - [InlineData("a", "b", "a\\b")] - [InlineData("a/", "b", "a/b")] - [InlineData("a\\", "b", "a\\b")] - [InlineData("", "b", "b")] - [InlineData("a", "", "a")] - [InlineData("/", "b", "/b")] - [InlineData("a", "/b", "a/b")] - [InlineData("a", "\\b", "a\\b")] - [InlineData("a/b", "c/d", "a/b\\c/d")] - [InlineData("a\\b", "c\\d", "a\\b\\c\\d")] -#else - [InlineData("a", "b", "a/b")] - [InlineData("a/", "b", "a/b")] - [InlineData("a\\", "b", "a\\b")] - [InlineData("", "b", "b")] - [InlineData("a", "", "a")] - [InlineData("/", "b", "/b")] - [InlineData("a", "/b", "a/b")] - [InlineData("a", "\\b", "a\\b")] - [InlineData("a/b", "c/d", "a/b/c/d")] - [InlineData("a\\b", "c\\d", "a\\b/c\\d")] -#endif - public void JoinPath(string path1, string path2, string expected) + [MemberData(nameof(TestData_JoinTwoPaths))] + public void JoinPath(string? path1, string? path2, string expected) { var vsb = new ValueStringBuilder(); try @@ -89,6 +97,54 @@ public void JoinPath(string path1, string path2, string expected) } } + public static TheoryData TestData_JoinThreePaths = new() + { + { "", "", "", "" }, + { Sep, Sep, Sep, $"{Sep}{Sep}{Sep}" }, + { AltSep, AltSep, AltSep, $"{AltSep}{AltSep}{AltSep}" }, + { "a", "", "", "a" }, + { "", "a", "", "a" }, + { "", "", "a", "a" }, + { "a", "", "a", $"a{OsSep}a" }, + { "a", "a", "", $"a{OsSep}a" }, + { "", "a", "a", $"a{OsSep}a" }, + { "a", "a", "a", $"a{OsSep}a{OsSep}a" }, + { "a", Sep, "a", $"a{Sep}a" }, + { $"a{Sep}", "", "a", $"a{Sep}a" }, + { $"a{Sep}", "a", "", $"a{Sep}a" }, + { "", $"a{Sep}", "a", $"a{Sep}a" }, + { "a", "", $"{Sep}a", $"a{Sep}a" }, + { $"a{AltSep}", "", "a", $"a{AltSep}a" }, + { $"a{AltSep}", "a", "", $"a{AltSep}a" }, + { "", $"a{AltSep}", "a", $"a{AltSep}a" }, + { "a", "", $"{AltSep}a", $"a{AltSep}a" }, + { null, null, null, "" }, + { "a", null, null, "a" }, + { null, "a", null, "a" }, + { null, null, "a", "a" }, + { "a", null, "a", $"a{OsSep}a" } + }; + + [Theory] + [MemberData(nameof(TestData_JoinThreePaths))] + public void JoinPath_ThreePaths(string? path1, string? path2, string? path3, string expected) + { + var vsb = new ValueStringBuilder(); + try + { + _pgFileSystem.JoinPath(path1.AsSpan(), path2.AsSpan(), path3.AsSpan(), ref vsb); + var result = vsb.ToString(); + Assert.Equal(expected, result); +#if Windows + Assert.Equal(result, _fileSystem.Path.Join(path1, path2, path3)); +#endif + } + finally + { + vsb.Dispose(); + } + } + [Fact] public void CombinePath_FirstArgNull_Throws() { diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs index 243e4321..d15f10c8 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs @@ -2,7 +2,6 @@ using System.IO.Abstractions; using System.Runtime.InteropServices; using AnakinRaW.CommonUtilities.Testing.Attributes; -using PG.StarWarsGame.Engine.Utilities; using Testably.Abstractions; using Xunit; @@ -61,6 +60,13 @@ public void UseVirtualStrategy_DefaultFallback_Resolves() AssertExists(); } + [Fact] + public void UseLiveVirtualStrategy_DefaultFallback_Resolves() + { + PgFileSystem.UseLiveVirtualStrategy(); + AssertExists(); + } + [PlatformSpecificFact(TestPlatformIdentifier.Windows)] public void UseWindowsStrategy_OnWindows_Resolves() { @@ -80,6 +86,12 @@ public void UseVirtualStrategy_WindowsFallback_OnNonWindows_Throws() Assert.Throws(() => PgFileSystem.UseVirtualStrategy(windowsFallback: true)); } + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void UseLiveVirtualStrategy_WindowsFallback_OnNonWindows_Throws() + { + Assert.Throws(() => PgFileSystem.UseLiveVirtualStrategy(windowsFallback: true)); + } + [Fact] public void Switching_BetweenStrategies_LeavesFileSystemUsable() { @@ -89,6 +101,9 @@ public void Switching_BetweenStrategies_LeavesFileSystemUsable() PgFileSystem.UseVirtualStrategy(); AssertExists(); + PgFileSystem.UseLiveVirtualStrategy(); + AssertExists(); + if (IsWindows) { PgFileSystem.UseWindowsStrategy(); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj index e2362402..cf1c3501 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj @@ -13,17 +13,17 @@ - + - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/FileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/FileExistsStrategy.cs index 43191cd5..3e4a79a0 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/FileExistsStrategy.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/FileExistsStrategy.cs @@ -4,11 +4,11 @@ namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; -internal abstract class FileExistsStrategy(IFileSystem fileSystem) : IDisposable +internal abstract class FileExistsStrategy(IFileSystem fileSystem) { protected readonly IFileSystem FileSystem = fileSystem; public abstract bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder); - public virtual void Dispose() { } + internal virtual void Cleanup() { } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs new file mode 100644 index 00000000..abef690a --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.FileSystem; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; + +internal sealed class LiveVirtualFileExistsStrategy(IFileSystem fileSystem, FileExistsStrategy underlying) + : VirtualFileExistsStrategyBase(fileSystem, underlying) +{ + private const int WatcherBufferSize = 64 * 1024; + + private readonly object _watchersLock = new(); + private readonly ConcurrentDictionary _watchers = new(StringComparer.OrdinalIgnoreCase); + + public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) + { + if (!baseDirectory.IsEmpty && FileSystem.Path.IsChildOf(baseDirectory, stringBuilder.AsSpan())) + EnsureWatcher(baseDirectory); + return base.FileExists(baseDirectory, ref stringBuilder); + } + + internal override void Cleanup() + { + IFileSystemWatcher[] watchers; + lock (_watchersLock) + { + watchers = new IFileSystemWatcher[_watchers.Count]; + _watchers.Values.CopyTo(watchers, 0); + _watchers.Clear(); + } + + foreach (var watcher in watchers) + TearDownWatcher(watcher); + + base.Cleanup(); + } + + private void EnsureWatcher(ReadOnlySpan baseDirectory) + { + var rootStr = baseDirectory.ToString(); + + // Fast path: already watching this directory — lockless, no OS call. + if (_watchers.ContainsKey(rootStr)) + return; + + // Only pay for the Directory.Exists syscall when the watcher might be missing. + if (!FileSystem.Directory.Exists(rootStr)) + return; + + lock (_watchersLock) + { + if (_watchers.ContainsKey(rootStr)) + return; + + var watcher = FileSystem.FileSystemWatcher.New(rootStr); + watcher.IncludeSubdirectories = true; + watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName; + watcher.InternalBufferSize = WatcherBufferSize; + + watcher.Created += OnFileEvent; + watcher.Deleted += OnFileEvent; + watcher.Changed += OnFileEvent; + watcher.Renamed += OnFileRenamed; + watcher.Error += OnWatcherError; + + watcher.EnableRaisingEvents = true; + _watchers[rootStr] = watcher; + } + } + + private void OnFileEvent(object sender, FileSystemEventArgs e) + { + InvalidatePathAndSubtree(e.FullPath); + } + + private void OnFileRenamed(object sender, RenamedEventArgs e) + { + InvalidatePathAndSubtree(e.OldFullPath); + InvalidatePathAndSubtree(e.FullPath); + } + + private void OnWatcherError(object sender, ErrorEventArgs e) + { + IFileSystemWatcher? broken = null; + string? brokenRoot = null; + lock (_watchersLock) + { + foreach (var kv in _watchers) + { + if (ReferenceEquals(kv.Value, sender)) + { + broken = kv.Value; + brokenRoot = kv.Key; + break; + } + } + if (broken is null) + return; + _watchers.TryRemove(brokenRoot!, out _); + } + + ClearCacheUnder(brokenRoot!); + TearDownWatcher(broken); + } + + private void TearDownWatcher(IFileSystemWatcher watcher) + { + watcher.EnableRaisingEvents = false; + watcher.Created -= OnFileEvent; + watcher.Deleted -= OnFileEvent; + watcher.Changed -= OnFileEvent; + watcher.Renamed -= OnFileRenamed; + watcher.Error -= OnWatcherError; + watcher.Dispose(); + } + + private void ClearCacheUnder(string root) + { + Store.TryRemove(root, out _); + var prefix = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? root + : root + Path.DirectorySeparatorChar; + foreach (var key in Store.Keys) + { + if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + Store.TryRemove(key, out _); + } + } + + private void InvalidatePathAndSubtree(string fullPath) + { + InvalidateParentOf(fullPath); + InvalidateSubtree(fullPath); + } + + private void InvalidateParentOf(string fullPath) + { + var parent = FileSystem.Path.GetDirectoryName(fullPath); + if (parent is { Length: > 0 }) + Store.TryRemove(parent, out _); + } + + private void InvalidateSubtree(string fullPath) + { + Store.TryRemove(fullPath, out _); + var prefix = fullPath + Path.DirectorySeparatorChar; + foreach (var key in Store.Keys) + { + if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + Store.TryRemove(key, out _); + } + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs index f4f077c7..dc0fc137 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs @@ -4,17 +4,14 @@ namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; /// /// Immutable snapshot of a single directory's file listing. Files only — no subdirectory recursion. -/// Built once by and never mutated thereafter. /// -internal sealed class VirtualDirectory(string onDiskPath, Dictionary files) +internal sealed class VirtualDirectory(string onDiskPath, IReadOnlyDictionary files) { - /// The directory's path with the on-disk casing. + /// Gets the directory's path with the on-disk casing. public string OnDiskPath { get; } = onDiskPath; /// - /// Filename map. Keys compare case-insensitively (so callers can look up "FOO.XML" against - /// the on-disk "foo.xml") and the value carries the case-preserved on-disk filename used - /// when joining the result back into a full path. + /// Gets the filename map. /// - public Dictionary Files { get; } = files; + public IReadOnlyDictionary Files { get; } = files; } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs index 5583ec7f..a47b3f32 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs @@ -1,162 +1,6 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; using System.IO.Abstractions; -using AnakinRaW.CommonUtilities.FileSystem; -using PG.StarWarsGame.Engine.Utilities; namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; internal sealed class VirtualFileExistsStrategy(IFileSystem fileSystem, FileExistsStrategy underlying) - : FileExistsStrategy(fileSystem) -{ - private readonly ConcurrentDictionary _store = - new(StringComparer.OrdinalIgnoreCase); - - public override void Dispose() - { - _store.Clear(); - underlying.Dispose(); - } - - public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) - { - var filePath = stringBuilder.AsSpan(); - - if (!IsUnderBaseDirectory(filePath, baseDirectory)) - return underlying.FileExists(baseDirectory, ref stringBuilder); - - var lastSep = filePath.LastIndexOf(Path.DirectorySeparatorChar); - if (lastSep <= 0) - return underlying.FileExists(baseDirectory, ref stringBuilder); - - var dirSpan = filePath.Slice(0, lastSep); - var fileName = filePath.Slice(lastSep + 1); - if (fileName.IsEmpty) - return underlying.FileExists(baseDirectory, ref stringBuilder); - - var dirKey = dirSpan.ToString(); - if (!_store.TryGetValue(dirKey, out var virtualDir)) - { - virtualDir = TrySnapshot(dirKey); - _store.TryAdd(dirKey, virtualDir); - } - - if (virtualDir is null) - return false; - - if (virtualDir.Files.TryGetValue(fileName.ToString(), out var onDiskName)) - { - stringBuilder.Length = 0; - stringBuilder.Append(virtualDir.OnDiskPath); - if (stringBuilder.Length > 0 && !LowLevelPath.IsDirectorySeparator(stringBuilder[stringBuilder.Length - 1])) - stringBuilder.Append(Path.DirectorySeparatorChar); - stringBuilder.Append(onDiskName); - return true; - } - - return false; - } - - private VirtualDirectory? TrySnapshot(string inputDirPath) - { - var onDiskPath = TryResolveDirectory(inputDirPath); - if (onDiskPath is null) - return null; - - var files = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var entry in FileSystem.Directory.EnumerateFiles(onDiskPath)) - { - var name = FileSystem.Path.GetFileName(entry); - files[name] = name; - } - return new VirtualDirectory(onDiskPath, files); - } - - private string? TryResolveDirectory(string dirPath) - { - if (string.IsNullOrEmpty(dirPath)) - return null; - - if (FileSystem.Directory.Exists(dirPath)) - return dirPath; - - var path = dirPath.AsSpan(); - var rootLen = FileSystem.Path.GetPathRoot(path).Length; - if (rootLen == 0) - return null; - - var currentDir = dirPath.Substring(0, rootLen); - if (!FileSystem.Directory.Exists(currentDir)) - return null; - - var sb = new ValueStringBuilder(stackalloc char[260]); - try - { - sb.Append(currentDir); - - var pos = rootLen; - if (pos < path.Length && path[pos] == Path.DirectorySeparatorChar) - pos++; - - while (pos < path.Length) - { - var rest = path.Slice(pos); - var nextSlash = rest.IndexOf(Path.DirectorySeparatorChar); - var componentEnd = nextSlash >= 0 ? pos + nextSlash : path.Length; - var component = path.Slice(pos, componentEnd - pos); - - if (component.IsEmpty) - { - pos = componentEnd + 1; - continue; - } - - var savedLen = sb.Length; - if (savedLen == 0 || !LowLevelPath.IsDirectorySeparator(sb[savedLen - 1])) - sb.Append(Path.DirectorySeparatorChar); - sb.Append(component); - - var literalPath = sb.AsSpan().ToString(); - if (FileSystem.Directory.Exists(literalPath)) - { - currentDir = literalPath; - pos = componentEnd + 1; - continue; - } - - sb.Length = savedLen; - - var found = false; - foreach (var entry in FileSystem.Directory.EnumerateDirectories(currentDir)) - { - if (FileSystem.Path.GetFileName(entry.AsSpan()).Equals(component, StringComparison.OrdinalIgnoreCase)) - { - sb.Length = 0; - sb.Append(entry); - currentDir = entry; - found = true; - break; - } - } - - if (!found) - return null; - - pos = componentEnd + 1; - } - - return currentDir; - } - finally - { - sb.Dispose(); - } - } - - private bool IsUnderBaseDirectory(ReadOnlySpan path, ReadOnlySpan gameDirectory) - { - return !gameDirectory.IsEmpty && FileSystem.Path.IsChildOf(gameDirectory, path); - } -} + : VirtualFileExistsStrategyBase(fileSystem, underlying); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs new file mode 100644 index 00000000..3a16e8a1 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.FileSystem; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; + +internal abstract class VirtualFileExistsStrategyBase(IFileSystem fileSystem, FileExistsStrategy underlying) + : FileExistsStrategy(fileSystem) +{ + protected readonly ConcurrentDictionary Store = + new(StringComparer.OrdinalIgnoreCase); + + protected readonly FileExistsStrategy Underlying = underlying; + + internal override void Cleanup() + { + Store.Clear(); + Underlying.Cleanup(); + } + + public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) + { + var filePath = stringBuilder.AsSpan(); + + if (!IsUnderGameDirectory(filePath, baseDirectory)) + return Underlying.FileExists(baseDirectory, ref stringBuilder); + + var fileName = FileSystem.Path.GetFileName(filePath); + if (fileName.IsEmpty) + return false; + + var dirSpan = FileSystem.Path.GetDirectoryName(filePath); + if (dirSpan.IsEmpty) + return Underlying.FileExists(baseDirectory, ref stringBuilder); + + var dirKey = dirSpan.ToString(); + if (!Store.TryGetValue(dirKey, out var virtualDir)) + { + virtualDir = TrySnapshot(dirKey); + Store.TryAdd(dirKey, virtualDir); + } + + if (virtualDir is null) + return false; + + if (virtualDir.Files.TryGetValue(fileName.ToString(), out var onDiskName)) + { + stringBuilder.Length = 0; + stringBuilder.Append(virtualDir.OnDiskPath); + if (stringBuilder.Length > 0 && !LowLevelPath.IsDirectorySeparator(stringBuilder[stringBuilder.Length - 1])) + stringBuilder.Append(FileSystem.Path.DirectorySeparatorChar); + stringBuilder.Append(onDiskName); + return true; + } + + return false; + } + + private VirtualDirectory? TrySnapshot(string inputDirPath) + { + var onDiskPath = TryResolveDirectory(inputDirPath); + if (onDiskPath is null) + return null; + + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in FileSystem.Directory.EnumerateFiles(onDiskPath)) + { + var name = FileSystem.Path.GetFileName(entry); + files[name] = name; + } + return new VirtualDirectory(onDiskPath, files); + } + + private string? TryResolveDirectory(string dirPath) + { + if (string.IsNullOrEmpty(dirPath)) + return null; + + if (FileSystem.Directory.Exists(dirPath)) + return dirPath; + + var path = dirPath.AsSpan(); + var rootLen = FileSystem.Path.GetPathRoot(path).Length; + if (rootLen == 0) + return null; + + var currentDir = dirPath.Substring(0, rootLen); + if (!FileSystem.Directory.Exists(currentDir)) + return null; + + var sb = new ValueStringBuilder(stackalloc char[260]); + try + { + sb.Append(currentDir); + + var pos = rootLen; + if (pos < path.Length && path[pos] == FileSystem.Path.DirectorySeparatorChar) + pos++; + + while (pos < path.Length) + { + var rest = path.Slice(pos); + var nextSlash = rest.IndexOf(FileSystem.Path.DirectorySeparatorChar); + var componentEnd = nextSlash >= 0 ? pos + nextSlash : path.Length; + var component = path.Slice(pos, componentEnd - pos); + + if (component.IsEmpty) + { + pos = componentEnd + 1; + continue; + } + + var savedLen = sb.Length; + if (savedLen == 0 || !LowLevelPath.IsDirectorySeparator(sb[savedLen - 1])) + sb.Append(FileSystem.Path.DirectorySeparatorChar); + sb.Append(component); + + var literalPath = sb.AsSpan().ToString(); + if (FileSystem.Directory.Exists(literalPath)) + { + currentDir = literalPath; + pos = componentEnd + 1; + continue; + } + + sb.Length = savedLen; + + var found = false; + foreach (var entry in FileSystem.Directory.EnumerateDirectories(currentDir)) + { + if (FileSystem.Path.GetFileName(entry.AsSpan()).Equals(component, StringComparison.OrdinalIgnoreCase)) + { + sb.Length = 0; + sb.Append(entry); + currentDir = entry; + found = true; + break; + } + } + + if (!found) + return null; + + pos = componentEnd + 1; + } + + return currentDir; + } + finally + { + sb.Dispose(); + } + } + + private bool IsUnderGameDirectory(ReadOnlySpan path, ReadOnlySpan gameDirectory) + { + return !gameDirectory.IsEmpty && FileSystem.Path.IsChildOf(gameDirectory, path); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs index d9f3cd8c..e7d4afbd 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs @@ -6,10 +6,31 @@ namespace PG.StarWarsGame.Engine.IO; public sealed partial class PetroglyphFileSystem { + /// + /// Combines strings into a path. + /// + /// + /// + /// This method is intended to concatenate individual strings into a single string that represents a file path. + /// However, if an argument other than the first contains a rooted path, any previous path components are ignored, + /// and the returned string begins with that rooted path component. + /// + /// + /// This method supports the directory separator characters ("/") and ("\"). + /// + /// + /// The first path to combine. + /// The second path to combine. + /// + /// The combined paths. If one of the specified paths is a zero-length string, this method returns the other path. + /// If contains an absolute path, this method returns . + /// + /// or is . + /// public string CombinePath(string pathA, string pathB) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.Combine(pathA, pathB); + return UnderlyingFileSystem.Path.Combine(pathA, pathB); if (pathA == null) throw new ArgumentNullException(nameof(pathA)); @@ -17,7 +38,7 @@ public string CombinePath(string pathA, string pathB) throw new ArgumentNullException(nameof(pathB)); return CombineInternal(pathA, pathB); } - + internal void JoinPath(ReadOnlySpan path1, ReadOnlySpan path2, ref ValueStringBuilder stringBuilder) { if (path1.Length == 0 && path2.Length == 0) @@ -34,11 +55,48 @@ internal void JoinPath(ReadOnlySpan path1, ReadOnlySpan path2, ref V var hasSeparator = IsDirectorySeparator(path1[path1.Length - 1]) || IsDirectorySeparator(path2[0]); if (!hasSeparator) - stringBuilder.Append(_underlyingFileSystem.Path.DirectorySeparatorChar); + stringBuilder.Append(UnderlyingFileSystem.Path.DirectorySeparatorChar); stringBuilder.Append(path2); } - + + internal void JoinPath(ReadOnlySpan path1, ReadOnlySpan path2, ReadOnlySpan path3, ref ValueStringBuilder stringBuilder) + { + if (path1.IsEmpty) + { + JoinPath(path2, path3, ref stringBuilder); + return; + } + + if (path2.IsEmpty) + { + JoinPath(path1, path3, ref stringBuilder); + return; + } + + if (path3.IsEmpty) + { + JoinPath(path1, path2, ref stringBuilder); + return; + } + + stringBuilder.Append(path1); + + var firstHasSeparator = IsDirectorySeparator(path1[path1.Length - 1]) || IsDirectorySeparator(path2[0]); + var secondHasSeparator = IsDirectorySeparator(path2[path2.Length - 1]) || IsDirectorySeparator(path3[0]); + + if (!firstHasSeparator) + stringBuilder.Append(UnderlyingFileSystem.Path.DirectorySeparatorChar); + + stringBuilder.Append(path2); + + if (!secondHasSeparator) + stringBuilder.Append(UnderlyingFileSystem.Path.DirectorySeparatorChar); + + stringBuilder.Append(path3); + } + + private string CombineInternal(string first, string second) { if (string.IsNullOrEmpty(first)) @@ -58,6 +116,6 @@ private string JoinInternal(string first, string second) var hasSeparator = IsDirectorySeparator(first[first.Length - 1]) || IsDirectorySeparator(second[0]); return hasSeparator ? string.Concat(first, second) - : string.Concat(first, _underlyingFileSystem.Path.DirectorySeparatorChar, second); + : string.Concat(first, UnderlyingFileSystem.Path.DirectorySeparatorChar, second); } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs index f28f9e13..c1f305e8 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs @@ -45,7 +45,7 @@ internal bool FileExists(ReadOnlySpan filePath, ref ValueStringBuilder str NormalizePath(ref stringBuilder); NormalizeDotSegmentsInPlace(ref stringBuilder); - return _strategy.FileExists(baseDirectory, ref stringBuilder); + return Strategy.FileExists(baseDirectory, ref stringBuilder); } internal void NormalizeDotSegmentsInPlace(ref ValueStringBuilder sb) @@ -54,7 +54,7 @@ internal void NormalizeDotSegmentsInPlace(ref ValueStringBuilder sb) if (len == 0) return; - var dirSeparator = _underlyingFileSystem.Path.DirectorySeparatorChar; + var dirSeparator = UnderlyingFileSystem.Path.DirectorySeparatorChar; var rootLen = GetPathRoot(sb.AsSpan()).Length; var writeEnd = rootLen; @@ -106,6 +106,6 @@ internal bool IsPathFullyQualified_Exists(ReadOnlySpan path) // However, this must not happen here, since we are operating on the actual file system. // E.g, \\Data\\Art\\... MUST not be treated as a fully qualified path. // This means, ultimately, we can just delegate to the underlying file system. - return _underlyingFileSystem.Path.IsPathFullyQualified(path); + return UnderlyingFileSystem.Path.IsPathFullyQualified(path); } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs index 1d5648bf..f72476a3 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs @@ -28,7 +28,7 @@ public sealed partial class PetroglyphFileSystem public string? GetFileName(string? path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.GetFileName(path); + return UnderlyingFileSystem.Path.GetFileName(path); if (path == null) return null; @@ -51,7 +51,7 @@ public sealed partial class PetroglyphFileSystem public ReadOnlySpan GetFileName(ReadOnlySpan path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.GetFileName(path); + return UnderlyingFileSystem.Path.GetFileName(path); var root = GetPathRoot(path).Length; var i = path.LastIndexOfAny(DirectorySeparatorChar, AltDirectorySeparatorChar); @@ -69,7 +69,7 @@ public ReadOnlySpan GetFileName(ReadOnlySpan path) public string? GetFileNameWithoutExtension(string? path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.GetFileNameWithoutExtension(path); + return UnderlyingFileSystem.Path.GetFileNameWithoutExtension(path); if (path == null) return null; @@ -88,7 +88,7 @@ public ReadOnlySpan GetFileName(ReadOnlySpan path) public ReadOnlySpan GetFileNameWithoutExtension(ReadOnlySpan path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.GetFileNameWithoutExtension(path); + return UnderlyingFileSystem.Path.GetFileNameWithoutExtension(path); var fileName = GetFileName(path); var lastPeriod = fileName.LastIndexOf('.'); return lastPeriod < 0 @@ -146,7 +146,7 @@ public ReadOnlySpan GetFileNameWithoutExtension(ReadOnlySpan path) public string? ChangeExtension(string? path, string? extension) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.ChangeExtension(path, extension); + return UnderlyingFileSystem.Path.ChangeExtension(path, extension); if (path == null) return null; @@ -194,7 +194,7 @@ public ReadOnlySpan GetFileNameWithoutExtension(ReadOnlySpan path) public ReadOnlySpan GetDirectoryName(ReadOnlySpan path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.GetDirectoryName(path); + return UnderlyingFileSystem.Path.GetDirectoryName(path); if (IsEffectivelyEmpty(path)) return ReadOnlySpan.Empty; diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs index 4afa3111..45d0cbb9 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs @@ -18,13 +18,13 @@ public sealed partial class PetroglyphFileSystem public bool PathsAreEqual(string pathA, string pathB) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return _underlyingFileSystem.Path.AreEqual(pathA, pathB); + return UnderlyingFileSystem.Path.AreEqual(pathA, pathB); var normalizedA = PathNormalizer.Normalize(pathA, PGFileSystemDirectorySeparatorNormalizeOptions); var normalizedB = PathNormalizer.Normalize(pathB, PGFileSystemDirectorySeparatorNormalizeOptions); - var fullA = _underlyingFileSystem.Path.GetFullPath(normalizedA); - var fullB = _underlyingFileSystem.Path.GetFullPath(normalizedB); + var fullA = UnderlyingFileSystem.Path.GetFullPath(normalizedA); + var fullB = UnderlyingFileSystem.Path.GetFullPath(normalizedB); return PathsEqual(fullA.AsSpan(), fullB.AsSpan(), Math.Max(fullA.Length, fullB.Length)); } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs index c7da143b..5796a747 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs @@ -1,4 +1,6 @@ using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using PG.StarWarsGame.Engine.IO.FileExistStrategies; @@ -6,7 +8,18 @@ namespace PG.StarWarsGame.Engine.IO; public sealed partial class PetroglyphFileSystem { - private FileExistsStrategy _strategy; + [ExcludeFromCodeCoverage] + [EditorBrowsable(EditorBrowsableState.Never)] + internal FileExistsStrategy Strategy { get; private set; } + + internal void CleanupStrategy() => Strategy.Cleanup(); + + private FileExistsStrategy CreateDefaultStrategy() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new WindowsFileExistsStrategy(UnderlyingFileSystem) + : new VirtualFileExistsStrategy(UnderlyingFileSystem, new WineFileExistsStrategy(UnderlyingFileSystem)); + } /// /// Switches the active file-exists strategy to one that issues a Win32 CreateFileA call per lookup. @@ -23,13 +36,13 @@ public sealed partial class PetroglyphFileSystem /// /// /// Selecting this strategy directly is rarely correct. Prefer - /// on non-Windows hosts and + /// on non-Windows hosts and /// on Windows. This method exists primarily to support the search engine used internally by - /// for paths outside the game directory. + /// for paths outside the game directory. /// /// Provides full mediation: every lookup re-walks the path with no caching. /// - public void UseWineStrategy() => SwapStrategy(new WineFileExistsStrategy(_underlyingFileSystem)); + public void UseWineStrategy() => SwapStrategy(new WineFileExistsStrategy(UnderlyingFileSystem)); /// /// Switches the active file-exists strategy to an immutable per-directory snapshot scoped to the game directory. @@ -52,25 +65,70 @@ public void UseVirtualStrategy(bool? windowsFallback = null) var useWindows = windowsFallback ?? RuntimeInformation.IsOSPlatform(OSPlatform.Windows); FileExistsStrategy fallback = useWindows ? CreateWindowsStrategy() - : new WineFileExistsStrategy(_underlyingFileSystem); + : new WineFileExistsStrategy(UnderlyingFileSystem); UseVirtualStrategy(fallback); } internal void UseVirtualStrategy(FileExistsStrategy underlying) - => SwapStrategy(new VirtualFileExistsStrategy(_underlyingFileSystem, underlying)); + => SwapStrategy(new VirtualFileExistsStrategy(UnderlyingFileSystem, underlying)); + + /// + /// Switches the active file-exists strategy to a snapshot-based one that refreshes itself when + /// files are added, removed, or renamed in the game directory. + /// + /// + /// + /// Equivalent to for lookups, but lazily attaches a + /// recursive to every distinct base directory passed + /// to . Each watcher's events invalidate cached directory listings under its + /// root on demand; the next lookup rebuilds the affected snapshot from disk. File + /// content changes are not tracked. + /// + /// + /// Each watcher is created on the first lookup that lands inside its base directory and is torn + /// down when the strategy is replaced or the file system is disposed. If a watcher's internal + /// buffer overflows or the OS otherwise reports an error, only that watcher is removed and only + /// its subtree is evicted from the cache; other roots continue to be tracked. + /// + /// + /// On Linux, each watcher consumes one inotify slot per directory in its subtree (per-user + /// kernel limit, fs.inotify.max_user_watches). Consumers tracking many large trees may + /// need to raise this limit. + /// + /// + /// + /// to delegate outside-game-directory lookups to the Windows + /// strategy; to delegate them to the Wine search + /// engine; to pick the Windows strategy on Windows hosts and the Wine + /// strategy otherwise. + /// + /// + /// is and the host is not Windows. + /// + public void UseLiveVirtualStrategy(bool? windowsFallback = null) + { + var useWindows = windowsFallback ?? RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + FileExistsStrategy fallback = useWindows + ? CreateWindowsStrategy() + : new WineFileExistsStrategy(UnderlyingFileSystem); + UseLiveVirtualStrategy(fallback); + } + + internal void UseLiveVirtualStrategy(FileExistsStrategy underlying) + => SwapStrategy(new LiveVirtualFileExistsStrategy(UnderlyingFileSystem, underlying)); private WindowsFileExistsStrategy CreateWindowsStrategy() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) throw new PlatformNotSupportedException( "The Windows file-exists strategy relies on Win32 CreateFileA and is only supported on Windows hosts."); - return new WindowsFileExistsStrategy(_underlyingFileSystem); + return new WindowsFileExistsStrategy(UnderlyingFileSystem); } private void SwapStrategy(FileExistsStrategy next) { - var old = _strategy; - _strategy = next; - old?.Dispose(); + var old = Strategy; + Strategy = next; + old.Cleanup(); } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs index ce47d470..b0969ca3 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs @@ -4,8 +4,6 @@ using System.IO; using System.IO.Abstractions; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using PG.StarWarsGame.Engine.IO.FileExistStrategies; namespace PG.StarWarsGame.Engine.IO; @@ -18,8 +16,8 @@ namespace PG.StarWarsGame.Engine.IO; /// public sealed partial class PetroglyphFileSystem { - private const char DirectorySeparatorChar = '/'; - private const char AltDirectorySeparatorChar = '\\'; + internal const char DirectorySeparatorChar = '/'; + internal const char AltDirectorySeparatorChar = '\\'; // ReSharper disable once InconsistentNaming private static readonly PathNormalizeOptions PGFileSystemDirectorySeparatorNormalizeOptions = new() @@ -29,12 +27,10 @@ public sealed partial class PetroglyphFileSystem UnifySeparatorKind = DirectorySeparatorKind.System }; - private readonly IFileSystem _underlyingFileSystem; - /// /// Gets the underlying file system abstraction. /// - public IFileSystem UnderlyingFileSystem => _underlyingFileSystem; + public IFileSystem UnderlyingFileSystem { get; } /// /// Initializes a new instance of the class. @@ -45,11 +41,9 @@ public PetroglyphFileSystem(IServiceProvider serviceProvider) { if (serviceProvider == null) throw new ArgumentNullException(nameof(serviceProvider)); - _underlyingFileSystem = serviceProvider.GetRequiredService(); + UnderlyingFileSystem = serviceProvider.GetRequiredService(); - _strategy = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? new WindowsFileExistsStrategy(_underlyingFileSystem) - : new VirtualFileExistsStrategy(_underlyingFileSystem, new WineFileExistsStrategy(_underlyingFileSystem)); + Strategy = CreateDefaultStrategy(); } /// @@ -69,7 +63,7 @@ public bool HasTrailingDirectorySeparator(ReadOnlySpan path) internal FileSystemStream OpenRead(string filePath) { - return _underlyingFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + return UnderlyingFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); } private static bool IsPathRooted(ReadOnlySpan path) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj index 02421b1e..3a4dd213 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj @@ -8,7 +8,7 @@ alamo,petroglyph,glyphx - + true true true @@ -19,7 +19,7 @@ preview - - + + diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/CaseInsensitivityFixture.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/CaseInsensitivityFixture.cs new file mode 100644 index 00000000..867b83cf --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/CaseInsensitivityFixture.cs @@ -0,0 +1,22 @@ +using System; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.Testing; + +namespace PG.StarWarsGame.Engine.Test; + +/// +/// Represents a test fixture for verifying that repositories resolve requests in a case-insensitive manner. +/// +/// Callback that writes fixtures into the virtual game's ConfigureGame origin. +/// Picks the repository under test from the constructed . +/// Lookup key the repository should resolve to the filesystem-backed fixture. +/// Expected content at . +/// Lookup key the repository should resolve to the MEG-backed fixture. +/// Expected content at . +public sealed record CaseInsensitivityFixture( + Action PopulateGame, + Func SelectRepository, + string FilesystemLookup, + string FilesystemContent, + string MegLookup, + string MegContent); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/EngineRepositoryTestBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/EngineRepositoryTestBase.cs new file mode 100644 index 00000000..f1e9553f --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/EngineRepositoryTestBase.cs @@ -0,0 +1,346 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Text; +using AnakinRaW.CommonUtilities.Testing.Extensions; +using PG.StarWarsGame.Engine.ErrorReporting; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.Repositories; +using PG.StarWarsGame.Engine.Testing; +using Testably.Abstractions; +using Xunit; + +namespace PG.StarWarsGame.Engine.Test; + +/// +/// Base class for engine-bound repository tests. +/// +public abstract class EngineRepositoryTestBase : EngineTestBase +{ + /// Gets the engine targeted by this test class. + protected abstract GameEngineType Engine { get; } + + /// Whether the repository resolves a file requested by name only, without its directory. + protected abstract bool ResolvesFileNameWithoutDirectory { get; } + + /// Whether the repository surfaces the path-too-long condition for an overlong request. + protected abstract bool SurfacesPathTooLong { get; } + + /// + /// The repository origins in descending lookup priority for this test class's . + /// + private IReadOnlyList ExpectedLoadOrder => Engine switch + { + GameEngineType.Foc => + [ + RepositoryLayer.Mod, + RepositoryLayer.Game, + RepositoryLayer.MasterMeg, + RepositoryLayer.Fallback, + ], + GameEngineType.Eaw => + [ + RepositoryLayer.Mod, + RepositoryLayer.Game, + RepositoryLayer.Fallback, + RepositoryLayer.MasterMeg, + ], + _ => throw new ArgumentOutOfRangeException() + }; + + /// + /// Builds a instance that provides test data and logic + /// for verifying case and separator insensitivity in repository lookups. + /// + /// + /// Path casing and separators automatically randomized by the underlying test logic. + /// + /// + /// A containing the configuration and data + /// necessary for case insensitivity tests. + /// + protected abstract CaseInsensitivityFixture BuildCaseInsensitivityFixture(); + + /// A simple fixture describing basic file existence tests for this test class's repository. + protected abstract RepositoryFixture RepositoryFixture { get; } + + protected sealed override IFileSystem CreateFileSystem() + { + // Real file system is required to test integration + // with PG.StarWarsGame.Engine.FileSystem + return new RealFileSystem(); + } + + /// Creates a builder bound to the test base's service provider. + protected VirtualGameRepoBuilder CreateBuilder() + { + return new VirtualGameRepoBuilder(ServiceProvider); + } + + /// Constructs an for this test class's . + /// The returned repository is sealed against further MEG modifications, matching the engine-init lifecycle. + protected IGameRepository CreateRepository(VirtualGameRepo repo) + { + return CreateRepository(Engine, repo, errorReporter: null); + } + + /// Constructs an for this test class's with a custom error reporter + /// to observe init-time assertions (e.g. for missing patches). + protected IGameRepository CreateRepository(VirtualGameRepo repo, IGameEngineErrorReporter? errorReporter) + { + return CreateRepository(Engine, repo, errorReporter); + } + + private GameRepository CreateRepository(GameEngineType engine, VirtualGameRepo repo, IGameEngineErrorReporter? errorReporter) + { + if (repo == null) + throw new ArgumentNullException(nameof(repo)); + + var factory = new GameRepositoryFactory(ServiceProvider); + var wrapper = new GameEngineErrorReporterWrapper(errorReporter); + var gameRepo = factory.Create(engine, repo.GameLocations, wrapper); + gameRepo.Seal(); + return gameRepo; + } + + /// Reads the stream to end as UTF-8 text and disposes it. Convenient for inspecting OpenFile results. + protected static string ReadAll(Stream stream) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + + using (stream) + using (var reader = new StreamReader(stream, Encoding.UTF8)) + return reader.ReadToEnd(); + } + + public static TheoryData SupportedFileSystemStrategies() + { + var data = new TheoryData(); + foreach (var strategy in PetroglyphFileSystemTestHelpers.SupportedForCurrentOS()) + data.Add(strategy); + return data; + } + + [Theory] + [MemberData(nameof(SupportedFileSystemStrategies))] + public void Lookup_IsCaseAndSeparatorInsensitive_AcrossFilesystemAndMeg(PetroglyphFileSystemStrategy strategy) + { + var fixture = BuildCaseInsensitivityFixture(); + + using var virt = CreateBuilder() + .ConfigureGame(fixture.PopulateGame) + .Build(); + var gameRepo = CreateRepository(virt); + gameRepo.PGFileSystem.ApplyStrategy(strategy); + var repoUnderTest = fixture.SelectRepository(gameRepo); + + var separatorRandom = new Random(42); + for (var i = 0; i < 32; i++) + { + var fsVariant = JitterSeparators(string.ShuffleCasing(fixture.FilesystemLookup), separatorRandom); + var megVariant = JitterSeparators(string.ShuffleCasing(fixture.MegLookup), separatorRandom); + + Assert.True(repoUnderTest.FileExists(fsVariant), $"Filesystem variant '{fsVariant}' should resolve."); + Assert.True(repoUnderTest.FileExists(megVariant), $"MEG variant '{megVariant}' should resolve."); + Assert.Equal(fixture.FilesystemContent, ReadAll(repoUnderTest.OpenFile(fsVariant))); + Assert.Equal(fixture.MegContent, ReadAll(repoUnderTest.OpenFile(megVariant))); + } + } + + #region Asset existence + + public static TheoryData AllOrigins() + { + return [RepositoryLayer.Mod, RepositoryLayer.Game, RepositoryLayer.MasterMeg, RepositoryLayer.Fallback]; + } + + [Theory] + [MemberData(nameof(AllOrigins))] + public void FileExists_ResolvesFromOrigin(RepositoryLayer origin) + { + var fixture = RepositoryFixture; + + var builder = CreateBuilder(); + WriteLayer(builder, origin, fixture.ResolvablePath, "content"); + using var repo = builder.Build(); + var repoUnderTest = fixture.SelectRepository(CreateRepository(repo)); + + Assert.True(repoUnderTest.FileExists(fixture.ResolvablePath)); + } + + [Theory] + [MemberData(nameof(AllOrigins))] + public void FileExists_EmptyPath_ReturnsFalse(RepositoryLayer origin) + { + var fixture = RepositoryFixture; + + var builder = CreateBuilder(); + WriteLayer(builder, origin, fixture.ResolvablePath, "content"); + using var repo = builder.Build(); + var repoUnderTest = fixture.SelectRepository(CreateRepository(repo)); + + Assert.False(repoUnderTest.FileExists("")); + } + + [Theory] + [MemberData(nameof(AllOrigins))] + public void FileExists_NullPath_ReturnsFalse(RepositoryLayer origin) + { + var fixture = RepositoryFixture; + + var builder = CreateBuilder(); + WriteLayer(builder, origin, fixture.ResolvablePath, "content"); + using var repo = builder.Build(); + var repoUnderTest = fixture.SelectRepository(CreateRepository(repo)); + + Assert.False(repoUnderTest.FileExists(null!)); + } + + [Fact] + public void FileExists_MissingAsset_ReturnsFalse() + { + var fixture = RepositoryFixture; + + using var repo = CreateBuilder().Build(); + var repoUnderTest = fixture.SelectRepository(CreateRepository(repo)); + + Assert.False(repoUnderTest.FileExists(fixture.ResolvablePath)); + } + + [Fact] + public void FileExists_FileNameWithoutDirectory_ResolvesWhenSupported() + { + // The file is written at its full path; whether its name alone resolves depends on the repository + // prepending a built-in directory (effects, textures) or not (models, base lookup). + var fixture = RepositoryFixture; + var fileName = FileSystem.Path.GetFileName(fixture.ResolvablePath); + + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write(fixture.ResolvablePath, "content")) + .Build(); + var repoUnderTest = fixture.SelectRepository(CreateRepository(repo)); + + Assert.Equal(ResolvesFileNameWithoutDirectory, repoUnderTest.FileExists(fileName)); + } + + [Fact] + public void FileExists_OverlongPath_IsMissingAndFlagsPathTooLongWhenSupported() + { + var fixture = RepositoryFixture; + + using var repo = CreateBuilder().Build(); + var repoUnderTest = fixture.SelectRepository(CreateRepository(repo)); + + var overlong = new string('a', 300); + var found = repoUnderTest.FileExists(overlong.AsSpan(), megFileOnly: false, out var pathTooLong); + + Assert.False(found); + Assert.Equal(SurfacesPathTooLong, pathTooLong); + } + + #endregion + + #region Default loading chain priority + + [Theory] + [MemberData(nameof(SupportedFileSystemStrategies))] + public void Priority_ResolvesAccordingToEngineLoadOrder(PetroglyphFileSystemStrategy strategy) + { + var fixture = RepositoryFixture; + var order = ExpectedLoadOrder; + + // Sliding 'top' down the list makes each origin, + // in turn, the highest-priority one holding the file (Example for FOC): + // top = mod -> all four origins hold it -> mod must win + // top = game -> game, MEG, fallback hold it -> game must win (mod is empty) + // top = MEG -> MEG, fallback hold it -> MEG must win (mod, game empty) + // top = fallback -> only fallback holds it -> fallback wins + for (var top = 0; top < order.Count; top++) + { + var builder = CreateBuilder(); + for (var i = top; i < order.Count; i++) + WriteLayer(builder, order[i], fixture.ResolvablePath, order[i].ToString()); + + using var repo = builder.Build(); + var gameRepo = CreateRepository(repo); + gameRepo.PGFileSystem.ApplyStrategy(strategy); + var repoUnderTest = fixture.SelectRepository(gameRepo); + + var winner = order[top]; + Assert.Equal(winner.ToString(), ReadAll(repoUnderTest.OpenFile(fixture.ResolvablePath))); + } + } + + private static void WriteLayer(VirtualGameRepoBuilder builder, RepositoryLayer layer, string path, string content) + { + switch (layer) + { + case RepositoryLayer.Mod: + builder.WithMod("Mod", w => w.Write(path, content)); + break; + case RepositoryLayer.Game: + builder.ConfigureGame(g => g.Write(path, content)); + break; + case RepositoryLayer.MasterMeg: + builder.ConfigureGame(g => g.WriteMeg("Data/Patch.meg", meg => meg.Add(path, content))); + break; + case RepositoryLayer.Fallback: + builder.WithFallbackGame(f => f.Write(path, content)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(layer), layer, null); + } + } + + [Fact] + public void Priority_ModDeclarationOrderIsRespected() + { + var fixture = RepositoryFixture; + + using var repo = CreateBuilder() + .WithMod("ModA", m => m.Write(fixture.ResolvablePath, "A")) + .WithMod("ModB", m => m.Write(fixture.ResolvablePath, "B")) + .Build(); + var repoUnderTest = fixture.SelectRepository(CreateRepository(repo)); + + Assert.Equal("A", ReadAll(repoUnderTest.OpenFile(fixture.ResolvablePath))); + } + + [Fact] + public void Priority_FallbackDeclarationOrderIsRespected() + { + var fixture = RepositoryFixture; + + using var repo = CreateBuilder() + .WithFallback("FallbackA", w => w.Write(fixture.ResolvablePath, "A")) + .WithFallback("FallbackB", w => w.Write(fixture.ResolvablePath, "B")) + .Build(); + var repoUnderTest = fixture.SelectRepository(CreateRepository(repo)); + + Assert.Equal("A", ReadAll(repoUnderTest.OpenFile(fixture.ResolvablePath))); + } + + #endregion + + private static string JitterSeparators(string path, Random random) + { + var chars = path.ToCharArray(); + for (var i = 0; i < chars.Length; i++) + { + if (chars[i] == '/' || chars[i] == '\\') + chars[i] = random.Next(2) == 0 ? '/' : '\\'; + } + return new string(chars); + } + + /// + /// Returns the last segment of an engine path, treating both '/' and '\' as separators. + /// + // Engine paths always use '\'; System.IO.Path.GetFileName only splits on '\' on Windows. + protected static string EngineFileName(string path) + { + return path.Substring(path.LastIndexOfAny(['/', '\\']) + 1); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/EngineTestBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/EngineTestBase.cs new file mode 100644 index 00000000..a97fe3f3 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/EngineTestBase.cs @@ -0,0 +1,30 @@ +using AnakinRaW.CommonUtilities.Hashing; +using AnakinRaW.CommonUtilities.Testing; +using Microsoft.Extensions.DependencyInjection; +using PG.Commons; +using PG.StarWarsGame.Files.ALO; +using PG.StarWarsGame.Files.MEG; +using PG.StarWarsGame.Files.MTD; +using PG.StarWarsGame.Files.XML; + +namespace PG.StarWarsGame.Engine.Test; + +/// +/// Represents a base class for engine-bound tests, providing the necessary service +/// registrations for constructing game repositories and related components. +/// +public abstract class EngineTestBase : TestBaseWithFileSystem +{ + protected override void SetupServices(IServiceCollection serviceCollection) + { + base.SetupServices(serviceCollection); + serviceCollection.AddSingleton(sp => new HashingService(sp)); + + serviceCollection.SupportMTD(); + serviceCollection.SupportMEG(); + serviceCollection.SupportALO(); + serviceCollection.SupportXML(); + PetroglyphCommons.ContributeServices(serviceCollection); + PetroglyphEngineServiceContribution.ContributeServices(serviceCollection); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/EffectsRepositoryTests.Existence.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/EffectsRepositoryTests.Existence.cs new file mode 100644 index 00000000..8a76e17f --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/EffectsRepositoryTests.Existence.cs @@ -0,0 +1,110 @@ +using Xunit; + +namespace PG.StarWarsGame.Engine.Test.IO; + +public abstract partial class EffectsRepositoryTests : EngineRepositoryTestBase +{ + protected override bool ResolvesFileNameWithoutDirectory => true; + + protected override bool SurfacesPathTooLong => false; + + protected override CaseInsensitivityFixture BuildCaseInsensitivityFixture() + { + return new CaseInsensitivityFixture( + PopulateGame: g => + { + g.Write("Data/Art/Shaders/MyShader.fx", "fs-fx"); + g.RegisterAndWriteMeg("Data/Shaders.meg", meg => meg.Add("Data/Art/Shaders/OtherShader.fx", "meg-fx")); + }, + SelectRepository: gameRepo => gameRepo.EffectsRepository, + FilesystemLookup: "MyShader", + FilesystemContent: "fs-fx", + MegLookup: "OtherShader", + MegContent: "meg-fx"); + } + + protected override RepositoryFixture RepositoryFixture => new( + SelectRepository: gameRepo => gameRepo.EffectsRepository, + ResolvablePath: "Data/Art/Shaders/MyShader.fx"); + + public static TheoryData ResolvableShaderLocations_Root() + { + return ShaderLocationsTheoryData( + "MyShader.fx", + "MyShader.fxo", + "MyShader.fxh"); + } + + public static TheoryData ResolvableShaderLocations_DataArts() + { + return ShaderLocationsTheoryData( + "Data/Art/Shaders/MyShader.fx", + "Data/Art/Shaders/Terrain/MyShader.fx"); + } + + [Theory] + [MemberData(nameof(ResolvableShaderLocations_Root))] + [MemberData(nameof(ResolvableShaderLocations_DataArts))] + public void FileExists_ResolvesShaderAtSupportedLocation(string writtenPath, string input) + { + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write(writtenPath, "x")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.True(gameRepo.EffectsRepository.FileExists(input)); + } + + [Theory] + [InlineData("Engine\\MyShader")] + [InlineData("Engine/MyShader")] + [InlineData("Engine\\MyShader.fx")] + [InlineData("Engine\\MyShader.bogus")] + public void FileExists_ShaderAddressedWithEngineSubdirPrefix_Resolves(string input) + { + // The Engine directory is not one of the hardcoded shader search paths, but a shader placed there is + // still reachable when the request carries the "Engine\" prefix: it resolves under the SHADERS base. + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write("Data/Art/Shaders/Engine/MyShader.fx", "engine-fx")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.True(gameRepo.EffectsRepository.FileExists(input)); + Assert.Equal("engine-fx", ReadAll(gameRepo.EffectsRepository.OpenFile(input))); + } + + [Theory] + [MemberData(nameof(ResolvableShaderLocations_Root))] + public void FileExists_ShaderInModRoot_ShouldNotResolve(string writtenPath, string input) + { + using var repo = CreateBuilder() + .ConfigureGame(g => { }) + .WithMod("MyMod", w => w.Write(writtenPath, "fx-in-mod")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.False(gameRepo.EffectsRepository.FileExists(input)); + } + + [Theory] + [MemberData(nameof(ResolvableShaderLocations_Root))] + public void FileExists_ShaderInFallbackRoot_ShouldNotResolve(string writtenPath, string input) + { + using var repo = CreateBuilder() + .ConfigureGame(g => { }) + .WithFallback("fallback", w => w.Write(writtenPath, "fx-in-fallback")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.False(gameRepo.EffectsRepository.FileExists(input)); + } + + private static TheoryData ShaderLocationsTheoryData(params string[] locations) + { + var data = new TheoryData(); + foreach (var location in locations) + foreach (var input in RepositoryTestData.EquivalentShaderNames) + data.Add(location, input); + return data; + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/EffectsRepositoryTests.Priority.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/EffectsRepositoryTests.Priority.cs new file mode 100644 index 00000000..8cb583ef --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/EffectsRepositoryTests.Priority.cs @@ -0,0 +1,215 @@ +using Xunit; + +namespace PG.StarWarsGame.Engine.Test.IO; + +public abstract partial class EffectsRepositoryTests +{ + #region Extension priority (outer loop) + + [Theory] + [MemberData(nameof(RepositoryTestData.ShaderInputs), MemberType = typeof(RepositoryTestData))] + public void Extension_FxBeatsOtherExtensions_SameLocation(string input) + { + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("MyShader.fx", "fx"); + g.Write("MyShader.fxo", "fxo"); + g.Write("MyShader.fxh", "fxh"); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("fx", ReadAll(gameRepo.EffectsRepository.OpenFile(input))); + } + + [Theory] + [MemberData(nameof(RepositoryTestData.ShaderInputs), MemberType = typeof(RepositoryTestData))] + public void Extension_FxoBeatsFxh_SameLocation(string input) + { + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("MyShader.fxo", "fxo"); + g.Write("MyShader.fxh", "fxh"); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("fxo", ReadAll(gameRepo.EffectsRepository.OpenFile(input))); + } + + [Theory] + [MemberData(nameof(RepositoryTestData.ShaderInputs), MemberType = typeof(RepositoryTestData))] + public void Extension_FxInDeepestDir_BeatsFxoAtBare(string input) + { + // Extension is the outer key in the lookup loop: + // .fx is probed in every directory before .fxo is tried anywhere. + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("Data/Art/Shaders/MyShader.fx", "fx-deepest"); + g.Write("MyShader.fxo", "fxo-bare"); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("fx-deepest", ReadAll(gameRepo.EffectsRepository.OpenFile(input))); + } + + [Theory] + [MemberData(nameof(RepositoryTestData.ShaderInputs), MemberType = typeof(RepositoryTestData))] + public void Extension_FxInFallback_BeatsFxoInModSameDir(string input) + { + // Extension priority dominates over chain position: + // .fx in fallback beats .fxo in a mod, because .fx is probed everywhere first. + using var repo = CreateBuilder() + .WithMod("Mod", m => m.Write("Data/Art/Shaders/MyShader.fxo", "mod-fxo")) + .WithFallbackGame(f => f.Write("Data/Art/Shaders/MyShader.fx", "fb-fx")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("fb-fx", ReadAll(gameRepo.EffectsRepository.OpenFile(input))); + } + + #endregion + + #region Directory priority (middle loop) + + [Theory] + [MemberData(nameof(RepositoryTestData.ShaderInputs), MemberType = typeof(RepositoryTestData))] + public void Directory_BareBeatsShaders_SameExtension(string input) + { + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("MyShader.fx", "bare"); + g.Write("Data/Art/Shaders/MyShader.fx", "shaders"); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("bare", ReadAll(gameRepo.EffectsRepository.OpenFile(input))); + } + + [Theory] + [MemberData(nameof(RepositoryTestData.ShaderInputs), MemberType = typeof(RepositoryTestData))] + public void Directory_ShadersBeatsTerrain(string input) + { + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("Data/Art/Shaders/MyShader.fx", "shaders"); + g.Write("Data/Art/Shaders/Terrain/MyShader.fx", "terrain"); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("shaders", ReadAll(gameRepo.EffectsRepository.OpenFile(input))); + } + + #endregion + + #region Root vs Data priority (inner loop) + + [Theory] + [MemberData(nameof(RepositoryTestData.ShaderInputs), MemberType = typeof(RepositoryTestData))] + public void RootLevel_ModIsInvisible_GameDirWins(string input) + { + using var repo = CreateBuilder() + .WithMod("Mod", m => m.Write("MyShader.fx", "mod-root-unreachable")) + .ConfigureGame(g => g.Write("MyShader.fx", "game-root")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("game-root", ReadAll(gameRepo.EffectsRepository.OpenFile(input))); + } + + [Theory] + [MemberData(nameof(RepositoryTestData.ShaderInputs), MemberType = typeof(RepositoryTestData))] + public void RootLevel_FallbackIsInvisible_MegWins(string input) + { + using var repo = CreateBuilder() + .WithFallbackGame(f => f.Write("MyShader.fx", "fb-root-unreachable")) + .ConfigureGame(g => g.RegisterAndWriteMeg("Data/Shaders.meg", m => m.Add("MyShader.fx", "meg"))) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("meg", ReadAll(gameRepo.EffectsRepository.OpenFile(input))); + } + + [Theory] + [MemberData(nameof(RepositoryTestData.ShaderInputs), MemberType = typeof(RepositoryTestData))] + public void RootLevel_GameBeatsMeg(string input) + { + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("MyShader.fx", "game-root"); + g.RegisterAndWriteMeg("Data/Shaders.meg", m => m.Add("MyShader.fx", "meg-root")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("game-root", ReadAll(gameRepo.EffectsRepository.OpenFile(input))); + } + + #endregion + + [Theory] + [MemberData(nameof(RepositoryTestData.ShaderInputs), MemberType = typeof(RepositoryTestData))] + public void FullMatrix_HighestReachablePriorityWins(string input) + { + using var repo = CreateBuilder() + .WithMod("Mod", m => + { + m.Write("Data/Art/Shaders/MyShader.fx", "WINNER"); + m.Write("Data/Art/Shaders/Terrain/MyShader.fx", "L1"); + m.Write("Data/Art/Shaders/MyShader.fxo", "L2"); + }) + .ConfigureGame(g => + { + g.Write("Data/Art/Shaders/MyShader.fx", "L3"); + g.Write("Data/Art/Shaders/Engine/MyShader.fx", "L4"); + g.RegisterAndWriteMeg("Data/Shaders.meg", meg => + { + meg.Add("Data/Art/Shaders/MyShader.fx", "L5"); + meg.Add("Data/Art/Shaders/MyShader.fxo", "L6"); + }); + }) + .WithFallbackGame(f => + { + f.Write("Data/Art/Shaders/MyShader.fx", "L7"); + f.Write("Data/Art/Shaders/MyShader.fxh", "L8"); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("WINNER", ReadAll(gameRepo.EffectsRepository.OpenFile(input))); + } + + [Theory] + [MemberData(nameof(RepositoryTestData.ShaderInputs), MemberType = typeof(RepositoryTestData))] + public void GameRootFx_BeatsModShadersFx(string input) + { + using var repo = CreateBuilder() + .WithMod("Mod", m => m.Write("Data/Art/Shaders/MyShader.fx", "mod-shaders")) + .ConfigureGame(g => g.Write("MyShader.fx", "game-root")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("game-root", ReadAll(gameRepo.EffectsRepository.OpenFile(input))); + } + + [Theory] + [MemberData(nameof(RepositoryTestData.ShaderInputs), MemberType = typeof(RepositoryTestData))] + public void OnlyFxh_StillResolves(string input) + { + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write("Data/Art/Shaders/MyShader.fxh", "fxh-deepest")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("fxh-deepest", ReadAll(gameRepo.EffectsRepository.OpenFile(input))); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/ExtensionFallbackRepositoryTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/ExtensionFallbackRepositoryTests.cs new file mode 100644 index 00000000..8d38651a --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/ExtensionFallbackRepositoryTests.cs @@ -0,0 +1,146 @@ +using Xunit; + +namespace PG.StarWarsGame.Engine.Test.IO; + +public abstract class ExtensionFallbackRepositoryTests : EngineRepositoryTestBase +{ + /// The extension a request falls back to when its own extension is not present. + protected abstract string FallbackExtension { get; } + + /// A supported extension that resolves by its own name but is never a fallback target. + protected abstract string SecondaryExtension { get; } + + [Fact] + public void FileExists_EachSupportedExtension_ResolvesByItsOwnName() + { + var select = RepositoryFixture.SelectRepository; + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write(AssetPath(FallbackExtension), "fallback"); + g.Write(AssetPath(SecondaryExtension), "secondary"); + }) + .Build(); + var repoUnderTest = select(CreateRepository(repo)); + + Assert.True(repoUnderTest.FileExists(AssetPath(FallbackExtension))); + Assert.Equal("fallback", ReadAll(repoUnderTest.OpenFile(AssetPath(FallbackExtension)))); + Assert.True(repoUnderTest.FileExists(AssetPath(SecondaryExtension))); + Assert.Equal("secondary", ReadAll(repoUnderTest.OpenFile(AssetPath(SecondaryExtension)))); + } + + [Fact] + public void FileExists_UnsupportedExtension_FallsBackToFallbackExtension() + { + var select = RepositoryFixture.SelectRepository; + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write(AssetPath(FallbackExtension), "fallback")) + .Build(); + var repoUnderTest = select(CreateRepository(repo)); + + Assert.True(repoUnderTest.FileExists(AssetPath(".unsupported"))); + } + + [Fact] + public void FileExists_UnsupportedExtension_FallsBackToFallbackExtensionInMeg() + { + var select = RepositoryFixture.SelectRepository; + using var repo = CreateBuilder() + .ConfigureGame(g => g.RegisterAndWriteMeg("Data/Assets.meg", + meg => meg.Add(AssetPath(FallbackExtension), "fallback"))) + .Build(); + var repoUnderTest = select(CreateRepository(repo)); + + Assert.True(repoUnderTest.FileExists(AssetPath(".unsupported"))); + } + + [Fact] + public void FileExists_UnsupportedExtension_DoesNotResolveSecondaryFile() + { + var select = RepositoryFixture.SelectRepository; + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write(AssetPath(SecondaryExtension), "secondary")) + .Build(); + var repoUnderTest = select(CreateRepository(repo)); + + Assert.False(repoUnderTest.FileExists(AssetPath(".unsupported"))); + } + + [Fact] + public void FileExists_ExtensionlessRequest_FallsBackToFallbackExtension() + { + var select = RepositoryFixture.SelectRepository; + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write(AssetPath(FallbackExtension), "fallback")) + .Build(); + var repoUnderTest = select(CreateRepository(repo)); + + Assert.True(repoUnderTest.FileExists(AssetPath(string.Empty))); + } + + [Fact] + public void FileExists_FileNameWithoutDirectory_FallsBackWhenSupported() + { + // Resolving a bare name with a non-fallback extension needs both the implicit directory and the + // extension fallback, so it succeeds only where the repository has an implicit directory. + var select = RepositoryFixture.SelectRepository; + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write(AssetPath(FallbackExtension), "fallback")) + .Build(); + var repoUnderTest = select(CreateRepository(repo)); + + var bareName = FileSystem.Path.GetFileName(AssetPath(SecondaryExtension)); + Assert.Equal(ResolvesFileNameWithoutDirectory, repoUnderTest.FileExists(bareName)); + } + + [Fact] + public void Priority_ExactExtensionBeatsFallbackExtension_SameOrigin() + { + var select = RepositoryFixture.SelectRepository; + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write(AssetPath(SecondaryExtension), "secondary"); + g.Write(AssetPath(FallbackExtension), "fallback"); + }) + .Build(); + var repoUnderTest = select(CreateRepository(repo)); + + Assert.Equal("secondary", ReadAll(repoUnderTest.OpenFile(AssetPath(SecondaryExtension)))); + } + + [Fact] + public void Priority_ExactExtensionInFallbackOrigin_BeatsFallbackExtensionInMod() + { + // The exact-extension pass walks the whole chain before the fallback-extension pass, so an exact hit + // in the fallback origin outranks a fallback-extension hit in a mod — extension dominates chain position. + var select = RepositoryFixture.SelectRepository; + using var repo = CreateBuilder() + .WithMod("Mod", m => m.Write(AssetPath(FallbackExtension), "mod-fallback")) + .WithFallbackGame(f => f.Write(AssetPath(SecondaryExtension), "fb-secondary")) + .Build(); + var repoUnderTest = select(CreateRepository(repo)); + + Assert.Equal("fb-secondary", ReadAll(repoUnderTest.OpenFile(AssetPath(SecondaryExtension)))); + } + + [Fact] + public void Priority_FallbackExtension_RespectsChainOrder() + { + // With no exact match anywhere, the fallback-extension pass still honors the chain: a mod's copy wins. + var select = RepositoryFixture.SelectRepository; + using var repo = CreateBuilder() + .WithMod("Mod", m => m.Write(AssetPath(FallbackExtension), "mod-fallback")) + .ConfigureGame(g => g.Write(AssetPath(FallbackExtension), "game-fallback")) + .Build(); + var repoUnderTest = select(CreateRepository(repo)); + + Assert.Equal("mod-fallback", ReadAll(repoUnderTest.OpenFile(AssetPath(".unsupported")))); + } + + private string AssetPath(string extension) + { + var resolvable = RepositoryFixture.ResolvablePath; + return resolvable.Substring(0, resolvable.LastIndexOf('.')) + extension; + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocEffectsRepositoryTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocEffectsRepositoryTests.cs new file mode 100644 index 00000000..b3bcf355 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocEffectsRepositoryTests.cs @@ -0,0 +1,6 @@ +namespace PG.StarWarsGame.Engine.Test.IO.Foc; + +public class FocEffectsRepositoryTests : EffectsRepositoryTests +{ + protected override GameEngineType Engine => GameEngineType.Foc; +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocGameRepositoryTests.Initialization.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocGameRepositoryTests.Initialization.cs new file mode 100644 index 00000000..007e65a1 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocGameRepositoryTests.Initialization.cs @@ -0,0 +1,147 @@ +using System.Linq; +using PG.StarWarsGame.Engine.ErrorReporting; +using Xunit; + +namespace PG.StarWarsGame.Engine.Test.IO.Foc; + +public partial class FocGameRepositoryTests +{ + [Fact] + public void Init_LoadsEawFallbackPatchMegs() + { + using var repo = CreateBuilder() + .WithFallbackGame(f => + { + f.WriteMeg("Data/Patch.meg", m => m.Add("Init/EawPatch.bin", "EawPatch")); + f.WriteMeg("Data/Patch2.meg", m => m.Add("Init/EawPatch2.bin", "EawPatch2")); + f.WriteMeg("Data/64Patch.meg", m => m.Add("Init/Eaw64Patch.bin", "Eaw64Patch")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("EawPatch", ReadAll(gameRepo.OpenFile("Init/EawPatch.bin", megFileOnly: true))); + Assert.Equal("EawPatch2", ReadAll(gameRepo.OpenFile("Init/EawPatch2.bin", megFileOnly: true))); + Assert.Equal("Eaw64Patch", ReadAll(gameRepo.OpenFile("Init/Eaw64Patch.bin", megFileOnly: true))); + } + + [Fact] + public void Init_LoadsEawFallbackMegaFilesXmlMegs() + { + const string megaFilesXml = """ + + + Data/EawCustom.meg + + """; + using var repo = CreateBuilder() + .WithFallbackGame(f => + { + f.Write("Data/MegaFiles.xml", megaFilesXml); + f.WriteMeg("Data/EawCustom.meg", m => m.Add("Init/InEawCustom.bin", "EawCustom")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("EawCustom", ReadAll(gameRepo.OpenFile("Init/InEawCustom.bin", megFileOnly: true))); + } + + [Fact] + public void MasterMeg_EawPatch2OverridesEawPatch() + { + using var repo = CreateBuilder() + .WithFallbackGame(f => + { + f.WriteMeg("Data/Patch.meg", m => m.Add("Init/EawConflict.bin", "EawPatch")); + f.WriteMeg("Data/Patch2.meg", m => m.Add("Init/EawConflict.bin", "EawPatch2")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("EawPatch2", ReadAll(gameRepo.OpenFile("Init/EawConflict.bin", megFileOnly: true))); + } + + [Fact] + public void MasterMeg_FocPatchOverridesEaw64Patch() + { + // All four EaW slots are loaded before any FoC slot, so even the latest EaW (64Patch) + // gets overridden by the earliest FoC (Patch). + // + // The empty FoC Patch2/64Patch are necessary: the FoC ctor probes "Data/Patch2.meg" and + // "Data/64Patch.meg" via the full lookup chain (mods → game → master → fallback). Without + // empty game-dir shadows, the foc 64Patch probe would fall through to the fallback's 64Patch + // and re-load the EaW entry as the very last write, defeating the test. + using var repo = CreateBuilder() + .WithFallbackGame(f => f.WriteMeg("Data/64Patch.meg", + m => m.Add("Init/CrossConflict.bin", "Eaw64Patch"))) + .ConfigureGame(g => + { + g.WriteMeg("Data/Patch.meg", m => m.Add("Init/CrossConflict.bin", "FocPatch")); + g.WriteMeg("Data/Patch2.meg", _ => { }); + g.WriteMeg("Data/64Patch.meg", _ => { }); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("FocPatch", ReadAll(gameRepo.OpenFile("Init/CrossConflict.bin", megFileOnly: true))); + } + + [Fact] + public void MasterMeg_FocMegaFilesXmlOverridesEaw64Patch() + { + const string focMegaFilesXml = """ + + + Data/FocCustom.meg + + """; + using var repo = CreateBuilder() + .WithFallbackGame(f => f.WriteMeg("Data/64Patch.meg", + m => m.Add("Init/CrossConflict.bin", "Eaw64Patch"))) + .ConfigureGame(g => + { + g.Write("Data/MegaFiles.xml", focMegaFilesXml); + g.WriteMeg("Data/FocCustom.meg", m => m.Add("Init/CrossConflict.bin", "FocCustom")); + g.WriteMeg("Data/Patch.meg", _ => { }); + g.WriteMeg("Data/Patch2.meg", _ => { }); + g.WriteMeg("Data/64Patch.meg", _ => { }); // shadow fallback's 64Patch + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("FocCustom", ReadAll(gameRepo.OpenFile("Init/CrossConflict.bin", megFileOnly: true))); + } + + [Fact] + public void ErrorReporter_MissingEawPatches_AssertsFileNotFound_WhenFallbackConfigured() + { + using var repo = CreateBuilder() + .WithFallbackGame(_ => { }) + .Build(); + var reporter = new RecordingErrorReporter(); + + _ = CreateRepository(repo, reporter); + + var names = reporter.Asserts + .Where(a => a.Kind == EngineAssertKind.FileNotFound) + .Select(a => EngineFileName(a.Value)) + .ToList(); + // 6 missing: Patch / Patch2 / 64Patch in both the fallback and the FoC dir. + Assert.Equal(2, names.Count(v => v == "Patch.meg")); + Assert.Equal(2, names.Count(v => v == "Patch2.meg")); + Assert.Equal(2, names.Count(v => v == "64Patch.meg")); + } + + [Fact] + public void ErrorReporter_NoFallbackConfigured_NoEawAttempts() + { + using var repo = CreateBuilder().Build(); + var reporter = new RecordingErrorReporter(); + + _ = CreateRepository(repo, reporter); + + var fileNotFound = reporter.Asserts + .Where(a => a.Kind == EngineAssertKind.FileNotFound) + .ToList(); + Assert.Equal(3, fileNotFound.Count); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocGameRepositoryTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocGameRepositoryTests.cs new file mode 100644 index 00000000..0c81ae50 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocGameRepositoryTests.cs @@ -0,0 +1,6 @@ +namespace PG.StarWarsGame.Engine.Test.IO.Foc; + +public partial class FocGameRepositoryTests : GameRepositoryTests +{ + protected override GameEngineType Engine => GameEngineType.Foc; +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocModelRepositoryTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocModelRepositoryTests.cs new file mode 100644 index 00000000..5209a92c --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocModelRepositoryTests.cs @@ -0,0 +1,6 @@ +namespace PG.StarWarsGame.Engine.Test.IO.Foc; + +public class FocModelRepositoryTests : ModelRepositoryTests +{ + protected override GameEngineType Engine => GameEngineType.Foc; +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocTextureRepositoryTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocTextureRepositoryTests.cs new file mode 100644 index 00000000..6650c664 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/Foc/FocTextureRepositoryTests.cs @@ -0,0 +1,6 @@ +namespace PG.StarWarsGame.Engine.Test.IO.Foc; + +public class FocTextureRepositoryTests : TextureRepositoryTests +{ + protected override GameEngineType Engine => GameEngineType.Foc; +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryFactoryTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryFactoryTests.cs new file mode 100644 index 00000000..a4a03d6b --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryFactoryTests.cs @@ -0,0 +1,40 @@ +using System; +using PG.StarWarsGame.Engine.ErrorReporting; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.Repositories; +using PG.StarWarsGame.Engine.Testing; +using Xunit; + +namespace PG.StarWarsGame.Engine.Test.IO; + +/// +/// Tests engine dispatch in isolation. The factory is engine-agnostic +/// infrastructure, so it is a standalone test class rather than an engine-bound repository test. +/// +public class GameRepositoryFactoryTests : EngineTestBase +{ + private GameRepository Create(GameEngineType engine, VirtualGameRepo repo) + { + var factory = new GameRepositoryFactory(ServiceProvider); + return factory.Create(engine, repo.GameLocations, new GameEngineErrorReporterWrapper(null)); + } + + [Fact] + public void Create_Foc_ReturnsFocGameRepositoryWithMatchingEngineType() + { + using var repo = new VirtualGameRepoBuilder(ServiceProvider).Build(); + + var gameRepo = Create(GameEngineType.Foc, repo); + + Assert.IsType(gameRepo); + Assert.Equal(GameEngineType.Foc, gameRepo.EngineType); + } + + [Fact] + public void Create_Eaw_ThrowsNotImplemented() + { + using var repo = new VirtualGameRepoBuilder(ServiceProvider).Build(); + + Assert.Throws(() => Create(GameEngineType.Eaw, repo)); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.FileLookup.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.FileLookup.cs new file mode 100644 index 00000000..4005e8a3 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.FileLookup.cs @@ -0,0 +1,209 @@ +using System.IO; +using Xunit; + +namespace PG.StarWarsGame.Engine.Test.IO; + +public abstract partial class GameRepositoryTests +{ + [Fact] + public void FileExists_MissingFile_ReturnsFalse() + { + using var repo = CreateBuilder().Build(); + var gameRepo = CreateRepository(repo); + + Assert.False(gameRepo.FileExists("Data/XML/DoesNotExist.xml")); + } + + [Fact] + public void FileExists_FileInGameDir_ReturnsTrue() + { + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write("Data/XML/Foo.xml", "")) + .ConfigureGame(g => g.WriteXml("Bar.xml", "")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.True(gameRepo.FileExists("Data/XML/Foo.xml")); + Assert.True(gameRepo.FileExists("Data/XML/Bar.xml")); + } + + [Fact] + public void FileExists_OutParams_FileInGameDir_NotInMeg() + { + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write("Data/XML/Foo.xml", "")) + .Build(); + var gameRepo = CreateRepository(repo); + + var found = gameRepo.FileExists("Data/XML/Foo.xml", megFileOnly: false, out var inMeg, out var actualPath); + + Assert.True(found); + Assert.False(inMeg); + Assert.NotNull(actualPath); + Assert.EndsWith("Foo.xml", actualPath); + } + + [Fact] + public void FileExists_OutParams_MissingFile_ActualPathIsNull() + { + using var repo = CreateBuilder().Build(); + var gameRepo = CreateRepository(repo); + + var found = gameRepo.FileExists("Data/XML/Missing.xml", megFileOnly: false, out _, out var actualPath); + + Assert.False(found); + Assert.Null(actualPath); + } + + [Fact] + public void FileExists_MegFileOnlyFlag_FileSystemHitIsIgnored() + { + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write("Data/XML/Foo.xml", "")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.True(gameRepo.FileExists("Data/XML/Foo.xml")); + Assert.False(gameRepo.FileExists("Data/XML/Foo.xml", megFileOnly: true)); + } + + [Fact] + public void OpenFile_FileInGameDir_ReturnsStreamWithContent() + { + const string payload = "hello-world"; + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write("Data/XML/Foo.xml", payload)) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal(payload, ReadAll(gameRepo.OpenFile("Data/XML/Foo.xml"))); + } + + [Fact] + public void OpenFile_Missing_ThrowsFileNotFound() + { + using var repo = CreateBuilder().Build(); + var gameRepo = CreateRepository(repo); + + Assert.Throws(() => gameRepo.OpenFile("Data/XML/Missing.xml")); + } + + [Fact] + public void TryOpenFile_Missing_ReturnsNull() + { + using var repo = CreateBuilder().Build(); + var gameRepo = CreateRepository(repo); + + Assert.Null(gameRepo.TryOpenFile("Data/XML/Missing.xml")); + } + + [Fact] + public void TryOpenFile_Present_ReturnsStream() + { + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write("Data/XML/Foo.xml", "x")) + .Build(); + var gameRepo = CreateRepository(repo); + + using var stream = gameRepo.TryOpenFile("Data/XML/Foo.xml"); + Assert.NotNull(stream); + Assert.Equal("x", ReadAll(stream)); + } + + [Fact] + public void FileExists_ModOverridesGame() + { + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write("Data/XML/Foo.xml", "from-game")) + .WithMod("MyMod", m => m.Write("Data/XML/Foo.xml", "from-mod")) + .Build(); + var gameRepo = CreateRepository(repo); + + gameRepo.FileExists("Data/XML/Foo.xml", megFileOnly: false, out _, out var actualPath); + Assert.NotNull(actualPath); + Assert.Contains("MyMod", actualPath); + + Assert.Equal("from-mod", ReadAll(gameRepo.OpenFile("Data/XML/Foo.xml"))); + } + + [Fact] + public void FileExists_NonDataPath_DoesNotConsultModOrFallback() + { + using var repo = CreateBuilder() + .WithMod("MyMod", f => f.Write("Other/Hidden.xml", "mod")) + .WithFallbackGame(f => f.Write("Other/Hidden.xml", "fbg")) + .WithFallback("Fallback", f => f.Write("Other/Hidden.xml", "fb")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.False(gameRepo.FileExists("Other/Hidden.xml")); + } + + [Fact] + public void FileExists_NonDataPath_TakeFromGame() + { + using var repo = CreateBuilder() + .ConfigureGame(f => f.Write("Other/Foo.xml", "game")) + .WithMod("MyMod", f => f.Write("Other/Foo.xml", "mod")) + .WithFallbackGame(f => f.Write("Other/Foo.xml", "fbg")) + .WithFallback("Fallback", f => f.Write("Other/Foo.xml", "fb")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.True(gameRepo.FileExists("Other/Foo.xml")); + Assert.Equal("game", ReadAll(gameRepo.OpenFile("Other/Foo.xml"))); + + } + + [Fact] + public void FileExists_ForwardSlashAndBackslash_BothResolve() + { + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write("Data/XML/Foo.xml", "x")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.True(gameRepo.FileExists("Data/XML/Foo.xml")); + Assert.True(gameRepo.FileExists("Data\\XML\\Foo.xml")); + Assert.True(gameRepo.FileExists("Data\\XML/Foo.xml")); + Assert.True(gameRepo.FileExists("Data/XML\\Foo.xml")); + } + + [Fact] + public void FileExists_DataPathWithDotPrefix_ModHit() + { + using var repo = CreateBuilder() + .WithMod("MyMod", f => f.Write("Data/XML/Foo.xml", "fb")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.True(gameRepo.FileExists("./Data/XML/Foo.xml")); + Assert.True(gameRepo.FileExists(".\\Data\\XML\\Foo.xml")); + Assert.True(gameRepo.FileExists("./Data\\XML\\Foo.xml")); + Assert.True(gameRepo.FileExists(".\\Data/XML\\Foo.xml")); + } + + [Fact] + public void FileExists_DataPathWithDotPrefix_FallbackHit() + { + using var repo = CreateBuilder() + .WithFallbackGame(f => f.Write("Data/XML/Foo.xml", "fb")) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.True(gameRepo.FileExists("./Data/XML/Foo.xml")); + Assert.True(gameRepo.FileExists(".\\Data\\XML\\Foo.xml")); + Assert.True(gameRepo.FileExists("./Data\\XML\\Foo.xml")); + Assert.True(gameRepo.FileExists(".\\Data/XML\\Foo.xml")); + } + + [Fact] + public void EmptyRepository_NoErrors_LookupReturnsFalse() + { + using var repo = CreateBuilder().Build(); + var gameRepo = CreateRepository(repo); + + Assert.False(gameRepo.FileExists("anything.txt")); + Assert.Null(gameRepo.TryOpenFile("anything.txt")); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.Initialization.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.Initialization.cs new file mode 100644 index 00000000..b7e0adc5 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.Initialization.cs @@ -0,0 +1,214 @@ +using System.Linq; +using PG.StarWarsGame.Engine.ErrorReporting; +using Xunit; + +namespace PG.StarWarsGame.Engine.Test.IO; + +public abstract partial class GameRepositoryTests +{ + // ----------------------- pre-load: which MEGs are loaded at construction time ----------------------- + + [Fact] + public void Init_LoadsAllPatchMegs() + { + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.WriteMeg("Data/Patch.meg", m => m.Add("Init/InPatch.bin", "Patch")); + g.WriteMeg("Data/Patch2.meg", m => m.Add("Init/InPatch2.bin", "Patch2")); + g.WriteMeg("Data/64Patch.meg", m => m.Add("Init/In64Patch.bin", "64Patch")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("Patch", ReadAll(gameRepo.OpenFile("Init/InPatch.bin", megFileOnly: true))); + Assert.Equal("Patch2", ReadAll(gameRepo.OpenFile("Init/InPatch2.bin", megFileOnly: true))); + Assert.Equal("64Patch", ReadAll(gameRepo.OpenFile("Init/In64Patch.bin", megFileOnly: true))); + } + + [Fact] + public void Init_LoadsMegsListedInMegaFilesXml() + { + const string megaFilesXml = """ + + + Data/First.meg + Data/Second.meg + + """; + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("Data/MegaFiles.xml", megaFilesXml); + g.WriteMeg("Data/First.meg", m => m.Add("Init/InFirst.bin", "First")); + g.WriteMeg("Data/Second.meg", m => m.Add("Init/InSecond.bin", "Second")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("First", ReadAll(gameRepo.OpenFile("Init/InFirst.bin", megFileOnly: true))); + Assert.Equal("Second", ReadAll(gameRepo.OpenFile("Init/InSecond.bin", megFileOnly: true))); + } + + // ----------------------- master MEG ordering: later load wins ----------------------- + + [Fact] + public void MasterMeg_Patch2OverridesPatch() + { + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.WriteMeg("Data/Patch.meg", m => m.Add("Init/Conflict.bin", "Patch")); + g.WriteMeg("Data/Patch2.meg", m => m.Add("Init/Conflict.bin", "Patch2")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("Patch2", ReadAll(gameRepo.OpenFile("Init/Conflict.bin", megFileOnly: true))); + } + + [Fact] + public void MasterMeg_64PatchOverridesPatch2() + { + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.WriteMeg("Data/Patch2.meg", m => m.Add("Init/Conflict.bin", "Patch2")); + g.WriteMeg("Data/64Patch.meg", m => m.Add("Init/Conflict.bin", "64Patch")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("64Patch", ReadAll(gameRepo.OpenFile("Init/Conflict.bin", megFileOnly: true))); + } + + [Fact] + public void MasterMeg_PatchOverridesMegaFilesXmlEntries() + { + const string megaFilesXml = """ + + + Data/Custom.meg + + """; + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("Data/MegaFiles.xml", megaFilesXml); + g.WriteMeg("Data/Custom.meg", m => m.Add("Init/Conflict.bin", "Custom")); + g.WriteMeg("Data/Patch.meg", m => m.Add("Init/Conflict.bin", "Patch")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("Patch", ReadAll(gameRepo.OpenFile("Init/Conflict.bin", megFileOnly: true))); + } + + [Fact] + public void MasterMeg_MegaFilesXml_LaterMegOverridesEarlier() + { + const string megaFilesXml = """ + + + Data/First.meg + Data/Second.meg + + """; + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("Data/MegaFiles.xml", megaFilesXml); + g.WriteMeg("Data/First.meg", m => m.Add("Init/Conflict.bin", "First")); + g.WriteMeg("Data/Second.meg", m => m.Add("Init/Conflict.bin", "Second")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("Second", ReadAll(gameRepo.OpenFile("Init/Conflict.bin", megFileOnly: true))); + } + + // ----------------------- error reporter signals at init ----------------------- + + [Fact] + public void ErrorReporter_MissingPatchMegs_AssertsFileNotFound() + { + // Empty repo (no fallback): the ctor will probe each patch slot and miss all three. + using var repo = CreateBuilder().Build(); + var reporter = new RecordingErrorReporter(); + + _ = CreateRepository(repo, reporter); + + var fileNotFoundNames = reporter.Asserts + .Where(a => a.Kind == EngineAssertKind.FileNotFound) + .Select(a => EngineFileName(a.Value)) + .ToList(); + Assert.Contains("Patch.meg", fileNotFoundNames); + Assert.Contains("Patch2.meg", fileNotFoundNames); + Assert.Contains("64Patch.meg", fileNotFoundNames); + } + + [Fact] + public void ErrorReporter_AllPatchesPresent_NoFileNotFoundForPatches() + { + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.WriteMeg("Data/Patch.meg", _ => { }); + g.WriteMeg("Data/Patch2.meg", _ => { }); + g.WriteMeg("Data/64Patch.meg", _ => { }); + }) + .Build(); + var reporter = new RecordingErrorReporter(); + + _ = CreateRepository(repo, reporter); + + var fileNotFoundNames = reporter.Asserts + .Where(a => a.Kind == EngineAssertKind.FileNotFound) + .Select(a => EngineFileName(a.Value)) + .ToList(); + Assert.DoesNotContain("Patch.meg", fileNotFoundNames); + Assert.DoesNotContain("Patch2.meg", fileNotFoundNames); + Assert.DoesNotContain("64Patch.meg", fileNotFoundNames); + } + + [Fact] + public void ErrorReporter_MegaFilesXmlReferencesMissingMeg_AssertsFileNotFound() + { + const string megaFilesXml = """ + + + Data/DoesNotExist.meg + + """; + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write("Data/MegaFiles.xml", megaFilesXml)) + .Build(); + var reporter = new RecordingErrorReporter(); + + _ = CreateRepository(repo, reporter); + + Assert.Contains(reporter.Asserts, a => + a.Kind == EngineAssertKind.FileNotFound && EngineFileName(a.Value) == "DoesNotExist.meg"); + } + + [Fact] + public void ErrorReporter_MissingSpeechMeg_DoesNotAssert() + { + // Speech.meg paths are intentionally silent: missing speech files only emit a debug log. + const string megaFilesXml = """ + + + Data/EnglishSpeech.meg + + """; + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write("Data/MegaFiles.xml", megaFilesXml)) + .Build(); + var reporter = new RecordingErrorReporter(); + + _ = CreateRepository(repo, reporter); + + Assert.DoesNotContain(reporter.Asserts, a => + a.Kind == EngineAssertKind.FileNotFound && EngineFileName(a.Value) == "EnglishSpeech.meg"); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.MegLoading.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.MegLoading.cs new file mode 100644 index 00000000..41e54260 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.MegLoading.cs @@ -0,0 +1,171 @@ +using Xunit; + +namespace PG.StarWarsGame.Engine.Test.IO; + +public abstract partial class GameRepositoryTests +{ + private const string TextEntry = "Data/Text/text.txt"; + + #region Intra-origin: several MEGs in the same game + + [Fact] + public void MegaFilesXml_ThreeListedMegs_LastListedWins() + { + const string megaFilesXml = """ + + + Data/A.meg + Data/B.meg + Data/C.meg + + """; + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("Data/MegaFiles.xml", megaFilesXml); + g.WriteMeg("Data/A.meg", m => m.Add(TextEntry, "A")); + g.WriteMeg("Data/B.meg", m => m.Add(TextEntry, "B")); + g.WriteMeg("Data/C.meg", m => m.Add(TextEntry, "C")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("C", ReadAll(gameRepo.OpenFile(TextEntry, megFileOnly: true))); + } + + [Fact] + public void MasterMeg_PatchSlotsOverrideMegaFilesXml_64PatchWinsOverall() + { + // Full precedence for one entry present in every slot: MegaFiles.xml-listed < Patch < Patch2 < 64Patch. + const string megaFilesXml = """ + + + Data/Custom.meg + + """; + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("Data/MegaFiles.xml", megaFilesXml); + g.WriteMeg("Data/Custom.meg", m => m.Add(TextEntry, "Custom")); + g.WriteMeg("Data/Patch.meg", m => m.Add(TextEntry, "Patch")); + g.WriteMeg("Data/Patch2.meg", m => m.Add(TextEntry, "Patch2")); + g.WriteMeg("Data/64Patch.meg", m => m.Add(TextEntry, "64Patch")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("64Patch", ReadAll(gameRepo.OpenFile(TextEntry, megFileOnly: true))); + } + + #endregion + + #region Inter-origin: mod MEGs shadow game MEGs + + [Fact] + public void ModPatchMeg_ShadowsGamePatchMeg() + { + // A patch slot is resolved through the file-lookup chain, so the mod's Data/Patch.meg is loaded + // instead of the game's — the game's copy never reaches the master MEG. + using var repo = CreateBuilder() + .ConfigureGame(g => g.WriteMeg("Data/Patch.meg", m => m.Add(TextEntry, "game"))) + .WithMod("Mod", m => m.WriteMeg("Data/Patch.meg", meg => meg.Add(TextEntry, "mod"))) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("mod", ReadAll(gameRepo.OpenFile(TextEntry, megFileOnly: true))); + } + + [Fact] + public void ModMegaFilesXml_ShadowsGameMegaFilesXml() + { + // Data/MegaFiles.xml is itself resolved through the chain, so only the mod's list is read; a MEG + // listed solely in the game's MegaFiles.xml is never loaded. + const string modXml = """ + + + Data/ModListed.meg + + """; + const string gameXml = """ + + + Data/GameListed.meg + + """; + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("Data/MegaFiles.xml", gameXml); + g.WriteMeg("Data/GameListed.meg", m => m.Add("Data/Text/game.txt", "game")); + }) + .WithMod("Mod", m => + { + m.Write("Data/MegaFiles.xml", modXml); + m.WriteMeg("Data/ModListed.meg", meg => meg.Add("Data/Text/mod.txt", "mod")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("mod", ReadAll(gameRepo.OpenFile("Data/Text/mod.txt", megFileOnly: true))); + Assert.False(gameRepo.FileExists("Data/Text/game.txt", megFileOnly: true)); + } + + [Fact] + public void MegaFilesXml_MixesModAndGameMegs_LaterListedWins() + { + const string megaFilesXml = """ + + + Data/FromMod.meg + Data/FromGame.meg + + """; + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("Data/MegaFiles.xml", megaFilesXml); + g.WriteMeg("Data/FromGame.meg", m => m.Add(TextEntry, "game")); + }) + .WithMod("Mod", m => m.WriteMeg("Data/FromMod.meg", meg => meg.Add(TextEntry, "mod"))) + .Build(); + var gameRepo = CreateRepository(repo); + + // FromGame.meg is listed after FromMod.meg, so the game entry wins for the shared path. + Assert.Equal("game", ReadAll(gameRepo.OpenFile(TextEntry, megFileOnly: true))); + } + + #endregion + + // Both of these normalized paths hash to CRC32 0x2AAF63A4: + // model : DATA\ART\MODELS\MOV_EMPIRE_INTRO_SHUTTLE_FIRE_DIE_00.ALA + // WAV entry : U000_EMP0212_ENG.WAV + // The model request collides with the bare WAV entry once the engine has prefixed the model directory, + // which is why the lookup is driven through DATA\ART\MODELS below. + private const string ModelLookup = @"Data\Art\Models\MOV_EMPIRE_INTRO_SHUTTLE_FIRE_DIE_00.ALA"; + private const string CollidingWav = "U000_EMP0212_ENG.WAV"; + + [Fact] + public void Crc32Collision_ModelLoadedFirst_ShadowedByLaterWav() + { + // Both files genuinely exist in the master MEG. The model's MEG (First.meg) is loaded before the + // WAV's MEG (Second.meg), so the later WAV takes over the shared CRC32 slot. + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.RegisterAndWriteMeg("Data/First.meg", meg => meg.Add(ModelLookup, "model-bytes")); + g.RegisterAndWriteMeg("Data/Second.meg", meg => meg.Add(CollidingWav, "wav-bytes")); + }) + .Build(); + var modelRepo = CreateRepository(repo).ModelRepository; + + var found = modelRepo.FileExists(ModelLookup, megFileOnly: false, out var inMeg, out var actualFilePath); + + // The model exists, yet requesting it resolves to the colliding WAV that was loaded later: the + // engine reports the WAV's path and hands back the WAV's bytes instead of the model. + Assert.True(found); + Assert.True(inMeg); + Assert.Equal(CollidingWav, actualFilePath); + Assert.Equal("wav-bytes", ReadAll(modelRepo.OpenFile(ModelLookup))); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.MegLookup.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.MegLookup.cs new file mode 100644 index 00000000..c5977a33 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.MegLookup.cs @@ -0,0 +1,155 @@ +using Xunit; + +namespace PG.StarWarsGame.Engine.Test.IO; + +public abstract partial class GameRepositoryTests +{ + [Fact] + public void FileExists_OutParams_HitInMeg_SetsInMegAndNormalizedPath() + { + using var repo = CreateBuilder() + .ConfigureGame(g => g.WriteMeg("Data/Patch.meg", + meg => meg.Add("Data/Audio/foo.wav", "audio"))) + .Build(); + var gameRepo = CreateRepository(repo); + + var found = gameRepo.FileExists("Data/Audio/foo.wav", megFileOnly: false, out var inMeg, out var actualPath); + + Assert.True(found); + Assert.True(inMeg); + Assert.Equal(@"DATA\AUDIO\FOO.WAV", actualPath); + } + + [Fact] + public void OpenFile_FromPatchMeg_ReturnsEntryBytes() + { + const string payload = "wav-payload"; + using var repo = CreateBuilder() + .ConfigureGame(g => g.WriteMeg("Data/Patch.meg", + meg => meg.Add("Data/Audio/foo.wav", payload))) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal(payload, ReadAll(gameRepo.OpenFile("Data/Audio/foo.wav"))); + } + + [Fact] + public void FileExists_MegLookup_CaseInsensitive() + { + using var repo = CreateBuilder() + .ConfigureGame(g => g.WriteMeg("Data/Patch.meg", + meg => meg.Add("Data/Audio/foo.wav", "x"))) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.True(gameRepo.FileExists("DATA/AUDIO/FOO.WAV")); + Assert.True(gameRepo.FileExists("data/audio/foo.wav")); + Assert.True(gameRepo.FileExists("Data/Audio/Foo.Wav")); + } + + [Fact] + public void FileExists_MegLookup_SeparatorInsensitive() + { + using var repo = CreateBuilder() + .ConfigureGame(g => g.WriteMeg("Data/Patch.meg", + meg => meg.Add("Data/Audio/foo.wav", "x"))) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.True(gameRepo.FileExists("Data/Audio/foo.wav")); + Assert.True(gameRepo.FileExists(@"Data\Audio\foo.wav")); + } + + [Fact] + public void FileExists_FileSystemWinsOverMeg() + { + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("Data/Audio/foo.wav", "from-fs"); + g.WriteMeg("Data/Patch.meg", meg => meg.Add("Data/Audio/foo.wav", "from-meg")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + var found = gameRepo.FileExists("Data/Audio/foo.wav", megFileOnly: false, out var inMeg, out _); + Assert.True(found); + Assert.False(inMeg); + + Assert.Equal("from-fs", ReadAll(gameRepo.OpenFile("Data/Audio/foo.wav"))); + } + + [Fact] + public void FileExists_MegFileOnlyFlag_SkipsFilesystemEvenIfPresent() + { + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("Data/Audio/foo.wav", "from-fs"); + g.WriteMeg("Data/Patch.meg", meg => meg.Add("Data/Audio/foo.wav", "from-meg")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + var found = gameRepo.FileExists("Data/Audio/foo.wav", megFileOnly: true, out var inMeg, out _); + Assert.True(found); + Assert.True(inMeg); + + Assert.Equal("from-meg", ReadAll(gameRepo.OpenFile("Data/Audio/foo.wav", megFileOnly: true))); + } + + [Fact] + public void FileExists_MissingMegEntry_ReturnsFalse() + { + using var repo = CreateBuilder() + .ConfigureGame(g => g.WriteMeg("Data/Patch.meg", + meg => meg.Add("Data/Audio/foo.wav", "x"))) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.False(gameRepo.FileExists("Data/Audio/missing.wav")); + } + + [Fact] + public void EmptyMegaFilesXml_DoesNotCrash() + { + const string megaFilesXml = """ + + + + """; + using var repo = CreateBuilder() + .ConfigureGame(g => g.Write("Data/MegaFiles.xml", megaFilesXml)) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.False(gameRepo.FileExists("anything.txt")); + } + + [Fact] + public void RegisterAndWriteMeg_LoadsMegViaGeneratedMegaFilesXml() + { + using var repo = CreateBuilder() + .ConfigureGame(g => g.RegisterAndWriteMeg("Data/Custom.meg", + m => m.Add(TextEntry, "registered"))) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("registered", ReadAll(gameRepo.OpenFile(TextEntry, megFileOnly: true))); + } + + [Fact] + public void RegisterAndWriteMeg_RegistrationOrderIsLoadOrder() + { + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.RegisterAndWriteMeg("Data/First.meg", m => m.Add(TextEntry, "first")); + g.RegisterAndWriteMeg("Data/Second.meg", m => m.Add(TextEntry, "second")); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("second", ReadAll(gameRepo.OpenFile(TextEntry, megFileOnly: true))); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.cs new file mode 100644 index 00000000..c0e347ab --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/GameRepositoryTests.cs @@ -0,0 +1,62 @@ +using PG.StarWarsGame.Engine.IO; +using Xunit; + +namespace PG.StarWarsGame.Engine.Test.IO; + +/// +/// Engine-agnostic tests for the base file lookup. +/// +public abstract partial class GameRepositoryTests : EngineRepositoryTestBase +{ + protected override bool ResolvesFileNameWithoutDirectory => false; + + protected override bool SurfacesPathTooLong => false; + + protected override CaseInsensitivityFixture BuildCaseInsensitivityFixture() + { + return new CaseInsensitivityFixture( + PopulateGame: g => + { + g.Write("Data/XML/Foo.xml", "fs-content"); + g.RegisterAndWriteMeg("Data/Content.meg", meg => meg.Add("Data/Audio/Bar.wav", "meg-content")); + }, + SelectRepository: gameRepo => gameRepo, + FilesystemLookup: "Data/XML/Foo.xml", + FilesystemContent: "fs-content", + MegLookup: "Data/Audio/Bar.wav", + MegContent: "meg-content"); + } + + protected override RepositoryFixture RepositoryFixture => new( + SelectRepository: gameRepo => gameRepo, + ResolvablePath: "Data/XML/Foo.xml"); + + [Fact] + public void Path_NoModConfigured_PointsToGameDirectory() + { + using var repo = CreateBuilder().Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal(RootPathOf(gameRepo, repo.GameLocations.GamePath), gameRepo.Path); + } + + [Fact] + public void Path_ModsConfigured_PointsToFirstMod() + { + using var repo = CreateBuilder() + .WithMod("FirstMod", _ => { }) + .WithMod("SecondMod", _ => { }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal(RootPathOf(gameRepo, repo.GameLocations.ModPaths[0]), gameRepo.Path); + } + + // The repository's Path is the fully-qualified top-most root (first mod, else game directory) with a + // trailing directory separator, resolved through the same file system the repository uses. + private static string RootPathOf(IGameRepository gameRepo, string rawRoot) + { + var path = gameRepo.PGFileSystem.UnderlyingFileSystem.Path; + return path.GetFullPath(rawRoot) + path.DirectorySeparatorChar; + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/ModelRepositoryTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/ModelRepositoryTests.cs new file mode 100644 index 00000000..a8ce3eb7 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/ModelRepositoryTests.cs @@ -0,0 +1,31 @@ +namespace PG.StarWarsGame.Engine.Test.IO; + +public abstract class ModelRepositoryTests : ExtensionFallbackRepositoryTests +{ + protected override bool ResolvesFileNameWithoutDirectory => false; + + protected override bool SurfacesPathTooLong => true; + + protected override string FallbackExtension => ".alo"; + + protected override string SecondaryExtension => ".ala"; + + protected override CaseInsensitivityFixture BuildCaseInsensitivityFixture() + { + return new CaseInsensitivityFixture( + PopulateGame: g => + { + g.Write("Data/Art/Models/Ship.alo", "fs-alo"); + g.RegisterAndWriteMeg("Data/Models.meg", meg => meg.Add("Data/Art/Models/OtherShip.alo", "meg-alo")); + }, + SelectRepository: gameRepo => gameRepo.ModelRepository, + FilesystemLookup: "Data/Art/Models/Ship.alo", + FilesystemContent: "fs-alo", + MegLookup: "Data/Art/Models/OtherShip.alo", + MegContent: "meg-alo"); + } + + protected override RepositoryFixture RepositoryFixture => new( + SelectRepository: gameRepo => gameRepo.ModelRepository, + ResolvablePath: "Data/Art/Models/Ship.alo"); +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/TextureRepositoryTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/TextureRepositoryTests.cs new file mode 100644 index 00000000..3d750f23 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/IO/TextureRepositoryTests.cs @@ -0,0 +1,50 @@ +using Xunit; + +namespace PG.StarWarsGame.Engine.Test.IO; + +public abstract class TextureRepositoryTests : ExtensionFallbackRepositoryTests +{ + protected override bool ResolvesFileNameWithoutDirectory => true; + + protected override bool SurfacesPathTooLong => true; + + protected override string FallbackExtension => ".dds"; + + protected override string SecondaryExtension => ".tga"; + + protected override CaseInsensitivityFixture BuildCaseInsensitivityFixture() + { + return new CaseInsensitivityFixture( + PopulateGame: g => + { + g.Write("Data/Art/Textures/MyTex.tga", "fs-tga"); + g.RegisterAndWriteMeg("Data/Textures.meg", meg => meg.Add("Data/Art/Textures/OtherTex.tga", "meg-tga")); + }, + SelectRepository: gameRepo => gameRepo.TextureRepository, + FilesystemLookup: "Data/Art/Textures/MyTex.tga", + FilesystemContent: "fs-tga", + MegLookup: "Data/Art/Textures/OtherTex.tga", + MegContent: "meg-tga"); + } + + protected override RepositoryFixture RepositoryFixture => new( + SelectRepository: gameRepo => gameRepo.TextureRepository, + ResolvablePath: "Data/Art/Textures/MyTex.tga"); + + [Fact] + public void Priority_AsIsLocationBeatsTexturesDirectory() + { + // For a bare request the path is probed as-is (here: the game root) before it is retried under the + // implicit ./Data/Art/Textures/ directory. + using var repo = CreateBuilder() + .ConfigureGame(g => + { + g.Write("MyTex.tga", "root"); + g.Write("Data/Art/Textures/MyTex.tga", "textures-dir"); + }) + .Build(); + var gameRepo = CreateRepository(repo); + + Assert.Equal("root", ReadAll(gameRepo.TextureRepository.OpenFile("MyTex.tga"))); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/PG.StarWarsGame.Engine.Test.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/PG.StarWarsGame.Engine.Test.csproj new file mode 100644 index 00000000..eeb1d920 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/PG.StarWarsGame.Engine.Test.csproj @@ -0,0 +1,38 @@ + + + + net10.0 + $(TargetFrameworks);net481 + preview + enable + + + + false + true + Exe + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/RecordingErrorReporter.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/RecordingErrorReporter.cs new file mode 100644 index 00000000..3b1278ed --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/RecordingErrorReporter.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using PG.StarWarsGame.Engine.ErrorReporting; +using PG.StarWarsGame.Files.XML.ErrorHandling; + +namespace PG.StarWarsGame.Engine.Test; + +internal sealed class RecordingErrorReporter : IGameEngineErrorReporter +{ + public List Asserts { get; } = []; + + public List InitializationErrors { get; } = []; + + public List XmlErrors { get; } = []; + + public void Assert(EngineAssert assert) + { + Asserts.Add(assert); + } + + public void Report(InitializationError error) + { + InitializationErrors.Add(error); + } + + public void Report(XmlError error) + { + XmlErrors.Add(error); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/RepositoryFixture.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/RepositoryFixture.cs new file mode 100644 index 00000000..6d6490a1 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/RepositoryFixture.cs @@ -0,0 +1,13 @@ +using System; +using PG.StarWarsGame.Engine.IO; + +namespace PG.StarWarsGame.Engine.Test; + +/// +/// Represents a test fixture for verifying that repositories resolve simple file requests. +/// +/// Picks the repository under test from the constructed . +/// Lookup key the repository should resolve to the filesystem-backed fixture. +public sealed record RepositoryFixture( + Func SelectRepository, + string ResolvablePath); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/RepositoryLayer.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/RepositoryLayer.cs new file mode 100644 index 00000000..999b415d --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/RepositoryLayer.cs @@ -0,0 +1,22 @@ +namespace PG.StarWarsGame.Engine.Test; + +/// +/// Specifies an origin layer of the repository loading chain. +/// +/// +/// The relative priority of these origins is engine-specific. +/// +public enum RepositoryLayer +{ + /// A mod path. + Mod, + + /// The base game directory. + Game, + + /// The master MEG archive. + MasterMeg, + + /// A fallback game directory. + Fallback, +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/RepositoryTestData.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/RepositoryTestData.cs new file mode 100644 index 00000000..7f7b620c --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Test/RepositoryTestData.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace PG.StarWarsGame.Engine.Test; + +/// Shared theory data for repository lookup tests. +internal static class RepositoryTestData +{ + /// + /// All shader-name forms that resolve identically: the effects repository strips the input's extension + /// before probing, so "MyShader.X" resolves the same regardless of X. + /// + public static readonly string[] EquivalentShaderNames = + [ + "MyShader", + "MyShader.fx", + "MyShader.fxo", + "MyShader.fxh", + "MyShader.bogus", + ]; + + public static IEnumerable ShaderInputs() + { + foreach (var input in EquivalentShaderNames) + yield return [input]; + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/AssemblyInfo.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/AssemblyInfo.cs new file mode 100644 index 00000000..0bc51f77 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("PG.StarWarsGame.Engine.Testing.Test")] diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/EmbeddedFixtures.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/EmbeddedFixtures.cs new file mode 100644 index 00000000..9a3dc567 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/EmbeddedFixtures.cs @@ -0,0 +1,26 @@ +using System; +using System.IO; +using System.Reflection; + +namespace PG.StarWarsGame.Engine.Testing; + +/// Loads bytes from embedded resources. +public static class EmbeddedFixtures +{ + /// Returns the bytes of an embedded resource. + /// The fully qualified resource name. + /// The assembly that contains the resource. uses the calling assembly. + /// was not found in . + public static byte[] Load(string resourceName, Assembly? source = null) + { + if (resourceName == null) + throw new ArgumentNullException(nameof(resourceName)); + + var asm = source ?? Assembly.GetCallingAssembly(); + using var stream = asm.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Embedded resource '{resourceName}' was not found in {asm.FullName}."); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/IMegContentBuilder.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/IMegContentBuilder.cs new file mode 100644 index 00000000..e0fa6428 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/IMegContentBuilder.cs @@ -0,0 +1,21 @@ +using System.Reflection; + +namespace PG.StarWarsGame.Engine.Testing; + +/// Composes the entries of a MEG archive. +public interface IMegContentBuilder +{ + /// Adds an entry with binary content. + /// The entry name is normalized to canonical MEG form (uppercase, backslash-separated). + IMegContentBuilder Add(string entryName, byte[] content); + + /// Adds an entry with text content. + /// Encoded as UTF-8. The entry name is normalized to canonical MEG form. + IMegContentBuilder Add(string entryName, string content); + + /// Adds an entry whose content is loaded from an embedded resource. + /// The entry name (will be normalized). + /// The fully qualified resource name. + /// The assembly containing the resource. uses the calling assembly. + IMegContentBuilder AddEmbedded(string entryName, string resourceName, Assembly? source = null); +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/IRepoOriginWriter.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/IRepoOriginWriter.cs new file mode 100644 index 00000000..1dc128c2 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/IRepoOriginWriter.cs @@ -0,0 +1,42 @@ +using System; +using System.Reflection; + +namespace PG.StarWarsGame.Engine.Testing; + +/// Provides per-origin write operations against the underlying file system. +public interface IRepoOriginWriter +{ + /// Writes the text content to a file relative to the origin root. + void Write(string relativePath, string content); + + /// Writes the binary content to a file relative to the origin root. + void Write(string relativePath, byte[] content); + + /// Writes the bytes of an embedded resource to a file relative to the origin root. + /// The destination path relative to the origin root. + /// The fully qualified resource name. + /// The assembly that contains the resource. uses the calling assembly. + void WriteEmbedded(string relativePath, string resourceName, Assembly? source = null); + + /// Removes a file relative to the origin root, if it exists. + void Remove(string relativePath); + + /// Writes the XML content to Data/XML/<name> relative to the origin root. + /// may contain subpath separators. + void WriteXml(string name, string content); + + /// Writes a MEG archive composed via the configure callback to a file relative to the origin root. + /// The destination path relative to the origin root. + /// The callback that populates the archive entries. + void WriteMeg(string relativePath, Action configure); + + /// Writes a MEG archive and registers it in this origin's Data/MegaFiles.xml, + /// which the builder emits when the repository is built. + /// The destination path relative to the origin root, also listed in MegaFiles.xml. + /// The callback that populates the archive entries. + void RegisterAndWriteMeg(string relativePath, Action configure); + + /// Writes an empty MEG archive to a file relative to the origin root. + /// The destination path relative to the origin root. + void WriteEmptyMeg(string relativePath); +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/MegContentBuilder.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/MegContentBuilder.cs new file mode 100644 index 00000000..389bba20 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/MegContentBuilder.cs @@ -0,0 +1,38 @@ +using System; +using System.Reflection; +using System.Text; +using PG.StarWarsGame.Files.MEG.Services.Builder; + +namespace PG.StarWarsGame.Engine.Testing; + +internal sealed class MegContentBuilder(IMegBuilder inner) : IMegContentBuilder +{ + private readonly IMegBuilder _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + + public IMegContentBuilder Add(string entryName, byte[] content) + { + if (entryName == null) + throw new ArgumentNullException(nameof(entryName)); + if (content == null) + throw new ArgumentNullException(nameof(content)); + + var result = _inner.AddBytes(content, entryName, encrypt: false); + return !result.Added + ? throw new InvalidOperationException($"Failed to add MEG entry '{entryName}': {result.Status} ({result.Message ?? "no message"}).") + : this; + } + + public IMegContentBuilder Add(string entryName, string content) + { + if (content == null) + throw new ArgumentNullException(nameof(content)); + + return Add(entryName, Encoding.UTF8.GetBytes(content)); + } + + public IMegContentBuilder AddEmbedded(string entryName, string resourceName, Assembly? source = null) + { + var asm = source ?? Assembly.GetCallingAssembly(); + return Add(entryName, EmbeddedFixtures.Load(resourceName, asm)); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/PG.StarWarsGame.Engine.Testing.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/PG.StarWarsGame.Engine.Testing.csproj new file mode 100644 index 00000000..bdb86af9 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/PG.StarWarsGame.Engine.Testing.csproj @@ -0,0 +1,20 @@ + + + netstandard2.0;netstandard2.1;net10.0 + preview + enable + disable + true + latest + false + Test scaffolding for the Petroglyph Star Wars game engine library. + + + + + + + + + + diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/PetroglyphFileSystemStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/PetroglyphFileSystemStrategy.cs new file mode 100644 index 00000000..e34eeb6a --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/PetroglyphFileSystemStrategy.cs @@ -0,0 +1,28 @@ +using PG.StarWarsGame.Engine.IO; + +namespace PG.StarWarsGame.Engine.Testing; + +/// +/// A selectable file-exists strategy. The Windows-backed strategies are +/// only supported on Windows hosts. +/// +public enum PetroglyphFileSystemStrategy +{ + /// Win32 CreateFileA per lookup (Windows only). + Windows, + + /// Case-folding, component-by-component walk. + Wine, + + /// Game-directory snapshot, delegating outside-game lookups to the Windows strategy (Windows only). + VirtualWindowsFallback, + + /// Game-directory snapshot, delegating outside-game lookups to the Wine strategy. + VirtualWineFallback, + + /// Watcher-backed game-directory snapshot, with the Windows fallback (Windows only). + LiveVirtualWindowsFallback, + + /// Watcher-backed game-directory snapshot, with the Wine fallback. + LiveVirtualWineFallback, +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/PetroglyphFileSystemTestHelpers.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/PetroglyphFileSystemTestHelpers.cs new file mode 100644 index 00000000..8c386586 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/PetroglyphFileSystemTestHelpers.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using PG.StarWarsGame.Engine.IO; + +namespace PG.StarWarsGame.Engine.Testing; + +/// Helpers for selecting file-exists strategies in tests. +public static class PetroglyphFileSystemTestHelpers +{ + /// The file-exists strategies supported on the current OS (Windows-backed strategies are Windows-only). + public static IReadOnlyList SupportedForCurrentOS() + { + var strategies = new List + { + PetroglyphFileSystemStrategy.Wine, + PetroglyphFileSystemStrategy.VirtualWineFallback, + PetroglyphFileSystemStrategy.LiveVirtualWineFallback, + }; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + strategies.Add(PetroglyphFileSystemStrategy.Windows); + strategies.Add(PetroglyphFileSystemStrategy.VirtualWindowsFallback); + strategies.Add(PetroglyphFileSystemStrategy.LiveVirtualWindowsFallback); + } + return strategies; + } + + /// Switches to the given file-exists strategy. + /// is . + /// A Windows-backed strategy is selected on a non-Windows host. + public static void ApplyStrategy(this PetroglyphFileSystem fileSystem, PetroglyphFileSystemStrategy strategy) + { + if (fileSystem is null) + throw new ArgumentNullException(nameof(fileSystem)); + + switch (strategy) + { + case PetroglyphFileSystemStrategy.Windows: fileSystem.UseWindowsStrategy(); break; + case PetroglyphFileSystemStrategy.Wine: fileSystem.UseWineStrategy(); break; + case PetroglyphFileSystemStrategy.VirtualWindowsFallback: fileSystem.UseVirtualStrategy(windowsFallback: true); break; + case PetroglyphFileSystemStrategy.VirtualWineFallback: fileSystem.UseVirtualStrategy(windowsFallback: false); break; + case PetroglyphFileSystemStrategy.LiveVirtualWindowsFallback: fileSystem.UseLiveVirtualStrategy(windowsFallback: true); break; + case PetroglyphFileSystemStrategy.LiveVirtualWineFallback: fileSystem.UseLiveVirtualStrategy(windowsFallback: false); break; + default: throw new ArgumentOutOfRangeException(nameof(strategy), strategy, null); + } + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/RepoOriginWriter.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/RepoOriginWriter.cs new file mode 100644 index 00000000..74bb2d19 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/RepoOriginWriter.cs @@ -0,0 +1,102 @@ +using System; +using System.IO.Abstractions; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using PG.StarWarsGame.Files.MEG.Files; +using PG.StarWarsGame.Files.MEG.Services.Builder; + +namespace PG.StarWarsGame.Engine.Testing; + +internal sealed class RepoOriginWriter(IServiceProvider services, string originPath, Action registerMeg) : IRepoOriginWriter +{ + private readonly IServiceProvider _services = services ?? throw new ArgumentNullException(nameof(services)); + private readonly IFileSystem _fileSystem = services.GetRequiredService(); + private readonly string _originPath = originPath ?? throw new ArgumentNullException(nameof(originPath)); + private readonly Action _registerMeg = registerMeg ?? throw new ArgumentNullException(nameof(registerMeg)); + + public void Write(string relativePath, string content) + { + if (content == null) + throw new ArgumentNullException(nameof(content)); + + var dst = Resolve(relativePath); + EnsureParent(dst); + _fileSystem.File.WriteAllText(dst, content); + } + + public void Write(string relativePath, byte[] content) + { + if (content == null) + throw new ArgumentNullException(nameof(content)); + + var dst = Resolve(relativePath); + EnsureParent(dst); + _fileSystem.File.WriteAllBytes(dst, content); + } + + public void WriteEmbedded(string relativePath, string resourceName, Assembly? source = null) + { + var asm = source ?? Assembly.GetCallingAssembly(); + var bytes = EmbeddedFixtures.Load(resourceName, asm); + Write(relativePath, bytes); + } + + public void Remove(string relativePath) + { + var dst = Resolve(relativePath); + if (_fileSystem.File.Exists(dst)) + _fileSystem.File.Delete(dst); + } + + public void WriteXml(string name, string content) + { + if (name == null) + throw new ArgumentNullException(nameof(name)); + + Write(_fileSystem.Path.Combine("Data", "XML", Normalize(name)), content); + } + + public void WriteMeg(string relativePath, Action configure) + { + if (configure == null) + throw new ArgumentNullException(nameof(configure)); + + var dst = Resolve(relativePath); + EnsureParent(dst); + + using var megBuilder = new EmpireAtWarMegBuilder(_fileSystem.Path.GetDirectoryName(dst)!, _services); + configure(new MegContentBuilder(megBuilder)); + using var fileInfo = new MegFileInformation(dst, MegFileVersion.V1, encryptionData: null); + megBuilder.Build(fileInfo, overwrite: true); + } + + public void WriteEmptyMeg(string relativePath) + { + WriteMeg(relativePath, _ => { }); + } + + public void RegisterAndWriteMeg(string relativePath, Action configure) + { + WriteMeg(relativePath, configure); + _registerMeg(relativePath); + } + + private string Resolve(string relativePath) + { + return relativePath == null + ? throw new ArgumentNullException(nameof(relativePath)) + : _fileSystem.Path.Combine(_originPath, Normalize(relativePath)); + } + + private string Normalize(string path) + { + return path.Replace('\\', _fileSystem.Path.DirectorySeparatorChar).Replace('/', _fileSystem.Path.DirectorySeparatorChar); + } + + private void EnsureParent(string fullPath) + { + var dir = _fileSystem.Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(dir)) + _fileSystem.Directory.CreateDirectory(dir!); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/RepoOriginWriterExtensions.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/RepoOriginWriterExtensions.cs new file mode 100644 index 00000000..fc0179ed --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/RepoOriginWriterExtensions.cs @@ -0,0 +1,30 @@ +using System; +using System.Reflection; + +namespace PG.StarWarsGame.Engine.Testing; + +/// Provides convenience extensions for . +public static class RepoOriginWriterExtensions +{ + /// Writes every embedded resource whose name starts with under the origin, stripping the prefix to form the destination path. + /// The origin writer. + /// The resource name prefix that identifies a tree of fixtures (e.g., "MinimalFoc"). + /// The assembly containing the resources. uses the calling assembly. + /// or is . + public static void WriteEmbeddedTree(this IRepoOriginWriter writer, string resourcePrefix, Assembly? source = null) + { + if (writer == null) + throw new ArgumentNullException(nameof(writer)); + if (resourcePrefix == null) + throw new ArgumentNullException(nameof(resourcePrefix)); + + var asm = source ?? Assembly.GetCallingAssembly(); + var prefix = resourcePrefix.TrimEnd('/') + "/"; + foreach (var name in asm.GetManifestResourceNames()) + { + if (!name.StartsWith(prefix, StringComparison.Ordinal)) + continue; + writer.WriteEmbedded(name.Substring(prefix.Length), name, asm); + } + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/VirtualGameRepo.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/VirtualGameRepo.cs new file mode 100644 index 00000000..5a26d587 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/VirtualGameRepo.cs @@ -0,0 +1,35 @@ +using System; +using System.IO.Abstractions; + +namespace PG.StarWarsGame.Engine.Testing; + +/// Represents a disposable temp-directory-backed virtual game repository. +public sealed class VirtualGameRepo : IDisposable +{ + private readonly IFileSystem _fileSystem; + + /// Gets the absolute path of the temp directory that backs this repository. + public string TempPath { get; } + + /// Gets the game locations describing the virtual layout. + public GameLocations GameLocations { get; } + + /// Initializes a new instance of the class. + /// The file system used to access and clean up the backing directory. + /// The absolute path of the temp directory that backs this repository. + /// The game locations describing the virtual layout. + /// , , or is . + public VirtualGameRepo(IFileSystem fileSystem, string tempPath, GameLocations gameLocations) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + TempPath = tempPath ?? throw new ArgumentNullException(nameof(tempPath)); + GameLocations = gameLocations ?? throw new ArgumentNullException(nameof(gameLocations)); + } + + /// + public void Dispose() + { + if (_fileSystem.Directory.Exists(TempPath)) + _fileSystem.Directory.Delete(TempPath, recursive: true); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/VirtualGameRepoBuilder.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/VirtualGameRepoBuilder.cs new file mode 100644 index 00000000..54671da3 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.Testing/VirtualGameRepoBuilder.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Text; +using AnakinRaW.CommonUtilities.Collections; +using Microsoft.Extensions.DependencyInjection; + +namespace PG.StarWarsGame.Engine.Testing; + +/// Builds a temp-directory-backed from raw file content. +public sealed class VirtualGameRepoBuilder +{ + private readonly IServiceProvider _services; + private readonly IFileSystem _fs; + private readonly string _tempRoot; + private readonly string _gameRoot; + private readonly List _modPaths = []; + private readonly List _fallbackPaths = []; + private readonly ValueListDictionary _registeredMegsByOrigin = new(); + private string? _fallbackGamePath; + + /// Initializes a new instance of the class. + /// The service provider supplying the file system and file-format services used by the builder. + /// is . + public VirtualGameRepoBuilder(IServiceProvider services) + { + _services = services ?? throw new ArgumentNullException(nameof(services)); + _fs = services.GetRequiredService(); + _tempRoot = _fs.Path.Combine(_fs.Path.GetTempPath(), + $"PG.StarWarsGame.Engine.Testing.{Guid.NewGuid():N}"); + _gameRoot = _fs.Path.Combine(_tempRoot, "game"); + _fs.Directory.CreateDirectory(_gameRoot); + } + + /// Configures files under the base game directory. + /// The base game directory always exists; this populates it. Unlike and + /// , it does not add an optional origin. + /// The writer callback. + /// is . + public VirtualGameRepoBuilder ConfigureGame(Action configure) + { + if (configure == null) + throw new ArgumentNullException(nameof(configure)); + + configure(CreateWriter(_gameRoot)); + return this; + } + + /// Configures files under the primary fallback game directory. + /// The writer callback. + /// is . + public VirtualGameRepoBuilder WithFallbackGame(Action configure) + { + if (configure == null) + throw new ArgumentNullException(nameof(configure)); + + if (_fallbackGamePath == null) + { + _fallbackGamePath = _fs.Path.Combine(_tempRoot, "fallback", "_primary"); + _fs.Directory.CreateDirectory(_fallbackGamePath); + } + configure(CreateWriter(_fallbackGamePath)); + return this; + } + + /// Configures files under a named additional fallback path. + /// A unique name identifying this fallback. + /// The writer callback. + /// is or empty. + /// is . + public VirtualGameRepoBuilder WithFallback(string name, Action configure) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("Fallback name must be non-empty.", nameof(name)); + if (configure == null) + throw new ArgumentNullException(nameof(configure)); + + var dir = _fs.Path.Combine(_tempRoot, "fallback", name); + if (!_fallbackPaths.Contains(dir)) + { + _fs.Directory.CreateDirectory(dir); + _fallbackPaths.Add(dir); + } + configure(CreateWriter(dir)); + return this; + } + + /// Configures files under a named mod path. + /// Mods are independent path roots. Declaration order is preserved as the order in . + /// A unique name identifying this mod. + /// The writer callback. + /// is or empty. + /// is . + public VirtualGameRepoBuilder WithMod(string name, Action configure) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("Mod name must be non-empty.", nameof(name)); + if (configure == null) + throw new ArgumentNullException(nameof(configure)); + + var dir = _fs.Path.Combine(_tempRoot, "mods", name); + if (!_modPaths.Contains(dir)) + { + _fs.Directory.CreateDirectory(dir); + _modPaths.Add(dir); + } + configure(CreateWriter(dir)); + return this; + } + + /// Builds the configured repository. + public VirtualGameRepo Build() + { + WriteRegisteredMegaFiles(); + + var fallbacks = new List(); + if (_fallbackGamePath != null) + fallbacks.Add(_fallbackGamePath); + fallbacks.AddRange(_fallbackPaths); + var locations = new GameLocations(_modPaths, _gameRoot, fallbacks); + return new VirtualGameRepo(_fs, _tempRoot, locations); + } + + private RepoOriginWriter CreateWriter(string originRoot) + { + return new RepoOriginWriter(_services, originRoot, relativePath => _registeredMegsByOrigin.Add(originRoot, relativePath)); + } + + // Emits a Data/MegaFiles.xml per origin that had MEGs registered via RegisterAndWriteMeg, in + // registration order (which is the master-MEG load order). + private void WriteRegisteredMegaFiles() + { + foreach (var originRoot in _registeredMegsByOrigin.Keys) + CreateWriter(originRoot).Write("Data/MegaFiles.xml", BuildMegaFilesXml(_registeredMegsByOrigin.GetValues(originRoot))); + } + + private static string BuildMegaFilesXml(IReadOnlyList megs) + { + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + foreach (var meg in megs) + sb.AppendLine($" {meg}"); + sb.Append(""); + return sb.ToString(); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameEngine.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameEngine.cs index 81881179..37d17ebc 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameEngine.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameEngine.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Threading; using PG.StarWarsGame.Engine.Audio.Sfx; using PG.StarWarsGame.Engine.CommandBar; using PG.StarWarsGame.Engine.GameConstants; @@ -11,25 +13,83 @@ namespace PG.StarWarsGame.Engine; -internal sealed class GameEngine : IStarWarsGameEngine +internal sealed class GameEngine : IStarWarsGameEngineHandle { + private int _disposed; + + private PetroglyphFileSystem? _pgFileSystem; + public required GameEngineType EngineType { get; init; } - public required IPGRender PGRender { get; init; } + public required IPGRender PGRender + { + get { ThrowIfDisposed(); return field; } + init; + } + + public required IFontManager FontManager + { + get { ThrowIfDisposed(); return field; } + init; + } + + public required ICommandBarGameManager CommandBar + { + get { ThrowIfDisposed(); return field; } + init; + } - public required IFontManager FontManager { get; init; } + public required IGameRepository GameRepository + { + get { ThrowIfDisposed(); return field; } + init + { + field = value; + _pgFileSystem = value.PGFileSystem; + } + } - public required ICommandBarGameManager CommandBar { get; init; } + public required IGameConstants GameConstants + { + get { ThrowIfDisposed(); return field; } + init; + } - public required IGameRepository GameRepository { get; init; } + public required IGuiDialogManager GuiDialogManager + { + get { ThrowIfDisposed(); return field; } + init; + } - public required IGameConstants GameConstants { get; init; } + public required IGameObjectTypeGameManager GameObjectTypeManager + { + get { ThrowIfDisposed(); return field; } + init; + } - public required IGuiDialogManager GuiDialogManager { get; init; } + public required ISfxEventGameManager SfxGameManager + { + get { ThrowIfDisposed(); return field; } + init; + } - public required IGameObjectTypeGameManager GameObjectTypeManager { get; init; } + public required IEnumerable InstalledLanguages + { + get { ThrowIfDisposed(); return field; } + init; + } - public required ISfxEventGameManager SfxGameManager { get; init; } + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + _pgFileSystem?.CleanupStrategy(); + _pgFileSystem = null; + } - public required IEnumerable InstalledLanguages { get; init; } -} \ No newline at end of file + private void ThrowIfDisposed() + { + if (_disposed != 0) + throw new ObjectDisposedException(nameof(GameEngine)); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/HardcodedEngineAssets.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/HardcodedEngineAssets.cs index e84d2833..1befb96f 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/HardcodedEngineAssets.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/HardcodedEngineAssets.cs @@ -5,10 +5,53 @@ namespace PG.StarWarsGame.Engine; public static class HardcodedEngineAssets { + public static IReadOnlyList HardcodedEngineShadersNames { get; } = new List + { + "Engine\\Global", + "AlamoEngine", + "RSkinGloss", + "MeshGloss", + "FillRateTest", + "Engine\\FrameEffectWipe", + "Engine\\FrameEffectAlpha", + "Engine\\PrimOpaque", + "Engine\\PrimAdditive", + "Engine\\PrimAlpha", + "Engine\\PrimModulate", + "Engine\\PrimDepthSpriteAdditive", + "Engine\\PrimDepthSpriteAlpha", + "Engine\\PrimDepthSpriteModulate", + "Engine\\StencilDarkenToAlpha", + "Engine\\StencilDarkenFinalBlur", + "Engine\\PrimDiffuseAlpha", + "Engine\\PrimHeat", + "Engine\\PrimParticleBumpAlpha", + "Engine\\PrimDecalBumpAlpha", + "Engine\\PrimAlphaScanlines", + "Engine\\SceneBloom", + "Engine\\SceneHeat", + "Engine\\FrameEffectFakeMotionBlur", + "SpaceFogOfWar", + "MeshOccludedUnit", + "RSkinOccludedUnit" + }; + + public static IReadOnlyList HardcodedTerrainShadersNames { get; } = new List + { + "TerrainRenderBump", + "TerrainRenderBumpDual", + "TerrainPassability", + "TerrainWater", + "TerrainLava", + "TerrainIce", + "TerrainFogOfWar", + "TerrainRenderBaked" + }; + /// /// These models / particles are hardcoded into StarWarsG.exe. /// - public static IList HardcodedFocModelsParticles { get; } = new List + public static IReadOnlyList HardcodedFocModelsParticles { get; } = new List { "i_tutorial_arrow.alo", "p_hero_empire_fx.alo", @@ -24,7 +67,7 @@ public static class HardcodedEngineAssets /// /// These models / particles are hardcoded into StarWarsG.exe. /// - public static IList HardcodedEawModelsParticles { get; } = new List + public static IReadOnlyList HardcodedEawModelsParticles { get; } = new List { "i_tutorial_arrow.alo", "p_hero_empire_fx.alo", @@ -34,7 +77,7 @@ public static class HardcodedEngineAssets "W_TextScroll.alo" }; - public static IList HardcodedFocTextures { get; } = new List + public static IReadOnlyList HardcodedFocTextures { get; } = new List { "splash.tga", "SPLASH_E3.tga", @@ -172,7 +215,7 @@ public static class HardcodedEngineAssets "W_Space_Reinforce_FOW_Grid.tga", }; - public static IList HardcodedEawTextures { get; } = new List + public static IReadOnlyList HardcodedEawTextures { get; } = new List { "splash.tga", "i_button_temporary.tga", @@ -280,7 +323,7 @@ public static class HardcodedEngineAssets "W_Space_Reinforce_FOW_Grid.tga", }; - public static IList GetHardcodedModelsAndParticles(GameEngineType engine) + public static IReadOnlyList GetHardcodedModelsAndParticles(GameEngineType engine) { return engine switch { @@ -290,7 +333,7 @@ public static IList GetHardcodedModelsAndParticles(GameEngineType engine }; } - public static IList GetHardcodedTextures(GameEngineType engine) + public static IReadOnlyList GetHardcodedTextures(GameEngineType engine) { return engine switch { diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/IGameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/IGameRepository.cs index 09183c80..0eb4e345 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/IGameRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/IGameRepository.cs @@ -1,11 +1,12 @@ -using PG.StarWarsGame.Engine.Localization; +using PG.StarWarsGame.Engine.Localization; namespace PG.StarWarsGame.Engine.IO; public interface IGameRepository : IRepository { /// - /// Gets the full qualified path of this repository with a trailing directory separator + /// Gets the fully qualified path of the repository's top-most root — the first mod directory when mods + /// are configured, otherwise the base game directory — with a trailing directory separator. /// string Path { get; } @@ -20,7 +21,5 @@ public interface IGameRepository : IRepository IRepository ModelRepository { get; } - bool FileExists(string filePath, string[] extensions, bool megFileOnly = false); - bool IsLanguageInstalled(LanguageType languageType); } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/MultiPassRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/MultiPassRepository.cs index 44a8b11d..39769bcc 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/MultiPassRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/MultiPassRepository.cs @@ -23,6 +23,27 @@ public Stream OpenFile(ReadOnlySpan filePath, bool megFileOnly = false) return fileStream; } + public Stream? TryOpenFile(string filePath, bool megFileOnly = false) + { + return TryOpenFile(filePath.AsSpan(), megFileOnly); + } + + public Stream? TryOpenFile(ReadOnlySpan filePath, bool megFileOnly = false) + { + var multiPassSb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); + var destinationSb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); + try + { + var fileFound = MultiPassAction(filePath, ref multiPassSb, ref destinationSb, megFileOnly); + return BaseRepository.OpenFileCore(fileFound); + } + finally + { + multiPassSb.Dispose(); + destinationSb.Dispose(); + } + } + public bool FileExists(string filePath, bool megFileOnly = false) { return FileExists(filePath.AsSpan(), megFileOnly); @@ -73,29 +94,8 @@ public bool FileExists(ReadOnlySpan filePath, bool megFileOnly, out bool p } private protected abstract FileFoundInfo MultiPassAction( - ReadOnlySpan filePath, + ReadOnlySpan filePath, ref ValueStringBuilder reusableStringBuilder, ref ValueStringBuilder destination, bool megFileOnly); - - public Stream? TryOpenFile(string filePath, bool megFileOnly = false) - { - return TryOpenFile(filePath.AsSpan(), megFileOnly); - } - - public Stream? TryOpenFile(ReadOnlySpan filePath, bool megFileOnly = false) - { - var multiPassSb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); - var destinationSb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); - try - { - var fileFound = MultiPassAction(filePath, ref multiPassSb, ref destinationSb, megFileOnly); - return BaseRepository.OpenFileCore(fileFound); - } - finally - { - multiPassSb.Dispose(); - destinationSb.Dispose(); - } - } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/EffectsRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/EffectsRepository.cs index 1557a7e9..e102dd7f 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/EffectsRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/EffectsRepository.cs @@ -10,7 +10,7 @@ internal class EffectsRepository(GameRepository baseRepository) : MultiPassRepos "DATA\\ART\\SHADERS", "DATA\\ART\\SHADERS\\TERRAIN", // This path is not coded to the engine - "DATA\\ART\\SHADERS\\ENGINE", + //"DATA\\ART\\SHADERS\\ENGINE", ]; // The engine does not support ".fxh" as a shader lookup, but as there might be some pre-compiling going on, this should be OK. diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/FocGameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/FocGameRepository.cs index fbe98122..bcc4f040 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/FocGameRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/FocGameRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using PG.StarWarsGame.Engine.ErrorReporting; @@ -25,9 +25,9 @@ public FocGameRepository(GameLocations gameLocations, GameEngineErrorReporterWra if (firstFallback is not null) { var eawMegs = LoadMegArchivesFromXml(firstFallback); - var eawPatch = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path.Combine(firstFallback, "Data/Patch.meg")); - var eawPatch2 = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path.Combine(firstFallback, "Data/Patch2.meg")); - var eaw64Patch = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path.Combine(firstFallback, "Data/64Patch.meg")); + var eawPatch = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path.Combine(firstFallback, "Data\\Patch.meg")); + var eawPatch2 = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path.Combine(firstFallback, "Data\\Patch2.meg")); + var eaw64Patch = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path.Combine(firstFallback, "Data\\64Patch.meg")); megsToConsider.AddRange(eawMegs); if (eawPatch is not null) @@ -39,9 +39,9 @@ public FocGameRepository(GameLocations gameLocations, GameEngineErrorReporterWra } var focOrModMegs = LoadMegArchivesFromXml("."); - var focPatch = LoadMegArchive("Data/Patch.meg"); - var focPatch2 = LoadMegArchive("Data/Patch2.meg"); - var foc64Patch = LoadMegArchive("Data/64Patch.meg"); + var focPatch = LoadMegArchive("Data\\Patch.meg"); + var focPatch2 = LoadMegArchive("Data\\Patch2.meg"); + var foc64Patch = LoadMegArchive("Data\\64Patch.meg"); megsToConsider.AddRange(focOrModMegs); if (focPatch is not null) @@ -56,6 +56,9 @@ public FocGameRepository(GameLocations gameLocations, GameEngineErrorReporterWra protected internal override FileFoundInfo FindFile(ReadOnlySpan filePath, ref ValueStringBuilder pathStringBuilder, bool megFileOnly = false) { + if (filePath.IsEmpty) + return default; + if (!megFileOnly) { var fileFoundInfo = FileFromAltExists(filePath, ModPaths, ref pathStringBuilder); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs index 7d0f1a12..b3589018 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs @@ -13,17 +13,6 @@ internal partial class GameRepository { private static readonly string[] DataPathPrefixes = ["DATA/", "DATA\\", "./DATA/", ".\\DATA\\"]; - public bool FileExists(string filePath, string[] extensions, bool megFileOnly = false) - { - foreach (var extension in extensions) - { - var newPath = PGFileSystem.ChangeExtension(filePath, extension); - if (FileExists(newPath, megFileOnly)) - return true; - } - return false; - } - public bool FileExists(string filePath, bool megFileOnly = false) { return FileExists(filePath.AsSpan(), megFileOnly); @@ -120,7 +109,7 @@ protected FileFoundInfo GetFileInfoFromMasterMeg(ReadOnlySpan filePath) if (filePath.Length > PGConstants.MaxMegEntryPathLength) { _logger.LogWarning("Trying to open a MEG entry which is longer than 259 characters: '{FileName}'", filePath.ToString()); - return default; + return new FileFoundInfo { PathTooLong = true }; } var sb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); @@ -172,7 +161,7 @@ protected FileFoundInfo FileFromAltExists(ReadOnlySpan filePath, IList filePath, IList path, out int cutoffLength) { - cutoffLength = 0; - if (path.Length < 5) - return false; - var sb = new ValueStringBuilder(stackalloc char[265]); sb.Append(path); + + // Normalizing is necessary because a previous Join/Combine uses the systems directory separator, + // while hardcoded paths usually use backslashes. This might lead to "asymmetric" separator usage. + // DataPathPrefixes paths only cover symmetric cases. PGFileSystem.NormalizePath(ref sb); try { @@ -198,11 +187,12 @@ private bool PathStartsWithDataDirectory(ReadOnlySpan path, out int cutoff { if (sb.AsSpan().StartsWith(prefix.AsSpan(), StringComparison.OrdinalIgnoreCase)) { - if (path[0] == '.') - cutoffLength = 2; + cutoffLength = prefix.Length; return true; } } + + cutoffLength = 0; return false; } finally diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.cs index c96cd67f..bce45793 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.cs @@ -177,9 +177,9 @@ public IEnumerable InitializeInstalledSfxMegFiles() if (firstFallback is not null) { var fallback2dNonLocalized = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path - .Combine(firstFallback, "DATA/AUDIO/SFX/SFX2D_NON_LOCALIZED.MEG")); + .Combine(firstFallback, "DATA\\AUDIO\\SFX\\SFX2D_NON_LOCALIZED.MEG")); var fallback3dNonLocalized = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path - .Combine(firstFallback, "DATA/AUDIO/SFX/SFX3D_NON_LOCALIZED.MEG")); + .Combine(firstFallback, "DATA\\AUDIO\\SFX\\SFX3D_NON_LOCALIZED.MEG")); if (fallback2dNonLocalized is not null) megsToAdd.Add(fallback2dNonLocalized); @@ -188,8 +188,8 @@ public IEnumerable InitializeInstalledSfxMegFiles() megsToAdd.Add(fallback3dNonLocalized); } - var nonLocalized2d = LoadMegArchive("DATA/AUDIO/SFX/SFX2D_NON_LOCALIZED.MEG"); - var nonLocalized3d = LoadMegArchive("DATA/AUDIO/SFX/SFX3D_NON_LOCALIZED.MEG"); + var nonLocalized2d = LoadMegArchive("DATA\\AUDIO\\SFX\\SFX2D_NON_LOCALIZED.MEG"); + var nonLocalized3d = LoadMegArchive("DATA\\AUDIO\\SFX\\SFX3D_NON_LOCALIZED.MEG"); if (nonLocalized2d is not null) megsToAdd.Add(nonLocalized2d); @@ -231,7 +231,7 @@ public IEnumerable InitializeInstalledSfxMegFiles() protected IList LoadMegArchivesFromXml(string lookupPath) { - var megFilesXmlPath = PGFileSystem.CombinePath(lookupPath, "Data/MegaFiles.xml"); + var megFilesXmlPath = PGFileSystem.CombinePath(lookupPath, "Data\\MegaFiles.xml"); using var xmlStream = TryOpenFile(megFilesXmlPath); @@ -314,8 +314,8 @@ public LanguageFiles(LanguageType language) { Language = language; var languageString = language.ToString().ToUpperInvariant(); - MasterTextDatFilePath = $"DATA/TEXT/MasterTextFile_{languageString}.DAT"; - Sfx2dMegFilePath = $"DATA/AUDIO/SFX/SFX2D_{languageString}.MEG"; + MasterTextDatFilePath = $"DATA\\TEXT\\MasterTextFile_{languageString}.DAT"; + Sfx2dMegFilePath = $"DATA\\AUDIO\\SFX\\SFX2D_{languageString}.MEG"; SpeechMegFileName = $"{languageString}SPEECH.MEG"; } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/ModelRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/ModelRepository.cs index e7368527..7089d052 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/ModelRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/ModelRepository.cs @@ -12,8 +12,8 @@ private protected override FileFoundInfo MultiPassAction( ref ValueStringBuilder destination, bool megFileOnly) { - if (!IsValidSize(filePath)) - return default; + if (!IsValidSize(filePath, out var pathTooLong)) + return new FileFoundInfo { PathTooLong = pathTooLong }; var fileInfo = BaseRepository.FindFile(filePath, ref destination, megFileOnly); if (fileInfo.FileFound) @@ -28,15 +28,26 @@ private protected override FileFoundInfo MultiPassAction( reusableStringBuilder.Append(".ALO"); var alternatePath = reusableStringBuilder.AsSpan(); - return !IsValidSize(alternatePath) - ? default + return !IsValidSize(alternatePath, out pathTooLong) + ? new FileFoundInfo { PathTooLong = pathTooLong } : BaseRepository.FindFile(alternatePath, ref destination, megFileOnly); } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsValidSize(ReadOnlySpan path) - { - return path.Length != 0 && path.Length < PGConstants.MaxModelFileName; + private static bool IsValidSize(ReadOnlySpan path, out bool pathTooLong) + { + switch (path.Length) + { + case 0: + pathTooLong = false; + return false; + case >= PGConstants.MaxModelFileName: + pathTooLong = true; + return false; + default: + pathTooLong = false; + return true; + } } private static ReadOnlySpan StripFileName(ReadOnlySpan src) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/TextureRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/TextureRepository.cs index a8e14326..36f34863 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/TextureRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/TextureRepository.cs @@ -1,12 +1,12 @@ -using PG.StarWarsGame.Engine.Utilities; +using PG.StarWarsGame.Engine.Utilities; using System; namespace PG.StarWarsGame.Engine.IO.Repositories; internal class TextureRepository(GameRepository baseRepository) : MultiPassRepository(baseRepository) { - private static readonly string DdsExtension = ".dds"; - private static readonly string TexturePath = "./Data/art/Textures/"; + private const string DdsExtension = ".dds"; + private const string TexturePath = "./Data/art/Textures/"; private protected override FileFoundInfo MultiPassAction( ReadOnlySpan filePath, @@ -46,10 +46,9 @@ private FileFoundInfo FindTexture(ref ValueStringBuilder multiPassStringBuilder, multiPassStringBuilder.Insert(0, TexturePath); - if (multiPassStringBuilder.AsSpan().Length > PGConstants.MaxTextureFileName) - return new FileFoundInfo { PathTooLong = true }; - - return BaseRepository.FindFile(multiPassStringBuilder.AsSpan(), ref pathStringBuilder); + return multiPassStringBuilder.AsSpan().Length > PGConstants.MaxTextureFileName + ? new FileFoundInfo { PathTooLong = true } + : BaseRepository.FindFile(multiPassStringBuilder.AsSpan(), ref pathStringBuilder); } private static void ChangeExtensionTo(ref ValueStringBuilder stringBuilder, ReadOnlySpan extension) @@ -60,11 +59,12 @@ private static void ChangeExtensionTo(ref ValueStringBuilder stringBuilder, Read // Also, while there are many cases, where this method breaks (such as "c:/test.abc/path.dds"), // it's the way how the engine works 🤷‍ + // The engine does strtok(name, ".") + strcat(".dds"): truncate at the first '.' if there is one, + // then always append the extension — so a name with no '.' still gets the extension appended. var firstPeriod = stringBuilder.AsSpan().IndexOf('.'); - if (firstPeriod == -1) - return; + if (firstPeriod != -1) + stringBuilder.Length = firstPeriod; - stringBuilder.Length = firstPeriod; stringBuilder.Append(extension); } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IPetroglyphStarWarsGameEngineService.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IPetroglyphStarWarsGameEngineService.cs index 80aaa611..ddb54762 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IPetroglyphStarWarsGameEngineService.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IPetroglyphStarWarsGameEngineService.cs @@ -1,16 +1,19 @@ -using System.Threading; +using System; +using System.Threading; using System.Threading.Tasks; using PG.StarWarsGame.Engine.ErrorReporting; +using PG.StarWarsGame.Engine.IO; namespace PG.StarWarsGame.Engine; public interface IPetroglyphStarWarsGameEngineService { - public Task InitializeAsync( + public Task InitializeAsync( GameEngineType engineType, GameLocations gameLocations, IGameEngineErrorReporter? errorReporter = null, - IGameEngineInitializationReporter? initReporter = null, + IGameEngineInitializationReporter? initReporter = null, bool cancelOnInitializationError = false, + Action? configureFileSystem = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IStarWarsGameEngineHandle.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IStarWarsGameEngineHandle.cs new file mode 100644 index 00000000..b183342b --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IStarWarsGameEngineHandle.cs @@ -0,0 +1,11 @@ +using System; + +namespace PG.StarWarsGame.Engine; + +/// +/// An owned reference to a that controls its lifetime. +/// Disposing this handle releases all resources held by the engine. +/// +public interface IStarWarsGameEngineHandle : IStarWarsGameEngine, IDisposable +{ +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj index 6beee66c..fd4974c8 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj @@ -18,13 +18,13 @@ preview - - - - + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -36,4 +36,8 @@ + + + + \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/PetroglyphStarWarsGameEngineService.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/PetroglyphStarWarsGameEngineService.cs index 42a24161..e8cc0b26 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/PetroglyphStarWarsGameEngineService.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/PetroglyphStarWarsGameEngineService.cs @@ -23,12 +23,13 @@ internal sealed class PetroglyphStarWarsGameEngineService(IServiceProvider servi private readonly ILogger? _logger = serviceProvider.GetService() ?.CreateLogger(typeof(PetroglyphStarWarsGameEngineService)); - public async Task InitializeAsync( + public async Task InitializeAsync( GameEngineType engineType, GameLocations gameLocations, IGameEngineErrorReporter? errorReporter = null, IGameEngineInitializationReporter? initReporter = null, bool cancelOnInitializationError = false, + Action? configureFileSystem = null, CancellationToken cancellationToken = default) { @@ -39,7 +40,7 @@ public async Task InitializeAsync( try { - return await InitializeEngineAsync(engineType, gameLocations, errorListenerWrapper, initReporter, cts.Token) + return await InitializeEngineAsync(engineType, gameLocations, errorListenerWrapper, initReporter, configureFileSystem, cts.Token) .ConfigureAwait(false); } finally @@ -57,11 +58,12 @@ void OnInitializationError(object sender, InitializationError e) } } - private async Task InitializeEngineAsync( + private async Task InitializeEngineAsync( GameEngineType engineType, GameLocations gameLocations, GameEngineErrorReporterWrapper errorReporter, IGameEngineInitializationReporter? initReporter, + Action? configureFileSystem, CancellationToken token) { try @@ -71,6 +73,7 @@ private async Task InitializeEngineAsync( var repoFactory = _serviceProvider.GetRequiredService(); var repository = repoFactory.Create(engineType, gameLocations, errorReporter); + configureFileSystem?.Invoke(repository.PGFileSystem); var pgRender = new PGRender(repository, errorReporter, serviceProvider); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/PG.StarWarsGame.Files.ALO.csproj b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/PG.StarWarsGame.Files.ALO.csproj index a92efb11..2ebaba46 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/PG.StarWarsGame.Files.ALO.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/PG.StarWarsGame.Files.ALO.csproj @@ -16,7 +16,7 @@ snupkg - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj index 7767e2bb..e9046886 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj @@ -17,6 +17,6 @@ preview - + \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj index 82d5b549..e00e21ad 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj @@ -18,10 +18,10 @@ preview - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test-local-update-dual.ps1 b/test-local-update-dual.ps1 new file mode 100644 index 00000000..579cefb4 --- /dev/null +++ b/test-local-update-dual.ps1 @@ -0,0 +1,54 @@ +# ========================================================================================= +# Local bootstrap for the dual-deploy self-update integration test. +# +# Stages a dual-channel local deploy (primary + /v2/) via deploy-local.ps1 -DualPublish, +# then runs the shared end-to-end test against the next-generation `/v2/` server. Validates +# that a full update cycle works after the post-migration server URL base change. +# +# Optionally accepts -CompatibilityUpdater to substitute an older external-updater binary +# into the primary channel's manifest (the production migration-release shape). +# +# Independent of test-local-update.ps1. +# +# Windows-only. +# ========================================================================================= + +#Requires -Version 7.0 + +[CmdletBinding()] +param( + [string]$InstalledVersion = '0.0.1-local', + [string]$ServerVersion = '99.99.99-local', + [string]$Branch = 'beta', + [string]$CompatibilityUpdater +) + +$ErrorActionPreference = 'Stop' + +$root = $PSScriptRoot +if ([string]::IsNullOrEmpty($root)) { $root = Get-Location } + +$deployArgs = @{ + InstalledVersion = $InstalledVersion + ServerVersion = $ServerVersion + DualPublish = $true +} +if ($CompatibilityUpdater) { $deployArgs.CompatibilityUpdater = $CompatibilityUpdater } + +& (Join-Path $root 'deploy-local.ps1') @deployArgs +if ($LASTEXITCODE -ne 0) { throw "deploy-local.ps1 -DualPublish failed (exit $LASTEXITCODE)." } + +$nextServerDir = Join-Path $root '.local_deploy\server\v2' +if (-not (Test-Path $nextServerDir)) { + throw "Expected /v2/ server dir at '$nextServerDir' but it does not exist." +} +$nextServerUri = "file:///$(((Resolve-Path $nextServerDir).Path -replace '\\','/'))" + +& (Join-Path $root 'modules\ModdingToolBase\scripts\Test-LocalUpdateCycle.ps1') ` + -AppExePath (Join-Path $root '.local_deploy\install\ModVerify.exe') ` + -ServerUri $nextServerUri ` + -Branch $Branch ` + -NoUpdateMessage 'No update available.' ` + -ExpectedNewVersion $ServerVersion + +exit $LASTEXITCODE diff --git a/test-local-update.ps1 b/test-local-update.ps1 new file mode 100644 index 00000000..aeda8f86 --- /dev/null +++ b/test-local-update.ps1 @@ -0,0 +1,37 @@ +# ========================================================================================= +# Local bootstrap for the self-update integration test. +# +# Stages a local deploy via deploy-local.ps1, then runs the shared end-to-end test from +# ModdingToolBase against the staged install dir + signed local server. +# +# Windows-only. +# ========================================================================================= + +#Requires -Version 7.0 + +[CmdletBinding()] +param( + [string]$InstalledVersion = '0.0.1-local', + [string]$ServerVersion = '99.99.99-local', + [string]$Branch = 'beta' +) + +$ErrorActionPreference = 'Stop' + +$root = $PSScriptRoot +if ([string]::IsNullOrEmpty($root)) { $root = Get-Location } + +& (Join-Path $root 'deploy-local.ps1') -InstalledVersion $InstalledVersion -ServerVersion $ServerVersion +if ($LASTEXITCODE -ne 0) { throw "deploy-local.ps1 failed (exit $LASTEXITCODE)." } + +$serverDir = Join-Path $root '.local_deploy\server' +$serverUri = "file:///$(((Resolve-Path $serverDir).Path -replace '\\','/'))" + +& (Join-Path $root 'modules\ModdingToolBase\scripts\Test-LocalUpdateCycle.ps1') ` + -AppExePath (Join-Path $root '.local_deploy\install\ModVerify.exe') ` + -ServerUri $serverUri ` + -Branch $Branch ` + -NoUpdateMessage 'No update available.' ` + -ExpectedNewVersion $ServerVersion + +exit $LASTEXITCODE diff --git a/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj b/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj index 6777b351..fc80dff4 100644 --- a/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj +++ b/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj @@ -13,19 +13,23 @@ - - - + + + - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs b/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs index ac98cfc1..231b92a4 100644 --- a/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs +++ b/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs @@ -34,6 +34,34 @@ public void Parse_UpdateAppArg() Assert.NotNull(settings.UpdateOptions); Assert.Equal("test", settings.UpdateOptions.BranchName); Assert.Equal("https://examlple.com", settings.UpdateOptions.ManifestUrl); + Assert.Null(settings.UpdateOptions.ServerUrl); + } + + [Fact] + public void Parse_UpdateAppArg_ServerUrl() + { + const string argString = "updateApplication --updateBranch test --updateServerUrl https://example.com/updates"; + + var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + Assert.True(settings.HasOptions); + Assert.Null(settings.ModVerifyOptions); + Assert.NotNull(settings.UpdateOptions); + Assert.Equal("test", settings.UpdateOptions.BranchName); + Assert.Equal("https://example.com/updates", settings.UpdateOptions.ServerUrl); + Assert.Null(settings.UpdateOptions.ManifestUrl); + } + + [Fact] + public void Parse_UpdateAppArg_ManifestAndServerUrl_AreMutuallyExclusive() + { + const string argString = "updateApplication --updateManifestUrl https://example.com/manifest.json --updateServerUrl https://example.com"; + + var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + Assert.False(settings.HasOptions); + Assert.Null(settings.ModVerifyOptions); + Assert.Null(settings.UpdateOptions); } [Fact] @@ -65,6 +93,7 @@ protected override ApplicationEnvironment CreateEnvironment() [InlineData("createBaseline --junkOption")] [InlineData("updateApplication")] [InlineData("updateApplication --updateBranch test --updateManifestUrl https://examlple.com")] + [InlineData("updateApplication --updateBranch test --updateServerUrl https://example.com")] public void Parse_InvalidArgs_NotUpdateable(string argString) { var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); @@ -214,34 +243,18 @@ public void Parse_CreateBaseline_MissingRequired_Fails(string argString) } [Theory] - [InlineData("verify --mods myMod --baseline myBaseline.json", "myBaseline.json", false, false)] - [InlineData("verify --mods myMod --searchBaseline", null, true, false)] - [InlineData("verify --path myMod --useDefaultBaseline", null, false, true)] - public void Parse_Verify_BaselineOptions(string argString, string? expectedBaseline, bool expectedSearchBaseline, bool expectedUseDefaultBaseline) + [InlineData("verify --mods myMod --baseline myBaseline.json", "myBaseline.json", false)] + [InlineData("verify --path myMod --useDefaultBaseline", null, true)] + public void Parse_Verify_BaselineOptions(string argString, string? expectedBaseline, bool expectedUseDefaultBaseline) { var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); Assert.True(settings.HasOptions); var verify = Assert.IsType(settings.ModVerifyOptions); - Assert.Equal(expectedBaseline, verify.Baseline); - Assert.Equal(expectedSearchBaseline, verify.SearchBaselineLocally); + Assert.Equal(expectedBaseline, verify.BaselinePaths); Assert.Equal(expectedUseDefaultBaseline, verify.UseDefaultBaseline); } - [Fact] - public void Parse_Verify_Baseline_And_SearchBaseline_CanBeParsedTogether() - { - // Mutual exclusivity of --baseline and --searchBaseline is enforced later by SettingsBuilder, not by the parser. - const string argString = "verify --mods myMod --baseline myBaseline.json --searchBaseline"; - - var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); - - Assert.True(settings.HasOptions); - var verify = Assert.IsType(settings.ModVerifyOptions); - Assert.Equal("myBaseline.json", verify.Baseline); - Assert.True(verify.SearchBaselineLocally); - } - [Theory] [InlineData("verify --path myMod --outDir myOut", "myOut")] [InlineData("verify --path myMod -o myOut", "myOut")] diff --git a/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs b/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs index 429567b8..f83ffeae 100644 --- a/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs +++ b/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs @@ -70,41 +70,82 @@ public void BuildSettings_FallbackGamePath_RequiresGamePath() } [Fact] - public void BuildSettings_UseDefaultBaseline_And_Baseline_Throws() + public void BuildSettings_UseDefaultBaseline_And_Baseline() { var options = new VerifyVerbOption { UseDefaultBaseline = true, - Baseline = "myBaseline.json", + BaselinePaths = "myBaseline.json", TargetPath = "myPath", }; - Assert.Throws(() => _builder.BuildSettings(options)); + var settings = _builder.BuildSettings(options); + Assert.NotNull(settings); + var verifySettings = Assert.IsType(settings); + Assert.Equal([FileSystem.Path.GetFullPath("myBaseline.json")], verifySettings.ReportSettings.BaselinePaths); + Assert.True(verifySettings.ReportSettings.UseDefaultBaseline); } [Fact] - public void BuildSettings_UseDefaultBaseline_And_SearchBaseline_Throws() + public void BuildSettings_UseDefaultBaseline_Alone_DoesNotThrow() { var options = new VerifyVerbOption { UseDefaultBaseline = true, - SearchBaselineLocally = true, TargetPath = "myPath", }; - Assert.Throws(() => _builder.BuildSettings(options)); + var settings = _builder.BuildSettings(options); + Assert.NotNull(settings); } [Fact] - public void BuildSettings_UseDefaultBaseline_Alone_DoesNotThrow() + public void BuildSettings_CreateBaseline_Baseline_And_UseDefaultBaseline() { - var options = new VerifyVerbOption + var options = new CreateBaselineVerbOption { + OutputFile = "out.json", + BaselinePaths = "input.json", UseDefaultBaseline = true, TargetPath = "myPath", }; var settings = _builder.BuildSettings(options); - Assert.NotNull(settings); + var baselineSettings = Assert.IsType(settings); + Assert.Equal([FileSystem.Path.GetFullPath("input.json")], baselineSettings.ReportSettings.BaselinePaths); + Assert.True(baselineSettings.ReportSettings.UseDefaultBaseline); + } + + [Fact] + public void BuildSettings_Baselines_SplitsByPathSeparator() + { + var separator = FileSystem.Path.PathSeparator; + var options = new VerifyVerbOption + { + BaselinePaths = $"first.json{separator}second.json", + TargetPath = "myPath", + }; + + var settings = _builder.BuildSettings(options); + var verifySettings = Assert.IsType(settings); + Assert.Equal( + [FileSystem.Path.GetFullPath("first.json"), FileSystem.Path.GetFullPath("second.json")], + verifySettings.ReportSettings.BaselinePaths); + } + + [Fact] + public void BuildSettings_VerifyVerb_UsesLiveVirtualFileSystem() + { + var options = new VerifyVerbOption { TargetPath = "myPath" }; + var settings = (AppVerifySettings)_builder.BuildSettings(options); + Assert.True(settings.VerifierServiceSettings.UseLiveVirtualFileSystem); + } + + [Fact] + public void BuildSettings_CreateBaselineVerb_DoesNotUseLiveVirtualFileSystem() + { + var options = new CreateBaselineVerbOption { TargetPath = "myPath", OutputFile = "out.json" }; + var settings = (AppBaselineSettings)_builder.BuildSettings(options); + Assert.False(settings.VerifierServiceSettings.UseLiveVirtualFileSystem); } } diff --git a/test/ModVerify.Test/AssemblyInfo.cs b/test/ModVerify.Test/AssemblyInfo.cs new file mode 100644 index 00000000..21712008 --- /dev/null +++ b/test/ModVerify.Test/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/CommandBarComponentFiles.xml b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/CommandBarComponentFiles.xml new file mode 100644 index 00000000..c900f1e9 --- /dev/null +++ b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/CommandBarComponentFiles.xml @@ -0,0 +1,4 @@ + + + Empty_CommandBarComponents.xml + diff --git a/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/Empty_CommandBarComponents.xml b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/Empty_CommandBarComponents.xml new file mode 100644 index 00000000..03dd08bd --- /dev/null +++ b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/Empty_CommandBarComponents.xml @@ -0,0 +1,6 @@ + + + + Icon + + diff --git a/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/Empty_GameObjects.xml b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/Empty_GameObjects.xml new file mode 100644 index 00000000..c3bcf1b4 --- /dev/null +++ b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/Empty_GameObjects.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/Empty_SFXEvents.xml b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/Empty_SFXEvents.xml new file mode 100644 index 00000000..728570e0 --- /dev/null +++ b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/Empty_SFXEvents.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/GameObjectFiles.xml b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/GameObjectFiles.xml new file mode 100644 index 00000000..de8cdede --- /dev/null +++ b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/GameObjectFiles.xml @@ -0,0 +1,4 @@ + + + Empty_GameObjects.xml + diff --git a/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/GuiDialogs.xml b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/GuiDialogs.xml new file mode 100644 index 00000000..dcf8336a --- /dev/null +++ b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/GuiDialogs.xml @@ -0,0 +1,8 @@ + + + + + placeholder + + + diff --git a/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/SFXEventFiles.xml b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/SFXEventFiles.xml new file mode 100644 index 00000000..656cfb22 --- /dev/null +++ b/test/ModVerify.Test/Fixtures/MinimalFoc/Data/XML/SFXEventFiles.xml @@ -0,0 +1,4 @@ + + + Empty_SFXEvents.xml + diff --git a/test/ModVerify.Test/Framework/ErrorAssertions.cs b/test/ModVerify.Test/Framework/ErrorAssertions.cs new file mode 100644 index 00000000..a2fad0b9 --- /dev/null +++ b/test/ModVerify.Test/Framework/ErrorAssertions.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using AET.ModVerify.Reporting; +using Xunit; + +namespace ModVerify.Test.Framework; + +internal static class ErrorAssertions +{ + public static VerificationError Single( + IEnumerable errors, string id, + string? asset = null, string? contextContains = null) + { + var matches = errors.Where(e => Match(e, id, asset, contextContains)).ToList(); + Assert.Single(matches); + return matches[0]; + } + + public static void None(IEnumerable errors, string id) + { + Assert.DoesNotContain(errors, e => e.Id == id); + } + + public static void Exactly(IEnumerable errors, int expected, string id) + { + Assert.Equal(expected, errors.Count(e => e.Id == id)); + } + + private static bool Match(VerificationError e, string id, string? asset, string? contextContains) + { + if (e.Id != id) + return false; + if (asset != null && e.Asset != asset) + return false; + return contextContains == null || e.ContextEntries.Any(c => c.Contains(contextContains)); + } +} diff --git a/test/ModVerify.Test/Framework/MinimalFoc.cs b/test/ModVerify.Test/Framework/MinimalFoc.cs new file mode 100644 index 00000000..c4bc2378 --- /dev/null +++ b/test/ModVerify.Test/Framework/MinimalFoc.cs @@ -0,0 +1,34 @@ +using System; +using System.Reflection; +using PG.StarWarsGame.Engine.Testing; + +namespace ModVerify.Test.Framework; + +/// Provides the minimal FoC skeleton as an extension on . +public static class MinimalFoc +{ + private static readonly Assembly Asm = typeof(MinimalFoc).Assembly; + + /// Scaffolds the committed minimal FoC skeleton onto the builder's game origin. + /// + /// Empty MEG archives are produced at scaffold time using the production MEG writer; the resulting bytes are written through the builder. + /// Hand-authoring binary MEG files in Fixtures/ would require committing platform-specific bytes. + /// + /// The builder to populate. + public static VirtualGameRepoBuilder WithMinimalFoc(this VirtualGameRepoBuilder builder) + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + return builder.ConfigureGame(g => + { + g.WriteEmptyMeg("Data/Patch.meg"); + g.WriteEmptyMeg("Data/Patch2.meg"); + g.WriteEmptyMeg("Data/64Patch.meg"); + g.WriteEmptyMeg("Data/Audio/SFX/SFX2D_NON_LOCALIZED.MEG"); + g.WriteEmptyMeg("Data/Audio/SFX/SFX3D_NON_LOCALIZED.MEG"); + + g.WriteEmbeddedTree("MinimalFoc", Asm); + }); + } +} diff --git a/test/ModVerify.Test/Framework/ModVerifyTestBase.cs b/test/ModVerify.Test/Framework/ModVerifyTestBase.cs new file mode 100644 index 00000000..90e367b2 --- /dev/null +++ b/test/ModVerify.Test/Framework/ModVerifyTestBase.cs @@ -0,0 +1,93 @@ +using System; +using System.IO.Abstractions; +using System.Threading.Tasks; +using AET.ModVerify; +using AET.ModVerify.Reporting; +using AET.ModVerify.Reporting.Baseline; +using AET.ModVerify.Reporting.Suppressions; +using AET.ModVerify.Settings; +using AnakinRaW.CommonUtilities.Hashing; +using AnakinRaW.CommonUtilities.Testing; +using Microsoft.Extensions.DependencyInjection; +using PG.Commons; +using PG.StarWarsGame.Engine; +using PG.StarWarsGame.Engine.Testing; +using PG.StarWarsGame.Files.ALO; +using PG.StarWarsGame.Files.MEG; +using PG.StarWarsGame.Files.MTD; +using PG.StarWarsGame.Files.XML; +using Testably.Abstractions; +using Xunit; + +namespace ModVerify.Test.Framework; + +public abstract class ModVerifyTestBase : TestBaseWithFileSystem +{ + protected override void SetupServices(IServiceCollection serviceCollection) + { + base.SetupServices(serviceCollection); + + serviceCollection.AddSingleton(sp => new HashingService(sp)); + + serviceCollection.SupportMTD(); + serviceCollection.SupportMEG(); + serviceCollection.SupportALO(); + serviceCollection.SupportXML(); + PetroglyphCommons.ContributeServices(serviceCollection); + PetroglyphEngineServiceContribution.ContributeServices(serviceCollection); + } + + protected override IFileSystem CreateFileSystem() + { + return new RealFileSystem(); + } + + /// Creates a builder bound to the test base's service provider. + protected VirtualGameRepoBuilder CreateBuilder() + { + return new VirtualGameRepoBuilder(ServiceProvider); + } + + /// Creates the default for a pipeline run. + /// defaults to 1 so the order of verifier invocations is deterministic. + protected virtual VerifierServiceSettings CreateDefaultSettings(IGameVerifiersProvider verifiers) + { + return new VerifierServiceSettings + { + VerifiersProvider = verifiers, + ParallelVerifiers = 1, + UseLiveVirtualFileSystem = false, + GameVerifySettings = GameVerifySettings.Default, + FailFastSettings = FailFastSetting.NoFailFast, + }; + } + + protected async Task RunPipelineAsync( + VirtualGameRepo repo, + IGameVerifiersProvider? verifiers = null, + BaselineCollection? baselines = null, + SuppressionList? suppressions = null, + VerifierServiceSettings? settings = null) + { + if (repo == null) + throw new ArgumentNullException(nameof(repo)); + + var serviceSettings = settings ?? CreateDefaultSettings( + verifiers ?? new DefaultGameVerifiersProvider()); + var target = new VerificationTarget + { + Engine = GameEngineType.Foc, + Location = repo.GameLocations, + Name = "test-target" + }; + + using var pipeline = new GameVerifyPipeline( + target, + serviceSettings, + ServiceProvider, + baselines ?? BaselineCollection.Empty, + suppressions ?? SuppressionList.Empty); + await pipeline.RunAsync(TestContext.Current.CancellationToken).ConfigureAwait(false); + return pipeline.Errors; + } +} diff --git a/test/ModVerify.Test/Framework/Providers/ErrorThenTrackingProvider.cs b/test/ModVerify.Test/Framework/Providers/ErrorThenTrackingProvider.cs new file mode 100644 index 00000000..5e431d01 --- /dev/null +++ b/test/ModVerify.Test/Framework/Providers/ErrorThenTrackingProvider.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using AET.ModVerify; +using AET.ModVerify.Reporting; +using AET.ModVerify.Settings; +using AET.ModVerify.Verifiers; +using ModVerify.Test.Framework.Verifiers; +using PG.StarWarsGame.Engine; + +namespace ModVerify.Test.Framework.Providers; + +internal sealed class ErrorThenTrackingProvider( + Func errorFactory) + : IGameVerifiersProvider +{ + public TrackingVerifier? Tracker { get; private set; } + + public IEnumerable GetVerifiers( + IStarWarsGameEngine engine, GameVerifySettings settings, IServiceProvider sp) + { + yield return errorFactory(engine, settings, sp); + Tracker = new TrackingVerifier(engine, settings, sp); + yield return Tracker; + } + + public static ErrorThenTrackingProvider Create( + string id, + string asset, + string[] context, + VerificationSeverity severity, + string message = "static error") + { + var spec = new StaticErrorSpec(id, asset, context, severity, message); + return new ErrorThenTrackingProvider((engine, settings, sp) => new StaticErrorVerifier([spec], engine, settings, sp)); + } +} diff --git a/test/ModVerify.Test/Framework/Providers/NoVerifiersProvider.cs b/test/ModVerify.Test/Framework/Providers/NoVerifiersProvider.cs new file mode 100644 index 00000000..37a3d3a6 --- /dev/null +++ b/test/ModVerify.Test/Framework/Providers/NoVerifiersProvider.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using AET.ModVerify; +using AET.ModVerify.Settings; +using AET.ModVerify.Verifiers; +using PG.StarWarsGame.Engine; + +namespace ModVerify.Test.Framework.Providers; + +internal sealed class NoVerifiersProvider : IGameVerifiersProvider +{ + public IEnumerable GetVerifiers( + IStarWarsGameEngine gameEngine, GameVerifySettings settings, IServiceProvider sp) + { + return []; + } +} diff --git a/test/ModVerify.Test/Framework/Providers/SingleVerifierProvider.cs b/test/ModVerify.Test/Framework/Providers/SingleVerifierProvider.cs new file mode 100644 index 00000000..1ff3b529 --- /dev/null +++ b/test/ModVerify.Test/Framework/Providers/SingleVerifierProvider.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using AET.ModVerify; +using AET.ModVerify.Settings; +using AET.ModVerify.Verifiers; +using PG.StarWarsGame.Engine; + +namespace ModVerify.Test.Framework.Providers; + +/// Provides a single verifier instance produced by the given factory. +internal sealed class SingleVerifierProvider( + Func factory) + : IGameVerifiersProvider + where TVerifier : GameVerifier +{ + private readonly Func _factory = factory + ?? throw new ArgumentNullException(nameof(factory)); + + public IEnumerable GetVerifiers( + IStarWarsGameEngine gameEngine, GameVerifySettings settings, IServiceProvider serviceProvider) + { + yield return _factory(gameEngine, settings, serviceProvider); + } +} diff --git a/test/ModVerify.Test/Framework/Providers/StaticErrorProvider.cs b/test/ModVerify.Test/Framework/Providers/StaticErrorProvider.cs new file mode 100644 index 00000000..253c926a --- /dev/null +++ b/test/ModVerify.Test/Framework/Providers/StaticErrorProvider.cs @@ -0,0 +1,23 @@ +using AET.ModVerify.Reporting; +using ModVerify.Test.Framework.Verifiers; + +namespace ModVerify.Test.Framework.Providers; + +internal static class StaticErrorProvider +{ + public static SingleVerifierProvider Create( + string id, + string asset, + string[] context, + string message = "static error", + VerificationSeverity severity = VerificationSeverity.Warning) + { + return Create(new StaticErrorSpec(id, asset, context, severity, message)); + } + + public static SingleVerifierProvider Create(params StaticErrorSpec[] errors) + { + return new SingleVerifierProvider( + (engine, settings, sp) => new StaticErrorVerifier(errors, engine, settings, sp)); + } +} diff --git a/test/ModVerify.Test/Framework/VerifierTestBase.cs b/test/ModVerify.Test/Framework/VerifierTestBase.cs new file mode 100644 index 00000000..bb7ccfb1 --- /dev/null +++ b/test/ModVerify.Test/Framework/VerifierTestBase.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AET.ModVerify.Reporting; +using AET.ModVerify.Settings; +using AET.ModVerify.Verifiers; +using ModVerify.Test.Framework.Providers; +using PG.StarWarsGame.Engine; +using PG.StarWarsGame.Engine.Testing; + +namespace ModVerify.Test.Framework; + +public abstract class VerifierTestBase : ModVerifyTestBase + where TVerifier : GameVerifier +{ + protected async Task> RunAsync( + VirtualGameRepo repo, + Func factory, + VerifierServiceSettings? settings = null) + { + if (factory == null) + throw new ArgumentNullException(nameof(factory)); + + var provider = new SingleVerifierProvider(factory); + var result = await RunPipelineAsync(repo, verifiers: provider, settings: settings).ConfigureAwait(false); + return result.NewErrors.Concat(result.ExistingErrors.Values.SelectMany(v => v)).ToList(); + } +} diff --git a/test/ModVerify.Test/Framework/Verifiers/StaticErrorVerifier.cs b/test/ModVerify.Test/Framework/Verifiers/StaticErrorVerifier.cs new file mode 100644 index 00000000..38b58f17 --- /dev/null +++ b/test/ModVerify.Test/Framework/Verifiers/StaticErrorVerifier.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using AET.ModVerify.Reporting; +using AET.ModVerify.Settings; +using AET.ModVerify.Verifiers; +using PG.StarWarsGame.Engine; + +namespace ModVerify.Test.Framework.Verifiers; + +/// Emits a fixed set of errors on each call. +internal sealed class StaticErrorVerifier( + IReadOnlyList errors, + IStarWarsGameEngine gameEngine, + GameVerifySettings settings, + IServiceProvider sp) + : GameVerifier(gameEngine, settings, sp) +{ + public override void Verify(CancellationToken token) + { + foreach (var spec in errors) + AddError(VerificationError.Create(this, spec.Id, spec.Message, spec.Severity, spec.Context, spec.Asset)); + } +} + +internal sealed record StaticErrorSpec( + string Id, + string Asset, + string[] Context, + VerificationSeverity Severity = VerificationSeverity.Warning, + string Message = "static error"); diff --git a/test/ModVerify.Test/Framework/Verifiers/TrackingVerifier.cs b/test/ModVerify.Test/Framework/Verifiers/TrackingVerifier.cs new file mode 100644 index 00000000..3f958fb4 --- /dev/null +++ b/test/ModVerify.Test/Framework/Verifiers/TrackingVerifier.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading; +using AET.ModVerify.Settings; +using AET.ModVerify.Verifiers; +using PG.StarWarsGame.Engine; + +namespace ModVerify.Test.Framework.Verifiers; + +/// A test verifier that records whether has been called. +internal sealed class TrackingVerifier(IStarWarsGameEngine engine, GameVerifySettings settings, IServiceProvider sp) + : GameVerifier(engine, settings, sp) +{ + public bool WasInvoked { get; private set; } + + public override void Verify(CancellationToken token) + { + WasInvoked = true; + } +} diff --git a/test/ModVerify.Test/ModVerify.Test.csproj b/test/ModVerify.Test/ModVerify.Test.csproj new file mode 100644 index 00000000..a4b23d98 --- /dev/null +++ b/test/ModVerify.Test/ModVerify.Test.csproj @@ -0,0 +1,44 @@ + + + + net10.0 + $(TargetFrameworks);net481 + preview + enable + + + + false + true + Exe + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + $([System.String]::Copy('%(RecursiveDir)%(Filename)%(Extension)').Replace('\','/')) + + + + diff --git a/test/ModVerify.Test/Pipeline/BaselineCategorizationTest.cs b/test/ModVerify.Test/Pipeline/BaselineCategorizationTest.cs new file mode 100644 index 00000000..1a3514e5 --- /dev/null +++ b/test/ModVerify.Test/Pipeline/BaselineCategorizationTest.cs @@ -0,0 +1,142 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AET.ModVerify.Reporting; +using AET.ModVerify.Reporting.Baseline; +using AET.ModVerify.Reporting.Suppressions; +using AET.ModVerify.Verifiers; +using ModVerify.Test.Framework; +using ModVerify.Test.Framework.Providers; +using ModVerify.Test.Framework.Verifiers; +using Xunit; + +namespace ModVerify.Test.Pipeline; + +public class BaselineCategorizationTest : ModVerifyTestBase +{ + [Fact] + public async Task Categorize_ErrorInRunAndBaseline_IsExisting() + { + using var repo = CreateBuilder().WithMinimalFoc().Build(); + + var baseline = BuildBaseline(("TEST00", "asset-1", ["ctx"])); + var provider = StaticErrorProvider.Create( + id: "TEST00", asset: "asset-1", context: ["ctx"]); + + var result = await RunPipelineAsync(repo, verifiers: provider, baselines: baseline); + + Assert.Empty(result.NewErrors); + Assert.Single(result.ExistingErrors.Values.SelectMany(v => v)); + } + + [Fact] + public async Task Categorize_ErrorOnlyInRun_IsNew() + { + using var repo = CreateBuilder().WithMinimalFoc().Build(); + var provider = StaticErrorProvider.Create( + id: "TEST00", asset: "asset-1", context: ["ctx"]); + + var result = await RunPipelineAsync(repo, verifiers: provider); + + Assert.Single(result.NewErrors, e => e is { Id: "TEST00", Asset: "asset-1" }); + } + + [Fact] + public async Task Categorize_ErrorOnlyInBaseline_IsResolved() + { + using var repo = CreateBuilder().WithMinimalFoc().Build(); + var baseline = BuildBaseline(("TEST00", "asset-1", ["ctx"])); + + var result = await RunPipelineAsync(repo, verifiers: new NoVerifiersProvider(), baselines: baseline); + + Assert.Single(result.ResolvedErrors.Values.SelectMany(v => v), + e => e is { Id: "TEST00", Asset: "asset-1" }); + Assert.Empty(result.NewErrors); + } + + [Fact] + public async Task Categorize_SuppressedErrorAlsoInBaseline_IsResolved() + { + using var repo = CreateBuilder().WithMinimalFoc().Build(); + + var baseline = BuildBaseline(("TEST00", "asset-1", ["ctx"])); + var suppressions = new SuppressionList( + [ + new SuppressionFilter(id: "TEST00", verifier: null, asset: "asset-1"), + ]); + var provider = StaticErrorProvider.Create( + id: "TEST00", asset: "asset-1", context: ["ctx"]); + + var result = await RunPipelineAsync(repo, verifiers: provider, baselines: baseline, suppressions: suppressions); + + Assert.Empty(result.NewErrors); + Assert.Empty(result.ExistingErrors.Values.SelectMany(v => v)); + Assert.Single(result.ResolvedErrors.Values.SelectMany(v => v), + e => e is { Id: "TEST00", Asset: "asset-1" }); + } + + [Fact] + public async Task Categorize_OneOfTwoEmittedErrorsSuppressed_BothInBaseline_SuppressedIsResolved_OtherIsExisting() + { + using var repo = CreateBuilder().WithMinimalFoc().Build(); + + var baseline = BuildBaseline( + ("TEST00", "asset-1", ["ctx"]), + ("TEST01", "asset-2", ["ctx"])); + var suppressions = new SuppressionList( + [ + new SuppressionFilter(id: "TEST00", verifier: null, asset: "asset-1"), + ]); + var provider = StaticErrorProvider.Create( + new StaticErrorSpec("TEST00", "asset-1", ["ctx"]), + new StaticErrorSpec("TEST01", "asset-2", ["ctx"])); + + var result = await RunPipelineAsync(repo, verifiers: provider, baselines: baseline, suppressions: suppressions); + + Assert.Empty(result.NewErrors); + Assert.Single(result.ExistingErrors.Values.SelectMany(v => v), + e => e is { Id: "TEST01", Asset: "asset-2" }); + Assert.Single(result.ResolvedErrors.Values.SelectMany(v => v), + e => e is { Id: "TEST00", Asset: "asset-1" }); + } + + [Fact] + public async Task Categorize_NewErrorAndSuppressedError_OnlyUnsuppressedAppearsAsNew() + { + using var repo = CreateBuilder().WithMinimalFoc().Build(); + + var suppressions = new SuppressionList( + [ + new SuppressionFilter(id: "TEST00", verifier: null, asset: "asset-1"), + ]); + var provider = StaticErrorProvider.Create( + new StaticErrorSpec("TEST00", "asset-1", ["ctx"]), + new StaticErrorSpec("TEST01", "asset-2", ["ctx"])); + + var result = await RunPipelineAsync(repo, verifiers: provider, suppressions: suppressions); + + Assert.Single(result.NewErrors, e => e is { Id: "TEST01", Asset: "asset-2" }); + Assert.DoesNotContain(result.NewErrors, e => e.Id == "TEST00"); + } + + private static BaselineCollection BuildBaseline(params (string Id, string Asset, string[] Context)[] entries) + { + var verifierInfo = new StubVerifierInfo("stub"); + var errors = entries + .Select(e => new VerificationError( + e.Id, "baseline error", verifierInfo, e.Context, e.Asset, VerificationSeverity.Warning)) + .ToList(); + var baseline = new VerificationBaseline(VerificationSeverity.Information, errors, target: null); + return new BaselineCollection([ + new IdentifiedBaseline("test-baseline", baseline, BaselineSource.File) + ]); + } + + private sealed class StubVerifierInfo(string name) : IGameVerifierInfo + { + public IGameVerifierInfo? Parent => null; + public IReadOnlyList VerifierChain => [this]; + public string Name { get; } = name; + public string FriendlyName => Name; + } +} diff --git a/test/ModVerify.Test/Pipeline/EngineErrorSurfacingTest.cs b/test/ModVerify.Test/Pipeline/EngineErrorSurfacingTest.cs new file mode 100644 index 00000000..729078a9 --- /dev/null +++ b/test/ModVerify.Test/Pipeline/EngineErrorSurfacingTest.cs @@ -0,0 +1,27 @@ +using System.Linq; +using System.Threading.Tasks; +using AET.ModVerify.Verifiers; +using ModVerify.Test.Framework; +using ModVerify.Test.Framework.Providers; +using Xunit; + +namespace ModVerify.Test.Pipeline; + +public class EngineErrorSurfacingTest : ModVerifyTestBase +{ + [Fact] + public async Task Verify_MalformedXml_SurfacesAsVerificationError() + { + using var repo = CreateBuilder() + .WithMinimalFoc() + .ConfigureGame(g => g.WriteXml("SFXEventFiles.xml", "< e.Id is VerifierErrorCodes.GenericXmlError or VerifierErrorCodes.EmptyXmlRoot) + .ToList(); + Assert.NotEmpty(xmlErrors); + } +} diff --git a/test/ModVerify.Test/Pipeline/FailFastTest.cs b/test/ModVerify.Test/Pipeline/FailFastTest.cs new file mode 100644 index 00000000..7f90b3c4 --- /dev/null +++ b/test/ModVerify.Test/Pipeline/FailFastTest.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading.Tasks; +using AET.ModVerify; +using AET.ModVerify.Reporting; +using AET.ModVerify.Reporting.Suppressions; +using AET.ModVerify.Settings; +using ModVerify.Test.Framework; +using ModVerify.Test.Framework.Providers; +using Xunit; + +namespace ModVerify.Test.Pipeline; + +public class FailFastTest : ModVerifyTestBase +{ + [Fact] + public async Task RunPipeline_ErrorAboveThreshold_AbortsBeforeSubsequentVerifiers() + { + using var repo = CreateBuilder().WithMinimalFoc().Build(); + + var provider = ErrorThenTrackingProvider.Create( + id: "TEST00", asset: "asset-1", context: ["ctx"], + severity: VerificationSeverity.Error); + + var settings = BuildFailFastSettings(provider); + + await Assert.ThrowsAnyAsync( + () => RunPipelineAsync(repo, settings: settings)); + + Assert.False(provider.Tracker!.WasInvoked, + "TrackingVerifier ran despite fail-fast — pipeline did not actually short-circuit."); + } + + [Fact] + public async Task RunPipeline_SuppressedErrorAboveThreshold_DoesNotAbort() + { + using var repo = CreateBuilder().WithMinimalFoc().Build(); + + var provider = ErrorThenTrackingProvider.Create( + id: "TEST00", asset: "asset-1", context: ["ctx"], + severity: VerificationSeverity.Error); + + var suppressions = new SuppressionList( + [ + new SuppressionFilter(id: "TEST00", verifier: null, asset: "asset-1"), + ]); + + var settings = BuildFailFastSettings(provider); + + var result = await RunPipelineAsync(repo, suppressions: suppressions, settings: settings); + + Assert.True(provider.Tracker!.WasInvoked, + "TrackingVerifier did not run despite the error being suppressed."); + Assert.Empty(result.NewErrors); + } + + private static VerifierServiceSettings BuildFailFastSettings(IGameVerifiersProvider provider) + { + return new VerifierServiceSettings + { + VerifiersProvider = provider, + ParallelVerifiers = 1, + UseLiveVirtualFileSystem = false, + FailFastSettings = new FailFastSetting(VerificationSeverity.Error), + GameVerifySettings = GameVerifySettings.Default with + { + ThrowsOnMinimumSeverity = VerificationSeverity.Error, + }, + }; + } +} diff --git a/test/ModVerify.Test/Pipeline/MinimalFocTest.cs b/test/ModVerify.Test/Pipeline/MinimalFocTest.cs new file mode 100644 index 00000000..529817eb --- /dev/null +++ b/test/ModVerify.Test/Pipeline/MinimalFocTest.cs @@ -0,0 +1,23 @@ +using System.Linq; +using System.Threading.Tasks; +using ModVerify.Test.Framework; +using ModVerify.Test.Framework.Providers; +using Xunit; + +namespace ModVerify.Test.Pipeline; + +public class MinimalFocTest : ModVerifyTestBase +{ + [Fact] + public async Task Verify_MinimalFoc_BootsCleanWithoutInitErrors() + { + using var repo = CreateBuilder() + .WithMinimalFoc() + .Build(); + + var result = await RunPipelineAsync(repo, verifiers: new NoVerifiersProvider()); + + Assert.Empty(result.NewErrors); + Assert.Empty(result.ExistingErrors.Values.SelectMany(v => v)); + } +} diff --git a/test/ModVerify.Test/Pipeline/SuppressionFilteringTest.cs b/test/ModVerify.Test/Pipeline/SuppressionFilteringTest.cs new file mode 100644 index 00000000..cc3fe1bc --- /dev/null +++ b/test/ModVerify.Test/Pipeline/SuppressionFilteringTest.cs @@ -0,0 +1,29 @@ +using System.Linq; +using System.Threading.Tasks; +using AET.ModVerify.Reporting.Suppressions; +using ModVerify.Test.Framework; +using ModVerify.Test.Framework.Providers; +using Xunit; + +namespace ModVerify.Test.Pipeline; + +public class SuppressionFilteringTest : ModVerifyTestBase +{ + [Fact] + public async Task RunPipeline_SuppressedError_IsFilteredBeforeBaselineCategorization() + { + using var repo = CreateBuilder().WithMinimalFoc().Build(); + + var suppressions = new SuppressionList( + [ + new SuppressionFilter(id: "TEST00", verifier: null, asset: "asset-1"), + ]); + + var provider = StaticErrorProvider.Create(id: "TEST00", asset: "asset-1", context: ["ctx"]); + + var result = await RunPipelineAsync(repo, verifiers: provider, suppressions: suppressions); + + Assert.Empty(result.NewErrors); + Assert.Empty(result.ExistingErrors.Values.SelectMany(v => v)); + } +} diff --git a/test/ModVerify.Test/Verifiers/CommandBarVerifierTest.cs b/test/ModVerify.Test/Verifiers/CommandBarVerifierTest.cs new file mode 100644 index 00000000..9c864a17 --- /dev/null +++ b/test/ModVerify.Test/Verifiers/CommandBarVerifierTest.cs @@ -0,0 +1,24 @@ +using System.Threading.Tasks; +using AET.ModVerify.Verifiers.CommandBar; +using ModVerify.Test.Framework; +using Xunit; + +namespace ModVerify.Test.Verifiers; + +public class CommandBarVerifierTest : VerifierTestBase +{ + [Fact] + public async Task Verify_NoShellsGroup_EmitsCmdBarError() + { + using var repo = CreateBuilder() + .WithMinimalFoc() + .Build(); + + var errors = await RunAsync(repo, + (engine, settings, sp) => new CommandBarVerifier(engine, settings, sp)); + + ErrorAssertions.Single(errors, + id: CommandBarVerifier.CommandBarNoShellsGroup, + asset: "GameCommandBar"); + } +} diff --git a/test/ModVerify.Test/Verifiers/GameObjectTypeVerifierTest.cs b/test/ModVerify.Test/Verifiers/GameObjectTypeVerifierTest.cs new file mode 100644 index 00000000..2184bcbf --- /dev/null +++ b/test/ModVerify.Test/Verifiers/GameObjectTypeVerifierTest.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; +using AET.ModVerify.Verifiers; +using AET.ModVerify.Verifiers.GameObjects; +using ModVerify.Test.Framework; +using Xunit; + +namespace ModVerify.Test.Verifiers; + +public class GameObjectTypeVerifierTest : VerifierTestBase +{ + [Fact] + public async Task Verify_GameObjectWithMissingLandModel_EmitsFileNotFound() + { + using var repo = CreateBuilder() + .WithMinimalFoc() + .ConfigureGame(g => + { + g.WriteXml("Empty_GameObjects.xml", """ + + + + does_not_exist.alo + + + """); + }) + .Build(); + + var errors = await RunAsync(repo, + (engine, settings, sp) => new GameObjectTypeVerifier(engine, settings, sp)); + + ErrorAssertions.Single(errors, + id: VerifierErrorCodes.FileNotFound, + asset: "DOES_NOT_EXIST.ALO", + contextContains: "INFANTRY_TROOPER_TEST"); + } +} diff --git a/test/ModVerify.Test/Verifiers/GuiDialogsVerifierTest.cs b/test/ModVerify.Test/Verifiers/GuiDialogsVerifierTest.cs new file mode 100644 index 00000000..d28ddbc2 --- /dev/null +++ b/test/ModVerify.Test/Verifiers/GuiDialogsVerifierTest.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using AET.ModVerify.Verifiers; +using AET.ModVerify.Verifiers.GuiDialogs; +using ModVerify.Test.Framework; +using Xunit; + +namespace ModVerify.Test.Verifiers; + +public class GuiDialogsVerifierTest : VerifierTestBase +{ + [Fact] + public async Task Verify_MissingMtdFile_EmitsFileNotFound() + { + using var repo = CreateBuilder() + .WithMinimalFoc() + .Build(); + + var errors = await RunAsync(repo, + (engine, settings, sp) => new GuiDialogsVerifier(engine, settings, sp)); + + ErrorAssertions.Single(errors, + id: VerifierErrorCodes.FileNotFound, + asset: "empty"); + } +} diff --git a/test/ModVerify.Test/Verifiers/HardcodedAssetsVerifierTest.cs b/test/ModVerify.Test/Verifiers/HardcodedAssetsVerifierTest.cs new file mode 100644 index 00000000..a814b6ff --- /dev/null +++ b/test/ModVerify.Test/Verifiers/HardcodedAssetsVerifierTest.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using AET.ModVerify.Verifiers; +using AET.ModVerify.Verifiers.Engine; +using ModVerify.Test.Framework; +using Xunit; + +namespace ModVerify.Test.Verifiers; + +public class HardcodedAssetsVerifierTest : VerifierTestBase +{ + [Fact] + public async Task Verify_MissingHardcodedAsset_EmitsFileNotFound() + { + using var repo = CreateBuilder() + .WithMinimalFoc() + .Build(); + + var errors = await RunAsync(repo, + (engine, settings, sp) => new HardcodedAssetsVerifier(engine, settings, sp)); + + Assert.Contains(errors, e => + e.Id == VerifierErrorCodes.FileNotFound && + e.Asset == "I_TUTORIAL_ARROW.ALO"); + } +} diff --git a/test/ModVerify.Test/Verifiers/SfxEventVerifierTest.cs b/test/ModVerify.Test/Verifiers/SfxEventVerifierTest.cs new file mode 100644 index 00000000..da9b0c93 --- /dev/null +++ b/test/ModVerify.Test/Verifiers/SfxEventVerifierTest.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; +using AET.ModVerify.Verifiers; +using AET.ModVerify.Verifiers.SfxEvents; +using ModVerify.Test.Framework; +using Xunit; + +namespace ModVerify.Test.Verifiers; + +public class SfxEventVerifierTest : VerifierTestBase +{ + [Fact] + public async Task Verify_SfxEventWithMissingSample_EmitsFileNotFound() + { + using var repo = CreateBuilder() + .WithMinimalFoc() + .ConfigureGame(g => + { + g.WriteXml("Empty_SFXEvents.xml", """ + + + + missing_sample.wav + + + """); + }) + .Build(); + + var errors = await RunAsync(repo, + (engine, settings, sp) => new SfxEventVerifier(engine, settings, sp)); + + ErrorAssertions.Single(errors, + id: VerifierErrorCodes.FileNotFound, + asset: "MISSING_SAMPLE.WAV", + contextContains: "TestEvent"); + } +} diff --git a/tools/v1/AnakinRaW.ExternalUpdater.exe b/tools/v1/AnakinRaW.ExternalUpdater.exe new file mode 100644 index 00000000..15949bf4 Binary files /dev/null and b/tools/v1/AnakinRaW.ExternalUpdater.exe differ diff --git a/version.json b/version.json index 3e87545b..13c8431a 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "0.1-beta", + "version": "0.2-beta", "publicReleaseRefSpec": [ "^refs/heads/main$" ],