diff --git a/scripts/sold-live-capture.py b/scripts/sold-live-capture.py new file mode 100755 index 0000000..74b7e5f --- /dev/null +++ b/scripts/sold-live-capture.py @@ -0,0 +1,241 @@ +#!/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())