Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Release

on:
push:
tags:
- '*'
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to build and release'
required: true

permissions:
contents: write

jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}

- uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Build
run: ./build.ps1

- name: Draft release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
body: ""
draft: true
files: |
dist/hd2-repatcher.exe
dist/hd2-repatcher-cli.exe
21 changes: 21 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Test

on:
push:
pull_request:

jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install dependencies
run: pip install -e .[test]

- name: Run tests
run: pytest
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
__pycache__/
*.pyc
.venv/
*.egg-info/
.pytest_cache/

# PyInstaller build output
build/
dist/
*.spec
148 changes: 148 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# HD2 Repatcher

Repatches Helldivers II unit mods after a game update.

When Helldivers II updates, unit mod `.patch` files can go out of sync with the
game's unit data, causing them to fail to load. This tool scans a folder of
patch files, finds the ones containing unit resources, and updates those
resources in place using the current game data.

## Requirements

- A Helldivers II install (specifically its `data` folder)
- Windows: no extra requirements — use the prebuilt executable below
- Linux/macOS: no prebuilt executable is provided, so run from source with
Python 3.10+ (see below)

## Installation

### Option 1: Prebuilt executable (Windows)

Download `hd2-repatcher.exe` (GUI, no console) or `hd2-repatcher-cli.exe`
(console/CLI) from the [Releases](../../releases) page. No Python required.
These are built by CI (`.github/workflows/release.yml`) on `windows-latest`.

### Option 2: Run from source (Windows, Linux, macOS)

Clone the repo, then:

```powershell
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install -e .
```

(On Linux/macOS, activate with `source .venv/bin/activate` instead.)

### Why two builds?

`hd2-repatcher.exe` is built by PyInstaller in **windowed** mode: it has no
console and no stdout/stderr at all, so double-clicking it never pops up a
window beyond its own dialogs.

> **Caveat:** dragging a mod folder onto the windowed `hd2-repatcher.exe`
> still processes it CLI-style — but with no console and no dialogs, so it
> runs (or fails) with zero feedback. Use `hd2-repatcher-cli.exe` for
> drag-and-drop.

`hd2-repatcher-cli.exe` is built in **console** mode instead. Run from an
already-open terminal, it just prints to that terminal like any other console
program. But double-click it (or drag a folder onto it), and since there's no
terminal for it to attach to, Windows pops up a brand new console window to
show the result — and the tool waits for a keypress before exiting, so the
window doesn't vanish before you can read it.

If you don't need CLI usage or drag-and-drop feedback, download the regular
windowed `hd2-repatcher.exe` so it doesn't pop up a console at you.

## Usage

### GUI

Double-click `hd2-repatcher.exe`, or from a source install run:

```powershell
hd2-repatcher
```

You'll be prompted to select your Helldivers II `data` folder (once — it's
cached for future runs), then the folder containing the patch files you want
to fix.

### CLI

```powershell
hd2-repatcher-cli --game "C:\Program Files (x86)\Steam\steamapps\common\Helldivers 2\data" C:\path\to\mods\SomeMod
```

- `-g`/`--game PATH` — path to the Helldivers II `data` folder. Only needs to
be passed once; it's cached for future runs. Requires at least one
`PATCH_FOLDER` in the same invocation.
- `--no-game-path-caching` — don't save or overwrite the cached game data
path when `-g`/`--game` is given.
- `PATCH_FOLDER [PATCH_FOLDER ...]` — one or more folders containing patch
files to update.

A source install (`pip install -e .`) puts both `hd2-repatcher` and
`hd2-repatcher-cli` on your PATH; they're the same program, named to mirror
the two prebuilt executables.

