-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcape_cod_patch.py
More file actions
286 lines (239 loc) · 11.6 KB
/
Copy pathcape_cod_patch.py
File metadata and controls
286 lines (239 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
#!/usr/bin/env python3
"""
The "Cape Cod" Patch -- X-COM: Terror from the Deep balance fix
=================================================================
Cape Cod is where lobsters go to die.
This is a small, reversible, backup-first balance patch for the GOG/DOSBox
release of X-COM: Terror from the Deep (1995). It edits the game's two DOS4GW
executables and one data file *in place* (file sizes never change) so DOSBox
runs the rebalanced game.
It is phased on purpose:
Phase 1 (default) Normalize the Lobster Man's hidden, undocumented
damage resistances back to 100%. This alone is a
~5x (AP) / 3.3x (Gauss) / 2x (Sonic) increase in
damage dealt to Lobsters and fixes most of the
problem on its own.
Phase 2 (--with-armor-hp) Modest Lobster armor + health trim. Secondary
tuning -- only apply if Phase 1 isn't enough.
Phase 3 (--with-weapon-power)Lower the weakest alien weapon (Sonic Pistol's
clip, power 80 -> 66) so a minimum 50% damage roll
no longer auto-kills a fresh 40-HP rookie.
Every offset below was decoded directly from the bytes of THIS install. Every
change is verified against a wide context window before writing; any mismatch
aborts loudly rather than risk a wrong write.
Usage:
python3 cape_cod_patch.py # apply Phase 1 only
python3 cape_cod_patch.py --with-armor-hp # Phase 1 + 2
python3 cape_cod_patch.py --with-weapon-power # Phase 1 + 3
python3 cape_cod_patch.py --full # all phases
python3 cape_cod_patch.py --dry-run # show diff, write nothing
python3 cape_cod_patch.py --verify # re-check applied state
python3 cape_cod_patch.py --revert # restore from backups
"""
import argparse
import os
import shutil
import sys
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
TFTD_DIR = os.path.join(BASE_DIR, "tftd")
BAK_SUFFIX = ".capecod.bak"
# --- Target files (relative path + verified original size, sanity only) -------
FILES = {
"TACTICAL": {"path": "UFO2EXE/TACTICAL.EXE", "size": 402201},
"GEOSCAPE": {"path": "UFOEXE/GEOSCAPE.EXE", "size": 490889},
"OBDATA": {"path": "GEODATA/OBDATA.DAT", "size": 4320},
}
def b(*vals):
"""Convenience: build a bytes object from a list of ints."""
return bytes(vals)
# -----------------------------------------------------------------------------
# A PatchSpec is purely declarative:
# file : key into FILES
# at : absolute file offset where `expect` begins
# expect : the exact bytes that MUST currently be present (a WIDE context
# window, not just the byte we change -- this anchors the structure
# and makes a wrong-build / offset-drift edit impossible)
# set : {index_within_expect: new_byte_value} -- the byte(s) to change
# desc : human-readable description for the report
#
# The engine verifies buf[at:at+len(expect)] == expect, then writes `set`.
# If the window already equals the post-patch bytes, the spec is reported as
# "already applied" and skipped (re-runs are safe and idempotent).
# -----------------------------------------------------------------------------
PHASE1 = [ # TACTICAL.EXE -- Lobster Man damage-modifier table (column 10).
# Each value is a 2-byte LE percentage; window is offset-4 .. offset+3.
dict(file="TACTICAL", at=0x61488, expect=b(0x64,0,0x50,0,0x14,0,0x64,0),
set={4: 0x64}, desc="Lobster AP (gun) resistance 20% -> 100%"),
dict(file="TACTICAL", at=0x614A4, expect=b(0x5A,0,0x64,0,0x1E,0,0x3C,0),
set={4: 0x64}, desc="Lobster Incendiary resistance 30% -> 100%"),
dict(file="TACTICAL", at=0x614C0, expect=b(0x64,0,0x96,0,0x1E,0,0x3C,0),
set={4: 0x64}, desc="Lobster High Explosive resistance 30% -> 100%"),
dict(file="TACTICAL", at=0x614DC, expect=b(0x5A,0,0x5A,0,0x1E,0,0x46,0),
set={4: 0x64}, desc="Lobster Gauss resistance 30% -> 100%"),
dict(file="TACTICAL", at=0x614F8, expect=b(0x6E,0,0x6E,0,0x32,0,0x5A,0),
set={4: 0x64}, desc="Lobster Sonic resistance 50% -> 100%"),
]
# GEOSCAPE.EXE -- six Lobster Man per-rank stat records (39 bytes each, from
# 0x770dc). Layout: [0:4]=armor f/s/r/u, [31]=Health. We verify the WHOLE 39
# bytes, then trim front/side armor and shave 20 health off each rank.
_LOBSTER_RANKS = [
(0x770DC, [20,20,15,10,0,35,0,1,0,4,4,22,17,0,10,0,25,2,6,0,0,52,0,20,2,2,6,5,0,0,66,110,90,65,70,54,62,78,20], 14, 90),
(0x77103, [20,20,15,10,0,40,0,1,0,4,4,22,18,0,10,0,30,3,6,0,0,52,0,25,2,3,6,4,0,0,70,115,95,70,70,54,62,78,20], 14, 95),
(0x7712A, [20,20,18,10,0,40,0,1,0,4,4,22,18,0,10,0,35,3,6,0,0,52,0,30,2,4,6,2,0,0,74,120,95,75,70,54,62,78,20], 14, 100),
(0x77151, [20,20,18,10,0,40,0,1,0,4,4,22,18,0,10,0,45,3,6,0,0,52,0,35,1,6,6,1,0,0,76,125,100,80,70,54,62,78,22], 14, 105),
(0x77178, [22,22,20,12,0,40,0,1,0,4,4,22,18,0,10,0,50,3,6,0,0,52,0,40,1,8,7,6,0,0,56,125,100,80,70,54,62,78,20], 16, 105),
(0x7719F, [20,20,20,10,0,35,0,1,0,3,3,22,18,0,7,0,30,5,5,0,0,53,0,18,2,4,7,5,0,0,66,135,100,80,70,65,62,78,20], 14, 115),
]
PHASE2 = []
for _i, (_at, _rec, _armor_new, _hp_new) in enumerate(_LOBSTER_RANKS):
PHASE2.append(dict(
file="GEOSCAPE", at=_at, expect=bytes(_rec),
set={0: _armor_new, 1: _armor_new, 31: _hp_new},
desc=("Lobster rank %d: front/side armor %d->%d, health %d->%d"
% (_i, _rec[0], _armor_new, _rec[31], _hp_new)),
))
PHASE3 = [ # OBDATA.DAT -- Sonic Clip (record 39) power byte at offset 2128.
dict(file="OBDATA", at=2122, expect=b(0,0,0,0,0x22,0x21,0x50,1,1,1),
set={6: 66}, desc="Sonic Clip (Sonic Pistol ammo) power 80 -> 66"),
]
def patched_bytes(spec):
"""Return the post-patch version of spec['expect']."""
out = bytearray(spec["expect"])
for i, v in spec["set"].items():
out[i] = v
return bytes(out)
def load(key):
path = os.path.normpath(os.path.join(TFTD_DIR, FILES[key]["path"]))
if not os.path.exists(path):
sys.exit("ERROR: missing target file: %s" % path)
with open(path, "rb") as f:
return path, bytearray(f.read())
def build_plan(args):
specs = list(PHASE1)
if args.with_armor_hp or args.full:
specs += PHASE2
if args.with_weapon_power or args.full:
specs += PHASE3
return specs
def main():
ap = argparse.ArgumentParser(
description="The Cape Cod Patch for X-COM: Terror from the Deep")
ap.add_argument("--with-armor-hp", action="store_true",
help="also apply Phase 2 (Lobster armor + health trim)")
ap.add_argument("--with-weapon-power", action="store_true",
help="also apply Phase 3 (weakest alien weapon power)")
ap.add_argument("--full", action="store_true",
help="apply all phases (1 + 2 + 3)")
ap.add_argument("--dry-run", action="store_true",
help="validate and show the diff without writing")
ap.add_argument("--verify", action="store_true",
help="report which patches are currently applied")
ap.add_argument("--revert", action="store_true",
help="restore all files from their .capecod.bak backups")
args = ap.parse_args()
print("=== The Cape Cod Patch -- X-COM: Terror from the Deep ===")
if args.revert:
return do_revert(args)
specs = build_plan(args)
# ---- Phase 1: load every touched file and verify every spec -------------
keys = sorted({s["file"] for s in specs})
loaded = {}
for k in keys:
path, data = load(k)
loaded[k] = {"path": path, "data": data}
if len(data) != FILES[k]["size"]:
print(" ! note: %s is %d bytes (expected %d) -- relying on byte "
"verification" % (FILES[k]["path"], len(data), FILES[k]["size"]))
to_apply, already, mismatches = [], [], []
for s in specs:
data = loaded[s["file"]]["data"]
cur = bytes(data[s["at"]: s["at"] + len(s["expect"])])
if cur == s["expect"]:
to_apply.append(s)
elif cur == patched_bytes(s):
already.append(s)
else:
mismatches.append((s, cur))
# ---- Report -------------------------------------------------------------
print("\nPlan: %s" % ", ".join(_phase_names(args)))
for s in specs:
tag = ("APPLY " if s in to_apply else
"done " if s in already else "MISMATCH")
print(" [%s] %-9s @ 0x%05X %s"
% (tag, FILES[s["file"]]["path"].split("/")[-1], s["at"], s["desc"]))
if mismatches:
print("\nERROR: %d patch site(s) did not match the expected original "
"bytes." % len(mismatches))
for s, cur in mismatches:
print(" @0x%05X %s\n expected %s\n found %s"
% (s["at"], s["desc"], s["expect"].hex(" "), cur.hex(" ")))
print("This is not the game build this patch was built for. Nothing "
"was written.")
sys.exit(1)
if args.verify:
print("\nVerify: %d applied, %d not yet applied."
% (len(already), len(to_apply)))
return
if not to_apply:
print("\nAll selected patches are already applied. Nothing to do.")
return
if args.dry_run:
print("\n[dry-run] %d change(s) would be written. No files modified."
% len(to_apply))
return
# ---- Backup (once) then write ------------------------------------------
print("")
for k in keys:
bak = loaded[k]["path"] + BAK_SUFFIX
if not os.path.exists(bak):
shutil.copy2(loaded[k]["path"], bak)
print(" backup: %s" % os.path.basename(bak))
for s in to_apply:
data = loaded[s["file"]]["data"]
for i, v in s["set"].items():
data[s["at"] + i] = v
for k in keys:
with open(loaded[k]["path"], "wb") as f:
f.write(loaded[k]["data"])
# ---- Re-verify on disk --------------------------------------------------
ok = True
for k in keys:
_, fresh = load(k)
if len(fresh) != len(loaded[k]["data"]):
ok = False
print(" ! %s size changed -- aborting confidence check" % k)
for s in to_apply:
_, fresh = load(s["file"])
if bytes(fresh[s["at"]: s["at"] + len(s["expect"])]) != patched_bytes(s):
ok = False
print(" ! re-verify failed @0x%05X" % s["at"])
print("\n%s Applied %d change(s)." % ("OK." if ok else "WARN:", len(to_apply)))
print("Backups kept as *.capecod.bak. Undo any time with --revert.")
print("Cape Cod is where lobsters go to die.")
def _phase_names(args):
names = ["Phase 1 (resistances)"]
if args.with_armor_hp or args.full:
names.append("Phase 2 (armor/health)")
if args.with_weapon_power or args.full:
names.append("Phase 3 (weapon power)")
return names
def do_revert(args):
restored = 0
for k in FILES:
path = os.path.normpath(os.path.join(TFTD_DIR, FILES[k]["path"]))
bak = path + BAK_SUFFIX
if not os.path.exists(bak):
continue
if args.dry_run:
print(" [dry-run] would restore %s" % FILES[k]["path"])
restored += 1
continue
shutil.copy2(bak, path)
print(" restored %s" % FILES[k]["path"])
restored += 1
if restored == 0:
print("No backups found -- nothing to revert.")
else:
print("\nReverted %d file(s) to original. Backups left in place." % restored)
if __name__ == "__main__":
main()