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
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Frozen baseline archives / inspects / manifests
|
||||||
|
/docker-backups/
|
||||||
|
|
||||||
|
# local dev screenshots (not versioned)
|
||||||
|
fifa17-recon/.screens/
|
||||||
|
|
||||||
|
# local hook backup
|
||||||
|
*.pre-storeguard.bak
|
||||||
|
|||||||
Generated
+6485
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
|
Useful framing: this project's failures have almost always come from proposing a fix
|
||||||
before testing the assumption under it. Hypotheses that come with a cheap way to
|
before testing the assumption under it. Hypotheses that come with a cheap way to
|
||||||
disconfirm them are worth far more than plausible ones.
|
disconfirm them are worth far more than plausible ones.
|
||||||
|
|
||||||
|
## FIFA 17 network-redirect milestone (2026-08-09)
|
||||||
|
|
||||||
|
Hook now installs a GENERIC network redirect on the fifa17 feature path (fifa17.rs
|
||||||
|
install_network_redirect): getaddrinfo IAT patch + inline connect detour + WSAConnect
|
||||||
|
IAT, with a configurable destination (connect_hook::set_target_ipv4) read from
|
||||||
|
openfut.cfg (single-line IP). Deployed DLL md5 bc9e0bc6, cfg=10.10.0.120.
|
||||||
|
|
||||||
|
RESULT of live launch (client 105 -> server 120):
|
||||||
|
- Error changed: "servers shut down" -> "Unable to connect to EA servers / check
|
||||||
|
network". Redirect IS firing (progress).
|
||||||
|
- BLOCKER A: getaddrinfo IAT patched 0+0 -> FIFA 17 does NOT resolve via IAT
|
||||||
|
getaddrinfo in the main exe or EAWebKit.dll. Names resolved via another path
|
||||||
|
(gethostbyname or internal DirtySDK resolver). So no hostname reached 120.
|
||||||
|
- BLOCKER B (architectural): FIFA 17 online = Blaze binary TCP on high ports. Log
|
||||||
|
shows connect 20.51.153.159:42230 sock_type=1 -> wsa_err=10035 (WOULDBLOCK->dead).
|
||||||
|
Port 42230 is NOT in the remap set (443,10041,42127,3216) so it was not redirected.
|
||||||
|
Even if redirected, the Docker bridge only speaks HTTPS on 8443 -- no Blaze
|
||||||
|
listener exists for FIFA 17. This is a server-side build, not a hook tweak.
|
||||||
|
|
||||||
|
NEXT (evidence-first): add gethostbyname (and possibly a DirtySDK resolver) capture
|
||||||
|
to learn the hostname behind 20.51.153.159; widen Blaze port remap; then scope a
|
||||||
|
Blaze-speaking bridge listener before expecting the error to clear.
|
||||||
|
|
||||||
|
## DNS/getaddrinfo fix — RESOLVED (2026-08-09, hook md5 67e3639b)
|
||||||
|
|
||||||
|
Added src/resolver_hook.rs: INLINE detours at ws2_32 export addresses for
|
||||||
|
getaddrinfo + GetAddrInfoW + gethostbyname (same unhook/rehook pattern as
|
||||||
|
connect_hook). Replaces the IAT approach that patched 0 slots on FIFA 17.
|
||||||
|
Wired into fifa17.rs install_network_redirect; hooks.rs gained redirect_ip_cstr()
|
||||||
|
and redirect_ip_str() helpers.
|
||||||
|
|
||||||
|
LIVE RESULT (client 105 -> server 120):
|
||||||
|
- resolver detours 3/3 installed.
|
||||||
|
- getaddrinfo(winter15.gosredirector.ea.com) -> redirect. Game now dials
|
||||||
|
10.10.0.120 (was 20.51.153.159 before). DNS BLOCKER A = SOLVED.
|
||||||
|
|
||||||
|
REMAINING BLOCKER B (architectural, NOT DNS): FIFA 17 online = EA Blaze binary
|
||||||
|
TCP. Game connects 10.10.0.120:42230 (gosredirector/Blaze redirector) ->
|
||||||
|
wsa_err=10035 (nothing listening). Two gaps: (1) connect_hook remap set lacks
|
||||||
|
42230; (2) even remapped, the Docker bridge only serves HTTPS on 8443 — no Blaze
|
||||||
|
listener exists. Clearing Unable to connect requires a Blaze redirector+main
|
||||||
|
server on the bridge side (real server build), not a hook change.
|
||||||
|
NOTE: the 3s TLS-handshake-EOF spam in bridge logs on :8443 is the LAUNCHER health
|
||||||
|
poller, not the game.
|
||||||
|
|||||||
@@ -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
|
*.log
|
||||||
__pycache__/
|
__pycache__/
|
||||||
captures/
|
captures/
|
||||||
|
staging/
|
||||||
tools/fifa17_profile.json
|
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)
|
### Freeze-risk summary (type fidelity is mandatory)
|
||||||
- `auctionInfo` → **array** (never object/scalar).
|
- `auctionInfo` → **array** (never object/scalar).
|
||||||
- `itemData` inside each record → **object** (the card; reuse `item_def`).
|
- `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**.
|
- `bidState`, `tradeState`, `sellerName` → **strings**.
|
||||||
- `credits`, `total`, `count`, `*Price`, `*Bid`, `expires`, `tradeId` → **numbers**.
|
- `credits`, `total`, `count`, `*Price`, `*Bid`, `expires`, `tradeId` → **numbers**.
|
||||||
- `watched` → **bool**.
|
- `watched` → **bool**.
|
||||||
@@ -966,7 +970,11 @@ Notes:
|
|||||||
{ "itemData": [ /* the single updated card item */ ] }
|
{ "itemData": [ /* the single updated card item */ ] }
|
||||||
|
|
||||||
// 8 DiscardCard — DELETE ut/delete/game/fifa17/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
|
// 9 DiscardCardByRes — DELETE ut/delete/game/fifa17/item
|
||||||
{ "totalCredits": 15000 }
|
{ "totalCredits": 15000 }
|
||||||
@@ -1042,7 +1050,7 @@ desyncs the SAX reader → tokenizer freeze at `0x1801c7f1a`.
|
|||||||
| `displayGroup` | 0xd9 | **ARRAY** | nested (freeze-risk) |
|
| `displayGroup` | 0xd9 | **ARRAY** | nested (freeze-risk) |
|
||||||
| `displayGroupAssetId` | 0xda | INT | `[rbp-0x80]` |
|
| `displayGroupAssetId` | 0xda | INT | `[rbp-0x80]` |
|
||||||
| `displayGroupUseDefaultImage` | 0xdb | BOOL | |
|
| `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) |
|
| `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) |
|
| `packContentInfo` | 0x20c | **OBJECT** | → `bronzeQuantity`(0x63), `silverQuantity`(0x2c6), `goldQuantity`(0x149), `rareQuantity`(0x273), `itemQuantity`(0x170), `start`(0x2e3), `unopened`(0x35d,bool) (freeze-risk) |
|
||||||
| `sortPriority` | 0x2cb | INT | |
|
| `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 |
|
| `itemList` | 0x16e | **ARRAY** of items (element deser `0x18013fe00`) | freeze-risk |
|
||||||
| `numberItems` | 0x1dd | INT | `[rsi+0x28]` |
|
| `numberItems` | 0x1dd | INT | `[rsi+0x28]` |
|
||||||
| `purchasedPackId` | 0x264 | INT | `[rsi+0x70]` |
|
| `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()`.
|
- **Status: already handled — VERIFIED byte-exact** against `store_buy()`.
|
||||||
- **Minimal known-good**:
|
- **Minimal known-good**:
|
||||||
@@ -1243,21 +1251,122 @@ reader → infinite spin at `0x1801c7f1a` (the hub freeze).
|
|||||||
- **Handled:** `utas_server.massinfo()` → `{userInfo, squad, settings, userData}`;
|
- **Handled:** `utas_server.massinfo()` → `{userInfo, squad, settings, userData}`;
|
||||||
`FUT_MASSINFO=full|squad|userinfo|settings|empty` bisects it one member per relaunch.
|
`FUT_MASSINFO=full|squad|userinfo|settings|empty` bisects it one member per relaunch.
|
||||||
|
|
||||||
### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED
|
### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED (schema) / the 42 flags are RECOVERED, UNTESTED
|
||||||
- **Deser:** `0x18013c6d0`
|
- **Deser:** `0x18013c6d0` (1982 bytes, 12061-char decompile, read end to end)
|
||||||
- **HTTP:** `GET ut/%s/settings`
|
- **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
|
- **Fields:** single wrapper key `configs` (0xa2) → array of config entries
|
||||||
`{ type (0x354), value (0x377) }`.
|
`{ type (0x354), value (0x377) }`. The key ladder really does hold nothing else.
|
||||||
- **Handled:** `utas_server.SETTINGS = {"configs": []}`. Min JSON: `{"configs":[]}`.
|
|
||||||
|
|
||||||
### FutGetHubDataServerResponse — CONFIDENCE: LOW (full schema) / HIGH (served {} works) — GAP
|
**The mechanism the key ladder hides.** A flag is not a JSON key. When an element
|
||||||
- **Wrapper:** `0x1801736ad` → inner `0x180173a50` / `0x180173b10` / `0x180173c00`.
|
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`
|
- **HTTP:** `GET ut/%s/hub`
|
||||||
- **Note:** uses **C++ reflection / vtable dispatch** (`call [rax+0x10]`,
|
- **CORRECTION (2026-08-06):** the earlier note here — "uses C++ reflection /
|
||||||
`call [rdx+0x1f8]`), NOT an inline atom ladder — no static field ladder to
|
vtable dispatch, NOT an inline atom ladder, no static field ladder to read,
|
||||||
read. It aggregates sub-objects (userInfo, settings, messages, etc.), each with
|
GAP" — was **WRONG**. `FUN_180139610` has an ordinary inline atom ladder: a
|
||||||
its own deser. Empty `{}` is tolerated (fields default).
|
running-sum `sub ecx,d / … / cmp ecx,d` dispatch plus a few direct `cmp esi,imm`.
|
||||||
- **Handled:** `utas_server` serves `{}` (validated hub-reaching). Deep populate = GAP.
|
It reads **18 atoms**, all enumerated below straight from the on-disk CardsDLL
|
||||||
|
via objdump (`fifa17-recon` scratchpad `hub_ladder.py`). The vtable calls are the
|
||||||
|
per-sub-object dispatch one indirection deeper, not the field read itself.
|
||||||
|
- **The 18 root atoms** (name ← `fut_atoms.tsv`):
|
||||||
|
`allObjectivesForCurrentGameSpaceId`(0x15), `auctionCount`(0x33),
|
||||||
|
`championEvent`(0x7a), `clubPlayers`(0x90), `draftSummary`(0xe4),
|
||||||
|
`friendlySeason`(0x131), `leaderboard`(0x186), `liveMessagesAvailable`(0x190),
|
||||||
|
`objectivesForCurrentUser`(0x1e3), `offlineSeason`(0x1ec), `ONLINE`(0x1f1),
|
||||||
|
`onlineSeason`(0x1f6), `SINGLE_PLAYER`(0x29d), `squad`(0x2cd),
|
||||||
|
`tournament`(0x328), `tournamentProgress`(0x32c), `tradePile`(0x333),
|
||||||
|
`watchlist`(0x381).
|
||||||
|
- **TILE MAP (which atom drives which hub tile):**
|
||||||
|
- `clubPlayers`(0x90) int → MY CLUB tile "N players" (TILE_ID 0x210)
|
||||||
|
- `auctionCount`(0x33) int → TRANSFER MARKET tile "N LIVE TRANSFERS" (TILE_ID 0x1b0)
|
||||||
|
- `tradePile`(0x333) **nested object**, sub-deser `0x18013ead0` → TRANSFER LIST
|
||||||
|
tile "N ITEMS / Selling / Sold". Sub-atoms: `count`(0xbc), `notification`(0x1da),
|
||||||
|
`selling`(0x2b8), `sold`(0x2c9) — all scalar int via `0x1801c79d0` (5 int reads,
|
||||||
|
one SKIP, object field loop; no array/nested object → no type-desync surface).
|
||||||
|
Same atom scheme as `FutGetAuctionCount`. **All active listings are `selling`;
|
||||||
|
`count == selling == len(listings)`, `sold == 0`.**
|
||||||
|
- `watchlist`(0x381) nested object, sub-deser `0x18013f3b0` → WATCH LIST tile (not
|
||||||
|
yet populated; empty watch list defaults to 0, which is correct today).
|
||||||
|
- **LIVE SYMPTOM this fixed (2026-08-06):** a card was actively listed
|
||||||
|
(`auctionCount` 1, Listed Items screen showed it) yet the TRANSFER LIST tile read
|
||||||
|
"0 items / Selling 0". The tile reads `hub.tradePile`, which we were omitting; it
|
||||||
|
does **not** re-poll `/tradePile/counts` (the standalone GetAuctionCount endpoint)
|
||||||
|
once at the hub. Serving `hub.tradePile:{count,selling,sold}` corrected the tile.
|
||||||
|
- **Handled:** `utas_server.hub_data()` serves `clubPlayers`, `auctionCount`, and
|
||||||
|
`tradePile:{count,selling,sold}` (`FUT_HUBDATA=1`, default on). Remaining atoms
|
||||||
|
(seasons/draft/tournament/objectives/leaderboard summaries) default to 0/absent,
|
||||||
|
which is correct while those modes are unpopulated.
|
||||||
|
|
||||||
### FutUserDataServerResponse — CONFIDENCE: MEDIUM
|
### FutUserDataServerResponse — CONFIDENCE: MEDIUM
|
||||||
- **Deser:** `0x18016dd50` (lea r8 @ `0x18016d98d`)
|
- **Deser:** `0x18016dd50` (lea r8 @ `0x18016d98d`)
|
||||||
|
|||||||
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
+144
-33
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches
|
"""Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches
|
||||||
the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches."""
|
the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches."""
|
||||||
import glob, time, struct
|
import glob, time, struct, sys
|
||||||
|
|
||||||
# Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches
|
# Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches
|
||||||
import glob, time, os
|
import glob, time, os
|
||||||
@@ -24,7 +24,46 @@ STORE_PATCHES = {
|
|||||||
0x1800175aa: NOP2,
|
0x1800175aa: NOP2,
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG="/tmp/autopatch.log"
|
# Store resolver crash-guard for the empty "My Packs" case (bug 6c; PROVEN R1 on the
|
||||||
|
# tested FIFA 17 build -- see docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV and
|
||||||
|
# docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md).
|
||||||
|
#
|
||||||
|
# When no `mypacks` group exists, FIFA's Store resolver receives category id -1. CardsDLL
|
||||||
|
# FUN_1800147f0 @ 0x180014858 is `JNZ 0x14869` (75 0f): the original treats every non-zero
|
||||||
|
# category (including -1) as resolvable, calls FUN_180014420, gets NULL, and crashes at the
|
||||||
|
# [NULL+0x48] deref in FUN_1800147f0 (0x180014882). Changing JNZ->JG (7f 0f) preserves
|
||||||
|
# positive-category resolution (EDI>0 branch) while routing zero/negative categories through
|
||||||
|
# the existing Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.
|
||||||
|
#
|
||||||
|
# CAVEAT: this guards the category SIGN only. It does NOT protect a stale *positive* invalid
|
||||||
|
# ordinal produced by changing the Store group topology (sentinel-present <-> sentinel-absent)
|
||||||
|
# DURING one running FIFA process -- that reproduced the same crash in the confounded run F3.
|
||||||
|
# The empty-My-Packs representation MUST stay stable for a FIFA session (see the SESSION-STABLE
|
||||||
|
# invariant in the client-fix plan).
|
||||||
|
#
|
||||||
|
# Orig-verified / fail-closed: applied only when the live bytes are the known original (75 0f);
|
||||||
|
# already-patched (7f 0f) is a no-op; anything else is logged and SKIPPED (never blindly
|
||||||
|
# overwritten), so an unrecognised CardsDLL build is not patched.
|
||||||
|
STORE_PATCHES_GUARDED = {
|
||||||
|
0x180014858: (bytes.fromhex("750f"), bytes.fromhex("7f0f")), # JNZ 0x14869 -> JG 0x14869
|
||||||
|
}
|
||||||
|
|
||||||
|
# Capability advertised to the launcher/backend once the resolver guard is VERIFIED
|
||||||
|
# live in a specific FIFA process (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md #3/#4).
|
||||||
|
EMPTY_MYPACKS_RESOLVER_CAPABILITY = "fifa17.empty_mypacks_resolver"
|
||||||
|
EMPTY_MYPACKS_RESOLVER_VERSION = 1
|
||||||
|
|
||||||
|
# The guarded site whose verified enforcement backs the capability above.
|
||||||
|
RESOLVER_GUARD_VA = 0x180014858
|
||||||
|
|
||||||
|
# Per-FIFA-pid guard status (fail-closed; FIFA17_PATCHED_CLIENT_CAPABILITY.md #4).
|
||||||
|
GUARD_NOT_ATTEMPTED = "NOT_ATTEMPTED" # CardsDLL not mapped / guard not yet evaluated
|
||||||
|
GUARD_VERIFIED = "VERIFIED" # live bytes == patch after enforcement (patch or noop)
|
||||||
|
GUARD_UNSUPPORTED_BUILD = "UNSUPPORTED_BUILD" # neither original nor patched (guarded_action -> skip)
|
||||||
|
GUARD_WRITE_FAILED = "WRITE_FAILED" # /proc/<pid>/mem write raised
|
||||||
|
GUARD_VERIFY_FAILED = "VERIFY_FAILED" # post-write re-read != patch
|
||||||
|
|
||||||
|
LOG=os.environ.get("OPENFUT_AUTOPATCH_LOG", f"/tmp/openfut-autopatch-{os.getuid()}.log")
|
||||||
|
|
||||||
def log(m):
|
def log(m):
|
||||||
line=f"[{time.strftime('%H:%M:%S')}] {m}"
|
line=f"[{time.strftime('%H:%M:%S')}] {m}"
|
||||||
@@ -52,40 +91,112 @@ def wr(pid,va,b):
|
|||||||
with open(f'/proc/{pid}/mem','r+b') as f:
|
with open(f'/proc/{pid}/mem','r+b') as f:
|
||||||
f.seek(va); f.write(b)
|
f.seek(va); f.write(b)
|
||||||
|
|
||||||
|
def guarded_action(cur, orig, patch):
|
||||||
|
"""Fail-closed decision for a guarded byte patch (see STORE_PATCHES_GUARDED).
|
||||||
|
|
||||||
|
Returns "noop" when the live bytes are already patched, "patch" when they are the
|
||||||
|
known original (safe to apply), or "skip" for anything else -- an unrecognised
|
||||||
|
CardsDLL build that must never be blindly overwritten.
|
||||||
|
"""
|
||||||
|
if cur == patch:
|
||||||
|
return "noop"
|
||||||
|
if cur == orig:
|
||||||
|
return "patch"
|
||||||
|
return "skip"
|
||||||
|
|
||||||
|
def guard_state_after(cur_before, orig, patch, wrote_ok, cur_after):
|
||||||
|
"""Map a guarded-patch enforcement outcome to a per-pid guard STATE (pure).
|
||||||
|
|
||||||
|
Mirrors guarded_action's decision, extended with post-write verification so the
|
||||||
|
caller advertises the capability only on VERIFIED. No /proc access -- unit-testable.
|
||||||
|
|
||||||
|
- cur_before == patch -> VERIFIED (already patched; guarded_action "noop")
|
||||||
|
- cur_before == orig -> WRITE_FAILED if the write raised, else VERIFIED when the
|
||||||
|
re-read is patch, else VERIFY_FAILED (guarded_action "patch")
|
||||||
|
- otherwise -> UNSUPPORTED_BUILD (guarded_action "skip")
|
||||||
|
"""
|
||||||
|
if cur_before == patch:
|
||||||
|
return GUARD_VERIFIED
|
||||||
|
if cur_before == orig:
|
||||||
|
if not wrote_ok:
|
||||||
|
return GUARD_WRITE_FAILED
|
||||||
|
if cur_after == patch:
|
||||||
|
return GUARD_VERIFIED
|
||||||
|
return GUARD_VERIFY_FAILED
|
||||||
|
return GUARD_UNSUPPORTED_BUILD
|
||||||
|
|
||||||
patched=set()
|
patched=set()
|
||||||
store_patched=set()
|
store_patched=set()
|
||||||
|
guard_reported=set()
|
||||||
|
|
||||||
log("=== AUTOPATCH watching for FIFA17.exe ===")
|
if __name__ == "__main__":
|
||||||
while True:
|
launcher_pid = None
|
||||||
for pid in find_pids():
|
if "--launcher-pid" in sys.argv:
|
||||||
if pid not in patched:
|
try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1])
|
||||||
try:
|
except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid")
|
||||||
g2=rd(pid,GATE2,3); g1=rd(pid,GATE1,6)
|
|
||||||
except Exception:
|
log("=== AUTOPATCH watching for FIFA17.exe ===")
|
||||||
continue # code not mapped yet
|
while True:
|
||||||
if g2==GATE2_PATCH and g1==GATE1_PATCH:
|
if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"):
|
||||||
log(f"pid {pid}: cert gates already patched"); patched.add(pid)
|
log(f"launcher pid {launcher_pid} exited; stopping autopatch")
|
||||||
elif g2==GATE2_ORIG and g1==GATE1_ORIG:
|
break
|
||||||
|
for pid in find_pids():
|
||||||
|
if pid not in patched:
|
||||||
try:
|
try:
|
||||||
wr(pid,GATE2,GATE2_PATCH); wr(pid,GATE1,GATE1_PATCH)
|
g2=rd(pid,GATE2,3); g1=rd(pid,GATE1,6)
|
||||||
log(f"pid {pid}: PATCHED cert gates")
|
except Exception:
|
||||||
patched.add(pid)
|
continue # code not mapped yet
|
||||||
|
if g2==GATE2_PATCH and g1==GATE1_PATCH:
|
||||||
|
log(f"pid {pid}: cert gates already patched"); patched.add(pid)
|
||||||
|
elif g2==GATE2_ORIG and g1==GATE1_ORIG:
|
||||||
|
try:
|
||||||
|
wr(pid,GATE2,GATE2_PATCH); wr(pid,GATE1,GATE1_PATCH)
|
||||||
|
log(f"pid {pid}: PATCHED cert gates")
|
||||||
|
patched.add(pid)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"pid {pid}: cert patch write failed: {e}")
|
||||||
|
|
||||||
|
# Continuously enforce store patches every tick
|
||||||
|
cbase = cardsdll_base(pid)
|
||||||
|
if cbase is not None:
|
||||||
|
try:
|
||||||
|
for va, data in STORE_PATCHES.items():
|
||||||
|
live = cbase + (va - IMG_BASE)
|
||||||
|
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)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"pid {pid}: cert patch write failed: {e}")
|
log(f"pid {pid}: store patch write failed: {e}")
|
||||||
|
|
||||||
# Continuously enforce store patches every tick
|
time.sleep(1)
|
||||||
cbase = cardsdll_base(pid)
|
|
||||||
if cbase is not None:
|
|
||||||
try:
|
|
||||||
for va, data in STORE_PATCHES.items():
|
|
||||||
live = cbase + (va - IMG_BASE)
|
|
||||||
if rd(pid, live, len(data)) != data:
|
|
||||||
wr(pid, live, data)
|
|
||||||
log(f"pid {pid}: ENFORCED store patch @ {live:#x}")
|
|
||||||
if pid not in store_patched:
|
|
||||||
log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}")
|
|
||||||
store_patched.add(pid)
|
|
||||||
except Exception as e:
|
|
||||||
log(f"pid {pid}: store patch write failed: {e}")
|
|
||||||
|
|
||||||
time.sleep(1)
|
|
||||||
|
|||||||
@@ -128,14 +128,52 @@ CLIENT_ID = ACCOUNT.CLIENT_ID
|
|||||||
PLATFORM = ACCOUNT.PLATFORM
|
PLATFORM = ACCOUNT.PLATFORM
|
||||||
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
|
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
|
||||||
|
|
||||||
# ================================================================== config
|
|
||||||
|
|
||||||
HOST = "127.0.0.1"
|
def refresh_account_identity():
|
||||||
|
"""Refresh launcher-selected identity before constructing a Blaze session.
|
||||||
|
|
||||||
|
The account sync endpoint runs in the separate UTAS process and atomically
|
||||||
|
replaces the shared active-account file. Blaze snapshots these aliases for
|
||||||
|
its response builders, so refresh them once at each new TCP session.
|
||||||
|
"""
|
||||||
|
global PERSONA_ID, PERSONA_NAME, USER_ID, EXT_ID, EMAIL, ACCOUNT_LOCALE_FALLBACK
|
||||||
|
ACCOUNT.load(force=True)
|
||||||
|
PERSONA_ID = ACCOUNT.persona_id
|
||||||
|
PERSONA_NAME = ACCOUNT.persona_name
|
||||||
|
USER_ID = ACCOUNT.user_id
|
||||||
|
EXT_ID = ACCOUNT.ext_id
|
||||||
|
EMAIL = ACCOUNT.email
|
||||||
|
ACCOUNT_LOCALE_FALLBACK = ACCOUNT.account_locale_int
|
||||||
|
|
||||||
|
# ================================================================== config
|
||||||
|
#
|
||||||
|
# Client/server split support (OpenFUT dev-container): two env vars, both
|
||||||
|
# defaulting to loopback so the original all-on-localhost flow is byte-identical.
|
||||||
|
# OPENFUT_BIND — the address the listeners bind (0.0.0.0 in a container).
|
||||||
|
# OPENFUT_ADVERTISE — the address this server hands back to the client for the
|
||||||
|
# NEXT hop (Blaze host, roster/UTAS/telemetry/QoS URLs). On
|
||||||
|
# 105-local this is 127.0.0.1; on the 120 server it is the
|
||||||
|
# server's LAN IP so the game dials 120 directly after the
|
||||||
|
# first (hook/DNAT-redirected) contact.
|
||||||
|
import os as _os_cfg
|
||||||
|
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
|
||||||
|
_BIND = _os_cfg.environ.get("OPENFUT_BIND", "127.0.0.1")
|
||||||
|
|
||||||
|
def _ip_str_to_u32(ip):
|
||||||
|
"""Dotted-quad -> big-endian u32 (matches the original (127<<24)|1 layout).
|
||||||
|
Falls back to loopback if the advertise value isn't a bare IPv4 literal."""
|
||||||
|
try:
|
||||||
|
a, b, c, d = (int(x) for x in ip.split("."))
|
||||||
|
return (a << 24) | (b << 16) | (c << 8) | d
|
||||||
|
except Exception:
|
||||||
|
return (127 << 24) | 1
|
||||||
|
|
||||||
|
HOST = _BIND
|
||||||
REDIR_PORT = 42127
|
REDIR_PORT = 42127
|
||||||
BLAZE_PORT = 42130
|
BLAZE_PORT = 42130
|
||||||
NUCLEUS_PORT = 42131
|
NUCLEUS_PORT = 42131
|
||||||
BLAZE_IP_STR = "127.0.0.1"
|
BLAZE_IP_STR = _ADVERTISE
|
||||||
BLAZE_IP_U32 = (127 << 24) | 1
|
BLAZE_IP_U32 = _ip_str_to_u32(_ADVERTISE)
|
||||||
LOG = "/tmp/blaze_responder.log"
|
LOG = "/tmp/blaze_responder.log"
|
||||||
RXDIR = "/tmp/blaze_rx"
|
RXDIR = "/tmp/blaze_rx"
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -157,7 +195,9 @@ REPLY_EMPTY_TO_UNKNOWN = True
|
|||||||
# (grid-blaze order) or after (pamplona order). Both are reported to work.
|
# (grid-blaze order) or after (pamplona order). Both are reported to work.
|
||||||
NOTIFY_BEFORE_LOGIN_REPLY = False
|
NOTIFY_BEFORE_LOGIN_REPLY = False
|
||||||
|
|
||||||
DUMP_FRAMES = True
|
# Raw Fire2 frames and decoded TDF can contain auth/session material. Keep the
|
||||||
|
# reverse-engineering capture path, but require an explicit opt-in for it.
|
||||||
|
DUMP_FRAMES = os.environ.get("OPENFUT_BLAZE_DUMP_FRAMES") == "1"
|
||||||
|
|
||||||
_log_lock = threading.Lock()
|
_log_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -525,7 +565,8 @@ OSDK_TICKER = []
|
|||||||
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
|
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
|
||||||
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
|
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
|
||||||
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
|
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
|
||||||
ROSTER_HOST = "127.0.0.1:8081"
|
ROSTER_HOST = "%s:8081" % _ADVERTISE
|
||||||
|
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
|
||||||
OSDK_ROSTER = [
|
OSDK_ROSTER = [
|
||||||
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
|
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
|
||||||
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0
|
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0
|
||||||
@@ -562,7 +603,6 @@ IDENTITY_PARAMS = [
|
|||||||
# FUT_POW=1 ./openfut-fut.sh restart
|
# FUT_POW=1 ./openfut-fut.sh restart
|
||||||
# and read /tmp/pow_server.log. FUT_POW=off is the instant fallback.
|
# and read /tmp/pow_server.log. FUT_POW=off is the instant fallback.
|
||||||
POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094")
|
POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094")
|
||||||
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
|
|
||||||
_POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes")
|
_POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes")
|
||||||
OSDK_POW = [
|
OSDK_POW = [
|
||||||
("FIFA_POW_URL", "http://%s/" % POW_HOST),
|
("FIFA_POW_URL", "http://%s/" % POW_HOST),
|
||||||
@@ -571,6 +611,14 @@ OSDK_POW = [
|
|||||||
("POW_IS_ON", "1"),
|
("POW_IS_ON", "1"),
|
||||||
] if _POW_ON else []
|
] if _POW_ON else []
|
||||||
|
|
||||||
|
# CardsDLL's shared web-file downloader also reads this key for FUT-owned content.
|
||||||
|
# In particular, opening SBC downloads /fut/packs/loc/storepackdescriptions.<locale>.xml
|
||||||
|
# after /sbs/sets succeeds. Keep the content base available even while the unrelated
|
||||||
|
# POW API remains opt-in through FUT_POW/POW_IS_ON.
|
||||||
|
FUT_CONTENT_CONFIG = [
|
||||||
|
("FIFA_POW_CONTENT_SERVER_URL", "http://%s" % POW_CONTENT_HOST),
|
||||||
|
]
|
||||||
|
|
||||||
CLIENT_CONFIGS = {
|
CLIENT_CONFIGS = {
|
||||||
"BlazeSDK": None, # built dynamically, see below
|
"BlazeSDK": None, # built dynamically, see below
|
||||||
"netres": OSDK_NETRES, # CFID (verified @0x143962be0)
|
"netres": OSDK_NETRES, # CFID (verified @0x143962be0)
|
||||||
@@ -595,7 +643,7 @@ CLIENT_CONFIGS = {
|
|||||||
# /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/"
|
# /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/"
|
||||||
# (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT
|
# (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT
|
||||||
# (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code).
|
# (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code).
|
||||||
UTAS_BASE = "http://127.0.0.1:8099/"
|
UTAS_BASE = "http://%s:8099/" % _ADVERTISE
|
||||||
FUT_RS4_MODULES = [
|
FUT_RS4_MODULES = [
|
||||||
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
|
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
|
||||||
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
|
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
|
||||||
@@ -669,6 +717,55 @@ FUT_RS4_CONFIG = (
|
|||||||
"IS_FIFAPOINT_PURCHASABLE", "IS_EASTORE_SERVICE_READY",
|
"IS_FIFAPOINT_PURCHASABLE", "IS_EASTORE_SERVICE_READY",
|
||||||
"COINS_PURCHASE_ENABLED", "POINTS_PURCHASE_ENABLED", "MONEY_PURCHASE_ENABLED",
|
"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
|
# NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any
|
||||||
# response -- proven inert (wf_96b6c0c5): they are JSON field names that route
|
# response -- proven inert (wf_96b6c0c5): they are JSON field names that route
|
||||||
# to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md.
|
# to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md.
|
||||||
@@ -682,18 +779,21 @@ FUT_RS4_CONFIG = (
|
|||||||
|
|
||||||
|
|
||||||
def client_config_for(cfid: str) -> list:
|
def client_config_for(cfid: str) -> list:
|
||||||
"""-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we
|
"""Return sorted config rows for one section.
|
||||||
still wrap in a present CONF field -- never an empty frame).
|
|
||||||
FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section
|
Unknown CFIDs still receive the shared FUT/content/POW rows because those
|
||||||
CardsDLL reads is unproven, so serve them everywhere)."""
|
consumers read the merged ``_all`` store and the contributing section is
|
||||||
|
unproven. The response always carries a present CONF field.
|
||||||
|
"""
|
||||||
# OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's
|
# OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's
|
||||||
# FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which
|
# FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which
|
||||||
# section it happens to read is unproven. Empty list when FUT_POW is unset, so
|
# section it happens to read is unproven. Empty list when FUT_POW is unset, so
|
||||||
# this is a no-op by default. (Putting the keys ONLY under a hypothetical
|
# this is a no-op by default. (Putting the keys ONLY under a hypothetical
|
||||||
# "OSDK_POW" CFID would be dead code -- nothing is known to request that name.)
|
# "OSDK_POW" CFID would be dead code -- nothing is known to request that name.)
|
||||||
if cfid == "BlazeSDK":
|
if cfid == "BlazeSDK":
|
||||||
return sorted(blazesdk_config() + FUT_RS4_CONFIG + OSDK_POW)
|
return sorted(blazesdk_config() + FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
|
||||||
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG + OSDK_POW)
|
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG
|
||||||
|
+ FUT_CONTENT_CONFIG + OSDK_POW)
|
||||||
|
|
||||||
|
|
||||||
def fetch_config_response_fields(cfid: str) -> "OrderedDict":
|
def fetch_config_response_fields(cfid: str) -> "OrderedDict":
|
||||||
@@ -716,7 +816,7 @@ def qos_config() -> "OrderedDict":
|
|||||||
has NO SVID, unlike Mirror's Edge Catalyst)."""
|
has NO SVID, unlike Mirror's Edge Catalyst)."""
|
||||||
return OrderedDict([
|
return OrderedDict([
|
||||||
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
|
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
|
||||||
("PSA", (STRING, "127.0.0.1")),
|
("PSA", (STRING, _ADVERTISE)),
|
||||||
("PSP", (INT, 17502)),
|
("PSP", (INT, 17502)),
|
||||||
]))),
|
]))),
|
||||||
("LNP", (INT, 10)),
|
("LNP", (INT, 10)),
|
||||||
@@ -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
|
client to have a well-formed config and then fail to connect quietly rather
|
||||||
than resolve a real EA hostname."""
|
than resolve a real EA hostname."""
|
||||||
tele = OrderedDict([ # GetTelemetryServerResponse (15)
|
tele = OrderedDict([ # GetTelemetryServerResponse (15)
|
||||||
("ADRS", (STRING, "127.0.0.1")),
|
("ADRS", (STRING, _ADVERTISE)),
|
||||||
("ANON", (INT, 0)),
|
("ANON", (INT, 0)),
|
||||||
("DISA", (STRING, "")),
|
("DISA", (STRING, "")),
|
||||||
("EDCT", (INT, 0)),
|
("EDCT", (INT, 0)),
|
||||||
@@ -1059,7 +1159,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
|
|||||||
("SVNM", (STRING, "telemetry-openfut")),
|
("SVNM", (STRING, "telemetry-openfut")),
|
||||||
])
|
])
|
||||||
tick = OrderedDict([ # GetTickerServerResponse (3)
|
tick = OrderedDict([ # GetTickerServerResponse (3)
|
||||||
("ADRS", (STRING, "127.0.0.1")),
|
("ADRS", (STRING, _ADVERTISE)),
|
||||||
("PORT", (INT, 8999)),
|
("PORT", (INT, 8999)),
|
||||||
("SKEY", (STRING, "")),
|
("SKEY", (STRING, "")),
|
||||||
])
|
])
|
||||||
@@ -1203,8 +1303,10 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
|
|||||||
log(" -- client locale 0x%08x captured for ALOC" % loc)
|
log(" -- client locale 0x%08x captured for ALOC" % loc)
|
||||||
resp = preauth_response_fields(service_name=sess.service_name)
|
resp = preauth_response_fields(service_name=sess.service_name)
|
||||||
payload = encode_tdf(resp)
|
payload = encode_tdf(resp)
|
||||||
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s"
|
log(" -> PreAuthResponse (INST=%r, %d payload bytes)"
|
||||||
% (sess.service_name, len(payload), heat2.dump(resp)))
|
% (sess.service_name, len(payload)))
|
||||||
|
if DUMP_FRAMES:
|
||||||
|
log(" -> PreAuthResponse TDF:\n%s" % heat2.dump(resp))
|
||||||
return [reply_to(hdr, payload)]
|
return [reply_to(hdr, payload)]
|
||||||
|
|
||||||
if cmd == CMD_PING:
|
if cmd == CMD_PING:
|
||||||
@@ -1218,8 +1320,9 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
|
|||||||
n = len(resp["CONF"][1][2])
|
n = len(resp["CONF"][1][2])
|
||||||
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
|
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
|
||||||
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
|
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
|
||||||
for k, v in resp["CONF"][1][2]:
|
if DUMP_FRAMES:
|
||||||
log(" %-32s = %s" % (k, v))
|
for k, v in resp["CONF"][1][2]:
|
||||||
|
log(" %-32s = %s" % (k, v))
|
||||||
return [reply_to(hdr, encode_tdf(resp))]
|
return [reply_to(hdr, encode_tdf(resp))]
|
||||||
|
|
||||||
if cmd == CMD_POSTAUTH:
|
if cmd == CMD_POSTAUTH:
|
||||||
@@ -1253,12 +1356,13 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
|
|||||||
sess.auth_code = get_str(fields or {}, "AUTH", "")
|
sess.auth_code = get_str(fields or {}, "AUTH", "")
|
||||||
sess.logged_in = True
|
sess.logged_in = True
|
||||||
sess.login_time = int(time.time())
|
sess.login_time = int(time.time())
|
||||||
log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus "
|
log(" == Authentication::login AUTH=[REDACTED] "
|
||||||
"validation -- forged offline session)" % sess.auth_code)
|
"(accepted as an offline OpenFUT session)")
|
||||||
resp = login_response_fields(sess)
|
resp = login_response_fields(sess)
|
||||||
payload = encode_tdf(resp)
|
payload = encode_tdf(resp)
|
||||||
log(" -> LoginResponse (%d bytes):\n%s"
|
log(" -> LoginResponse (%d bytes)" % len(payload))
|
||||||
% (len(payload), heat2.dump(resp)))
|
if DUMP_FRAMES:
|
||||||
|
log(" -> LoginResponse TDF:\n%s" % heat2.dump(resp))
|
||||||
notifs = build_login_notifications(sess, sess.login_time)
|
notifs = build_login_notifications(sess, sess.login_time)
|
||||||
out = []
|
out = []
|
||||||
if NOTIFY_BEFORE_LOGIN_REPLY:
|
if NOTIFY_BEFORE_LOGIN_REPLY:
|
||||||
@@ -1409,9 +1513,10 @@ _frame_counter = [0]
|
|||||||
|
|
||||||
|
|
||||||
def blaze_handle(raw: socket.socket, addr) -> None:
|
def blaze_handle(raw: socket.socket, addr) -> None:
|
||||||
|
refresh_account_identity()
|
||||||
log("*** BLAZE CONNECT from %s ***" % (addr,))
|
log("*** BLAZE CONNECT from %s ***" % (addr,))
|
||||||
sess = Session()
|
sess = Session()
|
||||||
log(" session key minted: %s" % sess.session_key)
|
log(" session key minted: [REDACTED]")
|
||||||
buf = bytearray()
|
buf = bytearray()
|
||||||
raw.settimeout(300)
|
raw.settimeout(300)
|
||||||
try:
|
try:
|
||||||
@@ -1442,10 +1547,10 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
|||||||
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
|
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
|
||||||
hdr["msg_num"], hdr["user_index"], hdr["options"],
|
hdr["msg_num"], hdr["user_index"], hdr["options"],
|
||||||
hdr["metadata_len"], hdr["payload_len"]))
|
hdr["metadata_len"], hdr["payload_len"]))
|
||||||
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
|
|
||||||
if metadata:
|
|
||||||
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
|
|
||||||
if DUMP_FRAMES:
|
if DUMP_FRAMES:
|
||||||
|
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
|
||||||
|
if metadata:
|
||||||
|
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
|
||||||
try:
|
try:
|
||||||
os.makedirs(RXDIR, exist_ok=True)
|
os.makedirs(RXDIR, exist_ok=True)
|
||||||
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
|
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
|
||||||
@@ -1460,7 +1565,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
|||||||
if payload:
|
if payload:
|
||||||
try:
|
try:
|
||||||
fields = decode_tdf(payload)
|
fields = decode_tdf(payload)
|
||||||
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
|
if DUMP_FRAMES:
|
||||||
|
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log("RX #%d TDF DECODE FAILED: %s" % (n, e))
|
log("RX #%d TDF DECODE FAILED: %s" % (n, e))
|
||||||
else:
|
else:
|
||||||
@@ -1481,7 +1587,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
|||||||
ohdr["msg_type"]),
|
ohdr["msg_type"]),
|
||||||
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
|
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
|
||||||
ohdr["msg_num"], len(out), ohdr["payload_len"]))
|
ohdr["msg_num"], len(out), ohdr["payload_len"]))
|
||||||
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
|
if DUMP_FRAMES:
|
||||||
|
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
|
||||||
except ConnectionResetError:
|
except ConnectionResetError:
|
||||||
log("BLAZE %s: connection reset by client" % (addr,))
|
log("BLAZE %s: connection reset by client" % (addr,))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -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:
|
# client can never reach accounts.ea.com. Note the exact spacing in the JSON:
|
||||||
# the client searches for the literal '"access_token" : "'.
|
# the client searches for the literal '"access_token" : "'.
|
||||||
|
|
||||||
|
def nucleus_sent_log(addr, size):
|
||||||
|
return "NUCLEUS SENT %s %dB access_token=[REDACTED]" % (addr, size)
|
||||||
|
|
||||||
|
|
||||||
def nucleus_handle(raw: socket.socket, addr) -> None:
|
def nucleus_handle(raw: socket.socket, addr) -> None:
|
||||||
try:
|
try:
|
||||||
raw.settimeout(10)
|
raw.settimeout(10)
|
||||||
@@ -1591,9 +1702,9 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
|
|||||||
head, _, rest = req.partition(b"\r\n\r\n")
|
head, _, rest = req.partition(b"\r\n\r\n")
|
||||||
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
|
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
|
||||||
log("NUCLEUS REQ %s: %s" % (addr, line0))
|
log("NUCLEUS REQ %s: %s" % (addr, line0))
|
||||||
if head:
|
if head and DUMP_FRAMES:
|
||||||
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
|
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
|
||||||
if rest:
|
if rest and DUMP_FRAMES:
|
||||||
log("NUCLEUS BODY: %r" % rest[:512])
|
log("NUCLEUS BODY: %r" % rest[:512])
|
||||||
|
|
||||||
token = "OPENFUT_" + "".join(
|
token = "OPENFUT_" + "".join(
|
||||||
@@ -1607,7 +1718,7 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
|
|||||||
b"Cache-Control: no-store\r\nContent-Length: "
|
b"Cache-Control: no-store\r\nContent-Length: "
|
||||||
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
||||||
raw.sendall(out)
|
raw.sendall(out)
|
||||||
log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token))
|
log(nucleus_sent_log(addr, len(out)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log("NUCLEUS ERR %s: %s" % (addr, e))
|
log("NUCLEUS ERR %s: %s" % (addr, e))
|
||||||
finally:
|
finally:
|
||||||
@@ -1659,6 +1770,10 @@ def _selftest() -> None:
|
|||||||
sess.account_locale = 0x656E5553
|
sess.account_locale = 0x656E5553
|
||||||
now = 1469000000
|
now = 1469000000
|
||||||
|
|
||||||
|
nucleus_summary = nucleus_sent_log(("127.0.0.1", 1234), 380)
|
||||||
|
assert "[REDACTED]" in nucleus_summary
|
||||||
|
assert "OPENFUT_selftest_secret" not in nucleus_summary
|
||||||
|
|
||||||
# ---- 1. preAuth still round-trips (regression guard vs v2)
|
# ---- 1. preAuth still round-trips (regression guard vs v2)
|
||||||
pre = preauth_response_fields()
|
pre = preauth_response_fields()
|
||||||
p = _check_roundtrip("PreAuthResponse", pre)
|
p = _check_roundtrip("PreAuthResponse", pre)
|
||||||
@@ -1682,9 +1797,11 @@ def _selftest() -> None:
|
|||||||
assert items == client_config_for(cfid), cfid
|
assert items == client_config_for(cfid), cfid
|
||||||
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
|
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
|
||||||
% (cfid, len(items), len(pb)))
|
% (cfid, len(items), len(pb)))
|
||||||
assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []"
|
shared = sorted(FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
|
||||||
|
assert client_config_for("TOTALLY_UNKNOWN") == shared, \
|
||||||
|
"unknown CFID must carry only the shared merged-store rows"
|
||||||
assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \
|
assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \
|
||||||
"unknown CFID must still carry a CONF field (empty map, not empty frame)"
|
"unknown CFID must still carry a CONF field"
|
||||||
|
|
||||||
# ---- 3. LoginResponse
|
# ---- 3. LoginResponse
|
||||||
lr = login_response_fields(sess)
|
lr = login_response_fields(sess)
|
||||||
|
|||||||
@@ -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):
|
def __init__(self, path=None):
|
||||||
self.path = path or ACCOUNT_PATH
|
self.path = path or ACCOUNT_PATH
|
||||||
self._loaded = False
|
self._loaded = False
|
||||||
|
self._file_signature = None
|
||||||
self._stored = {} # what is on disk (tier 2+3 only)
|
self._stored = {} # what is on disk (tier 2+3 only)
|
||||||
for f in _FIELDS:
|
for f in _FIELDS:
|
||||||
setattr(self, "_" + f, None)
|
setattr(self, "_" + f, None)
|
||||||
@@ -214,7 +215,8 @@ class Account:
|
|||||||
save the first time. Never raises on a malformed file -- a broken
|
save the first time. Never raises on a malformed file -- a broken
|
||||||
account file must not stop the harness booting."""
|
account file must not stop the harness booting."""
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
if self._loaded and not force:
|
signature = self._signature()
|
||||||
|
if self._loaded and not force and signature == self._file_signature:
|
||||||
return self
|
return self
|
||||||
stored = {}
|
stored = {}
|
||||||
if os.path.exists(self.path):
|
if os.path.exists(self.path):
|
||||||
@@ -239,8 +241,22 @@ class Account:
|
|||||||
% (self.path, e))
|
% (self.path, e))
|
||||||
self._stored = stored
|
self._stored = stored
|
||||||
self._loaded = True
|
self._loaded = True
|
||||||
|
self._file_signature = self._signature()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def _signature(self):
|
||||||
|
"""Identity of the active-account file across atomic replacements.
|
||||||
|
|
||||||
|
The launcher can select an account while Blaze/POW are already running
|
||||||
|
in separate processes. inode + mtime + size lets every process notice
|
||||||
|
the replacement on its next property read without restarting Docker.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
st = os.stat(self.path)
|
||||||
|
return st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
def _migrate_from_profile(self):
|
def _migrate_from_profile(self):
|
||||||
"""Lift identity/club out of a pre-existing fifa17_profile.json so an
|
"""Lift identity/club out of a pre-existing fifa17_profile.json so an
|
||||||
existing club name survives the move to this module. Read-only: the game
|
existing club name survives the move to this module. Read-only: the game
|
||||||
@@ -266,11 +282,32 @@ class Account:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
def _write(self):
|
def _write(self):
|
||||||
|
parent = os.path.dirname(self.path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
tmp = self.path + ".tmp"
|
tmp = self.path + ".tmp"
|
||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
json.dump(self._stored, f, indent=1, sort_keys=True)
|
json.dump(self._stored, f, indent=1, sort_keys=True)
|
||||||
f.write("\n")
|
f.write("\n")
|
||||||
os.replace(tmp, self.path)
|
os.replace(tmp, self.path)
|
||||||
|
self._file_signature = self._signature()
|
||||||
|
|
||||||
|
def replace(self, values):
|
||||||
|
"""Atomically replace the active identity with validated persisted values."""
|
||||||
|
with _LOCK:
|
||||||
|
clean = {k: v for k, v in values.items() if k in _FIELDS and v is not None}
|
||||||
|
if "persona_id" not in clean or "persona_name" not in clean:
|
||||||
|
raise ValueError("persona_id and persona_name are required")
|
||||||
|
clean["persona_id"] = int(clean["persona_id"])
|
||||||
|
clean["persona_name"] = str(clean["persona_name"]).strip()
|
||||||
|
if clean["persona_id"] <= 0 or not clean["persona_name"]:
|
||||||
|
raise ValueError("persona_id must be positive and persona_name must not be empty")
|
||||||
|
self._stored = clean
|
||||||
|
for field in _FIELDS:
|
||||||
|
setattr(self, "_" + field, None)
|
||||||
|
self._loaded = True
|
||||||
|
self._write()
|
||||||
|
return self
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""Persist tiers 2+3 (only fields that differ from the built-in default,
|
"""Persist tiers 2+3 (only fields that differ from the built-in default,
|
||||||
|
|||||||
@@ -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
|
CLUBITEM_ID_BASE = 960000000 # distinct from save 1e8, sweep 9e8, consumables 9.4e8
|
||||||
|
|
||||||
# (table, art id, stat id, stat name, UNVERIFIED cardsubtypeid)
|
# (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 = [
|
FAMILIES = [
|
||||||
("balls", "fcc_balls.json", 37, 0x1E, "balls", 149),
|
("balls", "fcc_balls.json", 37, 0x1E, "balls", 30),
|
||||||
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 148),
|
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 10),
|
||||||
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 145),
|
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 11),
|
||||||
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 146),
|
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 9),
|
||||||
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 150),
|
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 31),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Every cardsubtypeid known to reach cardtype 9. Used by probe_shelf().
|
# Candidate set for probe_shelf(). The old set {30,31,145..150} could NOT have answered
|
||||||
CARDTYPE9_SUBTYPES = (30, 31, 145, 146, 147, 148, 149, 150)
|
# 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
|
# 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.
|
# make the counter non-zero so the client asks, not to hand anyone a collection.
|
||||||
@@ -71,22 +85,35 @@ def _rows(fname):
|
|||||||
return []
|
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,
|
"""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
|
no nation, no league. A club item has none of those, and sending a field the
|
||||||
field the family does not have is how a wrong shape gets accepted and does
|
family does not have is how a wrong shape gets accepted and does nothing."""
|
||||||
nothing."""
|
|
||||||
it = {
|
it = {
|
||||||
"id": item_id,
|
"id": item_id,
|
||||||
"resourceId": carddbid,
|
"resourceId": carddbid,
|
||||||
"assetId": carddbid,
|
"assetId": carddbid,
|
||||||
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
|
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
|
||||||
"cardsubtypeid": subtype,
|
"cardsubtypeid": subtype,
|
||||||
"itemType": "club", # UNOBSERVED on the wire; see module docstring
|
|
||||||
"itemState": "free",
|
"itemState": "free",
|
||||||
"owners": 1,
|
"owners": 1,
|
||||||
"untradeable": False,
|
"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:
|
if extra:
|
||||||
it.update(extra)
|
it.update(extra)
|
||||||
return it
|
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"
|
# 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
|
# and then dies. Omission is safe; an unestablished field is not. None of
|
||||||
# the three was needed to draw a card.
|
# 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
|
nid += 1
|
||||||
out[name] = picked
|
out[name] = picked
|
||||||
return out
|
return out
|
||||||
|
|||||||
+373
-17
@@ -17,7 +17,125 @@ sys.path.insert(0, HERE)
|
|||||||
import fut_cards
|
import fut_cards
|
||||||
from fut_account import ACCOUNT # single source of truth for identity/club
|
from fut_account import ACCOUNT # single source of truth for identity/club
|
||||||
|
|
||||||
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
|
PROFILE_ROOT = os.environ.get("FUT_PROFILE_ROOT", "")
|
||||||
|
|
||||||
|
|
||||||
|
def profile_path_for(persona_id):
|
||||||
|
explicit = os.environ.get("FUT_PROFILE")
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
if PROFILE_ROOT:
|
||||||
|
return os.path.join(PROFILE_ROOT, str(int(persona_id)), "fifa17_profile.json")
|
||||||
|
return os.path.join(HERE, "fifa17_profile.json")
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE_PATH = profile_path_for(ACCOUNT.persona_id)
|
||||||
|
|
||||||
|
# ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------
|
||||||
|
#
|
||||||
|
# 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
|
# 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
|
# 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
|
_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,
|
def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00,
|
||||||
cardsubtypeid=0, rareflag=1):
|
cardsubtypeid=0, rareflag=1):
|
||||||
return {
|
return _with_discard({
|
||||||
"id": item_id,
|
"id": item_id,
|
||||||
"resourceId": (version << 24) | asset,
|
"resourceId": (version << 24) | asset,
|
||||||
"assetId": 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)],
|
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
|
||||||
"itemState": "free",
|
"itemState": "free",
|
||||||
"owners": 1,
|
"owners": 1,
|
||||||
"untradeable": True,
|
"untradeable": not TRADEABLE,
|
||||||
"contract": 7,
|
"contract": 7,
|
||||||
"fitness": 99,
|
"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():
|
def _new_profile():
|
||||||
@@ -107,6 +355,10 @@ def _new_profile():
|
|||||||
"purchased": [], # unassigned/pending items from opened packs
|
"purchased": [], # unassigned/pending items from opened packs
|
||||||
"squads": [], # saved squads (raw squad objects from PUT /squad)
|
"squads": [], # saved squads (raw squad objects from PUT /squad)
|
||||||
"packsOpened": 0,
|
"packsOpened": 0,
|
||||||
|
# Owned reward packs are separate from purchased items. Pack 70 is a
|
||||||
|
# one-time migration grant used to bring the retail My Packs flow online.
|
||||||
|
"unopenedPackIds": [70],
|
||||||
|
"unopenedSeeded": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -125,6 +377,10 @@ class Store:
|
|||||||
self._p = _new_profile()
|
self._p = _new_profile()
|
||||||
self._sync_identity()
|
self._sync_identity()
|
||||||
self._save()
|
self._save()
|
||||||
|
if not self._p.get("unopenedSeeded"):
|
||||||
|
self._p.setdefault("unopenedPackIds", []).append(70)
|
||||||
|
self._p["unopenedSeeded"] = True
|
||||||
|
self._save()
|
||||||
self._sync_identity()
|
self._sync_identity()
|
||||||
return self._p
|
return self._p
|
||||||
|
|
||||||
@@ -142,18 +398,51 @@ class Store:
|
|||||||
p["clubName"] = ACCOUNT.club_name
|
p["clubName"] = ACCOUNT.club_name
|
||||||
p["clubAbbr"] = ACCOUNT.club_abbr
|
p["clubAbbr"] = ACCOUNT.club_abbr
|
||||||
p["established"] = ACCOUNT.established
|
p["established"] = ACCOUNT.established
|
||||||
|
# EA/EASFC account-bar state belongs to the same persona as the FUT
|
||||||
|
# save, but remains a distinct balance from FUT coins.
|
||||||
|
p["powLevel"] = ACCOUNT.pow_level
|
||||||
|
p["powExp"] = ACCOUNT.pow_exp
|
||||||
|
p["powExpMax"] = ACCOUNT.pow_exp_max
|
||||||
|
p["powFunds"] = ACCOUNT.pow_funds
|
||||||
|
p["powFundsCap"] = ACCOUNT.pow_funds_cap
|
||||||
return p
|
return p
|
||||||
|
|
||||||
def _save(self):
|
def _save(self):
|
||||||
|
parent = os.path.dirname(self.path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
tmp = self.path + ".tmp"
|
tmp = self.path + ".tmp"
|
||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
json.dump(self._p, f, indent=1)
|
json.dump(self._p, f, indent=1)
|
||||||
os.replace(tmp, self.path)
|
os.replace(tmp, self.path)
|
||||||
|
|
||||||
|
def select_account(self, persona_id):
|
||||||
|
"""Switch the single active session to its isolated persistent FUT save."""
|
||||||
|
with _LOCK:
|
||||||
|
self.path = profile_path_for(persona_id)
|
||||||
|
self._p = None
|
||||||
|
return self.load()
|
||||||
|
|
||||||
# ---- accessors used by utas_server -------------------------------------
|
# ---- accessors used by utas_server -------------------------------------
|
||||||
def profile(self):
|
def profile(self):
|
||||||
return self.load()
|
return self.load()
|
||||||
|
|
||||||
|
def ensure_security_question(self):
|
||||||
|
"""Persist OpenFUT's account-scoped compatibility state for the FUT gate.
|
||||||
|
|
||||||
|
FIFA 17 transforms any entered answer before sending it. OpenFUT does not
|
||||||
|
need that value to emulate a retired service, so neither the clear text nor
|
||||||
|
the transformed value is stored. The only durable fact is that this
|
||||||
|
OpenFUT profile has an initialized, verified compatibility record.
|
||||||
|
"""
|
||||||
|
expected = {"version": 1, "verified": True}
|
||||||
|
with _LOCK:
|
||||||
|
p = self.load()
|
||||||
|
if p.get("securityQuestion") != expected:
|
||||||
|
p["securityQuestion"] = dict(expected)
|
||||||
|
self._save()
|
||||||
|
return dict(p["securityQuestion"])
|
||||||
|
|
||||||
def refresh_identity(self):
|
def refresh_identity(self):
|
||||||
"""Re-mirror ACCOUNT into the save AND persist it.
|
"""Re-mirror ACCOUNT into the save AND persist it.
|
||||||
|
|
||||||
@@ -182,7 +471,13 @@ class Store:
|
|||||||
return self.load()["coins"]
|
return self.load()["coins"]
|
||||||
|
|
||||||
def items(self):
|
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):
|
def add_items(self, new_items):
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
@@ -215,6 +510,15 @@ class Store:
|
|||||||
dv = it.get("discardValue") or 0
|
dv = it.get("discardValue") or 0
|
||||||
if dv:
|
if dv:
|
||||||
return int(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
|
r = it.get("rating") or 0
|
||||||
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
|
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
@@ -299,14 +603,45 @@ class Store:
|
|||||||
return moved
|
return moved
|
||||||
|
|
||||||
def purchased(self):
|
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
|
"""Items still held in the purchased/unassigned pile (returned by
|
||||||
GET /purchased/items); they move to the club via FutMoveCard (PUT /item)."""
|
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):
|
def active_squad(self):
|
||||||
sq = self.load()["squads"]
|
sq = self.load()["squads"]
|
||||||
return sq[0] if sq else None
|
return sq[0] if sq else None
|
||||||
|
|
||||||
|
def unopened_packs(self):
|
||||||
|
"""Owned reward-pack template IDs, including repeated grants."""
|
||||||
|
return list(self.load().get("unopenedPackIds", []))
|
||||||
|
|
||||||
|
def consume_unopened_pack(self, pack_id):
|
||||||
|
"""Atomically consume one owned instance of a reward pack."""
|
||||||
|
with _LOCK:
|
||||||
|
p = self.load()
|
||||||
|
owned = p.setdefault("unopenedPackIds", [])
|
||||||
|
try:
|
||||||
|
owned.remove(pack_id)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
self._save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def grant_unopened_pack(self, pack_id):
|
||||||
|
"""Persist one additional owned reward-pack instance."""
|
||||||
|
if pack_by_id(pack_id) is None:
|
||||||
|
return False
|
||||||
|
with _LOCK:
|
||||||
|
p = self.load()
|
||||||
|
p.setdefault("unopenedPackIds", []).append(pack_id)
|
||||||
|
self._save()
|
||||||
|
return True
|
||||||
|
|
||||||
def reconstruct_squad(self, squad):
|
def reconstruct_squad(self, squad):
|
||||||
"""FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>}
|
"""FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>}
|
||||||
(a reference). Re-embed the FULL club item by id so the squad reloads with
|
(a reference). Re-embed the FULL club item by id so the squad reloads with
|
||||||
@@ -351,7 +686,8 @@ class Store:
|
|||||||
return i
|
return i
|
||||||
|
|
||||||
|
|
||||||
def open_pack(self, price, count, gold=True, tiers=None):
|
def open_pack(self, price, count, gold=True, tiers=None, special_chance=0.0,
|
||||||
|
players_only=False):
|
||||||
"""Deduct `price` coins, generate `count` player items from the pool, and
|
"""Deduct `price` coins, generate `count` player items from the pool, and
|
||||||
place them in the PENDING purchased pile (unassigned). They are NOT owned
|
place them in the PENDING purchased pile (unassigned). They are NOT owned
|
||||||
club items until moved there via FutMoveCard (PUT /item). Returns None if
|
club items until moved there via FutMoveCard (PUT /item). Returns None if
|
||||||
@@ -373,19 +709,31 @@ class Store:
|
|||||||
# fixed number so it scales from a 5-card bronze to an 11-card premium.
|
# fixed number so it scales from a 5-card bronze to an 11-card premium.
|
||||||
n_extra = 0
|
n_extra = 0
|
||||||
extras = []
|
extras = []
|
||||||
if PACK_MIX and count >= 5:
|
if PACK_MIX and not players_only and count >= 5:
|
||||||
n_extra = max(1, count // 4)
|
n_extra = max(1, count // 4)
|
||||||
extras = _pack_extras(n_extra, self)
|
extras = _pack_extras(n_extra, self)
|
||||||
n_extra = len(extras)
|
n_extra = len(extras)
|
||||||
n_players = max(1, count - n_extra)
|
n_players = max(1, count - n_extra)
|
||||||
if tiers:
|
if tiers:
|
||||||
picks = [random.choice(fut_cards.pool_for(random.choice(tiers)))
|
# Draw each tier independently but reject duplicate asset IDs inside
|
||||||
for _ in range(n_players)]
|
# one pack. The real pool is large enough that this normally succeeds
|
||||||
|
# on the first attempt; the cap makes malformed tiny test pools safe.
|
||||||
|
picks = []
|
||||||
|
used_assets = set()
|
||||||
|
for _ in range(n_players):
|
||||||
|
tier_pool = fut_cards.pool_for(random.choice(tiers))
|
||||||
|
available = [p for p in tier_pool if p[0] not in used_assets]
|
||||||
|
pick = random.choice(available or tier_pool)
|
||||||
|
picks.append(pick)
|
||||||
|
used_assets.add(pick[0])
|
||||||
else:
|
else:
|
||||||
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
|
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
|
||||||
picks = [random.choice(pool) for _ in range(n_players)]
|
picks = random.sample(pool, min(n_players, len(pool)))
|
||||||
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at)
|
while len(picks) < n_players:
|
||||||
for (a, r, p, n, lg, tm, at) in picks]
|
picks.append(random.choice(pool))
|
||||||
|
items = [player_item(self.new_item_id(), pick,
|
||||||
|
special=random.random() < special_chance)
|
||||||
|
for pick in picks]
|
||||||
items += extras
|
items += extras
|
||||||
random.shuffle(items)
|
random.shuffle(items)
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
@@ -396,7 +744,9 @@ class Store:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
def last_pack(self):
|
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.
|
# no silver or bronze players at all, so all three packs were identical in practice.
|
||||||
PACK_CATALOG = [
|
PACK_CATALOG = [
|
||||||
{"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False,
|
{"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False,
|
||||||
"tiers": ["bronze"] * 8 + ["silver"] * 2},
|
"tiers": ["bronze"] * 8 + ["silver"] * 2, "specialChance": 0.005},
|
||||||
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True,
|
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True,
|
||||||
"tiers": ["gold"] * 6 + ["silver"] * 4},
|
"tiers": ["gold"] * 6 + ["silver"] * 4, "specialChance": 0.03},
|
||||||
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True,
|
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True,
|
||||||
"tiers": ["gold"] * 9 + ["silver"] * 1},
|
"tiers": ["gold"] * 9 + ["silver"] * 1, "specialChance": 0.08},
|
||||||
|
{"id": 7, "name": "Special Players Pack", "price": 25000, "count": 11,
|
||||||
|
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
|
||||||
|
"playersOnly": True},
|
||||||
|
{"id": 70, "name": "Reward Special Players Pack", "price": 0, "count": 11,
|
||||||
|
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
|
||||||
|
"playersOnly": True, "ownedOnly": True},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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