feat(ariadne): add extraction preparation script #12

Merged
alexion merged 1 commits from ariadne into main 2026-09-05 18:49:30 -04:00
5 changed files with 284 additions and 0 deletions

2
src/ariadne/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Local game-derived preparation output.
/output/

5
src/ariadne/BUILD Normal file
View File

@@ -0,0 +1,5 @@
sh_binary(
name = "extract_and_decompile",
srcs = ["extract-and-decompile.sh"],
data = ["extract-tools.nix"],
)

25
src/ariadne/README.md Normal file
View File

@@ -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 `<workspace>/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.

View File

@@ -0,0 +1,224 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: extract-and-decompile --game-root <path> [--output-root <path>] [--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 <output-root>/source/, transient work under <output-root>/work/, and local logs under <output-root>/logs/.
The default output root is <workspace>/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 <path>'
[[ -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"

View File

@@ -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
];
}