spacehaven-cheat-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spacehaven_cheat_engine/__init__.py +3 -0
- spacehaven_cheat_engine/__main__.py +6 -0
- spacehaven_cheat_engine/_version.py +83 -0
- spacehaven_cheat_engine/patcher.py +538 -0
- spacehaven_cheat_engine-0.1.0.dist-info/METADATA +198 -0
- spacehaven_cheat_engine-0.1.0.dist-info/RECORD +10 -0
- spacehaven_cheat_engine-0.1.0.dist-info/WHEEL +5 -0
- spacehaven_cheat_engine-0.1.0.dist-info/entry_points.txt +2 -0
- spacehaven_cheat_engine-0.1.0.dist-info/licenses/LICENSE +674 -0
- spacehaven_cheat_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Version information for spacehaven-cheat-engine.
|
|
3
|
+
|
|
4
|
+
This file is the canonical source for version numbers.
|
|
5
|
+
The __version__ string is automatically updated by git hooks
|
|
6
|
+
with build metadata (branch, build number, date, commit hash).
|
|
7
|
+
|
|
8
|
+
Format: MAJOR.MINOR.PATCH[-PHASE]_BRANCH_BUILD-YYYYMMDD-COMMITHASH
|
|
9
|
+
Example: 0.1.0_main_1-20260404-a1b2c3d4
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# Version components - edit these for version bumps
|
|
13
|
+
MAJOR = 0
|
|
14
|
+
MINOR = 1
|
|
15
|
+
PATCH = 0
|
|
16
|
+
PHASE = "" # Per-MINOR feature set: "" (stable), "alpha", "beta", "rc1", etc.
|
|
17
|
+
|
|
18
|
+
# Project-level phase (independent of version phase)
|
|
19
|
+
PROJECT_PHASE = "" # "", "alpha", "beta", "stable"
|
|
20
|
+
|
|
21
|
+
# Auto-updated by git hooks - do not edit manually
|
|
22
|
+
__version__ = "0.1.0_main_7-20260404-e8aa353a"
|
|
23
|
+
__app_name__ = "spacehaven-cheat-engine"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_version():
|
|
27
|
+
"""Return the full version string including branch and build info."""
|
|
28
|
+
return __version__
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_base_version():
|
|
32
|
+
"""Return the semantic version string (MAJOR.MINOR.PATCH[-PHASE])."""
|
|
33
|
+
if "_" in __version__:
|
|
34
|
+
return __version__.split("_")[0]
|
|
35
|
+
base = f"{MAJOR}.{MINOR}.{PATCH}"
|
|
36
|
+
if PHASE:
|
|
37
|
+
base = f"{base}-{PHASE}"
|
|
38
|
+
return base
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_display_version():
|
|
42
|
+
"""Return a human-readable version, including project phase if set."""
|
|
43
|
+
base = get_base_version()
|
|
44
|
+
if PROJECT_PHASE and PROJECT_PHASE != "stable":
|
|
45
|
+
return f"{base} [{PROJECT_PHASE.upper()}]"
|
|
46
|
+
return base
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_pip_version():
|
|
50
|
+
"""
|
|
51
|
+
Return PEP 440 compliant version for pip/setuptools.
|
|
52
|
+
|
|
53
|
+
Converts our version format to PEP 440:
|
|
54
|
+
- Main branch: 0.1.0_main_3-20260404-hash -> 0.1.0
|
|
55
|
+
- Dev branch: 0.1.0_dev_3-20260404-hash -> 0.1.0.dev3
|
|
56
|
+
- Alpha: 0.1.0-alpha_main_3 -> 0.1.0a0
|
|
57
|
+
"""
|
|
58
|
+
base = f"{MAJOR}.{MINOR}.{PATCH}"
|
|
59
|
+
|
|
60
|
+
# Map phase to PEP 440 pre-release segment
|
|
61
|
+
phase_map = {"alpha": "a0", "beta": "b0"}
|
|
62
|
+
if PHASE:
|
|
63
|
+
base += phase_map.get(PHASE, PHASE)
|
|
64
|
+
|
|
65
|
+
if "_" not in __version__:
|
|
66
|
+
return base
|
|
67
|
+
|
|
68
|
+
parts = __version__.split("_")
|
|
69
|
+
branch = parts[1] if len(parts) > 1 else "unknown"
|
|
70
|
+
|
|
71
|
+
if branch == "main":
|
|
72
|
+
return base
|
|
73
|
+
else:
|
|
74
|
+
build_info = "_".join(parts[2:]) if len(parts) > 2 else ""
|
|
75
|
+
build_num = build_info.split("-")[0] if "-" in build_info else "0"
|
|
76
|
+
return f"{base}.dev{build_num}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# For convenience in imports
|
|
80
|
+
VERSION = get_version()
|
|
81
|
+
BASE_VERSION = get_base_version()
|
|
82
|
+
PIP_VERSION = get_pip_version()
|
|
83
|
+
DISPLAY_VERSION = get_display_version()
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Space Haven - Max Character Points Patcher
|
|
3
|
+
Removes all skill/attribute point caps from character creation so players
|
|
4
|
+
can fully customize their crew using the game's built-in editor UI.
|
|
5
|
+
|
|
6
|
+
Pattern-based: finds methods by name in the Java class constant pool,
|
|
7
|
+
so it survives game updates as long as method names don't change.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python spacehaven_patcher.py # auto-detect game path, toggle
|
|
11
|
+
python spacehaven_patcher.py --enable # apply patches
|
|
12
|
+
python spacehaven_patcher.py --disable # restore original
|
|
13
|
+
python spacehaven_patcher.py --status # check current state
|
|
14
|
+
python spacehaven_patcher.py --path "D:\\..." # specify game path
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import struct
|
|
22
|
+
import sys
|
|
23
|
+
import zipfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_version():
|
|
28
|
+
try:
|
|
29
|
+
from spacehaven_cheat_engine._version import __version__
|
|
30
|
+
return __version__
|
|
31
|
+
except ImportError:
|
|
32
|
+
return "unknown"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
STEAM_COMMON_PATHS = [
|
|
36
|
+
Path("C:/Program Files (x86)/Steam/steamapps/common/SpaceHaven"),
|
|
37
|
+
Path("C:/Program Files/Steam/steamapps/common/SpaceHaven"),
|
|
38
|
+
Path("D:/SteamLibrary/steamapps/common/SpaceHaven"),
|
|
39
|
+
Path("E:/SteamLibrary/steamapps/common/SpaceHaven"),
|
|
40
|
+
Path("F:/SteamLibrary/steamapps/common/SpaceHaven"),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
SETTINGS_CLASS = "fi/bugbyte/spacehaven/SpaceHavenSettings.class"
|
|
44
|
+
CREWSTATE_CLASS = "fi/bugbyte/spacehaven/gui/menu/NewGameMenu$CrewState.class"
|
|
45
|
+
|
|
46
|
+
BACKUP_SUFFIX = ".cheatengine-backup"
|
|
47
|
+
|
|
48
|
+
# Method patches: (method_name, return_type, patch_bytes, description)
|
|
49
|
+
# return_type: 'V' = void, 'Z' = boolean, 'I' = int
|
|
50
|
+
# For void: B1 = return
|
|
51
|
+
# For boolean: 04 AC = iconst_1; ireturn (return true)
|
|
52
|
+
# For int: 10 7F AC = bipush 127; ireturn (return 127)
|
|
53
|
+
METHOD_PATCHES = [
|
|
54
|
+
("checkTooManyPoints", "V", bytes([0xB1]),
|
|
55
|
+
"checkTooManyPoints() -> no-op"),
|
|
56
|
+
("canAddPointToAttribute", "Z", bytes([0x04, 0xAC]),
|
|
57
|
+
"canAddPointToAttribute() -> always true"),
|
|
58
|
+
("canAddPointToSkill", "Z", bytes([0x04, 0xAC]),
|
|
59
|
+
"canAddPointToSkill() -> always true"),
|
|
60
|
+
("getFreeAttributePoints", "I", bytes([0x10, 0x7F, 0xAC]),
|
|
61
|
+
"getFreeAttributePoints() -> 127"),
|
|
62
|
+
("getFreeStartSkillPoints", "I", bytes([0x10, 0x7F, 0xAC]),
|
|
63
|
+
"getFreeStartSkillPoints() -> 127"),
|
|
64
|
+
("getFreeBaseSkillPoints", "I", bytes([0x10, 0x7F, 0xAC]),
|
|
65
|
+
"getFreeBaseSkillPoints() -> 127"),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Java class-file parser (minimal)
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def parse_constant_pool(data):
|
|
74
|
+
"""Parse a Java class file's constant pool. Returns (cp_list, end_offset)."""
|
|
75
|
+
cp_count = struct.unpack(">H", data[8:10])[0]
|
|
76
|
+
cp = [None] # 1-indexed
|
|
77
|
+
pos = 10
|
|
78
|
+
i = 1
|
|
79
|
+
while i < cp_count:
|
|
80
|
+
tag = data[pos]; pos += 1
|
|
81
|
+
if tag == 1: # UTF8
|
|
82
|
+
length = struct.unpack(">H", data[pos:pos+2])[0]; pos += 2
|
|
83
|
+
cp.append(("UTF8", data[pos:pos+length].decode("utf-8", "replace")))
|
|
84
|
+
pos += length
|
|
85
|
+
elif tag == 3: cp.append(("Integer",)); pos += 4
|
|
86
|
+
elif tag == 4: cp.append(("Float",)); pos += 4
|
|
87
|
+
elif tag == 5: cp.append(("Long",)); pos += 8; cp.append(None); i += 1
|
|
88
|
+
elif tag == 6: cp.append(("Double",)); pos += 8; cp.append(None); i += 1
|
|
89
|
+
elif tag == 7: cp.append(("Class", struct.unpack(">H", data[pos:pos+2])[0])); pos += 2
|
|
90
|
+
elif tag == 8: cp.append(("String",)); pos += 2
|
|
91
|
+
elif tag == 9: cp.append(("Fieldref", struct.unpack(">HH", data[pos:pos+4]))); pos += 4
|
|
92
|
+
elif tag == 10: cp.append(("Methodref",)); pos += 4
|
|
93
|
+
elif tag == 11: cp.append(("InterfaceMethodref",)); pos += 4
|
|
94
|
+
elif tag == 12: cp.append(("NameAndType", struct.unpack(">HH", data[pos:pos+4]))); pos += 4
|
|
95
|
+
elif tag == 15: cp.append(("MethodHandle",)); pos += 3
|
|
96
|
+
elif tag == 16: cp.append(("MethodType",)); pos += 2
|
|
97
|
+
elif tag == 18: cp.append(("InvokeDynamic",)); pos += 4
|
|
98
|
+
else:
|
|
99
|
+
raise ValueError(f"Unknown CP tag {tag} at offset {pos-1}")
|
|
100
|
+
i += 1
|
|
101
|
+
return cp, pos
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def find_fieldref_by_name(cp, field_name):
|
|
105
|
+
"""Find the CP index of a Fieldref whose name matches field_name."""
|
|
106
|
+
name_indices = [i for i, e in enumerate(cp) if e and e[0] == "UTF8" and e[1] == field_name]
|
|
107
|
+
if not name_indices:
|
|
108
|
+
return None
|
|
109
|
+
for nat_idx, entry in enumerate(cp):
|
|
110
|
+
if entry and entry[0] == "NameAndType" and entry[1][0] in name_indices:
|
|
111
|
+
for fr_idx, fr_entry in enumerate(cp):
|
|
112
|
+
if fr_entry and fr_entry[0] == "Fieldref" and fr_entry[1][1] == nat_idx:
|
|
113
|
+
return fr_idx
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def find_method_code_offsets(data, cp, cp_end):
|
|
118
|
+
"""Parse the class file to find method code body offsets.
|
|
119
|
+
|
|
120
|
+
Returns dict: {method_name: (code_offset, code_length, descriptor)}
|
|
121
|
+
"""
|
|
122
|
+
p = cp_end
|
|
123
|
+
p += 6 # access_flags, this_class, super_class
|
|
124
|
+
iface_count = struct.unpack(">H", data[p:p+2])[0]; p += 2
|
|
125
|
+
p += iface_count * 2
|
|
126
|
+
|
|
127
|
+
# Skip fields
|
|
128
|
+
fields_count = struct.unpack(">H", data[p:p+2])[0]; p += 2
|
|
129
|
+
for _ in range(fields_count):
|
|
130
|
+
p += 6
|
|
131
|
+
attrs_count = struct.unpack(">H", data[p:p+2])[0]; p += 2
|
|
132
|
+
for _ in range(attrs_count):
|
|
133
|
+
p += 2
|
|
134
|
+
attr_len = struct.unpack(">I", data[p:p+4])[0]; p += 4
|
|
135
|
+
p += attr_len
|
|
136
|
+
|
|
137
|
+
# Parse methods
|
|
138
|
+
methods = {}
|
|
139
|
+
methods_count = struct.unpack(">H", data[p:p+2])[0]; p += 2
|
|
140
|
+
|
|
141
|
+
for _ in range(methods_count):
|
|
142
|
+
m_access, m_name_idx, m_desc_idx = struct.unpack(">HHH", data[p:p+6]); p += 6
|
|
143
|
+
m_name = cp[m_name_idx][1] if cp[m_name_idx] and cp[m_name_idx][0] == "UTF8" else ""
|
|
144
|
+
m_desc = cp[m_desc_idx][1] if cp[m_desc_idx] and cp[m_desc_idx][0] == "UTF8" else ""
|
|
145
|
+
|
|
146
|
+
attrs_count = struct.unpack(">H", data[p:p+2])[0]; p += 2
|
|
147
|
+
for _ in range(attrs_count):
|
|
148
|
+
attr_name_idx = struct.unpack(">H", data[p:p+2])[0]; p += 2
|
|
149
|
+
attr_len = struct.unpack(">I", data[p:p+4])[0]; p += 4
|
|
150
|
+
attr_name = cp[attr_name_idx][1] if cp[attr_name_idx] and cp[attr_name_idx][0] == "UTF8" else ""
|
|
151
|
+
|
|
152
|
+
if attr_name == "Code":
|
|
153
|
+
_max_stack, _max_locals, code_len = struct.unpack(">HHI", data[p:p+8])
|
|
154
|
+
code_offset = p + 8
|
|
155
|
+
methods[m_name] = (code_offset, code_len, m_desc)
|
|
156
|
+
|
|
157
|
+
p += attr_len
|
|
158
|
+
|
|
159
|
+
return methods
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Patch logic
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
class PatchResult:
|
|
167
|
+
def __init__(self, name, offset, original_bytes, patched_bytes):
|
|
168
|
+
self.name = name
|
|
169
|
+
self.offset = offset
|
|
170
|
+
self.original_bytes = original_bytes
|
|
171
|
+
self.patched_bytes = patched_bytes
|
|
172
|
+
self.is_applied = False
|
|
173
|
+
self.is_original = False
|
|
174
|
+
|
|
175
|
+
def check_state(self, data):
|
|
176
|
+
"""Check whether this patch is currently applied, original, or unknown."""
|
|
177
|
+
current = bytes(data[self.offset:self.offset + len(self.patched_bytes)])
|
|
178
|
+
self.is_applied = (current == self.patched_bytes)
|
|
179
|
+
self.is_original = (current == self.original_bytes)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def find_all_patches(jar_path):
|
|
183
|
+
"""Scan the JAR and return all patches with their current state."""
|
|
184
|
+
patches_by_class = {}
|
|
185
|
+
|
|
186
|
+
with zipfile.ZipFile(str(jar_path), "r") as zf:
|
|
187
|
+
# --- SpaceHavenSettings: maxTotalSkillLevel bipush patch ---
|
|
188
|
+
settings_data = bytearray(zf.read(SETTINGS_CLASS))
|
|
189
|
+
settings_cp, _ = parse_constant_pool(settings_data)
|
|
190
|
+
settings_patches = []
|
|
191
|
+
|
|
192
|
+
fr_idx = find_fieldref_by_name(settings_cp, "maxTotalSkillLevel")
|
|
193
|
+
if fr_idx is not None:
|
|
194
|
+
hi = (fr_idx >> 8) & 0xFF
|
|
195
|
+
lo = fr_idx & 0xFF
|
|
196
|
+
for i in range(len(settings_data) - 3):
|
|
197
|
+
if settings_data[i] == 0xB3 and settings_data[i+1] == hi and settings_data[i+2] == lo:
|
|
198
|
+
if i >= 2 and settings_data[i-2] == 0x10:
|
|
199
|
+
p = PatchResult(
|
|
200
|
+
name="maxTotalSkillLevel (bipush)",
|
|
201
|
+
offset=i-1,
|
|
202
|
+
original_bytes=bytes([10]), # bipush 10
|
|
203
|
+
patched_bytes=bytes([127]), # bipush 127
|
|
204
|
+
)
|
|
205
|
+
p.check_state(settings_data)
|
|
206
|
+
settings_patches.append(p)
|
|
207
|
+
|
|
208
|
+
patches_by_class[SETTINGS_CLASS] = settings_patches
|
|
209
|
+
|
|
210
|
+
# --- NewGameMenu$CrewState: method body patches ---
|
|
211
|
+
crew_data = bytearray(zf.read(CREWSTATE_CLASS))
|
|
212
|
+
crew_cp, crew_cp_end = parse_constant_pool(crew_data)
|
|
213
|
+
crew_methods = find_method_code_offsets(crew_data, crew_cp, crew_cp_end)
|
|
214
|
+
crew_patches = []
|
|
215
|
+
|
|
216
|
+
for method_name, ret_type, patch_bytes, description in METHOD_PATCHES:
|
|
217
|
+
if method_name not in crew_methods:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
code_offset, code_len, descriptor = crew_methods[method_name]
|
|
221
|
+
# Verify return type matches
|
|
222
|
+
if not descriptor.endswith(ret_type):
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
original_bytes = bytes(crew_data[code_offset:code_offset + len(patch_bytes)])
|
|
226
|
+
p = PatchResult(
|
|
227
|
+
name=description,
|
|
228
|
+
offset=code_offset,
|
|
229
|
+
original_bytes=original_bytes,
|
|
230
|
+
patched_bytes=patch_bytes,
|
|
231
|
+
)
|
|
232
|
+
p.check_state(crew_data)
|
|
233
|
+
crew_patches.append(p)
|
|
234
|
+
|
|
235
|
+
patches_by_class[CREWSTATE_CLASS] = crew_patches
|
|
236
|
+
|
|
237
|
+
return patches_by_class
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def patch_config_json(game_path, enable=True):
|
|
241
|
+
"""Add or remove -noverify from config.json vmArgs.
|
|
242
|
+
|
|
243
|
+
Required because our method-body patches leave dead code that
|
|
244
|
+
trips Java's StackMapTable bytecode verifier. The game uses
|
|
245
|
+
Java 8 where -noverify is fully supported.
|
|
246
|
+
"""
|
|
247
|
+
config_path = game_path / "config.json"
|
|
248
|
+
if not config_path.exists():
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
config_backup = Path(str(config_path) + BACKUP_SUFFIX)
|
|
252
|
+
|
|
253
|
+
with open(str(config_path), "r") as f:
|
|
254
|
+
config = json.load(f)
|
|
255
|
+
|
|
256
|
+
vm_args = config.get("vmArgs", [])
|
|
257
|
+
# Both flags needed: some launchers recognize one but not the other
|
|
258
|
+
verify_flags = ["-noverify", "-Xverify:none"]
|
|
259
|
+
has_flags = any(f in vm_args for f in verify_flags)
|
|
260
|
+
|
|
261
|
+
if enable and not has_flags:
|
|
262
|
+
if not config_backup.exists():
|
|
263
|
+
shutil.copy2(str(config_path), str(config_backup))
|
|
264
|
+
for flag in verify_flags:
|
|
265
|
+
if flag not in vm_args:
|
|
266
|
+
vm_args.append(flag)
|
|
267
|
+
config["vmArgs"] = vm_args
|
|
268
|
+
with open(str(config_path), "w") as f:
|
|
269
|
+
json.dump(config, f, indent=2)
|
|
270
|
+
print(" config.json: added verification bypass flags")
|
|
271
|
+
elif not enable and has_flags:
|
|
272
|
+
if config_backup.exists():
|
|
273
|
+
shutil.copy2(str(config_backup), str(config_path))
|
|
274
|
+
print(" config.json: restored from backup")
|
|
275
|
+
else:
|
|
276
|
+
for flag in verify_flags:
|
|
277
|
+
if flag in vm_args:
|
|
278
|
+
vm_args.remove(flag)
|
|
279
|
+
config["vmArgs"] = vm_args
|
|
280
|
+
with open(str(config_path), "w") as f:
|
|
281
|
+
json.dump(config, f, indent=2)
|
|
282
|
+
print(" config.json: removed verification bypass flags")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def apply_patches(jar_path, enable=True):
|
|
286
|
+
"""Apply or revert all patches in the JAR."""
|
|
287
|
+
backup_path = Path(str(jar_path) + BACKUP_SUFFIX)
|
|
288
|
+
game_path = jar_path.parent
|
|
289
|
+
|
|
290
|
+
# For disable: always restore from backup (clean revert)
|
|
291
|
+
if not enable:
|
|
292
|
+
patch_config_json(game_path, enable=False)
|
|
293
|
+
if backup_path.exists():
|
|
294
|
+
os.remove(str(jar_path))
|
|
295
|
+
shutil.copy2(str(backup_path), str(jar_path))
|
|
296
|
+
print(" Restored original JAR from backup.")
|
|
297
|
+
return True
|
|
298
|
+
else:
|
|
299
|
+
print(" ERROR: No backup found. Cannot revert.")
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
# For enable: create backup if needed
|
|
303
|
+
if not backup_path.exists():
|
|
304
|
+
shutil.copy2(str(jar_path), str(backup_path))
|
|
305
|
+
print(f" Backup saved: {backup_path.name}")
|
|
306
|
+
|
|
307
|
+
patches_by_class = find_all_patches(jar_path)
|
|
308
|
+
|
|
309
|
+
# Read class data
|
|
310
|
+
class_data = {}
|
|
311
|
+
with zipfile.ZipFile(str(jar_path), "r") as zf:
|
|
312
|
+
for class_name in patches_by_class:
|
|
313
|
+
class_data[class_name] = bytearray(zf.read(class_name))
|
|
314
|
+
|
|
315
|
+
# Apply patches
|
|
316
|
+
total_patched = 0
|
|
317
|
+
for class_name, patches in patches_by_class.items():
|
|
318
|
+
for p in patches:
|
|
319
|
+
if p.is_applied:
|
|
320
|
+
print(f" already set: {p.name}")
|
|
321
|
+
else:
|
|
322
|
+
for j, byte_val in enumerate(p.patched_bytes):
|
|
323
|
+
class_data[class_name][p.offset + j] = byte_val
|
|
324
|
+
total_patched += 1
|
|
325
|
+
print(f" patched: {p.name}")
|
|
326
|
+
|
|
327
|
+
if total_patched == 0:
|
|
328
|
+
print(" Nothing to change -- already patched.")
|
|
329
|
+
return True
|
|
330
|
+
|
|
331
|
+
# Write modified JAR (skip duplicate entries in original)
|
|
332
|
+
temp_path = Path(str(jar_path) + ".tmp")
|
|
333
|
+
seen = set()
|
|
334
|
+
with zipfile.ZipFile(str(jar_path), "r") as zin:
|
|
335
|
+
with zipfile.ZipFile(str(temp_path), "w", zipfile.ZIP_DEFLATED) as zout:
|
|
336
|
+
for item in zin.infolist():
|
|
337
|
+
if item.filename in seen:
|
|
338
|
+
continue
|
|
339
|
+
seen.add(item.filename)
|
|
340
|
+
if item.filename in class_data:
|
|
341
|
+
zout.writestr(item, bytes(class_data[item.filename]))
|
|
342
|
+
else:
|
|
343
|
+
zout.writestr(item, zin.read(item.filename))
|
|
344
|
+
|
|
345
|
+
os.remove(str(jar_path))
|
|
346
|
+
shutil.move(str(temp_path), str(jar_path))
|
|
347
|
+
|
|
348
|
+
# Add -noverify to JVM args (method-body patches need this)
|
|
349
|
+
patch_config_json(game_path, enable=True)
|
|
350
|
+
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
# Status display
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
def print_status(jar_path):
|
|
359
|
+
"""Print the current patch status."""
|
|
360
|
+
backup_path = Path(str(jar_path) + BACKUP_SUFFIX)
|
|
361
|
+
|
|
362
|
+
if backup_path.exists():
|
|
363
|
+
with open(str(jar_path), "rb") as f:
|
|
364
|
+
current = f.read()
|
|
365
|
+
with open(str(backup_path), "rb") as f:
|
|
366
|
+
original = f.read()
|
|
367
|
+
|
|
368
|
+
if current == original:
|
|
369
|
+
print(" Status: Normal (unpatched)")
|
|
370
|
+
else:
|
|
371
|
+
patches_by_class = find_all_patches(jar_path)
|
|
372
|
+
applied = sum(1 for ps in patches_by_class.values() for p in ps if p.is_applied)
|
|
373
|
+
total = sum(len(ps) for ps in patches_by_class.values())
|
|
374
|
+
print(f" Patches: {applied}/{total} active")
|
|
375
|
+
for class_name, patches in patches_by_class.items():
|
|
376
|
+
short = class_name.split("/")[-1]
|
|
377
|
+
for p in patches:
|
|
378
|
+
state = "[ON] " if p.is_applied else "[off]"
|
|
379
|
+
print(f" {state} {p.name} (in {short})")
|
|
380
|
+
print()
|
|
381
|
+
print(" Status: CHEAT MODE ENABLED")
|
|
382
|
+
print(f" Backup: {backup_path.name}")
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
# No backup
|
|
386
|
+
patches_by_class = find_all_patches(jar_path)
|
|
387
|
+
any_applied = any(p.is_applied for ps in patches_by_class.values() for p in ps)
|
|
388
|
+
if any_applied:
|
|
389
|
+
print(" Status: Likely PATCHED (no backup for comparison)")
|
|
390
|
+
else:
|
|
391
|
+
print(" Status: Normal (unpatched)")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
# Game path detection
|
|
396
|
+
# ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
def find_game_path():
|
|
399
|
+
"""Try to auto-detect Space Haven installation."""
|
|
400
|
+
for path in STEAM_COMMON_PATHS:
|
|
401
|
+
if (path / "spacehaven.jar").exists():
|
|
402
|
+
return path
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def check_jar_locked(jar_path):
|
|
407
|
+
"""Check if the JAR file can be written to."""
|
|
408
|
+
try:
|
|
409
|
+
with open(jar_path, "r+b"):
|
|
410
|
+
pass
|
|
411
|
+
return False
|
|
412
|
+
except (PermissionError, OSError):
|
|
413
|
+
return True
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
# CLI
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
def main():
|
|
421
|
+
parser = argparse.ArgumentParser(
|
|
422
|
+
description="Space Haven - Max Character Points Patcher",
|
|
423
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
424
|
+
epilog=(
|
|
425
|
+
"Removes all point allocation caps from character creation.\n"
|
|
426
|
+
"The game's built-in editor UI works normally -- this just\n"
|
|
427
|
+
"removes the artificial ceiling on attributes and skills.\n\n"
|
|
428
|
+
"Patches 7 locations across 2 class files:\n"
|
|
429
|
+
" - maxTotalSkillLevel raised from 10 to 127\n"
|
|
430
|
+
" - checkTooManyPoints() disabled (no-op)\n"
|
|
431
|
+
" - canAddPointToAttribute() always returns true\n"
|
|
432
|
+
" - canAddPointToSkill() always returns true\n"
|
|
433
|
+
" - getFreeAttributePoints() returns 127\n"
|
|
434
|
+
" - getFreeStartSkillPoints() returns 127\n"
|
|
435
|
+
" - getFreeBaseSkillPoints() returns 127"
|
|
436
|
+
),
|
|
437
|
+
)
|
|
438
|
+
parser.add_argument("-V", "--version", action="version",
|
|
439
|
+
version=f"%(prog)s {_get_version()}")
|
|
440
|
+
parser.add_argument("-p", "--path", help="Path to Space Haven installation folder")
|
|
441
|
+
group = parser.add_mutually_exclusive_group()
|
|
442
|
+
group.add_argument("-e", "--enable", action="store_true", help="Apply patches (cheat mode)")
|
|
443
|
+
group.add_argument("-d", "--disable", action="store_true", help="Revert patches (normal mode)")
|
|
444
|
+
group.add_argument("-s", "--status", action="store_true", help="Check current patch state")
|
|
445
|
+
|
|
446
|
+
args = parser.parse_args()
|
|
447
|
+
|
|
448
|
+
print(r"""
|
|
449
|
+
___ _ _ ___ _ _
|
|
450
|
+
/ __|_ __ ___ __ __ | || | ___ _ _ __ __ / __| |_ ___ ___ | |_
|
|
451
|
+
\__ \ '_ / o \/ _/ _)| -- |/ o \ \/ / _)| \ | (__| ' \/ -_/ o \| _|
|
|
452
|
+
|___/ .__\__,_\__\__,|_||_|\__,_\__/\__,|_|_| \___|_||_\___\__,_|\__|
|
|
453
|
+
|_| Unlock Your Crew's Full Potential
|
|
454
|
+
""")
|
|
455
|
+
|
|
456
|
+
# Find game
|
|
457
|
+
game_path = Path(args.path) if args.path else find_game_path()
|
|
458
|
+
|
|
459
|
+
if game_path is None or not game_path.exists():
|
|
460
|
+
print("ERROR: Could not find Space Haven installation.")
|
|
461
|
+
print("Use --path to specify the game folder.")
|
|
462
|
+
print('Example: python spacehaven_patcher.py --path "D:\\Steam\\...\\SpaceHaven"')
|
|
463
|
+
return 1
|
|
464
|
+
|
|
465
|
+
jar_path = game_path / "spacehaven.jar"
|
|
466
|
+
if not jar_path.exists():
|
|
467
|
+
print(f"ERROR: spacehaven.jar not found in {game_path}")
|
|
468
|
+
return 1
|
|
469
|
+
|
|
470
|
+
print(f" Game: {game_path}")
|
|
471
|
+
print()
|
|
472
|
+
|
|
473
|
+
# Check if locked
|
|
474
|
+
if not args.status and check_jar_locked(jar_path):
|
|
475
|
+
print("ERROR: spacehaven.jar is locked.")
|
|
476
|
+
print("Close Space Haven and Steam before patching.")
|
|
477
|
+
return 1
|
|
478
|
+
|
|
479
|
+
# Status
|
|
480
|
+
if args.status:
|
|
481
|
+
print_status(jar_path)
|
|
482
|
+
return 0
|
|
483
|
+
|
|
484
|
+
# Explicit enable/disable
|
|
485
|
+
if args.enable:
|
|
486
|
+
print(" Applying patches...")
|
|
487
|
+
print()
|
|
488
|
+
if apply_patches(jar_path, enable=True):
|
|
489
|
+
print()
|
|
490
|
+
print(" Cheat mode enabled! Point caps removed.")
|
|
491
|
+
print(" Launch the game and create your dream crew.")
|
|
492
|
+
print()
|
|
493
|
+
print(" TIP: Run 'spacehaven-cheat --disable' after creating your")
|
|
494
|
+
print(" characters to restore clean game files for Steam updates.")
|
|
495
|
+
return 0
|
|
496
|
+
|
|
497
|
+
if args.disable:
|
|
498
|
+
print(" Reverting patches...")
|
|
499
|
+
print()
|
|
500
|
+
if apply_patches(jar_path, enable=False):
|
|
501
|
+
print()
|
|
502
|
+
print(" Normal mode restored.")
|
|
503
|
+
return 0
|
|
504
|
+
|
|
505
|
+
# Toggle mode (no args)
|
|
506
|
+
backup_path = Path(str(jar_path) + BACKUP_SUFFIX)
|
|
507
|
+
if backup_path.exists():
|
|
508
|
+
with open(str(jar_path), "rb") as f:
|
|
509
|
+
current = f.read()
|
|
510
|
+
with open(str(backup_path), "rb") as f:
|
|
511
|
+
original = f.read()
|
|
512
|
+
is_patched = current != original
|
|
513
|
+
else:
|
|
514
|
+
patches = find_all_patches(jar_path)
|
|
515
|
+
is_patched = any(p.is_applied for ps in patches.values() for p in ps)
|
|
516
|
+
|
|
517
|
+
if is_patched:
|
|
518
|
+
print(" Currently PATCHED. Reverting to normal...")
|
|
519
|
+
print()
|
|
520
|
+
if apply_patches(jar_path, enable=False):
|
|
521
|
+
print()
|
|
522
|
+
print(" Normal mode restored.")
|
|
523
|
+
else:
|
|
524
|
+
print(" Currently UNPATCHED. Applying cheat mode...")
|
|
525
|
+
print()
|
|
526
|
+
if apply_patches(jar_path, enable=True):
|
|
527
|
+
print()
|
|
528
|
+
print(" Cheat mode enabled! Point caps removed.")
|
|
529
|
+
print(" Launch the game and create your dream crew.")
|
|
530
|
+
print()
|
|
531
|
+
print(" TIP: Run 'spacehaven-cheat --disable' after creating your")
|
|
532
|
+
print(" characters to restore clean game files for Steam updates.")
|
|
533
|
+
|
|
534
|
+
return 0
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
if __name__ == "__main__":
|
|
538
|
+
sys.exit(main() or 0)
|