Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75148fe435 | |||
| 6b8b8e052f | |||
| 3153a93edf | |||
| f64106ed8b | |||
| 9faaf12dd7 | |||
| 83539e33ec | |||
| 695421cfd4 | |||
| 8cba70dc90 | |||
| 28773e7cf1 | |||
| 3ae5587a38 | |||
| 70a64e3709 | |||
| 622a774f6a | |||
| cc694774a3 | |||
| 3d3239bab9 | |||
| a7e3e43ae9 | |||
| 31fc590b99 | |||
| 245c22161b | |||
| 43557989f5 | |||
| a3fd51692f | |||
| e578443d73 | |||
| e3092ca0f9 | |||
| 21a81ad63c | |||
| 3f3d5704a7 | |||
| 89da7b7609 | |||
| 1605e6effd | |||
| afdbb364ca | |||
| d0dbfa99c0 | |||
| 897259c8fb |
@@ -27,3 +27,12 @@ __pycache__/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Frozen baseline archives / inspects / manifests
|
||||
/docker-backups/
|
||||
|
||||
# local dev screenshots (not versioned)
|
||||
fifa17-recon/.screens/
|
||||
|
||||
# local hook backup
|
||||
*.pre-storeguard.bak
|
||||
|
||||
Generated
+6485
File diff suppressed because it is too large
Load Diff
+10
@@ -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",
|
||||
]
|
||||
@@ -263,3 +263,48 @@ Both matter beyond themselves, because they are the only two routes into a match
|
||||
Useful framing: this project's failures have almost always come from proposing a fix
|
||||
before testing the assumption under it. Hypotheses that come with a cheap way to
|
||||
disconfirm them are worth far more than plausible ones.
|
||||
|
||||
## FIFA 17 network-redirect milestone (2026-08-09)
|
||||
|
||||
Hook now installs a GENERIC network redirect on the fifa17 feature path (fifa17.rs
|
||||
install_network_redirect): getaddrinfo IAT patch + inline connect detour + WSAConnect
|
||||
IAT, with a configurable destination (connect_hook::set_target_ipv4) read from
|
||||
openfut.cfg (single-line IP). Deployed DLL md5 bc9e0bc6, cfg=10.10.0.120.
|
||||
|
||||
RESULT of live launch (client 105 -> server 120):
|
||||
- Error changed: "servers shut down" -> "Unable to connect to EA servers / check
|
||||
network". Redirect IS firing (progress).
|
||||
- BLOCKER A: getaddrinfo IAT patched 0+0 -> FIFA 17 does NOT resolve via IAT
|
||||
getaddrinfo in the main exe or EAWebKit.dll. Names resolved via another path
|
||||
(gethostbyname or internal DirtySDK resolver). So no hostname reached 120.
|
||||
- BLOCKER B (architectural): FIFA 17 online = Blaze binary TCP on high ports. Log
|
||||
shows connect 20.51.153.159:42230 sock_type=1 -> wsa_err=10035 (WOULDBLOCK->dead).
|
||||
Port 42230 is NOT in the remap set (443,10041,42127,3216) so it was not redirected.
|
||||
Even if redirected, the Docker bridge only speaks HTTPS on 8443 -- no Blaze
|
||||
listener exists for FIFA 17. This is a server-side build, not a hook tweak.
|
||||
|
||||
NEXT (evidence-first): add gethostbyname (and possibly a DirtySDK resolver) capture
|
||||
to learn the hostname behind 20.51.153.159; widen Blaze port remap; then scope a
|
||||
Blaze-speaking bridge listener before expecting the error to clear.
|
||||
|
||||
## DNS/getaddrinfo fix — RESOLVED (2026-08-09, hook md5 67e3639b)
|
||||
|
||||
Added src/resolver_hook.rs: INLINE detours at ws2_32 export addresses for
|
||||
getaddrinfo + GetAddrInfoW + gethostbyname (same unhook/rehook pattern as
|
||||
connect_hook). Replaces the IAT approach that patched 0 slots on FIFA 17.
|
||||
Wired into fifa17.rs install_network_redirect; hooks.rs gained redirect_ip_cstr()
|
||||
and redirect_ip_str() helpers.
|
||||
|
||||
LIVE RESULT (client 105 -> server 120):
|
||||
- resolver detours 3/3 installed.
|
||||
- getaddrinfo(winter15.gosredirector.ea.com) -> redirect. Game now dials
|
||||
10.10.0.120 (was 20.51.153.159 before). DNS BLOCKER A = SOLVED.
|
||||
|
||||
REMAINING BLOCKER B (architectural, NOT DNS): FIFA 17 online = EA Blaze binary
|
||||
TCP. Game connects 10.10.0.120:42230 (gosredirector/Blaze redirector) ->
|
||||
wsa_err=10035 (nothing listening). Two gaps: (1) connect_hook remap set lacks
|
||||
42230; (2) even remapped, the Docker bridge only serves HTTPS on 8443 — no Blaze
|
||||
listener exists. Clearing Unable to connect requires a Blaze redirector+main
|
||||
server on the bridge side (real server build), not a hook change.
|
||||
NOTE: the 3s TLS-handshake-EOF spam in bridge logs on :8443 is the LAUNCHER health
|
||||
poller, not the game.
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule fifa-blaze updated: eccd46f52b...d2a9a01ec9
@@ -0,0 +1,16 @@
|
||||
# Keep the authoritative-tree build context lean: only tools/ and data/ runtime
|
||||
# files (plus the Dockerfile's own entrypoint/manifest) are needed in-image.
|
||||
.git
|
||||
.gitignore
|
||||
artifacts
|
||||
captures
|
||||
futmem
|
||||
staging
|
||||
docs
|
||||
FUT-RUNBOOK.md
|
||||
README.md
|
||||
data/memdump
|
||||
**/__pycache__
|
||||
*.pyc
|
||||
*.pem
|
||||
*.key
|
||||
@@ -9,4 +9,5 @@
|
||||
*.log
|
||||
__pycache__/
|
||||
captures/
|
||||
staging/
|
||||
tools/fifa17_profile.json
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
state/
|
||||
@@ -0,0 +1,11 @@
|
||||
# Copy to .env in this directory. Required for remote deployment.
|
||||
#
|
||||
# OPENFUT_ADVERTISE — the address of THIS host as seen from the game machine
|
||||
# (105). The responders advertise it to the client for every next hop (Blaze,
|
||||
# roster, UTAS, POW). Compose refuses to start without it.
|
||||
OPENFUT_ADVERTISE=10.10.0.120
|
||||
|
||||
# OPENFUT_BIND — address the listeners bind inside the container.
|
||||
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
|
||||
# uses the loopback default baked into the responders when unset.
|
||||
OPENFUT_BIND=0.0.0.0
|
||||
@@ -0,0 +1,62 @@
|
||||
# OpenFUT FIFA-17 FUT backend — Python migration deployment.
|
||||
#
|
||||
# Runs the 5 network responders (LSX / Blaze / roster / UTAS / POW) that FIFA 17
|
||||
# dials to reach the FUT hub. Pure-Python; the only third-party dep is
|
||||
# pycryptodome (LSX AES handshake). autopatch.py is intentionally NOT run here —
|
||||
# it patches the game process memory and belongs on the client (105).
|
||||
#
|
||||
# Build context is fifa17-recon/ (the repo tree). tools/ is the AUTHORITATIVE
|
||||
# recon tree (fifa17-recon/tools/). Only the runtime file set listed in
|
||||
# docker/fifa17-python/runtime-tools.list is installed into /app/tools, so the
|
||||
# deployed manifest stays byte-identical to the frozen baseline image
|
||||
# openfut-fut-backend:python-baseline-2026-08-10 (see docs/BASELINE-*.md) while
|
||||
# recon scripts, ghidra_queries and docs stay out of the image. data/ comes
|
||||
# from the authoritative fifa17-recon/data. A SHA256SUMS.txt is baked into the
|
||||
# image so any running backend can be matched to the exact dataset it was built
|
||||
# from.
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN pip install --no-cache-dir pycryptodome==3.20.0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Stage the authoritative tools tree in full...
|
||||
COPY tools/ /app/tools-full/
|
||||
|
||||
# ...then install ONLY the runtime manifest (baseline image minus the two
|
||||
# git-ignored certs, which are regenerated below).
|
||||
COPY docker/fifa17-python/runtime-tools.list /app/runtime-tools.list
|
||||
RUN set -eu; \
|
||||
mkdir -p /app/tools; \
|
||||
while IFS= read -r f; do \
|
||||
[ -n "$f" ] || continue; \
|
||||
mkdir -p "/app/tools/$(dirname "$f")"; \
|
||||
cp "/app/tools-full/$f" "/app/tools/$f"; \
|
||||
done < /app/runtime-tools.list; \
|
||||
rm -rf /app/tools-full
|
||||
|
||||
COPY data/ /app/data/
|
||||
|
||||
# Redirector TLS cert (CN/SAN = winter15.gosredirector.ea.com). ProtoSSL
|
||||
# cert-verify is patched client-side, so a self-signed cert is fine. The pair is
|
||||
# git-ignored (*.pem/*.key); regenerate if absent so a fresh checkout builds
|
||||
# without extra steps.
|
||||
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends openssl && \
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout tools/redir_key.pem -out tools/redir_cert.pem \
|
||||
-days 3650 -subj "/CN=winter15.gosredirector.ea.com" \
|
||||
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com" && \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# Bake a dataset manifest so every image is self-identifying.
|
||||
RUN find /app/tools /app/data -type f | LC_ALL=C sort | xargs sha256sum > /app/SHA256SUMS.txt
|
||||
|
||||
COPY docker/fifa17-python/entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# LSX 4216 | Blaze redir/main/nucleus 42127/42130/42131 | roster 8081 | UTAS 8099 | POW 8094/8080
|
||||
EXPOSE 4216 42127 42130 42131 8081 8099 8094 8080
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# OpenFUT FIFA-17 — CLIENT-side arming (runs on the GAME machine, e.g. 105).
|
||||
#
|
||||
# Companion to the dev container on the SERVER (120). The server runs the heavy
|
||||
# responders (Blaze / UTAS / roster / POW). Two pieces are inherently local to
|
||||
# the game and therefore stay here:
|
||||
#
|
||||
# * autopatch.py — patches FIFA17.exe process memory (ProtoSSL cert-verify).
|
||||
# Must run where the game runs; cannot be containerised.
|
||||
# * lsx_responder — the Origin/EADesktop emulator the game dials on the
|
||||
# hardcoded loopback 127.0.0.1:4216. Loopback IPC can't be
|
||||
# cleanly redirected to a remote host, so it lives here.
|
||||
#
|
||||
# Everything the game reaches by a routable address is redirected to the server:
|
||||
# * winter15.gosredirector.ea.com (hardcoded EA IP 159.153.51.20) -> SERVER:42127
|
||||
# * easw.easports.com (dead hardcoded UTAS host) -> SERVER (:8099)
|
||||
#
|
||||
# The server's responders were started with OPENFUT_ADVERTISE=<SERVER_IP>, so
|
||||
# after these first redirected contacts the game is handed <SERVER_IP> for every
|
||||
# later hop (Blaze main, roster, UTAS, telemetry) and dials the server directly.
|
||||
#
|
||||
# Usage: sudo OPENFUT_SERVER=203.0.113.10 ./client_arm.sh
|
||||
# (re-run after every reboot; the sysctl/iptables state is volatile)
|
||||
# ============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SERVER="${OPENFUT_SERVER:?set OPENFUT_SERVER to the backend host IP, e.g. 203.0.113.10}"
|
||||
GOS_EA_IP="159.153.51.20" # winter15.gosredirector.ea.com (hardcoded in FIFA17)
|
||||
UTAS_HOST="easw.easports.com" # dead UTAS host baked into CardsDLL
|
||||
UTAS_RE="${UTAS_HOST//./\\.}" # same, safe to embed in a regex
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "!! must run as root (sudo). Re-run: sudo OPENFUT_SERVER=$SERVER $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[client_arm] backend server = $SERVER"
|
||||
|
||||
# 1) allow /proc/PID/mem writes (autopatch's ProtoSSL cert-verify patch)
|
||||
sysctl -q kernel.yama.ptrace_scope=0
|
||||
|
||||
# 2) Redirect the hardcoded Blaze redirector IP to the server's redirector.
|
||||
# (Replace any stale rule first so re-runs and IP changes are clean.)
|
||||
while iptables -t nat -D OUTPUT -p tcp -d "$GOS_EA_IP" -j DNAT \
|
||||
--to-destination "$SERVER:42127" 2>/dev/null; do :; done
|
||||
iptables -t nat -A OUTPUT -p tcp -d "$GOS_EA_IP" -j DNAT --to-destination "$SERVER:42127"
|
||||
|
||||
# 2b) DNAT from OUTPUT to a REMOTE host needs a matching source-NAT on the way
|
||||
# out, or the server's replies (from its own IP) won't match the game's
|
||||
# conntrack entry. MASQUERADE the redirected flow so it is SNAT'd to this
|
||||
# host's outbound IP. (Harmless duplicate-guarded like the DNAT above.)
|
||||
while iptables -t nat -D POSTROUTING -p tcp -d "$SERVER" --dport 42127 \
|
||||
-j MASQUERADE 2>/dev/null; do :; done
|
||||
iptables -t nat -A POSTROUTING -p tcp -d "$SERVER" --dport 42127 -j MASQUERADE
|
||||
|
||||
# 3) Point the dead hardcoded UTAS host at the server. The port (8099) is carried
|
||||
# in the game's own URL, so only the name needs redirecting. Remove any prior
|
||||
# OpenFUT-managed line (loopback or other server) and write the current one.
|
||||
sed -i "/[[:space:]]${UTAS_RE}\b.*# openfut\$/d" /etc/hosts
|
||||
printf '%s\t%s\t# openfut\n' "$SERVER" "$UTAS_HOST" >> /etc/hosts
|
||||
|
||||
echo "[client_arm] --- armed ---"
|
||||
sysctl kernel.yama.ptrace_scope
|
||||
iptables -t nat -L OUTPUT -n | grep -i "$GOS_EA_IP" || echo " (DNAT missing!)"
|
||||
|
||||
# Verify the hosts entry by EFFECT, not by presence.
|
||||
#
|
||||
# glibc returns the FIRST match in /etc/hosts, so our line can be written
|
||||
# correctly and still lose to an earlier one -- and the sed above only removes
|
||||
# lines this script wrote (`# openfut`), so re-running never clears a foreign
|
||||
# one. The old check here was `grep easw /etc/hosts && echo ok`, which passed on
|
||||
# the shadowing line itself and reported success while resolution was wrong.
|
||||
#
|
||||
# Observed on 2026-08-11: a leftover `127.0.0.1 easw.easports.com` from the
|
||||
# single-machine era shadowed the OpenFUT line, and every re-run said "ok".
|
||||
resolved="$(getent ahosts "$UTAS_HOST" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ')"
|
||||
# SERVER may be a hostname, so compare address-to-address rather than comparing
|
||||
# the literal string against resolved IPs (which would warn spuriously).
|
||||
server_ips="$(getent ahosts "$SERVER" 2>/dev/null | awk '{print $1}' | sort -u)"
|
||||
[ -n "$server_ips" ] || server_ips="$SERVER"
|
||||
match=0
|
||||
for ip in $server_ips; do
|
||||
printf '%s' "$resolved" | grep -qw -- "$ip" && match=1
|
||||
done
|
||||
if [ "$match" -eq 1 ]; then
|
||||
echo " /etc/hosts ok ($UTAS_HOST -> $resolved)"
|
||||
else
|
||||
echo
|
||||
echo " !! WARNING: $UTAS_HOST resolves to [$resolved], not $SERVER."
|
||||
echo " An earlier /etc/hosts line is shadowing the OpenFUT one:"
|
||||
grep -nE "^[[:space:]]*[^#].*[[:space:]]${UTAS_RE}([[:space:]]|\$)" /etc/hosts \
|
||||
| grep -v '# openfut$' | sed 's/^/ /' || true
|
||||
echo
|
||||
echo " Not fatal: the responders advertise $SERVER, so the game stops using"
|
||||
echo " this name after the first hop. Worth removing the line above anyway."
|
||||
echo " Lines are listed rather than deleted -- this script will not remove"
|
||||
echo " /etc/hosts entries it did not write."
|
||||
fi
|
||||
echo
|
||||
echo "[client_arm] Next: start the LOCAL pieces (LSX + autopatch) with client_local.sh,"
|
||||
echo " ensure the container is up on $SERVER, then launch FIFA 17."
|
||||
@@ -0,0 +1,49 @@
|
||||
# OpenFUT FIFA-17 FUT backend — declarative deployment (server side, runs on 120).
|
||||
#
|
||||
# cp .env.example .env # set OPENFUT_ADVERTISE to THIS host's LAN IP
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the address
|
||||
# the servers hand the client (105) for every next hop (Blaze, roster, UTAS,
|
||||
# POW) and is required — there is no silent loopback fallback in remote mode.
|
||||
#
|
||||
# The client (105) still needs its first-hop redirect (hook or DNAT) plus
|
||||
# autopatch.py running locally; see client_arm.sh and the FIFARUNBOOK.
|
||||
name: openfut-fut-backend
|
||||
|
||||
services:
|
||||
fut-backend:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/fifa17-python/Dockerfile
|
||||
image: openfut-fut-backend:dev
|
||||
container_name: openfut-fut-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Bind all interfaces inside the container.
|
||||
OPENFUT_BIND: "${OPENFUT_BIND:-0.0.0.0}"
|
||||
# Address advertised to the client for the next hop. MUST be this host's
|
||||
# LAN IP as seen from the game machine (105). Required (see .env.example).
|
||||
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 10.10.0.120}"
|
||||
# POW content advertises port 8080 by default, which collides with the
|
||||
# openfut-core publish on this host. Remap it to 8085 on the host and
|
||||
# advertise the remapped endpoint.
|
||||
POW_CONTENT_ADDR: "0.0.0.0:8080"
|
||||
POW_CONTENT_HOST: "${OPENFUT_ADVERTISE}:8085"
|
||||
# Launcher-selected EA/Origin identity shared by LSX, Blaze, POW and UTAS.
|
||||
# FUT saves are isolated by persona beneath /state/accounts.
|
||||
FUT_ACCOUNT_PATH: "/state/active_account.json"
|
||||
FUT_PROFILE_ROOT: "/state/accounts"
|
||||
FUT_SETTINGS: "off"
|
||||
FUT_MODES: "1"
|
||||
volumes:
|
||||
- "../state:/state"
|
||||
ports:
|
||||
- "4216:4216" # LSX (Origin bootstrap)
|
||||
- "42127:42127" # Blaze redirector (TLS)
|
||||
- "42130:42130" # Blaze main
|
||||
- "42131:42131" # Nucleus OAuth stub
|
||||
- "8081:8081" # FUT roster XML (HTTPS)
|
||||
- "8099:8099" # UTAS / RS4 FUT REST API
|
||||
- "8094:8094" # POW / EASFC API
|
||||
- "8085:8080" # POW content (host 8085 -> container 8080; avoids core:8080)
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# OpenFUT FIFA-17 FUT backend — in-CONTAINER orchestrator.
|
||||
#
|
||||
# Runs the 5 network responders that the game dials. Unlike the host-based
|
||||
# openfut-fut.sh, this does NO host arming (no pkexec / iptables / /etc/hosts /
|
||||
# ptrace) — those are client-side concerns handled on the game machine (105).
|
||||
# autopatch.py is NOT run here: it patches the FIFA17.exe process memory and must
|
||||
# run on the box the game runs on.
|
||||
#
|
||||
# Address behaviour is driven by two env vars (see each responder):
|
||||
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
|
||||
# OPENFUT_ADVERTISE address handed to the client for the next hop
|
||||
# (the server's LAN IP, e.g. 10.10.0.120)
|
||||
# ============================================================================
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$(readlink -f "$0")")/tools"
|
||||
|
||||
BIND="${OPENFUT_BIND:-0.0.0.0}"
|
||||
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 10.10.0.120)}"
|
||||
export OPENFUT_BIND="$BIND"
|
||||
export OPENFUT_ADVERTISE="$ADV"
|
||||
# POW keys advertised by blaze must also point at the server, not loopback.
|
||||
export POW_HOST="${POW_HOST:-$ADV:8094}"
|
||||
export POW_CONTENT_HOST="${POW_CONTENT_HOST:-$ADV:8080}"
|
||||
export POW_ADDR="${POW_ADDR:-$BIND:8094}"
|
||||
export POW_CONTENT_ADDR="${POW_CONTENT_ADDR:-$BIND:8080}"
|
||||
|
||||
echo "[openfut] bind=$BIND advertise=$ADV"
|
||||
|
||||
# name script extra-env
|
||||
declare -a SERVERS=(
|
||||
"lsx|lsx_responder_v2.py|OPENFUT_LSX_EVENT_COUNT=100000"
|
||||
"blaze|blaze_responder_v3b.py|-"
|
||||
"roster|roster_server.py|-"
|
||||
"utas|utas_server.py|FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1 FUT_DISCARD_TABLE=1 FUT_DISCARD_SEND=1"
|
||||
"pow|pow_server.py|-"
|
||||
)
|
||||
|
||||
pids=()
|
||||
names=()
|
||||
for entry in "${SERVERS[@]}"; do
|
||||
IFS='|' read -r name script env <<<"$entry"
|
||||
envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
|
||||
echo "[openfut] starting $name ($script)"
|
||||
# shellcheck disable=SC2086
|
||||
$envprefix python3 -u "$script" &
|
||||
pids+=($!)
|
||||
names+=("$name")
|
||||
done
|
||||
|
||||
# Propagate SIGTERM/SIGINT to children so `docker stop` is clean.
|
||||
term() {
|
||||
echo "[openfut] shutting down…"
|
||||
for p in "${pids[@]}"; do kill "$p" 2>/dev/null || true; done
|
||||
wait
|
||||
exit 0
|
||||
}
|
||||
trap term TERM INT
|
||||
|
||||
# If ANY responder dies, take the whole container down so the failure is visible
|
||||
# (they all bind ports the game needs — a partial stack is a broken stack).
|
||||
while true; do
|
||||
for i in "${!pids[@]}"; do
|
||||
if ! kill -0 "${pids[$i]}" 2>/dev/null; then
|
||||
echo "[openfut] responder ${names[$i]} (pid ${pids[$i]}) exited - bringing container down"
|
||||
term
|
||||
fi
|
||||
done
|
||||
sleep 2
|
||||
done
|
||||
@@ -0,0 +1,77 @@
|
||||
origin_login_probe.py
|
||||
card_proof.py
|
||||
force_login_flag.py
|
||||
card_record_poke.py
|
||||
test_tournament_contract.py
|
||||
dmp_stack.py
|
||||
fut_clubitems.py
|
||||
test_autopatch_logging.py
|
||||
capture_lsx.py
|
||||
roster_server.py
|
||||
autopatch.py
|
||||
dbschema_probe.py
|
||||
test_account_profiles.py
|
||||
coach_window.py
|
||||
watch_club_model.py
|
||||
db_dump.py
|
||||
coach_probe.py
|
||||
uidiff.py
|
||||
probe_club_stats.py
|
||||
blaze_responder_v3.py
|
||||
dbdata_extract.py
|
||||
decode_fire2.py
|
||||
check_club_stat_vocab.py
|
||||
fut_accounts.py
|
||||
strip_dead_cards.py
|
||||
test_hub_offline_season_contract.py
|
||||
repair_club.py
|
||||
forge_node.py
|
||||
verify_preauth.py
|
||||
fut_coaches.py
|
||||
heat2.py
|
||||
test_security_question.py
|
||||
test_utas_log_redaction.py
|
||||
sbc_populate_poke.py
|
||||
atomdump.py
|
||||
lsx_responder.py
|
||||
fut_staff.py
|
||||
fut_cards.py
|
||||
blaze_responder.py
|
||||
blaze_responder_v2.py
|
||||
fut_store.py
|
||||
blaze_responder_v3b.py
|
||||
test_fut_contract.py
|
||||
utas_server.py
|
||||
lsx_force_online.py
|
||||
grab_crash_code.py
|
||||
gate_byte_probe.py
|
||||
fut_admin.py
|
||||
test_match_rewards.py
|
||||
lsx_responder_v2.py
|
||||
card_identity_probe.py
|
||||
extract_player_ids.py
|
||||
watch_online_mode.py
|
||||
store_enable_poke.py
|
||||
pow_server.py
|
||||
fut_account.py
|
||||
blaze_responder_v3_patched.py
|
||||
check_settings_flags.py
|
||||
test_match_lifecycle.py
|
||||
sbc_hook_poke.py
|
||||
futlog.py
|
||||
fut_seed.py
|
||||
hub_counter_probe.py
|
||||
fut_consumables.py
|
||||
db_catalog_walk.py
|
||||
memtool.py
|
||||
build_player_facts.py
|
||||
sweep_collect.py
|
||||
test_card_families.py
|
||||
fut_club_stats.py
|
||||
dmp.py
|
||||
build_consumables.py
|
||||
test_market_buy.py
|
||||
dump_login_code.py
|
||||
auth_watch.py
|
||||
vgamepad.py
|
||||
ghidra_env.py
|
||||
@@ -0,0 +1,121 @@
|
||||
# Python backend baseline — 2026-08-10
|
||||
|
||||
Frozen rollback target for the working offline FUT backend (Python migration) as
|
||||
it ran on 10.10.0.120. Everything here was recorded from the live system before
|
||||
any cleanup/restructure; the image and state are archived in
|
||||
`/home/alex/OpenFUT/docker-backups/`.
|
||||
|
||||
## Frozen image
|
||||
|
||||
| field | value |
|
||||
|------------|-------|
|
||||
| tag | `openfut-fut-backend:python-baseline-2026-08-10` |
|
||||
| image id | `e1f93ad647ab` |
|
||||
| digest | `sha256:e1f93ad647abbec32e2751f3e88fed75d3e574d4500395b21c31d0f0b96abac6` |
|
||||
| created | 2026-08-10T02:14:56Z (built as `openfut-fut-backend:dev`) |
|
||||
| size | 278 MB |
|
||||
| archive | `docker-backups/openfut-fut-backend-python-baseline-2026-08-10.tar.gz` (53 MB, `docker save \| gzip -1`) |
|
||||
|
||||
## Frozen container
|
||||
|
||||
| field | value |
|
||||
|------------|-------|
|
||||
| id | `f16d3204cbf48151be232ff8f4194b429e311042f8f6f95644760d4b8eba2938` |
|
||||
| created | 2026-08-10T02:14:56.194470252Z |
|
||||
| image | `openfut-fut-backend:dev` (= baseline image id) |
|
||||
| restart | `unless-stopped` |
|
||||
| network | `docker_default`, IP `172.19.0.2`, aliases `openfut-fut-backend`, `fut-backend` |
|
||||
| log | json-file |
|
||||
| inspect | `docker-backups/openfut-fut-backend-container-inspect-2026-08-10.json` |
|
||||
|
||||
### Environment (Config.Env)
|
||||
|
||||
```
|
||||
FUT_SETTINGS=off
|
||||
FUT_MODES=1
|
||||
OPENFUT_BIND=0.0.0.0
|
||||
OPENFUT_ADVERTISE=10.10.0.120
|
||||
POW_CONTENT_ADDR=0.0.0.0:8080
|
||||
POW_CONTENT_HOST=10.10.0.120:8085
|
||||
FUT_ACCOUNT_PATH=/state/active_account.json
|
||||
FUT_PROFILE_ROOT=/state/accounts
|
||||
PYTHON_VERSION=3.12.13 (python:3.12-slim base)
|
||||
```
|
||||
|
||||
### Volumes / mounts
|
||||
|
||||
Bind mount `docker/state` (host) -> `/state` (container, rw). Runtime state:
|
||||
`active_account.json` (active persona) + `accounts/` (FUT saves by persona).
|
||||
Snapshot: `docker-backups/state-2026-08-10/`.
|
||||
|
||||
### Ports (host -> container)
|
||||
|
||||
| host | container | service |
|
||||
|------|-----------|---------|
|
||||
| 4216 | 4216 | LSX (Origin bootstrap) |
|
||||
| 42127 | 42127 | Blaze redirector (TLS) |
|
||||
| 42130 | 42130 | Blaze main |
|
||||
| 42131 | 42131 | Nucleus OAuth stub |
|
||||
| 8081 | 8081 | FUT roster XML (HTTPS) |
|
||||
| 8099 | 8099 | UTAS / RS4 FUT REST API |
|
||||
| 8094 | 8094 | POW / EASFC API |
|
||||
| 8085 | 8080 | POW content (remapped to avoid openfut-core:8080) |
|
||||
|
||||
All listeners verified bound on `0.0.0.0` in the container (LSX/Blaze/nucleus,
|
||||
roster, UTAS, POW, POW content).
|
||||
|
||||
## Dataset manifest
|
||||
|
||||
`docker-backups/SHA256SUMS-container-baseline-2026-08-10.txt` — sha256 of all
|
||||
323 files under `/app/tools` + `/app/data` inside the running container.
|
||||
|
||||
`fifa17-python/tools/` and `fifa17-python/data/` are the staged sources that
|
||||
built this image (verified byte-identical to the container copies at freeze
|
||||
time). Images rebuilt from git now bake their own `/app/SHA256SUMS.txt`; the
|
||||
rebuild-equivalence check is `diff` between that and this manifest; the only expected deltas are pycache files (not committed) and the redir cert pair (regenerated per build).
|
||||
|
||||
## Restore
|
||||
|
||||
```sh
|
||||
# From the archived image (works offline, exact layers):
|
||||
docker load -i /home/alex/OpenFUT/docker-backups/openfut-fut-backend-python-baseline-2026-08-10.tar.gz
|
||||
docker tag openfut-fut-backend:python-baseline-2026-08-10 openfut-fut-backend:dev
|
||||
|
||||
# Or rebuild from git:
|
||||
cd /home/alex/OpenFUT/fifa17-recon/docker/fifa17-python
|
||||
cp .env.example .env # set OPENFUT_ADVERTISE
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Status at freeze time
|
||||
|
||||
- The 2026-08-10 `openfut-fut-backend` container was **left running untouched**
|
||||
(the .105 launcher audit uses it). No rebuild/replacement happens until that
|
||||
audit finishes; the frozen image is the rollback target if cleanup breaks it.
|
||||
- `docker/state` was **not** moved during restructure (bind path must not change
|
||||
while the container is live); the new compose mounts `../state` from the same
|
||||
location.
|
||||
- TURN/relay re-addressing (multiplayer) and long-tail endpoints (weather,
|
||||
matchday, kit assets) are deferred feature gaps — tracked separately.
|
||||
|
||||
## Running state vs image — what the frozen image does NOT contain
|
||||
|
||||
The baseline image (`python-baseline-2026-08-10` / `dev`) was built at 02:14Z,
|
||||
but the container's `/app` was hot-patched afterwards:
|
||||
|
||||
* `tools/utas_server.py` — gained the `FUT_MODES`-gated `offlineSeason` block in
|
||||
GetHubData's club response (keeps the hub's offline-season summary valid).
|
||||
* `tools/test_hub_offline_season_contract.py` — added to `/app/tools`.
|
||||
|
||||
`docker save` captures the image, not the container's writable layer, so the
|
||||
baseline tar.gz lacks those two changes. Two paths cover the exact runtime:
|
||||
|
||||
* `openfut-fut-backend:python-running-2026-08-10` — `docker commit` of the
|
||||
running container (sha256:093a98fa0496...), the exact runtime FS.
|
||||
* The committed `fifa17-python/tools` + `data` — synced to match the running
|
||||
container byte-for-byte (237 files verified, incl. the redir cert pair), so a
|
||||
fresh build reproduces the actual running backend. Proven by rebuilding from
|
||||
the committed sources and diffing the baked `/app/SHA256SUMS.txt` against the
|
||||
container manifest: identical.
|
||||
|
||||
Archive: `docker-backups/openfut-fut-backend-python-running-2026-08-10.tar.gz`.
|
||||
@@ -215,7 +215,11 @@ Path template `%s = "game/fifa17"`. Methods inferred from struct verb + endpoint
|
||||
### Freeze-risk summary (type fidelity is mandatory)
|
||||
- `auctionInfo` → **array** (never object/scalar).
|
||||
- `itemData` inside each record → **object** (the card; reuse `item_def`).
|
||||
- `duplicateItemIdList` → **array**.
|
||||
- `duplicateItemIdList` → **array of objects** (element deser `0x180138e10`: `itemId` 0x16d,
|
||||
`duplicateItemId` 0xeb, `itemLoans` 0x16f, `duplicateItemLoans` 0xed). Not an int list.
|
||||
`[]` is safe; a list of bare ints is a freeze. Control that this is not a misread:
|
||||
`dreamSquads` 0xe9 in FutMoveCard genuinely IS a bare int array, parsed by a
|
||||
`while (tok != 0xd)` loop calling the int getter with no inner object loop.
|
||||
- `bidState`, `tradeState`, `sellerName` → **strings**.
|
||||
- `credits`, `total`, `count`, `*Price`, `*Bid`, `expires`, `tradeId` → **numbers**.
|
||||
- `watched` → **bool**.
|
||||
@@ -966,7 +970,11 @@ Notes:
|
||||
{ "itemData": [ /* the single updated card item */ ] }
|
||||
|
||||
// 8 DiscardCard — DELETE ut/delete/game/fifa17/item
|
||||
{ "items": [ 123456789 ], "totalCredits": 15000, "id": 123456789 }
|
||||
// CORRECTED 2026-08-05: `items` is an array of OBJECTS and there is no top-level `id`.
|
||||
// The previous shape, { "items": [ 123456789 ], ..., "id": 123456789 }, was wrong twice
|
||||
// over, and feeding a bare int where the element parser expects an object is a tokenizer
|
||||
// desync, i.e. a hard freeze at 0x1801c7f1a, not a soft failure.
|
||||
{ "items": [ { "id": 123456789 } ], "totalCredits": 15000 }
|
||||
|
||||
// 9 DiscardCardByRes — DELETE ut/delete/game/fifa17/item
|
||||
{ "totalCredits": 15000 }
|
||||
@@ -1042,7 +1050,7 @@ desyncs the SAX reader → tokenizer freeze at `0x1801c7f1a`.
|
||||
| `displayGroup` | 0xd9 | **ARRAY** | nested (freeze-risk) |
|
||||
| `displayGroupAssetId` | 0xda | INT | `[rbp-0x80]` |
|
||||
| `displayGroupUseDefaultImage` | 0xdb | BOOL | |
|
||||
| `currencies` | 0xc5 | **ARRAY** | coin price: `[{name,funds,finalFunds}]` (freeze-risk) |
|
||||
| `currencies` | 0xc5 | **ARRAY** | coin price: `[{name,funds,finalFunds}]` (freeze-risk). **`finalFunds` is the number the tile RENDERS. CONFIRMED LIVE 2026-08-05** by serving `funds=15000, finalFunds=4321` on one pack and reading `4,321` off the store tile. `funds` is not displayed. |
|
||||
| `extPrice` | 0x119 | **OBJECT** | → `finalPrice`(0x125,obj `0x180139070`) + `originalPrice`(0x205,obj `0x18013aae0`); inner uses `amount`(0x1b)/`currency`(0xc4) (freeze-risk) |
|
||||
| `packContentInfo` | 0x20c | **OBJECT** | → `bronzeQuantity`(0x63), `silverQuantity`(0x2c6), `goldQuantity`(0x149), `rareQuantity`(0x273), `itemQuantity`(0x170), `start`(0x2e3), `unopened`(0x35d,bool) (freeze-risk) |
|
||||
| `sortPriority` | 0x2cb | INT | |
|
||||
@@ -1092,7 +1100,7 @@ desyncs the SAX reader → tokenizer freeze at `0x1801c7f1a`.
|
||||
| `itemList` | 0x16e | **ARRAY** of items (element deser `0x18013fe00`) | freeze-risk |
|
||||
| `numberItems` | 0x1dd | INT | `[rsi+0x28]` |
|
||||
| `purchasedPackId` | 0x264 | INT | `[rsi+0x70]` |
|
||||
| `duplicateItemIdList` | 0xec | **ARRAY** (int list) | freeze-risk |
|
||||
| `duplicateItemIdList` | 0xec | **ARRAY of OBJECTS** (element deser `0x180138e10`) | freeze-risk |
|
||||
|
||||
- **Status: already handled — VERIFIED byte-exact** against `store_buy()`.
|
||||
- **Minimal known-good**:
|
||||
@@ -1243,21 +1251,122 @@ reader → infinite spin at `0x1801c7f1a` (the hub freeze).
|
||||
- **Handled:** `utas_server.massinfo()` → `{userInfo, squad, settings, userData}`;
|
||||
`FUT_MASSINFO=full|squad|userinfo|settings|empty` bisects it one member per relaunch.
|
||||
|
||||
### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED
|
||||
- **Deser:** `0x18013c6d0`
|
||||
- **HTTP:** `GET ut/%s/settings`
|
||||
### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED (schema) / the 42 flags are RECOVERED, UNTESTED
|
||||
- **Deser:** `0x18013c6d0` (1982 bytes, 12061-char decompile, read end to end)
|
||||
- **HTTP:** `GET ut/%s/settings`, and the `settings` (0x2bf) member of `userMassInfo`
|
||||
(both callers of the deser: `0x18014e590` and `0x180174630`)
|
||||
- **Fields:** single wrapper key `configs` (0xa2) → array of config entries
|
||||
`{ type (0x354), value (0x377) }`.
|
||||
- **Handled:** `utas_server.SETTINGS = {"configs": []}`. Min JSON: `{"configs":[]}`.
|
||||
`{ type (0x354), value (0x377) }`. The key ladder really does hold nothing else.
|
||||
|
||||
### FutGetHubDataServerResponse — CONFIDENCE: LOW (full schema) / HIGH (served {} works) — GAP
|
||||
- **Wrapper:** `0x1801736ad` → inner `0x180173a50` / `0x180173b10` / `0x180173c00`.
|
||||
**The mechanism the key ladder hides.** A flag is not a JSON key. When an element
|
||||
closes, the client feeds the STRING VALUE of `type` back through the atom hasher
|
||||
(`FUN_180180d00`) and switches on the result, 42 arms wide:
|
||||
|
||||
```json
|
||||
{"configs": [{"type": "friendlySeasonsEnabled", "value": 1}]}
|
||||
```
|
||||
|
||||
So the flag vocabulary is the same atom table everything else uses, and the client
|
||||
hashes our string itself — a flag cannot be misnamed silently, it simply falls
|
||||
through to the default arm and is ignored.
|
||||
|
||||
- **`value` is type-forgiving.** Its getter `0x1801c79d0` accepts int (token 2),
|
||||
float (3), bool (4) and string (5, via `sscanf "%I64d"`), coercing all four to
|
||||
int64. `1`, `"1"` and `true` are equivalent. This is one of the few scalar
|
||||
getters in the API with NO desync risk on scalars. An object or array is still
|
||||
a freeze.
|
||||
- **The applier demands exactly 1.** `FUN_18011dc50` is the only writer of the
|
||||
gate bytes and every line is `gate_byte = (field == 1)`. Not truthiness. `2`,
|
||||
`-1` and `"yes"` all read as OFF.
|
||||
|
||||
**Flags that publish a UI gate key.** `FUN_18006cc60` publishes IS_* state keys by
|
||||
reading single bytes inside `FutDataManagerImpl` (service id `0xed84b11`, ctor
|
||||
`0x18010cdc0`). Those bytes are written ONLY by the applier, and the ctor never
|
||||
touches them (whole 16620-char ctor scanned):
|
||||
|
||||
| flag `type` | field | gate byte | UI key |
|
||||
|---|---|---|---|
|
||||
| `tradingEnabled` | `[10]` | `0x1fd2e` | `IS_TRADING_ENABLED` |
|
||||
| `storeEnabled` / `_JP` | `[0xb]` / `[0xc]` | `0x1fd2f` / `0x1fd30` | `IS_STORE_ENABLED` (accessor `0x18011c600` picks `_JP` when region == 4) |
|
||||
| `friendlySeasonsEnabled` | `[0x16]` | `0x1fd3a` | `IS_FRIENDLY_SEASON_ENABLED` |
|
||||
| `tournamentQuitEnabled` | `[0x20]` | `0x1fd3b` | `IS_TOURNAMENT_QUIT_ENABLED` |
|
||||
| `processingStateEnabled` | `[0x21]` | `0x1fd3c` | `IS_PROCESSING_STATE_ENABLED` |
|
||||
| `enableDraftMode` | `[0x17]` | `0x1fd3d` | `IS_DRAFT_MODE_ENABLED` |
|
||||
| `enableOfflineDraftMode` = `enableSinglePlayerDraftMode` | `[0x18]` | `0x1fd3e` | (shared arm, one field) |
|
||||
| `storyModeRewardEnabled` | `[0x1f]` | `0x1fd3f` | `IS_STORY_MODE_REWARD_ENABLED` |
|
||||
| `returningUserRewardsScreenEnabled` | `[0x19]` | `0x1fd40` | `IS_RETURNING_USER_REWARDS_SCREEN_ENABLED` |
|
||||
|
||||
**Why this is the standing suspect for Seasons and Draft.** Both refuse while
|
||||
making zero requests to any of the four servers, which no response shape can
|
||||
explain. A UI key evaluated from a byte that nothing ever wrote does explain it.
|
||||
The store is the control: `IS_STORE_ENABLED` reads the same kind of byte and its
|
||||
screen works, because `storeEnabled` and friends are already shipped through the
|
||||
**Blaze** client-config store (`FUT_RS4_CONFIG` in `blaze_responder_v3b.py`) —
|
||||
and that list contains no seasons, draft or tournament flag. Same mechanism, one
|
||||
population, one blank.
|
||||
|
||||
This is a hypothesis with a mechanism, not a confirmed cause. It predicts that
|
||||
sending the flags opens the screens; if they still refuse, the gate is upstream
|
||||
of the UI key and the whole settings line is dead.
|
||||
|
||||
**Two arms that are not simple assignments:**
|
||||
- `enableObjectives` (0xfd) and `enableObjectivesAsManagerTasks` (0xfe) share an
|
||||
arm that can only ever CLEAR `[0x1c]`: `if (value == 0) field = 0`. Sending 1
|
||||
is a no-op. Objectives cannot be turned ON here, only off.
|
||||
- `clientKeepAliveResetTimeoutSec` (0x86, vtable +0x68) and `getOperationTimeoutSec`
|
||||
(0x13d, +0x58) do not store a field; they call a timer object with `value * 1000`.
|
||||
Sending a small number shortens client timeouts. Leave them alone.
|
||||
|
||||
**`maximumTradePileSize` (0x1c0) is the positive control.** It lands in `[0]` and
|
||||
is passed to `FUN_18011f380`, and transfer-list capacity is visible in game. It
|
||||
distinguishes "the flag did not help" from "the configs array never reached the
|
||||
consumer at all", which no boolean flag can do on its own.
|
||||
|
||||
**Not in the switch:** `enableSquadBuildingSetsFeature` (0x100) is a real atom but
|
||||
has NO arm here, so SBC is gated somewhere else. Scanned the full decompile;
|
||||
this absence is asserted over the whole function, not a slice.
|
||||
|
||||
- **Handled:** `utas_server.SETTINGS`, `FUT_SETTINGS` (default `gates`).
|
||||
`off` restores the historical `{"configs": []}`.
|
||||
|
||||
### FutGetHubDataServerResponse — CONFIDENCE: HIGH (schema fully enumerated) — ✅ HANDLED (tiles populated)
|
||||
- **Deser:** `FUN_180139610` (root object parser). Wrapper `0x1801736ad`.
|
||||
- **HTTP:** `GET ut/%s/hub`
|
||||
- **Note:** uses **C++ reflection / vtable dispatch** (`call [rax+0x10]`,
|
||||
`call [rdx+0x1f8]`), NOT an inline atom ladder — no static field ladder to
|
||||
read. It aggregates sub-objects (userInfo, settings, messages, etc.), each with
|
||||
its own deser. Empty `{}` is tolerated (fields default).
|
||||
- **Handled:** `utas_server` serves `{}` (validated hub-reaching). Deep populate = GAP.
|
||||
- **CORRECTION (2026-08-06):** the earlier note here — "uses C++ reflection /
|
||||
vtable dispatch, NOT an inline atom ladder, no static field ladder to read,
|
||||
GAP" — was **WRONG**. `FUN_180139610` has an ordinary inline atom ladder: a
|
||||
running-sum `sub ecx,d / … / cmp ecx,d` dispatch plus a few direct `cmp esi,imm`.
|
||||
It reads **18 atoms**, all enumerated below straight from the on-disk CardsDLL
|
||||
via objdump (`fifa17-recon` scratchpad `hub_ladder.py`). The vtable calls are the
|
||||
per-sub-object dispatch one indirection deeper, not the field read itself.
|
||||
- **The 18 root atoms** (name ← `fut_atoms.tsv`):
|
||||
`allObjectivesForCurrentGameSpaceId`(0x15), `auctionCount`(0x33),
|
||||
`championEvent`(0x7a), `clubPlayers`(0x90), `draftSummary`(0xe4),
|
||||
`friendlySeason`(0x131), `leaderboard`(0x186), `liveMessagesAvailable`(0x190),
|
||||
`objectivesForCurrentUser`(0x1e3), `offlineSeason`(0x1ec), `ONLINE`(0x1f1),
|
||||
`onlineSeason`(0x1f6), `SINGLE_PLAYER`(0x29d), `squad`(0x2cd),
|
||||
`tournament`(0x328), `tournamentProgress`(0x32c), `tradePile`(0x333),
|
||||
`watchlist`(0x381).
|
||||
- **TILE MAP (which atom drives which hub tile):**
|
||||
- `clubPlayers`(0x90) int → MY CLUB tile "N players" (TILE_ID 0x210)
|
||||
- `auctionCount`(0x33) int → TRANSFER MARKET tile "N LIVE TRANSFERS" (TILE_ID 0x1b0)
|
||||
- `tradePile`(0x333) **nested object**, sub-deser `0x18013ead0` → TRANSFER LIST
|
||||
tile "N ITEMS / Selling / Sold". Sub-atoms: `count`(0xbc), `notification`(0x1da),
|
||||
`selling`(0x2b8), `sold`(0x2c9) — all scalar int via `0x1801c79d0` (5 int reads,
|
||||
one SKIP, object field loop; no array/nested object → no type-desync surface).
|
||||
Same atom scheme as `FutGetAuctionCount`. **All active listings are `selling`;
|
||||
`count == selling == len(listings)`, `sold == 0`.**
|
||||
- `watchlist`(0x381) nested object, sub-deser `0x18013f3b0` → WATCH LIST tile (not
|
||||
yet populated; empty watch list defaults to 0, which is correct today).
|
||||
- **LIVE SYMPTOM this fixed (2026-08-06):** a card was actively listed
|
||||
(`auctionCount` 1, Listed Items screen showed it) yet the TRANSFER LIST tile read
|
||||
"0 items / Selling 0". The tile reads `hub.tradePile`, which we were omitting; it
|
||||
does **not** re-poll `/tradePile/counts` (the standalone GetAuctionCount endpoint)
|
||||
once at the hub. Serving `hub.tradePile:{count,selling,sold}` corrected the tile.
|
||||
- **Handled:** `utas_server.hub_data()` serves `clubPlayers`, `auctionCount`, and
|
||||
`tradePile:{count,selling,sold}` (`FUT_HUBDATA=1`, default on). Remaining atoms
|
||||
(seasons/draft/tournament/objectives/leaderboard summaries) default to 0/absent,
|
||||
which is correct while those modes are unpopulated.
|
||||
|
||||
### FutUserDataServerResponse — CONFIDENCE: MEDIUM
|
||||
- **Deser:** `0x18016dd50` (lea r8 @ `0x18016d98d`)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
# The /settings feature gate - live-test script
|
||||
|
||||
> **CORRECTION, 2026-08-05 evening. Section 1 of this document is FALSE and the
|
||||
> test in section 3 should not be run as written.**
|
||||
>
|
||||
> Section 1 claims `IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` "have
|
||||
> never been set to true by anything, on any run". They are measured as **1**, on
|
||||
> two separate launches, while `/settings` was answering `{"configs": []}`:
|
||||
>
|
||||
> ```
|
||||
> disp 0x1fd3a (friendlySeasonsEnabled) value = 1
|
||||
> disp 0x1fd3d (enableDraftMode) value = 1
|
||||
> disp 0x1fd45 (packOpeningAnimationEnabled) value = 1
|
||||
> ```
|
||||
>
|
||||
> Reproduce with `tools/gate_byte_probe.py` (needs the client at the FUT hub, since
|
||||
> CardsDLL loads only then): it resolves the pid by comm,
|
||||
> re-derives the CardsDLL slide from `/proc/<pid>/maps`, proves it against the FNV
|
||||
> prologue at `0x180180d00` read from disk, walks the model singleton at
|
||||
> `DAT_1802e6398`, and decodes each displacement out of its accessor stub
|
||||
> (`0f b6 81 <disp32>`) rather than assuming it.
|
||||
>
|
||||
> **Where the reasoning went wrong.** The finding that `FUN_18011dc50` is the only
|
||||
> writer and that the `FutDataManagerImpl` constructor never touches those bytes was
|
||||
> correct. The inference drawn from it was not. The applier runs whether or not the
|
||||
> configs array has content, and the settings struct it is handed defaults these
|
||||
> fields to 1, so the bytes were being written all along. "Nothing populates the
|
||||
> array" was treated as "nothing writes the byte". Those are different claims and
|
||||
> only the first one was established.
|
||||
>
|
||||
> Seasons therefore does not refuse because its gate byte is false. Its gate byte is
|
||||
> true. The mechanism is still unknown and needs a fresh diagnosis. Everything below
|
||||
> the correction is kept as the record of a wrong turn, not as a plan.
|
||||
|
||||
Written 2026-08-05, after reversing `FutGetSettingsServerResponse` end to end.
|
||||
Nothing here has been in front of the game yet. The code default is `off`, which
|
||||
serves the exact historical `{"configs": []}`, so the tree is currently at the
|
||||
proven baseline and this test is opt-in.
|
||||
|
||||
Full schema, atom ids, gate bytes and accessor addresses are in `ENDPOINT_MAP.md`
|
||||
under `FutGetSettingsServerResponse`. This file is only the experiment.
|
||||
|
||||
---
|
||||
|
||||
## 1. The claim being tested
|
||||
|
||||
`GET /settings` is requested 11 times a session and has always been answered with
|
||||
an empty array. The array is not decoration:
|
||||
|
||||
- Each element is `{"type": "<name>", "value": <scalar>}`. The client hashes the
|
||||
**string value** of `type` through the atom hasher and switches on it, 42 arms
|
||||
wide, so a flag is a row rather than a key.
|
||||
- `FUN_18011dc50` is the **only** writer of the `IS_*` UI gate bytes inside
|
||||
`FutDataManagerImpl`, and every line of it is `byte = (field == 1)`.
|
||||
- The `FutDataManagerImpl` constructor never touches those bytes. The whole
|
||||
16620-char decompile was scanned for the block; it is absent.
|
||||
|
||||
So `IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` have never been set
|
||||
to true by anything, on any run, in the whole history of this project.
|
||||
|
||||
That is a mechanism for the standing bug in which **Seasons refuses while making
|
||||
zero requests to any of the four servers.** No response shape could ever explain
|
||||
that. A UI key evaluated from a byte nobody wrote does.
|
||||
|
||||
**The store is the control that makes this readable.** `IS_STORE_ENABLED` is the
|
||||
same kind of byte read the same way, and the store screen works. It works because
|
||||
`storeEnabled` and its siblings already reach the client through the **Blaze**
|
||||
client-config store (`FUT_RS4_CONFIG`). That list contains no seasons flag, no
|
||||
draft flag, no tournament flag. Same mechanism, one populated, one blank.
|
||||
|
||||
This is a hypothesis with a mechanism, not a demonstrated cause.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pre-flight, from the terminal, costs nothing
|
||||
|
||||
```bash
|
||||
cd fifa17-recon/tools
|
||||
FUT_SETTINGS=gates python3 check_settings_flags.py # expect: 14 rows, PASS
|
||||
python3 check_settings_flags.py # expect: mode=off, PASS
|
||||
```
|
||||
|
||||
The checker asserts every shipped flag name against **both** the atom table and
|
||||
the recovered switch arms. Both are needed: `enableSquadBuildingSetsFeature` is a
|
||||
genuine atom with no arm in this switch, so the atom table alone would wave
|
||||
through a flag that does nothing. A misnamed flag is silently inert and looks
|
||||
exactly like a failed fix, which is the failure mode this guards.
|
||||
|
||||
---
|
||||
|
||||
## 3. The run
|
||||
|
||||
Budget: **one launch.**
|
||||
|
||||
```bash
|
||||
cd fifa17-recon/tools
|
||||
FUT_SETTINGS=gates ./openfut-fut.sh start
|
||||
~/Desktop/launch-fifa17.sh
|
||||
```
|
||||
|
||||
Then, in order, and write down what each one does:
|
||||
|
||||
1. **Store.** Open it. This is the control and it goes first, because if
|
||||
populating the array broke the store then the applier demonstrably ran and
|
||||
everything after this reads differently.
|
||||
2. **Transfer list capacity.** Transfers → Transfer List. Read the capacity
|
||||
number. We send `maximumTradePileSize = 77`, a number FUT would never choose
|
||||
on its own.
|
||||
3. **Seasons.** Single-player Seasons, the exact path that has been refusing.
|
||||
4. **FUT Draft.** Both the offline and online entries.
|
||||
5. **Tournaments**, for `tournamentQuitEnabled`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reading the result
|
||||
|
||||
The control in step 2 is what makes a negative result informative, so read it
|
||||
before concluding anything about steps 3 to 5.
|
||||
|
||||
| Store (1) | Capacity (2) | Seasons (3) | Reading |
|
||||
|---|---|---|---|
|
||||
| works | **77** | opens | Confirmed. The gate was the empty array. Make `gates` the default and move to the `/match` shape, which has been blocked behind this. |
|
||||
| works | **77** | still refuses | The array reached the consumer and the flag was applied, so the gate is **upstream of the UI key**. The settings line is then dead for Seasons and the next move is a live probe of the refusal path, not more response work. This is a real result, not a null one. |
|
||||
| works | not 77 | still refuses | The array never reached the consumer at all. Everything above is untested rather than refuted. Suspect the massinfo `settings` member (the deser's other caller) is what the client actually reads, and check which of the two paths fires in `/tmp/utas.log`. |
|
||||
| **breaks** | any | any | The applier ran and re-asserting the store flags did not hold them. Fall back to `FUT_SETTINGS=keep`, which sends only the already-working flags plus the control. If `keep` also breaks the store, populating the array is harmful in itself and the whole approach is wrong. |
|
||||
|
||||
`keep` exists precisely so that "populating the array at all" and "the new gates"
|
||||
can be separated without guessing, and it costs one restart to use.
|
||||
|
||||
---
|
||||
|
||||
## 5. What would make this whole plan wrong
|
||||
|
||||
**The gate might not be a UI key at all.** Seasons could be refusing on an
|
||||
entitlement, a persona attribute, or a Blaze session property evaluated inside
|
||||
the Denuvo-packed executable, in which case no `/settings` body reaches it. The
|
||||
step-2 control is what tells these apart: it distinguishes "the flag did not
|
||||
help" from "the array was never consumed", and no boolean flag can do that alone.
|
||||
|
||||
**The store control could be weaker than it looks.** The argument assumes
|
||||
`IS_STORE_ENABLED` currently comes from the Blaze store rather than from a
|
||||
default. If it turns out the store screen does not read that key at all, then it
|
||||
is not a control for anything and the reasoning in §1 loses its anchor.
|
||||
|
||||
**Draft has a second known suspect.** `GET ut/%s/squad/mode/draft/state` is still
|
||||
answered by the generic `/squad` handler with a full active-squad object, which
|
||||
is a textbook type-desync candidate. If Draft still fails while Seasons opens,
|
||||
that route is the next thing to look at, not the flag.
|
||||
|
||||
**A negative result here is worth having.** The settings array has been the
|
||||
standing suspect for the greyed-out entry points for two rounds without anyone
|
||||
sending a single flag. Ruling it out costs one launch and removes it from the
|
||||
backlog permanently.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,369 @@
|
||||
# The refusing modes: Seasons, Draft, SBC/Objectives, Tournaments — where the greying is decided
|
||||
|
||||
Written 2026-08-06. One reconnaissance pass over the live gate-byte block and the
|
||||
six `/hub` mode sub-deserializers, then four parallel per-mode investigations
|
||||
(Seasons, Draft, SBC+Objectives, Tournaments), each followed by an independent
|
||||
adversarial verification round. FIFA 17 was running throughout as **pid 24653**,
|
||||
sitting at the FUT hub, and was read strictly read-only. No server was restarted,
|
||||
no server code was changed, no memory was poked, and FIFA was never launched or
|
||||
killed.
|
||||
|
||||
Slide for every live read: `live = static - 0x180000000 + 0x6ffffc140000`, i.e.
|
||||
slide `0x6ffe7c140000`, re-derived from `/proc/24653/maps` and proved by
|
||||
`tools/gate_byte_probe.py` reporting **CONTROL FNV MATCH** against the FNV hasher
|
||||
prologue at `0x180180d00`. CardsDLL is mapped from `/mnt/games/FIFA 17/
|
||||
CardsDLL_Win64_retail.dll`; the on-disk copy read with `objdump` is
|
||||
`/tmp/fut/cardsdll.dll`, image base `0x180000000`. Every address below is
|
||||
live-verified.
|
||||
|
||||
This document answers one question the brief posed: is the refusal of these four
|
||||
mode families decided by a **server-reachable input we are failing to send** (a hub
|
||||
mode sub-object, a massinfo member, a settings/config field, or a dedicated
|
||||
endpoint), **or** is it decided in the **Denuvo-packed FIFA17.exe / Frostbite
|
||||
front-end** with no server surface at all?
|
||||
|
||||
---
|
||||
|
||||
## 1. Headline — final verdicts (after adversarial verify)
|
||||
|
||||
Every mode was independently re-derived by a second agent that attempted to refute
|
||||
the first. **All four refutations failed. All four verdicts stand.**
|
||||
|
||||
| Mode | Atoms | Final verdict | Confidence | Verify |
|
||||
|---|---|---|---|---|
|
||||
| **FUT Seasons** (offline + online + friendly) | `friendlySeason 0x131`, `offlineSeason 0x1ec`, `onlineSeason 0x1f6` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME) |
|
||||
| **FUT Draft** (offline + online) | `draftSummary 0xe4` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME), strengthened |
|
||||
| **SBC + Objectives** | `objectivesForCurrentUser 0x1e3`, `allObjectivesForCurrentGameSpaceId 0x15` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME), prior chain corrected |
|
||||
| **FUT Tournaments** | `tournament 0x328`, `tournamentProgress 0x32c` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME) |
|
||||
|
||||
**There is no server fix for any of the four.** Every server-reachable input that
|
||||
touches these modes is either cosmetic (a hub stat list feeding a caption/count),
|
||||
an *output* value the client emits and never branches on, or a settings byte that
|
||||
is **already live=1** while the tile stays greyed. The decision lives in the packed
|
||||
front-end. This is the same shape as the transfer-market finding of the same day —
|
||||
except there the switch (`userInfo.feature.trade`) was ours to flip; here **no such
|
||||
switch exists on the wire.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Ground truth
|
||||
|
||||
### The named gate-byte block (`FutDataManagerImpl`, live pid 24653)
|
||||
|
||||
Names were resolved by finding the config serializer at `0x18006ccd0`, which pairs
|
||||
each `IS_*_ENABLED` string key (`.rdata 0x1801fc118..`) with a getter vtable slot,
|
||||
then decoding each slot's accessor stub (`0f b6 81 <disp32> c3`) to its model
|
||||
displacement. All values read live, slide-proven.
|
||||
|
||||
| Name | Displacement / slot | Live value |
|
||||
|---|---|---|
|
||||
| (unnamed) | `+0x1fd24` | 0 |
|
||||
| (unnamed) | `+0x1fd2c` | 1 |
|
||||
| (unnamed) | `+0x1fd2d` | 1 |
|
||||
| **IS_TRADING_ENABLED** | `+0x1fd2e` (slot+0x270) | 1 |
|
||||
| (unnamed) | `+0x1fd30` | 1 |
|
||||
| (unnamed) | `+0x1fd37` | 1 |
|
||||
| **IS_FRIENDLY_SEASON_ENABLED** | `+0x1fd3a` (slot+0x2b0) | 1 |
|
||||
| **IS_TOURNAMENT_QUIT_ENABLED** | `+0x1fd3b` (slot+0x2b8) | 1 |
|
||||
| **IS_PROCESSING_STATE_ENABLED** | `+0x1fd3c` (slot+0x2c0) | 1 |
|
||||
| **IS_DRAFT_MODE_ENABLED** | `+0x1fd3d` (slot+0x2c8) | 1 |
|
||||
| (unnamed) | `+0x1fd3e` (offline-draft-enable) | 1 |
|
||||
| **IS_STORY_MODE_REWARD_ENABLED** | `+0x1fd3f` (slot+0x2d8) | 1 |
|
||||
| **IS_RETURNING_USER_REWARDS_SCREEN_ENABLED** | `+0x1fd40` (slot+0x2f0) | 0 |
|
||||
| (unnamed) | `+0x1fd41` | 0 |
|
||||
| (unnamed) | `+0x1fd42` (allowGracePeriod, SBC) | 0 |
|
||||
| (unnamed) | `+0x1fd43` | 0 |
|
||||
| **objectives-enable** (corrected — see §5.3) | `+0x1fd44` | 1 |
|
||||
| **packOpeningAnimation** | `+0x1fd45` | 1 |
|
||||
| (unnamed) | `+0x1fd46` | 1 |
|
||||
| (unnamed) | `+0x1fd47` | 0 |
|
||||
| (unnamed) | `+0x1fd48` | 1 |
|
||||
| **IS_STORE_ENABLED** | computed getter slot+0x280 @`0x18011c600` (not a byte field) | (computed) |
|
||||
|
||||
Every named gate byte that governs a **refusing** mode reads **ENABLED=1** live.
|
||||
The only `0`-valued `*_ENABLED` byte, `IS_RETURNING_USER_REWARDS_SCREEN_ENABLED`,
|
||||
does not gate any of the four mode families. This re-confirms the brief's prior
|
||||
ground truth: the gate-byte layer does **not** explain the refusals.
|
||||
|
||||
### The six `/hub` mode sub-deserializers (`/hub` parser = `FUN_180139610`)
|
||||
|
||||
The hub parser reads 18 atoms via a running-sum sub/dec ladder; six dispatch to the
|
||||
refusing modes. Each nested sub-deser was read in full. **None carries an
|
||||
enable/available/unlocked boolean.**
|
||||
|
||||
| Atom | Name | Sub-deser VA | Fields (all cosmetic/data) |
|
||||
|---|---|---|---|
|
||||
| `0x131` | friendlySeason | `0x1801392a0` | creationTime, dataVersion, opponentPersonaId, opponentUserPoints, round, seasonId, userPoints, defId (8 ints) |
|
||||
| `0x1ec` | offlineSeason | `0x18013c3a0` | divisionId, gamesPlayed, points, progressDataVersion, totalGames (strings) |
|
||||
| `0x1f6` | onlineSeason | `0x18013c3a0` (shared) | divisionId, gamesPlayed, points, progressDataVersion, totalGames (strings) |
|
||||
| `0xe4` | draftSummary | `0x180138d60` | draftState (str-enum), gamesWon (int) |
|
||||
| `0x328` | tournament | `0x18013dc00` | id, assetName, imageFormat, silhouetteName, timeUntilEnd, tournamentType, AMATEUR, live_offline, offerState (display) |
|
||||
| `0x32c` | tournamentProgress | `0x18013df20` | data, tutorialClientData (free-form std::map) |
|
||||
|
||||
The recurring trap: several of these desers write a per-field byte
|
||||
(`offline/onlineSeason` `[r14+0xa]=1`; `tournament` `[rdi+0x162]=1`) that an early
|
||||
naive pass could mistake for a JSON enable flag. Every such write is a
|
||||
**parser-local "field present" marker**, written identically for every field —
|
||||
**not** a JSON-sourced availability input. This is the same class of mistake that
|
||||
made `hub.tradePile` look like a gate before it was shown to be a mere count.
|
||||
|
||||
---
|
||||
|
||||
## 3. FUT Seasons — NOT_SERVER_REACHABLE (HIGH)
|
||||
|
||||
**Atoms:** `friendlySeason 0x131`, `offlineSeason 0x1ec`, `onlineSeason 0x1f6`.
|
||||
**Gate byte:** `IS_FRIENDLY_SEASON_ENABLED +0x1fd3a`, live=1.
|
||||
|
||||
**Evidence chain.** The decisive site is the gate byte `+0x1fd3a`. A whole-`.text`
|
||||
grep finds **exactly two** references:
|
||||
|
||||
- **Writer** `0x18011dd2d`: `mov byte[rdi+0x1fd3a],al` inside settings applier
|
||||
`FUN_18011dc50`, preceded by `cmp dword[rbx+0x58],1 / sete al` — the byte is
|
||||
`(settings.field+0x58 == 1)`, sourced from config key `friendlySeasonsEnabled`.
|
||||
This is the **only** writer.
|
||||
- **Reader** `0x18011c500`: `movzx eax,byte[rcx+0x1fd3a]; ret` — a standalone
|
||||
vtable getter stub (slot+0x2b0). Its absolute address appears in the file exactly
|
||||
once, at the vtable, and grep finds **no** call/jmp to `0x18011c500` anywhere in
|
||||
CardsDLL `.text`. Its only consumer is the packed FIFA17.exe front-end via vtable
|
||||
dispatch.
|
||||
|
||||
The one server-writable input (`friendlySeasonsEnabled → +0x1fd3a`) is **already 1
|
||||
live**, and the tile is still greyed — so the front-end does not gate on this byte
|
||||
alone; it reads additional non-server state.
|
||||
|
||||
- **Hub sub-objects** carry no enable flag. `offline/onlineSeason` share deser
|
||||
`0x18013c3a0`, which FNV-hashes string keys and for each stores a division/games/
|
||||
points/version stat; `friendlySeason 0x1801392a0` is 8 numeric stats. The
|
||||
`[r14+0xa]=1` write is the "field present" marker. These feed a caption/count.
|
||||
- **Settings/massinfo:** `friendlySeasonsEnabled` is the sole season key the applier
|
||||
consumes → `+0x1fd3a`, already covered. No massinfo member carries a season enable.
|
||||
There is **no** `onlineSeasonEnabled`/`offlineSeasonEnabled` config key or gate
|
||||
byte anywhere in the DLL — verify enumerated all 24 gate-region getter stubs and
|
||||
the only season getter is `+0x1fd3a`.
|
||||
- **Dedicated endpoint:** `/season` and `/season/user` routes exist in
|
||||
`utas_server.py` (guarded by `FUT_MODES`) but the client has **never** requested
|
||||
them — 0 season hits across `captures/`, 486 real ProtoHttp requests over ~30
|
||||
boots, none for `/season`. And the tile greys at hub load, *before* any `/season`
|
||||
request could fire.
|
||||
- **Front-end:** the only season-enable identifiers in the whole DLL are the config
|
||||
*input* `friendlySeasonsEnabled` and the *output* getter name
|
||||
`IS_FRIENDLY_SEASON_ENABLED`. The viewmodel names
|
||||
(`futonlineseasonsviewmodel`, `futofflineseasonsviewmodel`,
|
||||
`futfriendlyseasons*viewmodel`) live in the Denuvo-packed FIFA17.exe.
|
||||
|
||||
**Authority boundary.** `friendlySeasonsEnabled` is a **server-writable input**,
|
||||
but it is already at ENABLED with its only reader **off-DLL (client)**. Offline/
|
||||
online seasons have **no server surface at all** — no config key, no gate byte, no
|
||||
getter. The grey/refuse decision is **client-side**.
|
||||
|
||||
---
|
||||
|
||||
## 4. FUT Draft — NOT_SERVER_REACHABLE (HIGH, strengthened by verify)
|
||||
|
||||
**Atoms:** `draftSummary 0xe4`. **Gate byte:** `IS_DRAFT_MODE_ENABLED +0x1fd3d`,
|
||||
live=1.
|
||||
|
||||
**Evidence chain.** Cross-ref of displacement `0x1fd3d` returns exactly two real
|
||||
sites (a `lea` to `0x1801fd3d8` and an instruction at address `0x18011fd3d` are
|
||||
coincidental, not xrefs):
|
||||
|
||||
- **Accessor stub** `0x18011c4b0`: `movzx eax,[rcx+0x1fd3d]; ret` (getter vtable
|
||||
`.rdata 0x18021c568`).
|
||||
- **Writer** `0x18011dd5a`: `mov [rdi+0x1fd3d],al` in applier `FUN_18011dc50`,
|
||||
`al = (settings[rbx+0x5c]==1)` = parsed `enableDraftMode`.
|
||||
|
||||
There is **no cmp/test/branch** on this byte anywhere. Its only CardsDLL consumer
|
||||
is the config serializer `0x18006ccd0`, which walks the `IS_*_ENABLED` key table and
|
||||
`call [rax+0x2c8]` to **emit** the value outward. So `IS_DRAFT_MODE_ENABLED` is an
|
||||
**output the client serializes, not an input any logic branches on.**
|
||||
|
||||
**The verifier strengthened this** by finding a consumer the first pass missed: a
|
||||
flux "DESTINATION" navigation emitter around `0x1800b2700`. At `0x1800b2711` it
|
||||
loads getter slot `+0x2c8` (draft-enable, `+0x1fd3d`) into `sil` and slot `+0x2d0`
|
||||
(offline-draft-enable, `+0x1fd3e`) into `[rsp+0x21]`. All six `GOTO_DRAFT_DISABLED`
|
||||
emit sites (`0x1800b2cb2`, `0x1800b2dc9`, `0x1800b333b/347`, `0x1800b349f/4a7`) are
|
||||
guarded by `test sil,sil` / `cmp [rsp+0x21],0` and route to `GOTO_DRAFT_DISABLED`
|
||||
**only when those bytes are 0**, else to `GOTO_DRAFT_OFFLINE/ONLINE`. Both bytes are
|
||||
**live=1**, so this emitter — the closest thing to a nav decision inside CardsDLL —
|
||||
already produces the ENABLED destinations, yet the tile is still greyed.
|
||||
|
||||
- **Hub sub-object** `draftSummary 0xe4`, member deser `0x180138d60`: exactly two
|
||||
atoms — `draftState 0xe3` (STRING → enum decoder `0x180138cc0`, a resume-state
|
||||
enum: INVALID + 2..8) and `gamesWon 0x13a` (INT). Wrapper `0x18013980c` loops
|
||||
`ONLINE 0x1f1` / `SINGLE_PLAYER 0x29d`, each → `0x180138d60`. No enable atom;
|
||||
`draftState` is the continue-state read after entry, not a tile gate.
|
||||
- **Settings/massinfo:** atoms `enableDraftMode 0xf9` / `enableOfflineDraftMode
|
||||
0xfa` / `enableSinglePlayerDraftMode 0xff` land on sibling emit-only bytes
|
||||
`+0x1fd3d`/`+0x1fd3e`/`+0x1fd3c` via the same applier — none branched on.
|
||||
- **Dedicated endpoints:** `GET /squad/mode/draft/state` (deser `0x180147070`) and
|
||||
`POST /purchase/mode/N/draft` (deser `0x18014c260`) are already routed in utas —
|
||||
but these are the **post-click** entry/session flow (render the draft screen, buy
|
||||
entry *after* the tile is pressed), not a tile-availability query.
|
||||
- **Front-end:** token strings (`USER_HAVE_DRAFT_TOKENS 0x1802055f8`,
|
||||
`GOTO_DRAFT_DISABLED 0x180209aa8`, etc.) are bare key-name `lea` emitters with no
|
||||
greying branch. Decision is in the packed FIFA17.exe.
|
||||
|
||||
**Authority boundary.** The two server-writable inputs (`enableDraftMode`,
|
||||
`enableOfflineDraftMode`) are **already at their enabled value**, and **every**
|
||||
CardsDLL consumer of them (config serializer *and* the navigation emitter) already
|
||||
treats draft as enabled. The persistent greying is decided **client-side** on
|
||||
non-server state.
|
||||
|
||||
---
|
||||
|
||||
## 5. SBC + Objectives — NOT_SERVER_REACHABLE (HIGH, prior chain corrected)
|
||||
|
||||
**Atoms:** `objectivesForCurrentUser 0x1e3`, `allObjectivesForCurrentGameSpaceId
|
||||
0x15`. **No `IS_OBJECTIVES`/`IS_SBC` gate-byte name exists** — the task premise that
|
||||
these are governed by no named `FutDataManagerImpl` gate byte is confirmed.
|
||||
|
||||
### 5.1 Hub sub-object = cosmetic list
|
||||
|
||||
In `FUN_180139610` both objectives atoms share one arm: `objectivesForCurrentUser
|
||||
0x1e3` (`0x180139794`) and `allObjectivesForCurrentGameSpaceId 0x15`
|
||||
(`0x1801397ad`) both jump to `0x1801398fe`, guarded by the parser-local marker
|
||||
`cmp BYTE [rsp+0x21],0x1`, calling sub-deser `0x18013a7f0`. That deser parses a
|
||||
nested `objectives 0x1e2` **array** of records (element parser `0x18006c9b0`) with
|
||||
**no** enabled/available/unlocked atom — it feeds the "MANAGER TASKS N/M" tile
|
||||
count/caption, the same cosmetic class as `hub.tradePile`.
|
||||
|
||||
### 5.2 No dedicated endpoint at the hub
|
||||
|
||||
The live log across 26+ hub sessions shows the client requests only `/hub` and
|
||||
`/settings`; it **never** calls `/sbs/*` (grep count 0) or any `/objectives`
|
||||
endpoint. `utas_server.py` has no `/sbs` route. No `FutGetObjectivesServerResponse`
|
||||
class exists — objectives are **ManagerQuests**, client-driven. The `sbs/*` structs
|
||||
that exist serve challenge **content after entry**, never polled at the hub.
|
||||
|
||||
### 5.3 The correction (verify fixed the first pass's chain)
|
||||
|
||||
The first pass mis-traced objectives to settings field `[0x1c]` → model `+0x1fd28`
|
||||
(default 60). **The verifier re-derived the settings jump table (dispatch
|
||||
`0x18013ca1e`, byte-idx `0x18013ced4`, jtbl `0x18013ce90`) and found the truth:**
|
||||
|
||||
- `enableObjectives 0xfd` **and** `enableObjectivesAsManagerTasks 0xfe` route to
|
||||
handler `0x18013cabd` = clear-only-on-zero into settings field `[0x70]`; applier
|
||||
`0x18011ddc7` (`cmp [rbx+0x70],1; sete al; mov [rdi+0x1fd44],al`) maps it to model
|
||||
gate byte **`+0x1fd44`** — which is **inside** the named gate block (not outside,
|
||||
as the first pass claimed), reads **1 (ENABLED) live**, and has exactly one reader
|
||||
DLL-wide: a getter stub `0x18011c570` returning the byte to the front-end with no
|
||||
internal gating use.
|
||||
- The first pass's `+0x1fd28` (default 60) is actually
|
||||
`squadBuildingSetsGracePeriodMinutes 0x2d0`, a numeric grace-period param —
|
||||
behavioral, not availability.
|
||||
- **SBC side:** `enableSquadBuildingSetsFeature 0x100` falls in the dispatch **gap**
|
||||
(`0x100-0x18=0xe8 > 0xe7 → DEFAULT/no handler`), as do `squadBuildingSetsClientData
|
||||
0x2cf` and `squadChallenge 0x2d1`. Only numeric SBC params have handlers
|
||||
(`allowGracePeriod 0x18 → +0x1fd42`, `allowUntradeable 0x19 → +0x206f8`,
|
||||
`gracePeriodMinutes 0x2d0 → [0x1c]/+0x1fd28`). **No SBC availability model byte
|
||||
exists.**
|
||||
|
||||
So the single server-controllable objectives-enable input (`+0x1fd44`) is already at
|
||||
1 yet the tile refuses, and SBC has **no** server enable surface whatsoever.
|
||||
|
||||
**Authority boundary.** Objectives-enable is a **server-writable byte already ON**,
|
||||
read only by the **client**. SBC availability has **no server surface** — its enable
|
||||
key is in the settings dispatch gap and lands on no byte. Decision is **client-side**
|
||||
(`futmanagerquestsviewmodel`; providers `FUT_MQ_QUESTS_DATA_DP` /
|
||||
`FUT_SQUAD_QUESTS_DP`) in the packed FIFA17.exe.
|
||||
|
||||
---
|
||||
|
||||
## 6. FUT Tournaments — NOT_SERVER_REACHABLE (HIGH)
|
||||
|
||||
**Atoms:** `tournament 0x328`, `tournamentProgress 0x32c`. **Gate byte:**
|
||||
`IS_TOURNAMENT_QUIT_ENABLED +0x1fd3b`, live=1 — but this governs **quitting** a
|
||||
tournament, not tile availability, and no `tournamentEnabled` atom exists in
|
||||
`docs/fut_atoms.tsv`.
|
||||
|
||||
**Evidence chain.**
|
||||
|
||||
- **Hub sub-objects, both cosmetic.** `tournament 0x328` deser `0x18013dc00` writes
|
||||
only display fields: id `[rdi+0x150]`, round `[rdi+0x160]`, timeUntilEnd
|
||||
`[rdi+0x158]`, silhouette-int `[rdi+0x15c]`, string blobs `[rdi]`/`[rdi+0xa8]`
|
||||
(assetName/silhouette/type), an `imageFormat=="dds"` render bool `[rdi+0x163]`
|
||||
(strcmp vs `.rdata 0x180219400`), and a `tournamentType` enum `[rdi+0x154]`
|
||||
decoded to `live_offline 0x195`/`live_online 0x196`/`offline 0x1e8`/`online 0x1f0`
|
||||
— a categorization, not availability. The `[rdi+0x162]=1` write is a
|
||||
record-completeness marker (all core fields present), not a JSON enable.
|
||||
`tournamentProgress 0x32c` deser `0x18013df20` builds a std::map (ctor
|
||||
`0x1801e5210`) of string keys `data 0xc9` / `tutorialClientData ~0x353` — free-form
|
||||
clientData, no enable atom. (The earlier `0x28a = returningUserRewardsScreenEnabled`
|
||||
label was a running-sum mis-decode; the true sum is `0xc9+0x28a=0x353
|
||||
tutorialClientData`.)
|
||||
- **Massinfo/settings.** `tournamentCoins 809 → +0x30` and `teamOfTournamentWinner
|
||||
776 (bool) → +0x34` appear only in the **FutDestroyMatch** reward deser
|
||||
`0x180121b60` — a match payout reached only *after* you are inside a tournament
|
||||
match; a reward count/trophy flag, not a tile gate. The settings applier switch
|
||||
`0x18013c6d0` has 42 arms; the only tournament arm is `tournamentQuitEnabled 0x32D
|
||||
→ +0x1fd3b` (quit, live=1).
|
||||
- **Gate byte** `+0x1fd3b`: getter stub `0x18011c660` is the vtable **emit**
|
||||
accessor the config serializer `0x18006ccd0` pairs with the JSON key to write it
|
||||
out — the client emits it, does not read it as a server input. Writer
|
||||
`0x18011dd3d`, `al = sete(cmp settings[rbx+off],1)`, defaults to 1. Already 1,
|
||||
wrong feature.
|
||||
- **Dedicated endpoint, never called.** `tournament_list` (deser `0x180169ef0`) and
|
||||
`tournament_user` (deser `0x180147cb0`) exist in `utas_server.py` but grep over
|
||||
`captures/` and the live `/tmp/utas_server.log` (3224 lines) finds **zero**
|
||||
ProtoHttp requests for any `/tournament` path across all boots — same as `/season`.
|
||||
The responses are never consumed.
|
||||
- **Front-end.** No CardsDLL response deserializer writes any "tournament
|
||||
available/unlocked" field. `eligibilities 0xf1` / `unlocks 0x35c` / `available 0x3e`
|
||||
are SBC/store vocab per `docs/ENDPOINT_MAP.md`, not wired to tournaments. Decision
|
||||
is in the packed FIFA17.exe.
|
||||
|
||||
**Authority boundary.** The only server-touchable tournament byte
|
||||
(`IS_TOURNAMENT_QUIT_ENABLED`) is an **emitted output** governing a different
|
||||
feature, already 1. Everything else is cosmetic hub data or post-entry reward data.
|
||||
Tile availability is decided **client-side**.
|
||||
|
||||
---
|
||||
|
||||
## 7. What changed vs the prior conclusion
|
||||
|
||||
The prior workflow examined **only the `FutDataManagerImpl` gate bytes** and
|
||||
concluded "no server fix" for these modes. This workflow re-opened the question by
|
||||
chasing the **hub-atom lead** — the six mode sub-deserializers we do not currently
|
||||
populate — plus massinfo members, settings arms, and dedicated endpoints.
|
||||
|
||||
**The hub-atom lead does not change the conclusion for any mode.** Per mode:
|
||||
|
||||
- **Seasons:** the hub `friendlySeason`/`offline`/`onlineSeason` sub-objects are
|
||||
numeric stat blobs (division/games/points), cosmetic like `hub.tradePile`. The
|
||||
`[r14+0xa]=1` byte is a "field present" marker, not a JSON enable. No change —
|
||||
still NOT_SERVER_REACHABLE.
|
||||
- **Draft:** `draftSummary` carries only `draftState`+`gamesWon`; verify additionally
|
||||
found the in-DLL navigation emitter already routes to the *enabled* destination on
|
||||
current live state. No change — verdict **strengthened**.
|
||||
- **SBC/Objectives:** the objectives hub arm is a cosmetic list feeding "MANAGER
|
||||
TASKS N/M". Verify *corrected the prior chain* — the real objectives-enable byte is
|
||||
`+0x1fd44` (inside the gate block, live=1), and SBC's enable key falls in a
|
||||
dispatch gap with no byte at all. No change to the verdict; the correction only
|
||||
hardens it.
|
||||
- **Tournaments:** both hub sub-objects are display/clientData only. No change.
|
||||
|
||||
**Net:** examining the hub atoms was the right next step, and it closed the lead
|
||||
rather than opening a fix. Every server-reachable surface for these four modes is
|
||||
now accounted for and none is an availability input. The prior "no server fix"
|
||||
conclusion holds, now on much broader evidence.
|
||||
|
||||
---
|
||||
|
||||
## 8. Client-vs-server authority boundaries (explicit)
|
||||
|
||||
| Surface | Who writes it | Who reads it | Is it a mode-availability gate? |
|
||||
|---|---|---|---|
|
||||
| Gate bytes `+0x1fd3a/3b/3d/44` etc. | **server** (settings applier `FUN_18011dc50`) | **client** (getter stubs, off-DLL vtable dispatch) + config serializer `0x18006ccd0` (emit) | No — all live=1, never branched on inside CardsDLL |
|
||||
| Hub mode sub-objects (`0x131/1ec/1f6/e4/328/32c`) | **server** (`/hub` body) | CardsDLL parsers → cosmetic captions/counts | No — no enable atom in any of the six desers |
|
||||
| `[r14+0xa]=1`, `[rdi+0x162]=1`, `[rsp+0x21]==1` markers | CardsDLL parser (local) | same parser | No — "field present" bookkeeping, never JSON-sourced |
|
||||
| Settings config keys (`friendlySeasonsEnabled`, `enableDraftMode`, `enableObjectives`, `tournamentQuitEnabled`) | **server** (`/settings`) | applier → gate bytes → **client** | No — inputs already at enabled; readers are off-DLL |
|
||||
| SBC enable (`enableSquadBuildingSetsFeature 0x100`) | — | — | **No surface** — falls in the settings dispatch gap, lands on no byte |
|
||||
| Offline/online season enable | — | — | **No surface** — no config key, no gate byte, no getter |
|
||||
| `/season`, `/tournament`, `/sbs/*` endpoints | server (utas, routed) | never requested at hub | No — client never polls them; tile greys before any request |
|
||||
| DestroyMatch reward fields (`tournamentCoins`, `teamOfTournamentWinner`) | server (post-match) | reward payout | No — reached only inside a match |
|
||||
| The greying/refusal decision itself | — | **client** (Denuvo-packed FIFA17.exe / Frostbite viewmodels) | **This is the gate — and it has no server surface** |
|
||||
|
||||
The single load-bearing fact across all four modes: **every server-writable enable
|
||||
input that exists is already at ENABLED live, its only reader is the client, and the
|
||||
tile refuses anyway.** No response body we can send flips a state the front-end has
|
||||
already decided.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
# FIFA 17 SBC client-hook implementation plan
|
||||
|
||||
## Outcome
|
||||
|
||||
Implement an opt-in, fail-closed hook that repairs the native response-to-deserializer
|
||||
dispatch for `GET /ut/game/fifa17/sbs/sets`. The hook must reuse the genuine response
|
||||
object and SAX reader from the real HTTP 200 transaction, run synchronously on the native
|
||||
transaction thread, and preserve the game's allocator, object ownership, callbacks, and
|
||||
index rebuilds.
|
||||
|
||||
This plan supersedes the intervention direction in `plan-2026-08-07-sbc-hook.md` and
|
||||
`sbc-hook-dll-spec.md` wherever those documents claim the client never issues `/sbs/sets`
|
||||
or recommend constructing a synthetic reader. The fresh 10:20:20 exchange proves the
|
||||
request is issued and receives populated JSON. The reconciliation report is authoritative.
|
||||
|
||||
## Proven anchors
|
||||
|
||||
All addresses are static VAs in `CardsDLL_Win64_retail.dll`, image base `0x180000000`.
|
||||
Runtime addresses are `CardsDLL base + (static VA - 0x180000000)`.
|
||||
|
||||
| Purpose | Address / identity |
|
||||
|---|---|
|
||||
| Category request constructor | `0x18017a7c0`, request vtable `0x18022e5c0`, tag `0x753c` |
|
||||
| `/sets` URI builder | `0x18017a980` |
|
||||
| Typed response factory | `0x18017aa10`, response vtable `0x18022e5b0` |
|
||||
| Typed category deserializer | `0x18017b2b0`, `rcx=response`, `rdx=genuine reader` |
|
||||
| Generic completion | `0x18016cca0`, exact-200 check at `0x18016cdd0` |
|
||||
| FUT root | `A = *0x1802e6398`, expected vtable `0x18021c2a0` |
|
||||
| SBC gate cache | `B=A+0x1f9d8`; ready byte `B+0x28` |
|
||||
| Category store | `M=*(A+0x20a68)`; count `WORD[M+0x50]` |
|
||||
| Renderer count read | `0x1800b5eda` |
|
||||
|
||||
Entering `0x18017b2b0` necessarily invokes the `A+0x20a68` lazy getter before JSON-key
|
||||
parsing. The fresh transaction left that pointer null, proving that the typed category
|
||||
deserializer was not entered.
|
||||
|
||||
## Architecture decision
|
||||
|
||||
Use the existing `openfut-hook` Rust `cdylib` and FIFA 17 feature boundary. Retain its
|
||||
deferred CardsDLL discovery, RVA calculation, guarded reads, default-off environment
|
||||
gates, and logging. Replace the stale Tier-1 idea of constructing a reader with this flow:
|
||||
|
||||
```text
|
||||
real /sbs/sets HTTP 200
|
||||
-> native generic completion and typed-response factory
|
||||
-> observe the real response object and real reader/body cursor
|
||||
-> at the proven skipped dispatch boundary, call the original typed method once
|
||||
-> native parser populates M and rebuilds its indices
|
||||
-> resume the native callback/completion chain
|
||||
-> validate M; use native gate state if available
|
||||
-> only if necessary, arm B+0x28 while B+0x08 remains zero
|
||||
```
|
||||
|
||||
Do not intercept at the socket layer, fabricate a SAX reader, retain response/reader
|
||||
pointers beyond their synchronous lifetime, hand-build EASTL category/set records, or
|
||||
write `B+0x08`/`B+0x20`.
|
||||
|
||||
## State and feature gates
|
||||
|
||||
Use independent flags; no stronger stage should be implied by a weaker one:
|
||||
|
||||
- `OPENFUT_SBC_HOOK=1`: resolve and fingerprint only.
|
||||
- `OPENFUT_SBC_TRACE=1`: install passive probes and structured logging.
|
||||
- `OPENFUT_SBC_DISPATCH=1`: enable the one-shot native dispatch repair.
|
||||
- `OPENFUT_SBC_COMMIT=1`: permit gate/refresh action after validated parse success.
|
||||
- Keep `OPENFUT_SBC_ARM_ONLY=1` solely as a separate negative-control experiment.
|
||||
|
||||
Represent runtime progress with an atomic state machine:
|
||||
|
||||
```text
|
||||
Disabled -> Resolved -> Intercepted -> Parsed -> Validated -> Committed
|
||||
\-> Failed
|
||||
```
|
||||
|
||||
Add a recursion-depth guard and a transaction one-shot keyed by request/response identity.
|
||||
Any fingerprint, pointer, status, class, thread, reader, or postcondition mismatch moves to
|
||||
`Failed` and resumes native execution without a write.
|
||||
|
||||
## Milestones
|
||||
|
||||
### M0 — reconcile and freeze the baseline
|
||||
|
||||
1. Mark the reconciliation report as the address/path authority.
|
||||
2. Record SHA-256, PE timestamp, `SizeOfImage`, and selected section hashes for the shipped
|
||||
CardsDLL, FIFA executable, built hook, and deployed proxy DLL.
|
||||
3. Preserve a known-good launcher and proxy DLL. Do not overwrite a game-directory DLL
|
||||
without an exact backup and hashes.
|
||||
4. Capture a baseline: FUT hub succeeds, `/sbs/sets` returns 200, SBC shows the modal,
|
||||
`M==0`, and the category deserializer is not observed.
|
||||
|
||||
Exit: the baseline is repeatable and its artifacts identify one binary build exactly.
|
||||
|
||||
### M1 — stabilize DLL loading
|
||||
|
||||
The existing `version.dll` injection has one historical successful log, but the current
|
||||
FIFA 17 launcher disables it after later crashes. Resolve this before SBC detours:
|
||||
|
||||
1. Port or implement the complete VERSION proxy export surface and forward every export.
|
||||
2. Build only `--features fifa17` for `x86_64-pc-windows-gnu` into a staging directory.
|
||||
3. Inspect PE architecture, exports, and imports with the MinGW binutils.
|
||||
4. Add a FIFA-17-specific launch path using the existing prefix/UMU configuration and
|
||||
explicit `WINEDLLOVERRIDES=version=n,b`.
|
||||
5. Run three cold launches with every SBC mutation/trace flag disabled.
|
||||
|
||||
Exit: all three launches reach the FUT hub, VERSION calls forward correctly, and disabling
|
||||
the override restores the pre-hook baseline.
|
||||
|
||||
### M2 — strengthen runtime resolution
|
||||
|
||||
Before any detour or byte write, validate:
|
||||
|
||||
- exact CardsDLL identity (`SizeOfImage`, PE metadata, and multiple section/function hashes);
|
||||
- FNV control bytes at `0x180180d00`;
|
||||
- expected bytes at every proposed patch site;
|
||||
- `A` and its expected vtable;
|
||||
- `B` and its expected vtable;
|
||||
- readable `M` slot and sane cache fields; and
|
||||
- that runtime VAs lie inside the expected CardsDLL sections.
|
||||
|
||||
Use the external read-only `futmem`/probe tooling as an independent oracle. Never cache an
|
||||
ASLR slide across launches.
|
||||
|
||||
Exit: resolve-only mode passes on two launches with different slides and aborts cleanly on
|
||||
a deliberately mismatched fingerprint fixture.
|
||||
|
||||
### M3 — passive transaction tracing
|
||||
|
||||
Instrument, without changing return values or state:
|
||||
|
||||
1. generic completion `0x18016cca0`;
|
||||
2. typed response factory `0x18017aa10`;
|
||||
3. typed category deserializer `0x18017b2b0`; and
|
||||
4. once found, the common body/SAX virtual-dispatch callsite.
|
||||
|
||||
Log a monotonic timestamp, session/build ID, thread ID, recursion depth, status, request
|
||||
pointer/vtable, response pointer/vtable, reader/body pointer and vtable, and `M`/`B`
|
||||
before and after. Correlate a request ordinal with `/tmp/utas.log`; do not log SID/auth
|
||||
values or full response bodies.
|
||||
|
||||
Do not use the existing generic four-register probe wrapper for `0x18016cca0`. That routine
|
||||
has a fifth stack argument. Use a relocated trampoline or a narrowly verified assembly
|
||||
stub that preserves the full Win64 ABI: nonvolatile GPRs, XMM6-XMM15 if touched, 32-byte
|
||||
shadow space, 16-byte call alignment, and all stack arguments. The diagnostic
|
||||
unhook/call/rehook mechanism is also racy and is not acceptable for the final repair.
|
||||
|
||||
Exit: one fresh exchange unambiguously identifies whether the factory is skipped, the typed
|
||||
object exists without a body/reader, or virtual deserialization dispatch is skipped.
|
||||
|
||||
### M4 — reverse the exact dispatch contract
|
||||
|
||||
Use M3 captures and static analysis to answer all of these before enabling intervention:
|
||||
|
||||
- the exact common body-to-response-deserializer callsite;
|
||||
- the relationship between response vtable `0x18022e5b0` slot `+0x08` and the older
|
||||
message-object vtable `0x18022e598` slot `+0x20`;
|
||||
- which completion argument or object field owns the genuine reader;
|
||||
- the reader's valid synchronous lifetime;
|
||||
- whether `0x1800b8c30` executes after a successful forced parse;
|
||||
- the native transaction/game thread identity; and
|
||||
- whether the parser can be reached more than once for one response.
|
||||
|
||||
Exit: a written call contract identifies the exact hook site, preserved instructions,
|
||||
original target, arguments, ownership, thread, and resume address.
|
||||
|
||||
### M5 — behavior-preserving detour
|
||||
|
||||
Install the production-form detour at the chosen boundary but initially tail-call the
|
||||
original path unchanged. Prefer a small audited trampoline abstraction over copying the
|
||||
repository's unhook/rehook diagnostic pattern.
|
||||
|
||||
Exit: exactly one balanced entry/exit is recorded per SBC exchange; HTTP traffic, modal,
|
||||
M/B state, timing, and unrelated FUT screens remain unchanged.
|
||||
|
||||
### M6 — guarded dispatch repair
|
||||
|
||||
On the native transaction thread and only while the genuine objects are live:
|
||||
|
||||
1. require request vtable `0x18022e5c0`, response vtable `0x18022e5b0`, and status 200;
|
||||
2. require a readable reader pointer/vtable and recursion depth zero;
|
||||
3. require that this transaction has not already been parsed;
|
||||
4. call the original typed method `0x18017b2b0(response, reader)` exactly once;
|
||||
5. capture its return and the resulting M state; and
|
||||
6. resume the native completion/callback path.
|
||||
|
||||
Never run this from the deferred worker or while the SBC controller is iterating. Do not
|
||||
attempt in-place memory repair after an exception or partial parse; preserve logs and
|
||||
relaunch FIFA.
|
||||
|
||||
Exit: the deserializer is observed once, returns successfully, and native execution
|
||||
continues without gate or refresh writes.
|
||||
|
||||
### M7 — validate and commit UI state
|
||||
|
||||
Before exposing populated data, require:
|
||||
|
||||
- `M != 0` and a bounded category count;
|
||||
- category vector `begin <= end <= capacity`;
|
||||
- `(end-begin) % 0xf0 == 0` and vector length equals `WORD[M+0x50]`;
|
||||
- sane, unique category/set identifiers and bounded nested counts;
|
||||
- all native index-rebuild/finalization calls observed; and
|
||||
- no duplicate parse or partial state.
|
||||
|
||||
First allow the native callback to arm the cache. If it does not, the only fallback is
|
||||
`BYTE[B+0x28]=1` while `B+0x08==0`; never write `B+0x08` or `B+0x20`. Initially require
|
||||
the user to close/reopen SBC for refresh. Do not synthesize Scaleform events until the
|
||||
signature and ownership contract of `0x1801a4a70` are independently proven.
|
||||
|
||||
Exit: no modal; displayed categories and set counts match the served response.
|
||||
|
||||
### M8 — regression, soak, and rollback proof
|
||||
|
||||
1. Open/close SBC ten times; enter every set/challenge and return.
|
||||
2. Verify a second `/sets` response is idempotent and does not duplicate data.
|
||||
3. Smoke-test hub, club, store, squads, and normal service traffic.
|
||||
4. Repeat from two fresh launches with different ASLR slides.
|
||||
5. Soak 30–60 minutes with navigation and, if supported, repeated FUT enter/exit.
|
||||
6. Disable all SBC flags and confirm the baseline behavior returns without detours/writes.
|
||||
7. Disable `WINEDLLOVERRIDES`, restore the exact backed-up proxy if needed, and prove hard
|
||||
rollback with FIFA closed.
|
||||
|
||||
Exit: zero crashes/freezes, stable counts and memory behavior, no unrelated FUT regression,
|
||||
and both soft and hard rollback are demonstrated.
|
||||
|
||||
## Testing and build checks
|
||||
|
||||
Run at minimum:
|
||||
|
||||
```text
|
||||
cargo fmt --check
|
||||
cargo test --features fifa17
|
||||
cargo check --release --features fifa17 --target x86_64-pc-windows-gnu
|
||||
cargo build --release --features fifa17 --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
Extract pure, host-testable helpers for RVA calculation, fingerprint comparison, state
|
||||
transitions, bounded vector validation, and structured event formatting. Windows calls,
|
||||
raw pointer reads, and patching should remain behind small interfaces so guard logic can be
|
||||
tested without launching FIFA.
|
||||
|
||||
## Stop conditions
|
||||
|
||||
Stop and roll back on any unknown binary fingerprint, patch-byte mismatch, wrong vtable,
|
||||
wrong thread, unexpected factory/deserializer count, recursion, invalid vector geometry,
|
||||
missing finalizer, partial parse, crash/freeze, unrelated FUT regression, or save/profile
|
||||
change. Preserve hook log, UTAS log, binary hashes, and crash evidence before relaunching.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- The hook is default-off and endpoint/class-specific.
|
||||
- Exact binary and patch-site fingerprints are verified before intervention.
|
||||
- The real category deserializer runs exactly once for each intended HTTP 200 response,
|
||||
using the genuine response and reader on their native thread.
|
||||
- `M` passes structural validation and the populated SBC menu supports drill-down.
|
||||
- No communication modal appears and non-SBC FUT behavior is unchanged.
|
||||
- Two fresh ASLR-distinct launches and the soak test pass.
|
||||
- Unsetting flags restores inert behavior; removing the proxy restores the original launch.
|
||||
@@ -0,0 +1,237 @@
|
||||
# SBC Menu Render Intervention — Plan (2026-08-07)
|
||||
|
||||
**STATUS (one line): YES, WITH CAVEATS — a populated SBC menu is achievable via a
|
||||
client-side hook, but ONLY by making the game's own parser fill its store; a
|
||||
/proc/mem byte poke alone can open the menu (negative control) but renders EMPTY, and
|
||||
the one remaining un-reversed item (the SAX input-source `vtable[+0x8]` byte-yield
|
||||
contract) blocks the fully-offline populate until a served /sbs/sets response or a
|
||||
completed reader is wired.**
|
||||
|
||||
All addresses are on-disk RVAs against CardsDLL image base `0x180000000`
|
||||
(`/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`, working copy `/tmp/fut/cardsdll.dll`).
|
||||
Live slide this session = `0x6ffe7c140000` (mapped base `0x6ffffc140000`), proven via
|
||||
FNV prologue at `0x180180d00`. Live values below are from read-only `/proc/12201/mem`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Definitive SBC data-flow
|
||||
|
||||
### Object graph
|
||||
- **A** = FUT root singleton = `*[0x1802e6398]`. Getter `0x18011a830`. A.vtable static
|
||||
`0x18021c2a0`. Live A = `0xb83e2b60` (vtable matches static — CONFIRMED).
|
||||
- **B** = SBC request/TTL gate cache = `A + 0x1f9d8`. B-getter = A.vtable[+0x4e8] =
|
||||
thunk `0x18011c1f0` (`lea rax,[rcx+0x1f9d8]; ret`). B.vtable static `0x1801fae70`
|
||||
(3 slots: dtor `0x180063040`, isValid `0x180065d40`, clear `0x180065d20`). Live B =
|
||||
`0xb8402538` (vtable matches). **B is the GATE, not the render source.**
|
||||
- **M** = SBC categories/sets store = `*(A + 0x20a68)`. Reached via A.vtable[+0x9b0] =
|
||||
lazy getter `0x18011b7d0` (if `A[+0x20a68]==0` it factory-creates an EMPTY M, type-id
|
||||
`0x13f0`, and caches it). Live M = `0x0` (never built this session — SBC menu not
|
||||
opened). **M IS the render source.**
|
||||
- The "SBC manager" is **A itself**: service-id `0xed84b12` resolver A.vtable[+0x18] =
|
||||
`0x180113f50` returns `this`, so `manager.vtable[+0x9b0] == A.vtable[+0x9b0] ==
|
||||
0x18011b7d0`. The old lead `0x1801e9010` is DEBUNKED — it is an `.rdata` function
|
||||
pointer slot (`->0x18018577a`), not a manager global.
|
||||
|
||||
### Render source (CLIENT authority)
|
||||
The SBC hub/squads controller (ctor `0x1800b5267`) caches M into `controller+0x140`
|
||||
by calling A.vtable[+0x9b0] once (`0x1800b554d`→`0x1800b5571`→store `[rsi+0x140]`),
|
||||
then registers Scaleform events `0x756c`–`0x7574`. The tile-build method (`0x1800b5e00`
|
||||
region) reads `[ctrl+0x140]=M` and at **`0x1800b5eda`** does
|
||||
`movzx ebx,WORD[M+0x50]; add bx,0x2; call [scaleform.vtable+0x58](count)` → emits
|
||||
**(category_count + 2) tiles**. This region reads `[ctrl+0x140]` seven times and reads
|
||||
B/`A+0x1fa00` **zero** times. M layout: cat count `WORD[M+0x50]`; cat vector
|
||||
`[M+0x58]..[M+0x60]` stride `0xf0`; per-cat set count `WORD[cat+0xb8]`, set vector
|
||||
`[cat+0xc0]` stride `0x3570`; secondary/featured vec `[M+0xa10]..[M+0xa18]`;
|
||||
indices at `+0x9e0/+0xa10/+0xa40`. **Correction on record:** earlier passes that
|
||||
called `B[+0x08]` the render source conflated the gate with the data source — the empty
|
||||
render was because M was null/empty, NOT because `B[+0x08]` was null.
|
||||
|
||||
### Populate path (CLIENT authority)
|
||||
The sbs/sets deserializer **`0x18017b2b0`** (rcx=this IGNORED; rdx=SAX cursor is the
|
||||
only live input) does the whole populate: fetch manager → get store M via
|
||||
`[manager.vtable+0x9b0]` (at `0x18017b327`) → clear `0x18015f3a0` → loop atom `0x6f`
|
||||
"categories": per item ctor `0x180159da0` (0xf0, vtable `0x18021b520`), cat-deser
|
||||
`0x18017ab80`, cat-finalize `0x180160e50`, APPEND `0x18015a770` (copy-ctor
|
||||
`0x18015a2b0`), dtor `0x1801105d0` → after loop rebuild indices `0x180160e00` +
|
||||
`0x180160f30` + `0x180161020` → commit `manager.vtable[+0x8]`. Always returns true.
|
||||
Set-row deser `0x18017ad60`. **Populate-target == render-source (both are M).**
|
||||
|
||||
### Prefetch gate (SERVER/front-end authority — THE WALL)
|
||||
There is **no native flag** to flip. The only native online check `0x1801642c0`
|
||||
(inside isValid) is stubbed `mov al,1; ret` — NOT the wall. The block is upstream in
|
||||
the Flash/ActionScript FUT front-end (FNV-name-hash bound; `RequestChallengeData` =
|
||||
`0x1801f9b30`, `futsbchubviewmodel` = `0x1801ee0a0` — no native xref), which refuses to
|
||||
issue `GET ut/game/fifa17/sbs/sets` offline, so deser `0x18017b2b0` never runs.
|
||||
**Newly proven:** the URL template `"ut/%s/sbs"` (`0x18021d908`) has ZERO references
|
||||
in the image (siblings `ut/%s/tournament`, `ut/%s/season` ARE referenced) — so
|
||||
**CardsDLL has no native code that self-builds/issues the sbs GET.** This kills any
|
||||
"force the req-mgr at A+0x2a0 to fetch on its own" idea. This is why the fix must be
|
||||
client-side and must FORCE the populate.
|
||||
|
||||
### Ready-arm (CLIENT authority)
|
||||
isValid `0x180065d40(B)` verified: `if !0x1801642c0() ret0` (stub→always passes);
|
||||
`cmp [rbx+0x28],0; je fail`; **`cmp QWORD[rbx+0x8],0; je 0x180065d75` → returns 1
|
||||
immediately (short-circuit)**; else QueryPerformanceCounter (`0x1801e50c0`) and compare
|
||||
`[rbx+0x20]` deadline. Normally B is armed by the completion callback `0x1800b8c30`
|
||||
(subscribed in svc ctor `0x1800b5765` via `manager.vtable[+0xa90]`) through the generic
|
||||
cache copy-assign `0x1800c21a0` (sets B+0x08=collection, B+0x20=deadline, B+0x28=1).
|
||||
Offline that callback never fires (no response). Live: `B[+0x08]=0`, `B[+0x28]=0`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Chosen minimal intervention and WHY
|
||||
|
||||
**Reuse the client's own parser; do NOT hand-build structs; arm ONLY `B[+0x28]`.**
|
||||
|
||||
Two tiers, safest-first:
|
||||
|
||||
- **Tier-0 (negative control — proves the gate):** write ONLY `BYTE[B+0x28]=1`.
|
||||
isValid short-circuits (B+0x08==0 branch) → menu OPENS instead of the error modal
|
||||
(`0x18016c330`), but renders EMPTY (M is null/empty). Do NOT write `B+0x08` or
|
||||
`B+0x20` — pointing B+0x08 at a collection forces isValid into the QPC-deadline
|
||||
branch, and with the live-stale deadline (`0xf10fb8cb9`) the gate SHUTS → modal, i.e.
|
||||
it DEFEATS the fix. This is the load-bearing correction from adversarial verification.
|
||||
|
||||
- **Tier-1 (real fix — populates M):**
|
||||
- **Preferred (Option 1, cleanest, zero forged state):** inject a canned
|
||||
`/sbs/sets` JSON response at the message-receive layer so the game builds the
|
||||
response-msg (ctor `0x18017b1c0`, vtable `0x18022e598`, deser slot +0x20 =
|
||||
`0x18017b2b0`), seats a genuine SAX cursor, its OWN chain populates M, and the
|
||||
native completion callback `0x1800b8c30` arms B for you. The bridge/core serves the
|
||||
JSON. Nothing forged.
|
||||
- **Fallback (Option 2):** from the hook, stand up a real SAX cursor over canned JSON
|
||||
(ctx `0x1801c63e0` + lexer `0x1801c8060` + an input-source whose `vtable[+0x8]`
|
||||
yields bytes), call deser `0x18017b2b0(rcx=ignored, rdx=cursor)`, then arm ONLY
|
||||
`BYTE[B+0x28]=1`. **Blocker:** the input-source `vtable[+0x8]` byte-yield contract
|
||||
is the ONE un-reversed item — a cold call with a null-source cursor CLEARS M
|
||||
(`0x18015f3a0`) then byte-scans a garbage pointer (`mov rdi,[rdi]` ~`0x18017b353`)
|
||||
→ wipes state + segfault. So Option 2 is NOT safe to run until the reader is
|
||||
reversed.
|
||||
|
||||
**Why not hand-build:** feeding `0x18015a770` a hand-built 0xf0 category (with nested
|
||||
0x3570 set records / EASTL sub-vectors) is the highest crash risk — the copy-ctor
|
||||
`0x18015a2b0` deep-copies inner sub-vectors; any bad begin/end/cap → heap corruption.
|
||||
The parser writes the correct geometry AND runs the index-rebuild finalizers that
|
||||
hand-built appends get wrong. Ruled out.
|
||||
|
||||
**Refresh:** after M is populated, fire refresh events `0x756c`–`0x7574` (or re-open the
|
||||
menu) so `0x1800b5eda` re-reads `WORD[M+0x50]`.
|
||||
|
||||
---
|
||||
|
||||
## 3. STAGED MORNING TEST PLAN (safest-first)
|
||||
|
||||
Precondition: FIFA at the FUT hub with CardsDLL loaded. Rollback for EVERY step =
|
||||
**relaunch FIFA** (all effects are volatile — single-byte poke or in-session hook state,
|
||||
cleared on restart). NEVER run `--apply` while the SBC menu is open/mid-iterate.
|
||||
|
||||
### Step 1 — Dry-run read confirm (ZERO writes)
|
||||
```
|
||||
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py
|
||||
```
|
||||
Expect: CONTROL FNV MATCH; A vtable match; B offset decoded live = `0x1f9d8`; B/A vtables
|
||||
match statics; `B+0x28=0`; `M=*(A+0x20a68)=0` (until SBC menu opened once).
|
||||
PASS = addresses match the model. Rollback: none needed (read-only).
|
||||
|
||||
### Step 2 — Review the DLL populate spec (ZERO writes)
|
||||
```
|
||||
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py --spec
|
||||
```
|
||||
Expect: printed injected-DLL spec (Option 1 preferred, Option 2 fallback). Read-only.
|
||||
|
||||
### Step 3 — Negative control (Tier-0, ONE byte write) — proves the GATE
|
||||
With the SBC menu **CLOSED**:
|
||||
```
|
||||
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py --apply
|
||||
```
|
||||
Writes exactly `BYTE[B+0x28]=1` (re-proves slide+vtables at write time; aborts on any
|
||||
mismatch; hard-refuses to write B+0x08/B+0x20). Then re-open the SBC menu.
|
||||
Expect: menu OPENS, no error modal, ~2 empty/placeholder tiles. This proves the gate +
|
||||
isValid short-circuit LIVE — it does NOT prove data. If it CRASHES: stop — B
|
||||
resolution/slide is wrong. Rollback: relaunch FIFA (byte clears on restart).
|
||||
|
||||
### Step 4 — Real fix (Tier-1) — proves the DATA (NOT for a blind run)
|
||||
Do this only after the DLL populate is implemented. Preferred: bring up the bridge/core
|
||||
`/sbs/sets` responder and let Option 1 (message-layer injection) drive the native chain;
|
||||
the completion callback arms B and M fills. Then the same gate opens a POPULATED menu
|
||||
(N+2 tiles). The hook module scaffold is `openfut-hook/src/sbc_hook.rs` — Tier-1
|
||||
`populate_m()` is present but deliberately refuses to call the deser until the SAX
|
||||
input-source reader is reversed (else it clears M and crashes). Build (when ready):
|
||||
```
|
||||
cd /home/alex/Documents/OpenFUT/openfut-launcher/openfut-hook && \
|
||||
cargo build --release --features fifa17 --target x86_64-pc-windows-gnu
|
||||
```
|
||||
Deploy as `version.dll` per launcher setup. Env gates (all default OFF):
|
||||
`OPENFUT_SBC_HOOK=1` (read-only resolve+log), `OPENFUT_SBC_ARM_ONLY=1` (Tier-0),
|
||||
`OPENFUT_SBC_POPULATE=1` (Tier-1, currently logs the blocker and returns).
|
||||
Rollback: unset env vars and relaunch FIFA.
|
||||
|
||||
### Step 5 — Cleanup
|
||||
Unset all `OPENFUT_SBC_*` env vars; relaunch FIFA to a clean state.
|
||||
|
||||
---
|
||||
|
||||
## 4. Crash-risk assessment
|
||||
|
||||
1. **Cold-calling `0x18017b2b0` without a real seated cursor** — CLEARS M
|
||||
(`0x18015f3a0`) first, then `mov rdi,[rdi]` byte-scan on a garbage ptr → wipes
|
||||
state + segfault. HIGHEST. Tier-1 code refuses this until the reader is reversed.
|
||||
2. **Writing `B+0x08`/`B+0x20`** — forces isValid into the QPC-deadline branch; stale
|
||||
deadline → gate SHUTS (modal), or garbage-ptr iterate crash. Self-defeating.
|
||||
Tool/code write ONLY `B+0x28`.
|
||||
3. **Populate off the game thread / mid-iterate** — lazy getter allocates on game heap,
|
||||
appender mutates EASTL vectors; a foreign thread races the allocator/menu iterate →
|
||||
heap corruption. Tier-1 must run on the game/message-pump thread with the menu closed.
|
||||
4. **Skipping the index-rebuild finalizers** (`0x180160e00/0x180160f30/0x180161020`)
|
||||
after append → stale `+0x9e0/+0xa10/+0xa40` indices → by-index getter `0x180160a80`
|
||||
reads OOB → crash/garbage tiles.
|
||||
5. **`WORD[M+0x50]` > actual 0xf0-stride entries** → tile loop walks past vector end
|
||||
(OOB read).
|
||||
6. **Hand-built 0xf0/0x3570 structs fed to `0x18015a770`** — copy-ctor `0x18015a2b0`
|
||||
deep-copies inner EASTL sub-vectors; bad begin/end/cap → heap corruption. Avoid.
|
||||
7. **No refresh after populate** (non-crash) — controller keeps the cached empty M at
|
||||
`ctrl+0x140`; `0x1800b5eda` won't re-run → still 2 placeholder tiles. Fire
|
||||
`0x756c`–`0x7574` or re-open.
|
||||
8. **Manager/store null** — deser does `mov rax,[rbx]` on the manager; registry lookup
|
||||
(hashes `0xed84b11`/`0xed84b12`) returning null → null-deref. Live registry
|
||||
`*[0x1802c2988]` non-null, so low risk; hook must still null-check M/store.
|
||||
|
||||
Tier-0 (single `B+0x28=1` write, B+0x08 left 0) is the verified-SAFE case: isValid
|
||||
short-circuits to 1, renders empty, no crash; bg-thread-tolerant like the /proc poke.
|
||||
|
||||
---
|
||||
|
||||
## 5. Poke tool + DLL-spec locations
|
||||
|
||||
- Poke tool (read-only default; `--spec`; `--apply` = ONLY `BYTE[B+0x28]=1`):
|
||||
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py`
|
||||
- Negative-control byte poke (older, triple-guarded):
|
||||
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_populate_poke.py`
|
||||
- Slide/read template + FNV control proof:
|
||||
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/gate_byte_probe.py`
|
||||
- DLL integration spec (RVA math, object graph, gate disasm, function-signature table,
|
||||
3 intervention tiers, 8-item crash register, staged test plan):
|
||||
`/home/alex/Documents/OpenFUT/fifa17-recon/docs/sbc-hook-dll-spec.md`
|
||||
- Injected-DLL module (fifa17-only; Tier-0 live, Tier-1 scaffolded/refusing):
|
||||
`/home/alex/Documents/OpenFUT/openfut-launcher/openfut-hook/src/sbc_hook.rs`
|
||||
(wired via `lib.rs` `#[cfg(feature="fifa17")] mod sbc_hook;` + `fifa17.rs`
|
||||
`crate::sbc_hook::install();`)
|
||||
- Atoms table: `/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv`
|
||||
|
||||
---
|
||||
|
||||
## Client-vs-server authority boundaries (flagged)
|
||||
|
||||
- **RENDER (M, tiles at `0x1800b5eda`)** — CLIENT. The client draws tiles solely from
|
||||
M; the server never touches this. Fix is client-side.
|
||||
- **POPULATE (deser `0x18017b2b0` → M)** — CLIENT parser, SERVER-fed data. The parser
|
||||
is native and reusable; the DATA it needs (`/sbs/sets` JSON) is a server response.
|
||||
Preferred fix has the bridge/core supply that JSON so the client parses it natively.
|
||||
- **PREFETCH GATE (issue `GET sbs/sets`)** — SERVER/front-end. THE WALL. No native
|
||||
flag; the SWF/ActionScript front-end refuses to request offline, and CardsDLL has no
|
||||
native code that issues the GET (`ut/%s/sbs` unreferenced). This cannot be fixed
|
||||
server-side by responding — the request is never sent. The hook must force the
|
||||
populate (inject the response at the message layer or drive the parser).
|
||||
- **READY-ARM (`B[+0x28]`, callback `0x1800b8c30`/commit `0x1800c21a0`)** — CLIENT.
|
||||
Normally armed by the completion callback (server-response-driven); offline the hook
|
||||
arms it (Tier-0 byte, or Option 1 lets the native callback arm it).
|
||||
@@ -0,0 +1,138 @@
|
||||
# SBC "problem communicating with the FIFA Ultimate Team servers" — definitive analysis
|
||||
|
||||
**Date:** 2026-08-07
|
||||
**Binary under study:** `/tmp/fut/cardsdll.dll` (on-disk PE, image base `0x180000000`; copy of `/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`)
|
||||
**Method:** clean-room, read-only. On-disk `objdump` re-verified in this pass; live values quoted from prior read-only `/proc/<pid>/mem` reads (pid 12201, slide `0x6ffe7c140000`, FNV control MATCH). No memory was written; FIFA was not touched.
|
||||
|
||||
---
|
||||
|
||||
## VERDICT (one line)
|
||||
|
||||
**The SBC modal is a CLIENT-SIDE, per-feature completion-path defect — the FUT client never re-arms a fetch/re-render for `sbs/sets` the way it does for the hub — so NO server response can cure it; the only offline lever is a client-memory patch, and the clean single-byte patch (`model+0x1fa00 = 1`) only SUPPRESSES the modal by forcing the completion predicate true, rendering from an empty, never-populated cache. It is NOT the go-online wall.**
|
||||
|
||||
---
|
||||
|
||||
## 1. What the SBC completion predicate actually checks (CONFIRMED on-disk)
|
||||
|
||||
The SBC menu entry runs a completion continuation whose gate is the shared predicate **`0x180065d40`**, called as `[cache_vtable+0x08]`. Re-disassembled this pass, byte-for-byte:
|
||||
|
||||
```
|
||||
180065d40 call 0x1801642c0 ; online/liveness sub-check
|
||||
180065d4e test al,al
|
||||
180065d50 je fail
|
||||
180065d52 cmp byte [rbx+0x28],0 ; <-- THE GATE: "value ready" flag
|
||||
180065d56 je fail
|
||||
180065d58 cmp qword [rbx+0x8],0 ; pending-op ptr
|
||||
180065d5d je pass (mov al,1) ; empty-collection shortcut -> success
|
||||
180065d5f lea rcx,[rsp+0x38]
|
||||
180065d64 call QueryPerformanceCounter ; [rip]->0x1801e50c0
|
||||
180065d6a mov rax,[rbx+0x20] ; QPC deadline
|
||||
180065d6e sub rax,[rsp+0x38]
|
||||
180065d73 js fail ; deadline passed -> fail
|
||||
180065d75 mov al,1 ; pass
|
||||
...
|
||||
180065d7d xor al,al ; fail
|
||||
```
|
||||
|
||||
Reduces to: `subcheck() && byte[cache+0x28]!=0 && (qword[cache+0x08]==0 || deadline[cache+0x20] not yet past)`.
|
||||
|
||||
- **The online/liveness sub-check `0x1801642c0` is stubbed OUT.** On-disk bytes are `b0 01 c3` = `mov al,1; ret` — always true, in the shipped file (not a live loader patch). **This is the reason SBC is NOT the go-online wall** (see §5).
|
||||
- `cache` (`rbx`) is an **embedded sub-object of the FUT root singleton** `A = *[0x1802e6398]`, selected by a vtable thunk (see §2). Its `+0x28` byte is a "value-ready" flag (init 0 by ctor `0x180062460`); `+0x08` is a pending-op pointer; `+0x20` is a QPC deadline. This is a copyable future/async-result value type. **The predicate never reads the parsed SBC categories, HTTP status, session, or any live-connection boolean.**
|
||||
|
||||
> **AUTHORITY BOUNDARY:** everything the predicate reads lives inside client process memory (`A+…`). Nothing in the `sbs/sets` HTTP response is an input to it. This is a **client-authority** decision end to end.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why hub passes but `sbs/sets` fails (CORRECTED after adversarial verification)
|
||||
|
||||
Both features run the **same predicate function** `0x180065d40`, but on **different embedded caches**, reached through **different per-response-class continuations**. That structural divergence is real and confirmed. **The originally-stated reason ("hub passes because its cache `+0x28` is set") is WRONG** and is corrected here — corroborated by a live measurement (HUB cache `+0x28 = 0` while the hub is displayed with no modal) and by the on-disk FALSE-branch disassembly gathered this pass.
|
||||
|
||||
### The two continuations, side by side (on-disk, this pass)
|
||||
|
||||
| | SBC (`FutLoadSetTypesServerResponse`) | HUB (`FutGetHubDataServerResponse`) |
|
||||
|---|---|---|
|
||||
| continuation | `0x180154860` | `0x180173770` |
|
||||
| get singleton A | `call 0x18011a830` (`mov rax,[0x1802e6398]`) | same |
|
||||
| select cache | `call [rdx+0x4e8]` → thunk `0x18011c1f0` = `lea rax,[rcx+0x1f9d8]` → **SBC cache A+0x1f9d8** | `call [rdx+0x1f8]` → thunk `0x18011a810` = `lea rax,[rcx+0x1fd70]` → **HUB cache A+0x1fd70** |
|
||||
| predicate | `call [rdx+0x08]` = `0x180065d40` | **same** `0x180065d40` |
|
||||
| on TRUE (jne) | render `0x18015491a → 0x180154600` | render `0x18017383d → 0x1801735e0` |
|
||||
| **on FALSE** | `lea rdx,[rbp-0x9]` (descriptor `0x18020a8b8`); **`call 0x18016c330`**; `jmp` return | **`call 0x1801213b0` (state reset)**; `lea 0x1801736f0` (continuation fn); **`call 0x18011f8e0` (register completion closure)**; `lea 0x18022cd30` (descriptor); **`call 0x18016c330`**; **`call 0x18011f900` (cleanup)** |
|
||||
|
||||
### What this proves
|
||||
|
||||
1. **`0x18016c330` is NOT an SBC-only "modal" function.** The HUB continuation calls the very same `0x18016c330` (at `0x18017382c`) on its own not-ready branch. It is a shared, descriptor-parameterized async dispatcher; SBC passes descriptor `0x18020a8b8`, hub passes `0x18022cd30`.
|
||||
|
||||
2. **At idle both predicates return FALSE.** Live: HUB cache `A+0x1fd70+0x28 = 0` **and** SBC cache `A+0x1f9d8+0x28 = 0`, both `+0x08 = 0`. The hub is on screen with no modal *while its own predicate would return FALSE*. So "hub `+0x28` is set" is false; a set flag is not what makes the hub pass.
|
||||
|
||||
3. **The real asymmetry is the FALSE-branch work.** On not-ready the HUB continuation **resets its request-state region** (`0x1801213b0`), **registers a completion closure** (`0x18011f8e0`, continuation `0x1801736f0`) so the arriving response re-runs the continuation and re-renders, then cleans up (`0x18011f900`). It is a proper get-or-fetch: cache-miss → (re)issue request → render on completion. **The SBC continuation does NONE of that** — it fires the dispatcher once with delegate `0x180154590`/descriptor `0x18020a8b8` and returns. It never re-arms a fetch and never wires the `sbs/sets` response back into a re-render.
|
||||
|
||||
**Conclusion:** hub and SBC diverge at the cache-selection call site (`[rdx+0x1f8]` vs `[rdx+0x4e8]`, one instruction apart), and — decisively — in the not-ready handling. The modal is produced **downstream in the SBC dispatched path** (dispatcher `0x18016c330` + delegate `0x180154590`), because the SBC feature is wired as a one-shot with no re-fetch/re-render, whereas the hub is wired as a self-rearming get-or-fetch. It is **not** decided by cache selection alone, **not** by the shared predicate, and **not** by the `+0x28` byte value at idle.
|
||||
|
||||
---
|
||||
|
||||
## 3. VERDICT by route — is SBC beatable, and how?
|
||||
|
||||
| Route | Outcome | Why |
|
||||
|---|---|---|
|
||||
| **A. Server response field / header / status** | **RULED OUT — no offline fix here** | No field in the `sbs/sets` body reaches the predicate (client-authority §1). Deeper: the SBC continuation never registers a completion closure to consume the response and re-render, so *even a perfect response is dropped on the floor*. The deserializer `0x18017b2b0` returning TRUE is genuinely irrelevant. |
|
||||
| **B. Client memory byte patch** `model+0x1fa00 = 1` | **Suppresses the modal, but empty menu — cosmetic** | Forces predicate TRUE → routes to the SBC render branch `0x18015491a → 0x180154600`, which reads the embedded SBC cache. That cache was never populated (`+0x08 == 0`, empty collection), so the likely result is an empty / non-functional SBC screen, not populated SBCs. **Untested under the read-only rule.** |
|
||||
| **C. Config `FUT/SBC_USE_STUBS`** (rdata `0x1802270f8`) | **Not the gate** | Read at the deser top only; the normal (off) path already runs. Flipping it does not touch `+0x28` or the continuation wiring. |
|
||||
| **D. "Needs the go-online wall solved"** | **REFUTED** | The only connection-like sub-check on this path (`0x1801642c0`) is stubbed to always-true on-disk. SBC is blocked by local per-feature completion wiring, not by the reconnect gate. See §5. |
|
||||
| **E. Client CODE patch of the SBC FALSE-branch** | **The only route to a *functional* SBC menu** | Make `0x180154860`'s not-ready branch replicate the hub's sequence: state reset `0x1801213b0` + register completion closure `0x18011f8e0`/`0x1801736f0` + dispatch + cleanup `0x18011f900`, so the `sbs/sets` response is fetched and rendered. This is a code patch, not a byte flip and not a server change. Out of scope for a server-side preservation fix; a client-side authority modification. |
|
||||
|
||||
**Bottom line:** there is **no server-side fix**. SBC is "beatable" only in the client-authority sense — either cosmetically (byte B, hides the modal over an empty menu) or functionally (route E, a code patch replicating the hub's re-arm). Neither is a change our offline server can make.
|
||||
|
||||
---
|
||||
|
||||
## 4. Memory patch details (if used) — flagged CLIENT-SIDE AUTHORITY
|
||||
|
||||
> **CLIENT-SIDE AUTHORITY — this is a modification of the FIFA client's own process memory, not an OpenFUT server response. It changes what the client decides, and it violates the current read-only rule; it is documented for completeness, not endorsed as the fix.**
|
||||
|
||||
- **Cosmetic modal-suppression (route B):**
|
||||
- **Absolute displacement into FUT root singleton:** `A + 0x1f9d8 + 0x28` = **`model + 0x1fa00`**, where `A = *[0x1802e6398]`.
|
||||
- **Live absolute (pid 12201 snapshot):** `0xb8402538 + 0x28 = 0xb8402560`.
|
||||
- **Value:** write `0x01` (one byte).
|
||||
- **Effect:** predicate `0x180065d40` short-circuits at `cmp byte[rbx+0x28],0` → with `+0x08==0` the empty-collection shortcut returns TRUE → continuation `jne 0x18015491a` renders. **Modal gone; SBC cache empty → expect an empty/possibly-broken menu.** Not verified (read-only).
|
||||
- **Persistence:** the object is embedded in the singleton (singleton lifetime). The SBC path calls only `[vt+0x08]`; nothing on this path calls the invalidator `[vt+0x10]=0x180065d20`, so a write should persist across menu re-entry (inferred from structure, not demonstrated).
|
||||
|
||||
- **Functional fix (route E)** requires a `.text` patch to the SBC continuation, not a data byte — see §3 row E. Do not confuse the two.
|
||||
|
||||
---
|
||||
|
||||
## 5. Relationship to the online-modes / go-online-wall finding
|
||||
|
||||
SBC is **not** the same wall as online Draft's "PRESS Q TO RECONNECT":
|
||||
|
||||
- The single connection-like sub-check reachable from the SBC predicate, `0x1801642c0`, is compiled out (`mov al,1; ret`) in the shipped binary. The SBC gate therefore encodes **no** unmet network condition — it is a purely local completion-wiring problem.
|
||||
- The online modes differ structurally: their gate keeps a real pending network op at `+0x08` and/or a non-stubbed sub-check, so their predicate encodes a network state a local byte-flip cannot satisfy. That is why the online wall is not beatable by a byte and SBC's modal is (cosmetically).
|
||||
- This is consistent with the prior **"refusing modes = no server fix"** finding: no field, count, header, or status in any HTTP response flips the client-side completion state for these features. SBC extends that finding with the precise mechanism — the client never re-arms the `sbs/sets` fetch/re-render at all.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — confirmed addresses (image base `0x180000000`)
|
||||
|
||||
| Symbol | Address | Note |
|
||||
|---|---|---|
|
||||
| FUT root singleton getter | `0x18011a830` | `mov rax,[0x1802e6398]; ret` |
|
||||
| FUT root singleton ptr | `[0x1802e6398]` | live `A = 0xb83e2b60` |
|
||||
| FUT root vtable (static) | `0x18021c2a0` | |
|
||||
| SBC cache selector thunk | `0x18011c1f0` | `lea rax,[rcx+0x1f9d8]` (slot `A.vt+0x4e8`) |
|
||||
| HUB cache selector thunk | `0x18011a810` | `lea rax,[rcx+0x1fd70]` (slot `A.vt+0x1f8`) |
|
||||
| SBC cache | `A+0x1f9d8` | vtable `0x1801fae70`; live `0xb8402538` |
|
||||
| HUB cache | `A+0x1fd70` | vtable `0x18021c1e0` |
|
||||
| shared predicate `isValid` | `0x180065d40` | `cache.vt+0x08` for both |
|
||||
| stubbed online sub-check | `0x1801642c0` | `b0 01 c3` = `mov al,1; ret` |
|
||||
| cache ctor / copy-ctor | `0x180062460` / `0x1800c21f3` | init `byte[+0x28]=0` |
|
||||
| invalidator | `0x180065d20` | `cache.vt+0x10`; not called on SBC path |
|
||||
| SBC continuation | `0x180154860` | class `RS4:FutLoadSetTypesServerResponse` (str `0x1802270b8`, vt row `0x180227090`) |
|
||||
| HUB continuation | `0x180173770` | class `RS4:FutGetHubDataServerResponse` (str `0x18022ce40`, vt row `0x18022ce18`) |
|
||||
| shared async dispatcher | `0x18016c330` | called by BOTH FALSE-branches (SBC `0x180154913`, HUB `0x18017382c`) |
|
||||
| SBC delegate / descriptor | invoke `0x180154590` / desc `0x18020a8b8` | |
|
||||
| HUB re-arm: state reset | `0x1801213b0` | HUB-only, `0x1801737bd` |
|
||||
| HUB re-arm: register closure | `0x18011f8e0` (cont. `0x1801736f0`) | HUB-only, `0x180173815` |
|
||||
| HUB re-arm: cleanup | `0x18011f900` | HUB-only, `0x180173836` |
|
||||
| SBC render branch (on TRUE) | `0x18015491a → 0x180154600` | reads empty SBC cache |
|
||||
| `sbs/sets` deserializer | `0x18017b2b0` | returns TRUE unconditionally (`mov al,1 @0x18017b751`); irrelevant to predicate |
|
||||
| QueryPerformanceCounter import | `0x1801e50c0` | |
|
||||
|
||||
**Which prior conclusion won:** the structural divergence (same predicate, different cache, different continuation; online sub-check stubbed; not server-fixable) is upheld. The specific pass/fail *reason* is corrected: it is the **FALSE-branch re-arm asymmetry**, not a set `+0x28` byte and not an SBC-exclusive `0x18016c330`.
|
||||
@@ -0,0 +1,211 @@
|
||||
# FIFA 17 SBC response reconciliation
|
||||
|
||||
**Verdict:** the live client receives HTTP 200 for `GET /ut/game/fifa17/sbs/sets`, but the
|
||||
typed `FutSBCLoadCategoryDetailsServerResponse` deserializer is not invoked. The evidence
|
||||
does **not** identify a server-controlled header, envelope field, or correlation value that
|
||||
can fix this. The previous `0x180154860` “SBC continuation” diagnosis was based on the wrong
|
||||
request class and is retracted.
|
||||
|
||||
## Scope and authority
|
||||
|
||||
This pass used only:
|
||||
|
||||
- the shipped `CardsDLL_Win64_retail.dll` copied to `/tmp/fut/cardsdll.dll`;
|
||||
- read-only `/proc/<pid>/mem` access to the running game;
|
||||
- the local OpenFUT request log; and
|
||||
- existing clean-room notes and scripts in this repository.
|
||||
|
||||
No game memory was written, no breakpoint was inserted, and no service or game process was
|
||||
restarted during the measurement.
|
||||
|
||||
## Fresh live observation
|
||||
|
||||
The control run used fresh FIFA process **PID 59054**. The CardsDLL mapping resolved to
|
||||
`0x6ffffc140000`, giving slide `0x6ffe7c140000`. Bytes at static control function
|
||||
`0x180180d00` matched the on-disk DLL, proving the mapping/slide before data reads.
|
||||
|
||||
At the FUT hub, before opening SBC:
|
||||
|
||||
- `A = *[0x1802e6398] = 0xb78f7c50`;
|
||||
- `M = *(A+0x20a68) = 0`;
|
||||
- hub cache byte `*(A+0x1fd70+0x28) = 1` (fresh hub response ready); and
|
||||
- SBC cache byte `*(A+0x1f9d8+0x28) = 0`.
|
||||
|
||||
The user then opened the SBC tile. The real client exchange was:
|
||||
|
||||
```text
|
||||
[10:20:20] GET /ut/game/fifa17/sbs/sets
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: OPENFUT-SID-0000000000000001
|
||||
Accept-Encoding: gzip
|
||||
-> 200 {"categories":[...]}
|
||||
```
|
||||
|
||||
The game displayed “There was a problem communicating with the FIFA Ultimate Team servers.”
|
||||
With that modal still open, the same slide was re-proved and `M` was still exactly zero.
|
||||
|
||||
### What `M == 0` proves
|
||||
|
||||
The typed `/sets` deserializer is `0x18017b2b0`. At `0x18017b309`–`0x18017b327` it obtains
|
||||
the FUT root and calls vtable slot `+0x9b0`, the lazy getter `0x18011b7d0`. That getter
|
||||
allocates and stores `A+0x20a68` before the deserializer examines the root object or the
|
||||
`categories` key.
|
||||
|
||||
Consequently:
|
||||
|
||||
- valid JSON would leave `M` non-null;
|
||||
- malformed or empty JSON reaching this function would also leave `M` non-null; and
|
||||
- `M == 0` after the completed HTTP transaction means `0x18017b2b0` was not invoked.
|
||||
|
||||
The normal reset of `M` is `0x180114ee0`; its observed use belongs to broad FUT-root
|
||||
initialization/reset work, not the `/sets` completion path. There is no evidence that the
|
||||
deserializer ran and then immediately cleared `M` during this transaction.
|
||||
|
||||
## Correct class map
|
||||
|
||||
Three classes were conflated in earlier notes:
|
||||
|
||||
| Function/class | Proven URI | Role |
|
||||
|---|---|---|
|
||||
| `FutSBCLoadCategoryDetailsServerResponse`, request URI builder `0x18017a980`, factory `0x18017aa10`, response deser `0x18017b2b0` | `/sets` under the `ut/%s/sbs` base | Initial category/set list; this is the live failing request |
|
||||
| `FutSBCSetDataServerResponse`, factory `0x18016fca0`, deser `0x18016fe90` | `/squadBuildingSets` (`0x18022bd88`) | Parses `reset`; not the observed `/sbs/sets` request |
|
||||
| `FutLoadSetTypesServerResponse`, deser `0x180154990` | `/challenge/%d/squad` (`0x1802270e0`) | Parses `challengeId`, `playerRequirements`, and `squad`; later challenge flow |
|
||||
|
||||
This corrects two prior claims:
|
||||
|
||||
1. `FutSBCSetDataServerResponse` does **not** share the literal `/sets` URI in this binary;
|
||||
its URI string is `/squadBuildingSets`.
|
||||
2. `0x180154860` is not a dedicated completion continuation for the initial category-list
|
||||
request. `0x180154830` is a generic callback thunk used by multiple request classes, while
|
||||
the nearby `0x180154990` parser and `/challenge/%d/squad` URI belong to
|
||||
`FutLoadSetTypesServerResponse`.
|
||||
|
||||
Therefore the earlier hub-versus-`0x180154860` comparison contrasted the hub with a later
|
||||
challenge-squad operation, not with `GET /sbs/sets`. Its proposed “copy the hub re-arm path”
|
||||
fix is unsupported for the category-list failure.
|
||||
|
||||
## What the generic completion code actually checks
|
||||
|
||||
The shared request completion routine `0x18016cca0`:
|
||||
|
||||
1. calls request vtable slot `+0x80` at `0x18016cd32` to create the class-selected typed
|
||||
response object;
|
||||
2. stores the received status at request offset `+0x48` (`0x18016cd3d`); and
|
||||
3. compares it with decimal 200 at `0x18016cdd0`.
|
||||
|
||||
Exactly 200 takes the success branch to `0x18016d0b9`. Non-200 status invokes the error
|
||||
translation path through request slot `+0x60` first. Response construction is selected by
|
||||
the request vtable; it is not selected by an HTTP response header or a JSON envelope field.
|
||||
|
||||
No pre-deserialization branch found in this path reads `Content-Type`, a request/correlation
|
||||
ID, the `X-UT-SID` response header, or a top-level JSON key. The live server already supplies
|
||||
the one proven transport-level success input: status 200.
|
||||
|
||||
## Hub comparison
|
||||
|
||||
The fresh hub response was consumed successfully and set the hub cache byte to one. After
|
||||
the subsequent navigation its resting value returned to zero. The SBC cache byte remained
|
||||
zero. This confirms that cache `+0x28` is transient async-result/TTL state; a later resting
|
||||
zero does not establish which completion branch ran.
|
||||
|
||||
The previous report's live snapshot—where both values were zero long after the requests—was
|
||||
therefore insufficient to infer the hub/SBC divergence. The fresh before/after measurement
|
||||
supersedes it.
|
||||
|
||||
## Server-fixability verdict
|
||||
|
||||
**Not demonstrated.** In particular:
|
||||
|
||||
- changing the category JSON cannot make the typed parser start, because the lazy store is
|
||||
allocated before any JSON key is inspected;
|
||||
- the server already returns the proven success status, 200;
|
||||
- request-class/response-class selection is client-owned; and
|
||||
- no header, envelope, or correlation field was found feeding a pre-parser decision.
|
||||
|
||||
This does not mathematically prove that no transport variation could ever affect the client.
|
||||
It does prove that the specific server-fix candidates proposed by the killed workflow were
|
||||
speculative and had no reading instruction behind them.
|
||||
|
||||
## Exact remaining unknown and next measurement
|
||||
|
||||
The unresolved boundary is between:
|
||||
|
||||
```text
|
||||
ProtoHttp completion with status 200
|
||||
-> class-selected response object creation
|
||||
-> delivery of response bytes/SAX cursor
|
||||
-> response vtable +0x08 (`0x18017b2b0`)
|
||||
```
|
||||
|
||||
The next useful experiment is transient tracing of calls—not another resting-state scan.
|
||||
Instrument, in a disposable/local diagnostic build or a non-mutating tracing facility:
|
||||
|
||||
- request factory `0x18017aa10`;
|
||||
- typed deserializer `0x18017b2b0`;
|
||||
- generic completion entry `0x18016cca0` and its status at `0x18016cdd0`; and
|
||||
- the generic response-body/SAX dispatch site that calls response vtable slot `+0x08`.
|
||||
|
||||
Record whether the factory is called, whether it returns an object with vtable
|
||||
`0x18022e5b0`, and whether a body/SAX object is delivered. That separates three remaining
|
||||
client-side possibilities: wrong request instance despite the URI, typed object created but
|
||||
body not attached, or body attached but virtual deserialization dispatch skipped.
|
||||
|
||||
Until that transient trace exists, the defensible implementation direction remains the
|
||||
client-side hook described in `docs/sbc-hook-dll-spec.md`, but its rationale must be stated
|
||||
as “native category deserializer is not reached,” not the retracted `0x180154860`
|
||||
hub-rearm theory.
|
||||
|
||||
## 2026-08-07 passive-trace result: deserialization is proven
|
||||
|
||||
The first gated passive client trace supersedes the final inference above. During exactly
|
||||
one SBC navigation, with every mutation feature disabled, the hook recorded:
|
||||
|
||||
```text
|
||||
SBC_TRACE: factory entry=1 exit=1 tid=652 this=0xb80cd910 result=0x7a99178;
|
||||
deser entry=1 exit=1 tid=652 this=0x7a99178 reader=0x7fcff7f8 result=true
|
||||
```
|
||||
|
||||
The matching UTAS request occurred at `11:09:01`: `GET /ut/game/fifa17/sbs/sets` returned
|
||||
HTTP 200 with one category and two sets. No degraded hook state was reported, and FIFA
|
||||
remained alive until the operator closed it after the single permitted attempt.
|
||||
|
||||
This proves all of the following for the observed request:
|
||||
|
||||
- the category response factory is called exactly once and returns a non-null object;
|
||||
- the native category deserializer is called exactly once on that same object;
|
||||
- the body reader is non-null;
|
||||
- deserialization returns success (`true`); and
|
||||
- both calls return normally on the same native thread.
|
||||
|
||||
Therefore the earlier `M == 0` resting snapshot did not prove that `0x18017b2b0` was
|
||||
skipped. The failure boundary is now strictly **after successful native deserialization**.
|
||||
The next measurement must trace the response object's post-deserializer completion,
|
||||
ownership handoff, and publication into the SBC UI/cache collection. Repeating the factory
|
||||
or deserializer trace will not add useful information.
|
||||
|
||||
## 2026-08-07 post-deserializer handoff trace
|
||||
|
||||
A second one-shot run combined the factory/deserializer probes with atomic replacements of
|
||||
the category request vtable slots `+0x90` (completion callback dispatch) and `+0x88`
|
||||
(response ownership transfer). All four calls completed on native thread 656:
|
||||
|
||||
```text
|
||||
request = 0xb80cdfe0
|
||||
factory response = 0x7c94808
|
||||
deserializer this = 0x7c94808, result=true
|
||||
+0x90 callback argument = 0x7c94808
|
||||
+0x88 owner-slot address = 0xbc51f7e8
|
||||
```
|
||||
|
||||
The matching `GET /ut/game/fifa17/sbs/sets` at `11:24:00` returned HTTP 200, and the same
|
||||
communication modal appeared. Both callback probes reported `entry=1 exit=1`; no degraded
|
||||
hook state or process failure occurred.
|
||||
|
||||
This proves that the parsed response reaches the category request's completion dispatcher
|
||||
and that its ownership-transfer routine also returns normally. The remaining failure
|
||||
boundary begins at the receiving owner object's vtable `+0x18` consumer invoked from
|
||||
`0x1801631e0`, or later collection/cache/UI validation. Network transport, response
|
||||
construction, native parsing, callback dispatch, and request-side ownership handoff are no
|
||||
longer candidate root causes.
|
||||
@@ -0,0 +1,337 @@
|
||||
# SBC render intervention — injected-DLL integration spec
|
||||
|
||||
**Goal:** make the FIFA 17 FUT **SBC menu render real SBC data** from inside the
|
||||
process (client-side), proven not server-fixable. The DLL is the existing
|
||||
`openfut-hook` (`version.dll`, cross-compiled `x86_64-pc-windows-gnu`, feature
|
||||
`fifa17`). In-process calls to client functions are safe here (unlike `/proc/mem`
|
||||
writes), because we run on the game's own threads with the real allocator.
|
||||
|
||||
**Binary of record (clean-room):** `/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`
|
||||
(on-disk copy `/tmp/fut/cardsdll.dll`), PE image base `0x180000000`. Every address
|
||||
below was re-verified byte-exact against this PE in this pass (vtable slots read from
|
||||
`.rdata`, prologues from `.text`). Do **not** build/deploy from this spec without the
|
||||
staged morning test (§9).
|
||||
|
||||
---
|
||||
|
||||
## 1. Module base + RVA math
|
||||
|
||||
CardsDLL is **not** present at `DllMain`/worker time — the boot module dump
|
||||
(`C:\openfut_hook.log`) has no `CardsDLL*` entry. It is loaded lazily **only when the
|
||||
user first enters Ultimate Team**. Therefore the hook must **defer** and poll for it,
|
||||
exactly like `probe::install_probes_deferred` polls for `anadius64.dll`.
|
||||
|
||||
- Loaded module name (Wine keeps the on-disk filename): **`CardsDLL_Win64_retail.dll`**.
|
||||
`GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0")`. Fallback: ToolHelp module walk
|
||||
matching a name containing `CardsDLL` (see `fifa17::dump_modules` for the pattern).
|
||||
- Image base in the PE is `0x180000000`. For any static VA in this doc:
|
||||
|
||||
```
|
||||
rva = VA_static - 0x180000000
|
||||
VA_runtime = cards_base + rva
|
||||
```
|
||||
|
||||
`cards_base` is the runtime `HMODULE` of `CardsDLL_Win64_retail.dll` (its in-memory
|
||||
load address). All the "0x180…" addresses below are **static VAs**; subtract
|
||||
`0x180000000` to get the RVA, add `cards_base` to get the live pointer/callable.
|
||||
|
||||
- Slide-proof control (optional sanity, mirrors `tools/gate_byte_probe.py`): the FNV
|
||||
prologue at VA `0x180180d00` must match the on-disk PE bytes
|
||||
`48 83 ec 28 48 85 c9 74 50 45 33 c0 ba c5 9d 1c 81 …`. If it does not, **abort** —
|
||||
the module map moved and the offsets are untrustworthy.
|
||||
|
||||
---
|
||||
|
||||
## 2. Verified object graph
|
||||
|
||||
```
|
||||
A = FUT root singleton = *(0x1802e6398) getter thunk 0x18011a830 = { mov rax,[rip→0x1802e6398]; ret }
|
||||
A.vtable (live [A]) = static 0x18021c2a0
|
||||
A.vtable[+0x4e8] = 0x18011c1f0 = { lea rax,[rcx+0x1f9d8]; ret } -> B getter
|
||||
A.vtable[+0x9b0] = 0x18011b7d0 = M lazy getter (see §3) -> M getter
|
||||
A.vtable[+0x18] = 0x180113f50 = service-id 0xed84b12 -> returns `this` (proves manager == A)
|
||||
|
||||
B = SBC request/ready TTL cache = A + 0x1f9d8 vtable static 0x1801fae70
|
||||
B+0x08 collection ptr (live 0 offline)
|
||||
B+0x20 QPC deadline
|
||||
B+0x28 ready byte (== A+0x1fa00 alias) <- the isValid gate byte
|
||||
B.vtable[+0x00] dtor = 0x180063040
|
||||
B.vtable[+0x08] isValid = 0x180065d40 (see §4)
|
||||
B.vtable[+0x10] clear = 0x180065d20
|
||||
|
||||
M = SBC categories/sets store = *(A + 0x20a68) <- THE RENDER SOURCE (see §3, §5)
|
||||
M+0x50 WORD category count
|
||||
M+0x58 cat-vector begin (element stride 0xf0)
|
||||
M+0x60 cat-vector end
|
||||
M+0xa10 secondary/featured vec begin (8-byte elems) (emptiness-checked at render)
|
||||
M+0xa18 secondary vec end
|
||||
per category (+0xf0 stride):
|
||||
cat+0xb8 WORD set count
|
||||
cat+0xc0 set-vector begin (element stride 0x3570)
|
||||
set+0x1c9 byte per-set flag
|
||||
```
|
||||
|
||||
HUB cache (works online) is the **same class** at `A + 0x1fd70` (vtable `0x18021c1e0`)
|
||||
— reference only.
|
||||
|
||||
**Manager fetch used by BOTH the deser and the render controller** (so
|
||||
populate-target == render-source):
|
||||
|
||||
```
|
||||
reg = 0x1800d7170() ; -> ®istry (static 0x1802c2988)
|
||||
mgr = 0x180009c80(&out, reg) ; out = manager (hashes 0xed84b11 / 0xed84b12)
|
||||
M = mgr.vtable[+0x9b0](mgr) ; 0x18011b7d0, lazily creates/returns *(A+0x20a68)
|
||||
```
|
||||
|
||||
Because svc-id `0xed84b12` resolves to `A` (A.vtable[+0x18] returns `this`),
|
||||
`mgr == A` and `mgr.vtable[+0x9b0] == A.vtable[+0x9b0] == 0x18011b7d0`. The hook may
|
||||
therefore fetch M the short way — `A = *(0x1802e6398); M = (*(void***)A)[0x9b0/8](A)` —
|
||||
**or** the long way (registry) — they return the identical object.
|
||||
|
||||
---
|
||||
|
||||
## 3. M lazy getter — 0x18011b7d0 (verified disassembly)
|
||||
|
||||
```
|
||||
18011b7d0 push rbx; push rdi; sub rsp,0x38
|
||||
18011b7e0 mov rdi,rcx ; rcx = A (this)
|
||||
18011b7e3 cmp QWORD [rcx+0x20a68],0 ; M already built?
|
||||
18011b7eb jne 18011b873 ; yes -> return it
|
||||
18011b7f1 call 0x18019e3c0 ; factory: allocate an EMPTY M (type-id 0x13f0)
|
||||
… … ; init fields, cache at A+0x20a68, return
|
||||
```
|
||||
|
||||
Cold-calling this alone **creates an EMPTY M** (`WORD[M+0x50]==0`) → the menu draws
|
||||
**2 placeholder tiles** (count+2). It does **not** populate. Populating is §5.
|
||||
|
||||
---
|
||||
|
||||
## 4. The gate — isValid 0x180065d40 (verified disassembly)
|
||||
|
||||
```
|
||||
180065d40 push rbx; sub rsp,0x20; mov rbx,rcx ; rcx = B
|
||||
180065d49 call 0x1801642c0 ; online sub-check — STUBBED `mov al,1;ret`
|
||||
180065d4e test al,al ; je fail ; never the wall
|
||||
180065d52 cmp BYTE [rbx+0x28],0 ; je fail ; <-- READY BYTE gate
|
||||
180065d58 cmp QWORD [rbx+0x8],0 ; je 0x180065d75 ; <-- if collection==0 -> RETURN 1 (short-circuit)
|
||||
180065d5f lea rcx,[rsp+0x38]; call [rip→0x1801e50c0]; QueryPerformanceCounter
|
||||
180065d6a mov rax,[rbx+0x20]; sub rax,[rsp+0x38] ; deadline - now
|
||||
180065d73 js fail ; past deadline -> fail
|
||||
180065d75 mov al,1 ; …; ret ; success
|
||||
```
|
||||
|
||||
**Load-bearing correction (adversarially confirmed, verified in this pass):** arm
|
||||
**only** `BYTE[B+0x28]=1` and **leave `QWORD[B+0x08]=0`**. With `B+0x08==0` the function
|
||||
takes the `je 0x180065d75` short-circuit and returns 1 immediately. If you instead
|
||||
write `B+0x08` (pointing it at the collection), isValid falls into the QPC-deadline
|
||||
branch; with the live-stale deadline (`B+0x20 = 0xf10fb8cb9`, already in the past) it
|
||||
returns **0 → modal → gate SHUTS**. So **never** manually write `B+0x08` or `B+0x20`.
|
||||
Rendering reads **M** (§5), not `B+0x08`, so nothing needs `B+0x08` set.
|
||||
|
||||
---
|
||||
|
||||
## 5. Render source — M, not B (verified disassembly)
|
||||
|
||||
Controller ctor caches M into `controller+0x140`:
|
||||
|
||||
```
|
||||
1800b554d call 0x1800d7170 ; reg
|
||||
1800b555d call 0x180009c80 ; mgr = out
|
||||
1800b556b mov rax,[rbx] ; mgr.vtable
|
||||
1800b5571 call [rax+0x9b0] ; M = 0x18011b7d0(mgr)
|
||||
1800b5577 mov [rsi+0x140], rax ; controller+0x140 = M
|
||||
…then registers Scaleform events 0x756c-0x7574 via 0x1801a4a70
|
||||
```
|
||||
|
||||
Tile-count emit (each menu build):
|
||||
|
||||
```
|
||||
1800b5eda mov rax,[r13+0x140] ; rax = M
|
||||
1800b5ee1 movzx ebx,WORD [rax+0x50] ; ebx = category count
|
||||
1800b5ee5 add bx,0x2 ; +2 placeholder tiles
|
||||
1800b5ee9 mov rax,[r15] ; Scaleform model vtable
|
||||
call [rax+0x58](count) ; push (category_count + 2) list tiles
|
||||
```
|
||||
|
||||
Helper thunks (verified): `0x18015fff0 = lea rax,[rcx+0x58]` (&M cat-vector),
|
||||
`0x1801607e0 = lea rax,[rcx+0xa10]` (&M secondary vector). **Zero** reads of
|
||||
`B`/`A+0x1f9d8`/`A+0x1fa00` exist in the tile-build region — B is purely the entry
|
||||
gate. Populate M ⇒ tiles appear.
|
||||
|
||||
---
|
||||
|
||||
## 6. Populate path — reuse the real parser (deser 0x18017b2b0)
|
||||
|
||||
The category rows are appended **only** by the sbs/sets deserializer. Its geometry and
|
||||
finalizers are the correct way to fill M (hand-building `0xf0`/`0x3570` structs is
|
||||
brittle and rejected — §8).
|
||||
|
||||
```
|
||||
18017b2b0 (rcx = this, IGNORED) (rdx = a PRIMED SAX reader over the token stream)
|
||||
18017b2ef mov rdi,rdx ; keeps the incoming reader in rdi (the byte source)
|
||||
18017b2fb call 0x1801c63e0(&localctx, 0, 0) ; builds a SECONDARY ctx with a NULL source
|
||||
18017b309 call 0x1800d7170 ; reg
|
||||
18017b316 call 0x180009c80 ; mgr
|
||||
18017b327 call [mgr.vtable+0x9b0] ; M (0x18011b7d0)
|
||||
… clear 0x18015f3a0(M) ; ALWAYS clears M first (see crash risk C1)
|
||||
… loop atom 0x6f "categories":
|
||||
0x180159da0(&tmp) ; cat ctor (0xf0, vtable 0x18021b520)
|
||||
0x18017ab80(&tmp, reader) ; cat deser (needs the reader)
|
||||
0x180160e50(&tmp) ; cat finalize (set index)
|
||||
0x18015a770(M, &tmp) ; APPEND (copy-in; copy-ctor 0x18015a2b0)
|
||||
0x1801105d0(&tmp) ; cat dtor
|
||||
… 0x180160e00(M); 0x180160f30(M); 0x180161020(M) ; rebuild M indices (+0x9e0/+0xa10/+0xa40)
|
||||
… commit mgr.vtable[+0x8](mgr)
|
||||
18017b751 ret (always true)
|
||||
```
|
||||
|
||||
**The reader (`rdx`) is the crux.** The deser does **not** ingest `rdx` through the
|
||||
`0x1801c63e0` ctx it builds (that one is created with a NULL source, `rdx=0/r8=0`);
|
||||
instead it keeps the **incoming** `rdx` in `rdi` and scans its bytes directly (e.g. the
|
||||
NUL-terminated backslash-unescape at `~0x18017b353` does `mov rdi,[rdi]`). So `rdx`
|
||||
must be a **fully-constructed, already-primed SAX reader/cursor object** seated over
|
||||
your canned `sbs/sets` JSON — the same object type the message framework produces on a
|
||||
real response. **Building that reader from scratch is the one remaining un-reversed
|
||||
contract** (its vtable, and specifically the `[+0x8]` byte-yield slot, are not yet
|
||||
pinned). Until it is, the fully-offline parser-reuse call is **not turnkey** — see the
|
||||
three tiers in §7.
|
||||
|
||||
SAX primitives already known (for when the reader is reconstructed): ctx init
|
||||
`0x1801c63e0(rcx=ctx,rdx=source,r8=flags)`, lexer `0x1801c8060`, next-token
|
||||
`0x1801c7f10`, begin-object `0x1801c8270`, INT `0x1801c79d0`, STR `0x1801c7aa0`,
|
||||
BOOL `0x1801c7620`, SKIP `0x180135ff0`.
|
||||
|
||||
Response-msg object (for the message-layer tier): ctor `0x18017b1c0` installs vtable
|
||||
`0x18022e598`; slot `[+0x20] == 0x18017b2b0` (deser) — **verified**. Constructing this
|
||||
object alone still does **not** seat the reader (the framework does that from received
|
||||
bytes), so it doesn't remove the reader gap.
|
||||
|
||||
---
|
||||
|
||||
## 7. Three intervention tiers (implement in this order)
|
||||
|
||||
**Tier 0 — arm-only negative control (SAFE, non-crash, renders EMPTY).**
|
||||
Resolve A→B, write `BYTE[B+0x28]=1`, leave `B+0x08=0`. isValid short-circuits true, the
|
||||
menu opens and draws **2 placeholder tiles** (M empty/null). Proves the gate model live
|
||||
without any populate. This is the first morning step and the baseline. Implemented and
|
||||
env-gated in `sbc_hook.rs` (`OPENFUT_SBC_ARM_ONLY=1`).
|
||||
|
||||
**Tier 1 — parser-reuse populate (the intended fix, BLOCKED on the reader).**
|
||||
On the game thread: build a primed SAX reader over canned `sbs/sets` JSON served by the
|
||||
bridge/core, `call 0x18017b2b0(rcx=0, rdx=reader)` (self-locates mgr, clears, appends,
|
||||
finalizes, commits → fills M), then Tier-0 arm (`BYTE[B+0x28]=1` only), then trigger a
|
||||
menu refresh (§ below). **Cannot be enabled** until the reader contract (§6) is
|
||||
reversed. `sbc_hook.rs` contains the guarded scaffold that logs the blocker and returns
|
||||
— it does **not** call the deser with a fabricated reader (that would clear M and/or
|
||||
crash — C1/C6).
|
||||
|
||||
**Tier 2 — message-layer injection (cleanest long-term, feasibility unproven).**
|
||||
Push a canned `sbs/sets` response through the real receive path so the framework builds
|
||||
the response-msg (`0x18017b1c0`), seats the reader itself, runs `0x18017b2b0`, fires the
|
||||
completion callback (`0x1800b8c30`, subscribed in svc ctor `0x1800b5765` via
|
||||
`mgr.vtable[+0xa90]`), and arms B natively (generic copy-assign `0x1800c21a0`) — **zero
|
||||
forged state**. Requires reconstructing the message-receive entry + response-msg wiring;
|
||||
treat as the target, not the default.
|
||||
|
||||
**Refresh trigger** (Tier 1/2): the controller re-reads `WORD[M+0x50]` at `0x1800b5eda`
|
||||
on every build, so **re-opening the SBC menu** suffices. Programmatic alternative: fire
|
||||
Scaleform refresh events `0x756c-0x7574` via `0x1801a4a70`. If M is populated but no
|
||||
refresh fires and the controller already cached an empty M at `ctrl+0x140`, you still see
|
||||
2 placeholder tiles (no crash, just no data) — see C7.
|
||||
|
||||
---
|
||||
|
||||
## 8. Function signatures (Win64 `extern "system"`; rcx, rdx, r8, r9 → rax)
|
||||
|
||||
| Purpose | Static VA | Signature (Rust `unsafe extern "system"`) |
|
||||
|---|---|---|
|
||||
| A getter thunk | 0x18011a830 | `fn() -> *mut u8` (returns `*(0x1802e6398)`) |
|
||||
| B getter (via A vtable +0x4e8) | 0x18011c1f0 | `fn(a: *mut u8) -> *mut u8` (`a+0x1f9d8`) |
|
||||
| M lazy getter (A vtable +0x9b0) | 0x18011b7d0 | `fn(mgr: *mut u8) -> *mut u8` (`*(mgr+0x20a68)`, lazily built) |
|
||||
| isValid (B vtable +0x08) | 0x180065d40 | `fn(b: *mut u8) -> bool` |
|
||||
| registry getter | 0x1800d7170 | `fn() -> *mut u8` |
|
||||
| manager getter | 0x180009c80 | `fn(out: *mut *mut u8, reg: *mut u8) -> *mut u8` |
|
||||
| sbs/sets deser (whole) | 0x18017b2b0 | `fn(this_ignored: *mut u8, reader: *mut u8) -> bool` |
|
||||
| SAX ctx init | 0x1801c63e0 | `fn(ctx: *mut u8, source: *mut u8, flags: u64) -> *mut u8` |
|
||||
| clear M | 0x18015f3a0 | `fn(m: *mut u8)` |
|
||||
| cat ctor (0xf0) | 0x180159da0 | `fn(tmp: *mut u8) -> *mut u8` |
|
||||
| cat deser | 0x18017ab80 | `fn(tmp: *mut u8, reader: *mut u8) -> bool` |
|
||||
| cat finalize | 0x180160e50 | `fn(tmp: *mut u8)` |
|
||||
| append into M | 0x18015a770 | `fn(m: *mut u8, tmp: *mut u8)` |
|
||||
| cat dtor | 0x1801105d0 | `fn(tmp: *mut u8)` |
|
||||
| M index rebuild ×3 | 0x180160e00 / 0x180160f30 / 0x180161020 | `fn(m: *mut u8)` each |
|
||||
| QueryPerformanceCounter thunk | 0x1801e50c0 | (indirect; not needed if B+0x08 left 0) |
|
||||
| Scaleform refresh dispatch | 0x1801a4a70 | `fn(ctrl: *mut u8, event_id: u32, …)` (event ids 0x756c-0x7574) |
|
||||
|
||||
M is **per-session heap** — never hardcode its address; always go A → `A.vtable[+0x9b0]`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Staged morning test plan (human, live)
|
||||
|
||||
Preconditions: FIFA 17 at the FUT hub (so CardsDLL is loaded). One env var flips each
|
||||
tier; all default **off/inert**. Watch `C:\openfut_hook.log`.
|
||||
|
||||
1. **Injection + resolution (read-only).** Launch with `OPENFUT_SBC_HOOK=1` only. The
|
||||
deferred thread should log: CardsDLL base + slide-control OK, then `A=…`, `B=…`,
|
||||
`B+0x28=0`, `M=*(A+0x20a68)=…` (0 until the SBC menu is opened once). No writes.
|
||||
*Pass:* addresses match the model; control FNV OK.
|
||||
2. **Tier-0 arm-only (negative control).** Add `OPENFUT_SBC_ARM_ONLY=1`. Open the SBC
|
||||
menu. Expected: **menu opens, draws ~2 empty placeholder tiles, no modal, no crash.**
|
||||
Confirms the gate byte and short-circuit live. If it crashes → stop (means B
|
||||
resolution is wrong; recheck slide).
|
||||
3. **Tier-1 populate — BLOCKED.** Do **not** enable until the SAX reader contract (§6)
|
||||
is reversed. `OPENFUT_SBC_POPULATE=1` currently only logs the blocker and returns.
|
||||
Next RE session: pin the reader vtable (`[+0x8]` byte-yield) and the reader ctor,
|
||||
then wire the §6 sequence and re-test on the game thread with the menu **closed**,
|
||||
then re-open to refresh.
|
||||
4. Revert env vars to unset when done.
|
||||
|
||||
---
|
||||
|
||||
## 10. Crash-risk register (verified against the PE + prior adversarial passes)
|
||||
|
||||
- **C1 — cold-calling deser without a real reader.** `0x18017b2b0` **clears M first**
|
||||
(`0x18015f3a0` before any append). A null/garbage reader → parses nothing but **wipes
|
||||
M** (renders empty, destroys prior state), and the byte-scan at `~0x18017b353`
|
||||
(`mov rdi,[rdi]`) segfaults on a bad pointer. This is exactly why Tier 1 is gated off.
|
||||
- **C2 — clear/finalize race.** Deser clears then rebuilds M's vectors+indices; if the
|
||||
render thread reads `WORD[M+0x50]` (`0x1800b5eda`) or by-index `0x180160a80` mid-build
|
||||
→ OOB/crash. Populate on the game thread with the menu **closed**, then refresh.
|
||||
- **C3 — skipping finalizers.** Any manual append via `0x18015a770` **must** be followed
|
||||
by `0x180160e00`/`0x180160f30`/`0x180161020` or the `+0x9e0/+0xa10/+0xa40` indices go
|
||||
stale and by-index lookups read OOB.
|
||||
- **C4 — hand-built `0xf0`/`0x3570` structs.** Append's copy-ctor `0x18015a2b0`
|
||||
deep-copies EASTL sub-vectors; a bad begin/end/cap → heap corruption. **Rejected**
|
||||
(§8): drive the real parser instead.
|
||||
- **C5 — writing `B+0x08`/`B+0x20`.** Forces isValid into the deadline branch; the
|
||||
live-stale deadline shuts the gate → modal. **Set only `B+0x28`, leave `B+0x08=0`.**
|
||||
- **C6 — null manager/M.** Deser does `mov rax,[mgr]`; if the registry lookup returned
|
||||
null it's a null-deref. Live registry `*(0x1802c2988)` is non-null offline, but the
|
||||
hook must null-check A, mgr, M before any use.
|
||||
- **C7 — no refresh (non-crash).** Populate without firing refresh / re-open → controller
|
||||
keeps its cached empty M → still 2 placeholder tiles. Fails the goal, not a crash.
|
||||
- **C8 — foreign-thread allocation.** The lazy getter and appenders allocate on / mutate
|
||||
the game heap; running them off the main/render thread races the allocator. Execute the
|
||||
populate on a game thread (message-pump / a game-thread detour), not a bg thread. The
|
||||
Tier-0 single-byte arm is tolerant of a bg write (it's what the `/proc` poke does), but
|
||||
populate is not.
|
||||
|
||||
---
|
||||
|
||||
## 11. Live-probe baseline (this pass, read-only `O_RDONLY`, zero writes)
|
||||
|
||||
FIFA17.exe **was running** at spec time (pid 12201), CardsDLL mapped. Fresh live reads
|
||||
this pass match the static model 1:1:
|
||||
|
||||
```
|
||||
slide 0x6ffe7c140000 CONTROL FNV OK
|
||||
A 0xb83e2b60 (= *(0x1802e6398))
|
||||
B 0xb8402538 vt=0x1801fae70 (matches static) B+0x08(coll)=0 B+0x20=0xf10fb8cb9 B+0x28(ready)=0
|
||||
HUB 0xb84028d0 vt=0x18021c1e0 coll=0 ready=0 (reference only)
|
||||
M *(A+0x20a68)=0 (SBC menu not opened this session -> M not yet built)
|
||||
```
|
||||
|
||||
So live: gate SHUT (`B+0x28=0`), collection null, **M null** — Tier-0 arm alone would
|
||||
render empty (matches the model). All §2–§6 addresses + all vtable slots were
|
||||
re-verified byte-exact against the on-disk PE in this pass.
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "futmem"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "futmem"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Read-only live-memory inspector for the FIFA 17 process (preservation / reverse-engineering tooling)"
|
||||
publish = false
|
||||
|
||||
# An EMPTY [workspace] table makes this crate its own workspace root.
|
||||
# Without it, cargo walks up the directory tree, finds
|
||||
# /home/alex/Documents/OpenFUT/Cargo.toml, sees that futmem is not in its
|
||||
# `members` list, and refuses to build. That parent manifest is untracked and
|
||||
# must not be edited, so we opt out from this side instead.
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
# memchr is the ONLY dependency, and it earns its place.
|
||||
# A `find` sweep covers roughly 3 GB of resident memory. The naive
|
||||
# `windows(n).position(...)` search runs at a few hundred MB/s; memchr's
|
||||
# memmem uses SIMD (AVX2 on this box) and runs an order of magnitude faster,
|
||||
# which turns a multi-minute sweep into a few seconds.
|
||||
# Everything else (argument parsing for four subcommands, /proc/<pid>/maps
|
||||
# parsing, hex dumping) is a few dozen lines of std and does not justify
|
||||
# pulling in clap or a proc-maps crate.
|
||||
memchr = "2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
@@ -0,0 +1,249 @@
|
||||
# futmem
|
||||
|
||||
A small, read-only live-memory inspector for FIFA 17, built for the OpenFUT
|
||||
preservation project.
|
||||
|
||||
`FIFA17.exe` is Denuvo-packed: its `.text` and `.rdata` exist in plaintext only
|
||||
inside the running process. Anything the packed executable owns can be reached
|
||||
only through live memory. `CardsDLL_Win64_retail.dll`, which holds nearly all the
|
||||
FUT logic, is unpacked but is loaded at a different address on every launch.
|
||||
`futmem` answers both problems: it finds the process, tells you where everything
|
||||
is loaded, and lets you search and dump it without touching a byte.
|
||||
|
||||
```
|
||||
cargo build --release
|
||||
./target/release/futmem maps
|
||||
```
|
||||
|
||||
## Read only by construction
|
||||
|
||||
A live game session may be running while this tool is used, and corrupting it
|
||||
costs the user their session. The read-only property is therefore structural
|
||||
rather than a matter of discipline:
|
||||
|
||||
* `/proc/<pid>/mem` is opened with `File::open`, i.e. `O_RDONLY`. The identifier
|
||||
`OpenOptions` does not appear anywhere in this crate.
|
||||
* `ProcMem` exposes `&self` read methods only. It hands out no `&mut File` and no
|
||||
raw file descriptor, so no caller outside `mem.rs` can upgrade the handle.
|
||||
* Nothing here calls `ptrace`, sends a signal, or stops the target.
|
||||
|
||||
There is no code path in this crate that can write to another process. Even if
|
||||
one were added by mistake, the kernel would reject the write on an `O_RDONLY`
|
||||
descriptor. Keep it that way.
|
||||
|
||||
## Subcommands
|
||||
|
||||
```
|
||||
futmem maps [--pid N]
|
||||
futmem find <pattern> [--pid N] [--ascii|--utf16|--hex] [--module NAME] [--max N]
|
||||
futmem strings [--pid N] [--min 6] [--range START-END] [--module NAME] [--utf16]
|
||||
[--grep SUBSTR] [--max N]
|
||||
futmem read <va> <len> [--pid N]
|
||||
```
|
||||
|
||||
With no `--pid`, the target is resolved by scanning `/proc/*/comm` for exactly
|
||||
`FIFA17.exe`. This matters: several processes in the Proton/umu tree carry
|
||||
"fifa17" in their command line, including a convincing
|
||||
`umu.exe /mnt/games/FIFA 17/_fifa17.exe` decoy, so a `pgrep -f` match is not good
|
||||
enough. Only `comm` is authoritative.
|
||||
|
||||
Addresses may be written `0x140000000` or `140000000`; bare values are read as
|
||||
hex, which is how this project writes them. Lengths accept `0x100`, `256`, `16k`,
|
||||
`2m`.
|
||||
|
||||
## What `maps` gives you that `cat /proc/pid/maps` does not
|
||||
|
||||
### The relocation slide, computed for you
|
||||
|
||||
Every address in the project's Ghidra database is based at `0x180000000`. The
|
||||
live module is somewhere else. `maps` prints the conversion directly:
|
||||
|
||||
```
|
||||
CardsDLL_Win64_retail.dll PRESENT base 0x6ffffc140000 size 0x31d000 static 0x180000000 slide +0x6ffe7c140000
|
||||
|
||||
CardsDLL address conversion: live_va = static_va + 0x6ffe7c140000
|
||||
```
|
||||
|
||||
It derives this by reading `ImageBase` from the *on-disk* PE (where the module
|
||||
wanted to load) and subtracting it from the live load address. The live header
|
||||
cannot be used for this, because Wine rewrites its `ImageBase` field to the
|
||||
actual load address.
|
||||
|
||||
**Module bases move on every launch.** Never cache the slide across a restart.
|
||||
|
||||
### The Wine mapping gotcha, made visible
|
||||
|
||||
Wine keeps only a PE's 4 KiB header file-backed and copies every section into
|
||||
anonymous memory. So this returns exactly one line:
|
||||
|
||||
```
|
||||
$ grep CardsDLL /proc/4048/maps
|
||||
6ffffc140000-6ffffc141000 r--p 00000000 00:37 2941670 /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll
|
||||
```
|
||||
|
||||
It is easy to misread that as "the module is barely mapped". A module table built
|
||||
naively from path grouping reports CardsDLL as a 4 KiB module; it is really
|
||||
`0x31d000` bytes. `futmem` reads `SizeOfImage` from the live PE header instead
|
||||
and flags the discrepancy:
|
||||
|
||||
```
|
||||
6ffffc140000 6ffffc45d000 3.11 MiB 1 CardsDLL_Win64_retail.dll [maps shows only 4.00 KiB; sections are anonymous]
|
||||
```
|
||||
|
||||
This also drives address attribution. A hit inside CardsDLL's `.rdata` lands in
|
||||
an anonymous region as far as the maps are concerned, so `find` checks module
|
||||
image spans *before* the region list and reports
|
||||
`CardsDLL_Win64_retail.dll+0x22c618` rather than `anon`.
|
||||
|
||||
Only genuine PE images claim a range. `/dev/nvidia0` is mapped at many scattered
|
||||
addresses, and letting its min..max span count as an "image" mis-attributed
|
||||
gigabytes of unrelated anonymous memory to it. Non-PE mappings own only their
|
||||
exact regions.
|
||||
|
||||
### Honest degradation
|
||||
|
||||
If the game has not loaded FUT yet, the difference is visible at a glance rather
|
||||
than showing as an empty table:
|
||||
|
||||
```
|
||||
KEY MODULES
|
||||
FIFA17.exe PRESENT base 0x140000000 ...
|
||||
CardsDLL_Win64_retail.dll ABSENT not in this process's maps (the game has not loaded it yet)
|
||||
```
|
||||
|
||||
An explicit `--pid` that does not point at the game is called out too, so a
|
||||
wrong-target mistake cannot pass unnoticed:
|
||||
|
||||
```
|
||||
pid 26072 (comm "bash"), 39 mapped regions <-- NOT FIFA17.exe; this is not the game process
|
||||
```
|
||||
|
||||
## Design notes
|
||||
|
||||
### pread, not seek + read
|
||||
|
||||
`FileExt::read_at` is `pread(2)`: the offset is an argument rather than a mutable
|
||||
cursor on the file. A `&ProcMem` can therefore be shared across threads later
|
||||
without a mutex and without one thread's seek corrupting another's read, and a
|
||||
whole class of "forgot to seek" bugs disappears.
|
||||
|
||||
### Partial sweeps are normal, and are reported
|
||||
|
||||
Many regions marked readable in `/proc/<pid>/maps` are not actually readable:
|
||||
guard pages, Wine's special mappings, and pages Denuvo has not faulted in all
|
||||
return `EIO`. A failed read is skipped and counted, never fatal, and every sweep
|
||||
prints its counts:
|
||||
|
||||
```
|
||||
1 hits; scanned 3552 regions (3.73 GiB), skipped 0 unreadable regions, 3 holes stepped over
|
||||
```
|
||||
|
||||
That line is there so a zero-hit result is never mistaken for proof of absence.
|
||||
When `find` returns nothing it says so explicitly.
|
||||
|
||||
### Chunked reads and the `pattern_len - 1` overlap
|
||||
|
||||
The target has roughly 3 GB resident, so regions are walked in 4 MiB chunks. The
|
||||
classic bug in hand-rolled scanners is that a pattern straddling a chunk boundary
|
||||
is never found: the tail of chunk N holds its first bytes and the head of chunk
|
||||
N+1 holds the rest, and neither buffer contains the whole thing.
|
||||
|
||||
Consecutive chunks therefore overlap by exactly `pattern_len - 1` bytes. That
|
||||
number is neither too small nor too large. Let a chunk cover `[0, n)` and the
|
||||
pattern have length `P`. A match starting at index `s` occupies `s ..= s + P - 1`,
|
||||
so the last match wholly inside the chunk starts at `s = n - P`. Advancing by
|
||||
`n - (P - 1)` starts the next chunk at `n - P + 1`, so:
|
||||
|
||||
* nothing is missed: every straddling match starts at `s >= n - P + 1`, inside
|
||||
the next chunk;
|
||||
* nothing is double-reported: the overlap begins at `n - P + 1`, strictly past
|
||||
`n - P`, the last index that can host a complete match in this chunk. The
|
||||
windows of reportable match *starts* are disjoint even though the byte windows
|
||||
overlap.
|
||||
|
||||
Overlapping by `P` would report every boundary-straddling match twice;
|
||||
overlapping by `P - 2` would miss one alignment.
|
||||
|
||||
This is verified against the live process rather than merely asserted. Region
|
||||
`0x144ed3000` is swept in 4 MiB chunks, so its first boundary falls at
|
||||
`0x1452d3000`. A 16-byte pattern placed 8 bytes before it straddles the boundary,
|
||||
and is found exactly once:
|
||||
|
||||
```
|
||||
$ futmem read 0x1452d2ff8 16
|
||||
0001452d2ff8 a9 48 01 90 90 90 90 90 90 99 51 48 8d 0d 0c 74 |.H........QH...t|
|
||||
|
||||
$ futmem find --hex a948019090909090909951488d0d0c74 --module fifa17
|
||||
0x0001452d2ff8 FIFA17.exe+0x52d2ff8
|
||||
1 hits
|
||||
```
|
||||
|
||||
One hit, not zero and not two.
|
||||
|
||||
String extraction uses a different mechanism for the same reason: it sweeps with
|
||||
zero overlap and carries an unfinished run across contiguous chunks, so a string
|
||||
spanning a boundary is still emitted whole. UTF-16 additionally carries a
|
||||
dangling low byte when a chunk ends mid-pair.
|
||||
|
||||
### Dependencies
|
||||
|
||||
`memchr` is the only dependency. Its `memmem` uses SIMD and runs roughly an order
|
||||
of magnitude faster than `windows(n).position(...)` over multiple gigabytes,
|
||||
which is the difference between a several-minute sweep and a few seconds.
|
||||
Everything else (argument parsing for four subcommands, maps parsing, PE header
|
||||
parsing, hex dumping) is a few dozen lines of `std` and does not justify pulling
|
||||
in `clap`.
|
||||
|
||||
### Standalone workspace
|
||||
|
||||
`Cargo.toml` carries an empty `[workspace]` table. Without it, cargo walks up the
|
||||
directory tree, finds the untracked workspace manifest at the repo root, sees that
|
||||
`futmem` is not in its `members` list, and refuses to build. Opting out from this
|
||||
side avoids editing that manifest.
|
||||
|
||||
## Performance
|
||||
|
||||
Measured against pid 4048 with the game sitting at the main menu, release build,
|
||||
best and worst of three runs each. These are wall clock, and they are dominated
|
||||
by the `pread` syscalls rather than by the search itself.
|
||||
|
||||
| Sweep | Scope | Wall clock |
|
||||
|---|---|---|
|
||||
| `strings --min 8 --grep pack` | 3.20 GiB, all anon private | 6.3 to 6.8 s |
|
||||
| `find --ascii` (global) | 3.73 GiB, all readable | 5.3 to 7.0 s |
|
||||
| `find --ascii --module cardsdll` | 3.11 MiB | 0.05 s |
|
||||
| `maps` | n/a | 0.05 s |
|
||||
|
||||
Scoping with `--module` is over a hundred times cheaper and should be the default
|
||||
habit when the target is known to live in CardsDLL. A global sweep costs about
|
||||
six seconds, which is cheap enough to use freely but not in a tight loop.
|
||||
|
||||
## Worked example
|
||||
|
||||
```
|
||||
$ futmem find --ascii 'RS4:FutSquadSave' --module cardsdll
|
||||
scanning CardsDLL_Win64_retail.dll image span 0x6ffffc140000-0x6ffffc45d000 (3.11 MiB)
|
||||
from /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll
|
||||
pattern 16 bytes, 7 candidate regions (3.11 MiB)
|
||||
|
||||
0x6ffffc36c618 CardsDLL_Win64_retail.dll+0x22c618
|
||||
6ffffc36c618 52 53 34 3a 46 75 74 53 71 75 61 64 53 61 76 65 |RS4:FutSquadSave|
|
||||
6ffffc36c628 53 65 72 76 65 72 52 65 73 70 6f 6e 73 65 00 00 |ServerResponse..|
|
||||
6ffffc36c638 5b 00 00 00 2c 25 64 00 5d 00 00 00 00 00 00 00 |[...,%d.].......|
|
||||
6ffffc36c648 63 61 70 74 61 69 6e 00 22 05 93 19 01 00 00 00 |captain.".......|
|
||||
|
||||
1 hits; scanned 7 regions (3.11 MiB), skipped 0 unreadable regions, 0 holes stepped over
|
||||
```
|
||||
|
||||
The `+0x22c618` offset converts straight back to the Ghidra address
|
||||
`0x18022c618`. Note that the literal is `RS4:FutSquadSaveServerResponse`, not
|
||||
`RS4:FutSquadSave` with a trailing NUL; read such patterns from the PE rather
|
||||
than assuming them.
|
||||
|
||||
## Scope
|
||||
|
||||
This tool is client-side instrumentation. It establishes nothing about the UTAS
|
||||
wire protocol and nothing a server emulator must reimplement. Its value is as the
|
||||
addressing base that lets other work read server-authoritative logic out of
|
||||
CardsDLL. Do not let addresses produced by this tool leak into a protocol
|
||||
document as if they were protocol.
|
||||
@@ -0,0 +1,125 @@
|
||||
//! A deliberately tiny argument parser.
|
||||
//!
|
||||
//! Four subcommands do not justify a `clap` dependency and its build time. The
|
||||
//! only subtlety is that some long options take a value (`--pid 165925`) and
|
||||
//! some are bare booleans (`--utf16`). A parser cannot tell those apart from
|
||||
//! the token stream alone, so each subcommand declares which of its options
|
||||
//! take a value and we look the name up in that list.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct Args {
|
||||
opts: HashMap<String, Option<String>>,
|
||||
pub positional: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ArgError(pub String);
|
||||
|
||||
impl std::fmt::Display for ArgError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Args {
|
||||
/// `value_flags` lists the long option names that consume the following
|
||||
/// token as their value. Everything else beginning with `--` is a boolean.
|
||||
/// `--name=value` is always accepted regardless of the list.
|
||||
pub fn parse<I: Iterator<Item = String>>(
|
||||
argv: I,
|
||||
value_flags: &[&str],
|
||||
) -> Result<Args, ArgError> {
|
||||
let mut opts: HashMap<String, Option<String>> = HashMap::new();
|
||||
let mut positional = Vec::new();
|
||||
let mut it = argv.peekable();
|
||||
|
||||
while let Some(tok) = it.next() {
|
||||
if let Some(rest) = tok.strip_prefix("--") {
|
||||
if rest.is_empty() {
|
||||
// A bare `--` ends option parsing; the rest is positional.
|
||||
positional.extend(it.by_ref());
|
||||
break;
|
||||
}
|
||||
if let Some((name, value)) = rest.split_once('=') {
|
||||
opts.insert(name.to_string(), Some(value.to_string()));
|
||||
} else if value_flags.contains(&rest) {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| ArgError(format!("--{rest} needs a value")))?;
|
||||
opts.insert(rest.to_string(), Some(value));
|
||||
} else {
|
||||
opts.insert(rest.to_string(), None);
|
||||
}
|
||||
} else {
|
||||
positional.push(tok);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Args { opts, positional })
|
||||
}
|
||||
|
||||
pub fn has(&self, name: &str) -> bool {
|
||||
self.opts.contains_key(name)
|
||||
}
|
||||
|
||||
pub fn value(&self, name: &str) -> Option<&str> {
|
||||
self.opts.get(name).and_then(|v| v.as_deref())
|
||||
}
|
||||
|
||||
pub fn parse_value<T: std::str::FromStr>(&self, name: &str) -> Result<Option<T>, ArgError> {
|
||||
match self.value(name) {
|
||||
None => Ok(None),
|
||||
Some(raw) => raw
|
||||
.parse::<T>()
|
||||
.map(Some)
|
||||
.map_err(|_| ArgError(format!("could not parse --{name} value {raw:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject typos instead of silently ignoring them. `futmem find --acii foo`
|
||||
/// should not quietly scan for nothing.
|
||||
pub fn reject_unknown(&self, known: &[&str]) -> Result<(), ArgError> {
|
||||
for name in self.opts.keys() {
|
||||
if !known.contains(&name.as_str()) {
|
||||
return Err(ArgError(format!("unknown option --{name}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `0x1234`, `1234` (hex assumed when the `0x` prefix is present,
|
||||
/// decimal otherwise) into a virtual address.
|
||||
pub fn parse_addr(raw: &str) -> Result<u64, ArgError> {
|
||||
let cleaned = raw.replace('_', "");
|
||||
let parsed = match cleaned
|
||||
.strip_prefix("0x")
|
||||
.or_else(|| cleaned.strip_prefix("0X"))
|
||||
{
|
||||
Some(hex) => u64::from_str_radix(hex, 16),
|
||||
// Bare addresses in this project are always written in hex
|
||||
// (`6ffffc140000`), so try hex first and fall back to decimal only for
|
||||
// values that are unambiguous.
|
||||
None => u64::from_str_radix(&cleaned, 16).or_else(|_| cleaned.parse::<u64>()),
|
||||
};
|
||||
parsed.map_err(|_| ArgError(format!("bad address {raw:?}")))
|
||||
}
|
||||
|
||||
/// Parse a length: `4096`, `0x1000`, `16k`, `2m`.
|
||||
pub fn parse_len(raw: &str) -> Result<u64, ArgError> {
|
||||
let lower = raw.to_ascii_lowercase();
|
||||
let (body, mult) = match lower.strip_suffix('k') {
|
||||
Some(b) => (b, 1024u64),
|
||||
None => match lower.strip_suffix('m') {
|
||||
Some(b) => (b, 1024 * 1024),
|
||||
None => (lower.as_str(), 1),
|
||||
},
|
||||
};
|
||||
let n = match body.strip_prefix("0x") {
|
||||
Some(hex) => u64::from_str_radix(hex, 16),
|
||||
None => body.parse::<u64>(),
|
||||
}
|
||||
.map_err(|_| ArgError(format!("bad length {raw:?}")))?;
|
||||
Ok(n * mult)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Hex + ASCII rendering, shared by `read` and by `find`'s context blocks.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::io::{self, Write};
|
||||
|
||||
fn ascii_gutter(row: &[u8]) -> String {
|
||||
row.iter()
|
||||
.map(|&b| {
|
||||
if (0x20..=0x7e).contains(&b) {
|
||||
b as char
|
||||
} else {
|
||||
'.'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Classic 16-bytes-per-line dump with absolute addresses in the left column.
|
||||
pub fn hexdump(out: &mut impl Write, base: u64, data: &[u8], indent: &str) -> io::Result<()> {
|
||||
for (i, row) in data.chunks(16).enumerate() {
|
||||
let addr = base + (i * 16) as u64;
|
||||
let mut hex = String::with_capacity(50);
|
||||
for (j, b) in row.iter().enumerate() {
|
||||
if j == 8 {
|
||||
hex.push(' ');
|
||||
}
|
||||
// Writing into a String is infallible.
|
||||
let _ = write!(hex, "{b:02x} ");
|
||||
}
|
||||
writeln!(out, "{indent}{addr:012x} {hex:<50}|{}|", ascii_gutter(row))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Turning `/proc/<pid>/maps` lines into a usable module table, and turning an
|
||||
//! address back into `module+offset`.
|
||||
//!
|
||||
//! # The Wine mapping gotcha this module exists to work around
|
||||
//!
|
||||
//! Under Wine, only a PE's 4 KiB header stays file-backed. Wine copies every
|
||||
//! section into ANONYMOUS memory. So `grep CardsDLL /proc/<pid>/maps` returns
|
||||
//! exactly one line, 4 KiB long, and a module table built naively from path
|
||||
//! grouping will report CardsDLL as a 4 KiB module. It is really 0x31d000 bytes.
|
||||
//! An agent who trusts the maps extent concludes the module is "barely mapped"
|
||||
//! and gives up, or computes a wrong module size and mis-attributes every hit.
|
||||
//!
|
||||
//! The fix: read `SizeOfImage` out of the live PE header at the module base.
|
||||
//! That field is authoritative for the module's real extent, and the header is
|
||||
//! the one part of the image that is reliably readable.
|
||||
//!
|
||||
//! # Deriving the slide automatically
|
||||
//!
|
||||
//! Wine rewrites the `ImageBase` field of the *live* header to the actual load
|
||||
//! address, so the live header cannot tell us where the module wanted to load.
|
||||
//! The on-disk file still can, and the maps line gives us its path. Reading the
|
||||
//! on-disk `ImageBase` and subtracting gives the relocation slide:
|
||||
//!
|
||||
//! ```text
|
||||
//! slide = live_base - disk_image_base
|
||||
//! live_va = static_va + slide
|
||||
//! ```
|
||||
//!
|
||||
//! For CardsDLL that is `0x6ffffc140000 - 0x180000000 = 0x6ffe7c140000`, the
|
||||
//! number every Ghidra-derived address in this project has to be adjusted by.
|
||||
//! Printing it removes the most error-prone manual step in the workflow.
|
||||
|
||||
use crate::maps::Region;
|
||||
use crate::mem::ProcMem;
|
||||
use std::fs;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Module {
|
||||
/// Bare file name, e.g. `CardsDLL_Win64_retail.dll`.
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
/// Lowest mapped address carrying this path. For a PE this is the header.
|
||||
pub base: u64,
|
||||
/// Highest address still carrying this path in the maps. Badly understates
|
||||
/// the truth under Wine; see the module docs.
|
||||
pub maps_end: u64,
|
||||
/// Number of separate maps lines mentioning this path.
|
||||
pub region_count: usize,
|
||||
/// `SizeOfImage` from the live PE header, the real extent.
|
||||
pub size_of_image: Option<u64>,
|
||||
/// `ImageBase` from the on-disk file: where the module was linked to load.
|
||||
pub disk_image_base: Option<u64>,
|
||||
}
|
||||
|
||||
impl Module {
|
||||
/// Best available end address: PE-derived when we have it, maps otherwise.
|
||||
pub fn end(&self) -> u64 {
|
||||
match self.size_of_image {
|
||||
Some(size) => self.base + size,
|
||||
None => self.maps_end,
|
||||
}
|
||||
}
|
||||
|
||||
/// The relocation slide: add this to a static (Ghidra) VA to get a live VA.
|
||||
pub fn slide(&self) -> Option<i128> {
|
||||
self.disk_image_base
|
||||
.map(|disk| self.base as i128 - disk as i128)
|
||||
}
|
||||
|
||||
/// Is this actually a PE image, as opposed to a device node, font or `.nls`
|
||||
/// data file that merely happens to be mapped?
|
||||
pub fn is_pe(&self) -> bool {
|
||||
self.size_of_image.is_some()
|
||||
}
|
||||
|
||||
/// Only PE images claim an address range.
|
||||
///
|
||||
/// Without the `is_pe` guard this mis-attributes badly. `/dev/nvidia0` is
|
||||
/// mapped at many scattered addresses, so its min..max span covers gigabytes
|
||||
/// of unrelated anonymous memory, and every hit in there would be reported
|
||||
/// as `nvidia0+0x...`. A non-PE mapping only ever owns the exact regions
|
||||
/// listed for it in the maps, which `describe` handles as a fallback.
|
||||
pub fn contains(&self, va: u64) -> bool {
|
||||
self.is_pe() && va >= self.base && va < self.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Little-endian scalar helpers. Returning `Option` keeps a truncated or
|
||||
/// malformed header from panicking the whole run.
|
||||
fn u16_at(buf: &[u8], off: usize) -> Option<u16> {
|
||||
buf.get(off..off + 2)
|
||||
.map(|s| u16::from_le_bytes([s[0], s[1]]))
|
||||
}
|
||||
fn u32_at(buf: &[u8], off: usize) -> Option<u32> {
|
||||
buf.get(off..off + 4)
|
||||
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||
}
|
||||
fn u64_at(buf: &[u8], off: usize) -> Option<u64> {
|
||||
buf.get(off..off + 8)
|
||||
.map(|s| u64::from_le_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]))
|
||||
}
|
||||
|
||||
/// `SizeOfImage` and `ImageBase` from a PE header blob.
|
||||
///
|
||||
/// Layout: `e_lfanew` at 0x3c points at the `PE\0\0` signature; the 20-byte
|
||||
/// COFF header follows; the optional header starts at signature+24. Within the
|
||||
/// optional header `SizeOfImage` sits at 0x38 for both PE32 and PE32+ (the
|
||||
/// layouts diverge only between 0x18 and 0x20). `ImageBase` is 8 bytes at 0x18
|
||||
/// for PE32+ and 4 bytes at 0x1c for PE32.
|
||||
fn parse_pe(buf: &[u8]) -> Option<(u64, u64)> {
|
||||
if buf.get(0..2)? != b"MZ" {
|
||||
return None;
|
||||
}
|
||||
let nt = u32_at(buf, 0x3c)? as usize;
|
||||
if buf.get(nt..nt + 4)? != b"PE\0\0" {
|
||||
return None;
|
||||
}
|
||||
let opt = nt + 24;
|
||||
let magic = u16_at(buf, opt)?;
|
||||
let size_of_image = u32_at(buf, opt + 0x38)? as u64;
|
||||
let image_base = match magic {
|
||||
0x20b => u64_at(buf, opt + 0x18)?, // PE32+
|
||||
0x10b => u32_at(buf, opt + 0x1c)? as u64, // PE32
|
||||
_ => return None,
|
||||
};
|
||||
Some((size_of_image, image_base))
|
||||
}
|
||||
|
||||
fn pe_from_disk(path: &str) -> Option<(u64, u64)> {
|
||||
// 4 KiB is more than enough for MZ + PE + optional header on any real image.
|
||||
let data = fs::read(path).ok()?;
|
||||
parse_pe(&data[..data.len().min(4096)])
|
||||
}
|
||||
|
||||
/// Build the module table. Modules are returned sorted by base address.
|
||||
pub fn modules(regions: &[Region], mem: &ProcMem) -> Vec<Module> {
|
||||
use std::collections::HashMap;
|
||||
let mut by_path: HashMap<&str, (u64, u64, usize)> = HashMap::new();
|
||||
|
||||
for r in regions {
|
||||
let Some(path) = r.path.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
if r.pseudo() {
|
||||
continue;
|
||||
}
|
||||
let entry = by_path.entry(path).or_insert((u64::MAX, 0, 0));
|
||||
entry.0 = entry.0.min(r.start);
|
||||
entry.1 = entry.1.max(r.end);
|
||||
entry.2 += 1;
|
||||
}
|
||||
|
||||
let mut out: Vec<Module> = by_path
|
||||
.into_iter()
|
||||
.map(|(path, (base, maps_end, region_count))| {
|
||||
// The live header gives the true extent; the on-disk header gives
|
||||
// the link-time base, which is what the slide is measured against.
|
||||
let live = mem.read_partial(base, 4096);
|
||||
let live_pe = parse_pe(&live);
|
||||
let disk_pe = pe_from_disk(path);
|
||||
Module {
|
||||
name: path.rsplit('/').next().unwrap_or(path).to_string(),
|
||||
path: path.to_string(),
|
||||
base,
|
||||
maps_end,
|
||||
region_count,
|
||||
size_of_image: live_pe.map(|(s, _)| s).or(disk_pe.map(|(s, _)| s)),
|
||||
disk_image_base: disk_pe.map(|(_, b)| b),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
out.sort_by_key(|m| m.base);
|
||||
out
|
||||
}
|
||||
|
||||
/// Case-insensitive lookup by name substring, e.g. `cardsdll`.
|
||||
pub fn find_module<'a>(mods: &'a [Module], needle: &str) -> Option<&'a Module> {
|
||||
let needle = needle.to_ascii_lowercase();
|
||||
mods.iter()
|
||||
.find(|m| m.name.to_ascii_lowercase().contains(&needle))
|
||||
}
|
||||
|
||||
/// Describe an address as `module+0xoff`, falling back to the region kind.
|
||||
///
|
||||
/// Checking module image spans BEFORE the region list is essential here: a hit
|
||||
/// inside CardsDLL's `.rdata` lands in an anonymous region as far as the maps
|
||||
/// are concerned, and would otherwise be reported as `anon`, throwing away the
|
||||
/// single most useful piece of context.
|
||||
pub fn describe(va: u64, mods: &[Module], regions: &[Region]) -> String {
|
||||
if let Some(m) = mods.iter().find(|m| m.contains(va)) {
|
||||
return format!("{}+{:#x}", m.name, va - m.base);
|
||||
}
|
||||
match regions.iter().find(|r| va >= r.start && va < r.end) {
|
||||
Some(r) => match r.path.as_deref() {
|
||||
Some(p) => format!("{}+{:#x}", p.rsplit('/').next().unwrap_or(p), va - r.start),
|
||||
None => format!("anon:{:#x}({})", r.start, r.perms),
|
||||
},
|
||||
None => "unmapped".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
//! # futmem: a read-only live-memory inspector for FIFA 17
|
||||
//!
|
||||
//! Preservation and interoperability tooling for the OpenFUT project. FIFA 17's
|
||||
//! `FIFA17.exe` is Denuvo-packed, so its `.text` and `.rdata` exist in plaintext
|
||||
//! only inside the running process. Anything the packed executable owns can be
|
||||
//! reached only through live memory. This tool is how you reach it.
|
||||
//!
|
||||
//! ## READ ONLY BY CONSTRUCTION
|
||||
//!
|
||||
//! A live game session may be running while this tool is used, and corrupting it
|
||||
//! costs the user their session. The read-only property is therefore structural
|
||||
//! rather than a matter of discipline:
|
||||
//!
|
||||
//! * `/proc/<pid>/mem` is opened with `File::open`, i.e. `O_RDONLY`. The string
|
||||
//! `OpenOptions` does not appear anywhere in this crate.
|
||||
//! * `ProcMem` exposes `&self` read methods only, hands out no `&mut File` and
|
||||
//! no raw descriptor, so no caller can upgrade the handle to a writable one.
|
||||
//! * Nothing here calls `ptrace`, sends a signal, or stops the target.
|
||||
//!
|
||||
//! There is no code path in this crate that can write to another process. Even
|
||||
//! if one were added by mistake, the kernel would reject the write on an
|
||||
//! `O_RDONLY` descriptor.
|
||||
//!
|
||||
//! ## Design notes
|
||||
//!
|
||||
//! * **pread, not seek+read.** `FileExt::read_at` takes the offset as an
|
||||
//! argument instead of mutating a shared file cursor, so a `&ProcMem` can be
|
||||
//! shared across threads later without a mutex, and a whole class of "forgot
|
||||
//! to seek" bugs disappears. See `mem.rs`.
|
||||
//! * **Partial sweeps are normal.** Many regions marked readable in
|
||||
//! `/proc/<pid>/maps` are not actually readable: guard pages, Wine's special
|
||||
//! mappings, and pages Denuvo has not faulted in all return `EIO`. A failed
|
||||
//! read is skipped and counted, never fatal, and the counts are printed so a
|
||||
//! zero-hit result is never mistaken for proof of absence. See `scan.rs`.
|
||||
//! * **Chunked reads with a `pattern_len - 1` overlap.** The target has roughly
|
||||
//! 3 GB resident, so regions are walked in 4 MiB chunks. Consecutive chunks
|
||||
//! overlap by exactly `pattern_len - 1` bytes so a pattern straddling a
|
||||
//! boundary is still found, and not double-reported. `scan.rs` carries the
|
||||
//! proof that this specific overlap is the correct one; it is the classic
|
||||
//! off-by-one in scanners of this kind.
|
||||
//! * **Minimal dependencies.** `memchr` is the only one, and it earns its place
|
||||
//! on a multi-gigabyte sweep. Four subcommands do not justify `clap`.
|
||||
//!
|
||||
//! ## The Wine mapping gotcha
|
||||
//!
|
||||
//! Wine keeps only a PE's 4 KiB header file-backed and copies the sections into
|
||||
//! anonymous memory. `grep CardsDLL /proc/<pid>/maps` therefore returns exactly
|
||||
//! one 4 KiB line. A module table built naively from the maps reports CardsDLL as
|
||||
//! a 4 KiB module when it is really 0x31d000 bytes. `futmem maps` reads
|
||||
//! `SizeOfImage` from the live PE header instead, and derives the relocation
|
||||
//! slide by comparing the live load address against the on-disk `ImageBase`, so
|
||||
//! the number needed to convert Ghidra addresses to live ones is printed rather
|
||||
//! than recomputed by hand.
|
||||
|
||||
mod cli;
|
||||
mod dump;
|
||||
mod image;
|
||||
mod maps;
|
||||
mod mem;
|
||||
mod scan;
|
||||
|
||||
use cli::{parse_addr, parse_len, ArgError, Args};
|
||||
use maps::{human, Region};
|
||||
use mem::ProcMem;
|
||||
use std::io::{self, BufWriter, Write};
|
||||
use std::process::ExitCode;
|
||||
|
||||
const COMM: &str = "FIFA17.exe";
|
||||
/// Modules this project always wants to know the status of.
|
||||
const KEY_MODULES: [&str; 3] = [
|
||||
"FIFA17.exe",
|
||||
"CardsDLL_Win64_retail.dll",
|
||||
"powdll_Win64_retail.dll",
|
||||
];
|
||||
|
||||
const USAGE: &str = "\
|
||||
futmem: read-only live-memory inspector for FIFA 17 (OpenFUT preservation tooling)
|
||||
|
||||
USAGE
|
||||
futmem maps [--pid N]
|
||||
futmem find <pattern> [--pid N] [--ascii|--utf16|--hex] [--module NAME] [--max N]
|
||||
futmem strings [--pid N] [--min 6] [--range START-END] [--module NAME] [--utf16]
|
||||
[--grep SUBSTR] [--max N]
|
||||
futmem read <va> <len> [--pid N]
|
||||
|
||||
COMMON
|
||||
--pid N Target pid. Omitted, futmem resolves the process whose
|
||||
/proc/<pid>/comm is exactly \"FIFA17.exe\". Decoy processes in the
|
||||
Proton tree match a pgrep -f on \"fifa17\", so comm is the authority.
|
||||
|
||||
find
|
||||
--ascii Pattern is ASCII text. This is the default.
|
||||
--utf16 Widen the ASCII pattern to UTF-16LE, how Windows stores most UI
|
||||
strings.
|
||||
--hex Pattern is a hex byte string, e.g. 4883ec284885c9. Spaces ignored.
|
||||
--module NAME Restrict the scan to a module's image span, matched case
|
||||
insensitively on a substring of the file name, e.g. --module cardsdll.
|
||||
--max N Stop after N hits.
|
||||
|
||||
strings
|
||||
--min N Minimum run length. Default 6.
|
||||
--range A-B Scan exactly this address range, e.g. --range 0x1450f3000-0x14b1a3000.
|
||||
--module NAME Scan a module's image span.
|
||||
--utf16 Extract UTF-16LE strings instead of ASCII.
|
||||
--grep S Only print strings containing S, matched case insensitively.
|
||||
--max N Stop after N strings.
|
||||
With none of --range or --module, the default scope is every anonymous private
|
||||
region, which is where a packed executable's decrypted data lives.
|
||||
|
||||
Addresses may be written 0x140000000 or 140000000; bare values are read as hex.
|
||||
Lengths accept 0x100, 256, 16k, 2m.
|
||||
|
||||
All operations are strictly read-only. See the crate docs for the guarantee.
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
let Some(sub) = argv.first().cloned() else {
|
||||
print!("{USAGE}");
|
||||
return ExitCode::FAILURE;
|
||||
};
|
||||
let rest = argv.into_iter().skip(1);
|
||||
|
||||
let stdout = io::stdout();
|
||||
let mut out = BufWriter::new(stdout.lock());
|
||||
|
||||
let result = match sub.as_str() {
|
||||
"maps" => cmd_maps(&mut out, rest),
|
||||
"find" => cmd_find(&mut out, rest),
|
||||
"strings" => cmd_strings(&mut out, rest),
|
||||
"read" => cmd_read(&mut out, rest),
|
||||
"-h" | "--help" | "help" => {
|
||||
print!("{USAGE}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
other => {
|
||||
eprintln!("futmem: unknown subcommand {other:?}\n");
|
||||
eprint!("{USAGE}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
// Flushing separately so a broken pipe (futmem strings | head) is not
|
||||
// reported as a failure.
|
||||
let flushed = out.flush();
|
||||
match (result, flushed) {
|
||||
(Err(e), _) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
|
||||
(_, Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
|
||||
(Err(e), _) => {
|
||||
eprintln!("futmem: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
(Ok(()), Err(e)) => {
|
||||
eprintln!("futmem: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
(Ok(()), Ok(())) => ExitCode::SUCCESS,
|
||||
}
|
||||
}
|
||||
|
||||
fn arg_err(e: ArgError) -> io::Error {
|
||||
new_invalid(e)
|
||||
}
|
||||
|
||||
/// Resolve the target pid from `--pid` or by scanning `/proc/*/comm`.
|
||||
fn resolve_pid(args: &Args) -> io::Result<i32> {
|
||||
match args.parse_value::<i32>("pid").map_err(arg_err)? {
|
||||
Some(pid) => Ok(pid),
|
||||
None => maps::find_pid(COMM),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- maps
|
||||
|
||||
fn cmd_maps<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
|
||||
let args = Args::parse(argv, &["pid"]).map_err(arg_err)?;
|
||||
args.reject_unknown(&["pid"]).map_err(arg_err)?;
|
||||
let pid = resolve_pid(&args)?;
|
||||
let regions = maps::read_maps(pid)?;
|
||||
let mem = ProcMem::open(pid)?;
|
||||
let mods = image::modules(®ions, &mem);
|
||||
|
||||
// Report the comm we actually found, not the one we hoped for: an explicit
|
||||
// --pid may point anywhere, and silently labelling it "FIFA17.exe" would
|
||||
// make a wrong-target mistake invisible.
|
||||
let comm = maps::read_comm(pid);
|
||||
let warn = if comm == COMM {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" <-- NOT {COMM}; this is not the game process")
|
||||
};
|
||||
writeln!(
|
||||
out,
|
||||
"pid {pid} (comm {comm:?}), {} mapped regions{warn}",
|
||||
regions.len()
|
||||
)?;
|
||||
writeln!(out)?;
|
||||
|
||||
// -- key modules first, so "is FUT loaded yet?" is answerable at a glance.
|
||||
writeln!(out, "KEY MODULES")?;
|
||||
for want in KEY_MODULES {
|
||||
match image::find_module(&mods, want) {
|
||||
Some(m) => {
|
||||
let slide = match m.slide() {
|
||||
Some(s) if s >= 0 => format!("slide +{:#x}", s),
|
||||
Some(s) => format!("slide -{:#x}", -s),
|
||||
None => "slide unknown".to_string(),
|
||||
};
|
||||
let static_base = m
|
||||
.disk_image_base
|
||||
.map(|b| format!("static {b:#x}"))
|
||||
.unwrap_or_else(|| "static ?".to_string());
|
||||
writeln!(
|
||||
out,
|
||||
" {:<28} PRESENT base {:#x} size {:#x} {static_base} {slide}",
|
||||
m.name,
|
||||
m.base,
|
||||
m.size_of_image.unwrap_or(m.maps_end - m.base),
|
||||
)?;
|
||||
}
|
||||
None => writeln!(
|
||||
out,
|
||||
" {want:<28} ABSENT not in this process's maps (the game has not loaded it yet)"
|
||||
)?,
|
||||
}
|
||||
}
|
||||
if let Some(m) = image::find_module(&mods, "CardsDLL") {
|
||||
if let Some(slide) = m.slide() {
|
||||
writeln!(out)?;
|
||||
writeln!(
|
||||
out,
|
||||
" CardsDLL address conversion: live_va = static_va + {slide:#x}"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" (Ghidra static base {:#x} -> live base {:#x}. Valid for pid {pid} only; \
|
||||
module bases move on every launch.)",
|
||||
m.disk_image_base.unwrap_or(0),
|
||||
m.base
|
||||
)?;
|
||||
}
|
||||
}
|
||||
writeln!(out)?;
|
||||
|
||||
// -- full module table
|
||||
writeln!(out, "MODULES (file-backed, grouped by path)")?;
|
||||
writeln!(
|
||||
out,
|
||||
" {:<14} {:<14} {:<12} {:>5} name",
|
||||
"base", "end (PE)", "size", "regs"
|
||||
)?;
|
||||
for m in &mods {
|
||||
let note = if !m.is_pe() {
|
||||
// A device node, .nls table or font, not a loadable image. Its
|
||||
// min..max span is meaningless, so say so rather than imply an extent.
|
||||
" [non-PE mapping; span is min..max of scattered regions]".to_string()
|
||||
} else if m.maps_end - m.base < m.end() - m.base {
|
||||
// The Wine gotcha, made visible instead of silently misleading.
|
||||
format!(
|
||||
" [maps shows only {}; sections are anonymous]",
|
||||
human(m.maps_end - m.base)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
writeln!(
|
||||
out,
|
||||
" {:<14x} {:<14x} {:<12} {:>5} {}{}",
|
||||
m.base,
|
||||
m.end(),
|
||||
human(m.end() - m.base),
|
||||
m.region_count,
|
||||
m.name,
|
||||
note
|
||||
)?;
|
||||
}
|
||||
writeln!(out)?;
|
||||
|
||||
// -- writable + executable regions: where packers put decrypted code.
|
||||
let wx: Vec<&Region> = regions
|
||||
.iter()
|
||||
.filter(|r| r.writable() && r.executable())
|
||||
.collect();
|
||||
let wx_total: u64 = wx.iter().map(|r| r.size()).sum();
|
||||
writeln!(
|
||||
out,
|
||||
"WRITABLE + EXECUTABLE REGIONS ({} regions, {})",
|
||||
wx.len(),
|
||||
human(wx_total)
|
||||
)?;
|
||||
// Wine emits hundreds of 4 KiB per-thread stubs that are pure noise.
|
||||
let mut small_wx = 0usize;
|
||||
for r in &wx {
|
||||
if r.size() <= 64 * 1024 {
|
||||
small_wx += 1;
|
||||
continue;
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {:012x}-{:012x} {} {:>10} {}",
|
||||
r.start,
|
||||
r.end,
|
||||
r.perms,
|
||||
human(r.size()),
|
||||
describe_region(r, &mods)
|
||||
)?;
|
||||
}
|
||||
if small_wx > 0 {
|
||||
writeln!(
|
||||
out,
|
||||
" (+{small_wx} regions of 64 KiB or less, Wine per-thread stubs, omitted)"
|
||||
)?;
|
||||
}
|
||||
writeln!(out)?;
|
||||
|
||||
// -- large anonymous private regions
|
||||
let mut anon: Vec<&Region> = regions
|
||||
.iter()
|
||||
.filter(|r| r.anonymous() && r.private() && r.readable() && r.size() > 1024 * 1024)
|
||||
.collect();
|
||||
anon.sort_by_key(|r| std::cmp::Reverse(r.size()));
|
||||
let anon_total: u64 = anon.iter().map(|r| r.size()).sum();
|
||||
writeln!(
|
||||
out,
|
||||
"ANONYMOUS PRIVATE REGIONS OVER 1 MB ({} regions, {})",
|
||||
anon.len(),
|
||||
human(anon_total)
|
||||
)?;
|
||||
for r in &anon {
|
||||
writeln!(
|
||||
out,
|
||||
" {:012x}-{:012x} {} {:>10} {}",
|
||||
r.start,
|
||||
r.end,
|
||||
r.perms,
|
||||
human(r.size()),
|
||||
describe_region(r, &mods)
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Label a region with the module whose image span contains it, if any.
|
||||
fn describe_region(r: &Region, mods: &[image::Module]) -> String {
|
||||
if let Some(p) = r.path.as_deref() {
|
||||
// The file offset matters for a packed executable: it says which part of
|
||||
// the on-disk image this mapping still corresponds to.
|
||||
let name = p.rsplit('/').next().unwrap_or(p);
|
||||
return format!("{name} @fileoff {:#x}", r.offset);
|
||||
}
|
||||
match mods.iter().find(|m| m.contains(r.start)) {
|
||||
Some(m) => format!("anon, inside {} image", m.name),
|
||||
None => "anon".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- find
|
||||
|
||||
fn cmd_find<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
|
||||
let known = ["pid", "ascii", "utf16", "hex", "module", "max"];
|
||||
let args = Args::parse(argv, &["pid", "module", "max"]).map_err(arg_err)?;
|
||||
args.reject_unknown(&known).map_err(arg_err)?;
|
||||
|
||||
let Some(raw) = args.positional.first() else {
|
||||
return Err(new_invalid(ArgError("find needs a pattern".into())));
|
||||
};
|
||||
|
||||
let pattern: Vec<u8> = if args.has("hex") {
|
||||
parse_hex(raw).map_err(arg_err)?
|
||||
} else if args.has("utf16") {
|
||||
// Widen ASCII to UTF-16LE: each byte followed by a zero high byte.
|
||||
raw.bytes().flat_map(|b| [b, 0]).collect()
|
||||
} else {
|
||||
raw.as_bytes().to_vec()
|
||||
};
|
||||
let max = args.parse_value::<usize>("max").map_err(arg_err)?;
|
||||
|
||||
let pid = resolve_pid(&args)?;
|
||||
let regions = maps::read_maps(pid)?;
|
||||
let mem = ProcMem::open(pid)?;
|
||||
let mods = image::modules(®ions, &mem);
|
||||
|
||||
let module = match args.value("module") {
|
||||
Some(name) => match image::find_module(&mods, name) {
|
||||
Some(m) => Some(m.clone()),
|
||||
None => {
|
||||
return Err(new_invalid(ArgError(format!(
|
||||
"no module matching {name:?} in pid {pid}; run `futmem maps` to list them"
|
||||
))))
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
if let Some(m) = &module {
|
||||
writeln!(
|
||||
out,
|
||||
"scanning {} image span {:#x}-{:#x} ({})\n from {}",
|
||||
m.name,
|
||||
m.base,
|
||||
m.end(),
|
||||
human(m.end() - m.base),
|
||||
m.path
|
||||
)?;
|
||||
}
|
||||
|
||||
let targets = scan::scan_targets(®ions, module.as_ref(), false);
|
||||
let target_bytes: u64 = targets.iter().map(|r| r.size()).sum();
|
||||
writeln!(
|
||||
out,
|
||||
"pattern {} bytes, {} candidate regions ({})",
|
||||
pattern.len(),
|
||||
targets.len(),
|
||||
human(target_bytes)
|
||||
)?;
|
||||
writeln!(out)?;
|
||||
|
||||
let mut hits: Vec<u64> = Vec::new();
|
||||
let stats = scan::find_pattern(&mem, &targets, &pattern, max, |va| hits.push(va));
|
||||
|
||||
for va in &hits {
|
||||
let loc = image::describe(*va, &mods, ®ions);
|
||||
writeln!(out, "{va:#014x} {loc}")?;
|
||||
let ctx = mem.read_partial(*va, 64);
|
||||
if !ctx.is_empty() {
|
||||
dump::hexdump(out, *va, &ctx, " ")?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(out)?;
|
||||
writeln!(out, "{} hits; {}", hits.len(), stats.summary())?;
|
||||
if hits.is_empty() {
|
||||
writeln!(
|
||||
out,
|
||||
"note: {} regions were unreadable, so an empty result is NOT proof of absence.",
|
||||
stats.regions_skipped
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_hex(raw: &str) -> Result<Vec<u8>, ArgError> {
|
||||
let cleaned: String = raw
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != ':' && *c != ',')
|
||||
.collect();
|
||||
let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned);
|
||||
if !cleaned.len().is_multiple_of(2) {
|
||||
return Err(ArgError(format!(
|
||||
"hex pattern has an odd number of digits ({})",
|
||||
cleaned.len()
|
||||
)));
|
||||
}
|
||||
(0..cleaned.len())
|
||||
.step_by(2)
|
||||
.map(|i| {
|
||||
u8::from_str_radix(&cleaned[i..i + 2], 16)
|
||||
.map_err(|_| ArgError(format!("bad hex byte {:?}", &cleaned[i..i + 2])))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- strings
|
||||
|
||||
fn cmd_strings<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
|
||||
let known = ["pid", "min", "range", "module", "utf16", "grep", "max"];
|
||||
let args =
|
||||
Args::parse(argv, &["pid", "min", "range", "module", "grep", "max"]).map_err(arg_err)?;
|
||||
args.reject_unknown(&known).map_err(arg_err)?;
|
||||
|
||||
let min = args
|
||||
.parse_value::<usize>("min")
|
||||
.map_err(arg_err)?
|
||||
.unwrap_or(6);
|
||||
let max = args.parse_value::<usize>("max").map_err(arg_err)?;
|
||||
let grep = args.value("grep");
|
||||
let utf16 = args.has("utf16");
|
||||
|
||||
let pid = resolve_pid(&args)?;
|
||||
let regions = maps::read_maps(pid)?;
|
||||
let mem = ProcMem::open(pid)?;
|
||||
let mods = image::modules(®ions, &mem);
|
||||
|
||||
let targets: Vec<Region> = if let Some(range) = args.value("range") {
|
||||
let (a, b) = range
|
||||
.split_once('-')
|
||||
.ok_or_else(|| new_invalid(ArgError("--range wants START-END".into())))?;
|
||||
let start = parse_addr(a).map_err(arg_err)?;
|
||||
let end = parse_addr(b).map_err(arg_err)?;
|
||||
if end <= start {
|
||||
return Err(new_invalid(ArgError(format!(
|
||||
"--range end {end:#x} is not above start {start:#x}"
|
||||
))));
|
||||
}
|
||||
writeln!(out, "scanning {start:#x}-{end:#x} ({})", human(end - start))?;
|
||||
vec![Region {
|
||||
start,
|
||||
end,
|
||||
perms: "r--p".to_string(),
|
||||
offset: 0,
|
||||
path: None,
|
||||
}]
|
||||
} else if let Some(name) = args.value("module") {
|
||||
let m = image::find_module(&mods, name).ok_or_else(|| {
|
||||
new_invalid(ArgError(format!(
|
||||
"no module matching {name:?} in pid {pid}"
|
||||
)))
|
||||
})?;
|
||||
writeln!(
|
||||
out,
|
||||
"scanning {} image span {:#x}-{:#x} ({})\n from {}",
|
||||
m.name,
|
||||
m.base,
|
||||
m.end(),
|
||||
human(m.end() - m.base),
|
||||
m.path
|
||||
)?;
|
||||
scan::scan_targets(®ions, Some(m), false)
|
||||
} else {
|
||||
// Default scope: anonymous private memory, where a packed executable's
|
||||
// decrypted data lives.
|
||||
let t = scan::scan_targets(®ions, None, true);
|
||||
let bytes: u64 = t.iter().map(|r| r.size()).sum();
|
||||
writeln!(
|
||||
out,
|
||||
"scanning {} anonymous private regions ({})",
|
||||
t.len(),
|
||||
human(bytes)
|
||||
)?;
|
||||
t
|
||||
};
|
||||
|
||||
let mut count = 0usize;
|
||||
let stats = scan::find_strings(&mem, &targets, utf16, min, grep, max, |va, s| {
|
||||
count += 1;
|
||||
// Ignoring the write error here keeps the closure simple; a broken pipe
|
||||
// is caught when the buffer is flushed in main.
|
||||
let _ = writeln!(out, "{va:#014x} {}", s);
|
||||
});
|
||||
|
||||
writeln!(out)?;
|
||||
writeln!(out, "{count} strings; {}", stats.summary())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- read
|
||||
|
||||
fn cmd_read<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
|
||||
let args = Args::parse(argv, &["pid"]).map_err(arg_err)?;
|
||||
args.reject_unknown(&["pid"]).map_err(arg_err)?;
|
||||
if args.positional.len() < 2 {
|
||||
return Err(new_invalid(ArgError("read needs <va> and <len>".into())));
|
||||
}
|
||||
let va = parse_addr(&args.positional[0]).map_err(arg_err)?;
|
||||
let len = parse_len(&args.positional[1]).map_err(arg_err)?;
|
||||
if len == 0 || len > 64 * 1024 * 1024 {
|
||||
return Err(new_invalid(ArgError(format!(
|
||||
"length {len} out of range (1 .. 64 MiB)"
|
||||
))));
|
||||
}
|
||||
|
||||
let pid = resolve_pid(&args)?;
|
||||
let regions = maps::read_maps(pid)?;
|
||||
let mem = ProcMem::open(pid)?;
|
||||
let mods = image::modules(®ions, &mem);
|
||||
|
||||
writeln!(out, "{va:#x} {}", image::describe(va, &mods, ®ions))?;
|
||||
let data = mem.read_exact(va, len as usize)?;
|
||||
dump::hexdump(out, va, &data, "")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn new_invalid(e: ArgError) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, e.0)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Parsing `/proc/<pid>/maps` and finding the FIFA 17 process.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Region {
|
||||
pub start: u64,
|
||||
pub end: u64,
|
||||
/// The raw four permission characters, e.g. `rwxp` or `r--s`.
|
||||
pub perms: String,
|
||||
/// File offset this mapping starts at, meaningless for anonymous regions.
|
||||
pub offset: u64,
|
||||
/// `None` for anonymous mappings.
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
pub fn size(&self) -> u64 {
|
||||
self.end - self.start
|
||||
}
|
||||
pub fn readable(&self) -> bool {
|
||||
self.perms.as_bytes().first() == Some(&b'r')
|
||||
}
|
||||
pub fn writable(&self) -> bool {
|
||||
self.perms.as_bytes().get(1) == Some(&b'w')
|
||||
}
|
||||
pub fn executable(&self) -> bool {
|
||||
self.perms.as_bytes().get(2) == Some(&b'x')
|
||||
}
|
||||
pub fn private(&self) -> bool {
|
||||
self.perms.as_bytes().get(3) == Some(&b'p')
|
||||
}
|
||||
pub fn anonymous(&self) -> bool {
|
||||
self.path.is_none()
|
||||
}
|
||||
/// Pseudo-files the kernel exposes. Reading `[vvar]` through
|
||||
/// `/proc/pid/mem` fails, and `[vsyscall]` is not interesting here.
|
||||
pub fn pseudo(&self) -> bool {
|
||||
matches!(self.path.as_deref(), Some(p) if p.starts_with('['))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_maps(pid: i32) -> io::Result<Vec<Region>> {
|
||||
let text = fs::read_to_string(format!("/proc/{pid}/maps")).map_err(|e| {
|
||||
let hint = if fs::metadata(format!("/proc/{pid}")).is_err() {
|
||||
format!("no process with pid {pid}")
|
||||
} else {
|
||||
format!("pid {pid} exists but its maps are unreadable (different user?)")
|
||||
};
|
||||
io::Error::new(e.kind(), format!("reading /proc/{pid}/maps: {hint}"))
|
||||
})?;
|
||||
Ok(text.lines().filter_map(parse_line).collect())
|
||||
}
|
||||
|
||||
/// The target's `comm`, so output can name what was actually inspected rather
|
||||
/// than assuming an explicit `--pid` pointed at the game.
|
||||
pub fn read_comm(pid: i32) -> String {
|
||||
fs::read_to_string(format!("/proc/{pid}/comm"))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "?".to_string())
|
||||
}
|
||||
|
||||
/// Pull the next whitespace-delimited field starting at `cursor`, advancing it.
|
||||
fn next_field<'a>(line: &'a str, cursor: &mut usize) -> Option<&'a str> {
|
||||
let bytes = line.as_bytes();
|
||||
while *cursor < bytes.len() && bytes[*cursor].is_ascii_whitespace() {
|
||||
*cursor += 1;
|
||||
}
|
||||
let start = *cursor;
|
||||
while *cursor < bytes.len() && !bytes[*cursor].is_ascii_whitespace() {
|
||||
*cursor += 1;
|
||||
}
|
||||
if start == *cursor {
|
||||
None
|
||||
} else {
|
||||
Some(&line[start..*cursor])
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_line(line: &str) -> Option<Region> {
|
||||
// Format: `start-end perms offset dev inode path`
|
||||
//
|
||||
// The path may contain spaces (`/mnt/games/FIFA 17/FIFA17.exe`) and may
|
||||
// carry a ` (deleted)` suffix, so we consume exactly five leading fields by
|
||||
// position and take the untouched remainder as the path.
|
||||
//
|
||||
// Doing this with `line.find(inode)` to locate the split point is a trap:
|
||||
// the inode of an anonymous mapping is "0", and `find("0")` happily matches
|
||||
// a zero digit inside the address range at the very start of the line. That
|
||||
// silently turns half the address into a path. Hence the explicit cursor.
|
||||
let mut cursor = 0usize;
|
||||
let range = next_field(line, &mut cursor)?;
|
||||
let perms = next_field(line, &mut cursor)?;
|
||||
let offset = next_field(line, &mut cursor)?;
|
||||
let _dev = next_field(line, &mut cursor)?;
|
||||
let _inode = next_field(line, &mut cursor)?;
|
||||
|
||||
let (start, end) = range.split_once('-')?;
|
||||
let start = u64::from_str_radix(start, 16).ok()?;
|
||||
let end = u64::from_str_radix(end, 16).ok()?;
|
||||
|
||||
let tail = line[cursor..].trim();
|
||||
let path = if tail.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tail.to_string())
|
||||
};
|
||||
|
||||
Some(Region {
|
||||
start,
|
||||
end,
|
||||
perms: perms.to_string(),
|
||||
offset: u64::from_str_radix(offset, 16).ok()?,
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the FIFA 17 process.
|
||||
///
|
||||
/// `comm` is the authority, NOT `cmdline`. Under Proton there are a dozen
|
||||
/// helper processes (bash, umu-run, srt-bwrap, pv-adverb, proton, umu.exe)
|
||||
/// whose command lines mention fifa17, and at least one of them
|
||||
/// (`umu.exe /mnt/games/FIFA 17/_fifa17.exe`) is a convincing decoy. Only the
|
||||
/// real game has `comm == "FIFA17.exe"`. Its `/proc/<pid>/exe` points at
|
||||
/// wine64-preloader, which is expected and is not a reason to doubt the match.
|
||||
pub fn find_pid(comm_name: &str) -> io::Result<i32> {
|
||||
let mut hits = Vec::new();
|
||||
for entry in fs::read_dir("/proc")? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
let Ok(pid) = name.parse::<i32>() else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) {
|
||||
if comm.trim() == comm_name {
|
||||
hits.push(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
match hits.len() {
|
||||
0 => Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("no process with comm == {comm_name:?}; is the game running? pass --pid to override"),
|
||||
)),
|
||||
1 => Ok(hits[0]),
|
||||
_ => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("{} processes have comm == {comm_name:?}: {hits:?}; pass --pid to disambiguate", hits.len()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn human(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = 0;
|
||||
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{bytes} B")
|
||||
} else {
|
||||
format!("{value:.2} {}", UNITS[unit])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Read-only access to another process's address space.
|
||||
//!
|
||||
//! # The safety property this module exists to guarantee
|
||||
//!
|
||||
//! A live FIFA 17 session may be running while this tool is used. Corrupting it
|
||||
//! costs the user their progress and their patience. So the guarantee here is
|
||||
//! structural, not a matter of being careful:
|
||||
//!
|
||||
//! * `/proc/<pid>/mem` is opened with [`File::open`], which is `O_RDONLY`.
|
||||
//! There is no [`std::fs::OpenOptions`] anywhere in this crate.
|
||||
//! * [`ProcMem`] exposes `&self` read methods only. It hands out no `&mut File`
|
||||
//! and no raw fd, so no caller outside this module can upgrade the handle.
|
||||
//! * Nothing in the crate calls `ptrace`, sends a signal, or writes to any
|
||||
//! path under `/proc`.
|
||||
//!
|
||||
//! Even if a caller tried to write, the kernel would reject it on an `O_RDONLY`
|
||||
//! descriptor. The type system and the open mode agree, which is the point.
|
||||
//!
|
||||
//! # Why pread and not seek + read
|
||||
//!
|
||||
//! [`FileExt::read_at`] is `pread(2)`: it takes the offset as an argument
|
||||
//! instead of mutating a shared file cursor. That means a `&ProcMem` can be
|
||||
//! shared across threads later without a mutex and without one thread's seek
|
||||
//! corrupting another's read. It also removes a whole class of "forgot to seek"
|
||||
//! bugs. There is never a reason to prefer seek+read here.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::os::unix::fs::FileExt;
|
||||
|
||||
/// The page size we assume when stepping over an unreadable hole. Every x86-64
|
||||
/// mapping is a multiple of this, so it is a safe granularity for recovery.
|
||||
pub const PAGE: u64 = 4096;
|
||||
|
||||
/// A read-only handle on a process's memory.
|
||||
pub struct ProcMem {
|
||||
file: File,
|
||||
}
|
||||
|
||||
/// What a single chunk read produced.
|
||||
pub enum ChunkRead {
|
||||
/// `n` bytes landed in the buffer. May be shorter than requested when the
|
||||
/// read ran into an unmapped hole partway through.
|
||||
Got(usize),
|
||||
/// Nothing readable at this address at all.
|
||||
Hole,
|
||||
}
|
||||
|
||||
impl ProcMem {
|
||||
/// Open the target read-only. See the module docs for why this is
|
||||
/// `File::open` and must stay that way.
|
||||
pub fn open(pid: i32) -> io::Result<Self> {
|
||||
let file = File::open(format!("/proc/{pid}/mem")).map_err(|e| {
|
||||
io::Error::new(
|
||||
e.kind(),
|
||||
format!("opening /proc/{pid}/mem: {e} (same-user or CAP_SYS_PTRACE required)"),
|
||||
)
|
||||
})?;
|
||||
Ok(Self { file })
|
||||
}
|
||||
|
||||
/// Best-effort read. Never fatal: a hole reports [`ChunkRead::Hole`] rather
|
||||
/// than propagating an error, because in a 3 GB sweep unreadable regions are
|
||||
/// the normal case, not an exceptional one.
|
||||
///
|
||||
/// Guard pages, Wine's special mappings and pages Denuvo has not faulted in
|
||||
/// are all marked readable in `/proc/<pid>/maps` yet return `EIO` here. The
|
||||
/// caller counts these and reports the total so the user knows the sweep was
|
||||
/// partial.
|
||||
pub fn read_chunk(&self, va: u64, buf: &mut [u8]) -> ChunkRead {
|
||||
match self.file.read_at(buf, va) {
|
||||
Ok(0) | Err(_) => ChunkRead::Hole,
|
||||
Ok(n) => ChunkRead::Got(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict read for cases where a short read is genuinely an error, such as
|
||||
/// an explicit `futmem read <va> <len>` the user asked for by hand.
|
||||
pub fn read_exact(&self, va: u64, len: usize) -> io::Result<Vec<u8>> {
|
||||
let mut buf = vec![0u8; len];
|
||||
self.file.read_exact_at(&mut buf, va).map_err(|e| {
|
||||
io::Error::new(
|
||||
e.kind(),
|
||||
format!("reading {len} bytes at {va:#x}: {e} (address may be unmapped)"),
|
||||
)
|
||||
})?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Read up to `len` bytes, returning however many were actually available.
|
||||
/// Used for printing context around a hit that sits near the end of a region.
|
||||
pub fn read_partial(&self, va: u64, len: usize) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; len];
|
||||
match self.file.read_at(&mut buf, va) {
|
||||
Ok(n) => {
|
||||
buf.truncate(n);
|
||||
buf
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Chunked sweeping of a remote address space, plus the two things we sweep
|
||||
//! for: byte patterns and printable strings.
|
||||
//!
|
||||
//! # Why chunking, and the off-by-one that ruins scanners
|
||||
//!
|
||||
//! The target has roughly 3 GB resident. Reading a region in one allocation is
|
||||
//! wasteful and can fail outright, so regions are walked in 4 MiB chunks.
|
||||
//!
|
||||
//! The classic bug in every hand-rolled scanner is that a pattern straddling a
|
||||
//! chunk boundary is never found: the tail of chunk N holds the first few bytes
|
||||
//! and the head of chunk N+1 holds the rest, and neither buffer contains the
|
||||
//! whole thing. The fix is to overlap consecutive chunks by `pattern_len - 1`
|
||||
//! bytes.
|
||||
//!
|
||||
//! That specific overlap is exactly right, and it is worth showing why it is
|
||||
//! neither too small nor too large. Let a chunk cover `[0, n)` and the pattern
|
||||
//! have length `P`. A match starting at index `s` occupies `s ..= s + P - 1`, so
|
||||
//! the last match fully inside the chunk starts at `s = n - P`. Any match
|
||||
//! starting at `s > n - P` runs off the end and must be caught by the next
|
||||
//! chunk, so the next chunk has to begin at or before `n - P + 1`. Advancing by
|
||||
//! `n - (P - 1)` starts it at precisely `n - P + 1`:
|
||||
//!
|
||||
//! * Nothing is missed: every straddling match starts at `s >= n - P + 1`,
|
||||
//! which is inside the next chunk.
|
||||
//! * Nothing is double-reported: the first index of the overlap is
|
||||
//! `n - P + 1`, which is strictly greater than `n - P`, the last index that
|
||||
//! can host a complete match in this chunk. The two windows of *reportable*
|
||||
//! match starts are disjoint even though the byte windows overlap.
|
||||
//!
|
||||
//! Overlapping by `P` instead would report every boundary-straddling match
|
||||
//! twice; overlapping by `P - 2` would miss one alignment. Hence `P - 1`.
|
||||
//!
|
||||
//! # Holes
|
||||
//!
|
||||
//! A region marked readable in `/proc/<pid>/maps` is frequently not readable in
|
||||
//! practice: guard pages, Wine's special mappings, and pages Denuvo has not
|
||||
//! faulted in all return `EIO`. These are counted and stepped over a page at a
|
||||
//! time, never propagated as errors, because in a sweep this size they are
|
||||
//! routine. The counts are reported so the user knows the sweep was partial and
|
||||
//! does not read a zero-hit result as proof of absence.
|
||||
|
||||
use crate::image::Module;
|
||||
use crate::maps::Region;
|
||||
use crate::mem::{ChunkRead, ProcMem, PAGE};
|
||||
|
||||
pub const CHUNK: usize = 4 * 1024 * 1024;
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct SweepStats {
|
||||
pub regions_scanned: usize,
|
||||
/// Regions from which not a single byte could be read.
|
||||
pub regions_skipped: usize,
|
||||
/// Individual chunk reads that hit an unreadable hole.
|
||||
pub holes: usize,
|
||||
pub bytes_read: u64,
|
||||
}
|
||||
|
||||
impl SweepStats {
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
"scanned {} regions ({}), skipped {} unreadable regions, {} holes stepped over",
|
||||
self.regions_scanned,
|
||||
crate::maps::human(self.bytes_read),
|
||||
self.regions_skipped,
|
||||
self.holes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn align_up(va: u64, align: u64) -> u64 {
|
||||
va.div_ceil(align) * align
|
||||
}
|
||||
|
||||
/// Walk one region in chunks, invoking `f(chunk_va, bytes, contiguous)`.
|
||||
///
|
||||
/// `contiguous` is true when this chunk's data continues directly from the
|
||||
/// previous callback with no gap, which string extraction needs in order to
|
||||
/// join a run that spans a boundary. `overlap` is `pattern_len - 1` for pattern
|
||||
/// search and 0 for stateful scanners that track continuity themselves.
|
||||
///
|
||||
/// Returns early (`false`) if `f` signals it has seen enough.
|
||||
fn sweep_region<F>(
|
||||
mem: &ProcMem,
|
||||
region: &Region,
|
||||
overlap: usize,
|
||||
buf: &mut [u8],
|
||||
stats: &mut SweepStats,
|
||||
f: &mut F,
|
||||
) -> bool
|
||||
where
|
||||
F: FnMut(u64, &[u8], bool) -> bool,
|
||||
{
|
||||
let mut pos = region.start;
|
||||
let mut contiguous = false;
|
||||
let mut read_anything = false;
|
||||
|
||||
while pos < region.end {
|
||||
let want = (buf.len() as u64).min(region.end - pos) as usize;
|
||||
match mem.read_chunk(pos, &mut buf[..want]) {
|
||||
ChunkRead::Hole => {
|
||||
stats.holes += 1;
|
||||
contiguous = false;
|
||||
// Step to the next page; the current one is unreadable.
|
||||
pos = align_up(pos + 1, PAGE);
|
||||
}
|
||||
ChunkRead::Got(n) => {
|
||||
read_anything = true;
|
||||
stats.bytes_read += n as u64;
|
||||
if !f(pos, &buf[..n], contiguous) {
|
||||
return false;
|
||||
}
|
||||
if pos + n as u64 >= region.end {
|
||||
break;
|
||||
}
|
||||
if n < want {
|
||||
// Short read: an unmapped hole begins at pos + n. No pattern
|
||||
// can span a hole, so no overlap is needed here; resume on
|
||||
// the next page boundary.
|
||||
contiguous = false;
|
||||
pos = align_up(pos + n as u64 + 1, PAGE);
|
||||
} else {
|
||||
if n <= overlap {
|
||||
break; // cannot make forward progress
|
||||
}
|
||||
contiguous = true;
|
||||
pos += (n - overlap) as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if read_anything {
|
||||
stats.regions_scanned += 1;
|
||||
} else {
|
||||
stats.regions_skipped += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Which regions a sweep should touch.
|
||||
pub fn scan_targets(regions: &[Region], module: Option<&Module>, anon_only: bool) -> Vec<Region> {
|
||||
regions
|
||||
.iter()
|
||||
.filter(|r| r.readable() && !r.pseudo())
|
||||
.filter(|r| !anon_only || r.anonymous())
|
||||
.filter_map(|r| match module {
|
||||
None => Some(r.clone()),
|
||||
// Clip the region to the module's image span rather than dropping
|
||||
// it: under Wine a module's sections live in large anonymous
|
||||
// regions that may extend past the image.
|
||||
Some(m) => {
|
||||
let start = r.start.max(m.base);
|
||||
let end = r.end.min(m.end());
|
||||
if start < end {
|
||||
let mut clipped = (*r).clone();
|
||||
clipped.start = start;
|
||||
clipped.end = end;
|
||||
Some(clipped)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// Search every target region for `pattern`. Calls `hit(va)` per match.
|
||||
pub fn find_pattern<F>(
|
||||
mem: &ProcMem,
|
||||
targets: &[Region],
|
||||
pattern: &[u8],
|
||||
max: Option<usize>,
|
||||
mut hit: F,
|
||||
) -> SweepStats
|
||||
where
|
||||
F: FnMut(u64),
|
||||
{
|
||||
let mut stats = SweepStats::default();
|
||||
if pattern.is_empty() {
|
||||
return stats;
|
||||
}
|
||||
let finder = memchr::memmem::Finder::new(pattern);
|
||||
let overlap = pattern.len() - 1;
|
||||
// The buffer must comfortably exceed the overlap or progress stalls.
|
||||
let mut buf = vec![0u8; CHUNK.max(pattern.len() * 4)];
|
||||
let mut found = 0usize;
|
||||
|
||||
for region in targets {
|
||||
let keep_going = sweep_region(
|
||||
mem,
|
||||
region,
|
||||
overlap,
|
||||
&mut buf,
|
||||
&mut stats,
|
||||
&mut |base, data, _contiguous| {
|
||||
for off in finder.find_iter(data) {
|
||||
hit(base + off as u64);
|
||||
found += 1;
|
||||
if max.is_some_and(|m| found >= m) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
},
|
||||
);
|
||||
if !keep_going {
|
||||
break;
|
||||
}
|
||||
}
|
||||
stats
|
||||
}
|
||||
|
||||
fn printable(b: u8) -> bool {
|
||||
(0x20..=0x7e).contains(&b)
|
||||
}
|
||||
|
||||
/// Extracts printable runs, carrying an unfinished run across contiguous chunks
|
||||
/// so a string straddling a boundary is still emitted whole.
|
||||
struct StringScanner {
|
||||
utf16: bool,
|
||||
min: usize,
|
||||
run: Vec<u8>,
|
||||
run_start: u64,
|
||||
open: bool,
|
||||
/// UTF-16 only: a low byte at the very end of a chunk whose high byte will
|
||||
/// arrive in the next one.
|
||||
carry: Option<(u64, u8)>,
|
||||
}
|
||||
|
||||
impl StringScanner {
|
||||
fn new(utf16: bool, min: usize) -> Self {
|
||||
Self {
|
||||
utf16,
|
||||
min,
|
||||
run: Vec::with_capacity(256),
|
||||
run_start: 0,
|
||||
open: false,
|
||||
carry: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn flush<F: FnMut(u64, &str)>(&mut self, emit: &mut F) {
|
||||
if self.open && self.run.len() >= self.min {
|
||||
// Runs are printable ASCII by construction, so this cannot fail.
|
||||
if let Ok(s) = std::str::from_utf8(&self.run) {
|
||||
emit(self.run_start, s);
|
||||
}
|
||||
}
|
||||
self.run.clear();
|
||||
self.open = false;
|
||||
}
|
||||
|
||||
fn push<F: FnMut(u64, &str)>(&mut self, va: u64, b: u8, emit: &mut F) {
|
||||
if !self.open {
|
||||
self.open = true;
|
||||
self.run_start = va;
|
||||
}
|
||||
self.run.push(b);
|
||||
// Guard against a pathological all-printable megabyte eating memory.
|
||||
if self.run.len() >= 4096 {
|
||||
self.flush(emit);
|
||||
}
|
||||
}
|
||||
|
||||
fn feed<F: FnMut(u64, &str)>(
|
||||
&mut self,
|
||||
base: u64,
|
||||
data: &[u8],
|
||||
contiguous: bool,
|
||||
emit: &mut F,
|
||||
) {
|
||||
if !contiguous {
|
||||
self.flush(emit);
|
||||
self.carry = None;
|
||||
}
|
||||
if self.utf16 {
|
||||
self.feed_utf16(base, data, emit);
|
||||
} else {
|
||||
for (i, &b) in data.iter().enumerate() {
|
||||
if printable(b) {
|
||||
self.push(base + i as u64, b, emit);
|
||||
} else {
|
||||
self.flush(emit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_utf16<F: FnMut(u64, &str)>(&mut self, base: u64, data: &[u8], emit: &mut F) {
|
||||
let mut i = 0usize;
|
||||
// A pair split across the chunk boundary: complete it if the high byte
|
||||
// is the expected 0x00, otherwise the run ends here.
|
||||
if let Some((addr, lo)) = self.carry.take() {
|
||||
if data.first() == Some(&0) && printable(lo) {
|
||||
self.push(addr, lo, emit);
|
||||
i = 1;
|
||||
} else {
|
||||
self.flush(emit);
|
||||
}
|
||||
}
|
||||
while i + 1 < data.len() {
|
||||
let (lo, hi) = (data[i], data[i + 1]);
|
||||
if hi == 0 && printable(lo) {
|
||||
self.push(base + i as u64, lo, emit);
|
||||
i += 2;
|
||||
} else {
|
||||
self.flush(emit);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if i < data.len() {
|
||||
self.carry = Some((base + i as u64, data[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract strings from every target region. Calls `emit(va, text)`.
|
||||
pub fn find_strings<F>(
|
||||
mem: &ProcMem,
|
||||
targets: &[Region],
|
||||
utf16: bool,
|
||||
min: usize,
|
||||
grep: Option<&str>,
|
||||
max: Option<usize>,
|
||||
mut emit: F,
|
||||
) -> SweepStats
|
||||
where
|
||||
F: FnMut(u64, &str),
|
||||
{
|
||||
let mut stats = SweepStats::default();
|
||||
let mut buf = vec![0u8; CHUNK];
|
||||
let grep_lower = grep.map(|g| g.to_ascii_lowercase());
|
||||
let mut count = 0usize;
|
||||
|
||||
for region in targets {
|
||||
let mut scanner = StringScanner::new(utf16, min);
|
||||
let mut stop = false;
|
||||
// overlap 0: the scanner tracks continuity itself via `contiguous`.
|
||||
let keep_going = sweep_region(
|
||||
mem,
|
||||
region,
|
||||
0,
|
||||
&mut buf,
|
||||
&mut stats,
|
||||
&mut |base, data, contiguous| {
|
||||
scanner.feed(base, data, contiguous, &mut |va, s| {
|
||||
let matches = match &grep_lower {
|
||||
Some(g) => s.to_ascii_lowercase().contains(g.as_str()),
|
||||
None => true,
|
||||
};
|
||||
if matches {
|
||||
emit(va, s);
|
||||
count += 1;
|
||||
if max.is_some_and(|m| count >= m) {
|
||||
stop = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
!stop
|
||||
},
|
||||
);
|
||||
scanner.flush(&mut |va, s| {
|
||||
let matches = match &grep_lower {
|
||||
Some(g) => s.to_ascii_lowercase().contains(g.as_str()),
|
||||
None => true,
|
||||
};
|
||||
if matches {
|
||||
emit(va, s);
|
||||
}
|
||||
});
|
||||
if !keep_going || stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
stats
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
gvenv/
|
||||
Regular → Executable
+115
-4
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches
|
||||
the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches."""
|
||||
import glob, time, struct
|
||||
import glob, time, struct, sys
|
||||
|
||||
# Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches
|
||||
import glob, time, os
|
||||
@@ -24,7 +24,46 @@ STORE_PATCHES = {
|
||||
0x1800175aa: NOP2,
|
||||
}
|
||||
|
||||
LOG="/tmp/autopatch.log"
|
||||
# Store resolver crash-guard for the empty "My Packs" case (bug 6c; PROVEN R1 on the
|
||||
# tested FIFA 17 build -- see docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV and
|
||||
# docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md).
|
||||
#
|
||||
# When no `mypacks` group exists, FIFA's Store resolver receives category id -1. CardsDLL
|
||||
# FUN_1800147f0 @ 0x180014858 is `JNZ 0x14869` (75 0f): the original treats every non-zero
|
||||
# category (including -1) as resolvable, calls FUN_180014420, gets NULL, and crashes at the
|
||||
# [NULL+0x48] deref in FUN_1800147f0 (0x180014882). Changing JNZ->JG (7f 0f) preserves
|
||||
# positive-category resolution (EDI>0 branch) while routing zero/negative categories through
|
||||
# the existing Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.
|
||||
#
|
||||
# CAVEAT: this guards the category SIGN only. It does NOT protect a stale *positive* invalid
|
||||
# ordinal produced by changing the Store group topology (sentinel-present <-> sentinel-absent)
|
||||
# DURING one running FIFA process -- that reproduced the same crash in the confounded run F3.
|
||||
# The empty-My-Packs representation MUST stay stable for a FIFA session (see the SESSION-STABLE
|
||||
# invariant in the client-fix plan).
|
||||
#
|
||||
# Orig-verified / fail-closed: applied only when the live bytes are the known original (75 0f);
|
||||
# already-patched (7f 0f) is a no-op; anything else is logged and SKIPPED (never blindly
|
||||
# overwritten), so an unrecognised CardsDLL build is not patched.
|
||||
STORE_PATCHES_GUARDED = {
|
||||
0x180014858: (bytes.fromhex("750f"), bytes.fromhex("7f0f")), # JNZ 0x14869 -> JG 0x14869
|
||||
}
|
||||
|
||||
# Capability advertised to the launcher/backend once the resolver guard is VERIFIED
|
||||
# live in a specific FIFA process (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md #3/#4).
|
||||
EMPTY_MYPACKS_RESOLVER_CAPABILITY = "fifa17.empty_mypacks_resolver"
|
||||
EMPTY_MYPACKS_RESOLVER_VERSION = 1
|
||||
|
||||
# The guarded site whose verified enforcement backs the capability above.
|
||||
RESOLVER_GUARD_VA = 0x180014858
|
||||
|
||||
# Per-FIFA-pid guard status (fail-closed; FIFA17_PATCHED_CLIENT_CAPABILITY.md #4).
|
||||
GUARD_NOT_ATTEMPTED = "NOT_ATTEMPTED" # CardsDLL not mapped / guard not yet evaluated
|
||||
GUARD_VERIFIED = "VERIFIED" # live bytes == patch after enforcement (patch or noop)
|
||||
GUARD_UNSUPPORTED_BUILD = "UNSUPPORTED_BUILD" # neither original nor patched (guarded_action -> skip)
|
||||
GUARD_WRITE_FAILED = "WRITE_FAILED" # /proc/<pid>/mem write raised
|
||||
GUARD_VERIFY_FAILED = "VERIFY_FAILED" # post-write re-read != patch
|
||||
|
||||
LOG=os.environ.get("OPENFUT_AUTOPATCH_LOG", f"/tmp/openfut-autopatch-{os.getuid()}.log")
|
||||
|
||||
def log(m):
|
||||
line=f"[{time.strftime('%H:%M:%S')}] {m}"
|
||||
@@ -52,11 +91,55 @@ def wr(pid,va,b):
|
||||
with open(f'/proc/{pid}/mem','r+b') as f:
|
||||
f.seek(va); f.write(b)
|
||||
|
||||
def guarded_action(cur, orig, patch):
|
||||
"""Fail-closed decision for a guarded byte patch (see STORE_PATCHES_GUARDED).
|
||||
|
||||
Returns "noop" when the live bytes are already patched, "patch" when they are the
|
||||
known original (safe to apply), or "skip" for anything else -- an unrecognised
|
||||
CardsDLL build that must never be blindly overwritten.
|
||||
"""
|
||||
if cur == patch:
|
||||
return "noop"
|
||||
if cur == orig:
|
||||
return "patch"
|
||||
return "skip"
|
||||
|
||||
def guard_state_after(cur_before, orig, patch, wrote_ok, cur_after):
|
||||
"""Map a guarded-patch enforcement outcome to a per-pid guard STATE (pure).
|
||||
|
||||
Mirrors guarded_action's decision, extended with post-write verification so the
|
||||
caller advertises the capability only on VERIFIED. No /proc access -- unit-testable.
|
||||
|
||||
- cur_before == patch -> VERIFIED (already patched; guarded_action "noop")
|
||||
- cur_before == orig -> WRITE_FAILED if the write raised, else VERIFIED when the
|
||||
re-read is patch, else VERIFY_FAILED (guarded_action "patch")
|
||||
- otherwise -> UNSUPPORTED_BUILD (guarded_action "skip")
|
||||
"""
|
||||
if cur_before == patch:
|
||||
return GUARD_VERIFIED
|
||||
if cur_before == orig:
|
||||
if not wrote_ok:
|
||||
return GUARD_WRITE_FAILED
|
||||
if cur_after == patch:
|
||||
return GUARD_VERIFIED
|
||||
return GUARD_VERIFY_FAILED
|
||||
return GUARD_UNSUPPORTED_BUILD
|
||||
|
||||
patched=set()
|
||||
store_patched=set()
|
||||
guard_reported=set()
|
||||
|
||||
log("=== AUTOPATCH watching for FIFA17.exe ===")
|
||||
while True:
|
||||
if __name__ == "__main__":
|
||||
launcher_pid = None
|
||||
if "--launcher-pid" in sys.argv:
|
||||
try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1])
|
||||
except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid")
|
||||
|
||||
log("=== AUTOPATCH watching for FIFA17.exe ===")
|
||||
while True:
|
||||
if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"):
|
||||
log(f"launcher pid {launcher_pid} exited; stopping autopatch")
|
||||
break
|
||||
for pid in find_pids():
|
||||
if pid not in patched:
|
||||
try:
|
||||
@@ -82,6 +165,34 @@ while True:
|
||||
if rd(pid, live, len(data)) != data:
|
||||
wr(pid, live, data)
|
||||
log(f"pid {pid}: ENFORCED store patch @ {live:#x}")
|
||||
for va, (orig, patch) in STORE_PATCHES_GUARDED.items():
|
||||
live = cbase + (va - IMG_BASE)
|
||||
cur = rd(pid, live, len(patch))
|
||||
action = guarded_action(cur, orig, patch)
|
||||
wrote_ok = True
|
||||
cur_after = cur
|
||||
if action == "patch":
|
||||
try:
|
||||
wr(pid, live, patch)
|
||||
log(f"pid {pid}: ENFORCED guarded store patch @ {live:#x} (JNZ->JG, empty My Packs)")
|
||||
except Exception as e:
|
||||
wrote_ok = False
|
||||
log(f"pid {pid}: guarded patch write failed @ {live:#x}: {e}")
|
||||
if wrote_ok:
|
||||
try:
|
||||
cur_after = rd(pid, live, len(patch))
|
||||
except Exception:
|
||||
cur_after = b""
|
||||
elif action == "skip":
|
||||
log(f"pid {pid}: SKIP guarded patch @ {live:#x}: unexpected {cur.hex()} (build mismatch)")
|
||||
# action == "noop": already patched; nothing to write.
|
||||
if va == RESOLVER_GUARD_VA and pid not in guard_reported:
|
||||
state = guard_state_after(cur, orig, patch, wrote_ok, cur_after)
|
||||
if state == GUARD_VERIFIED:
|
||||
log(f"[store-guard] verified capability {EMPTY_MYPACKS_RESOLVER_CAPABILITY}={EMPTY_MYPACKS_RESOLVER_VERSION} fifa_pid={pid}")
|
||||
else:
|
||||
log(f"[store-guard] guard status={state} fifa_pid={pid} (no capability advertised)")
|
||||
guard_reported.add(pid)
|
||||
if pid not in store_patched:
|
||||
log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}")
|
||||
store_patched.add(pid)
|
||||
|
||||
@@ -128,14 +128,52 @@ CLIENT_ID = ACCOUNT.CLIENT_ID
|
||||
PLATFORM = ACCOUNT.PLATFORM
|
||||
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
|
||||
|
||||
# ================================================================== config
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
def refresh_account_identity():
|
||||
"""Refresh launcher-selected identity before constructing a Blaze session.
|
||||
|
||||
The account sync endpoint runs in the separate UTAS process and atomically
|
||||
replaces the shared active-account file. Blaze snapshots these aliases for
|
||||
its response builders, so refresh them once at each new TCP session.
|
||||
"""
|
||||
global PERSONA_ID, PERSONA_NAME, USER_ID, EXT_ID, EMAIL, ACCOUNT_LOCALE_FALLBACK
|
||||
ACCOUNT.load(force=True)
|
||||
PERSONA_ID = ACCOUNT.persona_id
|
||||
PERSONA_NAME = ACCOUNT.persona_name
|
||||
USER_ID = ACCOUNT.user_id
|
||||
EXT_ID = ACCOUNT.ext_id
|
||||
EMAIL = ACCOUNT.email
|
||||
ACCOUNT_LOCALE_FALLBACK = ACCOUNT.account_locale_int
|
||||
|
||||
# ================================================================== config
|
||||
#
|
||||
# Client/server split support (OpenFUT dev-container): two env vars, both
|
||||
# defaulting to loopback so the original all-on-localhost flow is byte-identical.
|
||||
# OPENFUT_BIND — the address the listeners bind (0.0.0.0 in a container).
|
||||
# OPENFUT_ADVERTISE — the address this server hands back to the client for the
|
||||
# NEXT hop (Blaze host, roster/UTAS/telemetry/QoS URLs). On
|
||||
# 105-local this is 127.0.0.1; on the 120 server it is the
|
||||
# server's LAN IP so the game dials 120 directly after the
|
||||
# first (hook/DNAT-redirected) contact.
|
||||
import os as _os_cfg
|
||||
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
|
||||
_BIND = _os_cfg.environ.get("OPENFUT_BIND", "127.0.0.1")
|
||||
|
||||
def _ip_str_to_u32(ip):
|
||||
"""Dotted-quad -> big-endian u32 (matches the original (127<<24)|1 layout).
|
||||
Falls back to loopback if the advertise value isn't a bare IPv4 literal."""
|
||||
try:
|
||||
a, b, c, d = (int(x) for x in ip.split("."))
|
||||
return (a << 24) | (b << 16) | (c << 8) | d
|
||||
except Exception:
|
||||
return (127 << 24) | 1
|
||||
|
||||
HOST = _BIND
|
||||
REDIR_PORT = 42127
|
||||
BLAZE_PORT = 42130
|
||||
NUCLEUS_PORT = 42131
|
||||
BLAZE_IP_STR = "127.0.0.1"
|
||||
BLAZE_IP_U32 = (127 << 24) | 1
|
||||
BLAZE_IP_STR = _ADVERTISE
|
||||
BLAZE_IP_U32 = _ip_str_to_u32(_ADVERTISE)
|
||||
LOG = "/tmp/blaze_responder.log"
|
||||
RXDIR = "/tmp/blaze_rx"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -157,7 +195,9 @@ REPLY_EMPTY_TO_UNKNOWN = True
|
||||
# (grid-blaze order) or after (pamplona order). Both are reported to work.
|
||||
NOTIFY_BEFORE_LOGIN_REPLY = False
|
||||
|
||||
DUMP_FRAMES = True
|
||||
# Raw Fire2 frames and decoded TDF can contain auth/session material. Keep the
|
||||
# reverse-engineering capture path, but require an explicit opt-in for it.
|
||||
DUMP_FRAMES = os.environ.get("OPENFUT_BLAZE_DUMP_FRAMES") == "1"
|
||||
|
||||
_log_lock = threading.Lock()
|
||||
|
||||
@@ -525,7 +565,8 @@ OSDK_TICKER = []
|
||||
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
|
||||
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
|
||||
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
|
||||
ROSTER_HOST = "127.0.0.1:8081"
|
||||
ROSTER_HOST = "%s:8081" % _ADVERTISE
|
||||
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
|
||||
OSDK_ROSTER = [
|
||||
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
|
||||
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0
|
||||
@@ -562,7 +603,6 @@ IDENTITY_PARAMS = [
|
||||
# FUT_POW=1 ./openfut-fut.sh restart
|
||||
# and read /tmp/pow_server.log. FUT_POW=off is the instant fallback.
|
||||
POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094")
|
||||
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
|
||||
_POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes")
|
||||
OSDK_POW = [
|
||||
("FIFA_POW_URL", "http://%s/" % POW_HOST),
|
||||
@@ -571,6 +611,14 @@ OSDK_POW = [
|
||||
("POW_IS_ON", "1"),
|
||||
] if _POW_ON else []
|
||||
|
||||
# CardsDLL's shared web-file downloader also reads this key for FUT-owned content.
|
||||
# In particular, opening SBC downloads /fut/packs/loc/storepackdescriptions.<locale>.xml
|
||||
# after /sbs/sets succeeds. Keep the content base available even while the unrelated
|
||||
# POW API remains opt-in through FUT_POW/POW_IS_ON.
|
||||
FUT_CONTENT_CONFIG = [
|
||||
("FIFA_POW_CONTENT_SERVER_URL", "http://%s" % POW_CONTENT_HOST),
|
||||
]
|
||||
|
||||
CLIENT_CONFIGS = {
|
||||
"BlazeSDK": None, # built dynamically, see below
|
||||
"netres": OSDK_NETRES, # CFID (verified @0x143962be0)
|
||||
@@ -595,7 +643,7 @@ CLIENT_CONFIGS = {
|
||||
# /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/"
|
||||
# (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT
|
||||
# (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code).
|
||||
UTAS_BASE = "http://127.0.0.1:8099/"
|
||||
UTAS_BASE = "http://%s:8099/" % _ADVERTISE
|
||||
FUT_RS4_MODULES = [
|
||||
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
|
||||
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
|
||||
@@ -669,6 +717,55 @@ FUT_RS4_CONFIG = (
|
||||
"IS_FIFAPOINT_PURCHASABLE", "IS_EASTORE_SERVICE_READY",
|
||||
"COINS_PURCHASE_ENABLED", "POINTS_PURCHASE_ENABLED", "MONEY_PURCHASE_ENABLED",
|
||||
)]
|
||||
# FUT_TRADING: the transfer-market equivalent of the store block above.
|
||||
#
|
||||
# WHY THIS IS HERE AND NOT IN /settings. "Place on Transfer List" and "List on
|
||||
# Transfer Market" are greyed out because the TO_TRADE_PILE predicate
|
||||
# FUN_1801a7260 needs a service gate at vtable+0x270, which is
|
||||
# `movzx eax, byte [rcx+0x1fd2e]; ret`. That byte is the tradingEnabled gate and it
|
||||
# reads 0.
|
||||
#
|
||||
# Sending tradingEnabled through /settings does NOT move it, PROVEN live 2026-08-06:
|
||||
# the arm is right (case 0x336 writes param_2[10]) and the applier is right
|
||||
# (0x1fd2e = param_2[10] == 1), but the applier has NO caller Ghidra can see and is
|
||||
# not reachable from the settings deserializer. The decisive measurement: we served
|
||||
# maximumTradePileSize=77 and NO int gate field carries 77 (+0x1fd14=0, +0x1fd4c=0,
|
||||
# +0x1fd54=480). Every gate byte is a constructor default. That also explains
|
||||
# storeEnabled reading 1: a default, never our value.
|
||||
#
|
||||
# REFUTED 2026-08-06, KEPT ONLY AS A RECORD. THIS DOES NOT WORK. Do not turn it on
|
||||
# expecting an effect, and do not reason from it.
|
||||
#
|
||||
# The reasoning above was wrong in two places and the flag is inert:
|
||||
#
|
||||
# 1. IS_TRADING_ENABLED IS AN OUTPUT NAME, NOT AN INPUT. FUN_18006cc60 is a
|
||||
# PUBLISHER: at 0x18006ccc6 it does `call [rax+0x270]` (which reads gate byte
|
||||
# 0x1fd2e), then `lea rdx,[IS_TRADING_ENABLED]` and hands the value OUT under
|
||||
# that name. The only rip-relative reference to the literal 0x1801fc118 in the
|
||||
# whole of .text is that lea. There is no comparison against it anywhere, so a
|
||||
# client-config key of that name cannot be read as an input by anything. The same
|
||||
# is true of the IS_* store keys above, which means the store block may also be
|
||||
# inert and its apparent success was never actually attributed.
|
||||
# 2. The gate byte was briefly measured as 1 and that was over-claimed as a success.
|
||||
# On a fresh session it reads 0, and a thorough re-measurement read 0 on the very
|
||||
# pid where it had read 1. Either the first read was transient or something clears
|
||||
# it after login. The only writer of 0x1fd2e is FUN_18011dc50 at 0x18011dc91.
|
||||
#
|
||||
# What IS now known, and supersedes the "/settings is dead" claim in the note above:
|
||||
# FUN_18011dc50 is NOT unreachable. It is a VIRTUAL method at model vtable slot
|
||||
# +0x988 (absolute pointer at 0x18021cc28), which is why a direct-call search found
|
||||
# no callers. The real chain is
|
||||
# settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
|
||||
# -> completion callback FUN_180173e00 -> vt+0x988 / vt+0x998 -> gate bytes
|
||||
# and FUN_180173e00 bails before applying anything unless the int at response+0x1c
|
||||
# is zero. Which atom writes +0x1c is UNKNOWN and is the thing worth chasing.
|
||||
#
|
||||
# Default OFF and it should stay off.
|
||||
# NOTE: FUT_TRADING no longer does anything here. These keys are inert (output
|
||||
# names the DLL emits, never reads). The REAL trading fix is in utas_server.py:
|
||||
# userInfo.feature was banning trade. Left disabled so the flag has one meaning.
|
||||
+ ([] if True else
|
||||
[(k, "1") for k in ("tradingEnabled", "IS_TRADING_ENABLED")])
|
||||
# NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any
|
||||
# response -- proven inert (wf_96b6c0c5): they are JSON field names that route
|
||||
# to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md.
|
||||
@@ -682,18 +779,21 @@ FUT_RS4_CONFIG = (
|
||||
|
||||
|
||||
def client_config_for(cfid: str) -> list:
|
||||
"""-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we
|
||||
still wrap in a present CONF field -- never an empty frame).
|
||||
FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section
|
||||
CardsDLL reads is unproven, so serve them everywhere)."""
|
||||
"""Return sorted config rows for one section.
|
||||
|
||||
Unknown CFIDs still receive the shared FUT/content/POW rows because those
|
||||
consumers read the merged ``_all`` store and the contributing section is
|
||||
unproven. The response always carries a present CONF field.
|
||||
"""
|
||||
# OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's
|
||||
# FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which
|
||||
# section it happens to read is unproven. Empty list when FUT_POW is unset, so
|
||||
# this is a no-op by default. (Putting the keys ONLY under a hypothetical
|
||||
# "OSDK_POW" CFID would be dead code -- nothing is known to request that name.)
|
||||
if cfid == "BlazeSDK":
|
||||
return sorted(blazesdk_config() + FUT_RS4_CONFIG + OSDK_POW)
|
||||
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG + OSDK_POW)
|
||||
return sorted(blazesdk_config() + FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
|
||||
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG
|
||||
+ FUT_CONTENT_CONFIG + OSDK_POW)
|
||||
|
||||
|
||||
def fetch_config_response_fields(cfid: str) -> "OrderedDict":
|
||||
@@ -716,7 +816,7 @@ def qos_config() -> "OrderedDict":
|
||||
has NO SVID, unlike Mirror's Edge Catalyst)."""
|
||||
return OrderedDict([
|
||||
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
|
||||
("PSA", (STRING, "127.0.0.1")),
|
||||
("PSA", (STRING, _ADVERTISE)),
|
||||
("PSP", (INT, 17502)),
|
||||
]))),
|
||||
("LNP", (INT, 10)),
|
||||
@@ -1042,7 +1142,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
|
||||
client to have a well-formed config and then fail to connect quietly rather
|
||||
than resolve a real EA hostname."""
|
||||
tele = OrderedDict([ # GetTelemetryServerResponse (15)
|
||||
("ADRS", (STRING, "127.0.0.1")),
|
||||
("ADRS", (STRING, _ADVERTISE)),
|
||||
("ANON", (INT, 0)),
|
||||
("DISA", (STRING, "")),
|
||||
("EDCT", (INT, 0)),
|
||||
@@ -1059,7 +1159,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
|
||||
("SVNM", (STRING, "telemetry-openfut")),
|
||||
])
|
||||
tick = OrderedDict([ # GetTickerServerResponse (3)
|
||||
("ADRS", (STRING, "127.0.0.1")),
|
||||
("ADRS", (STRING, _ADVERTISE)),
|
||||
("PORT", (INT, 8999)),
|
||||
("SKEY", (STRING, "")),
|
||||
])
|
||||
@@ -1203,8 +1303,10 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
|
||||
log(" -- client locale 0x%08x captured for ALOC" % loc)
|
||||
resp = preauth_response_fields(service_name=sess.service_name)
|
||||
payload = encode_tdf(resp)
|
||||
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s"
|
||||
% (sess.service_name, len(payload), heat2.dump(resp)))
|
||||
log(" -> PreAuthResponse (INST=%r, %d payload bytes)"
|
||||
% (sess.service_name, len(payload)))
|
||||
if DUMP_FRAMES:
|
||||
log(" -> PreAuthResponse TDF:\n%s" % heat2.dump(resp))
|
||||
return [reply_to(hdr, payload)]
|
||||
|
||||
if cmd == CMD_PING:
|
||||
@@ -1218,6 +1320,7 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
|
||||
n = len(resp["CONF"][1][2])
|
||||
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
|
||||
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
|
||||
if DUMP_FRAMES:
|
||||
for k, v in resp["CONF"][1][2]:
|
||||
log(" %-32s = %s" % (k, v))
|
||||
return [reply_to(hdr, encode_tdf(resp))]
|
||||
@@ -1253,12 +1356,13 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
|
||||
sess.auth_code = get_str(fields or {}, "AUTH", "")
|
||||
sess.logged_in = True
|
||||
sess.login_time = int(time.time())
|
||||
log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus "
|
||||
"validation -- forged offline session)" % sess.auth_code)
|
||||
log(" == Authentication::login AUTH=[REDACTED] "
|
||||
"(accepted as an offline OpenFUT session)")
|
||||
resp = login_response_fields(sess)
|
||||
payload = encode_tdf(resp)
|
||||
log(" -> LoginResponse (%d bytes):\n%s"
|
||||
% (len(payload), heat2.dump(resp)))
|
||||
log(" -> LoginResponse (%d bytes)" % len(payload))
|
||||
if DUMP_FRAMES:
|
||||
log(" -> LoginResponse TDF:\n%s" % heat2.dump(resp))
|
||||
notifs = build_login_notifications(sess, sess.login_time)
|
||||
out = []
|
||||
if NOTIFY_BEFORE_LOGIN_REPLY:
|
||||
@@ -1409,9 +1513,10 @@ _frame_counter = [0]
|
||||
|
||||
|
||||
def blaze_handle(raw: socket.socket, addr) -> None:
|
||||
refresh_account_identity()
|
||||
log("*** BLAZE CONNECT from %s ***" % (addr,))
|
||||
sess = Session()
|
||||
log(" session key minted: %s" % sess.session_key)
|
||||
log(" session key minted: [REDACTED]")
|
||||
buf = bytearray()
|
||||
raw.settimeout(300)
|
||||
try:
|
||||
@@ -1442,10 +1547,10 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
||||
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
|
||||
hdr["msg_num"], hdr["user_index"], hdr["options"],
|
||||
hdr["metadata_len"], hdr["payload_len"]))
|
||||
if DUMP_FRAMES:
|
||||
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
|
||||
if metadata:
|
||||
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
|
||||
if DUMP_FRAMES:
|
||||
try:
|
||||
os.makedirs(RXDIR, exist_ok=True)
|
||||
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
|
||||
@@ -1460,6 +1565,7 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
||||
if payload:
|
||||
try:
|
||||
fields = decode_tdf(payload)
|
||||
if DUMP_FRAMES:
|
||||
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
|
||||
except Exception as e:
|
||||
log("RX #%d TDF DECODE FAILED: %s" % (n, e))
|
||||
@@ -1481,6 +1587,7 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
||||
ohdr["msg_type"]),
|
||||
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
|
||||
ohdr["msg_num"], len(out), ohdr["payload_len"]))
|
||||
if DUMP_FRAMES:
|
||||
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
|
||||
except ConnectionResetError:
|
||||
log("BLAZE %s: connection reset by client" % (addr,))
|
||||
@@ -1579,6 +1686,10 @@ def redir_handle(raw: socket.socket, addr) -> None:
|
||||
# client can never reach accounts.ea.com. Note the exact spacing in the JSON:
|
||||
# the client searches for the literal '"access_token" : "'.
|
||||
|
||||
def nucleus_sent_log(addr, size):
|
||||
return "NUCLEUS SENT %s %dB access_token=[REDACTED]" % (addr, size)
|
||||
|
||||
|
||||
def nucleus_handle(raw: socket.socket, addr) -> None:
|
||||
try:
|
||||
raw.settimeout(10)
|
||||
@@ -1591,9 +1702,9 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
|
||||
head, _, rest = req.partition(b"\r\n\r\n")
|
||||
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
|
||||
log("NUCLEUS REQ %s: %s" % (addr, line0))
|
||||
if head:
|
||||
if head and DUMP_FRAMES:
|
||||
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
|
||||
if rest:
|
||||
if rest and DUMP_FRAMES:
|
||||
log("NUCLEUS BODY: %r" % rest[:512])
|
||||
|
||||
token = "OPENFUT_" + "".join(
|
||||
@@ -1607,7 +1718,7 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
|
||||
b"Cache-Control: no-store\r\nContent-Length: "
|
||||
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
||||
raw.sendall(out)
|
||||
log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token))
|
||||
log(nucleus_sent_log(addr, len(out)))
|
||||
except Exception as e:
|
||||
log("NUCLEUS ERR %s: %s" % (addr, e))
|
||||
finally:
|
||||
@@ -1659,6 +1770,10 @@ def _selftest() -> None:
|
||||
sess.account_locale = 0x656E5553
|
||||
now = 1469000000
|
||||
|
||||
nucleus_summary = nucleus_sent_log(("127.0.0.1", 1234), 380)
|
||||
assert "[REDACTED]" in nucleus_summary
|
||||
assert "OPENFUT_selftest_secret" not in nucleus_summary
|
||||
|
||||
# ---- 1. preAuth still round-trips (regression guard vs v2)
|
||||
pre = preauth_response_fields()
|
||||
p = _check_roundtrip("PreAuthResponse", pre)
|
||||
@@ -1682,9 +1797,11 @@ def _selftest() -> None:
|
||||
assert items == client_config_for(cfid), cfid
|
||||
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
|
||||
% (cfid, len(items), len(pb)))
|
||||
assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []"
|
||||
shared = sorted(FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
|
||||
assert client_config_for("TOTALLY_UNKNOWN") == shared, \
|
||||
"unknown CFID must carry only the shared merged-store rows"
|
||||
assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \
|
||||
"unknown CFID must still carry a CONF field (empty map, not empty frame)"
|
||||
"unknown CFID must still carry a CONF field"
|
||||
|
||||
# ---- 3. LoginResponse
|
||||
lr = login_response_fields(sess)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline check: every /settings flag we ship is one the client actually switches on.
|
||||
|
||||
A flag name is not validated by anything at runtime. The client hashes the string
|
||||
we send and switches on the result, so a typo, a renamed field or a flag that
|
||||
simply has no arm in the switch is INERT and looks exactly like "the fix did not
|
||||
work". This asserts each shipped name against two independent sources:
|
||||
|
||||
1. docs/fut_atoms.tsv -- the recovered atom table (the name must hash to an id)
|
||||
2. the switch arms recovered from 0x18013c6d0 (the id must have an arm)
|
||||
|
||||
Source 2 is the one that matters: enableSquadBuildingSetsFeature is a perfectly
|
||||
real atom with NO arm, so source 1 alone would have passed it.
|
||||
|
||||
Run before shipping any settings change. No server needed.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
# The 42 atoms with an arm in FUN_18013c6d0, recovered 2026-08-05 by
|
||||
# tools/ghidra_queries/q_settings_flags.py + q_settings_types.py (full decompile,
|
||||
# both halves of the switch, coverage asserted by char count).
|
||||
SWITCH_ARMS = {
|
||||
0x18: "allowGracePeriodForSquadBuildingSets",
|
||||
0x19: "allowUntradeableForSquadBuildingSets",
|
||||
0x6D: "cardPackStoreEnabled", 0x6E: "cardPackStoreEnabled_JP",
|
||||
0x80: "checkServerDbVersion", 0x86: "clientKeepAliveResetTimeoutSec",
|
||||
0x8C: "clubCreateThreshold", 0x98: "coinEnabled", 0x99: "coinEnabled_JP",
|
||||
0xA3: "constrainGracePeriod", 0xBB: "couchPlayEnabled",
|
||||
0xF9: "enableDraftMode", 0xFA: "enableOfflineDraftMode",
|
||||
0xFB: "enableLiveMessaging", 0xFC: "enableLoyaltyBonusForConceptPlayers",
|
||||
0xFD: "enableObjectives", 0xFE: "enableObjectivesAsManagerTasks",
|
||||
0xFF: "enableSinglePlayerDraftMode", 0x118: "extendGameSessionTimerSec",
|
||||
0x11F: "fifaPointsEnabled", 0x120: "fifaPointsEnabled_JP",
|
||||
0x133: "friendlySeasonsEnabled", 0x13D: "getOperationTimeoutSec",
|
||||
0x16C: "itemDbVersion", 0x1C0: "maximumTradePileSize",
|
||||
0x1CD: "mtxEnabled", 0x1CE: "mtxEnabled_JP",
|
||||
0x1DE: "numEndMatchRetriesAllowed", 0x20E: "packOpeningAnimationEnabled",
|
||||
0x242: "pointsPackStoreEnabled", 0x257: "processingStateEnabled",
|
||||
0x28A: "returningUserRewardsScreenEnabled",
|
||||
0x2D0: "squadBuildingSetsGracePeriodMinutes",
|
||||
0x2F1: "storeEnabled", 0x2F2: "storeEnabled_JP",
|
||||
0x2F3: "storyModeRewardEnabled",
|
||||
0x2F5: "championsScheduleViewPeriodInMinutes",
|
||||
0x30F: "enableFloatPointSquadRating",
|
||||
0x310: "enableLegacyYearInfoInItemResourceId",
|
||||
0x320: "tokenRedemptionEnabled", 0x32D: "tournamentQuitEnabled",
|
||||
0x336: "tradingEnabled",
|
||||
}
|
||||
|
||||
# Arms that do NOT simply store a value. Shipping these has side effects.
|
||||
SPECIAL = {
|
||||
"enableObjectives": "shared arm can only CLEAR the field; 1 is a no-op, 0 disables",
|
||||
"enableObjectivesAsManagerTasks": "same shared arm as enableObjectives",
|
||||
"clientKeepAliveResetTimeoutSec": "reprograms a client timer with value*1000",
|
||||
"getOperationTimeoutSec": "reprograms a client timer with value*1000",
|
||||
"checkServerDbVersion": "makes the client go read a server_db_version config",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
fails = []
|
||||
|
||||
atoms = {}
|
||||
with open(os.path.join(HERE, "..", "docs", "fut_atoms.tsv")) as fh:
|
||||
for line in fh:
|
||||
p = line.rstrip("\n").split("\t")
|
||||
if len(p) >= 3:
|
||||
try:
|
||||
atoms[p[2]] = int(p[1], 16)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Cross-check the recovered table against the atom table both ways.
|
||||
by_name = {v: k for k, v in SWITCH_ARMS.items()}
|
||||
for name, aid in by_name.items():
|
||||
if name not in atoms:
|
||||
fails.append("switch arm %s (%#x) is not in fut_atoms.tsv" % (name, aid))
|
||||
elif atoms[name] != aid:
|
||||
fails.append("%s: switch says %#x, atom table says %#x"
|
||||
% (name, aid, atoms[name]))
|
||||
|
||||
import utas_server as u
|
||||
|
||||
body = u.SETTINGS
|
||||
if not isinstance(body, dict) or list(body) != ["configs"]:
|
||||
fails.append("body must be exactly {'configs': [...]}, got %r" % (body,))
|
||||
return report(fails)
|
||||
rows = body["configs"]
|
||||
if not isinstance(rows, list):
|
||||
fails.append("configs must be a LIST (a scalar here desyncs the parser)")
|
||||
return report(fails)
|
||||
|
||||
seen = set()
|
||||
for r in rows:
|
||||
if not isinstance(r, dict) or set(r) != {"type", "value"}:
|
||||
fails.append("row must be exactly {type, value}: %r" % (r,))
|
||||
continue
|
||||
t, v = r["type"], r["value"]
|
||||
# value: any scalar is safe (getter 0x1801c79d0 coerces int/float/bool/str),
|
||||
# but the applier tests `== 1`, so a bool True would work and a string "1"
|
||||
# would work -- ints keep it unambiguous. An object or array FREEZES.
|
||||
if isinstance(v, (dict, list)):
|
||||
fails.append("%s: value is %s -- an object/array here FREEZES the client"
|
||||
% (t, type(v).__name__))
|
||||
if not isinstance(t, str):
|
||||
fails.append("type must be a string, got %r" % (t,))
|
||||
continue
|
||||
if t in seen:
|
||||
fails.append("%s sent twice; last one wins, so this is at best confusing" % t)
|
||||
seen.add(t)
|
||||
if t not in by_name:
|
||||
hint = " (it IS an atom, but has no arm in the switch)" if t in atoms else ""
|
||||
fails.append("%s has no arm in 0x18013c6d0 -- INERT%s" % (t, hint))
|
||||
elif t in SPECIAL:
|
||||
print(" NOTE %-34s %s" % (t, SPECIAL[t]))
|
||||
|
||||
gates = {"friendlySeasonsEnabled", "enableDraftMode", "tournamentQuitEnabled"}
|
||||
# Read the mode off the server module, never re-declare the default here: a
|
||||
# checker with its own copy of a default tests the copy, not the server.
|
||||
mode = u._SETTINGS_MODE
|
||||
if mode == "gates":
|
||||
for g in sorted(gates - seen):
|
||||
fails.append("mode 'gates' but %s is missing" % g)
|
||||
for t in sorted(seen & gates):
|
||||
row = next(r for r in rows if r["type"] == t)
|
||||
if row["value"] != 1:
|
||||
fails.append("%s = %r; the applier tests `== 1`, nothing else opens "
|
||||
"the gate" % (t, row["value"]))
|
||||
|
||||
print(" mode=%s, %d rows, %d distinct flags, all with a live switch arm"
|
||||
% (mode, len(rows), len(seen)))
|
||||
return report(fails)
|
||||
|
||||
|
||||
def report(fails):
|
||||
if fails:
|
||||
print("\nFAIL (%d)" % len(fails))
|
||||
for f in fails:
|
||||
print(" - %s" % f)
|
||||
return 1
|
||||
print("PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+293
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env bash
|
||||
# FIFA 17 hook M1 staging/deployment helper.
|
||||
#
|
||||
# Safe defaults:
|
||||
# inspect (the default) is read-only;
|
||||
# stage writes only below the repository;
|
||||
# deploy and launch require separate, exact confirmation variables.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
hook_root="${repo_root}/openfut-launcher/openfut-hook"
|
||||
default_dll="${hook_root}/target/x86_64-pc-windows-gnu/release/openfut_hook.dll"
|
||||
stage_root="${repo_root}/fifa17-recon/staging/fifa17-hook-m1"
|
||||
|
||||
game_dir="${OPENFUT_FIFA17_GAME_DIR:-/mnt/games/FIFA 17}"
|
||||
wine_prefix="${OPENFUT_FIFA17_WINEPREFIX:-/home/alex/Games/umu/fifa17}"
|
||||
proton_path="${OPENFUT_FIFA17_PROTONPATH:-UMU-Proton-10.0-4}"
|
||||
hook_dll="${OPENFUT_FIFA17_HOOK_DLL:-${default_dll}}"
|
||||
system_version="${wine_prefix}/drive_c/windows/system32/version.dll"
|
||||
deployed_dll="${game_dir}/version.dll"
|
||||
|
||||
required_exports=(
|
||||
GetFileVersionInfoA GetFileVersionInfoExA GetFileVersionInfoExW
|
||||
GetFileVersionInfoSizeA GetFileVersionInfoSizeExA GetFileVersionInfoSizeExW
|
||||
GetFileVersionInfoSizeW GetFileVersionInfoW VerFindFileA VerFindFileW
|
||||
VerInstallFileA VerInstallFileW VerLanguageNameA VerLanguageNameW
|
||||
VerQueryValueA VerQueryValueW
|
||||
)
|
||||
|
||||
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
note() { printf '%s\n' "$*"; }
|
||||
need_file() { [[ -f "$1" ]] || die "missing file: $1"; }
|
||||
|
||||
sha256() { sha256sum -- "$1" | awk '{print $1}'; }
|
||||
|
||||
pe_exports() {
|
||||
x86_64-w64-mingw32-objdump -p "$1" |
|
||||
awk '/\[Ordinal\/Name Pointer\] Table/{in_names=1; next} in_names && /\+base\[/ {print $NF}'
|
||||
}
|
||||
|
||||
verify_pe64() {
|
||||
local dll=$1
|
||||
local format
|
||||
format="$(x86_64-w64-mingw32-objdump -f "$dll" | awk '/file format/{print $NF}')"
|
||||
[[ "$format" == "pei-x86-64" ]] || die "$dll is not a 64-bit PE DLL (format=${format:-unknown})"
|
||||
}
|
||||
|
||||
verify_exports() {
|
||||
local dll=$1 export_name
|
||||
local exports
|
||||
exports="$(pe_exports "$dll")"
|
||||
for export_name in "${required_exports[@]}"; do
|
||||
grep -Fxq "$export_name" <<<"$exports" ||
|
||||
die "$dll lacks VERSION export $export_name; refusing to stage/deploy"
|
||||
done
|
||||
}
|
||||
|
||||
# Refuse any DLL that is not a FIFA-17-profile build.
|
||||
#
|
||||
# openfut-hook builds TWO mutually exclusive injection paths from one crate: the
|
||||
# default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin hooks, while
|
||||
# `--features fifa17` installs ONLY the FIFA-17-safe logic (module map, FIFA 17
|
||||
# cert-verify, SBC dispatch, store tab bind). Deploying a default-feature build
|
||||
# into FIFA 17 hijacks the login transport and the client reports "Unable to
|
||||
# connect to the EA servers", with none of the FIFA 17 repairs present.
|
||||
#
|
||||
# That exact mistake happened on 2026-08-19 (artifact 1c71a17a, hand-built without
|
||||
# the feature): two failed launches, diagnosed only by comparing embedded strings.
|
||||
# `build` below passes the feature, but a hand-built DLL can reach `stage`/`deploy`
|
||||
# via OPENFUT_FIFA17_HOOK_DLL, so assert the profile on the bytes themselves.
|
||||
verify_fifa17_profile() {
|
||||
local dll=$1 marker
|
||||
# Markers that MUST be present: the FIFA 17 target module and its repairs.
|
||||
for marker in 'CardsDLL_Win64_retail.dll' 'SBC_DISPATCH'; do
|
||||
grep -qaF -- "$marker" "$dll" ||
|
||||
die "$dll is not a --features fifa17 build (missing $marker); refusing to stage/deploy"
|
||||
done
|
||||
# Markers that MUST be absent: the FIFA-23-only transport hooking.
|
||||
for marker in 'getaddrinfo IAT patched' 'connect: inline-hooked' 'origin_spy'; do
|
||||
if grep -qaF -- "$marker" "$dll"; then
|
||||
die "$dll contains FIFA-23-only hook '$marker'; build with --features fifa17"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
verify_inputs() {
|
||||
command -v sha256sum >/dev/null || die "sha256sum is required"
|
||||
command -v x86_64-w64-mingw32-objdump >/dev/null ||
|
||||
die "x86_64-w64-mingw32-objdump is required"
|
||||
need_file "$hook_dll"
|
||||
need_file "$system_version"
|
||||
verify_pe64 "$hook_dll"
|
||||
verify_fifa17_profile "$hook_dll"
|
||||
}
|
||||
|
||||
inspect() {
|
||||
verify_inputs
|
||||
note "mode=inspect (read-only)"
|
||||
note "hook=$hook_dll"
|
||||
note "hook_sha256=$(sha256 "$hook_dll")"
|
||||
note "system_version=$system_version"
|
||||
note "system_version_sha256=$(sha256 "$system_version")"
|
||||
note "game_dir=$game_dir"
|
||||
if [[ -f "$deployed_dll" ]]; then
|
||||
note "deployed_version_sha256=$(sha256 "$deployed_dll")"
|
||||
else
|
||||
note "deployed_version=absent"
|
||||
fi
|
||||
verify_exports "$hook_dll"
|
||||
note "version_exports=complete"
|
||||
}
|
||||
|
||||
build() {
|
||||
command -v cargo >/dev/null || die "cargo is required"
|
||||
note "Building the inert FIFA 17 hook into the package-local staging source path."
|
||||
CARGO_TARGET_DIR="${hook_root}/target" \
|
||||
cargo build --offline --release --features fifa17 \
|
||||
--target x86_64-pc-windows-gnu --manifest-path "${hook_root}/Cargo.toml"
|
||||
inspect
|
||||
}
|
||||
|
||||
stage() {
|
||||
verify_inputs
|
||||
verify_exports "$hook_dll"
|
||||
need_file "${game_dir}/CardsDLL_Win64_retail.dll"
|
||||
need_file "${game_dir}/FIFA17.exe"
|
||||
mkdir -p "$stage_root"
|
||||
local staged="${stage_root}/version.dll"
|
||||
cp -- "$hook_dll" "$staged"
|
||||
{
|
||||
printf 'artifact=%s\n' "$staged"
|
||||
printf 'artifact_sha256=%s\n' "$(sha256 "$staged")"
|
||||
printf 'source=%s\n' "$hook_dll"
|
||||
printf 'source_sha256=%s\n' "$(sha256 "$hook_dll")"
|
||||
printf 'system_version=%s\n' "$system_version"
|
||||
printf 'system_version_sha256=%s\n' "$(sha256 "$system_version")"
|
||||
printf 'cards_dll_sha256=%s\n' "$(sha256 "${game_dir}/CardsDLL_Win64_retail.dll")"
|
||||
printf 'fifa17_exe_sha256=%s\n' "$(sha256 "${game_dir}/FIFA17.exe")"
|
||||
} >"${stage_root}/manifest.txt"
|
||||
note "staged=$staged"
|
||||
note "manifest=${stage_root}/manifest.txt"
|
||||
note "No game-directory file was changed."
|
||||
}
|
||||
|
||||
require_game_stopped() {
|
||||
if pgrep -fi '(FIFA17|_fifa17)\.exe' >/dev/null; then
|
||||
die "FIFA 17 appears to be running; close it before deployment"
|
||||
fi
|
||||
}
|
||||
|
||||
deploy() {
|
||||
[[ "${OPENFUT_FIFA17_DEPLOY:-}" == "I_ACCEPT_VERSION_DLL_REPLACEMENT" ]] ||
|
||||
die "deploy requires OPENFUT_FIFA17_DEPLOY=I_ACCEPT_VERSION_DLL_REPLACEMENT"
|
||||
require_game_stopped
|
||||
local staged="${stage_root}/version.dll"
|
||||
local manifest="${stage_root}/manifest.txt"
|
||||
need_file "$staged"
|
||||
need_file "$manifest"
|
||||
verify_pe64 "$staged"
|
||||
verify_exports "$staged"
|
||||
verify_fifa17_profile "$staged"
|
||||
local recorded actual
|
||||
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
|
||||
actual="$(sha256 "$staged")"
|
||||
[[ -n "$recorded" && "$recorded" == "$actual" ]] || die "staged artifact hash does not match manifest"
|
||||
|
||||
local backup_dir="${game_dir}/openfut-backups"
|
||||
mkdir -p "$backup_dir"
|
||||
if [[ -f "$deployed_dll" ]]; then
|
||||
local old_hash backup
|
||||
old_hash="$(sha256 "$deployed_dll")"
|
||||
backup="${backup_dir}/version.dll.${old_hash}.bak"
|
||||
if [[ ! -e "$backup" ]]; then
|
||||
cp -- "$deployed_dll" "$backup"
|
||||
fi
|
||||
[[ "$(sha256 "$backup")" == "$old_hash" ]] || die "backup verification failed: $backup"
|
||||
note "backup=$backup"
|
||||
fi
|
||||
cp -- "$staged" "$deployed_dll"
|
||||
[[ "$(sha256 "$deployed_dll")" == "$actual" ]] || die "deployed DLL hash verification failed"
|
||||
note "deployed=$deployed_dll"
|
||||
note "deployed_sha256=$actual"
|
||||
}
|
||||
|
||||
launch() {
|
||||
local mode=${1:-baseline}
|
||||
local hook_enabled=0
|
||||
local trace_enabled=0
|
||||
local request_trace_enabled=0
|
||||
local notifier_trace_enabled=0
|
||||
local dispatch_enabled=0
|
||||
case "$mode" in
|
||||
baseline)
|
||||
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
|
||||
die "launch requires OPENFUT_FIFA17_LAUNCH=I_ACCEPT_M1_BASELINE_LAUNCH"
|
||||
;;
|
||||
resolve)
|
||||
[[ "${OPENFUT_FIFA17_RESOLVE:-}" == "I_ACCEPT_M2_RESOLVE_LAUNCH" ]] ||
|
||||
die "launch-resolve requires OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH"
|
||||
hook_enabled=1
|
||||
;;
|
||||
trace)
|
||||
[[ "${OPENFUT_FIFA17_TRACE:-}" == "I_ACCEPT_M3_PASSIVE_TRACE" ]] ||
|
||||
die "launch-trace requires OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE"
|
||||
hook_enabled=1
|
||||
trace_enabled=1
|
||||
request_trace_enabled=1
|
||||
notifier_trace_enabled=1
|
||||
;;
|
||||
dispatch)
|
||||
[[ "${OPENFUT_FIFA17_DISPATCH:-}" == "I_ACCEPT_GUARDED_NATIVE_DISPATCH" ]] ||
|
||||
die "launch-dispatch requires OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH"
|
||||
request_trace_enabled=1
|
||||
dispatch_enabled=1
|
||||
;;
|
||||
*) die "unknown launch mode: $mode" ;;
|
||||
esac
|
||||
need_file "$deployed_dll"
|
||||
local staged="${stage_root}/version.dll"
|
||||
local manifest="${stage_root}/manifest.txt"
|
||||
need_file "$staged"
|
||||
need_file "$manifest"
|
||||
verify_pe64 "$deployed_dll"
|
||||
verify_exports "$deployed_dll"
|
||||
local recorded
|
||||
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
|
||||
[[ -n "$recorded" && "$(sha256 "$staged")" == "$recorded" ]] ||
|
||||
die "staged artifact hash does not match manifest"
|
||||
[[ "$(sha256 "$deployed_dll")" == "$recorded" ]] ||
|
||||
die "deployed version.dll does not match the staged M1 artifact"
|
||||
command -v umu-run >/dev/null || die "umu-run is required"
|
||||
for name in OPENFUT_SBC_DISPATCH OPENFUT_SBC_ARM_ONLY OPENFUT_SBC_POPULATE; do
|
||||
[[ -z "${!name:-}" || "${!name}" == "0" ]] || die "$name must be unset or 0 for this launch"
|
||||
done
|
||||
mkdir -p "${wine_prefix}/dosdevices"
|
||||
ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
|
||||
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_DISPATCH=$dispatch_enabled); log=/tmp/fifa17-hook-m1-launch.log"
|
||||
cd "$game_dir"
|
||||
env \
|
||||
GAMEID=fifa17 \
|
||||
PROTONPATH="$proton_path" \
|
||||
WINEPREFIX="$wine_prefix" \
|
||||
WINEDLLOVERRIDES='version=n,b' \
|
||||
OPENFUT_SBC_HOOK="$hook_enabled" \
|
||||
OPENFUT_SBC_TRACE="$trace_enabled" \
|
||||
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
|
||||
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
|
||||
OPENFUT_SBC_DISPATCH="$dispatch_enabled" \
|
||||
OPENFUT_SBC_DISPATCH_TRACE=0 \
|
||||
OPENFUT_SBC_ARM_ONLY=0 \
|
||||
OPENFUT_SBC_POPULATE=0 \
|
||||
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-dispatch]
|
||||
|
||||
inspect Read-only PE/hash/export preflight (default).
|
||||
build Cross-build the inert FIFA17 hook, then run inspect.
|
||||
stage Copy a verified DLL into repo-local staging and write a hash manifest.
|
||||
deploy Back up and install version.dll; requires:
|
||||
OPENFUT_FIFA17_DEPLOY=I_ACCEPT_VERSION_DLL_REPLACEMENT
|
||||
launch Start the M1 inert-hook baseline; requires:
|
||||
OPENFUT_FIFA17_LAUNCH=I_ACCEPT_M1_BASELINE_LAUNCH
|
||||
launch-resolve
|
||||
Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires:
|
||||
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
|
||||
launch-trace
|
||||
Start the M3-M6 passive parser/request/notifier trace; requires:
|
||||
OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE
|
||||
launch-dispatch
|
||||
Trace and repair only a fully validated native status-999 completion; requires:
|
||||
OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH
|
||||
|
||||
Optional path overrides:
|
||||
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
|
||||
OPENFUT_FIFA17_WINEPREFIX, OPENFUT_FIFA17_PROTONPATH
|
||||
EOF
|
||||
}
|
||||
|
||||
case "${1:-inspect}" in
|
||||
inspect) inspect ;;
|
||||
build) build ;;
|
||||
stage) stage ;;
|
||||
deploy) deploy ;;
|
||||
launch) launch baseline ;;
|
||||
launch-resolve) launch resolve ;;
|
||||
launch-trace) launch trace ;;
|
||||
launch-dispatch) launch dispatch ;;
|
||||
-h|--help|help) usage ;;
|
||||
*) usage >&2; die "unknown command: $1" ;;
|
||||
esac
|
||||
@@ -204,6 +204,7 @@ class Account:
|
||||
def __init__(self, path=None):
|
||||
self.path = path or ACCOUNT_PATH
|
||||
self._loaded = False
|
||||
self._file_signature = None
|
||||
self._stored = {} # what is on disk (tier 2+3 only)
|
||||
for f in _FIELDS:
|
||||
setattr(self, "_" + f, None)
|
||||
@@ -214,7 +215,8 @@ class Account:
|
||||
save the first time. Never raises on a malformed file -- a broken
|
||||
account file must not stop the harness booting."""
|
||||
with _LOCK:
|
||||
if self._loaded and not force:
|
||||
signature = self._signature()
|
||||
if self._loaded and not force and signature == self._file_signature:
|
||||
return self
|
||||
stored = {}
|
||||
if os.path.exists(self.path):
|
||||
@@ -239,8 +241,22 @@ class Account:
|
||||
% (self.path, e))
|
||||
self._stored = stored
|
||||
self._loaded = True
|
||||
self._file_signature = self._signature()
|
||||
return self
|
||||
|
||||
def _signature(self):
|
||||
"""Identity of the active-account file across atomic replacements.
|
||||
|
||||
The launcher can select an account while Blaze/POW are already running
|
||||
in separate processes. inode + mtime + size lets every process notice
|
||||
the replacement on its next property read without restarting Docker.
|
||||
"""
|
||||
try:
|
||||
st = os.stat(self.path)
|
||||
return st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def _migrate_from_profile(self):
|
||||
"""Lift identity/club out of a pre-existing fifa17_profile.json so an
|
||||
existing club name survives the move to this module. Read-only: the game
|
||||
@@ -266,11 +282,32 @@ class Account:
|
||||
return out
|
||||
|
||||
def _write(self):
|
||||
parent = os.path.dirname(self.path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
tmp = self.path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(self._stored, f, indent=1, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp, self.path)
|
||||
self._file_signature = self._signature()
|
||||
|
||||
def replace(self, values):
|
||||
"""Atomically replace the active identity with validated persisted values."""
|
||||
with _LOCK:
|
||||
clean = {k: v for k, v in values.items() if k in _FIELDS and v is not None}
|
||||
if "persona_id" not in clean or "persona_name" not in clean:
|
||||
raise ValueError("persona_id and persona_name are required")
|
||||
clean["persona_id"] = int(clean["persona_id"])
|
||||
clean["persona_name"] = str(clean["persona_name"]).strip()
|
||||
if clean["persona_id"] <= 0 or not clean["persona_name"]:
|
||||
raise ValueError("persona_id must be positive and persona_name must not be empty")
|
||||
self._stored = clean
|
||||
for field in _FIELDS:
|
||||
setattr(self, "_" + field, None)
|
||||
self._loaded = True
|
||||
self._write()
|
||||
return self
|
||||
|
||||
def save(self):
|
||||
"""Persist tiers 2+3 (only fields that differ from the built-in default,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launcher-to-server active-account selection for the single-player stack."""
|
||||
import json
|
||||
import os
|
||||
|
||||
from fut_account import ACCOUNT
|
||||
from fut_store import STORE, profile_path_for
|
||||
|
||||
|
||||
def _existing_identity(persona_id):
|
||||
path = profile_path_for(persona_id)
|
||||
try:
|
||||
with open(path) as f:
|
||||
profile = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
if not isinstance(profile, dict):
|
||||
return {}
|
||||
return {
|
||||
"club_name": profile.get("clubName"),
|
||||
"club_abbr": profile.get("clubAbbr"),
|
||||
"established": profile.get("established"),
|
||||
"pow_level": profile.get("powLevel"),
|
||||
"pow_exp": profile.get("powExp"),
|
||||
"pow_exp_max": profile.get("powExpMax"),
|
||||
"pow_funds": profile.get("powFunds"),
|
||||
"pow_funds_cap": profile.get("powFundsCap"),
|
||||
}
|
||||
|
||||
|
||||
def activate(payload):
|
||||
"""Select/create one persistent profile and publish it to all responders."""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("account payload must be an object")
|
||||
try:
|
||||
persona_id = int(payload.get("personaId"))
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("personaId must be a positive integer") from None
|
||||
persona_name = payload.get("personaName")
|
||||
if persona_id <= 0 or not isinstance(persona_name, str) or not persona_name.strip():
|
||||
raise ValueError("personaId must be positive and personaName must not be empty")
|
||||
|
||||
values = _existing_identity(persona_id)
|
||||
values.update(persona_id=persona_id, persona_name=persona_name.strip())
|
||||
for wire, field in (("clubName", "club_name"), ("clubAbbr", "club_abbr"),
|
||||
("established", "established"), ("squadName", "squad_name"),
|
||||
("level", "pow_level"), ("experience", "pow_exp"),
|
||||
("experienceMax", "pow_exp_max"), ("accountFunds", "pow_funds"),
|
||||
("accountFundsCap", "pow_funds_cap")):
|
||||
if payload.get(wire) not in (None, ""):
|
||||
values[field] = payload[wire]
|
||||
|
||||
ACCOUNT.replace(values)
|
||||
ACCOUNT.set_online_profile()
|
||||
ACCOUNT.save()
|
||||
profile = STORE.select_account(persona_id)
|
||||
STORE.ensure_security_question()
|
||||
return {
|
||||
"personaId": ACCOUNT.persona_id,
|
||||
"personaName": ACCOUNT.persona_name,
|
||||
"clubName": ACCOUNT.club_name,
|
||||
"clubAbbr": ACCOUNT.club_abbr,
|
||||
"level": ACCOUNT.pow_level,
|
||||
"experience": ACCOUNT.pow_exp,
|
||||
"experienceMax": ACCOUNT.pow_exp_max,
|
||||
"accountFunds": ACCOUNT.pow_funds,
|
||||
"accountFundsCap": ACCOUNT.pow_funds_cap,
|
||||
"profilePath": os.path.relpath(STORE.path, os.path.dirname(ACCOUNT.path)),
|
||||
"coins": profile.get("coins", 0),
|
||||
"unopenedPacks": len(profile.get("unopenedPackIds", [])),
|
||||
}
|
||||
@@ -47,16 +47,30 @@ _DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "
|
||||
CLUBITEM_ID_BASE = 960000000 # distinct from save 1e8, sweep 9e8, consumables 9.4e8
|
||||
|
||||
# (table, art id, stat id, stat name, UNVERIFIED cardsubtypeid)
|
||||
# CORRECTED 2026-08-06. Every previous subtype was inside the 0x91..0x96 block, which
|
||||
# is TROPHIES: FUN_180108c00 computes subtype = tournamentType + 0x91, and FUN_1800fed90
|
||||
# is the only function in the binary whose case set is exactly {0x91..0x96}. So all five
|
||||
# families were pointed at the trophy range.
|
||||
#
|
||||
# Kits, stadia and badges are NOT cardtype 9. FUN_1800d8330 has
|
||||
# `case 9: case 10: case 0xb: return 7`, and cardtype 7 DOES have a resolver: manager
|
||||
# vtable +0x498 = FUN_180119bd0, reached from FUN_1800f6c40 when item+0x4c == 7, called
|
||||
# with (subtype, teamid, assetId). That matters for testing: CARD_SYSTEM.md said a wrong
|
||||
# club-item id "cannot announce itself", and for these three that is false. A wrong
|
||||
# teamid produces a visibly wrong TeamName_Abbr15_ caption, which is why kits go first.
|
||||
FAMILIES = [
|
||||
("balls", "fcc_balls.json", 37, 0x1E, "balls", 149),
|
||||
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 148),
|
||||
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 145),
|
||||
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 146),
|
||||
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 150),
|
||||
("balls", "fcc_balls.json", 37, 0x1E, "balls", 30),
|
||||
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 10),
|
||||
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 11),
|
||||
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 9),
|
||||
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 31),
|
||||
]
|
||||
|
||||
# Every cardsubtypeid known to reach cardtype 9. Used by probe_shelf().
|
||||
CARDTYPE9_SUBTYPES = (30, 31, 145, 146, 147, 148, 149, 150)
|
||||
# Candidate set for probe_shelf(). The old set {30,31,145..150} could NOT have answered
|
||||
# the question for kits, stadia or badges, because 9, 10 and 11 were not in it: the
|
||||
# probe route the docs preferred would have spent a launch and returned nothing for
|
||||
# three of the five families.
|
||||
CARDTYPE9_SUBTYPES = (9, 10, 11, 30, 31)
|
||||
|
||||
# How many of each family the starter club owns. Small on purpose: the point is to
|
||||
# make the counter non-zero so the client asks, not to hand anyone a collection.
|
||||
@@ -71,22 +85,35 @@ def _rows(fname):
|
||||
return []
|
||||
|
||||
|
||||
def _item(item_id, carddbid, cardassetid, subtype, extra=None):
|
||||
def _item(item_id, carddbid, cardassetid, subtype, teamid=None, extra=None):
|
||||
"""One club item. Deliberately narrow: no rating, no position, no attributes,
|
||||
no nation, no league, no team. A club item has none of those, and sending a
|
||||
field the family does not have is how a wrong shape gets accepted and does
|
||||
nothing."""
|
||||
no nation, no league. A club item has none of those, and sending a field the
|
||||
family does not have is how a wrong shape gets accepted and does nothing."""
|
||||
it = {
|
||||
"id": item_id,
|
||||
"resourceId": carddbid,
|
||||
"assetId": carddbid,
|
||||
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
|
||||
"cardsubtypeid": subtype,
|
||||
"itemType": "club", # UNOBSERVED on the wire; see module docstring
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
"untradeable": False,
|
||||
}
|
||||
# KIT (9) and BADGE (11) display as <caption> + TeamName_Abbr15_<teamid>, so
|
||||
# without teamid the name comes out as the caption alone. STADIUM (10) reads
|
||||
# StadiumName_<assetId>, which resourceId already supplies, so it needs nothing.
|
||||
# teamid is atom 0x306, read with the INT primitive FUN_1801c79d0 and stored at
|
||||
# record +0x94: an established scalar field, not a new shape.
|
||||
#
|
||||
# BE HONEST ABOUT THE 2026-08-05 CRASH: teamid was one of the three extras in the
|
||||
# response that crashed the client, and it was never bisected. `value` is the
|
||||
# established suspect, because it is an OBJECT member elsewhere and a scalar where
|
||||
# an object is expected is the 0x1801c7f1a busy loop, and that response also
|
||||
# carried 30 items across FIVE wrong subtypes at once. This adds teamid ALONE, to
|
||||
# ONE family, with the subtypes now corrected. That is the narrow test the crash
|
||||
# denied us, and it is why families are served one at a time.
|
||||
if teamid is not None and subtype in (9, 11):
|
||||
it["teamid"] = teamid
|
||||
if extra:
|
||||
it.update(extra)
|
||||
return it
|
||||
@@ -119,7 +146,13 @@ def shelf(next_id=CLUBITEM_ID_BASE, families=None):
|
||||
# at 0x1801c7f1a, which reads exactly like "the game is taking its time"
|
||||
# and then dies. Omission is safe; an unestablished field is not. None of
|
||||
# the three was needed to draw a card.
|
||||
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype))
|
||||
# teamid is passed but _item only APPLIES it to kits (9) and badges (11),
|
||||
# which are the two families whose caption is <name> + TeamName_Abbr15_
|
||||
# <teamid>. It is the one field from the fcc row being reintroduced after
|
||||
# the 2026-08-05 crash, deliberately alone and deliberately narrow: see
|
||||
# the note in _item(). value and leagueid stay omitted.
|
||||
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype,
|
||||
teamid=r.get("teamid")))
|
||||
nid += 1
|
||||
out[name] = picked
|
||||
return out
|
||||
|
||||
+373
-17
@@ -17,7 +17,125 @@ sys.path.insert(0, HERE)
|
||||
import fut_cards
|
||||
from fut_account import ACCOUNT # single source of truth for identity/club
|
||||
|
||||
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
|
||||
PROFILE_ROOT = os.environ.get("FUT_PROFILE_ROOT", "")
|
||||
|
||||
|
||||
def profile_path_for(persona_id):
|
||||
explicit = os.environ.get("FUT_PROFILE")
|
||||
if explicit:
|
||||
return explicit
|
||||
if PROFILE_ROOT:
|
||||
return os.path.join(PROFILE_ROOT, str(int(persona_id)), "fifa17_profile.json")
|
||||
return os.path.join(HERE, "fifa17_profile.json")
|
||||
|
||||
|
||||
PROFILE_PATH = profile_path_for(ACCOUNT.persona_id)
|
||||
|
||||
# ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------
|
||||
#
|
||||
# quick_sell() used to pay an invented rating tier (600/300/150/50). That number
|
||||
# was wrong for every card. The real table is `fcc_discardcoins` in the client's
|
||||
# own game DB, 141 rows keyed (cardtype, level, rare) -> price, recovered from the
|
||||
# running client 2026-08-05 and verified against 22 live club items, 22/22 exact.
|
||||
#
|
||||
# The client computes the DISPLAYED value itself with the same table whenever our
|
||||
# `discardValue` (atom 0xd7) is 0 or absent: FUN_18013fe00 stores our value at item
|
||||
# +0x38, and the guard at 0x180141025 (`cmp dword [rbp+0x198],0` / `ja`) skips the
|
||||
# local computation when it is non-zero. So today the client shows the real value
|
||||
# while the server pays a made-up one, and the two disagree on every card. This
|
||||
# makes the paid value agree with the shown value.
|
||||
#
|
||||
# value = round_half_up(rating * price / 100)
|
||||
# level = 3 if rating >= 75, 2 if 65..74, else 1 (0x180141e8a..0x180141ea3;
|
||||
# derived from rating, NOT a wire field)
|
||||
# cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and
|
||||
# checked across every subtype 0..599 with zero disagreements
|
||||
#
|
||||
# ZERO WIRE CHANGE. Nothing new is sent; only the coin figure the server credits
|
||||
# changes. Default off per the house rule, but this is the one patch worth
|
||||
# defaulting on after a single verification.
|
||||
# See docs/plan-2026-08-05-store-subsystem.md section 3.6.
|
||||
DISCARD_TABLE = os.environ.get("FUT_DISCARD_TABLE", "0") == "1"
|
||||
|
||||
_DP = {}
|
||||
|
||||
|
||||
def _dp(ct, rares, p1, p2, p3):
|
||||
for r in rares:
|
||||
_DP[(ct, 1, r)] = p1
|
||||
_DP[(ct, 2, r)] = p2
|
||||
_DP[(ct, 3, r)] = p3
|
||||
|
||||
|
||||
_dp(1, [0], 30, 150, 400)
|
||||
_dp(1, [1], 75, 350, 800)
|
||||
_dp(1, [7], 1500, 5000, 9000)
|
||||
_dp(1, [2, 3, 10, 13] + list(range(17, 32)), 2000, 7000, 12200)
|
||||
_dp(1, [4, 8, 9], 6000, 10000, 18000)
|
||||
_dp(1, [11], 10000, 15000, 24000)
|
||||
_dp(1, [5, 6], 20000, 40000, 80000)
|
||||
_dp(1, [12], 120000, 120000, 120000)
|
||||
_dp(2, [0], 20, 70, 110)
|
||||
_dp(2, [1], 25, 120, 320)
|
||||
for _ct in (3, 4, 5, 10):
|
||||
_dp(_ct, [0], 10, 55, 110)
|
||||
_dp(_ct, [1], 50, 100, 300)
|
||||
for _ct in (6, 7, 8, 9):
|
||||
_dp(_ct, [0], 5, 20, 40)
|
||||
_dp(_ct, [1], 20, 50, 70)
|
||||
|
||||
|
||||
def _cardtype(sub):
|
||||
"""FUN_1800d8330. 0 means no table row, which the client renders as value 0."""
|
||||
if sub is None:
|
||||
return 0
|
||||
if 0 <= sub <= 3:
|
||||
return 1
|
||||
if sub == 4:
|
||||
return 2
|
||||
if sub == 5:
|
||||
return 3
|
||||
if sub == 6:
|
||||
return 10
|
||||
if sub == 7:
|
||||
return 5
|
||||
if sub == 8:
|
||||
return 4
|
||||
if 9 <= sub <= 11:
|
||||
return 7
|
||||
if sub in (30, 31, 231, 232, 233, 236) or 145 <= sub <= 150:
|
||||
return 9
|
||||
if (51 <= sub <= 136) or (201 <= sub <= 220) or (250 <= sub <= 273) \
|
||||
or (300 <= sub <= 341):
|
||||
return 6
|
||||
return 0
|
||||
|
||||
|
||||
def discard_value(item):
|
||||
"""round_half_up(rating * price / 100), price from fcc_discardcoins.
|
||||
|
||||
Returns None when the formula does not apply, so callers fall back instead of
|
||||
paying nothing. THE UNRATED-CARD CASE IS NOT COVERED BY THE RECOVERED FORMULA:
|
||||
it was verified 22/22 against club items, all of which were rated players, and
|
||||
`rating * price / 100` collapses to 0 for a staff card carrying no rating. Found
|
||||
by running the whole save through it, where exactly one item (a staff card,
|
||||
cardsubtypeid 8, rating None) came back 0 while the old tier paid 50. Paying 0 for
|
||||
a card the previous code paid for is a regression, so unrated cards fall back.
|
||||
What FUT really pays for staff and consumables is UNKNOWN and worth recovering;
|
||||
the likely answer is the unscaled table price, but that is a guess and is not
|
||||
shipped as one.
|
||||
"""
|
||||
r = item.get("rating")
|
||||
if not r:
|
||||
return None
|
||||
ct = _cardtype(item.get("cardsubtypeid"))
|
||||
r = int(r)
|
||||
lvl = 3 if r >= 75 else 2 if r >= 65 else 1
|
||||
price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0)
|
||||
if not price:
|
||||
return None # no table row: the client renders 0, we should not
|
||||
n = r * price
|
||||
return n // 100 + (1 if n % 100 >= 50 else 0)
|
||||
|
||||
# Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX
|
||||
# and UTAS cannot drift apart; prefer ACCOUNT.<field> in new code. These are
|
||||
@@ -62,9 +180,37 @@ ITEM_ID_BASE = 100000000
|
||||
_SQUAD_FITNESS_TRAP = 219
|
||||
|
||||
|
||||
# FUT_TRADEABLE: send untradeable=false so the client's tradeable byte gets set.
|
||||
#
|
||||
# "Place on Transfer List" and "List on Transfer Market" are greyed out on every card,
|
||||
# and BOTH gates are ours. FUN_1801a7260, the TO_TRADE_PILE predicate published by
|
||||
# FUN_18003e370, returns 1 only if the service gate at vtable+0x270 is non-zero AND
|
||||
# item+0x49 is non-zero. The deserializer stores untradeable INVERTED (case 0x361 does
|
||||
# CONCAT11(cVar6 == '\0', ...)), so untradeable:true writes 0 and kills the flag.
|
||||
#
|
||||
# THIS FLAG ALONE IS NOT ENOUGH, and shipping it alone will look like the finding
|
||||
# failed. The other gate is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the
|
||||
# tradingEnabled gate byte. Measured live 2026-08-06 as 0, while friendlySeasons
|
||||
# (0x1fd3a), draftMode (0x1fd3d) and packOpeningAnimation (0x1fd45) all read 1 in the
|
||||
# same walk. tradingEnabled is the only gate byte yet found that is not already 1, and
|
||||
# it is ALREADY in _SETTINGS_KEEP: it has simply never been sent, because
|
||||
# _SETTINGS_MODE defaults to off. So the run needs FUT_SETTINGS=keep beside this.
|
||||
#
|
||||
# Freeze risk: none beyond what we already send. untradeable is atom 0x361 read by the
|
||||
# BOOL primitive FUN_1801c7620, and we already send the key on every card; only the
|
||||
# value changes. The constructor default for +0x49 is 1 (tradeable), so false moves
|
||||
# the field toward the client's own default rather than away from it.
|
||||
#
|
||||
# Side effects, both permissive rather than restrictive: item+0x49 also feeds
|
||||
# FUN_1800bc580, which counts untradeable squad members and publishes UNTRADABLE_COUNT,
|
||||
# which gates squad submission in FUN_1800bba10 (today that takes the
|
||||
# couldNotSubmitSquad branch).
|
||||
TRADEABLE = os.environ.get("FUT_TRADEABLE", "0") == "1"
|
||||
|
||||
|
||||
def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00,
|
||||
cardsubtypeid=0, rareflag=1):
|
||||
return {
|
||||
return _with_discard({
|
||||
"id": item_id,
|
||||
"resourceId": (version << 24) | asset,
|
||||
"assetId": asset,
|
||||
@@ -82,10 +228,112 @@ def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00
|
||||
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
"untradeable": True,
|
||||
"untradeable": not TRADEABLE,
|
||||
"contract": 7,
|
||||
"fitness": 99,
|
||||
}
|
||||
})
|
||||
# discardValue is stamped HERE, inside the single item factory, so every path that
|
||||
# builds an item gets it: pack contents, the starter grant, club reads and market
|
||||
# listings alike. Stamping it at one call site would leave the reveal screen and
|
||||
# the club showing different numbers for the same card.
|
||||
|
||||
|
||||
SPECIAL_CARD_TYPES = {
|
||||
# name: (rareflag, revision byte, rating/attribute boost, selection weight)
|
||||
# rareflag names come from FIFA 17's ItemRareType enum. Revisions are local,
|
||||
# stable identities; the client resolves the footballer from the low 24 bits.
|
||||
"TOTW": (3, 1, 2, 34),
|
||||
"PURPLE": (4, 2, 3, 7),
|
||||
"TOTY": (5, 3, 6, 3),
|
||||
"RECORD_BREAKER": (6, 4, 5, 2),
|
||||
"TOTS": (11, 5, 5, 7),
|
||||
"OTW": (21, 6, 2, 14),
|
||||
"HALLOWEEN": (22, 7, 3, 8),
|
||||
"MOVEMBER": (23, 8, 3, 8),
|
||||
"SBC": (24, 9, 4, 17),
|
||||
}
|
||||
|
||||
|
||||
def choose_special_type(player, rng=None):
|
||||
"""Choose a rating-appropriate FIFA 17 promo family for one pool row."""
|
||||
import random
|
||||
rng = rng or random
|
||||
rating = player[1]
|
||||
eligible = []
|
||||
for name, spec in SPECIAL_CARD_TYPES.items():
|
||||
if name in ("TOTY", "RECORD_BREAKER") and rating < 85:
|
||||
continue
|
||||
if name == "TOTS" and rating < 75:
|
||||
continue
|
||||
eligible.append((name, spec[3]))
|
||||
names, weights = zip(*eligible)
|
||||
return rng.choices(names, weights=weights, k=1)[0]
|
||||
|
||||
|
||||
def player_item(item_id, player, special=False):
|
||||
"""Build a base or named FIFA 17 special revision from a pool row.
|
||||
|
||||
`special=True` remains supported and chooses a weighted eligible family;
|
||||
callers and tests may also pass an explicit name such as ``"TOTY"``.
|
||||
"""
|
||||
asset, rating, pos, nation, league, team, attrs = player
|
||||
if special:
|
||||
special_name = choose_special_type(player) if special is True else special
|
||||
rareflag, version, boost, _weight = SPECIAL_CARD_TYPES[special_name]
|
||||
rating = min(99, rating + boost)
|
||||
attrs = [min(99, value + boost) for value in attrs]
|
||||
else:
|
||||
rareflag, version = 1, 0
|
||||
return _item(item_id, asset, rating, pos, nation, league, team, attrs,
|
||||
version=version, rareflag=rareflag)
|
||||
|
||||
|
||||
# FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS
|
||||
# the same number the server pays.
|
||||
#
|
||||
# Measured live 2026-08-06. With FUT_DISCARD_TABLE on, the server correctly paid 600
|
||||
# for a 75-rated rare gold (9,844,900 -> 9,845,500, exact) while the reveal screen
|
||||
# showed "Quick Sell 0", and "Quick Sell all remaining Items" showed 0 too. So the
|
||||
# figure was right and invisible, and the screen contradicted the wallet.
|
||||
#
|
||||
# The cause is the guard the table work reversed. FUN_18013fe00 stores our
|
||||
# discardValue at item +0x38; at 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` skips
|
||||
# the client's own local computation when that value is NON-ZERO. We seed 0, so the
|
||||
# client runs its own fcc_discardcoins lookup, that lookup returns no row for our
|
||||
# cards, the price register stays 0, and it renders 0. WHY its lookup misses is still
|
||||
# UNKNOWN and worth knowing, but it does not have to be answered to fix the display:
|
||||
# sending a non-zero value bypasses the lookup entirely and the client uses ours.
|
||||
#
|
||||
# Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
|
||||
# scalar getter 0x1801c79d0. The freezes on this project have all come from feeding an
|
||||
# object or array where a scalar was expected, never the reverse.
|
||||
#
|
||||
# Requires FUT_DISCARD_TABLE, since without the real table this would put the invented
|
||||
# tier on screen and make a wrong number authoritative-looking rather than merely paid.
|
||||
DISCARD_SEND = os.environ.get("FUT_DISCARD_SEND", "0") == "1" and DISCARD_TABLE
|
||||
|
||||
|
||||
def _with_discard(it):
|
||||
"""Apply the read-path flags to one item.
|
||||
|
||||
Two things, both of which MUST happen on read and not only at creation: the
|
||||
saved profile holds 246 items minted long before either flag existed, and the
|
||||
club route serves them straight out of the save. Stamping only in _item() left
|
||||
the wire carrying untradeable:true with FUT_TRADEABLE=1 set, which was caught by
|
||||
reading the served JSON rather than by unit-testing the factory.
|
||||
|
||||
Callers pass a COPY, so the save is never mutated by a read.
|
||||
"""
|
||||
if DISCARD_SEND:
|
||||
# Omit the key entirely when the formula does not apply, rather than sending
|
||||
# 0: a 0 makes the client fall back to its own lookup, and the tile binds our
|
||||
# value anyway, so 0 renders as 0.
|
||||
v = discard_value(it)
|
||||
if v:
|
||||
it["discardValue"] = v
|
||||
if TRADEABLE:
|
||||
it["untradeable"] = False
|
||||
return it
|
||||
|
||||
|
||||
def _new_profile():
|
||||
@@ -107,6 +355,10 @@ def _new_profile():
|
||||
"purchased": [], # unassigned/pending items from opened packs
|
||||
"squads": [], # saved squads (raw squad objects from PUT /squad)
|
||||
"packsOpened": 0,
|
||||
# Owned reward packs are separate from purchased items. Pack 70 is a
|
||||
# one-time migration grant used to bring the retail My Packs flow online.
|
||||
"unopenedPackIds": [70],
|
||||
"unopenedSeeded": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +377,10 @@ class Store:
|
||||
self._p = _new_profile()
|
||||
self._sync_identity()
|
||||
self._save()
|
||||
if not self._p.get("unopenedSeeded"):
|
||||
self._p.setdefault("unopenedPackIds", []).append(70)
|
||||
self._p["unopenedSeeded"] = True
|
||||
self._save()
|
||||
self._sync_identity()
|
||||
return self._p
|
||||
|
||||
@@ -142,18 +398,51 @@ class Store:
|
||||
p["clubName"] = ACCOUNT.club_name
|
||||
p["clubAbbr"] = ACCOUNT.club_abbr
|
||||
p["established"] = ACCOUNT.established
|
||||
# EA/EASFC account-bar state belongs to the same persona as the FUT
|
||||
# save, but remains a distinct balance from FUT coins.
|
||||
p["powLevel"] = ACCOUNT.pow_level
|
||||
p["powExp"] = ACCOUNT.pow_exp
|
||||
p["powExpMax"] = ACCOUNT.pow_exp_max
|
||||
p["powFunds"] = ACCOUNT.pow_funds
|
||||
p["powFundsCap"] = ACCOUNT.pow_funds_cap
|
||||
return p
|
||||
|
||||
def _save(self):
|
||||
parent = os.path.dirname(self.path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
tmp = self.path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(self._p, f, indent=1)
|
||||
os.replace(tmp, self.path)
|
||||
|
||||
def select_account(self, persona_id):
|
||||
"""Switch the single active session to its isolated persistent FUT save."""
|
||||
with _LOCK:
|
||||
self.path = profile_path_for(persona_id)
|
||||
self._p = None
|
||||
return self.load()
|
||||
|
||||
# ---- accessors used by utas_server -------------------------------------
|
||||
def profile(self):
|
||||
return self.load()
|
||||
|
||||
def ensure_security_question(self):
|
||||
"""Persist OpenFUT's account-scoped compatibility state for the FUT gate.
|
||||
|
||||
FIFA 17 transforms any entered answer before sending it. OpenFUT does not
|
||||
need that value to emulate a retired service, so neither the clear text nor
|
||||
the transformed value is stored. The only durable fact is that this
|
||||
OpenFUT profile has an initialized, verified compatibility record.
|
||||
"""
|
||||
expected = {"version": 1, "verified": True}
|
||||
with _LOCK:
|
||||
p = self.load()
|
||||
if p.get("securityQuestion") != expected:
|
||||
p["securityQuestion"] = dict(expected)
|
||||
self._save()
|
||||
return dict(p["securityQuestion"])
|
||||
|
||||
def refresh_identity(self):
|
||||
"""Re-mirror ACCOUNT into the save AND persist it.
|
||||
|
||||
@@ -182,7 +471,13 @@ class Store:
|
||||
return self.load()["coins"]
|
||||
|
||||
def items(self):
|
||||
return self.load()["items"]
|
||||
# Stamp discardValue on READ as well as on creation. _item() only covers cards
|
||||
# minted from now on, and the save already holds 246 items built before the
|
||||
# flag existed; without this the reveal screen would show real values while
|
||||
# the club showed 0 for everything older. Stamped on the way out and NOT
|
||||
# persisted, so the save stays clean and turning the flag off is a true revert.
|
||||
its = self.load()["items"]
|
||||
return [_with_discard(dict(it)) for it in its] if DISCARD_SEND else its
|
||||
|
||||
def add_items(self, new_items):
|
||||
with _LOCK:
|
||||
@@ -215,6 +510,15 @@ class Store:
|
||||
dv = it.get("discardValue") or 0
|
||||
if dv:
|
||||
return int(dv)
|
||||
if DISCARD_TABLE:
|
||||
# The real table. Matches what the client displays once
|
||||
# FUT_DISCARD_SEND puts the value on the wire.
|
||||
v = discard_value(it)
|
||||
if v is not None:
|
||||
return v
|
||||
# else: unrated card, formula does not apply, fall through
|
||||
# The invented tier. Wrong for every card, kept only as the live-proven
|
||||
# default until FUT_DISCARD_TABLE has been in front of the game once.
|
||||
r = it.get("rating") or 0
|
||||
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
|
||||
with _LOCK:
|
||||
@@ -299,14 +603,45 @@ class Store:
|
||||
return moved
|
||||
|
||||
def purchased(self):
|
||||
# Stamped on read exactly like items(). Leaving this out was a real defect:
|
||||
# the pending pile is the ONE place a quick-sell value is actually read, so
|
||||
# the club showed real numbers while the reveal screen showed 0 for anything
|
||||
# already sitting in the pile. Found by a verification pass, not by testing.
|
||||
"""Items still held in the purchased/unassigned pile (returned by
|
||||
GET /purchased/items); they move to the club via FutMoveCard (PUT /item)."""
|
||||
return self.load().get("purchased", [])
|
||||
pur = self.load().get("purchased", [])
|
||||
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
|
||||
|
||||
def active_squad(self):
|
||||
sq = self.load()["squads"]
|
||||
return sq[0] if sq else None
|
||||
|
||||
def unopened_packs(self):
|
||||
"""Owned reward-pack template IDs, including repeated grants."""
|
||||
return list(self.load().get("unopenedPackIds", []))
|
||||
|
||||
def consume_unopened_pack(self, pack_id):
|
||||
"""Atomically consume one owned instance of a reward pack."""
|
||||
with _LOCK:
|
||||
p = self.load()
|
||||
owned = p.setdefault("unopenedPackIds", [])
|
||||
try:
|
||||
owned.remove(pack_id)
|
||||
except ValueError:
|
||||
return False
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def grant_unopened_pack(self, pack_id):
|
||||
"""Persist one additional owned reward-pack instance."""
|
||||
if pack_by_id(pack_id) is None:
|
||||
return False
|
||||
with _LOCK:
|
||||
p = self.load()
|
||||
p.setdefault("unopenedPackIds", []).append(pack_id)
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def reconstruct_squad(self, squad):
|
||||
"""FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>}
|
||||
(a reference). Re-embed the FULL club item by id so the squad reloads with
|
||||
@@ -351,7 +686,8 @@ class Store:
|
||||
return i
|
||||
|
||||
|
||||
def open_pack(self, price, count, gold=True, tiers=None):
|
||||
def open_pack(self, price, count, gold=True, tiers=None, special_chance=0.0,
|
||||
players_only=False):
|
||||
"""Deduct `price` coins, generate `count` player items from the pool, and
|
||||
place them in the PENDING purchased pile (unassigned). They are NOT owned
|
||||
club items until moved there via FutMoveCard (PUT /item). Returns None if
|
||||
@@ -373,19 +709,31 @@ class Store:
|
||||
# fixed number so it scales from a 5-card bronze to an 11-card premium.
|
||||
n_extra = 0
|
||||
extras = []
|
||||
if PACK_MIX and count >= 5:
|
||||
if PACK_MIX and not players_only and count >= 5:
|
||||
n_extra = max(1, count // 4)
|
||||
extras = _pack_extras(n_extra, self)
|
||||
n_extra = len(extras)
|
||||
n_players = max(1, count - n_extra)
|
||||
if tiers:
|
||||
picks = [random.choice(fut_cards.pool_for(random.choice(tiers)))
|
||||
for _ in range(n_players)]
|
||||
# Draw each tier independently but reject duplicate asset IDs inside
|
||||
# one pack. The real pool is large enough that this normally succeeds
|
||||
# on the first attempt; the cap makes malformed tiny test pools safe.
|
||||
picks = []
|
||||
used_assets = set()
|
||||
for _ in range(n_players):
|
||||
tier_pool = fut_cards.pool_for(random.choice(tiers))
|
||||
available = [p for p in tier_pool if p[0] not in used_assets]
|
||||
pick = random.choice(available or tier_pool)
|
||||
picks.append(pick)
|
||||
used_assets.add(pick[0])
|
||||
else:
|
||||
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
|
||||
picks = [random.choice(pool) for _ in range(n_players)]
|
||||
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at)
|
||||
for (a, r, p, n, lg, tm, at) in picks]
|
||||
picks = random.sample(pool, min(n_players, len(pool)))
|
||||
while len(picks) < n_players:
|
||||
picks.append(random.choice(pool))
|
||||
items = [player_item(self.new_item_id(), pick,
|
||||
special=random.random() < special_chance)
|
||||
for pick in picks]
|
||||
items += extras
|
||||
random.shuffle(items)
|
||||
with _LOCK:
|
||||
@@ -396,7 +744,9 @@ class Store:
|
||||
return items
|
||||
|
||||
def last_pack(self):
|
||||
return self.load().get("purchased", [])
|
||||
# Same stamping as purchased(); this is the reveal-screen read path.
|
||||
pur = self.load().get("purchased", [])
|
||||
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
|
||||
|
||||
|
||||
|
||||
@@ -469,11 +819,17 @@ _LEGACY_POOL = STARTER_PLAYERS + [
|
||||
# no silver or bronze players at all, so all three packs were identical in practice.
|
||||
PACK_CATALOG = [
|
||||
{"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False,
|
||||
"tiers": ["bronze"] * 8 + ["silver"] * 2},
|
||||
"tiers": ["bronze"] * 8 + ["silver"] * 2, "specialChance": 0.005},
|
||||
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True,
|
||||
"tiers": ["gold"] * 6 + ["silver"] * 4},
|
||||
"tiers": ["gold"] * 6 + ["silver"] * 4, "specialChance": 0.03},
|
||||
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True,
|
||||
"tiers": ["gold"] * 9 + ["silver"] * 1},
|
||||
"tiers": ["gold"] * 9 + ["silver"] * 1, "specialChance": 0.08},
|
||||
{"id": 7, "name": "Special Players Pack", "price": 25000, "count": 11,
|
||||
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
|
||||
"playersOnly": True},
|
||||
{"id": 70, "name": "Reward Special Players Pack", "price": 0, "count": 11,
|
||||
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
|
||||
"playersOnly": True, "ownedOnly": True},
|
||||
]
|
||||
|
||||
|
||||
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read the FutDataManagerImpl UI gate bytes out of the LIVE FIFA 17 client.
|
||||
|
||||
Why this exists: on 2026-08-05 the /settings gate plan concluded that
|
||||
IS_FRIENDLY_SEASON_ENABLED and IS_DRAFT_MODE_ENABLED had never been set true by
|
||||
anything. Measured against the running client, both are 1, and have been all along.
|
||||
The applier FUN_18011dc50 runs whether or not the configs array has content, and the
|
||||
settings struct it is handed defaults these fields to 1. "Nothing populates the array"
|
||||
is not "nothing writes the byte".
|
||||
|
||||
Read-only. Opens /proc/<pid>/mem O_RDONLY and preads. Nothing here can write.
|
||||
|
||||
Nothing is assumed:
|
||||
* the pid is resolved by exact /proc/*/comm match, never hardcoded
|
||||
* the CardsDLL base is read from /proc/<pid>/maps, never cached across launches
|
||||
(Wine copies the sections into anonymous memory, so only the 4 KiB PE header is
|
||||
file-backed and `grep CardsDLL maps` returns exactly ONE line, which is easy to
|
||||
misread as "barely mapped")
|
||||
* the slide is PROVEN against the FNV atom-hash prologue at 0x180180d00, read from
|
||||
the on-disk PE, before any other address is trusted
|
||||
* each gate byte displacement is DECODED from its accessor stub (0f b6 81 <disp32>,
|
||||
movzx eax, byte [rcx+disp32]) rather than taken from a table
|
||||
|
||||
Requires the client to have reached Ultimate Team, since CardsDLL loads only then.
|
||||
Usage: python3 gate_byte_probe.py
|
||||
"""
|
||||
import os, struct, sys
|
||||
pid=None
|
||||
for d in os.listdir('/proc'):
|
||||
if d.isdigit():
|
||||
try:
|
||||
if open('/proc/%s/comm'%d).read().strip()=='FIFA17.exe': pid=int(d); break
|
||||
except Exception: pass
|
||||
assert pid, "not running"
|
||||
print("pid", pid)
|
||||
base=None
|
||||
for ln in open('/proc/%d/maps'%pid):
|
||||
if 'CardsDLL' in ln:
|
||||
base=int(ln.split('-')[0],16); print("cardsdll map line:", ln.strip())
|
||||
assert base
|
||||
slide = base - 0x180000000
|
||||
print("base %#x slide %#x" % (base, slide))
|
||||
fd=os.open('/proc/%d/mem'%pid, os.O_RDONLY)
|
||||
def rd(va,n): return os.pread(fd, n, va)
|
||||
# control: FNV prologue, bytes taken from the on-disk PE
|
||||
pe=open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll','rb').read()
|
||||
# .text rva 0x1000 rawptr 0x400
|
||||
def f(va): return va-0x180000000-0x1000+0x400
|
||||
ctl_disk=pe[f(0x180180d00):f(0x180180d00)+32]
|
||||
ctl_live=rd(0x180180d00+slide,32)
|
||||
print("CONTROL FNV", "MATCH" if ctl_disk==ctl_live else "MISMATCH", ctl_live.hex())
|
||||
# model singleton
|
||||
dat=0x1802e6398+slide
|
||||
obj=struct.unpack('<Q', rd(dat,8))[0]
|
||||
print("DAT_1802e6398 ->", hex(obj))
|
||||
vt=struct.unpack('<Q', rd(obj,8))[0]
|
||||
print("vtable live %#x static %#x" % (vt, vt-slide))
|
||||
for off,name in [(0x2b0,'friendlySeasons'),(0x2c8,'draftMode'),(0x2e0,'packOpeningAnimation')]:
|
||||
slot=struct.unpack('<Q', rd(vt+off,8))[0]
|
||||
stub=rd(slot,8)
|
||||
disp=struct.unpack('<I', stub[3:7])[0] if stub[:3]==b'\x0f\xb6\x81' else None
|
||||
val=rd(obj+disp,1)[0] if disp is not None else None
|
||||
print(" slot +%#x -> %#x stub=%s disp=%s value=%s" % (off, slot-slide, stub.hex(), hex(disp) if disp else None, val))
|
||||
# unopenedPacks total
|
||||
print("model+0x20950 =", struct.unpack('<I', rd(obj+0x20950,4))[0])
|
||||
os.close(fd)
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decode the running-sum atom ladders in /hub parser FUN_180139610 and name each
|
||||
atom from docs/fut_atoms.tsv.
|
||||
|
||||
The dispatch is `sub ecx,d0 / sub ecx,d1 / .../ cmp ecx,dN`: the atom that each
|
||||
branch handles is the CUMULATIVE sum of the deltas up to and including that step
|
||||
(a jz after each sub tests atom==running_sum). Plus there are direct `cmp esi,imm`.
|
||||
"""
|
||||
import subprocess, re
|
||||
|
||||
DLL = "/tmp/fut/cardsdll.dll"
|
||||
TSV = "/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"
|
||||
FUNC, STOP = 0x180139610, 0x18013e600
|
||||
|
||||
atoms = {}
|
||||
for line in open(TSV):
|
||||
p = line.rstrip("\n").split("\t")
|
||||
if len(p) >= 3:
|
||||
try: atoms[int(p[1], 16)] = p[2]
|
||||
except ValueError: pass
|
||||
|
||||
out = subprocess.check_output(
|
||||
["objdump", "-d", "-M", "intel",
|
||||
"--start-address=%#x" % FUNC, "--stop-address=%#x" % STOP, DLL], text=True)
|
||||
|
||||
# linear list of (addr, mnem, dest_reg, imm) for sub/cmp on 32-bit regs, stop at int3 pad
|
||||
seq = []
|
||||
int3 = 0
|
||||
for ln in out.splitlines():
|
||||
parts = ln.split("\t")
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
addr_s = parts[0].strip().rstrip(":")
|
||||
try:
|
||||
addr = int(addr_s, 16)
|
||||
except ValueError:
|
||||
continue
|
||||
instr = parts[2].strip()
|
||||
bits = instr.split(None, 1)
|
||||
mnem = bits[0]
|
||||
ops = bits[1].strip() if len(bits) > 1 else ""
|
||||
if mnem == "int3":
|
||||
int3 += 1
|
||||
if int3 >= 4: break
|
||||
continue
|
||||
int3 = 0
|
||||
mo = re.match(r"(e?[a-d]x|e?si|e?di|e?bp|r\d+d?),\s*(0x[0-9a-f]+)$", ops)
|
||||
if mnem in ("sub", "cmp") and mo:
|
||||
seq.append((addr, mnem, mo.group(1), int(mo.group(2), 16)))
|
||||
|
||||
# walk ladders: consecutive sub/cmp on the SAME register form one ladder; the running
|
||||
# sum at each element is the atom that element dispatches. A `cmp` closes the ladder.
|
||||
found = {} # atom -> (addr, kind)
|
||||
i = 0
|
||||
while i < len(seq):
|
||||
addr, mnem, reg, imm = seq[i]
|
||||
# a ladder starts on a sub
|
||||
if mnem == "sub":
|
||||
run = 0
|
||||
j = i
|
||||
while j < len(seq) and seq[j][2] == reg and seq[j][1] in ("sub", "cmp"):
|
||||
run += seq[j][3]
|
||||
found.setdefault(run, (seq[j][0], "ladder"))
|
||||
if seq[j][1] == "cmp":
|
||||
j += 1
|
||||
break
|
||||
j += 1
|
||||
i = j
|
||||
else:
|
||||
# a lone cmp reg,imm on an atom-holding reg is a direct atom test
|
||||
if 0 < imm <= 0x400:
|
||||
found.setdefault(imm, (addr, "direct"))
|
||||
i += 1
|
||||
|
||||
TOKENS = {0x1, 0x6, 0x7, 0x9, 0xa, 0xb, 0xc, 0xd} # SAX token enum, not atoms
|
||||
print("Atoms dispatched by hub parser FUN_%#x:" % FUNC)
|
||||
print("=" * 70)
|
||||
for a in sorted(found):
|
||||
if a in TOKENS:
|
||||
continue
|
||||
tag = " <-- TOKEN?" if a < 0x10 else ""
|
||||
print(" %#06x %-28s (%s @ %#x)%s" %
|
||||
(a, atoms.get(a, "?"), found[a][1], found[a][0], tag))
|
||||
|
||||
print("\nKnown tile counters for reference: 0x33=auctionCount, 0x90=clubPlayers")
|
||||
print("\nName-based tile-count candidates:")
|
||||
KEYS = ("sell","sold","trade","auction","pile","list","count","num","offer",
|
||||
"won","outbid","target","watch","transfer","active","unassigned")
|
||||
for a in sorted(found):
|
||||
if a in TOKENS: continue
|
||||
n = atoms.get(a, "").lower()
|
||||
if any(k in n for k in KEYS):
|
||||
print(" %#06x %s" % (a, atoms.get(a, "?")))
|
||||
@@ -0,0 +1,84 @@
|
||||
"""ADVERSARIAL Q1.
|
||||
|
||||
HYPOTHESIS UNDER ATTACK (dim1 claim 3): "FUN_1800150d0 ... finds-or-creates a group by
|
||||
an exact string compare on displayGroup.value", i.e. wire-record +0x00 holds
|
||||
displayGroup.value.
|
||||
|
||||
WHY IT IS NOT PROVEN: live we serve description == displayGroup.value == the SAME
|
||||
STRING for all three packs ("Bronze Pack"/"Gold Pack"/"Premium Gold"), so the live
|
||||
group caption cannot distinguish displayGroup.value (atom 0xd9->0x377) from
|
||||
description (atom 0xd1). If the key is actually `description`, recommendation #2
|
||||
(serve displayGroup.value="gold") silently does nothing.
|
||||
|
||||
METHOD: decompile the 0x158 wire-record element deserializer 0x18013af30 IN FULL,
|
||||
print len(src), and enumerate the atom dispatch. Explicitly search the raw
|
||||
disassembly of the function for EVERY syntactic dispatch form the brief warns about:
|
||||
== imm, != imm, switch case labels (jump table), and sub/dec ladders.
|
||||
CONTROL: atom 0x20f (packType) is known-present (live pack model +0x38 = "BRONZE"),
|
||||
so whatever form finds packType must also be applied to 0xd1/0xd9/0xda/0x2cb.
|
||||
The control uses the SAME method (raw immediate scan over the same instruction
|
||||
range), not a different one.
|
||||
"""
|
||||
import sys, traceback, re
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q1_out.txt"
|
||||
try:
|
||||
fh = open(OUT, "w")
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s); fh.write(s + "\n")
|
||||
|
||||
ATOMS = {0x23:"assetId",0xd1:"description",0xd9:"displayGroup",0xda:"displayGroupAssetId",
|
||||
0xdb:"displayGroupUseDefaultImage",0x15c:"id",0x20f:"packType",0x250:"priority",
|
||||
0x2cb:"sortPriority",0x377:"value",0x36a:"useDefaultImage",0x260:"purchase"}
|
||||
|
||||
for target in (0x18013af30,):
|
||||
f = func(target)
|
||||
P("=== FUNCTION %s @ %#x body=%s ===" % (f.getName(), int(f.getEntryPoint().getOffset()), f.getBody()))
|
||||
src = dec(target, 300)
|
||||
P("len(src) =", len(src))
|
||||
P("---- FULL DECOMPILE BEGIN ----")
|
||||
P(src)
|
||||
P("---- FULL DECOMPILE END ----")
|
||||
|
||||
# raw instruction scan of the whole function body for every atom immediate
|
||||
P()
|
||||
P("=== RAW INSTRUCTION SCAN over FUN_18013af30 body: all forms ===")
|
||||
f = func(0x18013af30)
|
||||
body = f.getBody()
|
||||
it = listing.getInstructions(body, True)
|
||||
ins = []
|
||||
while it.hasNext():
|
||||
i = it.next()
|
||||
ins.append((int(i.getAddress().getOffset()), str(i.getMnemonicString()), str(i)))
|
||||
P("instruction count:", len(ins))
|
||||
# collect all immediates appearing anywhere in the text form
|
||||
found = {}
|
||||
for a, mn, txt in ins:
|
||||
for m in re.finditer(r'0x([0-9a-fA-F]+)', txt):
|
||||
v = int(m.group(1), 16)
|
||||
if v in ATOMS:
|
||||
found.setdefault(v, []).append((a, mn, txt))
|
||||
for v in sorted(ATOMS):
|
||||
lst = found.get(v, [])
|
||||
P("atom %#05x %-28s hits=%d" % (v, ATOMS[v], len(lst)))
|
||||
for a, mn, txt in lst:
|
||||
P(" %#x %s" % (a, txt))
|
||||
# dispatch-form census: CMP/SUB/DEC ladders on the atom register
|
||||
P()
|
||||
P("=== dispatch-form census (CMP/SUB/DEC/SWITCH inside the function) ===")
|
||||
forms = {"CMP":0,"SUB":0,"DEC":0,"JMP":0,"SWITCH":0}
|
||||
for a, mn, txt in ins:
|
||||
if mn in forms: forms[mn]+=1
|
||||
if mn == "JMP" and "[" in txt: forms["SWITCH"]+=1
|
||||
P(forms)
|
||||
P("all CMP with a small immediate (candidate atom compares):")
|
||||
for a, mn, txt in ins:
|
||||
if mn in ("CMP","SUB","DEC","ADD") :
|
||||
m = re.search(r'0x([0-9a-fA-F]{1,4})\s*$', txt)
|
||||
if m:
|
||||
v=int(m.group(1),16)
|
||||
if 0x10 <= v <= 0x400:
|
||||
P(" %#x %-8s %s -> imm %#x %s" % (a, mn, txt, v, ATOMS.get(v,"")))
|
||||
fh.close()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,97 @@
|
||||
"""ADVERSARIAL Q2. Batch.
|
||||
|
||||
Targets under attack:
|
||||
(a) dim1 claim 4: "FUN_1800147f0 ... a miss returns NULL and the caller then
|
||||
dereferences address 0x40, i.e. it would crash" -- ABSENCE OF A NULL CHECK.
|
||||
Method: print the RAW DISASSEMBLY of FUN_1800147f0 from the CALL to
|
||||
FUN_180014420 to the next 40 instructions, so a TEST/JZ is visible if present.
|
||||
Control: the same raw-listing method applied to FUN_180014380's call sites,
|
||||
where the decompiler DOES show a null test, must show TEST/JZ. Same form.
|
||||
(b) dim1 claim 5: "+0x290 is written in exactly TWO places in all of CardsDLL".
|
||||
objdump found 12 dword/qword writes at +0x290 plus one QWORD write at +0x28c
|
||||
that covers it. Resolve the containing function of every one and decide.
|
||||
(c) dim1 claim 9/10: model+0x94 = group ordinal, model+0x1a0 = sortPriority;
|
||||
+0x1a0 pushed to no Flash field. Print FUN_18002c3c0 and FUN_180015d80 in full
|
||||
and print their exact address ranges so the claim can be re-checked in objdump.
|
||||
(d) dim1 claim 3: FUN_1800150d0 / FUN_180012950 / FUN_180014380 full.
|
||||
(e) dim1 claim 7: FUN_180014580 / FUN_180014df0 six literals; enumerate.
|
||||
(f) FUN_180014610 group-tile builder: does tile+0x9c really get the ordinal
|
||||
(CHILD_CATEGORY) and tile+0xac the displayGroupAssetId? Recommendation #1
|
||||
depends entirely on this.
|
||||
"""
|
||||
import sys, traceback, re
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q2_out.txt"
|
||||
try:
|
||||
fh = open(OUT, "w")
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s); fh.write(s + "\n")
|
||||
|
||||
TARGETS = [0x1800150d0, 0x180012950, 0x180014380, 0x180014420, 0x1800147f0,
|
||||
0x180014610, 0x18002c3c0, 0x180015d80, 0x180014580, 0x180014df0,
|
||||
0x18007e7f0, 0x18007d1a0, 0x18007dab0]
|
||||
P("=== FUNCTION BOUNDS ===")
|
||||
for t in TARGETS:
|
||||
f = func(t)
|
||||
if f is None:
|
||||
P("%#x -> NO FUNCTION" % t); continue
|
||||
P("%#x %-22s min=%#x max=%#x size=%#x" % (t, f.getName(),
|
||||
int(f.getBody().getMinAddress().getOffset()),
|
||||
int(f.getBody().getMaxAddress().getOffset()),
|
||||
int(f.getBody().getNumAddresses())))
|
||||
|
||||
# (b) resolve containing functions of every +0x290 write objdump found
|
||||
P()
|
||||
P("=== (b) containing functions of every raw +0x290 / +0x28c write ===")
|
||||
W = [0x180051da3,0x18007d3ba,0x18007f0c0,0x18008c777,0x18008fd45,0x1800d3564,
|
||||
0x1800d43fc,0x18013454a,0x180189d84,0x18018caa3,0x18018e1ff,0x180191f77,
|
||||
0x18015b885,0x180067eb0,0x180067ebf]
|
||||
for w in W:
|
||||
f = func(w)
|
||||
P(" %#x -> %s @ %#x" % (w, f.getName() if f else "NONE",
|
||||
int(f.getEntryPoint().getOffset()) if f else 0))
|
||||
# is any of those functions in the store-screen vtable?
|
||||
P()
|
||||
P("=== store screen vtable 0x1801ff690 (first 48 slots) ===")
|
||||
ents = set()
|
||||
for off, tgt, nm in vtable(0x1801ff690, 48):
|
||||
P(" +%#04x %#x %s" % (off, tgt, nm))
|
||||
ents.add(tgt)
|
||||
P("vtable also at 0x1801ff6f8 / 0x1801ff610 per the claim; dumping 0x1801ff610:")
|
||||
for off, tgt, nm in vtable(0x1801ff610, 24):
|
||||
P(" +%#04x %#x %s" % (off, tgt, nm))
|
||||
|
||||
# (a) raw disassembly around the FUN_180014420 call inside FUN_1800147f0
|
||||
P()
|
||||
P("=== (a) RAW LISTING of FUN_1800147f0 (whole function) ===")
|
||||
f = func(0x1800147f0)
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
n = 0
|
||||
while it.hasNext():
|
||||
i = it.next(); n += 1
|
||||
P(" %#x %s" % (int(i.getAddress().getOffset()), str(i)))
|
||||
P("instruction count:", n)
|
||||
|
||||
P()
|
||||
P("=== (a-control) RAW LISTING of FUN_180014610 (whole function) ===")
|
||||
f = func(0x180014610)
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
n = 0
|
||||
while it.hasNext():
|
||||
i = it.next(); n += 1
|
||||
P(" %#x %s" % (int(i.getAddress().getOffset()), str(i)))
|
||||
P("instruction count:", n)
|
||||
|
||||
for t in TARGETS:
|
||||
P()
|
||||
f = func(t)
|
||||
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
|
||||
src = dec(t, 300)
|
||||
P("len(src) =", len(src))
|
||||
P(src)
|
||||
P("======== END %#x ========" % t)
|
||||
fh.close()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
try: fh.close()
|
||||
except Exception: pass
|
||||
@@ -0,0 +1,72 @@
|
||||
"""ADVERSARIAL Q3.
|
||||
|
||||
Attacking dim1 claim 10: "sortPriority is inert at the UI. It reaches pack+0x1a0 and is
|
||||
pushed to no Flash field ... Both are dead ends for this bug."
|
||||
An objdump scan of the store cluster found 0x1800108cd/0x1800108d3
|
||||
mov eax,[rsi+0x1a0] ; cmp [rbx+0x1a0],eax
|
||||
which is the shape of a SORT COMPARATOR on two 0x1a8 models, and 0x18002cc62
|
||||
mov [rbx+0x1a0],esi
|
||||
inside FUN_18002cc90, which FUN_18002c3c0 tail-calls AFTER setting +0x1a0 = sortPriority.
|
||||
Both were missed by "grep the push list".
|
||||
|
||||
Also decompile:
|
||||
FUN_18002c8b0 -- the per-group filter in FUN_180014610; if it can HIDE a group the
|
||||
tile ordinals the user sees stop matching the group ordinals.
|
||||
FUN_18007e5e0 / FUN_18007df60 -- the six-panel binding (dim1 claim 7).
|
||||
FUN_18007e7f0 cases 0x7551 / 0x753f -- the CATEGORY_ID round trip.
|
||||
callers of FUN_1800147f0.
|
||||
"""
|
||||
import sys, traceback
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q3_out.txt"
|
||||
try:
|
||||
fh = open(OUT, "w")
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s); fh.write(s + "\n")
|
||||
|
||||
for a in (0x1800108cd, 0x18002cc62, 0x180010b5c, 0x180011c2c):
|
||||
f = func(a)
|
||||
P("%#x -> %s @ %#x size=%#x" % (a, f.getName() if f else "NONE",
|
||||
int(f.getEntryPoint().getOffset()) if f else 0,
|
||||
int(f.getBody().getNumAddresses()) if f else 0))
|
||||
|
||||
P()
|
||||
P("=== callers of FUN_1800147f0 ===")
|
||||
for frm, typ, fn, ent in xrefs_to(0x1800147f0):
|
||||
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||
P("=== callers of FUN_180014610 ===")
|
||||
for frm, typ, fn, ent in xrefs_to(0x180014610):
|
||||
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||
P("=== callers of FUN_18002c8b0 ===")
|
||||
for frm, typ, fn, ent in xrefs_to(0x18002c8b0):
|
||||
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||
P("=== callers of the comparator's containing function ===")
|
||||
cf = func(0x1800108cd)
|
||||
if cf:
|
||||
for frm, typ, fn, ent in xrefs_to(int(cf.getEntryPoint().getOffset())):
|
||||
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||
|
||||
tg = []
|
||||
if cf: tg.append(int(cf.getEntryPoint().getOffset()))
|
||||
tg += [0x18002cc90, 0x18002c8b0, 0x18007e5e0, 0x18007df60, 0x180014b60]
|
||||
for t in tg:
|
||||
f = func(t)
|
||||
P()
|
||||
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
|
||||
src = dec(t, 300)
|
||||
P("len(src) =", len(src))
|
||||
P(src)
|
||||
P("======== END %#x ========" % t)
|
||||
|
||||
# full FUN_18007e7f0 (big) -- print only, it is the CATEGORY_ID round trip
|
||||
P()
|
||||
P("======== DECOMPILE FUN_18007e7f0 (full) ========")
|
||||
src = dec(0x18007e7f0, 600)
|
||||
P("len(src) =", len(src))
|
||||
P(src)
|
||||
P("======== END ========")
|
||||
fh.close()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
try: fh.close()
|
||||
except Exception: pass
|
||||
@@ -0,0 +1,29 @@
|
||||
"""ADVERSARIAL Q4. Where is the +0x1a0 (sortPriority) merge sort actually used, and
|
||||
what does the 0x1a8 ctor leave in +0x1a0 / +0x94 for GROUP TILES (FUN_180014610 sets
|
||||
neither)? Also FUN_180012950 and FUN_180014380 in full for the group-key claim."""
|
||||
import sys, traceback
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q4_out.txt"
|
||||
try:
|
||||
fh = open(OUT, "w")
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s); fh.write(s + "\n")
|
||||
P("=== callers of FUN_180010cd0 (the merge-sort driver over +0x1a0) ===")
|
||||
for frm, typ, fn, ent in xrefs_to(0x180010cd0):
|
||||
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||
P("=== callers of FUN_180010890 ===")
|
||||
for frm, typ, fn, ent in xrefs_to(0x180010890):
|
||||
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||
for t in (0x1800130c0, 0x180012950, 0x180014380, 0x180010cd0):
|
||||
f = func(t)
|
||||
P()
|
||||
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
|
||||
src = dec(t, 300)
|
||||
P("len(src) =", len(src))
|
||||
P(src)
|
||||
P("======== END %#x ========" % t)
|
||||
fh.close()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
try: fh.close()
|
||||
except Exception: pass
|
||||
@@ -0,0 +1,30 @@
|
||||
"""ADVERSARIAL Q5. The sortPriority merge sort has exactly one entry point
|
||||
(0x180016f81 -> FUN_180010bc0). Identify its containing function, what list it sorts,
|
||||
and who calls it. Also print FUN_1800130c0 in full to see whether +0x1a0 / +0x94 are
|
||||
initialised at all for group tiles (FUN_180014610 sets neither)."""
|
||||
import sys, traceback
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q5_out.txt"
|
||||
try:
|
||||
fh = open(OUT, "w")
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s); fh.write(s + "\n")
|
||||
f = func(0x180016f81)
|
||||
P("0x180016f81 is inside %s @ %#x size=%#x" % (f.getName(), int(f.getEntryPoint().getOffset()),
|
||||
int(f.getBody().getNumAddresses())))
|
||||
ent = int(f.getEntryPoint().getOffset())
|
||||
P("=== callers of %s ===" % f.getName())
|
||||
for frm, typ, fn, e in xrefs_to(ent):
|
||||
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, e))
|
||||
for t in (ent, 0x1800130c0):
|
||||
g = func(t)
|
||||
P()
|
||||
P("======== DECOMPILE %s @ %#x ========" % (g.getName(), t))
|
||||
src = dec(t, 300)
|
||||
P("len(src) =", len(src)); P(src)
|
||||
P("======== END %#x ========" % t)
|
||||
fh.close()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
try: fh.close()
|
||||
except Exception: pass
|
||||
@@ -0,0 +1,104 @@
|
||||
"""ADVERSARIAL BATCH 1.
|
||||
|
||||
HYPOTHESES UNDER ATTACK (all from another agent, assumed WRONG until reproduced):
|
||||
H1 item+0x49 = (untradeable == false), written by atom 0x361 in FUN_18013fe00.
|
||||
H2 FUN_1801a7260 (TO_TRADE_PILE) requires item+0x49 != 0, and the eight flags are
|
||||
ENABLE flags.
|
||||
H3 FUN_18003e370 publishes 8 names in the order DISCARD, MODIFY, TO_ACTIVE_SQUAD,
|
||||
TO_TRADE_PILE, ... and FUN_1800e2a40 fills those 8 bytes in that order.
|
||||
H4 item+0x54 is the discard LEVEL written at 0x180141e8a..0x180141ea3, not itemType.
|
||||
H5 the itemState table starts at 0x180229cc0 with 12 entries.
|
||||
H6 FUN_180166660 has exactly one caller.
|
||||
H7 FUN_1801a8620 (+0x38) and FUN_1801a8090 (+0x3c) have exactly one xref each.
|
||||
|
||||
CONTROL: for every "exactly one caller" claim I also run the SAME xrefs_to call on a
|
||||
function that is known to have many callers (FUN_180135ff0, the value-SKIP, ~134) and
|
||||
on the FNV hasher 0x180180d00, so a zero/one result cannot be a broken scan.
|
||||
Everything is printed IN FULL; no truncation.
|
||||
"""
|
||||
import traceback, sys
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q1_raw.txt"
|
||||
try:
|
||||
f = open(OUT, "w")
|
||||
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
f.write(s + "\n")
|
||||
|
||||
P("=" * 30, "CONTROL: xrefs machinery works", "=" * 30)
|
||||
for nm, a in (("FUN_180135ff0 value-SKIP", 0x180135FF0),
|
||||
("FUN_180180d00 FNV hasher", 0x180180D00),
|
||||
("FUN_1801c7620 BOOL prim", 0x1801C7620)):
|
||||
xr = xrefs_to(a)
|
||||
ents = sorted(set(e for _, t, _, e in xr if "CALL" in t and e))
|
||||
P("%s: %d refs, %d distinct calling funcs" % (nm, len(xr), len(ents)))
|
||||
|
||||
P()
|
||||
P("=" * 30, "H7 discard getters", "=" * 30)
|
||||
for nm, a in (("FUN_1801a8620 (+0x38 DISCARD_CREDITS?)", 0x1801A8620),
|
||||
("FUN_1801a8090 (+0x3c CALCULATED?)", 0x1801A8090),
|
||||
("FUN_1801a80c0 (CARD_LEVEL?)", 0x1801A80C0)):
|
||||
P("---", nm)
|
||||
fn = fm.getFunctionAt(addr(a))
|
||||
P(" function at addr:", fn.getName() if fn else None)
|
||||
for frm, t, cf, e in xrefs_to(a):
|
||||
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||
P(" BODY:")
|
||||
P(dec(a))
|
||||
|
||||
P()
|
||||
P("=" * 30, "H6 FUN_180166660 callers", "=" * 30)
|
||||
for frm, t, cf, e in xrefs_to(0x180166660):
|
||||
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||
P(dec(0x180166660))
|
||||
|
||||
P()
|
||||
P("=" * 30, "H5 itemState table walk from 0x180229c00", "=" * 30)
|
||||
a = 0x180229C00
|
||||
for i in range(40):
|
||||
p = qword(a + i * 0x10)
|
||||
q = qword(a + i * 0x10 + 8)
|
||||
s = ""
|
||||
if 0x180000000 <= p < 0x181000000:
|
||||
try:
|
||||
s = rd_str(p, 60)
|
||||
except Exception:
|
||||
s = "?"
|
||||
P(" %#x p=%#018x q=%#018x %r" % (a + i * 0x10, p, q, s))
|
||||
|
||||
P()
|
||||
P("=" * 30, "H2 TO_TRADE_PILE predicate + siblings", "=" * 30)
|
||||
for a in (0x1801A7260, 0x1801A8940, 0x1801A71C0, 0x1801A7210, 0x1801A7250,
|
||||
0x1801A7180, 0x1801A7320, 0x1801A71E0, 0x1801A8900, 0x1801A89F0):
|
||||
fn = fm.getFunctionAt(addr(a))
|
||||
P("### %#x %s xrefs=%d" % (a, fn.getName() if fn else "NO FUNC", len(xrefs_to(a))))
|
||||
for frm, t, cf, e in xrefs_to(a):
|
||||
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||
P(dec(a))
|
||||
P()
|
||||
|
||||
P()
|
||||
P("=" * 30, "H3 publisher + filler, FULL", "=" * 30)
|
||||
for a in (0x18003E370, 0x1800E2A40):
|
||||
P("### %#x len-of-decompile follows" % a)
|
||||
d = dec(a)
|
||||
P(" len(src) =", len(d))
|
||||
P(d)
|
||||
P()
|
||||
|
||||
P()
|
||||
P("=" * 30, "H4 level write at 0x180141e60..0x180141ec0 raw disasm", "=" * 30)
|
||||
ins = listing.getInstructions(addr(0x180141E40), True)
|
||||
n = 0
|
||||
while ins.hasNext() and n < 60:
|
||||
i = ins.next()
|
||||
if int(i.getAddress().getOffset()) > 0x180141EC0:
|
||||
break
|
||||
P(" %#x %s" % (int(i.getAddress().getOffset()), i))
|
||||
n += 1
|
||||
|
||||
f.close()
|
||||
print("WROTE", OUT)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,20 @@
|
||||
"""BATCH 10: disassemble the undefined thunk at 0x18011c670 (slot +0x270 of the
|
||||
0xed84b12 service = the second gate on TO_TRADE_PILE)."""
|
||||
import traceback
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q10_raw.txt"
|
||||
try:
|
||||
f = open(OUT, "w")
|
||||
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
|
||||
P("bytes at 0x18011c670:", read_bytes(0x18011C670, 64).hex())
|
||||
it = listing.getInstructions(addr(0x18011C670), True)
|
||||
n = 0
|
||||
while it.hasNext() and n < 40:
|
||||
i = it.next(); a = int(i.getAddress().getOffset())
|
||||
if a > 0x18011C6F0: break
|
||||
P(" %#x %s" % (a, i)); n += 1
|
||||
P()
|
||||
for t in (0x18011C4C0, 0x18011C500):
|
||||
P("### %#x" % t); P(dec(t)); P()
|
||||
f.close(); print("WROTE", OUT)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""ADVERSARIAL BATCH 2 -- the ABSENCE claims, re-tested with a DIFFERENT method.
|
||||
|
||||
The other agent tested "+0x49 is compared in exactly two places" and "itemState 5/6
|
||||
are never tested" with a LOAD/COMPARE-PAIR scan keyed on displacement. That method
|
||||
has a structural blind spot: a compare performed on a value RETURNED BY AN ACCESSOR
|
||||
never shows the displacement at the compare site. FUN_1801a8940 is exactly such an
|
||||
accessor for +0x49 and it has a caller (FUN_1800bc580) the agent never opened.
|
||||
|
||||
MY METHOD (different): enumerate EVERY instruction in .text whose textual form
|
||||
contains the displacement, with no filter on opcode class at all -- so ==, !=, switch
|
||||
case labels and sub/dec ladders are all caught at the LOAD, and the containing
|
||||
function is then read. Plus a byte-pattern census of the two-instruction accessor
|
||||
shape 48 8b 4x 18 / <load disp> which finds getters my displacement scan would
|
||||
attribute to the getter rather than to its caller.
|
||||
|
||||
CONTROLS (same syntactic form as the targets -- a raw displacement load):
|
||||
0x38 and 0x3c : known-live fields, must come back non-zero
|
||||
0x4c : the other agent reported 37 pairs, must come back >= 37
|
||||
0xdeadbe : impossible displacement, must come back 0 (proves the scan can
|
||||
return zero for a real absence rather than always finding noise)
|
||||
"""
|
||||
import re, traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q2_raw.txt"
|
||||
try:
|
||||
f = open(OUT, "w")
|
||||
|
||||
def P(*a):
|
||||
f.write(" ".join(str(x) for x in a) + "\n")
|
||||
|
||||
TARGETS = [0x38, 0x3c, 0x48, 0x49, 0x4c, 0x54, 0x58, 0x5c, 0x60, 0x88, 0x90]
|
||||
pats = {d: re.compile(r"\+\s*0x%x\s*\]" % d) for d in TARGETS}
|
||||
impossible = re.compile(r"\+\s*0xdeadbe\s*\]")
|
||||
|
||||
hits = {d: [] for d in TARGETS}
|
||||
imp = []
|
||||
n = 0
|
||||
it = listing.getInstructions(True)
|
||||
while it.hasNext():
|
||||
i = it.next()
|
||||
s = i.toString()
|
||||
n += 1
|
||||
for d, p in pats.items():
|
||||
if p.search(s):
|
||||
hits[d].append((int(i.getAddress().getOffset()), s))
|
||||
if impossible.search(s):
|
||||
imp.append(int(i.getAddress().getOffset()))
|
||||
P("instructions scanned:", n)
|
||||
P("IMPOSSIBLE-DISPLACEMENT CONTROL 0xdeadbe hits:", len(imp), "(must be 0)")
|
||||
P()
|
||||
for d in TARGETS:
|
||||
fns = {}
|
||||
for a, s in hits[d]:
|
||||
fn = fm.getFunctionContaining(addr(a))
|
||||
k = (fn.getName(), int(fn.getEntryPoint().getOffset())) if fn else ("?", 0)
|
||||
fns.setdefault(k, []).append((a, s))
|
||||
P("### displacement +0x%02x : %d instructions in %d functions" % (d, len(hits[d]), len(fns)))
|
||||
if d in (0x49, 0x48):
|
||||
for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]):
|
||||
P(" %s @%#x (%d)" % (nm, e, len(lst)))
|
||||
for a, s in lst:
|
||||
P(" %#x %s" % (a, s))
|
||||
elif d == 0x5c:
|
||||
P(" functions:")
|
||||
for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]):
|
||||
P(" %s @%#x n=%d" % (nm, e, len(lst)))
|
||||
P()
|
||||
|
||||
P("=" * 30, "+0x5c FULL instruction list (itemState 5/6 absence retest)", "=" * 30)
|
||||
for a, s in hits[0x5C]:
|
||||
fn = fm.getFunctionContaining(addr(a))
|
||||
P(" %#x %-52s %s" % (a, s, fn.getName() if fn else "?"))
|
||||
P()
|
||||
|
||||
P("=" * 30, "ACCESSOR CENSUS: byte pattern 48 8b 4x 18 followed by a load", "=" * 30)
|
||||
seen = {}
|
||||
for reg in (0x41, 0x51, 0x49, 0x59, 0x71, 0x79):
|
||||
pat = bytes([0x48, 0x8B, reg, 0x18])
|
||||
for a in find_all(pat, blocks=(".text",)):
|
||||
try:
|
||||
nxt = read_bytes(a + 4, 8)
|
||||
except Exception:
|
||||
continue
|
||||
seen.setdefault(a, nxt)
|
||||
P("call-shape candidates:", len(seen))
|
||||
interest = {}
|
||||
for a, nxt in seen.items():
|
||||
disp = None
|
||||
if nxt[0] == 0x8B and (nxt[1] & 0xC0) == 0x40:
|
||||
disp = nxt[2]
|
||||
elif nxt[0] == 0x0F and nxt[1] in (0xB6, 0xB7) and (nxt[2] & 0xC0) == 0x40:
|
||||
disp = nxt[3]
|
||||
elif nxt[0] == 0x83 and (nxt[1] & 0xC0) == 0x40:
|
||||
disp = nxt[2]
|
||||
elif nxt[0] == 0x8A and (nxt[1] & 0xC0) == 0x40:
|
||||
disp = nxt[2]
|
||||
if disp in (0x38, 0x3C, 0x48, 0x49, 0x4C, 0x54, 0x58, 0x5C, 0x60, 0x88, 0x90):
|
||||
fn = fm.getFunctionContaining(addr(a))
|
||||
interest.setdefault(disp, []).append((a, fn.getName() if fn else "?",
|
||||
int(fn.getEntryPoint().getOffset()) if fn else 0))
|
||||
for d in sorted(interest):
|
||||
P("### accessor-shape loads of +0x%02x : %d" % (d, len(interest[d])))
|
||||
for a, nm, e in sorted(interest[d], key=lambda x: x[2]):
|
||||
P(" %#x in %s @%#x" % (a, nm, e))
|
||||
if e:
|
||||
nc = [(fr, t, cf, ce) for fr, t, cf, ce in xrefs_to(e) if "CALL" in t]
|
||||
P(" callers: %d -> %s" % (len(nc), sorted(set(cf for _, _, cf, _ in nc))))
|
||||
P()
|
||||
|
||||
P("=" * 30, "THE UNOPENED +0x49 CONSUMER: FUN_1800bc580", "=" * 30)
|
||||
d = dec(0x1800BC580)
|
||||
P("len(src) =", len(d))
|
||||
P(d)
|
||||
|
||||
f.close()
|
||||
print("WROTE", OUT)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""ADVERSARIAL BATCH 3.
|
||||
|
||||
My batch-2 displacement census turned up FOUR +0x5c sites the other agent's
|
||||
constant-collecting scan did not report, including MOV dword [RDI+0x5c],0x5 and
|
||||
MOV dword [RDI+0x5c],0x6 in FUN_180147070 -- i.e. the client WRITES forSale and
|
||||
offered. Their claim "forSale(5) and offered(6): NEVER TESTED ANYWHERE" and the
|
||||
action "nothing reads them" are under direct attack here.
|
||||
|
||||
Also under attack:
|
||||
- "no other code path can produce the greyout from wire data": FUN_1800bc580 is a
|
||||
THIRD +0x49 consumer (it counts untradeable squad members). What uses that count?
|
||||
- the FUN_1800e2a40 <-> FUN_18003e370 vtable link the agent flagged as a gap.
|
||||
- the +0x23f playStyle mapper, the 0x226 pile mapper, and the record-offset anchor
|
||||
inside FUN_18013fe00 (printed IN FULL, with len).
|
||||
|
||||
CONTROL for the vtable hunt: I search for the 8-byte pointer to FUN_1800e2a40 AND,
|
||||
in the same pass, for the pointer to FUN_1801a7260 (which the agent reported has NO
|
||||
8-byte pointer, only 4-byte .pdata RVAs) and to FUN_18003e370. A hunt that finds all
|
||||
three or none tells me the search itself is sound.
|
||||
"""
|
||||
import traceback, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q3_raw.txt"
|
||||
try:
|
||||
f = open(OUT, "w")
|
||||
|
||||
def P(*a):
|
||||
f.write(" ".join(str(x) for x in a) + "\n")
|
||||
|
||||
P("=" * 25, "A. itemState WRITERS/READERS the other scan missed", "=" * 25)
|
||||
for a in (0x180147070, 0x1801A6FC0, 0x1800A47B0, 0x18011DC50, 0x1800D73D0):
|
||||
d = dec(a)
|
||||
P("### %#x len=%d xrefs:" % (a, len(d)))
|
||||
for frm, t, cf, e in xrefs_to(a):
|
||||
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||
P(d)
|
||||
P()
|
||||
|
||||
P("=" * 25, "B. the third +0x49 consumer: who calls FUN_1800bc580", "=" * 25)
|
||||
for frm, t, cf, e in xrefs_to(0x1800BC580):
|
||||
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||
P("--- FUN_1801a8890 (the sibling predicate counted into param_2):")
|
||||
P(dec(0x1801A8890))
|
||||
P("--- FUN_1801a80a0:")
|
||||
P(dec(0x1801A80A0))
|
||||
|
||||
P()
|
||||
P("=" * 25, "C. vtable link FUN_1800e2a40 <- FUN_18003e370 slot 0x40", "=" * 25)
|
||||
for nm, a in (("FUN_1800e2a40", 0x1800E2A40), ("FUN_1801a7260", 0x1801A7260),
|
||||
("FUN_18003e370", 0x18003E370), ("FUN_1800eb850", 0x1800EB850)):
|
||||
pat = struct.pack("<Q", a)
|
||||
hits = find_all(pat, blocks=(".rdata", ".data"))
|
||||
P(" %s ptr8 hits: %s" % (nm, [hex(h) for h in hits]))
|
||||
for h in hits:
|
||||
# walk backwards to find the table start (first qword that is not a .text ptr)
|
||||
start = h
|
||||
while True:
|
||||
try:
|
||||
v = qword(start - 8)
|
||||
except Exception:
|
||||
break
|
||||
if not (0x180001000 <= v < 0x1801E5000):
|
||||
break
|
||||
start -= 8
|
||||
P(" table start %#x, slot +%#x" % (start, h - start))
|
||||
for i in range(0, 40):
|
||||
try:
|
||||
v = qword(start + i * 8)
|
||||
except Exception:
|
||||
break
|
||||
if not (0x180001000 <= v < 0x1801E5000):
|
||||
P(" +%#04x %#x <END>" % (i * 8, v))
|
||||
break
|
||||
fn = fm.getFunctionAt(addr(v))
|
||||
P(" +%#04x %#x %s%s" % (i * 8, v, fn.getName() if fn else "",
|
||||
" <== TARGET" if v == a else ""))
|
||||
|
||||
P()
|
||||
P("=" * 25, "D. FUN_18013fe00 FULL", "=" * 25)
|
||||
d = dec(0x18013FE00, timeout=600)
|
||||
P("len(src) =", len(d))
|
||||
P(d)
|
||||
|
||||
P()
|
||||
P("=" * 25, "E. mappers", "=" * 25)
|
||||
for nm, a in (("playStyle FUN_180136480", 0x180136480),
|
||||
("pile FUN_180142650", 0x180142650),
|
||||
("owners helper FUN_1800d7b50", 0x1800D7B50),
|
||||
("BOUGHT_FOR mapper FUN_1800d7b30", 0x1800D7B30),
|
||||
("family FUN_1800d8330", 0x1800D8330)):
|
||||
P("### " + nm)
|
||||
dd = dec(a)
|
||||
P(" len=%d" % len(dd))
|
||||
P(dd)
|
||||
P()
|
||||
|
||||
f.close()
|
||||
print("WROTE", OUT)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""ADVERSARIAL BATCH 4 -- the remaining serve-changing and absence claims.
|
||||
|
||||
- FUN_180141660: is the +0x54 level write really on the COMMON tail, or only on the
|
||||
"DB Error" path? If only on the error path the whole level story changes.
|
||||
- FUN_1801b3640: CMP dword [RAX+0x5c],R15D -- a REGISTER compare the other agent's
|
||||
constant-collecting scan could not evaluate. If R15D can be 5 or 6 their
|
||||
"forSale/offered are never tested" absence claim dies.
|
||||
- FUN_18003e550: the listing panel. Does "List on Transfer Market" have its own
|
||||
enable predicate the eight-flag array does not cover?
|
||||
- FUN_1800eb850: are DISCARD_CREDITS / CALCULATED_DISCARD_CREDITS really the two
|
||||
names, pushed from 0x1801a8620 / 0x1801a8090?
|
||||
- 0x226 pile census, re-tested by xrefs to the mapper FUN_180142650 (a DIFFERENT
|
||||
method from decompiling all 134 skip-callers).
|
||||
- itemState string-writer absence, re-tested by xrefs to every one of the 12 string
|
||||
literals, with the ITEM-TYPE table strings ('player','staff') as a control that
|
||||
has known extra users.
|
||||
- FUN_180008190: resolve the indirect string compare through the global vtable.
|
||||
"""
|
||||
import traceback, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q4_raw.txt"
|
||||
try:
|
||||
f = open(OUT, "w")
|
||||
|
||||
def P(*a):
|
||||
f.write(" ".join(str(x) for x in a) + "\n")
|
||||
|
||||
P("=" * 25, "A. FUN_180141660 -- is the level write a common tail?", "=" * 25)
|
||||
fn = fm.getFunctionAt(addr(0x180141660))
|
||||
body = fn.getBody()
|
||||
P("body:", body, " min %#x max %#x" % (int(body.getMinAddress().getOffset()),
|
||||
int(body.getMaxAddress().getOffset())))
|
||||
# every RET in the function, and every branch target landing at/after 0x180141e77
|
||||
rets, brs = [], []
|
||||
it = listing.getInstructions(body, True)
|
||||
while it.hasNext():
|
||||
i = it.next()
|
||||
m = i.getMnemonicString()
|
||||
a = int(i.getAddress().getOffset())
|
||||
if m == "RET":
|
||||
rets.append(a)
|
||||
if m.startswith("J"):
|
||||
for r in i.getFlows():
|
||||
t = int(r.getOffset())
|
||||
if 0x180141E70 <= t <= 0x180141EB0:
|
||||
brs.append((a, m, t))
|
||||
P("RET sites:", [hex(x) for x in rets])
|
||||
P("branches into the tail 0x180141e70..0x180141eb0:")
|
||||
for a, m, t in brs:
|
||||
P(" %#x %s -> %#x" % (a, m, t))
|
||||
P()
|
||||
P("FUN_180141660 decompile:")
|
||||
d = dec(0x180141660, timeout=600)
|
||||
P("len =", len(d))
|
||||
P(d)
|
||||
|
||||
P()
|
||||
P("=" * 25, "B. FUN_1801b3640 -- the register compare on +0x5c", "=" * 25)
|
||||
ins = listing.getInstructions(addr(0x1801B3860), True)
|
||||
n = 0
|
||||
while ins.hasNext() and n < 90:
|
||||
i = ins.next()
|
||||
a = int(i.getAddress().getOffset())
|
||||
if a > 0x1801B38E0:
|
||||
break
|
||||
P(" %#x %s" % (a, i))
|
||||
n += 1
|
||||
P()
|
||||
P("R15 setup search 0x1801b3640..0x1801b3894:")
|
||||
ins = listing.getInstructions(addr(0x1801B3640), True)
|
||||
while ins.hasNext():
|
||||
i = ins.next()
|
||||
a = int(i.getAddress().getOffset())
|
||||
if a > 0x1801B3894:
|
||||
break
|
||||
s = i.toString()
|
||||
if "R15" in s:
|
||||
P(" %#x %s" % (a, s))
|
||||
P()
|
||||
d = dec(0x1801B3640, timeout=600)
|
||||
P("FUN_1801b3640 len =", len(d))
|
||||
P(d)
|
||||
|
||||
P()
|
||||
P("=" * 25, "C. FUN_18003e550 listing panel + FUN_1800eb850 discard push", "=" * 25)
|
||||
for a in (0x18003E550, 0x1800EB850):
|
||||
d = dec(a, timeout=600)
|
||||
P("### %#x len=%d" % (a, len(d)))
|
||||
P(d)
|
||||
P()
|
||||
|
||||
P("=" * 25, "D. pile mapper xrefs (different method for the 0x226 census)", "=" * 25)
|
||||
for frm, t, cf, e in xrefs_to(0x180142650):
|
||||
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||
|
||||
P()
|
||||
P("=" * 25, "E. itemState string literals: every xref", "=" * 25)
|
||||
names = ["invalid", "free", "WAITING_FOR_GAME", "inGame", "forSale", "offered",
|
||||
"activeBadge", "activeHomeKit", "activeAwayKit", "activeBall",
|
||||
"activeStadium", "active",
|
||||
"player", "staff"] # last two = CONTROL, known to be used elsewhere
|
||||
for nm in names:
|
||||
hits = find_all(nm.encode() + b"\x00", blocks=(".rdata", ".data"))
|
||||
P("### %-18s literal hits: %s" % (nm, [hex(h) for h in hits]))
|
||||
for h in hits:
|
||||
for frm, t, cf, e in xrefs_to(h):
|
||||
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||
|
||||
P()
|
||||
P("=" * 25, "F. FUN_180008190 indirect compare + FUN_180130d10 + FUN_1801c3480", "=" * 25)
|
||||
for a in (0x180008190, 0x180130D10, 0x1801C3480):
|
||||
d = dec(a, timeout=600)
|
||||
P("### %#x len=%d" % (a, len(d)))
|
||||
P(d)
|
||||
P()
|
||||
|
||||
f.close()
|
||||
print("WROTE", OUT)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""ADVERSARIAL BATCH 5 -- consequences of the one serve-changing action, and the
|
||||
service gate the other agent left open.
|
||||
|
||||
1. untradeable:false flips item+0x49 to 1 on EVERY card. Besides TO_TRADE_PILE that
|
||||
byte feeds FUN_1800bc580, which counts untradeable members of the 11-slot active
|
||||
squad. Who consumes that count, and does flipping it change anything else?
|
||||
2. FUN_1801a7260's other gate: slot +0x270 of the service FUN_180009c80 resolves.
|
||||
Identify the service vtable and that slot if possible.
|
||||
"""
|
||||
import traceback, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q5_raw.txt"
|
||||
try:
|
||||
f = open(OUT, "w")
|
||||
|
||||
def P(*a):
|
||||
f.write(" ".join(str(x) for x in a) + "\n")
|
||||
|
||||
P("=" * 25, "1. consumers of the untradeable-squad count", "=" * 25)
|
||||
for a in (0x1800BB2A0, 0x1800BBA10):
|
||||
d = dec(a, timeout=600)
|
||||
P("### %#x len=%d" % (a, len(d)))
|
||||
P(d)
|
||||
P()
|
||||
|
||||
P("=" * 25, "2. the service locator used by FUN_1801a7260", "=" * 25)
|
||||
for nm, a in (("FUN_1800d7170", 0x1800D7170), ("FUN_180009c80", 0x180009C80),
|
||||
("FUN_180018bd0", 0x180018BD0), ("FUN_180009b60", 0x180009B60)):
|
||||
P("### " + nm)
|
||||
P(dec(a))
|
||||
P()
|
||||
|
||||
P("=" * 25, "3. any vtable with >= 0x280 bytes containing plausible slot 0x270", "=" * 25)
|
||||
# find .rdata runs of >= 0x50 consecutive .text pointers; report those long enough
|
||||
for b in mem.getBlocks():
|
||||
if b.getName() != ".rdata" or not b.isInitialized():
|
||||
continue
|
||||
s = int(b.getStart().getOffset())
|
||||
e = int(b.getEnd().getOffset())
|
||||
a = (s + 7) & ~7
|
||||
run_start = None
|
||||
while a + 8 <= e:
|
||||
try:
|
||||
v = qword(a)
|
||||
except Exception:
|
||||
break
|
||||
ok = 0x180001000 <= v < 0x1801E5000
|
||||
if ok and run_start is None:
|
||||
run_start = a
|
||||
elif not ok and run_start is not None:
|
||||
ln = a - run_start
|
||||
if ln >= 0x280:
|
||||
P(" vtable-ish run %#x..%#x len %#x slot+0x270 -> %#x %s" %
|
||||
(run_start, a, ln, qword(run_start + 0x270),
|
||||
(lambda fn: fn.getName() if fn else "")(fm.getFunctionAt(addr(qword(run_start + 0x270))))))
|
||||
run_start = None
|
||||
a += 8
|
||||
|
||||
f.close()
|
||||
print("WROTE", OUT)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""ADVERSARIAL BATCH 6 -- pin the service behind GUID 0xed84b11/0xed84b12 whose
|
||||
vtable slot +0x270 is the OTHER gate on TO_TRADE_PILE. If that gate is an online /
|
||||
transfer-market-availability check it may block the menu even with untradeable:false,
|
||||
which is the single biggest risk to the headline recommendation.
|
||||
|
||||
METHOD: the class that implements an interface references the same GUID constant when
|
||||
it registers. Scan .text for the 4-byte immediates and report every function.
|
||||
CONTROL: the same scan for 0x10c80b95 (the CardInventory-ish service FUN_18003e370
|
||||
uses) and 0xed80ed8 -- if those come back with registrars and 0xed84b11 does not, the
|
||||
absence is about this GUID and not about the scan.
|
||||
"""
|
||||
import traceback, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q6_raw.txt"
|
||||
try:
|
||||
f = open(OUT, "w")
|
||||
|
||||
def P(*a):
|
||||
f.write(" ".join(str(x) for x in a) + "\n")
|
||||
|
||||
for g in (0xED84B11, 0xED84B12, 0x10C80B95, 0x10C80B96, 0xED80ED8):
|
||||
pat = struct.pack("<I", g)
|
||||
hits = find_all(pat, blocks=(".text", ".rdata", ".data"))
|
||||
fns = {}
|
||||
for h in hits:
|
||||
fn = fm.getFunctionContaining(addr(h))
|
||||
k = fn.getName() if fn else "(data)"
|
||||
fns.setdefault(k, []).append(h)
|
||||
P("### GUID %#x : %d byte hits in %d functions" % (g, len(hits), len(fns)))
|
||||
for k, v in sorted(fns.items()):
|
||||
P(" %-24s %s" % (k, [hex(x) for x in v]))
|
||||
P()
|
||||
|
||||
P("=" * 25, "the registrar bodies", "=" * 25)
|
||||
seen = set()
|
||||
for g in (0xED84B11, 0xED84B12):
|
||||
for h in find_all(struct.pack("<I", g), blocks=(".text",)):
|
||||
fn = fm.getFunctionContaining(addr(h))
|
||||
if fn is None:
|
||||
continue
|
||||
e = int(fn.getEntryPoint().getOffset())
|
||||
if e in seen:
|
||||
continue
|
||||
seen.add(e)
|
||||
P("### %s @%#x" % (fn.getName(), e))
|
||||
P(dec(e))
|
||||
P()
|
||||
|
||||
f.close()
|
||||
print("WROTE", OUT)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""ADVERSARIAL BATCH 7 -- finish the two open links.
|
||||
|
||||
(a) 0x10c80b96 appears as data at 0x1800e1662, inside the function at vtable slot
|
||||
+0xa8 of the table 0x180215a80 -- the same table whose slot +0xd0 is
|
||||
FUN_1800e2a40. If that holds it independently proves the FUN_18003e370 ->
|
||||
FUN_1800e2a40 link the other agent could only infer semantically.
|
||||
(b) 0xed84b12 appears as data at 0x180113f52. Whatever class that belongs to is the
|
||||
service FUN_1801a7260 calls slot +0x270 on. Find its vtable and read slot 0x270.
|
||||
"""
|
||||
import traceback, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q7_raw.txt"
|
||||
try:
|
||||
f = open(OUT, "w")
|
||||
|
||||
def P(*a):
|
||||
f.write(" ".join(str(x) for x in a) + "\n")
|
||||
|
||||
for a in (0x1800E1662, 0x180113F52):
|
||||
fn = fm.getFunctionContaining(addr(a))
|
||||
P("### data GUID at %#x -> containing function %s @%#x" %
|
||||
(a, fn.getName() if fn else None,
|
||||
int(fn.getEntryPoint().getOffset()) if fn else 0))
|
||||
if fn:
|
||||
e = int(fn.getEntryPoint().getOffset())
|
||||
P(dec(e))
|
||||
hits = find_all(struct.pack("<Q", e), blocks=(".rdata", ".data"))
|
||||
P(" 8-byte pointer to it: %s" % [hex(h) for h in hits])
|
||||
for h in hits:
|
||||
start = h
|
||||
while True:
|
||||
try:
|
||||
v = qword(start - 8)
|
||||
except Exception:
|
||||
break
|
||||
if not (0x180001000 <= v < 0x1801E5000):
|
||||
break
|
||||
start -= 8
|
||||
P(" run start %#x, this fn at slot +%#x" % (start, h - start))
|
||||
# find the first non-stub entry -- the secondary vtable base
|
||||
base = start
|
||||
while qword(base) == 0x1801C577A:
|
||||
base += 8
|
||||
P(" first non-stub entry at %#x (offset +%#x from run start)" % (base, base - start))
|
||||
P(" => slot of this fn relative to first non-stub: +%#x" % (h - base))
|
||||
for i in range(0, 90):
|
||||
v = qword(base + i * 8)
|
||||
if not (0x180001000 <= v < 0x1801E5000):
|
||||
break
|
||||
f2 = fm.getFunctionAt(addr(v))
|
||||
mark = ""
|
||||
if i * 8 == 0x270:
|
||||
mark = " <== SLOT 0x270"
|
||||
if i * 8 == 0x40:
|
||||
mark = " <== SLOT 0x40"
|
||||
P(" +%#05x %#x %s%s" % (i * 8, v, f2.getName() if f2 else "", mark))
|
||||
P()
|
||||
|
||||
f.close()
|
||||
print("WROTE", OUT)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""BATCH 8: the two GUID-returning stubs are undefined functions. Read them as raw
|
||||
instructions and find the vtable that holds them. CONTROL: both stubs must decode to
|
||||
'mov eax, <guid>; ret' -- if they do not, my reading of them as interface-id getters
|
||||
is wrong and I say so."""
|
||||
import traceback, struct
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q8_raw.txt"
|
||||
try:
|
||||
f = open(OUT, "w")
|
||||
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
|
||||
for lo, hi in ((0x1800E1650, 0x1800E1690), (0x180113F40, 0x180113F80)):
|
||||
P("### raw %#x..%#x" % (lo, hi))
|
||||
P(" bytes:", read_bytes(lo, hi - lo).hex())
|
||||
it = listing.getInstructions(addr(lo), True)
|
||||
while it.hasNext():
|
||||
i = it.next()
|
||||
a = int(i.getAddress().getOffset())
|
||||
if a >= hi: break
|
||||
P(" %#x %s" % (a, i))
|
||||
P()
|
||||
for cand in (0x1800E1660, 0x180113F50, 0x180113F4C, 0x180113F40):
|
||||
hits = find_all(struct.pack("<Q", cand), blocks=(".rdata", ".data"))
|
||||
P("ptr8 to %#x : %s" % (cand, [hex(h) for h in hits]))
|
||||
for h in hits:
|
||||
start = h
|
||||
while True:
|
||||
try: v = qword(start - 8)
|
||||
except Exception: break
|
||||
if not (0x180001000 <= v < 0x1801E5000): break
|
||||
start -= 8
|
||||
base = start
|
||||
while qword(base) == 0x1801C577A: base += 8
|
||||
P(" run %#x, first non-stub %#x, this at +%#x from non-stub" % (start, base, h - base))
|
||||
for i in range(0, 100):
|
||||
v = qword(base + i * 8)
|
||||
if not (0x180001000 <= v < 0x1801E5000): break
|
||||
fn = fm.getFunctionAt(addr(v))
|
||||
if i*8 in (0x40, 0x270, 0x308, 0x290, 0x2b0, 0x148, 0xd0, 0x20):
|
||||
P(" +%#05x %#x %s" % (i*8, v, fn.getName() if fn else ""))
|
||||
P(" table length: %#x" % (i*8))
|
||||
f.close(); print("WROTE", OUT)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""BATCH 9. The cast helper is vtable slot +0x18 (FUN_180009c80 calls
|
||||
(*(*svc))[0x18] with the interface GUID). So vtable_base = cast_stub_slot_addr - 0x18.
|
||||
- 0x10c80b96 class: stub ptr at 0x180215b28 -> base 0x180215b10 -> slot +0x40 must be
|
||||
FUN_1800e2a40 if the FUN_18003e370 link is real. (CONTROL for the arithmetic.)
|
||||
- 0xed84b12 class: stub ptr at 0x18021c2b8 -> base 0x18021c2a0 -> slot +0x270 is the
|
||||
other gate on TO_TRADE_PILE.
|
||||
"""
|
||||
import traceback
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q9_raw.txt"
|
||||
try:
|
||||
f = open(OUT, "w")
|
||||
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
|
||||
for nm, base, slots in (("iface 0x10c80b96 (CONTROL)", 0x180215B10, (0x18, 0x40)),
|
||||
("iface 0xed84b12", 0x18021C2A0, (0x18, 0x270, 0x290, 0x2b0, 0x308, 0x148))):
|
||||
P("### %s vtable base %#x" % (nm, base))
|
||||
for s in slots:
|
||||
v = qword(base + s)
|
||||
fn = fm.getFunctionAt(addr(v))
|
||||
P(" +%#05x -> %#x %s" % (s, v, fn.getName() if fn else ""))
|
||||
P()
|
||||
for a in (0x1801B1CE0,):
|
||||
pass
|
||||
v = qword(0x18021C2A0 + 0x270)
|
||||
P("=== slot 0x270 body ===")
|
||||
P(dec(v, timeout=300))
|
||||
P("=== xrefs to it ===")
|
||||
for frm, t, cf, e in xrefs_to(v):
|
||||
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||
f.close(); print("WROTE", OUT)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""ADVERSARIAL VERIFICATION BATCH 1 (dim4 + dim5).
|
||||
|
||||
HYPOTHESES UNDER ATTACK
|
||||
H1 (dim5 f5/f7): the publisher FUN_18006cc60 maps model vtable slots to IS_* names,
|
||||
and IS_TRADING_ENABLED (0x1801fc118) has exactly ONE rip-relative reference in
|
||||
.text (the lea), i.e. the name is output-only.
|
||||
CONTROL: run the same rip-relative scanner against a literal that IS known to be
|
||||
compared, e.g. one of the ISOfferTrade error strings 0x180228f20, which must show
|
||||
up in a *different* instruction context, and against IS_STORE_ENABLED.
|
||||
H2 (dim5 f5 positive control): IS_STORE_ENABLED's accessor (vt+0x280) - what does it
|
||||
actually compute? If it is a live-evaluable expression we can compare STORE vs
|
||||
TRADING under the same publish mechanism.
|
||||
H3 (dim4 f2): FutGetSuggestedPricing deser 0x180163ee0 top-level token is
|
||||
START_ARRAY (loop terminates on 0xd) - CONTROL FUN_180165df0 (ISStart) must
|
||||
terminate on 10.
|
||||
H4 (dim4 f4): 0x1801642c0 is `return 1;`.
|
||||
H5 (dim4 f6): tradeState table 0x180229e40 / bidState ladder FUN_180166380.
|
||||
H6 (dim4 f9): IS_MAX_AUCTIONS publisher FUN_1800377c0 + GetAuctionCount deser
|
||||
0x180163770.
|
||||
H7 (dim4 f8): error mapper FUN_1801844c0.
|
||||
Everything printed IN FULL with len(src).
|
||||
"""
|
||||
import traceback, struct
|
||||
|
||||
def full(tag, va):
|
||||
try:
|
||||
s = dec(va)
|
||||
print("\n----- %s %#x len=%d -----" % (tag, va, len(s)))
|
||||
print(s)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
try:
|
||||
print("### H1: publisher FUN_18006cc60")
|
||||
full("publisher", 0x18006cc60)
|
||||
|
||||
print("\n### model vtable slots")
|
||||
VT = 0x18021c2a0
|
||||
for off in (0x270, 0x280, 0x2b0, 0x988, 0x998, 0xa58, 0xa60, 0x130, 0x5b8, 0xa00):
|
||||
t = qword(VT + off)
|
||||
print(" vt+%#05x -> %#x %s" % (off, t, fname(t) if 'fname' in dir() else ''))
|
||||
full("vt+0x280 IS_STORE_ENABLED accessor", qword(VT + 0x280))
|
||||
full("vt+0x270 IS_TRADING_ENABLED accessor", qword(VT + 0x270))
|
||||
full("vt+0xa58 TRADE_PILE_SIZE accessor", qword(VT + 0xa58))
|
||||
|
||||
print("\n### H1 rip-relative reference scan, form independent")
|
||||
# Scan .text for any 4-byte little-endian rel32 whose target == literal VA,
|
||||
# for every instruction end position. This catches lea/mov/cmp/push equally.
|
||||
tblk = None
|
||||
for b in mem.getBlocks():
|
||||
if b.getName() == ".text":
|
||||
tblk = b
|
||||
TS = int(tblk.getStart().getOffset()); TE = int(tblk.getEnd().getOffset())
|
||||
text = read_bytes(TS, TE - TS + 1)
|
||||
print(" .text %#x..%#x len=%d" % (TS, TE, len(text)))
|
||||
|
||||
def ripscan(target, label):
|
||||
hits = []
|
||||
for i in range(0, len(text) - 4):
|
||||
rel = struct.unpack_from('<i', text, i)[0]
|
||||
# instruction end = TS + i + 4 (rel32 is the last field of the insn)
|
||||
if TS + i + 4 + rel == target:
|
||||
hits.append(TS + i)
|
||||
print(" %-34s target %#x : %d candidate rel32 sites" % (label, target, len(hits)))
|
||||
for h in hits[:20]:
|
||||
print(" at %#x bytes %s fn %s" % (h - 3, text[h - 6:h + 6].hex(),
|
||||
(fm.getFunctionContaining(addr(h)) or "?")))
|
||||
return hits
|
||||
|
||||
lits = {}
|
||||
for nm in (b"IS_TRADING_ENABLED\x00", b"IS_STORE_ENABLED\x00",
|
||||
b"IS_DRAFT_MODE_ENABLED\x00", b"TRADE_PILE_SIZE\x00",
|
||||
b"IS_MAX_AUCTIONS\x00", b"NUM_MAX_AUCTIONS\x00",
|
||||
b"You are not allowed to bid on this trade\x00"):
|
||||
f = find_all(nm, blocks=(".rdata", ".data", ".text"))
|
||||
lits[nm] = f
|
||||
print(" literal %-45r -> %s" % (nm[:40], [hex(x) for x in f]))
|
||||
for nm, f in lits.items():
|
||||
for a in f:
|
||||
ripscan(a, nm[:30].decode(errors='replace'))
|
||||
|
||||
print("\n### H3 pricelimits vs ISStart control")
|
||||
full("FutGetSuggestedPricing deser", 0x180163ee0)
|
||||
full("FutISStart deser CONTROL", 0x180165df0)
|
||||
|
||||
print("\n### H4 generic ack deser")
|
||||
full("ack deser", 0x1801642c0)
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""ADVERSARIAL VERIFICATION BATCH 2.
|
||||
Everything printed IN FULL with len(src). No truncation, no absence claimed from
|
||||
a partial print.
|
||||
H8 dim4 f5: auctionInfo record deser 0x18013e410 has exactly 12 atoms + tradeId
|
||||
identity lookup via model vt+0xa00.
|
||||
H9 dim4 f7: shared IS-list body 0x18013e7f0, credits -> model vt+0x5b8.
|
||||
H10 dim4 f6: tradeState table walk FUN_180166bd0 (table 0x180229e40) and bidState
|
||||
ladder FUN_180166380 -- two DIFFERENT dispatch forms, read separately.
|
||||
H11 dim4 f8: FUN_1801844c0 status map, FUN_180165050 461 override.
|
||||
H12 dim4 f9: FUN_1800377c0 IS_MAX_AUCTIONS + FUN_180163770 GetAuctionCount deser.
|
||||
CONTROL for the publisher form: FUN_18000d550 TRADE_PILE_SIZE.
|
||||
H13 dim4 f11: deser VAs for FutISWatchList / FutGetAuctionCount / FutISStart via
|
||||
RS4 name -> abs64 ptr -> installed vtable -> slot +0x08, with FutISSearch and
|
||||
FutGetTradePile as the CONTROL pair (must come back 0x180163420 / 0x180170810).
|
||||
"""
|
||||
import traceback, struct
|
||||
|
||||
def full(tag, va):
|
||||
try:
|
||||
s = dec(va)
|
||||
print("\n----- %s %#x len=%d -----" % (tag, va, len(s)))
|
||||
print(s)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
try:
|
||||
for tag, va in [("auctionInfo record deser", 0x18013e410),
|
||||
("shared IS-list body", 0x18013e7f0),
|
||||
("tradeState decoder", 0x180166bd0),
|
||||
("bidState decoder", 0x180166380),
|
||||
("status mapper", 0x1801844c0),
|
||||
("ISOfferTrade 461 override", 0x180165050),
|
||||
("IS_MAX_AUCTIONS publisher", 0x1800377c0),
|
||||
("TRADE_PILE_SIZE publisher CONTROL", 0x18000d550),
|
||||
("GetAuctionCount deser", 0x180163770),
|
||||
("ISWatchList deser", 0x180166240),
|
||||
("ISSearch deser CONTROL", 0x180163420),
|
||||
("GetTradePile deser CONTROL", 0x180170810)]:
|
||||
full(tag, va)
|
||||
|
||||
print("\n### tradeState table at 0x180229e40")
|
||||
a = 0x180229e40
|
||||
for i in range(10):
|
||||
p = qword(a + i * 16); v = dword(a + i * 16 + 8)
|
||||
if p == 0:
|
||||
print(" [%d] NULL terminator, value=%d" % (i, v)); break
|
||||
print(" [%d] %#x %r = %d" % (i, p, rd_str(p), v if v < 0x80000000 else v - (1 << 32)))
|
||||
|
||||
print("\n### H13 RS4 name -> installed vtable -> slot+0x08")
|
||||
for nm, expect in [(b"RS4:FutISSearchServerResponse\x00", 0x180163420),
|
||||
(b"RS4:FutGetTradePileServerResponse\x00", 0x180170810),
|
||||
(b"RS4:FutISWatchListServerResponse\x00", None),
|
||||
(b"RS4:FutGetAuctionCountServerResponse\x00", None),
|
||||
(b"RS4:FutISStartServerResponse\x00", None),
|
||||
(b"RS4:FutGetSuggestedPricingServerResponse\x00", None),
|
||||
(b"RS4:FutRelistAllServerResponse\x00", None),
|
||||
(b"RS4:FutISWatchTradeServerResponse\x00", None),
|
||||
(b"RS4:FutISRemoveTradeServerResponse\x00", None),
|
||||
(b"RS4:FutISRemoveWatchServerResponse\x00", None),
|
||||
(b"RS4:FutISViewTradeServerResponse\x00", None),
|
||||
(b"RS4:FutISOfferTradeServerResponse\x00", None)]:
|
||||
locs = find_all(nm, blocks=(".rdata", ".data"))
|
||||
print("\n %s -> %s" % (nm.decode().rstrip("\x00"), [hex(x) for x in locs]))
|
||||
for L in locs:
|
||||
xs = xrefs_to(L)
|
||||
print(" xrefs: %s" % [(hex(a), t, f) for a, t, f, _ in xs])
|
||||
for a, t, f, ent in xs:
|
||||
if ent:
|
||||
s = dec(ent)
|
||||
# find the vtable it installs: look for PTR_ / &DAT_ assignment
|
||||
import re
|
||||
m = re.findall(r"(?:PTR_[A-Za-z_0-9]*_|DAT_|&)([0-9a-fA-F]{9})", s)
|
||||
print(" fn %s @%#x len=%d installs %s" % (f, ent, len(s), set(m)))
|
||||
for cand in set(m):
|
||||
try:
|
||||
vt = int(cand, 16)
|
||||
if 0x180200000 <= vt < 0x180290000:
|
||||
slot = qword(vt + 8)
|
||||
print(" vtable %#x slot+0x08 = %#x (expect %s)"
|
||||
% (vt, slot, hex(expect) if expect else "?"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""ADVERSARIAL BATCH 3 -- the relaunch-critical path.
|
||||
H14: does the settings deser FUN_18013c6d0 pre-initialise its struct fields
|
||||
+0x28..+0x40 to 1 before parsing? If it zero-inits them, then the observed
|
||||
live pattern (model+0x1fd2e=0 surrounded by 1s) cannot have come from the
|
||||
applier, i.e. the applier NEVER RAN -- which decides "never set" vs
|
||||
"set then cleared".
|
||||
Also: which atom writes struct+0x1c (the field FUN_180173e00 gates on)?
|
||||
H15: FUN_180173e00 in full -- the test rdx / cmp [rdx+0x1c],0 gate.
|
||||
H16: dim5 f8 -- FUN_180180770 blaze client-config reader, full key list.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
def full(tag, va):
|
||||
try:
|
||||
s = dec(va)
|
||||
print("\n===== %s %#x len=%d =====" % (tag, va, len(s)))
|
||||
print(s)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
try:
|
||||
full("settings deser FUN_18013c6d0", 0x18013c6d0)
|
||||
full("settings completion FUN_180173e00", 0x180173e00)
|
||||
full("blaze config reader FUN_180180770", 0x180180770)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""ADVERSARIAL BATCH 4 -- the settings RESPONSE object, not the model-side deser.
|
||||
FUN_180173e00 reads its param_2 (the FutGetSettings response) at +0x1c (error gate),
|
||||
copies +0x28..+0xc0 and hands &<copy of +0x28> to the gate applier vt+0x988, and
|
||||
copies +0xc8..+0xd4 and hands &<copy of +0xc8> to vt+0x998.
|
||||
So model+0x1fd2e <- response+0x50, and model+0x1fd1c <- response+0xd0.
|
||||
HYPOTHESIS: the FutGetSettings response deserializer writes response+0x50 and +0xd0
|
||||
from specific atoms. Find them.
|
||||
CONTROL: the same RS4-name -> vtable -> slot+0x08 resolution that reproduced
|
||||
FutISSearch 0x180163420 and FutGetTradePile 0x180170810 in batch 2.
|
||||
"""
|
||||
import traceback, re
|
||||
|
||||
try:
|
||||
for nm in (b"RS4:FutGetSettingsServerResponse\x00", b"RS4:FutSettingsServerResponse\x00",
|
||||
b"RS4:FutISSearchServerResponse\x00"):
|
||||
locs = find_all(nm, blocks=(".rdata", ".data"))
|
||||
print("\n### %s -> %s" % (nm.decode().rstrip("\x00"), [hex(x) for x in locs]))
|
||||
for L in locs:
|
||||
for a, t, f, ent in xrefs_to(L):
|
||||
if not ent: continue
|
||||
s = dec(ent)
|
||||
m = set(re.findall(r"(?:PTR_[A-Za-z_0-9]*_|DAT_|&)([0-9a-fA-F]{9})", s))
|
||||
print(" fn %s @%#x installs %s" % (f, ent, m))
|
||||
for c in m:
|
||||
v = int(c, 16)
|
||||
if 0x180200000 <= v < 0x180290000:
|
||||
print(" vtable %#x slot+0x08 = %#x" % (v, qword(v + 8)))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,10 @@
|
||||
"""BATCH 5: which atom writes FutGetSettings response+0x50 (-> IS_TRADING_ENABLED)
|
||||
and +0xd0 (-> TRADE_PILE_SIZE)? Two candidate desers resolved in batch 4."""
|
||||
import traceback, re
|
||||
try:
|
||||
for va in (0x18014e590, 0x180153060):
|
||||
s = dec(va)
|
||||
print("\n===== deser %#x len=%d =====" % (va, len(s)))
|
||||
print(s)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""ADVERSARIAL BATCH 1.
|
||||
|
||||
HYPOTHESES UNDER TEST (all from another agent, assumed WRONG until reproduced):
|
||||
H1 FUN_1800d8330 maps cardsubtypeid -> cardtype and returns 9 for exactly
|
||||
{0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec}; and returns 7 for 9,10,11.
|
||||
H2 FUN_180119bd0 arms: 9 -> KITS, 10 -> Stadium, 0xb -> Badge, else "".
|
||||
H3 FUN_1801a8640 == *(u32*)(*(u64*)(param_1+0x18)+0x50) i.e. cardsubtypeid.
|
||||
H4 FUN_1800f6c40 calls vtable+0x498 only when item+0x4c == 7, args
|
||||
(item+0x50, item+0x94, item+0x20); and sets IS_KIT_%d when item+0x50==9.
|
||||
H5 FUN_180141660 tail writes item+0x54 = level(rating@+0xb4): 3 if >=0x4b,
|
||||
else 2 - (rating < 0x41). <-- CONTRADICTS the live-map "+0x54 = itemType".
|
||||
|
||||
CONTROL: FUN_1800d8330 must decompile non-empty and its case labels must be
|
||||
recoverable; it is a jump table, which is the form that DEFEATED an earlier scan.
|
||||
Every decompile is written to disk IN FULL with its length printed, so no claim
|
||||
here can rest on a truncated body.
|
||||
|
||||
Output: /tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
|
||||
|
||||
try:
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
TARGETS = {
|
||||
"FUN_1800d8330": 0x1800d8330,
|
||||
"FUN_180119bd0": 0x180119bd0,
|
||||
"FUN_1801a8640": 0x1801a8640,
|
||||
"FUN_1800f6c40": 0x1800f6c40,
|
||||
"FUN_180141660": 0x180141660,
|
||||
"FUN_1801a8570": 0x1801a8570,
|
||||
"FUN_1801a8560": 0x1801a8560,
|
||||
"FUN_1801a8800": 0x1801a8800,
|
||||
"FUN_1801a8040": 0x1801a8040,
|
||||
"FUN_180136480": 0x180136480,
|
||||
}
|
||||
for name, a in TARGETS.items():
|
||||
src = dec(a)
|
||||
p = os.path.join(OUT, name + ".c")
|
||||
open(p, "w").write(src)
|
||||
print("WROTE %-16s len=%6d -> %s" % (name, len(src), p))
|
||||
|
||||
print()
|
||||
print("=== small functions printed IN FULL ===")
|
||||
for name in ("FUN_1800d8330", "FUN_1801a8640", "FUN_1801a8570", "FUN_1801a8560",
|
||||
"FUN_1801a8800", "FUN_1801a8040", "FUN_180119bd0"):
|
||||
src = open(os.path.join(OUT, name + ".c")).read()
|
||||
print("\n----------8<---------- %s (len=%d) ----------" % (name, len(src)))
|
||||
print(src)
|
||||
|
||||
print()
|
||||
print("=== CONTROL: case labels of FUN_1800d8330 via the listing ===")
|
||||
f = func(0x1800d8330)
|
||||
print("entry 0x%x body %s" % (int(f.getEntryPoint().getOffset()), f.getBody()))
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
n = 0
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
n += 1
|
||||
print("instruction count: %d" % n)
|
||||
# enumerate caseD_ labels inside the body
|
||||
st = prog.getSymbolTable()
|
||||
labs = []
|
||||
rng = f.getBody()
|
||||
for sym in st.getAllSymbols(True):
|
||||
a2 = sym.getAddress()
|
||||
if a2 is not None and rng.contains(a2) and str(sym.getName()).startswith("caseD_"):
|
||||
labs.append((str(sym.getName()), int(a2.getOffset())))
|
||||
print("caseD_ labels in FUN_1800d8330: %d -> %s" % (len(labs), sorted(set(l[0] for l in labs))))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""ADVERSARIAL BATCH 2.
|
||||
|
||||
MAIN ATTACK: the claim "cardtype 9 has NO resolver at all, so ball and leaguelogo
|
||||
display strings must come off the wire (localizedName + description)". That claim
|
||||
CHANGES WHAT WE SERVE, so it is priority 1.
|
||||
|
||||
Counter-evidence to chase: .rdata at 0x1802041d0 holds 'fcc_leaguelogos' and
|
||||
0x1802041e0 holds 'LeagueName_Abbr_15_%d', sitting immediately beside 'FUT_UC_KITS'
|
||||
(0x180204180) which IS a resolver literal. If some function formats
|
||||
LeagueName_Abbr_15_%d for a league logo, the "must come off the wire" claim is wrong.
|
||||
|
||||
H6 vtable+0x490 = FUN_18011a860 is a GENERIC name resolver taking
|
||||
(cardtype@+0x4c, cardsubtypeid@+0x50, resourceId@+0x18). Does it have a
|
||||
cardtype-9 arm?
|
||||
H7 'fcc_leaguelogos' / 'LeagueName_Abbr_15_%d' are referenced by some function.
|
||||
H8 FUN_18012ee20 has EXACTLY ONE caller (the club URL builder). [absence claim]
|
||||
H9 FUN_1800fed90 is the ONLY function whose switch case set is exactly
|
||||
{0x91..0x96}. [absence claim -- re-tested here by a DIFFERENT method than
|
||||
the original caseD_ symbol enumeration: I enumerate switch tables from the
|
||||
instruction/flow side via getBasicBlocks + scalar operands, AND repeat the
|
||||
symbol method, and compare the two.]
|
||||
H10 FUN_180141660 (the merge) is called on every deserialized item.
|
||||
|
||||
CONTROL for the xref questions: 'FUT_UC_KITS' at 0x180204180 MUST come back with
|
||||
>=1 referencing function (we already know FUN_180119bd0 uses it). If the xref
|
||||
method returns 0 for FUT_UC_KITS the method is broken and every negative is void.
|
||||
Same syntactic form (a .rdata string address referenced by a LEA) as the targets.
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
|
||||
|
||||
try:
|
||||
print("=== CONTROL + targets: xrefs to .rdata string addresses ===")
|
||||
STRS = {
|
||||
"FUT_UC_KITS (CONTROL)": 0x180204180,
|
||||
"FUT_UC_BALL": 0x180239120,
|
||||
"fcc_leaguelogos": 0x1802041d0,
|
||||
"LeagueName_Abbr_15_%d": 0x1802041e0,
|
||||
"leagues": 0x1802041b0,
|
||||
"Badge (0x1802041b8)": 0x1802041b8,
|
||||
"countryid": 0x1802041c0,
|
||||
"fcc_myclubs": 0x180204190,
|
||||
"TeamName_Abbr15_%d?": None,
|
||||
}
|
||||
for name, a in STRS.items():
|
||||
if a is None:
|
||||
continue
|
||||
try:
|
||||
xs = xrefs_to(a)
|
||||
except Exception as e:
|
||||
print(" %-24s XREF ERROR %s" % (name, e)); continue
|
||||
fns = sorted(set((x[2], x[3]) for x in xs))
|
||||
print(" %-24s 0x%x %d refs, funcs: %s" %
|
||||
(name, a, len(xs), ["%s@0x%x" % (n, e) for n, e in fns]))
|
||||
|
||||
print()
|
||||
print("=== find TeamName_Abbr15_%d and StadiumName_%d addresses then xref ===")
|
||||
for lit in (b"TeamName_Abbr15_%d\x00", b"StadiumName_%d\x00", b"LeagueName_Abbr_15_%d\x00",
|
||||
b"fcc_leaguelogos\x00", b"fcc_balls\x00", b"fcc_stadium\x00",
|
||||
b"fcc_badgecards\x00", b"fcc_kitcards\x00", b"fcc_misccards\x00"):
|
||||
hits = find_all(lit, blocks=(".rdata", ".data", ".text"))
|
||||
print(" %-26s %d hit(s) at %s" % (lit.rstrip(b"\x00").decode(), len(hits),
|
||||
[hex(h) for h in hits]))
|
||||
for h in hits:
|
||||
xs = xrefs_to(h)
|
||||
fns = sorted(set((x[2], x[3]) for x in xs))
|
||||
print(" -> %d refs: %s" % (len(xs), ["%s@0x%x" % (n, e) for n, e in fns]))
|
||||
|
||||
print()
|
||||
print("=== H6: generic resolver FUN_18011a860 (vtable +0x490) FULL ===")
|
||||
src = dec(0x18011a860)
|
||||
open(os.path.join(OUT, "FUN_18011a860.c"), "w").write(src)
|
||||
print("len=%d" % len(src))
|
||||
print(src)
|
||||
|
||||
print()
|
||||
print("=== H8: callers of FUN_18012ee20 (itemState code -> atom) ===")
|
||||
for fa in (0x18012ee20, 0x180141660, 0x180166660, 0x1800fed90):
|
||||
try:
|
||||
cs = callers(fa)
|
||||
except Exception:
|
||||
cs = [(x[0], x[2], x[3]) for x in xrefs_to(fa)]
|
||||
print(" FUN_%x callers: %s" % (fa, cs))
|
||||
|
||||
print()
|
||||
print("=== H9: switch case-set enumeration, TWO methods ===")
|
||||
st = prog.getSymbolTable()
|
||||
# method 1: caseD_ symbols grouped by containing function
|
||||
import collections
|
||||
bysym = collections.defaultdict(set)
|
||||
n = 0
|
||||
for sym in st.getAllSymbols(True):
|
||||
nm = str(sym.getName())
|
||||
if not nm.startswith("caseD_"):
|
||||
continue
|
||||
n += 1
|
||||
a2 = sym.getAddress()
|
||||
f = fm.getFunctionContaining(a2)
|
||||
if f is None:
|
||||
continue
|
||||
try:
|
||||
v = int(nm.split("_")[-1], 16)
|
||||
except ValueError:
|
||||
continue
|
||||
bysym[int(f.getEntryPoint().getOffset())].add(v)
|
||||
print(" method1: %d caseD_ symbols over %d functions" % (n, len(bysym)))
|
||||
TARGET = set(range(0x91, 0x97))
|
||||
exact = [hex(k) for k, v in bysym.items() if v == TARGET]
|
||||
superset = [hex(k) for k, v in bysym.items() if TARGET <= v and v != TARGET]
|
||||
overlap = [hex(k) for k, v in bysym.items() if (TARGET & v) and not (TARGET <= v)]
|
||||
print(" functions with case set EXACTLY {0x91..0x96}: %s" % exact)
|
||||
print(" functions whose case set is a SUPERSET: %s" % superset)
|
||||
print(" functions with PARTIAL overlap: %s" % overlap)
|
||||
print(" CONTROL FUN_1800d8330 present in method1? %s -> %s" %
|
||||
(0x1800d8330 in bysym, sorted(hex(x) for x in bysym.get(0x1800d8330, []))))
|
||||
|
||||
print()
|
||||
print("=== H10: callers of the merge FUN_180141660 ===")
|
||||
xs = xrefs_to(0x180141660)
|
||||
print(" %d refs: %s" % (len(xs), sorted(set("%s@0x%x" % (x[2], x[3]) for x in xs))))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""ADVERSARIAL BATCH 3.
|
||||
|
||||
PRIORITY-1 ATTACK: FUN_180098f20 is the ONLY referencer of both 'fcc_leaguelogos'
|
||||
and 'LeagueName_Abbr_15_%d'. If it resolves a league-logo display name from the DB,
|
||||
then the claim "cardtype 9 has no resolver at all, so ball and leaguelogo need
|
||||
localizedName + description off the wire" is WRONG, and that claim changes what we
|
||||
serve.
|
||||
|
||||
ALSO:
|
||||
H11 FUN_180108c00 deserializes atom 0x32f (tournamentType) and computes
|
||||
subtype = value + 0x91. (the trophy claim)
|
||||
H12 FUN_1801bfac0 arm iVar5 == 0x1e -> FUT_UC_BALL, and the 0x1f arm.
|
||||
H13 DAT_18022315c is the string "rare" (supports low-dword-of-uStack_130 = rareflag)
|
||||
H14 the deser's stack struct -> record copy: which stack slot becomes record+0x58.
|
||||
|
||||
CONTROL for the "who calls X" questions: FUN_180119bd0 must come back with >=1
|
||||
caller (we already proved FUN_1800f6c40 calls it through vtable slot +0x498 --
|
||||
though that is an INDIRECT call, so a direct-xref method may legitimately return 0;
|
||||
that is exactly why the control matters and why a 0 here is NOT an absence).
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
|
||||
|
||||
try:
|
||||
print("=== H13: strings at the DAT_ addresses used as DB column names ===")
|
||||
for a in (0x18022315c, 0x1801eeeb0, 0x1802ef590, 0x18021ce7c, 0x18021ce7f, 0x1801e9caf):
|
||||
try:
|
||||
print(" 0x%x -> %r" % (a, rd_str(a, 40)))
|
||||
except Exception as e:
|
||||
print(" 0x%x -> ERR %s" % (a, e))
|
||||
|
||||
print()
|
||||
print("=== PRIORITY 1: FUN_180098f20 FULL (the fcc_leaguelogos referencer) ===")
|
||||
src = dec(0x180098f20)
|
||||
open(os.path.join(OUT, "FUN_180098f20.c"), "w").write(src)
|
||||
print("len=%d" % len(src))
|
||||
print(src)
|
||||
|
||||
print()
|
||||
print("=== who calls FUN_180098f20 ? ===")
|
||||
for fa, label in ((0x180098f20, "leaguelogo resolver"),
|
||||
(0x180119bd0, "CONTROL kit/stadium/badge resolver (indirect-only expected)"),
|
||||
(0x18011a860, "generic resolver +0x490"),
|
||||
(0x180094580, "third FUT_UC_KITS user"),
|
||||
(0x1800991a0, "fcc_myclubs user"),
|
||||
(0x180099490, "leagues/countryid/Badge user")):
|
||||
xs = xrefs_to(fa)
|
||||
print(" 0x%x %-52s %d refs: %s" %
|
||||
(fa, label, len(xs), sorted(set("%s@0x%x" % (x[2], x[3]) for x in xs))))
|
||||
|
||||
print()
|
||||
print("=== H11: FUN_180108c00 FULL (tournamentType -> subtype 0x91+) ===")
|
||||
src = dec(0x180108c00)
|
||||
open(os.path.join(OUT, "FUN_180108c00.c"), "w").write(src)
|
||||
print("len=%d" % len(src))
|
||||
print(src[:9000])
|
||||
if len(src) > 9000:
|
||||
print("... [remainder in FUN_180108c00.c]")
|
||||
|
||||
print()
|
||||
print("=== FUN_1800fed90 FULL (the 0x91..0x96 switch) ===")
|
||||
src = dec(0x1800fed90)
|
||||
open(os.path.join(OUT, "FUN_1800fed90.c"), "w").write(src)
|
||||
print("len=%d" % len(src))
|
||||
print(src)
|
||||
|
||||
print()
|
||||
print("=== re-decompile the item deser MYSELF (do not trust the other agent's copy) ===")
|
||||
src = dec(0x18013fe00, timeout=600)
|
||||
p = os.path.join(OUT, "FUN_18013fe00.c")
|
||||
open(p, "w").write(src)
|
||||
print("len=%d -> %s" % (len(src), p))
|
||||
# print only the lines that matter for H14
|
||||
for i, ln in enumerate(src.splitlines(), 1):
|
||||
if ("uStack_130" in ln or "local_100" in ln or "FUN_180141660" in ln
|
||||
or "local_13c" in ln or "local_138" in ln):
|
||||
print(" %4d: %s" % (i, ln))
|
||||
|
||||
print()
|
||||
print("=== also dump the card-detail builder for the 0x1e / 0x1f arms ===")
|
||||
src = dec(0x1801bfac0, timeout=600)
|
||||
open(os.path.join(OUT, "FUN_1801bfac0.c"), "w").write(src)
|
||||
print("len=%d" % len(src))
|
||||
for i, ln in enumerate(src.splitlines(), 1):
|
||||
if ("0x1e" in ln or "0x1f" in ln or "FUT_UC_BALL" in ln or "FUN_1801a8640" in ln
|
||||
or "Stadium" in ln or "Badge" in ln or "FUT_UC_KITS" in ln
|
||||
or "LeagueName" in ln or "fcc_" in ln):
|
||||
print(" %4d: %s" % (i, ln))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""ADVERSARIAL VERIFICATION BATCH 1.
|
||||
|
||||
HYPOTHESES UNDER ATTACK (from the D4 report):
|
||||
H-A record+0x54 is card LEVEL derived from rating by an unconditional ladder in
|
||||
the tail of FUN_180141660, NOT itemType.
|
||||
H-B FUN_1801a87f0 is a one-byte read of record+0xb4 and all four OVERALL_RATING
|
||||
publishers call it.
|
||||
H-C playStyle lands at record+0x88, FUN_180136480 accepts only 0xfb..0x111.
|
||||
H-D atom 0x173 itemType never becomes an int.
|
||||
|
||||
CONTROLS.
|
||||
* For every "no such thing" statement I enumerate case labels, `== 0x`, `!= 0x`
|
||||
AND sub/dec ladders, and I state which form the positive control used.
|
||||
* Positive control for the dispatch enumeration: atoms 0x274 (rating) and 0x287
|
||||
(resourceId), both known-present, must be found by the SAME enumerator.
|
||||
* Positive control for the literal-xref method: a literal whose xref count is
|
||||
independently known.
|
||||
Everything is written to files; nothing is truncated.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
|
||||
|
||||
try:
|
||||
import re
|
||||
|
||||
def dump(name, s):
|
||||
p = OUT + name
|
||||
open(p, "w").write(s)
|
||||
print("WROTE %s (%d chars)" % (p, len(s)))
|
||||
|
||||
targets = {
|
||||
"merge_141660": 0x180141660,
|
||||
"deser_13fe00": 0x18013FE00,
|
||||
"playersmerge_135890": 0x180135890,
|
||||
"acc_rating_1a87f0": 0x1801A87F0,
|
||||
"acc_cardlevel_1a80c0": 0x1801A80C0,
|
||||
"acc_playstyle_1a85c0": 0x1801A85C0,
|
||||
"acc_league_1a8550": 0x1801A8550,
|
||||
"acc_attr_1a8450": 0x1801A8450,
|
||||
"acc_dream_1a8830": 0x1801A8830,
|
||||
"acc_assetid_1a8010": 0x1801A8010,
|
||||
"acc_asset2_1a8020": 0x1801A8020,
|
||||
"mapper_playstyle_136480": 0x180136480,
|
||||
"family_d8330": 0x1800D8330,
|
||||
"resid_166ca0": 0x180166CA0,
|
||||
}
|
||||
blob = []
|
||||
src = {}
|
||||
for nm, a in targets.items():
|
||||
f = func(a)
|
||||
s = dec(a, 600)
|
||||
src[nm] = s
|
||||
blob.append("=" * 78)
|
||||
blob.append("### %s @ %#x ghidra_fn=%s entry=%#x len=%d" % (
|
||||
nm, a, f.getName() if f else "NONE",
|
||||
int(f.getEntryPoint().getOffset()) if f else 0, len(s)))
|
||||
blob.append(s)
|
||||
dump("v1_bodies.txt", "\n".join(blob))
|
||||
|
||||
# ---- dispatch-form enumeration over the item deser, ALL FOUR FORMS
|
||||
d = src["deser_13fe00"]
|
||||
print("\n--- deser FUN_18013fe00 len=%d ---" % len(d))
|
||||
cases = sorted(set(int(x, 16) for x in re.findall(r"case\s+0x([0-9a-fA-F]+)", d)))
|
||||
cases += sorted(set(int(x) for x in re.findall(r"case\s+(\d+)", d)))
|
||||
eq = sorted(set(int(x, 16) for x in re.findall(r"==\s*0x([0-9a-fA-F]+)", d)))
|
||||
ne = sorted(set(int(x, 16) for x in re.findall(r"!=\s*0x([0-9a-fA-F]+)", d)))
|
||||
lt = sorted(set(int(x, 16) for x in re.findall(r"<\s*0x([0-9a-fA-F]+)", d)))
|
||||
sub = sorted(set(int(x, 16) for x in re.findall(r"-\s*0x([0-9a-fA-F]+)", d)))
|
||||
print("case labels (%d): %s" % (len(cases), [hex(c) for c in cases]))
|
||||
print("== 0x (%d): %s" % (len(eq), [hex(c) for c in eq]))
|
||||
print("!= 0x (%d): %s" % (len(ne), [hex(c) for c in ne]))
|
||||
print("< 0x (%d): %s" % (len(lt), [hex(c) for c in lt]))
|
||||
print("- 0x ladders (%d): %s" % (len(sub), [hex(c) for c in sub]))
|
||||
for probe, label in [(0x274, "rating CONTROL"), (0x287, "resourceId CONTROL"),
|
||||
(0x173, "itemType"), (0x23F, "playStyle"),
|
||||
(0x172, "itemState"), (0x207, "owners"),
|
||||
(0x361, "untradeable"), (0x1B, "amount"),
|
||||
(0x226, "pile"), (0x6B, "cardassetid"), (0x23, "assetId"),
|
||||
(0x18A, "leagueId"), (0x1D1, "nation"), (0x6C, "cardsubtypeid")]:
|
||||
forms = []
|
||||
if probe in cases:
|
||||
forms.append("case")
|
||||
if probe in eq:
|
||||
forms.append("==")
|
||||
if probe in ne:
|
||||
forms.append("!=")
|
||||
print(" atom %#x %-18s dispatch forms: %s" % (probe, label, forms or "NONE FOUND"))
|
||||
|
||||
# ---- who writes offset 0x54 anywhere in the two functions?
|
||||
print("\n--- textual writes to +0x54 / 0x54 in merge and deser ---")
|
||||
for nm in ("merge_141660", "deser_13fe00", "playersmerge_135890"):
|
||||
for ln_no, ln in enumerate(src[nm].split("\n")):
|
||||
if "0x54" in ln or "0xb4" in ln:
|
||||
print(" %-20s %4d| %s" % (nm, ln_no, ln.strip()))
|
||||
|
||||
# ---- OVERALL_RATING literal: locate it MYSELF, then xref
|
||||
print("\n--- OVERALL_RATING literal census ---")
|
||||
hits = find_all(b"OVERALL_RATING\x00")
|
||||
print("occurrences of 'OVERALL_RATING\\0':", [hex(h) for h in hits])
|
||||
for h in hits:
|
||||
xs = xrefs_to(h)
|
||||
print(" %#x xrefs=%d" % (h, len(xs)))
|
||||
for frm, t, fn, ent in xs:
|
||||
print(" from %#x %s in %s @%#x" % (frm, t, fn, ent))
|
||||
# control: a literal with an obviously different xref profile
|
||||
for lit in (b"CARD_LEVEL\x00", b"PLAY_STYLE\x00", b"LEAGUE_ID\x00",
|
||||
b"ATTRIBUTE_VALUE\x00", b"IS_DREAM_PLAYER\x00", b"ASSET_ID\x00"):
|
||||
hs = find_all(lit)
|
||||
print("\n%s occurrences: %s" % (lit, [hex(x) for x in hs]))
|
||||
for h in hs:
|
||||
xs = xrefs_to(h)
|
||||
print(" %#x xrefs=%d -> %s" % (h, len(xs), sorted(set(x[2] for x in xs))))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""ADVERSARIAL VERIFICATION BATCH 2.
|
||||
|
||||
Q1 COMPLETENESS GAP the D4 report admitted: are there raw, non-accessor reads of
|
||||
record+0xb4 anywhere in the binary? 0xb4 cannot be encoded as a signed disp8,
|
||||
so EVERY [reg+0xb4] reference must carry the literal disp32 bytes b4 00 00 00.
|
||||
Scanning .text for those four bytes and decoding the containing instruction is
|
||||
therefore an EXHAUSTIVE search, not a sample. Same scan for 0x54 and 0x88.
|
||||
Positive control: the scan must find FUN_1801a87f0 (+0xb4), FUN_180141660's
|
||||
ladder (+0xb4 and +0x54) and FUN_1801a85c0 (+0x88).
|
||||
|
||||
Q2 FUN_18013f4d0 -- the family-6 handler the deser tail calls with (record,
|
||||
resourceId, AMOUNT). If it stores amount in the record, the standing
|
||||
"amount is dropped" verdict is wrong.
|
||||
|
||||
Q3 the +0xe0 mystery: FUN_1801a8540, FUN_1800e5940 (manager publisher),
|
||||
FUN_1800e6e20 (player publisher) in full.
|
||||
|
||||
Q4 FUN_180166660 itemState mapper, FUN_1800d7b50/b30/b10/af0 value readers.
|
||||
|
||||
Q5 who calls FUN_18013fe00 and FUN_180141660 (is the ladder really on every path).
|
||||
"""
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
|
||||
|
||||
try:
|
||||
def scan_disp(off):
|
||||
pat = bytes([off & 0xFF, (off >> 8) & 0xFF, (off >> 16) & 0xFF, (off >> 24) & 0xFF])
|
||||
hits = find_all(pat, blocks=(".text",))
|
||||
rows = []
|
||||
for h in hits:
|
||||
ins = listing.getInstructionContaining(addr(h))
|
||||
if ins is None:
|
||||
continue
|
||||
a = int(ins.getAddress().getOffset())
|
||||
txt = str(ins)
|
||||
if ("0xb4]" in txt or "0x54]" in txt or "0x88]" in txt or
|
||||
hex(off) in txt.lower()):
|
||||
f = fm.getFunctionContaining(ins.getAddress())
|
||||
rows.append((a, txt, f.getName() if f else "?"))
|
||||
return rows
|
||||
|
||||
for off, label in ((0xB4, "record+0xb4 rating"),
|
||||
(0x54, "record+0x54 disputed"),
|
||||
(0x88, "record+0x88 playStyle")):
|
||||
rows = scan_disp(off)
|
||||
print("\n==== EXHAUSTIVE disp32 scan for [reg+%#x] (%s): %d instructions"
|
||||
% (off, label, len(rows)))
|
||||
seen = {}
|
||||
for a, txt, fn in rows:
|
||||
seen.setdefault(fn, []).append((a, txt))
|
||||
for fn in sorted(seen):
|
||||
print(" %-24s" % fn, ["%#x %s" % (a, t) for a, t in seen[fn]])
|
||||
|
||||
bodies = []
|
||||
for nm, a in (("f_13f4d0_family6", 0x18013F4D0),
|
||||
("acc_1a8540", 0x1801A8540),
|
||||
("acc_1a86b0", 0x1801A86B0),
|
||||
("acc_1a8590_nation", 0x1801A8590),
|
||||
("acc_1a86a0_team", 0x1801A86A0),
|
||||
("pub_mgr_1800e5940", 0x1800E5940),
|
||||
("pub_player_1800e6e20", 0x1800E6E20),
|
||||
("itemstate_166660", 0x180166660),
|
||||
("rd_d7b50", 0x1800D7B50), ("rd_d7b30", 0x1800D7B30),
|
||||
("rd_d7b10", 0x1800D7B10), ("rd_d7af0", 0x1800D7AF0),
|
||||
("stamp_d84e0", 0x1800D84E0)):
|
||||
f = func(a)
|
||||
s = dec(a, 600)
|
||||
bodies.append("=" * 78)
|
||||
bodies.append("### %s @ %#x len=%d" % (nm, a, len(s)))
|
||||
bodies.append(s)
|
||||
open(OUT + "v2_bodies.txt", "w").write("\n".join(bodies))
|
||||
print("\nWROTE v2_bodies.txt")
|
||||
|
||||
print("\n==== callers ====")
|
||||
for nm, a in (("FUN_18013fe00 item deser", 0x18013FE00),
|
||||
("FUN_180141660 merge", 0x180141660),
|
||||
("FUN_180135890 players merge", 0x180135890),
|
||||
("FUN_1801a87f0 rating acc", 0x1801A87F0),
|
||||
("FUN_1801a80c0 cardlevel acc", 0x1801A80C0),
|
||||
("FUN_1801a85c0 playstyle acc", 0x1801A85C0),
|
||||
("FUN_1801a8550 league acc", 0x1801A8550),
|
||||
("FUN_1801a8540", 0x1801A8540)):
|
||||
xs = xrefs_to(a)
|
||||
cs = sorted(set("%s@%#x" % (x[2], x[3]) for x in xs if x[1].startswith("UNCONDITIONAL_CALL") or "CALL" in x[1]))
|
||||
print("%-30s xrefs=%d callers=%s" % (nm, len(xs), cs))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""ADVERSARIAL BATCH 3: exhaustive [reg+disp32] scan, tightened.
|
||||
|
||||
0xb4 / 0x54 / 0x88 / 0xe0 cannot be a signed disp8, so every [reg+off] reference
|
||||
must carry the disp32 bytes literally. The scan is therefore exhaustive over .text.
|
||||
Filter: keep only instructions whose printed operand ends in "+ 0x<off>]", drop LEA
|
||||
and the unwind-stub noise.
|
||||
Positive controls that MUST appear: FUN_1801a87f0 (+0xb4 read),
|
||||
FUN_180141660 (+0xb4 read and +0x54 write), FUN_1801a85c0 (+0x88 read),
|
||||
FUN_1801a80c0 (+0x54 read and write).
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
for off in (0xB4, 0x54, 0x88, 0xE0):
|
||||
pat = bytes([off, 0, 0, 0])
|
||||
hits = find_all(pat, blocks=(".text",))
|
||||
rows = []
|
||||
for h in hits:
|
||||
ins = listing.getInstructionContaining(addr(h))
|
||||
if ins is None:
|
||||
continue
|
||||
txt = str(ins)
|
||||
if ("+ %s]" % hex(off)) not in txt:
|
||||
continue
|
||||
mn = txt.split()[0]
|
||||
if mn in ("LEA", "NOP"):
|
||||
continue
|
||||
f = fm.getFunctionContaining(ins.getAddress())
|
||||
fn = f.getName() if f else "?"
|
||||
if fn.startswith("Unwind") or fn.startswith("_guard"):
|
||||
continue
|
||||
rows.append((int(ins.getAddress().getOffset()), txt, fn))
|
||||
rows = sorted(set(rows))
|
||||
print("\n==== [reg+%#x] exhaustive disp32 scan: %d non-LEA, non-unwind instructions"
|
||||
% (off, len(rows)))
|
||||
byf = {}
|
||||
for a, t, fn in rows:
|
||||
byf.setdefault(fn, []).append((a, t))
|
||||
for fn in sorted(byf):
|
||||
print(" %-26s %s" % (fn, "; ".join("%#x %s" % x for x in byf[fn])))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""ADVERSARIAL BATCH 4.
|
||||
|
||||
CARD SIDE
|
||||
A. FUN_1801aa7f0 and FUN_1800e6410 read [reg+0xb4] as a byte but sit OUTSIDE the
|
||||
accessor range [0x1801a7000,0x1801a9000) the D4 report swept. Do they read an
|
||||
item record? If so the "OVERALL_RATING has exactly four publishers, all through
|
||||
FUN_1801a87f0" completeness argument has a hole.
|
||||
B. FUN_1801356c0 -- the family-2 (manager) merge. Does it clobber +0xdd..+0xfb the
|
||||
way the players merge does? That decides whether leagueId at +0xe0 survives for
|
||||
managers.
|
||||
C. FUN_180134cb0 -- writes +0xfc..+0x101, which FUN_1801a86b0 reads as the
|
||||
per-attribute chemistry delta.
|
||||
D. disp8 scan for [reg+0x54]: 0x54 fits a signed disp8 so the disp32 trick does
|
||||
NOT apply; iterate EVERY instruction in .text instead. Positive control:
|
||||
FUN_180141660 and FUN_1801a80c0 must appear.
|
||||
|
||||
ROUTE SIDE
|
||||
E. FUN_18012ec50 club ?type= switch, FUN_18012f4f0 club/stats switch,
|
||||
FUN_1801308c0 consumables suffix, FUN_18012ddf0 query builder -- full, so the
|
||||
"exactly 30 / exactly 7 / no /stats/team" absences can be re-tested against
|
||||
case labels AND == AND != AND ladders.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
|
||||
|
||||
try:
|
||||
import re
|
||||
bodies = []
|
||||
src = {}
|
||||
for nm, a in (("rating_reader_1aa7f0", 0x1801AA7F0),
|
||||
("rating_reader_e6410", 0x1800E6410),
|
||||
("mgr_merge_1356c0", 0x1801356C0),
|
||||
("chem_134cb0", 0x180134CB0),
|
||||
("clubtype_12ec50", 0x18012EC50),
|
||||
("clubstats_12f4f0", 0x18012F4F0),
|
||||
("consum_1308c0", 0x1801308C0),
|
||||
("clubsearch_12ddf0", 0x18012DDF0)):
|
||||
s = dec(a, 600)
|
||||
src[nm] = s
|
||||
bodies.append("=" * 78)
|
||||
bodies.append("### %s @ %#x len=%d" % (nm, a, len(s)))
|
||||
bodies.append(s)
|
||||
open(OUT + "v4_bodies.txt", "w").write("\n".join(bodies))
|
||||
print("WROTE v4_bodies.txt")
|
||||
|
||||
for nm in ("clubtype_12ec50", "clubstats_12f4f0"):
|
||||
s = src[nm]
|
||||
cases = re.findall(r"case\s+(0x[0-9a-fA-F]+|\d+):", s)
|
||||
eq = re.findall(r"==\s*(0x[0-9a-fA-F]+|\d+)", s)
|
||||
ne = re.findall(r"!=\s*(0x[0-9a-fA-F]+|\d+)", s)
|
||||
sub = re.findall(r"-\s*(0x[0-9a-fA-F]+|\d+)U?\s*<", s)
|
||||
print("\n%s len=%d cases=%d %s\n ==%s !=%s ladders=%s"
|
||||
% (nm, len(s), len(cases), cases, eq, ne, sub))
|
||||
|
||||
# ---- D: exhaustive instruction walk for [reg+0x54]
|
||||
print("\n==== EVERY instruction in .text referencing [reg + 0x54] ====")
|
||||
blk = [b for b in mem.getBlocks() if b.getName() == ".text"][0]
|
||||
it = listing.getInstructions(blk.getStart(), True)
|
||||
n = 0
|
||||
found = []
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
if ins.getAddress().getOffset() > int(blk.getEnd().getOffset()):
|
||||
break
|
||||
n += 1
|
||||
t = str(ins)
|
||||
if "+ 0x54]" in t:
|
||||
f = fm.getFunctionContaining(ins.getAddress())
|
||||
found.append((int(ins.getAddress().getOffset()), t,
|
||||
f.getName() if f else "?"))
|
||||
print("instructions walked: %d ; hits: %d" % (n, len(found)))
|
||||
byf = {}
|
||||
for a, t, fn in found:
|
||||
byf.setdefault(fn, []).append("%#x %s" % (a, t))
|
||||
for fn in sorted(byf):
|
||||
print(" %-26s %s" % (fn, "; ".join(byf[fn])))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Q1 recon: the itemState enum table and the club?type= strings.
|
||||
|
||||
HYPOTHESIS: the itemState enum table at 0x180229d20 (stride 0x10, 10 entries) is
|
||||
referenced by (a) a string->enum mapper in the deserializer and (b) an equip path
|
||||
that WRITES activeBadge/activeHomeKit/... The equip path is the place most likely
|
||||
to switch on cardsubtypeid for cardtype 9.
|
||||
|
||||
CONTROL: the table dump itself. The doc states the ten names; if the dump does not
|
||||
reproduce WAITING_FOR_GAME, inGame, forSale, offered, activeBadge, activeHomeKit,
|
||||
activeAwayKit, activeBall, activeStadium, active in that order, my table read is
|
||||
wrong and every conclusion downstream is void.
|
||||
|
||||
Also: locate the literals for club?type= singular names (stadium/ball/equippables)
|
||||
and the family caption keys, with occurrence counts, so later queries can pick a
|
||||
unique anchor.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
print("=== A: itemState enum table 0x180229d20, stride 0x10, 14 entries ===")
|
||||
T = 0x180229D20
|
||||
for i in range(14):
|
||||
e = T + i * 0x10
|
||||
q0 = qword(e)
|
||||
q1 = qword(e + 8)
|
||||
s = ""
|
||||
if 0x180000000 <= q0 < 0x181000000:
|
||||
try:
|
||||
s = rd_str(q0, 64)
|
||||
except Exception:
|
||||
s = "?"
|
||||
print(" [%2d] %#x: q0=%#018x %-24r q1=%#x" % (i, e, q0, s, q1))
|
||||
|
||||
print()
|
||||
print("=== B: xrefs to the table start and to each row ===")
|
||||
for i in range(12):
|
||||
e = T + i * 0x10
|
||||
xs = xrefs_to(e)
|
||||
if xs:
|
||||
print(" row %d @%#x:" % (i, e))
|
||||
for frm, typ, fn, ent in xs:
|
||||
print(" from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
|
||||
|
||||
print()
|
||||
print("=== C: string literals of interest, all occurrences ===")
|
||||
pats = [
|
||||
b"activeBadge", b"activeHomeKit", b"activeAwayKit", b"activeBall",
|
||||
b"activeStadium", b"itemState", b"forSale", b"inGame",
|
||||
b"equippables", b"stadium", b"Stadium", b"ball", b"Ball",
|
||||
b"badge", b"Badge", b"kit", b"Kit", b"clubLogo", b"leagueLogo",
|
||||
b"CLUBLOGO", b"LEAGUELOGO", b"BADGE", b"STADIUM", b"BALL", b"KIT",
|
||||
]
|
||||
for p in pats:
|
||||
hits = find_all(p)
|
||||
print(" %-16r n=%d %s" % (p.decode(), len(hits),
|
||||
" ".join("%#x" % h for h in hits[:12])))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Q10: the fcc_ table vocabulary and the classifier's neighbourhood.
|
||||
|
||||
Q8/Q9 changed the picture: inside the item deserializer, cardtype 9 items whose
|
||||
cardsubtypeid is in [0x91,0x95) take a custom-image path, and a SEPARATE
|
||||
deserializer FUN_180108c00 computes subtype = wireValue + 0x91 and then picks the
|
||||
loc format by range:
|
||||
0x91 <= s < 0x95 -> "TOURNY_LOC_%d"
|
||||
0x95 <= s < 0x97 -> "SEASON_LOC_%d"
|
||||
so 0x91..0x96 look like TROPHIES, not badges/kits/stadia/balls. Also, cardtype 7
|
||||
(subtypes 9,10,11) has an arm that defaults a field to 0x23 = 35, and 35 is the
|
||||
kit cardassetid recorded in tools/fut_clubitems.py.
|
||||
|
||||
This query gathers the vocabulary needed to test that:
|
||||
A. every "fcc_" table name literal in the binary, with the function that queries
|
||||
it -- the merge's per-family table map;
|
||||
B. every literal starting "cardsubtype" / "cardtype" (column names);
|
||||
C. the small helpers around the classifier: FUN_1800d84e0 (called right after it
|
||||
in the deser), FUN_1800d7b30/b50/af0/b10, FUN_1800d7170.
|
||||
|
||||
CONTROL: "fcc_discardcoins" must appear in A, and its query site must be
|
||||
FUN_18013fe00 (line 784 of the Q8 decompile). If it does not, the literal scan is
|
||||
not seeing the same code the decompiler is.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
print("=== A: fcc_ table literals ===")
|
||||
seen = set()
|
||||
for h in find_all(b"fcc_"):
|
||||
s = rd_str(h, 64)
|
||||
if not s or s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
xs = xrefs_to(h)
|
||||
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
|
||||
print(" %#x %-28r <- %s" % (h, s, who or "-"))
|
||||
print(" total distinct: %d" % len(seen))
|
||||
|
||||
print()
|
||||
print("=== B: cardtype / cardsubtype column literals ===")
|
||||
for pat in (b"cardtype", b"cardsubtype", b"carddbid", b"cardassetid"):
|
||||
for h in find_all(pat):
|
||||
s = rd_str(h, 64)
|
||||
xs = xrefs_to(h)
|
||||
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
|
||||
print(" %#x %-28r <- %s" % (h, s, who or "-"))
|
||||
|
||||
print()
|
||||
print("=== C: helpers ===")
|
||||
for a in (0x1800D84E0, 0x1800D7B30, 0x1800D7B50, 0x1800D7AF0, 0x1800D7B10):
|
||||
src = dec(a)
|
||||
print("-" * 70)
|
||||
print("FUN_%x len=%d" % (a, len(src)))
|
||||
print(src if len(src) < 2500 else src[:2500] + "\n...[TRUNCATED, len above]")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Q11: dump the candidate functions to files for local analysis.
|
||||
|
||||
Rationale: the interesting functions are 3k-27k chars each and printing them all to
|
||||
the transcript is wasteful. Write each decompile to
|
||||
/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec/FUN_<addr>.c and print only name+length here.
|
||||
|
||||
Set chosen from Q10:
|
||||
FUN_180098f20 queries fcc_leaguelogos AND uses carddbid + cardassetid
|
||||
FUN_180098560 / FUN_1800989f0 / FUN_180042440 / FUN_180043350 fcc_myclubscategories
|
||||
FUN_1800991a0 fcc_myclubs
|
||||
FUN_180141660 the merge (carddbid)
|
||||
FUN_18011a860 / FUN_1801356c0 / FUN_1801362e0 other carddbid users
|
||||
FUN_18013fe00 the shared item deserializer (full, for local grep)
|
||||
FUN_18011e9d0 the <0x95 callback from Q9
|
||||
FUN_18013af30 the remaining scan hit
|
||||
|
||||
CONTROL: FUN_18013fe00 must come out at 26234 chars, the length Q8 measured. A
|
||||
different length means a different function or a different decompiler setting.
|
||||
"""
|
||||
import os
|
||||
import traceback
|
||||
|
||||
OUT = ("/tmp/claude-1000/-home-alex-Documents-OpenFUT/"
|
||||
"8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec")
|
||||
|
||||
try:
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
for a in (0x180098F20, 0x180098560, 0x1800989F0, 0x180042440, 0x180043350,
|
||||
0x1800991A0, 0x180141660, 0x18011A860, 0x1801356C0, 0x1801362E0,
|
||||
0x18013FE00, 0x18011E9D0, 0x18013AF30, 0x180096670, 0x1801017E0):
|
||||
src = dec(a)
|
||||
p = os.path.join(OUT, "FUN_%x.c" % a)
|
||||
with open(p, "w") as f:
|
||||
f.write(src)
|
||||
print(" %-14s len=%d -> %s" % ("FUN_%x" % a, len(src), p))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Q12: the UI group tables around 0x180203260 and every family caption key.
|
||||
|
||||
Known: consumables group table at 0x180203260 (7 rows, stride 0x18, indexed by the
|
||||
switch in FUN_180096670 case 0xb) and staff at 0x180203310 (5 rows, case 8). The
|
||||
club-item claim "there is no equivalent table" is exactly the kind of absence this
|
||||
project keeps getting wrong, so walk the WHOLE region 0x180203100..0x180203700 as
|
||||
stride-0x18 triples and print anything string-shaped, then xref each candidate
|
||||
table start.
|
||||
|
||||
Also print every .rdata literal containing BADGE / STADIUM / BALL / KIT / LOGO /
|
||||
TROPHY (upper case, i.e. loc keys) with its xrefs. Q4 of the brief.
|
||||
|
||||
CONTROL: the consumables table at 0x180203260 must come out as the seven rows
|
||||
already recorded (TRAINING/CONTRACT/FITNESS/HEALING/PLAYSTYLE/MANAGER_LEAGUE/
|
||||
TACTIC_TRAINING with codes 0,1,4,3,0x17,0x18,0x11). If the walk does not reproduce
|
||||
it, the stride/layout assumption is wrong and nothing else in this query counts.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
|
||||
def walk(lo, hi, stride):
|
||||
a = lo
|
||||
while a < hi:
|
||||
cells = []
|
||||
for k in range(0, stride, 8):
|
||||
try:
|
||||
q = qword(a + k)
|
||||
except Exception:
|
||||
q = 0
|
||||
s = ""
|
||||
if 0x180000000 <= q < 0x181000000:
|
||||
try:
|
||||
t = rd_str(q, 80)
|
||||
if t and all(0x20 <= ord(c) < 0x7F for c in t):
|
||||
s = t
|
||||
except Exception:
|
||||
pass
|
||||
cells.append("%#x%s" % (q, (" %r" % s) if s else ""))
|
||||
print(" %#x %s" % (a, " | ".join(cells)))
|
||||
a += stride
|
||||
|
||||
|
||||
try:
|
||||
print("=== stride-0x18 walk 0x180203200..0x180203460 ===")
|
||||
walk(0x180203200, 0x180203460, 0x18)
|
||||
print()
|
||||
print("=== xrefs to plausible table starts ===")
|
||||
for a in range(0x180203200, 0x180203460, 8):
|
||||
xs = xrefs_to(a)
|
||||
if xs:
|
||||
print(" %#x:" % a)
|
||||
for frm, typ, fn, ent in xs:
|
||||
print(" %#x %s in %s(%#x)" % (frm, typ, fn, ent))
|
||||
print()
|
||||
print("=== upper-case family loc keys ===")
|
||||
seen = set()
|
||||
for pat in (b"BADGE", b"STADIUM", b"BALL", b"KIT", b"LOGO", b"TROPHY"):
|
||||
for h in find_all(pat):
|
||||
# walk back to the start of the C string
|
||||
p = h
|
||||
for _ in range(80):
|
||||
try:
|
||||
if mem.getByte(addr(p - 1)) & 0xFF == 0:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
p -= 1
|
||||
s = rd_str(p, 120)
|
||||
if p in seen or len(s) < 4:
|
||||
continue
|
||||
seen.add(p)
|
||||
xs = xrefs_to(p)
|
||||
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
|
||||
print(" %#x %-52r <- %s" % (p, s, who or "-"))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Q13: every FUT_MYCLUB_ loc key, and the table row that carries it.
|
||||
|
||||
Q12 reproduced the consumables table (control passed) and showed the staff table's
|
||||
middle column IS the cardtype (manager 2, headcoach 3, fitnesscoach 4, gkcoach 0xa,
|
||||
physio 5 -- exactly the merge's switch arms), and a trophies pair:
|
||||
0x180203380 {0x05, 0, FUT_MYCLUB_OFFLINE_TROPHIES_EARNED}
|
||||
0x180203398 {0x15, 1, FUT_MYCLUB_ONLINE_TROPHIES_EARNED}
|
||||
|
||||
If a badges/kits/stadia/balls row exists in the same shape, its middle column is the
|
||||
answer. Enumerate EVERY FUT_MYCLUB_ literal, find the pointer to it in .rdata/.data,
|
||||
and print the 0x18-byte row it sits in for all three possible cell positions, plus
|
||||
the rows either side.
|
||||
|
||||
CONTROL: FUT_MYCLUB_CONSUMABLES_TRAINING_EARNED must resolve to the row
|
||||
{0, ptr, 'training'} at 0x180203260. Any layout guess that cannot reproduce that row
|
||||
is wrong.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
keys = []
|
||||
for h in find_all(b"FUT_MYCLUB_"):
|
||||
s = rd_str(h, 120)
|
||||
keys.append((h, s))
|
||||
keys.sort()
|
||||
print("=== %d FUT_MYCLUB_ literals ===" % len(keys))
|
||||
for h, s in keys:
|
||||
print(" %#x %r" % (h, s))
|
||||
print()
|
||||
print("=== pointer rows ===")
|
||||
for h, s in keys:
|
||||
ptrs = find_all(h.to_bytes(8, "little"), blocks=(".rdata", ".data"))
|
||||
if not ptrs:
|
||||
print(" %-46r no pointer" % s)
|
||||
continue
|
||||
for pa in ptrs:
|
||||
ctx = []
|
||||
for off in (-0x18, -0x10, -8, 0, 8, 0x10, 0x18):
|
||||
try:
|
||||
q = qword(pa + off)
|
||||
except Exception:
|
||||
continue
|
||||
t = ""
|
||||
if 0x180000000 <= q < 0x181000000:
|
||||
try:
|
||||
u = rd_str(q, 80)
|
||||
if u and all(0x20 <= ord(c) < 0x7F for c in u):
|
||||
t = u
|
||||
except Exception:
|
||||
pass
|
||||
ctx.append("%+#5x:%#x%s" % (off, q, (" %r" % t) if t else ""))
|
||||
print(" %-46r @%#x" % (s, pa))
|
||||
print(" " + " ".join(ctx))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Q14: the club URL format strings and their builders.
|
||||
|
||||
Q6 found 'type=%s' at 0x180224c7a and 0x180224ff9 with no direct xref, which means
|
||||
each is the TAIL of a longer literal whose start is what the code references. Dump
|
||||
every C string in 0x180224a00..0x180225300 and 0x18021e200..0x18021e800 with xrefs,
|
||||
so the club request builder can be identified and decompiled.
|
||||
|
||||
Also dump 0x180228400..0x18022b200 for the transfermarket/club parameter strings.
|
||||
|
||||
CONTROL: '&cat=%s' at 0x1802285b8 is already known to be referenced by
|
||||
FUN_180162c90; it must show that xref here too.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
|
||||
def dump(lo, hi, tag):
|
||||
print("=== %s %#x..%#x ===" % (tag, lo, hi))
|
||||
p = lo
|
||||
while p < hi:
|
||||
try:
|
||||
b = mem.getByte(addr(p)) & 0xFF
|
||||
except Exception:
|
||||
p += 1
|
||||
continue
|
||||
if 0x20 <= b < 0x7F:
|
||||
s = rd_str(p, 160)
|
||||
if len(s) >= 3:
|
||||
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
|
||||
for _f, _t, fn, ent in xrefs_to(p)}))
|
||||
print(" %#x %-66r %s" % (p, s, who))
|
||||
p += max(1, len(s)) + 1
|
||||
else:
|
||||
p += 1
|
||||
|
||||
|
||||
try:
|
||||
dump(0x180224A00, 0x180225300, "club/url block")
|
||||
dump(0x18021E200, 0x18021E800, "route table")
|
||||
dump(0x180228400, 0x180229000, "params block")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Q15: find the equip path by the atoms it must name.
|
||||
|
||||
The itemState vocabulary is also in the atom table:
|
||||
0xc activeAwayKit 0xd activeBadge 0xe activeBall 0x10 activeHomeKit
|
||||
0x12 activeStadium 0xa active 0x12e free 0x164 inGame 0x1e5 offered
|
||||
and the club ?type= taxonomy switch FUN_18012ec50 shows how a name reaches the wire:
|
||||
FUN_180180cd0(atom) returns the atom's name string. So whatever chooses which of the
|
||||
five active* states to send must call FUN_180180cd0 with 0xc/0xd/0xe/0x10/0x12, and
|
||||
the choice is made from the item's family. That is the mapping the brief wants.
|
||||
|
||||
Method: enumerate every caller of FUN_180180cd0, decompile each once, and report the
|
||||
call sites whose literal argument is one of the atoms of interest:
|
||||
equip states 0xc 0xd 0xe 0x10 0x12
|
||||
club families 0x49 badge, 0x179 kit, 0x2d8 stadium, 0x4d ball,
|
||||
0x18d leaguelogos, 0x10a equippables, 0x4b badges, 0x4f balls,
|
||||
0x17c kits, 0x18e leagueLogos, 0x2d7 stadia
|
||||
Print the matching lines with context so the surrounding switch is visible.
|
||||
|
||||
CONTROL: FUN_18012ec50 is a known caller and must be reported with its family atoms
|
||||
(0x49, 0x179, 0x2d8, 0x4d, 0x18d, 0x10a). If it is not in the output, the caller
|
||||
enumeration or the literal matching is broken.
|
||||
"""
|
||||
import re
|
||||
import traceback
|
||||
|
||||
WANT = {0xC: "activeAwayKit", 0xD: "activeBadge", 0xE: "activeBall",
|
||||
0x10: "activeHomeKit", 0x12: "activeStadium", 0xA: "active",
|
||||
0x12E: "free", 0x164: "inGame", 0x1E5: "offered",
|
||||
0x49: "badge", 0x179: "kit", 0x2D8: "stadium", 0x4D: "ball",
|
||||
0x18D: "leaguelogos", 0x10A: "equippables", 0x4B: "badges",
|
||||
0x4F: "balls", 0x17C: "kits", 0x18E: "leagueLogos", 0x2D7: "stadia"}
|
||||
|
||||
try:
|
||||
ents = {}
|
||||
for frm, typ, fn, ent in xrefs_to(0x180180CD0):
|
||||
if ent:
|
||||
ents[ent] = fn
|
||||
print("callers of FUN_180180cd0: %d" % len(ents))
|
||||
pat = re.compile(r"FUN_180180cd0\((0x[0-9a-f]+|\d+)\)")
|
||||
nhit = 0
|
||||
for ent, fn in sorted(ents.items()):
|
||||
src = dec(ent)
|
||||
lines = src.splitlines()
|
||||
found = []
|
||||
for i, l in enumerate(lines):
|
||||
for m in pat.finditer(l):
|
||||
v = int(m.group(1), 0)
|
||||
if v in WANT:
|
||||
found.append((i, v))
|
||||
if not found:
|
||||
continue
|
||||
nhit += 1
|
||||
print("=" * 70)
|
||||
print("%s @%#x len=%d atoms=%s" %
|
||||
(fn, ent, len(src),
|
||||
sorted({"%#x=%s" % (v, WANT[v]) for _i, v in found})))
|
||||
shown = set()
|
||||
for i, _v in found:
|
||||
for j in range(max(0, i - 4), min(len(lines), i + 2)):
|
||||
if j in shown:
|
||||
continue
|
||||
shown.add(j)
|
||||
print(" %4d: %s" % (j, lines[j].strip()))
|
||||
print(" ---")
|
||||
print("functions with hits: %d" % nhit)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Q16: the equip path -- callers of the itemState serializer.
|
||||
|
||||
Q15 found FUN_18012ee20: itemState code -> atom name, with
|
||||
1->free 2->inGame 5->0x1f8 6->offered 100->activeBadge 0x65->activeHomeKit
|
||||
0x66->activeAwayKit 0x67->activeBall 0x68->activeStadium 0xff->active
|
||||
Whoever CALLS it with 0x64..0x68 is the equip path, and the code that picks which of
|
||||
those five to pass must know the item's family.
|
||||
|
||||
Dump: every caller of FUN_18012ee20 in full, plus FUN_18012ddf0 (the club URL
|
||||
builder) in full, plus FUN_18012ec50's caller chain context.
|
||||
|
||||
CONTROL: FUN_18012ddf0 must contain the five-way if/else on *(param_1+0x30) that
|
||||
Q15 printed (0xa badge, 0xb kit, 0x15 stadium, 0x16 ball, else equippables). If the
|
||||
full decompile lacks it, this is not the same function.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
ents = {}
|
||||
for frm, typ, fn, ent in xrefs_to(0x18012EE20):
|
||||
if ent:
|
||||
ents[ent] = fn
|
||||
print("callers of FUN_18012ee20 (itemState->atom): %d -> %s" %
|
||||
(len(ents), ["%s(%#x)" % (v, k) for k, v in ents.items()]))
|
||||
for ent in sorted(ents):
|
||||
src = dec(ent)
|
||||
print("=" * 78)
|
||||
print("CALLER %s @%#x len=%d" % (ents[ent], ent, len(src)))
|
||||
print(src)
|
||||
print("=" * 78)
|
||||
src = dec(0x18012DDF0)
|
||||
print("CLUB URL BUILDER FUN_18012ddf0 len=%d" % len(src))
|
||||
print(src)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Q17: enumerate EVERY switch case label in the binary, then find the ones that
|
||||
distinguish club subtypes.
|
||||
|
||||
Q5's scalar scan failed its control because jump-table case labels are not
|
||||
instruction immediates. Ghidra, however, names them: it creates symbols of the form
|
||||
switchD_<addr>_caseD_<n> (and caseD_<n>) at each case target. Walking the symbol
|
||||
table therefore enumerates switch dispatch in the one form a scalar scan cannot see.
|
||||
|
||||
Report every function whose case-value set intersects the club-subtype candidates
|
||||
{0x1e,0x1f,9,10,11,0x91..0x96} and print the full case set for each.
|
||||
|
||||
CONTROL: FUN_1800d8330 must appear with case labels including 0x1e, 0x1f, 0x91..0x96,
|
||||
0xe7..0xe9 and 0xec. If it does not, the symbol-based enumeration is broken and no
|
||||
absence claim may be made from it.
|
||||
"""
|
||||
import re
|
||||
import traceback
|
||||
|
||||
try:
|
||||
st = prog.getSymbolTable()
|
||||
it = st.getAllSymbols(True)
|
||||
pat = re.compile(r"caseD_([0-9a-fA-F]+)$")
|
||||
per = {}
|
||||
n = 0
|
||||
while it.hasNext():
|
||||
s = it.next()
|
||||
m = pat.search(s.getName())
|
||||
if not m:
|
||||
continue
|
||||
n += 1
|
||||
try:
|
||||
v = int(m.group(1), 16)
|
||||
except ValueError:
|
||||
continue
|
||||
f = fm.getFunctionContaining(s.getAddress())
|
||||
key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0)
|
||||
per.setdefault(key, set()).add(v)
|
||||
print("case labels found: %d in %d functions" % (n, len(per)))
|
||||
|
||||
CAND = {0x1E, 0x1F, 9, 10, 11, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96}
|
||||
print()
|
||||
print("=== functions whose case set meets the club-subtype candidates ===")
|
||||
rows = []
|
||||
for (name, ent), vals in per.items():
|
||||
inter = vals & CAND
|
||||
if len(inter) >= 2:
|
||||
rows.append((len(inter), name, ent, vals))
|
||||
rows.sort(reverse=True)
|
||||
for k, name, ent, vals in rows:
|
||||
print(" %-26s %#x hits=%d cases=%s" %
|
||||
(name, ent, k, sorted("%#x" % v for v in vals)))
|
||||
print()
|
||||
print("=== control: FUN_1800d8330 ===")
|
||||
for (name, ent), vals in per.items():
|
||||
if ent == 0x1800D8330:
|
||||
print(" YES cases=%s" % sorted("%#x" % v for v in vals))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Q18: FUN_1800fed90 -- a switch whose case set is EXACTLY {0x91..0x96}.
|
||||
|
||||
Q17's case-label enumeration (control passed on FUN_1800d8330) found exactly one
|
||||
function whose switch discriminates the six high club subtypes and nothing else:
|
||||
FUN_1800fed90. If 0x91..0x96 are trophies, this is where each one is turned into a
|
||||
concrete thing, and the six arms should be distinguishable.
|
||||
|
||||
Also decompile FUN_1800f4bc0 and FUN_1800f2f70 (case sets 0xa..0x14, i.e. they
|
||||
distinguish 10 and 11, the other two cardtype-7 subtypes) and FUN_1800d8260 /
|
||||
FUN_1800d86c0 / FUN_1800d8b50 (small enum->string helpers next to the classifier).
|
||||
|
||||
CONTROL: FUN_1800d8b50 is called by the club URL builder FUN_18012ddf0 to render a
|
||||
value for query key atom 0x243; it should decompile to a code->string table, which
|
||||
is a known shape. If it comes out as something else, my reading of the URL builder
|
||||
is wrong.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
for a in (0x1800FED90, 0x1800F4BC0, 0x1800F2F70, 0x1800D8260, 0x1800D86C0,
|
||||
0x1800D8B50):
|
||||
src = dec(a)
|
||||
print("=" * 78)
|
||||
print("FUN_%x len=%d" % (a, len(src)))
|
||||
print(src if len(src) < 9000 else src[:9000] + "\n...[cut at 9000, len above]")
|
||||
print("=" * 78)
|
||||
print("=== callers ===")
|
||||
for a in (0x1800FED90, 0x1800F4BC0):
|
||||
print(" callers of %#x:" % a)
|
||||
for frm, typ, fn, ent in xrefs_to(a):
|
||||
print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Q19: every constant the code compares a +0x50 (cardsubtypeid) or +0x4c (cardtype)
|
||||
field against.
|
||||
|
||||
The parsed item record has cardsubtypeid at +0x50 and cardtype at +0x4c. Instead of
|
||||
searching for a constant (which misses jump tables) or for a syntactic form (which
|
||||
misses != and ladders), search for the FIELD ACCESS and then collect every immediate
|
||||
that touches the loaded register within the next 8 instructions, whatever the
|
||||
mnemonic. Both the direct form (CMP dword [reg+0x50], imm) and the load-then-test
|
||||
form (MOV r32,[reg+0x50]; SUB r32,imm; CMP r32,imm) are covered.
|
||||
|
||||
CONTROL: FUN_18011e3c0 is known to do `*(int *)(x + 0x50) - 0x91U < 6`, so it must
|
||||
appear with 0x91 (and 6) attached to a +0x50 access. If the control is absent the
|
||||
scan is broken and nothing may be concluded from what it does not find.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
block = None
|
||||
for b in mem.getBlocks():
|
||||
if b.getName() == ".text":
|
||||
block = b
|
||||
break
|
||||
per = {}
|
||||
it = listing.getInstructions(block.getStart(), True)
|
||||
window = [] # [(reg_name, remaining_instrs)]
|
||||
n = 0
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
n += 1
|
||||
txt = str(ins)
|
||||
# 1) direct: memory operand with disp 0x50/0x4c and an immediate
|
||||
for disp in ("0x50", "0x4c"):
|
||||
if ("+ " + disp + "]") in txt or ("+" + disp + "]") in txt:
|
||||
imms = []
|
||||
for i in range(ins.getNumOperands()):
|
||||
for o in ins.getOpObjects(i):
|
||||
try:
|
||||
imms.append(int(o.getValue()))
|
||||
except Exception:
|
||||
pass
|
||||
f = fm.getFunctionContaining(ins.getAddress())
|
||||
key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0)
|
||||
rec = per.setdefault(key, {"direct": set(), "near": set()})
|
||||
for v in imms:
|
||||
if v not in (0x50, 0x4C) and 0 <= v < 0x1000:
|
||||
rec["direct"].add((disp, v))
|
||||
# start a window: whatever register this instruction defines
|
||||
for r in ins.getResultObjects():
|
||||
window.append([str(r), 8, key, disp])
|
||||
# 2) decay window and attach immediates that touch the tracked register
|
||||
nxt = []
|
||||
for w in window:
|
||||
reg, left, key, disp = w
|
||||
if left <= 0:
|
||||
continue
|
||||
if reg in txt:
|
||||
for i in range(ins.getNumOperands()):
|
||||
for o in ins.getOpObjects(i):
|
||||
try:
|
||||
v = int(o.getValue())
|
||||
except Exception:
|
||||
continue
|
||||
if 0 <= v < 0x1000:
|
||||
per.setdefault(key, {"direct": set(), "near": set()})
|
||||
per[key]["near"].add((disp, v))
|
||||
w[1] = left - 1
|
||||
nxt.append(w)
|
||||
window = nxt[-40:]
|
||||
|
||||
print("instructions scanned: %d" % n)
|
||||
print()
|
||||
CAND = {9, 10, 11, 0x1E, 0x1F, 7, 0x91}
|
||||
print("=== functions whose +0x50 / +0x4c constants meet {9,10,11,0x1e,0x1f,7,0x91} ===")
|
||||
for (name, ent), rec in sorted(per.items()):
|
||||
vals = rec["direct"] | rec["near"]
|
||||
hit = {v for _d, v in vals} & CAND
|
||||
if not hit:
|
||||
continue
|
||||
print(" %-24s %#x hits=%s" % (name, ent, sorted("%#x" % h for h in hit)))
|
||||
print(" direct=%s" % sorted("%s:%#x" % (d, v) for d, v in rec["direct"]))
|
||||
print(" near =%s" % sorted("%s:%#x" % (d, v) for d, v in rec["near"])[:40])
|
||||
print()
|
||||
print("=== control FUN_18011e3c0 ===")
|
||||
for (name, ent), rec in per.items():
|
||||
if ent == 0x18011E3C0:
|
||||
print(" direct=%s" % sorted("%s:%#x" % (d, v) for d, v in rec["direct"]))
|
||||
print(" near =%s" % sorted("%s:%#x" % (d, v) for d, v in rec["near"]))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Q2: two string clusters that look like family-name tables.
|
||||
|
||||
Q1 found 'badge' 0x18022a220, 'kit' 0x18022a228, 'leagueLogo' 0x18022a230,
|
||||
'ball' 0x18022a278 packed together, and a second cluster 'badge' 0x1802303c8,
|
||||
'ball' 0x1802303dc, 'equippables' 0x180230f48, 'leagueLogo' 0x180231608.
|
||||
|
||||
HYPOTHESIS: cluster 1 is the value list of a {name -> code} enum table like the
|
||||
itemState one (stride 0x10: char* then int). Cluster 2 is the club?type= route
|
||||
vocabulary.
|
||||
|
||||
CONTROL: the itemState table itself. My Q1 read started mid-table (row0 =
|
||||
activeBadge with code 0x64, while the doc's list starts at WAITING_FOR_GAME), so
|
||||
this query re-walks BACKWARDS from 0x180229d20 to find the real table start and
|
||||
prints it in full. If the ten documented names do not appear in order, my table
|
||||
walker is wrong.
|
||||
|
||||
Then: for every string in each cluster, find the .rdata qword that points at it
|
||||
(the table row) and print the row's neighbours, plus xrefs.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
|
||||
def dump_strings(lo, hi, label):
|
||||
print("=== strings %s %#x..%#x ===" % (label, lo, hi))
|
||||
p = lo
|
||||
while p < hi:
|
||||
try:
|
||||
b = mem.getByte(addr(p)) & 0xFF
|
||||
except Exception:
|
||||
p += 1
|
||||
continue
|
||||
if 0x20 <= b < 0x7F:
|
||||
s = rd_str(p, 96)
|
||||
if len(s) >= 2:
|
||||
print(" %#x %r" % (p, s))
|
||||
p += max(1, len(s)) + 1
|
||||
else:
|
||||
p += 1
|
||||
|
||||
|
||||
def walk_table(start, n, back=0):
|
||||
print("--- table walk from %#x, %d rows (stride 0x10) ---" % (start, n))
|
||||
for i in range(-back, n):
|
||||
e = start + i * 0x10
|
||||
try:
|
||||
q0, q1 = qword(e), qword(e + 8)
|
||||
except Exception:
|
||||
continue
|
||||
s = ""
|
||||
if 0x180000000 <= q0 < 0x181000000:
|
||||
try:
|
||||
s = rd_str(q0, 64)
|
||||
except Exception:
|
||||
s = "?"
|
||||
print(" [%3d] %#x ptr=%#x %-26r val=%#x" % (i, e, q0, s, q1))
|
||||
|
||||
|
||||
try:
|
||||
walk_table(0x180229D20, 8, back=14)
|
||||
print()
|
||||
dump_strings(0x18022A200, 0x18022A380, "cluster1")
|
||||
print()
|
||||
dump_strings(0x180230300, 0x180230420, "cluster2a")
|
||||
print()
|
||||
dump_strings(0x180230F00, 0x180230FA0, "cluster2b")
|
||||
print()
|
||||
dump_strings(0x180231380, 0x180231680, "cluster2c")
|
||||
print()
|
||||
print("=== xrefs / pointer-rows for cluster strings ===")
|
||||
for name, a in [("badge", 0x18022A220), ("kit", 0x18022A228),
|
||||
("leagueLogo", 0x18022A230), ("ball", 0x18022A278),
|
||||
("badge2", 0x1802303C8), ("ball2", 0x1802303DC),
|
||||
("equippables", 0x180230F48), ("leagueLogo2", 0x180231608),
|
||||
("itemState", 0x180231490)]:
|
||||
print(" %s @%#x" % (name, a))
|
||||
for frm, typ, fn, ent in xrefs_to(a):
|
||||
print(" xref from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
|
||||
ptrs = find_all(a.to_bytes(8, "little"), blocks=(".rdata", ".data"))
|
||||
for pa in ptrs[:8]:
|
||||
print(" ptr-row @%#x next-q=%#x prev-q=%#x" %
|
||||
(pa, qword(pa + 8), qword(pa - 8)))
|
||||
for frm, typ, fn, ent in xrefs_to(pa):
|
||||
print(" row xref %#x %s in %s(%#x)" % (frm, typ, fn, ent))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Q20: the functions that test cardtype==7 / cardsubtypeid in {9,10,11}.
|
||||
|
||||
Q19 (control passed: it recovered FUN_18011e3c0's 0x91/0x94/0x96 on a +0x50 field)
|
||||
flagged:
|
||||
FUN_1800f6c40 direct [+0x4c]==7 AND [+0x50]==9
|
||||
FUN_180084720 direct [+0x50]==9 and [+0x50]==0xb
|
||||
FUN_180094580 near [+0x50] 9 / 0xb / 3
|
||||
FUN_18015fa80 direct [+0x50]==9
|
||||
FUN_1801362e0 direct [+0x4c] 1 / 2 / 7
|
||||
Decompile each. Whatever these do with subtypes 9/10/11 is the club-family
|
||||
behaviour, and a loc key or asset id in any arm names the family.
|
||||
|
||||
CONTROL: FUN_1801362e0 is one of the merge's arms (called from FUN_180141660 case 2,
|
||||
the manager arm) so it must be a DB lookup on carddbid; if it is not, the +0x4c
|
||||
attribution is on a different struct and these hits are noise.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
for a in (0x1800F6C40, 0x180084720, 0x180094580, 0x18015FA80, 0x1801362E0):
|
||||
src = dec(a)
|
||||
print("=" * 78)
|
||||
print("FUN_%x len=%d" % (a, len(src)))
|
||||
print(src if len(src) < 12000 else src[:12000] + "\n...[cut, len above]")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Q21: the FUT data-manager vtable slots that name a club item.
|
||||
|
||||
FUN_1800f6c40 (the pack/award tile builder) does:
|
||||
if (item+0x4c == 1) -> ITEM_RARITY / ITEM_LEVEL
|
||||
else if (item+0x50 == 9) -> "IS_KIT_%d" = 1 <-- names subtype 9
|
||||
name = mgr->vt[0x490](out, item+0x4c cardtype, item+0x50 subtype, item+0x18)
|
||||
if (name empty && item+0x4c == 7)
|
||||
name = mgr->vt[0x498](out, item+0x50 subtype, item+0x94 teamid, item+0x20)
|
||||
where mgr = FUN_18011a830(). Slots 0x490 and 0x498 are therefore the club-item name
|
||||
resolvers and must switch on the subtype.
|
||||
|
||||
Resolve the manager's vtable, then decompile slots 0x490, 0x498, 0xa08, 0xa38, 0xa40.
|
||||
|
||||
CONTROL: slot 0xa08 is the one the item deserializer calls to file a parsed item
|
||||
(FUN_18013fe00 line 825), and slot 0xa40 is the lookup FUN_18011e3c0 uses with a
|
||||
resourceId. If the resolved vtable's 0xa08/0xa40 are not functions, the vtable
|
||||
resolution is wrong.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
src = dec(0x18011A830)
|
||||
print("=== FUN_18011a830 (manager accessor) len=%d ===" % len(src))
|
||||
print(src)
|
||||
# find the vtable it installs / the object's class
|
||||
print()
|
||||
print("=== candidate vtables referenced from FUN_18011a830 and its callees ===")
|
||||
f = func(0x18011A830)
|
||||
cands = set()
|
||||
for ad in f.getBody().getAddresses(True):
|
||||
ins = listing.getInstructionAt(ad)
|
||||
if ins is None:
|
||||
continue
|
||||
for r in ins.getReferencesFrom():
|
||||
t = int(r.getToAddress().getOffset())
|
||||
if 0x1801E5000 <= t <= 0x1802891FF:
|
||||
cands.add(t)
|
||||
for t in sorted(cands):
|
||||
try:
|
||||
v0, v1 = qword(t), qword(t + 8)
|
||||
except Exception:
|
||||
continue
|
||||
print(" %#x -> %#x %#x (%s / %s)" %
|
||||
(t, v0, v1, fname(v0) if 0x180000000 <= v0 < 0x181000000 else "-",
|
||||
fname(v1) if 0x180000000 <= v1 < 0x181000000 else "-"))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Q22: resolve the manager object's vtable through the global DAT_1802e6398.
|
||||
|
||||
FUN_18011a830 just returns DAT_1802e6398, so the vtable is installed wherever that
|
||||
global is written. Find the writers, decompile the smallest, and read the vtable it
|
||||
stores. Then dump slots 0x490 / 0x498 / 0xa08 / 0xa38 / 0xa40.
|
||||
|
||||
CONTROL: the recovered vtable's slot 0xa08 and 0xa40 must both be real functions
|
||||
(the item deserializer calls 0xa08 to file an item; FUN_18011e3c0 calls 0xa40 with a
|
||||
resourceId). If either is not a function, the vtable is wrong.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
print("=== writers/readers of DAT_1802e6398 ===")
|
||||
ents = {}
|
||||
for frm, typ, fn, ent in xrefs_to(0x1802E6398):
|
||||
ents.setdefault(ent, []).append((frm, typ, fn))
|
||||
for ent, lst in sorted(ents.items()):
|
||||
print(" %s(%#x) n=%d types=%s" %
|
||||
(lst[0][2], ent, len(lst), sorted({t for _f, t, _n in lst})))
|
||||
# the constructor is a function that WRITES it
|
||||
writers = [e for e, lst in ents.items()
|
||||
if any(t == "WRITE" for _f, t, _n in lst)]
|
||||
print("writers: %s" % ["%#x" % w for w in writers])
|
||||
for w in writers:
|
||||
src = dec(w)
|
||||
print("=" * 70)
|
||||
print("writer FUN_%x len=%d" % (w, len(src)))
|
||||
print(src if len(src) < 6000 else src[:6000] + "\n...[cut]")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Q23: callers of the manager setter FUN_18011d780 -> the manager's vtable.
|
||||
|
||||
CONTROL: the vtable found must have real functions at slots 0xa08 and 0xa40.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
for frm, typ, fn, ent in xrefs_to(0x18011D780):
|
||||
print("caller %s(%#x) via %#x %s" % (fn, ent, frm, typ))
|
||||
ents = {ent for _f, _t, _n, ent in xrefs_to(0x18011D780) if ent}
|
||||
for ent in ents:
|
||||
src = dec(ent)
|
||||
print("=" * 70)
|
||||
print("FUN_%x len=%d" % (ent, len(src)))
|
||||
print(src if len(src) < 7000 else src[:7000] + "\n...[cut]")
|
||||
f = func(ent)
|
||||
cands = set()
|
||||
for ad in f.getBody().getAddresses(True):
|
||||
ins = listing.getInstructionAt(ad)
|
||||
if ins is None:
|
||||
continue
|
||||
for r in ins.getReferencesFrom():
|
||||
t = int(r.getToAddress().getOffset())
|
||||
if 0x1801E5000 <= t <= 0x1802891FF:
|
||||
cands.add(t)
|
||||
print("--- .rdata refs, checked for vtable shape ---")
|
||||
for t in sorted(cands):
|
||||
try:
|
||||
v0, v1 = qword(t), qword(t + 8)
|
||||
s90, s98 = qword(t + 0x490), qword(t + 0x498)
|
||||
a08, a40 = qword(t + 0xA08), qword(t + 0xA40)
|
||||
except Exception:
|
||||
continue
|
||||
ok = all(fm.getFunctionAt(addr(x)) is not None
|
||||
for x in (v0, v1) if 0x180000000 <= x < 0x181000000)
|
||||
print(" %#x v0=%s v1=%s | +0x490=%s +0x498=%s +0xa08=%s +0xa40=%s%s" %
|
||||
(t, fname(v0), fname(v1), fname(s90), fname(s98),
|
||||
fname(a08), fname(a40), " <== VTABLE?" if ok else ""))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Q24: FUN_180119bd0 -- the cardtype-7 name resolver. This should BE the mapping.
|
||||
|
||||
The manager vtable was read out of the live process (read-only): DAT_1802e6398 ->
|
||||
object -> vtable static 0x18021c2a0, with
|
||||
+0x490 = FUN_18011a860 (cardtype switch 1,2,3,4,5,10 -- no club arm)
|
||||
+0x498 = FUN_180119bd0 (called ONLY when +0x490 returned empty AND cardtype==7,
|
||||
with args (cardsubtypeid, teamid, assetId))
|
||||
+0xa08 = FUN_18011cca0 (file a parsed item)
|
||||
+0xa38 = FUN_180113e40 (register trophy: (tournamentId, subtype, name))
|
||||
+0xa40 = FUN_18011bf40 (lookup by resourceId)
|
||||
|
||||
Decompile 0x498, 0xa38, 0xa40 and 0xa08.
|
||||
|
||||
CONTROL: FUN_18011a860 must be the same function Q11 dumped (12905 chars) with the
|
||||
cardtype switch; that is what makes the 0x498 fallback meaningful.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
for a, tag in [(0x180119BD0, "+0x498 club-item name resolver"),
|
||||
(0x180113E40, "+0xa38 trophy register"),
|
||||
(0x18011BF40, "+0xa40 lookup by resourceId"),
|
||||
(0x18011CCA0, "+0xa08 file parsed item")]:
|
||||
src = dec(a)
|
||||
print("=" * 78)
|
||||
print("%s FUN_%x len=%d" % (tag, a, len(src)))
|
||||
print(src if len(src) < 14000 else src[:14000] + "\n...[cut, len above]")
|
||||
print("=" * 78)
|
||||
print("control: FUN_18011a860 len=%d" % len(dec(0x18011A860)))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Q25: is there a cardtype-9 sibling of FUN_180119bd0 for balls and league logos?
|
||||
|
||||
FUN_180119bd0 settles cardtype 7: 9 -> "FUT_UC_KITS"+TeamName_Abbr15_<teamid>
|
||||
10 -> "Stadium"+StadiumName_<assetId>
|
||||
11 -> "Badge"+TeamName_Abbr15_<teamid>
|
||||
That leaves 0x1e and 0x1f (the only other cardtype-9 subtypes besides trophies
|
||||
0x91..0x96 and misc 0xe7..0xec) for ball and league logo.
|
||||
|
||||
Dump the loc-key string neighbourhood the resolver draws from (0x1801ec700..
|
||||
0x1801ed400 holds 'Stadium'/'Ball' literals) with xrefs, and xref the exact literals
|
||||
"Stadium", "Badge", "FUT_UC_KITS" to find any sibling resolver. A function that
|
||||
references a ball or league-logo caption is the cardtype-9 equivalent.
|
||||
|
||||
CONTROL: the literals "Stadium" and "Badge" must show FUN_180119bd0 as an xref. If
|
||||
they do not, I am looking at different copies of those strings.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
print("=== atoms 0xd1 and 0x19c (the two club-item wire strings) ===")
|
||||
for a in (0xD1, 0x19C):
|
||||
print(" %#x = %d" % (a, a))
|
||||
|
||||
print()
|
||||
print("=== string dump 0x1801ec700..0x1801ed400 ===")
|
||||
p = 0x1801EC700
|
||||
while p < 0x1801ED400:
|
||||
try:
|
||||
b = mem.getByte(addr(p)) & 0xFF
|
||||
except Exception:
|
||||
p += 1
|
||||
continue
|
||||
if 0x20 <= b < 0x7F:
|
||||
s = rd_str(p, 120)
|
||||
if len(s) >= 3:
|
||||
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
|
||||
for _f, _t, fn, ent in xrefs_to(p)}))
|
||||
print(" %#x %-46r %s" % (p, s, who))
|
||||
p += max(1, len(s)) + 1
|
||||
else:
|
||||
p += 1
|
||||
|
||||
print()
|
||||
print("=== exact-literal xrefs ===")
|
||||
for lit in (b"Stadium\x00", b"Badge\x00", b"FUT_UC_KITS\x00", b"Ball\x00",
|
||||
b"BallName", b"LeagueLogo", b"leaguelogo", b"FUT_UC_"):
|
||||
for h in find_all(lit):
|
||||
s = rd_str(h, 80)
|
||||
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
|
||||
for _f, _t, fn, ent in xrefs_to(h)}))
|
||||
print(" %-14r %#x %-30r <- %s" % (lit.rstrip(b"\x00").decode(), h, s, who or "-"))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Q26: FUN_1801bfac0 -- the one function that names ALL the club families.
|
||||
|
||||
It references 'Stadium', 'Badge', 'FUT_UC_KITS' and 'FUT_UC_BALL' (and the GK
|
||||
attribute captions), and Q10 showed it queries fcc_matches. If it switches on
|
||||
cardsubtypeid it will name the ball subtype, which FUN_180119bd0 (cardtype 7 only)
|
||||
cannot.
|
||||
|
||||
Also dump the string cluster 0x180239000..0x180239180 which holds FUT_UC_BALL,
|
||||
'Stadium' and 'badge' close together, with xrefs.
|
||||
|
||||
CONTROL: FUN_180119bd0 must appear as an xref of 'Stadium' 0x18021ce80 and 'Badge'
|
||||
0x1802041b8 -- it did in Q25, so the literal identification is sound.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
src = dec(0x1801BFAC0)
|
||||
print("=== FUN_1801bfac0 len=%d ===" % len(src))
|
||||
print(src if len(src) < 20000 else src[:20000] + "\n...[cut, len above]")
|
||||
print()
|
||||
print("=== strings 0x180238f80..0x180239200 ===")
|
||||
p = 0x180238F80
|
||||
while p < 0x180239200:
|
||||
try:
|
||||
b = mem.getByte(addr(p)) & 0xFF
|
||||
except Exception:
|
||||
p += 1
|
||||
continue
|
||||
if 0x20 <= b < 0x7F:
|
||||
s = rd_str(p, 120)
|
||||
if len(s) >= 3:
|
||||
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
|
||||
for _f, _t, fn, ent in xrefs_to(p)}))
|
||||
print(" %#x %-40r %s" % (p, s, who))
|
||||
p += max(1, len(s)) + 1
|
||||
else:
|
||||
p += 1
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Q27: dump FUN_1801bfac0 in FULL to disk (it is >20k chars and was cut in Q26).
|
||||
|
||||
It is the card-detail builder and the only function referencing FUT_UC_KITS,
|
||||
'Stadium', 'Badge' AND FUT_UC_BALL, so its club arms should name every family
|
||||
including the ball subtype that FUN_180119bd0 (cardtype 7 only) cannot reach.
|
||||
|
||||
Also dump FUN_180094580 (references FUT_UC_KITS, and Q19 flagged it testing
|
||||
item+0x50 against 9 and 0xb) and FUN_180099490 ('Badge').
|
||||
|
||||
No truncation: written to files, lengths printed here.
|
||||
"""
|
||||
import os
|
||||
import traceback
|
||||
|
||||
OUT = ("/tmp/claude-1000/-home-alex-Documents-OpenFUT/"
|
||||
"8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec")
|
||||
|
||||
try:
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
for a in (0x1801BFAC0, 0x180094580, 0x180099490, 0x18015FA80, 0x180102790,
|
||||
0x1800F6C40, 0x180084720):
|
||||
src = dec(a)
|
||||
p = os.path.join(OUT, "FUN_%x.c" % a)
|
||||
with open(p, "w") as f:
|
||||
f.write(src)
|
||||
print(" FUN_%x len=%d" % (a, len(src)))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Q28: verify the accessor that FUN_1801bfac0 switches on IS cardsubtypeid, and
|
||||
read the deserializer arms for the club-item string fields.
|
||||
|
||||
FUN_1801bfac0 does `iVar5 = FUN_1801a8640(local_78)` and then
|
||||
iVar5 == 9 -> FUT_UC_KITS (+ FUT_ThirdKit / KitBioAwayDescription variants)
|
||||
iVar5 == 10 -> "Stadium" + StadiumName_%d + StadiumDetailDesc
|
||||
iVar5 == 0xb-> "Badge" + TeamName_Abbr15_%d + badgeBioDescription
|
||||
iVar5 == 0x1e -> "FUT_UC_BALL"
|
||||
iVar5 == 0x1f -> league-derived id, no generic asset string
|
||||
iVar5 - 0xe7U < 2 / 0xe9 / 0xec -> misc
|
||||
That reading only holds if FUN_1801a8640 returns the item's +0x50 cardsubtypeid.
|
||||
Decompile it and its neighbours FUN_1801a8570 / FUN_1801a8560 / FUN_1801a8020 /
|
||||
FUN_1801a86a0 / FUN_1801a8800 / FUN_1801a87f0 / FUN_1801a8040.
|
||||
|
||||
CONTROL: FUN_1801a86a0 is used in the badge arm as the argument to
|
||||
TeamName_Abbr15_%d, so it must return the +0x94 teamid. If it returns something
|
||||
else, my field-offset map for these accessors is wrong.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
for a in (0x1801A8640, 0x1801A8570, 0x1801A8560, 0x1801A8020, 0x1801A86A0,
|
||||
0x1801A8800, 0x1801A87F0, 0x1801A8040):
|
||||
src = dec(a)
|
||||
print("-" * 70)
|
||||
print("FUN_%x len=%d" % (a, len(src)))
|
||||
print(src)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Q3: walk the whole enum-table block around 0x180229a00..0x180229e00.
|
||||
|
||||
Q2 found row @0x180229b50 = {'badge', 0xa}, 0x180229b60 = {'kit', 0xb},
|
||||
0x180229b70 = {'leagueLogo', 0xc}, and 0x180229c10 = {'ball', 0x16} -- i.e. a
|
||||
{name -> numeric code} table that NAMES THE CLUB FAMILIES. That is exactly the
|
||||
mapping the brief asks for, IF the codes are cardsubtypeids.
|
||||
|
||||
HYPOTHESIS: one of these tables is the cardsubtypeid vocabulary. Codes 0xa/0xb/0xc
|
||||
are NOT in the cardtype-9 subtype set (0x1e,0x1f,0x91..0x96), so either it is a
|
||||
different axis (an "item sub-family" enum) or the mapping is indirect.
|
||||
|
||||
CONTROL: the itemState table at 0x180229cc0 (invalid/free/WAITING_FOR_GAME/...)
|
||||
must reappear intact inside the same walk, with the same codes Q2 printed.
|
||||
|
||||
Dump every 0x10 row from 0x180229800 to 0x180229f00, printing ptr, string, value.
|
||||
Then xref every table start candidate (a row whose predecessor is not a valid
|
||||
string row) to find the lookup function.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
LO, HI = 0x180229800, 0x180229F00
|
||||
rows = []
|
||||
a = LO
|
||||
while a < HI:
|
||||
try:
|
||||
q0, q1 = qword(a), qword(a + 8)
|
||||
except Exception:
|
||||
a += 0x10
|
||||
continue
|
||||
s = None
|
||||
if 0x180000000 <= q0 < 0x181000000:
|
||||
try:
|
||||
t = rd_str(q0, 64)
|
||||
if t and all(0x20 <= ord(c) < 0x7F for c in t):
|
||||
s = t
|
||||
except Exception:
|
||||
pass
|
||||
rows.append((a, q0, s, q1))
|
||||
a += 0x10
|
||||
|
||||
print("=== enum row walk %#x..%#x ===" % (LO, HI))
|
||||
prev_ok = False
|
||||
starts = []
|
||||
for (a, q0, s, q1) in rows:
|
||||
mark = ""
|
||||
ok = s is not None
|
||||
if ok and not prev_ok:
|
||||
mark = " <== TABLE START?"
|
||||
starts.append(a)
|
||||
prev_ok = ok
|
||||
print(" %#x ptr=%#018x %-28r val=%#-10x%s" % (a, q0, s or "", q1, mark))
|
||||
|
||||
print()
|
||||
print("=== xrefs to each candidate table start ===")
|
||||
for a in starts:
|
||||
print(" start %#x" % a)
|
||||
for frm, typ, fn, ent in xrefs_to(a):
|
||||
print(" from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Q4: the enum converter helpers and their callers.
|
||||
|
||||
Q3 established two request-side vocabularies:
|
||||
table 0x180229ab0 "subtype filter": any=-1 playerGK=1..physio=9 badge=0xa kit=0xb
|
||||
leagueLogo=0xc playerTraining=0xd GKTraining=0xe position=0xf playStyle=0x10
|
||||
managerLeagueModifier=0x11 contract=0x12 fitness=0x13 healing=0x14
|
||||
stadium=0x15 ball=0x16
|
||||
table 0x180229c30 "type filter": any=-1 player=1 staff=2 clubInfo=3 training=4
|
||||
development=5 stadium=6 ball=7
|
||||
table 0x180229cc0 "itemState": invalid=0 free=1 WAITING_FOR_GAME=2 inGame=2
|
||||
forSale=5 offered=6 activeBadge=0x64 .. activeStadium=0x68 active=0xff
|
||||
|
||||
HYPOTHESIS: the converter functions FUN_180166300 (subtype), FUN_180166340 (type),
|
||||
FUN_180166660 (itemState) are string<->code helpers; their CALLERS are the request
|
||||
builder and the equip path. The equip path must choose 0x64..0x68 from the item, and
|
||||
that choice is the subtype->family mapping we want.
|
||||
|
||||
CONTROL: FUN_1800d8330, decompiled in full here, must reproduce the documented
|
||||
cardtype-9 subtype set {0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec}. If it does not, my
|
||||
project copy is not the analysed one.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
for a, tag in [(0x1800D8330, "CONTROL FUN_1800d8330 cardsubtype->cardtype"),
|
||||
(0x180166300, "subtype-enum helper"),
|
||||
(0x180166340, "type-enum helper"),
|
||||
(0x180166660, "itemState helper A"),
|
||||
(0x1801666F0, "itemState/other helper B"),
|
||||
(0x180166790, "helper C")]:
|
||||
src = dec(a)
|
||||
print("=" * 78)
|
||||
print("%s @%#x len=%d" % (tag, a, len(src)))
|
||||
print(src)
|
||||
print("=" * 78)
|
||||
print("=== callers ===")
|
||||
for a in (0x180166300, 0x180166340, 0x180166660, 0x1801666F0, 0x180166790):
|
||||
print(" callers of %#x:" % a)
|
||||
seen = set()
|
||||
for frm, typ, fn, ent in xrefs_to(a):
|
||||
if ent in seen:
|
||||
continue
|
||||
seen.add(ent)
|
||||
print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user