Once the game data path is cached, you can also just drag and drop one or more
mod folders directly onto `hd2-repatcher-cli.exe` (or a shortcut to it) —
Windows passes the dropped folder(s) as arguments, so the tool processes them
immediately instead of prompting, with a console window showing the result
(see [Why two builds?](#why-two-builds) for why this only pops up for the CLI
build). The window stays open until you press Enter.

Once the game path is cached, you can omit `-g`:

```powershell
hd2-repatcher-cli C:\path\to\mods\SomeMod C:\path\to\mods\AnotherMod
```

Exit code is non-zero if any corrupted patch files were found.

If you're integrating this into a mod manager, always pass the game data path
explicitly via `-g`/`--game` on every invocation rather than relying on the
cache — the cache is a convenience for interactive/manual use, and a mod
manager shouldn't assume a previous run (by itself or another tool) already
set it.

## Settings

The game data path chosen via the GUI or `-g`/`--game` is cached in
`%LOCALAPPDATA%\hd2-repatcher\settings.json`. Delete that file to
reset it, or pass `-g`/`--game` again to overwrite it.

## Testing

Unit tests live in `tests/` and run in CI on every push and pull request
(`.github/workflows/test.yml`). To run them locally:

```powershell
pip install -e ".[test]"
pytest
```

There's no automated end-to-end test against real game files (the game data
isn't available in CI), so changes that touch the patching logic should be
verified manually against a mod that's actually broken by a game update:

1. Find a mod that's known to break after updates, e.g.
[Invisible supply pack](https://www.nexusmods.com/helldivers2/mods/7308?tab=files),
and download an **older** file version — recent-enough game updates should
have desynced it from current unit data.
2. Install it with a mod manager (or manually) and confirm in-game that it's
broken (fails to load / crashes / doesn't apply).
3. Run it through the repatcher (GUI, drag-and-drop, or CLI) and confirm it
reports the patch as updated.
4. Redeploy the mod and confirm it now loads correctly in-game.

## Building

```powershell
./build.ps1
```

Builds both `dist\hd2-repatcher.exe` (windowed) and
`dist\hd2-repatcher-cli.exe` (console) via PyInstaller.
20 changes: 20 additions & 0 deletions build.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Builds standalone executables into dist\ (gitignored).
# Usage: ./build.ps1
$ErrorActionPreference = "Stop"

if (-not (Test-Path .venv)) {
python -m venv .venv
}
. .venv\Scripts\Activate.ps1

pip install -e ".[build]" -q

# GUI build: no console window, for double-click use
pyinstaller --onefile --windowed --name hd2-repatcher --clean --specpath build cli.py

# CLI build: normal console app, for use from a terminal
pyinstaller --onefile --name hd2-repatcher-cli --clean --specpath build cli.py

Write-Host "Build complete:"
Write-Host " dist\hd2-repatcher.exe (GUI, double-click)"
Write-Host " dist\hd2-repatcher-cli.exe (CLI, run from a terminal)"
153 changes: 153 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import argparse
import os
import sys

if os.name == "nt":
import ctypes

from settings import get_cached_game_data_path, set_cached_game_data_path
from update_unit_mods import (
LEGACY_MARKER_FILE,
SLIM_MARKER_FILE,
PatchResult,
init_game_resources,
is_valid_game_data_path,
process_patch_folder,
)

def print_cli_result(directory: str, result: PatchResult):
print(f"\n{directory}")
if result.patches_found == 0:
print(" No patch files found.")
return
print(f" Checked {result.patches_found} patch file(s)")
print(f" Updated {len(result.updated)} patch file(s) containing unit resources")
if result.no_units:
print(f" Skipped {len(result.no_units)} patch file(s) with no unit resources")
if result.corrupted_files:
print(f" Found {len(result.corrupted_files)} corrupted patch file(s):", file=sys.stderr)
for name in result.corrupted_files:
print(f" {os.path.normpath(name)}", file=sys.stderr)

def _process_image_path(pid):
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return None
try:
buf = ctypes.create_unicode_buffer(260)
size = ctypes.c_uint32(260)
if ctypes.windll.kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)):
return buf.value
return None
finally:
ctypes.windll.kernel32.CloseHandle(handle)

def pause_if_owns_console():
'''
When the console build is launched by double-click or drag-and-drop,
Windows spawns a fresh console that closes the instant this process
exits, taking the output with it. If every other process attached to
the console is either us or (for the --onefile build) the bootloader
process that re-executed itself as us, hold the window open so the
result can be read. If a real shell is attached (run from an existing
terminal), or in the windowed build (which has no console at all),
this is a no-op.
'''
if os.name != "nt":
return
process_ids = (ctypes.c_uint32 * 8)()
count = ctypes.windll.kernel32.GetConsoleProcessList(process_ids, 8)
if count == 0 or count > 8:
return
our_pid = os.getpid()
our_path = os.path.normcase(sys.executable)
for pid in process_ids[:count]:
if pid == our_pid:
continue
image_path = _process_image_path(pid)
if image_path is None or os.path.normcase(image_path) != our_path:
return
if sys.stdin is None or not sys.stdin.isatty():
return
try:
input("\nPress Enter to exit...")
except EOFError:
pass

def exit_cli(code):
pause_if_owns_console()
sys.exit(code)

def run_cli(game_path, patch_dirs):
if game_path is None:
cached = get_cached_game_data_path()
if cached and is_valid_game_data_path(cached):
game_path = cached
else:
print("error: no game data directory configured; pass -g/--game <path>", file=sys.stderr)
exit_cli(1)

print(f"Loading game resources from: {game_path}")
init_game_resources(game_path)

exit_code = 0
for patch_dir in patch_dirs:
patch_dir = os.path.abspath(patch_dir)
if not os.path.isdir(patch_dir):
print(f"error: '{patch_dir}' is not a directory", file=sys.stderr)
exit_code = 1
continue
result = process_patch_folder(patch_dir)
print_cli_result(patch_dir, result)
if result.corrupted_files:
exit_code = 1
exit_cli(exit_code)

def parse_args():
parser = argparse.ArgumentParser(description="Update unit resources in Helldivers II patch files.")
parser.add_argument("-g", "--game", metavar="PATH",
help="path to the Helldivers II game data folder; also cached for future runs")
parser.add_argument("--no-game-path-caching", action="store_true",
help="do not save or overwrite the cached game data path")
parser.add_argument("patches", nargs="*", metavar="PATCH_FOLDER",
help="folder(s) containing patch files to update")
args = parser.parse_args()
if args.game and not args.patches:
parser.error("at least one PATCH_FOLDER is required with -g/--game")
return args

def setup_console_io():
'''
The windowed build has no console, so sys.stdout/stderr are None; guard
stray print() calls from crashing it. The console build already has real
stdio and this is a no-op there.
'''
if sys.stdout is None:
sys.stdout = open(os.devnull, "w")
if sys.stderr is None:
sys.stderr = open(os.devnull, "w")

def main():
setup_console_io()
args = parse_args()

game_path = None
if args.game:
game_path = os.path.abspath(args.game)
if not is_valid_game_data_path(game_path):
print(f"error: '{args.game}' does not look like a Helldivers II data folder "
f"(expected to find `{LEGACY_MARKER_FILE}` or `{SLIM_MARKER_FILE}` inside it)", file=sys.stderr)
exit_cli(1)
if not args.no_game_path_caching:
set_cached_game_data_path(game_path)
print(f"Game data directory set to: {game_path}")

if args.patches:
run_cli(game_path, args.patches)
else:
from gui import run_gui
run_gui()

if __name__ == "__main__":
main()
Loading