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