diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 34f851f6..b903a442 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -55,7 +55,7 @@ jobs:
libavutil-dev \
libunicorn-dev \
libx11-dev
- sudo apt-get install -y libstormlib-dev || true
+ sudo apt-get install -y libstorm-dev || true
- name: Configure
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
@@ -191,9 +191,20 @@ jobs:
mingw-w64-clang-aarch64-shaderc
git
- - name: Install optional packages
+ - name: Build StormLib from source
shell: msys2 {0}
- run: pacman -S --noconfirm --needed mingw-w64-clang-aarch64-stormlib || true
+ run: |
+ git clone --depth 1 https://github.com/ladislav-zezula/StormLib.git /tmp/StormLib
+ # Disable x86 inline asm in bundled libtomcrypt (bswapl/movl) —
+ # __MINGW32__ is defined on CLANGARM64 which incorrectly enables x86 asm
+ cmake -S /tmp/StormLib -B /tmp/StormLib/build -G Ninja \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_INSTALL_PREFIX="$MINGW_PREFIX" \
+ -DBUILD_SHARED_LIBS=OFF \
+ -DCMAKE_C_FLAGS="-DLTC_NO_BSWAP" \
+ -DCMAKE_CXX_FLAGS="-DLTC_NO_BSWAP"
+ cmake --build /tmp/StormLib/build --parallel $(nproc)
+ cmake --install /tmp/StormLib/build
- name: Configure
shell: msys2 {0}
@@ -254,9 +265,16 @@ jobs:
mingw-w64-x86_64-nsis
git
- - name: Install optional packages
+ - name: Build StormLib from source
shell: msys2 {0}
- run: pacman -S --noconfirm --needed mingw-w64-x86_64-stormlib || true
+ run: |
+ git clone --depth 1 https://github.com/ladislav-zezula/StormLib.git /tmp/StormLib
+ cmake -S /tmp/StormLib -B /tmp/StormLib/build -G Ninja \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_INSTALL_PREFIX="$MINGW_PREFIX" \
+ -DBUILD_SHARED_LIBS=OFF
+ cmake --build /tmp/StormLib/build --parallel $(nproc)
+ cmake --install /tmp/StormLib/build
- name: Configure
shell: msys2 {0}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..0357408b
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,363 @@
+name: Release
+
+on:
+ push:
+ tags: ['v*']
+
+permissions:
+ contents: write
+
+jobs:
+ build-linux:
+ name: Build (linux-${{ matrix.arch }})
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x86-64
+ runner: ubuntu-24.04
+ - arch: arm64
+ runner: ubuntu-24.04-arm
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: true
+
+ - name: Cache apt packages
+ uses: actions/cache@v4
+ with:
+ path: /var/cache/apt/archives/*.deb
+ key: apt-${{ matrix.arch }}-${{ hashFiles('.github/workflows/release.yml') }}
+ restore-keys: apt-${{ matrix.arch }}-
+
+ - name: Install dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ cmake \
+ build-essential \
+ pkg-config \
+ libsdl2-dev \
+ libglew-dev \
+ libglm-dev \
+ libssl-dev \
+ zlib1g-dev \
+ libvulkan-dev \
+ vulkan-tools \
+ glslc \
+ libavformat-dev \
+ libavcodec-dev \
+ libswscale-dev \
+ libavutil-dev \
+ libunicorn-dev \
+ libx11-dev
+ sudo apt-get install -y libstorm-dev || true
+
+ - name: Configure
+ run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
+
+ - name: Build
+ run: cmake --build build --parallel $(nproc)
+
+ - name: Package
+ run: |
+ TAG="${GITHUB_REF_NAME}"
+ STAGING="wowee-${TAG}-linux-${{ matrix.arch }}"
+ mkdir -p "${STAGING}"
+
+ # Binary
+ cp build/bin/wowee "${STAGING}/"
+
+ # Asset extraction tool (if built — requires StormLib)
+ if [ -f build/bin/asset_extract ]; then
+ cp build/bin/asset_extract "${STAGING}/"
+ fi
+
+ # Extraction scripts and GUI
+ cp extract_assets.sh "${STAGING}/"
+ cp tools/asset_pipeline_gui.py "${STAGING}/"
+
+ # Assets (exclude proprietary music)
+ rsync -a --exclude='Original Music' build/bin/assets/ "${STAGING}/assets/"
+
+ # Data directory (git-tracked files only)
+ git ls-files Data/ | while read -r f; do
+ mkdir -p "${STAGING}/$(dirname "$f")"
+ cp "$f" "${STAGING}/$f"
+ done
+
+ tar czf "${STAGING}.tar.gz" "${STAGING}"
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: wowee-linux-${{ matrix.arch }}
+ path: wowee-*.tar.gz
+ if-no-files-found: error
+
+ build-macos:
+ name: Build (macOS arm64)
+ runs-on: macos-15
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: true
+
+ - name: Install dependencies
+ run: |
+ brew install cmake pkg-config sdl2 glew glm openssl@3 zlib ffmpeg unicorn \
+ stormlib vulkan-loader vulkan-headers shaderc dylibbundler || true
+ brew install dylibbundler 2>/dev/null || true
+
+ - name: Configure
+ run: |
+ BREW=$(brew --prefix)
+ export PKG_CONFIG_PATH="$BREW/lib/pkgconfig:$(brew --prefix ffmpeg)/lib/pkgconfig:$(brew --prefix openssl@3)/lib/pkgconfig:$(brew --prefix vulkan-loader)/lib/pkgconfig:$(brew --prefix shaderc)/lib/pkgconfig"
+ cmake -S . -B build \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_PREFIX_PATH="$BREW" \
+ -DOPENSSL_ROOT_DIR="$(brew --prefix openssl@3)"
+
+ - name: Build
+ run: cmake --build build --parallel $(sysctl -n hw.logicalcpu)
+
+ - name: Create .app bundle
+ run: |
+ TAG="${GITHUB_REF_NAME}"
+ mkdir -p Wowee.app/Contents/{MacOS,Frameworks,Resources}
+
+ # Wrapper launch script
+ printf '#!/bin/bash\ncd "$(dirname "$0")"\nexec ./wowee_bin "$@"\n' \
+ > Wowee.app/Contents/MacOS/wowee
+ chmod +x Wowee.app/Contents/MacOS/wowee
+
+ # Actual binary
+ cp build/bin/wowee Wowee.app/Contents/MacOS/wowee_bin
+
+ # Asset extraction tool (if built — requires StormLib)
+ if [ -f build/bin/asset_extract ]; then
+ cp build/bin/asset_extract Wowee.app/Contents/MacOS/
+ fi
+
+ # Extraction scripts and GUI
+ cp extract_assets.sh Wowee.app/Contents/MacOS/
+ cp tools/asset_pipeline_gui.py Wowee.app/Contents/MacOS/
+
+ # Assets (exclude proprietary music)
+ rsync -a --exclude='Original Music' build/bin/assets/ \
+ Wowee.app/Contents/MacOS/assets/
+
+ # Data directory (git-tracked files only)
+ git ls-files Data/ | while read -r f; do
+ mkdir -p "Wowee.app/Contents/MacOS/$(dirname "$f")"
+ cp "$f" "Wowee.app/Contents/MacOS/$f"
+ done
+
+ # Bundle dylibs
+ if command -v dylibbundler &>/dev/null; then
+ dylibbundler -od -b \
+ -x Wowee.app/Contents/MacOS/wowee_bin \
+ -d Wowee.app/Contents/Frameworks/ \
+ -p @executable_path/../Frameworks/
+ if [ -f Wowee.app/Contents/MacOS/asset_extract ]; then
+ dylibbundler -od -b \
+ -x Wowee.app/Contents/MacOS/asset_extract \
+ -d Wowee.app/Contents/Frameworks/ \
+ -p @executable_path/../Frameworks/
+ fi
+ fi
+
+ # Info.plist
+ cat > Wowee.app/Contents/Info.plist << EOF
+
+
+
+ CFBundleExecutablewowee
+ CFBundleIdentifiercom.wowee.app
+ CFBundleNameWowee
+ CFBundleVersion${TAG}
+ CFBundleShortVersionString${TAG}
+ CFBundlePackageTypeAPPL
+
+ EOF
+
+ # Ad-hoc codesign
+ codesign --force --deep --sign - Wowee.app
+
+ - name: Create DMG
+ run: |
+ TAG="${GITHUB_REF_NAME}"
+ hdiutil create -volname Wowee -srcfolder Wowee.app -ov -format UDZO \
+ "wowee-${TAG}-macos-arm64.dmg"
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: wowee-macos-arm64
+ path: wowee-*.dmg
+ if-no-files-found: error
+
+ build-windows:
+ name: Build (windows-${{ matrix.arch }})
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x86-64
+ runner: windows-latest
+ msystem: MINGW64
+ prefix: mingw-w64-x86_64
+ - arch: arm64
+ runner: windows-11-arm
+ msystem: CLANGARM64
+ prefix: mingw-w64-clang-aarch64
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: true
+
+ - name: Set up MSYS2
+ uses: msys2/setup-msys2@v2
+ with:
+ msystem: ${{ matrix.msystem }}
+ update: ${{ matrix.arch == 'arm64' }}
+ install: >-
+ ${{ matrix.prefix }}-cmake
+ ${{ matrix.arch == 'x86-64' && format('{0}-gcc', matrix.prefix) || format('{0}-clang', matrix.prefix) }}
+ ${{ matrix.prefix }}-ninja
+ ${{ matrix.prefix }}-pkgconf
+ ${{ matrix.prefix }}-SDL2
+ ${{ matrix.prefix }}-glew
+ ${{ matrix.prefix }}-glm
+ ${{ matrix.prefix }}-openssl
+ ${{ matrix.prefix }}-zlib
+ ${{ matrix.prefix }}-ffmpeg
+ ${{ matrix.prefix }}-unicorn
+ ${{ matrix.prefix }}-vulkan-loader
+ ${{ matrix.prefix }}-vulkan-headers
+ ${{ matrix.prefix }}-shaderc
+ git
+
+ - name: Install optional packages
+ shell: msys2 {0}
+ run: |
+ pacman -S --noconfirm --needed zip
+
+ - name: Build StormLib from source
+ shell: msys2 {0}
+ run: |
+ git clone --depth 1 https://github.com/ladislav-zezula/StormLib.git /tmp/StormLib
+ # Disable x86 inline asm in bundled libtomcrypt (bswapl/movl) —
+ # __MINGW32__ is defined on CLANGARM64 which incorrectly enables x86 asm
+ cmake -S /tmp/StormLib -B /tmp/StormLib/build -G Ninja \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_INSTALL_PREFIX="$MINGW_PREFIX" \
+ -DBUILD_SHARED_LIBS=OFF \
+ -DCMAKE_C_FLAGS="-DLTC_NO_BSWAP" \
+ -DCMAKE_CXX_FLAGS="-DLTC_NO_BSWAP"
+ cmake --build /tmp/StormLib/build --parallel $(nproc)
+ cmake --install /tmp/StormLib/build
+
+ - name: Configure
+ shell: msys2 {0}
+ run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
+
+ - name: Build
+ shell: msys2 {0}
+ run: cmake --build build --parallel $(nproc)
+
+ - name: Bundle DLLs
+ shell: msys2 {0}
+ run: |
+ for exe in build/bin/wowee.exe build/bin/asset_extract.exe; do
+ [ -f "$exe" ] || continue
+ ldd "$exe" \
+ | awk '/=> \// { print $3 }' \
+ | grep -iv '^/c/Windows' \
+ | xargs -I{} sh -c 'cp -n "{}" build/bin/ 2>/dev/null || true'
+ done
+
+ - name: Package
+ shell: msys2 {0}
+ run: |
+ TAG="${GITHUB_REF_NAME}"
+ STAGING="wowee-${TAG}-windows-${{ matrix.arch }}"
+ mkdir -p "${STAGING}"
+
+ # Binary and DLLs
+ cp build/bin/wowee.exe "${STAGING}/"
+ cp build/bin/*.dll "${STAGING}/" 2>/dev/null || true
+
+ # Asset extraction tool (if built — requires StormLib)
+ if [ -f build/bin/asset_extract.exe ]; then
+ cp build/bin/asset_extract.exe "${STAGING}/"
+ fi
+
+ # Extraction scripts and GUI
+ cp extract_assets.ps1 "${STAGING}/"
+ cp extract_assets.bat "${STAGING}/"
+ cp tools/asset_pipeline_gui.py "${STAGING}/"
+
+ # Assets (exclude proprietary music)
+ mkdir -p "${STAGING}/assets"
+ for d in build/bin/assets/*/; do
+ dirname="$(basename "$d")"
+ [ "$dirname" = "Original Music" ] && continue
+ cp -r "$d" "${STAGING}/assets/"
+ done
+ # Copy top-level asset files
+ cp build/bin/assets/* "${STAGING}/assets/" 2>/dev/null || true
+
+ # Data directory (git-tracked files only)
+ git ls-files Data/ | while read -r f; do
+ mkdir -p "${STAGING}/$(dirname "$f")"
+ cp "$f" "${STAGING}/$f"
+ done
+
+ # Create ZIP
+ zip -r "${STAGING}.zip" "${STAGING}"
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: wowee-windows-${{ matrix.arch }}
+ path: wowee-*.zip
+ if-no-files-found: error
+
+ release:
+ name: Create Release
+ needs: [build-linux, build-macos, build-windows]
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: artifacts/
+
+ - name: Create GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ run: |
+ TAG="${GITHUB_REF_NAME}"
+
+ # Collect all release files
+ FILES=()
+ for f in artifacts/*/*; do
+ FILES+=("$f")
+ done
+
+ gh release create "${TAG}" \
+ --title "Wowee ${TAG}" \
+ --generate-notes \
+ "${FILES[@]}"
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 91296a97..4709225b 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -45,7 +45,7 @@ jobs:
libavutil-dev \
libunicorn-dev \
libx11-dev
- sudo apt-get install -y libstormlib-dev || true
+ sudo apt-get install -y libstorm-dev || true
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
@@ -117,7 +117,7 @@ jobs:
libavutil-dev \
libunicorn-dev \
libx11-dev
- sudo apt-get install -y libstormlib-dev || true
+ sudo apt-get install -y libstorm-dev || true
- name: Configure (ASan/UBSan)
run: |
diff --git a/README.md b/README.md
index 6a91237c..9b59a5d1 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,8 @@ A native C++ World of Warcraft client with a custom Vulkan renderer.
[](https://github.com/sponsors/Kelsidavis)
[](https://discord.gg/SDqjA79B)
+[](https://youtu.be/B-jtpPmiXGM)
+
[](https://youtu.be/Pd9JuYYxu0o)
[](https://youtu.be/J4NXegzqWSQ)
diff --git a/extract_assets.ps1 b/extract_assets.ps1
index 9bcce87e..68e810b4 100644
--- a/extract_assets.ps1
+++ b/extract_assets.ps1
@@ -29,9 +29,16 @@ $ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$BuildDir = Join-Path $ScriptDir "build"
-$Binary = Join-Path $BuildDir "bin\asset_extract.exe"
$OutputDir = Join-Path $ScriptDir "Data"
+# Prefer pre-built binary next to this script (release archives), then build dir
+$BinaryLocal = Join-Path $ScriptDir "asset_extract.exe"
+if (Test-Path $BinaryLocal) {
+ $Binary = $BinaryLocal
+} else {
+ $Binary = Join-Path $BuildDir "bin\asset_extract.exe"
+}
+
# --- Validate arguments ---
if (-not (Test-Path $MpqDir -PathType Container)) {
Write-Error "Error: Directory not found: $MpqDir"
diff --git a/extract_assets.sh b/extract_assets.sh
index 532ee696..19916ed9 100755
--- a/extract_assets.sh
+++ b/extract_assets.sh
@@ -17,9 +17,15 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="${SCRIPT_DIR}/build"
-BINARY="${BUILD_DIR}/bin/asset_extract"
OUTPUT_DIR="${SCRIPT_DIR}/Data"
+# Prefer pre-built binary next to this script (release archives), then build dir
+if [ -x "${SCRIPT_DIR}/asset_extract" ]; then
+ BINARY="${SCRIPT_DIR}/asset_extract"
+else
+ BINARY="${BUILD_DIR}/bin/asset_extract"
+fi
+
# --- Validate arguments ---
if [ $# -lt 1 ]; then
echo "Usage: $0 /path/to/WoW/Data [classic|turtle|tbc|wotlk]"
@@ -73,7 +79,7 @@ if [ ! -f "$BINARY" ]; then
fi
if [ "$STORMLIB_FOUND" = false ]; then
echo "Error: StormLib not found."
- echo " Ubuntu/Debian: sudo apt install libstormlib-dev"
+ echo " Ubuntu/Debian: sudo apt install libstorm-dev"
echo " macOS: brew install stormlib"
echo " From source: https://github.com/ladislav-zezula/StormLib"
exit 1
diff --git a/include/rendering/loading_screen.hpp b/include/rendering/loading_screen.hpp
index 52f0ea08..5f119676 100644
--- a/include/rendering/loading_screen.hpp
+++ b/include/rendering/loading_screen.hpp
@@ -4,6 +4,8 @@
#include
#include
+struct SDL_Window;
+
namespace wowee {
namespace rendering {
@@ -27,11 +29,13 @@ public:
// Must be set before initialize() for Vulkan texture upload
void setVkContext(VkContext* ctx) { vkCtx = ctx; }
+ void setSDLWindow(SDL_Window* win) { sdlWindow = win; }
private:
bool loadImage(const std::string& path);
VkContext* vkCtx = nullptr;
+ SDL_Window* sdlWindow = nullptr;
// Vulkan texture for background image
VkImage bgImage = VK_NULL_HANDLE;
diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp
index 7d4d21b7..da99ed53 100644
--- a/include/rendering/terrain_manager.hpp
+++ b/include/rendering/terrain_manager.hpp
@@ -123,6 +123,41 @@ struct PendingTile {
std::unordered_map preloadedTextures;
};
+/**
+ * Phases for incremental tile finalization (one bounded unit of work per call)
+ */
+enum class FinalizationPhase {
+ TERRAIN, // Upload terrain mesh + textures + water
+ M2_MODELS, // Upload ONE M2 model per call
+ M2_INSTANCES, // Create all M2 instances (lightweight struct allocation)
+ WMO_MODELS, // Upload ONE WMO model per call
+ WMO_INSTANCES, // Create all WMO instances + load WMO liquids
+ WMO_DOODADS, // Upload ONE WMO doodad M2 per call
+ WATER, // Generate water ambient emitters
+ AMBIENT, // Register ambient emitters + commit tile
+ DONE // Fully finalized
+};
+
+/**
+ * In-progress tile finalization state — tracks progress across frames
+ */
+struct FinalizingTile {
+ std::shared_ptr pending;
+ FinalizationPhase phase = FinalizationPhase::TERRAIN;
+
+ // Progress indices within current phase
+ size_t m2ModelIndex = 0; // Next M2 model to upload
+ size_t wmoModelIndex = 0; // Next WMO model to upload
+ size_t wmoDoodadIndex = 0; // Next WMO doodad to upload
+
+ // Accumulated results (built up across phases)
+ std::vector m2InstanceIds;
+ std::vector wmoInstanceIds;
+ std::vector tileUniqueIds;
+ std::vector tileWmoUniqueIds;
+ std::unordered_set uploadedM2ModelIds;
+};
+
/**
* Terrain manager for multi-tile terrain streaming
*
@@ -219,8 +254,8 @@ public:
int getLoadedTileCount() const { return static_cast(loadedTiles.size()); }
int getPendingTileCount() const { return static_cast(pendingTiles.size()); }
int getReadyQueueCount() const { return static_cast(readyQueue.size()); }
- /** Total unfinished tiles (worker threads + ready queue) */
- int getRemainingTileCount() const { return static_cast(pendingTiles.size() + readyQueue.size()); }
+ /** Total unfinished tiles (worker threads + ready queue + finalizing) */
+ int getRemainingTileCount() const { return static_cast(pendingTiles.size() + readyQueue.size() + finalizingTiles_.size()); }
TileCoord getCurrentTile() const { return currentTile; }
/** Process all ready tiles immediately (use during loading screens) */
@@ -254,9 +289,10 @@ private:
std::shared_ptr prepareTile(int x, int y);
/**
- * Main thread: upload prepared tile data to GPU
+ * Advance incremental finalization of a tile (one bounded unit of work).
+ * Returns true when the tile is fully finalized (phase == DONE).
*/
- void finalizeTile(const std::shared_ptr& pending);
+ bool advanceFinalization(FinalizingTile& ft);
/**
* Background worker thread loop
@@ -341,16 +377,8 @@ private:
// Dedup set for WMO placements across tile boundaries (prevents rendering Stormwind 16x)
std::unordered_set placedWmoIds;
- // Progressive M2 upload queue (spread heavy uploads across frames)
- struct PendingM2Upload {
- uint32_t modelId;
- pipeline::M2Model model;
- std::string path;
- };
- std::queue m2UploadQueue_;
- static constexpr int MAX_M2_UPLOADS_PER_FRAME = 5; // Upload up to 5 models per frame
-
- void processM2UploadQueue();
+ // Tiles currently being incrementally finalized across frames
+ std::deque finalizingTiles_;
struct GroundEffectEntry {
std::array doodadIds{{0, 0, 0, 0}};
diff --git a/include/rendering/water_renderer.hpp b/include/rendering/water_renderer.hpp
index af255ca5..cf04cbc5 100644
--- a/include/rendering/water_renderer.hpp
+++ b/include/rendering/water_renderer.hpp
@@ -160,7 +160,7 @@ private:
VkDescriptorSetLayout sceneSetLayout = VK_NULL_HANDLE;
VkDescriptorPool sceneDescPool = VK_NULL_HANDLE;
VkDescriptorSet sceneSet = VK_NULL_HANDLE;
- static constexpr uint32_t MAX_WATER_SETS = 2048;
+ static constexpr uint32_t MAX_WATER_SETS = 16384;
VkSampler sceneColorSampler = VK_NULL_HANDLE;
VkSampler sceneDepthSampler = VK_NULL_HANDLE;
diff --git a/src/core/application.cpp b/src/core/application.cpp
index e5ca6e54..9e34d7aa 100644
--- a/src/core/application.cpp
+++ b/src/core/application.cpp
@@ -55,6 +55,21 @@
#include
#include
+#include
+#ifdef __linux__
+#include
+#include
+#elif defined(_WIN32)
+#ifndef WIN32_LEAN_AND_MEAN
+#define WIN32_LEAN_AND_MEAN
+#endif
+#include
+#elif defined(__APPLE__)
+#include
+#include
+#include
+#endif
+
namespace wowee {
namespace core {
@@ -230,6 +245,47 @@ bool Application::initialize() {
void Application::run() {
LOG_INFO("Starting main loop");
+
+ // Pin main thread to a dedicated CPU core to reduce scheduling jitter
+ {
+ int numCores = static_cast(std::thread::hardware_concurrency());
+ if (numCores >= 2) {
+#ifdef __linux__
+ cpu_set_t cpuset;
+ CPU_ZERO(&cpuset);
+ CPU_SET(0, &cpuset);
+ int rc = pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);
+ if (rc == 0) {
+ LOG_INFO("Main thread pinned to CPU core 0 (", numCores, " cores available)");
+ } else {
+ LOG_WARNING("Failed to pin main thread to CPU core 0 (error ", rc, ")");
+ }
+#elif defined(_WIN32)
+ DWORD_PTR mask = 1; // Core 0
+ DWORD_PTR prev = SetThreadAffinityMask(GetCurrentThread(), mask);
+ if (prev != 0) {
+ LOG_INFO("Main thread pinned to CPU core 0 (", numCores, " cores available)");
+ } else {
+ LOG_WARNING("Failed to pin main thread to CPU core 0 (error ", GetLastError(), ")");
+ }
+#elif defined(__APPLE__)
+ // macOS doesn't support hard pinning — use affinity tags to hint
+ // that the main thread should stay on its own core group
+ thread_affinity_policy_data_t policy = { 1 }; // tag 1 = main thread group
+ kern_return_t kr = thread_policy_set(
+ pthread_mach_thread_np(pthread_self()),
+ THREAD_AFFINITY_POLICY,
+ reinterpret_cast(&policy),
+ THREAD_AFFINITY_POLICY_COUNT);
+ if (kr == KERN_SUCCESS) {
+ LOG_INFO("Main thread affinity tag set (", numCores, " cores available)");
+ } else {
+ LOG_WARNING("Failed to set main thread affinity tag (error ", kr, ")");
+ }
+#endif
+ }
+ }
+
const bool frameProfileEnabled = envFlagEnabled("WOWEE_FRAME_PROFILE", false);
if (frameProfileEnabled) {
LOG_INFO("Frame timing profile enabled (WOWEE_FRAME_PROFILE=1)");
@@ -3009,6 +3065,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
// --- Loading screen for online mode ---
rendering::LoadingScreen loadingScreen;
loadingScreen.setVkContext(window->getVkContext());
+ loadingScreen.setSDLWindow(window->getSDLWindow());
bool loadingScreenOk = loadingScreen.initialize();
auto showProgress = [&](const char* msg, float progress) {
diff --git a/src/game/warden_module.cpp b/src/game/warden_module.cpp
index bad36430..68a2fc9c 100644
--- a/src/game/warden_module.cpp
+++ b/src/game/warden_module.cpp
@@ -529,34 +529,23 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) {
std::cout << "[WardenModule] Allocated " << moduleSize_ << " bytes of executable memory at "
<< moduleMemory_ << '\n';
- // Parse skip/copy pairs
- // Format: repeated [2B skip_count][2B copy_count][copy_count bytes data]
- // Skip = advance dest pointer (zeros), Copy = copy from source to dest
- // Terminates when skip_count == 0
+ // Parse copy/skip pairs (MaNGOS/TrinityCore format)
+ // Format: repeated [2B copy_count][copy_count bytes data][2B skip_count]
+ // Copy = copy from source to dest, Skip = advance dest pointer (zeros)
+ // Terminates when copy_count == 0
size_t pos = 4; // Skip 4-byte size header
size_t destOffset = 0;
int pairCount = 0;
while (pos + 2 <= exeData.size()) {
- // Read skip count (2 bytes LE)
- uint16_t skipCount = exeData[pos] | (exeData[pos + 1] << 8);
- pos += 2;
-
- if (skipCount == 0) {
- break; // End of skip/copy pairs
- }
-
- // Advance dest pointer by skipCount (gaps are zero-filled from memset)
- destOffset += skipCount;
-
// Read copy count (2 bytes LE)
- if (pos + 2 > exeData.size()) {
- std::cerr << "[WardenModule] Unexpected end of data reading copy count" << '\n';
- break;
- }
uint16_t copyCount = exeData[pos] | (exeData[pos + 1] << 8);
pos += 2;
+ if (copyCount == 0) {
+ break; // End of copy/skip pairs
+ }
+
if (copyCount > 0) {
if (pos + copyCount > exeData.size()) {
std::cerr << "[WardenModule] Copy section extends beyond data bounds" << '\n';
@@ -589,9 +578,19 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) {
destOffset += copyCount;
}
+ // Read skip count (2 bytes LE)
+ uint16_t skipCount = 0;
+ if (pos + 2 <= exeData.size()) {
+ skipCount = exeData[pos] | (exeData[pos + 1] << 8);
+ pos += 2;
+ }
+
+ // Advance dest pointer by skipCount (gaps are zero-filled from memset)
+ destOffset += skipCount;
+
pairCount++;
- std::cout << "[WardenModule] Pair " << pairCount << ": skip " << skipCount
- << ", copy " << copyCount << " (dest offset=" << destOffset << ")" << '\n';
+ std::cout << "[WardenModule] Pair " << pairCount << ": copy " << copyCount
+ << ", skip " << skipCount << " (dest offset=" << destOffset << ")" << '\n';
}
// Save position — remaining decompressed data contains relocation entries
diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp
index bacb3aa5..f7ffbdbb 100644
--- a/src/pipeline/asset_manager.cpp
+++ b/src/pipeline/asset_manager.cpp
@@ -294,10 +294,36 @@ std::shared_ptr AssetManager::loadDBC(const std::string& name) {
if (dbcData.empty()) {
std::string dbcPath = "DBFilesClient\\" + name;
dbcData = readFile(dbcPath);
- if (dbcData.empty()) {
- LOG_WARNING("DBC not found: ", name);
- return nullptr;
+ }
+
+ // If binary DBC not found and we skipped CSV earlier (forceBinaryForVisualDbc),
+ // try CSV as a last resort — better than no data at all (e.g. Classic expansion
+ // where binary DBCs come from MPQ extraction the user may not have done).
+ if (dbcData.empty() && forceBinaryForVisualDbc && !expansionDataPath_.empty()) {
+ std::string baseName = name;
+ auto dot = baseName.rfind('.');
+ if (dot != std::string::npos) {
+ baseName = baseName.substr(0, dot);
}
+ std::string csvPath = expansionDataPath_ + "/db/" + baseName + ".csv";
+ if (std::filesystem::exists(csvPath)) {
+ std::ifstream f(csvPath, std::ios::binary | std::ios::ate);
+ if (f) {
+ auto size = f.tellg();
+ if (size > 0) {
+ f.seekg(0);
+ dbcData.resize(static_cast(size));
+ f.read(reinterpret_cast(dbcData.data()), size);
+ LOG_INFO("Binary DBC not found, using CSV fallback: ", csvPath);
+ loadedFromCSV = true;
+ }
+ }
+ }
+ }
+
+ if (dbcData.empty()) {
+ LOG_WARNING("DBC not found: ", name);
+ return nullptr;
}
auto dbc = std::make_shared();
diff --git a/src/rendering/loading_screen.cpp b/src/rendering/loading_screen.cpp
index 3511d8e3..34ad1aa6 100644
--- a/src/rendering/loading_screen.cpp
+++ b/src/rendering/loading_screen.cpp
@@ -5,6 +5,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -327,6 +328,15 @@ void LoadingScreen::render() {
// Submit the frame to Vulkan (loading screen runs outside the main render loop)
if (vkCtx) {
+ // Handle window resize: recreate swapchain before acquiring an image
+ if (vkCtx->isSwapchainDirty() && sdlWindow) {
+ int w = 0, h = 0;
+ SDL_GetWindowSize(sdlWindow, &w, &h);
+ if (w > 0 && h > 0) {
+ vkCtx->recreateSwapchain(w, h);
+ }
+ }
+
uint32_t imageIndex = 0;
VkCommandBuffer cmd = vkCtx->beginFrame(imageIndex);
if (cmd != VK_NULL_HANDLE) {
diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp
index 4b5c0b7c..5caeab9d 100644
--- a/src/rendering/terrain_manager.cpp
+++ b/src/rendering/terrain_manager.cpp
@@ -22,6 +22,20 @@
#include
#include
+#ifdef __linux__
+#include
+#include
+#elif defined(_WIN32)
+#ifndef WIN32_LEAN_AND_MEAN
+#define WIN32_LEAN_AND_MEAN
+#endif
+#include
+#elif defined(__APPLE__)
+#include
+#include
+#include
+#endif
+
namespace wowee {
namespace rendering {
@@ -226,7 +240,9 @@ bool TerrainManager::loadTile(int x, int y) {
return false;
}
- finalizeTile(pending);
+ FinalizingTile ft;
+ ft.pending = std::move(pending);
+ while (!advanceFinalization(ft)) {}
return true;
}
@@ -269,9 +285,11 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) {
return nullptr;
}
- // Parse ADT
- pipeline::ADTTerrain terrain = pipeline::ADTLoader::load(adtData);
- if (!terrain.isLoaded()) {
+ // Parse ADT — allocate on heap to avoid stack overflow on macOS
+ // (ADTTerrain contains std::array ≈ 280 KB; macOS worker
+ // threads default to 512 KB stack, so two on-stack copies would overflow)
+ auto terrainPtr = std::make_unique(pipeline::ADTLoader::load(adtData));
+ if (!terrainPtr->isLoaded()) {
LOG_ERROR("Failed to parse ADT terrain: ", adtPath);
return nullptr;
}
@@ -282,47 +300,47 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) {
std::to_string(coord.x) + "_" + std::to_string(coord.y) + "_obj0.adt";
auto objData = assetManager->readFile(objPath);
if (!objData.empty()) {
- pipeline::ADTTerrain objTerrain = pipeline::ADTLoader::load(objData);
- if (objTerrain.isLoaded()) {
- const uint32_t doodadNameBase = static_cast(terrain.doodadNames.size());
- const uint32_t wmoNameBase = static_cast(terrain.wmoNames.size());
+ auto objTerrain = std::make_unique(pipeline::ADTLoader::load(objData));
+ if (objTerrain->isLoaded()) {
+ const uint32_t doodadNameBase = static_cast(terrainPtr->doodadNames.size());
+ const uint32_t wmoNameBase = static_cast(terrainPtr->wmoNames.size());
- terrain.doodadNames.insert(terrain.doodadNames.end(),
- objTerrain.doodadNames.begin(), objTerrain.doodadNames.end());
- terrain.wmoNames.insert(terrain.wmoNames.end(),
- objTerrain.wmoNames.begin(), objTerrain.wmoNames.end());
+ terrainPtr->doodadNames.insert(terrainPtr->doodadNames.end(),
+ objTerrain->doodadNames.begin(), objTerrain->doodadNames.end());
+ terrainPtr->wmoNames.insert(terrainPtr->wmoNames.end(),
+ objTerrain->wmoNames.begin(), objTerrain->wmoNames.end());
std::unordered_set existingDoodadUniqueIds;
- existingDoodadUniqueIds.reserve(terrain.doodadPlacements.size());
- for (const auto& p : terrain.doodadPlacements) {
+ existingDoodadUniqueIds.reserve(terrainPtr->doodadPlacements.size());
+ for (const auto& p : terrainPtr->doodadPlacements) {
if (p.uniqueId != 0) existingDoodadUniqueIds.insert(p.uniqueId);
}
size_t mergedDoodads = 0;
- for (auto placement : objTerrain.doodadPlacements) {
- if (placement.nameId >= objTerrain.doodadNames.size()) continue;
+ for (auto placement : objTerrain->doodadPlacements) {
+ if (placement.nameId >= objTerrain->doodadNames.size()) continue;
placement.nameId += doodadNameBase;
if (placement.uniqueId != 0 && !existingDoodadUniqueIds.insert(placement.uniqueId).second) {
continue;
}
- terrain.doodadPlacements.push_back(placement);
+ terrainPtr->doodadPlacements.push_back(placement);
mergedDoodads++;
}
std::unordered_set existingWmoUniqueIds;
- existingWmoUniqueIds.reserve(terrain.wmoPlacements.size());
- for (const auto& p : terrain.wmoPlacements) {
+ existingWmoUniqueIds.reserve(terrainPtr->wmoPlacements.size());
+ for (const auto& p : terrainPtr->wmoPlacements) {
if (p.uniqueId != 0) existingWmoUniqueIds.insert(p.uniqueId);
}
size_t mergedWmos = 0;
- for (auto placement : objTerrain.wmoPlacements) {
- if (placement.nameId >= objTerrain.wmoNames.size()) continue;
+ for (auto placement : objTerrain->wmoPlacements) {
+ if (placement.nameId >= objTerrain->wmoNames.size()) continue;
placement.nameId += wmoNameBase;
if (placement.uniqueId != 0 && !existingWmoUniqueIds.insert(placement.uniqueId).second) {
continue;
}
- terrain.wmoPlacements.push_back(placement);
+ terrainPtr->wmoPlacements.push_back(placement);
mergedWmos++;
}
@@ -334,11 +352,11 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) {
}
// Set tile coordinates so mesh knows where to position this tile in world
- terrain.coord.x = x;
- terrain.coord.y = y;
+ terrainPtr->coord.x = x;
+ terrainPtr->coord.y = y;
// Generate mesh
- pipeline::TerrainMesh mesh = pipeline::TerrainMeshGenerator::generate(terrain);
+ pipeline::TerrainMesh mesh = pipeline::TerrainMeshGenerator::generate(*terrainPtr);
if (mesh.validChunkCount == 0) {
LOG_ERROR("Failed to generate terrain mesh: ", adtPath);
return nullptr;
@@ -346,7 +364,7 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) {
auto pending = std::make_shared();
pending->coord = coord;
- pending->terrain = std::move(terrain);
+ pending->terrain = std::move(*terrainPtr);
pending->mesh = std::move(mesh);
std::unordered_set preparedModelIds;
@@ -646,176 +664,157 @@ void TerrainManager::logMissingAdtOnce(const std::string& adtPath) {
}
}
-void TerrainManager::finalizeTile(const std::shared_ptr& pending) {
+bool TerrainManager::advanceFinalization(FinalizingTile& ft) {
+ auto& pending = ft.pending;
int x = pending->coord.x;
int y = pending->coord.y;
TileCoord coord = pending->coord;
- LOG_DEBUG("Finalizing tile [", x, ",", y, "] (GPU upload)");
+ switch (ft.phase) {
- // Check if tile was already loaded (race condition guard) or failed
- if (loadedTiles.find(coord) != loadedTiles.end()) {
- return;
- }
- if (failedTiles.find(coord) != failedTiles.end()) {
- return;
- }
-
- // Upload pre-loaded textures to the GL cache so loadTerrain avoids file I/O
- if (!pending->preloadedTextures.empty()) {
- terrainRenderer->uploadPreloadedTextures(pending->preloadedTextures);
- }
-
- // Upload terrain to GPU
- if (!terrainRenderer->loadTerrain(pending->mesh, pending->terrain.textures, x, y)) {
- LOG_ERROR("Failed to upload terrain to GPU for tile [", x, ",", y, "]");
- failedTiles[coord] = true;
- return;
- }
-
- // Load water
- if (waterRenderer) {
- waterRenderer->loadFromTerrain(pending->terrain, true, x, y);
- }
-
- // Register water surface ambient sound emitters
- if (ambientSoundManager) {
- // Scan ADT water data for water surfaces
- int waterEmitterCount = 0;
- for (size_t chunkIdx = 0; chunkIdx < pending->terrain.waterData.size(); chunkIdx++) {
- const auto& chunkWater = pending->terrain.waterData[chunkIdx];
- if (!chunkWater.hasWater()) continue;
-
- // Calculate chunk position in world coordinates
- int chunkX = chunkIdx % 16;
- int chunkY = chunkIdx / 16;
-
- // WoW coordinates: Each ADT tile is 533.33 units, each chunk is 533.33/16 = 33.333 units
- // Tile origin in GL space
- float tileOriginX = (32.0f - x) * 533.33333f;
- float tileOriginY = (32.0f - y) * 533.33333f;
-
- // Chunk center position
- float chunkCenterX = tileOriginX + (chunkX + 0.5f) * 33.333333f;
- float chunkCenterY = tileOriginY + (chunkY + 0.5f) * 33.333333f;
-
- // Use first layer for height and type detection
- if (!chunkWater.layers.empty()) {
- const auto& layer = chunkWater.layers[0];
- float waterHeight = layer.minHeight;
-
- // Determine water type and register appropriate emitter
- // liquidType: 0=water/lake, 1=ocean, 2=magma, 3=slime
- if (layer.liquidType == 0) {
- // Lake/river water - add water surface emitter every 32 chunks to avoid spam
- if (chunkIdx % 32 == 0) {
- PendingTile::AmbientEmitter emitter;
- emitter.position = glm::vec3(chunkCenterX, chunkCenterY, waterHeight);
- emitter.type = 4; // WATER_SURFACE
- pending->ambientEmitters.push_back(emitter);
- waterEmitterCount++;
- }
- } else if (layer.liquidType == 1) {
- // Ocean - add ocean emitter every 64 chunks (oceans are very large)
- if (chunkIdx % 64 == 0) {
- PendingTile::AmbientEmitter emitter;
- emitter.position = glm::vec3(chunkCenterX, chunkCenterY, waterHeight);
- emitter.type = 4; // WATER_SURFACE (could add separate OCEAN type later)
- pending->ambientEmitters.push_back(emitter);
- waterEmitterCount++;
- }
- }
- // Skip magma and slime for now (no ambient sounds for those)
+ case FinalizationPhase::TERRAIN: {
+ // Check if tile was already loaded or failed
+ if (loadedTiles.find(coord) != loadedTiles.end() || failedTiles.find(coord) != failedTiles.end()) {
+ {
+ std::lock_guard lock(queueMutex);
+ pendingTiles.erase(coord);
}
+ ft.phase = FinalizationPhase::DONE;
+ return true;
}
- if (waterEmitterCount > 0) {
+
+ LOG_DEBUG("Finalizing tile [", x, ",", y, "] (incremental)");
+
+ // Upload pre-loaded textures
+ if (!pending->preloadedTextures.empty()) {
+ terrainRenderer->uploadPreloadedTextures(pending->preloadedTextures);
}
+
+ // Upload terrain mesh to GPU
+ if (!terrainRenderer->loadTerrain(pending->mesh, pending->terrain.textures, x, y)) {
+ LOG_ERROR("Failed to upload terrain to GPU for tile [", x, ",", y, "]");
+ failedTiles[coord] = true;
+ {
+ std::lock_guard lock(queueMutex);
+ pendingTiles.erase(coord);
+ }
+ ft.phase = FinalizationPhase::DONE;
+ return true;
+ }
+
+ // Load water immediately after terrain (same frame) — water is now
+ // deduplicated to ~1-2 merged surfaces per tile, so this is fast.
+ if (waterRenderer) {
+ waterRenderer->loadFromTerrain(pending->terrain, true, x, y);
+ }
+
+ // Ensure M2 renderer has asset manager
+ if (m2Renderer && assetManager) {
+ m2Renderer->initialize(nullptr, VK_NULL_HANDLE, assetManager);
+ }
+
+ ft.phase = FinalizationPhase::M2_MODELS;
+ return false;
}
- std::vector m2InstanceIds;
- std::vector wmoInstanceIds;
- std::vector tileUniqueIds;
- std::vector tileWmoUniqueIds;
-
- // Upload M2 models to GPU and create instances
- if (m2Renderer && assetManager) {
- // Always pass the latest asset manager. initialize() is idempotent and updates
- // the pointer even when the renderer was initialized earlier without assets.
- m2Renderer->initialize(nullptr, VK_NULL_HANDLE, assetManager);
-
- // Upload M2 models immediately (batching was causing hangs)
- // The 5ms time budget in processReadyTiles() limits the spike
- std::unordered_set uploadedModelIds;
- for (auto& m2Ready : pending->m2Models) {
+ case FinalizationPhase::M2_MODELS: {
+ // Upload ONE M2 model per call
+ if (m2Renderer && ft.m2ModelIndex < pending->m2Models.size()) {
+ auto& m2Ready = pending->m2Models[ft.m2ModelIndex];
if (m2Renderer->loadModel(m2Ready.model, m2Ready.modelId)) {
- uploadedModelIds.insert(m2Ready.modelId);
+ ft.uploadedM2ModelIds.insert(m2Ready.modelId);
+ }
+ ft.m2ModelIndex++;
+ // Stay in this phase until all models uploaded
+ if (ft.m2ModelIndex < pending->m2Models.size()) {
+ return false;
}
}
- if (!uploadedModelIds.empty()) {
- LOG_DEBUG(" Uploaded ", uploadedModelIds.size(), " M2 models for tile [", x, ",", y, "]");
+ if (!ft.uploadedM2ModelIds.empty()) {
+ LOG_DEBUG(" Uploaded ", ft.uploadedM2ModelIds.size(), " M2 models for tile [", x, ",", y, "]");
}
-
- // Create instances (deduplicate by uniqueId across tile boundaries)
- int loadedDoodads = 0;
- int skippedDedup = 0;
- for (const auto& p : pending->m2Placements) {
- // Skip if this doodad was already placed by a neighboring tile
- if (p.uniqueId != 0 && placedDoodadIds.count(p.uniqueId)) {
- skippedDedup++;
- continue;
- }
- uint32_t instId = m2Renderer->createInstance(p.modelId, p.position, p.rotation, p.scale);
- if (instId) {
- m2InstanceIds.push_back(instId);
- if (p.uniqueId != 0) {
- placedDoodadIds.insert(p.uniqueId);
- tileUniqueIds.push_back(p.uniqueId);
- }
- loadedDoodads++;
- }
- }
-
- LOG_DEBUG(" Loaded doodads for tile [", x, ",", y, "]: ",
- loadedDoodads, " instances (", uploadedModelIds.size(), " new models, ",
- skippedDedup, " dedup skipped)");
+ ft.phase = FinalizationPhase::M2_INSTANCES;
+ return false;
}
- // Upload WMO models to GPU and create instances
- if (wmoRenderer && assetManager) {
- // WMORenderer may be initialized before assets are ready; always re-pass assets.
- wmoRenderer->initialize(nullptr, VK_NULL_HANDLE, assetManager);
-
- int loadedWMOs = 0;
- int loadedLiquids = 0;
- int skippedWmoDedup = 0;
- for (auto& wmoReady : pending->wmoModels) {
- // Deduplicate by placement uniqueId when available.
- // Some ADTs use uniqueId=0, which is not safe for dedup.
- if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) {
- skippedWmoDedup++;
- continue;
+ case FinalizationPhase::M2_INSTANCES: {
+ // Create all M2 instances (lightweight struct allocation, no GPU work)
+ if (m2Renderer) {
+ int loadedDoodads = 0;
+ int skippedDedup = 0;
+ for (const auto& p : pending->m2Placements) {
+ if (p.uniqueId != 0 && placedDoodadIds.count(p.uniqueId)) {
+ skippedDedup++;
+ continue;
+ }
+ uint32_t instId = m2Renderer->createInstance(p.modelId, p.position, p.rotation, p.scale);
+ if (instId) {
+ ft.m2InstanceIds.push_back(instId);
+ if (p.uniqueId != 0) {
+ placedDoodadIds.insert(p.uniqueId);
+ ft.tileUniqueIds.push_back(p.uniqueId);
+ }
+ loadedDoodads++;
+ }
}
+ LOG_DEBUG(" Loaded doodads for tile [", x, ",", y, "]: ",
+ loadedDoodads, " instances (", ft.uploadedM2ModelIds.size(), " new models, ",
+ skippedDedup, " dedup skipped)");
+ }
+ ft.phase = FinalizationPhase::WMO_MODELS;
+ return false;
+ }
+
+ case FinalizationPhase::WMO_MODELS: {
+ // Upload ONE WMO model per call
+ if (wmoRenderer && assetManager) {
+ wmoRenderer->initialize(nullptr, VK_NULL_HANDLE, assetManager);
+
+ if (ft.wmoModelIndex < pending->wmoModels.size()) {
+ auto& wmoReady = pending->wmoModels[ft.wmoModelIndex];
+ // Deduplicate
+ if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) {
+ ft.wmoModelIndex++;
+ if (ft.wmoModelIndex < pending->wmoModels.size()) return false;
+ } else {
+ wmoRenderer->loadModel(wmoReady.model, wmoReady.modelId);
+ ft.wmoModelIndex++;
+ if (ft.wmoModelIndex < pending->wmoModels.size()) return false;
+ }
+ }
+ }
+ ft.phase = FinalizationPhase::WMO_INSTANCES;
+ return false;
+ }
+
+ case FinalizationPhase::WMO_INSTANCES: {
+ // Create all WMO instances + load WMO liquids
+ if (wmoRenderer) {
+ int loadedWMOs = 0;
+ int loadedLiquids = 0;
+ int skippedWmoDedup = 0;
+ for (auto& wmoReady : pending->wmoModels) {
+ if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) {
+ skippedWmoDedup++;
+ continue;
+ }
- if (wmoRenderer->loadModel(wmoReady.model, wmoReady.modelId)) {
uint32_t wmoInstId = wmoRenderer->createInstance(wmoReady.modelId, wmoReady.position, wmoReady.rotation);
if (wmoInstId) {
- wmoInstanceIds.push_back(wmoInstId);
+ ft.wmoInstanceIds.push_back(wmoInstId);
if (wmoReady.uniqueId != 0) {
placedWmoIds.insert(wmoReady.uniqueId);
- tileWmoUniqueIds.push_back(wmoReady.uniqueId);
+ ft.tileWmoUniqueIds.push_back(wmoReady.uniqueId);
}
loadedWMOs++;
// Load WMO liquids (canals, pools, etc.)
if (waterRenderer) {
- // Compute the same model matrix as WMORenderer uses
glm::mat4 modelMatrix = glm::mat4(1.0f);
modelMatrix = glm::translate(modelMatrix, wmoReady.position);
modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.z, glm::vec3(0.0f, 0.0f, 1.0f));
modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f));
modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f));
-
- // Load liquids from each WMO group
for (const auto& group : wmoReady.model.groups) {
if (group.liquid.hasLiquid()) {
waterRenderer->loadFromWMO(group.liquid, modelMatrix, wmoInstId);
@@ -825,60 +824,140 @@ void TerrainManager::finalizeTile(const std::shared_ptr& pending) {
}
}
}
+ if (loadedWMOs > 0 || skippedWmoDedup > 0) {
+ LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ",
+ loadedWMOs, " instances, ", skippedWmoDedup, " dedup skipped");
+ }
+ if (loadedLiquids > 0) {
+ LOG_DEBUG(" Loaded WMO liquids for tile [", x, ",", y, "]: ", loadedLiquids);
+ }
}
- if (loadedWMOs > 0 || skippedWmoDedup > 0) {
- LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ",
- loadedWMOs, " instances, ", skippedWmoDedup, " dedup skipped");
- }
- if (loadedLiquids > 0) {
- LOG_DEBUG(" Loaded WMO liquids for tile [", x, ",", y, "]: ", loadedLiquids);
- }
+ ft.phase = FinalizationPhase::WMO_DOODADS;
+ return false;
+ }
- // Upload WMO doodad M2 models
- if (m2Renderer) {
- for (auto& doodad : pending->wmoDoodads) {
- m2Renderer->loadModel(doodad.model, doodad.modelId);
- uint32_t wmoDoodadInstId = m2Renderer->createInstanceWithMatrix(
- doodad.modelId, doodad.modelMatrix, doodad.worldPosition);
- if (wmoDoodadInstId) m2InstanceIds.push_back(wmoDoodadInstId);
+ case FinalizationPhase::WMO_DOODADS: {
+ // Upload ONE WMO doodad M2 per call
+ if (m2Renderer && ft.wmoDoodadIndex < pending->wmoDoodads.size()) {
+ auto& doodad = pending->wmoDoodads[ft.wmoDoodadIndex];
+ m2Renderer->loadModel(doodad.model, doodad.modelId);
+ uint32_t wmoDoodadInstId = m2Renderer->createInstanceWithMatrix(
+ doodad.modelId, doodad.modelMatrix, doodad.worldPosition);
+ if (wmoDoodadInstId) ft.m2InstanceIds.push_back(wmoDoodadInstId);
+ ft.wmoDoodadIndex++;
+ if (ft.wmoDoodadIndex < pending->wmoDoodads.size()) return false;
+ }
+ ft.phase = FinalizationPhase::WATER;
+ return false;
+ }
+
+ case FinalizationPhase::WATER: {
+ // Terrain water was already loaded in TERRAIN phase.
+ // Generate water ambient emitters here.
+ if (ambientSoundManager) {
+ for (size_t chunkIdx = 0; chunkIdx < pending->terrain.waterData.size(); chunkIdx++) {
+ const auto& chunkWater = pending->terrain.waterData[chunkIdx];
+ if (!chunkWater.hasWater()) continue;
+
+ int chunkX = chunkIdx % 16;
+ int chunkY = chunkIdx / 16;
+ float tileOriginX = (32.0f - x) * 533.33333f;
+ float tileOriginY = (32.0f - y) * 533.33333f;
+ float chunkCenterX = tileOriginX + (chunkX + 0.5f) * 33.333333f;
+ float chunkCenterY = tileOriginY + (chunkY + 0.5f) * 33.333333f;
+
+ if (!chunkWater.layers.empty()) {
+ const auto& layer = chunkWater.layers[0];
+ float waterHeight = layer.minHeight;
+ if (layer.liquidType == 0 && chunkIdx % 32 == 0) {
+ PendingTile::AmbientEmitter emitter;
+ emitter.position = glm::vec3(chunkCenterX, chunkCenterY, waterHeight);
+ emitter.type = 4;
+ pending->ambientEmitters.push_back(emitter);
+ } else if (layer.liquidType == 1 && chunkIdx % 64 == 0) {
+ PendingTile::AmbientEmitter emitter;
+ emitter.position = glm::vec3(chunkCenterX, chunkCenterY, waterHeight);
+ emitter.type = 4;
+ pending->ambientEmitters.push_back(emitter);
+ }
+ }
}
}
- if (loadedWMOs > 0) {
- LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ", loadedWMOs);
- }
+ ft.phase = FinalizationPhase::AMBIENT;
+ return false;
}
- // Register ambient sound emitters with ambient sound manager
- if (ambientSoundManager && !pending->ambientEmitters.empty()) {
- for (const auto& emitter : pending->ambientEmitters) {
- // Cast uint32_t type to AmbientSoundManager::AmbientType enum
- auto type = static_cast(emitter.type);
- ambientSoundManager->addEmitter(emitter.position, type);
+ case FinalizationPhase::AMBIENT: {
+ // Register ambient sound emitters
+ if (ambientSoundManager && !pending->ambientEmitters.empty()) {
+ for (const auto& emitter : pending->ambientEmitters) {
+ auto type = static_cast(emitter.type);
+ ambientSoundManager->addEmitter(emitter.position, type);
+ }
}
+
+ // Commit tile to loadedTiles
+ auto tile = std::make_unique();
+ tile->coord = coord;
+ tile->terrain = std::move(pending->terrain);
+ tile->mesh = std::move(pending->mesh);
+ tile->loaded = true;
+ tile->m2InstanceIds = std::move(ft.m2InstanceIds);
+ tile->wmoInstanceIds = std::move(ft.wmoInstanceIds);
+ tile->wmoUniqueIds = std::move(ft.tileWmoUniqueIds);
+ tile->doodadUniqueIds = std::move(ft.tileUniqueIds);
+ getTileBounds(coord, tile->minX, tile->minY, tile->maxX, tile->maxY);
+ loadedTiles[coord] = std::move(tile);
+ putCachedTile(pending);
+
+ // Now safe to remove from pendingTiles (tile is in loadedTiles)
+ {
+ std::lock_guard lock(queueMutex);
+ pendingTiles.erase(coord);
+ }
+
+ LOG_DEBUG(" Finalized tile [", x, ",", y, "]");
+
+ ft.phase = FinalizationPhase::DONE;
+ return true;
}
- // Create tile entry
- auto tile = std::make_unique();
- tile->coord = coord;
- tile->terrain = std::move(pending->terrain);
- tile->mesh = std::move(pending->mesh);
- tile->loaded = true;
- tile->m2InstanceIds = std::move(m2InstanceIds);
- tile->wmoInstanceIds = std::move(wmoInstanceIds);
- tile->wmoUniqueIds = std::move(tileWmoUniqueIds);
- tile->doodadUniqueIds = std::move(tileUniqueIds);
-
- // Calculate world bounds
- getTileBounds(coord, tile->minX, tile->minY, tile->maxX, tile->maxY);
-
- loadedTiles[coord] = std::move(tile);
- putCachedTile(pending);
-
- LOG_DEBUG(" Finalized tile [", x, ",", y, "]");
+ case FinalizationPhase::DONE:
+ return true;
+ }
+ return true;
}
void TerrainManager::workerLoop() {
+ // Keep worker threads off core 0 (reserved for main thread)
+ {
+ int numCores = static_cast(std::thread::hardware_concurrency());
+ if (numCores >= 2) {
+#ifdef __linux__
+ cpu_set_t cpuset;
+ CPU_ZERO(&cpuset);
+ for (int i = 1; i < numCores; i++) {
+ CPU_SET(i, &cpuset);
+ }
+ pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);
+#elif defined(_WIN32)
+ DWORD_PTR mask = 0;
+ for (int i = 1; i < numCores && i < 64; i++) {
+ mask |= (static_cast(1) << i);
+ }
+ SetThreadAffinityMask(GetCurrentThread(), mask);
+#elif defined(__APPLE__)
+ // Use affinity tag 2 for workers (separate from main thread tag 1)
+ thread_affinity_policy_data_t policy = { 2 };
+ thread_policy_set(
+ pthread_mach_thread_np(pthread_self()),
+ THREAD_AFFINITY_POLICY,
+ reinterpret_cast(&policy),
+ THREAD_AFFINITY_POLICY_COUNT);
+#endif
+ }
+ }
LOG_INFO("Terrain worker thread started");
while (workerRunning.load()) {
@@ -925,80 +1004,60 @@ void TerrainManager::processReadyTiles() {
// Taxi mode gets a slightly larger budget to avoid visible late-pop terrain/models.
const float timeBudgetMs = taxiStreamingMode_ ? 8.0f : 5.0f;
auto startTime = std::chrono::high_resolution_clock::now();
- int processed = 0;
- while (true) {
- std::shared_ptr pending;
-
- {
- std::lock_guard lock(queueMutex);
- if (readyQueue.empty()) {
- break;
- }
- pending = readyQueue.front();
+ // Move newly ready tiles into the finalizing deque.
+ // Keep them in pendingTiles so streamTiles() won't re-enqueue them.
+ {
+ std::lock_guard lock(queueMutex);
+ while (!readyQueue.empty()) {
+ auto pending = readyQueue.front();
readyQueue.pop();
- }
-
- if (pending) {
- TileCoord coord = pending->coord;
-
- finalizeTile(pending);
-
- auto now = std::chrono::high_resolution_clock::now();
-
- {
- std::lock_guard lock(queueMutex);
- pendingTiles.erase(coord);
- }
- processed++;
-
- // Check if we've exceeded time budget
- float elapsedMs = std::chrono::duration(now - startTime).count();
- if (elapsedMs >= timeBudgetMs) {
- if (processed > 1) {
- LOG_DEBUG("Processed ", processed, " tiles in ", elapsedMs, "ms (budget: ", timeBudgetMs, "ms)");
- }
- break;
+ if (pending) {
+ FinalizingTile ft;
+ ft.pending = std::move(pending);
+ finalizingTiles_.push_back(std::move(ft));
}
}
}
-}
-void TerrainManager::processM2UploadQueue() {
- // Upload up to MAX_M2_UPLOADS_PER_FRAME models per frame
- int uploaded = 0;
- while (!m2UploadQueue_.empty() && uploaded < MAX_M2_UPLOADS_PER_FRAME) {
- auto& upload = m2UploadQueue_.front();
- if (m2Renderer) {
- m2Renderer->loadModel(upload.model, upload.modelId);
+ // Drive incremental finalization within time budget
+ while (!finalizingTiles_.empty()) {
+ auto& ft = finalizingTiles_.front();
+ bool done = advanceFinalization(ft);
+
+ if (done) {
+ finalizingTiles_.pop_front();
}
- m2UploadQueue_.pop();
- uploaded++;
- }
- if (uploaded > 0) {
- LOG_DEBUG("Uploaded ", uploaded, " M2 models (", m2UploadQueue_.size(), " remaining in queue)");
+ auto now = std::chrono::high_resolution_clock::now();
+ float elapsedMs = std::chrono::duration(now - startTime).count();
+ if (elapsedMs >= timeBudgetMs) {
+ break;
+ }
}
}
void TerrainManager::processAllReadyTiles() {
- while (true) {
- std::shared_ptr pending;
- {
- std::lock_guard lock(queueMutex);
- if (readyQueue.empty()) break;
- pending = readyQueue.front();
+ // Move all ready tiles into finalizing deque
+ // Keep in pendingTiles until committed (same as processReadyTiles)
+ {
+ std::lock_guard lock(queueMutex);
+ while (!readyQueue.empty()) {
+ auto pending = readyQueue.front();
readyQueue.pop();
- }
- if (pending) {
- TileCoord coord = pending->coord;
- finalizeTile(pending);
- {
- std::lock_guard lock(queueMutex);
- pendingTiles.erase(coord);
+ if (pending) {
+ FinalizingTile ft;
+ ft.pending = std::move(pending);
+ finalizingTiles_.push_back(std::move(ft));
}
}
}
+ // Finalize all tiles completely (no time budget — used for loading screens)
+ while (!finalizingTiles_.empty()) {
+ auto& ft = finalizingTiles_.front();
+ while (!advanceFinalization(ft)) {}
+ finalizingTiles_.pop_front();
+ }
}
std::shared_ptr TerrainManager::getCachedTile(const TileCoord& coord) {
@@ -1097,6 +1156,31 @@ void TerrainManager::unloadTile(int x, int y) {
pendingTiles.erase(coord);
}
+ // Remove from finalizingTiles_ if it's being incrementally finalized.
+ // Water may have already been loaded in TERRAIN phase, so clean it up.
+ for (auto fit = finalizingTiles_.begin(); fit != finalizingTiles_.end(); ++fit) {
+ if (fit->pending && fit->pending->coord == coord) {
+ // If past TERRAIN phase, water was already loaded — remove it
+ if (fit->phase != FinalizationPhase::TERRAIN && waterRenderer) {
+ waterRenderer->removeTile(x, y);
+ }
+ // Clean up any M2/WMO instances that were already created
+ if (m2Renderer && !fit->m2InstanceIds.empty()) {
+ m2Renderer->removeInstances(fit->m2InstanceIds);
+ }
+ if (wmoRenderer && !fit->wmoInstanceIds.empty()) {
+ for (uint32_t id : fit->wmoInstanceIds) {
+ if (waterRenderer) waterRenderer->removeWMO(id);
+ }
+ wmoRenderer->removeInstances(fit->wmoInstanceIds);
+ }
+ for (uint32_t uid : fit->tileUniqueIds) placedDoodadIds.erase(uid);
+ for (uint32_t uid : fit->tileWmoUniqueIds) placedWmoIds.erase(uid);
+ finalizingTiles_.erase(fit);
+ return;
+ }
+ }
+
auto it = loadedTiles.find(coord);
if (it == loadedTiles.end()) {
return;
@@ -1165,6 +1249,7 @@ void TerrainManager::unloadAll() {
while (!readyQueue.empty()) readyQueue.pop();
}
pendingTiles.clear();
+ finalizingTiles_.clear();
placedDoodadIds.clear();
LOG_INFO("Unloading all terrain tiles");
diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp
index 89c1e509..8ecb1d7a 100644
--- a/src/rendering/water_renderer.cpp
+++ b/src/rendering/water_renderer.cpp
@@ -14,6 +14,7 @@
#include
#include
#include
+#include
namespace wowee {
namespace rendering {
@@ -555,7 +556,27 @@ void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool ap
clear();
}
- int totalLayers = 0;
+ // ── Pass 1: collect layers into merge groups keyed by {liquidType, roundedHeight} ──
+ struct ChunkLayerInfo {
+ int chunkX, chunkY;
+ const pipeline::ADTTerrain::WaterLayer* layer;
+ };
+
+ struct MergeKey {
+ uint16_t liquidType;
+ int32_t roundedHeight; // minHeight * 2, rounded to int
+ bool operator==(const MergeKey& o) const {
+ return liquidType == o.liquidType && roundedHeight == o.roundedHeight;
+ }
+ };
+
+ struct MergeKeyHash {
+ size_t operator()(const MergeKey& k) const {
+ return std::hash()((uint64_t(k.liquidType) << 32) | uint32_t(k.roundedHeight));
+ }
+ };
+
+ std::unordered_map, MergeKeyHash> mergeGroups;
for (int chunkIdx = 0; chunkIdx < 256; chunkIdx++) {
const auto& chunkWater = terrain.waterData[chunkIdx];
@@ -563,34 +584,146 @@ void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool ap
int chunkX = chunkIdx % 16;
int chunkY = chunkIdx / 16;
- const auto& terrainChunk = terrain.getChunk(chunkX, chunkY);
for (const auto& layer : chunkWater.layers) {
- WaterSurface surface;
+ MergeKey key;
+ key.liquidType = layer.liquidType;
+ key.roundedHeight = static_cast(std::round(layer.minHeight * 2.0f));
+ mergeGroups[key].push_back({chunkX, chunkY, &layer});
+ }
+ }
- surface.position = glm::vec3(
- terrainChunk.position[0],
- terrainChunk.position[1],
- layer.minHeight
- );
- surface.origin = glm::vec3(
- surface.position.x - (static_cast(layer.y) * TILE_SIZE),
- surface.position.y - (static_cast(layer.x) * TILE_SIZE),
- layer.minHeight
- );
- surface.stepX = glm::vec3(0.0f, -TILE_SIZE, 0.0f);
- surface.stepY = glm::vec3(-TILE_SIZE, 0.0f, 0.0f);
+ // Tile origin = NW corner = chunk(0,0) position
+ const auto& chunk00 = terrain.getChunk(0, 0);
- surface.minHeight = layer.minHeight;
- surface.maxHeight = layer.maxHeight;
- surface.liquidType = layer.liquidType;
+ // Stormwind water lowering check
+ bool isStormwindArea = (tileX >= 28 && tileX <= 50 && tileY >= 28 && tileY <= 52);
+ float tileWorldX = 0, tileWorldY = 0;
+ glm::vec2 moonwellPos2D(0.0f);
+ if (isStormwindArea) {
+ tileWorldX = (32.0f - tileX) * 533.33333f;
+ tileWorldY = (32.0f - tileY) * 533.33333f;
+ moonwellPos2D = glm::vec2(-8755.9f, 1108.9f);
+ }
- surface.xOffset = layer.x;
- surface.yOffset = layer.y;
- surface.width = layer.width;
- surface.height = layer.height;
+ int totalSurfaces = 0;
- size_t numVertices = (layer.width + 1) * (layer.height + 1);
+ // Merge threshold: groups with more than this many chunks get merged into
+ // one tile-wide surface. Small groups (shore, lakes) stay per-chunk so
+ // their original mask / height data is preserved exactly.
+ constexpr size_t MERGE_THRESHOLD = 4;
+
+ // ── Pass 2: create surfaces ──
+ for (auto& [key, chunkLayers] : mergeGroups) {
+
+ // ── Small group → per-chunk surfaces (original code path) ──
+ if (chunkLayers.size() <= MERGE_THRESHOLD) {
+ for (const auto& info : chunkLayers) {
+ const auto& layer = *info.layer;
+ const auto& terrainChunk = terrain.getChunk(info.chunkX, info.chunkY);
+
+ WaterSurface surface;
+ surface.position = glm::vec3(
+ terrainChunk.position[0],
+ terrainChunk.position[1],
+ layer.minHeight
+ );
+ surface.origin = glm::vec3(
+ surface.position.x - (static_cast(layer.y) * TILE_SIZE),
+ surface.position.y - (static_cast(layer.x) * TILE_SIZE),
+ layer.minHeight
+ );
+ surface.stepX = glm::vec3(0.0f, -TILE_SIZE, 0.0f);
+ surface.stepY = glm::vec3(-TILE_SIZE, 0.0f, 0.0f);
+
+ surface.minHeight = layer.minHeight;
+ surface.maxHeight = layer.maxHeight;
+ surface.liquidType = layer.liquidType;
+ surface.xOffset = layer.x;
+ surface.yOffset = layer.y;
+ surface.width = layer.width;
+ surface.height = layer.height;
+
+ size_t numVertices = (layer.width + 1) * (layer.height + 1);
+ bool useFlat = true;
+ if (layer.heights.size() == numVertices) {
+ bool sane = true;
+ for (float h : layer.heights) {
+ if (!std::isfinite(h) || std::abs(h) > 50000.0f) { sane = false; break; }
+ if (h < layer.minHeight - 8.0f || h > layer.maxHeight + 8.0f) { sane = false; break; }
+ }
+ if (sane) { useFlat = false; surface.heights = layer.heights; }
+ }
+ if (useFlat) surface.heights.resize(numVertices, layer.minHeight);
+
+ if (isStormwindArea && layer.minHeight > 94.0f) {
+ float distToMoonwell = glm::distance(glm::vec2(tileWorldX, tileWorldY), moonwellPos2D);
+ if (distToMoonwell > 300.0f) {
+ for (float& h : surface.heights) h -= 1.0f;
+ surface.minHeight -= 1.0f;
+ surface.maxHeight -= 1.0f;
+ }
+ }
+
+ surface.mask = layer.mask;
+ surface.tileX = tileX;
+ surface.tileY = tileY;
+
+ createWaterMesh(surface);
+ if (surface.indexCount > 0 && vkCtx) {
+ updateMaterialUBO(surface);
+ }
+ surfaces.push_back(std::move(surface));
+ totalSurfaces++;
+ }
+ continue;
+ }
+
+ // ── Large group → merged tile-wide surface ──
+ WaterSurface surface;
+
+ float groupHeight = key.roundedHeight / 2.0f;
+
+ surface.width = 128;
+ surface.height = 128;
+ surface.xOffset = 0;
+ surface.yOffset = 0;
+ surface.liquidType = key.liquidType;
+ surface.tileX = tileX;
+ surface.tileY = tileY;
+
+ // Origin = chunk(0,0) position (NW corner of tile)
+ surface.origin = glm::vec3(chunk00.position[0], chunk00.position[1], groupHeight);
+ surface.position = surface.origin;
+ surface.stepX = glm::vec3(0.0f, -TILE_SIZE, 0.0f);
+ surface.stepY = glm::vec3(-TILE_SIZE, 0.0f, 0.0f);
+
+ surface.minHeight = groupHeight;
+ surface.maxHeight = groupHeight;
+
+ // Initialize height grid (129×129) with group height
+ constexpr int MERGED_W = 128;
+ const int gridW = MERGED_W + 1; // 129
+ const int gridH = MERGED_W + 1;
+ surface.heights.resize(gridW * gridH, groupHeight);
+
+ // Initialize mask (128×128 sub-tiles, all masked OUT)
+ // Mask uses LSB bit order: tileIndex = row * 128 + col
+ const int maskBytes = (MERGED_W * MERGED_W + 7) / 8;
+ surface.mask.resize(maskBytes, 0);
+
+ // ── Fill from each contributing chunk ──
+ for (const auto& info : chunkLayers) {
+ const auto& layer = *info.layer;
+
+ // Merged grid offset for this chunk
+ // gx = chunkY*8 + layer.x + localX, gy = chunkX*8 + layer.y + localY
+ int baseGx = info.chunkY * 8;
+ int baseGy = info.chunkX * 8;
+
+ // Copy heights
+ int layerGridW = layer.width + 1;
+ size_t numVertices = static_cast(layerGridW) * (layer.height + 1);
bool useFlat = true;
if (layer.heights.size() == numVertices) {
bool sane = true;
@@ -598,39 +731,79 @@ void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool ap
if (!std::isfinite(h) || std::abs(h) > 50000.0f) { sane = false; break; }
if (h < layer.minHeight - 8.0f || h > layer.maxHeight + 8.0f) { sane = false; break; }
}
- if (sane) { useFlat = false; surface.heights = layer.heights; }
+ if (sane) useFlat = false;
}
- if (useFlat) surface.heights.resize(numVertices, layer.minHeight);
- // Stormwind water lowering
- bool isStormwindArea = (tileX >= 28 && tileX <= 50 && tileY >= 28 && tileY <= 52);
- if (isStormwindArea && layer.minHeight > 94.0f) {
- float tileWorldX = (32.0f - tileX) * 533.33333f;
- float tileWorldY = (32.0f - tileY) * 533.33333f;
- glm::vec3 moonwellPos(-8755.9f, 1108.9f, 96.1f);
- float distToMoonwell = glm::distance(glm::vec2(tileWorldX, tileWorldY),
- glm::vec2(moonwellPos.x, moonwellPos.y));
- if (distToMoonwell > 300.0f) {
- for (float& h : surface.heights) h -= 1.0f;
- surface.minHeight -= 1.0f;
- surface.maxHeight -= 1.0f;
+ for (int ly = 0; ly <= layer.height; ly++) {
+ for (int lx = 0; lx <= layer.width; lx++) {
+ int mgx = baseGx + layer.x + lx;
+ int mgy = baseGy + layer.y + ly;
+ if (mgx >= gridW || mgy >= gridH) continue;
+
+ float h;
+ if (!useFlat) {
+ int layerIdx = ly * layerGridW + lx;
+ h = layer.heights[layerIdx];
+ } else {
+ h = layer.minHeight;
+ }
+
+ surface.heights[mgy * gridW + mgx] = h;
+ if (h < surface.minHeight) surface.minHeight = h;
+ if (h > surface.maxHeight) surface.maxHeight = h;
}
}
- surface.mask = layer.mask;
- surface.tileX = tileX;
- surface.tileY = tileY;
+ // Copy mask — mark contributing sub-tiles as renderable
+ for (int ly = 0; ly < layer.height; ly++) {
+ for (int lx = 0; lx < layer.width; lx++) {
+ bool render = true;
+ if (!layer.mask.empty()) {
+ int cx = layer.x + lx;
+ int cy = layer.y + ly;
+ int origTileIdx = cy * 8 + cx;
+ int origByte = origTileIdx / 8;
+ int origBit = origTileIdx % 8;
+ if (origByte < static_cast(layer.mask.size())) {
+ uint8_t mb = layer.mask[origByte];
+ render = (mb & (1 << origBit)) || (mb & (1 << (7 - origBit)));
+ }
+ }
- createWaterMesh(surface);
- if (surface.indexCount > 0 && vkCtx) {
- updateMaterialUBO(surface);
+ if (render) {
+ int mx = baseGx + layer.x + lx;
+ int my = baseGy + layer.y + ly;
+ if (mx >= MERGED_W || my >= MERGED_W) continue;
+
+ int mergedTileIdx = my * MERGED_W + mx;
+ int byteIdx = mergedTileIdx / 8;
+ int bitIdx = mergedTileIdx % 8;
+ surface.mask[byteIdx] |= static_cast(1 << bitIdx);
+ }
+ }
}
- surfaces.push_back(std::move(surface));
- totalLayers++;
}
+
+ // Stormwind water lowering
+ if (isStormwindArea && surface.minHeight > 94.0f) {
+ float distToMoonwell = glm::distance(glm::vec2(tileWorldX, tileWorldY), moonwellPos2D);
+ if (distToMoonwell > 300.0f) {
+ for (float& h : surface.heights) h -= 1.0f;
+ surface.minHeight -= 1.0f;
+ surface.maxHeight -= 1.0f;
+ }
+ }
+
+ createWaterMesh(surface);
+ if (surface.indexCount > 0 && vkCtx) {
+ updateMaterialUBO(surface);
+ }
+ surfaces.push_back(std::move(surface));
+ totalSurfaces++;
}
- LOG_DEBUG("Loaded ", totalLayers, " water layers from MH2O data");
+ LOG_DEBUG("Water: Loaded ", totalSurfaces, " surfaces from tile [", tileX, ",", tileY,
+ "] (", mergeGroups.size(), " groups), total surfaces: ", surfaces.size());
}
void WaterRenderer::removeTile(int tileX, int tileY) {
@@ -646,7 +819,7 @@ void WaterRenderer::removeTile(int tileX, int tileY) {
}
}
if (removed > 0) {
- LOG_DEBUG("Removed ", removed, " water surfaces for tile [", tileX, ",", tileY, "]");
+ LOG_DEBUG("Water: Removed ", removed, " surfaces for tile [", tileX, ",", tileY, "], remaining: ", surfaces.size());
}
}
@@ -948,7 +1121,8 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) {
bool renderTile = true;
if (!surface.mask.empty()) {
int tileIndex;
- if (surface.wmoId == 0 && surface.mask.size() >= 8) {
+ bool isMergedTerrain = (surface.wmoId == 0 && surface.width > 8);
+ if (surface.wmoId == 0 && surface.width <= 8 && surface.mask.size() >= 8) {
int cx = static_cast(surface.xOffset) + x;
int cy = static_cast(surface.yOffset) + y;
tileIndex = cy * 8 + cx;
@@ -959,9 +1133,14 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) {
int bitIndex = tileIndex % 8;
if (byteIndex < static_cast(surface.mask.size())) {
uint8_t maskByte = surface.mask[byteIndex];
- bool lsbOrder = (maskByte & (1 << bitIndex)) != 0;
- bool msbOrder = (maskByte & (1 << (7 - bitIndex))) != 0;
- renderTile = lsbOrder || msbOrder;
+ if (isMergedTerrain) {
+ // Merged surfaces use LSB-only bit order
+ renderTile = (maskByte & (1 << bitIndex)) != 0;
+ } else {
+ bool lsbOrder = (maskByte & (1 << bitIndex)) != 0;
+ bool msbOrder = (maskByte & (1 << (7 - bitIndex))) != 0;
+ renderTile = lsbOrder || msbOrder;
+ }
if (!renderTile) {
for (int dy = -1; dy <= 1; dy++) {
@@ -970,7 +1149,7 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) {
int nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= gridWidth-1 || ny >= gridHeight-1) continue;
int neighborIdx;
- if (surface.wmoId == 0 && surface.mask.size() >= 8) {
+ if (surface.wmoId == 0 && surface.width <= 8 && surface.mask.size() >= 8) {
neighborIdx = (static_cast(surface.yOffset) + ny) * 8 +
(static_cast(surface.xOffset) + nx);
} else {
@@ -980,9 +1159,16 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) {
int nBitIdx = neighborIdx % 8;
if (nByteIdx < static_cast(surface.mask.size())) {
uint8_t nMask = surface.mask[nByteIdx];
- if ((nMask & (1 << nBitIdx)) || (nMask & (1 << (7 - nBitIdx)))) {
- renderTile = true;
- goto found_neighbor;
+ if (isMergedTerrain) {
+ if (nMask & (1 << nBitIdx)) {
+ renderTile = true;
+ goto found_neighbor;
+ }
+ } else {
+ if ((nMask & (1 << nBitIdx)) || (nMask & (1 << (7 - nBitIdx)))) {
+ renderTile = true;
+ goto found_neighbor;
+ }
}
}
}
@@ -1100,7 +1286,7 @@ std::optional WaterRenderer::getWaterHeightAt(float glX, float glY) const
if (!surface.mask.empty()) {
int tileIndex;
- if (surface.wmoId == 0 && surface.mask.size() >= 8) {
+ if (surface.wmoId == 0 && surface.width <= 8 && surface.mask.size() >= 8) {
tileIndex = (static_cast(surface.yOffset) + iy) * 8 +
(static_cast(surface.xOffset) + ix);
} else {
@@ -1110,7 +1296,12 @@ std::optional WaterRenderer::getWaterHeightAt(float glX, float glY) const
int bitIndex = tileIndex % 8;
if (byteIndex < static_cast(surface.mask.size())) {
uint8_t maskByte = surface.mask[byteIndex];
- bool renderTile = (maskByte & (1 << bitIndex)) || (maskByte & (1 << (7 - bitIndex)));
+ bool renderTile;
+ if (surface.wmoId == 0 && surface.width > 8) {
+ renderTile = (maskByte & (1 << bitIndex)) != 0;
+ } else {
+ renderTile = (maskByte & (1 << bitIndex)) || (maskByte & (1 << (7 - bitIndex)));
+ }
if (!renderTile) continue;
}
}
@@ -1162,7 +1353,7 @@ std::optional WaterRenderer::getNearestWaterHeightAt(float glX, float glY
if (!surface.mask.empty()) {
int tileIndex;
- if (surface.wmoId == 0 && surface.mask.size() >= 8) {
+ if (surface.wmoId == 0 && surface.width <= 8 && surface.mask.size() >= 8) {
tileIndex = (static_cast(surface.yOffset) + iy) * 8 +
(static_cast(surface.xOffset) + ix);
} else {
@@ -1172,7 +1363,12 @@ std::optional WaterRenderer::getNearestWaterHeightAt(float glX, float glY
int bitIndex = tileIndex % 8;
if (byteIndex < static_cast(surface.mask.size())) {
uint8_t maskByte = surface.mask[byteIndex];
- bool renderTile = (maskByte & (1 << bitIndex)) || (maskByte & (1 << (7 - bitIndex)));
+ bool renderTile;
+ if (surface.wmoId == 0 && surface.width > 8) {
+ renderTile = (maskByte & (1 << bitIndex)) != 0;
+ } else {
+ renderTile = (maskByte & (1 << bitIndex)) || (maskByte & (1 << (7 - bitIndex)));
+ }
if (!renderTile) continue;
}
}
@@ -1228,7 +1424,7 @@ std::optional WaterRenderer::getWaterTypeAt(float glX, float glY) cons
if (!surface.mask.empty()) {
int tileIndex;
- if (surface.wmoId == 0 && surface.mask.size() >= 8) {
+ if (surface.wmoId == 0 && surface.width <= 8 && surface.mask.size() >= 8) {
tileIndex = (static_cast(surface.yOffset) + iy) * 8 +
(static_cast(surface.xOffset) + ix);
} else {
@@ -1238,7 +1434,12 @@ std::optional WaterRenderer::getWaterTypeAt(float glX, float glY) cons
int bitIndex = tileIndex % 8;
if (byteIndex < static_cast(surface.mask.size())) {
uint8_t maskByte = surface.mask[byteIndex];
- bool renderTile = (maskByte & (1 << bitIndex)) || (maskByte & (1 << (7 - bitIndex)));
+ bool renderTile;
+ if (surface.wmoId == 0 && surface.width > 8) {
+ renderTile = (maskByte & (1 << bitIndex)) != 0;
+ } else {
+ renderTile = (maskByte & (1 << bitIndex)) || (maskByte & (1 << (7 - bitIndex)));
+ }
if (!renderTile) continue;
}
}