commit
7f0d4210dc
30 changed files with 5698 additions and 0 deletions
@ -0,0 +1,148 @@ |
|||||||
|
# Byte-compiled / optimized / DLL files |
||||||
|
__pycache__/ |
||||||
|
*.py[cod] |
||||||
|
*$py.class |
||||||
|
|
||||||
|
# C extensions |
||||||
|
*.so |
||||||
|
*.dll |
||||||
|
*.dylib |
||||||
|
|
||||||
|
# Distribution / packaging |
||||||
|
.Python |
||||||
|
build/ |
||||||
|
develop-eggs/ |
||||||
|
dist/ |
||||||
|
downloads/ |
||||||
|
eggs/ |
||||||
|
.eggs/ |
||||||
|
lib/ |
||||||
|
lib64/ |
||||||
|
parts/ |
||||||
|
sdist/ |
||||||
|
var/ |
||||||
|
wheels/ |
||||||
|
pip-wheel-metadata/ |
||||||
|
share/python-wheels/ |
||||||
|
*.egg-info/ |
||||||
|
.installed.cfg |
||||||
|
*.egg |
||||||
|
MANIFEST |
||||||
|
|
||||||
|
# PyInstaller |
||||||
|
*.manifest |
||||||
|
*.spec |
||||||
|
|
||||||
|
# Installer logs |
||||||
|
pip-log.txt |
||||||
|
pip-delete-this-directory.txt |
||||||
|
|
||||||
|
# Unit test / coverage reports |
||||||
|
htmlcov/ |
||||||
|
.tox/ |
||||||
|
.nox/ |
||||||
|
.coverage |
||||||
|
.coverage.* |
||||||
|
.cache |
||||||
|
nosetests.xml |
||||||
|
coverage.xml |
||||||
|
*.cover |
||||||
|
*.py,cover |
||||||
|
.hypothesis/ |
||||||
|
.pytest_cache/ |
||||||
|
|
||||||
|
# Translations |
||||||
|
*.mo |
||||||
|
*.pot |
||||||
|
|
||||||
|
# Django stuff: |
||||||
|
*.log |
||||||
|
local_settings.py |
||||||
|
db.sqlite3 |
||||||
|
db.sqlite3-journal |
||||||
|
|
||||||
|
# Flask stuff: |
||||||
|
instance/ |
||||||
|
.webassets-cache |
||||||
|
|
||||||
|
# Scrapy stuff: |
||||||
|
.scrapy |
||||||
|
|
||||||
|
# Sphinx documentation |
||||||
|
docs/_build/ |
||||||
|
|
||||||
|
# PyBuilder |
||||||
|
target/ |
||||||
|
|
||||||
|
# Jupyter Notebook |
||||||
|
.ipynb_checkpoints |
||||||
|
|
||||||
|
# IPython |
||||||
|
profile_default/ |
||||||
|
ipython_config.py |
||||||
|
|
||||||
|
# pyenv |
||||||
|
.python-version |
||||||
|
|
||||||
|
# pipenv |
||||||
|
Pipfile.lock |
||||||
|
|
||||||
|
# PEP 582 |
||||||
|
__pypackages__/ |
||||||
|
|
||||||
|
# Celery stuff |
||||||
|
celerybeat-schedule |
||||||
|
celerybeat.pid |
||||||
|
|
||||||
|
# SageMath parsed files |
||||||
|
*.sage.py |
||||||
|
|
||||||
|
# Environments |
||||||
|
.env |
||||||
|
.venv |
||||||
|
env/ |
||||||
|
venv/ |
||||||
|
ENV/ |
||||||
|
env.bak/ |
||||||
|
venv.bak/ |
||||||
|
|
||||||
|
# Spyder project settings |
||||||
|
.spyderproject |
||||||
|
.spyproject |
||||||
|
|
||||||
|
# Rope project settings |
||||||
|
.ropeproject |
||||||
|
|
||||||
|
# mkdocs documentation |
||||||
|
/site |
||||||
|
|
||||||
|
# mypy |
||||||
|
.mypy_cache/ |
||||||
|
.dmypy.json |
||||||
|
dmypy.json |
||||||
|
|
||||||
|
# Pyre type checker |
||||||
|
.pyre/ |
||||||
|
|
||||||
|
# IDE |
||||||
|
.vscode/ |
||||||
|
.idea/ |
||||||
|
*.swp |
||||||
|
*.swo |
||||||
|
*~ |
||||||
|
|
||||||
|
# OS |
||||||
|
.DS_Store |
||||||
|
Thumbs.db |
||||||
|
|
||||||
|
# Project specific |
||||||
|
*.mpq |
||||||
|
test_*.txt |
||||||
|
extracted_*/ |
||||||
|
assets/ |
||||||
|
|
||||||
|
# Build artifacts |
||||||
|
build/ |
||||||
|
StormLib/ |
||||||
|
*.o |
||||||
|
*.obj |
||||||
@ -0,0 +1,264 @@ |
|||||||
|
# Build Instructions |
||||||
|
|
||||||
|
## Building StormLib for PyStorm |
||||||
|
|
||||||
|
PyStorm requires the StormLib shared library. You can either: |
||||||
|
1. Build it automatically using the provided script |
||||||
|
2. Build it manually |
||||||
|
3. Use a system-wide installation |
||||||
|
|
||||||
|
## Option 1: Automatic Build (Recommended) |
||||||
|
|
||||||
|
```bash |
||||||
|
# Build StormLib and embed it in the package |
||||||
|
python3 build_stormlib.py |
||||||
|
|
||||||
|
# Then install PyStorm |
||||||
|
pip install -e . |
||||||
|
``` |
||||||
|
|
||||||
|
The build script will: |
||||||
|
- Clone StormLib from GitHub |
||||||
|
- Compile it using CMake |
||||||
|
- Copy the compiled library to the `pystorm/` package directory |
||||||
|
|
||||||
|
## Option 2: Manual Build |
||||||
|
|
||||||
|
### Prerequisites |
||||||
|
|
||||||
|
- **git**: For cloning repositories |
||||||
|
- **cmake**: Build system (version 3.10+) |
||||||
|
- **C/C++ compiler**: GCC, Clang, or MSVC |
||||||
|
|
||||||
|
### Linux/macOS |
||||||
|
|
||||||
|
```bash |
||||||
|
# Install prerequisites |
||||||
|
# Ubuntu/Debian: |
||||||
|
sudo apt-get install git cmake build-essential |
||||||
|
|
||||||
|
# macOS: |
||||||
|
brew install cmake |
||||||
|
|
||||||
|
# Clone StormLib |
||||||
|
git clone https://github.com/ladislav-zezula/StormLib.git |
||||||
|
cd StormLib |
||||||
|
|
||||||
|
# Build |
||||||
|
mkdir build && cd build |
||||||
|
cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON |
||||||
|
cmake --build . --config Release |
||||||
|
|
||||||
|
# Copy library to PyStorm package |
||||||
|
cd ../../ |
||||||
|
cp StormLib/build/libstorm.so* pystorm/ # Linux |
||||||
|
# or |
||||||
|
cp StormLib/build/libstorm.dylib pystorm/ # macOS |
||||||
|
|
||||||
|
# Install PyStorm |
||||||
|
pip install -e . |
||||||
|
``` |
||||||
|
|
||||||
|
### Windows |
||||||
|
|
||||||
|
```bash |
||||||
|
# Install prerequisites |
||||||
|
# - Install Visual Studio with C++ support |
||||||
|
# - Install CMake from https://cmake.org/ |
||||||
|
|
||||||
|
# Clone StormLib |
||||||
|
git clone https://github.com/ladislav-zezula/StormLib.git |
||||||
|
cd StormLib |
||||||
|
|
||||||
|
# Build |
||||||
|
mkdir build |
||||||
|
cd build |
||||||
|
cmake .. -DBUILD_SHARED_LIBS=ON |
||||||
|
cmake --build . --config Release |
||||||
|
|
||||||
|
# Copy library to PyStorm package |
||||||
|
cd ..\..\ |
||||||
|
copy StormLib\build\Release\StormLib.dll pystorm\ |
||||||
|
|
||||||
|
# Install PyStorm |
||||||
|
pip install -e . |
||||||
|
``` |
||||||
|
|
||||||
|
## Option 3: System-Wide Installation |
||||||
|
|
||||||
|
Install StormLib to system library paths: |
||||||
|
|
||||||
|
### Linux |
||||||
|
|
||||||
|
```bash |
||||||
|
git clone https://github.com/ladislav-zezula/StormLib.git |
||||||
|
cd StormLib |
||||||
|
mkdir build && cd build |
||||||
|
cmake .. |
||||||
|
make |
||||||
|
sudo make install |
||||||
|
sudo ldconfig |
||||||
|
|
||||||
|
# Then install PyStorm |
||||||
|
pip install pystorm |
||||||
|
``` |
||||||
|
|
||||||
|
### macOS |
||||||
|
|
||||||
|
```bash |
||||||
|
git clone https://github.com/ladislav-zezula/StormLib.git |
||||||
|
cd StormLib |
||||||
|
mkdir build && cd build |
||||||
|
cmake .. |
||||||
|
make |
||||||
|
sudo make install |
||||||
|
|
||||||
|
# Then install PyStorm |
||||||
|
pip install pystorm |
||||||
|
``` |
||||||
|
|
||||||
|
### Windows |
||||||
|
|
||||||
|
Build StormLib and copy the DLL to: |
||||||
|
- `C:\Windows\System32\` (system-wide) |
||||||
|
- Or the Python Scripts directory |
||||||
|
- Or keep it with your application |
||||||
|
|
||||||
|
## Verification |
||||||
|
|
||||||
|
Test that everything works: |
||||||
|
|
||||||
|
```python |
||||||
|
import pystorm |
||||||
|
print(pystorm.__version__) |
||||||
|
|
||||||
|
# Try basic operations |
||||||
|
from pystorm import MPQArchive |
||||||
|
print("PyStorm is ready to use!") |
||||||
|
``` |
||||||
|
|
||||||
|
## Troubleshooting |
||||||
|
|
||||||
|
### Library Not Found |
||||||
|
|
||||||
|
If you get "StormLib not loaded" error: |
||||||
|
|
||||||
|
1. **Check package directory**: Ensure library is in `pystorm/` directory |
||||||
|
```bash |
||||||
|
ls pystorm/*.so # Linux |
||||||
|
ls pystorm/*.dylib # macOS |
||||||
|
dir pystorm\*.dll # Windows |
||||||
|
``` |
||||||
|
|
||||||
|
2. **Check system paths**: Ensure library is in library path |
||||||
|
```bash |
||||||
|
ldconfig -p | grep storm # Linux |
||||||
|
otool -L pystorm/libstorm.dylib # macOS (to check dependencies) |
||||||
|
``` |
||||||
|
|
||||||
|
3. **Set library path** (temporary): |
||||||
|
```bash |
||||||
|
export LD_LIBRARY_PATH=/path/to/pystorm:$LD_LIBRARY_PATH # Linux |
||||||
|
export DYLD_LIBRARY_PATH=/path/to/pystorm:$DYLD_LIBRARY_PATH # macOS |
||||||
|
``` |
||||||
|
|
||||||
|
### Build Failures |
||||||
|
|
||||||
|
**CMake not found**: |
||||||
|
```bash |
||||||
|
# Linux |
||||||
|
sudo apt-get install cmake |
||||||
|
|
||||||
|
# macOS |
||||||
|
brew install cmake |
||||||
|
|
||||||
|
# Windows |
||||||
|
# Download from https://cmake.org/ |
||||||
|
``` |
||||||
|
|
||||||
|
**Compiler not found**: |
||||||
|
```bash |
||||||
|
# Linux |
||||||
|
sudo apt-get install build-essential |
||||||
|
|
||||||
|
# macOS |
||||||
|
xcode-select --install |
||||||
|
|
||||||
|
# Windows |
||||||
|
# Install Visual Studio with C++ support |
||||||
|
``` |
||||||
|
|
||||||
|
**Git not found**: |
||||||
|
```bash |
||||||
|
# Linux |
||||||
|
sudo apt-get install git |
||||||
|
|
||||||
|
# macOS |
||||||
|
brew install git |
||||||
|
|
||||||
|
# Windows |
||||||
|
# Download from https://git-scm.com/ |
||||||
|
``` |
||||||
|
|
||||||
|
## Platform-Specific Notes |
||||||
|
|
||||||
|
### Linux |
||||||
|
- Library name: `libstorm.so` or `libstorm.so.X.X.X` |
||||||
|
- May need `sudo ldconfig` after system-wide install |
||||||
|
- Compatible with most distributions |
||||||
|
|
||||||
|
### macOS |
||||||
|
- Library name: `libstorm.dylib` |
||||||
|
- Minimum deployment target: macOS 10.13 |
||||||
|
- May need to sign library for distribution |
||||||
|
|
||||||
|
### Windows |
||||||
|
- Library name: `StormLib.dll` |
||||||
|
- Requires Visual Studio runtime |
||||||
|
- May need to copy additional DLLs |
||||||
|
|
||||||
|
## Creating Distributable Packages |
||||||
|
|
||||||
|
To create a wheel with embedded library: |
||||||
|
|
||||||
|
```bash |
||||||
|
# Build the library |
||||||
|
python3 build_stormlib.py |
||||||
|
|
||||||
|
# Build wheel |
||||||
|
pip install build |
||||||
|
python3 -m build |
||||||
|
|
||||||
|
# Result: dist/pystorm-1.0.0-py3-none-any.whl |
||||||
|
``` |
||||||
|
|
||||||
|
The wheel will include the compiled library and can be distributed. |
||||||
|
|
||||||
|
## Docker Build Example |
||||||
|
|
||||||
|
```dockerfile |
||||||
|
FROM python:3.11-slim |
||||||
|
|
||||||
|
# Install build dependencies |
||||||
|
RUN apt-get update && apt-get install -y \ |
||||||
|
git cmake build-essential \ |
||||||
|
&& rm -rf /var/lib/apt/lists/* |
||||||
|
|
||||||
|
# Clone and install PyStorm |
||||||
|
RUN git clone https://github.com/enne2/pystorm.git |
||||||
|
WORKDIR /pystorm |
||||||
|
|
||||||
|
# Build StormLib |
||||||
|
RUN python3 build_stormlib.py |
||||||
|
|
||||||
|
# Install PyStorm |
||||||
|
RUN pip install -e . |
||||||
|
|
||||||
|
# Verify installation |
||||||
|
RUN python3 -c "import pystorm; print(pystorm.__version__)" |
||||||
|
``` |
||||||
|
|
||||||
|
## License |
||||||
|
|
||||||
|
StormLib is licensed under the MIT License by Ladislav Zezula. |
||||||
|
PyStorm is also MIT licensed. |
||||||
@ -0,0 +1,30 @@ |
|||||||
|
MIT License |
||||||
|
|
||||||
|
Copyright (c) 2024 Matteo Benedetto |
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||||
|
of this software and associated documentation files (the "Software"), to deal |
||||||
|
in the Software without restriction, including without limitation the rights |
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||||
|
copies of the Software, and to permit persons to whom the Software is |
||||||
|
furnished to do so, subject to the following conditions: |
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all |
||||||
|
copies or substantial portions of the Software. |
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||||
|
SOFTWARE. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
This project uses StormLib, which is licensed under the MIT License: |
||||||
|
|
||||||
|
StormLib - A library for reading and writing MPQ archives |
||||||
|
Copyright (c) Ladislav Zezula 1998-2023 |
||||||
|
|
||||||
|
https://github.com/ladislav-zezula/StormLib |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
include README.md |
||||||
|
include LICENSE |
||||||
|
include requirements.txt |
||||||
|
recursive-include pystorm *.py |
||||||
|
recursive-include examples *.py |
||||||
|
recursive-exclude * __pycache__ |
||||||
|
recursive-exclude * *.py[co] |
||||||
|
recursive-exclude * *.so |
||||||
|
recursive-exclude * *.dll |
||||||
|
recursive-exclude * *.dylib |
||||||
@ -0,0 +1,82 @@ |
|||||||
|
# MPQ Inspector GUI Demo |
||||||
|
|
||||||
|
A graphical user interface for inspecting and working with MPQ archives. |
||||||
|
|
||||||
|
## Features |
||||||
|
|
||||||
|
- **Open MPQ Archives**: Browse and open `.mpq`, `.SC2Data`, `.SC2Map`, `.SC2Mod` files |
||||||
|
- **File Listing**: View all files in the archive with details: |
||||||
|
- Original size |
||||||
|
- Compressed size |
||||||
|
- Compression ratio |
||||||
|
- File flags (Compressed, Encrypted, Single Unit) |
||||||
|
- **Archive Statistics**: See total files, sizes, and overall compression ratio |
||||||
|
- **Extract Files**: |
||||||
|
- Extract individual files via context menu or double-click |
||||||
|
- Extract all files at once to a directory |
||||||
|
- **File Information**: View detailed information about any file |
||||||
|
- **Archive Verification**: Verify the integrity of the MPQ archive |
||||||
|
- **Keyboard Shortcuts**: |
||||||
|
- `Ctrl+O`: Open archive |
||||||
|
- `Ctrl+W`: Close archive |
||||||
|
- `Ctrl+Q`: Quit application |
||||||
|
|
||||||
|
## Running the Demo |
||||||
|
|
||||||
|
Make sure you have PyStorm installed: |
||||||
|
|
||||||
|
```bash |
||||||
|
# Activate virtual environment |
||||||
|
source venv/bin/activate |
||||||
|
|
||||||
|
# Launch the GUI |
||||||
|
python mpq_inspector.py |
||||||
|
``` |
||||||
|
|
||||||
|
Or make it executable and run directly: |
||||||
|
|
||||||
|
```bash |
||||||
|
chmod +x mpq_inspector.py |
||||||
|
./mpq_inspector.py |
||||||
|
``` |
||||||
|
|
||||||
|
## Usage |
||||||
|
|
||||||
|
1. **Open an Archive**: Click "Browse..." or press `Ctrl+O` to select an MPQ file |
||||||
|
2. **View Files**: The file list shows all contents with compression details |
||||||
|
3. **Extract Files**: |
||||||
|
- Right-click on a file → "Extract File..." |
||||||
|
- Or click "Extract All..." to extract everything |
||||||
|
4. **Get File Info**: Double-click any file to see detailed information |
||||||
|
5. **Verify Archive**: Click "Verify Archive" to check file integrity |
||||||
|
|
||||||
|
## Flag Meanings |
||||||
|
|
||||||
|
- `C`: Compressed file |
||||||
|
- `E`: Encrypted file |
||||||
|
- `S`: Single unit file |
||||||
|
- `-`: No special flags |
||||||
|
|
||||||
|
## Requirements |
||||||
|
|
||||||
|
- Python 3.7+ |
||||||
|
- tkinter (usually comes with Python) |
||||||
|
- PyStorm library |
||||||
|
|
||||||
|
## Screenshots |
||||||
|
|
||||||
|
The interface includes: |
||||||
|
- Top bar with file browser |
||||||
|
- Statistics panel showing archive info |
||||||
|
- Tree view with sortable columns |
||||||
|
- Context menu for file operations |
||||||
|
- Progress dialogs for batch operations |
||||||
|
|
||||||
|
## Note |
||||||
|
|
||||||
|
You'll need actual MPQ files to test the demo. These are typically found in: |
||||||
|
- Blizzard games (Diablo, StarCraft, Warcraft III, World of Warcraft) |
||||||
|
- Custom maps and mods for these games |
||||||
|
- `.SC2Data`, `.SC2Map`, `.SC2Mod` files from StarCraft II |
||||||
|
|
||||||
|
If you don't have MPQ files, you can create one using the `create_archive.py` example first! |
||||||
@ -0,0 +1,100 @@ |
|||||||
|
# PyStorm Testing & Debugging |
||||||
|
|
||||||
|
## Fixed Issues |
||||||
|
|
||||||
|
### SFILE_FIND_DATA Structure (v1.0.1) |
||||||
|
|
||||||
|
**Problem**: Segmentation fault when listing files in MPQ archives. |
||||||
|
|
||||||
|
**Root Cause**: The `SFILE_FIND_DATA` structure was incorrectly defined with `cFileName` as a pointer (`c_char_p`) instead of a fixed-size character array. |
||||||
|
|
||||||
|
**Solution**: Changed the structure definition to match StormLib's C header: |
||||||
|
|
||||||
|
```python |
||||||
|
# WRONG (caused segfault): |
||||||
|
class SFILE_FIND_DATA(Structure): |
||||||
|
_fields_ = [ |
||||||
|
("cFileName", c_char_p), # ❌ Pointer |
||||||
|
... |
||||||
|
] |
||||||
|
|
||||||
|
# CORRECT: |
||||||
|
class SFILE_FIND_DATA(Structure): |
||||||
|
_fields_ = [ |
||||||
|
("cFileName", c_char * 260), # ✅ Fixed array (MAX_PATH) |
||||||
|
... |
||||||
|
] |
||||||
|
``` |
||||||
|
|
||||||
|
This is because StormLib allocates the structure on the stack with a fixed-size buffer, not a heap-allocated string. |
||||||
|
|
||||||
|
## Testing with Real MPQ Files |
||||||
|
|
||||||
|
### Using Starcraft.mpq |
||||||
|
|
||||||
|
The `debug_starcraft.py` script tests PyStorm functionality with a real Starcraft MPQ archive: |
||||||
|
|
||||||
|
```bash |
||||||
|
source venv/bin/activate |
||||||
|
python debug_starcraft.py |
||||||
|
``` |
||||||
|
|
||||||
|
**What it tests**: |
||||||
|
1. ✅ Import PyStorm |
||||||
|
2. ✅ Check file exists |
||||||
|
3. ✅ Open archive |
||||||
|
4. ✅ List files (890 files found) |
||||||
|
5. ✅ Extract file |
||||||
|
6. ✅ Close archive |
||||||
|
|
||||||
|
**Note**: Some MPQ files report size=0 in metadata but contain actual data. This is normal for certain archive types. |
||||||
|
|
||||||
|
## GUI Demo |
||||||
|
|
||||||
|
Launch the MPQ Inspector GUI: |
||||||
|
|
||||||
|
```bash |
||||||
|
source venv/bin/activate |
||||||
|
python mpq_inspector.py |
||||||
|
``` |
||||||
|
|
||||||
|
Then use **File → Open MPQ** to browse and select `Starcraft.mpq`. |
||||||
|
|
||||||
|
## Known Issues |
||||||
|
|
||||||
|
### Metadata Size = 0 |
||||||
|
|
||||||
|
Some files in the archive report `dwFileSize = 0` in the file table, but extraction still works correctly. This appears to be a characteristic of the specific MPQ format version used in Starcraft. |
||||||
|
|
||||||
|
### Double Close Warning |
||||||
|
|
||||||
|
If you manually close an archive and let Python's garbage collector also call `__del__`, you might see a segfault. The fix is to set `archive._closed = True` after manual close, or just rely on the context manager (`with` statement). |
||||||
|
|
||||||
|
## Platform Notes |
||||||
|
|
||||||
|
### Linux |
||||||
|
- Library: `libstorm.so` (compiled from StormLib source) |
||||||
|
- Tested on: Ubuntu-like systems with Python 3.13 |
||||||
|
- No issues |
||||||
|
|
||||||
|
### Windows |
||||||
|
- Library: `StormLib.dll` |
||||||
|
- Not yet tested |
||||||
|
|
||||||
|
### macOS |
||||||
|
- Library: `libstorm.dylib` |
||||||
|
- Not yet tested |
||||||
|
|
||||||
|
## Development |
||||||
|
|
||||||
|
When working on PyStorm: |
||||||
|
|
||||||
|
1. Always activate the virtual environment |
||||||
|
2. Test with real MPQ files, not just synthetic ones |
||||||
|
3. Use `debug_starcraft.py` as a regression test |
||||||
|
4. The GUI (`mpq_inspector.py`) is the best end-to-end test |
||||||
|
|
||||||
|
## Credits |
||||||
|
|
||||||
|
- StormLib by Ladislav Zezula: https://github.com/ladislav-zezula/StormLib |
||||||
|
- Testing MPQ: Blizzard Entertainment's StarCraft |
||||||
@ -0,0 +1,221 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Build script to compile StormLib and prepare it for packaging |
||||||
|
""" |
||||||
|
|
||||||
|
import os |
||||||
|
import sys |
||||||
|
import shutil |
||||||
|
import subprocess |
||||||
|
import platform |
||||||
|
from pathlib import Path |
||||||
|
|
||||||
|
|
||||||
|
def get_platform_info(): |
||||||
|
"""Get platform-specific information""" |
||||||
|
system = platform.system() |
||||||
|
machine = platform.machine() |
||||||
|
|
||||||
|
if system == 'Linux': |
||||||
|
lib_name = 'libstorm.so' |
||||||
|
lib_pattern = '*.so*' |
||||||
|
elif system == 'Darwin': |
||||||
|
lib_name = 'libstorm.dylib' |
||||||
|
lib_pattern = '*.dylib' |
||||||
|
elif system == 'Windows': |
||||||
|
lib_name = 'StormLib.dll' |
||||||
|
lib_pattern = '*.dll' |
||||||
|
else: |
||||||
|
raise RuntimeError(f"Unsupported platform: {system}") |
||||||
|
|
||||||
|
return { |
||||||
|
'system': system, |
||||||
|
'machine': machine, |
||||||
|
'lib_name': lib_name, |
||||||
|
'lib_pattern': lib_pattern |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
def check_command(cmd): |
||||||
|
"""Check if a command is available""" |
||||||
|
try: |
||||||
|
subprocess.run([cmd, '--version'], |
||||||
|
stdout=subprocess.DEVNULL, |
||||||
|
stderr=subprocess.DEVNULL, |
||||||
|
check=False) |
||||||
|
return True |
||||||
|
except FileNotFoundError: |
||||||
|
return False |
||||||
|
|
||||||
|
|
||||||
|
def clone_stormlib(build_dir): |
||||||
|
"""Clone StormLib repository""" |
||||||
|
stormlib_dir = build_dir / 'StormLib' |
||||||
|
|
||||||
|
if stormlib_dir.exists(): |
||||||
|
print("StormLib directory already exists, using existing clone") |
||||||
|
return stormlib_dir |
||||||
|
|
||||||
|
print("Cloning StormLib repository...") |
||||||
|
result = subprocess.run( |
||||||
|
['git', 'clone', 'https://github.com/ladislav-zezula/StormLib.git', str(stormlib_dir)], |
||||||
|
cwd=build_dir |
||||||
|
) |
||||||
|
|
||||||
|
if result.returncode != 0: |
||||||
|
raise RuntimeError("Failed to clone StormLib") |
||||||
|
|
||||||
|
return stormlib_dir |
||||||
|
|
||||||
|
|
||||||
|
def build_stormlib(stormlib_dir, platform_info): |
||||||
|
"""Build StormLib using CMake""" |
||||||
|
build_subdir = stormlib_dir / 'build' |
||||||
|
build_subdir.mkdir(exist_ok=True) |
||||||
|
|
||||||
|
print(f"Building StormLib for {platform_info['system']}...") |
||||||
|
|
||||||
|
# Configure with CMake |
||||||
|
cmake_args = [ |
||||||
|
'cmake', |
||||||
|
'..', |
||||||
|
'-DCMAKE_BUILD_TYPE=Release', |
||||||
|
'-DBUILD_SHARED_LIBS=ON', |
||||||
|
] |
||||||
|
|
||||||
|
# Add platform-specific flags |
||||||
|
if platform_info['system'] == 'Darwin': |
||||||
|
# macOS specific flags |
||||||
|
cmake_args.extend([ |
||||||
|
'-DCMAKE_OSX_DEPLOYMENT_TARGET=10.13', |
||||||
|
]) |
||||||
|
|
||||||
|
print("Configuring with CMake...") |
||||||
|
result = subprocess.run(cmake_args, cwd=build_subdir) |
||||||
|
if result.returncode != 0: |
||||||
|
raise RuntimeError("CMake configuration failed") |
||||||
|
|
||||||
|
# Build |
||||||
|
print("Compiling...") |
||||||
|
result = subprocess.run(['cmake', '--build', '.', '--config', 'Release'], cwd=build_subdir) |
||||||
|
if result.returncode != 0: |
||||||
|
raise RuntimeError("Compilation failed") |
||||||
|
|
||||||
|
return build_subdir |
||||||
|
|
||||||
|
|
||||||
|
def find_library(build_dir, platform_info): |
||||||
|
"""Find the compiled library file""" |
||||||
|
# Search in common locations |
||||||
|
search_paths = [ |
||||||
|
build_dir, |
||||||
|
build_dir / 'Release', |
||||||
|
build_dir / 'Debug', |
||||||
|
] |
||||||
|
|
||||||
|
for search_path in search_paths: |
||||||
|
if not search_path.exists(): |
||||||
|
continue |
||||||
|
|
||||||
|
# Look for library files |
||||||
|
for lib_file in search_path.glob(platform_info['lib_pattern']): |
||||||
|
if platform_info['lib_name'] in lib_file.name: |
||||||
|
return lib_file |
||||||
|
|
||||||
|
raise RuntimeError(f"Could not find compiled library: {platform_info['lib_name']}") |
||||||
|
|
||||||
|
|
||||||
|
def copy_library_to_package(lib_file, package_dir): |
||||||
|
"""Copy the compiled library to the package directory""" |
||||||
|
dest = package_dir / lib_file.name |
||||||
|
|
||||||
|
print(f"Copying {lib_file.name} to package directory...") |
||||||
|
shutil.copy2(lib_file, dest) |
||||||
|
|
||||||
|
# On Linux, also handle symlinks |
||||||
|
if platform.system() == 'Linux': |
||||||
|
# Create a simple versioned name link |
||||||
|
base_name = 'libstorm.so' |
||||||
|
if lib_file.name != base_name: |
||||||
|
symlink_dest = package_dir / base_name |
||||||
|
if symlink_dest.exists() or symlink_dest.is_symlink(): |
||||||
|
symlink_dest.unlink() |
||||||
|
os.symlink(lib_file.name, symlink_dest) |
||||||
|
print(f"Created symlink: {base_name} -> {lib_file.name}") |
||||||
|
|
||||||
|
return dest |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
"""Main build function""" |
||||||
|
print("="*70) |
||||||
|
print("Building StormLib for PyStorm") |
||||||
|
print("="*70) |
||||||
|
print() |
||||||
|
|
||||||
|
# Get project root directory |
||||||
|
project_root = Path(__file__).parent.absolute() |
||||||
|
build_dir = project_root / 'build' |
||||||
|
package_dir = project_root / 'pystorm' |
||||||
|
|
||||||
|
# Check prerequisites |
||||||
|
print("Checking prerequisites...") |
||||||
|
if not check_command('git'): |
||||||
|
print("ERROR: git is not installed") |
||||||
|
return 1 |
||||||
|
|
||||||
|
if not check_command('cmake'): |
||||||
|
print("ERROR: cmake is not installed") |
||||||
|
print("Please install cmake:") |
||||||
|
print(" - Linux: sudo apt-get install cmake") |
||||||
|
print(" - macOS: brew install cmake") |
||||||
|
print(" - Windows: Download from https://cmake.org/") |
||||||
|
return 1 |
||||||
|
|
||||||
|
# Get platform info |
||||||
|
platform_info = get_platform_info() |
||||||
|
print(f"Platform: {platform_info['system']} ({platform_info['machine']})") |
||||||
|
print(f"Target library: {platform_info['lib_name']}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Create build directory |
||||||
|
build_dir.mkdir(exist_ok=True) |
||||||
|
|
||||||
|
try: |
||||||
|
# Clone StormLib |
||||||
|
stormlib_dir = clone_stormlib(build_dir) |
||||||
|
|
||||||
|
# Build StormLib |
||||||
|
build_subdir = build_stormlib(stormlib_dir, platform_info) |
||||||
|
|
||||||
|
# Find the compiled library |
||||||
|
lib_file = find_library(build_subdir, platform_info) |
||||||
|
print(f"Found compiled library: {lib_file}") |
||||||
|
|
||||||
|
# Copy to package directory |
||||||
|
dest_file = copy_library_to_package(lib_file, package_dir) |
||||||
|
|
||||||
|
print() |
||||||
|
print("="*70) |
||||||
|
print("Build completed successfully!") |
||||||
|
print("="*70) |
||||||
|
print(f"Library installed to: {dest_file}") |
||||||
|
print() |
||||||
|
print("You can now install the package:") |
||||||
|
print(" pip install -e .") |
||||||
|
print() |
||||||
|
|
||||||
|
return 0 |
||||||
|
|
||||||
|
except Exception as e: |
||||||
|
print() |
||||||
|
print("="*70) |
||||||
|
print("Build failed!") |
||||||
|
print("="*70) |
||||||
|
print(f"Error: {e}") |
||||||
|
print() |
||||||
|
return 1 |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': |
||||||
|
sys.exit(main()) |
||||||
@ -0,0 +1,122 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Create a sample MPQ archive for testing the MPQ Inspector |
||||||
|
This creates a small archive with a few text files |
||||||
|
""" |
||||||
|
|
||||||
|
from pathlib import Path |
||||||
|
import sys |
||||||
|
|
||||||
|
try: |
||||||
|
from pystorm import MPQArchive, MPQ_CREATE_ARCHIVE_V2, MPQ_FILE_COMPRESS, StormLibError |
||||||
|
except ImportError: |
||||||
|
print("Error: PyStorm not installed. Please run: pip install -e .") |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
|
||||||
|
def create_sample_mpq(): |
||||||
|
"""Create a sample MPQ archive with test files""" |
||||||
|
|
||||||
|
# Create temporary directory with sample files |
||||||
|
temp_dir = Path("temp_sample_files") |
||||||
|
temp_dir.mkdir(exist_ok=True) |
||||||
|
|
||||||
|
# Create some sample text files |
||||||
|
files_to_add = { |
||||||
|
"readme.txt": """MPQ Archive Sample |
||||||
|
================== |
||||||
|
|
||||||
|
This is a sample MPQ archive created by PyStorm. |
||||||
|
|
||||||
|
MPQ (MoPaQ) is an archive format used by Blizzard Entertainment |
||||||
|
games including Diablo, StarCraft, Warcraft III, and World of Warcraft. |
||||||
|
|
||||||
|
This archive contains several sample files for demonstration purposes. |
||||||
|
""", |
||||||
|
"data/config.txt": """# Sample Configuration File |
||||||
|
setting1=value1 |
||||||
|
setting2=value2 |
||||||
|
enabled=true |
||||||
|
compression=zlib |
||||||
|
""", |
||||||
|
"data/items.txt": """ItemID,Name,Type,Value |
||||||
|
1,Sword,Weapon,100 |
||||||
|
2,Shield,Armor,75 |
||||||
|
3,Potion,Consumable,25 |
||||||
|
4,Ring,Accessory,150 |
||||||
|
""", |
||||||
|
"scripts/init.lua": """-- Sample Lua Script |
||||||
|
print("Loading MPQ archive...") |
||||||
|
|
||||||
|
function initialize() |
||||||
|
print("Initialization complete!") |
||||||
|
end |
||||||
|
|
||||||
|
initialize() |
||||||
|
""", |
||||||
|
"maps/level1.txt": """MAP: Level 1 |
||||||
|
Size: 100x100 |
||||||
|
Tiles: 10000 |
||||||
|
Objects: 250 |
||||||
|
Enemies: 15 |
||||||
|
""", |
||||||
|
} |
||||||
|
|
||||||
|
# Create the files |
||||||
|
created_files = [] |
||||||
|
for filename, content in files_to_add.items(): |
||||||
|
filepath = temp_dir / filename |
||||||
|
filepath.parent.mkdir(parents=True, exist_ok=True) |
||||||
|
filepath.write_text(content) |
||||||
|
created_files.append(filepath) |
||||||
|
print(f"Created: {filename}") |
||||||
|
|
||||||
|
# Create the MPQ archive |
||||||
|
archive_path = "sample_archive.mpq" |
||||||
|
|
||||||
|
try: |
||||||
|
print(f"\nCreating MPQ archive: {archive_path}") |
||||||
|
with MPQArchive(archive_path, flags=MPQ_CREATE_ARCHIVE_V2) as archive: |
||||||
|
for filepath in created_files: |
||||||
|
# Get archived name (relative path with forward slashes) |
||||||
|
archived_name = str(filepath.relative_to(temp_dir)) |
||||||
|
archived_name = archived_name.replace("\\", "/") |
||||||
|
|
||||||
|
# Add with compression |
||||||
|
archive.add_file(str(filepath), archived_name, flags=MPQ_FILE_COMPRESS) |
||||||
|
print(f"Added to archive: {archived_name}") |
||||||
|
|
||||||
|
# Flush changes |
||||||
|
archive.flush() |
||||||
|
|
||||||
|
print(f"\n✓ Sample archive created: {archive_path}") |
||||||
|
print(f" Size: {Path(archive_path).stat().st_size} bytes") |
||||||
|
print("\nYou can now open this file with mpq_inspector.py!") |
||||||
|
|
||||||
|
except StormLibError as e: |
||||||
|
print(f"\n✗ Error creating archive: {e}") |
||||||
|
return False |
||||||
|
finally: |
||||||
|
# Clean up temporary files |
||||||
|
for filepath in created_files: |
||||||
|
filepath.unlink() |
||||||
|
|
||||||
|
# Remove empty directories |
||||||
|
for dirpath in sorted(temp_dir.rglob("*"), reverse=True): |
||||||
|
if dirpath.is_dir(): |
||||||
|
try: |
||||||
|
dirpath.rmdir() |
||||||
|
except OSError: |
||||||
|
pass |
||||||
|
temp_dir.rmdir() |
||||||
|
|
||||||
|
return True |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
print("PyStorm Sample MPQ Creator") |
||||||
|
print("=" * 50) |
||||||
|
print() |
||||||
|
|
||||||
|
success = create_sample_mpq() |
||||||
|
sys.exit(0 if success else 1) |
||||||
@ -0,0 +1,150 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Debug script to test PyStorm with Starcraft.mpq |
||||||
|
""" |
||||||
|
|
||||||
|
import sys |
||||||
|
from pathlib import Path |
||||||
|
|
||||||
|
print("="*60) |
||||||
|
print("PyStorm Debug Script - Testing with Starcraft.mpq") |
||||||
|
print("="*60) |
||||||
|
|
||||||
|
# Test 1: Import PyStorm |
||||||
|
print("\n[1/5] Importing PyStorm...") |
||||||
|
try: |
||||||
|
from pystorm import MPQArchive, StormLibError |
||||||
|
print("✓ PyStorm imported successfully") |
||||||
|
except ImportError as e: |
||||||
|
print(f"✗ Failed to import PyStorm: {e}") |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
# Test 2: Check file exists |
||||||
|
print("\n[2/5] Checking if Starcraft.mpq exists...") |
||||||
|
mpq_path = Path("Starcraft.mpq") |
||||||
|
if not mpq_path.exists(): |
||||||
|
print(f"✗ File not found: {mpq_path.absolute()}") |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
file_size = mpq_path.stat().st_size |
||||||
|
print(f"✓ File found: {mpq_path}") |
||||||
|
print(f" Size: {file_size:,} bytes ({file_size / 1024 / 1024:.2f} MB)") |
||||||
|
|
||||||
|
# Test 3: Open the archive |
||||||
|
print("\n[3/5] Opening archive...") |
||||||
|
try: |
||||||
|
archive = MPQArchive(str(mpq_path)) |
||||||
|
print("✓ Archive opened successfully") |
||||||
|
except StormLibError as e: |
||||||
|
print(f"✗ Failed to open archive: {e}") |
||||||
|
sys.exit(1) |
||||||
|
except Exception as e: |
||||||
|
print(f"✗ Unexpected error: {e}") |
||||||
|
import traceback |
||||||
|
traceback.print_exc() |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
# Test 4: List files |
||||||
|
print("\n[4/5] Listing files in archive...") |
||||||
|
try: |
||||||
|
files = archive.find_files("*") |
||||||
|
print(f"✓ Found {len(files)} files in archive") |
||||||
|
|
||||||
|
# Show first 10 files |
||||||
|
print("\nFirst 10 files:") |
||||||
|
for i, file_info in enumerate(files[:10], 1): |
||||||
|
name = file_info['name'] |
||||||
|
size = file_info['size'] |
||||||
|
compressed = file_info['compressed_size'] |
||||||
|
print(f" {i:2d}. {name:40s} {size:>10,} bytes") |
||||||
|
|
||||||
|
if len(files) > 10: |
||||||
|
print(f" ... and {len(files) - 10} more files") |
||||||
|
|
||||||
|
# Statistics |
||||||
|
total_size = sum(f['size'] for f in files) |
||||||
|
total_compressed = sum(f['compressed_size'] for f in files) |
||||||
|
ratio = (1 - total_compressed / total_size) * 100 if total_size > 0 else 0 |
||||||
|
|
||||||
|
print(f"\nArchive Statistics:") |
||||||
|
print(f" Total files: {len(files)}") |
||||||
|
print(f" Uncompressed size: {total_size:,} bytes ({total_size / 1024 / 1024:.2f} MB)") |
||||||
|
print(f" Compressed size: {total_compressed:,} bytes ({total_compressed / 1024 / 1024:.2f} MB)") |
||||||
|
print(f" Compression ratio: {ratio:.1f}%") |
||||||
|
|
||||||
|
except StormLibError as e: |
||||||
|
print(f"✗ Failed to list files: {e}") |
||||||
|
archive.close() |
||||||
|
sys.exit(1) |
||||||
|
except Exception as e: |
||||||
|
print(f"✗ Unexpected error: {e}") |
||||||
|
import traceback |
||||||
|
traceback.print_exc() |
||||||
|
archive.close() |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
# Test 5: Extract a small file |
||||||
|
print("\n[5/5] Testing file extraction...") |
||||||
|
try: |
||||||
|
# Find the smallest file to extract |
||||||
|
if files: |
||||||
|
smallest = min(files, key=lambda f: f['size']) |
||||||
|
test_file = smallest['name'] |
||||||
|
test_size = smallest['size'] |
||||||
|
|
||||||
|
print(f" Extracting: {test_file} ({test_size} bytes)") |
||||||
|
|
||||||
|
# Extract to temporary file then read |
||||||
|
import tempfile |
||||||
|
with tempfile.NamedTemporaryFile(delete=False) as tmp: |
||||||
|
tmp_path = tmp.name |
||||||
|
|
||||||
|
archive.extract_file(test_file, tmp_path) |
||||||
|
with open(tmp_path, 'rb') as f: |
||||||
|
data = f.read() |
||||||
|
|
||||||
|
import os |
||||||
|
os.unlink(tmp_path) |
||||||
|
|
||||||
|
print(f"✓ Successfully extracted {len(data)} bytes") |
||||||
|
|
||||||
|
# Show first 100 bytes if it's text-like |
||||||
|
if test_size < 1000: |
||||||
|
try: |
||||||
|
text = data.decode('utf-8', errors='ignore')[:200] |
||||||
|
if text.isprintable() or '\n' in text: |
||||||
|
print(f"\n Preview (first 200 chars):") |
||||||
|
print(" " + "-"*50) |
||||||
|
for line in text.split('\n')[:5]: |
||||||
|
print(f" {line}") |
||||||
|
print(" " + "-"*50) |
||||||
|
except: |
||||||
|
pass |
||||||
|
else: |
||||||
|
print(" No files to extract") |
||||||
|
|
||||||
|
except StormLibError as e: |
||||||
|
print(f"✗ Failed to extract file: {e}") |
||||||
|
archive.close() |
||||||
|
sys.exit(1) |
||||||
|
except Exception as e: |
||||||
|
print(f"✗ Unexpected error: {e}") |
||||||
|
import traceback |
||||||
|
traceback.print_exc() |
||||||
|
archive.close() |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
# Cleanup |
||||||
|
print("\n[6/6] Closing archive...") |
||||||
|
try: |
||||||
|
archive.close() |
||||||
|
print("✓ Archive closed") |
||||||
|
except Exception as e: |
||||||
|
print(f"✗ Failed to close archive: {e}") |
||||||
|
finally: |
||||||
|
# Prevent __del__ from trying to close again |
||||||
|
archive._closed = True |
||||||
|
|
||||||
|
print("\n" + "="*60) |
||||||
|
print("ALL TESTS PASSED! PyStorm is working correctly.") |
||||||
|
print("="*60) |
||||||
@ -0,0 +1,112 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Example: Basic MPQ Archive Operations |
||||||
|
Demonstrates how to work with MPQ archives using PyStorm |
||||||
|
""" |
||||||
|
|
||||||
|
from pystorm import MPQArchive, MPQ_CREATE_ARCHIVE_V2, MPQ_FILE_COMPRESS |
||||||
|
import sys |
||||||
|
|
||||||
|
|
||||||
|
def basic_operations_example(): |
||||||
|
"""Demonstrate basic MPQ archive operations""" |
||||||
|
print("=== Basic MPQ Archive Operations ===\n") |
||||||
|
|
||||||
|
# Create a new archive |
||||||
|
print("1. Creating a new archive...") |
||||||
|
try: |
||||||
|
with MPQArchive("test_archive.mpq", flags=MPQ_CREATE_ARCHIVE_V2) as archive: |
||||||
|
print(" ✓ Archive created successfully") |
||||||
|
|
||||||
|
# Add a text file |
||||||
|
print("\n2. Adding a text file...") |
||||||
|
test_content = b"Hello, MPQ World! This is a test file." |
||||||
|
with open("test_file.txt", "wb") as f: |
||||||
|
f.write(test_content) |
||||||
|
|
||||||
|
archive.add_file("test_file.txt", "internal/test.txt", |
||||||
|
flags=MPQ_FILE_COMPRESS) |
||||||
|
print(" ✓ File added successfully") |
||||||
|
|
||||||
|
# Flush changes |
||||||
|
archive.flush() |
||||||
|
print(" ✓ Changes flushed to disk") |
||||||
|
except Exception as e: |
||||||
|
print(f" ✗ Error: {e}") |
||||||
|
return |
||||||
|
|
||||||
|
# Open and read from the archive |
||||||
|
print("\n3. Opening the archive and reading the file...") |
||||||
|
try: |
||||||
|
with MPQArchive("test_archive.mpq") as archive: |
||||||
|
# Check if file exists |
||||||
|
if archive.has_file("internal/test.txt"): |
||||||
|
print(" ✓ File exists in archive") |
||||||
|
|
||||||
|
# Open and read the file |
||||||
|
with archive.open_file("internal/test.txt") as mpq_file: |
||||||
|
content = mpq_file.read() |
||||||
|
print(f" ✓ File content: {content.decode('utf-8')}") |
||||||
|
else: |
||||||
|
print(" ✗ File not found in archive") |
||||||
|
except Exception as e: |
||||||
|
print(f" ✗ Error: {e}") |
||||||
|
return |
||||||
|
|
||||||
|
# List files in archive |
||||||
|
print("\n4. Listing all files in archive...") |
||||||
|
try: |
||||||
|
with MPQArchive("test_archive.mpq") as archive: |
||||||
|
files = archive.find_files("*") |
||||||
|
print(f" Found {len(files)} file(s):") |
||||||
|
for file_info in files: |
||||||
|
print(f" - {file_info['name']}") |
||||||
|
print(f" Size: {file_info['size']} bytes") |
||||||
|
print(f" Compressed: {file_info['compressed_size']} bytes") |
||||||
|
except Exception as e: |
||||||
|
print(f" ✗ Error: {e}") |
||||||
|
return |
||||||
|
|
||||||
|
# Extract file |
||||||
|
print("\n5. Extracting file from archive...") |
||||||
|
try: |
||||||
|
with MPQArchive("test_archive.mpq") as archive: |
||||||
|
archive.extract_file("internal/test.txt", "extracted_test.txt") |
||||||
|
print(" ✓ File extracted successfully") |
||||||
|
|
||||||
|
# Verify extraction |
||||||
|
with open("extracted_test.txt", "rb") as f: |
||||||
|
extracted_content = f.read() |
||||||
|
print(f" ✓ Extracted content: {extracted_content.decode('utf-8')}") |
||||||
|
except Exception as e: |
||||||
|
print(f" ✗ Error: {e}") |
||||||
|
return |
||||||
|
|
||||||
|
# Clean up |
||||||
|
print("\n6. Cleaning up test files...") |
||||||
|
import os |
||||||
|
for file in ["test_file.txt", "extracted_test.txt", "test_archive.mpq"]: |
||||||
|
try: |
||||||
|
if os.path.exists(file): |
||||||
|
os.remove(file) |
||||||
|
print(f" ✓ Removed {file}") |
||||||
|
except Exception as e: |
||||||
|
print(f" ✗ Error removing {file}: {e}") |
||||||
|
|
||||||
|
print("\n=== Example completed successfully! ===") |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
"""Main entry point""" |
||||||
|
try: |
||||||
|
basic_operations_example() |
||||||
|
except KeyboardInterrupt: |
||||||
|
print("\n\nInterrupted by user") |
||||||
|
sys.exit(1) |
||||||
|
except Exception as e: |
||||||
|
print(f"\n\nUnexpected error: {e}") |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
@ -0,0 +1,119 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Example: Create an MPQ archive from a directory |
||||||
|
""" |
||||||
|
|
||||||
|
from pystorm import MPQArchive, MPQ_CREATE_ARCHIVE_V2, MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB |
||||||
|
from pathlib import Path |
||||||
|
import sys |
||||||
|
|
||||||
|
|
||||||
|
def create_archive_from_directory(source_dir, archive_path, compress=True): |
||||||
|
""" |
||||||
|
Create an MPQ archive from all files in a directory |
||||||
|
|
||||||
|
Args: |
||||||
|
source_dir: Directory containing files to archive |
||||||
|
archive_path: Path for the output MPQ archive |
||||||
|
compress: Whether to compress files (default: True) |
||||||
|
""" |
||||||
|
source_dir = Path(source_dir) |
||||||
|
archive_path = Path(archive_path) |
||||||
|
|
||||||
|
if not source_dir.exists(): |
||||||
|
print(f"Error: Directory not found: {source_dir}") |
||||||
|
return False |
||||||
|
|
||||||
|
if not source_dir.is_dir(): |
||||||
|
print(f"Error: Not a directory: {source_dir}") |
||||||
|
return False |
||||||
|
|
||||||
|
print(f"Creating archive from: {source_dir}") |
||||||
|
print(f"Output archive: {archive_path}") |
||||||
|
print(f"Compression: {'enabled' if compress else 'disabled'}\n") |
||||||
|
|
||||||
|
# Collect all files |
||||||
|
files_to_add = [] |
||||||
|
for file_path in source_dir.rglob("*"): |
||||||
|
if file_path.is_file(): |
||||||
|
files_to_add.append(file_path) |
||||||
|
|
||||||
|
if not files_to_add: |
||||||
|
print("Error: No files found in directory") |
||||||
|
return False |
||||||
|
|
||||||
|
print(f"Found {len(files_to_add)} file(s) to add\n") |
||||||
|
|
||||||
|
try: |
||||||
|
# Create the archive |
||||||
|
with MPQArchive(str(archive_path), flags=MPQ_CREATE_ARCHIVE_V2) as archive: |
||||||
|
added = 0 |
||||||
|
failed = 0 |
||||||
|
|
||||||
|
for i, file_path in enumerate(files_to_add, 1): |
||||||
|
# Get relative path for archived name |
||||||
|
archived_name = str(file_path.relative_to(source_dir)) |
||||||
|
# Replace backslashes with forward slashes (MPQ convention) |
||||||
|
archived_name = archived_name.replace("\\", "/") |
||||||
|
|
||||||
|
# Determine flags |
||||||
|
flags = MPQ_FILE_COMPRESS if compress else 0 |
||||||
|
compression = MPQ_COMPRESSION_ZLIB if compress else 0 |
||||||
|
|
||||||
|
try: |
||||||
|
archive.add_file(str(file_path), archived_name, flags, compression) |
||||||
|
added += 1 |
||||||
|
|
||||||
|
# Get file size for display |
||||||
|
file_size = file_path.stat().st_size |
||||||
|
size_kb = file_size / 1024 |
||||||
|
print(f"[{i}/{len(files_to_add)}] ✓ {archived_name} ({size_kb:.2f} KB)") |
||||||
|
|
||||||
|
except Exception as e: |
||||||
|
failed += 1 |
||||||
|
print(f"[{i}/{len(files_to_add)}] ✗ {archived_name} - Error: {e}") |
||||||
|
|
||||||
|
# Flush changes |
||||||
|
print("\nFlushing changes to disk...") |
||||||
|
archive.flush() |
||||||
|
|
||||||
|
print(f"\n{'='*60}") |
||||||
|
print(f"Archive creation complete!") |
||||||
|
print(f" Files added: {added}") |
||||||
|
print(f" Failed: {failed}") |
||||||
|
print(f" Output: {archive_path}") |
||||||
|
|
||||||
|
# Get archive size |
||||||
|
if archive_path.exists(): |
||||||
|
archive_size = archive_path.stat().st_size |
||||||
|
size_mb = archive_size / (1024 * 1024) |
||||||
|
print(f" Archive size: {size_mb:.2f} MB") |
||||||
|
|
||||||
|
print(f"{'='*60}") |
||||||
|
|
||||||
|
return failed == 0 |
||||||
|
|
||||||
|
except Exception as e: |
||||||
|
print(f"Error creating archive: {e}") |
||||||
|
return False |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
"""Main entry point""" |
||||||
|
if len(sys.argv) < 2: |
||||||
|
print("Usage: python create_archive.py <source_dir> [output.mpq] [--no-compress]") |
||||||
|
print("\nExample:") |
||||||
|
print(" python create_archive.py my_files output.mpq") |
||||||
|
print(" python create_archive.py my_files output.mpq --no-compress") |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
source_dir = sys.argv[1] |
||||||
|
archive_path = sys.argv[2] if len(sys.argv) > 2 and not sys.argv[2].startswith('--') else "output.mpq" |
||||||
|
compress = "--no-compress" not in sys.argv |
||||||
|
|
||||||
|
success = create_archive_from_directory(source_dir, archive_path, compress) |
||||||
|
sys.exit(0 if success else 1) |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
@ -0,0 +1,93 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Example: Extract all files from an MPQ archive |
||||||
|
""" |
||||||
|
|
||||||
|
from pystorm import MPQArchive |
||||||
|
from pathlib import Path |
||||||
|
import sys |
||||||
|
|
||||||
|
|
||||||
|
def extract_all_files(archive_path, output_dir): |
||||||
|
""" |
||||||
|
Extract all files from an MPQ archive to a directory |
||||||
|
|
||||||
|
Args: |
||||||
|
archive_path: Path to the MPQ archive |
||||||
|
output_dir: Directory to extract files to |
||||||
|
""" |
||||||
|
archive_path = Path(archive_path) |
||||||
|
output_dir = Path(output_dir) |
||||||
|
|
||||||
|
if not archive_path.exists(): |
||||||
|
print(f"Error: Archive not found: {archive_path}") |
||||||
|
return False |
||||||
|
|
||||||
|
# Create output directory |
||||||
|
output_dir.mkdir(parents=True, exist_ok=True) |
||||||
|
|
||||||
|
print(f"Extracting files from: {archive_path}") |
||||||
|
print(f"Output directory: {output_dir}\n") |
||||||
|
|
||||||
|
try: |
||||||
|
with MPQArchive(str(archive_path)) as archive: |
||||||
|
# Find all files |
||||||
|
files = archive.find_files("*") |
||||||
|
total_files = len(files) |
||||||
|
|
||||||
|
if total_files == 0: |
||||||
|
print("No files found in archive") |
||||||
|
return True |
||||||
|
|
||||||
|
print(f"Found {total_files} file(s) to extract\n") |
||||||
|
|
||||||
|
# Extract each file |
||||||
|
extracted = 0 |
||||||
|
failed = 0 |
||||||
|
|
||||||
|
for i, file_info in enumerate(files, 1): |
||||||
|
filename = file_info['name'] |
||||||
|
|
||||||
|
# Create output path |
||||||
|
output_path = output_dir / filename |
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True) |
||||||
|
|
||||||
|
# Extract file |
||||||
|
try: |
||||||
|
archive.extract_file(filename, str(output_path)) |
||||||
|
extracted += 1 |
||||||
|
print(f"[{i}/{total_files}] ✓ {filename}") |
||||||
|
except Exception as e: |
||||||
|
failed += 1 |
||||||
|
print(f"[{i}/{total_files}] ✗ {filename} - Error: {e}") |
||||||
|
|
||||||
|
print(f"\n{'='*60}") |
||||||
|
print(f"Extraction complete!") |
||||||
|
print(f" Successful: {extracted}") |
||||||
|
print(f" Failed: {failed}") |
||||||
|
print(f"{'='*60}") |
||||||
|
|
||||||
|
return failed == 0 |
||||||
|
|
||||||
|
except Exception as e: |
||||||
|
print(f"Error opening archive: {e}") |
||||||
|
return False |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
"""Main entry point""" |
||||||
|
if len(sys.argv) < 2: |
||||||
|
print("Usage: python extract_all.py <archive.mpq> [output_dir]") |
||||||
|
print("\nExample:") |
||||||
|
print(" python extract_all.py game.mpq extracted_files") |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
archive_path = sys.argv[1] |
||||||
|
output_dir = sys.argv[2] if len(sys.argv) > 2 else "extracted_files" |
||||||
|
|
||||||
|
success = extract_all_files(archive_path, output_dir) |
||||||
|
sys.exit(0 if success else 1) |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
@ -0,0 +1,130 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Example: List files in an MPQ archive with detailed information |
||||||
|
""" |
||||||
|
|
||||||
|
from pystorm import MPQArchive |
||||||
|
from pathlib import Path |
||||||
|
import sys |
||||||
|
|
||||||
|
|
||||||
|
def format_size(size_bytes): |
||||||
|
"""Format size in bytes to human-readable format""" |
||||||
|
for unit in ['B', 'KB', 'MB', 'GB']: |
||||||
|
if size_bytes < 1024.0: |
||||||
|
return f"{size_bytes:.2f} {unit}" |
||||||
|
size_bytes /= 1024.0 |
||||||
|
return f"{size_bytes:.2f} TB" |
||||||
|
|
||||||
|
|
||||||
|
def list_archive_files(archive_path, pattern="*", detailed=False): |
||||||
|
""" |
||||||
|
List files in an MPQ archive |
||||||
|
|
||||||
|
Args: |
||||||
|
archive_path: Path to the MPQ archive |
||||||
|
pattern: Pattern to match files (default: "*") |
||||||
|
detailed: Show detailed information (default: False) |
||||||
|
""" |
||||||
|
archive_path = Path(archive_path) |
||||||
|
|
||||||
|
if not archive_path.exists(): |
||||||
|
print(f"Error: Archive not found: {archive_path}") |
||||||
|
return False |
||||||
|
|
||||||
|
print(f"Archive: {archive_path}") |
||||||
|
print(f"Pattern: {pattern}\n") |
||||||
|
|
||||||
|
try: |
||||||
|
with MPQArchive(str(archive_path)) as archive: |
||||||
|
# Find files |
||||||
|
files = archive.find_files(pattern) |
||||||
|
|
||||||
|
if not files: |
||||||
|
print("No files found matching pattern") |
||||||
|
return True |
||||||
|
|
||||||
|
print(f"Found {len(files)} file(s)\n") |
||||||
|
|
||||||
|
# Calculate totals |
||||||
|
total_size = sum(f['size'] for f in files) |
||||||
|
total_compressed = sum(f['compressed_size'] for f in files) |
||||||
|
|
||||||
|
if detailed: |
||||||
|
# Detailed listing |
||||||
|
print(f"{'Name':<50} {'Size':<12} {'Compressed':<12} {'Ratio':<8} {'Flags'}") |
||||||
|
print("=" * 100) |
||||||
|
|
||||||
|
for file_info in sorted(files, key=lambda x: x['name']): |
||||||
|
name = file_info['name'] |
||||||
|
size = file_info['size'] |
||||||
|
compressed = file_info['compressed_size'] |
||||||
|
flags = file_info['flags'] |
||||||
|
|
||||||
|
# Calculate compression ratio |
||||||
|
if size > 0: |
||||||
|
ratio = (1 - compressed / size) * 100 |
||||||
|
else: |
||||||
|
ratio = 0 |
||||||
|
|
||||||
|
# Format flags |
||||||
|
flag_str = [] |
||||||
|
if flags & 0x00000200: # MPQ_FILE_COMPRESS |
||||||
|
flag_str.append("C") |
||||||
|
if flags & 0x00010000: # MPQ_FILE_ENCRYPTED |
||||||
|
flag_str.append("E") |
||||||
|
if flags & 0x01000000: # MPQ_FILE_SINGLE_UNIT |
||||||
|
flag_str.append("S") |
||||||
|
flag_display = "".join(flag_str) or "-" |
||||||
|
|
||||||
|
print(f"{name:<50} {format_size(size):<12} {format_size(compressed):<12} " |
||||||
|
f"{ratio:>6.1f}% {flag_display}") |
||||||
|
else: |
||||||
|
# Simple listing |
||||||
|
for file_info in sorted(files, key=lambda x: x['name']): |
||||||
|
print(f" {file_info['name']}") |
||||||
|
|
||||||
|
# Print summary |
||||||
|
print("\n" + "=" * 100) |
||||||
|
print(f"Total files: {len(files)}") |
||||||
|
print(f"Total size: {format_size(total_size)}") |
||||||
|
print(f"Total compressed: {format_size(total_compressed)}") |
||||||
|
|
||||||
|
if total_size > 0: |
||||||
|
overall_ratio = (1 - total_compressed / total_size) * 100 |
||||||
|
print(f"Overall compression ratio: {overall_ratio:.1f}%") |
||||||
|
|
||||||
|
return True |
||||||
|
|
||||||
|
except Exception as e: |
||||||
|
print(f"Error reading archive: {e}") |
||||||
|
return False |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
"""Main entry point""" |
||||||
|
if len(sys.argv) < 2: |
||||||
|
print("Usage: python list_files.py <archive.mpq> [pattern] [--detailed]") |
||||||
|
print("\nExample:") |
||||||
|
print(" python list_files.py game.mpq") |
||||||
|
print(" python list_files.py game.mpq '*.txt'") |
||||||
|
print(" python list_files.py game.mpq '*' --detailed") |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
archive_path = sys.argv[1] |
||||||
|
pattern = "*" |
||||||
|
detailed = False |
||||||
|
|
||||||
|
# Parse arguments |
||||||
|
for arg in sys.argv[2:]: |
||||||
|
if arg == "--detailed" or arg == "-d": |
||||||
|
detailed = True |
||||||
|
elif not arg.startswith('-'): |
||||||
|
pattern = arg |
||||||
|
|
||||||
|
success = list_archive_files(archive_path, pattern, detailed) |
||||||
|
sys.exit(0 if success else 1) |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
@ -0,0 +1,341 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
StarCraft MPQ Asset Extractor |
||||||
|
============================== |
||||||
|
|
||||||
|
Extracts and organizes all assets from Starcraft.mpq into a structured |
||||||
|
directory layout suitable for use with alternative game engines. |
||||||
|
|
||||||
|
Usage: |
||||||
|
python extract_starcraft_assets.py [--output DIR] [--mpq FILE] |
||||||
|
|
||||||
|
Output Structure: |
||||||
|
assets/ |
||||||
|
├── audio/ # All audio files (.wav) |
||||||
|
├── graphics/ # Graphics and images (.pcx, .grp, .smk, .bik) |
||||||
|
├── video/ # Video files (.smk, .bik) |
||||||
|
├── data/ # Game data files (.dat, .bin, .tbl) |
||||||
|
├── maps/ # Map files (.chk, .scm, .scx) |
||||||
|
├── fonts/ # Font files (.fnt) |
||||||
|
├── text/ # Text and string files (.txt, .tbl) |
||||||
|
├── scripts/ # Script files (.ais, .aiscript) |
||||||
|
└── unknown/ # Unknown/unclassified files |
||||||
|
""" |
||||||
|
|
||||||
|
import sys |
||||||
|
import argparse |
||||||
|
from pathlib import Path |
||||||
|
from collections import defaultdict |
||||||
|
import time |
||||||
|
|
||||||
|
try: |
||||||
|
from pystorm import MPQArchive, StormLibError |
||||||
|
except ImportError: |
||||||
|
print("Error: PyStorm not installed. Please run: pip install -e .") |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
|
||||||
|
# File type categorization |
||||||
|
FILE_CATEGORIES = { |
||||||
|
'audio': ['.wav', '.ogg', '.mp3'], |
||||||
|
'graphics': ['.pcx', '.grp', '.dds', '.tga', '.bmp'], |
||||||
|
'video': ['.smk', '.bik', '.avi'], |
||||||
|
'data': ['.dat', '.bin', '.pal', '.wpe', '.cv5', '.vf4', '.vx4', '.vr4'], |
||||||
|
'maps': ['.chk', '.scm', '.scx'], |
||||||
|
'fonts': ['.fnt', '.ttf'], |
||||||
|
'text': ['.txt', '.tbl', '.rtf'], |
||||||
|
'scripts': ['.ais', '.aiscript', '.ai'], |
||||||
|
'models': ['.m3', '.m2', '.mdx', '.mdl'], |
||||||
|
'shaders': ['.fx', '.hlsl', '.glsl'], |
||||||
|
'config': ['.ini', '.cfg', '.json', '.xml'], |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
def categorize_file(filename: str) -> str: |
||||||
|
""" |
||||||
|
Categorize a file based on its extension. |
||||||
|
|
||||||
|
Args: |
||||||
|
filename: The filename to categorize |
||||||
|
|
||||||
|
Returns: |
||||||
|
Category name (e.g., 'audio', 'graphics', 'unknown') |
||||||
|
""" |
||||||
|
ext = Path(filename).suffix.lower() |
||||||
|
|
||||||
|
for category, extensions in FILE_CATEGORIES.items(): |
||||||
|
if ext in extensions: |
||||||
|
return category |
||||||
|
|
||||||
|
# Special handling for files without extension |
||||||
|
if not ext or ext == '.xxx': |
||||||
|
# Try to guess from filename patterns |
||||||
|
name_lower = filename.lower() |
||||||
|
if 'sound' in name_lower or 'music' in name_lower: |
||||||
|
return 'audio' |
||||||
|
elif 'video' in name_lower or 'movie' in name_lower: |
||||||
|
return 'video' |
||||||
|
elif 'image' in name_lower or 'sprite' in name_lower: |
||||||
|
return 'graphics' |
||||||
|
elif 'map' in name_lower: |
||||||
|
return 'maps' |
||||||
|
elif 'script' in name_lower: |
||||||
|
return 'scripts' |
||||||
|
|
||||||
|
return 'unknown' |
||||||
|
|
||||||
|
|
||||||
|
def get_file_info(file_data: dict) -> str: |
||||||
|
""" |
||||||
|
Get a human-readable info string for a file. |
||||||
|
|
||||||
|
Args: |
||||||
|
file_data: Dictionary with file information |
||||||
|
|
||||||
|
Returns: |
||||||
|
Info string with size and compression info |
||||||
|
""" |
||||||
|
size = file_data['size'] |
||||||
|
compressed = file_data['compressed_size'] |
||||||
|
|
||||||
|
if size > 0: |
||||||
|
ratio = ((size - compressed) / size) * 100 if size > 0 else 0 |
||||||
|
return f"{format_size(size):>10} -> {format_size(compressed):>10} ({ratio:>5.1f}% compressed)" |
||||||
|
else: |
||||||
|
return f"{format_size(compressed):>10} (packed)" |
||||||
|
|
||||||
|
|
||||||
|
def format_size(size_bytes: int) -> str: |
||||||
|
"""Format size in bytes to human-readable format""" |
||||||
|
if size_bytes < 1024: |
||||||
|
return f"{size_bytes} B" |
||||||
|
elif size_bytes < 1024 * 1024: |
||||||
|
return f"{size_bytes / 1024:.1f} KB" |
||||||
|
else: |
||||||
|
return f"{size_bytes / 1024 / 1024:.1f} MB" |
||||||
|
|
||||||
|
|
||||||
|
def extract_and_organize(mpq_path: str, output_dir: str, verbose: bool = True): |
||||||
|
""" |
||||||
|
Extract and organize all files from an MPQ archive. |
||||||
|
|
||||||
|
Args: |
||||||
|
mpq_path: Path to the MPQ file |
||||||
|
output_dir: Output directory for extracted assets |
||||||
|
verbose: Print detailed progress information |
||||||
|
""" |
||||||
|
output_path = Path(output_dir) |
||||||
|
output_path.mkdir(parents=True, exist_ok=True) |
||||||
|
|
||||||
|
# Statistics |
||||||
|
stats = { |
||||||
|
'total_files': 0, |
||||||
|
'extracted': 0, |
||||||
|
'failed': 0, |
||||||
|
'by_category': defaultdict(int), |
||||||
|
'total_size': 0, |
||||||
|
'total_compressed': 0, |
||||||
|
} |
||||||
|
|
||||||
|
print("=" * 80) |
||||||
|
print("StarCraft MPQ Asset Extractor") |
||||||
|
print("=" * 80) |
||||||
|
print(f"\nInput: {mpq_path}") |
||||||
|
print(f"Output: {output_path.absolute()}\n") |
||||||
|
|
||||||
|
# Open the archive |
||||||
|
try: |
||||||
|
print("Opening MPQ archive...") |
||||||
|
archive = MPQArchive(mpq_path) |
||||||
|
print("✓ Archive opened successfully\n") |
||||||
|
except StormLibError as e: |
||||||
|
print(f"✗ Error opening archive: {e}") |
||||||
|
return False |
||||||
|
|
||||||
|
try: |
||||||
|
# List all files |
||||||
|
print("Scanning archive contents...") |
||||||
|
files = archive.find_files("*") |
||||||
|
stats['total_files'] = len(files) |
||||||
|
print(f"✓ Found {len(files)} files\n") |
||||||
|
|
||||||
|
if len(files) == 0: |
||||||
|
print("⚠ No files found in archive") |
||||||
|
return False |
||||||
|
|
||||||
|
# Organize files by category |
||||||
|
files_by_category = defaultdict(list) |
||||||
|
for file_info in files: |
||||||
|
category = categorize_file(file_info['name']) |
||||||
|
files_by_category[category].append(file_info) |
||||||
|
stats['by_category'][category] += 1 |
||||||
|
stats['total_size'] += file_info['size'] |
||||||
|
stats['total_compressed'] += file_info['compressed_size'] |
||||||
|
|
||||||
|
# Print category summary |
||||||
|
print("File Categories:") |
||||||
|
print("-" * 80) |
||||||
|
for category in sorted(files_by_category.keys()): |
||||||
|
count = len(files_by_category[category]) |
||||||
|
print(f" {category:.<20} {count:>4} files") |
||||||
|
print("-" * 80 + "\n") |
||||||
|
|
||||||
|
# Extract files category by category |
||||||
|
start_time = time.time() |
||||||
|
|
||||||
|
for category in sorted(files_by_category.keys()): |
||||||
|
category_files = files_by_category[category] |
||||||
|
category_dir = output_path / category |
||||||
|
category_dir.mkdir(parents=True, exist_ok=True) |
||||||
|
|
||||||
|
print(f"Extracting {category}/ ({len(category_files)} files)...") |
||||||
|
|
||||||
|
for i, file_info in enumerate(category_files, 1): |
||||||
|
filename = file_info['name'] |
||||||
|
|
||||||
|
# Create subdirectory structure if file has path separators |
||||||
|
if '\\' in filename or '/' in filename: |
||||||
|
# Normalize path separators |
||||||
|
rel_path = filename.replace('\\', '/') |
||||||
|
output_file = category_dir / rel_path |
||||||
|
output_file.parent.mkdir(parents=True, exist_ok=True) |
||||||
|
else: |
||||||
|
output_file = category_dir / filename |
||||||
|
|
||||||
|
try: |
||||||
|
archive.extract_file(filename, str(output_file)) |
||||||
|
stats['extracted'] += 1 |
||||||
|
|
||||||
|
if verbose and i % 50 == 0: |
||||||
|
progress = (i / len(category_files)) * 100 |
||||||
|
print(f" Progress: {progress:>5.1f}% ({i}/{len(category_files)})") |
||||||
|
|
||||||
|
except Exception as e: |
||||||
|
stats['failed'] += 1 |
||||||
|
if verbose: |
||||||
|
print(f" ✗ Failed: {filename} - {e}") |
||||||
|
|
||||||
|
print(f" ✓ Completed {category}/ - {len(category_files)} files\n") |
||||||
|
|
||||||
|
elapsed = time.time() - start_time |
||||||
|
|
||||||
|
except Exception as e: |
||||||
|
print(f"\n✗ Error during extraction: {e}") |
||||||
|
import traceback |
||||||
|
traceback.print_exc() |
||||||
|
return False |
||||||
|
finally: |
||||||
|
archive.close() |
||||||
|
|
||||||
|
# Print final statistics |
||||||
|
print("=" * 80) |
||||||
|
print("Extraction Complete!") |
||||||
|
print("=" * 80) |
||||||
|
print(f"\nStatistics:") |
||||||
|
print(f" Total files: {stats['total_files']:>6}") |
||||||
|
print(f" Extracted: {stats['extracted']:>6}") |
||||||
|
print(f" Failed: {stats['failed']:>6}") |
||||||
|
print(f" Time elapsed: {elapsed:>6.1f}s") |
||||||
|
print(f"\nStorage:") |
||||||
|
print(f" Uncompressed size: {format_size(stats['total_size'])}") |
||||||
|
print(f" Compressed size: {format_size(stats['total_compressed'])}") |
||||||
|
if stats['total_size'] > 0: |
||||||
|
ratio = ((stats['total_size'] - stats['total_compressed']) / stats['total_size']) * 100 |
||||||
|
print(f" Compression ratio: {ratio:.1f}%") |
||||||
|
|
||||||
|
print(f"\nFiles by category:") |
||||||
|
for category in sorted(stats['by_category'].keys()): |
||||||
|
count = stats['by_category'][category] |
||||||
|
percentage = (count / stats['total_files']) * 100 |
||||||
|
print(f" {category:.<20} {count:>4} files ({percentage:>5.1f}%)") |
||||||
|
|
||||||
|
print(f"\n✓ All assets extracted to: {output_path.absolute()}") |
||||||
|
print("\nNext steps:") |
||||||
|
print(" 1. Review the extracted files in the assets/ directory") |
||||||
|
print(" 2. Read STARCRAFT_ASSETS.md for file format documentation") |
||||||
|
print(" 3. Integrate assets into your game engine") |
||||||
|
|
||||||
|
return True |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
"""Main entry point""" |
||||||
|
parser = argparse.ArgumentParser( |
||||||
|
description="Extract and organize StarCraft MPQ assets", |
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter, |
||||||
|
epilog=__doc__ |
||||||
|
) |
||||||
|
|
||||||
|
parser.add_argument( |
||||||
|
'--mpq', |
||||||
|
default='Starcraft.mpq', |
||||||
|
help='Path to the MPQ file (default: Starcraft.mpq)' |
||||||
|
) |
||||||
|
|
||||||
|
parser.add_argument( |
||||||
|
'--output', |
||||||
|
'-o', |
||||||
|
default='assets', |
||||||
|
help='Output directory for extracted assets (default: assets/)' |
||||||
|
) |
||||||
|
|
||||||
|
parser.add_argument( |
||||||
|
'--quiet', |
||||||
|
'-q', |
||||||
|
action='store_true', |
||||||
|
help='Suppress verbose output' |
||||||
|
) |
||||||
|
|
||||||
|
parser.add_argument( |
||||||
|
'--list-only', |
||||||
|
'-l', |
||||||
|
action='store_true', |
||||||
|
help='Only list files without extracting' |
||||||
|
) |
||||||
|
|
||||||
|
args = parser.parse_args() |
||||||
|
|
||||||
|
# Check if MPQ file exists |
||||||
|
if not Path(args.mpq).exists(): |
||||||
|
print(f"Error: MPQ file not found: {args.mpq}") |
||||||
|
print(f"\nPlease provide the path to your StarCraft MPQ file:") |
||||||
|
print(f" python {sys.argv[0]} --mpq /path/to/Starcraft.mpq") |
||||||
|
return 1 |
||||||
|
|
||||||
|
# List only mode |
||||||
|
if args.list_only: |
||||||
|
try: |
||||||
|
archive = MPQArchive(args.mpq) |
||||||
|
files = archive.find_files("*") |
||||||
|
|
||||||
|
files_by_category = defaultdict(list) |
||||||
|
for file_info in files: |
||||||
|
category = categorize_file(file_info['name']) |
||||||
|
files_by_category[category].append(file_info['name']) |
||||||
|
|
||||||
|
print(f"\nFiles in {args.mpq}:") |
||||||
|
print("=" * 80) |
||||||
|
for category in sorted(files_by_category.keys()): |
||||||
|
print(f"\n{category.upper()}:") |
||||||
|
print("-" * 80) |
||||||
|
for filename in sorted(files_by_category[category]): |
||||||
|
print(f" {filename}") |
||||||
|
|
||||||
|
archive.close() |
||||||
|
return 0 |
||||||
|
except Exception as e: |
||||||
|
print(f"Error: {e}") |
||||||
|
return 1 |
||||||
|
|
||||||
|
# Extract files |
||||||
|
success = extract_and_organize( |
||||||
|
args.mpq, |
||||||
|
args.output, |
||||||
|
verbose=not args.quiet |
||||||
|
) |
||||||
|
|
||||||
|
return 0 if success else 1 |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
sys.exit(main()) |
||||||
@ -0,0 +1,120 @@ |
|||||||
|
#!/bin/bash |
||||||
|
# Build script for installing StormLib and PyStorm |
||||||
|
|
||||||
|
set -e |
||||||
|
|
||||||
|
echo "==================================" |
||||||
|
echo " PyStorm Installation Script" |
||||||
|
echo "==================================" |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Detect OS |
||||||
|
OS="$(uname -s)" |
||||||
|
case "${OS}" in |
||||||
|
Linux*) MACHINE=Linux;; |
||||||
|
Darwin*) MACHINE=Mac;; |
||||||
|
CYGWIN*|MINGW*|MSYS*) MACHINE=Windows;; |
||||||
|
*) MACHINE="UNKNOWN:${OS}" |
||||||
|
esac |
||||||
|
|
||||||
|
echo "Detected OS: ${MACHINE}" |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Check if StormLib is already installed |
||||||
|
echo "Checking for existing StormLib installation..." |
||||||
|
if ldconfig -p 2>/dev/null | grep -q libstorm || [ -f "/usr/local/lib/libstorm.so" ]; then |
||||||
|
echo "✓ StormLib appears to be installed" |
||||||
|
INSTALL_STORMLIB=false |
||||||
|
else |
||||||
|
echo "✗ StormLib not found" |
||||||
|
read -p "Do you want to build and install StormLib? (y/n) " -n 1 -r |
||||||
|
echo |
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then |
||||||
|
INSTALL_STORMLIB=true |
||||||
|
else |
||||||
|
INSTALL_STORMLIB=false |
||||||
|
echo "Warning: PyStorm requires StormLib to function properly" |
||||||
|
fi |
||||||
|
fi |
||||||
|
|
||||||
|
# Install StormLib if requested |
||||||
|
if [ "$INSTALL_STORMLIB" = true ]; then |
||||||
|
echo "" |
||||||
|
echo "=== Building and Installing StormLib ===" |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Check for required tools |
||||||
|
if ! command -v git &> /dev/null; then |
||||||
|
echo "Error: git is required but not installed" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
if ! command -v cmake &> /dev/null; then |
||||||
|
echo "Error: cmake is required but not installed" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
# Clone StormLib |
||||||
|
TEMP_DIR=$(mktemp -d) |
||||||
|
cd "$TEMP_DIR" |
||||||
|
|
||||||
|
echo "Cloning StormLib repository..." |
||||||
|
git clone https://github.com/ladislav-zezula/StormLib.git |
||||||
|
cd StormLib |
||||||
|
|
||||||
|
# Build |
||||||
|
echo "Building StormLib..." |
||||||
|
mkdir build |
||||||
|
cd build |
||||||
|
cmake .. |
||||||
|
make -j$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2) |
||||||
|
|
||||||
|
# Install |
||||||
|
echo "Installing StormLib (may require sudo)..." |
||||||
|
sudo make install |
||||||
|
|
||||||
|
# Update library cache (Linux only) |
||||||
|
if [ "$MACHINE" = "Linux" ]; then |
||||||
|
sudo ldconfig |
||||||
|
fi |
||||||
|
|
||||||
|
# Clean up |
||||||
|
cd / |
||||||
|
rm -rf "$TEMP_DIR" |
||||||
|
|
||||||
|
echo "✓ StormLib installed successfully" |
||||||
|
fi |
||||||
|
|
||||||
|
# Install PyStorm |
||||||
|
echo "" |
||||||
|
echo "=== Installing PyStorm ===" |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Check for Python 3 |
||||||
|
if ! command -v python3 &> /dev/null; then |
||||||
|
echo "Error: Python 3 is required but not installed" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
# Activate virtual environment if it exists |
||||||
|
if [ -d "venv" ]; then |
||||||
|
echo "Activating virtual environment..." |
||||||
|
source venv/bin/activate |
||||||
|
fi |
||||||
|
|
||||||
|
# Install PyStorm in development mode |
||||||
|
echo "Installing PyStorm..." |
||||||
|
python3 -m pip install -e . |
||||||
|
|
||||||
|
echo "" |
||||||
|
echo "==================================" |
||||||
|
echo " Installation Complete!" |
||||||
|
echo "==================================" |
||||||
|
echo "" |
||||||
|
echo "You can now use PyStorm:" |
||||||
|
echo " python3 -c 'import pystorm; print(pystorm.__version__)'" |
||||||
|
echo "" |
||||||
|
echo "Try the examples:" |
||||||
|
echo " cd examples" |
||||||
|
echo " python3 basic_operations.py" |
||||||
|
echo "" |
||||||
@ -0,0 +1,437 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
MPQ Inspector - A GUI tool to inspect MPQ archives |
||||||
|
Uses tkinter for the GUI and PyStorm for MPQ operations |
||||||
|
""" |
||||||
|
|
||||||
|
import tkinter as tk |
||||||
|
from tkinter import ttk, filedialog, messagebox, scrolledtext |
||||||
|
from pathlib import Path |
||||||
|
import sys |
||||||
|
from typing import Optional |
||||||
|
|
||||||
|
try: |
||||||
|
from pystorm import MPQArchive, StormLibError |
||||||
|
except ImportError: |
||||||
|
print("Error: PyStorm not installed. Please run: pip install -e .") |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
|
||||||
|
class MPQInspectorApp: |
||||||
|
"""Main application window for MPQ Inspector""" |
||||||
|
|
||||||
|
def __init__(self, root): |
||||||
|
self.root = root |
||||||
|
self.root.title("MPQ Inspector - PyStorm Demo") |
||||||
|
self.root.geometry("900x700") |
||||||
|
|
||||||
|
self.current_archive: Optional[MPQArchive] = None |
||||||
|
self.current_path: Optional[str] = None |
||||||
|
|
||||||
|
self.setup_ui() |
||||||
|
|
||||||
|
def setup_ui(self): |
||||||
|
"""Setup the user interface""" |
||||||
|
# Menu bar |
||||||
|
menubar = tk.Menu(self.root) |
||||||
|
self.root.config(menu=menubar) |
||||||
|
|
||||||
|
file_menu = tk.Menu(menubar, tearoff=0) |
||||||
|
menubar.add_cascade(label="File", menu=file_menu) |
||||||
|
file_menu.add_command(label="Open MPQ...", command=self.open_archive, accelerator="Ctrl+O") |
||||||
|
file_menu.add_command(label="Close Archive", command=self.close_archive, accelerator="Ctrl+W") |
||||||
|
file_menu.add_separator() |
||||||
|
file_menu.add_command(label="Exit", command=self.root.quit, accelerator="Ctrl+Q") |
||||||
|
|
||||||
|
help_menu = tk.Menu(menubar, tearoff=0) |
||||||
|
menubar.add_cascade(label="Help", menu=help_menu) |
||||||
|
help_menu.add_command(label="About", command=self.show_about) |
||||||
|
|
||||||
|
# Bind keyboard shortcuts |
||||||
|
self.root.bind('<Control-o>', lambda e: self.open_archive()) |
||||||
|
self.root.bind('<Control-w>', lambda e: self.close_archive()) |
||||||
|
self.root.bind('<Control-q>', lambda e: self.root.quit()) |
||||||
|
|
||||||
|
# Main container |
||||||
|
main_frame = ttk.Frame(self.root, padding="10") |
||||||
|
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) |
||||||
|
|
||||||
|
self.root.columnconfigure(0, weight=1) |
||||||
|
self.root.rowconfigure(0, weight=1) |
||||||
|
main_frame.columnconfigure(0, weight=1) |
||||||
|
main_frame.rowconfigure(2, weight=1) |
||||||
|
|
||||||
|
# Top bar - File selection |
||||||
|
top_frame = ttk.LabelFrame(main_frame, text="Archive", padding="5") |
||||||
|
top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) |
||||||
|
top_frame.columnconfigure(1, weight=1) |
||||||
|
|
||||||
|
ttk.Label(top_frame, text="File:").grid(row=0, column=0, sticky=tk.W, padx=(0, 5)) |
||||||
|
|
||||||
|
self.file_path_var = tk.StringVar(value="No archive loaded") |
||||||
|
file_entry = ttk.Entry(top_frame, textvariable=self.file_path_var, state='readonly') |
||||||
|
file_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5)) |
||||||
|
|
||||||
|
ttk.Button(top_frame, text="Browse...", command=self.open_archive).grid(row=0, column=2) |
||||||
|
|
||||||
|
# Stats bar |
||||||
|
stats_frame = ttk.LabelFrame(main_frame, text="Statistics", padding="5") |
||||||
|
stats_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) |
||||||
|
|
||||||
|
self.stats_text = tk.StringVar(value="No archive loaded") |
||||||
|
ttk.Label(stats_frame, textvariable=self.stats_text).grid(row=0, column=0, sticky=tk.W) |
||||||
|
|
||||||
|
# File list frame |
||||||
|
list_frame = ttk.LabelFrame(main_frame, text="Files in Archive", padding="5") |
||||||
|
list_frame.grid(row=2, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) |
||||||
|
list_frame.columnconfigure(0, weight=1) |
||||||
|
list_frame.rowconfigure(0, weight=1) |
||||||
|
|
||||||
|
# Create treeview for file list |
||||||
|
columns = ('size', 'compressed', 'ratio', 'flags') |
||||||
|
self.tree = ttk.Treeview(list_frame, columns=columns, show='tree headings') |
||||||
|
|
||||||
|
# Define headings |
||||||
|
self.tree.heading('#0', text='Filename') |
||||||
|
self.tree.heading('size', text='Size') |
||||||
|
self.tree.heading('compressed', text='Compressed') |
||||||
|
self.tree.heading('ratio', text='Ratio') |
||||||
|
self.tree.heading('flags', text='Flags') |
||||||
|
|
||||||
|
# Define column widths |
||||||
|
self.tree.column('#0', width=400) |
||||||
|
self.tree.column('size', width=100, anchor='e') |
||||||
|
self.tree.column('compressed', width=100, anchor='e') |
||||||
|
self.tree.column('ratio', width=80, anchor='e') |
||||||
|
self.tree.column('flags', width=80, anchor='center') |
||||||
|
|
||||||
|
# Scrollbars |
||||||
|
vsb = ttk.Scrollbar(list_frame, orient="vertical", command=self.tree.yview) |
||||||
|
hsb = ttk.Scrollbar(list_frame, orient="horizontal", command=self.tree.xview) |
||||||
|
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) |
||||||
|
|
||||||
|
self.tree.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.E, tk.W)) |
||||||
|
vsb.grid(row=0, column=1, sticky=(tk.N, tk.S)) |
||||||
|
hsb.grid(row=1, column=0, sticky=(tk.E, tk.W)) |
||||||
|
|
||||||
|
# Context menu for tree |
||||||
|
self.tree_menu = tk.Menu(self.root, tearoff=0) |
||||||
|
self.tree_menu.add_command(label="Extract File...", command=self.extract_selected_file) |
||||||
|
self.tree_menu.add_command(label="View File Info", command=self.show_file_info) |
||||||
|
|
||||||
|
self.tree.bind('<Button-3>', self.show_context_menu) |
||||||
|
self.tree.bind('<Double-1>', self.show_file_info) |
||||||
|
|
||||||
|
# Bottom buttons |
||||||
|
button_frame = ttk.Frame(main_frame) |
||||||
|
button_frame.grid(row=3, column=0, sticky=(tk.W, tk.E), pady=(10, 0)) |
||||||
|
|
||||||
|
ttk.Button(button_frame, text="Extract All...", command=self.extract_all_files).pack(side=tk.LEFT, padx=(0, 5)) |
||||||
|
ttk.Button(button_frame, text="Refresh", command=self.refresh_file_list).pack(side=tk.LEFT, padx=(0, 5)) |
||||||
|
ttk.Button(button_frame, text="Verify Archive", command=self.verify_archive).pack(side=tk.LEFT) |
||||||
|
|
||||||
|
ttk.Button(button_frame, text="Close", command=self.root.quit).pack(side=tk.RIGHT) |
||||||
|
|
||||||
|
def format_size(self, size_bytes): |
||||||
|
"""Format size in bytes to human-readable format""" |
||||||
|
for unit in ['B', 'KB', 'MB', 'GB']: |
||||||
|
if size_bytes < 1024.0: |
||||||
|
return f"{size_bytes:.1f} {unit}" |
||||||
|
size_bytes /= 1024.0 |
||||||
|
return f"{size_bytes:.1f} TB" |
||||||
|
|
||||||
|
def open_archive(self): |
||||||
|
"""Open an MPQ archive""" |
||||||
|
filename = filedialog.askopenfilename( |
||||||
|
title="Select MPQ Archive", |
||||||
|
filetypes=[ |
||||||
|
("MPQ Archives", "*.mpq"), |
||||||
|
("SC2 Archives", "*.SC2Data *.SC2Map *.SC2Mod"), |
||||||
|
("All Files", "*.*") |
||||||
|
] |
||||||
|
) |
||||||
|
|
||||||
|
if not filename: |
||||||
|
return |
||||||
|
|
||||||
|
try: |
||||||
|
# Close current archive if any |
||||||
|
self.close_archive() |
||||||
|
|
||||||
|
# Open new archive |
||||||
|
self.current_archive = MPQArchive(filename) |
||||||
|
self.current_path = filename |
||||||
|
|
||||||
|
# Update UI |
||||||
|
self.file_path_var.set(filename) |
||||||
|
self.refresh_file_list() |
||||||
|
|
||||||
|
except StormLibError as e: |
||||||
|
messagebox.showerror("Error", f"Failed to open archive:\n{str(e)}") |
||||||
|
except Exception as e: |
||||||
|
messagebox.showerror("Error", f"Unexpected error:\n{str(e)}") |
||||||
|
|
||||||
|
def close_archive(self): |
||||||
|
"""Close the current archive""" |
||||||
|
if self.current_archive: |
||||||
|
try: |
||||||
|
self.current_archive.close() |
||||||
|
except: |
||||||
|
pass |
||||||
|
self.current_archive = None |
||||||
|
self.current_path = None |
||||||
|
|
||||||
|
self.file_path_var.set("No archive loaded") |
||||||
|
self.stats_text.set("No archive loaded") |
||||||
|
|
||||||
|
# Clear tree |
||||||
|
for item in self.tree.get_children(): |
||||||
|
self.tree.delete(item) |
||||||
|
|
||||||
|
def refresh_file_list(self): |
||||||
|
"""Refresh the file list from the current archive""" |
||||||
|
if not self.current_archive: |
||||||
|
return |
||||||
|
|
||||||
|
# Clear existing items |
||||||
|
for item in self.tree.get_children(): |
||||||
|
self.tree.delete(item) |
||||||
|
|
||||||
|
try: |
||||||
|
# Get all files |
||||||
|
files = self.current_archive.find_files("*") |
||||||
|
|
||||||
|
# Update stats |
||||||
|
total_size = sum(f['size'] for f in files) |
||||||
|
total_compressed = sum(f['compressed_size'] for f in files) |
||||||
|
|
||||||
|
if total_size > 0: |
||||||
|
ratio = (1 - total_compressed / total_size) * 100 |
||||||
|
else: |
||||||
|
ratio = 0 |
||||||
|
|
||||||
|
self.stats_text.set( |
||||||
|
f"Files: {len(files)} | " |
||||||
|
f"Total Size: {self.format_size(total_size)} | " |
||||||
|
f"Compressed: {self.format_size(total_compressed)} | " |
||||||
|
f"Ratio: {ratio:.1f}%" |
||||||
|
) |
||||||
|
|
||||||
|
# Add files to tree |
||||||
|
for file_info in sorted(files, key=lambda x: x['name']): |
||||||
|
name = file_info['name'] |
||||||
|
size = file_info['size'] |
||||||
|
compressed = file_info['compressed_size'] |
||||||
|
flags = file_info['flags'] |
||||||
|
|
||||||
|
# Calculate compression ratio |
||||||
|
if size > 0: |
||||||
|
file_ratio = (1 - compressed / size) * 100 |
||||||
|
else: |
||||||
|
file_ratio = 0 |
||||||
|
|
||||||
|
# Format flags |
||||||
|
flag_str = [] |
||||||
|
if flags & 0x00000200: # MPQ_FILE_COMPRESS |
||||||
|
flag_str.append("C") |
||||||
|
if flags & 0x00010000: # MPQ_FILE_ENCRYPTED |
||||||
|
flag_str.append("E") |
||||||
|
if flags & 0x01000000: # MPQ_FILE_SINGLE_UNIT |
||||||
|
flag_str.append("S") |
||||||
|
flag_display = "".join(flag_str) or "-" |
||||||
|
|
||||||
|
self.tree.insert('', 'end', text=name, values=( |
||||||
|
self.format_size(size), |
||||||
|
self.format_size(compressed), |
||||||
|
f"{file_ratio:.1f}%", |
||||||
|
flag_display |
||||||
|
)) |
||||||
|
|
||||||
|
except Exception as e: |
||||||
|
messagebox.showerror("Error", f"Failed to read archive:\n{str(e)}") |
||||||
|
|
||||||
|
def show_context_menu(self, event): |
||||||
|
"""Show context menu for tree item""" |
||||||
|
if not self.current_archive: |
||||||
|
return |
||||||
|
|
||||||
|
item = self.tree.identify_row(event.y) |
||||||
|
if item: |
||||||
|
self.tree.selection_set(item) |
||||||
|
self.tree_menu.post(event.x_root, event.y_root) |
||||||
|
|
||||||
|
def extract_selected_file(self): |
||||||
|
"""Extract the selected file""" |
||||||
|
if not self.current_archive: |
||||||
|
return |
||||||
|
|
||||||
|
selection = self.tree.selection() |
||||||
|
if not selection: |
||||||
|
messagebox.showwarning("Warning", "Please select a file to extract") |
||||||
|
return |
||||||
|
|
||||||
|
item = selection[0] |
||||||
|
filename = self.tree.item(item, 'text') |
||||||
|
|
||||||
|
# Ask where to save |
||||||
|
output_path = filedialog.asksaveasfilename( |
||||||
|
title="Save File As", |
||||||
|
initialfile=Path(filename).name, |
||||||
|
defaultextension="*", |
||||||
|
filetypes=[("All Files", "*.*")] |
||||||
|
) |
||||||
|
|
||||||
|
if not output_path: |
||||||
|
return |
||||||
|
|
||||||
|
try: |
||||||
|
self.current_archive.extract_file(filename, output_path) |
||||||
|
messagebox.showinfo("Success", f"File extracted to:\n{output_path}") |
||||||
|
except Exception as e: |
||||||
|
messagebox.showerror("Error", f"Failed to extract file:\n{str(e)}") |
||||||
|
|
||||||
|
def extract_all_files(self): |
||||||
|
"""Extract all files from the archive""" |
||||||
|
if not self.current_archive: |
||||||
|
messagebox.showwarning("Warning", "No archive loaded") |
||||||
|
return |
||||||
|
|
||||||
|
# Ask for output directory |
||||||
|
output_dir = filedialog.askdirectory(title="Select Output Directory") |
||||||
|
if not output_dir: |
||||||
|
return |
||||||
|
|
||||||
|
output_dir = Path(output_dir) |
||||||
|
|
||||||
|
try: |
||||||
|
files = self.current_archive.find_files("*") |
||||||
|
total = len(files) |
||||||
|
|
||||||
|
if total == 0: |
||||||
|
messagebox.showinfo("Info", "No files to extract") |
||||||
|
return |
||||||
|
|
||||||
|
# Create progress window |
||||||
|
progress_window = tk.Toplevel(self.root) |
||||||
|
progress_window.title("Extracting Files") |
||||||
|
progress_window.geometry("400x120") |
||||||
|
progress_window.transient(self.root) |
||||||
|
progress_window.grab_set() |
||||||
|
|
||||||
|
ttk.Label(progress_window, text="Extracting files...").pack(pady=(10, 5)) |
||||||
|
|
||||||
|
progress_var = tk.StringVar(value="0 / 0") |
||||||
|
ttk.Label(progress_window, textvariable=progress_var).pack() |
||||||
|
|
||||||
|
progress = ttk.Progressbar(progress_window, length=350, mode='determinate', maximum=total) |
||||||
|
progress.pack(pady=10) |
||||||
|
|
||||||
|
extracted = 0 |
||||||
|
failed = 0 |
||||||
|
|
||||||
|
for i, file_info in enumerate(files, 1): |
||||||
|
filename = file_info['name'] |
||||||
|
output_path = output_dir / filename |
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True) |
||||||
|
|
||||||
|
try: |
||||||
|
self.current_archive.extract_file(filename, str(output_path)) |
||||||
|
extracted += 1 |
||||||
|
except: |
||||||
|
failed += 1 |
||||||
|
|
||||||
|
progress['value'] = i |
||||||
|
progress_var.set(f"{i} / {total}") |
||||||
|
progress_window.update() |
||||||
|
|
||||||
|
progress_window.destroy() |
||||||
|
|
||||||
|
messagebox.showinfo( |
||||||
|
"Extraction Complete", |
||||||
|
f"Extracted: {extracted}\nFailed: {failed}\n\nOutput directory:\n{output_dir}" |
||||||
|
) |
||||||
|
|
||||||
|
except Exception as e: |
||||||
|
messagebox.showerror("Error", f"Extraction failed:\n{str(e)}") |
||||||
|
|
||||||
|
def show_file_info(self, event=None): |
||||||
|
"""Show detailed information about selected file""" |
||||||
|
if not self.current_archive: |
||||||
|
return |
||||||
|
|
||||||
|
selection = self.tree.selection() |
||||||
|
if not selection: |
||||||
|
return |
||||||
|
|
||||||
|
item = selection[0] |
||||||
|
filename = self.tree.item(item, 'text') |
||||||
|
values = self.tree.item(item, 'values') |
||||||
|
|
||||||
|
# Create info window |
||||||
|
info_window = tk.Toplevel(self.root) |
||||||
|
info_window.title(f"File Info - {Path(filename).name}") |
||||||
|
info_window.geometry("500x300") |
||||||
|
info_window.transient(self.root) |
||||||
|
|
||||||
|
# Create text widget |
||||||
|
text = scrolledtext.ScrolledText(info_window, wrap=tk.WORD, font=('Courier', 10)) |
||||||
|
text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) |
||||||
|
|
||||||
|
# Add info |
||||||
|
info_text = f"""File Information |
||||||
|
{'='*60} |
||||||
|
|
||||||
|
Filename: {filename} |
||||||
|
Size: {values[0]} |
||||||
|
Compressed Size: {values[1]} |
||||||
|
Compression Ratio: {values[2]} |
||||||
|
Flags: {values[3]} |
||||||
|
|
||||||
|
Archive: {self.current_path} |
||||||
|
""" |
||||||
|
|
||||||
|
text.insert('1.0', info_text) |
||||||
|
text.config(state=tk.DISABLED) |
||||||
|
|
||||||
|
# Add close button |
||||||
|
ttk.Button(info_window, text="Close", command=info_window.destroy).pack(pady=(0, 10)) |
||||||
|
|
||||||
|
def verify_archive(self): |
||||||
|
"""Verify the archive integrity""" |
||||||
|
if not self.current_archive: |
||||||
|
messagebox.showwarning("Warning", "No archive loaded") |
||||||
|
return |
||||||
|
|
||||||
|
try: |
||||||
|
result = self.current_archive.verify() |
||||||
|
if result == 0: |
||||||
|
messagebox.showinfo("Verification", "Archive verification successful!\n\nThe archive is valid.") |
||||||
|
else: |
||||||
|
messagebox.showwarning("Verification", f"Archive verification failed!\n\nError code: {result}") |
||||||
|
except Exception as e: |
||||||
|
messagebox.showerror("Error", f"Verification failed:\n{str(e)}") |
||||||
|
|
||||||
|
def show_about(self): |
||||||
|
"""Show about dialog""" |
||||||
|
about_text = """MPQ Inspector |
||||||
|
Version 1.0.0 |
||||||
|
|
||||||
|
A GUI tool for inspecting MPQ archives using PyStorm. |
||||||
|
|
||||||
|
PyStorm: Python bindings for StormLib |
||||||
|
StormLib: Created by Ladislav Zezula |
||||||
|
|
||||||
|
Licensed under MIT License |
||||||
|
""" |
||||||
|
messagebox.showinfo("About MPQ Inspector", about_text) |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
"""Main entry point""" |
||||||
|
root = tk.Tk() |
||||||
|
app = MPQInspectorApp(root) |
||||||
|
root.mainloop() |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
[build-system] |
||||||
|
requires = ["setuptools>=61.0", "wheel"] |
||||||
|
build-backend = "setuptools.build_meta" |
||||||
|
|
||||||
|
[project] |
||||||
|
name = "pystorm" |
||||||
|
version = "1.0.0" |
||||||
|
description = "Python bindings for StormLib - A library for working with MPQ archives" |
||||||
|
readme = "README.md" |
||||||
|
requires-python = ">=3.7" |
||||||
|
license = {text = "MIT"} |
||||||
|
authors = [ |
||||||
|
{name = "Matteo Benedetto", email = "your.email@example.com"} |
||||||
|
] |
||||||
|
keywords = ["mpq", "stormlib", "blizzard", "archive", "mopaq"] |
||||||
|
classifiers = [ |
||||||
|
"Development Status :: 4 - Beta", |
||||||
|
"Intended Audience :: Developers", |
||||||
|
"License :: OSI Approved :: MIT License", |
||||||
|
"Programming Language :: Python :: 3", |
||||||
|
"Programming Language :: Python :: 3.7", |
||||||
|
"Programming Language :: Python :: 3.8", |
||||||
|
"Programming Language :: Python :: 3.9", |
||||||
|
"Programming Language :: Python :: 3.10", |
||||||
|
"Programming Language :: Python :: 3.11", |
||||||
|
"Programming Language :: Python :: 3.12", |
||||||
|
"Topic :: Software Development :: Libraries", |
||||||
|
"Topic :: System :: Archiving", |
||||||
|
] |
||||||
|
|
||||||
|
[project.urls] |
||||||
|
Homepage = "https://github.com/enne2/pystorm" |
||||||
|
Repository = "https://github.com/enne2/pystorm" |
||||||
|
Documentation = "https://github.com/enne2/pystorm#readme" |
||||||
|
"Bug Tracker" = "https://github.com/enne2/pystorm/issues" |
||||||
|
"StormLib Repository" = "https://github.com/ladislav-zezula/StormLib" |
||||||
|
|
||||||
|
[project.optional-dependencies] |
||||||
|
dev = [ |
||||||
|
"pytest>=7.0", |
||||||
|
"black>=22.0", |
||||||
|
"flake8>=4.0", |
||||||
|
"mypy>=0.950", |
||||||
|
] |
||||||
|
|
||||||
|
[tool.setuptools] |
||||||
|
packages = ["pystorm"] |
||||||
|
|
||||||
|
[tool.setuptools.package-data] |
||||||
|
pystorm = ["*.so", "*.so.*", "*.dll", "*.dylib"] |
||||||
|
|
||||||
|
[tool.black] |
||||||
|
line-length = 100 |
||||||
|
target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] |
||||||
|
|
||||||
|
[tool.mypy] |
||||||
|
python_version = "3.7" |
||||||
|
warn_return_any = true |
||||||
|
warn_unused_configs = true |
||||||
|
disallow_untyped_defs = false |
||||||
@ -0,0 +1,109 @@ |
|||||||
|
""" |
||||||
|
PyStorm - Python bindings for StormLib |
||||||
|
A library for working with MPQ archives (Blizzard's MoPaQ archive format) |
||||||
|
""" |
||||||
|
|
||||||
|
from .stormlib import ( |
||||||
|
# Exception |
||||||
|
StormLibError, |
||||||
|
|
||||||
|
# Archive operations |
||||||
|
SFileOpenArchive, |
||||||
|
SFileCreateArchive, |
||||||
|
SFileCloseArchive, |
||||||
|
SFileFlushArchive, |
||||||
|
SFileCompactArchive, |
||||||
|
|
||||||
|
# File operations |
||||||
|
SFileOpenFileEx, |
||||||
|
SFileCloseFile, |
||||||
|
SFileReadFile, |
||||||
|
SFileGetFileSize, |
||||||
|
SFileSetFilePointer, |
||||||
|
SFileHasFile, |
||||||
|
SFileExtractFile, |
||||||
|
|
||||||
|
# File manipulation |
||||||
|
SFileAddFileEx, |
||||||
|
SFileRemoveFile, |
||||||
|
SFileRenameFile, |
||||||
|
|
||||||
|
# File search |
||||||
|
SFileFindFirstFile, |
||||||
|
SFileFindNextFile, |
||||||
|
SFileFindClose, |
||||||
|
|
||||||
|
# Archive verification |
||||||
|
SFileVerifyFile, |
||||||
|
SFileVerifyArchive, |
||||||
|
|
||||||
|
# Constants |
||||||
|
MPQArchive, |
||||||
|
MPQFile, |
||||||
|
SFILE_OPEN_FROM_MPQ, |
||||||
|
MPQ_OPEN_NO_LISTFILE, |
||||||
|
MPQ_OPEN_NO_ATTRIBUTES, |
||||||
|
MPQ_OPEN_READ_ONLY, |
||||||
|
MPQ_CREATE_LISTFILE, |
||||||
|
MPQ_CREATE_ATTRIBUTES, |
||||||
|
MPQ_CREATE_ARCHIVE_V1, |
||||||
|
MPQ_CREATE_ARCHIVE_V2, |
||||||
|
MPQ_FILE_COMPRESS, |
||||||
|
MPQ_FILE_ENCRYPTED, |
||||||
|
MPQ_FILE_FIX_KEY, |
||||||
|
MPQ_FILE_SINGLE_UNIT, |
||||||
|
MPQ_FILE_DELETE_MARKER, |
||||||
|
MPQ_FILE_SECTOR_CRC, |
||||||
|
MPQ_FILE_EXISTS, |
||||||
|
MPQ_COMPRESSION_HUFFMANN, |
||||||
|
MPQ_COMPRESSION_ZLIB, |
||||||
|
MPQ_COMPRESSION_PKWARE, |
||||||
|
MPQ_COMPRESSION_BZIP2, |
||||||
|
MPQ_COMPRESSION_SPARSE, |
||||||
|
MPQ_COMPRESSION_ADPCM_MONO, |
||||||
|
MPQ_COMPRESSION_ADPCM_STEREO, |
||||||
|
MPQ_COMPRESSION_LZMA, |
||||||
|
) |
||||||
|
|
||||||
|
__version__ = "1.0.0" |
||||||
|
__author__ = "Matteo Benedetto" |
||||||
|
__license__ = "MIT" |
||||||
|
|
||||||
|
__all__ = [ |
||||||
|
# Exception |
||||||
|
"StormLibError", |
||||||
|
|
||||||
|
# Archive operations |
||||||
|
"SFileOpenArchive", |
||||||
|
"SFileCreateArchive", |
||||||
|
"SFileCloseArchive", |
||||||
|
"SFileFlushArchive", |
||||||
|
"SFileCompactArchive", |
||||||
|
|
||||||
|
# File operations |
||||||
|
"SFileOpenFileEx", |
||||||
|
"SFileCloseFile", |
||||||
|
"SFileReadFile", |
||||||
|
"SFileGetFileSize", |
||||||
|
"SFileSetFilePointer", |
||||||
|
"SFileHasFile", |
||||||
|
"SFileExtractFile", |
||||||
|
|
||||||
|
# File manipulation |
||||||
|
"SFileAddFileEx", |
||||||
|
"SFileRemoveFile", |
||||||
|
"SFileRenameFile", |
||||||
|
|
||||||
|
# File search |
||||||
|
"SFileFindFirstFile", |
||||||
|
"SFileFindNextFile", |
||||||
|
"SFileFindClose", |
||||||
|
|
||||||
|
# Archive verification |
||||||
|
"SFileVerifyFile", |
||||||
|
"SFileVerifyArchive", |
||||||
|
|
||||||
|
# Classes |
||||||
|
"MPQArchive", |
||||||
|
"MPQFile", |
||||||
|
] |
||||||
Binary file not shown.
@ -0,0 +1,622 @@ |
|||||||
|
""" |
||||||
|
Python bindings for StormLib using ctypes |
||||||
|
""" |
||||||
|
|
||||||
|
import ctypes |
||||||
|
import os |
||||||
|
import sys |
||||||
|
from ctypes import ( |
||||||
|
c_void_p, c_char_p, c_wchar_p, c_uint, c_ulong, c_ulonglong, |
||||||
|
c_int, c_bool, c_char, POINTER, Structure, byref, create_string_buffer |
||||||
|
) |
||||||
|
from pathlib import Path |
||||||
|
from typing import Optional, Union, List, Tuple |
||||||
|
|
||||||
|
|
||||||
|
# Find and load the StormLib shared library |
||||||
|
def _find_stormlib(): |
||||||
|
"""Locate the StormLib shared library""" |
||||||
|
# Try common library names |
||||||
|
if sys.platform == 'win32': |
||||||
|
lib_names = ['StormLib.dll', 'storm.dll'] |
||||||
|
elif sys.platform == 'darwin': |
||||||
|
lib_names = ['libstorm.dylib', 'StormLib.dylib'] |
||||||
|
else: # Linux and other Unix-like |
||||||
|
lib_names = ['libstorm.so', 'StormLib.so'] |
||||||
|
|
||||||
|
# Try to load from system paths first |
||||||
|
for lib_name in lib_names: |
||||||
|
try: |
||||||
|
return ctypes.CDLL(lib_name) |
||||||
|
except OSError: |
||||||
|
pass |
||||||
|
|
||||||
|
# Try to load from package directory |
||||||
|
package_dir = Path(__file__).parent |
||||||
|
for lib_name in lib_names: |
||||||
|
lib_path = package_dir / lib_name |
||||||
|
if lib_path.exists(): |
||||||
|
try: |
||||||
|
return ctypes.CDLL(str(lib_path)) |
||||||
|
except OSError: |
||||||
|
pass |
||||||
|
|
||||||
|
raise OSError( |
||||||
|
"Could not find StormLib shared library. " |
||||||
|
"Please ensure StormLib is installed and accessible. " |
||||||
|
"You can build it from: https://github.com/ladislav-zezula/StormLib" |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
# Try to load the library |
||||||
|
try: |
||||||
|
_stormlib = _find_stormlib() |
||||||
|
except OSError as e: |
||||||
|
_stormlib = None |
||||||
|
_load_error = str(e) |
||||||
|
|
||||||
|
|
||||||
|
# Constants from StormLib.h |
||||||
|
# Open flags |
||||||
|
SFILE_OPEN_FROM_MPQ = 0x00000000 |
||||||
|
SFILE_OPEN_CHECK_EXISTS = 0xFFFFFFFC |
||||||
|
SFILE_OPEN_LOCAL_FILE = 0xFFFFFFFF |
||||||
|
|
||||||
|
# MPQ Open flags |
||||||
|
MPQ_OPEN_NO_LISTFILE = 0x00010000 |
||||||
|
MPQ_OPEN_NO_ATTRIBUTES = 0x00020000 |
||||||
|
MPQ_OPEN_NO_HEADER_SEARCH = 0x00040000 |
||||||
|
MPQ_OPEN_FORCE_MPQ_V1 = 0x00080000 |
||||||
|
MPQ_OPEN_CHECK_SECTOR_CRC = 0x00100000 |
||||||
|
MPQ_OPEN_PATCH = 0x00200000 |
||||||
|
MPQ_OPEN_READ_ONLY = 0x00000100 |
||||||
|
MPQ_OPEN_ENCRYPTED = 0x00010000 |
||||||
|
|
||||||
|
# MPQ Create flags |
||||||
|
MPQ_CREATE_LISTFILE = 0x00100000 |
||||||
|
MPQ_CREATE_ATTRIBUTES = 0x00200000 |
||||||
|
MPQ_CREATE_SIGNATURE = 0x00400000 |
||||||
|
MPQ_CREATE_ARCHIVE_V1 = 0x00000000 |
||||||
|
MPQ_CREATE_ARCHIVE_V2 = 0x01000000 |
||||||
|
MPQ_CREATE_ARCHIVE_V3 = 0x02000000 |
||||||
|
MPQ_CREATE_ARCHIVE_V4 = 0x03000000 |
||||||
|
|
||||||
|
# File flags |
||||||
|
MPQ_FILE_IMPLODE = 0x00000100 |
||||||
|
MPQ_FILE_COMPRESS = 0x00000200 |
||||||
|
MPQ_FILE_ENCRYPTED = 0x00010000 |
||||||
|
MPQ_FILE_FIX_KEY = 0x00020000 |
||||||
|
MPQ_FILE_DELETE_MARKER = 0x02000000 |
||||||
|
MPQ_FILE_SECTOR_CRC = 0x04000000 |
||||||
|
MPQ_FILE_SINGLE_UNIT = 0x01000000 |
||||||
|
MPQ_FILE_EXISTS = 0x80000000 |
||||||
|
|
||||||
|
# Compression types |
||||||
|
MPQ_COMPRESSION_HUFFMANN = 0x01 |
||||||
|
MPQ_COMPRESSION_ZLIB = 0x02 |
||||||
|
MPQ_COMPRESSION_PKWARE = 0x08 |
||||||
|
MPQ_COMPRESSION_BZIP2 = 0x10 |
||||||
|
MPQ_COMPRESSION_SPARSE = 0x20 |
||||||
|
MPQ_COMPRESSION_ADPCM_MONO = 0x40 |
||||||
|
MPQ_COMPRESSION_ADPCM_STEREO = 0x80 |
||||||
|
MPQ_COMPRESSION_LZMA = 0x12 |
||||||
|
|
||||||
|
# Verification flags |
||||||
|
MPQ_ATTRIBUTE_CRC32 = 0x00000001 |
||||||
|
MPQ_ATTRIBUTE_FILETIME = 0x00000002 |
||||||
|
MPQ_ATTRIBUTE_MD5 = 0x00000004 |
||||||
|
|
||||||
|
# File pointer movement |
||||||
|
FILE_BEGIN = 0 |
||||||
|
FILE_CURRENT = 1 |
||||||
|
FILE_END = 2 |
||||||
|
|
||||||
|
# Structures |
||||||
|
class SFILE_FIND_DATA(Structure): |
||||||
|
"""Structure for file search results""" |
||||||
|
_fields_ = [ |
||||||
|
("cFileName", c_char * 260), # MAX_PATH = 260 |
||||||
|
("szPlainName", c_char_p), |
||||||
|
("dwHashIndex", c_uint), |
||||||
|
("dwBlockIndex", c_uint), |
||||||
|
("dwFileSize", c_uint), |
||||||
|
("dwFileFlags", c_uint), |
||||||
|
("dwCompSize", c_uint), |
||||||
|
("dwFileTimeLo", c_uint), |
||||||
|
("dwFileTimeHi", c_uint), |
||||||
|
("lcLocale", c_uint), |
||||||
|
] |
||||||
|
|
||||||
|
|
||||||
|
# Function prototypes |
||||||
|
if _stormlib: |
||||||
|
# Archive operations |
||||||
|
_SFileOpenArchive = _stormlib.SFileOpenArchive |
||||||
|
_SFileOpenArchive.argtypes = [c_char_p, c_uint, c_uint, POINTER(c_void_p)] |
||||||
|
_SFileOpenArchive.restype = c_bool |
||||||
|
|
||||||
|
_SFileCreateArchive = _stormlib.SFileCreateArchive |
||||||
|
_SFileCreateArchive.argtypes = [c_char_p, c_uint, c_uint, POINTER(c_void_p)] |
||||||
|
_SFileCreateArchive.restype = c_bool |
||||||
|
|
||||||
|
_SFileCloseArchive = _stormlib.SFileCloseArchive |
||||||
|
_SFileCloseArchive.argtypes = [c_void_p] |
||||||
|
_SFileCloseArchive.restype = c_bool |
||||||
|
|
||||||
|
_SFileFlushArchive = _stormlib.SFileFlushArchive |
||||||
|
_SFileFlushArchive.argtypes = [c_void_p] |
||||||
|
_SFileFlushArchive.restype = c_bool |
||||||
|
|
||||||
|
_SFileCompactArchive = _stormlib.SFileCompactArchive |
||||||
|
_SFileCompactArchive.argtypes = [c_void_p, c_char_p, c_bool] |
||||||
|
_SFileCompactArchive.restype = c_bool |
||||||
|
|
||||||
|
# File operations |
||||||
|
_SFileOpenFileEx = _stormlib.SFileOpenFileEx |
||||||
|
_SFileOpenFileEx.argtypes = [c_void_p, c_char_p, c_uint, POINTER(c_void_p)] |
||||||
|
_SFileOpenFileEx.restype = c_bool |
||||||
|
|
||||||
|
_SFileCloseFile = _stormlib.SFileCloseFile |
||||||
|
_SFileCloseFile.argtypes = [c_void_p] |
||||||
|
_SFileCloseFile.restype = c_bool |
||||||
|
|
||||||
|
_SFileReadFile = _stormlib.SFileReadFile |
||||||
|
_SFileReadFile.argtypes = [c_void_p, c_void_p, c_uint, POINTER(c_uint), c_void_p] |
||||||
|
_SFileReadFile.restype = c_bool |
||||||
|
|
||||||
|
_SFileGetFileSize = _stormlib.SFileGetFileSize |
||||||
|
_SFileGetFileSize.argtypes = [c_void_p, POINTER(c_uint)] |
||||||
|
_SFileGetFileSize.restype = c_uint |
||||||
|
|
||||||
|
_SFileSetFilePointer = _stormlib.SFileSetFilePointer |
||||||
|
_SFileSetFilePointer.argtypes = [c_void_p, c_int, POINTER(c_int), c_uint] |
||||||
|
_SFileSetFilePointer.restype = c_uint |
||||||
|
|
||||||
|
_SFileHasFile = _stormlib.SFileHasFile |
||||||
|
_SFileHasFile.argtypes = [c_void_p, c_char_p] |
||||||
|
_SFileHasFile.restype = c_bool |
||||||
|
|
||||||
|
_SFileExtractFile = _stormlib.SFileExtractFile |
||||||
|
_SFileExtractFile.argtypes = [c_void_p, c_char_p, c_char_p, c_uint] |
||||||
|
_SFileExtractFile.restype = c_bool |
||||||
|
|
||||||
|
# File manipulation |
||||||
|
_SFileAddFileEx = _stormlib.SFileAddFileEx |
||||||
|
_SFileAddFileEx.argtypes = [c_void_p, c_char_p, c_char_p, c_uint, c_uint, c_uint] |
||||||
|
_SFileAddFileEx.restype = c_bool |
||||||
|
|
||||||
|
_SFileRemoveFile = _stormlib.SFileRemoveFile |
||||||
|
_SFileRemoveFile.argtypes = [c_void_p, c_char_p, c_uint] |
||||||
|
_SFileRemoveFile.restype = c_bool |
||||||
|
|
||||||
|
_SFileRenameFile = _stormlib.SFileRenameFile |
||||||
|
_SFileRenameFile.argtypes = [c_void_p, c_char_p, c_char_p] |
||||||
|
_SFileRenameFile.restype = c_bool |
||||||
|
|
||||||
|
# File search |
||||||
|
_SFileFindFirstFile = _stormlib.SFileFindFirstFile |
||||||
|
_SFileFindFirstFile.argtypes = [c_void_p, c_char_p, POINTER(SFILE_FIND_DATA), c_char_p] |
||||||
|
_SFileFindFirstFile.restype = c_void_p |
||||||
|
|
||||||
|
_SFileFindNextFile = _stormlib.SFileFindNextFile |
||||||
|
_SFileFindNextFile.argtypes = [c_void_p, POINTER(SFILE_FIND_DATA)] |
||||||
|
_SFileFindNextFile.restype = c_bool |
||||||
|
|
||||||
|
_SFileFindClose = _stormlib.SFileFindClose |
||||||
|
_SFileFindClose.argtypes = [c_void_p] |
||||||
|
_SFileFindClose.restype = c_bool |
||||||
|
|
||||||
|
# Verification |
||||||
|
_SFileVerifyFile = _stormlib.SFileVerifyFile |
||||||
|
_SFileVerifyFile.argtypes = [c_void_p, c_char_p, c_uint] |
||||||
|
_SFileVerifyFile.restype = c_uint |
||||||
|
|
||||||
|
_SFileVerifyArchive = _stormlib.SFileVerifyArchive |
||||||
|
_SFileVerifyArchive.argtypes = [c_void_p] |
||||||
|
_SFileVerifyArchive.restype = c_uint |
||||||
|
|
||||||
|
|
||||||
|
class StormLibError(Exception): |
||||||
|
"""Exception raised for StormLib errors""" |
||||||
|
pass |
||||||
|
|
||||||
|
|
||||||
|
def _check_library(): |
||||||
|
"""Check if the library was loaded successfully""" |
||||||
|
if _stormlib is None: |
||||||
|
raise StormLibError(f"StormLib not loaded: {_load_error}") |
||||||
|
|
||||||
|
|
||||||
|
class MPQArchive: |
||||||
|
"""High-level wrapper for MPQ archive operations""" |
||||||
|
|
||||||
|
def __init__(self, path: Union[str, Path], flags: int = 0, priority: int = 0): |
||||||
|
""" |
||||||
|
Open an MPQ archive |
||||||
|
|
||||||
|
Args: |
||||||
|
path: Path to the MPQ archive |
||||||
|
flags: Open flags (MPQ_OPEN_*) |
||||||
|
priority: Archive priority (0 by default) |
||||||
|
""" |
||||||
|
_check_library() |
||||||
|
self.path = str(path) |
||||||
|
self.handle = c_void_p() |
||||||
|
self._closed = False |
||||||
|
|
||||||
|
path_bytes = self.path.encode('utf-8') |
||||||
|
if not _SFileOpenArchive(path_bytes, priority, flags, byref(self.handle)): |
||||||
|
raise StormLibError(f"Failed to open archive: {self.path}") |
||||||
|
|
||||||
|
def __enter__(self): |
||||||
|
return self |
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb): |
||||||
|
self.close() |
||||||
|
|
||||||
|
def close(self): |
||||||
|
"""Close the archive""" |
||||||
|
if not self._closed and self.handle: |
||||||
|
_SFileCloseArchive(self.handle) |
||||||
|
self._closed = True |
||||||
|
|
||||||
|
def has_file(self, filename: str) -> bool: |
||||||
|
"""Check if a file exists in the archive""" |
||||||
|
if self._closed: |
||||||
|
raise StormLibError("Archive is closed") |
||||||
|
return _SFileHasFile(self.handle, filename.encode('utf-8')) |
||||||
|
|
||||||
|
def open_file(self, filename: str, search_scope: int = SFILE_OPEN_FROM_MPQ) -> 'MPQFile': |
||||||
|
"""Open a file from the archive""" |
||||||
|
if self._closed: |
||||||
|
raise StormLibError("Archive is closed") |
||||||
|
return MPQFile(self, filename, search_scope) |
||||||
|
|
||||||
|
def extract_file(self, filename: str, output_path: Union[str, Path], |
||||||
|
search_scope: int = SFILE_OPEN_FROM_MPQ) -> bool: |
||||||
|
"""Extract a file from the archive""" |
||||||
|
if self._closed: |
||||||
|
raise StormLibError("Archive is closed") |
||||||
|
|
||||||
|
result = _SFileExtractFile( |
||||||
|
self.handle, |
||||||
|
filename.encode('utf-8'), |
||||||
|
str(output_path).encode('utf-8'), |
||||||
|
search_scope |
||||||
|
) |
||||||
|
if not result: |
||||||
|
raise StormLibError(f"Failed to extract file: {filename}") |
||||||
|
return result |
||||||
|
|
||||||
|
def add_file(self, local_path: Union[str, Path], archived_name: str, |
||||||
|
flags: int = MPQ_FILE_COMPRESS, |
||||||
|
compression: int = MPQ_COMPRESSION_ZLIB) -> bool: |
||||||
|
"""Add a file to the archive""" |
||||||
|
if self._closed: |
||||||
|
raise StormLibError("Archive is closed") |
||||||
|
|
||||||
|
result = _SFileAddFileEx( |
||||||
|
self.handle, |
||||||
|
str(local_path).encode('utf-8'), |
||||||
|
archived_name.encode('utf-8'), |
||||||
|
flags, |
||||||
|
compression, |
||||||
|
compression |
||||||
|
) |
||||||
|
if not result: |
||||||
|
raise StormLibError(f"Failed to add file: {local_path}") |
||||||
|
return result |
||||||
|
|
||||||
|
def remove_file(self, filename: str, search_scope: int = SFILE_OPEN_FROM_MPQ) -> bool: |
||||||
|
"""Remove a file from the archive""" |
||||||
|
if self._closed: |
||||||
|
raise StormLibError("Archive is closed") |
||||||
|
|
||||||
|
result = _SFileRemoveFile( |
||||||
|
self.handle, |
||||||
|
filename.encode('utf-8'), |
||||||
|
search_scope |
||||||
|
) |
||||||
|
if not result: |
||||||
|
raise StormLibError(f"Failed to remove file: {filename}") |
||||||
|
return result |
||||||
|
|
||||||
|
def rename_file(self, old_name: str, new_name: str) -> bool: |
||||||
|
"""Rename a file in the archive""" |
||||||
|
if self._closed: |
||||||
|
raise StormLibError("Archive is closed") |
||||||
|
|
||||||
|
result = _SFileRenameFile( |
||||||
|
self.handle, |
||||||
|
old_name.encode('utf-8'), |
||||||
|
new_name.encode('utf-8') |
||||||
|
) |
||||||
|
if not result: |
||||||
|
raise StormLibError(f"Failed to rename file: {old_name}") |
||||||
|
return result |
||||||
|
|
||||||
|
def find_files(self, mask: str = "*") -> List[dict]: |
||||||
|
"""Find files in the archive matching a mask""" |
||||||
|
if self._closed: |
||||||
|
raise StormLibError("Archive is closed") |
||||||
|
|
||||||
|
files = [] |
||||||
|
find_data = SFILE_FIND_DATA() |
||||||
|
|
||||||
|
handle = _SFileFindFirstFile( |
||||||
|
self.handle, |
||||||
|
mask.encode('utf-8'), |
||||||
|
byref(find_data), |
||||||
|
None |
||||||
|
) |
||||||
|
|
||||||
|
if not handle: |
||||||
|
return files |
||||||
|
|
||||||
|
try: |
||||||
|
while True: |
||||||
|
# cFileName is now an array, decode it directly |
||||||
|
filename = find_data.cFileName.decode('utf-8') if find_data.cFileName else '' |
||||||
|
file_info = { |
||||||
|
'name': filename, |
||||||
|
'size': find_data.dwFileSize, |
||||||
|
'compressed_size': find_data.dwCompSize, |
||||||
|
'flags': find_data.dwFileFlags, |
||||||
|
} |
||||||
|
files.append(file_info) |
||||||
|
|
||||||
|
if not _SFileFindNextFile(handle, byref(find_data)): |
||||||
|
break |
||||||
|
finally: |
||||||
|
_SFileFindClose(handle) |
||||||
|
|
||||||
|
return files |
||||||
|
|
||||||
|
def flush(self) -> bool: |
||||||
|
"""Flush pending changes to disk""" |
||||||
|
if self._closed: |
||||||
|
raise StormLibError("Archive is closed") |
||||||
|
return _SFileFlushArchive(self.handle) |
||||||
|
|
||||||
|
def compact(self, listfile: Optional[str] = None) -> bool: |
||||||
|
"""Compact the archive""" |
||||||
|
if self._closed: |
||||||
|
raise StormLibError("Archive is closed") |
||||||
|
|
||||||
|
listfile_bytes = listfile.encode('utf-8') if listfile else None |
||||||
|
return _SFileCompactArchive(self.handle, listfile_bytes, False) |
||||||
|
|
||||||
|
def verify(self) -> int: |
||||||
|
"""Verify archive integrity""" |
||||||
|
if self._closed: |
||||||
|
raise StormLibError("Archive is closed") |
||||||
|
return _SFileVerifyArchive(self.handle) |
||||||
|
|
||||||
|
def __del__(self): |
||||||
|
self.close() |
||||||
|
|
||||||
|
|
||||||
|
class MPQFile: |
||||||
|
"""High-level wrapper for MPQ file operations""" |
||||||
|
|
||||||
|
def __init__(self, archive: MPQArchive, filename: str, search_scope: int = SFILE_OPEN_FROM_MPQ): |
||||||
|
"""Open a file from an MPQ archive""" |
||||||
|
_check_library() |
||||||
|
self.archive = archive |
||||||
|
self.filename = filename |
||||||
|
self.handle = c_void_p() |
||||||
|
self._closed = False |
||||||
|
|
||||||
|
if not _SFileOpenFileEx( |
||||||
|
archive.handle, |
||||||
|
filename.encode('utf-8'), |
||||||
|
search_scope, |
||||||
|
byref(self.handle) |
||||||
|
): |
||||||
|
raise StormLibError(f"Failed to open file: {filename}") |
||||||
|
|
||||||
|
def __enter__(self): |
||||||
|
return self |
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb): |
||||||
|
self.close() |
||||||
|
|
||||||
|
def close(self): |
||||||
|
"""Close the file""" |
||||||
|
if not self._closed and self.handle: |
||||||
|
_SFileCloseFile(self.handle) |
||||||
|
self._closed = True |
||||||
|
|
||||||
|
def get_size(self) -> int: |
||||||
|
"""Get the file size""" |
||||||
|
if self._closed: |
||||||
|
raise StormLibError("File is closed") |
||||||
|
|
||||||
|
high = c_uint() |
||||||
|
low = _SFileGetFileSize(self.handle, byref(high)) |
||||||
|
return (high.value << 32) | low |
||||||
|
|
||||||
|
def read(self, size: Optional[int] = None) -> bytes: |
||||||
|
"""Read data from the file""" |
||||||
|
if self._closed: |
||||||
|
raise StormLibError("File is closed") |
||||||
|
|
||||||
|
if size is None: |
||||||
|
size = self.get_size() |
||||||
|
|
||||||
|
buffer = create_string_buffer(size) |
||||||
|
bytes_read = c_uint() |
||||||
|
|
||||||
|
if not _SFileReadFile(self.handle, buffer, size, byref(bytes_read), None): |
||||||
|
raise StormLibError(f"Failed to read file: {self.filename}") |
||||||
|
|
||||||
|
return buffer.raw[:bytes_read.value] |
||||||
|
|
||||||
|
def seek(self, offset: int, whence: int = FILE_BEGIN) -> int: |
||||||
|
"""Seek to a position in the file""" |
||||||
|
if self._closed: |
||||||
|
raise StormLibError("File is closed") |
||||||
|
|
||||||
|
high = c_int(offset >> 32) |
||||||
|
low = offset & 0xFFFFFFFF |
||||||
|
|
||||||
|
result = _SFileSetFilePointer(self.handle, low, byref(high), whence) |
||||||
|
if result == 0xFFFFFFFF: |
||||||
|
raise StormLibError(f"Failed to seek in file: {self.filename}") |
||||||
|
|
||||||
|
return (high.value << 32) | result |
||||||
|
|
||||||
|
def __del__(self): |
||||||
|
self.close() |
||||||
|
|
||||||
|
|
||||||
|
# Low-level function wrappers |
||||||
|
def SFileOpenArchive(path: str, priority: int = 0, flags: int = 0) -> c_void_p: |
||||||
|
"""Open an MPQ archive (low-level)""" |
||||||
|
_check_library() |
||||||
|
handle = c_void_p() |
||||||
|
if not _SFileOpenArchive(path.encode('utf-8'), priority, flags, byref(handle)): |
||||||
|
raise StormLibError(f"Failed to open archive: {path}") |
||||||
|
return handle |
||||||
|
|
||||||
|
|
||||||
|
def SFileCreateArchive(path: str, flags: int = 0, max_file_count: int = 1000) -> c_void_p: |
||||||
|
"""Create a new MPQ archive (low-level)""" |
||||||
|
_check_library() |
||||||
|
handle = c_void_p() |
||||||
|
if not _SFileCreateArchive(path.encode('utf-8'), flags, max_file_count, byref(handle)): |
||||||
|
raise StormLibError(f"Failed to create archive: {path}") |
||||||
|
return handle |
||||||
|
|
||||||
|
|
||||||
|
def SFileCloseArchive(handle: c_void_p) -> bool: |
||||||
|
"""Close an MPQ archive (low-level)""" |
||||||
|
_check_library() |
||||||
|
return _SFileCloseArchive(handle) |
||||||
|
|
||||||
|
|
||||||
|
def SFileFlushArchive(handle: c_void_p) -> bool: |
||||||
|
"""Flush archive changes (low-level)""" |
||||||
|
_check_library() |
||||||
|
return _SFileFlushArchive(handle) |
||||||
|
|
||||||
|
|
||||||
|
def SFileCompactArchive(handle: c_void_p, listfile: Optional[str] = None) -> bool: |
||||||
|
"""Compact an archive (low-level)""" |
||||||
|
_check_library() |
||||||
|
listfile_bytes = listfile.encode('utf-8') if listfile else None |
||||||
|
return _SFileCompactArchive(handle, listfile_bytes, False) |
||||||
|
|
||||||
|
|
||||||
|
def SFileOpenFileEx(archive_handle: c_void_p, filename: str, |
||||||
|
search_scope: int = SFILE_OPEN_FROM_MPQ) -> c_void_p: |
||||||
|
"""Open a file from archive (low-level)""" |
||||||
|
_check_library() |
||||||
|
handle = c_void_p() |
||||||
|
if not _SFileOpenFileEx(archive_handle, filename.encode('utf-8'), |
||||||
|
search_scope, byref(handle)): |
||||||
|
raise StormLibError(f"Failed to open file: {filename}") |
||||||
|
return handle |
||||||
|
|
||||||
|
|
||||||
|
def SFileCloseFile(handle: c_void_p) -> bool: |
||||||
|
"""Close a file (low-level)""" |
||||||
|
_check_library() |
||||||
|
return _SFileCloseFile(handle) |
||||||
|
|
||||||
|
|
||||||
|
def SFileReadFile(handle: c_void_p, size: int) -> bytes: |
||||||
|
"""Read from a file (low-level)""" |
||||||
|
_check_library() |
||||||
|
buffer = create_string_buffer(size) |
||||||
|
bytes_read = c_uint() |
||||||
|
if not _SFileReadFile(handle, buffer, size, byref(bytes_read), None): |
||||||
|
raise StormLibError("Failed to read file") |
||||||
|
return buffer.raw[:bytes_read.value] |
||||||
|
|
||||||
|
|
||||||
|
def SFileGetFileSize(handle: c_void_p) -> int: |
||||||
|
"""Get file size (low-level)""" |
||||||
|
_check_library() |
||||||
|
high = c_uint() |
||||||
|
low = _SFileGetFileSize(handle, byref(high)) |
||||||
|
return (high.value << 32) | low |
||||||
|
|
||||||
|
|
||||||
|
def SFileSetFilePointer(handle: c_void_p, offset: int, whence: int = FILE_BEGIN) -> int: |
||||||
|
"""Set file pointer (low-level)""" |
||||||
|
_check_library() |
||||||
|
high = c_int(offset >> 32) |
||||||
|
low = offset & 0xFFFFFFFF |
||||||
|
result = _SFileSetFilePointer(handle, low, byref(high), whence) |
||||||
|
if result == 0xFFFFFFFF: |
||||||
|
raise StormLibError("Failed to set file pointer") |
||||||
|
return (high.value << 32) | result |
||||||
|
|
||||||
|
|
||||||
|
def SFileHasFile(archive_handle: c_void_p, filename: str) -> bool: |
||||||
|
"""Check if file exists in archive (low-level)""" |
||||||
|
_check_library() |
||||||
|
return _SFileHasFile(archive_handle, filename.encode('utf-8')) |
||||||
|
|
||||||
|
|
||||||
|
def SFileExtractFile(archive_handle: c_void_p, filename: str, output_path: str, |
||||||
|
search_scope: int = SFILE_OPEN_FROM_MPQ) -> bool: |
||||||
|
"""Extract file from archive (low-level)""" |
||||||
|
_check_library() |
||||||
|
return _SFileExtractFile(archive_handle, filename.encode('utf-8'), |
||||||
|
output_path.encode('utf-8'), search_scope) |
||||||
|
|
||||||
|
|
||||||
|
def SFileAddFileEx(archive_handle: c_void_p, local_path: str, archived_name: str, |
||||||
|
flags: int = MPQ_FILE_COMPRESS, |
||||||
|
compression: int = MPQ_COMPRESSION_ZLIB) -> bool: |
||||||
|
"""Add file to archive (low-level)""" |
||||||
|
_check_library() |
||||||
|
return _SFileAddFileEx(archive_handle, local_path.encode('utf-8'), |
||||||
|
archived_name.encode('utf-8'), flags, compression, compression) |
||||||
|
|
||||||
|
|
||||||
|
def SFileRemoveFile(archive_handle: c_void_p, filename: str, |
||||||
|
search_scope: int = SFILE_OPEN_FROM_MPQ) -> bool: |
||||||
|
"""Remove file from archive (low-level)""" |
||||||
|
_check_library() |
||||||
|
return _SFileRemoveFile(archive_handle, filename.encode('utf-8'), search_scope) |
||||||
|
|
||||||
|
|
||||||
|
def SFileRenameFile(archive_handle: c_void_p, old_name: str, new_name: str) -> bool: |
||||||
|
"""Rename file in archive (low-level)""" |
||||||
|
_check_library() |
||||||
|
return _SFileRenameFile(archive_handle, old_name.encode('utf-8'), new_name.encode('utf-8')) |
||||||
|
|
||||||
|
|
||||||
|
def SFileFindFirstFile(archive_handle: c_void_p, mask: str = "*") -> Tuple[c_void_p, SFILE_FIND_DATA]: |
||||||
|
"""Find first file (low-level)""" |
||||||
|
_check_library() |
||||||
|
find_data = SFILE_FIND_DATA() |
||||||
|
handle = _SFileFindFirstFile(archive_handle, mask.encode('utf-8'), byref(find_data), None) |
||||||
|
return handle, find_data |
||||||
|
|
||||||
|
|
||||||
|
def SFileFindNextFile(find_handle: c_void_p, find_data: SFILE_FIND_DATA) -> bool: |
||||||
|
"""Find next file (low-level)""" |
||||||
|
_check_library() |
||||||
|
return _SFileFindNextFile(find_handle, byref(find_data)) |
||||||
|
|
||||||
|
|
||||||
|
def SFileFindClose(find_handle: c_void_p) -> bool: |
||||||
|
"""Close find handle (low-level)""" |
||||||
|
_check_library() |
||||||
|
return _SFileFindClose(find_handle) |
||||||
|
|
||||||
|
|
||||||
|
def SFileVerifyFile(archive_handle: c_void_p, filename: str, flags: int = 0) -> int: |
||||||
|
"""Verify file (low-level)""" |
||||||
|
_check_library() |
||||||
|
return _SFileVerifyFile(archive_handle, filename.encode('utf-8'), flags) |
||||||
|
|
||||||
|
|
||||||
|
def SFileVerifyArchive(archive_handle: c_void_p) -> int: |
||||||
|
"""Verify archive (low-level)""" |
||||||
|
_check_library() |
||||||
|
return _SFileVerifyArchive(archive_handle) |
||||||
@ -0,0 +1,131 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Setup script for PyStorm - Python bindings for StormLib |
||||||
|
""" |
||||||
|
|
||||||
|
from setuptools import setup, find_packages |
||||||
|
from setuptools.command.build_py import build_py |
||||||
|
from pathlib import Path |
||||||
|
import subprocess |
||||||
|
import sys |
||||||
|
import os |
||||||
|
|
||||||
|
|
||||||
|
class BuildStormLib(build_py): |
||||||
|
"""Custom build command to compile StormLib if needed""" |
||||||
|
|
||||||
|
def run(self): |
||||||
|
"""Run the build""" |
||||||
|
package_dir = Path(__file__).parent / 'pystorm' |
||||||
|
|
||||||
|
# Check if library already exists in package |
||||||
|
lib_patterns = ['*.so', '*.dll', '*.dylib'] |
||||||
|
lib_found_in_package = False |
||||||
|
|
||||||
|
for pattern in lib_patterns: |
||||||
|
if list(package_dir.glob(pattern)): |
||||||
|
lib_found_in_package = True |
||||||
|
print(f"Found StormLib library in package directory") |
||||||
|
break |
||||||
|
|
||||||
|
if not lib_found_in_package: |
||||||
|
# Try to build StormLib |
||||||
|
print("\n" + "="*70) |
||||||
|
print("StormLib not found in package directory") |
||||||
|
print("="*70) |
||||||
|
print("\nAttempting to build StormLib automatically...") |
||||||
|
|
||||||
|
try: |
||||||
|
build_script = Path(__file__).parent / 'build_stormlib.py' |
||||||
|
if build_script.exists(): |
||||||
|
result = subprocess.run( |
||||||
|
[sys.executable, str(build_script)], |
||||||
|
cwd=Path(__file__).parent |
||||||
|
) |
||||||
|
if result.returncode == 0: |
||||||
|
print("✓ StormLib built successfully") |
||||||
|
else: |
||||||
|
print("✗ Failed to build StormLib automatically") |
||||||
|
self._print_manual_instructions() |
||||||
|
else: |
||||||
|
self._print_manual_instructions() |
||||||
|
except Exception as e: |
||||||
|
print(f"Error during automatic build: {e}") |
||||||
|
self._print_manual_instructions() |
||||||
|
|
||||||
|
# Run the normal build |
||||||
|
build_py.run(self) |
||||||
|
|
||||||
|
def _print_manual_instructions(self): |
||||||
|
"""Print manual installation instructions""" |
||||||
|
print("\nTo build StormLib manually:") |
||||||
|
print("\n python3 build_stormlib.py") |
||||||
|
print("\nOr install StormLib system-wide:") |
||||||
|
print("\n1. Clone the repository:") |
||||||
|
print(" git clone https://github.com/ladislav-zezula/StormLib.git") |
||||||
|
print("\n2. Build and install:") |
||||||
|
print(" cd StormLib") |
||||||
|
print(" mkdir build && cd build") |
||||||
|
print(" cmake ..") |
||||||
|
print(" make") |
||||||
|
print(" sudo make install # Linux/macOS") |
||||||
|
print("\n3. Then run: pip install -e .") |
||||||
|
print("="*70 + "\n") |
||||||
|
|
||||||
|
|
||||||
|
# Read the README file |
||||||
|
readme_path = Path(__file__).parent / "README.md" |
||||||
|
long_description = "" |
||||||
|
if readme_path.exists(): |
||||||
|
long_description = readme_path.read_text(encoding="utf-8") |
||||||
|
|
||||||
|
|
||||||
|
setup( |
||||||
|
name="pystorm", |
||||||
|
version="1.0.0", |
||||||
|
description="Python bindings for StormLib - A library for working with MPQ archives", |
||||||
|
long_description=long_description, |
||||||
|
long_description_content_type="text/markdown", |
||||||
|
author="Matteo Benedetto", |
||||||
|
author_email="your.email@example.com", |
||||||
|
url="https://github.com/enne2/pystorm", |
||||||
|
license="MIT", |
||||||
|
packages=find_packages(), |
||||||
|
package_data={ |
||||||
|
'pystorm': ['*.so', '*.so.*', '*.dll', '*.dylib'], |
||||||
|
}, |
||||||
|
include_package_data=True, |
||||||
|
python_requires=">=3.7", |
||||||
|
classifiers=[ |
||||||
|
"Development Status :: 4 - Beta", |
||||||
|
"Intended Audience :: Developers", |
||||||
|
"License :: OSI Approved :: MIT License", |
||||||
|
"Programming Language :: Python :: 3", |
||||||
|
"Programming Language :: Python :: 3.7", |
||||||
|
"Programming Language :: Python :: 3.8", |
||||||
|
"Programming Language :: Python :: 3.9", |
||||||
|
"Programming Language :: Python :: 3.10", |
||||||
|
"Programming Language :: Python :: 3.11", |
||||||
|
"Programming Language :: Python :: 3.12", |
||||||
|
"Topic :: Software Development :: Libraries", |
||||||
|
"Topic :: System :: Archiving", |
||||||
|
], |
||||||
|
keywords="mpq stormlib blizzard archive mopaq", |
||||||
|
project_urls={ |
||||||
|
"Homepage": "https://github.com/enne2/pystorm", |
||||||
|
"Repository": "https://github.com/enne2/pystorm", |
||||||
|
"Bug Tracker": "https://github.com/enne2/pystorm/issues", |
||||||
|
"StormLib Repository": "https://github.com/ladislav-zezula/StormLib", |
||||||
|
}, |
||||||
|
cmdclass={ |
||||||
|
'build_py': BuildStormLib, |
||||||
|
}, |
||||||
|
extras_require={ |
||||||
|
'dev': [ |
||||||
|
'pytest>=7.0', |
||||||
|
'black>=22.0', |
||||||
|
'flake8>=4.0', |
||||||
|
'mypy>=0.950', |
||||||
|
], |
||||||
|
}, |
||||||
|
) |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
"""Test if tkinter works""" |
||||||
|
|
||||||
|
import sys |
||||||
|
|
||||||
|
# Test basic tkinter |
||||||
|
try: |
||||||
|
import tkinter as tk |
||||||
|
print("✓ tkinter imported") |
||||||
|
except ImportError as e: |
||||||
|
print(f"✗ Failed to import tkinter: {e}") |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
# Test creating a window |
||||||
|
try: |
||||||
|
root = tk.Tk() |
||||||
|
print("✓ Created Tk root window") |
||||||
|
root.withdraw() # Don't show it |
||||||
|
print("✓ Window hidden") |
||||||
|
root.destroy() |
||||||
|
print("✓ Window destroyed") |
||||||
|
print("\ntkinter is working correctly!") |
||||||
|
except Exception as e: |
||||||
|
print(f"✗ Error with tkinter: {e}") |
||||||
|
import traceback |
||||||
|
traceback.print_exc() |
||||||
|
sys.exit(1) |
||||||
Loading…
Reference in new issue