#!/usr/bin/env python3
"""SBC menu render probe + minimal gate-arm poke (FIFA 17 CardsDLL).

WHAT THIS DOES
--------------
READ-ONLY BY DEFAULT. With no flags it opens /proc/<pid>/mem O_RDONLY, proves the
CardsDLL slide against the on-disk FNV prologue, and reports the exact live state of
the SBC data flow so the human can see whether a poke would render anything:

  A   = FUT root singleton   = *(0x1802e6398)            (vtable static 0x18021c2a0)
  B   = SBC request/TTL cache = A + 0x1f9d8              (vtable static 0x1801fae70)
        B+0x08 collection ptr, B+0x20 QPC deadline, B+0x28 READY byte (the gate)
        B offset 0x1f9d8 is DECODED live from A.vtable[+0x4e8] thunk
        (48 8d 81 <disp32> = lea rax,[rcx+disp32]), not taken on faith.
  M   = SBC categories store = *(A + 0x20a68)            (THE RENDER SOURCE)
        lazy getter A.vtable[+0x9b0] = 0x18011b7d0; category count = WORD[M+0x50];
        category vector M+0x58..M+0x60 (stride 0xf0). The SBC menu draws
        WORD[M+0x50] + 2 tiles. M is NULL until the menu is opened (lazily built,
        empty offline) or the sbs/sets response is parsed.
  HUB = sibling cache        = A + 0x1fd70
  SBC req-mgr                = A + 0x2a0

THE VERIFIED GATE (proven byte-exact against the shipped DLL, isValid 0x180065d40):
    call 0x1801642c0          ; online sub-check -- STUBBED `mov al,1; ret`, not the wall
    cmp BYTE[rbx+0x28],0 ; je fail    ; <-- the READY gate
    cmp QWORD[rbx+0x8],0 ; je RET_1    ; <-- SHORT-CIRCUIT: coll==0 => return 1
    <QPC deadline compare>            ; only reached when B+0x08 != 0
  So isValid returns TRUE with B+0x28=1 AND B+0x08=0 (short-circuit). Writing B+0x08
  forces the deadline branch; with a stale/past B+0x20 that returns 0 -> the error
  modal. That is why this tool NEVER writes B+0x08 or B+0x20 -- doing so can DEFEAT
  the fix and is a crash risk if the pointer is not a real EASTL collection.

THE INTERVENTION THIS TOOL CAN APPLY (--apply)
----------------------------------------------
The ONLY write blessed by adversarial verification as non-crashing from a bare
/proc/mem poke is:

    BYTE[B+0x28] = 1      (arm the SBC ready gate; leave B+0x08 and B+0x20 alone)

This OPENS the SBC menu (isValid short-circuits to true) instead of the error modal.
It renders EMPTY (2 placeholder tiles) unless M is populated, because tiles come from
WORD[M+0x50], not from B. It is the proven-safe NEGATIVE CONTROL / gate-open step.

WHY A POPULATED MENU NEEDS THE INJECTED DLL, NOT THIS TOOL
----------------------------------------------------------
Populating M means running the client's OWN parser (deser 0x18017b2b0) over a real
sbs/sets response, so it clears+builds M with the correct 0xf0/0x3570 geometry and
rebuilds the indices. That requires executing code IN-PROCESS (the openfut-hook DLL)
or serving GET ut/game/fifa17/sbs/sets through the bridge so the native completion
path populates M and arms B for you. A /proc/mem byte poke cannot build M's nested
EASTL vectors safely (hand-building 0xf0/0x3570 records is the highest-crash option
all three verifiers rejected), and it cannot call the deser with a seated SAX cursor.
Cold-calling the deser with a null cursor WIPES M (clear runs before append) and
parses nothing. So: this tool arms the gate; the DLL (spec printed by --spec) does
the populate. See docs and the openfut-hook integration notes.

RISK / SAFETY
-------------
  * Default run = READ ONLY. Nothing here writes unless you pass --apply.
  * --apply WRITES LIVE GAME MEMORY (/proc/<pid>/mem O_WRONLY): one byte, B+0x28=1.
    Do this only on a client sitting in the FUT hub, ideally with the SBC menu CLOSED
    (never mutate while the menu is mid-iterate). Then re-open the SBC menu to render.
  * --apply re-proves the slide AND re-verifies B.vtable == static 0x1801fae70 before
    writing, and aborts on any mismatch. It refuses to write anything but B+0x28.
  * If FIFA17.exe is not running or CardsDLL is not mapped, the tool says so and exits
    0 -- static analysis is authoritative; live steps are best-effort.

USAGE
  python3 sbc_hook_poke.py            # read-only probe + dry-run plan (default)
  python3 sbc_hook_poke.py --spec     # also print the injected-DLL populate spec
  python3 sbc_hook_poke.py --apply    # WRITE BYTE[B+0x28]=1 (arm gate) -- HUMAN ONLY
"""
import os, struct, sys

