70a64e3709
Freeze the running offline FUT backend into version control as fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a fresh checkout: * OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is required for remote mode (compose and entrypoint fail without it) * docker-compose.yml reproducing the frozen baseline container exactly (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart) * .env.example / .env for site config - the LAN IP is never hardcoded in source * tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10, verified byte-identical to the running container at freeze time * client_arm.sh (the 105 client-side arming counterpart) * Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying * docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record, restore instructions and rebuild-equivalence procedure Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored. The live container is untouched pending the .105 launcher audit.
342 lines
13 KiB
Python
342 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
lsx_force_online.py -- force FIFA 17's Origin/LSX layer to report ONLINE.
|
|
|
|
THIS IS LAYER 1. It must succeed before ANY Blaze work (blaze_responder_v3.py)
|
|
is reachable. The client's own flow graph gates FUT behind Origin:
|
|
|
|
{"name":"launchFUTFlow","file":"/online/origin.nav",
|
|
"outputs":{"OriginIsOnlineTrue":"startFutBlazeLogin","quit":"mainMenu"}}
|
|
|
|
so while Origin says offline the client shows
|
|
"Unable to connect to the EA Servers ... log in to Origin in Online Mode"
|
|
(TXT_ORIGIN_OFFLINE_POPUP_TEXT) and sends Authentication::logout (1/0x46) to
|
|
Blaze instead of login (1/0x0A).
|
|
|
|
PREFERRED FIX IS NOT THIS FILE. Prefer `lsx_responder.py`: bind 127.0.0.1:4216
|
|
BEFORE launching FIFA 17. The Steampunks stub's socket setup
|
|
(sub_0x6ffffc932130) does bind -> listen -> accept with NO SO_REUSEADDR and, on
|
|
bind failure, branches to 0x6ffffc932245 -> freeaddrinfo/closesocket/WSACleanup/
|
|
return 1 -- i.e. it stands down CLEANLY and the game's OriginSDK connects to us.
|
|
That is a real request-driven LSX server and can also answer GetAuthCode,
|
|
GetProfile and QueryEntitlements, which no memory patch can synthesise.
|
|
|
|
USE THIS FILE when the game is ALREADY RUNNING and you only want to flip the
|
|
online verdict (e.g. to confirm `OriginIsOnlineTrue` fires at all).
|
|
|
|
------------------------------------------------------------------------------
|
|
PATCH (A) -- the emu's response template. DEFAULT.
|
|
------------------------------------------------------------------------------
|
|
Region : stp-origin_emu.dll unpacked image, 0x6ffffc931000-0x6ffffc93d000,
|
|
already mapped rwxp (no mprotect needed).
|
|
Template: 0x6ffffc9353b0
|
|
<LSX><Response id="%d" sender=""><InternetConnectedState
|
|
connected="0"/></Response></LSX>
|
|
VA : 0x6ffffc9353f4 (the '0' inside connected="0")
|
|
BEFORE : 30 ('0')
|
|
AFTER : 31 ('1')
|
|
The format string is re-read on every use, so the patch applies to every
|
|
future emission -- but the stub is a BLIND FIXED-SCRIPT REPLAYER (18 canned
|
|
responses in a fixed order, then ErrorSuccess forever, loop 0x6ffffc932dd3).
|
|
Template 17 is the InternetConnectedState one. If the game has already
|
|
passed step 17, the stub is parked in the ErrorSuccess loop and will never
|
|
emit this template again -- the patch then does nothing, and Q-to-reconnect
|
|
does NOT help. Patch BEFORE the game boots past the Origin probe, or use
|
|
patch (B) / lsx_responder.py.
|
|
|
|
------------------------------------------------------------------------------
|
|
PATCH (B) -- g_originOnline, the parsed verdict itself. --flag / --hold
|
|
------------------------------------------------------------------------------
|
|
Module : FIFA17.exe, mapped flat at 0x140000000 under Wine/Proton.
|
|
VA : 0x1443337f8 (g_originOnline, one byte)
|
|
BEFORE : 00 (or stale garbage -- see caveat below)
|
|
AFTER : 01
|
|
Sole reader : 0x146f38aa9 movzx eax, BYTE PTR [0x1443337f8] (the popup /
|
|
OriginIsOnline predicate; a bare global read, no refresh)
|
|
Sole writer : 0x146f1e6d9 mov BYTE PTR [0x1443337f8], al
|
|
-- the LSX GetInternetConnectedState callback (0x146f1e6b0),
|
|
which also broadcasts FE::FIFA::OriginOnlineEvent.
|
|
|
|
CAVEAT (measured live): this byte currently reads 0x01 already, yet the flow
|
|
still fails. The stub answered the FIRST GetInternetConnectedState (id 17)
|
|
with a well-formed connected="0" but answered the LATER polls (ids 19-22)
|
|
with a type-mismatched generic ErrorSuccess, so the SDK found no `connected`
|
|
attribute and stored stale garbage. Therefore: a 1 in this byte is NOT
|
|
sufficient on its own -- the FE::FIFA::OriginOnlineEvent broadcast that the
|
|
writer performs is what actually drives origin.nav. --hold keeps the byte at
|
|
1 so it cannot be clobbered, but only a real LSX reply (lsx_responder.py)
|
|
makes the callback run and fire the event. Treat (B) as diagnostic.
|
|
|
|
------------------------------------------------------------------------------
|
|
CLEAN ROOM: every address above was recovered by static + dynamic analysis of
|
|
binaries we own (FIFA17.exe and stp-origin_emu.dll as loaded in our own running
|
|
process). Nothing derives from the 2021 EA/FIFA leak.
|
|
|
|
USAGE
|
|
python3 lsx_force_online.py # apply (A); idempotent
|
|
python3 lsx_force_online.py --flag # apply (A) + (B) once
|
|
python3 lsx_force_online.py --hold # (A) + rewrite (B) every 0.5s
|
|
python3 lsx_force_online.py --status # read both, change nothing
|
|
python3 lsx_force_online.py --restore # put the saved originals back
|
|
python3 lsx_force_online.py --watch # wait for FIFA17.exe, then apply
|
|
|
|
Requires /proc/PID/mem write access (kernel.yama.ptrace_scope=0, or run as the
|
|
same user with ptrace_scope=1 which is already known to work here).
|
|
"""
|
|
|
|
import glob
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
# ------------------------------------------------------------------ patches
|
|
|
|
# (A) stp-origin_emu.dll InternetConnectedState template.
|
|
EMU_TEMPLATE_VA = 0x6FFFFC9353B0
|
|
EMU_PATCH_VA = 0x6FFFFC9353F4
|
|
EMU_BEFORE = b"0" # 0x30
|
|
EMU_AFTER = b"1" # 0x31
|
|
EMU_TEMPLATE_HEAD = b'<LSX><Response id="%d" sender=""><InternetConnectedState'
|
|
EMU_REGION = (0x6FFFFC931000, 0x6FFFFC93D000) # rwxp unpacked image
|
|
|
|
# (B) FIFA17.exe g_originOnline.
|
|
FLAG_VA = 0x1443337F8
|
|
FLAG_AFTER = b"\x01"
|
|
|
|
BACKUP_DIR = "/tmp/lsx_force_online"
|
|
LOGFILE = "/tmp/lsx_force_online.log"
|
|
|
|
|
|
def log(m):
|
|
line = "[%s] %s" % (time.strftime("%H:%M:%S"), m)
|
|
print(line, flush=True)
|
|
try:
|
|
with open(LOGFILE, "a") as fh:
|
|
fh.write(line + "\n")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ------------------------------------------------------------------ process
|
|
|
|
def find_pid():
|
|
for d in glob.glob("/proc/[0-9]*"):
|
|
try:
|
|
if open(d + "/comm").read().strip() == "FIFA17.exe":
|
|
return int(d.rsplit("/", 1)[-1])
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def rd(pid, va, n):
|
|
with open("/proc/%d/mem" % pid, "rb") as f:
|
|
f.seek(va)
|
|
return f.read(n)
|
|
|
|
|
|
def wr(pid, va, b):
|
|
with open("/proc/%d/mem" % pid, "r+b") as f:
|
|
f.seek(va)
|
|
f.write(b)
|
|
|
|
|
|
def backup(va, orig):
|
|
os.makedirs(BACKUP_DIR, exist_ok=True)
|
|
p = os.path.join(BACKUP_DIR, "orig_%x.bin" % va)
|
|
if not os.path.exists(p): # never overwrite the true original
|
|
with open(p, "wb") as fh:
|
|
fh.write(orig)
|
|
return p
|
|
|
|
|
|
# ------------------------------------------------------ template relocation
|
|
#
|
|
# The unpacked emu image address has been stable at 0x6ffffc931000 across our
|
|
# runs, but it is a runtime mapping -- do not trust it blindly. Verify the
|
|
# template is where we expect; if not, rescan the rwxp regions for it and
|
|
# recompute the patch offset from the template head.
|
|
|
|
def locate_emu_patch(pid):
|
|
"""-> (patch_va, template_va) or (None, None)."""
|
|
want = EMU_TEMPLATE_HEAD
|
|
try:
|
|
head = rd(pid, EMU_TEMPLATE_VA, len(want))
|
|
if head == want:
|
|
return EMU_PATCH_VA, EMU_TEMPLATE_VA
|
|
except Exception:
|
|
pass
|
|
|
|
log("template not at 0x%x -- rescanning writable+executable maps"
|
|
% EMU_TEMPLATE_VA)
|
|
delta = EMU_PATCH_VA - EMU_TEMPLATE_VA # +0x44
|
|
try:
|
|
maps = open("/proc/%d/maps" % pid).read().splitlines()
|
|
except Exception as e:
|
|
log("cannot read maps: %s" % e)
|
|
return None, None
|
|
for line in maps:
|
|
try:
|
|
rng, perms = line.split()[0], line.split()[1]
|
|
if "w" not in perms or "r" not in perms:
|
|
continue
|
|
lo, hi = (int(x, 16) for x in rng.split("-"))
|
|
if hi - lo > 64 * 1024 * 1024:
|
|
continue
|
|
blob = rd(pid, lo, hi - lo)
|
|
except Exception:
|
|
continue
|
|
off = blob.find(want)
|
|
while off != -1:
|
|
tva = lo + off
|
|
pva = tva + delta
|
|
try:
|
|
if rd(pid, pva, 1) in (EMU_BEFORE, EMU_AFTER):
|
|
log("template relocated: 0x%x (patch byte 0x%x)" % (tva, pva))
|
|
return pva, tva
|
|
except Exception:
|
|
pass
|
|
off = blob.find(want, off + 1)
|
|
return None, None
|
|
|
|
|
|
# ------------------------------------------------------------------ actions
|
|
|
|
def show_template(pid, tva):
|
|
try:
|
|
raw = rd(pid, tva, 96).split(b"\x00")[0]
|
|
log(" template @0x%x: %s" % (tva, raw.decode("ascii", "replace")))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def apply_emu(pid):
|
|
pva, tva = locate_emu_patch(pid)
|
|
if pva is None:
|
|
log("PATCH (A): template NOT FOUND -- is stp-origin_emu loaded? "
|
|
"(is the game past the Origin probe already?)")
|
|
return False
|
|
cur = rd(pid, pva, 1)
|
|
if cur == EMU_AFTER:
|
|
log("PATCH (A): already applied at 0x%x (connected=\"1\")" % pva)
|
|
show_template(pid, tva)
|
|
return True
|
|
if cur != EMU_BEFORE:
|
|
log("PATCH (A): UNEXPECTED byte %s at 0x%x (want %s) -- refusing"
|
|
% (cur.hex(), pva, EMU_BEFORE.hex()))
|
|
return False
|
|
backup(pva, cur)
|
|
wr(pid, pva, EMU_AFTER)
|
|
now = rd(pid, pva, 1)
|
|
log("PATCH (A): 0x%x %s -> %s %s"
|
|
% (pva, cur.hex(), now.hex(), "OK" if now == EMU_AFTER else "FAILED"))
|
|
show_template(pid, tva)
|
|
return now == EMU_AFTER
|
|
|
|
|
|
def apply_flag(pid):
|
|
cur = rd(pid, FLAG_VA, 1)
|
|
if cur == FLAG_AFTER:
|
|
log("PATCH (B): g_originOnline @0x%x already 0x01" % FLAG_VA)
|
|
return True
|
|
backup(FLAG_VA, cur)
|
|
wr(pid, FLAG_VA, FLAG_AFTER)
|
|
now = rd(pid, FLAG_VA, 1)
|
|
log("PATCH (B): g_originOnline @0x%x %s -> %s %s"
|
|
% (FLAG_VA, cur.hex(), now.hex(), "OK" if now == FLAG_AFTER else "FAILED"))
|
|
return now == FLAG_AFTER
|
|
|
|
|
|
def status(pid):
|
|
pva, tva = locate_emu_patch(pid)
|
|
if pva is None:
|
|
log("STATUS (A): template not found in this process")
|
|
else:
|
|
b = rd(pid, pva, 1)
|
|
log("STATUS (A): 0x%x = %s -> connected=\"%s\"%s"
|
|
% (pva, b.hex(), b.decode("ascii", "replace"),
|
|
" [PATCHED]" if b == EMU_AFTER else ""))
|
|
show_template(pid, tva)
|
|
b = rd(pid, FLAG_VA, 1)
|
|
log("STATUS (B): g_originOnline @0x%x = %s (%s)"
|
|
% (FLAG_VA, b.hex(),
|
|
"online" if b == b"\x01" else "offline/garbage"))
|
|
log("NOTE: a 1 in (B) is NOT proof of success -- see the CAVEAT in this "
|
|
"file's docstring. Only a well-formed LSX connected=\"1\" reply makes "
|
|
"the callback broadcast FE::FIFA::OriginOnlineEvent, which is what "
|
|
"origin.nav actually consumes.")
|
|
|
|
|
|
def restore(pid):
|
|
if not os.path.isdir(BACKUP_DIR):
|
|
log("RESTORE: nothing saved in %s" % BACKUP_DIR)
|
|
return
|
|
for fn in sorted(os.listdir(BACKUP_DIR)):
|
|
if not fn.startswith("orig_"):
|
|
continue
|
|
va = int(fn[5:].split(".")[0], 16)
|
|
orig = open(os.path.join(BACKUP_DIR, fn), "rb").read()
|
|
try:
|
|
wr(pid, va, orig)
|
|
log("RESTORE: 0x%x <- %s (now %s)"
|
|
% (va, orig.hex(), rd(pid, va, len(orig)).hex()))
|
|
except Exception as e:
|
|
log("RESTORE: 0x%x FAILED: %s" % (va, e))
|
|
|
|
|
|
# ------------------------------------------------------------------ main
|
|
|
|
def main():
|
|
argv = sys.argv[1:]
|
|
want_flag = "--flag" in argv or "--hold" in argv
|
|
hold = "--hold" in argv
|
|
|
|
if "--watch" in argv:
|
|
log("=== WATCH: waiting for FIFA17.exe ===")
|
|
seen = set()
|
|
while True:
|
|
pid = find_pid()
|
|
if pid and pid not in seen:
|
|
try:
|
|
if apply_emu(pid):
|
|
if want_flag:
|
|
apply_flag(pid)
|
|
seen.add(pid)
|
|
except Exception as e:
|
|
log("pid %d not ready yet (%s)" % (pid, e))
|
|
time.sleep(1)
|
|
|
|
pid = find_pid()
|
|
if not pid:
|
|
raise SystemExit("FIFA17.exe not running (use --watch to wait for it)")
|
|
log("=== lsx_force_online pid=%d ===" % pid)
|
|
|
|
if "--status" in argv:
|
|
status(pid)
|
|
return
|
|
if "--restore" in argv:
|
|
restore(pid)
|
|
return
|
|
|
|
apply_emu(pid)
|
|
if want_flag:
|
|
apply_flag(pid)
|
|
if hold:
|
|
log("HOLD: rewriting g_originOnline every 0.5 s (Ctrl-C to stop)")
|
|
try:
|
|
while True:
|
|
try:
|
|
if rd(pid, FLAG_VA, 1) != FLAG_AFTER:
|
|
wr(pid, FLAG_VA, FLAG_AFTER)
|
|
log("HOLD: g_originOnline was clobbered, reset to 0x01")
|
|
except Exception as e:
|
|
log("HOLD: process gone (%s)" % e)
|
|
break
|
|
time.sleep(0.5)
|
|
except KeyboardInterrupt:
|
|
log("HOLD: stopped")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|