This commit is contained in:
kittnz 2026-02-25 13:44:53 +01:00
commit 9aa87b322f
15 changed files with 1179 additions and 373 deletions

View file

@ -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}

363
.github/workflows/release.yml vendored Normal file
View file

@ -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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleExecutable</key><string>wowee</string>
<key>CFBundleIdentifier</key><string>com.wowee.app</string>
<key>CFBundleName</key><string>Wowee</string>
<key>CFBundleVersion</key><string>${TAG}</string>
<key>CFBundleShortVersionString</key><string>${TAG}</string>
<key>CFBundlePackageType</key><string>APPL</string>
</dict></plist>
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[@]}"

View file

@ -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: |

View file

@ -9,6 +9,8 @@ A native C++ World of Warcraft client with a custom Vulkan renderer.
[![Sponsor](https://img.shields.io/github/sponsors/Kelsidavis?label=Sponsor&logo=GitHub)](https://github.com/sponsors/Kelsidavis)
[![Discord](https://img.shields.io/discord/1?label=Discord&logo=discord)](https://discord.gg/SDqjA79B)
[![Watch the video](https://img.youtube.com/vi/B-jtpPmiXGM/maxresdefault.jpg)](https://youtu.be/B-jtpPmiXGM)
[![Watch the video](https://img.youtube.com/vi/Pd9JuYYxu0o/maxresdefault.jpg)](https://youtu.be/Pd9JuYYxu0o)
[![Watch the video](https://img.youtube.com/vi/J4NXegzqWSQ/maxresdefault.jpg)](https://youtu.be/J4NXegzqWSQ)

View file

@ -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"

View file

@ -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

View file

@ -4,6 +4,8 @@
#include <string>
#include <vector>
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;

View file

@ -123,6 +123,41 @@ struct PendingTile {
std::unordered_map<std::string, pipeline::BLPImage> 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<PendingTile> 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<uint32_t> m2InstanceIds;
std::vector<uint32_t> wmoInstanceIds;
std::vector<uint32_t> tileUniqueIds;
std::vector<uint32_t> tileWmoUniqueIds;
std::unordered_set<uint32_t> uploadedM2ModelIds;
};
/**
* Terrain manager for multi-tile terrain streaming
*
@ -219,8 +254,8 @@ public:
int getLoadedTileCount() const { return static_cast<int>(loadedTiles.size()); }
int getPendingTileCount() const { return static_cast<int>(pendingTiles.size()); }
int getReadyQueueCount() const { return static_cast<int>(readyQueue.size()); }
/** Total unfinished tiles (worker threads + ready queue) */
int getRemainingTileCount() const { return static_cast<int>(pendingTiles.size() + readyQueue.size()); }
/** Total unfinished tiles (worker threads + ready queue + finalizing) */
int getRemainingTileCount() const { return static_cast<int>(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<PendingTile> 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<PendingTile>& 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<uint32_t> placedWmoIds;
// Progressive M2 upload queue (spread heavy uploads across frames)
struct PendingM2Upload {
uint32_t modelId;
pipeline::M2Model model;
std::string path;
};
std::queue<PendingM2Upload> 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<FinalizingTile> finalizingTiles_;
struct GroundEffectEntry {
std::array<uint32_t, 4> doodadIds{{0, 0, 0, 0}};

View file

@ -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;

View file

@ -55,6 +55,21 @@
#include <set>
#include <filesystem>
#include <thread>
#ifdef __linux__
#include <sched.h>
#include <pthread.h>
#elif defined(_WIN32)
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#elif defined(__APPLE__)
#include <mach/mach.h>
#include <mach/thread_policy.h>
#include <pthread.h>
#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<int>(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<thread_policy_t>(&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) {

View file

@ -529,34 +529,23 @@ bool WardenModule::parseExecutableFormat(const std::vector<uint8_t>& 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<uint8_t>& 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

View file

@ -294,10 +294,36 @@ std::shared_ptr<DBCFile> 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_t>(size));
f.read(reinterpret_cast<char*>(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<DBCFile>();

View file

@ -5,6 +5,7 @@
#include <imgui_internal.h>
#include <imgui_impl_vulkan.h>
#include <imgui_impl_sdl2.h>
#include <SDL2/SDL.h>
#include <random>
#include <chrono>
#include <cstdio>
@ -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) {

View file

@ -22,6 +22,20 @@
#include <functional>
#include <unordered_set>
#ifdef __linux__
#include <sched.h>
#include <pthread.h>
#elif defined(_WIN32)
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#elif defined(__APPLE__)
#include <mach/mach.h>
#include <mach/thread_policy.h>
#include <pthread.h>
#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<PendingTile> 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<MapChunk,256> ≈ 280 KB; macOS worker
// threads default to 512 KB stack, so two on-stack copies would overflow)
auto terrainPtr = std::make_unique<pipeline::ADTTerrain>(pipeline::ADTLoader::load(adtData));
if (!terrainPtr->isLoaded()) {
LOG_ERROR("Failed to parse ADT terrain: ", adtPath);
return nullptr;
}
@ -282,47 +300,47 @@ std::shared_ptr<PendingTile> 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<uint32_t>(terrain.doodadNames.size());
const uint32_t wmoNameBase = static_cast<uint32_t>(terrain.wmoNames.size());
auto objTerrain = std::make_unique<pipeline::ADTTerrain>(pipeline::ADTLoader::load(objData));
if (objTerrain->isLoaded()) {
const uint32_t doodadNameBase = static_cast<uint32_t>(terrainPtr->doodadNames.size());
const uint32_t wmoNameBase = static_cast<uint32_t>(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<uint32_t> 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<uint32_t> 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<PendingTile> 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<PendingTile> TerrainManager::prepareTile(int x, int y) {
auto pending = std::make_shared<PendingTile>();
pending->coord = coord;
pending->terrain = std::move(terrain);
pending->terrain = std::move(*terrainPtr);
pending->mesh = std::move(mesh);
std::unordered_set<uint32_t> preparedModelIds;
@ -646,176 +664,157 @@ void TerrainManager::logMissingAdtOnce(const std::string& adtPath) {
}
}
void TerrainManager::finalizeTile(const std::shared_ptr<PendingTile>& 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<std::mutex> 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<std::mutex> 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<uint32_t> m2InstanceIds;
std::vector<uint32_t> wmoInstanceIds;
std::vector<uint32_t> tileUniqueIds;
std::vector<uint32_t> 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<uint32_t> 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<PendingTile>& 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<audio::AmbientSoundManager::AmbientType>(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<audio::AmbientSoundManager::AmbientType>(emitter.type);
ambientSoundManager->addEmitter(emitter.position, type);
}
}
// Commit tile to loadedTiles
auto tile = std::make_unique<TerrainTile>();
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<std::mutex> 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<TerrainTile>();
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<int>(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<DWORD_PTR>(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<thread_policy_t>(&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<PendingTile> pending;
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> lock(queueMutex);
pendingTiles.erase(coord);
}
processed++;
// Check if we've exceeded time budget
float elapsedMs = std::chrono::duration<float, std::milli>(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<float, std::milli>(now - startTime).count();
if (elapsedMs >= timeBudgetMs) {
break;
}
}
}
void TerrainManager::processAllReadyTiles() {
while (true) {
std::shared_ptr<PendingTile> pending;
{
std::lock_guard<std::mutex> 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<std::mutex> lock(queueMutex);
while (!readyQueue.empty()) {
auto pending = readyQueue.front();
readyQueue.pop();
}
if (pending) {
TileCoord coord = pending->coord;
finalizeTile(pending);
{
std::lock_guard<std::mutex> 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<PendingTile> 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");

View file

@ -14,6 +14,7 @@
#include <cstring>
#include <limits>
#include <array>
#include <unordered_map>
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>()((uint64_t(k.liquidType) << 32) | uint32_t(k.roundedHeight));
}
};
std::unordered_map<MergeKey, std::vector<ChunkLayerInfo>, 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<int32_t>(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<float>(layer.y) * TILE_SIZE),
surface.position.y - (static_cast<float>(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<float>(layer.y) * TILE_SIZE),
surface.position.y - (static_cast<float>(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<size_t>(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<int>(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<uint8_t>(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<int>(surface.xOffset) + x;
int cy = static_cast<int>(surface.yOffset) + y;
tileIndex = cy * 8 + cx;
@ -959,9 +1133,14 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) {
int bitIndex = tileIndex % 8;
if (byteIndex < static_cast<int>(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<int>(surface.yOffset) + ny) * 8 +
(static_cast<int>(surface.xOffset) + nx);
} else {
@ -980,9 +1159,16 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) {
int nBitIdx = neighborIdx % 8;
if (nByteIdx < static_cast<int>(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<float> 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<int>(surface.yOffset) + iy) * 8 +
(static_cast<int>(surface.xOffset) + ix);
} else {
@ -1110,7 +1296,12 @@ std::optional<float> WaterRenderer::getWaterHeightAt(float glX, float glY) const
int bitIndex = tileIndex % 8;
if (byteIndex < static_cast<int>(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<float> 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<int>(surface.yOffset) + iy) * 8 +
(static_cast<int>(surface.xOffset) + ix);
} else {
@ -1172,7 +1363,12 @@ std::optional<float> WaterRenderer::getNearestWaterHeightAt(float glX, float glY
int bitIndex = tileIndex % 8;
if (byteIndex < static_cast<int>(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<uint16_t> 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<int>(surface.yOffset) + iy) * 8 +
(static_cast<int>(surface.xOffset) + ix);
} else {
@ -1238,7 +1434,12 @@ std::optional<uint16_t> WaterRenderer::getWaterTypeAt(float glX, float glY) cons
int bitIndex = tileIndex % 8;
if (byteIndex < static_cast<int>(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;
}
}