#!/usr/bin/env python3 """ONE-COMMAND live capture for the seller-facing sold-row A/B. Run while FIFA 17 sits on the Transfer List against the STAGING stack. Captures, labels and cross-checks everything at once, so a variant can never be half-recorded or mixed with the other: 1. staging wire surfaces (/tradePile, /tradePile/counts, /trade/status); 2. the client's OWN auction record, decoded read-only out of /proc//mem -- STATE, YOURBID, COINS_AWARDED, MIN_CREDITS, IS_GLOW, INBOX, CARD_OFFERSTATE; 3. the staging host's route-log DELTA since the previous capture, which is how a client-issued `DELETE .../trade/sold` is OBSERVED rather than assumed; 4. a WIRE-vs-MEMORY cross-check of every shared field, plus the native IS_GLOW / INBOX formulas recomputed from the wire. Point 4 is the discipline that matters. It validates the observation mechanism against a known-positive in the same run: if the wire says `bidState: "highest"` and the client's memory decodes `2(highest)`, the probe is demonstrably reading the right struct THIS time. Earlier sessions were misled twice by unvalidated negatives -- a sampler bug that printed "countdown NO", and empty auction containers read while the Transfer List was not bound. An empty read is not an empty pile. python3 scripts/sold-live-capture.py --variant A_highest --label pre-clear """ import argparse import hashlib import http.client import json import os import re import subprocess import sys import time REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) STAGING_DIR = "/home/alex/openfut-sold-staging" HOST_PORT = 8299 CLIENT = "alex@10.10.0.105" PROBE = "/tmp/auction_record_probe.py" CURSOR = os.path.join(STAGING_DIR, ".capture-cursor") FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094} SURFACES = { "tradePile": "/ut/game/fifa17/tradePile", "counts": "/ut/game/fifa17/tradePile/counts", "trade_status_unfiltered": "/ut/game/fifa17/trade/status", } TRADE_STATE = {"active": 1, "inactive": 2, "expired": 3, "closed": 4} BID_STATE = {"none": 0, "outbid": 1, "highest": 2, "buyNow": 3} ITEM_STATE = {"invalid": 0, "free": 1, "forSale": 5, "offered": 6} def get(path): assert HOST_PORT not in FORBIDDEN, "refusing to contact a production port" c = http.client.HTTPConnection("127.0.0.1", HOST_PORT, timeout=20) c.request("GET", path, headers={"X-OpenFUT-Game": "fifa17"}) r = c.getresponse() raw = r.read() c.close() body = json.loads(raw) sha = hashlib.sha256( json.dumps(body, sort_keys=True, separators=(",", ":")).encode()).hexdigest() return {"status": r.status, "body": body, "sha256": sha} def probe_client(): """Decode the client's live auction records. Never fabricates: no client, or an unbound container, is reported as-is.""" r = subprocess.run(["ssh", "-o", "BatchMode=yes", CLIENT, f"cd /tmp && python3 {PROBE} 2>&1"], capture_output=True, text=True, timeout=120) text = r.stdout + r.stderr rows = [] for m in re.finditer( r"\[\d+\]\s+rec=(\S+)\s+tradeId=(\d+)\s+state=(-?\d+)\((\w+)\)\s+" r"bid=(-?\d+)\((\w+)\)\s+buyNow=(\d+)\s+start=(\d+)\s+cur=(\d+)\s+" r"expires=(-?\d+)\s+coins=(\d+)\s+glow=(\d+)\s+inbox=(\d+)\s+watch=(\d+)" r"(?:.*?\n\s+item=\S+\s+CARD_ID=(\d+)\s+cardType=(\d+)\s+tradeable=(\d+)\s+" r"itemState=(-?\d+)\((\S+?)\)\s+rating=(\d+))?", text, re.S): g = m.groups() rows.append({ "record": g[0], "tradeId": int(g[1]), "STATE_tradeState": f"{g[2]}({g[3]})", "YOURBID_bidState": f"{g[4]}({g[5]})", "MAX_CREDITS_buyNowPrice": int(g[6]), "RESERVEDPRICE_startingBid": int(g[7]), "MIN_CREDITS_currentBid": int(g[8]), "TIME_REMAINING_expires": int(g[9]), "COINS_AWARDED_coinsProcessed": int(g[10]), "IS_GLOW": int(g[11]), "INBOX": int(g[12]), "IS_WATCHED": int(g[13]), "CARD_ID": int(g[14]) if g[14] else None, "CARD_TYPE": int(g[15]) if g[15] else None, "tradeable": int(g[16]) if g[16] else None, "CARD_OFFERSTATE_itemState": f"{g[17]}({g[18]})" if g[17] else None, "rating": int(g[19]) if g[19] else None, }) return { "client_running": "not running" not in text, "slide_control": "MATCH" if "FNV control=MATCH" in text else "UNVERIFIED", "rows": rows, "raw": text.strip(), } def route_delta(): """Staging host log lines since the previous capture: how a client-issued DELETE is observed rather than inferred.""" log = os.path.join(STAGING_DIR, "logs", "utas-host.log") if not os.path.exists(log): return {"available": False, "lines": []} start = 0 if os.path.exists(CURSOR): try: start = int(open(CURSOR).read().strip()) except Exception: start = 0 lines = open(log, errors="replace").read().splitlines() with open(CURSOR, "w") as f: f.write(str(len(lines))) new = lines[start:] return { "available": True, "from_line": start, "to_line": len(lines), "lines": new, "requests": [l for l in new if "route=" in l], "clear_or_delete": [l for l in new if "clear-sold" in l or "market-cancel" in l], } def cross_check(wire_row, mem_rows): out = [] if not wire_row: return [{"field": "", "ok": False, "note": "nothing on the wire to compare against"}] mem = next((m for m in mem_rows if m["tradeId"] == wire_row.get("tradeId")), None) if mem is None: return [{"field": "", "ok": False, "note": "client holds no record for this tradeId -- it is probably not " "on the Transfer List screen. An empty container is NOT an " "empty pile; re-navigate and re-capture."}] def cmp(field, wire_val, mem_val, expect=None): ok = (mem_val == expect) if expect is not None else (wire_val == mem_val) out.append({"field": field, "wire": wire_val, "memory": mem_val, "ok": ok}) ts, bs = wire_row.get("tradeState"), wire_row.get("bidState") cmp("tradeState/STATE", ts, mem["STATE_tradeState"], f"{TRADE_STATE.get(ts)}({ts})") cmp("bidState/YOURBID", bs, mem["YOURBID_bidState"], f"{BID_STATE.get(bs)}({bs})") cmp("currentBid/MIN_CREDITS", wire_row.get("currentBid"), mem["MIN_CREDITS_currentBid"]) cmp("buyNowPrice/MAX_CREDITS", wire_row.get("buyNowPrice"), mem["MAX_CREDITS_buyNowPrice"]) cmp("startingBid/RESERVEDPRICE", wire_row.get("startingBid"), mem["RESERVEDPRICE_startingBid"]) cmp("expires/TIME_REMAINING", wire_row.get("expires"), mem["TIME_REMAINING_expires"]) cmp("coinsProcessed/COINS_AWARDED", wire_row.get("coinsProcessed"), mem["COINS_AWARDED_coinsProcessed"]) istate = wire_row.get("itemData", {}).get("itemState") cmp("itemState/CARD_OFFERSTATE", istate, mem["CARD_OFFERSTATE_itemState"], f"{ITEM_STATE.get(istate)}({istate})" if istate else None) exp_glow = int(bs != "none") if ts == "closed" else int(bs in ("outbid", "buyNow")) exp_inbox = int(bs in ("highest", "buyNow")) out.append({"field": "IS_GLOW (native formula)", "expected": exp_glow, "memory": mem["IS_GLOW"], "ok": mem["IS_GLOW"] == exp_glow}) out.append({"field": "INBOX (native formula)", "expected": exp_inbox, "memory": mem["INBOX"], "ok": mem["INBOX"] == exp_inbox}) return out def main(): ap = argparse.ArgumentParser() ap.add_argument("--variant", required=True, help="A_highest | B_buyNow | C_cp0 | D_cp1") ap.add_argument("--label", default="capture", help="pre-clear | post-clear | reentry") ap.add_argument("--out", default=os.path.join( REPO, "docs/evidence", f"sold-ab-{time.strftime('%Y-%m-%d')}")) args = ap.parse_args() os.makedirs(args.out, exist_ok=True) print("=" * 74) print(f"== LIVE CAPTURE variant={args.variant} label={args.label}") print("=" * 74) banner = "" hlog = os.path.join(STAGING_DIR, "logs", "utas-host.log") if os.path.exists(hlog): for l in open(hlog, errors="replace"): if "sold-experiment" in l: banner = l.strip() print(f" host banner : {banner or '(none)'}") surfaces = {n: get(p) for n, p in SURFACES.items()} rows = surfaces["tradePile"]["body"].get("auctionInfo", []) wire_row = rows[0] if rows else {} print(f" wire : total={surfaces['tradePile']['body'].get('total')} " f"tradeState={wire_row.get('tradeState')} bidState={wire_row.get('bidState')} " f"coinsProcessed={wire_row.get('coinsProcessed')}") print(f" counts : {json.dumps(surfaces['counts']['body'])}") mem = probe_client() print(f" client : running={mem['client_running']} slide={mem['slide_control']} " f"records={len(mem['rows'])}") for r in mem["rows"]: print(f" tradeId={r['tradeId']} STATE={r['STATE_tradeState']} " f"YOURBID={r['YOURBID_bidState']} " f"COINS_AWARDED={r['COINS_AWARDED_coinsProcessed']} " f"MIN_CREDITS={r['MIN_CREDITS_currentBid']} IS_GLOW={r['IS_GLOW']} " f"INBOX={r['INBOX']} CARD_OFFERSTATE={r['CARD_OFFERSTATE_itemState']}") xc = cross_check(wire_row, mem["rows"]) print(" cross-check (wire vs the client's own memory):") for c in xc: mark = "ok " if c.get("ok") else "FAIL" print(f" [{mark}] {c['field']:26s} " f"wire/exp={c.get('wire', c.get('expected'))!r} mem={c.get('memory')!r} " f"{c.get('note', '')}") validated = bool(mem["rows"]) and all(c.get("ok") for c in xc) print(f" INSTRUMENTATION {'VALIDATED' if validated else 'NOT VALIDATED'} this run") routes = route_delta() print(f" route delta : lines {routes.get('from_line')}..{routes.get('to_line')}, " f"{len(routes.get('requests', []))} request line(s)") for l in routes.get("requests", [])[-30:]: print(f" {l}") if routes.get("clear_or_delete"): print(" *** CLEAR/DELETE observed:") for l in routes["clear_or_delete"]: print(f" *** {l}") snap = {"variant": args.variant, "label": args.label, "captured": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "host_banner": banner, "surfaces": surfaces, "client_memory": mem, "cross_check": xc, "instrumentation_validated": validated, "route_delta": routes} path = os.path.join(args.out, f"live-{args.variant}-{args.label}.json") with open(path, "w") as f: json.dump(snap, f, indent=2, sort_keys=True) print(f"\n wrote {os.path.relpath(path, REPO)}") return 0 if __name__ == "__main__": sys.exit(main())