mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-22 23:30:14 +00:00
Compare commits
56 commits
4be7910fdf
...
6dd7213083
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dd7213083 | ||
|
|
7dbf950323 | ||
|
|
711cb966ef | ||
|
|
14f672ab6a | ||
|
|
b5291d1883 | ||
|
|
510370dc7b | ||
|
|
9b9d56543c | ||
|
|
182b6686ac | ||
|
|
68a379610e | ||
|
|
f6f072a957 | ||
|
|
eef269ffb8 | ||
|
|
b5a48729b8 | ||
|
|
b7479cbb50 | ||
|
|
eb3cdbcc5f | ||
|
|
f7c752a316 | ||
|
|
4d0eef1f6f | ||
|
|
bfeb978eff | ||
|
|
0c8fb94f0c | ||
|
|
3f0e19970e | ||
|
|
047b9157ad | ||
|
|
e2e049b718 | ||
|
|
17bf963f3e | ||
|
|
2b8bb76d7a | ||
|
|
1598766b1e | ||
|
|
c77bd15538 | ||
|
|
90e7d61b6d | ||
|
|
6f7c57d975 | ||
|
|
6a8939d420 | ||
|
|
80c4e77c12 | ||
|
|
1979aa926b | ||
|
|
26f1a2d606 | ||
|
|
3849ad75ce | ||
|
|
2c67331bc3 | ||
|
|
6fa1e49cb2 | ||
|
|
9892d82c52 | ||
|
|
b699557597 | ||
|
|
6e94a3345f | ||
|
|
4f3e817913 | ||
|
|
efc394ce9e | ||
|
|
1d4f69add3 | ||
|
|
68b3cef0fe | ||
|
|
7034bc5f63 | ||
|
|
164124783b | ||
|
|
98739c1610 | ||
|
|
2f1b142e14 | ||
|
|
e464300346 | ||
|
|
73abbc2a08 | ||
|
|
d1414b6a46 | ||
|
|
f472ee3be8 | ||
|
|
d7e1a3773c | ||
|
|
d14f82cb7c | ||
|
|
fe2987dae1 | ||
|
|
2c25e08a25 | ||
|
|
a10e3e86fb | ||
|
|
508b7e839b | ||
|
|
6426bde7ea |
21 changed files with 2150 additions and 182 deletions
126
EXPANSION_GUIDE.md
Normal file
126
EXPANSION_GUIDE.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Multi-Expansion Architecture Guide
|
||||
|
||||
WoWee supports three World of Warcraft expansions in a unified codebase using an expansion profile system. This guide explains how the multi-expansion support works.
|
||||
|
||||
## Supported Expansions
|
||||
|
||||
- **Vanilla (Classic) 1.12** - Original World of Warcraft
|
||||
- **The Burning Crusade (TBC) 2.4.3** - First expansion
|
||||
- **Wrath of the Lich King (WotLK) 3.3.5a** - Second expansion
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The multi-expansion support is built on the **Expansion Profile** system:
|
||||
|
||||
1. **ExpansionProfile** (`include/game/expansion_profile.hpp`) - Metadata about each expansion
|
||||
- Defines protocol version, data paths, asset locations
|
||||
- Specifies which packet parsers to use
|
||||
|
||||
2. **Packet Parsers** - Expansion-specific message handling
|
||||
- `packet_parsers_classic.cpp` - Vanilla 1.12 message parsing
|
||||
- `packet_parsers_tbc.cpp` - TBC 2.4.3 message parsing
|
||||
- `packet_parsers_wotlk.cpp` (default) - WotLK 3.3.5a message parsing
|
||||
|
||||
3. **Update Fields** - Expansion-specific entity data layout
|
||||
- Loaded from `update_fields.json` in expansion data directory
|
||||
- Defines UNIT_END, OBJECT_END, field indices for stats/health/mana
|
||||
|
||||
## How to Use Different Expansions
|
||||
|
||||
### At Startup
|
||||
|
||||
WoWee auto-detects the expansion based on:
|
||||
1. Realm list response (protocol version)
|
||||
2. Server build number
|
||||
3. Update field count
|
||||
|
||||
### Manual Selection
|
||||
|
||||
Set environment variable:
|
||||
```bash
|
||||
WOWEE_EXPANSION=tbc ./wowee # Force TBC
|
||||
WOWEE_EXPANSION=classic ./wowee # Force Classic
|
||||
```
|
||||
|
||||
## Key Differences Between Expansions
|
||||
|
||||
### Packet Format Differences
|
||||
|
||||
#### SMSG_SPELL_COOLDOWN
|
||||
- **Classic**: 12 bytes per entry (spellId + itemId + cooldown, no flags)
|
||||
- **TBC/WotLK**: 8 bytes per entry (spellId + cooldown) + flags byte
|
||||
|
||||
#### SMSG_ACTION_BUTTONS
|
||||
- **Classic**: 120 slots, no mode byte
|
||||
- **TBC**: 132 slots, no mode byte
|
||||
- **WotLK**: 144 slots + uint8 mode byte
|
||||
|
||||
#### SMSG_PARTY_MEMBER_STATS
|
||||
- **Classic/TBC**: Full uint64 for guid, uint16 health
|
||||
- **WotLK**: PackedGuid format, uint32 health
|
||||
|
||||
### Data Differences
|
||||
|
||||
- **Talent trees**: Different spell IDs and tree structure per expansion
|
||||
- **Items**: Different ItemDisplayInfo entries
|
||||
- **Spells**: Different base stats, cooldowns
|
||||
- **Character textures**: Expansion-specific variants for races
|
||||
|
||||
## Adding Support for Another Expansion
|
||||
|
||||
1. Create new expansion profile entry in `expansion_profile.cpp`
|
||||
2. Add packet parser file (`packet_parsers_*.cpp`) for message variants
|
||||
3. Create update_fields.json with correct field layout
|
||||
4. Test realm connection and character loading
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Checking Current Expansion
|
||||
|
||||
```cpp
|
||||
#include "game/expansion_profile.hpp"
|
||||
|
||||
// Global helper
|
||||
bool isClassicLikeExpansion() {
|
||||
auto profile = ExpansionProfile::getActive();
|
||||
return profile && (profile->name == "Classic" || profile->name == "Vanilla");
|
||||
}
|
||||
|
||||
// Specific check
|
||||
if (GameHandler::getInstance().isActiveExpansion("tbc")) {
|
||||
// TBC-specific code
|
||||
}
|
||||
```
|
||||
|
||||
### Expansion-Specific Packet Parsing
|
||||
|
||||
```cpp
|
||||
// In packet_parsers_*.cpp, implement expansion-specific logic
|
||||
bool parseXxxPacket(BitStream& data, ...) {
|
||||
// Custom logic for this expansion's packet format
|
||||
}
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### "Update fields mismatch" Error
|
||||
- Ensure `update_fields.json` matches server's field layout
|
||||
- Check OBJECT_END and UNIT_END values
|
||||
- Verify field indices for your target expansion
|
||||
|
||||
### "Unknown packet" Warnings
|
||||
- Expansion-specific opcodes may not be registered
|
||||
- Check packet parser registration in `game_handler.cpp`
|
||||
- Verify expansion profile is active
|
||||
|
||||
### Packet Parsing Failures
|
||||
- Each expansion has different struct layouts
|
||||
- Always read data size first, then upfront validate
|
||||
- Use size capping (e.g., max 100 items in list)
|
||||
|
||||
## References
|
||||
|
||||
- `include/game/expansion_profile.hpp` - Expansion metadata
|
||||
- `docs/status.md` - Current feature support by expansion
|
||||
- `src/game/packet_parsers_*.cpp` - Format-specific parsing logic
|
||||
- `docs/` directory - Additional protocol documentation
|
||||
218
GETTING_STARTED.md
Normal file
218
GETTING_STARTED.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# Getting Started with WoWee
|
||||
|
||||
WoWee is a native C++ World of Warcraft client that connects to private servers. This guide walks you through setting up and playing WoWee.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **World of Warcraft Game Data** (Vanilla 1.12, TBC 2.4.3, or WotLK 3.3.5a)
|
||||
- **A Private Server** (AzerothCore, TrinityCore, Mangos, or Turtle WoW compatible)
|
||||
- **System Requirements**: Linux, macOS, or Windows with a Vulkan-capable GPU
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1: Build WoWee
|
||||
|
||||
See [Building](README.md#building) section in README for detailed build instructions.
|
||||
|
||||
**Quick start (Linux/macOS)**:
|
||||
```bash
|
||||
./build.sh
|
||||
cd build/bin
|
||||
./wowee
|
||||
```
|
||||
|
||||
**Quick start (Windows)**:
|
||||
```powershell
|
||||
.\build.ps1
|
||||
cd build\bin
|
||||
.\wowee.exe
|
||||
```
|
||||
|
||||
### Step 2: Extract Game Data
|
||||
|
||||
WoWee needs game assets from your WoW installation:
|
||||
|
||||
**Using provided script (Linux/macOS)**:
|
||||
```bash
|
||||
./extract_assets.sh /path/to/wow/directory
|
||||
```
|
||||
|
||||
**Using provided script (Windows)**:
|
||||
```powershell
|
||||
.\extract_assets.ps1 -WowDirectory "C:\Program Files\World of Warcraft"
|
||||
```
|
||||
|
||||
**Manual extraction**:
|
||||
1. Install [StormLib](https://github.com/ladislav-zezula/StormLib)
|
||||
2. Extract to `./Data/`:
|
||||
```
|
||||
Data/
|
||||
├── dbc/ # DBC files
|
||||
├── map/ # World map data
|
||||
├── adt/ # Terrain chunks
|
||||
├── wmo/ # Building models
|
||||
├── m2/ # Character/creature models
|
||||
└── blp/ # Textures
|
||||
```
|
||||
|
||||
### Step 3: Connect to a Server
|
||||
|
||||
1. **Start WoWee**
|
||||
```bash
|
||||
cd build/bin && ./wowee
|
||||
```
|
||||
|
||||
2. **Enter Realm Information**
|
||||
- Server Address: e.g., `localhost:3724` or `play.example.com:3724`
|
||||
- WoWee fetches the realm list automatically
|
||||
- Select your realm and click **Connect**
|
||||
|
||||
3. **Choose Character**
|
||||
- Select existing character or create new one
|
||||
- Customize appearance and settings
|
||||
- Click **Enter World**
|
||||
|
||||
## First Steps in Game
|
||||
|
||||
### Default Controls
|
||||
|
||||
| Action | Key |
|
||||
|--------|-----|
|
||||
| Move Forward | W |
|
||||
| Move Backward | S |
|
||||
| Strafe Left | A |
|
||||
| Strafe Right | D |
|
||||
| Jump | Space |
|
||||
| Toggle Chat | Enter |
|
||||
| Interact (talk to NPC, loot) | F |
|
||||
| Open Inventory | B |
|
||||
| Open Spellbook | P |
|
||||
| Open Talent Tree | T |
|
||||
| Open Quest Log | Q |
|
||||
| Open World Map | W (when not typing) |
|
||||
| Toggle Minimap | M |
|
||||
| Toggle Nameplates | V |
|
||||
| Toggle Party Frames | F |
|
||||
| Toggle Settings | Escape |
|
||||
| Target Next Enemy | Tab |
|
||||
| Target Previous Enemy | Shift+Tab |
|
||||
|
||||
### Customizing Controls
|
||||
|
||||
Press **Escape** → **Keybindings** to customize hotkeys.
|
||||
|
||||
## Recommended First Steps
|
||||
|
||||
### 1. Adjust Graphics Settings
|
||||
- Press Escape → **Video Settings**
|
||||
- Select appropriate **Graphics Preset** for your GPU:
|
||||
- **LOW**: Low-end GPUs or when performance is priority
|
||||
- **MEDIUM**: Balanced quality and performance
|
||||
- **HIGH**: Good GPU with modern drivers
|
||||
- **ULTRA**: High-end GPU for maximum quality
|
||||
|
||||
### 2. Adjust Audio
|
||||
- Press Escape → **Audio Settings**
|
||||
- Set **Master Volume** to preferred level
|
||||
- Adjust individual audio tracks (Music, Ambient, UI, etc.)
|
||||
- Toggle **Original Soundtrack** if available
|
||||
|
||||
### 3. Configure UI
|
||||
- Press Escape → **Game Settings**
|
||||
- Minimap preferences (rotation, square mode, zoom)
|
||||
- Bag settings (separate windows, compact mode)
|
||||
- Action bar visibility
|
||||
|
||||
### 4. Complete First Quest
|
||||
- Talk to nearby NPCs (they have quest markers ! or ?)
|
||||
- Accept quest, complete objectives, return for reward
|
||||
- Level up and gain experience
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Data Directory
|
||||
Game data is loaded from `Data/` subdirectory:
|
||||
- If running from build folder: `../../Data` (symlinked automatically)
|
||||
- If running from binary folder: `./Data` (must exist)
|
||||
- If running in-place: Ensure `Data/` is in correct location
|
||||
|
||||
### Settings
|
||||
- Settings are saved to `~/.wowee/settings.cfg` (Linux/macOS)
|
||||
- Or `%APPDATA%\wowee\settings.cfg` (Windows)
|
||||
- Keybindings, graphics settings, and UI state persist
|
||||
|
||||
### Multi-Expansion Support
|
||||
WoWee auto-detects expansion from server:
|
||||
- **Vanilla 1.12** - Original game
|
||||
- **TBC 2.4.3** - Burning Crusade
|
||||
- **WotLK 3.3.5a** - Wrath of the Lich King
|
||||
|
||||
You can override with environment variable:
|
||||
```bash
|
||||
WOWEE_EXPANSION=tbc ./wowee # Force TBC
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No realm list" or "Connection Failed"
|
||||
- Check server address is correct
|
||||
- Verify server is running
|
||||
- See [Troubleshooting Guide](TROUBLESHOOTING.md#connection-issues)
|
||||
|
||||
### Graphics Errors
|
||||
- See [Graphics Troubleshooting](TROUBLESHOOTING.md#graphics-issues)
|
||||
- Start with LOW graphics preset
|
||||
- Update GPU driver
|
||||
|
||||
### Audio Not Working
|
||||
- Check system audio is enabled
|
||||
- Verify audio files are extracted
|
||||
- See [Audio Troubleshooting](TROUBLESHOOTING.md#audio-issues)
|
||||
|
||||
### General Issues
|
||||
- Comprehensive troubleshooting: See [TROUBLESHOOTING.md](TROUBLESHOOTING.md)
|
||||
- Check logs in `~/.wowee/logs/` for errors
|
||||
- Verify expansion matches server requirements
|
||||
|
||||
## Server Configuration
|
||||
|
||||
### Tested Servers
|
||||
- **AzerothCore** - Full support, recommended for learning
|
||||
- **TrinityCore** - Full support, extensive customization
|
||||
- **Mangos** - Full support, solid foundation
|
||||
- **Turtle WoW** - Full support, 1.17 custom content
|
||||
|
||||
### Server Requirements
|
||||
- Must support Vanilla, TBC, or WotLK protocol
|
||||
- Warden anti-cheat supported (module execution via emulation)
|
||||
- Network must allow connections to realm list and world server ports
|
||||
|
||||
See [Multi-Expansion Guide](EXPANSION_GUIDE.md) for protocol details.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Explore the World** - Travel to different zones and enjoy the landscape
|
||||
2. **Join a Guild** - Find other players to group with
|
||||
3. **Run Dungeons** - Experience instanced content
|
||||
4. **PvP** - Engage in player-versus-player combat
|
||||
5. **Twink Alt** - Create additional characters
|
||||
6. **Customize Settings** - Fine-tune graphics, audio, and UI
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Game Issues**: See [TROUBLESHOOTING.md](TROUBLESHOOTING.md)
|
||||
- **Graphics Help**: See [Graphics & Performance](README.md#graphics--performance) section
|
||||
- **Multi-Expansion**: See [EXPANSION_GUIDE.md](EXPANSION_GUIDE.md)
|
||||
- **Building Issues**: See [README.md](README.md#building)
|
||||
|
||||
## Tips for Better Performance
|
||||
|
||||
- Start with reasonable graphics preset for your GPU
|
||||
- Close other applications when testing
|
||||
- Keep GPU drivers updated
|
||||
- Use FSR2 (if supported) for smooth 60+ FPS on weaker hardware
|
||||
- Monitor frame rate with debug overlay (if available)
|
||||
|
||||
## Enjoy!
|
||||
|
||||
WoWee is a project to experience classic World of Warcraft on a modern engine. Have fun exploring Azeroth!
|
||||
25
README.md
25
README.md
|
|
@ -69,7 +69,30 @@ Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**.
|
|||
- **Pets** -- Pet tracking via SMSG_PET_SPELLS, action bar (10 slots with icon/autocast tinting/tooltips), dismiss button
|
||||
- **Map Exploration** -- Subzone-level fog-of-war reveal matching retail behavior
|
||||
- **Warden** -- Warden anti-cheat module execution via Unicorn Engine x86 emulation (cross-platform, no Wine)
|
||||
- **UI** -- Loading screens with progress bar, settings window (shadow distance slider), minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view)
|
||||
- **UI** -- Loading screens with progress bar, settings window with graphics quality presets (LOW/MEDIUM/HIGH/ULTRA), shadow distance slider, minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view)
|
||||
|
||||
## Graphics & Performance
|
||||
|
||||
### Quality Presets
|
||||
|
||||
WoWee includes four built-in graphics quality presets to help you quickly balance visual quality and performance:
|
||||
|
||||
| Preset | Shadows | MSAA | Normal Mapping | Clutter Density |
|
||||
|--------|---------|------|----------------|-----------------|
|
||||
| **LOW** | Off | Off | Disabled | 25% |
|
||||
| **MEDIUM** | 200m distance | 2x | Basic | 60% |
|
||||
| **HIGH** | 350m distance | 4x | Full (0.8x) | 100% |
|
||||
| **ULTRA** | 500m distance | 8x | Enhanced (1.2x) | 150% |
|
||||
|
||||
Press Escape to open **Video Settings** and select a preset, or adjust individual settings for a custom configuration.
|
||||
|
||||
### Performance Tips
|
||||
|
||||
- Start with **LOW** or **MEDIUM** if you experience frame drops
|
||||
- Shadows and MSAA have the largest impact on performance
|
||||
- Reduce **shadow distance** if shadows cause issues
|
||||
- Disable **water refraction** if you encounter GPU errors (requires FSR to be active)
|
||||
- Use **FSR2** (built-in upscaling) for better frame rates on modern GPUs
|
||||
|
||||
## Building
|
||||
|
||||
|
|
|
|||
186
TROUBLESHOOTING.md
Normal file
186
TROUBLESHOOTING.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
# Troubleshooting Guide
|
||||
|
||||
This guide covers common issues and solutions for WoWee.
|
||||
|
||||
## Connection Issues
|
||||
|
||||
### "Authentication Failed"
|
||||
- **Cause**: Incorrect server address, expired realm list, or version mismatch
|
||||
- **Solution**:
|
||||
1. Verify server address in realm list is correct
|
||||
2. Ensure your WoW data directory is for the correct expansion (Vanilla/TBC/WotLK)
|
||||
3. Check that the emulator server is running and reachable
|
||||
|
||||
### "Realm List Connection Failed"
|
||||
- **Cause**: Server is down, firewall blocking connection, or DNS issue
|
||||
- **Solution**:
|
||||
1. Verify server IP/hostname is correct
|
||||
2. Test connectivity: `ping realm-server-address`
|
||||
3. Check firewall rules for port 3724 (auth) and 8085 (realm list)
|
||||
4. Try using IP address instead of hostname (DNS issues)
|
||||
|
||||
### "Connection Lost During Login"
|
||||
- **Cause**: Network timeout, server overload, or incompatible protocol version
|
||||
- **Solution**:
|
||||
1. Check your network connection
|
||||
2. Reduce number of assets loading (lower graphics preset)
|
||||
3. Verify server supports this expansion version
|
||||
|
||||
## Graphics Issues
|
||||
|
||||
### "VK_ERROR_DEVICE_LOST" or Client Crashes
|
||||
- **Cause**: GPU driver issue, insufficient VRAM, or graphics feature incompatibility
|
||||
- **Solution**:
|
||||
1. **Immediate**: Disable advanced graphics features:
|
||||
- Press Escape → Video Settings
|
||||
- Set graphics preset to **LOW**
|
||||
- Disable Water Refraction (requires FSR)
|
||||
- Disable MSAA (set to Off)
|
||||
2. **Medium term**: Update GPU driver to latest version
|
||||
3. **Verify**: Use a graphics test tool to ensure GPU stability
|
||||
4. **If persists**: Try FSR2 disabled mode - check renderer logs
|
||||
|
||||
### Black Screen or Rendering Issues
|
||||
- **Cause**: Missing shaders, GPU memory allocation failure, or incorrect graphics settings
|
||||
- **Solution**:
|
||||
1. Check logs: Look in `~/.wowee/logs/` for error messages
|
||||
2. Verify shaders compiled: Check for `.spv` files in `assets/shaders/compiled/`
|
||||
3. Reduce shadow distance: Press Escape → Video Settings → Lower shadow distance from 300m to 100m
|
||||
4. Disable shadows entirely if issues persist
|
||||
|
||||
### Low FPS or Frame Stuttering
|
||||
- **Cause**: Too high graphics settings for your GPU, memory fragmentation, or asset loading
|
||||
- **Solution**:
|
||||
1. Apply lower graphics preset: Escape → LOW or MEDIUM
|
||||
2. Disable MSAA: Set to "Off"
|
||||
3. Reduce draw distance: Move further away from complex areas
|
||||
4. Close other applications consuming GPU memory
|
||||
5. Check CPU usage - if high, reduce number of visible entities
|
||||
|
||||
### Water/Terrain Flickering
|
||||
- **Cause**: Shadow mapping artifacts, terrain LOD issues, or GPU memory pressure
|
||||
- **Solution**:
|
||||
1. Increase shadow distance slightly (150m to 200m)
|
||||
2. Disable shadows entirely as last resort
|
||||
3. Check GPU memory usage
|
||||
|
||||
## Audio Issues
|
||||
|
||||
### No Sound
|
||||
- **Cause**: Audio initialization failed, missing audio data, or incorrect mixer setup
|
||||
- **Solution**:
|
||||
1. Check system audio is working: Test with another application
|
||||
2. Verify audio files extracted: Check for `.wav` files in `Data/Audio/`
|
||||
3. Unmute audio: Look for speaker icon in minimap (top-right) - click to unmute
|
||||
4. Check settings: Escape → Audio Settings → Master Volume > 0
|
||||
|
||||
### Sound Cutting Out
|
||||
- **Cause**: Audio buffer underrun, too many simultaneous sounds, or driver issue
|
||||
- **Solution**:
|
||||
1. Lower audio volume: Escape → Audio Settings → Reduce Master Volume
|
||||
2. Disable distant ambient sounds: Reduce Ambient Volume
|
||||
3. Reduce number of particle effects
|
||||
4. Update audio driver
|
||||
|
||||
## Gameplay Issues
|
||||
|
||||
### Character Stuck or Not Moving
|
||||
- **Cause**: Network synchronization issue, collision bug, or server desync
|
||||
- **Solution**:
|
||||
1. Try pressing Escape to deselect any target, then move
|
||||
2. Jump (Spacebar) to test physics
|
||||
3. Reload the character: Press Escape → Disconnect → Reconnect
|
||||
4. Check for transport/vehicle state: Press 'R' to dismount if applicable
|
||||
|
||||
### Spells Not Casting or Showing "Error"
|
||||
- **Cause**: Cooldown, mana insufficient, target out of range, or server desync
|
||||
- **Solution**:
|
||||
1. Verify spell is off cooldown (action bar shows availability)
|
||||
2. Check mana/energy: Look at player frame (top-left)
|
||||
3. Verify target range: Hover action bar button for range info
|
||||
4. Check server logs for error messages (combat log will show reason)
|
||||
|
||||
### Quests Not Updating
|
||||
- **Cause**: Objective already completed in different session, quest giver not found, or network desync
|
||||
- **Solution**:
|
||||
1. Check quest objective: Open quest log (Q key) → Verify objective requirements
|
||||
2. Re-interact with NPC to trigger update packet
|
||||
3. Reload character if issue persists
|
||||
|
||||
### Items Not Appearing in Inventory
|
||||
- **Cause**: Inventory full, item filter active, or network desync
|
||||
- **Solution**:
|
||||
1. Check inventory space: Open inventory (B key) → Count free slots
|
||||
2. Verify item isn't already there: Search inventory for item name
|
||||
3. Check if bags are full: Open bag windows, consolidate items
|
||||
4. Reload character if item is still missing
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### For Low-End GPUs
|
||||
```
|
||||
Graphics Preset: LOW
|
||||
- Shadows: OFF
|
||||
- MSAA: OFF
|
||||
- Normal Mapping: Disabled
|
||||
- Clutter Density: 25%
|
||||
- Draw Distance: Minimum
|
||||
- Particles: Reduced
|
||||
```
|
||||
|
||||
### For Mid-Range GPUs
|
||||
```
|
||||
Graphics Preset: MEDIUM
|
||||
- Shadows: 200m
|
||||
- MSAA: 2x
|
||||
- Normal Mapping: Basic
|
||||
- Clutter Density: 60%
|
||||
- FSR2: Enabled (if desired)
|
||||
```
|
||||
|
||||
### For High-End GPUs
|
||||
```
|
||||
Graphics Preset: HIGH or ULTRA
|
||||
- Shadows: 350-500m
|
||||
- MSAA: 4-8x
|
||||
- Normal Mapping: Full (1.2x strength)
|
||||
- Clutter Density: 100-150%
|
||||
- FSR2: Optional (for 4K smoothness)
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Check Logs
|
||||
Detailed logs are saved to:
|
||||
- **Linux/macOS**: `~/.wowee/logs/`
|
||||
- **Windows**: `%APPDATA%\wowee\logs\`
|
||||
|
||||
Include relevant log entries when reporting issues.
|
||||
|
||||
### Check Server Compatibility
|
||||
- **AzerothCore**: Full support
|
||||
- **TrinityCore**: Full support
|
||||
- **Mangos**: Full support
|
||||
- **Turtle WoW**: Full support (1.17)
|
||||
|
||||
### Report Issues
|
||||
If you encounter a bug:
|
||||
1. Enable logging: Watch console for error messages
|
||||
2. Reproduce the issue consistently
|
||||
3. Gather system info: GPU, driver version, OS
|
||||
4. Check if issue is expansion-specific (Classic/TBC/WotLK)
|
||||
5. Report with detailed steps to reproduce
|
||||
|
||||
### Clear Cache
|
||||
If experiencing persistent issues, clear WoWee's cache:
|
||||
```bash
|
||||
# Linux/macOS
|
||||
rm -rf ~/.wowee/warden_cache/
|
||||
rm -rf ~/.wowee/asset_cache/
|
||||
|
||||
# Windows
|
||||
rmdir %APPDATA%\wowee\warden_cache\ /s
|
||||
rmdir %APPDATA%\wowee\asset_cache\ /s
|
||||
```
|
||||
|
||||
Then restart WoWee to rebuild cache.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Project Status
|
||||
|
||||
**Last updated**: 2026-03-07
|
||||
**Last updated**: 2026-03-11
|
||||
|
||||
## What This Repo Is
|
||||
|
||||
|
|
@ -35,10 +35,9 @@ Implemented (working in normal use):
|
|||
In progress / known gaps:
|
||||
|
||||
- Transports: M2 transports (trams) working with position-delta riding; WMO transports (ships, zeppelins) working with path following; some edge cases remain
|
||||
- 3D positional audio: not implemented (mono/stereo only)
|
||||
- Visual edge cases: some M2/WMO rendering gaps (character shin mesh, some particle effects)
|
||||
- Interior rendering: WMO interior shadows disabled (too dark); lava steam particles sparse
|
||||
- Water refraction: implemented but disabled by default (can cause VK_ERROR_DEVICE_LOST on some GPUs)
|
||||
- Lava steam particles: sparse in some areas (tuning opportunity)
|
||||
- Water refraction: implemented but disabled by default (can cause VK_ERROR_DEVICE_LOST on some GPUs); currently requires FSR to be active
|
||||
|
||||
## Where To Look
|
||||
|
||||
|
|
|
|||
|
|
@ -825,6 +825,11 @@ public:
|
|||
glm::vec3 getComposedWorldPosition(); // Compose transport transform * local offset
|
||||
TransportManager* getTransportManager() { return transportManager_.get(); }
|
||||
void setPlayerOnTransport(uint64_t transportGuid, const glm::vec3& localOffset) {
|
||||
// Validate transport is registered before attaching player
|
||||
// (defer if transport not yet registered to prevent desyncs)
|
||||
if (transportGuid != 0 && !isTransportGuid(transportGuid)) {
|
||||
return; // Transport not yet registered; skip attachment
|
||||
}
|
||||
playerTransportGuid_ = transportGuid;
|
||||
playerTransportOffset_ = localOffset;
|
||||
playerTransportStickyGuid_ = transportGuid;
|
||||
|
|
@ -1063,7 +1068,18 @@ public:
|
|||
void closeGossip();
|
||||
bool isGossipWindowOpen() const { return gossipWindowOpen; }
|
||||
const GossipMessageData& getCurrentGossip() const { return currentGossip; }
|
||||
bool isQuestDetailsOpen() const { return questDetailsOpen; }
|
||||
bool isQuestDetailsOpen() {
|
||||
// Check if delayed opening timer has expired
|
||||
if (questDetailsOpen) return true;
|
||||
if (questDetailsOpenTime != std::chrono::steady_clock::time_point{}) {
|
||||
if (std::chrono::steady_clock::now() >= questDetailsOpenTime) {
|
||||
questDetailsOpen = true;
|
||||
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const QuestDetailsData& getQuestDetails() const { return currentQuestDetails; }
|
||||
|
||||
// Gossip / quest map POI markers (SMSG_GOSSIP_POI)
|
||||
|
|
@ -1846,6 +1862,7 @@ private:
|
|||
float timeSinceLastMoveHeartbeat_ = 0.0f; // Periodic movement heartbeat to keep server position synced
|
||||
float moveHeartbeatInterval_ = 0.5f;
|
||||
uint32_t lastLatency = 0; // Last measured latency (milliseconds)
|
||||
std::chrono::steady_clock::time_point pingTimestamp_; // Time CMSG_PING was sent
|
||||
|
||||
// Player GUID and map
|
||||
uint64_t playerGuid = 0;
|
||||
|
|
@ -2179,6 +2196,7 @@ private:
|
|||
|
||||
// Quest details
|
||||
bool questDetailsOpen = false;
|
||||
std::chrono::steady_clock::time_point questDetailsOpenTime{}; // Delayed opening to allow item data to load
|
||||
QuestDetailsData currentQuestDetails;
|
||||
|
||||
// Quest turn-in
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ private:
|
|||
bool pendingMinimapRotate = false;
|
||||
bool pendingMinimapSquare = false;
|
||||
bool pendingMinimapNpcDots = false;
|
||||
bool pendingShowLatencyMeter = true;
|
||||
bool pendingSeparateBags = true;
|
||||
bool pendingAutoLoot = false;
|
||||
|
||||
|
|
@ -143,11 +144,23 @@ private:
|
|||
bool pendingAMDFramegen = false;
|
||||
bool fsrSettingsApplied_ = false;
|
||||
|
||||
// Graphics quality presets
|
||||
enum class GraphicsPreset : int {
|
||||
CUSTOM = 0,
|
||||
LOW = 1,
|
||||
MEDIUM = 2,
|
||||
HIGH = 3,
|
||||
ULTRA = 4
|
||||
};
|
||||
GraphicsPreset currentGraphicsPreset = GraphicsPreset::CUSTOM;
|
||||
GraphicsPreset pendingGraphicsPreset = GraphicsPreset::CUSTOM;
|
||||
|
||||
// UI element transparency (0.0 = fully transparent, 1.0 = fully opaque)
|
||||
float uiOpacity_ = 0.65f;
|
||||
bool minimapRotate_ = false;
|
||||
bool minimapSquare_ = false;
|
||||
bool minimapNpcDots_ = false;
|
||||
bool showLatencyMeter_ = true; // Show server latency indicator
|
||||
bool minimapSettingsApplied_ = false;
|
||||
bool volumeSettingsApplied_ = false; // True once saved volume settings applied to audio managers
|
||||
bool msaaSettingsApplied_ = false; // True once saved MSAA setting applied to renderer
|
||||
|
|
@ -252,6 +265,8 @@ private:
|
|||
void renderTalentWipeConfirmDialog(game::GameHandler& gameHandler);
|
||||
void renderEscapeMenu();
|
||||
void renderSettingsWindow();
|
||||
void applyGraphicsPreset(GraphicsPreset preset);
|
||||
void updateGraphicsPresetFromCurrentSettings();
|
||||
void renderQuestMarkers(game::GameHandler& gameHandler);
|
||||
void renderMinimapMarkers(game::GameHandler& gameHandler);
|
||||
void renderQuestObjectiveTracker(game::GameHandler& gameHandler);
|
||||
|
|
|
|||
|
|
@ -6451,7 +6451,34 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
|
|||
|
||||
// Show tabard mesh only when CreatureDisplayInfoExtra equips one.
|
||||
if (hasGroup12 && hasEquippedTabard) {
|
||||
uint16_t tabardSid = pickFromGroup(1201, 12);
|
||||
uint16_t wantTabard = 1201; // Default fallback
|
||||
|
||||
// Try to read tabard geoset variant from ItemDisplayInfo.dbc (slot 9)
|
||||
if (hasHumanoidExtra && itDisplayData != displayDataMap_.end() &&
|
||||
itDisplayData->second.extraDisplayId != 0) {
|
||||
auto itExtra = humanoidExtraMap_.find(itDisplayData->second.extraDisplayId);
|
||||
if (itExtra != humanoidExtraMap_.end()) {
|
||||
uint32_t tabardDisplayId = itExtra->second.equipDisplayId[9];
|
||||
if (tabardDisplayId != 0) {
|
||||
auto itemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc");
|
||||
const auto* idiL = pipeline::getActiveDBCLayout()
|
||||
? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
|
||||
if (itemDisplayDbc && idiL) {
|
||||
int32_t tabardIdx = itemDisplayDbc->findRecordById(tabardDisplayId);
|
||||
if (tabardIdx >= 0) {
|
||||
// Get geoset variant from ItemDisplayInfo GeosetGroup1 field
|
||||
const uint32_t ggField = (*idiL)["GeosetGroup1"];
|
||||
uint32_t tabardGG = itemDisplayDbc->getUInt32(static_cast<uint32_t>(tabardIdx), ggField);
|
||||
if (tabardGG > 0) {
|
||||
wantTabard = static_cast<uint16_t>(1200 + tabardGG);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t tabardSid = pickFromGroup(wantTabard, 12);
|
||||
if (tabardSid != 0) normalizedGeosets.insert(tabardSid);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4491,6 +4491,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
}
|
||||
if (currentQuestDetails.questId == questId) {
|
||||
questDetailsOpen = false;
|
||||
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
||||
currentQuestDetails = QuestDetailsData{};
|
||||
removed = true;
|
||||
}
|
||||
|
|
@ -7641,6 +7642,9 @@ void GameHandler::sendPing() {
|
|||
LOG_DEBUG("Sending CMSG_PING (heartbeat)");
|
||||
LOG_DEBUG(" Sequence: ", pingSequence);
|
||||
|
||||
// Record send time for RTT measurement
|
||||
pingTimestamp_ = std::chrono::steady_clock::now();
|
||||
|
||||
// Build and send ping packet
|
||||
auto packet = PingPacket::build(pingSequence, lastLatency);
|
||||
socket->send(packet);
|
||||
|
|
@ -7662,7 +7666,12 @@ void GameHandler::handlePong(network::Packet& packet) {
|
|||
return;
|
||||
}
|
||||
|
||||
LOG_DEBUG("Heartbeat acknowledged (sequence: ", data.sequence, ")");
|
||||
// Measure round-trip time
|
||||
auto rtt = std::chrono::steady_clock::now() - pingTimestamp_;
|
||||
lastLatency = static_cast<uint32_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(rtt).count());
|
||||
|
||||
LOG_DEBUG("Heartbeat acknowledged (sequence: ", data.sequence, ", latency: ", lastLatency, "ms)");
|
||||
}
|
||||
|
||||
uint32_t GameHandler::nextMovementTimestampMs() {
|
||||
|
|
@ -8404,6 +8413,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender,
|
||||
appearanceBytes, facial,
|
||||
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
|
||||
} else {
|
||||
LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec,
|
||||
" displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render");
|
||||
}
|
||||
}
|
||||
} else if (creatureSpawnCallback_) {
|
||||
|
|
@ -8800,6 +8812,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender,
|
||||
appearanceBytes, facial,
|
||||
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
|
||||
} else {
|
||||
LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec,
|
||||
" displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render");
|
||||
}
|
||||
}
|
||||
} else if (creatureSpawnCallback_) {
|
||||
|
|
@ -8956,20 +8971,23 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY);
|
||||
const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS);
|
||||
const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1);
|
||||
|
||||
auto it = onlineItems_.find(block.guid);
|
||||
bool isItemInInventory = (it != onlineItems_.end());
|
||||
|
||||
for (const auto& [key, val] : block.fields) {
|
||||
auto it = onlineItems_.find(block.guid);
|
||||
if (key == itemStackField) {
|
||||
if (it != onlineItems_.end() && it->second.stackCount != val) {
|
||||
if (key == itemStackField && isItemInInventory) {
|
||||
if (it->second.stackCount != val) {
|
||||
it->second.stackCount = val;
|
||||
inventoryChanged = true;
|
||||
}
|
||||
} else if (key == itemDurField) {
|
||||
if (it != onlineItems_.end() && it->second.curDurability != val) {
|
||||
} else if (key == itemDurField && isItemInInventory) {
|
||||
if (it->second.curDurability != val) {
|
||||
it->second.curDurability = val;
|
||||
inventoryChanged = true;
|
||||
}
|
||||
} else if (key == itemMaxDurField) {
|
||||
if (it != onlineItems_.end() && it->second.maxDurability != val) {
|
||||
} else if (key == itemMaxDurField && isItemInInventory) {
|
||||
if (it->second.maxDurability != val) {
|
||||
it->second.maxDurability = val;
|
||||
inventoryChanged = true;
|
||||
}
|
||||
|
|
@ -10595,7 +10613,19 @@ void GameHandler::tabTarget(float playerX, float playerY, float playerZ) {
|
|||
const uint64_t guid = e->getGuid();
|
||||
auto* unit = dynamic_cast<Unit*>(e.get());
|
||||
if (!unit) return false; // Not a unit (shouldn't happen after type filter)
|
||||
if (unit->getHealth() == 0) return false; // Dead / corpse
|
||||
if (unit->getHealth() == 0) {
|
||||
// Dead corpse: only targetable if it has loot or is skinnableable
|
||||
// If corpse was looted and is now empty, skip it (except for skinning)
|
||||
auto lootIt = localLootState_.find(guid);
|
||||
if (lootIt == localLootState_.end() || lootIt->second.data.items.empty()) {
|
||||
// No loot data or all items taken; check if skinnableable
|
||||
// For now, skip empty looted corpses (proper skinning check requires
|
||||
// creature type data that may not be immediately available)
|
||||
return false;
|
||||
}
|
||||
// Has unlooted items available
|
||||
return true;
|
||||
}
|
||||
const bool hostileByFaction = unit->isHostile();
|
||||
const bool hostileByCombat = isAggressiveTowardPlayer(guid);
|
||||
if (!hostileByFaction && !hostileByCombat) return false;
|
||||
|
|
@ -13890,6 +13920,10 @@ void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t
|
|||
if (slot < 0 || slot >= ACTION_BAR_SLOTS) return;
|
||||
actionBar[slot].type = type;
|
||||
actionBar[slot].id = id;
|
||||
// Pre-query item information so action bar displays item name instead of "Item" placeholder
|
||||
if (type == ActionBarSlot::ITEM && id != 0) {
|
||||
queryItemInfo(id, 0);
|
||||
}
|
||||
saveCharacterConfig();
|
||||
}
|
||||
|
||||
|
|
@ -15051,10 +15085,12 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
|
|||
// animation/sound and expects the client to request the mail list.
|
||||
bool isMailbox = false;
|
||||
bool chestLike = false;
|
||||
// Chest-type game objects (type=3): on all expansions, also send CMSG_LOOT so
|
||||
// the server opens the loot response. Other harvestable/interactive types rely
|
||||
// on the server auto-sending SMSG_LOOT_RESPONSE after CMSG_GAMEOBJ_USE.
|
||||
bool shouldSendLoot = isActiveExpansion("classic") || isActiveExpansion("turtle");
|
||||
// Always send CMSG_LOOT after CMSG_GAMEOBJ_USE for any gameobject that could be
|
||||
// lootable. The server silently ignores CMSG_LOOT for non-lootable objects
|
||||
// (doors, buttons, etc.), so this is safe. Not sending it is the main reason
|
||||
// chests fail to open when their GO type is not yet cached or their name doesn't
|
||||
// contain the word "chest" (e.g. lockboxes, coffers, strongboxes, caches).
|
||||
bool shouldSendLoot = true;
|
||||
if (entity && entity->getType() == ObjectType::GAMEOBJECT) {
|
||||
auto go = std::static_pointer_cast<GameObject>(entity);
|
||||
auto* info = getCachedGameObjectInfo(go->getEntry());
|
||||
|
|
@ -15070,22 +15106,20 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
|
|||
refreshMailList();
|
||||
} else if (info && info->type == 3) {
|
||||
chestLike = true;
|
||||
// Type-3 chests require CMSG_LOOT on all expansions (AzerothCore WotLK included)
|
||||
shouldSendLoot = true;
|
||||
} else if (turtleMode) {
|
||||
// Turtle compatibility: keep eager loot open behavior.
|
||||
shouldSendLoot = true;
|
||||
}
|
||||
}
|
||||
if (!chestLike && !goName.empty()) {
|
||||
std::string lower = goName;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
chestLike = (lower.find("chest") != std::string::npos);
|
||||
if (chestLike) shouldSendLoot = true;
|
||||
chestLike = (lower.find("chest") != std::string::npos ||
|
||||
lower.find("lockbox") != std::string::npos ||
|
||||
lower.find("strongbox") != std::string::npos ||
|
||||
lower.find("coffer") != std::string::npos ||
|
||||
lower.find("cache") != std::string::npos);
|
||||
}
|
||||
// For WotLK chest-like gameobjects, also send CMSG_GAMEOBJ_REPORT_USE.
|
||||
if (!isMailbox && chestLike && isActiveExpansion("wotlk")) {
|
||||
// For WotLK, CMSG_GAMEOBJ_REPORT_USE is required for chests (and is harmless for others).
|
||||
if (!isMailbox && isActiveExpansion("wotlk")) {
|
||||
network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE));
|
||||
reportUse.writeUInt64(guid);
|
||||
socket->send(reportUse);
|
||||
|
|
@ -15330,10 +15364,11 @@ void GameHandler::handleQuestDetails(network::Packet& packet) {
|
|||
break;
|
||||
}
|
||||
// Pre-fetch item info for all reward items so icons and names are ready
|
||||
// by the time the offer-reward dialog opens (after the player turns in).
|
||||
// both in this details window and later in the offer-reward dialog (after the player turns in).
|
||||
for (const auto& item : data.rewardChoiceItems) queryItemInfo(item.itemId, 0);
|
||||
for (const auto& item : data.rewardItems) queryItemInfo(item.itemId, 0);
|
||||
questDetailsOpen = true;
|
||||
// Delay opening the window slightly to allow item queries to complete
|
||||
questDetailsOpenTime = std::chrono::steady_clock::now() + std::chrono::milliseconds(100);
|
||||
gossipWindowOpen = false;
|
||||
}
|
||||
|
||||
|
|
@ -15583,6 +15618,7 @@ void GameHandler::acceptQuest() {
|
|||
LOG_DEBUG("Ignoring duplicate quest accept while pending: questId=", questId);
|
||||
triggerQuestAcceptResync(questId, npcGuid, "duplicate-accept");
|
||||
questDetailsOpen = false;
|
||||
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
||||
currentQuestDetails = QuestDetailsData{};
|
||||
return;
|
||||
}
|
||||
|
|
@ -15592,6 +15628,7 @@ void GameHandler::acceptQuest() {
|
|||
LOG_INFO("Ignoring duplicate quest accept already in server quest log: questId=", questId,
|
||||
" slot=", serverSlot);
|
||||
questDetailsOpen = false;
|
||||
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
||||
currentQuestDetails = QuestDetailsData{};
|
||||
return;
|
||||
}
|
||||
|
|
@ -15608,6 +15645,7 @@ void GameHandler::acceptQuest() {
|
|||
pendingQuestAcceptNpcGuids_[questId] = npcGuid;
|
||||
|
||||
questDetailsOpen = false;
|
||||
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
||||
currentQuestDetails = QuestDetailsData{};
|
||||
|
||||
// Re-query quest giver status so marker updates (! → ?)
|
||||
|
|
@ -15620,6 +15658,7 @@ void GameHandler::acceptQuest() {
|
|||
|
||||
void GameHandler::declineQuest() {
|
||||
questDetailsOpen = false;
|
||||
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
||||
currentQuestDetails = QuestDetailsData{};
|
||||
}
|
||||
|
||||
|
|
@ -15686,6 +15725,7 @@ void GameHandler::handleQuestRequestItems(network::Packet& packet) {
|
|||
questRequestItemsOpen_ = true;
|
||||
gossipWindowOpen = false;
|
||||
questDetailsOpen = false;
|
||||
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
||||
|
||||
// Query item names for required items
|
||||
for (const auto& item : data.requiredItems) {
|
||||
|
|
@ -15742,6 +15782,7 @@ void GameHandler::handleQuestOfferReward(network::Packet& packet) {
|
|||
questRequestItemsOpen_ = false;
|
||||
gossipWindowOpen = false;
|
||||
questDetailsOpen = false;
|
||||
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
||||
|
||||
// Query item names for reward items
|
||||
for (const auto& item : data.choiceRewards)
|
||||
|
|
@ -17070,9 +17111,13 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
|
|||
LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK");
|
||||
}
|
||||
|
||||
// Reload terrain at new position
|
||||
// Reload terrain at new position.
|
||||
// Pass isSameMap as isInitialEntry so the application despawns and
|
||||
// re-registers renderer instances before the server resends CREATE_OBJECTs.
|
||||
// Without this, same-map SMSG_NEW_WORLD (dungeon wing teleporters, etc.)
|
||||
// leaves zombie renderer instances that block fresh entity spawns.
|
||||
if (worldEntryCallback_) {
|
||||
worldEntryCallback_(mapId, serverX, serverY, serverZ, false);
|
||||
worldEntryCallback_(mapId, serverX, serverY, serverZ, isSameMap);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ namespace ClassicMoveFlags {
|
|||
// Same as TBC: u8 UpdateFlags, JUMPING=0x2000, 8 speeds, no pitchRate
|
||||
// ============================================================================
|
||||
bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) {
|
||||
// Validate minimum packet size for updateFlags byte
|
||||
if (packet.getReadPos() >= packet.getSize()) {
|
||||
LOG_WARNING("[Classic] Movement block packet too small (need at least 1 byte for updateFlags)");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Classic: UpdateFlags is uint8 (same as TBC)
|
||||
uint8_t updateFlags = packet.readUInt8();
|
||||
block.updateFlags = static_cast<uint16_t>(updateFlags);
|
||||
|
|
@ -385,14 +391,30 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da
|
|||
// Hit targets
|
||||
if (rem() < 1) return true;
|
||||
data.hitCount = packet.readUInt8();
|
||||
// Cap hit count to prevent OOM from huge target lists
|
||||
if (data.hitCount > 128) {
|
||||
LOG_WARNING("[Classic] Spell go: hitCount capped (requested=", (int)data.hitCount, ")");
|
||||
data.hitCount = 128;
|
||||
}
|
||||
data.hitTargets.reserve(data.hitCount);
|
||||
for (uint8_t i = 0; i < data.hitCount && rem() >= 1; ++i) {
|
||||
data.hitTargets.push_back(UpdateObjectParser::readPackedGuid(packet));
|
||||
}
|
||||
// Check if we read all expected hits
|
||||
if (data.hitTargets.size() < data.hitCount) {
|
||||
LOG_WARNING("[Classic] Spell go: truncated hit targets at index ", (int)data.hitTargets.size(),
|
||||
"/", (int)data.hitCount);
|
||||
data.hitCount = data.hitTargets.size();
|
||||
}
|
||||
|
||||
// Miss targets
|
||||
if (rem() < 1) return true;
|
||||
data.missCount = packet.readUInt8();
|
||||
// Cap miss count to prevent OOM
|
||||
if (data.missCount > 128) {
|
||||
LOG_WARNING("[Classic] Spell go: missCount capped (requested=", (int)data.missCount, ")");
|
||||
data.missCount = 128;
|
||||
}
|
||||
data.missTargets.reserve(data.missCount);
|
||||
for (uint8_t i = 0; i < data.missCount && rem() >= 2; ++i) {
|
||||
SpellGoMissEntry m;
|
||||
|
|
@ -401,6 +423,12 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da
|
|||
m.missType = packet.readUInt8();
|
||||
data.missTargets.push_back(m);
|
||||
}
|
||||
// Check if we read all expected misses
|
||||
if (data.missTargets.size() < data.missCount) {
|
||||
LOG_WARNING("[Classic] Spell go: truncated miss targets at index ", (int)data.missTargets.size(),
|
||||
"/", (int)data.missCount);
|
||||
data.missCount = data.missTargets.size();
|
||||
}
|
||||
|
||||
LOG_DEBUG("[Classic] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,
|
||||
" misses=", (int)data.missCount);
|
||||
|
|
@ -658,14 +686,40 @@ bool ClassicPacketParsers::parseCastResult(network::Packet& packet, uint32_t& sp
|
|||
// - After flags: uint8 firstLogin (same as TBC)
|
||||
// ============================================================================
|
||||
bool ClassicPacketParsers::parseCharEnum(network::Packet& packet, CharEnumResponse& response) {
|
||||
// Validate minimum packet size for count byte
|
||||
if (packet.getSize() < 1) {
|
||||
LOG_ERROR("[Classic] SMSG_CHAR_ENUM packet too small: ", packet.getSize(), " bytes");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t count = packet.readUInt8();
|
||||
|
||||
// Cap count to prevent excessive memory allocation
|
||||
constexpr uint8_t kMaxCharacters = 32;
|
||||
if (count > kMaxCharacters) {
|
||||
LOG_WARNING("[Classic] Character count ", (int)count, " exceeds max ", (int)kMaxCharacters,
|
||||
", capping");
|
||||
count = kMaxCharacters;
|
||||
}
|
||||
|
||||
LOG_INFO("[Classic] Parsing SMSG_CHAR_ENUM: ", (int)count, " characters");
|
||||
|
||||
response.characters.clear();
|
||||
response.characters.reserve(count);
|
||||
|
||||
for (uint8_t i = 0; i < count; ++i) {
|
||||
// Sanity check: ensure we have at least minimal data before reading next character
|
||||
// Minimum: guid(8) + name(1) + race(1) + class(1) + gender(1) + appearance(4)
|
||||
// + facialFeatures(1) + level(1) + zone(4) + map(4) + pos(12) + guild(4)
|
||||
// + flags(4) + firstLogin(1) + pet(12) + equipment(20*5)
|
||||
constexpr size_t kMinCharacterSize = 8 + 1 + 1 + 1 + 1 + 4 + 1 + 1 + 4 + 4 + 12 + 4 + 4 + 1 + 12 + 100;
|
||||
if (packet.getReadPos() + kMinCharacterSize > packet.getSize()) {
|
||||
LOG_WARNING("[Classic] Character enum packet truncated at character ", (int)(i + 1),
|
||||
", pos=", packet.getReadPos(), " needed=", kMinCharacterSize,
|
||||
" size=", packet.getSize());
|
||||
break;
|
||||
}
|
||||
|
||||
Character character;
|
||||
|
||||
// GUID (8 bytes)
|
||||
|
|
@ -947,6 +1001,12 @@ bool ClassicPacketParsers::parseGuildQueryResponse(network::Packet& packet, Guil
|
|||
// ============================================================================
|
||||
|
||||
bool ClassicPacketParsers::parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) {
|
||||
// Validate minimum packet size: entry(4)
|
||||
if (packet.getSize() < 4) {
|
||||
LOG_ERROR("Classic SMSG_GAMEOBJECT_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)");
|
||||
return false;
|
||||
}
|
||||
|
||||
data.entry = packet.readUInt32();
|
||||
|
||||
// High bit set means gameobject not found
|
||||
|
|
@ -956,6 +1016,12 @@ bool ClassicPacketParsers::parseGameObjectQueryResponse(network::Packet& packet,
|
|||
return true;
|
||||
}
|
||||
|
||||
// Validate minimum size for fixed fields: type(4) + displayId(4)
|
||||
if (packet.getSize() - packet.getReadPos() < 8) {
|
||||
LOG_ERROR("Classic SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated before names (entry=", data.entry, ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
data.type = packet.readUInt32();
|
||||
data.displayId = packet.readUInt32();
|
||||
// 4 name strings
|
||||
|
|
@ -971,6 +1037,16 @@ bool ClassicPacketParsers::parseGameObjectQueryResponse(network::Packet& packet,
|
|||
data.data[i] = packet.readUInt32();
|
||||
}
|
||||
data.hasData = true;
|
||||
} else if (remaining > 0) {
|
||||
// Partial data field; read what we can
|
||||
uint32_t fieldsToRead = remaining / 4;
|
||||
for (uint32_t i = 0; i < fieldsToRead && i < 24; i++) {
|
||||
data.data[i] = packet.readUInt32();
|
||||
}
|
||||
if (fieldsToRead < 24) {
|
||||
LOG_WARNING("Classic SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated in data fields (", fieldsToRead,
|
||||
" of 24 read, entry=", data.entry, ")");
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type == 15) { // MO_TRANSPORT
|
||||
|
|
@ -1000,9 +1076,24 @@ bool ClassicPacketParsers::parseGossipMessage(network::Packet& packet, GossipMes
|
|||
data.titleTextId = packet.readUInt32();
|
||||
uint32_t optionCount = packet.readUInt32();
|
||||
|
||||
// Cap option count to reasonable maximum
|
||||
constexpr uint32_t kMaxGossipOptions = 256;
|
||||
if (optionCount > kMaxGossipOptions) {
|
||||
LOG_WARNING("Classic SMSG_GOSSIP_MESSAGE optionCount=", optionCount, " exceeds max ",
|
||||
kMaxGossipOptions, ", capping");
|
||||
optionCount = kMaxGossipOptions;
|
||||
}
|
||||
|
||||
data.options.clear();
|
||||
data.options.reserve(optionCount);
|
||||
for (uint32_t i = 0; i < optionCount; ++i) {
|
||||
// Sanity check: ensure minimum bytes available for option (id(4)+icon(1)+isCoded(1)+text(1))
|
||||
remaining = packet.getSize() - packet.getReadPos();
|
||||
if (remaining < 7) {
|
||||
LOG_WARNING("Classic gossip option ", i, " truncated (", remaining, " bytes left)");
|
||||
break;
|
||||
}
|
||||
|
||||
GossipOption opt;
|
||||
opt.id = packet.readUInt32();
|
||||
opt.icon = packet.readUInt8();
|
||||
|
|
@ -1014,10 +1105,33 @@ bool ClassicPacketParsers::parseGossipMessage(network::Packet& packet, GossipMes
|
|||
data.options.push_back(opt);
|
||||
}
|
||||
|
||||
// Ensure we have at least 4 bytes for questCount
|
||||
remaining = packet.getSize() - packet.getReadPos();
|
||||
if (remaining < 4) {
|
||||
LOG_WARNING("Classic SMSG_GOSSIP_MESSAGE truncated before questCount");
|
||||
return data.options.size() > 0; // Return true if we got at least some options
|
||||
}
|
||||
|
||||
uint32_t questCount = packet.readUInt32();
|
||||
|
||||
// Cap quest count to reasonable maximum
|
||||
constexpr uint32_t kMaxGossipQuests = 256;
|
||||
if (questCount > kMaxGossipQuests) {
|
||||
LOG_WARNING("Classic SMSG_GOSSIP_MESSAGE questCount=", questCount, " exceeds max ",
|
||||
kMaxGossipQuests, ", capping");
|
||||
questCount = kMaxGossipQuests;
|
||||
}
|
||||
|
||||
data.quests.clear();
|
||||
data.quests.reserve(questCount);
|
||||
for (uint32_t i = 0; i < questCount; ++i) {
|
||||
// Sanity check: ensure minimum bytes available for quest (id(4)+icon(4)+level(4)+title(1))
|
||||
remaining = packet.getSize() - packet.getReadPos();
|
||||
if (remaining < 13) {
|
||||
LOG_WARNING("Classic gossip quest ", i, " truncated (", remaining, " bytes left)");
|
||||
break;
|
||||
}
|
||||
|
||||
GossipQuestItem quest;
|
||||
quest.questId = packet.readUInt32();
|
||||
quest.questIcon = packet.readUInt32();
|
||||
|
|
@ -1193,6 +1307,12 @@ network::Packet ClassicPacketParsers::buildItemQuery(uint32_t entry, uint64_t gu
|
|||
}
|
||||
|
||||
bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) {
|
||||
// Validate minimum packet size: entry(4)
|
||||
if (packet.getSize() < 4) {
|
||||
LOG_ERROR("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: packet too small (", packet.getSize(), " bytes)");
|
||||
return false;
|
||||
}
|
||||
|
||||
data.entry = packet.readUInt32();
|
||||
|
||||
// High bit set means item not found
|
||||
|
|
@ -1201,6 +1321,12 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
|
|||
return true;
|
||||
}
|
||||
|
||||
// Validate minimum size for fixed fields: itemClass(4) + subClass(4) + 4 name strings + displayInfoId(4) + quality(4)
|
||||
if (packet.getSize() - packet.getReadPos() < 8) {
|
||||
LOG_ERROR("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before names (entry=", data.entry, ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t itemClass = packet.readUInt32();
|
||||
uint32_t subClass = packet.readUInt32();
|
||||
// Vanilla: NO SoundOverrideSubclass
|
||||
|
|
@ -1249,6 +1375,12 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
|
|||
data.displayInfoId = packet.readUInt32();
|
||||
data.quality = packet.readUInt32();
|
||||
|
||||
// Validate minimum size for fixed fields: Flags(4) + BuyPrice(4) + SellPrice(4) + inventoryType(4)
|
||||
if (packet.getSize() - packet.getReadPos() < 16) {
|
||||
LOG_ERROR("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before inventoryType (entry=", data.entry, ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
packet.readUInt32(); // Flags
|
||||
// Vanilla: NO Flags2
|
||||
packet.readUInt32(); // BuyPrice
|
||||
|
|
@ -1256,6 +1388,12 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
|
|||
|
||||
data.inventoryType = packet.readUInt32();
|
||||
|
||||
// Validate minimum size for remaining fixed fields: 13×4 = 52 bytes
|
||||
if (packet.getSize() - packet.getReadPos() < 52) {
|
||||
LOG_ERROR("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before stats (entry=", data.entry, ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
packet.readUInt32(); // AllowableClass
|
||||
packet.readUInt32(); // AllowableRace
|
||||
data.itemLevel = packet.readUInt32();
|
||||
|
|
@ -1271,8 +1409,16 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
|
|||
data.maxStack = static_cast<int32_t>(packet.readUInt32()); // Stackable
|
||||
data.containerSlots = packet.readUInt32();
|
||||
|
||||
// Vanilla: 10 stat pairs, NO statsCount prefix
|
||||
// Vanilla: 10 stat pairs, NO statsCount prefix (10×8 = 80 bytes)
|
||||
if (packet.getSize() - packet.getReadPos() < 80) {
|
||||
LOG_WARNING("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated in stats section (entry=", data.entry, ")");
|
||||
// Read what we can
|
||||
}
|
||||
for (uint32_t i = 0; i < 10; i++) {
|
||||
if (packet.getSize() - packet.getReadPos() < 8) {
|
||||
LOG_WARNING("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: stat ", i, " truncated (entry=", data.entry, ")");
|
||||
break;
|
||||
}
|
||||
uint32_t statType = packet.readUInt32();
|
||||
int32_t statValue = static_cast<int32_t>(packet.readUInt32());
|
||||
if (statType != 0) {
|
||||
|
|
@ -1295,6 +1441,11 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
|
|||
// Vanilla: 5 damage types (same count as WotLK)
|
||||
bool haveWeaponDamage = false;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
// Each damage entry is dmgMin(4) + dmgMax(4) + damageType(4) = 12 bytes
|
||||
if (packet.getSize() - packet.getReadPos() < 12) {
|
||||
LOG_WARNING("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: damage ", i, " truncated (entry=", data.entry, ")");
|
||||
break;
|
||||
}
|
||||
float dmgMin = packet.readFloat();
|
||||
float dmgMax = packet.readFloat();
|
||||
uint32_t damageType = packet.readUInt32();
|
||||
|
|
@ -1308,6 +1459,11 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
|
|||
}
|
||||
}
|
||||
|
||||
// Validate minimum size for armor field (4 bytes)
|
||||
if (packet.getSize() - packet.getReadPos() < 4) {
|
||||
LOG_WARNING("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before armor (entry=", data.entry, ")");
|
||||
return true; // Have core fields; armor is important but optional
|
||||
}
|
||||
data.armor = static_cast<int32_t>(packet.readUInt32());
|
||||
|
||||
// Remaining tail can vary by core. Read resistances + delay when present.
|
||||
|
|
@ -1621,6 +1777,12 @@ network::Packet ClassicPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, u
|
|||
// ============================================================================
|
||||
bool ClassicPacketParsers::parseCreatureQueryResponse(network::Packet& packet,
|
||||
CreatureQueryResponseData& data) {
|
||||
// Validate minimum packet size: entry(4)
|
||||
if (packet.getSize() < 4) {
|
||||
LOG_ERROR("Classic SMSG_CREATURE_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)");
|
||||
return false;
|
||||
}
|
||||
|
||||
data.entry = packet.readUInt32();
|
||||
if (data.entry & 0x80000000) {
|
||||
data.entry &= ~0x80000000;
|
||||
|
|
@ -1635,15 +1797,19 @@ bool ClassicPacketParsers::parseCreatureQueryResponse(network::Packet& packet,
|
|||
data.subName = packet.readString();
|
||||
// NOTE: NO iconName field in Classic 1.12 — goes straight to typeFlags
|
||||
if (packet.getReadPos() + 16 > packet.getSize()) {
|
||||
LOG_WARNING("[Classic] Creature query: truncated at typeFlags (entry=", data.entry, ")");
|
||||
return true;
|
||||
LOG_WARNING("Classic SMSG_CREATURE_QUERY_RESPONSE: truncated at typeFlags (entry=", data.entry, ")");
|
||||
data.typeFlags = 0;
|
||||
data.creatureType = 0;
|
||||
data.family = 0;
|
||||
data.rank = 0;
|
||||
return true; // Have name/sub fields; base fields are important but optional
|
||||
}
|
||||
data.typeFlags = packet.readUInt32();
|
||||
data.creatureType = packet.readUInt32();
|
||||
data.family = packet.readUInt32();
|
||||
data.rank = packet.readUInt32();
|
||||
|
||||
LOG_DEBUG("[Classic] Creature query: ", data.name, " type=", data.creatureType,
|
||||
LOG_DEBUG("Classic SMSG_CREATURE_QUERY_RESPONSE: ", data.name, " type=", data.creatureType,
|
||||
" rank=", data.rank);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ namespace TbcMoveFlags {
|
|||
// - Flag 0x08 (HIGH_GUID) reads 2 u32s (Classic: 1 u32)
|
||||
// ============================================================================
|
||||
bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) {
|
||||
// Validate minimum packet size for updateFlags byte
|
||||
if (packet.getReadPos() >= packet.getSize()) {
|
||||
LOG_WARNING("[TBC] Movement block packet too small (need at least 1 byte for updateFlags)");
|
||||
return false;
|
||||
}
|
||||
|
||||
// TBC 2.4.3: UpdateFlags is uint8 (1 byte)
|
||||
uint8_t updateFlags = packet.readUInt8();
|
||||
block.updateFlags = static_cast<uint16_t>(updateFlags);
|
||||
|
|
@ -297,14 +303,40 @@ network::Packet TbcPacketParsers::buildMovementPacket(LogicalOpcode opcode,
|
|||
// - Equipment: 20 items (not 23)
|
||||
// ============================================================================
|
||||
bool TbcPacketParsers::parseCharEnum(network::Packet& packet, CharEnumResponse& response) {
|
||||
// Validate minimum packet size for count byte
|
||||
if (packet.getSize() < 1) {
|
||||
LOG_ERROR("[TBC] SMSG_CHAR_ENUM packet too small: ", packet.getSize(), " bytes");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t count = packet.readUInt8();
|
||||
|
||||
// Cap count to prevent excessive memory allocation
|
||||
constexpr uint8_t kMaxCharacters = 32;
|
||||
if (count > kMaxCharacters) {
|
||||
LOG_WARNING("[TBC] Character count ", (int)count, " exceeds max ", (int)kMaxCharacters,
|
||||
", capping");
|
||||
count = kMaxCharacters;
|
||||
}
|
||||
|
||||
LOG_INFO("[TBC] Parsing SMSG_CHAR_ENUM: ", (int)count, " characters");
|
||||
|
||||
response.characters.clear();
|
||||
response.characters.reserve(count);
|
||||
|
||||
for (uint8_t i = 0; i < count; ++i) {
|
||||
// Sanity check: ensure we have at least minimal data before reading next character
|
||||
// Minimum: guid(8) + name(1) + race(1) + class(1) + gender(1) + appearance(4)
|
||||
// + facialFeatures(1) + level(1) + zone(4) + map(4) + pos(12) + guild(4)
|
||||
// + flags(4) + firstLogin(1) + pet(12) + equipment(20*9)
|
||||
constexpr size_t kMinCharacterSize = 8 + 1 + 1 + 1 + 1 + 4 + 1 + 1 + 4 + 4 + 12 + 4 + 4 + 1 + 12 + 180;
|
||||
if (packet.getReadPos() + kMinCharacterSize > packet.getSize()) {
|
||||
LOG_WARNING("[TBC] Character enum packet truncated at character ", (int)(i + 1),
|
||||
", pos=", packet.getReadPos(), " needed=", kMinCharacterSize,
|
||||
" size=", packet.getSize());
|
||||
break;
|
||||
}
|
||||
|
||||
Character character;
|
||||
|
||||
// GUID (8 bytes)
|
||||
|
|
@ -508,9 +540,25 @@ bool TbcPacketParsers::parseGossipMessage(network::Packet& packet, GossipMessage
|
|||
data.titleTextId = packet.readUInt32();
|
||||
uint32_t optionCount = packet.readUInt32();
|
||||
|
||||
// Cap option count to reasonable maximum
|
||||
constexpr uint32_t kMaxGossipOptions = 256;
|
||||
if (optionCount > kMaxGossipOptions) {
|
||||
LOG_WARNING("[TBC] SMSG_GOSSIP_MESSAGE optionCount=", optionCount, " exceeds max ",
|
||||
kMaxGossipOptions, ", capping");
|
||||
optionCount = kMaxGossipOptions;
|
||||
}
|
||||
|
||||
data.options.clear();
|
||||
data.options.reserve(optionCount);
|
||||
for (uint32_t i = 0; i < optionCount; ++i) {
|
||||
// Sanity check: ensure minimum bytes available for option
|
||||
// (id(4)+icon(1)+isCoded(1)+boxMoney(4)+text(1)+boxText(1))
|
||||
size_t remaining = packet.getSize() - packet.getReadPos();
|
||||
if (remaining < 12) {
|
||||
LOG_WARNING("[TBC] gossip option ", i, " truncated (", remaining, " bytes left)");
|
||||
break;
|
||||
}
|
||||
|
||||
GossipOption opt;
|
||||
opt.id = packet.readUInt32();
|
||||
opt.icon = packet.readUInt8();
|
||||
|
|
@ -521,10 +569,34 @@ bool TbcPacketParsers::parseGossipMessage(network::Packet& packet, GossipMessage
|
|||
data.options.push_back(opt);
|
||||
}
|
||||
|
||||
// Ensure we have at least 4 bytes for questCount
|
||||
size_t remaining = packet.getSize() - packet.getReadPos();
|
||||
if (remaining < 4) {
|
||||
LOG_WARNING("[TBC] SMSG_GOSSIP_MESSAGE truncated before questCount");
|
||||
return data.options.size() > 0; // Return true if we got at least some options
|
||||
}
|
||||
|
||||
uint32_t questCount = packet.readUInt32();
|
||||
|
||||
// Cap quest count to reasonable maximum
|
||||
constexpr uint32_t kMaxGossipQuests = 256;
|
||||
if (questCount > kMaxGossipQuests) {
|
||||
LOG_WARNING("[TBC] SMSG_GOSSIP_MESSAGE questCount=", questCount, " exceeds max ",
|
||||
kMaxGossipQuests, ", capping");
|
||||
questCount = kMaxGossipQuests;
|
||||
}
|
||||
|
||||
data.quests.clear();
|
||||
data.quests.reserve(questCount);
|
||||
for (uint32_t i = 0; i < questCount; ++i) {
|
||||
// Sanity check: ensure minimum bytes available for quest
|
||||
// (id(4)+icon(4)+level(4)+title(1))
|
||||
remaining = packet.getSize() - packet.getReadPos();
|
||||
if (remaining < 13) {
|
||||
LOG_WARNING("[TBC] gossip quest ", i, " truncated (", remaining, " bytes left)");
|
||||
break;
|
||||
}
|
||||
|
||||
GossipQuestItem quest;
|
||||
quest.questId = packet.readUInt32();
|
||||
quest.questIcon = packet.readUInt32();
|
||||
|
|
@ -886,12 +958,24 @@ bool TbcPacketParsers::parseNameQueryResponse(network::Packet& packet, NameQuery
|
|||
// - Has statsCount prefix (Classic reads 10 pairs with no prefix)
|
||||
// ============================================================================
|
||||
bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) {
|
||||
// Validate minimum packet size: entry(4)
|
||||
if (packet.getSize() < 4) {
|
||||
LOG_ERROR("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: packet too small (", packet.getSize(), " bytes)");
|
||||
return false;
|
||||
}
|
||||
|
||||
data.entry = packet.readUInt32();
|
||||
if (data.entry & 0x80000000) {
|
||||
data.entry &= ~0x80000000;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Validate minimum size for fixed fields: itemClass(4) + subClass(4) + soundOverride(4) + 4 name strings + displayInfoId(4) + quality(4)
|
||||
if (packet.getSize() - packet.getReadPos() < 12) {
|
||||
LOG_ERROR("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before names (entry=", data.entry, ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t itemClass = packet.readUInt32();
|
||||
uint32_t subClass = packet.readUInt32();
|
||||
data.itemClass = itemClass;
|
||||
|
|
@ -908,6 +992,12 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
|
|||
data.displayInfoId = packet.readUInt32();
|
||||
data.quality = packet.readUInt32();
|
||||
|
||||
// Validate minimum size for fixed fields: Flags(4) + BuyPrice(4) + SellPrice(4) + inventoryType(4)
|
||||
if (packet.getSize() - packet.getReadPos() < 16) {
|
||||
LOG_ERROR("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before inventoryType (entry=", data.entry, ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
packet.readUInt32(); // Flags (TBC: 1 flags field only — no Flags2)
|
||||
// TBC: NO Flags2, NO BuyCount
|
||||
packet.readUInt32(); // BuyPrice
|
||||
|
|
@ -915,6 +1005,12 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
|
|||
|
||||
data.inventoryType = packet.readUInt32();
|
||||
|
||||
// Validate minimum size for remaining fixed fields: 13×4 = 52 bytes
|
||||
if (packet.getSize() - packet.getReadPos() < 52) {
|
||||
LOG_ERROR("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before statsCount (entry=", data.entry, ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
packet.readUInt32(); // AllowableClass
|
||||
packet.readUInt32(); // AllowableRace
|
||||
data.itemLevel = packet.readUInt32();
|
||||
|
|
@ -931,9 +1027,22 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
|
|||
data.containerSlots = packet.readUInt32();
|
||||
|
||||
// TBC: statsCount prefix + exactly statsCount pairs (WotLK always sends 10)
|
||||
if (packet.getSize() - packet.getReadPos() < 4) {
|
||||
LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated at statsCount (entry=", data.entry, ")");
|
||||
return true; // Have core fields; stats are optional
|
||||
}
|
||||
uint32_t statsCount = packet.readUInt32();
|
||||
if (statsCount > 10) statsCount = 10; // sanity cap
|
||||
if (statsCount > 10) {
|
||||
LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: statsCount=", statsCount, " exceeds max 10 (entry=",
|
||||
data.entry, "), capping");
|
||||
statsCount = 10;
|
||||
}
|
||||
for (uint32_t i = 0; i < statsCount; i++) {
|
||||
// Each stat is 2 uint32s = 8 bytes
|
||||
if (packet.getSize() - packet.getReadPos() < 8) {
|
||||
LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: stat ", i, " truncated (entry=", data.entry, ")");
|
||||
break;
|
||||
}
|
||||
uint32_t statType = packet.readUInt32();
|
||||
int32_t statValue = static_cast<int32_t>(packet.readUInt32());
|
||||
switch (statType) {
|
||||
|
|
@ -950,9 +1059,14 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
|
|||
}
|
||||
// TBC: NO ScalingStatDistribution, NO ScalingStatValue (WotLK-only)
|
||||
|
||||
// 5 damage entries
|
||||
// 5 damage entries (5×12 = 60 bytes)
|
||||
bool haveWeaponDamage = false;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
// Each damage entry is dmgMin(4) + dmgMax(4) + damageType(4) = 12 bytes
|
||||
if (packet.getSize() - packet.getReadPos() < 12) {
|
||||
LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: damage ", i, " truncated (entry=", data.entry, ")");
|
||||
break;
|
||||
}
|
||||
float dmgMin = packet.readFloat();
|
||||
float dmgMax = packet.readFloat();
|
||||
uint32_t damageType = packet.readUInt32();
|
||||
|
|
@ -965,6 +1079,11 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
|
|||
}
|
||||
}
|
||||
|
||||
// Validate minimum size for armor (4 bytes)
|
||||
if (packet.getSize() - packet.getReadPos() < 4) {
|
||||
LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before armor (entry=", data.entry, ")");
|
||||
return true; // Have core fields; armor is important but optional
|
||||
}
|
||||
data.armor = static_cast<int32_t>(packet.readUInt32());
|
||||
|
||||
if (packet.getSize() - packet.getReadPos() >= 28) {
|
||||
|
|
@ -1157,13 +1276,29 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data)
|
|||
}
|
||||
|
||||
data.hitCount = packet.readUInt8();
|
||||
// Cap hit count to prevent OOM from huge target lists
|
||||
if (data.hitCount > 128) {
|
||||
LOG_WARNING("[TBC] Spell go: hitCount capped (requested=", (int)data.hitCount, ")");
|
||||
data.hitCount = 128;
|
||||
}
|
||||
data.hitTargets.reserve(data.hitCount);
|
||||
for (uint8_t i = 0; i < data.hitCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) {
|
||||
data.hitTargets.push_back(packet.readUInt64()); // full GUID in TBC
|
||||
}
|
||||
// Check if we read all expected hits
|
||||
if (data.hitTargets.size() < data.hitCount) {
|
||||
LOG_WARNING("[TBC] Spell go: truncated hit targets at index ", (int)data.hitTargets.size(),
|
||||
"/", (int)data.hitCount);
|
||||
data.hitCount = data.hitTargets.size();
|
||||
}
|
||||
|
||||
if (packet.getReadPos() < packet.getSize()) {
|
||||
data.missCount = packet.readUInt8();
|
||||
// Cap miss count to prevent OOM
|
||||
if (data.missCount > 128) {
|
||||
LOG_WARNING("[TBC] Spell go: missCount capped (requested=", (int)data.missCount, ")");
|
||||
data.missCount = 128;
|
||||
}
|
||||
data.missTargets.reserve(data.missCount);
|
||||
for (uint8_t i = 0; i < data.missCount && packet.getReadPos() + 9 <= packet.getSize(); ++i) {
|
||||
SpellGoMissEntry m;
|
||||
|
|
@ -1171,6 +1306,12 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data)
|
|||
m.missType = packet.readUInt8();
|
||||
data.missTargets.push_back(m);
|
||||
}
|
||||
// Check if we read all expected misses
|
||||
if (data.missTargets.size() < data.missCount) {
|
||||
LOG_WARNING("[TBC] Spell go: truncated miss targets at index ", (int)data.missTargets.size(),
|
||||
"/", (int)data.missCount);
|
||||
data.missCount = data.missTargets.size();
|
||||
}
|
||||
}
|
||||
|
||||
LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -64,9 +64,9 @@ bool CharacterPreview::initialize(pipeline::AssetManager* am) {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Disable fog and shadows for the preview
|
||||
// Configure lighting for character preview
|
||||
// Use distant fog to avoid clipping, enable shadows for visual depth
|
||||
charRenderer_->setFog(glm::vec3(0.05f, 0.05f, 0.1f), 9999.0f, 10000.0f);
|
||||
charRenderer_->clearShadowMap();
|
||||
|
||||
camera_ = std::make_unique<Camera>();
|
||||
// Portrait-style camera: WoW Z-up coordinate system
|
||||
|
|
@ -819,8 +819,8 @@ void CharacterPreview::compositePass(VkCommandBuffer cmd, uint32_t frameIndex) {
|
|||
// No fog in preview
|
||||
ubo.fogColor = glm::vec4(0.05f, 0.05f, 0.1f, 0.0f);
|
||||
ubo.fogParams = glm::vec4(9999.0f, 10000.0f, 0.0f, 0.0f);
|
||||
// Shadows disabled
|
||||
ubo.shadowParams = glm::vec4(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
// Enable shadows for visual depth in preview (strength=0.5 for subtle effect)
|
||||
ubo.shadowParams = glm::vec4(1.0f, 0.5f, 0.0f, 0.0f);
|
||||
|
||||
std::memcpy(previewUBOMapped_[fi], &ubo, sizeof(GPUPerFrameData));
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
#include "rendering/vk_utils.hpp"
|
||||
#include "rendering/vk_frame_data.hpp"
|
||||
#include "rendering/camera.hpp"
|
||||
#include "rendering/frustum.hpp"
|
||||
#include "pipeline/asset_manager.hpp"
|
||||
#include "pipeline/blp_loader.hpp"
|
||||
#include "core/logger.hpp"
|
||||
|
|
@ -1647,7 +1648,13 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) {
|
|||
inst.animationTime += deltaTime * 1000.0f;
|
||||
if (seq.duration > 0 && inst.animationTime >= static_cast<float>(seq.duration)) {
|
||||
if (inst.animationLoop) {
|
||||
inst.animationTime = std::fmod(inst.animationTime, static_cast<float>(seq.duration));
|
||||
// Subtract duration instead of fmod to preserve float precision
|
||||
// fmod() loses precision with large animationTime values
|
||||
inst.animationTime -= static_cast<float>(seq.duration);
|
||||
// Clamp to [0, duration) to handle multiple loops in one frame
|
||||
while (inst.animationTime >= static_cast<float>(seq.duration)) {
|
||||
inst.animationTime -= static_cast<float>(seq.duration);
|
||||
}
|
||||
} else {
|
||||
// One-shot animation finished: return to Stand (0) unless dead
|
||||
if (inst.currentAnimationId != 1 /*Death*/) {
|
||||
|
|
@ -1961,16 +1968,18 @@ void CharacterRenderer::prepareRender(uint32_t frameIndex) {
|
|||
}
|
||||
}
|
||||
|
||||
void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, [[maybe_unused]] const Camera& camera) {
|
||||
void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) {
|
||||
if (instances.empty() || !opaquePipeline_) {
|
||||
return;
|
||||
}
|
||||
const float renderRadius = static_cast<float>(envSizeOrDefault("WOWEE_CHAR_RENDER_RADIUS", 130));
|
||||
const float renderRadiusSq = renderRadius * renderRadius;
|
||||
const float nearNoConeCullSq = 16.0f * 16.0f;
|
||||
const float backfaceDotCull = -0.30f;
|
||||
const float characterCullRadius = 2.0f; // Estimate character radius for frustum testing
|
||||
const glm::vec3 camPos = camera.getPosition();
|
||||
const glm::vec3 camForward = camera.getForward();
|
||||
|
||||
// Extract frustum planes for per-instance visibility testing
|
||||
Frustum frustum;
|
||||
frustum.extractFromMatrix(camera.getViewProjectionMatrix());
|
||||
|
||||
uint32_t frameIndex = vkCtx_->getCurrentFrame();
|
||||
uint32_t frameSlot = frameIndex % 2u;
|
||||
|
|
@ -2001,22 +2010,17 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
|
|||
|
||||
// Skip invisible instances (e.g., player in first-person mode)
|
||||
if (!instance.visible) continue;
|
||||
// Character instance culling: avoid drawing far-away / strongly behind-camera
|
||||
// actors in dense city scenes.
|
||||
|
||||
// Character instance culling: test both distance and frustum visibility
|
||||
if (!instance.hasOverrideModelMatrix) {
|
||||
glm::vec3 toInst = instance.position - camPos;
|
||||
float distSq = glm::dot(toInst, toInst);
|
||||
|
||||
// Distance cull: skip if beyond render radius
|
||||
if (distSq > renderRadiusSq) continue;
|
||||
if (distSq > nearNoConeCullSq) {
|
||||
// Backface cull without sqrt: dot(toInst, camFwd) / |toInst| < threshold
|
||||
// ⟺ dot < 0 || dot² < threshold² * distSq (when threshold < 0, dot must be negative)
|
||||
float rawDot = glm::dot(toInst, camForward);
|
||||
if (backfaceDotCull >= 0.0f) {
|
||||
if (rawDot < 0.0f || rawDot * rawDot < backfaceDotCull * backfaceDotCull * distSq) continue;
|
||||
} else {
|
||||
if (rawDot < 0.0f && rawDot * rawDot > backfaceDotCull * backfaceDotCull * distSq) continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Frustum cull: skip if outside view frustum
|
||||
if (!frustum.intersectsSphere(instance.position, characterCullRadius)) continue;
|
||||
}
|
||||
|
||||
if (!instance.cachedModel) continue;
|
||||
|
|
|
|||
|
|
@ -1880,7 +1880,15 @@ static void resolveTrackTime(const pipeline::M2AnimationTrack& track,
|
|||
// Global sequence: always use sub-array 0, wrap time at global duration
|
||||
outSeqIdx = 0;
|
||||
float dur = static_cast<float>(globalSeqDurations[track.globalSequence]);
|
||||
outTime = (dur > 0.0f) ? std::fmod(time, dur) : 0.0f;
|
||||
if (dur > 0.0f) {
|
||||
// Use iterative subtraction instead of fmod() to preserve precision
|
||||
outTime = time;
|
||||
while (outTime >= dur) {
|
||||
outTime -= dur;
|
||||
}
|
||||
} else {
|
||||
outTime = 0.0f;
|
||||
}
|
||||
} else {
|
||||
outSeqIdx = seqIdx;
|
||||
outTime = time;
|
||||
|
|
@ -1997,7 +2005,7 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
|
|||
std::uniform_real_distribution<float> distDrift(-0.2f, 0.2f);
|
||||
|
||||
smokeEmitAccum += deltaTime;
|
||||
float emitInterval = 1.0f / 16.0f; // 16 particles per second per emitter
|
||||
float emitInterval = 1.0f / 48.0f; // 48 particles per second per emitter (was 32; increased for denser lava/magma steam effects in sparse areas)
|
||||
|
||||
if (smokeEmitAccum >= emitInterval &&
|
||||
static_cast<int>(smokeParticles.size()) < MAX_SMOKE_PARTICLES) {
|
||||
|
|
@ -2070,8 +2078,9 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
|
|||
for (size_t idx : particleOnlyInstanceIndices_) {
|
||||
if (idx >= instances.size()) continue;
|
||||
auto& instance = instances[idx];
|
||||
if (instance.animTime > 3333.0f) {
|
||||
instance.animTime = std::fmod(instance.animTime, 3333.0f);
|
||||
// Use iterative subtraction instead of fmod() to preserve precision
|
||||
while (instance.animTime > 3333.0f) {
|
||||
instance.animTime -= 3333.0f;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2114,7 +2123,11 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
|
|||
instance.animTime = 0.0f;
|
||||
instance.variationTimer = 4000.0f + static_cast<float>(rand() % 6000);
|
||||
} else {
|
||||
instance.animTime = std::fmod(instance.animTime, std::max(1.0f, instance.animDuration));
|
||||
// Use iterative subtraction instead of fmod() to preserve precision
|
||||
float duration = std::max(1.0f, instance.animDuration);
|
||||
while (instance.animTime >= duration) {
|
||||
instance.animTime -= duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3452,8 +3465,12 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame
|
|||
if ((em.flags & kParticleFlagTiled) && totalTiles > 1) {
|
||||
float animSeconds = inst.animTime / 1000.0f;
|
||||
uint32_t animFrame = static_cast<uint32_t>(std::floor(animSeconds * totalTiles)) % totalTiles;
|
||||
tileIndex = std::fmod(p.tileIndex + static_cast<float>(animFrame),
|
||||
static_cast<float>(totalTiles));
|
||||
tileIndex = p.tileIndex + static_cast<float>(animFrame);
|
||||
float tilesFloat = static_cast<float>(totalTiles);
|
||||
// Wrap tile index within totalTiles range
|
||||
while (tileIndex >= tilesFloat) {
|
||||
tileIndex -= tilesFloat;
|
||||
}
|
||||
}
|
||||
group.vertexData.push_back(tileIndex);
|
||||
totalParticles++;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "rendering/quest_marker_renderer.hpp"
|
||||
#include "rendering/camera.hpp"
|
||||
#include "rendering/frustum.hpp"
|
||||
#include "rendering/vk_context.hpp"
|
||||
#include "rendering/vk_shader.hpp"
|
||||
#include "rendering/vk_pipeline.hpp"
|
||||
|
|
@ -374,6 +375,10 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe
|
|||
glm::mat4 view = camera.getViewMatrix();
|
||||
glm::vec3 cameraPos = camera.getPosition();
|
||||
|
||||
// Extract frustum planes for visibility testing
|
||||
Frustum frustum;
|
||||
frustum.extractFromMatrix(camera.getViewProjectionMatrix());
|
||||
|
||||
// Get camera right and up vectors for billboarding
|
||||
glm::vec3 cameraRight = glm::vec3(view[0][0], view[1][0], view[2][0]);
|
||||
glm::vec3 cameraUp = glm::vec3(view[0][1], view[1][1], view[2][1]);
|
||||
|
|
@ -398,6 +403,11 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe
|
|||
glm::vec3 toCamera = cameraPos - marker.position;
|
||||
float distSq = glm::dot(toCamera, toCamera);
|
||||
if (distSq > CULL_DIST_SQ) continue;
|
||||
|
||||
// Frustum cull quest markers (small sphere for icon)
|
||||
constexpr float markerCullRadius = 0.5f;
|
||||
if (!frustum.intersectsSphere(marker.position, markerCullRadius)) continue;
|
||||
|
||||
float dist = std::sqrt(distSq);
|
||||
|
||||
// Calculate fade alpha
|
||||
|
|
|
|||
|
|
@ -2008,7 +2008,12 @@ void Renderer::updateCharacterAnimation() {
|
|||
// Rider bob: sinusoidal motion synced to mount's run animation (only used in fallback positioning)
|
||||
mountBob = 0.0f;
|
||||
if (moving && haveMountState && curMountDur > 1.0f) {
|
||||
float norm = std::fmod(curMountTime, curMountDur) / curMountDur;
|
||||
// Wrap mount time preserving precision via subtraction instead of fmod
|
||||
float wrappedTime = curMountTime;
|
||||
while (wrappedTime >= curMountDur) {
|
||||
wrappedTime -= curMountDur;
|
||||
}
|
||||
float norm = wrappedTime / curMountDur;
|
||||
// One bounce per stride cycle
|
||||
float bobSpeed = taxiFlight_ ? 2.0f : 1.0f;
|
||||
mountBob = std::sin(norm * 2.0f * 3.14159f * bobSpeed) * 0.12f;
|
||||
|
|
@ -2580,8 +2585,13 @@ bool Renderer::shouldTriggerFootstepEvent(uint32_t animationId, float animationT
|
|||
return false;
|
||||
}
|
||||
|
||||
float norm = std::fmod(animationTimeMs, animationDurationMs) / animationDurationMs;
|
||||
if (norm < 0.0f) norm += 1.0f;
|
||||
// Wrap animation time preserving precision via subtraction instead of fmod
|
||||
float wrappedTime = animationTimeMs;
|
||||
while (wrappedTime >= animationDurationMs) {
|
||||
wrappedTime -= animationDurationMs;
|
||||
}
|
||||
if (wrappedTime < 0.0f) wrappedTime += animationDurationMs;
|
||||
float norm = wrappedTime / animationDurationMs;
|
||||
|
||||
if (animationId != footstepLastAnimationId) {
|
||||
footstepLastAnimationId = animationId;
|
||||
|
|
@ -2875,8 +2885,13 @@ void Renderer::update(float deltaTime) {
|
|||
float animTimeMs = 0.0f, animDurationMs = 0.0f;
|
||||
if (characterRenderer->getAnimationState(mountInstanceId_, animId, animTimeMs, animDurationMs) &&
|
||||
animDurationMs > 1.0f && cameraController->isMoving()) {
|
||||
float norm = std::fmod(animTimeMs, animDurationMs) / animDurationMs;
|
||||
if (norm < 0.0f) norm += 1.0f;
|
||||
// Wrap animation time preserving precision via subtraction instead of fmod
|
||||
float wrappedTime = animTimeMs;
|
||||
while (wrappedTime >= animDurationMs) {
|
||||
wrappedTime -= animDurationMs;
|
||||
}
|
||||
if (wrappedTime < 0.0f) wrappedTime += animDurationMs;
|
||||
float norm = wrappedTime / animDurationMs;
|
||||
|
||||
if (animId != mountFootstepLastAnimId) {
|
||||
mountFootstepLastAnimId = animId;
|
||||
|
|
|
|||
|
|
@ -1952,40 +1952,27 @@ VkDescriptorSet WMORenderer::allocateMaterialSet() {
|
|||
|
||||
bool WMORenderer::isGroupVisible(const GroupResources& group, const glm::mat4& modelMatrix,
|
||||
const Camera& camera) const {
|
||||
// Simple frustum culling using bounding box
|
||||
// Transform bounding box corners to world space
|
||||
glm::vec3 corners[8] = {
|
||||
glm::vec3(group.boundingBoxMin.x, group.boundingBoxMin.y, group.boundingBoxMin.z),
|
||||
glm::vec3(group.boundingBoxMax.x, group.boundingBoxMin.y, group.boundingBoxMin.z),
|
||||
glm::vec3(group.boundingBoxMin.x, group.boundingBoxMax.y, group.boundingBoxMin.z),
|
||||
glm::vec3(group.boundingBoxMax.x, group.boundingBoxMax.y, group.boundingBoxMin.z),
|
||||
glm::vec3(group.boundingBoxMin.x, group.boundingBoxMin.y, group.boundingBoxMax.z),
|
||||
glm::vec3(group.boundingBoxMax.x, group.boundingBoxMin.y, group.boundingBoxMax.z),
|
||||
glm::vec3(group.boundingBoxMin.x, group.boundingBoxMax.y, group.boundingBoxMax.z),
|
||||
glm::vec3(group.boundingBoxMax.x, group.boundingBoxMax.y, group.boundingBoxMax.z)
|
||||
};
|
||||
// Proper frustum-AABB intersection test for accurate visibility culling
|
||||
// Transform bounding box min/max to world space
|
||||
glm::vec3 localMin = group.boundingBoxMin;
|
||||
glm::vec3 localMax = group.boundingBoxMax;
|
||||
|
||||
// Transform corners to world space
|
||||
for (int i = 0; i < 8; i++) {
|
||||
glm::vec4 worldPos = modelMatrix * glm::vec4(corners[i], 1.0f);
|
||||
corners[i] = glm::vec3(worldPos);
|
||||
}
|
||||
// Transform min and max to world space
|
||||
glm::vec4 worldMinH = modelMatrix * glm::vec4(localMin, 1.0f);
|
||||
glm::vec4 worldMaxH = modelMatrix * glm::vec4(localMax, 1.0f);
|
||||
glm::vec3 worldMin = glm::vec3(worldMinH);
|
||||
glm::vec3 worldMax = glm::vec3(worldMaxH);
|
||||
|
||||
// Simple check: if all corners are behind camera, cull
|
||||
// (This is a very basic culling implementation - a full frustum test would be better)
|
||||
glm::vec3 forward = camera.getForward();
|
||||
glm::vec3 camPos = camera.getPosition();
|
||||
// Ensure min/max are correct after transformation (handles non-uniform scaling)
|
||||
glm::vec3 boundsMin = glm::min(worldMin, worldMax);
|
||||
glm::vec3 boundsMax = glm::max(worldMin, worldMax);
|
||||
|
||||
int behindCount = 0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
glm::vec3 toCorner = corners[i] - camPos;
|
||||
if (glm::dot(toCorner, forward) < 0.0f) {
|
||||
behindCount++;
|
||||
}
|
||||
}
|
||||
// Extract frustum planes from view-projection matrix
|
||||
Frustum frustum;
|
||||
frustum.extractFromMatrix(camera.getViewProjectionMatrix());
|
||||
|
||||
// If all corners are behind camera, cull
|
||||
return behindCount < 8;
|
||||
// Test if AABB intersects view frustum
|
||||
return frustum.intersectsAABB(boundsMin, boundsMax);
|
||||
}
|
||||
|
||||
int WMORenderer::findContainingGroup(const ModelData& model, const glm::vec3& localPos) const {
|
||||
|
|
|
|||
|
|
@ -5317,6 +5317,10 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
|
|||
// Player nameplates are always shown; NPC nameplates respect the V-key toggle
|
||||
if (!isPlayer && !showNameplates_) continue;
|
||||
|
||||
// For corpses (dead units), only show a minimal grey nameplate if selected
|
||||
bool isCorpse = (unit->getHealth() == 0);
|
||||
if (isCorpse && !isTarget) continue;
|
||||
|
||||
// Prefer the renderer's actual instance position so the nameplate tracks the
|
||||
// rendered model exactly (avoids drift from the parallel entity interpolator).
|
||||
glm::vec3 renderPos;
|
||||
|
|
@ -5349,9 +5353,13 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
|
|||
float alpha = dist < (cullDist - 5.0f) ? 1.0f : 1.0f - (dist - (cullDist - 5.0f)) / 5.0f;
|
||||
auto A = [&](int v) { return static_cast<int>(v * alpha); };
|
||||
|
||||
// Bar colour by hostility
|
||||
// Bar colour by hostility (grey for corpses)
|
||||
ImU32 barColor, bgColor;
|
||||
if (unit->isHostile()) {
|
||||
if (isCorpse) {
|
||||
// Minimal grey bar for selected corpses (loot/skin targets)
|
||||
barColor = IM_COL32(140, 140, 140, A(200));
|
||||
bgColor = IM_COL32(70, 70, 70, A(160));
|
||||
} else if (unit->isHostile()) {
|
||||
barColor = IM_COL32(220, 60, 60, A(200));
|
||||
bgColor = IM_COL32(100, 25, 25, A(160));
|
||||
} else {
|
||||
|
|
@ -5372,7 +5380,10 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
|
|||
0.0f, 1.0f);
|
||||
|
||||
drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW, sy + barH), bgColor, 2.0f);
|
||||
drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW * healthPct, sy + barH), barColor, 2.0f);
|
||||
// For corpses, don't fill health bar (just show grey background)
|
||||
if (!isCorpse) {
|
||||
drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW * healthPct, sy + barH), barColor, 2.0f);
|
||||
}
|
||||
drawList->AddRect (ImVec2(barX - 1.0f, sy - 1.0f), ImVec2(barX + barW + 1.0f, sy + barH + 1.0f), borderColor, 2.0f);
|
||||
|
||||
// Name + level label above health bar
|
||||
|
|
@ -5384,10 +5395,14 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
|
|||
// Fall back to level as placeholder while the name query is pending.
|
||||
if (!unitName.empty())
|
||||
snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str());
|
||||
else if (level > 0)
|
||||
snprintf(labelBuf, sizeof(labelBuf), "Player (%u)", level);
|
||||
else
|
||||
snprintf(labelBuf, sizeof(labelBuf), "Player");
|
||||
else {
|
||||
// Name query may be pending; request it now to ensure it gets resolved
|
||||
gameHandler.queryPlayerName(unit->getGuid());
|
||||
if (level > 0)
|
||||
snprintf(labelBuf, sizeof(labelBuf), "Player (%u)", level);
|
||||
else
|
||||
snprintf(labelBuf, sizeof(labelBuf), "Player");
|
||||
}
|
||||
} else if (level > 0) {
|
||||
uint32_t playerLevel = gameHandler.getPlayerLevel();
|
||||
// Show skull for units more than 10 levels above the player
|
||||
|
|
@ -7741,6 +7756,8 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) {
|
|||
// Show only the most recently sold item (LIFO).
|
||||
const int i = 0;
|
||||
const auto& entry = buyback[0];
|
||||
// Proactively ensure buyback item info is loaded
|
||||
gameHandler.ensureItemInfo(entry.item.itemId);
|
||||
uint32_t sellPrice = entry.item.sellPrice;
|
||||
if (sellPrice == 0) {
|
||||
if (auto* info = gameHandler.getItemInfo(entry.item.itemId); info && info->valid) {
|
||||
|
|
@ -7804,6 +7821,9 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) {
|
|||
ImGui::TableNextRow();
|
||||
ImGui::PushID(vi);
|
||||
|
||||
// Proactively ensure vendor item info is loaded
|
||||
gameHandler.ensureItemInfo(item.itemId);
|
||||
|
||||
ImGui::TableSetColumnIndex(0);
|
||||
auto* info = gameHandler.getItemInfo(item.itemId);
|
||||
if (info && info->valid) {
|
||||
|
|
@ -8561,6 +8581,7 @@ void GameScreen::renderSettingsWindow() {
|
|||
pendingMinimapRotate = minimapRotate_;
|
||||
pendingMinimapSquare = minimapSquare_;
|
||||
pendingMinimapNpcDots = minimapNpcDots_;
|
||||
pendingShowLatencyMeter = showLatencyMeter_;
|
||||
if (renderer) {
|
||||
if (auto* minimap = renderer->getMinimap()) {
|
||||
minimap->setRotateWithCamera(minimapRotate_);
|
||||
|
|
@ -8595,16 +8616,37 @@ void GameScreen::renderSettingsWindow() {
|
|||
if (ImGui::BeginTabItem("Video")) {
|
||||
ImGui::Spacing();
|
||||
|
||||
// Graphics Quality Presets
|
||||
{
|
||||
const char* presetLabels[] = { "Custom", "Low", "Medium", "High", "Ultra" };
|
||||
int presetIdx = static_cast<int>(pendingGraphicsPreset);
|
||||
if (ImGui::Combo("Quality Preset", &presetIdx, presetLabels, 5)) {
|
||||
pendingGraphicsPreset = static_cast<GraphicsPreset>(presetIdx);
|
||||
if (pendingGraphicsPreset != GraphicsPreset::CUSTOM) {
|
||||
applyGraphicsPreset(pendingGraphicsPreset);
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
ImGui::TextDisabled("Adjust these for custom settings");
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
if (ImGui::Checkbox("Fullscreen", &pendingFullscreen)) {
|
||||
window->setFullscreen(pendingFullscreen);
|
||||
updateGraphicsPresetFromCurrentSettings();
|
||||
saveSettings();
|
||||
}
|
||||
if (ImGui::Checkbox("VSync", &pendingVsync)) {
|
||||
window->setVsync(pendingVsync);
|
||||
updateGraphicsPresetFromCurrentSettings();
|
||||
saveSettings();
|
||||
}
|
||||
if (ImGui::Checkbox("Shadows", &pendingShadows)) {
|
||||
if (renderer) renderer->setShadowsEnabled(pendingShadows);
|
||||
updateGraphicsPresetFromCurrentSettings();
|
||||
saveSettings();
|
||||
}
|
||||
if (pendingShadows) {
|
||||
|
|
@ -8612,6 +8654,7 @@ void GameScreen::renderSettingsWindow() {
|
|||
ImGui::SetNextItemWidth(150.0f);
|
||||
if (ImGui::SliderFloat("Distance##shadow", &pendingShadowDistance, 40.0f, 500.0f, "%.0f")) {
|
||||
if (renderer) renderer->setShadowDistance(pendingShadowDistance);
|
||||
updateGraphicsPresetFromCurrentSettings();
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
|
|
@ -8643,6 +8686,7 @@ void GameScreen::renderSettingsWindow() {
|
|||
VK_SAMPLE_COUNT_4_BIT, VK_SAMPLE_COUNT_8_BIT
|
||||
};
|
||||
if (renderer) renderer->setMsaaSamples(aaSamples[pendingAntiAliasing]);
|
||||
updateGraphicsPresetFromCurrentSettings();
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
|
|
@ -8909,6 +8953,16 @@ void GameScreen::renderSettingsWindow() {
|
|||
}
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::SeparatorText("Network");
|
||||
ImGui::Spacing();
|
||||
if (ImGui::Checkbox("Show Latency Meter", &pendingShowLatencyMeter)) {
|
||||
showLatencyMeter_ = pendingShowLatencyMeter;
|
||||
saveSettings();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("(ms indicator near minimap)");
|
||||
|
||||
ImGui::EndChild();
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
|
|
@ -9426,6 +9480,175 @@ void GameScreen::renderSettingsWindow() {
|
|||
ImGui::End();
|
||||
}
|
||||
|
||||
void GameScreen::applyGraphicsPreset(GraphicsPreset preset) {
|
||||
auto* renderer = core::Application::getInstance().getRenderer();
|
||||
|
||||
// Define preset values based on quality level
|
||||
switch (preset) {
|
||||
case GraphicsPreset::LOW: {
|
||||
pendingShadows = false;
|
||||
pendingShadowDistance = 100.0f;
|
||||
pendingAntiAliasing = 0; // Off
|
||||
pendingNormalMapping = false;
|
||||
pendingPOM = false;
|
||||
pendingGroundClutterDensity = 25;
|
||||
if (renderer) {
|
||||
renderer->setShadowsEnabled(false);
|
||||
renderer->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT);
|
||||
if (auto* wr = renderer->getWMORenderer()) {
|
||||
wr->setNormalMappingEnabled(false);
|
||||
wr->setPOMEnabled(false);
|
||||
}
|
||||
if (auto* cr = renderer->getCharacterRenderer()) {
|
||||
cr->setNormalMappingEnabled(false);
|
||||
cr->setPOMEnabled(false);
|
||||
}
|
||||
if (auto* tm = renderer->getTerrainManager()) {
|
||||
tm->setGroundClutterDensityScale(0.25f);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GraphicsPreset::MEDIUM: {
|
||||
pendingShadows = true;
|
||||
pendingShadowDistance = 200.0f;
|
||||
pendingAntiAliasing = 1; // 2x MSAA
|
||||
pendingNormalMapping = true;
|
||||
pendingNormalMapStrength = 0.6f;
|
||||
pendingPOM = true;
|
||||
pendingPOMQuality = 0; // Low
|
||||
pendingGroundClutterDensity = 60;
|
||||
if (renderer) {
|
||||
renderer->setShadowsEnabled(true);
|
||||
renderer->setShadowDistance(200.0f);
|
||||
renderer->setMsaaSamples(VK_SAMPLE_COUNT_2_BIT);
|
||||
if (auto* wr = renderer->getWMORenderer()) {
|
||||
wr->setNormalMappingEnabled(true);
|
||||
wr->setNormalMapStrength(0.6f);
|
||||
wr->setPOMEnabled(true);
|
||||
wr->setPOMQuality(0);
|
||||
}
|
||||
if (auto* cr = renderer->getCharacterRenderer()) {
|
||||
cr->setNormalMappingEnabled(true);
|
||||
cr->setNormalMapStrength(0.6f);
|
||||
cr->setPOMEnabled(true);
|
||||
cr->setPOMQuality(0);
|
||||
}
|
||||
if (auto* tm = renderer->getTerrainManager()) {
|
||||
tm->setGroundClutterDensityScale(0.60f);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GraphicsPreset::HIGH: {
|
||||
pendingShadows = true;
|
||||
pendingShadowDistance = 350.0f;
|
||||
pendingAntiAliasing = 2; // 4x MSAA
|
||||
pendingNormalMapping = true;
|
||||
pendingNormalMapStrength = 0.8f;
|
||||
pendingPOM = true;
|
||||
pendingPOMQuality = 1; // Medium
|
||||
pendingGroundClutterDensity = 100;
|
||||
if (renderer) {
|
||||
renderer->setShadowsEnabled(true);
|
||||
renderer->setShadowDistance(350.0f);
|
||||
renderer->setMsaaSamples(VK_SAMPLE_COUNT_4_BIT);
|
||||
if (auto* wr = renderer->getWMORenderer()) {
|
||||
wr->setNormalMappingEnabled(true);
|
||||
wr->setNormalMapStrength(0.8f);
|
||||
wr->setPOMEnabled(true);
|
||||
wr->setPOMQuality(1);
|
||||
}
|
||||
if (auto* cr = renderer->getCharacterRenderer()) {
|
||||
cr->setNormalMappingEnabled(true);
|
||||
cr->setNormalMapStrength(0.8f);
|
||||
cr->setPOMEnabled(true);
|
||||
cr->setPOMQuality(1);
|
||||
}
|
||||
if (auto* tm = renderer->getTerrainManager()) {
|
||||
tm->setGroundClutterDensityScale(1.0f);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GraphicsPreset::ULTRA: {
|
||||
pendingShadows = true;
|
||||
pendingShadowDistance = 500.0f;
|
||||
pendingAntiAliasing = 3; // 8x MSAA
|
||||
pendingNormalMapping = true;
|
||||
pendingNormalMapStrength = 1.2f;
|
||||
pendingPOM = true;
|
||||
pendingPOMQuality = 2; // High
|
||||
pendingGroundClutterDensity = 150;
|
||||
if (renderer) {
|
||||
renderer->setShadowsEnabled(true);
|
||||
renderer->setShadowDistance(500.0f);
|
||||
renderer->setMsaaSamples(VK_SAMPLE_COUNT_8_BIT);
|
||||
if (auto* wr = renderer->getWMORenderer()) {
|
||||
wr->setNormalMappingEnabled(true);
|
||||
wr->setNormalMapStrength(1.2f);
|
||||
wr->setPOMEnabled(true);
|
||||
wr->setPOMQuality(2);
|
||||
}
|
||||
if (auto* cr = renderer->getCharacterRenderer()) {
|
||||
cr->setNormalMappingEnabled(true);
|
||||
cr->setNormalMapStrength(1.2f);
|
||||
cr->setPOMEnabled(true);
|
||||
cr->setPOMQuality(2);
|
||||
}
|
||||
if (auto* tm = renderer->getTerrainManager()) {
|
||||
tm->setGroundClutterDensityScale(1.5f);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
currentGraphicsPreset = preset;
|
||||
pendingGraphicsPreset = preset;
|
||||
}
|
||||
|
||||
void GameScreen::updateGraphicsPresetFromCurrentSettings() {
|
||||
// Check if current settings match any preset, otherwise mark as CUSTOM
|
||||
// This is a simplified check; could be enhanced with more detailed matching
|
||||
|
||||
auto matchesPreset = [this](GraphicsPreset preset) -> bool {
|
||||
switch (preset) {
|
||||
case GraphicsPreset::LOW:
|
||||
return !pendingShadows && pendingAntiAliasing == 0 && !pendingNormalMapping && !pendingPOM &&
|
||||
pendingGroundClutterDensity <= 30;
|
||||
case GraphicsPreset::MEDIUM:
|
||||
return pendingShadows && pendingShadowDistance >= 180 && pendingShadowDistance <= 220 &&
|
||||
pendingAntiAliasing == 1 && pendingNormalMapping && pendingPOM &&
|
||||
pendingGroundClutterDensity >= 50 && pendingGroundClutterDensity <= 70;
|
||||
case GraphicsPreset::HIGH:
|
||||
return pendingShadows && pendingShadowDistance >= 330 && pendingShadowDistance <= 370 &&
|
||||
pendingAntiAliasing == 2 && pendingNormalMapping && pendingPOM &&
|
||||
pendingGroundClutterDensity >= 90 && pendingGroundClutterDensity <= 110;
|
||||
case GraphicsPreset::ULTRA:
|
||||
return pendingShadows && pendingShadowDistance >= 480 && pendingAntiAliasing == 3 &&
|
||||
pendingNormalMapping && pendingPOM && pendingGroundClutterDensity >= 140;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Try to match a preset, otherwise mark as custom
|
||||
if (matchesPreset(GraphicsPreset::LOW)) {
|
||||
pendingGraphicsPreset = GraphicsPreset::LOW;
|
||||
} else if (matchesPreset(GraphicsPreset::MEDIUM)) {
|
||||
pendingGraphicsPreset = GraphicsPreset::MEDIUM;
|
||||
} else if (matchesPreset(GraphicsPreset::HIGH)) {
|
||||
pendingGraphicsPreset = GraphicsPreset::HIGH;
|
||||
} else if (matchesPreset(GraphicsPreset::ULTRA)) {
|
||||
pendingGraphicsPreset = GraphicsPreset::ULTRA;
|
||||
} else {
|
||||
pendingGraphicsPreset = GraphicsPreset::CUSTOM;
|
||||
}
|
||||
}
|
||||
|
||||
void GameScreen::renderQuestMarkers(game::GameHandler& gameHandler) {
|
||||
const auto& statuses = gameHandler.getNpcQuestStatuses();
|
||||
if (statuses.empty()) return;
|
||||
|
|
@ -9868,9 +10091,9 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
|
|||
break; // Show at most one queue slot indicator
|
||||
}
|
||||
|
||||
// Latency indicator (shown when in world and last latency is known)
|
||||
// Latency indicator (toggleable in Interface settings)
|
||||
uint32_t latMs = gameHandler.getLatencyMs();
|
||||
if (latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) {
|
||||
if (showLatencyMeter_ && latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) {
|
||||
ImVec4 latColor;
|
||||
if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.8f); // Green < 100ms
|
||||
else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.8f); // Yellow < 250ms
|
||||
|
|
@ -10128,6 +10351,7 @@ void GameScreen::saveSettings() {
|
|||
out << "minimap_rotate=" << (pendingMinimapRotate ? 1 : 0) << "\n";
|
||||
out << "minimap_square=" << (pendingMinimapSquare ? 1 : 0) << "\n";
|
||||
out << "minimap_npc_dots=" << (pendingMinimapNpcDots ? 1 : 0) << "\n";
|
||||
out << "show_latency_meter=" << (pendingShowLatencyMeter ? 1 : 0) << "\n";
|
||||
out << "separate_bags=" << (pendingSeparateBags ? 1 : 0) << "\n";
|
||||
out << "show_action_bar2=" << (pendingShowActionBar2 ? 1 : 0) << "\n";
|
||||
out << "action_bar2_offset_x=" << pendingActionBar2OffsetX << "\n";
|
||||
|
|
@ -10154,6 +10378,7 @@ void GameScreen::saveSettings() {
|
|||
|
||||
// Gameplay
|
||||
out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n";
|
||||
out << "graphics_preset=" << static_cast<int>(currentGraphicsPreset) << "\n";
|
||||
out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n";
|
||||
out << "shadows=" << (pendingShadows ? 1 : 0) << "\n";
|
||||
out << "shadow_distance=" << pendingShadowDistance << "\n";
|
||||
|
|
@ -10227,6 +10452,9 @@ void GameScreen::loadSettings() {
|
|||
int v = std::stoi(val);
|
||||
minimapNpcDots_ = (v != 0);
|
||||
pendingMinimapNpcDots = minimapNpcDots_;
|
||||
} else if (key == "show_latency_meter") {
|
||||
showLatencyMeter_ = (std::stoi(val) != 0);
|
||||
pendingShowLatencyMeter = showLatencyMeter_;
|
||||
} else if (key == "separate_bags") {
|
||||
pendingSeparateBags = (std::stoi(val) != 0);
|
||||
inventoryScreen.setSeparateBags(pendingSeparateBags);
|
||||
|
|
@ -10267,6 +10495,11 @@ void GameScreen::loadSettings() {
|
|||
else if (key == "activity_volume") pendingActivityVolume = std::clamp(std::stoi(val), 0, 100);
|
||||
// Gameplay
|
||||
else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0);
|
||||
else if (key == "graphics_preset") {
|
||||
int presetVal = std::clamp(std::stoi(val), 0, 4);
|
||||
currentGraphicsPreset = static_cast<GraphicsPreset>(presetVal);
|
||||
pendingGraphicsPreset = currentGraphicsPreset;
|
||||
}
|
||||
else if (key == "ground_clutter_density") pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150);
|
||||
else if (key == "shadows") pendingShadows = (std::stoi(val) != 0);
|
||||
else if (key == "shadow_distance") pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 500.0f);
|
||||
|
|
|
|||
|
|
@ -1357,7 +1357,7 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) {
|
|||
}
|
||||
}
|
||||
|
||||
// Weapon row
|
||||
// Weapon row - positioned to the right of left column to avoid crowding main equipment
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
|
||||
|
|
@ -1366,6 +1366,9 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) {
|
|||
game::EquipSlot::OFF_HAND,
|
||||
game::EquipSlot::RANGED,
|
||||
};
|
||||
|
||||
// Position weapons in center column area (after left column, 3D preview renders on top)
|
||||
ImGui::SetCursorPosX(contentStartX + slotSize + 8.0f);
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (i > 0) ImGui::SameLine();
|
||||
const auto& slot = inventory.getEquipSlot(weaponSlots[i]);
|
||||
|
|
|
|||
|
|
@ -228,9 +228,9 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
|
|||
if (bgIt != bgTextureCache_.end()) {
|
||||
bgTex = bgIt->second;
|
||||
} else {
|
||||
// Try to load the background texture
|
||||
// Only load the background if icon uploads aren't saturating this frame.
|
||||
// Background is cosmetic; skip if we're already loading icons this frame.
|
||||
std::string bgPath = bgFile;
|
||||
// Normalize path separators
|
||||
for (auto& c : bgPath) { if (c == '\\') c = '/'; }
|
||||
bgPath += ".blp";
|
||||
auto blpData = assetManager->readFile(bgPath);
|
||||
|
|
@ -244,6 +244,7 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
|
|||
}
|
||||
}
|
||||
}
|
||||
// Cache even if null to avoid retrying every frame on missing files
|
||||
bgTextureCache_[tabId] = bgTex;
|
||||
}
|
||||
|
||||
|
|
@ -618,6 +619,17 @@ VkDescriptorSet TalentScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManag
|
|||
auto cit = spellIconCache.find(iconId);
|
||||
if (cit != spellIconCache.end()) return cit->second;
|
||||
|
||||
// Rate-limit texture uploads to avoid multi-hundred-ms stalls when switching
|
||||
// to a tab whose icons are not yet cached (each upload is a blocking GPU op).
|
||||
// Allow at most 4 new icon loads per frame; the rest show a blank icon and
|
||||
// load on the next frame, spreading the cost across ~5 frames.
|
||||
static int loadsThisFrame = 0;
|
||||
static int lastImGuiFrame = -1;
|
||||
int curFrame = ImGui::GetFrameCount();
|
||||
if (curFrame != lastImGuiFrame) { loadsThisFrame = 0; lastImGuiFrame = curFrame; }
|
||||
if (loadsThisFrame >= 4) return VK_NULL_HANDLE; // defer, don't cache null
|
||||
++loadsThisFrame;
|
||||
|
||||
auto pit = spellIconPaths.find(iconId);
|
||||
if (pit == spellIconPaths.end()) {
|
||||
spellIconCache[iconId] = VK_NULL_HANDLE;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue