From 73b19ba83488ffea18ebfaf98fe0d7cdfcf8d09d Mon Sep 17 00:00:00 2001 From: "Nathan V." Date: Thu, 30 Apr 2026 17:41:27 -0400 Subject: [PATCH 1/5] separate a lot of utilities into services --- AGENTS.md | 544 ++++++++++++++++++ .../Abstractions/Messaging/MessageCategory.cs | 2 +- .../Abstractions/Services/IPathProvider.cs | 6 + .../Services/IResourceDBLookup.cs | 9 + .../Services/IShaderSourceStore.cs | 8 + .../Services/ISplicerSampleStore.cs | 8 + .../Services/IStringTableStore.cs | 14 + .../Services/ITextureBitmapStore.cs | 12 + .../Services/VolatilityFilePathFilter.cs | 7 + Volatility/CLI/CLIMessageUtilities.cs | 93 +++ Volatility/CLI/Commands/AutotestCommand.cs | 81 +-- .../CLI/Commands/CreateResourceCommand.cs | 72 ++- Volatility/CLI/Commands/ExitCommand.cs | 6 +- .../CLI/Commands/ExportResourceCommand.cs | 223 +++---- Volatility/CLI/Commands/HelpCommand.cs | 26 +- .../CLI/Commands/ImportResourceCommand.cs | 85 +-- .../CLI/Commands/ImportStringTableCommand.cs | 106 ++-- Volatility/CLI/Commands/PortTextureCommand.cs | 28 +- .../CLI/Commands/TextureToDDSCommand.cs | 26 +- Volatility/CLI/ICommand.cs | 46 +- Volatility/Frontend.cs | 47 +- .../VolatilityServiceCollectionExtensions.cs | 60 ++ .../Autotest/GameAutotestOperation.cs | 225 +++++--- .../Operations/OperationResultFactory.cs | 29 + .../Resources/CreateResourceOperation.cs | 96 ++-- .../CreateShaderProgramBufferOperation.cs | 92 ++- .../Resources/ExportResourceOperation.cs | 38 +- .../Resources/ImportResourceOperation.cs | 84 ++- .../Resources/LoadResourceOperation.cs | 59 +- .../Resources/PortTextureOperation.cs | 95 +-- .../Resources/SaveResourceOperation.cs | 55 +- .../Resources/TextureToDDSOperation.cs | 22 +- .../ImportStringTableOperation.cs | 64 ++- .../LoadResourceDictionaryOperation.cs | 63 +- .../MergeStringTableEntriesOperation.cs | 84 ++- .../StringTables/StringTableResourceEntry.cs | 2 +- .../Resources/Renderable/RenderableBPR.cs | 2 + .../Resources/Renderable/RenderablePC.cs | 2 + .../Resources/Renderable/RenderablePS3.cs | 2 + .../Resources/Renderable/RenderableX360.cs | 2 + Volatility/Resources/Resource.cs | 72 +-- Volatility/Resources/ResourceFactory.cs | 184 +++--- Volatility/Resources/ResourceImport.cs | 69 +-- Volatility/Resources/Shader/ShaderBase.cs | 14 +- Volatility/Resources/Shader/ShaderPC.cs | 48 +- Volatility/Resources/Splicer/Splicer.cs | 37 +- Volatility/Services/DefaultProcessRunner.cs | 101 +++- .../DefaultShaderCompiler.cs} | 166 +++--- .../Services/DxcShaderCompilerAdapter.cs | 18 - .../Services/EnvironmentPathProvider.cs | 146 ++++- Volatility/Services/FileResourceDBLookup.cs | 125 ++++ Volatility/Services/FileShaderSourceStore.cs | 56 ++ Volatility/Services/FileSplicerSampleStore.cs | 42 ++ .../FileStringTableStore.cs} | 34 +- Volatility/Services/FileTextureBitmapStore.cs | 151 +++++ Volatility/Utilities/EnvironmentUtilities.cs | 71 --- Volatility/Utilities/PS3TextureUtilities.cs | 115 +--- Volatility/Utilities/ProcessUtilities.cs | 127 ---- Volatility/Utilities/ResourceIDUtilities.cs | 81 +-- .../Utilities/TextureBitmapUtilities.cs | 82 --- Volatility/Utilities/WorkspaceUtilities.cs | 29 - Volatility/Volatility.csproj | 1 + 62 files changed, 2815 insertions(+), 1479 deletions(-) create mode 100644 AGENTS.md create mode 100644 Volatility/Abstractions/Services/IResourceDBLookup.cs create mode 100644 Volatility/Abstractions/Services/IShaderSourceStore.cs create mode 100644 Volatility/Abstractions/Services/ISplicerSampleStore.cs create mode 100644 Volatility/Abstractions/Services/IStringTableStore.cs create mode 100644 Volatility/Abstractions/Services/ITextureBitmapStore.cs create mode 100644 Volatility/Abstractions/Services/VolatilityFilePathFilter.cs create mode 100644 Volatility/CLI/CLIMessageUtilities.cs create mode 100644 Volatility/Hosting/VolatilityServiceCollectionExtensions.cs create mode 100644 Volatility/Operations/OperationResultFactory.cs rename Volatility/{Utilities/DxcShaderCompiler.cs => Services/DefaultShaderCompiler.cs} (56%) delete mode 100644 Volatility/Services/DxcShaderCompilerAdapter.cs create mode 100644 Volatility/Services/FileResourceDBLookup.cs create mode 100644 Volatility/Services/FileShaderSourceStore.cs create mode 100644 Volatility/Services/FileSplicerSampleStore.cs rename Volatility/{Utilities/StringTableStorageUtilities.cs => Services/FileStringTableStore.cs} (56%) create mode 100644 Volatility/Services/FileTextureBitmapStore.cs delete mode 100644 Volatility/Utilities/EnvironmentUtilities.cs delete mode 100644 Volatility/Utilities/ProcessUtilities.cs delete mode 100644 Volatility/Utilities/TextureBitmapUtilities.cs delete mode 100644 Volatility/Utilities/WorkspaceUtilities.cs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7cbfd26 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,544 @@ +# 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. Single .NET 9.0 console 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/Volatility.csproj +dotnet run --project Volatility/Volatility.csproj # interactive REPL +dotnet run --project Volatility/Volatility.csproj -- # one-shot +``` + +Release publish mirrors CI (`.github/workflows/dotnet.yml`): +```bash +dotnet publish --configuration Release --runtime win-x64 --self-contained true Volatility/Volatility.csproj +``` +The csproj sets `PublishSingleFile`, `PublishTrimmed`, and copies `tools/dxc/**` to the output — the DXC tree must be present for shader operations after publish. + +## Testing + +There is no unit-test framework in this repo. Correctness is verified by the built-in `autotest` command, which 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 ([Frontend.cs](Volatility/Frontend.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 in [TypeUtilities](Volatility/Utilities/TypeUtilities.cs). **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/Abstractions/Messaging/MessageCategory.cs b/Volatility/Abstractions/Messaging/MessageCategory.cs index 5ee818e..ad1e4f9 100644 --- a/Volatility/Abstractions/Messaging/MessageCategory.cs +++ b/Volatility/Abstractions/Messaging/MessageCategory.cs @@ -9,5 +9,5 @@ public enum MessageCategory StringTable, Autotest, Process, - Cli + CLI } diff --git a/Volatility/Abstractions/Services/IPathProvider.cs b/Volatility/Abstractions/Services/IPathProvider.cs index 678e0a6..01df364 100644 --- a/Volatility/Abstractions/Services/IPathProvider.cs +++ b/Volatility/Abstractions/Services/IPathProvider.cs @@ -4,4 +4,10 @@ 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/IResourceDBLookup.cs b/Volatility/Abstractions/Services/IResourceDBLookup.cs new file mode 100644 index 0000000..ad4c150 --- /dev/null +++ b/Volatility/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/Volatility/Abstractions/Services/IShaderSourceStore.cs b/Volatility/Abstractions/Services/IShaderSourceStore.cs new file mode 100644 index 0000000..c933b84 --- /dev/null +++ b/Volatility/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/Volatility/Abstractions/Services/ISplicerSampleStore.cs b/Volatility/Abstractions/Services/ISplicerSampleStore.cs new file mode 100644 index 0000000..f4d44d6 --- /dev/null +++ b/Volatility/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/Volatility/Abstractions/Services/IStringTableStore.cs b/Volatility/Abstractions/Services/IStringTableStore.cs new file mode 100644 index 0000000..1f03f16 --- /dev/null +++ b/Volatility/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/Volatility/Abstractions/Services/ITextureBitmapStore.cs b/Volatility/Abstractions/Services/ITextureBitmapStore.cs new file mode 100644 index 0000000..6fa5285 --- /dev/null +++ b/Volatility/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/Volatility/Abstractions/Services/VolatilityFilePathFilter.cs b/Volatility/Abstractions/Services/VolatilityFilePathFilter.cs new file mode 100644 index 0000000..b528711 --- /dev/null +++ b/Volatility/Abstractions/Services/VolatilityFilePathFilter.cs @@ -0,0 +1,7 @@ +namespace Volatility.Abstractions.Services; + +public enum VolatilityFilePathFilter +{ + Any, + Header +} diff --git a/Volatility/CLI/CLIMessageUtilities.cs b/Volatility/CLI/CLIMessageUtilities.cs new file mode 100644 index 0000000..73c1280 --- /dev/null +++ b/Volatility/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/Volatility/CLI/Commands/AutotestCommand.cs index 40fcc0d..0d7dc93 100644 --- a/Volatility/CLI/Commands/AutotestCommand.cs +++ b/Volatility/CLI/Commands/AutotestCommand.cs @@ -1,6 +1,9 @@ using System.Reflection; using System.Text; +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Services; +using Volatility.CLI; using Volatility.Operations.Autotest; using Volatility.Resources; @@ -11,6 +14,9 @@ namespace Volatility.CLI.Commands; internal class AutotestCommand : ICommand { + private readonly IPathProvider pathProvider; + private readonly GameAutotestOperation gameAutotestOperation; + 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,8 +39,7 @@ public async Task Execute() IReadOnlyList gamePaths = ParseGamePaths(); if (gamePaths.Count > 0) { - GameAutotestOperation operation = new(); - GameAutotestSummary summary = await operation.ExecuteAsync(new GameAutotestOptions + GameAutotestSummary summary = await gameAutotestOperation.ExecuteAsync(new GameAutotestOptions { GamePaths = gamePaths, BundleToolPath = BundleToolPath, @@ -44,31 +49,35 @@ public async Task Execute() KeepArtifacts = KeepArtifacts }); - Console.WriteLine( - $"AUTOTEST - Completed. Passed={summary.Passed}, Failed={summary.Failed}, Skipped={summary.Skipped}"); + 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); } return; } if (!string.IsNullOrEmpty(Path)) { - TextureBase? header = Format switch + if (!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 = (TextureBase)ResourceFactory.LoadResource( + ResourceType.Texture, + platform, + inputPath, + resourceDBLookup: null); - TestHeaderRW($"autotest_{System.IO.Path.GetFileName(Path)}", header); + TestHeaderRW($"autotest_{System.IO.Path.GetFileName(inputPath)}", header); return; } @@ -156,7 +165,9 @@ public async Task Execute() // 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); } public void SetArgs(Dictionary args) @@ -209,22 +220,12 @@ public void TestHeaderRW(string name, TextureBase header, bool skipImport = fals 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(); - } + TextureBase newHeader = (TextureBase)ResourceFactory.LoadResource( + ResourceType.Texture, + header.ResourcePlatform, + fs.Name, + resourceDBLookup: null, + x64: header.ResourceArch == Arch.x64); TestCompareHeaders(header, newHeader); } @@ -310,7 +311,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 +405,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 +423,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 +449,9 @@ private static string EscapeMarkdownCell(string value) .Trim(); } - public AutotestCommand() { } + public AutotestCommand(IPathProvider pathProvider, GameAutotestOperation gameAutotestOperation) + { + this.pathProvider = pathProvider; + this.gameAutotestOperation = gameAutotestOperation; + } } diff --git a/Volatility/CLI/Commands/CreateResourceCommand.cs b/Volatility/CLI/Commands/CreateResourceCommand.cs index 5f83c09..d503c3f 100644 --- a/Volatility/CLI/Commands/CreateResourceCommand.cs +++ b/Volatility/CLI/Commands/CreateResourceCommand.cs @@ -1,14 +1,20 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.CLI; 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 { + 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=]"; @@ -24,74 +30,85 @@ public async Task Execute() { if (string.IsNullOrWhiteSpace(ResType)) { - Console.WriteLine("Error: No resource type specified! (--type)"); + CLIMessageUtilities.Error("Error: No resource type specified! (--type)"); return; } if (string.IsNullOrWhiteSpace(Format)) { - Console.WriteLine("Error: No format specified! (--format)"); + CLIMessageUtilities.Error("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)"); + 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 (!TypeUtilities.TryParseEnum(formatValue, out Platform platform)) + if (!Volatility.Utilities.TypeUtilities.TryParseEnum(formatValue, out Platform platform)) { - Console.WriteLine("Error: Invalid file format specified!"); + CLIMessageUtilities.Error("Error: Invalid file format specified!"); return; } - if (!TypeUtilities.TryParseEnum(ResType, out ResourceType resType)) + if (!Volatility.Utilities.TypeUtilities.TryParseEnum(ResType, out ResourceType resourceType)) { - Console.WriteLine("Error: Invalid resource type specified!"); + CLIMessageUtilities.Error("Error: Invalid resource type specified!"); return; } ResourceID? parsedId = null; if (!string.IsNullOrWhiteSpace(ResourceId)) { - if (!TryParseResourceID(ResourceId, out var id)) + if (!TryParseResourceID(ResourceId, out ResourceID id)) { - Console.WriteLine("Error: Invalid resource ID specified! (--id)"); + CLIMessageUtilities.Error("Error: Invalid resource ID specified! (--id)"); return; } parsedId = id; } - string resourcesDirectory = GetEnvironmentDirectory(EnvironmentDirectory.Resources); - CreateResourceOperation createOperation = new(resourcesDirectory); - SaveResourceOperation saveOperation = new(); + OperationResult createResult = await createOperation.ExecuteAsync( + new CreateResourceRequest(resourceType, platform, Name, OutputPath, parsedId, isX64), + progress: null, + cancellationToken: CancellationToken.None); + CLIMessageUtilities.PublishIssues(createResult.Issues, MessageCategory.Resource); - CreateResourceResult result; - try + if (!createResult.Success || createResult.Value == null) { - result = createOperation.Execute(resType, platform, Name, OutputPath, parsedId, isX64); + return; } - catch (Exception ex) + + CreateResourceResult result = createResult.Value; + if (pathProvider.FileExists(result.ResourcePath) && !Overwrite) { - Console.WriteLine($"Error: {ex.Message}"); + CLIMessageUtilities.Error($"Error: Output file already exists ({result.ResourcePath}). Use --overwrite to replace it."); return; } - if (File.Exists(result.ResourcePath) && !Overwrite) + OperationResult saveResult = await saveOperation.ExecuteAsync( + new SaveResourceRequest(result.Resource, result.ResourcePath), + progress: null, + cancellationToken: CancellationToken.None); + CLIMessageUtilities.PublishIssues(saveResult.Issues, MessageCategory.Resource); + + if (!saveResult.Success) { - 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)}."); + CLIMessageUtilities.Success( + $"Created {Path.GetFileName(result.ResourcePath)} at {pathProvider.GetFullPath(result.ResourcePath)}.", + MessageCategory.Resource); } public void SetArgs(Dictionary args) @@ -104,4 +121,13 @@ public void SetArgs(Dictionary args) 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/Volatility/CLI/Commands/ExitCommand.cs index 2bcce81..48ee166 100644 --- a/Volatility/CLI/Commands/ExitCommand.cs +++ b/Volatility/CLI/Commands/ExitCommand.cs @@ -1,3 +1,5 @@ +using Volatility.CLI; + namespace Volatility.CLI.Commands; internal class ExitCommand : ICommand @@ -8,11 +10,11 @@ internal class ExitCommand : ICommand public async Task Execute() { - Console.WriteLine("Exiting Volatility..."); + CLIMessageUtilities.Info("Exiting Volatility..."); Environment.Exit(0); } public void SetArgs(Dictionary args) { } public ExitCommand() { } -} \ No newline at end of file +} diff --git a/Volatility/CLI/Commands/ExportResourceCommand.cs b/Volatility/CLI/Commands/ExportResourceCommand.cs index 04e1387..6e55b0c 100644 --- a/Volatility/CLI/Commands/ExportResourceCommand.cs +++ b/Volatility/CLI/Commands/ExportResourceCommand.cs @@ -1,141 +1,152 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.CLI; 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() - { + private readonly IPathProvider pathProvider; + private readonly IOperation loadOperation; + private readonly ExportResourceOperation 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)) { - Console.WriteLine("Error: No resource path specified! (--respath)"); + CLIMessageUtilities.Error("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; - } + { + 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 - ( - GetEnvironmentDirectory(EnvironmentDirectory.Resources), - ResourcePath - ) - }"; + string filePath = Path.Combine(pathProvider.GetDirectory(VolatilityPathLocation.Resources), ResourcePath); + string[] sourceFiles = pathProvider.GetFilePaths(filePath, VolatilityFilePathFilter.Any, Recursive); - string[] sourceFiles = ICommand.GetFilePathsInDirectory(filePath, ICommand.TargetFileType.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 (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 (!Volatility.Utilities.TypeUtilities.TryParseEnum(Format, out Platform platform)) + { + CLIMessageUtilities.Error("Error: Invalid file format specified!"); + return; + } - if (!TypeUtilities.TryParseEnum(Format, out Platform platform)) + 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; + } + + List tasks = []; + foreach (string sourceFile in sourceFiles) + { + CLIMessageUtilities.Info(sourceFile, MessageCategory.Resource); + + tasks.Add(Task.Run(async () => + { + if (!pathProvider.FileExists(sourceFile)) { - throw new InvalidPlatformException("Error: Invalid file format specified!"); + CLIMessageUtilities.Error("Error: Invalid file import path specified!"); + return; } - Unpacker? importUnpackerOverride = null; - if (!string.IsNullOrEmpty(Imports) && !string.Equals(Imports, "DEFAULT", StringComparison.OrdinalIgnoreCase)) + if (!Volatility.Utilities.TypeUtilities.TryParseEnum(Path.GetExtension(sourceFile).TrimStart('.'), out ResourceType resourceType)) { - if (!TypeUtilities.TryParseEnum(Imports, out Unpacker parsedUnpacker)) - { - Console.WriteLine("Error: Invalid imports export mode specified!"); - return; - } - - importUnpackerOverride = parsedUnpacker; + CLIMessageUtilities.Error("Error: Resource type is invalid!"); + return; } - var loadOperation = new LoadResourceOperation(); - var exportOperation = new ExportResourceOperation(); + OperationResult loadResult = await loadOperation.ExecuteAsync( + new LoadResourceRequest(sourceFile, resourceType, platform), + progress: null, + cancellationToken: CancellationToken.None); + CLIMessageUtilities.PublishIssues(loadResult.Issues, MessageCategory.Resource); - List tasks = new List(); - foreach (string sourceFile in sourceFiles) + if (!loadResult.Success || loadResult.Value == null) { - 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); + try + { + await exportOperation.ExecuteAsync( + loadResult.Value.Resource, + OutputPath, + platform, + importUnpackerOverride, + ImportsFile); } - 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}"); + catch (Exception ex) + { + CLIMessageUtilities.Error( + $"ERROR: Unable to export {Path.GetFileName(sourceFile)} as {resourceType}!" + + $"{Environment.NewLine}Message from {ex.TargetSite}: {ex.Message}." + + $"{Environment.NewLine}Stack Trace:{Environment.NewLine}{ex.StackTrace}"); return; } - await exportOperation.ExecuteAsync(resource, OutputPath, platform, importUnpackerOverride, ImportsFile); - - Console.WriteLine($"Exported {Path.GetFileName(ResourcePath)} as {Path.GetFullPath(OutputPath)}."); - })); - } - await Task.WhenAll(tasks); + CLIMessageUtilities.Success( + $"Exported {Path.GetFileName(sourceFile)} as {pathProvider.GetFullPath(OutputPath)}.", + MessageCategory.Resource); + })); } - 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; - } + await Task.WhenAll(tasks); + } - public ExportResourceCommand() { } + 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, + ExportResourceOperation exportOperation) + { + this.pathProvider = pathProvider; + this.loadOperation = loadOperation; + this.exportOperation = exportOperation; + } } diff --git a/Volatility/CLI/Commands/HelpCommand.cs b/Volatility/CLI/Commands/HelpCommand.cs index d46f438..1b6d143 100644 --- a/Volatility/CLI/Commands/HelpCommand.cs +++ b/Volatility/CLI/Commands/HelpCommand.cs @@ -20,19 +20,12 @@ public async Task Execute() 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; - } - } + ShowUsage(command); + return; } } - Console.WriteLine("Available commands:"); + CLIMessageUtilities.Info("Available commands:"); foreach (var command in GetDerivedTypes(typeof(ICommand))) { string commandName = GetStaticPropertyValue(command, nameof(CommandToken)); @@ -40,10 +33,10 @@ public async Task Execute() if (string.IsNullOrEmpty(commandName)) continue; - Console.WriteLine($" {commandName} - {GetStaticPropertyValue(command, nameof(CommandDescription))}"); + CLIMessageUtilities.Info($" {commandName} - {GetStaticPropertyValue(command, nameof(CommandDescription))}"); } - Console.WriteLine("For information on command arguments, run: help ."); + CLIMessageUtilities.Info("For information on command arguments, run: help ."); } public void SetArgs(Dictionary args) @@ -51,5 +44,14 @@ 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/Volatility/CLI/Commands/ImportResourceCommand.cs b/Volatility/CLI/Commands/ImportResourceCommand.cs index 80bab56..d59debe 100644 --- a/Volatility/CLI/Commands/ImportResourceCommand.cs +++ b/Volatility/CLI/Commands/ImportResourceCommand.cs @@ -1,13 +1,19 @@ +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; -using static Volatility.Utilities.EnvironmentUtilities; - namespace Volatility.CLI.Commands; internal class ImportResourceCommand : ICommand { + private readonly IPathProvider pathProvider; + private readonly ImportResourceOperation importOperation; + private readonly SaveResourceOperation 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="; @@ -22,41 +28,28 @@ public async Task Execute() { if (ResType == "AUTO") { - Console.WriteLine("Error: Automatic typing is not supported yet! Please specify a type (--type)"); + CLIMessageUtilities.Error("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)"); + CLIMessageUtilities.Error("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) + string fullImportPath = pathProvider.GetFullPath(ImportPath); + if (!pathProvider.FileExists(fullImportPath) && !pathProvider.DirectoryExists(fullImportPath)) { - Console.WriteLine($"Error: Caught file exception: {e.Message}"); + CLIMessageUtilities.Error("Error: Invalid file import path specified!"); return; } - string[] sourceFiles = ICommand.GetFilePathsInDirectory(ImportPath, ICommand.TargetFileType.Header, Recursive); + string[] sourceFiles = pathProvider.GetFilePaths(fullImportPath, VolatilityFilePathFilter.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)"); + CLIMessageUtilities.Error($"Error: No valid file(s) found at the specified path ({ImportPath}). Ensure the path exists and spaces are properly enclosed. (--path)"); return; } @@ -72,25 +65,42 @@ public async Task Execute() if (!TypeUtilities.TryParseEnum(ResType, out ResourceType resType)) { - Console.WriteLine("Error: Invalid resource type specified!"); + CLIMessageUtilities.Error("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(); + 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 () => { - 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)}."); + ImportResourceResult result = await importOperation.ExecuteAsync(new ImportResourceRequest( + resType, + platform, + sourceFile, + isX64, + resourcesDirectory, + toolsDirectory, + splicerDirectory, + Overwrite)); + + OperationResult saveResult = await saveOperation.ExecuteAsync( + new SaveResourceRequest(result.Resource, result.ResourcePath), + 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); })); } @@ -105,5 +115,14 @@ public void SetArgs(Dictionary args) Overwrite = args.TryGetValue("overwrite", out var ow) && (bool)ow; Recursive = args.TryGetValue("recurse", out var re) && (bool)re; } - public ImportResourceCommand() { } + + public ImportResourceCommand( + IPathProvider pathProvider, + ImportResourceOperation importOperation, + SaveResourceOperation saveOperation) + { + this.pathProvider = pathProvider; + this.importOperation = importOperation; + this.saveOperation = saveOperation; + } } diff --git a/Volatility/CLI/Commands/ImportStringTableCommand.cs b/Volatility/CLI/Commands/ImportStringTableCommand.cs index e499d88..ccb23b6 100644 --- a/Volatility/CLI/Commands/ImportStringTableCommand.cs +++ b/Volatility/CLI/Commands/ImportStringTableCommand.cs @@ -1,12 +1,18 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.CLI; using Volatility.Operations.StringTables; -using Volatility.Utilities; - -using static Volatility.Utilities.EnvironmentUtilities; namespace Volatility.CLI.Commands; internal class ImportStringTableCommand : ICommand { + private readonly IPathProvider pathProvider; + private readonly IStringTableStore stringTableStore; + private readonly IOperation loadOperation; + private readonly ImportStringTableOperation 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="; @@ -22,53 +28,73 @@ public async Task Execute() { if (string.IsNullOrEmpty(ImportPath)) { - Console.WriteLine("Error: No import path specified! (--path)"); + CLIMessageUtilities.Error("Error: No import path specified! (--path)"); return; } - var filePaths = ICommand.GetFilePathsInDirectory(ImportPath, ICommand.TargetFileType.Any, Recursive); + string[] filePaths = pathProvider.GetFilePaths(ImportPath, VolatilityFilePathFilter.Any, Recursive); if (filePaths.Length == 0) { - Console.WriteLine("Error: No files or folders found within the specified path!"); + CLIMessageUtilities.Error("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!"); + CLIMessageUtilities.Info( + "Importing data from ResourceStringTables into the ResourceDB... this may take a while!", + MessageCategory.StringTable); - string directoryPath = GetEnvironmentDirectory(EnvironmentDirectory.ResourceDB); - Directory.CreateDirectory(directoryPath); + string directoryPath = pathProvider.GetDirectory(VolatilityPathLocation.ResourceDB); + pathProvider.CreateDirectory(directoryPath); - var loadOperation = new LoadResourceDictionaryOperation(); - var mergeOperation = new MergeStringTableEntriesOperation(); - var importOperation = new ImportStringTableOperation(mergeOperation); string version = Version ?? "v2"; - if (version == "v1") + try { - 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 == "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 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; + } + + await importOperation.ExecuteAsync(filePaths, loadResult.Value.Entries, Endian ?? "le", Overwrite, Verbose); + await stringTableStore.WriteYamlAsync(yamlFile, loadResult.Value.Entries); + + CLIMessageUtilities.Success( + $"Finished importing all ResourceDB (v2) data at {yamlFile}.", + MessageCategory.StringTable); } - - if (version != "v2") + catch (Exception ex) { - Console.WriteLine("Error: Invalid version specified! (--version must be v1 or v2)"); - return; + CLIMessageUtilities.Error($"Error: {ex.Message}"); } - - 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) @@ -81,5 +107,15 @@ public void SetArgs(Dictionary args) Verbose = args.TryGetValue("verbose", out var ve) && (bool)ve; } - public ImportStringTableCommand() { } + public ImportStringTableCommand( + IPathProvider pathProvider, + IStringTableStore stringTableStore, + IOperation loadOperation, + ImportStringTableOperation importOperation) + { + this.pathProvider = pathProvider; + this.stringTableStore = stringTableStore; + this.loadOperation = loadOperation; + this.importOperation = importOperation; + } } diff --git a/Volatility/CLI/Commands/PortTextureCommand.cs b/Volatility/CLI/Commands/PortTextureCommand.cs index bbc5b7c..4d70775 100644 --- a/Volatility/CLI/Commands/PortTextureCommand.cs +++ b/Volatility/CLI/Commands/PortTextureCommand.cs @@ -1,9 +1,14 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Services; using Volatility.Operations.Resources; namespace Volatility.CLI.Commands; internal class PortTextureCommand : ICommand { + private readonly IPathProvider pathProvider; + private readonly PortTextureOperation 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=]"; @@ -19,17 +24,20 @@ public async Task Execute() { if (string.IsNullOrEmpty(SourcePath)) { - Console.WriteLine("Error: No source path specified! (--inpath)"); + CLIMessageUtilities.Error("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(); + 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; + } - var operation = new PortTextureOperation(); + CLIMessageUtilities.Info( + $"Starting {sourceFiles.Length} PortTexture tasks...", + MessageCategory.Texture); await operation.ExecuteAsync(sourceFiles, SourceFormat ?? string.Empty, SourcePath ?? string.Empty, DestinationFormat ?? string.Empty, DestinationPath, Verbose, UseGTF); } @@ -52,5 +60,9 @@ public void SetArgs(Dictionary args) UseGTF = args.TryGetValue("usegtf", out var usegtf) && (bool)usegtf; } - public PortTextureCommand() { } + public PortTextureCommand(IPathProvider pathProvider, PortTextureOperation operation) + { + this.pathProvider = pathProvider; + this.operation = operation; + } } diff --git a/Volatility/CLI/Commands/TextureToDDSCommand.cs b/Volatility/CLI/Commands/TextureToDDSCommand.cs index 8b2ee95..3a53d2e 100644 --- a/Volatility/CLI/Commands/TextureToDDSCommand.cs +++ b/Volatility/CLI/Commands/TextureToDDSCommand.cs @@ -1,3 +1,5 @@ +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Services; using Volatility.Operations.Resources; using Volatility.Resources; using Volatility.Utilities; @@ -6,6 +8,9 @@ namespace Volatility.CLI.Commands; internal class TextureToDDSCommand : ICommand { + private readonly IPathProvider pathProvider; + private readonly TextureToDDSOperation 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 +26,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,11 +55,10 @@ 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); } @@ -68,5 +72,9 @@ public void SetArgs(Dictionary args) Verbose = args.TryGetValue("verbose", out var ve) && (bool)ve; } - public TextureToDDSCommand() { } + public TextureToDDSCommand(IPathProvider pathProvider, TextureToDDSOperation operation) + { + this.pathProvider = pathProvider; + this.operation = operation; + } } diff --git a/Volatility/CLI/ICommand.cs b/Volatility/CLI/ICommand.cs index 0042dbd..89a61e9 100644 --- a/Volatility/CLI/ICommand.cs +++ b/Volatility/CLI/ICommand.cs @@ -1,4 +1,5 @@ using static Volatility.Utilities.TypeUtilities; +using Volatility.CLI; namespace Volatility; @@ -18,47 +19,6 @@ public void ShowUsage() var parameters = GetStaticPropertyValue(thisType, "CommandParameters"); var description = GetStaticPropertyValue(thisType, "CommandDescription"); - Console.WriteLine($"Usage:\n {token} {parameters}\n{description}"); + CLIMessageUtilities.Info(thisType.Name, $"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/Frontend.cs b/Volatility/Frontend.cs index 41b3694..9327dea 100644 --- a/Volatility/Frontend.cs +++ b/Volatility/Frontend.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,7 @@ 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)); #if DEBUG throw; #endif @@ -200,7 +222,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/Volatility/Hosting/VolatilityServiceCollectionExtensions.cs b/Volatility/Hosting/VolatilityServiceCollectionExtensions.cs new file mode 100644 index 0000000..77cb697 --- /dev/null +++ b/Volatility/Hosting/VolatilityServiceCollectionExtensions.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; +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.AddSingleton(); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddTransient(serviceProvider => + new(serviceProvider.GetRequiredService().GetDirectory(VolatilityPathLocation.Resources))); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(serviceProvider => + new(serviceProvider.GetRequiredService>())); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + services.AddTransient>(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddTransient>(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddTransient>(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddTransient>(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddTransient>(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddTransient>(serviceProvider => + serviceProvider.GetRequiredService()); + + return services; + } +} diff --git a/Volatility/Operations/Autotest/GameAutotestOperation.cs b/Volatility/Operations/Autotest/GameAutotestOperation.cs index ee73cde..4b6d078 100644 --- a/Volatility/Operations/Autotest/GameAutotestOperation.cs +++ b/Volatility/Operations/Autotest/GameAutotestOperation.cs @@ -1,5 +1,8 @@ using System.Globalization; +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Operations; using Volatility.Operations.Resources; using Volatility.Resources; using Volatility.Utilities; @@ -35,6 +38,15 @@ internal sealed record GameAutotestCaseResult( internal sealed class GameAutotestOperation { + private readonly IPathProvider pathProvider; + private readonly IProcessRunner processRunner; + private readonly ImportResourceOperation importOperation; + private readonly SaveResourceOperation saveOperation; + private readonly LoadResourceOperation loadOperation; + private readonly ExportResourceOperation exportOperation; + private readonly TextureToDDSOperation textureToDdsOperation; + private readonly PortTextureOperation portTextureOperation; + private static readonly HashSet RoundTripTypes = [ ResourceType.Texture, @@ -86,6 +98,26 @@ internal sealed class GameAutotestOperation "TRK_UNIT0_GR.BNDL", ]; + public GameAutotestOperation( + IPathProvider pathProvider, + IProcessRunner processRunner, + ImportResourceOperation importOperation, + SaveResourceOperation saveOperation, + LoadResourceOperation loadOperation, + ExportResourceOperation exportOperation, + TextureToDDSOperation textureToDdsOperation, + PortTextureOperation portTextureOperation) + { + this.pathProvider = pathProvider; + this.processRunner = processRunner; + this.importOperation = importOperation; + this.saveOperation = saveOperation; + this.loadOperation = loadOperation; + this.exportOperation = exportOperation; + this.textureToDdsOperation = textureToDdsOperation; + this.portTextureOperation = portTextureOperation; + } + public async Task ExecuteAsync(GameAutotestOptions options) { if (options.GamePaths.Count == 0) @@ -93,12 +125,12 @@ public async Task ExecuteAsync(GameAutotestOptions options) throw new InvalidOperationException("At least one game path must be provided."); } - string repoRoot = WorkspaceUtilities.FindRepositoryRoot(); + string repoRoot = pathProvider.GetRepositoryRoot(); bool useYapBundleTool = IsYapTool(options.BundleToolPath); string bundleToolPath = GetBundleTool(repoRoot, options.BundleToolPath); string sessionRoot = GetSessionRoot(repoRoot, options.WorkingDirectory); - Directory.CreateDirectory(sessionRoot); + pathProvider.CreateDirectory(sessionRoot); GameAutotestSummary summary = new(); foreach (string gamePath in options.GamePaths) @@ -119,7 +151,7 @@ private async Task RunGameAsync( GameAutotestSummary summary) { 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}"); @@ -156,23 +188,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 +205,22 @@ private async Task RunGameAsync( await RunRoundTripAsync( game, candidate, - importPass1, - importPass2, - saveOperation, - loadOperation, - exportOperation, + pass1Resources, + pass2Resources, + toolsRoot, + splicerPass1, + splicerPass2, exportsRoot, summary); } else if (ImportOnlyTypes.Contains(candidate.ResourceType)) { - await RunImportOnlyAsync(game, candidate, importPass1, saveOperation, summary); + await RunImportOnlyAsync(game, candidate, pass1Resources, toolsRoot, splicerPass1, summary); } if (candidate.ResourceType == ResourceType.Texture) { - await RunTextureOperationsAsync(game, candidate, textureToDdsOperation, portTextureOperation, ddsRoot, portRoot, summary); + await RunTextureOperationsAsync(game, candidate, ddsRoot, portRoot, summary); } } @@ -206,14 +230,14 @@ 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) { @@ -223,12 +247,20 @@ 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 importOperation.ExecuteAsync(new ImportResourceRequest( + candidate.ResourceType, + game.Platform, + candidate.SourcePath, + IsX64: false, + pass1Resources, + toolsRoot, + splicerPass1, + Overwrite: true)); + await SaveAsync(firstImport.Resource, firstImport.ResourcePath); + + Resource loaded = await LoadAsync(firstImport.ResourcePath, candidate.ResourceType, game.Platform); exportPath = Path.Combine(exportsRoot, Path.GetFileName(candidate.SourcePath)); - await exportOperation.ExecuteAsync(loaded, exportPath, game.Platform); + await exportOperation.ExecuteAsync(loaded, exportPath, game.Platform, splicerDirectory: splicerPass1); BinaryComparisonResult binaryComparison = CompareFiles(candidate.SourcePath, exportPath); AddCase(summary, new GameAutotestCaseResult( @@ -240,8 +272,16 @@ 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 importOperation.ExecuteAsync(new ImportResourceRequest( + candidate.ResourceType, + game.Platform, + exportPath, + IsX64: false, + pass2Resources, + toolsRoot, + splicerPass2, + Overwrite: true)); + await SaveAsync(secondImport.Resource, secondImport.ResourcePath); string firstYaml = NormalizeYaml(await File.ReadAllTextAsync(firstImport.ResourcePath)); string secondYaml = NormalizeYaml(await File.ReadAllTextAsync(secondImport.ResourcePath)); @@ -282,19 +322,28 @@ private static async Task RunRoundTripAsync( } } - private static async Task RunImportOnlyAsync( + private async Task RunImportOnlyAsync( GameInstall game, ResourceTestCandidate candidate, - ImportResourceOperation importOperation, - SaveResourceOperation saveOperation, + string resourcesDirectory, + string toolsRoot, + string splicerDirectory, GameAutotestSummary summary) { 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 importOperation.ExecuteAsync(new ImportResourceRequest( + candidate.ResourceType, + game.Platform, + candidate.SourcePath, + IsX64: false, + resourcesDirectory, + toolsRoot, + splicerDirectory, + Overwrite: true)); + await SaveAsync(importResult.Resource, importResult.ResourcePath); AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "import", "PASS", TestedResourceType: candidate.ResourceType)); } catch (Exception ex) @@ -303,11 +352,9 @@ 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) @@ -337,7 +384,7 @@ 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); + pathProvider.CreateDirectory(destinationPath); await portTextureOperation.ExecuteAsync( [candidate.SourcePath], @@ -346,7 +393,7 @@ await portTextureOperation.ExecuteAsync( destinationFormat, destinationPath, verbose: false, - useGtf: false); + useGTF: false); AddCase(summary, new GameAutotestCaseResult(game.Name, portCaseName, "porttexture", "PASS", TestedResourceType: ResourceType.Texture)); } @@ -356,13 +403,41 @@ await portTextureOperation.ExecuteAsync( } } + private async Task LoadAsync(string sourceFile, ResourceType resourceType, Platform platform) + { + OperationResult result = await loadOperation.ExecuteAsync( + new LoadResourceRequest(sourceFile, resourceType, platform), + progress: null, + cancellationToken: CancellationToken.None); + + if (!result.Success || result.Value == null) + { + throw OperationResultFactory.CreateException(result, "Failed to load resource."); + } + + return result.Value.Resource; + } + + private async Task SaveAsync(Resource resource, string filePath) + { + OperationResult result = await saveOperation.ExecuteAsync( + new SaveResourceRequest(resource, filePath), + progress: null, + cancellationToken: CancellationToken.None); + + 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, @@ -376,7 +451,7 @@ private static List ProbeBundles( } string probeRoot = Path.Combine(gameWorkRoot, "bundle_probes"); - Directory.CreateDirectory(probeRoot); + pathProvider.CreateDirectory(probeRoot); HashSet reportedUnsupportedTypes = []; List probes = []; @@ -393,14 +468,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)); @@ -444,7 +519,7 @@ private static List ProbeBundles( return probes; } - private static List ProbeYapBundles( + private List ProbeYapBundles( GameInstall game, string bundleToolPath, string gameWorkRoot, @@ -471,7 +546,7 @@ private static List ProbeYapBundles( try { - ProcessUtilities.RunAndCapture( + processRunner.RunAndCapture( bundleToolPath, $"-d e \"{inputRoot}\" \"{probeRoot}\"", Path.GetDirectoryName(bundleToolPath)); @@ -513,7 +588,7 @@ private static List ProbeYapBundles( return probes; } - private static List GetBundleTests( + private List GetBundleTests( GameInstall game, string bundleToolPath, bool useYapBundleTool, @@ -523,7 +598,7 @@ private static List GetBundleTests( GameAutotestSummary summary) { string extractedRoot = Path.Combine(gameWorkRoot, "bundles"); - Directory.CreateDirectory(extractedRoot); + pathProvider.CreateDirectory(extractedRoot); HashSet blockedTypes = []; Dictionary selectedCounts = new(); @@ -573,7 +648,7 @@ private static List GetBundleTests( try { - ProcessUtilities.RunAndCapture( + processRunner.RunAndCapture( bundleToolPath, $"--bundle \"{probedBundle.BundlePath}\" --output \"{outputDirectory}\" --manifest \"{manifestPath}\"", Path.GetDirectoryName(bundleToolPath)); @@ -701,14 +776,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) @@ -1053,7 +1128,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 +1137,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 +1147,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 +1163,27 @@ 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.TUB); } diff --git a/Volatility/Operations/OperationResultFactory.cs b/Volatility/Operations/OperationResultFactory.cs new file mode 100644 index 0000000..6d5b980 --- /dev/null +++ b/Volatility/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/Volatility/Operations/Resources/CreateResourceOperation.cs b/Volatility/Operations/Resources/CreateResourceOperation.cs index 8890594..363704f 100644 --- a/Volatility/Operations/Resources/CreateResourceOperation.cs +++ b/Volatility/Operations/Resources/CreateResourceOperation.cs @@ -1,47 +1,55 @@ +using Volatility.Abstractions.Operations; +using Volatility.Operations; using Volatility.Resources; namespace Volatility.Operations.Resources; -internal sealed class CreateResourceOperation +internal sealed class CreateResourceOperation(string resourcesDirectory) + : IOperation { - 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) + public Task> ExecuteAsync( + CreateResourceRequest request, + IProgress? progress, + CancellationToken cancellationToken) { - Resource resource = ResourceFactory.CreateResource(resourceType, platform, string.Empty, isX64); + cancellationToken.ThrowIfCancellationRequested(); - string resolvedPath = ResolveOutputPath(outputPath, resourceType, assetName); - string resolvedAssetName = ResolveAssetName(assetName, resolvedPath); - - if (!string.IsNullOrWhiteSpace(resolvedAssetName)) - resource.AssetName = resolvedAssetName; - - if (resourceId.HasValue) + try { - resource.ResourceID = resourceId.Value; + 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))); } - else if (!string.IsNullOrWhiteSpace(resolvedAssetName)) + catch (Exception ex) { - resource.ResourceID = ResourceID.HashFromString(resolvedAssetName); + return Task.FromResult(OperationResultFactory.Failure( + "create_resource_failed", + ex.Message, + nameof(CreateResourceOperation))); } - - 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) @@ -51,17 +59,19 @@ private string ResolveOutputPath(string? outputPath, ResourceType resourceType, 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)); + resolvedPath = Path.Combine(resourcesDirectory, NormalizeGameDBPath(assetName)); } else if (!Path.IsPathRooted(outputPath)) { - resolvedPath = Path.Combine(resourcesDirectory, NormalizeGameDbPath(outputPath)); + resolvedPath = Path.Combine(resourcesDirectory, NormalizeGameDBPath(outputPath)); } else { - resolvedPath = NormalizeGameDbPath(outputPath); + resolvedPath = NormalizeGameDBPath(outputPath); } return Path.ChangeExtension(resolvedPath, resourceType.ToString()); @@ -70,20 +80,32 @@ private string ResolveOutputPath(string? outputPath, ResourceType resourceType, private static string ResolveAssetName(string? assetName, string outputPath) { if (!string.IsNullOrWhiteSpace(assetName)) + { return assetName; + } return Path.GetFileNameWithoutExtension(outputPath); } - private static string NormalizeGameDbPath(string path) + 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 CreateResourceRequest( + ResourceType ResourceType, + Platform Platform, + string? AssetName, + string? OutputPath, + ResourceID? ResourceId, + bool IsX64) : IOperationRequest; + internal sealed record CreateResourceResult(Resource Resource, string ResourcePath); diff --git a/Volatility/Operations/Resources/CreateShaderProgramBufferOperation.cs b/Volatility/Operations/Resources/CreateShaderProgramBufferOperation.cs index a68f1b8..c72f3dc 100644 --- a/Volatility/Operations/Resources/CreateShaderProgramBufferOperation.cs +++ b/Volatility/Operations/Resources/CreateShaderProgramBufferOperation.cs @@ -1,50 +1,72 @@ +using Volatility.Abstractions.Operations; +using Volatility.Operations; using Volatility.Resources; using Volatility.Utilities; namespace Volatility.Operations.Resources; internal sealed class CreateShaderProgramBufferOperation + : IOperation { - public ShaderProgramBufferBase ExecuteFromFile(string csoPath, ShaderStageType stage, Platform platform) + public async Task> ExecuteAsync( + CreateShaderProgramBufferRequest request, + IProgress? progress, + CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(csoPath)) - throw new ArgumentNullException(nameof(csoPath)); - if (!File.Exists(csoPath)) - throw new FileNotFoundException($"CSO file not found: {csoPath}"); + cancellationToken.ThrowIfCancellationRequested(); - byte[] csoBytes = File.ReadAllBytes(csoPath); - return Execute(csoBytes, stage, platform); - } - - public ShaderProgramBufferBase Execute(byte[] csoBytes, ShaderStageType stage, Platform platform) - { - return platform switch + try { - Platform.BPR => ShaderProgramBufferBPR.FromCSO(csoBytes, stage), - _ => throw new NotSupportedException($"ShaderProgramBuffer is not implemented for platform {platform}.") - }; - } + 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)); + } - public ShaderProgramBufferBPR ExecuteFromFile(string csoPath, ShaderStageType stage) - { - return (ShaderProgramBufferBPR)ExecuteFromFile(csoPath, stage, Platform.BPR); - } + if (!File.Exists(request.CsoPath)) + { + return OperationResultFactory.Failure( + "shader_program_buffer_missing_file", + $"CSO file not found: {request.CsoPath}", + nameof(CreateShaderProgramBufferOperation)); + } - public ShaderProgramBufferBPR Execute(byte[] csoBytes, ShaderStageType stage) - { - return (ShaderProgramBufferBPR)Execute(csoBytes, stage, Platform.BPR); + 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 void WriteToFile(ShaderProgramBufferBase buffer, string outputPath, Platform platform) { - if (buffer == null) - throw new ArgumentNullException(nameof(buffer)); + 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)) @@ -55,22 +77,38 @@ public void WriteToFile(ShaderProgramBufferBase buffer, string outputPath, Platf 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) + private static ShaderProgramBufferBase CreateBuffer(byte[] csoBytes, ShaderStageType stage, Platform platform) { - WriteToFile(buffer, outputPath, Platform.BPR); + 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}."); } } + +internal sealed record CreateShaderProgramBufferRequest( + ShaderStageType Stage, + Platform Platform, + string? CsoPath = null, + byte[]? CsoBytes = null) : IOperationRequest; + +internal sealed record CreateShaderProgramBufferResult(ShaderProgramBufferBase Buffer); diff --git a/Volatility/Operations/Resources/ExportResourceOperation.cs b/Volatility/Operations/Resources/ExportResourceOperation.cs index 508df0c..ee033d3 100644 --- a/Volatility/Operations/Resources/ExportResourceOperation.cs +++ b/Volatility/Operations/Resources/ExportResourceOperation.cs @@ -1,15 +1,23 @@ +using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; +using Volatility.Operations; using Volatility.Resources; using Volatility.Utilities; namespace Volatility.Operations.Resources; -internal class ExportResourceOperation +internal sealed class ExportResourceOperation( + IPathProvider pathProvider, + IShaderCompiler shaderCompiler, + CreateShaderProgramBufferOperation shaderProgramBufferOperation, + ISplicerSampleStore splicerSampleStore) { public Task ExecuteAsync( Resource resource, string outputPath, Platform platform, Unpacker? importUnpackerOverride = null, - bool writeImportsToSeparateFile = false) + bool writeImportsToSeparateFile = false, + string? splicerDirectory = null) { string? directoryPath = Path.GetDirectoryName(outputPath); @@ -18,6 +26,13 @@ public Task ExecuteAsync( Directory.CreateDirectory(directoryPath); } + if (resource is Splicer splicer) + { + splicerSampleStore.PopulateDependentSamples( + splicer, + splicerDirectory ?? pathProvider.GetDirectory(VolatilityPathLocation.Splicer)); + } + using FileStream fs = new(outputPath, FileMode.Create); Endian endian = resource.ResourceEndian != Endian.Agnostic @@ -51,21 +66,30 @@ public Task ExecuteAsync( 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); + shaderCompiler.CompileToCSO(shader, stage, csoPath); + OperationResult bufferResult = shaderProgramBufferOperation.ExecuteAsync( + new CreateShaderProgramBufferRequest(stage.ResolveStage(), platform, csoPath), + progress: null, + cancellationToken: CancellationToken.None).GetAwaiter().GetResult(); + + if (!bufferResult.Success || bufferResult.Value == null) + { + throw OperationResultFactory.CreateException(bufferResult, "Failed to create shader program buffer."); + } + + ShaderProgramBufferBase buffer = bufferResult.Value.Buffer; + shaderProgramBufferOperation.WriteToFile(buffer, shaderProgramBufferPath, platform); WritePaddedCSOFile(csoPath, GetSecondaryResourcePath(shaderProgramBufferPath)); } } else { - DXCShaderCompiler.CompileStagesToCSO(shader, stages, stage => + shaderCompiler.CompileStagesToCSO(shader, stages, stage => GetShaderProgramBufferPath(outputPath, stage, useStageSuffix)); } } diff --git a/Volatility/Operations/Resources/ImportResourceOperation.cs b/Volatility/Operations/Resources/ImportResourceOperation.cs index 6d93b07..269f466 100644 --- a/Volatility/Operations/Resources/ImportResourceOperation.cs +++ b/Volatility/Operations/Resources/ImportResourceOperation.cs @@ -1,34 +1,28 @@ using System.Text.RegularExpressions; - +using Volatility.Abstractions.Services; using Volatility.Resources; -using Volatility.Utilities; namespace Volatility.Operations.Resources; -internal partial class ImportResourceOperation +internal sealed partial class ImportResourceOperation( + IResourceDBLookup resourceDBLookup, + ITextureBitmapStore textureBitmapStore, + IProcessRunner processRunner, + IShaderSourceStore shaderSourceStore) { - 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) + public async Task ExecuteAsync(ImportResourceRequest request) { - 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); + Resource resource = ResourceFactory.LoadResource( + request.ResourceType, + request.Platform, + request.SourceFile, + resourceDBLookup, + request.IsX64); string filePath = Path.Combine ( - resourcesDirectory, - $"{DBToFileRegex().Replace(resource.AssetName, string.Empty)}.{resourceType}" + request.ResourcesDirectory, + $"{DBToFileRegex().Replace(resource.AssetName, string.Empty)}.{request.ResourceType}" ); string? directoryPath = Path.GetDirectoryName(filePath); @@ -38,9 +32,14 @@ public async Task ExecuteAsync(ResourceType resourceType, Directory.CreateDirectory(directoryPath); } - if (resourceType == ResourceType.Texture) + if (resource is ShaderBase shader) + { + shaderSourceStore.MaterializeImportedSource(shader, request.ResourcesDirectory); + } + + if (request.ResourceType == ResourceType.Texture) { - string texturePath = TextureBitmapUtilities.GetSecondaryBitmapPath(sourceFile, resource.Unpacker); + string texturePath = textureBitmapStore.GetSecondaryBitmapPath(request.SourceFile, resource.Unpacker); if (resource is TextureBase texture && File.Exists(texturePath)) { @@ -50,15 +49,15 @@ public async Task ExecuteAsync(ResourceType resourceType, Path.GetFileNameWithoutExtension(Path.GetFileNameWithoutExtension(Path.GetFullPath(filePath))) ); - TextureBitmapUtilities.WriteNormalizedBitmapFile(texture, texturePath, $"{outPath}.{resourceType}Bitmap", overwrite); + textureBitmapStore.WriteNormalizedBitmapFile(texture, texturePath, $"{outPath}.{request.ResourceType}Bitmap", request.Overwrite); } } - if (resourceType == ResourceType.Splicer) + if (request.ResourceType == ResourceType.Splicer) { string sxPath = Path.Combine ( - toolsDirectory, + request.ToolsDirectory, "sx.exe" ); @@ -70,7 +69,7 @@ public async Task ExecuteAsync(ResourceType resourceType, string sampleDirectory = Path.Combine ( - splicerDirectory, + request.SplicerDirectory, "Samples" ); @@ -84,7 +83,7 @@ public async Task ExecuteAsync(ResourceType resourceType, string samplePathName = Path.Combine(sampleDirectory, sampleName); - if (!File.Exists($"{samplePathName}.snr") || overwrite) + if (!File.Exists($"{samplePathName}.snr") || request.Overwrite) { Console.WriteLine($"Writing extracted sample {sampleName}.snr"); await File.WriteAllBytesAsync($"{samplePathName}.snr", samples[i].Data); @@ -102,10 +101,10 @@ public async Task ExecuteAsync(ResourceType resourceType, convertedSamplePathName = Path.Combine(convertedSamplePathName, sampleName + ".wav"); - if (!File.Exists(convertedSamplePathName) || overwrite) + if (!File.Exists(convertedSamplePathName) || request.Overwrite) { Console.WriteLine($"Converting extracted sample {sampleName}.snr to wave..."); - ProcessUtilities.RunAndRelayOutput( + processRunner.RunAndRelayOutput( sxPath, $"-wave -s16l_int -v0 \"{samplePathName}.snr\" -=\"{convertedSamplePathName}\""); } @@ -121,23 +120,18 @@ public async Task ExecuteAsync(ResourceType resourceType, 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 ImportResourceRequest( + ResourceType ResourceType, + Platform Platform, + string SourceFile, + bool IsX64, + string ResourcesDirectory, + string ToolsDirectory, + string SplicerDirectory, + bool Overwrite); + internal sealed record ImportResourceResult(Resource Resource, string ResourcePath); diff --git a/Volatility/Operations/Resources/LoadResourceOperation.cs b/Volatility/Operations/Resources/LoadResourceOperation.cs index a51abd9..1a90feb 100644 --- a/Volatility/Operations/Resources/LoadResourceOperation.cs +++ b/Volatility/Operations/Resources/LoadResourceOperation.cs @@ -1,26 +1,63 @@ using System.Runtime.Serialization; - +using Volatility.Abstractions.Operations; +using Volatility.Operations; using Volatility.Resources; using Volatility.Utilities; namespace Volatility.Operations.Resources; -internal class LoadResourceOperation +internal sealed class LoadResourceOperation : IOperation { - public async Task ExecuteAsync(string sourceFile, ResourceType resourceType, Platform platform) + public async Task> ExecuteAsync( + LoadResourceRequest request, + IProgress? progress, + CancellationToken cancellationToken) { - string yaml = await File.ReadAllTextAsync(sourceFile); + if (string.IsNullOrWhiteSpace(request.SourceFile)) + { + return OperationResultFactory.Failure( + "load_resource_missing_path", + "A source file path is required.", + nameof(LoadResourceOperation)); + } - Resource resource = ResourceFactory.CreateResource(resourceType, platform, string.Empty); + 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); - 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)); + } - if (result is null) + result.ImportedFileName = request.SourceFile; + progress?.Report(new OperationProgress("load-resource", 1.0, request.SourceFile)); + return OperationResultFactory.Success(new LoadResourceResult(result)); + } + catch (SerializationException ex) { - throw new SerializationException(); + return OperationResultFactory.Failure( + "load_resource_deserialize_failed", + ex.Message, + nameof(LoadResourceOperation)); + } + catch (Exception ex) + { + return OperationResultFactory.Failure( + "load_resource_failed", + ex.Message, + nameof(LoadResourceOperation)); } - - result.ImportedFileName = sourceFile; - return result; } + } + +internal sealed record LoadResourceRequest(string SourceFile, ResourceType ResourceType, Platform Platform) : IOperationRequest; + +internal sealed record LoadResourceResult(Resource Resource); diff --git a/Volatility/Operations/Resources/PortTextureOperation.cs b/Volatility/Operations/Resources/PortTextureOperation.cs index 407ea0b..36cef24 100644 --- a/Volatility/Operations/Resources/PortTextureOperation.cs +++ b/Volatility/Operations/Resources/PortTextureOperation.cs @@ -1,5 +1,6 @@ using System.Reflection; +using Volatility.Abstractions.Services; using Volatility.Resources; using Volatility.Utilities; @@ -7,11 +8,15 @@ namespace Volatility.Operations.Resources; -internal class PortTextureOperation +internal sealed class PortTextureOperation( + IResourceDBLookup resourceDBLookup, + ITextureBitmapStore textureBitmapStore) { - public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFormat, string sourcePath, string destinationFormat, string? destinationPath, bool verbose, bool useGtf) + 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; + TextureFormatSpec sourceSpec = ParseTextureFormat(sourceFormat); + TextureFormatSpec destinationSpec = ParseTextureFormat(destinationFormat); List tasks = new List(); @@ -19,18 +24,11 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor { tasks.Add(Task.Run(async () => { - TextureBase? sourceTexture = ConstructHeader(sourceFile, sourceFormat, verbose); - TextureBase? destinationTexture = ConstructHeader(resolvedDestinationPath, destinationFormat, verbose); + TextureBase sourceTexture = LoadSourceTexture(sourceFile, sourceSpec, verbose); + TextureBase destinationTexture = CreateDestinationTexture(destinationSpec, 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(); + string localSourceFormat = sourceSpec.DisplayName; + string localDestinationFormat = destinationSpec.DisplayName; CopyProperties(sourceTexture, destinationTexture); @@ -167,19 +165,29 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor { outPath = $"{Path.GetDirectoryName(resolvedDestinationPath)}{Path.DirectorySeparatorChar}{outResourceFilename}"; } - else if (new DirectoryInfo(resolvedDestinationPath).Exists) + else if (Directory.Exists(resolvedDestinationPath) || !Path.HasExtension(resolvedDestinationPath)) + { + outPath = Path.Combine(resolvedDestinationPath, outResourceFilename); + } + else { - outPath = resolvedDestinationPath + Path.DirectorySeparatorChar + outResourceFilename; + outPath = resolvedDestinationPath; } - string sourceBitmapPath = TextureBitmapUtilities.GetSecondaryBitmapPath(sourceFile, sourceTexture.Unpacker); + string? outputDirectory = Path.GetDirectoryName(outPath); + if (!string.IsNullOrEmpty(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + string sourceBitmapPath = textureBitmapStore.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); + string destinationBitmapPath = textureBitmapStore.GetSecondaryBitmapPath(outPath, sourceTexture.Unpacker); if (Path.Exists(destinationBitmapPath)) { @@ -188,12 +196,12 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor try { - if (useGtf && sourceTexture.ResourcePlatform == Platform.PS3) + if (useGTF && sourceTexture is TexturePS3 sourcePs3) { - PS3TextureUtilities.PS3GTFToDDS(sourcePath, sourceBitmapPath, destinationBitmapPath, verbose); + textureBitmapStore.ConvertPS3GTFToDDS(sourcePs3, sourceBitmapPath, destinationBitmapPath, verbose); } - byte[] sourceBitmapData = TextureBitmapUtilities.ReadNormalizedBitmapData(sourceTexture, sourceBitmapPath); + byte[] sourceBitmapData = textureBitmapStore.ReadNormalizedBitmapData(sourceTexture, sourceBitmapPath); if (destinationTexture is TextureX360 destX && sourceTexture.ResourcePlatform != Platform.X360) { @@ -235,38 +243,49 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor } catch (Exception ex) { - Console.WriteLine($"Error trying to copy bitmap data for {Path.GetFileNameWithoutExtension(sourceFile)}: {ex.Message}"); + throw new IOException( + $"Failed to port texture data for {Path.GetFileNameWithoutExtension(sourceFile)}: {ex.Message}", + ex); } - Console.WriteLine($"Successfully ported {localSourceFormat} formatted {Path.GetFileNameWithoutExtension(sourceFile)}to {localDestinationFormat} as {Path.GetFileNameWithoutExtension(outPath)}."); + 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) + private TextureBase LoadSourceTexture(string path, TextureFormatSpec format, bool verbose) { - if (header.ResourcePlatform == Platform.BPR && format.EndsWith("X64", StringComparison.Ordinal)) - { - header.SetResourceArch(Arch.x64); - return "BPR"; - } - return format; + if (verbose) Console.WriteLine($"Loading {format.DisplayName} texture property data..."); + return (TextureBase)ResourceFactory.LoadResource(ResourceType.Texture, format.Platform, path, resourceDBLookup, format.IsX64); } - private static TextureBase? ConstructHeader(string path, string format, bool verbose) + private static TextureBase CreateDestinationTexture(TextureFormatSpec format, bool verbose) { - if (verbose) Console.WriteLine($"Constructing {format} texture property data..."); - return format switch + if (verbose) Console.WriteLine($"Constructing {format.DisplayName} texture property data..."); + return (TextureBase)ResourceFactory.CreateResource(ResourceType.Texture, format.Platform, format.IsX64); + } + + private static TextureFormatSpec ParseTextureFormat(string format) + { + string normalizedFormat = format.Trim().ToUpperInvariant(); + bool isX64 = normalizedFormat.EndsWith("X64", StringComparison.OrdinalIgnoreCase); + if (isX64) { - "BPR" => new TextureBPR(path), - "BPRX64" => new TextureBPR(path), - "TUB" => new TexturePC(path), - "X360" => new TextureX360(path), - "PS3" => new TexturePS3(path), + 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 static void CopyProperties(TextureBase source, TextureBase destination) @@ -509,4 +528,6 @@ private bool TryConvertTexture(TextureBase srcTexture, TextureBase destTexture, { DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT2_3 }, { DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM, GPUTEXTUREFORMAT.GPUTEXTUREFORMAT_DXT4_5 }, }; + + private readonly record struct TextureFormatSpec(Platform Platform, bool IsX64, string DisplayName); } diff --git a/Volatility/Operations/Resources/SaveResourceOperation.cs b/Volatility/Operations/Resources/SaveResourceOperation.cs index 3fcc0c4..cc97df7 100644 --- a/Volatility/Operations/Resources/SaveResourceOperation.cs +++ b/Volatility/Operations/Resources/SaveResourceOperation.cs @@ -1,11 +1,12 @@ -using YamlDotNet.Serialization; - +using Volatility.Abstractions.Operations; +using Volatility.Operations; using Volatility.Resources; using Volatility.Utilities; +using YamlDotNet.Serialization; namespace Volatility.Operations.Resources; -internal class SaveResourceOperation +internal sealed class SaveResourceOperation : IOperation { private readonly ISerializer serializer; @@ -21,16 +22,52 @@ public SaveResourceOperation() .Build(); } - public async Task ExecuteAsync(Resource resource, string filePath) + public async Task> ExecuteAsync( + SaveResourceRequest request, + IProgress? progress, + CancellationToken cancellationToken) { - string? directoryPath = Path.GetDirectoryName(filePath); + if (request.Resource is null) + { + return OperationResultFactory.Failure( + "save_resource_missing_resource", + "A resource instance is required.", + nameof(SaveResourceOperation)); + } - if (!string.IsNullOrEmpty(directoryPath)) + if (string.IsNullOrWhiteSpace(request.FilePath)) { - Directory.CreateDirectory(directoryPath); + return OperationResultFactory.Failure( + "save_resource_missing_path", + "A file path is required.", + nameof(SaveResourceOperation)); } - string serializedString = serializer.Serialize(resource); - await File.WriteAllTextAsync(filePath, serializedString); + try + { + 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)); + } } + } + +internal sealed record SaveResourceRequest(Resource Resource, string FilePath) : IOperationRequest; + +internal sealed record SaveResourceResult(string FilePath); diff --git a/Volatility/Operations/Resources/TextureToDDSOperation.cs b/Volatility/Operations/Resources/TextureToDDSOperation.cs index 3a7cf7a..ce094a6 100644 --- a/Volatility/Operations/Resources/TextureToDDSOperation.cs +++ b/Volatility/Operations/Resources/TextureToDDSOperation.cs @@ -1,9 +1,12 @@ +using Volatility.Abstractions.Services; using Volatility.Resources; using Volatility.Utilities; namespace Volatility.Operations.Resources; -internal class TextureToDDSOperation +internal sealed class TextureToDDSOperation( + IResourceDBLookup resourceDBLookup, + ITextureBitmapStore textureBitmapStore) { public async Task ExecuteAsync(IEnumerable sourceFiles, Platform platform, bool isX64, string? outputPath, bool overwrite, bool verbose) { @@ -15,17 +18,17 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, Platform platfor { tasks.Add(Task.Run(async () => { - TextureBase texture = (TextureBase)ResourceFactory.CreateResource(ResourceType.Texture, platform, sourceFile, isX64); - string sourceBitmapPath = TextureBitmapUtilities.GetSecondaryBitmapPath(sourceFile, texture.Unpacker); + TextureBase texture = (TextureBase)ResourceFactory.LoadResource(ResourceType.Texture, platform, sourceFile, resourceDBLookup, 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 = TextureBitmapUtilities.ReadNormalizedBitmapData(texture, sourceBitmapPath); + byte[] bitmapData = textureBitmapStore.ReadNormalizedBitmapData(texture, sourceBitmapPath); byte[] ddsData = DDSTextureUtilities.CreateDDSFile(texture, bitmapData); - string destinationPath = ResolveOutputPath(sourceFile, texture.Unpacker, outputPath, multipleInputs); + string destinationPath = ResolveOutputPath(sourceFile, texture.Unpacker, outputPath, multipleInputs, textureBitmapStore); string? destinationDirectory = Path.GetDirectoryName(destinationPath); if (!string.IsNullOrEmpty(destinationDirectory)) @@ -47,9 +50,14 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, Platform platfor await Task.WhenAll(tasks); } - private static string ResolveOutputPath(string sourceFile, Unpacker unpacker, string? outputPath, bool multipleInputs) + private static string ResolveOutputPath( + string sourceFile, + Unpacker unpacker, + string? outputPath, + bool multipleInputs, + ITextureBitmapStore textureBitmapStore) { - string outputName = TextureBitmapUtilities.GetResourceBaseName(sourceFile, unpacker) + ".dds"; + string outputName = textureBitmapStore.GetResourceBaseName(sourceFile, unpacker) + ".dds"; if (string.IsNullOrWhiteSpace(outputPath)) { diff --git a/Volatility/Operations/StringTables/ImportStringTableOperation.cs b/Volatility/Operations/StringTables/ImportStringTableOperation.cs index 515dbc7..c7d8def 100644 --- a/Volatility/Operations/StringTables/ImportStringTableOperation.cs +++ b/Volatility/Operations/StringTables/ImportStringTableOperation.cs @@ -1,27 +1,43 @@ using System.Text; using System.Xml.Linq; +using Volatility.Abstractions.Operations; +using Volatility.Operations; -using static Volatility.Utilities.ResourceIDUtilities; using static Volatility.Utilities.DictUtilities; +using static Volatility.Utilities.ResourceIDUtilities; namespace Volatility.Operations.StringTables; -internal class ImportStringTableOperation +internal sealed class ImportStringTableOperation { - private readonly MergeStringTableEntriesOperation mergeOperation; + private readonly IOperation mergeOperation; - public ImportStringTableOperation(MergeStringTableEntriesOperation mergeOperation) + public ImportStringTableOperation( + IOperation mergeOperation) { this.mergeOperation = mergeOperation; } - public async Task ExecuteAsync(IEnumerable filePaths, Dictionary> entries, string endian, bool overwrite, bool verbose) + 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) + foreach (Dictionary> fileResult in results) { - mergeOperation.Execute(entries, fileResult, overwrite); + OperationResult mergeResult = await mergeOperation.ExecuteAsync( + new MergeStringTableEntriesRequest(entries, fileResult, overwrite), + progress: null, + cancellationToken: CancellationToken.None); + + if (!mergeResult.Success) + { + throw OperationResultFactory.CreateException(mergeResult, "Failed to merge string table entries."); + } } GC.Collect(); @@ -29,7 +45,11 @@ public async Task ExecuteAsync(IEnumerable filePaths, Dictionary>> ProcessFileAsync(string filePath, string endian, bool overwrite, bool verbose) + private static async Task>> ProcessFileAsync( + string filePath, + string endian, + bool overwrite, + bool verbose) { var entriesByType = new Dictionary>(StringComparer.OrdinalIgnoreCase); string fileName = Path.GetFileName(filePath)!; @@ -39,12 +59,16 @@ private async Task") + "".Length; if (start < 0 || end <= start) { - if (verbose) Console.WriteLine($"Skipping (no table): {fileName}"); + if (verbose) + { + Console.WriteLine($"Skipping (no table): {fileName}"); + } + return entriesByType; } XDocument xmlDoc = XDocument.Parse(text[start..end]); - var entries = xmlDoc.Descendants("Resource") + var resourceEntries = xmlDoc.Descendants("Resource") .Select(x => new { Id = endian == "be" @@ -54,20 +78,28 @@ private async Task new Dictionary()); - if (!dict.TryGetValue(e.Id, out StringTableResourceEntry? existing)) + var dict = entriesByType.GetOrCreate(entry.Type, () => new Dictionary()); + if (!dict.TryGetValue(entry.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}"); + dict[entry.Id] = new StringTableResourceEntry { Name = entry.Name, Appearances = { fileName } }; + if (verbose) + { + Console.WriteLine($"Found {entry.Type} entry in {Path.GetFileName(filePath)} - {entry.Name}"); + } } else { if (overwrite) - existing.Name = e.Name; + { + existing.Name = entry.Name; + } + if (!existing.Appearances.Contains(fileName)) + { existing.Appearances.Add(fileName); + } } } diff --git a/Volatility/Operations/StringTables/LoadResourceDictionaryOperation.cs b/Volatility/Operations/StringTables/LoadResourceDictionaryOperation.cs index 2c2e4e0..f299fdf 100644 --- a/Volatility/Operations/StringTables/LoadResourceDictionaryOperation.cs +++ b/Volatility/Operations/StringTables/LoadResourceDictionaryOperation.cs @@ -1,9 +1,12 @@ +using Volatility.Abstractions.Operations; +using Volatility.Operations; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace Volatility.Operations.StringTables; -internal class LoadResourceDictionaryOperation +internal sealed class LoadResourceDictionaryOperation + : IOperation { private readonly IDeserializer deserializer; @@ -14,17 +17,63 @@ public LoadResourceDictionaryOperation() .Build(); } - public async Task>> ExecuteAsync(string yamlFile) + public async Task> ExecuteAsync( + LoadResourceDictionaryRequest request, + IProgress? progress, + CancellationToken cancellationToken) { - if (!File.Exists(yamlFile)) + if (string.IsNullOrWhiteSpace(request.YamlFile)) { - return new Dictionary>(StringComparer.OrdinalIgnoreCase); + return OperationResultFactory.Failure( + "load_resource_dictionary_missing_path", + "A ResourceDB YAML file path is required.", + nameof(LoadResourceDictionaryOperation)); } - string content = await File.ReadAllTextAsync(yamlFile); + try + { + if (!File.Exists(request.YamlFile)) + { + return OperationResultFactory.Success( + new LoadResourceDictionaryResult( + new Dictionary>(StringComparer.OrdinalIgnoreCase))); + } - Dictionary>? result = deserializer.Deserialize>>(content); + string content = await File.ReadAllTextAsync(request.YamlFile, cancellationToken); + Dictionary>? result = + deserializer.Deserialize>>(content); - return result ?? new Dictionary>(StringComparer.OrdinalIgnoreCase); + 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 async Task>> ExecuteAsync(string yamlFile) + { + OperationResult result = await ExecuteAsync( + new LoadResourceDictionaryRequest(yamlFile), + progress: null, + cancellationToken: CancellationToken.None); + + if (!result.Success || result.Value == null) + { + throw OperationResultFactory.CreateException(result, "Failed to load resource dictionary."); + } + + return result.Value.Entries; } } + +internal sealed record LoadResourceDictionaryRequest(string YamlFile) : IOperationRequest; + +internal sealed record LoadResourceDictionaryResult( + Dictionary> Entries); diff --git a/Volatility/Operations/StringTables/MergeStringTableEntriesOperation.cs b/Volatility/Operations/StringTables/MergeStringTableEntriesOperation.cs index bf9534f..aadff96 100644 --- a/Volatility/Operations/StringTables/MergeStringTableEntriesOperation.cs +++ b/Volatility/Operations/StringTables/MergeStringTableEntriesOperation.cs @@ -1,38 +1,84 @@ +using Volatility.Abstractions.Operations; +using Volatility.Operations; + namespace Volatility.Operations.StringTables; -internal class MergeStringTableEntriesOperation +internal sealed class MergeStringTableEntriesOperation + : IOperation { - public void Execute(Dictionary> target, Dictionary> source, bool overwrite) + public Task> ExecuteAsync( + MergeStringTableEntriesRequest request, + IProgress? progress, + CancellationToken cancellationToken) { - foreach ((string typeKey, Dictionary resourceEntries) in source) - { - if (!target.TryGetValue(typeKey, out Dictionary? typeDict)) - { - target[typeKey] = new Dictionary(resourceEntries, StringComparer.OrdinalIgnoreCase); - continue; - } + cancellationToken.ThrowIfCancellationRequested(); - foreach ((string resourceKey, StringTableResourceEntry entry) in resourceEntries) + try + { + foreach ((string typeKey, Dictionary resourceEntries) in request.Source) { - if (!typeDict.TryGetValue(resourceKey, out StringTableResourceEntry? existing)) + if (!request.Target.TryGetValue(typeKey, out Dictionary? typeDict)) { - typeDict[resourceKey] = entry; + request.Target[typeKey] = new Dictionary(resourceEntries, StringComparer.OrdinalIgnoreCase); continue; } - if (overwrite) + foreach ((string resourceKey, StringTableResourceEntry entry) in resourceEntries) { - existing.Name = entry.Name; - } + if (!typeDict.TryGetValue(resourceKey, out StringTableResourceEntry? existing)) + { + typeDict[resourceKey] = entry; + continue; + } - foreach (string appearance in entry.Appearances) - { - if (!existing.Appearances.Contains(appearance)) + if (request.Overwrite) + { + existing.Name = entry.Name; + } + + foreach (string appearance in entry.Appearances) { - existing.Appearances.Add(appearance); + 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 void Execute( + Dictionary> target, + Dictionary> source, + bool overwrite) + { + OperationResult result = ExecuteAsync( + new MergeStringTableEntriesRequest(target, source, overwrite), + progress: null, + cancellationToken: CancellationToken.None).GetAwaiter().GetResult(); + + if (!result.Success) + { + throw OperationResultFactory.CreateException(result, "Failed to merge string table entries."); } } } + +internal sealed record MergeStringTableEntriesRequest( + Dictionary> Target, + Dictionary> Source, + bool Overwrite) : IOperationRequest; + +internal sealed record MergeStringTableEntriesResult( + Dictionary> Entries); diff --git a/Volatility/Operations/StringTables/StringTableResourceEntry.cs b/Volatility/Operations/StringTables/StringTableResourceEntry.cs index efdc645..8f389b9 100644 --- a/Volatility/Operations/StringTables/StringTableResourceEntry.cs +++ b/Volatility/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/Renderable/RenderableBPR.cs b/Volatility/Resources/Renderable/RenderableBPR.cs index e02542c..96a384f 100644 --- a/Volatility/Resources/Renderable/RenderableBPR.cs +++ b/Volatility/Resources/Renderable/RenderableBPR.cs @@ -20,6 +20,8 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann base.ParseFromStream(reader, endianness); } + public RenderableBPR() : base() { } + public RenderableBPR(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } diff --git a/Volatility/Resources/Renderable/RenderablePC.cs b/Volatility/Resources/Renderable/RenderablePC.cs index 2614008..9ce814e 100644 --- a/Volatility/Resources/Renderable/RenderablePC.cs +++ b/Volatility/Resources/Renderable/RenderablePC.cs @@ -20,5 +20,7 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann base.ParseFromStream(reader, endianness); } + public RenderablePC() : base() { } + public RenderablePC(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } diff --git a/Volatility/Resources/Renderable/RenderablePS3.cs b/Volatility/Resources/Renderable/RenderablePS3.cs index a8b9bda..d73e492 100644 --- a/Volatility/Resources/Renderable/RenderablePS3.cs +++ b/Volatility/Resources/Renderable/RenderablePS3.cs @@ -6,5 +6,7 @@ public class RenderablePS3 : RenderableBase public override Endian ResourceEndian => Endian.BE; public override Platform ResourcePlatform => Platform.PS3; + public RenderablePS3() : base() { } + public RenderablePS3(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } diff --git a/Volatility/Resources/Renderable/RenderableX360.cs b/Volatility/Resources/Renderable/RenderableX360.cs index e83c6fd..29c61e9 100644 --- a/Volatility/Resources/Renderable/RenderableX360.cs +++ b/Volatility/Resources/Renderable/RenderableX360.cs @@ -6,5 +6,7 @@ public class RenderableX360 : RenderableBase public override Endian ResourceEndian => Endian.BE; public override Platform ResourcePlatform => Platform.X360; + public RenderableX360() : base() { } + public RenderableX360(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } diff --git a/Volatility/Resources/Resource.cs b/Volatility/Resources/Resource.cs index 68d2335..4b62afc 100644 --- a/Volatility/Resources/Resource.cs +++ b/Volatility/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); @@ -47,69 +50,70 @@ public Resource() { } public Resource(string path, Endian endianness = Endian.Agnostic) { - InitializeFromPath(path, endianness); + LoadFromPath(path, endianness); } - protected void InitializeFromPath(string path, Endian endianness = Endian.Agnostic) + internal void LoadFromPath( + string path, + Endian endianness = Endian.Agnostic, + IResourceDBLookup? resourceDBLookup = null) { if (string.IsNullOrEmpty(path)) return; ImportedFileName = path; - // Don't parse a directory if (new DirectoryInfo(path).Exists) return; string? name = Path.GetFileNameWithoutExtension(ImportedFileName); - - Endian importEndianness = (ResourceEndian != Endian.Agnostic) ? ResourceEndian : endianness; + Endian importEndianness = ResourceEndian != Endian.Agnostic ? ResourceEndian : endianness; 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 { - Unpacker.DGI => name.Replace("_", ""), + Unpacker.DGI => name.Replace("_", string.Empty, StringComparison.Ordinal), Unpacker.Bnd2Manager or Unpacker.YAP when idx > 0 => name[..idx], - Unpacker.Bnd2Manager or Unpacker.YAP => name, + 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); + ResourceID = Convert.ToUInt64( + importEndianness == Endian.LE && Unpacker != Unpacker.YAP + ? FlipResourceIDEndian(name) + : name, + 16); - string newName = GetNameByResourceID(ResourceID); - AssetName = !string.IsNullOrEmpty(newName) - ? newName + string resolvedName = resourceDBLookup?.GetNameByResourceId(ResourceID) ?? string.Empty; + AssetName = !string.IsNullOrEmpty(resolvedName) + ? resolvedName : 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(new FileStream(path, FileMode.Open), importEndianness); + ParseFromStream(reader, importEndianness); } private static Unpacker GetUnpackerFromFileName(string filename) @@ -121,7 +125,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 +133,6 @@ public virtual void PushAll() { } public virtual void PullAll() { } } - public enum ResourceType { Texture = 0x0, @@ -270,7 +272,7 @@ public enum Platform PS3 = 3, } -public enum Unpacker +public enum Unpacker { Raw = 0, Volatility = 1, diff --git a/Volatility/Resources/ResourceFactory.cs b/Volatility/Resources/ResourceFactory.cs index 47db2a9..4d503ae 100644 --- a/Volatility/Resources/ResourceFactory.cs +++ b/Volatility/Resources/ResourceFactory.cs @@ -1,15 +1,48 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; -using Volatility.Abstractions.Messaging; -using Volatility.Messaging; +using Volatility.Abstractions.Services; namespace Volatility.Resources; public static class ResourceFactory { - private static readonly Dictionary<(ResourceType, Platform), Func> resourceCreators = CreateResourceCreators(); + private static readonly Dictionary<(ResourceType, Platform), ResourceRegistrationInfo> resourceCreators = CreateResourceCreators(); - private static Dictionary<(ResourceType, Platform), Func> 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; + } + + public static Resource LoadResource( + ResourceType resourceType, + Platform platform, + string filePath, + IResourceDBLookup? resourceDBLookup, + bool x64 = false) + { + ResourceRegistrationInfo registration = ResolveRegistration(resourceType, platform); + Resource resource = registration.Activator(); + ApplyArchOption(resource, x64); + resource.LoadFromPath(filePath, ResolveLoadEndianness(resource, platform, registration.EndianMapped), resourceDBLookup); + + if (registration.PullAll) + { + resource.PullAll(); + } + + return resource; + } + + private static Dictionary<(ResourceType, Platform), ResourceRegistrationInfo> CreateResourceCreators() { ResourceCreatorRegistry registry = new(); @@ -39,33 +72,42 @@ public static class ResourceFactory return registry.Build(); } - public static Resource CreateResource(ResourceType resourceType, Platform platform, string filePath, bool x64 = false) + private static ResourceRegistrationInfo ResolveRegistration(ResourceType resourceType, Platform platform) { - return CreateResource(resourceType, platform, filePath, VolatilityMessageHost.Sink, x64); + if (resourceCreators.TryGetValue((resourceType, platform), out ResourceRegistrationInfo registration)) + { + return registration; + } + + throw new InvalidPlatformException($"The '{resourceType}' type is not supported for the '{platform}' platform."); } - public static Resource CreateResource( - ResourceType resourceType, - Platform platform, - string filePath, - IMessageSink sink, - bool x64 = false) + private static void ApplyArchOption(Resource resource, bool x64) { - sink.Info($"Constructing {platform} {resourceType} resource property data...", MessageCategory.Resource, nameof(ResourceFactory)); + if (x64) + { + resource.SetResourceArch(Arch.x64); + } + } - var key = (resourceType, platform); - if (resourceCreators.TryGetValue(key, out var creator)) + private static Endian ResolveLoadEndianness(Resource resource, Platform platform, bool endianMapped) + { + if (resource.ResourceEndian != Endian.Agnostic) { - Resource output = creator(filePath); - if (x64) - { - output.SetResourceArch(Arch.x64); - } + return resource.ResourceEndian; + } - return output; + if (!endianMapped) + { + return Endian.Agnostic; } - throw new InvalidPlatformException($"The '{resourceType}' type is not supported for the '{platform}' platform."); + return 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}'.") + }; } private static void AddRegisteredResource<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TResource>( @@ -77,27 +119,7 @@ public static Resource CreateResource( 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; - }); - } + private readonly Dictionary<(ResourceType, Platform), ResourceRegistrationInfo> creators = new(); public void AddRegistrations( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type resourceClass) @@ -111,79 +133,32 @@ public void AddRegistrations( { 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); + creators.Add( + (resourceType, platform), + new ResourceRegistrationInfo( + CreateActivator(resourceClass), + registration.EndianMapped, + registration.PullAll)); } } } - public Dictionary<(ResourceType, Platform), Func> Build() + public Dictionary<(ResourceType, Platform), ResourceRegistrationInfo> Build() { - return _creators; + return creators; } - private static Func CreatePathCreator( + private static Func CreateActivator( [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)]); + ConstructorInfo? constructor = resourceClass.GetConstructor(Type.EmptyTypes); if (constructor == null) { throw new InvalidOperationException( - $"Resource class '{resourceClass.FullName}' must expose a (string path, Endian endianness) constructor for endian-mapped registration."); + $"Resource class '{resourceClass.FullName}' must expose a parameterless constructor."); } - 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; - }; + return () => (Resource)constructor.Invoke([]); } private static IEnumerable ExpandPlatforms(RegistrationPlatforms platforms) @@ -214,4 +189,9 @@ private static IEnumerable ExpandPlatforms(RegistrationPlatforms platf } } } + + private sealed record ResourceRegistrationInfo( + Func Activator, + bool EndianMapped, + bool PullAll); } diff --git a/Volatility/Resources/ResourceImport.cs b/Volatility/Resources/ResourceImport.cs index 621af6d..f63e4a7 100644 --- a/Volatility/Resources/ResourceImport.cs +++ b/Volatility/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/Shader/ShaderBase.cs b/Volatility/Resources/Shader/ShaderBase.cs index 45c230c..da19b88 100644 --- a/Volatility/Resources/Shader/ShaderBase.cs +++ b/Volatility/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,6 +95,11 @@ public bool TryReadShaderSourceText(out string shaderSourceText) return true; } + internal static Regex ShaderPathSanitizer() + { + return new Regex(@"(\?ID=\d+)|:", RegexOptions.Compiled); + } + public ShaderBase() : base() { } public ShaderBase(string path) : base(path) { } diff --git a/Volatility/Resources/Shader/ShaderPC.cs b/Volatility/Resources/Shader/ShaderPC.cs index 02b37a2..2e01423 100644 --- a/Volatility/Resources/Shader/ShaderPC.cs +++ b/Volatility/Resources/Shader/ShaderPC.cs @@ -1,7 +1,4 @@ using System.Text; -using System.Text.RegularExpressions; - -using static Volatility.Utilities.EnvironmentUtilities; namespace Volatility.Resources; @@ -13,8 +10,6 @@ public class ShaderPC : ShaderBase public string Name; - private static readonly Regex DbToFileRegex = new(@"(\?ID=\d+)|:", RegexOptions.Compiled); - public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness) { base.WriteToStream(writer, endianness); @@ -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; } } diff --git a/Volatility/Resources/Splicer/Splicer.cs b/Volatility/Resources/Splicer/Splicer.cs index 1004b2a..146237b 100644 --- a/Volatility/Resources/Splicer/Splicer.cs +++ b/Volatility/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() diff --git a/Volatility/Services/DefaultProcessRunner.cs b/Volatility/Services/DefaultProcessRunner.cs index d5910ad..62ffaae 100644 --- a/Volatility/Services/DefaultProcessRunner.cs +++ b/Volatility/Services/DefaultProcessRunner.cs @@ -1,19 +1,34 @@ using System.Diagnostics; +using System.Text; +using Volatility.Abstractions.Messaging; using Volatility.Abstractions.Services; -using Volatility.Utilities; namespace Volatility.Services; -public sealed class DefaultProcessRunner : IProcessRunner +public sealed class DefaultProcessRunner(IMessageSink sink) : IProcessRunner { public string RunAndCapture(string fileName, string arguments, string? workingDirectory = null) { - return ProcessUtilities.RunAndCapture(fileName, arguments, workingDirectory); + return RunAndCapture(CreateStartInfo(fileName, arguments, workingDirectory)); } public string RunAndCapture(ProcessStartInfo startInfo) { - return ProcessUtilities.RunAndCapture(startInfo); + using Process process = new() { StartInfo = startInfo }; + StringBuilder output = new(); + + process.Start(); + output.Append(process.StandardOutput.ReadToEnd()); + output.Append(process.StandardError.ReadToEnd()); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Process '{GetProcessDisplayName(startInfo)}' failed with exit code {process.ExitCode}.{Environment.NewLine}{output}"); + } + + return output.ToString(); } public void RunAndRelayOutput( @@ -23,7 +38,7 @@ public void RunAndRelayOutput( Action? stdoutHandler = null, Action? stderrHandler = null) { - ProcessUtilities.RunAndRelayOutput(fileName, arguments, workingDirectory, stdoutHandler, stderrHandler); + RunAndRelayOutput(CreateStartInfo(fileName, arguments, workingDirectory), stdoutHandler, stderrHandler); } public void RunAndRelayOutput( @@ -31,6 +46,80 @@ public void RunAndRelayOutput( Action? stdoutHandler = null, Action? stderrHandler = null) { - ProcessUtilities.RunAndRelayOutput(startInfo, stdoutHandler, stderrHandler); + using Process process = new() { StartInfo = startInfo }; + StringBuilder output = new(); + + process.OutputDataReceived += (_, e) => + { + if (string.IsNullOrEmpty(e.Data)) + { + return; + } + + output.AppendLine(e.Data); + RelayOutput(e.Data, stdoutHandler); + }; + + process.ErrorDataReceived += (_, e) => + { + if (string.IsNullOrEmpty(e.Data)) + { + return; + } + + output.AppendLine(e.Data); + RelayOutput(e.Data, stderrHandler); + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Process '{GetProcessDisplayName(startInfo)}' failed with exit code {process.ExitCode}.{Environment.NewLine}{output}"); + } + } + + private static ProcessStartInfo CreateStartInfo(string fileName, string arguments, string? workingDirectory) + { + return new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Directory.GetCurrentDirectory() : workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + } + + private void RelayOutput(string data, Action? handler) + { + if (handler != null) + { + handler(data); + return; + } + + sink.Verbose(data, MessageCategory.Process, nameof(DefaultProcessRunner)); + } + + private static string GetProcessDisplayName(ProcessStartInfo startInfo) + { + if (!string.IsNullOrWhiteSpace(startInfo.Arguments)) + { + return $"{startInfo.FileName} {startInfo.Arguments}"; + } + + if (startInfo.ArgumentList.Count > 0) + { + return $"{startInfo.FileName} {string.Join(' ', startInfo.ArgumentList)}"; + } + + return startInfo.FileName; } } diff --git a/Volatility/Utilities/DxcShaderCompiler.cs b/Volatility/Services/DefaultShaderCompiler.cs similarity index 56% rename from Volatility/Utilities/DxcShaderCompiler.cs rename to Volatility/Services/DefaultShaderCompiler.cs index 8ed7fb2..92c436d 100644 --- a/Volatility/Utilities/DxcShaderCompiler.cs +++ b/Volatility/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/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 index 55ff961..752891a 100644 --- a/Volatility/Services/EnvironmentPathProvider.cs +++ b/Volatility/Services/EnvironmentPathProvider.cs @@ -1,26 +1,150 @@ +using System.Diagnostics; +using System.Reflection; using Volatility.Abstractions.Services; -using Volatility.Utilities; 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) { - return EnvironmentUtilities.GetEnvironmentDirectory(location switch + string executableDirectory = GetExecutableDirectory(); + if (!RelativePaths.TryGetValue(location, out string[]? segments)) { - 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!") - }); + throw new ArgumentOutOfRangeException(nameof(location), location, "Unknown path location!"); + } + + return segments.Length == 0 + ? executableDirectory + : Path.Combine([executableDirectory, .. segments]); } public string GetExecutableDirectory() { - return EnvironmentUtilities.GetExecutableDirectory(); + string? processPath = null; + PropertyInfo? processPathProperty = typeof(Environment).GetProperty("ProcessPath", BindingFlags.Static | BindingFlags.Public); + if (processPathProperty != null) + { + processPath = processPathProperty.GetValue(null) as string; + } + + if (string.IsNullOrEmpty(processPath) && + Assembly.GetEntryAssembly()?.Location is string entryAssemblyLocation && + !string.IsNullOrEmpty(entryAssemblyLocation)) + { + processPath = entryAssemblyLocation; + } + + 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/Volatility/Services/FileResourceDBLookup.cs b/Volatility/Services/FileResourceDBLookup.cs new file mode 100644 index 0000000..3913cb1 --- /dev/null +++ b/Volatility/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/Volatility/Services/FileShaderSourceStore.cs b/Volatility/Services/FileShaderSourceStore.cs new file mode 100644 index 0000000..4b32140 --- /dev/null +++ b/Volatility/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/Volatility/Services/FileSplicerSampleStore.cs b/Volatility/Services/FileSplicerSampleStore.cs new file mode 100644 index 0000000..0c1be70 --- /dev/null +++ b/Volatility/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/Volatility/Services/FileStringTableStore.cs similarity index 56% rename from Volatility/Utilities/StringTableStorageUtilities.cs rename to Volatility/Services/FileStringTableStore.cs index 1c38263..bfa65d7 100644 --- a/Volatility/Utilities/StringTableStorageUtilities.cs +++ b/Volatility/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/Volatility/Services/FileTextureBitmapStore.cs b/Volatility/Services/FileTextureBitmapStore.cs new file mode 100644 index 0000000..b6be8c8 --- /dev/null +++ b/Volatility/Services/FileTextureBitmapStore.cs @@ -0,0 +1,151 @@ +using Volatility.Abstractions.Services; +using Volatility.Resources; +using Volatility.Utilities; + +namespace Volatility.Services; + +public sealed class FileTextureBitmapStore( + IPathProvider pathProvider, + IProcessRunner processRunner) + : 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) + { + Console.WriteLine($"Running: {gtf2ddsExecutablePath} -o \"{destinationBitmapPath}.dds\" \"{destinationBitmapPath}.gtf\""); + Console.WriteLine("Converting PS3 GTF texture to DDS..."); + } + + 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) + { + Console.WriteLine("Trimmed converted DDS header."); + } + } + + 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/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/PS3TextureUtilities.cs b/Volatility/Utilities/PS3TextureUtilities.cs index b7db1fb..1754e26 100644 --- a/Volatility/Utilities/PS3TextureUtilities.cs +++ b/Volatility/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/ProcessUtilities.cs b/Volatility/Utilities/ProcessUtilities.cs deleted file mode 100644 index b7b1f16..0000000 --- a/Volatility/Utilities/ProcessUtilities.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System.Diagnostics; -using System.Text; -using Volatility.Abstractions.Messaging; -using Volatility.Messaging; - -namespace Volatility.Utilities; - -internal static class ProcessUtilities -{ - public static string RunAndCapture(string fileName, string arguments, string? workingDirectory = null) - { - ProcessStartInfo startInfo = CreateStartInfo(fileName, arguments, workingDirectory); - return RunAndCapture(startInfo); - } - - public static string RunAndCapture(ProcessStartInfo startInfo) - { - using Process process = new() { StartInfo = startInfo }; - StringBuilder output = new(); - - process.Start(); - output.Append(process.StandardOutput.ReadToEnd()); - output.Append(process.StandardError.ReadToEnd()); - process.WaitForExit(); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException( - $"Process '{GetProcessDisplayName(startInfo)}' failed with exit code {process.ExitCode}.{Environment.NewLine}{output}"); - } - - return output.ToString(); - } - - public static 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); - } - - public static void RunAndRelayOutput( - ProcessStartInfo startInfo, - Action? stdoutHandler = null, - Action? stderrHandler = null) - { - using Process process = new() { StartInfo = startInfo }; - StringBuilder output = new(); - - process.OutputDataReceived += (_, e) => - { - if (string.IsNullOrEmpty(e.Data)) - { - return; - } - - output.AppendLine(e.Data); - RelayOutput(e.Data, stdoutHandler); - }; - - process.ErrorDataReceived += (_, e) => - { - if (string.IsNullOrEmpty(e.Data)) - { - return; - } - - output.AppendLine(e.Data); - RelayOutput(e.Data, stderrHandler); - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - process.WaitForExit(); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException( - $"Process '{GetProcessDisplayName(startInfo)}' failed with exit code {process.ExitCode}.{Environment.NewLine}{output}"); - } - } - - private static ProcessStartInfo CreateStartInfo(string fileName, string arguments, string? workingDirectory) - { - return new ProcessStartInfo - { - FileName = fileName, - Arguments = arguments, - WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Directory.GetCurrentDirectory() : workingDirectory, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - } - - private static void RelayOutput(string data, Action? handler) - { - if (handler != null) - { - handler(data); - return; - } - - VolatilityMessageHost.Sink.Verbose(data, MessageCategory.Process, nameof(ProcessUtilities)); - } - - private static string GetProcessDisplayName(ProcessStartInfo startInfo) - { - if (!string.IsNullOrWhiteSpace(startInfo.Arguments)) - { - return $"{startInfo.FileName} {startInfo.Arguments}"; - } - - if (startInfo.ArgumentList.Count > 0) - { - return $"{startInfo.FileName} {string.Join(' ', startInfo.ArgumentList)}"; - } - - return startInfo.FileName; - } -} diff --git a/Volatility/Utilities/ResourceIDUtilities.cs b/Volatility/Utilities/ResourceIDUtilities.cs index f5615a3..5223dcc 100644 --- a/Volatility/Utilities/ResourceIDUtilities.cs +++ b/Volatility/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/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 index 7d751e5..c154eda 100644 --- a/Volatility/Volatility.csproj +++ b/Volatility/Volatility.csproj @@ -31,6 +31,7 @@ + From b950549948093a96f5a9a78138449a8cda3a331f Mon Sep 17 00:00:00 2001 From: "Nathan V." Date: Mon, 11 May 2026 20:45:35 -0400 Subject: [PATCH 2/5] more services refactoring --- .../Abstractions/Messaging/VolatilityLog.cs | 9 ++- .../Abstractions/Services/IResourceFactory.cs | 15 +++++ .../CLI/Commands/CreateResourceCommand.cs | 8 +-- .../CLI/Commands/ImportResourceCommand.cs | 6 +- .../VolatilityServiceCollectionExtensions.cs | 55 ++++++++----------- Volatility/Messaging/NullMessageSink.cs | 16 ------ .../Autotest/GameAutotestOperation.cs | 10 ++-- .../Resources/CreateResourceOperation.cs | 12 ++-- .../CreateShaderProgramBufferOperation.cs | 6 +- .../Resources/ExportResourceOperation.cs | 4 +- .../Resources/LoadResourceOperation.cs | 10 ++-- .../Resources/SaveResourceOperation.cs | 12 +++- .../LoadResourceDictionaryOperation.cs | 18 +----- .../MergeStringTableEntriesOperation.cs | 19 +------ Volatility/Services/DefaultResourceFactory.cs | 22 ++++++++ .../Services/EnvironmentPathProvider.cs | 12 ++-- Volatility/Services/FileTextureBitmapStore.cs | 19 +++++-- 17 files changed, 127 insertions(+), 126 deletions(-) create mode 100644 Volatility/Abstractions/Services/IResourceFactory.cs delete mode 100644 Volatility/Messaging/NullMessageSink.cs create mode 100644 Volatility/Services/DefaultResourceFactory.cs diff --git a/Volatility/Abstractions/Messaging/VolatilityLog.cs b/Volatility/Abstractions/Messaging/VolatilityLog.cs index 8411ab5..a8232d5 100644 --- a/Volatility/Abstractions/Messaging/VolatilityLog.cs +++ b/Volatility/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/Services/IResourceFactory.cs b/Volatility/Abstractions/Services/IResourceFactory.cs new file mode 100644 index 0000000..182deb7 --- /dev/null +++ b/Volatility/Abstractions/Services/IResourceFactory.cs @@ -0,0 +1,15 @@ +using Volatility.Resources; + +namespace Volatility.Abstractions.Services; + +public interface IResourceFactory +{ + Resource CreateResource(ResourceType resourceType, Platform platform, bool x64 = false); + + Resource LoadResource( + ResourceType resourceType, + Platform platform, + string filePath, + IResourceDBLookup? resourceDBLookup, + bool x64 = false); +} diff --git a/Volatility/CLI/Commands/CreateResourceCommand.cs b/Volatility/CLI/Commands/CreateResourceCommand.cs index d503c3f..2459385 100644 --- a/Volatility/CLI/Commands/CreateResourceCommand.cs +++ b/Volatility/CLI/Commands/CreateResourceCommand.cs @@ -89,14 +89,8 @@ public async Task Execute() } CreateResourceResult result = createResult.Value; - if (pathProvider.FileExists(result.ResourcePath) && !Overwrite) - { - CLIMessageUtilities.Error($"Error: Output file already exists ({result.ResourcePath}). Use --overwrite to replace it."); - return; - } - OperationResult saveResult = await saveOperation.ExecuteAsync( - new SaveResourceRequest(result.Resource, result.ResourcePath), + new SaveResourceRequest(result.Resource, result.ResourcePath, Overwrite), progress: null, cancellationToken: CancellationToken.None); CLIMessageUtilities.PublishIssues(saveResult.Issues, MessageCategory.Resource); diff --git a/Volatility/CLI/Commands/ImportResourceCommand.cs b/Volatility/CLI/Commands/ImportResourceCommand.cs index d59debe..d8aca50 100644 --- a/Volatility/CLI/Commands/ImportResourceCommand.cs +++ b/Volatility/CLI/Commands/ImportResourceCommand.cs @@ -12,7 +12,7 @@ internal class ImportResourceCommand : ICommand { private readonly IPathProvider pathProvider; private readonly ImportResourceOperation importOperation; - private readonly SaveResourceOperation saveOperation; + 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."; @@ -89,7 +89,7 @@ public async Task Execute() Overwrite)); OperationResult saveResult = await saveOperation.ExecuteAsync( - new SaveResourceRequest(result.Resource, result.ResourcePath), + new SaveResourceRequest(result.Resource, result.ResourcePath, Overwrite), progress: null, cancellationToken: CancellationToken.None); @@ -119,7 +119,7 @@ public void SetArgs(Dictionary args) public ImportResourceCommand( IPathProvider pathProvider, ImportResourceOperation importOperation, - SaveResourceOperation saveOperation) + IOperation saveOperation) { this.pathProvider = pathProvider; this.importOperation = importOperation; diff --git a/Volatility/Hosting/VolatilityServiceCollectionExtensions.cs b/Volatility/Hosting/VolatilityServiceCollectionExtensions.cs index 77cb697..b2e002a 100644 --- a/Volatility/Hosting/VolatilityServiceCollectionExtensions.cs +++ b/Volatility/Hosting/VolatilityServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Volatility.Abstractions.Messaging; using Volatility.Abstractions.Operations; using Volatility.Abstractions.Services; @@ -14,47 +15,35 @@ public static class VolatilityServiceCollectionExtensions { public static IServiceCollection AddVolatilityCore(this IServiceCollection services) { - services.AddSingleton(); - services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); - services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.TryAddSingleton(); + services.TryAddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.TryAddSingleton(serviceProvider => serviceProvider.GetRequiredService()); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); - services.AddTransient(serviceProvider => - new(serviceProvider.GetRequiredService().GetDirectory(VolatilityPathLocation.Resources))); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + services.AddTransient, CreateResourceOperation>(); + services.AddTransient, LoadResourceOperation>(); + services.AddTransient, SaveResourceOperation>(); + services.AddTransient, CreateShaderProgramBufferOperation>(); + services.AddTransient, LoadResourceDictionaryOperation>(); + services.AddTransient, MergeStringTableEntriesOperation>(); + + // Temporary registrations for operations that are not on the OperationResult contract yet services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(serviceProvider => - new(serviceProvider.GetRequiredService>())); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient>(serviceProvider => - serviceProvider.GetRequiredService()); - services.AddTransient>(serviceProvider => - serviceProvider.GetRequiredService()); - services.AddTransient>(serviceProvider => - serviceProvider.GetRequiredService()); - services.AddTransient>(serviceProvider => - serviceProvider.GetRequiredService()); - services.AddTransient>(serviceProvider => - serviceProvider.GetRequiredService()); - services.AddTransient>(serviceProvider => - serviceProvider.GetRequiredService()); - return services; } } 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/Autotest/GameAutotestOperation.cs b/Volatility/Operations/Autotest/GameAutotestOperation.cs index 4b6d078..062bc43 100644 --- a/Volatility/Operations/Autotest/GameAutotestOperation.cs +++ b/Volatility/Operations/Autotest/GameAutotestOperation.cs @@ -41,8 +41,8 @@ internal sealed class GameAutotestOperation private readonly IPathProvider pathProvider; private readonly IProcessRunner processRunner; private readonly ImportResourceOperation importOperation; - private readonly SaveResourceOperation saveOperation; - private readonly LoadResourceOperation loadOperation; + private readonly IOperation saveOperation; + private readonly IOperation loadOperation; private readonly ExportResourceOperation exportOperation; private readonly TextureToDDSOperation textureToDdsOperation; private readonly PortTextureOperation portTextureOperation; @@ -102,8 +102,8 @@ public GameAutotestOperation( IPathProvider pathProvider, IProcessRunner processRunner, ImportResourceOperation importOperation, - SaveResourceOperation saveOperation, - LoadResourceOperation loadOperation, + IOperation saveOperation, + IOperation loadOperation, ExportResourceOperation exportOperation, TextureToDDSOperation textureToDdsOperation, PortTextureOperation portTextureOperation) @@ -421,7 +421,7 @@ private async Task LoadAsync(string sourceFile, ResourceType resourceT private async Task SaveAsync(Resource resource, string filePath) { OperationResult result = await saveOperation.ExecuteAsync( - new SaveResourceRequest(resource, filePath), + new SaveResourceRequest(resource, filePath, Overwrite: true), progress: null, cancellationToken: CancellationToken.None); diff --git a/Volatility/Operations/Resources/CreateResourceOperation.cs b/Volatility/Operations/Resources/CreateResourceOperation.cs index 363704f..7504659 100644 --- a/Volatility/Operations/Resources/CreateResourceOperation.cs +++ b/Volatility/Operations/Resources/CreateResourceOperation.cs @@ -1,10 +1,13 @@ using Volatility.Abstractions.Operations; +using Volatility.Abstractions.Services; using Volatility.Operations; using Volatility.Resources; namespace Volatility.Operations.Resources; -internal sealed class CreateResourceOperation(string resourcesDirectory) +internal sealed class CreateResourceOperation( + IPathProvider pathProvider, + IResourceFactory resourceFactory) : IOperation { public Task> ExecuteAsync( @@ -16,7 +19,7 @@ public Task> ExecuteAsync( try { - Resource resource = ResourceFactory.CreateResource(request.ResourceType, request.Platform, request.IsX64); + 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); @@ -54,6 +57,7 @@ public Task> ExecuteAsync( private string ResolveOutputPath(string? outputPath, ResourceType resourceType, string? assetName) { + string resourcesDirectory = pathProvider.GetDirectory(VolatilityPathLocation.Resources); string resolvedPath; if (string.IsNullOrWhiteSpace(outputPath)) @@ -100,7 +104,7 @@ private static string NormalizeGameDBPath(string path) } } -internal sealed record CreateResourceRequest( +public sealed record CreateResourceRequest( ResourceType ResourceType, Platform Platform, string? AssetName, @@ -108,4 +112,4 @@ internal sealed record CreateResourceRequest( ResourceID? ResourceId, bool IsX64) : IOperationRequest; -internal sealed record CreateResourceResult(Resource Resource, string ResourcePath); +public sealed record CreateResourceResult(Resource Resource, string ResourcePath); diff --git a/Volatility/Operations/Resources/CreateShaderProgramBufferOperation.cs b/Volatility/Operations/Resources/CreateShaderProgramBufferOperation.cs index c72f3dc..a1be9cd 100644 --- a/Volatility/Operations/Resources/CreateShaderProgramBufferOperation.cs +++ b/Volatility/Operations/Resources/CreateShaderProgramBufferOperation.cs @@ -56,7 +56,7 @@ public async Task> ExecuteAsync } } - public void WriteToFile(ShaderProgramBufferBase buffer, string outputPath, Platform platform) + public static void WriteToFile(ShaderProgramBufferBase buffer, string outputPath, Platform platform) { ArgumentNullException.ThrowIfNull(buffer); @@ -105,10 +105,10 @@ private static void ValidatePlatform(Platform platform) } } -internal sealed record CreateShaderProgramBufferRequest( +public sealed record CreateShaderProgramBufferRequest( ShaderStageType Stage, Platform Platform, string? CsoPath = null, byte[]? CsoBytes = null) : IOperationRequest; -internal sealed record CreateShaderProgramBufferResult(ShaderProgramBufferBase Buffer); +public sealed record CreateShaderProgramBufferResult(ShaderProgramBufferBase Buffer); diff --git a/Volatility/Operations/Resources/ExportResourceOperation.cs b/Volatility/Operations/Resources/ExportResourceOperation.cs index ee033d3..0971442 100644 --- a/Volatility/Operations/Resources/ExportResourceOperation.cs +++ b/Volatility/Operations/Resources/ExportResourceOperation.cs @@ -8,7 +8,7 @@ namespace Volatility.Operations.Resources; internal sealed class ExportResourceOperation( IPathProvider pathProvider, IShaderCompiler shaderCompiler, - CreateShaderProgramBufferOperation shaderProgramBufferOperation, + IOperation shaderProgramBufferOperation, ISplicerSampleStore splicerSampleStore) { public Task ExecuteAsync( @@ -83,7 +83,7 @@ public Task ExecuteAsync( } ShaderProgramBufferBase buffer = bufferResult.Value.Buffer; - shaderProgramBufferOperation.WriteToFile(buffer, shaderProgramBufferPath, platform); + CreateShaderProgramBufferOperation.WriteToFile(buffer, shaderProgramBufferPath, platform); WritePaddedCSOFile(csoPath, GetSecondaryResourcePath(shaderProgramBufferPath)); } } diff --git a/Volatility/Operations/Resources/LoadResourceOperation.cs b/Volatility/Operations/Resources/LoadResourceOperation.cs index 1a90feb..ba032b4 100644 --- a/Volatility/Operations/Resources/LoadResourceOperation.cs +++ b/Volatility/Operations/Resources/LoadResourceOperation.cs @@ -1,12 +1,14 @@ 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 : IOperation +internal sealed class LoadResourceOperation(IResourceFactory resourceFactory) + : IOperation { public async Task> ExecuteAsync( LoadResourceRequest request, @@ -25,7 +27,7 @@ public async Task> ExecuteAsync( { string yaml = await File.ReadAllTextAsync(request.SourceFile, cancellationToken); - Resource resource = ResourceFactory.CreateResource(request.ResourceType, request.Platform); + Resource resource = resourceFactory.CreateResource(request.ResourceType, request.Platform); Resource? result = (Resource?)ResourceYamlDeserializer.DeserializeResource(resource.GetType(), yaml); if (result is null) @@ -58,6 +60,6 @@ public async Task> ExecuteAsync( } -internal sealed record LoadResourceRequest(string SourceFile, ResourceType ResourceType, Platform Platform) : IOperationRequest; +public sealed record LoadResourceRequest(string SourceFile, ResourceType ResourceType, Platform Platform) : IOperationRequest; -internal sealed record LoadResourceResult(Resource Resource); +public sealed record LoadResourceResult(Resource Resource); diff --git a/Volatility/Operations/Resources/SaveResourceOperation.cs b/Volatility/Operations/Resources/SaveResourceOperation.cs index cc97df7..098e389 100644 --- a/Volatility/Operations/Resources/SaveResourceOperation.cs +++ b/Volatility/Operations/Resources/SaveResourceOperation.cs @@ -45,6 +45,14 @@ public async Task> ExecuteAsync( 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)) { @@ -68,6 +76,6 @@ public async Task> ExecuteAsync( } -internal sealed record SaveResourceRequest(Resource Resource, string FilePath) : IOperationRequest; +public sealed record SaveResourceRequest(Resource Resource, string FilePath, bool Overwrite) : IOperationRequest; -internal sealed record SaveResourceResult(string FilePath); +public sealed record SaveResourceResult(string FilePath); diff --git a/Volatility/Operations/StringTables/LoadResourceDictionaryOperation.cs b/Volatility/Operations/StringTables/LoadResourceDictionaryOperation.cs index f299fdf..47e8162 100644 --- a/Volatility/Operations/StringTables/LoadResourceDictionaryOperation.cs +++ b/Volatility/Operations/StringTables/LoadResourceDictionaryOperation.cs @@ -57,23 +57,9 @@ public async Task> ExecuteAsync( } } - public async Task>> ExecuteAsync(string yamlFile) - { - OperationResult result = await ExecuteAsync( - new LoadResourceDictionaryRequest(yamlFile), - progress: null, - cancellationToken: CancellationToken.None); - - if (!result.Success || result.Value == null) - { - throw OperationResultFactory.CreateException(result, "Failed to load resource dictionary."); - } - - return result.Value.Entries; - } } -internal sealed record LoadResourceDictionaryRequest(string YamlFile) : IOperationRequest; +public sealed record LoadResourceDictionaryRequest(string YamlFile) : IOperationRequest; -internal sealed record LoadResourceDictionaryResult( +public sealed record LoadResourceDictionaryResult( Dictionary> Entries); diff --git a/Volatility/Operations/StringTables/MergeStringTableEntriesOperation.cs b/Volatility/Operations/StringTables/MergeStringTableEntriesOperation.cs index aadff96..18bf24b 100644 --- a/Volatility/Operations/StringTables/MergeStringTableEntriesOperation.cs +++ b/Volatility/Operations/StringTables/MergeStringTableEntriesOperation.cs @@ -58,27 +58,12 @@ public Task> ExecuteAsync( } } - public void Execute( - Dictionary> target, - Dictionary> source, - bool overwrite) - { - OperationResult result = ExecuteAsync( - new MergeStringTableEntriesRequest(target, source, overwrite), - progress: null, - cancellationToken: CancellationToken.None).GetAwaiter().GetResult(); - - if (!result.Success) - { - throw OperationResultFactory.CreateException(result, "Failed to merge string table entries."); - } - } } -internal sealed record MergeStringTableEntriesRequest( +public sealed record MergeStringTableEntriesRequest( Dictionary> Target, Dictionary> Source, bool Overwrite) : IOperationRequest; -internal sealed record MergeStringTableEntriesResult( +public sealed record MergeStringTableEntriesResult( Dictionary> Entries); diff --git a/Volatility/Services/DefaultResourceFactory.cs b/Volatility/Services/DefaultResourceFactory.cs new file mode 100644 index 0000000..31f8fb5 --- /dev/null +++ b/Volatility/Services/DefaultResourceFactory.cs @@ -0,0 +1,22 @@ +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); + } + + public Resource LoadResource( + ResourceType resourceType, + Platform platform, + string filePath, + IResourceDBLookup? resourceDBLookup, + bool x64 = false) + { + return ResourceFactory.LoadResource(resourceType, platform, filePath, resourceDBLookup, x64); + } +} diff --git a/Volatility/Services/EnvironmentPathProvider.cs b/Volatility/Services/EnvironmentPathProvider.cs index 752891a..ece0312 100644 --- a/Volatility/Services/EnvironmentPathProvider.cs +++ b/Volatility/Services/EnvironmentPathProvider.cs @@ -33,17 +33,15 @@ public string GetDirectory(VolatilityPathLocation location) public string GetExecutableDirectory() { string? processPath = null; - PropertyInfo? processPathProperty = typeof(Environment).GetProperty("ProcessPath", BindingFlags.Static | BindingFlags.Public); - if (processPathProperty != null) + if (Assembly.GetEntryAssembly()?.Location is string entryAssemblyLocation && + !string.IsNullOrEmpty(entryAssemblyLocation)) { - processPath = processPathProperty.GetValue(null) as string; + processPath = entryAssemblyLocation; } - if (string.IsNullOrEmpty(processPath) && - Assembly.GetEntryAssembly()?.Location is string entryAssemblyLocation && - !string.IsNullOrEmpty(entryAssemblyLocation)) + if (string.IsNullOrEmpty(processPath)) { - processPath = entryAssemblyLocation; + processPath = Environment.ProcessPath; } if (string.IsNullOrEmpty(processPath)) diff --git a/Volatility/Services/FileTextureBitmapStore.cs b/Volatility/Services/FileTextureBitmapStore.cs index b6be8c8..71996f8 100644 --- a/Volatility/Services/FileTextureBitmapStore.cs +++ b/Volatility/Services/FileTextureBitmapStore.cs @@ -1,3 +1,4 @@ +using Volatility.Abstractions.Messaging; using Volatility.Abstractions.Services; using Volatility.Resources; using Volatility.Utilities; @@ -6,7 +7,8 @@ namespace Volatility.Services; public sealed class FileTextureBitmapStore( IPathProvider pathProvider, - IProcessRunner processRunner) + IProcessRunner processRunner, + IMessageSink messageSink) : ITextureBitmapStore { public string GetResourceBaseName(string headerPath, Unpacker unpacker) @@ -80,8 +82,14 @@ public void ConvertPS3GTFToDDS(TexturePS3 texture, string sourceBitmapPath, stri if (verbose) { - Console.WriteLine($"Running: {gtf2ddsExecutablePath} -o \"{destinationBitmapPath}.dds\" \"{destinationBitmapPath}.gtf\""); - Console.WriteLine("Converting PS3 GTF texture to DDS..."); + 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( @@ -100,7 +108,10 @@ public void ConvertPS3GTFToDDS(TexturePS3 texture, string sourceBitmapPath, stri if (verbose) { - Console.WriteLine("Trimmed converted DDS header."); + messageSink.Verbose( + "Trimmed converted DDS header.", + MessageCategory.Texture, + nameof(FileTextureBitmapStore)); } } From 57154d02684c873b19c7df5f7ed51c72a232dbe8 Mon Sep 17 00:00:00 2001 From: "Nathan V." Date: Mon, 11 May 2026 21:34:52 -0400 Subject: [PATCH 3/5] use IOperation on operations, route messages through message sink, cleanup --- Volatility/CLI/Commands/AutotestCommand.cs | 35 ++- .../CLI/Commands/ExportResourceCommand.cs | 22 +- .../CLI/Commands/ImportResourceCommand.cs | 19 +- .../CLI/Commands/ImportStringTableCommand.cs | 27 +- Volatility/CLI/Commands/PortTextureCommand.cs | 26 +- .../CLI/Commands/TextureToDDSCommand.cs | 13 +- .../VolatilityServiceCollectionExtensions.cs | 14 +- .../Autotest/GameAutotestOperation.cs | 261 ++++++++++++------ .../Resources/ExportResourceOperation.cs | 172 +++++++----- .../Resources/ImportResourceOperation.cs | 202 ++++++++------ .../Resources/PortTextureOperation.cs | 157 ++++++++--- .../Resources/TextureToDDSOperation.cs | 138 ++++++--- .../ImportStringTableOperation.cs | 93 +++++-- Volatility/Resources/GuiPopup/GuiPopup.cs | 4 - Volatility/Resources/Model/Model.cs | 5 - .../Resources/SnapshotData/SnapshotData.cs | 2 +- 16 files changed, 807 insertions(+), 383 deletions(-) diff --git a/Volatility/CLI/Commands/AutotestCommand.cs b/Volatility/CLI/Commands/AutotestCommand.cs index 0d7dc93..52661d0 100644 --- a/Volatility/CLI/Commands/AutotestCommand.cs +++ b/Volatility/CLI/Commands/AutotestCommand.cs @@ -2,8 +2,10 @@ using System.Text; using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; using Volatility.Abstractions.Services; using Volatility.CLI; +using Volatility.Operations; using Volatility.Operations.Autotest; using Volatility.Resources; @@ -15,7 +17,7 @@ namespace Volatility.CLI.Commands; internal class AutotestCommand : ICommand { private readonly IPathProvider pathProvider; - private readonly GameAutotestOperation gameAutotestOperation; + private readonly IOperation gameAutotestOperation; public static string CommandToken => "autotest"; public static string CommandDescription => "Runs automatic tests to ensure the application is working." + @@ -39,15 +41,26 @@ public async Task Execute() IReadOnlyList gamePaths = ParseGamePaths(); if (gamePaths.Count > 0) { - GameAutotestSummary summary = await gameAutotestOperation.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."); + } + + GameAutotestSummary summary = result.Value; CLIMessageUtilities.Info( $"AUTOTEST - Completed. Passed={summary.Passed}, Failed={summary.Failed}, Skipped={summary.Skipped}", @@ -449,7 +462,9 @@ private static string EscapeMarkdownCell(string value) .Trim(); } - public AutotestCommand(IPathProvider pathProvider, GameAutotestOperation gameAutotestOperation) + public AutotestCommand( + IPathProvider pathProvider, + IOperation gameAutotestOperation) { this.pathProvider = pathProvider; this.gameAutotestOperation = gameAutotestOperation; diff --git a/Volatility/CLI/Commands/ExportResourceCommand.cs b/Volatility/CLI/Commands/ExportResourceCommand.cs index 6e55b0c..1757dbc 100644 --- a/Volatility/CLI/Commands/ExportResourceCommand.cs +++ b/Volatility/CLI/Commands/ExportResourceCommand.cs @@ -11,7 +11,7 @@ internal class ExportResourceCommand : ICommand { private readonly IPathProvider pathProvider; private readonly IOperation loadOperation; - private readonly ExportResourceOperation exportOperation; + 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."; @@ -102,21 +102,19 @@ public async Task Execute() return; } - try - { - await exportOperation.ExecuteAsync( + OperationResult exportResult = await exportOperation.ExecuteAsync( + new ExportResourceRequest( loadResult.Value.Resource, OutputPath, platform, importUnpackerOverride, - ImportsFile); - } - catch (Exception ex) + ImportsFile), + progress: null, + cancellationToken: CancellationToken.None); + CLIMessageUtilities.PublishIssues(exportResult.Issues, MessageCategory.Resource); + + if (!exportResult.Success) { - CLIMessageUtilities.Error( - $"ERROR: Unable to export {Path.GetFileName(sourceFile)} as {resourceType}!" + - $"{Environment.NewLine}Message from {ex.TargetSite}: {ex.Message}." + - $"{Environment.NewLine}Stack Trace:{Environment.NewLine}{ex.StackTrace}"); return; } @@ -143,7 +141,7 @@ public void SetArgs(Dictionary args) public ExportResourceCommand( IPathProvider pathProvider, IOperation loadOperation, - ExportResourceOperation exportOperation) + IOperation exportOperation) { this.pathProvider = pathProvider; this.loadOperation = loadOperation; diff --git a/Volatility/CLI/Commands/ImportResourceCommand.cs b/Volatility/CLI/Commands/ImportResourceCommand.cs index d8aca50..7688fc0 100644 --- a/Volatility/CLI/Commands/ImportResourceCommand.cs +++ b/Volatility/CLI/Commands/ImportResourceCommand.cs @@ -11,7 +11,7 @@ namespace Volatility.CLI.Commands; internal class ImportResourceCommand : ICommand { private readonly IPathProvider pathProvider; - private readonly ImportResourceOperation importOperation; + private readonly IOperation importOperation; private readonly IOperation saveOperation; public static string CommandToken => "ImportResource"; @@ -78,7 +78,8 @@ public async Task Execute() { tasks.Add(Task.Run(async () => { - ImportResourceResult result = await importOperation.ExecuteAsync(new ImportResourceRequest( + OperationResult importResult = await importOperation.ExecuteAsync( + new ImportResourceRequest( resType, platform, sourceFile, @@ -86,7 +87,17 @@ public async Task Execute() resourcesDirectory, toolsDirectory, splicerDirectory, - Overwrite)); + 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), @@ -118,7 +129,7 @@ public void SetArgs(Dictionary args) public ImportResourceCommand( IPathProvider pathProvider, - ImportResourceOperation importOperation, + IOperation importOperation, IOperation saveOperation) { this.pathProvider = pathProvider; diff --git a/Volatility/CLI/Commands/ImportStringTableCommand.cs b/Volatility/CLI/Commands/ImportStringTableCommand.cs index ccb23b6..21aecfa 100644 --- a/Volatility/CLI/Commands/ImportStringTableCommand.cs +++ b/Volatility/CLI/Commands/ImportStringTableCommand.cs @@ -11,7 +11,7 @@ internal class ImportStringTableCommand : ICommand private readonly IPathProvider pathProvider; private readonly IStringTableStore stringTableStore; private readonly IOperation loadOperation; - private readonly ImportStringTableOperation importOperation; + private readonly IOperation importOperation; public static string CommandToken => "ImportStringTable"; public static string CommandDescription => "Imports entries into the ResourceDB from files containing a ResourceStringTable."; @@ -54,7 +54,16 @@ public async Task Execute() { string jsonFile = Path.Combine(directoryPath, "ResourceDB.json"); var importedEntries = new Dictionary>(StringComparer.OrdinalIgnoreCase); - await importOperation.ExecuteAsync(filePaths, importedEntries, Endian ?? "le", Overwrite, Verbose); + 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); @@ -84,7 +93,17 @@ public async Task Execute() return; } - await importOperation.ExecuteAsync(filePaths, loadResult.Value.Entries, Endian ?? "le", Overwrite, Verbose); + 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( @@ -111,7 +130,7 @@ public ImportStringTableCommand( IPathProvider pathProvider, IStringTableStore stringTableStore, IOperation loadOperation, - ImportStringTableOperation importOperation) + IOperation importOperation) { this.pathProvider = pathProvider; this.stringTableStore = stringTableStore; diff --git a/Volatility/CLI/Commands/PortTextureCommand.cs b/Volatility/CLI/Commands/PortTextureCommand.cs index 4d70775..2c7ed13 100644 --- a/Volatility/CLI/Commands/PortTextureCommand.cs +++ b/Volatility/CLI/Commands/PortTextureCommand.cs @@ -1,5 +1,7 @@ using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; using Volatility.Abstractions.Services; +using Volatility.Operations; using Volatility.Operations.Resources; namespace Volatility.CLI.Commands; @@ -7,7 +9,7 @@ namespace Volatility.CLI.Commands; internal class PortTextureCommand : ICommand { private readonly IPathProvider pathProvider; - private readonly PortTextureOperation operation; + 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."; @@ -39,7 +41,23 @@ public async Task Execute() $"Starting {sourceFiles.Length} PortTexture tasks...", MessageCategory.Texture); - await operation.ExecuteAsync(sourceFiles, SourceFormat ?? string.Empty, SourcePath ?? string.Empty, DestinationFormat ?? string.Empty, DestinationPath, Verbose, UseGTF); + 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) @@ -60,7 +78,9 @@ public void SetArgs(Dictionary args) UseGTF = args.TryGetValue("usegtf", out var usegtf) && (bool)usegtf; } - public PortTextureCommand(IPathProvider pathProvider, PortTextureOperation operation) + public PortTextureCommand( + IPathProvider pathProvider, + IOperation operation) { this.pathProvider = pathProvider; this.operation = operation; diff --git a/Volatility/CLI/Commands/TextureToDDSCommand.cs b/Volatility/CLI/Commands/TextureToDDSCommand.cs index 3a53d2e..3c07d02 100644 --- a/Volatility/CLI/Commands/TextureToDDSCommand.cs +++ b/Volatility/CLI/Commands/TextureToDDSCommand.cs @@ -1,4 +1,5 @@ using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; using Volatility.Abstractions.Services; using Volatility.Operations.Resources; using Volatility.Resources; @@ -9,7 +10,7 @@ namespace Volatility.CLI.Commands; internal class TextureToDDSCommand : ICommand { private readonly IPathProvider pathProvider; - private readonly TextureToDDSOperation operation; + private readonly IOperation operation; public static string CommandToken => "TextureToDDS"; public static string CommandDescription => "Converts texture resources and their sidecar bitmap data into DDS files."; @@ -59,7 +60,11 @@ public async Task Execute() $"Starting {sourceFiles.Length} Texture to DDS tasks...", MessageCategory.Texture); - 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); } public void SetArgs(Dictionary args) @@ -72,7 +77,9 @@ public void SetArgs(Dictionary args) Verbose = args.TryGetValue("verbose", out var ve) && (bool)ve; } - public TextureToDDSCommand(IPathProvider pathProvider, TextureToDDSOperation operation) + public TextureToDDSCommand( + IPathProvider pathProvider, + IOperation operation) { this.pathProvider = pathProvider; this.operation = operation; diff --git a/Volatility/Hosting/VolatilityServiceCollectionExtensions.cs b/Volatility/Hosting/VolatilityServiceCollectionExtensions.cs index b2e002a..f44dda4 100644 --- a/Volatility/Hosting/VolatilityServiceCollectionExtensions.cs +++ b/Volatility/Hosting/VolatilityServiceCollectionExtensions.cs @@ -35,14 +35,12 @@ public static IServiceCollection AddVolatilityCore(this IServiceCollection servi services.AddTransient, CreateShaderProgramBufferOperation>(); services.AddTransient, LoadResourceDictionaryOperation>(); services.AddTransient, MergeStringTableEntriesOperation>(); - - // Temporary registrations for operations that are not on the OperationResult contract yet - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + services.AddTransient, ImportResourceOperation>(); + services.AddTransient, ImportStringTableOperation>(); + services.AddTransient, ExportResourceOperation>(); + services.AddTransient, TextureToDDSOperation>(); + services.AddTransient, PortTextureOperation>(); + services.AddTransient, GameAutotestOperation>(); return services; } diff --git a/Volatility/Operations/Autotest/GameAutotestOperation.cs b/Volatility/Operations/Autotest/GameAutotestOperation.cs index 062bc43..ad10cae 100644 --- a/Volatility/Operations/Autotest/GameAutotestOperation.cs +++ b/Volatility/Operations/Autotest/GameAutotestOperation.cs @@ -1,5 +1,6 @@ using System.Globalization; +using Volatility.Abstractions.Messaging; using Volatility.Abstractions.Operations; using Volatility.Abstractions.Services; using Volatility.Operations; @@ -10,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; } @@ -20,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; } @@ -28,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, @@ -37,15 +38,17 @@ internal sealed record GameAutotestCaseResult( ResourceType? TestedResourceType = null); internal sealed class GameAutotestOperation + : IOperation { private readonly IPathProvider pathProvider; private readonly IProcessRunner processRunner; - private readonly ImportResourceOperation importOperation; + private readonly IMessageSink messageSink; + private readonly IOperation importOperation; private readonly IOperation saveOperation; private readonly IOperation loadOperation; - private readonly ExportResourceOperation exportOperation; - private readonly TextureToDDSOperation textureToDdsOperation; - private readonly PortTextureOperation portTextureOperation; + private readonly IOperation exportOperation; + private readonly IOperation textureToDdsOperation; + private readonly IOperation portTextureOperation; private static readonly HashSet RoundTripTypes = [ @@ -101,15 +104,17 @@ internal sealed class GameAutotestOperation public GameAutotestOperation( IPathProvider pathProvider, IProcessRunner processRunner, - ImportResourceOperation importOperation, + IMessageSink messageSink, + IOperation importOperation, IOperation saveOperation, IOperation loadOperation, - ExportResourceOperation exportOperation, - TextureToDDSOperation textureToDdsOperation, - PortTextureOperation portTextureOperation) + IOperation exportOperation, + IOperation textureToDdsOperation, + IOperation portTextureOperation) { this.pathProvider = pathProvider; this.processRunner = processRunner; + this.messageSink = messageSink; this.importOperation = importOperation; this.saveOperation = saveOperation; this.loadOperation = loadOperation; @@ -118,28 +123,50 @@ public GameAutotestOperation( this.portTextureOperation = portTextureOperation; } - public async Task ExecuteAsync(GameAutotestOptions options) + 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(options.BundleToolPath); - string bundleToolPath = GetBundleTool(repoRoot, options.BundleToolPath); - string sessionRoot = GetSessionRoot(repoRoot, options.WorkingDirectory); + string repoRoot = pathProvider.GetRepositoryRoot(); + bool useYapBundleTool = IsYapTool(request.BundleToolPath); + string bundleToolPath = GetBundleTool(repoRoot, request.BundleToolPath); + string sessionRoot = GetSessionRoot(repoRoot, request.WorkingDirectory); - pathProvider.CreateDirectory(sessionRoot); + pathProvider.CreateDirectory(sessionRoot); - GameAutotestSummary summary = new(); - foreach (string gamePath in options.GamePaths) + GameAutotestSummary summary = new(); + foreach (string gamePath in request.GamePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + + GameInstall game = DetectGame(gamePath); + await RunGameAsync(game, bundleToolPath, useYapBundleTool, sessionRoot, request, summary, cancellationToken); + progress?.Report(new OperationProgress("game-autotest", null, game.Name)); + } + + 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( @@ -147,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}"); 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; @@ -211,16 +239,17 @@ await RunRoundTripAsync( splicerPass1, splicerPass2, exportsRoot, - summary); + summary, + cancellationToken); } else if (ImportOnlyTypes.Contains(candidate.ResourceType)) { - await RunImportOnlyAsync(game, candidate, pass1Resources, toolsRoot, splicerPass1, summary); + await RunImportOnlyAsync(game, candidate, pass1Resources, toolsRoot, splicerPass1, summary, cancellationToken); } if (candidate.ResourceType == ResourceType.Texture) { - await RunTextureOperationsAsync(game, candidate, ddsRoot, portRoot, summary); + await RunTextureOperationsAsync(game, candidate, ddsRoot, portRoot, summary, cancellationToken); } } @@ -239,7 +268,8 @@ private async Task RunRoundTripAsync( string splicerPass1, string splicerPass2, string exportsRoot, - GameAutotestSummary summary) + GameAutotestSummary summary, + CancellationToken cancellationToken) { string caseName = $"{candidate.ResourceType}:{candidate.DisplayName}"; string? exportPath = null; @@ -247,7 +277,7 @@ private async Task RunRoundTripAsync( try { - ImportResourceResult firstImport = await importOperation.ExecuteAsync(new ImportResourceRequest( + ImportResourceResult firstImport = await ImportAsync(new ImportResourceRequest( candidate.ResourceType, game.Platform, candidate.SourcePath, @@ -255,12 +285,16 @@ private async Task RunRoundTripAsync( pass1Resources, toolsRoot, splicerPass1, - Overwrite: true)); - await SaveAsync(firstImport.Resource, firstImport.ResourcePath); + Overwrite: true), cancellationToken); + await SaveAsync(firstImport.Resource, firstImport.ResourcePath, cancellationToken); - Resource loaded = await LoadAsync(firstImport.ResourcePath, candidate.ResourceType, game.Platform); + 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, splicerDirectory: splicerPass1); + await ExportAsync(new ExportResourceRequest( + loaded, + exportPath, + game.Platform, + SplicerDirectory: splicerPass1), cancellationToken); BinaryComparisonResult binaryComparison = CompareFiles(candidate.SourcePath, exportPath); AddCase(summary, new GameAutotestCaseResult( @@ -272,7 +306,7 @@ private async Task RunRoundTripAsync( TestedResourceType: candidate.ResourceType)); binaryParityRecorded = true; - ImportResourceResult secondImport = await importOperation.ExecuteAsync(new ImportResourceRequest( + ImportResourceResult secondImport = await ImportAsync(new ImportResourceRequest( candidate.ResourceType, game.Platform, exportPath, @@ -280,11 +314,11 @@ private async Task RunRoundTripAsync( pass2Resources, toolsRoot, splicerPass2, - Overwrite: true)); - await SaveAsync(secondImport.Resource, secondImport.ResourcePath); + 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)) { @@ -328,13 +362,14 @@ private async Task RunImportOnlyAsync( string resourcesDirectory, string toolsRoot, string splicerDirectory, - GameAutotestSummary summary) + GameAutotestSummary summary, + CancellationToken cancellationToken) { string caseName = $"{candidate.ResourceType}:{candidate.DisplayName}"; try { - ImportResourceResult importResult = await importOperation.ExecuteAsync(new ImportResourceRequest( + ImportResourceResult importResult = await ImportAsync(new ImportResourceRequest( candidate.ResourceType, game.Platform, candidate.SourcePath, @@ -342,8 +377,8 @@ private async Task RunImportOnlyAsync( resourcesDirectory, toolsRoot, splicerDirectory, - Overwrite: true)); - await SaveAsync(importResult.Resource, importResult.ResourcePath); + 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) @@ -357,12 +392,28 @@ private async Task RunTextureOperationsAsync( ResourceTestCandidate candidate, 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) @@ -386,14 +437,22 @@ private async Task RunTextureOperationsAsync( string destinationPath = Path.Combine(portRoot, destinationPlatform.ToString()); pathProvider.CreateDirectory(destinationPath); - await portTextureOperation.ExecuteAsync( - [candidate.SourcePath], - sourceFormat, - candidate.SourcePath, - destinationFormat, - destinationPath, - verbose: false, - useGTF: false); + 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)); } @@ -403,12 +462,12 @@ await portTextureOperation.ExecuteAsync( } } - private async Task LoadAsync(string sourceFile, ResourceType resourceType, Platform platform) + 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: CancellationToken.None); + cancellationToken); if (!result.Success || result.Value == null) { @@ -418,12 +477,40 @@ private async Task LoadAsync(string sourceFile, ResourceType resourceT return result.Value.Resource; } - private async Task SaveAsync(Resource resource, string filePath) + 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: CancellationToken.None); + cancellationToken); if (!result.Success) { @@ -442,7 +529,7 @@ private List ProbeBundles( string bundleToolPath, bool useYapBundleTool, string gameWorkRoot, - GameAutotestOptions options, + GameAutotestRequest options, GameAutotestSummary summary) { if (useYapBundleTool) @@ -493,8 +580,10 @@ private 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) @@ -523,7 +612,7 @@ private List ProbeYapBundles( GameInstall game, string bundleToolPath, string gameWorkRoot, - GameAutotestOptions options, + GameAutotestRequest options, GameAutotestSummary summary) { string probeRoot = Path.Combine(gameWorkRoot, "bundle_probes"); @@ -564,8 +653,10 @@ private 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) @@ -593,7 +684,7 @@ private List GetBundleTests( string bundleToolPath, bool useYapBundleTool, string gameWorkRoot, - GameAutotestOptions options, + GameAutotestRequest options, IReadOnlyList probedBundles, GameAutotestSummary summary) { @@ -673,8 +764,10 @@ private 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 @@ -1329,29 +1422,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/Volatility/Operations/Resources/ExportResourceOperation.cs b/Volatility/Operations/Resources/ExportResourceOperation.cs index 0971442..5fb612e 100644 --- a/Volatility/Operations/Resources/ExportResourceOperation.cs +++ b/Volatility/Operations/Resources/ExportResourceOperation.cs @@ -3,6 +3,7 @@ using Volatility.Operations; using Volatility.Resources; using Volatility.Utilities; + namespace Volatility.Operations.Resources; internal sealed class ExportResourceOperation( @@ -10,100 +11,121 @@ internal sealed class ExportResourceOperation( IShaderCompiler shaderCompiler, IOperation shaderProgramBufferOperation, ISplicerSampleStore splicerSampleStore) + : IOperation { - public Task ExecuteAsync( - Resource resource, - string outputPath, - Platform platform, - Unpacker? importUnpackerOverride = null, - bool writeImportsToSeparateFile = false, - string? splicerDirectory = null) + public async Task> ExecuteAsync( + ExportResourceRequest request, + IProgress? progress, + CancellationToken cancellationToken) { - string? directoryPath = Path.GetDirectoryName(outputPath); + cancellationToken.ThrowIfCancellationRequested(); - if (!string.IsNullOrEmpty(directoryPath)) + try { - Directory.CreateDirectory(directoryPath); - } + Resource resource = request.Resource; + string outputPath = request.OutputPath; + Platform platform = request.Platform; - if (resource is Splicer splicer) - { - splicerSampleStore.PopulateDependentSamples( - splicer, - splicerDirectory ?? pathProvider.GetDirectory(VolatilityPathLocation.Splicer)); - } + string? directoryPath = Path.GetDirectoryName(outputPath); + + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } - using FileStream fs = new(outputPath, FileMode.Create); + if (resource is Splicer splicer) + { + splicerSampleStore.PopulateDependentSamples( + splicer, + request.SplicerDirectory ?? pathProvider.GetDirectory(VolatilityPathLocation.Splicer)); + } - Endian endian = resource.ResourceEndian != Endian.Agnostic - ? resource.ResourceEndian - : EndianMapping.GetDefaultEndian(platform); + using FileStream fs = new(outputPath, FileMode.Create); - using ResourceBinaryWriter writer = new(fs, endian); + Endian endian = resource.ResourceEndian != Endian.Agnostic + ? resource.ResourceEndian + : EndianMapping.GetDefaultEndian(platform); - switch (resource) - { - case TextureBase texture: - texture.PushAll(); - goto default; - default: - resource.WriteToStream(writer); - break; - } + using ResourceBinaryWriter writer = new(fs, endian); - WriteExternalImports( - resource, - outputPath, - writer, - endian, - ResolveExternalImportsUnpackerFormat(resource, importUnpackerOverride), - writeImportsToSeparateFile); + switch (resource) + { + case TextureBase texture: + texture.PushAll(); + goto default; + default: + resource.WriteToStream(writer); + break; + } - if (resource is ShaderBase shader) - { - var stages = shader.GetCompileStages(); - bool useStageSuffix = stages.Count > 1; + WriteExternalImports( + resource, + outputPath, + writer, + endian, + ResolveExternalImportsUnpackerFormat(resource, request.ImportUnpackerOverride), + request.WriteImportsToSeparateFile); - if (platform == Platform.BPR) + if (resource is ShaderBase shader) { - foreach (var stage in stages) + var stages = shader.GetCompileStages(); + bool useStageSuffix = stages.Count > 1; + + if (platform == Platform.BPR) { - string shaderProgramBufferPath = GetShaderProgramBufferPath(outputPath, stage, useStageSuffix); - string csoPath = GetShaderCSOPath(outputPath, stage, useStageSuffix); + foreach (var stage in stages) + { + cancellationToken.ThrowIfCancellationRequested(); - shaderCompiler.CompileToCSO(shader, stage, csoPath); - OperationResult bufferResult = shaderProgramBufferOperation.ExecuteAsync( - new CreateShaderProgramBufferRequest(stage.ResolveStage(), platform, csoPath), - progress: null, - cancellationToken: CancellationToken.None).GetAwaiter().GetResult(); + string shaderProgramBufferPath = GetShaderProgramBufferPath(outputPath, stage, useStageSuffix); + string csoPath = GetShaderCSOPath(outputPath, stage, useStageSuffix); - if (!bufferResult.Success || bufferResult.Value == null) - { - throw OperationResultFactory.CreateException(bufferResult, "Failed to create shader program buffer."); - } + 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)); + ShaderProgramBufferBase buffer = bufferResult.Value.Buffer; + CreateShaderProgramBufferOperation.WriteToFile(buffer, shaderProgramBufferPath, platform); + WritePaddedCSOFile(csoPath, GetSecondaryResourcePath(shaderProgramBufferPath)); + } + } + else + { + shaderCompiler.CompileStagesToCSO(shader, stages, stage => + GetShaderProgramBufferPath(outputPath, stage, useStageSuffix)); } } - else + + if (resource is ShaderProgramBufferBPR shaderProgramBuffer && platform == Platform.BPR) { - shaderCompiler.CompileStagesToCSO(shader, stages, stage => - GetShaderProgramBufferPath(outputPath, stage, useStageSuffix)); + if (shaderProgramBuffer.CompiledShaderBytecode.Length > 0) + { + string secondaryCSOPath = GetSecondaryResourcePath(outputPath); + WritePaddedCSOBytes(shaderProgramBuffer.CompiledShaderBytecode, secondaryCSOPath); + } } - } - if (resource is ShaderProgramBufferBPR shaderProgramBuffer && platform == Platform.BPR) + progress?.Report(new OperationProgress("export-resource", 1.0, outputPath)); + return OperationResultFactory.Success(new ExportResourceResult(outputPath)); + } + catch (OperationCanceledException) { - if (shaderProgramBuffer.CompiledShaderBytecode.Length > 0) - { - string secondaryCSOPath = GetSecondaryResourcePath(outputPath); - WritePaddedCSOBytes(shaderProgramBuffer.CompiledShaderBytecode, secondaryCSOPath); - } + throw; + } + catch (Exception ex) + { + return OperationResultFactory.Failure( + "export_resource_failed", + ex.Message, + nameof(ExportResourceOperation)); } - - return Task.CompletedTask; } private static Unpacker ResolveExternalImportsUnpackerFormat( @@ -262,3 +284,13 @@ private static void WritePaddedCSOBytes(byte[] csoBytes, string outputPath) PaddingUtilities.WritePadding(output, 0x100); } } + +public sealed record ExportResourceRequest( + Resource Resource, + string OutputPath, + Platform Platform, + Unpacker? ImportUnpackerOverride = null, + bool WriteImportsToSeparateFile = false, + string? SplicerDirectory = null) : IOperationRequest; + +public sealed record ExportResourceResult(string OutputPath); diff --git a/Volatility/Operations/Resources/ImportResourceOperation.cs b/Volatility/Operations/Resources/ImportResourceOperation.cs index 269f466..fcd133b 100644 --- a/Volatility/Operations/Resources/ImportResourceOperation.cs +++ b/Volatility/Operations/Resources/ImportResourceOperation.cs @@ -1,130 +1,170 @@ 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( + IResourceFactory resourceFactory, IResourceDBLookup resourceDBLookup, ITextureBitmapStore textureBitmapStore, IProcessRunner processRunner, - IShaderSourceStore shaderSourceStore) + IShaderSourceStore shaderSourceStore, + IMessageSink messageSink) + : IOperation { - public async Task ExecuteAsync(ImportResourceRequest request) + public async Task> ExecuteAsync( + ImportResourceRequest request, + IProgress? progress, + CancellationToken cancellationToken) { - Resource resource = ResourceFactory.LoadResource( - request.ResourceType, - request.Platform, - request.SourceFile, - resourceDBLookup, - 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); - } + cancellationToken.ThrowIfCancellationRequested(); - if (resource is ShaderBase shader) + try { - shaderSourceStore.MaterializeImportedSource(shader, request.ResourcesDirectory); - } + Resource resource = resourceFactory.LoadResource( + request.ResourceType, + request.Platform, + request.SourceFile, + resourceDBLookup, + request.IsX64); + + string filePath = Path.Combine + ( + request.ResourcesDirectory, + $"{DBToFileRegex().Replace(resource.AssetName, string.Empty)}.{request.ResourceType}" + ); - if (request.ResourceType == ResourceType.Texture) - { - string texturePath = textureBitmapStore.GetSecondaryBitmapPath(request.SourceFile, resource.Unpacker); + string? directoryPath = Path.GetDirectoryName(filePath); - if (resource is TextureBase texture && File.Exists(texturePath)) + if (!string.IsNullOrEmpty(directoryPath)) { - string outPath = Path.Combine - ( - directoryPath ?? string.Empty, - Path.GetFileNameWithoutExtension(Path.GetFileNameWithoutExtension(Path.GetFullPath(filePath))) - ); + Directory.CreateDirectory(directoryPath); + } - textureBitmapStore.WriteNormalizedBitmapFile(texture, texturePath, $"{outPath}.{request.ResourceType}Bitmap", request.Overwrite); + if (resource is ShaderBase shader) + { + shaderSourceStore.MaterializeImportedSource(shader, request.ResourcesDirectory); } - } - if (request.ResourceType == ResourceType.Splicer) - { - string sxPath = Path.Combine - ( - request.ToolsDirectory, - "sx.exe" - ); + if (request.ResourceType == ResourceType.Texture) + { + string texturePath = textureBitmapStore.GetSecondaryBitmapPath(request.SourceFile, resource.Unpacker); - bool sxExists = File.Exists(sxPath); + if (resource is TextureBase texture && File.Exists(texturePath)) + { + string outPath = Path.Combine + ( + directoryPath ?? string.Empty, + Path.GetFileNameWithoutExtension(Path.GetFileNameWithoutExtension(Path.GetFullPath(filePath))) + ); - Splicer? splicer = resource as Splicer; + textureBitmapStore.WriteNormalizedBitmapFile(texture, texturePath, $"{outPath}.{request.ResourceType}Bitmap", request.Overwrite); + } + } - List? samples = splicer?.GetLoadedSamples(); + if (request.ResourceType == ResourceType.Splicer) + { + string sxPath = Path.Combine + ( + request.ToolsDirectory, + "sx.exe" + ); - string sampleDirectory = Path.Combine - ( - request.SplicerDirectory, - "Samples" - ); + bool sxExists = File.Exists(sxPath); - Directory.CreateDirectory(sampleDirectory); + Splicer? splicer = resource as Splicer; - if (samples != null) - { - for (int i = 0; i < samples.Count; i++) - { - string sampleName = $"{samples[i].SampleID}"; + List? samples = splicer?.GetLoadedSamples(); - string samplePathName = Path.Combine(sampleDirectory, sampleName); + string sampleDirectory = Path.Combine + ( + request.SplicerDirectory, + "Samples" + ); - if (!File.Exists($"{samplePathName}.snr") || request.Overwrite) - { - Console.WriteLine($"Writing extracted sample {sampleName}.snr"); - await File.WriteAllBytesAsync($"{samplePathName}.snr", samples[i].Data); - } - else - { - Console.WriteLine($"Skipping extracted sample {sampleName}.snr"); - } + Directory.CreateDirectory(sampleDirectory); - if (sxExists) + if (samples != null) + { + for (int i = 0; i < samples.Count; i++) { - string convertedSamplePathName = Path.Combine(sampleDirectory, "_extracted"); + cancellationToken.ThrowIfCancellationRequested(); - Directory.CreateDirectory(convertedSamplePathName); + string sampleName = $"{samples[i].SampleID}"; - convertedSamplePathName = Path.Combine(convertedSamplePathName, sampleName + ".wav"); + string samplePathName = Path.Combine(sampleDirectory, sampleName); - if (!File.Exists(convertedSamplePathName) || request.Overwrite) + if (!File.Exists($"{samplePathName}.snr") || request.Overwrite) { - Console.WriteLine($"Converting extracted sample {sampleName}.snr to wave..."); - processRunner.RunAndRelayOutput( - sxPath, - $"-wave -s16l_int -v0 \"{samplePathName}.snr\" -=\"{convertedSamplePathName}\""); + messageSink.Info( + $"Writing extracted sample {sampleName}.snr", + MessageCategory.Resource, + nameof(ImportResourceOperation)); + await File.WriteAllBytesAsync($"{samplePathName}.snr", samples[i].Data, cancellationToken); } else { - Console.WriteLine($"Converted sample {Path.GetFileName(convertedSamplePathName)} already exists, skipping..."); + 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)); + } } } } } - } - return new ImportResourceResult(resource, filePath); + 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(); } -internal sealed record ImportResourceRequest( +public sealed record ImportResourceRequest( ResourceType ResourceType, Platform Platform, string SourceFile, @@ -132,6 +172,6 @@ internal sealed record ImportResourceRequest( string ResourcesDirectory, string ToolsDirectory, string SplicerDirectory, - bool Overwrite); + bool Overwrite) : IOperationRequest; -internal sealed record ImportResourceResult(Resource Resource, string ResourcePath); +public sealed record ImportResourceResult(Resource Resource, string ResourcePath); diff --git a/Volatility/Operations/Resources/PortTextureOperation.cs b/Volatility/Operations/Resources/PortTextureOperation.cs index 36cef24..0979e73 100644 --- a/Volatility/Operations/Resources/PortTextureOperation.cs +++ b/Volatility/Operations/Resources/PortTextureOperation.cs @@ -1,6 +1,9 @@ using System.Reflection; +using Volatility.Abstractions.Messaging; +using Volatility.Abstractions.Operations; using Volatility.Abstractions.Services; +using Volatility.Operations; using Volatility.Resources; using Volatility.Utilities; @@ -9,21 +12,71 @@ namespace Volatility.Operations.Resources; internal sealed class PortTextureOperation( + IResourceFactory resourceFactory, IResourceDBLookup resourceDBLookup, - ITextureBitmapStore textureBitmapStore) + ITextureBitmapStore textureBitmapStore, + IMessageSink messageSink) + : IOperation { - public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFormat, string sourcePath, string destinationFormat, string? destinationPath, bool verbose, bool useGTF) + public async Task> ExecuteAsync( + PortTextureRequest request, + IProgress? progress, + CancellationToken cancellationToken) { - string resolvedDestinationPath = string.IsNullOrEmpty(destinationPath) ? sourcePath : destinationPath; - TextureFormatSpec sourceSpec = ParseTextureFormat(sourceFormat); - TextureFormatSpec destinationSpec = ParseTextureFormat(destinationFormat); + cancellationToken.ThrowIfCancellationRequested(); - List tasks = new List(); - - foreach (string sourceFile in sourceFiles) + try { - tasks.Add(Task.Run(async () => + 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); @@ -45,7 +98,7 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor 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})"); + LogWarning($"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); @@ -54,7 +107,7 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor 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})"); + LogWarning($"Destination texture format is {x360ps3Format}! (Source is {x360.Format.DataFormat})"); break; case (TextureBPR bprsrc, TextureBPR bprdst): bprdst.Format = bprsrc.Format; @@ -67,7 +120,7 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor 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})"); + LogWarning($"Destination texture format is {tubbprFormat}! (Source is {tub.Format})"); break; case (TextureBPR bpr, TexturePC tub): BPRtoTUBMapping.TryGetValue(bpr.Format, out D3DFORMAT bprtubFormat); @@ -75,7 +128,7 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor sourceFormatIndex = (int)bpr.Format; destinationFormatIndex = (int)bprtubFormat; if (bprtubFormat == D3DFORMAT.D3DFMT_UNKNOWN) - Console.WriteLine($"WARNING: Destination texture format is {bprtubFormat}! (Source is {bpr.Format})"); + LogWarning($"Destination texture format is {bprtubFormat}! (Source is {bpr.Format})"); break; case (TexturePS3 ps3, TextureBPR bpr): PS3toBPRMapping.TryGetValue(ps3.Format, out DXGI_FORMAT ps3bprFormat); @@ -84,7 +137,7 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor 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})"); + LogWarning($"Destination texture format is {ps3bprFormat}! (Source is {ps3.Format})"); break; case (TexturePS3 ps3, TexturePC tub): PS3toTUBMapping.TryGetValue(ps3.Format, out D3DFORMAT ps3tubFormat); @@ -93,7 +146,7 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor sourceFormatIndex = (int)ps3.Format; destinationFormatIndex = (int)ps3tubFormat; if (ps3tubFormat == D3DFORMAT.D3DFMT_UNKNOWN) - Console.WriteLine($"WARNING: Destination texture format is {ps3tubFormat}! (Source is {ps3.Format})"); + LogWarning($"Destination texture format is {ps3tubFormat}! (Source is {ps3.Format})"); break; case (TextureX360 x360, TexturePC tub): X360toTUBMapping.TryGetValue(x360.Format.DataFormat, out D3DFORMAT x360tubFormat); @@ -102,7 +155,7 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor 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})"); + LogWarning($"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); @@ -111,7 +164,7 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor 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})"); + LogWarning($"Destination texture format is {x360bprFormat}! (Source is {x360.Format.DataFormat})"); break; case (TexturePC tub, TextureX360 x360): TUBtoX360Mapping.TryGetValue(tub.Format, out GPUTEXTUREFORMAT tubx360Format); @@ -120,7 +173,7 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor 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})"); + LogWarning($"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); @@ -129,7 +182,7 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor 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})"); + LogWarning($"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); @@ -138,7 +191,7 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor 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})"); + LogWarning($"Destination texture format is {tubps3Format}! (Source is {tub.Format})"); break; case (TextureBPR bpr, TextureX360 x360): BPRtoX360Mapping.TryGetValue(bpr.Format, out GPUTEXTUREFORMAT bprx360Format); @@ -147,7 +200,7 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor 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})"); + LogWarning($"Destination texture format is {bprx360Format}! (Source is {bpr.Format})"); break; default: throw new NotImplementedException($"Conversion technique {localSourceFormat} > {localDestinationFormat} is not yet implemented."); @@ -184,18 +237,20 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor if (!Path.Exists(sourceBitmapPath)) { - Console.WriteLine($"Failed to find associated bitmap data for {Path.GetFileNameWithoutExtension(sourceFile)} at path {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)) { - if (verbose) Console.WriteLine($"Found existing bitmap data at {destinationBitmapPath}, overwriting..."); + LogVerbose(verbose, $"Found existing bitmap data at {destinationBitmapPath}, overwriting..."); } try { + cancellationToken.ThrowIfCancellationRequested(); + if (useGTF && sourceTexture is TexturePS3 sourcePs3) { textureBitmapStore.ConvertPS3GTFToDDS(sourcePs3, sourceBitmapPath, destinationBitmapPath, verbose); @@ -210,19 +265,19 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor 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); + LogVerbose(verbose, $"Writing associated bitmap data for {Path.GetDirectoryName(outPath)}{Path.DirectorySeparatorChar}{Path.GetFileNameWithoutExtension(outPath)}_texture.dat..."); + await File.WriteAllBytesAsync(destinationBitmapPath, sourceBitmapData, cancellationToken); } else { - if (verbose) Console.WriteLine($"Converting associated bitmap data for {Path.GetDirectoryName(outPath)}{Path.DirectorySeparatorChar}{Path.GetFileNameWithoutExtension(outPath)}_texture.dat..."); + LogVerbose(verbose, $"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."); + 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; - if (verbose) Console.WriteLine($"BPR PlacedDataSize set to {destBprTexture.PlacedDataSize} (file: {destinationBitmapPath})."); + LogVerbose(verbose, $"BPR PlacedDataSize set to {destBprTexture.PlacedDataSize} (file: {destinationBitmapPath})."); } using FileStream fs = new(outPath, FileMode.Create, FileAccess.Write); @@ -230,7 +285,7 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor { try { - if (verbose) Console.WriteLine($"Writing converted {destinationFormat} texture property data to destination file {Path.GetFileName(outPath)}..."); + LogVerbose(verbose, $"Writing converted {destinationSpec.DisplayName} texture property data to destination file {Path.GetFileName(outPath)}..."); destinationTexture.WriteToStream(writer); } catch @@ -248,23 +303,36 @@ public async Task ExecuteAsync(IEnumerable sourceFiles, string sourceFor ex); } - Console.WriteLine($"Successfully ported {localSourceFormat} formatted {Path.GetFileNameWithoutExtension(sourceFile)} to {localDestinationFormat} as {Path.GetFileNameWithoutExtension(outPath)}."); - })); - } - - await Task.WhenAll(tasks); + 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) { - if (verbose) Console.WriteLine($"Loading {format.DisplayName} texture property data..."); - return (TextureBase)ResourceFactory.LoadResource(ResourceType.Texture, format.Platform, path, resourceDBLookup, format.IsX64); + LogVerbose(verbose, $"Loading {format.DisplayName} texture property data..."); + return (TextureBase)resourceFactory.LoadResource(ResourceType.Texture, format.Platform, path, resourceDBLookup, 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 static TextureBase CreateDestinationTexture(TextureFormatSpec format, bool verbose) + private void LogVerbose(bool verbose, string text) { - if (verbose) Console.WriteLine($"Constructing {format.DisplayName} texture property data..."); - return (TextureBase)ResourceFactory.CreateResource(ResourceType.Texture, format.Platform, format.IsX64); + 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) @@ -531,3 +599,14 @@ private bool TryConvertTexture(TextureBase srcTexture, TextureBase destTexture, 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/Volatility/Operations/Resources/TextureToDDSOperation.cs b/Volatility/Operations/Resources/TextureToDDSOperation.cs index ce094a6..46dc69e 100644 --- a/Volatility/Operations/Resources/TextureToDDSOperation.cs +++ b/Volatility/Operations/Resources/TextureToDDSOperation.cs @@ -1,53 +1,111 @@ +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( + IResourceFactory resourceFactory, IResourceDBLookup resourceDBLookup, - ITextureBitmapStore textureBitmapStore) + ITextureBitmapStore textureBitmapStore, + IMessageSink messageSink) + : IOperation { - public async Task ExecuteAsync(IEnumerable sourceFiles, Platform platform, bool isX64, string? outputPath, bool overwrite, bool verbose) + public async Task> ExecuteAsync( + TextureToDDSRequest request, + IProgress? progress, + CancellationToken cancellationToken) { - string[] files = sourceFiles.ToArray(); - bool multipleInputs = files.Length > 1; + cancellationToken.ThrowIfCancellationRequested(); - List tasks = new(); - foreach (string sourceFile in files) + try { - tasks.Add(Task.Run(async () => + string[] files = request.SourceFiles.ToArray(); + bool multipleInputs = files.Length > 1; + + List> tasks = new(); + foreach (string sourceFile in files) { - TextureBase texture = (TextureBase)ResourceFactory.LoadResource(ResourceType.Texture, platform, sourceFile, resourceDBLookup, 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 = DDSTextureUtilities.CreateDDSFile(texture, bitmapData); - string destinationPath = ResolveOutputPath(sourceFile, texture.Unpacker, outputPath, multipleInputs, textureBitmapStore); - - 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}."); - })); + 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(); + + TextureBase texture = (TextureBase)resourceFactory.LoadResource( + ResourceType.Texture, + request.Platform, + sourceFile, + resourceDBLookup, + 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 Task.WhenAll(tasks); + 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( @@ -75,3 +133,13 @@ private static string ResolveOutputPath( 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/Volatility/Operations/StringTables/ImportStringTableOperation.cs b/Volatility/Operations/StringTables/ImportStringTableOperation.cs index c7d8def..f31db7e 100644 --- a/Volatility/Operations/StringTables/ImportStringTableOperation.cs +++ b/Volatility/Operations/StringTables/ImportStringTableOperation.cs @@ -1,5 +1,6 @@ using System.Text; using System.Xml.Linq; +using Volatility.Abstractions.Messaging; using Volatility.Abstractions.Operations; using Volatility.Operations; @@ -9,59 +10,86 @@ namespace Volatility.Operations.StringTables; internal sealed class ImportStringTableOperation + : IOperation { private readonly IOperation mergeOperation; + private readonly IMessageSink messageSink; public ImportStringTableOperation( - IOperation mergeOperation) + IOperation mergeOperation, + IMessageSink messageSink) { this.mergeOperation = mergeOperation; + this.messageSink = messageSink; } - public async Task ExecuteAsync( - IEnumerable filePaths, - Dictionary> entries, - string endian, - bool overwrite, - bool verbose) + public async Task> ExecuteAsync( + ImportStringTableRequest request, + IProgress? progress, + CancellationToken cancellationToken) { - var results = await Task.WhenAll(filePaths.Select(path => ProcessFileAsync(path, endian, overwrite, verbose))); + cancellationToken.ThrowIfCancellationRequested(); - foreach (Dictionary> fileResult in results) + try { - OperationResult mergeResult = await mergeOperation.ExecuteAsync( - new MergeStringTableEntriesRequest(entries, fileResult, overwrite), - progress: null, - cancellationToken: CancellationToken.None); + var results = await Task.WhenAll(request.FilePaths.Select(path => + ProcessFileAsync(path, request.Endian, request.Overwrite, request.Verbose, cancellationToken))); - if (!mergeResult.Success) + foreach (Dictionary> fileResult in results) { - throw OperationResultFactory.CreateException(mergeResult, "Failed to merge string table entries."); + 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)); + } } - } - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); + 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 static async Task>> ProcessFileAsync( + private async Task>> ProcessFileAsync( string filePath, string endian, bool overwrite, - bool verbose) + bool verbose, + CancellationToken cancellationToken) { var entriesByType = new Dictionary>(StringComparer.OrdinalIgnoreCase); string fileName = Path.GetFileName(filePath)!; - string text = Encoding.UTF8.GetString(await File.ReadAllBytesAsync(filePath)); + string text = Encoding.UTF8.GetString(await File.ReadAllBytesAsync(filePath, cancellationToken)); - int start = text.IndexOf(""); - int end = text.IndexOf("") + "".Length; + int start = text.IndexOf("", StringComparison.Ordinal); + int end = text.IndexOf("", StringComparison.Ordinal) + "".Length; if (start < 0 || end <= start) { if (verbose) { - Console.WriteLine($"Skipping (no table): {fileName}"); + messageSink.Verbose( + $"Skipping (no table): {fileName}", + MessageCategory.StringTable, + nameof(ImportStringTableOperation)); } return entriesByType; @@ -86,7 +114,10 @@ private static async Task FilePaths, + Dictionary> Entries, + string Endian, + bool Overwrite, + bool Verbose) : IOperationRequest; + +public sealed record ImportStringTableResult( + Dictionary> Entries); diff --git a/Volatility/Resources/GuiPopup/GuiPopup.cs b/Volatility/Resources/GuiPopup/GuiPopup.cs index 7360721..914955e 100644 --- a/Volatility/Resources/GuiPopup/GuiPopup.cs +++ b/Volatility/Resources/GuiPopup/GuiPopup.cs @@ -114,10 +114,6 @@ 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() { } diff --git a/Volatility/Resources/Model/Model.cs b/Volatility/Resources/Model/Model.cs index cab8551..8972165 100644 --- a/Volatility/Resources/Model/Model.cs +++ b/Volatility/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( diff --git a/Volatility/Resources/SnapshotData/SnapshotData.cs b/Volatility/Resources/SnapshotData/SnapshotData.cs index 088be84..7325b93 100644 --- a/Volatility/Resources/SnapshotData/SnapshotData.cs +++ b/Volatility/Resources/SnapshotData/SnapshotData.cs @@ -90,7 +90,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; From 8a63ccb2ef2e96c4f96a4a61c9a3a263b954dd37 Mon Sep 17 00:00:00 2001 From: "Nathan V." Date: Mon, 11 May 2026 21:42:26 -0400 Subject: [PATCH 4/5] fix exportresource overwrite behaviour --- .../CLI/Commands/ExportResourceCommand.cs | 31 +++++- .../Autotest/GameAutotestOperation.cs | 1 + .../Resources/ExportResourceOperation.cs | 99 ++++++++++++++++++- 3 files changed, 126 insertions(+), 5 deletions(-) diff --git a/Volatility/CLI/Commands/ExportResourceCommand.cs b/Volatility/CLI/Commands/ExportResourceCommand.cs index 1757dbc..98c8643 100644 --- a/Volatility/CLI/Commands/ExportResourceCommand.cs +++ b/Volatility/CLI/Commands/ExportResourceCommand.cs @@ -15,7 +15,7 @@ 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 static string CommandParameters => "--recurse --overwrite --type= --format= --respath= --outpath= [--imports=] [--importsfile]"; public string? Format { get; set; } public string? ResourcePath { get; set; } @@ -72,6 +72,11 @@ public async Task Execute() 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) { @@ -102,13 +107,16 @@ public async Task Execute() return; } + string outputPath = ResolveOutputPath(sourceFile, inputRoot, OutputPath, multipleOutputs); + OperationResult exportResult = await exportOperation.ExecuteAsync( new ExportResourceRequest( loadResult.Value.Resource, - OutputPath, + outputPath, platform, importUnpackerOverride, - ImportsFile), + ImportsFile, + Overwrite), progress: null, cancellationToken: CancellationToken.None); CLIMessageUtilities.PublishIssues(exportResult.Issues, MessageCategory.Resource); @@ -119,7 +127,7 @@ public async Task Execute() } CLIMessageUtilities.Success( - $"Exported {Path.GetFileName(sourceFile)} as {pathProvider.GetFullPath(OutputPath)}.", + $"Exported {Path.GetFileName(sourceFile)} as {pathProvider.GetFullPath(outputPath)}.", MessageCategory.Resource); })); } @@ -147,4 +155,19 @@ public ExportResourceCommand( 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/Volatility/Operations/Autotest/GameAutotestOperation.cs b/Volatility/Operations/Autotest/GameAutotestOperation.cs index ad10cae..5b8cf06 100644 --- a/Volatility/Operations/Autotest/GameAutotestOperation.cs +++ b/Volatility/Operations/Autotest/GameAutotestOperation.cs @@ -294,6 +294,7 @@ await ExportAsync(new ExportResourceRequest( loaded, exportPath, game.Platform, + Overwrite: true, SplicerDirectory: splicerPass1), cancellationToken); BinaryComparisonResult binaryComparison = CompareFiles(candidate.SourcePath, exportPath); diff --git a/Volatility/Operations/Resources/ExportResourceOperation.cs b/Volatility/Operations/Resources/ExportResourceOperation.cs index 5fb612e..6e27096 100644 --- a/Volatility/Operations/Resources/ExportResourceOperation.cs +++ b/Volatility/Operations/Resources/ExportResourceOperation.cs @@ -26,6 +26,14 @@ public async Task> ExecuteAsync( 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)) @@ -40,7 +48,7 @@ public async Task> ExecuteAsync( request.SplicerDirectory ?? pathProvider.GetDirectory(VolatilityPathLocation.Splicer)); } - using FileStream fs = new(outputPath, FileMode.Create); + using FileStream fs = new(outputPath, request.Overwrite ? FileMode.Create : FileMode.CreateNew); Endian endian = resource.ResourceEndian != Endian.Agnostic ? resource.ResourceEndian @@ -135,6 +143,94 @@ private static Unpacker ResolveExternalImportsUnpackerFormat( 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, @@ -291,6 +387,7 @@ public sealed record ExportResourceRequest( Platform Platform, Unpacker? ImportUnpackerOverride = null, bool WriteImportsToSeparateFile = false, + bool Overwrite = false, string? SplicerDirectory = null) : IOperationRequest; public sealed record ExportResourceResult(string OutputPath); From 8d24dbd78332a415b9dd69671ac80f3a792f266d Mon Sep 17 00:00:00 2001 From: Adriwin <76881633+Adriwin06@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:49:24 +0200 Subject: [PATCH 5/5] refactor: split Core/CLI, decouple file I/O, and resolve compiler warnings (#11) --- .github/workflows/dotnet.yml | 12 +- AGENTS.md | 26 +- Directory.Build.props | 7 + GEMINI.md | 552 ++++++++++++++++ Volatility.sln | 17 +- .../Resources/PortTextureOperation.cs | 612 ------------------ Volatility/Volatility.csproj | 48 -- .../CLI/CLIMessageUtilities.cs | 0 .../CLI/Commands/AutotestCommand.cs | 191 +++--- .../CLI/Commands/ClearCommand.cs | 6 +- .../CLI/Commands/CreateResourceCommand.cs | 0 .../CLI/Commands/ExitCommand.cs | 3 +- .../CLI/Commands/ExportResourceCommand.cs | 0 .../CLI/Commands/HelpCommand.cs | 20 +- .../CLI/Commands/ImportResourceCommand.cs | 2 +- .../CLI/Commands/ImportStringTableCommand.cs | 0 .../CLI/Commands/NullCommand.cs | 4 +- .../CLI/Commands/PortTextureCommand.cs | 4 +- .../CLI/Commands/TextureToDDSCommand.cs | 5 + .../Volatility.Cli}/CLI/ConsoleMessageSink.cs | 0 .../Volatility.Cli}/CLI/ICommand.cs | 10 +- src/Volatility.Cli/GlobalUsings.cs | 2 + .../Volatility.Cli/Program.cs | 1 + src/Volatility.Cli/Volatility.Cli.csproj | 49 ++ .../Volatility.Cli}/tools/dxc/README.txt | 0 .../Volatility.Cli}/volatility_icon.ico | Bin .../Abstractions/Messaging/IMessageBus.cs | 0 .../Abstractions/Messaging/IMessageSink.cs | 0 .../Abstractions/Messaging/MessageCategory.cs | 0 .../Abstractions/Messaging/MessageSeverity.cs | 0 .../Abstractions/Messaging/VolatilityLog.cs | 0 .../Messaging/VolatilityMessage.cs | 0 .../Abstractions/Operations/IOperation.cs | 0 .../Operations/IOperationRequest.cs | 0 .../Abstractions/Operations/OperationIssue.cs | 0 .../Operations/OperationProgress.cs | 0 .../Operations/OperationResult.cs | 0 .../Abstractions/Services/IPathProvider.cs | 0 .../Abstractions/Services/IProcessRunner.cs | 0 .../Services/IResourceDBLookup.cs | 0 .../Abstractions/Services/IResourceFactory.cs | 7 - .../Services/IResourceSerializer.cs | 17 + .../Abstractions/Services/IShaderCompiler.cs | 0 .../Services/IShaderSourceStore.cs | 0 .../Services/ISplicerSampleStore.cs | 0 .../Services/IStringTableStore.cs | 0 .../Services/ITextureBitmapStore.cs | 0 .../Services/VolatilityFilePathFilter.cs | 0 .../Services/VolatilityPathLocation.cs | 0 src/Volatility.Core/AssemblyInfo.cs | 4 + .../Attributes/EditorCategoryAttribute.cs | 0 .../Attributes/EditorHiddenAttribute.cs | 0 .../Attributes/EditorLabelAttribute.cs | 0 .../Attributes/EditorReadOnlyAttribute.cs | 0 .../Attributes/EditorTooltipAttribute.cs | 0 {Volatility => src/Volatility.Core}/Endian.cs | 0 .../Exceptions/InvalidPlatformException.cs | 5 - .../VolatilityServiceCollectionExtensions.cs | 2 + .../Volatility.Core}/Messaging/MessageBus.cs | 0 .../Messaging/VolatilityMessageHost.cs | 0 .../Autotest/GameAutotestOperation.cs | 8 +- .../Operations/OperationResultFactory.cs | 0 .../Resources/CreateResourceOperation.cs | 0 .../CreateShaderProgramBufferOperation.cs | 0 .../Resources/ExportResourceOperation.cs | 0 .../Resources/ImportResourceOperation.cs | 22 +- .../Resources/LoadResourceOperation.cs | 0 .../Resources/PortTextureOperation.cs | 271 ++++++++ .../Resources/SaveResourceOperation.cs | 0 .../Resources/TextureFormatConverter.cs | 382 +++++++++++ .../Resources/TextureRoundTripOperation.cs | 90 +++ .../Resources/TextureToDDSOperation.cs | 15 +- .../ImportStringTableOperation.cs | 0 .../LoadResourceDictionaryOperation.cs | 0 .../MergeStringTableEntriesOperation.cs | 0 .../StringTables/StringTableResourceEntry.cs | 0 .../Resources/AptData/AptData.cs | 16 +- .../AttribSysVault/AttribSysVault.cs | 3 - .../Resources/BinaryResource.cs | 7 - .../EnvironmentKeyframe.cs | 1 - .../EnvironmentTimeLine.cs | 2 - .../Resources/GuiPopup/GuiPopup.cs | 3 - .../Resources/InstanceList/InstanceList.cs | 2 - .../Volatility.Core}/Resources/Model/Model.cs | 3 - .../Resources/Renderable/RenderableBPR.cs | 4 +- .../Resources/Renderable/RenderableBase.cs | 4 +- .../Resources/Renderable/RenderablePC.cs | 4 +- .../Resources/Renderable/RenderablePS3.cs | 4 +- .../Resources/Renderable/RenderableX360.cs | 4 +- .../Volatility.Core}/Resources/Resource.cs | 89 ++- .../Resources/ResourceFactory.cs | 38 +- .../Resources/ResourceImport.cs | 0 .../Resources/ResourceMetadata.cs | 0 .../Resources/ResourceSerializationOptions.cs | 14 + .../Resources/Shader/ShaderBase.cs | 4 - .../Resources/Shader/ShaderPC.cs | 6 +- .../ShaderProgramBufferBPR.cs | 4 +- .../ShaderProgramBufferBase.cs | 4 +- .../Resources/SnapshotData/SnapshotData.cs | 2 - .../Resources/Splicer/Splicer.cs | 3 - .../StreamedDeformationSpec.cs | 2 - .../Resources/Texture/TextureBPR.cs | 4 +- .../Resources/Texture/TextureBase.cs | 4 +- .../Resources/Texture/TexturePC.cs | 4 +- .../Resources/Texture/TexturePS3.cs | 4 +- .../Resources/Texture/TextureX360.cs | 4 +- .../Services/DefaultProcessRunner.cs | 0 .../Services/DefaultResourceFactory.cs | 10 - .../Services/DefaultResourceSerializer.cs | 73 +++ .../Services/DefaultShaderCompiler.cs | 0 .../Services/EnvironmentPathProvider.cs | 0 .../Services/FileResourceDBLookup.cs | 0 .../Services/FileShaderSourceStore.cs | 0 .../Services/FileSplicerSampleStore.cs | 0 .../Services/FileStringTableStore.cs | 0 .../Services/FileTextureBitmapStore.cs | 0 .../Volatility.Core}/StrongID.cs | 0 {Volatility => src/Volatility.Core}/Types.cs | 0 .../Volatility.Core}/Utilities/BitReader.cs | 0 .../Utilities/CgsIDUtilities.cs | 0 .../Utilities/DDSTextureUtilities.cs | 0 .../Utilities/DataUtilities.cs | 0 .../Utilities/DictUtilities.cs | 0 .../Utilities/DxbcReflectionParser.cs | 0 .../Utilities/EndianAwareBinaryReader.cs | 0 .../Utilities/EndianAwareBinaryWriter.cs | 10 +- .../Utilities/EndianUtilities.cs | 0 .../Utilities/MatrixUtilities.cs | 0 .../Utilities/PS3TextureUtilities.cs | 0 .../Utilities/PaddingUtilities.cs | 0 .../Utilities/ResourceBinaryReader.cs | 0 .../Utilities/ResourceBinaryWriter.cs | 3 +- .../Utilities/ResourceIDUtilities.cs | 0 .../Utilities/ResourcePropertyComparer.cs | 65 ++ .../Utilities/ResourceUtilities.cs | 0 .../Utilities/TypeUtilities.cs | 16 +- .../Utilities/X360TextureUtilities.cs | 8 +- .../YAML/BitArrayYamlTypeConverter.cs | 0 .../Utilities/YAML/FieldDescriptor.cs | 8 +- .../YAML/IncludeFieldsTypeInspector.cs | 4 +- .../YAML/ResourceYamlDeserializer.cs | 26 +- .../YAML/ResourceYamlTypeConverter.cs | 87 +-- .../YAML/StringEnumYamlTypeConverter.cs | 4 +- .../YAML/StrongIDYamlTypeConverter.cs | 6 +- src/Volatility.Core/Volatility.Core.csproj | 16 + tests/Volatility.Core.Tests/GlobalUsings.cs | 2 + .../OperationIntegrationTests.cs | 247 +++++++ .../Volatility.Core.Tests.csproj | 21 + 148 files changed, 2131 insertions(+), 1112 deletions(-) create mode 100644 Directory.Build.props create mode 100644 GEMINI.md delete mode 100644 Volatility/Operations/Resources/PortTextureOperation.cs delete mode 100644 Volatility/Volatility.csproj rename {Volatility => src/Volatility.Cli}/CLI/CLIMessageUtilities.cs (100%) rename {Volatility => src/Volatility.Cli}/CLI/Commands/AutotestCommand.cs (76%) rename {Volatility => src/Volatility.Cli}/CLI/Commands/ClearCommand.cs (77%) rename {Volatility => src/Volatility.Cli}/CLI/Commands/CreateResourceCommand.cs (100%) rename {Volatility => src/Volatility.Cli}/CLI/Commands/ExitCommand.cs (88%) rename {Volatility => src/Volatility.Cli}/CLI/Commands/ExportResourceCommand.cs (100%) rename {Volatility => src/Volatility.Cli}/CLI/Commands/HelpCommand.cs (68%) rename {Volatility => src/Volatility.Cli}/CLI/Commands/ImportResourceCommand.cs (98%) rename {Volatility => src/Volatility.Cli}/CLI/Commands/ImportStringTableCommand.cs (100%) rename {Volatility => src/Volatility.Cli}/CLI/Commands/NullCommand.cs (75%) rename {Volatility => src/Volatility.Cli}/CLI/Commands/PortTextureCommand.cs (97%) rename {Volatility => src/Volatility.Cli}/CLI/Commands/TextureToDDSCommand.cs (95%) rename {Volatility => src/Volatility.Cli}/CLI/ConsoleMessageSink.cs (100%) rename {Volatility => src/Volatility.Cli}/CLI/ICommand.cs (71%) create mode 100644 src/Volatility.Cli/GlobalUsings.cs rename Volatility/Frontend.cs => src/Volatility.Cli/Program.cs (99%) create mode 100644 src/Volatility.Cli/Volatility.Cli.csproj rename {Volatility => src/Volatility.Cli}/tools/dxc/README.txt (100%) rename {Volatility => src/Volatility.Cli}/volatility_icon.ico (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Messaging/IMessageBus.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Messaging/IMessageSink.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Messaging/MessageCategory.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Messaging/MessageSeverity.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Messaging/VolatilityLog.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Messaging/VolatilityMessage.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Operations/IOperation.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Operations/IOperationRequest.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Operations/OperationIssue.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Operations/OperationProgress.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Operations/OperationResult.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Services/IPathProvider.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Services/IProcessRunner.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Services/IResourceDBLookup.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Services/IResourceFactory.cs (52%) create mode 100644 src/Volatility.Core/Abstractions/Services/IResourceSerializer.cs rename {Volatility => src/Volatility.Core}/Abstractions/Services/IShaderCompiler.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Services/IShaderSourceStore.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Services/ISplicerSampleStore.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Services/IStringTableStore.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Services/ITextureBitmapStore.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Services/VolatilityFilePathFilter.cs (100%) rename {Volatility => src/Volatility.Core}/Abstractions/Services/VolatilityPathLocation.cs (100%) create mode 100644 src/Volatility.Core/AssemblyInfo.cs rename {Volatility => src/Volatility.Core}/Attributes/EditorCategoryAttribute.cs (100%) rename {Volatility => src/Volatility.Core}/Attributes/EditorHiddenAttribute.cs (100%) rename {Volatility => src/Volatility.Core}/Attributes/EditorLabelAttribute.cs (100%) rename {Volatility => src/Volatility.Core}/Attributes/EditorReadOnlyAttribute.cs (100%) rename {Volatility => src/Volatility.Core}/Attributes/EditorTooltipAttribute.cs (100%) rename {Volatility => src/Volatility.Core}/Endian.cs (100%) rename {Volatility => src/Volatility.Core}/Exceptions/InvalidPlatformException.cs (81%) rename {Volatility => src/Volatility.Core}/Hosting/VolatilityServiceCollectionExtensions.cs (93%) rename {Volatility => src/Volatility.Core}/Messaging/MessageBus.cs (100%) rename {Volatility => src/Volatility.Core}/Messaging/VolatilityMessageHost.cs (100%) rename {Volatility => src/Volatility.Core}/Operations/Autotest/GameAutotestOperation.cs (99%) rename {Volatility => src/Volatility.Core}/Operations/OperationResultFactory.cs (100%) rename {Volatility => src/Volatility.Core}/Operations/Resources/CreateResourceOperation.cs (100%) rename {Volatility => src/Volatility.Core}/Operations/Resources/CreateShaderProgramBufferOperation.cs (100%) rename {Volatility => src/Volatility.Core}/Operations/Resources/ExportResourceOperation.cs (100%) rename {Volatility => src/Volatility.Core}/Operations/Resources/ImportResourceOperation.cs (91%) rename {Volatility => src/Volatility.Core}/Operations/Resources/LoadResourceOperation.cs (100%) create mode 100644 src/Volatility.Core/Operations/Resources/PortTextureOperation.cs rename {Volatility => src/Volatility.Core}/Operations/Resources/SaveResourceOperation.cs (100%) create mode 100644 src/Volatility.Core/Operations/Resources/TextureFormatConverter.cs create mode 100644 src/Volatility.Core/Operations/Resources/TextureRoundTripOperation.cs rename {Volatility => src/Volatility.Core}/Operations/Resources/TextureToDDSOperation.cs (92%) rename {Volatility => src/Volatility.Core}/Operations/StringTables/ImportStringTableOperation.cs (100%) rename {Volatility => src/Volatility.Core}/Operations/StringTables/LoadResourceDictionaryOperation.cs (100%) rename {Volatility => src/Volatility.Core}/Operations/StringTables/MergeStringTableEntriesOperation.cs (100%) rename {Volatility => src/Volatility.Core}/Operations/StringTables/StringTableResourceEntry.cs (100%) rename {Volatility => src/Volatility.Core}/Resources/AptData/AptData.cs (91%) rename {Volatility => src/Volatility.Core}/Resources/AttribSysVault/AttribSysVault.cs (99%) rename {Volatility => src/Volatility.Core}/Resources/BinaryResource.cs (88%) rename {Volatility => src/Volatility.Core}/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs (99%) rename {Volatility => src/Volatility.Core}/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs (98%) rename {Volatility => src/Volatility.Core}/Resources/GuiPopup/GuiPopup.cs (98%) rename {Volatility => src/Volatility.Core}/Resources/InstanceList/InstanceList.cs (98%) rename {Volatility => src/Volatility.Core}/Resources/Model/Model.cs (98%) rename {Volatility => src/Volatility.Core}/Resources/Renderable/RenderableBPR.cs (93%) rename {Volatility => src/Volatility.Core}/Resources/Renderable/RenderableBase.cs (92%) rename {Volatility => src/Volatility.Core}/Resources/Renderable/RenderablePC.cs (82%) rename {Volatility => src/Volatility.Core}/Resources/Renderable/RenderablePS3.cs (64%) rename {Volatility => src/Volatility.Core}/Resources/Renderable/RenderableX360.cs (65%) rename {Volatility => src/Volatility.Core}/Resources/Resource.cs (79%) rename {Volatility => src/Volatility.Core}/Resources/ResourceFactory.cs (81%) rename {Volatility => src/Volatility.Core}/Resources/ResourceImport.cs (100%) rename {Volatility => src/Volatility.Core}/Resources/ResourceMetadata.cs (100%) create mode 100644 src/Volatility.Core/Resources/ResourceSerializationOptions.cs rename {Volatility => src/Volatility.Core}/Resources/Shader/ShaderBase.cs (97%) rename {Volatility => src/Volatility.Core}/Resources/Shader/ShaderPC.cs (94%) rename {Volatility => src/Volatility.Core}/Resources/ShaderProgramBuffer/ShaderProgramBufferBPR.cs (98%) rename {Volatility => src/Volatility.Core}/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs (81%) rename {Volatility => src/Volatility.Core}/Resources/SnapshotData/SnapshotData.cs (97%) rename {Volatility => src/Volatility.Core}/Resources/Splicer/Splicer.cs (99%) rename {Volatility => src/Volatility.Core}/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs (99%) rename {Volatility => src/Volatility.Core}/Resources/Texture/TextureBPR.cs (98%) rename {Volatility => src/Volatility.Core}/Resources/Texture/TextureBase.cs (96%) rename {Volatility => src/Volatility.Core}/Resources/Texture/TexturePC.cs (98%) rename {Volatility => src/Volatility.Core}/Resources/Texture/TexturePS3.cs (97%) rename {Volatility => src/Volatility.Core}/Resources/Texture/TextureX360.cs (99%) rename {Volatility => src/Volatility.Core}/Services/DefaultProcessRunner.cs (100%) rename {Volatility => src/Volatility.Core}/Services/DefaultResourceFactory.cs (53%) create mode 100644 src/Volatility.Core/Services/DefaultResourceSerializer.cs rename {Volatility => src/Volatility.Core}/Services/DefaultShaderCompiler.cs (100%) rename {Volatility => src/Volatility.Core}/Services/EnvironmentPathProvider.cs (100%) rename {Volatility => src/Volatility.Core}/Services/FileResourceDBLookup.cs (100%) rename {Volatility => src/Volatility.Core}/Services/FileShaderSourceStore.cs (100%) rename {Volatility => src/Volatility.Core}/Services/FileSplicerSampleStore.cs (100%) rename {Volatility => src/Volatility.Core}/Services/FileStringTableStore.cs (100%) rename {Volatility => src/Volatility.Core}/Services/FileTextureBitmapStore.cs (100%) rename {Volatility => src/Volatility.Core}/StrongID.cs (100%) rename {Volatility => src/Volatility.Core}/Types.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/BitReader.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/CgsIDUtilities.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/DDSTextureUtilities.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/DataUtilities.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/DictUtilities.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/DxbcReflectionParser.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/EndianAwareBinaryReader.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/EndianAwareBinaryWriter.cs (79%) rename {Volatility => src/Volatility.Core}/Utilities/EndianUtilities.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/MatrixUtilities.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/PS3TextureUtilities.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/PaddingUtilities.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/ResourceBinaryReader.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/ResourceBinaryWriter.cs (97%) rename {Volatility => src/Volatility.Core}/Utilities/ResourceIDUtilities.cs (100%) create mode 100644 src/Volatility.Core/Utilities/ResourcePropertyComparer.cs rename {Volatility => src/Volatility.Core}/Utilities/ResourceUtilities.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/TypeUtilities.cs (79%) rename {Volatility => src/Volatility.Core}/Utilities/X360TextureUtilities.cs (97%) rename {Volatility => src/Volatility.Core}/Utilities/YAML/BitArrayYamlTypeConverter.cs (100%) rename {Volatility => src/Volatility.Core}/Utilities/YAML/FieldDescriptor.cs (84%) rename {Volatility => src/Volatility.Core}/Utilities/YAML/IncludeFieldsTypeInspector.cs (93%) rename {Volatility => src/Volatility.Core}/Utilities/YAML/ResourceYamlDeserializer.cs (72%) rename {Volatility => src/Volatility.Core}/Utilities/YAML/ResourceYamlTypeConverter.cs (51%) rename {Volatility => src/Volatility.Core}/Utilities/YAML/StringEnumYamlTypeConverter.cs (72%) rename {Volatility => src/Volatility.Core}/Utilities/YAML/StrongIDYamlTypeConverter.cs (87%) create mode 100644 src/Volatility.Core/Volatility.Core.csproj create mode 100644 tests/Volatility.Core.Tests/GlobalUsings.cs create mode 100644 tests/Volatility.Core.Tests/OperationIntegrationTests.cs create mode 100644 tests/Volatility.Core.Tests/Volatility.Core.Tests.csproj 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 index 7cbfd26..641fc83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,27 +4,35 @@ 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. Single .NET 9.0 console project. +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/Volatility.csproj -dotnet run --project Volatility/Volatility.csproj # interactive REPL -dotnet run --project Volatility/Volatility.csproj -- # one-shot +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 Volatility/Volatility.csproj +dotnet publish --configuration Release --runtime win-x64 --self-contained true src/Volatility.Cli/Volatility.Cli.csproj ``` -The csproj sets `PublishSingleFile`, `PublishTrimmed`, and copies `tools/dxc/**` to the output — the DXC tree must be present for shader operations after publish. +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 -There is no unit-test framework in this repo. Correctness is verified by the built-in `autotest` command, which has two modes: +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). @@ -33,8 +41,8 @@ When changing a resource's read/write path, the game-mode autotest on a real Bur ## Architecture -### Command dispatch ([Frontend.cs](Volatility/Frontend.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 in [TypeUtilities](Volatility/Utilities/TypeUtilities.cs). **Adding a new command requires registering it in `Frontend.Commands` — there is no auto-discovery.** +### 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. 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/Operations/Resources/PortTextureOperation.cs b/Volatility/Operations/Resources/PortTextureOperation.cs deleted file mode 100644 index 0979e73..0000000 --- a/Volatility/Operations/Resources/PortTextureOperation.cs +++ /dev/null @@ -1,612 +0,0 @@ -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, - 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; - - 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) - LogWarning($"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) - LogWarning($"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) - LogWarning($"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) - LogWarning($"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) - LogWarning($"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) - LogWarning($"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) - LogWarning($"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) - LogWarning($"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) - LogWarning($"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) - LogWarning($"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) - LogWarning($"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) - LogWarning($"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 (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 (!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..."); - return (TextureBase)resourceFactory.LoadResource(ResourceType.Texture, format.Platform, path, resourceDBLookup, 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 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 }, - }; - - 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/Volatility/Volatility.csproj b/Volatility/Volatility.csproj deleted file mode 100644 index c154eda..0000000 --- a/Volatility/Volatility.csproj +++ /dev/null @@ -1,48 +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/Volatility/CLI/CLIMessageUtilities.cs b/src/Volatility.Cli/CLI/CLIMessageUtilities.cs similarity index 100% rename from Volatility/CLI/CLIMessageUtilities.cs rename to src/Volatility.Cli/CLI/CLIMessageUtilities.cs diff --git a/Volatility/CLI/Commands/AutotestCommand.cs b/src/Volatility.Cli/CLI/Commands/AutotestCommand.cs similarity index 76% rename from Volatility/CLI/Commands/AutotestCommand.cs rename to src/Volatility.Cli/CLI/Commands/AutotestCommand.cs index 52661d0..6c4cc10 100644 --- a/Volatility/CLI/Commands/AutotestCommand.cs +++ b/src/Volatility.Cli/CLI/Commands/AutotestCommand.cs @@ -5,8 +5,10 @@ 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; @@ -18,6 +20,8 @@ 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." + @@ -57,7 +61,7 @@ public async Task Execute() CLIMessageUtilities.PublishIssues(result.Issues); if (!result.Success || result.Value == null) { - throw OperationResultFactory.CreateException(result, "Game autotest failed."); + throw OperationResultFactory.CreateException(result, "Game autotest failed to execute."); } GameAutotestSummary summary = result.Value; @@ -73,24 +77,44 @@ public async Task Execute() $"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)) { - if (!TryParseEnum(Format, out Platform platform)) + if (string.IsNullOrEmpty(Format) || !TryParseEnum(Format, out Platform platform)) { throw new InvalidPlatformException(); } string inputPath = pathProvider.GetFullPath(Path); - TextureBase header = (TextureBase)ResourceFactory.LoadResource( - ResourceType.Texture, - platform, - inputPath, - resourceDBLookup: null); + 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(inputPath)}", 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; } @@ -101,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() { @@ -114,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() @@ -131,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() @@ -152,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() @@ -174,18 +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"; 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 : ""; @@ -207,99 +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(); - } + OperationResult opResult = await textureRoundTripOperation.ExecuteAsync( + new TextureRoundTripRequest(name, header, skipImport), + progress: null, + cancellationToken: CancellationToken.None); - 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 = (TextureBase)ResourceFactory.LoadResource( - ResourceType.Texture, - header.ResourcePlatform, - fs.Name, - resourceDBLookup: null, - x64: header.ResourceArch == Arch.x64); - - 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() @@ -464,9 +453,13 @@ private static string EscapeMarkdownCell(string value) public AutotestCommand( IPathProvider pathProvider, - IOperation gameAutotestOperation) + 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/Volatility/CLI/Commands/CreateResourceCommand.cs b/src/Volatility.Cli/CLI/Commands/CreateResourceCommand.cs similarity index 100% rename from Volatility/CLI/Commands/CreateResourceCommand.cs rename to src/Volatility.Cli/CLI/Commands/CreateResourceCommand.cs diff --git a/Volatility/CLI/Commands/ExitCommand.cs b/src/Volatility.Cli/CLI/Commands/ExitCommand.cs similarity index 88% rename from Volatility/CLI/Commands/ExitCommand.cs rename to src/Volatility.Cli/CLI/Commands/ExitCommand.cs index 48ee166..8b57a01 100644 --- a/Volatility/CLI/Commands/ExitCommand.cs +++ b/src/Volatility.Cli/CLI/Commands/ExitCommand.cs @@ -8,10 +8,11 @@ internal class ExitCommand : ICommand public static string CommandDescription => "Exits the application."; public static string CommandParameters => ""; - public async Task Execute() + public Task Execute() { CLIMessageUtilities.Info("Exiting Volatility..."); Environment.Exit(0); + return Task.CompletedTask; } public void SetArgs(Dictionary args) { } diff --git a/Volatility/CLI/Commands/ExportResourceCommand.cs b/src/Volatility.Cli/CLI/Commands/ExportResourceCommand.cs similarity index 100% rename from Volatility/CLI/Commands/ExportResourceCommand.cs rename to src/Volatility.Cli/CLI/Commands/ExportResourceCommand.cs diff --git a/Volatility/CLI/Commands/HelpCommand.cs b/src/Volatility.Cli/CLI/Commands/HelpCommand.cs similarity index 68% rename from Volatility/CLI/Commands/HelpCommand.cs rename to src/Volatility.Cli/CLI/Commands/HelpCommand.cs index 1b6d143..a470455 100644 --- a/Volatility/CLI/Commands/HelpCommand.cs +++ b/src/Volatility.Cli/CLI/Commands/HelpCommand.cs @@ -13,7 +13,7 @@ internal class HelpCommand : ICommand public string? WantedCommand { get; set; } - public async Task Execute() + public Task Execute() { if (!string.IsNullOrEmpty(WantedCommand)) { @@ -21,22 +21,22 @@ public async Task Execute() if (command != null) { ShowUsage(command); - return; + return Task.CompletedTask; } } CLIMessageUtilities.Info("Available commands:"); - foreach (var command in GetDerivedTypes(typeof(ICommand))) + foreach (var command in Frontend.Commands.Values.Distinct()) { - string commandName = GetStaticPropertyValue(command, nameof(CommandToken)); + string? commandName = GetStaticPropertyValue(command, "CommandToken"); if (string.IsNullOrEmpty(commandName)) continue; - CLIMessageUtilities.Info($" {commandName} - {GetStaticPropertyValue(command, nameof(CommandDescription))}"); - + CLIMessageUtilities.Info($" {commandName} - {GetStaticPropertyValue(command, "CommandDescription")}"); } CLIMessageUtilities.Info("For information on command arguments, run: help ."); + return Task.CompletedTask; } public void SetArgs(Dictionary args) @@ -46,11 +46,11 @@ public void SetArgs(Dictionary args) private static void ShowUsage(Type commandType) { - string token = GetStaticPropertyValue(commandType, nameof(CommandToken)); - string parameters = GetStaticPropertyValue(commandType, nameof(CommandParameters)); - string description = GetStaticPropertyValue(commandType, nameof(CommandDescription)); + 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}"); + CLIMessageUtilities.Info(nameof(HelpCommand), $"Usage:\n {token ?? ""} {parameters ?? ""}\n{description ?? ""}"); } public HelpCommand() { } diff --git a/Volatility/CLI/Commands/ImportResourceCommand.cs b/src/Volatility.Cli/CLI/Commands/ImportResourceCommand.cs similarity index 98% rename from Volatility/CLI/Commands/ImportResourceCommand.cs rename to src/Volatility.Cli/CLI/Commands/ImportResourceCommand.cs index 7688fc0..35c3da5 100644 --- a/Volatility/CLI/Commands/ImportResourceCommand.cs +++ b/src/Volatility.Cli/CLI/Commands/ImportResourceCommand.cs @@ -63,7 +63,7 @@ public async Task Execute() throw new InvalidPlatformException("Error: Invalid file format specified!"); } - if (!TypeUtilities.TryParseEnum(ResType, out ResourceType resType)) + if (string.IsNullOrEmpty(ResType) || !TypeUtilities.TryParseEnum(ResType, out ResourceType resType)) { CLIMessageUtilities.Error("Error: Invalid resource type specified!"); return; diff --git a/Volatility/CLI/Commands/ImportStringTableCommand.cs b/src/Volatility.Cli/CLI/Commands/ImportStringTableCommand.cs similarity index 100% rename from Volatility/CLI/Commands/ImportStringTableCommand.cs rename to src/Volatility.Cli/CLI/Commands/ImportStringTableCommand.cs 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/Volatility/CLI/Commands/PortTextureCommand.cs b/src/Volatility.Cli/CLI/Commands/PortTextureCommand.cs similarity index 97% rename from Volatility/CLI/Commands/PortTextureCommand.cs rename to src/Volatility.Cli/CLI/Commands/PortTextureCommand.cs index 2c7ed13..c2cb283 100644 --- a/Volatility/CLI/Commands/PortTextureCommand.cs +++ b/src/Volatility.Cli/CLI/Commands/PortTextureCommand.cs @@ -63,13 +63,13 @@ public async Task Execute() 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(); + : 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(); + : 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; diff --git a/Volatility/CLI/Commands/TextureToDDSCommand.cs b/src/Volatility.Cli/CLI/Commands/TextureToDDSCommand.cs similarity index 95% rename from Volatility/CLI/Commands/TextureToDDSCommand.cs rename to src/Volatility.Cli/CLI/Commands/TextureToDDSCommand.cs index 3c07d02..1a386a8 100644 --- a/Volatility/CLI/Commands/TextureToDDSCommand.cs +++ b/src/Volatility.Cli/CLI/Commands/TextureToDDSCommand.cs @@ -1,6 +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; @@ -65,6 +66,10 @@ public async Task Execute() 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) 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/Volatility/CLI/ICommand.cs b/src/Volatility.Cli/CLI/ICommand.cs similarity index 71% rename from Volatility/CLI/ICommand.cs rename to src/Volatility.Cli/CLI/ICommand.cs index 89a61e9..eed5652 100644 --- a/Volatility/CLI/ICommand.cs +++ b/src/Volatility.Cli/CLI/ICommand.cs @@ -5,13 +5,13 @@ namespace Volatility; internal interface ICommand { - static string CommandToken { get; } - static string CommandDescription { get; } - static string CommandParameters { get; } + static abstract string CommandToken { get; } + static abstract string CommandDescription { get; } + static abstract string CommandParameters { get; } - async Task Execute() { } + Task Execute() => Task.CompletedTask; void SetArgs(Dictionary args); - public void ShowUsage() + public void ShowUsage() { Type thisType = GetType(); 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 99% rename from Volatility/Frontend.cs rename to src/Volatility.Cli/Program.cs index 9327dea..e64a0b9 100644 --- a/Volatility/Frontend.cs +++ b/src/Volatility.Cli/Program.cs @@ -185,6 +185,7 @@ static void RunCommandTokenized(string[] input) catch (Exception ex) { VolatilityMessageHost.Sink.Error($"Error: {ex.Message}", MessageCategory.CLI, nameof(Frontend)); + Environment.ExitCode = 1; #if DEBUG throw; #endif 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 100% rename from Volatility/Abstractions/Messaging/MessageCategory.cs rename to src/Volatility.Core/Abstractions/Messaging/MessageCategory.cs 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 100% rename from Volatility/Abstractions/Messaging/VolatilityLog.cs rename to src/Volatility.Core/Abstractions/Messaging/VolatilityLog.cs 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/Volatility/Abstractions/Services/IPathProvider.cs b/src/Volatility.Core/Abstractions/Services/IPathProvider.cs similarity index 100% rename from Volatility/Abstractions/Services/IPathProvider.cs rename to src/Volatility.Core/Abstractions/Services/IPathProvider.cs 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/Volatility/Abstractions/Services/IResourceDBLookup.cs b/src/Volatility.Core/Abstractions/Services/IResourceDBLookup.cs similarity index 100% rename from Volatility/Abstractions/Services/IResourceDBLookup.cs rename to src/Volatility.Core/Abstractions/Services/IResourceDBLookup.cs diff --git a/Volatility/Abstractions/Services/IResourceFactory.cs b/src/Volatility.Core/Abstractions/Services/IResourceFactory.cs similarity index 52% rename from Volatility/Abstractions/Services/IResourceFactory.cs rename to src/Volatility.Core/Abstractions/Services/IResourceFactory.cs index 182deb7..7d2346c 100644 --- a/Volatility/Abstractions/Services/IResourceFactory.cs +++ b/src/Volatility.Core/Abstractions/Services/IResourceFactory.cs @@ -5,11 +5,4 @@ namespace Volatility.Abstractions.Services; public interface IResourceFactory { Resource CreateResource(ResourceType resourceType, Platform platform, bool x64 = false); - - Resource LoadResource( - ResourceType resourceType, - Platform platform, - string filePath, - IResourceDBLookup? resourceDBLookup, - 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/Volatility/Abstractions/Services/IShaderSourceStore.cs b/src/Volatility.Core/Abstractions/Services/IShaderSourceStore.cs similarity index 100% rename from Volatility/Abstractions/Services/IShaderSourceStore.cs rename to src/Volatility.Core/Abstractions/Services/IShaderSourceStore.cs diff --git a/Volatility/Abstractions/Services/ISplicerSampleStore.cs b/src/Volatility.Core/Abstractions/Services/ISplicerSampleStore.cs similarity index 100% rename from Volatility/Abstractions/Services/ISplicerSampleStore.cs rename to src/Volatility.Core/Abstractions/Services/ISplicerSampleStore.cs diff --git a/Volatility/Abstractions/Services/IStringTableStore.cs b/src/Volatility.Core/Abstractions/Services/IStringTableStore.cs similarity index 100% rename from Volatility/Abstractions/Services/IStringTableStore.cs rename to src/Volatility.Core/Abstractions/Services/IStringTableStore.cs diff --git a/Volatility/Abstractions/Services/ITextureBitmapStore.cs b/src/Volatility.Core/Abstractions/Services/ITextureBitmapStore.cs similarity index 100% rename from Volatility/Abstractions/Services/ITextureBitmapStore.cs rename to src/Volatility.Core/Abstractions/Services/ITextureBitmapStore.cs diff --git a/Volatility/Abstractions/Services/VolatilityFilePathFilter.cs b/src/Volatility.Core/Abstractions/Services/VolatilityFilePathFilter.cs similarity index 100% rename from Volatility/Abstractions/Services/VolatilityFilePathFilter.cs rename to src/Volatility.Core/Abstractions/Services/VolatilityFilePathFilter.cs 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/Volatility/Hosting/VolatilityServiceCollectionExtensions.cs b/src/Volatility.Core/Hosting/VolatilityServiceCollectionExtensions.cs similarity index 93% rename from Volatility/Hosting/VolatilityServiceCollectionExtensions.cs rename to src/Volatility.Core/Hosting/VolatilityServiceCollectionExtensions.cs index f44dda4..17b1e0a 100644 --- a/Volatility/Hosting/VolatilityServiceCollectionExtensions.cs +++ b/src/Volatility.Core/Hosting/VolatilityServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ public static IServiceCollection AddVolatilityCore(this IServiceCollection servi services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -41,6 +42,7 @@ public static IServiceCollection AddVolatilityCore(this IServiceCollection servi 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 99% rename from Volatility/Operations/Autotest/GameAutotestOperation.cs rename to src/Volatility.Core/Operations/Autotest/GameAutotestOperation.cs index 5b8cf06..224475c 100644 --- a/Volatility/Operations/Autotest/GameAutotestOperation.cs +++ b/src/Volatility.Core/Operations/Autotest/GameAutotestOperation.cs @@ -294,6 +294,7 @@ await ExportAsync(new ExportResourceRequest( loaded, exportPath, game.Platform, + WriteImportsToSeparateFile: true, Overwrite: true, SplicerDirectory: splicerPass1), cancellationToken); @@ -951,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) || @@ -1278,6 +1279,11 @@ private GameInstall DetectGame(string gamePath) 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); } diff --git a/Volatility/Operations/OperationResultFactory.cs b/src/Volatility.Core/Operations/OperationResultFactory.cs similarity index 100% rename from Volatility/Operations/OperationResultFactory.cs rename to src/Volatility.Core/Operations/OperationResultFactory.cs diff --git a/Volatility/Operations/Resources/CreateResourceOperation.cs b/src/Volatility.Core/Operations/Resources/CreateResourceOperation.cs similarity index 100% rename from Volatility/Operations/Resources/CreateResourceOperation.cs rename to src/Volatility.Core/Operations/Resources/CreateResourceOperation.cs diff --git a/Volatility/Operations/Resources/CreateShaderProgramBufferOperation.cs b/src/Volatility.Core/Operations/Resources/CreateShaderProgramBufferOperation.cs similarity index 100% rename from Volatility/Operations/Resources/CreateShaderProgramBufferOperation.cs rename to src/Volatility.Core/Operations/Resources/CreateShaderProgramBufferOperation.cs diff --git a/Volatility/Operations/Resources/ExportResourceOperation.cs b/src/Volatility.Core/Operations/Resources/ExportResourceOperation.cs similarity index 100% rename from Volatility/Operations/Resources/ExportResourceOperation.cs rename to src/Volatility.Core/Operations/Resources/ExportResourceOperation.cs diff --git a/Volatility/Operations/Resources/ImportResourceOperation.cs b/src/Volatility.Core/Operations/Resources/ImportResourceOperation.cs similarity index 91% rename from Volatility/Operations/Resources/ImportResourceOperation.cs rename to src/Volatility.Core/Operations/Resources/ImportResourceOperation.cs index fcd133b..089253e 100644 --- a/Volatility/Operations/Resources/ImportResourceOperation.cs +++ b/src/Volatility.Core/Operations/Resources/ImportResourceOperation.cs @@ -8,7 +8,7 @@ namespace Volatility.Operations.Resources; internal sealed partial class ImportResourceOperation( - IResourceFactory resourceFactory, + IResourceSerializer resourceSerializer, IResourceDBLookup resourceDBLookup, ITextureBitmapStore textureBitmapStore, IProcessRunner processRunner, @@ -25,12 +25,20 @@ public async Task> ExecuteAsync( try { - Resource resource = resourceFactory.LoadResource( - request.ResourceType, - request.Platform, - request.SourceFile, - resourceDBLookup, - request.IsX64); + 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 ( diff --git a/Volatility/Operations/Resources/LoadResourceOperation.cs b/src/Volatility.Core/Operations/Resources/LoadResourceOperation.cs similarity index 100% rename from Volatility/Operations/Resources/LoadResourceOperation.cs rename to src/Volatility.Core/Operations/Resources/LoadResourceOperation.cs 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/Volatility/Operations/Resources/SaveResourceOperation.cs b/src/Volatility.Core/Operations/Resources/SaveResourceOperation.cs similarity index 100% rename from Volatility/Operations/Resources/SaveResourceOperation.cs rename to src/Volatility.Core/Operations/Resources/SaveResourceOperation.cs 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/Volatility/Operations/Resources/TextureToDDSOperation.cs b/src/Volatility.Core/Operations/Resources/TextureToDDSOperation.cs similarity index 92% rename from Volatility/Operations/Resources/TextureToDDSOperation.cs rename to src/Volatility.Core/Operations/Resources/TextureToDDSOperation.cs index 46dc69e..56f6d85 100644 --- a/Volatility/Operations/Resources/TextureToDDSOperation.cs +++ b/src/Volatility.Core/Operations/Resources/TextureToDDSOperation.cs @@ -8,7 +8,7 @@ namespace Volatility.Operations.Resources; internal sealed class TextureToDDSOperation( - IResourceFactory resourceFactory, + IResourceSerializer resourceSerializer, IResourceDBLookup resourceDBLookup, ITextureBitmapStore textureBitmapStore, IMessageSink messageSink) @@ -62,12 +62,17 @@ private async Task ConvertFileAsync( { cancellationToken.ThrowIfCancellationRequested(); - TextureBase texture = (TextureBase)resourceFactory.LoadResource( + using FileStream stream = File.OpenRead(sourceFile); + TextureBase texture = (TextureBase)resourceSerializer.Deserialize( + stream, ResourceType.Texture, request.Platform, - sourceFile, - resourceDBLookup, - request.IsX64); + new ResourceSerializationOptions + { + FileName = sourceFile, + ResourceDBLookup = resourceDBLookup, + x64 = request.IsX64 + }); string sourceBitmapPath = textureBitmapStore.GetSecondaryBitmapPath(sourceFile, texture.Unpacker); diff --git a/Volatility/Operations/StringTables/ImportStringTableOperation.cs b/src/Volatility.Core/Operations/StringTables/ImportStringTableOperation.cs similarity index 100% rename from Volatility/Operations/StringTables/ImportStringTableOperation.cs rename to src/Volatility.Core/Operations/StringTables/ImportStringTableOperation.cs diff --git a/Volatility/Operations/StringTables/LoadResourceDictionaryOperation.cs b/src/Volatility.Core/Operations/StringTables/LoadResourceDictionaryOperation.cs similarity index 100% rename from Volatility/Operations/StringTables/LoadResourceDictionaryOperation.cs rename to src/Volatility.Core/Operations/StringTables/LoadResourceDictionaryOperation.cs diff --git a/Volatility/Operations/StringTables/MergeStringTableEntriesOperation.cs b/src/Volatility.Core/Operations/StringTables/MergeStringTableEntriesOperation.cs similarity index 100% rename from Volatility/Operations/StringTables/MergeStringTableEntriesOperation.cs rename to src/Volatility.Core/Operations/StringTables/MergeStringTableEntriesOperation.cs diff --git a/Volatility/Operations/StringTables/StringTableResourceEntry.cs b/src/Volatility.Core/Operations/StringTables/StringTableResourceEntry.cs similarity index 100% rename from Volatility/Operations/StringTables/StringTableResourceEntry.cs rename to src/Volatility.Core/Operations/StringTables/StringTableResourceEntry.cs 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 98% rename from Volatility/Resources/GuiPopup/GuiPopup.cs rename to src/Volatility.Core/Resources/GuiPopup/GuiPopup.cs index 914955e..7131294 100644 --- a/Volatility/Resources/GuiPopup/GuiPopup.cs +++ b/src/Volatility.Core/Resources/GuiPopup/GuiPopup.cs @@ -118,9 +118,6 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann 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 98% rename from Volatility/Resources/Model/Model.cs rename to src/Volatility.Core/Resources/Model/Model.cs index 8972165..bd9eb5e 100644 --- a/Volatility/Resources/Model/Model.cs +++ b/src/Volatility.Core/Resources/Model/Model.cs @@ -116,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 96a384f..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 @@ -21,8 +21,6 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann } public RenderableBPR() : base() { } - - public RenderableBPR(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } 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 9ce814e..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 @@ -21,6 +21,4 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann } public RenderablePC() : base() { } - - public RenderablePC(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } diff --git a/Volatility/Resources/Renderable/RenderablePS3.cs b/src/Volatility.Core/Resources/Renderable/RenderablePS3.cs similarity index 64% rename from Volatility/Resources/Renderable/RenderablePS3.cs rename to src/Volatility.Core/Resources/Renderable/RenderablePS3.cs index d73e492..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 @@ -7,6 +7,4 @@ public class RenderablePS3 : RenderableBase public override Platform ResourcePlatform => Platform.PS3; public RenderablePS3() : base() { } - - public RenderablePS3(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } diff --git a/Volatility/Resources/Renderable/RenderableX360.cs b/src/Volatility.Core/Resources/Renderable/RenderableX360.cs similarity index 65% rename from Volatility/Resources/Renderable/RenderableX360.cs rename to src/Volatility.Core/Resources/Renderable/RenderableX360.cs index 29c61e9..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 @@ -7,6 +7,4 @@ public class RenderableX360 : RenderableBase public override Platform ResourcePlatform => Platform.X360; public RenderableX360() : base() { } - - public RenderableX360(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } diff --git a/Volatility/Resources/Resource.cs b/src/Volatility.Core/Resources/Resource.cs similarity index 79% rename from Volatility/Resources/Resource.cs rename to src/Volatility.Core/Resources/Resource.cs index 4b62afc..3cb895f 100644 --- a/Volatility/Resources/Resource.cs +++ b/src/Volatility.Core/Resources/Resource.cs @@ -48,71 +48,64 @@ public virtual void ParseFromStream(ResourceBinaryReader reader, Endian endianne public Resource() { } - public Resource(string path, Endian endianness = Endian.Agnostic) - { - LoadFromPath(path, endianness); - } - - internal void LoadFromPath( - string path, + internal void LoadFromStream( + Stream stream, Endian endianness = Endian.Agnostic, + string? filename = null, IResourceDBLookup? resourceDBLookup = null) { - if (string.IsNullOrEmpty(path)) - return; - - ImportedFileName = path; - - if (new DirectoryInfo(path).Exists) - return; - - string? name = Path.GetFileNameWithoutExtension(ImportedFileName); Endian importEndianness = ResourceEndian != Endian.Agnostic ? ResourceEndian : endianness; - if (!string.IsNullOrEmpty(name)) + if (!string.IsNullOrEmpty(filename)) { - 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 - }; - } - - if (ValidateResourceID(name)) - { - ResourceID = Convert.ToUInt64( - importEndianness == Endian.LE && Unpacker != Unpacker.YAP - ? FlipResourceIDEndian(name) - : name, - 16); + ImportedFileName = filename; + string? name = Path.GetFileNameWithoutExtension(ImportedFileName); - string resolvedName = resourceDBLookup?.GetNameByResourceId(ResourceID) ?? string.Empty; - AssetName = !string.IsNullOrEmpty(resolvedName) - ? resolvedName - : ResourceID.ToString(); - } - else + if (!string.IsNullOrEmpty(name)) { - AssetName = 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 + }; + } - if (TryParseResourceID(name, out ResourceID parsedResourceId)) + if (ValidateResourceID(name)) { - ResourceID = parsedResourceId; + ResourceID = Convert.ToUInt64( + importEndianness == Endian.LE && Unpacker != Unpacker.YAP + ? FlipResourceIDEndian(name) + : name, + 16); + + string resolvedName = resourceDBLookup?.GetNameByResourceId(ResourceID) ?? string.Empty; + AssetName = !string.IsNullOrEmpty(resolvedName) + ? resolvedName + : ResourceID.ToString(); } else { - ResourceID = ResourceID.HashFromString(name); + AssetName = name; + + if (TryParseResourceID(name, out ResourceID parsedResourceId)) + { + ResourceID = parsedResourceId; + } + else + { + ResourceID = ResourceID.HashFromString(name); + } } } } - using ResourceBinaryReader reader = new(new FileStream(path, FileMode.Open), importEndianness); + using ResourceBinaryReader reader = new(stream, importEndianness); ParseFromStream(reader, importEndianness); } diff --git a/Volatility/Resources/ResourceFactory.cs b/src/Volatility.Core/Resources/ResourceFactory.cs similarity index 81% rename from Volatility/Resources/ResourceFactory.cs rename to src/Volatility.Core/Resources/ResourceFactory.cs index 4d503ae..564a77e 100644 --- a/Volatility/Resources/ResourceFactory.cs +++ b/src/Volatility.Core/Resources/ResourceFactory.cs @@ -22,25 +22,7 @@ public static Resource CreateResource(ResourceType resourceType, Platform platfo return resource; } - public static Resource LoadResource( - ResourceType resourceType, - Platform platform, - string filePath, - IResourceDBLookup? resourceDBLookup, - bool x64 = false) - { - ResourceRegistrationInfo registration = ResolveRegistration(resourceType, platform); - Resource resource = registration.Activator(); - ApplyArchOption(resource, x64); - resource.LoadFromPath(filePath, ResolveLoadEndianness(resource, platform, registration.EndianMapped), resourceDBLookup); - if (registration.PullAll) - { - resource.PullAll(); - } - - return resource; - } private static Dictionary<(ResourceType, Platform), ResourceRegistrationInfo> CreateResourceCreators() { @@ -74,7 +56,7 @@ public static Resource LoadResource( private static ResourceRegistrationInfo ResolveRegistration(ResourceType resourceType, Platform platform) { - if (resourceCreators.TryGetValue((resourceType, platform), out ResourceRegistrationInfo registration)) + if (resourceCreators.TryGetValue((resourceType, platform), out ResourceRegistrationInfo? registration)) { return registration; } @@ -90,25 +72,7 @@ private static void ApplyArchOption(Resource resource, bool x64) } } - private static Endian ResolveLoadEndianness(Resource resource, Platform platform, bool endianMapped) - { - if (resource.ResourceEndian != Endian.Agnostic) - { - return resource.ResourceEndian; - } - if (!endianMapped) - { - return Endian.Agnostic; - } - - return 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}'.") - }; - } private static void AddRegisteredResource<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TResource>( ResourceCreatorRegistry registry) diff --git a/Volatility/Resources/ResourceImport.cs b/src/Volatility.Core/Resources/ResourceImport.cs similarity index 100% rename from Volatility/Resources/ResourceImport.cs rename to src/Volatility.Core/Resources/ResourceImport.cs 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 97% rename from Volatility/Resources/Shader/ShaderBase.cs rename to src/Volatility.Core/Resources/Shader/ShaderBase.cs index da19b88..aa28d4c 100644 --- a/Volatility/Resources/Shader/ShaderBase.cs +++ b/src/Volatility.Core/Resources/Shader/ShaderBase.cs @@ -101,10 +101,6 @@ internal static Regex ShaderPathSanitizer() } public ShaderBase() : base() { } - - public ShaderBase(string path) : base(path) { } - - public ShaderBase(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } public enum ShaderStageType diff --git a/Volatility/Resources/Shader/ShaderPC.cs b/src/Volatility.Core/Resources/Shader/ShaderPC.cs similarity index 94% rename from Volatility/Resources/Shader/ShaderPC.cs rename to src/Volatility.Core/Resources/Shader/ShaderPC.cs index 2e01423..ebd56d6 100644 --- a/Volatility/Resources/Shader/ShaderPC.cs +++ b/src/Volatility.Core/Resources/Shader/ShaderPC.cs @@ -8,7 +8,7 @@ public class ShaderPC : ShaderBase public override Endian ResourceEndian => Endian.LE; public override Platform ResourcePlatform => Platform.TUB; - public string Name; + public string Name = string.Empty; public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness) { @@ -25,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) @@ -66,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 97% rename from Volatility/Resources/SnapshotData/SnapshotData.cs rename to src/Volatility.Core/Resources/SnapshotData/SnapshotData.cs index 7325b93..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) diff --git a/Volatility/Resources/Splicer/Splicer.cs b/src/Volatility.Core/Resources/Splicer/Splicer.cs similarity index 99% rename from Volatility/Resources/Splicer/Splicer.cs rename to src/Volatility.Core/Resources/Splicer/Splicer.cs index 146237b..37db8f7 100644 --- a/Volatility/Resources/Splicer/Splicer.cs +++ b/src/Volatility.Core/Resources/Splicer/Splicer.cs @@ -148,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/Services/DefaultProcessRunner.cs b/src/Volatility.Core/Services/DefaultProcessRunner.cs similarity index 100% rename from Volatility/Services/DefaultProcessRunner.cs rename to src/Volatility.Core/Services/DefaultProcessRunner.cs diff --git a/Volatility/Services/DefaultResourceFactory.cs b/src/Volatility.Core/Services/DefaultResourceFactory.cs similarity index 53% rename from Volatility/Services/DefaultResourceFactory.cs rename to src/Volatility.Core/Services/DefaultResourceFactory.cs index 31f8fb5..1b2062c 100644 --- a/Volatility/Services/DefaultResourceFactory.cs +++ b/src/Volatility.Core/Services/DefaultResourceFactory.cs @@ -9,14 +9,4 @@ public Resource CreateResource(ResourceType resourceType, Platform platform, boo { return ResourceFactory.CreateResource(resourceType, platform, x64); } - - public Resource LoadResource( - ResourceType resourceType, - Platform platform, - string filePath, - IResourceDBLookup? resourceDBLookup, - bool x64 = false) - { - return ResourceFactory.LoadResource(resourceType, platform, filePath, resourceDBLookup, 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/Services/DefaultShaderCompiler.cs b/src/Volatility.Core/Services/DefaultShaderCompiler.cs similarity index 100% rename from Volatility/Services/DefaultShaderCompiler.cs rename to src/Volatility.Core/Services/DefaultShaderCompiler.cs diff --git a/Volatility/Services/EnvironmentPathProvider.cs b/src/Volatility.Core/Services/EnvironmentPathProvider.cs similarity index 100% rename from Volatility/Services/EnvironmentPathProvider.cs rename to src/Volatility.Core/Services/EnvironmentPathProvider.cs diff --git a/Volatility/Services/FileResourceDBLookup.cs b/src/Volatility.Core/Services/FileResourceDBLookup.cs similarity index 100% rename from Volatility/Services/FileResourceDBLookup.cs rename to src/Volatility.Core/Services/FileResourceDBLookup.cs diff --git a/Volatility/Services/FileShaderSourceStore.cs b/src/Volatility.Core/Services/FileShaderSourceStore.cs similarity index 100% rename from Volatility/Services/FileShaderSourceStore.cs rename to src/Volatility.Core/Services/FileShaderSourceStore.cs diff --git a/Volatility/Services/FileSplicerSampleStore.cs b/src/Volatility.Core/Services/FileSplicerSampleStore.cs similarity index 100% rename from Volatility/Services/FileSplicerSampleStore.cs rename to src/Volatility.Core/Services/FileSplicerSampleStore.cs diff --git a/Volatility/Services/FileStringTableStore.cs b/src/Volatility.Core/Services/FileStringTableStore.cs similarity index 100% rename from Volatility/Services/FileStringTableStore.cs rename to src/Volatility.Core/Services/FileStringTableStore.cs diff --git a/Volatility/Services/FileTextureBitmapStore.cs b/src/Volatility.Core/Services/FileTextureBitmapStore.cs similarity index 100% rename from Volatility/Services/FileTextureBitmapStore.cs rename to src/Volatility.Core/Services/FileTextureBitmapStore.cs 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 100% rename from Volatility/Utilities/PS3TextureUtilities.cs rename to src/Volatility.Core/Utilities/PS3TextureUtilities.cs 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 100% rename from Volatility/Utilities/ResourceIDUtilities.cs rename to src/Volatility.Core/Utilities/ResourceIDUtilities.cs 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 + + + + + + + + + + + + + +