diff --git a/.dockerignore b/.dockerignore index 796b96d..7d4c5cf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,9 @@ -/build +# Local build, docs and test output: large, stale and never read by the image build +build/ +book/ +.venv/ +test/data/ + +# .git is deliberately not ignored. The root CMakeLists runs git rev-parse to +# stamp the commit into the version header, and without the repository the +# released binary reports "unknown". diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d6ec4dc..683d7cd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,39 +1,34 @@ name: Build on: - workflow_call + workflow_call: + inputs: + artifacts: + description: Build the full release artefact set, not just the cheap subset + type: boolean + default: false + +permissions: + contents: read jobs: build_linux: strategy: + # Report both architectures independently; one failing says nothing about + # the other, and cancelling hides half the answer + fail-fast: false matrix: include: # AMD64 builds on ubuntu-24.04 - arch: amd64 - libc: glibc - runner: ubuntu-24.04 - dockerfile: Dockerfile.glibc - platform: linux/amd64 - binary_path: /usr/local/bin/mpqcli - - arch: amd64 - libc: musl + asset: mpqcli-linux-x86_64 runner: ubuntu-24.04 - dockerfile: Dockerfile.musl platform: linux/amd64 - binary_path: /mpqcli # ARM64 builds on ubuntu-24.04-arm - arch: arm64 - libc: glibc + asset: mpqcli-linux-aarch64 runner: ubuntu-24.04-arm - dockerfile: Dockerfile.glibc platform: linux/arm64 - binary_path: /usr/local/bin/mpqcli - - arch: arm64 - libc: musl - runner: ubuntu-24.04-arm - dockerfile: Dockerfile.musl - platform: linux/arm64 - binary_path: /mpqcli runs-on: ${{ matrix.runner }} steps: @@ -42,84 +37,76 @@ jobs: with: submodules: true - - name: Build Docker image (${{ matrix.arch }} ${{ matrix.libc }}) + - name: Build Docker image (${{ matrix.arch }}) run: | - docker build --platform ${{ matrix.platform }} -t mpqcli-${{ matrix.arch }}-${{ matrix.libc }} -f ${{ matrix.dockerfile }} . + docker build --platform ${{ matrix.platform }} -t mpqcli-${{ matrix.arch }} . - name: Extract binary from Docker image run: | - # Create a temporary container from the image - CONTAINER_ID=$(docker create mpqcli-${{ matrix.arch }}-${{ matrix.libc }}) - # Copy the binary from the container - docker cp $CONTAINER_ID:${{ matrix.binary_path }} ./mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }} - # Clean up the container + CONTAINER_ID=$(docker create mpqcli-${{ matrix.arch }}) + docker cp $CONTAINER_ID:/mpqcli ./${{ matrix.asset }} docker rm $CONTAINER_ID - - name: Verify binary + # Run the bytes that will ship: a static binary missing its loader builds + # without complaint and only fails here + - name: Smoke-run the artefact run: | - file ./mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }} - echo "Binary created: mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }}" + chmod +x ./${{ matrix.asset }} + ./${{ matrix.asset }} version - - name: Upload binary as artifact + - name: Upload binary as artefact uses: actions/upload-artifact@v7 with: - name: mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }} - path: mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }} + name: ${{ matrix.asset }} + path: ${{ matrix.asset }} retention-days: 1 - build_windows: - runs-on: windows-2022 - steps: - - name: Check out repository code - uses: actions/checkout@v6 - with: - submodules: true - - - name: Run cmake - shell: bash + # The amd64 image is the one published. Saving it here rather than + # rebuilding it in a job of its own means release and prerelease push + # exactly the bytes this job smoke-ran. Only they consume it, so a pull + # request skips the save. + - name: Save Docker image as a tar file + if: matrix.arch == 'amd64' && inputs.artifacts run: | - cmake -B build \ - -G "Visual Studio 17 2022" \ - -A x64 \ - -DBUILD_MPQCLI=ON - - - name: Compile project - run: cmake --build build --config Release - - - name: Check mpqcli version - run: build/bin/Release/mpqcli.exe version - - - name: Copy mpqcli binary - run: cp build/bin/Release/mpqcli.exe build/bin/mpqcli-windows-amd64.exe + docker tag mpqcli-${{ matrix.arch }} mpqcli + docker save mpqcli -o mpqcli-docker.tar - - name: Upload Artifact + - name: Upload Docker image as artefact + if: matrix.arch == 'amd64' && inputs.artifacts uses: actions/upload-artifact@v7 with: - name: mpqcli-windows-amd64.exe - path: build/bin/mpqcli-windows-amd64.exe + name: mpqcli-docker + path: mpqcli-docker.tar retention-days: 1 - build_docker: - runs-on: ubuntu-24.04 + # Gated on the full artefact set: test.yml already compiles this platform, so + # a pull request learns nothing from a second Windows build, and the runner + # bills at twice a Linux one + build_windows: + if: inputs.artifacts + runs-on: windows-2022 steps: - name: Check out repository code uses: actions/checkout@v6 with: submodules: true - # For the distributable Docker image we use musl - # This results in a smaller image size - - name: Build Docker image (AMD64) + # Raw cmake rather than make: the Makefile needs a POSIX shell, and the + # shipped build gets its own tree rather than the everyday build/dev + - name: Build + shell: bash run: | - docker build --platform linux/amd64 -t mpqcli -f Dockerfile.musl . + cmake -B build/release -G "Visual Studio 17 2022" -A x64 -DMPQCLI_BUILD_APP=ON + cmake --build build/release --config Release + mv build/release/bin/mpqcli.exe ./mpqcli-windows-x86_64.exe - - name: Save Docker image as a tar file - run: | - docker save mpqcli -o mpqcli-docker.tar + - name: Smoke-run the artefact + shell: bash + run: ./mpqcli-windows-x86_64.exe version - - name: Upload Docker image as artifact + - name: Upload binary as artefact uses: actions/upload-artifact@v7 with: - name: mpqcli-docker - path: mpqcli-docker.tar + name: mpqcli-windows-x86_64.exe + path: mpqcli-windows-x86_64.exe retention-days: 1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b86979c..a3923f3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -15,11 +15,8 @@ concurrency: group: "pages" cancel-in-progress: false -# Required permissions for GitHub Pages deployment permissions: contents: read - pages: write - id-token: write env: MDBOOK_VERSION: 0.5.4 @@ -46,7 +43,7 @@ jobs: - name: Build docs run: make docs_build - - name: Upload Pages artifact + - name: Upload Pages artefact uses: actions/upload-pages-artifact@v5 with: path: book/ @@ -54,6 +51,10 @@ jobs: deploy: needs: build runs-on: ubuntu-24.04 + # Only the deploy job publishes, so only it gets the Pages permissions + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 68db4c7..e6b059e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,6 +3,9 @@ name: Lint on: workflow_call +permissions: + contents: read + jobs: lint_cpp: runs-on: ubuntu-24.04 @@ -12,10 +15,7 @@ jobs: with: submodules: true - - run: make install_clang_tools - - - run: make configure - - - run: make fmt_check + - name: Install clang tools + run: sudo apt-get install -y clang-18 clang-format-18 clang-tidy-18 - - run: make lint_cpp + - run: make check_all diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 64e43bd..30ac1ac 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,33 +10,39 @@ on: - ".clang-format" - ".clang-tidy" - "src/**" + - "app/**" + - "cmake/**" + - "completion/**" - "extern/**" - "test/**" - "CMakeLists.txt" - - "Dockerfile.glibc" - - "Dockerfile.musl" + - "Dockerfile" + - ".dockerignore" + - ".gpipe.yml" +# Never cancel on main. The prerelease job deletes the dev release, recreates +# its tag and pushes the image; a run cancelled part-way through leaves those +# three disagreeing with each other, which costs more than the runner minutes +# cancelling would save. concurrency: - group: prerelease - cancel-in-progress: true + group: main-${{ github.ref }} + cancel-in-progress: false permissions: - contents: write - packages: write + contents: read jobs: - build: - uses: ./.github/workflows/build.yml lint: uses: ./.github/workflows/lint.yml - needs: build test: uses: ./.github/workflows/test.yml - needs: build + build: + uses: ./.github/workflows/build.yml + with: + artifacts: true prerelease: uses: ./.github/workflows/prerelease.yml - needs: - - build - - lint - - test - secrets: inherit + needs: [build, lint, test] + permissions: + contents: write + packages: write diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index cdc8167..70c7dc0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -4,24 +4,35 @@ on: pull_request: paths: - ".github/workflows/**" + - "Makefile" - ".clang-format" - ".clang-tidy" - "src/**" + - "app/**" + - "cmake/**" + - "completion/**" - "extern/**" - "test/**" - "CMakeLists.txt" - - "Dockerfile.glibc" - - "Dockerfile.musl" + - "Dockerfile" + - ".dockerignore" + - ".gpipe.yml" + +# Grouped by pull request number, so a push only ever supersedes an earlier run of +# its own branch and never one somebody else is waiting on +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true permissions: contents: read +# No needs between the three: lint configures its own tree, test builds what it +# runs, and nothing reads build's artefacts until a release jobs: - build: - uses: ./.github/workflows/build.yml lint: uses: ./.github/workflows/lint.yml - needs: build test: uses: ./.github/workflows/test.yml - needs: build + build: + uses: ./.github/workflows/build.yml diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 280aff3..53de509 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -14,48 +14,37 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Download Linux GLIBC binary + - name: Download all build artefacts uses: actions/download-artifact@v8 with: - name: mpqcli-linux-amd64-glibc + path: dist + merge-multiple: true - - name: Download Linux MUSL binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-linux-amd64-musl - - - name: Download Linux ARM64 GLIBC binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-linux-arm64-glibc - - - name: Download Linux ARM64 MUSL binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-linux-arm64-musl - - - name: Download Windows binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-windows-amd64.exe - - - name: Delete existing dev release + - name: Delete any existing dev release run: | - gh release delete dev --yes --cleanup-tag || true + if err=$(gh release view dev 2>&1 >/dev/null); then + gh release delete dev --yes --cleanup-tag + elif grep -qi "release not found" <<<"$err"; then + echo "No existing dev release" + else + echo "::error::could not determine whether a dev release exists: ${err}" + exit 1 + fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # --target pins the tag to the commit that was built rather than to whatever + # main points at by the time this job runs - name: Create prerelease run: | gh release create dev \ + --target "${GITHUB_SHA}" \ --title "Dev (Pre-release)" \ --prerelease \ - --notes "**Commit:** ${{ github.sha }}" \ - mpqcli-linux-amd64-glibc \ - mpqcli-linux-amd64-musl \ - mpqcli-linux-arm64-glibc \ - mpqcli-linux-arm64-musl \ - mpqcli-windows-amd64.exe + --notes "**Commit:** ${GITHUB_SHA}" \ + dist/mpqcli-linux-x86_64 \ + dist/mpqcli-linux-aarch64 \ + dist/mpqcli-windows-x86_64.exe env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -63,7 +52,7 @@ jobs: needs: prerelease_binaries runs-on: ubuntu-24.04 steps: - - name: Download Docker image artifact + - name: Download Docker image artefact uses: actions/download-artifact@v8 with: name: mpqcli-docker @@ -72,11 +61,15 @@ jobs: run: | docker load -i mpqcli-docker.tar - - name: Tag Docker image as dev - run: | - docker tag mpqcli ghcr.io/thegraydot/mpqcli:dev - - - name: Push Docker image to GitHub Container Registry + # dev only, never latest: latest tracks releases, so pointing it at a + # rolling build makes an untagged docker pull return whatever last landed + # on main + - name: Push dev tag to GitHub Container Registry + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACTOR: ${{ github.actor }} + IMAGE: ghcr.io/${{ github.repository }} run: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - docker push ghcr.io/thegraydot/mpqcli:dev + echo "$GH_TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin + docker tag mpqcli "$IMAGE:dev" + docker push "$IMAGE:dev" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 403c985..8f2496e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,9 +3,11 @@ name: Release on: workflow_call +# id-token: write obtains the OIDC token gpipe uses for cosign keyless signing permissions: contents: write packages: write + id-token: write jobs: release_binaries: @@ -13,66 +15,58 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} steps: + # Default depth. Nothing here reads git history: get_changelog reads + # CHANGELOG.md from the working tree, and gh release create uses the API. - name: Checkout code uses: actions/checkout@v6 - - name: Resolve release version + - name: Resolve release version from the tag id: version - run: echo "version=$(make get_project_version)" >> "$GITHUB_OUTPUT" - - - name: Download Linux GLIBC binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-linux-amd64-glibc - - - name: Download Linux MUSL binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-linux-amd64-musl + run: | + version="${GITHUB_REF_NAME#v}" + project_version="$(make get_version)" + if [ "$version" != "$project_version" ]; then + echo "::error::tag ${GITHUB_REF_NAME} does not match the project version ${project_version} in CMakeLists.txt" + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" - - name: Download Linux ARM64 GLIBC binary + - name: Download all build artefacts uses: actions/download-artifact@v8 with: - name: mpqcli-linux-arm64-glibc + path: dist + merge-multiple: true - - name: Download Linux ARM64 MUSL binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-linux-arm64-musl + - name: Extract release notes from CHANGELOG.md + run: make get_changelog TAG="${GITHUB_REF_NAME}" > /tmp/release-notes.md - - name: Download Windows binary - uses: actions/download-artifact@v8 + - uses: thomaslaurenson/gpipe@v1 with: - name: mpqcli-windows-amd64.exe - - - name: Extract changelog for this tag - id: changelog - run: | - { - echo "content<> "$GITHUB_OUTPUT" + cosign_sign: true + # Name every asset. A dist/mpqcli-* glob is shorter and wrong: with + # merge-multiple every artefact lands flat in dist/, so the Docker image + # tar matches too and is attached to the release as if it were a binary. - name: Release package run: | - gh release create ${{ github.ref_name }} \ - --title "${{ github.ref_name }}" \ - --notes "$CHANGELOG_CONTENT" \ - mpqcli-linux-amd64-glibc \ - mpqcli-linux-amd64-musl \ - mpqcli-linux-arm64-glibc \ - mpqcli-linux-arm64-musl \ - mpqcli-windows-amd64.exe + gh release create "${GITHUB_REF_NAME}" \ + --title "${GITHUB_REF_NAME}" \ + --notes-file /tmp/release-notes.md \ + dist/mpqcli-linux-x86_64 \ + dist/mpqcli-linux-aarch64 \ + dist/mpqcli-windows-x86_64.exe \ + install.sh install.ps1 \ + checksums.txt checksums.txt.sigstore.json env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CHANGELOG_CONTENT: ${{ steps.changelog.outputs.content }} + # Gated on the release: a registry outage then leaves a complete release with + # no image, rather than an image with no release release_docker: needs: release_binaries runs-on: ubuntu-24.04 steps: - - name: Download Docker image artifact + - name: Download Docker image artefact uses: actions/download-artifact@v8 with: name: mpqcli-docker @@ -81,13 +75,15 @@ jobs: run: | docker load -i mpqcli-docker.tar - - name: Tag Docker image with version - run: | - docker tag mpqcli ghcr.io/thegraydot/mpqcli:${{ needs.release_binaries.outputs.version }} - docker tag mpqcli ghcr.io/thegraydot/mpqcli:latest - - - name: Push Docker image to GitHub Container Registry + - name: Push to GitHub Container Registry + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACTOR: ${{ github.actor }} + IMAGE: ghcr.io/${{ github.repository }} + VERSION: ${{ needs.release_binaries.outputs.version }} run: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - docker push ghcr.io/thegraydot/mpqcli:${{ needs.release_binaries.outputs.version }} - docker push ghcr.io/thegraydot/mpqcli:latest + echo "$GH_TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin + docker tag mpqcli "$IMAGE:$VERSION" + docker tag mpqcli "$IMAGE:latest" + docker push "$IMAGE:$VERSION" + docker push "$IMAGE:latest" diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 721c58f..d9ad926 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -6,22 +6,21 @@ on: - "v*.*.*" permissions: - contents: write - packages: write + contents: read jobs: - build: - uses: ./.github/workflows/build.yml lint: uses: ./.github/workflows/lint.yml - needs: build test: uses: ./.github/workflows/test.yml - needs: build + build: + uses: ./.github/workflows/build.yml + with: + artifacts: true release: uses: ./.github/workflows/release.yml - needs: - - build - - lint - - test - secrets: inherit + needs: [build, lint, test] + permissions: + contents: write + packages: write + id-token: write diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ea49e8b..6d358f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,46 +8,51 @@ permissions: jobs: test_linux: + name: test_linux (${{ matrix.compiler }}, ${{ matrix.build_type }}, ${{ matrix.arch }}) strategy: + # Report every entry independently. Cancelling one on another's failure + # hides half the findings on exactly the runs where they matter. + fail-fast: false matrix: include: - # AMD64 tests on ubuntu-24.04 - - arch: amd64 - libc: glibc + - compiler: gcc + cxx: g++ + build_type: Debug + arch: amd64 runner: ubuntu-24.04 - - arch: amd64 - libc: musl + # Release paired with one compiler rather than a third amd64 job: NDEBUG + # and the optimiser are what ships, and nothing else exercises them + - compiler: clang + cxx: clang++-18 + build_type: Release + arch: amd64 runner: ubuntu-24.04 - # ARM64 tests on ubuntu-24.04-arm - - arch: arm64 - libc: glibc - runner: ubuntu-24.04-arm - - arch: arm64 - libc: musl + # build.yml ships an arm64 binary, so arm64 is tested natively as well + - compiler: gcc + cxx: g++ + build_type: Release + arch: arm64 runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} + # BUILD_TYPE goes in the environment, not on the configure line: build re-runs + # configure through its prerequisite chain, and an invocation without it would + # reset the cache to the Debug default, so the Release entries would quietly + # test Debug and report green + env: + BUILD_TYPE: ${{ matrix.build_type }} steps: - # Only need repo content to install test/requirements.txt - name: Check out repository code uses: actions/checkout@v6 with: - submodules: false - - - name: Download Linux binary (${{ matrix.arch }} ${{ matrix.libc }}) - uses: actions/download-artifact@v8 - with: - name: mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }} - path: build/bin + submodules: true - - name: Rename binary to "mpqcli" - run: mv build/bin/mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }} build/bin/mpqcli + - name: Install clang + if: matrix.compiler == 'clang' + run: sudo apt-get install -y clang-18 - - name: Make binary executable - run: chmod +x ./build/bin/mpqcli - - - name: Check mpqcli version (${{ matrix.arch }} ${{ matrix.libc }}) - run: ./build/bin/mpqcli version + - run: make configure CMAKE_ARGS="-DCMAKE_CXX_COMPILER=${{ matrix.cxx }} -DMPQCLI_WERROR=ON" + - run: make build - name: Setup python uses: actions/setup-python@v6 @@ -59,30 +64,25 @@ jobs: python -m pip install --upgrade pip pip install -r test/requirements.txt - - name: Run tests (${{ matrix.arch }} ${{ matrix.libc }}) - run: | - python3 -m pytest test + - name: Run tests + run: python3 -m pytest test test_windows: runs-on: windows-2022 steps: - # Only need repo content to install test/requirements.txt - name: Check out repository code uses: actions/checkout@v6 with: - submodules: false + submodules: true - - name: Download Windows binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-windows-amd64.exe - path: build/bin/Release - - - name: Rename binary to "mpqcli" - run: mv build/bin/Release/mpqcli-windows-amd64.exe build/bin/mpqcli.exe - - - name: Check mpqcli version - run: ./build/bin/mpqcli.exe version + # Raw cmake: the Makefile needs a POSIX shell. MPQCLI_WERROR stays off here + # until the tree is known to compile clean under /W4, which nothing has yet + # checked; it is on in the Linux jobs. + - name: Configure and build + shell: bash + run: | + cmake -B build/dev -G "Visual Studio 17 2022" -A x64 -DMPQCLI_BUILD_APP=ON + cmake --build build/dev --config Release --parallel - name: Setup python uses: actions/setup-python@v6 @@ -95,5 +95,4 @@ jobs: pip install -r test/requirements.txt - name: Run tests - run: | - python -m pytest test + run: python -m pytest test diff --git a/.gitignore b/.gitignore index fe4e3bc..319317a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # CUSTOM /book -/build_lint /mermaid*.js /test/data @@ -41,7 +40,7 @@ /build # PYTHON -# Byte-compiled / optimized / DLL files +# Byte-compiled / optimised / DLL files __pycache__/ *.py[cod] *$py.class diff --git a/.gpipe.yml b/.gpipe.yml new file mode 100644 index 0000000..9acbe48 --- /dev/null +++ b/.gpipe.yml @@ -0,0 +1,12 @@ +binary: mpqcli + +platforms: + linux_amd64: + path: ./dist/mpqcli-linux-x86_64 + name: mpqcli-linux-x86_64 + linux_arm64: + path: ./dist/mpqcli-linux-aarch64 + name: mpqcli-linux-aarch64 + windows_amd64: + path: ./dist/mpqcli-windows-x86_64.exe + name: mpqcli-windows-x86_64.exe diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd9853..6bef9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 0.12.0 - 2026-10-04 + +### Added + +- Stop cleanly on Ctrl-C, leaving no partial archive behind + +### Changed + +- Ship one static musl Linux binary per architecture, with the release assets renamed by OS and architecture +- Publish install scripts and a signed checksums file with each release +- Run the Docker image in /data so mounted paths need no prefix +- Print progress and warnings on stderr, leaving stdout for results alone +- Mark an overwritten file as changed rather than added when adding + +### Fixed + +- Fail with a non-zero exit code on an unknown subcommand, a missing argument or an internal error +- Fail with a non-zero exit code when closing, signing, listing or printing a signature fails +- Complete every locale, game profile and archive path in the zsh, fish and PowerShell scripts + +### Removed + +- Remove the install scripts from the repository; each release now carries its own + ## 0.11.0 - 2026-09-21 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 078ed20..4d1091d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,33 +1,51 @@ cmake_minimum_required(VERSION 3.25) -project(MPQCLI VERSION 0.11.0) +project(mpqcli VERSION 0.12.0) -# Options -option(BUILD_MPQCLI "Build the mpqcli CLI app" ON) -option(BUILD_STATIC "Build static binary" OFF) +# An option() name is global to the CMake cache, including a consumer's, so these +# carry the project prefix; a bare BUILD_STATIC claims a name anyone might want. +option(MPQCLI_BUILD_APP "Build the mpqcli binary" ON) +option(MPQCLI_BUILD_STATIC "Build static binary" OFF) +# Off by default, so a new compiler release adding a warning cannot stop a +# developer working. The Linux CI test builds turn it on, and check_lint holds +# the same bar: clang-tidy replays these flags and .clang-tidy treats every +# warning as an error. +option(MPQCLI_WERROR "Treat compiler warnings as errors" OFF) -# Set project defaults set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) if(NOT CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE Release) + set(CMAKE_BUILD_TYPE Debug) endif() set(CMAKE_POSITION_INDEPENDENT_CODE ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/bin") -# Static linking configuration -if(BUILD_STATIC) +include("${PROJECT_SOURCE_DIR}/cmake/mark_system.cmake") + +# Multi-config generators (Visual Studio, Xcode) append the config name to the +# output directory, so the bare variable above would put the binary in +# bin/Release/ rather than bin/. Set all four configs, not just RELEASE, so a +# Debug build in an IDE lands in the same place. +foreach(cfg IN ITEMS DEBUG RELEASE RELWITHDEBINFO MINSIZEREL) + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_${cfg} "${PROJECT_BINARY_DIR}/bin") +endforeach() + +if(MSVC) + add_compile_definitions(__STORMLIB_NO_STATIC_LINK__) +endif() + +if(MPQCLI_BUILD_STATIC) set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -static") set(CMAKE_FIND_LIBRARY_SUFFIXES ".a") set(BUILD_SHARED_LIBS OFF) endif() -# Determine git commit hash execute_process( COMMAND git rev-parse HEAD - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_VARIABLE GIT_COMMIT_HASH RESULT_VARIABLE GIT_COMMIT_RESULT @@ -37,8 +55,7 @@ if(NOT GIT_COMMIT_RESULT EQUAL 0 OR NOT GIT_COMMIT_HASH) set(GIT_COMMIT_HASH "unknown") endif() -# Handle StormLib dependency -if (NOT EXISTS "${CMAKE_SOURCE_DIR}/extern/StormLib/CMakeLists.txt") +if (NOT EXISTS "${PROJECT_SOURCE_DIR}/extern/StormLib/CMakeLists.txt") message(FATAL_ERROR "Missing dependency: StormLib mpqcli requires the StormLib library. @@ -48,45 +65,52 @@ Did you forget to execute the following commands? git submodule update") endif() -# Use StormLib's bundled zlib/bzip2/libtommath sources instead of requiring -# system packages. This keeps mpqcli buildable everywhere (musl/glibc Docker -# images, MSVC, plain `cmake -B build`) without extra system dependencies. +# Use StormLib's bundled zlib, bzip2 and libtommath sources rather than system +# packages, so the musl Docker image, MSVC and a plain cmake -B build/dev all +# build with nothing installed beyond a compiler. set(STORM_USE_BUNDLED_LIBRARIES ON CACHE BOOL "" FORCE) add_subdirectory(extern/StormLib) +mark_system(storm) -# Handle CLI11 dependency -if(BUILD_MPQCLI) - if (NOT EXISTS "${CMAKE_SOURCE_DIR}/extern/CLI11/CMakeLists.txt") +# Only app/ parses arguments, so the core never sees CLI11 +if(MPQCLI_BUILD_APP) + if (NOT EXISTS "${PROJECT_SOURCE_DIR}/extern/CLI11/CMakeLists.txt") message(FATAL_ERROR - "Missing dependency: CLI11 - mpqcli requires the CLI11 library. - It is provided as a submodule of this repository. - Did you forget to execute the following commands? - git submodule init - git submodule update") +"Missing dependency: CLI11 +mpqcli requires the CLI11 library. +It is provided as a submodule of this repository. +Did you forget to execute the following commands? + git submodule init + git submodule update") endif() add_subdirectory(extern/CLI11) +endif() - # Handle hash-library dependency (CRC32 + MD5 for the add --update checks) - if (NOT EXISTS "${CMAKE_SOURCE_DIR}/extern/hash-library/crc32.cpp") - message(FATAL_ERROR - "Missing dependency: hash-library - mpqcli requires the hash-library library. - It is provided as a submodule of this repository. - Did you forget to execute the following commands? - git submodule init - git submodule update") - endif() +# CRC32 and MD5 for the add --update checks +if (NOT EXISTS "${PROJECT_SOURCE_DIR}/extern/hash-library/crc32.cpp") + message(FATAL_ERROR +"Missing dependency: hash-library +mpqcli requires the hash-library library. +It is provided as a submodule of this repository. +Did you forget to execute the following commands? + git submodule init + git submodule update") +endif() + +# hash-library ships no CMakeLists; compile only the two algorithms we use +add_library(mpqcli_hash_library STATIC + extern/hash-library/crc32.cpp + extern/hash-library/md5.cpp +) +target_include_directories(mpqcli_hash_library SYSTEM INTERFACE "${PROJECT_SOURCE_DIR}/extern") +add_library(mpqcli::hash_library ALIAS mpqcli_hash_library) - # hash-library ships no CMakeLists; compile only the two algorithms we use - add_library(hash-library STATIC - extern/hash-library/crc32.cpp - extern/hash-library/md5.cpp - ) - target_include_directories(hash-library SYSTEM INTERFACE "${CMAKE_SOURCE_DIR}/extern") +# Unconditional: building the core without the app is what proves nothing in +# src/ has picked up a dependency on app/. +add_subdirectory(src) - # Add the main application - add_subdirectory(src) +if(MPQCLI_BUILD_APP) + add_subdirectory(app) endif() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8c5810..b24cc96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,65 +2,65 @@ Contributions are welcome. Please read the guidelines below before opening a pull request. -## Before You Start +## Before you start If you are unsure whether a feature fits the project, or whether an existing tool could already be combined with `mpqcli` to achieve the same result, open an issue first. This avoids wasted effort and keeps the project focused. **mpqcli follows the Unix philosophy.** The tool is designed to do one thing well and to compose with other tools via pipes and redirection. If you find yourself wanting to add functionality that could be handled by a separate tool - for example, sorting the output of `list` - the right answer is usually to pipe the output to that tool rather than adding it here. -## Prerequisites and Setup +## Prerequisites and setup Clone the repository and initialise submodules: -``` +```sh git clone https://github.com/thegraydot/mpqcli.git cd mpqcli git submodule update --init --recursive ``` -Install the clang lint tools: +Install the clang lint tools, pinned to the major version the checks are formatted against: -``` -make install_clang_tools +```sh +sudo apt-get install -y clang-18 clang-format-18 clang-tidy-18 ``` -## Makefile Reference +## Makefile reference Run `make help` to list all available targets. Common ones: -| Target | Description | -|----------------------------|--------------------------------------------------------------------| -| `make install_clang_tools` | Install clang-format and clang-tidy via apt | -| `make configure` | Configure cmake build with clang (required before `make lint`) | -| `make build_linux` | Build for Linux using cmake | -| `make build_windows` | Build for Windows using cmake | -| `make build_clean` | Remove the cmake build directory | -| `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | -| `make test_mpqcli` | Run the pytest test suite | -| `make lint` | Run all C++ linters (clang-format + clang-tidy) | -| `make fmt_check` | Check formatting only (dry run) | -| `make fmt` | Auto-fix formatting in-place | -| `make lint_cpp` | Run clang-tidy static analysis | -| `make clean` | Remove all build and test artifacts | - -## Requirements for a Pull Request +| Target | Description | +|---|---| +| `make configure` | Configure the cmake build (uses the default compiler) | +| `make build` | Build the project into build/dev | +| `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | +| `make test_mpqcli` | Run the pytest test suite | +| `make check_all` | Run every static check (clang-format, clang-tidy, completion scripts) | +| `make check_format` | Check formatting only (dry run) | +| `make format` | Auto-fix formatting in-place | +| `make configure_lint` | Configure build/lint with clang++ for clang-tidy | +| `make check_lint` | Run clang-tidy static analysis | +| `make check_embed` | Syntax-check the embedded completion scripts | +| `make clean` | Remove all build, test and docs artefacts | + +## Requirements for a pull request ### 1. Builds on your platform Make sure the project builds cleanly on your development machine before opening a PR: +```sh +make build ``` -make build_linux # Linux -make build_windows # Windows -``` -A PR automatically triggers the CI build workflow, which compiles and tests across all supported Linux targets (AMD64 and ARM64, glibc and musl). You are not expected to reproduce all of those locally. +On Windows without a POSIX shell, use the cmake commands from the building page instead. + +A PR automatically triggers the CI build workflow, which compiles and tests across all supported Linux targets (AMD64 and ARM64). You are not expected to reproduce all of those locally. ### 2. Tests pass Run the test suite before submitting: -``` +```sh make test_create_venv # first-time setup only make test_mpqcli ``` @@ -73,20 +73,19 @@ If your change adds or modifies user-facing functionality - such as a new subcom ### 4. Linting must pass -All C++ code is formatted with clang-format and analysed with clang-tidy. `clang-tidy` needs a compile database generated with clang, so run `make configure` first (`make build_linux`/`make build_windows` alone will not work, since they don't set up the compiler flags clang-tidy needs): +All C++ code is formatted with clang-format and analysed with clang-tidy, and the completion scripts are parsed by their shells. `make check_all` runs all three checks. It configures a clang build tree of its own for clang-tidy, so it needs no prior `make configure`: -``` -make configure -make lint +```sh +make check_all ``` If there are formatting violations, auto-fix them with: -``` -make fmt +```sh +make format ``` -Then re-run `make lint` to confirm everything passes. +Then re-run `make check_all` to confirm everything passes. ### 5. Match the existing code style @@ -115,25 +114,25 @@ if (flags & MPQ_FILE_COMPRESS) result += 'c'; // clang-format on ``` -## Known Design Constraints +## Known design constraints ### StormLib locale state is global and not thread-safe -`SFileSetLocale` sets a process-wide locale variable (`g_lcFileLocale`) inside StormLib. All locale-sensitive operations in `mpq.cpp` - file open, add, remove, read, extract, and list - call `SFileSetLocale` immediately before the relevant StormLib call. There is no locale-explicit alternative in StormLib's public API (`SFileOpenFileEx`, `SFileAddFileEx`, etc. all read `g_lcFileLocale` internally). +`SFileSetLocale` sets a process-wide locale variable (`g_lcFileLocale`) inside StormLib. All locale-sensitive operations in `src/mpq/` - file open, add, remove, read, extract, and list - call `SFileSetLocale` immediately before the relevant StormLib call. There is no locale-explicit alternative in StormLib's public API (`SFileOpenFileEx`, `SFileAddFileEx`, etc. all read `g_lcFileLocale` internally). This means: - The `SFileSetLocale` + StormLib-call sequence is **not atomic** and would be unsafe under concurrency -- mpqcli is intentionally **single-threaded**; do not introduce threads or async I/O without auditing every locale-sensitive call site in `mpq.cpp` +- mpqcli is intentionally **single-threaded**; do not introduce threads or async I/O without auditing every locale-sensitive call site in `src/mpq/` If you add a new StormLib call that is locale-sensitive, follow the existing pattern: call `SFileSetLocale` immediately before it, with no intervening calls between the two. -## Workflow Summary +## Workflow summary 1. Fork the repository and create a branch for your change 2. Run `git submodule update --init --recursive` after cloning -3. Run `make install_clang_tools` to install lint dependencies -4. Make your changes and verify they build: `make build_linux` -5. Run `make configure` and then `make lint`, fixing any issues +3. Install the clang tools as shown above +4. Make your changes and verify they build: `make build` +5. Run `make check_all`, fixing any issues 6. Run `make test_mpqcli` and confirm all tests pass 7. Open a pull request with a clear description of what was changed and why diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1cef2dc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Stage 1: Build +FROM alpine:3.23 AS build + +RUN apk add --no-cache \ + # The C and C++ toolchain, with the musl-dev the static link needs + build-base \ + # Configures and drives the build + cmake \ + # Stamps the commit hash into the version header; see .dockerignore + git + +WORKDIR /src +COPY . . + +# MPQCLI_BUILD_STATIC is what makes the scratch stage viable: the binary carries +# musl and libstdc++ with it and needs no loader at runtime +RUN cmake -B build/release \ + -DCMAKE_BUILD_TYPE=Release \ + -DMPQCLI_BUILD_APP=ON \ + -DMPQCLI_BUILD_STATIC=ON \ + && cmake --build build/release --parallel "$(nproc)" \ + && strip build/release/bin/mpqcli + +# Stage 2: Runtime +FROM scratch + +LABEL org.opencontainers.image.source="https://github.com/thegraydot/mpqcli" +LABEL org.opencontainers.image.description="A command-line tool to create, add, remove, list, extract, read, rename, and verify MPQ archives using the StormLib library" +LABEL org.opencontainers.image.licenses="MIT" + +COPY --from=build /src/build/release/bin/mpqcli /mpqcli + +# No USER, deliberately. The image exists to process files on a bind mount, and +# no fixed uid can match whoever owns the host directory, so a non-root default +# would fail every write for most Linux users. Callers who want output owned by +# themselves pass --user "$(id -u):$(id -g)", which the installation docs show. +WORKDIR /data +ENTRYPOINT ["/mpqcli"] diff --git a/Dockerfile.glibc b/Dockerfile.glibc deleted file mode 100644 index 8310658..0000000 --- a/Dockerfile.glibc +++ /dev/null @@ -1,30 +0,0 @@ -# Stage 1: Build -FROM ubuntu:24.04 AS builder - -RUN apt-get update && apt-get install -y \ - build-essential \ - cmake \ - git \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /mpqcli - -COPY . . - -RUN cmake -B build \ - -DCMAKE_BUILD_TYPE=Release \ - -DBUILD_MPQCLI=ON \ - -DBUILD_STATIC=ON - -RUN cmake --build build - -RUN strip build/bin/mpqcli - -# Stage 2: Create a minimal runtime image -FROM ubuntu:24.04 AS runtime - -COPY --from=builder /mpqcli/build/bin/mpqcli /usr/local/bin/mpqcli - -RUN chmod +x /usr/local/bin/mpqcli - -ENTRYPOINT ["mpqcli"] diff --git a/Dockerfile.musl b/Dockerfile.musl deleted file mode 100644 index 012213d..0000000 --- a/Dockerfile.musl +++ /dev/null @@ -1,27 +0,0 @@ -# Stage 1: Build -FROM alpine:3.23 AS builder - -RUN apk add --no-cache \ - build-base \ - cmake \ - git - -WORKDIR /mpqcli - -COPY . . - -RUN cmake -B build \ - -DCMAKE_BUILD_TYPE=Release \ - -DBUILD_MPQCLI=ON \ - -DBUILD_STATIC=ON - -RUN cmake --build build - -RUN strip build/bin/mpqcli - -# Stage 2: Create a minimal runtime image -FROM scratch AS runtime - -COPY --from=builder /mpqcli/build/bin/mpqcli /mpqcli - -ENTRYPOINT ["/mpqcli"] diff --git a/Makefile b/Makefile index 7140c1c..5336ff3 100644 --- a/Makefile +++ b/Makefile @@ -1,90 +1,67 @@ SHELL := /bin/bash -CMAKE_BUILD_TYPE := Release -BUILD_MPQCLI := ON -CLANG_VERSION := 18 -VERSION := $(shell awk '/project\(MPQCLI VERSION/ {gsub(/\)/, "", $$3); print $$3}' CMakeLists.txt) +# Project-owned C++ directories, derived rather than hardcoded so that adding +# one does not also require remembering to edit the format and lint targets +CPP_DIRS := $(wildcard src app test) +CPP_LINT_DIRS := $(wildcard src app) + +JOBS ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) +BUILD_TYPE ?= Debug +CMAKE_ARGS ?= +MPQCLI_BUILD_APP ?= ON + +# Prefer the versioned tool, fall back to the plain name. Falling back rather +# than failing keeps the error legible: "clang-format: command not found" +# beats a make-level complaint about an empty variable. +CLANG_VERSION ?= 18 +CLANG_CC ?= $(shell command -v clang-$(CLANG_VERSION) || echo clang) +CLANG_CXX ?= $(shell command -v clang++-$(CLANG_VERSION) || echo clang++) +CLANG_FORMAT ?= $(shell command -v clang-format-$(CLANG_VERSION) || echo clang-format) +CLANG_TIDY ?= $(shell command -v clang-tidy-$(CLANG_VERSION) || echo clang-tidy) + +# clang-tidy resolves headers through the compiler that produced +# compile_commands.json. Pointed at a GCC build it cannot find libstdc++ and +# emits confident diagnostics from a broken AST, so it gets its own tree. +LINT_DIR ?= build/lint + +# Empty on a machine with no GCC, and configure_lint then passes no +# --gcc-install-dir at all. The && is what keeps it empty: dirname of an empty +# string is ".", which clang would take as the install directory. +GCC_INSTALL_DIR := $(shell f=$$(gcc -print-libgcc-file-name 2>/dev/null) && dirname "$$f") + README := README.md PACKAGE_URL := https://github.com/thegraydot/mpqcli/pkgs/container/mpqcli -GCC_INSTALL_DIR := $(shell dirname "$(shell gcc -print-libgcc-file-name)") -TAG ?= $(shell git describe --tags --abbrev=0 2>/dev/null) + +# The version is declared once, in project(... VERSION X.Y.Z). The +# cmake_minimum_required line also says VERSION and comes first, so it is skipped. +VERSION_AWK := /cmake_minimum_required/ { next } \ + match($$0, /VERSION[ \t]+[0-9]+\.[0-9]+\.[0-9]+/) { \ + v = substr($$0, RSTART, RLENGTH); sub(/VERSION[ \t]+/, "", v); \ + print v; found = 1; exit } \ + END { if (!found) exit 1 } +VERSION = $(shell awk '$(VERSION_AWK)' CMakeLists.txt) .PHONY: help help: ## Show this help message - @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf " %-22s %s\n", $$1, $$2}' + @awk 'BEGIN {FS = ":.*?## "} /^##@ / {printf "\n%s\n", substr($$0, 5)} \ + /^[a-zA-Z_-]+:.*## / {printf " %-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST) -# BUILD -.PHONY: install_clang_tools -install_clang_tools: ## Install clang lint dependencies - sudo apt-get install -y clang-format-$(CLANG_VERSION) clang-tidy-$(CLANG_VERSION) +##@ BUILD .PHONY: configure -configure: ## Configure cmake build (debug, with compile_commands.json) - cmake -B build \ - -DCMAKE_BUILD_TYPE=Debug \ +configure: ## Configure the cmake build + cmake -B build/dev \ + -DCMAKE_BUILD_TYPE=$(BUILD_TYPE) \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ - -DBUILD_MPQCLI=$(BUILD_MPQCLI) \ - -DCMAKE_CXX_COMPILER=clang++-$(CLANG_VERSION) \ - -DCMAKE_CXX_FLAGS="--gcc-install-dir=$(GCC_INSTALL_DIR)" + -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) \ + $(CMAKE_ARGS) .PHONY: build -build: ## Build via cmake - cmake --build build - -.PHONY: build_linux -build_linux: ## Build for Linux using cmake - cmake -B build \ - -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ - -DBUILD_MPQCLI=$(BUILD_MPQCLI) - cmake --build build - -.PHONY: build_windows -build_windows: ## Build for Windows using cmake - cmake -B build \ - -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ - -DBUILD_MPQCLI=$(BUILD_MPQCLI) - cmake --build build --config $(CMAKE_BUILD_TYPE) - -.PHONY: build_clean -build_clean: ## Remove cmake build directory - rm -rf build - -# DOCKER -.PHONY: docker_musl_build -docker_musl_build: ## Build Docker image using musl - docker build -t mpqcli:$(VERSION) -f Dockerfile.musl . - -.PHONY: docker_musl_run -docker_musl_run: ## Run the musl Docker image - @docker run -it mpqcli:$(VERSION) version +build: configure ## Build the project + cmake --build build/dev --parallel $(JOBS) -.PHONY: docker_glibc_build -docker_glibc_build: ## Build Docker image using glibc - docker build -t mpqcli:$(VERSION) -f Dockerfile.glibc . +##@ TEST -.PHONY: docker_glibc_run -docker_glibc_run: ## Run the glibc Docker image - @docker run -it mpqcli:$(VERSION) version - -# DOCS -.PHONY: docs_mermaid -docs_mermaid: ## Fetch mermaid.min.js and mermaid-init.js into the repo root (gitignored) - mdbook-mermaid install . - -.PHONY: docs_build -docs_build: docs_mermaid ## Build the documentation site into book/ - mdbook build - -.PHONY: docs_serve -docs_serve: docs_mermaid ## Serve the docs locally with live reload - mdbook serve --open - -.PHONY: docs_clean -docs_clean: ## Remove the generated docs site and mermaid assets - rm -rf book mermaid.min.js mermaid-init.js - -# TEST .PHONY: test test: build test_mpqcli ## Run test suite (builds binary first) @@ -108,35 +85,76 @@ test_lint: ## Run ruff linter on test directory . ./.venv/bin/activate && \ ruff check ./test -# LINT -.PHONY: fmt_check -fmt_check: ## Check C++ formatting with clang-format - find src \( -name "*.cpp" -o -name "*.h" \) \ - | xargs clang-format-$(CLANG_VERSION) --dry-run --Werror - -.PHONY: fmt -fmt: ## Auto-fix C++ formatting with clang-format - find src \( -name "*.cpp" -o -name "*.h" \) \ - | xargs clang-format-$(CLANG_VERSION) -i - -.PHONY: lint_cpp -lint_cpp: ## Run clang-tidy static analysis (requires: make configure) - clang-tidy-$(CLANG_VERSION) --quiet -p build \ - --header-filter="$(CURDIR)/src/.*" src/*.cpp 2>&1 \ +##@ LINT + +.PHONY: format +format: ## Format all source files with clang-format + find $(CPP_DIRS) \( -name "*.cpp" -o -name "*.h" \) \ + | xargs $(CLANG_FORMAT) -i + +.PHONY: check_format +check_format: ## Check formatting without modifying files + find $(CPP_DIRS) \( -name "*.cpp" -o -name "*.h" \) \ + | xargs $(CLANG_FORMAT) --dry-run --Werror + +.PHONY: configure_lint +configure_lint: ## Configure $(LINT_DIR) with clang, so clang-tidy can parse the sources + cmake -B $(LINT_DIR) \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DCMAKE_C_COMPILER=$(CLANG_CC) \ + -DCMAKE_CXX_COMPILER=$(CLANG_CXX) \ + $(if $(GCC_INSTALL_DIR),-DCMAKE_CXX_FLAGS="--gcc-install-dir=$(GCC_INSTALL_DIR)") \ + $(CMAKE_ARGS) + +.PHONY: check_lint +check_lint: configure_lint ## Run clang-tidy static analysis + $(CLANG_TIDY) --quiet -p $(LINT_DIR) \ + --header-filter="$(CURDIR)/(src|app)/.*" $$(find $(CPP_LINT_DIRS) -name "*.cpp") 2>&1 \ | grep -v " warnings generated"; \ exit $${PIPESTATUS[0]} -.PHONY: lint -lint: fmt_check lint_cpp ## Run all C++ linters +.PHONY: check_embed +check_embed: ## Syntax-check the embedded completion scripts with every shell present + bash -n completion/mpqcli.bash + @if command -v zsh >/dev/null; then zsh -n completion/mpqcli.zsh; \ + else echo "[*] zsh not installed, skipped"; fi + @if command -v fish >/dev/null; then fish --no-execute completion/mpqcli.fish; \ + else echo "[*] fish not installed, skipped"; fi + @if command -v pwsh >/dev/null; then pwsh -NoProfile -Command \ + '[scriptblock]::Create((Get-Content -Raw completion/mpqcli.ps1)) | Out-Null'; \ + else echo "[*] pwsh not installed, skipped"; fi -.PHONY: ci -ci: configure build fmt_check lint_cpp test ## Run all CI checks locally +##@ DOCS -# CLEAN -.PHONY: clean -clean: build_clean test_clean docs_clean ## Remove all build, test, and docs artifacts +.PHONY: docs_mermaid +docs_mermaid: ## Fetch mermaid.min.js and mermaid-init.js into the repo root (gitignored) + mdbook-mermaid install . + +.PHONY: docs_build +docs_build: docs_mermaid ## Build the documentation site into book/ + mdbook build + +.PHONY: docs_serve +docs_serve: docs_mermaid ## Serve the docs locally with live reload + mdbook serve --open + +.PHONY: docs_clean +docs_clean: ## Remove the generated docs site and mermaid assets + rm -rf book mermaid.min.js mermaid-init.js + +##@ DOCKER + +.PHONY: docker_build +docker_build: ## Build the Docker image, tagged with the project version + docker build -t mpqcli:$(VERSION) . + +.PHONY: docker_run +docker_run: ## Run the Docker image + @docker run -it mpqcli:$(VERSION) version + +##@ GENERATE -# GENERATE # The docs site builds from the committed copy, so this is run deliberately and the # result committed rather than being made a prerequisite of docs_build .PHONY: gen_docs_changelog @@ -144,29 +162,8 @@ gen_docs_changelog: ## Copy CHANGELOG.md into the docs site @cp CHANGELOG.md docs/changelog.md @echo "[*] Updated docs/changelog.md" -# GET -.PHONY: get_project_version -get_project_version: ## Print the project version from CMakeLists.txt - @grep -oE 'VERSION [0-9]+\.[0-9]+\.[0-9]+' CMakeLists.txt | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' - -.PHONY: get_changelog -get_changelog: ## Print release notes for TAG to stdout (default: latest tag; override with TAG=v1.0.0) - @if [ -z "$(TAG)" ]; then \ - echo "Error: no tag resolved. Create a git tag or pass TAG=v1.0.0" >&2; \ - exit 1; \ - fi - @notes=$$(awk -v tag="$(TAG)" \ - '/^## /{if(found)exit; if(index($$0,"## "tag" ")==1 || $$0=="## "tag)found=1; next} found{print}' \ - CHANGELOG.md); \ - if [ -z "$$notes" ]; then \ - echo "Error: no CHANGELOG entry found for $(TAG)" >&2; \ - exit 1; \ - fi; \ - echo "$$notes" - -# RELEASE -.PHONY: fetch_downloads -fetch_downloads: ## Fetch package downloads and update README.md badge +.PHONY: gen_downloads +gen_downloads: ## Update the package downloads badge in README.md from GitHub @DOWNLOADS=$$(curl -s "$(PACKAGE_URL)" \ | grep -A2 "Total downloads" \ | grep -o '

[0-9]*

' \ @@ -175,3 +172,42 @@ fetch_downloads: ## Fetch package downloads and update README.md badge | head -1); \ sed -i "s/package_downloads-[0-9]*-green/package_downloads-$$DOWNLOADS-green/" $(README); \ echo "[*] Updated package downloads badge: $$DOWNLOADS" + +##@ GET + +.PHONY: get_version +get_version: ## Print the project version from CMakeLists.txt (fails if absent) + @awk '$(VERSION_AWK)' CMakeLists.txt + +.PHONY: get_changelog +get_changelog: ## Print release notes for TAG to stdout (TAG=v1.0.0) + @tag="$(TAG)"; tag="$${tag#v}"; \ + if [[ -z "$$tag" ]]; then \ + printf 'get_changelog: TAG is empty; pass TAG=v1.0.0\n' >&2; \ + exit 1; \ + fi; \ + notes="$$(awk -v tag="$$tag" ' \ + /^## / { if (found) exit; if (index($$0,"## "tag" ")==1 || $$0=="## "tag) found=1; next } \ + found { lines[n++]=$$0 } \ + END { \ + s=0; while (s=s && lines[e]~/^[[:space:]]*$$/) e--; \ + for (i=s;i<=e;i++) print lines[i] \ + }' CHANGELOG.md)"; \ + if [[ -z "$$notes" ]]; then \ + printf 'get_changelog: no CHANGELOG entry for %s\n' "$$tag" >&2; \ + exit 1; \ + fi; \ + printf '%s\n' "$$notes" + +##@ CI + +.PHONY: check_all +check_all: check_format check_lint check_embed ## Run every static check + +.PHONY: ci +ci: check_all test ## Run the checks CI runs + +.PHONY: clean +clean: test_clean docs_clean ## Remove all build, test, docs and release artefacts + rm -rf build dist install.sh install.ps1 checksums.txt checksums.txt.sigstore.json diff --git a/README.md b/README.md index fe7da85..050fbf1 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # mpqcli -![Build Status](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/tag.yml?style=flat) ![Test Status](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/tag.yml?style=flat&label=test) +![Release Build](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/tag.yml?style=flat&label=release&logo=github) ![Main Build](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/main.yml?style=flat&label=main&logo=github) -![Release Version](https://img.shields.io/github/v/release/thegraydot/mpqcli?style=flat) +![Release Version](https://img.shields.io/github/v/release/thegraydot/mpqcli?style=flat&logo=github) ![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?style=flat&label=downloads&logo=github) ![Package downloads](https://img.shields.io/badge/package_downloads-996-green?style=flat&logo=github) -![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?label=release_downloads) ![Package downloads](https://img.shields.io/badge/package_downloads-996-green) +![C++ Version](https://img.shields.io/badge/Version-17-blue?style=flat&logo=cplusplus) A command-line tool to create, add, remove, list, extract, read, rename, and verify MPQ archives using the [StormLib library](https://github.com/ladislav-zezula/StormLib). -> ⚠️ **Warning:** This project is under active development and will change functionality between released versions until version 1.0.0. +> **Warning:** This project is under active development and will change functionality between released versions until version 1.0.0. ## Overview @@ -30,7 +30,7 @@ Docker images are available on [GitHub packages](https://github.com/thegraydot/m ## Quickstart ```bash -curl -fsSL https://raw.githubusercontent.com/thegraydot/mpqcli/main/scripts/install.sh | bash +curl -fsSL https://github.com/thegraydot/mpqcli/releases/latest/download/install.sh | bash ... mpqcli list ``` @@ -38,7 +38,8 @@ mpqcli list Microsoft Windows: ```powershell -irm https://raw.githubusercontent.com/thegraydot/mpqcli/main/scripts/install.ps1 | iex +Invoke-WebRequest -Uri https://github.com/thegraydot/mpqcli/releases/latest/download/install.ps1 -OutFile install.ps1 +.\install.ps1 ... mpqcli.exe list ``` diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt new file mode 100644 index 0000000..5760955 --- /dev/null +++ b/app/CMakeLists.txt @@ -0,0 +1,36 @@ +# Re-run configure when a script is edited, so the embedded copy cannot go stale +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${PROJECT_SOURCE_DIR}/completion/mpqcli.bash" + "${PROJECT_SOURCE_DIR}/completion/mpqcli.zsh" + "${PROJECT_SOURCE_DIR}/completion/mpqcli.ps1" + "${PROJECT_SOURCE_DIR}/completion/mpqcli.fish" +) +file(READ "${PROJECT_SOURCE_DIR}/completion/mpqcli.bash" BASH_COMPLETION_SCRIPT) +file(READ "${PROJECT_SOURCE_DIR}/completion/mpqcli.zsh" ZSH_COMPLETION_SCRIPT) +file(READ "${PROJECT_SOURCE_DIR}/completion/mpqcli.ps1" PS_COMPLETION_SCRIPT) +file(READ "${PROJECT_SOURCE_DIR}/completion/mpqcli.fish" FISH_COMPLETION_SCRIPT) +configure_file( + "${PROJECT_SOURCE_DIR}/cmake/completion_data.h.in" + "${PROJECT_BINARY_DIR}/include/mpqcli/completion_data.h" + @ONLY +) + +add_executable(mpqcli + main.cpp + about.cpp + add.cpp + compact.cpp + completion.cpp + create.cpp + extract.cpp + info.cpp + list.cpp + read.cpp + remove.cpp + rename.cpp + validators.cpp + verify.cpp + version.cpp +) + +target_link_libraries(mpqcli PRIVATE mpqcli::core CLI11::CLI11 mpqcli::warnings) diff --git a/app/about.cpp b/app/about.cpp new file mode 100644 index 0000000..55bb54a --- /dev/null +++ b/app/about.cpp @@ -0,0 +1,22 @@ +#include +#include + +#include + +#include "commands.h" + +void RegisterAbout(CLI::App &app, Context &context) { + auto *sub = app.add_subcommand("about", "Prints program information"); + sub->callback([&context]() { + context.out << "Name: mpqcli" << std::endl; + context.out << "Version: " << mpqcli::version_string << "-" << mpqcli::git_commit_hash + << std::endl; + context.out << "Author: Thomas Laurenson" << std::endl; + context.out << "License: MIT" << std::endl; + context.out << "GitHub: https://github.com/thegraydot/mpqcli" << std::endl; + context.out << "Dependencies:" << std::endl; + context.out << " - StormLib (https://github.com/ladislav-zezula/StormLib)" << std::endl; + context.out << " - CLI11 (https://github.com/CLIUtils/CLI11)" << std::endl; + context.out << " - hash-library (https://github.com/stbrumme/hash-library)" << std::endl; + }); +} diff --git a/app/add.cpp b/app/add.cpp new file mode 100644 index 0000000..54dabde --- /dev/null +++ b/app/add.cpp @@ -0,0 +1,60 @@ +#include "commands/add.h" + +#include +#include +#include + +#include + +#include "commands.h" +#include "gamerules/rules.h" +#include "util/path.h" +#include "validators.h" + +void RegisterAdd(CLI::App &app, Context &context) { + struct Options { + mpqcli::AddOptions add; + // Local paths, or - for the lines on stdin, settled into add.files by the callback + std::vector files; + }; + + auto options = std::make_shared(); + auto *sub = app.add_subcommand("add", "Add files to an existing MPQ archive"); + + sub->add_option("archive", options->add.archive, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("files", options->files, + "Files or directories to add; pass - to read paths from stdin") + ->required() + ->expected(-1); + sub->add_option("-p,--path", options->add.path, + "Archive path for a single file, or prefix for a directory"); + CLI::Option *overwrite = sub->add_flag("-w,--overwrite", options->add.overwrite, + "Replace every file that already exists in the archive"); + sub->add_flag("-u,--update", options->add.update, + "Replace only files that changed. Compares size, then timestamp/MD5/CRC32") + ->excludes(overwrite); + sub->add_option("--locale", options->add.locale, "Locale to use for added file") + ->check(locale_valid); + sub->add_option("-g,--game", options->add.game_profile, + "Game profile for compression rules. Valid options:\n" + + mpqcli::GameRules::GetAvailableProfiles()) + ->check(game_profile_valid); + + mpqcli::CompressionSettingsOverrides &compression = options->add.compression_overrides; + sub->add_option("--flags", compression.flags, "Override MPQ file flags") + ->group("Game setting overrides"); + sub->add_option("--compression", compression.compression, + "Override compression for first sector") + ->group("Game setting overrides"); + sub->add_option("--compression-next", compression.compression_next, + "Override compression for subsequent sectors") + ->group("Game setting overrides"); + + sub->callback([options, &context]() { + const auto files = mpqcli::ExpandStdinMarker(options->files, context.in); + options->add.files.assign(files.begin(), files.end()); + context.exit_code = mpqcli::Add(options->add, context.err, context.cancelled) ? 0 : 1; + }); +} diff --git a/app/commands.h b/app/commands.h new file mode 100644 index 0000000..c16eaf7 --- /dev/null +++ b/app/commands.h @@ -0,0 +1,33 @@ +#ifndef COMMANDS_H +#define COMMANDS_H + +#include +#include +#include + +#include + +/// What main hands every subcommand: the real streams and a slot for the exit code +struct Context { + std::ostream &out; + std::ostream &err; + std::istream ∈ + const std::atomic &cancelled; + int exit_code = 0; +}; + +void RegisterVersion(CLI::App &app, Context &context); +void RegisterAbout(CLI::App &app, Context &context); +void RegisterInfo(CLI::App &app, Context &context); +void RegisterCreate(CLI::App &app, Context &context); +void RegisterAdd(CLI::App &app, Context &context); +void RegisterRemove(CLI::App &app, Context &context); +void RegisterRename(CLI::App &app, Context &context); +void RegisterList(CLI::App &app, Context &context); +void RegisterExtract(CLI::App &app, Context &context); +void RegisterRead(CLI::App &app, Context &context); +void RegisterVerify(CLI::App &app, Context &context); +void RegisterCompact(CLI::App &app, Context &context); +void RegisterCompletion(CLI::App &app, Context &context); + +#endif diff --git a/app/compact.cpp b/app/compact.cpp new file mode 100644 index 0000000..6f516ec --- /dev/null +++ b/app/compact.cpp @@ -0,0 +1,20 @@ +#include "commands/compact.h" + +#include + +#include + +#include "commands.h" + +void RegisterCompact(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("compact", "Compact the MPQ archive"); + + sub->add_option("target", options->target, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive") + ->check(CLI::ExistingFile); + + sub->callback([options, &context]() { mpqcli::Compact(*options, context.err); }); +} diff --git a/app/completion.cpp b/app/completion.cpp new file mode 100644 index 0000000..996be57 --- /dev/null +++ b/app/completion.cpp @@ -0,0 +1,21 @@ +#include + +#include + +#include "commands.h" + +void RegisterCompletion(CLI::App &app, Context &context) { + auto *sub = app.add_subcommand("completion", "Generate shell completion script"); + sub->require_subcommand(1); + sub->add_subcommand("bash", "Generate bash completion script")->callback([&context]() { + context.out << mpqcli::bash_completion_script; + }); + sub->add_subcommand("zsh", "Generate zsh completion script")->callback([&context]() { + context.out << mpqcli::zsh_completion_script; + }); + sub->add_subcommand("powershell", "Generate PowerShell completion script") + ->callback([&context]() { context.out << mpqcli::ps_completion_script; }); + sub->add_subcommand("fish", "Generate fish completion script")->callback([&context]() { + context.out << mpqcli::fish_completion_script; + }); +} diff --git a/app/create.cpp b/app/create.cpp new file mode 100644 index 0000000..cb8f4ce --- /dev/null +++ b/app/create.cpp @@ -0,0 +1,75 @@ +#include "commands/create.h" + +#include +#include +#include + +#include +#include + +#include "commands.h" +#include "gamerules/rules.h" +#include "validators.h" + +void RegisterCreate(CLI::App &app, Context &context) { + struct Options { + mpqcli::CreateOptions create; + // The documented numbering is 1 to 4; StormLib counts the same formats from 0 + std::optional version; + }; + + auto options = std::make_shared(); + auto *sub = app.add_subcommand("create", "Create an MPQ archive from target file or directory"); + + sub->add_option("target", options->create.target, "Directory or file to put in MPQ archive") + ->required() + ->check(CLI::ExistingPath); + sub->add_option("-p,--path", options->create.path, + "Archive path for a single file, or prefix for a directory"); + sub->add_option("-o,--output", options->create.output, "Output MPQ archive"); + sub->add_flag("-s,--sign", options->create.sign, "Sign the MPQ archive (default false)"); + sub->add_option("--locale", options->create.locale, "Locale to use for added files") + ->check(locale_valid); + sub->add_option("-g,--game", options->create.game_profile, + "Game profile for MPQ creation. Valid options:\n" + + mpqcli::GameRules::GetAvailableProfiles()) + ->check(game_profile_valid); + + mpqcli::MpqCreateSettingsOverrides &create = options->create.create_overrides; + sub->add_option("--version", options->version, "Override the MPQ archive version") + ->check(CLI::Range(1, 4)) + ->group("Game setting overrides"); + sub->add_option("--stream-flags", create.stream_flags, "Override stream flags") + ->group("Game setting overrides"); + sub->add_option("--sector-size", create.sector_size, "Override sector size") + ->group("Game setting overrides"); + sub->add_option("--raw-chunk-size", create.raw_chunk_size, "Override raw chunk size for MPQ v4") + ->group("Game setting overrides"); + sub->add_option("--file-flags1", create.file_flags1, "Override file flags for (listfile)") + ->group("Game setting overrides"); + sub->add_option("--file-flags2", create.file_flags2, "Override file flags for (attributes)") + ->group("Game setting overrides"); + sub->add_option("--file-flags3", create.file_flags3, "Override file flags for (signature)") + ->group("Game setting overrides"); + sub->add_option("--attr-flags", create.attr_flags, + "Override attribute flags (CRC32, FILETIME, MD5)") + ->group("Game setting overrides"); + + mpqcli::CompressionSettingsOverrides &compression = options->create.compression_overrides; + sub->add_option("--flags", compression.flags, "Override MPQ file flags for added files") + ->group("Game setting overrides"); + sub->add_option("--compression", compression.compression, + "Override compression for first sector of added files") + ->group("Game setting overrides"); + sub->add_option("--compression-next", compression.compression_next, + "Override compression for subsequent sectors of added files") + ->group("Game setting overrides"); + + sub->callback([options, &context]() { + if (options->version.has_value()) { + options->create.create_overrides.mpq_version = + static_cast(options->version.value() - 1); + } + context.exit_code = mpqcli::Create(options->create, context.err, context.cancelled) ? 0 : 1; + }); +} diff --git a/app/extract.cpp b/app/extract.cpp new file mode 100644 index 0000000..ad954cb --- /dev/null +++ b/app/extract.cpp @@ -0,0 +1,27 @@ +#include "commands/extract.h" + +#include + +#include + +#include "commands.h" + +void RegisterExtract(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("extract", "Extract files from the MPQ archive"); + + sub->add_option("target", options->target, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("-o,--output", options->output, "Output directory"); + sub->add_option("-f,--file", options->file, "Target file to extract"); + sub->add_flag("-k,--keep", options->keep_folder_structure, + "Keep folder structure (default false)"); + sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive") + ->check(CLI::ExistingFile); + sub->add_option("--locale", options->locale, "Preferred locale for extracted file"); + + sub->callback([options, &context]() { + context.exit_code = mpqcli::Extract(*options, context.err, context.cancelled) ? 0 : 1; + }); +} diff --git a/app/info.cpp b/app/info.cpp new file mode 100644 index 0000000..9b014c1 --- /dev/null +++ b/app/info.cpp @@ -0,0 +1,31 @@ +#include "commands/info.h" + +#include +#include +#include + +#include + +#include "commands.h" + +namespace { + +const std::set valid_properties = { + "format-version", "header-offset", "header-size", "archive-size", + "file-count", "max-files", "signature-type", +}; + +} // namespace + +void RegisterInfo(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("info", "Prints info about an MPQ archive"); + + sub->add_option("target", options->target, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("-p,--property", options->property, "Prints only a specific property value") + ->check(CLI::IsMember(valid_properties)); + + sub->callback([options, &context]() { mpqcli::Info(*options, context.out); }); +} diff --git a/app/list.cpp b/app/list.cpp new file mode 100644 index 0000000..ad2e2f1 --- /dev/null +++ b/app/list.cpp @@ -0,0 +1,37 @@ +#include "commands/list.h" + +#include +#include +#include + +#include + +#include "commands.h" + +namespace { + +const std::set valid_properties = { + "hash-index", "name-hash1", "name-hash2", "name-hash3", "locale", + "file-index", "byte-offset", "file-time", "file-size", "compressed-size", + "flags", "encryption-key", "encryption-key-raw", +}; + +} // namespace + +void RegisterList(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("list", "List files from the MPQ archive"); + + sub->add_option("target", options->target, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive") + ->check(CLI::ExistingFile); + sub->add_flag("-d,--detailed", options->detailed, + "File listing with additional columns (default false)"); + sub->add_flag("-a,--all", options->all, "File listing including hidden files (default false)"); + sub->add_option("-p,--property", options->properties, "Prints only specific property values") + ->check(CLI::IsMember(valid_properties)); + + sub->callback([options, &context]() { mpqcli::List(*options, context.out, context.err); }); +} diff --git a/app/main.cpp b/app/main.cpp new file mode 100644 index 0000000..e225138 --- /dev/null +++ b/app/main.cpp @@ -0,0 +1,80 @@ +#include +#include +#include +#include +#include + +#include + +#include "commands.h" +#include "errors.h" + +namespace { + +// The one piece of mutable global state: a signal handler takes no arguments and +// has nowhere else to write, and it may touch nothing but a lock-free atomic +std::atomic interrupted{false}; +static_assert(decltype(interrupted)::is_always_lock_free, + "a signal handler may only touch a lock-free atomic"); + +extern "C" void OnInterrupt(int) { + interrupted.store(true, std::memory_order_relaxed); +} + +} // namespace + +int main(int argc, char **argv) { + Context context{std::cout, std::cerr, std::cin, interrupted}; + + CLI::App app{ + "A command line tool to create, add, remove, list, extract, read, rename, and verify MPQ " + "archives using the StormLib library"}; + app.require_subcommand(1); + // No option here starts with a slash, so on Windows a path such as /data/patch.mpq + // is an argument rather than an option + app.allow_windows_style_options(false); + + RegisterVersion(app, context); + RegisterAbout(app, context); + RegisterInfo(app, context); + RegisterCreate(app, context); + RegisterAdd(app, context); + RegisterRemove(app, context); + RegisterRename(app, context); + RegisterList(app, context); + RegisterExtract(app, context); + RegisterRead(app, context); + RegisterVerify(app, context); + RegisterCompact(app, context); + RegisterCompletion(app, context); + + // Windows never delivers SIGTERM; registering it there is harmless and keeps + // one code path + std::signal(SIGINT, OnInterrupt); + std::signal(SIGTERM, OnInterrupt); + + try { + app.parse(argc, argv); + } catch (const CLI::ParseError &e) { + // An empty command line is a request for help rather than a mistake. The argc + // check is what keeps an unknown subcommand, which raises the same error, failing. + if (argc == 1 && e.get_exit_code() == static_cast(CLI::ExitCodes::RequiredError)) { + context.out << app.help() << std::endl; + return 0; + } + return app.exit(e, context.out, context.err); + } catch (const mpqcli::Interrupted &) { + // The user asked for this, so nothing is reported. Dying of the signal rather + // than returning a code is what lets the shell treat it as an interrupt. + std::signal(SIGINT, SIG_DFL); + std::raise(SIGINT); + return 1; // Not reached + } catch (const mpqcli::Error &e) { + context.err << "[!] " << e.what() << std::endl; + return 1; + } catch (const std::exception &e) { + context.err << "[!] Unexpected error: " << e.what() << std::endl; + return 2; + } + return context.exit_code; +} diff --git a/app/read.cpp b/app/read.cpp new file mode 100644 index 0000000..5d5997b --- /dev/null +++ b/app/read.cpp @@ -0,0 +1,22 @@ +#include "commands/read.h" + +#include + +#include + +#include "commands.h" + +void RegisterRead(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("read", "Read a file from an MPQ archive"); + + sub->add_option("file", options->file, "File to read")->required(); + sub->add_option("target", options->target, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("--locale", options->locale, "Preferred locale for read file"); + + sub->callback([options, &context]() { + context.exit_code = mpqcli::Read(*options, context.out, context.err) ? 0 : 1; + }); +} diff --git a/app/remove.cpp b/app/remove.cpp new file mode 100644 index 0000000..7e3a40a --- /dev/null +++ b/app/remove.cpp @@ -0,0 +1,28 @@ +#include "commands/remove.h" + +#include + +#include + +#include "commands.h" +#include "util/path.h" +#include "validators.h" + +void RegisterRemove(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("remove", "Remove files from an existing MPQ archive"); + + sub->add_option("archive", options->archive, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("files", options->files, + "Archive paths of files to remove; pass - to read paths from stdin") + ->required() + ->expected(-1); + sub->add_option("--locale", options->locale, "Locale of file to remove")->check(locale_valid); + + sub->callback([options, &context]() { + options->files = mpqcli::ExpandStdinMarker(options->files, context.in); + context.exit_code = mpqcli::Remove(*options, context.err, context.cancelled) ? 0 : 1; + }); +} diff --git a/app/rename.cpp b/app/rename.cpp new file mode 100644 index 0000000..7dd2d95 --- /dev/null +++ b/app/rename.cpp @@ -0,0 +1,24 @@ +#include "commands/rename.h" + +#include + +#include + +#include "commands.h" +#include "validators.h" + +void RegisterRename(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("rename", "Rename a file in an existing MPQ archive"); + + sub->add_option("archive", options->archive, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("old-file", options->old_file, "Current archive path of the file")->required(); + sub->add_option("new-file", options->new_file, "New archive path of the file")->required(); + sub->add_option("--locale", options->locale, "Locale of file to rename")->check(locale_valid); + + sub->callback([options, &context]() { + context.exit_code = mpqcli::Rename(*options, context.err) ? 0 : 1; + }); +} diff --git a/app/validators.cpp b/app/validators.cpp new file mode 100644 index 0000000..ae9c495 --- /dev/null +++ b/app/validators.cpp @@ -0,0 +1,50 @@ +#include "validators.h" + +#include + +#include "gamerules/rules.h" +#include "util/locales.h" + +using namespace mpqcli; + +// Accepts every profile name but lists only the canonical ones on failure +extern const CLI::Validator game_profile_valid = CLI::Validator( + [](const std::string &str) { + if (str == "default") + return std::string(); + + const GameProfile profile = GameRules::StringToProfile(str); + + // StringToProfile returns GENERIC for an unrecognised name, so the only + // way to tell a real "generic" from a miss is to compare the input + if (profile == GameProfile::GENERIC && str != "generic") { + std::string valid_profiles = "Game profile must be one of:"; + for (const auto &p : GameRules::GetCanonicalProfiles()) { + valid_profiles += " " + p; + } + return valid_profiles; + } + return std::string(); + }, + "", "GameProfileValidator"); + +extern const CLI::Validator locale_valid = CLI::Validator( + [](const std::string &str) { + if (str == "default") + return std::string(); + + if (ParseHexLocale(str) != default_locale) { + return std::string(); + } + + const LCID locale = LangToLocale(str); + if (locale == 0) { + std::string valid_locales = "Locale must be nothing, or one of:"; + for (const auto &l : GetAllLocales()) { + valid_locales += " " + l; + } + return valid_locales; + } + return std::string(); + }, + "", "LocaleValidator"); diff --git a/app/validators.h b/app/validators.h new file mode 100644 index 0000000..e103b66 --- /dev/null +++ b/app/validators.h @@ -0,0 +1,9 @@ +#ifndef VALIDATORS_H +#define VALIDATORS_H + +#include + +extern const CLI::Validator game_profile_valid; +extern const CLI::Validator locale_valid; + +#endif diff --git a/app/verify.cpp b/app/verify.cpp new file mode 100644 index 0000000..2f6bf57 --- /dev/null +++ b/app/verify.cpp @@ -0,0 +1,21 @@ +#include "commands/verify.h" + +#include + +#include + +#include "commands.h" + +void RegisterVerify(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("verify", "Verify the MPQ archive"); + + sub->add_option("target", options->target, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_flag("-p,--print", options->print_signature, "Print the digital signature (in hex)"); + + sub->callback([options, &context]() { + context.exit_code = mpqcli::Verify(*options, context.out, context.err) ? 0 : 1; + }); +} diff --git a/app/version.cpp b/app/version.cpp new file mode 100644 index 0000000..6d59355 --- /dev/null +++ b/app/version.cpp @@ -0,0 +1,13 @@ +#include +#include + +#include + +#include "commands.h" + +void RegisterVersion(CLI::App &app, Context &context) { + auto *sub = app.add_subcommand("version", "Prints program version"); + sub->callback([&context]() { + context.out << mpqcli::version_string << "-" << mpqcli::git_commit_hash << std::endl; + }); +} diff --git a/cmake/completion_data.h.in b/cmake/completion_data.h.in new file mode 100644 index 0000000..5f1ac21 --- /dev/null +++ b/cmake/completion_data.h.in @@ -0,0 +1,14 @@ +// Generated from the scripts in completion/, do not edit +#pragma once + +namespace mpqcli { + +inline constexpr char bash_completion_script[] = R"BASH_MPQCLI(@BASH_COMPLETION_SCRIPT@)BASH_MPQCLI"; + +inline constexpr char zsh_completion_script[] = R"ZSH_MPQCLI(@ZSH_COMPLETION_SCRIPT@)ZSH_MPQCLI"; + +inline constexpr char ps_completion_script[] = R"PS_MPQCLI(@PS_COMPLETION_SCRIPT@)PS_MPQCLI"; + +inline constexpr char fish_completion_script[] = R"FISH_MPQCLI(@FISH_COMPLETION_SCRIPT@)FISH_MPQCLI"; + +} // namespace mpqcli diff --git a/cmake/mark_system.cmake b/cmake/mark_system.cmake new file mode 100644 index 0000000..7d8c303 --- /dev/null +++ b/cmake/mark_system.cmake @@ -0,0 +1,10 @@ +# Re-declare a dependency's interface includes as SYSTEM includes, so clang-tidy +# and the compiler ignore them. Needed for any submodule that exports its own +# target: linking it otherwise pulls its headers in as ordinary -I paths. +function(mark_system target) + get_target_property(_incs ${target} INTERFACE_INCLUDE_DIRECTORIES) + if(_incs) + set_target_properties(${target} PROPERTIES + INTERFACE_SYSTEM_INCLUDE_DIRECTORIES "${_incs}") + endif() +endfunction() diff --git a/cmake/version.h.in b/cmake/version.h.in new file mode 100644 index 0000000..305dc1f --- /dev/null +++ b/cmake/version.h.in @@ -0,0 +1,20 @@ +#pragma once + +namespace mpqcli { + +/// Major version, from project(... VERSION) in the root CMakeLists.txt +inline constexpr int version_major = @PROJECT_VERSION_MAJOR@; + +/// Minor version +inline constexpr int version_minor = @PROJECT_VERSION_MINOR@; + +/// Patch version +inline constexpr int version_patch = @PROJECT_VERSION_PATCH@; + +/// The version as a dotted string, "1.2.3" +inline constexpr const char *version_string = "@PROJECT_VERSION@"; + +/// The commit the binary was built from, or "unknown" outside a git checkout +inline constexpr const char *git_commit_hash = "@GIT_COMMIT_HASH@"; + +} // namespace mpqcli diff --git a/completion/mpqcli.bash b/completion/mpqcli.bash new file mode 100644 index 0000000..5990f53 --- /dev/null +++ b/completion/mpqcli.bash @@ -0,0 +1,292 @@ +# Bash completion for mpqcli. Uses the bash-completion helpers when they are +# loaded and falls back to plain compgen otherwise, so it also runs on bash 3.2 + +# Fill COMPREPLY with the words in $1 that match the current word +_mpqcli_words() { + local word + COMPREPLY=() + while IFS= read -r word; do + COMPREPLY+=("$word") + done < <(compgen -W "$1" -- "$cur") +} + +# Complete filesystem paths. With no argument every path is offered, with -d +# only directories, and with an extension such as mpq only files of that type +# in either case plus directories, so an archive can be reached by descending +_mpqcli_filedir() { + if declare -f _filedir > /dev/null 2>&1; then + _filedir "${1-}" + return + fi + + local path upper + COMPREPLY=() + compopt -o filenames 2> /dev/null + if [[ "${1-}" == -d ]]; then + while IFS= read -r path; do + COMPREPLY+=("$path") + done < <(compgen -d -- "$cur") + elif [[ -n "${1-}" ]]; then + upper=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]') + while IFS= read -r path; do + if [[ -d "$path" || "$path" == *."$1" || "$path" == *."$upper" ]]; then + COMPREPLY+=("$path") + fi + done < <(compgen -f -- "$cur") + else + while IFS= read -r path; do + COMPREPLY+=("$path") + done < <(compgen -f -- "$cur") + fi +} + +# Set pos to the index, from zero, of the positional argument under the cursor. +# Every option is assumed to take a value in the following word, except the +# flags passed as arguments, and a lone - counts as a positional (stdin) +_mpqcli_positional_index() { + local i flag + pos=0 + for ((i = 2; i < cword; i++)); do + case "${words[i]}" in + -) pos=$((pos + 1)) ;; + --*=*) ;; + -*) + for flag in "$@"; do + [[ "${words[i]}" == "$flag" ]] && continue 2 + done + i=$((i + 1)) + ;; + *) pos=$((pos + 1)) ;; + esac + done +} + +_mpqcli() { + local cur prev words cword pos + + # Use bash-completion helpers when available, fall back to COMP_* variables. + # The -s splits --option=value so the value completes like a separate word + if declare -f _init_completion > /dev/null 2>&1; then + _init_completion -s || return + else + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + words=("${COMP_WORDS[@]}") + cword=$COMP_CWORD + fi + + local subcommands="version about info create add remove rename list extract read verify compact completion" + local -a locales=( + default enUS zhTW csCZ deDE esES frFR itIT + jaJP koKR nlNL plPL ptBR ruRU zhCN enGB esMX ptPT + ) + local -a games=( + generic + diablo1 diablo d1 + lordsofmagic lomse + starcraft starcraft1 sc sc1 + warcraft2 wc2 war2 + diablo2 d2 + warcraft3 wc3 war3 + warcraft3-map wc3-map war3-map + wow1 wow-vanilla + wow2 wow-tbc + wow3 wow-wotlk + wow4 wow-cataclysm + wow5 wow-mop + starcraft2 sc2 + diablo3 d3 + ) + local -a info_properties=( + format-version header-offset header-size archive-size + file-count max-files signature-type + ) + local -a list_properties=( + hash-index name-hash1 name-hash2 name-hash3 locale + file-index byte-offset file-time file-size compressed-size + flags encryption-key encryption-key-raw + ) + + if [[ $cword -eq 1 ]]; then + _mpqcli_words "$subcommands" + return + fi + + case "${words[1]}" in + info) + case "$prev" in + -p|--property) _mpqcli_words "${info_properties[*]}"; return ;; + esac + if [[ "$cur" == -* ]]; then + _mpqcli_words "-p --property" + else + _mpqcli_positional_index + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi + fi + ;; + + create) + case "$prev" in + --locale) _mpqcli_words "${locales[*]}"; return ;; + -g|--game) _mpqcli_words "${games[*]}"; return ;; + --version) _mpqcli_words "1 2 3 4"; return ;; + -o|--output) _mpqcli_filedir; return ;; + -p|--path|--stream-flags|--sector-size|--raw-chunk-size|\ + --file-flags1|--file-flags2|--file-flags3|--attr-flags|\ + --flags|--compression|--compression-next) + return ;; + esac + if [[ "$cur" == -* ]]; then + _mpqcli_words "-p --path -o --output -s --sign --locale -g --game --version \ +--stream-flags --sector-size --raw-chunk-size --file-flags1 --file-flags2 --file-flags3 \ +--attr-flags --flags --compression --compression-next" + else + _mpqcli_positional_index -s --sign + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir + fi + fi + ;; + + add) + case "$prev" in + --locale) _mpqcli_words "${locales[*]}"; return ;; + -g|--game) _mpqcli_words "${games[*]}"; return ;; + -p|--path|--flags|--compression|--compression-next) return ;; + esac + if [[ "$cur" == -* ]]; then + _mpqcli_words "-p --path -w --overwrite -u --update --locale -g --game \ +--flags --compression --compression-next" + else + # Positional 1 is the archive; the rest are files or directories to add + _mpqcli_positional_index -w --overwrite -u --update + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + else + _mpqcli_filedir + fi + fi + ;; + + remove) + case "$prev" in + --locale) _mpqcli_words "${locales[*]}"; return ;; + esac + if [[ "$cur" == -* ]]; then + _mpqcli_words "--locale" + else + # Positional 1 is the archive; the rest are in-archive paths, so + # they get no filesystem completion + _mpqcli_positional_index + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi + fi + ;; + + rename) + case "$prev" in + --locale) _mpqcli_words "${locales[*]}"; return ;; + esac + if [[ "$cur" == -* ]]; then + _mpqcli_words "--locale" + else + # Positional 1 is the archive; positionals 2 and 3 are in-archive + # paths, so they get no filesystem completion + _mpqcli_positional_index + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi + fi + ;; + + list) + case "$prev" in + -l|--listfile) _mpqcli_filedir; return ;; + -p|--property) _mpqcli_words "${list_properties[*]}"; return ;; + esac + if [[ "$cur" == -* ]]; then + _mpqcli_words "-l --listfile -d --detailed -a --all -p --property" + else + _mpqcli_positional_index -d --detailed -a --all + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi + fi + ;; + + extract) + case "$prev" in + -o|--output) _mpqcli_filedir -d; return ;; + -l|--listfile) _mpqcli_filedir; return ;; + --locale) _mpqcli_words "${locales[*]}"; return ;; + -f|--file) return ;; + esac + if [[ "$cur" == -* ]]; then + _mpqcli_words "-o --output -f --file -k --keep -l --listfile --locale" + else + _mpqcli_positional_index -k --keep + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi + fi + ;; + + read) + case "$prev" in + --locale) _mpqcli_words "${locales[*]}"; return ;; + esac + if [[ "$cur" == -* ]]; then + _mpqcli_words "--locale" + else + # Positional 1 is an in-archive path, so it gets no filesystem + # completion; positional 2 is the archive + _mpqcli_positional_index + if [[ $pos -eq 1 ]]; then + _mpqcli_filedir mpq + fi + fi + ;; + + verify) + if [[ "$cur" == -* ]]; then + _mpqcli_words "-p --print" + else + _mpqcli_positional_index -p --print + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi + fi + ;; + + compact) + case "$prev" in + -l|--listfile) _mpqcli_filedir; return ;; + esac + if [[ "$cur" == -* ]]; then + _mpqcli_words "-l --listfile" + else + _mpqcli_positional_index + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi + fi + ;; + + completion) + if [[ "$cur" != -* ]]; then + _mpqcli_positional_index + if [[ $pos -eq 0 ]]; then + _mpqcli_words "bash zsh powershell fish" + fi + fi + ;; + + version|about) ;; + esac +} + +complete -F _mpqcli mpqcli diff --git a/src/completion/mpqcli.fish b/completion/mpqcli.fish similarity index 54% rename from src/completion/mpqcli.fish rename to completion/mpqcli.fish index 2d7b3b6..48f3fc0 100644 --- a/src/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -1,13 +1,42 @@ -# Fish shell completions for mpqcli -# Source: generated from mpqcli/src/main.cpp +# Fish completion for mpqcli # # Install: -# cp mpqcli.fish ~/.config/fish/completions/mpqcli.fish +# mpqcli completion fish > ~/.config/fish/completions/mpqcli.fish -# Disable file completion globally; re-enable per argument where needed. +# Disable file completion globally; re-enable per argument where needed complete -c mpqcli -f -# Shared value sets (sourced directly from main.cpp) +# Index, from one, of the positional argument under the cursor. The command +# name and subcommand are skipped, as are options and the value following any +# option not listed as a flag in the arguments. A lone - counts as a positional +function __mpqcli_positional_index + set -l index 1 + set -l skip 0 + for token in (commandline -poc)[3..-1] + if test $skip -eq 1 + set skip 0 + continue + end + switch $token + case '-' + set index (math $index + 1) + case '--*=*' + case '-*' + if not contains -- $token $argv + set skip 1 + end + case '*' + set index (math $index + 1) + end + end + echo $index +end + +# Shared value sets, kept in step with app/main.cpp, app/info.cpp, app/list.cpp, +# src/util/locales.cpp and src/gamerules/profiles.cpp +set -l __mpqcli_subcommands \ + version about info create add remove rename list extract read verify compact completion + set -l __mpqcli_locales \ default \ enUS enGB zhTW zhCN csCZ deDE esES esMX \ @@ -17,7 +46,7 @@ set -l __mpqcli_game_profiles \ generic \ diablo1 diablo d1 \ lordsofmagic lomse \ - starcraft starcraft1 sc1 \ + starcraft starcraft1 sc sc1 \ warcraft2 wc2 war2 \ diablo2 d2 \ warcraft3 wc3 war3 \ @@ -40,41 +69,40 @@ set -l __mpqcli_list_properties \ flags encryption-key encryption-key-raw # Top-level subcommands (no subcommand active yet) -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a version -d 'Print program version' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a about -d 'Print program information' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a info -d 'Print info about an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a create -d 'Create an MPQ archive from a file or directory' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a add -d 'Add files to an existing MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a remove -d 'Remove files from an existing MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a rename -d 'Rename a file in an existing MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a list -d 'List files in an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a extract -d 'Extract files from an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a read -d 'Read a file from an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a verify -d 'Verify an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a compact -d 'Compact the MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a completion -d 'Generate shell completion script' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a version -d 'Prints program version' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a about -d 'Prints program information' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a info -d 'Prints info about an MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a create -d 'Create an MPQ archive from target file or directory' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a add -d 'Add files to an existing MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a remove -d 'Remove files from an existing MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a rename -d 'Rename a file in an existing MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a list -d 'List files from the MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a extract -d 'Extract files from the MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a read -d 'Read a file from an MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a verify -d 'Verify the MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a compact -d 'Compact the MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a completion -d 'Generate shell completion script' # info # info [-p/--property ] -# positional: existing file -complete -c mpqcli -n '__fish_seen_subcommand_from info' -F +complete -c mpqcli -n '__fish_seen_subcommand_from info; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from info' \ -s p -l property -d 'Print only a specific property value' \ - -r -a "$__mpqcli_info_properties" + -r -f -a "$__mpqcli_info_properties" # create # create [-p/--path] [-o/--output] [-s/--sign] @@ -82,89 +110,95 @@ complete -c mpqcli -n '__fish_seen_subcommand_from info' \ # [--version] [--stream-flags] [--sector-size] [--raw-chunk-size] # [--file-flags1/2/3] [--attr-flags] # [--flags] [--compression] [--compression-next] -complete -c mpqcli -n '__fish_seen_subcommand_from create' -F +complete -c mpqcli -n '__fish_seen_subcommand_from create; and test (__mpqcli_positional_index -s --sign) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -s p -l path -d 'Archive path for a single file, or prefix for a directory' -r + -s p -l path -d 'Archive path for a single file, or prefix for a directory' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -s o -l output -d 'Output MPQ archive path' -r -F complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -s s -l sign -d 'Sign the MPQ archive' complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -l locale -d 'Locale to use for added files' \ - -r -a "$__mpqcli_locales" + -r -f -a "$__mpqcli_locales" complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -s g -l game -d 'Game profile for MPQ creation' \ - -r -a "$__mpqcli_game_profiles" + -r -f -a "$__mpqcli_game_profiles" # Game setting overrides complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l version -d 'Override MPQ archive version (1-4)' -r + -l version -d 'Override MPQ archive version (1-4)' -r -f -a '1 2 3 4' complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l stream-flags -d 'Override stream flags' -r + -l stream-flags -d 'Override stream flags' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l sector-size -d 'Override sector size' -r + -l sector-size -d 'Override sector size' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l raw-chunk-size -d 'Override raw chunk size (MPQ v4)' -r + -l raw-chunk-size -d 'Override raw chunk size (MPQ v4)' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l file-flags1 -d 'Override file flags for (listfile)' -r + -l file-flags1 -d 'Override file flags for (listfile)' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l file-flags2 -d 'Override file flags for (attributes)' -r + -l file-flags2 -d 'Override file flags for (attributes)' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l file-flags3 -d 'Override file flags for (signature)' -r + -l file-flags3 -d 'Override file flags for (signature)' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l attr-flags -d 'Override attribute flags (CRC32, FILETIME, MD5)' -r + -l attr-flags -d 'Override attribute flags (CRC32, FILETIME, MD5)' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l flags -d 'Override MPQ file flags for added files' -r + -l flags -d 'Override MPQ file flags for added files' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l compression -d 'Override compression for first sector of added files' -r + -l compression -d 'Override compression for first sector of added files' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l compression-next -d 'Override compression for subsequent sectors of added files' -r + -l compression-next -d 'Override compression for subsequent sectors of added files' -r -f # add # add [-p/--path] [-w/--overwrite | -u/--update] # [--locale] [-g/--game] # [--flags] [--compression] [--compression-next] +# Positional 1 is the archive and the rest are files or directories to add, so +# every positional takes a path complete -c mpqcli -n '__fish_seen_subcommand_from add' -F complete -c mpqcli -n '__fish_seen_subcommand_from add' \ - -s p -l path -d 'Archive path for a single file, or prefix for a directory' -r + -s p -l path -d 'Archive path for a single file, or prefix for a directory' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from add' \ -s w -l overwrite -d 'Replace every file that already exists in the archive' complete -c mpqcli -n '__fish_seen_subcommand_from add' \ -s u -l update -d 'Replace only files that changed' complete -c mpqcli -n '__fish_seen_subcommand_from add' \ -l locale -d 'Locale to use for added file' \ - -r -a "$__mpqcli_locales" + -r -f -a "$__mpqcli_locales" complete -c mpqcli -n '__fish_seen_subcommand_from add' \ -s g -l game -d 'Game profile for compression rules' \ - -r -a "$__mpqcli_game_profiles" + -r -f -a "$__mpqcli_game_profiles" complete -c mpqcli -n '__fish_seen_subcommand_from add' \ - -l flags -d 'Override MPQ file flags' -r + -l flags -d 'Override MPQ file flags' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from add' \ - -l compression -d 'Override compression for first sector' -r + -l compression -d 'Override compression for first sector' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from add' \ - -l compression-next -d 'Override compression for subsequent sectors' -r + -l compression-next -d 'Override compression for subsequent sectors' -r -f # remove # remove [--locale] -complete -c mpqcli -n '__fish_seen_subcommand_from remove' -F +# Positional 1 is the archive; the rest are in-archive paths, so they get no +# filesystem completion +complete -c mpqcli -n '__fish_seen_subcommand_from remove; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from remove' \ -l locale -d 'Locale of the file to remove' \ - -r -a "$__mpqcli_locales" + -r -f -a "$__mpqcli_locales" # rename # rename [--locale] -complete -c mpqcli -n '__fish_seen_subcommand_from rename' -F +# Positional 1 is the archive; positionals 2 and 3 are in-archive paths, so +# they get no filesystem completion +complete -c mpqcli -n '__fish_seen_subcommand_from rename; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from rename' \ -l locale -d 'Locale of the file to rename' \ - -r -a "$__mpqcli_locales" + -r -f -a "$__mpqcli_locales" # list # list [-l/--listfile] [-d/--detailed] [-a/--all] # [-p/--property ] -complete -c mpqcli -n '__fish_seen_subcommand_from list' -F +complete -c mpqcli -n '__fish_seen_subcommand_from list; and test (__mpqcli_positional_index -d --detailed -a --all) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from list' \ -s l -l listfile -d 'External file listing content of the archive' -r -F @@ -174,54 +208,56 @@ complete -c mpqcli -n '__fish_seen_subcommand_from list' \ -s a -l all -d 'Include hidden files' complete -c mpqcli -n '__fish_seen_subcommand_from list' \ -s p -l property -d 'Print only specific property values' \ - -r -a "$__mpqcli_list_properties" + -r -f -a "$__mpqcli_list_properties" # extract # extract [-o/--output] [-f/--file] [-k/--keep] # [-l/--listfile] [--locale] -complete -c mpqcli -n '__fish_seen_subcommand_from extract' -F +complete -c mpqcli -n '__fish_seen_subcommand_from extract; and test (__mpqcli_positional_index -k --keep) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ - -s o -l output -d 'Output directory' -r -F + -s o -l output -d 'Output directory' -r -f -a '(__fish_complete_directories)' complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ - -s f -l file -d 'Target file to extract' -r + -s f -l file -d 'Target file to extract' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -s k -l keep -d 'Keep folder structure' complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -s l -l listfile -d 'External file listing content of the archive' -r -F complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -l locale -d 'Preferred locale for extracted file' \ - -r -a "$__mpqcli_locales" + -r -f -a "$__mpqcli_locales" # read # read [--locale] -complete -c mpqcli -n '__fish_seen_subcommand_from read' -F +# Positional 1 is an in-archive path, so it gets no filesystem completion; +# positional 2 is the archive +complete -c mpqcli -n '__fish_seen_subcommand_from read; and test (__mpqcli_positional_index) -eq 2' -F complete -c mpqcli -n '__fish_seen_subcommand_from read' \ -l locale -d 'Preferred locale for read file' \ - -r -a "$__mpqcli_locales" + -r -f -a "$__mpqcli_locales" # verify # verify [-p/--print] -complete -c mpqcli -n '__fish_seen_subcommand_from verify' -F +complete -c mpqcli -n '__fish_seen_subcommand_from verify; and test (__mpqcli_positional_index -p --print) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from verify' \ -s p -l print -d 'Print the digital signature (in hex)' # compact # compact [-l/--listfile] -complete -c mpqcli -n '__fish_seen_subcommand_from compact' -F +complete -c mpqcli -n '__fish_seen_subcommand_from compact; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from compact' \ -s l -l listfile -d 'External file listing content of the archive' -r -F # completion # completion -complete -c mpqcli -n '__fish_seen_subcommand_from completion' \ +complete -c mpqcli -n '__fish_seen_subcommand_from completion; and not __fish_seen_subcommand_from bash zsh powershell fish' \ -a bash -d 'Generate bash completion script' -complete -c mpqcli -n '__fish_seen_subcommand_from completion' \ +complete -c mpqcli -n '__fish_seen_subcommand_from completion; and not __fish_seen_subcommand_from bash zsh powershell fish' \ -a zsh -d 'Generate zsh completion script' -complete -c mpqcli -n '__fish_seen_subcommand_from completion' \ +complete -c mpqcli -n '__fish_seen_subcommand_from completion; and not __fish_seen_subcommand_from bash zsh powershell fish' \ -a powershell -d 'Generate PowerShell completion script' -complete -c mpqcli -n '__fish_seen_subcommand_from completion' \ +complete -c mpqcli -n '__fish_seen_subcommand_from completion; and not __fish_seen_subcommand_from bash zsh powershell fish' \ -a fish -d 'Generate fish completion script' diff --git a/src/completion/mpqcli.ps1 b/completion/mpqcli.ps1 similarity index 85% rename from src/completion/mpqcli.ps1 rename to completion/mpqcli.ps1 index 1dfd51d..30ae3ac 100644 --- a/src/completion/mpqcli.ps1 +++ b/completion/mpqcli.ps1 @@ -9,10 +9,9 @@ arguments fall back to PowerShell's native filesystem completion. .NOTES - Source it from your $PROFILE: - . /path/to/mpqcli-completion.ps1 + Append it to your profile so it loads at startup: + mpqcli completion powershell >> $PROFILE - Or copy it into a module / profile script that loads at startup. Works for any executable named 'mpqcli' or 'mpqcli.exe'. #> @@ -41,7 +40,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl 'generic', 'diablo1', 'diablo', 'd1', 'lordsofmagic', 'lomse', - 'starcraft', 'starcraft1', 'sc1', + 'starcraft', 'starcraft1', 'sc', 'sc1', 'warcraft2', 'wc2', 'war2', 'diablo2', 'd2', 'warcraft3', 'wc3', 'war3', @@ -72,14 +71,14 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl 'flags', 'encryption-key', 'encryption-key-raw' ) - # Options available per subcommand. Each value is a hashtable mapping the - # option token to a short help string. + # Options available per subcommand. Each value maps the option token to a + # short help string, in the order the help text lists them $optionSpec = @{ - 'info' = @{ + 'info' = [ordered]@{ '-p' = 'Print only a specific property value' '--property' = 'Print only a specific property value' } - 'create' = @{ + 'create' = [ordered]@{ '-p' = 'Archive path for a single file, or prefix for a directory' '--path' = 'Archive path for a single file, or prefix for a directory' '-o' = 'Output MPQ archive' @@ -101,12 +100,12 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--compression' = 'Override compression for first sector' '--compression-next' = 'Override compression for subsequent sectors' } - 'add' = @{ + 'add' = [ordered]@{ '-p' = 'Archive path for a single file, or prefix for a directory' '--path' = 'Archive path for a single file, or prefix for a directory' - '-w' = 'Overwrite file if it already is in MPQ archive' + '-w' = 'Replace every file that already exists in the archive' '--overwrite' = 'Replace every file that already exists in the archive' - '-u' = 'Skip unchanged files when adding a directory' + '-u' = 'Replace only files that changed' '--update' = 'Replace only files that changed' '--locale' = 'Locale to use for added file' '-g' = 'Game profile for compression rules' @@ -115,13 +114,13 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--compression' = 'Override compression for first sector' '--compression-next' = 'Override compression for subsequent sectors' } - 'remove' = @{ + 'remove' = [ordered]@{ '--locale' = 'Locale of file to remove' } - 'rename' = @{ + 'rename' = [ordered]@{ '--locale' = 'Locale of file to rename' } - 'list' = @{ + 'list' = [ordered]@{ '-l' = 'File listing content of an MPQ archive' '--listfile' = 'File listing content of an MPQ archive' '-d' = 'File listing with additional columns' @@ -131,7 +130,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '-p' = 'Print only specific property values' '--property' = 'Print only specific property values' } - 'extract' = @{ + 'extract' = [ordered]@{ '-o' = 'Output directory' '--output' = 'Output directory' '-f' = 'Target file to extract' @@ -142,14 +141,14 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--listfile' = 'File listing content of an MPQ archive' '--locale' = 'Preferred locale for extracted file' } - 'read' = @{ + 'read' = [ordered]@{ '--locale' = 'Preferred locale for read file' } - 'verify' = @{ + 'verify' = [ordered]@{ '-p' = 'Print the digital signature (in hex)' '--print' = 'Print the digital signature (in hex)' } - 'compact' = @{ + 'compact' = [ordered]@{ '-l' = 'File listing content of an MPQ archive' '--listfile' = 'File listing content of an MPQ archive' } @@ -158,27 +157,28 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl 'completion' = @{} } - # Options whose *argument* should complete from a static value set. + # Options whose *argument* should complete from a static value set $valueOptions = @{ '--locale' = $locales '-g' = $gameProfiles '--game' = $gameProfiles + '--version' = @('1', '2', '3', '4') } - # Property options depend on the subcommand (info vs list), handled below. + # Property options depend on the subcommand (info vs list), handled below - # Parse the current command line - - # Tokenized elements of the command line, excluding the executable itself. + # Tokenised elements of the command line, excluding the executable itself $elements = @($commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.ToString() }) - # Identify the active subcommand (first element that is a known subcommand). + # Identify the active subcommand: the first token, provided it is not the + # word still being typed. The @() keeps a single token from collapsing to + # a string, whose [0] would be its first character + $prior = @(if ([string]::IsNullOrEmpty($wordToComplete)) { $elements } + else { $elements | Select-Object -SkipLast 1 }) $subcommand = $null - foreach ($el in $elements) { - if ($subcommands.Contains($el)) { $subcommand = $el; break } - } + if ($prior.Count -ge 1 -and $subcommands.Contains($prior[0])) { $subcommand = $prior[0] } - # The token immediately preceding the cursor (the one we may be an arg to). + # The token immediately preceding the cursor (the one we may be an arg to) $prevToken = $null if ($elements.Count -ge 1) { if ([string]::IsNullOrEmpty($wordToComplete)) { @@ -226,16 +226,16 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl return } - # 4) Completing the argument to a value-bearing option + # 3) Completing the argument to a value-bearing option if ($prevToken) { - # Locale / game profile options. + # Locale / game profile options if ($valueOptions.ContainsKey($prevToken)) { $items = foreach ($v in $valueOptions[$prevToken]) { [pscustomobject]@{ Value = $v; Tip = $prevToken } } return & $emit $items 'ParameterValue' } - # Property options: meaning depends on the subcommand. + # Property options: meaning depends on the subcommand if ($prevToken -in @('-p', '--property')) { if ($subcommand -eq 'info') { $items = foreach ($v in $infoProperties) { @@ -247,9 +247,9 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl [pscustomobject]@{ Value = $v; Tip = 'list property' } } return & $emit $items 'ParameterValue' } - # For 'verify', -p/--print is a flag (no value) -> fall through. + # For 'verify', -p/--print is a flag (no value) -> fall through } - # Options that take a file/dir path -> let PowerShell complete paths. + # Options that take a file/dir path -> let PowerShell complete paths $pathOptions = @('-o', '--output', '-l', '--listfile', '-f', '--file', '-p', '--path') if ($prevToken -in $pathOptions -and @@ -258,21 +258,17 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl } } - # 5) Completing an option for the current subcommand - if ($wordToComplete -like '-*' -or [string]::IsNullOrEmpty($wordToComplete)) { + # 4) Completing an option for the current subcommand + if ($wordToComplete -like '-*') { $opts = $optionSpec[$subcommand] if ($opts) { $items = foreach ($k in $opts.Keys) { [pscustomobject]@{ Value = $k; Tip = $opts[$k] } } - $results = & $emit $items 'ParameterName' - if ($wordToComplete -like '-*') { return $results } - # When word is empty we still also allow path completion, so only - # return option results if the user has started a dash. - if ($results) { return $results } + return & $emit $items 'ParameterName' } } - # 6) Fall back to native filesystem completion (archives/files) + # 5) Fall back to native filesystem completion (archives/files) return } diff --git a/src/completion/mpqcli.zsh b/completion/mpqcli.zsh similarity index 76% rename from src/completion/mpqcli.zsh rename to completion/mpqcli.zsh index 87ce3e8..b1f463e 100644 --- a/src/completion/mpqcli.zsh +++ b/completion/mpqcli.zsh @@ -9,7 +9,7 @@ _mpqcli_games=( generic diablo1 diablo d1 lordsofmagic lomse - starcraft starcraft1 sc1 + starcraft starcraft1 sc sc1 warcraft2 wc2 war2 diablo2 d2 warcraft3 wc3 war3 @@ -35,7 +35,7 @@ _mpqcli_list_properties=( ) _mpqcli() { - local state + local context state state_descr line typeset -A opt_args _arguments -C \ @@ -63,9 +63,9 @@ _mpqcli() { _mpqcli_cmds() { local cmds=( - 'version:Print program version' - 'about:Print program information' - 'info:Print info about an MPQ archive' + 'version:Prints program version' + 'about:Prints program information' + 'info:Prints info about an MPQ archive' 'create:Create an MPQ archive from target file or directory' 'add:Add files to an existing MPQ archive' 'remove:Remove files from an existing MPQ archive' @@ -80,21 +80,28 @@ _mpqcli_cmds() { _describe 'subcommand' cmds } +# MPQ archives in either case plus directories; _files offers every file when +# nothing matches, so archives with other extensions stay reachable +_mpqcli_archives() { + _files -g '*.(#i)mpq' "$@" +} + _mpqcli_info() { local props="${_mpqcli_info_properties[*]}" _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '(-p --property)'{-p,--property}'[print only a specific property]:property:('"$props"')' } _mpqcli_create() { + local locales="${_mpqcli_locales[*]}" games="${_mpqcli_games[*]}" _arguments \ '1:target:_files' \ '(-p --path)'{-p,--path}'[archive path for a single file, or prefix for a directory]:path' \ '(-o --output)'{-o,--output}'[output archive]:file:_files' \ '(-s --sign)'{-s,--sign}'[sign the archive]' \ - '--locale[locale for added files]:locale:('"${_mpqcli_locales[@]}"')' \ - '(-g --game)'{-g,--game}'[game profile]:profile:('"${_mpqcli_games[@]}"')' \ + '--locale[locale for added files]:locale:('"$locales"')' \ + '(-g --game)'{-g,--game}'[game profile]:profile:('"$games"')' \ '--version[MPQ archive version (1-4)]:version:(1 2 3 4)' \ '--stream-flags[override stream flags]:flags' \ '--sector-size[override sector size]:size' \ @@ -109,38 +116,41 @@ _mpqcli_create() { } _mpqcli_add() { + local locales="${_mpqcli_locales[*]}" games="${_mpqcli_games[*]}" _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '*:files:_files' \ '(-p --path)'{-p,--path}'[archive path or prefix for directory add]:path' \ '(-w --overwrite -u --update)'{-w,--overwrite}'[replace every existing file]' \ '(-u --update -w --overwrite)'{-u,--update}'[replace only files that changed]' \ - '--locale[locale for added file]:locale:('"${_mpqcli_locales[@]}"')' \ - '(-g --game)'{-g,--game}'[game profile]:profile:('"${_mpqcli_games[@]}"')' \ + '--locale[locale for added file]:locale:('"$locales"')' \ + '(-g --game)'{-g,--game}'[game profile]:profile:('"$games"')' \ '--flags[override MPQ file flags]:flags' \ '--compression[override compression for first sector]:compression' \ '--compression-next[override compression for subsequent sectors]:compression' } _mpqcli_remove() { + local locales="${_mpqcli_locales[*]}" _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '*:archive paths' \ - '--locale[locale of file to remove]:locale:('"${_mpqcli_locales[@]}"')' + '--locale[locale of file to remove]:locale:('"$locales"')' } _mpqcli_rename() { + local locales="${_mpqcli_locales[*]}" _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '2:old archive path' \ '3:new archive path' \ - '--locale[locale of file to rename]:locale:("${_mpqcli_locales[@]}")' + '--locale[locale of file to rename]:locale:('"$locales"')' } _mpqcli_list() { local props="${_mpqcli_list_properties[*]}" _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' \ '(-d --detailed)'{-d,--detailed}'[detailed listing with extra columns]' \ '(-a --all)'{-a,--all}'[include hidden files]' \ @@ -148,35 +158,41 @@ _mpqcli_list() { } _mpqcli_extract() { + local locales="${_mpqcli_locales[*]}" _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '(-o --output)'{-o,--output}'[output directory]:dir:_files -/' \ '(-f --file)'{-f,--file}'[target file to extract]:file' \ '(-k --keep)'{-k,--keep}'[keep folder structure]' \ '(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' \ - '--locale[preferred locale for extracted file]:locale:('"${_mpqcli_locales[@]}"')' + '--locale[preferred locale for extracted file]:locale:('"$locales"')' } _mpqcli_read() { + local locales="${_mpqcli_locales[*]}" _arguments \ '1:file-in-archive' \ - '2:archive:_files' \ - '--locale[preferred locale for read file]:locale:('"${_mpqcli_locales[@]}"')' + '2:archive:_mpqcli_archives' \ + '--locale[preferred locale for read file]:locale:('"$locales"')' } _mpqcli_verify() { _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '(-p --print)'{-p,--print}'[print the digital signature in hex]' } _mpqcli_compact() { _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' } _mpqcli_completion() { + _arguments '1:shell:_mpqcli_shells' +} + +_mpqcli_shells() { local shells=( 'bash:Generate bash completion script' 'zsh:Generate zsh completion script' diff --git a/docs/advanced.md b/docs/advanced.md index a44118a..d334761 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -1,4 +1,4 @@ -# Advanced Examples +# Advanced examples ## Search and extract files on Linux diff --git a/docs/building.md b/docs/building.md index 7bcd190..e39f0be 100644 --- a/docs/building.md +++ b/docs/building.md @@ -13,28 +13,28 @@ ```bash $ git clone --recursive https://github.com/thegraydot/mpqcli.git $ cd mpqcli -$ cmake -B build -$ cmake --build build +$ cmake -B build/release -DCMAKE_BUILD_TYPE=Release +$ cmake --build build/release ``` -The `mpqcli` binary will be available in: `./build/bin/mpqcli` +The `mpqcli` binary will be available in: `./build/release/bin/mpqcli` ## Windows ```bash $ git clone --recursive https://github.com/thegraydot/mpqcli.git $ cd mpqcli -$ cmake -B build -$ cmake --build build --config Release +$ cmake -B build/release +$ cmake --build build/release --config Release ``` -The `mpqcli.exe` binary will be available in: `.\build\bin\Release\mpqcli.exe` +The `mpqcli.exe` binary will be available in: `.\build\release\bin\mpqcli.exe` ## Dependencies ### StormLib -This project requires the [StormLib](https://github.com/ladislav-zezula/StormLib) library. Many thanks to [Ladislav Zezula](https://github.com/ladislav-zezula) for authoring such a good library and releasing the code under an open-source license. The StormLib library has a number of requirements. However, the build method specifies using the libraries bundled with StormLib. +This project requires the [StormLib](https://github.com/ladislav-zezula/StormLib) library. Many thanks to [Ladislav Zezula](https://github.com/ladislav-zezula) for authoring such a good library and releasing the code under an open-source licence. The StormLib library has a number of requirements. However, the build method specifies using the libraries bundled with StormLib. ### CLI11 @@ -48,13 +48,17 @@ To configure the testing environment you will need Python installed, as well as ```bash $ sudo apt install python3-venv python3-pip -$ python3 -m venv test/.venv -$ source test/.venv/bin/activate +$ python3 -m venv .venv +$ source .venv/bin/activate $ pip3 install -r test/requirements.txt ``` -Then you can run the tests using: +The tests run the binary in `build/dev`, so build it there and then run the tests using: ```bash +$ cmake -B build/dev +$ cmake --build build/dev $ python3 -m pytest test -s ``` + +`make test` does the same in one step. diff --git a/docs/changelog.md b/docs/changelog.md index 7fd9853..6bef9b8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,29 @@ # Changelog +## 0.12.0 - 2026-10-04 + +### Added + +- Stop cleanly on Ctrl-C, leaving no partial archive behind + +### Changed + +- Ship one static musl Linux binary per architecture, with the release assets renamed by OS and architecture +- Publish install scripts and a signed checksums file with each release +- Run the Docker image in /data so mounted paths need no prefix +- Print progress and warnings on stderr, leaving stdout for results alone +- Mark an overwritten file as changed rather than added when adding + +### Fixed + +- Fail with a non-zero exit code on an unknown subcommand, a missing argument or an internal error +- Fail with a non-zero exit code when closing, signing, listing or printing a signature fails +- Complete every locale, game profile and archive path in the zsh, fish and PowerShell scripts + +### Removed + +- Remove the install scripts from the repository; each release now carries its own + ## 0.11.0 - 2026-09-21 ### Added diff --git a/docs/commands/add.md b/docs/commands/add.md index f31b2f4..570cd34 100644 --- a/docs/commands/add.md +++ b/docs/commands/add.md @@ -2,9 +2,7 @@ Add one or more files to an existing MPQ archive. -The archive is always the first positional argument, followed by one or more files or directories. -All inputs are processed in a single open/close cycle, which is significantly faster than -calling `add` once per file. +The archive is always the first positional argument, followed by one or more files or directories. All inputs are processed in a single open/close cycle, which is significantly faster than calling `add` once per file. ## Add a single file @@ -27,8 +25,7 @@ $ mpqcli add wow-patch.mpq fth.txt fta.txt fts.txt ## Add files from stdin -Pass `-` as the file argument to read paths from standard input. This works with `find`, -`ls`, or any other tool that produces file paths. +Pass `-` as the file argument to read paths from standard input. This works with `find`, `ls`, or any other tool that produces file paths. ```bash $ find . -name "*.blp" | mpqcli add wow-patch.mpq - @@ -39,8 +36,7 @@ $ find . -name "*.blp" | mpqcli add wow-patch.mpq - ## Add a directory -Pass a directory path to recursively add all files within it. The directory structure is -preserved relative to the directory root. +Pass a directory path to recursively add all files within it. The directory structure is preserved relative to the directory root. ```bash $ mpqcli add wow-patch.mpq textures/ @@ -76,21 +72,17 @@ $ mpqcli add wow-patch.mpq textures/ --path textures ## Replacing files that already exist -`add` never replaces an archived file unless you ask it to. Two flags control what -happens when a file is already present, and they are **mutually exclusive**, passing -both is an error, because "replace everything" and "replace only what changed" are -contradictory requests. +`add` never replaces an archived file unless you ask it to. Two flags control what happens when a file is already present, and they are **mutually exclusive**, passing both is an error, because "replace everything" and "replace only what changed" are contradictory requests. | Flags | Behaviour when the file already exists | -| --- | --- | +|---|---| | *(neither)* | Skip it and leave the archived copy alone | | `-w`, `--overwrite` | Replace it unconditionally | | `-u`, `--update` | Replace it only if the local file differs | ### Default: skip -Without either flag, an existing file is skipped. This is a normal outcome, not an -error, so the exit code is still `0`: +Without either flag, an existing file is skipped. This is a normal outcome, not an error, so the exit code is still `0`: ```bash $ mpqcli add wow-patch.mpq allegiance.txt @@ -102,14 +94,13 @@ $ mpqcli add wow-patch.mpq allegiance.txt ```bash $ mpqcli add wow-patch.mpq allegiance.txt --overwrite -[+] File already exists in MPQ archive: allegiance.txt - Overwriting... +[~] File already exists in MPQ archive: allegiance.txt - Overwriting... [+] Adding file: allegiance.txt ``` ## Add with a locale -Use `--locale` to store the file under a specific locale. Files added without `--locale` -use the default locale. +Use `--locale` to store the file under a specific locale. Files added without `--locale` use the default locale. ```bash $ mpqcli add wow-patch.mpq allianz.txt --locale deDE @@ -127,34 +118,23 @@ $ mpqcli add archive.mpq khwhat1.wav --game warcraft2 ## Replace only what changed with --update -The `--update` flag replaces a file only when the local copy differs from the archived -one, and skips it otherwise. This is useful for incremental updates where only changed -files need to be re-added. It applies to single files and directories alike, the -comparison is always made per file. +The `--update` flag replaces a file only when the local copy differs from the archived one, and skips it otherwise. This is useful for incremental updates where only changed files need to be re-added. It applies to single files and directories alike, the comparison is always made per file. The skip decision follows this chain: 1. **File size** must match. If the sizes differ the file is always re-added. 2. If the sizes match, the archive's `(attributes)` file is consulted: - - **Timestamp** – if the archive stores file timestamps, the local file's - last-modification time is compared at one-second resolution. A match skips the file. - - **MD5** – if the timestamp did not match or is unavailable, and the archive stores MD5 - checksums, the MD5 of the local file is computed and compared. A match skips the file. - - **CRC32** – if neither timestamp nor MD5 produced a match or was available, and the - archive stores CRC32 checksums, those are compared. A match skips the file. - - **No attributes** – if the archive has no `(attributes)` file, the file is always - re-added even when sizes match, because no reliable content check is possible. - -Note: a timestamp match alone skips the file, without comparing checksums. A file whose -content changed but whose size and modification time were both preserved (for example by -`cp -p` or tools that restore timestamps) will therefore not be detected as changed. This -is the same trade-off tools like `rsync` make by default. If exact change detection -matters, use `--overwrite` instead of `--update` to unconditionally replace every file. + - **Timestamp**: if the archive stores file timestamps, the local file's last-modification time is compared at one-second resolution. A match skips the file. + - **MD5**: if the timestamp did not match or is unavailable, and the archive stores MD5 checksums, the MD5 of the local file is computed and compared. A match skips the file. + - **CRC32**: if neither timestamp nor MD5 produced a match or was available, and the archive stores CRC32 checksums, those are compared. A match skips the file. + - **No attributes**: if the archive has no `(attributes)` file, the file is always re-added even when sizes match, because no reliable content check is possible. + +Note: a timestamp match alone skips the file, without comparing checksums. A file whose content changed but whose size and modification time were both preserved (for example by `cp -p` or tools that restore timestamps) will therefore not be detected as changed. This is the same trade-off tools like `rsync` make by default. If exact change detection matters, use `--overwrite` instead of `--update` to unconditionally replace every file. ```bash $ mpqcli add wow-patch.mpq textures/ --update -[~] Skipping unchanged file: Creature\Bear\Bear.blp (MD5 matches) -[+] File already exists in MPQ archive: Creature\Wolf\Wolf.blp - Overwriting... +[*] Skipping unchanged file: Creature\Bear\Bear.blp (MD5 matches) +[~] File already exists in MPQ archive: Creature\Wolf\Wolf.blp - Overwriting... [+] Adding file: Creature\Wolf\Wolf.blp [*] For textures: 1 files added, 1 files skipped, 0 files failed. ``` @@ -163,7 +143,7 @@ The same comparison applies when the target is a single file: ```bash $ mpqcli add wow-patch.mpq allegiance.txt --update -[~] Skipping unchanged file: allegiance.txt (MD5 matches) +[*] Skipping unchanged file: allegiance.txt (MD5 matches) ``` Flow chart of update method: diff --git a/docs/commands/completion.md b/docs/commands/completion.md index 4b2639a..5b37a0a 100644 --- a/docs/commands/completion.md +++ b/docs/commands/completion.md @@ -2,7 +2,7 @@ Print a shell completion script to stdout. -## Supported Shells +## Supported shells - bash - zsh diff --git a/docs/commands/create.md b/docs/commands/create.md index cbdb31a..4ec1f68 100644 --- a/docs/commands/create.md +++ b/docs/commands/create.md @@ -10,7 +10,7 @@ Create an MPQ file from a target directory. Automatically adds `(listfile)` to t $ mpqcli create ``` -The default mode of operation for the `create` subcommand is to take everything from the "target" directory (and below) and recursively add it to the archive. The directory structure is retained. Windows-style backslash path separators are used (`\`), as per the observed behavior in most MPQ archives. +The default mode of operation for the `create` subcommand is to take everything from the "target" directory (and below) and recursively add it to the archive. The directory structure is retained. Windows-style backslash path separators are used (`\`), as per the observed behaviour in most MPQ archives. ## Create an MPQ archive for a specific game diff --git a/docs/contributing.md b/docs/contributing.md index e8c5810..b24cc96 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -2,65 +2,65 @@ Contributions are welcome. Please read the guidelines below before opening a pull request. -## Before You Start +## Before you start If you are unsure whether a feature fits the project, or whether an existing tool could already be combined with `mpqcli` to achieve the same result, open an issue first. This avoids wasted effort and keeps the project focused. **mpqcli follows the Unix philosophy.** The tool is designed to do one thing well and to compose with other tools via pipes and redirection. If you find yourself wanting to add functionality that could be handled by a separate tool - for example, sorting the output of `list` - the right answer is usually to pipe the output to that tool rather than adding it here. -## Prerequisites and Setup +## Prerequisites and setup Clone the repository and initialise submodules: -``` +```sh git clone https://github.com/thegraydot/mpqcli.git cd mpqcli git submodule update --init --recursive ``` -Install the clang lint tools: +Install the clang lint tools, pinned to the major version the checks are formatted against: -``` -make install_clang_tools +```sh +sudo apt-get install -y clang-18 clang-format-18 clang-tidy-18 ``` -## Makefile Reference +## Makefile reference Run `make help` to list all available targets. Common ones: -| Target | Description | -|----------------------------|--------------------------------------------------------------------| -| `make install_clang_tools` | Install clang-format and clang-tidy via apt | -| `make configure` | Configure cmake build with clang (required before `make lint`) | -| `make build_linux` | Build for Linux using cmake | -| `make build_windows` | Build for Windows using cmake | -| `make build_clean` | Remove the cmake build directory | -| `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | -| `make test_mpqcli` | Run the pytest test suite | -| `make lint` | Run all C++ linters (clang-format + clang-tidy) | -| `make fmt_check` | Check formatting only (dry run) | -| `make fmt` | Auto-fix formatting in-place | -| `make lint_cpp` | Run clang-tidy static analysis | -| `make clean` | Remove all build and test artifacts | - -## Requirements for a Pull Request +| Target | Description | +|---|---| +| `make configure` | Configure the cmake build (uses the default compiler) | +| `make build` | Build the project into build/dev | +| `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | +| `make test_mpqcli` | Run the pytest test suite | +| `make check_all` | Run every static check (clang-format, clang-tidy, completion scripts) | +| `make check_format` | Check formatting only (dry run) | +| `make format` | Auto-fix formatting in-place | +| `make configure_lint` | Configure build/lint with clang++ for clang-tidy | +| `make check_lint` | Run clang-tidy static analysis | +| `make check_embed` | Syntax-check the embedded completion scripts | +| `make clean` | Remove all build, test and docs artefacts | + +## Requirements for a pull request ### 1. Builds on your platform Make sure the project builds cleanly on your development machine before opening a PR: +```sh +make build ``` -make build_linux # Linux -make build_windows # Windows -``` -A PR automatically triggers the CI build workflow, which compiles and tests across all supported Linux targets (AMD64 and ARM64, glibc and musl). You are not expected to reproduce all of those locally. +On Windows without a POSIX shell, use the cmake commands from the building page instead. + +A PR automatically triggers the CI build workflow, which compiles and tests across all supported Linux targets (AMD64 and ARM64). You are not expected to reproduce all of those locally. ### 2. Tests pass Run the test suite before submitting: -``` +```sh make test_create_venv # first-time setup only make test_mpqcli ``` @@ -73,20 +73,19 @@ If your change adds or modifies user-facing functionality - such as a new subcom ### 4. Linting must pass -All C++ code is formatted with clang-format and analysed with clang-tidy. `clang-tidy` needs a compile database generated with clang, so run `make configure` first (`make build_linux`/`make build_windows` alone will not work, since they don't set up the compiler flags clang-tidy needs): +All C++ code is formatted with clang-format and analysed with clang-tidy, and the completion scripts are parsed by their shells. `make check_all` runs all three checks. It configures a clang build tree of its own for clang-tidy, so it needs no prior `make configure`: -``` -make configure -make lint +```sh +make check_all ``` If there are formatting violations, auto-fix them with: -``` -make fmt +```sh +make format ``` -Then re-run `make lint` to confirm everything passes. +Then re-run `make check_all` to confirm everything passes. ### 5. Match the existing code style @@ -115,25 +114,25 @@ if (flags & MPQ_FILE_COMPRESS) result += 'c'; // clang-format on ``` -## Known Design Constraints +## Known design constraints ### StormLib locale state is global and not thread-safe -`SFileSetLocale` sets a process-wide locale variable (`g_lcFileLocale`) inside StormLib. All locale-sensitive operations in `mpq.cpp` - file open, add, remove, read, extract, and list - call `SFileSetLocale` immediately before the relevant StormLib call. There is no locale-explicit alternative in StormLib's public API (`SFileOpenFileEx`, `SFileAddFileEx`, etc. all read `g_lcFileLocale` internally). +`SFileSetLocale` sets a process-wide locale variable (`g_lcFileLocale`) inside StormLib. All locale-sensitive operations in `src/mpq/` - file open, add, remove, read, extract, and list - call `SFileSetLocale` immediately before the relevant StormLib call. There is no locale-explicit alternative in StormLib's public API (`SFileOpenFileEx`, `SFileAddFileEx`, etc. all read `g_lcFileLocale` internally). This means: - The `SFileSetLocale` + StormLib-call sequence is **not atomic** and would be unsafe under concurrency -- mpqcli is intentionally **single-threaded**; do not introduce threads or async I/O without auditing every locale-sensitive call site in `mpq.cpp` +- mpqcli is intentionally **single-threaded**; do not introduce threads or async I/O without auditing every locale-sensitive call site in `src/mpq/` If you add a new StormLib call that is locale-sensitive, follow the existing pattern: call `SFileSetLocale` immediately before it, with no intervening calls between the two. -## Workflow Summary +## Workflow summary 1. Fork the repository and create a branch for your change 2. Run `git submodule update --init --recursive` after cloning -3. Run `make install_clang_tools` to install lint dependencies -4. Make your changes and verify they build: `make build_linux` -5. Run `make configure` and then `make lint`, fixing any issues +3. Install the clang tools as shown above +4. Make your changes and verify they build: `make build` +5. Run `make check_all`, fixing any issues 6. Run `make test_mpqcli` and confirm all tests pass 7. Open a pull request with a clear description of what was changed and why diff --git a/docs/installation.md b/docs/installation.md index d2b4584..73f2b56 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,24 +1,27 @@ # Installation -## Precompiled Binaries +## Precompiled binaries Pre-built binaries are available for Linux and Windows. Linux/WSL: ```bash -$ curl -fsSL https://raw.githubusercontent.com/thegraydot/mpqcli/main/scripts/install.sh | bash +$ curl -fsSL https://github.com/thegraydot/mpqcli/releases/latest/download/install.sh | bash ``` Microsoft Windows: ```powershell -PS> irm https://raw.githubusercontent.com/thegraydot/mpqcli/main/scripts/install.ps1 | iex +PS> Invoke-WebRequest -Uri https://github.com/thegraydot/mpqcli/releases/latest/download/install.ps1 -OutFile install.ps1 +PS> .\install.ps1 ``` +Both installers put `mpqcli` on the system path and may ask for elevation; pass `--user` on Linux or `-User` on Windows to install for the current user instead. Each release also ships `checksums.txt`, signed with cosign: the installer verifies the SHA-256 of the binary always, and the signature when cosign is installed. + Check the [latest release with binaries](https://github.com/thegraydot/mpqcli/releases). -## Docker Image +## Docker image The Docker image for `mpqcli` is hosted on [GitHub Container Registry (GHCR)](https://ghcr.io). It provides a lightweight and portable way to use `mpqcli` without needing to build or download a binary. @@ -31,11 +34,25 @@ $ docker pull ghcr.io/thegraydot/mpqcli:latest You can run `mpqcli` commands directly using the Docker container. For example: ```bash -$ docker run ghcr.io/thegraydot/mpqcli:latest version +$ docker run --rm ghcr.io/thegraydot/mpqcli:latest version +``` + +To use local files, mount a directory from your host system at `/data`. That is the container's working directory, so paths are relative to the mounted directory. The following example lists `example.mpq` from the present working directory: + +```bash +$ docker run --rm -v "$PWD":/data ghcr.io/thegraydot/mpqcli:latest list example.mpq ``` -To use local files in the container, mount a directory from your host system. In the following example, the `-v` argument is used to mount the present working directory to `/data` directory in the container. Then the `mpqcli` container runs the `list` subcommand with `/data/example.mpq` as the target MPQ archive. +The container runs as root, so a file it creates or extracts is owned by root on the host. To have the output owned by you, run the container as your own user: ```bash -$ docker run -v $(pwd):/data ghcr.io/thegraydot/mpqcli:latest list /data/example.mpq +$ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/data ghcr.io/thegraydot/mpqcli:latest create -o example.mpq files/ ``` + +A shell function makes that the default for everyday use: + +```bash +mpqcli() { docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/data ghcr.io/thegraydot/mpqcli:latest "$@"; } +``` + +Under rootless Docker or Podman the container's root already maps to your own user, so run without `--user` there; on Podman, `--userns=keep-id` is the equivalent of the flag above. diff --git a/docs/introduction.md b/docs/introduction.md index c0a4e1f..46978de 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -2,7 +2,7 @@ A command-line tool to create, add, remove, list, extract, read, rename, verify, and compact MPQ archives using the [StormLib library](https://github.com/ladislav-zezula/StormLib). -> ⚠️ **Warning:** This project is under active development and will change functionality between released versions until version 1.0.0. +> **Warning:** This project is under active development and will change functionality between released versions until version 1.0.0. ## Overview diff --git a/scripts/install.ps1 b/scripts/install.ps1 deleted file mode 100644 index 11a440f..0000000 --- a/scripts/install.ps1 +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env pwsh - -param( - [string]$Version = "", - [string]$InstallDir = "$env:LOCALAPPDATA\Programs\mpqcli", - [switch]$Help -) - -$ErrorActionPreference = "Stop" -$ProgressPreference = "SilentlyContinue" - -$BinaryName = "mpqcli-windows-amd64.exe" -$Repo = "thegraydot/mpqcli" -$BaseUrl = "https://github.com/$Repo/releases" - -function Write-Info { - param($Message) - Write-Host "[INFO] " -ForegroundColor Green -NoNewline - Write-Host $Message -} - -function Write-Warn { - param($Message) - Write-Host "[WARN] " -ForegroundColor Yellow -NoNewline - Write-Host $Message -} - -function Write-Error-And-Exit { - param($Message) - Write-Host "[ERROR] " -ForegroundColor Red -NoNewline - Write-Host $Message - exit 1 -} - -function Get-LatestVersion { - try { - $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" - $version = $release.tag_name -replace 'v', '' - return $version - } catch { - Write-Error-And-Exit "Failed to get latest version: $_" - } -} - -function Get-Platform { - $arch = [System.Environment]::Is64BitOperatingSystem - if ($arch) { - $platform = "x86_64-pc-windows-msvc" - } else { - Write-Error-And-Exit "32-bit Windows is not supported" - } - - # Check for ARM64 - $processorArch = $env:PROCESSOR_ARCHITECTURE - if ($processorArch -eq "ARM64") { - Write-Error-And-Exit "ARM64 Windows is not supported" - } - - return $platform -} - -function Download-File { - param( - [string]$Url, - [string]$OutFile - ) - - try { - Write-Info "Downloading from $Url" - Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing - } catch { - if ($_.Exception.Response.StatusCode -eq 404) { - return $false - } - Write-Error-And-Exit "Failed to download: $_" - } - return $true -} - -function Install-MPQCLI { - param( - [string]$Version, - [string]$InstallDir - ) - - # Get version if not specified - if ([string]::IsNullOrEmpty($Version)) { - Write-Info "Fetching latest version..." - $Version = Get-LatestVersion - } - - Write-Info "Installing mpqcli v$Version" - - # Detect platform - $platform = Get-Platform - Write-Info "Detected platform: $platform" - - # Create temp directory - $tempDir = New-TemporaryFile | ForEach-Object { Remove-Item $_; New-Item -ItemType Directory -Path $_ } - - try { - # Download files - $fileName = "$BinaryName-windows.exe" - $downloadUrl = "$BaseUrl/download/v$Version/$fileName" - - if (-not (Download-File -Url $downloadUrl -OutFile $fileName)) { - Write-Error-And-Exit "Failed to download binary" - } - - # Create install directory - if (-not (Test-Path $InstallDir)) { - New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null - } - - # Install binary - $binaryName = "$BinaryName.exe" - $sourcePath = Join-Path $tempDir $binaryName - $destPath = Join-Path $InstallDir $binaryName - - if (Test-Path $sourcePath) { - Write-Info "Installing to $destPath" - Copy-Item -Path $sourcePath -Destination $destPath -Force - } else { - Write-Error-And-Exit "Binary not found in archive" - } - - # Verify installation - try { - $testOutput = & $destPath --version 2>&1 - Write-Info "Installation successful!" - Write-Info "Binary installed to: $destPath" - Write-Info "Version: $testOutput" - } catch { - Write-Error-And-Exit "Installation verification failed" - } - - # Check PATH - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - if ($userPath -notlike "*$InstallDir*") { - Write-Warn "$InstallDir is not in your PATH" - Write-Info "To add it to PATH, run:" - Write-Host ' [Environment]::SetEnvironmentVariable("Path", $env:Path + ";' + $InstallDir + '", "User")' - Write-Info "Or restart your terminal after running:" - Write-Host " setx PATH `"%PATH%;$InstallDir`"" - } else { - Write-Info "$BinaryName is available in your PATH" - } - - } finally { - # Cleanup - Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue - } -} - -# Main -if ($Help) { - Write-Host @" -Install script for mpqcli - -USAGE: - install.ps1 [-Version ] [-InstallDir ] [-Help] - -OPTIONS: - -Version Install specific version (default: latest) - -InstallDir Installation directory (default: $env:LOCALAPPDATA\Programs\mpqcli) - -Help Show this help message - -EXAMPLES: - # Install latest version - .\install.ps1 - - # Install specific version - .\install.ps1 -Version 0.8.0 - - # Install to custom directory - .\install.ps1 -InstallDir C:\Tools\mpqcli -"@ - exit 0 -} - -Install-MPQCLI -Version $Version -InstallDir $InstallDir diff --git a/scripts/install.sh b/scripts/install.sh deleted file mode 100755 index b242d26..0000000 --- a/scripts/install.sh +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -REPO="thegraydot/mpqcli" -BASE_URL="https://github.com/${REPO}/releases" - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -info() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -warn() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -error() { - echo -e "${RED}[ERROR]${NC} $1" - exit 1 -} - -# Detect OS and architecture -detect_platform() { - local os arch lib - - # Detect OS - case "$(uname -s)" in - Linux*) os="linux";; - *) error "Unsupported operating system: $(uname -s)";; - esac - - # Detect architecture - case "$(uname -m)" in - x86_64|amd64) arch="amd64";; - aarch64|arm64) arch="arm64";; - *) error "Unsupported architecture: $(uname -m)";; - esac - - # Determine if we should use glibc or musl - # Priority: Try glibc first (if available), fallback to musl - - # Check if we have ldd command - if command -v ldd >/dev/null 2>&1; then - # Check for a musl-based system (like Alpine) - if ldd --version 2>&1 | grep -q musl; then - lib="musl" - # Check for a glibc-based system (like Ubuntu, Debian etc.) - elif ldd --version 2>&1 | grep -q "GNU\|GLIBC"; then - lib="glibc" - fi - # If ldd is not available, check for common glibc files - elif [[ -f /lib/x86_64-linux-gnu/libc.so.6 ]] || \ - [[ -f /lib64/libc.so.6 ]] || \ - [[ -f /usr/lib/libc.so.6 ]]; then - lib="glibc" - else - # If ldd is not available or cannot find glibc - # default to musl for compatibility - lib="musl" - fi - - echo "${os}-${arch}-${lib}" -} - -# Download file -download_file() { - local version="$1" - local platform="$2" - local filename="mpqcli-${platform}" - local download_url="${BASE_URL}/download/v${version}/${filename}" - - info "Downloading from: $download_url" - - if command -v curl >/dev/null 2>&1; then - if curl -fsSL "$download_url" -o "mpqcli"; then - return 0 - fi - elif command -v wget >/dev/null 2>&1; then - if wget -q "$download_url" -O "mpqcli"; then - return 0 - fi - else - error "Neither curl nor wget found. Please install one of them." - fi - - error "Failed to download binary for platform: $platform" -} - -# Get latest release version -get_latest_version() { - local version - version=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ - | grep '"tag_name":' \ - | sed -E 's/.*"v([^"]+)".*/\1/') - - if [[ -z "$version" ]]; then - error "Failed to get latest version" - fi - - echo "$version" -} - -# Main installation function -install() { - local version="${1:-}" - local install_dir="${INSTALL_DIR:-/usr/local/bin}" - - # Get version if not specified - if [[ -z "$version" ]]; then - info "Fetching latest version..." - version=$(get_latest_version) - fi - - info "Installing mpqcli ${version}" - - # Detect platform - local platform - platform=$(detect_platform) - - # Show appropriate message based on detected platform - if [[ "$platform" == *"-glibc" ]]; then - info "System appears compatible with GLIBC binaries" - else - info "Using MUSL binary for better compatibility" - fi - - info "Detected platform: ${platform}" - - # Download binar - download_file "$version" "$platform" - - # Ensure binary is executable - if [[ -f "mpqcli" ]]; then - info "Making binary executable..." - chmod +x "mpqcli" - else - error "Binary mpqcli not found after download" - fi - - # Install binary - info "Installing to $install_dir" - warn "You may need to enter your password..." - sudo mv "mpqcli" "$install_dir/" - - # Verify installation - if "${install_dir}/mpqcli" version >/dev/null 2>&1; then - info "Installation successful!" - info "Binary installed to: ${install_dir}/mpqcli" - - # Check if install_dir is in PATH - if ! echo "$PATH" | grep -q "$install_dir"; then - warn "${install_dir} is not in your PATH" - warn "Add it to your PATH by adding this to your shell configuration:" - warn " export PATH=\"${install_dir}:\$PATH\"" - fi - else - error "Installation verification failed" - fi -} - -# Show help -show_help() { - cat << EOF -Install script for mpqcli - -USAGE: - $0 [OPTIONS] [VERSION] - -OPTIONS: - -h, --help Show this help message - -d, --dir DIR Install directory (default: \$HOME/.local/bin) - -t, --tag TAG Install specific release tag - -ENVIRONMENT VARIABLES: - INSTALL_DIR Override default install directory - -EXAMPLES: - # Install latest version - $0 - - # Install specific version - $0 0.8.0 - - # Install to custom directory - INSTALL_DIR=$HOME/.local/bin $0 - - # Install specific tag - $0 --tag v0.8.0 -EOF -} - -# Parse command line arguments -main() { - local version="" - local tag="" - - while [[ $# -gt 0 ]]; do - case $1 in - -h|--help) - show_help - exit 0 - ;; - -d|--dir) - INSTALL_DIR="$2" - shift 2 - ;; - -t|--tag) - tag="$2" - shift 2 - ;; - -*) - error "Unknown option: $1" - ;; - *) - version="$1" - shift - ;; - esac - done - - # Extract version from tag if provided - if [[ -n "$tag" ]]; then - version="${tag#v}" - fi - - install "$version" -} - -# Run main function -main "$@" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c6c5495..61ece65 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,51 +1,80 @@ -# Configure version header -configure_file(mpqcli.h.in "${CMAKE_BINARY_DIR}/mpqcli.h") - -# Embed completion scripts into generated headers. -# Declare the script files as configure-time dependencies so CMake re-generates -# completion_data.h whenever the bash or zsh source scripts are edited. -set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS - "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.bash" - "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.zsh" - "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.ps1" - "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.fish" +# The warning bar, defined once and linked privately by every target the project +# owns. -Wsign-conversion is spelled out because clang folds it into -Wconversion +# and GCC does not; left implicit, the two compilers hold different bars for the +# same source. The vendored targets deliberately do not link this. +add_library(mpqcli_warnings INTERFACE) +target_compile_options(mpqcli_warnings INTERFACE + $<$:/W4 /permissive-> + $<$>:-Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Wshadow -Wnon-virtual-dtor -Wold-style-cast> ) -file(READ "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.bash" BASH_COMPLETION_SCRIPT) -file(READ "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.zsh" ZSH_COMPLETION_SCRIPT) -file(READ "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.ps1" PS_COMPLETION_SCRIPT) -file(READ "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.fish" FISH_COMPLETION_SCRIPT) -configure_file(completion_data.h.in "${CMAKE_BINARY_DIR}/completion_data.h") - -# Set output directory for the executable -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") - -# Windows specific settings -if (MSVC) - add_compile_definitions(__STORMLIB_NO_STATIC_LINK__) - set(CMAKE_GENERATOR_PLATFORM x64) + +if(MPQCLI_WERROR) + target_compile_options(mpqcli_warnings INTERFACE + $<$:/WX> + $<$>:-Werror> + ) endif() -# Create the main executable -add_executable(mpqcli - main.cpp - commands.cpp - completion.cpp - mpq.cpp - helpers.cpp - locales.cpp - gamerules.cpp -) +add_library(mpqcli::warnings ALIAS mpqcli_warnings) -# Add dependencies -add_dependencies(mpqcli storm) +# Generated into the include tree the core exports, so app/ reaches it through the +# core's PUBLIC include directory rather than a second include path +configure_file( + "${PROJECT_SOURCE_DIR}/cmake/version.h.in" + "${PROJECT_BINARY_DIR}/include/mpqcli/version.h" + @ONLY +) -# Include directories -target_include_directories(mpqcli SYSTEM PRIVATE - "${CMAKE_SOURCE_DIR}/extern/StormLib/src" +# Internal to this repository, so no include/. Keeping the logic in a library +# rather than beside main() is what lets test binaries link it; a test that +# recompiles the sources instead is a second build that drifts from the +# shipped one. +add_library(mpqcli_core STATIC + commands/add.cpp + commands/compact.cpp + commands/create.cpp + commands/extract.cpp + commands/info.cpp + commands/list.cpp + commands/read.cpp + commands/remove.cpp + commands/rename.cpp + commands/verify.cpp + mpq/add.cpp + mpq/archive.cpp + mpq/compact.cpp + mpq/extract.cpp + mpq/flags.cpp + mpq/info.cpp + mpq/list.cpp + mpq/query.cpp + mpq/read.cpp + mpq/remove.cpp + mpq/rename.cpp + mpq/verify.cpp + gamerules/profile_data.cpp + gamerules/profiles.cpp + gamerules/rules.cpp + util/capacity.cpp + util/format.cpp + util/hash.cpp + util/locales.cpp + util/path.cpp + util/string.cpp ) -target_include_directories(mpqcli PRIVATE - "${CMAKE_BINARY_DIR}" + +add_dependencies(mpqcli_core storm) + +# PUBLIC so app/ and the test binaries can include these headers by name +target_include_directories(mpqcli_core PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}" + "${PROJECT_BINARY_DIR}/include" ) -# Link libraries -target_link_libraries(mpqcli PRIVATE storm CLI11::CLI11 hash-library) +target_link_libraries(mpqcli_core PUBLIC storm mpqcli::hash_library) +target_link_libraries(mpqcli_core PRIVATE mpqcli::warnings) + +# Nothing outside this repository links the core. The alias is for the +# configure-time check: a typo in mpqcli::core fails now, where a typo in a raw +# mpqcli_core is taken for a system library and fails at link time instead. +add_library(mpqcli::core ALIAS mpqcli_core) diff --git a/src/commands.cpp b/src/commands.cpp deleted file mode 100644 index e19415a..0000000 --- a/src/commands.cpp +++ /dev/null @@ -1,445 +0,0 @@ -#include "commands.h" - -#include -#include -#include -#include -#include - -#include - -#include "gamerules.h" -#include "helpers.h" -#include "locales.h" -#include "mpq.h" -#include "mpqcli.h" - -namespace fs = std::filesystem; - -std::string ResolveArchiveName(const std::string &f, const std::optional &path, - const bool treat_as_directory = false) { - fs::path file_path = path.value_or(fs::path(f).filename().u8string()); - if (treat_as_directory) { - const std::string filename = fs::path(f).filename().u8string(); - file_path = path.value_or("") / fs::path(filename); - } - return WindowsifyFilePath(file_path); -} - -int HandleVersion() { - std::cout << MPQCLI_VERSION << "-" << GIT_COMMIT_HASH << std::endl; - return 0; -} - -int HandleAbout() { - std::cout << "Name: mpqcli" << std::endl; - std::cout << "Version: " << MPQCLI_VERSION << "-" << GIT_COMMIT_HASH << std::endl; - std::cout << "Author: Thomas Laurenson" << std::endl; - std::cout << "License: MIT" << std::endl; - std::cout << "GitHub: https://github.com/thegraydot/mpqcli" << std::endl; - std::cout << "Dependencies:" << std::endl; - std::cout << " - StormLib (https://github.com/ladislav-zezula/StormLib)" << std::endl; - std::cout << " - CLI11 (https://github.com/CLIUtils/CLI11)" << std::endl; - std::cout << " - hash-library (https://github.com/stbrumme/hash-library)" << std::endl; - return 0; -} - -int HandleInfo(const std::string &target, const std::optional &property) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } - PrintMpqInfo(archive, property); - CloseMpqArchive(archive); - return 0; -} - -int HandleCreate(const std::string &target, const std::optional &path, - const std::optional &output, bool sign_archive, - const std::optional &locale, - const std::optional &game_profile, int32_t mpq_version, - int64_t stream_flags, int64_t sector_size, int64_t raw_chunk_size, - int64_t file_flags1, int64_t file_flags2, int64_t file_flags3, int64_t attr_flags, - int64_t file_flags, int64_t file_compression, int64_t file_compression_next) { - std::error_code ec; - fs::path output_file_path; - if (output.has_value()) { - output_file_path = fs::absolute(output.value(), ec); - if (ec) { - std::cerr << "[!] Failed to resolve output path: (" << ec.value() << ") " - << ec.message() << ": " << output.value() << std::endl; - return 1; - } - } else { - output_file_path = fs::path(target); - // If the path ends with a separator (e.g. "dir/"), strip the - // trailing separator first so we get "dir.mpq" - if (output_file_path.filename().empty()) { - output_file_path = output_file_path.parent_path(); - } - output_file_path.replace_extension(".mpq"); - } - std::string output_file = output_file_path.u8string(); - - GameProfile profile; - if (game_profile.has_value()) { - profile = GameRules::StringToProfile(game_profile.value()); - } else { - profile = GameRules::GetDefaultProfile(); - } - GameRules game_rules(profile); - - std::cout << "[*] Game profile: " << game_profile.value_or("default") - << ", Output file: " << output_file << std::endl; - - if (mpq_version > 0) { - mpq_version--; // We label versions 1-4, but StormLib uses 0-3 - } - - // Apply MpqCreateSettings overrides if provided - MpqCreateSettingsOverrides overrides; - if (mpq_version >= 0) - overrides.mpq_version = static_cast(mpq_version); - if (stream_flags >= 0) - overrides.stream_flags = static_cast(stream_flags); - if (file_flags1 >= 0) - overrides.file_flags1 = static_cast(file_flags1); - if (file_flags2 >= 0) - overrides.file_flags2 = static_cast(file_flags2); - if (file_flags3 >= 0) - overrides.file_flags3 = static_cast(file_flags3); - if (attr_flags >= 0) - overrides.attr_flags = static_cast(attr_flags); - if (sector_size >= 0) - overrides.sector_size = static_cast(sector_size); - if (raw_chunk_size >= 0) - overrides.raw_chunk_size = static_cast(raw_chunk_size); - game_rules.OverrideCreateSettings(overrides); - - // List the files up front: the archive's max file count is fixed at creation - std::vector files; - const bool is_directory = fs::is_directory(target, ec); - if (is_directory) { - files = ListFilesRecursive(target, ec); - if (ec) { - std::cerr << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() - << ": " << target << std::endl; - return 1; - } - } else if (!fs::is_regular_file(target, ec)) { - std::cerr << "[!] Not a file or directory: " << target << std::endl; - return 1; - } - const uint32_t file_count = - CalculateMpqMaxFileValue(is_directory ? static_cast(files.size()) : 1); - - // Create the MPQ archive and add files - int result = 0; - HANDLE archive = CreateMpqArchive(output_file, file_count, game_rules); - if (archive) { - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - - // Apply AddFileSettings overrides if provided - CompressionSettingsOverrides add_overrides; - if (file_flags >= 0) - add_overrides.flags = static_cast(file_flags); - if (file_compression >= 0) - add_overrides.compression = static_cast(file_compression); - if (file_compression_next >= 0) - add_overrides.compression_next = static_cast(file_compression_next); - - if (is_directory) { - const std::string prefix = path.value_or(""); - result |= AddFiles(archive, files, target, prefix, lcid, game_rules, add_overrides); - } else { - std::string archive_path = ResolveArchiveName(target, path); - result |= AddFile(archive, target, archive_path, lcid, game_rules, add_overrides); - } - - if (sign_archive) { - SignMpqArchive(archive); - } - CloseMpqArchive(archive); - } else { - std::cerr << "[!] Failed to create MPQ archive." << std::endl; - return 1; - } - - return result; -} - -int HandleAdd(const std::vector &files, const std::string &target, - const std::optional &path, bool overwrite, bool update, - const std::optional &locale, - const std::optional &game_profile, int64_t file_flags, - int64_t file_compression, int64_t file_compression_next) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, 0)) { - return 1; - } - - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - - GameProfile profile; - if (game_profile.has_value()) { - profile = GameRules::StringToProfile(game_profile.value()); - std::cout << "[*] Using game profile: " << game_profile.value() << std::endl; - } else { - profile = GameRules::GetDefaultProfile(); - } - GameRules game_rules(profile); - - CompressionSettingsOverrides add_overrides; - if (file_flags >= 0) - add_overrides.flags = static_cast(file_flags); - if (file_compression >= 0) - add_overrides.compression = static_cast(file_compression); - if (file_compression_next >= 0) - add_overrides.compression_next = static_cast(file_compression_next); - - std::error_code ec; - bool has_directory = false; - for (const auto &f : files) { - if (fs::is_directory(f, ec)) { - has_directory = true; - break; - } - } - - int result = 0; - int files_skipped = 0; - for (const auto &f : files) { - if (!fs::exists(f, ec)) { - std::cerr << "[!] Path does not exist: " << f << std::endl; - result |= 1; - continue; - } - - if (fs::is_directory(f, ec)) { - std::vector directory_files = ListFilesRecursive(f, ec); - if (ec) { - std::cerr << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() - << ": " << f << std::endl; - result |= 1; - continue; - } - std::string prefix = path.value_or(""); - result |= AddFiles(archive, directory_files, f, prefix, lcid, game_rules, add_overrides, - overwrite, update, &files_skipped); - - } else if (fs::is_regular_file(f, ec)) { - const bool treat_as_directory = has_directory || files.size() > 1; - std::string archive_path = ResolveArchiveName(f, path, treat_as_directory); - result |= AddFile(archive, f, archive_path, lcid, game_rules, add_overrides, overwrite, - update, &files_skipped); - - } else { - std::cerr << "[!] Not a file or directory: " << f << std::endl; - result |= 1; - } - } - - // Skipping pre-existing files is the default, so point at the flags that change it - // rather than letting the run look like it silently did nothing. - if (!overwrite && !update && files_skipped > 0) { - std::cerr << "[*] " << files_skipped - << " file(s) already in the archive were skipped. Use --overwrite to replace " - "them, or --update to replace only the ones that changed." - << std::endl; - } - - CloseMpqArchive(archive); - return result; -} - -int HandleRemove(const std::vector &files, const std::string &target, - const std::optional &locale) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, 0)) { - return 1; - } - - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - std::unordered_set seen; - int overall_result = 0; - for (const auto &f : files) { - if (!seen.insert(f).second) { - continue; - } - int result = RemoveFile(archive, f, lcid); - if (result != 0) { - overall_result = result; - } - } - CloseMpqArchive(archive); - return overall_result; -} - -int HandleRename(const std::string &old_file, const std::string &new_file, - const std::string &target, const std::optional &locale) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, 0)) { - return 1; - } - - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - int result = RenameFile(archive, old_file, new_file, lcid); - CloseMpqArchive(archive); - return result; -} - -int HandleList(const std::string &target, const std::optional &listfile_name, - bool list_all, bool list_detailed, const std::vector &properties) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } - ListFiles(archive, listfile_name, list_all, list_detailed, properties); - CloseMpqArchive(archive); - return 0; -} - -int HandleExtract(const std::string &target, const std::optional &output, - const std::optional &file, bool keep_folder_structure, - const std::optional &listfile_name, - const std::optional &locale) { - // If no output directory specified, use MPQ path without extension - // If output directory specified, create it if it doesn't exist - std::error_code ec; - std::string effective_output; - if (!output.has_value()) { - fs::path target_path = fs::absolute(target, ec); - if (ec) { - std::cerr << "[!] Failed to resolve archive path: (" << ec.value() << ") " - << ec.message() << ": " << target << std::endl; - return 1; - } - effective_output = (target_path.parent_path() / target_path.stem()).u8string(); - } else { - effective_output = output.value(); - } - fs::create_directory(effective_output, ec); - if (ec) { - std::error_code query_ec; - if (!fs::is_directory(effective_output, query_ec)) { - std::cerr << "[!] Failed to create output directory: (" << ec.value() << ") " - << ec.message() << ": " << effective_output << std::endl; - return 1; - } - } - - // ExtractFile can only check for symlink traversal where the OS can resolve - // real paths; warn once up front on volumes where it cannot (RAM disks) - static_cast(fs::canonical(effective_output, ec)); - if (ec) { - std::cout << "[!] Warning: Output directory cannot be fully resolved, symlinks will not " - "be checked during extraction: " - << effective_output << std::endl; - } - - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } - - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - if (locale.has_value() && lcid == default_locale) { - std::cout << "[!] Warning: The locale '" << locale.value() - << "' is unknown. Will use default locale instead." << std::endl; - } - - int result; - if (file.has_value()) { - result = ExtractFile(archive, effective_output, file.value(), keep_folder_structure, lcid); - } else { - result = ExtractFiles(archive, effective_output, listfile_name, lcid); - } - CloseMpqArchive(archive); - - if (result != 0) { - std::cerr << std::endl << "[!] Failed to extract all files." << std::endl; - } - return result; -} - -int HandleRead(const std::string &file, const std::string &target, - const std::optional &locale) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } - - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - if (locale.has_value() && lcid == default_locale) { - std::cout << "[!] Warning: The locale '" << locale.value() - << "' is unknown. Will use default locale instead." << std::endl; - } - - uint32_t file_size; - auto file_content = ReadFile(archive, file.c_str(), &file_size, lcid); - if (!file_content) { - CloseMpqArchive(archive); - return 1; - } - - PrintAsBinary(file_content.get(), file_size); - - CloseMpqArchive(archive); - return 0; -} - -int HandleVerify(const std::string &target, bool print_signature) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } - - int result; - switch (VerifyMpqArchive(archive)) { - case ERROR_WEAK_SIGNATURE_OK: - case ERROR_STRONG_SIGNATURE_OK: - if (print_signature) { - // If printing the signature, don't print success message - // because the user might want to pipe/redirect the signature data - PrintMpqSignature(archive, target); - } else { - std::cout << "[*] Verify success" << std::endl; - } - result = 0; - break; - - case ERROR_WEAK_SIGNATURE_ERROR: - case ERROR_STRONG_SIGNATURE_ERROR: - if (print_signature) { - // Print the (invalid) signature bytes for forensic inspection, - // but still fail: the archive content no longer matches it - PrintMpqSignature(archive, target); - } - std::cerr << "[!] Verify failed: signature is present but invalid" << std::endl; - result = 1; - break; - - case ERROR_NO_SIGNATURE: - std::cerr << "[!] Verify failed: archive has no signature" << std::endl; - result = 1; - break; - - default: // ERROR_VERIFY_FAILED or any other value - std::cerr << "[!] Verify failed" << std::endl; - result = 1; - break; - } - CloseMpqArchive(archive); - return result; -} - -int HandleCompact(const std::string &target, const std::optional &listfile_name) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, 0)) { - return 1; - } - - const int result = CompactMpqArchive(archive, listfile_name); - CloseMpqArchive(archive); - return result; -} diff --git a/src/commands.h b/src/commands.h deleted file mode 100644 index 27bb95f..0000000 --- a/src/commands.h +++ /dev/null @@ -1,39 +0,0 @@ -#ifndef COMMANDS_H -#define COMMANDS_H - -#include -#include -#include -#include - -int HandleVersion(); -int HandleAbout(); -int HandleInfo(const std::string &target, const std::optional &property); -int HandleCreate(const std::string &target, const std::optional &path, - const std::optional &output, bool sign_archive, - const std::optional &locale, - const std::optional &game_profile, int32_t mpq_version, - int64_t stream_flags, int64_t sector_size, int64_t raw_chunk_size, - int64_t file_flags1, int64_t file_flags2, int64_t file_flags3, int64_t attr_flags, - int64_t file_flags, int64_t file_compression, int64_t file_compression_next); -int HandleAdd(const std::vector &files, const std::string &target, - const std::optional &path, bool overwrite, bool update, - const std::optional &locale, - const std::optional &game_profile, int64_t file_flags, - int64_t file_compression, int64_t file_compression_next); -int HandleRemove(const std::vector &files, const std::string &target, - const std::optional &locale); -int HandleRename(const std::string &old_file, const std::string &new_file, - const std::string &target, const std::optional &locale); -int HandleList(const std::string &target, const std::optional &listfile_name, - bool list_all, bool list_detailed, const std::vector &properties); -int HandleExtract(const std::string &target, const std::optional &output, - const std::optional &file, bool keep_folder_structure, - const std::optional &listfile_name, - const std::optional &locale); -int HandleRead(const std::string &file, const std::string &target, - const std::optional &locale); -int HandleVerify(const std::string &target, bool print_signature); -int HandleCompact(const std::string &target, const std::optional &listfile_name); - -#endif // COMMANDS_H diff --git a/src/commands/add.cpp b/src/commands/add.cpp new file mode 100644 index 0000000..3a89e98 --- /dev/null +++ b/src/commands/add.cpp @@ -0,0 +1,96 @@ +#include "commands/add.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "errors.h" +#include "gamerules/rules.h" +#include "mpq/add.h" +#include "mpq/archive.h" +#include "util/locales.h" +#include "util/path.h" + +namespace fs = std::filesystem; + +namespace mpqcli { + +bool Add(const AddOptions &options, std::ostream &err, const std::atomic &cancelled) { + Archive archive = Archive::Open(options.archive, 0); + + LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; + + GameProfile profile; + if (options.game_profile.has_value()) { + profile = GameRules::StringToProfile(options.game_profile.value()); + err << "[*] Using game profile: " << options.game_profile.value() << std::endl; + } else { + profile = GameRules::GetDefaultProfile(); + } + GameRules game_rules(profile); + + std::error_code ec; + bool has_directory = false; + for (const auto &f : options.files) { + if (fs::is_directory(f, ec)) { + has_directory = true; + break; + } + } + + int result = 0; + int files_skipped = 0; + for (const auto &f : options.files) { + ThrowIfCancelled(cancelled); + + if (!fs::exists(f, ec)) { + err << "[!] Path does not exist: " << f.string() << std::endl; + result |= 1; + continue; + } + + if (fs::is_directory(f, ec)) { + std::vector directory_files = ListFilesRecursive(f, ec); + if (ec) { + err << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() + << ": " << f.string() << std::endl; + result |= 1; + continue; + } + std::string prefix = options.path.value_or(""); + result |= AddFiles(archive.Handle(), directory_files, f, prefix, lcid, game_rules, err, + cancelled, options.compression_overrides, options.overwrite, + options.update, &files_skipped); + + } else if (fs::is_regular_file(f, ec)) { + const bool treat_as_directory = has_directory || options.files.size() > 1; + std::string archive_path = ResolveArchiveName(f, options.path, treat_as_directory); + result |= AddFile(archive.Handle(), f, archive_path, lcid, game_rules, err, + options.compression_overrides, options.overwrite, options.update, + &files_skipped); + + } else { + err << "[!] Not a file or directory: " << f.string() << std::endl; + result |= 1; + } + } + + // Skipping pre-existing files is the default, so point at the flags that change it + // rather than letting the run look like it silently did nothing. + if (!options.overwrite && !options.update && files_skipped > 0) { + err << "[*] " << files_skipped + << " file(s) already in the archive were skipped. Use --overwrite to replace " + "them, or --update to replace only the ones that changed." + << std::endl; + } + + archive.Close(); + return result == 0; +} + +} // namespace mpqcli diff --git a/src/commands/add.h b/src/commands/add.h new file mode 100644 index 0000000..3d337b1 --- /dev/null +++ b/src/commands/add.h @@ -0,0 +1,32 @@ +#ifndef COMMANDS_ADD_H +#define COMMANDS_ADD_H + +#include +#include +#include +#include +#include +#include + +#include "gamerules/settings.h" + +namespace mpqcli { + +/// Settled arguments for the add subcommand +struct AddOptions { + std::filesystem::path archive; + std::vector files; + std::optional path; + bool overwrite = false; + bool update = false; + std::optional locale; + std::optional game_profile; + CompressionSettingsOverrides compression_overrides; +}; + +/// Adds files and directories to an archive, returning false if any of them failed +bool Add(const AddOptions &options, std::ostream &err, const std::atomic &cancelled); + +} // namespace mpqcli + +#endif diff --git a/src/commands/compact.cpp b/src/commands/compact.cpp new file mode 100644 index 0000000..21a15a0 --- /dev/null +++ b/src/commands/compact.cpp @@ -0,0 +1,16 @@ +#include "commands/compact.h" + +#include + +#include "mpq/archive.h" +#include "mpq/compact.h" + +namespace mpqcli { + +void Compact(const CompactOptions &options, std::ostream &err) { + Archive archive = Archive::Open(options.target, 0); + CompactMpqArchive(archive.Handle(), options.listfile, err); + archive.Close(); +} + +} // namespace mpqcli diff --git a/src/commands/compact.h b/src/commands/compact.h new file mode 100644 index 0000000..69d3116 --- /dev/null +++ b/src/commands/compact.h @@ -0,0 +1,21 @@ +#ifndef COMMANDS_COMPACT_H +#define COMMANDS_COMPACT_H + +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the compact subcommand +struct CompactOptions { + std::filesystem::path target; + std::optional listfile; +}; + +/// Compacts the archive +void Compact(const CompactOptions &options, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/commands/create.cpp b/src/commands/create.cpp new file mode 100644 index 0000000..75f654c --- /dev/null +++ b/src/commands/create.cpp @@ -0,0 +1,129 @@ +#include "commands/create.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "errors.h" +#include "gamerules/rules.h" +#include "mpq/add.h" +#include "mpq/archive.h" +#include "util/capacity.h" +#include "util/locales.h" +#include "util/path.h" + +namespace fs = std::filesystem; + +namespace mpqcli { + +namespace { + +/// Removes the archive being written on the way out unless it was moved into place +struct PartialArchive { + fs::path path; + bool keep = false; + + ~PartialArchive() { + if (!keep) { + std::error_code ec; + fs::remove(path, ec); + } + } +}; + +} // namespace + +bool Create(const CreateOptions &options, std::ostream &err, const std::atomic &cancelled) { + std::error_code ec; + fs::path output_file_path; + if (options.output.has_value()) { + output_file_path = fs::absolute(options.output.value(), ec); + if (ec) { + throw FileError("Failed to resolve output path", options.output.value(), ec); + } + } else { + output_file_path = options.target; + // If the path ends with a separator (e.g. "dir/"), strip the + // trailing separator first so we get "dir.mpq" + if (output_file_path.filename().empty()) { + output_file_path = output_file_path.parent_path(); + } + output_file_path.replace_extension(".mpq"); + } + if (fs::exists(output_file_path, ec)) { + throw ArchiveError("File already exists: " + output_file_path.string()); + } + + GameProfile profile; + if (options.game_profile.has_value()) { + profile = GameRules::StringToProfile(options.game_profile.value()); + } else { + profile = GameRules::GetDefaultProfile(); + } + GameRules game_rules(profile); + + err << "[*] Game profile: " << options.game_profile.value_or("default") + << ", Output file: " << output_file_path.string() << std::endl; + + game_rules.OverrideCreateSettings(options.create_overrides); + + // List the files up front: the archive's max file count is fixed at creation + std::vector files; + const bool is_directory = fs::is_directory(options.target, ec); + if (is_directory) { + files = ListFilesRecursive(options.target, ec); + if (ec) { + throw FileError("Failed to list directory", options.target, ec); + } + } else if (!fs::is_regular_file(options.target, ec)) { + throw FileError("Not a file or directory", options.target); + } + const uint32_t file_count = + CalculateMpqMaxFileValue(is_directory ? static_cast(files.size()) : 1); + + // The archive is invalid until it is closed, so it is written beside the output + // and renamed into place once complete; an interrupt or error before that leaves + // nothing at the output path. A partial left by an earlier interrupted run would + // otherwise be refused as an existing file. + fs::path partial_path = output_file_path; + partial_path += ".partial"; + fs::remove(partial_path, ec); + PartialArchive partial{partial_path}; + + Archive archive = Archive::Create(partial_path, file_count, game_rules); + LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; + + int result = 0; + if (is_directory) { + const std::string prefix = options.path.value_or(""); + result |= AddFiles(archive.Handle(), files, options.target, prefix, lcid, game_rules, err, + cancelled, options.compression_overrides); + } else { + std::string archive_path = ResolveArchiveName(options.target, options.path); + result |= AddFile(archive.Handle(), options.target, archive_path, lcid, game_rules, err, + options.compression_overrides); + } + + if (options.sign) { + archive.Sign(); + } + archive.Close(); + + // A failed rename must not delete the complete archive it was about to move + partial.keep = true; + fs::rename(partial_path, output_file_path, ec); + if (ec) { + throw FileError("Failed to move " + partial_path.string() + " into place", output_file_path, + ec); + } + + return result == 0; +} + +} // namespace mpqcli diff --git a/src/commands/create.h b/src/commands/create.h new file mode 100644 index 0000000..692b606 --- /dev/null +++ b/src/commands/create.h @@ -0,0 +1,31 @@ +#ifndef COMMANDS_CREATE_H +#define COMMANDS_CREATE_H + +#include +#include +#include +#include +#include + +#include "gamerules/settings.h" + +namespace mpqcli { + +/// Settled arguments for the create subcommand +struct CreateOptions { + std::filesystem::path target; + std::optional path; + std::optional output; + bool sign = false; + std::optional locale; + std::optional game_profile; + MpqCreateSettingsOverrides create_overrides; + CompressionSettingsOverrides compression_overrides; +}; + +/// Creates an archive from a file or directory, returning false if any file failed to add +bool Create(const CreateOptions &options, std::ostream &err, const std::atomic &cancelled); + +} // namespace mpqcli + +#endif diff --git a/src/commands/extract.cpp b/src/commands/extract.cpp new file mode 100644 index 0000000..fde7bb9 --- /dev/null +++ b/src/commands/extract.cpp @@ -0,0 +1,72 @@ +#include "commands/extract.h" + +#include +#include +#include +#include + +#include + +#include "errors.h" +#include "mpq/archive.h" +#include "mpq/extract.h" +#include "util/locales.h" + +namespace fs = std::filesystem; + +namespace mpqcli { + +bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic &cancelled) { + std::error_code ec; + fs::path effective_output; + if (!options.output.has_value()) { + fs::path target_path = fs::absolute(options.target, ec); + if (ec) { + throw FileError("Failed to resolve archive path", options.target, ec); + } + effective_output = target_path.parent_path() / target_path.stem(); + } else { + effective_output = options.output.value(); + } + fs::create_directory(effective_output, ec); + if (ec) { + std::error_code query_ec; + if (!fs::is_directory(effective_output, query_ec)) { + throw FileError("Failed to create output directory", effective_output, ec); + } + } + + // ExtractFile can only check for symlink traversal where the OS can resolve + // real paths; warn once up front on volumes where it cannot (RAM disks) + static_cast(fs::canonical(effective_output, ec)); + if (ec) { + err << "[!] Output directory cannot be fully resolved, symlinks will not " + "be checked during extraction: " + << effective_output.string() << std::endl; + } + + Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); + + LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; + if (options.locale.has_value() && lcid == default_locale) { + err << "[!] The locale '" << options.locale.value() + << "' is unknown. Will use default locale instead." << std::endl; + } + + int result; + if (options.file.has_value()) { + result = ExtractFile(archive.Handle(), effective_output, options.file.value(), + options.keep_folder_structure, lcid, err); + } else { + result = ExtractFiles(archive.Handle(), effective_output, options.listfile, lcid, err, + cancelled); + } + archive.Close(); + + if (result != 0) { + err << std::endl << "[!] Failed to extract all files." << std::endl; + } + return result == 0; +} + +} // namespace mpqcli diff --git a/src/commands/extract.h b/src/commands/extract.h new file mode 100644 index 0000000..bfe997c --- /dev/null +++ b/src/commands/extract.h @@ -0,0 +1,27 @@ +#ifndef COMMANDS_EXTRACT_H +#define COMMANDS_EXTRACT_H + +#include +#include +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the extract subcommand +struct ExtractOptions { + std::filesystem::path target; + std::optional output; + std::optional file; + bool keep_folder_structure = false; + std::optional listfile; + std::optional locale; +}; + +/// Extracts one file or every file, returning false if any of them failed +bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic &cancelled); + +} // namespace mpqcli + +#endif diff --git a/src/commands/info.cpp b/src/commands/info.cpp new file mode 100644 index 0000000..9388a7e --- /dev/null +++ b/src/commands/info.cpp @@ -0,0 +1,18 @@ +#include "commands/info.h" + +#include + +#include + +#include "mpq/archive.h" +#include "mpq/info.h" + +namespace mpqcli { + +void Info(const InfoOptions &options, std::ostream &out) { + Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); + PrintMpqInfo(archive.Handle(), options.property, out); + archive.Close(); +} + +} // namespace mpqcli diff --git a/src/commands/info.h b/src/commands/info.h new file mode 100644 index 0000000..84ec9c5 --- /dev/null +++ b/src/commands/info.h @@ -0,0 +1,22 @@ +#ifndef COMMANDS_INFO_H +#define COMMANDS_INFO_H + +#include +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the info subcommand +struct InfoOptions { + std::filesystem::path target; + std::optional property; +}; + +/// Prints the archive's properties, or only the one named, to out +void Info(const InfoOptions &options, std::ostream &out); + +} // namespace mpqcli + +#endif diff --git a/src/commands/list.cpp b/src/commands/list.cpp new file mode 100644 index 0000000..e5b4839 --- /dev/null +++ b/src/commands/list.cpp @@ -0,0 +1,19 @@ +#include "commands/list.h" + +#include + +#include + +#include "mpq/archive.h" +#include "mpq/list.h" + +namespace mpqcli { + +void List(const ListOptions &options, std::ostream &out, std::ostream &err) { + Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); + ListFiles(archive.Handle(), options.listfile, options.all, options.detailed, options.properties, + out, err); + archive.Close(); +} + +} // namespace mpqcli diff --git a/src/commands/list.h b/src/commands/list.h new file mode 100644 index 0000000..14e490a --- /dev/null +++ b/src/commands/list.h @@ -0,0 +1,26 @@ +#ifndef COMMANDS_LIST_H +#define COMMANDS_LIST_H + +#include +#include +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the list subcommand +struct ListOptions { + std::filesystem::path target; + std::optional listfile; + bool detailed = false; + bool all = false; + std::vector properties; +}; + +/// Prints the archive's files to out +void List(const ListOptions &options, std::ostream &out, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/commands/read.cpp b/src/commands/read.cpp new file mode 100644 index 0000000..33c7c3c --- /dev/null +++ b/src/commands/read.cpp @@ -0,0 +1,38 @@ +#include "commands/read.h" + +#include +#include + +#include + +#include "mpq/archive.h" +#include "mpq/read.h" +#include "util/format.h" +#include "util/locales.h" + +namespace mpqcli { + +bool Read(const ReadOptions &options, std::ostream &out, std::ostream &err) { + Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); + + LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; + if (options.locale.has_value() && lcid == default_locale) { + err << "[!] The locale '" << options.locale.value() + << "' is unknown. Will use default locale instead." << std::endl; + } + + uint32_t file_size; + auto file_content = + ReadArchivedFile(archive.Handle(), options.file.c_str(), &file_size, lcid, err); + if (!file_content) { + archive.Close(); + return false; + } + + WriteBinary(out, file_content.get(), file_size); + + archive.Close(); + return true; +} + +} // namespace mpqcli diff --git a/src/commands/read.h b/src/commands/read.h new file mode 100644 index 0000000..1a02663 --- /dev/null +++ b/src/commands/read.h @@ -0,0 +1,23 @@ +#ifndef COMMANDS_READ_H +#define COMMANDS_READ_H + +#include +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the read subcommand +struct ReadOptions { + std::string file; + std::filesystem::path target; + std::optional locale; +}; + +/// Writes one archived file's bytes to out, returning false if it could not be read +bool Read(const ReadOptions &options, std::ostream &out, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/commands/remove.cpp b/src/commands/remove.cpp new file mode 100644 index 0000000..0204567 --- /dev/null +++ b/src/commands/remove.cpp @@ -0,0 +1,36 @@ +#include "commands/remove.h" + +#include +#include +#include +#include + +#include + +#include "errors.h" +#include "mpq/archive.h" +#include "mpq/remove.h" +#include "util/locales.h" + +namespace mpqcli { + +bool Remove(const RemoveOptions &options, std::ostream &err, const std::atomic &cancelled) { + Archive archive = Archive::Open(options.archive, 0); + + LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; + std::unordered_set seen; + bool all_removed = true; + for (const auto &f : options.files) { + ThrowIfCancelled(cancelled); + if (!seen.insert(f).second) { + continue; + } + if (RemoveFile(archive.Handle(), f, lcid, err) != 0) { + all_removed = false; + } + } + archive.Close(); + return all_removed; +} + +} // namespace mpqcli diff --git a/src/commands/remove.h b/src/commands/remove.h new file mode 100644 index 0000000..bad3df5 --- /dev/null +++ b/src/commands/remove.h @@ -0,0 +1,25 @@ +#ifndef COMMANDS_REMOVE_H +#define COMMANDS_REMOVE_H + +#include +#include +#include +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the remove subcommand +struct RemoveOptions { + std::filesystem::path archive; + std::vector files; + std::optional locale; +}; + +/// Removes files from an archive, returning false if any of them could not be removed +bool Remove(const RemoveOptions &options, std::ostream &err, const std::atomic &cancelled); + +} // namespace mpqcli + +#endif diff --git a/src/commands/rename.cpp b/src/commands/rename.cpp new file mode 100644 index 0000000..5e4a6bf --- /dev/null +++ b/src/commands/rename.cpp @@ -0,0 +1,22 @@ +#include "commands/rename.h" + +#include + +#include + +#include "mpq/archive.h" +#include "mpq/rename.h" +#include "util/locales.h" + +namespace mpqcli { + +bool Rename(const RenameOptions &options, std::ostream &err) { + Archive archive = Archive::Open(options.archive, 0); + + LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; + const int result = RenameFile(archive.Handle(), options.old_file, options.new_file, lcid, err); + archive.Close(); + return result == 0; +} + +} // namespace mpqcli diff --git a/src/commands/rename.h b/src/commands/rename.h new file mode 100644 index 0000000..2bc8987 --- /dev/null +++ b/src/commands/rename.h @@ -0,0 +1,24 @@ +#ifndef COMMANDS_RENAME_H +#define COMMANDS_RENAME_H + +#include +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the rename subcommand +struct RenameOptions { + std::filesystem::path archive; + std::string old_file; + std::string new_file; + std::optional locale; +}; + +/// Renames a file inside an archive, returning false if it could not be renamed +bool Rename(const RenameOptions &options, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/commands/verify.cpp b/src/commands/verify.cpp new file mode 100644 index 0000000..26c71c1 --- /dev/null +++ b/src/commands/verify.cpp @@ -0,0 +1,51 @@ +#include "commands/verify.h" + +#include + +#include + +#include "mpq/archive.h" +#include "mpq/verify.h" + +namespace mpqcli { + +bool Verify(const VerifyOptions &options, std::ostream &out, std::ostream &err) { + Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); + + bool verified; + switch (VerifyMpqArchive(archive.Handle())) { + case ERROR_WEAK_SIGNATURE_OK: + case ERROR_STRONG_SIGNATURE_OK: + if (options.print_signature) { + PrintMpqSignature(archive.Handle(), options.target, out, err); + } + err << "[*] Verify success" << std::endl; + verified = true; + break; + + case ERROR_WEAK_SIGNATURE_ERROR: + case ERROR_STRONG_SIGNATURE_ERROR: + if (options.print_signature) { + // Print the (invalid) signature bytes for forensic inspection, + // but still fail: the archive content no longer matches it + PrintMpqSignature(archive.Handle(), options.target, out, err); + } + err << "[!] Verify failed: signature is present but invalid" << std::endl; + verified = false; + break; + + case ERROR_NO_SIGNATURE: + err << "[!] Verify failed: archive has no signature" << std::endl; + verified = false; + break; + + default: // ERROR_VERIFY_FAILED or any other value + err << "[!] Verify failed" << std::endl; + verified = false; + break; + } + archive.Close(); + return verified; +} + +} // namespace mpqcli diff --git a/src/commands/verify.h b/src/commands/verify.h new file mode 100644 index 0000000..3deb2f8 --- /dev/null +++ b/src/commands/verify.h @@ -0,0 +1,20 @@ +#ifndef COMMANDS_VERIFY_H +#define COMMANDS_VERIFY_H + +#include +#include + +namespace mpqcli { + +/// Settled arguments for the verify subcommand +struct VerifyOptions { + std::filesystem::path target; + bool print_signature = false; +}; + +/// Checks the archive's signature, returning false when it is missing or invalid +bool Verify(const VerifyOptions &options, std::ostream &out, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/completion.cpp b/src/completion.cpp deleted file mode 100644 index ce787a9..0000000 --- a/src/completion.cpp +++ /dev/null @@ -1,21 +0,0 @@ -#include "completion.h" - -#include - -#include "completion_data.h" - -void HandleCompletionBash() { - std::cout << BashCompletionScript; -} - -void HandleCompletionZsh() { - std::cout << ZshCompletionScript; -} - -void HandleCompletionPs() { - std::cout << PsCompletionScript; -} - -void HandleCompletionFish() { - std::cout << FishCompletionScript; -} diff --git a/src/completion.h b/src/completion.h deleted file mode 100644 index 926a176..0000000 --- a/src/completion.h +++ /dev/null @@ -1,9 +0,0 @@ -#ifndef COMPLETION_H -#define COMPLETION_H - -void HandleCompletionBash(); -void HandleCompletionZsh(); -void HandleCompletionPs(); -void HandleCompletionFish(); - -#endif // COMPLETION_H diff --git a/src/completion/mpqcli.bash b/src/completion/mpqcli.bash deleted file mode 100644 index cc247da..0000000 --- a/src/completion/mpqcli.bash +++ /dev/null @@ -1,242 +0,0 @@ -# Use _filedir when available, otherwise fall back to compgen. -# Pass an extension (e.g. "mpq") to restrict completions to that type plus directories. -_mpqcli_filedir() { - if declare -f _filedir > /dev/null 2>&1; then - _filedir "${1-}" - else - if [[ -n "${1-}" ]]; then - local -a _f _d - mapfile -t _f < <(compgen -f -X "!*.$1" -- "$cur") - mapfile -t _d < <(compgen -d -- "$cur") - COMPREPLY=("${_f[@]}" "${_d[@]}") - else - mapfile -t COMPREPLY < <(compgen -f -- "$cur") - fi - fi -} - -_mpqcli() { - local cur prev words cword - - # Use bash-completion helpers when available, fall back to COMP_* variables. - if declare -f _init_completion > /dev/null 2>&1; then - _init_completion || return - else - COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - words=("${COMP_WORDS[@]}") - cword=$COMP_CWORD - fi - - local subcommands="version about info create add remove rename list extract read verify compact completion" - local -a locales=( - default enUS zhTW csCZ deDE esES frFR itIT - jaJP koKR nlNL plPL ptBR ruRU zhCN enGB esMX ptPT - ) - local -a games=( - generic - diablo1 diablo d1 - lordsofmagic lomse - starcraft starcraft1 sc1 - warcraft2 wc2 war2 - diablo2 d2 - warcraft3 wc3 war3 - warcraft3-map wc3-map war3-map - wow1 wow-vanilla - wow2 wow-tbc - wow3 wow-wotlk - wow4 wow-cataclysm - wow5 wow-mop - starcraft2 sc2 - diablo3 d3 - ) - local -a info_properties=( - format-version header-offset header-size archive-size - file-count max-files signature-type - ) - local -a list_properties=( - hash-index name-hash1 name-hash2 name-hash3 locale - file-index byte-offset file-time file-size compressed-size - flags encryption-key encryption-key-raw - ) - - if [[ $cword -eq 1 ]]; then - mapfile -t COMPREPLY < <(compgen -W "$subcommands" -- "$cur") - return - fi - - local subcmd="${words[1]}" - - case "$subcmd" in - info) - case "$prev" in - -p|--property) - mapfile -t COMPREPLY < <(compgen -W "${info_properties[*]}" -- "$cur") - return ;; - esac - if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-p --property" -- "$cur") - else - _mpqcli_filedir mpq - fi - ;; - - create) - case "$prev" in - --locale) - mapfile -t COMPREPLY < <(compgen -W "${locales[*]}" -- "$cur") - return ;; - -g|--game) - mapfile -t COMPREPLY < <(compgen -W "${games[*]}" -- "$cur") - return ;; - --version) - mapfile -t COMPREPLY < <(compgen -W "1 2 3 4" -- "$cur") - return ;; - -p|--path|-o|--output|--stream-flags|--sector-size|\ - --raw-chunk-size|--file-flags1|--file-flags2|--file-flags3|--attr-flags|\ - --flags|--compression|--compression-next) - return ;; - esac - if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-p --path -o --output -s --sign \ ---locale -g --game --version --stream-flags --sector-size --raw-chunk-size \ ---file-flags1 --file-flags2 --file-flags3 --attr-flags \ ---flags --compression --compression-next" -- "$cur") - else - _mpqcli_filedir - fi - ;; - - add) - case "$prev" in - --locale) - mapfile -t COMPREPLY < <(compgen -W "${locales[*]}" -- "$cur") - return ;; - -g|--game) - mapfile -t COMPREPLY < <(compgen -W "${games[*]}" -- "$cur") - return ;; - -p|--path|--flags|--compression|--compression-next) - return ;; - esac - if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-p --path -w --overwrite -u --update \ ---locale -g --game \ ---flags --compression --compression-next" -- "$cur") - else - _mpqcli_filedir - fi - ;; - - remove) - case "$prev" in - --locale) - mapfile -t COMPREPLY < <(compgen -W "${locales[*]}" -- "$cur") - return ;; - esac - if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "--locale" -- "$cur") - else - _mpqcli_filedir - fi - ;; - - rename) - case "$prev" in - --locale) - mapfile -t COMPREPLY < <(compgen -W "${locales[*]}" -- "$cur") - return ;; - esac - if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "--locale" -- "$cur") - elif [[ $cword -eq 2 ]]; then - # positional 1 is the archive; positionals 2 and 3 are in-archive - # paths, so they get no filesystem completion - _mpqcli_filedir mpq - fi - ;; - - list) - case "$prev" in - -l|--listfile) - _mpqcli_filedir - return ;; - -p|--property) - mapfile -t COMPREPLY < <(compgen -W "${list_properties[*]}" -- "$cur") - return ;; - esac - if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-l --listfile -d --detailed -a --all -p --property" -- "$cur") - else - _mpqcli_filedir mpq - fi - ;; - - extract) - case "$prev" in - -o|--output) - _mpqcli_filedir - return ;; - -l|--listfile) - _mpqcli_filedir - return ;; - --locale) - mapfile -t COMPREPLY < <(compgen -W "${locales[*]}" -- "$cur") - return ;; - -f|--file) - return ;; - esac - if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-o --output -f --file -k --keep -l --listfile --locale" -- "$cur") - else - _mpqcli_filedir mpq - fi - ;; - - read) - case "$prev" in - --locale) - mapfile -t COMPREPLY < <(compgen -W "${locales[*]}" -- "$cur") - return ;; - esac - if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "--locale" -- "$cur") - elif [[ $cword -ge 3 ]]; then - # positional 1 is an in-archive path (no filesystem completion); - # positional 2+ is the archive file - _mpqcli_filedir mpq - fi - ;; - - verify) - if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-p --print" -- "$cur") - else - _mpqcli_filedir mpq - fi - ;; - - compact) - case "$prev" in - -l|--listfile) - _mpqcli_filedir - return ;; - esac - if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-l --listfile" -- "$cur") - else - _mpqcli_filedir mpq - fi - ;; - - completion) - if [[ "$cur" != -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "bash zsh powershell fish" -- "$cur") - fi - ;; - - version|about) ;; - esac -} - -complete -F _mpqcli mpqcli diff --git a/src/completion_data.h.in b/src/completion_data.h.in deleted file mode 100644 index 081fc13..0000000 --- a/src/completion_data.h.in +++ /dev/null @@ -1,16 +0,0 @@ -#ifndef COMPLETION_DATA_H -#define COMPLETION_DATA_H - -// Auto-generated from src/completion/mpqcli.bash, do not edit directly -static constexpr char BashCompletionScript[] = R"BASH_MPQCLI(@BASH_COMPLETION_SCRIPT@)BASH_MPQCLI"; - -// Auto-generated from src/completion/mpqcli.zsh, do not edit directly -static constexpr char ZshCompletionScript[] = R"ZSH_MPQCLI(@ZSH_COMPLETION_SCRIPT@)ZSH_MPQCLI"; - -// Auto-generated from src/completion/mpqcli.ps1, do not edit directly -static constexpr char PsCompletionScript[] = R"PS_MPQCLI(@PS_COMPLETION_SCRIPT@)PS_MPQCLI"; - -// Auto-generated from src/completion/mpqcli.fish, do not edit directly -static constexpr char FishCompletionScript[] = R"FISH_MPQCLI(@FISH_COMPLETION_SCRIPT@)FISH_MPQCLI"; - -#endif // COMPLETION_DATA_H diff --git a/src/errors.h b/src/errors.h new file mode 100644 index 0000000..e0e2bbc --- /dev/null +++ b/src/errors.h @@ -0,0 +1,104 @@ +#ifndef ERRORS_H +#define ERRORS_H + +#include +#include +#include +#include +#include +#include +#include + +#include "util/format.h" + +namespace mpqcli { + +/// Base for every exception thrown by mpqcli +class Error : public std::runtime_error { +public: + explicit Error(const std::string &message) : std::runtime_error(message) {} +}; + +/// Thrown when the cancellation flag was set part-way through a command +class Interrupted : public Error { +public: + Interrupted() : Error("Interrupted") {} +}; + +/// Throws Interrupted once cancelled is set; polled once per item at the top of a long loop +inline void ThrowIfCancelled(const std::atomic &cancelled) { + if (cancelled.load(std::memory_order_relaxed)) { + throw Interrupted{}; + } +} + +/// Thrown when a filesystem operation fails; the message ends with the code, its text and the path +class FileError : public Error { +public: + FileError(const std::string &what_failed, std::filesystem::path path, + std::error_code error_code) + : Error(what_failed + ": (" + std::to_string(error_code.value()) + ") " + + error_code.message() + ": " + path.string()), + path_(std::move(path)), error_code_(error_code) {} + + /// For a failure with no code to report, such as a path of the wrong kind + FileError(const std::string &what_failed, std::filesystem::path path) + : Error(what_failed + ": " + path.string()), path_(std::move(path)) {} + + const std::filesystem::path &Path() const { return path_; } + const std::error_code &ErrorCode() const { return error_code_; } + +private: + std::filesystem::path path_; + std::error_code error_code_; +}; + +/// Base for failures reading or writing an archive +class ArchiveError : public Error { +public: + explicit ArchiveError(const std::string &message) : Error(message) {} +}; + +/// Thrown when a StormLib call fails; the message ends with the code and its text +class StormError : public ArchiveError { +public: + StormError(const std::string &what_failed, uint32_t error_code) + : ArchiveError(what_failed + ": (" + std::to_string(error_code) + ") " + + StormErrorString(error_code)), + error_code_(error_code) {} + + uint32_t ErrorCode() const { return error_code_; } + +private: + uint32_t error_code_; +}; + +/// Thrown when an archive cannot be opened +class ArchiveOpenError : public StormError { +public: + ArchiveOpenError(std::filesystem::path path, uint32_t error_code) + : StormError("Failed to open MPQ archive: " + path.string(), error_code), + path_(std::move(path)) {} + + const std::filesystem::path &Path() const { return path_; } + +private: + std::filesystem::path path_; +}; + +/// Thrown when an archive cannot be created +class ArchiveCreateError : public StormError { +public: + ArchiveCreateError(std::filesystem::path path, uint32_t error_code) + : StormError("Failed to create MPQ archive: " + path.string(), error_code), + path_(std::move(path)) {} + + const std::filesystem::path &Path() const { return path_; } + +private: + std::filesystem::path path_; +}; + +} // namespace mpqcli + +#endif diff --git a/src/gamerules.cpp b/src/gamerules.cpp deleted file mode 100644 index f7b49e2..0000000 --- a/src/gamerules.cpp +++ /dev/null @@ -1,554 +0,0 @@ -#include "gamerules.h" - -#include -#include -#include - -#include - -// Constructor -GameRules::GameRules(GameProfile game_profile) : profile_(game_profile) { - InitializeRules(); -} - -// Helper function to convert string to lowercase -static std::string ToLower(const std::string &str) { - std::string result = str; - std::transform(result.begin(), result.end(), result.begin(), - [](unsigned char c) { return std::tolower(c); }); - return result; -} - -// Helper function to match wildcards (* and ?) -bool GameRules::MatchFileMask(const std::string &filename, const std::string &mask) { - // Convert both to lowercase for case-insensitive matching - std::string lower_filename = ToLower(filename); - std::string lower_mask = ToLower(mask); - - // Replace backslashes with forward slashes for consistent path handling - std::replace(lower_filename.begin(), lower_filename.end(), '\\', '/'); - std::replace(lower_mask.begin(), lower_mask.end(), '\\', '/'); - - // Simple wildcard matching - size_t mask_pos = 0; - size_t file_pos = 0; - size_t star_pos = std::string::npos; - size_t match_pos = 0; - - while (file_pos < lower_filename.length()) { - if (mask_pos < lower_mask.length() && - (lower_mask[mask_pos] == '?' || lower_mask[mask_pos] == lower_filename[file_pos])) { - mask_pos++; - file_pos++; - } else if (mask_pos < lower_mask.length() && lower_mask[mask_pos] == '*') { - star_pos = mask_pos; - match_pos = file_pos; - mask_pos++; - } else if (star_pos != std::string::npos) { - mask_pos = star_pos + 1; - match_pos++; - file_pos = match_pos; - } else { - return false; - } - } - - while (mask_pos < lower_mask.length() && lower_mask[mask_pos] == '*') { - mask_pos++; - } - - return mask_pos == lower_mask.length(); -} - -void GameRules::AddRuleByFileMask(const std::string &file_mask, DWORD mpq_flags, - DWORD compression_first, DWORD compression_next) { - rules_.emplace_back(file_mask, mpq_flags, compression_first, compression_next); -} - -// Use UINT32_MAX for sizeMax to indicate "no upper limit" -// Examples: -// AddRuleByFileSize(0, 0, ...) - Match files with exactly 0 bytes -// AddRuleByFileSize(0, 0x4000, ...) - Match files from 0 to 16KB -// AddRuleByFileSize(0x4000, UINT32_MAX, ...) - Match files from 16KB onwards -void GameRules::AddRuleByFileSize(DWORD size_min, DWORD size_max, DWORD mpq_flags, - DWORD compression_first, DWORD compression_next) { - rules_.emplace_back(size_min, size_max, mpq_flags, compression_first, compression_next); -} - -void GameRules::AddRuleDefault(DWORD mpq_flags, DWORD compression_first, DWORD compression_next) { - rules_.emplace_back(mpq_flags, compression_first, compression_next); -} - -// Get compression settings for a specific file -CompressionSettings GameRules::GetCompressionSettings(const std::string &filename, - const DWORD file_size) const { - // Iterate through rules in order (first match wins) - for (const auto &rule : rules_) { - switch (rule.type) { - case RuleType::FILE_MASK: - if (MatchFileMask(filename, rule.file_mask)) { - return {rule.mpq_flags, rule.compression_first, rule.compression_next}; - } - break; - - case RuleType::FILE_SIZE: { - // Use UINT32_MAX to indicate "no upper limit" - bool has_upper_limit = (rule.size_max != UINT32_MAX); - bool in_range = - file_size >= rule.size_min && (!has_upper_limit || file_size <= rule.size_max); - - if (in_range) { - return {rule.mpq_flags, rule.compression_first, rule.compression_next}; - } - break; - } - - case RuleType::DEFAULT: - return {rule.mpq_flags, rule.compression_first, rule.compression_next}; - } - } - - // Fallback if no rules match (shouldn't happen if DEFAULT rule is present) - return {MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE, - MPQ_COMPRESSION_NEXT_SAME}; -} - -// Override MPQ creation settings with user-provided values -void GameRules::OverrideCreateSettings(const MpqCreateSettingsOverrides &overrides) { - // Track whether user explicitly set fileFlags2 (needed for automatic adjustment logic) - bool user_set_file_flags2 = false; - - // Step 1: Apply user overrides - // User-provided values always take priority, even if they might be incorrect. - // We only apply override if the optional has a value (i.e., user specified it) - - if (overrides.mpq_version.has_value()) { - create_settings_.mpq_version = overrides.mpq_version.value(); - } - - if (overrides.stream_flags.has_value()) { - create_settings_.stream_flags = overrides.stream_flags.value(); - } - - if (overrides.sector_size.has_value()) { - create_settings_.sector_size = overrides.sector_size.value(); - } - - if (overrides.raw_chunk_size.has_value()) { - create_settings_.raw_chunk_size = overrides.raw_chunk_size.value(); - } - - if (overrides.file_flags1.has_value()) { - create_settings_.file_flags1 = overrides.file_flags1.value(); - } - - if (overrides.file_flags2.has_value()) { - create_settings_.file_flags2 = overrides.file_flags2.value(); - user_set_file_flags2 = true; // User explicitly set this value - } - - if (overrides.file_flags3.has_value()) { - create_settings_.file_flags3 = overrides.file_flags3.value(); - } - - if (overrides.attr_flags.has_value()) { - create_settings_.attr_flags = overrides.attr_flags.value(); - } - - // Step 2: Apply automatic adjustments based on dependencies - // These only apply if the user hasn't explicitly overridden the values - - // fileFlags2 controls the (attributes) file, which is only meaningful when - // attrFlags is also set. According to StormLib's SFileCreateArchive.cpp: - // - The (attributes) file is created only when BOTH fileFlags2 AND attrFlags are non-zero - // - If attrFlags is set but fileFlags2 is still 0 (not overridden by user or profile), - // we should set fileFlags2 to MPQ_FILE_DEFAULT_INTERNAL to enable the attributes file - - if (!user_set_file_flags2 && create_settings_.file_flags2 == 0 && - create_settings_.attr_flags != 0) { - // User wants attributes (attrFlags is set) but hasn't specified how to store - // the (attributes) file itself. Use the default internal file flags. - create_settings_.file_flags2 = MPQ_FILE_DEFAULT_INTERNAL; - } - - // Note: If user explicitly sets fileFlags2 to 0 via override, we respect that choice - // even if attrFlags is non-zero. -} - -// Get the profile name map (single source of truth for all valid profile names) -static const std::map &GetProfileMap() { - static const std::map profile_map = { - {"generic", GameProfile::GENERIC}, - {"diablo1", GameProfile::DIABLO1}, - {"diablo", GameProfile::DIABLO1}, - {"d1", GameProfile::DIABLO1}, - {"lordsofmagic", GameProfile::LORDSOFMAGIC}, - {"lomse", GameProfile::LORDSOFMAGIC}, - {"starcraft", GameProfile::STARCRAFT1}, - {"starcraft1", GameProfile::STARCRAFT1}, - {"sc", GameProfile::STARCRAFT1}, - {"sc1", GameProfile::STARCRAFT1}, - {"warcraft2", GameProfile::WARCRAFT2}, - {"wc2", GameProfile::WARCRAFT2}, - {"war2", GameProfile::WARCRAFT2}, - {"diablo2", GameProfile::DIABLO2}, - {"d2", GameProfile::DIABLO2}, - {"warcraft3", GameProfile::WARCRAFT3}, - {"wc3", GameProfile::WARCRAFT3}, - {"war3", GameProfile::WARCRAFT3}, - {"warcraft3-map", GameProfile::WARCRAFT3_MAP}, - {"wc3-map", GameProfile::WARCRAFT3_MAP}, - {"war3-map", GameProfile::WARCRAFT3_MAP}, - {"wow1", GameProfile::WOW_1X}, - {"wow-vanilla", GameProfile::WOW_1X}, - {"wow2", GameProfile::WOW_2X}, - {"wow-tbc", GameProfile::WOW_2X}, - {"wow3", GameProfile::WOW_3X}, - {"wow-wotlk", GameProfile::WOW_3X}, - {"wow4", GameProfile::WOW_4X}, - {"wow-cataclysm", GameProfile::WOW_4X}, - {"wow5", GameProfile::WOW_5X}, - {"wow-mop", GameProfile::WOW_5X}, - {"starcraft2", GameProfile::STARCRAFT2}, - {"sc2", GameProfile::STARCRAFT2}, - {"diablo3", GameProfile::DIABLO3}, - {"d3", GameProfile::DIABLO3}}; - return profile_map; -} - -// Convert string to GameProfile enum -GameProfile GameRules::StringToProfile(const std::string &profile_name) { - const auto &profile_map = GetProfileMap(); - std::string lower = ToLower(profile_name); - auto it = profile_map.find(lower); - if (it != profile_map.end()) { - return it->second; - } - return GameProfile::GENERIC; -} - -// Convert GameProfile enum to string -std::string GameRules::ProfileToString(GameProfile profile) { - switch (profile) { - case GameProfile::GENERIC: - return "generic"; - case GameProfile::DIABLO1: - return "diablo1"; - case GameProfile::LORDSOFMAGIC: - return "lordsofmagic"; - case GameProfile::WARCRAFT2: - return "warcraft2"; - case GameProfile::STARCRAFT1: - return "starcraft1"; - case GameProfile::DIABLO2: - return "diablo2"; - case GameProfile::WARCRAFT3: - return "warcraft3"; - case GameProfile::WARCRAFT3_MAP: - return "warcraft3-map"; - case GameProfile::WOW_1X: - return "wow-vanilla"; - case GameProfile::WOW_2X: - return "wow-tbc"; - case GameProfile::WOW_3X: - return "wow-wotlk"; - case GameProfile::WOW_4X: - return "wow-cataclysm"; - case GameProfile::WOW_5X: - return "wow-mop"; - case GameProfile::STARCRAFT2: - return "starcraft2"; - case GameProfile::DIABLO3: - return "diablo3"; - default: - return "generic"; - } -} - -// Get list of canonical game profile names (for display purposes) -std::vector GameRules::GetCanonicalProfiles() { - static const std::vector all_profiles = { - GameProfile::GENERIC, GameProfile::DIABLO1, GameProfile::LORDSOFMAGIC, - GameProfile::STARCRAFT1, GameProfile::WARCRAFT2, GameProfile::DIABLO2, - GameProfile::WARCRAFT3, GameProfile::WARCRAFT3_MAP, GameProfile::WOW_1X, - GameProfile::WOW_2X, GameProfile::WOW_3X, GameProfile::WOW_4X, - GameProfile::WOW_5X, GameProfile::STARCRAFT2, GameProfile::DIABLO3, - }; - - std::vector profiles; - profiles.reserve(all_profiles.size()); - for (const auto &p : all_profiles) { - profiles.push_back(ProfileToString(p)); - } - return profiles; -} - -// Get available profiles as a comma-separated string -std::string GameRules::GetAvailableProfiles() { - auto profiles = GetCanonicalProfiles(); - std::string result; - - for (size_t i = 0; i < profiles.size(); ++i) { - result += profiles[i]; - if (i < profiles.size() - 1) { - result += ", "; - } - } - - return result; -} - -// Validator for CLI11 - accepts all profile names but only displays canonical ones -extern const CLI::Validator game_profile_valid = CLI::Validator( - [](const std::string &str) { - if (str == "default") - return std::string(); - - // Try to convert the string to a profile - GameProfile profile = GameRules::StringToProfile(str); - - // If it's GENERIC and the input wasn't "generic", it means the profile wasn't found - if (profile == GameProfile::GENERIC && str != "generic") { - std::string valid_profiles = "Game profile must be one of:"; - for (const auto &p : GameRules::GetCanonicalProfiles()) { - valid_profiles += " " + p; - } - return valid_profiles; - } - return std::string(); - }, - "", "GameProfileValidator"); - -// Initialize rules for the selected game profile -void GameRules::InitializeRules() { - rules_.clear(); - - switch (profile_) { - case GameProfile::DIABLO1: - case GameProfile::LORDSOFMAGIC: - // File rules when adding files to archive: - AddRuleByFileMask("*.wav", MPQ_FILE_ENCRYPTED, 0x00, 0x00); - AddRuleByFileMask("*.smk", 0x00000000, 0x00, 0x00); - AddRuleByFileMask("*.bik", 0x00000000, 0x00, 0x00); - AddRuleByFileMask("*.mpq", MPQ_FILE_ENCRYPTED, 0x00, 0x00); - AddRuleByFileMask("game", MPQ_FILE_IMPLODE, 0x00, 0x00); - AddRuleByFileMask("hero", MPQ_FILE_IMPLODE, 0x00, 0x00); - AddRuleDefault(MPQ_FILE_IMPLODE | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE); - - // Settings for archive creation: - create_settings_.mpq_version = MPQ_FORMAT_VERSION_1; - create_settings_.sector_size = 0x1000; - break; - - case GameProfile::WARCRAFT2: - case GameProfile::STARCRAFT1: - // File rules when adding files to archive: - AddRuleByFileMask("*.wav", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, - MPQ_COMPRESSION_PKWARE, - MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_STEREO); - AddRuleByFileMask("*.smk", 0x00000000, 0x00, 0x00); - AddRuleByFileMask("*.bik", 0x00000000, 0x00, 0x00); - AddRuleByFileMask("*.mpq", 0x00000000, 0x00, 0x00); - AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, - MPQ_COMPRESSION_PKWARE); - - // Settings for archive creation: - create_settings_.mpq_version = MPQ_FORMAT_VERSION_1; - create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC; - create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC; - create_settings_.sector_size = 0x1000; - break; - - case GameProfile::DIABLO2: - // File rules when adding files to archive: - AddRuleByFileMask("*.wav", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, - MPQ_COMPRESSION_PKWARE, - MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_STEREO); - AddRuleByFileMask("*.d2", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleByFileMask("*.txt", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleByFileMask("*.dc6", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleByFileMask("*.tbl", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleByFileMask("*.map", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleByFileMask("*.key", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleByFileMask("*.dat", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleByFileMask("*.ds1", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleByFileMask("*.dcc", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleByFileMask("*.cof", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleByFileMask("*.dt1", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleByFileMask("*.pl2", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleByFileMask("*.dn1", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleByFileMask("*.ico", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); - AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, - MPQ_COMPRESSION_PKWARE); - - // Settings for archive creation: - create_settings_.mpq_version = MPQ_FORMAT_VERSION_1; - create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.sector_size = 0x1000; - break; - - case GameProfile::WARCRAFT3: - // File rules when adding files to archive: - AddRuleByFileMask("Abilities\\*.wav", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB, - MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO); - AddRuleByFileMask("Buildings\\*.wav", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB, - MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO); - AddRuleByFileMask("*.wav", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, - MPQ_COMPRESSION_ZLIB, - MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO); - - AddRuleByFileMask("ReplaceableTextures\\WorldEditUI\\*.blp", MPQ_FILE_COMPRESS, - MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("ReplaceableTextures\\Selection\\*.blp", MPQ_FILE_COMPRESS, - MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("ReplaceableTextures\\Shadows\\*.blp", MPQ_FILE_COMPRESS, - MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("UI\\Glues\\Loading\\Backgrounds\\*.blp", 0, 0); - AddRuleByFileMask("UI\\Glues\\Loading\\Multiplayer\\*.blp", 0, 0); - AddRuleByFileMask("UI\\*.blp", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.blp", 0, 0); - - AddRuleByFileMask("Maps\\Campaign\\*.w3m", 0, 0); - AddRuleByFileMask("*.w3m", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, - MPQ_COMPRESSION_PKWARE); - - AddRuleByFileMask("*.toc", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.ifl", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.mdx", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.tga", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.slk", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.ai", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.j", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.txt", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, - MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.fdf", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, - MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.pld", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, - MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.mid", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, - MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.dls", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, - MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.mpq", 0, 0); - AddRuleByFileMask("*.mp3", 0, 0); - - AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, - MPQ_COMPRESSION_PKWARE); - - // Settings for archive creation: - create_settings_.mpq_version = MPQ_FORMAT_VERSION_1; - create_settings_.sector_size = 0x1000; - create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.attr_flags = MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32; - break; - - case GameProfile::WARCRAFT3_MAP: // Warcraft III Map files - // File rules when adding files to archive: - AddRuleDefault(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); - - // Settings for archive creation: - create_settings_.mpq_version = MPQ_FORMAT_VERSION_1; - create_settings_.sector_size = 0x1000; - create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.attr_flags = MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32; - break; - - case GameProfile::WOW_1X: - // File rules when adding files to archive: - AddRuleByFileMask("*.mp3", 0, 0); - AddRuleDefault(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); - - // Settings for archive creation: - create_settings_.mpq_version = MPQ_FORMAT_VERSION_1; - create_settings_.sector_size = 0x1000; - create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.attr_flags = - MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; - break; - - case GameProfile::WOW_2X: - case GameProfile::WOW_3X: - // File rules when adding files to archive: - AddRuleByFileMask("*.mp3", 0, 0); - AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC, MPQ_COMPRESSION_ZLIB); - - // Settings for archive creation: - create_settings_.mpq_version = MPQ_FORMAT_VERSION_2; - create_settings_.sector_size = 0x1000; - create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.attr_flags = - MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; - break; - - case GameProfile::WOW_4X: - case GameProfile::WOW_5X: - // File rules when adding files to archive: - AddRuleByFileSize(0, 0, MPQ_FILE_DELETE_MARKER, 0); - AddRuleByFileMask("*.mp3", 0, 0); - AddRuleByFileMask("*.ogg", 0, 0); - AddRuleByFileMask("*.ogv", 0, 0); - AddRuleByFileSize(0, 0x4000, MPQ_FILE_COMPRESS | MPQ_FILE_SINGLE_UNIT, - MPQ_COMPRESSION_ZLIB); - AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC, MPQ_COMPRESSION_ZLIB); - - // Settings for archive creation: - create_settings_.mpq_version = MPQ_FORMAT_VERSION_4; - create_settings_.raw_chunk_size = 0x4000; - create_settings_.sector_size = 0x4000; - create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.attr_flags = MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; - break; - - case GameProfile::STARCRAFT2: - // File rules when adding files to archive: - AddRuleByFileSize(0, 0, MPQ_FILE_DELETE_MARKER, 0); - AddRuleByFileMask("*.mp3", 0, 0); - AddRuleByFileMask("*.ogg", 0, 0); - AddRuleByFileMask("*.ogv", 0, 0); - AddRuleByFileSize(0, 0x4000, MPQ_FILE_COMPRESS | MPQ_FILE_SINGLE_UNIT, - MPQ_COMPRESSION_ZLIB); - AddRuleByFileMask("*.wav", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); - AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC, MPQ_COMPRESSION_ZLIB); - - // Settings for archive creation: - create_settings_.mpq_version = MPQ_FORMAT_VERSION_2; - create_settings_.sector_size = 0x4000; - create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.attr_flags = MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; - break; - - case GameProfile::DIABLO3: - // File rules when adding files to archive: - AddRuleByFileSize(0, 0, MPQ_FILE_DELETE_MARKER, 0); - AddRuleByFileMask("*.mp3", 0, 0); - AddRuleByFileMask("*.ogg", 0, 0); - AddRuleByFileMask("*.ogv", 0, 0); - AddRuleByFileSize(0, 0x4000, MPQ_FILE_COMPRESS | MPQ_FILE_SINGLE_UNIT, - MPQ_COMPRESSION_ZLIB); - AddRuleDefault(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); - - // Settings for archive creation: - create_settings_.mpq_version = MPQ_FORMAT_VERSION_4; - create_settings_.raw_chunk_size = 0x4000; - create_settings_.sector_size = 0x4000; - create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; - create_settings_.attr_flags = MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; - break; - - case GameProfile::GENERIC: - default: - // File rules when adding files to archive: - AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE); - - // For settings for archive creation, use defaults from MpqCreateSettings constructor - break; - } -} diff --git a/src/gamerules.h b/src/gamerules.h deleted file mode 100644 index 1a26fdb..0000000 --- a/src/gamerules.h +++ /dev/null @@ -1,161 +0,0 @@ -#ifndef GAMERULES_H -#define GAMERULES_H - -#include -#include -#include -#include - -#include - -enum class GameProfile { - GENERIC, // Default/generic MPQ with basic compression - DIABLO1, // Diablo I / Hellfire (1997) - LORDSOFMAGIC, // Lords of Magic SE (1998) - STARCRAFT1, // StarCraft / Brood War (1998) - WARCRAFT2, // Warcraft II: Battle.net Edition (1999) - DIABLO2, // Diablo II / Lords of Destruction (2000) - WARCRAFT3, // Warcraft III / The Frozen Throne (2002) - WARCRAFT3_MAP, // Warcraft III Map files (2002) - WOW_1X, // World of Warcraft 1 - Vanilla (2004) - WOW_2X, // World of Warcraft 2 - The Burning Crusade (2007) - WOW_3X, // World of Warcraft 3 - Wrath of the Lich King (2008) - WOW_4X, // World of Warcraft 4 - Cataclysm (2010) - WOW_5X, // World of Warcraft 5 - Mists of Pandaria (2012) - STARCRAFT2, // StarCraft II (2010) - DIABLO3 // Diablo III (2012) -}; - -enum class RuleType { - FILE_MASK, // Rule based on file pattern (e.g., "*.wav") - FILE_SIZE, // Rule based on file size range - DEFAULT // Default rule (fallback) -}; - -// Structure representing a single compression rule -struct CompressionRule { - RuleType type; - std::string file_mask; // For FILE_MASK rules (e.g., "*.wav", "UI\\*.blp") - DWORD size_min; // For FILE_SIZE rules - DWORD size_max; // For FILE_SIZE rules - DWORD mpq_flags; // MPQ file flags (compression, encryption, etc.) - DWORD compression_first; // Compression for first sector - DWORD compression_next; // Compression for subsequent sectors - - CompressionRule(std::string mask, const DWORD flags, const DWORD comp_first, - const DWORD comp_next = MPQ_COMPRESSION_NEXT_SAME) - : type(RuleType::FILE_MASK), file_mask(std::move(mask)), size_min(0), size_max(0), - mpq_flags(flags), compression_first(comp_first), compression_next(comp_next) {} - - CompressionRule(const DWORD min_size, const DWORD max_size, const DWORD flags, - const DWORD comp_first, const DWORD comp_next = MPQ_COMPRESSION_NEXT_SAME) - : type(RuleType::FILE_SIZE), file_mask(""), size_min(min_size), size_max(max_size), - mpq_flags(flags), compression_first(comp_first), compression_next(comp_next) {} - - CompressionRule(const DWORD flags, const DWORD comp_first, - const DWORD comp_next = MPQ_COMPRESSION_NEXT_SAME) - : type(RuleType::DEFAULT), file_mask(""), size_min(0), size_max(0), mpq_flags(flags), - compression_first(comp_first), compression_next(comp_next) {} -}; - -// Structure to hold compression settings for a file -struct CompressionSettings { - DWORD mpq_flags; - DWORD compression_first; - DWORD compression_next; -}; - -// Structure to hold optional override settings for adding files -struct CompressionSettingsOverrides { - std::optional flags; - std::optional compression; - std::optional compression_next; -}; - -// Structure to hold MPQ archive creation settings -struct MpqCreateSettings { - DWORD mpq_version; // MPQ format version (1, 2, 3, or 4) - DWORD stream_flags; // Stream flags (e.g., STREAM_PROVIDER_FLAT) - DWORD file_flags1; // File flags for (listfile) - DWORD file_flags2; // File flags for (attributes) - DWORD file_flags3; // File flags for (signature) - DWORD attr_flags; // Attribute flags (CRC32, FILETIME, MD5, etc.) - DWORD sector_size; // Sector size (typically 0x1000 or 0x4000) - DWORD raw_chunk_size; // Raw chunk size (for MPQ v4, typically 0x4000) - - // Constructor with defaults - MpqCreateSettings() - : mpq_version(MPQ_FORMAT_VERSION_1), - stream_flags(STREAM_PROVIDER_FLAT | BASE_PROVIDER_FILE), - file_flags1(MPQ_FILE_DEFAULT_INTERNAL), file_flags2(0), file_flags3(0), attr_flags(0), - sector_size(0x1000), raw_chunk_size(0) {} -}; - -// Structure to hold optional override settings for MPQ archive creation -struct MpqCreateSettingsOverrides { - std::optional mpq_version; - std::optional stream_flags; - std::optional file_flags1; - std::optional file_flags2; - std::optional file_flags3; - std::optional attr_flags; - std::optional sector_size; - std::optional raw_chunk_size; -}; - -// Game rules class that manages compression rules for different games -class GameRules { -private: - GameProfile profile_; - std::vector rules_; - MpqCreateSettings create_settings_; - - // Helper function to match file mask pattern - static bool MatchFileMask(const std::string &filename, const std::string &mask); - - // Add rule by file mask - void AddRuleByFileMask(const std::string &file_mask, DWORD mpq_flags, DWORD compression_first, - DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); - - // Add rule by file size - void AddRuleByFileSize(DWORD size_min, DWORD size_max, DWORD mpq_flags, DWORD compression_first, - DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); - - // Add default rule - void AddRuleDefault(DWORD mpq_flags, DWORD compression_first, - DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); - - // Initialize rules for the selected game profile - void InitializeRules(); - - // Convert GameProfile enum to string - static std::string ProfileToString(GameProfile profile); - -public: - // Constructor - explicit GameRules(GameProfile game_profile); - - // Get compression settings for a specific file - [[nodiscard]] CompressionSettings GetCompressionSettings(const std::string &filename, - DWORD file_size) const; - - // Get MPQ creation settings - [[nodiscard]] const MpqCreateSettings &GetCreateSettings() const { return create_settings_; } - - // Override MPQ creation settings - void OverrideCreateSettings(const MpqCreateSettingsOverrides &overrides); - - // Convert string to GameProfile enum - static GameProfile StringToProfile(const std::string &profile_name); - - // Get list of canonical game profile names (for display purposes) - static std::vector GetCanonicalProfiles(); - - // Get available profiles as a comma-separated string - static std::string GetAvailableProfiles(); - - // Get default game profile (GENERIC) - static GameProfile GetDefaultProfile() { return GameProfile::GENERIC; } -}; - -#endif // GAMERULES_H diff --git a/src/gamerules/profile_data.cpp b/src/gamerules/profile_data.cpp new file mode 100644 index 0000000..425529b --- /dev/null +++ b/src/gamerules/profile_data.cpp @@ -0,0 +1,238 @@ +#include "gamerules/rules.h" + +namespace mpqcli { + +void GameRules::InitializeRules() { + rules_.clear(); + + switch (profile_) { + case GameProfile::DIABLO1: + case GameProfile::LORDSOFMAGIC: + // File rules when adding files to archive: + AddRuleByFileMask("*.wav", MPQ_FILE_ENCRYPTED, 0x00, 0x00); + AddRuleByFileMask("*.smk", 0x00000000, 0x00, 0x00); + AddRuleByFileMask("*.bik", 0x00000000, 0x00, 0x00); + AddRuleByFileMask("*.mpq", MPQ_FILE_ENCRYPTED, 0x00, 0x00); + AddRuleByFileMask("game", MPQ_FILE_IMPLODE, 0x00, 0x00); + AddRuleByFileMask("hero", MPQ_FILE_IMPLODE, 0x00, 0x00); + AddRuleDefault(MPQ_FILE_IMPLODE | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE); + + // Settings for archive creation: + create_settings_.mpq_version = MPQ_FORMAT_VERSION_1; + create_settings_.sector_size = 0x1000; + break; + + case GameProfile::WARCRAFT2: + case GameProfile::STARCRAFT1: + // File rules when adding files to archive: + AddRuleByFileMask("*.wav", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_PKWARE, + MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_STEREO); + AddRuleByFileMask("*.smk", 0x00000000, 0x00, 0x00); + AddRuleByFileMask("*.bik", 0x00000000, 0x00, 0x00); + AddRuleByFileMask("*.mpq", 0x00000000, 0x00, 0x00); + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_PKWARE); + + // Settings for archive creation: + create_settings_.mpq_version = MPQ_FORMAT_VERSION_1; + create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC; + create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC; + create_settings_.sector_size = 0x1000; + break; + + case GameProfile::DIABLO2: + // File rules when adding files to archive: + AddRuleByFileMask("*.wav", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_PKWARE, + MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_STEREO); + AddRuleByFileMask("*.d2", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.txt", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.dc6", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.tbl", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.map", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.key", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.dat", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.ds1", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.dcc", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.cof", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.dt1", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.pl2", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.dn1", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.ico", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_PKWARE); + + // Settings for archive creation: + create_settings_.mpq_version = MPQ_FORMAT_VERSION_1; + create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.sector_size = 0x1000; + break; + + case GameProfile::WARCRAFT3: + // File rules when adding files to archive: + AddRuleByFileMask("Abilities\\*.wav", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB, + MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO); + AddRuleByFileMask("Buildings\\*.wav", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB, + MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO); + AddRuleByFileMask("*.wav", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_ZLIB, + MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO); + + AddRuleByFileMask("ReplaceableTextures\\WorldEditUI\\*.blp", MPQ_FILE_COMPRESS, + MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("ReplaceableTextures\\Selection\\*.blp", MPQ_FILE_COMPRESS, + MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("ReplaceableTextures\\Shadows\\*.blp", MPQ_FILE_COMPRESS, + MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("UI\\Glues\\Loading\\Backgrounds\\*.blp", 0, 0); + AddRuleByFileMask("UI\\Glues\\Loading\\Multiplayer\\*.blp", 0, 0); + AddRuleByFileMask("UI\\*.blp", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.blp", 0, 0); + + AddRuleByFileMask("Maps\\Campaign\\*.w3m", 0, 0); + AddRuleByFileMask("*.w3m", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_PKWARE); + + AddRuleByFileMask("*.toc", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.ifl", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.mdx", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.tga", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.slk", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.ai", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.j", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.txt", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.fdf", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.pld", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.mid", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.dls", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.mpq", 0, 0); + AddRuleByFileMask("*.mp3", 0, 0); + + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_PKWARE); + + // Settings for archive creation: + create_settings_.mpq_version = MPQ_FORMAT_VERSION_1; + create_settings_.sector_size = 0x1000; + create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.attr_flags = MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32; + break; + + case GameProfile::WARCRAFT3_MAP: // Warcraft III Map files + // File rules when adding files to archive: + AddRuleDefault(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + + // Settings for archive creation: + create_settings_.mpq_version = MPQ_FORMAT_VERSION_1; + create_settings_.sector_size = 0x1000; + create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.attr_flags = MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32; + break; + + case GameProfile::WOW_1X: + // File rules when adding files to archive: + AddRuleByFileMask("*.mp3", 0, 0); + AddRuleDefault(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + + // Settings for archive creation: + create_settings_.mpq_version = MPQ_FORMAT_VERSION_1; + create_settings_.sector_size = 0x1000; + create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.attr_flags = + MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; + break; + + case GameProfile::WOW_2X: + case GameProfile::WOW_3X: + // File rules when adding files to archive: + AddRuleByFileMask("*.mp3", 0, 0); + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC, MPQ_COMPRESSION_ZLIB); + + // Settings for archive creation: + create_settings_.mpq_version = MPQ_FORMAT_VERSION_2; + create_settings_.sector_size = 0x1000; + create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.attr_flags = + MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; + break; + + case GameProfile::WOW_4X: + case GameProfile::WOW_5X: + // File rules when adding files to archive: + AddRuleByFileSize(0, 0, MPQ_FILE_DELETE_MARKER, 0); + AddRuleByFileMask("*.mp3", 0, 0); + AddRuleByFileMask("*.ogg", 0, 0); + AddRuleByFileMask("*.ogv", 0, 0); + AddRuleByFileSize(0, 0x4000, MPQ_FILE_COMPRESS | MPQ_FILE_SINGLE_UNIT, + MPQ_COMPRESSION_ZLIB); + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC, MPQ_COMPRESSION_ZLIB); + + // Settings for archive creation: + create_settings_.mpq_version = MPQ_FORMAT_VERSION_4; + create_settings_.raw_chunk_size = 0x4000; + create_settings_.sector_size = 0x4000; + create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.attr_flags = MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; + break; + + case GameProfile::STARCRAFT2: + // File rules when adding files to archive: + AddRuleByFileSize(0, 0, MPQ_FILE_DELETE_MARKER, 0); + AddRuleByFileMask("*.mp3", 0, 0); + AddRuleByFileMask("*.ogg", 0, 0); + AddRuleByFileMask("*.ogv", 0, 0); + AddRuleByFileSize(0, 0x4000, MPQ_FILE_COMPRESS | MPQ_FILE_SINGLE_UNIT, + MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.wav", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC, MPQ_COMPRESSION_ZLIB); + + // Settings for archive creation: + create_settings_.mpq_version = MPQ_FORMAT_VERSION_2; + create_settings_.sector_size = 0x4000; + create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.attr_flags = MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; + break; + + case GameProfile::DIABLO3: + // File rules when adding files to archive: + AddRuleByFileSize(0, 0, MPQ_FILE_DELETE_MARKER, 0); + AddRuleByFileMask("*.mp3", 0, 0); + AddRuleByFileMask("*.ogg", 0, 0); + AddRuleByFileMask("*.ogv", 0, 0); + AddRuleByFileSize(0, 0x4000, MPQ_FILE_COMPRESS | MPQ_FILE_SINGLE_UNIT, + MPQ_COMPRESSION_ZLIB); + AddRuleDefault(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + + // Settings for archive creation: + create_settings_.mpq_version = MPQ_FORMAT_VERSION_4; + create_settings_.raw_chunk_size = 0x4000; + create_settings_.sector_size = 0x4000; + create_settings_.file_flags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.file_flags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + create_settings_.attr_flags = MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; + break; + + case GameProfile::GENERIC: + default: + // File rules when adding files to archive: + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE); + + // For settings for archive creation, use defaults from MpqCreateSettings constructor + break; + } +} + +} // namespace mpqcli diff --git a/src/gamerules/profiles.cpp b/src/gamerules/profiles.cpp new file mode 100644 index 0000000..2796e9f --- /dev/null +++ b/src/gamerules/profiles.cpp @@ -0,0 +1,136 @@ +#include "gamerules/profiles.h" + +#include +#include +#include +#include + +#include "gamerules/rules.h" +#include "util/string.h" + +namespace mpqcli { + +namespace { + +// The single source of truth for valid profile names and their aliases +const std::map &GetProfileMap() { + static const std::map profile_map = { + {"generic", GameProfile::GENERIC}, + {"diablo1", GameProfile::DIABLO1}, + {"diablo", GameProfile::DIABLO1}, + {"d1", GameProfile::DIABLO1}, + {"lordsofmagic", GameProfile::LORDSOFMAGIC}, + {"lomse", GameProfile::LORDSOFMAGIC}, + {"starcraft", GameProfile::STARCRAFT1}, + {"starcraft1", GameProfile::STARCRAFT1}, + {"sc", GameProfile::STARCRAFT1}, + {"sc1", GameProfile::STARCRAFT1}, + {"warcraft2", GameProfile::WARCRAFT2}, + {"wc2", GameProfile::WARCRAFT2}, + {"war2", GameProfile::WARCRAFT2}, + {"diablo2", GameProfile::DIABLO2}, + {"d2", GameProfile::DIABLO2}, + {"warcraft3", GameProfile::WARCRAFT3}, + {"wc3", GameProfile::WARCRAFT3}, + {"war3", GameProfile::WARCRAFT3}, + {"warcraft3-map", GameProfile::WARCRAFT3_MAP}, + {"wc3-map", GameProfile::WARCRAFT3_MAP}, + {"war3-map", GameProfile::WARCRAFT3_MAP}, + {"wow1", GameProfile::WOW_1X}, + {"wow-vanilla", GameProfile::WOW_1X}, + {"wow2", GameProfile::WOW_2X}, + {"wow-tbc", GameProfile::WOW_2X}, + {"wow3", GameProfile::WOW_3X}, + {"wow-wotlk", GameProfile::WOW_3X}, + {"wow4", GameProfile::WOW_4X}, + {"wow-cataclysm", GameProfile::WOW_4X}, + {"wow5", GameProfile::WOW_5X}, + {"wow-mop", GameProfile::WOW_5X}, + {"starcraft2", GameProfile::STARCRAFT2}, + {"sc2", GameProfile::STARCRAFT2}, + {"diablo3", GameProfile::DIABLO3}, + {"d3", GameProfile::DIABLO3}}; + return profile_map; +} + +} // namespace + +GameProfile GameRules::StringToProfile(const std::string &profile_name) { + const auto &profile_map = GetProfileMap(); + std::string lower = ToLower(profile_name); + auto it = profile_map.find(lower); + if (it != profile_map.end()) { + return it->second; + } + return GameProfile::GENERIC; +} + +std::string GameRules::ProfileToString(GameProfile profile) { + switch (profile) { + case GameProfile::GENERIC: + return "generic"; + case GameProfile::DIABLO1: + return "diablo1"; + case GameProfile::LORDSOFMAGIC: + return "lordsofmagic"; + case GameProfile::WARCRAFT2: + return "warcraft2"; + case GameProfile::STARCRAFT1: + return "starcraft1"; + case GameProfile::DIABLO2: + return "diablo2"; + case GameProfile::WARCRAFT3: + return "warcraft3"; + case GameProfile::WARCRAFT3_MAP: + return "warcraft3-map"; + case GameProfile::WOW_1X: + return "wow-vanilla"; + case GameProfile::WOW_2X: + return "wow-tbc"; + case GameProfile::WOW_3X: + return "wow-wotlk"; + case GameProfile::WOW_4X: + return "wow-cataclysm"; + case GameProfile::WOW_5X: + return "wow-mop"; + case GameProfile::STARCRAFT2: + return "starcraft2"; + case GameProfile::DIABLO3: + return "diablo3"; + default: + return "generic"; + } +} + +std::vector GameRules::GetCanonicalProfiles() { + static const std::vector all_profiles = { + GameProfile::GENERIC, GameProfile::DIABLO1, GameProfile::LORDSOFMAGIC, + GameProfile::STARCRAFT1, GameProfile::WARCRAFT2, GameProfile::DIABLO2, + GameProfile::WARCRAFT3, GameProfile::WARCRAFT3_MAP, GameProfile::WOW_1X, + GameProfile::WOW_2X, GameProfile::WOW_3X, GameProfile::WOW_4X, + GameProfile::WOW_5X, GameProfile::STARCRAFT2, GameProfile::DIABLO3, + }; + + std::vector profiles; + profiles.reserve(all_profiles.size()); + for (const auto &p : all_profiles) { + profiles.push_back(ProfileToString(p)); + } + return profiles; +} + +std::string GameRules::GetAvailableProfiles() { + auto profiles = GetCanonicalProfiles(); + std::string result; + + for (size_t i = 0; i < profiles.size(); ++i) { + result += profiles[i]; + if (i < profiles.size() - 1) { + result += ", "; + } + } + + return result; +} + +} // namespace mpqcli diff --git a/src/gamerules/profiles.h b/src/gamerules/profiles.h new file mode 100644 index 0000000..63e9ed2 --- /dev/null +++ b/src/gamerules/profiles.h @@ -0,0 +1,26 @@ +#ifndef GAMERULES_PROFILES_H +#define GAMERULES_PROFILES_H + +namespace mpqcli { + +enum class GameProfile { + GENERIC, // Default/generic MPQ with basic compression + DIABLO1, // Diablo I / Hellfire (1997) + LORDSOFMAGIC, // Lords of Magic SE (1998) + STARCRAFT1, // StarCraft / Brood War (1998) + WARCRAFT2, // Warcraft II: Battle.net Edition (1999) + DIABLO2, // Diablo II / Lords of Destruction (2000) + WARCRAFT3, // Warcraft III / The Frozen Throne (2002) + WARCRAFT3_MAP, // Warcraft III Map files (2002) + WOW_1X, // World of Warcraft 1 - Vanilla (2004) + WOW_2X, // World of Warcraft 2 - The Burning Crusade (2007) + WOW_3X, // World of Warcraft 3 - Wrath of the Lich King (2008) + WOW_4X, // World of Warcraft 4 - Cataclysm (2010) + WOW_5X, // World of Warcraft 5 - Mists of Pandaria (2012) + STARCRAFT2, // StarCraft II (2010) + DIABLO3 // Diablo III (2012) +}; + +} // namespace mpqcli + +#endif diff --git a/src/gamerules/rules.cpp b/src/gamerules/rules.cpp new file mode 100644 index 0000000..cdc5e25 --- /dev/null +++ b/src/gamerules/rules.cpp @@ -0,0 +1,152 @@ +#include "gamerules/rules.h" + +#include +#include +#include +#include + +#include "util/string.h" + +namespace mpqcli { + +GameRules::GameRules(GameProfile game_profile) : profile_(game_profile) { + InitializeRules(); +} + +bool GameRules::MatchFileMask(const std::string &filename, const std::string &mask) { + std::string lower_filename = ToLower(filename); + std::string lower_mask = ToLower(mask); + + // A mask and a name may each use either separator + std::replace(lower_filename.begin(), lower_filename.end(), '\\', '/'); + std::replace(lower_mask.begin(), lower_mask.end(), '\\', '/'); + + size_t mask_pos = 0; + size_t file_pos = 0; + size_t star_pos = std::string::npos; + size_t match_pos = 0; + + while (file_pos < lower_filename.length()) { + if (mask_pos < lower_mask.length() && + (lower_mask[mask_pos] == '?' || lower_mask[mask_pos] == lower_filename[file_pos])) { + mask_pos++; + file_pos++; + } else if (mask_pos < lower_mask.length() && lower_mask[mask_pos] == '*') { + star_pos = mask_pos; + match_pos = file_pos; + mask_pos++; + } else if (star_pos != std::string::npos) { + mask_pos = star_pos + 1; + match_pos++; + file_pos = match_pos; + } else { + return false; + } + } + + while (mask_pos < lower_mask.length() && lower_mask[mask_pos] == '*') { + mask_pos++; + } + + return mask_pos == lower_mask.length(); +} + +void GameRules::AddRuleByFileMask(const std::string &file_mask, DWORD mpq_flags, + DWORD compression_first, DWORD compression_next) { + rules_.emplace_back(file_mask, mpq_flags, compression_first, compression_next); +} + +void GameRules::AddRuleByFileSize(DWORD size_min, DWORD size_max, DWORD mpq_flags, + DWORD compression_first, DWORD compression_next) { + rules_.emplace_back(size_min, size_max, mpq_flags, compression_first, compression_next); +} + +void GameRules::AddRuleDefault(DWORD mpq_flags, DWORD compression_first, DWORD compression_next) { + rules_.emplace_back(mpq_flags, compression_first, compression_next); +} + +CompressionSettings GameRules::GetCompressionSettings(const std::string &filename, + const DWORD file_size) const { + for (const auto &rule : rules_) { + switch (rule.type) { + case RuleType::FILE_MASK: + if (MatchFileMask(filename, rule.file_mask)) { + return {rule.mpq_flags, rule.compression_first, rule.compression_next}; + } + break; + + case RuleType::FILE_SIZE: { + bool has_upper_limit = (rule.size_max != UINT32_MAX); + bool in_range = + file_size >= rule.size_min && (!has_upper_limit || file_size <= rule.size_max); + + if (in_range) { + return {rule.mpq_flags, rule.compression_first, rule.compression_next}; + } + break; + } + + case RuleType::DEFAULT: + return {rule.mpq_flags, rule.compression_first, rule.compression_next}; + } + } + + // Reached only by a profile that declares no DEFAULT rule + return {MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE, + MPQ_COMPRESSION_NEXT_SAME}; +} + +void GameRules::OverrideCreateSettings(const MpqCreateSettingsOverrides &overrides) { + // Whether the user set file_flags2 themselves decides the adjustment below + bool user_set_file_flags2 = false; + + // User overrides first: a value the user gave always wins, even one that + // looks wrong + + if (overrides.mpq_version.has_value()) { + create_settings_.mpq_version = overrides.mpq_version.value(); + } + + if (overrides.stream_flags.has_value()) { + create_settings_.stream_flags = overrides.stream_flags.value(); + } + + if (overrides.sector_size.has_value()) { + create_settings_.sector_size = overrides.sector_size.value(); + } + + if (overrides.raw_chunk_size.has_value()) { + create_settings_.raw_chunk_size = overrides.raw_chunk_size.value(); + } + + if (overrides.file_flags1.has_value()) { + create_settings_.file_flags1 = overrides.file_flags1.value(); + } + + if (overrides.file_flags2.has_value()) { + create_settings_.file_flags2 = overrides.file_flags2.value(); + user_set_file_flags2 = true; + } + + if (overrides.file_flags3.has_value()) { + create_settings_.file_flags3 = overrides.file_flags3.value(); + } + + if (overrides.attr_flags.has_value()) { + create_settings_.attr_flags = overrides.attr_flags.value(); + } + + // Then the adjustments the overrides imply, only where the user left the + // dependent value alone + + // StormLib (SFileCreateArchive.cpp) writes the (attributes) file only when both + // file_flags2 and attr_flags are non-zero, so attributes asked for without a + // storage flag would silently never be written. An explicit zero from the user + // is left alone. + if (!user_set_file_flags2 && create_settings_.file_flags2 == 0 && + create_settings_.attr_flags != 0) { + create_settings_.file_flags2 = MPQ_FILE_DEFAULT_INTERNAL; + } +} + +} // namespace mpqcli diff --git a/src/gamerules/rules.h b/src/gamerules/rules.h new file mode 100644 index 0000000..7bdd821 --- /dev/null +++ b/src/gamerules/rules.h @@ -0,0 +1,71 @@ +#ifndef GAMERULES_RULES_H +#define GAMERULES_RULES_H + +#include +#include + +#include + +#include "gamerules/profiles.h" +#include "gamerules/settings.h" + +namespace mpqcli { + +/// Compression rules and archive creation settings for one game profile +class GameRules { +private: + GameProfile profile_; + std::vector rules_; + MpqCreateSettings create_settings_; + + /// Matches filename against a mask with * and ? wildcards, ignoring case + static bool MatchFileMask(const std::string &filename, const std::string &mask); + + /// Adds a rule matched by file mask + void AddRuleByFileMask(const std::string &file_mask, DWORD mpq_flags, DWORD compression_first, + DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); + + /// Adds a rule matched by file size; a size_max of UINT32_MAX means no upper limit + void AddRuleByFileSize(DWORD size_min, DWORD size_max, DWORD mpq_flags, DWORD compression_first, + DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); + + /// Adds the fallback rule used when no other matches + void AddRuleDefault(DWORD mpq_flags, DWORD compression_first, + DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); + + /// Initialises the rules for the selected game profile + void InitializeRules(); + + /// Converts a GameProfile to its canonical name + static std::string ProfileToString(GameProfile profile); + +public: + /// Builds the rule set for game_profile + explicit GameRules(GameProfile game_profile); + + /// Returns the compression settings for a file, first matching rule wins + [[nodiscard]] CompressionSettings GetCompressionSettings(const std::string &filename, + DWORD file_size) const; + + /// Returns the MPQ creation settings + [[nodiscard]] const MpqCreateSettings &GetCreateSettings() const { return create_settings_; } + + /// Applies user overrides on top of the profile's creation settings + void OverrideCreateSettings(const MpqCreateSettingsOverrides &overrides); + + /// Converts a profile name or alias to a GameProfile, GENERIC when unknown + static GameProfile StringToProfile(const std::string &profile_name); + + /// Returns the canonical profile names, for display + static std::vector GetCanonicalProfiles(); + + /// Returns the canonical profile names as one comma-separated string + static std::string GetAvailableProfiles(); + + /// Returns the default profile, GENERIC + static GameProfile GetDefaultProfile() { return GameProfile::GENERIC; } +}; + +} // namespace mpqcli + +#endif diff --git a/src/gamerules/settings.h b/src/gamerules/settings.h new file mode 100644 index 0000000..df27440 --- /dev/null +++ b/src/gamerules/settings.h @@ -0,0 +1,92 @@ +#ifndef GAMERULES_SETTINGS_H +#define GAMERULES_SETTINGS_H + +#include +#include +#include + +#include + +namespace mpqcli { + +/// How a compression rule selects the files it applies to +enum class RuleType { + FILE_MASK, // Rule based on file pattern (e.g., "*.wav") + FILE_SIZE, // Rule based on file size range + DEFAULT // Default rule (fallback) +}; + +/// A single compression rule +struct CompressionRule { + RuleType type; + std::string file_mask; // For FILE_MASK rules (e.g., "*.wav", "UI\\*.blp") + DWORD size_min; // For FILE_SIZE rules + DWORD size_max; // For FILE_SIZE rules + DWORD mpq_flags; // MPQ file flags (compression, encryption, etc.) + DWORD compression_first; // Compression for first sector + DWORD compression_next; // Compression for subsequent sectors + + CompressionRule(std::string mask, const DWORD flags, const DWORD comp_first, + const DWORD comp_next = MPQ_COMPRESSION_NEXT_SAME) + : type(RuleType::FILE_MASK), file_mask(std::move(mask)), size_min(0), size_max(0), + mpq_flags(flags), compression_first(comp_first), compression_next(comp_next) {} + + CompressionRule(const DWORD min_size, const DWORD max_size, const DWORD flags, + const DWORD comp_first, const DWORD comp_next = MPQ_COMPRESSION_NEXT_SAME) + : type(RuleType::FILE_SIZE), file_mask(""), size_min(min_size), size_max(max_size), + mpq_flags(flags), compression_first(comp_first), compression_next(comp_next) {} + + CompressionRule(const DWORD flags, const DWORD comp_first, + const DWORD comp_next = MPQ_COMPRESSION_NEXT_SAME) + : type(RuleType::DEFAULT), file_mask(""), size_min(0), size_max(0), mpq_flags(flags), + compression_first(comp_first), compression_next(comp_next) {} +}; + +/// The compression settings applied to one file +struct CompressionSettings { + DWORD mpq_flags; + DWORD compression_first; + DWORD compression_next; +}; + +/// Optional overrides for the settings applied to added files +struct CompressionSettingsOverrides { + std::optional flags; + std::optional compression; + std::optional compression_next; +}; + +/// MPQ archive creation settings +struct MpqCreateSettings { + DWORD mpq_version; // MPQ format version (1, 2, 3, or 4) + DWORD stream_flags; // Stream flags (e.g., STREAM_PROVIDER_FLAT) + DWORD file_flags1; // File flags for (listfile) + DWORD file_flags2; // File flags for (attributes) + DWORD file_flags3; // File flags for (signature) + DWORD attr_flags; // Attribute flags (CRC32, FILETIME, MD5, etc.) + DWORD sector_size; // Sector size (typically 0x1000 or 0x4000) + DWORD raw_chunk_size; // Raw chunk size (for MPQ v4, typically 0x4000) + + /// Defaults for a version 1 archive with a flat file stream + MpqCreateSettings() + : mpq_version(MPQ_FORMAT_VERSION_1), + stream_flags(STREAM_PROVIDER_FLAT | BASE_PROVIDER_FILE), + file_flags1(MPQ_FILE_DEFAULT_INTERNAL), file_flags2(0), file_flags3(0), attr_flags(0), + sector_size(0x1000), raw_chunk_size(0) {} +}; + +/// Optional overrides for MPQ archive creation settings +struct MpqCreateSettingsOverrides { + std::optional mpq_version; + std::optional stream_flags; + std::optional file_flags1; + std::optional file_flags2; + std::optional file_flags3; + std::optional attr_flags; + std::optional sector_size; + std::optional raw_chunk_size; +}; + +} // namespace mpqcli + +#endif diff --git a/src/helpers.cpp b/src/helpers.cpp deleted file mode 100644 index d9ffda7..0000000 --- a/src/helpers.cpp +++ /dev/null @@ -1,201 +0,0 @@ -#include "helpers.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef _WIN32 -#include -#include -#endif - -#include - -namespace fs = std::filesystem; - -std::string FileTimeToLsTime(int64_t file_time) { - if (file_time == 0) { - return ""; - } - constexpr int64_t epoch_diff = 11644473600LL; - int64_t unix_time = (file_time / 10000000) - epoch_diff; - char buf[20]; - struct tm tm_buf; -#ifdef _WIN32 - localtime_s(&tm_buf, &unix_time); -#else - localtime_r(&unix_time, &tm_buf); -#endif - strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm_buf); - return std::string(buf); -} - -std::string NormalizeFilePath(const fs::path &path) { - std::string file_path = path.u8string(); -#ifndef _WIN32 - std::replace(file_path.begin(), file_path.end(), '\\', '/'); - return file_path; -#else - return file_path; -#endif -} - -std::string WindowsifyFilePath(const fs::path &path) { - std::string file_path = path.u8string(); - std::replace(file_path.begin(), file_path.end(), '/', '\\'); - return file_path; -} - -std::string StormErrorString(uint32_t err) { - switch (err) { - // clang-format off - case ERROR_SUCCESS: return "Success"; - case ERROR_FILE_NOT_FOUND: return "File not found"; - case ERROR_ACCESS_DENIED: return "Access denied (archive may be read-only or have open files)"; - case ERROR_INVALID_HANDLE: return "Invalid handle"; - case ERROR_NOT_ENOUGH_MEMORY: return "Not enough memory"; - case ERROR_NOT_SUPPORTED: return "Operation not supported"; - case ERROR_INVALID_PARAMETER: return "Invalid parameter"; - case ERROR_DISK_FULL: return "Disk full"; - case ERROR_ALREADY_EXISTS: return "Already exists"; - case ERROR_INSUFFICIENT_BUFFER: return "Insufficient buffer"; - case ERROR_BAD_FORMAT: return "Bad MPQ format"; - case ERROR_NO_MORE_FILES: return "No more files"; - case ERROR_HANDLE_EOF: return "End of file"; - case ERROR_CAN_NOT_COMPLETE: return "Cannot complete"; - case ERROR_FILE_CORRUPT: return "File is corrupt"; - case ERROR_BUFFER_OVERFLOW: return "Buffer overflow"; - case ERROR_INVALID_DATA: return "Invalid data"; - case ERROR_NO_UNICODE_TRANSLATION: return "No Unicode translation"; - case ERROR_AVI_FILE: return "Not an MPQ file (AVI file)"; - case ERROR_UNKNOWN_FILE_KEY: return "Unknown file encryption key"; - case ERROR_CHECKSUM_ERROR: return "Sector checksum mismatch"; - case ERROR_INTERNAL_FILE: return "Operation not allowed on internal file"; - case ERROR_BASE_FILE_MISSING: return "Base file missing for incremental patch"; - case ERROR_MARKED_FOR_DELETE: return "File is marked as deleted in the MPQ"; - case ERROR_FILE_INCOMPLETE: return "Required file part is missing"; - case ERROR_UNKNOWN_FILE_NAMES: return "At least one file name is unknown (listfile is incomplete)"; - case ERROR_CANT_FIND_PATCH_PREFIX: return "Cannot find patch prefix"; - case ERROR_FAKE_MPQ_HEADER: return "Fake MPQ header at this position"; - case ERROR_FILE_DELETED: return "File contains delete marker"; - default: return std::strerror(static_cast(err)); - // clang-format on - } -} - -std::vector ListFilesRecursive(const fs::path &directory, std::error_code &ec) { - std::vector files; - fs::recursive_directory_iterator it(directory, ec); - while (!ec && it != fs::recursive_directory_iterator()) { - // A dangling symlink reports not_found rather than a hard error, and is - // skipped like any other non-regular entry - std::error_code status_ec; - const fs::file_status status = it->status(status_ec); - if (fs::is_regular_file(status)) { - files.push_back(it->path()); - } else if (status_ec && status.type() != fs::file_type::not_found) { - ec = status_ec; - break; - } - it.increment(ec); - } - if (ec) { - return {}; - } - std::sort(files.begin(), files.end()); - return files; -} - -uint32_t CalculateMpqMaxFileValue(uint32_t file_count) { - // Always add 3 for "special" files - file_count += 3; - - // Based on file count, determine the max number of files an MPQ archive can hold - // We always have a minimum of 32 - // Anything over is rounded up to the closest power of 2 - // For example: 64, 128, 256 - // This is examples behavior of WoW MPQ archives (patches and installs) - if (file_count <= 32) { - return 32; - } - - return NextPowerOfTwo(file_count); -} - -uint32_t NextPowerOfTwo(uint32_t n) { - n--; - n |= n >> 1; - n |= n >> 2; - n |= n >> 4; - n |= n >> 8; - n |= n >> 16; - return n + 1; -} - -void PrintAsBinary(const char *buffer, uint32_t size) { -#ifdef _WIN32 - _setmode(_fileno(stdout), _O_BINARY); -#endif - std::cout.write(buffer, size); -} - -// CRC32 (ZIP/gzip polynomial) and MD5 are provided by the hash-library -// submodule, matching the values StormLib stores in (attributes). -std::optional ComputeFileCrc32(const fs::path &path) { - std::ifstream f(path, std::ios::binary); - if (!f) { - return std::nullopt; - } - CRC32 crc32; - char buf[65536]; - while (f.read(buf, sizeof(buf)) || f.gcount() > 0) { - crc32.add(buf, static_cast(f.gcount())); - } - // getHash yields the checksum as big-endian bytes; reassemble the value. - unsigned char digest[CRC32::HashBytes]; - crc32.getHash(digest); - return (static_cast(digest[0]) << 24) | (static_cast(digest[1]) << 16) | - (static_cast(digest[2]) << 8) | static_cast(digest[3]); -} - -bool ComputeFileMd5(const fs::path &path, uint8_t *md5_out) { - std::ifstream f(path, std::ios::binary); - if (!f) { - return false; - } - MD5 md5; - char buf[65536]; - while (f.read(buf, sizeof(buf)) || f.gcount() > 0) { - md5.add(buf, static_cast(f.gcount())); - } - md5.getHash(md5_out); - return true; -} - -// Returns the file's last-modification time as a Windows FILETIME value -// (100-nanosecond intervals since 1601-01-01 UTC). Returns 0 on error. -uint64_t LocalFileTimestamp(const fs::path &path) { -#ifdef _WIN32 - // _wstat64 handles paths with non-ASCII characters, which the narrow - // stat() would mangle on Windows. - struct _stat64 st {}; - if (_wstat64(path.wstring().c_str(), &st) != 0) { - return 0; - } -#else - struct stat st {}; - if (stat(path.string().c_str(), &st) != 0) { - return 0; - } -#endif - constexpr int64_t epoch_diff = 11644473600LL; - return static_cast((static_cast(st.st_mtime) + epoch_diff) * 10000000LL); -} diff --git a/src/helpers.h b/src/helpers.h deleted file mode 100644 index 617f7f1..0000000 --- a/src/helpers.h +++ /dev/null @@ -1,29 +0,0 @@ -#ifndef HELPERS_H -#define HELPERS_H - -#include -#include -#include -#include -#include -#include - -namespace fs = std::filesystem; - -std::string FileTimeToLsTime(int64_t file_time); -std::string NormalizeFilePath(const fs::path &path); -std::string WindowsifyFilePath(const fs::path &path); -std::string StormErrorString(uint32_t err); - -std::vector ListFilesRecursive(const fs::path &directory, std::error_code &ec); -uint32_t CalculateMpqMaxFileValue(uint32_t file_count); -uint32_t NextPowerOfTwo(uint32_t n); -void PrintAsBinary(const char *buffer, uint32_t size); - -// Local file checksum / timestamp helpers used by the --update logic. -// Each returns std::nullopt / false / 0 if the file cannot be read. -std::optional ComputeFileCrc32(const fs::path &path); -bool ComputeFileMd5(const fs::path &path, uint8_t *md5_out); -uint64_t LocalFileTimestamp(const fs::path &path); - -#endif diff --git a/src/main.cpp b/src/main.cpp deleted file mode 100644 index 825b873..0000000 --- a/src/main.cpp +++ /dev/null @@ -1,385 +0,0 @@ -#include -#include -#include -#include -#include - -#include - -#include "commands.h" -#include "completion.h" -#include "gamerules.h" -#include "validators.h" - -int main(int argc, char **argv) { - CLI::App app{ - "A command line tool to create, add, remove, list, extract, read, rename, and verify MPQ " - "archives " - "using the StormLib library"}; - - app.require_subcommand(1); - - // CLI: base - // These are reused in multiple subcommands - // clang-format off - std::string base_target; // all subcommands - std::string base_file; // extract, read - std::optional base_locale; // add, create, extract, read, remove - std::optional base_path; // add, create - std::optional base_output; // create, extract - std::optional base_listfile_name; // extract, list, compact - std::optional base_game_profile; // add, create - int64_t file_flags = -1; // add, create - int64_t file_compression = -1; // add, create - int64_t file_compression_next = -1; // add, create - // clang-format on - // CLI: info - std::optional info_property; - // CLI: add - bool add_overwrite = false; - bool add_update = false; - std::vector add_files; - // CLI: remove - std::vector remove_files; - // CLI: rename - std::string rename_new_file; - // CLI: extract - bool extract_keep_folder_structure = false; - // CLI: create - bool create_sign_archive = false; - int32_t create_mpq_version = -1; - int64_t create_stream_flags = -1; - int64_t create_sector_size = -1; - int64_t create_raw_chunk_size = -1; - int64_t create_file_flags1 = -1; - int64_t create_file_flags2 = -1; - int64_t create_file_flags3 = -1; - int64_t create_attr_flags = -1; - // CLI: list - bool list_detailed = false; - bool list_all = false; - std::vector list_properties; - // CLI: verify - bool verify_print_signature = false; - - // Preserve vertical alignment of string set initialisers - // clang-format off - std::set valid_info_properties = { - "format-version", - "header-offset", - "header-size", - "archive-size", - "file-count", - "max-files", - "signature-type", - }; - std::set valid_file_list_properties = { - "hash-index", - "name-hash1", - "name-hash2", - "name-hash3", - "locale", - "file-index", - "byte-offset", - "file-time", - "file-size", - "compressed-size", - "flags", - "encryption-key", - "encryption-key-raw", - }; - // clang-format on - - // Subcommand: Version - CLI::App *version = app.add_subcommand("version", "Prints program version"); - - // Subcommand: About - CLI::App *about = app.add_subcommand("about", "Prints program information"); - - // Subcommand: Info - CLI::App *info = app.add_subcommand("info", "Prints info about an MPQ archive"); - info->add_option("target", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - info->add_option("-p,--property", info_property, "Prints only a specific property value") - ->check(CLI::IsMember(valid_info_properties)); - - // Subcommand: Create - CLI::App *create = - app.add_subcommand("create", "Create an MPQ archive from target file or directory"); - create->add_option("target", base_target, "Directory or file to put in MPQ archive") - ->required() - ->check(CLI::ExistingPath); - create->add_option("-p,--path", base_path, - "Archive path for a single file, or prefix for a directory"); - create->add_option("-o,--output", base_output, "Output MPQ archive"); - create->add_flag("-s,--sign", create_sign_archive, "Sign the MPQ archive (default false)"); - create->add_option("--locale", base_locale, "Locale to use for added files") - ->check(locale_valid); - create - ->add_option("-g,--game", base_game_profile, - "Game profile for MPQ creation. Valid options:\n" + - GameRules::GetAvailableProfiles()) - ->check(game_profile_valid); - // MPQ creation settings overrides - create->add_option("--version", create_mpq_version, "Override the MPQ archive version") - ->check(CLI::Range(1, 4)) - ->group("Game setting overrides"); - create->add_option("--stream-flags", create_stream_flags, "Override stream flags") - ->group("Game setting overrides"); - create->add_option("--sector-size", create_sector_size, "Override sector size") - ->group("Game setting overrides"); - create - ->add_option("--raw-chunk-size", create_raw_chunk_size, - "Override raw chunk size for MPQ v4") - ->group("Game setting overrides"); - create->add_option("--file-flags1", create_file_flags1, "Override file flags for (listfile)") - ->group("Game setting overrides"); - create->add_option("--file-flags2", create_file_flags2, "Override file flags for (attributes)") - ->group("Game setting overrides"); - create->add_option("--file-flags3", create_file_flags3, "Override file flags for (signature)") - ->group("Game setting overrides"); - create - ->add_option("--attr-flags", create_attr_flags, - "Override attribute flags (CRC32, FILETIME, MD5)") - ->group("Game setting overrides"); - // Compression settings overrides for files being added - create->add_option("--flags", file_flags, "Override MPQ file flags for added files") - ->group("Game setting overrides"); - create - ->add_option("--compression", file_compression, - "Override compression for first sector of added files") - ->group("Game setting overrides"); - create - ->add_option("--compression-next", file_compression_next, - "Override compression for subsequent sectors of added files") - ->group("Game setting overrides"); - - // Subcommand: Add - CLI::App *add = app.add_subcommand("add", "Add files to an existing MPQ archive"); - add->add_option("archive", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - add->add_option("files", add_files, - "Files or directories to add; pass - to read paths from stdin") - ->required() - ->expected(-1); - add->add_option("-p,--path", base_path, - "Archive path for a single file, or prefix for a directory"); - CLI::Option *add_overwrite_flag = add->add_flag( - "-w,--overwrite", add_overwrite, "Replace every file that already exists in the archive"); - add->add_flag("-u,--update", add_update, - "Replace only files that changed. Compares size, then timestamp/MD5/CRC32") - ->excludes(add_overwrite_flag); - add->add_option("--locale", base_locale, "Locale to use for added file")->check(locale_valid); - add->add_option("-g,--game", base_game_profile, - "Game profile for compression rules. Valid options:\n" + - GameRules::GetAvailableProfiles()) - ->check(game_profile_valid); - // Compression settings overrides - add->add_option("--flags", file_flags, "Override MPQ file flags") - ->group("Game setting overrides"); - add->add_option("--compression", file_compression, "Override compression for first sector") - ->group("Game setting overrides"); - add->add_option("--compression-next", file_compression_next, - "Override compression for subsequent sectors") - ->group("Game setting overrides"); - - // Subcommand: Remove - CLI::App *remove = app.add_subcommand("remove", "Remove files from an existing MPQ archive"); - remove->add_option("archive", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - remove - ->add_option("files", remove_files, - "Archive paths of files to remove; pass - to read paths from stdin") - ->required() - ->expected(-1); - remove->add_option("--locale", base_locale, "Locale of file to remove")->check(locale_valid); - - // Subcommand: Rename - CLI::App *rename = app.add_subcommand("rename", "Rename a file in an existing MPQ archive"); - rename->add_option("archive", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - rename->add_option("old-file", base_file, "Current archive path of the file")->required(); - rename->add_option("new-file", rename_new_file, "New archive path of the file")->required(); - rename->add_option("--locale", base_locale, "Locale of file to rename")->check(locale_valid); - - // Subcommand: List - CLI::App *list = app.add_subcommand("list", "List files from the MPQ archive"); - list->add_option("target", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - list->add_option("-l,--listfile", base_listfile_name, "File listing content of an MPQ archive") - ->check(CLI::ExistingFile); - list->add_flag("-d,--detailed", list_detailed, - "File listing with additional columns (default false)"); - list->add_flag("-a,--all", list_all, "File listing including hidden files (default false)"); - list->add_option("-p,--property", list_properties, "Prints only specific property values") - ->check(CLI::IsMember(valid_file_list_properties)); - - // Subcommand: Extract - CLI::App *extract = app.add_subcommand("extract", "Extract files from the MPQ archive"); - extract->add_option("target", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - extract->add_option("-o,--output", base_output, "Output directory"); - extract->add_option("-f,--file", base_file, "Target file to extract"); - extract->add_flag("-k,--keep", extract_keep_folder_structure, - "Keep folder structure (default false)"); - extract - ->add_option("-l,--listfile", base_listfile_name, "File listing content of an MPQ archive") - ->check(CLI::ExistingFile); - extract->add_option("--locale", base_locale, "Preferred locale for extracted file"); - - // Subcommand: Read - CLI::App *read = app.add_subcommand("read", "Read a file from an MPQ archive"); - read->add_option("file", base_file, "File to read")->required(); - read->add_option("target", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - read->add_option("--locale", base_locale, "Preferred locale for read file"); - - // Subcommand: Verify - CLI::App *verify = app.add_subcommand("verify", "Verify the MPQ archive"); - verify->add_option("target", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - verify->add_flag("-p,--print", verify_print_signature, "Print the digital signature (in hex)"); - - // Subcommand: Compact - CLI::App *compact = app.add_subcommand("compact", "Compact the MPQ archive"); - compact->add_option("target", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - compact - ->add_option("-l,--listfile", base_listfile_name, "File listing content of an MPQ archive") - ->check(CLI::ExistingFile); - - // Subcommand: Completion - CLI::App *completion = app.add_subcommand("completion", "Generate shell completion script"); - CLI::App *completion_bash = - completion->add_subcommand("bash", "Generate bash completion script"); - CLI::App *completion_zsh = completion->add_subcommand("zsh", "Generate zsh completion script"); - CLI::App *completion_ps = - completion->add_subcommand("powershell", "Generate PowerShell completion script"); - CLI::App *completion_fish = - completion->add_subcommand("fish", "Generate fish completion script"); - - try { - app.parse(argc, argv); - } catch (const CLI::ParseError &e) { - // If we get a "subcommand required" error, print help message - if (e.get_exit_code() == static_cast(CLI::ExitCodes::RequiredError)) { - std::cout << app.help() << std::endl; - return 0; - } - return app.exit(e); - } - - if (app.got_subcommand(version)) { - return HandleVersion(); - } - - if (app.got_subcommand(about)) { - return HandleAbout(); - } - - if (app.got_subcommand(info)) { - return HandleInfo(base_target, info_property); - } - - if (app.got_subcommand(create)) { - return HandleCreate(base_target, base_path, base_output, create_sign_archive, base_locale, - base_game_profile, create_mpq_version, create_stream_flags, - create_sector_size, create_raw_chunk_size, create_file_flags1, - create_file_flags2, create_file_flags3, create_attr_flags, file_flags, - file_compression, file_compression_next); - } - - if (app.got_subcommand(add)) { - std::vector resolved_add_files; - for (const auto &f : add_files) { - if (f == "-") { - std::string line; - while (std::getline(std::cin, line)) { - if (!line.empty()) - resolved_add_files.push_back(line); - } - } else { - resolved_add_files.push_back(f); - } - } - return HandleAdd(resolved_add_files, base_target, base_path, add_overwrite, add_update, - base_locale, base_game_profile, file_flags, file_compression, - file_compression_next); - } - - if (app.got_subcommand(remove)) { - std::vector resolved_remove_files; - for (const auto &f : remove_files) { - if (f == "-") { - std::string line; - while (std::getline(std::cin, line)) { - if (!line.empty()) - resolved_remove_files.push_back(line); - } - } else { - resolved_remove_files.push_back(f); - } - } - return HandleRemove(resolved_remove_files, base_target, base_locale); - } - - if (app.got_subcommand(rename)) { - return HandleRename(base_file, rename_new_file, base_target, base_locale); - } - - if (app.got_subcommand(list)) { - return HandleList(base_target, base_listfile_name, list_all, list_detailed, - list_properties); - } - - if (app.got_subcommand(extract)) { - std::optional extract_file = - base_file.empty() ? std::nullopt : std::make_optional(base_file); - return HandleExtract(base_target, base_output, extract_file, extract_keep_folder_structure, - base_listfile_name, base_locale); - } - - if (app.got_subcommand(read)) { - return HandleRead(base_file, base_target, base_locale); - } - - if (app.got_subcommand(verify)) { - return HandleVerify(base_target, verify_print_signature); - } - - if (app.got_subcommand(compact)) { - return HandleCompact(base_target, base_listfile_name); - } - - if (app.got_subcommand(completion)) { - if (completion->got_subcommand(completion_bash)) { - HandleCompletionBash(); - return 0; - } - if (completion->got_subcommand(completion_zsh)) { - HandleCompletionZsh(); - return 0; - } - if (completion->got_subcommand(completion_ps)) { - HandleCompletionPs(); - return 0; - } - if (completion->got_subcommand(completion_fish)) { - HandleCompletionFish(); - return 0; - } - std::cerr << completion->help(); - return 1; - } - - return 0; -} diff --git a/src/mpq.cpp b/src/mpq.cpp deleted file mode 100644 index 053cfa5..0000000 --- a/src/mpq.cpp +++ /dev/null @@ -1,852 +0,0 @@ -#include "mpq.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "gamerules.h" -#include "helpers.h" -#include "locales.h" - -namespace fs = std::filesystem; - -static const std::vector special_mpq_files = {"(listfile)", "(signature)", - "(attributes)"}; - -bool OpenMpqArchive(const std::string &filename, HANDLE *archive, int32_t flags) { - if (!SFileOpenArchive(filename.c_str(), 0, flags, archive)) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to open MPQ archive: " << filename << ": (" << error << ") " - << StormErrorString(error) << std::endl; - return false; - } - return true; -} - -bool CloseMpqArchive(HANDLE archive) { - if (!SFileCloseArchive(archive)) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to close MPQ archive: (" << error << ") " - << StormErrorString(error) << std::endl; - return false; - } - return true; -} - -bool FileExistsInArchiveForLocale(const HANDLE archive, const std::string &file_path, - const LCID locale) { - bool file_exists = false; - SFileSetLocale(locale); - HANDLE file; - if (SFileOpenFileEx(archive, file_path.c_str(), SFILE_OPEN_FROM_MPQ, &file)) { - const auto file_locale = GetFileInfo(file, SFileInfoLocale); - if (file_locale == locale) { - file_exists = true; - } - SFileCloseFile(file); - } - return file_exists; -} - -bool SignMpqArchive(HANDLE archive) { - if (!SFileSignArchive(archive, SIGNATURE_TYPE_WEAK)) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to sign MPQ archive: (" << error << ") " << StormErrorString(error) - << std::endl; - return false; - } - return true; -} - -static bool IsWithinDirectory(const fs::path &base, const fs::path &path) { - return std::mismatch(base.begin(), base.end(), path.begin(), path.end()).first == base.end(); -} - -int ExtractFiles(HANDLE archive, const std::string &output, - const std::optional &listfile_name, LCID preferred_locale) { - SFileSetLocale(preferred_locale); - // Check if the user provided a listfile input - const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; - - SFILE_FIND_DATA find_data; - HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); - if (find_handle == nullptr) { - std::cerr << "[!] Failed to find first file in MPQ archive." << std::endl; - return 1; - } - - int32_t result = 0; - do { - result |= ExtractFile(archive, output, find_data.cFileName, - true, // Keep folder structure - preferred_locale); - } while (SFileFindNextFile(find_handle, &find_data)); - - SFileFindClose(find_handle); - return result; -} - -int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, - bool keep_folder_structure, LCID preferred_locale) { - SFileSetLocale(preferred_locale); - if (!FileExistsInArchiveForLocale(archive, file_name.c_str(), preferred_locale) && - !FileExistsInArchiveForLocale(archive, file_name.c_str(), default_locale)) { - std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(preferred_locale, " for locale ", true) << ": " << file_name - << std::endl; - return 1; - } - - // Change forward slashes on non-Windows systems - fs::path file_name_path(file_name); - std::string file_name_string = NormalizeFilePath(file_name_path); - - // Remove folder structure if keepFolderStructure is false - if (!keep_folder_structure) { - file_name_path = fs::path(file_name_string); - file_name_string = file_name_path.filename().u8string(); - } - - std::error_code ec; - fs::path output_path_base = fs::absolute(output, ec).lexically_normal(); - if (ec) { - std::cerr << "[!] Failed to resolve output directory: (" << ec.value() << ") " - << ec.message() << ": " << output << std::endl; - return 1; - } - if (output_path_base.filename().empty()) { - output_path_base = output_path_base.parent_path(); - } - - // Guard against path traversal attacks in two stages. First lexically, so a - // ".." entry is rejected before anything is created on disk - fs::path output_file_path_name = (output_path_base / file_name_string).lexically_normal(); - if (!IsWithinDirectory(output_path_base, output_file_path_name)) { - std::cerr << "[!] Blocked: path traversal attempt detected: " << file_name_string - << std::endl; - return 1; - } - - // Ensure sub-directories for folder-nested files exist before resolving - fs::create_directories(output_file_path_name.parent_path(), ec); - if (ec) { - std::cerr << "[!] Failed to create output directory: (" << ec.value() << ") " - << ec.message() << ": " << output_file_path_name.parent_path().u8string() - << std::endl; - return 1; - } - - // Second, through the OS to also catch symlinks. Volumes that cannot report - // real paths (RAM disks) fail here, in which case the lexical check above is - // the only guard; HandleExtract warns about this once - fs::path resolved_base = fs::canonical(output_path_base, ec); - if (!ec) { - fs::path resolved_output = fs::canonical(output_file_path_name.parent_path(), ec) / - output_file_path_name.filename(); - if (ec) { - std::cerr << "[!] Failed to resolve output path: (" << ec.value() << ") " - << ec.message() << ": " << output_file_path_name.u8string() << std::endl; - return 1; - } - if (!IsWithinDirectory(resolved_base, resolved_output)) { - std::cerr << "[!] Blocked: path traversal attempt detected: " << file_name_string - << std::endl; - return 1; - } - } - - std::string output_file_name{output_file_path_name.u8string()}; - - if (SFileExtractFile(archive, file_name.c_str(), output_file_name.c_str(), 0)) { - std::cout << "[*] Extracted: " << file_name_string << std::endl; - } else { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed: (" << error << ") " << StormErrorString(error) << ": " - << file_name << std::endl; - return 1; - } - - return 0; -} - -HANDLE CreateMpqArchive(const std::string &output_archive_name, const uint32_t file_count, - const GameRules &game_rules) { - // Check if file already exists - std::error_code ec; - if (fs::exists(output_archive_name, ec)) { - std::cerr << "[!] File already exists: " << output_archive_name << " Exiting..." - << std::endl; - return nullptr; - } - - HANDLE archive; - - // Use game-specific create settings - const MpqCreateSettings &settings = game_rules.GetCreateSettings(); - - SFILE_CREATE_MPQ create_info = {}; - // All logic for defaults and dependencies is handled in GameRules::OverrideCreateSettings - create_info.cbSize = sizeof(SFILE_CREATE_MPQ); - create_info.dwMpqVersion = settings.mpq_version; - create_info.dwStreamFlags = settings.stream_flags; - create_info.dwFileFlags1 = settings.file_flags1; - create_info.dwFileFlags2 = settings.file_flags2; - create_info.dwFileFlags3 = settings.file_flags3; - create_info.dwAttrFlags = settings.attr_flags; - create_info.dwSectorSize = settings.sector_size; - create_info.dwRawChunkSize = settings.raw_chunk_size; - create_info.dwMaxFileCount = file_count; - - const bool result = SFileCreateArchive2(output_archive_name.c_str(), &create_info, &archive); - - if (!result) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to create MPQ archive: " << output_archive_name << ": (" << error - << ") " << StormErrorString(error) << std::endl; - return nullptr; - } - - return archive; -} - -static bool ArchivedFileMatches(HANDLE archive, HANDLE file, const fs::path &local_file, - std::string &match_reason) { - const DWORD archived_size = SFileGetFileSize(file, nullptr); - std::error_code ec; - const uintmax_t disk_size = fs::file_size(local_file, ec); - if (ec || disk_size != static_cast(archived_size)) { - return false; - } - - const DWORD attr_flags = SFileGetAttributes(archive); - - // Step 1: Timestamp: cheapest check, no local file I/O. - if (attr_flags & MPQ_ATTRIBUTE_FILETIME) { - const uint64_t archived_time = GetFileInfo(file, SFileInfoFileTime); - const uint64_t local_time = LocalFileTimestamp(local_file); - // Compare at second resolution: stat() has only second precision. - if (archived_time != 0 && local_time != 0 && - archived_time / 10000000u == local_time / 10000000u) { - match_reason = "Timestamp matches"; - return true; - } - } - - // Step 2: MD5: if timestamp did not match or was unavailable. - if (attr_flags & MPQ_ATTRIBUTE_MD5) { - uint8_t archived_md5[MD5_DIGEST_SIZE]{}; - if (SFileGetFileInfo(file, SFileInfoMD5, archived_md5, sizeof(archived_md5), nullptr)) { - // An all-zero digest means "no MD5 stored". A file whose - // real MD5 is all zeroes is astronomically unlikely; the - // worst case is a redundant re-add. - const uint8_t zero_md5[MD5_DIGEST_SIZE]{}; - if (std::memcmp(archived_md5, zero_md5, MD5_DIGEST_SIZE) != 0) { - uint8_t local_md5[MD5_DIGEST_SIZE]{}; - if (ComputeFileMd5(local_file, local_md5) && - std::memcmp(local_md5, archived_md5, MD5_DIGEST_SIZE) == 0) { - match_reason = "MD5 matches"; - return true; - } - } - } - } - - // Step 3: CRC32: if neither timestamp nor MD5 matched or was available. - if (attr_flags & MPQ_ATTRIBUTE_CRC32) { - const DWORD archived_crc32 = GetFileInfo(file, SFileInfoCRC32); - // Zero means "no CRC32 stored"; a file whose real CRC32 is - // zero just gets a redundant re-add. - if (archived_crc32 != 0) { - if (auto local_crc32 = ComputeFileCrc32(local_file)) { - if (*local_crc32 == archived_crc32) { - match_reason = "CRC32 matches"; - return true; - } - } - } - } - - // No attributes present, or none matched: re-add the file. - return false; -} - -int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, - const std::string &path_prefix, LCID locale, const GameRules &game_rules, - const CompressionSettingsOverrides &overrides, bool overwrite, bool update, - int *skipped) { - int files_added = 0; - int files_skipped = 0; - int files_failed = 0; - - for (const auto &file : files) { - // Determine relative path lexically rather than with fs::relative, which - // resolves paths through the OS and throws on volumes that cannot report - // real paths (RAM disks, some network shares). - fs::path input_file_path = file.lexically_relative(base_path); - std::string archive_file_path; - - if (path_prefix.empty()) { - archive_file_path = WindowsifyFilePath(input_file_path.u8string()); - } else { - archive_file_path = - WindowsifyFilePath((fs::path(path_prefix) / input_file_path).u8string()); - } - - if (std::find(special_mpq_files.begin(), special_mpq_files.end(), archive_file_path) != - special_mpq_files.end()) { - std::cout << "[*] Skipping special MPQ file: " << archive_file_path << std::endl; - continue; - } - - int file_skipped = 0; - if (AddFile(archive, file, archive_file_path, locale, game_rules, overrides, overwrite, - update, &file_skipped) != 0) { - files_failed++; - } else if (file_skipped > 0) { - files_skipped++; - } else { - files_added++; - } - } - - if (update) { - std::cout << "[*] For " << base_path.u8string() << ": " << files_added << " files added, " - << files_skipped << " files skipped, " << files_failed << " files failed." - << std::endl; - } - - if (skipped != nullptr) { - *skipped += files_skipped; - } - - return files_failed > 0 ? 1 : 0; -} - -int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archive_file_path, - const LCID locale, const GameRules &game_rules, - const CompressionSettingsOverrides &overrides, bool overwrite, bool update, - int *skipped) { - // Return if file doesn't exist on disk - std::error_code ec; - if (!fs::exists(local_file, ec)) { - std::cerr << "[!] File doesn't exist on disk: " << local_file << std::endl; - return 1; - } - - // Check if file exists in MPQ archive - SFileSetLocale(locale); - HANDLE file; - if (SFileOpenFileEx(archive, archive_file_path.c_str(), SFILE_OPEN_FROM_MPQ, &file)) { - const auto file_locale = GetFileInfo(file, SFileInfoLocale); - if (file_locale == locale) { - // --update: leave the archived copy alone while it still matches the local file - std::string match_reason; - const bool unchanged = - update && ArchivedFileMatches(archive, file, local_file, match_reason); - SFileCloseFile(file); - - if (unchanged) { - std::cout << "[~] Skipping unchanged file: " << archive_file_path << " (" - << match_reason << ")" << std::endl; - if (skipped != nullptr) { - (*skipped)++; - } - return 0; - } - - // Without either flag, leaving the archived copy in place is the documented - // default, so this is a skip rather than a failure. - if (!overwrite && !update) { - std::cerr << "[!] File" << PrettyPrintLocale(locale, " for locale ") - << " already exists in MPQ archive: " << archive_file_path - << " - Skipping..." << std::endl; - if (skipped != nullptr) { - (*skipped)++; - } - return 0; - } - - std::cout << "[+] File" << PrettyPrintLocale(locale, " for locale ") - << " already exists in MPQ archive: " << archive_file_path - << " - Overwriting..." << std::endl; - } else { - SFileCloseFile(file); - } - } - std::cout << "[+] Adding file" << PrettyPrintLocale(locale, " for locale ") << ": " - << archive_file_path << std::endl; - - // Verify that we are not exceeding maxFile size of the archive, and if we do, increase it - int32_t number_of_files = GetFileInfo(archive, SFileMpqNumberOfFiles); - int32_t max_files = GetFileInfo(archive, SFileMpqMaxFileCount); - - if (number_of_files + 1 > max_files) { - uint32_t new_max_files = NextPowerOfTwo(static_cast(number_of_files + 1)); - bool set_max_file_count = SFileSetMaxFileCount(archive, new_max_files); - if (!set_max_file_count) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to increase new max file count to " << new_max_files << ": (" - << error << ") " << StormErrorString(error) << std::endl; - return 1; - } - } - - // Get file size for rule matching - const std::uintmax_t raw_file_size = fs::file_size(local_file, ec); - if (ec) { - std::cerr << "[!] Failed to read file size: (" << ec.value() << ") " << ec.message() << ": " - << local_file << std::endl; - return 1; - } - if (raw_file_size > std::numeric_limits::max()) { - std::cerr << "[!] Warning: file exceeds 4GB, size-based compression rules may not apply " - "correctly: " - << local_file << std::endl; - } - const DWORD file_size = static_cast( - std::min(raw_file_size, static_cast(std::numeric_limits::max()))); - - // Get game-specific rules - const auto settings = game_rules.GetCompressionSettings(archive_file_path, file_size); - - // Apply overrides where specified, otherwise use game rules - DWORD flags = overrides.flags.value_or(settings.mpq_flags); - DWORD compression = overrides.compression.value_or(settings.compression_first); - DWORD compression_next = overrides.compression_next.value_or(settings.compression_next); - - // Both flags mean "replace what is already there"; --update has simply decided - // beforehand that this particular file is worth replacing. - if (overwrite || update) { - flags |= MPQ_FILE_REPLACEEXISTING; - } - - bool added_file = - SFileAddFileEx(archive, local_file.u8string().c_str(), archive_file_path.c_str(), flags, - compression, compression_next); - - if (!added_file) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to add: " << archive_file_path << ": (" << error << ") " - << StormErrorString(error) << std::endl; - return 1; - } - - return 0; -} - -int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale) { - SFileSetLocale(locale); - std::cout << "[-] Removing file" << PrettyPrintLocale(locale, " for locale ") << ": " - << archive_file_path << std::endl; - - if (!FileExistsInArchiveForLocale(archive, archive_file_path, locale)) { - std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(locale, " for locale ", true) << ": " << archive_file_path - << std::endl; - return 1; - } - - if (!SFileRemoveFile(archive, archive_file_path.c_str(), 0)) { - std::cerr << "[!] Failed: File cannot be removed" - << PrettyPrintLocale(locale, " for locale ", true) << ": " << archive_file_path - << std::endl; - return 1; - } - - return 0; -} - -int RenameFile(HANDLE archive, const std::string &old_archive_file_path, - const std::string &new_archive_file_path, LCID locale) { - SFileSetLocale(locale); - const std::string new_stored_path = WindowsifyFilePath(new_archive_file_path); - std::cout << "[~] Renaming file" << PrettyPrintLocale(locale, " for locale ") << ": " - << old_archive_file_path << " -> " << new_stored_path << std::endl; - - if (!FileExistsInArchiveForLocale(archive, old_archive_file_path, locale)) { - std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(locale, " for locale ", true) << ": " - << old_archive_file_path << std::endl; - return 1; - } - - if (!SFileRenameFile(archive, old_archive_file_path.c_str(), new_stored_path.c_str())) { - std::cerr << "[!] Failed: File cannot be renamed" - << PrettyPrintLocale(locale, " for locale ", true) << ": " - << old_archive_file_path << " -> " << new_stored_path << std::endl; - return 1; - } - - return 0; -} - -std::string GetFlagString(uint32_t flags) { - std::string result; - - // Preserve column-aligned flag-to-char mappings - // clang-format off - if (flags & MPQ_FILE_IMPLODE) result += 'i'; - if (flags & MPQ_FILE_COMPRESS) result += 'c'; - if (flags & MPQ_FILE_ENCRYPTED) result += 'e'; - if (flags & MPQ_FILE_KEY_V2) result += '2'; - if (flags & MPQ_FILE_PATCH_FILE) result += 'p'; - if (flags & MPQ_FILE_SINGLE_UNIT) result += 'u'; - if (flags & MPQ_FILE_DELETE_MARKER) result += 'd'; - if (flags & MPQ_FILE_SECTOR_CRC) result += 'r'; - if (flags & MPQ_FILE_SIGNATURE) result += 's'; - if (flags & MPQ_FILE_EXISTS) result += 'x'; - if (flags & MPQ_FILE_COMPRESS_MASK) result += 'm'; - if (flags & MPQ_FILE_DEFAULT_INTERNAL) result += 'n'; - if (flags & MPQ_FILE_FIX_KEY) result += 'f'; - // clang-format on - - return result; -} - -int ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, - bool list_detailed, const std::vector &properties) { - // Check if the user provided a listfile input - const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; - - SFILE_FIND_DATA find_data; - HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); - if (find_handle == nullptr) { - std::cerr << "[!] Failed to find first file in MPQ archive." << std::endl; - return -1; - } - - std::vector properties_to_print = - properties.empty() ? std::vector{"file-size", "locale", "file-time"} - : properties; - if (!properties.empty()) { - list_detailed = - true; // If the user specified properties, we need to print the detailed output - } - - // Map of property name to SFileInfoClass, defined once, outside the loop - static const std::map property_info_class = { - {"hash-index", SFileInfoHashIndex}, - {"name-hash1", SFileInfoNameHash1}, - {"name-hash2", SFileInfoNameHash2}, - {"name-hash3", SFileInfoNameHash3}, - {"locale", SFileInfoLocale}, - {"file-index", SFileInfoFileIndex}, - {"byte-offset", SFileInfoByteOffset}, - {"file-time", SFileInfoFileTime}, - {"file-size", SFileInfoFileSize}, - {"compressed-size", SFileInfoCompressedSize}, - {"flags", SFileInfoFlags}, - {"encryption-key", SFileInfoEncryptionKey}, - {"encryption-key-raw", SFileInfoEncryptionKeyRaw}, - }; - - std::set - seen_file_names; // Used to prevent printing the same file name multiple times - // Loop through all files in the MPQ archive - do { - // Skip special files unless user wants to list all (like ls -a) - if (!list_all && std::find(special_mpq_files.begin(), special_mpq_files.end(), - find_data.cFileName) != special_mpq_files.end()) { - continue; - } - - // Print the detailed (long) file listing (like ls -l) - if (list_detailed) { - if (seen_file_names.find(find_data.cFileName) != seen_file_names.end()) { - // Filename has been seen before, and thus printed before. Skip over it. - continue; - } - seen_file_names.insert(find_data.cFileName); - - // Multiple files can be stored with identical filenames under different locales. - // Loop over all locales and print the file details for each locale. - DWORD max_locales = 32; // This will be updated in the call to SFileEnumLocales - std::vector file_locale_vec(max_locales); - LCID *file_locales = file_locale_vec.data(); - - DWORD result = - SFileEnumLocales(archive, find_data.cFileName, file_locales, &max_locales, 0); - - if (result == ERROR_INVALID_PARAMETER) { - // This ought to mean that the file name is unknown, whereupon `SFileEnumLocales` - // exits early since its check for `IsPseudoFileName` returns true. If that is the - // case, it will not have populated `fileLocales` or have updated `maxLocales`. Just - // set the maxLocales to 1 and list the file with the unknown name once. - max_locales = 1; - file_locales[0] = default_locale; - - } else if (result == ERROR_INVALID_HANDLE || result == ERROR_NOT_SUPPORTED) { - std::cerr << "[!] Internal error for file: " << find_data.cFileName << std::endl; - continue; - - } else if (result == ERROR_INSUFFICIENT_BUFFER) { - std::cerr << "[!] There are more than " << max_locales - << " locales for the file: " << find_data.cFileName - << ". Will only list the " << max_locales << " first files." << std::endl; - } - - // Loop through all found locales - for (DWORD i = 0; i < max_locales; i++) { - LCID locale = file_locales[i]; - SFileSetLocale(locale); - HANDLE file; - - // We need to open the file to get detailed information - // Use our custom GetFileInfo function - if (!SFileOpenFileEx(archive, find_data.cFileName, SFILE_OPEN_FROM_MPQ, &file)) { - std::cerr << "[!] Failed to open file: " << find_data.cFileName << std::endl; - continue; // Skip to the next file - } - - for (const auto &prop : properties_to_print) { - auto it = property_info_class.find(prop); - if (it == property_info_class.end()) - continue; - - if (prop == "hash-index" || prop == "file-index") { - std::cout << std::setw(5) << GetFileInfo(file, it->second) << " "; - } else if (prop == "name-hash1" || prop == "name-hash2") { - std::cout << std::setfill('0') << std::hex << std::setw(8) - << GetFileInfo(file, it->second) << std::setfill(' ') - << std::dec << " "; - } else if (prop == "name-hash3") { - std::cout << std::setfill('0') << std::hex << std::setw(16) - << GetFileInfo(file, it->second) << std::setfill(' ') - << std::dec << " "; - } else if (prop == "locale") { - std::cout << std::setw(4) - << LocaleToLang(GetFileInfo(file, it->second)) << " "; - } else if (prop == "byte-offset") { - std::cout << std::hex << std::setw(8) - << GetFileInfo(file, it->second) << std::dec << " "; - } else if (prop == "file-time") { - std::cout << std::setw(19) - << FileTimeToLsTime(GetFileInfo(file, it->second)) - << " "; - } else if (prop == "file-size" || prop == "compressed-size") { - std::cout << std::setw(8) << GetFileInfo(file, it->second) << " "; - } else if (prop == "flags") { - std::cout << std::setw(8) - << GetFlagString(GetFileInfo(file, it->second)) << " "; - } else if (prop == "encryption-key" || prop == "encryption-key-raw") { - std::cout << std::setfill('0') << std::hex << std::setw(8) - << GetFileInfo(file, it->second) << std::setfill(' ') - << std::dec << " "; - } - } - - std::cout << " " << find_data.cFileName << std::endl; - SFileCloseFile(file); - } - SFileSetLocale(default_locale); // Reset locale to default after changing it - } else { - // Print just the filename (like default ls command output) - std::cout << find_data.cFileName << std::endl; - } - - } while (SFileFindNextFile(find_handle, &find_data)); - - SFileFindClose(find_handle); - return 0; -} - -std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned int *file_size, - LCID preferred_locale) { - SFileSetLocale(preferred_locale); - if (!FileExistsInArchiveForLocale(archive, file_name, preferred_locale) && - !FileExistsInArchiveForLocale(archive, file_name, default_locale)) { - std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(preferred_locale, " for locale ", true) << ": " << file_name - << std::endl; - return nullptr; - } - - HANDLE file; - if (!SFileOpenFileEx(archive, file_name, SFILE_OPEN_FROM_MPQ, &file)) { - std::cerr << "[!] Failed: File cannot be opened: " << file_name << std::endl; - return nullptr; - } - - *file_size = SFileGetFileSize(file, nullptr); - if (*file_size == SFILE_INVALID_SIZE) { - std::cerr << "[!] Failed: Invalid file size for: " << file_name << std::endl; - SFileCloseFile(file); - return nullptr; - } - - auto file_content = std::make_unique(*file_size); - DWORD bytes_read; - if (!SFileReadFile(file, file_content.get(), *file_size, &bytes_read, nullptr)) { - std::cerr << "[!] Failed: Cannot read file contents for: " << file_name << std::endl; - SFileCloseFile(file); - return nullptr; - } - - SFileCloseFile(file); - return file_content; -} - -void PrintMpqInfo(HANDLE archive, const std::optional &info_property) { - // Map of property names to their corresponding actions - std::map> property_actions = { - {"format-version", - [&](bool print_name) { - TMPQHeader header = GetFileInfo(archive, SFileMpqHeader); - uint16_t format_version = - header.wFormatVersion + 1; // Add +1 because StormLib starts at 0 - if (print_name) { - std::cout << "Format version: "; - } - std::cout << format_version << std::endl; - }}, - {"header-offset", - [&](bool print_name) { - int64_t header_offset = GetFileInfo(archive, SFileMpqHeaderOffset); - if (print_name) { - std::cout << "Header offset: "; - } - std::cout << header_offset << std::endl; - }}, - {"header-size", - [&](bool print_name) { - int64_t header_size = GetFileInfo(archive, SFileMpqHeaderSize); - if (print_name) { - std::cout << "Header size: "; - } - std::cout << header_size << std::endl; - }}, - {"archive-size", - [&](bool print_name) { - int64_t archive_size = GetFileInfo(archive, SFileMpqArchiveSize64); - if (print_name) { - std::cout << "Archive size: "; - } - std::cout << archive_size << std::endl; - }}, - {"file-count", - [&](bool print_name) { - int32_t number_of_files = GetFileInfo(archive, SFileMpqNumberOfFiles); - if (print_name) { - std::cout << "File count: "; - } - std::cout << number_of_files << std::endl; - }}, - {"max-files", - [&](bool print_name) { - int32_t max_files = GetFileInfo(archive, SFileMpqMaxFileCount); - if (print_name) { - std::cout << "Max files: "; - } - std::cout << max_files << std::endl; - }}, - {"signature-type", [&](bool print_name) { - int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); - if (print_name) { - std::cout << "Signature type: "; - } - if (signature_type == SIGNATURE_TYPE_NONE) { - std::cout << "None" << std::endl; - } else if (signature_type == SIGNATURE_TYPE_WEAK) { - std::cout << "Weak" << std::endl; - } else if (signature_type == SIGNATURE_TYPE_STRONG) { - std::cout << "Strong" << std::endl; - } - }}}; - - // If infoProperty is not set, print all properties with their names (key) - // Otherwise, print only the specified property value - if (!info_property.has_value()) { - for (const auto &[key, action] : property_actions) { - action(true); // Print property name and value - } - } else { - auto it = property_actions.find(info_property.value()); - if (it != property_actions.end()) { - it->second(false); // Print only the value - } - } -} - -uint32_t VerifyMpqArchive(HANDLE archive) { - return SFileVerifyArchive(archive); -} - -int CompactMpqArchive(HANDLE archive, const std::optional &listfile_name) { - std::cout << "[*] Compacting archive. This may take some time..." << std::endl; - // Check if the user provided a listfile input - const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; - - if (!SFileCompactArchive(archive, listfile, false)) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to compact archive: (" << error << ") " << StormErrorString(error) - << std::endl; - return 1; - } - return 0; -} - -int32_t PrintMpqSignature(HANDLE archive, const std::string &target) { - // Determine if we have a strong or weak digital signature - int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); - - std::vector signature_content; - - if (signature_type == SIGNATURE_TYPE_NONE) { - return 1; - } else if (signature_type == SIGNATURE_TYPE_WEAK) { - const char *file_name = "(signature)"; - uint32_t file_size; - auto file_content = ReadFile(archive, file_name, &file_size, default_locale); - - if (!file_content) { - std::cerr << "[!] Failed to read weak signature file." << std::endl; - return -1; - } - signature_content.resize(file_size); - std::copy(file_content.get(), file_content.get() + file_size, signature_content.begin()); - - PrintAsBinary(file_content.get(), file_size); - - } else if (signature_type == SIGNATURE_TYPE_STRONG) { - // StormLib does not expose the strong signature via SFileGetFileInfo into a - // growable buffer (SFileMpqStrongSignature's advertised size always exceeds - // sizeof(std::vector)), so read it directly from the archive file instead. - int64_t archive_size = GetFileInfo(archive, SFileMpqArchiveSize64); - int64_t archive_offset = GetFileInfo(archive, SFileMpqHeaderOffset); - - const fs::path archive_path(target); - std::error_code ec; - const std::uintmax_t file_size = fs::file_size(archive_path, ec); - if (ec) { - std::cerr << "[!] Failed to read archive size: (" << ec.value() << ") " << ec.message() - << ": " << target << std::endl; - return -1; - } - int64_t signature_length = file_size - archive_offset - archive_size; - - if (signature_length <= 0) { - std::cerr << "[!] Invalid signature length: " << signature_length << std::endl; - return -1; - } - - std::ifstream file_mpq(archive_path, std::ios::binary); - file_mpq.seekg(archive_offset + archive_size, std::ios::beg); - signature_content.resize(static_cast(signature_length)); - file_mpq.read(signature_content.data(), signature_content.size()); - file_mpq.close(); - - PrintAsBinary(signature_content.data(), static_cast(signature_content.size())); - } - - return 0; -} diff --git a/src/mpq.h b/src/mpq.h deleted file mode 100644 index 362628d..0000000 --- a/src/mpq.h +++ /dev/null @@ -1,52 +0,0 @@ -#ifndef MPQ_H -#define MPQ_H - -#include -#include -#include -#include - -#include - -#include "gamerules.h" - -namespace fs = std::filesystem; - -bool OpenMpqArchive(const std::string &filename, HANDLE *archive, int32_t flags); -bool CloseMpqArchive(HANDLE archive); -bool SignMpqArchive(HANDLE archive); -int ExtractFiles(HANDLE archive, const std::string &output, - const std::optional &listfile_name, LCID preferred_locale); -int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, - bool keep_folder_structure, LCID preferred_locale); -HANDLE CreateMpqArchive(const std::string &output_archive_name, uint32_t file_count, - const GameRules &game_rules); -int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, - const std::string &path_prefix, LCID locale, const GameRules &game_rules, - const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), - bool overwrite = false, bool update = false, int *skipped = nullptr); -int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archive_file_path, - LCID locale, const GameRules &game_rules, - const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), - bool overwrite = false, bool update = false, int *skipped = nullptr); -int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale); -int RenameFile(HANDLE archive, const std::string &old_archive_file_path, - const std::string &new_archive_file_path, LCID locale); -int ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, - bool list_detailed, const std::vector &properties); -std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned int *file_size, - LCID preferred_locale); -void PrintMpqInfo(HANDLE archive, const std::optional &info_property); -uint32_t VerifyMpqArchive(HANDLE archive); -int CompactMpqArchive(HANDLE archive, const std::optional &listfile_name); -int32_t PrintMpqSignature(HANDLE archive, const std::string &target); - -template T GetFileInfo(HANDLE file, SFileInfoClass info_class) { - T value{}; - if (!SFileGetFileInfo(file, info_class, &value, sizeof(T), nullptr)) { - return T{}; - } - return value; -} - -#endif diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp new file mode 100644 index 0000000..80631c5 --- /dev/null +++ b/src/mpq/add.cpp @@ -0,0 +1,188 @@ +#include "mpq/add.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "errors.h" +#include "gamerules/rules.h" +#include "mpq/query.h" +#include "util/capacity.h" +#include "util/format.h" +#include "util/locales.h" +#include "util/path.h" + +namespace fs = std::filesystem; + +namespace mpqcli { + +int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, + const std::string &path_prefix, LCID locale, const GameRules &game_rules, + std::ostream &err, const std::atomic &cancelled, + const CompressionSettingsOverrides &overrides, bool overwrite, bool update, + int *skipped) { + int files_added = 0; + int files_skipped = 0; + int files_failed = 0; + + for (const auto &file : files) { + ThrowIfCancelled(cancelled); + + // Determine relative path lexically rather than with fs::relative, which + // resolves paths through the OS and throws on volumes that cannot report + // real paths (RAM disks, some network shares). + fs::path input_file_path = file.lexically_relative(base_path); + std::string archive_file_path; + + if (path_prefix.empty()) { + archive_file_path = WindowsifyFilePath(input_file_path.u8string()); + } else { + archive_file_path = + WindowsifyFilePath((fs::path(path_prefix) / input_file_path).u8string()); + } + + if (std::find(special_mpq_files.begin(), special_mpq_files.end(), archive_file_path) != + special_mpq_files.end()) { + err << "[*] Skipping special MPQ file: " << archive_file_path << std::endl; + continue; + } + + int file_skipped = 0; + if (AddFile(archive, file, archive_file_path, locale, game_rules, err, overrides, overwrite, + update, &file_skipped) != 0) { + files_failed++; + } else if (file_skipped > 0) { + files_skipped++; + } else { + files_added++; + } + } + + if (update) { + err << "[*] For " << base_path.string() << ": " << files_added << " files added, " + << files_skipped << " files skipped, " << files_failed << " files failed." << std::endl; + } + + if (skipped != nullptr) { + *skipped += files_skipped; + } + + return files_failed > 0 ? 1 : 0; +} + +int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archive_file_path, + const LCID locale, const GameRules &game_rules, std::ostream &err, + const CompressionSettingsOverrides &overrides, bool overwrite, bool update, + int *skipped) { + std::error_code ec; + if (!fs::exists(local_file, ec)) { + err << "[!] File doesn't exist on disk: " << local_file.string() << std::endl; + return 1; + } + + SFileSetLocale(locale); + HANDLE file; + if (SFileOpenFileEx(archive, archive_file_path.c_str(), SFILE_OPEN_FROM_MPQ, &file)) { + const auto file_locale = GetFileInfo(file, SFileInfoLocale); + if (file_locale == locale) { + // --update: leave the archived copy alone while it still matches the local file + std::string match_reason; + const bool unchanged = + update && ArchivedFileMatches(archive, file, local_file, match_reason); + SFileCloseFile(file); + + if (unchanged) { + err << "[*] Skipping unchanged file: " << archive_file_path << " (" << match_reason + << ")" << std::endl; + if (skipped != nullptr) { + (*skipped)++; + } + return 0; + } + + // Without either flag, leaving the archived copy in place is the documented + // default, so this is a skip rather than a failure. + if (!overwrite && !update) { + err << "[!] File" << PrettyPrintLocale(locale, " for locale ") + << " already exists in MPQ archive: " << archive_file_path << " - Skipping..." + << std::endl; + if (skipped != nullptr) { + (*skipped)++; + } + return 0; + } + + err << "[~] File" << PrettyPrintLocale(locale, " for locale ") + << " already exists in MPQ archive: " << archive_file_path << " - Overwriting..." + << std::endl; + } else { + SFileCloseFile(file); + } + } + err << "[+] Adding file" << PrettyPrintLocale(locale, " for locale ") << ": " + << archive_file_path << std::endl; + + // Verify that we are not exceeding maxFile size of the archive, and if we do, increase it + int32_t number_of_files = GetFileInfo(archive, SFileMpqNumberOfFiles); + int32_t max_files = GetFileInfo(archive, SFileMpqMaxFileCount); + + if (number_of_files + 1 > max_files) { + uint32_t new_max_files = NextPowerOfTwo(static_cast(number_of_files + 1)); + bool set_max_file_count = SFileSetMaxFileCount(archive, new_max_files); + if (!set_max_file_count) { + const auto error = SErrGetLastError(); + err << "[!] Failed to increase new max file count to " << new_max_files << ": (" + << error << ") " << StormErrorString(error) << std::endl; + return 1; + } + } + + const std::uintmax_t raw_file_size = fs::file_size(local_file, ec); + if (ec) { + err << "[!] Failed to read file size: (" << ec.value() << ") " << ec.message() << ": " + << local_file.string() << std::endl; + return 1; + } + if (raw_file_size > std::numeric_limits::max()) { + err << "[!] File exceeds 4GB, size-based compression rules may not apply " + "correctly: " + << local_file.string() << std::endl; + } + const DWORD file_size = static_cast( + std::min(raw_file_size, static_cast(std::numeric_limits::max()))); + + const auto settings = game_rules.GetCompressionSettings(archive_file_path, file_size); + + DWORD flags = overrides.flags.value_or(settings.mpq_flags); + DWORD compression = overrides.compression.value_or(settings.compression_first); + DWORD compression_next = overrides.compression_next.value_or(settings.compression_next); + + // Both flags mean "replace what is already there"; --update has simply decided + // beforehand that this particular file is worth replacing. + if (overwrite || update) { + flags |= MPQ_FILE_REPLACEEXISTING; + } + + bool added_file = + SFileAddFileEx(archive, local_file.string().c_str(), archive_file_path.c_str(), flags, + compression, compression_next); + + if (!added_file) { + const auto error = SErrGetLastError(); + err << "[!] Failed to add: " << archive_file_path << ": (" << error << ") " + << StormErrorString(error) << std::endl; + return 1; + } + + return 0; +} + +} // namespace mpqcli diff --git a/src/mpq/add.h b/src/mpq/add.h new file mode 100644 index 0000000..2bd07af --- /dev/null +++ b/src/mpq/add.h @@ -0,0 +1,30 @@ +#ifndef MPQ_ADD_H +#define MPQ_ADD_H + +#include +#include +#include +#include +#include + +#include + +#include "gamerules/rules.h" +#include "gamerules/settings.h" + +namespace mpqcli { + +int AddFiles(HANDLE archive, const std::vector &files, + const std::filesystem::path &base_path, const std::string &path_prefix, LCID locale, + const GameRules &game_rules, std::ostream &err, const std::atomic &cancelled, + const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), + bool overwrite = false, bool update = false, int *skipped = nullptr); +int AddFile(HANDLE archive, const std::filesystem::path &local_file, + const std::string &archive_file_path, LCID locale, const GameRules &game_rules, + std::ostream &err, + const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), + bool overwrite = false, bool update = false, int *skipped = nullptr); + +} // namespace mpqcli + +#endif diff --git a/src/mpq/archive.cpp b/src/mpq/archive.cpp new file mode 100644 index 0000000..22d0607 --- /dev/null +++ b/src/mpq/archive.cpp @@ -0,0 +1,92 @@ +#include "mpq/archive.h" + +#include +#include +#include +#include + +#include + +#include "errors.h" +#include "gamerules/rules.h" +#include "gamerules/settings.h" + +namespace fs = std::filesystem; + +namespace mpqcli { + +Archive Archive::Open(const fs::path &path, const DWORD flags) { + HANDLE handle = nullptr; + if (!SFileOpenArchive(path.string().c_str(), 0, flags, &handle)) { + throw ArchiveOpenError(path, SErrGetLastError()); + } + return Archive(handle); +} + +Archive Archive::Create(const fs::path &path, const uint32_t file_count, + const GameRules &game_rules) { + std::error_code ec; + if (fs::exists(path, ec)) { + throw ArchiveError("File already exists: " + path.string()); + } + + const MpqCreateSettings &settings = game_rules.GetCreateSettings(); + + SFILE_CREATE_MPQ create_info = {}; + create_info.cbSize = sizeof(SFILE_CREATE_MPQ); + create_info.dwMpqVersion = settings.mpq_version; + create_info.dwStreamFlags = settings.stream_flags; + create_info.dwFileFlags1 = settings.file_flags1; + create_info.dwFileFlags2 = settings.file_flags2; + create_info.dwFileFlags3 = settings.file_flags3; + create_info.dwAttrFlags = settings.attr_flags; + create_info.dwSectorSize = settings.sector_size; + create_info.dwRawChunkSize = settings.raw_chunk_size; + create_info.dwMaxFileCount = file_count; + + HANDLE handle = nullptr; + if (!SFileCreateArchive2(path.string().c_str(), &create_info, &handle)) { + throw ArchiveCreateError(path, SErrGetLastError()); + } + return Archive(handle); +} + +Archive::Archive(Archive &&other) noexcept : handle_(other.handle_) { + other.handle_ = nullptr; +} + +Archive &Archive::operator=(Archive &&other) noexcept { + if (this != &other) { + if (handle_ != nullptr) { + SFileCloseArchive(handle_); + } + handle_ = other.handle_; + other.handle_ = nullptr; + } + return *this; +} + +Archive::~Archive() { + if (handle_ != nullptr) { + SFileCloseArchive(handle_); + } +} + +void Archive::Sign() { + if (!SFileSignArchive(handle_, SIGNATURE_TYPE_WEAK)) { + throw StormError("Failed to sign MPQ archive", SErrGetLastError()); + } +} + +void Archive::Close() { + if (!SFileCloseArchive(handle_)) { + // StormLib frees the handle even when its flush fails, so drop it before + // the throw or the destructor would close it a second time + const auto error = SErrGetLastError(); + handle_ = nullptr; + throw StormError("Failed to close MPQ archive", error); + } + handle_ = nullptr; +} + +} // namespace mpqcli diff --git a/src/mpq/archive.h b/src/mpq/archive.h new file mode 100644 index 0000000..ff5d8a7 --- /dev/null +++ b/src/mpq/archive.h @@ -0,0 +1,55 @@ +#ifndef MPQ_ARCHIVE_H +#define MPQ_ARCHIVE_H + +#include +#include + +#include + +#include "gamerules/rules.h" + +namespace mpqcli { + +/// An open MPQ archive, closed on destruction unless Close was called first +class Archive { +public: + /// Opens an existing archive with StormLib's MPQ_OPEN_* flags + /// + /// @throws ArchiveOpenError if StormLib cannot open it + static Archive Open(const std::filesystem::path &path, DWORD flags); + + /// Creates an archive sized for file_count files with the profile's settings + /// + /// @throws ArchiveError if a file already exists at path + /// @throws ArchiveCreateError if StormLib cannot create it + static Archive Create(const std::filesystem::path &path, uint32_t file_count, + const GameRules &game_rules); + + Archive(const Archive &) = delete; + Archive &operator=(const Archive &) = delete; + Archive(Archive &&other) noexcept; + Archive &operator=(Archive &&other) noexcept; + ~Archive(); + + /// Signs the archive with the Blizzard weak signature + /// + /// @throws StormError if signing fails + void Sign(); + + /// Closes the archive and writes its tables; the destructor closes silently instead + /// + /// @throws StormError if the close fails, leaving the archive possibly incomplete + void Close(); + + /// The StormLib handle, for the calls this class does not wrap + HANDLE Handle() const { return handle_; } + +private: + explicit Archive(HANDLE handle) : handle_(handle) {} + + HANDLE handle_; +}; + +} // namespace mpqcli + +#endif diff --git a/src/mpq/compact.cpp b/src/mpq/compact.cpp new file mode 100644 index 0000000..6f597f1 --- /dev/null +++ b/src/mpq/compact.cpp @@ -0,0 +1,26 @@ +#include "mpq/compact.h" + +#include +#include +#include +#include + +#include + +#include "errors.h" + +namespace mpqcli { + +void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name, + std::ostream &err) { + err << "[*] Compacting archive. This may take some time..." << std::endl; + const std::string listfile_string = + listfile_name.has_value() ? listfile_name->string() : std::string(); + const char *listfile = listfile_name.has_value() ? listfile_string.c_str() : nullptr; + + if (!SFileCompactArchive(archive, listfile, false)) { + throw StormError("Failed to compact archive", SErrGetLastError()); + } +} + +} // namespace mpqcli diff --git a/src/mpq/compact.h b/src/mpq/compact.h new file mode 100644 index 0000000..a39441d --- /dev/null +++ b/src/mpq/compact.h @@ -0,0 +1,20 @@ +#ifndef MPQ_COMPACT_H +#define MPQ_COMPACT_H + +#include +#include +#include + +#include + +namespace mpqcli { + +/// Compacts the archive, resolving names it cannot from listfile_name when given +/// +/// @throws StormError if StormLib cannot compact it +void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name, + std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp new file mode 100644 index 0000000..23f70fe --- /dev/null +++ b/src/mpq/extract.cpp @@ -0,0 +1,133 @@ +#include "mpq/extract.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "errors.h" +#include "mpq/query.h" +#include "util/format.h" +#include "util/locales.h" +#include "util/path.h" + +namespace fs = std::filesystem; + +namespace mpqcli { + +int ExtractFiles(HANDLE archive, const fs::path &output, + const std::optional &listfile_name, LCID preferred_locale, + std::ostream &err, const std::atomic &cancelled) { + SFileSetLocale(preferred_locale); + const std::string listfile_string = + listfile_name.has_value() ? listfile_name->string() : std::string(); + const char *listfile = listfile_name.has_value() ? listfile_string.c_str() : nullptr; + + SFILE_FIND_DATA find_data; + HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); + if (find_handle == nullptr) { + throw ArchiveError("Failed to find first file in MPQ archive."); + } + + int32_t result = 0; + do { + // Closed by hand rather than through ThrowIfCancelled, so the find handle + // does not leak on the way out + if (cancelled.load(std::memory_order_relaxed)) { + SFileFindClose(find_handle); + throw Interrupted{}; + } + result |= ExtractFile(archive, output, find_data.cFileName, + true, // Keep folder structure + preferred_locale, err); + } while (SFileFindNextFile(find_handle, &find_data)); + + SFileFindClose(find_handle); + return result; +} + +int ExtractFile(HANDLE archive, const fs::path &output, const std::string &file_name, + bool keep_folder_structure, LCID preferred_locale, std::ostream &err) { + SFileSetLocale(preferred_locale); + if (!FileExistsInArchiveForLocale(archive, file_name.c_str(), preferred_locale) && + !FileExistsInArchiveForLocale(archive, file_name.c_str(), default_locale)) { + err << "[!] Failed: File doesn't exist" + << PrettyPrintLocale(preferred_locale, " for locale ", true) << ": " << file_name + << std::endl; + return 1; + } + + fs::path file_name_path(file_name); + std::string file_name_string = NormalizeFilePath(file_name_path); + + if (!keep_folder_structure) { + file_name_path = fs::path(file_name_string); + file_name_string = file_name_path.filename().u8string(); + } + + std::error_code ec; + fs::path output_path_base = fs::absolute(output, ec).lexically_normal(); + if (ec) { + err << "[!] Failed to resolve output directory: (" << ec.value() << ") " << ec.message() + << ": " << output.string() << std::endl; + return 1; + } + if (output_path_base.filename().empty()) { + output_path_base = output_path_base.parent_path(); + } + + // Guard against path traversal attacks in two stages. First lexically, so a + // ".." entry is rejected before anything is created on disk. + fs::path output_file_path_name = (output_path_base / file_name_string).lexically_normal(); + if (!IsWithinDirectory(output_path_base, output_file_path_name)) { + err << "[!] Blocked: path traversal attempt detected: " << file_name_string << std::endl; + return 1; + } + + // Ensure sub-directories for folder-nested files exist before resolving + fs::create_directories(output_file_path_name.parent_path(), ec); + if (ec) { + err << "[!] Failed to create output directory: (" << ec.value() << ") " << ec.message() + << ": " << output_file_path_name.parent_path().string() << std::endl; + return 1; + } + + // Second, through the OS to also catch symlinks. Volumes that cannot report + // real paths (RAM disks) fail here, in which case the lexical check above is + // the only guard, and the caller warns about that once. + fs::path resolved_base = fs::canonical(output_path_base, ec); + if (!ec) { + fs::path resolved_output = fs::canonical(output_file_path_name.parent_path(), ec) / + output_file_path_name.filename(); + if (ec) { + err << "[!] Failed to resolve output path: (" << ec.value() << ") " << ec.message() + << ": " << output_file_path_name.string() << std::endl; + return 1; + } + if (!IsWithinDirectory(resolved_base, resolved_output)) { + err << "[!] Blocked: path traversal attempt detected: " << file_name_string + << std::endl; + return 1; + } + } + + std::string output_file_name{output_file_path_name.string()}; + + if (SFileExtractFile(archive, file_name.c_str(), output_file_name.c_str(), 0)) { + err << "[*] Extracted: " << file_name_string << std::endl; + } else { + const auto error = SErrGetLastError(); + err << "[!] Failed: (" << error << ") " << StormErrorString(error) << ": " << file_name + << std::endl; + return 1; + } + + return 0; +} + +} // namespace mpqcli diff --git a/src/mpq/extract.h b/src/mpq/extract.h new file mode 100644 index 0000000..101afd5 --- /dev/null +++ b/src/mpq/extract.h @@ -0,0 +1,25 @@ +#ifndef MPQ_EXTRACT_H +#define MPQ_EXTRACT_H + +#include +#include +#include +#include +#include + +#include + +namespace mpqcli { + +/// Extracts every file the archive can name into output, returning non-zero if any failed +/// +/// @throws ArchiveError if the archive's files cannot be enumerated +int ExtractFiles(HANDLE archive, const std::filesystem::path &output, + const std::optional &listfile_name, LCID preferred_locale, + std::ostream &err, const std::atomic &cancelled); +int ExtractFile(HANDLE archive, const std::filesystem::path &output, const std::string &file_name, + bool keep_folder_structure, LCID preferred_locale, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/mpq/flags.cpp b/src/mpq/flags.cpp new file mode 100644 index 0000000..bae82ae --- /dev/null +++ b/src/mpq/flags.cpp @@ -0,0 +1,33 @@ +#include "mpq/flags.h" + +#include +#include + +#include + +namespace mpqcli { + +std::string GetFlagString(uint32_t flags) { + std::string result; + + // Preserve column-aligned flag-to-char mappings + // clang-format off + if (flags & MPQ_FILE_IMPLODE) result += 'i'; + if (flags & MPQ_FILE_COMPRESS) result += 'c'; + if (flags & MPQ_FILE_ENCRYPTED) result += 'e'; + if (flags & MPQ_FILE_KEY_V2) result += '2'; + if (flags & MPQ_FILE_PATCH_FILE) result += 'p'; + if (flags & MPQ_FILE_SINGLE_UNIT) result += 'u'; + if (flags & MPQ_FILE_DELETE_MARKER) result += 'd'; + if (flags & MPQ_FILE_SECTOR_CRC) result += 'r'; + if (flags & MPQ_FILE_SIGNATURE) result += 's'; + if (flags & MPQ_FILE_EXISTS) result += 'x'; + if (flags & MPQ_FILE_COMPRESS_MASK) result += 'm'; + if (flags & MPQ_FILE_DEFAULT_INTERNAL) result += 'n'; + if (flags & MPQ_FILE_FIX_KEY) result += 'f'; + // clang-format on + + return result; +} + +} // namespace mpqcli diff --git a/src/mpq/flags.h b/src/mpq/flags.h new file mode 100644 index 0000000..e092df6 --- /dev/null +++ b/src/mpq/flags.h @@ -0,0 +1,13 @@ +#ifndef MPQ_FLAGS_H +#define MPQ_FLAGS_H + +#include +#include + +namespace mpqcli { + +std::string GetFlagString(uint32_t flags); + +} // namespace mpqcli + +#endif diff --git a/src/mpq/info.cpp b/src/mpq/info.cpp new file mode 100644 index 0000000..adb31b5 --- /dev/null +++ b/src/mpq/info.cpp @@ -0,0 +1,95 @@ +#include "mpq/info.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "mpq/query.h" + +namespace mpqcli { + +void PrintMpqInfo(HANDLE archive, const std::optional &info_property, + std::ostream &out) { + std::map> property_actions = { + {"format-version", + [&](bool print_name) { + TMPQHeader header = GetFileInfo(archive, SFileMpqHeader); + uint16_t format_version = + header.wFormatVersion + 1; // StormLib counts the formats from 0 + if (print_name) { + out << "Format version: "; + } + out << format_version << std::endl; + }}, + {"header-offset", + [&](bool print_name) { + int64_t header_offset = GetFileInfo(archive, SFileMpqHeaderOffset); + if (print_name) { + out << "Header offset: "; + } + out << header_offset << std::endl; + }}, + {"header-size", + [&](bool print_name) { + int64_t header_size = GetFileInfo(archive, SFileMpqHeaderSize); + if (print_name) { + out << "Header size: "; + } + out << header_size << std::endl; + }}, + {"archive-size", + [&](bool print_name) { + int64_t archive_size = GetFileInfo(archive, SFileMpqArchiveSize64); + if (print_name) { + out << "Archive size: "; + } + out << archive_size << std::endl; + }}, + {"file-count", + [&](bool print_name) { + int32_t number_of_files = GetFileInfo(archive, SFileMpqNumberOfFiles); + if (print_name) { + out << "File count: "; + } + out << number_of_files << std::endl; + }}, + {"max-files", + [&](bool print_name) { + int32_t max_files = GetFileInfo(archive, SFileMpqMaxFileCount); + if (print_name) { + out << "Max files: "; + } + out << max_files << std::endl; + }}, + {"signature-type", [&](bool print_name) { + int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); + if (print_name) { + out << "Signature type: "; + } + if (signature_type == SIGNATURE_TYPE_NONE) { + out << "None" << std::endl; + } else if (signature_type == SIGNATURE_TYPE_WEAK) { + out << "Weak" << std::endl; + } else if (signature_type == SIGNATURE_TYPE_STRONG) { + out << "Strong" << std::endl; + } + }}}; + + if (!info_property.has_value()) { + for (const auto &[key, action] : property_actions) { + action(true); + } + } else { + auto it = property_actions.find(info_property.value()); + if (it != property_actions.end()) { + it->second(false); + } + } +} + +} // namespace mpqcli diff --git a/src/mpq/info.h b/src/mpq/info.h new file mode 100644 index 0000000..35a8041 --- /dev/null +++ b/src/mpq/info.h @@ -0,0 +1,17 @@ +#ifndef MPQ_INFO_H +#define MPQ_INFO_H + +#include +#include +#include + +#include + +namespace mpqcli { + +void PrintMpqInfo(HANDLE archive, const std::optional &info_property, + std::ostream &out); + +} // namespace mpqcli + +#endif diff --git a/src/mpq/list.cpp b/src/mpq/list.cpp new file mode 100644 index 0000000..ee174fd --- /dev/null +++ b/src/mpq/list.cpp @@ -0,0 +1,162 @@ +#include "mpq/list.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "errors.h" +#include "mpq/flags.h" +#include "mpq/query.h" +#include "util/format.h" +#include "util/locales.h" + +namespace mpqcli { + +void ListFiles(HANDLE archive, const std::optional &listfile_name, + bool list_all, bool list_detailed, const std::vector &properties, + std::ostream &out, std::ostream &err) { + const std::string listfile_string = + listfile_name.has_value() ? listfile_name->string() : std::string(); + const char *listfile = listfile_name.has_value() ? listfile_string.c_str() : nullptr; + + SFILE_FIND_DATA find_data; + HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); + if (find_handle == nullptr) { + throw ArchiveError("Failed to find first file in MPQ archive."); + } + + std::vector properties_to_print = + properties.empty() ? std::vector{"file-size", "locale", "file-time"} + : properties; + if (!properties.empty()) { + // Named properties are only printed by the detailed listing + list_detailed = true; + } + + static const std::map property_info_class = { + {"hash-index", SFileInfoHashIndex}, + {"name-hash1", SFileInfoNameHash1}, + {"name-hash2", SFileInfoNameHash2}, + {"name-hash3", SFileInfoNameHash3}, + {"locale", SFileInfoLocale}, + {"file-index", SFileInfoFileIndex}, + {"byte-offset", SFileInfoByteOffset}, + {"file-time", SFileInfoFileTime}, + {"file-size", SFileInfoFileSize}, + {"compressed-size", SFileInfoCompressedSize}, + {"flags", SFileInfoFlags}, + {"encryption-key", SFileInfoEncryptionKey}, + {"encryption-key-raw", SFileInfoEncryptionKeyRaw}, + }; + + // The enumeration yields one entry per locale of a name and the detailed listing + // prints every locale in one go, so later entries for a seen name are skipped + std::set seen_file_names; + do { + if (!list_all && std::find(special_mpq_files.begin(), special_mpq_files.end(), + find_data.cFileName) != special_mpq_files.end()) { + continue; + } + + if (list_detailed) { + if (seen_file_names.find(find_data.cFileName) != seen_file_names.end()) { + continue; + } + seen_file_names.insert(find_data.cFileName); + + // Multiple files can be stored with identical filenames under different locales + DWORD max_locales = 32; // Updated in place by SFileEnumLocales + std::vector file_locale_vec(max_locales); + LCID *file_locales = file_locale_vec.data(); + + DWORD result = + SFileEnumLocales(archive, find_data.cFileName, file_locales, &max_locales, 0); + + if (result == ERROR_INVALID_PARAMETER) { + // StormLib rejects a name it cannot resolve (IsPseudoFileName) before + // touching file_locales or max_locales, so the unknown name is listed + // once under the default locale + max_locales = 1; + file_locales[0] = default_locale; + + } else if (result == ERROR_INVALID_HANDLE || result == ERROR_NOT_SUPPORTED) { + err << "[!] Internal error for file: " << find_data.cFileName << std::endl; + continue; + + } else if (result == ERROR_INSUFFICIENT_BUFFER) { + err << "[!] There are more than " << max_locales + << " locales for the file: " << find_data.cFileName << ". Will only list the " + << max_locales << " first files." << std::endl; + } + + for (DWORD i = 0; i < max_locales; i++) { + LCID locale = file_locales[i]; + SFileSetLocale(locale); + HANDLE file; + + if (!SFileOpenFileEx(archive, find_data.cFileName, SFILE_OPEN_FROM_MPQ, &file)) { + err << "[!] Failed to open file: " << find_data.cFileName << std::endl; + continue; + } + + for (const auto &prop : properties_to_print) { + auto it = property_info_class.find(prop); + if (it == property_info_class.end()) + continue; + + if (prop == "hash-index" || prop == "file-index") { + out << std::setw(5) << GetFileInfo(file, it->second) << " "; + } else if (prop == "name-hash1" || prop == "name-hash2") { + out << std::setfill('0') << std::hex << std::setw(8) + << GetFileInfo(file, it->second) << std::setfill(' ') + << std::dec << " "; + } else if (prop == "name-hash3") { + out << std::setfill('0') << std::hex << std::setw(16) + << GetFileInfo(file, it->second) << std::setfill(' ') + << std::dec << " "; + } else if (prop == "locale") { + // StormLib packs the hash entry's platform byte into bits 16 to 23 of + // the LCID (SFILE_MAKE_LCID); only the low 16 bits are the locale + const LCID file_locale = SFILE_LOCALE(GetFileInfo(file, it->second)); + out << std::setw(4) << LocaleToLang(file_locale) << " "; + } else if (prop == "byte-offset") { + out << std::hex << std::setw(8) << GetFileInfo(file, it->second) + << std::dec << " "; + } else if (prop == "file-time") { + out << std::setw(19) + << FileTimeToLsTime(GetFileInfo(file, it->second)) << " "; + } else if (prop == "file-size" || prop == "compressed-size") { + out << std::setw(8) << GetFileInfo(file, it->second) << " "; + } else if (prop == "flags") { + out << std::setw(8) + << GetFlagString(GetFileInfo(file, it->second)) << " "; + } else if (prop == "encryption-key" || prop == "encryption-key-raw") { + out << std::setfill('0') << std::hex << std::setw(8) + << GetFileInfo(file, it->second) << std::setfill(' ') + << std::dec << " "; + } + } + + out << " " << find_data.cFileName << std::endl; + SFileCloseFile(file); + } + SFileSetLocale(default_locale); + } else { + out << find_data.cFileName << std::endl; + } + + } while (SFileFindNextFile(find_handle, &find_data)); + + SFileFindClose(find_handle); +} + +} // namespace mpqcli diff --git a/src/mpq/list.h b/src/mpq/list.h new file mode 100644 index 0000000..8a0a81f --- /dev/null +++ b/src/mpq/list.h @@ -0,0 +1,23 @@ +#ifndef MPQ_LIST_H +#define MPQ_LIST_H + +#include +#include +#include +#include +#include + +#include + +namespace mpqcli { + +/// Prints the archive's files to out, one per line, with the requested properties +/// +/// @throws ArchiveError if the archive's files cannot be enumerated +void ListFiles(HANDLE archive, const std::optional &listfile_name, + bool list_all, bool list_detailed, const std::vector &properties, + std::ostream &out, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/mpq/query.cpp b/src/mpq/query.cpp new file mode 100644 index 0000000..32d1917 --- /dev/null +++ b/src/mpq/query.cpp @@ -0,0 +1,94 @@ +#include "mpq/query.h" + +#include +#include +#include +#include +#include + +#include + +#include "util/hash.h" +#include "util/path.h" + +namespace fs = std::filesystem; + +namespace mpqcli { + +bool FileExistsInArchiveForLocale(const HANDLE archive, const std::string &file_path, + const LCID locale) { + bool file_exists = false; + SFileSetLocale(locale); + HANDLE file; + if (SFileOpenFileEx(archive, file_path.c_str(), SFILE_OPEN_FROM_MPQ, &file)) { + const auto file_locale = GetFileInfo(file, SFileInfoLocale); + if (file_locale == locale) { + file_exists = true; + } + SFileCloseFile(file); + } + return file_exists; +} + +bool ArchivedFileMatches(HANDLE archive, HANDLE file, const fs::path &local_file, + std::string &match_reason) { + const DWORD archived_size = SFileGetFileSize(file, nullptr); + std::error_code ec; + const uintmax_t disk_size = fs::file_size(local_file, ec); + if (ec || disk_size != static_cast(archived_size)) { + return false; + } + + const DWORD attr_flags = SFileGetAttributes(archive); + + // Timestamp first: the cheapest check, with no local file I/O + if (attr_flags & MPQ_ATTRIBUTE_FILETIME) { + const uint64_t archived_time = GetFileInfo(file, SFileInfoFileTime); + const uint64_t local_time = LocalFileTimestamp(local_file); + // Compare at second resolution: stat() has only second precision + if (archived_time != 0 && local_time != 0 && + archived_time / 10000000u == local_time / 10000000u) { + match_reason = "Timestamp matches"; + return true; + } + } + + // MD5 when the timestamp did not match or was unavailable + if (attr_flags & MPQ_ATTRIBUTE_MD5) { + uint8_t archived_md5[MD5_DIGEST_SIZE]{}; + if (SFileGetFileInfo(file, SFileInfoMD5, archived_md5, sizeof(archived_md5), nullptr)) { + // An all-zero digest means "no MD5 stored". A file whose + // real MD5 is all zeroes is astronomically unlikely; the + // worst case is a redundant re-add. + const uint8_t zero_md5[MD5_DIGEST_SIZE]{}; + if (std::memcmp(archived_md5, zero_md5, MD5_DIGEST_SIZE) != 0) { + uint8_t local_md5[MD5_DIGEST_SIZE]{}; + if (ComputeFileMd5(local_file, local_md5) && + std::memcmp(local_md5, archived_md5, MD5_DIGEST_SIZE) == 0) { + match_reason = "MD5 matches"; + return true; + } + } + } + } + + // CRC32 when neither timestamp nor MD5 matched or was available + if (attr_flags & MPQ_ATTRIBUTE_CRC32) { + const DWORD archived_crc32 = GetFileInfo(file, SFileInfoCRC32); + // Zero means "no CRC32 stored"; a file whose real CRC32 is + // zero just gets a redundant re-add. + if (archived_crc32 != 0) { + if (auto local_crc32 = ComputeFileCrc32(local_file)) { + if (*local_crc32 == archived_crc32) { + match_reason = "CRC32 matches"; + return true; + } + } + } + } + + // No attributes present, or none matched: re-add the file + return false; +} + +} // namespace mpqcli diff --git a/src/mpq/query.h b/src/mpq/query.h new file mode 100644 index 0000000..5cc91dd --- /dev/null +++ b/src/mpq/query.h @@ -0,0 +1,32 @@ +#ifndef MPQ_QUERY_H +#define MPQ_QUERY_H + +#include +#include +#include + +#include + +namespace mpqcli { + +/// Files the archive maintains for itself rather than on a user's behalf +inline const std::vector special_mpq_files = {"(listfile)", "(signature)", + "(attributes)"}; + +bool FileExistsInArchiveForLocale(HANDLE archive, const std::string &file_path, LCID locale); + +/// Reports whether the archived copy of local_file is byte-for-byte current +bool ArchivedFileMatches(HANDLE archive, HANDLE file, const std::filesystem::path &local_file, + std::string &match_reason); + +template T GetFileInfo(HANDLE file, SFileInfoClass info_class) { + T value{}; + if (!SFileGetFileInfo(file, info_class, &value, sizeof(T), nullptr)) { + return T{}; + } + return value; +} + +} // namespace mpqcli + +#endif diff --git a/src/mpq/read.cpp b/src/mpq/read.cpp new file mode 100644 index 0000000..3fc329e --- /dev/null +++ b/src/mpq/read.cpp @@ -0,0 +1,50 @@ +#include "mpq/read.h" + +#include +#include + +#include + +#include "mpq/query.h" +#include "util/locales.h" + +namespace mpqcli { + +std::unique_ptr ReadArchivedFile(HANDLE archive, const char *file_name, + unsigned int *file_size, LCID preferred_locale, + std::ostream &err) { + SFileSetLocale(preferred_locale); + if (!FileExistsInArchiveForLocale(archive, file_name, preferred_locale) && + !FileExistsInArchiveForLocale(archive, file_name, default_locale)) { + err << "[!] Failed: File doesn't exist" + << PrettyPrintLocale(preferred_locale, " for locale ", true) << ": " << file_name + << std::endl; + return nullptr; + } + + HANDLE file; + if (!SFileOpenFileEx(archive, file_name, SFILE_OPEN_FROM_MPQ, &file)) { + err << "[!] Failed: File cannot be opened: " << file_name << std::endl; + return nullptr; + } + + *file_size = SFileGetFileSize(file, nullptr); + if (*file_size == SFILE_INVALID_SIZE) { + err << "[!] Failed: Invalid file size for: " << file_name << std::endl; + SFileCloseFile(file); + return nullptr; + } + + auto file_content = std::make_unique(*file_size); + DWORD bytes_read; + if (!SFileReadFile(file, file_content.get(), *file_size, &bytes_read, nullptr)) { + err << "[!] Failed: Cannot read file contents for: " << file_name << std::endl; + SFileCloseFile(file); + return nullptr; + } + + SFileCloseFile(file); + return file_content; +} + +} // namespace mpqcli diff --git a/src/mpq/read.h b/src/mpq/read.h new file mode 100644 index 0000000..71a08f2 --- /dev/null +++ b/src/mpq/read.h @@ -0,0 +1,17 @@ +#ifndef MPQ_READ_H +#define MPQ_READ_H + +#include +#include + +#include + +namespace mpqcli { + +std::unique_ptr ReadArchivedFile(HANDLE archive, const char *file_name, + unsigned int *file_size, LCID preferred_locale, + std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/mpq/remove.cpp b/src/mpq/remove.cpp new file mode 100644 index 0000000..d77b9a9 --- /dev/null +++ b/src/mpq/remove.cpp @@ -0,0 +1,35 @@ +#include "mpq/remove.h" + +#include +#include + +#include + +#include "mpq/query.h" +#include "util/locales.h" + +namespace mpqcli { + +int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale, + std::ostream &err) { + SFileSetLocale(locale); + err << "[-] Removing file" << PrettyPrintLocale(locale, " for locale ") << ": " + << archive_file_path << std::endl; + + if (!FileExistsInArchiveForLocale(archive, archive_file_path, locale)) { + err << "[!] Failed: File doesn't exist" << PrettyPrintLocale(locale, " for locale ", true) + << ": " << archive_file_path << std::endl; + return 1; + } + + if (!SFileRemoveFile(archive, archive_file_path.c_str(), 0)) { + err << "[!] Failed: File cannot be removed" + << PrettyPrintLocale(locale, " for locale ", true) << ": " << archive_file_path + << std::endl; + return 1; + } + + return 0; +} + +} // namespace mpqcli diff --git a/src/mpq/remove.h b/src/mpq/remove.h new file mode 100644 index 0000000..8a430d2 --- /dev/null +++ b/src/mpq/remove.h @@ -0,0 +1,16 @@ +#ifndef MPQ_REMOVE_H +#define MPQ_REMOVE_H + +#include +#include + +#include + +namespace mpqcli { + +int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale, + std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/mpq/rename.cpp b/src/mpq/rename.cpp new file mode 100644 index 0000000..4aea16e --- /dev/null +++ b/src/mpq/rename.cpp @@ -0,0 +1,37 @@ +#include "mpq/rename.h" + +#include +#include + +#include + +#include "mpq/query.h" +#include "util/locales.h" +#include "util/path.h" + +namespace mpqcli { + +int RenameFile(HANDLE archive, const std::string &old_archive_file_path, + const std::string &new_archive_file_path, LCID locale, std::ostream &err) { + SFileSetLocale(locale); + const std::string new_stored_path = WindowsifyFilePath(new_archive_file_path); + err << "[~] Renaming file" << PrettyPrintLocale(locale, " for locale ") << ": " + << old_archive_file_path << " -> " << new_stored_path << std::endl; + + if (!FileExistsInArchiveForLocale(archive, old_archive_file_path, locale)) { + err << "[!] Failed: File doesn't exist" << PrettyPrintLocale(locale, " for locale ", true) + << ": " << old_archive_file_path << std::endl; + return 1; + } + + if (!SFileRenameFile(archive, old_archive_file_path.c_str(), new_stored_path.c_str())) { + err << "[!] Failed: File cannot be renamed" + << PrettyPrintLocale(locale, " for locale ", true) << ": " << old_archive_file_path + << " -> " << new_stored_path << std::endl; + return 1; + } + + return 0; +} + +} // namespace mpqcli diff --git a/src/mpq/rename.h b/src/mpq/rename.h new file mode 100644 index 0000000..e066a9d --- /dev/null +++ b/src/mpq/rename.h @@ -0,0 +1,16 @@ +#ifndef MPQ_RENAME_H +#define MPQ_RENAME_H + +#include +#include + +#include + +namespace mpqcli { + +int RenameFile(HANDLE archive, const std::string &old_archive_file_path, + const std::string &new_archive_file_path, LCID locale, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/mpq/verify.cpp b/src/mpq/verify.cpp new file mode 100644 index 0000000..45dbe4a --- /dev/null +++ b/src/mpq/verify.cpp @@ -0,0 +1,79 @@ +#include "mpq/verify.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "errors.h" +#include "mpq/query.h" +#include "mpq/read.h" +#include "util/format.h" +#include "util/locales.h" + +namespace fs = std::filesystem; + +namespace mpqcli { + +uint32_t VerifyMpqArchive(HANDLE archive) { + return SFileVerifyArchive(archive); +} + +void PrintMpqSignature(HANDLE archive, const fs::path &target, std::ostream &out, + std::ostream &err) { + int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); + + if (signature_type == SIGNATURE_TYPE_NONE) { + return; + } else if (signature_type == SIGNATURE_TYPE_WEAK) { + const char *file_name = "(signature)"; + uint32_t file_size; + auto file_content = ReadArchivedFile(archive, file_name, &file_size, default_locale, err); + + if (!file_content) { + throw ArchiveError("Failed to read weak signature file."); + } + + WriteBinary(out, file_content.get(), file_size); + + } else if (signature_type == SIGNATURE_TYPE_STRONG) { + // StormLib does not expose the strong signature via SFileGetFileInfo into a + // growable buffer (SFileMpqStrongSignature's advertised size always exceeds + // sizeof(std::vector)), so read it directly from the archive file instead. + int64_t archive_size = GetFileInfo(archive, SFileMpqArchiveSize64); + int64_t archive_offset = GetFileInfo(archive, SFileMpqHeaderOffset); + + std::error_code ec; + const auto file_size = static_cast(fs::file_size(target, ec)); + if (ec) { + throw FileError("Failed to read archive size", target, ec); + } + // fs::file_size returns uintmax_t, so without the cast the subtraction runs + // unsigned and a legitimately negative length reaches the check below only + // through a conversion C++17 leaves implementation-defined + const int64_t signature_length = file_size - archive_offset - archive_size; + + if (signature_length <= 0) { + throw ArchiveError("Invalid signature length: " + std::to_string(signature_length)); + } + + std::vector signature_content(static_cast(signature_length)); + std::ifstream file_mpq(target, std::ios::binary); + file_mpq.seekg(archive_offset + archive_size, std::ios::beg); + file_mpq.read(signature_content.data(), + static_cast(signature_content.size())); + if (!file_mpq) { + throw ArchiveError("Failed to read strong signature: " + target.string()); + } + + WriteBinary(out, signature_content.data(), static_cast(signature_content.size())); + } +} + +} // namespace mpqcli diff --git a/src/mpq/verify.h b/src/mpq/verify.h new file mode 100644 index 0000000..87f0ba4 --- /dev/null +++ b/src/mpq/verify.h @@ -0,0 +1,23 @@ +#ifndef MPQ_VERIFY_H +#define MPQ_VERIFY_H + +#include +#include +#include + +#include + +namespace mpqcli { + +uint32_t VerifyMpqArchive(HANDLE archive); + +/// Writes the archive's weak or strong signature bytes to out +/// +/// @throws ArchiveError if the signature cannot be read +/// @throws FileError if the archive's size on disk cannot be read +void PrintMpqSignature(HANDLE archive, const std::filesystem::path &target, std::ostream &out, + std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/mpqcli.h.in b/src/mpqcli.h.in deleted file mode 100644 index b824f5f..0000000 --- a/src/mpqcli.h.in +++ /dev/null @@ -1,2 +0,0 @@ -#define MPQCLI_VERSION "@MPQCLI_VERSION@" -#define GIT_COMMIT_HASH "@GIT_COMMIT_HASH@" diff --git a/src/util/capacity.cpp b/src/util/capacity.cpp new file mode 100644 index 0000000..9e5521d --- /dev/null +++ b/src/util/capacity.cpp @@ -0,0 +1,31 @@ +#include "util/capacity.h" + +#include + +namespace mpqcli { + +uint32_t NextPowerOfTwo(uint32_t n) { + n--; + n |= n >> 1; + n |= n >> 2; + n |= n >> 4; + n |= n >> 8; + n |= n >> 16; + return n + 1; +} + +uint32_t CalculateMpqMaxFileValue(uint32_t file_count) { + // Always add 3 for "special" files + file_count += 3; + + // The table holds at least 32 entries; above that the count rounds up to the + // next power of two (64, 128, 256), which matches the WoW patch and install + // archives + if (file_count <= 32) { + return 32; + } + + return NextPowerOfTwo(file_count); +} + +} // namespace mpqcli diff --git a/src/util/capacity.h b/src/util/capacity.h new file mode 100644 index 0000000..8360174 --- /dev/null +++ b/src/util/capacity.h @@ -0,0 +1,16 @@ +#ifndef UTIL_CAPACITY_H +#define UTIL_CAPACITY_H + +#include + +namespace mpqcli { + +/// Rounds n up to the next power of two +uint32_t NextPowerOfTwo(uint32_t n); + +/// Returns the max file count an archive holding file_count files should declare +uint32_t CalculateMpqMaxFileValue(uint32_t file_count); + +} // namespace mpqcli + +#endif diff --git a/src/util/format.cpp b/src/util/format.cpp new file mode 100644 index 0000000..c65857c --- /dev/null +++ b/src/util/format.cpp @@ -0,0 +1,84 @@ +#include "util/format.h" + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#endif + +#include + +namespace mpqcli { + +std::string FileTimeToLsTime(int64_t file_time) { + if (file_time == 0) { + return ""; + } + constexpr int64_t epoch_diff = 11644473600LL; + int64_t unix_time = (file_time / 10000000) - epoch_diff; + char buf[20]; + struct tm tm_buf; +#ifdef _WIN32 + localtime_s(&tm_buf, &unix_time); +#else + localtime_r(&unix_time, &tm_buf); +#endif + strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm_buf); + return std::string(buf); +} + +std::string StormErrorString(uint32_t err) { + switch (err) { + // clang-format off + case ERROR_SUCCESS: return "Success"; + case ERROR_FILE_NOT_FOUND: return "File not found"; + case ERROR_ACCESS_DENIED: return "Access denied (archive may be read-only or have open files)"; + case ERROR_INVALID_HANDLE: return "Invalid handle"; + case ERROR_NOT_ENOUGH_MEMORY: return "Not enough memory"; + case ERROR_NOT_SUPPORTED: return "Operation not supported"; + case ERROR_INVALID_PARAMETER: return "Invalid parameter"; + case ERROR_DISK_FULL: return "Disk full"; + case ERROR_ALREADY_EXISTS: return "Already exists"; + case ERROR_INSUFFICIENT_BUFFER: return "Insufficient buffer"; + case ERROR_BAD_FORMAT: return "Bad MPQ format"; + case ERROR_NO_MORE_FILES: return "No more files"; + case ERROR_HANDLE_EOF: return "End of file"; + case ERROR_CAN_NOT_COMPLETE: return "Cannot complete"; + case ERROR_FILE_CORRUPT: return "File is corrupt"; + case ERROR_BUFFER_OVERFLOW: return "Buffer overflow"; + case ERROR_INVALID_DATA: return "Invalid data"; + case ERROR_NO_UNICODE_TRANSLATION: return "No Unicode translation"; + case ERROR_AVI_FILE: return "Not an MPQ file (AVI file)"; + case ERROR_UNKNOWN_FILE_KEY: return "Unknown file encryption key"; + case ERROR_CHECKSUM_ERROR: return "Sector checksum mismatch"; + case ERROR_INTERNAL_FILE: return "Operation not allowed on internal file"; + case ERROR_BASE_FILE_MISSING: return "Base file missing for incremental patch"; + case ERROR_MARKED_FOR_DELETE: return "File is marked as deleted in the MPQ"; + case ERROR_FILE_INCOMPLETE: return "Required file part is missing"; + case ERROR_UNKNOWN_FILE_NAMES: return "At least one file name is unknown (listfile is incomplete)"; + case ERROR_CANT_FIND_PATCH_PREFIX: return "Cannot find patch prefix"; + case ERROR_FAKE_MPQ_HEADER: return "Fake MPQ header at this position"; + case ERROR_FILE_DELETED: return "File contains delete marker"; + default: return std::strerror(static_cast(err)); + // clang-format on + } +} + +void WriteBinary(std::ostream &out, const char *buffer, uint32_t size) { +#ifdef _WIN32 + // The mode belongs to the file descriptor rather than the stream, so switching + // it for a captured stream would change the real stdout for the rest of the run + if (&out == &std::cout) { + _setmode(_fileno(stdout), _O_BINARY); + } +#endif + out.write(buffer, size); +} + +} // namespace mpqcli diff --git a/src/util/format.h b/src/util/format.h new file mode 100644 index 0000000..80aef84 --- /dev/null +++ b/src/util/format.h @@ -0,0 +1,18 @@ +#ifndef UTIL_FORMAT_H +#define UTIL_FORMAT_H + +#include +#include +#include + +namespace mpqcli { + +std::string FileTimeToLsTime(int64_t file_time); +std::string StormErrorString(uint32_t err); + +/// Writes size bytes of buffer to out without line-ending translation +void WriteBinary(std::ostream &out, const char *buffer, uint32_t size); + +} // namespace mpqcli + +#endif diff --git a/src/util/hash.cpp b/src/util/hash.cpp new file mode 100644 index 0000000..c9cc77a --- /dev/null +++ b/src/util/hash.cpp @@ -0,0 +1,46 @@ +#include "util/hash.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace mpqcli { + +std::optional ComputeFileCrc32(const fs::path &path) { + std::ifstream f(path, std::ios::binary); + if (!f) { + return std::nullopt; + } + CRC32 crc32; + char buf[65536]; + while (f.read(buf, sizeof(buf)) || f.gcount() > 0) { + crc32.add(buf, static_cast(f.gcount())); + } + // getHash yields the checksum as big-endian bytes; reassemble the value + unsigned char digest[CRC32::HashBytes]; + crc32.getHash(digest); + return (static_cast(digest[0]) << 24) | (static_cast(digest[1]) << 16) | + (static_cast(digest[2]) << 8) | static_cast(digest[3]); +} + +bool ComputeFileMd5(const fs::path &path, uint8_t *md5_out) { + std::ifstream f(path, std::ios::binary); + if (!f) { + return false; + } + MD5 md5; + char buf[65536]; + while (f.read(buf, sizeof(buf)) || f.gcount() > 0) { + md5.add(buf, static_cast(f.gcount())); + } + md5.getHash(md5_out); + return true; +} + +} // namespace mpqcli diff --git a/src/util/hash.h b/src/util/hash.h new file mode 100644 index 0000000..ba0b522 --- /dev/null +++ b/src/util/hash.h @@ -0,0 +1,21 @@ +#ifndef UTIL_HASH_H +#define UTIL_HASH_H + +#include +#include +#include + +namespace mpqcli { + +// CRC32 (ZIP/gzip polynomial) and MD5 match the values StormLib stores in +// (attributes), which is what lets the --update check compare them directly. + +/// Returns the CRC32 of path, or nullopt if it cannot be read +std::optional ComputeFileCrc32(const std::filesystem::path &path); + +/// Writes the MD5 of path to md5_out, returning false if it cannot be read +bool ComputeFileMd5(const std::filesystem::path &path, uint8_t *md5_out); + +} // namespace mpqcli + +#endif diff --git a/src/locales.cpp b/src/util/locales.cpp similarity index 67% rename from src/locales.cpp rename to src/util/locales.cpp index 257c221..56a251c 100644 --- a/src/locales.cpp +++ b/src/util/locales.cpp @@ -1,23 +1,19 @@ -#include "locales.h" +#include "util/locales.h" #include -#include -#include #include #include +#include #include +namespace mpqcli { + namespace { -// Files in MPQs have locales with which they are associated. -// Multiple files can have the same file name if they have different locales. -// This function maps locales to language names. -// -// The mappings are from the Windows Language Code Identifier (LCID). -// They can be found, for example, here: +// Files with the same name can coexist under different locales; the identifiers +// are Windows LCIDs, listed in MS-LCID: // https://winprotocoldoc.z19.web.core.windows.net/MS-LCID/%5bMS-LCID%5d.pdf -// Define a bidirectional map for locale-language mappings -const std::map locale_to_lang_map = { +const std::map locale_to_lang_map = { {0x000, "enUS"}, // Default - English (US) {0x404, "zhTW"}, // Chinese (Taiwan) {0x405, "csCZ"}, // Czech @@ -38,11 +34,10 @@ const std::map locale_to_lang_map = { {0x816, "ptPT"}, // Portuguese (Portugal) }; -// Create a reverse map for language-to-locale lookups -const std::map lang_to_locale_map = []() { - std::map reverse_map; +const std::map lang_to_locale_map = []() { + std::map reverse_map; for (const auto &[locale, lang] : locale_to_lang_map) { - if (locale != default_locale) { // Skip the default locale to avoid duplication + if (locale != default_locale) { // 0x000 prints as enUS but enUS resolves to 0x409 reverse_map[lang] = locale; } } @@ -53,26 +48,26 @@ std::string FormatLocaleAsHex(const LCID locale) { std::stringstream ss; ss << std::hex << std::uppercase << locale; const std::string hex_str = ss.str(); - // Prepend 0s if needed + // Pad to four digits, guarding the subtraction: an LCID wider than 0xFFFF + // formats to more than four characters and would underflow the length + if (hex_str.length() >= 4) { + return hex_str; + } return std::string(4 - hex_str.length(), '0') + hex_str; } } // namespace -// Check if a string is a 4-character hexadecimal number and parse it -// Returns the parsed LCID if valid, otherwise returns defaultLocale (0) LCID ParseHexLocale(const std::string &str) { if (str.length() != 4) { return default_locale; } - // Check if all characters are hexadecimal for (char c : str) { if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) { return default_locale; } } - // Parse the hexadecimal string std::stringstream ss; ss << std::hex << str; LCID locale; @@ -80,7 +75,7 @@ LCID ParseHexLocale(const std::string &str) { return locale; } -std::string LocaleToLang(uint16_t locale) { +std::string LocaleToLang(const LCID locale) { auto it = locale_to_lang_map.find(locale); return it != locale_to_lang_map.end() ? it->second : FormatLocaleAsHex(locale); } @@ -91,7 +86,6 @@ LCID LangToLocale(const std::string &lang) { return it->second; } - // Try parsing as a hexadecimal LCID LCID hex_locale = ParseHexLocale(lang); if (hex_locale != default_locale) { return hex_locale; @@ -103,11 +97,10 @@ LCID LangToLocale(const std::string &lang) { std::vector GetAllLocales() { std::vector locales; for (const auto &[locale, lang] : locale_to_lang_map) { - if (locale != default_locale) { // Skip the default locale to avoid duplication + if (locale != default_locale) { // enUS would otherwise be listed twice locales.push_back(lang); } } - // Sort the locales for consistent output std::sort(locales.begin(), locales.end()); return locales; } @@ -119,3 +112,5 @@ std::string PrettyPrintLocale(const LCID locale, const std::string &prefix, bool const auto lang = LocaleToLang(locale); return prefix + lang; } + +} // namespace mpqcli diff --git a/src/locales.h b/src/util/locales.h similarity index 62% rename from src/locales.h rename to src/util/locales.h index ff07736..b45d6c2 100644 --- a/src/locales.h +++ b/src/util/locales.h @@ -1,19 +1,23 @@ -#ifndef LOCALES_H -#define LOCALES_H +#ifndef UTIL_LOCALES_H +#define UTIL_LOCALES_H -#include #include #include #include +namespace mpqcli { + inline constexpr LCID default_locale = 0; -std::string LocaleToLang(uint16_t locale); +std::string LocaleToLang(LCID locale); LCID LangToLocale(const std::string &lang); +/// Parses a four-digit hexadecimal locale, returning default_locale for anything else LCID ParseHexLocale(const std::string &str); std::vector GetAllLocales(); std::string PrettyPrintLocale(LCID locale, const std::string &prefix = "", bool always_print = false); -#endif // LOCALES_H +} // namespace mpqcli + +#endif diff --git a/src/util/path.cpp b/src/util/path.cpp new file mode 100644 index 0000000..45a2174 --- /dev/null +++ b/src/util/path.cpp @@ -0,0 +1,105 @@ +#include "util/path.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace mpqcli { + +std::string NormalizeFilePath(const fs::path &path) { + std::string file_path = path.u8string(); +#ifndef _WIN32 + std::replace(file_path.begin(), file_path.end(), '\\', '/'); + return file_path; +#else + return file_path; +#endif +} + +std::string WindowsifyFilePath(const fs::path &path) { + std::string file_path = path.u8string(); + std::replace(file_path.begin(), file_path.end(), '/', '\\'); + return file_path; +} + +std::vector ListFilesRecursive(const fs::path &directory, std::error_code &ec) { + std::vector files; + fs::recursive_directory_iterator it(directory, ec); + while (!ec && it != fs::recursive_directory_iterator()) { + // A dangling symlink reports not_found rather than a hard error, and is + // skipped like any other non-regular entry + std::error_code status_ec; + const fs::file_status status = it->status(status_ec); + if (fs::is_regular_file(status)) { + files.push_back(it->path()); + } else if (status_ec && status.type() != fs::file_type::not_found) { + ec = status_ec; + break; + } + it.increment(ec); + } + if (ec) { + return {}; + } + std::sort(files.begin(), files.end()); + return files; +} + +std::vector ExpandStdinMarker(const std::vector &paths, + std::istream &in) { + std::vector expanded; + for (const auto &arg : paths) { + if (arg != "-") { + expanded.push_back(arg); + continue; + } + std::string line; + while (std::getline(in, line)) { + if (!line.empty()) { + expanded.push_back(line); + } + } + } + return expanded; +} + +uint64_t LocalFileTimestamp(const fs::path &path) { +#ifdef _WIN32 + // _wstat64 handles paths with non-ASCII characters, which the narrow + // stat() would mangle on Windows. + struct _stat64 st {}; + if (_wstat64(path.wstring().c_str(), &st) != 0) { + return 0; + } +#else + struct stat st {}; + if (stat(path.string().c_str(), &st) != 0) { + return 0; + } +#endif + constexpr int64_t epoch_diff = 11644473600LL; + return static_cast((static_cast(st.st_mtime) + epoch_diff) * 10000000LL); +} + +bool IsWithinDirectory(const fs::path &base, const fs::path &path) { + return std::mismatch(base.begin(), base.end(), path.begin(), path.end()).first == base.end(); +} + +std::string ResolveArchiveName(const fs::path &f, const std::optional &path, + const bool treat_as_directory) { + fs::path file_path = path.value_or(f.filename().u8string()); + if (treat_as_directory) { + file_path = fs::path(path.value_or("")) / fs::path(f.filename().u8string()); + } + return WindowsifyFilePath(file_path); +} + +} // namespace mpqcli diff --git a/src/util/path.h b/src/util/path.h new file mode 100644 index 0000000..4e4560e --- /dev/null +++ b/src/util/path.h @@ -0,0 +1,37 @@ +#ifndef UTIL_PATH_H +#define UTIL_PATH_H + +#include +#include +#include +#include +#include +#include +#include + +namespace mpqcli { + +std::string NormalizeFilePath(const std::filesystem::path &path); +std::string WindowsifyFilePath(const std::filesystem::path &path); + +/// Reports whether path lies lexically under base, without resolving either +bool IsWithinDirectory(const std::filesystem::path &base, const std::filesystem::path &path); + +/// Builds the in-archive name for a local file +std::string ResolveArchiveName(const std::filesystem::path &f, + const std::optional &path, + bool treat_as_directory = false); + +std::vector ListFilesRecursive(const std::filesystem::path &directory, + std::error_code &ec); + +/// Replaces each "-" in paths with the non-empty lines read from in +std::vector ExpandStdinMarker(const std::vector &paths, std::istream &in); + +/// Returns the file's last-modification time as a Windows FILETIME value +/// (100-nanosecond intervals since 1601-01-01 UTC), or 0 if it cannot be read +uint64_t LocalFileTimestamp(const std::filesystem::path &path); + +} // namespace mpqcli + +#endif diff --git a/src/util/string.cpp b/src/util/string.cpp new file mode 100644 index 0000000..1276ede --- /dev/null +++ b/src/util/string.cpp @@ -0,0 +1,16 @@ +#include "util/string.h" + +#include +#include +#include + +namespace mpqcli { + +std::string ToLower(const std::string &str) { + std::string result = str; + std::transform(result.begin(), result.end(), result.begin(), + [](unsigned char c) { return std::tolower(c); }); + return result; +} + +} // namespace mpqcli diff --git a/src/util/string.h b/src/util/string.h new file mode 100644 index 0000000..fb76ab5 --- /dev/null +++ b/src/util/string.h @@ -0,0 +1,13 @@ +#ifndef UTIL_STRING_H +#define UTIL_STRING_H + +#include + +namespace mpqcli { + +/// Returns str with every ASCII letter lowercased +std::string ToLower(const std::string &str); + +} // namespace mpqcli + +#endif diff --git a/src/validators.h b/src/validators.h deleted file mode 100644 index dbfbec5..0000000 --- a/src/validators.h +++ /dev/null @@ -1,34 +0,0 @@ -#ifndef VALIDATORS_H -#define VALIDATORS_H - -#include - -#include "gamerules.h" -#include "locales.h" - -// Defined in gamerules.cpp -extern const CLI::Validator game_profile_valid; - -// Inline locale validator -inline const auto locale_valid = CLI::Validator( - [](const std::string &str) { - if (str == "default") - return std::string(); - - if (ParseHexLocale(str) != default_locale) { - return std::string(); - } - - const LCID locale = LangToLocale(str); - if (locale == 0) { - std::string valid_locales = "Locale must be nothing, or one of:"; - for (const auto &l : GetAllLocales()) { - valid_locales += " " + l; - } - return valid_locales; - } - return std::string(); - }, - "", "LocaleValidator"); - -#endif // VALIDATORS_H diff --git a/test/conftest.py b/test/conftest.py index ed62f8e..d4efca6 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -14,9 +14,9 @@ def binary_path(): script_dir = Path(__file__).parent if platform.system() == "Windows": - binary = script_dir.parent / "build" / "bin" / "mpqcli.exe" + binary = script_dir.parent / "build" / "dev" / "bin" / "mpqcli.exe" else: - binary = script_dir.parent / "build" / "bin" / "mpqcli" + binary = script_dir.parent / "build" / "dev" / "bin" / "mpqcli" if not binary.exists(): pytest.fail(f"Binary not found at {binary}") diff --git a/test/test_add.py b/test/test_add.py index 5ccbdcc..0b48fb4 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -58,13 +58,13 @@ def test_add_file_to_mpq_archive(binary_path, generate_test_files): ) output_lines = set(result.stdout.splitlines()) - expected_stdout_output = { - "[+] Adding file: test.txt", - } + expected_stdout_output = set() assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) - expected_stderr_output = set() + expected_stderr_output = { + "[+] Adding file: test.txt", + } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" @@ -98,8 +98,8 @@ def test_add_multiple_files_to_mpq_archive(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: alpha.txt" in result.stdout - assert "[+] Adding file: beta.txt" in result.stdout + assert "[+] Adding file: alpha.txt" in result.stderr + assert "[+] Adding file: beta.txt" in result.stderr expected_content = { "enUS bytes", @@ -245,14 +245,14 @@ def test_add_existing_file_with_overwrite_should_succeed(binary_path, generate_t ) output_lines = set(result.stdout.splitlines()) - expected_stdout_output = { - "[+] File already exists in MPQ archive: cats.txt - Overwriting...", - "[+] Adding file: cats.txt", - } + expected_stdout_output = set() assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) - expected_stderr_output = set() + expected_stderr_output = { + "[~] File already exists in MPQ archive: cats.txt - Overwriting...", + "[+] Adding file: cats.txt", + } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" @@ -278,13 +278,13 @@ def test_add_nonexisting_file_with_overwrite_should_succeed(binary_path, generat ) output_lines = set(result.stdout.splitlines()) - expected_stdout_output = { - "[+] Adding file: test.txt", - } + expected_stdout_output = set() assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) - expected_stderr_output = set() + expected_stderr_output = { + "[+] Adding file: test.txt", + } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" @@ -343,13 +343,13 @@ def test_create_mpq_with_locale(binary_path, generate_test_files): ) output_lines = set(result.stdout.splitlines()) - expected_stdout_output = { - "[+] Adding file for locale esES: cats.txt", - } + expected_stdout_output = set() assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) - expected_stderr_output = set() + expected_stderr_output = { + "[+] Adding file for locale esES: cats.txt", + } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" @@ -392,8 +392,8 @@ def test_add_file_with_game_profile(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" - assert f"Using game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" - assert f"Adding file: test_{profile}.txt" in result.stdout + assert f"Using game profile: {profile}" in result.stderr, f"Game profile message not found for {profile}" + assert f"Adding file: test_{profile}.txt" in result.stderr list_result = subprocess.run( [str(binary_path), "list", str(target_file), "-d", "-p", "flags"], @@ -459,7 +459,7 @@ def test_add_file_with_all_game_profiles(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" - assert f"Using game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" + assert f"Using game profile: {profile}" in result.stderr, f"Game profile message not found for {profile}" list_result = subprocess.run( [str(binary_path), "list", str(target_file)], @@ -517,14 +517,14 @@ def test_add_existing_locale_file_with_overwrite_should_succeed(binary_path, gen text=True ) - expected_stdout = { - "[+] File for locale esES already exists in MPQ archive: cats.txt - Overwriting...", + expected_stderr = { + "[~] File for locale esES already exists in MPQ archive: cats.txt - Overwriting...", "[+] Adding file for locale esES: cats.txt", } - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" verify_file_in_mpq_has_content( binary_path, target_file, "cats.txt", @@ -569,11 +569,11 @@ def test_add_file_after_all_locale_variants_removed(binary_path, generate_locale text=True ) - expected_stdout = {"[+] Adding file: cats.txt"} - output_lines = set(result.stdout.splitlines()) + expected_stderr = {"[+] Adding file: cats.txt"} + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"Re-add after full removal failed: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" verify_file_in_mpq_has_content( binary_path, target_file, "cats.txt", @@ -603,8 +603,8 @@ def test_add_directory_to_mpq_archive(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: sub\\a.txt" in result.stdout - assert "[+] Adding file: b.txt" in result.stdout + assert "[+] Adding file: sub\\a.txt" in result.stderr + assert "[+] Adding file: b.txt" in result.stderr list_result = subprocess.run( [str(binary_path), "list", str(target_mpq)], @@ -677,7 +677,7 @@ def test_add_directory_with_overwrite(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] File already exists in MPQ archive: cats.txt - Overwriting..." in result.stdout + assert "[~] File already exists in MPQ archive: cats.txt - Overwriting..." in result.stderr verify_file_in_mpq_has_content(binary_path, target_mpq, "cats.txt", {"This is the replaced cats file."}) finally: @@ -737,10 +737,10 @@ def test_add_update_skips_unchanged_files(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Skipping unchanged file: cats.txt (MD5 matches)" in result.stdout - assert "[~] Skipping unchanged file: dogs.txt (MD5 matches)" in result.stdout - assert "files added" in result.stdout - assert "files skipped" in result.stdout + assert "[*] Skipping unchanged file: cats.txt (MD5 matches)" in result.stderr + assert "[*] Skipping unchanged file: dogs.txt (MD5 matches)" in result.stderr + assert "files added" in result.stderr + assert "files skipped" in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -766,10 +766,10 @@ def test_add_update_adds_changed_files(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stdout - assert "[~] Skipping unchanged file: dogs.txt" in result.stdout - assert "1 files added" in result.stdout - assert "1 files skipped" in result.stdout + assert "[+] Adding file: cats.txt" in result.stderr + assert "[*] Skipping unchanged file: dogs.txt" in result.stderr + assert "1 files added" in result.stderr + assert "1 files skipped" in result.stderr verify_file_in_mpq_has_content( binary_path, target_mpq, "cats.txt", @@ -804,8 +804,8 @@ def test_add_update_second_run_skips_all(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file:" not in result.stdout - assert "0 files added" in result.stdout + assert "[+] Adding file:" not in result.stderr + assert "0 files added" in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -833,8 +833,8 @@ def test_add_update_single_file_skips_when_unchanged(binary_path, generate_test_ ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Skipping unchanged file: cats.txt (MD5 matches)" in result.stdout - assert "[+] Adding file: cats.txt" not in result.stdout + assert "[*] Skipping unchanged file: cats.txt (MD5 matches)" in result.stderr + assert "[+] Adding file: cats.txt" not in result.stderr finally: shutil.rmtree(work_dir, ignore_errors=True) @@ -860,7 +860,7 @@ def test_add_update_single_file_replaces_when_changed(binary_path, generate_test ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stdout + assert "[+] Adding file: cats.txt" in result.stderr verify_file_in_mpq_has_content( binary_path, target_mpq, "cats.txt", @@ -915,8 +915,8 @@ def test_add_update_skips_unchanged_files_via_crc32(binary_path, generate_test_f ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Skipping unchanged file: cats.txt (CRC32 matches)" in result.stdout - assert "files skipped" in result.stdout + assert "[*] Skipping unchanged file: cats.txt (CRC32 matches)" in result.stderr + assert "files skipped" in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -944,8 +944,8 @@ def test_add_update_adds_changed_files_via_crc32(binary_path, generate_test_file ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stdout - assert "Skipping unchanged" not in result.stdout + assert "[+] Adding file: cats.txt" in result.stderr + assert "Skipping unchanged" not in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -974,8 +974,8 @@ def test_add_update_skips_unchanged_files_via_timestamp(binary_path, generate_te ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Skipping unchanged file: cats.txt (Timestamp matches)" in result.stdout - assert "files skipped" in result.stdout + assert "[*] Skipping unchanged file: cats.txt (Timestamp matches)" in result.stderr + assert "files skipped" in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -1006,8 +1006,8 @@ def test_add_update_adds_changed_files_via_timestamp(binary_path, generate_test_ ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stdout - assert "Skipping unchanged" not in result.stdout + assert "[+] Adding file: cats.txt" in result.stderr + assert "Skipping unchanged" not in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -1034,8 +1034,8 @@ def test_add_update_always_adds_without_attributes(binary_path, generate_test_fi ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stdout - assert "Skipping unchanged" not in result.stdout + assert "[+] Adding file: cats.txt" in result.stderr + assert "Skipping unchanged" not in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -1063,8 +1063,8 @@ def test_add_files_via_stdin(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: stdin_a.txt" in result.stdout - assert "[+] Adding file: stdin_b.txt" in result.stdout + assert "[+] Adding file: stdin_a.txt" in result.stderr + assert "[+] Adding file: stdin_b.txt" in result.stderr list_result = subprocess.run( [str(binary_path), "list", str(target_mpq)], diff --git a/test/test_compact.py b/test/test_compact.py index 575fa41..1d53fa7 100644 --- a/test/test_compact.py +++ b/test/test_compact.py @@ -74,8 +74,8 @@ def test_compact_mpq_without_listfile(binary_path, generate_mpq_without_internal output_lines = result.stderr.splitlines() assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert len(output_lines) == 1, f"Unexpected output: {output_lines}" - assert output_lines[0].startswith(expected_prefix), f"Unexpected output: {output_lines}" + assert len(output_lines) == 2, f"Unexpected output: {output_lines}" + assert output_lines[1].startswith(expected_prefix), f"Unexpected output: {output_lines}" def test_compact_mpq_with_listfile(binary_path, generate_mpq_without_internal_listfile): @@ -101,7 +101,7 @@ def test_compact_mpq_with_listfile(binary_path, generate_mpq_without_internal_li text=True ) - output_lines = result.stdout.splitlines() + output_lines = result.stderr.splitlines() assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" assert output_lines == expected_output, f"Unexpected output: {output_lines}" diff --git a/test/test_completion.py b/test/test_completion.py index 545f9c5..67a18be 100644 --- a/test/test_completion.py +++ b/test/test_completion.py @@ -37,7 +37,7 @@ def test_completion_bash_output_size(binary_path): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - script_path = Path(__file__).parent.parent / "src" / "completion" / "mpqcli.bash" + script_path = Path(__file__).parent.parent / "completion" / "mpqcli.bash" assert len(result.stdout) == len(script_path.read_text()) @@ -50,7 +50,7 @@ def test_completion_zsh_output_size(binary_path): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - script_path = Path(__file__).parent.parent / "src" / "completion" / "mpqcli.zsh" + script_path = Path(__file__).parent.parent / "completion" / "mpqcli.zsh" assert len(result.stdout) == len(script_path.read_text()) @@ -76,7 +76,7 @@ def test_completion_powershell_output_size(binary_path): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - script_path = Path(__file__).parent.parent / "src" / "completion" / "mpqcli.ps1" + script_path = Path(__file__).parent.parent / "completion" / "mpqcli.ps1" assert len(result.stdout) == len(script_path.read_text()) @@ -101,7 +101,7 @@ def test_completion_fish_output_size(binary_path): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - script_path = Path(__file__).parent.parent / "src" / "completion" / "mpqcli.fish" + script_path = Path(__file__).parent.parent / "completion" / "mpqcli.fish" assert len(result.stdout) == len(script_path.read_text()) diff --git a/test/test_create.py b/test/test_create.py index abd14ee..235e9c0 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -163,8 +163,8 @@ def test_create_mpq_with_weak_signature(binary_path, generate_test_files): text=True ) assert verify_result.returncode == 0, f"Signature verification failed: {verify_result.stderr}" - assert "[*] Verify success" in verify_result.stdout, \ - f"Unexpected verify output: {verify_result.stdout}" + assert "[*] Verify success" in verify_result.stderr, \ + f"Unexpected verify output: {verify_result.stderr}" def test_create_mpq_already_exists(binary_path, generate_test_files): @@ -377,7 +377,7 @@ def test_create_mpq_with_game_profile(binary_path, generate_test_files): assert result.returncode == 0, f"mpqcli failed with error for profile {profile}: {result.stderr}" assert output_file.exists(), f"MPQ file was not created for profile {profile}" assert output_file.stat().st_size > 0, f"MPQ file is empty for profile {profile}" - assert f"[*] Game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" + assert f"[*] Game profile: {profile}" in result.stderr, f"Game profile message not found for {profile}" # Verify the MPQ version version_result = subprocess.run( @@ -480,7 +480,7 @@ def test_create_mpq_with_all_game_profiles(binary_path, generate_test_files): assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" assert output_file.exists(), f"MPQ file was not created for profile {profile}" - assert f"[*] Game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" + assert f"[*] Game profile: {profile}" in result.stderr, f"Game profile message not found for {profile}" # Verify the MPQ version version_result = subprocess.run( diff --git a/test/test_extract.py b/test/test_extract.py index 45aa4e9..3fa6400 100644 --- a/test/test_extract.py +++ b/test/test_extract.py @@ -58,7 +58,7 @@ def test_extract_mpq_default_options(binary_path, generate_test_files): text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -120,7 +120,7 @@ def test_extract_mpq_output_directory_specified(binary_path, generate_test_files text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -179,7 +179,7 @@ def test_extract_file_from_mpq_output_directory_specified(binary_path, generate_ text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -228,15 +228,15 @@ def test_extract_file_from_mpq_with_locale(binary_path, generate_locales_mpq_tes text=True ) - expected_stdout = { + expected_stderr = { "[*] Extracted: " + file_to_extract } output_file = output_dir / file_to_extract expected_content = "Este es un archivo sobre gatos." - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" assert output_file.exists(), "Output directory was not created" assert output_file.read_text(encoding="utf-8") == expected_content, "Unexpected file content" @@ -265,15 +265,15 @@ def test_extract_file_from_mpq_with_default_locale(binary_path, generate_locales text=True ) - expected_stdout = { + expected_stderr = { "[*] Extracted: " + file_to_extract } output_file = output_dir / file_to_extract expected_content = "This is a file about cats." - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" assert output_file.exists(), "Output directory was not created" assert output_file.read_text(encoding="utf-8") == expected_content, "Unexpected file content" @@ -303,16 +303,16 @@ def test_extract_file_from_mpq_with_illegal_locale(binary_path, generate_locales text=True ) - expected_stdout = { - "[!] Warning: The locale 'nosuchlocale' is unknown. Will use default locale instead.", + expected_stderr = { + "[!] The locale 'nosuchlocale' is unknown. Will use default locale instead.", "[*] Extracted: " + file_to_extract } output_file = output_dir / file_to_extract expected_content = "This is a file about cats." - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" assert output_file.exists(), "Output directory was not created" assert output_file.read_text(encoding="utf-8") == expected_content, "Unexpected file content" @@ -341,15 +341,15 @@ def test_extract_file_from_mpq_with_locale_not_in_file(binary_path, generate_loc text=True ) - expected_stdout = { + expected_stderr = { "[*] Extracted: " + file_to_extract } output_file = output_dir / file_to_extract expected_content = "This is a file about cats." - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" assert output_file.exists(), "Output directory was not created" assert output_file.read_text(encoding="utf-8") == expected_content, "Unexpected file content" @@ -467,7 +467,7 @@ def test_extract_all_files_from_mpq_without_providing_listfile(binary_path, gene text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -513,7 +513,7 @@ def test_extract_all_files_from_mpq_without_providing_listfile_and_with_given_lo text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -557,7 +557,7 @@ def test_extract_single_file_from_mpq_without_providing_listfile(binary_path, ge text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -604,7 +604,7 @@ def test_extract_all_files_from_mpq_providing_partial_external_listfile(binary_p text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -652,7 +652,7 @@ def test_extract_all_files_from_mpq_providing_partial_external_listfile_and_with text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -699,7 +699,7 @@ def test_extract_all_files_from_mpq_providing_complete_external_listfile(binary_ text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -747,7 +747,7 @@ def test_extract_all_files_from_mpq_providing_complete_external_listfile_and_wit text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -787,11 +787,10 @@ def test_extract_path_traversal_is_blocked(binary_path, generate_path_traversal_ text=True ) - expected_stdout = { + expected_stdout = set() + expected_stderr = { "[*] Extracted: safe.txt", "[*] Extracted: (listfile)", - } - expected_stderr = { "[!] Blocked: path traversal attempt detected: " + os.path.normpath("../../sneaky.txt"), "", "[!] Failed to extract all files.", @@ -863,11 +862,11 @@ def test_extract_nested_file_from_mpq(binary_path, generate_test_files): text=True ) - expected_stdout = {"[*] Extracted: cats.txt"} - output_lines = set(result.stdout.splitlines()) + expected_stderr = {"[*] Extracted: cats.txt"} + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" extracted_file = output_dir / "cats.txt" assert extracted_file.exists(), "Extracted nested file does not exist" diff --git a/test/test_read.py b/test/test_read.py index e72a5bf..8fbf02a 100644 --- a/test/test_read.py +++ b/test/test_read.py @@ -128,13 +128,16 @@ def test_read_file_from_mpq_with_illegal_locale(binary_path, generate_locales_mp ) expected_content = { - "[!] Warning: The locale 'nosuchlocale' is unknown. Will use default locale instead.", "This is a file about cats.", } + expected_warning = { + "[!] The locale 'nosuchlocale' is unknown. Will use default locale instead.", + } output_lines = set(result.stdout.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" assert output_lines == expected_content, f"Unexpected output: {output_lines}" + assert set(result.stderr.splitlines()) == expected_warning, f"Unexpected stderr: {result.stderr}" def test_read_file_from_mpq_with_locale_not_in_file(binary_path, generate_locales_mpq_test_files): diff --git a/test/test_remove.py b/test/test_remove.py index 16da4f1..65aef92 100644 --- a/test/test_remove.py +++ b/test/test_remove.py @@ -44,13 +44,12 @@ def test_remove_target_file_does_not_exist(binary_path, generate_locales_mpq_tes ) output_lines = set(result.stdout.splitlines()) - expected_stdout_output = { - "[-] Removing file: does-not-exist.txt", - } + expected_stdout_output = set() assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) expected_stderr_output = { + "[-] Removing file: does-not-exist.txt", "[!] Failed: File doesn't exist for locale enUS: does-not-exist.txt", } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" @@ -92,13 +91,12 @@ def test_remove_file_from_mpq_archive_with_wrong_locale_given( ) output_lines = set(result.stdout.splitlines()) - expected_output = { - "[-] Removing file for locale ptPT: " + test_file, - } + expected_output = set() assert output_lines == expected_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) expected_stderr_output = { + "[-] Removing file for locale ptPT: " + test_file, "[!] Failed: File doesn't exist for locale ptPT: " + test_file, } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" @@ -127,13 +125,13 @@ def test_remove_default_locale_file_from_mpq_archive_unique_name(binary_path, ge ) output_lines = set(result.stdout.splitlines()) - expected_output = { - "[-] Removing file: capybaras.txt", - } + expected_output = set() assert output_lines == expected_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) - expected_stderr_output = set() + expected_stderr_output = { + "[-] Removing file: capybaras.txt", + } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" @@ -311,7 +309,7 @@ def test_remove_files_via_stdin(binary_path, generate_locales_mpq_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[-] Removing file: cats.txt" in result.stdout + assert "[-] Removing file: cats.txt" in result.stderr expected_output = { "deDE cats.txt", diff --git a/test/test_rename.py b/test/test_rename.py index 191a7d1..acbe3b0 100644 --- a/test/test_rename.py +++ b/test/test_rename.py @@ -14,7 +14,7 @@ def test_rename_file_in_mpq_archive(binary_path, generate_mpq_without_internal_l ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Renaming file: capybaras.txt -> renamed.txt" in result.stdout + assert "[~] Renaming file: capybaras.txt -> renamed.txt" in result.stderr result = subprocess.run( [str(binary_path), "list", str(target_file), "--listfile", str(listfile)], @@ -78,13 +78,12 @@ def test_rename_target_file_does_not_exist(binary_path, generate_locales_mpq_tes ) output_lines = set(result.stdout.splitlines()) - expected_stdout_output = { - "[~] Renaming file: does-not-exist.txt -> renamed.txt", - } + expected_stdout_output = set() assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) expected_stderr_output = { + "[~] Renaming file: does-not-exist.txt -> renamed.txt", "[!] Failed: File doesn't exist for locale enUS: does-not-exist.txt", } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" @@ -126,13 +125,12 @@ def test_rename_file_from_mpq_archive_with_wrong_locale_given( ) output_lines = set(result.stdout.splitlines()) - expected_output = { - "[~] Renaming file for locale ptPT: " + test_file + " -> renamed.txt", - } + expected_output = set() assert output_lines == expected_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) expected_stderr_output = { + "[~] Renaming file for locale ptPT: " + test_file + " -> renamed.txt", "[!] Failed: File doesn't exist for locale ptPT: " + test_file, } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" @@ -161,13 +159,13 @@ def test_rename_default_locale_file_from_mpq_archive_unique_name(binary_path, ge ) output_lines = set(result.stdout.splitlines()) - expected_output = { - "[~] Renaming file: capybaras.txt -> renamed.txt", - } + expected_output = set() assert output_lines == expected_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) - expected_stderr_output = set() + expected_stderr_output = { + "[~] Renaming file: capybaras.txt -> renamed.txt", + } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" @@ -338,7 +336,7 @@ def test_rename_to_existing_name_fails(binary_path, generate_mpq_without_interna ) assert result.returncode == 1, f"mpqcli unexpectedly succeeded: {result.stdout}" - assert "[~] Renaming file: capybaras.txt -> elephants.txt" in result.stdout + assert "[~] Renaming file: capybaras.txt -> elephants.txt" in result.stderr assert "[!] Failed: File cannot be renamed for locale enUS: capybaras.txt -> elephants.txt" in result.stderr # Verify the archive is unchanged: both files should still exist under their original names. @@ -386,7 +384,7 @@ def test_rename_converts_forward_slashes_to_backslashes(binary_path, generate_mp ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Renaming file: capybaras.txt -> deep\\renamed.txt" in result.stdout + assert "[~] Renaming file: capybaras.txt -> deep\\renamed.txt" in result.stderr result = subprocess.run( [str(binary_path), "list", str(target_file), "--listfile", str(listfile)], diff --git a/test/test_verify.py b/test/test_verify.py index 23e3e1e..95ffd93 100644 --- a/test/test_verify.py +++ b/test/test_verify.py @@ -87,7 +87,7 @@ def test_verify_weak_signature(binary_path): text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" assert output_lines == expected_output, f"Unexpected output: {output_lines}"