#!/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()
