wip: checkpoint FIFA 17 SBC research for Windows migration

This commit is contained in:
funman300
2026-08-07 12:03:22 -07:00
parent 3d3239bab9
commit cc694774a3
47 changed files with 9105 additions and 4 deletions
Generated
+6480
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",
]
+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."
}
}
@@ -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,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,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,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,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()
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""SBC menu render probe + minimal gate-arm poke (FIFA 17 CardsDLL).
WHAT THIS DOES
--------------
READ-ONLY BY DEFAULT. With no flags it opens /proc/<pid>/mem O_RDONLY, proves the
CardsDLL slide against the on-disk FNV prologue, and reports the exact live state of
the SBC data flow so the human can see whether a poke would render anything:
A = FUT root singleton = *(0x1802e6398) (vtable static 0x18021c2a0)
B = SBC request/TTL cache = A + 0x1f9d8 (vtable static 0x1801fae70)
B+0x08 collection ptr, B+0x20 QPC deadline, B+0x28 READY byte (the gate)
B offset 0x1f9d8 is DECODED live from A.vtable[+0x4e8] thunk
(48 8d 81 <disp32> = lea rax,[rcx+disp32]), not taken on faith.
M = SBC categories store = *(A + 0x20a68) (THE RENDER SOURCE)
lazy getter A.vtable[+0x9b0] = 0x18011b7d0; category count = WORD[M+0x50];
category vector M+0x58..M+0x60 (stride 0xf0). The SBC menu draws
WORD[M+0x50] + 2 tiles. M is NULL until the menu is opened (lazily built,
empty offline) or the sbs/sets response is parsed.
HUB = sibling cache = A + 0x1fd70
SBC req-mgr = A + 0x2a0
THE VERIFIED GATE (proven byte-exact against the shipped DLL, isValid 0x180065d40):
call 0x1801642c0 ; online sub-check -- STUBBED `mov al,1; ret`, not the wall
cmp BYTE[rbx+0x28],0 ; je fail ; <-- the READY gate
cmp QWORD[rbx+0x8],0 ; je RET_1 ; <-- SHORT-CIRCUIT: coll==0 => return 1
<QPC deadline compare> ; only reached when B+0x08 != 0
So isValid returns TRUE with B+0x28=1 AND B+0x08=0 (short-circuit). Writing B+0x08
forces the deadline branch; with a stale/past B+0x20 that returns 0 -> the error
modal. That is why this tool NEVER writes B+0x08 or B+0x20 -- doing so can DEFEAT
the fix and is a crash risk if the pointer is not a real EASTL collection.
THE INTERVENTION THIS TOOL CAN APPLY (--apply)
----------------------------------------------
The ONLY write blessed by adversarial verification as non-crashing from a bare
/proc/mem poke is:
BYTE[B+0x28] = 1 (arm the SBC ready gate; leave B+0x08 and B+0x20 alone)
This OPENS the SBC menu (isValid short-circuits to true) instead of the error modal.
It renders EMPTY (2 placeholder tiles) unless M is populated, because tiles come from
WORD[M+0x50], not from B. It is the proven-safe NEGATIVE CONTROL / gate-open step.
WHY A POPULATED MENU NEEDS THE INJECTED DLL, NOT THIS TOOL
----------------------------------------------------------
Populating M means running the client's OWN parser (deser 0x18017b2b0) over a real
sbs/sets response, so it clears+builds M with the correct 0xf0/0x3570 geometry and
rebuilds the indices. That requires executing code IN-PROCESS (the openfut-hook DLL)
or serving GET ut/game/fifa17/sbs/sets through the bridge so the native completion
path populates M and arms B for you. A /proc/mem byte poke cannot build M's nested
EASTL vectors safely (hand-building 0xf0/0x3570 records is the highest-crash option
all three verifiers rejected), and it cannot call the deser with a seated SAX cursor.
Cold-calling the deser with a null cursor WIPES M (clear runs before append) and
parses nothing. So: this tool arms the gate; the DLL (spec printed by --spec) does
the populate. See docs and the openfut-hook integration notes.
RISK / SAFETY
-------------
* Default run = READ ONLY. Nothing here writes unless you pass --apply.
* --apply WRITES LIVE GAME MEMORY (/proc/<pid>/mem O_WRONLY): one byte, B+0x28=1.
Do this only on a client sitting in the FUT hub, ideally with the SBC menu CLOSED
(never mutate while the menu is mid-iterate). Then re-open the SBC menu to render.
* --apply re-proves the slide AND re-verifies B.vtable == static 0x1801fae70 before
writing, and aborts on any mismatch. It refuses to write anything but B+0x28.
* If FIFA17.exe is not running or CardsDLL is not mapped, the tool says so and exits
0 -- static analysis is authoritative; live steps are best-effort.
USAGE
python3 sbc_hook_poke.py # read-only probe + dry-run plan (default)
python3 sbc_hook_poke.py --spec # also print the injected-DLL populate spec
python3 sbc_hook_poke.py --apply # WRITE BYTE[B+0x28]=1 (arm gate) -- HUMAN ONLY
"""
import os, struct, sys
# ---- static VAs (image base 0x180000000; add live slide) --------------------
A_SINGLETON = 0x1802e6398 # slot holding A = FUT root singleton ptr
CTRL_VA = 0x180180d00 # FNV atom-hash prologue used to prove the slide
A_VT_STATIC = 0x18021c2a0 # A.vtable (verify live == this + slide)
B_VT_STATIC = 0x1801fae70 # B.vtable (verify live == this + slide)
A_VT_BGETTER = 0x4e8 # A.vtable slot -> thunk lea rax,[rcx+0x1f9d8]
A_VT_MGETTER = 0x9b0 # A.vtable slot -> M lazy getter 0x18011b7d0
M_CACHE_OFF = 0x20a68 # M cache slot on A (decoded from getter cmp)
HUB_OFF = 0x1fd70
REQMGR_OFF = 0x2a0
ISVALID_VA = 0x180065d40
ONLINE_STUB = 0x1801642c0 # expect b0 01 c3 (mov al,1; ret)
B_READY_OFF = 0x28
PE_PATHS = ['/tmp/fut/cardsdll.dll', '/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll']
def find_pid():
for d in os.listdir('/proc'):
if d.isdigit():
try:
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
return int(d)
except Exception:
pass
return None
def load_pe():
for p in PE_PATHS:
try:
return open(p, 'rb').read()
except Exception:
continue
return None
def print_spec():
print("""
== INJECTED-DLL POPULATE SPEC (openfut-hook / version.dll) ===================
The poke tool arms the gate; the DLL must POPULATE M. Preferred, lowest-risk,
zero-forged-state path (run ON THE GAME MAIN/UI THREAD, SBC menu CLOSED):
Option 1 (best) -- serve the response, let the native chain do everything:
Route GET ut/game/fifa17/sbs/sets through the bridge/core with real JSON.
The client's own dispatcher builds the response-msg (ctor 0x18017b1c0,
deser slot +0x20 = 0x18017b2b0), seats a genuine SAX cursor, and its own
chain populates M and arms B via the completion callback 0x1800b8c30
(subscribed in svc ctor 0x1800b5765). No memory forging at all. NOTE: the
front-end refuses to ISSUE the fetch offline and the "ut/%s/sbs" template
(0x18021d908) has no native xref, so the DLL must inject the RESPONSE at the
message-receive layer (not rely on the client to send the GET).
Option 2 (fallback) -- drive the real parser from the hook:
1. reg = 0x1800d7170() ; -> &registry 0x1802c2988
2. mgr = 0x180009c80(&out, reg) ; hashes 0xed84b11/0xed84b12
3. build a REAL seated SAX cursor over canned sbs/sets JSON:
ctx = 0x1801c63e0(...) + lexer 0x1801c8060 + an input-source object
whose vtable[+0x8] yields your JSON bytes. A null-source cursor parses
nothing AND the deser clears M first -> do not cold-call with null.
4. 0x18017b2b0(rcx=ignored, rdx=cursor) ; self-locates mgr, clears M,
per-cat ctor 0x180159da0 / cat-deser 0x18017ab80 / finalize 0x180160e50 /
append 0x18015a770, then store finalizers 0x180160e00 + 0x180160f30 +
0x180161020, then commit mgr.vtable[+0x8]. Sets WORD[M+0x50]=N.
5. arm gate: A.vtable[+0x4e8](A) -> B; set ONLY BYTE[B+0x28]=1.
Do NOT write B+0x08 or B+0x20 (short-circuit; see isValid proof).
6. trigger render: re-open the SBC menu, or fire refresh events 0x756c-0x7574
so the controller re-reads WORD[M+0x50] at 0x1800b5eda.
DO NOT: hand-build 0xf0 category / 0x3570 set records for a direct append
(deep-copy ctor 0x18015a2b0 derefs inner EASTL sub-vectors -> heap corruption);
skip the index-rebuild finalizers (by-index getter 0x180160a80 reads OOB);
mutate M while the menu iterates; or run any of this off the main thread.
=============================================================================
""")
def main():
apply = '--apply' in sys.argv
if '--spec' in sys.argv:
print_spec()
pid = find_pid()
if not pid:
print("FIFA17.exe not running -> skipping live steps. Static analysis is "
"authoritative; no write possible. (see --spec for the DLL plan)")
return 0
print("pid %d" % pid)
base = None
for ln in open('/proc/%d/maps' % pid):
if 'CardsDLL' in ln:
base = int(ln.split('-')[0], 16)
break
if not base:
print("CardsDLL not mapped (client not in Ultimate Team yet). Skip live step.")
return 0
slide = base - 0x180000000
print("base %#x slide %#x" % (base, slide))
fdr = os.open('/proc/%d/mem' % pid, os.O_RDONLY)
rd = lambda va, n: os.pread(fdr, n, va)
q = lambda va: struct.unpack('<Q', rd(va, 8))[0]
w = lambda va: struct.unpack('<H', rd(va, 2))[0]
# ---- prove the slide against the on-disk FNV prologue --------------------
pe = load_pe()
if pe is None:
print("on-disk DLL not found (%s); cannot prove slide -> refuse." % PE_PATHS)
os.close(fdr); return 1
f = lambda va: va - 0x180000000 - 0x1000 + 0x400 # .text rva 0x1000 rawptr 0x400
ctl_ok = pe[f(CTRL_VA):f(CTRL_VA)+24] == rd(CTRL_VA + slide, 24)
print("CONTROL FNV %s" % ("MATCH" if ctl_ok else "MISMATCH -> ABORT"))
if not ctl_ok:
os.close(fdr); return 1
# ---- prove the two gate facts from on-disk bytes ------------------------
online_stub = pe[f(ONLINE_STUB):f(ONLINE_STUB)+3]
print("online sub-check 0x1801642c0 on-disk = %s %s"
% (online_stub.hex(), "(stubbed mov al,1;ret -- NOT the wall)"
if online_stub == b'\xb0\x01\xc3' else "(UNEXPECTED)"))
# ---- A root + vtable ----------------------------------------------------
A = q(A_SINGLETON + slide)
A_vt = q(A) - slide
print("A(FUT root) = %#x A.vtable %#x %s"
% (A, A_vt, "(match)" if A_vt == A_VT_STATIC else "(MISMATCH static %#x)" % A_VT_STATIC))
# ---- decode B offset live from A.vtable[+0x4e8] thunk -------------------
bthunk = q((A_vt + slide) + A_VT_BGETTER) # A.vtable slot -> thunk VA (live)
stub = rd(bthunk, 7)
b_off = None
if stub[:3] == b'\x48\x8d\x81': # lea rax,[rcx+disp32]
b_off = struct.unpack('<i', stub[3:7])[0]
print("A.vtable[+0x4e8] -> %#x stub=%s decoded B offset=%s"
% (bthunk - slide, stub.hex(),
hex(b_off) if b_off is not None else "?? (expected 0x1f9d8)"))
if b_off is None:
b_off = 0x1f9d8 # fall back to the model constant, but we warned above
B = A + b_off
# ---- B cache fields -----------------------------------------------------
def show_cache(name, C, expect_vt=None):
vt = q(C) - slide
coll = q(C + 0x08); dl = q(C + 0x20); ready = rd(C + B_READY_OFF, 1)[0]
tag = ""
if expect_vt is not None:
tag = "(match)" if vt == expect_vt else "(MISMATCH static %#x)" % expect_vt
print(" %-4s @%#x vt=%#x %s coll(+8)=%#x deadline(+0x20)=%#x ready(+0x28)=%d"
% (name, C, vt, tag, coll, dl, ready))
return vt, coll, dl, ready
print("live cache state:")
b_vt, b_coll, b_dl, b_ready = show_cache("SBC", B, B_VT_STATIC)
show_cache("HUB", A + HUB_OFF)
print(" reqmgr @%#x +0x08=%#x" % (A + REQMGR_OFF, q(A + REQMGR_OFF + 0x08)))
# ---- M = the render source ---------------------------------------------
M = q(A + M_CACHE_OFF)
if M == 0:
print(" M (render source, *(A+0x20a68)) = 0 -> NOT built yet "
"(SBC menu not opened this session). Empty offline.")
cat_count = 0
else:
cat_count = w(M + 0x50)
print(" M (render source) = %#x WORD[M+0x50] category count = %d "
"(menu would draw %d tiles)" % (M, cat_count, cat_count + 2))
# ---- the plan / dry-run -------------------------------------------------
print("\n-- INTERVENTION PLAN --")
print(" Verified-safe write (this tool, --apply): BYTE @ %#x (B+0x28) = 1"
% (B + B_READY_OFF))
print(" effect: isValid short-circuits TRUE -> SBC menu OPENS instead of modal.")
print(" render: EMPTY unless M is populated (tiles = WORD[M+0x50], not B).")
print(" REFUSED here (footgun): writing B+0x08 or B+0x20 -> deadline branch,")
print(" can return FALSE (modal) and/or crash on a bogus collection ptr.")
print(" Populated render: needs the injected DLL to fill M (run with --spec).")
if not apply:
cur = rd(B + B_READY_OFF, 1)[0]
print("\n[DRY-RUN] default mode -- no memory written. current BYTE[%#x]=%d, "
"would set =1. Pass --apply to write (HUMAN ONLY)."
% (B + B_READY_OFF, cur))
os.close(fdr)
return 0
# ---- --apply: the single blessed byte write -----------------------------
# re-verify EVERYTHING load-bearing before touching live memory.
if not ctl_ok or A_vt != A_VT_STATIC or b_vt != B_VT_STATIC:
print("\n[ABORT] slide/vtable sanity failed at write time -> refusing to write.")
os.close(fdr); return 1
if b_off != 0x1f9d8:
print("\n[ABORT] B offset decoded as %s (expected 0x1f9d8) -> refusing to write."
% hex(b_off))
os.close(fdr); return 1
target = B + B_READY_OFF
before = rd(target, 1)[0]
print("\n[APPLY] target BYTE @ %#x before=%d" % (target, before))
if before == 1:
print("[APPLY] already 1 -> nothing to do (idempotent).")
os.close(fdr); return 0
fdw = os.open('/proc/%d/mem' % pid, os.O_WRONLY)
n = os.pwrite(fdw, b'\x01', target)
os.close(fdw)
after = rd(target, 1)[0]
print("[APPLY] wrote %d byte(s); read-back BYTE @ %#x = %d %s"
% (n, target, after, "(OK)" if after == 1 else "(WRITE FAILED)"))
print("[APPLY] now RE-OPEN the SBC menu. Expect: menu opens (no modal); tiles will")
print(" be EMPTY/placeholder unless M was populated by the DLL first.")
os.close(fdr)
return 0
if __name__ == '__main__':
sys.exit(main())
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""SBC cache populate/arm probe + poke (FIFA 17 CardsDLL).
READ-ONLY BY DEFAULT. The write path exists for the human's morning test but is
NEVER reached unless you pass --arm AND --i-mean-it. Running with no args only
READS /proc/<pid>/mem (O_RDONLY) and prints what a poke WOULD do.
Object graph (all static VAs, image base 0x180000000; add the live slide):
A = FUT root singleton = *(0x1802e6398) (getter 0x18011a830)
B = SBC TTL cache = A + 0x1f9d8 (vtable 0x1801fae70)
B+0x08 collection ptr, B+0x20 QPC deadline, B+0x28 ready byte
isValid = 0x180065d40 (B.vtable[+0x08]); clear = 0x180065d20 (B.vtable[+0x10])
HUB TTL cache = A + 0x1fd70 (same class, armed online)
SBC set-data req mgr = A + 0x2a0 (vtable 0x18022be90, ctor 0x18016fac0)
Populate path (normal, online):
fetch sbs/sets -> req mgr A+0x2a0 -> response obj (factory 0x18016fca0, vt 0x18022be80)
-> SAX drive 0x18016c330 -> top deser 0x18017b2b0
(which does service.[+0x9b0] to get the SBC manager, then)
category deser 0x18017ab80 / set deser 0x18017ad60
-> finalizers 0x180160e00 / 0x180160e50 / 0x180161020 build the SBC manager's
category array (stride 0xf0) + set array (stride 0x3570); select/rebuild 0x180160b80
-> generic cache commit copy-assigns a stack temp {collection, deadline, ready=1}
into B (assign 0x1800c21a0), arming B+0x28 and pointing B+0x08 at the manager data.
The UI renders by polling isValid(B) each frame and iterating *(B+0x08). Arming
B+0x28 alone (see --arm-flag-only) opens the menu but draws EMPTY (collection NULL).
A populated render needs *(B+0x08) to point at a real set/category collection.
"""
import os, struct, sys
SLIDE_KNOWN = 0x6ffe7c140000 # informational; actual slide is read from maps
A_SINGLETON = 0x1802e6398
B_OFF = 0x1f9d8
HUB_OFF = 0x1fd70
REQMGR_OFF = 0x2a0
CTRL_VA = 0x180180d00
def find_pid():
for d in os.listdir('/proc'):
if d.isdigit():
try:
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
return int(d)
except Exception:
pass
return None
def main():
arm = '--arm' in sys.argv
flag_only = '--arm-flag-only' in sys.argv
confirm = '--i-mean-it' in sys.argv
pid = find_pid()
if not pid:
print("FIFA17.exe not running -> nothing to read. (static analysis is authoritative)")
return
base = None
for ln in open('/proc/%d/maps' % pid):
if 'CardsDLL' in ln:
base = int(ln.split('-')[0], 16); break
if not base:
print("CardsDLL not mapped yet (client not in Ultimate Team). Skip live step.")
return
slide = base - 0x180000000
fdr = os.open('/proc/%d/mem' % pid, os.O_RDONLY)
rd = lambda va, n: os.pread(fdr, n, va)
q = lambda va: struct.unpack('<Q', rd(va, 8))[0]
# prove slide against on-disk FNV prologue
pe = open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll', 'rb').read()
f = lambda va: va - 0x180000000 - 0x1000 + 0x400
ok = pe[f(CTRL_VA):f(CTRL_VA)+24] == rd(CTRL_VA + slide, 24)
print("pid %d base %#x slide %#x CONTROL %s" % (pid, base, slide, "OK" if ok else "MISMATCH-ABORT"))
if not ok:
os.close(fdr); return
A = q(A_SINGLETON + slide)
B = A + B_OFF
HUB = A + HUB_OFF
print("A(FUT root)=%#x B(SBC cache)=%#x HUB=%#x reqmgr=%#x" % (A, B, HUB, A + REQMGR_OFF))
def show(name, C):
coll = q(C + 0x08); dl = q(C + 0x20); ready = rd(C + 0x28, 1)[0]
vt = q(C) - slide
print(" %-4s vt=%#x coll(+8)=%#x deadline(+0x20)=%#x ready(+0x28)=%d"
% (name, vt, coll, dl, ready))
return coll, dl, ready
print("live cache state:")
show("SBC", B); show("HUB", HUB)
# what a poke WOULD do
print("\n-- INTERVENTION PLAN (dry-run) --")
print(" [flag-only] write BYTE @ %#x = 1 (opens menu, EMPTY render)" % (B + 0x28))
print(" [real fix] preferred = force the client to issue sbs/sets so its own")
print(" parser populates the SBC manager and commits B. The offline")
print(" block is the FUT front-end refusing to call the native fetch;")
print(" route the issued GET ut/game/fifa17/sbs/sets through the bridge.")
if flag_only and arm and confirm:
# guarded, explicit, single-byte only
fdw = os.open('/proc/%d/mem' % pid, os.O_WRONLY)
os.pwrite(fdw, b'\x01', B + 0x28)
os.close(fdw)
print("\n[WROTE] BYTE @ %#x = 1 (flag-only). Expect menu opens, likely empty." % (B + 0x28))
elif arm:
print("\n[SAFE] --arm given but not both --arm-flag-only and --i-mean-it; no write performed.")
os.close(fdr)
if __name__ == '__main__':
main()
+251
View File
@@ -1145,6 +1145,20 @@ ROUTES = [
(re.compile(G + r"/tournament"), lambda m, h: (200, tournament_list() if (_MODES and h.command == "GET") else {})),
(re.compile(G + r"/leaderboards"), lambda m, h: leaderboard_route(h) if _MODES else (200, {})),
(re.compile(G + r"/champion"), lambda m, h: champion_route(h) if _MODES else (200, {})),
# ---- Squad Building Challenges (SBC). Path family is `sbs/*` (not `sbc`).
# Baseline had NO sbs routes -> GET sbs/sets fell through to the catch-all {} and
# the client threw "problem communicating with the FUT servers" (empty set list,
# not a parse fault: the response root is object + skip-tolerant, so {} parses but
# carries no data). See sbc_sets_route for the reversed shape and the freeze rules.
# Order matters (first-match-wins, rx.search is unanchored): the more specific
# sbs paths MUST precede the generic /sbs/sets below, which would otherwise
# substring-match /sbs/sets/tag. squad save/load precedes start/submit; start vs
# submit split by body/method inside sbc_challenge_route.
(re.compile(G + r"/sbs/sets/tag/?$"), lambda m, h: sbc_tag_route(h) if _SBC else (200, {})),
(re.compile(G + r"/sbs/setId/\d+/challenges"), lambda m, h: sbc_challenges_route(h) if _SBC else (200, {})),
(re.compile(G + r"/sbs/challenge/\d+/squad/?$"), lambda m, h: sbc_squad_route(h) if _SBC else (200, {})),
(re.compile(G + r"/sbs/challenge/\d+"), lambda m, h: sbc_challenge_route(h) if _SBC else (200, {})),
(re.compile(G + r"/sbs/sets"), lambda m, h: sbc_sets_route(h) if _SBC else (200, {})),
# FutGetCaptcha 0x18014e78d: encodedImg(str b64) sequence(int) sizeBeforeEncode(int).
# Served always -- an empty captcha is strictly better than the catch-all {},
# and all three fields are scalars (no freeze risk).
@@ -2238,6 +2252,243 @@ def leaderboard_route(h):
return 200, {"entries": []}
# ---- Squad Building Challenges (SBC) -----------------------------------------
# GET ut/%s/sbs/sets is the FIRST call the SBC hub makes. Two response classes both
# bind to sbs/sets:
# FutSBCSetDataServerResponse deser 0x18016fe90 -- OBJECT root, parses ONLY
# `reset`(0x283 bool); every other key is
# value-SKIP'd. Carries NO set list. This is
# why {} produced "problem communicating": the
# body parsed fine but held no data.
# FutSBCLoadCategoryDetailsServerResponse deser 0x18017ac08 -- OBJECT root, parses
# categoryId(0x73 int), name(0x1d0 str),
# priority(0x250 int), sets(0x2be ARRAY). Each
# set element (parser 0x18017ad60) reads
# categoryId(0x73), name(0x1d0), description
# (0xd1), priority(0x250), challengesCount
# (0x78), challengesCompletedCount(0x77),
# awards(0x47 ARRAY). This is the one that
# renders the list.
# Both parsers are object-root and skip-tolerant, so a MERGED body satisfies whichever
# class the client instantiates for a given sbs/sets request with zero freeze risk.
# FREEZE-CRITICAL: `sets` and every element's `awards` MUST be JSON arrays if present
# (a scalar/object there desyncs the array reader into the 0x1801c7f1a busy loop).
# Omitting them defaults to empty (safe). Reversed 2026-08-06 from the on-disk
# CardsDLL (control-checked against FutLoadSetChallengesResponse 0x18017bbbb).
#
# DEFAULT ON: the current behaviour is a hard "problem communicating" modal, so there
# is no working state to protect. FUT_SBC=0 restores the catch-all {}.
_SBC = os.environ.get("FUT_SBC", "1") == "1"
# Moddable SBC content. Each category holds its sets; each set optionally holds a
# `challenges` list served by the sbs/setId/{id}/challenges route (that key is an
# internal detail, stripped before it goes on the sbs/sets wire). Keep `sets`,
# `awards`, and each set's `challenges` as lists. setId ties a set to its challenge
# records so the two calls stay consistent.
SBC_CATEGORIES = [
{
"categoryId": 1, "name": "Foundations", "priority": 1,
"sets": [
{"setId": 1, "categoryId": 1, "name": "Bronze Challenge",
"description": "Submit an 11-player squad.", "priority": 1,
"challengesCount": 1, "challengesCompletedCount": 0, "awards": [],
"hidden": False, "endTime": 4102444800,
"challenges": [
{"challengeId": 101, "name": "League Basics",
"description": "Submit an 11-player squad.",
"formation": "f442", "type": "OPEN_CHALLENGE", "status": "OPEN"},
]},
{"setId": 2, "categoryId": 1, "name": "Simple Start",
"description": "Get started with your first SBC.", "priority": 2,
"challengesCount": 1, "challengesCompletedCount": 0, "awards": [],
"hidden": False, "endTime": 4102444800,
"challenges": [
{"challengeId": 201, "name": "First Steps",
"description": "Get started with your first SBC.",
"formation": "f442", "type": "OPEN_CHALLENGE", "status": "OPEN"},
]},
],
},
]
# Non-container / non-key scalars every challenge record carries, with freeze-safe
# defaults. FutLoadSetChallengesResponse per-record parser 0x18017bb50: ints
# challengeId/setId/categoryId/index/endTime/trophyId/timesCompleted; strings
# type/name/description/challengeImageId/status/formation (formation MUST be a string
# -- int desyncs the string mapper 0x180166590); bool repeatable; ARRAYS awards +
# elgReq (a scalar there hits the type-desync busy-loop 0x1801c7f1a).
_SBC_CHALLENGE_DEFAULTS = {
"categoryId": 0, "index": 0, "type": "OPEN_CHALLENGE",
"name": "SBC Challenge", "description": "Submit a squad.",
"challengeImageId": "", "formation": "f442", "endTime": 0,
"repeatable": False, "trophyId": 0, "status": "OPEN",
"timesCompleted": 0, "awards": [], "elgReq": [],
}
def _sbc_sets_payload():
"""Categories for FutSBCLoadCategoryDetailsServerResponse, internal keys removed.
Strips the internal `challenges` list from each set: the sbs/sets set-element
parser 0x18017ad60 only reads categoryId/name/description/priority/
challengesCount/challengesCompletedCount/awards, and we keep the wire minimal
and identical to the proven success body rather than lean on value-SKIP."""
cats = []
for cat in SBC_CATEGORIES:
sets = [{k: v for k, v in s.items() if k != "challenges"}
for s in cat.get("sets", [])]
c = {k: v for k, v in cat.items() if k != "sets"}
c["sets"] = sets
cats.append(c)
return cats
def _sbc_find_set(set_id):
"""Return (category, set) for setId, or (None, None) -- keeps setIds consistent
between sbs/sets and sbs/setId/{id}/challenges."""
for cat in SBC_CATEGORIES:
for s in cat.get("sets", []):
if s.get("setId") == set_id:
return cat, s
return None, None
def _sbc_challenge_record(set_id, cat, s, idx, ch):
"""Build one freeze-safe FutLoadSetChallengesResponse record. awards/elgReq are
forced to lists; overrides from the moddable `challenges` entry win over the
defaults, but only for the recognised scalar/array fields."""
rec = dict(_SBC_CHALLENGE_DEFAULTS)
rec["challengeId"] = ch.get("challengeId", set_id * 100 + idx + 1)
rec["setId"] = set_id
rec["categoryId"] = (cat or {}).get("categoryId", rec["categoryId"])
rec["index"] = idx
for k, v in ch.items():
if k in _SBC_CHALLENGE_DEFAULTS:
rec[k] = v
rec["awards"] = list(rec.get("awards") or [])
rec["elgReq"] = list(rec.get("elgReq") or [])
return rec
def sbc_sets_route(h):
"""GET ut/%s/sbs/sets -- the SBC category/set list.
Deser: FutSBCLoadCategoryDetailsServerResponse body 0x18017b2b0, dispatch
0x18017b64c. The root is a JSON OBJECT (END_OBJECT cmp eax,0xa @0x18017b66a);
the SOLE recognised top-level key is atom 0x6f = "categories", read as an ARRAY
(discriminator 0x18017b697; END_ARRAY cmp eax,0xd @0x18017b6b7). GATE (by
elimination, no scalar/status/reset is parsed at top level): the categories
array must be NON-EMPTY, else the client shows the "problem communicating" modal
(no freeze -- object-root accepts {} cleanly). Earlier single-object /
merged-with-reset bodies failed because every top-level key other than
"categories" is value-SKIP'd -> empty categories -> the modal.
FREEZE-CRITICAL: categories[], each category's sets[], and each set's awards[]
MUST be JSON arrays; each category is an object. Reversed from CardsDLL."""
if h.command != "GET":
return 200, {}
cats = _sbc_sets_payload()
log(" SBC: sbs/sets -> %d categor(y/ies), %d set(s) total"
% (len(cats), sum(len(c.get("sets", [])) for c in cats)))
return 200, {"categories": cats}
def sbc_challenges_route(h):
"""GET ut/%s/sbs/setId/{setId}/challenges -- FutLoadSetChallengesResponse.
Top-level deser 0x18017c4e0 is OBJECT-root (END_OBJECT cmp eax,0xa @0x18017c8a7);
the SOLE recognised key is atom 0x76 = "challenges", read as an ARRAY (END_ARRAY
cmp eax,0xd @0x18017c8e7; per-record deser 0x18017bb50). Return an OBJECT
{"challenges":[...]}, never a bare array (that would desync the object-root).
Records are wired to the set's own challenges (or synthesised to challengesCount)
so challengeIds/setIds stay consistent with sbs/sets.
FREEZE-CRITICAL: challenges[], and per-record awards[] (0x47) + elgReq[] (0xf7)
are arrays; formation is a string."""
m = re.search(r"/setId/(\d+)/challenges", h.path)
set_id = int(m.group(1)) if m else 0
cat, s = _sbc_find_set(set_id)
records = []
if s is not None:
chs = s.get("challenges")
if not chs:
chs = [{} for _ in range(max(1, int(s.get("challengesCount", 1))))]
for idx, ch in enumerate(chs):
records.append(_sbc_challenge_record(set_id, cat, s, idx, ch))
log(" SBC: challenges setId=%d -> %d challenge(s)" % (set_id, len(records)))
return 200, {"challenges": records}
def sbc_start_route(h):
"""POST ut/%s/sbs/challenge/{id} with EMPTY body -- START a challenge ->
FutSBCStartChallengeResponse (deser 0x180155949, OBJECT-root; {} freeze-safe).
FREEZE-CRITICAL: "squad" (atom 0x2cd) MUST be a JSON OBJECT -- its value is
delegated to the OBJECT-root squad deser 0x18013d1f0; an array there desyncs.
"playerRequirements" (0x237) MUST be a JSON ARRAY (END_ARRAY cmp eax,0xd
@0x1801559e7). No non-empty gate; empty {} / [] are accepted."""
m = re.search(r"/challenge/(\d+)", h.path)
cid = int(m.group(1)) if m else 0
log(" SBC: start challenge id=%d" % cid)
return 200, {"challengeId": cid, "squad": {}, "playerRequirements": []}
def sbc_submit_route(h):
"""POST/PUT ut/%s/sbs/challenge/{id} with a BODY -- SUBMIT a challenge ->
FutSBCSubmitChallengeServerResponse (deser 0x180161b00, dispatch 0x180161bda,
OBJECT-root; {} freeze-safe).
FREEZE-CRITICAL: grantedChallengeAwards (atom 0x14a) and grantedSetAwards
(atom 0x14b) MUST each be a JSON ARRAY (END_ARRAY cmp eax,0xd), never a
scalar/object; empty [] is safe. Scalars challengeId/setId/credits/
preOrderPacks/recoveredPacks are ints."""
m = re.search(r"/challenge/(\d+)", h.path)
cid = int(m.group(1)) if m else 0
log(" SBC: submit challenge id=%d" % cid)
return 200, {"challengeId": cid, "setId": 0, "credits": 0,
"preOrderPacks": 0, "recoveredPacks": 0,
"grantedChallengeAwards": [], "grantedSetAwards": []}
def sbc_challenge_route(h):
"""Dispatch the shared path ut/%s/sbs/challenge/{id}: START vs SUBMIT.
Both builders format the identical "/challenge/%d" (str 0x180227300). The proven
discriminator is the REQUEST BODY -- START emits an empty body (POST), SUBMIT a
populated JSON body (PUT/POST). We route to SUBMIT on a PUT or any non-empty
body, else START (covers both the body-presence and method discriminators)."""
if h.command == "PUT" or getattr(h, "_body", b""):
return sbc_submit_route(h)
return sbc_start_route(h)
def sbc_squad_route(h):
"""ut/%s/sbs/challenge/{id}/squad -- method-discriminated save/load.
PUT -> FutSBCSaveSquadChallengeServerResponse (deser 0x18017cff0; parses only
id 0x15c -> return {"id":<id>}). GET -> FutLoadSetTypesServerResponse (deser
0x180154990; reads squad 0x2cd + playerRequirements 0x237). Both roots are JSON
objects. NOTE: unlike START, here "squad" (0x2cd) is parsed as an ARRAY (callback
0x18013d1f0, state [rbx+0xb8]=3), and "playerRequirements" (0x237) is an ARRAY
(END_ARRAY cmp eax,0xd @0x180154e07). The unified body is freeze-safe under both
verbs: PUT reads id and value-SKIPs the arrays; GET reads the arrays and
value-SKIPs id."""
m = re.search(r"/challenge/(\d+)/squad", h.path)
cid = int(m.group(1)) if m else 0
if h.command == "PUT":
log(" SBC: save squad challenge id=%d" % cid)
return 200, {"id": cid}
log(" SBC: load squad challenge id=%d" % cid)
return 200, {"id": cid, "squad": [], "playerRequirements": []}
def sbc_tag_route(h):
"""POST/PUT ut/%s/sbs/sets/tag -- FutSBCTagSetsServerResponse (deser 0x1801542f0,
OBJECT-root, parses NO fields: every key value-SKIP'd, always returns success).
Pure ack. The one hard rule is the container: the root MUST be a JSON object --
{} is fully accepted; an array/scalar root would busy-loop at 0x1801c7f1a."""
log(" SBC: sets/tag ack")
return 200, {}
# ---- Draft current state -----------------------------------------------------
# GET ut/%s/squad/mode/draft/state?mode=ONLINE|SINGLE_PLAYER
# -> FutGetDraftCurrentStateServerResponse, deser 0x180147070.