diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 3c73f5d..7fd378c 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -51,14 +51,14 @@ jobs: dotnet-version: '9.x' - name: Restore dependencies - run: dotnet restore Volatility/Volatility.csproj + run: dotnet restore src/Volatility.Cli/Volatility.Cli.csproj - name: Fetch latest DXC shell: pwsh run: | $ErrorActionPreference = 'Stop' $rid = '${{ matrix.rid }}' - $projectDir = Join-Path $PWD 'Volatility' + $projectDir = Join-Path $PWD 'src/Volatility.Cli' $toolsDir = Join-Path $projectDir 'tools' $dxcDir = Join-Path $toolsDir 'dxc' New-Item -ItemType Directory -Force -Path $dxcDir | Out-Null @@ -109,7 +109,7 @@ jobs: - name: Publish for ${{ matrix.rid }} shell: pwsh - run: dotnet publish --configuration Release --runtime ${{ matrix.rid }} --self-contained true -p:ApplicationIcon=volatility_icon.ico --output ${{ matrix.output }} Volatility/Volatility.csproj + run: dotnet publish --configuration Release --runtime ${{ matrix.rid }} --self-contained true -p:ApplicationIcon=src/Volatility.Cli/volatility_icon.ico --output ${{ matrix.output }} src/Volatility.Cli/Volatility.Cli.csproj - name: Zip artifact shell: pwsh @@ -138,13 +138,13 @@ jobs: dotnet-version: '9.x' - name: Restore dependencies - run: dotnet restore Volatility/Volatility.csproj + run: dotnet restore src/Volatility.Cli/Volatility.Cli.csproj - name: Publish macOS x64 - run: dotnet publish --configuration Release --runtime osx-x64 --self-contained true -p:ApplicationIcon=volatility_icon.ico --output ./volatility_macos-x64_release Volatility/Volatility.csproj + run: dotnet publish --configuration Release --runtime osx-x64 --self-contained true -p:ApplicationIcon=src/Volatility.Cli/volatility_icon.ico --output ./volatility_macos-x64_release src/Volatility.Cli/Volatility.Cli.csproj - name: Publish macOS arm64 - run: dotnet publish --configuration Release --runtime osx-arm64 --self-contained true -p:ApplicationIcon=volatility_icon.ico --output ./volatility_macos-arm64_release Volatility/Volatility.csproj + run: dotnet publish --configuration Release --runtime osx-arm64 --self-contained true -p:ApplicationIcon=src/Volatility.Cli/volatility_icon.ico --output ./volatility_macos-arm64_release src/Volatility.Cli/Volatility.Cli.csproj - name: Zip macOS universal bundle run: zip -r Volatility_macos-universal_Release.zip volatility_macos-x64_release volatility_macos-arm64_release diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..641fc83 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,552 @@ +# AGENTS.md + +This file provides guidance to agents when working with code in this repository. + +## Project Overview + +Volatility is a platform-agnostic interface for *Burnout Paradise* resource files (textures, renderables, environment data, GUI popups, shaders, etc.). It imports binary resources from any supported platform (TUB/PC, BPR, X360, PS3) into a standardized YAML representation and exports YAML back to any target platform's binary format. + +The codebase is split into three main projects: +- `src/Volatility.Core`: A .NET 9.0 class library implementing the backend resource model, binary serialization, operations layer, and DI services. +- `src/Volatility.Cli`: A .NET 9.0 console project containing the CLI command routing and dependency injection setup. +- `tests/Volatility.Core.Tests`: An xUnit integration test project. + +The submodule `tools/libbndl-extractor` is required for the game-path autotest workflow — it pins `Bo98/libbndl` as a nested submodule, so clones must use `git submodule update --init --recursive`. + +## Build / Run + +```bash +dotnet build Volatility.sln +dotnet run --project src/Volatility.Cli/Volatility.Cli.csproj # interactive REPL +dotnet run --project src/Volatility.Cli/Volatility.Cli.csproj -- # one-shot +``` + +Release publish mirrors CI (`.github/workflows/dotnet.yml`): +```bash +dotnet publish --configuration Release --runtime win-x64 --self-contained true src/Volatility.Cli/Volatility.Cli.csproj +``` +The CLI csproj sets `PublishSingleFile`, `PublishTrimmed`, and roots `Volatility.Core` to ensure safe reflection. It also copies `tools/dxc/**` to the output — the DXC tree must be present for shader operations after publish. + +## Testing + +Correctness is verified by: + +1. **xUnit Tests** (`dotnet test Volatility.sln`): Unit and integration tests in `tests/Volatility.Core.Tests/` validating operation round-trips, DDS conversions, and other core behaviors. +2. **Built-in Autotest Command** (`autotest`): The command has two modes: + +1. **Synthetic / path mode** (`autotest --format= [--path=]`): constructs or loads a texture header, writes it out, re-imports the result, and reflects over every public property/field to compare exported vs. re-imported values. Mismatches print in red. +2. **Game mode** (`autotest --game=` or `--games=a|b|c`): drives [GameAutotestOperation.cs](Volatility/Operations/Autotest/GameAutotestOperation.cs) — extracts real bundles via `tools/libbndl-extractor` (or YAP with `--bundletool=YAP`), runs import/export per supported `ResourceType`, and for the types in `RoundTripTypes` performs **exact binary parity** checks against the original bundle-extracted files. Useful flags: `--resourcelimit`, `--bundlelimit`, `--keepartifacts`, `--recap=` (writes a markdown recap). + +When changing a resource's read/write path, the game-mode autotest on a real Burnout install is the authoritative parity check — synthetic mode only exercises textures. A green synthetic run is not evidence that non-texture types still round-trip. + +## Architecture + +### Command dispatch ([Program.cs](src/Volatility.Cli/Program.cs)) +`Main` either enters a REPL or runs one tokenized command. The command registry is the static dictionary `Frontend.Commands` mapping lowercase name → `ICommand` type; commands are instantiated via `Activator.CreateInstance`. Args are parsed as `--key=value` or bare `--flag` (defaulted to `true`) into a `Dictionary` passed to `ICommand.SetArgs`. Every command implements static `CommandToken`/`CommandDescription`/`CommandParameters` used by `HelpCommand` via reflection. **Adding a new command requires registering it in `Frontend.Commands` — there is no auto-discovery.** + +### Resource model ([Resources/](Volatility/Resources/)) +`Resource` (abstract base) → per-type abstract class (e.g. `TextureBase`, `RenderableBase`) → per-platform concrete class (e.g. `TexturePC`, `TextureBPR`, `TextureX360`, `TexturePS3`). Concrete classes override `ResourceEndian`, `ResourcePlatform`, and implement `ParseFromStream`/`WriteToStream`. Many also implement `PushAll`/`PullAll` to sync between a platform-specific struct and the portable fields inherited from the base. + +Two attributes drive discovery and construction: +- `[ResourceDefinition(ResourceType.Foo)]` on the base class (or any class in the hierarchy) — maps the class to a `ResourceType` enum. Read via [ResourceMetadata.cs](Volatility/Resources/ResourceMetadata.cs). +- `[ResourceRegistration(RegistrationPlatforms.X, EndianMapped = true, PullAll = true)]` on concrete classes — specifies which platforms that class serves. `EndianMapped = true` makes [ResourceFactory](Volatility/Resources/ResourceFactory.cs) pick the `(string, Endian)` constructor and pass the platform's default endianness (BE for X360/PS3, LE for TUB/BPR). `PullAll = true` auto-invokes `PullAll()` after construction. + +[ResourceFactory](Volatility/Resources/ResourceFactory.cs) builds a `(ResourceType, Platform) → Func` map at startup by reading those attributes via reflection. **Every new resource class must be added to `AddRegisteredResource<>` calls in `CreateResourceCreators()`** — there is no assembly scan. The two registration sites (attribute + factory list) must stay in sync. + +The `Arch` enum (x32/x64) distinguishes 32-bit pointer layouts (all original releases, most BPR) from 64-bit console BPR. Import commands accept suffixes like `bprx64`. Write pointer-width fields via `ResourceBinaryWriter.WritePointer(value, ResourceArch)` — the same applies to any struct whose size depends on `Arch`. Don't hardcode 4-byte pointer writes. + +### Endianness ([Endian.cs](Volatility/Endian.cs), [Utilities/EndianAware*](Volatility/Utilities/)) +Readers and writers are endian-aware. `Endian.Agnostic` means "follow the caller's intent"; concrete resources override `ResourceEndian` when the platform forces BE/LE. `EndianMapping.GetDefaultEndian(Platform)` is the canonical platform→endian lookup. Never swap bytes manually — use `EndianAwareBinaryReader`/`Writer` or `ResourceBinaryReader`/`Writer`. + +### Operations layer ([Operations/](Volatility/Operations/)) +CLI command classes are thin: they parse args and delegate to `Operations/` classes (e.g. `ImportResourceCommand` → `ImportResourceOperation`). Place real import/export/port logic in an `Operation` class, not in the CLI class. + +### YAML serialization ([Utilities/YAML/](Volatility/Utilities/YAML/)) +YamlDotNet is used with custom type inspectors/converters so that fields (not just properties) and `StrongID`/`ResourceID`/`BitArray` types round-trip correctly. The editor attributes in [Attributes/](Volatility/Attributes/) (`EditorCategory`, `EditorLabel`, `EditorTooltip`, `EditorHidden`, `EditorReadOnly`) are metadata-only today — they're consumed by the YAML layer and reserved for a future GUI. New public fields/properties that should appear in the YAML surface get `EditorCategory`/`Label`/`Tooltip`; derived or runtime-only members get `EditorHidden`/`EditorReadOnly` rather than being silently serialized. + +### Runtime layout +At runtime, `EnvironmentUtilities.GetEnvironmentDirectory(...)` resolves paths relative to the executable: +- `tools/` — `dxc/` (shader compiler, copied from `Volatility/tools/dxc/`) and `libbndl-extractor/` (submodule; only needed for `autotest --game=...`) +- `data/ResourceDB/` — resource-ID → asset-name lookup used during import +- `data/Resources/` — default output destination for imported YAML +- `data/Splicer/` — Splicer resource data + +`WorkspaceUtilities.FindRepositoryRoot()` walks up from CWD or the executable until it finds `Volatility.sln`; the autotest uses this to locate `tools/libbndl-extractor`. Don't hardcode path strings — resolve runtime paths through `GetEnvironmentDirectory`, and any repo-root lookup through `FindRepositoryRoot()`. + +## Agent behavior + +This project is actively being matured. + +Do not treat the existing implementation as inherently correct, stable, or worth preserving. + +Treat internal compatibility as a liability unless compatibility is explicitly declared as a requirement. + +Prefer structural simplification over preserving old code paths. + +Do not preserve: + +- legacy code paths +- compatibility shims +- duplicated flows +- obsolete APIs +- old state models +- workaround behavior +- fallback behavior that hides the primary failure + +Assume internal backward compatibility is not required. + +Compatibility is required only for: + +- public APIs explicitly marked stable +- persisted user data +- database migrations +- external integrations +- documented plugin interfaces +- binary format compatibility required for import/export correctness +- behavior the user explicitly says must remain supported + +Everything else may be changed, renamed, removed, or consolidated when doing so improves the structure. + +## Debugging and fixes + +For bugs, regressions, broken behavior, failing tests, broken import/export paths, or unclear implementation requests: + +1. Diagnose before editing. +2. Trace the relevant execution path before proposing a fix. +3. Identify the root cause, not just the nearest failing symptom. +4. Identify whether the current path is legacy, transitional, duplicated, or structurally wrong. +5. Present options before implementation when the change affects architecture, binary layout, state flow, resource parsing, serialization, command behavior, platform behavior, or shared utilities. +6. Do not write code until the chosen approach is clear. + +Avoid: + +- adding fallback logic without proving why the primary path fails +- adding duplicate state to mask synchronization problems +- weakening tests or parity checks to match broken behavior +- adding special cases before checking the general path +- broad refactors unrelated to the root cause +- adapters, bridges, fallbacks, feature-detection branches, or dual implementations unless there is a stated migration need +- preserving old code solely because existing callers still use it internally + +Before coding, provide: + +- root cause +- evidence +- affected files/functions +- relevant execution path +- legacy, duplicated, or obsolete paths involved +- what should become the new canonical path +- what should be removed +- what breakage is acceptable +- compatibility requirements, if any +- fix/refactor options +- recommended approach +- verification plan + +After coding, provide: + +- changed files +- reason for each change +- why this fixes the root cause +- legacy paths removed +- duplicate logic eliminated +- whether the new canonical path is used consistently +- tests/checks performed + +## Codebase maturation policy + +When solving an issue: + +1. Identify whether the existing path is legacy, transitional, duplicated, or structurally wrong. +2. If a cleaner replacement exists, remove the old path instead of adapting around it. +3. Do not maintain backward compatibility for internal APIs, components, data shapes, commands, utilities, resource classes, or state flows unless the task explicitly says compatibility is required. +4. Do not add adapters, bridges, fallbacks, feature-detection branches, or dual implementations unless there is a stated migration need. +5. Prefer one canonical path. +6. Update all call sites to the canonical path. +7. Delete unused compatibility helpers. +8. Update tests, autotest expectations, docs, and command help to assert the new intended behavior, not legacy behavior. + +## Investigation-only mode + +When asked to investigate, diagnose, audit, plan, or review: + +- Do not modify files. +- Do not write code. +- Trace the relevant execution path. +- Identify the actual source of the behavior. +- List the files/functions involved and why each matters. +- Present 2-4 possible fixes. +- For each fix, explain: + - what it changes structurally + - what risk it introduces + - whether it is a local patch or architectural fix + - whether it preserves or removes legacy behavior + - how to verify it +- Recommend one option. +- If the root cause is uncertain, say so and list what evidence is missing. + +## Implementation mode + +When implementing an approved plan: + +- Implement only the selected option. +- Keep the diff focused. +- Do not preserve broken structure just to reduce the diff. +- Remove obsolete paths instead of supporting both. +- Update all call sites to the canonical path. +- Do not add guards, retries, fallbacks, duplicate state, or special cases unless they address the root cause. +- Do not make tests pass by weakening the test or adapting around the failure. +- Update tests and documentation to match the new intended behavior. + +After implementation, report: + +1. changed files +2. why each change was necessary +3. how this addresses the root cause +4. what legacy behavior was removed +5. what tests or manual checks verify it + +## Working style and hygiene + +Surface tradeoffs and ask when requirements are ambiguous. + +Do not silently pick one interpretation when the choice affects binary compatibility, resource format behavior, architecture, or public command behavior. + +Ship the minimum code that solves the real problem. + +Minimum code does not mean preserving bad structure. If a slightly larger change removes a bad path and creates a better canonical path, prefer the structural fix. + +No unrequested configurability. + +No error handling for impossible scenarios. + +Do not restyle unrelated code. + +Do not perform broad refactors unrelated to the request. + +Do not delete unrelated dead code silently. If nearby obsolete code should be removed but is outside the current scope, mention it. + +Match surrounding style. + +Do not add comments unless the surrounding code already uses comments for the same kind of logic, or the logic is format-specific and non-obvious. + +## Cross-cutting hygiene checks + +After a change, check the following: + +- Does a local helper duplicate existing boilerplate in `BitReader`, `ResourceBinaryReader`, `ResourceBinaryWriter`, `EndianUtilities`, `ResourceIDUtilities`, `TypeUtilities`, or the YAML helpers? If so, use the existing boilerplate. +- If the helper is a good generalization, should it be promoted to a shared utility instead of staying local? +- Do new struct read/write paths follow the same shape as `EnvironmentKeyframe`? +- If a struct read/write path deviates from the common shape, is the structure genuinely too specialized for that form? +- Did this change touch code that predates the current boilerplate and could now be simplified by adopting it? +- Are any introduced constants referenced from only one site? +- Do any introduced constants encode a value already named elsewhere? +- Did the change introduce a second path where one canonical path would be better? +- Did the change preserve legacy behavior without an explicit reason? +- Did the change hide a parsing, writing, endian, pointer-width, or serialization failure behind a fallback? + +## Registration and layering reminders + +These systems have no compile-time enforcement and are easy to miss: + +- New commands must be wired into `Frontend.Commands`. +- New resource classes need both attributes and `ResourceFactory` registration. +- Pointer-width fields must use `WritePointer(..., ResourceArch)` or equivalent architecture-aware logic. +- Endian I/O must use endian-aware readers/writers. +- CLI classes should stay thin and delegate real work to `Operations/`. +- YAML-visible members need the appropriate editor attributes. +- Runtime paths should go through `EnvironmentUtilities`. +- Repository-root lookup should go through `WorkspaceUtilities.FindRepositoryRoot()`. + +## Binary format discipline + +Binary compatibility with the original game formats matters. + +Do not confuse internal code compatibility with file-format compatibility. + +Internal APIs may be changed aggressively when that improves the codebase. + +Binary import/export behavior must remain correct for supported platforms unless the user explicitly asks to change support. + +When modifying binary parsing or writing: + +- account for platform +- account for endian +- account for pointer width +- account for alignment/padding +- account for versioned layouts +- account for resource-specific struct sizes +- verify with the strongest available autotest mode + +Do not add a parser fallback merely because a file fails to parse. + +First determine whether the failure is caused by: + +- wrong platform detection +- wrong endian +- wrong architecture +- wrong struct size +- wrong offset +- wrong count +- wrong version assumption +- wrong resource type +- damaged or unsupported input + +## Preferred fix shape + +Prefer this sequence: + +1. understand the format or code path +2. identify the root cause +3. choose the canonical model +4. remove or replace the wrong path +5. update all callers +6. verify round-trip behavior +7. delete obsolete helpers or compatibility branches + +Avoid this sequence: + +1. observe failure +2. add guard +3. add fallback +4. preserve old path +5. make output appear valid without proving correctness + +## Agent behavior + +This project is actively being matured. + +Do not treat the existing implementation as inherently correct, stable, or worth preserving. + +Treat internal compatibility as a liability unless compatibility is explicitly declared as a requirement. + +Prefer structural simplification over preserving old code paths. + +Do not preserve: + +- legacy code paths +- compatibility shims +- duplicated flows +- obsolete APIs +- old state models +- workaround behavior +- fallback behavior that hides the primary failure + +Assume internal backward compatibility is not required. + +Compatibility is required only for: + +- public APIs explicitly marked stable +- persisted user data +- database migrations +- external integrations +- documented plugin interfaces +- binary format compatibility required for import/export correctness +- behavior the user explicitly says must remain supported + +Everything else may be changed, renamed, removed, or consolidated when doing so improves the structure. + +## Debugging and fixes + +For bugs, regressions, broken behavior, failing tests, broken import/export paths, or unclear implementation requests: + +1. Diagnose before editing. +2. Trace the relevant execution path before proposing a fix. +3. Identify the root cause, not just the nearest failing symptom. +4. Identify whether the current path is legacy, transitional, duplicated, or structurally wrong. +5. Present options before implementation when the change affects architecture, binary layout, state flow, resource parsing, serialization, command behavior, platform behavior, or shared utilities. +6. Do not write code until the chosen approach is clear. + +Avoid: + +- adding fallback logic without proving why the primary path fails +- adding duplicate state to mask synchronization problems +- weakening tests or parity checks to match broken behavior +- adding special cases before checking the general path +- broad refactors unrelated to the root cause +- adapters, bridges, fallbacks, feature-detection branches, or dual implementations unless there is a stated migration need +- preserving old code solely because existing callers still use it internally + +Before coding, provide: + +- root cause +- evidence +- affected files/functions +- relevant execution path +- legacy, duplicated, or obsolete paths involved +- what should become the new canonical path +- what should be removed +- what breakage is acceptable +- compatibility requirements, if any +- fix/refactor options +- recommended approach +- verification plan + +After coding, provide: + +- changed files +- reason for each change +- why this fixes the root cause +- legacy paths removed +- duplicate logic eliminated +- whether the new canonical path is used consistently +- tests/checks performed + +## Codebase maturation policy + +When solving an issue: + +1. Identify whether the existing path is legacy, transitional, duplicated, or structurally wrong. +2. If a cleaner replacement exists, remove the old path instead of adapting around it. +3. Do not maintain backward compatibility for internal APIs, components, data shapes, commands, utilities, resource classes, or state flows unless the task explicitly says compatibility is required. +4. Do not add adapters, bridges, fallbacks, feature-detection branches, or dual implementations unless there is a stated migration need. +5. Prefer one canonical path. +6. Update all call sites to the canonical path. +7. Delete unused compatibility helpers. +8. Update tests, autotest expectations, docs, and command help to assert the new intended behavior, not legacy behavior. + +## Investigation-only mode + +When asked to investigate, diagnose, audit, plan, or review: + +- Do not modify files. +- Do not write code. +- Trace the relevant execution path. +- Identify the actual source of the behavior. +- List the files/functions involved and why each matters. +- Present 2-4 possible fixes. +- For each fix, explain: + - what it changes structurally + - what risk it introduces + - whether it is a local patch or architectural fix + - whether it preserves or removes legacy behavior + - how to verify it +- Recommend one option. +- If the root cause is uncertain, say so and list what evidence is missing. + +## Implementation mode + +When implementing an approved plan: + +- Implement only the selected option. +- Keep the diff focused. +- Do not preserve broken structure just to reduce the diff. +- Remove obsolete paths instead of supporting both. +- Update all call sites to the canonical path. +- Do not add guards, retries, fallbacks, duplicate state, or special cases unless they address the root cause. +- Do not make tests pass by weakening the test or adapting around the failure. +- Update tests and documentation to match the new intended behavior. + +After implementation, report: + +1. changed files +2. why each change was necessary +3. how this addresses the root cause +4. what legacy behavior was removed +5. what tests or manual checks verify it + +## Working style and hygiene + +Surface tradeoffs and ask when requirements are ambiguous. + +Do not silently pick one interpretation when the choice affects binary compatibility, resource format behavior, architecture, or public command behavior. + +Ship the minimum code that solves the real problem. + +Minimum code does not mean preserving bad structure. If a slightly larger change removes a bad path and creates a better canonical path, prefer the structural fix. + +No unrequested configurability. + +No error handling for impossible scenarios. + +Do not restyle unrelated code. + +Do not perform broad refactors unrelated to the request. + +Do not delete unrelated dead code silently. If nearby obsolete code should be removed but is outside the current scope, mention it. + +Match surrounding style. + +Do not add comments unless the surrounding code already uses comments for the same kind of logic, or the logic is format-specific and non-obvious. + +## Cross-cutting hygiene checks + +After a change, check the following: + +- Does a local helper duplicate existing boilerplate in `BitReader`, `ResourceBinaryReader`, `ResourceBinaryWriter`, `EndianUtilities`, `ResourceIDUtilities`, `TypeUtilities`, or the YAML helpers? If so, use the existing boilerplate. +- If the helper is a good generalization, should it be promoted to a shared utility instead of staying local? +- Do new struct read/write paths follow the same shape as `EnvironmentKeyframe`? +- If a struct read/write path deviates from the common shape, is the structure genuinely too specialized for that form? +- Did this change touch code that predates the current boilerplate and could now be simplified by adopting it? +- Are any introduced constants referenced from only one site? +- Do any introduced constants encode a value already named elsewhere? +- Did the change introduce a second path where one canonical path would be better? +- Did the change preserve legacy behavior without an explicit reason? +- Did the change hide a parsing, writing, endian, pointer-width, or serialization failure behind a fallback? + +## Registration and layering reminders + +These systems have no compile-time enforcement and are easy to miss: + +- New commands must be wired into `Frontend.Commands`. +- New resource classes need both attributes and `ResourceFactory` registration. +- Pointer-width fields must use `WritePointer(..., ResourceArch)` or equivalent architecture-aware logic. +- Endian I/O must use endian-aware readers/writers. +- CLI classes should stay thin and delegate real work to `Operations/`. +- YAML-visible members need the appropriate editor attributes. +- Runtime paths should go through `EnvironmentUtilities`. +- Repository-root lookup should go through `WorkspaceUtilities.FindRepositoryRoot()`. + +## Binary format discipline + +Binary compatibility with the original game formats matters. + +Do not confuse internal code compatibility with file-format compatibility. + +Internal APIs may be changed aggressively when that improves the codebase. + +Binary import/export behavior must remain correct for supported platforms unless the user explicitly asks to change support. + +When modifying binary parsing or writing: + +- account for platform +- account for endian +- account for pointer width +- account for alignment/padding +- account for versioned layouts +- account for resource-specific struct sizes +- verify with the strongest available autotest mode + +Do not add a parser fallback merely because a file fails to parse. + +First determine whether the failure is caused by: + +- wrong platform detection +- wrong endian +- wrong architecture +- wrong struct size +- wrong offset +- wrong count +- wrong version assumption +- wrong resource type +- damaged or unsupported input + +## Preferred fix shape + +Prefer this sequence: + +1. understand the format or code path +2. identify the root cause +3. choose the canonical model +4. remove or replace the wrong path +5. update all callers +6. verify round-trip behavior +7. delete obsolete helpers or compatibility branches + +Avoid this sequence: + +1. observe failure +2. add guard +3. add fallback +4. preserve old path +5. make output appear valid without proving correctness diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..b6c82f2 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + latest + enable + enable + + diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..641fc83 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,552 @@ +# AGENTS.md + +This file provides guidance to agents when working with code in this repository. + +## Project Overview + +Volatility is a platform-agnostic interface for *Burnout Paradise* resource files (textures, renderables, environment data, GUI popups, shaders, etc.). It imports binary resources from any supported platform (TUB/PC, BPR, X360, PS3) into a standardized YAML representation and exports YAML back to any target platform's binary format. + +The codebase is split into three main projects: +- `src/Volatility.Core`: A .NET 9.0 class library implementing the backend resource model, binary serialization, operations layer, and DI services. +- `src/Volatility.Cli`: A .NET 9.0 console project containing the CLI command routing and dependency injection setup. +- `tests/Volatility.Core.Tests`: An xUnit integration test project. + +The submodule `tools/libbndl-extractor` is required for the game-path autotest workflow — it pins `Bo98/libbndl` as a nested submodule, so clones must use `git submodule update --init --recursive`. + +## Build / Run + +```bash +dotnet build Volatility.sln +dotnet run --project src/Volatility.Cli/Volatility.Cli.csproj # interactive REPL +dotnet run --project src/Volatility.Cli/Volatility.Cli.csproj -- # one-shot +``` + +Release publish mirrors CI (`.github/workflows/dotnet.yml`): +```bash +dotnet publish --configuration Release --runtime win-x64 --self-contained true src/Volatility.Cli/Volatility.Cli.csproj +``` +The CLI csproj sets `PublishSingleFile`, `PublishTrimmed`, and roots `Volatility.Core` to ensure safe reflection. It also copies `tools/dxc/**` to the output — the DXC tree must be present for shader operations after publish. + +## Testing + +Correctness is verified by: + +1. **xUnit Tests** (`dotnet test Volatility.sln`): Unit and integration tests in `tests/Volatility.Core.Tests/` validating operation round-trips, DDS conversions, and other core behaviors. +2. **Built-in Autotest Command** (`autotest`): The command has two modes: + +1. **Synthetic / path mode** (`autotest --format= [--path=]`): constructs or loads a texture header, writes it out, re-imports the result, and reflects over every public property/field to compare exported vs. re-imported values. Mismatches print in red. +2. **Game mode** (`autotest --game=` or `--games=a|b|c`): drives [GameAutotestOperation.cs](Volatility/Operations/Autotest/GameAutotestOperation.cs) — extracts real bundles via `tools/libbndl-extractor` (or YAP with `--bundletool=YAP`), runs import/export per supported `ResourceType`, and for the types in `RoundTripTypes` performs **exact binary parity** checks against the original bundle-extracted files. Useful flags: `--resourcelimit`, `--bundlelimit`, `--keepartifacts`, `--recap=` (writes a markdown recap). + +When changing a resource's read/write path, the game-mode autotest on a real Burnout install is the authoritative parity check — synthetic mode only exercises textures. A green synthetic run is not evidence that non-texture types still round-trip. + +## Architecture + +### Command dispatch ([Program.cs](src/Volatility.Cli/Program.cs)) +`Main` either enters a REPL or runs one tokenized command. The command registry is the static dictionary `Frontend.Commands` mapping lowercase name → `ICommand` type; commands are instantiated via `Activator.CreateInstance`. Args are parsed as `--key=value` or bare `--flag` (defaulted to `true`) into a `Dictionary` passed to `ICommand.SetArgs`. Every command implements static `CommandToken`/`CommandDescription`/`CommandParameters` used by `HelpCommand` via reflection. **Adding a new command requires registering it in `Frontend.Commands` — there is no auto-discovery.** + +### Resource model ([Resources/](Volatility/Resources/)) +`Resource` (abstract base) → per-type abstract class (e.g. `TextureBase`, `RenderableBase`) → per-platform concrete class (e.g. `TexturePC`, `TextureBPR`, `TextureX360`, `TexturePS3`). Concrete classes override `ResourceEndian`, `ResourcePlatform`, and implement `ParseFromStream`/`WriteToStream`. Many also implement `PushAll`/`PullAll` to sync between a platform-specific struct and the portable fields inherited from the base. + +Two attributes drive discovery and construction: +- `[ResourceDefinition(ResourceType.Foo)]` on the base class (or any class in the hierarchy) — maps the class to a `ResourceType` enum. Read via [ResourceMetadata.cs](Volatility/Resources/ResourceMetadata.cs). +- `[ResourceRegistration(RegistrationPlatforms.X, EndianMapped = true, PullAll = true)]` on concrete classes — specifies which platforms that class serves. `EndianMapped = true` makes [ResourceFactory](Volatility/Resources/ResourceFactory.cs) pick the `(string, Endian)` constructor and pass the platform's default endianness (BE for X360/PS3, LE for TUB/BPR). `PullAll = true` auto-invokes `PullAll()` after construction. + +[ResourceFactory](Volatility/Resources/ResourceFactory.cs) builds a `(ResourceType, Platform) → Func` map at startup by reading those attributes via reflection. **Every new resource class must be added to `AddRegisteredResource<>` calls in `CreateResourceCreators()`** — there is no assembly scan. The two registration sites (attribute + factory list) must stay in sync. + +The `Arch` enum (x32/x64) distinguishes 32-bit pointer layouts (all original releases, most BPR) from 64-bit console BPR. Import commands accept suffixes like `bprx64`. Write pointer-width fields via `ResourceBinaryWriter.WritePointer(value, ResourceArch)` — the same applies to any struct whose size depends on `Arch`. Don't hardcode 4-byte pointer writes. + +### Endianness ([Endian.cs](Volatility/Endian.cs), [Utilities/EndianAware*](Volatility/Utilities/)) +Readers and writers are endian-aware. `Endian.Agnostic` means "follow the caller's intent"; concrete resources override `ResourceEndian` when the platform forces BE/LE. `EndianMapping.GetDefaultEndian(Platform)` is the canonical platform→endian lookup. Never swap bytes manually — use `EndianAwareBinaryReader`/`Writer` or `ResourceBinaryReader`/`Writer`. + +### Operations layer ([Operations/](Volatility/Operations/)) +CLI command classes are thin: they parse args and delegate to `Operations/` classes (e.g. `ImportResourceCommand` → `ImportResourceOperation`). Place real import/export/port logic in an `Operation` class, not in the CLI class. + +### YAML serialization ([Utilities/YAML/](Volatility/Utilities/YAML/)) +YamlDotNet is used with custom type inspectors/converters so that fields (not just properties) and `StrongID`/`ResourceID`/`BitArray` types round-trip correctly. The editor attributes in [Attributes/](Volatility/Attributes/) (`EditorCategory`, `EditorLabel`, `EditorTooltip`, `EditorHidden`, `EditorReadOnly`) are metadata-only today — they're consumed by the YAML layer and reserved for a future GUI. New public fields/properties that should appear in the YAML surface get `EditorCategory`/`Label`/`Tooltip`; derived or runtime-only members get `EditorHidden`/`EditorReadOnly` rather than being silently serialized. + +### Runtime layout +At runtime, `EnvironmentUtilities.GetEnvironmentDirectory(...)` resolves paths relative to the executable: +- `tools/` — `dxc/` (shader compiler, copied from `Volatility/tools/dxc/`) and `libbndl-extractor/` (submodule; only needed for `autotest --game=...`) +- `data/ResourceDB/` — resource-ID → asset-name lookup used during import +- `data/Resources/` — default output destination for imported YAML +- `data/Splicer/` — Splicer resource data + +`WorkspaceUtilities.FindRepositoryRoot()` walks up from CWD or the executable until it finds `Volatility.sln`; the autotest uses this to locate `tools/libbndl-extractor`. Don't hardcode path strings — resolve runtime paths through `GetEnvironmentDirectory`, and any repo-root lookup through `FindRepositoryRoot()`. + +## Agent behavior + +This project is actively being matured. + +Do not treat the existing implementation as inherently correct, stable, or worth preserving. + +Treat internal compatibility as a liability unless compatibility is explicitly declared as a requirement. + +Prefer structural simplification over preserving old code paths. + +Do not preserve: + +- legacy code paths +- compatibility shims +- duplicated flows +- obsolete APIs +- old state models +- workaround behavior +- fallback behavior that hides the primary failure + +Assume internal backward compatibility is not required. + +Compatibility is required only for: + +- public APIs explicitly marked stable +- persisted user data +- database migrations +- external integrations +- documented plugin interfaces +- binary format compatibility required for import/export correctness +- behavior the user explicitly says must remain supported + +Everything else may be changed, renamed, removed, or consolidated when doing so improves the structure. + +## Debugging and fixes + +For bugs, regressions, broken behavior, failing tests, broken import/export paths, or unclear implementation requests: + +1. Diagnose before editing. +2. Trace the relevant execution path before proposing a fix. +3. Identify the root cause, not just the nearest failing symptom. +4. Identify whether the current path is legacy, transitional, duplicated, or structurally wrong. +5. Present options before implementation when the change affects architecture, binary layout, state flow, resource parsing, serialization, command behavior, platform behavior, or shared utilities. +6. Do not write code until the chosen approach is clear. + +Avoid: + +- adding fallback logic without proving why the primary path fails +- adding duplicate state to mask synchronization problems +- weakening tests or parity checks to match broken behavior +- adding special cases before checking the general path +- broad refactors unrelated to the root cause +- adapters, bridges, fallbacks, feature-detection branches, or dual implementations unless there is a stated migration need +- preserving old code solely because existing callers still use it internally + +Before coding, provide: + +- root cause +- evidence +- affected files/functions +- relevant execution path +- legacy, duplicated, or obsolete paths involved +- what should become the new canonical path +- what should be removed +- what breakage is acceptable +- compatibility requirements, if any +- fix/refactor options +- recommended approach +- verification plan + +After coding, provide: + +- changed files +- reason for each change +- why this fixes the root cause +- legacy paths removed +- duplicate logic eliminated +- whether the new canonical path is used consistently +- tests/checks performed + +## Codebase maturation policy + +When solving an issue: + +1. Identify whether the existing path is legacy, transitional, duplicated, or structurally wrong. +2. If a cleaner replacement exists, remove the old path instead of adapting around it. +3. Do not maintain backward compatibility for internal APIs, components, data shapes, commands, utilities, resource classes, or state flows unless the task explicitly says compatibility is required. +4. Do not add adapters, bridges, fallbacks, feature-detection branches, or dual implementations unless there is a stated migration need. +5. Prefer one canonical path. +6. Update all call sites to the canonical path. +7. Delete unused compatibility helpers. +8. Update tests, autotest expectations, docs, and command help to assert the new intended behavior, not legacy behavior. + +## Investigation-only mode + +When asked to investigate, diagnose, audit, plan, or review: + +- Do not modify files. +- Do not write code. +- Trace the relevant execution path. +- Identify the actual source of the behavior. +- List the files/functions involved and why each matters. +- Present 2-4 possible fixes. +- For each fix, explain: + - what it changes structurally + - what risk it introduces + - whether it is a local patch or architectural fix + - whether it preserves or removes legacy behavior + - how to verify it +- Recommend one option. +- If the root cause is uncertain, say so and list what evidence is missing. + +## Implementation mode + +When implementing an approved plan: + +- Implement only the selected option. +- Keep the diff focused. +- Do not preserve broken structure just to reduce the diff. +- Remove obsolete paths instead of supporting both. +- Update all call sites to the canonical path. +- Do not add guards, retries, fallbacks, duplicate state, or special cases unless they address the root cause. +- Do not make tests pass by weakening the test or adapting around the failure. +- Update tests and documentation to match the new intended behavior. + +After implementation, report: + +1. changed files +2. why each change was necessary +3. how this addresses the root cause +4. what legacy behavior was removed +5. what tests or manual checks verify it + +## Working style and hygiene + +Surface tradeoffs and ask when requirements are ambiguous. + +Do not silently pick one interpretation when the choice affects binary compatibility, resource format behavior, architecture, or public command behavior. + +Ship the minimum code that solves the real problem. + +Minimum code does not mean preserving bad structure. If a slightly larger change removes a bad path and creates a better canonical path, prefer the structural fix. + +No unrequested configurability. + +No error handling for impossible scenarios. + +Do not restyle unrelated code. + +Do not perform broad refactors unrelated to the request. + +Do not delete unrelated dead code silently. If nearby obsolete code should be removed but is outside the current scope, mention it. + +Match surrounding style. + +Do not add comments unless the surrounding code already uses comments for the same kind of logic, or the logic is format-specific and non-obvious. + +## Cross-cutting hygiene checks + +After a change, check the following: + +- Does a local helper duplicate existing boilerplate in `BitReader`, `ResourceBinaryReader`, `ResourceBinaryWriter`, `EndianUtilities`, `ResourceIDUtilities`, `TypeUtilities`, or the YAML helpers? If so, use the existing boilerplate. +- If the helper is a good generalization, should it be promoted to a shared utility instead of staying local? +- Do new struct read/write paths follow the same shape as `EnvironmentKeyframe`? +- If a struct read/write path deviates from the common shape, is the structure genuinely too specialized for that form? +- Did this change touch code that predates the current boilerplate and could now be simplified by adopting it? +- Are any introduced constants referenced from only one site? +- Do any introduced constants encode a value already named elsewhere? +- Did the change introduce a second path where one canonical path would be better? +- Did the change preserve legacy behavior without an explicit reason? +- Did the change hide a parsing, writing, endian, pointer-width, or serialization failure behind a fallback? + +## Registration and layering reminders + +These systems have no compile-time enforcement and are easy to miss: + +- New commands must be wired into `Frontend.Commands`. +- New resource classes need both attributes and `ResourceFactory` registration. +- Pointer-width fields must use `WritePointer(..., ResourceArch)` or equivalent architecture-aware logic. +- Endian I/O must use endian-aware readers/writers. +- CLI classes should stay thin and delegate real work to `Operations/`. +- YAML-visible members need the appropriate editor attributes. +- Runtime paths should go through `EnvironmentUtilities`. +- Repository-root lookup should go through `WorkspaceUtilities.FindRepositoryRoot()`. + +## Binary format discipline + +Binary compatibility with the original game formats matters. + +Do not confuse internal code compatibility with file-format compatibility. + +Internal APIs may be changed aggressively when that improves the codebase. + +Binary import/export behavior must remain correct for supported platforms unless the user explicitly asks to change support. + +When modifying binary parsing or writing: + +- account for platform +- account for endian +- account for pointer width +- account for alignment/padding +- account for versioned layouts +- account for resource-specific struct sizes +- verify with the strongest available autotest mode + +Do not add a parser fallback merely because a file fails to parse. + +First determine whether the failure is caused by: + +- wrong platform detection +- wrong endian +- wrong architecture +- wrong struct size +- wrong offset +- wrong count +- wrong version assumption +- wrong resource type +- damaged or unsupported input + +## Preferred fix shape + +Prefer this sequence: + +1. understand the format or code path +2. identify the root cause +3. choose the canonical model +4. remove or replace the wrong path +5. update all callers +6. verify round-trip behavior +7. delete obsolete helpers or compatibility branches + +Avoid this sequence: + +1. observe failure +2. add guard +3. add fallback +4. preserve old path +5. make output appear valid without proving correctness + +## Agent behavior + +This project is actively being matured. + +Do not treat the existing implementation as inherently correct, stable, or worth preserving. + +Treat internal compatibility as a liability unless compatibility is explicitly declared as a requirement. + +Prefer structural simplification over preserving old code paths. + +Do not preserve: + +- legacy code paths +- compatibility shims +- duplicated flows +- obsolete APIs +- old state models +- workaround behavior +- fallback behavior that hides the primary failure + +Assume internal backward compatibility is not required. + +Compatibility is required only for: + +- public APIs explicitly marked stable +- persisted user data +- database migrations +- external integrations +- documented plugin interfaces +- binary format compatibility required for import/export correctness +- behavior the user explicitly says must remain supported + +Everything else may be changed, renamed, removed, or consolidated when doing so improves the structure. + +## Debugging and fixes + +For bugs, regressions, broken behavior, failing tests, broken import/export paths, or unclear implementation requests: + +1. Diagnose before editing. +2. Trace the relevant execution path before proposing a fix. +3. Identify the root cause, not just the nearest failing symptom. +4. Identify whether the current path is legacy, transitional, duplicated, or structurally wrong. +5. Present options before implementation when the change affects architecture, binary layout, state flow, resource parsing, serialization, command behavior, platform behavior, or shared utilities. +6. Do not write code until the chosen approach is clear. + +Avoid: + +- adding fallback logic without proving why the primary path fails +- adding duplicate state to mask synchronization problems +- weakening tests or parity checks to match broken behavior +- adding special cases before checking the general path +- broad refactors unrelated to the root cause +- adapters, bridges, fallbacks, feature-detection branches, or dual implementations unless there is a stated migration need +- preserving old code solely because existing callers still use it internally + +Before coding, provide: + +- root cause +- evidence +- affected files/functions +- relevant execution path +- legacy, duplicated, or obsolete paths involved +- what should become the new canonical path +- what should be removed +- what breakage is acceptable +- compatibility requirements, if any +- fix/refactor options +- recommended approach +- verification plan + +After coding, provide: + +- changed files +- reason for each change +- why this fixes the root cause +- legacy paths removed +- duplicate logic eliminated +- whether the new canonical path is used consistently +- tests/checks performed + +## Codebase maturation policy + +When solving an issue: + +1. Identify whether the existing path is legacy, transitional, duplicated, or structurally wrong. +2. If a cleaner replacement exists, remove the old path instead of adapting around it. +3. Do not maintain backward compatibility for internal APIs, components, data shapes, commands, utilities, resource classes, or state flows unless the task explicitly says compatibility is required. +4. Do not add adapters, bridges, fallbacks, feature-detection branches, or dual implementations unless there is a stated migration need. +5. Prefer one canonical path. +6. Update all call sites to the canonical path. +7. Delete unused compatibility helpers. +8. Update tests, autotest expectations, docs, and command help to assert the new intended behavior, not legacy behavior. + +## Investigation-only mode + +When asked to investigate, diagnose, audit, plan, or review: + +- Do not modify files. +- Do not write code. +- Trace the relevant execution path. +- Identify the actual source of the behavior. +- List the files/functions involved and why each matters. +- Present 2-4 possible fixes. +- For each fix, explain: + - what it changes structurally + - what risk it introduces + - whether it is a local patch or architectural fix + - whether it preserves or removes legacy behavior + - how to verify it +- Recommend one option. +- If the root cause is uncertain, say so and list what evidence is missing. + +## Implementation mode + +When implementing an approved plan: + +- Implement only the selected option. +- Keep the diff focused. +- Do not preserve broken structure just to reduce the diff. +- Remove obsolete paths instead of supporting both. +- Update all call sites to the canonical path. +- Do not add guards, retries, fallbacks, duplicate state, or special cases unless they address the root cause. +- Do not make tests pass by weakening the test or adapting around the failure. +- Update tests and documentation to match the new intended behavior. + +After implementation, report: + +1. changed files +2. why each change was necessary +3. how this addresses the root cause +4. what legacy behavior was removed +5. what tests or manual checks verify it + +## Working style and hygiene + +Surface tradeoffs and ask when requirements are ambiguous. + +Do not silently pick one interpretation when the choice affects binary compatibility, resource format behavior, architecture, or public command behavior. + +Ship the minimum code that solves the real problem. + +Minimum code does not mean preserving bad structure. If a slightly larger change removes a bad path and creates a better canonical path, prefer the structural fix. + +No unrequested configurability. + +No error handling for impossible scenarios. + +Do not restyle unrelated code. + +Do not perform broad refactors unrelated to the request. + +Do not delete unrelated dead code silently. If nearby obsolete code should be removed but is outside the current scope, mention it. + +Match surrounding style. + +Do not add comments unless the surrounding code already uses comments for the same kind of logic, or the logic is format-specific and non-obvious. + +## Cross-cutting hygiene checks + +After a change, check the following: + +- Does a local helper duplicate existing boilerplate in `BitReader`, `ResourceBinaryReader`, `ResourceBinaryWriter`, `EndianUtilities`, `ResourceIDUtilities`, `TypeUtilities`, or the YAML helpers? If so, use the existing boilerplate. +- If the helper is a good generalization, should it be promoted to a shared utility instead of staying local? +- Do new struct read/write paths follow the same shape as `EnvironmentKeyframe`? +- If a struct read/write path deviates from the common shape, is the structure genuinely too specialized for that form? +- Did this change touch code that predates the current boilerplate and could now be simplified by adopting it? +- Are any introduced constants referenced from only one site? +- Do any introduced constants encode a value already named elsewhere? +- Did the change introduce a second path where one canonical path would be better? +- Did the change preserve legacy behavior without an explicit reason? +- Did the change hide a parsing, writing, endian, pointer-width, or serialization failure behind a fallback? + +## Registration and layering reminders + +These systems have no compile-time enforcement and are easy to miss: + +- New commands must be wired into `Frontend.Commands`. +- New resource classes need both attributes and `ResourceFactory` registration. +- Pointer-width fields must use `WritePointer(..., ResourceArch)` or equivalent architecture-aware logic. +- Endian I/O must use endian-aware readers/writers. +- CLI classes should stay thin and delegate real work to `Operations/`. +- YAML-visible members need the appropriate editor attributes. +- Runtime paths should go through `EnvironmentUtilities`. +- Repository-root lookup should go through `WorkspaceUtilities.FindRepositoryRoot()`. + +## Binary format discipline + +Binary compatibility with the original game formats matters. + +Do not confuse internal code compatibility with file-format compatibility. + +Internal APIs may be changed aggressively when that improves the codebase. + +Binary import/export behavior must remain correct for supported platforms unless the user explicitly asks to change support. + +When modifying binary parsing or writing: + +- account for platform +- account for endian +- account for pointer width +- account for alignment/padding +- account for versioned layouts +- account for resource-specific struct sizes +- verify with the strongest available autotest mode + +Do not add a parser fallback merely because a file fails to parse. + +First determine whether the failure is caused by: + +- wrong platform detection +- wrong endian +- wrong architecture +- wrong struct size +- wrong offset +- wrong count +- wrong version assumption +- wrong resource type +- damaged or unsupported input + +## Preferred fix shape + +Prefer this sequence: + +1. understand the format or code path +2. identify the root cause +3. choose the canonical model +4. remove or replace the wrong path +5. update all callers +6. verify round-trip behavior +7. delete obsolete helpers or compatibility branches + +Avoid this sequence: + +1. observe failure +2. add guard +3. add fallback +4. preserve old path +5. make output appear valid without proving correctness diff --git a/Volatility.sln b/Volatility.sln index d64fc4e..98c6181 100644 --- a/Volatility.sln +++ b/Volatility.sln @@ -1,9 +1,10 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.7.34003.232 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volatility", "Volatility\Volatility.csproj", "{4111E0A2-78F4-4AFD-9EED-056B8FF9160B}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volatility.Core", "src\Volatility.Core\Volatility.Core.csproj", "{8B33FB4D-4E10-4A00-8806-382909C104AB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volatility.Cli", "src\Volatility.Cli\Volatility.Cli.csproj", "{4111E0A2-78F4-4AFD-9EED-056B8FF9160B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volatility.Core.Tests", "tests\Volatility.Core.Tests\Volatility.Core.Tests.csproj", "{B8B3EFAD-E182-4C67-AF2B-2A8B3D963F9E}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -11,10 +12,18 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8B33FB4D-4E10-4A00-8806-382909C104AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B33FB4D-4E10-4A00-8806-382909C104AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B33FB4D-4E10-4A00-8806-382909C104AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B33FB4D-4E10-4A00-8806-382909C104AB}.Release|Any CPU.Build.0 = Release|Any CPU {4111E0A2-78F4-4AFD-9EED-056B8FF9160B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4111E0A2-78F4-4AFD-9EED-056B8FF9160B}.Debug|Any CPU.Build.0 = Debug|Any CPU {4111E0A2-78F4-4AFD-9EED-056B8FF9160B}.Release|Any CPU.ActiveCfg = Release|Any CPU {4111E0A2-78F4-4AFD-9EED-056B8FF9160B}.Release|Any CPU.Build.0 = Release|Any CPU + {B8B3EFAD-E182-4C67-AF2B-2A8B3D963F9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8B3EFAD-E182-4C67-AF2B-2A8B3D963F9E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8B3EFAD-E182-4C67-AF2B-2A8B3D963F9E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8B3EFAD-E182-4C67-AF2B-2A8B3D963F9E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Volatility/Abstractions/Services/IPathProvider.cs b/Volatility/Abstractions/Services/IPathProvider.cs deleted file mode 100644 index 678e0a6..0000000 --- a/Volatility/Abstractions/Services/IPathProvider.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Volatility.Abstractions.Services; - -public interface IPathProvider -{ - string GetDirectory(VolatilityPathLocation location); - string GetExecutableDirectory(); -} diff --git a/Volatility/CLI/Commands/CreateResourceCommand.cs b/Volatility/CLI/Commands/CreateResourceCommand.cs deleted file mode 100644 index 5f83c09..0000000 --- a/Volatility/CLI/Commands/CreateResourceCommand.cs +++ /dev/null @@ -1,107 +0,0 @@ -using Volatility.Operations.Resources; -using Volatility.Resources; -using Volatility.Utilities; - -using static Volatility.Utilities.EnvironmentUtilities; -using static Volatility.Utilities.ResourceIDUtilities; - -namespace Volatility.CLI.Commands; - -internal class CreateResourceCommand : ICommand -{ - public static string CommandToken => "CreateResource"; - public static string CommandDescription => "Creates a new resource file with default/empty fields."; - public static string CommandParameters => "[--overwrite] --type= --format= --name= [--id=] [--outpath=]"; - - public string? ResType { get; set; } - public string? Format { get; set; } - public string? Name { get; set; } - public string? ResourceId { get; set; } - public string? OutputPath { get; set; } - public bool Overwrite { get; set; } - - public async Task Execute() - { - if (string.IsNullOrWhiteSpace(ResType)) - { - Console.WriteLine("Error: No resource type specified! (--type)"); - return; - } - - if (string.IsNullOrWhiteSpace(Format)) - { - Console.WriteLine("Error: No format specified! (--format)"); - return; - } - - if (string.IsNullOrWhiteSpace(Name) && string.IsNullOrWhiteSpace(OutputPath)) - { - Console.WriteLine("Error: No resource name or output path specified! (--name or --outpath)"); - return; - } - - string formatValue = Format ?? string.Empty; - bool isX64 = formatValue.EndsWith("x64", StringComparison.OrdinalIgnoreCase); - if (isX64) - formatValue = formatValue[..^3]; - - if (!TypeUtilities.TryParseEnum(formatValue, out Platform platform)) - { - Console.WriteLine("Error: Invalid file format specified!"); - return; - } - - if (!TypeUtilities.TryParseEnum(ResType, out ResourceType resType)) - { - Console.WriteLine("Error: Invalid resource type specified!"); - return; - } - - ResourceID? parsedId = null; - if (!string.IsNullOrWhiteSpace(ResourceId)) - { - if (!TryParseResourceID(ResourceId, out var id)) - { - Console.WriteLine("Error: Invalid resource ID specified! (--id)"); - return; - } - - parsedId = id; - } - - string resourcesDirectory = GetEnvironmentDirectory(EnvironmentDirectory.Resources); - CreateResourceOperation createOperation = new(resourcesDirectory); - SaveResourceOperation saveOperation = new(); - - CreateResourceResult result; - try - { - result = createOperation.Execute(resType, platform, Name, OutputPath, parsedId, isX64); - } - catch (Exception ex) - { - Console.WriteLine($"Error: {ex.Message}"); - return; - } - - if (File.Exists(result.ResourcePath) && !Overwrite) - { - Console.WriteLine($"Error: Output file already exists ({result.ResourcePath}). Use --overwrite to replace it."); - return; - } - - await saveOperation.ExecuteAsync(result.Resource, result.ResourcePath); - Console.WriteLine($"Created {Path.GetFileName(result.ResourcePath)} at {Path.GetFullPath(result.ResourcePath)}."); - } - - public void SetArgs(Dictionary args) - { - ResType = (args.TryGetValue("type", out object? restype) ? restype as string : string.Empty)?.ToUpper(); - Format = (args.TryGetValue("format", out object? format) ? format as string : string.Empty)?.ToUpper(); - Name = args.TryGetValue("name", out object? name) ? name as string : ""; - ResourceId = args.TryGetValue("id", out object? id) ? id as string : ""; - OutputPath = args.TryGetValue("outpath", out object? outpath) ? outpath as string : ""; - Overwrite = args.TryGetValue("overwrite", out var ow) && (bool)ow; - } - -} diff --git a/Volatility/CLI/Commands/ExportResourceCommand.cs b/Volatility/CLI/Commands/ExportResourceCommand.cs deleted file mode 100644 index 04e1387..0000000 --- a/Volatility/CLI/Commands/ExportResourceCommand.cs +++ /dev/null @@ -1,141 +0,0 @@ -using Volatility.Operations.Resources; -using Volatility.Resources; -using Volatility.Utilities; - -using static Volatility.Utilities.EnvironmentUtilities; - -namespace Volatility.CLI.Commands; - -internal class ExportResourceCommand : ICommand -{ - public static string CommandToken => "ExportResource"; - public static string CommandDescription => "Exports information and relevant data from an imported/created resource into a platform's format."; - public static string CommandParameters => "--recurse --overwrite --type= --format= --respath= --outpath= [--imports=] [--importsfile]"; - - public string? Format { get; set; } - public string? ResourcePath { get; set; } - public string? OutputPath { get; set; } - public string? Imports { get; set; } - public bool ImportsFile { get; set; } - public bool Overwrite { get; set; } - public bool Recursive { get; set; } - - public async Task Execute() - { - if (string.IsNullOrEmpty(Format)) - { - Console.WriteLine("Error: No resource path specified! (--respath)"); - return; - } - if (string.IsNullOrEmpty(ResourcePath)) - { - Console.WriteLine("Error: No resource path specified! (--respath)"); - return; - } - if (string.IsNullOrEmpty(OutputPath)) - { - Console.WriteLine("Error: No output path specified! (--outpath)"); - return; - } - - string filePath = $"" + - $"{ Path.Combine - ( - GetEnvironmentDirectory(EnvironmentDirectory.Resources), - ResourcePath - ) - }"; - - string[] sourceFiles = ICommand.GetFilePathsInDirectory(filePath, ICommand.TargetFileType.Any, Recursive); - - if (sourceFiles.Length == 0) - { - Console.WriteLine($"Error: No valid file(s) found at the specified path ({ResourcePath}). Ensure the path exists and spaces are properly enclosed. (--path)"); - return; - } - - if (!TypeUtilities.TryParseEnum(Format, out Platform platform)) - { - throw new InvalidPlatformException("Error: Invalid file format specified!"); - } - - Unpacker? importUnpackerOverride = null; - if (!string.IsNullOrEmpty(Imports) && !string.Equals(Imports, "DEFAULT", StringComparison.OrdinalIgnoreCase)) - { - if (!TypeUtilities.TryParseEnum(Imports, out Unpacker parsedUnpacker)) - { - Console.WriteLine("Error: Invalid imports export mode specified!"); - return; - } - - importUnpackerOverride = parsedUnpacker; - } - - var loadOperation = new LoadResourceOperation(); - var exportOperation = new ExportResourceOperation(); - - List tasks = new List(); - foreach (string sourceFile in sourceFiles) - { - Console.WriteLine(sourceFile); - - tasks.Add(Task.Run(async () => - { - try - { - File.GetAttributes(sourceFile); - } - catch (FileNotFoundException) - { - Console.WriteLine("Error: Invalid file import path specified!"); - return; - } - catch (DirectoryNotFoundException) - { - Console.WriteLine("Error: Can not find directory for specified import path!"); - return; - } - catch (Exception e) - { - Console.WriteLine($"Error: Caught file exception: {e.Message}"); - return; - } - - if (!TypeUtilities.TryParseEnum(Path.GetExtension(sourceFile).TrimStart('.'), out ResourceType resourceType)) - { - Console.WriteLine("Error: Resource type is invalid!"); - return; - } - - Resource resource; - try - { - resource = await loadOperation.ExecuteAsync(sourceFile, resourceType, platform); - } - catch (Exception e) - { - Console.WriteLine($"ERROR: Unable to deserialize {Path.GetFileName(sourceFile)} as {resourceType}!\nMessage from {e.TargetSite}: {e.Message}.\nStack Trace:\n{e.StackTrace}"); - return; - } - - await exportOperation.ExecuteAsync(resource, OutputPath, platform, importUnpackerOverride, ImportsFile); - - Console.WriteLine($"Exported {Path.GetFileName(ResourcePath)} as {Path.GetFullPath(OutputPath)}."); - })); - } - await Task.WhenAll(tasks); - } - - public void SetArgs(Dictionary args) - { - Format = (args.TryGetValue("format", out object? format) ? format as string : "")?.ToUpper(); - ResourcePath = args.TryGetValue("respath", out object? respath) ? respath as string : ""; - OutputPath = args.TryGetValue("outpath", out object? outpath) ? outpath as string : ""; - Imports = args.TryGetValue("imports", out object? imports) ? imports as string : ""; - ImportsFile = args.TryGetValue("importsfile", out var importsfile) && (bool)importsfile; - Overwrite = args.TryGetValue("overwrite", out var ow) && (bool)ow; - Recursive = args.TryGetValue("recurse", out var re) && (bool)re; - } - - public ExportResourceCommand() { } -} diff --git a/Volatility/CLI/Commands/HelpCommand.cs b/Volatility/CLI/Commands/HelpCommand.cs deleted file mode 100644 index d46f438..0000000 --- a/Volatility/CLI/Commands/HelpCommand.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Reflection; - -using static Volatility.Utilities.TypeUtilities; - -namespace Volatility.CLI.Commands; - -internal class HelpCommand : ICommand -{ - public static string CommandToken => "help"; - public static string CommandDescription => "Displays all available commands & parameters for individual commands."; - public static string CommandParameters => "[command]"; - - public string? WantedCommand { get; set; } - - - public async Task Execute() - { - if (!string.IsNullOrEmpty(WantedCommand)) - { - Frontend.Commands.TryGetValue(WantedCommand.ToLowerInvariant(), out Type? command); - if (command != null) - { - ConstructorInfo? constructor = command.GetConstructor(Array.Empty()); - if (constructor != null) - { - if (constructor.Invoke(Array.Empty()) is ICommand commandInstance) - { - commandInstance?.ShowUsage(); - return; - } - } - } - } - - Console.WriteLine("Available commands:"); - foreach (var command in GetDerivedTypes(typeof(ICommand))) - { - string commandName = GetStaticPropertyValue(command, nameof(CommandToken)); - - if (string.IsNullOrEmpty(commandName)) - continue; - - Console.WriteLine($" {commandName} - {GetStaticPropertyValue(command, nameof(CommandDescription))}"); - - } - Console.WriteLine("For information on command arguments, run: help ."); - } - - public void SetArgs(Dictionary args) - { - WantedCommand = args.TryGetValue("commandName", out object? name) ? name as string : ""; - } - - public HelpCommand() { } -} diff --git a/Volatility/CLI/Commands/ImportResourceCommand.cs b/Volatility/CLI/Commands/ImportResourceCommand.cs deleted file mode 100644 index 80bab56..0000000 --- a/Volatility/CLI/Commands/ImportResourceCommand.cs +++ /dev/null @@ -1,109 +0,0 @@ -using Volatility.Operations.Resources; -using Volatility.Resources; -using Volatility.Utilities; - -using static Volatility.Utilities.EnvironmentUtilities; - -namespace Volatility.CLI.Commands; - -internal class ImportResourceCommand : ICommand -{ - public static string CommandToken => "ImportResource"; - public static string CommandDescription => "Imports information and relevant data from a specified platform's resource into a standardized format."; - public static string CommandParameters => "--recurse --overwrite --type= --format= --path="; - - public string? ResType { get; set; } - public string? Format { get; set; } - public string? ImportPath { get; set; } - public bool Overwrite { get; set; } - public bool Recursive { get; set; } - - public async Task Execute() - { - if (ResType == "AUTO") - { - Console.WriteLine("Error: Automatic typing is not supported yet! Please specify a type (--type)"); - return; - } - - if (string.IsNullOrEmpty(ImportPath)) - { - Console.WriteLine("Error: No import path specified! (--path)"); - return; - } - - try - { - File.GetAttributes(ImportPath); - } - catch (FileNotFoundException) - { - Console.WriteLine("Error: Invalid file import path specified!"); - return; - } - catch (DirectoryNotFoundException) - { - Console.WriteLine("Error: Can not find directory for specified import path!"); - return; - } - catch (Exception e) - { - Console.WriteLine($"Error: Caught file exception: {e.Message}"); - return; - } - - string[] sourceFiles = ICommand.GetFilePathsInDirectory(ImportPath, ICommand.TargetFileType.Header, Recursive); - - if (sourceFiles.Length == 0) - { - Console.WriteLine($"Error: No valid file(s) found at the specified path ({ImportPath}). Ensure the path exists and spaces are properly enclosed. (--path)"); - return; - } - - string formatValue = Format ?? string.Empty; - bool isX64 = formatValue.EndsWith("x64", StringComparison.OrdinalIgnoreCase); - if (isX64) - formatValue = formatValue[..^3]; - - if (!TypeUtilities.TryParseEnum(formatValue, out Platform platform)) - { - throw new InvalidPlatformException("Error: Invalid file format specified!"); - } - - if (!TypeUtilities.TryParseEnum(ResType, out ResourceType resType)) - { - Console.WriteLine("Error: Invalid resource type specified!"); - return; - } - - string resourcesDirectory = GetEnvironmentDirectory(EnvironmentDirectory.Resources); - string toolsDirectory = GetEnvironmentDirectory(EnvironmentDirectory.Tools); - string splicerDirectory = GetEnvironmentDirectory(EnvironmentDirectory.Splicer); - - var importOperation = new ImportResourceOperation(resourcesDirectory, toolsDirectory, splicerDirectory, Overwrite); - var saveOperation = new SaveResourceOperation(); - - List tasks = new List(); - foreach (string sourceFile in sourceFiles) - { - tasks.Add(Task.Run(async () => - { - ImportResourceResult result = await importOperation.ExecuteAsync(resType, platform, sourceFile, isX64); - await saveOperation.ExecuteAsync(result.Resource, result.ResourcePath); - Console.WriteLine($"Imported {Path.GetFileName(sourceFile)} as {Path.GetFullPath(result.ResourcePath)}."); - })); - } - - await Task.WhenAll(tasks); - } - - public void SetArgs(Dictionary args) - { - ResType = (args.TryGetValue("type", out object? restype) ? restype as string : "auto")?.ToUpper(); - Format = (args.TryGetValue("format", out object? format) ? format as string : "auto")?.ToUpper(); - ImportPath = args.TryGetValue("path", out object? path) ? path as string : ""; - Overwrite = args.TryGetValue("overwrite", out var ow) && (bool)ow; - Recursive = args.TryGetValue("recurse", out var re) && (bool)re; - } - public ImportResourceCommand() { } -} diff --git a/Volatility/CLI/Commands/ImportStringTableCommand.cs b/Volatility/CLI/Commands/ImportStringTableCommand.cs deleted file mode 100644 index e499d88..0000000 --- a/Volatility/CLI/Commands/ImportStringTableCommand.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Volatility.Operations.StringTables; -using Volatility.Utilities; - -using static Volatility.Utilities.EnvironmentUtilities; - -namespace Volatility.CLI.Commands; - -internal class ImportStringTableCommand : ICommand -{ - public static string CommandToken => "ImportStringTable"; - public static string CommandDescription => "Imports entries into the ResourceDB from files containing a ResourceStringTable."; - public static string CommandParameters => "[--verbose] [--overwrite] [--recurse] [--endian=] [--version=] --path="; - - public string? Endian { get; set; } - public string? ImportPath { get; set; } - public string? Version { get; set; } - public bool Overwrite { get; set; } - public bool Recursive { get; set; } - public bool Verbose { get; set; } - - public async Task Execute() - { - if (string.IsNullOrEmpty(ImportPath)) - { - Console.WriteLine("Error: No import path specified! (--path)"); - return; - } - - var filePaths = ICommand.GetFilePathsInDirectory(ImportPath, ICommand.TargetFileType.Any, Recursive); - if (filePaths.Length == 0) - { - Console.WriteLine("Error: No files or folders found within the specified path!"); - return; - } - - Console.WriteLine("Importing data from ResourceStringTables into the ResourceDB... this may take a while!"); - - string directoryPath = GetEnvironmentDirectory(EnvironmentDirectory.ResourceDB); - Directory.CreateDirectory(directoryPath); - - var loadOperation = new LoadResourceDictionaryOperation(); - var mergeOperation = new MergeStringTableEntriesOperation(); - var importOperation = new ImportStringTableOperation(mergeOperation); - string version = Version ?? "v2"; - - if (version == "v1") - { - string jsonFile = Path.Combine(directoryPath, "ResourceDB.json"); - var importedEntries = new Dictionary>(StringComparer.OrdinalIgnoreCase); - await importOperation.ExecuteAsync(filePaths, importedEntries, Endian ?? "le", Overwrite, Verbose); - - var legacyEntries = await StringTableStorageUtilities.LoadJsonAsync(jsonFile); - StringTableStorageUtilities.MergeLegacyEntries(legacyEntries, importedEntries, Overwrite); - await StringTableStorageUtilities.WriteJsonAsync(jsonFile, legacyEntries); - - Console.WriteLine($"Finished importing all ResourceDB (v1) data at {jsonFile}."); - return; - } - - if (version != "v2") - { - Console.WriteLine("Error: Invalid version specified! (--version must be v1 or v2)"); - return; - } - - string yamlFile = Path.Combine(directoryPath, "ResourceDB.yaml"); - var allEntries = await loadOperation.ExecuteAsync(yamlFile); - await importOperation.ExecuteAsync(filePaths, allEntries, Endian ?? "le", Overwrite, Verbose); - await StringTableStorageUtilities.WriteYamlAsync(yamlFile, allEntries); - - Console.WriteLine($"Finished importing all ResourceDB (v2) data at {yamlFile}."); - } - - public void SetArgs(Dictionary args) - { - Endian = (args.TryGetValue("endian", out object? format) ? format as string : "le")?.ToLowerInvariant() ?? "le"; - ImportPath = args.TryGetValue("path", out object? path) ? path as string : ""; - Version = (args.TryGetValue("version", out object? version) ? version as string : "v2")?.ToLowerInvariant() ?? "v2"; - Overwrite = args.TryGetValue("overwrite", out var ow) && (bool)ow; - Recursive = args.TryGetValue("recurse", out var re) && (bool)re; - Verbose = args.TryGetValue("verbose", out var ve) && (bool)ve; - } - - public ImportStringTableCommand() { } -} diff --git a/Volatility/CLI/Commands/PortTextureCommand.cs b/Volatility/CLI/Commands/PortTextureCommand.cs deleted file mode 100644 index bbc5b7c..0000000 --- a/Volatility/CLI/Commands/PortTextureCommand.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Volatility.Operations.Resources; - -namespace Volatility.CLI.Commands; - -internal class PortTextureCommand : ICommand -{ - public static string CommandToken => "PortTexture"; - public static string CommandDescription => "Ports texture data from a given source format to the specified destination format."; - public static string CommandParameters => "[--verbose] [--usegtf] --informat= --inpath= --outformat= [--outpath=]"; - - public string? SourceFormat { get; set; } - public string? SourcePath { get; set; } - public string? DestinationFormat { get; set; } - public string? DestinationPath { get; set; } - public bool Verbose { get; set; } - public bool UseGTF { get; set; } - - public async Task Execute() - { - if (string.IsNullOrEmpty(SourcePath)) - { - Console.WriteLine("Error: No source path specified! (--inpath)"); - return; - } - - var sourceFiles = ICommand.GetFilePathsInDirectory(SourcePath, ICommand.TargetFileType.Header); - - Console.ForegroundColor = ConsoleColor.Blue; - Console.WriteLine($"Starting {sourceFiles.Length} PortTexture tasks..."); - Console.ResetColor(); - - var operation = new PortTextureOperation(); - - await operation.ExecuteAsync(sourceFiles, SourceFormat ?? string.Empty, SourcePath ?? string.Empty, DestinationFormat ?? string.Empty, DestinationPath, Verbose, UseGTF); - } - - public void SetArgs(Dictionary args) - { - SourceFormat = (args.TryGetValue("informat", out object? informat) ? informat as string - : args.TryGetValue("if", out object? iff) ? iff as string : "auto").ToUpper(); - - SourcePath = args.TryGetValue("inpath", out object? inpath) ? inpath as string - : args.TryGetValue("ip", out object? ipp) ? ipp as string : ""; - - DestinationFormat = (args.TryGetValue("outformat", out object? outformat) ? outformat as string - : args.TryGetValue("of", out object? off) ? off as string : "auto").ToUpper(); - - DestinationPath = args.TryGetValue("outpath", out object? outpath) ? outpath as string - : args.TryGetValue("op", out object? opp) ? opp as string : SourcePath; - - Verbose = args.TryGetValue("verbose", out var verbose) && (bool)verbose; - UseGTF = args.TryGetValue("usegtf", out var usegtf) && (bool)usegtf; - } - - public PortTextureCommand() { } -} diff --git a/Volatility/CLI/ICommand.cs b/Volatility/CLI/ICommand.cs deleted file mode 100644 index 0042dbd..0000000 --- a/Volatility/CLI/ICommand.cs +++ /dev/null @@ -1,64 +0,0 @@ -using static Volatility.Utilities.TypeUtilities; - -namespace Volatility; - -internal interface ICommand -{ - static string CommandToken { get; } - static string CommandDescription { get; } - static string CommandParameters { get; } - - async Task Execute() { } - void SetArgs(Dictionary args); - public void ShowUsage() - { - Type thisType = GetType(); - - var token = GetStaticPropertyValue(thisType, "CommandToken"); - var parameters = GetStaticPropertyValue(thisType, "CommandParameters"); - var description = GetStaticPropertyValue(thisType, "CommandDescription"); - - Console.WriteLine($"Usage:\n {token} {parameters}\n{description}"); - } - static string[] GetFilePathsInDirectory(string path, TargetFileType filter, bool recurse = false) - { - List files = new(); - - path = Path.GetFullPath(path); - - if (File.Exists(path)) - { - files.Add(path); - } - else if (Directory.Exists(path)) - { - SearchOption searchOption = recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; - files = new List(Directory.EnumerateFiles(path, "*", searchOption)); - } - for (int i = files.Count - 1; i >= 0; i--) - { - var name = Path.GetFileName(files[i]); - switch (filter) - { - case TargetFileType.Header: - if (!name.Contains(".dat") && !name.Contains("_1.bin") - || name.Contains("_secondary") - || name.Contains("_texture") - || name.Contains("_imports") - || name.Contains("_model") - || name.Contains("_body")) - files.RemoveAt(i); - break; - default: - break; - } - } - return files.ToArray(); - } - - public enum TargetFileType - { - Any, - Header - } -} \ No newline at end of file diff --git a/Volatility/Messaging/NullMessageSink.cs b/Volatility/Messaging/NullMessageSink.cs deleted file mode 100644 index 5722a16..0000000 --- a/Volatility/Messaging/NullMessageSink.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Volatility.Abstractions.Messaging; - -namespace Volatility.Messaging; - -public sealed class NullMessageSink : IMessageSink -{ - public static NullMessageSink Instance { get; } = new(); - - private NullMessageSink() - { - } - - public void Publish(in VolatilityMessage message) - { - } -} diff --git a/Volatility/Operations/Resources/CreateResourceOperation.cs b/Volatility/Operations/Resources/CreateResourceOperation.cs deleted file mode 100644 index 8890594..0000000 --- a/Volatility/Operations/Resources/CreateResourceOperation.cs +++ /dev/null @@ -1,89 +0,0 @@ -using Volatility.Resources; - -namespace Volatility.Operations.Resources; - -internal sealed class CreateResourceOperation -{ - private readonly string resourcesDirectory; - - public CreateResourceOperation(string resourcesDirectory) - { - this.resourcesDirectory = resourcesDirectory; - } - - public CreateResourceResult Execute( - ResourceType resourceType, - Platform platform, - string? assetName, - string? outputPath, - ResourceID? resourceId, - bool isX64) - { - Resource resource = ResourceFactory.CreateResource(resourceType, platform, string.Empty, isX64); - - string resolvedPath = ResolveOutputPath(outputPath, resourceType, assetName); - string resolvedAssetName = ResolveAssetName(assetName, resolvedPath); - - if (!string.IsNullOrWhiteSpace(resolvedAssetName)) - resource.AssetName = resolvedAssetName; - - if (resourceId.HasValue) - { - resource.ResourceID = resourceId.Value; - } - else if (!string.IsNullOrWhiteSpace(resolvedAssetName)) - { - resource.ResourceID = ResourceID.HashFromString(resolvedAssetName); - } - - if (resource is ShaderBase shader && string.IsNullOrWhiteSpace(shader.ShaderSourcePath)) - { - shader.ShaderSourcePath = $"{Path.GetFileName(resolvedPath)}.hlsl"; - } - - return new CreateResourceResult(resource, resolvedPath); - } - - private string ResolveOutputPath(string? outputPath, ResourceType resourceType, string? assetName) - { - string resolvedPath; - - if (string.IsNullOrWhiteSpace(outputPath)) - { - if (string.IsNullOrWhiteSpace(assetName)) - throw new InvalidOperationException("Either a resource name or an output path must be provided."); - - resolvedPath = Path.Combine(resourcesDirectory, NormalizeGameDbPath(assetName)); - } - else if (!Path.IsPathRooted(outputPath)) - { - resolvedPath = Path.Combine(resourcesDirectory, NormalizeGameDbPath(outputPath)); - } - else - { - resolvedPath = NormalizeGameDbPath(outputPath); - } - - return Path.ChangeExtension(resolvedPath, resourceType.ToString()); - } - - private static string ResolveAssetName(string? assetName, string outputPath) - { - if (!string.IsNullOrWhiteSpace(assetName)) - return assetName; - - return Path.GetFileNameWithoutExtension(outputPath); - } - - private static string NormalizeGameDbPath(string path) - { - const string prefix = "gamedb://"; - - if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - return "gamedb/" + path[prefix.Length..]; - - return path; - } -} - -internal sealed record CreateResourceResult(Resource Resource, string ResourcePath); diff --git a/Volatility/Operations/Resources/CreateShaderProgramBufferOperation.cs b/Volatility/Operations/Resources/CreateShaderProgramBufferOperation.cs deleted file mode 100644 index a68f1b8..0000000 --- a/Volatility/Operations/Resources/CreateShaderProgramBufferOperation.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Volatility.Resources; -using Volatility.Utilities; - -namespace Volatility.Operations.Resources; - -internal sealed class CreateShaderProgramBufferOperation -{ - public ShaderProgramBufferBase ExecuteFromFile(string csoPath, ShaderStageType stage, Platform platform) - { - if (string.IsNullOrWhiteSpace(csoPath)) - throw new ArgumentNullException(nameof(csoPath)); - if (!File.Exists(csoPath)) - throw new FileNotFoundException($"CSO file not found: {csoPath}"); - - byte[] csoBytes = File.ReadAllBytes(csoPath); - return Execute(csoBytes, stage, platform); - } - - public ShaderProgramBufferBase Execute(byte[] csoBytes, ShaderStageType stage, Platform platform) - { - return platform switch - { - Platform.BPR => ShaderProgramBufferBPR.FromCSO(csoBytes, stage), - _ => throw new NotSupportedException($"ShaderProgramBuffer is not implemented for platform {platform}.") - }; - } - - public ShaderProgramBufferBPR ExecuteFromFile(string csoPath, ShaderStageType stage) - { - return (ShaderProgramBufferBPR)ExecuteFromFile(csoPath, stage, Platform.BPR); - } - - public ShaderProgramBufferBPR Execute(byte[] csoBytes, ShaderStageType stage) - { - return (ShaderProgramBufferBPR)Execute(csoBytes, stage, Platform.BPR); - } - - public void WriteToFile(ShaderProgramBufferBase buffer, string outputPath, Platform platform) - { - if (buffer == null) - throw new ArgumentNullException(nameof(buffer)); - - ValidatePlatform(platform); - - Platform bufferPlatform = buffer.ResourcePlatform; - if (bufferPlatform != Platform.Agnostic && bufferPlatform != platform) - throw new InvalidOperationException($"ShaderProgramBuffer platform mismatch: buffer={bufferPlatform}, requested={platform}."); - - string? directory = Path.GetDirectoryName(outputPath); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - - using FileStream fs = new(outputPath, FileMode.Create); - Endian endian = buffer.ResourceEndian; - if (endian == Endian.Agnostic) - endian = EndianMapping.GetDefaultEndian(platform); - - using ResourceBinaryWriter writer = new(fs, endian); - buffer.WriteToStream(writer, endian); - } - - public void WriteToFile(ShaderProgramBufferBase buffer, string outputPath) - { - WriteToFile(buffer, outputPath, Platform.BPR); - } - - private static void ValidatePlatform(Platform platform) - { - if (platform == Platform.BPR) - return; - - throw new NotSupportedException($"ShaderProgramBuffer is not implemented for platform {platform}."); - } -} diff --git a/Volatility/Operations/Resources/ExportResourceOperation.cs b/Volatility/Operations/Resources/ExportResourceOperation.cs deleted file mode 100644 index 508df0c..0000000 --- a/Volatility/Operations/Resources/ExportResourceOperation.cs +++ /dev/null @@ -1,240 +0,0 @@ -using Volatility.Resources; -using Volatility.Utilities; -namespace Volatility.Operations.Resources; - -internal class ExportResourceOperation -{ - public Task ExecuteAsync( - Resource resource, - string outputPath, - Platform platform, - Unpacker? importUnpackerOverride = null, - bool writeImportsToSeparateFile = false) - { - string? directoryPath = Path.GetDirectoryName(outputPath); - - if (!string.IsNullOrEmpty(directoryPath)) - { - Directory.CreateDirectory(directoryPath); - } - - using FileStream fs = new(outputPath, FileMode.Create); - - Endian endian = resource.ResourceEndian != Endian.Agnostic - ? resource.ResourceEndian - : EndianMapping.GetDefaultEndian(platform); - - using ResourceBinaryWriter writer = new(fs, endian); - - switch (resource) - { - case TextureBase texture: - texture.PushAll(); - goto default; - default: - resource.WriteToStream(writer); - break; - } - - WriteExternalImports( - resource, - outputPath, - writer, - endian, - ResolveExternalImportsUnpackerFormat(resource, importUnpackerOverride), - writeImportsToSeparateFile); - - if (resource is ShaderBase shader) - { - var stages = shader.GetCompileStages(); - bool useStageSuffix = stages.Count > 1; - - if (platform == Platform.BPR) - { - CreateShaderProgramBufferOperation bufferOperation = new(); - foreach (var stage in stages) - { - string shaderProgramBufferPath = GetShaderProgramBufferPath(outputPath, stage, useStageSuffix); - string csoPath = GetShaderCSOPath(outputPath, stage, useStageSuffix); - - DXCShaderCompiler.CompileToCSO(shader, stage, csoPath); - ShaderProgramBufferBase buffer = bufferOperation.ExecuteFromFile(csoPath, stage.ResolveStage(), platform); - bufferOperation.WriteToFile(buffer, shaderProgramBufferPath, platform); - WritePaddedCSOFile(csoPath, GetSecondaryResourcePath(shaderProgramBufferPath)); - } - } - else - { - DXCShaderCompiler.CompileStagesToCSO(shader, stages, stage => - GetShaderProgramBufferPath(outputPath, stage, useStageSuffix)); - } - } - - if (resource is ShaderProgramBufferBPR shaderProgramBuffer && platform == Platform.BPR) - { - if (shaderProgramBuffer.CompiledShaderBytecode.Length > 0) - { - string secondaryCSOPath = GetSecondaryResourcePath(outputPath); - WritePaddedCSOBytes(shaderProgramBuffer.CompiledShaderBytecode, secondaryCSOPath); - } - } - - return Task.CompletedTask; - } - - private static Unpacker ResolveExternalImportsUnpackerFormat( - Resource resource, - Unpacker? importUnpackerOverride) - { - return importUnpackerOverride ?? resource.Unpacker; - } - - private static void WriteExternalImports( - Resource resource, - string outputPath, - ResourceBinaryWriter writer, - Endian endian, - Unpacker importUnpacker, - bool forceExternalImportsFile) - { - List> imports = resource.GetExternalImports().ToList(); - - string yamlImportsPath = ResourceImport.GetImportsPath(outputPath, Unpacker.YAP); - string datImportsPath = ResourceImport.GetImportsPath(outputPath, Unpacker.Raw); - - if (imports.Count == 0) - { - ResourceImport.DeleteImportsSidecarFiles(outputPath); - return; - } - - if (importUnpacker == Unpacker.YAP) - { - ResourceImport.DeleteImportsSidecarFiles(outputPath); - WriteExternalImportsYaml(imports, yamlImportsPath); - return; - } - - if (forceExternalImportsFile) - { - ResourceImport.DeleteImportsSidecarFiles(outputPath); - WriteExternalImportsDat(datImportsPath, endian, imports); - return; - } - - writer.BaseStream.Seek(0, SeekOrigin.End); - WriteBinaryImports(writer, imports); - ResourceImport.DeleteImportsSidecarFiles(outputPath); - } - - private static void WriteExternalImportsYaml( - List> imports, - string importsPath) - { - List lines = new(imports.Count); - foreach (KeyValuePair entry in imports) - { - ulong resourceId = ResourceUtilities.ResolveResourceID(entry.Value); - lines.Add($"- \"0x{entry.Key:x8}\": \"{resourceId:X8}\""); - } - - File.WriteAllLines(importsPath, lines); - } - - private static void WriteExternalImportsDat( - string importsPath, - Endian endianness, - List> imports) - { - using FileStream fs = new(importsPath, FileMode.Create, FileAccess.Write); - using EndianAwareBinaryWriter writer = new(fs, endianness); - WriteBinaryImports(writer, imports); - } - - private static void WriteBinaryImports( - EndianAwareBinaryWriter writer, - List> imports) - { - foreach (KeyValuePair entry in imports) - { - if (entry.Key < 0 || entry.Key > uint.MaxValue) - { - throw new InvalidDataException( - $"Import offset 0x{entry.Key:X} cannot be stored in a binary imports block."); - } - - // Probably overkill but I just want to make sure we always use the correct writer overloads - writer.Write((ulong)ResourceUtilities.ResolveResourceID(entry.Value)); - writer.Write((uint)entry.Key); - writer.Write(0x00000000); - } - } - - private static string GetShaderProgramBufferPath( - string shaderOutputPath, - ShaderStageCompile stage, - bool useStageSuffix) - { - string? directory = Path.GetDirectoryName(shaderOutputPath); - string baseName = Path.GetFileNameWithoutExtension(shaderOutputPath); - string stageSuffix = useStageSuffix ? $".{ShaderStageCompile.GetStageSuffix(stage.ResolveStage())}" : string.Empty; - string fileName = $"{baseName}.secondary{stageSuffix}.ShaderProgramBuffer"; - return string.IsNullOrEmpty(directory) - ? fileName - : Path.Combine(directory, fileName); - } - - private static string GetShaderCSOPath( - string shaderOutputPath, - ShaderStageCompile stage, - bool useStageSuffix) - { - string? directory = Path.GetDirectoryName(shaderOutputPath); - string baseName = Path.GetFileNameWithoutExtension(shaderOutputPath); - string stageSuffix = useStageSuffix ? $".{ShaderStageCompile.GetStageSuffix(stage.ResolveStage())}" : string.Empty; - string fileName = $"{baseName}.secondary{stageSuffix}.cso"; - return string.IsNullOrEmpty(directory) - ? fileName - : Path.Combine(directory, fileName); - } - - private static string GetSecondaryResourcePath(string primaryPath) - { - string? directory = Path.GetDirectoryName(primaryPath); - string baseName = Path.GetFileNameWithoutExtension(primaryPath); - string extension = Path.GetExtension(primaryPath); - string fileName = $"{baseName}.secondary{extension}"; - return string.IsNullOrEmpty(directory) - ? fileName - : Path.Combine(directory, fileName); - } - - private static void WritePaddedCSOFile(string sourcePath, string outputPath) - { - string? directory = Path.GetDirectoryName(outputPath); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - - using FileStream input = new(sourcePath, FileMode.Open, FileAccess.Read); - using FileStream output = new(outputPath, FileMode.Create, FileAccess.Write); - input.CopyTo(output); - - PaddingUtilities.WritePadding(output, 0x100); - } - - private static void WritePaddedCSOBytes(byte[] csoBytes, string outputPath) - { - string? directory = Path.GetDirectoryName(outputPath); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - - using FileStream output = new(outputPath, FileMode.Create, FileAccess.Write); - output.Write(csoBytes); - - PaddingUtilities.WritePadding(output, 0x100); - } -} diff --git a/Volatility/Operations/Resources/ImportResourceOperation.cs b/Volatility/Operations/Resources/ImportResourceOperation.cs deleted file mode 100644 index 6d93b07..0000000 --- a/Volatility/Operations/Resources/ImportResourceOperation.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System.Text.RegularExpressions; - -using Volatility.Resources; -using Volatility.Utilities; - -namespace Volatility.Operations.Resources; - -internal partial class ImportResourceOperation -{ - private readonly string resourcesDirectory; - private readonly string toolsDirectory; - private readonly string splicerDirectory; - private readonly bool overwrite; - private static readonly object ConsolePromptLock = new(); - - public ImportResourceOperation(string resourcesDirectory, string toolsDirectory, string splicerDirectory, bool overwrite) - { - this.resourcesDirectory = resourcesDirectory; - this.toolsDirectory = toolsDirectory; - this.splicerDirectory = splicerDirectory; - this.overwrite = overwrite; - } - - public async Task ExecuteAsync(ResourceType resourceType, Platform platform, string sourceFile, bool isX64) - { - Resource resource = ResourceFactory.CreateResource(resourceType, platform, sourceFile, isX64); - - string filePath = Path.Combine - ( - resourcesDirectory, - $"{DBToFileRegex().Replace(resource.AssetName, string.Empty)}.{resourceType}" - ); - - string? directoryPath = Path.GetDirectoryName(filePath); - - if (!string.IsNullOrEmpty(directoryPath)) - { - Directory.CreateDirectory(directoryPath); - } - - if (resourceType == ResourceType.Texture) - { - string texturePath = TextureBitmapUtilities.GetSecondaryBitmapPath(sourceFile, resource.Unpacker); - - if (resource is TextureBase texture && File.Exists(texturePath)) - { - string outPath = Path.Combine - ( - directoryPath ?? string.Empty, - Path.GetFileNameWithoutExtension(Path.GetFileNameWithoutExtension(Path.GetFullPath(filePath))) - ); - - TextureBitmapUtilities.WriteNormalizedBitmapFile(texture, texturePath, $"{outPath}.{resourceType}Bitmap", overwrite); - } - } - - if (resourceType == ResourceType.Splicer) - { - string sxPath = Path.Combine - ( - toolsDirectory, - "sx.exe" - ); - - bool sxExists = File.Exists(sxPath); - - Splicer? splicer = resource as Splicer; - - List? samples = splicer?.GetLoadedSamples(); - - string sampleDirectory = Path.Combine - ( - splicerDirectory, - "Samples" - ); - - Directory.CreateDirectory(sampleDirectory); - - if (samples != null) - { - for (int i = 0; i < samples.Count; i++) - { - string sampleName = $"{samples[i].SampleID}"; - - string samplePathName = Path.Combine(sampleDirectory, sampleName); - - if (!File.Exists($"{samplePathName}.snr") || overwrite) - { - Console.WriteLine($"Writing extracted sample {sampleName}.snr"); - await File.WriteAllBytesAsync($"{samplePathName}.snr", samples[i].Data); - } - else - { - Console.WriteLine($"Skipping extracted sample {sampleName}.snr"); - } - - if (sxExists) - { - string convertedSamplePathName = Path.Combine(sampleDirectory, "_extracted"); - - Directory.CreateDirectory(convertedSamplePathName); - - convertedSamplePathName = Path.Combine(convertedSamplePathName, sampleName + ".wav"); - - if (!File.Exists(convertedSamplePathName) || overwrite) - { - Console.WriteLine($"Converting extracted sample {sampleName}.snr to wave..."); - ProcessUtilities.RunAndRelayOutput( - sxPath, - $"-wave -s16l_int -v0 \"{samplePathName}.snr\" -=\"{convertedSamplePathName}\""); - } - else - { - Console.WriteLine($"Converted sample {Path.GetFileName(convertedSamplePathName)} already exists, skipping..."); - } - } - } - } - } - - return new ImportResourceResult(resource, filePath); - } - - private static bool PromptOverwrite(string filePath) - { - lock (ConsolePromptLock) - { - Console.Write($"{Path.GetFileName(filePath)} already exists. Overwrite? [y/N]: "); - string? input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input)) - return false; - - input = input.Trim(); - return input.Equals("y", StringComparison.OrdinalIgnoreCase) - || input.Equals("yes", StringComparison.OrdinalIgnoreCase); - } - } - - [GeneratedRegex(@"(\?ID=\d+)|:")] - private static partial Regex DBToFileRegex(); -} - -internal sealed record ImportResourceResult(Resource Resource, string ResourcePath); diff --git a/Volatility/Operations/Resources/LoadResourceOperation.cs b/Volatility/Operations/Resources/LoadResourceOperation.cs deleted file mode 100644 index a51abd9..0000000 --- a/Volatility/Operations/Resources/LoadResourceOperation.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Runtime.Serialization; - -using Volatility.Resources; -using Volatility.Utilities; - -namespace Volatility.Operations.Resources; - -internal class LoadResourceOperation -{ - public async Task ExecuteAsync(string sourceFile, ResourceType resourceType, Platform platform) - { - string yaml = await File.ReadAllTextAsync(sourceFile); - - Resource resource = ResourceFactory.CreateResource(resourceType, platform, string.Empty); - - Resource? result = (Resource?)ResourceYamlDeserializer.DeserializeResource(resource.GetType(), yaml); - - if (result is null) - { - throw new SerializationException(); - } - - result.ImportedFileName = sourceFile; - return result; - } -} diff --git a/Volatility/Operations/Resources/PortTextureOperation.cs b/Volatility/Operations/Resources/PortTextureOperation.cs deleted file mode 100644 index 407ea0b..0000000 --- a/Volatility/Operations/Resources/PortTextureOperation.cs +++ /dev/null @@ -1,512 +0,0 @@ -using System.Reflection; - -using Volatility.Resources; -using Volatility.Utilities; - -using static Volatility.Utilities.ResourceIDUtilities; - -namespace Volatility.Operations.Resources; - -internal class PortTextureOperation -{ - public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFormat, string sourcePath, string destinationFormat, string? destinationPath, bool verbose, bool useGtf) - { - string resolvedDestinationPath = string.IsNullOrEmpty(destinationPath) ? sourcePath : destinationPath; - - List tasks = new List(); - - foreach (string sourceFile in sourceFiles) - { - tasks.Add(Task.Run(async () => - { - TextureBase? sourceTexture = ConstructHeader(sourceFile, sourceFormat, verbose); - TextureBase? destinationTexture = ConstructHeader(resolvedDestinationPath, destinationFormat, verbose); - - if (sourceTexture == null || destinationTexture == null) - { - throw new InvalidOperationException("Failed to initialize texture header. Ensure the platform matches the file format and that the path is correct."); - } - - string localSourceFormat = BPRx64Hack(sourceTexture, sourceFormat); - string localDestinationFormat = BPRx64Hack(destinationTexture, destinationFormat); - - sourceTexture.PullAll(); - - CopyProperties(sourceTexture, destinationTexture); - - bool flipEndian = false; - int sourceFormatIndex = 0; - int destinationFormatIndex = 0; - switch ((sourceTexture, destinationTexture)) - { - case (TexturePS3 ps3, TextureX360 x360): - PS3toX360Mapping.TryGetValue(ps3.Format, out GPUTEXTUREFORMAT ps3x360Format); - x360.Format.DataFormat = ps3x360Format; - x360.Format.Endian = GPUENDIAN.GPUENDIAN_NONE; - flipEndian = false; - sourceFormatIndex = (int)ps3.Format; - destinationFormatIndex = (int)ps3x360Format; - if (ps3x360Format == GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_1_REVERSE) - Console.WriteLine($"WARNING: Destination texture format is {ps3x360Format}! (Source is {ps3.Format})"); - break; - case (TextureX360 x360, TexturePS3 ps3): - X360toPS3Mapping.TryGetValue(x360.Format.DataFormat, out CELL_GCM_COLOR_FORMAT x360ps3Format); - ps3.Format = x360ps3Format; - flipEndian = false; - sourceFormatIndex = (int)x360.Format.DataFormat; - destinationFormatIndex = (int)x360ps3Format; - if (x360ps3Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_INVALID) - Console.WriteLine($"WARNING: Destination texture format is {x360ps3Format}! (Source is {x360.Format.DataFormat})"); - break; - case (TextureBPR bprsrc, TextureBPR bprdst): - bprdst.Format = bprsrc.Format; - sourceFormatIndex = (int)bprsrc.Format; - destinationFormatIndex = sourceFormatIndex; - break; - case (TexturePC tub, TextureBPR bpr): - TUBtoBPRMapping.TryGetValue(tub.Format, out DXGI_FORMAT tubbprFormat); - bpr.Format = tubbprFormat; - sourceFormatIndex = (int)tub.Format; - destinationFormatIndex = (int)tubbprFormat; - if (tubbprFormat == DXGI_FORMAT.DXGI_FORMAT_UNKNOWN) - Console.WriteLine($"WARNING: Destination texture format is {tubbprFormat}! (Source is {tub.Format})"); - break; - case (TextureBPR bpr, TexturePC tub): - BPRtoTUBMapping.TryGetValue(bpr.Format, out D3DFORMAT bprtubFormat); - tub.Format = bprtubFormat; - sourceFormatIndex = (int)bpr.Format; - destinationFormatIndex = (int)bprtubFormat; - if (bprtubFormat == D3DFORMAT.D3DFMT_UNKNOWN) - Console.WriteLine($"WARNING: Destination texture format is {bprtubFormat}! (Source is {bpr.Format})"); - break; - case (TexturePS3 ps3, TextureBPR bpr): - PS3toBPRMapping.TryGetValue(ps3.Format, out DXGI_FORMAT ps3bprFormat); - bpr.Format = ps3bprFormat; - flipEndian = true; - sourceFormatIndex = (int)ps3.Format; - destinationFormatIndex = (int)ps3bprFormat; - if (ps3bprFormat == DXGI_FORMAT.DXGI_FORMAT_UNKNOWN) - Console.WriteLine($"WARNING: Destination texture format is {ps3bprFormat}! (Source is {ps3.Format})"); - break; - case (TexturePS3 ps3, TexturePC tub): - PS3toTUBMapping.TryGetValue(ps3.Format, out D3DFORMAT ps3tubFormat); - tub.Format = ps3tubFormat; - flipEndian = true; - sourceFormatIndex = (int)ps3.Format; - destinationFormatIndex = (int)ps3tubFormat; - if (ps3tubFormat == D3DFORMAT.D3DFMT_UNKNOWN) - Console.WriteLine($"WARNING: Destination texture format is {ps3tubFormat}! (Source is {ps3.Format})"); - break; - case (TextureX360 x360, TexturePC tub): - X360toTUBMapping.TryGetValue(x360.Format.DataFormat, out D3DFORMAT x360tubFormat); - tub.Format = x360tubFormat; - flipEndian = true; - sourceFormatIndex = (int)x360.Format.DataFormat; - destinationFormatIndex = (int)x360tubFormat; - if (x360tubFormat == D3DFORMAT.D3DFMT_UNKNOWN) - Console.WriteLine($"WARNING: Destination texture format is {x360tubFormat}! (Source is {x360.Format.DataFormat})"); - break; - case (TextureX360 x360, TextureBPR bpr): - X360toBPRMapping.TryGetValue(x360.Format.DataFormat, out DXGI_FORMAT x360bprFormat); - bpr.Format = x360bprFormat; - flipEndian = true; - sourceFormatIndex = (int)x360.Format.DataFormat; - destinationFormatIndex = (int)x360bprFormat; - if (x360bprFormat == DXGI_FORMAT.DXGI_FORMAT_UNKNOWN) - Console.WriteLine($"WARNING: Destination texture format is {x360bprFormat}! (Source is {x360.Format.DataFormat})"); - break; - case (TexturePC tub, TextureX360 x360): - TUBtoX360Mapping.TryGetValue(tub.Format, out GPUTEXTUREFORMAT tubx360Format); - x360.Format.DataFormat = tubx360Format; - flipEndian = true; - sourceFormatIndex = (int)tub.Format; - destinationFormatIndex = (int)tubx360Format; - if (tubx360Format == GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_1_REVERSE) - Console.WriteLine($"WARNING: Destination texture format is {tubx360Format}! (Source is {tub.Format})"); - break; - case (TextureBPR bpr, TexturePS3 ps3): - BPRtoPS3Mapping.TryGetValue(bpr.Format, out CELL_GCM_COLOR_FORMAT bprps3format); - ps3.Format = bprps3format; - flipEndian = true; - sourceFormatIndex = (int)bpr.Format; - destinationFormatIndex = (int)bprps3format; - if (bprps3format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_INVALID) - Console.WriteLine($"WARNING: Destination texture format is {bprps3format}! (Source is {bpr.Format})"); - break; - case (TexturePC tub, TexturePS3 ps3): - TUBtoPS3Mapping.TryGetValue(tub.Format, out CELL_GCM_COLOR_FORMAT tubps3Format); - ps3.Format = tubps3Format; - flipEndian = true; - sourceFormatIndex = (int)tub.Format; - destinationFormatIndex = (int)tubps3Format; - if (tubps3Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_INVALID) - Console.WriteLine($"WARNING: Destination texture format is {tubps3Format}! (Source is {tub.Format})"); - break; - case (TextureBPR bpr, TextureX360 x360): - BPRtoX360Mapping.TryGetValue(bpr.Format, out GPUTEXTUREFORMAT bprx360Format); - x360.Format.DataFormat = bprx360Format; - flipEndian = true; - sourceFormatIndex = (int)bpr.Format; - destinationFormatIndex = (int)bprx360Format; - if (bprx360Format == GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_1_REVERSE) - Console.WriteLine($"WARNING: Destination texture format is {bprx360Format}! (Source is {bpr.Format})"); - break; - default: - throw new NotImplementedException($"Conversion technique {localSourceFormat} > {localDestinationFormat} is not yet implemented."); - }; - - destinationTexture.PushAll(); - - string outPath = string.Empty; - - string outResourceFilename = (flipEndian && sourceTexture.Unpacker != Unpacker.YAP) - ? FlipPathResourceIDEndian(Path.GetFileName(sourceFile)) - : Path.GetFileName(sourceFile); - - if (resolvedDestinationPath == sourceFile) - { - outPath = $"{Path.GetDirectoryName(resolvedDestinationPath)}{Path.DirectorySeparatorChar}{outResourceFilename}"; - } - else if (new DirectoryInfo(resolvedDestinationPath).Exists) - { - outPath = resolvedDestinationPath + Path.DirectorySeparatorChar + outResourceFilename; - } - - string sourceBitmapPath = TextureBitmapUtilities.GetSecondaryBitmapPath(sourceFile, sourceTexture.Unpacker); - - if (!Path.Exists(sourceBitmapPath)) - { - Console.WriteLine($"Failed to find associated bitmap data for {Path.GetFileNameWithoutExtension(sourceFile)} at path {sourceBitmapPath}!"); - } - - string destinationBitmapPath = TextureBitmapUtilities.GetSecondaryBitmapPath(outPath, sourceTexture.Unpacker); - - if (Path.Exists(destinationBitmapPath)) - { - if (verbose) Console.WriteLine($"Found existing bitmap data at {destinationBitmapPath}, overwriting..."); - } - - try - { - if (useGtf && sourceTexture.ResourcePlatform == Platform.PS3) - { - PS3TextureUtilities.PS3GTFToDDS(sourcePath, sourceBitmapPath, destinationBitmapPath, verbose); - } - - byte[] sourceBitmapData = TextureBitmapUtilities.ReadNormalizedBitmapData(sourceTexture, sourceBitmapPath); - - if (destinationTexture is TextureX360 destX && sourceTexture.ResourcePlatform != Platform.X360) - { - destX.Format.MaxMipLevel = destX.Format.MinMipLevel; - } - - if (!TryConvertTexture(sourceTexture, destinationTexture, sourceBitmapData, destinationBitmapPath)) - { - if (verbose) Console.WriteLine($"Writing associated bitmap data for {Path.GetDirectoryName(outPath)}{Path.DirectorySeparatorChar}{Path.GetFileNameWithoutExtension(outPath)}_texture.dat..."); - File.WriteAllBytes(destinationBitmapPath, sourceBitmapData); - } - else - { - if (verbose) Console.WriteLine($"Converting associated bitmap data for {Path.GetDirectoryName(outPath)}{Path.DirectorySeparatorChar}{Path.GetFileNameWithoutExtension(outPath)}_texture.dat..."); - } - if (verbose) Console.WriteLine($"Wrote texture bitmap data to {destinationFormat} destination directory."); - - if (destinationTexture is TextureBPR destBprTexture && File.Exists(destinationBitmapPath)) - { - destBprTexture.PlacedDataSize = (uint)new FileInfo(destinationBitmapPath).Length; - if (verbose) Console.WriteLine($"BPR PlacedDataSize set to {destBprTexture.PlacedDataSize} (file: {destinationBitmapPath})."); - } - - using FileStream fs = new(outPath, FileMode.Create, FileAccess.Write); - using (ResourceBinaryWriter writer = new(fs, destinationTexture.ResourceEndian)) - { - try - { - if (verbose) Console.WriteLine($"Writing converted {destinationFormat} texture property data to destination file {Path.GetFileName(outPath)}..."); - destinationTexture.WriteToStream(writer); - } - catch - { - throw new IOException("Failed to write converted texture property data to stream."); - } - writer.Close(); - fs.Close(); - } - } - catch (Exception ex) - { - Console.WriteLine($"Error trying to copy bitmap data for {Path.GetFileNameWithoutExtension(sourceFile)}: {ex.Message}"); - } - - Console.WriteLine($"Successfully ported {localSourceFormat} formatted {Path.GetFileNameWithoutExtension(sourceFile)}to {localDestinationFormat} as {Path.GetFileNameWithoutExtension(outPath)}."); - })); - } - - await Task.WhenAll(tasks); - } - - private string BPRx64Hack(TextureBase header, string format) - { - if (header.ResourcePlatform == Platform.BPR && format.EndsWith("X64", StringComparison.Ordinal)) - { - header.SetResourceArch(Arch.x64); - return "BPR"; - } - return format; - } - - private static TextureBase? ConstructHeader(string path, string format, bool verbose) - { - if (verbose) Console.WriteLine($"Constructing {format} texture property data..."); - return format switch - { - "BPR" => new TextureBPR(path), - "BPRX64" => new TextureBPR(path), - "TUB" => new TexturePC(path), - "X360" => new TextureX360(path), - "PS3" => new TexturePS3(path), - _ => throw new InvalidPlatformException(), - }; - } - - private static void CopyProperties(TextureBase source, TextureBase destination) - { - if (source == null) throw new ArgumentNullException(nameof(source)); - if (destination == null) throw new ArgumentNullException(nameof(destination)); - - Type srcType = source.GetType(); - Type dstType = destination.GetType(); - - Type typeToReflect = srcType == dstType - ? srcType - : typeof(TextureBase); - - IEnumerable props = typeToReflect - .GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(p => p.CanRead - && p.CanWrite - && p.GetIndexParameters().Length == 0); - - foreach (PropertyInfo prop in props) - { - object? value = prop.GetValue(source); - prop.SetValue(destination, value); - } - } - - private bool TryConvertTexture(TextureBase srcTexture, TextureBase destTexture, byte[] bitmap, string outPath) - { - switch (srcTexture, destTexture) - { - case (TexturePS3 ps3, TextureBPR bpr): - if (ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8) - { - if (bpr.Format == DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM) - { - DDSTextureUtilities.A8R8G8B8toR8G8B8A8(bitmap, ps3.Width, ps3.Height, ps3.MipmapLevels); - break; - } - - if (bpr.Format == DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM) - { - DDSTextureUtilities.A8R8G8B8toB8G8R8A8(bitmap, ps3.Width, ps3.Height, ps3.MipmapLevels); - break; - } - } - bitmap = Array.Empty(); - return false; - case (TexturePS3 ps3, TexturePC tub): - if (ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8) - { - if (tub.Format == D3DFORMAT.D3DFMT_A8R8G8B8) - { - break; - } - - if (tub.Format == D3DFORMAT.D3DFMT_A8B8G8R8) - { - DDSTextureUtilities.A8R8G8B8toA8B8G8R8(bitmap, ps3.Width, ps3.Height, ps3.MipmapLevels); - break; - } - } - bitmap = Array.Empty(); - return false; - case (TexturePS3 ps3, TextureX360 x360): - if (ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 - && x360.Format.DataFormat == GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_8_8_8) - { - break; - } - bitmap = Array.Empty(); - return false; - case (TexturePC tub, TextureBPR bpr): - if (tub.Format == D3DFORMAT.D3DFMT_A8R8G8B8 - && bpr.Format == DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM) - DDSTextureUtilities.A8R8G8B8toB8G8R8A8(bitmap, destTexture.Width, destTexture.Height, destTexture.MipmapLevels); - if (tub.Format == D3DFORMAT.D3DFMT_A8B8G8R8 - && bpr.Format == DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM) - DDSTextureUtilities.A8B8G8R8toB8G8R8A8(bitmap, destTexture.Width, destTexture.Height, destTexture.MipmapLevels); - break; - case (TextureBPR bpr, TexturePS3 ps3): - if (ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8) - { - if (bpr.Format == DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM) - { - DDSTextureUtilities.R8G8B8A8toA8R8G8B8(bitmap, bpr.Width, bpr.Height, bpr.MipmapLevels); - bitmap = PS3TextureUtilities.EncodePS3A8R8G8B8(bitmap, bpr.Width, bpr.Height, bpr.MipmapLevels); - break; - } - - if (bpr.Format == DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM) - { - DDSTextureUtilities.B8G8R8A8toA8R8G8B8(bitmap, bpr.Width, bpr.Height, bpr.MipmapLevels); - bitmap = PS3TextureUtilities.EncodePS3A8R8G8B8(bitmap, bpr.Width, bpr.Height, bpr.MipmapLevels); - break; - } - } - bitmap = Array.Empty(); - return false; - case (TexturePC tub, TexturePS3 ps3): - if (ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8) - { - if (tub.Format == D3DFORMAT.D3DFMT_A8R8G8B8) - { - bitmap = PS3TextureUtilities.EncodePS3A8R8G8B8(bitmap, tub.Width, tub.Height, tub.MipmapLevels); - break; - } - - if (tub.Format == D3DFORMAT.D3DFMT_A8B8G8R8) - { - DDSTextureUtilities.A8B8G8R8toA8R8G8B8(bitmap, tub.Width, tub.Height, tub.MipmapLevels); - bitmap = PS3TextureUtilities.EncodePS3A8R8G8B8(bitmap, tub.Width, tub.Height, tub.MipmapLevels); - break; - } - } - bitmap = Array.Empty(); - return false; - case (TextureX360 x360, TexturePS3 ps3): - if (ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 - && x360.Format.DataFormat == GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_8_8_8) - { - bitmap = PS3TextureUtilities.EncodePS3A8R8G8B8(bitmap, x360.Width, x360.Height, x360.MipmapLevels); - break; - } - bitmap = Array.Empty(); - return false; - default: - bitmap = Array.Empty(); - return false; - }; - File.WriteAllBytes(outPath, bitmap); - return true; - } - - private static readonly Dictionary X360toTUBMapping = new() - { - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT1, D3DFORMAT.D3DFMT_DXT1 }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT2_3, D3DFORMAT.D3DFMT_DXT3 }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT4_5, D3DFORMAT.D3DFMT_DXT5 }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8, D3DFORMAT.D3DFMT_A8 }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_16_16_16_16, D3DFORMAT.D3DFMT_A16B16G16R16 }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_8_8_8, D3DFORMAT.D3DFMT_A8R8G8B8 }, - }; - - private static readonly Dictionary X360toBPRMapping = new() - { - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT1, DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT2_3, DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT4_5, DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8, DXGI_FORMAT.DXGI_FORMAT_A8_UNORM }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_16_16_16_16, DXGI_FORMAT.DXGI_FORMAT_R16G16B16A16_UNORM }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_8_8_8, DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM }, - }; - - private static readonly Dictionary TUBtoBPRMapping = new() - { - { D3DFORMAT.D3DFMT_DXT1, DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM }, - { D3DFORMAT.D3DFMT_DXT3, DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM }, - { D3DFORMAT.D3DFMT_DXT5, DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM }, - { D3DFORMAT.D3DFMT_A8, DXGI_FORMAT.DXGI_FORMAT_A8_UNORM }, - { D3DFORMAT.D3DFMT_A8B8G8R8, DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM }, - { D3DFORMAT.D3DFMT_A8R8G8B8, DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM }, - }; - - private static readonly Dictionary BPRtoTUBMapping = new() - { - { DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM, D3DFORMAT.D3DFMT_DXT1 }, - { DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM, D3DFORMAT.D3DFMT_DXT3 }, - { DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM, D3DFORMAT.D3DFMT_DXT5 }, - { DXGI_FORMAT.DXGI_FORMAT_A8_UNORM, D3DFORMAT.D3DFMT_A8 }, - { DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM, D3DFORMAT.D3DFMT_A8B8G8R8 }, - }; - - private static readonly Dictionary PS3toTUBMapping = new() - { - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT1, D3DFORMAT.D3DFMT_DXT1 }, - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT23, D3DFORMAT.D3DFMT_DXT3 }, - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT45, D3DFORMAT.D3DFMT_DXT5 }, - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_B8, D3DFORMAT.D3DFMT_A8 }, - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8, D3DFORMAT.D3DFMT_A8R8G8B8 }, - }; - - private static readonly Dictionary PS3toX360Mapping = new() - { - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT1, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT1 }, - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT23, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT2_3 }, - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT45, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT4_5 }, - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_B8, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_B }, - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_8_8_8 }, - }; - - private static readonly Dictionary TUBtoX360Mapping = new() - { - { D3DFORMAT.D3DFMT_DXT1, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT1 }, - { D3DFORMAT.D3DFMT_DXT3, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT2_3 }, - { D3DFORMAT.D3DFMT_DXT5, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT4_5 }, - }; - - private static readonly Dictionary X360toPS3Mapping = new() - { - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT1, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT1 }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT2_3, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT23 }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT4_5, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT45 }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_B, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_B8 }, - { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_8_8_8, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 }, - }; - - private static readonly Dictionary PS3toBPRMapping = new() - { - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT1, DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM }, - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT23, DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM }, - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT45, DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM }, - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_B8, DXGI_FORMAT.DXGI_FORMAT_A8_UNORM }, - { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8, DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM } - }; - - private static readonly Dictionary BPRtoPS3Mapping = new() - { - { DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT1 }, - { DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT23 }, - { DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT45 }, - { DXGI_FORMAT.DXGI_FORMAT_A8_UNORM, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_B8 }, - { DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 }, - { DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 }, - }; - - private static readonly Dictionary TUBtoPS3Mapping = new() - { - { D3DFORMAT.D3DFMT_DXT1, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT1 }, - { D3DFORMAT.D3DFMT_DXT3, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT23 }, - { D3DFORMAT.D3DFMT_DXT5, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT45 }, - { D3DFORMAT.D3DFMT_A8, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_B8 }, - { D3DFORMAT.D3DFMT_A8R8G8B8, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 }, - { D3DFORMAT.D3DFMT_A8B8G8R8, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 }, - }; - - private static readonly Dictionary BPRtoX360Mapping = new() - { - { DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT1 }, - { DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT2_3 }, - { DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT4_5 }, - }; -} diff --git a/Volatility/Operations/Resources/SaveResourceOperation.cs b/Volatility/Operations/Resources/SaveResourceOperation.cs deleted file mode 100644 index 3fcc0c4..0000000 --- a/Volatility/Operations/Resources/SaveResourceOperation.cs +++ /dev/null @@ -1,36 +0,0 @@ -using YamlDotNet.Serialization; - -using Volatility.Resources; -using Volatility.Utilities; - -namespace Volatility.Operations.Resources; - -internal class SaveResourceOperation -{ - private readonly ISerializer serializer; - - public SaveResourceOperation() - { - serializer = new SerializerBuilder() - .DisableAliases() - .WithTypeInspector(inner => new IncludeFieldsTypeInspector(inner)) - .WithTypeConverter(new ResourceYamlTypeConverter()) - .WithTypeConverter(new BitArrayYamlTypeConverter()) - .WithTypeConverter(new StrongIDYamlTypeConverter()) - .WithTypeConverter(new StringEnumYamlTypeConverter()) - .Build(); - } - - public async Task ExecuteAsync(Resource resource, string filePath) - { - string? directoryPath = Path.GetDirectoryName(filePath); - - if (!string.IsNullOrEmpty(directoryPath)) - { - Directory.CreateDirectory(directoryPath); - } - - string serializedString = serializer.Serialize(resource); - await File.WriteAllTextAsync(filePath, serializedString); - } -} diff --git a/Volatility/Operations/Resources/TextureToDDSOperation.cs b/Volatility/Operations/Resources/TextureToDDSOperation.cs deleted file mode 100644 index 3a7cf7a..0000000 --- a/Volatility/Operations/Resources/TextureToDDSOperation.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Volatility.Resources; -using Volatility.Utilities; - -namespace Volatility.Operations.Resources; - -internal class TextureToDDSOperation -{ - public async Task ExecuteAsync(IEnumerable sourceFiles, Platform platform, bool isX64, string? outputPath, bool overwrite, bool verbose) - { - string[] files = sourceFiles.ToArray(); - bool multipleInputs = files.Length > 1; - - List tasks = new(); - foreach (string sourceFile in files) - { - tasks.Add(Task.Run(async () => - { - TextureBase texture = (TextureBase)ResourceFactory.CreateResource(ResourceType.Texture, platform, sourceFile, isX64); - string sourceBitmapPath = TextureBitmapUtilities.GetSecondaryBitmapPath(sourceFile, texture.Unpacker); - - if (!File.Exists(sourceBitmapPath)) - { - throw new FileNotFoundException($"Failed to find associated bitmap data at path '{sourceBitmapPath}'."); - } - - byte[] bitmapData = TextureBitmapUtilities.ReadNormalizedBitmapData(texture, sourceBitmapPath); - byte[] ddsData = DDSTextureUtilities.CreateDDSFile(texture, bitmapData); - string destinationPath = ResolveOutputPath(sourceFile, texture.Unpacker, outputPath, multipleInputs); - - string? destinationDirectory = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(destinationDirectory)) - { - Directory.CreateDirectory(destinationDirectory); - } - - if (!overwrite && File.Exists(destinationPath)) - { - throw new IOException($"The file '{destinationPath}' already exists."); - } - - if (verbose) Console.WriteLine($"Writing DDS texture data to {destinationPath}..."); - await File.WriteAllBytesAsync(destinationPath, ddsData); - Console.WriteLine($"Wrote DDS for {Path.GetFileName(sourceFile)} to {destinationPath}."); - })); - } - - await Task.WhenAll(tasks); - } - - private static string ResolveOutputPath(string sourceFile, Unpacker unpacker, string? outputPath, bool multipleInputs) - { - string outputName = TextureBitmapUtilities.GetResourceBaseName(sourceFile, unpacker) + ".dds"; - - if (string.IsNullOrWhiteSpace(outputPath)) - { - return Path.Combine(Path.GetDirectoryName(sourceFile) ?? string.Empty, outputName); - } - - bool outputLooksLikeFile = Path.HasExtension(outputPath) - && string.Equals(Path.GetExtension(outputPath), ".dds", StringComparison.OrdinalIgnoreCase); - - if (!multipleInputs && outputLooksLikeFile) - { - return outputPath; - } - - return Path.Combine(outputPath, outputName); - } -} diff --git a/Volatility/Operations/StringTables/ImportStringTableOperation.cs b/Volatility/Operations/StringTables/ImportStringTableOperation.cs deleted file mode 100644 index 515dbc7..0000000 --- a/Volatility/Operations/StringTables/ImportStringTableOperation.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Text; -using System.Xml.Linq; - -using static Volatility.Utilities.ResourceIDUtilities; -using static Volatility.Utilities.DictUtilities; - -namespace Volatility.Operations.StringTables; - -internal class ImportStringTableOperation -{ - private readonly MergeStringTableEntriesOperation mergeOperation; - - public ImportStringTableOperation(MergeStringTableEntriesOperation mergeOperation) - { - this.mergeOperation = mergeOperation; - } - - public async Task ExecuteAsync(IEnumerable filePaths, Dictionary> entries, string endian, bool overwrite, bool verbose) - { - var results = await Task.WhenAll(filePaths.Select(path => ProcessFileAsync(path, endian, overwrite, verbose))); - - foreach (var fileResult in results) - { - mergeOperation.Execute(entries, fileResult, overwrite); - } - - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - } - - private async Task>> ProcessFileAsync(string filePath, string endian, bool overwrite, bool verbose) - { - var entriesByType = new Dictionary>(StringComparer.OrdinalIgnoreCase); - string fileName = Path.GetFileName(filePath)!; - string text = Encoding.UTF8.GetString(await File.ReadAllBytesAsync(filePath)); - - int start = text.IndexOf(""); - int end = text.IndexOf("") + "".Length; - if (start < 0 || end <= start) - { - if (verbose) Console.WriteLine($"Skipping (no table): {fileName}"); - return entriesByType; - } - - XDocument xmlDoc = XDocument.Parse(text[start..end]); - var entries = xmlDoc.Descendants("Resource") - .Select(x => new - { - Id = endian == "be" - ? FlipResourceIDEndian((string)x.Attribute("id")!) - : (string)x.Attribute("id")!, - Type = (string)x.Attribute("type")!, - Name = (string)x.Attribute("name")! - }).ToList(); - - foreach (var e in entries) - { - var dict = entriesByType.GetOrCreate(e.Type, () => new Dictionary()); - if (!dict.TryGetValue(e.Id, out StringTableResourceEntry? existing)) - { - dict[e.Id] = new StringTableResourceEntry { Name = e.Name, Appearances = { fileName } }; - if (verbose) Console.WriteLine($"Found {e.Type} entry in {Path.GetFileName(filePath)} - {e.Name}"); - } - else - { - if (overwrite) - existing.Name = e.Name; - if (!existing.Appearances.Contains(fileName)) - existing.Appearances.Add(fileName); - } - } - - return entriesByType; - } -} diff --git a/Volatility/Operations/StringTables/LoadResourceDictionaryOperation.cs b/Volatility/Operations/StringTables/LoadResourceDictionaryOperation.cs deleted file mode 100644 index 2c2e4e0..0000000 --- a/Volatility/Operations/StringTables/LoadResourceDictionaryOperation.cs +++ /dev/null @@ -1,30 +0,0 @@ -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - -namespace Volatility.Operations.StringTables; - -internal class LoadResourceDictionaryOperation -{ - private readonly IDeserializer deserializer; - - public LoadResourceDictionaryOperation() - { - deserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .Build(); - } - - public async Task>> ExecuteAsync(string yamlFile) - { - if (!File.Exists(yamlFile)) - { - return new Dictionary>(StringComparer.OrdinalIgnoreCase); - } - - string content = await File.ReadAllTextAsync(yamlFile); - - Dictionary>? result = deserializer.Deserialize>>(content); - - return result ?? new Dictionary>(StringComparer.OrdinalIgnoreCase); - } -} diff --git a/Volatility/Operations/StringTables/MergeStringTableEntriesOperation.cs b/Volatility/Operations/StringTables/MergeStringTableEntriesOperation.cs deleted file mode 100644 index bf9534f..0000000 --- a/Volatility/Operations/StringTables/MergeStringTableEntriesOperation.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace Volatility.Operations.StringTables; - -internal class MergeStringTableEntriesOperation -{ - public void Execute(Dictionary> target, Dictionary> source, bool overwrite) - { - foreach ((string typeKey, Dictionary resourceEntries) in source) - { - if (!target.TryGetValue(typeKey, out Dictionary? typeDict)) - { - target[typeKey] = new Dictionary(resourceEntries, StringComparer.OrdinalIgnoreCase); - continue; - } - - foreach ((string resourceKey, StringTableResourceEntry entry) in resourceEntries) - { - if (!typeDict.TryGetValue(resourceKey, out StringTableResourceEntry? existing)) - { - typeDict[resourceKey] = entry; - continue; - } - - if (overwrite) - { - existing.Name = entry.Name; - } - - foreach (string appearance in entry.Appearances) - { - if (!existing.Appearances.Contains(appearance)) - { - existing.Appearances.Add(appearance); - } - } - } - } - } -} diff --git a/Volatility/Resources/ResourceFactory.cs b/Volatility/Resources/ResourceFactory.cs deleted file mode 100644 index 47db2a9..0000000 --- a/Volatility/Resources/ResourceFactory.cs +++ /dev/null @@ -1,217 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using Volatility.Abstractions.Messaging; -using Volatility.Messaging; - -namespace Volatility.Resources; - -public static class ResourceFactory -{ - private static readonly Dictionary<(ResourceType, Platform), Func> resourceCreators = CreateResourceCreators(); - - private static Dictionary<(ResourceType, Platform), Func> CreateResourceCreators() - { - ResourceCreatorRegistry registry = new(); - - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - AddRegisteredResource(registry); - - return registry.Build(); - } - - public static Resource CreateResource(ResourceType resourceType, Platform platform, string filePath, bool x64 = false) - { - return CreateResource(resourceType, platform, filePath, VolatilityMessageHost.Sink, x64); - } - - public static Resource CreateResource( - ResourceType resourceType, - Platform platform, - string filePath, - IMessageSink sink, - bool x64 = false) - { - sink.Info($"Constructing {platform} {resourceType} resource property data...", MessageCategory.Resource, nameof(ResourceFactory)); - - var key = (resourceType, platform); - if (resourceCreators.TryGetValue(key, out var creator)) - { - Resource output = creator(filePath); - if (x64) - { - output.SetResourceArch(Arch.x64); - } - - return output; - } - - throw new InvalidPlatformException($"The '{resourceType}' type is not supported for the '{platform}' platform."); - } - - private static void AddRegisteredResource<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TResource>( - ResourceCreatorRegistry registry) - where TResource : Resource - { - registry.AddRegistrations(typeof(TResource)); - } - - private sealed class ResourceCreatorRegistry - { - private readonly Dictionary<(ResourceType, Platform), Func> _creators = new(); - - public void AddCreator(ResourceType resourceType, Platform platform, Func creator) - { - _creators.Add((resourceType, platform), creator); - } - - public void Add( - ResourceType resourceType, - Platform platform, - Func creator, - Action? afterCreate = null) - where TResource : Resource - { - AddCreator(resourceType, platform, path => - { - TResource resource = creator(path); - afterCreate?.Invoke(resource); - return resource; - }); - } - - public void AddRegistrations( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type resourceClass) - { - ResourceType resourceType = ResourceMetadata.GetResourceType(resourceClass); - ResourceRegistrationAttribute[] registrations = resourceClass - .GetCustomAttributes(inherit: false) - .ToArray(); - - foreach (ResourceRegistrationAttribute registration in registrations) - { - foreach (Platform platform in ExpandPlatforms(registration.Platforms)) - { - Func creator = registration.EndianMapped - ? CreateEndianMappedCreator(resourceClass, platform) - : CreatePathCreator(resourceClass); - - if (registration.PullAll) - { - creator = WrapWithPullAll(creator); - } - - AddCreator(resourceType, platform, creator); - } - } - } - - public Dictionary<(ResourceType, Platform), Func> Build() - { - return _creators; - } - - private static Func CreatePathCreator( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type resourceClass) - { - ConstructorInfo? stringCtor = resourceClass.GetConstructor([typeof(string)]); - if (stringCtor != null) - { - return path => (Resource)stringCtor.Invoke([path]); - } - - ConstructorInfo? stringEndianCtor = resourceClass.GetConstructor([typeof(string), typeof(Endian)]); - if (stringEndianCtor != null) - { - return path => (Resource)stringEndianCtor.Invoke([path, Endian.Agnostic]); - } - - throw new InvalidOperationException( - $"Could not find a usable string constructor for resource class '{resourceClass.FullName}'."); - } - - private static Func CreateEndianMappedCreator( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type resourceClass, - Platform platform) - { - if (platform == Platform.Agnostic) - { - throw new InvalidOperationException( - $"Resource class '{resourceClass.FullName}' cannot use endian-mapped registration with Platform.Agnostic."); - } - - ConstructorInfo? constructor = resourceClass.GetConstructor([typeof(string), typeof(Endian)]); - if (constructor == null) - { - throw new InvalidOperationException( - $"Resource class '{resourceClass.FullName}' must expose a (string path, Endian endianness) constructor for endian-mapped registration."); - } - - Endian endianness = platform switch - { - Platform.BPR or Platform.TUB => Endian.LE, - Platform.X360 or Platform.PS3 => Endian.BE, - _ => throw new InvalidOperationException($"No default endianness mapping exists for platform '{platform}'."), - }; - - return path => (Resource)constructor.Invoke([path, endianness]); - } - - private static Func WrapWithPullAll(Func creator) - { - return path => - { - Resource resource = creator(path); - resource.PullAll(); - return resource; - }; - } - - private static IEnumerable ExpandPlatforms(RegistrationPlatforms platforms) - { - if ((platforms & RegistrationPlatforms.Agnostic) != 0) - { - yield return Platform.Agnostic; - } - - if ((platforms & RegistrationPlatforms.BPR) != 0) - { - yield return Platform.BPR; - } - - if ((platforms & RegistrationPlatforms.TUB) != 0) - { - yield return Platform.TUB; - } - - if ((platforms & RegistrationPlatforms.X360) != 0) - { - yield return Platform.X360; - } - - if ((platforms & RegistrationPlatforms.PS3) != 0) - { - yield return Platform.PS3; - } - } - } -} diff --git a/Volatility/Services/DefaultProcessRunner.cs b/Volatility/Services/DefaultProcessRunner.cs deleted file mode 100644 index d5910ad..0000000 --- a/Volatility/Services/DefaultProcessRunner.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Diagnostics; -using Volatility.Abstractions.Services; -using Volatility.Utilities; - -namespace Volatility.Services; - -public sealed class DefaultProcessRunner : IProcessRunner -{ - public string RunAndCapture(string fileName, string arguments, string? workingDirectory = null) - { - return ProcessUtilities.RunAndCapture(fileName, arguments, workingDirectory); - } - - public string RunAndCapture(ProcessStartInfo startInfo) - { - return ProcessUtilities.RunAndCapture(startInfo); - } - - public void RunAndRelayOutput( - string fileName, - string arguments, - string? workingDirectory = null, - Action? stdoutHandler = null, - Action? stderrHandler = null) - { - ProcessUtilities.RunAndRelayOutput(fileName, arguments, workingDirectory, stdoutHandler, stderrHandler); - } - - public void RunAndRelayOutput( - ProcessStartInfo startInfo, - Action? stdoutHandler = null, - Action? stderrHandler = null) - { - ProcessUtilities.RunAndRelayOutput(startInfo, stdoutHandler, stderrHandler); - } -} diff --git a/Volatility/Services/DxcShaderCompilerAdapter.cs b/Volatility/Services/DxcShaderCompilerAdapter.cs deleted file mode 100644 index 899b0aa..0000000 --- a/Volatility/Services/DxcShaderCompilerAdapter.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Volatility.Abstractions.Services; -using Volatility.Resources; -using Volatility.Utilities; - -namespace Volatility.Services; - -public sealed class DxcShaderCompilerAdapter : IShaderCompiler -{ - public void CompileStagesToCSO(ShaderBase shader, IReadOnlyList stages, Func outputPathFactory) - { - DXCShaderCompiler.CompileStagesToCSO(shader, stages, outputPathFactory); - } - - public void CompileToCSO(ShaderBase shader, ShaderStageCompile stage, string outputPath) - { - DXCShaderCompiler.CompileToCSO(shader, stage, outputPath); - } -} diff --git a/Volatility/Services/EnvironmentPathProvider.cs b/Volatility/Services/EnvironmentPathProvider.cs deleted file mode 100644 index 55ff961..0000000 --- a/Volatility/Services/EnvironmentPathProvider.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Volatility.Abstractions.Services; -using Volatility.Utilities; - -namespace Volatility.Services; - -public sealed class EnvironmentPathProvider : IPathProvider -{ - public string GetDirectory(VolatilityPathLocation location) - { - return EnvironmentUtilities.GetEnvironmentDirectory(location switch - { - VolatilityPathLocation.Executable => EnvironmentUtilities.EnvironmentDirectory.Executable, - VolatilityPathLocation.Tools => EnvironmentUtilities.EnvironmentDirectory.Tools, - VolatilityPathLocation.Data => EnvironmentUtilities.EnvironmentDirectory.Data, - VolatilityPathLocation.ResourceDB => EnvironmentUtilities.EnvironmentDirectory.ResourceDB, - VolatilityPathLocation.Resources => EnvironmentUtilities.EnvironmentDirectory.Resources, - VolatilityPathLocation.Splicer => EnvironmentUtilities.EnvironmentDirectory.Splicer, - _ => throw new ArgumentOutOfRangeException(nameof(location), location, "Unknown path location!") - }); - } - - public string GetExecutableDirectory() - { - return EnvironmentUtilities.GetExecutableDirectory(); - } -} diff --git a/Volatility/Utilities/EnvironmentUtilities.cs b/Volatility/Utilities/EnvironmentUtilities.cs deleted file mode 100644 index 9c3f5a9..0000000 --- a/Volatility/Utilities/EnvironmentUtilities.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Diagnostics; -using System.Reflection; - -namespace Volatility.Utilities; - -public static class EnvironmentUtilities -{ - public enum EnvironmentDirectory - { - Executable, - Tools, - Data, - ResourceDB, - Resources, - Splicer, - } - - private static readonly IReadOnlyDictionary _relativePaths - = new Dictionary - { - [EnvironmentDirectory.Executable] = [], - [EnvironmentDirectory.Tools] = ["tools"], - [EnvironmentDirectory.Data] = ["data"], - [EnvironmentDirectory.ResourceDB] = ["data", "ResourceDB"], - [EnvironmentDirectory.Resources] = ["data", "Resources"], - [EnvironmentDirectory.Splicer] = ["data", "Splicer"], - }; - - public static string GetEnvironmentDirectory(EnvironmentDirectory dir) - { - var baseDir = GetExecutableDirectory(); - - if (!_relativePaths.TryGetValue(dir, out var segments)) - throw new ArgumentOutOfRangeException(nameof(dir), dir, "Unknown environment directory type!"); - - return segments.Length == 0 - ? baseDir - : Path.Combine(new[] { baseDir }.Concat(segments).ToArray()); - } - - public static string GetExecutableDirectory() - { - string? processPath = null; - var ppProp = typeof(Environment).GetProperty("ProcessPath", BindingFlags.Static | BindingFlags.Public); - if (ppProp != null) - { - processPath = ppProp.GetValue(null) as string; - } - - if (string.IsNullOrEmpty(processPath) && - Assembly.GetEntryAssembly()?.Location is string entryLoc && - !string.IsNullOrEmpty(entryLoc)) - { - processPath = entryLoc; - } - - if (string.IsNullOrEmpty(processPath)) - { - processPath = Process.GetCurrentProcess().MainModule?.FileName; - } - - if (string.IsNullOrEmpty(processPath)) - throw new InvalidOperationException("Unable to determine the process executable path."); - - var exeDir = Path.GetDirectoryName(processPath); - if (string.IsNullOrEmpty(exeDir)) - throw new InvalidOperationException($"Cannot determine directory of executable: {processPath}"); - - return exeDir; - } -} diff --git a/Volatility/Utilities/TextureBitmapUtilities.cs b/Volatility/Utilities/TextureBitmapUtilities.cs deleted file mode 100644 index 9f0d56a..0000000 --- a/Volatility/Utilities/TextureBitmapUtilities.cs +++ /dev/null @@ -1,82 +0,0 @@ -using Volatility.Resources; - -namespace Volatility.Utilities; - -internal static class TextureBitmapUtilities -{ - public static string GetResourceBaseName(string headerPath, Unpacker unpacker) - { - return GetHeaderBaseName(Path.GetFileName(headerPath), unpacker); - } - - public static string GetSecondaryBitmapPath(string headerPath, Unpacker unpacker) - { - string? directory = Path.GetDirectoryName(headerPath); - string baseName = GetResourceBaseName(headerPath, unpacker); - string secondarySuffix = GetSecondaryResourceSuffix(unpacker); - - return string.IsNullOrEmpty(directory) - ? baseName + secondarySuffix - : Path.Combine(directory, baseName + secondarySuffix); - } - - public static byte[] ReadNormalizedBitmapData(TextureBase texture, string bitmapPath) - { - return NormalizeBitmapData(texture, File.ReadAllBytes(bitmapPath)); - } - - public static void WriteNormalizedBitmapFile(TextureBase texture, string sourceBitmapPath, string outputPath, bool overwrite = true) - { - if (!overwrite && File.Exists(outputPath)) - { - throw new IOException($"The file '{outputPath}' already exists."); - } - - File.WriteAllBytes(outputPath, ReadNormalizedBitmapData(texture, sourceBitmapPath)); - } - - public static byte[] NormalizeBitmapData(TextureBase texture, byte[] bitmapData) - { - return texture switch - { - TextureX360 x360 when x360.Format.Tiled => X360TextureUtilities.GetUntiled360TextureData(x360, bitmapData), - TexturePS3 ps3 when ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 - => PS3TextureUtilities.DecodePS3A8R8G8B8(bitmapData, ps3.Width, ps3.Height, ps3.MipmapLevels), - _ => bitmapData, - }; - } - - private static string GetHeaderBaseName(string headerFileName, Unpacker unpacker) - { - string primarySuffix = GetPrimaryResourceSuffix(unpacker); - return headerFileName.EndsWith(primarySuffix, StringComparison.OrdinalIgnoreCase) - ? headerFileName[..^primarySuffix.Length] - : Path.GetFileNameWithoutExtension(headerFileName); - } - - private static string GetPrimaryResourceSuffix(Unpacker unpacker) - { - return unpacker switch - { - Unpacker.Bnd2Manager => "_1.bin", - Unpacker.DGI => ".dat", - Unpacker.YAP => "_primary.dat", - Unpacker.Raw => ".dat", - Unpacker.Volatility => throw new NotImplementedException(), - _ => throw new NotImplementedException(), - }; - } - - private static string GetSecondaryResourceSuffix(Unpacker unpacker) - { - return unpacker switch - { - Unpacker.Bnd2Manager => "_2.bin", - Unpacker.DGI => "_texture.dat", - Unpacker.YAP => "_secondary.dat", - Unpacker.Raw => "_texture.dat", - Unpacker.Volatility => throw new NotImplementedException(), - _ => throw new NotImplementedException(), - }; - } -} diff --git a/Volatility/Utilities/WorkspaceUtilities.cs b/Volatility/Utilities/WorkspaceUtilities.cs deleted file mode 100644 index cc12d46..0000000 --- a/Volatility/Utilities/WorkspaceUtilities.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace Volatility.Utilities; - -internal static class WorkspaceUtilities -{ - public static string FindRepositoryRoot() - { - foreach (string startPath in GetCandidateStartPaths()) - { - string? current = Path.GetFullPath(startPath); - while (!string.IsNullOrEmpty(current)) - { - if (File.Exists(Path.Combine(current, "Volatility.sln"))) - { - return current; - } - - current = Directory.GetParent(current)?.FullName; - } - } - - throw new DirectoryNotFoundException("Unable to locate the repository root containing Volatility.sln."); - } - - private static IEnumerable GetCandidateStartPaths() - { - yield return Directory.GetCurrentDirectory(); - yield return EnvironmentUtilities.GetExecutableDirectory(); - } -} diff --git a/Volatility/Volatility.csproj b/Volatility/Volatility.csproj deleted file mode 100644 index 7d751e5..0000000 --- a/Volatility/Volatility.csproj +++ /dev/null @@ -1,47 +0,0 @@ - - - - Exe - net9.0 - enable - enable - volatility_icon.ico - - - - - - - - $([System.DateTime]::Now.ToString("yyyy/MM/dd HH:mm:ss")) - true - true - true - - - - - - - - - <_Parameter1>BuildTimestamp - <_Parameter2>$(BuildTimestamp) - - - - - - - - - - - - PreserveNewest - PreserveNewest - - - - - diff --git a/src/Volatility.Cli/CLI/CLIMessageUtilities.cs b/src/Volatility.Cli/CLI/CLIMessageUtilities.cs new file mode 100644 index 0000000..73c1280 --- /dev/null +++ b/src/Volatility.Cli/CLI/CLIMessageUtilities.cs @@ -0,0 +1,93 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Messaging; + +namespace Volatility.CLI; + +internal static class CLIMessageUtilities +{ + public static void PublishIssues(IEnumerable issues, MessageCategory category = MessageCategory.CLI) + { + foreach (OperationIssue issue in issues) + { + PublishIssue(issue, category); + } + } + + public static void PublishIssue(OperationIssue issue, MessageCategory category = MessageCategory.CLI) + { + Publish(issue.Severity, issue.Message, category, issue.Source); + } + + public static void Verbose(string message, MessageCategory category = MessageCategory.CLI) + { + Publish(MessageSeverity.Verbose, message, category, typeof(TSource).Name); + } + + public static void Info(string message, MessageCategory category = MessageCategory.CLI) + { + Publish(MessageSeverity.Info, message, category, typeof(TSource).Name); + } + + public static void Success(string message, MessageCategory category = MessageCategory.CLI) + { + Publish(MessageSeverity.Success, message, category, typeof(TSource).Name); + } + + public static void Warning(string message, MessageCategory category = MessageCategory.CLI) + { + Publish(MessageSeverity.Warning, message, category, typeof(TSource).Name); + } + + public static void Error(string message, MessageCategory category = MessageCategory.CLI) + { + Publish(MessageSeverity.Error, message, category, typeof(TSource).Name); + } + + public static void Verbose(string source, string message, MessageCategory category = MessageCategory.CLI) + { + Publish(MessageSeverity.Verbose, message, category, source); + } + + public static void Info(string source, string message, MessageCategory category = MessageCategory.CLI) + { + Publish(MessageSeverity.Info, message, category, source); + } + + public static void Success(string source, string message, MessageCategory category = MessageCategory.CLI) + { + Publish(MessageSeverity.Success, message, category, source); + } + + public static void Warning(string source, string message, MessageCategory category = MessageCategory.CLI) + { + Publish(MessageSeverity.Warning, message, category, source); + } + + public static void Error(string source, string message, MessageCategory category = MessageCategory.CLI) + { + Publish(MessageSeverity.Error, message, category, source); + } + + private static void Publish(MessageSeverity severity, string message, MessageCategory category, string? source) + { + switch (severity) + { + case MessageSeverity.Verbose: + VolatilityMessageHost.Sink.Verbose(message, category, source); + break; + case MessageSeverity.Info: + VolatilityMessageHost.Sink.Info(message, category, source); + break; + case MessageSeverity.Success: + VolatilityMessageHost.Sink.Success(message, category, source); + break; + case MessageSeverity.Warning: + VolatilityMessageHost.Sink.Warning(message, category, source); + break; + default: + VolatilityMessageHost.Sink.Error(message, category, source); + break; + } + } +} diff --git a/Volatility/CLI/Commands/AutotestCommand.cs b/src/Volatility.Cli/CLI/Commands/AutotestCommand.cs similarity index 64% rename from Volatility/CLI/Commands/AutotestCommand.cs rename to src/Volatility.Cli/CLI/Commands/AutotestCommand.cs index 40fcc0d..6c4cc10 100644 --- a/Volatility/CLI/Commands/AutotestCommand.cs +++ b/src/Volatility.Cli/CLI/Commands/AutotestCommand.cs @@ -1,7 +1,14 @@ using System.Reflection; using System.Text; +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.CLI; +using Volatility.Core.Utilities; +using Volatility.Operations; using Volatility.Operations.Autotest; +using Volatility.Operations.Resources; using Volatility.Resources; using static Volatility.Utilities.TypeUtilities; @@ -11,6 +18,11 @@ namespace Volatility.CLI.Commands; internal class AutotestCommand : ICommand { + private readonly IPathProvider pathProvider; + private readonly IOperation gameAutotestOperation; + private readonly IOperation textureRoundTripOperation; + private readonly IResourceSerializer resourceSerializer; + public static string CommandToken => "autotest"; public static string CommandDescription => "Runs automatic tests to ensure the application is working." + " When provided a path & format, will import, export, then reimport specified file to ensure IO parity." + @@ -33,42 +45,76 @@ public async Task Execute() IReadOnlyList gamePaths = ParseGamePaths(); if (gamePaths.Count > 0) { - GameAutotestOperation operation = new(); - GameAutotestSummary summary = await operation.ExecuteAsync(new GameAutotestOptions + OperationResult result = await gameAutotestOperation.ExecuteAsync( + new GameAutotestRequest + { + GamePaths = gamePaths, + BundleToolPath = BundleToolPath, + WorkingDirectory = WorkingDirectory, + BundleLimitPerGame = BundleLimit, + ResourcesPerType = ResourceLimit, + KeepArtifacts = KeepArtifacts + }, + progress: null, + cancellationToken: CancellationToken.None); + + CLIMessageUtilities.PublishIssues(result.Issues); + if (!result.Success || result.Value == null) { - GamePaths = gamePaths, - BundleToolPath = BundleToolPath, - WorkingDirectory = WorkingDirectory, - BundleLimitPerGame = BundleLimit, - ResourcesPerType = ResourceLimit, - KeepArtifacts = KeepArtifacts - }); + throw OperationResultFactory.CreateException(result, "Game autotest failed to execute."); + } - Console.WriteLine( - $"AUTOTEST - Completed. Passed={summary.Passed}, Failed={summary.Failed}, Skipped={summary.Skipped}"); + GameAutotestSummary summary = result.Value; + + CLIMessageUtilities.Info( + $"AUTOTEST - Completed. Passed={summary.Passed}, Failed={summary.Failed}, Skipped={summary.Skipped}", + MessageCategory.Autotest); if (!string.IsNullOrWhiteSpace(RecapPath)) { string recapFilePath = WriteDetailedRecap(gamePaths, summary, RecapPath); - Console.WriteLine($"AUTOTEST - Detailed recap written to: {recapFilePath}"); + CLIMessageUtilities.Success( + $"AUTOTEST - Detailed recap written to: {recapFilePath}", + MessageCategory.Autotest); + } + + if (summary.Failed > 0) + { + throw new InvalidOperationException($"Game autotest completed with {summary.Failed} failed cases."); } return; } if (!string.IsNullOrEmpty(Path)) { - TextureBase? header = Format switch + if (string.IsNullOrEmpty(Format) || !TryParseEnum(Format, out Platform platform)) { - "BPR" => new TextureBPR(Path), - "TUB" => new TexturePC(Path), - "X360" => new TextureX360(Path), - "PS3" => new TexturePS3(Path), - _ => throw new InvalidPlatformException(), - }; + throw new InvalidPlatformException(); + } - header.PullAll(); + string inputPath = pathProvider.GetFullPath(Path); + TextureBase header; + using (FileStream fs = File.OpenRead(inputPath)) + { + header = (TextureBase)resourceSerializer.Deserialize( + fs, + ResourceType.Texture, + platform, + new ResourceSerializationOptions + { + FileName = inputPath + }); + } - TestHeaderRW($"autotest_{System.IO.Path.GetFileName(Path)}", header); + (bool Success, int MismatchCount) result = await TestHeaderRW($"autotest_{System.IO.Path.GetFileName(inputPath)}", header); + if (!result.Success) + { + throw new InvalidOperationException("Path autotest failed to execute successfully."); + } + if (result.MismatchCount > 0) + { + throw new InvalidOperationException($"Path autotest failed: {result.MismatchCount} mismatches detected."); + } return; } @@ -79,7 +125,9 @@ public async Task Execute() * will interpret from an input format, then write * them out to various platform formatted header files. */ - + int totalMismatches = 0; + bool allSuccessful = true; + // TUB Texture data test case TexturePC textureHeaderPC = new() { @@ -92,7 +140,9 @@ public async Task Execute() UsageFlags = TextureBaseUsageFlags.GRTexture }; - TestHeaderRW("autotest_header_PC.dat", textureHeaderPC); + (bool Success, int MismatchCount) pcResult = await TestHeaderRW("autotest_header_PC.dat", textureHeaderPC); + if (!pcResult.Success) allSuccessful = false; + totalMismatches += pcResult.MismatchCount; // BPR Texture data test case TextureBPR textureHeaderBPR = new() @@ -109,14 +159,18 @@ public async Task Execute() // SKIPPING BPR IMPORT AS IT'S NOT SUPPORTED YET // Write 32 bit test BPR header - TestHeaderRW("autotest_header_BPR.dat", textureHeaderBPR); + (bool Success, int MismatchCount) bprResult = await TestHeaderRW("autotest_header_BPR.dat", textureHeaderBPR); + if (!bprResult.Success) allSuccessful = false; + totalMismatches += bprResult.MismatchCount; textureHeaderBPR.SetResourceArch(Arch.x64); textureHeaderBPR.AssetName = "autotest_header_BPRx64"; textureHeaderBPR.ResourceID = ResourceID.HashFromString(textureHeaderBPR.AssetName); // Write 64 bit test BPR header - TestHeaderRW("autotest_header_BPRx64.dat", textureHeaderBPR); + (bool Success, int MismatchCount) bprX64Result = await TestHeaderRW("autotest_header_BPRx64.dat", textureHeaderBPR); + if (!bprX64Result.Success) allSuccessful = false; + totalMismatches += bprX64Result.MismatchCount; // PS3 Texture data test case TexturePS3 textureHeaderPS3 = new() @@ -130,7 +184,9 @@ public async Task Execute() UsageFlags = TextureBaseUsageFlags.GRTexture }; textureHeaderPS3.PushAll(); - TestHeaderRW("autotest_header_PS3.dat", textureHeaderPS3); + (bool Success, int MismatchCount) ps3Result = await TestHeaderRW("autotest_header_PS3.dat", textureHeaderPS3); + if (!ps3Result.Success) allSuccessful = false; + totalMismatches += ps3Result.MismatchCount; // X360 Texture data test case TextureX360 textureHeaderX360 = new() @@ -152,16 +208,29 @@ public async Task Execute() UsageFlags = TextureBaseUsageFlags.GRTexture }; textureHeaderX360.PushAll(); - TestHeaderRW("autotest_header_X360.dat", textureHeaderX360); + (bool Success, int MismatchCount) x360Result = await TestHeaderRW("autotest_header_X360.dat", textureHeaderX360); + if (!x360Result.Success) allSuccessful = false; + totalMismatches += x360Result.MismatchCount; // File name endian flip test case string endianFlipTestName = "12_34_56_78_texture.dat"; - Console.WriteLine($"AUTOTEST - Endian Test: Flipped endian {endianFlipTestName} to {FlipPathResourceIDEndian(endianFlipTestName)}"); + CLIMessageUtilities.Info( + $"AUTOTEST - Endian Test: Flipped endian {endianFlipTestName} to {FlipPathResourceIDEndian(endianFlipTestName)}", + MessageCategory.Autotest); + + if (!allSuccessful) + { + throw new InvalidOperationException("One or more synthetic autotests failed to execute successfully."); + } + if (totalMismatches > 0) + { + throw new InvalidOperationException($"Synthetic autotest failed: {totalMismatches} mismatches detected."); + } } public void SetArgs(Dictionary args) { - Format = (args.TryGetValue("format", out object? format) ? format as string : "auto").ToUpper(); + Format = (args.TryGetValue("format", out object? format) ? format as string : "auto")?.ToUpper() ?? "AUTO"; Path = args.TryGetValue("path", out object? path) ? path as string : ""; GamePath = args.TryGetValue("game", out object? game) ? game as string : ""; GamePaths = args.TryGetValue("games", out object? games) ? games as string : ""; @@ -183,109 +252,43 @@ public void SetArgs(Dictionary args) } } - public void TestHeaderRW(string name, TextureBase header, bool skipImport = false) + public async Task<(bool Success, int MismatchCount)> TestHeaderRW(string name, TextureBase header, bool skipImport = false) { - using (FileStream fs = new(name, FileMode.Create)) - { - // We don't want the command runner to catch the error - try - { - header.PushAll(); - } - catch (NotImplementedException) - { - Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($"A push isn't implemented for {header.GetType().Name}!"); - Console.ResetColor(); - } - - using (ResourceBinaryWriter writer = new(fs, header.ResourceEndian)) - { - Console.WriteLine($"AUTOTEST - Writing autotest {name} to working directory..."); - header.WriteToStream(writer); - writer.Close(); - } - - if (skipImport) - return; - - TextureBase? newHeader = System.ComponentModel.TypeDescriptor.CreateInstance( - provider: null, - objectType: header.GetType(), - argTypes: [typeof(string)], - args: new object[] { fs.Name }) as TextureBase; - - try - { - newHeader?.PullAll(); - } - catch (NotImplementedException) - { - Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($"A pull isn't implemented for {newHeader?.GetType().Name}!"); - Console.ResetColor(); - } + OperationResult opResult = await textureRoundTripOperation.ExecuteAsync( + new TextureRoundTripRequest(name, header, skipImport), + progress: null, + cancellationToken: CancellationToken.None); - TestCompareHeaders(header, newHeader); + if (!opResult.Success || opResult.Value == null) + { + CLIMessageUtilities.Error($"Failed to roundtrip header: {name}"); + return (false, 0); } - } - - public static void TestCompareHeaders(object exported, object imported) - { - Type type = exported.GetType(); - - Console.WriteLine(">> Comparing properties and fields of " + type.Name + ":"); - - PropertyInfo[] properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); - int mismatches = 0; - foreach (PropertyInfo property in properties) + TextureRoundTripResult result = opResult.Value; + if (!result.PushImplemented) { - - object value1 = property.GetValue(exported, null); - object value2 = property.GetValue(imported, null); - - if (IsComplexType(property.PropertyType)) - { - Console.WriteLine($" > Inspecting nested type {property.Name}:"); - TestCompareHeaders(value1, value2); - Console.WriteLine($" > Finished inspecting nested type {property.Name}"); - } - else if (!Equals(value1, value2)) - { - mismatches++; - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Mismatch - {property.Name}: Exported = {value1}, Imported = {value2}"); - Console.ResetColor(); - } + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($"A push isn't implemented for {header.GetType().Name}!"); + Console.ResetColor(); } - - FieldInfo[] fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance); - foreach (FieldInfo field in fields) + + if (skipImport) + return (true, 0); + + Console.WriteLine(">> Comparing properties and fields of " + header.GetType().Name + ":"); + foreach (PropertyMismatch mismatch in result.Mismatches) { - object value1 = field.GetValue(exported); - object value2 = field.GetValue(imported); - - if (IsComplexType(field.FieldType)) - { - Console.WriteLine($" > Inspecting nested type {field.Name}:"); - TestCompareHeaders(value1, value2); - Console.WriteLine($" > Finished inspecting nested type {field.Name}"); - } - else if (!Equals(value1, value2)) - { - mismatches++; - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Mismatch - {field.Name}: Exported = {value1}, Imported = {value2}"); - Console.ResetColor(); - } + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Mismatch - {mismatch.Path}: Exported = {mismatch.Exported}, Imported = {mismatch.Imported}"); + Console.ResetColor(); } - if (mismatches == 0) - Console.ForegroundColor = ConsoleColor.Green; - - Console.WriteLine(">> Finished Comparing properties and fields of " + type.Name + $" - {mismatches} mismatches"); + Console.ForegroundColor = result.Mismatches.Count == 0 ? ConsoleColor.Green : ConsoleColor.Red; + Console.WriteLine(">> Finished Comparing properties and fields of " + header.GetType().Name + $" - {result.Mismatches.Count} mismatches"); Console.ResetColor(); + + return (true, result.Mismatches.Count); } private IReadOnlyList ParseGamePaths() @@ -310,7 +313,7 @@ private IReadOnlyList ParseGamePaths() .ToList(); } - private static string WriteDetailedRecap(IReadOnlyList gamePaths, GameAutotestSummary summary, string outputPath) + private string WriteDetailedRecap(IReadOnlyList gamePaths, GameAutotestSummary summary, string outputPath) { string recapPath = ResolveRecapPath(outputPath); StringBuilder builder = new(); @@ -404,17 +407,17 @@ private static string WriteDetailedRecap(IReadOnlyList gamePaths, GameAu return recapPath; } - private static string ResolveRecapPath(string outputPath) + private string ResolveRecapPath(string outputPath) { - string fullPath = System.IO.Path.GetFullPath(outputPath); + string fullPath = pathProvider.GetFullPath(outputPath); bool looksLikeDirectory = outputPath.EndsWith(System.IO.Path.DirectorySeparatorChar) || outputPath.EndsWith(System.IO.Path.AltDirectorySeparatorChar) || string.IsNullOrWhiteSpace(System.IO.Path.GetExtension(fullPath)); - if (Directory.Exists(fullPath) || looksLikeDirectory) + if (pathProvider.DirectoryExists(fullPath) || looksLikeDirectory) { - Directory.CreateDirectory(fullPath); + pathProvider.CreateDirectory(fullPath); string timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); return System.IO.Path.Combine(fullPath, $"autotest_recap_{timestamp}.md"); } @@ -422,7 +425,7 @@ private static string ResolveRecapPath(string outputPath) string? directory = System.IO.Path.GetDirectoryName(fullPath); if (!string.IsNullOrWhiteSpace(directory)) { - Directory.CreateDirectory(directory); + pathProvider.CreateDirectory(directory); } return fullPath; @@ -448,5 +451,15 @@ private static string EscapeMarkdownCell(string value) .Trim(); } - public AutotestCommand() { } + public AutotestCommand( + IPathProvider pathProvider, + IOperation gameAutotestOperation, + IOperation textureRoundTripOperation, + IResourceSerializer resourceSerializer) + { + this.pathProvider = pathProvider; + this.gameAutotestOperation = gameAutotestOperation; + this.textureRoundTripOperation = textureRoundTripOperation; + this.resourceSerializer = resourceSerializer; + } } diff --git a/Volatility/CLI/Commands/ClearCommand.cs b/src/Volatility.Cli/CLI/Commands/ClearCommand.cs similarity index 77% rename from Volatility/CLI/Commands/ClearCommand.cs rename to src/Volatility.Cli/CLI/Commands/ClearCommand.cs index 93d1fa0..4fd97ae 100644 --- a/Volatility/CLI/Commands/ClearCommand.cs +++ b/src/Volatility.Cli/CLI/Commands/ClearCommand.cs @@ -5,7 +5,11 @@ internal class ClearCommand : ICommand public static string CommandToken => "clear"; public static string CommandDescription => "Clears the console."; public static string CommandParameters => ""; - public async Task Execute() => Console.Clear(); + public Task Execute() + { + Console.Clear(); + return Task.CompletedTask; + } public void SetArgs(Dictionary args) { } public ClearCommand() { } } diff --git a/src/Volatility.Cli/CLI/Commands/CreateResourceCommand.cs b/src/Volatility.Cli/CLI/Commands/CreateResourceCommand.cs new file mode 100644 index 0000000..2459385 --- /dev/null +++ b/src/Volatility.Cli/CLI/Commands/CreateResourceCommand.cs @@ -0,0 +1,127 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.CLI; +using Volatility.Operations.Resources; +using Volatility.Resources; + +using static Volatility.Utilities.ResourceIDUtilities; + +namespace Volatility.CLI.Commands; + +internal class CreateResourceCommand : ICommand +{ + private readonly IPathProvider pathProvider; + private readonly IOperation createOperation; + private readonly IOperation saveOperation; + + public static string CommandToken => "CreateResource"; + public static string CommandDescription => "Creates a new resource file with default/empty fields."; + public static string CommandParameters => "[--overwrite] --type= --format= --name= [--id=] [--outpath=]"; + + public string? ResType { get; set; } + public string? Format { get; set; } + public string? Name { get; set; } + public string? ResourceId { get; set; } + public string? OutputPath { get; set; } + public bool Overwrite { get; set; } + + public async Task Execute() + { + if (string.IsNullOrWhiteSpace(ResType)) + { + CLIMessageUtilities.Error("Error: No resource type specified! (--type)"); + return; + } + + if (string.IsNullOrWhiteSpace(Format)) + { + CLIMessageUtilities.Error("Error: No format specified! (--format)"); + return; + } + + if (string.IsNullOrWhiteSpace(Name) && string.IsNullOrWhiteSpace(OutputPath)) + { + CLIMessageUtilities.Error("Error: No resource name or output path specified! (--name or --outpath)"); + return; + } + + string formatValue = Format ?? string.Empty; + bool isX64 = formatValue.EndsWith("x64", StringComparison.OrdinalIgnoreCase); + if (isX64) + { + formatValue = formatValue[..^3]; + } + + if (!Volatility.Utilities.TypeUtilities.TryParseEnum(formatValue, out Platform platform)) + { + CLIMessageUtilities.Error("Error: Invalid file format specified!"); + return; + } + + if (!Volatility.Utilities.TypeUtilities.TryParseEnum(ResType, out ResourceType resourceType)) + { + CLIMessageUtilities.Error("Error: Invalid resource type specified!"); + return; + } + + ResourceID? parsedId = null; + if (!string.IsNullOrWhiteSpace(ResourceId)) + { + if (!TryParseResourceID(ResourceId, out ResourceID id)) + { + CLIMessageUtilities.Error("Error: Invalid resource ID specified! (--id)"); + return; + } + + parsedId = id; + } + + OperationResult createResult = await createOperation.ExecuteAsync( + new CreateResourceRequest(resourceType, platform, Name, OutputPath, parsedId, isX64), + progress: null, + cancellationToken: CancellationToken.None); + CLIMessageUtilities.PublishIssues(createResult.Issues, MessageCategory.Resource); + + if (!createResult.Success || createResult.Value == null) + { + return; + } + + CreateResourceResult result = createResult.Value; + OperationResult saveResult = await saveOperation.ExecuteAsync( + new SaveResourceRequest(result.Resource, result.ResourcePath, Overwrite), + progress: null, + cancellationToken: CancellationToken.None); + CLIMessageUtilities.PublishIssues(saveResult.Issues, MessageCategory.Resource); + + if (!saveResult.Success) + { + return; + } + + CLIMessageUtilities.Success( + $"Created {Path.GetFileName(result.ResourcePath)} at {pathProvider.GetFullPath(result.ResourcePath)}.", + MessageCategory.Resource); + } + + public void SetArgs(Dictionary args) + { + ResType = (args.TryGetValue("type", out object? restype) ? restype as string : string.Empty)?.ToUpper(); + Format = (args.TryGetValue("format", out object? format) ? format as string : string.Empty)?.ToUpper(); + Name = args.TryGetValue("name", out object? name) ? name as string : ""; + ResourceId = args.TryGetValue("id", out object? id) ? id as string : ""; + OutputPath = args.TryGetValue("outpath", out object? outpath) ? outpath as string : ""; + Overwrite = args.TryGetValue("overwrite", out var ow) && (bool)ow; + } + + public CreateResourceCommand( + IPathProvider pathProvider, + IOperation createOperation, + IOperation saveOperation) + { + this.pathProvider = pathProvider; + this.createOperation = createOperation; + this.saveOperation = saveOperation; + } +} diff --git a/Volatility/CLI/Commands/ExitCommand.cs b/src/Volatility.Cli/CLI/Commands/ExitCommand.cs similarity index 70% rename from Volatility/CLI/Commands/ExitCommand.cs rename to src/Volatility.Cli/CLI/Commands/ExitCommand.cs index 2bcce81..8b57a01 100644 --- a/Volatility/CLI/Commands/ExitCommand.cs +++ b/src/Volatility.Cli/CLI/Commands/ExitCommand.cs @@ -1,3 +1,5 @@ +using Volatility.CLI; + namespace Volatility.CLI.Commands; internal class ExitCommand : ICommand @@ -6,13 +8,14 @@ internal class ExitCommand : ICommand public static string CommandDescription => "Exits the application."; public static string CommandParameters => ""; - public async Task Execute() + public Task Execute() { - Console.WriteLine("Exiting Volatility..."); + CLIMessageUtilities.Info("Exiting Volatility..."); Environment.Exit(0); + return Task.CompletedTask; } public void SetArgs(Dictionary args) { } public ExitCommand() { } -} \ No newline at end of file +} diff --git a/src/Volatility.Cli/CLI/Commands/ExportResourceCommand.cs b/src/Volatility.Cli/CLI/Commands/ExportResourceCommand.cs new file mode 100644 index 0000000..98c8643 --- /dev/null +++ b/src/Volatility.Cli/CLI/Commands/ExportResourceCommand.cs @@ -0,0 +1,173 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.CLI; +using Volatility.Operations.Resources; +using Volatility.Resources; + +namespace Volatility.CLI.Commands; + +internal class ExportResourceCommand : ICommand +{ + private readonly IPathProvider pathProvider; + private readonly IOperation loadOperation; + private readonly IOperation exportOperation; + + public static string CommandToken => "ExportResource"; + public static string CommandDescription => "Exports information and relevant data from an imported/created resource into a platform's format."; + public static string CommandParameters => "--recurse --overwrite --type= --format= --respath= --outpath= [--imports=] [--importsfile]"; + + public string? Format { get; set; } + public string? ResourcePath { get; set; } + public string? OutputPath { get; set; } + public string? Imports { get; set; } + public bool ImportsFile { get; set; } + public bool Overwrite { get; set; } + public bool Recursive { get; set; } + + public async Task Execute() + { + if (string.IsNullOrEmpty(Format)) + { + CLIMessageUtilities.Error("Error: No resource path specified! (--respath)"); + return; + } + + if (string.IsNullOrEmpty(ResourcePath)) + { + CLIMessageUtilities.Error("Error: No resource path specified! (--respath)"); + return; + } + + if (string.IsNullOrEmpty(OutputPath)) + { + CLIMessageUtilities.Error("Error: No output path specified! (--outpath)"); + return; + } + + string filePath = Path.Combine(pathProvider.GetDirectory(VolatilityPathLocation.Resources), ResourcePath); + string[] sourceFiles = pathProvider.GetFilePaths(filePath, VolatilityFilePathFilter.Any, Recursive); + + if (sourceFiles.Length == 0) + { + CLIMessageUtilities.Error($"Error: No valid file(s) found at the specified path ({ResourcePath}). Ensure the path exists and spaces are properly enclosed. (--path)"); + return; + } + + if (!Volatility.Utilities.TypeUtilities.TryParseEnum(Format, out Platform platform)) + { + CLIMessageUtilities.Error("Error: Invalid file format specified!"); + return; + } + + Unpacker? importUnpackerOverride = null; + if (!string.IsNullOrEmpty(Imports) && !string.Equals(Imports, "DEFAULT", StringComparison.OrdinalIgnoreCase)) + { + if (!Volatility.Utilities.TypeUtilities.TryParseEnum(Imports, out Unpacker parsedUnpacker)) + { + CLIMessageUtilities.Error("Error: Invalid imports export mode specified!"); + return; + } + + importUnpackerOverride = parsedUnpacker; + } + + bool multipleOutputs = sourceFiles.Length > 1; + string inputRoot = pathProvider.DirectoryExists(filePath) + ? pathProvider.GetFullPath(filePath) + : Path.GetDirectoryName(pathProvider.GetFullPath(filePath)) ?? pathProvider.GetFullPath(filePath); + + List tasks = []; + foreach (string sourceFile in sourceFiles) + { + CLIMessageUtilities.Info(sourceFile, MessageCategory.Resource); + + tasks.Add(Task.Run(async () => + { + if (!pathProvider.FileExists(sourceFile)) + { + CLIMessageUtilities.Error("Error: Invalid file import path specified!"); + return; + } + + if (!Volatility.Utilities.TypeUtilities.TryParseEnum(Path.GetExtension(sourceFile).TrimStart('.'), out ResourceType resourceType)) + { + CLIMessageUtilities.Error("Error: Resource type is invalid!"); + return; + } + + OperationResult loadResult = await loadOperation.ExecuteAsync( + new LoadResourceRequest(sourceFile, resourceType, platform), + progress: null, + cancellationToken: CancellationToken.None); + CLIMessageUtilities.PublishIssues(loadResult.Issues, MessageCategory.Resource); + + if (!loadResult.Success || loadResult.Value == null) + { + return; + } + + string outputPath = ResolveOutputPath(sourceFile, inputRoot, OutputPath, multipleOutputs); + + OperationResult exportResult = await exportOperation.ExecuteAsync( + new ExportResourceRequest( + loadResult.Value.Resource, + outputPath, + platform, + importUnpackerOverride, + ImportsFile, + Overwrite), + progress: null, + cancellationToken: CancellationToken.None); + CLIMessageUtilities.PublishIssues(exportResult.Issues, MessageCategory.Resource); + + if (!exportResult.Success) + { + return; + } + + CLIMessageUtilities.Success( + $"Exported {Path.GetFileName(sourceFile)} as {pathProvider.GetFullPath(outputPath)}.", + MessageCategory.Resource); + })); + } + + await Task.WhenAll(tasks); + } + + public void SetArgs(Dictionary args) + { + Format = (args.TryGetValue("format", out object? format) ? format as string : "")?.ToUpper(); + ResourcePath = args.TryGetValue("respath", out object? respath) ? respath as string : ""; + OutputPath = args.TryGetValue("outpath", out object? outpath) ? outpath as string : ""; + Imports = args.TryGetValue("imports", out object? imports) ? imports as string : ""; + ImportsFile = args.TryGetValue("importsfile", out var importsfile) && (bool)importsfile; + Overwrite = args.TryGetValue("overwrite", out var ow) && (bool)ow; + Recursive = args.TryGetValue("recurse", out var re) && (bool)re; + } + + public ExportResourceCommand( + IPathProvider pathProvider, + IOperation loadOperation, + IOperation exportOperation) + { + this.pathProvider = pathProvider; + this.loadOperation = loadOperation; + this.exportOperation = exportOperation; + } + + private static string ResolveOutputPath( + string sourceFile, + string inputRoot, + string outputPath, + bool multipleOutputs) + { + if (!multipleOutputs) + { + return outputPath; + } + + string relativePath = Path.GetRelativePath(inputRoot, sourceFile); + return Path.Combine(outputPath, relativePath); + } +} diff --git a/src/Volatility.Cli/CLI/Commands/HelpCommand.cs b/src/Volatility.Cli/CLI/Commands/HelpCommand.cs new file mode 100644 index 0000000..a470455 --- /dev/null +++ b/src/Volatility.Cli/CLI/Commands/HelpCommand.cs @@ -0,0 +1,57 @@ +using System.Reflection; + +using static Volatility.Utilities.TypeUtilities; + +namespace Volatility.CLI.Commands; + +internal class HelpCommand : ICommand +{ + public static string CommandToken => "help"; + public static string CommandDescription => "Displays all available commands & parameters for individual commands."; + public static string CommandParameters => "[command]"; + + public string? WantedCommand { get; set; } + + + public Task Execute() + { + if (!string.IsNullOrEmpty(WantedCommand)) + { + Frontend.Commands.TryGetValue(WantedCommand.ToLowerInvariant(), out Type? command); + if (command != null) + { + ShowUsage(command); + return Task.CompletedTask; + } + } + + CLIMessageUtilities.Info("Available commands:"); + foreach (var command in Frontend.Commands.Values.Distinct()) + { + string? commandName = GetStaticPropertyValue(command, "CommandToken"); + + if (string.IsNullOrEmpty(commandName)) + continue; + + CLIMessageUtilities.Info($" {commandName} - {GetStaticPropertyValue(command, "CommandDescription")}"); + } + CLIMessageUtilities.Info("For information on command arguments, run: help ."); + return Task.CompletedTask; + } + + public void SetArgs(Dictionary args) + { + WantedCommand = args.TryGetValue("commandName", out object? name) ? name as string : ""; + } + + private static void ShowUsage(Type commandType) + { + string? token = GetStaticPropertyValue(commandType, nameof(CommandToken)); + string? parameters = GetStaticPropertyValue(commandType, nameof(CommandParameters)); + string? description = GetStaticPropertyValue(commandType, nameof(CommandDescription)); + + CLIMessageUtilities.Info(nameof(HelpCommand), $"Usage:\n {token ?? ""} {parameters ?? ""}\n{description ?? ""}"); + } + + public HelpCommand() { } +} diff --git a/src/Volatility.Cli/CLI/Commands/ImportResourceCommand.cs b/src/Volatility.Cli/CLI/Commands/ImportResourceCommand.cs new file mode 100644 index 0000000..35c3da5 --- /dev/null +++ b/src/Volatility.Cli/CLI/Commands/ImportResourceCommand.cs @@ -0,0 +1,139 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Operations; +using Volatility.Operations.Resources; +using Volatility.Resources; +using Volatility.Utilities; + +namespace Volatility.CLI.Commands; + +internal class ImportResourceCommand : ICommand +{ + private readonly IPathProvider pathProvider; + private readonly IOperation importOperation; + private readonly IOperation saveOperation; + + public static string CommandToken => "ImportResource"; + public static string CommandDescription => "Imports information and relevant data from a specified platform's resource into a standardized format."; + public static string CommandParameters => "--recurse --overwrite --type= --format= --path="; + + public string? ResType { get; set; } + public string? Format { get; set; } + public string? ImportPath { get; set; } + public bool Overwrite { get; set; } + public bool Recursive { get; set; } + + public async Task Execute() + { + if (ResType == "AUTO") + { + CLIMessageUtilities.Error("Error: Automatic typing is not supported yet! Please specify a type (--type)"); + return; + } + + if (string.IsNullOrEmpty(ImportPath)) + { + CLIMessageUtilities.Error("Error: No import path specified! (--path)"); + return; + } + + string fullImportPath = pathProvider.GetFullPath(ImportPath); + if (!pathProvider.FileExists(fullImportPath) && !pathProvider.DirectoryExists(fullImportPath)) + { + CLIMessageUtilities.Error("Error: Invalid file import path specified!"); + return; + } + + string[] sourceFiles = pathProvider.GetFilePaths(fullImportPath, VolatilityFilePathFilter.Header, Recursive); + + if (sourceFiles.Length == 0) + { + CLIMessageUtilities.Error($"Error: No valid file(s) found at the specified path ({ImportPath}). Ensure the path exists and spaces are properly enclosed. (--path)"); + return; + } + + string formatValue = Format ?? string.Empty; + bool isX64 = formatValue.EndsWith("x64", StringComparison.OrdinalIgnoreCase); + if (isX64) + formatValue = formatValue[..^3]; + + if (!TypeUtilities.TryParseEnum(formatValue, out Platform platform)) + { + throw new InvalidPlatformException("Error: Invalid file format specified!"); + } + + if (string.IsNullOrEmpty(ResType) || !TypeUtilities.TryParseEnum(ResType, out ResourceType resType)) + { + CLIMessageUtilities.Error("Error: Invalid resource type specified!"); + return; + } + + string resourcesDirectory = pathProvider.GetDirectory(VolatilityPathLocation.Resources); + string toolsDirectory = pathProvider.GetDirectory(VolatilityPathLocation.Tools); + string splicerDirectory = pathProvider.GetDirectory(VolatilityPathLocation.Splicer); + + List tasks = new List(); + foreach (string sourceFile in sourceFiles) + { + tasks.Add(Task.Run(async () => + { + OperationResult importResult = await importOperation.ExecuteAsync( + new ImportResourceRequest( + resType, + platform, + sourceFile, + isX64, + resourcesDirectory, + toolsDirectory, + splicerDirectory, + Overwrite), + progress: null, + cancellationToken: CancellationToken.None); + CLIMessageUtilities.PublishIssues(importResult.Issues, MessageCategory.Resource); + + if (!importResult.Success || importResult.Value == null) + { + throw OperationResultFactory.CreateException(importResult, "Failed to import resource."); + } + + ImportResourceResult result = importResult.Value; + + OperationResult saveResult = await saveOperation.ExecuteAsync( + new SaveResourceRequest(result.Resource, result.ResourcePath, Overwrite), + progress: null, + cancellationToken: CancellationToken.None); + + if (!saveResult.Success) + { + throw OperationResultFactory.CreateException(saveResult, "Failed to save imported resource."); + } + + CLIMessageUtilities.Success( + $"Imported {Path.GetFileName(sourceFile)} as {pathProvider.GetFullPath(result.ResourcePath)}.", + MessageCategory.Resource); + })); + } + + await Task.WhenAll(tasks); + } + + public void SetArgs(Dictionary args) + { + ResType = (args.TryGetValue("type", out object? restype) ? restype as string : "auto")?.ToUpper(); + Format = (args.TryGetValue("format", out object? format) ? format as string : "auto")?.ToUpper(); + ImportPath = args.TryGetValue("path", out object? path) ? path as string : ""; + Overwrite = args.TryGetValue("overwrite", out var ow) && (bool)ow; + Recursive = args.TryGetValue("recurse", out var re) && (bool)re; + } + + public ImportResourceCommand( + IPathProvider pathProvider, + IOperation importOperation, + IOperation saveOperation) + { + this.pathProvider = pathProvider; + this.importOperation = importOperation; + this.saveOperation = saveOperation; + } +} diff --git a/src/Volatility.Cli/CLI/Commands/ImportStringTableCommand.cs b/src/Volatility.Cli/CLI/Commands/ImportStringTableCommand.cs new file mode 100644 index 0000000..21aecfa --- /dev/null +++ b/src/Volatility.Cli/CLI/Commands/ImportStringTableCommand.cs @@ -0,0 +1,140 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.CLI; +using Volatility.Operations.StringTables; + +namespace Volatility.CLI.Commands; + +internal class ImportStringTableCommand : ICommand +{ + private readonly IPathProvider pathProvider; + private readonly IStringTableStore stringTableStore; + private readonly IOperation loadOperation; + private readonly IOperation importOperation; + + public static string CommandToken => "ImportStringTable"; + public static string CommandDescription => "Imports entries into the ResourceDB from files containing a ResourceStringTable."; + public static string CommandParameters => "[--verbose] [--overwrite] [--recurse] [--endian=] [--version=] --path="; + + public string? Endian { get; set; } + public string? ImportPath { get; set; } + public string? Version { get; set; } + public bool Overwrite { get; set; } + public bool Recursive { get; set; } + public bool Verbose { get; set; } + + public async Task Execute() + { + if (string.IsNullOrEmpty(ImportPath)) + { + CLIMessageUtilities.Error("Error: No import path specified! (--path)"); + return; + } + + string[] filePaths = pathProvider.GetFilePaths(ImportPath, VolatilityFilePathFilter.Any, Recursive); + if (filePaths.Length == 0) + { + CLIMessageUtilities.Error("Error: No files or folders found within the specified path!"); + return; + } + + CLIMessageUtilities.Info( + "Importing data from ResourceStringTables into the ResourceDB... this may take a while!", + MessageCategory.StringTable); + + string directoryPath = pathProvider.GetDirectory(VolatilityPathLocation.ResourceDB); + pathProvider.CreateDirectory(directoryPath); + + string version = Version ?? "v2"; + + try + { + if (version == "v1") + { + string jsonFile = Path.Combine(directoryPath, "ResourceDB.json"); + var importedEntries = new Dictionary>(StringComparer.OrdinalIgnoreCase); + OperationResult importResult = await importOperation.ExecuteAsync( + new ImportStringTableRequest(filePaths, importedEntries, Endian ?? "le", Overwrite, Verbose), + progress: null, + cancellationToken: CancellationToken.None); + CLIMessageUtilities.PublishIssues(importResult.Issues, MessageCategory.StringTable); + + if (!importResult.Success) + { + return; + } + + var legacyEntries = await stringTableStore.LoadJsonAsync(jsonFile); + stringTableStore.MergeLegacyEntries(legacyEntries, importedEntries, Overwrite); + await stringTableStore.WriteJsonAsync(jsonFile, legacyEntries); + + CLIMessageUtilities.Success( + $"Finished importing all ResourceDB (v1) data at {jsonFile}.", + MessageCategory.StringTable); + return; + } + + if (version != "v2") + { + CLIMessageUtilities.Error("Error: Invalid version specified! (--version must be v1 or v2)"); + return; + } + + string yamlFile = Path.Combine(directoryPath, "ResourceDB.yaml"); + OperationResult loadResult = await loadOperation.ExecuteAsync( + new LoadResourceDictionaryRequest(yamlFile), + progress: null, + cancellationToken: CancellationToken.None); + CLIMessageUtilities.PublishIssues(loadResult.Issues, MessageCategory.StringTable); + + if (!loadResult.Success || loadResult.Value == null) + { + return; + } + + OperationResult importV2Result = await importOperation.ExecuteAsync( + new ImportStringTableRequest(filePaths, loadResult.Value.Entries, Endian ?? "le", Overwrite, Verbose), + progress: null, + cancellationToken: CancellationToken.None); + CLIMessageUtilities.PublishIssues(importV2Result.Issues, MessageCategory.StringTable); + + if (!importV2Result.Success) + { + return; + } + + await stringTableStore.WriteYamlAsync(yamlFile, loadResult.Value.Entries); + + CLIMessageUtilities.Success( + $"Finished importing all ResourceDB (v2) data at {yamlFile}.", + MessageCategory.StringTable); + } + catch (Exception ex) + { + CLIMessageUtilities.Error($"Error: {ex.Message}"); + } + } + + public void SetArgs(Dictionary args) + { + Endian = (args.TryGetValue("endian", out object? format) ? format as string : "le")?.ToLowerInvariant() ?? "le"; + ImportPath = args.TryGetValue("path", out object? path) ? path as string : ""; + Version = (args.TryGetValue("version", out object? version) ? version as string : "v2")?.ToLowerInvariant() ?? "v2"; + Overwrite = args.TryGetValue("overwrite", out var ow) && (bool)ow; + Recursive = args.TryGetValue("recurse", out var re) && (bool)re; + Verbose = args.TryGetValue("verbose", out var ve) && (bool)ve; + } + + public ImportStringTableCommand( + IPathProvider pathProvider, + IStringTableStore stringTableStore, + IOperation loadOperation, + IOperation importOperation) + { + this.pathProvider = pathProvider; + this.stringTableStore = stringTableStore; + this.loadOperation = loadOperation; + this.importOperation = importOperation; + } +} diff --git a/Volatility/CLI/Commands/NullCommand.cs b/src/Volatility.Cli/CLI/Commands/NullCommand.cs similarity index 75% rename from Volatility/CLI/Commands/NullCommand.cs rename to src/Volatility.Cli/CLI/Commands/NullCommand.cs index b372477..29a1e24 100644 --- a/Volatility/CLI/Commands/NullCommand.cs +++ b/src/Volatility.Cli/CLI/Commands/NullCommand.cs @@ -3,10 +3,10 @@ namespace Volatility.CLI.Commands; internal class NullCommand : ICommand { public static string CommandToken => ""; - public string CommandDescription => ""; + public static string CommandDescription => ""; public static string CommandParameters => ""; - public async Task Execute() { } + public Task Execute() => Task.CompletedTask; public void SetArgs(Dictionary args) { } diff --git a/src/Volatility.Cli/CLI/Commands/PortTextureCommand.cs b/src/Volatility.Cli/CLI/Commands/PortTextureCommand.cs new file mode 100644 index 0000000..c2cb283 --- /dev/null +++ b/src/Volatility.Cli/CLI/Commands/PortTextureCommand.cs @@ -0,0 +1,88 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Operations; +using Volatility.Operations.Resources; + +namespace Volatility.CLI.Commands; + +internal class PortTextureCommand : ICommand +{ + private readonly IPathProvider pathProvider; + private readonly IOperation operation; + + public static string CommandToken => "PortTexture"; + public static string CommandDescription => "Ports texture data from a given source format to the specified destination format."; + public static string CommandParameters => "[--verbose] [--usegtf] --informat= --inpath= --outformat= [--outpath=]"; + + public string? SourceFormat { get; set; } + public string? SourcePath { get; set; } + public string? DestinationFormat { get; set; } + public string? DestinationPath { get; set; } + public bool Verbose { get; set; } + public bool UseGTF { get; set; } + + public async Task Execute() + { + if (string.IsNullOrEmpty(SourcePath)) + { + CLIMessageUtilities.Error("Error: No source path specified! (--inpath)"); + return; + } + + string[] sourceFiles = pathProvider.GetFilePaths(SourcePath, VolatilityFilePathFilter.Header); + if (sourceFiles.Length == 0) + { + CLIMessageUtilities.Error($"Error: No valid file(s) found at the specified path ({SourcePath}). Ensure the path exists and spaces are properly enclosed. (--inpath)"); + return; + } + + CLIMessageUtilities.Info( + $"Starting {sourceFiles.Length} PortTexture tasks...", + MessageCategory.Texture); + + OperationResult result = await operation.ExecuteAsync( + new PortTextureRequest( + sourceFiles, + SourceFormat ?? string.Empty, + SourcePath ?? string.Empty, + DestinationFormat ?? string.Empty, + DestinationPath, + Verbose, + UseGTF), + progress: null, + cancellationToken: CancellationToken.None); + + CLIMessageUtilities.PublishIssues(result.Issues); + if (!result.Success) + { + throw OperationResultFactory.CreateException(result, "Failed to port texture."); + } + } + + public void SetArgs(Dictionary args) + { + SourceFormat = (args.TryGetValue("informat", out object? informat) ? informat as string + : args.TryGetValue("if", out object? iff) ? iff as string : "auto")?.ToUpper() ?? "AUTO"; + + SourcePath = args.TryGetValue("inpath", out object? inpath) ? inpath as string + : args.TryGetValue("ip", out object? ipp) ? ipp as string : ""; + + DestinationFormat = (args.TryGetValue("outformat", out object? outformat) ? outformat as string + : args.TryGetValue("of", out object? off) ? off as string : "auto")?.ToUpper() ?? "AUTO"; + + DestinationPath = args.TryGetValue("outpath", out object? outpath) ? outpath as string + : args.TryGetValue("op", out object? opp) ? opp as string : SourcePath; + + Verbose = args.TryGetValue("verbose", out var verbose) && (bool)verbose; + UseGTF = args.TryGetValue("usegtf", out var usegtf) && (bool)usegtf; + } + + public PortTextureCommand( + IPathProvider pathProvider, + IOperation operation) + { + this.pathProvider = pathProvider; + this.operation = operation; + } +} diff --git a/Volatility/CLI/Commands/TextureToDDSCommand.cs b/src/Volatility.Cli/CLI/Commands/TextureToDDSCommand.cs similarity index 54% rename from Volatility/CLI/Commands/TextureToDDSCommand.cs rename to src/Volatility.Cli/CLI/Commands/TextureToDDSCommand.cs index 8b2ee95..1a386a8 100644 --- a/Volatility/CLI/Commands/TextureToDDSCommand.cs +++ b/src/Volatility.Cli/CLI/Commands/TextureToDDSCommand.cs @@ -1,3 +1,7 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Operations; using Volatility.Operations.Resources; using Volatility.Resources; using Volatility.Utilities; @@ -6,6 +10,9 @@ namespace Volatility.CLI.Commands; internal class TextureToDDSCommand : ICommand { + private readonly IPathProvider pathProvider; + private readonly IOperation operation; + public static string CommandToken => "TextureToDDS"; public static string CommandDescription => "Converts texture resources and their sidecar bitmap data into DDS files."; public static string CommandParameters => "[--recurse] [--overwrite] [--verbose] --format= --path= [--outpath=]"; @@ -21,20 +28,20 @@ public async Task Execute() { if (string.IsNullOrWhiteSpace(Format)) { - Console.WriteLine("Error: No input format specified! (--format)"); + CLIMessageUtilities.Error("Error: No input format specified! (--format)"); return; } if (string.IsNullOrWhiteSpace(InputPath)) { - Console.WriteLine("Error: No input path specified! (--path)"); + CLIMessageUtilities.Error("Error: No input path specified! (--path)"); return; } - string[] sourceFiles = ICommand.GetFilePathsInDirectory(InputPath, ICommand.TargetFileType.Header, Recursive); + string[] sourceFiles = pathProvider.GetFilePaths(InputPath, VolatilityFilePathFilter.Header, Recursive); if (sourceFiles.Length == 0) { - Console.WriteLine($"Error: No valid file(s) found at the specified path ({InputPath}). Ensure the path exists and spaces are properly enclosed. (--path)"); + CLIMessageUtilities.Error($"Error: No valid file(s) found at the specified path ({InputPath}). Ensure the path exists and spaces are properly enclosed. (--path)"); return; } @@ -50,12 +57,19 @@ public async Task Execute() throw new InvalidPlatformException("Error: Invalid file format specified!"); } - Console.ForegroundColor = ConsoleColor.Blue; - Console.WriteLine($"Starting {sourceFiles.Length} Texture to DDS tasks..."); - Console.ResetColor(); + CLIMessageUtilities.Info( + $"Starting {sourceFiles.Length} Texture to DDS tasks...", + MessageCategory.Texture); - var operation = new TextureToDDSOperation(); - await operation.ExecuteAsync(sourceFiles, platform, isX64, OutputPath, Overwrite, Verbose); + OperationResult result = await operation.ExecuteAsync( + new TextureToDDSRequest(sourceFiles, platform, isX64, OutputPath, Overwrite, Verbose), + progress: null, + cancellationToken: CancellationToken.None); + CLIMessageUtilities.PublishIssues(result.Issues, MessageCategory.Texture); + if (!result.Success) + { + throw OperationResultFactory.CreateException(result, "Failed to convert texture to DDS."); + } } public void SetArgs(Dictionary args) @@ -68,5 +82,11 @@ public void SetArgs(Dictionary args) Verbose = args.TryGetValue("verbose", out var ve) && (bool)ve; } - public TextureToDDSCommand() { } + public TextureToDDSCommand( + IPathProvider pathProvider, + IOperation operation) + { + this.pathProvider = pathProvider; + this.operation = operation; + } } diff --git a/Volatility/CLI/ConsoleMessageSink.cs b/src/Volatility.Cli/CLI/ConsoleMessageSink.cs similarity index 100% rename from Volatility/CLI/ConsoleMessageSink.cs rename to src/Volatility.Cli/CLI/ConsoleMessageSink.cs diff --git a/src/Volatility.Cli/CLI/ICommand.cs b/src/Volatility.Cli/CLI/ICommand.cs new file mode 100644 index 0000000..eed5652 --- /dev/null +++ b/src/Volatility.Cli/CLI/ICommand.cs @@ -0,0 +1,24 @@ +using static Volatility.Utilities.TypeUtilities; +using Volatility.CLI; + +namespace Volatility; + +internal interface ICommand +{ + static abstract string CommandToken { get; } + static abstract string CommandDescription { get; } + static abstract string CommandParameters { get; } + + Task Execute() => Task.CompletedTask; + void SetArgs(Dictionary args); + public void ShowUsage() + { + Type thisType = GetType(); + + var token = GetStaticPropertyValue(thisType, "CommandToken"); + var parameters = GetStaticPropertyValue(thisType, "CommandParameters"); + var description = GetStaticPropertyValue(thisType, "CommandDescription"); + + CLIMessageUtilities.Info(thisType.Name, $"Usage:\n {token} {parameters}\n{description}"); + } +} diff --git a/src/Volatility.Cli/GlobalUsings.cs b/src/Volatility.Cli/GlobalUsings.cs new file mode 100644 index 0000000..1314398 --- /dev/null +++ b/src/Volatility.Cli/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using ResourceID = StrongID; +global using SnrID = StrongID; diff --git a/Volatility/Frontend.cs b/src/Volatility.Cli/Program.cs similarity index 80% rename from Volatility/Frontend.cs rename to src/Volatility.Cli/Program.cs index 41b3694..e64a0b9 100644 --- a/Volatility/Frontend.cs +++ b/src/Volatility.Cli/Program.cs @@ -1,13 +1,17 @@ using System.Reflection; +using Microsoft.Extensions.DependencyInjection; using Volatility.Abstractions.Messaging; using Volatility.CLI; using Volatility.CLI.Commands; +using Volatility.Hosting; using Volatility.Messaging; namespace Volatility; internal class Frontend { + private static IServiceProvider? services; + /* * TODO LIST: * @@ -39,22 +43,40 @@ internal class Frontend static void Main(string[] args) { + ServiceCollection services = new(); + services.AddVolatilityCore(); + + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + Frontend.services = serviceProvider; + VolatilityMessageHost.Reset(serviceProvider.GetRequiredService()); using IDisposable consoleSubscription = ConfigureMessaging(); - if (args.Length > 0) + try { - RunCommandTokenized(args); + if (args.Length > 0) + { + RunCommandTokenized(args); + } + else + { + CommandLine(); + } } - else + finally { - CommandLine(); + Frontend.services = null; + VolatilityMessageHost.Reset(); } } static IDisposable ConfigureMessaging() { - VolatilityMessageHost.Reset(); - return VolatilityMessageHost.Bus.Subscribe(new ConsoleMessageSink()); + if (services == null) + { + throw new InvalidOperationException("Volatility services have not been configured."); + } + + return services.GetRequiredService().Subscribe(new ConsoleMessageSink()); } static void CommandLine() @@ -73,7 +95,7 @@ static void CommandLine() VolatilityMessageHost.Sink.Info( $"Volatility {assembly.GetName().Version} - Build Date: {buildTimestamp}\n", - MessageCategory.Cli, + MessageCategory.CLI, nameof(Frontend)); while (true) @@ -136,7 +158,7 @@ static void RunCommand(string? input) } catch (Exception ex) { - VolatilityMessageHost.Sink.Error($"Error: {ex.Message}", MessageCategory.Cli, nameof(Frontend)); + VolatilityMessageHost.Sink.Error($"Error: {ex.Message}", MessageCategory.CLI, nameof(Frontend)); #if DEBUG throw; #endif @@ -162,7 +184,8 @@ static void RunCommandTokenized(string[] input) } catch (Exception ex) { - VolatilityMessageHost.Sink.Error($"Error: {ex.Message}", MessageCategory.Cli, nameof(Frontend)); + VolatilityMessageHost.Sink.Error($"Error: {ex.Message}", MessageCategory.CLI, nameof(Frontend)); + Environment.ExitCode = 1; #if DEBUG throw; #endif @@ -200,7 +223,12 @@ static ICommand ParseCommandTokenized(string[] input) // Split command if (Commands.TryGetValue(commandName, out Type? commandType) && commandType != null) { - ICommand? command = Activator.CreateInstance(commandType) as ICommand; + if (services == null) + { + throw new InvalidOperationException("Volatility services have not been configured."); + } + + ICommand? command = ActivatorUtilities.CreateInstance(services, commandType) as ICommand; if (command != null) { command.SetArgs(args); diff --git a/src/Volatility.Cli/Volatility.Cli.csproj b/src/Volatility.Cli/Volatility.Cli.csproj new file mode 100644 index 0000000..247128e --- /dev/null +++ b/src/Volatility.Cli/Volatility.Cli.csproj @@ -0,0 +1,49 @@ + + + + Exe + net9.0 + enable + enable + volatility_icon.ico + + + + + + + + $([System.DateTime]::Now.ToString("yyyy/MM/dd HH:mm:ss")) + true + true + true + + + + + + + + + + <_Parameter1>BuildTimestamp + <_Parameter2>$(BuildTimestamp) + + + + + + + + + + + + + + PreserveNewest + PreserveNewest + + + + diff --git a/Volatility/tools/dxc/README.txt b/src/Volatility.Cli/tools/dxc/README.txt similarity index 100% rename from Volatility/tools/dxc/README.txt rename to src/Volatility.Cli/tools/dxc/README.txt diff --git a/Volatility/volatility_icon.ico b/src/Volatility.Cli/volatility_icon.ico similarity index 100% rename from Volatility/volatility_icon.ico rename to src/Volatility.Cli/volatility_icon.ico diff --git a/Volatility/Abstractions/Messaging/IMessageBus.cs b/src/Volatility.Core/Abstractions/Messaging/IMessageBus.cs similarity index 100% rename from Volatility/Abstractions/Messaging/IMessageBus.cs rename to src/Volatility.Core/Abstractions/Messaging/IMessageBus.cs diff --git a/Volatility/Abstractions/Messaging/IMessageSink.cs b/src/Volatility.Core/Abstractions/Messaging/IMessageSink.cs similarity index 100% rename from Volatility/Abstractions/Messaging/IMessageSink.cs rename to src/Volatility.Core/Abstractions/Messaging/IMessageSink.cs diff --git a/Volatility/Abstractions/Messaging/MessageCategory.cs b/src/Volatility.Core/Abstractions/Messaging/MessageCategory.cs similarity index 95% rename from Volatility/Abstractions/Messaging/MessageCategory.cs rename to src/Volatility.Core/Abstractions/Messaging/MessageCategory.cs index 5ee818e..ad1e4f9 100644 --- a/Volatility/Abstractions/Messaging/MessageCategory.cs +++ b/src/Volatility.Core/Abstractions/Messaging/MessageCategory.cs @@ -9,5 +9,5 @@ public enum MessageCategory StringTable, Autotest, Process, - Cli + CLI } diff --git a/Volatility/Abstractions/Messaging/MessageSeverity.cs b/src/Volatility.Core/Abstractions/Messaging/MessageSeverity.cs similarity index 100% rename from Volatility/Abstractions/Messaging/MessageSeverity.cs rename to src/Volatility.Core/Abstractions/Messaging/MessageSeverity.cs diff --git a/Volatility/Abstractions/Messaging/VolatilityLog.cs b/src/Volatility.Core/Abstractions/Messaging/VolatilityLog.cs similarity index 95% rename from Volatility/Abstractions/Messaging/VolatilityLog.cs rename to src/Volatility.Core/Abstractions/Messaging/VolatilityLog.cs index 8411ab5..a8232d5 100644 --- a/Volatility/Abstractions/Messaging/VolatilityLog.cs +++ b/src/Volatility.Core/Abstractions/Messaging/VolatilityLog.cs @@ -1,5 +1,3 @@ -using Volatility.Messaging; - namespace Volatility.Abstractions.Messaging; public static class VolatilityLog @@ -62,7 +60,12 @@ private static void PublishMessage( string? source, IReadOnlyDictionary? data) { + if (sink is null) + { + return; + } + VolatilityMessage message = new(severity, category, text, source, data); - (sink ?? NullMessageSink.Instance).Publish(in message); + sink.Publish(in message); } } diff --git a/Volatility/Abstractions/Messaging/VolatilityMessage.cs b/src/Volatility.Core/Abstractions/Messaging/VolatilityMessage.cs similarity index 100% rename from Volatility/Abstractions/Messaging/VolatilityMessage.cs rename to src/Volatility.Core/Abstractions/Messaging/VolatilityMessage.cs diff --git a/Volatility/Abstractions/Operations/IOperation.cs b/src/Volatility.Core/Abstractions/Operations/IOperation.cs similarity index 100% rename from Volatility/Abstractions/Operations/IOperation.cs rename to src/Volatility.Core/Abstractions/Operations/IOperation.cs diff --git a/Volatility/Abstractions/Operations/IOperationRequest.cs b/src/Volatility.Core/Abstractions/Operations/IOperationRequest.cs similarity index 100% rename from Volatility/Abstractions/Operations/IOperationRequest.cs rename to src/Volatility.Core/Abstractions/Operations/IOperationRequest.cs diff --git a/Volatility/Abstractions/Operations/OperationIssue.cs b/src/Volatility.Core/Abstractions/Operations/OperationIssue.cs similarity index 100% rename from Volatility/Abstractions/Operations/OperationIssue.cs rename to src/Volatility.Core/Abstractions/Operations/OperationIssue.cs diff --git a/Volatility/Abstractions/Operations/OperationProgress.cs b/src/Volatility.Core/Abstractions/Operations/OperationProgress.cs similarity index 100% rename from Volatility/Abstractions/Operations/OperationProgress.cs rename to src/Volatility.Core/Abstractions/Operations/OperationProgress.cs diff --git a/Volatility/Abstractions/Operations/OperationResult.cs b/src/Volatility.Core/Abstractions/Operations/OperationResult.cs similarity index 100% rename from Volatility/Abstractions/Operations/OperationResult.cs rename to src/Volatility.Core/Abstractions/Operations/OperationResult.cs diff --git a/src/Volatility.Core/Abstractions/Services/IPathProvider.cs b/src/Volatility.Core/Abstractions/Services/IPathProvider.cs new file mode 100644 index 0000000..01df364 --- /dev/null +++ b/src/Volatility.Core/Abstractions/Services/IPathProvider.cs @@ -0,0 +1,13 @@ +namespace Volatility.Abstractions.Services; + +public interface IPathProvider +{ + string GetDirectory(VolatilityPathLocation location); + string GetExecutableDirectory(); + string GetRepositoryRoot(); + string GetFullPath(string path); + bool FileExists(string path); + bool DirectoryExists(string path); + void CreateDirectory(string path); + string[] GetFilePaths(string path, VolatilityFilePathFilter filter, bool recurse = false); +} diff --git a/Volatility/Abstractions/Services/IProcessRunner.cs b/src/Volatility.Core/Abstractions/Services/IProcessRunner.cs similarity index 100% rename from Volatility/Abstractions/Services/IProcessRunner.cs rename to src/Volatility.Core/Abstractions/Services/IProcessRunner.cs diff --git a/src/Volatility.Core/Abstractions/Services/IResourceDBLookup.cs b/src/Volatility.Core/Abstractions/Services/IResourceDBLookup.cs new file mode 100644 index 0000000..ad4c150 --- /dev/null +++ b/src/Volatility.Core/Abstractions/Services/IResourceDBLookup.cs @@ -0,0 +1,9 @@ +using Volatility.Resources; + +namespace Volatility.Abstractions.Services; + +public interface IResourceDBLookup +{ + string GetNameByResourceId(string id); + string GetNameByResourceId(ResourceID id); +} diff --git a/src/Volatility.Core/Abstractions/Services/IResourceFactory.cs b/src/Volatility.Core/Abstractions/Services/IResourceFactory.cs new file mode 100644 index 0000000..7d2346c --- /dev/null +++ b/src/Volatility.Core/Abstractions/Services/IResourceFactory.cs @@ -0,0 +1,8 @@ +using Volatility.Resources; + +namespace Volatility.Abstractions.Services; + +public interface IResourceFactory +{ + Resource CreateResource(ResourceType resourceType, Platform platform, bool x64 = false); +} diff --git a/src/Volatility.Core/Abstractions/Services/IResourceSerializer.cs b/src/Volatility.Core/Abstractions/Services/IResourceSerializer.cs new file mode 100644 index 0000000..d316c96 --- /dev/null +++ b/src/Volatility.Core/Abstractions/Services/IResourceSerializer.cs @@ -0,0 +1,17 @@ +using Volatility.Resources; + +namespace Volatility.Abstractions.Services; + +public interface IResourceSerializer +{ + Resource Deserialize( + Stream stream, + ResourceType resourceType, + Platform platform, + ResourceSerializationOptions options); + + void Serialize( + Resource resource, + Stream stream, + ResourceSerializationOptions options); +} diff --git a/Volatility/Abstractions/Services/IShaderCompiler.cs b/src/Volatility.Core/Abstractions/Services/IShaderCompiler.cs similarity index 100% rename from Volatility/Abstractions/Services/IShaderCompiler.cs rename to src/Volatility.Core/Abstractions/Services/IShaderCompiler.cs diff --git a/src/Volatility.Core/Abstractions/Services/IShaderSourceStore.cs b/src/Volatility.Core/Abstractions/Services/IShaderSourceStore.cs new file mode 100644 index 0000000..c933b84 --- /dev/null +++ b/src/Volatility.Core/Abstractions/Services/IShaderSourceStore.cs @@ -0,0 +1,8 @@ +using Volatility.Resources; + +namespace Volatility.Abstractions.Services; + +public interface IShaderSourceStore +{ + void MaterializeImportedSource(ShaderBase shader, string resourcesDirectory); +} diff --git a/src/Volatility.Core/Abstractions/Services/ISplicerSampleStore.cs b/src/Volatility.Core/Abstractions/Services/ISplicerSampleStore.cs new file mode 100644 index 0000000..f4d44d6 --- /dev/null +++ b/src/Volatility.Core/Abstractions/Services/ISplicerSampleStore.cs @@ -0,0 +1,8 @@ +using Volatility.Resources; + +namespace Volatility.Abstractions.Services; + +public interface ISplicerSampleStore +{ + void PopulateDependentSamples(Splicer splicer, string splicerDirectory, bool recurse = false); +} diff --git a/src/Volatility.Core/Abstractions/Services/IStringTableStore.cs b/src/Volatility.Core/Abstractions/Services/IStringTableStore.cs new file mode 100644 index 0000000..1f03f16 --- /dev/null +++ b/src/Volatility.Core/Abstractions/Services/IStringTableStore.cs @@ -0,0 +1,14 @@ +using Volatility.Operations.StringTables; + +namespace Volatility.Abstractions.Services; + +public interface IStringTableStore +{ + Task WriteYamlAsync(string yamlFile, Dictionary> entries); + Task> LoadJsonAsync(string jsonFile); + Task WriteJsonAsync(string jsonFile, Dictionary entries); + void MergeLegacyEntries( + Dictionary target, + Dictionary> source, + bool overwrite); +} diff --git a/src/Volatility.Core/Abstractions/Services/ITextureBitmapStore.cs b/src/Volatility.Core/Abstractions/Services/ITextureBitmapStore.cs new file mode 100644 index 0000000..6fa5285 --- /dev/null +++ b/src/Volatility.Core/Abstractions/Services/ITextureBitmapStore.cs @@ -0,0 +1,12 @@ +using Volatility.Resources; + +namespace Volatility.Abstractions.Services; + +public interface ITextureBitmapStore +{ + string GetResourceBaseName(string headerPath, Unpacker unpacker); + string GetSecondaryBitmapPath(string headerPath, Unpacker unpacker); + byte[] ReadNormalizedBitmapData(TextureBase texture, string bitmapPath); + void WriteNormalizedBitmapFile(TextureBase texture, string sourceBitmapPath, string outputPath, bool overwrite = true); + void ConvertPS3GTFToDDS(TexturePS3 texture, string sourceBitmapPath, string destinationBitmapPath, bool verbose = false); +} diff --git a/src/Volatility.Core/Abstractions/Services/VolatilityFilePathFilter.cs b/src/Volatility.Core/Abstractions/Services/VolatilityFilePathFilter.cs new file mode 100644 index 0000000..b528711 --- /dev/null +++ b/src/Volatility.Core/Abstractions/Services/VolatilityFilePathFilter.cs @@ -0,0 +1,7 @@ +namespace Volatility.Abstractions.Services; + +public enum VolatilityFilePathFilter +{ + Any, + Header +} diff --git a/Volatility/Abstractions/Services/VolatilityPathLocation.cs b/src/Volatility.Core/Abstractions/Services/VolatilityPathLocation.cs similarity index 100% rename from Volatility/Abstractions/Services/VolatilityPathLocation.cs rename to src/Volatility.Core/Abstractions/Services/VolatilityPathLocation.cs diff --git a/src/Volatility.Core/AssemblyInfo.cs b/src/Volatility.Core/AssemblyInfo.cs new file mode 100644 index 0000000..ece01e9 --- /dev/null +++ b/src/Volatility.Core/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Volatility.Cli")] +[assembly: InternalsVisibleTo("Volatility.Core.Tests")] diff --git a/Volatility/Attributes/EditorCategoryAttribute.cs b/src/Volatility.Core/Attributes/EditorCategoryAttribute.cs similarity index 100% rename from Volatility/Attributes/EditorCategoryAttribute.cs rename to src/Volatility.Core/Attributes/EditorCategoryAttribute.cs diff --git a/Volatility/Attributes/EditorHiddenAttribute.cs b/src/Volatility.Core/Attributes/EditorHiddenAttribute.cs similarity index 100% rename from Volatility/Attributes/EditorHiddenAttribute.cs rename to src/Volatility.Core/Attributes/EditorHiddenAttribute.cs diff --git a/Volatility/Attributes/EditorLabelAttribute.cs b/src/Volatility.Core/Attributes/EditorLabelAttribute.cs similarity index 100% rename from Volatility/Attributes/EditorLabelAttribute.cs rename to src/Volatility.Core/Attributes/EditorLabelAttribute.cs diff --git a/Volatility/Attributes/EditorReadOnlyAttribute.cs b/src/Volatility.Core/Attributes/EditorReadOnlyAttribute.cs similarity index 100% rename from Volatility/Attributes/EditorReadOnlyAttribute.cs rename to src/Volatility.Core/Attributes/EditorReadOnlyAttribute.cs diff --git a/Volatility/Attributes/EditorTooltipAttribute.cs b/src/Volatility.Core/Attributes/EditorTooltipAttribute.cs similarity index 100% rename from Volatility/Attributes/EditorTooltipAttribute.cs rename to src/Volatility.Core/Attributes/EditorTooltipAttribute.cs diff --git a/Volatility/Endian.cs b/src/Volatility.Core/Endian.cs similarity index 100% rename from Volatility/Endian.cs rename to src/Volatility.Core/Endian.cs diff --git a/Volatility/Exceptions/InvalidPlatformException.cs b/src/Volatility.Core/Exceptions/InvalidPlatformException.cs similarity index 81% rename from Volatility/Exceptions/InvalidPlatformException.cs rename to src/Volatility.Core/Exceptions/InvalidPlatformException.cs index 6d319df..e6c4643 100644 --- a/Volatility/Exceptions/InvalidPlatformException.cs +++ b/src/Volatility.Core/Exceptions/InvalidPlatformException.cs @@ -2,7 +2,6 @@ namespace Volatility; -[Serializable] class InvalidPlatformException : Exception { public InvalidPlatformException() @@ -17,10 +16,6 @@ public InvalidPlatformException(string? message, Exception? innerException) : ba { } - protected InvalidPlatformException(SerializationInfo info, StreamingContext context) : base(info, context) - { - } - public override string Message => "Invalid platform specified."; public static InvalidPlatformException SpecifyInvalidPlatform(string platform) diff --git a/src/Volatility.Core/Hosting/VolatilityServiceCollectionExtensions.cs b/src/Volatility.Core/Hosting/VolatilityServiceCollectionExtensions.cs new file mode 100644 index 0000000..17b1e0a --- /dev/null +++ b/src/Volatility.Core/Hosting/VolatilityServiceCollectionExtensions.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Messaging; +using Volatility.Operations.Autotest; +using Volatility.Operations.Resources; +using Volatility.Operations.StringTables; +using Volatility.Services; + +namespace Volatility.Hosting; + +public static class VolatilityServiceCollectionExtensions +{ + public static IServiceCollection AddVolatilityCore(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.TryAddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.AddTransient, CreateResourceOperation>(); + services.AddTransient, LoadResourceOperation>(); + services.AddTransient, SaveResourceOperation>(); + services.AddTransient, CreateShaderProgramBufferOperation>(); + services.AddTransient, LoadResourceDictionaryOperation>(); + services.AddTransient, MergeStringTableEntriesOperation>(); + services.AddTransient, ImportResourceOperation>(); + services.AddTransient, ImportStringTableOperation>(); + services.AddTransient, ExportResourceOperation>(); + services.AddTransient, TextureToDDSOperation>(); + services.AddTransient, PortTextureOperation>(); + services.AddTransient, GameAutotestOperation>(); + services.AddTransient, TextureRoundTripOperation>(); + + return services; + } +} diff --git a/Volatility/Messaging/MessageBus.cs b/src/Volatility.Core/Messaging/MessageBus.cs similarity index 100% rename from Volatility/Messaging/MessageBus.cs rename to src/Volatility.Core/Messaging/MessageBus.cs diff --git a/Volatility/Messaging/VolatilityMessageHost.cs b/src/Volatility.Core/Messaging/VolatilityMessageHost.cs similarity index 100% rename from Volatility/Messaging/VolatilityMessageHost.cs rename to src/Volatility.Core/Messaging/VolatilityMessageHost.cs diff --git a/Volatility/Operations/Autotest/GameAutotestOperation.cs b/src/Volatility.Core/Operations/Autotest/GameAutotestOperation.cs similarity index 75% rename from Volatility/Operations/Autotest/GameAutotestOperation.cs rename to src/Volatility.Core/Operations/Autotest/GameAutotestOperation.cs index ee73cde..224475c 100644 --- a/Volatility/Operations/Autotest/GameAutotestOperation.cs +++ b/src/Volatility.Core/Operations/Autotest/GameAutotestOperation.cs @@ -1,5 +1,9 @@ using System.Globalization; +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Operations; using Volatility.Operations.Resources; using Volatility.Resources; using Volatility.Utilities; @@ -7,7 +11,7 @@ namespace Volatility.Operations.Autotest; -internal sealed class GameAutotestOptions +public sealed class GameAutotestRequest : IOperationRequest { public required IReadOnlyList GamePaths { get; init; } public string? BundleToolPath { get; init; } @@ -17,7 +21,7 @@ internal sealed class GameAutotestOptions public bool KeepArtifacts { get; init; } } -internal sealed class GameAutotestSummary +public sealed class GameAutotestSummary { public int Passed { get; set; } public int Failed { get; set; } @@ -25,7 +29,7 @@ internal sealed class GameAutotestSummary public List Cases { get; } = []; } -internal sealed record GameAutotestCaseResult( +public sealed record GameAutotestCaseResult( string Game, string Name, string Operation, @@ -34,7 +38,18 @@ internal sealed record GameAutotestCaseResult( ResourceType? TestedResourceType = null); internal sealed class GameAutotestOperation + : IOperation { + private readonly IPathProvider pathProvider; + private readonly IProcessRunner processRunner; + private readonly IMessageSink messageSink; + private readonly IOperation importOperation; + private readonly IOperation saveOperation; + private readonly IOperation loadOperation; + private readonly IOperation exportOperation; + private readonly IOperation textureToDdsOperation; + private readonly IOperation portTextureOperation; + private static readonly HashSet RoundTripTypes = [ ResourceType.Texture, @@ -86,28 +101,72 @@ internal sealed class GameAutotestOperation "TRK_UNIT0_GR.BNDL", ]; - public async Task ExecuteAsync(GameAutotestOptions options) + public GameAutotestOperation( + IPathProvider pathProvider, + IProcessRunner processRunner, + IMessageSink messageSink, + IOperation importOperation, + IOperation saveOperation, + IOperation loadOperation, + IOperation exportOperation, + IOperation textureToDdsOperation, + IOperation portTextureOperation) + { + this.pathProvider = pathProvider; + this.processRunner = processRunner; + this.messageSink = messageSink; + this.importOperation = importOperation; + this.saveOperation = saveOperation; + this.loadOperation = loadOperation; + this.exportOperation = exportOperation; + this.textureToDdsOperation = textureToDdsOperation; + this.portTextureOperation = portTextureOperation; + } + + public async Task> ExecuteAsync( + GameAutotestRequest request, + IProgress? progress, + CancellationToken cancellationToken) { - if (options.GamePaths.Count == 0) + cancellationToken.ThrowIfCancellationRequested(); + + try { - throw new InvalidOperationException("At least one game path must be provided."); - } + if (request.GamePaths.Count == 0) + { + throw new InvalidOperationException("At least one game path must be provided."); + } + + string repoRoot = pathProvider.GetRepositoryRoot(); + bool useYapBundleTool = IsYapTool(request.BundleToolPath); + string bundleToolPath = GetBundleTool(repoRoot, request.BundleToolPath); + string sessionRoot = GetSessionRoot(repoRoot, request.WorkingDirectory); - string repoRoot = WorkspaceUtilities.FindRepositoryRoot(); - bool useYapBundleTool = IsYapTool(options.BundleToolPath); - string bundleToolPath = GetBundleTool(repoRoot, options.BundleToolPath); - string sessionRoot = GetSessionRoot(repoRoot, options.WorkingDirectory); + pathProvider.CreateDirectory(sessionRoot); + + GameAutotestSummary summary = new(); + foreach (string gamePath in request.GamePaths) + { + cancellationToken.ThrowIfCancellationRequested(); - Directory.CreateDirectory(sessionRoot); + GameInstall game = DetectGame(gamePath); + await RunGameAsync(game, bundleToolPath, useYapBundleTool, sessionRoot, request, summary, cancellationToken); + progress?.Report(new OperationProgress("game-autotest", null, game.Name)); + } - GameAutotestSummary summary = new(); - foreach (string gamePath in options.GamePaths) + return OperationResultFactory.Success(summary); + } + catch (OperationCanceledException) { - GameInstall game = DetectGame(gamePath); - await RunGameAsync(game, bundleToolPath, useYapBundleTool, sessionRoot, options, summary); + throw; + } + catch (Exception ex) + { + return OperationResultFactory.Failure( + "game_autotest_failed", + ex.Message, + nameof(GameAutotestOperation)); } - - return summary; } private async Task RunGameAsync( @@ -115,14 +174,15 @@ private async Task RunGameAsync( string bundleToolPath, bool useYapBundleTool, string sessionRoot, - GameAutotestOptions options, - GameAutotestSummary summary) + GameAutotestRequest options, + GameAutotestSummary summary, + CancellationToken cancellationToken) { string gameWorkRoot = Path.Combine(sessionRoot, $"{SanitizePath(game.Name)}_{game.Platform}"); - Directory.CreateDirectory(gameWorkRoot); + pathProvider.CreateDirectory(gameWorkRoot); - Console.WriteLine($"AUTOTEST - Game: {game.Name} ({game.Platform})"); - Console.WriteLine($"AUTOTEST - Working directory: {gameWorkRoot}"); + messageSink.Info($"AUTOTEST - Game: {game.Name} ({game.Platform})", MessageCategory.Autotest, nameof(GameAutotestOperation)); + messageSink.Info($"AUTOTEST - Working directory: {gameWorkRoot}", MessageCategory.Autotest, nameof(GameAutotestOperation)); int failuresBefore = summary.Failed; @@ -156,23 +216,15 @@ private async Task RunGameAsync( string exportsRoot = Path.Combine(gameWorkRoot, "exports"); string ddsRoot = Path.Combine(gameWorkRoot, "dds"); string portRoot = Path.Combine(gameWorkRoot, "port"); - string toolsRoot = EnvironmentUtilities.GetEnvironmentDirectory(EnvironmentUtilities.EnvironmentDirectory.Tools); - - Directory.CreateDirectory(pass1Resources); - Directory.CreateDirectory(pass2Resources); - Directory.CreateDirectory(splicerPass1); - Directory.CreateDirectory(splicerPass2); - Directory.CreateDirectory(exportsRoot); - Directory.CreateDirectory(ddsRoot); - Directory.CreateDirectory(portRoot); - - ImportResourceOperation importPass1 = new(pass1Resources, toolsRoot, splicerPass1, overwrite: true); - ImportResourceOperation importPass2 = new(pass2Resources, toolsRoot, splicerPass2, overwrite: true); - SaveResourceOperation saveOperation = new(); - LoadResourceOperation loadOperation = new(); - ExportResourceOperation exportOperation = new(); - TextureToDDSOperation textureToDdsOperation = new(); - PortTextureOperation portTextureOperation = new(); + string toolsRoot = pathProvider.GetDirectory(VolatilityPathLocation.Tools); + + pathProvider.CreateDirectory(pass1Resources); + pathProvider.CreateDirectory(pass2Resources); + pathProvider.CreateDirectory(splicerPass1); + pathProvider.CreateDirectory(splicerPass2); + pathProvider.CreateDirectory(exportsRoot); + pathProvider.CreateDirectory(ddsRoot); + pathProvider.CreateDirectory(portRoot); foreach (ResourceTestCandidate candidate in candidates) { @@ -181,22 +233,23 @@ private async Task RunGameAsync( await RunRoundTripAsync( game, candidate, - importPass1, - importPass2, - saveOperation, - loadOperation, - exportOperation, + pass1Resources, + pass2Resources, + toolsRoot, + splicerPass1, + splicerPass2, exportsRoot, - summary); + summary, + cancellationToken); } else if (ImportOnlyTypes.Contains(candidate.ResourceType)) { - await RunImportOnlyAsync(game, candidate, importPass1, saveOperation, summary); + await RunImportOnlyAsync(game, candidate, pass1Resources, toolsRoot, splicerPass1, summary, cancellationToken); } if (candidate.ResourceType == ResourceType.Texture) { - await RunTextureOperationsAsync(game, candidate, textureToDdsOperation, portTextureOperation, ddsRoot, portRoot, summary); + await RunTextureOperationsAsync(game, candidate, ddsRoot, portRoot, summary, cancellationToken); } } @@ -206,16 +259,17 @@ await RunRoundTripAsync( } } - private static async Task RunRoundTripAsync( + private async Task RunRoundTripAsync( GameInstall game, ResourceTestCandidate candidate, - ImportResourceOperation importPass1, - ImportResourceOperation importPass2, - SaveResourceOperation saveOperation, - LoadResourceOperation loadOperation, - ExportResourceOperation exportOperation, + string pass1Resources, + string pass2Resources, + string toolsRoot, + string splicerPass1, + string splicerPass2, string exportsRoot, - GameAutotestSummary summary) + GameAutotestSummary summary, + CancellationToken cancellationToken) { string caseName = $"{candidate.ResourceType}:{candidate.DisplayName}"; string? exportPath = null; @@ -223,12 +277,26 @@ private static async Task RunRoundTripAsync( try { - ImportResourceResult firstImport = await importPass1.ExecuteAsync(candidate.ResourceType, game.Platform, candidate.SourcePath, isX64: false); - await saveOperation.ExecuteAsync(firstImport.Resource, firstImport.ResourcePath); - - Resource loaded = await loadOperation.ExecuteAsync(firstImport.ResourcePath, candidate.ResourceType, game.Platform); + ImportResourceResult firstImport = await ImportAsync(new ImportResourceRequest( + candidate.ResourceType, + game.Platform, + candidate.SourcePath, + IsX64: false, + pass1Resources, + toolsRoot, + splicerPass1, + Overwrite: true), cancellationToken); + await SaveAsync(firstImport.Resource, firstImport.ResourcePath, cancellationToken); + + Resource loaded = await LoadAsync(firstImport.ResourcePath, candidate.ResourceType, game.Platform, cancellationToken); exportPath = Path.Combine(exportsRoot, Path.GetFileName(candidate.SourcePath)); - await exportOperation.ExecuteAsync(loaded, exportPath, game.Platform); + await ExportAsync(new ExportResourceRequest( + loaded, + exportPath, + game.Platform, + WriteImportsToSeparateFile: true, + Overwrite: true, + SplicerDirectory: splicerPass1), cancellationToken); BinaryComparisonResult binaryComparison = CompareFiles(candidate.SourcePath, exportPath); AddCase(summary, new GameAutotestCaseResult( @@ -240,11 +308,19 @@ private static async Task RunRoundTripAsync( TestedResourceType: candidate.ResourceType)); binaryParityRecorded = true; - ImportResourceResult secondImport = await importPass2.ExecuteAsync(candidate.ResourceType, game.Platform, exportPath, isX64: false); - await saveOperation.ExecuteAsync(secondImport.Resource, secondImport.ResourcePath); + ImportResourceResult secondImport = await ImportAsync(new ImportResourceRequest( + candidate.ResourceType, + game.Platform, + exportPath, + IsX64: false, + pass2Resources, + toolsRoot, + splicerPass2, + Overwrite: true), cancellationToken); + await SaveAsync(secondImport.Resource, secondImport.ResourcePath, cancellationToken); - string firstYaml = NormalizeYaml(await File.ReadAllTextAsync(firstImport.ResourcePath)); - string secondYaml = NormalizeYaml(await File.ReadAllTextAsync(secondImport.ResourcePath)); + string firstYaml = NormalizeYaml(await File.ReadAllTextAsync(firstImport.ResourcePath, cancellationToken)); + string secondYaml = NormalizeYaml(await File.ReadAllTextAsync(secondImport.ResourcePath, cancellationToken)); if (string.Equals(firstYaml, secondYaml, StringComparison.Ordinal)) { @@ -282,19 +358,29 @@ private static async Task RunRoundTripAsync( } } - private static async Task RunImportOnlyAsync( + private async Task RunImportOnlyAsync( GameInstall game, ResourceTestCandidate candidate, - ImportResourceOperation importOperation, - SaveResourceOperation saveOperation, - GameAutotestSummary summary) + string resourcesDirectory, + string toolsRoot, + string splicerDirectory, + GameAutotestSummary summary, + CancellationToken cancellationToken) { string caseName = $"{candidate.ResourceType}:{candidate.DisplayName}"; try { - ImportResourceResult importResult = await importOperation.ExecuteAsync(candidate.ResourceType, game.Platform, candidate.SourcePath, isX64: false); - await saveOperation.ExecuteAsync(importResult.Resource, importResult.ResourcePath); + ImportResourceResult importResult = await ImportAsync(new ImportResourceRequest( + candidate.ResourceType, + game.Platform, + candidate.SourcePath, + IsX64: false, + resourcesDirectory, + toolsRoot, + splicerDirectory, + Overwrite: true), cancellationToken); + await SaveAsync(importResult.Resource, importResult.ResourcePath, cancellationToken); AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "import", "PASS", TestedResourceType: candidate.ResourceType)); } catch (Exception ex) @@ -303,19 +389,33 @@ private static async Task RunImportOnlyAsync( } } - private static async Task RunTextureOperationsAsync( + private async Task RunTextureOperationsAsync( GameInstall game, ResourceTestCandidate candidate, - TextureToDDSOperation textureToDdsOperation, - PortTextureOperation portTextureOperation, string ddsRoot, string portRoot, - GameAutotestSummary summary) + GameAutotestSummary summary, + CancellationToken cancellationToken) { string ddsCaseName = $"{candidate.DisplayName}:dds"; try { - await textureToDdsOperation.ExecuteAsync([candidate.SourcePath], game.Platform, isX64: false, ddsRoot, overwrite: true, verbose: false); + OperationResult result = await textureToDdsOperation.ExecuteAsync( + new TextureToDDSRequest( + [candidate.SourcePath], + game.Platform, + false, + ddsRoot, + true, + false), + progress: null, + cancellationToken); + + if (!result.Success) + { + throw OperationResultFactory.CreateException(result, "Failed to convert texture to DDS."); + } + AddCase(summary, new GameAutotestCaseResult(game.Name, ddsCaseName, "texturetodds", "PASS", TestedResourceType: ResourceType.Texture)); } catch (Exception ex) @@ -337,16 +437,24 @@ private static async Task RunTextureOperationsAsync( string destinationFormat = destinationPlatform == Platform.TUB ? "TUB" : destinationPlatform.ToString().ToUpperInvariant(); string sourceFormat = game.Platform == Platform.TUB ? "TUB" : game.Platform.ToString().ToUpperInvariant(); string destinationPath = Path.Combine(portRoot, destinationPlatform.ToString()); - Directory.CreateDirectory(destinationPath); - - await portTextureOperation.ExecuteAsync( - [candidate.SourcePath], - sourceFormat, - candidate.SourcePath, - destinationFormat, - destinationPath, - verbose: false, - useGtf: false); + pathProvider.CreateDirectory(destinationPath); + + OperationResult portResult = await portTextureOperation.ExecuteAsync( + new PortTextureRequest( + [candidate.SourcePath], + sourceFormat, + candidate.SourcePath, + destinationFormat, + destinationPath, + Verbose: false, + UseGTF: false), + progress: null, + cancellationToken); + + if (!portResult.Success) + { + throw OperationResultFactory.CreateException(portResult, "Failed to port texture."); + } AddCase(summary, new GameAutotestCaseResult(game.Name, portCaseName, "porttexture", "PASS", TestedResourceType: ResourceType.Texture)); } @@ -356,18 +464,74 @@ await portTextureOperation.ExecuteAsync( } } + private async Task LoadAsync(string sourceFile, ResourceType resourceType, Platform platform, CancellationToken cancellationToken) + { + OperationResult result = await loadOperation.ExecuteAsync( + new LoadResourceRequest(sourceFile, resourceType, platform), + progress: null, + cancellationToken); + + if (!result.Success || result.Value == null) + { + throw OperationResultFactory.CreateException(result, "Failed to load resource."); + } + + return result.Value.Resource; + } + + private async Task ImportAsync(ImportResourceRequest request, CancellationToken cancellationToken) + { + OperationResult result = await importOperation.ExecuteAsync( + request, + progress: null, + cancellationToken); + + if (!result.Success || result.Value == null) + { + throw OperationResultFactory.CreateException(result, "Failed to import resource."); + } + + return result.Value; + } + + private async Task ExportAsync(ExportResourceRequest request, CancellationToken cancellationToken) + { + OperationResult result = await exportOperation.ExecuteAsync( + request, + progress: null, + cancellationToken); + + if (!result.Success) + { + throw OperationResultFactory.CreateException(result, "Failed to export resource."); + } + } + + private async Task SaveAsync(Resource resource, string filePath, CancellationToken cancellationToken) + { + OperationResult result = await saveOperation.ExecuteAsync( + new SaveResourceRequest(resource, filePath, Overwrite: true), + progress: null, + cancellationToken); + + if (!result.Success) + { + throw OperationResultFactory.CreateException(result, "Failed to save resource."); + } + } + private static IEnumerable GetDirect(GameInstall game) { _ = game; yield break; } - private static List ProbeBundles( + private List ProbeBundles( GameInstall game, string bundleToolPath, bool useYapBundleTool, string gameWorkRoot, - GameAutotestOptions options, + GameAutotestRequest options, GameAutotestSummary summary) { if (useYapBundleTool) @@ -376,7 +540,7 @@ private static List ProbeBundles( } string probeRoot = Path.Combine(gameWorkRoot, "bundle_probes"); - Directory.CreateDirectory(probeRoot); + pathProvider.CreateDirectory(probeRoot); HashSet reportedUnsupportedTypes = []; List probes = []; @@ -393,14 +557,14 @@ private static List ProbeBundles( { if (useYapBundleTool) { - ProcessUtilities.RunAndCapture( + processRunner.RunAndCapture( bundleToolPath, $"e \"{bundlePath}\" \"{outputDirectory}\"", Path.GetDirectoryName(bundleToolPath)); } else { - ProcessUtilities.RunAndCapture( + processRunner.RunAndCapture( bundleToolPath, $"--bundle \"{bundlePath}\" --output \"{outputDirectory}\" --manifest \"{manifestPath}\" --metadataonly", Path.GetDirectoryName(bundleToolPath)); @@ -418,8 +582,10 @@ private static List ProbeBundles( : ParseManifest(bundlePath, outputDirectory, manifestPath).ToList(); int supportedCount = entries.Count(entry => IsSupportedType(entry.ResourceType)); - Console.WriteLine( - $"AUTOTEST - Probed {bundleName}: Resources={entries.Count}, Supported={supportedCount}, Types={GetTypeSummary(entries.Select(entry => entry.ResourceType))}"); + messageSink.Info( + $"AUTOTEST - Probed {bundleName}: Resources={entries.Count}, Supported={supportedCount}, Types={GetTypeSummary(entries.Select(entry => entry.ResourceType))}", + MessageCategory.Autotest, + nameof(GameAutotestOperation)); foreach (ResourceType unsupportedType in entries .Select(entry => entry.ResourceType) @@ -444,11 +610,11 @@ private static List ProbeBundles( return probes; } - private static List ProbeYapBundles( + private List ProbeYapBundles( GameInstall game, string bundleToolPath, string gameWorkRoot, - GameAutotestOptions options, + GameAutotestRequest options, GameAutotestSummary summary) { string probeRoot = Path.Combine(gameWorkRoot, "bundle_probes"); @@ -471,7 +637,7 @@ private static List ProbeYapBundles( try { - ProcessUtilities.RunAndCapture( + processRunner.RunAndCapture( bundleToolPath, $"-d e \"{inputRoot}\" \"{probeRoot}\"", Path.GetDirectoryName(bundleToolPath)); @@ -489,8 +655,10 @@ private static List ProbeYapBundles( { int supportedCount = probe.Entries.Count(entry => IsSupportedType(entry.ResourceType)); - Console.WriteLine( - $"AUTOTEST - Probed {Path.GetFileName(probe.BundlePath)}: Resources={probe.Entries.Count}, Supported={supportedCount}, Types={GetTypeSummary(probe.Entries.Select(entry => entry.ResourceType))}"); + messageSink.Info( + $"AUTOTEST - Probed {Path.GetFileName(probe.BundlePath)}: Resources={probe.Entries.Count}, Supported={supportedCount}, Types={GetTypeSummary(probe.Entries.Select(entry => entry.ResourceType))}", + MessageCategory.Autotest, + nameof(GameAutotestOperation)); foreach (ResourceType unsupportedType in probe.Entries .Select(entry => entry.ResourceType) @@ -513,17 +681,17 @@ private static List ProbeYapBundles( return probes; } - private static List GetBundleTests( + private List GetBundleTests( GameInstall game, string bundleToolPath, bool useYapBundleTool, string gameWorkRoot, - GameAutotestOptions options, + GameAutotestRequest options, IReadOnlyList probedBundles, GameAutotestSummary summary) { string extractedRoot = Path.Combine(gameWorkRoot, "bundles"); - Directory.CreateDirectory(extractedRoot); + pathProvider.CreateDirectory(extractedRoot); HashSet blockedTypes = []; Dictionary selectedCounts = new(); @@ -573,7 +741,7 @@ private static List GetBundleTests( try { - ProcessUtilities.RunAndCapture( + processRunner.RunAndCapture( bundleToolPath, $"--bundle \"{probedBundle.BundlePath}\" --output \"{outputDirectory}\" --manifest \"{manifestPath}\"", Path.GetDirectoryName(bundleToolPath)); @@ -598,8 +766,10 @@ private static List GetBundleTests( .Where(entry => !string.IsNullOrWhiteSpace(entry.PrimaryPath)) .ToList(); - Console.WriteLine( - $"AUTOTEST - Extracted {bundleName}: Resources={extractedEntries.Count}, Selected={selectedEntries.Count}"); + messageSink.Info( + $"AUTOTEST - Extracted {bundleName}: Resources={extractedEntries.Count}, Selected={selectedEntries.Count}", + MessageCategory.Autotest, + nameof(GameAutotestOperation)); } Dictionary extractedById = extractedEntries @@ -701,14 +871,14 @@ private static bool IsBundleFile(string path) } } - private static void ResetDirectory(string path) + private void ResetDirectory(string path) { - if (Directory.Exists(path)) + if (pathProvider.DirectoryExists(path)) { Directory.Delete(path, recursive: true); } - Directory.CreateDirectory(path); + pathProvider.CreateDirectory(path); } private static IEnumerable ParseManifest(string bundlePath, string outputDirectory, string manifestPath) @@ -782,7 +952,7 @@ private static List ParseMeta(string bundlePath, string out .ToList(); List entries = []; - foreach ((object rawResourceId, object rawResourceData) in resources) + foreach ((object rawResourceId, object rawResourceData) in resources!) { string? resourceIdHex = Convert.ToString(rawResourceId, CultureInfo.InvariantCulture)?.Trim(); if (string.IsNullOrWhiteSpace(resourceIdHex) || @@ -1053,7 +1223,7 @@ private static bool IsYapTool(string? bundleToolPath) return string.Equals(bundleToolPath, "YAP", StringComparison.OrdinalIgnoreCase); } - private static string GetBundleTool(string repoRoot, string? bundleToolPath) + private string GetBundleTool(string repoRoot, string? bundleToolPath) { if (IsYapTool(bundleToolPath)) { @@ -1062,8 +1232,8 @@ private static string GetBundleTool(string repoRoot, string? bundleToolPath) if (!string.IsNullOrWhiteSpace(bundleToolPath)) { - string explicitPath = Path.GetFullPath(bundleToolPath); - if (!File.Exists(explicitPath)) + string explicitPath = pathProvider.GetFullPath(bundleToolPath); + if (!pathProvider.FileExists(explicitPath)) { throw new FileNotFoundException($"Bundle extractor not found: {explicitPath}"); } @@ -1072,15 +1242,15 @@ private static string GetBundleTool(string repoRoot, string? bundleToolPath) } string defaultTool = Path.Combine(repoRoot, "tools", "libbndl-extractor", "build", "volatility_libbndl_extract.exe"); - if (File.Exists(defaultTool)) + if (pathProvider.FileExists(defaultTool)) { return defaultTool; } string buildScript = Path.Combine(repoRoot, "tools", "libbndl-extractor", "build.ps1"); - ProcessUtilities.RunAndCapture("powershell", $"-ExecutionPolicy Bypass -File \"{buildScript}\"", repoRoot); + processRunner.RunAndCapture("powershell", $"-ExecutionPolicy Bypass -File \"{buildScript}\"", repoRoot); - if (!File.Exists(defaultTool)) + if (!pathProvider.FileExists(defaultTool)) { throw new FileNotFoundException($"Failed to build bundle extractor at {defaultTool}"); } @@ -1088,27 +1258,32 @@ private static string GetBundleTool(string repoRoot, string? bundleToolPath) return defaultTool; } - private static string GetSessionRoot(string repoRoot, string? workingDirectory) + private string GetSessionRoot(string repoRoot, string? workingDirectory) { if (!string.IsNullOrWhiteSpace(workingDirectory)) { - return Path.GetFullPath(workingDirectory); + return pathProvider.GetFullPath(workingDirectory); } string stamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); return Path.Combine(repoRoot, ".tmp", "game-autotest", stamp); } - private static GameInstall DetectGame(string gamePath) + private GameInstall DetectGame(string gamePath) { - string fullPath = Path.GetFullPath(gamePath); - if (!Directory.Exists(fullPath)) + string fullPath = pathProvider.GetFullPath(gamePath); + if (!pathProvider.DirectoryExists(fullPath)) { throw new DirectoryNotFoundException($"Game directory not found: {fullPath}"); } - if (File.Exists(Path.Combine(fullPath, "BurnoutPR.exe")) || - File.Exists(Path.Combine(fullPath, "BurnoutPR_trial.exe"))) + if (pathProvider.FileExists(Path.Combine(fullPath, "BurnoutPR.exe")) || + pathProvider.FileExists(Path.Combine(fullPath, "BurnoutPR_trial.exe"))) + { + return new GameInstall(Path.GetFileName(fullPath), fullPath, Platform.BPR); + } + + if (pathProvider.FileExists(Path.Combine(fullPath, "BurnoutParadise.exe"))) { return new GameInstall(Path.GetFileName(fullPath), fullPath, Platform.TUB); } @@ -1254,29 +1429,41 @@ private static string SanitizePath(string value) return string.IsNullOrWhiteSpace(value) ? "game" : value; } - private static void AddCase(GameAutotestSummary summary, GameAutotestCaseResult result) + private void AddCase(GameAutotestSummary summary, GameAutotestCaseResult result) { summary.Cases.Add(result); + MessageSeverity severity; switch (result.Outcome) { case "PASS": summary.Passed++; - Console.ForegroundColor = ConsoleColor.Green; + severity = MessageSeverity.Success; break; case "FAIL": summary.Failed++; - Console.ForegroundColor = ConsoleColor.Red; + severity = MessageSeverity.Error; break; default: summary.Skipped++; - Console.ForegroundColor = ConsoleColor.DarkYellow; + severity = MessageSeverity.Warning; break; } - Console.WriteLine($"[{result.Outcome}] {result.Game} {result.Operation} {result.Name}" + - (string.IsNullOrWhiteSpace(result.Details) ? string.Empty : $" - {result.Details}")); - Console.ResetColor(); + messageSink.Publish(new VolatilityMessage( + severity, + MessageCategory.Autotest, + $"[{result.Outcome}] {result.Game} {result.Operation} {result.Name}" + + (string.IsNullOrWhiteSpace(result.Details) ? string.Empty : $" - {result.Details}"), + nameof(GameAutotestOperation), + new Dictionary + { + ["game"] = result.Game, + ["name"] = result.Name, + ["operation"] = result.Operation, + ["outcome"] = result.Outcome, + ["resourceType"] = result.TestedResourceType, + })); } private sealed record GameInstall(string Name, string RootPath, Platform Platform); diff --git a/src/Volatility.Core/Operations/OperationResultFactory.cs b/src/Volatility.Core/Operations/OperationResultFactory.cs new file mode 100644 index 0000000..6d5b980 --- /dev/null +++ b/src/Volatility.Core/Operations/OperationResultFactory.cs @@ -0,0 +1,29 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; + +namespace Volatility.Operations; + +internal static class OperationResultFactory +{ + public static OperationResult Success(T value, params OperationIssue[] issues) + { + return new OperationResult(true, value, issues); + } + + public static OperationResult Failure( + string code, + string message, + string source, + MessageSeverity severity = MessageSeverity.Error) + { + return new OperationResult( + false, + default, + [new OperationIssue(severity, code, message, source)]); + } + + public static InvalidOperationException CreateException(OperationResult result, string fallbackMessage) + { + return new InvalidOperationException(result.Issues.FirstOrDefault()?.Message ?? fallbackMessage); + } +} diff --git a/src/Volatility.Core/Operations/Resources/CreateResourceOperation.cs b/src/Volatility.Core/Operations/Resources/CreateResourceOperation.cs new file mode 100644 index 0000000..7504659 --- /dev/null +++ b/src/Volatility.Core/Operations/Resources/CreateResourceOperation.cs @@ -0,0 +1,115 @@ +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Operations; +using Volatility.Resources; + +namespace Volatility.Operations.Resources; + +internal sealed class CreateResourceOperation( + IPathProvider pathProvider, + IResourceFactory resourceFactory) + : IOperation +{ + public Task> ExecuteAsync( + CreateResourceRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + Resource resource = resourceFactory.CreateResource(request.ResourceType, request.Platform, request.IsX64); + + string resolvedPath = ResolveOutputPath(request.OutputPath, request.ResourceType, request.AssetName); + string resolvedAssetName = ResolveAssetName(request.AssetName, resolvedPath); + + if (!string.IsNullOrWhiteSpace(resolvedAssetName)) + { + resource.AssetName = resolvedAssetName; + } + + if (request.ResourceId.HasValue) + { + resource.ResourceID = request.ResourceId.Value; + } + else if (!string.IsNullOrWhiteSpace(resolvedAssetName)) + { + resource.ResourceID = ResourceID.HashFromString(resolvedAssetName); + } + + if (resource is ShaderBase shader && string.IsNullOrWhiteSpace(shader.ShaderSourcePath)) + { + shader.ShaderSourcePath = $"{Path.GetFileName(resolvedPath)}.hlsl"; + } + + progress?.Report(new OperationProgress("create-resource", 1.0, resolvedPath)); + return Task.FromResult(OperationResultFactory.Success(new CreateResourceResult(resource, resolvedPath))); + } + catch (Exception ex) + { + return Task.FromResult(OperationResultFactory.Failure( + "create_resource_failed", + ex.Message, + nameof(CreateResourceOperation))); + } + } + + private string ResolveOutputPath(string? outputPath, ResourceType resourceType, string? assetName) + { + string resourcesDirectory = pathProvider.GetDirectory(VolatilityPathLocation.Resources); + string resolvedPath; + + if (string.IsNullOrWhiteSpace(outputPath)) + { + if (string.IsNullOrWhiteSpace(assetName)) + { + throw new InvalidOperationException("Either a resource name or an output path must be provided."); + } + + resolvedPath = Path.Combine(resourcesDirectory, NormalizeGameDBPath(assetName)); + } + else if (!Path.IsPathRooted(outputPath)) + { + resolvedPath = Path.Combine(resourcesDirectory, NormalizeGameDBPath(outputPath)); + } + else + { + resolvedPath = NormalizeGameDBPath(outputPath); + } + + return Path.ChangeExtension(resolvedPath, resourceType.ToString()); + } + + private static string ResolveAssetName(string? assetName, string outputPath) + { + if (!string.IsNullOrWhiteSpace(assetName)) + { + return assetName; + } + + return Path.GetFileNameWithoutExtension(outputPath); + } + + private static string NormalizeGameDBPath(string path) + { + const string prefix = "gamedb://"; + + if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return "gamedb/" + path[prefix.Length..]; + } + + return path; + } +} + +public sealed record CreateResourceRequest( + ResourceType ResourceType, + Platform Platform, + string? AssetName, + string? OutputPath, + ResourceID? ResourceId, + bool IsX64) : IOperationRequest; + +public sealed record CreateResourceResult(Resource Resource, string ResourcePath); diff --git a/src/Volatility.Core/Operations/Resources/CreateShaderProgramBufferOperation.cs b/src/Volatility.Core/Operations/Resources/CreateShaderProgramBufferOperation.cs new file mode 100644 index 0000000..a1be9cd --- /dev/null +++ b/src/Volatility.Core/Operations/Resources/CreateShaderProgramBufferOperation.cs @@ -0,0 +1,114 @@ +using Volatility.Abstractions.Operations; +using Volatility.Operations; +using Volatility.Resources; +using Volatility.Utilities; + +namespace Volatility.Operations.Resources; + +internal sealed class CreateShaderProgramBufferOperation + : IOperation +{ + public async Task> ExecuteAsync( + CreateShaderProgramBufferRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + byte[] csoBytes; + if (request.CsoBytes != null) + { + csoBytes = request.CsoBytes; + } + else + { + if (string.IsNullOrWhiteSpace(request.CsoPath)) + { + return OperationResultFactory.Failure( + "shader_program_buffer_missing_input", + "Either a CSO path or CSO bytes must be provided.", + nameof(CreateShaderProgramBufferOperation)); + } + + if (!File.Exists(request.CsoPath)) + { + return OperationResultFactory.Failure( + "shader_program_buffer_missing_file", + $"CSO file not found: {request.CsoPath}", + nameof(CreateShaderProgramBufferOperation)); + } + + csoBytes = await File.ReadAllBytesAsync(request.CsoPath, cancellationToken); + } + + ShaderProgramBufferBase buffer = CreateBuffer(csoBytes, request.Stage, request.Platform); + progress?.Report(new OperationProgress("create-shader-program-buffer", 1.0, request.CsoPath)); + return OperationResultFactory.Success(new CreateShaderProgramBufferResult(buffer)); + } + catch (Exception ex) + { + return OperationResultFactory.Failure( + "create_shader_program_buffer_failed", + ex.Message, + nameof(CreateShaderProgramBufferOperation)); + } + } + + public static void WriteToFile(ShaderProgramBufferBase buffer, string outputPath, Platform platform) + { + ArgumentNullException.ThrowIfNull(buffer); + + ValidatePlatform(platform); + + Platform bufferPlatform = buffer.ResourcePlatform; + if (bufferPlatform != Platform.Agnostic && bufferPlatform != platform) + { + throw new InvalidOperationException($"ShaderProgramBuffer platform mismatch: buffer={bufferPlatform}, requested={platform}."); + } + + string? directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + using FileStream fs = new(outputPath, FileMode.Create); + Endian endian = buffer.ResourceEndian; + if (endian == Endian.Agnostic) + { + endian = EndianMapping.GetDefaultEndian(platform); + } + + using ResourceBinaryWriter writer = new(fs, endian); + buffer.WriteToStream(writer, endian); + } + + private static ShaderProgramBufferBase CreateBuffer(byte[] csoBytes, ShaderStageType stage, Platform platform) + { + return platform switch + { + Platform.BPR => ShaderProgramBufferBPR.FromCSO(csoBytes, stage), + _ => throw new NotSupportedException($"ShaderProgramBuffer is not implemented for platform {platform}.") + }; + } + + private static void ValidatePlatform(Platform platform) + { + if (platform == Platform.BPR) + { + return; + } + + throw new NotSupportedException($"ShaderProgramBuffer is not implemented for platform {platform}."); + } +} + +public sealed record CreateShaderProgramBufferRequest( + ShaderStageType Stage, + Platform Platform, + string? CsoPath = null, + byte[]? CsoBytes = null) : IOperationRequest; + +public sealed record CreateShaderProgramBufferResult(ShaderProgramBufferBase Buffer); diff --git a/src/Volatility.Core/Operations/Resources/ExportResourceOperation.cs b/src/Volatility.Core/Operations/Resources/ExportResourceOperation.cs new file mode 100644 index 0000000..6e27096 --- /dev/null +++ b/src/Volatility.Core/Operations/Resources/ExportResourceOperation.cs @@ -0,0 +1,393 @@ +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Operations; +using Volatility.Resources; +using Volatility.Utilities; + +namespace Volatility.Operations.Resources; + +internal sealed class ExportResourceOperation( + IPathProvider pathProvider, + IShaderCompiler shaderCompiler, + IOperation shaderProgramBufferOperation, + ISplicerSampleStore splicerSampleStore) + : IOperation +{ + public async Task> ExecuteAsync( + ExportResourceRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + Resource resource = request.Resource; + string outputPath = request.OutputPath; + Platform platform = request.Platform; + + if (!request.Overwrite && TryGetExistingOutputPath(resource, outputPath, platform, request.ImportUnpackerOverride, request.WriteImportsToSeparateFile, out string? existingOutputPath)) + { + return OperationResultFactory.Failure( + "export_resource_target_exists", + $"Output file already exists ({existingOutputPath}). Use overwrite to replace it.", + nameof(ExportResourceOperation)); + } + + string? directoryPath = Path.GetDirectoryName(outputPath); + + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + if (resource is Splicer splicer) + { + splicerSampleStore.PopulateDependentSamples( + splicer, + request.SplicerDirectory ?? pathProvider.GetDirectory(VolatilityPathLocation.Splicer)); + } + + using FileStream fs = new(outputPath, request.Overwrite ? FileMode.Create : FileMode.CreateNew); + + Endian endian = resource.ResourceEndian != Endian.Agnostic + ? resource.ResourceEndian + : EndianMapping.GetDefaultEndian(platform); + + using ResourceBinaryWriter writer = new(fs, endian); + + switch (resource) + { + case TextureBase texture: + texture.PushAll(); + goto default; + default: + resource.WriteToStream(writer); + break; + } + + WriteExternalImports( + resource, + outputPath, + writer, + endian, + ResolveExternalImportsUnpackerFormat(resource, request.ImportUnpackerOverride), + request.WriteImportsToSeparateFile); + + if (resource is ShaderBase shader) + { + var stages = shader.GetCompileStages(); + bool useStageSuffix = stages.Count > 1; + + if (platform == Platform.BPR) + { + foreach (var stage in stages) + { + cancellationToken.ThrowIfCancellationRequested(); + + string shaderProgramBufferPath = GetShaderProgramBufferPath(outputPath, stage, useStageSuffix); + string csoPath = GetShaderCSOPath(outputPath, stage, useStageSuffix); + + shaderCompiler.CompileToCSO(shader, stage, csoPath); + OperationResult bufferResult = await shaderProgramBufferOperation.ExecuteAsync( + new CreateShaderProgramBufferRequest(stage.ResolveStage(), platform, csoPath), + progress: null, + cancellationToken); + + if (!bufferResult.Success || bufferResult.Value == null) + { + throw OperationResultFactory.CreateException(bufferResult, "Failed to create shader program buffer."); + } + + ShaderProgramBufferBase buffer = bufferResult.Value.Buffer; + CreateShaderProgramBufferOperation.WriteToFile(buffer, shaderProgramBufferPath, platform); + WritePaddedCSOFile(csoPath, GetSecondaryResourcePath(shaderProgramBufferPath)); + } + } + else + { + shaderCompiler.CompileStagesToCSO(shader, stages, stage => + GetShaderProgramBufferPath(outputPath, stage, useStageSuffix)); + } + } + + if (resource is ShaderProgramBufferBPR shaderProgramBuffer && platform == Platform.BPR) + { + if (shaderProgramBuffer.CompiledShaderBytecode.Length > 0) + { + string secondaryCSOPath = GetSecondaryResourcePath(outputPath); + WritePaddedCSOBytes(shaderProgramBuffer.CompiledShaderBytecode, secondaryCSOPath); + } + } + + progress?.Report(new OperationProgress("export-resource", 1.0, outputPath)); + return OperationResultFactory.Success(new ExportResourceResult(outputPath)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return OperationResultFactory.Failure( + "export_resource_failed", + ex.Message, + nameof(ExportResourceOperation)); + } + } + + private static Unpacker ResolveExternalImportsUnpackerFormat( + Resource resource, + Unpacker? importUnpackerOverride) + { + return importUnpackerOverride ?? resource.Unpacker; + } + + private static bool TryGetExistingOutputPath( + Resource resource, + string outputPath, + Platform platform, + Unpacker? importUnpackerOverride, + bool forceExternalImportsFile, + out string? existingOutputPath) + { + foreach (string path in EnumerateOutputPaths(resource, outputPath, platform, importUnpackerOverride, forceExternalImportsFile)) + { + if (File.Exists(path)) + { + existingOutputPath = path; + return true; + } + } + + existingOutputPath = null; + return false; + } + + private static IEnumerable EnumerateOutputPaths( + Resource resource, + string outputPath, + Platform platform, + Unpacker? importUnpackerOverride, + bool forceExternalImportsFile) + { + yield return outputPath; + + foreach (string importsPath in EnumerateImportsSidecarPaths(outputPath, ResolveExternalImportsUnpackerFormat(resource, importUnpackerOverride), forceExternalImportsFile)) + { + yield return importsPath; + } + + if (resource is ShaderBase shader) + { + var stages = shader.GetCompileStages(); + bool useStageSuffix = stages.Count > 1; + + foreach (var stage in stages) + { + string shaderProgramBufferPath = GetShaderProgramBufferPath(outputPath, stage, useStageSuffix); + + yield return shaderProgramBufferPath; + + if (platform == Platform.BPR) + { + yield return GetShaderCSOPath(outputPath, stage, useStageSuffix); + yield return GetSecondaryResourcePath(shaderProgramBufferPath); + } + } + } + + if (resource is ShaderProgramBufferBPR shaderProgramBuffer && + platform == Platform.BPR && + shaderProgramBuffer.CompiledShaderBytecode.Length > 0) + { + yield return GetSecondaryResourcePath(outputPath); + } + } + + private static IEnumerable EnumerateImportsSidecarPaths( + string outputPath, + Unpacker importUnpacker, + bool forceExternalImportsFile) + { + string yamlImportsPath = ResourceImport.GetImportsPath(outputPath, Unpacker.YAP); + string datImportsPath = ResourceImport.GetImportsPath(outputPath, Unpacker.Raw); + + if (importUnpacker == Unpacker.YAP) + { + yield return yamlImportsPath; + yield return datImportsPath; + yield break; + } + + if (forceExternalImportsFile) + { + yield return datImportsPath; + yield return yamlImportsPath; + yield break; + } + + yield return yamlImportsPath; + yield return datImportsPath; + } + + private static void WriteExternalImports( + Resource resource, + string outputPath, + ResourceBinaryWriter writer, + Endian endian, + Unpacker importUnpacker, + bool forceExternalImportsFile) + { + List> imports = resource.GetExternalImports().ToList(); + + string yamlImportsPath = ResourceImport.GetImportsPath(outputPath, Unpacker.YAP); + string datImportsPath = ResourceImport.GetImportsPath(outputPath, Unpacker.Raw); + + if (imports.Count == 0) + { + ResourceImport.DeleteImportsSidecarFiles(outputPath); + return; + } + + if (importUnpacker == Unpacker.YAP) + { + ResourceImport.DeleteImportsSidecarFiles(outputPath); + WriteExternalImportsYaml(imports, yamlImportsPath); + return; + } + + if (forceExternalImportsFile) + { + ResourceImport.DeleteImportsSidecarFiles(outputPath); + WriteExternalImportsDat(datImportsPath, endian, imports); + return; + } + + writer.BaseStream.Seek(0, SeekOrigin.End); + WriteBinaryImports(writer, imports); + ResourceImport.DeleteImportsSidecarFiles(outputPath); + } + + private static void WriteExternalImportsYaml( + List> imports, + string importsPath) + { + List lines = new(imports.Count); + foreach (KeyValuePair entry in imports) + { + ulong resourceId = ResourceUtilities.ResolveResourceID(entry.Value); + lines.Add($"- \"0x{entry.Key:x8}\": \"{resourceId:X8}\""); + } + + File.WriteAllLines(importsPath, lines); + } + + private static void WriteExternalImportsDat( + string importsPath, + Endian endianness, + List> imports) + { + using FileStream fs = new(importsPath, FileMode.Create, FileAccess.Write); + using EndianAwareBinaryWriter writer = new(fs, endianness); + WriteBinaryImports(writer, imports); + } + + private static void WriteBinaryImports( + EndianAwareBinaryWriter writer, + List> imports) + { + foreach (KeyValuePair entry in imports) + { + if (entry.Key < 0 || entry.Key > uint.MaxValue) + { + throw new InvalidDataException( + $"Import offset 0x{entry.Key:X} cannot be stored in a binary imports block."); + } + + // Probably overkill but I just want to make sure we always use the correct writer overloads + writer.Write((ulong)ResourceUtilities.ResolveResourceID(entry.Value)); + writer.Write((uint)entry.Key); + writer.Write(0x00000000); + } + } + + private static string GetShaderProgramBufferPath( + string shaderOutputPath, + ShaderStageCompile stage, + bool useStageSuffix) + { + string? directory = Path.GetDirectoryName(shaderOutputPath); + string baseName = Path.GetFileNameWithoutExtension(shaderOutputPath); + string stageSuffix = useStageSuffix ? $".{ShaderStageCompile.GetStageSuffix(stage.ResolveStage())}" : string.Empty; + string fileName = $"{baseName}.secondary{stageSuffix}.ShaderProgramBuffer"; + return string.IsNullOrEmpty(directory) + ? fileName + : Path.Combine(directory, fileName); + } + + private static string GetShaderCSOPath( + string shaderOutputPath, + ShaderStageCompile stage, + bool useStageSuffix) + { + string? directory = Path.GetDirectoryName(shaderOutputPath); + string baseName = Path.GetFileNameWithoutExtension(shaderOutputPath); + string stageSuffix = useStageSuffix ? $".{ShaderStageCompile.GetStageSuffix(stage.ResolveStage())}" : string.Empty; + string fileName = $"{baseName}.secondary{stageSuffix}.cso"; + return string.IsNullOrEmpty(directory) + ? fileName + : Path.Combine(directory, fileName); + } + + private static string GetSecondaryResourcePath(string primaryPath) + { + string? directory = Path.GetDirectoryName(primaryPath); + string baseName = Path.GetFileNameWithoutExtension(primaryPath); + string extension = Path.GetExtension(primaryPath); + string fileName = $"{baseName}.secondary{extension}"; + return string.IsNullOrEmpty(directory) + ? fileName + : Path.Combine(directory, fileName); + } + + private static void WritePaddedCSOFile(string sourcePath, string outputPath) + { + string? directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + using FileStream input = new(sourcePath, FileMode.Open, FileAccess.Read); + using FileStream output = new(outputPath, FileMode.Create, FileAccess.Write); + input.CopyTo(output); + + PaddingUtilities.WritePadding(output, 0x100); + } + + private static void WritePaddedCSOBytes(byte[] csoBytes, string outputPath) + { + string? directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + using FileStream output = new(outputPath, FileMode.Create, FileAccess.Write); + output.Write(csoBytes); + + PaddingUtilities.WritePadding(output, 0x100); + } +} + +public sealed record ExportResourceRequest( + Resource Resource, + string OutputPath, + Platform Platform, + Unpacker? ImportUnpackerOverride = null, + bool WriteImportsToSeparateFile = false, + bool Overwrite = false, + string? SplicerDirectory = null) : IOperationRequest; + +public sealed record ExportResourceResult(string OutputPath); diff --git a/src/Volatility.Core/Operations/Resources/ImportResourceOperation.cs b/src/Volatility.Core/Operations/Resources/ImportResourceOperation.cs new file mode 100644 index 0000000..089253e --- /dev/null +++ b/src/Volatility.Core/Operations/Resources/ImportResourceOperation.cs @@ -0,0 +1,185 @@ +using System.Text.RegularExpressions; +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Operations; +using Volatility.Resources; + +namespace Volatility.Operations.Resources; + +internal sealed partial class ImportResourceOperation( + IResourceSerializer resourceSerializer, + IResourceDBLookup resourceDBLookup, + ITextureBitmapStore textureBitmapStore, + IProcessRunner processRunner, + IShaderSourceStore shaderSourceStore, + IMessageSink messageSink) + : IOperation +{ + public async Task> ExecuteAsync( + ImportResourceRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + Resource resource; + using (FileStream stream = File.OpenRead(request.SourceFile)) + { + resource = resourceSerializer.Deserialize( + stream, + request.ResourceType, + request.Platform, + new ResourceSerializationOptions + { + FileName = request.SourceFile, + ResourceDBLookup = resourceDBLookup, + x64 = request.IsX64 + }); + } + + string filePath = Path.Combine + ( + request.ResourcesDirectory, + $"{DBToFileRegex().Replace(resource.AssetName, string.Empty)}.{request.ResourceType}" + ); + + string? directoryPath = Path.GetDirectoryName(filePath); + + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + if (resource is ShaderBase shader) + { + shaderSourceStore.MaterializeImportedSource(shader, request.ResourcesDirectory); + } + + if (request.ResourceType == ResourceType.Texture) + { + string texturePath = textureBitmapStore.GetSecondaryBitmapPath(request.SourceFile, resource.Unpacker); + + if (resource is TextureBase texture && File.Exists(texturePath)) + { + string outPath = Path.Combine + ( + directoryPath ?? string.Empty, + Path.GetFileNameWithoutExtension(Path.GetFileNameWithoutExtension(Path.GetFullPath(filePath))) + ); + + textureBitmapStore.WriteNormalizedBitmapFile(texture, texturePath, $"{outPath}.{request.ResourceType}Bitmap", request.Overwrite); + } + } + + if (request.ResourceType == ResourceType.Splicer) + { + string sxPath = Path.Combine + ( + request.ToolsDirectory, + "sx.exe" + ); + + bool sxExists = File.Exists(sxPath); + + Splicer? splicer = resource as Splicer; + + List? samples = splicer?.GetLoadedSamples(); + + string sampleDirectory = Path.Combine + ( + request.SplicerDirectory, + "Samples" + ); + + Directory.CreateDirectory(sampleDirectory); + + if (samples != null) + { + for (int i = 0; i < samples.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + string sampleName = $"{samples[i].SampleID}"; + + string samplePathName = Path.Combine(sampleDirectory, sampleName); + + if (!File.Exists($"{samplePathName}.snr") || request.Overwrite) + { + messageSink.Info( + $"Writing extracted sample {sampleName}.snr", + MessageCategory.Resource, + nameof(ImportResourceOperation)); + await File.WriteAllBytesAsync($"{samplePathName}.snr", samples[i].Data, cancellationToken); + } + else + { + messageSink.Info( + $"Skipping extracted sample {sampleName}.snr", + MessageCategory.Resource, + nameof(ImportResourceOperation)); + } + + if (sxExists) + { + string convertedSamplePathName = Path.Combine(sampleDirectory, "_extracted"); + + Directory.CreateDirectory(convertedSamplePathName); + + convertedSamplePathName = Path.Combine(convertedSamplePathName, sampleName + ".wav"); + + if (!File.Exists(convertedSamplePathName) || request.Overwrite) + { + messageSink.Info( + $"Converting extracted sample {sampleName}.snr to wave...", + MessageCategory.Resource, + nameof(ImportResourceOperation)); + processRunner.RunAndRelayOutput( + sxPath, + $"-wave -s16l_int -v0 \"{samplePathName}.snr\" -=\"{convertedSamplePathName}\""); + } + else + { + messageSink.Info( + $"Converted sample {Path.GetFileName(convertedSamplePathName)} already exists, skipping...", + MessageCategory.Resource, + nameof(ImportResourceOperation)); + } + } + } + } + } + + progress?.Report(new OperationProgress("import-resource", 1.0, filePath)); + return OperationResultFactory.Success(new ImportResourceResult(resource, filePath)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return OperationResultFactory.Failure( + "import_resource_failed", + ex.Message, + nameof(ImportResourceOperation)); + } + } + + [GeneratedRegex(@"(\?ID=\d+)|:")] + private static partial Regex DBToFileRegex(); +} + +public sealed record ImportResourceRequest( + ResourceType ResourceType, + Platform Platform, + string SourceFile, + bool IsX64, + string ResourcesDirectory, + string ToolsDirectory, + string SplicerDirectory, + bool Overwrite) : IOperationRequest; + +public sealed record ImportResourceResult(Resource Resource, string ResourcePath); diff --git a/src/Volatility.Core/Operations/Resources/LoadResourceOperation.cs b/src/Volatility.Core/Operations/Resources/LoadResourceOperation.cs new file mode 100644 index 0000000..ba032b4 --- /dev/null +++ b/src/Volatility.Core/Operations/Resources/LoadResourceOperation.cs @@ -0,0 +1,65 @@ +using System.Runtime.Serialization; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Operations; +using Volatility.Resources; +using Volatility.Utilities; + +namespace Volatility.Operations.Resources; + +internal sealed class LoadResourceOperation(IResourceFactory resourceFactory) + : IOperation +{ + public async Task> ExecuteAsync( + LoadResourceRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.SourceFile)) + { + return OperationResultFactory.Failure( + "load_resource_missing_path", + "A source file path is required.", + nameof(LoadResourceOperation)); + } + + try + { + string yaml = await File.ReadAllTextAsync(request.SourceFile, cancellationToken); + + Resource resource = resourceFactory.CreateResource(request.ResourceType, request.Platform); + Resource? result = (Resource?)ResourceYamlDeserializer.DeserializeResource(resource.GetType(), yaml); + + if (result is null) + { + return OperationResultFactory.Failure( + "load_resource_deserialize_failed", + $"Unable to deserialize '{Path.GetFileName(request.SourceFile)}'.", + nameof(LoadResourceOperation)); + } + + result.ImportedFileName = request.SourceFile; + progress?.Report(new OperationProgress("load-resource", 1.0, request.SourceFile)); + return OperationResultFactory.Success(new LoadResourceResult(result)); + } + catch (SerializationException ex) + { + return OperationResultFactory.Failure( + "load_resource_deserialize_failed", + ex.Message, + nameof(LoadResourceOperation)); + } + catch (Exception ex) + { + return OperationResultFactory.Failure( + "load_resource_failed", + ex.Message, + nameof(LoadResourceOperation)); + } + } + +} + +public sealed record LoadResourceRequest(string SourceFile, ResourceType ResourceType, Platform Platform) : IOperationRequest; + +public sealed record LoadResourceResult(Resource Resource); diff --git a/src/Volatility.Core/Operations/Resources/PortTextureOperation.cs b/src/Volatility.Core/Operations/Resources/PortTextureOperation.cs new file mode 100644 index 0000000..21cf52f --- /dev/null +++ b/src/Volatility.Core/Operations/Resources/PortTextureOperation.cs @@ -0,0 +1,271 @@ +using System.Reflection; + +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Operations; +using Volatility.Resources; +using Volatility.Utilities; + +using static Volatility.Utilities.ResourceIDUtilities; + +namespace Volatility.Operations.Resources; + +internal sealed class PortTextureOperation( + IResourceFactory resourceFactory, + IResourceSerializer resourceSerializer, + IResourceDBLookup resourceDBLookup, + ITextureBitmapStore textureBitmapStore, + IMessageSink messageSink) + : IOperation +{ + public async Task> ExecuteAsync( + PortTextureRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + string resolvedDestinationPath = string.IsNullOrEmpty(request.DestinationPath) + ? request.SourcePath + : request.DestinationPath; + TextureFormatSpec sourceSpec = ParseTextureFormat(request.SourceFormat); + TextureFormatSpec destinationSpec = ParseTextureFormat(request.DestinationFormat); + + List outputPaths = new(request.SourceFiles.Count); + for (int i = 0; i < request.SourceFiles.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + string outputPath = await PortFileAsync( + request.SourceFiles[i], + resolvedDestinationPath, + sourceSpec, + destinationSpec, + request.Verbose, + request.UseGTF, + cancellationToken); + outputPaths.Add(outputPath); + progress?.Report(new OperationProgress( + "port-texture", + (double)outputPaths.Count / request.SourceFiles.Count, + outputPath)); + } + + return OperationResultFactory.Success(new PortTextureResult(outputPaths)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return OperationResultFactory.Failure( + "port_texture_failed", + ex.Message, + nameof(PortTextureOperation)); + } + } + + private async Task PortFileAsync( + string sourceFile, + string resolvedDestinationPath, + TextureFormatSpec sourceSpec, + TextureFormatSpec destinationSpec, + bool verbose, + bool useGTF, + CancellationToken cancellationToken) + { + TextureBase sourceTexture = LoadSourceTexture(sourceFile, sourceSpec, verbose); + TextureBase destinationTexture = CreateDestinationTexture(destinationSpec, verbose); + + string localSourceFormat = sourceSpec.DisplayName; + string localDestinationFormat = destinationSpec.DisplayName; + + TextureFormatConverter.CopyProperties(sourceTexture, destinationTexture); + + TextureFormatConverter.ConvertFormat( + sourceTexture, + destinationTexture, + localSourceFormat, + localDestinationFormat, + out bool flipEndian, + out int sourceFormatIndex, + out int destinationFormatIndex, + msg => LogWarning(msg)); + + destinationTexture.PushAll(); + + string outPath = string.Empty; + + string outResourceFilename = (flipEndian && sourceTexture.Unpacker != Unpacker.YAP) + ? FlipPathResourceIDEndian(Path.GetFileName(sourceFile)) + : Path.GetFileName(sourceFile); + + if (resolvedDestinationPath == sourceFile) + { + outPath = $"{Path.GetDirectoryName(resolvedDestinationPath)}{Path.DirectorySeparatorChar}{outResourceFilename}"; + } + else if (Directory.Exists(resolvedDestinationPath) || !Path.HasExtension(resolvedDestinationPath)) + { + outPath = Path.Combine(resolvedDestinationPath, outResourceFilename); + } + else + { + outPath = resolvedDestinationPath; + } + + string? outputDirectory = Path.GetDirectoryName(outPath); + if (!string.IsNullOrEmpty(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + string sourceBitmapPath = textureBitmapStore.GetSecondaryBitmapPath(sourceFile, sourceTexture.Unpacker); + + if (!Path.Exists(sourceBitmapPath)) + { + LogWarning($"Failed to find associated bitmap data for {Path.GetFileNameWithoutExtension(sourceFile)} at path {sourceBitmapPath}!"); + } + + string destinationBitmapPath = textureBitmapStore.GetSecondaryBitmapPath(outPath, sourceTexture.Unpacker); + + if (Path.Exists(destinationBitmapPath)) + { + LogVerbose(verbose, $"Found existing bitmap data at {destinationBitmapPath}, overwriting..."); + } + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + if (useGTF && sourceTexture is TexturePS3 sourcePs3) + { + textureBitmapStore.ConvertPS3GTFToDDS(sourcePs3, sourceBitmapPath, destinationBitmapPath, verbose); + } + + byte[] sourceBitmapData = textureBitmapStore.ReadNormalizedBitmapData(sourceTexture, sourceBitmapPath); + + if (destinationTexture is TextureX360 destX && sourceTexture.ResourcePlatform != Platform.X360) + { + destX.Format.MaxMipLevel = destX.Format.MinMipLevel; + } + + if (!TextureFormatConverter.TryConvertTexture(sourceTexture, destinationTexture, sourceBitmapData, destinationBitmapPath)) + { + LogVerbose(verbose, $"Writing associated bitmap data for {Path.GetDirectoryName(outPath)}{Path.DirectorySeparatorChar}{Path.GetFileNameWithoutExtension(outPath)}_texture.dat..."); + await File.WriteAllBytesAsync(destinationBitmapPath, sourceBitmapData, cancellationToken); + } + else + { + LogVerbose(verbose, $"Converting associated bitmap data for {Path.GetDirectoryName(outPath)}{Path.DirectorySeparatorChar}{Path.GetFileNameWithoutExtension(outPath)}_texture.dat..."); + } + LogVerbose(verbose, $"Wrote texture bitmap data to {destinationSpec.DisplayName} destination directory."); + + if (destinationTexture is TextureBPR destBprTexture && File.Exists(destinationBitmapPath)) + { + destBprTexture.PlacedDataSize = (uint)new FileInfo(destinationBitmapPath).Length; + LogVerbose(verbose, $"BPR PlacedDataSize set to {destBprTexture.PlacedDataSize} (file: {destinationBitmapPath})."); + } + + using FileStream fs = new(outPath, FileMode.Create, FileAccess.Write); + using (ResourceBinaryWriter writer = new(fs, destinationTexture.ResourceEndian)) + { + try + { + LogVerbose(verbose, $"Writing converted {destinationSpec.DisplayName} texture property data to destination file {Path.GetFileName(outPath)}..."); + destinationTexture.WriteToStream(writer); + } + catch + { + throw new IOException("Failed to write converted texture property data to stream."); + } + writer.Close(); + fs.Close(); + } + } + catch (Exception ex) + { + throw new IOException( + $"Failed to port texture data for {Path.GetFileNameWithoutExtension(sourceFile)}: {ex.Message}", + ex); + } + + messageSink.Success( + $"Successfully ported {localSourceFormat} formatted {Path.GetFileNameWithoutExtension(sourceFile)} to {localDestinationFormat} as {Path.GetFileNameWithoutExtension(outPath)}.", + MessageCategory.Texture, + nameof(PortTextureOperation)); + return outPath; + } + + private TextureBase LoadSourceTexture(string path, TextureFormatSpec format, bool verbose) + { + LogVerbose(verbose, $"Loading {format.DisplayName} texture property data..."); + using FileStream fs = File.OpenRead(path); + return (TextureBase)resourceSerializer.Deserialize( + fs, + ResourceType.Texture, + format.Platform, + new ResourceSerializationOptions + { + FileName = path, + ResourceDBLookup = resourceDBLookup, + x64 = format.IsX64 + }); + } + + private TextureBase CreateDestinationTexture(TextureFormatSpec format, bool verbose) + { + LogVerbose(verbose, $"Constructing {format.DisplayName} texture property data..."); + return (TextureBase)resourceFactory.CreateResource(ResourceType.Texture, format.Platform, format.IsX64); + } + + private void LogVerbose(bool verbose, string text) + { + if (verbose) + { + messageSink.Verbose(text, MessageCategory.Texture, nameof(PortTextureOperation)); + } + } + + private void LogWarning(string text) + { + messageSink.Warning(text, MessageCategory.Texture, nameof(PortTextureOperation)); + } + + private static TextureFormatSpec ParseTextureFormat(string format) + { + string normalizedFormat = format.Trim().ToUpperInvariant(); + bool isX64 = normalizedFormat.EndsWith("X64", StringComparison.OrdinalIgnoreCase); + if (isX64) + { + normalizedFormat = normalizedFormat[..^3]; + } + + Platform platform = normalizedFormat switch + { + "BPR" => Platform.BPR, + "TUB" => Platform.TUB, + "X360" => Platform.X360, + "PS3" => Platform.PS3, + _ => throw new InvalidPlatformException(), + }; + + return new TextureFormatSpec(platform, isX64, isX64 ? $"{normalizedFormat}X64" : normalizedFormat); + } + + private readonly record struct TextureFormatSpec(Platform Platform, bool IsX64, string DisplayName); +} + +public sealed record PortTextureRequest( + IReadOnlyList SourceFiles, + string SourceFormat, + string SourcePath, + string DestinationFormat, + string? DestinationPath, + bool Verbose, + bool UseGTF) : IOperationRequest; + +public sealed record PortTextureResult(IReadOnlyList OutputPaths); diff --git a/src/Volatility.Core/Operations/Resources/SaveResourceOperation.cs b/src/Volatility.Core/Operations/Resources/SaveResourceOperation.cs new file mode 100644 index 0000000..098e389 --- /dev/null +++ b/src/Volatility.Core/Operations/Resources/SaveResourceOperation.cs @@ -0,0 +1,81 @@ +using Volatility.Abstractions.Operations; +using Volatility.Operations; +using Volatility.Resources; +using Volatility.Utilities; +using YamlDotNet.Serialization; + +namespace Volatility.Operations.Resources; + +internal sealed class SaveResourceOperation : IOperation +{ + private readonly ISerializer serializer; + + public SaveResourceOperation() + { + serializer = new SerializerBuilder() + .DisableAliases() + .WithTypeInspector(inner => new IncludeFieldsTypeInspector(inner)) + .WithTypeConverter(new ResourceYamlTypeConverter()) + .WithTypeConverter(new BitArrayYamlTypeConverter()) + .WithTypeConverter(new StrongIDYamlTypeConverter()) + .WithTypeConverter(new StringEnumYamlTypeConverter()) + .Build(); + } + + public async Task> ExecuteAsync( + SaveResourceRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + if (request.Resource is null) + { + return OperationResultFactory.Failure( + "save_resource_missing_resource", + "A resource instance is required.", + nameof(SaveResourceOperation)); + } + + if (string.IsNullOrWhiteSpace(request.FilePath)) + { + return OperationResultFactory.Failure( + "save_resource_missing_path", + "A file path is required.", + nameof(SaveResourceOperation)); + } + + try + { + if (!request.Overwrite && File.Exists(request.FilePath)) + { + return OperationResultFactory.Failure( + "save_resource_target_exists", + $"Output file already exists ({request.FilePath}). Use overwrite to replace it.", + nameof(SaveResourceOperation)); + } + + string? directoryPath = Path.GetDirectoryName(request.FilePath); + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + string serializedString = serializer.Serialize(request.Resource); + await File.WriteAllTextAsync(request.FilePath, serializedString, cancellationToken); + + progress?.Report(new OperationProgress("save-resource", 1.0, request.FilePath)); + return OperationResultFactory.Success(new SaveResourceResult(request.FilePath)); + } + catch (Exception ex) + { + return OperationResultFactory.Failure( + "save_resource_failed", + ex.Message, + nameof(SaveResourceOperation)); + } + } + +} + +public sealed record SaveResourceRequest(Resource Resource, string FilePath, bool Overwrite) : IOperationRequest; + +public sealed record SaveResourceResult(string FilePath); diff --git a/src/Volatility.Core/Operations/Resources/TextureFormatConverter.cs b/src/Volatility.Core/Operations/Resources/TextureFormatConverter.cs new file mode 100644 index 0000000..143860e --- /dev/null +++ b/src/Volatility.Core/Operations/Resources/TextureFormatConverter.cs @@ -0,0 +1,382 @@ +using System.Reflection; +using Volatility.Resources; +using Volatility.Utilities; + +namespace Volatility.Operations.Resources; + +public static class TextureFormatConverter +{ + public static readonly Dictionary X360toTUBMapping = new() + { + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT1, D3DFORMAT.D3DFMT_DXT1 }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT2_3, D3DFORMAT.D3DFMT_DXT3 }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT4_5, D3DFORMAT.D3DFMT_DXT5 }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8, D3DFORMAT.D3DFMT_A8 }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_16_16_16_16, D3DFORMAT.D3DFMT_A16B16G16R16 }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_8_8_8, D3DFORMAT.D3DFMT_A8R8G8B8 }, + }; + + public static readonly Dictionary X360toBPRMapping = new() + { + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT1, DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT2_3, DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT4_5, DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8, DXGI_FORMAT.DXGI_FORMAT_A8_UNORM }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_16_16_16_16, DXGI_FORMAT.DXGI_FORMAT_R16G16B16A16_UNORM }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_8_8_8, DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM }, + }; + + public static readonly Dictionary TUBtoBPRMapping = new() + { + { D3DFORMAT.D3DFMT_DXT1, DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM }, + { D3DFORMAT.D3DFMT_DXT3, DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM }, + { D3DFORMAT.D3DFMT_DXT5, DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM }, + { D3DFORMAT.D3DFMT_A8, DXGI_FORMAT.DXGI_FORMAT_A8_UNORM }, + { D3DFORMAT.D3DFMT_A8B8G8R8, DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM }, + { D3DFORMAT.D3DFMT_A8R8G8B8, DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM }, + }; + + public static readonly Dictionary BPRtoTUBMapping = new() + { + { DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM, D3DFORMAT.D3DFMT_DXT1 }, + { DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM, D3DFORMAT.D3DFMT_DXT3 }, + { DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM, D3DFORMAT.D3DFMT_DXT5 }, + { DXGI_FORMAT.DXGI_FORMAT_A8_UNORM, D3DFORMAT.D3DFMT_A8 }, + { DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM, D3DFORMAT.D3DFMT_A8B8G8R8 }, + }; + + public static readonly Dictionary PS3toTUBMapping = new() + { + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT1, D3DFORMAT.D3DFMT_DXT1 }, + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT23, D3DFORMAT.D3DFMT_DXT3 }, + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT45, D3DFORMAT.D3DFMT_DXT5 }, + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_B8, D3DFORMAT.D3DFMT_A8 }, + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8, D3DFORMAT.D3DFMT_A8R8G8B8 }, + }; + + public static readonly Dictionary PS3toX360Mapping = new() + { + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT1, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT1 }, + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT23, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT2_3 }, + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT45, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT4_5 }, + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_B8, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_B }, + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_8_8_8 }, + }; + + public static readonly Dictionary TUBtoX360Mapping = new() + { + { D3DFORMAT.D3DFMT_DXT1, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT1 }, + { D3DFORMAT.D3DFMT_DXT3, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT2_3 }, + { D3DFORMAT.D3DFMT_DXT5, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT4_5 }, + }; + + public static readonly Dictionary X360toPS3Mapping = new() + { + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT1, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT1 }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT2_3, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT23 }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT4_5, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT45 }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_B, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_B8 }, + { GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_8_8_8, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 }, + }; + + public static readonly Dictionary PS3toBPRMapping = new() + { + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT1, DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM }, + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT23, DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM }, + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT45, DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM }, + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_B8, DXGI_FORMAT.DXGI_FORMAT_A8_UNORM }, + { CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8, DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM } + }; + + public static readonly Dictionary BPRtoPS3Mapping = new() + { + { DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT1 }, + { DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT23 }, + { DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT45 }, + { DXGI_FORMAT.DXGI_FORMAT_A8_UNORM, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_B8 }, + { DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 }, + { DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 }, + }; + + public static readonly Dictionary TUBtoPS3Mapping = new() + { + { D3DFORMAT.D3DFMT_DXT1, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT1 }, + { D3DFORMAT.D3DFMT_DXT3, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT23 }, + { D3DFORMAT.D3DFMT_DXT5, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_COMPRESSED_DXT45 }, + { D3DFORMAT.D3DFMT_A8, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_B8 }, + { D3DFORMAT.D3DFMT_A8R8G8B8, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 }, + { D3DFORMAT.D3DFMT_A8B8G8R8, CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 }, + }; + + public static readonly Dictionary BPRtoX360Mapping = new() + { + { DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT1 }, + { DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT2_3 }, + { DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT4_5 }, + }; + + public static void CopyProperties(TextureBase source, TextureBase destination) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (destination == null) throw new ArgumentNullException(nameof(destination)); + + Type srcType = source.GetType(); + Type dstType = destination.GetType(); + + Type typeToReflect = srcType == dstType + ? srcType + : typeof(TextureBase); + + IEnumerable props = typeToReflect + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead + && p.CanWrite + && p.GetIndexParameters().Length == 0); + + foreach (PropertyInfo prop in props) + { + object? value = prop.GetValue(source); + prop.SetValue(destination, value); + } + } + + public static void ConvertFormat( + TextureBase sourceTexture, + TextureBase destinationTexture, + string localSourceFormat, + string localDestinationFormat, + out bool flipEndian, + out int sourceFormatIndex, + out int destinationFormatIndex, + Action warningLogger) + { + flipEndian = false; + sourceFormatIndex = 0; + destinationFormatIndex = 0; + + switch ((sourceTexture, destinationTexture)) + { + case (TexturePS3 ps3, TextureX360 x360): + PS3toX360Mapping.TryGetValue(ps3.Format, out GPUTEXTUREFORMAT ps3x360Format); + x360.Format.DataFormat = ps3x360Format; + x360.Format.Endian = GPUENDIAN.GPUENDIAN_NONE; + flipEndian = false; + sourceFormatIndex = (int)ps3.Format; + destinationFormatIndex = (int)ps3x360Format; + if (ps3x360Format == GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_1_REVERSE) + warningLogger($"Destination texture format is {ps3x360Format}! (Source is {ps3.Format})"); + break; + case (TextureX360 x360, TexturePS3 ps3): + X360toPS3Mapping.TryGetValue(x360.Format.DataFormat, out CELL_GCM_COLOR_FORMAT x360ps3Format); + ps3.Format = x360ps3Format; + flipEndian = false; + sourceFormatIndex = (int)x360.Format.DataFormat; + destinationFormatIndex = (int)x360ps3Format; + if (x360ps3Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_INVALID) + warningLogger($"Destination texture format is {x360ps3Format}! (Source is {x360.Format.DataFormat})"); + break; + case (TextureBPR bprsrc, TextureBPR bprdst): + bprdst.Format = bprsrc.Format; + sourceFormatIndex = (int)bprsrc.Format; + destinationFormatIndex = sourceFormatIndex; + break; + case (TexturePC tub, TextureBPR bpr): + TUBtoBPRMapping.TryGetValue(tub.Format, out DXGI_FORMAT tubbprFormat); + bpr.Format = tubbprFormat; + sourceFormatIndex = (int)tub.Format; + destinationFormatIndex = (int)tubbprFormat; + if (tubbprFormat == DXGI_FORMAT.DXGI_FORMAT_UNKNOWN) + warningLogger($"Destination texture format is {tubbprFormat}! (Source is {tub.Format})"); + break; + case (TextureBPR bpr, TexturePC tub): + BPRtoTUBMapping.TryGetValue(bpr.Format, out D3DFORMAT bprtubFormat); + tub.Format = bprtubFormat; + sourceFormatIndex = (int)bpr.Format; + destinationFormatIndex = (int)bprtubFormat; + if (bprtubFormat == D3DFORMAT.D3DFMT_UNKNOWN) + warningLogger($"Destination texture format is {bprtubFormat}! (Source is {bpr.Format})"); + break; + case (TexturePS3 ps3, TextureBPR bpr): + PS3toBPRMapping.TryGetValue(ps3.Format, out DXGI_FORMAT ps3bprFormat); + bpr.Format = ps3bprFormat; + flipEndian = true; + sourceFormatIndex = (int)ps3.Format; + destinationFormatIndex = (int)ps3bprFormat; + if (ps3bprFormat == DXGI_FORMAT.DXGI_FORMAT_UNKNOWN) + warningLogger($"Destination texture format is {ps3bprFormat}! (Source is {ps3.Format})"); + break; + case (TexturePS3 ps3, TexturePC tub): + PS3toTUBMapping.TryGetValue(ps3.Format, out D3DFORMAT ps3tubFormat); + tub.Format = ps3tubFormat; + flipEndian = true; + sourceFormatIndex = (int)ps3.Format; + destinationFormatIndex = (int)ps3tubFormat; + if (ps3tubFormat == D3DFORMAT.D3DFMT_UNKNOWN) + warningLogger($"Destination texture format is {ps3tubFormat}! (Source is {ps3.Format})"); + break; + case (TextureX360 x360, TexturePC tub): + X360toTUBMapping.TryGetValue(x360.Format.DataFormat, out D3DFORMAT x360tubFormat); + tub.Format = x360tubFormat; + flipEndian = true; + sourceFormatIndex = (int)x360.Format.DataFormat; + destinationFormatIndex = (int)x360tubFormat; + if (x360tubFormat == D3DFORMAT.D3DFMT_UNKNOWN) + warningLogger($"Destination texture format is {x360tubFormat}! (Source is {x360.Format.DataFormat})"); + break; + case (TextureX360 x360, TextureBPR bpr): + X360toBPRMapping.TryGetValue(x360.Format.DataFormat, out DXGI_FORMAT x360bprFormat); + bpr.Format = x360bprFormat; + flipEndian = true; + sourceFormatIndex = (int)x360.Format.DataFormat; + destinationFormatIndex = (int)x360bprFormat; + if (x360bprFormat == DXGI_FORMAT.DXGI_FORMAT_UNKNOWN) + warningLogger($"Destination texture format is {x360bprFormat}! (Source is {x360.Format.DataFormat})"); + break; + case (TexturePC tub, TextureX360 x360): + TUBtoX360Mapping.TryGetValue(tub.Format, out GPUTEXTUREFORMAT tubx360Format); + x360.Format.DataFormat = tubx360Format; + flipEndian = true; + sourceFormatIndex = (int)tub.Format; + destinationFormatIndex = (int)tubx360Format; + if (tubx360Format == GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_1_REVERSE) + warningLogger($"Destination texture format is {tubx360Format}! (Source is {tub.Format})"); + break; + case (TextureBPR bpr, TexturePS3 ps3): + BPRtoPS3Mapping.TryGetValue(bpr.Format, out CELL_GCM_COLOR_FORMAT bprps3format); + ps3.Format = bprps3format; + flipEndian = true; + sourceFormatIndex = (int)bpr.Format; + destinationFormatIndex = (int)bprps3format; + if (bprps3format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_INVALID) + warningLogger($"Destination texture format is {bprps3format}! (Source is {bpr.Format})"); + break; + case (TexturePC tub, TexturePS3 ps3): + TUBtoPS3Mapping.TryGetValue(tub.Format, out CELL_GCM_COLOR_FORMAT tubps3Format); + ps3.Format = tubps3Format; + flipEndian = true; + sourceFormatIndex = (int)tub.Format; + destinationFormatIndex = (int)tubps3Format; + if (tubps3Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_INVALID) + warningLogger($"Destination texture format is {tubps3Format}! (Source is {tub.Format})"); + break; + case (TextureBPR bpr, TextureX360 x360): + BPRtoX360Mapping.TryGetValue(bpr.Format, out GPUTEXTUREFORMAT bprx360Format); + x360.Format.DataFormat = bprx360Format; + flipEndian = true; + sourceFormatIndex = (int)bpr.Format; + destinationFormatIndex = (int)bprx360Format; + if (bprx360Format == GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_1_REVERSE) + warningLogger($"Destination texture format is {bprx360Format}! (Source is {bpr.Format})"); + break; + default: + throw new NotImplementedException($"Conversion technique {localSourceFormat} > {localDestinationFormat} is not yet implemented."); + } + } + + public static bool TryConvertTexture(TextureBase srcTexture, TextureBase destTexture, byte[] bitmap, string outPath) + { + switch (srcTexture, destTexture) + { + case (TexturePS3 ps3, TextureBPR bpr): + if (ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8) + { + if (bpr.Format == DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM) + { + DDSTextureUtilities.A8R8G8B8toR8G8B8A8(bitmap, ps3.Width, ps3.Height, ps3.MipmapLevels); + break; + } + + if (bpr.Format == DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM) + { + DDSTextureUtilities.A8R8G8B8toB8G8R8A8(bitmap, ps3.Width, ps3.Height, ps3.MipmapLevels); + break; + } + } + bitmap = Array.Empty(); + return false; + case (TexturePS3 ps3, TexturePC tub): + if (ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8) + { + if (tub.Format == D3DFORMAT.D3DFMT_A8R8G8B8) + { + break; + } + + if (tub.Format == D3DFORMAT.D3DFMT_A8B8G8R8) + { + DDSTextureUtilities.A8R8G8B8toA8B8G8R8(bitmap, ps3.Width, ps3.Height, ps3.MipmapLevels); + break; + } + } + bitmap = Array.Empty(); + return false; + case (TexturePS3 ps3, TextureX360 x360): + if (ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 + && x360.Format.DataFormat == GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_8_8_8) + { + break; + } + bitmap = Array.Empty(); + return false; + case (TexturePC tub, TextureBPR bpr): + if (tub.Format == D3DFORMAT.D3DFMT_A8R8G8B8 + && bpr.Format == DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM) + DDSTextureUtilities.A8R8G8B8toB8G8R8A8(bitmap, destTexture.Width, destTexture.Height, destTexture.MipmapLevels); + if (tub.Format == D3DFORMAT.D3DFMT_A8B8G8R8 + && bpr.Format == DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM) + DDSTextureUtilities.A8B8G8R8toB8G8R8A8(bitmap, destTexture.Width, destTexture.Height, destTexture.MipmapLevels); + break; + case (TextureBPR bpr, TexturePS3 ps3): + if (ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8) + { + if (bpr.Format == DXGI_FORMAT.DXGI_FORMAT_R8G8B8A8_UNORM) + { + DDSTextureUtilities.R8G8B8A8toA8R8G8B8(bitmap, bpr.Width, bpr.Height, bpr.MipmapLevels); + bitmap = PS3TextureUtilities.EncodePS3A8R8G8B8(bitmap, bpr.Width, bpr.Height, bpr.MipmapLevels); + break; + } + + if (bpr.Format == DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM) + { + DDSTextureUtilities.B8G8R8A8toA8R8G8B8(bitmap, bpr.Width, bpr.Height, bpr.MipmapLevels); + bitmap = PS3TextureUtilities.EncodePS3A8R8G8B8(bitmap, bpr.Width, bpr.Height, bpr.MipmapLevels); + break; + } + } + bitmap = Array.Empty(); + return false; + case (TexturePC tub, TexturePS3 ps3): + if (ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8) + { + if (tub.Format == D3DFORMAT.D3DFMT_A8R8G8B8) + { + bitmap = PS3TextureUtilities.EncodePS3A8R8G8B8(bitmap, tub.Width, tub.Height, tub.MipmapLevels); + break; + } + + if (tub.Format == D3DFORMAT.D3DFMT_A8B8G8R8) + { + DDSTextureUtilities.A8B8G8R8toA8R8G8B8(bitmap, tub.Width, tub.Height, tub.MipmapLevels); + bitmap = PS3TextureUtilities.EncodePS3A8R8G8B8(bitmap, tub.Width, tub.Height, tub.MipmapLevels); + break; + } + } + bitmap = Array.Empty(); + return false; + case (TextureX360 x360, TexturePS3 ps3): + if (ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 + && x360.Format.DataFormat == GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_8_8_8_8) + { + bitmap = PS3TextureUtilities.EncodePS3A8R8G8B8(bitmap, x360.Width, x360.Height, x360.MipmapLevels); + break; + } + bitmap = Array.Empty(); + return false; + default: + bitmap = Array.Empty(); + return false; + } + File.WriteAllBytes(outPath, bitmap); + return true; + } +} diff --git a/src/Volatility.Core/Operations/Resources/TextureRoundTripOperation.cs b/src/Volatility.Core/Operations/Resources/TextureRoundTripOperation.cs new file mode 100644 index 0000000..7205377 --- /dev/null +++ b/src/Volatility.Core/Operations/Resources/TextureRoundTripOperation.cs @@ -0,0 +1,90 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Core.Utilities; +using Volatility.Resources; +using Volatility.Utilities; + +namespace Volatility.Operations.Resources; + +public sealed record TextureRoundTripRequest(string Filename, TextureBase Header, bool SkipImport = false) : IOperationRequest; + +public sealed record TextureRoundTripResult( + string Filename, + bool PushImplemented, + List Mismatches); + +internal sealed class TextureRoundTripOperation( + IResourceSerializer resourceSerializer, + IMessageSink messageSink) + : IOperation +{ + public Task> ExecuteAsync( + TextureRoundTripRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + bool pushImplemented = true; + try + { + request.Header.PushAll(); + } + catch (NotImplementedException) + { + pushImplemented = false; + messageSink.Verbose( + $"A push isn't implemented for {request.Header.GetType().Name}!", + MessageCategory.Autotest, + nameof(TextureRoundTripOperation)); + } + + try + { + using (FileStream fs = new(request.Filename, FileMode.Create)) + { + using (ResourceBinaryWriter writer = new(fs, request.Header.ResourceEndian)) + { + messageSink.Info( + $"AUTOTEST - Writing autotest {request.Filename} to working directory...", + MessageCategory.Autotest, + nameof(TextureRoundTripOperation)); + request.Header.WriteToStream(writer); + writer.Close(); + } + } + + if (request.SkipImport) + { + return Task.FromResult(OperationResultFactory.Success(new TextureRoundTripResult(request.Filename, pushImplemented, []))); + } + + TextureBase newHeader; + using (FileStream fs = File.OpenRead(request.Filename)) + { + newHeader = (TextureBase)resourceSerializer.Deserialize( + fs, + ResourceType.Texture, + request.Header.ResourcePlatform, + new ResourceSerializationOptions + { + FileName = request.Filename, + x64 = request.Header.ResourceArch == Arch.x64 + }); + } + + List mismatches = ResourcePropertyComparer.Compare(request.Header, newHeader); + + progress?.Report(new OperationProgress("texture-roundtrip", 1.0, request.Filename)); + return Task.FromResult(OperationResultFactory.Success(new TextureRoundTripResult(request.Filename, pushImplemented, mismatches))); + } + catch (Exception ex) + { + return Task.FromResult(OperationResultFactory.Failure( + "texture_roundtrip_failed", + ex.Message, + nameof(TextureRoundTripOperation))); + } + } +} diff --git a/src/Volatility.Core/Operations/Resources/TextureToDDSOperation.cs b/src/Volatility.Core/Operations/Resources/TextureToDDSOperation.cs new file mode 100644 index 0000000..56f6d85 --- /dev/null +++ b/src/Volatility.Core/Operations/Resources/TextureToDDSOperation.cs @@ -0,0 +1,150 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Operations; +using Volatility.Resources; +using Volatility.Utilities; + +namespace Volatility.Operations.Resources; + +internal sealed class TextureToDDSOperation( + IResourceSerializer resourceSerializer, + IResourceDBLookup resourceDBLookup, + ITextureBitmapStore textureBitmapStore, + IMessageSink messageSink) + : IOperation +{ + public async Task> ExecuteAsync( + TextureToDDSRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + string[] files = request.SourceFiles.ToArray(); + bool multipleInputs = files.Length > 1; + + List> tasks = new(); + foreach (string sourceFile in files) + { + tasks.Add(ConvertFileAsync(sourceFile, request, multipleInputs, cancellationToken)); + } + + string[] outputPaths = await Task.WhenAll(tasks); + progress?.Report(new OperationProgress("texture-to-dds", 1.0, null)); + return OperationResultFactory.Success(new TextureToDDSResult(outputPaths)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return OperationResultFactory.Failure( + "texture_to_dds_failed", + ex.Message, + nameof(TextureToDDSOperation)); + } + } + + public static byte[] ConvertToDDS(TextureBase texture, byte[] bitmapData) + { + return DDSTextureUtilities.CreateDDSFile(texture, bitmapData); + } + + private async Task ConvertFileAsync( + string sourceFile, + TextureToDDSRequest request, + bool multipleInputs, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + using FileStream stream = File.OpenRead(sourceFile); + TextureBase texture = (TextureBase)resourceSerializer.Deserialize( + stream, + ResourceType.Texture, + request.Platform, + new ResourceSerializationOptions + { + FileName = sourceFile, + ResourceDBLookup = resourceDBLookup, + x64 = request.IsX64 + }); + + string sourceBitmapPath = textureBitmapStore.GetSecondaryBitmapPath(sourceFile, texture.Unpacker); + + if (!File.Exists(sourceBitmapPath)) + { + throw new FileNotFoundException($"Failed to find associated bitmap data at path '{sourceBitmapPath}'."); + } + + byte[] bitmapData = textureBitmapStore.ReadNormalizedBitmapData(texture, sourceBitmapPath); + byte[] ddsData = ConvertToDDS(texture, bitmapData); + string destinationPath = ResolveOutputPath(sourceFile, texture.Unpacker, request.OutputPath, multipleInputs, textureBitmapStore); + + string? destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + + if (!request.Overwrite && File.Exists(destinationPath)) + { + throw new IOException($"The file '{destinationPath}' already exists."); + } + + if (request.Verbose) + { + messageSink.Verbose( + $"Writing DDS texture data to {destinationPath}...", + MessageCategory.Texture, + nameof(TextureToDDSOperation)); + } + + await File.WriteAllBytesAsync(destinationPath, ddsData, cancellationToken); + messageSink.Info( + $"Wrote DDS for {Path.GetFileName(sourceFile)} to {destinationPath}.", + MessageCategory.Texture, + nameof(TextureToDDSOperation)); + + return destinationPath; + } + + private static string ResolveOutputPath( + string sourceFile, + Unpacker unpacker, + string? outputPath, + bool multipleInputs, + ITextureBitmapStore textureBitmapStore) + { + string outputName = textureBitmapStore.GetResourceBaseName(sourceFile, unpacker) + ".dds"; + + if (string.IsNullOrWhiteSpace(outputPath)) + { + return Path.Combine(Path.GetDirectoryName(sourceFile) ?? string.Empty, outputName); + } + + bool outputLooksLikeFile = Path.HasExtension(outputPath) + && string.Equals(Path.GetExtension(outputPath), ".dds", StringComparison.OrdinalIgnoreCase); + + if (!multipleInputs && outputLooksLikeFile) + { + return outputPath; + } + + return Path.Combine(outputPath, outputName); + } +} + +public sealed record TextureToDDSRequest( + IReadOnlyList SourceFiles, + Platform Platform, + bool IsX64, + string? OutputPath, + bool Overwrite, + bool Verbose) : IOperationRequest; + +public sealed record TextureToDDSResult(IReadOnlyList OutputPaths); diff --git a/src/Volatility.Core/Operations/StringTables/ImportStringTableOperation.cs b/src/Volatility.Core/Operations/StringTables/ImportStringTableOperation.cs new file mode 100644 index 0000000..f31db7e --- /dev/null +++ b/src/Volatility.Core/Operations/StringTables/ImportStringTableOperation.cs @@ -0,0 +1,149 @@ +using System.Text; +using System.Xml.Linq; +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Operations; + +using static Volatility.Utilities.DictUtilities; +using static Volatility.Utilities.ResourceIDUtilities; + +namespace Volatility.Operations.StringTables; + +internal sealed class ImportStringTableOperation + : IOperation +{ + private readonly IOperation mergeOperation; + private readonly IMessageSink messageSink; + + public ImportStringTableOperation( + IOperation mergeOperation, + IMessageSink messageSink) + { + this.mergeOperation = mergeOperation; + this.messageSink = messageSink; + } + + public async Task> ExecuteAsync( + ImportStringTableRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var results = await Task.WhenAll(request.FilePaths.Select(path => + ProcessFileAsync(path, request.Endian, request.Overwrite, request.Verbose, cancellationToken))); + + foreach (Dictionary> fileResult in results) + { + cancellationToken.ThrowIfCancellationRequested(); + + OperationResult mergeResult = await mergeOperation.ExecuteAsync( + new MergeStringTableEntriesRequest(request.Entries, fileResult, request.Overwrite), + progress: null, + cancellationToken); + + if (!mergeResult.Success) + { + return OperationResultFactory.Failure( + "import_string_table_merge_failed", + mergeResult.Issues.FirstOrDefault()?.Message ?? "Failed to merge string table entries.", + nameof(ImportStringTableOperation)); + } + } + + progress?.Report(new OperationProgress("import-string-table", 1.0, null)); + return OperationResultFactory.Success(new ImportStringTableResult(request.Entries)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return OperationResultFactory.Failure( + "import_string_table_failed", + ex.Message, + nameof(ImportStringTableOperation)); + } + } + + private async Task>> ProcessFileAsync( + string filePath, + string endian, + bool overwrite, + bool verbose, + CancellationToken cancellationToken) + { + var entriesByType = new Dictionary>(StringComparer.OrdinalIgnoreCase); + string fileName = Path.GetFileName(filePath)!; + string text = Encoding.UTF8.GetString(await File.ReadAllBytesAsync(filePath, cancellationToken)); + + int start = text.IndexOf("", StringComparison.Ordinal); + int end = text.IndexOf("", StringComparison.Ordinal) + "".Length; + if (start < 0 || end <= start) + { + if (verbose) + { + messageSink.Verbose( + $"Skipping (no table): {fileName}", + MessageCategory.StringTable, + nameof(ImportStringTableOperation)); + } + + return entriesByType; + } + + XDocument xmlDoc = XDocument.Parse(text[start..end]); + var resourceEntries = xmlDoc.Descendants("Resource") + .Select(x => new + { + Id = endian == "be" + ? FlipResourceIDEndian((string)x.Attribute("id")!) + : (string)x.Attribute("id")!, + Type = (string)x.Attribute("type")!, + Name = (string)x.Attribute("name")! + }).ToList(); + + foreach (var entry in resourceEntries) + { + var dict = entriesByType.GetOrCreate(entry.Type, () => new Dictionary()); + if (!dict.TryGetValue(entry.Id, out StringTableResourceEntry? existing)) + { + dict[entry.Id] = new StringTableResourceEntry { Name = entry.Name, Appearances = { fileName } }; + if (verbose) + { + messageSink.Verbose( + $"Found {entry.Type} entry in {Path.GetFileName(filePath)} - {entry.Name}", + MessageCategory.StringTable, + nameof(ImportStringTableOperation)); + } + } + else + { + if (overwrite) + { + existing.Name = entry.Name; + } + + if (!existing.Appearances.Contains(fileName)) + { + existing.Appearances.Add(fileName); + } + } + } + + return entriesByType; + } +} + +public sealed record ImportStringTableRequest( + IReadOnlyList FilePaths, + Dictionary> Entries, + string Endian, + bool Overwrite, + bool Verbose) : IOperationRequest; + +public sealed record ImportStringTableResult( + Dictionary> Entries); diff --git a/src/Volatility.Core/Operations/StringTables/LoadResourceDictionaryOperation.cs b/src/Volatility.Core/Operations/StringTables/LoadResourceDictionaryOperation.cs new file mode 100644 index 0000000..47e8162 --- /dev/null +++ b/src/Volatility.Core/Operations/StringTables/LoadResourceDictionaryOperation.cs @@ -0,0 +1,65 @@ +using Volatility.Abstractions.Operations; +using Volatility.Operations; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Volatility.Operations.StringTables; + +internal sealed class LoadResourceDictionaryOperation + : IOperation +{ + private readonly IDeserializer deserializer; + + public LoadResourceDictionaryOperation() + { + deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + } + + public async Task> ExecuteAsync( + LoadResourceDictionaryRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.YamlFile)) + { + return OperationResultFactory.Failure( + "load_resource_dictionary_missing_path", + "A ResourceDB YAML file path is required.", + nameof(LoadResourceDictionaryOperation)); + } + + try + { + if (!File.Exists(request.YamlFile)) + { + return OperationResultFactory.Success( + new LoadResourceDictionaryResult( + new Dictionary>(StringComparer.OrdinalIgnoreCase))); + } + + string content = await File.ReadAllTextAsync(request.YamlFile, cancellationToken); + Dictionary>? result = + deserializer.Deserialize>>(content); + + progress?.Report(new OperationProgress("load-resource-dictionary", 1.0, request.YamlFile)); + return OperationResultFactory.Success( + new LoadResourceDictionaryResult( + result ?? new Dictionary>(StringComparer.OrdinalIgnoreCase))); + } + catch (Exception ex) + { + return OperationResultFactory.Failure( + "load_resource_dictionary_failed", + ex.Message, + nameof(LoadResourceDictionaryOperation)); + } + } + +} + +public sealed record LoadResourceDictionaryRequest(string YamlFile) : IOperationRequest; + +public sealed record LoadResourceDictionaryResult( + Dictionary> Entries); diff --git a/src/Volatility.Core/Operations/StringTables/MergeStringTableEntriesOperation.cs b/src/Volatility.Core/Operations/StringTables/MergeStringTableEntriesOperation.cs new file mode 100644 index 0000000..18bf24b --- /dev/null +++ b/src/Volatility.Core/Operations/StringTables/MergeStringTableEntriesOperation.cs @@ -0,0 +1,69 @@ +using Volatility.Abstractions.Operations; +using Volatility.Operations; + +namespace Volatility.Operations.StringTables; + +internal sealed class MergeStringTableEntriesOperation + : IOperation +{ + public Task> ExecuteAsync( + MergeStringTableEntriesRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + foreach ((string typeKey, Dictionary resourceEntries) in request.Source) + { + if (!request.Target.TryGetValue(typeKey, out Dictionary? typeDict)) + { + request.Target[typeKey] = new Dictionary(resourceEntries, StringComparer.OrdinalIgnoreCase); + continue; + } + + foreach ((string resourceKey, StringTableResourceEntry entry) in resourceEntries) + { + if (!typeDict.TryGetValue(resourceKey, out StringTableResourceEntry? existing)) + { + typeDict[resourceKey] = entry; + continue; + } + + if (request.Overwrite) + { + existing.Name = entry.Name; + } + + foreach (string appearance in entry.Appearances) + { + if (!existing.Appearances.Contains(appearance)) + { + existing.Appearances.Add(appearance); + } + } + } + } + + progress?.Report(new OperationProgress("merge-string-table-entries", 1.0, null)); + return Task.FromResult(OperationResultFactory.Success(new MergeStringTableEntriesResult(request.Target))); + } + catch (Exception ex) + { + return Task.FromResult(OperationResultFactory.Failure( + "merge_string_table_entries_failed", + ex.Message, + nameof(MergeStringTableEntriesOperation))); + } + } + +} + +public sealed record MergeStringTableEntriesRequest( + Dictionary> Target, + Dictionary> Source, + bool Overwrite) : IOperationRequest; + +public sealed record MergeStringTableEntriesResult( + Dictionary> Entries); diff --git a/Volatility/Operations/StringTables/StringTableResourceEntry.cs b/src/Volatility.Core/Operations/StringTables/StringTableResourceEntry.cs similarity index 80% rename from Volatility/Operations/StringTables/StringTableResourceEntry.cs rename to src/Volatility.Core/Operations/StringTables/StringTableResourceEntry.cs index efdc645..8f389b9 100644 --- a/Volatility/Operations/StringTables/StringTableResourceEntry.cs +++ b/src/Volatility.Core/Operations/StringTables/StringTableResourceEntry.cs @@ -1,6 +1,6 @@ namespace Volatility.Operations.StringTables; -internal class StringTableResourceEntry +public class StringTableResourceEntry { public string Name { get; set; } = string.Empty; public List Appearances { get; set; } = new(); diff --git a/Volatility/Resources/AptData/AptData.cs b/src/Volatility.Core/Resources/AptData/AptData.cs similarity index 91% rename from Volatility/Resources/AptData/AptData.cs rename to src/Volatility.Core/Resources/AptData/AptData.cs index 319554c..e36aab1 100644 --- a/Volatility/Resources/AptData/AptData.cs +++ b/src/Volatility.Core/Resources/AptData/AptData.cs @@ -1,4 +1,4 @@ -using static Volatility.Utilities.DataUtilities; +using static Volatility.Utilities.DataUtilities; namespace Volatility.Resources; @@ -6,8 +6,8 @@ namespace Volatility.Resources; [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class AptData : Resource { - public string MovieName; - public string BaseComponentName; + public string MovieName = string.Empty; + public string BaseComponentName = string.Empty; public GuiGeometryObject GuiGeometry; public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) @@ -49,9 +49,9 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann List geometryMeshes = []; // GuiGeometryMesh - for (int j = 0; i < numMeshes; i++) + for (int j = 0; j < numMeshes; j++) { - reader.BaseStream.Seek(geometryMeshesPtr + (0x4 * i), SeekOrigin.Begin); + reader.BaseStream.Seek(geometryMeshesPtr + (0x4 * j), SeekOrigin.Begin); reader.BaseStream.Seek(reader.ReadUInt32(), SeekOrigin.Begin); // GuiGeometryMeshHeader @@ -67,9 +67,9 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann List vertices = []; // GuiVertex - for (int k = 0; i < numVerts; i++) + for (int k = 0; k < numVerts; k++) { - reader.BaseStream.Seek(vertsPtr + (0x4 * i), SeekOrigin.Begin); + reader.BaseStream.Seek(vertsPtr + (0x4 * k), SeekOrigin.Begin); reader.BaseStream.Seek(reader.ReadUInt32(), SeekOrigin.Begin); vertices.Add(new() @@ -103,8 +103,6 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann } public AptData() : base() { } - - public AptData(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } public struct GuiGeometryObject diff --git a/Volatility/Resources/AttribSysVault/AttribSysVault.cs b/src/Volatility.Core/Resources/AttribSysVault/AttribSysVault.cs similarity index 99% rename from Volatility/Resources/AttribSysVault/AttribSysVault.cs rename to src/Volatility.Core/Resources/AttribSysVault/AttribSysVault.cs index 4b77f04..a2609ab 100644 --- a/Volatility/Resources/AttribSysVault/AttribSysVault.cs +++ b/src/Volatility.Core/Resources/AttribSysVault/AttribSysVault.cs @@ -121,9 +121,6 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes public AttribSysVault() : base() { } - public AttribSysVault(string path, Endian endianness = Endian.Agnostic) - : base(path, endianness) { } - private void ParseVlt(EndianAwareBinaryReader reader, List pendingAttributes) { while (reader.BaseStream.Position < reader.BaseStream.Length) diff --git a/Volatility/Resources/BinaryResource.cs b/src/Volatility.Core/Resources/BinaryResource.cs similarity index 88% rename from Volatility/Resources/BinaryResource.cs rename to src/Volatility.Core/Resources/BinaryResource.cs index 8479db3..34948d9 100644 --- a/Volatility/Resources/BinaryResource.cs +++ b/src/Volatility.Core/Resources/BinaryResource.cs @@ -25,13 +25,6 @@ public BinaryResource() : base() DataOffset = 0x10; } - public BinaryResource(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) - { - if (DataOffset == 0) - { - DataOffset = 0x10; - } - } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { diff --git a/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs b/src/Volatility.Core/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs similarity index 99% rename from Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs rename to src/Volatility.Core/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs index 9a09998..8d99c7e 100644 --- a/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs +++ b/src/Volatility.Core/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs @@ -20,7 +20,6 @@ public class EnvironmentKeyframe : Resource public EnvironmentKeyframe() : base() { } - public EnvironmentKeyframe(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { diff --git a/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs b/src/Volatility.Core/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs similarity index 98% rename from Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs rename to src/Volatility.Core/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs index 84aad07..572004e 100644 --- a/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs +++ b/src/Volatility.Core/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs @@ -104,8 +104,6 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann public EnvironmentTimeline() : base() { } - public EnvironmentTimeline(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } - private static int GetLocationStructSize(Arch arch) { return arch == Arch.x64 ? 0x18 : 0x0C; diff --git a/Volatility/Resources/GuiPopup/GuiPopup.cs b/src/Volatility.Core/Resources/GuiPopup/GuiPopup.cs similarity index 96% rename from Volatility/Resources/GuiPopup/GuiPopup.cs rename to src/Volatility.Core/Resources/GuiPopup/GuiPopup.cs index 7360721..7131294 100644 --- a/Volatility/Resources/GuiPopup/GuiPopup.cs +++ b/src/Volatility.Core/Resources/GuiPopup/GuiPopup.cs @@ -114,17 +114,10 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann Popups.Add(popup); } - if (totalSize > 0 && totalSize != reader.BaseStream.Length) - { - Console.WriteLine($"WARNING: GuiPopup reported size 0x{totalSize:X}, actual size 0x{reader.BaseStream.Length:X}."); - } } public GuiPopup() : base() { } - public GuiPopup(string path, Endian endianness) - : base(path, endianness) { } - public enum PopupStyle : int { E_POPUPSTYLE_CRASHNAV_WAIT = 0, diff --git a/Volatility/Resources/InstanceList/InstanceList.cs b/src/Volatility.Core/Resources/InstanceList/InstanceList.cs similarity index 98% rename from Volatility/Resources/InstanceList/InstanceList.cs rename to src/Volatility.Core/Resources/InstanceList/InstanceList.cs index 95f4928..6d87db7 100644 --- a/Volatility/Resources/InstanceList/InstanceList.cs +++ b/src/Volatility.Core/Resources/InstanceList/InstanceList.cs @@ -25,8 +25,6 @@ public class InstanceList : Resource public InstanceList() : base() { } - public InstanceList(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } - public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) { base.WriteToStream(writer, endianness); diff --git a/Volatility/Resources/Model/Model.cs b/src/Volatility.Core/Resources/Model/Model.cs similarity index 97% rename from Volatility/Resources/Model/Model.cs rename to src/Volatility.Core/Resources/Model/Model.cs index cab8551..bd9eb5e 100644 --- a/Volatility/Resources/Model/Model.cs +++ b/src/Volatility.Core/Resources/Model/Model.cs @@ -87,11 +87,6 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann throw new InvalidDataException($"Version mismatch! Version should be 2. (Found version {version})"); } - if (numRenderables == 0) - { - Console.WriteLine("WARNING: Found no renderables in this model!"); - } - if (numStates != numRenderables) { throw new InvalidDataException( @@ -121,9 +116,6 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann public Model() : base() { } - public Model(string path, Endian endianness = Endian.Agnostic) - : base(path, endianness) { } - private static ModelData ReadModelData( ResourceBinaryReader reader, Arch arch, diff --git a/Volatility/Resources/Renderable/RenderableBPR.cs b/src/Volatility.Core/Resources/Renderable/RenderableBPR.cs similarity index 93% rename from Volatility/Resources/Renderable/RenderableBPR.cs rename to src/Volatility.Core/Resources/Renderable/RenderableBPR.cs index e02542c..4ac7c20 100644 --- a/Volatility/Resources/Renderable/RenderableBPR.cs +++ b/src/Volatility.Core/Resources/Renderable/RenderableBPR.cs @@ -1,4 +1,4 @@ -namespace Volatility.Resources; +namespace Volatility.Resources; [ResourceRegistration(RegistrationPlatforms.BPR)] public class RenderableBPR : RenderableBase @@ -20,7 +20,7 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann base.ParseFromStream(reader, endianness); } - public RenderableBPR(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public RenderableBPR() : base() { } } public enum D3D11_PRIMITIVE_TOPOLOGY diff --git a/Volatility/Resources/Renderable/RenderableBase.cs b/src/Volatility.Core/Resources/Renderable/RenderableBase.cs similarity index 92% rename from Volatility/Resources/Renderable/RenderableBase.cs rename to src/Volatility.Core/Resources/Renderable/RenderableBase.cs index c1185a3..a9fa58d 100644 --- a/Volatility/Resources/Renderable/RenderableBase.cs +++ b/src/Volatility.Core/Resources/Renderable/RenderableBase.cs @@ -1,4 +1,4 @@ -using System.Collections; +using System.Collections; using Volatility.Utilities; @@ -41,8 +41,6 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann } protected RenderableBase() : base() { } - - protected RenderableBase(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } diff --git a/Volatility/Resources/Renderable/RenderablePC.cs b/src/Volatility.Core/Resources/Renderable/RenderablePC.cs similarity index 82% rename from Volatility/Resources/Renderable/RenderablePC.cs rename to src/Volatility.Core/Resources/Renderable/RenderablePC.cs index 2614008..029b9f0 100644 --- a/Volatility/Resources/Renderable/RenderablePC.cs +++ b/src/Volatility.Core/Resources/Renderable/RenderablePC.cs @@ -1,4 +1,4 @@ -namespace Volatility.Resources; +namespace Volatility.Resources; [ResourceRegistration(RegistrationPlatforms.TUB)] public class RenderablePC : RenderableBase @@ -20,5 +20,5 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann base.ParseFromStream(reader, endianness); } - public RenderablePC(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public RenderablePC() : base() { } } diff --git a/Volatility/Resources/Renderable/RenderablePS3.cs b/src/Volatility.Core/Resources/Renderable/RenderablePS3.cs similarity index 61% rename from Volatility/Resources/Renderable/RenderablePS3.cs rename to src/Volatility.Core/Resources/Renderable/RenderablePS3.cs index a8b9bda..c0f78b6 100644 --- a/Volatility/Resources/Renderable/RenderablePS3.cs +++ b/src/Volatility.Core/Resources/Renderable/RenderablePS3.cs @@ -1,4 +1,4 @@ -namespace Volatility.Resources; +namespace Volatility.Resources; [ResourceRegistration(RegistrationPlatforms.PS3)] public class RenderablePS3 : RenderableBase @@ -6,5 +6,5 @@ public class RenderablePS3 : RenderableBase public override Endian ResourceEndian => Endian.BE; public override Platform ResourcePlatform => Platform.PS3; - public RenderablePS3(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public RenderablePS3() : base() { } } diff --git a/Volatility/Resources/Renderable/RenderableX360.cs b/src/Volatility.Core/Resources/Renderable/RenderableX360.cs similarity index 61% rename from Volatility/Resources/Renderable/RenderableX360.cs rename to src/Volatility.Core/Resources/Renderable/RenderableX360.cs index e83c6fd..c5312b2 100644 --- a/Volatility/Resources/Renderable/RenderableX360.cs +++ b/src/Volatility.Core/Resources/Renderable/RenderableX360.cs @@ -1,4 +1,4 @@ -namespace Volatility.Resources; +namespace Volatility.Resources; [ResourceRegistration(RegistrationPlatforms.X360)] public class RenderableX360 : RenderableBase @@ -6,5 +6,5 @@ public class RenderableX360 : RenderableBase public override Endian ResourceEndian => Endian.BE; public override Platform ResourcePlatform => Platform.X360; - public RenderableX360(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public RenderableX360() : base() { } } diff --git a/Volatility/Resources/Resource.cs b/src/Volatility.Core/Resources/Resource.cs similarity index 69% rename from Volatility/Resources/Resource.cs rename to src/Volatility.Core/Resources/Resource.cs index 68d2335..3cb895f 100644 --- a/Volatility/Resources/Resource.cs +++ b/src/Volatility.Core/Resources/Resource.cs @@ -1,4 +1,6 @@ -using static Volatility.Utilities.ResourceIDUtilities; +using Volatility.Abstractions.Services; + +using static Volatility.Utilities.ResourceIDUtilities; namespace Volatility.Resources; @@ -20,21 +22,22 @@ public abstract class Resource public Unpacker Unpacker = Unpacker.Raw; public ResourceType ResourceType => ResourceMetadata.GetResourceType(GetType()); - public virtual Endian ResourceEndian => Endian.Agnostic; // Forced endianness for platform-specific resources (e.g. Textures) + public virtual Endian ResourceEndian => Endian.Agnostic; public virtual Platform ResourcePlatform => Platform.Agnostic; public virtual Arch ResourceArch => Arch; public virtual void SetResourceArch(Arch newArch) { Arch = newArch; } public virtual IEnumerable> GetExternalImports() { yield break; } - public virtual void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) - { + public virtual void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) + { if (ResourceEndian != Endian.Agnostic) writer.SetEndianness(ResourceEndian); else if (endianness != Endian.Agnostic) writer.SetEndianness(endianness); } - public virtual void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) + + public virtual void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { if (ResourceEndian != Endian.Agnostic) reader.SetEndianness(ResourceEndian); @@ -45,71 +48,65 @@ public virtual void ParseFromStream(ResourceBinaryReader reader, Endian endianne public Resource() { } - public Resource(string path, Endian endianness = Endian.Agnostic) - { - InitializeFromPath(path, endianness); - } - - protected void InitializeFromPath(string path, Endian endianness = Endian.Agnostic) + internal void LoadFromStream( + Stream stream, + Endian endianness = Endian.Agnostic, + string? filename = null, + IResourceDBLookup? resourceDBLookup = null) { - if (string.IsNullOrEmpty(path)) - return; + Endian importEndianness = ResourceEndian != Endian.Agnostic ? ResourceEndian : endianness; - ImportedFileName = path; - - // Don't parse a directory - if (new DirectoryInfo(path).Exists) - return; + if (!string.IsNullOrEmpty(filename)) + { + ImportedFileName = filename; + string? name = Path.GetFileNameWithoutExtension(ImportedFileName); - string? name = Path.GetFileNameWithoutExtension(ImportedFileName); + if (!string.IsNullOrEmpty(name)) + { + Unpacker = GetUnpackerFromFileName(Path.GetFileName(ImportedFileName)); + if (Unpacker != Unpacker.Raw) + { + int idx = name.LastIndexOf('_'); + name = Unpacker switch + { + Unpacker.DGI => name.Replace("_", string.Empty, StringComparison.Ordinal), + Unpacker.Bnd2Manager or Unpacker.YAP when idx > 0 => name[..idx], + Unpacker.Bnd2Manager or Unpacker.YAP => name, + _ => name + }; + } - Endian importEndianness = (ResourceEndian != Endian.Agnostic) ? ResourceEndian : endianness; + if (ValidateResourceID(name)) + { + ResourceID = Convert.ToUInt64( + importEndianness == Endian.LE && Unpacker != Unpacker.YAP + ? FlipResourceIDEndian(name) + : name, + 16); - if (!string.IsNullOrEmpty(name)) - { - // If the filename is a ResourceID, we scan the users' ResourceDB (if available) - // to find a matching asset name for the provided ResourceID. If none is found, - // the ResourceID is used in place of a real asset name. If the filename is not a - // ResourceID, we simply use the file name as the asset name, and calculate a new ResourceID. - Unpacker = GetUnpackerFromFileName(Path.GetFileName(ImportedFileName)); - if (Unpacker != Unpacker.Raw) - { - int idx = name.LastIndexOf('_'); - name = Unpacker switch + string resolvedName = resourceDBLookup?.GetNameByResourceId(ResourceID) ?? string.Empty; + AssetName = !string.IsNullOrEmpty(resolvedName) + ? resolvedName + : ResourceID.ToString(); + } + else { - Unpacker.DGI => name.Replace("_", ""), - Unpacker.Bnd2Manager or Unpacker.YAP when idx > 0 => name[..idx], - Unpacker.Bnd2Manager or Unpacker.YAP => name, - _ => name - }; - } - if (ValidateResourceID(name)) - { - // We store ResourceIDs how BE platforms do to be consistent with the original console releases. - // This makes it easy to cross reference assets between all platforms. - ResourceID = Convert.ToUInt64((importEndianness == Endian.LE && Unpacker != Unpacker.YAP) - ? FlipResourceIDEndian(name) - : name - , 16); + AssetName = name; - string newName = GetNameByResourceID(ResourceID); - AssetName = !string.IsNullOrEmpty(newName) - ? newName - : ResourceID.ToString(); - } - else - { - // TODO: Add new entry to ResourceDB - ResourceID = Convert.ToUInt64(ResourceID.FromIDString(name)); - AssetName = name; + if (TryParseResourceID(name, out ResourceID parsedResourceId)) + { + ResourceID = parsedResourceId; + } + else + { + ResourceID = ResourceID.HashFromString(name); + } + } } - } - using (ResourceBinaryReader reader = new ResourceBinaryReader(new FileStream($"{path}", FileMode.Open), importEndianness)) - { - ParseFromStream(reader, importEndianness); - } + using ResourceBinaryReader reader = new(stream, importEndianness); + ParseFromStream(reader, importEndianness); } private static Unpacker GetUnpackerFromFileName(string filename) @@ -121,7 +118,6 @@ var n when n.EndsWith("_1.bin", StringComparison.OrdinalIgnoreCase) => Unpacker. var n when n.EndsWith("_primary.dat", StringComparison.OrdinalIgnoreCase) => Unpacker.YAP, var n when n.EndsWith(".dat", StringComparison.OrdinalIgnoreCase) && n.Count(c => c == '_') == 3 => Unpacker.DGI, - var n when n.EndsWith(".dat", StringComparison.OrdinalIgnoreCase) => Unpacker.YAP, _ => Unpacker.Raw, }; } @@ -130,7 +126,6 @@ public virtual void PushAll() { } public virtual void PullAll() { } } - public enum ResourceType { Texture = 0x0, @@ -270,7 +265,7 @@ public enum Platform PS3 = 3, } -public enum Unpacker +public enum Unpacker { Raw = 0, Volatility = 1, diff --git a/src/Volatility.Core/Resources/ResourceFactory.cs b/src/Volatility.Core/Resources/ResourceFactory.cs new file mode 100644 index 0000000..564a77e --- /dev/null +++ b/src/Volatility.Core/Resources/ResourceFactory.cs @@ -0,0 +1,161 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Volatility.Abstractions.Services; + +namespace Volatility.Resources; + +public static class ResourceFactory +{ + private static readonly Dictionary<(ResourceType, Platform), ResourceRegistrationInfo> resourceCreators = CreateResourceCreators(); + + public static Resource CreateResource(ResourceType resourceType, Platform platform, bool x64 = false) + { + ResourceRegistrationInfo registration = ResolveRegistration(resourceType, platform); + Resource resource = registration.Activator(); + ApplyArchOption(resource, x64); + + if (registration.PullAll) + { + resource.PullAll(); + } + + return resource; + } + + + + private static Dictionary<(ResourceType, Platform), ResourceRegistrationInfo> CreateResourceCreators() + { + ResourceCreatorRegistry registry = new(); + + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + + return registry.Build(); + } + + private static ResourceRegistrationInfo ResolveRegistration(ResourceType resourceType, Platform platform) + { + if (resourceCreators.TryGetValue((resourceType, platform), out ResourceRegistrationInfo? registration)) + { + return registration; + } + + throw new InvalidPlatformException($"The '{resourceType}' type is not supported for the '{platform}' platform."); + } + + private static void ApplyArchOption(Resource resource, bool x64) + { + if (x64) + { + resource.SetResourceArch(Arch.x64); + } + } + + + + private static void AddRegisteredResource<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TResource>( + ResourceCreatorRegistry registry) + where TResource : Resource + { + registry.AddRegistrations(typeof(TResource)); + } + + private sealed class ResourceCreatorRegistry + { + private readonly Dictionary<(ResourceType, Platform), ResourceRegistrationInfo> creators = new(); + + public void AddRegistrations( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type resourceClass) + { + ResourceType resourceType = ResourceMetadata.GetResourceType(resourceClass); + ResourceRegistrationAttribute[] registrations = resourceClass + .GetCustomAttributes(inherit: false) + .ToArray(); + + foreach (ResourceRegistrationAttribute registration in registrations) + { + foreach (Platform platform in ExpandPlatforms(registration.Platforms)) + { + creators.Add( + (resourceType, platform), + new ResourceRegistrationInfo( + CreateActivator(resourceClass), + registration.EndianMapped, + registration.PullAll)); + } + } + } + + public Dictionary<(ResourceType, Platform), ResourceRegistrationInfo> Build() + { + return creators; + } + + private static Func CreateActivator( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type resourceClass) + { + ConstructorInfo? constructor = resourceClass.GetConstructor(Type.EmptyTypes); + if (constructor == null) + { + throw new InvalidOperationException( + $"Resource class '{resourceClass.FullName}' must expose a parameterless constructor."); + } + + return () => (Resource)constructor.Invoke([]); + } + + private static IEnumerable ExpandPlatforms(RegistrationPlatforms platforms) + { + if ((platforms & RegistrationPlatforms.Agnostic) != 0) + { + yield return Platform.Agnostic; + } + + if ((platforms & RegistrationPlatforms.BPR) != 0) + { + yield return Platform.BPR; + } + + if ((platforms & RegistrationPlatforms.TUB) != 0) + { + yield return Platform.TUB; + } + + if ((platforms & RegistrationPlatforms.X360) != 0) + { + yield return Platform.X360; + } + + if ((platforms & RegistrationPlatforms.PS3) != 0) + { + yield return Platform.PS3; + } + } + } + + private sealed record ResourceRegistrationInfo( + Func Activator, + bool EndianMapped, + bool PullAll); +} diff --git a/Volatility/Resources/ResourceImport.cs b/src/Volatility.Core/Resources/ResourceImport.cs similarity index 82% rename from Volatility/Resources/ResourceImport.cs rename to src/Volatility.Core/Resources/ResourceImport.cs index 621af6d..f63e4a7 100644 --- a/Volatility/Resources/ResourceImport.cs +++ b/src/Volatility.Core/Resources/ResourceImport.cs @@ -1,39 +1,26 @@ -using System.Globalization; - +using System.Globalization; using YamlDotNet.Serialization; -using static Volatility.Utilities.ResourceIDUtilities; - namespace Volatility.Resources; public struct ResourceImport { public const int ImportEntrySize = 0x10; - // The idea here is that if the name is populated but - // the ID is empty, the name will be calculated into an ID - // on export. If both a name and ID exist, use the ID, as - // this will keep consistency for imported assets. If you - // want to use the calculated name, clear the ReferenceID field. public string Name; public ResourceID ReferenceID; public bool ExternalImport; - public ResourceImport() + public ResourceImport() { Name = string.Empty; } - public ResourceImport(ResourceID id, bool externalImport = false, bool useCalculatedName = false) + public ResourceImport(ResourceID id, bool externalImport = false) { ReferenceID = id; ExternalImport = externalImport; - Name = GetNameByResourceID(id); - - if (Name.Length > 0 && useCalculatedName) - { - ReferenceID = 0x0; - } + Name = string.Empty; } public ResourceImport(string name, bool externalImport = false) @@ -65,15 +52,14 @@ public static bool ReadExternalImport(int index, EndianAwareBinaryReader reader, { long originalPosition = reader.BaseStream.Position; - // In-resource imports block if (reader.BaseStream.Length >= importBlockOffset + ((long)ImportEntrySize * index) + ImportEntrySize) { reader.BaseStream.Seek(importBlockOffset + ((long)ImportEntrySize * index), SeekOrigin.Begin); resourceImport = new ResourceImport(reader.ReadUInt64(), externalImport: true); - + reader.BaseStream.Seek(originalPosition, SeekOrigin.Begin); - + return true; } @@ -91,7 +77,6 @@ public static bool ReadExternalImport(int index, EndianAwareBinaryReader reader, if (File.Exists(yamlPath)) { resourceImport = new ResourceImport(GetYAMLImportValueAt(yamlPath, index), externalImport: true); - return true; } } @@ -105,22 +90,24 @@ public static bool ReadExternalImport(long fileOffset, EndianAwareBinaryReader r long originalPosition = reader.BaseStream.Position; reader.BaseStream.Seek(importBlockOffset, SeekOrigin.Begin); - - // In-resource imports block + while (reader.BaseStream.Position + ImportEntrySize <= reader.BaseStream.Length) { ulong resourceValue = reader.ReadUInt64(); long entryKey = reader.ReadUInt32(); - if (entryKey != fileOffset) continue; - + if (entryKey != fileOffset) + { + continue; + } + resourceImport = new ResourceImport(resourceValue, externalImport: true); reader.BaseStream.Seek(originalPosition, SeekOrigin.Begin); return true; } reader.BaseStream.Seek(originalPosition, SeekOrigin.Begin); - + if (reader.BaseStream is FileStream fs) { if (TryReadBinaryImportByKey(GetImportsPath(fs.Name, Unpacker.Raw), reader.Endianness, fileOffset, out ResourceID binaryImport)) @@ -214,42 +201,42 @@ private static void DeleteFileIfExists(string path) public static ResourceID GetYAMLImportValueAt(string yamlPath, int index) { - var yaml = File.ReadAllText(yamlPath); - var deser = new DeserializerBuilder().Build(); + string yaml = File.ReadAllText(yamlPath); + IDeserializer deserializer = new DeserializerBuilder().Build(); - var list = deser - .Deserialize>>(yaml) + List> list = deserializer.Deserialize>>(yaml) ?? throw new InvalidDataException("Expected a YAML sequence of mappings."); if (index < 0 || index >= list.Count) throw new ArgumentOutOfRangeException(nameof(index), $"Failed to resolve resource import {index}, valid range 0–{list.Count - 1}"); - var kv = list[index].Values.GetEnumerator(); - kv.MoveNext(); - return Convert.ToUInt32(kv.Current, 16); + Dictionary.ValueCollection.Enumerator enumerator = list[index].Values.GetEnumerator(); + enumerator.MoveNext(); + return Convert.ToUInt32(enumerator.Current, 16); } public static ResourceID GetYAMLImportValueByKey(string yamlPath, long fileOffset) { - var yaml = File.ReadAllText(yamlPath); - var deser = new DeserializerBuilder().Build(); - var list = deser.Deserialize>>(yaml); + string yaml = File.ReadAllText(yamlPath); + IDeserializer deserializer = new DeserializerBuilder().Build(); + List>? list = deserializer.Deserialize>>(yaml); - string keyStr = $"0x{fileOffset.ToString("x8")}"; - var matchingDict = list.FirstOrDefault(d => d.ContainsKey(keyStr)); + string keyStr = $"0x{fileOffset:x8}"; + Dictionary? matchingDict = list?.FirstOrDefault(dictionary => dictionary.ContainsKey(keyStr)); if (matchingDict != null) { string valueStr = matchingDict[keyStr]; if (valueStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) { - valueStr = valueStr.Substring(2); + valueStr = valueStr[2..]; } + if (ulong.TryParse(valueStr, NumberStyles.HexNumber, null, out ulong value)) { return value; } } + return ResourceID.Default; } -}; - +} diff --git a/Volatility/Resources/ResourceMetadata.cs b/src/Volatility.Core/Resources/ResourceMetadata.cs similarity index 100% rename from Volatility/Resources/ResourceMetadata.cs rename to src/Volatility.Core/Resources/ResourceMetadata.cs diff --git a/src/Volatility.Core/Resources/ResourceSerializationOptions.cs b/src/Volatility.Core/Resources/ResourceSerializationOptions.cs new file mode 100644 index 0000000..0723ecb --- /dev/null +++ b/src/Volatility.Core/Resources/ResourceSerializationOptions.cs @@ -0,0 +1,14 @@ +using Volatility.Abstractions.Services; + +namespace Volatility.Resources; + +public sealed class ResourceSerializationOptions +{ + public string? FileName { get; set; } + public string? AssetName { get; set; } + public ResourceID? ResourceID { get; set; } + public Unpacker? Unpacker { get; set; } + public IResourceDBLookup? ResourceDBLookup { get; set; } + public bool x64 { get; set; } + public Endian? Endianness { get; set; } +} diff --git a/Volatility/Resources/Shader/ShaderBase.cs b/src/Volatility.Core/Resources/Shader/ShaderBase.cs similarity index 95% rename from Volatility/Resources/Shader/ShaderBase.cs rename to src/Volatility.Core/Resources/Shader/ShaderBase.cs index 45c230c..aa28d4c 100644 --- a/Volatility/Resources/Shader/ShaderBase.cs +++ b/src/Volatility.Core/Resources/Shader/ShaderBase.cs @@ -1,4 +1,7 @@ -namespace Volatility.Resources; +using System.Text.RegularExpressions; +using YamlDotNet.Serialization; + +namespace Volatility.Resources; [ResourceDefinition(ResourceType.Shader)] [ResourceRegistration(RegistrationPlatforms.Agnostic)] @@ -27,10 +30,14 @@ public class ShaderBase : Resource [EditorCategory("Shader/Compile"), EditorLabel("Additional Arguments"), EditorTooltip("Extra dxc command-line arguments.")] public List AdditionalArguments { get; set; } = []; + [YamlIgnore] + public string? ImportedShaderSourceText { get; set; } + public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness) { base.WriteToStream(writer, endianness); } + public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness) { base.ParseFromStream(reader, endianness); @@ -88,11 +95,12 @@ public bool TryReadShaderSourceText(out string shaderSourceText) return true; } - public ShaderBase() : base() { } - - public ShaderBase(string path) : base(path) { } + internal static Regex ShaderPathSanitizer() + { + return new Regex(@"(\?ID=\d+)|:", RegexOptions.Compiled); + } - public ShaderBase(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public ShaderBase() : base() { } } public enum ShaderStageType diff --git a/Volatility/Resources/Shader/ShaderPC.cs b/src/Volatility.Core/Resources/Shader/ShaderPC.cs similarity index 51% rename from Volatility/Resources/Shader/ShaderPC.cs rename to src/Volatility.Core/Resources/Shader/ShaderPC.cs index 02b37a2..ebd56d6 100644 --- a/Volatility/Resources/Shader/ShaderPC.cs +++ b/src/Volatility.Core/Resources/Shader/ShaderPC.cs @@ -1,7 +1,4 @@ using System.Text; -using System.Text.RegularExpressions; - -using static Volatility.Utilities.EnvironmentUtilities; namespace Volatility.Resources; @@ -11,9 +8,7 @@ public class ShaderPC : ShaderBase public override Endian ResourceEndian => Endian.LE; public override Platform ResourcePlatform => Platform.TUB; - public string Name; - - private static readonly Regex DbToFileRegex = new(@"(\?ID=\d+)|:", RegexOptions.Compiled); + public string Name = string.Empty; public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness) { @@ -30,7 +25,7 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann reader.BaseStream.Seek(baseOffset + 0x8, SeekOrigin.Begin); Name = reader.ReadString(); - string shaderSourceText = string.Empty; + string? shaderSourceText = string.Empty; long pointerOffset = baseOffset + 0x24; if (pointerOffset + sizeof(uint) <= reader.BaseStream.Length) @@ -44,48 +39,7 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann if (!string.IsNullOrEmpty(shaderSourceText)) { - string resourcesDirectory = GetEnvironmentDirectory(EnvironmentDirectory.Resources); - string outputPath; - - if (string.IsNullOrWhiteSpace(ShaderSourcePath)) - { - string baseName = !string.IsNullOrWhiteSpace(AssetName) - ? AssetName - : !string.IsNullOrWhiteSpace(ImportedFileName) - ? Path.GetFileNameWithoutExtension(ImportedFileName) - : "shader"; - - string sanitizedName = DbToFileRegex.Replace(baseName, string.Empty); - if (string.IsNullOrWhiteSpace(sanitizedName)) - sanitizedName = "shader"; - - ShaderSourcePath = $"{sanitizedName}.{ResourceType.Shader}.hlsl"; - outputPath = Path.Combine(resourcesDirectory, ShaderSourcePath); - } - else if (Path.IsPathRooted(ShaderSourcePath)) - { - outputPath = ShaderSourcePath; - } - else - { - outputPath = Path.Combine(resourcesDirectory, ShaderSourcePath); - } - - if (!File.Exists(outputPath)) - { - try - { - string? directory = Path.GetDirectoryName(outputPath); - if (!string.IsNullOrWhiteSpace(directory)) - Directory.CreateDirectory(directory); - - File.WriteAllText(outputPath, shaderSourceText, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - } - catch - { - // Best-effort: keep parsed shader even if we cannot write the file. - } - } + ImportedShaderSourceText = shaderSourceText; } } @@ -112,6 +66,4 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann } public ShaderPC() : base() { } - - public ShaderPC(string path) : base(path) { } } diff --git a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBPR.cs b/src/Volatility.Core/Resources/ShaderProgramBuffer/ShaderProgramBufferBPR.cs similarity index 98% rename from Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBPR.cs rename to src/Volatility.Core/Resources/ShaderProgramBuffer/ShaderProgramBufferBPR.cs index 4ed28ec..63c790a 100644 --- a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBPR.cs +++ b/src/Volatility.Core/Resources/ShaderProgramBuffer/ShaderProgramBufferBPR.cs @@ -1,4 +1,4 @@ -using static Volatility.Utilities.DataUtilities; +using static Volatility.Utilities.DataUtilities; using System.Text; @@ -138,8 +138,6 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann public ShaderProgramBufferBPR() : base() { } - public ShaderProgramBufferBPR(string path) : base(path) { } - public static ShaderProgramBufferBPR FromCSO(byte[] csoBytes, ShaderStageType stage) { if (csoBytes == null) diff --git a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs b/src/Volatility.Core/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs similarity index 81% rename from Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs rename to src/Volatility.Core/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs index 1ec7ed2..c806e42 100644 --- a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs +++ b/src/Volatility.Core/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs @@ -1,4 +1,4 @@ -namespace Volatility.Resources; +namespace Volatility.Resources; [ResourceDefinition(ResourceType.RwShaderProgramBuffer)] public class ShaderProgramBufferBase : Resource @@ -13,6 +13,4 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann } public ShaderProgramBufferBase() : base() { } - - public ShaderProgramBufferBase(string path) : base(path) { } } diff --git a/Volatility/Resources/SnapshotData/SnapshotData.cs b/src/Volatility.Core/Resources/SnapshotData/SnapshotData.cs similarity index 94% rename from Volatility/Resources/SnapshotData/SnapshotData.cs rename to src/Volatility.Core/Resources/SnapshotData/SnapshotData.cs index 088be84..72738fa 100644 --- a/Volatility/Resources/SnapshotData/SnapshotData.cs +++ b/src/Volatility.Core/Resources/SnapshotData/SnapshotData.cs @@ -50,8 +50,6 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann public SnapshotData() : base() { } - public SnapshotData(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } - private int GetSnapshotCountForWrite() { if (Channels.Count == 0) @@ -90,7 +88,7 @@ public static SnapshotChannelData Read(ResourceBinaryReader reader) uint terminator = reader.ReadUInt32(); if (terminator != 0xFFFFFFFF) { - Console.Error.WriteLine($"Expected 0xFFFFFFFF at {reader.BaseStream.Position}, got {terminator}!"); + throw new InvalidDataException($"Expected 0xFFFFFFFF at {reader.BaseStream.Position}, got {terminator}."); } return channelData; diff --git a/Volatility/Resources/Splicer/Splicer.cs b/src/Volatility.Core/Resources/Splicer/Splicer.cs similarity index 89% rename from Volatility/Resources/Splicer/Splicer.cs rename to src/Volatility.Core/Resources/Splicer/Splicer.cs index 1004b2a..37db8f7 100644 --- a/Volatility/Resources/Splicer/Splicer.cs +++ b/src/Volatility.Core/Resources/Splicer/Splicer.cs @@ -1,7 +1,5 @@ using System.Runtime.InteropServices; -using static Volatility.Utilities.EnvironmentUtilities; - namespace Volatility.Resources; // The Splicer resource type contains multiple sound assets and presets for @@ -79,8 +77,6 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) { - LoadDependentSamples(); - int totalRefs = Splices.Sum(s => s.SampleRefs.Count); int sizeOfSplices = Splices.Count * SpliceHeaderSize; int sizeOfSampleRefs = totalRefs * SampleRefSize; @@ -140,38 +136,9 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes writer.BaseStream.Position = endPosition; } - public void LoadDependentSamples(bool recurse = false) + public void SetLoadedSamples(List samples) { - List needed = Splices - .SelectMany(s => s.SampleRefs.Select(sr => sr.Sample)) - .Distinct() - .ToList(); - - string dir = Path.Combine - ( - GetEnvironmentDirectory(EnvironmentDirectory.Splicer), - "Samples" - ); - - string[] files = Directory.GetFiles(dir, "*.snr", recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); - - Dictionary map = new(needed.Count); - foreach (string f in files) - { - byte[] data = File.ReadAllBytes(f); - SnrID id = SnrID.HashFromBytes(data); - if (!map.ContainsKey(id) && needed.Contains(id)) - { - map[id] = data; - } - } - - foreach (SnrID id in needed.Where(id => !map.ContainsKey(id))) - { - throw new FileNotFoundException($"Missing sample for {id}"); - } - - _samples = needed.Select(id => new SpliceSample { SampleID = id, Data = map[id] }).ToList(); + _samples = samples ?? throw new ArgumentNullException(nameof(samples)); } public List GetLoadedSamples() @@ -181,9 +148,6 @@ public List GetLoadedSamples() public Splicer() : base() { } - public Splicer(string path, Endian endianness = Endian.Agnostic) - : base(path, endianness) { } - private static List ReadSamples( ResourceBinaryReader reader, List samplePointers, diff --git a/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs b/src/Volatility.Core/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs similarity index 99% rename from Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs rename to src/Volatility.Core/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs index a4c0e15..949b668 100644 --- a/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs +++ b/src/Volatility.Core/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs @@ -580,8 +580,6 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes public StreamedDeformationSpec() : base() { } - public StreamedDeformationSpec(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } - // Section writers private static void WriteLocatorPointSpecList(ResourceBinaryWriter writer, LocatorPointSpecList value, Arch arch) diff --git a/Volatility/Resources/Texture/TextureBPR.cs b/src/Volatility.Core/Resources/Texture/TextureBPR.cs similarity index 98% rename from Volatility/Resources/Texture/TextureBPR.cs rename to src/Volatility.Core/Resources/Texture/TextureBPR.cs index d47eab8..b5310de 100644 --- a/Volatility/Resources/Texture/TextureBPR.cs +++ b/src/Volatility.Core/Resources/Texture/TextureBPR.cs @@ -1,4 +1,4 @@ -using Volatility.Utilities; +using Volatility.Utilities; namespace Volatility.Resources; @@ -33,8 +33,6 @@ public override DIMENSION Dimension public TextureBPR() : base() { } - public TextureBPR(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } - public override void PushInternalFormat() { } public override void PullInternalFormat() { } public override void PushInternalFlags() { } diff --git a/Volatility/Resources/Texture/TextureBase.cs b/src/Volatility.Core/Resources/Texture/TextureBase.cs similarity index 96% rename from Volatility/Resources/Texture/TextureBase.cs rename to src/Volatility.Core/Resources/Texture/TextureBase.cs index 67a155a..b71426f 100644 --- a/Volatility/Resources/Texture/TextureBase.cs +++ b/src/Volatility.Core/Resources/Texture/TextureBase.cs @@ -1,4 +1,4 @@ -namespace Volatility.Resources; +namespace Volatility.Resources; // The Texture resource type contains in-game images, which are either displayed // through Apt UI, applied to models, or used as cubemaps. Textures vary by platform, @@ -94,8 +94,6 @@ public override void PushAll() } protected TextureBase() : base() => Depth = 1; - - protected TextureBase(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } // BPR formatted but converted for each platform public enum DIMENSION : int diff --git a/Volatility/Resources/Texture/TexturePC.cs b/src/Volatility.Core/Resources/Texture/TexturePC.cs similarity index 98% rename from Volatility/Resources/Texture/TexturePC.cs rename to src/Volatility.Core/Resources/Texture/TexturePC.cs index 0a193fc..1b0d95d 100644 --- a/Volatility/Resources/Texture/TexturePC.cs +++ b/src/Volatility.Core/Resources/Texture/TexturePC.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; namespace Volatility.Resources; @@ -31,8 +31,6 @@ public D3DFORMAT Format public TexturePC() : base() { } - public TexturePC(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } - public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) { base.WriteToStream(writer, endianness); diff --git a/Volatility/Resources/Texture/TexturePS3.cs b/src/Volatility.Core/Resources/Texture/TexturePS3.cs similarity index 97% rename from Volatility/Resources/Texture/TexturePS3.cs rename to src/Volatility.Core/Resources/Texture/TexturePS3.cs index 2ed42b6..f9562d5 100644 --- a/Volatility/Resources/Texture/TexturePS3.cs +++ b/src/Volatility.Core/Resources/Texture/TexturePS3.cs @@ -1,4 +1,4 @@ -using static Volatility.Utilities.PS3TextureUtilities; +using static Volatility.Utilities.PS3TextureUtilities; namespace Volatility.Resources; @@ -21,8 +21,6 @@ public class TexturePS3 : TextureBase public TexturePS3() : base() { } - public TexturePS3(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } - public override void PullInternalDimension() { Dimension = CubeMapEnable ? DIMENSION.DIMENSION_CUBE : CellDimension switch diff --git a/Volatility/Resources/Texture/TextureX360.cs b/src/Volatility.Core/Resources/Texture/TextureX360.cs similarity index 99% rename from Volatility/Resources/Texture/TextureX360.cs rename to src/Volatility.Core/Resources/Texture/TextureX360.cs index 7eae86b..eb00bb5 100644 --- a/Volatility/Resources/Texture/TextureX360.cs +++ b/src/Volatility.Core/Resources/Texture/TextureX360.cs @@ -1,4 +1,4 @@ -using System.Collections; +using System.Collections; using System.Text; using Volatility.Utilities; @@ -42,8 +42,6 @@ public class TextureX360 : TextureBase public TextureX360() : base() { } - public TextureX360(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } - public override void PullInternalDimension() { DIMENSION OutputDimension = Format.Dimension switch diff --git a/Volatility/Utilities/ProcessUtilities.cs b/src/Volatility.Core/Services/DefaultProcessRunner.cs similarity index 78% rename from Volatility/Utilities/ProcessUtilities.cs rename to src/Volatility.Core/Services/DefaultProcessRunner.cs index b7b1f16..62ffaae 100644 --- a/Volatility/Utilities/ProcessUtilities.cs +++ b/src/Volatility.Core/Services/DefaultProcessRunner.cs @@ -1,19 +1,18 @@ using System.Diagnostics; using System.Text; using Volatility.Abstractions.Messaging; -using Volatility.Messaging; +using Volatility.Abstractions.Services; -namespace Volatility.Utilities; +namespace Volatility.Services; -internal static class ProcessUtilities +public sealed class DefaultProcessRunner(IMessageSink sink) : IProcessRunner { - public static string RunAndCapture(string fileName, string arguments, string? workingDirectory = null) + public string RunAndCapture(string fileName, string arguments, string? workingDirectory = null) { - ProcessStartInfo startInfo = CreateStartInfo(fileName, arguments, workingDirectory); - return RunAndCapture(startInfo); + return RunAndCapture(CreateStartInfo(fileName, arguments, workingDirectory)); } - public static string RunAndCapture(ProcessStartInfo startInfo) + public string RunAndCapture(ProcessStartInfo startInfo) { using Process process = new() { StartInfo = startInfo }; StringBuilder output = new(); @@ -32,18 +31,17 @@ public static string RunAndCapture(ProcessStartInfo startInfo) return output.ToString(); } - public static void RunAndRelayOutput( + public void RunAndRelayOutput( string fileName, string arguments, string? workingDirectory = null, Action? stdoutHandler = null, Action? stderrHandler = null) { - ProcessStartInfo startInfo = CreateStartInfo(fileName, arguments, workingDirectory); - RunAndRelayOutput(startInfo, stdoutHandler, stderrHandler); + RunAndRelayOutput(CreateStartInfo(fileName, arguments, workingDirectory), stdoutHandler, stderrHandler); } - public static void RunAndRelayOutput( + public void RunAndRelayOutput( ProcessStartInfo startInfo, Action? stdoutHandler = null, Action? stderrHandler = null) @@ -99,7 +97,7 @@ private static ProcessStartInfo CreateStartInfo(string fileName, string argument }; } - private static void RelayOutput(string data, Action? handler) + private void RelayOutput(string data, Action? handler) { if (handler != null) { @@ -107,7 +105,7 @@ private static void RelayOutput(string data, Action? handler) return; } - VolatilityMessageHost.Sink.Verbose(data, MessageCategory.Process, nameof(ProcessUtilities)); + sink.Verbose(data, MessageCategory.Process, nameof(DefaultProcessRunner)); } private static string GetProcessDisplayName(ProcessStartInfo startInfo) diff --git a/src/Volatility.Core/Services/DefaultResourceFactory.cs b/src/Volatility.Core/Services/DefaultResourceFactory.cs new file mode 100644 index 0000000..1b2062c --- /dev/null +++ b/src/Volatility.Core/Services/DefaultResourceFactory.cs @@ -0,0 +1,12 @@ +using Volatility.Abstractions.Services; +using Volatility.Resources; + +namespace Volatility.Services; + +public sealed class DefaultResourceFactory : IResourceFactory +{ + public Resource CreateResource(ResourceType resourceType, Platform platform, bool x64 = false) + { + return ResourceFactory.CreateResource(resourceType, platform, x64); + } +} diff --git a/src/Volatility.Core/Services/DefaultResourceSerializer.cs b/src/Volatility.Core/Services/DefaultResourceSerializer.cs new file mode 100644 index 0000000..2d711ec --- /dev/null +++ b/src/Volatility.Core/Services/DefaultResourceSerializer.cs @@ -0,0 +1,73 @@ +using Volatility.Abstractions.Services; +using Volatility.Resources; + +namespace Volatility.Services; + +public sealed class DefaultResourceSerializer : IResourceSerializer +{ + private readonly IResourceFactory _resourceFactory; + + public DefaultResourceSerializer(IResourceFactory resourceFactory) + { + _resourceFactory = resourceFactory; + } + + public Resource Deserialize( + Stream stream, + ResourceType resourceType, + Platform platform, + ResourceSerializationOptions options) + { + Resource resource = _resourceFactory.CreateResource(resourceType, platform, options.x64); + + if (options.AssetName != null) + { + resource.AssetName = options.AssetName; + } + if (options.ResourceID.HasValue) + { + resource.ResourceID = options.ResourceID.Value; + } + if (options.Unpacker.HasValue) + { + resource.Unpacker = options.Unpacker.Value; + } + + Endian defaultEndian = ResolveLoadEndianness(resource, platform); + Endian finalEndian = options.Endianness ?? defaultEndian; + + resource.LoadFromStream(stream, finalEndian, options.FileName, options.ResourceDBLookup); + + resource.PullAll(); + + return resource; + } + + public void Serialize( + Resource resource, + Stream stream, + ResourceSerializationOptions options) + { + resource.PushAll(); + + Endian finalEndian = options.Endianness ?? resource.ResourceEndian; + + using ResourceBinaryWriter writer = new(stream, finalEndian, leaveOpen: true); + resource.WriteToStream(writer, finalEndian); + } + + private static Endian ResolveLoadEndianness(Resource resource, Platform platform) + { + if (resource.ResourceEndian != Endian.Agnostic) + { + return resource.ResourceEndian; + } + + return platform switch + { + Platform.BPR or Platform.TUB => Endian.LE, + Platform.X360 or Platform.PS3 => Endian.BE, + _ => Endian.Agnostic + }; + } +} diff --git a/Volatility/Utilities/DxcShaderCompiler.cs b/src/Volatility.Core/Services/DefaultShaderCompiler.cs similarity index 56% rename from Volatility/Utilities/DxcShaderCompiler.cs rename to src/Volatility.Core/Services/DefaultShaderCompiler.cs index 8ed7fb2..92c436d 100644 --- a/Volatility/Utilities/DxcShaderCompiler.cs +++ b/src/Volatility.Core/Services/DefaultShaderCompiler.cs @@ -1,50 +1,49 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Runtime.InteropServices; +using Volatility.Abstractions.Services; using Volatility.Resources; -using static Volatility.Utilities.EnvironmentUtilities; +namespace Volatility.Services; -namespace Volatility.Utilities; - -public static class DXCShaderCompiler +public sealed class DefaultShaderCompiler( + IPathProvider pathProvider, + IProcessRunner processRunner) + : IShaderCompiler { private const string DXCPathEnvVar = "VOLATILITY_DXC_PATH"; - public static void CompileStagesToCSO(ShaderBase shader, IReadOnlyList stages, Func outputPathFactory) + public void CompileStagesToCSO(ShaderBase shader, IReadOnlyList stages, Func outputPathFactory) { - if (shader == null) - throw new ArgumentNullException(nameof(shader)); + ArgumentNullException.ThrowIfNull(shader); + if (stages == null || stages.Count == 0) + { throw new InvalidOperationException("No shader stages were provided."); + } - foreach (var stage in stages) + foreach (ShaderStageCompile stage in stages) { - string outputPath = outputPathFactory(stage); - CompileToCSO(shader, stage, outputPath); + CompileToCSO(shader, stage, outputPathFactory(stage)); } } - public static void CompileToCSO(ShaderBase shader, ShaderStageCompile stage, string outputPath) + public void CompileToCSO(ShaderBase shader, ShaderStageCompile stage, string outputPath) { - if (shader == null) - throw new ArgumentNullException(nameof(shader)); - if (stage == null) - throw new ArgumentNullException(nameof(stage)); - - string entryPoint = ResolveEntryPoint(shader, stage); - string targetProfile = ResolveTargetProfile(shader, stage); + ArgumentNullException.ThrowIfNull(shader); + ArgumentNullException.ThrowIfNull(stage); - string? outputDir = Path.GetDirectoryName(outputPath); - if (!string.IsNullOrEmpty(outputDir)) + string? outputDirectory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDirectory)) { - Directory.CreateDirectory(outputDir); + Directory.CreateDirectory(outputDirectory); } - string dxcPath = ResolveDXCPath(); + string dxcPath = ResolveDxcPath(); string sourcePath = ResolveSourcePath(shader); + string entryPoint = ResolveEntryPoint(shader, stage); + string targetProfile = ResolveTargetProfile(shader, stage); - ProcessStartInfo startInfo = BuildStartInfo(dxcPath, sourcePath, shader, stage, entryPoint, targetProfile, outputPath); - ProcessUtilities.RunAndCapture(startInfo); + processRunner.RunAndCapture(BuildStartInfo(dxcPath, sourcePath, shader, stage, entryPoint, targetProfile, outputPath)); } private static ProcessStartInfo BuildStartInfo( @@ -56,7 +55,7 @@ private static ProcessStartInfo BuildStartInfo( string targetProfile, string outputPath) { - ProcessStartInfo start = new() + ProcessStartInfo startInfo = new() { FileName = dxcPath, UseShellExecute = false, @@ -65,85 +64,97 @@ private static ProcessStartInfo BuildStartInfo( CreateNoWindow = true }; - start.ArgumentList.Add("-nologo"); - start.ArgumentList.Add("-E"); - start.ArgumentList.Add(entryPoint); - start.ArgumentList.Add("-T"); - start.ArgumentList.Add(targetProfile); - start.ArgumentList.Add("-Fo"); - start.ArgumentList.Add(outputPath); + startInfo.ArgumentList.Add("-nologo"); + startInfo.ArgumentList.Add("-E"); + startInfo.ArgumentList.Add(entryPoint); + startInfo.ArgumentList.Add("-T"); + startInfo.ArgumentList.Add(targetProfile); + startInfo.ArgumentList.Add("-Fo"); + startInfo.ArgumentList.Add(outputPath); - foreach (var define in EnumerateDefines(shader, stage)) + foreach (ShaderDefine define in EnumerateDefines(shader, stage)) { - if (define == null || string.IsNullOrWhiteSpace(define.Name)) + if (string.IsNullOrWhiteSpace(define.Name)) + { continue; + } - start.ArgumentList.Add("-D"); - start.ArgumentList.Add(string.IsNullOrWhiteSpace(define.Value) + startInfo.ArgumentList.Add("-D"); + startInfo.ArgumentList.Add(string.IsNullOrWhiteSpace(define.Value) ? define.Name : $"{define.Name}={define.Value}"); } if (shader.IncludeDirectories != null) { - foreach (var includeDir in shader.IncludeDirectories) + foreach (string includeDirectory in shader.IncludeDirectories) { - if (string.IsNullOrWhiteSpace(includeDir)) + if (string.IsNullOrWhiteSpace(includeDirectory)) + { continue; + } - start.ArgumentList.Add("-I"); - start.ArgumentList.Add(includeDir); + startInfo.ArgumentList.Add("-I"); + startInfo.ArgumentList.Add(includeDirectory); } } if (shader.AdditionalArguments != null) { - foreach (var arg in shader.AdditionalArguments) + foreach (string arg in shader.AdditionalArguments) { - if (string.IsNullOrWhiteSpace(arg)) - continue; - - start.ArgumentList.Add(arg); + if (!string.IsNullOrWhiteSpace(arg)) + { + startInfo.ArgumentList.Add(arg); + } } } if (stage.AdditionalArguments != null) { - foreach (var arg in stage.AdditionalArguments) + foreach (string arg in stage.AdditionalArguments) { - if (string.IsNullOrWhiteSpace(arg)) - continue; - - start.ArgumentList.Add(arg); + if (!string.IsNullOrWhiteSpace(arg)) + { + startInfo.ArgumentList.Add(arg); + } } } - start.ArgumentList.Add(sourcePath); - return start; + startInfo.ArgumentList.Add(sourcePath); + return startInfo; } private static IEnumerable EnumerateDefines(ShaderBase shader, ShaderStageCompile stage) { if (shader.Defines != null) { - foreach (var define in shader.Defines) + foreach (ShaderDefine define in shader.Defines) + { yield return define; + } } if (stage.Defines != null) { - foreach (var define in stage.Defines) + foreach (ShaderDefine define in stage.Defines) + { yield return define; + } } } private static string ResolveEntryPoint(ShaderBase shader, ShaderStageCompile stage) { if (!string.IsNullOrWhiteSpace(stage.EntryPoint)) + { return stage.EntryPoint; + } if (!string.IsNullOrWhiteSpace(shader.EntryPoint)) + { return shader.EntryPoint; + } return "main"; } @@ -151,14 +162,20 @@ private static string ResolveEntryPoint(ShaderBase shader, ShaderStageCompile st private static string ResolveTargetProfile(ShaderBase shader, ShaderStageCompile stage) { if (!string.IsNullOrWhiteSpace(stage.TargetProfile)) + { return stage.TargetProfile; + } string? prefix = ShaderStageCompile.GetProfilePrefix(stage.ResolveStage()); if (!string.IsNullOrWhiteSpace(prefix)) + { return $"{prefix}_5_0"; + } if (!string.IsNullOrWhiteSpace(shader.TargetProfile)) + { return shader.TargetProfile; + } return "ps_5_0"; } @@ -167,62 +184,79 @@ private static string ResolveSourcePath(ShaderBase shader) { string? resolvedPath = shader.ResolveShaderSourcePath(); if (string.IsNullOrWhiteSpace(resolvedPath)) + { throw new InvalidOperationException("ShaderSourcePath is empty."); + } if (!File.Exists(resolvedPath)) + { throw new FileNotFoundException($"Shader source file not found: {resolvedPath}"); + } return resolvedPath; } - private static string ResolveDXCPath() + private string ResolveDxcPath() { string? overridePath = Environment.GetEnvironmentVariable(DXCPathEnvVar); if (!string.IsNullOrWhiteSpace(overridePath)) { if (!File.Exists(overridePath)) + { throw new FileNotFoundException($"DXC override path not found: {overridePath}"); + } + return overridePath; } - string exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dxc.exe" : "dxc"; - string toolsDir = GetEnvironmentDirectory(EnvironmentDirectory.Tools); - string rid = GetRuntimeRid(); + string executableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dxc.exe" : "dxc"; + string toolsDirectory = pathProvider.GetDirectory(VolatilityPathLocation.Tools); + string runtimeRid = GetRuntimeRid(); string[] candidates = [ - Path.Combine(toolsDir, "dxc", exeName), - Path.Combine(toolsDir, "dxc", rid, exeName), - Path.Combine(toolsDir, "dxc", "bin", exeName) + Path.Combine(toolsDirectory, "dxc", executableName), + Path.Combine(toolsDirectory, "dxc", runtimeRid, executableName), + Path.Combine(toolsDirectory, "dxc", "bin", executableName) ]; foreach (string candidate in candidates) { if (File.Exists(candidate)) + { return candidate; + } } - string? pathCandidate = FindOnPath(exeName); + string? pathCandidate = FindOnPath(executableName); if (!string.IsNullOrEmpty(pathCandidate)) + { return pathCandidate; + } - throw new FileNotFoundException($"dxc not found. Set {DXCPathEnvVar} or place it under {Path.Combine(toolsDir, "dxc")}."); + throw new FileNotFoundException($"dxc not found. Set {DXCPathEnvVar} or place it under {Path.Combine(toolsDirectory, "dxc")}."); } - private static string? FindOnPath(string exeName) + private static string? FindOnPath(string executableName) { string? path = Environment.GetEnvironmentVariable("PATH"); if (string.IsNullOrWhiteSpace(path)) + { return null; + } foreach (string entry in path.Split(Path.PathSeparator)) { if (string.IsNullOrWhiteSpace(entry)) + { continue; + } - string candidate = Path.Combine(entry.Trim(), exeName); + string candidate = Path.Combine(entry.Trim(), executableName); if (File.Exists(candidate)) + { return candidate; + } } return null; diff --git a/src/Volatility.Core/Services/EnvironmentPathProvider.cs b/src/Volatility.Core/Services/EnvironmentPathProvider.cs new file mode 100644 index 0000000..ece0312 --- /dev/null +++ b/src/Volatility.Core/Services/EnvironmentPathProvider.cs @@ -0,0 +1,148 @@ +using System.Diagnostics; +using System.Reflection; +using Volatility.Abstractions.Services; + +namespace Volatility.Services; + +public sealed class EnvironmentPathProvider : IPathProvider +{ + private static readonly IReadOnlyDictionary RelativePaths = + new Dictionary + { + [VolatilityPathLocation.Executable] = [], + [VolatilityPathLocation.Tools] = ["tools"], + [VolatilityPathLocation.Data] = ["data"], + [VolatilityPathLocation.ResourceDB] = ["data", "ResourceDB"], + [VolatilityPathLocation.Resources] = ["data", "Resources"], + [VolatilityPathLocation.Splicer] = ["data", "Splicer"], + }; + + public string GetDirectory(VolatilityPathLocation location) + { + string executableDirectory = GetExecutableDirectory(); + if (!RelativePaths.TryGetValue(location, out string[]? segments)) + { + throw new ArgumentOutOfRangeException(nameof(location), location, "Unknown path location!"); + } + + return segments.Length == 0 + ? executableDirectory + : Path.Combine([executableDirectory, .. segments]); + } + + public string GetExecutableDirectory() + { + string? processPath = null; + if (Assembly.GetEntryAssembly()?.Location is string entryAssemblyLocation && + !string.IsNullOrEmpty(entryAssemblyLocation)) + { + processPath = entryAssemblyLocation; + } + + if (string.IsNullOrEmpty(processPath)) + { + processPath = Environment.ProcessPath; + } + + if (string.IsNullOrEmpty(processPath)) + { + processPath = Process.GetCurrentProcess().MainModule?.FileName; + } + + if (string.IsNullOrEmpty(processPath)) + { + throw new InvalidOperationException("Unable to determine the process executable path."); + } + + string? executableDirectory = Path.GetDirectoryName(processPath); + if (string.IsNullOrEmpty(executableDirectory)) + { + throw new InvalidOperationException($"Cannot determine directory of executable: {processPath}"); + } + + return executableDirectory; + } + + public string GetFullPath(string path) + { + return Path.GetFullPath(path); + } + + public bool FileExists(string path) + { + return File.Exists(path); + } + + public bool DirectoryExists(string path) + { + return Directory.Exists(path); + } + + public void CreateDirectory(string path) + { + Directory.CreateDirectory(path); + } + + public string[] GetFilePaths(string path, VolatilityFilePathFilter filter, bool recurse = false) + { + List files = []; + string fullPath = GetFullPath(path); + + if (FileExists(fullPath)) + { + files.Add(fullPath); + } + else if (DirectoryExists(fullPath)) + { + SearchOption searchOption = recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + files = [.. Directory.EnumerateFiles(fullPath, "*", searchOption)]; + } + + for (int i = files.Count - 1; i >= 0; i--) + { + string name = Path.GetFileName(files[i]); + switch (filter) + { + case VolatilityFilePathFilter.Header: + if ((!name.Contains(".dat", StringComparison.OrdinalIgnoreCase) && + !name.Contains("_1.bin", StringComparison.OrdinalIgnoreCase)) || + name.Contains("_secondary", StringComparison.OrdinalIgnoreCase) || + name.Contains("_texture", StringComparison.OrdinalIgnoreCase) || + name.Contains("_imports", StringComparison.OrdinalIgnoreCase) || + name.Contains("_model", StringComparison.OrdinalIgnoreCase) || + name.Contains("_body", StringComparison.OrdinalIgnoreCase)) + { + files.RemoveAt(i); + } + break; + } + } + + return [.. files]; + } + + public string GetRepositoryRoot() + { + foreach (string startPath in GetCandidateStartPaths()) + { + string? current = GetFullPath(startPath); + while (!string.IsNullOrEmpty(current)) + { + if (FileExists(Path.Combine(current, "Volatility.sln"))) + { + return current; + } + + current = Directory.GetParent(current)?.FullName; + } + } + + throw new DirectoryNotFoundException("Unable to locate the repository root containing Volatility.sln."); + } + + private IEnumerable GetCandidateStartPaths() + { + yield return Directory.GetCurrentDirectory(); + yield return GetExecutableDirectory(); + } +} diff --git a/src/Volatility.Core/Services/FileResourceDBLookup.cs b/src/Volatility.Core/Services/FileResourceDBLookup.cs new file mode 100644 index 0000000..3913cb1 --- /dev/null +++ b/src/Volatility.Core/Services/FileResourceDBLookup.cs @@ -0,0 +1,125 @@ +using Newtonsoft.Json; +using Volatility.Abstractions.Services; +using Volatility.Operations.StringTables; +using Volatility.Resources; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Volatility.Services; + +public sealed class FileResourceDBLookup(IPathProvider pathProvider) : IResourceDBLookup +{ + private readonly object sync = new(); + private string? cachedPath; + private DateTime cachedWriteTimeUtc; + private Dictionary cachedLookup = new(StringComparer.OrdinalIgnoreCase); + + public string GetNameByResourceId(string id) + { + string normalizedId = NormalizeId(id); + IReadOnlyDictionary lookup = LoadLookup(); + return lookup.TryGetValue(normalizedId, out string? value) ? value : string.Empty; + } + + public string GetNameByResourceId(ResourceID id) + { + return GetNameByResourceId(id.ToString()); + } + + private IReadOnlyDictionary LoadLookup() + { + lock (sync) + { + string? resourceDBPath = ResolveResourceDBPath(); + if (resourceDBPath == null) + { + cachedPath = null; + cachedLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + cachedWriteTimeUtc = default; + return cachedLookup; + } + + DateTime writeTimeUtc = File.GetLastWriteTimeUtc(resourceDBPath); + if (string.Equals(cachedPath, resourceDBPath, StringComparison.OrdinalIgnoreCase) && + cachedWriteTimeUtc == writeTimeUtc) + { + return cachedLookup; + } + + cachedLookup = LoadEntries(resourceDBPath); + cachedPath = resourceDBPath; + cachedWriteTimeUtc = writeTimeUtc; + return cachedLookup; + } + } + + private string? ResolveResourceDBPath() + { + string resourceDBDirectory = pathProvider.GetDirectory(VolatilityPathLocation.ResourceDB); + string yamlPath = Path.Combine(resourceDBDirectory, "ResourceDB.yaml"); + if (File.Exists(yamlPath)) + { + return yamlPath; + } + + string jsonPath = Path.Combine(resourceDBDirectory, "ResourceDB.json"); + return File.Exists(jsonPath) ? jsonPath : null; + } + + private static Dictionary LoadEntries(string resourceDBPath) + { + return string.Equals(Path.GetExtension(resourceDBPath), ".yaml", StringComparison.OrdinalIgnoreCase) + ? LoadYamlEntries(resourceDBPath) + : LoadJsonEntries(resourceDBPath); + } + + private static Dictionary LoadYamlEntries(string yamlPath) + { + string content = File.ReadAllText(yamlPath); + Dictionary>? typedEntries = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build() + .Deserialize>>(content); + + Dictionary lookup = new(StringComparer.OrdinalIgnoreCase); + if (typedEntries == null) + { + return lookup; + } + + foreach (Dictionary typeEntries in typedEntries.Values) + { + foreach ((string resourceId, StringTableResourceEntry entry) in typeEntries) + { + string normalizedId = NormalizeId(resourceId); + if (!lookup.ContainsKey(normalizedId) && !string.IsNullOrWhiteSpace(entry.Name)) + { + lookup[normalizedId] = entry.Name; + } + } + } + + return lookup; + } + + private static Dictionary LoadJsonEntries(string jsonPath) + { + Dictionary? data = + JsonConvert.DeserializeObject>(File.ReadAllText(jsonPath)); + + return data != null + ? new Dictionary( + data.ToDictionary(entry => NormalizeId(entry.Key), entry => entry.Value), + StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private static string NormalizeId(string id) + { + return id + .Trim() + .Replace("_", string.Empty, StringComparison.Ordinal) + .Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase) + .ToLowerInvariant(); + } +} diff --git a/src/Volatility.Core/Services/FileShaderSourceStore.cs b/src/Volatility.Core/Services/FileShaderSourceStore.cs new file mode 100644 index 0000000..4b32140 --- /dev/null +++ b/src/Volatility.Core/Services/FileShaderSourceStore.cs @@ -0,0 +1,56 @@ +using System.Text; +using Volatility.Abstractions.Services; +using Volatility.Resources; + +namespace Volatility.Services; + +public sealed class FileShaderSourceStore : IShaderSourceStore +{ + public void MaterializeImportedSource(ShaderBase shader, string resourcesDirectory) + { + if (string.IsNullOrWhiteSpace(shader.ImportedShaderSourceText)) + { + return; + } + + string outputPath = ResolveOutputPath(shader, resourcesDirectory); + if (File.Exists(outputPath)) + { + shader.ImportedShaderSourceText = null; + return; + } + + string? directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(outputPath, shader.ImportedShaderSourceText, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + shader.ImportedShaderSourceText = null; + } + + private static string ResolveOutputPath(ShaderBase shader, string resourcesDirectory) + { + if (string.IsNullOrWhiteSpace(shader.ShaderSourcePath)) + { + string baseName = !string.IsNullOrWhiteSpace(shader.AssetName) + ? shader.AssetName + : !string.IsNullOrWhiteSpace(shader.ImportedFileName) + ? Path.GetFileNameWithoutExtension(shader.ImportedFileName) + : "shader"; + + string sanitizedName = ShaderBase.ShaderPathSanitizer().Replace(baseName, string.Empty); + if (string.IsNullOrWhiteSpace(sanitizedName)) + { + sanitizedName = "shader"; + } + + shader.ShaderSourcePath = $"{sanitizedName}.{ResourceType.Shader}.hlsl"; + } + + return Path.IsPathRooted(shader.ShaderSourcePath) + ? shader.ShaderSourcePath + : Path.Combine(resourcesDirectory, shader.ShaderSourcePath); + } +} diff --git a/src/Volatility.Core/Services/FileSplicerSampleStore.cs b/src/Volatility.Core/Services/FileSplicerSampleStore.cs new file mode 100644 index 0000000..0c1be70 --- /dev/null +++ b/src/Volatility.Core/Services/FileSplicerSampleStore.cs @@ -0,0 +1,42 @@ +using Volatility.Abstractions.Services; +using Volatility.Resources; + +namespace Volatility.Services; + +public sealed class FileSplicerSampleStore : ISplicerSampleStore +{ + public void PopulateDependentSamples(Splicer splicer, string splicerDirectory, bool recurse = false) + { + ArgumentNullException.ThrowIfNull(splicer); + + List needed = splicer.Splices + .SelectMany(splice => splice.SampleRefs.Select(sampleReference => sampleReference.Sample)) + .Distinct() + .ToList(); + + string sampleDirectory = Path.Combine(splicerDirectory, "Samples"); + string[] files = Directory.GetFiles(sampleDirectory, "*.snr", recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); + + Dictionary sampleMap = new(needed.Count); + foreach (string filePath in files) + { + byte[] data = File.ReadAllBytes(filePath); + SnrID sampleId = SnrID.HashFromBytes(data); + if (!sampleMap.ContainsKey(sampleId) && needed.Contains(sampleId)) + { + sampleMap[sampleId] = data; + } + } + + foreach (SnrID sampleId in needed.Where(sampleId => !sampleMap.ContainsKey(sampleId))) + { + throw new FileNotFoundException($"Missing sample for {sampleId}"); + } + + splicer.SetLoadedSamples(needed.Select(sampleId => new Splicer.SpliceSample + { + SampleID = sampleId, + Data = sampleMap[sampleId] + }).ToList()); + } +} diff --git a/Volatility/Utilities/StringTableStorageUtilities.cs b/src/Volatility.Core/Services/FileStringTableStore.cs similarity index 56% rename from Volatility/Utilities/StringTableStorageUtilities.cs rename to src/Volatility.Core/Services/FileStringTableStore.cs index 1c38263..bfa65d7 100644 --- a/Volatility/Utilities/StringTableStorageUtilities.cs +++ b/src/Volatility.Core/Services/FileStringTableStore.cs @@ -1,27 +1,25 @@ using System.Text; - using Newtonsoft.Json; - +using Volatility.Abstractions.Services; using Volatility.Operations.StringTables; - using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; -namespace Volatility.Utilities; +namespace Volatility.Services; -internal static class StringTableStorageUtilities +public sealed class FileStringTableStore : IStringTableStore { - public static async Task WriteYamlAsync(string yamlFile, Dictionary> entries) + public async Task WriteYamlAsync(string yamlFile, Dictionary> entries) { - var serializer = new SerializerBuilder() + string yaml = new SerializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) - .Build(); + .Build() + .Serialize(entries); - string yaml = serializer.Serialize(entries); await File.WriteAllTextAsync(yamlFile, yaml, Encoding.UTF8); } - public static async Task> LoadJsonAsync(string jsonFile) + public async Task> LoadJsonAsync(string jsonFile) { if (!File.Exists(jsonFile)) { @@ -36,19 +34,22 @@ public static async Task> LoadJsonAsync(string jsonFi : new Dictionary(StringComparer.OrdinalIgnoreCase); } - public static async Task WriteJsonAsync(string jsonFile, Dictionary entries) + public async Task WriteJsonAsync(string jsonFile, Dictionary entries) { string json = JsonConvert.SerializeObject(entries, Formatting.Indented); await File.WriteAllTextAsync(jsonFile, json, Encoding.UTF8); } - public static void MergeLegacyEntries(Dictionary target, Dictionary> source, bool overwrite) + public void MergeLegacyEntries( + Dictionary target, + Dictionary> source, + bool overwrite) { - foreach ((string _, Dictionary resourceEntries) in source) + foreach (Dictionary resourceEntries in source.Values) { foreach ((string resourceKey, StringTableResourceEntry entry) in resourceEntries) { - string normalizedKey = NormalizeResourceID(resourceKey); + string normalizedKey = resourceKey.Replace("_", string.Empty, StringComparison.Ordinal).ToLowerInvariant(); if (!target.ContainsKey(normalizedKey) || overwrite) { @@ -57,9 +58,4 @@ public static void MergeLegacyEntries(Dictionary target, Diction } } } - - private static string NormalizeResourceID(string resourceID) - { - return resourceID.Replace("_", "").ToLowerInvariant(); - } } diff --git a/src/Volatility.Core/Services/FileTextureBitmapStore.cs b/src/Volatility.Core/Services/FileTextureBitmapStore.cs new file mode 100644 index 0000000..71996f8 --- /dev/null +++ b/src/Volatility.Core/Services/FileTextureBitmapStore.cs @@ -0,0 +1,162 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Services; +using Volatility.Resources; +using Volatility.Utilities; + +namespace Volatility.Services; + +public sealed class FileTextureBitmapStore( + IPathProvider pathProvider, + IProcessRunner processRunner, + IMessageSink messageSink) + : ITextureBitmapStore +{ + public string GetResourceBaseName(string headerPath, Unpacker unpacker) + { + return GetHeaderBaseName(Path.GetFileName(headerPath), unpacker); + } + + public string GetSecondaryBitmapPath(string headerPath, Unpacker unpacker) + { + string? directory = Path.GetDirectoryName(headerPath); + string baseName = GetResourceBaseName(headerPath, unpacker); + string secondarySuffix = GetSecondaryResourceSuffix(unpacker); + + return string.IsNullOrEmpty(directory) + ? baseName + secondarySuffix + : Path.Combine(directory, baseName + secondarySuffix); + } + + public byte[] ReadNormalizedBitmapData(TextureBase texture, string bitmapPath) + { + return NormalizeBitmapData(texture, File.ReadAllBytes(bitmapPath)); + } + + public void WriteNormalizedBitmapFile(TextureBase texture, string sourceBitmapPath, string outputPath, bool overwrite = true) + { + if (!overwrite && File.Exists(outputPath)) + { + throw new IOException($"The file '{outputPath}' already exists."); + } + + File.WriteAllBytes(outputPath, ReadNormalizedBitmapData(texture, sourceBitmapPath)); + } + + public void ConvertPS3GTFToDDS(TexturePS3 texture, string sourceBitmapPath, string destinationBitmapPath, bool verbose = false) + { + byte[] header = new byte[0xE]; + using MemoryStream ps3Stream = new(header); + using ResourceBinaryWriter writer = new(ps3Stream, texture.ResourceEndian); + + texture.WriteToStream(writer); + ps3Stream.ReadExactly(header, 0, 0xE); + + byte[] fileBytes = File.ReadAllBytes(sourceBitmapPath); + byte[] size = BitConverter.GetBytes(fileBytes.Length); + Array.Reverse(size); + + byte[] gtfBytes = new byte[] + { + 0x02, 0x02, 0x00, 0xFF, + } + .Concat(size) + .Concat(new byte[] + { + 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x80, + }) + .Concat(size) + .Concat(header) + .Concat(new byte[0x5A]) + .Concat(fileBytes) + .ToArray(); + + File.WriteAllBytes($"{destinationBitmapPath}.gtf", gtfBytes); + + string gtf2ddsExecutablePath = Path.Combine(pathProvider.GetDirectory(VolatilityPathLocation.Tools), "gtf2dds.exe"); + if (!File.Exists(gtf2ddsExecutablePath)) + { + throw new FileNotFoundException("Unable to find external tool gtf2dds.exe!"); + } + + if (verbose) + { + messageSink.Verbose( + $"Running: {gtf2ddsExecutablePath} -o \"{destinationBitmapPath}.dds\" \"{destinationBitmapPath}.gtf\"", + MessageCategory.Texture, + nameof(FileTextureBitmapStore)); + messageSink.Verbose( + "Converting PS3 GTF texture to DDS...", + MessageCategory.Texture, + nameof(FileTextureBitmapStore)); + } + + processRunner.RunAndRelayOutput( + gtf2ddsExecutablePath, + $"-o \"{destinationBitmapPath}.dds\" \"{destinationBitmapPath}.gtf\""); + + fileBytes = File.ReadAllBytes($"{destinationBitmapPath}.dds"); + if (fileBytes.Length <= 0x80) + { + throw new InvalidDataException($"Texture file '{destinationBitmapPath}.dds' is too short to contain a DDS header."); + } + + byte[] trimmedBytes = new byte[fileBytes.Length - 0x80]; + Array.Copy(fileBytes, 0x80, trimmedBytes, 0, trimmedBytes.Length); + File.WriteAllBytes(destinationBitmapPath, trimmedBytes); + + if (verbose) + { + messageSink.Verbose( + "Trimmed converted DDS header.", + MessageCategory.Texture, + nameof(FileTextureBitmapStore)); + } + } + + private static byte[] NormalizeBitmapData(TextureBase texture, byte[] bitmapData) + { + return texture switch + { + TextureX360 x360 when x360.Format.Tiled => X360TextureUtilities.GetUntiled360TextureData(x360, bitmapData), + TexturePS3 ps3 when ps3.Format == CELL_GCM_COLOR_FORMAT.CELL_GCM_TEXTURE_A8R8G8B8 + => PS3TextureUtilities.DecodePS3A8R8G8B8(bitmapData, ps3.Width, ps3.Height, ps3.MipmapLevels), + _ => bitmapData, + }; + } + + private static string GetHeaderBaseName(string headerFileName, Unpacker unpacker) + { + string primarySuffix = GetPrimaryResourceSuffix(unpacker); + return headerFileName.EndsWith(primarySuffix, StringComparison.OrdinalIgnoreCase) + ? headerFileName[..^primarySuffix.Length] + : Path.GetFileNameWithoutExtension(headerFileName); + } + + private static string GetPrimaryResourceSuffix(Unpacker unpacker) + { + return unpacker switch + { + Unpacker.Bnd2Manager => "_1.bin", + Unpacker.DGI => ".dat", + Unpacker.YAP => "_primary.dat", + Unpacker.Raw => ".dat", + Unpacker.Volatility => throw new NotImplementedException(), + _ => throw new NotImplementedException(), + }; + } + + private static string GetSecondaryResourceSuffix(Unpacker unpacker) + { + return unpacker switch + { + Unpacker.Bnd2Manager => "_2.bin", + Unpacker.DGI => "_texture.dat", + Unpacker.YAP => "_secondary.dat", + Unpacker.Raw => "_texture.dat", + Unpacker.Volatility => throw new NotImplementedException(), + _ => throw new NotImplementedException(), + }; + } +} diff --git a/Volatility/StrongID.cs b/src/Volatility.Core/StrongID.cs similarity index 100% rename from Volatility/StrongID.cs rename to src/Volatility.Core/StrongID.cs diff --git a/Volatility/Types.cs b/src/Volatility.Core/Types.cs similarity index 100% rename from Volatility/Types.cs rename to src/Volatility.Core/Types.cs diff --git a/Volatility/Utilities/BitReader.cs b/src/Volatility.Core/Utilities/BitReader.cs similarity index 100% rename from Volatility/Utilities/BitReader.cs rename to src/Volatility.Core/Utilities/BitReader.cs diff --git a/Volatility/Utilities/CgsIDUtilities.cs b/src/Volatility.Core/Utilities/CgsIDUtilities.cs similarity index 100% rename from Volatility/Utilities/CgsIDUtilities.cs rename to src/Volatility.Core/Utilities/CgsIDUtilities.cs diff --git a/Volatility/Utilities/DDSTextureUtilities.cs b/src/Volatility.Core/Utilities/DDSTextureUtilities.cs similarity index 100% rename from Volatility/Utilities/DDSTextureUtilities.cs rename to src/Volatility.Core/Utilities/DDSTextureUtilities.cs diff --git a/Volatility/Utilities/DataUtilities.cs b/src/Volatility.Core/Utilities/DataUtilities.cs similarity index 100% rename from Volatility/Utilities/DataUtilities.cs rename to src/Volatility.Core/Utilities/DataUtilities.cs diff --git a/Volatility/Utilities/DictUtilities.cs b/src/Volatility.Core/Utilities/DictUtilities.cs similarity index 100% rename from Volatility/Utilities/DictUtilities.cs rename to src/Volatility.Core/Utilities/DictUtilities.cs diff --git a/Volatility/Utilities/DxbcReflectionParser.cs b/src/Volatility.Core/Utilities/DxbcReflectionParser.cs similarity index 100% rename from Volatility/Utilities/DxbcReflectionParser.cs rename to src/Volatility.Core/Utilities/DxbcReflectionParser.cs diff --git a/Volatility/Utilities/EndianAwareBinaryReader.cs b/src/Volatility.Core/Utilities/EndianAwareBinaryReader.cs similarity index 100% rename from Volatility/Utilities/EndianAwareBinaryReader.cs rename to src/Volatility.Core/Utilities/EndianAwareBinaryReader.cs diff --git a/Volatility/Utilities/EndianAwareBinaryWriter.cs b/src/Volatility.Core/Utilities/EndianAwareBinaryWriter.cs similarity index 79% rename from Volatility/Utilities/EndianAwareBinaryWriter.cs rename to src/Volatility.Core/Utilities/EndianAwareBinaryWriter.cs index 7a060fc..00db83c 100644 --- a/Volatility/Utilities/EndianAwareBinaryWriter.cs +++ b/src/Volatility.Core/Utilities/EndianAwareBinaryWriter.cs @@ -1,4 +1,4 @@ -using Volatility.Utilities; +using Volatility.Utilities; public class EndianAwareBinaryWriter : BinaryWriter { @@ -12,6 +12,14 @@ public EndianAwareBinaryWriter(Stream output, Endian endianness) : base(output) SetEndianness(endianness); } + public EndianAwareBinaryWriter(Stream output, Endian endianness, bool leaveOpen) : base(output, System.Text.Encoding.UTF8, leaveOpen) + { + if (endianness == Endian.Agnostic) + throw new InvalidOperationException("An agnostic endianness was passed to EndianAwareBinaryWriter! Ensure that the operation passes a valid endianness before attempting to use the writer."); + + SetEndianness(endianness); + } + public void SetEndianness(Endian endianness) { Endianness = endianness; diff --git a/Volatility/Utilities/EndianUtilities.cs b/src/Volatility.Core/Utilities/EndianUtilities.cs similarity index 100% rename from Volatility/Utilities/EndianUtilities.cs rename to src/Volatility.Core/Utilities/EndianUtilities.cs diff --git a/Volatility/Utilities/MatrixUtilities.cs b/src/Volatility.Core/Utilities/MatrixUtilities.cs similarity index 100% rename from Volatility/Utilities/MatrixUtilities.cs rename to src/Volatility.Core/Utilities/MatrixUtilities.cs diff --git a/Volatility/Utilities/PS3TextureUtilities.cs b/src/Volatility.Core/Utilities/PS3TextureUtilities.cs similarity index 58% rename from Volatility/Utilities/PS3TextureUtilities.cs rename to src/Volatility.Core/Utilities/PS3TextureUtilities.cs index b7db1fb..1754e26 100644 --- a/Volatility/Utilities/PS3TextureUtilities.cs +++ b/src/Volatility.Core/Utilities/PS3TextureUtilities.cs @@ -1,9 +1,3 @@ -using System.Diagnostics; - -using Volatility.Resources; - -using static Volatility.Utilities.EnvironmentUtilities; - namespace Volatility.Utilities; public static class PS3TextureUtilities @@ -40,14 +34,12 @@ public static byte[] DecodePS3A8R8G8B8(byte[] sourceData, int width, int height, int mipHeight = Math.Max(1, height >> mip); int mipSize = checked(mipWidth * mipHeight * 4); - DecodeMortonMipLevel - ( + DecodeMortonMipLevel( sourceData.AsSpan(sourceOffset, mipSize), linearData.AsSpan(destinationOffset, mipSize), mipWidth, mipHeight, - 4 - ); + 4); sourceOffset += mipSize; destinationOffset += mipSize; @@ -83,14 +75,12 @@ public static byte[] EncodePS3A8R8G8B8(byte[] sourceData, int width, int height, int mipHeight = Math.Max(1, height >> mip); int mipSize = checked(mipWidth * mipHeight * 4); - EncodeMortonMipLevel - ( + EncodeMortonMipLevel( sourceData.AsSpan(sourceOffset, mipSize), mortonData.AsSpan(destinationOffset, mipSize), mipWidth, mipHeight, - 4 - ); + 4); sourceOffset += mipSize; destinationOffset += mipSize; @@ -99,103 +89,6 @@ public static byte[] EncodePS3A8R8G8B8(byte[] sourceData, int width, int height, return mortonData; } - public static void PS3GTFToDDS(TexturePS3 ps3Header, string sourceBitmapPath, string destinationBitmapPath, bool verbose = false) - { - byte[] header = new byte[0xE]; - using MemoryStream ps3Stream = new(header); - using ResourceBinaryWriter writer = new(ps3Stream, ps3Header.ResourceEndian); - - ps3Header.WriteToStream(writer); - ps3Stream.ReadExactly(header, 0, 0xE); - - writer.Close(); - ps3Stream.Close(); - - PS3GTFToDDS(header, sourceBitmapPath, destinationBitmapPath, verbose); - } - - public static void PS3GTFToDDS(string ps3HeaderPath, string sourceBitmapPath, string destinationBitmapPath, bool verbose = false) - { - using FileStream ps3Stream = new(ps3HeaderPath, FileMode.Open, FileAccess.Read); - using BinaryReader reader = new(ps3Stream); - - byte[] ps3Header = reader.ReadBytes(0xE); - - reader.Close(); - ps3Stream.Close(); - - PS3GTFToDDS(ps3Header, sourceBitmapPath, destinationBitmapPath, verbose); - } - - public static void PS3GTFToDDS(byte[] ps3Header, string sourceBitmapPath, string destinationBitmapPath, bool verbose = false) - { - Array.ConstrainedCopy(ps3Header, 0, ps3Header, 0, 0xE); - - byte[] fileBytes = File.ReadAllBytes(sourceBitmapPath); - byte[] size = BitConverter.GetBytes(fileBytes.Length); - Array.Reverse(size); - - byte[] gtf = new byte[] - { - 0x02, 0x02, 0x00, 0xFF, - } - .Concat(size) - .Concat(new byte[] - { - 0x00, 0x00, 0x00, 0x01, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x80, - }) - .Concat(size) - .Concat(ps3Header) - .Concat(new byte[0x5A]) - .Concat(fileBytes) - .ToArray(); - - File.WriteAllBytes($"{destinationBitmapPath}.gtf", gtf); - - string gtf2ddsPath = Path.Combine - ( - GetEnvironmentDirectory(EnvironmentDirectory.Tools), - "gtf2dds.exe" - ); - - if (!File.Exists(gtf2ddsPath)) - { - throw new FileNotFoundException("Unable to find external tool gtf2dds.exe!"); - } - - if (verbose) Console.WriteLine($"Running: {gtf2ddsPath} -o \"{destinationBitmapPath}.dds\" \"{destinationBitmapPath}.gtf\""); - if (verbose) Console.WriteLine("Converting PS3 GTF texture to DDS..."); - - ProcessUtilities.RunAndRelayOutput( - gtf2ddsPath, - $"-o \"{destinationBitmapPath}.dds\" \"{destinationBitmapPath}.gtf\""); - - fileBytes = File.ReadAllBytes($"{destinationBitmapPath}.dds"); - - if (fileBytes.Length > 0x80) - { - byte[] newBytes = new byte[fileBytes.Length - 0x80]; - Array.Copy(fileBytes, 0x80, newBytes, 0, newBytes.Length); - - try - { - File.WriteAllBytes(destinationBitmapPath, newBytes); - } - catch (IOException e) - { - Console.WriteLine($"Error trying to write trimmed DDS data for {Path.GetFileNameWithoutExtension(sourceBitmapPath)}: {e.Message}"); - } - - if (verbose) Console.WriteLine("Trimmed converted DDS header."); - } - else - { - Console.WriteLine($"Error trying to write trimmed DDS data for {Path.GetFileNameWithoutExtension(sourceBitmapPath)}: Texture file is too short! Not a DDS file."); - } - } - private static void DecodeMortonMipLevel(ReadOnlySpan sourceData, Span destinationData, int width, int height, int bytesPerPixel) { (uint MortonIndex, int LinearOffset)[] pixelOrder = new (uint MortonIndex, int LinearOffset)[width * height]; diff --git a/Volatility/Utilities/PaddingUtilities.cs b/src/Volatility.Core/Utilities/PaddingUtilities.cs similarity index 100% rename from Volatility/Utilities/PaddingUtilities.cs rename to src/Volatility.Core/Utilities/PaddingUtilities.cs diff --git a/Volatility/Utilities/ResourceBinaryReader.cs b/src/Volatility.Core/Utilities/ResourceBinaryReader.cs similarity index 100% rename from Volatility/Utilities/ResourceBinaryReader.cs rename to src/Volatility.Core/Utilities/ResourceBinaryReader.cs diff --git a/Volatility/Utilities/ResourceBinaryWriter.cs b/src/Volatility.Core/Utilities/ResourceBinaryWriter.cs similarity index 97% rename from Volatility/Utilities/ResourceBinaryWriter.cs rename to src/Volatility.Core/Utilities/ResourceBinaryWriter.cs index e75263c..78acf90 100644 --- a/Volatility/Utilities/ResourceBinaryWriter.cs +++ b/src/Volatility.Core/Utilities/ResourceBinaryWriter.cs @@ -1,9 +1,10 @@ -using Volatility.Resources; +using Volatility.Resources; using Volatility.Utilities; public class ResourceBinaryWriter : EndianAwareBinaryWriter { public ResourceBinaryWriter(Stream output, Endian endianness) : base(output, endianness) { } + public ResourceBinaryWriter(Stream output, Endian endianness, bool leaveOpen) : base(output, endianness, leaveOpen) { } public void Write(Vector2 value, bool intrinsic = false) { diff --git a/Volatility/Utilities/ResourceIDUtilities.cs b/src/Volatility.Core/Utilities/ResourceIDUtilities.cs similarity index 52% rename from Volatility/Utilities/ResourceIDUtilities.cs rename to src/Volatility.Core/Utilities/ResourceIDUtilities.cs index f5615a3..5223dcc 100644 --- a/Volatility/Utilities/ResourceIDUtilities.cs +++ b/src/Volatility.Core/Utilities/ResourceIDUtilities.cs @@ -1,9 +1,6 @@ -using System.Globalization; - -using Newtonsoft.Json; +using System.Globalization; using static Volatility.Utilities.DataUtilities; -using static Volatility.Utilities.EnvironmentUtilities; namespace Volatility.Utilities; @@ -35,9 +32,9 @@ public static string[] ResourceNameToResourceID(string id) return Enumerable.Range(0, id.Length / 2).Select(i => id.Substring(i * 2, 2)).ToArray(); } - public static bool ValidateResourceID(string ResourceID) + public static bool ValidateResourceID(string resourceID) { - string[] id = PathToResourceID(ResourceID); + string[] id = PathToResourceID(resourceID); if (id.Length != 4) { @@ -45,13 +42,16 @@ public static bool ValidateResourceID(string ResourceID) { return IsHexadecimal(id[0]) && (id[0].Length == 0x8 || id[0].Length == 0x10); } + return false; - } + } foreach (string part in id) { if (part.Length != 2 || !IsHexadecimal(part)) + { return false; + } } return true; @@ -64,7 +64,7 @@ public static bool TryParseResourceID(string input, out ResourceID resourceID) if (string.IsNullOrWhiteSpace(input)) return false; - string trimmed = input.Trim().Replace("_", ""); + string trimmed = input.Trim().Replace("_", string.Empty, StringComparison.Ordinal); if (trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) trimmed = trimmed[2..]; @@ -87,75 +87,36 @@ public static bool TryParseResourceID(string input, out ResourceID resourceID) return false; } - public static byte[] FlipResourceIDEndian(byte[] ResourceIDElements) + public static byte[] FlipResourceIDEndian(byte[] resourceIDElements) { - if (ResourceIDElements.Length > 4) // Shouldn't usually happen + if (resourceIDElements.Length > 4) { - Array.Reverse(ResourceIDElements, 0, 4); + Array.Reverse(resourceIDElements, 0, 4); } else { - Array.Reverse(ResourceIDElements); + Array.Reverse(resourceIDElements); } - return ResourceIDElements; + + return resourceIDElements; } - public static string[] FlipResourceIDEndian(string[] ResourceIDElements) + public static string[] FlipResourceIDEndian(string[] resourceIDElements) { - if (ResourceIDElements.Length > 4) // File names & properties + if (resourceIDElements.Length > 4) { - Array.Reverse(ResourceIDElements, 0, 4); + Array.Reverse(resourceIDElements, 0, 4); } else { - Array.Reverse(ResourceIDElements); - } - return ResourceIDElements; - } - - public static string FlipResourceIDEndian(string ResourceID) - { - return string.Concat(FlipResourceIDEndian(ResourceNameToResourceID(ResourceID))); - } - - public static string GetNameByResourceID(string id) - { - string path = Path.Combine - ( - GetEnvironmentDirectory(EnvironmentDirectory.ResourceDB), - "ResourceDB.json" - ); - - if (File.Exists(path)) - { - Dictionary? data = JsonConvert.DeserializeObject>(File.ReadAllText(path)); - - return data.TryGetValue(id.Replace("_", "").ToLower(), out string? value) ? value : ""; + Array.Reverse(resourceIDElements); } - return ""; + return resourceIDElements; } - public static string GetNameByResourceID(ResourceID id) + public static string FlipResourceIDEndian(string resourceID) { - string path = Path.Combine - ( - GetEnvironmentDirectory(EnvironmentDirectory.ResourceDB), - "ResourceDB.json" - ); - - if (File.Exists(path)) - { - Dictionary? data = new Dictionary(StringComparer.OrdinalIgnoreCase); - - using var reader = File.OpenText(path); - using var json = new JsonTextReader(reader); - new JsonSerializer() - .Populate(json, data); - - return data.TryGetValue(id.ToString(), out string? value) ? value : ""; - } - - return ""; + return string.Concat(FlipResourceIDEndian(ResourceNameToResourceID(resourceID))); } } diff --git a/src/Volatility.Core/Utilities/ResourcePropertyComparer.cs b/src/Volatility.Core/Utilities/ResourcePropertyComparer.cs new file mode 100644 index 0000000..c1fa4cd --- /dev/null +++ b/src/Volatility.Core/Utilities/ResourcePropertyComparer.cs @@ -0,0 +1,65 @@ +using System.Reflection; +using Volatility.Utilities; + +namespace Volatility.Core.Utilities; + +public record PropertyMismatch(string Path, object? Exported, object? Imported); + +public static class ResourcePropertyComparer +{ + public static List Compare(object exported, object imported) + { + var mismatches = new List(); + CompareRecursive(exported, imported, "", mismatches); + return mismatches; + } + + private static void CompareRecursive(object? exported, object? imported, string prefix, List mismatches) + { + if (exported == null || imported == null) + { + if (exported != imported) + { + mismatches.Add(new PropertyMismatch(prefix, exported, imported)); + } + return; + } + + Type type = exported.GetType(); + PropertyInfo[] properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (PropertyInfo property in properties) + { + if (property.GetIndexParameters().Length > 0) continue; + + object? value1 = property.GetValue(exported, null); + object? value2 = property.GetValue(imported, null); + string propPath = string.IsNullOrEmpty(prefix) ? property.Name : $"{prefix}.{property.Name}"; + + if (TypeUtilities.IsComplexType(property.PropertyType)) + { + CompareRecursive(value1, value2, propPath, mismatches); + } + else if (!Equals(value1, value2)) + { + mismatches.Add(new PropertyMismatch(propPath, value1, value2)); + } + } + + FieldInfo[] fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance); + foreach (FieldInfo field in fields) + { + object? value1 = field.GetValue(exported); + object? value2 = field.GetValue(imported); + string fieldPath = string.IsNullOrEmpty(prefix) ? field.Name : $"{prefix}.{field.Name}"; + + if (TypeUtilities.IsComplexType(field.FieldType)) + { + CompareRecursive(value1, value2, fieldPath, mismatches); + } + else if (!Equals(value1, value2)) + { + mismatches.Add(new PropertyMismatch(fieldPath, value1, value2)); + } + } + } +} diff --git a/Volatility/Utilities/ResourceUtilities.cs b/src/Volatility.Core/Utilities/ResourceUtilities.cs similarity index 100% rename from Volatility/Utilities/ResourceUtilities.cs rename to src/Volatility.Core/Utilities/ResourceUtilities.cs diff --git a/Volatility/Utilities/TypeUtilities.cs b/src/Volatility.Core/Utilities/TypeUtilities.cs similarity index 79% rename from Volatility/Utilities/TypeUtilities.cs rename to src/Volatility.Core/Utilities/TypeUtilities.cs index 0bc835c..92773c1 100644 --- a/Volatility/Utilities/TypeUtilities.cs +++ b/src/Volatility.Core/Utilities/TypeUtilities.cs @@ -1,4 +1,4 @@ -using System.Collections; +using System.Collections; using System.Reflection; using System.Runtime.InteropServices; @@ -6,23 +6,17 @@ namespace Volatility.Utilities; public static class TypeUtilities { - public static string GetStaticPropertyValue(Type type, string propertyName) + public static string? GetStaticPropertyValue(Type type, string propertyName) { - PropertyInfo property = type.GetProperty(propertyName, BindingFlags.Static | BindingFlags.Public); + PropertyInfo? property = type.GetProperty(propertyName, BindingFlags.Static | BindingFlags.Public); if (property != null && property.PropertyType == typeof(string)) { - return (string)property.GetValue(null); + return (string?)property.GetValue(null); } return null; } - public static Type[] GetDerivedTypes(Type baseType) - { - return AppDomain.CurrentDomain.GetAssemblies() - .SelectMany(assembly => assembly.GetTypes()) - .Where(type => baseType.IsAssignableFrom(type) && type.IsClass && !type.IsAbstract) - .ToArray(); - } + public static T? GetAttribute(MemberInfo member) where T : Attribute { diff --git a/Volatility/Utilities/X360TextureUtilities.cs b/src/Volatility.Core/Utilities/X360TextureUtilities.cs similarity index 97% rename from Volatility/Utilities/X360TextureUtilities.cs rename to src/Volatility.Core/Utilities/X360TextureUtilities.cs index 1acc736..0d64c9b 100644 --- a/Volatility/Utilities/X360TextureUtilities.cs +++ b/src/Volatility.Core/Utilities/X360TextureUtilities.cs @@ -1,4 +1,4 @@ -using Volatility.Resources; +using Volatility.Resources; namespace Volatility.Utilities; @@ -47,10 +47,10 @@ public static void ConvertMipmapsToX360(TextureBase header, GPUTEXTUREFORMAT for } } - private static byte[][] AlignAndPackMipmaps(byte[][] mipmaps, TextureBase textureInfo, GPUTEXTUREFORMAT format) + private static byte[]?[] AlignAndPackMipmaps(byte[][] mipmaps, TextureBase textureInfo, GPUTEXTUREFORMAT format) { const int alignment = 4096; - var alignedMipmaps = new byte[mipmaps.Length][]; + byte[]?[] alignedMipmaps = new byte[mipmaps.Length][]; int totalSize = 0; for (int i = 0; i < mipmaps.Length; i++) @@ -59,7 +59,7 @@ private static byte[][] AlignAndPackMipmaps(byte[][] mipmaps, TextureBase textur int paddedSize = ((mipSize + alignment - 1) / alignment) * alignment; alignedMipmaps[i] = new byte[paddedSize]; - Array.Copy(mipmaps[i], alignedMipmaps[i], mipSize); + Array.Copy(mipmaps[i], alignedMipmaps[i]!, mipSize); totalSize += paddedSize; diff --git a/Volatility/Utilities/YAML/BitArrayYamlTypeConverter.cs b/src/Volatility.Core/Utilities/YAML/BitArrayYamlTypeConverter.cs similarity index 100% rename from Volatility/Utilities/YAML/BitArrayYamlTypeConverter.cs rename to src/Volatility.Core/Utilities/YAML/BitArrayYamlTypeConverter.cs diff --git a/Volatility/Utilities/YAML/FieldDescriptor.cs b/src/Volatility.Core/Utilities/YAML/FieldDescriptor.cs similarity index 84% rename from Volatility/Utilities/YAML/FieldDescriptor.cs rename to src/Volatility.Core/Utilities/YAML/FieldDescriptor.cs index 6cc5c8b..5933adc 100644 --- a/Volatility/Utilities/YAML/FieldDescriptor.cs +++ b/src/Volatility.Core/Utilities/YAML/FieldDescriptor.cs @@ -35,18 +35,18 @@ public string Name public bool AllowNulls => true; - public Type TypeOverride { get; set; } + public Type? TypeOverride { get; set; } public bool Required { get; set; } - public Type ConverterType { get; set; } + public Type? ConverterType { get; set; } - public T GetCustomAttribute() where T : Attribute + public T? GetCustomAttribute() where T : Attribute { return field.GetCustomAttribute(); } - public void Write(object target, object value) + public void Write(object target, object? value) { field.SetValue(target, value); } diff --git a/Volatility/Utilities/YAML/IncludeFieldsTypeInspector.cs b/src/Volatility.Core/Utilities/YAML/IncludeFieldsTypeInspector.cs similarity index 93% rename from Volatility/Utilities/YAML/IncludeFieldsTypeInspector.cs rename to src/Volatility.Core/Utilities/YAML/IncludeFieldsTypeInspector.cs index 6f6311a..8bd7df8 100644 --- a/Volatility/Utilities/YAML/IncludeFieldsTypeInspector.cs +++ b/src/Volatility.Core/Utilities/YAML/IncludeFieldsTypeInspector.cs @@ -17,7 +17,7 @@ public IncludeFieldsTypeInspector(ITypeInspector innerTypeInspector) this.innerTypeInspector = innerTypeInspector; } - public override IEnumerable GetProperties(Type type, object container) + public override IEnumerable GetProperties(Type type, object? container) { var descriptors = innerTypeInspector.GetProperties(type, container).ToList(); var existingNames = new HashSet(descriptors.Select(d => d.Name)); @@ -34,7 +34,7 @@ public override IEnumerable GetProperties(Type type, object public override string GetEnumValue(object value) { - return value.ToString(); + return value.ToString() ?? string.Empty; } public override string GetEnumName(Type enumType, string value) diff --git a/Volatility/Utilities/YAML/ResourceYamlDeserializer.cs b/src/Volatility.Core/Utilities/YAML/ResourceYamlDeserializer.cs similarity index 72% rename from Volatility/Utilities/YAML/ResourceYamlDeserializer.cs rename to src/Volatility.Core/Utilities/YAML/ResourceYamlDeserializer.cs index 0e23040..d95cf01 100644 --- a/Volatility/Utilities/YAML/ResourceYamlDeserializer.cs +++ b/src/Volatility.Core/Utilities/YAML/ResourceYamlDeserializer.cs @@ -10,13 +10,13 @@ namespace Volatility.Utilities; public static class ResourceYamlDeserializer { - public static object DeserializeResource(Type resourceClass, string yaml) + public static object? DeserializeResource(Type resourceClass, string yaml) { var deserializer = new DeserializerBuilder().Build(); - var root = deserializer.Deserialize>(yaml); + var root = deserializer.Deserialize>(yaml); var hierarchyTypes = new List(); - Type currentType = resourceClass; + Type? currentType = resourceClass; while (currentType != null && currentType != typeof(object)) { hierarchyTypes.Insert(0, currentType); @@ -24,7 +24,7 @@ public static object DeserializeResource(Type resourceClass, string yaml) } string baseKey = hierarchyTypes[0].Name + ".Properties"; - Dictionary mergedProperties = new Dictionary(); + Dictionary mergedProperties = new Dictionary(); if (root != null && root.ContainsKey(baseKey)) { mergedProperties = TryConvertToDictionary(root[baseKey]); @@ -53,20 +53,24 @@ public static object DeserializeResource(Type resourceClass, string yaml) } } - private static Dictionary TryConvertToDictionary(object obj) + private static Dictionary TryConvertToDictionary(object? obj) { if (obj is IDictionary genericDict) { - return genericDict.ToDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value); + return genericDict.ToDictionary(kvp => kvp.Key?.ToString() ?? string.Empty, kvp => (object?)kvp.Value); } else if (obj is Dictionary dict) { - return dict; + return dict.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value); } - return new Dictionary(); + else if (obj is Dictionary dictNullable) + { + return dictNullable; + } + return new Dictionary(); } - private static Dictionary DeepMerge(Dictionary target, Dictionary source) + private static Dictionary DeepMerge(Dictionary target, Dictionary source) { foreach (var kv in source) { @@ -84,7 +88,7 @@ private static Dictionary DeepMerge(Dictionary t return target; } - private static Dictionary MergeProperties(Dictionary baseDict, List derivedKeys) + private static Dictionary MergeProperties(Dictionary baseDict, List derivedKeys) { foreach (var key in derivedKeys) { @@ -98,7 +102,7 @@ private static Dictionary MergeProperties(Dictionary RemovePropertiesKeys(Dictionary dict) + private static Dictionary RemovePropertiesKeys(Dictionary dict) { return dict.Where(kvp => !kvp.Key.EndsWith(".Properties")) .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); diff --git a/Volatility/Utilities/YAML/ResourceYamlTypeConverter.cs b/src/Volatility.Core/Utilities/YAML/ResourceYamlTypeConverter.cs similarity index 51% rename from Volatility/Utilities/YAML/ResourceYamlTypeConverter.cs rename to src/Volatility.Core/Utilities/YAML/ResourceYamlTypeConverter.cs index dab63bb..98eb95d 100644 --- a/Volatility/Utilities/YAML/ResourceYamlTypeConverter.cs +++ b/src/Volatility.Core/Utilities/YAML/ResourceYamlTypeConverter.cs @@ -25,10 +25,12 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer nestedObjec throw new NotImplementedException(); } - public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer nestedObjectSerializer) + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer nestedObjectSerializer) { + if (value == null) return; + var hierarchyTypes = new List(); - Type currentType = value.GetType(); + Type? currentType = value.GetType(); while (currentType != null && Accepts(currentType)) { hierarchyTypes.Add(currentType); @@ -38,13 +40,13 @@ public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerialize var processedMembers = new HashSet(); - Dictionary root = new Dictionary(); - Dictionary currentDict = null; + var root = new Dictionary(); + Dictionary? currentDict = null; for (int i = 0; i < hierarchyTypes.Count; i++) { Type t = hierarchyTypes[i]; - var typeProperties = new Dictionary(); + var typeProperties = new Dictionary(); var members = t.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Cast() @@ -54,7 +56,7 @@ public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerialize { if (!processedMembers.Contains(member.Name)) { - object memberValue = null; + object? memberValue = null; if (member.MemberType == MemberTypes.Property) { memberValue = ((PropertyInfo)member).GetValue(value); @@ -77,7 +79,10 @@ public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerialize } else { - currentDict[key] = typeProperties; + if (currentDict != null) + { + currentDict[key] = typeProperties; + } currentDict = typeProperties; } } @@ -85,7 +90,7 @@ public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerialize nestedObjectSerializer(root); } - private void WriteValue(IEmitter emitter, object value) + private void WriteValue(IEmitter emitter, object? value) { if (value == null) { @@ -96,7 +101,7 @@ private void WriteValue(IEmitter emitter, object value) emitter.Emit(new MappingStart(null, null, false, MappingStyle.Block)); foreach (DictionaryEntry entry in dict) { - emitter.Emit(new Scalar(entry.Key.ToString())); + emitter.Emit(new Scalar(entry.Key.ToString() ?? string.Empty)); WriteValue(emitter, entry.Value); } emitter.Emit(new MappingEnd()); @@ -112,69 +117,7 @@ private void WriteValue(IEmitter emitter, object value) } else { - emitter.Emit(new Scalar(value.ToString())); - } - } - - // This method now expects the YAML to have a nested structure. - public static object DeserializeResource(Type resourceClass, string yaml) - { - var deserializer = new DeserializerBuilder().Build(); - var root = deserializer.Deserialize>(yaml); - - var hierarchyTypes = new List(); - Type currentType = resourceClass; - while (currentType != null && currentType != typeof(object)) - { - hierarchyTypes.Insert(0, currentType); - currentType = currentType.BaseType; - } - - Dictionary mergedProperties = new Dictionary(); - if (root != null) - { - string baseKey = hierarchyTypes[0].Name + ".Properties"; - if (root.ContainsKey(baseKey)) - { - mergedProperties = TryConvertToDictionary(root[baseKey]); - } - } - - Dictionary currentDict = mergedProperties; - for (int i = 1; i < hierarchyTypes.Count; i++) - { - string key = hierarchyTypes[i].Name + ".Properties"; - if (currentDict != null && currentDict.ContainsKey(key)) - { - var nestedDict = TryConvertToDictionary(currentDict[key]); - foreach (var kv in nestedDict) - { - currentDict[kv.Key] = kv.Value; - } - currentDict = nestedDict; - } - } - - var serializer = new SerializerBuilder().Build(); - string mergedYaml = serializer.Serialize(mergedProperties); - var finalDeserializer = new DeserializerBuilder().Build(); - using (var reader = new StringReader(mergedYaml)) - { - var resource = finalDeserializer.Deserialize(reader, resourceClass); - return resource; - } - } - - private static Dictionary TryConvertToDictionary(object obj) - { - if (obj is IDictionary genericDict) - { - return genericDict.ToDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value); - } - else if (obj is Dictionary dict) - { - return dict; + emitter.Emit(new Scalar(value.ToString() ?? string.Empty)); } - return new Dictionary(); } } \ No newline at end of file diff --git a/Volatility/Utilities/YAML/StringEnumYamlTypeConverter.cs b/src/Volatility.Core/Utilities/YAML/StringEnumYamlTypeConverter.cs similarity index 72% rename from Volatility/Utilities/YAML/StringEnumYamlTypeConverter.cs rename to src/Volatility.Core/Utilities/YAML/StringEnumYamlTypeConverter.cs index b8f6b38..5aa0c1c 100644 --- a/Volatility/Utilities/YAML/StringEnumYamlTypeConverter.cs +++ b/src/Volatility.Core/Utilities/YAML/StringEnumYamlTypeConverter.cs @@ -16,8 +16,8 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer nestedObjec return Enum.Parse(type, scalar.Value); } - public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer nestedObjectSerializer) + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer nestedObjectSerializer) { - emitter.Emit(new Scalar(value.ToString())); + emitter.Emit(new Scalar(value?.ToString() ?? string.Empty)); } } \ No newline at end of file diff --git a/Volatility/Utilities/YAML/StrongIDYamlTypeConverter.cs b/src/Volatility.Core/Utilities/YAML/StrongIDYamlTypeConverter.cs similarity index 87% rename from Volatility/Utilities/YAML/StrongIDYamlTypeConverter.cs rename to src/Volatility.Core/Utilities/YAML/StrongIDYamlTypeConverter.cs index 9e771b7..58f5420 100644 --- a/Volatility/Utilities/YAML/StrongIDYamlTypeConverter.cs +++ b/src/Volatility.Core/Utilities/YAML/StrongIDYamlTypeConverter.cs @@ -1,4 +1,4 @@ -using YamlDotNet.Core; +using YamlDotNet.Core; using YamlDotNet.Core.Events; using YamlDotNet.Serialization; @@ -28,9 +28,9 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer nestedObjec return ctor.Invoke(new object[] { value })!; } - public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer nestedObjectSerializer) + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer nestedObjectSerializer) { - var text = value.ToString()!; + var text = value?.ToString() ?? string.Empty; emitter.Emit(new Scalar( anchor: null, tag: null, diff --git a/src/Volatility.Core/Volatility.Core.csproj b/src/Volatility.Core/Volatility.Core.csproj new file mode 100644 index 0000000..6d7583a --- /dev/null +++ b/src/Volatility.Core/Volatility.Core.csproj @@ -0,0 +1,16 @@ + + + + net9.0 + enable + enable + + + + + + + + + + diff --git a/tests/Volatility.Core.Tests/GlobalUsings.cs b/tests/Volatility.Core.Tests/GlobalUsings.cs new file mode 100644 index 0000000..1314398 --- /dev/null +++ b/tests/Volatility.Core.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using ResourceID = StrongID; +global using SnrID = StrongID; diff --git a/tests/Volatility.Core.Tests/OperationIntegrationTests.cs b/tests/Volatility.Core.Tests/OperationIntegrationTests.cs new file mode 100644 index 0000000..fa6deca --- /dev/null +++ b/tests/Volatility.Core.Tests/OperationIntegrationTests.cs @@ -0,0 +1,247 @@ +using Microsoft.Extensions.DependencyInjection; +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Hosting; +using Volatility.Operations.Resources; +using Volatility.Resources; +using Xunit; + +namespace Volatility.Core.Tests; + +public class OperationIntegrationTests +{ + private class CaptureMessageSink : IMessageSink + { + public List Messages { get; } = []; + + public void Publish(in VolatilityMessage message) + { + Messages.Add(message); + } + } + + [Fact] + public async Task TestTextureRoundTripOperationSuccess() + { + // Arrange + var services = new ServiceCollection(); + services.AddVolatilityCore(); + + var captureSink = new CaptureMessageSink(); + services.AddSingleton(captureSink); + + var sp = services.BuildServiceProvider(); + sp.GetRequiredService().Subscribe(captureSink); + + var roundTripOp = sp.GetRequiredService>(); + + // Create a directory structure that satisfies: + // DirectoryInfo(ImportedFileName).Parent.Parent.Name ends with "_GR" + string tempDir = Path.Combine(Path.GetTempPath(), "VolatilityTests_" + Guid.NewGuid().ToString("N") + "_GR", "textures"); + Directory.CreateDirectory(tempDir); + string tempFile = Path.Combine(tempDir, "test_texture.dat"); + + var textureHeader = new TexturePC + { + AssetName = "test_texture", + ResourceID = ResourceID.HashFromString("test_texture"), + Format = D3DFORMAT.D3DFMT_DXT1, + Width = 256, + Height = 256, + MipmapLevels = 1, + UsageFlags = TextureBaseUsageFlags.GRTexture, + ImportedFileName = tempFile + }; + + try + { + // Act + var result = await roundTripOp.ExecuteAsync( + new TextureRoundTripRequest(tempFile, textureHeader, SkipImport: false), + progress: null, + cancellationToken: CancellationToken.None); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Value); + Assert.True(result.Value.PushImplemented); + Assert.Empty(result.Value.Mismatches); + Assert.NotEmpty(captureSink.Messages); + } + finally + { + if (Directory.Exists(Path.GetDirectoryName(tempDir))) + { + Directory.Delete(Path.GetDirectoryName(tempDir)!, true); + } + } + } + + [Fact] + public async Task TestTextureToDDSOperationSuccess() + { + // Arrange + var services = new ServiceCollection(); + services.AddVolatilityCore(); + + var captureSink = new CaptureMessageSink(); + services.AddSingleton(captureSink); + + var sp = services.BuildServiceProvider(); + sp.GetRequiredService().Subscribe(captureSink); + + var serializer = sp.GetRequiredService(); + var toDdsOp = sp.GetRequiredService>(); + + string tempDir = Path.Combine(Path.GetTempPath(), "VolatilityTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + string tempFile = Path.Combine(tempDir, "test_texture.dat"); + string tempBitmap = Path.Combine(tempDir, "test_texture_texture.dat"); + + var textureHeader = new TexturePC + { + AssetName = "test_texture", + ResourceID = ResourceID.HashFromString("test_texture"), + Format = D3DFORMAT.D3DFMT_DXT1, + Width = 16, + Height = 16, + MipmapLevels = 1, + UsageFlags = TextureBaseUsageFlags.GRTexture, + ImportedFileName = tempFile + }; + + // Write header using serializer + using (FileStream fs = File.Create(tempFile)) + { + serializer.Serialize(textureHeader, fs, new ResourceSerializationOptions { x64 = false }); + } + + // Write dummy bitmap data (8 bytes for DXT1 16x16) + byte[] dummyBitmap = new byte[8]; + File.WriteAllBytes(tempBitmap, dummyBitmap); + + try + { + // Act + var result = await toDdsOp.ExecuteAsync( + new TextureToDDSRequest([tempFile], Platform.TUB, IsX64: false, OutputPath: tempDir, Overwrite: true, Verbose: true), + progress: null, + cancellationToken: CancellationToken.None); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Value); + Assert.Single(result.Value.OutputPaths); + + string expectedDds = Path.Combine(tempDir, "test_texture.dds"); + Assert.True(File.Exists(expectedDds)); + + byte[] ddsBytes = File.ReadAllBytes(expectedDds); + Assert.True(ddsBytes.Length > 4); + Assert.Equal(0x44, ddsBytes[0]); // 'D' + Assert.Equal(0x44, ddsBytes[1]); // 'D' + Assert.Equal(0x53, ddsBytes[2]); // 'S' + Assert.Equal(0x20, ddsBytes[3]); // ' ' + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task TestPortTextureOperationSuccess() + { + // Arrange + var services = new ServiceCollection(); + services.AddVolatilityCore(); + + var captureSink = new CaptureMessageSink(); + services.AddSingleton(captureSink); + + var sp = services.BuildServiceProvider(); + sp.GetRequiredService().Subscribe(captureSink); + + var serializer = sp.GetRequiredService(); + var portOp = sp.GetRequiredService>(); + + string tempDir = Path.Combine(Path.GetTempPath(), "VolatilityTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + string tempFile = Path.Combine(tempDir, "test_port.dat"); + string tempBitmap = Path.Combine(tempDir, "test_port_texture.dat"); + + var textureHeader = new TexturePC + { + AssetName = "test_port", + ResourceID = ResourceID.HashFromString("test_port"), + Format = D3DFORMAT.D3DFMT_DXT1, + Width = 16, + Height = 16, + MipmapLevels = 1, + UsageFlags = TextureBaseUsageFlags.GRTexture, + ImportedFileName = tempFile + }; + + // Write header using serializer (TUB/PC source) + using (FileStream fs = File.Create(tempFile)) + { + serializer.Serialize(textureHeader, fs, new ResourceSerializationOptions { x64 = false }); + } + + // Write dummy bitmap data + byte[] dummyBitmap = new byte[8]; + File.WriteAllBytes(tempBitmap, dummyBitmap); + + string destDir = Path.Combine(tempDir, "output"); + + try + { + // Act + var result = await portOp.ExecuteAsync( + new PortTextureRequest( + SourceFiles: [tempFile], + SourceFormat: "TUB", + SourcePath: tempFile, + DestinationFormat: "BPR", + DestinationPath: destDir, + Verbose: true, + UseGTF: false), + progress: null, + cancellationToken: CancellationToken.None); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Value); + Assert.Single(result.Value.OutputPaths); + + string portedHeader = Path.Combine(destDir, "test_port.dat"); + string portedBitmap = Path.Combine(destDir, "test_port_texture.dat"); + + Assert.True(File.Exists(portedHeader)); + Assert.True(File.Exists(portedBitmap)); + + // Deserialize ported BPR header and check if it ported successfully + using FileStream fs = File.OpenRead(portedHeader); + var portedTexture = (TextureBPR)serializer.Deserialize( + fs, + ResourceType.Texture, + Platform.BPR, + new ResourceSerializationOptions { FileName = portedHeader }); + + Assert.Equal(DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM, portedTexture.Format); + Assert.Equal((uint)16, portedTexture.Width); + Assert.Equal((uint)16, portedTexture.Height); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } +} diff --git a/tests/Volatility.Core.Tests/Volatility.Core.Tests.csproj b/tests/Volatility.Core.Tests/Volatility.Core.Tests.csproj new file mode 100644 index 0000000..20aefe4 --- /dev/null +++ b/tests/Volatility.Core.Tests/Volatility.Core.Tests.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + +