Skip to content

Commit 4088bbc

Browse files
committed
add windows batches
1 parent d7cabda commit 4088bbc

8 files changed

Lines changed: 400 additions & 20 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ __pycache__/
55
.pytest_cache/
66
.mypy_cache/
77
.tmp_spawningtool/
8+
drop-log.txt
9+
install-log.txt
810
*.egg-info/
911
dist/
1012
build/

Drop replays here.bat

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
@echo off
2+
setlocal
3+
set "ROOT=%~dp0"
4+
set "LOG=%ROOT%drop-log.txt"
5+
set "PYTHONPATH=%ROOT%src;%PYTHONPATH%"
6+
echo Running sc2-replay-salt...>"%LOG%"
7+
echo.>>"%LOG%"
8+
echo Working on your replay file. This can take a moment...
9+
10+
if exist "%ROOT%.venv\Scripts\python.exe" (
11+
"%ROOT%.venv\Scripts\python.exe" -m sc2_replay_salt --easy --no-prompt %* >>"%LOG%" 2>>&1
12+
) else (
13+
where py >nul 2>nul
14+
if %ERRORLEVEL%==0 (
15+
py -3 -m sc2_replay_salt --easy --no-prompt %* >>"%LOG%" 2>>&1
16+
) else (
17+
python -m sc2_replay_salt --easy --no-prompt %* >>"%LOG%" 2>>&1
18+
)
19+
)
20+
21+
echo.
22+
type "%LOG%"
23+
echo.

Install once.bat

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
@echo off
2+
setlocal
3+
set "ROOT=%~dp0"
4+
set "LOG=%ROOT%install-log.txt"
5+
6+
echo Setting up sc2-replay-salt...
7+
echo Setup log for sc2-replay-salt>"%LOG%"
8+
echo.>>"%LOG%"
9+
10+
where py >nul 2>nul
11+
if %ERRORLEVEL%==0 (
12+
set "PY=py -3"
13+
) else (
14+
set "PY=python"
15+
)
16+
17+
if not exist "%ROOT%.venv\Scripts\python.exe" (
18+
echo Creating the private Python environment...
19+
%PY% -m venv "%ROOT%.venv" >>"%LOG%" 2>>&1
20+
if errorlevel 1 goto failed
21+
)
22+
23+
echo Updating the installer tools...
24+
"%ROOT%.venv\Scripts\python.exe" -m pip --disable-pip-version-check install --upgrade pip setuptools wheel >>"%LOG%" 2>>&1
25+
if errorlevel 1 goto failed
26+
27+
echo Installing the replay reader...
28+
"%ROOT%.venv\Scripts\python.exe" -m pip --disable-pip-version-check install --no-build-isolation -e "%ROOT%." >>"%LOG%" 2>>&1
29+
if errorlevel 1 goto failed
30+
31+
echo.
32+
echo Ready. You can now drop .SC2Replay files onto "Drop replays here.bat".
33+
echo.
34+
pause
35+
exit /b 0
36+
37+
:failed
38+
echo.
39+
echo Setup failed, but the details were saved to install-log.txt.
40+
echo Make sure Python 3.10 or newer is installed, then run this file again.
41+
echo.
42+
pause
43+
exit /b 1

README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
Decode StarCraft II replay files and print a readable build order for a selected player.
44

5+
## Easiest Use
6+
7+
For a normal Windows workflow:
8+
9+
1. Double-click `Install once.bat`.
10+
2. Drag one or more `.SC2Replay` files onto `Drop replays here.bat`.
11+
3. Open the `build order.txt` file created next to each replay.
12+
13+
The drop script writes both the readable build order and the SALT import string. The first time it sees a replay with multiple players, it asks which player to analyze and remembers that name for future replays. If it cannot ask, it writes one file per player so the user still gets a result. By default it extracts roughly the first 9 minutes, which is usually the useful practice window for a build order.
14+
515
## Setup
616

717
Recommended:
@@ -69,9 +79,15 @@ python -m sc2_replay_salt "C:\path\to\game.SC2Replay" --player 2
6979
python -m sc2_replay_salt "C:\path\to\game.SC2Replay" --player SomeName
7080
```
7181

72-
If a replay has multiple players and the terminal is interactive, the tool asks which player to analyze. In non-interactive usage it falls back to the first player.
82+
If a replay has multiple players and the terminal is interactive, the tool asks which player to analyze. The selected player is remembered in your user profile and reused when that player appears in future replays. In non-interactive usage it falls back to the remembered player when possible, otherwise the first player.
83+
84+
Print the readable table and SALT import string together:
85+
86+
```powershell
87+
python -m sc2_replay_salt "C:\path\to\game.SC2Replay" --player 2 --both
88+
```
7389

74-
Print a SALT import string instead of the table:
90+
Print only a SALT import string:
7591

7692
```powershell
7793
python -m sc2_replay_salt "C:\path\to\game.SC2Replay" --player 2 --salt
@@ -84,6 +100,7 @@ By default the table omits starting-state units, worker production, temporary sp
84100

85101
```powershell
86102
python -m sc2_replay_salt "C:\path\to\game.SC2Replay" --max-minutes 8
103+
python -m sc2_replay_salt "C:\path\to\game.SC2Replay" --full-game
87104
python -m sc2_replay_salt "C:\path\to\game.SC2Replay" --include-workers
88105
python -m sc2_replay_salt "C:\path\to\game.SC2Replay" --include-starting-state
89106
python -m sc2_replay_salt "C:\path\to\game.SC2Replay" --include-type-changes

src/sc2_replay_salt/cli.py

Lines changed: 133 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,68 +4,104 @@
44
import os
55
import sys
66
from pathlib import Path
7+
from typing import Sequence
78

89
from dotenv import load_dotenv
910

10-
from .build_order import BuildOrderOptions, format_build_order, player_refs, replay_paths, resolve_player
11+
from .build_order import BuildOrderItem, BuildOrderOptions, PlayerRef, format_build_order, player_refs, replay_paths, resolve_player
12+
from .defaults import load_defaults, save_defaults
1113
from .replaystats import compare_reference, fetch_reference, format_comparison
1214
from .salt import format_salt_encoding
1315
from .sc2reader_backend import build_order_for_replay, load_replay, replay_players, replay_time_scale
1416

1517

18+
DEFAULT_MAX_MINUTES = 9.0
19+
ALL_PLAYERS = object()
20+
21+
1622
def main(argv: list[str] | None = None) -> int:
1723
load_dotenv()
1824
parser = argparse.ArgumentParser(description="Print a readable build order from StarCraft II replay files.")
19-
parser.add_argument("path", type=Path, help="Replay file or directory containing .SC2Replay files.")
20-
parser.add_argument("--player", help="Player id or exact player name. Defaults to the first player.")
25+
parser.add_argument("paths", nargs="*", type=Path, help="Replay files or folders containing .SC2Replay files.")
26+
parser.add_argument("--player", help="Player id or exact player name. Defaults to the remembered player when possible.")
2127
parser.add_argument("--limit", type=int, help="Maximum number of replays to analyze from a directory.")
2228
parser.add_argument("--no-prompt", action="store_true", help="Do not ask for a player in interactive terminals.")
2329
parser.add_argument("--include-starting-state", action="store_true", help="Include units present at frame 0.")
2430
parser.add_argument("--include-type-changes", action="store_true", help="Include every unit type-change event.")
2531
parser.add_argument("--include-workers", action="store_true", help="Include worker production.")
26-
parser.add_argument("--max-minutes", type=float, help="Only print events up to this game minute.")
32+
parser.add_argument("--max-minutes", type=float, help=f"Only print events up to this game minute. Defaults to {DEFAULT_MAX_MINUTES:g}.")
33+
parser.add_argument("--full-game", action="store_true", help="Do not apply the default 9-minute build-order limit.")
2734
parser.add_argument("--salt", action="store_true", help="Print SALT encoding instead of a readable build order.")
35+
parser.add_argument("--both", action="store_true", help="Print readable build order and SALT encoding together.")
2836
parser.add_argument("--salt-title", help="Title to embed in SALT output. Defaults to the replay file name.")
37+
parser.add_argument("--easy", action="store_true", help="Friendly drag-and-drop mode: write build-order text files next to the replays.")
2938
parser.add_argument("--compare-replaystats", help="SC2ReplayStats replay URL to compare decoded state against.")
3039
parser.add_argument("--replaystats-team", type=int, default=0, help="SC2ReplayStats team slot to compare, zero-based.")
3140
args = parser.parse_args(argv)
3241

42+
if not args.paths:
43+
message = "Drop one or more .SC2Replay files onto 'Drop replays here.bat' or pass a replay path."
44+
print(message, file=sys.stderr if not args.easy else sys.stdout)
45+
return 2
46+
47+
defaults = load_defaults()
3348
try:
34-
paths = replay_paths(args.path)
35-
except FileNotFoundError:
36-
print(f"Path does not exist: {args.path}", file=sys.stderr)
49+
paths = _collect_replay_paths(args.paths)
50+
except FileNotFoundError as exc:
51+
print(f"Path does not exist: {exc}", file=sys.stderr)
3752
return 2
3853

3954
if args.limit is not None:
4055
paths = paths[: args.limit]
4156
if not paths:
42-
print(f"No .SC2Replay files found in {args.path}", file=sys.stderr)
57+
print("No .SC2Replay files found.", file=sys.stderr)
4358
return 2
4459

4560
exit_code = 0
61+
max_seconds = _max_seconds(args.max_minutes, args.full_game)
4662
options = BuildOrderOptions(
4763
include_starting_state=args.include_starting_state,
4864
include_type_changes=args.include_type_changes,
4965
include_workers=args.include_workers,
50-
max_seconds=args.max_minutes * 60 if args.max_minutes is not None else None,
66+
max_seconds=max_seconds,
5167
)
5268
for index, path in enumerate(paths):
53-
if index:
69+
if index and not args.easy:
5470
print("\n" + "=" * 72 + "\n")
5571
try:
56-
player_selector = args.player or _prompt_for_player(path, args.no_prompt)
72+
player_selector = args.player or _player_selector(
73+
path,
74+
args.no_prompt,
75+
defaults,
76+
all_on_no_prompt=args.easy,
77+
)
78+
if args.easy and player_selector is ALL_PLAYERS:
79+
_write_easy_outputs_for_all_players(path, options, args.salt_title, defaults)
80+
continue
81+
if player_selector is ALL_PLAYERS:
82+
player_selector = None
5783
replay, player, items = build_order_for_replay(path, player_selector, options)
5884
replay_name = str(getattr(replay, "filename", None) or path.name)
59-
if args.salt:
60-
print(format_salt_encoding(items, args.salt_title or Path(replay_name).stem))
85+
salt_title = args.salt_title or Path(replay_name).stem
86+
if args.easy or args.both:
87+
output = format_combined_output(replay_name, player, items, salt_title)
88+
elif args.salt:
89+
output = format_salt_encoding(items, salt_title)
6190
else:
62-
print(format_build_order(replay_name, player, items))
91+
output = format_build_order(replay_name, player, items)
92+
93+
_remember_player(defaults, player.name)
94+
if args.easy:
95+
output_path = _easy_output_path(path)
96+
output_path.write_text(output + "\n", encoding="utf-8")
97+
print(f"Wrote {output_path}")
98+
else:
99+
print(output)
63100
if args.compare_replaystats:
64101
reference = fetch_reference(
65102
args.compare_replaystats,
66103
session_id=os.getenv("SC2REPLAYSTATS_PHPSESSID"),
67104
)
68-
max_seconds = int(args.max_minutes * 60) if args.max_minutes is not None else None
69105
result = compare_reference(
70106
list(getattr(replay, "tracker_events", None) or []),
71107
player,
@@ -83,20 +119,99 @@ def main(argv: list[str] | None = None) -> int:
83119
return exit_code
84120

85121

86-
def _prompt_for_player(path: Path, no_prompt: bool) -> str | None:
87-
if no_prompt or not sys.stdin.isatty():
122+
def format_combined_output(
123+
replay_name: str,
124+
player: PlayerRef,
125+
items: Sequence[BuildOrderItem],
126+
salt_title: str,
127+
) -> str:
128+
return f"{format_build_order(replay_name, player, items)}\n\nSALT:\n{format_salt_encoding(items, salt_title)}"
129+
130+
131+
def _collect_replay_paths(paths: Sequence[Path]) -> list[Path]:
132+
replay_files: list[Path] = []
133+
for path in paths:
134+
replay_files.extend(replay_paths(path))
135+
return replay_files
136+
137+
138+
def _write_easy_outputs_for_all_players(
139+
path: Path,
140+
options: BuildOrderOptions,
141+
salt_title: str | None,
142+
defaults: dict[str, object],
143+
) -> None:
144+
replay = load_replay(path)
145+
refs = player_refs(replay_players(replay))
146+
for ref in refs:
147+
replay, player, items = build_order_for_replay(path, str(ref.pid), options)
148+
replay_name = str(getattr(replay, "filename", None) or path.name)
149+
output = format_combined_output(replay_name, player, items, salt_title or Path(replay_name).stem)
150+
output_path = _easy_output_path(path, player)
151+
output_path.write_text(output + "\n", encoding="utf-8")
152+
print(f"Wrote {output_path}")
153+
154+
155+
def _max_seconds(max_minutes: float | None, full_game: bool) -> float | None:
156+
if full_game:
88157
return None
158+
return (max_minutes if max_minutes is not None else DEFAULT_MAX_MINUTES) * 60
89159

160+
161+
def _player_selector(
162+
path: Path,
163+
no_prompt: bool,
164+
defaults: dict[str, object],
165+
all_on_no_prompt: bool = False,
166+
) -> str | object | None:
167+
remembered = _remembered_player(defaults)
90168
replay = load_replay(path)
91169
refs = player_refs(replay_players(replay))
170+
if remembered:
171+
for ref in refs:
172+
if ref.name.casefold() == remembered.casefold() or str(ref.pid) == remembered:
173+
return remembered
174+
175+
if no_prompt or not sys.stdin.isatty():
176+
if all_on_no_prompt and len(refs) > 1:
177+
return ALL_PLAYERS
178+
return None
179+
92180
if len(refs) <= 1:
93181
return None
94182

95183
print(f"{path.name} has multiple players:")
96184
for ref in refs:
97185
print(f" {ref.label}")
98-
choice = input("Analyze player id/name [default first]: ").strip()
186+
try:
187+
choice = input("Analyze player id/name [default first; remembered next time]: ").strip()
188+
except EOFError:
189+
return ALL_PLAYERS
99190
if not choice:
100191
return None
101192
resolve_player(refs, choice)
102193
return choice
194+
195+
196+
def _remembered_player(defaults: dict[str, object]) -> str | None:
197+
player = defaults.get("player")
198+
return player if isinstance(player, str) and player.strip() else None
199+
200+
201+
def _remember_player(defaults: dict[str, object], player: str) -> None:
202+
if defaults.get("player") == player:
203+
return
204+
defaults["player"] = player
205+
try:
206+
save_defaults(defaults)
207+
except OSError:
208+
pass
209+
210+
211+
def _easy_output_path(path: Path, player: PlayerRef | None = None) -> Path:
212+
player_suffix = f" - {_safe_filename_part(player.name)}" if player is not None else ""
213+
return path.with_name(f"{path.stem}{player_suffix} build order.txt")
214+
215+
216+
def _safe_filename_part(value: str) -> str:
217+
return "".join(character if character not in '<>:"/\\|?*' else "_" for character in value).strip() or "player"

src/sc2_replay_salt/defaults.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
from pathlib import Path
6+
from typing import Any
7+
8+
9+
CONFIG_ENV = "SC2_REPLAY_SALT_CONFIG"
10+
11+
12+
def config_path() -> Path:
13+
override = os.getenv(CONFIG_ENV)
14+
if override:
15+
return Path(override)
16+
17+
appdata = os.getenv("APPDATA")
18+
if appdata:
19+
return Path(appdata) / "sc2-replay-salt" / "defaults.json"
20+
return Path.home() / ".sc2-replay-salt" / "defaults.json"
21+
22+
23+
def load_defaults() -> dict[str, Any]:
24+
path = config_path()
25+
try:
26+
data = json.loads(path.read_text(encoding="utf-8"))
27+
except (FileNotFoundError, json.JSONDecodeError, OSError):
28+
return {}
29+
return data if isinstance(data, dict) else {}
30+
31+
32+
def save_defaults(defaults: dict[str, Any]) -> None:
33+
path = config_path()
34+
path.parent.mkdir(parents=True, exist_ok=True)
35+
path.write_text(json.dumps(defaults, indent=2, sort_keys=True), encoding="utf-8")

0 commit comments

Comments
 (0)