новая структура проекта
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
project(mpq_extractor VERSION 1.0.0)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# ── Paths ──────────────────────────────────────────────────────────────────────
|
||||
set(GODOT_CPP_DIR "${CMAKE_SOURCE_DIR}/../../third_party/godot-cpp" CACHE PATH "Path to godot-cpp")
|
||||
set(STORMLIB_DIR "${CMAKE_SOURCE_DIR}/../../third_party/StormLib" CACHE PATH "Path to StormLib")
|
||||
|
||||
# ── godot-cpp ──────────────────────────────────────────────────────────────────
|
||||
add_subdirectory(${GODOT_CPP_DIR} godot-cpp)
|
||||
|
||||
# ── StormLib ───────────────────────────────────────────────────────────────────
|
||||
# Disable StormLib's own tests/examples
|
||||
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
|
||||
set(STORMLIB_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||
add_subdirectory(${STORMLIB_DIR} StormLib)
|
||||
|
||||
# ── Extension library ──────────────────────────────────────────────────────────
|
||||
file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS "src/*.cpp")
|
||||
|
||||
add_library(mpq_extractor SHARED ${SOURCES})
|
||||
|
||||
target_include_directories(mpq_extractor PRIVATE
|
||||
src
|
||||
${STORMLIB_DIR}/src
|
||||
)
|
||||
|
||||
target_link_libraries(mpq_extractor PRIVATE
|
||||
godot-cpp
|
||||
storm
|
||||
)
|
||||
|
||||
# ── Output: addons/mpq_extractor/bin/ ─────────────────────────────────────────
|
||||
set(OUTPUT_DIR "${CMAKE_SOURCE_DIR}/../../addons/mpq_extractor/bin")
|
||||
|
||||
set_target_properties(mpq_extractor PROPERTIES
|
||||
LIBRARY_OUTPUT_DIRECTORY "${OUTPUT_DIR}"
|
||||
RUNTIME_OUTPUT_DIRECTORY "${OUTPUT_DIR}"
|
||||
LIBRARY_OUTPUT_DIRECTORY_DEBUG "${OUTPUT_DIR}"
|
||||
LIBRARY_OUTPUT_DIRECTORY_RELEASE "${OUTPUT_DIR}"
|
||||
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${OUTPUT_DIR}"
|
||||
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${OUTPUT_DIR}"
|
||||
)
|
||||
|
||||
# Platform-specific naming (Godot convention: lib<name>.<platform>.<arch>.dll/so)
|
||||
if(WIN32)
|
||||
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
|
||||
set(PLATFORM_SUFFIX "windows.x86_64")
|
||||
else()
|
||||
set(PLATFORM_SUFFIX "windows.x86_32")
|
||||
endif()
|
||||
set_target_properties(mpq_extractor PROPERTIES
|
||||
PREFIX ""
|
||||
OUTPUT_NAME "libmpq_extractor.${PLATFORM_SUFFIX}"
|
||||
SUFFIX ".dll"
|
||||
)
|
||||
elseif(UNIX AND NOT APPLE)
|
||||
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
|
||||
set(PLATFORM_SUFFIX "linux.x86_64")
|
||||
else()
|
||||
set(PLATFORM_SUFFIX "linux.x86_32")
|
||||
endif()
|
||||
set_target_properties(mpq_extractor PROPERTIES
|
||||
PREFIX ""
|
||||
OUTPUT_NAME "libmpq_extractor.${PLATFORM_SUFFIX}"
|
||||
SUFFIX ".so"
|
||||
)
|
||||
elseif(APPLE)
|
||||
set_target_properties(mpq_extractor PROPERTIES
|
||||
PREFIX ""
|
||||
OUTPUT_NAME "libmpq_extractor.macos.universal"
|
||||
SUFFIX ".dylib"
|
||||
)
|
||||
endif()
|
||||
|
||||
# ── Install (optional) ─────────────────────────────────────────────────────────
|
||||
message(STATUS "mpq_extractor → ${OUTPUT_DIR}")
|
||||
@@ -0,0 +1,78 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
rem Build script for mpq_extractor GDExtension on Windows.
|
||||
rem Requires Visual Studio 2022 with the C++ workload installed.
|
||||
|
||||
set BUILD_TYPE=%1
|
||||
if "%BUILD_TYPE%"=="" set BUILD_TYPE=Release
|
||||
|
||||
set SCRIPT_DIR=%~dp0
|
||||
if "%SCRIPT_DIR:~-1%"=="\" set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
|
||||
set BUILD_DIR=%SCRIPT_DIR%\build
|
||||
|
||||
echo [mpq_extractor] BUILD_TYPE=%BUILD_TYPE%
|
||||
|
||||
rem Locate cmake.exe, first from PATH and then from common VS2022 installs.
|
||||
set VS_CMAKE=
|
||||
for /f "delims=" %%i in ('where cmake 2^>nul') do (
|
||||
set VS_CMAKE=%%i
|
||||
goto :cmake_found
|
||||
)
|
||||
|
||||
set VSCMAKE_GUESS=C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe
|
||||
if exist "%VSCMAKE_GUESS%" (
|
||||
set VS_CMAKE=%VSCMAKE_GUESS%
|
||||
goto :cmake_found
|
||||
)
|
||||
|
||||
set VSCMAKE_GUESS=C:\Program Files\Microsoft Visual Studio\2022\Professional\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe
|
||||
if exist "%VSCMAKE_GUESS%" (
|
||||
set VS_CMAKE=%VSCMAKE_GUESS%
|
||||
goto :cmake_found
|
||||
)
|
||||
|
||||
set VSCMAKE_GUESS=C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe
|
||||
if exist "%VSCMAKE_GUESS%" (
|
||||
set VS_CMAKE=%VSCMAKE_GUESS%
|
||||
goto :cmake_found
|
||||
)
|
||||
|
||||
echo ERROR: cmake.exe not found.
|
||||
echo Install CMake or add it to PATH.
|
||||
exit /b 1
|
||||
|
||||
:cmake_found
|
||||
echo [mpq_extractor] cmake: %VS_CMAKE%
|
||||
|
||||
rem Initialize submodules when needed.
|
||||
if not exist "%SCRIPT_DIR%\..\..\third_party\godot-cpp\CMakeLists.txt" (
|
||||
echo [mpq_extractor] Initializing submodules...
|
||||
cd /d "%SCRIPT_DIR%\..\.."
|
||||
git submodule update --init --recursive
|
||||
if errorlevel 1 (
|
||||
echo ERROR: git submodule update failed.
|
||||
exit /b 1
|
||||
)
|
||||
cd /d "%SCRIPT_DIR%"
|
||||
)
|
||||
|
||||
rem Configure.
|
||||
"%VS_CMAKE%" -S "%SCRIPT_DIR%" -B "%BUILD_DIR%" -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=%BUILD_TYPE%
|
||||
if errorlevel 1 (
|
||||
echo ERROR: cmake configure failed.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
rem Build.
|
||||
"%VS_CMAKE%" --build "%BUILD_DIR%" --config %BUILD_TYPE% --parallel
|
||||
if errorlevel 1 (
|
||||
echo ERROR: cmake build failed.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [mpq_extractor] Done. DLL is in:
|
||||
echo %SCRIPT_DIR%\..\..\addons\mpq_extractor\bin\
|
||||
|
||||
endlocal
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build script for mpq_extractor GDExtension on Linux/macOS
|
||||
set -e
|
||||
|
||||
BUILD_TYPE="${1:-Release}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
|
||||
echo "[mpq_extractor] BUILD_TYPE=$BUILD_TYPE"
|
||||
|
||||
# Init submodules if needed
|
||||
if [ ! -f "$SCRIPT_DIR/../../third_party/godot-cpp/CMakeLists.txt" ]; then
|
||||
echo "[mpq_extractor] Initializing submodules..."
|
||||
cd "$SCRIPT_DIR/../.."
|
||||
git submodule update --init --recursive
|
||||
fi
|
||||
|
||||
# Configure
|
||||
cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" \
|
||||
-DCMAKE_BUILD_TYPE="$BUILD_TYPE"
|
||||
|
||||
# Build
|
||||
cmake --build "$BUILD_DIR" --config "$BUILD_TYPE" --parallel "$(nproc 2>/dev/null || sysctl -n hw.logicalcpu)"
|
||||
|
||||
echo ""
|
||||
echo "[mpq_extractor] Done. Library is in:"
|
||||
echo " $SCRIPT_DIR/../../addons/mpq_extractor/bin/"
|
||||
@@ -0,0 +1,643 @@
|
||||
#include "adt_loader.h"
|
||||
#include "wow_chunk_reader.h"
|
||||
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
#include <godot_cpp/variant/vector3.hpp>
|
||||
|
||||
#include <fstream>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ADT constants
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static constexpr float TILE_SIZE = 533.33333f; // yards per map tile
|
||||
static constexpr float CHUNK_SIZE = TILE_SIZE / 16.f; // 33.333 yards
|
||||
static constexpr float UNIT_SIZE = CHUNK_SIZE / 8.f; // 4.166 yards (between outer verts)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Raw structures
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
#pragma pack(push, 1)
|
||||
|
||||
struct MDDFEntry { // M2 doodad placement
|
||||
uint32_t nameId;
|
||||
uint32_t uniqueId;
|
||||
float pos[3]; // WoW world coords
|
||||
float rot[3]; // degrees
|
||||
uint16_t scale; // 1024 = 1.0
|
||||
uint16_t flags;
|
||||
};
|
||||
|
||||
struct MODFEntry { // WMO placement
|
||||
uint32_t nameId;
|
||||
uint32_t uniqueId;
|
||||
float pos[3];
|
||||
float rot[3];
|
||||
float bboxMin[3];
|
||||
float bboxMax[3];
|
||||
uint16_t flags;
|
||||
uint16_t doodadSet;
|
||||
uint16_t nameSet;
|
||||
uint16_t padding;
|
||||
};
|
||||
|
||||
struct MCNKHeader {
|
||||
uint32_t flags;
|
||||
uint32_t indexX;
|
||||
uint32_t indexY;
|
||||
uint32_t nLayers;
|
||||
uint32_t nDoodadRefs;
|
||||
uint32_t ofsMCVT;
|
||||
uint32_t ofsMCNR;
|
||||
uint32_t ofsMCLY;
|
||||
uint32_t ofsMCRF;
|
||||
uint32_t ofsMCAL;
|
||||
uint32_t sizeMCAL;
|
||||
uint32_t ofsMCSH;
|
||||
uint32_t sizeMCSH;
|
||||
uint32_t areaId;
|
||||
uint32_t nMapObjRefs;
|
||||
uint32_t holes; // low 16 bits are the classic 4x4 hole mask
|
||||
uint16_t doodadMapping[8];
|
||||
uint8_t doodadStencil[8];
|
||||
uint32_t ofsMCSE;
|
||||
uint32_t nSoundEmitters;
|
||||
uint32_t ofsMCLQ;
|
||||
uint32_t sizeMCLQ;
|
||||
float zpos;
|
||||
float xpos;
|
||||
float ypos;
|
||||
uint32_t ofsMCCV;
|
||||
uint32_t unused1;
|
||||
uint32_t unused2;
|
||||
};
|
||||
|
||||
struct MCLYEntry {
|
||||
uint32_t textureId;
|
||||
uint32_t flags;
|
||||
uint32_t ofsMCAL;
|
||||
int32_t effectId;
|
||||
};
|
||||
|
||||
struct MH2OHeader {
|
||||
uint32_t ofsInformation;
|
||||
uint32_t nLayers;
|
||||
uint32_t ofsAttributes;
|
||||
};
|
||||
|
||||
struct MH2OInformation {
|
||||
uint16_t liquidId;
|
||||
uint16_t liquidVertexFormat;
|
||||
float minHeight;
|
||||
float maxHeight;
|
||||
uint8_t xOffset;
|
||||
uint8_t yOffset;
|
||||
uint8_t width;
|
||||
uint8_t height;
|
||||
uint32_t ofsInfoMask;
|
||||
uint32_t ofsHeightMap;
|
||||
};
|
||||
|
||||
#pragma pack(pop)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
std::vector<uint8_t> ADTLoader::read_file(const std::string &path) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) return {};
|
||||
auto sz = f.tellg(); f.seekg(0);
|
||||
std::vector<uint8_t> buf(sz);
|
||||
f.read(reinterpret_cast<char *>(buf.data()), sz);
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string ADTLoader::to_std(const String &s) {
|
||||
return std::string(s.utf8().get_data());
|
||||
}
|
||||
|
||||
String ADTLoader::to_godot(const std::string &s) {
|
||||
return String(s.c_str());
|
||||
}
|
||||
|
||||
PackedStringArray ADTLoader::parse_names_with_offsets(
|
||||
const WoWChunk &names_chunk, const WoWChunk &ids_chunk)
|
||||
{
|
||||
PackedStringArray result;
|
||||
if (names_chunk.size == 0 || ids_chunk.size == 0) return result;
|
||||
|
||||
const char *ndata = reinterpret_cast<const char *>(names_chunk.data);
|
||||
const uint32_t *offsets = ids_chunk.array<uint32_t>();
|
||||
uint32_t n = ids_chunk.size / 4;
|
||||
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
uint32_t ofs = offsets[i];
|
||||
if (ofs >= names_chunk.size) { result.push_back(""); continue; }
|
||||
result.push_back(String(ndata + ofs));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
static constexpr uint32_t MCNK_FLAG_DO_NOT_FIX_ALPHA_MAP = 0x8000;
|
||||
static constexpr uint32_t MCLY_FLAG_ALPHA_COMPRESSED = 0x200;
|
||||
static constexpr size_t MCNK_CHUNK_HEADER_SIZE = 8;
|
||||
|
||||
bool mcnk_subchunk_fits(uint32_t offset_from_chunk_start, size_t bytes_needed, size_t mcnk_payload_size) {
|
||||
if (offset_from_chunk_start < MCNK_CHUNK_HEADER_SIZE) {
|
||||
return false;
|
||||
}
|
||||
const size_t offset_in_payload = (size_t)offset_from_chunk_start - MCNK_CHUNK_HEADER_SIZE;
|
||||
return offset_in_payload + bytes_needed <= mcnk_payload_size;
|
||||
}
|
||||
|
||||
bool mh2o_region_fits(uint32_t offset_in_payload, size_t bytes_needed, size_t mh2o_payload_size) {
|
||||
const size_t offset = (size_t)offset_in_payload;
|
||||
return offset <= mh2o_payload_size && bytes_needed <= mh2o_payload_size - offset;
|
||||
}
|
||||
|
||||
const uint8_t *mcnk_subchunk_ptr(const uint8_t *mcnk_payload, uint32_t offset_from_chunk_start) {
|
||||
if (offset_from_chunk_start < MCNK_CHUNK_HEADER_SIZE) {
|
||||
return nullptr;
|
||||
}
|
||||
return mcnk_payload + ((size_t)offset_from_chunk_start - MCNK_CHUNK_HEADER_SIZE);
|
||||
}
|
||||
|
||||
PackedByteArray decode_mcal_big_alpha(const uint8_t *src, size_t available_bytes) {
|
||||
PackedByteArray out;
|
||||
if (available_bytes < 64 * 64) {
|
||||
return out;
|
||||
}
|
||||
|
||||
out.resize(64 * 64);
|
||||
std::memcpy(out.ptrw(), src, 64 * 64);
|
||||
return out;
|
||||
}
|
||||
|
||||
PackedByteArray decode_mcal_old_alpha(const uint8_t *src, size_t available_bytes, bool fix_edges) {
|
||||
PackedByteArray out;
|
||||
if (available_bytes < 64 * 32) {
|
||||
return out;
|
||||
}
|
||||
|
||||
out.resize(64 * 64);
|
||||
uint8_t *dst = out.ptrw();
|
||||
|
||||
// Legacy MCAL uses the old 4-bit layout.
|
||||
// Noggit decodes it into a transposed linear buffer (x-major, not row-major)
|
||||
// and uploads that buffer directly to the GPU. Reproducing that layout keeps
|
||||
// layer placement aligned with Noggit / the original client for classic ADTs.
|
||||
size_t pos = 0;
|
||||
for (int x = 0; x < 64; ++x) {
|
||||
for (int y = 0; y < 64; y += 2) {
|
||||
if (pos >= available_bytes) {
|
||||
return PackedByteArray();
|
||||
}
|
||||
|
||||
const uint8_t value = src[pos++];
|
||||
const uint8_t low = value & 0x0F;
|
||||
const uint8_t high = (value >> 4) & 0x0F;
|
||||
|
||||
dst[x * 64 + (y + 0)] = (low << 4) | low;
|
||||
dst[x * 64 + (y + 1)] = (high << 4) | high;
|
||||
}
|
||||
}
|
||||
|
||||
if (fix_edges) {
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
dst[i * 64 + 63] = dst[i * 64 + 62];
|
||||
dst[63 * 64 + i] = dst[62 * 64 + i];
|
||||
}
|
||||
dst[63 * 64 + 63] = dst[62 * 64 + 62];
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
PackedByteArray decode_mcal_compressed_alpha(const uint8_t *src, size_t available_bytes) {
|
||||
PackedByteArray out;
|
||||
out.resize(64 * 64);
|
||||
uint8_t *dst = out.ptrw();
|
||||
|
||||
size_t in_pos = 0;
|
||||
size_t out_pos = 0;
|
||||
while (in_pos < available_bytes && out_pos < 64 * 64) {
|
||||
const uint8_t header = src[in_pos++];
|
||||
const size_t count = header & 0x7F;
|
||||
const bool fill = (header & 0x80) != 0;
|
||||
|
||||
if (count == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const size_t write_count = std::min<size_t>(count, 64 * 64 - out_pos);
|
||||
if (fill) {
|
||||
if (in_pos >= available_bytes) {
|
||||
return PackedByteArray();
|
||||
}
|
||||
std::memset(dst + out_pos, src[in_pos++], write_count);
|
||||
} else {
|
||||
if (in_pos + count > available_bytes) {
|
||||
return PackedByteArray();
|
||||
}
|
||||
std::memcpy(dst + out_pos, src + in_pos, write_count);
|
||||
in_pos += count;
|
||||
}
|
||||
|
||||
out_pos += write_count;
|
||||
}
|
||||
|
||||
if (out_pos != 64 * 64) {
|
||||
return PackedByteArray();
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ADT placement tables (MDDF/MODF) do not use the same world-space basis as
|
||||
// MCNK origins. In 3.3.5a they are already expressed in tile/world space that
|
||||
// matches the streamed terrain layout, with the vector stored as X, Z, Y.
|
||||
inline void adt_placement_pos_to_godot(float px, float pz, float py,
|
||||
float &gx, float &gy, float &gz) {
|
||||
gx = px;
|
||||
gy = pz;
|
||||
gz = py;
|
||||
}
|
||||
|
||||
// MDDF/MODF Euler angles are stored in degrees using the same axis layout as
|
||||
// placement positions: Y-up, so rot[1] is yaw. Map each axis straight through
|
||||
// to Godot's Y-up Euler space — the position conversion is identity, so the
|
||||
// rotation conversion is identity too. rot[1] gets +180° later for WMOs.
|
||||
inline void adt_placement_rot_to_godot(float rx, float ry_yaw, float rz,
|
||||
float &gx, float &gy, float &gz) {
|
||||
constexpr float D2R = (float)(M_PI / 180.0);
|
||||
gx = rx * D2R;
|
||||
gy = ry_yaw * D2R;
|
||||
gz = rz * D2R;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main loader
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
Dictionary ADTLoader::load_adt_from_bytes(const PackedByteArray &bytes) {
|
||||
if (bytes.is_empty()) return Dictionary();
|
||||
// Wrap in a vector for the shared parse path
|
||||
std::vector<uint8_t> buf(bytes.size());
|
||||
std::memcpy(buf.data(), bytes.ptr(), bytes.size());
|
||||
// Reuse the body of load_adt by calling the internal parse
|
||||
// (inline here to avoid duplicating the reader setup)
|
||||
const uint8_t *raw = buf.data();
|
||||
size_t len = buf.size();
|
||||
Dictionary result;
|
||||
_parse_adt(raw, len, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
Dictionary ADTLoader::load_adt(const String &path) {
|
||||
Dictionary result;
|
||||
auto buf = read_file(to_std(path));
|
||||
if (buf.empty()) {
|
||||
UtilityFunctions::push_error("ADTLoader: cannot read ", path);
|
||||
return result;
|
||||
}
|
||||
_parse_adt(buf.data(), buf.size(), result);
|
||||
return result;
|
||||
}
|
||||
|
||||
void ADTLoader::_parse_adt(const uint8_t *raw, size_t len, Dictionary &result) {
|
||||
ChunkReader reader(raw, len);
|
||||
WoWChunk chunk;
|
||||
|
||||
// ── Pass 1: collect named chunks ──────────────────────────────────────────
|
||||
WoWChunk cMTEX{}, cMMDX{}, cMMID{}, cMWMO{}, cMWID{}, cMDDF{}, cMODF{}, cMH2O{};
|
||||
std::vector<WoWChunk> mcnk_chunks;
|
||||
mcnk_chunks.reserve(256);
|
||||
|
||||
while (reader.next(chunk)) {
|
||||
if (chunk.is("MTEX")) cMTEX = chunk;
|
||||
else if (chunk.is("MMDX")) cMMDX = chunk;
|
||||
else if (chunk.is("MMID")) cMMID = chunk;
|
||||
else if (chunk.is("MWMO")) cMWMO = chunk;
|
||||
else if (chunk.is("MWID")) cMWID = chunk;
|
||||
else if (chunk.is("MDDF")) cMDDF = chunk;
|
||||
else if (chunk.is("MODF")) cMODF = chunk;
|
||||
else if (chunk.is("MH2O")) cMH2O = chunk;
|
||||
else if (chunk.is("MCNK")) mcnk_chunks.push_back(chunk);
|
||||
}
|
||||
|
||||
// ── Textures ──────────────────────────────────────────────────────────────
|
||||
PackedStringArray textures;
|
||||
if (cMTEX.size > 0) {
|
||||
const char *p = reinterpret_cast<const char *>(cMTEX.data);
|
||||
const char *end = p + cMTEX.size;
|
||||
while (p < end) {
|
||||
textures.push_back(String(p));
|
||||
p += strlen(p) + 1;
|
||||
}
|
||||
}
|
||||
result["textures"] = textures;
|
||||
|
||||
// ── M2 / WMO names ────────────────────────────────────────────────────────
|
||||
result["m2_names"] = (cMMDX.size && cMMID.size)
|
||||
? parse_names_with_offsets(cMMDX, cMMID)
|
||||
: PackedStringArray();
|
||||
result["wmo_names"] = (cMWMO.size && cMWID.size)
|
||||
? parse_names_with_offsets(cMWMO, cMWID)
|
||||
: PackedStringArray();
|
||||
|
||||
// ── M2 placements ─────────────────────────────────────────────────────────
|
||||
Array m2_placements;
|
||||
if (cMDDF.size > 0) {
|
||||
uint32_t n = cMDDF.count_of(sizeof(MDDFEntry));
|
||||
auto *dd = cMDDF.array<MDDFEntry>();
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
float gx, gy, gz, rx, ry, rz;
|
||||
adt_placement_pos_to_godot(dd[i].pos[0], dd[i].pos[1], dd[i].pos[2], gx, gy, gz);
|
||||
adt_placement_rot_to_godot(dd[i].rot[0], dd[i].rot[1], dd[i].rot[2], rx, ry, rz);
|
||||
|
||||
Dictionary p;
|
||||
p["name_id"] = (int)dd[i].nameId;
|
||||
p["pos"] = Vector3(gx, gy, gz);
|
||||
p["rot"] = Vector3(rx, ry, rz);
|
||||
p["scale"] = dd[i].scale / 1024.f;
|
||||
m2_placements.push_back(p);
|
||||
}
|
||||
}
|
||||
result["m2_placements"] = m2_placements;
|
||||
|
||||
// ── WMO placements ────────────────────────────────────────────────────────
|
||||
Array wmo_placements;
|
||||
if (cMODF.size > 0) {
|
||||
uint32_t n = cMODF.count_of(sizeof(MODFEntry));
|
||||
auto *od = cMODF.array<MODFEntry>();
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
float gx, gy, gz, rx, ry, rz;
|
||||
adt_placement_pos_to_godot(od[i].pos[0], od[i].pos[1], od[i].pos[2], gx, gy, gz);
|
||||
adt_placement_rot_to_godot(od[i].rot[0], od[i].rot[1], od[i].rot[2], rx, ry, rz);
|
||||
|
||||
Dictionary p;
|
||||
p["name_id"] = (int)od[i].nameId;
|
||||
p["pos"] = Vector3(gx, gy, gz);
|
||||
p["rot"] = Vector3(rx, ry, rz);
|
||||
p["scale"] = 1.0f;
|
||||
wmo_placements.push_back(p);
|
||||
}
|
||||
}
|
||||
result["wmo_placements"] = wmo_placements;
|
||||
|
||||
// ── MCNK chunks ───────────────────────────────────────────────────────────
|
||||
Array chunks_array;
|
||||
chunks_array.resize((int)mcnk_chunks.size());
|
||||
|
||||
for (int ci = 0; ci < (int)mcnk_chunks.size(); ++ci) {
|
||||
const WoWChunk &mc = mcnk_chunks[ci];
|
||||
if (mc.size < sizeof(MCNKHeader)) { chunks_array[ci] = Dictionary(); continue; }
|
||||
|
||||
const MCNKHeader &hdr = *reinterpret_cast<const MCNKHeader *>(mc.data);
|
||||
const uint8_t *base = mc.data;
|
||||
|
||||
// World origin of this chunk (MCNK stores z/x/y, convert to Godot)
|
||||
float ox, oy, oz;
|
||||
wow_to_godot(hdr.zpos, hdr.xpos, hdr.ypos, ox, oy, oz);
|
||||
|
||||
// ── Heights (MCVT) ───────────────────────────────────────────────
|
||||
PackedFloat32Array heights;
|
||||
PackedVector3Array normals_arr;
|
||||
|
||||
if (hdr.ofsMCVT && mcnk_subchunk_fits(hdr.ofsMCVT, 8 + 145 * 4, mc.size)) {
|
||||
// MCVT sub-chunk: 8-byte header + 145 floats
|
||||
const uint8_t *p = mcnk_subchunk_ptr(base, hdr.ofsMCVT);
|
||||
// skip sub-chunk magic(4) + size(4)
|
||||
const float *hdata = reinterpret_cast<const float *>(p + 8);
|
||||
heights.resize(145);
|
||||
for (int j = 0; j < 145; ++j)
|
||||
heights[j] = hdata[j];
|
||||
}
|
||||
|
||||
if (hdr.ofsMCNR && mcnk_subchunk_fits(hdr.ofsMCNR, 8 + 145 * 3, mc.size)) {
|
||||
const uint8_t *p = mcnk_subchunk_ptr(base, hdr.ofsMCNR) + 8; // skip sub-chunk header
|
||||
normals_arr.resize(145);
|
||||
for (int j = 0; j < 145; ++j) {
|
||||
// MCNR stores signed bytes in WoW-axis order X, Z, Y.
|
||||
// Terrain positions are converted with wow_to_godot(wx, wy, wz),
|
||||
// so normals must be transformed with the same basis change:
|
||||
// Godot = (-WoW.Y, WoW.Z, -WoW.X).
|
||||
float wx = (int8_t)p[j * 3 + 0] / 127.f;
|
||||
float wz = (int8_t)p[j * 3 + 1] / 127.f;
|
||||
float wy = (int8_t)p[j * 3 + 2] / 127.f;
|
||||
normals_arr[j] = Vector3(-wy, wz, -wx).normalized();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Texture layers (MCLY) ────────────────────────────────────────
|
||||
Array layers;
|
||||
if (hdr.ofsMCLY && hdr.nLayers > 0 &&
|
||||
mcnk_subchunk_fits(hdr.ofsMCLY, 8 + hdr.nLayers * sizeof(MCLYEntry), mc.size))
|
||||
{
|
||||
const MCLYEntry *ly = reinterpret_cast<const MCLYEntry *>(
|
||||
mcnk_subchunk_ptr(base, hdr.ofsMCLY) + 8);
|
||||
for (uint32_t li = 0; li < hdr.nLayers; ++li) {
|
||||
Dictionary ld;
|
||||
ld["texture_id"] = (int)ly[li].textureId;
|
||||
ld["flags"] = (int)ly[li].flags;
|
||||
ld["effect_id"] = (int)ly[li].effectId;
|
||||
ld["alpha_offset"] = (int)ly[li].ofsMCAL;
|
||||
layers.push_back(ld);
|
||||
}
|
||||
}
|
||||
|
||||
Array alpha_maps;
|
||||
if (hdr.ofsMCAL && hdr.sizeMCAL >= 8 &&
|
||||
mcnk_subchunk_fits(hdr.ofsMCAL, hdr.sizeMCAL, mc.size) &&
|
||||
layers.size() > 1)
|
||||
{
|
||||
const uint8_t *mcal_data = mcnk_subchunk_ptr(base, hdr.ofsMCAL) + 8;
|
||||
const size_t mcal_size = hdr.sizeMCAL - 8;
|
||||
const bool fix_old_edges = (hdr.flags & MCNK_FLAG_DO_NOT_FIX_ALPHA_MAP) == 0;
|
||||
|
||||
bool has_compressed_alpha = false;
|
||||
for (int li = 1; li < layers.size(); ++li) {
|
||||
Dictionary layer = layers[li];
|
||||
if (((int)layer.get("flags", 0) & (int)MCLY_FLAG_ALPHA_COMPRESSED) != 0) {
|
||||
has_compressed_alpha = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const int alpha_layer_count = layers.size() - 1;
|
||||
const bool use_big_alpha = has_compressed_alpha ||
|
||||
mcal_size >= (size_t)(alpha_layer_count * 4096);
|
||||
|
||||
for (int li = 1; li < layers.size(); ++li) {
|
||||
Dictionary layer = layers[li];
|
||||
const int flags = (int)layer.get("flags", 0);
|
||||
const size_t start = (size_t)(int)layer.get("alpha_offset", 0);
|
||||
size_t end = mcal_size;
|
||||
PackedByteArray alpha_data;
|
||||
|
||||
for (int next_li = li + 1; next_li < layers.size(); ++next_li) {
|
||||
Dictionary next_layer = layers[next_li];
|
||||
const size_t next_start = (size_t)(int)next_layer.get("alpha_offset", 0);
|
||||
if (next_start > start) {
|
||||
end = std::min(end, next_start);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (start >= mcal_size || end <= start) {
|
||||
alpha_maps.push_back(PackedByteArray());
|
||||
continue;
|
||||
}
|
||||
|
||||
const size_t available = end - start;
|
||||
const uint8_t *src = mcal_data + start;
|
||||
|
||||
if ((flags & (int)MCLY_FLAG_ALPHA_COMPRESSED) != 0) {
|
||||
alpha_data = decode_mcal_compressed_alpha(src, available);
|
||||
} else if (use_big_alpha) {
|
||||
alpha_data = decode_mcal_big_alpha(src, available);
|
||||
} else {
|
||||
alpha_data = decode_mcal_old_alpha(src, available, fix_old_edges);
|
||||
}
|
||||
|
||||
alpha_maps.push_back(alpha_data);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build chunk dictionary ───────────────────────────────────────
|
||||
Array liquids;
|
||||
if (cMH2O.size >= 256 * sizeof(MH2OHeader)) {
|
||||
const size_t header_index = (size_t)hdr.indexY * 16 + hdr.indexX;
|
||||
const size_t header_offset = header_index * sizeof(MH2OHeader);
|
||||
|
||||
if (header_index < 256 && header_offset + sizeof(MH2OHeader) <= cMH2O.size) {
|
||||
const MH2OHeader &water_header =
|
||||
*reinterpret_cast<const MH2OHeader *>(cMH2O.data + header_offset);
|
||||
|
||||
if (water_header.nLayers > 0 &&
|
||||
water_header.ofsInformation > 0 &&
|
||||
mh2o_region_fits(
|
||||
water_header.ofsInformation,
|
||||
(size_t)water_header.nLayers * sizeof(MH2OInformation),
|
||||
cMH2O.size))
|
||||
{
|
||||
for (uint32_t li = 0; li < water_header.nLayers; ++li) {
|
||||
const uint32_t info_offset =
|
||||
water_header.ofsInformation + li * sizeof(MH2OInformation);
|
||||
const MH2OInformation &info =
|
||||
*reinterpret_cast<const MH2OInformation *>(cMH2O.data + info_offset);
|
||||
|
||||
if (info.width == 0 || info.height == 0 ||
|
||||
info.xOffset > 8 || info.yOffset > 8 ||
|
||||
info.xOffset + info.width > 8 ||
|
||||
info.yOffset + info.height > 8)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PackedByteArray liquid_mask;
|
||||
liquid_mask.resize(8 * 8);
|
||||
uint8_t *mask_ptr = liquid_mask.ptrw();
|
||||
std::memset(mask_ptr, 0, 8 * 8);
|
||||
|
||||
uint64_t info_mask = 0xFFFFFFFFFFFFFFFFull;
|
||||
const size_t mask_bits = (size_t)info.width * info.height;
|
||||
const size_t mask_bytes = (mask_bits + 7) / 8;
|
||||
if (info.ofsInfoMask > 0) {
|
||||
if (!mh2o_region_fits(info.ofsInfoMask, mask_bytes, cMH2O.size)) {
|
||||
continue;
|
||||
}
|
||||
info_mask = 0;
|
||||
std::memcpy(&info_mask, cMH2O.data + info.ofsInfoMask, mask_bytes);
|
||||
}
|
||||
|
||||
size_t bit_index = 0;
|
||||
for (int z = 0; z < info.height; ++z) {
|
||||
for (int x = 0; x < info.width; ++x, ++bit_index) {
|
||||
if (((info_mask >> bit_index) & 1ull) == 0) {
|
||||
continue;
|
||||
}
|
||||
mask_ptr[(info.yOffset + z) * 8 + (info.xOffset + x)] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
PackedFloat32Array liquid_heights;
|
||||
liquid_heights.resize(9 * 9);
|
||||
float *height_ptr = liquid_heights.ptrw();
|
||||
for (int i = 0; i < 9 * 9; ++i) {
|
||||
height_ptr[i] = info.minHeight;
|
||||
}
|
||||
|
||||
const bool has_height_values =
|
||||
info.ofsHeightMap > 0 &&
|
||||
(info.liquidVertexFormat == 0 ||
|
||||
info.liquidVertexFormat == 1 ||
|
||||
info.liquidVertexFormat == 3);
|
||||
|
||||
if (has_height_values) {
|
||||
const size_t vertex_count = (size_t)(info.width + 1) * (info.height + 1);
|
||||
const size_t height_bytes = vertex_count * sizeof(float);
|
||||
if (!mh2o_region_fits(info.ofsHeightMap, height_bytes, cMH2O.size)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const float *src_heights =
|
||||
reinterpret_cast<const float *>(cMH2O.data + info.ofsHeightMap);
|
||||
for (int z = 0; z <= info.height; ++z) {
|
||||
for (int x = 0; x <= info.width; ++x) {
|
||||
const size_t src_index = (size_t)z * (info.width + 1) + x;
|
||||
const size_t dst_index =
|
||||
(size_t)(info.yOffset + z) * 9 + (info.xOffset + x);
|
||||
height_ptr[dst_index] =
|
||||
std::clamp(src_heights[src_index], info.minHeight, info.maxHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary liquid;
|
||||
liquid["liquid_id"] = (int)info.liquidId;
|
||||
liquid["vertex_format"] = (int)info.liquidVertexFormat;
|
||||
liquid["min_height"] = info.minHeight;
|
||||
liquid["max_height"] = info.maxHeight;
|
||||
liquid["x_offset"] = (int)info.xOffset;
|
||||
liquid["y_offset"] = (int)info.yOffset;
|
||||
liquid["width"] = (int)info.width;
|
||||
liquid["height"] = (int)info.height;
|
||||
liquid["mask"] = liquid_mask;
|
||||
liquid["heights"] = liquid_heights;
|
||||
liquids.push_back(liquid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary cd;
|
||||
cd["index_x"] = (int)hdr.indexX;
|
||||
cd["index_y"] = (int)hdr.indexY;
|
||||
cd["origin"] = Vector3(ox, oy, oz);
|
||||
cd["heights"] = heights;
|
||||
cd["normals"] = normals_arr;
|
||||
cd["holes"] = (int)(hdr.holes & 0xFFFF);
|
||||
cd["layers"] = layers;
|
||||
cd["alpha_maps"] = alpha_maps;
|
||||
cd["liquids"] = liquids;
|
||||
|
||||
chunks_array[ci] = cd;
|
||||
}
|
||||
|
||||
result["chunks"] = chunks_array;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
void ADTLoader::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("load_adt", "path"), &ADTLoader::load_adt);
|
||||
ClassDB::bind_method(D_METHOD("load_adt_from_bytes", "bytes"), &ADTLoader::load_adt_from_bytes);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
#include <godot_cpp/classes/ref_counted.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <godot_cpp/variant/array.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
#include <godot_cpp/variant/packed_float32_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector3_array.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/packed_string_array.hpp>
|
||||
|
||||
#include "wow_chunk_reader.h"
|
||||
|
||||
namespace godot {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ADTLoader
|
||||
//
|
||||
// Loads WoW 3.3.5a ADT terrain tiles.
|
||||
//
|
||||
// Usage (GDScript):
|
||||
// var adt = ADTLoader.new()
|
||||
// var data = adt.load_adt("C:/wow/Data/World/Maps/Azeroth/Azeroth_32_48.adt")
|
||||
//
|
||||
// Return Dictionary:
|
||||
// {
|
||||
// "textures": PackedStringArray, # all texture filenames in MTEX
|
||||
// "m2_names": PackedStringArray, # M2 model filenames
|
||||
// "wmo_names": PackedStringArray, # WMO filenames
|
||||
// "m2_placements": Array[Dictionary],
|
||||
// "wmo_placements": Array[Dictionary],
|
||||
// "chunks": Array[Dictionary], # 16×16 = 256 entries, row-major
|
||||
// }
|
||||
//
|
||||
// Placement Dictionary (both M2 and WMO):
|
||||
// {
|
||||
// "name_id": int, # index into m2_names / wmo_names
|
||||
// "pos": Vector3, # Godot world coords
|
||||
// "rot": Vector3, # Euler angles (radians)
|
||||
// "scale": float, # M2 only (WMO always 1.0)
|
||||
// }
|
||||
//
|
||||
// Chunk Dictionary:
|
||||
// {
|
||||
// "index_x": int, # 0-15
|
||||
// "index_y": int, # 0-15
|
||||
// "origin": Vector3, # Godot world position of chunk origin
|
||||
// "heights": PackedFloat32Array, # 145 values (9×9 outer + 8×8 inner grid)
|
||||
// "normals": PackedVector3Array, # 145 normals
|
||||
// "holes": int, # hole bit mask (low 16 bits)
|
||||
// "layers": Array[Dictionary], # texture layer definitions
|
||||
// "alpha_maps": Array[PackedByteArray], # one per layer (layer 0 has none)
|
||||
// }
|
||||
//
|
||||
// Layer Dictionary:
|
||||
// {
|
||||
// "texture_id": int,
|
||||
// "flags": int,
|
||||
// "effect_id": int,
|
||||
// "alpha_offset": int,
|
||||
// }
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class ADTLoader : public RefCounted {
|
||||
GDCLASS(ADTLoader, RefCounted)
|
||||
|
||||
public:
|
||||
Dictionary load_adt(const String &path);
|
||||
Dictionary load_adt_from_bytes(const PackedByteArray &bytes);
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
static std::vector<uint8_t> read_file(const std::string &path);
|
||||
static std::string to_std(const String &s);
|
||||
static String to_godot(const std::string &s);
|
||||
|
||||
// Shared parse implementation
|
||||
static void _parse_adt(const uint8_t *raw, size_t len, Dictionary &result);
|
||||
|
||||
// Parse names from MMDX/MWMO (null-separated) using MMID/MWID offset tables
|
||||
static PackedStringArray parse_names_with_offsets(
|
||||
const WoWChunk &names_chunk, const WoWChunk &ids_chunk);
|
||||
};
|
||||
|
||||
} // namespace godot
|
||||
@@ -0,0 +1,248 @@
|
||||
#include "blp_loader.h"
|
||||
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
|
||||
#include <fstream>
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BLP2 binary structures
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
#pragma pack(push, 1)
|
||||
struct BLP2Header {
|
||||
char magic[4]; // "BLP2"
|
||||
uint32_t type; // 0=JPEG 1=palette 2=DXT
|
||||
uint8_t encoding; // 1=raw(palette) 2=DXT 3=raw(BGRA)
|
||||
uint8_t alphaDepth; // 0,1,4,8
|
||||
uint8_t alphaEncoding; // 0=DXT1 1=DXT3 7=DXT5
|
||||
uint8_t hasMipmaps;
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint32_t mipmapOffset[16];
|
||||
uint32_t mipmapSize[16];
|
||||
};
|
||||
#pragma pack(pop)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DXT colour block helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
static void unpack_565(uint16_t c, uint8_t &r, uint8_t &g, uint8_t &b) {
|
||||
r = (c >> 11) & 0x1F; r = (r << 3) | (r >> 2);
|
||||
g = (c >> 5) & 0x3F; g = (g << 2) | (g >> 4);
|
||||
b = c & 0x1F; b = (b << 3) | (b >> 2);
|
||||
}
|
||||
|
||||
// Decode a 4×4 DXT1 colour block into dst (RGBA row-major, y=block_y*4+row)
|
||||
static void decode_dxt1_block(const uint8_t *src,
|
||||
uint8_t *dst, int pitch, bool has_alpha)
|
||||
{
|
||||
uint16_t c0 = src[0] | (src[1] << 8);
|
||||
uint16_t c1 = src[2] | (src[3] << 8);
|
||||
uint8_t r[4], g[4], b[4], a[4];
|
||||
unpack_565(c0, r[0], g[0], b[0]); a[0] = 255;
|
||||
unpack_565(c1, r[1], g[1], b[1]); a[1] = 255;
|
||||
|
||||
if (c0 > c1 || !has_alpha) {
|
||||
r[2] = (2*r[0]+r[1])/3; g[2] = (2*g[0]+g[1])/3; b[2] = (2*b[0]+b[1])/3; a[2] = 255;
|
||||
r[3] = (r[0]+2*r[1])/3; g[3] = (g[0]+2*g[1])/3; b[3] = (b[0]+2*b[1])/3; a[3] = 255;
|
||||
} else {
|
||||
r[2] = (r[0]+r[1])/2; g[2] = (g[0]+g[1])/2; b[2] = (b[0]+b[1])/2; a[2] = 255;
|
||||
r[3] = 0; g[3] = 0; b[3] = 0; a[3] = 0;
|
||||
}
|
||||
|
||||
uint32_t indices = src[4] | (src[5]<<8) | (src[6]<<16) | (src[7]<<24);
|
||||
for (int row = 0; row < 4; ++row) {
|
||||
for (int col = 0; col < 4; ++col) {
|
||||
int i = (indices >> (2*(row*4+col))) & 3;
|
||||
uint8_t *p = dst + row * pitch + col * 4;
|
||||
p[0] = r[i]; p[1] = g[i]; p[2] = b[i]; p[3] = a[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DXT3 explicit alpha block (16 bytes: 8 alpha + 8 colour)
|
||||
static void decode_dxt3_block(const uint8_t *src, uint8_t *dst, int pitch) {
|
||||
uint8_t alpha[16];
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
alpha[i*2] = (src[i] & 0x0F) * 17;
|
||||
alpha[i*2+1] = (src[i] >> 4) * 17;
|
||||
}
|
||||
decode_dxt1_block(src + 8, dst, pitch, false);
|
||||
for (int row = 0; row < 4; ++row)
|
||||
for (int col = 0; col < 4; ++col)
|
||||
dst[row * pitch + col * 4 + 3] = alpha[row*4+col];
|
||||
}
|
||||
|
||||
// DXT5 interpolated alpha block (16 bytes: 8 alpha + 8 colour)
|
||||
static void decode_dxt5_block(const uint8_t *src, uint8_t *dst, int pitch) {
|
||||
uint8_t a0 = src[0], a1 = src[1];
|
||||
uint8_t atable[8];
|
||||
atable[0] = a0; atable[1] = a1;
|
||||
if (a0 > a1) {
|
||||
for (int i = 2; i < 8; ++i)
|
||||
atable[i] = ((8-i)*a0 + (i-1)*a1) / 7;
|
||||
} else {
|
||||
for (int i = 2; i < 6; ++i)
|
||||
atable[i] = ((6-i)*a0 + (i-1)*a1) / 5;
|
||||
atable[6] = 0; atable[7] = 255;
|
||||
}
|
||||
uint64_t abits = 0;
|
||||
for (int i = 0; i < 6; ++i) abits |= ((uint64_t)src[2+i]) << (i*8);
|
||||
|
||||
decode_dxt1_block(src + 8, dst, pitch, false);
|
||||
for (int row = 0; row < 4; ++row)
|
||||
for (int col = 0; col < 4; ++col) {
|
||||
int bit = row*4+col;
|
||||
int idx = (abits >> (bit*3)) & 7;
|
||||
dst[row*pitch + col*4 + 3] = atable[idx];
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Full mip-level decoders
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
void BLPLoader::decode_dxt1(const uint8_t *src, int w, int h,
|
||||
std::vector<uint8_t> &dst, bool has_alpha)
|
||||
{
|
||||
int bw = (w + 3) / 4, bh = (h + 3) / 4;
|
||||
dst.assign(w * h * 4, 0);
|
||||
for (int by = 0; by < bh; ++by)
|
||||
for (int bx = 0; bx < bw; ++bx, src += 8) {
|
||||
uint8_t *d = dst.data() + (by*4*w + bx*4) * 4;
|
||||
decode_dxt1_block(src, d, w*4, has_alpha);
|
||||
}
|
||||
}
|
||||
|
||||
void BLPLoader::decode_dxt3(const uint8_t *src, int w, int h,
|
||||
std::vector<uint8_t> &dst)
|
||||
{
|
||||
int bw = (w+3)/4, bh = (h+3)/4;
|
||||
dst.assign(w * h * 4, 0);
|
||||
for (int by = 0; by < bh; ++by)
|
||||
for (int bx = 0; bx < bw; ++bx, src += 16) {
|
||||
uint8_t *d = dst.data() + (by*4*w + bx*4)*4;
|
||||
decode_dxt3_block(src, d, w*4);
|
||||
}
|
||||
}
|
||||
|
||||
void BLPLoader::decode_dxt5(const uint8_t *src, int w, int h,
|
||||
std::vector<uint8_t> &dst)
|
||||
{
|
||||
int bw = (w+3)/4, bh = (h+3)/4;
|
||||
dst.assign(w * h * 4, 0);
|
||||
for (int by = 0; by < bh; ++by)
|
||||
for (int bx = 0; bx < bw; ++bx, src += 16) {
|
||||
uint8_t *d = dst.data() + (by*4*w + bx*4)*4;
|
||||
decode_dxt5_block(src, d, w*4);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main parser
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
Ref<Image> BLPLoader::parse(const uint8_t *data, size_t len) {
|
||||
if (len < sizeof(BLP2Header)) return Ref<Image>();
|
||||
|
||||
const BLP2Header &hdr = *reinterpret_cast<const BLP2Header *>(data);
|
||||
if (std::memcmp(hdr.magic, "BLP2", 4) != 0) {
|
||||
// Try BLP1 magic
|
||||
if (std::memcmp(hdr.magic, "BLP1", 4) != 0) return Ref<Image>();
|
||||
}
|
||||
|
||||
uint32_t w = hdr.width, h = hdr.height;
|
||||
if (w == 0 || h == 0) return Ref<Image>();
|
||||
|
||||
// Use mip level 0 only
|
||||
uint32_t mip_ofs = hdr.mipmapOffset[0];
|
||||
uint32_t mip_size = hdr.mipmapSize[0];
|
||||
if (mip_ofs + mip_size > len) return Ref<Image>();
|
||||
const uint8_t *mip = data + mip_ofs;
|
||||
|
||||
std::vector<uint8_t> rgba;
|
||||
Image::Format fmt = Image::FORMAT_RGBA8;
|
||||
|
||||
if (hdr.encoding == 2) {
|
||||
// DXT compressed
|
||||
bool has_alpha = hdr.alphaDepth > 0;
|
||||
switch (hdr.alphaEncoding) {
|
||||
case 0: decode_dxt1(mip, w, h, rgba, has_alpha); break;
|
||||
case 1: decode_dxt3(mip, w, h, rgba); break;
|
||||
case 7: decode_dxt5(mip, w, h, rgba); break;
|
||||
default: decode_dxt1(mip, w, h, rgba, has_alpha); break;
|
||||
}
|
||||
|
||||
} else if (hdr.encoding == 1) {
|
||||
// Palettized: 256×4 BGRA palette at offset 0xA4, then index data
|
||||
const size_t palette_ofs = sizeof(BLP2Header);
|
||||
if (palette_ofs + 256*4 > len) return Ref<Image>();
|
||||
const uint8_t *palette = data + palette_ofs;
|
||||
|
||||
rgba.resize(w * h * 4);
|
||||
for (uint32_t i = 0; i < w * h && i < mip_size; ++i) {
|
||||
uint8_t idx = mip[i];
|
||||
rgba[i*4+0] = palette[idx*4+2]; // R (from BGR)
|
||||
rgba[i*4+1] = palette[idx*4+1]; // G
|
||||
rgba[i*4+2] = palette[idx*4+0]; // B
|
||||
rgba[i*4+3] = (hdr.alphaDepth == 0) ? 255 : mip[w*h + i];
|
||||
}
|
||||
|
||||
} else if (hdr.encoding == 3) {
|
||||
// Uncompressed BGRA
|
||||
rgba.resize(w * h * 4);
|
||||
for (uint32_t i = 0; i < w * h * 4 && i + 3 < mip_size; i += 4) {
|
||||
rgba[i+0] = mip[i+2]; // R
|
||||
rgba[i+1] = mip[i+1]; // G
|
||||
rgba[i+2] = mip[i+0]; // B
|
||||
rgba[i+3] = mip[i+3]; // A
|
||||
}
|
||||
} else {
|
||||
return Ref<Image>();
|
||||
}
|
||||
|
||||
if (rgba.empty()) return Ref<Image>();
|
||||
|
||||
PackedByteArray pba;
|
||||
pba.resize((int)rgba.size());
|
||||
std::memcpy(pba.ptrw(), rgba.data(), rgba.size());
|
||||
|
||||
return Image::create_from_data(w, h, false, fmt, pba);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
Ref<Image> BLPLoader::load_image(const String &path) {
|
||||
auto buf = read_file(to_std(path));
|
||||
if (buf.empty()) {
|
||||
UtilityFunctions::push_error("BLPLoader: cannot read ", path);
|
||||
return Ref<Image>();
|
||||
}
|
||||
return parse(buf.data(), buf.size());
|
||||
}
|
||||
|
||||
Ref<Image> BLPLoader::load_image_from_bytes(const PackedByteArray &bytes) {
|
||||
if (bytes.is_empty()) return Ref<Image>();
|
||||
return parse(bytes.ptr(), bytes.size());
|
||||
}
|
||||
|
||||
std::vector<uint8_t> BLPLoader::read_file(const std::string &path) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) return {};
|
||||
auto sz = f.tellg(); f.seekg(0);
|
||||
std::vector<uint8_t> buf(sz);
|
||||
f.read(reinterpret_cast<char *>(buf.data()), sz);
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string BLPLoader::to_std(const String &s) {
|
||||
return std::string(s.utf8().get_data());
|
||||
}
|
||||
|
||||
void BLPLoader::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("load_image", "path"), &BLPLoader::load_image);
|
||||
ClassDB::bind_method(D_METHOD("load_image_from_bytes", "bytes"), &BLPLoader::load_image_from_bytes);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
#include <godot_cpp/classes/ref_counted.hpp>
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/classes/image_texture.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
|
||||
namespace godot {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BLPLoader — WoW BLP2 texture → Godot Image
|
||||
//
|
||||
// Supports all formats used by WoW 3.3.5a:
|
||||
// • DXT1 / DXT3 / DXT5 (encoding=2, most common)
|
||||
// • Palettized BGRA (encoding=1)
|
||||
// • Uncompressed BGRA (encoding=3, rare)
|
||||
//
|
||||
// Usage (GDScript):
|
||||
// var img = BLPLoader.new().load_image("C:/path/texture.blp")
|
||||
// var tex = ImageTexture.create_from_image(img)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class BLPLoader : public RefCounted {
|
||||
GDCLASS(BLPLoader, RefCounted)
|
||||
|
||||
public:
|
||||
// Load from absolute filesystem path
|
||||
Ref<Image> load_image(const String &path);
|
||||
|
||||
// Load from raw bytes (e.g. from MPQManager::read_file)
|
||||
Ref<Image> load_image_from_bytes(const PackedByteArray &bytes);
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
static Ref<Image> parse(const uint8_t *data, size_t len);
|
||||
|
||||
// DXT decoders (output RGBA8)
|
||||
static void decode_dxt1(const uint8_t *src, int w, int h,
|
||||
std::vector<uint8_t> &dst, bool has_alpha);
|
||||
static void decode_dxt3(const uint8_t *src, int w, int h,
|
||||
std::vector<uint8_t> &dst);
|
||||
static void decode_dxt5(const uint8_t *src, int w, int h,
|
||||
std::vector<uint8_t> &dst);
|
||||
|
||||
static std::vector<uint8_t> read_file(const std::string &path);
|
||||
static std::string to_std(const String &s);
|
||||
};
|
||||
|
||||
} // namespace godot
|
||||
@@ -0,0 +1,339 @@
|
||||
#include "m2_loader.h"
|
||||
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
#include <godot_cpp/variant/packed_string_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector3_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector2_array.hpp>
|
||||
#include <godot_cpp/variant/packed_int32_array.hpp>
|
||||
|
||||
#include <fstream>
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Raw structures (WoW 3.3.5a, little-endian, packed)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
#pragma pack(push, 1)
|
||||
|
||||
// M2 file header — starts at offset 0, no chunk wrapper in 3.3.5a
|
||||
struct M2Header {
|
||||
uint32_t magic; // 'MD20' LE = 0x3032444D
|
||||
uint32_t version;
|
||||
uint32_t nName;
|
||||
uint32_t ofsName;
|
||||
uint32_t flags;
|
||||
uint32_t nGlobalLoops;
|
||||
uint32_t ofsGlobalLoops;
|
||||
uint32_t nAnimations;
|
||||
uint32_t ofsAnimations;
|
||||
uint32_t nAnimLookup;
|
||||
uint32_t ofsAnimLookup;
|
||||
uint32_t nBones;
|
||||
uint32_t ofsBones;
|
||||
uint32_t nKeyBoneLookup; // unused for static render
|
||||
uint32_t ofsKeyBoneLookup;
|
||||
uint32_t nVertices; // offset 60
|
||||
uint32_t ofsVertices; // offset 64
|
||||
uint32_t nViews; // offset 68 — number of .skin files
|
||||
uint32_t nColors;
|
||||
uint32_t ofsColors;
|
||||
uint32_t nTextures; // offset 80
|
||||
uint32_t ofsTextures; // offset 84
|
||||
uint32_t nTexWeights; // unused
|
||||
uint32_t ofsTexWeights;
|
||||
uint32_t nTexTransforms; // unused
|
||||
uint32_t ofsTexTransforms;
|
||||
uint32_t nReplTexLookup; // unused
|
||||
uint32_t ofsReplTexLookup;
|
||||
uint32_t nMaterials; // offset 112
|
||||
uint32_t ofsMaterials; // offset 116
|
||||
uint32_t nBoneCombos; // unused
|
||||
uint32_t ofsBoneCombos;
|
||||
uint32_t nTextureCombos; // offset 128
|
||||
uint32_t ofsTextureCombos; // offset 132
|
||||
};
|
||||
// Total: 34 × 4 = 136 bytes
|
||||
|
||||
struct M2Vertex {
|
||||
float pos[3];
|
||||
uint8_t boneWeights[4];
|
||||
uint8_t boneIndices[4];
|
||||
float normal[3];
|
||||
float texCoords[2];
|
||||
float texCoords2[2];
|
||||
};
|
||||
// Total: 48 bytes
|
||||
|
||||
struct M2Texture {
|
||||
uint32_t type; // 0 = hardcoded filename
|
||||
uint32_t flags;
|
||||
uint32_t nFilename;
|
||||
uint32_t ofsFilename;
|
||||
};
|
||||
|
||||
struct M2Material {
|
||||
uint16_t flags;
|
||||
uint16_t blendingMode; // 0=opaque 1=alpha_key 2=alpha_blend
|
||||
};
|
||||
|
||||
struct SkinHeader {
|
||||
uint32_t magic; // 'SKIN' LE = 0x4E494B53
|
||||
uint32_t nIndices;
|
||||
uint32_t ofsIndices;
|
||||
uint32_t nTriangles;
|
||||
uint32_t ofsTriangles;
|
||||
uint32_t nProperties;
|
||||
uint32_t ofsProperties;
|
||||
uint32_t nSubMeshes;
|
||||
uint32_t ofsSubMeshes;
|
||||
uint32_t nTextureUnits;
|
||||
uint32_t ofsTextureUnits;
|
||||
uint32_t LOD;
|
||||
};
|
||||
// Total: 48 bytes
|
||||
|
||||
struct SkinSubMesh {
|
||||
uint16_t submeshID;
|
||||
uint16_t level;
|
||||
uint16_t vertexStart;
|
||||
uint16_t vertexCount;
|
||||
uint16_t triangleStart;
|
||||
uint16_t triangleCount;
|
||||
uint16_t boneCount;
|
||||
uint16_t boneStart;
|
||||
uint16_t boneInfluences;
|
||||
uint16_t centerBoneIndex;
|
||||
float centerPosition[3];
|
||||
float sortCenterPosition[3];
|
||||
float sortRadius;
|
||||
};
|
||||
// Total: 10×2 + 7×4 = 48 bytes
|
||||
|
||||
struct SkinTextureUnit {
|
||||
uint8_t flags;
|
||||
uint8_t priority;
|
||||
uint16_t shaderID;
|
||||
uint16_t skinSectionIndex;
|
||||
uint16_t flags2;
|
||||
uint16_t colorIndex;
|
||||
uint16_t materialIndex;
|
||||
uint16_t materialLayer;
|
||||
uint16_t textureCount;
|
||||
uint16_t textureComboIndex;
|
||||
uint16_t textureCoordComboIndex;
|
||||
uint16_t textureWeightComboIndex;
|
||||
uint16_t textureTransformComboIndex;
|
||||
};
|
||||
// Total: 2 + 11×2 = 24 bytes
|
||||
|
||||
#pragma pack(pop)
|
||||
|
||||
static constexpr uint32_t MAGIC_MD20 = 0x3032444D; // 'MD20' LE
|
||||
static constexpr uint32_t MAGIC_SKIN = 0x4E494B53; // 'SKIN' LE
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
std::vector<uint8_t> M2Loader::read_file(const std::string &path) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) return {};
|
||||
auto sz = f.tellg();
|
||||
f.seekg(0);
|
||||
std::vector<uint8_t> buf(sz);
|
||||
f.read(reinterpret_cast<char *>(buf.data()), sz);
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string M2Loader::to_std(const String &s) {
|
||||
return std::string(s.utf8().get_data());
|
||||
}
|
||||
|
||||
String M2Loader::to_godot(const std::string &s) {
|
||||
return String(s.c_str());
|
||||
}
|
||||
|
||||
// Safe bounds-checked array accessor into a buffer
|
||||
template<typename T>
|
||||
static const T *safe_array(const std::vector<uint8_t> &buf, uint32_t ofs, uint32_t count) {
|
||||
if (count == 0) return nullptr;
|
||||
uint64_t end = (uint64_t)ofs + (uint64_t)count * sizeof(T);
|
||||
if (end > buf.size()) return nullptr;
|
||||
return reinterpret_cast<const T *>(buf.data() + ofs);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Core parser
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string &path) {
|
||||
if (buf.size() < sizeof(M2Header)) return Dictionary();
|
||||
|
||||
const auto &hdr = *reinterpret_cast<const M2Header *>(buf.data());
|
||||
if (hdr.magic != MAGIC_MD20) {
|
||||
UtilityFunctions::push_error("M2Loader: unexpected magic, expected MD20");
|
||||
return Dictionary();
|
||||
}
|
||||
|
||||
// ── Vertices ─────────────────────────────────────────────────────────────
|
||||
PackedVector3Array vertices, normals;
|
||||
PackedVector2Array uvs;
|
||||
|
||||
const auto *verts = safe_array<M2Vertex>(buf, hdr.ofsVertices, hdr.nVertices);
|
||||
if (verts && hdr.nVertices > 0) {
|
||||
vertices.resize(hdr.nVertices);
|
||||
normals.resize(hdr.nVertices);
|
||||
uvs.resize(hdr.nVertices);
|
||||
for (uint32_t i = 0; i < hdr.nVertices; ++i) {
|
||||
const auto &v = verts[i];
|
||||
// WoW model space (X right, Y forward, Z up) → Godot (X right, Y up, Z back)
|
||||
vertices[i] = Vector3( v.pos[0], v.pos[2], -v.pos[1]);
|
||||
normals[i] = Vector3( v.normal[0], v.normal[2], -v.normal[1]);
|
||||
// WoW V origin is top-left; Godot expects bottom-left → flip V
|
||||
uvs[i] = Vector2(v.texCoords[0], 1.0f - v.texCoords[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Textures ─────────────────────────────────────────────────────────────
|
||||
PackedStringArray textures;
|
||||
const auto *tex_arr = safe_array<M2Texture>(buf, hdr.ofsTextures, hdr.nTextures);
|
||||
if (tex_arr) {
|
||||
for (uint32_t i = 0; i < hdr.nTextures; ++i) {
|
||||
const auto &t = tex_arr[i];
|
||||
std::string fname;
|
||||
if (t.type == 0 && t.nFilename > 0) {
|
||||
uint64_t end = (uint64_t)t.ofsFilename + t.nFilename;
|
||||
if (end <= buf.size()) {
|
||||
fname = std::string(
|
||||
reinterpret_cast<const char *>(buf.data() + t.ofsFilename),
|
||||
t.nFilename);
|
||||
// trim null terminators
|
||||
while (!fname.empty() && fname.back() == '\0')
|
||||
fname.pop_back();
|
||||
for (auto &c : fname) if (c == '\\') c = '/';
|
||||
}
|
||||
}
|
||||
textures.push_back(to_godot(fname));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Materials ────────────────────────────────────────────────────────────
|
||||
Array materials;
|
||||
const auto *mat_arr = safe_array<M2Material>(buf, hdr.ofsMaterials, hdr.nMaterials);
|
||||
if (mat_arr) {
|
||||
for (uint32_t i = 0; i < hdr.nMaterials; ++i) {
|
||||
Dictionary m;
|
||||
m["flags"] = (int)mat_arr[i].flags;
|
||||
m["blend_mode"] = (int)mat_arr[i].blendingMode;
|
||||
materials.push_back(m);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Texture combos ───────────────────────────────────────────────────────
|
||||
PackedInt32Array texture_combos;
|
||||
const auto *tc_arr = safe_array<uint16_t>(buf, hdr.ofsTextureCombos, hdr.nTextureCombos);
|
||||
if (tc_arr) {
|
||||
for (uint32_t i = 0; i < hdr.nTextureCombos; ++i)
|
||||
texture_combos.push_back((int)tc_arr[i]);
|
||||
}
|
||||
|
||||
// ── Skin file ────────────────────────────────────────────────────────────
|
||||
// Find <basename>00.skin in the same directory as the .m2
|
||||
std::string skin_path = path;
|
||||
{
|
||||
auto dot = skin_path.rfind('.');
|
||||
if (dot != std::string::npos)
|
||||
skin_path = skin_path.substr(0, dot);
|
||||
skin_path += "00.skin";
|
||||
}
|
||||
|
||||
auto skin_buf = read_file(skin_path);
|
||||
|
||||
PackedInt32Array indices;
|
||||
Array batches;
|
||||
|
||||
if (skin_buf.size() >= sizeof(SkinHeader)) {
|
||||
const auto &skin = *reinterpret_cast<const SkinHeader *>(skin_buf.data());
|
||||
|
||||
if (skin.magic == MAGIC_SKIN) {
|
||||
const uint16_t *skin_idx = safe_array<uint16_t>(skin_buf, skin.ofsIndices, skin.nIndices);
|
||||
const uint16_t *skin_tri = safe_array<uint16_t>(skin_buf, skin.ofsTriangles, skin.nTriangles);
|
||||
const SkinSubMesh *sms = safe_array<SkinSubMesh> (skin_buf, skin.ofsSubMeshes, skin.nSubMeshes);
|
||||
const SkinTextureUnit *tu = safe_array<SkinTextureUnit>(skin_buf, skin.ofsTextureUnits, skin.nTextureUnits);
|
||||
|
||||
if (skin_idx && skin_tri && sms && tu) {
|
||||
for (uint32_t u = 0; u < skin.nTextureUnits; ++u) {
|
||||
uint32_t sm_idx = tu[u].skinSectionIndex;
|
||||
if (sm_idx >= skin.nSubMeshes) continue;
|
||||
const SkinSubMesh &sm = sms[sm_idx];
|
||||
|
||||
// triangleStart may be extended by level in large models
|
||||
uint32_t tri_start = (uint32_t)sm.triangleStart + ((uint32_t)sm.level << 16);
|
||||
uint32_t tri_count = sm.triangleCount;
|
||||
if (tri_start + tri_count > skin.nTriangles) continue;
|
||||
|
||||
int idx_start = (int)indices.size();
|
||||
|
||||
// Each group of 3 triangle-array entries forms one triangle.
|
||||
// Remap: final_vertex = skin_idx[skin_tri[t]]
|
||||
// Reverse winding (WoW CW → Godot CCW).
|
||||
for (uint32_t t = 0; t + 2 < tri_count; t += 3) {
|
||||
uint16_t a = skin_tri[tri_start + t];
|
||||
uint16_t b = skin_tri[tri_start + t + 1];
|
||||
uint16_t c = skin_tri[tri_start + t + 2];
|
||||
if (a >= skin.nIndices || b >= skin.nIndices || c >= skin.nIndices)
|
||||
continue;
|
||||
indices.push_back((int)skin_idx[a]);
|
||||
indices.push_back((int)skin_idx[c]); // reversed
|
||||
indices.push_back((int)skin_idx[b]);
|
||||
}
|
||||
|
||||
int idx_count = (int)indices.size() - idx_start;
|
||||
if (idx_count <= 0) continue;
|
||||
|
||||
Dictionary batch;
|
||||
batch["index_start"] = idx_start;
|
||||
batch["index_count"] = idx_count;
|
||||
batch["material_id"] = (int)tu[u].materialIndex;
|
||||
batch["texture_combo_index"] = (int)tu[u].textureComboIndex;
|
||||
batches.push_back(batch);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
UtilityFunctions::push_warning("M2Loader: skin file missing or invalid: ",
|
||||
to_godot(skin_path));
|
||||
}
|
||||
} else {
|
||||
UtilityFunctions::push_warning("M2Loader: skin file not found: ",
|
||||
to_godot(skin_path));
|
||||
}
|
||||
|
||||
Dictionary result;
|
||||
result["textures"] = textures;
|
||||
result["materials"] = materials;
|
||||
result["texture_combos"] = texture_combos;
|
||||
result["vertices"] = vertices;
|
||||
result["normals"] = normals;
|
||||
result["uvs"] = uvs;
|
||||
result["indices"] = indices;
|
||||
result["batches"] = batches;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
Dictionary M2Loader::load_m2(const String &path) {
|
||||
std::string spath = to_std(path);
|
||||
auto buf = read_file(spath);
|
||||
if (buf.empty()) {
|
||||
UtilityFunctions::push_error("M2Loader: cannot read ", path);
|
||||
return Dictionary();
|
||||
}
|
||||
return parse_m2(buf, spath);
|
||||
}
|
||||
|
||||
void M2Loader::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("load_m2", "path"), &M2Loader::load_m2);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
#include <godot_cpp/classes/ref_counted.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
|
||||
#include "wow_chunk_reader.h"
|
||||
|
||||
namespace godot {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// M2Loader
|
||||
//
|
||||
// Loads WoW 3.3.5a .m2 model files (raw format, starts with MD20 magic).
|
||||
// Parses the first skin file (<basename>00.skin) for render geometry.
|
||||
//
|
||||
// Usage (GDScript):
|
||||
// var loader = M2Loader.new()
|
||||
// var data = loader.load_m2("C:/wow/extracted/World/Doodad/Gnome/Gnome.m2")
|
||||
//
|
||||
// Return Dictionary:
|
||||
// {
|
||||
// "textures": PackedStringArray, # .blp texture paths (may be empty for replaceable)
|
||||
// "materials": Array[Dictionary], # [{flags, blend_mode}]
|
||||
// "texture_combos": PackedInt32Array, # textureCombos[i] = index into textures
|
||||
// "vertices": PackedVector3Array, # Godot-space positions
|
||||
// "normals": PackedVector3Array,
|
||||
// "uvs": PackedVector2Array,
|
||||
// "indices": PackedInt32Array, # flat triangle list into vertex array
|
||||
// "batches": Array[Dictionary], # render batches
|
||||
// }
|
||||
//
|
||||
// Each batch Dictionary:
|
||||
// {
|
||||
// "index_start": int, # offset into indices array
|
||||
// "index_count": int, # number of indices for this batch
|
||||
// "material_id": int, # index into materials
|
||||
// "texture_combo_index": int, # index into texture_combos → texture
|
||||
// }
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class M2Loader : public RefCounted {
|
||||
GDCLASS(M2Loader, RefCounted)
|
||||
|
||||
public:
|
||||
Dictionary load_m2(const String &path);
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
Dictionary parse_m2(const std::vector<uint8_t> &buf, const std::string &path);
|
||||
|
||||
static std::vector<uint8_t> read_file(const std::string &path);
|
||||
static std::string to_std(const String &s);
|
||||
static String to_godot(const std::string &s);
|
||||
};
|
||||
|
||||
} // namespace godot
|
||||
@@ -0,0 +1,324 @@
|
||||
#include "mpq_manager.h"
|
||||
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
#include <godot_cpp/classes/dir_access.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using namespace godot;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WoW 3.3.5a (build 12340) MPQ load order
|
||||
// Source: Blizzard client, TrinityCore/MaNGOS toolchain
|
||||
//
|
||||
// Priority value = open order (higher = wins).
|
||||
// Base archives 1–7, locale archives 10–20 (locale always wins over base).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
std::vector<std::pair<std::string, int>>
|
||||
MPQManager::build_archive_list(const std::string &data_dir, const std::string &locale)
|
||||
{
|
||||
// {relative path from data_dir, priority}
|
||||
std::vector<std::pair<std::string, int>> list = {
|
||||
// ── Base archives (lowest priority) ───────────────────────────────
|
||||
{ "common.mpq", 1 },
|
||||
{ "common-2.mpq", 2 },
|
||||
{ "expansion.mpq", 3 },
|
||||
{ "lichking.mpq", 4 },
|
||||
{ "patch.mpq", 5 },
|
||||
{ "patch-2.mpq", 6 },
|
||||
{ "patch-3.mpq", 7 },
|
||||
// ── Locale archives (higher priority) ─────────────────────────────
|
||||
{ locale + "/locale-" + locale + ".mpq", 10 },
|
||||
{ locale + "/speech-" + locale + ".mpq", 11 },
|
||||
{ locale + "/expansion-locale-" + locale + ".mpq", 12 },
|
||||
{ locale + "/lichking-locale-" + locale + ".mpq", 13 },
|
||||
{ locale + "/patch-" + locale + ".mpq", 14 },
|
||||
{ locale + "/patch-" + locale + "-2.mpq", 15 },
|
||||
{ locale + "/patch-" + locale + "-3.mpq", 16 },
|
||||
};
|
||||
|
||||
// Prepend data_dir to each entry
|
||||
for (auto &entry : list) {
|
||||
entry.first = data_dir + "/" + entry.first;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
MPQManager::MPQManager() = default;
|
||||
|
||||
MPQManager::~MPQManager() {
|
||||
close();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
int MPQManager::open_client(const String &base_path, const String &locale)
|
||||
{
|
||||
close();
|
||||
|
||||
std::string data_dir = to_std(base_path) + "/Data";
|
||||
std::string loc = to_std(locale);
|
||||
|
||||
auto archive_list = build_archive_list(data_dir, loc);
|
||||
|
||||
int opened = 0;
|
||||
for (auto &[path, priority] : archive_list) {
|
||||
if (!fs::exists(path)) {
|
||||
// Not every archive is required to be present
|
||||
continue;
|
||||
}
|
||||
if (open_archive(String(path.c_str()), priority)) {
|
||||
++opened;
|
||||
}
|
||||
}
|
||||
|
||||
if (opened == 0) {
|
||||
UtilityFunctions::push_error("MPQManager: no archives found in ", base_path);
|
||||
} else {
|
||||
UtilityFunctions::print("MPQManager: opened ", opened, " archives from ", base_path);
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
bool MPQManager::open_archive(const String &path, int priority)
|
||||
{
|
||||
std::string spath = to_std(path);
|
||||
HANDLE hMpq = nullptr;
|
||||
|
||||
if (!SFileOpenArchive(spath.c_str(), 0, MPQ_OPEN_READ_ONLY | STREAM_FLAG_READ_ONLY, &hMpq)) {
|
||||
UtilityFunctions::push_warning("MPQManager: failed to open ", path,
|
||||
" (error ", (int)GetLastError(), ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
MPQArchiveEntry entry;
|
||||
entry.handle = hMpq;
|
||||
entry.path = path;
|
||||
entry.priority = priority;
|
||||
|
||||
// Insert sorted ascending by priority so index 0 = lowest
|
||||
auto it = std::lower_bound(
|
||||
m_archives.begin(), m_archives.end(), priority,
|
||||
[](const MPQArchiveEntry &e, int p){ return e.priority < p; });
|
||||
m_archives.insert(it, entry);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
void MPQManager::close()
|
||||
{
|
||||
for (auto &entry : m_archives) {
|
||||
if (entry.handle) {
|
||||
SFileCloseArchive(entry.handle);
|
||||
entry.handle = nullptr;
|
||||
}
|
||||
}
|
||||
m_archives.clear();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Internal: search from highest priority (back of vector) to lowest
|
||||
HANDLE MPQManager::find_file(const std::string &internal_path) const
|
||||
{
|
||||
for (auto it = m_archives.rbegin(); it != m_archives.rend(); ++it) {
|
||||
HANDLE hFile = nullptr;
|
||||
if (SFileOpenFileEx(it->handle, internal_path.c_str(), 0, &hFile)) {
|
||||
SFileCloseFile(hFile);
|
||||
return it->handle;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
bool MPQManager::has_file(const String &internal_path) const
|
||||
{
|
||||
return find_file(to_std(internal_path)) != nullptr;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
PackedByteArray MPQManager::read_file(const String &internal_path) const
|
||||
{
|
||||
std::string spath = to_std(internal_path);
|
||||
HANDLE hArchive = find_file(spath);
|
||||
if (!hArchive) {
|
||||
UtilityFunctions::push_error("MPQManager: file not found: ", internal_path);
|
||||
return PackedByteArray();
|
||||
}
|
||||
|
||||
HANDLE hFile = nullptr;
|
||||
if (!SFileOpenFileEx(hArchive, spath.c_str(), 0, &hFile)) {
|
||||
return PackedByteArray();
|
||||
}
|
||||
|
||||
DWORD file_size = SFileGetFileSize(hFile, nullptr);
|
||||
if (file_size == SFILE_INVALID_SIZE || file_size == 0) {
|
||||
SFileCloseFile(hFile);
|
||||
return PackedByteArray();
|
||||
}
|
||||
|
||||
PackedByteArray result;
|
||||
result.resize((int)file_size);
|
||||
uint8_t *ptr = result.ptrw();
|
||||
|
||||
DWORD read = 0;
|
||||
bool ok = SFileReadFile(hFile, ptr, file_size, &read, nullptr);
|
||||
SFileCloseFile(hFile);
|
||||
|
||||
if (!ok || read != file_size) {
|
||||
UtilityFunctions::push_error("MPQManager: read error on ", internal_path);
|
||||
return PackedByteArray();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
bool MPQManager::extract_file(const String &internal_path, const String &output_path) const
|
||||
{
|
||||
std::string spath = to_std(internal_path);
|
||||
std::string outpath = to_std(output_path);
|
||||
|
||||
HANDLE hArchive = find_file(spath);
|
||||
if (!hArchive) {
|
||||
UtilityFunctions::push_error("MPQManager: file not found: ", internal_path);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
fs::path out(outpath);
|
||||
fs::create_directories(out.parent_path());
|
||||
|
||||
return SFileExtractFile(hArchive, spath.c_str(), outpath.c_str(), 0);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
int MPQManager::extract_files(const String &filter, const String &output_dir) const
|
||||
{
|
||||
if (m_archives.empty()) return 0;
|
||||
|
||||
std::string out_dir = to_std(output_dir);
|
||||
std::string sfilter = to_std(filter);
|
||||
|
||||
// Collect unique file names from all archives (highest priority wins)
|
||||
// We iterate archives highest→lowest and track already-seen paths.
|
||||
std::unordered_set<std::string> seen;
|
||||
int count = 0;
|
||||
|
||||
for (auto it = m_archives.rbegin(); it != m_archives.rend(); ++it) {
|
||||
SFILE_FIND_DATA fd;
|
||||
HANDLE hFind = SFileFindFirstFile(it->handle, sfilter.c_str(), &fd, nullptr);
|
||||
if (!hFind) continue;
|
||||
|
||||
do {
|
||||
std::string fname(fd.cFileName);
|
||||
if (seen.count(fname)) continue; // already extracted from higher-priority archive
|
||||
seen.insert(fname);
|
||||
|
||||
// Build output path mirroring internal path
|
||||
fs::path out_path = fs::path(out_dir) / fs::path(fname);
|
||||
fs::create_directories(out_path.parent_path());
|
||||
|
||||
if (SFileExtractFile(it->handle, fname.c_str(), out_path.string().c_str(), 0)) {
|
||||
++count;
|
||||
} else {
|
||||
UtilityFunctions::push_warning("MPQManager: failed to extract ", String(fname.c_str()));
|
||||
}
|
||||
} while (SFileFindNextFile(hFind, &fd));
|
||||
|
||||
SFileFindClose(hFind);
|
||||
}
|
||||
|
||||
UtilityFunctions::print("MPQManager: extracted ", count, " files → ", output_dir);
|
||||
return count;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
PackedStringArray MPQManager::list_files(const String &filter) const
|
||||
{
|
||||
std::string sfilter = to_std(filter.is_empty() ? String("*") : filter);
|
||||
std::unordered_set<std::string> seen;
|
||||
PackedStringArray result;
|
||||
|
||||
// Iterate highest priority first so duplicates are attributed to winner
|
||||
for (auto it = m_archives.rbegin(); it != m_archives.rend(); ++it) {
|
||||
SFILE_FIND_DATA fd;
|
||||
HANDLE hFind = SFileFindFirstFile(it->handle, sfilter.c_str(), &fd, nullptr);
|
||||
if (!hFind) continue;
|
||||
|
||||
do {
|
||||
std::string fname(fd.cFileName);
|
||||
if (!seen.count(fname)) {
|
||||
seen.insert(fname);
|
||||
result.push_back(String(fname.c_str()));
|
||||
}
|
||||
} while (SFileFindNextFile(hFind, &fd));
|
||||
|
||||
SFileFindClose(hFind);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
int MPQManager::get_archive_count() const {
|
||||
return (int)m_archives.size();
|
||||
}
|
||||
|
||||
Array MPQManager::get_archive_info() const {
|
||||
Array out;
|
||||
for (auto it = m_archives.rbegin(); it != m_archives.rend(); ++it) {
|
||||
DWORD file_count = 0;
|
||||
SFileGetFileInfo(it->handle, SFileMpqNumberOfFiles, &file_count, sizeof(file_count), nullptr);
|
||||
|
||||
Dictionary d;
|
||||
d["path"] = it->path;
|
||||
d["priority"] = it->priority;
|
||||
d["file_count"] = (int)file_count;
|
||||
out.push_back(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
String MPQManager::to_godot(const std::string &s) {
|
||||
return String(s.c_str());
|
||||
}
|
||||
|
||||
std::string MPQManager::to_std(const String &s) {
|
||||
return std::string(s.utf8().get_data());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GDScript bindings
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
void MPQManager::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("open_client", "base_path", "locale"),
|
||||
&MPQManager::open_client);
|
||||
ClassDB::bind_method(D_METHOD("open_archive", "path", "priority"),
|
||||
&MPQManager::open_archive);
|
||||
ClassDB::bind_method(D_METHOD("close"),
|
||||
&MPQManager::close);
|
||||
ClassDB::bind_method(D_METHOD("has_file", "internal_path"),
|
||||
&MPQManager::has_file);
|
||||
ClassDB::bind_method(D_METHOD("read_file", "internal_path"),
|
||||
&MPQManager::read_file);
|
||||
ClassDB::bind_method(D_METHOD("extract_file", "internal_path", "output_path"),
|
||||
&MPQManager::extract_file);
|
||||
ClassDB::bind_method(D_METHOD("extract_files", "filter", "output_dir"),
|
||||
&MPQManager::extract_files);
|
||||
ClassDB::bind_method(D_METHOD("list_files", "filter"),
|
||||
&MPQManager::list_files, DEFVAL("*"));
|
||||
ClassDB::bind_method(D_METHOD("get_archive_count"),
|
||||
&MPQManager::get_archive_count);
|
||||
ClassDB::bind_method(D_METHOD("get_archive_info"),
|
||||
&MPQManager::get_archive_info);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#pragma once
|
||||
|
||||
#include <godot_cpp/classes/ref_counted.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
#include <godot_cpp/variant/packed_string_array.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
|
||||
#include <StormLib.h>
|
||||
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
namespace godot {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MPQArchiveEntry – one open archive with its priority
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
struct MPQArchiveEntry {
|
||||
HANDLE handle = nullptr;
|
||||
String path;
|
||||
int priority = 0; // higher = wins
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MPQManager – GDScript-accessible class
|
||||
// Usage:
|
||||
// var mgr = MPQManager.new()
|
||||
// mgr.open_client("res://sources", "ruRU")
|
||||
// var data = mgr.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
||||
// mgr.extract_all("DBFilesClient", "res://data/extracted/dbc")
|
||||
// mgr.close()
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class MPQManager : public RefCounted {
|
||||
GDCLASS(MPQManager, RefCounted)
|
||||
|
||||
public:
|
||||
MPQManager();
|
||||
~MPQManager();
|
||||
|
||||
// Open all MPQ archives from a WoW 3.3.5a client folder.
|
||||
// base_path – folder containing Data/
|
||||
// locale – e.g. "ruRU", "enUS", "deDE"
|
||||
// Returns number of successfully opened archives (0 on total failure).
|
||||
int open_client(const String &base_path, const String &locale);
|
||||
|
||||
// Open a single MPQ archive explicitly (for custom workflows).
|
||||
bool open_archive(const String &path, int priority);
|
||||
|
||||
// Close all open archives.
|
||||
void close();
|
||||
|
||||
// Check whether a file exists in any archive.
|
||||
bool has_file(const String &internal_path) const;
|
||||
|
||||
// Read a file from the highest-priority archive that contains it.
|
||||
PackedByteArray read_file(const String &internal_path) const;
|
||||
|
||||
// Extract a single file to output_path (creates parent dirs).
|
||||
bool extract_file(const String &internal_path, const String &output_path) const;
|
||||
|
||||
// Extract all files matching filter (e.g. "DBFilesClient\\*.dbc")
|
||||
// into output_dir, preserving sub-paths.
|
||||
// Returns number of extracted files.
|
||||
int extract_files(const String &filter, const String &output_dir) const;
|
||||
|
||||
// List all files in all archives matching filter. Deduplicates by name,
|
||||
// keeping only the entry from the highest-priority archive.
|
||||
PackedStringArray list_files(const String &filter = "*") const;
|
||||
|
||||
// Info: how many archives are open
|
||||
int get_archive_count() const;
|
||||
|
||||
// Info: array of Dictionaries { "path": ..., "priority": ..., "file_count": ... }
|
||||
Array get_archive_info() const;
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
std::vector<MPQArchiveEntry> m_archives; // sorted ascending by priority
|
||||
|
||||
// Returns the handle of the highest-priority archive that contains the file.
|
||||
// Returns nullptr if not found.
|
||||
HANDLE find_file(const std::string &internal_path) const;
|
||||
|
||||
// Helper: std::string → Godot String
|
||||
static String to_godot(const std::string &s);
|
||||
// Helper: Godot String → std::string (ANSI/UTF8 path)
|
||||
static std::string to_std(const String &s);
|
||||
|
||||
// Ensure output directory exists (creates recursively)
|
||||
static bool ensure_dir(const std::string &path);
|
||||
|
||||
// WoW 3.3.5a archive load order (lowest→highest priority)
|
||||
// locale archives always have higher priority than base archives
|
||||
static std::vector<std::pair<std::string,int>> build_archive_list(
|
||||
const std::string &data_dir,
|
||||
const std::string &locale);
|
||||
};
|
||||
|
||||
} // namespace godot
|
||||
@@ -0,0 +1,40 @@
|
||||
#include "register_types.h"
|
||||
#include "mpq_manager.h"
|
||||
#include "wmo_loader.h"
|
||||
#include "adt_loader.h"
|
||||
#include "blp_loader.h"
|
||||
#include "m2_loader.h"
|
||||
|
||||
#include <godot_cpp/core/defs.hpp>
|
||||
#include <godot_cpp/godot.hpp>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
void initialize_mpq_extractor_module(ModuleInitializationLevel p_level) {
|
||||
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) return;
|
||||
ClassDB::register_class<MPQManager>();
|
||||
ClassDB::register_class<WMOLoader>();
|
||||
ClassDB::register_class<ADTLoader>();
|
||||
ClassDB::register_class<BLPLoader>();
|
||||
ClassDB::register_class<M2Loader>();
|
||||
}
|
||||
|
||||
void uninitialize_mpq_extractor_module(ModuleInitializationLevel p_level) {
|
||||
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) return;
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
|
||||
GDExtensionBool GDE_EXPORT mpq_extractor_init(
|
||||
GDExtensionInterfaceGetProcAddress p_get_proc_address,
|
||||
const GDExtensionClassLibraryPtr p_library,
|
||||
GDExtensionInitialization *r_initialization)
|
||||
{
|
||||
godot::GDExtensionBinding::InitObject init_obj(p_get_proc_address, p_library, r_initialization);
|
||||
init_obj.register_initializer(initialize_mpq_extractor_module);
|
||||
init_obj.register_terminator(uninitialize_mpq_extractor_module);
|
||||
init_obj.set_minimum_library_initialization_level(MODULE_INITIALIZATION_LEVEL_SCENE);
|
||||
return init_obj.init();
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
|
||||
void initialize_mpq_extractor_module(godot::ModuleInitializationLevel p_level);
|
||||
void uninitialize_mpq_extractor_module(godot::ModuleInitializationLevel p_level);
|
||||
@@ -0,0 +1,355 @@
|
||||
#include "wmo_loader.h"
|
||||
#include "wow_chunk_reader.h"
|
||||
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
#include <godot_cpp/variant/packed_string_array.hpp>
|
||||
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using namespace godot;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Raw WMO binary structures (packed, no padding)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
#pragma pack(push, 1)
|
||||
|
||||
struct MOHDChunk { // Root header
|
||||
uint32_t nMaterials;
|
||||
uint32_t nGroups;
|
||||
uint32_t nPortals;
|
||||
uint32_t nLights;
|
||||
uint32_t nModels;
|
||||
uint32_t nDoodads;
|
||||
uint32_t nSets;
|
||||
uint32_t ambColor;
|
||||
uint32_t areaID;
|
||||
float bboxMin[3];
|
||||
float bboxMax[3];
|
||||
uint16_t flags;
|
||||
uint16_t numLiquids;
|
||||
};
|
||||
|
||||
struct MOMTEntry { // Material
|
||||
uint32_t flags;
|
||||
uint32_t shader;
|
||||
uint32_t blendMode;
|
||||
uint32_t texUnit0; // index into MOTX
|
||||
uint32_t sidnColor;
|
||||
uint32_t frameSidnColor;
|
||||
uint32_t texUnit1;
|
||||
uint32_t diffColor;
|
||||
uint32_t groundType;
|
||||
uint32_t texUnit2;
|
||||
uint32_t color2;
|
||||
uint32_t flags2;
|
||||
uint32_t runTimeData[4];
|
||||
};
|
||||
|
||||
struct MOGPHeader { // Group header (inside MOGP chunk)
|
||||
uint32_t groupNameOfs;
|
||||
uint32_t descNameOfs;
|
||||
uint32_t flags;
|
||||
float bboxMin[3];
|
||||
float bboxMax[3];
|
||||
uint16_t portalStart;
|
||||
uint16_t portalCount;
|
||||
uint16_t transBatchCount;
|
||||
uint16_t intBatchCount;
|
||||
uint16_t extBatchCount;
|
||||
uint16_t padding;
|
||||
uint8_t fogIds[4];
|
||||
uint32_t liquidType;
|
||||
uint32_t groupID;
|
||||
uint32_t unk32a;
|
||||
uint32_t unk32b;
|
||||
};
|
||||
|
||||
struct MOBAEntry { // Render batch
|
||||
int16_t bboxMin[3];
|
||||
int16_t bboxMax[3];
|
||||
uint32_t indexStart;
|
||||
uint16_t indexCount;
|
||||
uint16_t vertexStart;
|
||||
uint16_t vertexEnd;
|
||||
uint8_t flags;
|
||||
uint8_t materialId;
|
||||
};
|
||||
|
||||
struct MOVTEntry { float x, y, z; };
|
||||
struct MONREntry { float x, y, z; };
|
||||
struct MOTVEntry { float u, v; };
|
||||
struct MOCVEntry { uint8_t b, g, r, a; };
|
||||
|
||||
#pragma pack(pop)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
std::vector<uint8_t> WMOLoader::read_file(const std::string &path) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) return {};
|
||||
auto sz = f.tellg();
|
||||
f.seekg(0);
|
||||
std::vector<uint8_t> buf(sz);
|
||||
f.read(reinterpret_cast<char *>(buf.data()), sz);
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string WMOLoader::to_std(const String &s) {
|
||||
return std::string(s.utf8().get_data());
|
||||
}
|
||||
|
||||
String WMOLoader::to_godot(const std::string &s) {
|
||||
return String(s.c_str());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Root parser
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
Dictionary WMOLoader::parse_root(const std::vector<uint8_t> &buf) {
|
||||
Dictionary root;
|
||||
if (buf.empty()) return root;
|
||||
|
||||
ChunkReader reader(buf.data(), buf.size());
|
||||
WoWChunk chunk;
|
||||
|
||||
PackedStringArray textures;
|
||||
Array materials;
|
||||
std::vector<std::string> tex_strings;
|
||||
|
||||
while (reader.next(chunk)) {
|
||||
|
||||
if (chunk.is("MOTX")) {
|
||||
// Null-separated texture paths
|
||||
auto names = parse_string_block(chunk);
|
||||
tex_strings = names;
|
||||
for (auto &n : names)
|
||||
textures.push_back(to_godot(n));
|
||||
|
||||
} else if (chunk.is("MOMT")) {
|
||||
uint32_t n = chunk.count_of(sizeof(MOMTEntry));
|
||||
auto *mt = chunk.array<MOMTEntry>();
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
Dictionary mat;
|
||||
mat["flags"] = (int)mt[i].flags;
|
||||
mat["shader"] = (int)mt[i].shader;
|
||||
mat["blend_mode"] = (int)mt[i].blendMode;
|
||||
// texUnit offsets → find index in tex_strings
|
||||
auto tex_idx = [&](uint32_t ofs) -> int {
|
||||
uint32_t cur = 0;
|
||||
for (int j = 0; j < (int)tex_strings.size(); ++j) {
|
||||
if (cur == ofs) return j;
|
||||
cur += (uint32_t)tex_strings[j].size() + 1;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
mat["texture0"] = tex_idx(mt[i].texUnit0);
|
||||
mat["texture1"] = tex_idx(mt[i].texUnit1);
|
||||
materials.push_back(mat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
root["textures"] = textures;
|
||||
root["materials"] = materials;
|
||||
return root;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Group parser
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
Dictionary WMOLoader::parse_group(const std::vector<uint8_t> &buf) {
|
||||
Dictionary group;
|
||||
if (buf.empty()) return group;
|
||||
|
||||
ChunkReader top(buf.data(), buf.size());
|
||||
WoWChunk chunk;
|
||||
|
||||
// Skip MVER, find MOGP
|
||||
while (top.next(chunk)) {
|
||||
if (!chunk.is("MOGP")) continue;
|
||||
|
||||
// MOGP data = MOGPHeader + sub-chunks
|
||||
if (chunk.size < sizeof(MOGPHeader)) break;
|
||||
// const auto &hdr = *reinterpret_cast<const MOGPHeader *>(chunk.data);
|
||||
|
||||
// Sub-chunks start after the header
|
||||
size_t sub_ofs = sizeof(MOGPHeader);
|
||||
ChunkReader sub(chunk.data + sub_ofs, chunk.size - sub_ofs);
|
||||
WoWChunk sc;
|
||||
|
||||
PackedVector3Array vertices, normals;
|
||||
PackedVector2Array uvs;
|
||||
PackedColorArray colors;
|
||||
PackedInt32Array indices;
|
||||
Array batches;
|
||||
|
||||
while (sub.next(sc)) {
|
||||
if (sc.is("MOVT")) {
|
||||
uint32_t n = sc.count_of(sizeof(MOVTEntry));
|
||||
auto *v = sc.array<MOVTEntry>();
|
||||
vertices.resize(n);
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
float gx, gy, gz;
|
||||
// WMO local coords → negate Z for Godot Y-up
|
||||
gx = -v[i].y;
|
||||
gy = v[i].z;
|
||||
gz = -v[i].x;
|
||||
vertices[i] = Vector3(gx, gy, gz);
|
||||
}
|
||||
} else if (sc.is("MONR")) {
|
||||
uint32_t n = sc.count_of(sizeof(MONREntry));
|
||||
auto *v = sc.array<MONREntry>();
|
||||
normals.resize(n);
|
||||
for (uint32_t i = 0; i < n; ++i)
|
||||
normals[i] = Vector3(-v[i].y, v[i].z, -v[i].x);
|
||||
|
||||
} else if (sc.is("MOTV")) {
|
||||
uint32_t n = sc.count_of(sizeof(MOTVEntry));
|
||||
auto *v = sc.array<MOTVEntry>();
|
||||
uvs.resize(n);
|
||||
for (uint32_t i = 0; i < n; ++i)
|
||||
uvs[i] = Vector2(v[i].u, v[i].v);
|
||||
|
||||
} else if (sc.is("MOVI")) {
|
||||
uint32_t n = sc.size / 2;
|
||||
auto *idx = sc.array<uint16_t>();
|
||||
indices.resize(n);
|
||||
for (uint32_t i = 0; i < n; ++i)
|
||||
indices[i] = (int)idx[i];
|
||||
|
||||
} else if (sc.is("MOCV")) {
|
||||
uint32_t n = sc.count_of(sizeof(MOCVEntry));
|
||||
auto *v = sc.array<MOCVEntry>();
|
||||
colors.resize(n);
|
||||
for (uint32_t i = 0; i < n; ++i)
|
||||
colors[i] = Color(v[i].r / 255.f, v[i].g / 255.f,
|
||||
v[i].b / 255.f, v[i].a / 255.f);
|
||||
|
||||
} else if (sc.is("MOBA")) {
|
||||
uint32_t n = sc.count_of(sizeof(MOBAEntry));
|
||||
auto *ba = sc.array<MOBAEntry>();
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
Dictionary b;
|
||||
b["index_start"] = (int)ba[i].indexStart;
|
||||
b["index_count"] = (int)ba[i].indexCount;
|
||||
b["vertex_start"] = (int)ba[i].vertexStart;
|
||||
b["vertex_count"] = (int)ba[i].vertexEnd - (int)ba[i].vertexStart + 1;
|
||||
b["material_id"] = (int)ba[i].materialId;
|
||||
b["flags"] = (int)ba[i].flags;
|
||||
batches.push_back(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group["vertices"] = vertices;
|
||||
group["normals"] = normals;
|
||||
group["uvs"] = uvs;
|
||||
group["colors"] = colors;
|
||||
group["indices"] = indices;
|
||||
group["batches"] = batches;
|
||||
break; // only one MOGP per group file
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
Dictionary WMOLoader::load_root(const String &path) {
|
||||
auto buf = read_file(to_std(path));
|
||||
if (buf.empty()) {
|
||||
UtilityFunctions::push_error("WMOLoader: cannot read ", path);
|
||||
return Dictionary();
|
||||
}
|
||||
return parse_root(buf);
|
||||
}
|
||||
|
||||
Dictionary WMOLoader::load_group(const String &path) {
|
||||
auto buf = read_file(to_std(path));
|
||||
if (buf.empty()) {
|
||||
UtilityFunctions::push_error("WMOLoader: cannot read ", path);
|
||||
return Dictionary();
|
||||
}
|
||||
return parse_group(buf);
|
||||
}
|
||||
|
||||
Dictionary WMOLoader::load_wmo(const String &path) {
|
||||
std::string spath = to_std(path);
|
||||
auto root_buf = read_file(spath);
|
||||
if (root_buf.empty()) {
|
||||
UtilityFunctions::push_error("WMOLoader: cannot read root ", path);
|
||||
return Dictionary();
|
||||
}
|
||||
|
||||
Dictionary result = parse_root(root_buf);
|
||||
|
||||
// Count groups from MOHD
|
||||
uint32_t nGroups = 0;
|
||||
{
|
||||
ChunkReader r(root_buf.data(), root_buf.size());
|
||||
WoWChunk c;
|
||||
if (r.find("MOHD", c) && c.size >= sizeof(MOHDChunk))
|
||||
nGroups = c.as<MOHDChunk>().nGroups;
|
||||
}
|
||||
|
||||
// Build base path (strip .wmo extension)
|
||||
std::string base = spath;
|
||||
if (base.size() > 4 && base.substr(base.size() - 4) == ".wmo")
|
||||
base = base.substr(0, base.size() - 4);
|
||||
|
||||
Array groups;
|
||||
for (uint32_t i = 0; i < nGroups; ++i) {
|
||||
char suffix[16];
|
||||
std::snprintf(suffix, sizeof(suffix), "_%03u.wmo", i);
|
||||
std::string gpath = base + suffix;
|
||||
|
||||
auto gbuf = read_file(gpath);
|
||||
if (gbuf.empty()) {
|
||||
UtilityFunctions::push_warning("WMOLoader: group not found: ",
|
||||
to_godot(gpath));
|
||||
groups.push_back(Dictionary());
|
||||
continue;
|
||||
}
|
||||
groups.push_back(parse_group(gbuf));
|
||||
}
|
||||
|
||||
result["groups"] = groups;
|
||||
UtilityFunctions::print("WMOLoader: loaded ", (int)nGroups, " groups from ", path);
|
||||
return result;
|
||||
}
|
||||
|
||||
Dictionary WMOLoader::load_wmo_from_bytes(const PackedByteArray &root_bytes,
|
||||
const Array &group_bytes_array)
|
||||
{
|
||||
std::vector<uint8_t> rbuf(root_bytes.size());
|
||||
std::memcpy(rbuf.data(), root_bytes.ptr(), root_bytes.size());
|
||||
|
||||
Dictionary result = parse_root(rbuf);
|
||||
Array groups;
|
||||
|
||||
for (int i = 0; i < group_bytes_array.size(); ++i) {
|
||||
PackedByteArray gb = group_bytes_array[i];
|
||||
if (gb.is_empty()) { groups.push_back(Dictionary()); continue; }
|
||||
std::vector<uint8_t> gbuf(gb.size());
|
||||
std::memcpy(gbuf.data(), gb.ptr(), gb.size());
|
||||
groups.push_back(parse_group(gbuf));
|
||||
}
|
||||
|
||||
result["groups"] = groups;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
void WMOLoader::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("load_wmo", "path"), &WMOLoader::load_wmo);
|
||||
ClassDB::bind_method(D_METHOD("load_root", "path"), &WMOLoader::load_root);
|
||||
ClassDB::bind_method(D_METHOD("load_group", "path"), &WMOLoader::load_group);
|
||||
ClassDB::bind_method(D_METHOD("load_wmo_from_bytes", "root_bytes", "group_bytes_array"), &WMOLoader::load_wmo_from_bytes);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
#include <godot_cpp/classes/ref_counted.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <godot_cpp/variant/array.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
#include <godot_cpp/variant/packed_vector3_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector2_array.hpp>
|
||||
#include <godot_cpp/variant/packed_int32_array.hpp>
|
||||
#include <godot_cpp/variant/packed_color_array.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
|
||||
#include "wow_chunk_reader.h"
|
||||
|
||||
namespace godot {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WMOLoader
|
||||
//
|
||||
// Loads WoW 3.3.5a WMO files (root + group files).
|
||||
//
|
||||
// Usage (GDScript):
|
||||
// var wmo = WMOLoader.new()
|
||||
// var data = wmo.load_wmo("C:/wow/Data/World/wmo/Azeroth/Buildings/StormwindHouse/StormwindHouse.wmo")
|
||||
//
|
||||
// Return value is a Dictionary:
|
||||
// {
|
||||
// "textures": PackedStringArray, # texture filenames referenced by materials
|
||||
// "materials": Array[Dictionary], # material definitions
|
||||
// "groups": Array[Dictionary], # one entry per group file
|
||||
// }
|
||||
//
|
||||
// Each group Dictionary:
|
||||
// {
|
||||
// "name": String,
|
||||
// "vertices": PackedVector3Array,
|
||||
// "normals": PackedVector3Array,
|
||||
// "uvs": PackedVector2Array,
|
||||
// "colors": PackedColorArray, # may be empty
|
||||
// "indices": PackedInt32Array,
|
||||
// "batches": Array[Dictionary], # render batches
|
||||
// }
|
||||
//
|
||||
// Each batch Dictionary:
|
||||
// {
|
||||
// "index_start": int,
|
||||
// "index_count": int,
|
||||
// "vertex_start": int,
|
||||
// "vertex_count": int,
|
||||
// "material_id": int,
|
||||
// }
|
||||
//
|
||||
// Each material Dictionary:
|
||||
// {
|
||||
// "texture0": int, # index into textures array (-1 = none)
|
||||
// "texture1": int,
|
||||
// "blend_mode": int, # 0=opaque 1=alpha key 2=alpha blend
|
||||
// "flags": int,
|
||||
// }
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class WMOLoader : public RefCounted {
|
||||
GDCLASS(WMOLoader, RefCounted)
|
||||
|
||||
public:
|
||||
// Load root WMO + all group files from the same directory.
|
||||
// path must be an absolute OS path.
|
||||
Dictionary load_wmo(const String &path);
|
||||
|
||||
// Load only the root file (no groups).
|
||||
Dictionary load_root(const String &path);
|
||||
|
||||
// Load a single group file.
|
||||
Dictionary load_group(const String &path);
|
||||
|
||||
// Load from raw bytes (e.g. from MPQManager::read_file).
|
||||
// group_bytes: Array of PackedByteArray, one per group file.
|
||||
Dictionary load_wmo_from_bytes(const PackedByteArray &root_bytes,
|
||||
const Array &group_bytes_array);
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
Dictionary parse_root(const std::vector<uint8_t> &buf);
|
||||
Dictionary parse_group(const std::vector<uint8_t> &buf);
|
||||
|
||||
static std::vector<uint8_t> read_file(const std::string &path);
|
||||
static std::string to_std(const String &s);
|
||||
static String to_godot(const std::string &s);
|
||||
};
|
||||
|
||||
} // namespace godot
|
||||
@@ -0,0 +1,112 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Lightweight in-memory chunk reader for WoW chunked binary formats
|
||||
// (WMO, ADT, WDT, etc.)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
struct WoWChunk {
|
||||
uint32_t magic = 0;
|
||||
uint32_t size = 0;
|
||||
const uint8_t *data = nullptr; // points into the owning buffer
|
||||
|
||||
// Compare magic as 4 chars (e.g. "MVER").
|
||||
// WoW stores chunk IDs reversed in the file (MVER → REVM bytes).
|
||||
// After memcpy into uint32_t LE the logical name sits MSB→LSB,
|
||||
// so we compare each nibble from the top.
|
||||
bool is(const char *tag) const {
|
||||
return ((magic >> 24) & 0xFF) == (uint8_t)tag[0] &&
|
||||
((magic >> 16) & 0xFF) == (uint8_t)tag[1] &&
|
||||
((magic >> 8) & 0xFF) == (uint8_t)tag[2] &&
|
||||
((magic >> 0) & 0xFF) == (uint8_t)tag[3];
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
const T &as() const { return *reinterpret_cast<const T *>(data); }
|
||||
|
||||
template<typename T>
|
||||
const T *array() const { return reinterpret_cast<const T *>(data); }
|
||||
|
||||
uint32_t count_of(size_t elem_size) const {
|
||||
return size / (uint32_t)elem_size;
|
||||
}
|
||||
};
|
||||
|
||||
// Reads all top-level chunks from a buffer.
|
||||
// For WMO groups, use ChunkReader on the MOGP payload to get sub-chunks.
|
||||
class ChunkReader {
|
||||
public:
|
||||
explicit ChunkReader(const uint8_t *buf, size_t len)
|
||||
: m_buf(buf), m_len(len) {}
|
||||
|
||||
// Returns true if chunk at current position was read into `out`
|
||||
bool next(WoWChunk &out) {
|
||||
if (m_pos + 8 > m_len) return false;
|
||||
std::memcpy(&out.magic, m_buf + m_pos, 4);
|
||||
std::memcpy(&out.size, m_buf + m_pos + 4, 4);
|
||||
m_pos += 8;
|
||||
if (m_pos + out.size > m_len) return false;
|
||||
out.data = m_buf + m_pos;
|
||||
m_pos += out.size;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool eof() const { return m_pos >= m_len; }
|
||||
void reset() { m_pos = 0; }
|
||||
|
||||
// Find first chunk with given 4-char magic, reset on miss
|
||||
bool find(const char *tag, WoWChunk &out) {
|
||||
reset();
|
||||
WoWChunk c;
|
||||
while (next(c)) {
|
||||
if (c.is(tag)) { out = c; return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
const uint8_t *m_buf;
|
||||
size_t m_len;
|
||||
size_t m_pos = 0;
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// WoW world coordinates → Godot Y-up
|
||||
// WoW: X=North, Y=West, Z=Up, world center at (17066.666, 17066.666)
|
||||
inline void wow_to_godot(float wx, float wy, float wz,
|
||||
float &gx, float &gy, float &gz)
|
||||
{
|
||||
gx = -(wy - 17066.666f);
|
||||
gy = wz;
|
||||
gz = -(wx - 17066.666f);
|
||||
}
|
||||
|
||||
// WoW rotation (degrees, ZXY order) → Godot Euler (radians, XYZ)
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
inline void wow_rot_to_godot(float rx, float ry, float rz,
|
||||
float &gx, float &gy, float &gz)
|
||||
{
|
||||
constexpr float D2R = (float)(M_PI / 180.0);
|
||||
gx = rx * D2R;
|
||||
gy = (rz - 90.0f) * D2R; // WoW Z-rot maps to Godot Y
|
||||
gz = -ry * D2R;
|
||||
}
|
||||
|
||||
// Collect null-separated strings from a chunk into a vector
|
||||
inline std::vector<std::string> parse_string_block(const WoWChunk &c) {
|
||||
std::vector<std::string> result;
|
||||
const char *p = reinterpret_cast<const char *>(c.data);
|
||||
const char *end = p + c.size;
|
||||
while (p < end) {
|
||||
result.emplace_back(p);
|
||||
p += result.back().size() + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,34 @@
|
||||
## Free-fly camera: WASD + QE up/down, RMB hold to look, Shift to speed up
|
||||
extends Camera3D
|
||||
|
||||
@export var speed : float = 200.0
|
||||
@export var fast_mult : float = 5.0
|
||||
@export var sensitivity: float = 0.003
|
||||
|
||||
var _captured := false
|
||||
|
||||
func _ready() -> void:
|
||||
current = true
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
if event.button_index == MOUSE_BUTTON_RIGHT:
|
||||
_captured = event.pressed
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE
|
||||
|
||||
if _captured and event is InputEventMouseMotion:
|
||||
rotate_y(-event.relative.x * sensitivity)
|
||||
rotate_object_local(Vector3.RIGHT, -event.relative.y * sensitivity)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _captured:
|
||||
return
|
||||
var move := Vector3.ZERO
|
||||
if Input.is_key_pressed(KEY_W): move -= basis.z
|
||||
if Input.is_key_pressed(KEY_S): move += basis.z
|
||||
if Input.is_key_pressed(KEY_A): move -= basis.x
|
||||
if Input.is_key_pressed(KEY_D): move += basis.x
|
||||
if Input.is_key_pressed(KEY_E): move += Vector3.UP
|
||||
if Input.is_key_pressed(KEY_Q): move -= Vector3.UP
|
||||
var s := speed * (fast_mult if Input.is_key_pressed(KEY_SHIFT) else 1.0)
|
||||
position += move.normalized() * s * delta
|
||||
@@ -0,0 +1 @@
|
||||
uid://bauggobg40psr
|
||||
@@ -0,0 +1,359 @@
|
||||
## CharacterGeosetController – WoW 3.3.5a (build 12340) character geoset manager.
|
||||
##
|
||||
## Attach to the root of an imported character GLB scene.
|
||||
##
|
||||
## Correct geoset ID ranges (from M2SkinMeshPartID, WotLK 3.3.5):
|
||||
## 0 skin – base body, always visible
|
||||
## 1 – 34 hair – hair style variants
|
||||
## 101 – 124 facial1 – beard
|
||||
## 201 – 219 facial2 – mustache
|
||||
## 301 – 319 facial3 – sideburns
|
||||
## 401 – 405 gloves
|
||||
## 501 – 510 boots
|
||||
## 601 – 614 shirt
|
||||
## 701 – 711 ears
|
||||
## 801 – 804 wristbands (801 = bare wrists)
|
||||
## 901 – 905 kneepads
|
||||
## 1001– 1004 chest
|
||||
## 1101– 1105 pants
|
||||
## 1201– 1204 tabard
|
||||
## 1301– 1303 legs
|
||||
## 1401– 1414 shirt_doublet
|
||||
## 1501– 1524 cape
|
||||
## 1601– 1614 facial_jewelry
|
||||
## 1701– 1705 eye_effects
|
||||
## 1801– 1804 belt
|
||||
## 1901– 1914 trail
|
||||
## 2001– 2008 feet
|
||||
|
||||
@tool
|
||||
extends Node3D
|
||||
|
||||
const EYE_GLOW_SHADER := preload("res://src/scenes/character/eye_glow.gdshader")
|
||||
|
||||
## Customisation (0 = auto-select first available on _ready)
|
||||
@export var hair_style: int = 0 : set = _set_hair
|
||||
@export var facial1: int = 0 : set = _set_facial1
|
||||
@export var facial2: int = 0 : set = _set_facial2
|
||||
@export var facial3: int = 0 : set = _set_facial3
|
||||
@export var ears: int = 0 : set = _set_ears
|
||||
@export var eye_effects: int = 0 : set = _set_eye_effects
|
||||
@export var eye_glow_intensity: float = 1.3
|
||||
|
||||
## Skin / face customisation – forwarded to CharacterTextureCompositor sibling/child.
|
||||
@export var skin_color: int = 0 : set = _set_skin_color
|
||||
@export var face_style: int = 0 : set = _set_face_style
|
||||
|
||||
## Internal: available geoset IDs per category (populated in _ready from actual model).
|
||||
var _available: Dictionary = {} # category_name -> Array[int] (sorted)
|
||||
## Internal: valid ranges for texture-based customisation (from compositor).
|
||||
var _skin_color_count: int = 0
|
||||
var _face_style_count: int = 0
|
||||
|
||||
## Equipment toggles (off by default = naked character)
|
||||
@export var show_gloves: bool = false : set = _set_gloves
|
||||
@export var show_boots: bool = false : set = _set_boots
|
||||
@export var show_shirt: bool = false : set = _set_shirt
|
||||
## Wristband geoset ID: 801 = bare wrists (no bracer), 802+ = equipped bracers.
|
||||
## 0 = auto (picks first available in model).
|
||||
@export var wristband_style: int = 0 : set = _set_wristbands
|
||||
@export var show_kneepads: bool = false : set = _set_kneepads
|
||||
@export var show_chest: bool = false : set = _set_chest
|
||||
@export var show_pants: bool = false : set = _set_pants
|
||||
@export var show_tabard: bool = false : set = _set_tabard
|
||||
@export var show_legs: bool = false : set = _set_legs
|
||||
@export var show_cape: bool = false : set = _set_cape
|
||||
@export var show_belt: bool = false : set = _set_belt
|
||||
@export var show_feet: bool = false : set = _set_feet
|
||||
|
||||
# ── WoW 3.3.5 geoset ID ranges ───────────────────────────────────────────────
|
||||
# Each entry: [start, end_exclusive, category_name]
|
||||
const RANGES: Array = [
|
||||
[0, 1, "skin"],
|
||||
[1, 35, "hair"],
|
||||
[101, 125, "facial1"],
|
||||
[201, 220, "facial2"],
|
||||
[301, 320, "facial3"],
|
||||
[401, 406, "gloves"],
|
||||
[501, 511, "boots"],
|
||||
[601, 615, "shirt"],
|
||||
[701, 712, "ears"],
|
||||
[801, 805, "wristbands"],
|
||||
[901, 906, "kneepads"],
|
||||
[1001, 1005, "chest"],
|
||||
[1101, 1106, "pants"],
|
||||
[1201, 1205, "tabard"],
|
||||
[1301, 1304, "legs"],
|
||||
[1401, 1415, "shirt_doublet"],
|
||||
[1501, 1525, "cape"],
|
||||
[1601, 1615, "facial_jewelry"],
|
||||
[1701, 1706, "eye_effects"],
|
||||
[1801, 1805, "belt"],
|
||||
[1901, 1915, "trail"],
|
||||
[2001, 2009, "feet"],
|
||||
]
|
||||
|
||||
func _ready() -> void:
|
||||
var nodes := _geoset_nodes()
|
||||
if nodes.is_empty():
|
||||
return
|
||||
|
||||
# Build _available: actual geoset IDs present in the model, grouped by category.
|
||||
_available.clear()
|
||||
for child in nodes:
|
||||
var gid := _geoset_id(child.name)
|
||||
var cat := _category(gid)
|
||||
if not _available.has(cat):
|
||||
_available[cat] = []
|
||||
_available[cat].append(gid)
|
||||
for cat in _available:
|
||||
(_available[cat] as Array).sort()
|
||||
|
||||
# Query compositor for skin/face counts (it knows what PNGs are on disk).
|
||||
var comp := _get_compositor()
|
||||
if comp:
|
||||
_skin_color_count = comp.get_skin_color_count()
|
||||
_face_style_count = comp.get_face_style_count()
|
||||
|
||||
# Clamp each export to the nearest valid value for this model.
|
||||
hair_style = _clamp_geoset("hair", hair_style)
|
||||
facial1 = _clamp_geoset("facial1", facial1)
|
||||
facial2 = _clamp_geoset("facial2", facial2)
|
||||
facial3 = _clamp_geoset("facial3", facial3)
|
||||
ears = _clamp_geoset("ears", ears)
|
||||
eye_effects = _clamp_geoset("eye_effects", eye_effects)
|
||||
wristband_style = _clamp_geoset("wristbands", wristband_style)
|
||||
skin_color = clampi(skin_color, 0, maxi(_skin_color_count - 1, 0))
|
||||
face_style = clampi(face_style, 0, maxi(_face_style_count - 1, 0))
|
||||
|
||||
_apply_all(nodes)
|
||||
_apply_eye_glow_shader(nodes)
|
||||
|
||||
# ── setters ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func _set_hair(v): hair_style = _clamp_geoset("hair", v); _apply_variant("hair", hair_style)
|
||||
func _set_facial1(v): facial1 = _clamp_geoset("facial1", v); _apply_variant("facial1", facial1)
|
||||
func _set_facial2(v): facial2 = _clamp_geoset("facial2", v); _apply_variant("facial2", facial2)
|
||||
func _set_facial3(v): facial3 = _clamp_geoset("facial3", v); _apply_variant("facial3", facial3)
|
||||
func _set_ears(v): ears = _clamp_geoset("ears", v); _apply_variant("ears", ears)
|
||||
func _set_eye_effects(v): eye_effects = _clamp_geoset("eye_effects", v); _apply_variant("eye_effects", eye_effects)
|
||||
|
||||
func _set_skin_color(v: int) -> void:
|
||||
skin_color = clampi(v, 0, maxi(_skin_color_count - 1, 0))
|
||||
var c := _get_compositor()
|
||||
if c: c.skin_color = skin_color
|
||||
|
||||
func _set_face_style(v: int) -> void:
|
||||
face_style = clampi(v, 0, maxi(_face_style_count - 1, 0))
|
||||
var c := _get_compositor()
|
||||
if c: c.face_style = face_style
|
||||
|
||||
func _get_compositor() -> Node:
|
||||
# Look for CharacterTextureCompositor as a direct child first,
|
||||
# then as a sibling (child of our parent).
|
||||
for child in get_children():
|
||||
if child.get_script() and child.has_method("refresh"):
|
||||
return child
|
||||
var p := get_parent()
|
||||
if p:
|
||||
for sibling in p.get_children():
|
||||
if sibling != self and sibling.get_script() and sibling.has_method("refresh"):
|
||||
return sibling
|
||||
return null
|
||||
|
||||
func _set_gloves(v): show_gloves = v; _apply_toggle("gloves", v)
|
||||
func _set_boots(v): show_boots = v; _apply_toggle("boots", v)
|
||||
func _set_shirt(v): show_shirt = v; _apply_toggle("shirt", v)
|
||||
func _set_wristbands(v): wristband_style = v; _apply_variant("wristbands", v)
|
||||
func _set_kneepads(v): show_kneepads = v; _apply_toggle("kneepads", v)
|
||||
func _set_chest(v): show_chest = v; _apply_toggle("chest", v)
|
||||
func _set_pants(v): show_pants = v; _apply_toggle("pants", v)
|
||||
func _set_tabard(v): show_tabard = v; _apply_toggle("tabard", v)
|
||||
func _set_legs(v): show_legs = v; _apply_toggle("legs", v)
|
||||
func _set_cape(v): show_cape = v; _apply_toggle("cape", v)
|
||||
func _set_belt(v): show_belt = v; _apply_toggle("belt", v)
|
||||
func _set_feet(v): show_feet = v; _apply_toggle("feet", v)
|
||||
|
||||
# ── visibility ────────────────────────────────────────────────────────────────
|
||||
|
||||
## Show the geoset matching exact ID `wanted`; hide all others in the category.
|
||||
func _apply_variant(cat: String, wanted: int) -> void:
|
||||
for child in _geoset_nodes():
|
||||
var gid := _geoset_id(child.name)
|
||||
if _category(gid) == cat:
|
||||
child.visible = (gid == wanted)
|
||||
|
||||
## Show or hide all geosets in a category.
|
||||
func _apply_toggle(cat: String, on: bool) -> void:
|
||||
for child in _geoset_nodes():
|
||||
if _category(_geoset_id(child.name)) == cat:
|
||||
child.visible = on
|
||||
|
||||
func _apply_all(nodes: Array[Node] = []) -> void:
|
||||
if nodes.is_empty():
|
||||
nodes = _geoset_nodes()
|
||||
|
||||
for child in nodes:
|
||||
var gid := _geoset_id(child.name)
|
||||
var cat := _category(gid)
|
||||
|
||||
match cat:
|
||||
"skin": child.visible = true
|
||||
"hair": child.visible = (gid == hair_style)
|
||||
"facial1": child.visible = (gid == facial1)
|
||||
"facial2": child.visible = (gid == facial2)
|
||||
"facial3": child.visible = (gid == facial3)
|
||||
"ears": child.visible = (gid == ears)
|
||||
"eye_effects": child.visible = (gid == eye_effects)
|
||||
"gloves": child.visible = show_gloves
|
||||
"boots": child.visible = show_boots
|
||||
"shirt": child.visible = show_shirt
|
||||
"wristbands": child.visible = (gid == wristband_style)
|
||||
"kneepads": child.visible = show_kneepads
|
||||
"chest": child.visible = show_chest
|
||||
"pants": child.visible = show_pants
|
||||
"tabard": child.visible = show_tabard
|
||||
"legs": child.visible = show_legs
|
||||
"cape": child.visible = show_cape
|
||||
"belt": child.visible = show_belt
|
||||
"feet": child.visible = show_feet
|
||||
# shirt_doublet, facial_jewelry, trail: follow shirt / facial / trail
|
||||
"shirt_doublet": child.visible = show_shirt
|
||||
"facial_jewelry":child.visible = (gid == facial1 + 1500) # rough guess
|
||||
"trail": child.visible = true
|
||||
_: child.visible = true # unknown – leave visible
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func _geoset_nodes() -> Array[Node]:
|
||||
var result: Array[Node] = []
|
||||
_collect(self, result)
|
||||
return result
|
||||
|
||||
func _collect(node: Node, result: Array[Node]) -> void:
|
||||
for child in node.get_children():
|
||||
if child.name.begins_with("geoset_") and child is MeshInstance3D:
|
||||
result.append(child)
|
||||
else:
|
||||
_collect(child, result)
|
||||
|
||||
## Extract geoset_id from node name "geoset_NNNN_category_vNN"
|
||||
func _geoset_id(node_name: String) -> int:
|
||||
var parts := node_name.split("_")
|
||||
if parts.size() >= 2:
|
||||
return parts[1].to_int()
|
||||
return -1
|
||||
|
||||
## Map a geoset ID to its category name using the WoW 3.3.5 ranges.
|
||||
func _category(gid: int) -> String:
|
||||
for r in RANGES:
|
||||
if gid >= r[0] and gid < r[1]:
|
||||
return r[2]
|
||||
return "unknown"
|
||||
|
||||
## Replace materials on eye-glow geosets with the multiplicative glow shader.
|
||||
## Detects eye-glow blend in two ways (in priority order):
|
||||
## 1. GLTF node extras["blend_mode"] == 4 (Mod) or 3 (Add) from M2 material data
|
||||
## 2. Fallback: geoset ID in the eye_effects range 1701-1705
|
||||
## Call once after the scene loads.
|
||||
func _apply_eye_glow_shader(nodes: Array[Node] = []) -> void:
|
||||
if nodes.is_empty():
|
||||
nodes = _geoset_nodes()
|
||||
|
||||
# Per-race glow colour defaults (Mod blending: tints the eye behind it).
|
||||
# With blend_mul: result = ALBEDO * background.
|
||||
# Use saturated hues so the tint is strong; 1.0 on the dominant channel
|
||||
# keeps that channel of the background intact while suppressing others.
|
||||
var race_colors := {
|
||||
"bloodelf": Color(0.1, 1.0, 0.2), # green
|
||||
"nightelf": Color(0.6, 0.9, 1.0), # silver-blue
|
||||
"deathknight": Color(0.1, 0.4, 1.0), # icy blue
|
||||
"scourge": Color(0.1, 0.4, 1.0), # icy blue (undead DKs)
|
||||
}
|
||||
|
||||
# Guess race from scene/model name
|
||||
var scene_name := get_name().to_lower()
|
||||
var glow_color := Color(0.2, 1.0, 0.3) # default green
|
||||
for race in race_colors:
|
||||
if race in scene_name:
|
||||
glow_color = race_colors[race]
|
||||
break
|
||||
|
||||
for child in nodes:
|
||||
var mesh_inst := child as MeshInstance3D
|
||||
if not mesh_inst:
|
||||
continue
|
||||
|
||||
# Determine whether this geoset uses WoW eye-glow blending.
|
||||
# Primary: check blend_mode from M2 material stored in GLTF extras.
|
||||
# blend_mode=4 → Mod (DST_COLOR * src) — most races (Blood Elf, Night Elf, etc.)
|
||||
# blend_mode=3 → Add (SRC_ALPHA + ONE) — pure additive (rare; DK particles etc.)
|
||||
# Fallback: geoset ID in the eye_effects range (1701-1705) for older GLBs.
|
||||
var is_eye_glow := false
|
||||
if mesh_inst.has_meta("extras"):
|
||||
var extras = mesh_inst.get_meta("extras")
|
||||
if extras is Dictionary and extras.has("blend_mode"):
|
||||
var bm: int = extras["blend_mode"]
|
||||
is_eye_glow = (bm == 4 or bm == 3)
|
||||
if not is_eye_glow:
|
||||
is_eye_glow = (_category(_geoset_id(child.name)) == "eye_effects")
|
||||
|
||||
if not is_eye_glow:
|
||||
continue
|
||||
|
||||
# Replace every surface material with the glow shader
|
||||
for si in range(mesh_inst.get_surface_override_material_count()):
|
||||
var orig_mat := mesh_inst.mesh.surface_get_material(si)
|
||||
var shader_mat := ShaderMaterial.new()
|
||||
shader_mat.shader = EYE_GLOW_SHADER
|
||||
shader_mat.set_shader_parameter("glow_color", Vector3(glow_color.r, glow_color.g, glow_color.b))
|
||||
shader_mat.set_shader_parameter("glow_intensity", eye_glow_intensity)
|
||||
# Carry over the original albedo texture if it was a StandardMaterial3D
|
||||
if orig_mat is StandardMaterial3D:
|
||||
var std := orig_mat as StandardMaterial3D
|
||||
if std.albedo_texture:
|
||||
shader_mat.set_shader_parameter("albedo_texture", std.albedo_texture)
|
||||
mesh_inst.set_surface_override_material(si, shader_mat)
|
||||
|
||||
## Returns sorted list of geoset IDs available for a given category.
|
||||
## After _ready() this uses the cached _available dict; before _ready() it scans nodes.
|
||||
func get_ids(cat: String) -> Array[int]:
|
||||
if not _available.is_empty():
|
||||
var ids: Array = _available.get(cat, [])
|
||||
var result: Array[int] = []
|
||||
for id in ids:
|
||||
result.append(id)
|
||||
return result
|
||||
# Fallback: scan live (called before _ready in edge cases)
|
||||
var result: Array[int] = []
|
||||
for child in _geoset_nodes():
|
||||
var gid := _geoset_id(child.name)
|
||||
if _category(gid) == cat and not gid in result:
|
||||
result.append(gid)
|
||||
result.sort()
|
||||
return result
|
||||
|
||||
## Returns valid skin colour count for this model (0 if compositor not found yet).
|
||||
func get_skin_color_count() -> int: return _skin_color_count
|
||||
## Returns valid face style count for this model (0 if compositor not found yet).
|
||||
func get_face_style_count() -> int: return _face_style_count
|
||||
|
||||
## Clamp/snap v to the nearest available geoset ID in cat.
|
||||
## Returns v unchanged if _available is not populated yet or cat is missing.
|
||||
func _clamp_geoset(cat: String, v: int) -> int:
|
||||
if not _available.has(cat):
|
||||
return v
|
||||
var ids: Array = _available[cat] # already sorted
|
||||
if ids.is_empty():
|
||||
return v
|
||||
if v in ids:
|
||||
return v
|
||||
# Pick the nearest available ID
|
||||
var best: int = ids[0]
|
||||
var best_dist: int = abs(v - best)
|
||||
for id in ids:
|
||||
var d: int = abs(v - id)
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
best = id
|
||||
return best
|
||||
@@ -0,0 +1 @@
|
||||
uid://cb1cwcj4p6d6p
|
||||
@@ -0,0 +1,16 @@
|
||||
[gd_scene format=3 uid="uid://c71mitsmy1vq1"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://cb1cwcj4p6d6p" path="res://src/scenes/character/character_geoset_controller.gd" id="1_vyw27"]
|
||||
[ext_resource type="PackedScene" uid="uid://cf4xxu36nrmnq" path="res://src/resources/characters/Draenei/Female/DraeneiFemale.glb" id="2_vyw27"]
|
||||
|
||||
[node name="CharacterPreview" type="Node3D" unique_id=733111432]
|
||||
script = ExtResource("1_vyw27")
|
||||
hair_style = 3
|
||||
facial1 = 104
|
||||
facial2 = 1021
|
||||
facial3 = 83
|
||||
ears = 724
|
||||
eye_effects = 1702
|
||||
wristband_style = 802
|
||||
|
||||
[node name="DraeneiFemale" parent="." unique_id=999499863 instance=ExtResource("2_vyw27")]
|
||||
@@ -0,0 +1,242 @@
|
||||
## CharacterTextureCompositor
|
||||
##
|
||||
## Runtime skin-texture compositing for WoW 3.3.5a character models.
|
||||
##
|
||||
## Attach as a child of the character's root node (next to
|
||||
## CharacterGeosetController). Set [member textures_dir] to the
|
||||
## {ModelName}_textures/ folder that was exported alongside the GLB.
|
||||
##
|
||||
## UV layout of the 512x512 WoW character skin texture:
|
||||
## (0, 0) 256x128 NakedTorsoSkin – bra area
|
||||
## (0, 128) 256x128 NakedPelvisSkin – panties area
|
||||
## (0, 320) 256x64 FaceUpper – forehead / upper face
|
||||
## (0, 384) 256x128 FaceLower – eyes, mouth, nose
|
||||
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
# ── Layer paste positions (pixels, top-left origin) ─────────────────────────
|
||||
const NAKED_TORSO_POS := Vector2i(0, 0)
|
||||
const NAKED_PELVIS_POS := Vector2i(0, 128)
|
||||
const FACE_UPPER_POS := Vector2i(0, 320)
|
||||
const FACE_LOWER_POS := Vector2i(0, 384)
|
||||
|
||||
# ── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
## Path to the {ModelName}_textures/ folder.
|
||||
## Example: "res://src/resources/characters/BloodElf/Female/BloodElfFemale_textures"
|
||||
@export var textures_dir: String = "" : set = _set_textures_dir
|
||||
|
||||
## Skin colour index (selects skin_XX.png / naked_torso_XX.png /
|
||||
## naked_pelvis_XX.png).
|
||||
@export var skin_color: int = 0 : set = _set_skin_color
|
||||
|
||||
## Face style index (first part of face_lower_SS_CC.png filename).
|
||||
@export var face_style: int = 0 : set = _set_face_style
|
||||
|
||||
# ── Internal state ───────────────────────────────────────────────────────────
|
||||
|
||||
# MeshInstance3D surfaces to override: Array of {node, surface_idx}
|
||||
var _skin_surfaces: Array = []
|
||||
|
||||
# True once _ready has run (prevents setters from firing before init)
|
||||
var _ready_done := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_ready_done = true
|
||||
if textures_dir.is_empty():
|
||||
_auto_detect_textures_dir()
|
||||
_scan_skin_surfaces()
|
||||
_recomposite()
|
||||
|
||||
|
||||
# ── Setters ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func _set_textures_dir(v: String) -> void:
|
||||
textures_dir = v
|
||||
if _ready_done:
|
||||
_scan_skin_surfaces()
|
||||
_recomposite()
|
||||
|
||||
func _set_skin_color(v: int) -> void:
|
||||
skin_color = v
|
||||
if _ready_done: _recomposite()
|
||||
|
||||
func _set_face_style(v: int) -> void:
|
||||
face_style = v
|
||||
if _ready_done: _recomposite()
|
||||
|
||||
|
||||
# ── Auto-detection ───────────────────────────────────────────────────────────
|
||||
|
||||
func _auto_detect_textures_dir() -> void:
|
||||
# Convention: GLB is at res://src/resources/characters/Race/Gender/Model.glb
|
||||
# Textures: same folder / Model_textures/
|
||||
# We walk the scene tree to find the first MeshInstance3D and infer from
|
||||
# the mesh resource path.
|
||||
var root := get_parent() if get_parent() else get_tree().current_scene
|
||||
for mesh_inst in _iter_mesh_instances(root):
|
||||
var mesh := mesh_inst.mesh
|
||||
if mesh == null:
|
||||
continue
|
||||
var rpath := mesh.resource_path # e.g. res://src/resources/.../BloodElfFemale.glb::Mesh_0
|
||||
if rpath.is_empty():
|
||||
continue
|
||||
# Extract the GLB path (everything before "::")
|
||||
var glb_path := rpath.get_slice("::", 0)
|
||||
var dir := glb_path.get_base_dir()
|
||||
var stem := glb_path.get_file().get_basename()
|
||||
var candidate := dir.path_join(stem + "_textures")
|
||||
if DirAccess.dir_exists_absolute(candidate):
|
||||
textures_dir = candidate
|
||||
print("CharacterTextureCompositor: auto-detected textures_dir = ", textures_dir)
|
||||
return
|
||||
push_warning("CharacterTextureCompositor: could not auto-detect textures_dir; set it manually.")
|
||||
|
||||
|
||||
# ── Surface discovery ────────────────────────────────────────────────────────
|
||||
|
||||
func _scan_skin_surfaces() -> void:
|
||||
_skin_surfaces.clear()
|
||||
var root := get_parent() if get_parent() else self
|
||||
for mesh_inst in _iter_mesh_instances(root):
|
||||
var mesh := mesh_inst.mesh
|
||||
if mesh == null:
|
||||
continue
|
||||
for si in range(mesh.get_surface_count()):
|
||||
if _is_skin_surface(mesh_inst, si):
|
||||
_skin_surfaces.append({"node": mesh_inst, "surface": si})
|
||||
|
||||
|
||||
## A surface is a "skin surface" if:
|
||||
## - its active material is a StandardMaterial3D (not ShaderMaterial = eye glow)
|
||||
## - AND its albedo texture is 512x512 (the composited body skin)
|
||||
func _is_skin_surface(mesh_inst: MeshInstance3D, si: int) -> bool:
|
||||
# Prefer surface override, fall back to mesh material
|
||||
var mat := mesh_inst.get_surface_override_material(si)
|
||||
if mat == null:
|
||||
mat = mesh_inst.mesh.surface_get_material(si)
|
||||
if not (mat is StandardMaterial3D):
|
||||
return false
|
||||
var std := mat as StandardMaterial3D
|
||||
var tex := std.albedo_texture
|
||||
if tex == null:
|
||||
return false
|
||||
# Skin texture is 512x512; hair/other textures are smaller
|
||||
var img := tex.get_image()
|
||||
if img == null:
|
||||
return false
|
||||
return img.get_width() == 512 and img.get_height() == 512
|
||||
|
||||
|
||||
# ── Compositing ──────────────────────────────────────────────────────────────
|
||||
|
||||
func _recomposite() -> void:
|
||||
if textures_dir.is_empty() or _skin_surfaces.is_empty():
|
||||
return
|
||||
|
||||
var skin_tex := _build_skin_texture()
|
||||
if skin_tex == null:
|
||||
return
|
||||
|
||||
_apply_texture(skin_tex)
|
||||
|
||||
|
||||
func _build_skin_texture() -> ImageTexture:
|
||||
# 1. Base skin
|
||||
var base := _load_layer("skin_%02d.png" % skin_color)
|
||||
if base == null:
|
||||
push_warning("CharacterTextureCompositor: missing skin_%02d.png" % skin_color)
|
||||
return null
|
||||
|
||||
# 2. Naked overlays (underwear)
|
||||
_blit(base, _load_layer("naked_torso_%02d.png" % skin_color), NAKED_TORSO_POS)
|
||||
_blit(base, _load_layer("naked_pelvis_%02d.png" % skin_color), NAKED_PELVIS_POS)
|
||||
|
||||
# 3. Face
|
||||
_blit(base, _load_layer("face_upper_%02d_%02d.png" % [face_style, skin_color]), FACE_UPPER_POS)
|
||||
_blit(base, _load_layer("face_lower_%02d_%02d.png" % [face_style, skin_color]), FACE_LOWER_POS)
|
||||
|
||||
return ImageTexture.create_from_image(base)
|
||||
|
||||
|
||||
func _apply_texture(tex: ImageTexture) -> void:
|
||||
for entry in _skin_surfaces:
|
||||
var mesh_inst: MeshInstance3D = entry["node"]
|
||||
var si: int = entry["surface"]
|
||||
if not is_instance_valid(mesh_inst):
|
||||
continue
|
||||
|
||||
# Clone the existing material so we don't mutate the shared resource
|
||||
var orig_mat := mesh_inst.get_surface_override_material(si)
|
||||
if orig_mat == null:
|
||||
orig_mat = mesh_inst.mesh.surface_get_material(si)
|
||||
if not (orig_mat is StandardMaterial3D):
|
||||
continue
|
||||
|
||||
var mat := orig_mat.duplicate() as StandardMaterial3D
|
||||
mat.albedo_texture = tex
|
||||
mesh_inst.set_surface_override_material(si, mat)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func _load_layer(filename: String) -> Image:
|
||||
var path := textures_dir.path_join(filename)
|
||||
if not FileAccess.file_exists(path):
|
||||
return null
|
||||
var img := Image.load_from_file(path)
|
||||
return img
|
||||
|
||||
|
||||
func _blit(dst: Image, src: Image, pos: Vector2i) -> void:
|
||||
if src == null:
|
||||
return
|
||||
dst.blend_rect(src, Rect2i(Vector2i.ZERO, src.get_size()), pos)
|
||||
|
||||
|
||||
func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]:
|
||||
var result: Array[MeshInstance3D] = []
|
||||
_collect_meshes(root, result)
|
||||
return result
|
||||
|
||||
|
||||
func _collect_meshes(node: Node, result: Array[MeshInstance3D]) -> void:
|
||||
if node is MeshInstance3D:
|
||||
result.append(node)
|
||||
for child in node.get_children():
|
||||
_collect_meshes(child, result)
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
## Force a full rescan + recomposite (call after swapping the character GLB).
|
||||
func refresh() -> void:
|
||||
_scan_skin_surfaces()
|
||||
_recomposite()
|
||||
|
||||
|
||||
## Returns the number of skin surfaces found (useful for debugging).
|
||||
func get_skin_surface_count() -> int:
|
||||
return _skin_surfaces.size()
|
||||
|
||||
|
||||
## Returns how many skin colour variants exist (i.e. max valid skin_color + 1).
|
||||
func get_skin_color_count() -> int:
|
||||
if textures_dir.is_empty():
|
||||
return 0
|
||||
var n := 0
|
||||
while FileAccess.file_exists(textures_dir.path_join("skin_%02d.png" % n)):
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
## Returns how many face styles exist (i.e. max valid face_style + 1).
|
||||
func get_face_style_count() -> int:
|
||||
if textures_dir.is_empty():
|
||||
return 0
|
||||
var n := 0
|
||||
while FileAccess.file_exists(textures_dir.path_join("face_lower_%02d_00.png" % n)):
|
||||
n += 1
|
||||
return n
|
||||
@@ -0,0 +1 @@
|
||||
uid://dowc3tg8h7avb
|
||||
@@ -0,0 +1,75 @@
|
||||
[gd_scene format=3 uid="uid://b76a81ecfo3rs"]
|
||||
|
||||
[ext_resource type="PackedScene" uid="uid://uc8ihrw2g0hy" path="res://src/resources/characters/BloodElf/Female/BloodElfFemale.glb" id="1_22k5x"]
|
||||
|
||||
[node name="BloodElfFemale" unique_id=396948181 instance=ExtResource("1_22k5x")]
|
||||
|
||||
[node name="Skeleton3D" parent="Armature" parent_id_path=PackedInt32Array(1689329618) index="0" unique_id=1185793760]
|
||||
bones/1/position = Vector3(0.020567639, 1.1418878, -0.017966472)
|
||||
bones/2/position = Vector3(-5.5879354e-09, 0.013204693, 0.00061981526)
|
||||
bones/2/rotation = Quaternion(0.123804875, 0.11940578, 0.013367673, 0.9850056)
|
||||
bones/3/rotation = Quaternion(-0.06487095, 0.021112822, -0.035487678, 0.99703896)
|
||||
bones/4/rotation = Quaternion(-0.11656414, -0.009169256, 0.012765532, 0.9930588)
|
||||
bones/5/rotation = Quaternion(0.051051795, -0.04647367, 0.0915637, 0.9934033)
|
||||
bones/6/rotation = Quaternion(-0.0043863687, -0.08180441, -0.15909226, 0.98385894)
|
||||
bones/9/position = Vector3(0.095388055, 0.06224084, 0)
|
||||
bones/13/rotation = Quaternion(0.0036348682, -0.0020478186, -0.04413144, 0.99901706)
|
||||
bones/14/rotation = Quaternion(0.0050126333, -0.0004394381, 0.046115804, 0.99892354)
|
||||
bones/15/rotation = Quaternion(-0.045602754, -0.019244853, -0.1461395, 0.98802495)
|
||||
bones/16/rotation = Quaternion(-0.0010522516, 0.0012768576, 0.006581648, 0.999977)
|
||||
bones/17/rotation = Quaternion(-0.007964021, 0.00088885747, 0.03926191, 0.9991969)
|
||||
bones/21/position = Vector3(0.116049744, 0.0758859, -0.01846384)
|
||||
bones/21/rotation = Quaternion(-0.05670909, -0.0046792068, -0.03128321, 0.99788964)
|
||||
bones/22/rotation = Quaternion(-0.09271755, -0.025453072, 0.047640584, 0.99422634)
|
||||
bones/24/position = Vector3(0.11551966, -0.0015778318, -0.021920012)
|
||||
bones/25/position = Vector3(0.116256274, 0.06848195, 0.02165297)
|
||||
bones/25/rotation = Quaternion(0.02255213, -0.0075094164, -0.0022095616, 0.9997151)
|
||||
bones/26/rotation = Quaternion(0.09265718, 0.025392218, 0.047640927, 0.9942335)
|
||||
bones/27/rotation = Quaternion(0.0009912114, -0.0013378978, 0.0065816464, 0.999977)
|
||||
bones/28/rotation = Quaternion(0.007905513, -0.0009485561, 0.039257552, 0.9991975)
|
||||
bones/32/position = Vector3(0.1155197, -0.0015768784, 0.021421462)
|
||||
bones/37/rotation = Quaternion(0.06507223, 0.09674508, 0.068695635, 0.9908012)
|
||||
bones/38/rotation = Quaternion(-0.022194698, -0.06936834, -0.09758068, 0.9925591)
|
||||
bones/39/rotation = Quaternion(-0.03300783, -0.013267887, 0.03763607, 0.9986581)
|
||||
bones/40/rotation = Quaternion(0.033775434, -0.016210906, -0.057038154, 0.9976689)
|
||||
bones/51/rotation = Quaternion(-0.029908726, 0.020630918, -0.016968625, 0.99919564)
|
||||
bones/56/rotation = Quaternion(0.0024567072, -0.013335031, -0.0029236332, 0.9999038)
|
||||
bones/60/rotation = Quaternion(-0.016449975, -0.0067556957, 0.017745575, 0.99968445)
|
||||
bones/63/rotation = Quaternion(0.026935987, -0.05643349, 0.049124315, 0.9968333)
|
||||
bones/64/rotation = Quaternion(-0.027013818, 0.0563725, 0.04912438, 0.99683464)
|
||||
bones/65/rotation = Quaternion(-0.09290248, -0.15748595, 0.06828833, 0.98076713)
|
||||
bones/66/rotation = Quaternion(0.14272292, -0.011203573, -0.11163199, 0.9833834)
|
||||
bones/67/rotation = Quaternion(0.06000255, 0.01980045, 0.04261177, 0.9970917)
|
||||
bones/68/rotation = Quaternion(-0.022882408, 0.006796895, -0.1232713, 0.99208593)
|
||||
bones/69/position = Vector3(0.0413239, -0.06549978, -0.020981606)
|
||||
bones/69/rotation = Quaternion(-0.023896394, -0.10211665, 0.037965663, 0.9937604)
|
||||
bones/71/rotation = Quaternion(-0.08642865, -0.023835018, -0.0076296474, 0.99594367)
|
||||
bones/73/rotation = Quaternion(-0.053072903, -0.012879106, -0.0046084006, 0.99849695)
|
||||
bones/75/rotation = Quaternion(-0.032319825, -0.007080453, -0.0044558025, 0.9994426)
|
||||
bones/77/rotation = Quaternion(0.0020142787, 0.00033571312, 0.0003967519, 0.99999785)
|
||||
bones/79/position = Vector3(0.014056671, -0.1078162, -0.01205897)
|
||||
bones/79/rotation = Quaternion(0.0065921065, 0.017426355, -0.06521913, 0.997697)
|
||||
bones/88/rotation = Quaternion(-0.0022131095, 0.011718128, -0.0025471686, 0.99992573)
|
||||
bones/90/rotation = Quaternion(0.029425986, 0.010602127, 0.021910178, 0.99927056)
|
||||
bones/116/position = Vector3(5.5879354e-09, -0.013204574, -0.00061981526)
|
||||
bones/116/rotation = Quaternion(0.061444823, 0.11480759, -0.11069672, 0.9852868)
|
||||
bones/117/rotation = Quaternion(-0.15319423, 0.0766162, 0.067507334, 0.98290604)
|
||||
bones/118/rotation = Quaternion(0.0016076086, -0.0010537306, 0.0116005195, 0.99993086)
|
||||
bones/119/rotation = Quaternion(0.073131986, 0.04742621, 0.024243427, 0.995899)
|
||||
bones/120/rotation = Quaternion(-0.012878833, 0.0033570419, 0.0015869653, 0.9999102)
|
||||
bones/122/rotation = Quaternion(0.00078478147, -0.000550218, 0.0058596972, 0.9999825)
|
||||
bones/124/rotation = Quaternion(-0.13583149, 0.09406532, 0.17353375, 0.97086954)
|
||||
bones/125/rotation = Quaternion(0.01501317, -0.009498554, -0.10665566, 0.9941373)
|
||||
bones/126/rotation = Quaternion(0.04327209, -0.18378712, 0.019996213, 0.9818096)
|
||||
bones/127/rotation = Quaternion(0.0030824, -0.005432348, 0.0014954217, 0.9999794)
|
||||
bones/129/rotation = Quaternion(0.0074802367, -0.004755205, -0.05334622, 0.9985368)
|
||||
bones/131/position = Vector3(-0.15702619, -0.063200355, -0.00018678745)
|
||||
bones/131/rotation = Quaternion(-0.08914321, 0.0019654154, 0.059140634, 0.9942596)
|
||||
bones/132/rotation = Quaternion(0.04012855, 0.005028783, -0.012988713, 0.99909747)
|
||||
bones/133/rotation = Quaternion(-0.077178694, 0.076181, 0.12862553, 0.98574615)
|
||||
bones/134/rotation = Quaternion(-0.024023479, -0.006217299, -0.013663187, 0.99959874)
|
||||
bones/135/rotation = Quaternion(0.009281775, 0.039282277, 0.052435584, 0.9978082)
|
||||
bones/136/position = Vector3(-0.18500036, 0.052093893, 0.009707565)
|
||||
bones/136/rotation = Quaternion(-0.061086744, -0.06087173, 0.17241617, 0.98124194)
|
||||
bones/137/rotation = Quaternion(-0.024402294, -0.0006531618, 0.047712527, 0.99856275)
|
||||
bones/138/rotation = Quaternion(-0.018057052, -0.030023871, 0.06942246, 0.9969719)
|
||||
@@ -0,0 +1,35 @@
|
||||
// WoW 3.3.5 character eye glow effect
|
||||
// Replicates WoW M2 blendingMode=4 (Add: ONE + ONE — pure additive).
|
||||
//
|
||||
// render_mode breakdown:
|
||||
// blend_add — additive blending: eye adds light, makes area brighter
|
||||
// unshaded — no lighting response, fully self-illuminated
|
||||
// depth_draw_never — don't write depth, renders on top without occlusion
|
||||
// cull_disabled — visible from both sides (eye meshes are thin)
|
||||
//
|
||||
// How it works:
|
||||
// result = shader_output + background. The eye glow mesh sits over the
|
||||
// eye base and adds bright colored light in the shape of the glow texture.
|
||||
// Darker texture areas contribute nothing; bright areas max out the glow.
|
||||
|
||||
shader_type spatial;
|
||||
render_mode blend_add, unshaded, depth_draw_never, cull_disabled;
|
||||
|
||||
// Base texture from the M2 eye-glow BLP (e.g. BloodElfFemaleEyeGlowGreen.blp)
|
||||
uniform sampler2D albedo_texture : source_color, hint_default_white;
|
||||
|
||||
// Tint + brightness multiplier. Defaults match Blood Elf green glow.
|
||||
uniform vec3 glow_color : source_color = vec3(0.1, 1.0, 0.2);
|
||||
uniform float glow_intensity : hint_range(0.0, 4.0, 0.05) = 1.3;
|
||||
|
||||
void fragment() {
|
||||
vec4 tex = texture(albedo_texture, UV);
|
||||
|
||||
// Use texture luminance as the glow mask; multiply by tint and intensity.
|
||||
// High-luminance areas of the texture produce bright colored glow.
|
||||
float lum = dot(tex.rgb, vec3(0.299, 0.587, 0.114));
|
||||
ALBEDO = glow_color * glow_intensity * lum;
|
||||
|
||||
// Alpha drives blend contribution (SRC_ALPHA + ONE in additive mode).
|
||||
ALPHA = tex.a * lum;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://0p15euukhw1p
|
||||
@@ -0,0 +1,80 @@
|
||||
@tool
|
||||
## Editor-time preview for a single ADT tile.
|
||||
extends Node3D
|
||||
|
||||
const PREVIEW_NODE_NAME := "__adt_tile_preview__"
|
||||
const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd")
|
||||
|
||||
@export var extracted_dir: String = "res://data/extracted"
|
||||
@export var map_name: String = "Azeroth"
|
||||
@export var tile_x: int = 32
|
||||
@export var tile_y: int = 48
|
||||
@export var auto_rebuild_in_editor: bool = true
|
||||
@export var reload_now: bool = false
|
||||
|
||||
var _last_signature := ""
|
||||
var _rebuild_queued := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_process(true)
|
||||
_queue_rebuild()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if reload_now:
|
||||
reload_now = false
|
||||
_queue_rebuild()
|
||||
|
||||
if Engine.is_editor_hint() and auto_rebuild_in_editor:
|
||||
var current_signature := _make_signature()
|
||||
if current_signature != _last_signature:
|
||||
_queue_rebuild()
|
||||
|
||||
|
||||
func _queue_rebuild() -> void:
|
||||
if _rebuild_queued or not is_inside_tree():
|
||||
return
|
||||
_rebuild_queued = true
|
||||
call_deferred("_rebuild_preview")
|
||||
|
||||
|
||||
func _rebuild_preview() -> void:
|
||||
_rebuild_queued = false
|
||||
_clear_preview()
|
||||
_last_signature = _make_signature()
|
||||
|
||||
if not ClassDB.class_exists("ADTLoader"):
|
||||
push_warning("ADTLoader not found. Rebuild GDExtension first.")
|
||||
return
|
||||
|
||||
var abs_extracted := ProjectSettings.globalize_path(extracted_dir)
|
||||
var adt_path := "%s/World/Maps/%s/%s_%d_%d.adt" % [
|
||||
abs_extracted, map_name, map_name, tile_x, tile_y
|
||||
]
|
||||
|
||||
if not FileAccess.file_exists(adt_path):
|
||||
push_warning("ADT not found: %s" % adt_path)
|
||||
return
|
||||
|
||||
var loader = ClassDB.instantiate("ADTLoader")
|
||||
var data: Dictionary = loader.call("load_adt", adt_path)
|
||||
if data.is_empty() or not data.has("chunks"):
|
||||
push_warning("Failed to load ADT: %s" % adt_path)
|
||||
return
|
||||
|
||||
var builder := ADT_BUILDER_SCRIPT.new()
|
||||
var terrain: Node3D = builder.build_scene(data, abs_extracted)
|
||||
terrain.name = PREVIEW_NODE_NAME
|
||||
add_child(terrain)
|
||||
|
||||
|
||||
func _clear_preview() -> void:
|
||||
var preview := get_node_or_null(PREVIEW_NODE_NAME)
|
||||
if preview:
|
||||
remove_child(preview)
|
||||
preview.free()
|
||||
|
||||
|
||||
func _make_signature() -> String:
|
||||
return "%s|%s|%d|%d" % [extracted_dir, map_name, tile_x, tile_y]
|
||||
@@ -0,0 +1 @@
|
||||
uid://cacewewk7xgw4
|
||||
@@ -0,0 +1,30 @@
|
||||
[gd_scene format=3 uid="uid://cbgbp30flxdgx"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://cacewewk7xgw4" path="res://src/scenes/preview/adt_tile_preview.gd" id="1_preview"]
|
||||
[ext_resource type="Script" uid="uid://bauggobg40psr" path="res://src/scenes/camera/fly_camera.gd" id="2_flycam"]
|
||||
|
||||
[sub_resource type="Sky" id="sky_1"]
|
||||
|
||||
[sub_resource type="Environment" id="env_1"]
|
||||
background_mode = 2
|
||||
sky = SubResource("sky_1")
|
||||
ambient_light_source = 3
|
||||
ambient_light_color = Color(1, 1, 1, 1)
|
||||
ambient_light_energy = 0.5
|
||||
|
||||
[node name="ADTTilePreview" type="Node3D" unique_id=623058517]
|
||||
script = ExtResource("1_preview")
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="." unique_id=597499101]
|
||||
transform = Transform3D(1, 0, 0, 0, 0.906308, 0.422618, 0, -0.422618, 0.906308, 18.5112, 168.016, 111.689)
|
||||
current = true
|
||||
far = 20000.0
|
||||
script = ExtResource("2_flycam")
|
||||
|
||||
[node name="Sun" type="DirectionalLight3D" parent="." unique_id=697026372]
|
||||
transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0.5, -0.612372, 0.612372, 0, 0, 0)
|
||||
light_energy = 1.5
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1785163215]
|
||||
environment = SubResource("env_1")
|
||||
@@ -0,0 +1,41 @@
|
||||
[gd_scene format=3 uid="uid://f1nqi4emji47"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_stream"]
|
||||
[ext_resource type="Script" uid="uid://bauggobg40psr" path="res://src/scenes/camera/fly_camera.gd" id="2_flycam"]
|
||||
|
||||
[sub_resource type="Sky" id="Sky_1"]
|
||||
|
||||
[sub_resource type="Environment" id="Environment_1"]
|
||||
background_mode = 2
|
||||
sky = SubResource("Sky_1")
|
||||
ambient_light_source = 3
|
||||
ambient_light_color = Color(1, 1, 1, 1)
|
||||
ambient_light_energy = 0.5
|
||||
|
||||
[node name="StreamingWorld" type="Node3D" unique_id=1063159974]
|
||||
script = ExtResource("1_stream")
|
||||
camera_path = NodePath("Camera3D")
|
||||
update_interval = 0.1
|
||||
max_concurrent_tile_tasks = 4
|
||||
chunk_ops_per_tick = 64
|
||||
cached_tile_mesh_limit = 48
|
||||
use_baked_tile_cache = false
|
||||
enable_water = true
|
||||
terrain_cast_shadows = true
|
||||
m2_cast_shadows = null
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="." unique_id=502573687]
|
||||
transform = Transform3D(1, 0, 0, 0, 0.707107, 0.707107, 0, -0.707107, 0.707107, 0, 300, 300)
|
||||
current = true
|
||||
far = 50000.0
|
||||
script = ExtResource("2_flycam")
|
||||
speed = 1200.0
|
||||
fast_mult = 8.0
|
||||
|
||||
[node name="Sun" type="DirectionalLight3D" parent="." unique_id=1436804627]
|
||||
transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0.5, -0.612372, 0.612372, 0, 0, 0)
|
||||
light_energy = 1.5
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=12906896]
|
||||
environment = SubResource("Environment_1")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://yi6lawwjgocg
|
||||
@@ -0,0 +1,26 @@
|
||||
[gd_scene format=3 uid="uid://chk8oerov1xve"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://dq3t6tfy5wa0r" path="res://src/scenes/streaming/test_world_loader.gd" id="1_gyc4i"]
|
||||
[ext_resource type="Script" path="res://src/scenes/camera/fly_camera.gd" id="2_flycam"]
|
||||
|
||||
[sub_resource type="Sky" id="sky_1"]
|
||||
|
||||
[sub_resource type="Environment" id="env_1"]
|
||||
background_mode = 2
|
||||
sky = SubResource("sky_1")
|
||||
|
||||
[node name="World" type="Node3D"]
|
||||
script = ExtResource("1_gyc4i")
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 0.857, 0.515, 0, -0.515, 0.857, 0, 500, 500)
|
||||
far = 20000.0
|
||||
current = true
|
||||
script = ExtResource("2_flycam")
|
||||
|
||||
[node name="Sun" type="DirectionalLight3D" parent="."]
|
||||
transform = Transform3D(0.866, -0.353, 0.354, 0, 0.707, 0.707, -0.5, -0.612, 0.612, 0, 0, 0)
|
||||
light_energy = 1.5
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
environment = SubResource("env_1")
|
||||
@@ -0,0 +1,85 @@
|
||||
## Загрузчик Элвиннского леса — 3×3 ADT тайла из extracted/
|
||||
extends Node3D
|
||||
|
||||
const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd")
|
||||
|
||||
@export var extracted_dir : String = "res://data/extracted"
|
||||
@export var map_name : String = "Azeroth"
|
||||
|
||||
## Центр Элвиннского леса (тайл 32,48), загружаем 3×3 вокруг него
|
||||
@export var center_x : int = 32
|
||||
@export var center_y : int = 48
|
||||
@export var radius : int = 1 ## 1 = 3×3, 2 = 5×5
|
||||
|
||||
func _ready() -> void:
|
||||
if not ClassDB.class_exists("ADTLoader"):
|
||||
push_error("ADTLoader не найден — пересобери GDExtension")
|
||||
return
|
||||
|
||||
var abs_extracted := ProjectSettings.globalize_path(extracted_dir)
|
||||
print("Папка extracted: ", abs_extracted)
|
||||
|
||||
var loader = ClassDB.instantiate("ADTLoader")
|
||||
var loaded := 0
|
||||
|
||||
for dy in range(-radius, radius + 1):
|
||||
for dx in range(-radius, radius + 1):
|
||||
var tx := center_x + dx
|
||||
var ty := center_y + dy
|
||||
var path := "%s/World/Maps/%s/%s_%d_%d.adt" % [
|
||||
abs_extracted, map_name, map_name, tx, ty]
|
||||
|
||||
print("Проверяю: ", path, " → ", FileAccess.file_exists(path))
|
||||
if not FileAccess.file_exists(path):
|
||||
continue
|
||||
|
||||
var data: Dictionary = loader.call("load_adt", path)
|
||||
if data.is_empty() or not data.has("chunks"):
|
||||
push_warning("Не удалось загрузить: ", path)
|
||||
continue
|
||||
|
||||
# Debug first chunk of first tile only
|
||||
if loaded == 0:
|
||||
var chunks: Array = data.get("chunks", [])
|
||||
if chunks.size() > 0:
|
||||
var c0 = chunks[0]
|
||||
var origin0: Vector3 = c0.get("origin", Vector3.ZERO)
|
||||
var h: PackedFloat32Array = c0.get("heights", PackedFloat32Array())
|
||||
print("DEBUG chunk[0] origin=", origin0)
|
||||
if h.size() > 0:
|
||||
print("DEBUG heights[0]=", h[0], " [72]=", h[72] if h.size()>72 else 0.0)
|
||||
|
||||
var builder := ADT_BUILDER_SCRIPT.new()
|
||||
var terrain: Node3D = builder.build_scene(data, abs_extracted)
|
||||
add_child(terrain)
|
||||
loaded += 1
|
||||
print("Загружен тайл %d,%d (%d чанков)" % [tx, ty, terrain.get_child_count()])
|
||||
|
||||
print("Всего тайлов загружено: ", loaded)
|
||||
# Print terrain AABB so you know where to fly
|
||||
await get_tree().process_frame
|
||||
var aabb := AABB(); var first := true
|
||||
for child in get_children():
|
||||
if not (child is Node3D): continue
|
||||
for chunk in child.get_children():
|
||||
if chunk is MeshInstance3D and chunk.mesh:
|
||||
var b: AABB = chunk.get_aabb()
|
||||
b.position += chunk.global_position
|
||||
if first: aabb = b; first = false
|
||||
else: aabb = aabb.merge(b)
|
||||
var bad_count := 0
|
||||
for child in get_children():
|
||||
if not (child is Node3D): continue
|
||||
for chunk in child.get_children():
|
||||
if not (chunk is MeshInstance3D): continue
|
||||
var gpos: Vector3 = chunk.global_position
|
||||
var local_aabb: AABB = chunk.get_aabb()
|
||||
if abs(gpos.y) > 1000.0 or abs(local_aabb.position.y) > 1000.0 or local_aabb.size.y > 1000.0:
|
||||
if bad_count < 5:
|
||||
print("BAD chunk ", chunk.name, " gpos=", gpos, " local_aabb=", local_aabb)
|
||||
bad_count += 1
|
||||
if bad_count > 0:
|
||||
print("Total bad chunks: ", bad_count)
|
||||
if not first:
|
||||
print("Terrain AABB center=", aabb.get_center(), " size=", aabb.size)
|
||||
print("Fly to: ", aabb.get_center() + Vector3(0, aabb.size.length() * 0.3, 0))
|
||||
@@ -0,0 +1 @@
|
||||
uid://dq3t6tfy5wa0r
|
||||
Binary file not shown.
@@ -0,0 +1,161 @@
|
||||
extends SceneTree
|
||||
|
||||
const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd")
|
||||
const BAKED_TILE_SCRIPT := preload("res://src/resources/baked_adt_tile.gd")
|
||||
|
||||
var _builder
|
||||
var _loader
|
||||
var _image_cache: Dictionary = {}
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var map_name := _get_arg_value(args, "--map", "Azeroth")
|
||||
var extracted_dir := _normalize_res_path(_get_arg_value(args, "--extracted", "res://data/extracted"))
|
||||
var output_dir := _normalize_res_path(_get_arg_value(args, "--output", "res://data/cache/baked_terrain_v2"))
|
||||
var legacy_texture_size = _get_optional_int_arg(args, "--texture-size")
|
||||
var full_texture_size := maxi(
|
||||
256,
|
||||
_get_arg_value(
|
||||
args,
|
||||
"--full-texture-size",
|
||||
str(legacy_texture_size if legacy_texture_size != null else 2048)
|
||||
).to_int()
|
||||
)
|
||||
var coarse_texture_size := maxi(
|
||||
128,
|
||||
_get_arg_value(
|
||||
args,
|
||||
"--coarse-texture-size",
|
||||
str(legacy_texture_size if legacy_texture_size != null else 512)
|
||||
).to_int()
|
||||
)
|
||||
var tile_x: Variant = _get_optional_int_arg(args, "--tile-x")
|
||||
var tile_y: Variant = _get_optional_int_arg(args, "--tile-y")
|
||||
var force := args.has("--force")
|
||||
|
||||
_builder = ADT_BUILDER_SCRIPT.new()
|
||||
if not ClassDB.class_exists("ADTLoader"):
|
||||
push_error("ADTLoader not found. Rebuild GDExtension first.")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
_loader = ClassDB.instantiate("ADTLoader")
|
||||
if _loader == null:
|
||||
push_error("Failed to instantiate ADTLoader.")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var map_dir := extracted_dir.path_join("World/Maps/%s" % map_name)
|
||||
var dir := DirAccess.open(map_dir)
|
||||
if dir == null:
|
||||
push_error("Cannot open map directory: %s" % map_dir)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_dir.path_join(map_name)))
|
||||
|
||||
var baked := 0
|
||||
var skipped := 0
|
||||
var failed := 0
|
||||
var started_ms := Time.get_ticks_msec()
|
||||
|
||||
for file_name in dir.get_files():
|
||||
if not file_name.ends_with(".adt"):
|
||||
continue
|
||||
|
||||
var stem := file_name.trim_suffix(".adt")
|
||||
var parts := stem.split("_")
|
||||
if parts.size() != 3 or parts[0] != map_name:
|
||||
continue
|
||||
if not parts[1].is_valid_int() or not parts[2].is_valid_int():
|
||||
continue
|
||||
|
||||
var tx := parts[1].to_int()
|
||||
var ty := parts[2].to_int()
|
||||
if tile_x != null and tx != tile_x:
|
||||
continue
|
||||
if tile_y != null and ty != tile_y:
|
||||
continue
|
||||
var source_res_path := map_dir.path_join(file_name)
|
||||
var source_abs_path := ProjectSettings.globalize_path(source_res_path)
|
||||
var output_res_path := output_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty])
|
||||
var output_abs_path := ProjectSettings.globalize_path(output_res_path)
|
||||
|
||||
if not force and FileAccess.file_exists(output_abs_path):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
var data: Dictionary = _loader.call("load_adt", source_abs_path)
|
||||
if data.is_empty() or not data.has("chunks"):
|
||||
push_warning("Bake failed to parse ADT: %s" % source_res_path)
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
var full_payload: Dictionary = _builder.build_baked_tile_render_payload(
|
||||
data,
|
||||
_image_cache,
|
||||
ProjectSettings.globalize_path(extracted_dir),
|
||||
0,
|
||||
full_texture_size)
|
||||
var coarse_payload: Dictionary = _builder.build_baked_tile_render_payload(
|
||||
data,
|
||||
_image_cache,
|
||||
ProjectSettings.globalize_path(extracted_dir),
|
||||
3,
|
||||
coarse_texture_size)
|
||||
|
||||
if full_payload.is_empty():
|
||||
push_warning("Bake produced empty full mesh: %s" % source_res_path)
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
var baked_tile: Resource = BAKED_TILE_SCRIPT.new()
|
||||
baked_tile.set("format_version", BAKED_TILE_SCRIPT.FORMAT_VERSION)
|
||||
baked_tile.set("map_name", map_name)
|
||||
baked_tile.set("tile_x", tx)
|
||||
baked_tile.set("tile_y", ty)
|
||||
baked_tile.set("tile_origin", _builder.get_tile_origin_for_data(data))
|
||||
baked_tile.set("full_texture_size", full_texture_size)
|
||||
baked_tile.set("coarse_texture_size", coarse_texture_size)
|
||||
baked_tile.set("wmo_names", data.get("wmo_names", PackedStringArray()))
|
||||
baked_tile.set("wmo_placements", data.get("wmo_placements", []))
|
||||
baked_tile.set("m2_names", data.get("m2_names", PackedStringArray()))
|
||||
baked_tile.set("m2_placements", data.get("m2_placements", []))
|
||||
baked_tile.set("full_mesh", full_payload.get("mesh", null))
|
||||
baked_tile.set("coarse_mesh", coarse_payload.get("mesh", null))
|
||||
|
||||
var save_err := ResourceSaver.save(baked_tile, output_res_path)
|
||||
if save_err != OK:
|
||||
push_warning("Failed to save baked tile: %s (err=%d)" % [output_res_path, save_err])
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
baked += 1
|
||||
if baked % 25 == 0:
|
||||
print("Baked %d tiles..." % baked)
|
||||
|
||||
var elapsed_ms := Time.get_ticks_msec() - started_ms
|
||||
print("Bake finished. baked=%d skipped=%d failed=%d elapsed=%.2fs" % [
|
||||
baked, skipped, failed, float(elapsed_ms) / 1000.0])
|
||||
quit(0 if failed == 0 else 2)
|
||||
|
||||
|
||||
func _get_arg_value(args: PackedStringArray, name: String, default_value: String) -> String:
|
||||
var index := args.find(name)
|
||||
if index >= 0 and index + 1 < args.size():
|
||||
return args[index + 1]
|
||||
return default_value
|
||||
|
||||
|
||||
func _get_optional_int_arg(args: PackedStringArray, name: String):
|
||||
var index := args.find(name)
|
||||
if index >= 0 and index + 1 < args.size() and args[index + 1].is_valid_int():
|
||||
return args[index + 1].to_int()
|
||||
return null
|
||||
|
||||
|
||||
func _normalize_res_path(path: String) -> String:
|
||||
if path.begins_with("res://"):
|
||||
return path
|
||||
return "res://%s" % path.trim_prefix("./").trim_prefix("/")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dyamnnp08iy2e
|
||||
@@ -0,0 +1,283 @@
|
||||
## Bakes terrain, M2 doodads and WMO objects in one pass.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path <project> --script res://src/tools/bake_all.gd -- \
|
||||
## --map Azeroth --force
|
||||
##
|
||||
## Optional args:
|
||||
## --extracted res://data/extracted
|
||||
## --terrain-output res://data/cache/baked_terrain_v2
|
||||
## --m2-output res://data/cache/m2_glb
|
||||
## --wmo-output res://data/cache/wmo_tscn
|
||||
## --full-texture-size 2048
|
||||
## --coarse-texture-size 512
|
||||
## --skip-terrain --skip-m2 --skip-wmo
|
||||
extends SceneTree
|
||||
|
||||
const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd")
|
||||
const BAKED_TILE_SCRIPT := preload("res://src/resources/baked_adt_tile.gd")
|
||||
const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_builder.gd")
|
||||
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
|
||||
|
||||
var _image_cache: Dictionary = {}
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
|
||||
if not ClassDB.class_exists("ADTLoader"):
|
||||
push_error("ADTLoader not found. Rebuild GDExtension first.")
|
||||
quit(1); return
|
||||
if not ClassDB.class_exists("WMOLoader"):
|
||||
push_error("WMOLoader not found. Rebuild GDExtension first.")
|
||||
quit(1); return
|
||||
if not ClassDB.class_exists("M2Loader"):
|
||||
push_error("M2Loader not found. Rebuild GDExtension first.")
|
||||
quit(1); return
|
||||
|
||||
var map_name := _arg(args, "--map", "Azeroth")
|
||||
var extracted := _res(_arg(args, "--extracted", "res://data/extracted"))
|
||||
var terrain_out := _res(_arg(args, "--terrain-output","res://data/cache/baked_terrain_v2"))
|
||||
var m2_out := _res(_arg(args, "--m2-output", "res://data/cache/m2_glb"))
|
||||
var wmo_out := _res(_arg(args, "--wmo-output", "res://data/cache/wmo_tscn"))
|
||||
var full_tex_size := maxi(256, _arg(args, "--full-texture-size", "2048").to_int())
|
||||
var coarse_size := maxi(128, _arg(args, "--coarse-texture-size", "512").to_int())
|
||||
var force := args.has("--force")
|
||||
var skip_terrain := args.has("--skip-terrain")
|
||||
var skip_m2 := args.has("--skip-m2")
|
||||
var skip_wmo := args.has("--skip-wmo")
|
||||
|
||||
var map_dir := extracted.path_join("World/Maps/%s" % map_name)
|
||||
var dir := DirAccess.open(map_dir)
|
||||
if dir == null:
|
||||
push_error("Cannot open map directory: %s" % map_dir)
|
||||
quit(1); return
|
||||
|
||||
var total_start := Time.get_ticks_msec()
|
||||
|
||||
# ── Phase 1: Terrain ─────────────────────────────────────────────────────
|
||||
if not skip_terrain:
|
||||
print("\n=== Phase 1/3: Terrain ===")
|
||||
_bake_terrain(map_name, map_dir, extracted, terrain_out,
|
||||
full_tex_size, coarse_size, force)
|
||||
else:
|
||||
print("\n=== Phase 1/3: Terrain [SKIPPED] ===")
|
||||
|
||||
# ── Phase 2: M2 ──────────────────────────────────────────────────────────
|
||||
if not skip_m2:
|
||||
print("\n=== Phase 2/3: M2 Doodads ===")
|
||||
var unique_m2 := _collect_m2(map_name, map_dir)
|
||||
_bake_m2(unique_m2, extracted, m2_out, force)
|
||||
else:
|
||||
print("\n=== Phase 2/3: M2 Doodads [SKIPPED] ===")
|
||||
|
||||
# ── Phase 3: WMO ─────────────────────────────────────────────────────────
|
||||
if not skip_wmo:
|
||||
print("\n=== Phase 3/3: WMO Objects ===")
|
||||
var unique_wmo := _collect_wmo(map_name, map_dir)
|
||||
_bake_wmo(unique_wmo, extracted, wmo_out, force)
|
||||
else:
|
||||
print("\n=== Phase 3/3: WMO Objects [SKIPPED] ===")
|
||||
|
||||
var elapsed := float(Time.get_ticks_msec() - total_start) / 1000.0
|
||||
print("\nAll done in %.1fs." % elapsed)
|
||||
quit(0)
|
||||
|
||||
|
||||
# ── Terrain ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func _bake_terrain(map_name: String, map_dir: String, extracted: String,
|
||||
output_dir: String, full_tex: int, coarse_tex: int, force: bool) -> void:
|
||||
DirAccess.make_dir_recursive_absolute(
|
||||
ProjectSettings.globalize_path(output_dir.path_join(map_name)))
|
||||
|
||||
var builder = ADT_BUILDER_SCRIPT.new()
|
||||
var loader = ClassDB.instantiate("ADTLoader")
|
||||
var dir = DirAccess.open(map_dir)
|
||||
var baked := 0; var skipped := 0; var failed := 0
|
||||
|
||||
for fname in dir.get_files():
|
||||
if not fname.ends_with(".adt"): continue
|
||||
var parts := fname.trim_suffix(".adt").split("_")
|
||||
if parts.size() != 3 or parts[0] != map_name: continue
|
||||
if not parts[1].is_valid_int() or not parts[2].is_valid_int(): continue
|
||||
var tx := parts[1].to_int()
|
||||
var ty := parts[2].to_int()
|
||||
var out_path := output_dir.path_join(map_name).path_join(
|
||||
"%s_%d_%d.res" % [map_name, tx, ty])
|
||||
if not force and FileAccess.file_exists(ProjectSettings.globalize_path(out_path)):
|
||||
skipped += 1; continue
|
||||
|
||||
var data: Dictionary = loader.call("load_adt",
|
||||
ProjectSettings.globalize_path(map_dir.path_join(fname)))
|
||||
if data.is_empty() or not data.has("chunks"):
|
||||
failed += 1; continue
|
||||
|
||||
var full_payload := builder.build_baked_tile_render_payload(
|
||||
data, _image_cache, ProjectSettings.globalize_path(extracted), 0, full_tex)
|
||||
var coarse_payload := builder.build_baked_tile_render_payload(
|
||||
data, _image_cache, ProjectSettings.globalize_path(extracted), 3, coarse_tex)
|
||||
if full_payload.is_empty():
|
||||
failed += 1; continue
|
||||
|
||||
var tile: Resource = BAKED_TILE_SCRIPT.new()
|
||||
tile.set("format_version", BAKED_TILE_SCRIPT.FORMAT_VERSION)
|
||||
tile.set("map_name", map_name)
|
||||
tile.set("tile_x", tx)
|
||||
tile.set("tile_y", ty)
|
||||
tile.set("tile_origin", builder.get_tile_origin_for_data(data))
|
||||
tile.set("full_texture_size", full_tex)
|
||||
tile.set("coarse_texture_size", coarse_tex)
|
||||
tile.set("wmo_names", data.get("wmo_names", PackedStringArray()))
|
||||
tile.set("wmo_placements", data.get("wmo_placements", []))
|
||||
tile.set("m2_names", data.get("m2_names", PackedStringArray()))
|
||||
tile.set("m2_placements", data.get("m2_placements", []))
|
||||
tile.set("full_mesh", full_payload.get("mesh", null))
|
||||
tile.set("coarse_mesh", coarse_payload.get("mesh", null))
|
||||
|
||||
if ResourceSaver.save(tile, out_path) != OK:
|
||||
failed += 1; continue
|
||||
baked += 1
|
||||
if baked % 25 == 0:
|
||||
print(" terrain: baked=%d skipped=%d failed=%d" % [baked, skipped, failed])
|
||||
|
||||
print(" terrain done. baked=%d skipped=%d failed=%d" % [baked, skipped, failed])
|
||||
|
||||
|
||||
# ── M2 ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func _collect_m2(map_name: String, map_dir: String) -> Dictionary:
|
||||
var loader = ClassDB.instantiate("ADTLoader")
|
||||
var dir = DirAccess.open(map_dir)
|
||||
var unique: Dictionary = {}
|
||||
for fname in dir.get_files():
|
||||
if not fname.ends_with(".adt"): continue
|
||||
var parts := fname.trim_suffix(".adt").split("_")
|
||||
if parts.size() != 3 or parts[0] != map_name: continue
|
||||
var data: Dictionary = loader.call("load_adt",
|
||||
ProjectSettings.globalize_path(map_dir.path_join(fname)))
|
||||
for rel in data.get("m2_names", PackedStringArray()):
|
||||
var norm := str(rel).replace("\\", "/").to_lower()
|
||||
if norm.ends_with(".mdx") or norm.ends_with(".mdl"):
|
||||
norm = norm.get_basename() + ".m2"
|
||||
if not norm.is_empty():
|
||||
unique[norm] = true
|
||||
print(" found %d unique M2 models." % unique.size())
|
||||
return unique
|
||||
|
||||
|
||||
func _bake_m2(unique: Dictionary, extracted: String, output_dir: String, force: bool) -> void:
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_dir))
|
||||
var loader = ClassDB.instantiate("M2Loader")
|
||||
var baked := 0; var skipped := 0; var failed := 0
|
||||
var total := unique.size(); var i := 0
|
||||
|
||||
for rel_path in unique.keys():
|
||||
i += 1
|
||||
var out_path := output_dir.path_join(rel_path.get_basename() + ".tscn")
|
||||
if not force and ResourceLoader.exists(out_path):
|
||||
skipped += 1; continue
|
||||
|
||||
var abs_m2 := ProjectSettings.globalize_path(extracted.path_join(rel_path))
|
||||
if not FileAccess.file_exists(abs_m2):
|
||||
failed += 1; continue
|
||||
|
||||
var data: Dictionary = loader.call("load_m2", abs_m2)
|
||||
if data.is_empty() or data.get("vertices", PackedVector3Array()).is_empty():
|
||||
failed += 1; continue
|
||||
|
||||
var node: Node3D = M2_BUILDER_SCRIPT.build(data, extracted)
|
||||
if node == null:
|
||||
failed += 1; continue
|
||||
|
||||
var scene := PackedScene.new()
|
||||
if scene.pack(node) != OK:
|
||||
node.free(); failed += 1; continue
|
||||
node.free()
|
||||
|
||||
DirAccess.make_dir_recursive_absolute(
|
||||
ProjectSettings.globalize_path(out_path.get_base_dir()))
|
||||
if ResourceSaver.save(scene, out_path) != OK:
|
||||
failed += 1; continue
|
||||
|
||||
baked += 1
|
||||
if i % 50 == 0 or i == total:
|
||||
print(" m2 [%d/%d] baked=%d skipped=%d failed=%d" % [i, total, baked, skipped, failed])
|
||||
|
||||
print(" m2 done. baked=%d skipped=%d failed=%d" % [baked, skipped, failed])
|
||||
|
||||
|
||||
# ── WMO ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
func _collect_wmo(map_name: String, map_dir: String) -> Dictionary:
|
||||
var loader = ClassDB.instantiate("ADTLoader")
|
||||
var dir = DirAccess.open(map_dir)
|
||||
var unique: Dictionary = {}
|
||||
for fname in dir.get_files():
|
||||
if not fname.ends_with(".adt"): continue
|
||||
var parts := fname.trim_suffix(".adt").split("_")
|
||||
if parts.size() != 3 or parts[0] != map_name: continue
|
||||
var data: Dictionary = loader.call("load_adt",
|
||||
ProjectSettings.globalize_path(map_dir.path_join(fname)))
|
||||
for rel in data.get("wmo_names", PackedStringArray()):
|
||||
var norm := str(rel).replace("\\", "/").to_lower()
|
||||
if not norm.is_empty():
|
||||
unique[norm] = true
|
||||
print(" found %d unique WMO models." % unique.size())
|
||||
return unique
|
||||
|
||||
|
||||
func _bake_wmo(unique: Dictionary, extracted: String, output_dir: String, force: bool) -> void:
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_dir))
|
||||
var loader = ClassDB.instantiate("WMOLoader")
|
||||
var baked := 0; var skipped := 0; var failed := 0
|
||||
var total := unique.size(); var i := 0
|
||||
|
||||
for rel_path in unique.keys():
|
||||
i += 1
|
||||
var out_path := output_dir.path_join(rel_path.get_basename() + ".tscn")
|
||||
if not force and ResourceLoader.exists(out_path):
|
||||
skipped += 1; continue
|
||||
|
||||
var abs_wmo := ProjectSettings.globalize_path(extracted.path_join(rel_path))
|
||||
if not FileAccess.file_exists(abs_wmo):
|
||||
failed += 1; continue
|
||||
|
||||
var data: Dictionary = loader.call("load_wmo", abs_wmo)
|
||||
if data.is_empty():
|
||||
failed += 1; continue
|
||||
|
||||
var node: Node3D = WMO_BUILDER_SCRIPT.build(data, extracted)
|
||||
if node == null:
|
||||
failed += 1; continue
|
||||
|
||||
var scene := PackedScene.new()
|
||||
if scene.pack(node) != OK:
|
||||
node.free(); failed += 1; continue
|
||||
node.free()
|
||||
|
||||
DirAccess.make_dir_recursive_absolute(
|
||||
ProjectSettings.globalize_path(out_path.get_base_dir()))
|
||||
if ResourceSaver.save(scene, out_path) != OK:
|
||||
failed += 1; continue
|
||||
|
||||
baked += 1
|
||||
if i % 20 == 0 or i == total:
|
||||
print(" wmo [%d/%d] baked=%d skipped=%d failed=%d" % [i, total, baked, skipped, failed])
|
||||
|
||||
print(" wmo done. baked=%d skipped=%d failed=%d" % [baked, skipped, failed])
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func _arg(args: PackedStringArray, name: String, default: String) -> String:
|
||||
var idx := args.find(name)
|
||||
if idx >= 0 and idx + 1 < args.size():
|
||||
return args[idx + 1]
|
||||
return default
|
||||
|
||||
|
||||
func _res(path: String) -> String:
|
||||
if path.begins_with("res://") or path.begins_with("user://"):
|
||||
return path
|
||||
return "res://" + path.trim_prefix("./").trim_prefix("/")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bij5obimkjcud
|
||||
@@ -0,0 +1,128 @@
|
||||
## Pre-bakes M2 doodad models into PackedScene (.tscn) files for fast runtime loading.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path <project> --script res://src/tools/bake_m2_cache.gd -- \
|
||||
## --map Azeroth --extracted res://data/extracted --output res://data/cache/m2_glb --force
|
||||
extends SceneTree
|
||||
|
||||
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var map_name := _arg(args, "--map", "Azeroth")
|
||||
var extracted := _res(_arg(args, "--extracted", "res://data/extracted"))
|
||||
var output_dir := _res(_arg(args, "--output", "res://data/cache/m2_glb"))
|
||||
var force := args.has("--force")
|
||||
|
||||
if not ClassDB.class_exists("ADTLoader") or not ClassDB.class_exists("M2Loader"):
|
||||
push_error("GDExtension not loaded. Rebuild first.")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_dir))
|
||||
|
||||
# Collect unique M2 paths from all ADT tiles
|
||||
var unique: Dictionary = {}
|
||||
var adt_loader = ClassDB.instantiate("ADTLoader")
|
||||
var map_dir := extracted.path_join("World/Maps/%s" % map_name)
|
||||
var dir := DirAccess.open(map_dir)
|
||||
if dir == null:
|
||||
push_error("Cannot open map dir: %s" % map_dir)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("Scanning ADT tiles...")
|
||||
var scanned := 0
|
||||
for fname in dir.get_files():
|
||||
if not fname.ends_with(".adt"):
|
||||
continue
|
||||
var parts := fname.trim_suffix(".adt").split("_")
|
||||
if parts.size() != 3 or parts[0] != map_name:
|
||||
continue
|
||||
var data: Dictionary = adt_loader.call("load_adt",
|
||||
ProjectSettings.globalize_path(map_dir.path_join(fname)))
|
||||
if data.is_empty():
|
||||
continue
|
||||
for rel in data.get("m2_names", PackedStringArray()):
|
||||
var norm := str(rel).replace("\\", "/").to_lower()
|
||||
if norm.ends_with(".mdx") or norm.ends_with(".mdl"):
|
||||
norm = norm.get_basename() + ".m2"
|
||||
if not norm.is_empty():
|
||||
unique[norm] = true
|
||||
scanned += 1
|
||||
|
||||
print("Found %d unique M2 models from %d tiles." % [unique.size(), scanned])
|
||||
|
||||
var m2_loader = ClassDB.instantiate("M2Loader")
|
||||
var baked := 0
|
||||
var skipped := 0
|
||||
var failed := 0
|
||||
var total := unique.size()
|
||||
var i := 0
|
||||
|
||||
for rel_path in unique.keys():
|
||||
i += 1
|
||||
var stem: String = rel_path.get_basename()
|
||||
var out_path := output_dir.path_join(stem + ".tscn")
|
||||
|
||||
if not force and ResourceLoader.exists(out_path):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
var abs_m2 := ProjectSettings.globalize_path(extracted.path_join(rel_path))
|
||||
if not FileAccess.file_exists(abs_m2):
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
var data: Dictionary = m2_loader.call("load_m2", abs_m2)
|
||||
if data.is_empty() or data.get("vertices", PackedVector3Array()).is_empty():
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
var node: Node3D = M2_BUILDER_SCRIPT.build(data, extracted)
|
||||
if node == null:
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
_set_owner_recursive(node, node)
|
||||
var scene := PackedScene.new()
|
||||
var err := scene.pack(node)
|
||||
node.free()
|
||||
if err != OK:
|
||||
push_warning("pack failed for %s: %d" % [rel_path, err])
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
DirAccess.make_dir_recursive_absolute(
|
||||
ProjectSettings.globalize_path(out_path.get_base_dir()))
|
||||
err = ResourceSaver.save(scene, out_path)
|
||||
if err != OK:
|
||||
push_warning("save failed for %s: %d" % [out_path, err])
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
baked += 1
|
||||
if i % 50 == 0 or i == total:
|
||||
print("[%d/%d] baked=%d skipped=%d failed=%d" % [i, total, baked, skipped, failed])
|
||||
|
||||
print("Done. baked=%d skipped=%d failed=%d" % [baked, skipped, failed])
|
||||
quit(0)
|
||||
|
||||
|
||||
func _arg(args: PackedStringArray, name: String, default: String) -> String:
|
||||
var idx := args.find(name)
|
||||
if idx >= 0 and idx + 1 < args.size():
|
||||
return args[idx + 1]
|
||||
return default
|
||||
|
||||
|
||||
func _res(path: String) -> String:
|
||||
if path.begins_with("res://") or path.begins_with("user://"):
|
||||
return path
|
||||
return "res://" + path.trim_prefix("./").trim_prefix("/")
|
||||
|
||||
|
||||
func _set_owner_recursive(node: Node, owner_root: Node) -> void:
|
||||
for child in node.get_children():
|
||||
child.owner = owner_root
|
||||
_set_owner_recursive(child, owner_root)
|
||||
@@ -0,0 +1 @@
|
||||
uid://djtm87b82im5y
|
||||
@@ -0,0 +1,126 @@
|
||||
## Pre-bakes WMO world objects into PackedScene (.tscn) files for fast runtime loading.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path <project> --script res://src/tools/bake_wmo_cache.gd -- \
|
||||
## --map Azeroth --extracted res://data/extracted --output res://data/cache/wmo_tscn --force
|
||||
extends SceneTree
|
||||
|
||||
const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_builder.gd")
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var map_name := _arg(args, "--map", "Azeroth")
|
||||
var extracted := _res(_arg(args, "--extracted", "res://data/extracted"))
|
||||
var output_dir := _res(_arg(args, "--output", "res://data/cache/wmo_tscn"))
|
||||
var force := args.has("--force")
|
||||
|
||||
if not ClassDB.class_exists("ADTLoader") or not ClassDB.class_exists("WMOLoader"):
|
||||
push_error("GDExtension not loaded. Rebuild first.")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_dir))
|
||||
|
||||
# Collect unique WMO paths from all ADT tiles
|
||||
var unique: Dictionary = {}
|
||||
var adt_loader = ClassDB.instantiate("ADTLoader")
|
||||
var map_dir := extracted.path_join("World/Maps/%s" % map_name)
|
||||
var dir := DirAccess.open(map_dir)
|
||||
if dir == null:
|
||||
push_error("Cannot open map dir: %s" % map_dir)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("Scanning ADT tiles...")
|
||||
var scanned := 0
|
||||
for fname in dir.get_files():
|
||||
if not fname.ends_with(".adt"):
|
||||
continue
|
||||
var parts := fname.trim_suffix(".adt").split("_")
|
||||
if parts.size() != 3 or parts[0] != map_name:
|
||||
continue
|
||||
var data: Dictionary = adt_loader.call("load_adt",
|
||||
ProjectSettings.globalize_path(map_dir.path_join(fname)))
|
||||
if data.is_empty():
|
||||
continue
|
||||
for rel in data.get("wmo_names", PackedStringArray()):
|
||||
var norm := str(rel).replace("\\", "/").to_lower()
|
||||
if not norm.is_empty():
|
||||
unique[norm] = true
|
||||
scanned += 1
|
||||
|
||||
print("Found %d unique WMO models from %d tiles." % [unique.size(), scanned])
|
||||
|
||||
var wmo_loader = ClassDB.instantiate("WMOLoader")
|
||||
var baked := 0
|
||||
var skipped := 0
|
||||
var failed := 0
|
||||
var total := unique.size()
|
||||
var i := 0
|
||||
|
||||
for rel_path in unique.keys():
|
||||
i += 1
|
||||
var stem: String = rel_path.get_basename()
|
||||
var out_path := output_dir.path_join(stem + ".tscn")
|
||||
|
||||
if not force and ResourceLoader.exists(out_path):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
var abs_wmo := ProjectSettings.globalize_path(extracted.path_join(rel_path))
|
||||
if not FileAccess.file_exists(abs_wmo):
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
var data: Dictionary = wmo_loader.call("load_wmo", abs_wmo)
|
||||
if data.is_empty():
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
var node: Node3D = WMO_BUILDER_SCRIPT.build(data, extracted)
|
||||
if node == null:
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
_set_owner_recursive(node, node)
|
||||
var scene := PackedScene.new()
|
||||
var err := scene.pack(node)
|
||||
node.free()
|
||||
if err != OK:
|
||||
push_warning("pack failed for %s: %d" % [rel_path, err])
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
DirAccess.make_dir_recursive_absolute(
|
||||
ProjectSettings.globalize_path(out_path.get_base_dir()))
|
||||
err = ResourceSaver.save(scene, out_path)
|
||||
if err != OK:
|
||||
push_warning("save failed for %s: %d" % [out_path, err])
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
baked += 1
|
||||
if i % 20 == 0 or i == total:
|
||||
print("[%d/%d] baked=%d skipped=%d failed=%d" % [i, total, baked, skipped, failed])
|
||||
|
||||
print("Done. baked=%d skipped=%d failed=%d" % [baked, skipped, failed])
|
||||
quit(0)
|
||||
|
||||
|
||||
func _arg(args: PackedStringArray, name: String, default: String) -> String:
|
||||
var idx := args.find(name)
|
||||
if idx >= 0 and idx + 1 < args.size():
|
||||
return args[idx + 1]
|
||||
return default
|
||||
|
||||
|
||||
func _res(path: String) -> String:
|
||||
if path.begins_with("res://") or path.begins_with("user://"):
|
||||
return path
|
||||
return "res://" + path.trim_prefix("./").trim_prefix("/")
|
||||
|
||||
|
||||
func _set_owner_recursive(node: Node, owner_root: Node) -> void:
|
||||
for child in node.get_children():
|
||||
child.owner = owner_root
|
||||
_set_owner_recursive(child, owner_root)
|
||||
@@ -0,0 +1 @@
|
||||
uid://beyuk31ywdwua
|
||||
@@ -0,0 +1,94 @@
|
||||
extends SceneTree
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var map_name := _get_arg_value(args, "--map", "Azeroth")
|
||||
var extracted_dir := _normalize_res_path(_get_arg_value(args, "--extracted", "res://data/extracted"))
|
||||
var output_path := _normalize_output_path(_get_arg_value(args, "--output", "res://data/cache/m2_glb/%s_m2_list.txt" % map_name))
|
||||
|
||||
if not ClassDB.class_exists("ADTLoader"):
|
||||
push_error("ADTLoader not found. Rebuild GDExtension first.")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var loader = ClassDB.instantiate("ADTLoader")
|
||||
if loader == null:
|
||||
push_error("Failed to instantiate ADTLoader.")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var map_dir := extracted_dir.path_join("World/Maps/%s" % map_name)
|
||||
var dir := DirAccess.open(map_dir)
|
||||
if dir == null:
|
||||
push_error("Cannot open map directory: %s" % map_dir)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var unique_models := {}
|
||||
var scanned_tiles := 0
|
||||
|
||||
for file_name in dir.get_files():
|
||||
if not file_name.ends_with(".adt"):
|
||||
continue
|
||||
|
||||
var stem := file_name.trim_suffix(".adt")
|
||||
var parts := stem.split("_")
|
||||
if parts.size() != 3 or parts[0] != map_name:
|
||||
continue
|
||||
|
||||
var source_abs_path := ProjectSettings.globalize_path(map_dir.path_join(file_name))
|
||||
var data: Dictionary = loader.call("load_adt", source_abs_path)
|
||||
if data.is_empty():
|
||||
continue
|
||||
|
||||
var names: PackedStringArray = data.get("m2_names", PackedStringArray())
|
||||
for rel_path in names:
|
||||
var normalized := str(rel_path).replace("\\", "/")
|
||||
if not normalized.is_empty():
|
||||
unique_models[normalized.to_lower()] = normalized
|
||||
|
||||
scanned_tiles += 1
|
||||
if scanned_tiles % 50 == 0:
|
||||
print("Scanned %d tiles..." % scanned_tiles)
|
||||
|
||||
var models: PackedStringArray = PackedStringArray(unique_models.values())
|
||||
models.sort()
|
||||
|
||||
var output_abs_path := ProjectSettings.globalize_path(output_path)
|
||||
DirAccess.make_dir_recursive_absolute(output_abs_path.get_base_dir())
|
||||
|
||||
var file := FileAccess.open(output_path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("Failed to open output file: %s" % output_path)
|
||||
quit(2)
|
||||
return
|
||||
|
||||
for rel_path in models:
|
||||
file.store_line(rel_path)
|
||||
file.close()
|
||||
|
||||
print("Collected %d unique M2 models from %d tiles -> %s" % [
|
||||
models.size(),
|
||||
scanned_tiles,
|
||||
output_path
|
||||
])
|
||||
quit(0)
|
||||
|
||||
|
||||
func _get_arg_value(args: PackedStringArray, name: String, default_value: String) -> String:
|
||||
var index := args.find(name)
|
||||
if index >= 0 and index + 1 < args.size():
|
||||
return args[index + 1]
|
||||
return default_value
|
||||
|
||||
|
||||
func _normalize_res_path(path: String) -> String:
|
||||
if path.begins_with("res://"):
|
||||
return path
|
||||
return "res://%s" % path.trim_prefix("./").trim_prefix("/")
|
||||
|
||||
|
||||
func _normalize_output_path(path: String) -> String:
|
||||
if path.begins_with("res://") or path.begins_with("user://"):
|
||||
return path
|
||||
return "res://%s" % path.trim_prefix("./").trim_prefix("/")
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2ef25y28ysf3
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user