Gundam Breaker 4 UltraWide Fix v1.0.0
32:9 / 21:9 ultrawide support for Gundam Breaker 4 (UE4.27) via SUWSF + Ultimate ASI Loader. Forces Hor+ FOV (AspectRatioAxisConstraint=MaintainYFOV) and disables pillarboxing, patched in decrypted memory at runtime so it holds through missions (the exe is SteamStub-encrypted; a static patch is impossible). Confirmed working in gameplay at 5120x1440 (Samsung Odyssey G93SC) on Proton. Requires WINEDLLOVERRIDES="dsound=n,b" on Proton so the loader injects. Includes: patch/SUWSF.ini, Proton installer with clean uninstall, release-zip builder, and runtime dump/verify tooling. Third-party bundles under MIT (see THIRD_PARTY.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify (or derive) SUWSF AOB patterns against a decrypted GB4 dump.
|
||||
|
||||
- Reads patch/SUWSF.ini, extracts every [Patch:*] Pattern, and reports how many
|
||||
matches each has in the dump produced by dump_decrypted.py.
|
||||
- 1 clean match -> pattern is good, ship it.
|
||||
- 0 matches -> pattern needs deriving for GB4 (see notes printed).
|
||||
- many matches -> pattern too loose; tighten or set Match to a specific index.
|
||||
|
||||
Usage:
|
||||
python3 tools/aob.py [dump.bin] [SUWSF.ini]
|
||||
"""
|
||||
import re, sys, os
|
||||
|
||||
DUMP = sys.argv[1] if len(sys.argv) > 1 else \
|
||||
"/tmp/claude-1000/-home-ckoch-Documents-Development-BG4-UltraWide-fix/1a8ebba4-6fbe-45ae-a873-0e69d867e0db/scratchpad/gb4dump/gb4_decrypted.bin"
|
||||
INI = sys.argv[2] if len(sys.argv) > 2 else \
|
||||
os.path.join(os.path.dirname(__file__), "..", "patch", "SUWSF.ini")
|
||||
|
||||
|
||||
def parse_pattern(pat):
|
||||
"""'0F ?? 3F' -> (regex bytes). ?? = wildcard byte."""
|
||||
out = bytearray()
|
||||
mask = []
|
||||
for tok in pat.split():
|
||||
if tok in ("??", "?"):
|
||||
out.append(0)
|
||||
mask.append(False)
|
||||
else:
|
||||
out.append(int(tok, 16))
|
||||
mask.append(True)
|
||||
# build a regex over raw bytes
|
||||
rx = b""
|
||||
for b, m in zip(out, mask):
|
||||
rx += re.escape(bytes([b])) if m else b"[\\x00-\\xff]"
|
||||
return re.compile(rx, re.DOTALL)
|
||||
|
||||
|
||||
def find_all(data, rx, limit=50):
|
||||
hits, pos = [], 0
|
||||
while len(hits) < limit:
|
||||
m = rx.search(data, pos)
|
||||
if not m:
|
||||
break
|
||||
hits.append(m.start())
|
||||
pos = m.start() + 1
|
||||
return hits
|
||||
|
||||
|
||||
def load_patches(ini_path):
|
||||
patches = []
|
||||
cur = None
|
||||
with open(ini_path) as f:
|
||||
for raw in f:
|
||||
line = raw.strip()
|
||||
if line.startswith("[") and "Patch" in line:
|
||||
cur = {"name": line.strip("[]")}
|
||||
patches.append(cur)
|
||||
elif line.startswith("[") :
|
||||
cur = None
|
||||
elif cur is not None and "=" in line and not line.startswith(";"):
|
||||
k, _, v = line.partition("=")
|
||||
cur[k.strip()] = v.strip().strip('"')
|
||||
return patches
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(DUMP):
|
||||
print(f"[!] dump not found: {DUMP}\n Run tools/dump_decrypted.py while the game is running.")
|
||||
sys.exit(1)
|
||||
data = open(DUMP, "rb").read()
|
||||
print(f"[+] dump: {len(data):,} bytes")
|
||||
patches = load_patches(os.path.abspath(INI))
|
||||
print(f"[+] {len(patches)} patches in {os.path.abspath(INI)}\n")
|
||||
for p in patches:
|
||||
pat = p.get("Pattern")
|
||||
if not pat:
|
||||
continue
|
||||
rx = parse_pattern(pat)
|
||||
hits = find_all(data, rx)
|
||||
enabled = p.get("Enabled", "true").lower()
|
||||
status = "OK " if len(hits) == 1 else ("MISS" if not hits else "MANY")
|
||||
print(f"[{status}] {p['name']:32s} enabled={enabled:5s} matches={len(hits)} {pat[:40]}...")
|
||||
for h in hits[:4]:
|
||||
ctx = data[h:h + 24].hex(" ")
|
||||
print(f" @rva 0x{h:x}: {ctx}")
|
||||
print("\nLegend: OK=exactly 1 match (ship it) | MISS=derive new pattern | MANY=tighten/Match=n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the drop-in release zip from committed sources + vendored SUWSF.
|
||||
# Output: dist/GB4_UltraWide_Fix.zip (attach to the GitHub Release)
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$HERE"
|
||||
|
||||
V="${1:-vendor/SUWSF-x64}"
|
||||
for f in "$V/SUWSF.asi" "$V/dsound.dll" "$V/LICENSE" patch/SUWSF.ini; do
|
||||
[ -f "$f" ] || { echo "[!] missing $f"; exit 1; }
|
||||
done
|
||||
|
||||
rm -rf dist/GB4_UltraWide_Fix dist/GB4_UltraWide_Fix.zip
|
||||
mkdir -p dist/GB4_UltraWide_Fix
|
||||
cp "$V/SUWSF.asi" "$V/dsound.dll" patch/SUWSF.ini dist/GB4_UltraWide_Fix/
|
||||
cp "$V/LICENSE" dist/GB4_UltraWide_Fix/SUWSF-LICENSE.txt
|
||||
cp README.md THIRD_PARTY.md dist/GB4_UltraWide_Fix/ 2>/dev/null || true
|
||||
( cd dist && zip -r -q GB4_UltraWide_Fix.zip GB4_UltraWide_Fix )
|
||||
echo "[+] Built dist/GB4_UltraWide_Fix.zip"
|
||||
( cd dist && sha256sum GB4_UltraWide_Fix.zip )
|
||||
unzip -l dist/GB4_UltraWide_Fix.zip
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump the DECRYPTED GB4-Win64-Shipping.exe image from a running (Proton) process.
|
||||
|
||||
Why: the on-disk exe is SteamStub-encrypted (.text entropy 8.0). The real UE4
|
||||
code only exists in memory after the stub decrypts it at launch. This grabs that
|
||||
memory so we can verify/derive the exact SUWSF byte patterns offline.
|
||||
|
||||
Usage:
|
||||
# 1. Launch Gundam Breaker 4 (get to the main menu / lobby).
|
||||
# 2. Run:
|
||||
python3 tools/dump_decrypted.py
|
||||
# -> writes scratch dump + region metadata, then prints AOB match report.
|
||||
|
||||
If you get "Operation not permitted", either run with sudo, or temporarily:
|
||||
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope (reset to 1 after)
|
||||
"""
|
||||
import ctypes, ctypes.util, os, re, sys, json, struct, glob
|
||||
|
||||
MODULE_HINT = "GB4-Win64-Shipping.exe"
|
||||
OUTDIR = os.environ.get("GB4_DUMP_DIR", "/tmp/claude-1000/-home-ckoch-Documents-Development-BG4-UltraWide-fix/1a8ebba4-6fbe-45ae-a873-0e69d867e0db/scratchpad/gb4dump")
|
||||
|
||||
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
|
||||
|
||||
|
||||
class iovec(ctypes.Structure):
|
||||
_fields_ = [("iov_base", ctypes.c_void_p), ("iov_len", ctypes.c_size_t)]
|
||||
|
||||
|
||||
libc.process_vm_readv.restype = ctypes.c_ssize_t
|
||||
libc.process_vm_readv.argtypes = [ctypes.c_int, ctypes.POINTER(iovec), ctypes.c_ulong,
|
||||
ctypes.POINTER(iovec), ctypes.c_ulong, ctypes.c_ulong]
|
||||
|
||||
|
||||
def find_pid():
|
||||
if len(sys.argv) > 1 and sys.argv[1].isdigit():
|
||||
return int(sys.argv[1])
|
||||
for p in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
with open(f"{p}/cmdline", "rb") as f:
|
||||
cl = f.read().replace(b"\0", b" ").decode("utf-8", "replace")
|
||||
if MODULE_HINT in cl:
|
||||
return int(os.path.basename(p))
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def read_mem(pid, addr, size):
|
||||
buf = ctypes.create_string_buffer(size)
|
||||
local = iovec(ctypes.cast(buf, ctypes.c_void_p), size)
|
||||
remote = iovec(ctypes.c_void_p(addr), size)
|
||||
n = libc.process_vm_readv(pid, ctypes.byref(local), 1, ctypes.byref(remote), 1, 0)
|
||||
if n < 0:
|
||||
# fallback: /proc/pid/mem pread
|
||||
try:
|
||||
with open(f"/proc/{pid}/mem", "rb", 0) as m:
|
||||
m.seek(addr)
|
||||
return m.read(size)
|
||||
except OSError as e:
|
||||
raise OSError(f"read fail @0x{addr:x}: {os.strerror(ctypes.get_errno())} / {e}")
|
||||
return buf.raw[:n]
|
||||
|
||||
|
||||
def module_regions(pid):
|
||||
"""Return list of (start,end,perms,path) VMAs backed by the module exe."""
|
||||
regs = []
|
||||
modpath = None
|
||||
with open(f"/proc/{pid}/maps") as f:
|
||||
for line in f:
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+ *(.*)", line)
|
||||
if not m:
|
||||
continue
|
||||
start, end, perms, path = int(m[1], 16), int(m[2], 16), m[3], m[4]
|
||||
if MODULE_HINT in path:
|
||||
if modpath is None:
|
||||
modpath = path
|
||||
regs.append((start, end, perms, path))
|
||||
return modpath, regs
|
||||
|
||||
|
||||
def main():
|
||||
pid = find_pid()
|
||||
if not pid:
|
||||
print(f"[!] No running process matching '{MODULE_HINT}'. Launch the game first.")
|
||||
sys.exit(1)
|
||||
print(f"[+] pid = {pid}")
|
||||
modpath, regs = module_regions(pid)
|
||||
if not regs:
|
||||
print("[!] Module not mapped yet. Reach the main menu, then retry.")
|
||||
sys.exit(1)
|
||||
base = min(r[0] for r in regs)
|
||||
print(f"[+] module base = 0x{base:x} ({modpath})")
|
||||
os.makedirs(OUTDIR, exist_ok=True)
|
||||
meta = {"pid": pid, "module": modpath, "base": base, "regions": []}
|
||||
blob = bytearray()
|
||||
for start, end, perms, path in regs:
|
||||
size = end - start
|
||||
try:
|
||||
data = read_mem(pid, start, size)
|
||||
except OSError as e:
|
||||
print(f" skip 0x{start:x}-0x{end:x} {perms}: {e}")
|
||||
continue
|
||||
rva = start - base
|
||||
# pad blob so file offset == rva (sparse-ish, capped)
|
||||
if rva >= 0 and rva < 0x20000000:
|
||||
if len(blob) < rva:
|
||||
blob.extend(b"\x00" * (rva - len(blob)))
|
||||
blob[rva:rva + len(data)] = data
|
||||
meta["regions"].append({"start": start, "end": end, "rva": rva,
|
||||
"perms": perms, "size": size, "got": len(data)})
|
||||
print(f" dumped 0x{start:x}-0x{end:x} {perms} rva=0x{rva:x} ({len(data)} bytes)")
|
||||
dump_path = os.path.join(OUTDIR, "gb4_decrypted.bin")
|
||||
with open(dump_path, "wb") as f:
|
||||
f.write(blob)
|
||||
with open(os.path.join(OUTDIR, "gb4_dump_meta.json"), "w") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
print(f"[+] wrote {dump_path} ({len(blob)} bytes, rva-aligned)")
|
||||
print(f"[+] wrote {os.path.join(OUTDIR, 'gb4_dump_meta.json')}")
|
||||
|
||||
# quick sanity: is it decrypted? look for the UE4 version string / a UTF-16 hint
|
||||
if b"++UE4+Release-4.27" in blob or b"CalculateProjectionMatrix" in blob:
|
||||
print("[+] decrypted UE4 code confirmed in dump.")
|
||||
print("\nNext: python3 tools/aob.py (verifies SUWSF patterns against this dump)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Locate FMinimalViewInfo::CalculateProjectionMatrixGivenView in GB4-Win64-Shipping.exe.
|
||||
|
||||
Fingerprint: the function computes max(0.001f, FOV) * (PI/360) — so it
|
||||
references both float constants 0.001f and pi/360 within a short span.
|
||||
We find all RIP-relative references to those constants and cluster them.
|
||||
"""
|
||||
import pefile, struct, math, sys
|
||||
|
||||
EXE = sys.argv[1] if len(sys.argv) > 1 else \
|
||||
"/media/ckoch/Data/steam/steamapps/common/GBBBB/GB4/Binaries/Win64/GB4-Win64-Shipping.exe"
|
||||
|
||||
pe = pefile.PE(EXE, fast_load=True)
|
||||
base = pe.OPTIONAL_HEADER.ImageBase
|
||||
|
||||
sections = {}
|
||||
for s in pe.sections:
|
||||
name = s.Name.rstrip(b"\0").decode()
|
||||
sections[name] = (s.VirtualAddress, s.SizeOfRawData, s.get_data())
|
||||
print(f"section {name:8s} va=0x{base+s.VirtualAddress:x} rawsize=0x{s.SizeOfRawData:x}")
|
||||
|
||||
text_rva, _, text = sections[".text"]
|
||||
|
||||
# --- find constant locations anywhere outside .text (rdata usually) ---
|
||||
consts = {
|
||||
"0.001f": struct.pack("<f", 0.001),
|
||||
"pi/360": struct.pack("<f", math.pi / 360),
|
||||
}
|
||||
const_vas = {k: [] for k in consts}
|
||||
for name, (rva, size, data) in sections.items():
|
||||
if name == ".text":
|
||||
continue
|
||||
for key, pat in consts.items():
|
||||
off = -1
|
||||
while True:
|
||||
off = data.find(pat, off + 1)
|
||||
if off < 0:
|
||||
break
|
||||
va = base + rva + off
|
||||
# only aligned-ish hits to cut noise
|
||||
const_vas[key].append(va)
|
||||
for k, v in const_vas.items():
|
||||
print(f"{k}: {len(v)} occurrences in data sections")
|
||||
|
||||
# --- find rip-relative references in .text to any of those VAs ---
|
||||
targets = {}
|
||||
for k, vas in const_vas.items():
|
||||
for va in vas:
|
||||
targets.setdefault(va, k)
|
||||
|
||||
refs = [] # (text_off, va, key)
|
||||
tbase = base + text_rva
|
||||
n = len(text)
|
||||
for i in range(n - 4):
|
||||
disp = struct.unpack_from("<i", text, i)[0]
|
||||
va = tbase + i + 4 + disp
|
||||
k = targets.get(va)
|
||||
if k:
|
||||
refs.append((i, va, k))
|
||||
print(f"total rip refs to candidate consts: {len(refs)}")
|
||||
|
||||
# cluster: find 0.001f refs with a pi/360 ref within 0x100 bytes
|
||||
refs.sort()
|
||||
by_key = {}
|
||||
for off, va, k in refs:
|
||||
by_key.setdefault(k, []).append(off)
|
||||
|
||||
import bisect
|
||||
p360 = by_key.get("pi/360", [])
|
||||
hits = []
|
||||
for off in by_key.get("0.001f", []):
|
||||
j = bisect.bisect_left(p360, off - 0x100)
|
||||
while j < len(p360) and p360[j] < off + 0x100:
|
||||
hits.append((off, p360[j]))
|
||||
j += 1
|
||||
print(f"\nclustered candidates (0.001f + pi/360 within 0x100):")
|
||||
for a, b in hits:
|
||||
print(f" 0.001f@text+0x{a:x} (va 0x{tbase+a:x}) pi/360@text+0x{b:x} (va 0x{tbase+b:x})")
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reads the newest Proton log for GB4 (appid 1672500) and extracts SUWSF + DLL-load evidence.
|
||||
set -uo pipefail
|
||||
LOG=$(ls -t "$HOME"/steam-1672500.log "$HOME"/.steam/steam/logs/steam-1672500.log 2>/dev/null | head -1)
|
||||
[ -z "${LOG:-}" ] && { echo "[!] No steam-1672500.log found. Add PROTON_LOG=1 to launch options and relaunch."; exit 1; }
|
||||
echo "[+] Log: $LOG ($(stat -c%s "$LOG") bytes, modified $(stat -c%y "$LOG"))"
|
||||
echo "=== Did our native dsound.dll load? ==="
|
||||
grep -iE "dsound\.dll|SUWSF\.asi|Ultimate" "$LOG" | grep -iE "load|builtin|native|override" | head
|
||||
echo "=== SUWSF patch results ==="
|
||||
grep -iE "Searching for patches|Found patch|patches found| matches|No pattern found|skipping patch|Patches disabled|INITIALIZED" "$LOG" | head -60
|
||||
echo "=== (if both sections empty, the ASI did not inject: check WINEDLLOVERRIDES) ==="
|
||||
Reference in New Issue
Block a user