fifa17-python: commit working FUT backend deployment (client/server split)
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.
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user