Skip to content

Commit 4ff20d3

Browse files
authored
multiple improvements
macOS support (untested) made program automation-ready
1 parent adfbb4b commit 4ff20d3

7 files changed

Lines changed: 464 additions & 63 deletions

File tree

README.md

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,39 @@ Download from the [releases page](https://github.com/Cat-Ling/unren) for your pl
2323
## Usage
2424

2525
### Interactive Mode
26+
Run without arguments to launch the interactive menu:
2627
```bash
27-
# From game's root directory
2828
./unren-go
29-
30-
# Or specify directory
31-
./unren-go --dir /path/to/game
3229
```
3330

34-
### Command Line
31+
If the game files aren't found, you'll see a **Recovery Menu** allowing you to browse directories interactively to locate the game.
32+
33+
### Automation & CLI Mode
34+
Run with flags to perform actions immediately (no menu):
3535
```bash
36-
./unren-go --no-menu # Exit after single operation
36+
# Extract RPA and Decompile RPYC
37+
./unren-go -e -d /path/to/game
38+
39+
# Perform ALL actions (Extract + Decompile + Apply all patches)
40+
./unren-go --all /path/to/game
41+
42+
# Enable specific features
43+
./unren-go --console --skip /path/to/game
3744
```
3845

46+
### Options
47+
| Flag | Short | Description |
48+
|------|-------|-------------|
49+
| `--extract` | `-e` | Extract RPA packages |
50+
| `--decompile` | `-d` | Decompile RPYC files |
51+
| `--all` | `-a` | Perform all actions |
52+
| `--console` | | Enable Developer Console/Menu |
53+
| `--quicksave` | | Enable Quick Save/Load |
54+
| `--skip` | | Force enable skipping unseen content |
55+
| `--rollback` | | Force enable infinite rollback |
56+
| `--help` | `-h` | Show help and valid usages |
57+
| `--version` | `-v` | Show version info |
58+
3959
## License
4060

4161
MIT License

detector/detector.go

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package detector
22

33
import (
4+
"fmt"
45
"os"
56
"path/filepath"
67
"strings"
@@ -10,6 +11,8 @@ import (
1011

1112
// GameInfo contains detected information about a Ren'Py game
1213
type GameInfo struct {
14+
// Name of the game (for display purposes)
15+
Name string
1316
// RootDir is the game's root directory (where the executable is)
1417
RootDir string
1518
// GameDir is the game's "game" subdirectory
@@ -22,6 +25,8 @@ type GameInfo struct {
2225
RPYCFiles []string
2326
// HasLib indicates if lib/ directory exists
2427
HasLib bool
28+
// LibDir is the path to the lib/ directory
29+
LibDir string
2530
// HasRenPy indicates if renpy/ directory exists
2631
HasRenPy bool
2732
}
@@ -36,6 +41,16 @@ func DetectGame(dir string) (*GameInfo, error) {
3641

3742
info := &GameInfo{}
3843

44+
// Check for macOS App Bundle structure
45+
// Standard Ren'Py Mac apps: MyApp.app/Contents/Resources/autorun/game
46+
macAutorun := filepath.Join(absDir, "Contents", "Resources", "autorun")
47+
if utils.DirExists(macAutorun) {
48+
// Capture the .app name before switching context
49+
info.Name = filepath.Base(absDir)
50+
// Switch context to the autorun folder which acts as the game root
51+
absDir = macAutorun
52+
}
53+
3954
// Check if we're in the game/ subdirectory or the root
4055
if filepath.Base(absDir) == "game" {
4156
// We're in the game/ directory, parent is root
@@ -48,15 +63,34 @@ func DetectGame(dir string) (*GameInfo, error) {
4863
} else {
4964
// Try to find game/ in current directory
5065
// Maybe we're in some other subdirectory
66+
fmt.Printf("Detection: Failed to find game directory in %s\n", absDir)
5167
return nil, &GameNotFoundError{Dir: absDir}
5268
}
5369

70+
// If Name wasn't set by Mac detection, use the internal root dir name
71+
if info.Name == "" {
72+
info.Name = filepath.Base(info.RootDir)
73+
}
74+
5475
// Check for lib/ and renpy/ directories
55-
info.HasLib = utils.DirExists(filepath.Join(info.RootDir, "lib"))
76+
libDir := filepath.Join(info.RootDir, "lib")
77+
if utils.DirExists(libDir) {
78+
info.HasLib = true
79+
info.LibDir = libDir
80+
} else {
81+
// Fallback for macOS: lib/ might be in the parent directory (Contents/Resources/lib)
82+
// while root is Contents/Resources/autorun
83+
parentLib := filepath.Join(filepath.Dir(info.RootDir), "lib")
84+
if utils.DirExists(parentLib) {
85+
info.HasLib = true
86+
info.LibDir = parentLib
87+
}
88+
}
89+
5690
info.HasRenPy = utils.DirExists(filepath.Join(info.RootDir, "renpy"))
5791

5892
// Detect Ren'Py version
59-
info.RenPyVersion = detectRenPyVersion(info.RootDir)
93+
info.RenPyVersion = detectRenPyVersion(info)
6094

6195
// Find RPA files
6296
info.RPAFiles, _ = utils.FindFilesWithExtension(info.GameDir, ".rpa")
@@ -68,33 +102,28 @@ func DetectGame(dir string) (*GameInfo, error) {
68102
}
69103

70104
// detectRenPyVersion attempts to detect the Ren'Py version
71-
func detectRenPyVersion(rootDir string) int {
105+
func detectRenPyVersion(info *GameInfo) int {
72106
// Check for Python version indicators in lib/ directory
73-
libDir := filepath.Join(rootDir, "lib")
74-
if !utils.DirExists(libDir) {
75-
return 0
76-
}
77-
78-
// Walk lib directory looking for python version hints
79-
entries, err := os.ReadDir(libDir)
80-
if err != nil {
81-
return 0
82-
}
83-
84-
for _, entry := range entries {
85-
name := strings.ToLower(entry.Name())
86-
// Ren'Py 8 uses Python 3
87-
if strings.Contains(name, "py3") || strings.Contains(name, "python3") {
88-
return 8
89-
}
90-
// Ren'Py 7 and earlier use Python 2
91-
if strings.Contains(name, "py2") || strings.Contains(name, "python2") {
92-
return 7
107+
if info.HasLib {
108+
// Walk lib directory looking for python version hints
109+
entries, err := os.ReadDir(info.LibDir)
110+
if err == nil {
111+
for _, entry := range entries {
112+
name := strings.ToLower(entry.Name())
113+
// Ren'Py 8 uses Python 3
114+
if strings.Contains(name, "py3") || strings.Contains(name, "python3") {
115+
return 8
116+
}
117+
// Ren'Py 7 and earlier use Python 2
118+
if strings.Contains(name, "py2") || strings.Contains(name, "python2") {
119+
return 7
120+
}
121+
}
93122
}
94123
}
95124

96125
// Check for specific version files
97-
if utils.FileExists(filepath.Join(rootDir, "renpy", "__pycache__")) {
126+
if utils.FileExists(filepath.Join(info.RootDir, "renpy", "__pycache__")) {
98127
// __pycache__ indicates Python 3, so Ren'Py 8
99128
return 8
100129
}

files/python/rpatool.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,9 +382,12 @@ def save(self, filename = None):
382382
print('Could not open archive file {0} for reading: {1}'.format(archive, e), file=sys.stderr)
383383
sys.exit(1)
384384

385+
errors = 0
386+
385387
if arguments.create or arguments.append:
386388
# We need this seperate function to recursively process directories.
387389
def add_file(filename):
390+
nonlocal errors
388391
# If the archive path differs from the actual file path, as given in the argument,
389392
# extract the archive path and actual file path.
390393
if filename.find('=') != -1:
@@ -402,6 +405,7 @@ def add_file(filename):
402405
archive.add(outfile, file.read())
403406
except Exception as e:
404407
print('Could not add file {0} to archive: {1}'.format(filename, e), file=sys.stderr)
408+
errors += 1
405409

406410
# Iterate over the given files to add to archive.
407411
for filename in arguments.files:
@@ -413,20 +417,25 @@ def add_file(filename):
413417
archive.save(output)
414418
except Exception as e:
415419
print('Could not save archive file: {0}'.format(e), file=sys.stderr)
420+
errors += 1
421+
416422
elif arguments.delete:
417423
# Iterate over the given files to delete from the archive.
418424
for filename in arguments.files:
419425
try:
420426
archive.remove(filename)
421427
except Exception as e:
422428
print('Could not delete file {0} from archive: {1}'.format(filename, e), file=sys.stderr)
429+
errors += 1
423430

424431
# Set version for saving, and save.
425432
archive.version = version
426433
try:
427434
archive.save(output)
428435
except Exception as e:
429436
print('Could not save archive file: {0}'.format(e), file=sys.stderr)
437+
errors += 1
438+
430439
elif arguments.extract:
431440
# Either extract the given files, or all files if no files are given.
432441
if len(arguments.files) > 0:
@@ -456,6 +465,8 @@ def add_file(filename):
456465
file.write(contents)
457466
except Exception as e:
458467
print('Could not extract file {0} from archive: {1}'.format(filename, e), file=sys.stderr)
468+
errors += 1
469+
459470
elif arguments.list:
460471
# Print the sorted file list.
461472
list = archive.list()
@@ -466,3 +477,6 @@ def add_file(filename):
466477
print('No operation given :(')
467478
print('Use {0} --help for usage details.'.format(sys.argv[0]))
468479

480+
if errors > 0:
481+
sys.exit(1)
482+

files/python/rpatool_py2.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,12 +358,14 @@ def save(self, filename = None):
358358
# Normalize files.
359359
if len(arguments.files) > 0 and isinstance(arguments.files[0], list):
360360
arguments.files = arguments.files[0]
361-
361+
362362
try:
363363
archive = RenPyArchive(archive, padlength=padding, key=key, version=version, verbose=arguments.verbose)
364364
except IOError as e:
365365
print('Could not open archive file {0} for reading: {1}'.format(archive, e), file=sys.stderr)
366366
sys.exit(1)
367+
368+
errors = [0]
367369

368370
if arguments.create or arguments.append:
369371
# We need this seperate function to recursively process directories.
@@ -385,6 +387,7 @@ def add_file(filename):
385387
archive.add(outfile, file.read())
386388
except Exception as e:
387389
print('Could not add file {0} to archive: {1}'.format(filename, e), file=sys.stderr)
390+
errors[0] += 1
388391

389392
# Iterate over the given files to add to archive.
390393
for filename in arguments.files:
@@ -396,20 +399,25 @@ def add_file(filename):
396399
archive.save(output)
397400
except Exception as e:
398401
print('Could not save archive file: {0}'.format(e), file=sys.stderr)
402+
errors[0] += 1
403+
399404
elif arguments.delete:
400405
# Iterate over the given files to delete from the archive.
401406
for filename in arguments.files:
402407
try:
403408
archive.remove(filename)
404409
except Exception as e:
405410
print('Could not delete file {0} from archive: {1}'.format(filename, e), file=sys.stderr)
411+
errors[0] += 1
406412

407413
# Set version for saving, and save.
408414
archive.version = version
409415
try:
410416
archive.save(output)
411417
except Exception as e:
412418
print('Could not save archive file: {0}'.format(e), file=sys.stderr)
419+
errors[0] += 1
420+
413421
elif arguments.extract:
414422
# Either extract the given files, or all files if no files are given.
415423
if len(arguments.files) > 0:
@@ -439,6 +447,8 @@ def add_file(filename):
439447
file.write(contents)
440448
except Exception as e:
441449
print('Could not extract file {0} from archive: {1}'.format(filename, e), file=sys.stderr)
450+
errors[0] += 1
451+
442452
elif arguments.list:
443453
# Print the sorted file list.
444454
list = archive.list()
@@ -449,3 +459,6 @@ def add_file(filename):
449459
print('No operation given :(')
450460
print('Use {0} --help for usage details.'.format(sys.argv[0]))
451461

462+
if errors[0] > 0:
463+
sys.exit(1)
464+

gem.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Gems & Hacks
2+
3+
This document outlines the specific workarounds and "hacky" solutions implemented in `unren-go` to support complex features like cross-platform macOS detection and robust automation piping.
4+
5+
## 1. macOS `.app` Transparency (The Detector Hack)
6+
**Using macOS Game Files on Linux/Windows**
7+
8+
Ren'Py games on macOS are packaged as `.app` bundles, which are directories. The actual game assets (RPYC/RPA) live deep inside at `Contents/Resources/autorun/`.
9+
To make this transparent to the user (so they can just point at `Game.app`), we implemented a path redirection hack in `detector/detector.go`.
10+
11+
**Location:** `detector/detector.go` -> `DetectGame`
12+
**The Hack:**
13+
```go
14+
// Check for macOS App Bundle structure
15+
if strings.HasSuffix(absDir, ".app") || strings.Contains(absDir, ".app/") {
16+
// ...
17+
macAutorun := filepath.Join(absDir, "Contents", "Resources", "autorun")
18+
if utils.DirExists(macAutorun) {
19+
// Switch context to the autorun folder which acts as the game root
20+
absDir = macAutorun
21+
}
22+
}
23+
```
24+
We aggressively rewrite the `absDir` variable if we suspect a macOS bundle. This allows the rest of the detection logic (checking for `game/`) to work unchanged.
25+
26+
## 2. OS-Agnostic Python Detection
27+
**Finding Mac Binaries on Linux**
28+
29+
Normally, `runner.go` should verify the OS (`runtime.GOOS`) before deciding where to look for the Python interpreter. However, to support analyzing macOS games on Linux (detecting version, etc.), we removed the OS guard.
30+
31+
**Location:** `runner/runner.go` -> `findPython`
32+
**The Hack:**
33+
```go
34+
// Check for macOS App Bundle structure (Check unconditionally to support cross-OS inspection)
35+
macExePath := filepath.Join(filepath.Dir(filepath.Dir(libDir)), "MacOS", "python")
36+
if _, err := os.Stat(macExePath); err == nil {
37+
return macExePath, libDir, true, nil
38+
}
39+
```
40+
We check for the Mac binary *unconditionally*. While we can't *execute* this binary on Linux/Windows, detecting it allows the `GameInfo` struct to be populated correctly, letting the tool report the Ren'Py version and file counts even if it can't run the decompression.
41+
42+
## 3. Pipeline State Mutation
43+
**The `-e -d` Pipeline Fix**
44+
45+
When running `unren-go -e -d`, the tool extracts RPAs and then immediately tries to decompile RPYCs. The problem is that the `game` struct is populated *before* extraction, so the decompiler doesn't know about the files that were just created.
46+
47+
**Location:** `main.go` -> `handleExtractRPA`
48+
**The Hack:**
49+
```go
50+
// Refresh RPYC file list in case new files were extracted
51+
// This ensures that if decompilation runs after this, it sees the new files
52+
if found, err := utils.FindFilesWithExtension(game.GameDir, ".rpyc"); err == nil {
53+
game.RPYCFiles = found
54+
}
55+
```
56+
We actively mutate the `game.RPYCFiles` slice inside the extraction polling function. This couples the detection state with the extraction action, but it's the most efficient way to ensure the pipeline proceeds without a full re-detection loop.
57+
58+
## 4. Vendor Script Patching
59+
**Fixing `rpatool.py` Exit Codes**
60+
61+
The original `rpatool` library returns exit code `0` (Success) even if it encounters errors during batch extraction. This made implementing the `--clean` flag dangerous, as we rely on exit codes to know if it's safe to delete source files.
62+
63+
**Location:** `files/python/rpatool.py` (and `rpatool_py2.py`)
64+
**The Hack:**
65+
We injected error tracking logic into the upstream script:
66+
```python
67+
errors = 0
68+
# ... inside loops ...
69+
except Exception as e:
70+
errors += 1
71+
# ... at end of file ...
72+
if errors > 0:
73+
sys.exit(1)
74+
```
75+
Instead of wrapping the python execution in complex stderr parsing (which is brittle), we patched the "vendor" code directly to behave like a standard CLI tool. This ensures `unren-go` receives a clear signal (`exit status 1`) if *anything* goes wrong, protecting user data.

0 commit comments

Comments
 (0)