# ---- static VAs (image base 0x180000000; add live slide) --------------------
A_SINGLETON  = 0x1802e6398      # slot holding A = FUT root singleton ptr
CTRL_VA      = 0x180180d00      # FNV atom-hash prologue used to prove the slide
A_VT_STATIC  = 0x18021c2a0      # A.vtable (verify live == this + slide)
B_VT_STATIC  = 0x1801fae70      # B.vtable (verify live == this + slide)
A_VT_BGETTER = 0x4e8            # A.vtable slot -> thunk lea rax,[rcx+0x1f9d8]
A_VT_MGETTER = 0x9b0            # A.vtable slot -> M lazy getter 0x18011b7d0
M_CACHE_OFF  = 0x20a68          # M cache slot on A (decoded from getter cmp)
HUB_OFF      = 0x1fd70
REQMGR_OFF   = 0x2a0
ISVALID_VA   = 0x180065d40
ONLINE_STUB  = 0x1801642c0      # expect b0 01 c3 (mov al,1; ret)
B_READY_OFF  = 0x28

PE_PATHS = ['/tmp/fut/cardsdll.dll', '/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll']


def find_pid():
    for d in os.listdir('/proc'):
        if d.isdigit():
            try:
                if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
                    return int(d)
            except Exception:
                pass
    return None


def load_pe():
    for p in PE_PATHS:
        try:
            return open(p, 'rb').read()
        except Exception:
            continue
    return None


def print_spec():
    print("""
== INJECTED-DLL POPULATE SPEC (openfut-hook / version.dll) ===================
The poke tool arms the gate; the DLL must POPULATE M. Preferred, lowest-risk,
zero-forged-state path (run ON THE GAME MAIN/UI THREAD, SBC menu CLOSED):

  Option 1 (best) -- serve the response, let the native chain do everything:
    Route GET ut/game/fifa17/sbs/sets through the bridge/core with real JSON.
    The client's own dispatcher builds the response-msg (ctor 0x18017b1c0,
    deser slot +0x20 = 0x18017b2b0), seats a genuine SAX cursor, and its own
    chain populates M and arms B via the completion callback 0x1800b8c30
    (subscribed in svc ctor 0x1800b5765). No memory forging at all. NOTE: the
    front-end refuses to ISSUE the fetch offline and the "ut/%s/sbs" template
    (0x18021d908) has no native xref, so the DLL must inject the RESPONSE at the
    message-receive layer (not rely on the client to send the GET).

  Option 2 (fallback) -- drive the real parser from the hook:
    1. reg   = 0x1800d7170()                       ; -> &registry 0x1802c2988
    2. mgr   = 0x180009c80(&out, reg)              ; hashes 0xed84b11/0xed84b12
    3. build a REAL seated SAX cursor over canned sbs/sets JSON:
         ctx  = 0x1801c63e0(...)  + lexer 0x1801c8060 + an input-source object
         whose vtable[+0x8] yields your JSON bytes. A null-source cursor parses
         nothing AND the deser clears M first -> do not cold-call with null.
    4. 0x18017b2b0(rcx=ignored, rdx=cursor)        ; self-locates mgr, clears M,
         per-cat ctor 0x180159da0 / cat-deser 0x18017ab80 / finalize 0x180160e50 /
         append 0x18015a770, then store finalizers 0x180160e00 + 0x180160f30 +
         0x180161020, then commit mgr.vtable[+0x8]. Sets WORD[M+0x50]=N.
    5. arm gate: A.vtable[+0x4e8](A) -> B; set ONLY BYTE[B+0x28]=1.
         Do NOT write B+0x08 or B+0x20 (short-circuit; see isValid proof).
    6. trigger render: re-open the SBC menu, or fire refresh events 0x756c-0x7574
         so the controller re-reads WORD[M+0x50] at 0x1800b5eda.

  DO NOT: hand-build 0xf0 category / 0x3570 set records for a direct append
  (deep-copy ctor 0x18015a2b0 derefs inner EASTL sub-vectors -> heap corruption);
  skip the index-rebuild finalizers (by-index getter 0x180160a80 reads OOB);
  mutate M while the menu iterates; or run any of this off the main thread.
=============================================================================
""")


