9ffbd651b1
Turns the operator's job into "navigate, say go" and removes any chance of a half-recorded variant. One command captures and labels: the staging wire surfaces, the client's OWN auction record decoded read-only from /proc/<pid>/mem (STATE, YOURBID, COINS_AWARDED, MIN_CREDITS, IS_GLOW, INBOX, CARD_OFFERSTATE), and the staging host route-log DELTA since the last capture -- which is how a client-issued DELETE .../trade/sold gets OBSERVED rather than assumed. The part that matters is the wire-vs-memory cross-check. 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. It also recomputes the native IS_GLOW/INBOX formulas from the wire and compares them to what the client stored. Proven honest on first run: with the client attached to PRODUCTION and not on the Transfer List, it reported the staging sold row on the wire, 0 client records, and INSTRUMENTATION NOT VALIDATED -- refusing to draw a conclusion from an empty read. Two earlier sessions were misled by exactly that (a sampler bug printing "countdown NO", and auction containers read while the screen was unbound), so an empty container is explicitly not treated as an empty pile. Probe base-address discovery was separately confirmed against the live client (pid 36958, FNV control=MATCH, model resolved, containers read cleanly), and production's wire independently agreed at total=0. No production change. Client config untouched (still production Blaze ports).
242 lines
11 KiB
Python
Executable File
242 lines
11 KiB
Python
Executable File
#!/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/<pid>/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": "<no wire row>", "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": "<no memory row>", "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())
|