18 Commits

Author SHA1 Message Date
OpenFUT Dev 75148fe435 chore(fifa17-client): bump launcher submodule to preserved hook WIP (f16828a); ignore .bak 2026-08-20 09:13:16 -07:00
OpenFUT Dev 6b8b8e052f chore(fifa17-client): preserve .105 client tooling WIP; gitignore local .screens 2026-08-20 09:12:06 -07:00
funman300 3153a93edf fifa17-recon: drop superseded docker-side tools/data copies
fifa17-recon/tools (authoritative) and fifa17-recon/data now feed the Docker
build directly via the curated runtime-tools.list manifest. The duplicated
fifa17-python/tools+data are removed so the repo has a single source of truth;
the rebuilt openfut-fut-backend:dev image is byte-identical to the previous
deployment (verified: manifest diff empty, 446/446 contract checks pass).
2026-08-10 17:21:58 -07:00
funman300 f64106ed8b fifa17-recon: fix compose dockerfile path for relocated build context 2026-08-10 17:20:27 -07:00
funman300 9faaf12dd7 fifa17-recon: Docker build consumes authoritative tools via curated manifest
Build context moves from docker/fifa17-python/ up to fifa17-recon/ so the
Dockerfile reads the single-source tools/ and data/ trees. Only the 77 runtime
files listed in runtime-tools.list are installed into /app/tools (baseline image
minus the two git-ignored certs, regenerated in-image). memdump and recon
artifacts are excluded via fifa17-recon/.dockerignore.
2026-08-10 17:19:07 -07:00
funman300 83539e33ec fifa17-recon: take running-backend versions of 8 runtime files (direction fix)
The earlier reconcile committed the local working-tree versions of these
files, which are OLDER than the deployed backend. The running container (C)
is byte-identical to docker/fifa17-python/tools (B) and is a strict superset:
it adds profile_path_for/select_account/ensure_security_question (fut_store),
safe_header_for_log/safe_request_path/security_question_route (utas_server),
account_sync_route/_match_call/match_ready_body, plus POW balance fields and
match lifecycle support, with zero unique local functions lost.

Reconciled tree is now a strict superset of B with every shared file
byte-identical; verified via md5 map (0 missing, 0 differing).
2026-08-10 17:12:27 -07:00
funman300 695421cfd4 Merge remote-tracking branch 'origin/main' into fifa17-fut-squad-and-userinfo 2026-08-10 17:08:08 -07:00
funman300 8cba70dc90 fifa17-recon: reconcile authoritative tools with running backend (B)
- Add 8 files present in docker/fifa17-python/tools but missing from the
  top-level tree: fut_accounts.py + 7 test_*.py contracts (all committed in
  the server's docker tree; byte-identical to the running image).
- Preserve newer responder work already matching the running container:
  utas_server.py (offlineSeason), lsx_responder_v2.py (OPENFUT_BIND),
  blaze_responder_v3b.py, autopatch.py, pow_server.py, fut_store.py,
  test_fut_contract.py, fifa17-hook-m1.sh.
- Add 30 newer ghidra_queries (draft purchase/state, SBC 9-26, runtime
  registries). Local tree is now a strict superset of B with all shared
  files byte-identical.
2026-08-10 17:08:06 -07:00
root 28773e7cf1 fifa17-python: sync tools to running container state
The frozen baseline image predates two hot-patches made in the running
container after build:
* utas_server.py: FUT_MODES-gated offlineSeason block in GetHubData's club
  response (keeps the offline-season summary valid)
* test_hub_offline_season_contract.py added to /app/tools

Sync fifa17-python/tools to the running container (verified byte-identical,
237 files incl. the redir cert pair) and snapshot the live FS as
openfut-fut-backend:python-running-2026-08-10 (docker commit). A fresh build
from the committed sources now reproduces the running backend exactly
(baked SHA256SUMS.txt diffed against the container manifest: identical).
2026-08-10 23:56:58 +00:00
root 3ae5587a38 docs: baseline manifest equivalence note (pycache + cert deltas expected) 2026-08-10 23:54:59 +00:00
root 70a64e3709 fifa17-python: commit working FUT backend deployment (client/server split)
Freeze the running offline FUT backend into version control as
fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a
fresh checkout:

* OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders
  (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is
  required for remote mode (compose and entrypoint fail without it)
* docker-compose.yml reproducing the frozen baseline container exactly
  (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart)
* .env.example / .env for site config - the LAN IP is never hardcoded in source
* tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10,
  verified byte-identical to the running container at freeze time
* client_arm.sh (the 105 client-side arming counterpart)
* Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying
* docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record,
  restore instructions and rebuild-equivalence procedure

Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored.
The live container is untouched pending the .105 launcher audit.
2026-08-10 23:54:04 +00:00
funman300 622a774f6a chore: update openfut-launcher submodule to feat/sbc-hook-tracing branch
Tracks SBC hook tracing PR #1 for FIFA 17 reverse-engineering
2026-08-08 17:50:53 -07:00
funman300 cc694774a3 wip: checkpoint FIFA 17 SBC research for Windows migration 2026-08-07 12:03:22 -07:00
funman300 3d3239bab9 feat: document and stage FIFA 17 SBC hook workflow 2026-08-07 11:44:05 -07:00
funman300 a7e3e43ae9 fifa17-recon: the refusing modes have no server fix, and the hub-atom lead is cosmetic too
Completed the refusing-modes workflow (ground truth + 4 per-mode investigations +
adversarial verify each + synthesis). All four mode families -- Seasons, Draft,
SBC/Objectives, Tournaments -- are NOT_SERVER_REACHABLE, HIGH confidence, all four
adversarial refutations failed.

Live re-confirmed on pid 24653 (slide proven via FNV control): every named
mode-gating byte reads ENABLED=1 (IS_FRIENDLY_SEASON_ENABLED +0x1fd3a,
IS_TOURNAMENT_QUIT_ENABLED +0x1fd3b, IS_DRAFT_MODE_ENABLED +0x1fd3d, plus the
unnamed offline-draft-enable +0x1fd3e) yet the tiles stay greyed.

The new lead this pass added -- do the six /hub mode sub-objects gate availability?
-- is refuted: friendlySeason/offlineSeason/onlineSeason/draftSummary/tournament/
tournamentProgress carry only stats and display strings, no enabled/available/
unlocked atom. They are cosmetic, exactly like hub.tradePile. The one
server-writable input that exists (friendlySeasonsEnabled -> +0x1fd3a via applier
FUN_18011dc50) has its sole reader in the packed FIFA17.exe front-end via a vtable
getter with no CardsDLL caller, and it is already 1. The refusal is decided in the
Denuvo-packed Frostbite front-end, which has no server surface.

docs/plan-2026-08-06-refusing-modes.md: full evidence chains, gate-byte table, the
six sub-deser field maps, per-mode verdicts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrx9to3pihN6Sm9sXgc8np
2026-08-06 18:59:23 -07:00
funman300 31fc590b99 fifa17-recon: the FUT-hub Transfer List tile counts, and the hub parser is NOT reflection
The Transfer List hub tile read "0 items / Selling 0" while a card was actively
listed. Enumerating the /hub parser FUN_180139610 straight from the on-disk
CardsDLL (objdump) refutes the old ENDPOINT_MAP claim that it uses C++ reflection
with "no atom ladder, nothing to enumerate": it has an ordinary running-sum atom
ladder reading 18 atoms. The tile is fed by hub.tradePile (0x333), a nested object
(sub-deser 0x18013ead0) reading count/selling/sold as scalar ints -- the same
scheme as GetAuctionCount, so serving it in the hub body is freeze-safe. The tile
never re-polls the standalone /tradePile/counts, which is why fixing that endpoint
alone did not move the tile.

Also: the hub tile polls LOWERCASE tradepile/counts while the Transfer List screen
uses camelCase tradePile; our case-sensitive routes matched only the screen, so the
tile's counts call fell through to /trade and got a shape the counts deser skips.
Made the tradePile routes case-insensitive.

And bake the proven transfer-market flags (FUT_TRADING/PILESIZES/TRADEABLE/
DISCARD_TABLE/DISCARD_SEND) into openfut-fut.sh so a plain `start` brings up the
working state instead of regressing trading to greyed-out.

- tools/utas_server.py: hub_data() serves tradePile:{count,selling,sold};
  tradePile routes now re.I
- tools/openfut-fut.sh: utas launched with the working flag set
- docs/ENDPOINT_MAP.md: full 18-atom hub map + tile map, correction of the
  reflection claim
- tools/ghidra_queries/objdump_atom_ladder.py: the objdump-based atom-ladder
  decoder used to derive the above

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrx9to3pihN6Sm9sXgc8np
2026-08-06 18:28:52 -07:00
funman300 245c22161b fifa17-recon: correct tradePile/counts shape, and narrow the marketdata array fix
Two follow-ups on the working transfer market.

1. GET /tradePile/counts now returns the FutGetAuctionCount shape
   ({count, maxAuctionsAllowed, offered, selling, sold}, all scalar ints, atoms
   0xbc/0x1bf/0x1e5/0x2b8/0x2c9) via a dedicated route ordered before /tradePile.
   Previously it fell through to tradepile_route and got the auction-LIST body, which
   the counts deser skips, leaving every tally at its constructor default. Survivable
   but wrong; the doc flags the loaded byte at +0x28 as gating a completion-handler
   branch. selling reflects real STORE.listings().

2. Narrowed the marketdata bare-array fix to /pricelimits only. The client sends TWO
   marketdata requests: /marketdata/pricelimits (GetSuggestedPricing, a bare array,
   the thing that froze) and plain /marketdata?defId=N (price comparison, an OBJECT).
   The prior commit returned the array for both, which the contract suite caught
   (test_market_bodies: 'list' has no attribute get) -- plain /marketdata wants
   {minPrice,maxPrice} and was never the freeze. Returning the array for it would be
   the same desync in reverse. Now: pricelimits -> array, plain marketdata -> object.

The contract suite catching my over-broadened fix before it reached the game is the
suite doing its job. 439 contract checks pass, market unit suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:39:38 -07:00
funman300 43557989f5 fifa17-recon: the transfer market works -- listed a card end to end, no freeze
The subsystem that was fully greyed-out this morning now lists a card on the transfer
market: price screen, Submit, "your item is now up for trade", TRANSFER LIST 0/100,
auctionCount 1, and STORE.listings() holds the auction. Every step verified at the
instruction level first, then confirmed live. Three fixes, all behind flags, all off by
default until this run proved them.

1. WE WERE BANNING OUR OWN TRADING. userInfo.feature (atom 0x11c) is a RESTRICTION map,
   not a grant; we sent feature={"trade":true}, which is a trade BAN. Verified in
   q_feature_trade.py: FUN_18013ec10 parses feature/trade into userInfo+0x17c, and at
   the massinfo END_OBJECT the client runs
     cmp byte [rsi+0x17c],0 / jz skip / mov dword [rsi+0x50],0
   feeding applier 0x18011dc91 -> IS_TRADING_ENABLED (model+0x1fd2e) = 0. It runs LAST
   and unconditionally, which is why the gate read 0 all day regardless of /settings or
   the Blaze config store. FUT_TRADING sends feature={} instead. Live: gate flipped
   0 -> 1 on UT re-entry (model rebuilt, pointer changed, byte read 1).

2. TRANSFER LIST CAPACITY 0/0. pileSizeClientData (massinfo atom 0x227, parser
   0x18013adb0) is the capacity, NOT the "MY CLUB counter" the old comment claimed.
   Verified in q_pilesize_keys.py: exactly two storing arms, key 2 -> model+0x1fd1c
   (TRADE_PILE_SIZE) and key 4 -> +0x1fd20 (watch list), every other key SKIP'd. The old
   code would have sprayed the 246 club count into the capacity. FUT_PILESIZES sends
   key 2 = 100, key 4 = 50. Live: capacity read 0 -> 100, header showed 0/100.

3. THE PRICE SCREEN FROZE THE CLIENT. GET marketdata/pricelimits was answered with an
   OBJECT {minPrice,maxPrice}; the deser 0x180163ee0 reads a BARE TOP-LEVEL ARRAY
   (root loop while tok != 0xd), so object-where-array desynced the SAX reader into the
   0x1801c7f1a busy loop (confirmed live: utime climbing 227 ticks/s, core pinned).
   Verified in q_pricelimits.py: element fields defId 0xcf, maxPrice 0x1c2, minPrice
   0x1ca, all scalar ints. marketdata_route now returns a bare array, one element per
   requested defId. Live: price screen opened and Submit succeeded.

Corrected along the way, all now in the code: two prior "trading root causes" from
earlier today were wrong (the Blaze IS_TRADING_ENABLED keys are output-only names, and
the applier is a virtual method at vtable+0x988, not unreachable). Those refutations are
recorded in blaze_responder_v3b.py and the doc.

Also lands the transfer-market recon doc (plan-2026-08-06-transfer-market.md) and the
market Ghidra query set.

Server-authoritative economy note: the 5% transfer fee and the price bands (currently a
150..15000 placeholder per defId) are not yet real; that is refinement, not a freeze.
The live-auction market SCREEN ("List on Transfer Market" browse) is a separate surface
still to do (P4 auction-counts route, P5 empty market bodies).

Live: 439 contract checks pass. Card listed and persisted, auctionCount 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:33:10 -07:00
174 changed files with 18135 additions and 227 deletions
+9
View File
@@ -27,3 +27,12 @@ __pycache__/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Frozen baseline archives / inspects / manifests
/docker-backups/
# local dev screenshots (not versioned)
fifa17-recon/.screens/
# local hook backup
*.pre-storeguard.bak
Generated
+6485
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
[workspace]
resolver = "2"
members = [
"openfut-core",
"openfut-bridge",
"openfut-launcher",
"openfut-launcher/openfut-hook",
"fifa-blaze/crates/blaze-proto",
"fifa-blaze/crates/server",
]
+45
View File
@@ -263,3 +263,48 @@ Both matter beyond themselves, because they are the only two routes into a match
Useful framing: this project's failures have almost always come from proposing a fix Useful framing: this project's failures have almost always come from proposing a fix
before testing the assumption under it. Hypotheses that come with a cheap way to before testing the assumption under it. Hypotheses that come with a cheap way to
disconfirm them are worth far more than plausible ones. disconfirm them are worth far more than plausible ones.
## FIFA 17 network-redirect milestone (2026-08-09)
Hook now installs a GENERIC network redirect on the fifa17 feature path (fifa17.rs
install_network_redirect): getaddrinfo IAT patch + inline connect detour + WSAConnect
IAT, with a configurable destination (connect_hook::set_target_ipv4) read from
openfut.cfg (single-line IP). Deployed DLL md5 bc9e0bc6, cfg=10.10.0.120.
RESULT of live launch (client 105 -> server 120):
- Error changed: "servers shut down" -> "Unable to connect to EA servers / check
network". Redirect IS firing (progress).
- BLOCKER A: getaddrinfo IAT patched 0+0 -> FIFA 17 does NOT resolve via IAT
getaddrinfo in the main exe or EAWebKit.dll. Names resolved via another path
(gethostbyname or internal DirtySDK resolver). So no hostname reached 120.
- BLOCKER B (architectural): FIFA 17 online = Blaze binary TCP on high ports. Log
shows connect 20.51.153.159:42230 sock_type=1 -> wsa_err=10035 (WOULDBLOCK->dead).
Port 42230 is NOT in the remap set (443,10041,42127,3216) so it was not redirected.
Even if redirected, the Docker bridge only speaks HTTPS on 8443 -- no Blaze
listener exists for FIFA 17. This is a server-side build, not a hook tweak.
NEXT (evidence-first): add gethostbyname (and possibly a DirtySDK resolver) capture
to learn the hostname behind 20.51.153.159; widen Blaze port remap; then scope a
Blaze-speaking bridge listener before expecting the error to clear.
## DNS/getaddrinfo fix — RESOLVED (2026-08-09, hook md5 67e3639b)
Added src/resolver_hook.rs: INLINE detours at ws2_32 export addresses for
getaddrinfo + GetAddrInfoW + gethostbyname (same unhook/rehook pattern as
connect_hook). Replaces the IAT approach that patched 0 slots on FIFA 17.
Wired into fifa17.rs install_network_redirect; hooks.rs gained redirect_ip_cstr()
and redirect_ip_str() helpers.
LIVE RESULT (client 105 -> server 120):
- resolver detours 3/3 installed.
- getaddrinfo(winter15.gosredirector.ea.com) -> redirect. Game now dials
10.10.0.120 (was 20.51.153.159 before). DNS BLOCKER A = SOLVED.
REMAINING BLOCKER B (architectural, NOT DNS): FIFA 17 online = EA Blaze binary
TCP. Game connects 10.10.0.120:42230 (gosredirector/Blaze redirector) ->
wsa_err=10035 (nothing listening). Two gaps: (1) connect_hook remap set lacks
42230; (2) even remapped, the Docker bridge only serves HTTPS on 8443 — no Blaze
listener exists. Clearing Unable to connect requires a Blaze redirector+main
server on the bridge side (real server build), not a hook change.
NOTE: the 3s TLS-handshake-EOF spam in bridge logs on :8443 is the LAUNCHER health
poller, not the game.
+340
View File
@@ -0,0 +1,340 @@
{
"metadata": {
"reportDate": "2026-07-28",
"codebaseName": "OpenFUT",
"version": "0.1.0",
"submodulesCovered": [
"openfut-core",
"openfut-bridge",
"openfut-launcher"
],
"language": "Rust",
"framework": "Axum + SQLite"
},
"vulnerabilities": [
{
"severity": "critical",
"category": "authentication",
"file": "openfut-core/src/services/profile.rs",
"line": 8,
"cwe": "CWE-287",
"title": "Missing Authentication on All Endpoints",
"description": "No authentication or authorization checks on any API endpoint. The system uses single-profile design with get_active_profile() returning the first row (LIMIT 1) without any token validation, session management, or per-user isolation. In a networked context, any HTTP client can access all endpoints without credentials.",
"impact": "Complete compromise of data confidentiality and integrity. Any attacker can view, modify, or delete all user data without authentication.",
"exploitPath": "curl http://127.0.0.1:8080/clubs - accesses club data without any auth headers or tokens",
"recommendation": "Implement stateless JWT tokens or session-based authentication. Add middleware to validate tokens on all endpoints. Implement per-user authorization checks in services."
},
{
"severity": "critical",
"category": "injection",
"file": "openfut-core/src/routes/auth.rs",
"line": 87,
"cwe": "CWE-89",
"title": "SQL Injection via String Interpolation",
"description": "SQL table names are interpolated using string formatting: sqlx::query(&format!(\"DELETE FROM {table}\")). Although currently hardcoded in a loop, this violates parameterized query principles and creates a risk if the table list ever becomes user-controlled or the pattern is copied elsewhere.",
"impact": "Potential remote code execution via database manipulation. If extended to user input, attackers could modify arbitrary tables or drop the database.",
"exploitPath": "Currently mitigated by hardcoded table names, but the pattern is dangerous and violates secure coding practices.",
"recommendation": "Use SQLx's dynamic query builders or identifier types that properly escape table/column names. Replace format! string interpolation with sqlx::query_builder for dynamic identifiers."
},
{
"severity": "high",
"category": "configuration",
"file": "openfut-bridge/src/proxy.rs",
"line": 44,
"cwe": "CWE-295",
"title": "TLS Certificate Validation Disabled",
"description": "HTTP client explicitly disables TLS certificate validation: .danger_accept_invalid_certs(true). This bypasses all certificate pinning, expiration, and hostname verification, making the bridge vulnerable to man-in-the-middle attacks.",
"impact": "Attacker positioned between bridge and upstream can intercept, modify, or read all traffic. Compromises confidentiality and integrity of requests to Core and external services.",
"exploitPath": "MITM attack between openfut-bridge and openfut-core or upstream services. ARP spoofing on localhost subnet would redirect traffic.",
"recommendation": "Remove .danger_accept_invalid_certs(true) in production. If testing requires it, gate behind a development-only environment variable with strong warning. Use proper certificate management (CA bundles, cert pinning)."
},
{
"severity": "high",
"category": "dos",
"file": "openfut-core/src/services/season.rs",
"line": 23,
"cwe": "CWE-248",
"title": "Unguarded expect() Causes Denial of Service",
"description": "Multiple unchecked expect() calls that will panic and crash the server if database queries fail or return unexpected results: Ok(fetch(pool, profile_id).await?.expect(\"just inserted\"))",
"impact": "Denial of service. A single database inconsistency or race condition crashes the entire server, making the application unavailable.",
"exploitPath": "Trigger race conditions during concurrent requests (e.g., rapid profile deletion + season fetch). Database corruption or migration failure crashes the service immediately.",
"recommendation": "Replace expect() with proper error handling (Result types, error logging, graceful degradation). Handle database query failures without panicking. Add integration tests for race conditions."
},
{
"severity": "high",
"category": "dos",
"file": "openfut-core/src/services/season.rs",
"line": 69,
"cwe": "CWE-248",
"title": "Unguarded expect() in season fetch",
"description": "let season = fetch(pool, profile_id).await?.expect(\"season must exist\"); Panics if season is not found.",
"impact": "Server crash on missing or deleted season records.",
"exploitPath": "Delete a season via concurrent requests, then call /seasons endpoint. Server panics.",
"recommendation": "Return proper error (AppError::NotFound) instead of panicking."
},
{
"severity": "high",
"category": "dos",
"file": "openfut-core/src/services/season.rs",
"line": 144,
"cwe": "CWE-248",
"title": "Unguarded expect() in season update",
"description": "let updated = fetch(pool, profile_id).await?.expect(\"season must exist\");",
"impact": "Server crash on concurrent season modifications.",
"exploitPath": "Rapid concurrent season updates that fail race conditions.",
"recommendation": "Handle missing records gracefully."
},
{
"severity": "high",
"category": "cors",
"file": "openfut-core/src/app.rs",
"line": 257,
"cwe": "CWE-346",
"title": "Permissive CORS Configuration Allows All Origins",
"description": ".layer(CorsLayer::permissive()) enables CORS for all origins (*), methods, and headers. Any website can make cross-origin requests to the API and access/modify data.",
"impact": "Cross-site request forgery (CSRF) attacks. Malicious websites can issue API requests on behalf of users. Data exfiltration via JavaScript from any origin.",
"exploitPath": "Attacker website:\n <img src=\"http://127.0.0.1:8080/clubs\" />\n Fetch API calls to delete profiles, modify squads, etc.",
"recommendation": "Restrict CORS to specific origins (e.g., localhost:3000 for web UI, or the game process if exposed). Use CorsLayer::very_restrictive() as default and explicitly allowlist origins."
},
{
"severity": "high",
"category": "dos",
"file": "openfut-bridge/src/proxy.rs",
"line": 47,
"cwe": "CWE-248",
"title": "HTTP Client Construction Panic",
"description": ".expect(\"failed to build HTTP client\") will panic if the HTTP client fails to initialize, crashing the entire proxy service on startup.",
"impact": "Service unavailability. Bridge cannot start if HTTP client configuration is invalid.",
"exploitPath": "Invalid system configuration or missing TLS libraries causes HTTP client build to fail, crashing bridge during startup.",
"recommendation": "Return Result<ProxyState, Error> from new() and handle construction errors. Use anyhow::Context for better error messages."
},
{
"severity": "medium",
"category": "information-disclosure",
"file": "openfut-core/src/error.rs",
"line": 54,
"cwe": "CWE-209",
"title": "Error Messages Leak Implementation Details",
"description": "JSON parsing errors are returned directly to clients: format!(\"json parse error: {e}\"). Exposes serde_json parser internals and syntax details useful for crafting attacks.",
"impact": "Information disclosure. Attackers learn the JSON parser implementation and can tailor payloads to bypass validation or find parser-specific quirks.",
"exploitPath": "Send malformed JSON to any endpoint. Response includes parser error details (e.g., 'expected `,` at line 2 col 5') that aid in crafting exploits.",
"recommendation": "Return generic error message to clients: 'invalid request format'. Log detailed errors internally with tracing for debugging."
},
{
"severity": "medium",
"category": "information-disclosure",
"file": "openfut-core/src/error.rs",
"line": 40,
"cwe": "CWE-215",
"title": "Database Errors Logged with Full Details",
"description": "Database errors are logged with full SQL/query details: tracing::error!(\"Database error: {e}\"). If logs are exposed or compromised, schema, query patterns, and data structure are revealed.",
"impact": "Information disclosure in logs. Compromised log files expose database schema and query logic useful for SQL injection or data exfiltration planning.",
"exploitPath": "Access server logs (via log aggregation service, file access, etc.) and extract database schema and query patterns.",
"recommendation": "Log only error type and ID to clients. Sanitize logs before exporting. Use structured logging with field masking for queries."
},
{
"severity": "medium",
"category": "input-validation",
"file": "openfut-core/src/routes/auth.rs",
"line": 17,
"cwe": "CWE-1025",
"title": "Hardcoded Default Credentials",
"description": "Default username 'Player 1' is hardcoded with no unique identifier enforcement. Multiple profiles can be created with identical usernames, and weak defaults are used.",
"impact": "Weak account creation, potential for account confusion or conflicts. No strong identity guarantees.",
"exploitPath": "Multiple users create profiles with default 'Player 1' username. No way to distinguish profiles programmatically.",
"recommendation": "Require explicit username on profile creation. Use UUIDs as primary identifiers. Validate username uniqueness and minimum length."
},
{
"severity": "medium",
"category": "input-validation",
"file": "openfut-core/src/services/",
"line": 0,
"cwe": "CWE-400",
"title": "Missing Input Length Validation",
"description": "No maximum length checks on string fields (usernames, club names, squad names, etc.). Large inputs can cause database bloat, memory exhaustion, or DoS.",
"impact": "Denial of service via large payloads. Database bloat. Memory exhaustion. While DefaultBodyLimit::max(256KB) provides some protection, field-level validation is missing.",
"exploitPath": "POST /auth/local with username = 256KB string. Database receives bloated data. Repeated calls exhaust storage.",
"recommendation": "Add input validation for all user-submitted strings. Set maximum lengths (e.g., username: 50 chars, club name: 100 chars). Validate at route handler level."
},
{
"severity": "medium",
"category": "configuration",
"file": "openfut-core/src/db.rs",
"line": 13,
"cwe": "CWE-315",
"title": "Unencrypted SQLite Database on Disk",
"description": "SQLite database file (openfut.db) is stored unencrypted on disk. All user data, profiles, squads, cards, etc., are readable by anyone with filesystem access.",
"impact": "Data breach if server filesystem is compromised. No protection against:local file access, stolen backups, forensic recovery.",
"exploitPath": "Attacker gains filesystem access (compromised server, stolen disk). Reads openfut.db directly. All game data is readable without authentication.",
"recommendation": "Use SQLite encryption (e.g., sqlcipher crate) or migrate to PostgreSQL with TLS. Implement file-level encryption. Use restrictive filesystem permissions (0600)."
},
{
"severity": "medium",
"category": "rate-limiting",
"file": "openfut-core/src/app.rs",
"line": 0,
"cwe": "CWE-770",
"title": "No Rate Limiting on Endpoints",
"description": "No per-IP or per-user rate limiting. Endpoints like POST /auth/reset can be called repeatedly without restriction, allowing attackers to repeatedly wipe all data.",
"impact": "Denial of service and data destruction. Attacker can spam /auth/reset to destroy user data or exhaust server resources.",
"exploitPath": "for i in 1..1000: POST /auth/reset with confirm='reset'. All data wiped repeatedly.",
"recommendation": "Implement rate limiting middleware using tower_governor or similar. Add per-IP limits (e.g., 10 requests/min) and per-endpoint limits. Use exponential backoff."
},
{
"severity": "low",
"category": "audit-logging",
"file": "openfut-core/src/services/",
"line": 0,
"cwe": "CWE-778",
"title": "Missing Audit Logging",
"description": "No audit trail of user actions (profile creation, data deletion, squad modifications). Cannot detect unauthorized access, data tampering, or compliance violations.",
"impact": "Incident response and forensics are impossible. Cannot determine who did what and when. Compliance risks (GDPR, etc.).",
"exploitPath": "Attacker deletes all profiles, modifies squads. No audit log shows what happened or who did it.",
"recommendation": "Add audit logging for all data mutations. Log: timestamp, user (profile) ID, action, resource affected, before/after state. Store in separate immutable table."
},
{
"severity": "low",
"category": "dependencies",
"file": "openfut-bridge/Cargo.toml",
"line": 0,
"cwe": "CWE-1035",
"title": "Older Dependency Versions (reqwest, rustls)",
"description": "openfut-bridge uses reqwest 0.11 (latest is 0.12) and rustls 0.21 (latest is 0.23). Intentional for version matching, but creates a larger surface area for known CVEs.",
"impact": "Potential vulnerabilities in older dependencies. Delayed access to security patches.",
"exploitPath": "Known CVE in reqwest 0.11 or rustls 0.21 could be exploited. Combined with danger_accept_invalid_certs, TLS bypass becomes easier.",
"recommendation": "Upgrade dependencies to latest versions when possible. Monitor CVE databases (CVE, RustSec) for the versions in use. Pin versions and set up automated dependency updates."
},
{
"severity": "low",
"category": "error-handling",
"file": "openfut-core/src/app.rs",
"line": 256,
"cwe": "CWE-248",
"title": "Body Size Limit Without Per-Field Validation",
"description": "DefaultBodyLimit::max(256KB) limits the entire request body, but individual fields are not validated. A single large field can consume most of the limit.",
"impact": "Mild DoS. Large field values cause database bloat. Not a critical issue due to body limit, but field-level validation would be better.",
"exploitPath": "POST /auth/local with 250KB club_name field. Database receives bloated data.",
"recommendation": "Add per-field validation in addition to body limits. Validate and sanitize fields before database insertion."
}
],
"riskScore": 82,
"riskCategory": "CRITICAL",
"riskSummary": "OpenFUT has critical security issues that would make it unsafe for production or networked deployment. The most severe are the complete absence of authentication/authorization and the SQL injection pattern in the auth.rs module. The system is designed as single-player (single-profile) with no multi-tenant isolation, which is dangerous if exposed to the network.",
"recommendations": [
{
"priority": "CRITICAL",
"area": "Authentication & Authorization",
"recommendation": "Implement JWT-based or session-based authentication on all endpoints. Add middleware to validate auth tokens on every request. Implement per-profile authorization checks. Currently any HTTP client can access all endpoints.",
"effort": "High",
"impact": "Blocks all data breaches from unauthenticated access"
},
{
"priority": "CRITICAL",
"area": "SQL Injection Prevention",
"recommendation": "Replace sqlx::query(&format!(...)) in auth.rs:87 with proper parameterized identifiers. Use sqlx::query_builder for dynamic table/column names instead of string interpolation.",
"effort": "Low",
"impact": "Prevents SQL injection even if pattern is copied to user input"
},
{
"priority": "HIGH",
"area": "TLS & Transport Security",
"recommendation": "Remove .danger_accept_invalid_certs(true) from proxy.rs:44. If development requires it, gate behind an environment variable (e.g., DEV_SKIP_TLS_VERIFICATION) with strong warnings in logs.",
"effort": "Low",
"impact": "Prevents MITM attacks on bridge-to-core communication"
},
{
"priority": "HIGH",
"area": "Error Handling",
"recommendation": "Replace all expect() calls with proper Result handling. Use anyhow::Context or custom error types. Add logging for debugging but return generic errors to clients.",
"effort": "Medium",
"impact": "Prevents DoS via server panics"
},
{
"priority": "HIGH",
"area": "CORS",
"recommendation": "Replace CorsLayer::permissive() with CorsLayer::very_restrictive() or explicit allowlist. For single-player use, restrict to localhost and the game process only.",
"effort": "Low",
"impact": "Prevents CSRF and cross-origin attacks"
},
{
"priority": "HIGH",
"area": "Rate Limiting",
"recommendation": "Add per-IP rate limiting using tower_governor or similar. Implement limits on destructive endpoints (e.g., POST /auth/reset: 1 request per hour per IP).",
"effort": "Medium",
"impact": "Prevents DoS and repeated data destruction"
},
{
"priority": "MEDIUM",
"area": "Input Validation",
"recommendation": "Add maximum length validation for all string fields (username, club_name, squad_name, etc.). Enforce at route handler level. Example: username max 50 chars, club_name max 100 chars.",
"effort": "Medium",
"impact": "Prevents database bloat and data validation failures"
},
{
"priority": "MEDIUM",
"area": "Data Encryption",
"recommendation": "Use SQLite encryption (sqlcipher) or migrate to PostgreSQL with TLS. Set restrictive filesystem permissions (0600) on openfut.db.",
"effort": "High",
"impact": "Protects data at rest from filesystem access"
},
{
"priority": "MEDIUM",
"area": "Error Message Handling",
"recommendation": "Return generic error messages to clients. Log detailed errors internally. Example: client sees 'invalid request', server logs 'JSON parse error: expected `,` at line 2'.",
"effort": "Low",
"impact": "Reduces information disclosure"
},
{
"priority": "MEDIUM",
"area": "Audit Logging",
"recommendation": "Add audit trail for all data mutations (create, update, delete). Log timestamp, profile ID, action, resource, and before/after state. Store in immutable audit_log table.",
"effort": "Medium",
"impact": "Enables incident response and forensics"
},
{
"priority": "LOW",
"area": "Dependency Management",
"recommendation": "Upgrade reqwest to 0.12 and rustls to 0.23 when possible. Set up Dependabot or RustSec monitoring for CVEs. Regularly audit dependencies.",
"effort": "Low",
"impact": "Reduces attack surface from known CVEs"
},
{
"priority": "LOW",
"area": "Default Values",
"recommendation": "Remove hardcoded default username 'Player 1'. Require explicit username on profile creation. Use UUIDs for profile identification.",
"effort": "Low",
"impact": "Improves account identity and prevents confusion"
}
],
"securityDesignNotes": {
"intendedUse": "OpenFUT is designed for single-player offline use. Single-profile design is intentional for local FIFA 23 emulation.",
"deploymentContext": "Localhost only (127.0.0.1:8080). Not intended for networked or multi-user deployment.",
"implicationForSecurity": "Many security issues (no auth, permissive CORS) are acceptable for localhost-only use. However, the code structure lacks security boundaries, so if ever exposed to the network, it would be completely unsecured. Recommend adding security gates now rather than retrofitting later.",
"suggestedDefensiveApproach": "Even for single-player use, add security layers (basic auth, CORS restrictions, rate limiting) to prevent accidental misuse if deployed in an unsafe context."
},
"positiveFindingsAndStrengths": [
"✓ SQLx is used throughout with parameterized queries (except auth.rs:87)",
"✓ Foreign key constraints are enforced in SQLite",
"✓ UUIDs are used for entity IDs instead of sequential IDs (reduces enumeration attacks)",
"✓ Request body size is limited to 256KB (prevents large payload DoS)",
"✓ Concurrency is limited to 256 concurrent requests",
"✓ Sensitive tokens (X-UT-SID, X-UT-PHISHING-TOKEN) are stripped from captures",
"✓ Logging is structured using tracing crate (good for audit trails)",
"✓ Services layer properly encapsulates database access"
],
"testingRecommendations": [
"Add integration tests for authentication bypass (attempt to access endpoints without tokens)",
"Test SQL injection payloads in auth.rs:87 pattern (if table names become dynamic)",
"Test CORS with cross-origin requests from external origins",
"Test rate limiting with rapid concurrent requests to /auth/reset",
"Test input validation with oversized strings (100MB+ usernames)",
"Test panic handling with corrupted database state",
"Test TLS MITM scenarios (certificate pinning validation)",
"Add fuzz testing for JSON parsing to find edge cases"
],
"complianceNotes": {
"gdpr": "No explicit data handling policy. If user data is processed, GDPR requires consent, data retention limits, and audit trails. Not currently implemented.",
"dataProtection": "Unencrypted database at rest violates most data protection frameworks.",
"logging": "Audit logging is missing, violating compliance requirements."
}
}
+16
View File
@@ -0,0 +1,16 @@
# Keep the authoritative-tree build context lean: only tools/ and data/ runtime
# files (plus the Dockerfile's own entrypoint/manifest) are needed in-image.
.git
.gitignore
artifacts
captures
futmem
staging
docs
FUT-RUNBOOK.md
README.md
data/memdump
**/__pycache__
*.pyc
*.pem
*.key
+1
View File
@@ -9,4 +9,5 @@
*.log *.log
__pycache__/ __pycache__/
captures/ captures/
staging/
tools/fifa17_profile.json tools/fifa17_profile.json
+1
View File
@@ -0,0 +1 @@
state/
@@ -0,0 +1,11 @@
# Copy to .env in this directory. Required for remote deployment.
#
# OPENFUT_ADVERTISE — the address of THIS host as seen from the game machine
# (105). The responders advertise it to the client for every next hop (Blaze,
# roster, UTAS, POW). Compose refuses to start without it.
OPENFUT_ADVERTISE=10.10.0.120
# OPENFUT_BIND — address the listeners bind inside the container.
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
# uses the loopback default baked into the responders when unset.
OPENFUT_BIND=0.0.0.0
@@ -0,0 +1,62 @@
# OpenFUT FIFA-17 FUT backend — Python migration deployment.
#
# Runs the 5 network responders (LSX / Blaze / roster / UTAS / POW) that FIFA 17
# dials to reach the FUT hub. Pure-Python; the only third-party dep is
# pycryptodome (LSX AES handshake). autopatch.py is intentionally NOT run here —
# it patches the game process memory and belongs on the client (105).
#
# Build context is fifa17-recon/ (the repo tree). tools/ is the AUTHORITATIVE
# recon tree (fifa17-recon/tools/). Only the runtime file set listed in
# docker/fifa17-python/runtime-tools.list is installed into /app/tools, so the
# deployed manifest stays byte-identical to the frozen baseline image
# openfut-fut-backend:python-baseline-2026-08-10 (see docs/BASELINE-*.md) while
# recon scripts, ghidra_queries and docs stay out of the image. data/ comes
# from the authoritative fifa17-recon/data. A SHA256SUMS.txt is baked into the
# image so any running backend can be matched to the exact dataset it was built
# from.
FROM python:3.12-slim
RUN pip install --no-cache-dir pycryptodome==3.20.0
WORKDIR /app
# Stage the authoritative tools tree in full...
COPY tools/ /app/tools-full/
# ...then install ONLY the runtime manifest (baseline image minus the two
# git-ignored certs, which are regenerated below).
COPY docker/fifa17-python/runtime-tools.list /app/runtime-tools.list
RUN set -eu; \
mkdir -p /app/tools; \
while IFS= read -r f; do \
[ -n "$f" ] || continue; \
mkdir -p "/app/tools/$(dirname "$f")"; \
cp "/app/tools-full/$f" "/app/tools/$f"; \
done < /app/runtime-tools.list; \
rm -rf /app/tools-full
COPY data/ /app/data/
# Redirector TLS cert (CN/SAN = winter15.gosredirector.ea.com). ProtoSSL
# cert-verify is patched client-side, so a self-signed cert is fine. The pair is
# git-ignored (*.pem/*.key); regenerate if absent so a fresh checkout builds
# without extra steps.
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
apt-get update && apt-get install -y --no-install-recommends openssl && \
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout tools/redir_key.pem -out tools/redir_cert.pem \
-days 3650 -subj "/CN=winter15.gosredirector.ea.com" \
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com" && \
rm -rf /var/lib/apt/lists/*; \
fi
# Bake a dataset manifest so every image is self-identifying.
RUN find /app/tools /app/data -type f | LC_ALL=C sort | xargs sha256sum > /app/SHA256SUMS.txt
COPY docker/fifa17-python/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# LSX 4216 | Blaze redir/main/nucleus 42127/42130/42131 | roster 8081 | UTAS 8099 | POW 8094/8080
EXPOSE 4216 42127 42130 42131 8081 8099 8094 8080
ENTRYPOINT ["/app/entrypoint.sh"]
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# ============================================================================
# OpenFUT FIFA-17 — CLIENT-side arming (runs on the GAME machine, e.g. 105).
#
# Companion to the dev container on the SERVER (120). The server runs the heavy
# responders (Blaze / UTAS / roster / POW). Two pieces are inherently local to
# the game and therefore stay here:
#
# * autopatch.py — patches FIFA17.exe process memory (ProtoSSL cert-verify).
# Must run where the game runs; cannot be containerised.
# * lsx_responder — the Origin/EADesktop emulator the game dials on the
# hardcoded loopback 127.0.0.1:4216. Loopback IPC can't be
# cleanly redirected to a remote host, so it lives here.
#
# Everything the game reaches by a routable address is redirected to the server:
# * winter15.gosredirector.ea.com (hardcoded EA IP 159.153.51.20) -> SERVER:42127
# * easw.easports.com (dead hardcoded UTAS host) -> SERVER (:8099)
#
# The server's responders were started with OPENFUT_ADVERTISE=<SERVER_IP>, so
# after these first redirected contacts the game is handed <SERVER_IP> for every
# later hop (Blaze main, roster, UTAS, telemetry) and dials the server directly.
#
# Usage: sudo OPENFUT_SERVER=203.0.113.10 ./client_arm.sh
# (re-run after every reboot; the sysctl/iptables state is volatile)
# ============================================================================
set -euo pipefail
SERVER="${OPENFUT_SERVER:?set OPENFUT_SERVER to the backend host IP, e.g. 203.0.113.10}"
GOS_EA_IP="159.153.51.20" # winter15.gosredirector.ea.com (hardcoded in FIFA17)
UTAS_HOST="easw.easports.com" # dead UTAS host baked into CardsDLL
UTAS_RE="${UTAS_HOST//./\\.}" # same, safe to embed in a regex
if [ "$(id -u)" -ne 0 ]; then
echo "!! must run as root (sudo). Re-run: sudo OPENFUT_SERVER=$SERVER $0" >&2
exit 1
fi
echo "[client_arm] backend server = $SERVER"
# 1) allow /proc/PID/mem writes (autopatch's ProtoSSL cert-verify patch)
sysctl -q kernel.yama.ptrace_scope=0
# 2) Redirect the hardcoded Blaze redirector IP to the server's redirector.
# (Replace any stale rule first so re-runs and IP changes are clean.)
while iptables -t nat -D OUTPUT -p tcp -d "$GOS_EA_IP" -j DNAT \
--to-destination "$SERVER:42127" 2>/dev/null; do :; done
iptables -t nat -A OUTPUT -p tcp -d "$GOS_EA_IP" -j DNAT --to-destination "$SERVER:42127"
# 2b) DNAT from OUTPUT to a REMOTE host needs a matching source-NAT on the way
# out, or the server's replies (from its own IP) won't match the game's
# conntrack entry. MASQUERADE the redirected flow so it is SNAT'd to this
# host's outbound IP. (Harmless duplicate-guarded like the DNAT above.)
while iptables -t nat -D POSTROUTING -p tcp -d "$SERVER" --dport 42127 \
-j MASQUERADE 2>/dev/null; do :; done
iptables -t nat -A POSTROUTING -p tcp -d "$SERVER" --dport 42127 -j MASQUERADE
# 3) Point the dead hardcoded UTAS host at the server. The port (8099) is carried
# in the game's own URL, so only the name needs redirecting. Remove any prior
# OpenFUT-managed line (loopback or other server) and write the current one.
sed -i "/[[:space:]]${UTAS_RE}\b.*# openfut\$/d" /etc/hosts
printf '%s\t%s\t# openfut\n' "$SERVER" "$UTAS_HOST" >> /etc/hosts
echo "[client_arm] --- armed ---"
sysctl kernel.yama.ptrace_scope
iptables -t nat -L OUTPUT -n | grep -i "$GOS_EA_IP" || echo " (DNAT missing!)"
# Verify the hosts entry by EFFECT, not by presence.
#
# glibc returns the FIRST match in /etc/hosts, so our line can be written
# correctly and still lose to an earlier one -- and the sed above only removes
# lines this script wrote (`# openfut`), so re-running never clears a foreign
# one. The old check here was `grep easw /etc/hosts && echo ok`, which passed on
# the shadowing line itself and reported success while resolution was wrong.
#
# Observed on 2026-08-11: a leftover `127.0.0.1 easw.easports.com` from the
# single-machine era shadowed the OpenFUT line, and every re-run said "ok".
resolved="$(getent ahosts "$UTAS_HOST" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ')"
# SERVER may be a hostname, so compare address-to-address rather than comparing
# the literal string against resolved IPs (which would warn spuriously).
server_ips="$(getent ahosts "$SERVER" 2>/dev/null | awk '{print $1}' | sort -u)"
[ -n "$server_ips" ] || server_ips="$SERVER"
match=0
for ip in $server_ips; do
printf '%s' "$resolved" | grep -qw -- "$ip" && match=1
done
if [ "$match" -eq 1 ]; then
echo " /etc/hosts ok ($UTAS_HOST -> $resolved)"
else
echo
echo " !! WARNING: $UTAS_HOST resolves to [$resolved], not $SERVER."
echo " An earlier /etc/hosts line is shadowing the OpenFUT one:"
grep -nE "^[[:space:]]*[^#].*[[:space:]]${UTAS_RE}([[:space:]]|\$)" /etc/hosts \
| grep -v '# openfut$' | sed 's/^/ /' || true
echo
echo " Not fatal: the responders advertise $SERVER, so the game stops using"
echo " this name after the first hop. Worth removing the line above anyway."
echo " Lines are listed rather than deleted -- this script will not remove"
echo " /etc/hosts entries it did not write."
fi
echo
echo "[client_arm] Next: start the LOCAL pieces (LSX + autopatch) with client_local.sh,"
echo " ensure the container is up on $SERVER, then launch FIFA 17."
@@ -0,0 +1,49 @@
# OpenFUT FIFA-17 FUT backend — declarative deployment (server side, runs on 120).
#
# cp .env.example .env # set OPENFUT_ADVERTISE to THIS host's LAN IP
# docker compose up -d --build
#
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the address
# the servers hand the client (105) for every next hop (Blaze, roster, UTAS,
# POW) and is required — there is no silent loopback fallback in remote mode.
#
# The client (105) still needs its first-hop redirect (hook or DNAT) plus
# autopatch.py running locally; see client_arm.sh and the FIFARUNBOOK.
name: openfut-fut-backend
services:
fut-backend:
build:
context: ../..
dockerfile: docker/fifa17-python/Dockerfile
image: openfut-fut-backend:dev
container_name: openfut-fut-backend
restart: unless-stopped
environment:
# Bind all interfaces inside the container.
OPENFUT_BIND: "${OPENFUT_BIND:-0.0.0.0}"
# Address advertised to the client for the next hop. MUST be this host's
# LAN IP as seen from the game machine (105). Required (see .env.example).
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 10.10.0.120}"
# POW content advertises port 8080 by default, which collides with the
# openfut-core publish on this host. Remap it to 8085 on the host and
# advertise the remapped endpoint.
POW_CONTENT_ADDR: "0.0.0.0:8080"
POW_CONTENT_HOST: "${OPENFUT_ADVERTISE}:8085"
# Launcher-selected EA/Origin identity shared by LSX, Blaze, POW and UTAS.
# FUT saves are isolated by persona beneath /state/accounts.
FUT_ACCOUNT_PATH: "/state/active_account.json"
FUT_PROFILE_ROOT: "/state/accounts"
FUT_SETTINGS: "off"
FUT_MODES: "1"
volumes:
- "../state:/state"
ports:
- "4216:4216" # LSX (Origin bootstrap)
- "42127:42127" # Blaze redirector (TLS)
- "42130:42130" # Blaze main
- "42131:42131" # Nucleus OAuth stub
- "8081:8081" # FUT roster XML (HTTPS)
- "8099:8099" # UTAS / RS4 FUT REST API
- "8094:8094" # POW / EASFC API
- "8085:8080" # POW content (host 8085 -> container 8080; avoids core:8080)
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# ============================================================================
# OpenFUT FIFA-17 FUT backend — in-CONTAINER orchestrator.
#
# Runs the 5 network responders that the game dials. Unlike the host-based
# openfut-fut.sh, this does NO host arming (no pkexec / iptables / /etc/hosts /
# ptrace) — those are client-side concerns handled on the game machine (105).
# autopatch.py is NOT run here: it patches the FIFA17.exe process memory and must
# run on the box the game runs on.
#
# Address behaviour is driven by two env vars (see each responder):
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
# OPENFUT_ADVERTISE address handed to the client for the next hop
# (the server's LAN IP, e.g. 10.10.0.120)
# ============================================================================
set -uo pipefail
cd "$(dirname "$(readlink -f "$0")")/tools"
BIND="${OPENFUT_BIND:-0.0.0.0}"
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 10.10.0.120)}"
export OPENFUT_BIND="$BIND"
export OPENFUT_ADVERTISE="$ADV"
# POW keys advertised by blaze must also point at the server, not loopback.
export POW_HOST="${POW_HOST:-$ADV:8094}"
export POW_CONTENT_HOST="${POW_CONTENT_HOST:-$ADV:8080}"
export POW_ADDR="${POW_ADDR:-$BIND:8094}"
export POW_CONTENT_ADDR="${POW_CONTENT_ADDR:-$BIND:8080}"
echo "[openfut] bind=$BIND advertise=$ADV"
# name script extra-env
declare -a SERVERS=(
"lsx|lsx_responder_v2.py|OPENFUT_LSX_EVENT_COUNT=100000"
"blaze|blaze_responder_v3b.py|-"
"roster|roster_server.py|-"
"utas|utas_server.py|FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1 FUT_DISCARD_TABLE=1 FUT_DISCARD_SEND=1"
"pow|pow_server.py|-"
)
pids=()
names=()
for entry in "${SERVERS[@]}"; do
IFS='|' read -r name script env <<<"$entry"
envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
echo "[openfut] starting $name ($script)"
# shellcheck disable=SC2086
$envprefix python3 -u "$script" &
pids+=($!)
names+=("$name")
done
# Propagate SIGTERM/SIGINT to children so `docker stop` is clean.
term() {
echo "[openfut] shutting down…"
for p in "${pids[@]}"; do kill "$p" 2>/dev/null || true; done
wait
exit 0
}
trap term TERM INT
# If ANY responder dies, take the whole container down so the failure is visible
# (they all bind ports the game needs — a partial stack is a broken stack).
while true; do
for i in "${!pids[@]}"; do
if ! kill -0 "${pids[$i]}" 2>/dev/null; then
echo "[openfut] responder ${names[$i]} (pid ${pids[$i]}) exited - bringing container down"
term
fi
done
sleep 2
done
@@ -0,0 +1,77 @@
origin_login_probe.py
card_proof.py
force_login_flag.py
card_record_poke.py
test_tournament_contract.py
dmp_stack.py
fut_clubitems.py
test_autopatch_logging.py
capture_lsx.py
roster_server.py
autopatch.py
dbschema_probe.py
test_account_profiles.py
coach_window.py
watch_club_model.py
db_dump.py
coach_probe.py
uidiff.py
probe_club_stats.py
blaze_responder_v3.py
dbdata_extract.py
decode_fire2.py
check_club_stat_vocab.py
fut_accounts.py
strip_dead_cards.py
test_hub_offline_season_contract.py
repair_club.py
forge_node.py
verify_preauth.py
fut_coaches.py
heat2.py
test_security_question.py
test_utas_log_redaction.py
sbc_populate_poke.py
atomdump.py
lsx_responder.py
fut_staff.py
fut_cards.py
blaze_responder.py
blaze_responder_v2.py
fut_store.py
blaze_responder_v3b.py
test_fut_contract.py
utas_server.py
lsx_force_online.py
grab_crash_code.py
gate_byte_probe.py
fut_admin.py
test_match_rewards.py
lsx_responder_v2.py
card_identity_probe.py
extract_player_ids.py
watch_online_mode.py
store_enable_poke.py
pow_server.py
fut_account.py
blaze_responder_v3_patched.py
check_settings_flags.py
test_match_lifecycle.py
sbc_hook_poke.py
futlog.py
fut_seed.py
hub_counter_probe.py
fut_consumables.py
db_catalog_walk.py
memtool.py
build_player_facts.py
sweep_collect.py
test_card_families.py
fut_club_stats.py
dmp.py
build_consumables.py
test_market_buy.py
dump_login_code.py
auth_watch.py
vgamepad.py
ghidra_env.py
@@ -0,0 +1,121 @@
# Python backend baseline — 2026-08-10
Frozen rollback target for the working offline FUT backend (Python migration) as
it ran on 10.10.0.120. Everything here was recorded from the live system before
any cleanup/restructure; the image and state are archived in
`/home/alex/OpenFUT/docker-backups/`.
## Frozen image
| field | value |
|------------|-------|
| tag | `openfut-fut-backend:python-baseline-2026-08-10` |
| image id | `e1f93ad647ab` |
| digest | `sha256:e1f93ad647abbec32e2751f3e88fed75d3e574d4500395b21c31d0f0b96abac6` |
| created | 2026-08-10T02:14:56Z (built as `openfut-fut-backend:dev`) |
| size | 278 MB |
| archive | `docker-backups/openfut-fut-backend-python-baseline-2026-08-10.tar.gz` (53 MB, `docker save \| gzip -1`) |
## Frozen container
| field | value |
|------------|-------|
| id | `f16d3204cbf48151be232ff8f4194b429e311042f8f6f95644760d4b8eba2938` |
| created | 2026-08-10T02:14:56.194470252Z |
| image | `openfut-fut-backend:dev` (= baseline image id) |
| restart | `unless-stopped` |
| network | `docker_default`, IP `172.19.0.2`, aliases `openfut-fut-backend`, `fut-backend` |
| log | json-file |
| inspect | `docker-backups/openfut-fut-backend-container-inspect-2026-08-10.json` |
### Environment (Config.Env)
```
FUT_SETTINGS=off
FUT_MODES=1
OPENFUT_BIND=0.0.0.0
OPENFUT_ADVERTISE=10.10.0.120
POW_CONTENT_ADDR=0.0.0.0:8080
POW_CONTENT_HOST=10.10.0.120:8085
FUT_ACCOUNT_PATH=/state/active_account.json
FUT_PROFILE_ROOT=/state/accounts
PYTHON_VERSION=3.12.13 (python:3.12-slim base)
```
### Volumes / mounts
Bind mount `docker/state` (host) -> `/state` (container, rw). Runtime state:
`active_account.json` (active persona) + `accounts/` (FUT saves by persona).
Snapshot: `docker-backups/state-2026-08-10/`.
### Ports (host -> container)
| host | container | service |
|------|-----------|---------|
| 4216 | 4216 | LSX (Origin bootstrap) |
| 42127 | 42127 | Blaze redirector (TLS) |
| 42130 | 42130 | Blaze main |
| 42131 | 42131 | Nucleus OAuth stub |
| 8081 | 8081 | FUT roster XML (HTTPS) |
| 8099 | 8099 | UTAS / RS4 FUT REST API |
| 8094 | 8094 | POW / EASFC API |
| 8085 | 8080 | POW content (remapped to avoid openfut-core:8080) |
All listeners verified bound on `0.0.0.0` in the container (LSX/Blaze/nucleus,
roster, UTAS, POW, POW content).
## Dataset manifest
`docker-backups/SHA256SUMS-container-baseline-2026-08-10.txt` — sha256 of all
323 files under `/app/tools` + `/app/data` inside the running container.
`fifa17-python/tools/` and `fifa17-python/data/` are the staged sources that
built this image (verified byte-identical to the container copies at freeze
time). Images rebuilt from git now bake their own `/app/SHA256SUMS.txt`; the
rebuild-equivalence check is `diff` between that and this manifest; the only expected deltas are pycache files (not committed) and the redir cert pair (regenerated per build).
## Restore
```sh
# From the archived image (works offline, exact layers):
docker load -i /home/alex/OpenFUT/docker-backups/openfut-fut-backend-python-baseline-2026-08-10.tar.gz
docker tag openfut-fut-backend:python-baseline-2026-08-10 openfut-fut-backend:dev
# Or rebuild from git:
cd /home/alex/OpenFUT/fifa17-recon/docker/fifa17-python
cp .env.example .env # set OPENFUT_ADVERTISE
docker compose up -d --build
```
## Status at freeze time
- The 2026-08-10 `openfut-fut-backend` container was **left running untouched**
(the .105 launcher audit uses it). No rebuild/replacement happens until that
audit finishes; the frozen image is the rollback target if cleanup breaks it.
- `docker/state` was **not** moved during restructure (bind path must not change
while the container is live); the new compose mounts `../state` from the same
location.
- TURN/relay re-addressing (multiplayer) and long-tail endpoints (weather,
matchday, kit assets) are deferred feature gaps — tracked separately.
## Running state vs image — what the frozen image does NOT contain
The baseline image (`python-baseline-2026-08-10` / `dev`) was built at 02:14Z,
but the container's `/app` was hot-patched afterwards:
* `tools/utas_server.py` — gained the `FUT_MODES`-gated `offlineSeason` block in
GetHubData's club response (keeps the hub's offline-season summary valid).
* `tools/test_hub_offline_season_contract.py` — added to `/app/tools`.
`docker save` captures the image, not the container's writable layer, so the
baseline tar.gz lacks those two changes. Two paths cover the exact runtime:
* `openfut-fut-backend:python-running-2026-08-10` — `docker commit` of the
running container (sha256:093a98fa0496...), the exact runtime FS.
* The committed `fifa17-python/tools` + `data` — synced to match the running
container byte-for-byte (237 files verified, incl. the redir cert pair), so a
fresh build reproduces the actual running backend. Proven by rebuilding from
the committed sources and diffing the baked `/app/SHA256SUMS.txt` against the
container manifest: identical.
Archive: `docker-backups/openfut-fut-backend-python-running-2026-08-10.tar.gz`.
+37 -7
View File
@@ -1329,14 +1329,44 @@ this absence is asserted over the whole function, not a slice.
- **Handled:** `utas_server.SETTINGS`, `FUT_SETTINGS` (default `gates`). - **Handled:** `utas_server.SETTINGS`, `FUT_SETTINGS` (default `gates`).
`off` restores the historical `{"configs": []}`. `off` restores the historical `{"configs": []}`.
### FutGetHubDataServerResponse — CONFIDENCE: LOW (full schema) / HIGH (served {} works) — GAP ### FutGetHubDataServerResponse — CONFIDENCE: HIGH (schema fully enumerated) — ✅ HANDLED (tiles populated)
- **Wrapper:** `0x1801736ad` → inner `0x180173a50` / `0x180173b10` / `0x180173c00`. - **Deser:** `FUN_180139610` (root object parser). Wrapper `0x1801736ad`.
- **HTTP:** `GET ut/%s/hub` - **HTTP:** `GET ut/%s/hub`
- **Note:** uses **C++ reflection / vtable dispatch** (`call [rax+0x10]`, - **CORRECTION (2026-08-06):** the earlier note here — "uses C++ reflection /
`call [rdx+0x1f8]`), NOT an inline atom ladder — no static field ladder to vtable dispatch, NOT an inline atom ladder, no static field ladder to read,
read. It aggregates sub-objects (userInfo, settings, messages, etc.), each with GAP" — was **WRONG**. `FUN_180139610` has an ordinary inline atom ladder: a
its own deser. Empty `{}` is tolerated (fields default). running-sum `sub ecx,d / … / cmp ecx,d` dispatch plus a few direct `cmp esi,imm`.
- **Handled:** `utas_server` serves `{}` (validated hub-reaching). Deep populate = GAP. It reads **18 atoms**, all enumerated below straight from the on-disk CardsDLL
via objdump (`fifa17-recon` scratchpad `hub_ladder.py`). The vtable calls are the
per-sub-object dispatch one indirection deeper, not the field read itself.
- **The 18 root atoms** (name ← `fut_atoms.tsv`):
`allObjectivesForCurrentGameSpaceId`(0x15), `auctionCount`(0x33),
`championEvent`(0x7a), `clubPlayers`(0x90), `draftSummary`(0xe4),
`friendlySeason`(0x131), `leaderboard`(0x186), `liveMessagesAvailable`(0x190),
`objectivesForCurrentUser`(0x1e3), `offlineSeason`(0x1ec), `ONLINE`(0x1f1),
`onlineSeason`(0x1f6), `SINGLE_PLAYER`(0x29d), `squad`(0x2cd),
`tournament`(0x328), `tournamentProgress`(0x32c), `tradePile`(0x333),
`watchlist`(0x381).
- **TILE MAP (which atom drives which hub tile):**
- `clubPlayers`(0x90) int → MY CLUB tile "N players" (TILE_ID 0x210)
- `auctionCount`(0x33) int → TRANSFER MARKET tile "N LIVE TRANSFERS" (TILE_ID 0x1b0)
- `tradePile`(0x333) **nested object**, sub-deser `0x18013ead0` → TRANSFER LIST
tile "N ITEMS / Selling / Sold". Sub-atoms: `count`(0xbc), `notification`(0x1da),
`selling`(0x2b8), `sold`(0x2c9) — all scalar int via `0x1801c79d0` (5 int reads,
one SKIP, object field loop; no array/nested object → no type-desync surface).
Same atom scheme as `FutGetAuctionCount`. **All active listings are `selling`;
`count == selling == len(listings)`, `sold == 0`.**
- `watchlist`(0x381) nested object, sub-deser `0x18013f3b0` → WATCH LIST tile (not
yet populated; empty watch list defaults to 0, which is correct today).
- **LIVE SYMPTOM this fixed (2026-08-06):** a card was actively listed
(`auctionCount` 1, Listed Items screen showed it) yet the TRANSFER LIST tile read
"0 items / Selling 0". The tile reads `hub.tradePile`, which we were omitting; it
does **not** re-poll `/tradePile/counts` (the standalone GetAuctionCount endpoint)
once at the hub. Serving `hub.tradePile:{count,selling,sold}` corrected the tile.
- **Handled:** `utas_server.hub_data()` serves `clubPlayers`, `auctionCount`, and
`tradePile:{count,selling,sold}` (`FUT_HUBDATA=1`, default on). Remaining atoms
(seasons/draft/tournament/objectives/leaderboard summaries) default to 0/absent,
which is correct while those modes are unpopulated.
### FutUserDataServerResponse — CONFIDENCE: MEDIUM ### FutUserDataServerResponse — CONFIDENCE: MEDIUM
- **Deser:** `0x18016dd50` (lea r8 @ `0x18016d98d`) - **Deser:** `0x18016dd50` (lea r8 @ `0x18016d98d`)
@@ -0,0 +1,369 @@
# The refusing modes: Seasons, Draft, SBC/Objectives, Tournaments — where the greying is decided
Written 2026-08-06. One reconnaissance pass over the live gate-byte block and the
six `/hub` mode sub-deserializers, then four parallel per-mode investigations
(Seasons, Draft, SBC+Objectives, Tournaments), each followed by an independent
adversarial verification round. FIFA 17 was running throughout as **pid 24653**,
sitting at the FUT hub, and was read strictly read-only. No server was restarted,
no server code was changed, no memory was poked, and FIFA was never launched or
killed.
Slide for every live read: `live = static - 0x180000000 + 0x6ffffc140000`, i.e.
slide `0x6ffe7c140000`, re-derived from `/proc/24653/maps` and proved by
`tools/gate_byte_probe.py` reporting **CONTROL FNV MATCH** against the FNV hasher
prologue at `0x180180d00`. CardsDLL is mapped from `/mnt/games/FIFA 17/
CardsDLL_Win64_retail.dll`; the on-disk copy read with `objdump` is
`/tmp/fut/cardsdll.dll`, image base `0x180000000`. Every address below is
live-verified.
This document answers one question the brief posed: is the refusal of these four
mode families decided by a **server-reachable input we are failing to send** (a hub
mode sub-object, a massinfo member, a settings/config field, or a dedicated
endpoint), **or** is it decided in the **Denuvo-packed FIFA17.exe / Frostbite
front-end** with no server surface at all?
---
## 1. Headline — final verdicts (after adversarial verify)
Every mode was independently re-derived by a second agent that attempted to refute
the first. **All four refutations failed. All four verdicts stand.**
| Mode | Atoms | Final verdict | Confidence | Verify |
|---|---|---|---|---|
| **FUT Seasons** (offline + online + friendly) | `friendlySeason 0x131`, `offlineSeason 0x1ec`, `onlineSeason 0x1f6` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME) |
| **FUT Draft** (offline + online) | `draftSummary 0xe4` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME), strengthened |
| **SBC + Objectives** | `objectivesForCurrentUser 0x1e3`, `allObjectivesForCurrentGameSpaceId 0x15` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME), prior chain corrected |
| **FUT Tournaments** | `tournament 0x328`, `tournamentProgress 0x32c` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME) |
**There is no server fix for any of the four.** Every server-reachable input that
touches these modes is either cosmetic (a hub stat list feeding a caption/count),
an *output* value the client emits and never branches on, or a settings byte that
is **already live=1** while the tile stays greyed. The decision lives in the packed
front-end. This is the same shape as the transfer-market finding of the same day —
except there the switch (`userInfo.feature.trade`) was ours to flip; here **no such
switch exists on the wire.**
---
## 2. Ground truth
### The named gate-byte block (`FutDataManagerImpl`, live pid 24653)
Names were resolved by finding the config serializer at `0x18006ccd0`, which pairs
each `IS_*_ENABLED` string key (`.rdata 0x1801fc118..`) with a getter vtable slot,
then decoding each slot's accessor stub (`0f b6 81 <disp32> c3`) to its model
displacement. All values read live, slide-proven.
| Name | Displacement / slot | Live value |
|---|---|---|
| (unnamed) | `+0x1fd24` | 0 |
| (unnamed) | `+0x1fd2c` | 1 |
| (unnamed) | `+0x1fd2d` | 1 |
| **IS_TRADING_ENABLED** | `+0x1fd2e` (slot+0x270) | 1 |
| (unnamed) | `+0x1fd30` | 1 |
| (unnamed) | `+0x1fd37` | 1 |
| **IS_FRIENDLY_SEASON_ENABLED** | `+0x1fd3a` (slot+0x2b0) | 1 |
| **IS_TOURNAMENT_QUIT_ENABLED** | `+0x1fd3b` (slot+0x2b8) | 1 |
| **IS_PROCESSING_STATE_ENABLED** | `+0x1fd3c` (slot+0x2c0) | 1 |
| **IS_DRAFT_MODE_ENABLED** | `+0x1fd3d` (slot+0x2c8) | 1 |
| (unnamed) | `+0x1fd3e` (offline-draft-enable) | 1 |
| **IS_STORY_MODE_REWARD_ENABLED** | `+0x1fd3f` (slot+0x2d8) | 1 |
| **IS_RETURNING_USER_REWARDS_SCREEN_ENABLED** | `+0x1fd40` (slot+0x2f0) | 0 |
| (unnamed) | `+0x1fd41` | 0 |
| (unnamed) | `+0x1fd42` (allowGracePeriod, SBC) | 0 |
| (unnamed) | `+0x1fd43` | 0 |
| **objectives-enable** (corrected — see §5.3) | `+0x1fd44` | 1 |
| **packOpeningAnimation** | `+0x1fd45` | 1 |
| (unnamed) | `+0x1fd46` | 1 |
| (unnamed) | `+0x1fd47` | 0 |
| (unnamed) | `+0x1fd48` | 1 |
| **IS_STORE_ENABLED** | computed getter slot+0x280 @`0x18011c600` (not a byte field) | (computed) |
Every named gate byte that governs a **refusing** mode reads **ENABLED=1** live.
The only `0`-valued `*_ENABLED` byte, `IS_RETURNING_USER_REWARDS_SCREEN_ENABLED`,
does not gate any of the four mode families. This re-confirms the brief's prior
ground truth: the gate-byte layer does **not** explain the refusals.
### The six `/hub` mode sub-deserializers (`/hub` parser = `FUN_180139610`)
The hub parser reads 18 atoms via a running-sum sub/dec ladder; six dispatch to the
refusing modes. Each nested sub-deser was read in full. **None carries an
enable/available/unlocked boolean.**
| Atom | Name | Sub-deser VA | Fields (all cosmetic/data) |
|---|---|---|---|
| `0x131` | friendlySeason | `0x1801392a0` | creationTime, dataVersion, opponentPersonaId, opponentUserPoints, round, seasonId, userPoints, defId (8 ints) |
| `0x1ec` | offlineSeason | `0x18013c3a0` | divisionId, gamesPlayed, points, progressDataVersion, totalGames (strings) |
| `0x1f6` | onlineSeason | `0x18013c3a0` (shared) | divisionId, gamesPlayed, points, progressDataVersion, totalGames (strings) |
| `0xe4` | draftSummary | `0x180138d60` | draftState (str-enum), gamesWon (int) |
| `0x328` | tournament | `0x18013dc00` | id, assetName, imageFormat, silhouetteName, timeUntilEnd, tournamentType, AMATEUR, live_offline, offerState (display) |
| `0x32c` | tournamentProgress | `0x18013df20` | data, tutorialClientData (free-form std::map) |
The recurring trap: several of these desers write a per-field byte
(`offline/onlineSeason` `[r14+0xa]=1`; `tournament` `[rdi+0x162]=1`) that an early
naive pass could mistake for a JSON enable flag. Every such write is a
**parser-local "field present" marker**, written identically for every field —
**not** a JSON-sourced availability input. This is the same class of mistake that
made `hub.tradePile` look like a gate before it was shown to be a mere count.
---
## 3. FUT Seasons — NOT_SERVER_REACHABLE (HIGH)
**Atoms:** `friendlySeason 0x131`, `offlineSeason 0x1ec`, `onlineSeason 0x1f6`.
**Gate byte:** `IS_FRIENDLY_SEASON_ENABLED +0x1fd3a`, live=1.
**Evidence chain.** The decisive site is the gate byte `+0x1fd3a`. A whole-`.text`
grep finds **exactly two** references:
- **Writer** `0x18011dd2d`: `mov byte[rdi+0x1fd3a],al` inside settings applier
`FUN_18011dc50`, preceded by `cmp dword[rbx+0x58],1 / sete al` — the byte is
`(settings.field+0x58 == 1)`, sourced from config key `friendlySeasonsEnabled`.
This is the **only** writer.
- **Reader** `0x18011c500`: `movzx eax,byte[rcx+0x1fd3a]; ret` — a standalone
vtable getter stub (slot+0x2b0). Its absolute address appears in the file exactly
once, at the vtable, and grep finds **no** call/jmp to `0x18011c500` anywhere in
CardsDLL `.text`. Its only consumer is the packed FIFA17.exe front-end via vtable
dispatch.
The one server-writable input (`friendlySeasonsEnabled → +0x1fd3a`) is **already 1
live**, and the tile is still greyed — so the front-end does not gate on this byte
alone; it reads additional non-server state.
- **Hub sub-objects** carry no enable flag. `offline/onlineSeason` share deser
`0x18013c3a0`, which FNV-hashes string keys and for each stores a division/games/
points/version stat; `friendlySeason 0x1801392a0` is 8 numeric stats. The
`[r14+0xa]=1` write is the "field present" marker. These feed a caption/count.
- **Settings/massinfo:** `friendlySeasonsEnabled` is the sole season key the applier
consumes → `+0x1fd3a`, already covered. No massinfo member carries a season enable.
There is **no** `onlineSeasonEnabled`/`offlineSeasonEnabled` config key or gate
byte anywhere in the DLL — verify enumerated all 24 gate-region getter stubs and
the only season getter is `+0x1fd3a`.
- **Dedicated endpoint:** `/season` and `/season/user` routes exist in
`utas_server.py` (guarded by `FUT_MODES`) but the client has **never** requested
them — 0 season hits across `captures/`, 486 real ProtoHttp requests over ~30
boots, none for `/season`. And the tile greys at hub load, *before* any `/season`
request could fire.
- **Front-end:** the only season-enable identifiers in the whole DLL are the config
*input* `friendlySeasonsEnabled` and the *output* getter name
`IS_FRIENDLY_SEASON_ENABLED`. The viewmodel names
(`futonlineseasonsviewmodel`, `futofflineseasonsviewmodel`,
`futfriendlyseasons*viewmodel`) live in the Denuvo-packed FIFA17.exe.
**Authority boundary.** `friendlySeasonsEnabled` is a **server-writable input**,
but it is already at ENABLED with its only reader **off-DLL (client)**. Offline/
online seasons have **no server surface at all** — no config key, no gate byte, no
getter. The grey/refuse decision is **client-side**.
---
## 4. FUT Draft — NOT_SERVER_REACHABLE (HIGH, strengthened by verify)
**Atoms:** `draftSummary 0xe4`. **Gate byte:** `IS_DRAFT_MODE_ENABLED +0x1fd3d`,
live=1.
**Evidence chain.** Cross-ref of displacement `0x1fd3d` returns exactly two real
sites (a `lea` to `0x1801fd3d8` and an instruction at address `0x18011fd3d` are
coincidental, not xrefs):
- **Accessor stub** `0x18011c4b0`: `movzx eax,[rcx+0x1fd3d]; ret` (getter vtable
`.rdata 0x18021c568`).
- **Writer** `0x18011dd5a`: `mov [rdi+0x1fd3d],al` in applier `FUN_18011dc50`,
`al = (settings[rbx+0x5c]==1)` = parsed `enableDraftMode`.
There is **no cmp/test/branch** on this byte anywhere. Its only CardsDLL consumer
is the config serializer `0x18006ccd0`, which walks the `IS_*_ENABLED` key table and
`call [rax+0x2c8]` to **emit** the value outward. So `IS_DRAFT_MODE_ENABLED` is an
**output the client serializes, not an input any logic branches on.**
**The verifier strengthened this** by finding a consumer the first pass missed: a
flux "DESTINATION" navigation emitter around `0x1800b2700`. At `0x1800b2711` it
loads getter slot `+0x2c8` (draft-enable, `+0x1fd3d`) into `sil` and slot `+0x2d0`
(offline-draft-enable, `+0x1fd3e`) into `[rsp+0x21]`. All six `GOTO_DRAFT_DISABLED`
emit sites (`0x1800b2cb2`, `0x1800b2dc9`, `0x1800b333b/347`, `0x1800b349f/4a7`) are
guarded by `test sil,sil` / `cmp [rsp+0x21],0` and route to `GOTO_DRAFT_DISABLED`
**only when those bytes are 0**, else to `GOTO_DRAFT_OFFLINE/ONLINE`. Both bytes are
**live=1**, so this emitter — the closest thing to a nav decision inside CardsDLL —
already produces the ENABLED destinations, yet the tile is still greyed.
- **Hub sub-object** `draftSummary 0xe4`, member deser `0x180138d60`: exactly two
atoms — `draftState 0xe3` (STRING → enum decoder `0x180138cc0`, a resume-state
enum: INVALID + 2..8) and `gamesWon 0x13a` (INT). Wrapper `0x18013980c` loops
`ONLINE 0x1f1` / `SINGLE_PLAYER 0x29d`, each → `0x180138d60`. No enable atom;
`draftState` is the continue-state read after entry, not a tile gate.
- **Settings/massinfo:** atoms `enableDraftMode 0xf9` / `enableOfflineDraftMode
0xfa` / `enableSinglePlayerDraftMode 0xff` land on sibling emit-only bytes
`+0x1fd3d`/`+0x1fd3e`/`+0x1fd3c` via the same applier — none branched on.
- **Dedicated endpoints:** `GET /squad/mode/draft/state` (deser `0x180147070`) and
`POST /purchase/mode/N/draft` (deser `0x18014c260`) are already routed in utas —
but these are the **post-click** entry/session flow (render the draft screen, buy
entry *after* the tile is pressed), not a tile-availability query.
- **Front-end:** token strings (`USER_HAVE_DRAFT_TOKENS 0x1802055f8`,
`GOTO_DRAFT_DISABLED 0x180209aa8`, etc.) are bare key-name `lea` emitters with no
greying branch. Decision is in the packed FIFA17.exe.
**Authority boundary.** The two server-writable inputs (`enableDraftMode`,
`enableOfflineDraftMode`) are **already at their enabled value**, and **every**
CardsDLL consumer of them (config serializer *and* the navigation emitter) already
treats draft as enabled. The persistent greying is decided **client-side** on
non-server state.
---
## 5. SBC + Objectives — NOT_SERVER_REACHABLE (HIGH, prior chain corrected)
**Atoms:** `objectivesForCurrentUser 0x1e3`, `allObjectivesForCurrentGameSpaceId
0x15`. **No `IS_OBJECTIVES`/`IS_SBC` gate-byte name exists** — the task premise that
these are governed by no named `FutDataManagerImpl` gate byte is confirmed.
### 5.1 Hub sub-object = cosmetic list
In `FUN_180139610` both objectives atoms share one arm: `objectivesForCurrentUser
0x1e3` (`0x180139794`) and `allObjectivesForCurrentGameSpaceId 0x15`
(`0x1801397ad`) both jump to `0x1801398fe`, guarded by the parser-local marker
`cmp BYTE [rsp+0x21],0x1`, calling sub-deser `0x18013a7f0`. That deser parses a
nested `objectives 0x1e2` **array** of records (element parser `0x18006c9b0`) with
**no** enabled/available/unlocked atom — it feeds the "MANAGER TASKS N/M" tile
count/caption, the same cosmetic class as `hub.tradePile`.
### 5.2 No dedicated endpoint at the hub
The live log across 26+ hub sessions shows the client requests only `/hub` and
`/settings`; it **never** calls `/sbs/*` (grep count 0) or any `/objectives`
endpoint. `utas_server.py` has no `/sbs` route. No `FutGetObjectivesServerResponse`
class exists — objectives are **ManagerQuests**, client-driven. The `sbs/*` structs
that exist serve challenge **content after entry**, never polled at the hub.
### 5.3 The correction (verify fixed the first pass's chain)
The first pass mis-traced objectives to settings field `[0x1c]` → model `+0x1fd28`
(default 60). **The verifier re-derived the settings jump table (dispatch
`0x18013ca1e`, byte-idx `0x18013ced4`, jtbl `0x18013ce90`) and found the truth:**
- `enableObjectives 0xfd` **and** `enableObjectivesAsManagerTasks 0xfe` route to
handler `0x18013cabd` = clear-only-on-zero into settings field `[0x70]`; applier
`0x18011ddc7` (`cmp [rbx+0x70],1; sete al; mov [rdi+0x1fd44],al`) maps it to model
gate byte **`+0x1fd44`** — which is **inside** the named gate block (not outside,
as the first pass claimed), reads **1 (ENABLED) live**, and has exactly one reader
DLL-wide: a getter stub `0x18011c570` returning the byte to the front-end with no
internal gating use.
- The first pass's `+0x1fd28` (default 60) is actually
`squadBuildingSetsGracePeriodMinutes 0x2d0`, a numeric grace-period param —
behavioral, not availability.
- **SBC side:** `enableSquadBuildingSetsFeature 0x100` falls in the dispatch **gap**
(`0x100-0x18=0xe8 > 0xe7 → DEFAULT/no handler`), as do `squadBuildingSetsClientData
0x2cf` and `squadChallenge 0x2d1`. Only numeric SBC params have handlers
(`allowGracePeriod 0x18 → +0x1fd42`, `allowUntradeable 0x19 → +0x206f8`,
`gracePeriodMinutes 0x2d0 → [0x1c]/+0x1fd28`). **No SBC availability model byte
exists.**
So the single server-controllable objectives-enable input (`+0x1fd44`) is already at
1 yet the tile refuses, and SBC has **no** server enable surface whatsoever.
**Authority boundary.** Objectives-enable is a **server-writable byte already ON**,
read only by the **client**. SBC availability has **no server surface** — its enable
key is in the settings dispatch gap and lands on no byte. Decision is **client-side**
(`futmanagerquestsviewmodel`; providers `FUT_MQ_QUESTS_DATA_DP` /
`FUT_SQUAD_QUESTS_DP`) in the packed FIFA17.exe.
---
## 6. FUT Tournaments — NOT_SERVER_REACHABLE (HIGH)
**Atoms:** `tournament 0x328`, `tournamentProgress 0x32c`. **Gate byte:**
`IS_TOURNAMENT_QUIT_ENABLED +0x1fd3b`, live=1 — but this governs **quitting** a
tournament, not tile availability, and no `tournamentEnabled` atom exists in
`docs/fut_atoms.tsv`.
**Evidence chain.**
- **Hub sub-objects, both cosmetic.** `tournament 0x328` deser `0x18013dc00` writes
only display fields: id `[rdi+0x150]`, round `[rdi+0x160]`, timeUntilEnd
`[rdi+0x158]`, silhouette-int `[rdi+0x15c]`, string blobs `[rdi]`/`[rdi+0xa8]`
(assetName/silhouette/type), an `imageFormat=="dds"` render bool `[rdi+0x163]`
(strcmp vs `.rdata 0x180219400`), and a `tournamentType` enum `[rdi+0x154]`
decoded to `live_offline 0x195`/`live_online 0x196`/`offline 0x1e8`/`online 0x1f0`
— a categorization, not availability. The `[rdi+0x162]=1` write is a
record-completeness marker (all core fields present), not a JSON enable.
`tournamentProgress 0x32c` deser `0x18013df20` builds a std::map (ctor
`0x1801e5210`) of string keys `data 0xc9` / `tutorialClientData ~0x353` — free-form
clientData, no enable atom. (The earlier `0x28a = returningUserRewardsScreenEnabled`
label was a running-sum mis-decode; the true sum is `0xc9+0x28a=0x353
tutorialClientData`.)
- **Massinfo/settings.** `tournamentCoins 809 → +0x30` and `teamOfTournamentWinner
776 (bool) → +0x34` appear only in the **FutDestroyMatch** reward deser
`0x180121b60` — a match payout reached only *after* you are inside a tournament
match; a reward count/trophy flag, not a tile gate. The settings applier switch
`0x18013c6d0` has 42 arms; the only tournament arm is `tournamentQuitEnabled 0x32D
→ +0x1fd3b` (quit, live=1).
- **Gate byte** `+0x1fd3b`: getter stub `0x18011c660` is the vtable **emit**
accessor the config serializer `0x18006ccd0` pairs with the JSON key to write it
out — the client emits it, does not read it as a server input. Writer
`0x18011dd3d`, `al = sete(cmp settings[rbx+off],1)`, defaults to 1. Already 1,
wrong feature.
- **Dedicated endpoint, never called.** `tournament_list` (deser `0x180169ef0`) and
`tournament_user` (deser `0x180147cb0`) exist in `utas_server.py` but grep over
`captures/` and the live `/tmp/utas_server.log` (3224 lines) finds **zero**
ProtoHttp requests for any `/tournament` path across all boots — same as `/season`.
The responses are never consumed.
- **Front-end.** No CardsDLL response deserializer writes any "tournament
available/unlocked" field. `eligibilities 0xf1` / `unlocks 0x35c` / `available 0x3e`
are SBC/store vocab per `docs/ENDPOINT_MAP.md`, not wired to tournaments. Decision
is in the packed FIFA17.exe.
**Authority boundary.** The only server-touchable tournament byte
(`IS_TOURNAMENT_QUIT_ENABLED`) is an **emitted output** governing a different
feature, already 1. Everything else is cosmetic hub data or post-entry reward data.
Tile availability is decided **client-side**.
---
## 7. What changed vs the prior conclusion
The prior workflow examined **only the `FutDataManagerImpl` gate bytes** and
concluded "no server fix" for these modes. This workflow re-opened the question by
chasing the **hub-atom lead** — the six mode sub-deserializers we do not currently
populate — plus massinfo members, settings arms, and dedicated endpoints.
**The hub-atom lead does not change the conclusion for any mode.** Per mode:
- **Seasons:** the hub `friendlySeason`/`offline`/`onlineSeason` sub-objects are
numeric stat blobs (division/games/points), cosmetic like `hub.tradePile`. The
`[r14+0xa]=1` byte is a "field present" marker, not a JSON enable. No change —
still NOT_SERVER_REACHABLE.
- **Draft:** `draftSummary` carries only `draftState`+`gamesWon`; verify additionally
found the in-DLL navigation emitter already routes to the *enabled* destination on
current live state. No change — verdict **strengthened**.
- **SBC/Objectives:** the objectives hub arm is a cosmetic list feeding "MANAGER
TASKS N/M". Verify *corrected the prior chain* — the real objectives-enable byte is
`+0x1fd44` (inside the gate block, live=1), and SBC's enable key falls in a
dispatch gap with no byte at all. No change to the verdict; the correction only
hardens it.
- **Tournaments:** both hub sub-objects are display/clientData only. No change.
**Net:** examining the hub atoms was the right next step, and it closed the lead
rather than opening a fix. Every server-reachable surface for these four modes is
now accounted for and none is an availability input. The prior "no server fix"
conclusion holds, now on much broader evidence.
---
## 8. Client-vs-server authority boundaries (explicit)
| Surface | Who writes it | Who reads it | Is it a mode-availability gate? |
|---|---|---|---|
| Gate bytes `+0x1fd3a/3b/3d/44` etc. | **server** (settings applier `FUN_18011dc50`) | **client** (getter stubs, off-DLL vtable dispatch) + config serializer `0x18006ccd0` (emit) | No — all live=1, never branched on inside CardsDLL |
| Hub mode sub-objects (`0x131/1ec/1f6/e4/328/32c`) | **server** (`/hub` body) | CardsDLL parsers → cosmetic captions/counts | No — no enable atom in any of the six desers |
| `[r14+0xa]=1`, `[rdi+0x162]=1`, `[rsp+0x21]==1` markers | CardsDLL parser (local) | same parser | No — "field present" bookkeeping, never JSON-sourced |
| Settings config keys (`friendlySeasonsEnabled`, `enableDraftMode`, `enableObjectives`, `tournamentQuitEnabled`) | **server** (`/settings`) | applier → gate bytes → **client** | No — inputs already at enabled; readers are off-DLL |
| SBC enable (`enableSquadBuildingSetsFeature 0x100`) | — | — | **No surface** — falls in the settings dispatch gap, lands on no byte |
| Offline/online season enable | — | — | **No surface** — no config key, no gate byte, no getter |
| `/season`, `/tournament`, `/sbs/*` endpoints | server (utas, routed) | never requested at hub | No — client never polls them; tile greys before any request |
| DestroyMatch reward fields (`tournamentCoins`, `teamOfTournamentWinner`) | server (post-match) | reward payout | No — reached only inside a match |
| The greying/refusal decision itself | — | **client** (Denuvo-packed FIFA17.exe / Frostbite viewmodels) | **This is the gate — and it has no server surface** |
The single load-bearing fact across all four modes: **every server-writable enable
input that exists is already at ENABLED live, its only reader is the client, and the
tile refuses anyway.** No response body we can send flips a state the front-end has
already decided.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,256 @@
# FIFA 17 SBC client-hook implementation plan
## Outcome
Implement an opt-in, fail-closed hook that repairs the native response-to-deserializer
dispatch for `GET /ut/game/fifa17/sbs/sets`. The hook must reuse the genuine response
object and SAX reader from the real HTTP 200 transaction, run synchronously on the native
transaction thread, and preserve the game's allocator, object ownership, callbacks, and
index rebuilds.
This plan supersedes the intervention direction in `plan-2026-08-07-sbc-hook.md` and
`sbc-hook-dll-spec.md` wherever those documents claim the client never issues `/sbs/sets`
or recommend constructing a synthetic reader. The fresh 10:20:20 exchange proves the
request is issued and receives populated JSON. The reconciliation report is authoritative.
## Proven anchors
All addresses are static VAs in `CardsDLL_Win64_retail.dll`, image base `0x180000000`.
Runtime addresses are `CardsDLL base + (static VA - 0x180000000)`.
| Purpose | Address / identity |
|---|---|
| Category request constructor | `0x18017a7c0`, request vtable `0x18022e5c0`, tag `0x753c` |
| `/sets` URI builder | `0x18017a980` |
| Typed response factory | `0x18017aa10`, response vtable `0x18022e5b0` |
| Typed category deserializer | `0x18017b2b0`, `rcx=response`, `rdx=genuine reader` |
| Generic completion | `0x18016cca0`, exact-200 check at `0x18016cdd0` |
| FUT root | `A = *0x1802e6398`, expected vtable `0x18021c2a0` |
| SBC gate cache | `B=A+0x1f9d8`; ready byte `B+0x28` |
| Category store | `M=*(A+0x20a68)`; count `WORD[M+0x50]` |
| Renderer count read | `0x1800b5eda` |
Entering `0x18017b2b0` necessarily invokes the `A+0x20a68` lazy getter before JSON-key
parsing. The fresh transaction left that pointer null, proving that the typed category
deserializer was not entered.
## Architecture decision
Use the existing `openfut-hook` Rust `cdylib` and FIFA 17 feature boundary. Retain its
deferred CardsDLL discovery, RVA calculation, guarded reads, default-off environment
gates, and logging. Replace the stale Tier-1 idea of constructing a reader with this flow:
```text
real /sbs/sets HTTP 200
-> native generic completion and typed-response factory
-> observe the real response object and real reader/body cursor
-> at the proven skipped dispatch boundary, call the original typed method once
-> native parser populates M and rebuilds its indices
-> resume the native callback/completion chain
-> validate M; use native gate state if available
-> only if necessary, arm B+0x28 while B+0x08 remains zero
```
Do not intercept at the socket layer, fabricate a SAX reader, retain response/reader
pointers beyond their synchronous lifetime, hand-build EASTL category/set records, or
write `B+0x08`/`B+0x20`.
## State and feature gates
Use independent flags; no stronger stage should be implied by a weaker one:
- `OPENFUT_SBC_HOOK=1`: resolve and fingerprint only.
- `OPENFUT_SBC_TRACE=1`: install passive probes and structured logging.
- `OPENFUT_SBC_DISPATCH=1`: enable the one-shot native dispatch repair.
- `OPENFUT_SBC_COMMIT=1`: permit gate/refresh action after validated parse success.
- Keep `OPENFUT_SBC_ARM_ONLY=1` solely as a separate negative-control experiment.
Represent runtime progress with an atomic state machine:
```text
Disabled -> Resolved -> Intercepted -> Parsed -> Validated -> Committed
\-> Failed
```
Add a recursion-depth guard and a transaction one-shot keyed by request/response identity.
Any fingerprint, pointer, status, class, thread, reader, or postcondition mismatch moves to
`Failed` and resumes native execution without a write.
## Milestones
### M0 — reconcile and freeze the baseline
1. Mark the reconciliation report as the address/path authority.
2. Record SHA-256, PE timestamp, `SizeOfImage`, and selected section hashes for the shipped
CardsDLL, FIFA executable, built hook, and deployed proxy DLL.
3. Preserve a known-good launcher and proxy DLL. Do not overwrite a game-directory DLL
without an exact backup and hashes.
4. Capture a baseline: FUT hub succeeds, `/sbs/sets` returns 200, SBC shows the modal,
`M==0`, and the category deserializer is not observed.
Exit: the baseline is repeatable and its artifacts identify one binary build exactly.
### M1 — stabilize DLL loading
The existing `version.dll` injection has one historical successful log, but the current
FIFA 17 launcher disables it after later crashes. Resolve this before SBC detours:
1. Port or implement the complete VERSION proxy export surface and forward every export.
2. Build only `--features fifa17` for `x86_64-pc-windows-gnu` into a staging directory.
3. Inspect PE architecture, exports, and imports with the MinGW binutils.
4. Add a FIFA-17-specific launch path using the existing prefix/UMU configuration and
explicit `WINEDLLOVERRIDES=version=n,b`.
5. Run three cold launches with every SBC mutation/trace flag disabled.
Exit: all three launches reach the FUT hub, VERSION calls forward correctly, and disabling
the override restores the pre-hook baseline.
### M2 — strengthen runtime resolution
Before any detour or byte write, validate:
- exact CardsDLL identity (`SizeOfImage`, PE metadata, and multiple section/function hashes);
- FNV control bytes at `0x180180d00`;
- expected bytes at every proposed patch site;
- `A` and its expected vtable;
- `B` and its expected vtable;
- readable `M` slot and sane cache fields; and
- that runtime VAs lie inside the expected CardsDLL sections.
Use the external read-only `futmem`/probe tooling as an independent oracle. Never cache an
ASLR slide across launches.
Exit: resolve-only mode passes on two launches with different slides and aborts cleanly on
a deliberately mismatched fingerprint fixture.
### M3 — passive transaction tracing
Instrument, without changing return values or state:
1. generic completion `0x18016cca0`;
2. typed response factory `0x18017aa10`;
3. typed category deserializer `0x18017b2b0`; and
4. once found, the common body/SAX virtual-dispatch callsite.
Log a monotonic timestamp, session/build ID, thread ID, recursion depth, status, request
pointer/vtable, response pointer/vtable, reader/body pointer and vtable, and `M`/`B`
before and after. Correlate a request ordinal with `/tmp/utas.log`; do not log SID/auth
values or full response bodies.
Do not use the existing generic four-register probe wrapper for `0x18016cca0`. That routine
has a fifth stack argument. Use a relocated trampoline or a narrowly verified assembly
stub that preserves the full Win64 ABI: nonvolatile GPRs, XMM6-XMM15 if touched, 32-byte
shadow space, 16-byte call alignment, and all stack arguments. The diagnostic
unhook/call/rehook mechanism is also racy and is not acceptable for the final repair.
Exit: one fresh exchange unambiguously identifies whether the factory is skipped, the typed
object exists without a body/reader, or virtual deserialization dispatch is skipped.
### M4 — reverse the exact dispatch contract
Use M3 captures and static analysis to answer all of these before enabling intervention:
- the exact common body-to-response-deserializer callsite;
- the relationship between response vtable `0x18022e5b0` slot `+0x08` and the older
message-object vtable `0x18022e598` slot `+0x20`;
- which completion argument or object field owns the genuine reader;
- the reader's valid synchronous lifetime;
- whether `0x1800b8c30` executes after a successful forced parse;
- the native transaction/game thread identity; and
- whether the parser can be reached more than once for one response.
Exit: a written call contract identifies the exact hook site, preserved instructions,
original target, arguments, ownership, thread, and resume address.
### M5 — behavior-preserving detour
Install the production-form detour at the chosen boundary but initially tail-call the
original path unchanged. Prefer a small audited trampoline abstraction over copying the
repository's unhook/rehook diagnostic pattern.
Exit: exactly one balanced entry/exit is recorded per SBC exchange; HTTP traffic, modal,
M/B state, timing, and unrelated FUT screens remain unchanged.
### M6 — guarded dispatch repair
On the native transaction thread and only while the genuine objects are live:
1. require request vtable `0x18022e5c0`, response vtable `0x18022e5b0`, and status 200;
2. require a readable reader pointer/vtable and recursion depth zero;
3. require that this transaction has not already been parsed;
4. call the original typed method `0x18017b2b0(response, reader)` exactly once;
5. capture its return and the resulting M state; and
6. resume the native completion/callback path.
Never run this from the deferred worker or while the SBC controller is iterating. Do not
attempt in-place memory repair after an exception or partial parse; preserve logs and
relaunch FIFA.
Exit: the deserializer is observed once, returns successfully, and native execution
continues without gate or refresh writes.
### M7 — validate and commit UI state
Before exposing populated data, require:
- `M != 0` and a bounded category count;
- category vector `begin <= end <= capacity`;
- `(end-begin) % 0xf0 == 0` and vector length equals `WORD[M+0x50]`;
- sane, unique category/set identifiers and bounded nested counts;
- all native index-rebuild/finalization calls observed; and
- no duplicate parse or partial state.
First allow the native callback to arm the cache. If it does not, the only fallback is
`BYTE[B+0x28]=1` while `B+0x08==0`; never write `B+0x08` or `B+0x20`. Initially require
the user to close/reopen SBC for refresh. Do not synthesize Scaleform events until the
signature and ownership contract of `0x1801a4a70` are independently proven.
Exit: no modal; displayed categories and set counts match the served response.
### M8 — regression, soak, and rollback proof
1. Open/close SBC ten times; enter every set/challenge and return.
2. Verify a second `/sets` response is idempotent and does not duplicate data.
3. Smoke-test hub, club, store, squads, and normal service traffic.
4. Repeat from two fresh launches with different ASLR slides.
5. Soak 30–60 minutes with navigation and, if supported, repeated FUT enter/exit.
6. Disable all SBC flags and confirm the baseline behavior returns without detours/writes.
7. Disable `WINEDLLOVERRIDES`, restore the exact backed-up proxy if needed, and prove hard
rollback with FIFA closed.
Exit: zero crashes/freezes, stable counts and memory behavior, no unrelated FUT regression,
and both soft and hard rollback are demonstrated.
## Testing and build checks
Run at minimum:
```text
cargo fmt --check
cargo test --features fifa17
cargo check --release --features fifa17 --target x86_64-pc-windows-gnu
cargo build --release --features fifa17 --target x86_64-pc-windows-gnu
```
Extract pure, host-testable helpers for RVA calculation, fingerprint comparison, state
transitions, bounded vector validation, and structured event formatting. Windows calls,
raw pointer reads, and patching should remain behind small interfaces so guard logic can be
tested without launching FIFA.
## Stop conditions
Stop and roll back on any unknown binary fingerprint, patch-byte mismatch, wrong vtable,
wrong thread, unexpected factory/deserializer count, recursion, invalid vector geometry,
missing finalizer, partial parse, crash/freeze, unrelated FUT regression, or save/profile
change. Preserve hook log, UTAS log, binary hashes, and crash evidence before relaunching.
## Definition of done
- The hook is default-off and endpoint/class-specific.
- Exact binary and patch-site fingerprints are verified before intervention.
- The real category deserializer runs exactly once for each intended HTTP 200 response,
using the genuine response and reader on their native thread.
- `M` passes structural validation and the populated SBC menu supports drill-down.
- No communication modal appears and non-SBC FUT behavior is unchanged.
- Two fresh ASLR-distinct launches and the soak test pass.
- Unsetting flags restores inert behavior; removing the proxy restores the original launch.
@@ -0,0 +1,237 @@
# SBC Menu Render Intervention — Plan (2026-08-07)
**STATUS (one line): YES, WITH CAVEATS — a populated SBC menu is achievable via a
client-side hook, but ONLY by making the game's own parser fill its store; a
/proc/mem byte poke alone can open the menu (negative control) but renders EMPTY, and
the one remaining un-reversed item (the SAX input-source `vtable[+0x8]` byte-yield
contract) blocks the fully-offline populate until a served /sbs/sets response or a
completed reader is wired.**
All addresses are on-disk RVAs against CardsDLL image base `0x180000000`
(`/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`, working copy `/tmp/fut/cardsdll.dll`).
Live slide this session = `0x6ffe7c140000` (mapped base `0x6ffffc140000`), proven via
FNV prologue at `0x180180d00`. Live values below are from read-only `/proc/12201/mem`.
---
## 1. Definitive SBC data-flow
### Object graph
- **A** = FUT root singleton = `*[0x1802e6398]`. Getter `0x18011a830`. A.vtable static
`0x18021c2a0`. Live A = `0xb83e2b60` (vtable matches static — CONFIRMED).
- **B** = SBC request/TTL gate cache = `A + 0x1f9d8`. B-getter = A.vtable[+0x4e8] =
thunk `0x18011c1f0` (`lea rax,[rcx+0x1f9d8]; ret`). B.vtable static `0x1801fae70`
(3 slots: dtor `0x180063040`, isValid `0x180065d40`, clear `0x180065d20`). Live B =
`0xb8402538` (vtable matches). **B is the GATE, not the render source.**
- **M** = SBC categories/sets store = `*(A + 0x20a68)`. Reached via A.vtable[+0x9b0] =
lazy getter `0x18011b7d0` (if `A[+0x20a68]==0` it factory-creates an EMPTY M, type-id
`0x13f0`, and caches it). Live M = `0x0` (never built this session — SBC menu not
opened). **M IS the render source.**
- The "SBC manager" is **A itself**: service-id `0xed84b12` resolver A.vtable[+0x18] =
`0x180113f50` returns `this`, so `manager.vtable[+0x9b0] == A.vtable[+0x9b0] ==
0x18011b7d0`. The old lead `0x1801e9010` is DEBUNKED — it is an `.rdata` function
pointer slot (`->0x18018577a`), not a manager global.
### Render source (CLIENT authority)
The SBC hub/squads controller (ctor `0x1800b5267`) caches M into `controller+0x140`
by calling A.vtable[+0x9b0] once (`0x1800b554d`→`0x1800b5571`→store `[rsi+0x140]`),
then registers Scaleform events `0x756c`–`0x7574`. The tile-build method (`0x1800b5e00`
region) reads `[ctrl+0x140]=M` and at **`0x1800b5eda`** does
`movzx ebx,WORD[M+0x50]; add bx,0x2; call [scaleform.vtable+0x58](count)` → emits
**(category_count + 2) tiles**. This region reads `[ctrl+0x140]` seven times and reads
B/`A+0x1fa00` **zero** times. M layout: cat count `WORD[M+0x50]`; cat vector
`[M+0x58]..[M+0x60]` stride `0xf0`; per-cat set count `WORD[cat+0xb8]`, set vector
`[cat+0xc0]` stride `0x3570`; secondary/featured vec `[M+0xa10]..[M+0xa18]`;
indices at `+0x9e0/+0xa10/+0xa40`. **Correction on record:** earlier passes that
called `B[+0x08]` the render source conflated the gate with the data source — the empty
render was because M was null/empty, NOT because `B[+0x08]` was null.
### Populate path (CLIENT authority)
The sbs/sets deserializer **`0x18017b2b0`** (rcx=this IGNORED; rdx=SAX cursor is the
only live input) does the whole populate: fetch manager → get store M via
`[manager.vtable+0x9b0]` (at `0x18017b327`) → clear `0x18015f3a0` → loop atom `0x6f`
"categories": per item ctor `0x180159da0` (0xf0, vtable `0x18021b520`), cat-deser
`0x18017ab80`, cat-finalize `0x180160e50`, APPEND `0x18015a770` (copy-ctor
`0x18015a2b0`), dtor `0x1801105d0` → after loop rebuild indices `0x180160e00` +
`0x180160f30` + `0x180161020` → commit `manager.vtable[+0x8]`. Always returns true.
Set-row deser `0x18017ad60`. **Populate-target == render-source (both are M).**
### Prefetch gate (SERVER/front-end authority — THE WALL)
There is **no native flag** to flip. The only native online check `0x1801642c0`
(inside isValid) is stubbed `mov al,1; ret` — NOT the wall. The block is upstream in
the Flash/ActionScript FUT front-end (FNV-name-hash bound; `RequestChallengeData` =
`0x1801f9b30`, `futsbchubviewmodel` = `0x1801ee0a0` — no native xref), which refuses to
issue `GET ut/game/fifa17/sbs/sets` offline, so deser `0x18017b2b0` never runs.
**Newly proven:** the URL template `"ut/%s/sbs"` (`0x18021d908`) has ZERO references
in the image (siblings `ut/%s/tournament`, `ut/%s/season` ARE referenced) — so
**CardsDLL has no native code that self-builds/issues the sbs GET.** This kills any
"force the req-mgr at A+0x2a0 to fetch on its own" idea. This is why the fix must be
client-side and must FORCE the populate.
### Ready-arm (CLIENT authority)
isValid `0x180065d40(B)` verified: `if !0x1801642c0() ret0` (stub→always passes);
`cmp [rbx+0x28],0; je fail`; **`cmp QWORD[rbx+0x8],0; je 0x180065d75` → returns 1
immediately (short-circuit)**; else QueryPerformanceCounter (`0x1801e50c0`) and compare
`[rbx+0x20]` deadline. Normally B is armed by the completion callback `0x1800b8c30`
(subscribed in svc ctor `0x1800b5765` via `manager.vtable[+0xa90]`) through the generic
cache copy-assign `0x1800c21a0` (sets B+0x08=collection, B+0x20=deadline, B+0x28=1).
Offline that callback never fires (no response). Live: `B[+0x08]=0`, `B[+0x28]=0`.
---
## 2. Chosen minimal intervention and WHY
**Reuse the client's own parser; do NOT hand-build structs; arm ONLY `B[+0x28]`.**
Two tiers, safest-first:
- **Tier-0 (negative control — proves the gate):** write ONLY `BYTE[B+0x28]=1`.
isValid short-circuits (B+0x08==0 branch) → menu OPENS instead of the error modal
(`0x18016c330`), but renders EMPTY (M is null/empty). Do NOT write `B+0x08` or
`B+0x20` — pointing B+0x08 at a collection forces isValid into the QPC-deadline
branch, and with the live-stale deadline (`0xf10fb8cb9`) the gate SHUTS → modal, i.e.
it DEFEATS the fix. This is the load-bearing correction from adversarial verification.
- **Tier-1 (real fix — populates M):**
- **Preferred (Option 1, cleanest, zero forged state):** inject a canned
`/sbs/sets` JSON response at the message-receive layer so the game builds the
response-msg (ctor `0x18017b1c0`, vtable `0x18022e598`, deser slot +0x20 =
`0x18017b2b0`), seats a genuine SAX cursor, its OWN chain populates M, and the
native completion callback `0x1800b8c30` arms B for you. The bridge/core serves the
JSON. Nothing forged.
- **Fallback (Option 2):** from the hook, stand up a real SAX cursor over canned JSON
(ctx `0x1801c63e0` + lexer `0x1801c8060` + an input-source whose `vtable[+0x8]`
yields bytes), call deser `0x18017b2b0(rcx=ignored, rdx=cursor)`, then arm ONLY
`BYTE[B+0x28]=1`. **Blocker:** the input-source `vtable[+0x8]` byte-yield contract
is the ONE un-reversed item — a cold call with a null-source cursor CLEARS M
(`0x18015f3a0`) then byte-scans a garbage pointer (`mov rdi,[rdi]` ~`0x18017b353`)
→ wipes state + segfault. So Option 2 is NOT safe to run until the reader is
reversed.
**Why not hand-build:** feeding `0x18015a770` a hand-built 0xf0 category (with nested
0x3570 set records / EASTL sub-vectors) is the highest crash risk — the copy-ctor
`0x18015a2b0` deep-copies inner sub-vectors; any bad begin/end/cap → heap corruption.
The parser writes the correct geometry AND runs the index-rebuild finalizers that
hand-built appends get wrong. Ruled out.
**Refresh:** after M is populated, fire refresh events `0x756c`–`0x7574` (or re-open the
menu) so `0x1800b5eda` re-reads `WORD[M+0x50]`.
---
## 3. STAGED MORNING TEST PLAN (safest-first)
Precondition: FIFA at the FUT hub with CardsDLL loaded. Rollback for EVERY step =
**relaunch FIFA** (all effects are volatile — single-byte poke or in-session hook state,
cleared on restart). NEVER run `--apply` while the SBC menu is open/mid-iterate.
### Step 1 — Dry-run read confirm (ZERO writes)
```
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py
```
Expect: CONTROL FNV MATCH; A vtable match; B offset decoded live = `0x1f9d8`; B/A vtables
match statics; `B+0x28=0`; `M=*(A+0x20a68)=0` (until SBC menu opened once).
PASS = addresses match the model. Rollback: none needed (read-only).
### Step 2 — Review the DLL populate spec (ZERO writes)
```
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py --spec
```
Expect: printed injected-DLL spec (Option 1 preferred, Option 2 fallback). Read-only.
### Step 3 — Negative control (Tier-0, ONE byte write) — proves the GATE
With the SBC menu **CLOSED**:
```
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py --apply
```
Writes exactly `BYTE[B+0x28]=1` (re-proves slide+vtables at write time; aborts on any
mismatch; hard-refuses to write B+0x08/B+0x20). Then re-open the SBC menu.
Expect: menu OPENS, no error modal, ~2 empty/placeholder tiles. This proves the gate +
isValid short-circuit LIVE — it does NOT prove data. If it CRASHES: stop — B
resolution/slide is wrong. Rollback: relaunch FIFA (byte clears on restart).
### Step 4 — Real fix (Tier-1) — proves the DATA (NOT for a blind run)
Do this only after the DLL populate is implemented. Preferred: bring up the bridge/core
`/sbs/sets` responder and let Option 1 (message-layer injection) drive the native chain;
the completion callback arms B and M fills. Then the same gate opens a POPULATED menu
(N+2 tiles). The hook module scaffold is `openfut-hook/src/sbc_hook.rs` — Tier-1
`populate_m()` is present but deliberately refuses to call the deser until the SAX
input-source reader is reversed (else it clears M and crashes). Build (when ready):
```
cd /home/alex/Documents/OpenFUT/openfut-launcher/openfut-hook && \
cargo build --release --features fifa17 --target x86_64-pc-windows-gnu
```
Deploy as `version.dll` per launcher setup. Env gates (all default OFF):
`OPENFUT_SBC_HOOK=1` (read-only resolve+log), `OPENFUT_SBC_ARM_ONLY=1` (Tier-0),
`OPENFUT_SBC_POPULATE=1` (Tier-1, currently logs the blocker and returns).
Rollback: unset env vars and relaunch FIFA.
### Step 5 — Cleanup
Unset all `OPENFUT_SBC_*` env vars; relaunch FIFA to a clean state.
---
## 4. Crash-risk assessment
1. **Cold-calling `0x18017b2b0` without a real seated cursor** — CLEARS M
(`0x18015f3a0`) first, then `mov rdi,[rdi]` byte-scan on a garbage ptr → wipes
state + segfault. HIGHEST. Tier-1 code refuses this until the reader is reversed.
2. **Writing `B+0x08`/`B+0x20`** — forces isValid into the QPC-deadline branch; stale
deadline → gate SHUTS (modal), or garbage-ptr iterate crash. Self-defeating.
Tool/code write ONLY `B+0x28`.
3. **Populate off the game thread / mid-iterate** — lazy getter allocates on game heap,
appender mutates EASTL vectors; a foreign thread races the allocator/menu iterate →
heap corruption. Tier-1 must run on the game/message-pump thread with the menu closed.
4. **Skipping the index-rebuild finalizers** (`0x180160e00/0x180160f30/0x180161020`)
after append → stale `+0x9e0/+0xa10/+0xa40` indices → by-index getter `0x180160a80`
reads OOB → crash/garbage tiles.
5. **`WORD[M+0x50]` > actual 0xf0-stride entries** → tile loop walks past vector end
(OOB read).
6. **Hand-built 0xf0/0x3570 structs fed to `0x18015a770`** — copy-ctor `0x18015a2b0`
deep-copies inner EASTL sub-vectors; bad begin/end/cap → heap corruption. Avoid.
7. **No refresh after populate** (non-crash) — controller keeps the cached empty M at
`ctrl+0x140`; `0x1800b5eda` won't re-run → still 2 placeholder tiles. Fire
`0x756c`–`0x7574` or re-open.
8. **Manager/store null** — deser does `mov rax,[rbx]` on the manager; registry lookup
(hashes `0xed84b11`/`0xed84b12`) returning null → null-deref. Live registry
`*[0x1802c2988]` non-null, so low risk; hook must still null-check M/store.
Tier-0 (single `B+0x28=1` write, B+0x08 left 0) is the verified-SAFE case: isValid
short-circuits to 1, renders empty, no crash; bg-thread-tolerant like the /proc poke.
---
## 5. Poke tool + DLL-spec locations
- Poke tool (read-only default; `--spec`; `--apply` = ONLY `BYTE[B+0x28]=1`):
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py`
- Negative-control byte poke (older, triple-guarded):
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_populate_poke.py`
- Slide/read template + FNV control proof:
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/gate_byte_probe.py`
- DLL integration spec (RVA math, object graph, gate disasm, function-signature table,
3 intervention tiers, 8-item crash register, staged test plan):
`/home/alex/Documents/OpenFUT/fifa17-recon/docs/sbc-hook-dll-spec.md`
- Injected-DLL module (fifa17-only; Tier-0 live, Tier-1 scaffolded/refusing):
`/home/alex/Documents/OpenFUT/openfut-launcher/openfut-hook/src/sbc_hook.rs`
(wired via `lib.rs` `#[cfg(feature="fifa17")] mod sbc_hook;` + `fifa17.rs`
`crate::sbc_hook::install();`)
- Atoms table: `/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv`
---
## Client-vs-server authority boundaries (flagged)
- **RENDER (M, tiles at `0x1800b5eda`)** — CLIENT. The client draws tiles solely from
M; the server never touches this. Fix is client-side.
- **POPULATE (deser `0x18017b2b0` → M)** — CLIENT parser, SERVER-fed data. The parser
is native and reusable; the DATA it needs (`/sbs/sets` JSON) is a server response.
Preferred fix has the bridge/core supply that JSON so the client parses it natively.
- **PREFETCH GATE (issue `GET sbs/sets`)** — SERVER/front-end. THE WALL. No native
flag; the SWF/ActionScript front-end refuses to request offline, and CardsDLL has no
native code that issues the GET (`ut/%s/sbs` unreferenced). This cannot be fixed
server-side by responding — the request is never sent. The hook must force the
populate (inject the response at the message layer or drive the parser).
- **READY-ARM (`B[+0x28]`, callback `0x1800b8c30`/commit `0x1800c21a0`)** — CLIENT.
Normally armed by the completion callback (server-response-driven); offline the hook
arms it (Tier-0 byte, or Option 1 lets the native callback arm it).
@@ -0,0 +1,138 @@
# SBC "problem communicating with the FIFA Ultimate Team servers" — definitive analysis
**Date:** 2026-08-07
**Binary under study:** `/tmp/fut/cardsdll.dll` (on-disk PE, image base `0x180000000`; copy of `/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`)
**Method:** clean-room, read-only. On-disk `objdump` re-verified in this pass; live values quoted from prior read-only `/proc/<pid>/mem` reads (pid 12201, slide `0x6ffe7c140000`, FNV control MATCH). No memory was written; FIFA was not touched.
---
## VERDICT (one line)
**The SBC modal is a CLIENT-SIDE, per-feature completion-path defect — the FUT client never re-arms a fetch/re-render for `sbs/sets` the way it does for the hub — so NO server response can cure it; the only offline lever is a client-memory patch, and the clean single-byte patch (`model+0x1fa00 = 1`) only SUPPRESSES the modal by forcing the completion predicate true, rendering from an empty, never-populated cache. It is NOT the go-online wall.**
---
## 1. What the SBC completion predicate actually checks (CONFIRMED on-disk)
The SBC menu entry runs a completion continuation whose gate is the shared predicate **`0x180065d40`**, called as `[cache_vtable+0x08]`. Re-disassembled this pass, byte-for-byte:
```
180065d40 call 0x1801642c0 ; online/liveness sub-check
180065d4e test al,al
180065d50 je fail
180065d52 cmp byte [rbx+0x28],0 ; <-- THE GATE: "value ready" flag
180065d56 je fail
180065d58 cmp qword [rbx+0x8],0 ; pending-op ptr
180065d5d je pass (mov al,1) ; empty-collection shortcut -> success
180065d5f lea rcx,[rsp+0x38]
180065d64 call QueryPerformanceCounter ; [rip]->0x1801e50c0
180065d6a mov rax,[rbx+0x20] ; QPC deadline
180065d6e sub rax,[rsp+0x38]
180065d73 js fail ; deadline passed -> fail
180065d75 mov al,1 ; pass
...
180065d7d xor al,al ; fail
```
Reduces to: `subcheck() && byte[cache+0x28]!=0 && (qword[cache+0x08]==0 || deadline[cache+0x20] not yet past)`.
- **The online/liveness sub-check `0x1801642c0` is stubbed OUT.** On-disk bytes are `b0 01 c3` = `mov al,1; ret` — always true, in the shipped file (not a live loader patch). **This is the reason SBC is NOT the go-online wall** (see §5).
- `cache` (`rbx`) is an **embedded sub-object of the FUT root singleton** `A = *[0x1802e6398]`, selected by a vtable thunk (see §2). Its `+0x28` byte is a "value-ready" flag (init 0 by ctor `0x180062460`); `+0x08` is a pending-op pointer; `+0x20` is a QPC deadline. This is a copyable future/async-result value type. **The predicate never reads the parsed SBC categories, HTTP status, session, or any live-connection boolean.**
> **AUTHORITY BOUNDARY:** everything the predicate reads lives inside client process memory (`A+…`). Nothing in the `sbs/sets` HTTP response is an input to it. This is a **client-authority** decision end to end.
---
## 2. Why hub passes but `sbs/sets` fails (CORRECTED after adversarial verification)
Both features run the **same predicate function** `0x180065d40`, but on **different embedded caches**, reached through **different per-response-class continuations**. That structural divergence is real and confirmed. **The originally-stated reason ("hub passes because its cache `+0x28` is set") is WRONG** and is corrected here — corroborated by a live measurement (HUB cache `+0x28 = 0` while the hub is displayed with no modal) and by the on-disk FALSE-branch disassembly gathered this pass.
### The two continuations, side by side (on-disk, this pass)
| | SBC (`FutLoadSetTypesServerResponse`) | HUB (`FutGetHubDataServerResponse`) |
|---|---|---|
| continuation | `0x180154860` | `0x180173770` |
| get singleton A | `call 0x18011a830` (`mov rax,[0x1802e6398]`) | same |
| select cache | `call [rdx+0x4e8]` → thunk `0x18011c1f0` = `lea rax,[rcx+0x1f9d8]` → **SBC cache A+0x1f9d8** | `call [rdx+0x1f8]` → thunk `0x18011a810` = `lea rax,[rcx+0x1fd70]` → **HUB cache A+0x1fd70** |
| predicate | `call [rdx+0x08]` = `0x180065d40` | **same** `0x180065d40` |
| on TRUE (jne) | render `0x18015491a → 0x180154600` | render `0x18017383d → 0x1801735e0` |
| **on FALSE** | `lea rdx,[rbp-0x9]` (descriptor `0x18020a8b8`); **`call 0x18016c330`**; `jmp` return | **`call 0x1801213b0` (state reset)**; `lea 0x1801736f0` (continuation fn); **`call 0x18011f8e0` (register completion closure)**; `lea 0x18022cd30` (descriptor); **`call 0x18016c330`**; **`call 0x18011f900` (cleanup)** |
### What this proves
1. **`0x18016c330` is NOT an SBC-only "modal" function.** The HUB continuation calls the very same `0x18016c330` (at `0x18017382c`) on its own not-ready branch. It is a shared, descriptor-parameterized async dispatcher; SBC passes descriptor `0x18020a8b8`, hub passes `0x18022cd30`.
2. **At idle both predicates return FALSE.** Live: HUB cache `A+0x1fd70+0x28 = 0` **and** SBC cache `A+0x1f9d8+0x28 = 0`, both `+0x08 = 0`. The hub is on screen with no modal *while its own predicate would return FALSE*. So "hub `+0x28` is set" is false; a set flag is not what makes the hub pass.
3. **The real asymmetry is the FALSE-branch work.** On not-ready the HUB continuation **resets its request-state region** (`0x1801213b0`), **registers a completion closure** (`0x18011f8e0`, continuation `0x1801736f0`) so the arriving response re-runs the continuation and re-renders, then cleans up (`0x18011f900`). It is a proper get-or-fetch: cache-miss → (re)issue request → render on completion. **The SBC continuation does NONE of that** — it fires the dispatcher once with delegate `0x180154590`/descriptor `0x18020a8b8` and returns. It never re-arms a fetch and never wires the `sbs/sets` response back into a re-render.
**Conclusion:** hub and SBC diverge at the cache-selection call site (`[rdx+0x1f8]` vs `[rdx+0x4e8]`, one instruction apart), and — decisively — in the not-ready handling. The modal is produced **downstream in the SBC dispatched path** (dispatcher `0x18016c330` + delegate `0x180154590`), because the SBC feature is wired as a one-shot with no re-fetch/re-render, whereas the hub is wired as a self-rearming get-or-fetch. It is **not** decided by cache selection alone, **not** by the shared predicate, and **not** by the `+0x28` byte value at idle.
---
## 3. VERDICT by route — is SBC beatable, and how?
| Route | Outcome | Why |
|---|---|---|
| **A. Server response field / header / status** | **RULED OUT — no offline fix here** | No field in the `sbs/sets` body reaches the predicate (client-authority §1). Deeper: the SBC continuation never registers a completion closure to consume the response and re-render, so *even a perfect response is dropped on the floor*. The deserializer `0x18017b2b0` returning TRUE is genuinely irrelevant. |
| **B. Client memory byte patch** `model+0x1fa00 = 1` | **Suppresses the modal, but empty menu — cosmetic** | Forces predicate TRUE → routes to the SBC render branch `0x18015491a → 0x180154600`, which reads the embedded SBC cache. That cache was never populated (`+0x08 == 0`, empty collection), so the likely result is an empty / non-functional SBC screen, not populated SBCs. **Untested under the read-only rule.** |
| **C. Config `FUT/SBC_USE_STUBS`** (rdata `0x1802270f8`) | **Not the gate** | Read at the deser top only; the normal (off) path already runs. Flipping it does not touch `+0x28` or the continuation wiring. |
| **D. "Needs the go-online wall solved"** | **REFUTED** | The only connection-like sub-check on this path (`0x1801642c0`) is stubbed to always-true on-disk. SBC is blocked by local per-feature completion wiring, not by the reconnect gate. See §5. |
| **E. Client CODE patch of the SBC FALSE-branch** | **The only route to a *functional* SBC menu** | Make `0x180154860`'s not-ready branch replicate the hub's sequence: state reset `0x1801213b0` + register completion closure `0x18011f8e0`/`0x1801736f0` + dispatch + cleanup `0x18011f900`, so the `sbs/sets` response is fetched and rendered. This is a code patch, not a byte flip and not a server change. Out of scope for a server-side preservation fix; a client-side authority modification. |
**Bottom line:** there is **no server-side fix**. SBC is "beatable" only in the client-authority sense — either cosmetically (byte B, hides the modal over an empty menu) or functionally (route E, a code patch replicating the hub's re-arm). Neither is a change our offline server can make.
---
## 4. Memory patch details (if used) — flagged CLIENT-SIDE AUTHORITY
> **CLIENT-SIDE AUTHORITY — this is a modification of the FIFA client's own process memory, not an OpenFUT server response. It changes what the client decides, and it violates the current read-only rule; it is documented for completeness, not endorsed as the fix.**
- **Cosmetic modal-suppression (route B):**
- **Absolute displacement into FUT root singleton:** `A + 0x1f9d8 + 0x28` = **`model + 0x1fa00`**, where `A = *[0x1802e6398]`.
- **Live absolute (pid 12201 snapshot):** `0xb8402538 + 0x28 = 0xb8402560`.
- **Value:** write `0x01` (one byte).
- **Effect:** predicate `0x180065d40` short-circuits at `cmp byte[rbx+0x28],0` → with `+0x08==0` the empty-collection shortcut returns TRUE → continuation `jne 0x18015491a` renders. **Modal gone; SBC cache empty → expect an empty/possibly-broken menu.** Not verified (read-only).
- **Persistence:** the object is embedded in the singleton (singleton lifetime). The SBC path calls only `[vt+0x08]`; nothing on this path calls the invalidator `[vt+0x10]=0x180065d20`, so a write should persist across menu re-entry (inferred from structure, not demonstrated).
- **Functional fix (route E)** requires a `.text` patch to the SBC continuation, not a data byte — see §3 row E. Do not confuse the two.
---
## 5. Relationship to the online-modes / go-online-wall finding
SBC is **not** the same wall as online Draft's "PRESS Q TO RECONNECT":
- The single connection-like sub-check reachable from the SBC predicate, `0x1801642c0`, is compiled out (`mov al,1; ret`) in the shipped binary. The SBC gate therefore encodes **no** unmet network condition — it is a purely local completion-wiring problem.
- The online modes differ structurally: their gate keeps a real pending network op at `+0x08` and/or a non-stubbed sub-check, so their predicate encodes a network state a local byte-flip cannot satisfy. That is why the online wall is not beatable by a byte and SBC's modal is (cosmetically).
- This is consistent with the prior **"refusing modes = no server fix"** finding: no field, count, header, or status in any HTTP response flips the client-side completion state for these features. SBC extends that finding with the precise mechanism — the client never re-arms the `sbs/sets` fetch/re-render at all.
---
## Appendix — confirmed addresses (image base `0x180000000`)
| Symbol | Address | Note |
|---|---|---|
| FUT root singleton getter | `0x18011a830` | `mov rax,[0x1802e6398]; ret` |
| FUT root singleton ptr | `[0x1802e6398]` | live `A = 0xb83e2b60` |
| FUT root vtable (static) | `0x18021c2a0` | |
| SBC cache selector thunk | `0x18011c1f0` | `lea rax,[rcx+0x1f9d8]` (slot `A.vt+0x4e8`) |
| HUB cache selector thunk | `0x18011a810` | `lea rax,[rcx+0x1fd70]` (slot `A.vt+0x1f8`) |
| SBC cache | `A+0x1f9d8` | vtable `0x1801fae70`; live `0xb8402538` |
| HUB cache | `A+0x1fd70` | vtable `0x18021c1e0` |
| shared predicate `isValid` | `0x180065d40` | `cache.vt+0x08` for both |
| stubbed online sub-check | `0x1801642c0` | `b0 01 c3` = `mov al,1; ret` |
| cache ctor / copy-ctor | `0x180062460` / `0x1800c21f3` | init `byte[+0x28]=0` |
| invalidator | `0x180065d20` | `cache.vt+0x10`; not called on SBC path |
| SBC continuation | `0x180154860` | class `RS4:FutLoadSetTypesServerResponse` (str `0x1802270b8`, vt row `0x180227090`) |
| HUB continuation | `0x180173770` | class `RS4:FutGetHubDataServerResponse` (str `0x18022ce40`, vt row `0x18022ce18`) |
| shared async dispatcher | `0x18016c330` | called by BOTH FALSE-branches (SBC `0x180154913`, HUB `0x18017382c`) |
| SBC delegate / descriptor | invoke `0x180154590` / desc `0x18020a8b8` | |
| HUB re-arm: state reset | `0x1801213b0` | HUB-only, `0x1801737bd` |
| HUB re-arm: register closure | `0x18011f8e0` (cont. `0x1801736f0`) | HUB-only, `0x180173815` |
| HUB re-arm: cleanup | `0x18011f900` | HUB-only, `0x180173836` |
| SBC render branch (on TRUE) | `0x18015491a → 0x180154600` | reads empty SBC cache |
| `sbs/sets` deserializer | `0x18017b2b0` | returns TRUE unconditionally (`mov al,1 @0x18017b751`); irrelevant to predicate |
| QueryPerformanceCounter import | `0x1801e50c0` | |
**Which prior conclusion won:** the structural divergence (same predicate, different cache, different continuation; online sub-check stubbed; not server-fixable) is upheld. The specific pass/fail *reason* is corrected: it is the **FALSE-branch re-arm asymmetry**, not a set `+0x28` byte and not an SBC-exclusive `0x18016c330`.
@@ -0,0 +1,211 @@
# FIFA 17 SBC response reconciliation
**Verdict:** the live client receives HTTP 200 for `GET /ut/game/fifa17/sbs/sets`, but the
typed `FutSBCLoadCategoryDetailsServerResponse` deserializer is not invoked. The evidence
does **not** identify a server-controlled header, envelope field, or correlation value that
can fix this. The previous `0x180154860` “SBC continuation” diagnosis was based on the wrong
request class and is retracted.
## Scope and authority
This pass used only:
- the shipped `CardsDLL_Win64_retail.dll` copied to `/tmp/fut/cardsdll.dll`;
- read-only `/proc/<pid>/mem` access to the running game;
- the local OpenFUT request log; and
- existing clean-room notes and scripts in this repository.
No game memory was written, no breakpoint was inserted, and no service or game process was
restarted during the measurement.
## Fresh live observation
The control run used fresh FIFA process **PID 59054**. The CardsDLL mapping resolved to
`0x6ffffc140000`, giving slide `0x6ffe7c140000`. Bytes at static control function
`0x180180d00` matched the on-disk DLL, proving the mapping/slide before data reads.
At the FUT hub, before opening SBC:
- `A = *[0x1802e6398] = 0xb78f7c50`;
- `M = *(A+0x20a68) = 0`;
- hub cache byte `*(A+0x1fd70+0x28) = 1` (fresh hub response ready); and
- SBC cache byte `*(A+0x1f9d8+0x28) = 0`.
The user then opened the SBC tile. The real client exchange was:
```text
[10:20:20] GET /ut/game/fifa17/sbs/sets
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
Accept: application/json
Content-Type: application/json
X-UT-SID: OPENFUT-SID-0000000000000001
Accept-Encoding: gzip
-> 200 {"categories":[...]}
```
The game displayed “There was a problem communicating with the FIFA Ultimate Team servers.”
With that modal still open, the same slide was re-proved and `M` was still exactly zero.
### What `M == 0` proves
The typed `/sets` deserializer is `0x18017b2b0`. At `0x18017b309`–`0x18017b327` it obtains
the FUT root and calls vtable slot `+0x9b0`, the lazy getter `0x18011b7d0`. That getter
allocates and stores `A+0x20a68` before the deserializer examines the root object or the
`categories` key.
Consequently:
- valid JSON would leave `M` non-null;
- malformed or empty JSON reaching this function would also leave `M` non-null; and
- `M == 0` after the completed HTTP transaction means `0x18017b2b0` was not invoked.
The normal reset of `M` is `0x180114ee0`; its observed use belongs to broad FUT-root
initialization/reset work, not the `/sets` completion path. There is no evidence that the
deserializer ran and then immediately cleared `M` during this transaction.
## Correct class map
Three classes were conflated in earlier notes:
| Function/class | Proven URI | Role |
|---|---|---|
| `FutSBCLoadCategoryDetailsServerResponse`, request URI builder `0x18017a980`, factory `0x18017aa10`, response deser `0x18017b2b0` | `/sets` under the `ut/%s/sbs` base | Initial category/set list; this is the live failing request |
| `FutSBCSetDataServerResponse`, factory `0x18016fca0`, deser `0x18016fe90` | `/squadBuildingSets` (`0x18022bd88`) | Parses `reset`; not the observed `/sbs/sets` request |
| `FutLoadSetTypesServerResponse`, deser `0x180154990` | `/challenge/%d/squad` (`0x1802270e0`) | Parses `challengeId`, `playerRequirements`, and `squad`; later challenge flow |
This corrects two prior claims:
1. `FutSBCSetDataServerResponse` does **not** share the literal `/sets` URI in this binary;
its URI string is `/squadBuildingSets`.
2. `0x180154860` is not a dedicated completion continuation for the initial category-list
request. `0x180154830` is a generic callback thunk used by multiple request classes, while
the nearby `0x180154990` parser and `/challenge/%d/squad` URI belong to
`FutLoadSetTypesServerResponse`.
Therefore the earlier hub-versus-`0x180154860` comparison contrasted the hub with a later
challenge-squad operation, not with `GET /sbs/sets`. Its proposed “copy the hub re-arm path”
fix is unsupported for the category-list failure.
## What the generic completion code actually checks
The shared request completion routine `0x18016cca0`:
1. calls request vtable slot `+0x80` at `0x18016cd32` to create the class-selected typed
response object;
2. stores the received status at request offset `+0x48` (`0x18016cd3d`); and
3. compares it with decimal 200 at `0x18016cdd0`.
Exactly 200 takes the success branch to `0x18016d0b9`. Non-200 status invokes the error
translation path through request slot `+0x60` first. Response construction is selected by
the request vtable; it is not selected by an HTTP response header or a JSON envelope field.
No pre-deserialization branch found in this path reads `Content-Type`, a request/correlation
ID, the `X-UT-SID` response header, or a top-level JSON key. The live server already supplies
the one proven transport-level success input: status 200.
## Hub comparison
The fresh hub response was consumed successfully and set the hub cache byte to one. After
the subsequent navigation its resting value returned to zero. The SBC cache byte remained
zero. This confirms that cache `+0x28` is transient async-result/TTL state; a later resting
zero does not establish which completion branch ran.
The previous report's live snapshot—where both values were zero long after the requests—was
therefore insufficient to infer the hub/SBC divergence. The fresh before/after measurement
supersedes it.
## Server-fixability verdict
**Not demonstrated.** In particular:
- changing the category JSON cannot make the typed parser start, because the lazy store is
allocated before any JSON key is inspected;
- the server already returns the proven success status, 200;
- request-class/response-class selection is client-owned; and
- no header, envelope, or correlation field was found feeding a pre-parser decision.
This does not mathematically prove that no transport variation could ever affect the client.
It does prove that the specific server-fix candidates proposed by the killed workflow were
speculative and had no reading instruction behind them.
## Exact remaining unknown and next measurement
The unresolved boundary is between:
```text
ProtoHttp completion with status 200
-> class-selected response object creation
-> delivery of response bytes/SAX cursor
-> response vtable +0x08 (`0x18017b2b0`)
```
The next useful experiment is transient tracing of calls—not another resting-state scan.
Instrument, in a disposable/local diagnostic build or a non-mutating tracing facility:
- request factory `0x18017aa10`;
- typed deserializer `0x18017b2b0`;
- generic completion entry `0x18016cca0` and its status at `0x18016cdd0`; and
- the generic response-body/SAX dispatch site that calls response vtable slot `+0x08`.
Record whether the factory is called, whether it returns an object with vtable
`0x18022e5b0`, and whether a body/SAX object is delivered. That separates three remaining
client-side possibilities: wrong request instance despite the URI, typed object created but
body not attached, or body attached but virtual deserialization dispatch skipped.
Until that transient trace exists, the defensible implementation direction remains the
client-side hook described in `docs/sbc-hook-dll-spec.md`, but its rationale must be stated
as “native category deserializer is not reached,” not the retracted `0x180154860`
hub-rearm theory.
## 2026-08-07 passive-trace result: deserialization is proven
The first gated passive client trace supersedes the final inference above. During exactly
one SBC navigation, with every mutation feature disabled, the hook recorded:
```text
SBC_TRACE: factory entry=1 exit=1 tid=652 this=0xb80cd910 result=0x7a99178;
deser entry=1 exit=1 tid=652 this=0x7a99178 reader=0x7fcff7f8 result=true
```
The matching UTAS request occurred at `11:09:01`: `GET /ut/game/fifa17/sbs/sets` returned
HTTP 200 with one category and two sets. No degraded hook state was reported, and FIFA
remained alive until the operator closed it after the single permitted attempt.
This proves all of the following for the observed request:
- the category response factory is called exactly once and returns a non-null object;
- the native category deserializer is called exactly once on that same object;
- the body reader is non-null;
- deserialization returns success (`true`); and
- both calls return normally on the same native thread.
Therefore the earlier `M == 0` resting snapshot did not prove that `0x18017b2b0` was
skipped. The failure boundary is now strictly **after successful native deserialization**.
The next measurement must trace the response object's post-deserializer completion,
ownership handoff, and publication into the SBC UI/cache collection. Repeating the factory
or deserializer trace will not add useful information.
## 2026-08-07 post-deserializer handoff trace
A second one-shot run combined the factory/deserializer probes with atomic replacements of
the category request vtable slots `+0x90` (completion callback dispatch) and `+0x88`
(response ownership transfer). All four calls completed on native thread 656:
```text
request = 0xb80cdfe0
factory response = 0x7c94808
deserializer this = 0x7c94808, result=true
+0x90 callback argument = 0x7c94808
+0x88 owner-slot address = 0xbc51f7e8
```
The matching `GET /ut/game/fifa17/sbs/sets` at `11:24:00` returned HTTP 200, and the same
communication modal appeared. Both callback probes reported `entry=1 exit=1`; no degraded
hook state or process failure occurred.
This proves that the parsed response reaches the category request's completion dispatcher
and that its ownership-transfer routine also returns normally. The remaining failure
boundary begins at the receiving owner object's vtable `+0x18` consumer invoked from
`0x1801631e0`, or later collection/cache/UI validation. Network transport, response
construction, native parsing, callback dispatch, and request-side ownership handoff are no
longer candidate root causes.
+337
View File
@@ -0,0 +1,337 @@
# SBC render intervention — injected-DLL integration spec
**Goal:** make the FIFA 17 FUT **SBC menu render real SBC data** from inside the
process (client-side), proven not server-fixable. The DLL is the existing
`openfut-hook` (`version.dll`, cross-compiled `x86_64-pc-windows-gnu`, feature
`fifa17`). In-process calls to client functions are safe here (unlike `/proc/mem`
writes), because we run on the game's own threads with the real allocator.
**Binary of record (clean-room):** `/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`
(on-disk copy `/tmp/fut/cardsdll.dll`), PE image base `0x180000000`. Every address
below was re-verified byte-exact against this PE in this pass (vtable slots read from
`.rdata`, prologues from `.text`). Do **not** build/deploy from this spec without the
staged morning test (§9).
---
## 1. Module base + RVA math
CardsDLL is **not** present at `DllMain`/worker time — the boot module dump
(`C:\openfut_hook.log`) has no `CardsDLL*` entry. It is loaded lazily **only when the
user first enters Ultimate Team**. Therefore the hook must **defer** and poll for it,
exactly like `probe::install_probes_deferred` polls for `anadius64.dll`.
- Loaded module name (Wine keeps the on-disk filename): **`CardsDLL_Win64_retail.dll`**.
`GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0")`. Fallback: ToolHelp module walk
matching a name containing `CardsDLL` (see `fifa17::dump_modules` for the pattern).
- Image base in the PE is `0x180000000`. For any static VA in this doc:
```
rva = VA_static - 0x180000000
VA_runtime = cards_base + rva
```
`cards_base` is the runtime `HMODULE` of `CardsDLL_Win64_retail.dll` (its in-memory
load address). All the "0x180…" addresses below are **static VAs**; subtract
`0x180000000` to get the RVA, add `cards_base` to get the live pointer/callable.
- Slide-proof control (optional sanity, mirrors `tools/gate_byte_probe.py`): the FNV
prologue at VA `0x180180d00` must match the on-disk PE bytes
`48 83 ec 28 48 85 c9 74 50 45 33 c0 ba c5 9d 1c 81 …`. If it does not, **abort** —
the module map moved and the offsets are untrustworthy.
---
## 2. Verified object graph
```
A = FUT root singleton = *(0x1802e6398) getter thunk 0x18011a830 = { mov rax,[rip→0x1802e6398]; ret }
A.vtable (live [A]) = static 0x18021c2a0
A.vtable[+0x4e8] = 0x18011c1f0 = { lea rax,[rcx+0x1f9d8]; ret } -> B getter
A.vtable[+0x9b0] = 0x18011b7d0 = M lazy getter (see §3) -> M getter
A.vtable[+0x18] = 0x180113f50 = service-id 0xed84b12 -> returns `this` (proves manager == A)
B = SBC request/ready TTL cache = A + 0x1f9d8 vtable static 0x1801fae70
B+0x08 collection ptr (live 0 offline)
B+0x20 QPC deadline
B+0x28 ready byte (== A+0x1fa00 alias) <- the isValid gate byte
B.vtable[+0x00] dtor = 0x180063040
B.vtable[+0x08] isValid = 0x180065d40 (see §4)
B.vtable[+0x10] clear = 0x180065d20
M = SBC categories/sets store = *(A + 0x20a68) <- THE RENDER SOURCE (see §3, §5)
M+0x50 WORD category count
M+0x58 cat-vector begin (element stride 0xf0)
M+0x60 cat-vector end
M+0xa10 secondary/featured vec begin (8-byte elems) (emptiness-checked at render)
M+0xa18 secondary vec end
per category (+0xf0 stride):
cat+0xb8 WORD set count
cat+0xc0 set-vector begin (element stride 0x3570)
set+0x1c9 byte per-set flag
```
HUB cache (works online) is the **same class** at `A + 0x1fd70` (vtable `0x18021c1e0`)
— reference only.
**Manager fetch used by BOTH the deser and the render controller** (so
populate-target == render-source):
```
reg = 0x1800d7170() ; -> &registry (static 0x1802c2988)
mgr = 0x180009c80(&out, reg) ; out = manager (hashes 0xed84b11 / 0xed84b12)
M = mgr.vtable[+0x9b0](mgr) ; 0x18011b7d0, lazily creates/returns *(A+0x20a68)
```
Because svc-id `0xed84b12` resolves to `A` (A.vtable[+0x18] returns `this`),
`mgr == A` and `mgr.vtable[+0x9b0] == A.vtable[+0x9b0] == 0x18011b7d0`. The hook may
therefore fetch M the short way — `A = *(0x1802e6398); M = (*(void***)A)[0x9b0/8](A)` —
**or** the long way (registry) — they return the identical object.
---
## 3. M lazy getter — 0x18011b7d0 (verified disassembly)
```
18011b7d0 push rbx; push rdi; sub rsp,0x38
18011b7e0 mov rdi,rcx ; rcx = A (this)
18011b7e3 cmp QWORD [rcx+0x20a68],0 ; M already built?
18011b7eb jne 18011b873 ; yes -> return it
18011b7f1 call 0x18019e3c0 ; factory: allocate an EMPTY M (type-id 0x13f0)
… … ; init fields, cache at A+0x20a68, return
```
Cold-calling this alone **creates an EMPTY M** (`WORD[M+0x50]==0`) → the menu draws
**2 placeholder tiles** (count+2). It does **not** populate. Populating is §5.
---
## 4. The gate — isValid 0x180065d40 (verified disassembly)
```
180065d40 push rbx; sub rsp,0x20; mov rbx,rcx ; rcx = B
180065d49 call 0x1801642c0 ; online sub-check — STUBBED `mov al,1;ret`
180065d4e test al,al ; je fail ; never the wall
180065d52 cmp BYTE [rbx+0x28],0 ; je fail ; <-- READY BYTE gate
180065d58 cmp QWORD [rbx+0x8],0 ; je 0x180065d75 ; <-- if collection==0 -> RETURN 1 (short-circuit)
180065d5f lea rcx,[rsp+0x38]; call [rip→0x1801e50c0]; QueryPerformanceCounter
180065d6a mov rax,[rbx+0x20]; sub rax,[rsp+0x38] ; deadline - now
180065d73 js fail ; past deadline -> fail
180065d75 mov al,1 ; …; ret ; success
```
**Load-bearing correction (adversarially confirmed, verified in this pass):** arm
**only** `BYTE[B+0x28]=1` and **leave `QWORD[B+0x08]=0`**. With `B+0x08==0` the function
takes the `je 0x180065d75` short-circuit and returns 1 immediately. If you instead
write `B+0x08` (pointing it at the collection), isValid falls into the QPC-deadline
branch; with the live-stale deadline (`B+0x20 = 0xf10fb8cb9`, already in the past) it
returns **0 → modal → gate SHUTS**. So **never** manually write `B+0x08` or `B+0x20`.
Rendering reads **M** (§5), not `B+0x08`, so nothing needs `B+0x08` set.
---
## 5. Render source — M, not B (verified disassembly)
Controller ctor caches M into `controller+0x140`:
```
1800b554d call 0x1800d7170 ; reg
1800b555d call 0x180009c80 ; mgr = out
1800b556b mov rax,[rbx] ; mgr.vtable
1800b5571 call [rax+0x9b0] ; M = 0x18011b7d0(mgr)
1800b5577 mov [rsi+0x140], rax ; controller+0x140 = M
…then registers Scaleform events 0x756c-0x7574 via 0x1801a4a70
```
Tile-count emit (each menu build):
```
1800b5eda mov rax,[r13+0x140] ; rax = M
1800b5ee1 movzx ebx,WORD [rax+0x50] ; ebx = category count
1800b5ee5 add bx,0x2 ; +2 placeholder tiles
1800b5ee9 mov rax,[r15] ; Scaleform model vtable
call [rax+0x58](count) ; push (category_count + 2) list tiles
```
Helper thunks (verified): `0x18015fff0 = lea rax,[rcx+0x58]` (&M cat-vector),
`0x1801607e0 = lea rax,[rcx+0xa10]` (&M secondary vector). **Zero** reads of
`B`/`A+0x1f9d8`/`A+0x1fa00` exist in the tile-build region — B is purely the entry
gate. Populate M ⇒ tiles appear.
---
## 6. Populate path — reuse the real parser (deser 0x18017b2b0)
The category rows are appended **only** by the sbs/sets deserializer. Its geometry and
finalizers are the correct way to fill M (hand-building `0xf0`/`0x3570` structs is
brittle and rejected — §8).
```
18017b2b0 (rcx = this, IGNORED) (rdx = a PRIMED SAX reader over the token stream)
18017b2ef mov rdi,rdx ; keeps the incoming reader in rdi (the byte source)
18017b2fb call 0x1801c63e0(&localctx, 0, 0) ; builds a SECONDARY ctx with a NULL source
18017b309 call 0x1800d7170 ; reg
18017b316 call 0x180009c80 ; mgr
18017b327 call [mgr.vtable+0x9b0] ; M (0x18011b7d0)
… clear 0x18015f3a0(M) ; ALWAYS clears M first (see crash risk C1)
… loop atom 0x6f "categories":
0x180159da0(&tmp) ; cat ctor (0xf0, vtable 0x18021b520)
0x18017ab80(&tmp, reader) ; cat deser (needs the reader)
0x180160e50(&tmp) ; cat finalize (set index)
0x18015a770(M, &tmp) ; APPEND (copy-in; copy-ctor 0x18015a2b0)
0x1801105d0(&tmp) ; cat dtor
… 0x180160e00(M); 0x180160f30(M); 0x180161020(M) ; rebuild M indices (+0x9e0/+0xa10/+0xa40)
… commit mgr.vtable[+0x8](mgr)
18017b751 ret (always true)
```
**The reader (`rdx`) is the crux.** The deser does **not** ingest `rdx` through the
`0x1801c63e0` ctx it builds (that one is created with a NULL source, `rdx=0/r8=0`);
instead it keeps the **incoming** `rdx` in `rdi` and scans its bytes directly (e.g. the
NUL-terminated backslash-unescape at `~0x18017b353` does `mov rdi,[rdi]`). So `rdx`
must be a **fully-constructed, already-primed SAX reader/cursor object** seated over
your canned `sbs/sets` JSON — the same object type the message framework produces on a
real response. **Building that reader from scratch is the one remaining un-reversed
contract** (its vtable, and specifically the `[+0x8]` byte-yield slot, are not yet
pinned). Until it is, the fully-offline parser-reuse call is **not turnkey** — see the
three tiers in §7.
SAX primitives already known (for when the reader is reconstructed): ctx init
`0x1801c63e0(rcx=ctx,rdx=source,r8=flags)`, lexer `0x1801c8060`, next-token
`0x1801c7f10`, begin-object `0x1801c8270`, INT `0x1801c79d0`, STR `0x1801c7aa0`,
BOOL `0x1801c7620`, SKIP `0x180135ff0`.
Response-msg object (for the message-layer tier): ctor `0x18017b1c0` installs vtable
`0x18022e598`; slot `[+0x20] == 0x18017b2b0` (deser) — **verified**. Constructing this
object alone still does **not** seat the reader (the framework does that from received
bytes), so it doesn't remove the reader gap.
---
## 7. Three intervention tiers (implement in this order)
**Tier 0 — arm-only negative control (SAFE, non-crash, renders EMPTY).**
Resolve A→B, write `BYTE[B+0x28]=1`, leave `B+0x08=0`. isValid short-circuits true, the
menu opens and draws **2 placeholder tiles** (M empty/null). Proves the gate model live
without any populate. This is the first morning step and the baseline. Implemented and
env-gated in `sbc_hook.rs` (`OPENFUT_SBC_ARM_ONLY=1`).
**Tier 1 — parser-reuse populate (the intended fix, BLOCKED on the reader).**
On the game thread: build a primed SAX reader over canned `sbs/sets` JSON served by the
bridge/core, `call 0x18017b2b0(rcx=0, rdx=reader)` (self-locates mgr, clears, appends,
finalizes, commits → fills M), then Tier-0 arm (`BYTE[B+0x28]=1` only), then trigger a
menu refresh (§ below). **Cannot be enabled** until the reader contract (§6) is
reversed. `sbc_hook.rs` contains the guarded scaffold that logs the blocker and returns
— it does **not** call the deser with a fabricated reader (that would clear M and/or
crash — C1/C6).
**Tier 2 — message-layer injection (cleanest long-term, feasibility unproven).**
Push a canned `sbs/sets` response through the real receive path so the framework builds
the response-msg (`0x18017b1c0`), seats the reader itself, runs `0x18017b2b0`, fires the
completion callback (`0x1800b8c30`, subscribed in svc ctor `0x1800b5765` via
`mgr.vtable[+0xa90]`), and arms B natively (generic copy-assign `0x1800c21a0`) — **zero
forged state**. Requires reconstructing the message-receive entry + response-msg wiring;
treat as the target, not the default.
**Refresh trigger** (Tier 1/2): the controller re-reads `WORD[M+0x50]` at `0x1800b5eda`
on every build, so **re-opening the SBC menu** suffices. Programmatic alternative: fire
Scaleform refresh events `0x756c-0x7574` via `0x1801a4a70`. If M is populated but no
refresh fires and the controller already cached an empty M at `ctrl+0x140`, you still see
2 placeholder tiles (no crash, just no data) — see C7.
---
## 8. Function signatures (Win64 `extern "system"`; rcx, rdx, r8, r9 → rax)
| Purpose | Static VA | Signature (Rust `unsafe extern "system"`) |
|---|---|---|
| A getter thunk | 0x18011a830 | `fn() -> *mut u8` (returns `*(0x1802e6398)`) |
| B getter (via A vtable +0x4e8) | 0x18011c1f0 | `fn(a: *mut u8) -> *mut u8` (`a+0x1f9d8`) |
| M lazy getter (A vtable +0x9b0) | 0x18011b7d0 | `fn(mgr: *mut u8) -> *mut u8` (`*(mgr+0x20a68)`, lazily built) |
| isValid (B vtable +0x08) | 0x180065d40 | `fn(b: *mut u8) -> bool` |
| registry getter | 0x1800d7170 | `fn() -> *mut u8` |
| manager getter | 0x180009c80 | `fn(out: *mut *mut u8, reg: *mut u8) -> *mut u8` |
| sbs/sets deser (whole) | 0x18017b2b0 | `fn(this_ignored: *mut u8, reader: *mut u8) -> bool` |
| SAX ctx init | 0x1801c63e0 | `fn(ctx: *mut u8, source: *mut u8, flags: u64) -> *mut u8` |
| clear M | 0x18015f3a0 | `fn(m: *mut u8)` |
| cat ctor (0xf0) | 0x180159da0 | `fn(tmp: *mut u8) -> *mut u8` |
| cat deser | 0x18017ab80 | `fn(tmp: *mut u8, reader: *mut u8) -> bool` |
| cat finalize | 0x180160e50 | `fn(tmp: *mut u8)` |
| append into M | 0x18015a770 | `fn(m: *mut u8, tmp: *mut u8)` |
| cat dtor | 0x1801105d0 | `fn(tmp: *mut u8)` |
| M index rebuild ×3 | 0x180160e00 / 0x180160f30 / 0x180161020 | `fn(m: *mut u8)` each |
| QueryPerformanceCounter thunk | 0x1801e50c0 | (indirect; not needed if B+0x08 left 0) |
| Scaleform refresh dispatch | 0x1801a4a70 | `fn(ctrl: *mut u8, event_id: u32, …)` (event ids 0x756c-0x7574) |
M is **per-session heap** — never hardcode its address; always go A → `A.vtable[+0x9b0]`.
---
## 9. Staged morning test plan (human, live)
Preconditions: FIFA 17 at the FUT hub (so CardsDLL is loaded). One env var flips each
tier; all default **off/inert**. Watch `C:\openfut_hook.log`.
1. **Injection + resolution (read-only).** Launch with `OPENFUT_SBC_HOOK=1` only. The
deferred thread should log: CardsDLL base + slide-control OK, then `A=…`, `B=…`,
`B+0x28=0`, `M=*(A+0x20a68)=…` (0 until the SBC menu is opened once). No writes.
*Pass:* addresses match the model; control FNV OK.
2. **Tier-0 arm-only (negative control).** Add `OPENFUT_SBC_ARM_ONLY=1`. Open the SBC
menu. Expected: **menu opens, draws ~2 empty placeholder tiles, no modal, no crash.**
Confirms the gate byte and short-circuit live. If it crashes → stop (means B
resolution is wrong; recheck slide).
3. **Tier-1 populate — BLOCKED.** Do **not** enable until the SAX reader contract (§6)
is reversed. `OPENFUT_SBC_POPULATE=1` currently only logs the blocker and returns.
Next RE session: pin the reader vtable (`[+0x8]` byte-yield) and the reader ctor,
then wire the §6 sequence and re-test on the game thread with the menu **closed**,
then re-open to refresh.
4. Revert env vars to unset when done.
---
## 10. Crash-risk register (verified against the PE + prior adversarial passes)
- **C1 — cold-calling deser without a real reader.** `0x18017b2b0` **clears M first**
(`0x18015f3a0` before any append). A null/garbage reader → parses nothing but **wipes
M** (renders empty, destroys prior state), and the byte-scan at `~0x18017b353`
(`mov rdi,[rdi]`) segfaults on a bad pointer. This is exactly why Tier 1 is gated off.
- **C2 — clear/finalize race.** Deser clears then rebuilds M's vectors+indices; if the
render thread reads `WORD[M+0x50]` (`0x1800b5eda`) or by-index `0x180160a80` mid-build
→ OOB/crash. Populate on the game thread with the menu **closed**, then refresh.
- **C3 — skipping finalizers.** Any manual append via `0x18015a770` **must** be followed
by `0x180160e00`/`0x180160f30`/`0x180161020` or the `+0x9e0/+0xa10/+0xa40` indices go
stale and by-index lookups read OOB.
- **C4 — hand-built `0xf0`/`0x3570` structs.** Append's copy-ctor `0x18015a2b0`
deep-copies EASTL sub-vectors; a bad begin/end/cap → heap corruption. **Rejected**
(§8): drive the real parser instead.
- **C5 — writing `B+0x08`/`B+0x20`.** Forces isValid into the deadline branch; the
live-stale deadline shuts the gate → modal. **Set only `B+0x28`, leave `B+0x08=0`.**
- **C6 — null manager/M.** Deser does `mov rax,[mgr]`; if the registry lookup returned
null it's a null-deref. Live registry `*(0x1802c2988)` is non-null offline, but the
hook must null-check A, mgr, M before any use.
- **C7 — no refresh (non-crash).** Populate without firing refresh / re-open → controller
keeps its cached empty M → still 2 placeholder tiles. Fails the goal, not a crash.
- **C8 — foreign-thread allocation.** The lazy getter and appenders allocate on / mutate
the game heap; running them off the main/render thread races the allocator. Execute the
populate on a game thread (message-pump / a game-thread detour), not a bg thread. The
Tier-0 single-byte arm is tolerant of a bg write (it's what the `/proc` poke does), but
populate is not.
---
## 11. Live-probe baseline (this pass, read-only `O_RDONLY`, zero writes)
FIFA17.exe **was running** at spec time (pid 12201), CardsDLL mapped. Fresh live reads
this pass match the static model 1:1:
```
slide 0x6ffe7c140000 CONTROL FNV OK
A 0xb83e2b60 (= *(0x1802e6398))
B 0xb8402538 vt=0x1801fae70 (matches static) B+0x08(coll)=0 B+0x20=0xf10fb8cb9 B+0x28(ready)=0
HUB 0xb84028d0 vt=0x18021c1e0 coll=0 ready=0 (reference only)
M *(A+0x20a68)=0 (SBC menu not opened this session -> M not yet built)
```
So live: gate SHUT (`B+0x28=0`), collection null, **M null** — Tier-0 arm alone would
render empty (matches the model). All §2–§6 addresses + all vtable slots were
re-verified byte-exact against the on-disk PE in this pass.
Regular → Executable
+115 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches """Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches
the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches.""" the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches."""
import glob, time, struct import glob, time, struct, sys
# Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches # Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches
import glob, time, os import glob, time, os
@@ -24,7 +24,46 @@ STORE_PATCHES = {
0x1800175aa: NOP2, 0x1800175aa: NOP2,
} }
LOG="/tmp/autopatch.log" # Store resolver crash-guard for the empty "My Packs" case (bug 6c; PROVEN R1 on the
# tested FIFA 17 build -- see docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV and
# docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md).
#
# When no `mypacks` group exists, FIFA's Store resolver receives category id -1. CardsDLL
# FUN_1800147f0 @ 0x180014858 is `JNZ 0x14869` (75 0f): the original treats every non-zero
# category (including -1) as resolvable, calls FUN_180014420, gets NULL, and crashes at the
# [NULL+0x48] deref in FUN_1800147f0 (0x180014882). Changing JNZ->JG (7f 0f) preserves
# positive-category resolution (EDI>0 branch) while routing zero/negative categories through
# the existing Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.
#
# CAVEAT: this guards the category SIGN only. It does NOT protect a stale *positive* invalid
# ordinal produced by changing the Store group topology (sentinel-present <-> sentinel-absent)
# DURING one running FIFA process -- that reproduced the same crash in the confounded run F3.
# The empty-My-Packs representation MUST stay stable for a FIFA session (see the SESSION-STABLE
# invariant in the client-fix plan).
#
# Orig-verified / fail-closed: applied only when the live bytes are the known original (75 0f);
# already-patched (7f 0f) is a no-op; anything else is logged and SKIPPED (never blindly
# overwritten), so an unrecognised CardsDLL build is not patched.
STORE_PATCHES_GUARDED = {
0x180014858: (bytes.fromhex("750f"), bytes.fromhex("7f0f")), # JNZ 0x14869 -> JG 0x14869
}
# Capability advertised to the launcher/backend once the resolver guard is VERIFIED
# live in a specific FIFA process (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md #3/#4).
EMPTY_MYPACKS_RESOLVER_CAPABILITY = "fifa17.empty_mypacks_resolver"
EMPTY_MYPACKS_RESOLVER_VERSION = 1
# The guarded site whose verified enforcement backs the capability above.
RESOLVER_GUARD_VA = 0x180014858
# Per-FIFA-pid guard status (fail-closed; FIFA17_PATCHED_CLIENT_CAPABILITY.md #4).
GUARD_NOT_ATTEMPTED = "NOT_ATTEMPTED" # CardsDLL not mapped / guard not yet evaluated
GUARD_VERIFIED = "VERIFIED" # live bytes == patch after enforcement (patch or noop)
GUARD_UNSUPPORTED_BUILD = "UNSUPPORTED_BUILD" # neither original nor patched (guarded_action -> skip)
GUARD_WRITE_FAILED = "WRITE_FAILED" # /proc/<pid>/mem write raised
GUARD_VERIFY_FAILED = "VERIFY_FAILED" # post-write re-read != patch
LOG=os.environ.get("OPENFUT_AUTOPATCH_LOG", f"/tmp/openfut-autopatch-{os.getuid()}.log")
def log(m): def log(m):
line=f"[{time.strftime('%H:%M:%S')}] {m}" line=f"[{time.strftime('%H:%M:%S')}] {m}"
@@ -52,11 +91,55 @@ def wr(pid,va,b):
with open(f'/proc/{pid}/mem','r+b') as f: with open(f'/proc/{pid}/mem','r+b') as f:
f.seek(va); f.write(b) f.seek(va); f.write(b)
def guarded_action(cur, orig, patch):
"""Fail-closed decision for a guarded byte patch (see STORE_PATCHES_GUARDED).
Returns "noop" when the live bytes are already patched, "patch" when they are the
known original (safe to apply), or "skip" for anything else -- an unrecognised
CardsDLL build that must never be blindly overwritten.
"""
if cur == patch:
return "noop"
if cur == orig:
return "patch"
return "skip"
def guard_state_after(cur_before, orig, patch, wrote_ok, cur_after):
"""Map a guarded-patch enforcement outcome to a per-pid guard STATE (pure).
Mirrors guarded_action's decision, extended with post-write verification so the
caller advertises the capability only on VERIFIED. No /proc access -- unit-testable.
- cur_before == patch -> VERIFIED (already patched; guarded_action "noop")
- cur_before == orig -> WRITE_FAILED if the write raised, else VERIFIED when the
re-read is patch, else VERIFY_FAILED (guarded_action "patch")
- otherwise -> UNSUPPORTED_BUILD (guarded_action "skip")
"""
if cur_before == patch:
return GUARD_VERIFIED
if cur_before == orig:
if not wrote_ok:
return GUARD_WRITE_FAILED
if cur_after == patch:
return GUARD_VERIFIED
return GUARD_VERIFY_FAILED
return GUARD_UNSUPPORTED_BUILD
patched=set() patched=set()
store_patched=set() store_patched=set()
guard_reported=set()
log("=== AUTOPATCH watching for FIFA17.exe ===") if __name__ == "__main__":
while True: launcher_pid = None
if "--launcher-pid" in sys.argv:
try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1])
except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid")
log("=== AUTOPATCH watching for FIFA17.exe ===")
while True:
if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"):
log(f"launcher pid {launcher_pid} exited; stopping autopatch")
break
for pid in find_pids(): for pid in find_pids():
if pid not in patched: if pid not in patched:
try: try:
@@ -82,6 +165,34 @@ while True:
if rd(pid, live, len(data)) != data: if rd(pid, live, len(data)) != data:
wr(pid, live, data) wr(pid, live, data)
log(f"pid {pid}: ENFORCED store patch @ {live:#x}") log(f"pid {pid}: ENFORCED store patch @ {live:#x}")
for va, (orig, patch) in STORE_PATCHES_GUARDED.items():
live = cbase + (va - IMG_BASE)
cur = rd(pid, live, len(patch))
action = guarded_action(cur, orig, patch)
wrote_ok = True
cur_after = cur
if action == "patch":
try:
wr(pid, live, patch)
log(f"pid {pid}: ENFORCED guarded store patch @ {live:#x} (JNZ->JG, empty My Packs)")
except Exception as e:
wrote_ok = False
log(f"pid {pid}: guarded patch write failed @ {live:#x}: {e}")
if wrote_ok:
try:
cur_after = rd(pid, live, len(patch))
except Exception:
cur_after = b""
elif action == "skip":
log(f"pid {pid}: SKIP guarded patch @ {live:#x}: unexpected {cur.hex()} (build mismatch)")
# action == "noop": already patched; nothing to write.
if va == RESOLVER_GUARD_VA and pid not in guard_reported:
state = guard_state_after(cur, orig, patch, wrote_ok, cur_after)
if state == GUARD_VERIFIED:
log(f"[store-guard] verified capability {EMPTY_MYPACKS_RESOLVER_CAPABILITY}={EMPTY_MYPACKS_RESOLVER_VERSION} fifa_pid={pid}")
else:
log(f"[store-guard] guard status={state} fifa_pid={pid} (no capability advertised)")
guard_reported.add(pid)
if pid not in store_patched: if pid not in store_patched:
log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}") log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}")
store_patched.add(pid) store_patched.add(pid)
+103 -32
View File
@@ -128,14 +128,52 @@ CLIENT_ID = ACCOUNT.CLIENT_ID
PLATFORM = ACCOUNT.PLATFORM PLATFORM = ACCOUNT.PLATFORM
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n" SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
# ================================================================== config
HOST = "127.0.0.1" def refresh_account_identity():
"""Refresh launcher-selected identity before constructing a Blaze session.
The account sync endpoint runs in the separate UTAS process and atomically
replaces the shared active-account file. Blaze snapshots these aliases for
its response builders, so refresh them once at each new TCP session.
"""
global PERSONA_ID, PERSONA_NAME, USER_ID, EXT_ID, EMAIL, ACCOUNT_LOCALE_FALLBACK
ACCOUNT.load(force=True)
PERSONA_ID = ACCOUNT.persona_id
PERSONA_NAME = ACCOUNT.persona_name
USER_ID = ACCOUNT.user_id
EXT_ID = ACCOUNT.ext_id
EMAIL = ACCOUNT.email
ACCOUNT_LOCALE_FALLBACK = ACCOUNT.account_locale_int
# ================================================================== config
#
# Client/server split support (OpenFUT dev-container): two env vars, both
# defaulting to loopback so the original all-on-localhost flow is byte-identical.
# OPENFUT_BIND — the address the listeners bind (0.0.0.0 in a container).
# OPENFUT_ADVERTISE — the address this server hands back to the client for the
# NEXT hop (Blaze host, roster/UTAS/telemetry/QoS URLs). On
# 105-local this is 127.0.0.1; on the 120 server it is the
# server's LAN IP so the game dials 120 directly after the
# first (hook/DNAT-redirected) contact.
import os as _os_cfg
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
_BIND = _os_cfg.environ.get("OPENFUT_BIND", "127.0.0.1")
def _ip_str_to_u32(ip):
"""Dotted-quad -> big-endian u32 (matches the original (127<<24)|1 layout).
Falls back to loopback if the advertise value isn't a bare IPv4 literal."""
try:
a, b, c, d = (int(x) for x in ip.split("."))
return (a << 24) | (b << 16) | (c << 8) | d
except Exception:
return (127 << 24) | 1
HOST = _BIND
REDIR_PORT = 42127 REDIR_PORT = 42127
BLAZE_PORT = 42130 BLAZE_PORT = 42130
NUCLEUS_PORT = 42131 NUCLEUS_PORT = 42131
BLAZE_IP_STR = "127.0.0.1" BLAZE_IP_STR = _ADVERTISE
BLAZE_IP_U32 = (127 << 24) | 1 BLAZE_IP_U32 = _ip_str_to_u32(_ADVERTISE)
LOG = "/tmp/blaze_responder.log" LOG = "/tmp/blaze_responder.log"
RXDIR = "/tmp/blaze_rx" RXDIR = "/tmp/blaze_rx"
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
@@ -157,7 +195,9 @@ REPLY_EMPTY_TO_UNKNOWN = True
# (grid-blaze order) or after (pamplona order). Both are reported to work. # (grid-blaze order) or after (pamplona order). Both are reported to work.
NOTIFY_BEFORE_LOGIN_REPLY = False NOTIFY_BEFORE_LOGIN_REPLY = False
DUMP_FRAMES = True # Raw Fire2 frames and decoded TDF can contain auth/session material. Keep the
# reverse-engineering capture path, but require an explicit opt-in for it.
DUMP_FRAMES = os.environ.get("OPENFUT_BLAZE_DUMP_FRAMES") == "1"
_log_lock = threading.Lock() _log_lock = threading.Lock()
@@ -525,7 +565,8 @@ OSDK_TICKER = []
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url. # branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject # Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK. # http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
ROSTER_HOST = "127.0.0.1:8081" ROSTER_HOST = "%s:8081" % _ADVERTISE
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
OSDK_ROSTER = [ OSDK_ROSTER = [
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST), ("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0 ("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0
@@ -562,7 +603,6 @@ IDENTITY_PARAMS = [
# FUT_POW=1 ./openfut-fut.sh restart # FUT_POW=1 ./openfut-fut.sh restart
# and read /tmp/pow_server.log. FUT_POW=off is the instant fallback. # and read /tmp/pow_server.log. FUT_POW=off is the instant fallback.
POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094") POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094")
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
_POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes") _POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes")
OSDK_POW = [ OSDK_POW = [
("FIFA_POW_URL", "http://%s/" % POW_HOST), ("FIFA_POW_URL", "http://%s/" % POW_HOST),
@@ -571,6 +611,14 @@ OSDK_POW = [
("POW_IS_ON", "1"), ("POW_IS_ON", "1"),
] if _POW_ON else [] ] if _POW_ON else []
# CardsDLL's shared web-file downloader also reads this key for FUT-owned content.
# In particular, opening SBC downloads /fut/packs/loc/storepackdescriptions.<locale>.xml
# after /sbs/sets succeeds. Keep the content base available even while the unrelated
# POW API remains opt-in through FUT_POW/POW_IS_ON.
FUT_CONTENT_CONFIG = [
("FIFA_POW_CONTENT_SERVER_URL", "http://%s" % POW_CONTENT_HOST),
]
CLIENT_CONFIGS = { CLIENT_CONFIGS = {
"BlazeSDK": None, # built dynamically, see below "BlazeSDK": None, # built dynamically, see below
"netres": OSDK_NETRES, # CFID (verified @0x143962be0) "netres": OSDK_NETRES, # CFID (verified @0x143962be0)
@@ -595,7 +643,7 @@ CLIENT_CONFIGS = {
# /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/" # /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/"
# (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT # (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT
# (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code). # (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code).
UTAS_BASE = "http://127.0.0.1:8099/" UTAS_BASE = "http://%s:8099/" % _ADVERTISE
FUT_RS4_MODULES = [ FUT_RS4_MODULES = [
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD", "AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER", "DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
@@ -713,8 +761,11 @@ FUT_RS4_CONFIG = (
# is zero. Which atom writes +0x1c is UNKNOWN and is the thing worth chasing. # is zero. Which atom writes +0x1c is UNKNOWN and is the thing worth chasing.
# #
# Default OFF and it should stay off. # Default OFF and it should stay off.
+ ([(k, "1") for k in ("tradingEnabled", "IS_TRADING_ENABLED")] # NOTE: FUT_TRADING no longer does anything here. These keys are inert (output
if os.environ.get("FUT_TRADING") else []) # names the DLL emits, never reads). The REAL trading fix is in utas_server.py:
# userInfo.feature was banning trade. Left disabled so the flag has one meaning.
+ ([] if True else
[(k, "1") for k in ("tradingEnabled", "IS_TRADING_ENABLED")])
# NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any # NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any
# response -- proven inert (wf_96b6c0c5): they are JSON field names that route # response -- proven inert (wf_96b6c0c5): they are JSON field names that route
# to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md. # to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md.
@@ -728,18 +779,21 @@ FUT_RS4_CONFIG = (
def client_config_for(cfid: str) -> list: def client_config_for(cfid: str) -> list:
"""-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we """Return sorted config rows for one section.
still wrap in a present CONF field -- never an empty frame).
FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section Unknown CFIDs still receive the shared FUT/content/POW rows because those
CardsDLL reads is unproven, so serve them everywhere).""" consumers read the merged ``_all`` store and the contributing section is
unproven. The response always carries a present CONF field.
"""
# OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's # OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's
# FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which # FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which
# section it happens to read is unproven. Empty list when FUT_POW is unset, so # section it happens to read is unproven. Empty list when FUT_POW is unset, so
# this is a no-op by default. (Putting the keys ONLY under a hypothetical # this is a no-op by default. (Putting the keys ONLY under a hypothetical
# "OSDK_POW" CFID would be dead code -- nothing is known to request that name.) # "OSDK_POW" CFID would be dead code -- nothing is known to request that name.)
if cfid == "BlazeSDK": if cfid == "BlazeSDK":
return sorted(blazesdk_config() + FUT_RS4_CONFIG + OSDK_POW) return sorted(blazesdk_config() + FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG + OSDK_POW) return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG
+ FUT_CONTENT_CONFIG + OSDK_POW)
def fetch_config_response_fields(cfid: str) -> "OrderedDict": def fetch_config_response_fields(cfid: str) -> "OrderedDict":
@@ -762,7 +816,7 @@ def qos_config() -> "OrderedDict":
has NO SVID, unlike Mirror's Edge Catalyst).""" has NO SVID, unlike Mirror's Edge Catalyst)."""
return OrderedDict([ return OrderedDict([
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo ("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
("PSA", (STRING, "127.0.0.1")), ("PSA", (STRING, _ADVERTISE)),
("PSP", (INT, 17502)), ("PSP", (INT, 17502)),
]))), ]))),
("LNP", (INT, 10)), ("LNP", (INT, 10)),
@@ -1088,7 +1142,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
client to have a well-formed config and then fail to connect quietly rather client to have a well-formed config and then fail to connect quietly rather
than resolve a real EA hostname.""" than resolve a real EA hostname."""
tele = OrderedDict([ # GetTelemetryServerResponse (15) tele = OrderedDict([ # GetTelemetryServerResponse (15)
("ADRS", (STRING, "127.0.0.1")), ("ADRS", (STRING, _ADVERTISE)),
("ANON", (INT, 0)), ("ANON", (INT, 0)),
("DISA", (STRING, "")), ("DISA", (STRING, "")),
("EDCT", (INT, 0)), ("EDCT", (INT, 0)),
@@ -1105,7 +1159,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
("SVNM", (STRING, "telemetry-openfut")), ("SVNM", (STRING, "telemetry-openfut")),
]) ])
tick = OrderedDict([ # GetTickerServerResponse (3) tick = OrderedDict([ # GetTickerServerResponse (3)
("ADRS", (STRING, "127.0.0.1")), ("ADRS", (STRING, _ADVERTISE)),
("PORT", (INT, 8999)), ("PORT", (INT, 8999)),
("SKEY", (STRING, "")), ("SKEY", (STRING, "")),
]) ])
@@ -1249,8 +1303,10 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
log(" -- client locale 0x%08x captured for ALOC" % loc) log(" -- client locale 0x%08x captured for ALOC" % loc)
resp = preauth_response_fields(service_name=sess.service_name) resp = preauth_response_fields(service_name=sess.service_name)
payload = encode_tdf(resp) payload = encode_tdf(resp)
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s" log(" -> PreAuthResponse (INST=%r, %d payload bytes)"
% (sess.service_name, len(payload), heat2.dump(resp))) % (sess.service_name, len(payload)))
if DUMP_FRAMES:
log(" -> PreAuthResponse TDF:\n%s" % heat2.dump(resp))
return [reply_to(hdr, payload)] return [reply_to(hdr, payload)]
if cmd == CMD_PING: if cmd == CMD_PING:
@@ -1264,6 +1320,7 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
n = len(resp["CONF"][1][2]) n = len(resp["CONF"][1][2])
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s" log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)")) % (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
if DUMP_FRAMES:
for k, v in resp["CONF"][1][2]: for k, v in resp["CONF"][1][2]:
log(" %-32s = %s" % (k, v)) log(" %-32s = %s" % (k, v))
return [reply_to(hdr, encode_tdf(resp))] return [reply_to(hdr, encode_tdf(resp))]
@@ -1299,12 +1356,13 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
sess.auth_code = get_str(fields or {}, "AUTH", "") sess.auth_code = get_str(fields or {}, "AUTH", "")
sess.logged_in = True sess.logged_in = True
sess.login_time = int(time.time()) sess.login_time = int(time.time())
log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus " log(" == Authentication::login AUTH=[REDACTED] "
"validation -- forged offline session)" % sess.auth_code) "(accepted as an offline OpenFUT session)")
resp = login_response_fields(sess) resp = login_response_fields(sess)
payload = encode_tdf(resp) payload = encode_tdf(resp)
log(" -> LoginResponse (%d bytes):\n%s" log(" -> LoginResponse (%d bytes)" % len(payload))
% (len(payload), heat2.dump(resp))) if DUMP_FRAMES:
log(" -> LoginResponse TDF:\n%s" % heat2.dump(resp))
notifs = build_login_notifications(sess, sess.login_time) notifs = build_login_notifications(sess, sess.login_time)
out = [] out = []
if NOTIFY_BEFORE_LOGIN_REPLY: if NOTIFY_BEFORE_LOGIN_REPLY:
@@ -1455,9 +1513,10 @@ _frame_counter = [0]
def blaze_handle(raw: socket.socket, addr) -> None: def blaze_handle(raw: socket.socket, addr) -> None:
refresh_account_identity()
log("*** BLAZE CONNECT from %s ***" % (addr,)) log("*** BLAZE CONNECT from %s ***" % (addr,))
sess = Session() sess = Session()
log(" session key minted: %s" % sess.session_key) log(" session key minted: [REDACTED]")
buf = bytearray() buf = bytearray()
raw.settimeout(300) raw.settimeout(300)
try: try:
@@ -1488,10 +1547,10 @@ def blaze_handle(raw: socket.socket, addr) -> None:
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]), MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
hdr["msg_num"], hdr["user_index"], hdr["options"], hdr["msg_num"], hdr["user_index"], hdr["options"],
hdr["metadata_len"], hdr["payload_len"])) hdr["metadata_len"], hdr["payload_len"]))
if DUMP_FRAMES:
log("RX #%d HEX:\n%s" % (n, hexdump(frame))) log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
if metadata: if metadata:
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata))) log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
if DUMP_FRAMES:
try: try:
os.makedirs(RXDIR, exist_ok=True) os.makedirs(RXDIR, exist_ok=True)
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin" fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
@@ -1506,6 +1565,7 @@ def blaze_handle(raw: socket.socket, addr) -> None:
if payload: if payload:
try: try:
fields = decode_tdf(payload) fields = decode_tdf(payload)
if DUMP_FRAMES:
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields))) log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
except Exception as e: except Exception as e:
log("RX #%d TDF DECODE FAILED: %s" % (n, e)) log("RX #%d TDF DECODE FAILED: %s" % (n, e))
@@ -1527,6 +1587,7 @@ def blaze_handle(raw: socket.socket, addr) -> None:
ohdr["msg_type"]), ohdr["msg_type"]),
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]), MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
ohdr["msg_num"], len(out), ohdr["payload_len"])) ohdr["msg_num"], len(out), ohdr["payload_len"]))
if DUMP_FRAMES:
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024))) log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
except ConnectionResetError: except ConnectionResetError:
log("BLAZE %s: connection reset by client" % (addr,)) log("BLAZE %s: connection reset by client" % (addr,))
@@ -1625,6 +1686,10 @@ def redir_handle(raw: socket.socket, addr) -> None:
# client can never reach accounts.ea.com. Note the exact spacing in the JSON: # client can never reach accounts.ea.com. Note the exact spacing in the JSON:
# the client searches for the literal '"access_token" : "'. # the client searches for the literal '"access_token" : "'.
def nucleus_sent_log(addr, size):
return "NUCLEUS SENT %s %dB access_token=[REDACTED]" % (addr, size)
def nucleus_handle(raw: socket.socket, addr) -> None: def nucleus_handle(raw: socket.socket, addr) -> None:
try: try:
raw.settimeout(10) raw.settimeout(10)
@@ -1637,9 +1702,9 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
head, _, rest = req.partition(b"\r\n\r\n") head, _, rest = req.partition(b"\r\n\r\n")
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else "" line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
log("NUCLEUS REQ %s: %s" % (addr, line0)) log("NUCLEUS REQ %s: %s" % (addr, line0))
if head: if head and DUMP_FRAMES:
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace")) log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
if rest: if rest and DUMP_FRAMES:
log("NUCLEUS BODY: %r" % rest[:512]) log("NUCLEUS BODY: %r" % rest[:512])
token = "OPENFUT_" + "".join( token = "OPENFUT_" + "".join(
@@ -1653,7 +1718,7 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
b"Cache-Control: no-store\r\nContent-Length: " b"Cache-Control: no-store\r\nContent-Length: "
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body) + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
raw.sendall(out) raw.sendall(out)
log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token)) log(nucleus_sent_log(addr, len(out)))
except Exception as e: except Exception as e:
log("NUCLEUS ERR %s: %s" % (addr, e)) log("NUCLEUS ERR %s: %s" % (addr, e))
finally: finally:
@@ -1705,6 +1770,10 @@ def _selftest() -> None:
sess.account_locale = 0x656E5553 sess.account_locale = 0x656E5553
now = 1469000000 now = 1469000000
nucleus_summary = nucleus_sent_log(("127.0.0.1", 1234), 380)
assert "[REDACTED]" in nucleus_summary
assert "OPENFUT_selftest_secret" not in nucleus_summary
# ---- 1. preAuth still round-trips (regression guard vs v2) # ---- 1. preAuth still round-trips (regression guard vs v2)
pre = preauth_response_fields() pre = preauth_response_fields()
p = _check_roundtrip("PreAuthResponse", pre) p = _check_roundtrip("PreAuthResponse", pre)
@@ -1728,9 +1797,11 @@ def _selftest() -> None:
assert items == client_config_for(cfid), cfid assert items == client_config_for(cfid), cfid
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes" print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
% (cfid, len(items), len(pb))) % (cfid, len(items), len(pb)))
assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []" shared = sorted(FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
assert client_config_for("TOTALLY_UNKNOWN") == shared, \
"unknown CFID must carry only the shared merged-store rows"
assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \ assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \
"unknown CFID must still carry a CONF field (empty map, not empty frame)" "unknown CFID must still carry a CONF field"
# ---- 3. LoginResponse # ---- 3. LoginResponse
lr = login_response_fields(sess) lr = login_response_fields(sess)
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env bash
# FIFA 17 hook M1 staging/deployment helper.
#
# Safe defaults:
# inspect (the default) is read-only;
# stage writes only below the repository;
# deploy and launch require separate, exact confirmation variables.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
hook_root="${repo_root}/openfut-launcher/openfut-hook"
default_dll="${hook_root}/target/x86_64-pc-windows-gnu/release/openfut_hook.dll"
stage_root="${repo_root}/fifa17-recon/staging/fifa17-hook-m1"
game_dir="${OPENFUT_FIFA17_GAME_DIR:-/mnt/games/FIFA 17}"
wine_prefix="${OPENFUT_FIFA17_WINEPREFIX:-/home/alex/Games/umu/fifa17}"
proton_path="${OPENFUT_FIFA17_PROTONPATH:-UMU-Proton-10.0-4}"
hook_dll="${OPENFUT_FIFA17_HOOK_DLL:-${default_dll}}"
system_version="${wine_prefix}/drive_c/windows/system32/version.dll"
deployed_dll="${game_dir}/version.dll"
required_exports=(
GetFileVersionInfoA GetFileVersionInfoExA GetFileVersionInfoExW
GetFileVersionInfoSizeA GetFileVersionInfoSizeExA GetFileVersionInfoSizeExW
GetFileVersionInfoSizeW GetFileVersionInfoW VerFindFileA VerFindFileW
VerInstallFileA VerInstallFileW VerLanguageNameA VerLanguageNameW
VerQueryValueA VerQueryValueW
)
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
note() { printf '%s\n' "$*"; }
need_file() { [[ -f "$1" ]] || die "missing file: $1"; }
sha256() { sha256sum -- "$1" | awk '{print $1}'; }
pe_exports() {
x86_64-w64-mingw32-objdump -p "$1" |
awk '/\[Ordinal\/Name Pointer\] Table/{in_names=1; next} in_names && /\+base\[/ {print $NF}'
}
verify_pe64() {
local dll=$1
local format
format="$(x86_64-w64-mingw32-objdump -f "$dll" | awk '/file format/{print $NF}')"
[[ "$format" == "pei-x86-64" ]] || die "$dll is not a 64-bit PE DLL (format=${format:-unknown})"
}
verify_exports() {
local dll=$1 export_name
local exports
exports="$(pe_exports "$dll")"
for export_name in "${required_exports[@]}"; do
grep -Fxq "$export_name" <<<"$exports" ||
die "$dll lacks VERSION export $export_name; refusing to stage/deploy"
done
}
# Refuse any DLL that is not a FIFA-17-profile build.
#
# openfut-hook builds TWO mutually exclusive injection paths from one crate: the
# default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin hooks, while
# `--features fifa17` installs ONLY the FIFA-17-safe logic (module map, FIFA 17
# cert-verify, SBC dispatch, store tab bind). Deploying a default-feature build
# into FIFA 17 hijacks the login transport and the client reports "Unable to
# connect to the EA servers", with none of the FIFA 17 repairs present.
#
# That exact mistake happened on 2026-08-19 (artifact 1c71a17a, hand-built without
# the feature): two failed launches, diagnosed only by comparing embedded strings.
# `build` below passes the feature, but a hand-built DLL can reach `stage`/`deploy`
# via OPENFUT_FIFA17_HOOK_DLL, so assert the profile on the bytes themselves.
verify_fifa17_profile() {
local dll=$1 marker
# Markers that MUST be present: the FIFA 17 target module and its repairs.
for marker in 'CardsDLL_Win64_retail.dll' 'SBC_DISPATCH'; do
grep -qaF -- "$marker" "$dll" ||
die "$dll is not a --features fifa17 build (missing $marker); refusing to stage/deploy"
done
# Markers that MUST be absent: the FIFA-23-only transport hooking.
for marker in 'getaddrinfo IAT patched' 'connect: inline-hooked' 'origin_spy'; do
if grep -qaF -- "$marker" "$dll"; then
die "$dll contains FIFA-23-only hook '$marker'; build with --features fifa17"
fi
done
}
verify_inputs() {
command -v sha256sum >/dev/null || die "sha256sum is required"
command -v x86_64-w64-mingw32-objdump >/dev/null ||
die "x86_64-w64-mingw32-objdump is required"
need_file "$hook_dll"
need_file "$system_version"
verify_pe64 "$hook_dll"
verify_fifa17_profile "$hook_dll"
}
inspect() {
verify_inputs
note "mode=inspect (read-only)"
note "hook=$hook_dll"
note "hook_sha256=$(sha256 "$hook_dll")"
note "system_version=$system_version"
note "system_version_sha256=$(sha256 "$system_version")"
note "game_dir=$game_dir"
if [[ -f "$deployed_dll" ]]; then
note "deployed_version_sha256=$(sha256 "$deployed_dll")"
else
note "deployed_version=absent"
fi
verify_exports "$hook_dll"
note "version_exports=complete"
}
build() {
command -v cargo >/dev/null || die "cargo is required"
note "Building the inert FIFA 17 hook into the package-local staging source path."
CARGO_TARGET_DIR="${hook_root}/target" \
cargo build --offline --release --features fifa17 \
--target x86_64-pc-windows-gnu --manifest-path "${hook_root}/Cargo.toml"
inspect
}
stage() {
verify_inputs
verify_exports "$hook_dll"
need_file "${game_dir}/CardsDLL_Win64_retail.dll"
need_file "${game_dir}/FIFA17.exe"
mkdir -p "$stage_root"
local staged="${stage_root}/version.dll"
cp -- "$hook_dll" "$staged"
{
printf 'artifact=%s\n' "$staged"
printf 'artifact_sha256=%s\n' "$(sha256 "$staged")"
printf 'source=%s\n' "$hook_dll"
printf 'source_sha256=%s\n' "$(sha256 "$hook_dll")"
printf 'system_version=%s\n' "$system_version"
printf 'system_version_sha256=%s\n' "$(sha256 "$system_version")"
printf 'cards_dll_sha256=%s\n' "$(sha256 "${game_dir}/CardsDLL_Win64_retail.dll")"
printf 'fifa17_exe_sha256=%s\n' "$(sha256 "${game_dir}/FIFA17.exe")"
} >"${stage_root}/manifest.txt"
note "staged=$staged"
note "manifest=${stage_root}/manifest.txt"
note "No game-directory file was changed."
}
require_game_stopped() {
if pgrep -fi '(FIFA17|_fifa17)\.exe' >/dev/null; then
die "FIFA 17 appears to be running; close it before deployment"
fi
}
deploy() {
[[ "${OPENFUT_FIFA17_DEPLOY:-}" == "I_ACCEPT_VERSION_DLL_REPLACEMENT" ]] ||
die "deploy requires OPENFUT_FIFA17_DEPLOY=I_ACCEPT_VERSION_DLL_REPLACEMENT"
require_game_stopped
local staged="${stage_root}/version.dll"
local manifest="${stage_root}/manifest.txt"
need_file "$staged"
need_file "$manifest"
verify_pe64 "$staged"
verify_exports "$staged"
verify_fifa17_profile "$staged"
local recorded actual
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
actual="$(sha256 "$staged")"
[[ -n "$recorded" && "$recorded" == "$actual" ]] || die "staged artifact hash does not match manifest"
local backup_dir="${game_dir}/openfut-backups"
mkdir -p "$backup_dir"
if [[ -f "$deployed_dll" ]]; then
local old_hash backup
old_hash="$(sha256 "$deployed_dll")"
backup="${backup_dir}/version.dll.${old_hash}.bak"
if [[ ! -e "$backup" ]]; then
cp -- "$deployed_dll" "$backup"
fi
[[ "$(sha256 "$backup")" == "$old_hash" ]] || die "backup verification failed: $backup"
note "backup=$backup"
fi
cp -- "$staged" "$deployed_dll"
[[ "$(sha256 "$deployed_dll")" == "$actual" ]] || die "deployed DLL hash verification failed"
note "deployed=$deployed_dll"
note "deployed_sha256=$actual"
}
launch() {
local mode=${1:-baseline}
local hook_enabled=0
local trace_enabled=0
local request_trace_enabled=0
local notifier_trace_enabled=0
local dispatch_enabled=0
case "$mode" in
baseline)
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
die "launch requires OPENFUT_FIFA17_LAUNCH=I_ACCEPT_M1_BASELINE_LAUNCH"
;;
resolve)
[[ "${OPENFUT_FIFA17_RESOLVE:-}" == "I_ACCEPT_M2_RESOLVE_LAUNCH" ]] ||
die "launch-resolve requires OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH"
hook_enabled=1
;;
trace)
[[ "${OPENFUT_FIFA17_TRACE:-}" == "I_ACCEPT_M3_PASSIVE_TRACE" ]] ||
die "launch-trace requires OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE"
hook_enabled=1
trace_enabled=1
request_trace_enabled=1
notifier_trace_enabled=1
;;
dispatch)
[[ "${OPENFUT_FIFA17_DISPATCH:-}" == "I_ACCEPT_GUARDED_NATIVE_DISPATCH" ]] ||
die "launch-dispatch requires OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH"
request_trace_enabled=1
dispatch_enabled=1
;;
*) die "unknown launch mode: $mode" ;;
esac
need_file "$deployed_dll"
local staged="${stage_root}/version.dll"
local manifest="${stage_root}/manifest.txt"
need_file "$staged"
need_file "$manifest"
verify_pe64 "$deployed_dll"
verify_exports "$deployed_dll"
local recorded
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
[[ -n "$recorded" && "$(sha256 "$staged")" == "$recorded" ]] ||
die "staged artifact hash does not match manifest"
[[ "$(sha256 "$deployed_dll")" == "$recorded" ]] ||
die "deployed version.dll does not match the staged M1 artifact"
command -v umu-run >/dev/null || die "umu-run is required"
for name in OPENFUT_SBC_DISPATCH OPENFUT_SBC_ARM_ONLY OPENFUT_SBC_POPULATE; do
[[ -z "${!name:-}" || "${!name}" == "0" ]] || die "$name must be unset or 0 for this launch"
done
mkdir -p "${wine_prefix}/dosdevices"
ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_DISPATCH=$dispatch_enabled); log=/tmp/fifa17-hook-m1-launch.log"
cd "$game_dir"
env \
GAMEID=fifa17 \
PROTONPATH="$proton_path" \
WINEPREFIX="$wine_prefix" \
WINEDLLOVERRIDES='version=n,b' \
OPENFUT_SBC_HOOK="$hook_enabled" \
OPENFUT_SBC_TRACE="$trace_enabled" \
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
OPENFUT_SBC_DISPATCH="$dispatch_enabled" \
OPENFUT_SBC_DISPATCH_TRACE=0 \
OPENFUT_SBC_ARM_ONLY=0 \
OPENFUT_SBC_POPULATE=0 \
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
}
usage() {
cat <<'EOF'
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-dispatch]
inspect Read-only PE/hash/export preflight (default).
build Cross-build the inert FIFA17 hook, then run inspect.
stage Copy a verified DLL into repo-local staging and write a hash manifest.
deploy Back up and install version.dll; requires:
OPENFUT_FIFA17_DEPLOY=I_ACCEPT_VERSION_DLL_REPLACEMENT
launch Start the M1 inert-hook baseline; requires:
OPENFUT_FIFA17_LAUNCH=I_ACCEPT_M1_BASELINE_LAUNCH
launch-resolve
Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires:
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
launch-trace
Start the M3-M6 passive parser/request/notifier trace; requires:
OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE
launch-dispatch
Trace and repair only a fully validated native status-999 completion; requires:
OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH
Optional path overrides:
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
OPENFUT_FIFA17_WINEPREFIX, OPENFUT_FIFA17_PROTONPATH
EOF
}
case "${1:-inspect}" in
inspect) inspect ;;
build) build ;;
stage) stage ;;
deploy) deploy ;;
launch) launch baseline ;;
launch-resolve) launch resolve ;;
launch-trace) launch trace ;;
launch-dispatch) launch dispatch ;;
-h|--help|help) usage ;;
*) usage >&2; die "unknown command: $1" ;;
esac
+38 -1
View File
@@ -204,6 +204,7 @@ class Account:
def __init__(self, path=None): def __init__(self, path=None):
self.path = path or ACCOUNT_PATH self.path = path or ACCOUNT_PATH
self._loaded = False self._loaded = False
self._file_signature = None
self._stored = {} # what is on disk (tier 2+3 only) self._stored = {} # what is on disk (tier 2+3 only)
for f in _FIELDS: for f in _FIELDS:
setattr(self, "_" + f, None) setattr(self, "_" + f, None)
@@ -214,7 +215,8 @@ class Account:
save the first time. Never raises on a malformed file -- a broken save the first time. Never raises on a malformed file -- a broken
account file must not stop the harness booting.""" account file must not stop the harness booting."""
with _LOCK: with _LOCK:
if self._loaded and not force: signature = self._signature()
if self._loaded and not force and signature == self._file_signature:
return self return self
stored = {} stored = {}
if os.path.exists(self.path): if os.path.exists(self.path):
@@ -239,8 +241,22 @@ class Account:
% (self.path, e)) % (self.path, e))
self._stored = stored self._stored = stored
self._loaded = True self._loaded = True
self._file_signature = self._signature()
return self return self
def _signature(self):
"""Identity of the active-account file across atomic replacements.
The launcher can select an account while Blaze/POW are already running
in separate processes. inode + mtime + size lets every process notice
the replacement on its next property read without restarting Docker.
"""
try:
st = os.stat(self.path)
return st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size
except OSError:
return None
def _migrate_from_profile(self): def _migrate_from_profile(self):
"""Lift identity/club out of a pre-existing fifa17_profile.json so an """Lift identity/club out of a pre-existing fifa17_profile.json so an
existing club name survives the move to this module. Read-only: the game existing club name survives the move to this module. Read-only: the game
@@ -266,11 +282,32 @@ class Account:
return out return out
def _write(self): def _write(self):
parent = os.path.dirname(self.path)
if parent:
os.makedirs(parent, exist_ok=True)
tmp = self.path + ".tmp" tmp = self.path + ".tmp"
with open(tmp, "w") as f: with open(tmp, "w") as f:
json.dump(self._stored, f, indent=1, sort_keys=True) json.dump(self._stored, f, indent=1, sort_keys=True)
f.write("\n") f.write("\n")
os.replace(tmp, self.path) os.replace(tmp, self.path)
self._file_signature = self._signature()
def replace(self, values):
"""Atomically replace the active identity with validated persisted values."""
with _LOCK:
clean = {k: v for k, v in values.items() if k in _FIELDS and v is not None}
if "persona_id" not in clean or "persona_name" not in clean:
raise ValueError("persona_id and persona_name are required")
clean["persona_id"] = int(clean["persona_id"])
clean["persona_name"] = str(clean["persona_name"]).strip()
if clean["persona_id"] <= 0 or not clean["persona_name"]:
raise ValueError("persona_id must be positive and persona_name must not be empty")
self._stored = clean
for field in _FIELDS:
setattr(self, "_" + field, None)
self._loaded = True
self._write()
return self
def save(self): def save(self):
"""Persist tiers 2+3 (only fields that differ from the built-in default, """Persist tiers 2+3 (only fields that differ from the built-in default,
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Launcher-to-server active-account selection for the single-player stack."""
import json
import os
from fut_account import ACCOUNT
from fut_store import STORE, profile_path_for
def _existing_identity(persona_id):
path = profile_path_for(persona_id)
try:
with open(path) as f:
profile = json.load(f)
except (OSError, ValueError):
return {}
if not isinstance(profile, dict):
return {}
return {
"club_name": profile.get("clubName"),
"club_abbr": profile.get("clubAbbr"),
"established": profile.get("established"),
"pow_level": profile.get("powLevel"),
"pow_exp": profile.get("powExp"),
"pow_exp_max": profile.get("powExpMax"),
"pow_funds": profile.get("powFunds"),
"pow_funds_cap": profile.get("powFundsCap"),
}
def activate(payload):
"""Select/create one persistent profile and publish it to all responders."""
if not isinstance(payload, dict):
raise ValueError("account payload must be an object")
try:
persona_id = int(payload.get("personaId"))
except (TypeError, ValueError):
raise ValueError("personaId must be a positive integer") from None
persona_name = payload.get("personaName")
if persona_id <= 0 or not isinstance(persona_name, str) or not persona_name.strip():
raise ValueError("personaId must be positive and personaName must not be empty")
values = _existing_identity(persona_id)
values.update(persona_id=persona_id, persona_name=persona_name.strip())
for wire, field in (("clubName", "club_name"), ("clubAbbr", "club_abbr"),
("established", "established"), ("squadName", "squad_name"),
("level", "pow_level"), ("experience", "pow_exp"),
("experienceMax", "pow_exp_max"), ("accountFunds", "pow_funds"),
("accountFundsCap", "pow_funds_cap")):
if payload.get(wire) not in (None, ""):
values[field] = payload[wire]
ACCOUNT.replace(values)
ACCOUNT.set_online_profile()
ACCOUNT.save()
profile = STORE.select_account(persona_id)
STORE.ensure_security_question()
return {
"personaId": ACCOUNT.persona_id,
"personaName": ACCOUNT.persona_name,
"clubName": ACCOUNT.club_name,
"clubAbbr": ACCOUNT.club_abbr,
"level": ACCOUNT.pow_level,
"experience": ACCOUNT.pow_exp,
"experienceMax": ACCOUNT.pow_exp_max,
"accountFunds": ACCOUNT.pow_funds,
"accountFundsCap": ACCOUNT.pow_funds_cap,
"profilePath": os.path.relpath(STORE.path, os.path.dirname(ACCOUNT.path)),
"coins": profile.get("coins", 0),
"unopenedPacks": len(profile.get("unopenedPackIds", [])),
}
+159 -11
View File
@@ -17,7 +17,19 @@ sys.path.insert(0, HERE)
import fut_cards import fut_cards
from fut_account import ACCOUNT # single source of truth for identity/club from fut_account import ACCOUNT # single source of truth for identity/club
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json")) PROFILE_ROOT = os.environ.get("FUT_PROFILE_ROOT", "")
def profile_path_for(persona_id):
explicit = os.environ.get("FUT_PROFILE")
if explicit:
return explicit
if PROFILE_ROOT:
return os.path.join(PROFILE_ROOT, str(int(persona_id)), "fifa17_profile.json")
return os.path.join(HERE, "fifa17_profile.json")
PROFILE_PATH = profile_path_for(ACCOUNT.persona_id)
# ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------ # ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------
# #
@@ -226,6 +238,56 @@ def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00
# the club showing different numbers for the same card. # the club showing different numbers for the same card.
SPECIAL_CARD_TYPES = {
# name: (rareflag, revision byte, rating/attribute boost, selection weight)
# rareflag names come from FIFA 17's ItemRareType enum. Revisions are local,
# stable identities; the client resolves the footballer from the low 24 bits.
"TOTW": (3, 1, 2, 34),
"PURPLE": (4, 2, 3, 7),
"TOTY": (5, 3, 6, 3),
"RECORD_BREAKER": (6, 4, 5, 2),
"TOTS": (11, 5, 5, 7),
"OTW": (21, 6, 2, 14),
"HALLOWEEN": (22, 7, 3, 8),
"MOVEMBER": (23, 8, 3, 8),
"SBC": (24, 9, 4, 17),
}
def choose_special_type(player, rng=None):
"""Choose a rating-appropriate FIFA 17 promo family for one pool row."""
import random
rng = rng or random
rating = player[1]
eligible = []
for name, spec in SPECIAL_CARD_TYPES.items():
if name in ("TOTY", "RECORD_BREAKER") and rating < 85:
continue
if name == "TOTS" and rating < 75:
continue
eligible.append((name, spec[3]))
names, weights = zip(*eligible)
return rng.choices(names, weights=weights, k=1)[0]
def player_item(item_id, player, special=False):
"""Build a base or named FIFA 17 special revision from a pool row.
`special=True` remains supported and chooses a weighted eligible family;
callers and tests may also pass an explicit name such as ``"TOTY"``.
"""
asset, rating, pos, nation, league, team, attrs = player
if special:
special_name = choose_special_type(player) if special is True else special
rareflag, version, boost, _weight = SPECIAL_CARD_TYPES[special_name]
rating = min(99, rating + boost)
attrs = [min(99, value + boost) for value in attrs]
else:
rareflag, version = 1, 0
return _item(item_id, asset, rating, pos, nation, league, team, attrs,
version=version, rareflag=rareflag)
# FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS # FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS
# the same number the server pays. # the same number the server pays.
# #
@@ -293,6 +355,10 @@ def _new_profile():
"purchased": [], # unassigned/pending items from opened packs "purchased": [], # unassigned/pending items from opened packs
"squads": [], # saved squads (raw squad objects from PUT /squad) "squads": [], # saved squads (raw squad objects from PUT /squad)
"packsOpened": 0, "packsOpened": 0,
# Owned reward packs are separate from purchased items. Pack 70 is a
# one-time migration grant used to bring the retail My Packs flow online.
"unopenedPackIds": [70],
"unopenedSeeded": True,
} }
@@ -311,6 +377,10 @@ class Store:
self._p = _new_profile() self._p = _new_profile()
self._sync_identity() self._sync_identity()
self._save() self._save()
if not self._p.get("unopenedSeeded"):
self._p.setdefault("unopenedPackIds", []).append(70)
self._p["unopenedSeeded"] = True
self._save()
self._sync_identity() self._sync_identity()
return self._p return self._p
@@ -328,18 +398,51 @@ class Store:
p["clubName"] = ACCOUNT.club_name p["clubName"] = ACCOUNT.club_name
p["clubAbbr"] = ACCOUNT.club_abbr p["clubAbbr"] = ACCOUNT.club_abbr
p["established"] = ACCOUNT.established p["established"] = ACCOUNT.established
# EA/EASFC account-bar state belongs to the same persona as the FUT
# save, but remains a distinct balance from FUT coins.
p["powLevel"] = ACCOUNT.pow_level
p["powExp"] = ACCOUNT.pow_exp
p["powExpMax"] = ACCOUNT.pow_exp_max
p["powFunds"] = ACCOUNT.pow_funds
p["powFundsCap"] = ACCOUNT.pow_funds_cap
return p return p
def _save(self): def _save(self):
parent = os.path.dirname(self.path)
if parent:
os.makedirs(parent, exist_ok=True)
tmp = self.path + ".tmp" tmp = self.path + ".tmp"
with open(tmp, "w") as f: with open(tmp, "w") as f:
json.dump(self._p, f, indent=1) json.dump(self._p, f, indent=1)
os.replace(tmp, self.path) os.replace(tmp, self.path)
def select_account(self, persona_id):
"""Switch the single active session to its isolated persistent FUT save."""
with _LOCK:
self.path = profile_path_for(persona_id)
self._p = None
return self.load()
# ---- accessors used by utas_server ------------------------------------- # ---- accessors used by utas_server -------------------------------------
def profile(self): def profile(self):
return self.load() return self.load()
def ensure_security_question(self):
"""Persist OpenFUT's account-scoped compatibility state for the FUT gate.
FIFA 17 transforms any entered answer before sending it. OpenFUT does not
need that value to emulate a retired service, so neither the clear text nor
the transformed value is stored. The only durable fact is that this
OpenFUT profile has an initialized, verified compatibility record.
"""
expected = {"version": 1, "verified": True}
with _LOCK:
p = self.load()
if p.get("securityQuestion") != expected:
p["securityQuestion"] = dict(expected)
self._save()
return dict(p["securityQuestion"])
def refresh_identity(self): def refresh_identity(self):
"""Re-mirror ACCOUNT into the save AND persist it. """Re-mirror ACCOUNT into the save AND persist it.
@@ -513,6 +616,32 @@ class Store:
sq = self.load()["squads"] sq = self.load()["squads"]
return sq[0] if sq else None return sq[0] if sq else None
def unopened_packs(self):
"""Owned reward-pack template IDs, including repeated grants."""
return list(self.load().get("unopenedPackIds", []))
def consume_unopened_pack(self, pack_id):
"""Atomically consume one owned instance of a reward pack."""
with _LOCK:
p = self.load()
owned = p.setdefault("unopenedPackIds", [])
try:
owned.remove(pack_id)
except ValueError:
return False
self._save()
return True
def grant_unopened_pack(self, pack_id):
"""Persist one additional owned reward-pack instance."""
if pack_by_id(pack_id) is None:
return False
with _LOCK:
p = self.load()
p.setdefault("unopenedPackIds", []).append(pack_id)
self._save()
return True
def reconstruct_squad(self, squad): def reconstruct_squad(self, squad):
"""FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>} """FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>}
(a reference). Re-embed the FULL club item by id so the squad reloads with (a reference). Re-embed the FULL club item by id so the squad reloads with
@@ -557,7 +686,8 @@ class Store:
return i return i
def open_pack(self, price, count, gold=True, tiers=None): def open_pack(self, price, count, gold=True, tiers=None, special_chance=0.0,
players_only=False):
"""Deduct `price` coins, generate `count` player items from the pool, and """Deduct `price` coins, generate `count` player items from the pool, and
place them in the PENDING purchased pile (unassigned). They are NOT owned place them in the PENDING purchased pile (unassigned). They are NOT owned
club items until moved there via FutMoveCard (PUT /item). Returns None if club items until moved there via FutMoveCard (PUT /item). Returns None if
@@ -579,19 +709,31 @@ class Store:
# fixed number so it scales from a 5-card bronze to an 11-card premium. # fixed number so it scales from a 5-card bronze to an 11-card premium.
n_extra = 0 n_extra = 0
extras = [] extras = []
if PACK_MIX and count >= 5: if PACK_MIX and not players_only and count >= 5:
n_extra = max(1, count // 4) n_extra = max(1, count // 4)
extras = _pack_extras(n_extra, self) extras = _pack_extras(n_extra, self)
n_extra = len(extras) n_extra = len(extras)
n_players = max(1, count - n_extra) n_players = max(1, count - n_extra)
if tiers: if tiers:
picks = [random.choice(fut_cards.pool_for(random.choice(tiers))) # Draw each tier independently but reject duplicate asset IDs inside
for _ in range(n_players)] # one pack. The real pool is large enough that this normally succeeds
# on the first attempt; the cap makes malformed tiny test pools safe.
picks = []
used_assets = set()
for _ in range(n_players):
tier_pool = fut_cards.pool_for(random.choice(tiers))
available = [p for p in tier_pool if p[0] not in used_assets]
pick = random.choice(available or tier_pool)
picks.append(pick)
used_assets.add(pick[0])
else: else:
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
picks = [random.choice(pool) for _ in range(n_players)] picks = random.sample(pool, min(n_players, len(pool)))
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at) while len(picks) < n_players:
for (a, r, p, n, lg, tm, at) in picks] picks.append(random.choice(pool))
items = [player_item(self.new_item_id(), pick,
special=random.random() < special_chance)
for pick in picks]
items += extras items += extras
random.shuffle(items) random.shuffle(items)
with _LOCK: with _LOCK:
@@ -677,11 +819,17 @@ _LEGACY_POOL = STARTER_PLAYERS + [
# no silver or bronze players at all, so all three packs were identical in practice. # no silver or bronze players at all, so all three packs were identical in practice.
PACK_CATALOG = [ PACK_CATALOG = [
{"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False, {"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False,
"tiers": ["bronze"] * 8 + ["silver"] * 2}, "tiers": ["bronze"] * 8 + ["silver"] * 2, "specialChance": 0.005},
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True, {"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True,
"tiers": ["gold"] * 6 + ["silver"] * 4}, "tiers": ["gold"] * 6 + ["silver"] * 4, "specialChance": 0.03},
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True, {"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True,
"tiers": ["gold"] * 9 + ["silver"] * 1}, "tiers": ["gold"] * 9 + ["silver"] * 1, "specialChance": 0.08},
{"id": 7, "name": "Special Players Pack", "price": 25000, "count": 11,
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
"playersOnly": True},
{"id": 70, "name": "Reward Special Players Pack", "price": 0, "count": 11,
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
"playersOnly": True, "ownedOnly": True},
] ]
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Decode the running-sum atom ladders in /hub parser FUN_180139610 and name each
atom from docs/fut_atoms.tsv.
The dispatch is `sub ecx,d0 / sub ecx,d1 / .../ cmp ecx,dN`: the atom that each
branch handles is the CUMULATIVE sum of the deltas up to and including that step
(a jz after each sub tests atom==running_sum). Plus there are direct `cmp esi,imm`.
"""
import subprocess, re
DLL = "/tmp/fut/cardsdll.dll"
TSV = "/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"
FUNC, STOP = 0x180139610, 0x18013e600
atoms = {}
for line in open(TSV):
p = line.rstrip("\n").split("\t")
if len(p) >= 3:
try: atoms[int(p[1], 16)] = p[2]
except ValueError: pass
out = subprocess.check_output(
["objdump", "-d", "-M", "intel",
"--start-address=%#x" % FUNC, "--stop-address=%#x" % STOP, DLL], text=True)
# linear list of (addr, mnem, dest_reg, imm) for sub/cmp on 32-bit regs, stop at int3 pad
seq = []
int3 = 0
for ln in out.splitlines():
parts = ln.split("\t")
if len(parts) < 3:
continue
addr_s = parts[0].strip().rstrip(":")
try:
addr = int(addr_s, 16)
except ValueError:
continue
instr = parts[2].strip()
bits = instr.split(None, 1)
mnem = bits[0]
ops = bits[1].strip() if len(bits) > 1 else ""
if mnem == "int3":
int3 += 1
if int3 >= 4: break
continue
int3 = 0
mo = re.match(r"(e?[a-d]x|e?si|e?di|e?bp|r\d+d?),\s*(0x[0-9a-f]+)$", ops)
if mnem in ("sub", "cmp") and mo:
seq.append((addr, mnem, mo.group(1), int(mo.group(2), 16)))
# walk ladders: consecutive sub/cmp on the SAME register form one ladder; the running
# sum at each element is the atom that element dispatches. A `cmp` closes the ladder.
found = {} # atom -> (addr, kind)
i = 0
while i < len(seq):
addr, mnem, reg, imm = seq[i]
# a ladder starts on a sub
if mnem == "sub":
run = 0
j = i
while j < len(seq) and seq[j][2] == reg and seq[j][1] in ("sub", "cmp"):
run += seq[j][3]
found.setdefault(run, (seq[j][0], "ladder"))
if seq[j][1] == "cmp":
j += 1
break
j += 1
i = j
else:
# a lone cmp reg,imm on an atom-holding reg is a direct atom test
if 0 < imm <= 0x400:
found.setdefault(imm, (addr, "direct"))
i += 1
TOKENS = {0x1, 0x6, 0x7, 0x9, 0xa, 0xb, 0xc, 0xd} # SAX token enum, not atoms
print("Atoms dispatched by hub parser FUN_%#x:" % FUNC)
print("=" * 70)
for a in sorted(found):
if a in TOKENS:
continue
tag = " <-- TOKEN?" if a < 0x10 else ""
print(" %#06x %-28s (%s @ %#x)%s" %
(a, atoms.get(a, "?"), found[a][1], found[a][0], tag))
print("\nKnown tile counters for reference: 0x33=auctionCount, 0x90=clubPlayers")
print("\nName-based tile-count candidates:")
KEYS = ("sell","sold","trade","auction","pile","list","count","num","offer",
"won","outbid","target","watch","transfer","active","unassigned")
for a in sorted(found):
if a in TOKENS: continue
n = atoms.get(a, "").lower()
if any(k in n for k in KEYS):
print(" %#06x %s" % (a, atoms.get(a, "?")))
@@ -0,0 +1,90 @@
"""ADVERSARIAL VERIFICATION BATCH 1 (dim4 + dim5).
HYPOTHESES UNDER ATTACK
H1 (dim5 f5/f7): the publisher FUN_18006cc60 maps model vtable slots to IS_* names,
and IS_TRADING_ENABLED (0x1801fc118) has exactly ONE rip-relative reference in
.text (the lea), i.e. the name is output-only.
CONTROL: run the same rip-relative scanner against a literal that IS known to be
compared, e.g. one of the ISOfferTrade error strings 0x180228f20, which must show
up in a *different* instruction context, and against IS_STORE_ENABLED.
H2 (dim5 f5 positive control): IS_STORE_ENABLED's accessor (vt+0x280) - what does it
actually compute? If it is a live-evaluable expression we can compare STORE vs
TRADING under the same publish mechanism.
H3 (dim4 f2): FutGetSuggestedPricing deser 0x180163ee0 top-level token is
START_ARRAY (loop terminates on 0xd) - CONTROL FUN_180165df0 (ISStart) must
terminate on 10.
H4 (dim4 f4): 0x1801642c0 is `return 1;`.
H5 (dim4 f6): tradeState table 0x180229e40 / bidState ladder FUN_180166380.
H6 (dim4 f9): IS_MAX_AUCTIONS publisher FUN_1800377c0 + GetAuctionCount deser
0x180163770.
H7 (dim4 f8): error mapper FUN_1801844c0.
Everything printed IN FULL with len(src).
"""
import traceback, struct
def full(tag, va):
try:
s = dec(va)
print("\n----- %s %#x len=%d -----" % (tag, va, len(s)))
print(s)
except Exception:
traceback.print_exc()
try:
print("### H1: publisher FUN_18006cc60")
full("publisher", 0x18006cc60)
print("\n### model vtable slots")
VT = 0x18021c2a0
for off in (0x270, 0x280, 0x2b0, 0x988, 0x998, 0xa58, 0xa60, 0x130, 0x5b8, 0xa00):
t = qword(VT + off)
print(" vt+%#05x -> %#x %s" % (off, t, fname(t) if 'fname' in dir() else ''))
full("vt+0x280 IS_STORE_ENABLED accessor", qword(VT + 0x280))
full("vt+0x270 IS_TRADING_ENABLED accessor", qword(VT + 0x270))
full("vt+0xa58 TRADE_PILE_SIZE accessor", qword(VT + 0xa58))
print("\n### H1 rip-relative reference scan, form independent")
# Scan .text for any 4-byte little-endian rel32 whose target == literal VA,
# for every instruction end position. This catches lea/mov/cmp/push equally.
tblk = None
for b in mem.getBlocks():
if b.getName() == ".text":
tblk = b
TS = int(tblk.getStart().getOffset()); TE = int(tblk.getEnd().getOffset())
text = read_bytes(TS, TE - TS + 1)
print(" .text %#x..%#x len=%d" % (TS, TE, len(text)))
def ripscan(target, label):
hits = []
for i in range(0, len(text) - 4):
rel = struct.unpack_from('<i', text, i)[0]
# instruction end = TS + i + 4 (rel32 is the last field of the insn)
if TS + i + 4 + rel == target:
hits.append(TS + i)
print(" %-34s target %#x : %d candidate rel32 sites" % (label, target, len(hits)))
for h in hits[:20]:
print(" at %#x bytes %s fn %s" % (h - 3, text[h - 6:h + 6].hex(),
(fm.getFunctionContaining(addr(h)) or "?")))
return hits
lits = {}
for nm in (b"IS_TRADING_ENABLED\x00", b"IS_STORE_ENABLED\x00",
b"IS_DRAFT_MODE_ENABLED\x00", b"TRADE_PILE_SIZE\x00",
b"IS_MAX_AUCTIONS\x00", b"NUM_MAX_AUCTIONS\x00",
b"You are not allowed to bid on this trade\x00"):
f = find_all(nm, blocks=(".rdata", ".data", ".text"))
lits[nm] = f
print(" literal %-45r -> %s" % (nm[:40], [hex(x) for x in f]))
for nm, f in lits.items():
for a in f:
ripscan(a, nm[:30].decode(errors='replace'))
print("\n### H3 pricelimits vs ISStart control")
full("FutGetSuggestedPricing deser", 0x180163ee0)
full("FutISStart deser CONTROL", 0x180165df0)
print("\n### H4 generic ack deser")
full("ack deser", 0x1801642c0)
except Exception:
traceback.print_exc()
@@ -0,0 +1,84 @@
"""ADVERSARIAL VERIFICATION BATCH 2.
Everything printed IN FULL with len(src). No truncation, no absence claimed from
a partial print.
H8 dim4 f5: auctionInfo record deser 0x18013e410 has exactly 12 atoms + tradeId
identity lookup via model vt+0xa00.
H9 dim4 f7: shared IS-list body 0x18013e7f0, credits -> model vt+0x5b8.
H10 dim4 f6: tradeState table walk FUN_180166bd0 (table 0x180229e40) and bidState
ladder FUN_180166380 -- two DIFFERENT dispatch forms, read separately.
H11 dim4 f8: FUN_1801844c0 status map, FUN_180165050 461 override.
H12 dim4 f9: FUN_1800377c0 IS_MAX_AUCTIONS + FUN_180163770 GetAuctionCount deser.
CONTROL for the publisher form: FUN_18000d550 TRADE_PILE_SIZE.
H13 dim4 f11: deser VAs for FutISWatchList / FutGetAuctionCount / FutISStart via
RS4 name -> abs64 ptr -> installed vtable -> slot +0x08, with FutISSearch and
FutGetTradePile as the CONTROL pair (must come back 0x180163420 / 0x180170810).
"""
import traceback, struct
def full(tag, va):
try:
s = dec(va)
print("\n----- %s %#x len=%d -----" % (tag, va, len(s)))
print(s)
except Exception:
traceback.print_exc()
try:
for tag, va in [("auctionInfo record deser", 0x18013e410),
("shared IS-list body", 0x18013e7f0),
("tradeState decoder", 0x180166bd0),
("bidState decoder", 0x180166380),
("status mapper", 0x1801844c0),
("ISOfferTrade 461 override", 0x180165050),
("IS_MAX_AUCTIONS publisher", 0x1800377c0),
("TRADE_PILE_SIZE publisher CONTROL", 0x18000d550),
("GetAuctionCount deser", 0x180163770),
("ISWatchList deser", 0x180166240),
("ISSearch deser CONTROL", 0x180163420),
("GetTradePile deser CONTROL", 0x180170810)]:
full(tag, va)
print("\n### tradeState table at 0x180229e40")
a = 0x180229e40
for i in range(10):
p = qword(a + i * 16); v = dword(a + i * 16 + 8)
if p == 0:
print(" [%d] NULL terminator, value=%d" % (i, v)); break
print(" [%d] %#x %r = %d" % (i, p, rd_str(p), v if v < 0x80000000 else v - (1 << 32)))
print("\n### H13 RS4 name -> installed vtable -> slot+0x08")
for nm, expect in [(b"RS4:FutISSearchServerResponse\x00", 0x180163420),
(b"RS4:FutGetTradePileServerResponse\x00", 0x180170810),
(b"RS4:FutISWatchListServerResponse\x00", None),
(b"RS4:FutGetAuctionCountServerResponse\x00", None),
(b"RS4:FutISStartServerResponse\x00", None),
(b"RS4:FutGetSuggestedPricingServerResponse\x00", None),
(b"RS4:FutRelistAllServerResponse\x00", None),
(b"RS4:FutISWatchTradeServerResponse\x00", None),
(b"RS4:FutISRemoveTradeServerResponse\x00", None),
(b"RS4:FutISRemoveWatchServerResponse\x00", None),
(b"RS4:FutISViewTradeServerResponse\x00", None),
(b"RS4:FutISOfferTradeServerResponse\x00", None)]:
locs = find_all(nm, blocks=(".rdata", ".data"))
print("\n %s -> %s" % (nm.decode().rstrip("\x00"), [hex(x) for x in locs]))
for L in locs:
xs = xrefs_to(L)
print(" xrefs: %s" % [(hex(a), t, f) for a, t, f, _ in xs])
for a, t, f, ent in xs:
if ent:
s = dec(ent)
# find the vtable it installs: look for PTR_ / &DAT_ assignment
import re
m = re.findall(r"(?:PTR_[A-Za-z_0-9]*_|DAT_|&)([0-9a-fA-F]{9})", s)
print(" fn %s @%#x len=%d installs %s" % (f, ent, len(s), set(m)))
for cand in set(m):
try:
vt = int(cand, 16)
if 0x180200000 <= vt < 0x180290000:
slot = qword(vt + 8)
print(" vtable %#x slot+0x08 = %#x (expect %s)"
% (vt, slot, hex(expect) if expect else "?"))
except Exception:
pass
except Exception:
traceback.print_exc()
@@ -0,0 +1,26 @@
"""ADVERSARIAL BATCH 3 -- the relaunch-critical path.
H14: does the settings deser FUN_18013c6d0 pre-initialise its struct fields
+0x28..+0x40 to 1 before parsing? If it zero-inits them, then the observed
live pattern (model+0x1fd2e=0 surrounded by 1s) cannot have come from the
applier, i.e. the applier NEVER RAN -- which decides "never set" vs
"set then cleared".
Also: which atom writes struct+0x1c (the field FUN_180173e00 gates on)?
H15: FUN_180173e00 in full -- the test rdx / cmp [rdx+0x1c],0 gate.
H16: dim5 f8 -- FUN_180180770 blaze client-config reader, full key list.
"""
import traceback
def full(tag, va):
try:
s = dec(va)
print("\n===== %s %#x len=%d =====" % (tag, va, len(s)))
print(s)
except Exception:
traceback.print_exc()
try:
full("settings deser FUN_18013c6d0", 0x18013c6d0)
full("settings completion FUN_180173e00", 0x180173e00)
full("blaze config reader FUN_180180770", 0x180180770)
except Exception:
traceback.print_exc()
@@ -0,0 +1,29 @@
"""ADVERSARIAL BATCH 4 -- the settings RESPONSE object, not the model-side deser.
FUN_180173e00 reads its param_2 (the FutGetSettings response) at +0x1c (error gate),
copies +0x28..+0xc0 and hands &<copy of +0x28> to the gate applier vt+0x988, and
copies +0xc8..+0xd4 and hands &<copy of +0xc8> to vt+0x998.
So model+0x1fd2e <- response+0x50, and model+0x1fd1c <- response+0xd0.
HYPOTHESIS: the FutGetSettings response deserializer writes response+0x50 and +0xd0
from specific atoms. Find them.
CONTROL: the same RS4-name -> vtable -> slot+0x08 resolution that reproduced
FutISSearch 0x180163420 and FutGetTradePile 0x180170810 in batch 2.
"""
import traceback, re
try:
for nm in (b"RS4:FutGetSettingsServerResponse\x00", b"RS4:FutSettingsServerResponse\x00",
b"RS4:FutISSearchServerResponse\x00"):
locs = find_all(nm, blocks=(".rdata", ".data"))
print("\n### %s -> %s" % (nm.decode().rstrip("\x00"), [hex(x) for x in locs]))
for L in locs:
for a, t, f, ent in xrefs_to(L):
if not ent: continue
s = dec(ent)
m = set(re.findall(r"(?:PTR_[A-Za-z_0-9]*_|DAT_|&)([0-9a-fA-F]{9})", s))
print(" fn %s @%#x installs %s" % (f, ent, m))
for c in m:
v = int(c, 16)
if 0x180200000 <= v < 0x180290000:
print(" vtable %#x slot+0x08 = %#x" % (v, qword(v + 8)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,10 @@
"""BATCH 5: which atom writes FutGetSettings response+0x50 (-> IS_TRADING_ENABLED)
and +0xd0 (-> TRADE_PILE_SIZE)? Two candidate desers resolved in batch 4."""
import traceback, re
try:
for va in (0x18014e590, 0x180153060):
s = dec(va)
print("\n===== deser %#x len=%d =====" % (va, len(s)))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,71 @@
"""Verify: does userInfo.feature={"trade":true} ZERO the trade gate byte?
The claim (workflow wf_29791945): userInfo.feature (atom 0x11c) is a RESTRICTION map,
not a grant. Sending trade (atom 0x330) = true marks trade restricted, and at the
massinfo top-level END_OBJECT, 0x180174f19 does `mov dword [rsi+0x50],0`, which feeds
the applier 0x18011dc91 `mov [rdi+0x1fd2e],al`, forcing IS_TRADING_ENABLED = 0. It runs
LAST and unconditionally, so no configs/Blaze value can beat it.
This has to be right before we change server code, because two prior trading root-causes
this session were wrong. Verify the actual instructions rather than trust the summary.
CONTROL: storeEnabled path must NOT be zeroed the same way (the store works), so whatever
zeroes trade must be specific to the feature/trade branch, not applied to store.
"""
import re, traceback
MASSINFO = 0x180174630 # massinfo deser root (calls settings deser + appliers)
ZERO_SITE = 0x180174f19 # claimed `mov dword [rsi+0x50],0`
APPLIER = 0x18011DC50
try:
src = dec(MASSINFO)
f = func(MASSINFO)
print("%#x massinfo root body %d / decompile %d chars"
% (MASSINFO, f.getBody().getNumAddresses() if f else -1, len(src)))
# a) the instruction at the claimed zero site, read raw
print("\n=== instructions around %#x ===" % ZERO_SITE)
ins = listing.getInstructionAt(addr(ZERO_SITE))
if ins is None:
# step back to find the containing instruction
ins = listing.getInstructionContaining(addr(ZERO_SITE))
a = addr(ZERO_SITE - 0x18)
for _ in range(14):
i = listing.getInstructionAt(a)
if i is None:
a = a.add(1); continue
mark = " <== claimed zero site" if int(i.getAddress().getOffset()) == ZERO_SITE else ""
print(" %#x %s%s" % (int(i.getAddress().getOffset()), i, mark))
a = i.getAddress().add(i.getLength())
# b) does the feature(0x11c)/trade(0x330) atom appear in the massinfo deser or a callee?
print("\n=== feature 0x11c / trade 0x330 dispatch, in massinfo + callees ===")
scan = [MASSINFO] + [a for a, _ in callees(MASSINFO)]
for ent in scan:
try:
d = dec(ent)
except Exception:
continue
hits = []
for atom, name in ((0x11c, "feature"), (0x330, "trade")):
for m in re.finditer(r"(case |== |!= )0x%x\b" % atom, d):
hits.append(name)
if hits:
print(" %#x %-20s handles: %s" % (ent, fname(ent), sorted(set(hits))))
# c) confirm the applier writes 0x1fd2e from a field, and trace what feeds it
print("\n=== applier %#x: the 0x1fd2e write and its source ===" % APPLIER)
da = dec(APPLIER)
for ln in da.splitlines():
if "0x1fd2e" in ln or "param_2[10]" in ln:
print(" " + ln.strip())
# d) CONTROL: is there a zero-write to the store field (0x1fd2f) anywhere near the
# trade zero site? there should NOT be, or the store would break too.
print("\n=== CONTROL: any 0x1fd2f (store) zeroing near the trade path? ===")
n = sum(1 for ln in src.splitlines() if "0x50] = 0" in ln.replace(" ", "") or "rsi+0x50" in ln)
print(" '[rsi+0x50]=0'-style writes in massinfo root: look above; store gate is a different offset")
except Exception:
traceback.print_exc()
@@ -0,0 +1,26 @@
"""DIMENSION 4 q1: Enumerate the published UI surface (FUN_18006cc60), the
userInfo.feature restriction map (FUN_18013ec10), and locate every draft/tournament
string + its xrefs.
Hypothesis: the entry gate for Draft/Tournaments is EITHER a feature-restriction
sub-key we might send, OR a published-context name other than IS_DRAFT_MODE_ENABLED /
IS_TOURNAMENT_QUIT_ENABLED, OR script-layer (no server-reachable input).
Control: FUN_18006cc60 is the known publisher (transfer-market doc). If it decompiles
and its IS_* names resolve, the query mechanics work. Print lengths in full to avoid
the truncated-decompile absence trap.
"""
import traceback
try:
# 1. The publisher (authoritative slot->name table per the brief)
d = dec(0x18006cc60)
print("=== FUN_18006cc60 publisher len=%d ===" % len(d))
print(d)
# 2. The userInfo.feature restriction parser
d2 = dec(0x18013ec10)
print("\n=== FUN_18013ec10 userInfo/feature parser len=%d ===" % len(d2))
print(d2)
except Exception:
traceback.print_exc()
print("QUERY_DONE")
@@ -0,0 +1,50 @@
"""DIMENSION 4 q2: locate the draft/tournament entry decision.
Hypothesis: entry is gated in the script layer / a manager singleton with no server
writer, NOT by any server-reachable field. Test by (a) enumerating draft/tournament
script-event + manager literals and their xrefs, (b) reading the CompetitionManager
setters FUN_180101680/FUN_1801016c0 (the Seasons lead) and looking for draft/tourney
analogues, (c) finding who READS the draft gate byte model+0x1fd3d and tournament
+0x1fd3b.
Control: 'IS_DRAFT_MODE_ENABLED' literal must resolve and xref into FUN_18006cc60
(the known publisher). If it does, the string/xref mechanics work.
"""
import traceback
try:
def show_str_xrefs(lit, blocks=(".rdata",)):
hits = find_all(lit.encode() + b"\x00", blocks)
print("\n--- literal %r : %d hit(s) ---" % (lit, len(hits)))
for h in hits:
print(" @ %#x" % h)
for frm, typ, fn, ent in xrefs_to(h):
print(" xref from %#x %s in %s (%#x)" % (frm, typ, fn, ent))
# control
show_str_xrefs("IS_DRAFT_MODE_ENABLED")
# draft / tournament script + manager literals
for lit in ("NOSEASONS", "NODRAFT", "NOTOURNAMENT", "DRAFTSQUAD_ON",
"SINGLE_PLAYER", "DRAFT_TOKEN", "DraftMode", "Draft",
"CompetitionManager", "TournamentInfo", "TournamentManager",
"DraftManager", "OnlineDraft", "OfflineDraft"):
show_str_xrefs(lit)
# substring scan for any *draft*/*tournament* ascii literal in .rdata
print("\n=== .rdata literals containing 'raft' or 'ourna' ===")
for needle in (b"raft", b"ourna"):
seen = set()
for h in find_all(needle, (".rdata",)):
# back up to string start
p = h
while p > h - 64:
b = read_bytes(p - 1, 1)
if not b or b[0] == 0 or b[0] < 0x20 or b[0] > 0x7e:
break
p -= 1
s = rd_str(p, 96)
if s and s not in seen and (b"raft" in s.encode() or b"ourna" in s.encode()):
seen.add(s)
print(" %#x %r" % (p, s))
except Exception:
traceback.print_exc()
print("QUERY_DONE")
@@ -0,0 +1,41 @@
"""DIMENSION 4 q3: do the draft/tournament MODE-STATE literals have native gating
callers, or are they inert descriptor names driven from the script layer?
Hypothesis: like the Seasons CompetitionManager setters (FUN_180101680/1801016c0,
zero callers), the draft/tournament mode nodes are named descriptors with no native
entry-gate; entry is decided in the packed front-end. Test by reading the xref
callers of each mode-state literal and decompiling the first native caller of each.
Control: 'NOSEASONS' xref is known (FUN_180057330). Re-confirm the Seasons setters
have zero callers as the reference negative.
"""
import traceback
try:
def xr(a, label):
print("\n--- %s @ %#x ---" % (label, a))
xs = xrefs_to(a)
for frm, typ, fn, ent in xs:
print(" from %#x %s in %s (%#x)" % (frm, typ, fn, ent))
return xs
xr(0x1801fbb50, "fefifa::FUTDraftOfflineMode")
xr(0x1801fbbc8, "fefifa::FUTOnlineDraftMode")
xr(0x180209a70, "CentralDraftModeOffline")
xr(0x180209ae0, "CentralDraftModeOnline")
xr(0x1801ebca0, "draftentry")
xr(0x1801fbbe8, "fefifa::FUTOfflineTournament")
xr(0x1801fbc08, "fefifa::FUTOnlineTournament")
xr(0x180218f18, "FUT::TournamentInfo")
xr(0x18021f870, "DraftMode(0x18021f870)")
xr(0x1801fbbd9, "DraftMode(0x1801fbbd9)")
# Seasons CompetitionManager control: setters + singleton
print("\n=== CONTROL: Seasons CompetitionManager setters callers ===")
for a in (0x180101680, 0x1801016c0):
print("callers(%#x) = %s" % (a, callers(a)))
print("xrefs_to DAT_1802e6328 (CompetitionManager singleton):")
for frm, typ, fn, ent in xrefs_to(0x1802e6328):
print(" from %#x %s in %s (%#x)" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
print("QUERY_DONE")
@@ -0,0 +1,20 @@
"""DIMENSION 4 q4: read the hub tile builder FUN_1800b2680 in full (references both
CentralDraftModeOffline and CentralDraftModeOnline), plus 'draftentry' FUN_180016190
and the mode-node factories FUN_18006b820/FUN_18006b960 (online/offline draft) and
FUN_18006baa0/FUN_18006bd20 (offline/online tournament).
Hypothesis: FUN_1800b2680 builds the draft/tournament/seasons hub tiles and either
(a) gates a tile on a server-reachable field, or (b) builds them unconditionally,
which would make the refusal script-layer. Print full length to avoid truncation.
"""
import traceback
try:
for a, lbl in [(0x1800b2680, "hub tile builder FUN_1800b2680"),
(0x180016190, "draftentry FUN_180016190")]:
d = dec(a)
print("=== %s len=%d ===" % (lbl, len(d)))
print(d)
print("\n")
except Exception:
traceback.print_exc()
print("QUERY_DONE")
@@ -0,0 +1,37 @@
"""DIMENSION 4 q5: map model vtable slots +0x2b0..+0x320 to their accessor
displacements, so slot +0x2d0 (the offline-draft-specific gate in FUN_1800b2680)
and slot +0x320 (cVar9) can be measured live.
Model vtable static = 0x18021c2a0 (from ground truth / card doc). For each slot read
the target function's first bytes; if it is the accessor stub 0f b6 81 <disp32> c3
(movzx eax,byte [rcx+disp]; ret) decode disp.
Control: slot +0x270 must decode to disp 0x1fd2e (IS_TRADING), slot +0x2c8 to 0x1fd3d
(IS_DRAFT_MODE_ENABLED) -- both established in the card-subsystem doc.
"""
import traceback
try:
VT = 0x18021c2a0
names = {0x270:"IS_TRADING(+0x1fd2e)", 0x280:"IS_STORE", 0x2b0:"FRIENDLY_SEASON(+0x1fd3a)",
0x2b8:"TOURNAMENT_QUIT(+0x1fd3b)", 0x2c0:"PROCESSING(+0x1fd3c)",
0x2c8:"DRAFT_MODE(+0x1fd3d)", 0x2d0:"?offline-draft gate?",
0x2d8:"STORY_MODE_REWARD", 0x2e0:"packAnim(+0x1fd45)",
0x2f0:"RETURNING_USER", 0x320:"cVar9(FUN_1800b2680)"}
for slot in range(0x2a0, 0x330, 8):
tgt = qword(VT + slot)
b = read_bytes(tgt, 8)
disp = None
if b[:3] == b"\x0f\xb6\x81": # movzx eax, byte [rcx+disp32]
import struct
disp = struct.unpack("<i", b[3:7])[0]
note = names.get(slot, "")
print("slot +%#05x -> %#012x stub=%s disp=%s %s" %
(slot, tgt, b.hex(), hex(disp) if disp is not None else "(not a byte-accessor)", note))
if disp is None:
# decompile non-trivial accessors (offline draft gate / cVar9 may compute)
if slot in (0x2d0, 0x320):
print(" --- dec slot +%#x target ---" % slot)
print(dec(tgt))
except Exception:
traceback.print_exc()
print("QUERY_DONE")
@@ -0,0 +1,38 @@
"""DIMENSION 4 q6: are the GOTO_* tile destinations consumed by native gate code or
only handed to the front-end script layer? And what do the draft/tournament mode-node
factories register?
Hypothesis: GOTO_DRAFT_ONLINE/OFFLINE/DISABLED and GOTO_*TOURNAMENT appear ONLY as
string VALUES passed to the UI property setter in FUN_1800b2680 (no native consumer),
i.e. the destination is dispatched by the packed front-end -> script layer.
Control: GOTO_DRAFT_DISABLED must appear in FUN_1800b2680 (we just read it there).
"""
import traceback
try:
def whereis(lit):
hits = find_all(lit.encode() + b"\x00", (".rdata",))
print("\n--- %r : %d literal hit(s) ---" % (lit, len(hits)))
for h in hits:
xs = xrefs_to(h)
if not xs:
print(" @%#x NO xref (string only referenced by offset math / not a lea target)" % h)
for frm, typ, fn, ent in xs:
print(" @%#x xref from %#x %s in %s (%#x)" % (h, frm, typ, fn, ent))
for s in ("GOTO_DRAFT_ONLINE", "GOTO_DRAFT_OFFLINE", "GOTO_DRAFT_DISABLED",
"GOTO_OFFLINE_TOURNAMENT", "GOTO_ONLINE_CHAMPIONS", "GOTO_OFFLINE_SEASON",
"GOTO_ONLINE_SEASON"):
whereis(s)
# the draft mode-node factories (reference fefifa::FUTOnlineDraftMode / OfflineMode)
for a, lbl in [(0x18006b820, "FUN_18006b820 (FUTOnlineDraftMode node)"),
(0x18006b960, "FUN_18006b960 (FUTDraftOfflineMode node)"),
(0x18006baa0, "FUN_18006baa0 (FUTOfflineTournament node)"),
(0x18006bd20, "FUN_18006bd20 (FUTOnlineTournament node)")]:
d = dec(a)
print("\n=== %s len=%d ===" % (lbl, len(d)))
print(d)
except Exception:
traceback.print_exc()
print("QUERY_DONE")
@@ -0,0 +1,29 @@
"""DIMENSION 4 q7 (Q2 rigor): trace the three draft settings atoms 0xf9/0xfa/0xff
through the settings deser FUN_18013c6d0 to struct fields, and through the applier
FUN_18011dc50 to gate bytes +0x1fd3d / +0x1fd3e. Also enumerate ALL native readers
of the two draft gate bytes to confirm the tile builder is the only consumer.
Control: applier must contain a write to +0x1fd2e gated on (field==1) (IS_TRADING,
established). Print applier + deser in full (lengths printed) to avoid truncation.
"""
import traceback
try:
d = dec(0x18011dc50)
print("=== applier FUN_18011dc50 len=%d ===" % len(d))
print(d)
d2 = dec(0x18013c6d0)
print("\n=== settings deser FUN_18013c6d0 len=%d ===" % len(d2))
print(d2)
# native readers of the two draft gate bytes: scan .text for movzx/cmp/mov disp32
import struct as _s
print("\n=== raw disp32 sites for 0x1fd3d and 0x1fd3e in .text ===")
for disp in (0x1fd3d, 0x1fd3e):
pat = _s.pack("<i", disp)
hits = find_all(pat, (".text",))
for h in hits:
fn = fname(h)
print(" disp %#x referenced @%#x in %s" % (disp, h, fn))
except Exception:
traceback.print_exc()
print("QUERY_DONE")
@@ -0,0 +1,12 @@
"""DIMENSION 4 q8: characterize FUN_1800b73e0, the extra direct reader of the
offline-draft byte model+0x1fd3e, to confirm it is not a second independent gate
(it reads the same byte that measures 1 live). Also who calls it."""
import traceback
try:
d = dec(0x1800b73e0)
print("=== FUN_1800b73e0 len=%d ===" % len(d))
print(d)
print("\ncallers(FUN_1800b73e0) =", callers(0x1800b73e0))
except Exception:
traceback.print_exc()
print("QUERY_DONE")
@@ -0,0 +1,25 @@
"""Trace FutPurchaseDraftModeServerResponse beyond its known seven-int parser."""
cls = "FutPurchaseDraftModeServerResponse"
print("CLASS", cls, class_deser(cls))
seen = set()
for deser, vt, factory in class_deser(cls):
print("\nVTABLE", hex(vt), "FACTORY", hex(factory), "DESER", hex(deser))
print(vtable(vt, 32))
for target in [factory, deser] + [t for _, t, name in vtable(vt, 32) if name]:
if target in seen:
continue
seen.add(target)
print("\n===", hex(target), fname(target), "===")
print(dec(target, 300))
print("XREFS", xrefs_to(target)[:100])
for target in (0x18014C090, 0x18014C260, 0x18014C820, 0x18014C8A0):
if target in seen:
continue
print("\n=== CANDIDATE", hex(target), fname(target), "===")
print(dec(target, 300))
print("XREFS", xrefs_to(target)[:100])
print("QUERY_DONE")
@@ -0,0 +1,22 @@
"""Bind the live draft-purchase URI builder to one of its two response factories."""
for literal in (
"purchase/mode/",
"/purchase/mode/",
"draft",
"ut/%s/draft/mode",
"FutPurchaseDraftModeServerResponse",
):
print("\nLITERAL", repr(literal))
for hit in find_all(literal.encode() + b"\x00"):
print(hex(hit), rd_str(hit), xrefs_to(hit)[:100])
for target in (0x180224EF8, 0x1802262F0, 0x18014C090, 0x180150260):
print("\nTARGET", hex(target), fname(target))
print("XREFS", xrefs_to(target)[:200])
for frm, typ, fn, ent in xrefs_to(target):
if ent:
print("\nOWNER", hex(ent), fn)
print(dec(ent, 300))
print("QUERY_DONE")
@@ -0,0 +1,16 @@
"""Dump both request vtables sharing FutPurchaseDraftModeServerResponse."""
for vt in (0x180226300, 0x180224F08):
print("\nREQUEST_VTABLE", hex(vt))
rows = vtable(vt, 40)
print(rows)
seen = set()
for off, target, name in rows:
if not name or target in seen:
continue
seen.add(target)
print("\n=== SLOT", hex(off), hex(target), name, "===")
print(dec(target, 300))
print("XREFS", xrefs_to(target)[:100])
print("QUERY_DONE")
@@ -0,0 +1,23 @@
"""Recover the JSON element shape consumed by the array-root draft response."""
targets = (
0x180138BD0, # helper called once per array element
0x180150310, # array-root FutPurchaseDraftModeServerResponse parser
)
seen = set()
for target in targets:
print("\n=== TARGET", hex(target), fname(target), "===")
print(dec(target, 500))
print("XREFS", xrefs_to(target)[:150])
# Include direct callees so small string/value accessors used by the helper
# are visible without broad, noisy whole-program searching.
for callee, name in callees(target):
if callee in seen:
continue
seen.add(callee)
print("\n--- CALLEE", hex(callee), name, "---")
print(dec(callee, 250))
print("QUERY_DONE")
@@ -0,0 +1,17 @@
"""Trace active draft-state enum literals and the current-state response consumers."""
for literal in ("DRAFTSQUAD_ON", "DRAFTSQUAD_OFF", "DRAFT_SQUAD", "squadState",
"stateParam1", "stateParam2", "roundsInfo"):
print("\n=== LITERAL", literal, "===")
for hit in find_all(literal.encode() + b"\x00", (".rdata", ".data")):
print("HIT", hex(hit), "XREFS", xrefs_to(hit)[:100])
for _frm, _typ, _name, entry in xrefs_to(hit):
print("\n--- XREF FUNCTION", hex(entry), fname(entry), "---")
print(dec(entry, 500))
for target in (0x180147070,):
print("\n=== STATE DESERIALIZER", hex(target), fname(target), "===")
print(dec(target, 500))
print("CALLERS", callers(target))
print("QUERY_DONE")
@@ -0,0 +1,21 @@
"""Resolve draft-state atom IDs to their authoritative wire strings."""
ATOM_TABLE = 0x1802D2760
def atom_name(index):
pointer = qword(ATOM_TABLE + index * 8)
return rd_str(pointer, 96)
groups = {
"squadState values": (0x1AC, 0x6A, 0x9C, 0x12C, 0x169, 0x225, 0x23E, 0x277, 0x278),
"stateParam1 values": (0x169, 0x1AA, 0x22D),
"entranceCriteria keys": (0x96, 0xDF, 0x241),
"top-level keys": (0x108, 0x13B, 0x293, 0x2CD, 0x2D5, 0x2EE, 0x2EF),
}
for group, indices in groups.items():
print("\n===", group, "===")
for index in indices:
print(hex(index), repr(atom_name(index)))
print("QUERY_DONE")
@@ -0,0 +1,37 @@
"""DIMENSION 1 Q1: enumerate the userInfo.feature restriction vocabulary IN FULL.
Hypothesis: FUN_18013ec10 (userInfo deser) handles atom 0x11c (feature) by entering a
nested object-parse loop that dispatches sub-keys (trade=0x330 known) each writing a byte
into the userInfo record. Enumerate EVERY sub-key and the offset each writes.
CONTROL: the known trade atom 0x330 MUST appear and map to +0x17c. If it does not, the
dispatch form assumed is wrong and the enumeration below is unreliable.
Method: print full decompile length + full text of FUN_18013ec10, then scan for the
feature atom 0x11c and identify the nested parser (a callee entered at that case), then
decompile that callee in full too.
"""
import re, traceback
UI_DESER = 0x18013ec10
try:
f = func(UI_DESER)
src = dec(UI_DESER, 300)
print("=== FUN_%08x body=%d insns decompile=%d chars ===" %
(UI_DESER, f.getBody().getNumAddresses() if f else -1, len(src)))
print(src)
print("\n=== callees of FUN_%08x ===" % UI_DESER)
for a, n in callees(UI_DESER):
print(" %#x %s" % (a, n))
# where does 0x11c (feature) / 0x330 (trade) appear textually?
print("\n=== atom mentions in the decompile ===")
for atom, name in ((0x11c, "feature"), (0x330, "trade"), (0x17c, "off+0x17c"),
(0x50, "off+0x50")):
for ln in src.splitlines():
if ("0x%x" % atom) in ln.replace("0X", "0x"):
print(" [%-10s] %s" % (name, ln.strip()))
except Exception:
traceback.print_exc()
@@ -0,0 +1,51 @@
"""DIMENSION 1 Q2: trace what the feature.trade byte gates at massinfo END_OBJECT,
and hunt for ANY other feature-style END_OBJECT zeroing (mode restrictions beyond trade).
Findings so far (q_md_feature_1): userInfo deser FUN_18013ec10 feature-object (case 0x11c)
recognises EXACTLY ONE sub-key, trade 0x330, writing byte *(u8*)(param_1 + 0x29). param_1
is undefined4* so this is byte offset 0x29*4 = 0xa4. But prior notes / q_feature_trade say
the massinfo check reads +0x17c and zeroes +0x50. Resolve the offset, and enumerate every
`cmp byte [rec+X],0 ; jz ; mov ... [rec+Y],0` restriction site in the massinfo root.
CONTROL: the known trade zero-site 0x180174f19 (mov dword [rsi+0x50],0) MUST appear.
Method: decompile massinfo root FUN_180174630 in full; print it; then walk its instruction
listing for every `mov ...,0` guarded by a `cmp byte [reg+disp],0 ; jz`, printing disp/target.
"""
import re, traceback
MASSINFO = 0x180174630
try:
f = func(MASSINFO)
src = dec(MASSINFO, 300)
print("=== FUN_%08x massinfo root body=%d insns decompile=%d chars ===" %
(MASSINFO, f.getBody().getNumAddresses() if f else -1, len(src)))
print(src)
# walk raw instructions for the restriction pattern: cmp byte [r+d],0 ; jz ; mov [r+d2],imm
print("\n=== raw scan: cmp byte [reg+disp],0x0 sites in massinfo body ===")
it = f.getBody().getAddresses(True)
prev = []
for ad in it:
ins = listing.getInstructionAt(ad)
if ins is None:
continue
s = str(ins)
prev.append((int(ad.getOffset()), s))
if len(prev) > 8:
prev.pop(0)
# detect cmp of a byte ptr against 0
if s.startswith("CMP") and "byte ptr" in s.lower() and s.rstrip().endswith(",0x0"):
print(" --- window around %#x ---" % int(ad.getOffset()))
for a2, s2 in prev[-3:]:
print(" %#x %s" % (a2, s2))
# print next 5 insns
nxt = ins
for _ in range(5):
nxt = listing.getInstructionAt(nxt.getAddress().add(nxt.getLength()))
if nxt is None:
break
print(" %#x %s" % (int(nxt.getAddress().getOffset()), str(nxt)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,55 @@
"""DIMENSION 1 Q1/Q4 airtight check: is trade (0x330) or feature (0x11c) dispatched
ANYWHERE other than the userInfo deser FUN_18013ec10?
If a second function compares against 0x330 or 0x11c, there could be another feature-style
restriction map. Enumerate ALL comparison FORMS by scanning instruction operands for the
immediates 0x330 and 0x11c across .text, and report the containing function of each.
CONTROL: FUN_18013ec10 (0x18013ec10) MUST appear for both 0x330 and 0x11c (the known site).
If it does not, the operand-immediate scan is broken and results are unreliable.
"""
import traceback
TARGETS = {0x330: "trade", 0x11c: "feature"}
KNOWN = 0x18013ec10
try:
# scan every instruction in .text for a scalar operand equal to a target immediate
hits = {t: set() for t in TARGETS}
text = None
for b in mem.getBlocks():
if b.getName() == ".text" and b.isInitialized():
text = b
break
ins = listing.getInstructions(text.getStart(), True)
count = 0
while ins.hasNext():
i = ins.next()
count += 1
n = i.getNumOperands()
for op in range(n):
objs = i.getOpObjects(op)
for o in objs:
try:
v = o.getValue() if hasattr(o, "getValue") else None
except Exception:
v = None
if v is None:
continue
v = int(v) & 0xFFFFFFFF
if v in TARGETS:
f = fm.getFunctionContaining(i.getAddress())
hits[v].add((f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0))
print("scanned %d .text instructions" % count)
for t, name in TARGETS.items():
print("\n=== immediate 0x%x (%s) appears in these functions ===" % (t, name))
got_known = False
for fn, ent in sorted(hits[t], key=lambda x: x[1]):
mark = " <== KNOWN userInfo deser" if ent == KNOWN else ""
print(" %#x %s%s" % (ent, fn, mark))
if ent == KNOWN:
got_known = True
print(" CONTROL FUN_18013ec10 present: %s" % got_known)
except Exception:
traceback.print_exc()
@@ -0,0 +1,40 @@
"""DIMENSION 2 Q1/Q2: the publisher, the applier, the settings deser, the ctor.
HYPOTHESIS: FUN_18006cc60 publishes IS_* names by reading model vtable slots; the
complete set is 10 names over a contiguous .rdata run 0x1801fc118..0x1801fc228.
FUN_18011dc50 is the applier (byte = field==1). FUN_18013c6d0 is the settings deser
that maps atoms -> struct fields. FUN_18014e320 is the settings-struct ctor.
CONTROL: FUN_18006cc60 must reference IS_TRADING_ENABLED and call the vt+0x270
accessor already proven (reads 0x1fd2e). If the decompile of the applier shows
`cmp [reg+0x28],1 / sete / mov [rdi+0x1fd2e]` we have the known trading writer as a
positive control that the field-index arithmetic is right.
"""
import traceback
try:
PUB = 0x18006cc60
APP = 0x18011dc50
DESER = 0x18013c6d0
CTOR = 0x18014e320
print("=" * 70)
print("PUBLISHER FUN_18006cc60 (len / decompile)")
print("=" * 70)
d = dec(PUB)
print("len:", len(d))
print(d)
print("=" * 70)
print(".rdata name run 0x1801fc118..0x1801fc250 (contiguous IS_* names)")
print("=" * 70)
p = 0x1801fc118
end = 0x1801fc260
while p < end:
s = rd_str(p)
if s:
print("%#x %r" % (p, s))
p += len(s) + 1
else:
p += 1
except Exception:
traceback.print_exc()
@@ -0,0 +1,93 @@
"""DIMENSION 2 Q1/Q3: slot->disp resolution + READER search per gate byte.
HYPOTHESIS: each publisher slot is an accessor stub `0f b6 81 <disp32> c3`
(movzx eax,byte[rcx+disp]; ret) at model vtable 0x18021c2a0. For the refusing
modes (season/draft/tournament), the ONLY reader of the gate byte is the publisher
FUN_18006cc60, which hands the value to the script layer -- i.e. no native mode gate.
CONTROL: slot 0x270 must decode to disp 0x1fd2e (trading), already proven by two
prior docs. Reader scan must find FUN_18011dc50 (applier, WRITES 0x1fd2e) and
FUN_1801a7260 (TO_TRADE_PILE predicate, READS 0x1fd2e) among the disp-32 hits for
0x1fd2e -- both known, so if either is missing the scan form is wrong.
"""
import traceback
try:
MODEL_VT = 0x18021c2a0
slots = {
0x270: "IS_TRADING_ENABLED",
0x280: "IS_STORE_ENABLED",
0x2b0: "IS_FRIENDLY_SEASON_ENABLED",
0x2b8: "IS_TOURNAMENT_QUIT_ENABLED",
0x2c0: "IS_PROCESSING_STATE_ENABLED",
0x2c8: "IS_DRAFT_MODE_ENABLED",
0x2d8: "IS_STORY_MODE_REWARD_ENABLED",
0x2f0: "IS_RETURNING_USER_REWARDS_SCREEN_ENABLED",
}
print("=" * 70)
print("SLOT -> accessor -> displacement (model offset)")
print("=" * 70)
disp_by_name = {}
for slot in sorted(slots):
tgt = qword(MODEL_VT + slot)
stub = read_bytes(tgt, 8)
disp = None
# 0f b6 81 <disp32> c3 -> movzx eax, byte [rcx+disp32] ; ret
if stub[0:3] == b"\x0f\xb6\x81" and stub[7] == 0xc3:
disp = int.from_bytes(stub[3:7], "little")
# 8b 81 <disp32> c3 -> mov eax, [rcx+disp32] ; ret (int getter, 4-byte)
elif stub[0:2] == b"\x8b\x81" and stub[6] == 0xc3:
disp = int.from_bytes(stub[2:6], "little")
name = slots[slot]
disp_by_name[name] = disp
print("slot +%#05x %-42s -> %#011x stub=%s disp=%s"
% (slot, name, tgt, stub.hex(),
("%#x" % disp) if disp is not None else "??"))
print()
print("=" * 70)
print("READERS: .text hits for each displacement (raw disp32 LE, form-agnostic)")
print("catches movzx/mov/cmp/lea/setcc in every encoding")
print("=" * 70)
for name, disp in disp_by_name.items():
if disp is None:
continue
pat = disp.to_bytes(4, "little")
hits = find_all(pat, blocks=(".text",))
print("\n%-42s disp %#x (%d hit(s))" % (name, disp, len(hits)))
for h in hits:
f = fm.getFunctionContaining(addr(h))
fn = f.getName() if f else "?"
ent = int(f.getEntryPoint().getOffset()) if f else 0
ins = listing.getInstructionAt(addr(h - 3)) or listing.getInstructionAt(addr(h - 2)) or listing.getInstructionAt(addr(h))
print(" %#011x in %-16s (%#x) ins~ %s"
% (h, fn, ent, str(ins) if ins else "?"))
print()
print("=" * 70)
print("READERS via vtable slot call: .text scan for call [reg+slot] (ff /2 disp32)")
print("=" * 70)
# FF /2 with mod=10 (disp32): modrm 0x90..0x97 (rax..rdi), 0x94 needs SIB
call_modrm = [0x90, 0x91, 0x92, 0x93, 0x95, 0x96, 0x97]
for slot in sorted(slots):
pat_disp = slot.to_bytes(4, "little")
found = []
for mrm in call_modrm:
pat = bytes([0xff, mrm]) + pat_disp
for h in find_all(pat, blocks=(".text",)):
f = fm.getFunctionContaining(addr(h))
found.append((h, f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0))
# also REX.W/B variants (41 ff /2, 48/49 not valid for call reg-indirect but include 41)
for rex in (0x41,):
for mrm in [0x90, 0x91, 0x92, 0x93, 0x95, 0x96, 0x97]:
pat = bytes([rex, 0xff, mrm]) + pat_disp
for h in find_all(pat, blocks=(".text",)):
f = fm.getFunctionContaining(addr(h))
found.append((h, f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0))
print("\nslot +%#05x %-42s (%d call-site(s))" % (slot, slots[slot], len(found)))
for h, fn, ent in found:
print(" %#011x in %-16s (%#x)" % (h, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,58 @@
"""DIMENSION 2 Q3/Q4: readers for the refusing modes + SBC/Objectives bytes.
HYPOTHESIS: for season/draft/tournament the gate byte is read only to be
republished to the script layer (publisher) or to gate an unrelated sub-panel, not
to open the mode from a server response. SBC/Objectives have settings fields
(0x1fd2c/0x1fd42/0x1fd28, 0x1fd44) but NO IS_* publisher name; find their readers.
CONTROL: the applier FUN_18011dc50 must contain `mov [rdi+0x1fd3a],al` (season)
preceded by a `cmp [reg+FIELD],1 / sete al`, giving the season struct field index;
we already know trading is field +0x28 -> byte 0x1fd2e, so that pairing in the same
decompile validates the field-index reading.
"""
import traceback
try:
print("=" * 70)
print("APPLIER FUN_18011dc50 (field -> byte, full)")
print("=" * 70)
d = dec(0x18011dc50)
print("len:", len(d))
print(d)
# readers to inspect: season 0x2b0 candidates (non-publisher), draft hub builder
for label, fa in [
("SEASON reader FUN_1800b0e20 (slot 0x2b0)", 0x1800b0e20),
("SEASON reader FUN_18011e3c0 (slot 0x2b0)", 0x18011e3c0),
("DRAFT hub-tile builder FUN_1800b2680 (slot 0x2c8)", 0x1800b2680),
]:
print("=" * 70)
print(label)
print("=" * 70)
d = dec(fa)
print("len:", len(d))
print(d[:6000])
print("=" * 70)
print("SBC / OBJECTIVES byte disp-scans (.text, form-agnostic)")
print("=" * 70)
for name, disp in [
("allowUntradeableForSquadBuildingSets", 0x1fd2c),
("squadBuildingSetsGracePeriodMinutes", 0x1fd28),
("allowGracePeriodForSquadBuildingSets", 0x1fd42),
("enableObjectives", 0x1fd44),
("packOpeningAnimationEnabled", 0x1fd45),
]:
pat = disp.to_bytes(4, "little")
hits = find_all(pat, blocks=(".text",))
print("\n%-40s disp %#x (%d hit(s))" % (name, disp, len(hits)))
for h in hits:
f = fm.getFunctionContaining(addr(h))
fn = f.getName() if f else "?"
ent = int(f.getEntryPoint().getOffset()) if f else 0
ins = (listing.getInstructionAt(addr(h - 3)) or
listing.getInstructionAt(addr(h - 2)) or
listing.getInstructionAt(addr(h)))
print(" %#011x in %-16s (%#x) ins~ %s"
% (h, fn, ent, str(ins) if ins else "?"))
except Exception:
traceback.print_exc()
@@ -0,0 +1,77 @@
"""DIMENSION 2 Q3/Q4 finish: draft-tile gating in FUN_1800b2680; SBC/Objectives
accessor slots and whether any native code reads them.
HYPOTHESIS: IS_DRAFT_MODE_ENABLED (cVar7) gates whether the draft hub tile is drawn
/ enabled. SBC(0x1fd2c,0x1fd42) and Objectives(0x1fd44) have accessor stubs at some
model vtable slots but NO IS_* publisher name; either a vtable-slot caller reads
them or they are consumed only by their own accessor (i.e. no native mode gate).
CONTROL: draft accessor is model slot 0x2c8 (proven). Walking the vtable and
matching disp must reproduce 0x2c8->0x1fd3d and 0x270->0x1fd2e.
"""
import traceback
try:
MODEL_VT = 0x18021c2a0
# find slots for the SBC/objectives displacements by walking vtable
want = {0x1fd2c: "allowUntradeableForSBC", 0x1fd42: "allowGracePeriodForSBC",
0x1fd44: "enableObjectives", 0x1fd28: "sbcGracePeriodMinutes",
0x1fd3e: "offlineDraft(0x2d0?)", 0x1fd2e: "trading(ctl)",
0x1fd3d: "draft(ctl)"}
slot_for_disp = {}
print("=" * 70)
print("vtable walk: slot -> accessor disp (0x200..0x340)")
print("=" * 70)
for slot in range(0x200, 0x340, 8):
tgt = qword(MODEL_VT + slot)
if not (0x180000000 <= tgt < 0x181000000):
continue
stub = read_bytes(tgt, 8)
disp = None
if stub[0:3] == b"\x0f\xb6\x81" and stub[7] == 0xc3:
disp = int.from_bytes(stub[3:7], "little")
elif stub[0:2] == b"\x8b\x81" and stub[6] == 0xc3:
disp = int.from_bytes(stub[2:6], "little")
if disp in want:
slot_for_disp[disp] = slot
print("slot +%#05x -> %#011x disp %#x %s"
% (slot, tgt, disp, want[disp]))
# scan slot-callers for the objectives + SBC slots
print()
print("=" * 70)
print("slot-call readers for SBC/Objectives accessor slots")
print("=" * 70)
call_modrm = [0x90, 0x91, 0x92, 0x93, 0x95, 0x96, 0x97]
for disp in (0x1fd44, 0x1fd2c, 0x1fd42):
slot = slot_for_disp.get(disp)
if slot is None:
print("\ndisp %#x: no vtable slot found in range" % disp)
continue
pat_disp = slot.to_bytes(4, "little")
found = []
for pre in ([], [0x41]):
for mrm in call_modrm:
pat = bytes(pre + [0xff, mrm]) + pat_disp
for h in find_all(pat, blocks=(".text",)):
f = fm.getFunctionContaining(addr(h))
found.append((h, f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0))
print("\ndisp %#x slot +%#05x %-24s (%d call-site(s))"
% (disp, slot, want[disp], len(found)))
for h, fn, ent in found:
print(" %#011x in %-16s (%#x)" % (h, fn, ent))
# rest of the draft hub-tile builder: how cVar7/8/9 gate the tile
print()
print("=" * 70)
print("FUN_1800b2680 draft/tile gating region (search cVar / DRAFT in decompile)")
print("=" * 70)
d = dec(0x1800b2680)
lines = d.splitlines()
for i, ln in enumerate(lines):
if any(k in ln for k in ("cVar7", "cVar8", "cVar9", "DRAFT", "0x70", "0x60",
"DESTINATION", "GOTO_", "SBC", "OBJECTIVE", "case 0xc",
"caseD_")):
print("%4d: %s" % (i, ln.strip()))
except Exception:
traceback.print_exc()
@@ -0,0 +1,16 @@
"""DIMENSION 2 Q2 finish: settings deser FUN_18013c6d0 atom -> struct field.
HYPOTHESIS: the deser matches each settings atom and stores into param_2[i], the
same struct the applier reads. Extract atom -> field index so each gate byte maps
to a concrete /settings flag atom.
CONTROL: trading must be atom 0x336 -> field index 10 (0x28), already proven in the
transfer-market doc. If that pair appears, the atom->field reading is right.
"""
import traceback
try:
d = dec(0x18013c6d0)
print("len:", len(d))
print(d)
except Exception:
traceback.print_exc()
@@ -0,0 +1,47 @@
"""DIMENSION 5 SBC/Objectives.
HYPOTHESIS Q1: enableSquadBuildingSetsFeature (atom 0x100) is READ as an input that
gates the SBC menu -- OR it is an OUTPUT name only ever emitted (like IS_TRADING_ENABLED
turned out to be). Decide by string xrefs: if the only lea to the literal is inside a
publisher (contiguous .rdata name run, straight-line stores), it is output-only.
CONTROL: enableObjectives -- known to have a settings-switch arm (CLEAR-only). Its
literal should be referenced somewhere that is NOT a publisher. And IS_TRADING_ENABLED
literal -> should resolve to the publisher FUN_18006cc60 (proven output name), the
NEGATIVE control for "publisher == output-only".
Q2: SBC set-list deser 0x180154990 + FUT/SBC_USE_STUBS string. What gates stub SBCs.
"""
import traceback
try:
def show_str_xrefs(label, needle):
print("\n=== %s : %r ===" % (label, needle))
hits = find_all(needle)
print(" string occurrences:", [hex(h) for h in hits])
for h in hits:
print(" literal @%#x = %r" % (h, rd_str(h, 60)))
# references land on the string addr itself for lea r8,[rip+..]
for a in (h, h - 4):
xs = xrefs_to(a)
if xs:
print(" xrefs_to(%#x):" % a)
for frm, typ, fn, ent in xs:
print(" from %#x %s in %s (%#x)" % (frm, typ, fn, ent))
show_str_xrefs("SBC feature flag", b"enableSquadBuildingSetsFeature\x00")
show_str_xrefs("Objectives flag (control)", b"enableObjectives\x00")
show_str_xrefs("Objectives-as-mgr flag", b"enableObjectivesAsManagerTasks\x00")
show_str_xrefs("IS_TRADING_ENABLED (output-name neg control)", b"IS_TRADING_ENABLED\x00")
# SBC_USE_STUBS -- brief says deser 0x180154990 checks FUT/SBC_USE_STUBS
for n in (b"SBC_USE_STUBS", b"USE_STUBS", b"FUT/SBC"):
print("\n=== search %r ===" % n)
for h in find_all(n):
print(" @%#x = %r" % (h, rd_str(h - 8, 80)))
print("\n=== dec 0x180154990 (SBC set-list deser per brief) ===")
d = dec(0x180154990)
print("LEN", len(d))
print(d)
except Exception:
traceback.print_exc()
@@ -0,0 +1,50 @@
"""Resolve the concrete owner behind request+0x08 for the SBC category request.
q_md_sbc_9 proved generic slot +0x88 (0x1801631e0) invokes:
owner = *(request + 8)
owner.vtable[+0x18](owner, parsed_response, 0)
Work backwards from the category request constructor and its callers to identify who
supplies request+8, then map candidate owner vtables and their +0x18 consumers.
"""
import traceback
try:
def show(a, label):
f = func(a)
print("\n=== %s %#x %s ===" % (label, a, f.getName() if f else "?"))
print(dec(a))
ctor = 0x18017a7c0
show(ctor, "category request constructor")
print("\n=== ctor callers ===")
for ent, name in callers(ctor):
print(" %#x %s" % (ent, name))
show(ent, "ctor caller")
print("\n=== ctor xrefs ===")
for frm, typ, name, ent in xrefs_to(ctor):
print(" from=%#x type=%s fn=%s entry=%#x" % (frm, typ, name, ent))
# The request base constructor is usually visible as the first direct call in
# the category constructor. Dump every direct callee so request+8 initialization
# can be distinguished from URI/tag setup.
print("\n=== constructor direct callees ===")
for target, name in callees(ctor):
print(" %#x %s" % (target, name))
show(target, "ctor callee")
# Ghidra did not create a function at the traced +0x90 thunk. Print its raw
# instructions and nearby containing-function identity without assuming a body.
print("\n=== raw callback thunk at 0x180154830 ===")
ad = addr(0x180154830)
for _ in range(48):
ins = listing.getInstructionAt(ad)
if ins is None:
print(" %s <not disassembled>" % ad)
ad = ad.add(1)
continue
print(" %s %s" % (ad, ins))
ad = ins.getNext().getAddress() if ins.getNext() else ad.add(ins.getLength())
except Exception:
traceback.print_exc()
@@ -0,0 +1,34 @@
"""Trace the FUT-root constructor's third argument, inherited by every request at +8.
The category request lives at FUT root +0x4140 (qword index 0x828). Its base ctor
stores the root constructor's param_3 at request+8, making that object the receiver
of owner.vtable[+0x18](owner, parsed_response, 0).
"""
import traceback
try:
root_ctor = 0x18010cdc0
print("=== root ctor callers ===")
for ent, name in callers(root_ctor):
print("\n--- %#x %s ---" % (ent, name))
print(dec(ent))
print("\n=== root ctor xrefs ===")
for frm, typ, name, ent in xrefs_to(root_ctor):
print(" from=%#x type=%s fn=%s entry=%#x" % (frm, typ, name, ent))
if ent:
print(dec(ent))
# Static singleton slot and root vtables provide adjacent factory/type metadata.
for site in (0x1802e6398, 0x18021c2a0, 0x18021cda8, 0x18021cdb8):
print("\n=== qwords around %#x ===" % site)
for i in range(-8, 16):
p = site + i * 8
try:
value = qword(p)
except Exception:
continue
print(" [%#x] = %#x %s" % (p, value, fname(value)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,29 @@
"""Map the category success notifier already instrumented at 0x18017aa80.
The checkpoint hook can passively record ctx+0x88 and the +0x58..+0x60 handler
vector. Establish where this notifier sits relative to request ownership transfer and
whether it is the concrete receiver-side publication path we need to observe live.
"""
import traceback
try:
target = 0x18017aa80
print("=== notifier 0x18017aa80 ===")
print(dec(target))
print("\n=== notifier callers ===")
for ent, name in callers(target):
print(" %#x %s" % (ent, name))
print(dec(ent))
print("\n=== notifier xrefs ===")
for frm, typ, name, ent in xrefs_to(target):
print(" from=%#x type=%s fn=%s entry=%#x" % (frm, typ, name, ent))
# Adjacent category request methods often expose the notifier through a vtable
# or callback descriptor; inspect nearby functions and data references.
for a in (0x18017aa80, 0x18017aaf0, 0x18017ab80, 0x18017b1c0):
f = func(a)
print("\n=== %#x %s ===" % (a, f.getName() if f else "?"))
print(dec(a))
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""Map the sole live category-notifier listener into CardsDLL.
Live capture 2026-08-07:
listener object 0x4216ca48
listener vtable 0x6ffffc20d6d0
vtable +0x08 0x6ffffc1e577a
CardsDLL slide 0x6ffe7c020000
static method 0x1801c577a
"""
TARGET = 0x1801C577A
VTABLE = 0x1801ED6D0
print("=== live notifier listener method ===")
target_function = func(TARGET)
if target_function is None:
print("no Ghidra function at %#x" % TARGET)
print("raw PE decoding: jmp [0x1801e5200], imported CRT _purecall")
else:
print("containing function:", target_function.getName(),
hex(int(target_function.getEntryPoint().getOffset())))
print(dec(TARGET))
print("\n=== listener vtable ===")
for slot, target, name in vtable(VTABLE, 12):
print("%+#04x %#x %s" % (slot, target, name))
print("\n=== method callers/xrefs ===")
print("callers:", callers(TARGET) if target_function is not None else [])
for row in xrefs_to(TARGET):
print(row)
@@ -0,0 +1,29 @@
"""Find concrete siblings of the live notifier listener's abstract vtable."""
import struct
VTABLE = 0x1801ED6D0
DTOR = 0x180018EF0
PURECALL_THUNK = 0x1801C577A
print("=== exact vtable references ===")
for row in xrefs_to(VTABLE):
print(row)
print("\n=== vtables sharing the live listener destructor ===")
for hit in find_all(struct.pack("<Q", DTOR), blocks=(".rdata", ".data")):
try:
slots = [qword(hit + i * 8) for i in range(12)]
except Exception:
continue
# Require the same broad interface shape: destructor in slot 0 and at least
# one CardsDLL code pointer after it. This filters incidental data matches.
if slots[0] != DTOR or not any(0x180000000 <= x < 0x1801E5000 for x in slots[1:]):
continue
print("vtable=%#x slot8=%#x %s" %
(hit, slots[1], "PURE" if slots[1] == PURECALL_THUNK else "CONCRETE"))
for i, target in enumerate(slots):
print(" +%#04x %#x %s" % (i * 8, target, fname(target)))
refs_here = xrefs_to(hit)
if refs_here:
print(" refs:", refs_here)
@@ -0,0 +1,31 @@
"""Locate event 0x753c users and category-listener registration/removal paths."""
import struct
EVENT = 0x753C
NOTIFIER = 0x18017AA80
print("=== immediate/data occurrences of event 0x753c ===")
seen = set()
for hit in find_all(struct.pack("<I", EVENT)):
print("hit", hex(hit))
owner = func(hit)
if owner is not None:
entry = int(owner.getEntryPoint().getOffset())
print(" containing", hex(entry), owner.getName())
seen.add(entry)
for row in xrefs_to(hit):
print(" ", row)
if row[3]:
seen.add(row[3])
print("\n=== decompile functions referencing event literal ===")
for entry in sorted(seen):
print("\n--- %#x %s ---" % (entry, fname(entry)))
print(dec(entry))
print("\n=== category request ctor/dtor and notifier neighborhood ===")
for target in (0x18017A7C0, 0x18017AA10, NOTIFIER, 0x18017AAF0, 0x18017B1C0):
print("\n--- %#x %s ---" % (target, fname(target)))
print("callers", callers(target))
print("xrefs", xrefs_to(target))
@@ -0,0 +1,16 @@
"""Resolve the SBC controller and its 0x756c refresh registration/dispatch contract."""
TARGETS = (
(0x1800B5260, "SBC controller allocation/ctor neighborhood"),
(0x1800B53F0, "SBC controller constructor"),
(0x1800B5760, "SBC service/controller constructor"),
(0x1800B5E00, "SBC tile builder"),
(0x1801A4A70, "event registration"),
(0x1801A4CD0, "event dispatch"),
)
for target, label in TARGETS:
print("\n=== %s %#x %s ===" % (label, target, fname(target)))
print(dec(target))
print("callers", callers(target))
print("xrefs", xrefs_to(target))
@@ -0,0 +1,10 @@
"""Decompile the concrete SBC controller event-listener vtable."""
VTABLE = 0x18020A888
print("=== SBC controller event subobject vtable ===")
for off in range(0, 0x80, 8):
target = qword(VTABLE + off)
print("\nslot +%#x -> %#x %s" % (off, target, fname(target)))
if 0x180001000 <= target < 0x180200000:
print(dec(target, 180))
print("callers", callers(target)[:30])
@@ -0,0 +1,7 @@
"""Follow the SBC category-completion continuation registered by event 0x753c."""
for target in (0x1800B8950, 0x1800B89D0, 0x1800B8C30, 0x1800BA460, 0x1800B7090):
print("\n=== %#x %s ===" % (target, fname(target)))
print(dec(target, 300))
print("callers", callers(target)[:50])
print("xrefs", xrefs_to(target)[:50])
@@ -0,0 +1,10 @@
"""Resolve manager +0xe0 used to schedule the ServerErrSets continuation."""
for target in (0x180009C80, 0x1800D7170, 0x180154830, 0x1801631E0):
print("\n=== %#x %s ===" % (target, fname(target)))
print(dec(target, 300))
print("xrefs", xrefs_to(target)[:80])
print("\n=== candidate manager vtables referencing category request callbacks ===")
for target in (0x1800B8950, 0x18017AA80, 0x18017B2B0):
print(hex(target), xrefs_to(target)[:100])
@@ -0,0 +1,39 @@
"""DIMENSION 5 SBC/Objectives -- query 2.
Q1 established so far: enableSquadBuildingSetsFeature literal @0x180230eb8 has EXACTLY
ONE xref, a DATA ref from 0x1802d2f60. Test that 0x1802d2f60 is the atom-dictionary
slot for atom 0x100 (dict base 0x1802d2760 + 0x100*8 = 0x1802d2f60). If so, the flag
is a PURE dictionary entry: never read as a named input, never emitted -- so CardsDLL
does not gate SBC on it, and a Blaze-config delivery of it can only reach the packed
script layer, never CardsDLL.
Also:
- callers of 0x180154990 (the SBC stub loader) -> where the SBC menu/data path enters.
- FUN_180007c30/FUN_180007c40 -> is 'FUT/SBC_USE_STUBS' a client tunable (not server)?
- publisher FUN_18006cc60 full body -> is there ANY SBC/objectives enable name emitted?
- scan for any published string mentioning SBC / SQUAD_BUILD / CHALLENGE enable.
"""
import traceback
try:
dictbase = 0x1802d2760
for atom in (0x100, 0xfd, 0xfe):
slot = dictbase + atom * 8
ptr = qword(slot)
print("atom %#x -> dict slot %#x -> ptr %#x = %r"
% (atom, slot, ptr, rd_str(ptr, 50) if 0x180000000 <= ptr < 0x181000000 else "?"))
print("\n=== callers of 0x180154990 (SBC stub loader) ===")
for ent, nm in callers(0x180154990):
print(" %#x %s" % (ent, nm))
print("\n=== FUN_180007c30 (config store getter?) ===")
print(dec(0x180007c30)[:1500])
print("\n=== FUN_180007c40 (named-config lookup?) ===")
print(dec(0x180007c40)[:2500])
print("\n=== publisher FUN_18006cc60 full ===")
d = dec(0x18006cc60)
print("LEN", len(d))
print(d)
except Exception:
traceback.print_exc()
@@ -0,0 +1,21 @@
"""Find completion callbacks that test the same status field at response+0x1c."""
patterns = (
bytes.fromhex("83 7a 1c 00"), # cmp dword ptr [rdx+1c],0
bytes.fromhex("83 79 1c 00"), # cmp dword ptr [rcx+1c],0
bytes.fromhex("83 78 1c 00"), # cmp dword ptr [rax+1c],0
)
seen = set()
for pattern in patterns:
print("\npattern", pattern.hex())
for hit in find_all(pattern):
f = func(hit)
if f is None:
continue
entry = int(f.getEntryPoint().getOffset())
if entry in seen:
continue
seen.add(entry)
print("\n=== hit %#x function %#x %s ===" % (hit, entry, f.getName()))
print(dec(f, 180)[:5000])
@@ -0,0 +1,14 @@
"""Map the live category response object's vtable and status-bearing base class."""
VTABLE = 0x18022E5B0
print("=== live category response vtable ===")
print("vtable xrefs", xrefs_to(VTABLE)[:100])
for off in range(0, 0x100, 8):
target = qword(VTABLE + off)
print("slot +%#x -> %#x %s" % (off, target, fname(target)))
if 0x180001000 <= target < 0x180200000 and off < 0x60:
print(dec(target, 120)[:3000])
print("\n=== direct references to vtable entries/address ===")
for a in range(VTABLE - 0x20, VTABLE + 0x20, 8):
print(hex(a), xrefs_to(a)[:40])
@@ -0,0 +1,22 @@
"""Find static assignments/usages of completion status 999 (0x3e7)."""
patterns = []
for modrm in (0x40, 0x41, 0x42, 0x43, 0x46, 0x47, 0x80, 0x81, 0x82, 0x83, 0x86, 0x87):
patterns.append(bytes((0xC7, modrm, 0x1C, 0xE7, 0x03, 0x00, 0x00)))
patterns.extend((bytes.fromhex("b8 e7 03 00 00"), bytes.fromhex("b9 e7 03 00 00"),
bytes.fromhex("ba e7 03 00 00"), bytes.fromhex("41 b8 e7 03 00 00")))
seen = set()
for pattern in patterns:
for hit in find_all(pattern):
f = func(hit)
entry = int(f.getEntryPoint().getOffset()) if f else 0
key = (entry, hit)
if key in seen:
continue
seen.add(key)
print("\n=== pattern %s hit %#x function %#x %s ===" %
(pattern.hex(), hit, entry, f.getName() if f else "?"))
if f:
print(dec(f, 240)[:10000])
print("callers", callers(f)[:80])
@@ -0,0 +1,8 @@
"""Trace callers of the HTTP/FUT status mapper returning 999."""
for target in (0x1801844C0, 0x180163120, 0x180165050, 0x180165CC0,
0x18016C060, 0x180184A90):
print("\n=== %#x %s ===" % (target, fname(target)))
print(dec(target, 300)[:18000])
print("callers", callers(target)[:100])
print("xrefs", xrefs_to(target)[:100])
@@ -0,0 +1,26 @@
"""Decompile the transport-result conversion and SBC response base methods."""
TARGETS = (
0x180184420,
0x1801844C0,
0x180184A90,
0x180163120,
0x1801631E0,
0x180165050,
0x180165CC0,
0x18016C060,
0x18016C110,
0x18016C950,
0x18016CA40,
0x18016CAC0,
0x18016CB20,
0x18016CB90,
0x18016CBE0,
0x18016CCA0,
0x18016D230,
)
for address in TARGETS:
print("\n===== %#x %s =====" % (address, fname(address)))
print(dec(address, 60))
@@ -0,0 +1,31 @@
"""Enumerate CardsDLL instructions that write a dword-like value to object +0x1c.
This is intentionally a read-only listing query. It finds explicit memory writes whose
rendered destination operand contains displacement 0x1c, then groups them by function.
"""
listing = prog.getListing()
seen = set()
for insn in listing.getInstructions(True):
text = insn.toString().lower()
if "0x1c" not in text and "+1ch" not in text:
continue
refs = insn.getReferencesFrom()
has_write = any(ref.getReferenceType().isWrite() for ref in refs)
# Register-relative memory writes do not always produce a Ghidra reference, so retain
# the common write mnemonics and require the first rendered operand to contain +0x1c.
mnemonic = insn.getMnemonicString().lower()
dst = insn.getDefaultOperandRepresentation(0).lower()
if "0x1c" not in dst and "+1ch" not in dst:
continue
if not has_write and mnemonic not in ("mov", "movzx", "and", "or", "xor", "inc", "dec"):
continue
owner = func(int(insn.getAddress().getOffset()))
entry = int(owner.getEntryPoint().getOffset()) if owner else 0
key = (entry, int(insn.getAddress().getOffset()))
if key in seen:
continue
seen.add(key)
print("%#x function=%#x %s :: %s" %
(key[1], entry, owner.getName() if owner else "?", insn.toString()))
@@ -0,0 +1,7 @@
"""Inspect the two additional CardsDLL functions with explicit dword writes to +0x1c."""
for target in (0x180171970, 0x1801790A0):
print("\n===== %#x %s =====" % (target, fname(target)))
print(dec(target, 180))
print("callers", callers(target)[:100])
print("xrefs", xrefs_to(target)[:100])
@@ -0,0 +1,41 @@
"""DIMENSION 5 SBC/Objectives -- query 3.
Find the SBC MENU gate. Established: no SBC gate byte in publisher; enableSquadBuilding
SetsFeature not read by CardsDLL. So is there ANY CardsDLL SBC gate/publish, or is it
script-layer?
- xrefs_to(0x180154990): how is the SBC stub loader dispatched (vtable slot?).
- broad string scan for SBC/squad-building surface + any xref that is a publisher emit
(lea in .text) vs pure dictionary (DATA in .data dict region 0x1802d2xxx).
- hub tile builder FUN_1800b2680: does it feature-gate SBC?
- squadBuildingSetsClientData atom 0x2cf: is there a massinfo parser arm? what does it set?
"""
import traceback
try:
print("=== xrefs_to(0x180154990) ===")
for frm, typ, fn, ent in xrefs_to(0x180154990):
print(" from %#x %s in %s (%#x)" % (frm, typ, fn, ent))
print("\n=== string surface: SBC / squad-building / challenge feature ===")
for n in (b"IS_SBC", b"SBC_ENABLED", b"SQUAD_BUILD", b"SquadBuildingSets",
b"squadBuildingSets", b"FUT_SBC", b"futsquadbuildingchallenge",
b"SquadBuildingChallenge", b"MANAGER_TASKS", b"IS_OBJECTIVE",
b"OBJECTIVES_ENABLED", b"managerquest", b"ManagerQuest"):
hits = find_all(n)
if not hits:
continue
for h in hits:
xs = xrefs_to(h)
# classify each xref: DATA in dict region vs code lea
tags = []
for frm, typ, fn, ent in xs:
where = "DICT" if 0x1802d2000 <= frm < 0x1802d4000 else ("CODE:%s(%#x)@%#x" % (fn, ent, frm))
tags.append("%s/%s" % (typ, where))
print(" %r @%#x xrefs=%s" % (rd_str(h, 48), h, tags or "NONE"))
print("\n=== hub tile builder FUN_1800b2680 (SBC feature gate?) ===")
d = dec(0x1800b2680)
print("LEN", len(d))
print(d[:6000])
except Exception:
traceback.print_exc()
@@ -0,0 +1,38 @@
"""DIMENSION 5 SBC/Objectives -- query 4.
- classify 0x180154990's 3 data xrefs (0x1802fceb8 vtable? 0x180277430 dispatch?
0x180226fc8 factory/name?). Read the .rdata name near 0x180226fc8 and the qwords
around each site.
- full 0x180154990: what does FUT/SBC_USE_STUBS==1 actually build? (print full)
- grep the hub tile builder text for SBC/CHALLENGE/SET/GOTO_ destinations + switch arms.
- check for a CompetitionManager-style SBC singleton or count writer.
"""
import traceback
try:
def ctx_qwords(a, before=4, after=6):
print(" qwords around %#x:" % a)
for i in range(-before, after):
p = a + i*8
v = qword(p)
nm = fname(v) if 0x180000000 <= v < 0x181000000 else ""
s = ""
if 0x180000000 <= v < 0x181000000:
st = rd_str(v, 40)
if st.isprintable() and len(st) > 2:
s = repr(st)
print(" [%#x] = %#x %s %s" % (p, v, nm, s))
for site in (0x1802fceb8, 0x180277430, 0x180226fc8):
print("\n=== xref site %#x ===" % site)
# what block
b = None
for blk in mem.getBlocks():
if blk.getStart().getOffset() <= site <= blk.getEnd().getOffset():
b = blk.getName()
print(" block:", b)
ctx_qwords(site)
print("\n=== full FUN_180154990 ===")
print(dec(0x180154990))
except Exception:
traceback.print_exc()
@@ -0,0 +1,37 @@
"""DIMENSION 5 -- query 5. Does CardsDLL have an SBC/objectives HUB TILE case or a
feature gate for them? Grep the full hub tile builder + look for GOTO_/DESTINATION
strings mentioning SBC/challenge/manager-task, and any 'enableSquadBuildingSets'-style
gate. Also enumerate the dispatch table around 0x180226fc0 (what handler group it is).
"""
import traceback
try:
d = dec(0x1800b2680)
import re
print("=== hub builder: lines mentioning SBC/CHALLENGE/TASK/GOTO_/DESTINATION/SQUAD_BUILD ===")
for ln in d.splitlines():
if re.search(r"SBC|CHALLENGE|MANAGER_TASK|MANAGERTASK|GOTO_|DESTINATION|SQUAD_BUILD|OBJECTIVE|QUEST", ln, re.I):
print(" " + ln.strip()[:140])
print("\n=== all quoted string literals in hub builder (tile destinations) ===")
seen = set()
for m in re.findall(r'"([^"]{2,60})"', d):
if m not in seen:
seen.add(m)
print(" ", m)
print("\n=== dispatch table @0x180226fc0 (handler group containing 0x180154990) ===")
for i in range(-6, 16):
p = 0x180226fc0 + i*8
v = qword(p)
nm = fname(v) if 0x180000000 <= v < 0x181000000 else ""
print(" [%#x] %#x %s" % (p, v, nm))
print("\n=== xrefs_to dispatch table base region (who indexes 0x180226fc0) ===")
for base in (0x180226fc0, 0x180226fb0, 0x180226fb8):
xs = xrefs_to(base)
if xs:
print(" refs to %#x:" % base)
for frm, typ, fn, ent in xs:
print(" %#x %s in %s(%#x)" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,42 @@
"""DIMENSION 5 -- query 6. The hub tile builder DOES compute enabled/disabled tile
destinations (GOTO_DRAFT_DISABLED, GOTO_MANAGER_QUEST_DISABLED). Find the SBC and
MANAGER-QUEST tile cases and the exact gate condition, and whether an ENABLED variant
destination exists (GOTO_MANAGER_QUEST / GOTO_SBC / GOTO_SQUAD_BUILDING...).
Map the gate-byte accessor vtable slots read at the top of FUN_1800b2680 to model
displacements (slot 0x2c8/0x2d0/0x320) by reading each stub's disp32.
"""
import traceback
try:
print("=== search enabled/disabled destination strings across binary ===")
for n in (b"GOTO_MANAGER_QUEST", b"GOTO_SBC", b"GOTO_SQUAD_BUILDING",
b"GOTO_SQUAD_BUILDING_SETS", b"MANAGER_QUEST", b"SQUAD_BUILDING_SETS",
b"GOTO_DRAFT"):
for h in find_all(n):
print(" %#x %r" % (h, rd_str(h, 60)))
# map model vtable slots to disp: read accessor stub bytes 0f b6 81 <disp32> c3
print("\n=== model gate-byte accessor slots (DAT_1802e6398 vtable static 0x18021c2a0) ===")
vtbase = 0x18021c2a0
for slot in (0x2c8, 0x2d0, 0x320, 0x2b0, 0x270, 0x2e0):
tgt = qword(vtbase + slot)
b = read_bytes(tgt, 8)
disp = None
if b[0:3] == bytes.fromhex("0fb681"):
disp = int.from_bytes(b[3:7], "little")
print(" slot +%#x -> %#x bytes=%s disp=%s"
% (slot, tgt, b.hex(), hex(disp) if disp is not None else "?"))
# Now dump the hub builder and print the SBC + manager-quest tile blocks with context
d = dec(0x1800b2680)
lines = d.splitlines()
print("\n=== hub builder around SBC tile image + 0x110 + 0x230 manager quest ===")
for i, ln in enumerate(lines):
if ("GameHub_SBS" in ln or "MANAGER_QUEST" in ln or "0x230" in ln
or "0x110" in ln or "DREAMSQUAD" in ln):
lo = max(0, i-14); hi = min(len(lines), i+4)
print(" --- ctx @line %d ---" % i)
for j in range(lo, hi):
print(" " + lines[j].strip()[:150])
except Exception:
traceback.print_exc()
@@ -0,0 +1,54 @@
"""DIMENSION 5 -- query 7. Nail the verdicts.
A) OBJECTIVES gate: cVar9 = model slot 0x320 = accessor FUN_18011c570 = disp 0x1fd44,
used to pick GOTO_MANAGER_QUEST vs GOTO_MANAGER_QUEST_DISABLED. Confirm the ONLY
consumers of that accessor (and of the draft accessors 0x2c8/0x2d0) so we can say
which gate byte drives which tile. CONTROL: trading accessor 0x270 (disp 0x1fd2e)
should be consumed by the TO_TRADE_PILE predicate, not the hub builder.
B) settings arm for enableObjectives (atom 0xfd=253) in the applier chain: is it
CLEAR-only? Decompile the settings deser 0x18013c6d0 and grep its arms near 0xfd/0xfe.
C) SBC: is there ANY CardsDLL reader of the SBC-config gate bytes as a MENU gate?
accessor stubs for disp 0x1fd2c/0x1fd28/0x1fd42 (SBC settings) -> their callers.
"""
import traceback
try:
def callers_of(a, tag):
print("\n=== callers of %#x (%s) ===" % (a, tag))
cs = callers(a)
if not cs:
print(" (none via getCallingFunctions)")
for ent, nm in cs:
print(" %#x %s" % (ent, nm))
callers_of(0x18011c570, "objectives accessor disp 0x1fd44 / slot 0x320")
callers_of(0x18011c4b0, "draft accessor disp 0x1fd3d / slot 0x2c8")
callers_of(0x18011c580, "offline-draft accessor disp 0x1fd3e / slot 0x2d0")
callers_of(0x18011c670, "trading accessor disp 0x1fd2e / slot 0x270 (CONTROL)")
# find accessor stubs for SBC settings disps by scanning .text for 0f b6 81 <disp>
print("\n=== find accessor stubs for SBC-config disps 0x1fd2c/0x1fd28/0x1fd42 ===")
for disp in (0x1fd2c, 0x1fd28, 0x1fd42):
pat = bytes.fromhex("0fb681") + disp.to_bytes(4, "little")
for h in find_all(pat, blocks=(".text",)):
f = func(h)
ent = int(f.getEntryPoint().getOffset()) if f else 0
print(" disp %#x stub @%#x in %#x" % (disp, h, ent))
if ent:
for cent, cnm in callers(ent):
print(" <- %#x %s" % (cent, cnm))
print("\n=== settings deser 0x18013c6d0 : arms near enableObjectives 0xfd/0xfe ===")
d = dec(0x18013c6d0)
print("LEN", len(d))
import re
lines = d.splitlines()
for i, ln in enumerate(lines):
if re.search(r"0xfd\b|0xfe\b|== 0xfd|253|254|0x70\)|\+ 0x70|field.*0x1c", ln):
lo=max(0,i-3); hi=min(len(lines),i+5)
print(" --- @%d ---" % i)
for j in range(lo,hi):
print(" "+lines[j].strip()[:140])
except Exception:
traceback.print_exc()
@@ -0,0 +1,19 @@
"""DIMENSION 5 -- query 8. Dump the DAT_1802e0f04 case 1 (SBC/SBS tile) and case 2
regions of FUN_1800b2680 in full, to confirm the SBS tile (GameHub_SBS.png) receives
only FG_PATH from CardsDLL and no DESTINATION / no SBC-specific enable gate.
Print raw line numbers so the case boundaries are unambiguous.
"""
import traceback
try:
d = dec(0x1800b2680)
lines = d.splitlines()
# find the SBS image line and print a wide window
for i, ln in enumerate(lines):
if "GameHub_SBS" in ln:
lo = max(0, i-30); hi = min(len(lines), i+60)
print("=== window %d..%d around GameHub_SBS ===" % (lo, hi))
for j in range(lo, hi):
print("%4d %s" % (j, lines[j].rstrip()[:150]))
break
except Exception:
traceback.print_exc()
@@ -0,0 +1,61 @@
"""Continue the SBC response handoff analysis after the 2026-08-07 passive trace.
Proven live boundary:
request +0x80 factory -> response 0x18022e5b0
response +0x08 -> 0x18017b2b0 returns true
request +0x90 -> parsed response callback returns normally
request +0x88 -> ownership transfer returns normally
The next unknown is the receiving owner's virtual +0x18 consumer called by
0x1801631e0. Recover the concrete receiver, its vtable, and downstream publication.
"""
import traceback
try:
def dump_function(a, label):
f = func(a)
print("\n=== %s @%#x (%s) ===" % (label, a, f.getName() if f else "?"))
if f:
print("entry=%s body=%s" % (f.getEntryPoint(), f.getBody()))
print(dec(a))
def dump_instructions(a, before=0, count=80):
f = func(a)
print("\n=== instructions around %#x ===" % a)
if not f:
return
rows = []
for ad in f.getBody().getAddresses(True):
ins = listing.getInstructionAt(ad)
if ins:
rows.append(ins)
pivot = next((i for i, ins in enumerate(rows)
if int(ins.getAddress().getOffset()) >= a), 0)
for ins in rows[max(0, pivot-before):pivot+count]:
print(" %s %s" % (ins.getAddress(), ins))
dump_function(0x1801631e0, "post-request ownership handoff / owner consumer")
dump_instructions(0x1801631e0, count=120)
print("\n=== callers/xrefs of 0x1801631e0 ===")
for ent, name in callers(0x1801631e0):
print(" caller %#x %s" % (ent, name))
print(dec(ent))
for frm, typ, name, ent in xrefs_to(0x1801631e0):
print(" xref from=%#x type=%s fn=%s entry=%#x" %
(frm, typ, name, ent))
request_vtable = 0x18022e5c0
print("\n=== category request vtable %#x ===" % request_vtable)
for off, target, name in vtable(request_vtable, 40):
print(" +%#04x -> %#x %s" % (off, target, name))
for slot, label in ((0x80, "typed factory"),
(0x88, "ownership transfer"),
(0x90, "completion callback")):
target = qword(request_vtable + slot)
dump_function(target, "request %s slot +%#x" % (label, slot))
dump_instructions(target, count=100)
except Exception:
traceback.print_exc()
@@ -0,0 +1,30 @@
"""Find indirect calls to service-interface slot +0xe0 and compare contracts."""
PATTERNS = (
bytes.fromhex("ff 90 e0 00 00 00"),
bytes.fromhex("ff 91 e0 00 00 00"),
bytes.fromhex("ff 92 e0 00 00 00"),
bytes.fromhex("ff 93 e0 00 00 00"),
bytes.fromhex("ff 96 e0 00 00 00"),
bytes.fromhex("ff 97 e0 00 00 00"),
bytes.fromhex("41 ff 90 e0 00 00 00"),
bytes.fromhex("41 ff 91 e0 00 00 00"),
bytes.fromhex("41 ff 92 e0 00 00 00"),
bytes.fromhex("41 ff 93 e0 00 00 00"),
)
seen = set()
for pattern in PATTERNS:
for hit in find_all(pattern, blocks=(".text",)):
owner = func(hit)
if owner is None:
continue
entry = int(owner.getEntryPoint().getOffset())
if entry in seen:
continue
seen.add(entry)
print("\n===== call %#x function %#x %s =====" %
(hit, entry, owner.getName()))
print(dec(owner, 120)[:12000])
print("callees", callees(owner)[:80])
@@ -0,0 +1,54 @@
"""DIMENSION 3 SEASONS q1.
HYPOTHESIS: the Seasons refusal is decided in the front-end SCRIPT layer, not in
CardsDLL. If so, the CardsDLL season loaders make no network call, only read a
u16 count, and the CompetitionManager mode setters have ZERO in-DLL callers
(driven from outside). Prove or refute by enumerating callers of the mode setters,
decompiling the loader chain, and tracing the NOSEASONS event.
CONTROL: for the "zero callers" claim, a control with KNOWN callers must be in the
same batch -- I use FUN_180057560 (LoadOfflineSeasons) itself, which per prior work
is reached from the RPC dispatch, so callers() must be NON-empty for it if the
mechanism is sound; if callers() returns [] for a function I know is called, the
query form is broken and no absence claim is valid.
"""
import traceback
try:
targets = {
"FUN_180101680 (CompMgr mode set A)": 0x180101680,
"FUN_1801016c0 (CompMgr mode set B)": 0x1801016c0,
"FUN_180057560 LoadOfflineSeasons": 0x180057560,
"FUN_1800576b0 LoadSeasons": 0x1800576b0,
"FUN_180057230 LoadCurrentOfflineSeason": 0x180057230,
"FUN_180057330 (NOSEASONS fire?)": 0x180057330,
}
for name, a in targets.items():
print("=" * 70)
print(name, hex(a))
try:
cs = callers(a)
except Exception as e:
cs = "ERR %r" % e
print(" callers:", cs)
# NOSEASONS literal
print("=" * 70)
print("NOSEASONS literal search")
for lit in (b"NOSEASONS\x00", b"NOSEASONS"):
hits = find_all(lit)
print(" ", lit, "->", [hex(h) for h in hits])
# xrefs to the reported literal addr
print(" xrefs to 0x1801f92c0:")
for x in xrefs_to(0x1801f92c0):
print(" ", hex(x[0]), x[1], x[2], hex(x[3]))
# CompetitionManager singleton
print("=" * 70)
print("DAT_1802e6328 (CompetitionManager singleton) xrefs:")
for x in xrefs_to(0x1802e6328):
print(" ", hex(x[0]), x[1], x[2], hex(x[3]))
sys.stdout.flush()
os._exit(0)
except Exception:
traceback.print_exc()
sys.stdout.flush()
os._exit(0)
@@ -0,0 +1,42 @@
"""DIMENSION 3 SEASONS q2.
HYPOTHESIS: the season loaders / CompMgr mode setters are dispatched via a table
(RPC descriptor or vtable) rather than direct CALL, so callers()==[] is a
search-form artifact, NOT proof of script-layer. Also: the actual refusal is
count==0 -> fire NOSEASONS in FUN_180057330; establish where the count is read
and whether a server response could write it.
CONTROL: search for a KNOWN table-member function address as an 8-byte LE pointer
to prove find_all-pointer form works: I use FUN_180057560 vs a control that I
expect to appear in .data (the RPC descriptor). If NEITHER the target nor any
control pointer is found, the pointer-search form is broken.
"""
import traceback, struct
try:
def ptr_hits(a):
le = struct.pack("<Q", a)
return find_all(le, blocks=(".rdata", ".data", ".pdata"))
for name, a in [
("FUN_180101680 modeA", 0x180101680),
("FUN_1801016c0 modeB", 0x1801016c0),
("FUN_180057560 LoadOfflineSeasons", 0x180057560),
("FUN_1800576b0 LoadSeasons", 0x1800576b0),
("FUN_180057230 LoadCurOfflineSeason", 0x180057230),
("FUN_180057330 NOSEASONS", 0x180057330),
]:
hits = ptr_hits(a)
print("PTRHITS", name, hex(a), "->", [hex(h) for h in hits])
print("\n############ DECOMPILE FUN_180057330 (NOSEASONS fire) ############")
d = dec(0x180057330)
print("LEN", len(d)); print(d)
print("\n############ DECOMPILE FUN_180057560 (LoadOfflineSeasons) ############")
d = dec(0x180057560)
print("LEN", len(d)); print(d)
sys.stdout.flush()
os._exit(0)
except Exception:
traceback.print_exc()
sys.stdout.flush()
os._exit(0)
@@ -0,0 +1,45 @@
"""DIMENSION 3 SEASONS q3.
GOAL: find WHO WRITES the model season-list vector (this+0x5c68, exposed via
vtable +0x898) and the current-season short at this+0x7138+0x96/+0x98. If the ONLY
writer is the /season SeasonList deserializer, then a server response CAN populate
it (server-reachable). If nothing writes it, or only a script-driven loader does,
the gate is upstream of any server response.
Also: identify the 0x1801f8xxx table (script-command dispatch?) and dump the
descriptor rows around the season callbacks; and dump the vtable region 0x180219ac0.
CONTROL: for the deser store-target question, decompile 0x1801683f0 (SeasonList
deser) AND 0x180167740 (element parser) IN FULL (print len) and look for a store
into a model offset vs a local response object.
"""
import traceback, struct
try:
# what references the season callback table cluster 0x1801f8a38..0x1801f8ab8?
print("### xrefs into the 0x1801f8xxx season-callback cluster ###")
for a in (0x1801f8a38, 0x1801f8a50, 0x1801f8a58, 0x1801f8ab0, 0x1801f8ab8):
print(" cluster", hex(a), "bytes:", read_bytes(a-8, 24).hex())
for x in xrefs_to(a):
print(" xref", hex(x[0]), x[1], x[2], hex(x[3]))
# dump the callback table region as pointers to see the row structure
print("\n### dump 0x1801f8a30..0x1801f8ac0 as qwords ###")
for off in range(0x1801f8a30, 0x1801f8ac0, 8):
v = qword(off)
print(" ", hex(off), hex(v), fname(v) if 0x180000000 <= v < 0x181000000 else "")
print("\n### dump vtable region 0x180219aa0..0x180219af0 ###")
for off in range(0x180219aa0, 0x180219af0, 8):
v = qword(off)
print(" ", hex(off), hex(v), fname(v) if 0x180000000 <= v < 0x181000000 else "")
# SeasonList deserializer + element parser: where do they store?
print("\n############ DECOMPILE 0x1801683f0 (SeasonList deser) ############")
d = dec(0x1801683f0); print("LEN", len(d)); print(d)
print("\n############ DECOMPILE 0x180167740 (season element parser) ############")
d = dec(0x180167740); print("LEN", len(d)); print(d)
sys.stdout.flush()
os._exit(0)
except Exception:
traceback.print_exc()
sys.stdout.flush()
os._exit(0)
@@ -0,0 +1,70 @@
"""DIMENSION 3 SEASONS q4.
ESTABLISHED: SeasonList deser 0x1801683f0 clears+repopulates the model season-list
vector (model vtable +0x898). FUN_180057330 reads that vector; empty -> NOSEASONS.
NOW: (a) confirm +0x898 getter returns this+0x5c68 and +0x588 getter -> this+0x7138;
(b) find WHO ISSUES the GET /season (SEASONLIST) RPC and its callers -- is the
request reachable, or is it never issued; (c) find every writer of the count short
at this+0x7138+0x96/+0x98 via a disp32 scan (form-independent).
CONTROL for disp32 scan: also scan for a KNOWN-written model offset (0x1fd2e, the
trading gate byte, known to have exactly one writer FUN_18011dc50) -> must find >=1
hit, else the scan form is broken.
"""
import traceback, struct
try:
MODEL_VT = 0x18021c2a0
print("### model vtable getters ###")
for slot in (0x588, 0x898, 0x850):
t = qword(MODEL_VT + slot)
print("slot +%#x -> %#x %s" % (slot, t, fname(t)))
print(dec(t)[:600])
print("-" * 40)
def disp32_scan(off, label, blocks=(".text",)):
le = struct.pack("<i", off)
hits = find_all(le, blocks=blocks)
print("DISP32", label, hex(off), "->", len(hits), "hits")
for h in hits:
f = fm.getFunctionContaining(addr(h))
print(" ", hex(h), f.getName() if f else "?")
return hits
print("\n### disp32 scans (form-independent) ###")
disp32_scan(0x1fd2e, "CONTROL trading gate byte")
disp32_scan(0x5c68, "season list vector base")
disp32_scan(0x7138, "season sub-struct base")
# the +0x96 / +0x98 short lives INSIDE the +0x7138 struct; its writers deref a
# pointer to that struct then +0x96. Hard to disp32-scan directly; instead show
# readers/writers of the +0x7138 getter result are the callers of slot +0x588.
# SEASONLIST RPC: descriptor row 69, stride 0x30, base 0x1802caa28
print("\n### RPC descriptor row 69 (SEASONLIST) ###")
base = 0x1802caa28
row = base + 69 * 0x30
print("row addr", hex(row), "bytes:", read_bytes(row, 0x30).hex())
# first qword often a name ptr, look for a char* to 'season'
for o in range(0, 0x30, 8):
v = qword(row + o)
s = ""
if 0x180000000 <= v < 0x181000000:
try:
s = rd_str(v, 40)
except Exception:
s = ""
print(" +%#x %#x %r" % (o, v, s))
# find the 'ut/%s/season' or 'season' URL template and its xref (the issuer)
print("\n### 'season' url template search ###")
for lit in (b"ut/%s/season\x00", b"/season\x00", b"season\x00"):
hits = find_all(lit, blocks=(".rdata",))
print(" ", lit, "->", [hex(h) for h in hits][:8])
for h in hits[:4]:
for x in xrefs_to(h):
print(" xref", hex(x[0]), x[2], hex(x[3]))
sys.stdout.flush()
os._exit(0)
except Exception:
traceback.print_exc()
sys.stdout.flush()
os._exit(0)
@@ -0,0 +1,39 @@
"""DIMENSION 3 SEASONS q5.
Q: is the GET /season (SEASONLIST) request reachable, and from where? Descriptor
row 69 handler is FUN_180124710. Get its callers and decompile it. Also decompile
the +0x7138 struct writer FUN_18011c2e0 and FUN_18011a830-area accessor to locate
the writer of the count short at +0x7138+0x96/+0x98. And decompile the three vtable
getter stubs (0x18011c150/+0x588, 0x18011b8a0/+0x898) via dec() on the address.
CONTROL: callers() proven working in q1 (returned [] for table-dispatched fns and
non-[] is expected for a normally-called fn); FUN_18011dc50 is a known
table/virtual-dispatched writer, use its caller set shape as sanity.
"""
import traceback
try:
for name, a in [
("FUN_180124710 SEASONLIST handler", 0x180124710),
("FUN_18011c2e0 (+0x7138 accessor)", 0x18011c2e0),
]:
print("=" * 60, name, hex(a))
print("callers:", callers(a))
d = dec(a); print("LEN", len(d)); print(d)
print("=" * 60, "getter stub +0x588 @0x18011c150")
print(dec(0x18011c150))
print("=" * 60, "getter stub +0x898 @0x18011b8a0")
print(dec(0x18011b8a0))
print("=" * 60, "accessor @0x18011a822 area (fn 0x18011a830?)")
print("fname 0x18011a822 ->", fname(0x18011a822))
print(dec(0x18011a822)[:1200])
# who calls the SeasonList RESPONSE deser's install? find xrefs to 0x180124710
print("=" * 60, "xrefs_to FUN_180124710")
for x in xrefs_to(0x180124710):
print(" ", hex(x[0]), x[1], x[2], hex(x[3]))
sys.stdout.flush()
os._exit(0)
except Exception:
traceback.print_exc()
sys.stdout.flush()
os._exit(0)
@@ -0,0 +1,59 @@
"""DIMENSION 3 SEASONS q6.
Decode the non-function targets by raw bytes; find who consumes the +0x898 season
vector getter; find who ISSUES the SEASONLIST RPC (xrefs to descriptor row and the
RPC dispatch); find the writer of the +0x7138+0x96/+0x98 count short.
"""
import traceback, struct
try:
def show(a, n, label):
b = read_bytes(a, n)
print(label, hex(a), b.hex())
print("### decode getter/handler stubs ###")
show(0x18011b8a0, 12, "+0x898 getter") # expect lea rax,[rcx+0x5c68];ret
show(0x18011c150, 12, "+0x588 getter") # expect lea rax,[rcx+0x7138];ret
show(0x180124710, 48, "SEASONLIST handler")
# instructions via listing for the handler
print("\n### listing FUN_180124710 (SEASONLIST handler) ###")
a = addr(0x180124710)
for _ in range(24):
ins = listing.getInstructionAt(a)
if ins is None:
print(" (no instr at", a, ")"); break
print(" ", a, ins)
a = ins.getAddress().add(ins.getLength())
# who references the +0x898 getter stub -> all season-vector consumers
print("\n### xrefs_to +0x898 getter stub 0x18011b8a0 ###")
for x in xrefs_to(0x18011b8a0):
print(" ", hex(x[0]), x[1], x[2], hex(x[3]))
# who references the SEASONLIST descriptor row and its neighbours (RPC issue)
print("\n### xrefs_to descriptor row region ###")
for row in (0x1802cb718, 0x1802cb720, 0x1802cb738):
print(" row", hex(row))
for x in xrefs_to(row):
print(" ", hex(x[0]), x[1], x[2], hex(x[3]))
# xref to the URL-base pointer 0x18021e0d0 (ut/%s/season) -> the URL builder
print("\n### xrefs_to url base ptr 0x18021e0d0 and template 0x18021e598 ###")
for a2 in (0x18021e0d0, 0x18021e598):
for x in xrefs_to(a2):
print(" ", hex(a2), "<-", hex(x[0]), x[1], x[2], hex(x[3]))
# +0x7138 struct: FUN_1801129f0 (reset?) and who calls FUN_18011c2e0
print("\n### FUN_1801129f0 (season struct op) callers + decomp head ###")
print("callers:", callers(0x1801129f0))
print(dec(0x1801129f0)[:900])
print("\n### xrefs_to FUN_18011c2e0 (writes +0x7138 area) ###")
for x in xrefs_to(0x18011c2e0):
print(" ", hex(x[0]), x[1], x[2], hex(x[3]))
sys.stdout.flush()
os._exit(0)
except Exception:
traceback.print_exc()
sys.stdout.flush()
os._exit(0)
@@ -0,0 +1,50 @@
"""DIMENSION 3 SEASONS q7.
(a) Is the +0x7138 season-struct writer (model vtable slot +0x990 = FUN_18011c2e0)
reached from the massinfo/settings RESPONSE path (a boot server lever), like the
settings applier at +0x988? Find call sites of slot +0x990.
(b) Does userInfo.feature parser FUN_18013ec10 have a season-related restriction key?
List its atom compares.
(c) Confirm FUN_1801683f0 is the FutSeasonList RESPONSE deser (RS4 name -> vtable +8).
(d) Does the massinfo body deser (FUN_180174xxx region) or its completion touch the
season vector / +0x7138 (i.e. can boot populate seasons)?
CONTROL: for the RS4 resolution, also resolve a KNOWN class RS4:FutSquadSave ->
must give 0x180171a60 (per class_deser docstring) as a passing control.
"""
import traceback, struct
try:
# (a) find call sites of model vtable slot +0x990 (0x990 disp on a call through rax/rcx)
# The applier +0x988 was called from 0x180173f0b and 0x18011e21a. Search .text for
# the byte pattern of a call [reg+0x990]: ff 90 90 09 00 00 (call [rax+0x990]) and
# ff 91 90 09 00 00 (call [rcx+0x990]) and other regs.
print("### call [reg+0x990] sites (season struct writer) ###")
for modrm in (0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97):
pat = bytes([0xff, modrm]) + struct.pack("<i", 0x990)
for h in find_all(pat, blocks=(".text",)):
f = fm.getFunctionContaining(addr(h))
print(" +0x990 call", hex(h), "in", f.getName() if f else "?", "modrm", hex(modrm))
print("### control: call [reg+0x988] sites (settings applier) ###")
for modrm in (0x90, 0x91, 0x92, 0x93):
pat = bytes([0xff, modrm]) + struct.pack("<i", 0x988)
for h in find_all(pat, blocks=(".text",)):
f = fm.getFunctionContaining(addr(h))
print(" +0x988 call", hex(h), "in", f.getName() if f else "?")
# (b) feature parser atom compares
print("\n### FUN_18013ec10 (userInfo.feature parser) decompile ###")
d = dec(0x18013ec10); print("LEN", len(d)); print(d)
# (c) RS4:FutSeasonList resolution + control
print("\n### RS4 resolution ###")
for cls in (b"RS4:FutSeasonListServerResponse", b"RS4:FutSquadSaveServerResponse"):
for a in find_all(cls, blocks=(".rdata",)):
print(" class", cls, "@", hex(a))
for x in xrefs_to(a):
fn = x[2]
print(" factory xref", hex(x[0]), fn, hex(x[3]))
sys.stdout.flush()
os._exit(0)
except Exception:
traceback.print_exc()
sys.stdout.flush()
os._exit(0)
@@ -0,0 +1,47 @@
"""DIMENSION 3 SEASONS q8 (final): confirm the SeasonList deser is the SOLE populator
of the season-list vector by enumerating every call [reg+0x898] site; confirm
FUN_180174630 is the massinfo body handler; resolve atom names for the season keys.
"""
import traceback, struct
try:
print("### all call [reg+0x898] sites (season-vector consumers) ###")
for modrm in range(0x90, 0x98):
pat = bytes([0xff, modrm]) + struct.pack("<i", 0x898)
for h in find_all(pat, blocks=(".text",)):
f = fm.getFunctionContaining(addr(h))
print(" ", hex(h), "in", f.getName() if f else "?")
print("\n### FUN_180174630 identity: does it parse the massinfo body? head ###")
d = dec(0x180174630)
print("LEN", len(d))
# print the first 1500 chars to see the member dispatch + userInfo/settings/season calls
print(d[:1800])
print("\n### resolve atom names via fut_atoms.tsv ###")
import os as _os
tsv = "/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"
want = {0x2ad,0x354,0x35e,0x24b,0x27b,0xdd,0xdc,0x253,0x1b8,0x330,0x11c,0x2d4}
try:
with open(tsv) as f:
for line in f:
parts = line.rstrip("\n").split("\t")
if len(parts) >= 2:
try:
v = int(parts[0], 0)
except ValueError:
try:
v = int(parts[1], 0)
except (ValueError, IndexError):
continue
parts = [parts[1], parts[0]] + parts[2:]
if v in want:
print(" ", hex(v), parts[1] if len(parts) > 1 else parts)
except Exception as e:
print(" tsv err", e)
sys.stdout.flush()
os._exit(0)
except Exception:
traceback.print_exc()
sys.stdout.flush()
os._exit(0)
@@ -0,0 +1,23 @@
"""q9: do FUN_18006ac20 / FUN_180105c90 / FUN_180057b00 WRITE (push/clear) the season
vector, or only READ it? Confirms the SeasonList deser is the sole populator.
Signature of a writer: assigns plVar[1] (size) or calls a push/grow (FUN_180166e00 /
FUN_180050a00) after the +0x898 getter. A reader only iterates *plVar..plVar[1].
"""
import traceback
try:
for a in (0x18006ac20, 0x180105c90, 0x180057b00):
d = dec(a)
# find the region around the +0x898 call
i = d.find("0x898")
seg = d[max(0,i-200):i+500] if i >= 0 else d[:600]
writes = ("166e00" in d) or ("180050a00" in d) or ("0512f0" in d and "[1] = " in d)
print("=" * 60, hex(a), "LEN", len(d))
print(" push(166e00)?", "180166e00" in d, " copy(50a00)?", "180050a00" in d,
" clear(512f0)?", "1800512f0" in d)
print(seg)
sys.stdout.flush()
os._exit(0)
except Exception:
traceback.print_exc()
sys.stdout.flush()
os._exit(0)
@@ -0,0 +1,64 @@
"""ADVERSARIAL VERIFY Dimension 1: userInfo.feature restriction vocabulary.
Attacks:
D1.1 feature loop recognises EXACTLY one sub-key (trade 0x330), all else value-SKIP.
D1.2 trade uses INT getter FUN_1801c79d0, writes +0xa4 only when ==1.
D1.3 massinfo root FUN_180174630: at END_OBJECT the SOLE `cmp byte[reg+disp],0` site
is +0x17c -> zero [reg+0x50]; nothing else zeroes a settings field from a feature byte.
Control: 0x330 MUST appear in the feature loop and map to +0xa4 (param_1+0x29). If the
massinfo case that calls the feature parser is not +0xd8, the +0x17c arithmetic is wrong.
"""
import traceback
try:
FEAT = 0x18013ec10
MASS = 0x180174630
src = dec(FEAT, 300)
print("=== FEATURE FUN_18013ec10 decompile=%d chars ===" % len(src))
print(src)
# enumerate every integer constant compared in the loop (dispatch forms)
print("\n=== raw instructions in feature parser: CMP/immediates + calls ===")
f = func(FEAT)
it = listing.getInstructions(f.getBody(), True)
cnt = 0
while it.hasNext():
ins = it.next()
m = ins.getMnemonicString()
s = str(ins)
if m in ("CMP", "SUB", "LEA") and ("0x330" in s or "0x11c" in s):
print(" %#x %s" % (ins.getAddress().getOffset(), s))
if m == "CALL":
print(" %#x %s" % (ins.getAddress().getOffset(), s))
cnt += 1
print(" (total insns=%d)" % cnt)
print("\n=== MASSINFO root FUN_180174630: scan for cmp byte[reg+disp],0x0 ===")
fm2 = func(MASS)
it = listing.getInstructions(fm2.getBody(), True)
hits = []
n = 0
prev = []
while it.hasNext():
ins = it.next()
n += 1
m = ins.getMnemonicString()
s = str(ins)
# cmp byte ptr [reg + disp], 0
if m == "CMP" and "byte ptr" in s and s.rstrip().endswith(",0x0"):
hits.append((ins.getAddress().getOffset(), s))
# any MOV of 0 into [reg+0x50]
if m == "MOV" and "dword ptr" in s and "0x50]" in s and s.rstrip().endswith(",0x0"):
print(" ZERO-WRITE %#x %s" % (ins.getAddress().getOffset(), s))
print(" cmp byte[reg+disp],0 sites: %d" % len(hits))
for a, s in hits:
print(" %#x %s" % (a, s))
print(" (massinfo total insns=%d)" % n)
# confirm which case calls the feature parser and at what struct offset
print("\n=== calls to FUN_18013ec10 (feature) from anywhere ===")
for frm, typ, fn, ent in xrefs_to(FEAT):
print(" %#x %s in %s" % (frm, typ, fn))
except Exception:
traceback.print_exc()
@@ -0,0 +1,38 @@
"""ADVERSARIAL VERIFY Dimension 2: gate-byte writers + readers.
Attacks (priority = claims that change what we send):
D2.6 Objectives deser cases 0xfd/0xfe are CLEAR-ONLY (value==0 => field=0, no set).
-> action "DO NOT send enableObjectives:0". If it also SETs, action is wrong.
D2.5 Draft: FUN_1800b2680 reads slot 0x2c8 (0x1fd3d) / 0x2d0 (0x1fd3e) and gates the
tile GOTO_DRAFT_*; the byte ACTUALLY gates (a cmp/test on the model slot result).
D2.3 Seasons: FUN_1800b2680 season tiles drawn UNCONDITIONALLY (no cVar gate).
D2.2 Applier FUN_18011dc50 writes byte = (field==1); one MOV per byte.
D2.1 Publisher FUN_18006cc60 IS_* names.
Control: draft slot 0x2c8 must decode to disp 0x1fd3d via the accessor stub; if not the
whole slot->disp table is unreliable.
"""
import traceback
try:
PUB = 0x18006cc60
DESER = 0x18013c6d0
APPLY = 0x18011dc50
HUB = 0x1800b2680
SEASONPANEL = 0x1800b0e20
print("=== PUBLISHER FUN_18006cc60 ===")
print(dec(PUB, 240))
print("\n=== APPLIER FUN_18011dc50 ===")
print(dec(APPLY, 240))
print("\n=== HUB-TILE BUILDER FUN_1800b2680 ===")
print(dec(HUB, 300))
# deser: only print the arms for the mode atoms we care about
print("\n=== DESER FUN_18013c6d0 (full) ===")
ds = dec(DESER, 300)
print("len=%d" % len(ds))
print(ds)
except Exception:
traceback.print_exc()
@@ -0,0 +1,52 @@
"""ADVERSARIAL verify of Seasons Findings 2 & 4 + getter offsets.
HYPOTHESIS UNDER ATTACK:
F2: model+0x5c68 season vector written ONLY by FUN_1801683f0 (/season deser).
F4: SEASONLIST descriptor @0x1802cb718 and URL 'ut/%s/season' @0x18021e598 have no code xref.
Getters: vtable+0x898 -> lea rax,[rcx+0x5c68]; vtable+0x588 -> lea rax,[rcx+0x7138].
CONTROL: resolve a KNOWN getter/xref form the same way (disp32 immediate scan) and
confirm the scanner actually finds multi-hit patterns (not silently zero).
"""
import traceback
try:
MODEL_VT=0x18021c2a0
# 1. getter slots
for slot in (0x898,0x588,0x988,0x990):
t=qword(MODEL_VT+slot)
print("vtable +%#x -> %#x %s" % (slot,t,fname(t)))
print(" dec head:", " | ".join(dec(t).splitlines()[:6]))
# 2. disp32 immediate scan in .text for 0x5c68 (le 4-byte) and 0x7138
for off_name,val in (("0x5c68",0x5c68),("0x7138",0x7138),("0x1fd3a",0x1fd3a)):
pat=val.to_bytes(4,'little')
hits=find_all(pat, blocks=(".text",))
print("\ndisp32 scan .text for %s (%s): %d hits" % (off_name, pat.hex(), len(hits)))
for h in hits[:12]:
f=func(h); print(" @%#x in %s" % (h, f.getName() if f else '?'))
# 3. call sites of [reg+0x898] -- scan .text for the modrm/disp32 forms of call [r+0x898]
# common encodings: FF 90 98 08 00 00 (call [rax+0x898]); reg varies in modrm middle bits.
print("\n--- call [reg+0x898] sites (FF /2 disp32 = 98 08 00 00) ---")
disp=(0x898).to_bytes(4,'little')
for pat_desc,pat in [("call [rax+d]",b"\xff\x90"+disp),("call [rcx+d]",b"\xff\x91"+disp),
("call [rdx+d]",b"\xff\x92"+disp),("call [rbx+d]",b"\xff\x93"+disp),
("call [rsi+d]",b"\xff\x96"+disp),("call [rdi+d]",b"\xff\x97"+disp),
("call [r8+d]",b"\x41\xff\x90"+disp),("call [r9+d]",b"\x41\xff\x91"+disp),
("call [r10+d]",b"\x41\xff\x92"+disp),("call [r11+d]",b"\x41\xff\x93"+disp)]:
hits=find_all(pat, blocks=(".text",))
for h in hits:
f=func(h); print(" %s @%#x in %s" % (pat_desc,h,f.getName() if f else '?'))
# 4. Finding 4: descriptor + url xrefs
print("\n--- F4: SEASONLIST descriptor / url xrefs ---")
print("xrefs_to(0x1802cb718):", xrefs_to(0x1802cb718))
print("xrefs_to(0x18021e598) url ut/%s/season:", xrefs_to(0x18021e598))
print("string @0x18021e598:", repr(rd_str(0x18021e598)))
# SEASONLIST literal locate
sl=find_all(b"SEASONLIST\x00")
print("SEASONLIST literal at:", [hex(x) for x in sl])
for a in sl:
print(" xrefs_to(%#x):"%a, xrefs_to(a))
# url literal locate
us=find_all(b"ut/%s/season\x00")
print("'ut/%s/season' literal at:", [hex(x) for x in us])
for a in us:
print(" xrefs_to(%#x):"%a, xrefs_to(a))
except Exception:
traceback.print_exc()
@@ -0,0 +1,45 @@
"""Adversarial verify Dimension 4/5 decompile claims.
Hypothesis under attack:
(A) FUN_1800b2680 case 0xc reads slot+0x2c8 -> GOTO_DRAFT_ONLINE/DISABLED;
case 0xd reads slot+0x2d0 AND +0x2c8 -> GOTO_DRAFT_OFFLINE/DISABLED;
cases 5/0xe (tournament) set GOTO_* UNCONDITIONALLY;
objectives block reads slot 0x320 -> GOTO_MANAGER_QUEST(_DISABLED).
(B) settings deser FUN_18013c6d0: 0xf9->[0x17]; 0xfa&0xff->[0x18]; 0xfd&0xfe->[0x1c].
(C) applier FUN_18011dc50: +0x1fd3d=[0x17]==1; +0x1fd3e=[0x18]==1; +0x1fd44=[0x1c]==1.
(D) feature FUN_18013ec10 arm 0x11c recognizes ONLY atom 0x330.
Control: settings 0x336 tradingEnabled -> [10]; applier +0x1fd2e=[10]==1 (known good).
Method: print full decompile length + context around each token so absence claims
are from FULL text, not truncation.
"""
import traceback
def ctx(txt, needles, before=2, after=6):
lines=txt.splitlines()
hits=set()
for i,l in enumerate(lines):
for n in needles:
if n in l:
for j in range(max(0,i-before), min(len(lines),i+after+1)):
hits.add(j)
for j in sorted(hits):
print(" %4d: %s"%(j,lines[j]))
try:
for ea,name,needles in [
(0x1800b2680,"FUN_1800b2680 (hub tile builder)",
["GOTO_DRAFT","GOTO_MANAGER_QUEST","GOTO_OFFLINE_TOURNAMENT","GOTO_ONLINE_CHAMPIONS",
"GOTO_OFFLINE_SEASON","GOTO_ONLINE_SEASON","0x2c8","0x2d0","0x320","0x2b8",
"SBS","GameHub_SBS","0xd0)","GOTO_SBC","GOTO_SQUAD"]),
(0x18013c6d0,"FUN_18013c6d0 (settings deser)",
["0xf9","0xfa","0xff","0xfd","0xfe","0x336","0x17]","0x18]","0x1c]","[10]","param_2[10]"]),
(0x18011dc50,"FUN_18011dc50 (applier)",None),
(0x18013ec10,"FUN_18013ec10 (feature/massinfo)",
["0x11c","0x330","0x336"]),
]:
c=dec(ea)
print("="*70)
print("%s len=%d"%(name,len(c)))
if needles is None:
print(c)
else:
ctx(c,needles)
except Exception:
traceback.print_exc()
@@ -0,0 +1,33 @@
"""D3 Q1/Q4: full decompile of the TO_TRADE_PILE predicate FUN_1801a7260 and its
publisher FUN_18003e370 / filler FUN_1800e2a40.
HYPOTHESIS: FUN_1801a7260 has MORE than the two documented terms (service gate,
item+0x49). Specifically it may consult the pile discriminator item+0x60 (live:
1 for /club, 6 for /purchased) or a pile/state field, which would make the
PURCHASED pile the reason the menu is greyed.
CONTROL: FUN_18003e550 (the listing panel publisher, DURATION/START_PRICE/
ASKING_PRICE) is decompiled in the same batch -- a function known to exist and to
be reachable, so a successful decompile there proves the decompiler is working
and a failure on the target is a real failure, not a harness problem.
Every decompile prints len(src) and is printed IN FULL (absence trap rule).
"""
import traceback
try:
TARGETS = [
("FUN_1801a7260 TO_TRADE_PILE predicate", 0x1801a7260),
("FUN_18003e370 eight-flag publisher", 0x18003e370),
("FUN_1800e2a40 flag filler", 0x1800e2a40),
("FUN_18003e550 CONTROL listing panel publisher", 0x18003e550),
]
for label, a in TARGETS:
src = dec(a)
print("=" * 78)
print("### %s @ %#x len(src)=%d" % (label, a, len(src)))
print("=" * 78)
print(src)
print()
except Exception:
traceback.print_exc()

Some files were not shown because too many files have changed in this diff Show More