diff --git a/CMakeLists.txt b/CMakeLists.txt index 3f5d563..a7e7c57 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,26 +56,29 @@ pico_sdk_init() # Configure BLUEPAD32 input backend if selected if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") - # Ensure Python3 is available and execute patch preparation find_package(Python3 REQUIRED COMPONENTS Interpreter) - + set(BLUEPAD32_PREP_SCRIPT ${CMAKE_CURRENT_LIST_DIR}/tools/prepare_bluepad32.py) + set(BLUEPAD32_SOURCE ${CMAKE_CURRENT_LIST_DIR}/external/bluepad32) + set(BLUEPAD32_PATCH ${CMAKE_CURRENT_LIST_DIR}/patches/bluepad32-sdl3-imu.patch) + set(BLUEPAD32_ROOT ${CMAKE_CURRENT_BINARY_DIR}/_deps/bluepad32-src) execute_process( COMMAND ${Python3_EXECUTABLE} ${BLUEPAD32_PREP_SCRIPT} + --bluepad32 ${BLUEPAD32_SOURCE} + --patch ${BLUEPAD32_PATCH} + --output ${BLUEPAD32_ROOT} WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR} RESULT_VARIABLE BLUEPAD32_PREP_RESULT OUTPUT_VARIABLE BLUEPAD32_PREP_OUTPUT ERROR_VARIABLE BLUEPAD32_PREP_ERROR ) - + if(NOT BLUEPAD32_PREP_RESULT EQUAL 0) message(FATAL_ERROR - "Failed to prepare Bluepad32: Patch application or validation failed. " + "Failed to prepare isolated Bluepad32 source. " "Details: ${BLUEPAD32_PREP_ERROR}") endif() - - # Configure Bluepad32 include paths and subdirectory - set(BLUEPAD32_ROOT ${CMAKE_CURRENT_LIST_DIR}/external/bluepad32) + set(BTSTACK_ROOT ${PICO_SDK_PATH}/lib/btstack) include_directories( ${CMAKE_CURRENT_LIST_DIR}/bluepad32_config diff --git a/README.md b/README.md index 11c03ce..9cf6348 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ This uses an isolated `build-aio/` CMake cache and publishes: The default `python3 build.py` command and `firmware/switch-pico.*` artifacts remain the UART/Pico build. The AIO build requires `PICO_BOARD=pico2_w`; it is not interchangeable with the original non-wireless Pico firmware. -Both `build.py --aio` and direct AIO CMake configuration apply `patches/bluepad32-sdl3-imu.patch` idempotently before compiling Bluepad32. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. It intentionally leaves the dependency worktree dirty; the committed submodule revision remains Bluepad32 4.2.0. +Both `build.py --aio` and direct AIO CMake configuration copy the pinned Bluepad32 source into the active build directory and apply `patches/bluepad32-sdl3-imu.patch` there before compiling. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. The `external/bluepad32` submodule remains pristine; patch or source-revision drift fails configuration. ### Pairing up to four controllers diff --git a/build.py b/build.py index 7fc2ca0..eda4f08 100644 --- a/build.py +++ b/build.py @@ -366,7 +366,6 @@ def build( firmware_uf2_path, ): if aio or adapter_feasibility: - run_cmd([sys.executable, str(SCRIPT_DIR / "tools" / "prepare_bluepad32.py")]) definitions = [ "-DSWITCH_PICO_LOG=OFF", "-DPICO_BOARD=pico2_w", diff --git a/tests/test_prepare_bluepad32.py b/tests/test_prepare_bluepad32.py index 3e4673f..fe17f76 100644 --- a/tests/test_prepare_bluepad32.py +++ b/tests/test_prepare_bluepad32.py @@ -1,339 +1,194 @@ -""" -Tests for prepare_bluepad32.py patch preparation tool. - -Tests cover: -- Fresh patch application -- Idempotence (second invocation succeeds without changing content) -- Missing paths validation -- Diverged/ambiguous repository states -""" +from __future__ import annotations +import shutil import subprocess -import tempfile -import pytest -from pathlib import Path -from unittest.mock import patch as mock_patch - import sys +from pathlib import Path + +import pytest + sys.path.insert(0, str(Path(__file__).parent.parent / "tools")) -from prepare_bluepad32 import ( +from prepare_bluepad32 import ( # noqa: E402 PatchError, - resolve_paths, check_paths, - is_patch_applied, - apply_patch, + main, prepare_bluepad32, + resolve_paths, ) -@pytest.fixture -def temp_repo_structure(): - """Create a temporary directory structure with git repositories.""" - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - - # Create bluepad32 repo - bp_dir = root / "external" / "bluepad32" - bp_dir.mkdir(parents=True) - subprocess.run(["git", "init"], cwd=bp_dir, check=True, capture_output=True) - subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=bp_dir, check=True, capture_output=True) - subprocess.run(["git", "config", "user.name", "Test User"], cwd=bp_dir, check=True, capture_output=True) - - # Create a file to patch - test_file = bp_dir / "test.txt" - test_file.write_text("line 1\n") - subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "initial"], cwd=bp_dir, check=True, capture_output=True) - - # Create patches dir - patches_dir = root / "patches" - patches_dir.mkdir() - - yield root, bp_dir, patches_dir - - -def create_simple_patch(repo_path: Path, patch_path: Path, file_to_patch: str = "test.txt") -> str: - """ - Create a simple patch file that modifies a file in the repository. - - Returns the patch content as a string. - """ - # Create the modification - test_file = repo_path / file_to_patch - original_content = test_file.read_text() - modified_content = original_content + "line 2\n" - - # Generate patch using git diff - test_file.write_text(modified_content) - result = subprocess.run( - ["git", "diff", file_to_patch], - cwd=repo_path, +def run_git(repository: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(repository), *args], capture_output=True, text=True, check=True, ) - patch_content = result.stdout - - # Reset the file to original state - test_file.write_text(original_content) - - # Write patch to file - patch_path.write_text(patch_content) - return patch_content -def test_resolve_paths_with_defaults(): - """Test that resolve_paths returns expected default paths.""" - with tempfile.TemporaryDirectory() as tmpdir: - bp_path, patch_path = resolve_paths(Path(tmpdir)) - assert bp_path == Path(tmpdir) / "external" / "bluepad32" - assert patch_path == Path(tmpdir) / "patches" / "bluepad32-sdl3-imu.patch" +@pytest.fixture +def bluepad32_fixture(tmp_path: Path) -> tuple[Path, Path, Path, Path]: + source = tmp_path / "external" / "bluepad32" + source.mkdir(parents=True) + run_git(source, "init") + run_git(source, "config", "user.email", "test@example.com") + run_git(source, "config", "user.name", "Test User") + (source / "test.txt").write_text("line 1\n", encoding="utf-8") + run_git(source, "add", "test.txt") + run_git(source, "commit", "-m", "initial") + + patches = tmp_path / "patches" + patches.mkdir() + patch = patches / "bluepad32-sdl3-imu.patch" + original = (source / "test.txt").read_text(encoding="utf-8") + (source / "test.txt").write_text(f"{original}line 2\n", encoding="utf-8") + patch.write_text(run_git(source, "diff", "--", "test.txt").stdout, encoding="utf-8") + run_git(source, "restore", "test.txt") + + output = tmp_path / "build-aio" / "_deps" / "bluepad32-src" + return tmp_path, source, patch, output -def test_resolve_paths_no_root(): - """Test resolve_paths with no root uses current directory.""" - bp_path, patch_path = resolve_paths() - assert bp_path.is_absolute() - assert patch_path.is_absolute() +def source_status(source: Path) -> str: + return run_git(source, "status", "--porcelain", "--untracked-files=all").stdout -def test_check_paths_missing_bluepad32(temp_repo_structure): - """Test that check_paths fails if bluepad32 dir is missing.""" - root, bp_dir, patches_dir = temp_repo_structure - - # Remove bluepad32 - import shutil - shutil.rmtree(bp_dir) - - patch_file = patches_dir / "test.patch" - patch_file.write_text("dummy") - +def test_resolve_paths_uses_project_build_tree(tmp_path: Path) -> None: + source, patch, output = resolve_paths(tmp_path) + + assert source == tmp_path / "external" / "bluepad32" + assert patch == tmp_path / "patches" / "bluepad32-sdl3-imu.patch" + assert output == tmp_path / "build" / "_deps" / "bluepad32-src" + + +def test_check_paths_rejects_missing_source( + bluepad32_fixture: tuple[Path, Path, Path, Path], +) -> None: + _, source, patch, output = bluepad32_fixture + shutil.rmtree(source) + with pytest.raises(PatchError, match="bluepad32 directory does not exist"): - check_paths(bp_dir, patch_file) + check_paths(source, patch, output) -def test_check_paths_missing_patch(temp_repo_structure): - """Test that check_paths fails if patch file is missing.""" - root, bp_dir, patches_dir = temp_repo_structure - - patch_file = patches_dir / "nonexistent.patch" - +def test_check_paths_rejects_missing_patch( + bluepad32_fixture: tuple[Path, Path, Path, Path], +) -> None: + _, source, patch, output = bluepad32_fixture + patch.unlink() + with pytest.raises(PatchError, match="patch file does not exist"): - check_paths(bp_dir, patch_file) + check_paths(source, patch, output) -def test_check_paths_bluepad32_not_git_repo(temp_repo_structure): - """Test that check_paths fails if bluepad32 is not a git repo.""" - root, bp_dir, patches_dir = temp_repo_structure - - # Remove .git to make it not a git repo - import shutil - shutil.rmtree(bp_dir / ".git") - - patch_file = patches_dir / "test.patch" - patch_file.write_text("dummy") - +def test_check_paths_rejects_non_repository( + bluepad32_fixture: tuple[Path, Path, Path, Path], +) -> None: + _, source, patch, output = bluepad32_fixture + shutil.rmtree(source / ".git") + with pytest.raises(PatchError, match="not a git repository"): - check_paths(bp_dir, patch_file) + check_paths(source, patch, output) -def test_fresh_patch_application(temp_repo_structure): - """Test applying a fresh patch to a clean repository.""" - root, bp_dir, patches_dir = temp_repo_structure - - patch_file = patches_dir / "test.patch" - create_simple_patch(bp_dir, patch_file, "test.txt") - - # Verify test.txt before patch - test_file = bp_dir / "test.txt" - original = test_file.read_text() - assert "line 2" not in original - - # Apply patch - apply_patch(bp_dir, patch_file) - - # Verify test.txt after patch - patched = test_file.read_text() - assert "line 2" in patched +def test_check_paths_rejects_output_inside_source( + bluepad32_fixture: tuple[Path, Path, Path, Path], +) -> None: + _, source, patch, _ = bluepad32_fixture + + with pytest.raises(PatchError, match="outside the Bluepad32 source tree"): + check_paths(source, patch, source / "patched") -def test_idempotent_patch_application(temp_repo_structure): - """Test that applying the same patch twice succeeds (idempotence).""" - root, bp_dir, patches_dir = temp_repo_structure - - patch_file = patches_dir / "test.patch" - create_simple_patch(bp_dir, patch_file, "test.txt") - - # First application - apply_patch(bp_dir, patch_file) - test_file = bp_dir / "test.txt" - after_first = test_file.read_text() - - # Second application should succeed without changing content - apply_patch(bp_dir, patch_file) - after_second = test_file.read_text() - - assert after_first == after_second +def test_prepare_patches_copy_and_preserves_pristine_source( + bluepad32_fixture: tuple[Path, Path, Path, Path], +) -> None: + _, source, patch, output = bluepad32_fixture + + result = prepare_bluepad32(source, patch, output) + + assert result == output + assert (source / "test.txt").read_text(encoding="utf-8") == "line 1\n" + assert (output / "test.txt").read_text(encoding="utf-8") == "line 1\nline 2\n" + assert not (output / ".git").exists() + assert source_status(source) == "" -def test_is_patch_applied_not_applied(temp_repo_structure): - """Test is_patch_applied returns False for unapplied patch.""" - root, bp_dir, patches_dir = temp_repo_structure - - patch_file = patches_dir / "test.patch" - create_simple_patch(bp_dir, patch_file, "test.txt") - - # Patch not applied yet - assert is_patch_applied(bp_dir, patch_file) is False +def test_prepare_replaces_existing_output_idempotently( + bluepad32_fixture: tuple[Path, Path, Path, Path], +) -> None: + _, source, patch, output = bluepad32_fixture + prepare_bluepad32(source, patch, output) + (output / "stale.txt").write_text("stale", encoding="utf-8") + + prepare_bluepad32(source, patch, output) + + assert not (output / "stale.txt").exists() + assert (output / "test.txt").read_text(encoding="utf-8") == "line 1\nline 2\n" + assert source_status(source) == "" -def test_is_patch_applied_already_applied(temp_repo_structure): - """Test is_patch_applied returns True for already applied patch.""" - root, bp_dir, patches_dir = temp_repo_structure - - patch_file = patches_dir / "test.patch" - create_simple_patch(bp_dir, patch_file, "test.txt") - - # Apply patch first - apply_patch(bp_dir, patch_file) - - # Now check should detect it's applied - assert is_patch_applied(bp_dir, patch_file) is True +def test_prepare_rejects_dirty_source_without_replacing_output( + bluepad32_fixture: tuple[Path, Path, Path, Path], +) -> None: + _, source, patch, output = bluepad32_fixture + output.mkdir(parents=True) + sentinel = output / "sentinel.txt" + sentinel.write_text("keep", encoding="utf-8") + (source / "test.txt").write_text("local edit\n", encoding="utf-8") + + with pytest.raises(PatchError, match="must be pristine"): + prepare_bluepad32(source, patch, output) + + assert sentinel.read_text(encoding="utf-8") == "keep" -def test_diverged_repository_state(temp_repo_structure): - """Test that diverged repository (patch doesn't apply cleanly) is rejected.""" - root, bp_dir, patches_dir = temp_repo_structure - - patch_file = patches_dir / "test.patch" - create_simple_patch(bp_dir, patch_file, "test.txt") - - # Diverge the repository by modifying the file such that the patch conflicts - test_file = bp_dir / "test.txt" - test_file.write_text("completely different line 1\n") - subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "divergence"], cwd=bp_dir, check=True, capture_output=True) - - # Try to apply patch - should fail because file content doesn't match +def test_prepare_rejects_diverged_source_revision( + bluepad32_fixture: tuple[Path, Path, Path, Path], +) -> None: + _, source, patch, output = bluepad32_fixture + (source / "test.txt").write_text("different base\n", encoding="utf-8") + run_git(source, "add", "test.txt") + run_git(source, "commit", "-m", "diverge") + with pytest.raises(PatchError, match="Patch validation failed"): - apply_patch(bp_dir, patch_file) + prepare_bluepad32(source, patch, output) + + assert not output.exists() -def test_missing_bluepad32_path(temp_repo_structure): - """Test prepare_bluepad32 fails gracefully with missing bluepad32.""" - root, bp_dir, patches_dir = temp_repo_structure - - import shutil - shutil.rmtree(bp_dir) - - patch_file = patches_dir / "test.patch" - patch_file.write_text("dummy") - - with pytest.raises(PatchError, match="bluepad32 directory does not exist"): - prepare_bluepad32(bp_dir, patch_file) +def test_prepare_uses_default_paths( + bluepad32_fixture: tuple[Path, Path, Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + root, source, _, _ = bluepad32_fixture + monkeypatch.chdir(root) + + output = prepare_bluepad32() + + assert output == root / "build" / "_deps" / "bluepad32-src" + assert (output / "test.txt").read_text(encoding="utf-8") == "line 1\nline 2\n" + assert source_status(source) == "" -def test_missing_patch_file(temp_repo_structure): - """Test prepare_bluepad32 fails gracefully with missing patch file.""" - root, bp_dir, patches_dir = temp_repo_structure - - patch_file = patches_dir / "nonexistent.patch" - - with pytest.raises(PatchError, match="patch file does not exist"): - prepare_bluepad32(bp_dir, patch_file) +def test_cli_accepts_explicit_output( + bluepad32_fixture: tuple[Path, Path, Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + _, source, patch, output = bluepad32_fixture + monkeypatch.setattr( + sys, + "argv", + [ + "prepare_bluepad32.py", + "--bluepad32", + str(source), + "--patch", + str(patch), + "--output", + str(output), + ], + ) - -def test_prepare_bluepad32_full_workflow(temp_repo_structure): - """Test complete prepare_bluepad32 workflow: apply then idempotent re-apply.""" - root, bp_dir, patches_dir = temp_repo_structure - - patch_file = patches_dir / "test.patch" - create_simple_patch(bp_dir, patch_file, "test.txt") - - test_file = bp_dir / "test.txt" - original = test_file.read_text() - - # First prepare (should apply patch) - prepare_bluepad32(bp_dir, patch_file) - after_first = test_file.read_text() - assert after_first != original - assert "line 2" in after_first - - # Second prepare (should be idempotent) - prepare_bluepad32(bp_dir, patch_file) - after_second = test_file.read_text() - assert after_first == after_second - - -def test_prepare_bluepad32_with_defaults(temp_repo_structure): - """Test prepare_bluepad32 uses correct defaults when paths not provided.""" - root, bp_dir, patches_dir = temp_repo_structure - - patch_file = patches_dir / "bluepad32-sdl3-imu.patch" - create_simple_patch(bp_dir, patch_file, "test.txt") - - # Change to root directory and call with defaults - import os - original_cwd = os.getcwd() - try: - os.chdir(root) - prepare_bluepad32() # Use defaults - finally: - os.chdir(original_cwd) - - # Verify patch was applied - test_file = bp_dir / "test.txt" - assert "line 2" in test_file.read_text() - - -def test_patch_application_with_conflicting_content(temp_repo_structure): - """Test that patch with conflicting content is rejected.""" - root, bp_dir, patches_dir = temp_repo_structure - - # Create a patch that adds a specific change - patch_content = """--- a/test.txt -+++ b/test.txt -@@ -1 +1,3 @@ - line 1 -+line 2 -+line 3 -""" - - patch_file = patches_dir / "conflict.patch" - patch_file.write_text(patch_content) - - # Modify the file to have different content that won't match the patch context - test_file = bp_dir / "test.txt" - test_file.write_text("modified line 1\n") - subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "modify"], cwd=bp_dir, check=True, capture_output=True) - - # Try to apply patch - should fail due to context mismatch - with pytest.raises(PatchError): - apply_patch(bp_dir, patch_file) - - -def test_cli_with_explicit_paths(temp_repo_structure): - """Test CLI argument parsing with explicit paths.""" - root, bp_dir, patches_dir = temp_repo_structure - - patch_file = patches_dir / "test.patch" - create_simple_patch(bp_dir, patch_file, "test.txt") - - # Simulate CLI call - sys.argv = [ - "prepare_bluepad32.py", - "--bluepad32", str(bp_dir), - "--patch", str(patch_file), - ] - - from prepare_bluepad32 import main - - # Should not raise - try: - main() - except SystemExit as e: - # main() calls sys.exit on success, which we need to catch - if e.code != 0: - raise + assert main() == 0 + assert (output / "test.txt").read_text(encoding="utf-8") == "line 1\nline 2\n" + assert source_status(source) == "" diff --git a/tools/prepare_bluepad32.py b/tools/prepare_bluepad32.py index 981d40f..67a634e 100755 --- a/tools/prepare_bluepad32.py +++ b/tools/prepare_bluepad32.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Apply the project Bluepad32 patch exactly once.""" +"""Create a patched build-local copy of the pinned Bluepad32 source.""" from __future__ import annotations import argparse +import shutil import subprocess import sys from pathlib import Path @@ -12,69 +13,125 @@ class PatchError(RuntimeError): pass -def resolve_paths(repo_root: Path | None = None) -> tuple[Path, Path]: +def resolve_paths(repo_root: Path | None = None) -> tuple[Path, Path, Path]: root = Path.cwd() if repo_root is None else Path(repo_root) return ( root / "external" / "bluepad32", root / "patches" / "bluepad32-sdl3-imu.patch", + root / "build" / "_deps" / "bluepad32-src", ) -def check_paths(bluepad32_path: Path, patch_path: Path) -> None: - if not bluepad32_path.is_dir(): - raise PatchError(f"bluepad32 directory does not exist: {bluepad32_path}") - if not (bluepad32_path / ".git").exists(): - raise PatchError(f"Bluepad32 is not a git repository: {bluepad32_path}") - if not patch_path.is_file(): - raise PatchError(f"patch file does not exist: {patch_path}") - - -def git_apply(bluepad32_path: Path, patch_path: Path, *args: str) -> subprocess.CompletedProcess[str]: +def _run(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: return subprocess.run( - ["git", "-C", str(bluepad32_path), "apply", *args, str(patch_path)], + command, + cwd=cwd, capture_output=True, text=True, check=False, ) -def is_patch_applied(bluepad32_path: Path, patch_path: Path) -> bool: - return git_apply(bluepad32_path, patch_path, "--reverse", "--check").returncode == 0 +def check_paths(source_path: Path, patch_path: Path, output_path: Path) -> None: + if not source_path.is_dir(): + raise PatchError(f"bluepad32 directory does not exist: {source_path}") + if not patch_path.is_file(): + raise PatchError(f"patch file does not exist: {patch_path}") + + repository_check = _run( + ["git", "-C", str(source_path), "rev-parse", "--is-inside-work-tree"] + ) + if repository_check.returncode != 0: + raise PatchError(f"Bluepad32 is not a git repository: {source_path}") + + source = source_path.resolve() + output = output_path.resolve() + if output == source or source in output.parents: + raise PatchError("patched output must be outside the Bluepad32 source tree") -def apply_patch(bluepad32_path: Path, patch_path: Path) -> None: - if is_patch_applied(bluepad32_path, patch_path): - return +def validate_pristine_source(source_path: Path, patch_path: Path) -> None: + status = _run( + [ + "git", + "-C", + str(source_path), + "status", + "--porcelain", + "--untracked-files=all", + ] + ) + if status.returncode != 0: + detail = status.stderr.strip() or "git status failed" + raise PatchError(f"Could not inspect Bluepad32 source: {detail}") + if status.stdout.strip(): + raise PatchError( + "Bluepad32 source must be pristine; patches are applied only to the build-local copy" + ) - check = git_apply(bluepad32_path, patch_path, "--check") + check = _run( + ["git", "-C", str(source_path), "apply", "--check", str(patch_path)] + ) if check.returncode != 0: detail = check.stderr.strip() or "patch does not apply" - raise PatchError(f"Patch validation failed (repository may be diverged):\n{detail}") + raise PatchError(f"Patch validation failed (source revision may have diverged):\n{detail}") - result = git_apply(bluepad32_path, patch_path) - if result.returncode != 0: - detail = result.stderr.strip() or "git apply failed" - raise PatchError(f"Could not patch Bluepad32: {detail}") + +def _copy_and_patch(source_path: Path, patch_path: Path, output_path: Path) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + staging_path = output_path.with_name(f".{output_path.name}.tmp") + + if staging_path.exists(): + shutil.rmtree(staging_path) + + try: + shutil.copytree( + source_path, + staging_path, + symlinks=True, + ignore=shutil.ignore_patterns(".git"), + ) + result = _run( + ["git", "apply", "--no-index", str(patch_path.resolve())], + cwd=staging_path, + ) + if result.returncode != 0: + detail = result.stderr.strip() or "git apply failed" + raise PatchError(f"Could not patch build-local Bluepad32 copy: {detail}") + + if output_path.exists(): + shutil.rmtree(output_path) + staging_path.replace(output_path) + except Exception: + if staging_path.exists(): + shutil.rmtree(staging_path) + raise def prepare_bluepad32( - bluepad32_path: Path | None = None, + source_path: Path | None = None, patch_path: Path | None = None, -) -> None: - default_bluepad32, default_patch = resolve_paths() - dependency = Path(bluepad32_path or default_bluepad32) + output_path: Path | None = None, +) -> Path: + default_source, default_patch, default_output = resolve_paths() + source = Path(source_path or default_source) patch = Path(patch_path or default_patch) - check_paths(dependency, patch) - apply_patch(dependency, patch) + output = Path(output_path or default_output) + + check_paths(source, patch, output) + validate_pristine_source(source, patch) + _copy_and_patch(source, patch, output) + return output def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--bluepad32", type=Path) parser.add_argument("--patch", type=Path) + parser.add_argument("--output", type=Path) args = parser.parse_args() try: - prepare_bluepad32(args.bluepad32, args.patch) + prepare_bluepad32(args.bluepad32, args.patch, args.output) except PatchError as exc: print(f"error: {exc}", file=sys.stderr) return 1