def main():
    apply = '--apply' in sys.argv
    if '--spec' in sys.argv:
        print_spec()

    pid = find_pid()
    if not pid:
        print("FIFA17.exe not running -> skipping live steps. Static analysis is "
              "authoritative; no write possible. (see --spec for the DLL plan)")
        return 0
    print("pid %d" % pid)

    base = None
    for ln in open('/proc/%d/maps' % pid):
        if 'CardsDLL' in ln:
            base = int(ln.split('-')[0], 16)
            break
    if not base:
        print("CardsDLL not mapped (client not in Ultimate Team yet). Skip live step.")
        return 0
    slide = base - 0x180000000
    print("base %#x  slide %#x" % (base, slide))

    fdr = os.open('/proc/%d/mem' % pid, os.O_RDONLY)
    rd = lambda va, n: os.pread(fdr, n, va)
    q  = lambda va: struct.unpack('<Q', rd(va, 8))[0]
    w  = lambda va: struct.unpack('<H', rd(va, 2))[0]

    # ---- prove the slide against the on-disk FNV prologue --------------------
    pe = load_pe()
    if pe is None:
        print("on-disk DLL not found (%s); cannot prove slide -> refuse." % PE_PATHS)
        os.close(fdr); return 1
    f = lambda va: va - 0x180000000 - 0x1000 + 0x400  # .text rva 0x1000 rawptr 0x400
    ctl_ok = pe[f(CTRL_VA):f(CTRL_VA)+24] == rd(CTRL_VA + slide, 24)
    print("CONTROL FNV %s" % ("MATCH" if ctl_ok else "MISMATCH -> ABORT"))
    if not ctl_ok:
        os.close(fdr); return 1

    # ---- prove the two gate facts from on-disk bytes ------------------------
    online_stub = pe[f(ONLINE_STUB):f(ONLINE_STUB)+3]
    print("online sub-check 0x1801642c0 on-disk = %s %s"
          % (online_stub.hex(), "(stubbed mov al,1;ret -- NOT the wall)"
             if online_stub == b'\xb0\x01\xc3' else "(UNEXPECTED)"))

    # ---- A root + vtable ----------------------------------------------------
    A = q(A_SINGLETON + slide)
    A_vt = q(A) - slide
    print("A(FUT root) = %#x   A.vtable %#x %s"
          % (A, A_vt, "(match)" if A_vt == A_VT_STATIC else "(MISMATCH static %#x)" % A_VT_STATIC))

    # ---- decode B offset live from A.vtable[+0x4e8] thunk -------------------
    bthunk = q((A_vt + slide) + A_VT_BGETTER)  # A.vtable slot -> thunk VA (live)
    stub = rd(bthunk, 7)
    b_off = None
    if stub[:3] == b'\x48\x8d\x81':  # lea rax,[rcx+disp32]
        b_off = struct.unpack('<i', stub[3:7])[0]
    print("A.vtable[+0x4e8] -> %#x  stub=%s  decoded B offset=%s"
          % (bthunk - slide, stub.hex(),
             hex(b_off) if b_off is not None else "?? (expected 0x1f9d8)"))
    if b_off is None:
        b_off = 0x1f9d8  # fall back to the model constant, but we warned above
    B = A + b_off

    # ---- B cache fields -----------------------------------------------------
    def show_cache(name, C, expect_vt=None):
        vt = q(C) - slide
        coll = q(C + 0x08); dl = q(C + 0x20); ready = rd(C + B_READY_OFF, 1)[0]
        tag = ""
        if expect_vt is not None:
            tag = "(match)" if vt == expect_vt else "(MISMATCH static %#x)" % expect_vt
        print("  %-4s @%#x  vt=%#x %s  coll(+8)=%#x  deadline(+0x20)=%#x  ready(+0x28)=%d"
              % (name, C, vt, tag, coll, dl, ready))
        return vt, coll, dl, ready

    print("live cache state:")
    b_vt, b_coll, b_dl, b_ready = show_cache("SBC", B, B_VT_STATIC)
    show_cache("HUB", A + HUB_OFF)
    print("  reqmgr @%#x  +0x08=%#x" % (A + REQMGR_OFF, q(A + REQMGR_OFF + 0x08)))

    # ---- M = the render source ---------------------------------------------
    M = q(A + M_CACHE_OFF)
    if M == 0:
        print("  M (render source, *(A+0x20a68)) = 0  -> NOT built yet "
              "(SBC menu not opened this session). Empty offline.")
        cat_count = 0
    else:
        cat_count = w(M + 0x50)
        print("  M (render source) = %#x  WORD[M+0x50] category count = %d  "
              "(menu would draw %d tiles)" % (M, cat_count, cat_count + 2))

    # ---- the plan / dry-run -------------------------------------------------
    print("\n-- INTERVENTION PLAN --")
    print("  Verified-safe write (this tool, --apply): BYTE @ %#x (B+0x28) = 1"
          % (B + B_READY_OFF))
    print("    effect: isValid short-circuits TRUE -> SBC menu OPENS instead of modal.")
    print("    render: EMPTY unless M is populated (tiles = WORD[M+0x50], not B).")
    print("    REFUSED here (footgun): writing B+0x08 or B+0x20 -> deadline branch,")
    print("      can return FALSE (modal) and/or crash on a bogus collection ptr.")
    print("  Populated render: needs the injected DLL to fill M (run with --spec).")

    if not apply:
        cur = rd(B + B_READY_OFF, 1)[0]
        print("\n[DRY-RUN] default mode -- no memory written. current BYTE[%#x]=%d, "
              "would set =1. Pass --apply to write (HUMAN ONLY)."
              % (B + B_READY_OFF, cur))
        os.close(fdr)
        return 0

    # ---- --apply: the single blessed byte write -----------------------------
    # re-verify EVERYTHING load-bearing before touching live memory.
    if not ctl_ok or A_vt != A_VT_STATIC or b_vt != B_VT_STATIC:
        print("\n[ABORT] slide/vtable sanity failed at write time -> refusing to write.")
        os.close(fdr); return 1
    if b_off != 0x1f9d8:
        print("\n[ABORT] B offset decoded as %s (expected 0x1f9d8) -> refusing to write."
              % hex(b_off))
        os.close(fdr); return 1

    target = B + B_READY_OFF
    before = rd(target, 1)[0]
    print("\n[APPLY] target BYTE @ %#x  before=%d" % (target, before))
    if before == 1:
        print("[APPLY] already 1 -> nothing to do (idempotent).")
        os.close(fdr); return 0
    fdw = os.open('/proc/%d/mem' % pid, os.O_WRONLY)
    n = os.pwrite(fdw, b'\x01', target)
    os.close(fdw)
    after = rd(target, 1)[0]
    print("[APPLY] wrote %d byte(s); read-back BYTE @ %#x = %d %s"
          % (n, target, after, "(OK)" if after == 1 else "(WRITE FAILED)"))
    print("[APPLY] now RE-OPEN the SBC menu. Expect: menu opens (no modal); tiles will")
    print("        be EMPTY/placeholder unless M was populated by the DLL first.")
    os.close(fdr)
    return 0


if __name__ == '__main__':
    sys.exit(main())
