Add AC6 PAC extraction and texture export tooling

This commit is contained in:
salh
2026-04-24 17:01:15 +03:00
parent d56edb4afe
commit 121c910b57
18 changed files with 2795 additions and 19 deletions
+1
View File
@@ -26,6 +26,7 @@ endif()
# Sources
set(AC6RECOMP_SOURCES
src/main.cpp
src/ac6_pac_decode_dump.cpp
src/d3d_hooks.cpp
src/render_hooks.cpp
src/ac6_texture_overrides.cpp
+7
View File
@@ -102,6 +102,13 @@ First pass limitations:
- cube textures are skipped
- unsupported DXGI formats fall back to the original guest texture
Current AC6 workflow notes:
- for uncompressed replacements, DX10 DDS with 8.8.8.8 RGBA has been the
safest authoring path
- for specular-style replacements, BC1 has been the most reliable format so far
- hot reload is useful for iteration, but a full game restart is still the
reliable check when cache reuse makes results look stale or mixed
The fallback path is always the original guest texture load. A bad or missing
replacement will not block rendering.
+27
View File
@@ -184,12 +184,39 @@ wrong, or a higher-priority override is winning.
What to do:
- confirm the filename or manifest rule matches the dumped stable key
- keep the same format, size, array/depth, and mip count
- for uncompressed replacements, prefer DX10 DDS with 8.8.8.8 RGBA
- for specular-style replacements, BC1 has been the most reliable format so far
- if you use a manifest, place it directly in override/textures or
mods/<mod_name>/textures and use [[swap]] entries with source paths
relative to that textures folder
- test with override/textures first
Problem: The wrong texture is replaced.
Cause: A wildcard manifest rule is too broad.
What to do: Narrow the stable_key_globs rule or switch to exact stable_keys.
Problem: Auto reload works, but the result flips between skins or looks stale.
Cause: Texture cache reuse is currently best-effort for hot reload, and AC6 can
reuse texture layouts across appearances.
What to do:
- treat full game restart as the reliable validation path
- use exact stable_keys before trying wildcard manifest rules
- if a result looks "50/50", verify with a cold launch before assuming the
dump or replacement is wrong
Current AC6 authoring notes
These are not hard engine guarantees, but they match the current round of AC6
experiments:
- DX10 DDS plus 8.8.8.8 RGBA has been the safest choice for general texture
replacements
- BC1 has been the most reliable choice for specular replacements
- hangar and in-mission variants may not always use the same dumped texture,
even when the art looks related
- some captured textures may still need more investigation if the dump looks
incomplete or visibly corrupted
Practical advice
- Use the current session folder first, not the full dump folder.
+149
View File
@@ -0,0 +1,149 @@
# AC6 Asset Pipeline
This pipeline extracts and converts the AC6 assets we currently understand.
It automates these stages:
1. `DATA.TBL` + `DATA00.PAC` / `DATA01.PAC` index extraction
2. Runtime PAC decode dump parsing
3. Recursive `FHM` extraction
4. `SWG` UI metadata parsing
5. `NTXR` texture export
## Important Limitation
The pipeline can process **all PAC entries and all runtime dumps you already have**, but it **cannot yet offline-decompress every compressed PAC entry in the archive** by itself.
That means:
- Raw PAC entries are handled in one pass.
- Runtime-decoded assets are handled in one pass.
- If a compressed asset has never been decoded by the game and never appeared in `out/ac6_pac_runtime_dump`, this pipeline cannot currently materialize it.
So the pipeline is "all at once" for the **current corpus**, not yet "decode the entire PAC archive from scratch with no runtime help".
If you want truly complete one-shot extraction of every compressed PAC asset without launching the game, the remaining missing piece is an offline implementation of AC6's mode-1 decompressor.
## Prerequisites
- The game assets exist at:
- `C:\ext\New folder\AC6_recomp\out\build\win-amd64-relwithdebinfo\assets`
- If you want runtime-decoded content included, you must already have:
- `C:\ext\New folder\AC6_recomp\out\ac6_pac_runtime_dump`
To collect runtime dumps in future runs:
```powershell
$env:AC6_DUMP_PAC_DECODED='1'
& 'C:\ext\New folder\AC6_recomp\out\build\win-amd64-relwithdebinfo\ac6recomp.exe'
```
Or use the launcher helper:
```powershell
powershell -ExecutionPolicy Bypass -File .\tools\launch_ac6_with_pac_dump.ps1
```
That script sets `AC6_DUMP_PAC_DECODED=1` and launches `ac6recomp.exe` for you.
## One-Command Usage
From the repo root:
```powershell
python .\tools\run_ac6_asset_pipeline.py
```
This uses the default paths:
- Asset root: `out\build\win-amd64-relwithdebinfo\assets`
- Raw PAC output: `out\ac6_pac_extracted_raw`
- Runtime dump input: `out\ac6_pac_runtime_dump`
- Typed FHM output: `out\ac6_runtime_fhm_typed`
- SWG output: `out\ac6_runtime_swg_parsed`
- Texture output: `out\ac6_runtime_ntxr_exported`
The wrapper prints a final JSON summary with the current corpus totals, including:
- PAC entries extracted
- runtime `FHM` container count
- parsed `SWG` files
- exported/skipped `NTXR` textures
## Useful Variants
Use a custom asset root:
```powershell
python .\tools\run_ac6_asset_pipeline.py --asset-root 'C:\path\to\assets'
```
Skip PAC re-extraction and only process existing dumps:
```powershell
python .\tools\run_ac6_asset_pipeline.py --skip-pac-extract
```
Recommended workflow after a new play session:
```powershell
powershell -ExecutionPolicy Bypass -File .\tools\launch_ac6_with_pac_dump.ps1
python .\tools\run_ac6_asset_pipeline.py --skip-pac-extract
```
Extract only PAC entries marked raw:
```powershell
python .\tools\run_ac6_asset_pipeline.py --raw-only
```
## Output Folders
- Raw PAC extraction:
- `C:\ext\New folder\AC6_recomp\out\ac6_pac_extracted_raw`
- Parsed runtime FHM corpus:
- `C:\ext\New folder\AC6_recomp\out\ac6_runtime_fhm_typed`
- Parsed SWG metadata:
- `C:\ext\New folder\AC6_recomp\out\ac6_runtime_swg_parsed`
- Exported textures:
- `C:\ext\New folder\AC6_recomp\out\ac6_runtime_ntxr_exported`
## What To Open
- Main texture manifest:
- `C:\ext\New folder\AC6_recomp\out\ac6_runtime_ntxr_exported\manifest.json`
- SWG manifest:
- `C:\ext\New folder\AC6_recomp\out\ac6_runtime_swg_parsed\manifest.json`
- Example exported textures:
- `C:\ext\New folder\AC6_recomp\out\ac6_runtime_ntxr_exported`
## Notes About Texture Types
Not every texture is a normal color image.
The exporter now handles:
- Standard tiled `R8` atlases
- Standard tiled `RGBA8` atlases
- `BC1` textures
- `BC3` textures
- Packed-mip `BC3` families
- Six-face `BC1` assets using contact-sheet previews
- Several support textures stored as grayscale-in-ARGB
Some outputs are still valid but are things like:
- masks
- glyph sheets
- lookup textures
- normal maps
- cubemap-style faces
## Current Status
At the time this guide was written, the texture exporter was producing:
- `845` exported textures
- `0` skipped textures
That count applies to the currently available runtime dump corpus.
+71
View File
@@ -0,0 +1,71 @@
#include <rex/logging.h>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <mutex>
#include <sstream>
#include <string>
#include <string_view>
namespace {
bool DumpingEnabled() {
static const bool enabled = [] {
const char* value = std::getenv("AC6_DUMP_PAC_DECODED");
return value && value[0] && std::string_view(value) != "0";
}();
return enabled;
}
std::filesystem::path DumpRoot() {
return std::filesystem::path("out") / "ac6_pac_runtime_dump";
}
std::mutex& DumpMutex() {
static std::mutex mutex;
return mutex;
}
} // namespace
void Ac6DumpPacDecodedEntry(uint16_t entry_index, uint8_t codec_mode, uint32_t compressed_size,
uint32_t decompressed_size, uint32_t source_offset,
const uint8_t* host_data) {
if (!DumpingEnabled() || !host_data || decompressed_size == 0) {
return;
}
std::scoped_lock lock(DumpMutex());
std::error_code ec;
const auto root = DumpRoot();
std::filesystem::create_directories(root, ec);
if (ec) {
REXFS_ERROR("[AC6 PAC] failed to create dump directory {}: {}", root.string(), ec.message());
return;
}
std::ostringstream name;
name << "entry_" << entry_index << "_mode" << static_cast<uint32_t>(codec_mode) << "_c" << compressed_size
<< "_u" << decompressed_size << "_off" << std::hex << source_offset << std::dec << ".bin";
const auto path = root / name.str();
std::ofstream file(path, std::ios::binary | std::ios::trunc);
if (!file) {
REXFS_ERROR("[AC6 PAC] failed to open decoded dump {}", path.string());
return;
}
file.write(reinterpret_cast<const char*>(host_data), static_cast<std::streamsize>(decompressed_size));
if (!file) {
REXFS_ERROR("[AC6 PAC] failed to write decoded dump {}", path.string());
return;
}
REXFS_INFO(
"[AC6 PAC] dumped decoded entry index={} mode={} compressed=0x{:x} decompressed=0x{:x} "
"source_offset=0x{:x} path={}",
entry_index, static_cast<uint32_t>(codec_mode), compressed_size, decompressed_size, source_offset,
path.string());
}
@@ -10,9 +10,11 @@
*/
#include <algorithm>
#include <array>
#include <cstdarg>
#include <cstring>
#include <sstream>
#include <unordered_set>
#include <utility>
#include <rex/assert.h>
@@ -51,6 +53,97 @@ REXCVAR_DEFINE_BOOL(d3d12_submit_on_primary_buffer_end, true, "GPU/D3D12",
namespace rex::graphics::d3d12 {
namespace {
constexpr size_t kSuspiciousSpriteStateHistorySize = 16;
struct SuspiciousSpriteStateWrite {
uint32_t reg_index = 0;
uint32_t value = 0;
};
std::array<SuspiciousSpriteStateWrite, kSuspiciousSpriteStateHistorySize>
g_suspicious_sprite_state_history;
uint32_t g_suspicious_sprite_state_history_count = 0;
size_t g_suspicious_sprite_state_history_pos = 0;
bool IsSuspiciousSpriteStateRegister(uint32_t index) {
switch (index) {
case XE_GPU_REG_RB_MODECONTROL:
case XE_GPU_REG_RB_SURFACE_INFO:
case XE_GPU_REG_RB_COLORCONTROL:
case XE_GPU_REG_RB_COLOR_INFO:
case XE_GPU_REG_RB_COLOR_MASK:
case XE_GPU_REG_RB_BLENDCONTROL0:
case XE_GPU_REG_RB_DEPTHCONTROL:
case XE_GPU_REG_RB_DEPTH_INFO:
case XE_GPU_REG_PA_SU_POINT_SIZE:
case XE_GPU_REG_PA_SU_POINT_MINMAX:
return true;
default:
return false;
}
}
const char* GetSuspiciousSpriteStateRegisterName(uint32_t index) {
switch (index) {
case XE_GPU_REG_RB_MODECONTROL:
return "RB_MODECONTROL";
case XE_GPU_REG_RB_SURFACE_INFO:
return "RB_SURFACE_INFO";
case XE_GPU_REG_RB_COLORCONTROL:
return "RB_COLORCONTROL";
case XE_GPU_REG_RB_COLOR_INFO:
return "RB_COLOR_INFO0";
case XE_GPU_REG_RB_COLOR_MASK:
return "RB_COLOR_MASK";
case XE_GPU_REG_RB_BLENDCONTROL0:
return "RB_BLENDCONTROL0";
case XE_GPU_REG_RB_DEPTHCONTROL:
return "RB_DEPTHCONTROL";
case XE_GPU_REG_RB_DEPTH_INFO:
return "RB_DEPTH_INFO";
case XE_GPU_REG_PA_SU_POINT_SIZE:
return "PA_SU_POINT_SIZE";
case XE_GPU_REG_PA_SU_POINT_MINMAX:
return "PA_SU_POINT_MINMAX";
default:
return "UNKNOWN";
}
}
void RecordSuspiciousSpriteStateWrite(uint32_t index, uint32_t value) {
if (!IsSuspiciousSpriteStateRegister(index)) {
return;
}
g_suspicious_sprite_state_history[g_suspicious_sprite_state_history_pos] = {index, value};
g_suspicious_sprite_state_history_pos =
(g_suspicious_sprite_state_history_pos + 1) % kSuspiciousSpriteStateHistorySize;
g_suspicious_sprite_state_history_count = std::min<uint32_t>(
g_suspicious_sprite_state_history_count + 1, kSuspiciousSpriteStateHistorySize);
}
std::string FormatSuspiciousSpriteStateHistory() {
std::ostringstream stream;
stream << "TrailStateHistory:";
if (!g_suspicious_sprite_state_history_count) {
stream << " <empty>";
return stream.str();
}
size_t start = (g_suspicious_sprite_state_history_pos + kSuspiciousSpriteStateHistorySize -
g_suspicious_sprite_state_history_count) %
kSuspiciousSpriteStateHistorySize;
stream << std::hex << std::uppercase;
for (uint32_t i = 0; i < g_suspicious_sprite_state_history_count; ++i) {
const SuspiciousSpriteStateWrite& write =
g_suspicious_sprite_state_history[(start + i) % kSuspiciousSpriteStateHistorySize];
stream << ' ' << GetSuspiciousSpriteStateRegisterName(write.reg_index) << "=0x" << write.value;
}
return stream.str();
}
} // namespace
// Generated with `xb buildshaders`.
namespace shaders {
#include "../shaders/bytecode/d3d12_5_1/apply_gamma_pwl_cs.h"
@@ -1847,6 +1940,7 @@ void D3D12CommandProcessor::ShutdownContext() {
void D3D12CommandProcessor::WriteRegister(uint32_t index, uint32_t value) {
CommandProcessor::WriteRegister(index, value);
RecordSuspiciousSpriteStateWrite(index, value);
if (index >= XE_GPU_REG_SHADER_CONSTANT_000_X && index <= XE_GPU_REG_SHADER_CONSTANT_511_W) {
if (frame_open_) {
@@ -2579,6 +2673,124 @@ bool D3D12CommandProcessor::IssueDraw(xenos::PrimitiveType primitive_type, uint3
uint32_t normalized_color_mask =
pixel_shader ? draw_util::GetNormalizedColorMask(regs, pixel_shader->writes_color_targets())
: 0;
bool forced_ac6_trail_color_mask = false;
if (pixel_shader && !normalized_color_mask &&
primitive_processing_result.guest_primitive_type == xenos::PrimitiveType::kPointList &&
regs.Get<reg::RB_MODECONTROL>().edram_mode == xenos::EdramMode::kColorDepth &&
vertex_shader->ucode_data_hash() == UINT64_C(0xC049A8C9E556F129) &&
pixel_shader->ucode_data_hash() == UINT64_C(0x2E372EA28CC404B7)) {
xenos::ColorRenderTargetFormat color_format =
regs.Get<reg::RB_COLOR_INFO>(reg::RB_COLOR_INFO::rt_register_indices[0]).color_format;
uint32_t format_component_count =
xenos::GetColorRenderTargetFormatComponentCount(color_format);
if (format_component_count) {
uint32_t format_component_mask = (uint32_t(1) << format_component_count) - 1;
normalized_color_mask = format_component_mask | (uint32_t(0b1111) & ~format_component_mask);
forced_ac6_trail_color_mask = true;
static bool logged_forced_ac6_trail_color_mask = false;
if (!logged_forced_ac6_trail_color_mask) {
logged_forced_ac6_trail_color_mask = true;
REXGPU_WARN(
"Forcing RT0 color writes for AC6 trail point-list pass VS {:016X} / PS {:016X}",
vertex_shader->ucode_data_hash(), pixel_shader->ucode_data_hash());
}
}
}
bool trace_sprite_draw =
primitive_processing_result.guest_primitive_type == xenos::PrimitiveType::kPointList ||
primitive_processing_result.guest_primitive_type == xenos::PrimitiveType::kRectangleList ||
primitive_processing_result.host_primitive_type == xenos::PrimitiveType::kPointList ||
primitive_processing_result.host_primitive_type == xenos::PrimitiveType::kRectangleList;
if (trace_sprite_draw) {
uint64_t vertex_shader_hash = vertex_shader->ucode_data_hash();
uint64_t pixel_shader_hash = pixel_shader ? pixel_shader->ucode_data_hash() : 0;
auto pa_su_point_size = regs.Get<reg::PA_SU_POINT_SIZE>();
auto pa_su_point_minmax = regs.Get<reg::PA_SU_POINT_MINMAX>();
auto rb_modecontrol = regs.Get<reg::RB_MODECONTROL>();
auto sq_program_cntl = regs.Get<reg::SQ_PROGRAM_CNTL>();
auto sq_context_misc = regs.Get<reg::SQ_CONTEXT_MISC>();
auto rb_colorcontrol = regs.Get<reg::RB_COLORCONTROL>();
auto rt0_blendcontrol =
regs.Get<reg::RB_BLENDCONTROL>(reg::RB_BLENDCONTROL::rt_register_indices[0]);
uint32_t raw_rb_color_mask = regs[XE_GPU_REG_RB_COLOR_MASK];
uint32_t edram_mode = uint32_t(rb_modecontrol.edram_mode);
uint32_t sq_param_gen = sq_program_cntl.param_gen;
uint32_t sq_param_gen_pos = sq_context_misc.param_gen_pos;
uint32_t ps_param_gen_enable = pixel_shader_modification.pixel.param_gen_enable;
uint32_t ps_param_gen_interpolator =
pixel_shader_modification.pixel.param_gen_interpolator;
uint32_t ps_param_gen_point = pixel_shader_modification.pixel.param_gen_point;
uint32_t ps_writes_color_targets = pixel_shader ? pixel_shader->writes_color_targets() : 0;
uint32_t ps_writes_depth = pixel_shader ? pixel_shader->writes_depth() : 0;
uint32_t vs_output_point_size = vertex_shader_modification.vertex.output_point_size;
uint32_t alpha_test_enable = rb_colorcontrol.alpha_test_enable;
uint32_t alpha_to_mask_enable = rb_colorcontrol.alpha_to_mask_enable;
uint32_t depth_enable = normalized_depth_control.z_enable;
uint32_t depth_write_enable = normalized_depth_control.z_write_enable;
uint32_t point_width = pa_su_point_size.width;
uint32_t point_height = pa_su_point_size.height;
uint32_t point_min_size = pa_su_point_minmax.min_size;
uint32_t point_max_size = pa_su_point_minmax.max_size;
uint64_t trace_key = vertex_shader_hash;
trace_key ^= pixel_shader_hash * UINT64_C(0x9E3779B185EBCA87);
trace_key ^= uint64_t(primitive_processing_result.guest_primitive_type) << 1;
trace_key ^= uint64_t(primitive_processing_result.host_primitive_type) << 5;
trace_key ^= uint64_t(primitive_processing_result.host_vertex_shader_type) << 9;
trace_key ^= uint64_t(pa_su_point_size.value) << 32;
trace_key ^= uint64_t(pa_su_point_minmax.value);
trace_key ^= uint64_t(normalized_depth_control.value) << 16;
trace_key ^= uint64_t(raw_rb_color_mask) << 20;
trace_key ^= uint64_t(normalized_color_mask) << 48;
trace_key ^= uint64_t(rb_colorcontrol.value);
trace_key ^= uint64_t(ps_writes_color_targets) << 24;
trace_key ^= uint64_t(ps_writes_depth) << 28;
trace_key ^= uint64_t(edram_mode) << 60;
trace_key ^= uint64_t(ps_param_gen_enable) << 13;
trace_key ^= uint64_t(ps_param_gen_point) << 14;
trace_key ^= uint64_t(ps_param_gen_interpolator) << 15;
static std::unordered_set<uint64_t> logged_trace_keys;
if (logged_trace_keys.emplace(trace_key).second) {
REXGPU_INFO(
"SpriteTrace: guest_prim={} host_prim={} host_vs_type={} VS={:016X}/{}b "
"PS={:016X}/{}b interp_mask={:04X} ps_param_gen_pos={} sq_param_gen={} "
"sq_param_gen_pos={} ps_param_gen_enable={} ps_param_gen_interp={} "
"ps_param_gen_point={} vs_output_point_size={} vs_point_writes={} "
"point_size={}x{} point_minmax={}..{} rb_color_mask={:04X} "
"ps_writes_color_targets={:X} ps_writes_depth={} color_mask={:04X} "
"forced_color_mask={} "
"edram_mode={} alpha_test={} a2c={} z_enable={} z_write={} zfunc={} "
"rt0_blend=({},{},{},{},{},{})",
uint32_t(primitive_processing_result.guest_primitive_type),
uint32_t(primitive_processing_result.host_primitive_type),
uint32_t(primitive_processing_result.host_vertex_shader_type), vertex_shader_hash,
vertex_shader->ucode_dword_count() * sizeof(uint32_t), pixel_shader_hash,
pixel_shader ? (pixel_shader->ucode_dword_count() * sizeof(uint32_t)) : 0,
interpolator_mask, ps_param_gen_pos == UINT32_MAX ? -1 : int32_t(ps_param_gen_pos),
sq_param_gen, sq_param_gen_pos, ps_param_gen_enable, ps_param_gen_interpolator,
ps_param_gen_point, vs_output_point_size,
vertex_shader->writes_point_size_edge_flag_kill_vertex(), point_width, point_height,
point_min_size, point_max_size, raw_rb_color_mask, ps_writes_color_targets,
ps_writes_depth, normalized_color_mask, forced_ac6_trail_color_mask, edram_mode,
alpha_test_enable,
alpha_to_mask_enable, depth_enable,
depth_write_enable, uint32_t(normalized_depth_control.zfunc),
uint32_t(rt0_blendcontrol.color_srcblend),
uint32_t(rt0_blendcontrol.color_destblend),
uint32_t(rt0_blendcontrol.color_comb_fcn),
uint32_t(rt0_blendcontrol.alpha_srcblend),
uint32_t(rt0_blendcontrol.alpha_destblend),
uint32_t(rt0_blendcontrol.alpha_comb_fcn));
}
bool trace_trail_state_history =
raw_rb_color_mask == 0 &&
(vertex_shader_hash == UINT64_C(0xC049A8C9E556F129) ||
pixel_shader_hash == UINT64_C(0x2E372EA28CC404B7));
static std::unordered_set<uint64_t> logged_trace_state_history_keys;
if (trace_trail_state_history &&
logged_trace_state_history_keys.emplace(trace_key ^ UINT64_C(0xA5A5A5A5F00DFACE)).second) {
REXGPU_INFO("{}", FormatSuspiciousSpriteStateHistory());
}
}
if (!render_target_cache_->Update(is_rasterization_done, normalized_depth_control,
normalized_color_mask, *vertex_shader)) {
return false;
@@ -2604,6 +2816,35 @@ bool D3D12CommandProcessor::IssueDraw(xenos::PrimitiveType primitive_type, uint3
bound_depth_and_color_render_target_formats);
} else {
bound_depth_and_color_render_target_bits = 0;
std::memset(bound_depth_and_color_render_target_formats, 0,
sizeof(bound_depth_and_color_render_target_formats));
}
if (trace_sprite_draw) {
uint64_t vertex_shader_hash = vertex_shader->ucode_data_hash();
uint64_t pixel_shader_hash = pixel_shader ? pixel_shader->ucode_data_hash() : 0;
uint64_t trace_rt_key = vertex_shader_hash;
trace_rt_key ^= pixel_shader_hash * UINT64_C(0xD6E8FEB86659FD93);
trace_rt_key ^= uint64_t(primitive_processing_result.guest_primitive_type) << 1;
trace_rt_key ^= uint64_t(primitive_processing_result.host_primitive_type) << 5;
trace_rt_key ^= uint64_t(normalized_depth_control.value) << 16;
trace_rt_key ^= uint64_t(normalized_color_mask) << 48;
trace_rt_key ^= uint64_t(bound_depth_and_color_render_target_bits) << 24;
for (uint32_t i = 0; i < 1 + xenos::kMaxColorRenderTargets; ++i) {
trace_rt_key ^=
uint64_t(bound_depth_and_color_render_target_formats[i]) << ((i * 11) & 63);
}
static std::unordered_set<uint64_t> logged_trace_rt_keys;
if (logged_trace_rt_keys.emplace(trace_rt_key).second) {
REXGPU_INFO(
"SpriteTraceRT: host_rt_path={} bound_bits={:02X} formats=[{:X},{:X},{:X},{:X},{:X}] "
"normalized_color_mask={:04X}",
uint32_t(render_target_cache_->GetPath()), bound_depth_and_color_render_target_bits,
bound_depth_and_color_render_target_formats[0],
bound_depth_and_color_render_target_formats[1],
bound_depth_and_color_render_target_formats[2],
bound_depth_and_color_render_target_formats[3],
bound_depth_and_color_render_target_formats[4], normalized_color_mask);
}
}
void* pipeline_handle;
ID3D12RootSignature* root_signature;
@@ -2013,13 +2013,9 @@ void PipelineCache::CreateDxbcGeometryShader(GeometryShaderKey key,
// Names (after the parameters).
name_ptr = uint32_t((shader_out.size() - osgn_position_dwords) * sizeof(uint32_t));
uint32_t osgn_name_ptr_texcoord = name_ptr;
if (key.interpolator_count) {
if (key.interpolator_count || key.has_point_coordinates) {
name_ptr += dxbc::AppendAlignedString(shader_out, "TEXCOORD");
}
uint32_t osgn_name_ptr_xespritetexcoord = name_ptr;
if (key.has_point_coordinates) {
name_ptr += dxbc::AppendAlignedString(shader_out, "XESPRITETEXCOORD");
}
uint32_t osgn_name_ptr_sv_position = name_ptr;
name_ptr += dxbc::AppendAlignedString(shader_out, "SV_Position");
uint32_t osgn_name_ptr_sv_clip_distance = name_ptr;
@@ -2059,13 +2055,15 @@ void PipelineCache::CreateDxbcGeometryShader(GeometryShaderKey key,
}
}
// Point coordinates (XESPRITETEXCOORD).
// Point coordinates use the next TEXCOORD semantic after guest interpolators
// so the DXBC->DXIL path doesn't depend on custom GS->PS semantic names.
if (key.has_point_coordinates) {
output_register_point_coordinates = output_register_index;
assert_true(osgn_parameter_index < osgn_parameter_count);
dxbc::SignatureParameterForGS& osgn_point_coordinates =
osgn_parameters[osgn_parameter_index++];
osgn_point_coordinates.semantic_name_ptr = osgn_name_ptr_xespritetexcoord;
osgn_point_coordinates.semantic_name_ptr = osgn_name_ptr_texcoord;
osgn_point_coordinates.semantic_index = key.interpolator_count;
osgn_point_coordinates.component_type = dxbc::SignatureRegisterComponentType::kFloat32;
osgn_point_coordinates.register_index = output_register_index++;
osgn_point_coordinates.mask = 0b0011;
@@ -24,10 +24,11 @@
#include <rex/ui/d3d12/d3d12_util.h>
REXCVAR_DEFINE_BOOL(
d3d12_expand_point_sprites_in_vs, true, "GPU/D3D12",
"Expand Xbox point lists as triangle strips in the vertex shader instead of the host "
"point-list + geometry-shader path. Fixes missing trails/particles on some titles and "
"drivers where native point expansion is culled or rasterized incorrectly.")
d3d12_expand_point_sprites_in_vs, false, "GPU/D3D12",
"Experimental: expand Xbox point lists as triangle strips in the vertex shader instead of "
"the host point-list + geometry-shader path. Disabled by default on D3D12 because the DXBC "
"fallback path is not yet reliable enough for AC6 point-sprite effects such as missile "
"trails.")
.lifecycle(rex::cvar::Lifecycle::kInitOnly);
namespace rex::graphics::d3d12 {
@@ -37,6 +38,9 @@ D3D12PrimitiveProcessor::~D3D12PrimitiveProcessor() {
}
bool D3D12PrimitiveProcessor::Initialize() {
// Keep the DXBC VS-expansion fallback opt-in for debugging, but prefer the
// native point-list + geometry-shader path by default until the D3D12
// translator matches the Vulkan point-sprite fallback behavior.
const bool point_sprites_native_without_vs = !REXCVAR_GET(d3d12_expand_point_sprites_in_vs);
if (!InitializeCommon(true, false, false, true, point_sprites_native_without_vs, true)) {
Shutdown();
@@ -14,7 +14,12 @@
#include <cfloat>
#include <cstddef>
#include <cstring>
#include <iomanip>
#include <memory>
#include <mutex>
#include <sstream>
#include <string_view>
#include <unordered_set>
#include <utility>
#include <rex/assert.h>
@@ -37,6 +42,10 @@
namespace rex::graphics::d3d12 {
REXCVAR_DEFINE_BOOL(d3d12_log_bc1_diagnostics, false, "GPU/D3D12",
"Log detailed diagnostics for BC1 texture loads and dumps")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
// Generated with `xb buildshaders`.
namespace shaders {
#include "../shaders/bytecode/d3d12_5_1/texture_load_128bpb_cs.h"
@@ -99,6 +108,9 @@ namespace {
constexpr D3D12_FORMAT_SUPPORT1 kLinearFilterSupport = D3D12_FORMAT_SUPPORT1_SHADER_SAMPLE;
std::mutex g_bc1_diagnostic_mutex;
std::unordered_set<std::string> g_bc1_diagnostic_logged_keys;
bool IsFormatSampleFilterable(ID3D12Device* device, DXGI_FORMAT format) {
if (format == DXGI_FORMAT_UNKNOWN) {
return false;
@@ -112,6 +124,55 @@ bool IsFormatSampleFilterable(ID3D12Device* device, DXGI_FORMAT format) {
return (support.Support1 & kLinearFilterSupport) == kLinearFilterSupport;
}
bool ShouldLogBc1Diagnostics(xenos::TextureFormat format, xenos::Endian endianness) {
return REXCVAR_GET(d3d12_log_bc1_diagnostics) &&
GetBaseFormat(format) == xenos::TextureFormat::k_DXT1 &&
endianness != xenos::Endian::kNone;
}
bool MarkBc1DiagnosticKeyLogged(std::string_view stable_key) {
std::lock_guard<std::mutex> lock(g_bc1_diagnostic_mutex);
return g_bc1_diagnostic_logged_keys.insert(std::string(stable_key)).second;
}
const char* GetEndianDebugName(xenos::Endian endianness) {
switch (endianness) {
case xenos::Endian::kNone:
return "kNone";
case xenos::Endian::k8in16:
return "k8in16";
case xenos::Endian::k8in32:
return "k8in32";
case xenos::Endian::k16in32:
return "k16in32";
default:
return "unknown";
}
}
const char* GetLoadShaderDebugName(uint32_t index) {
switch (index) {
case 3:
return "kLoadShaderIndex64bpb";
case 24:
return "kLoadShaderIndexDXT1ToRGBA8";
default:
return "other";
}
}
std::string FormatByteSample(const uint8_t* data, size_t count) {
std::ostringstream stream;
stream << std::hex << std::uppercase << std::setfill('0');
for (size_t i = 0; i < count; ++i) {
if (i != 0) {
stream << ' ';
}
stream << std::setw(2) << uint32_t(data[i]);
}
return stream.str();
}
} // namespace
const D3D12TextureCache::HostFormat D3D12TextureCache::kBestHostFormats[64] = {
@@ -1849,6 +1910,35 @@ bool D3D12TextureCache::LoadTextureDataFromResidentMemoryImpl(Texture& texture,
(is_base ? host_slice_size_base : host_slice_sizes_mips[level]) = level_host_slice_size;
copy_buffer_size += level_host_slice_size * array_size;
}
const bool log_bc1_diagnostics = ShouldLogBc1Diagnostics(guest_format, texture_key.endianness);
std::string bc1_diagnostic_stable_key;
if (log_bc1_diagnostics) {
const uint64_t texture_key_hash = XXH3_64bits(&texture_key, sizeof(texture_key));
bc1_diagnostic_stable_key = ac6::textures::BuildTextureStableKey(
texture_key_hash, texture_key.base_page, texture_key.mip_page, uint32_t(texture_key.dimension),
width, height, depth_or_array_size, texture_key.mip_max_level + 1, uint32_t(texture_key.format),
uint32_t(texture_key.endianness), texture_key.tiled != 0, texture_key.packed_mips != 0,
texture_key.signed_separate != 0, texture_key.scaled_resolve != 0);
if (MarkBc1DiagnosticKeyLogged(bc1_diagnostic_stable_key)) {
REXGPU_INFO(
"BC1 diagnostic {}: shader={} host_copy_format={} sample_format={} tiled={} packed={} "
"scaled={} endian={} base=0x{:08X} mip=0x{:08X} size={}x{}x{} mips={} "
"guest_base_row_pitch={} guest_base_z_rows={} guest_base_slice_stride=0x{:X} "
"guest_base_extent=0x{:X} guest_mips_extent=0x{:X} host_base_row_pitch={} "
"host_base_width={} host_base_height={} host_base_depth={} copy_buffer_size=0x{:X}",
bc1_diagnostic_stable_key, GetLoadShaderDebugName(load_shader),
ac6::textures::DescribeDxgiFormat(host_copy_format),
ac6::textures::DescribeDxgiFormat(host_sample_format), texture_key.tiled ? 1 : 0,
texture_key.packed_mips ? 1 : 0, texture_resolution_scaled ? 1 : 0,
GetEndianDebugName(texture_key.endianness), texture_key.base_page << 12, texture_key.mip_page << 12,
width, height, depth_or_array_size, texture_key.mip_max_level + 1, guest_layout.base.row_pitch_bytes,
guest_layout.base.z_slice_stride_block_rows, guest_layout.base.array_slice_stride_bytes,
guest_layout.base.level_data_extent_bytes, guest_layout.mips_total_extent_bytes,
host_slice_layout_base.Footprint.RowPitch, host_slice_layout_base.Footprint.Width,
host_slice_layout_base.Footprint.Height, host_slice_layout_base.Footprint.Depth,
uint32_t(copy_buffer_size));
}
}
D3D12_RESOURCE_STATES copy_buffer_state = D3D12_RESOURCE_STATE_UNORDERED_ACCESS;
ID3D12Resource* copy_buffer =
command_processor_.RequestScratchGPUBuffer(uint32_t(copy_buffer_size), copy_buffer_state);
@@ -2313,6 +2403,17 @@ void D3D12TextureCache::ProcessCompletedTextureTransfers() {
continue;
}
if (ShouldLogBc1Diagnostics(xenos::TextureFormat(it->guest_format), xenos::Endian(it->endianness)) &&
!dds_image.subresources.empty() && !dds_image.subresources.front().data.empty()) {
const ac6::textures::DdsSubresource& subresource = dds_image.subresources.front();
const size_t sample_size = std::min<size_t>(subresource.data.size(), 8);
REXGPU_INFO(
"BC1 diagnostic {}: dump_format={} first_block={} row_pitch={} slice_pitch={} payload_size={}",
it->stable_key, ac6::textures::DescribeDxgiFormat(dds_image.format),
FormatByteSample(subresource.data.data(), sample_size), subresource.row_pitch,
subresource.slice_pitch, subresource.data.size());
}
ac6::textures::TextureDumpMetadata metadata;
metadata.stable_key = it->stable_key;
metadata.texture_key_hash = it->texture_key_hash;
@@ -2602,7 +2602,8 @@ void DxbcShaderTranslator::WriteInputSignature() {
}
}
// Point coordinates for PsParamGen (XESPRITETEXCOORD).
// Point coordinates for PsParamGen. Use the next TEXCOORD semantic after
// guest interpolators for better DXBC->DXIL linkage reliability on D3D12.
size_t point_coordinates_position = shader_object_.size();
if (in_reg_ps_point_coordinates_ != UINT32_MAX) {
shader_object_.resize(shader_object_.size() + kParameterDwords);
@@ -2610,6 +2611,7 @@ void DxbcShaderTranslator::WriteInputSignature() {
{
auto& point_coordinates = *reinterpret_cast<dxbc::SignatureParameter*>(
shader_object_.data() + point_coordinates_position);
point_coordinates.semantic_index = interpolator_count;
point_coordinates.component_type = dxbc::SignatureRegisterComponentType::kFloat32;
point_coordinates.register_index = in_reg_ps_point_coordinates_;
point_coordinates.mask = 0b0011;
@@ -2665,20 +2667,19 @@ void DxbcShaderTranslator::WriteInputSignature() {
// Semantic names.
uint32_t semantic_offset = uint32_t((shader_object_.size() - blob_position) * sizeof(uint32_t));
if (interpolator_count) {
if (interpolator_count || in_reg_ps_point_coordinates_ != UINT32_MAX) {
auto interpolators = reinterpret_cast<dxbc::SignatureParameter*>(shader_object_.data() +
interpolator_position);
for (uint32_t i = 0; i < interpolator_count; ++i) {
interpolators[i].semantic_name_ptr = semantic_offset;
}
if (in_reg_ps_point_coordinates_ != UINT32_MAX) {
auto& point_coordinates = *reinterpret_cast<dxbc::SignatureParameter*>(
shader_object_.data() + point_coordinates_position);
point_coordinates.semantic_name_ptr = semantic_offset;
}
semantic_offset += dxbc::AppendAlignedString(shader_object_, "TEXCOORD");
}
if (in_reg_ps_point_coordinates_ != UINT32_MAX) {
auto& point_coordinates = *reinterpret_cast<dxbc::SignatureParameter*>(
shader_object_.data() + point_coordinates_position);
point_coordinates.semantic_name_ptr = semantic_offset;
semantic_offset += dxbc::AppendAlignedString(shader_object_, "XESPRITETEXCOORD");
}
{
auto& position =
*reinterpret_cast<dxbc::SignatureParameter*>(shader_object_.data() + position_position);
@@ -6,6 +6,9 @@
// Disable warnings about unused parameters for kernel functions
#pragma GCC diagnostic ignored "-Wunused-parameter"
#include <algorithm>
#include <cctype>
#include <native/filesystem/device.h>
#include <rex/kernel/xboxkrnl/private.h>
#include <rex/logging.h>
@@ -14,6 +17,7 @@
#include <rex/ppc/types.h>
#include <rex/system/info/file.h>
#include <rex/system/kernel_state.h>
#include <rex/system/thread_state.h>
#include <rex/system/util/string_utils.h>
#include <rex/system/xevent.h>
#include <rex/system/xfile.h>
@@ -26,6 +30,36 @@
namespace rex::kernel::xboxkrnl {
using namespace rex::system;
namespace {
bool AsciiCaseInsensitiveEquals(char left, char right) {
return std::tolower(static_cast<unsigned char>(left)) ==
std::tolower(static_cast<unsigned char>(right));
}
bool ContainsAsciiInsensitive(std::string_view haystack, std::string_view needle) {
return std::search(haystack.begin(), haystack.end(), needle.begin(), needle.end(),
AsciiCaseInsensitiveEquals) != haystack.end();
}
bool IsFocusedAc6PacPath(std::string_view path) {
return ContainsAsciiInsensitive(path, "DATA00.PAC") ||
ContainsAsciiInsensitive(path, "DATA01.PAC") ||
ContainsAsciiInsensitive(path, "DATA.TBL");
}
uint32_t CurrentGuestCallerAddress() {
auto* thread_state = runtime::ThreadState::Get();
if (!thread_state || !thread_state->context()) {
return 0;
}
const uint32_t lr = static_cast<uint32_t>(thread_state->context()->lr);
return lr >= 4 ? (lr - 4) : 0;
}
} // namespace
struct CreateOptions {
// https://processhacker.sourceforge.io/doc/ntioapi_8h.html
static const uint32_t FILE_DIRECTORY_FILE = 0x00000001;
@@ -122,6 +156,14 @@ ppc_u32_result_t NtCreateFile_entry(ppc_pu32_t handle_out, ppc_u32_t desired_acc
"NtCreateFile", "path={} access={:#x} attrs={:#x} share={:#x} disp={:#x} options={:#x}",
target_path, (uint32_t)desired_access, (uint32_t)file_attributes, (uint32_t)share_access,
(uint32_t)creation_disposition, (uint32_t)create_options);
if (IsFocusedAc6PacPath(target_path)) {
REXKRNL_INFO(
"[AC6 PAC] NtCreateFile caller={:08X} path={} access={:#x} attrs={:#x} share={:#x} "
"disp={:#x} options={:#x}",
CurrentGuestCallerAddress(), target_path, (uint32_t)desired_access,
(uint32_t)file_attributes, (uint32_t)share_access, (uint32_t)creation_disposition,
(uint32_t)create_options);
}
// Enforce that the path is ASCII.
if (!IsValidPath(target_path, false)) {
@@ -206,6 +248,16 @@ ppc_u32_result_t NtReadFile_entry(ppc_u32_t file_handle, ppc_u32_t event_handle,
result = X_STATUS_INVALID_HANDLE;
}
const bool focused_pac_read = file && IsFocusedAc6PacPath(file->path());
if (focused_pac_read) {
REXKRNL_INFO(
"[AC6 PAC] NtReadFile request caller={:08X} thid={} path={} handle={:#x} len={:#x} "
"offset={} sync={}",
CurrentGuestCallerAddress(), XThread::GetCurrentThreadId(), file->path(),
(uint32_t)file_handle, (uint32_t)buffer_length, byte_offset_ptr ? (int64_t)byte_offset : -1,
file->is_synchronous());
}
if (XSUCCEEDED(result)) {
uint32_t bytes_read = 0;
result = file->Read(buffer.guest_address(), buffer_length,
@@ -239,6 +291,15 @@ ppc_u32_result_t NtReadFile_entry(ppc_u32_t file_handle, ppc_u32_t event_handle,
result = X_STATUS_PENDING;
}
signal_event = true;
if (focused_pac_read) {
REXKRNL_INFO(
"[AC6 PAC] NtReadFile result caller={:08X} path={} status={:#x} bytes_read={:#x} "
"iosb_status={:#x} iosb_info={:#x}",
CurrentGuestCallerAddress(), file->path(), result, bytes_read,
io_status_block ? (uint32_t)io_status_block->status : 0xFFFFFFFFu,
io_status_block ? (uint32_t)io_status_block->information : 0xFFFFFFFFu);
}
}
if (XFAILED(result) && io_status_block) {
@@ -285,6 +346,17 @@ ppc_u32_result_t NtReadFileScatter_entry(ppc_u32_t file_handle, ppc_u32_t event_
result = X_STATUS_INVALID_HANDLE;
}
const bool focused_pac_read = file && IsFocusedAc6PacPath(file->path());
if (focused_pac_read) {
const uint64_t byte_offset = byte_offset_ptr ? static_cast<uint64_t>(*byte_offset_ptr) : 0;
REXKRNL_INFO(
"[AC6 PAC] NtReadFileScatter request caller={:08X} thid={} path={} handle={:#x} "
"len={:#x} offset={} sync={}",
CurrentGuestCallerAddress(), XThread::GetCurrentThreadId(), file->path(),
(uint32_t)file_handle, (uint32_t)length, byte_offset_ptr ? (int64_t)byte_offset : -1,
file->is_synchronous());
}
if (XSUCCEEDED(result)) {
uint32_t bytes_read = 0;
result = file->ReadScatter(segment_array.guest_address(), length,
@@ -305,6 +377,15 @@ ppc_u32_result_t NtReadFileScatter_entry(ppc_u32_t file_handle, ppc_u32_t event_
result = X_STATUS_PENDING;
}
signal_event = true;
if (focused_pac_read) {
REXKRNL_INFO(
"[AC6 PAC] NtReadFileScatter result caller={:08X} path={} status={:#x} bytes_read={:#x} "
"iosb_status={:#x} iosb_info={:#x}",
CurrentGuestCallerAddress(), file->path(), result, bytes_read,
io_status_block ? (uint32_t)io_status_block->status : 0xFFFFFFFFu,
io_status_block ? (uint32_t)io_status_block->information : 0xFFFFFFFFu);
}
}
if (XFAILED(result) && io_status_block) {
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import os
import struct
from pathlib import Path
HEADER_SIZE = 8
ENTRY_SIZE = 16
def parse_tbl(path: Path) -> list[dict]:
data = path.read_bytes()
if len(data) < HEADER_SIZE:
raise ValueError("DATA.TBL is too small")
entry_count, pack_count = struct.unpack_from(">II", data, 0)
expected_size = HEADER_SIZE + (entry_count * ENTRY_SIZE)
if len(data) != expected_size:
raise ValueError(f"unexpected DATA.TBL size: got {len(data)}, expected {expected_size}")
entries = []
for index in range(entry_count):
group, offset, compressed_size, decompressed_size = struct.unpack_from(
">4I", data, HEADER_SIZE + (index * ENTRY_SIZE)
)
pac_name = "DATA01.PAC" if (group & 0x01000000) else "DATA00.PAC"
storage_kind = "raw" if (group & 0x00020000) else "compressed"
entries.append(
{
"index": index,
"group": group,
"group_hex": f"0x{group:08x}",
"pac_name": pac_name,
"storage_kind": storage_kind,
"offset": offset,
"compressed_size": compressed_size,
"decompressed_size": decompressed_size,
}
)
return entries
def sha256_path(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
while True:
chunk = f.read(1024 * 1024)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def extract_entries(asset_root: Path, output_root: Path, entries: list[dict], include_compressed: bool) -> dict:
pac_bytes = {
"DATA00.PAC": asset_root.joinpath("DATA00.PAC").read_bytes(),
"DATA01.PAC": asset_root.joinpath("DATA01.PAC").read_bytes(),
}
pac_sizes = {name: len(data) for name, data in pac_bytes.items()}
output_root.mkdir(parents=True, exist_ok=True)
files_dir = output_root / "files"
files_dir.mkdir(exist_ok=True)
manifest_entries = []
extracted_count = 0
skipped_count = 0
for entry in entries:
if entry["storage_kind"] == "compressed" and not include_compressed:
skipped_count += 1
manifest_entries.append({**entry, "extracted": False, "reason": "compressed entry skipped"})
continue
pac_name = entry["pac_name"]
pac_size = pac_sizes[pac_name]
start = entry["offset"]
end = start + entry["compressed_size"]
if end > pac_size:
raise ValueError(
f"entry {entry['index']} exceeds {pac_name}: offset=0x{start:x}, size=0x{entry['compressed_size']:x}"
)
blob = pac_bytes[pac_name][start:end]
subdir = files_dir / pac_name.replace(".PAC", "") / entry["storage_kind"]
subdir.mkdir(parents=True, exist_ok=True)
out_path = subdir / f"{entry['index']:04d}.bin"
out_path.write_bytes(blob)
manifest_entries.append(
{
**entry,
"extracted": True,
"path": str(out_path.relative_to(output_root)).replace("\\", "/"),
"sha256": hashlib.sha256(blob).hexdigest(),
"head_hex": blob[:32].hex(),
}
)
extracted_count += 1
manifest = {
"asset_root": str(asset_root),
"output_root": str(output_root),
"entry_count": len(entries),
"extracted_count": extracted_count,
"skipped_count": skipped_count,
"include_compressed": include_compressed,
"archives": {
name: {
"size": size,
"sha256": sha256_path(asset_root / name),
}
for name, size in pac_sizes.items()
},
"entries": manifest_entries,
}
(output_root / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
return manifest
def main() -> int:
parser = argparse.ArgumentParser(description="Extract indexed records from Ace Combat 6 DATA00/01.PAC using DATA.TBL.")
parser.add_argument("asset_root", type=Path, help="Directory containing DATA.TBL, DATA00.PAC, and DATA01.PAC")
parser.add_argument(
"--output",
type=Path,
default=Path("out") / "ac6_pac_extracted_raw",
help="Output directory for the manifest and extracted records",
)
parser.add_argument(
"--raw-only",
action="store_true",
help="Extract only entries marked raw in DATA.TBL and skip compressed entries",
)
args = parser.parse_args()
asset_root = args.asset_root.resolve()
output_root = args.output.resolve()
entries = parse_tbl(asset_root / "DATA.TBL")
manifest = extract_entries(asset_root, output_root, entries, include_compressed=not args.raw_only)
print(
json.dumps(
{
"entry_count": manifest["entry_count"],
"extracted_count": manifest["extracted_count"],
"skipped_count": manifest["skipped_count"],
"output_root": manifest["output_root"],
},
indent=2,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import struct
from collections import defaultdict
from pathlib import Path
DUMP_RE = re.compile(
r"^entry_(?P<record_id>\d+)_mode(?P<mode>\d+)_c(?P<compressed_size>\d+)_u(?P<decompressed_size>\d+)"
r"(?:_off(?P<source_offset>[0-9a-fA-F]+))?\.bin$"
)
def load_manifest_entries(path: Path) -> dict[tuple[int, int], list[dict]]:
manifest = json.loads(path.read_text(encoding="utf-8"))
by_pair: dict[tuple[int, int], list[dict]] = defaultdict(list)
for entry in manifest["entries"]:
if entry["storage_kind"] != "compressed":
continue
by_pair[(entry["compressed_size"], entry["decompressed_size"])].append(entry)
return by_pair
def parse_fhm(blob: bytes) -> list[dict]:
if len(blob) < 0x1C or blob[:4] != b"FHM ":
return []
count = struct.unpack_from(">I", blob, 0x10)[0]
if count == 0:
return []
table_base = 0x14
offsets_base = table_base
sizes_base = offsets_base + (count * 4)
if sizes_base + (count * 4) > len(blob):
return []
offsets = [struct.unpack_from(">I", blob, offsets_base + (i * 4))[0] for i in range(count)]
sizes = [struct.unpack_from(">I", blob, sizes_base + (i * 4))[0] for i in range(count)]
entries = []
for index, (offset, size) in enumerate(zip(offsets, sizes)):
if offset >= len(blob):
continue
end = offset + size
if end > len(blob):
next_offset = offsets[index + 1] if index + 1 < len(offsets) else len(blob)
end = min(next_offset, len(blob))
if end <= offset:
continue
child = blob[offset:end]
entries.append(
{
"index": index,
"offset": offset,
"size": len(child),
"magic": child[:4].decode("ascii", errors="replace"),
"data": child,
}
)
return entries
def safe_name(name: str) -> str:
return "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in name)
def magic_extension(magic: str) -> str:
normalized = magic.strip().upper()
mapping = {
"FHM": ".fhm",
"NTXR": ".ntxr",
"NSXR": ".nsxr",
"MDLP": ".mdlp",
"PLAD": ".plad",
"BFX": ".bfx",
"BSN": ".bsn",
"ACE6": ".ace6",
"NFH": ".nfh",
}
return mapping.get(normalized, ".bin")
def extract_container(blob: bytes, container_dir: Path, output_root: Path, depth: int,
max_depth: int) -> list[dict]:
children = parse_fhm(blob)
if not children:
return []
child_entries = []
for child in children:
safe_magic = safe_name(child["magic"])
child_name = f"{child['index']:03d}_{safe_magic}{magic_extension(child['magic'])}"
child_path = container_dir / child_name
child_path.write_bytes(child["data"])
child_entry = {
"index": child["index"],
"offset": child["offset"],
"size": child["size"],
"magic": child["magic"],
"path": str(child_path.relative_to(output_root)).replace("\\", "/"),
}
if depth < max_depth and child["data"][:4] == b"FHM ":
nested_dir = container_dir / f"{child['index']:03d}_{safe_magic}"
nested_dir.mkdir(parents=True, exist_ok=True)
nested_children = extract_container(child["data"], nested_dir, output_root, depth + 1,
max_depth)
if nested_children:
child_entry["nested"] = nested_children
child_entries.append(child_entry)
return child_entries
def main() -> int:
parser = argparse.ArgumentParser(description="Extract child payloads from runtime-dumped AC6 FHM containers.")
parser.add_argument(
"--dump-dir",
type=Path,
default=Path("out") / "ac6_pac_runtime_dump",
help="Directory containing runtime PAC decode dumps",
)
parser.add_argument(
"--manifest",
type=Path,
default=Path("out") / "ac6_pac_extracted_raw" / "manifest.json",
help="Manifest produced by extract_ac6_pac.py",
)
parser.add_argument(
"--output",
type=Path,
default=Path("out") / "ac6_runtime_fhm_extracted",
help="Output directory for parsed FHM containers and child payloads",
)
parser.add_argument(
"--max-depth",
type=int,
default=4,
help="Maximum nested FHM recursion depth",
)
args = parser.parse_args()
dump_dir = args.dump_dir.resolve()
manifest_path = args.manifest.resolve()
output_root = args.output.resolve()
output_root.mkdir(parents=True, exist_ok=True)
by_pair = load_manifest_entries(manifest_path)
extracted = []
selected_dumps: dict[tuple[int, int, int, int], Path] = {}
for dump_path in sorted(dump_dir.glob("*.bin")):
match = DUMP_RE.match(dump_path.name)
if not match:
continue
meta = match.groupdict()
key = (
int(meta["record_id"]),
int(meta["mode"]),
int(meta["compressed_size"]),
int(meta["decompressed_size"]),
)
current = selected_dumps.get(key)
if current is None:
selected_dumps[key] = dump_path
continue
current_match = DUMP_RE.match(current.name)
assert current_match is not None
current_has_offset = current_match.groupdict()["source_offset"] is not None
new_has_offset = meta["source_offset"] is not None
if new_has_offset and not current_has_offset:
selected_dumps[key] = dump_path
for dump_path in sorted(selected_dumps.values()):
match = DUMP_RE.match(dump_path.name)
assert match is not None
meta = match.groupdict()
compressed_size = int(meta["compressed_size"])
decompressed_size = int(meta["decompressed_size"])
codec_mode = int(meta["mode"])
record_id = int(meta["record_id"])
source_offset = int(meta["source_offset"], 16) if meta["source_offset"] else None
candidates = by_pair.get((compressed_size, decompressed_size), [])
base_label = (
f"idx_{candidates[0]['index']:04d}"
if len(candidates) == 1
else f"pair_c{compressed_size}_u{decompressed_size}"
)
container_dir = output_root / safe_name(base_label)
container_dir.mkdir(parents=True, exist_ok=True)
blob = dump_path.read_bytes()
children = parse_fhm(blob)
if not children:
raw_path = container_dir / dump_path.name
raw_path.write_bytes(blob)
extracted.append(
{
"dump": dump_path.name,
"record_id": record_id,
"codec_mode": codec_mode,
"compressed_size": compressed_size,
"decompressed_size": decompressed_size,
"source_offset": source_offset,
"candidate_indexes": [entry["index"] for entry in candidates],
"kind": "raw",
"path": str(raw_path.relative_to(output_root)).replace("\\", "/"),
}
)
continue
child_entries = extract_container(blob, container_dir, output_root, 0, args.max_depth)
extracted.append(
{
"dump": dump_path.name,
"record_id": record_id,
"codec_mode": codec_mode,
"compressed_size": compressed_size,
"decompressed_size": decompressed_size,
"source_offset": source_offset,
"candidate_indexes": [entry["index"] for entry in candidates],
"kind": "fhm",
"child_count": len(child_entries),
"children": child_entries,
}
)
manifest = {
"dump_dir": str(dump_dir),
"manifest": str(manifest_path),
"output": str(output_root),
"containers": extracted,
}
(output_root / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
print(
json.dumps(
{
"containers": len(extracted),
"output": str(output_root),
},
indent=2,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+14
View File
@@ -0,0 +1,14 @@
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
$exePath = Join-Path $repoRoot 'out\build\win-amd64-relwithdebinfo\ac6recomp.exe'
if (-not (Test-Path -LiteralPath $exePath)) {
throw "ac6recomp.exe not found at $exePath"
}
$env:AC6_DUMP_PAC_DECODED = '1'
Write-Host "AC6_DUMP_PAC_DECODED=1"
Write-Host "Launching $exePath"
Start-Process -FilePath $exePath -WorkingDirectory (Split-Path -Parent $exePath)
+333
View File
@@ -0,0 +1,333 @@
#include <algorithm>
#include <array>
#include <cctype>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
extern "C" {
#include "mspack/lzx.h"
#include "mspack/mspack.h"
}
namespace {
constexpr uint32_t kEntrySize = 16;
constexpr uint32_t kHeaderSize = 8;
constexpr int kInputBufferSize = 1 << 15;
struct TblEntry {
uint32_t group;
uint32_t offset;
uint32_t compressed_size;
uint32_t decompressed_size;
};
struct ProbeInput {
std::vector<uint8_t> data;
size_t pos = 0;
};
struct ProbeOutput {
std::vector<uint8_t> data;
};
struct ProbeFile {
mspack_file base{};
ProbeInput* input = nullptr;
ProbeOutput* output = nullptr;
};
struct ProbeResult {
int window_bits = 0;
int reset_interval = 0;
int status = MSPACK_ERR_ARGS;
std::vector<uint8_t> output;
};
uint32_t ReadBE32(const uint8_t* bytes) {
return (uint32_t(bytes[0]) << 24) | (uint32_t(bytes[1]) << 16) |
(uint32_t(bytes[2]) << 8) | uint32_t(bytes[3]);
}
std::string HexPrefix(const std::vector<uint8_t>& data, size_t count) {
std::ostringstream out;
out << std::hex << std::setfill('0');
const size_t limit = std::min(count, data.size());
for (size_t i = 0; i < limit; ++i) {
out << std::setw(2) << unsigned(data[i]);
}
return out.str();
}
std::string AsciiPrefix(const std::vector<uint8_t>& data, size_t count) {
std::string out;
const size_t limit = std::min(count, data.size());
out.reserve(limit);
for (size_t i = 0; i < limit; ++i) {
const unsigned char c = data[i];
out.push_back(std::isprint(c) ? char(c) : '.');
}
return out;
}
std::optional<std::vector<uint8_t>> ReadFile(const std::string& path) {
std::ifstream file(path, std::ios::binary);
if (!file) {
return std::nullopt;
}
file.seekg(0, std::ios::end);
const auto size = file.tellg();
if (size < 0) {
return std::nullopt;
}
file.seekg(0, std::ios::beg);
std::vector<uint8_t> data(static_cast<size_t>(size));
if (!data.empty()) {
file.read(reinterpret_cast<char*>(data.data()), static_cast<std::streamsize>(data.size()));
if (!file) {
return std::nullopt;
}
}
return data;
}
std::optional<std::vector<TblEntry>> ParseTbl(const std::string& path) {
const auto bytes = ReadFile(path);
if (!bytes || bytes->size() < kHeaderSize) {
return std::nullopt;
}
const uint32_t count = ReadBE32(bytes->data());
const uint32_t pack_count = ReadBE32(bytes->data() + 4);
(void)pack_count;
if (bytes->size() != kHeaderSize + (size_t(count) * kEntrySize)) {
return std::nullopt;
}
std::vector<TblEntry> entries;
entries.reserve(count);
for (uint32_t i = 0; i < count; ++i) {
const uint8_t* p = bytes->data() + kHeaderSize + (i * kEntrySize);
entries.push_back(TblEntry{
ReadBE32(p + 0),
ReadBE32(p + 4),
ReadBE32(p + 8),
ReadBE32(p + 12),
});
}
return entries;
}
int ProbeRead(mspack_file* file, void* buffer, int bytes) {
auto* handle = reinterpret_cast<ProbeFile*>(file);
if (!handle || !handle->input || bytes < 0) {
return -1;
}
const size_t remaining = handle->input->data.size() - handle->input->pos;
const size_t to_read = std::min<size_t>(remaining, static_cast<size_t>(bytes));
if (to_read > 0) {
std::memcpy(buffer, handle->input->data.data() + handle->input->pos, to_read);
handle->input->pos += to_read;
}
return static_cast<int>(to_read);
}
int ProbeWrite(mspack_file* file, void* buffer, int bytes) {
auto* handle = reinterpret_cast<ProbeFile*>(file);
if (!handle || !handle->output || bytes < 0) {
return -1;
}
const auto* src = reinterpret_cast<const uint8_t*>(buffer);
handle->output->data.insert(handle->output->data.end(), src, src + bytes);
return bytes;
}
int ProbeSeek(mspack_file* file, off_t offset, int mode) {
auto* handle = reinterpret_cast<ProbeFile*>(file);
if (!handle || !handle->input) {
return -1;
}
size_t base = 0;
switch (mode) {
case MSPACK_SYS_SEEK_START:
base = 0;
break;
case MSPACK_SYS_SEEK_CUR:
base = handle->input->pos;
break;
case MSPACK_SYS_SEEK_END:
base = handle->input->data.size();
break;
default:
return -1;
}
if (offset < 0 && static_cast<size_t>(-offset) > base) {
return -1;
}
const size_t next = offset >= 0 ? base + static_cast<size_t>(offset)
: base - static_cast<size_t>(-offset);
if (next > handle->input->data.size()) {
return -1;
}
handle->input->pos = next;
return 0;
}
off_t ProbeTell(mspack_file* file) {
auto* handle = reinterpret_cast<ProbeFile*>(file);
if (!handle || !handle->input) {
return off_t(-1);
}
return static_cast<off_t>(handle->input->pos);
}
void ProbeMessage(mspack_file*, const char* format, ...) {
std::va_list args;
va_start(args, format);
std::vfprintf(stderr, format, args);
std::fputc('\n', stderr);
va_end(args);
}
void* ProbeAlloc(mspack_system*, size_t bytes) {
return std::malloc(bytes);
}
void ProbeFree(void* ptr) {
std::free(ptr);
}
void ProbeCopy(void* src, void* dest, size_t bytes) {
std::memcpy(dest, src, bytes);
}
ProbeResult TryLzx(const std::vector<uint8_t>& compressed, uint32_t expected_size,
int window_bits, int reset_interval) {
ProbeInput input{compressed, 0};
ProbeOutput output;
ProbeFile in_file{};
ProbeFile out_file{};
in_file.input = &input;
out_file.output = &output;
mspack_system system{};
system.open = nullptr;
system.close = nullptr;
system.read = &ProbeRead;
system.write = &ProbeWrite;
system.seek = &ProbeSeek;
system.tell = &ProbeTell;
system.message = &ProbeMessage;
system.alloc = &ProbeAlloc;
system.free = &ProbeFree;
system.copy = &ProbeCopy;
system.null_ptr = nullptr;
ProbeResult result;
result.window_bits = window_bits;
result.reset_interval = reset_interval;
lzxd_stream* lzx = lzxd_init(&system, &in_file.base, &out_file.base, window_bits,
reset_interval, kInputBufferSize, expected_size, 0);
if (!lzx) {
result.status = MSPACK_ERR_NOMEMORY;
return result;
}
result.status = lzxd_decompress(lzx, expected_size);
result.output = std::move(output.data);
lzxd_free(lzx);
return result;
}
std::string PacPathForGroup(const std::string& asset_root, uint32_t group) {
const bool is_data01 = (group & 0x01000000u) != 0;
return asset_root + "\\" + (is_data01 ? "DATA01.PAC" : "DATA00.PAC");
}
void Usage() {
std::cerr << "usage: pac_probe_lzx <asset_root> <entry_index>\n";
}
} // namespace
int main(int argc, char** argv) {
if (argc != 3) {
Usage();
return 1;
}
const std::string asset_root = argv[1];
const int entry_index = std::atoi(argv[2]);
if (entry_index < 0) {
std::cerr << "invalid entry index\n";
return 1;
}
const auto entries = ParseTbl(asset_root + "\\DATA.TBL");
if (!entries) {
std::cerr << "failed to parse DATA.TBL\n";
return 1;
}
if (static_cast<size_t>(entry_index) >= entries->size()) {
std::cerr << "entry index out of range\n";
return 1;
}
const TblEntry& entry = (*entries)[entry_index];
const auto pac_bytes = ReadFile(PacPathForGroup(asset_root, entry.group));
if (!pac_bytes) {
std::cerr << "failed to read PAC file\n";
return 1;
}
if (size_t(entry.offset) + size_t(entry.compressed_size) > pac_bytes->size()) {
std::cerr << "entry is out of bounds for PAC file\n";
return 1;
}
const std::vector<uint8_t> compressed(
pac_bytes->begin() + entry.offset,
pac_bytes->begin() + entry.offset + entry.compressed_size);
std::cout << "entry=" << entry_index << " group=0x" << std::hex << entry.group
<< " offset=0x" << entry.offset << " csize=0x" << entry.compressed_size
<< " usize=0x" << entry.decompressed_size << std::dec << "\n";
std::cout << "compressed_head_hex=" << HexPrefix(compressed, 32) << "\n";
std::array<int, 7> reset_candidates{0, 1, 2, 4, 8, 16, 32};
bool found = false;
for (int window_bits = 15; window_bits <= 21; ++window_bits) {
for (int reset_interval : reset_candidates) {
ProbeResult result = TryLzx(compressed, entry.decompressed_size, window_bits, reset_interval);
if (result.status == MSPACK_ERR_OK && result.output.size() == entry.decompressed_size) {
found = true;
std::cout << "OK window_bits=" << window_bits
<< " reset_interval=" << reset_interval
<< " out_head_hex=" << HexPrefix(result.output, 32)
<< " out_head_ascii=" << AsciiPrefix(result.output, 32) << "\n";
} else {
std::cout << "FAIL window_bits=" << window_bits
<< " reset_interval=" << reset_interval
<< " status=" << result.status
<< " produced=" << result.output.size() << "\n";
}
}
}
return found ? 0 : 2;
}
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import struct
from pathlib import Path
def be32(blob: bytes, offset: int) -> int:
return struct.unpack_from(">I", blob, offset)[0]
def read_c_string(blob: bytes, offset: int) -> str:
end = blob.find(b"\0", offset)
if end < 0:
end = len(blob)
return blob[offset:end].decode("ascii", errors="replace")
def extract_ascii_strings(blob: bytes) -> list[dict[str, int | str]]:
results = []
for match in re.finditer(rb"[ -~]{4,}", blob):
text = match.group().decode("ascii", errors="replace")
if text.count("?") > len(text) // 2:
continue
results.append({"offset": match.start(), "text": text})
return results
def detect_texture_table(blob: bytes, ntxr_count: int) -> tuple[int, list[dict[str, int]]] | tuple[None, list]:
if ntxr_count <= 0:
return None, []
max_start = max(0, len(blob) - ntxr_count * 12)
for start in range(0, max_start + 1, 4):
entries = []
ok = True
for index in range(ntxr_count):
off = start + index * 12
tex_id = be32(blob, off + 0)
width, height = struct.unpack_from(">HH", blob, off + 4)
flags = be32(blob, off + 8)
if tex_id != index:
ok = False
break
if width <= 0 or height <= 0 or width > 8192 or height > 8192:
ok = False
break
entries.append(
{
"texture_id": tex_id,
"width": width,
"height": height,
"flags": flags,
}
)
if ok:
return start, entries
return None, []
def parse_one(swg_path: Path) -> dict:
blob = swg_path.read_bytes()
if blob[:4] != b"SWG\0":
raise ValueError(f"{swg_path} is not an SWG blob")
sibling_ntxrs = sorted(swg_path.parent.glob("*_NTXR.ntxr"))
table_offset, texture_table = detect_texture_table(blob, len(sibling_ntxrs))
if texture_table:
for entry in texture_table:
candidate = swg_path.parent / f"{entry['texture_id'] + 1:03d}_NTXR.ntxr"
entry["candidate_ntxr"] = candidate.name if candidate.exists() else None
strings = extract_ascii_strings(blob)
return {
"source": str(swg_path),
"magic": blob[:4].decode("ascii", errors="replace"),
"widget_name": read_c_string(blob, 0x08) if len(blob) > 0x08 else "",
"size": len(blob),
"sibling_ntxr_count": len(sibling_ntxrs),
"texture_table_offset": table_offset,
"texture_table": texture_table,
"strings": strings,
}
def main() -> int:
parser = argparse.ArgumentParser(description="Parse AC6 SWG UI metadata blobs.")
parser.add_argument(
"--input",
type=Path,
default=Path("out") / "ac6_runtime_fhm_typed",
help="SWG file or directory to scan",
)
parser.add_argument(
"--output",
type=Path,
default=Path("out") / "ac6_runtime_swg_parsed",
help="Output directory for parsed SWG json files",
)
args = parser.parse_args()
input_path = args.input.resolve()
output_root = args.output.resolve()
output_root.mkdir(parents=True, exist_ok=True)
swg_files = [input_path] if input_path.is_file() else sorted(input_path.rglob("*_SWG_.bin"))
parsed = []
for swg_path in swg_files:
result = parse_one(swg_path)
relative = swg_path.relative_to(input_path.parent if input_path.is_file() else input_path)
out_path = output_root / relative.with_suffix(".json")
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(result, indent=2), encoding="utf-8")
parsed.append(
{
"source": str(relative).replace("\\", "/"),
"output": str(out_path.relative_to(output_root)).replace("\\", "/"),
"widget_name": result["widget_name"],
"texture_table_offset": result["texture_table_offset"],
"texture_count": len(result["texture_table"]),
}
)
manifest = {
"input": str(input_path),
"output": str(output_root),
"parsed_count": len(parsed),
"files": parsed,
}
(output_root / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
print(json.dumps({"parsed_count": len(parsed), "output": str(output_root)}, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
THIS_DIR = Path(__file__).resolve().parent
DEFAULT_ASSET_ROOT = Path("out") / "build" / "win-amd64-relwithdebinfo" / "assets"
DEFAULT_RAW_OUT = Path("out") / "ac6_pac_extracted_raw"
DEFAULT_DUMP_DIR = Path("out") / "ac6_pac_runtime_dump"
DEFAULT_TYPED_OUT = Path("out") / "ac6_runtime_fhm_typed"
DEFAULT_SWG_OUT = Path("out") / "ac6_runtime_swg_parsed"
DEFAULT_NTXR_OUT = Path("out") / "ac6_runtime_ntxr_exported"
def run_step(args: list[str], cwd: Path) -> None:
cmd = [sys.executable, *args]
subprocess.run(cmd, cwd=cwd, check=True)
def read_json(path: Path) -> dict | None:
if not path.exists():
return None
return json.loads(path.read_text(encoding="utf-8"))
def count_fhm_containers(manifest: dict | None) -> int | None:
if not manifest:
return None
containers = manifest.get("containers")
if isinstance(containers, list):
return len(containers)
return None
def main() -> int:
parser = argparse.ArgumentParser(
description="Run the AC6 asset extraction pipeline over PAC archives and collected runtime decode dumps."
)
parser.add_argument(
"--asset-root",
type=Path,
default=DEFAULT_ASSET_ROOT,
help="Directory containing DATA.TBL, DATA00.PAC, and DATA01.PAC",
)
parser.add_argument(
"--raw-out",
type=Path,
default=DEFAULT_RAW_OUT,
help="Output directory for extract_ac6_pac.py",
)
parser.add_argument(
"--dump-dir",
type=Path,
default=DEFAULT_DUMP_DIR,
help="Directory containing runtime PAC decode dumps",
)
parser.add_argument(
"--typed-out",
type=Path,
default=DEFAULT_TYPED_OUT,
help="Output directory for extract_ac6_runtime_fhm.py",
)
parser.add_argument(
"--swg-out",
type=Path,
default=DEFAULT_SWG_OUT,
help="Output directory for parse_ac6_swg.py",
)
parser.add_argument(
"--ntxr-out",
type=Path,
default=DEFAULT_NTXR_OUT,
help="Output directory for export_ac6_ntxr.py",
)
parser.add_argument(
"--raw-only",
action="store_true",
help="Pass --raw-only to extract_ac6_pac.py",
)
parser.add_argument(
"--skip-pac-extract",
action="store_true",
help="Skip extract_ac6_pac.py even if the PAC archives are available",
)
args = parser.parse_args()
repo_root = THIS_DIR.parent
asset_root = args.asset_root.resolve()
raw_out = args.raw_out.resolve()
dump_dir = args.dump_dir.resolve()
typed_out = args.typed_out.resolve()
swg_out = args.swg_out.resolve()
ntxr_out = args.ntxr_out.resolve()
raw_manifest = raw_out / "manifest.json"
if not args.skip_pac_extract:
if asset_root.exists():
pac_args = [str(THIS_DIR / "extract_ac6_pac.py"), str(asset_root), "--output", str(raw_out)]
if args.raw_only:
pac_args.append("--raw-only")
run_step(pac_args, repo_root)
elif not raw_manifest.exists():
raise SystemExit(f"asset root does not exist and no prior raw manifest is available: {asset_root}")
if not dump_dir.exists():
raise SystemExit(f"runtime dump directory does not exist: {dump_dir}")
fhm_args = [
str(THIS_DIR / "extract_ac6_runtime_fhm.py"),
"--dump-dir",
str(dump_dir),
"--output",
str(typed_out),
]
if raw_manifest.exists():
fhm_args.extend(["--manifest", str(raw_manifest)])
run_step(fhm_args, repo_root)
run_step(
[
str(THIS_DIR / "parse_ac6_swg.py"),
"--input",
str(typed_out),
"--output",
str(swg_out),
],
repo_root,
)
run_step(
[
str(THIS_DIR / "export_ac6_ntxr.py"),
"--input",
str(typed_out),
"--output",
str(ntxr_out),
],
repo_root,
)
summary = {
"asset_root": str(asset_root),
"raw_manifest": str(raw_manifest) if raw_manifest.exists() else None,
"dump_dir": str(dump_dir),
"typed_manifest": str(typed_out / "manifest.json"),
"swg_manifest": str(swg_out / "manifest.json"),
"ntxr_manifest": str(ntxr_out / "manifest.json"),
}
raw_data = read_json(raw_manifest)
typed_data = read_json(typed_out / "manifest.json")
swg_data = read_json(swg_out / "manifest.json")
ntxr_data = read_json(ntxr_out / "manifest.json")
if raw_data:
summary["pac_entries"] = raw_data.get("entry_count")
summary["pac_extracted"] = raw_data.get("extracted_count")
summary["pac_skipped"] = raw_data.get("skipped_count")
fhm_containers = count_fhm_containers(typed_data)
if fhm_containers is not None:
summary["fhm_containers"] = fhm_containers
if swg_data:
summary["swg_files"] = swg_data.get("parsed_count")
if ntxr_data:
summary["textures_exported"] = ntxr_data.get("exported_count")
summary["textures_skipped"] = ntxr_data.get("skipped_count")
print(json.dumps(summary, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())