diff --git a/src/ariadne/.gitignore b/src/ariadne/.gitignore new file mode 100644 index 0000000..f1cadcb --- /dev/null +++ b/src/ariadne/.gitignore @@ -0,0 +1,2 @@ +# Local game-derived preparation output. +/output/ diff --git a/src/ariadne/BUILD b/src/ariadne/BUILD new file mode 100644 index 0000000..e40b023 --- /dev/null +++ b/src/ariadne/BUILD @@ -0,0 +1,5 @@ +sh_binary( + name = "extract_and_decompile", + srcs = ["extract-and-decompile.sh"], + data = ["extract-tools.nix"], +) diff --git a/src/ariadne/README.md b/src/ariadne/README.md new file mode 100644 index 0000000..1d25d73 --- /dev/null +++ b/src/ariadne/README.md @@ -0,0 +1,25 @@ +# Ariadne + +Ariadne prepares Ren'Py game source for later analysis. + +## Bazel shape + +Keep project-owned source under `src/ariadne/`. Keep game-derived files local and ignored under +`src/ariadne/output/`. + +The first target is a shell entry point: + +```sh +bazel run //src/ariadne:extract_and_decompile -- \ + --game-root /path/to/game-install +``` + +The script defaults to `/src/ariadne/output`. +This path stays stable when you run the command from inside `src/ariadne`. +Use the game installation directory as the game root. +One run processes all installed season directories. +If `rpycdec` is not available, the script uses `extract-tools.nix` to enter a Nix shell. + +Keep the script as a thin orchestration layer. +Bazel owns stable inputs and wrappers. +Keep large game files and recovered output outside the build graph. diff --git a/src/ariadne/extract-and-decompile.sh b/src/ariadne/extract-and-decompile.sh new file mode 100755 index 0000000..0249baa --- /dev/null +++ b/src/ariadne/extract-and-decompile.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: extract-and-decompile --game-root [--output-root ] [--force] + +Prepare local game-owned source for Ariadne. + +Use the game installation directory as the game root to process all installed seasons. +The utility writes recovered source under /source/, transient work under /work/, and local logs under /logs/. +The default output root is /src/ariadne/output. +Without --force, it refuses to run when any of those directories already exists. +With --force, it deletes and recreates only source/, work/, and logs/ under the output root. +The script enters a Nix shell for rpycdec when required tools are not already available. +Game-derived preparation output must remain local and must not be committed. +USAGE +} + +fail() { + printf 'extract-and-decompile: %s\n' "$*" >&2 + exit 2 +} + +require_tool() { + local name=$1 + if ! command -v "$name" >/dev/null 2>&1; then + fail "missing required tool '$name'" + fi +} + +tools_available() { + command -v python3 >/dev/null 2>&1 && command -v rpycdec >/dev/null 2>&1 +} + +enter_nix_shell_if_needed() { + if tools_available; then + return + fi + + if [[ "${ARIADNE_NIX_SHELL:-}" == 1 ]]; then + require_tool python3 + require_tool rpycdec + return + fi + + require_tool nix + + local tools_nix + if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then + tools_nix=$BUILD_WORKSPACE_DIRECTORY/src/ariadne/extract-tools.nix + else + tools_nix=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/extract-tools.nix + fi + + [[ -f "$tools_nix" ]] || fail "missing Ariadne tool definition: $tools_nix" + + say "entering Nix shell for rpycdec" + export ARIADNE_NIX_SHELL=1 + exec nix shell \ + --extra-experimental-features 'nix-command flakes' \ + --file "$tools_nix" \ + --command bash "$0" "$@" +} + +say() { + printf 'extract-and-decompile: %s\n' "$*" >&2 +} + +game_root= +default_output_root=${BUILD_WORKSPACE_DIRECTORY:-$(pwd)}/src/ariadne/output +output_root=$default_output_root +force=false +original_args=("$@") + +while [[ $# -gt 0 ]]; do + case "$1" in + --game-root) + [[ $# -ge 2 ]] || fail '--game-root requires a path' + game_root=$2 + shift 2 + ;; + --output-root) + [[ $# -ge 2 ]] || fail '--output-root requires a path' + output_root=$2 + shift 2 + ;; + --force) + force=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unexpected argument: $1" + ;; + esac +done + +[[ -n "$game_root" ]] || fail 'missing --game-root ' +[[ -d "$game_root" ]] || fail "game root is not a directory: $game_root" + +enter_nix_shell_if_needed "${original_args[@]}" + +preparation_dirs=(source work logs) + +for name in "${preparation_dirs[@]}"; do + path="$output_root/$name" + if [[ -e "$path" && "$force" != true ]]; then + fail "refusing to overwrite existing preparation path without --force: $path" + fi +done + +if [[ "$force" == true ]]; then + say "removing existing preparation directories under $output_root" + for name in "${preparation_dirs[@]}"; do + rm -rf -- "${output_root:?}/$name" + done +fi + +say "creating preparation directories under $output_root" +for name in "${preparation_dirs[@]}"; do + mkdir -p -- "$output_root/$name" +done + +source_dir=$output_root/source +work_dir=$output_root/work +logs_dir=$output_root/logs +summary_log=$logs_dir/extract-and-decompile.log +: > "$summary_log" + +log() { + printf '%s\n' "$*" >> "$summary_log" +} + +record() { + say "$*" + log "$*" +} + +relative_to_game_root() { + realpath --relative-to="$game_root" "$1" +} + +is_renpy_runtime_path() { + local relative=$1 + [[ "$relative" == renpy/* || "$relative" == */renpy/* ]] +} + +copy_source_file() { + local input=$1 + local input_root=$2 + local label=$3 + local relative output + relative=$(realpath --relative-to="$input_root" "$input") + if is_renpy_runtime_path "$relative"; then + log "skipped $label Ren'Py runtime source: $relative" + return + fi + output=$source_dir/$relative + mkdir -p -- "$(dirname -- "$output")" + cp -- "$input" "$output" + record "copied $label source: $relative" +} + +copy_loose_source() { + copy_source_file "$1" "$game_root" loose +} + +decompile_compiled_script() { + local input=$1 + local input_root=$2 + local relative output_dir log_file + relative=$(realpath --relative-to="$input_root" "$input") + if is_renpy_runtime_path "$relative"; then + log "skipped Ren'Py runtime compiled script: $relative" + return + fi + output_dir=$source_dir/$(dirname -- "$relative") + log_file=$logs_dir/decompile-${relative//\//__}.log + mkdir -p -- "$output_dir" + say "decompiling script: $relative" + RPYCDEC_NO_WARNING=1 rpycdec decompile --output "$output_dir" "$input" > "$log_file" 2>&1 + log "decompiled script: $relative" +} + +extract_archive() { + local archive=$1 + local relative archive_output log_file + relative=$(relative_to_game_root "$archive") + archive_output=$work_dir/archives/${relative%.rpa} + log_file=$logs_dir/unrpa-${relative//\//__}.log + mkdir -p -- "$archive_output" + say "extracting archive: $relative" + RPYCDEC_NO_WARNING=1 rpycdec unrpa --output "$archive_output" "$archive" > "$log_file" 2>&1 + log "extracted archive: $relative" + + while IFS= read -r -d '' archived_source; do + copy_source_file "$archived_source" "$archive_output" archived + done < <(find "$archive_output" -type f -name '*.rpy' -print0 | sort -z) + + while IFS= read -r -d '' compiled; do + decompile_compiled_script "$compiled" "$archive_output" + done < <(find "$archive_output" -type f \( -name '*.rpyc' -o -name '*.rpymc' \) -print0 | sort -z) +} + +say "scanning loose source files" +while IFS= read -r -d '' source_file; do + copy_loose_source "$source_file" +done < <(find "$game_root" -type f -name '*.rpy' -print0 | sort -z) + +say "scanning archives" +while IFS= read -r -d '' archive; do + extract_archive "$archive" +done < <(find "$game_root" -type f -name '*.rpa' -print0 | sort -z) + +say "scanning loose compiled scripts" +while IFS= read -r -d '' compiled; do + decompile_compiled_script "$compiled" "$game_root" +done < <(find "$game_root" -type f \( -name '*.rpyc' -o -name '*.rpymc' \) -print0 | sort -z) + +say "done" diff --git a/src/ariadne/extract-tools.nix b/src/ariadne/extract-tools.nix new file mode 100644 index 0000000..70bd6b9 --- /dev/null +++ b/src/ariadne/extract-tools.nix @@ -0,0 +1,28 @@ +let + pkgs = import (builtins.getFlake "nixpkgs") { system = builtins.currentSystem; }; + rpycdec = pkgs.python3Packages.buildPythonApplication { + pname = "rpycdec"; + version = "0.0.0+1000bd1"; + pyproject = true; + src = pkgs.fetchFromGitHub { + owner = "cnfatal"; + repo = "rpycdec"; + rev = "1000bd1da10ef07e82f6fa560c621e6a4b55d1b8"; + hash = "sha256-VoVbIKs8xz5x2s0OGqdHbY55fWDruqMAX8YYjEQhwTQ="; + }; + nativeBuildInputs = with pkgs.python3Packages; [ + hatch-vcs + hatchling + ]; + env.SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RPYCDEC = "0.0.0+1000bd1"; + }; +in +pkgs.buildEnv { + name = "ariadne-extract-and-decompile-tools"; + paths = [ + pkgs.bash + pkgs.coreutils + pkgs.findutils + rpycdec + ]; +}