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.
@@ -0,0 +1,3 @@
1
+ """Space Haven Cheat Engine - Remove character creation point caps."""
2
+
3
+ from spacehaven_cheat_engine._version import __version__, __app_name__, PIP_VERSION
@@ -0,0 +1,6 @@
1
+ """Allow running as: python -m spacehaven_cheat_engine"""
2
+
3
+ from spacehaven_cheat_engine.patcher import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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)